Skip to content

Commit 5cad2d1

Browse files
authored
feat(safari): extract installed extensions (#583)
1 parent 7a5db25 commit 5cad2d1

3 files changed

Lines changed: 276 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package safari
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"sort"
9+
"strings"
10+
11+
"github.com/moond4rk/plist"
12+
13+
"github.com/moond4rk/hackbrowserdata/types"
14+
)
15+
16+
// Safari keeps extensions in two sibling plists under the container's Safari dir:
17+
//
18+
// Safari/AppExtensions/Extensions.plist — legacy App Extensions (XPC-based)
19+
// Safari/WebExtensions/Extensions.plist — modern Safari Web Extensions
20+
//
21+
// Both files share the same top-level shape: a dictionary keyed by
22+
// "<bundleID> (<teamID>)". Only WebExtensions carry an `Enabled` field;
23+
// an App Extension that appears in the plist is implicitly enabled.
24+
const (
25+
safariExtensionsSubdir = "Safari"
26+
safariAppExtensionsSubdir = "AppExtensions"
27+
safariWebExtensionsSubdir = "WebExtensions"
28+
safariExtensionsPlistFile = "Extensions.plist"
29+
)
30+
31+
// extensionKeyPattern matches the "<bundleID> (<teamID>)" key format Safari uses.
32+
var extensionKeyPattern = regexp.MustCompile(`^(\S+)\s+\(([^)]+)\)$`)
33+
34+
// safariExtension mirrors the per-extension dict value in Extensions.plist.
35+
// Only fields that map onto types.ExtensionEntry are decoded; richer fields
36+
// (Permissions, AccessibleOrigins, …) are intentionally ignored for the
37+
// minimum implementation.
38+
type safariExtension struct {
39+
Enabled *bool `plist:"Enabled"`
40+
}
41+
42+
// extractExtensions reads both AppExtensions/Extensions.plist and
43+
// WebExtensions/Extensions.plist from the profile's Safari container and
44+
// returns the merged list, sorted by key for deterministic output.
45+
// A missing plist on either side is skipped silently.
46+
func extractExtensions(container string) ([]types.ExtensionEntry, error) {
47+
records, err := readSafariExtensions(container)
48+
if err != nil {
49+
return nil, err
50+
}
51+
52+
extensions := make([]types.ExtensionEntry, 0, len(records))
53+
for _, r := range records {
54+
extensions = append(extensions, types.ExtensionEntry{
55+
Name: r.bundleID,
56+
ID: r.key,
57+
Enabled: r.enabled,
58+
})
59+
}
60+
return extensions, nil
61+
}
62+
63+
func countExtensions(container string) (int, error) {
64+
records, err := readSafariExtensions(container)
65+
if err != nil {
66+
return 0, err
67+
}
68+
return len(records), nil
69+
}
70+
71+
type extensionRecord struct {
72+
key string
73+
bundleID string
74+
enabled bool
75+
}
76+
77+
func readSafariExtensions(container string) ([]extensionRecord, error) {
78+
safariDir := filepath.Join(container, safariExtensionsSubdir)
79+
var all []extensionRecord
80+
for _, sub := range []string{safariAppExtensionsSubdir, safariWebExtensionsSubdir} {
81+
p := filepath.Join(safariDir, sub, safariExtensionsPlistFile)
82+
records, err := decodeSafariExtensionsPlist(p)
83+
if err != nil {
84+
if os.IsNotExist(err) {
85+
continue
86+
}
87+
return nil, err
88+
}
89+
all = append(all, records...)
90+
}
91+
sort.Slice(all, func(i, j int) bool { return all[i].key < all[j].key })
92+
return all, nil
93+
}
94+
95+
func decodeSafariExtensionsPlist(path string) ([]extensionRecord, error) {
96+
f, err := os.Open(path)
97+
if err != nil {
98+
return nil, err
99+
}
100+
defer f.Close()
101+
102+
var decoded map[string]safariExtension
103+
if err := plist.NewDecoder(f).Decode(&decoded); err != nil {
104+
return nil, fmt.Errorf("decode extensions %s: %w", path, err)
105+
}
106+
107+
records := make([]extensionRecord, 0, len(decoded))
108+
for key, ext := range decoded {
109+
enabled := true
110+
if ext.Enabled != nil {
111+
enabled = *ext.Enabled
112+
}
113+
records = append(records, extensionRecord{
114+
key: key,
115+
bundleID: bundleIDFromExtensionKey(key),
116+
enabled: enabled,
117+
})
118+
}
119+
return records, nil
120+
}
121+
122+
// bundleIDFromExtensionKey extracts the bundle ID from a "<bundleID> (<teamID>)"
123+
// key; falls back to the trimmed full key when the format doesn't match.
124+
func bundleIDFromExtensionKey(key string) string {
125+
if m := extensionKeyPattern.FindStringSubmatch(key); m != nil {
126+
return m[1]
127+
}
128+
return strings.TrimSpace(key)
129+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package safari
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/moond4rk/plist"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// testExtensionEntry mirrors the shape of one entry in Safari's Extensions.plist:
14+
// an untyped dictionary keyed by string. Using a map (instead of safariExtension)
15+
// lets tests omit keys like Enabled for AppExtension-style fixtures, matching
16+
// what Safari actually writes.
17+
type testExtensionEntry map[string]any
18+
19+
// writeTestExtensionsPlist writes an Extensions.plist under
20+
// <container>/Safari/<subdir>/Extensions.plist. subdir is either
21+
// "AppExtensions" or "WebExtensions".
22+
func writeTestExtensionsPlist(t *testing.T, container, subdir string, entries map[string]testExtensionEntry) {
23+
t.Helper()
24+
dir := filepath.Join(container, safariExtensionsSubdir, subdir)
25+
require.NoError(t, os.MkdirAll(dir, 0o755))
26+
27+
f, err := os.Create(filepath.Join(dir, safariExtensionsPlistFile))
28+
require.NoError(t, err)
29+
defer f.Close()
30+
require.NoError(t, plist.NewBinaryEncoder(f).Encode(entries))
31+
}
32+
33+
func TestExtractExtensions_AppAndWebMerged(t *testing.T) {
34+
container := t.TempDir()
35+
writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{
36+
"com.colliderli.iina.OpenInIINA (67CQ77V27R)": {},
37+
})
38+
writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{
39+
"com.1password.safari.extension (2BUA8C4S2C)": {"Enabled": true},
40+
})
41+
42+
got, err := extractExtensions(container)
43+
require.NoError(t, err)
44+
require.Len(t, got, 2)
45+
46+
// Results are sorted by key, so 1Password (com.1…) comes before iina (com.c…).
47+
assert.Equal(t, "com.1password.safari.extension", got[0].Name)
48+
assert.Equal(t, "com.1password.safari.extension (2BUA8C4S2C)", got[0].ID)
49+
assert.True(t, got[0].Enabled)
50+
51+
assert.Equal(t, "com.colliderli.iina.OpenInIINA", got[1].Name)
52+
assert.Equal(t, "com.colliderli.iina.OpenInIINA (67CQ77V27R)", got[1].ID)
53+
// AppExtensions omit the Enabled field — defaults to true (present == enabled).
54+
assert.True(t, got[1].Enabled)
55+
}
56+
57+
func TestExtractExtensions_EnabledFlag(t *testing.T) {
58+
container := t.TempDir()
59+
writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{
60+
"com.example.a (AAAAAAAAAA)": {"Enabled": true},
61+
"com.example.b (BBBBBBBBBB)": {"Enabled": false},
62+
})
63+
64+
got, err := extractExtensions(container)
65+
require.NoError(t, err)
66+
require.Len(t, got, 2)
67+
assert.True(t, got[0].Enabled)
68+
assert.False(t, got[1].Enabled)
69+
}
70+
71+
func TestExtractExtensions_BundleIDFallbackOnUnexpectedKey(t *testing.T) {
72+
container := t.TempDir()
73+
writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{
74+
"legacy-key-without-team-id": {},
75+
})
76+
77+
got, err := extractExtensions(container)
78+
require.NoError(t, err)
79+
require.Len(t, got, 1)
80+
// Regex miss → fall back to the full trimmed key.
81+
assert.Equal(t, "legacy-key-without-team-id", got[0].Name)
82+
assert.Equal(t, "legacy-key-without-team-id", got[0].ID)
83+
}
84+
85+
func TestExtractExtensions_OnlyAppExt(t *testing.T) {
86+
container := t.TempDir()
87+
writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{
88+
"com.example.only (XXXXXXXXX1)": {},
89+
})
90+
91+
got, err := extractExtensions(container)
92+
require.NoError(t, err)
93+
require.Len(t, got, 1)
94+
assert.Equal(t, "com.example.only", got[0].Name)
95+
}
96+
97+
func TestExtractExtensions_OnlyWebExt(t *testing.T) {
98+
container := t.TempDir()
99+
writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{
100+
"com.example.web (XXXXXXXXX2)": {"Enabled": true},
101+
})
102+
103+
got, err := extractExtensions(container)
104+
require.NoError(t, err)
105+
require.Len(t, got, 1)
106+
assert.Equal(t, "com.example.web", got[0].Name)
107+
}
108+
109+
func TestExtractExtensions_NoPlists(t *testing.T) {
110+
container := t.TempDir()
111+
got, err := extractExtensions(container)
112+
require.NoError(t, err)
113+
assert.Empty(t, got)
114+
}
115+
116+
func TestCountExtensions(t *testing.T) {
117+
container := t.TempDir()
118+
writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{
119+
"com.example.a (AAAAAAAAAA)": {},
120+
"com.example.b (BBBBBBBBBB)": {},
121+
})
122+
writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{
123+
"com.example.c (CCCCCCCCCC)": {"Enabled": true},
124+
})
125+
126+
count, err := countExtensions(container)
127+
require.NoError(t, err)
128+
assert.Equal(t, 3, count)
129+
}

