mirror of
https://github.com/moonD4rk/HackBrowserData.git
synced 2026-05-19 18:58:03 +02:00
feat(safari): extract installed extensions (#583)
This commit is contained in:
@@ -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
|
||||
// "<bundleID> (<teamID>)". 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 "<bundleID> (<teamID>)" 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 "<bundleID> (<teamID>)"
|
||||
// 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)
|
||||
}
|
||||
@@ -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
|
||||
// <container>/Safari/<subdir>/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)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user