diff --git a/browser/safari/extract_extension.go b/browser/safari/extract_extension.go new file mode 100644 index 0000000..fd043f2 --- /dev/null +++ b/browser/safari/extract_extension.go @@ -0,0 +1,129 @@ +package safari + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/moond4rk/plist" + + "github.com/moond4rk/hackbrowserdata/types" +) + +// Safari keeps extensions in two sibling plists under the container's Safari dir: +// +// Safari/AppExtensions/Extensions.plist — legacy App Extensions (XPC-based) +// Safari/WebExtensions/Extensions.plist — modern Safari Web Extensions +// +// Both files share the same top-level shape: a dictionary keyed by +// " ()". Only WebExtensions carry an `Enabled` field; +// an App Extension that appears in the plist is implicitly enabled. +const ( + safariExtensionsSubdir = "Safari" + safariAppExtensionsSubdir = "AppExtensions" + safariWebExtensionsSubdir = "WebExtensions" + safariExtensionsPlistFile = "Extensions.plist" +) + +// extensionKeyPattern matches the " ()" key format Safari uses. +var extensionKeyPattern = regexp.MustCompile(`^(\S+)\s+\(([^)]+)\)$`) + +// safariExtension mirrors the per-extension dict value in Extensions.plist. +// Only fields that map onto types.ExtensionEntry are decoded; richer fields +// (Permissions, AccessibleOrigins, …) are intentionally ignored for the +// minimum implementation. +type safariExtension struct { + Enabled *bool `plist:"Enabled"` +} + +// extractExtensions reads both AppExtensions/Extensions.plist and +// WebExtensions/Extensions.plist from the profile's Safari container and +// returns the merged list, sorted by key for deterministic output. +// A missing plist on either side is skipped silently. +func extractExtensions(container string) ([]types.ExtensionEntry, error) { + records, err := readSafariExtensions(container) + if err != nil { + return nil, err + } + + extensions := make([]types.ExtensionEntry, 0, len(records)) + for _, r := range records { + extensions = append(extensions, types.ExtensionEntry{ + Name: r.bundleID, + ID: r.key, + Enabled: r.enabled, + }) + } + return extensions, nil +} + +func countExtensions(container string) (int, error) { + records, err := readSafariExtensions(container) + if err != nil { + return 0, err + } + return len(records), nil +} + +type extensionRecord struct { + key string + bundleID string + enabled bool +} + +func readSafariExtensions(container string) ([]extensionRecord, error) { + safariDir := filepath.Join(container, safariExtensionsSubdir) + var all []extensionRecord + for _, sub := range []string{safariAppExtensionsSubdir, safariWebExtensionsSubdir} { + p := filepath.Join(safariDir, sub, safariExtensionsPlistFile) + records, err := decodeSafariExtensionsPlist(p) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + all = append(all, records...) + } + sort.Slice(all, func(i, j int) bool { return all[i].key < all[j].key }) + return all, nil +} + +func decodeSafariExtensionsPlist(path string) ([]extensionRecord, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var decoded map[string]safariExtension + if err := plist.NewDecoder(f).Decode(&decoded); err != nil { + return nil, fmt.Errorf("decode extensions %s: %w", path, err) + } + + records := make([]extensionRecord, 0, len(decoded)) + for key, ext := range decoded { + enabled := true + if ext.Enabled != nil { + enabled = *ext.Enabled + } + records = append(records, extensionRecord{ + key: key, + bundleID: bundleIDFromExtensionKey(key), + enabled: enabled, + }) + } + return records, nil +} + +// bundleIDFromExtensionKey extracts the bundle ID from a " ()" +// key; falls back to the trimmed full key when the format doesn't match. +func bundleIDFromExtensionKey(key string) string { + if m := extensionKeyPattern.FindStringSubmatch(key); m != nil { + return m[1] + } + return strings.TrimSpace(key) +} diff --git a/browser/safari/extract_extension_test.go b/browser/safari/extract_extension_test.go new file mode 100644 index 0000000..9b635b0 --- /dev/null +++ b/browser/safari/extract_extension_test.go @@ -0,0 +1,129 @@ +package safari + +import ( + "os" + "path/filepath" + "testing" + + "github.com/moond4rk/plist" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testExtensionEntry mirrors the shape of one entry in Safari's Extensions.plist: +// an untyped dictionary keyed by string. Using a map (instead of safariExtension) +// lets tests omit keys like Enabled for AppExtension-style fixtures, matching +// what Safari actually writes. +type testExtensionEntry map[string]any + +// writeTestExtensionsPlist writes an Extensions.plist under +// /Safari//Extensions.plist. subdir is either +// "AppExtensions" or "WebExtensions". +func writeTestExtensionsPlist(t *testing.T, container, subdir string, entries map[string]testExtensionEntry) { + t.Helper() + dir := filepath.Join(container, safariExtensionsSubdir, subdir) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + f, err := os.Create(filepath.Join(dir, safariExtensionsPlistFile)) + require.NoError(t, err) + defer f.Close() + require.NoError(t, plist.NewBinaryEncoder(f).Encode(entries)) +} + +func TestExtractExtensions_AppAndWebMerged(t *testing.T) { + container := t.TempDir() + writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{ + "com.colliderli.iina.OpenInIINA (67CQ77V27R)": {}, + }) + writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{ + "com.1password.safari.extension (2BUA8C4S2C)": {"Enabled": true}, + }) + + got, err := extractExtensions(container) + require.NoError(t, err) + require.Len(t, got, 2) + + // Results are sorted by key, so 1Password (com.1…) comes before iina (com.c…). + assert.Equal(t, "com.1password.safari.extension", got[0].Name) + assert.Equal(t, "com.1password.safari.extension (2BUA8C4S2C)", got[0].ID) + assert.True(t, got[0].Enabled) + + assert.Equal(t, "com.colliderli.iina.OpenInIINA", got[1].Name) + assert.Equal(t, "com.colliderli.iina.OpenInIINA (67CQ77V27R)", got[1].ID) + // AppExtensions omit the Enabled field — defaults to true (present == enabled). + assert.True(t, got[1].Enabled) +} + +func TestExtractExtensions_EnabledFlag(t *testing.T) { + container := t.TempDir() + writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{ + "com.example.a (AAAAAAAAAA)": {"Enabled": true}, + "com.example.b (BBBBBBBBBB)": {"Enabled": false}, + }) + + got, err := extractExtensions(container) + require.NoError(t, err) + require.Len(t, got, 2) + assert.True(t, got[0].Enabled) + assert.False(t, got[1].Enabled) +} + +func TestExtractExtensions_BundleIDFallbackOnUnexpectedKey(t *testing.T) { + container := t.TempDir() + writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{ + "legacy-key-without-team-id": {}, + }) + + got, err := extractExtensions(container) + require.NoError(t, err) + require.Len(t, got, 1) + // Regex miss → fall back to the full trimmed key. + assert.Equal(t, "legacy-key-without-team-id", got[0].Name) + assert.Equal(t, "legacy-key-without-team-id", got[0].ID) +} + +func TestExtractExtensions_OnlyAppExt(t *testing.T) { + container := t.TempDir() + writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{ + "com.example.only (XXXXXXXXX1)": {}, + }) + + got, err := extractExtensions(container) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "com.example.only", got[0].Name) +} + +func TestExtractExtensions_OnlyWebExt(t *testing.T) { + container := t.TempDir() + writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{ + "com.example.web (XXXXXXXXX2)": {"Enabled": true}, + }) + + got, err := extractExtensions(container) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "com.example.web", got[0].Name) +} + +func TestExtractExtensions_NoPlists(t *testing.T) { + container := t.TempDir() + got, err := extractExtensions(container) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestCountExtensions(t *testing.T) { + container := t.TempDir() + writeTestExtensionsPlist(t, container, safariAppExtensionsSubdir, map[string]testExtensionEntry{ + "com.example.a (AAAAAAAAAA)": {}, + "com.example.b (BBBBBBBBBB)": {}, + }) + writeTestExtensionsPlist(t, container, safariWebExtensionsSubdir, map[string]testExtensionEntry{ + "com.example.c (CCCCCCCCCC)": {"Enabled": true}, + }) + + count, err := countExtensions(container) + require.NoError(t, err) + assert.Equal(t, 3, count) +} diff --git a/browser/safari/safari.go b/browser/safari/safari.go index 5904c13..b894102 100644 --- a/browser/safari/safari.go +++ b/browser/safari/safari.go @@ -71,6 +71,14 @@ func (b *Browser) Extract(categories []types.Category) (*types.BrowserData, erro } continue } + // Extension plists (AppExtensions + WebExtensions) live directly in the container + // and are read in-place; attribute to default only until per-profile layouts are verified. + if cat == types.Extension { + if b.profile.isDefault() { + b.extractCategory(data, cat, "") + } + continue + } path, ok := tempPaths[cat] if !ok { continue @@ -97,6 +105,12 @@ func (b *Browser) CountEntries(categories []types.Category) (map[types.Category] } continue } + if cat == types.Extension { + if b.profile.isDefault() { + counts[cat] = b.countCategory(cat, "") + } + continue + } path, ok := tempPaths[cat] if !ok { continue @@ -138,6 +152,8 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, p data.Downloads, err = extractDownloads(path, b.profile.downloadOwnerUUID()) case types.LocalStorage: data.LocalStorage, err = extractLocalStorage(path) + case types.Extension: + data.Extensions, err = extractExtensions(b.profile.container) default: return } @@ -162,6 +178,8 @@ func (b *Browser) countCategory(cat types.Category, path string) int { count, err = countDownloads(path, b.profile.downloadOwnerUUID()) case types.LocalStorage: count, err = countLocalStorage(path) + case types.Extension: + count, err = countExtensions(b.profile.container) default: // Unsupported categories silently return 0. }