browser/safari/safari.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro
7171
}
7272
continue
7373
}
74+
// Extension plists (AppExtensions + WebExtensions) live directly in the container
75+
// and are read in-place; attribute to default only until per-profile layouts are verified.
76+
if cat == types.Extension {
77+
if b.profile.isDefault() {
78+
b.extractCategory(data, cat, "")
79+
}
80+
continue
81+
}
7482
path, ok := tempPaths[cat]
7583
if !ok {
7684
continue
@@ -97,6 +105,12 @@ func (b *Browser) CountEntries(categories []types.Category) (map[types.Category]
97105
}
98106
continue
99107
}
108+
if cat == types.Extension {
109+
if b.profile.isDefault() {
110+
counts[cat] = b.countCategory(cat, "")
111+
}
112+
continue
113+
}
100114
path, ok := tempPaths[cat]
101115
if !ok {
102116
continue
@@ -138,6 +152,8 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p
138152
data.Downloads, err = extractDownloads(path, b.profile.downloadOwnerUUID())
139153
case types.LocalStorage:
140154
data.LocalStorage, err = extractLocalStorage(path)
155+
case types.Extension:
156+
data.Extensions, err = extractExtensions(b.profile.container)
141157
default:
142158
return
143159
}
@@ -162,6 +178,8 @@ func (b *Browser) countCategory(cat types.Category, path string) int {
162178
count, err = countDownloads(path, b.profile.downloadOwnerUUID())
163179
case types.LocalStorage:
164180
count, err = countLocalStorage(path)
181+
case types.Extension:
182+
count, err = countExtensions(b.profile.container)
165183
default:
166184
// Unsupported categories silently return 0.
167185
}

0 commit comments

Comments
 (0)