Skip to content

Commit d75738b

Browse files
authored
feat(safari): multi-profile support (#581)
* feat(safari): multi-profile support
1 parent 7b9a973 commit d75738b

8 files changed

Lines changed: 677 additions & 104 deletions

File tree

browser/safari/extract_download.go

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,23 @@ import (
99
"github.com/moond4rk/hackbrowserdata/types"
1010
)
1111

12-
// safariDownloads mirrors the plist structure of Safari's Downloads.plist.
1312
type safariDownloads struct {
1413
DownloadHistory []safariDownloadEntry `plist:"DownloadHistory"`
1514
}
1615

1716
type safariDownloadEntry struct {
18-
URL string `plist:"DownloadEntryURL"`
19-
Path string `plist:"DownloadEntryPath"`
20-
TotalBytes float64 `plist:"DownloadEntryProgressTotalToLoad"`
21-
RemoveWhenDone bool `plist:"DownloadEntryRemoveWhenDoneKey"`
22-
DownloadIdentifier string `plist:"DownloadEntryIdentifier"`
17+
URL string `plist:"DownloadEntryURL"`
18+
Path string `plist:"DownloadEntryPath"`
19+
TotalBytes int64 `plist:"DownloadEntryProgressTotalToLoad"`
20+
ProfileUUID string `plist:"DownloadEntryProfileUUIDStringKey"`
21+
RemoveWhenDone bool `plist:"DownloadEntryRemoveWhenDoneKey"`
22+
DownloadIdentifier string `plist:"DownloadEntryIdentifier"`
2323
}
2424

25-
func extractDownloads(path string) ([]types.DownloadEntry, error) {
25+
// extractDownloads reads Downloads.plist (shared across Safari profiles) and returns only the entries
26+
// owned by ownerUUID — either "DefaultProfile" or a named profile's uppercase UUID. Entries written by
27+
// older Safari (no ProfileUUID field) are attributed to the default profile.
28+
func extractDownloads(path, ownerUUID string) ([]types.DownloadEntry, error) {
2629
f, err := os.Open(path)
2730
if err != nil {
2831
return nil, fmt.Errorf("open downloads: %w", err)
@@ -36,19 +39,30 @@ func extractDownloads(path string) ([]types.DownloadEntry, error) {
3639

3740
var downloads []types.DownloadEntry
3841
for _, d := range dl.DownloadHistory {
42+
if !ownsDownload(d.ProfileUUID, ownerUUID) {
43+
continue
44+
}
3945
downloads = append(downloads, types.DownloadEntry{
4046
URL: d.URL,
4147
TargetPath: d.Path,
42-
TotalBytes: int64(d.TotalBytes),
48+
TotalBytes: d.TotalBytes,
4349
})
4450
}
4551
return downloads, nil
4652
}
4753

48-
func countDownloads(path string) (int, error) {
49-
downloads, err := extractDownloads(path)
54+
func countDownloads(path, ownerUUID string) (int, error) {
55+
downloads, err := extractDownloads(path, ownerUUID)
5056
if err != nil {
5157
return 0, err
5258
}
5359
return len(downloads), nil
5460
}
61+
62+
// ownsDownload treats empty ProfileUUID as DefaultProfile for backward compat with pre-profile Safari.
63+
func ownsDownload(entryUUID, ownerUUID string) bool {
64+
if entryUUID == "" {
65+
entryUUID = defaultProfileSentinel
66+
}
67+
return entryUUID == ownerUUID
68+
}

browser/safari/extract_download_test.go

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,46 +20,54 @@ func buildTestDownloadsPlist(t *testing.T, dl safariDownloads) string {
2020
return path
2121
}
2222

23-
func TestExtractDownloads(t *testing.T) {
23+
func TestExtractDownloads_DefaultProfileOnly(t *testing.T) {
24+
// Mixed-owner plist: only entries tagged with DefaultProfile (or untagged, for
25+
// pre-profile Safari) should surface for the default profile.
26+
const namedUUID = "5604E6F5-02ED-4E40-8249-63DE7BC986C8"
2427
dl := safariDownloads{
2528
DownloadHistory: []safariDownloadEntry{
26-
{
27-
URL: "https://example.com/file.zip",
28-
Path: "/Users/test/Downloads/file.zip",
29-
TotalBytes: 1024000,
30-
},
31-
{
32-
URL: "https://go.dev/dl/go1.20.tar.gz",
33-
Path: "/Users/test/Downloads/go1.20.tar.gz",
34-
TotalBytes: 98765432,
35-
},
29+
{URL: "https://a.com/a.zip", Path: "/tmp/a.zip", TotalBytes: 1024000, ProfileUUID: defaultProfileSentinel},
30+
{URL: "https://b.com/b.zip", Path: "/tmp/b.zip", TotalBytes: 98765432, ProfileUUID: namedUUID},
31+
{URL: "https://c.com/legacy.zip", Path: "/tmp/legacy.zip", TotalBytes: 500, ProfileUUID: ""}, // pre-profile Safari
3632
},
3733
}
3834

3935
path := buildTestDownloadsPlist(t, dl)
40-
downloads, err := extractDownloads(path)
36+
downloads, err := extractDownloads(path, defaultProfileSentinel)
4137
require.NoError(t, err)
4238
require.Len(t, downloads, 2)
39+
assert.Equal(t, "https://a.com/a.zip", downloads[0].URL)
40+
assert.Equal(t, "https://c.com/legacy.zip", downloads[1].URL)
41+
}
4342

44-
assert.Equal(t, "https://example.com/file.zip", downloads[0].URL)
45-
assert.Equal(t, "/Users/test/Downloads/file.zip", downloads[0].TargetPath)
46-
assert.Equal(t, int64(1024000), downloads[0].TotalBytes)
43+
func TestExtractDownloads_NamedProfileOnly(t *testing.T) {
44+
const namedUUID = "5604E6F5-02ED-4E40-8249-63DE7BC986C8"
45+
dl := safariDownloads{
46+
DownloadHistory: []safariDownloadEntry{
47+
{URL: "https://a.com/a.zip", Path: "/tmp/a.zip", TotalBytes: 100, ProfileUUID: defaultProfileSentinel},
48+
{URL: "https://b.com/b.zip", Path: "/tmp/b.zip", TotalBytes: 200, ProfileUUID: namedUUID},
49+
},
50+
}
4751

48-
assert.Equal(t, "https://go.dev/dl/go1.20.tar.gz", downloads[1].URL)
49-
assert.Equal(t, int64(98765432), downloads[1].TotalBytes)
52+
path := buildTestDownloadsPlist(t, dl)
53+
downloads, err := extractDownloads(path, namedUUID)
54+
require.NoError(t, err)
55+
require.Len(t, downloads, 1)
56+
assert.Equal(t, "https://b.com/b.zip", downloads[0].URL)
57+
assert.Equal(t, int64(200), downloads[0].TotalBytes)
5058
}
5159

5260
func TestCountDownloads(t *testing.T) {
5361
dl := safariDownloads{
5462
DownloadHistory: []safariDownloadEntry{
55-
{URL: "https://a.com/1.zip", Path: "/tmp/1.zip", TotalBytes: 100},
56-
{URL: "https://b.com/2.zip", Path: "/tmp/2.zip", TotalBytes: 200},
57-
{URL: "https://c.com/3.zip", Path: "/tmp/3.zip", TotalBytes: 300},
63+
{URL: "https://a.com/1.zip", Path: "/tmp/1.zip", TotalBytes: 100, ProfileUUID: defaultProfileSentinel},
64+
{URL: "https://b.com/2.zip", Path: "/tmp/2.zip", TotalBytes: 200, ProfileUUID: defaultProfileSentinel},
65+
{URL: "https://c.com/3.zip", Path: "/tmp/3.zip", TotalBytes: 300, ProfileUUID: defaultProfileSentinel},
5866
},
5967
}
6068

6169
path := buildTestDownloadsPlist(t, dl)
62-
count, err := countDownloads(path)
70+
count, err := countDownloads(path, defaultProfileSentinel)
6371
require.NoError(t, err)
6472
assert.Equal(t, 3, count)
6573
}
@@ -68,7 +76,7 @@ func TestExtractDownloads_Empty(t *testing.T) {
6876
dl := safariDownloads{}
6977
path := buildTestDownloadsPlist(t, dl)
7078

71-
downloads, err := extractDownloads(path)
79+
downloads, err := extractDownloads(path, defaultProfileSentinel)
7280
require.NoError(t, err)
7381
assert.Empty(t, downloads)
7482
}

browser/safari/profiles.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package safari
2+
3+
import (
4+
"database/sql"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strings"
10+
11+
_ "modernc.org/sqlite"
12+
13+
"github.com/moond4rk/hackbrowserdata/log"
14+
)
15+
16+
// profileContext tracks the uppercase (Safari/Profiles/<UUID>) and lowercase
17+
// (WebKit/WebsiteDataStore/<uuid>) UUID forms a named profile needs. Both empty ⇒ default profile.
18+
type profileContext struct {
19+
name string
20+
uuidUpper string
21+
uuidLower string
22+
legacyHome string // ~/Library/Safari
23+
container string // ~/Library/Containers/com.apple.Safari/Data/Library
24+
}
25+
26+
func (p profileContext) isDefault() bool { return p.uuidUpper == "" }
27+
28+
// downloadOwnerUUID is the value Safari writes into DownloadEntryProfileUUIDStringKey
29+
// for downloads that belong to this profile. The default profile uses the sentinel
30+
// "DefaultProfile"; named profiles use their uppercase UUID.
31+
func (p profileContext) downloadOwnerUUID() string {
32+
if p.isDefault() {
33+
return defaultProfileSentinel
34+
}
35+
return p.uuidUpper
36+
}
37+
38+
// SafariTabs.db lists profiles in bookmarks rows with subtype=2. external_uuid "DefaultProfile"
39+
// is the sentinel for the implicit default, which has no per-UUID directory.
40+
const (
41+
safariTabsDBRelPath = "Safari/SafariTabs.db"
42+
safariProfileSubtype = 2
43+
defaultProfileSentinel = "DefaultProfile"
44+
)
45+
46+
// Path-unsafe bytes for filenames/CSV values; Unicode letters (CJK etc.) survive.
47+
var unsafeNameChars = regexp.MustCompile(`[/\\:*?"<>|\x00-\x1f]+`)
48+
49+
// Canonical 8-4-4-4-12 hex UUID — format check only, no semantic parse.
50+
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$`)
51+
52+
// discoverSafariProfiles always lists the default first, then named profiles from SafariTabs.db
53+
// (authoritative) with a ReadDir fallback only if the DB itself is unreadable.
54+
func discoverSafariProfiles(legacyHome string) []profileContext {
55+
container := deriveContainerRoot(legacyHome)
56+
57+
profiles := []profileContext{{
58+
name: "default",
59+
legacyHome: legacyHome,
60+
container: container,
61+
}}
62+
63+
named, err := readNamedProfilesFromDB(container)
64+
if err != nil {
65+
// Empty DB (nil, nil) is authoritative; fall back only when DB itself is unreadable.
66+
named = readNamedProfilesFromDir(container)
67+
}
68+
for _, p := range named {
69+
p.legacyHome = legacyHome
70+
p.container = container
71+
profiles = append(profiles, p)
72+
}
73+
74+
disambiguateNames(profiles)
75+
return profiles
76+
}
77+
78+
func deriveContainerRoot(legacyHome string) string {
79+
return filepath.Join(filepath.Dir(legacyHome), "Containers", "com.apple.Safari", "Data", "Library")
80+
}
81+
82+
// readNamedProfilesFromDB returns (nil, err) when the DB is missing/unreadable so the caller can
83+
// try the ReadDir fallback; (slice, nil) — possibly empty — is authoritative.
84+
func readNamedProfilesFromDB(container string) ([]profileContext, error) {
85+
// Read-only + immutable so we don't disturb Safari's live WAL.
86+
dsn := "file:" + filepath.Join(container, safariTabsDBRelPath) + "?mode=ro&immutable=1"
87+
db, err := sql.Open("sqlite", dsn)
88+
if err != nil {
89+
return nil, fmt.Errorf("open SafariTabs.db: %w", err)
90+
}
91+
defer db.Close()
92+
93+
// Ping forces connection; sql.Open is lazy and won't detect a missing file.
94+
if err := db.Ping(); err != nil {
95+
return nil, fmt.Errorf("ping SafariTabs.db: %w", err)
96+
}
97+
98+
rows, err := db.Query(
99+
`SELECT external_uuid, title FROM bookmarks WHERE subtype = ? AND external_uuid != ?`,
100+
safariProfileSubtype, defaultProfileSentinel,
101+
)
102+
if err != nil {
103+
return nil, fmt.Errorf("query SafariTabs.db: %w", err)
104+
}
105+
defer rows.Close()
106+
107+
var out []profileContext
108+
for rows.Next() {
109+
var externalUUID, title sql.NullString
110+
if err := rows.Scan(&externalUUID, &title); err != nil {
111+
log.Debugf("safari profiles: scan row: %v", err)
112+
continue
113+
}
114+
if !isCanonicalUUID(externalUUID.String) {
115+
continue
116+
}
117+
out = append(out, newNamedProfile(externalUUID.String, title.String))
118+
}
119+
if err := rows.Err(); err != nil {
120+
return nil, fmt.Errorf("iterate SafariTabs.db rows: %w", err)
121+
}
122+
return out, nil
123+
}
124+
125+
// readNamedProfilesFromDir is the fallback for missing SafariTabs.db. Names are synthesized from UUIDs.
126+
func readNamedProfilesFromDir(container string) []profileContext {
127+
entries, err := os.ReadDir(filepath.Join(container, "Safari", "Profiles"))
128+
if err != nil {
129+
return nil
130+
}
131+
132+
var out []profileContext
133+
for _, e := range entries {
134+
if !e.IsDir() || !isCanonicalUUID(e.Name()) {
135+
continue
136+
}
137+
out = append(out, newNamedProfile(e.Name(), ""))
138+
}
139+
return out
140+
}
141+
142+
func newNamedProfile(upperUUID, title string) profileContext {
143+
return profileContext{
144+
name: resolveProfileName(title, upperUUID),
145+
uuidUpper: upperUUID,
146+
uuidLower: strings.ToLower(upperUUID),
147+
}
148+
}
149+
150+
func isCanonicalUUID(s string) bool { return uuidPattern.MatchString(s) }
151+
152+
// resolveProfileName prefers the SafariTabs.db title, falling back to "profile-<uuid[:8]>".
153+
func resolveProfileName(title, upperUUID string) string {
154+
if name := sanitizeProfileName(title); name != "" {
155+
return name
156+
}
157+
return "profile-" + strings.ToLower(upperUUID[:8])
158+
}
159+
160+
func sanitizeProfileName(name string) string {
161+
name = strings.TrimSpace(name)
162+
if name == "" {
163+
return ""
164+
}
165+
return unsafeNameChars.ReplaceAllString(name, "_")
166+
}
167+
168+
// disambiguateNames appends "-2", "-3", … to duplicate names, in place.
169+
func disambiguateNames(profiles []profileContext) {
170+
occurrences := make(map[string]int, len(profiles))
171+
for i := range profiles {
172+
original := profiles[i].name
173+
if prior := occurrences[original]; prior > 0 {
174+
profiles[i].name = fmt.Sprintf("%s-%d", original, prior+1)
175+
}
176+
occurrences[original]++
177+
}
178+
}

0 commit comments

Comments
 (0)