feat: cli migrate to cobra with subcommands (#550)

* feat: migrate CLI to cobra with dump/list/version subcommands (#546)
* fix: remove residual duckduckgo references and add README/LICENSE to release archives
* fix: address PR review feedback from Copilot
This commit is contained in:
Roger
2026-04-05 14:25:51 +08:00
committed by GitHub
parent 068b82178f
commit 4af2ded428
15 changed files with 418 additions and 112 deletions
+25 -10
View File
@@ -16,19 +16,30 @@ import (
type Browser interface {
BrowserName() string
ProfileName() string
ProfileDir() string
Extract(categories []types.Category) (*types.BrowserData, error)
}
// PickBrowsers returns browsers matching the given name.
// When name is "all", all known browsers are tried.
// profilePath overrides the default user data directory (only when targeting a specific browser).
func PickBrowsers(name, profilePath string) ([]Browser, error) {
return pickFromConfigs(platformBrowsers(), name, profilePath)
// PickOptions configures which browsers to pick.
type PickOptions struct {
Name string // browser name filter: "all"|"chrome"|"firefox"|...
ProfilePath string // custom profile directory override
KeychainPassword string // macOS keychain password (ignored on other platforms)
}
// PickBrowsers returns browsers matching the given options.
// When Name is "all", all known browsers are tried.
// ProfilePath overrides the default user data directory (only when targeting a specific browser).
func PickBrowsers(opts PickOptions) ([]Browser, error) {
return pickFromConfigs(platformBrowsers(), opts)
}
// pickFromConfigs is the testable core of PickBrowsers.
func pickFromConfigs(configs []types.BrowserConfig, name, profilePath string) ([]Browser, error) {
name = strings.ToLower(name)
func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser, error) {
name := strings.ToLower(opts.Name)
if name == "" {
name = "all"
}
var browsers []Browser
for _, cfg := range configs {
@@ -36,14 +47,18 @@ func pickFromConfigs(configs []types.BrowserConfig, name, profilePath string) ([
continue
}
if profilePath != "" && name != "all" {
if opts.ProfilePath != "" && name != "all" {
if cfg.Kind == types.KindFirefox {
cfg.UserDataDir = filepath.Dir(filepath.Clean(profilePath))
cfg.UserDataDir = filepath.Dir(filepath.Clean(opts.ProfilePath))
} else {
cfg.UserDataDir = profilePath
cfg.UserDataDir = opts.ProfilePath
}
}
if opts.KeychainPassword != "" {
cfg.KeychainPassword = opts.KeychainPassword
}
bs, err := newBrowsers(cfg)
if err != nil {
log.Errorf("browser %s: %v", cfg.Name, err)
+9 -3
View File
@@ -27,6 +27,7 @@ func TestListBrowsers(t *testing.T) {
func TestPickFromConfigs_NameFilter(t *testing.T) {
dir := t.TempDir()
mkFile(t, dir, "Default", "Preferences")
mkFile(t, dir, "Default", "Login Data")
mkFile(t, dir, "Default", "History")
@@ -67,7 +68,7 @@ func TestPickFromConfigs_NameFilter(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
browsers, err := pickFromConfigs(configs, tt.pickName, "")
browsers, err := pickFromConfigs(configs, PickOptions{Name: tt.pickName})
require.NoError(t, err)
assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles)
})
@@ -76,8 +77,10 @@ func TestPickFromConfigs_NameFilter(t *testing.T) {
func TestPickFromConfigs_BrowserKind(t *testing.T) {
chromeDir := t.TempDir()
mkFile(t, chromeDir, "Default", "Preferences")
mkFile(t, chromeDir, "Default", "Login Data")
mkFile(t, chromeDir, "Default", "History")
mkFile(t, chromeDir, "Profile 1", "Preferences")
mkFile(t, chromeDir, "Profile 1", "Login Data")
mkFile(t, chromeDir, "Profile 1", "History")
@@ -86,6 +89,7 @@ func TestPickFromConfigs_BrowserKind(t *testing.T) {
mkFile(t, firefoxDir, "abc123.default-release", "places.sqlite")
yandexDir := t.TempDir()
mkFile(t, yandexDir, "Default", "Preferences")
mkFile(t, yandexDir, "Default", "Ya Passman Data")
mkFile(t, yandexDir, "Default", "History")
@@ -129,7 +133,7 @@ func TestPickFromConfigs_BrowserKind(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
browsers, err := pickFromConfigs(tt.configs, "all", "")
browsers, err := pickFromConfigs(tt.configs, PickOptions{Name: "all"})
require.NoError(t, err)
assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles)
})
@@ -138,8 +142,10 @@ func TestPickFromConfigs_BrowserKind(t *testing.T) {
func TestPickFromConfigs_ProfilePath(t *testing.T) {
chromeDir := t.TempDir()
mkFile(t, chromeDir, "Default", "Preferences")
mkFile(t, chromeDir, "Default", "Login Data")
mkFile(t, chromeDir, "Default", "History")
mkFile(t, chromeDir, "Profile 1", "Preferences")
mkFile(t, chromeDir, "Profile 1", "Login Data")
mkFile(t, chromeDir, "Profile 1", "History")
@@ -189,7 +195,7 @@ func TestPickFromConfigs_ProfilePath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
browsers, err := pickFromConfigs(tt.configs, tt.pickName, tt.profilePath)
browsers, err := pickFromConfigs(tt.configs, PickOptions{Name: tt.pickName, ProfilePath: tt.profilePath})
require.NoError(t, err)
assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles)
})
+7 -1
View File
@@ -68,6 +68,12 @@ func platformBrowsers() []types.BrowserConfig {
Kind: types.KindChromiumYandex,
UserDataDir: homeDir + "/AppData/Local/Yandex/YandexBrowser/User Data",
},
{
Key: "360x",
Name: speed360XName,
Kind: types.KindChromium,
UserDataDir: homeDir + "/AppData/Local/360ChromeX/Chrome/User Data",
},
{
Key: "360",
Name: speed360Name,
@@ -90,7 +96,7 @@ func platformBrowsers() []types.BrowserConfig {
Key: "sogou",
Name: sogouName,
Kind: types.KindChromium,
UserDataDir: homeDir + "/AppData/Roaming/SogouExplorer/Webkit",
UserDataDir: homeDir + "/AppData/Local/Sogou/SogouExplorer/User Data",
},
{
Key: "firefox",
+29 -4
View File
@@ -57,6 +57,7 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
}
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileDir() string { return b.profileDir }
func (b *Browser) ProfileName() string {
if b.profileDir == "" {
return ""
@@ -173,8 +174,9 @@ func (b *Browser) extractCategory(data *types.BrowserData, cat types.Category, m
}
}
// discoverProfiles lists subdirectories of userDataDir that contain at least
// one known data source. Each such directory is a browser profile.
// discoverProfiles lists subdirectories of userDataDir that are valid
// Chromium profile directories. A directory is considered a profile if it
// contains a "Preferences" file, which Chromium creates for every profile.
func discoverProfiles(userDataDir string, sources map[types.Category][]sourcePath) []string {
entries, err := os.ReadDir(userDataDir)
if err != nil {
@@ -188,18 +190,41 @@ func discoverProfiles(userDataDir string, sources map[types.Category][]sourcePat
continue
}
dir := filepath.Join(userDataDir, e.Name())
if hasAnySource(sources, dir) {
if isProfileDir(dir) {
profiles = append(profiles, dir)
}
}
// Flat layout fallback (older Opera): data files directly in userDataDir
// Flat layout fallback (older Opera): data files directly in userDataDir.
// Opera stores data alongside Local State in userDataDir itself, so check
// for any known source file instead of Preferences.
if len(profiles) == 0 && hasAnySource(sources, userDataDir) {
profiles = append(profiles, userDataDir)
}
return profiles
}
// profileMarkers are filenames that identify a directory as a Chromium profile.
// Chromium creates a per-profile preferences file on first use; checking for
// its existence filters out non-profile subdirectories (Crashpad, ShaderCache, etc.).
//
// - "Preferences" — standard Chromium and all major forks (Chrome, Edge, Brave, …)
// - "Preferences_02" — Tencent-based browsers (QQ Browser, Sogou Explorer)
var profileMarkers = []string{
"Preferences",
"Preferences_02",
}
// isProfileDir reports whether dir is a valid Chromium profile directory.
func isProfileDir(dir string) bool {
for _, name := range profileMarkers {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
// hasAnySource checks if dir contains at least one source file or directory.
func hasAnySource(sources map[types.Category][]sourcePath, dir string) bool {
for _, candidates := range sources {
+7
View File
@@ -45,6 +45,7 @@ func buildFixtures() {
fixture.chrome = filepath.Join(fixture.root, "chrome")
mkFile(fixture.chrome, "Local State")
for _, p := range []string{"Default", "Profile 1", "Profile 3"} {
mkFile(fixture.chrome, p, "Preferences")
mkFile(fixture.chrome, p, "Login Data")
mkFile(fixture.chrome, p, "History")
mkFile(fixture.chrome, p, "Bookmarks")
@@ -60,6 +61,7 @@ func buildFixtures() {
fixture.opera = filepath.Join(fixture.root, "opera")
mkFile(fixture.opera, "Local State")
mkFile(fixture.opera, "Default", "Preferences")
mkFile(fixture.opera, "Default", "Login Data")
mkFile(fixture.opera, "Default", "History")
mkFile(fixture.opera, "Default", "Bookmarks")
@@ -73,6 +75,7 @@ func buildFixtures() {
fixture.yandex = filepath.Join(fixture.root, "yandex")
mkFile(fixture.yandex, "Local State")
mkFile(fixture.yandex, "Default", "Preferences")
mkFile(fixture.yandex, "Default", "Ya Passman Data")
mkFile(fixture.yandex, "Default", "Ya Credit Cards")
mkFile(fixture.yandex, "Default", "History")
@@ -80,14 +83,17 @@ func buildFixtures() {
mkFile(fixture.yandex, "Default", "Bookmarks")
fixture.oldCookies = filepath.Join(fixture.root, "old-cookies")
mkFile(fixture.oldCookies, "Default", "Preferences")
mkFile(fixture.oldCookies, "Default", "History")
mkFile(fixture.oldCookies, "Default", "Cookies")
fixture.bothCookies = filepath.Join(fixture.root, "both-cookies")
mkFile(fixture.bothCookies, "Default", "Preferences")
mkFile(fixture.bothCookies, "Default", "Cookies")
mkFile(fixture.bothCookies, "Default", "Network", "Cookies")
fixture.leveldb = filepath.Join(fixture.root, "leveldb")
mkFile(fixture.leveldb, "Default", "Preferences")
mkFile(fixture.leveldb, "Default", "History")
mkDir(fixture.leveldb, "Default", "Local Storage", "leveldb")
mkFile(fixture.leveldb, "Default", "Local Storage", "leveldb", "000001.ldb")
@@ -95,6 +101,7 @@ func buildFixtures() {
mkFile(fixture.leveldb, "Default", "Session Storage", "000001.ldb")
fixture.leveldbOnly = filepath.Join(fixture.root, "leveldb-only")
mkFile(fixture.leveldbOnly, "Default", "Preferences")
mkDir(fixture.leveldbOnly, "Default", "Local Storage", "leveldb")
mkDir(fixture.leveldbOnly, "Default", "Session Storage")
+2 -1
View File
@@ -19,7 +19,8 @@ const (
coccocName = "CocCoc"
yandexName = "Yandex"
firefoxName = "Firefox"
speed360Name = "360speed"
speed360Name = "360 Speed"
speed360XName = "360 Speed X"
qqBrowserName = "QQ"
dcBrowserName = "DC"
sogouName = "Sogou"
+1
View File
@@ -47,6 +47,7 @@ func NewBrowsers(cfg types.BrowserConfig) ([]*Browser, error) {
}
func (b *Browser) BrowserName() string { return b.cfg.Name }
func (b *Browser) ProfileDir() string { return b.profileDir }
func (b *Browser) ProfileName() string {
if b.profileDir == "" {
return ""