feat(safari): multi-profile support (#581)

* feat(safari): multi-profile support
This commit is contained in:
Roger
2026-04-21 15:50:36 +08:00
committed by GitHub
parent 7b9a973c9c
commit d75738b90f
8 changed files with 676 additions and 103 deletions
+47 -18
View File
@@ -6,26 +6,55 @@ import (
"github.com/moond4rk/hackbrowserdata/types"
)
// sourcePath describes a single candidate location for browser data,
// relative to the Safari data directory.
type sourcePath struct {
rel string // relative path from dataDir
isDir bool // true for directory targets
abs string
isDir bool
}
func file(rel string) sourcePath { return sourcePath{rel: filepath.FromSlash(rel)} }
func file(abs string) sourcePath { return sourcePath{abs: abs} }
// safariSources defines the Safari file layout.
// Each category maps to one or more candidate paths tried in priority order;
// the first existing path wins.
var safariSources = map[types.Category][]sourcePath{
types.History: {file("History.db")},
types.Bookmark: {file("Bookmarks.plist")},
types.Download: {file("Downloads.plist")},
types.Cookie: {
// macOS 14+ (containerized Safari)
file("../Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"),
// macOS ≤13 (traditional path)
file("../Cookies/Cookies.binarycookies"),
},
// buildSources dispatches between the default and named-profile path layouts.
//
// macOS 14+ layout:
// - History, Cookie: per-profile (separate files per profile UUID)
// - Download: shared plist, filtered by DownloadEntryProfileUUIDStringKey at extract time
// - Bookmark: shared plist, attributed to default only (no per-entry UUID available)
// - Password: macOS Keychain (shared, not listed)
func buildSources(p profileContext) map[types.Category][]sourcePath {
if p.isDefault() {
return defaultSources(p)
}
return namedSources(p)
}
// defaultSources: cookies try macOS 14+ container first, then the ≤13 legacy path.
func defaultSources(p profileContext) map[types.Category][]sourcePath {
home := p.legacyHome
containerCookies := filepath.Join(p.container, "Cookies", "Cookies.binarycookies")
legacyCookies := filepath.Join(filepath.Dir(home), "Cookies", "Cookies.binarycookies")
return map[types.Category][]sourcePath{
types.History: {file(filepath.Join(home, "History.db"))},
types.Cookie: {file(containerCookies), file(legacyCookies)},
types.Bookmark: {file(filepath.Join(home, "Bookmarks.plist"))},
types.Download: {file(filepath.Join(home, "Downloads.plist"))},
}
}
// namedSources omits Bookmark (shared plist with no per-entry profile tag, so attributed to default).
// Download is included because Downloads.plist carries DownloadEntryProfileUUIDStringKey per entry;
// extractDownloads filters by owner UUID so default and named profiles each see their own downloads.
//
// 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"))},
}
}