Skip to content

feat(safari): multi-profile support#581

Merged
moonD4rk merged 2 commits intomainfrom
feat/safari-multi-profile
Apr 21, 2026
Merged

feat(safari): multi-profile support#581
moonD4rk merged 2 commits intomainfrom
feat/safari-multi-profile

Conversation

@moonD4rk
Copy link
Copy Markdown
Owner

Summary

  • Enumerate macOS 14+ Safari profiles from SafariTabs.db (authoritative) with a Safari/Profiles/ ReadDir fallback; always includes the default profile.
  • History / Cookie are per-profile — default reads from ~/Library/Safari/ + ~/Library/Containers/.../Cookies/, named profiles read from Safari/Profiles/<UUID>/ + WebKit/WebsiteDataStore/<uuid>/Cookies/.
  • Download is a shared plist filtered per-entry via DownloadEntryProfileUUIDStringKey (empty = pre-profile Safari → default).
  • Bookmark has no per-entry profile tag, so it stays attributed to the default profile only (avoids duplicating the same entries per profile).
  • Password (macOS Keychain) is user-scope; also attributed to default only.

Addresses the multi-profile item of #565; PR5 (LocalStorage) is the next step and has a hook point left in buildSources.

Test plan

  • go test ./browser/safari/... -count=1 — new profile-discovery tests (default-only, named+default, title fallback, orphan UUID, missing-DB ReadDir fallback, duplicate names, UUID case, empty-DB authoritative) and per-profile download filtering.
  • golangci-lint run ./browser/safari/ — 0 issues.
  • GOOS=windows GOARCH=amd64 go build / GOOS=linux GOARCH=amd64 go build — cross-compile clean.
  • End-to-end on macOS 14+ with 2 real profiles (default + profile 2):
    • history.json: 8815 / 12 split across profiles
    • cookie.json: 66 / 46 split across profiles
    • download.json: 2 / 2 correctly attributed by DownloadEntryProfileUUIDStringKey
    • password.json: 17 (default only, no duplication)
    • bookmark.json: 28 (default only, no duplication)

Copilot AI review requested due to automatic review settings April 20, 2026 16:27
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 20, 2026

Codecov Report

❌ Patch coverage is 87.94326% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.55%. Comparing base (7b9a973) to head (5d5ec1c).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
browser/safari/profiles.go 85.36% 7 Missing and 5 partials ⚠️
browser/safari/safari.go 82.14% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #581      +/-   ##
==========================================
+ Coverage   72.79%   73.55%   +0.76%     
==========================================
  Files          56       57       +1     
  Lines        2286     2401     +115     
==========================================
+ Hits         1664     1766     +102     
- Misses        473      481       +8     
- Partials      149      154       +5     
Flag Coverage Δ
unittests 73.55% <87.94%> (+0.76%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Safari multi-profile support on macOS 14+ by introducing profile discovery and per-profile source path resolution, while keeping shared data (Keychain passwords, bookmarks) attributed to the default profile and filtering shared downloads per entry.

Changes:

  • Discover named Safari profiles via SafariTabs.db (authoritative) with a Safari/Profiles/ ReadDir fallback when the DB is unreadable.
  • Resolve per-profile source paths for History/Cookies and filter shared Downloads.plist entries by DownloadEntryProfileUUIDStringKey.
  • Add extensive tests for profile discovery, source building, and per-profile download filtering.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
browser/safari/testutil_test.go Adds a helper to create SafariTabs.db fixtures for profile discovery tests.
browser/safari/source.go Refactors Safari source path resolution to be profile-aware (default vs named).
browser/safari/safari.go Returns one Browser per profile; routes extraction/counting accordingly and filters shared downloads.
browser/safari/safari_test.go Adds multi-profile NewBrowsers test and updates resolveSourcePaths tests for new API.
browser/safari/profiles.go Implements profile discovery, name sanitization, UUID normalization, and disambiguation.
browser/safari/profiles_test.go New test suite covering discovery behavior, fallback rules, naming, and source building.
browser/safari/extract_download.go Filters downloads by profile ownership and updates plist field mappings.
browser/safari/extract_download_test.go Updates/expands tests for per-profile download filtering and counting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

continue
}
out = append(out, newNamedProfile(externalUUID.String, title.String))
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readNamedProfilesFromDB iterates rows but never checks rows.Err() before returning. That can silently drop query/IO errors mid-iteration and treat the DB as authoritative with partial results. Please add a rows.Err() check (and return an error) before returning out.

Suggested change
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate SafariTabs.db rows: %w", err)
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 5d5ec1c. Added rows.Err() check after the iteration so mid-iteration IO errors no longer get swallowed as an authoritative empty result.

Comment thread browser/safari/safari_test.go Outdated
Comment on lines +133 to +134
sources := buildSources(profileContext{legacyHome: dir})
resolved := resolveSourcePaths(sources)
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test builds sources with profileContext{legacyHome: dir} but leaves profileContext.container empty. defaultSources will then generate a relative container cookie path ("Cookies/Cookies.binarycookies"), so resolveSourcePaths may accidentally stat a real file from the current working directory and make the test flaky. Set container explicitly (deriveContainerRoot(dir)) or adjust buildSources/defaultSources to derive container when empty.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d5ec1c — both tests now pass container: deriveContainerRoot(dir) so defaultSources gets absolute paths instead of relative candidates against cwd.

Comment thread browser/safari/safari_test.go Outdated
Comment on lines +141 to +142
sources := buildSources(profileContext{legacyHome: t.TempDir()})
assert.Empty(t, resolveSourcePaths(sources))
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: container is empty here, so buildSources/defaultSources can produce relative candidate paths (e.g., "Cookies/Cookies.binarycookies"), making the assertion dependent on the process working directory. Provide container (deriveContainerRoot) or make buildSources fill it in.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix as above in 5d5ec1cTestResolveSourcePaths_Empty now sets container explicitly.

Comment thread browser/safari/source.go Outdated
Comment on lines +44 to +56
// namedSources omits shared categories (Bookmark, Download) — those are attributed to the default profile.
//
// LocalStorage slot for a follow-up PR:
//
// file(filepath.Join(p.container, "WebKit/WebsiteDataStore", p.uuidLower, "LocalStorage"))
func namedSources(p profileContext) map[types.Category][]sourcePath {
profileDir := filepath.Join(p.container, "Safari", "Profiles", p.uuidUpper)
webkitStore := filepath.Join(p.container, "WebKit", "WebsiteDataStore", p.uuidLower)

return map[types.Category][]sourcePath{
types.History: {file(filepath.Join(profileDir, "History.db"))},
types.Cookie: {file(filepath.Join(webkitStore, "Cookies", "Cookies.binarycookies"))},
types.Download: {file(filepath.Join(p.legacyHome, "Downloads.plist"))},
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says namedSources omits shared categories "(Bookmark, Download)", but the function actually includes types.Download. This is misleading for future maintenance—either update the comment to reflect that Downloads.plist is shared but still included (and filtered per-profile), or remove Download from namedSources if the intent is truly default-only attribution.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment — fixed in 5d5ec1c. Updated to explain that Download is included but filtered by DownloadEntryProfileUUIDStringKey in extractDownloads, while Bookmark stays default-only because it has no per-entry profile tag.

@moonD4rk moonD4rk merged commit d75738b into main Apr 21, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants