diff --git a/browser/browser.go b/browser/browser.go index 3326a74..7c5e0b6 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -50,6 +50,8 @@ func pickFromConfigs(configs []types.BrowserConfig, opts PickOptions) ([]Browser // it's harmless (DPAPI reads Local State per-profile, D-Bus is stateless). retriever := keyretriever.DefaultRetriever(opts.KeychainPassword) + configs = resolveGlobs(configs) + var browsers []Browser for _, cfg := range configs { if name != "all" && cfg.Key != name { @@ -94,6 +96,34 @@ type retrieverSetter interface { SetRetriever(keyretriever.KeyRetriever) } +// resolveGlobs expands glob patterns in browser configs' UserDataDir. +// This supports MSIX/UWP browsers on Windows whose package directories +// contain a dynamic publisher hash suffix (e.g., "TheBrowserCompany.Arc_*"). +// +// For literal paths (no glob metacharacters), Glob returns the path itself +// when it exists, so the config passes through unchanged. When a path does +// not exist and contains no metacharacters, Glob returns nil and the +// original config is preserved — the main loop handles "not found" as usual. +// +// When a glob matches multiple directories, the config is duplicated so +// each resolved path is treated as a separate browser data directory. +func resolveGlobs(configs []types.BrowserConfig) []types.BrowserConfig { + var out []types.BrowserConfig + for _, cfg := range configs { + matches, _ := filepath.Glob(cfg.UserDataDir) + if len(matches) == 0 { + out = append(out, cfg) + continue + } + for _, dir := range matches { + c := cfg + c.UserDataDir = dir + out = append(out, c) + } + } + return out +} + // newBrowsers dispatches to the correct engine based on BrowserKind // and converts engine-specific types to the Browser interface. func newBrowsers(cfg types.BrowserConfig) ([]Browser, error) { diff --git a/browser/browser_test.go b/browser/browser_test.go index b838b41..f5e92c1 100644 --- a/browser/browser_test.go +++ b/browser/browser_test.go @@ -25,57 +25,33 @@ func TestListBrowsers(t *testing.T) { assert.True(t, sort.StringsAreSorted(list)) } -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") - - configs := []types.BrowserConfig{ - {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: dir}, - {Key: "edge", Name: "Edge", Kind: types.Chromium, UserDataDir: dir}, - } - - tests := []struct { - name string - pickName string - wantNames []string - wantProfiles []string - }{ - { - name: "exact match", - pickName: "chrome", - wantNames: []string{"Chrome"}, - wantProfiles: []string{"Default"}, - }, - { - name: "case insensitive", - pickName: "Chrome", - wantNames: []string{"Chrome"}, - wantProfiles: []string{"Default"}, - }, - { - name: "all returns both", - pickName: "all", - wantNames: []string{"Chrome", "Edge"}, - wantProfiles: []string{"Default", "Default"}, - }, - { - name: "unknown returns empty", - pickName: "safari", - }, - } +type pickTest struct { + name string + configs []types.BrowserConfig + opts PickOptions + wantNames []string + wantProfiles []string +} +func runPickTests(t *testing.T, tests []pickTest) { + t.Helper() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - browsers, err := pickFromConfigs(configs, PickOptions{Name: tt.pickName}) + browsers, err := pickFromConfigs(tt.configs, tt.opts) require.NoError(t, err) assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles) }) } } -func TestPickFromConfigs_BrowserKind(t *testing.T) { +func TestPickFromConfigs(t *testing.T) { + // --- fixtures: single-profile chromium (for name filter tests) --- + singleDir := t.TempDir() + mkFile(t, singleDir, "Default", "Preferences") + mkFile(t, singleDir, "Default", "Login Data") + mkFile(t, singleDir, "Default", "History") + + // --- fixtures: multi-profile chromium --- chromeDir := t.TempDir() mkFile(t, chromeDir, "Default", "Preferences") mkFile(t, chromeDir, "Default", "Login Data") @@ -84,128 +60,287 @@ func TestPickFromConfigs_BrowserKind(t *testing.T) { mkFile(t, chromeDir, "Profile 1", "Login Data") mkFile(t, chromeDir, "Profile 1", "History") + // --- fixtures: firefox --- firefoxDir := t.TempDir() mkFile(t, firefoxDir, "abc123.default-release", "logins.json") mkFile(t, firefoxDir, "abc123.default-release", "places.sqlite") + // --- fixtures: yandex --- yandexDir := t.TempDir() mkFile(t, yandexDir, "Default", "Preferences") mkFile(t, yandexDir, "Default", "Ya Passman Data") mkFile(t, yandexDir, "Default", "History") + // --- fixtures: glob (MSIX-like package directories) --- + globBase := t.TempDir() + mkFile(t, globBase, "App.Browser_abc123", "UserData", "Default", "Preferences") + mkFile(t, globBase, "App.Browser_abc123", "UserData", "Default", "History") + mkFile(t, globBase, "App.Browser_def456", "UserData", "Default", "Preferences") + mkFile(t, globBase, "App.Browser_def456", "UserData", "Default", "History") + mkFile(t, globBase, "Solo.Browser_xyz789", "UserData", "Default", "Preferences") + mkFile(t, globBase, "Solo.Browser_xyz789", "UserData", "Default", "History") + + nameFilterConfigs := []types.BrowserConfig{ + {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: singleDir}, + {Key: "edge", Name: "Edge", Kind: types.Chromium, UserDataDir: singleDir}, + } + + t.Run("NameFilter", func(t *testing.T) { + runPickTests(t, []pickTest{ + { + name: "exact match", + configs: nameFilterConfigs, + opts: PickOptions{Name: "chrome"}, + wantNames: []string{"Chrome"}, + wantProfiles: []string{"Default"}, + }, + { + name: "case insensitive", + configs: nameFilterConfigs, + opts: PickOptions{Name: "Chrome"}, + wantNames: []string{"Chrome"}, + wantProfiles: []string{"Default"}, + }, + { + name: "all returns both", + configs: nameFilterConfigs, + opts: PickOptions{Name: "all"}, + wantNames: []string{"Chrome", "Edge"}, + wantProfiles: []string{"Default", "Default"}, + }, + { + name: "unknown returns empty", + configs: nameFilterConfigs, + opts: PickOptions{Name: "safari"}, + }, + }) + }) + + t.Run("BrowserKind", func(t *testing.T) { + runPickTests(t, []pickTest{ + { + name: "chromium multi-profile", + configs: []types.BrowserConfig{ + {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir}, + }, + opts: PickOptions{Name: "all"}, + wantNames: []string{"Chrome", "Chrome"}, + wantProfiles: []string{"Default", "Profile 1"}, + }, + { + name: "firefox random dir", + configs: []types.BrowserConfig{ + {Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir}, + }, + opts: PickOptions{Name: "all"}, + wantNames: []string{"Firefox"}, + wantProfiles: []string{"abc123.default-release"}, + }, + { + name: "yandex variant", + configs: []types.BrowserConfig{ + {Key: "yandex", Name: "Yandex", Kind: types.ChromiumYandex, UserDataDir: yandexDir}, + }, + opts: PickOptions{Name: "all"}, + wantNames: []string{"Yandex"}, + wantProfiles: []string{"Default"}, + }, + { + name: "nonexistent dir", + configs: []types.BrowserConfig{ + {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/nonexistent"}, + }, + opts: PickOptions{Name: "all"}, + }, + }) + }) + + t.Run("ProfilePath", func(t *testing.T) { + runPickTests(t, []pickTest{ + { + name: "chromium uses path directly", + configs: []types.BrowserConfig{ + {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/wrong"}, + }, + opts: PickOptions{Name: "chrome", ProfilePath: filepath.Join(chromeDir, "Default")}, + wantNames: []string{"Chrome"}, + wantProfiles: []string{"Default"}, + }, + { + name: "firefox uses parent dir", + configs: []types.BrowserConfig{ + {Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: "/wrong"}, + }, + opts: PickOptions{Name: "firefox", ProfilePath: filepath.Join(firefoxDir, "abc123.default-release")}, + wantNames: []string{"Firefox"}, + wantProfiles: []string{"abc123.default-release"}, + }, + { + name: "ignored when name is all", + configs: []types.BrowserConfig{ + {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir}, + }, + opts: PickOptions{Name: "all", ProfilePath: "/some/override"}, + wantNames: []string{"Chrome", "Chrome"}, + wantProfiles: []string{"Default", "Profile 1"}, + }, + }) + }) + + t.Run("Glob", func(t *testing.T) { + runPickTests(t, []pickTest{ + { + name: "single match", + configs: []types.BrowserConfig{ + {Key: "solo", Name: "Solo", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "Solo.Browser_*", "UserData")}, + }, + opts: PickOptions{Name: "all"}, + wantNames: []string{"Solo"}, + wantProfiles: []string{"Default"}, + }, + { + name: "multiple matches", + configs: []types.BrowserConfig{ + {Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "App.Browser_*", "UserData")}, + }, + opts: PickOptions{Name: "all"}, + wantNames: []string{"Arc", "Arc"}, + wantProfiles: []string{"Default", "Default"}, + }, + { + name: "no match", + configs: []types.BrowserConfig{ + {Key: "missing", Name: "Missing", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "NoSuch_*", "UserData")}, + }, + opts: PickOptions{Name: "all"}, + }, + { + name: "mixed with literal", + configs: []types.BrowserConfig{ + {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: singleDir}, + {Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "Solo.Browser_*", "UserData")}, + }, + opts: PickOptions{Name: "all"}, + wantNames: []string{"Arc", "Chrome"}, + wantProfiles: []string{"Default", "Default"}, + }, + { + name: "with name filter", + configs: []types.BrowserConfig{ + {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: singleDir}, + {Key: "arc", Name: "Arc", Kind: types.Chromium, UserDataDir: filepath.Join(globBase, "App.Browser_*", "UserData")}, + }, + opts: PickOptions{Name: "arc"}, + wantNames: []string{"Arc", "Arc"}, + wantProfiles: []string{"Default", "Default"}, + }, + }) + }) +} + +func TestResolveGlobs(t *testing.T) { + // Create directories for glob matching: + // base/ + // ├── App.Browser_abc123/UserData/ (match 1) + // ├── App.Browser_def456/UserData/ (match 2) + // └── ExactBrowser/UserData/ (literal path) + base := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(base, "App.Browser_abc123", "UserData"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(base, "App.Browser_def456", "UserData"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(base, "ExactBrowser", "UserData"), 0o755)) + tests := []struct { - name string - configs []types.BrowserConfig - wantNames []string - wantProfiles []string + name string + configs []types.BrowserConfig + wantDirs []string // expected UserDataDir values after resolution }{ { - name: "chromium multi-profile", + name: "literal path exists", configs: []types.BrowserConfig{ - {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir}, + {Key: "exact", UserDataDir: filepath.Join(base, "ExactBrowser", "UserData")}, }, - wantNames: []string{"Chrome", "Chrome"}, - wantProfiles: []string{"Default", "Profile 1"}, + wantDirs: []string{filepath.Join(base, "ExactBrowser", "UserData")}, }, { - name: "firefox random dir", + name: "literal path not exists preserved", configs: []types.BrowserConfig{ - {Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: firefoxDir}, + {Key: "missing", UserDataDir: filepath.Join(base, "NoSuchBrowser", "UserData")}, }, - wantNames: []string{"Firefox"}, - wantProfiles: []string{"abc123.default-release"}, + wantDirs: []string{filepath.Join(base, "NoSuchBrowser", "UserData")}, }, { - name: "yandex variant", + name: "glob single match", configs: []types.BrowserConfig{ - {Key: "yandex", Name: "Yandex", Kind: types.ChromiumYandex, UserDataDir: yandexDir}, + {Key: "single", UserDataDir: filepath.Join(base, "ExactBrow*", "UserData")}, }, - wantNames: []string{"Yandex"}, - wantProfiles: []string{"Default"}, + wantDirs: []string{filepath.Join(base, "ExactBrowser", "UserData")}, }, { - name: "nonexistent dir", + name: "glob multiple matches", configs: []types.BrowserConfig{ - {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/nonexistent"}, + {Key: "multi", UserDataDir: filepath.Join(base, "App.Browser_*", "UserData")}, }, + wantDirs: []string{ + filepath.Join(base, "App.Browser_abc123", "UserData"), + filepath.Join(base, "App.Browser_def456", "UserData"), + }, + }, + { + name: "glob no match preserved", + configs: []types.BrowserConfig{ + {Key: "nomatch", UserDataDir: filepath.Join(base, "NoSuch_*", "UserData")}, + }, + wantDirs: []string{filepath.Join(base, "NoSuch_*", "UserData")}, + }, + { + name: "mixed literal and glob", + configs: []types.BrowserConfig{ + {Key: "chrome", UserDataDir: filepath.Join(base, "ExactBrowser", "UserData")}, + {Key: "arc", UserDataDir: filepath.Join(base, "App.Browser_*", "UserData")}, + }, + wantDirs: []string{ + filepath.Join(base, "ExactBrowser", "UserData"), + filepath.Join(base, "App.Browser_abc123", "UserData"), + filepath.Join(base, "App.Browser_def456", "UserData"), + }, + }, + { + name: "empty input", + configs: nil, + wantDirs: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - browsers, err := pickFromConfigs(tt.configs, PickOptions{Name: "all"}) - require.NoError(t, err) - assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles) + got := resolveGlobs(tt.configs) + + var gotDirs []string + for _, cfg := range got { + gotDirs = append(gotDirs, cfg.UserDataDir) + } + sort.Strings(gotDirs) + sort.Strings(tt.wantDirs) + assert.Equal(t, tt.wantDirs, gotDirs) + + // Verify non-UserDataDir fields are preserved. + for _, cfg := range got { + found := false + for _, orig := range tt.configs { + if cfg.Key != orig.Key { + continue + } + found = true + assert.Equal(t, orig.Name, cfg.Name) + assert.Equal(t, orig.Kind, cfg.Kind) + break + } + assert.True(t, found, "unexpected key %q in output", cfg.Key) + } }) } } -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") - - firefoxDir := t.TempDir() - mkFile(t, firefoxDir, "abc123.default-release", "logins.json") - mkFile(t, firefoxDir, "abc123.default-release", "places.sqlite") - - tests := []struct { - name string - configs []types.BrowserConfig - pickName string - profilePath string - wantNames []string - wantProfiles []string - }{ - { - name: "chromium uses path directly", - configs: []types.BrowserConfig{ - {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/wrong"}, - }, - pickName: "chrome", - profilePath: filepath.Join(chromeDir, "Default"), - wantNames: []string{"Chrome"}, - wantProfiles: []string{"Default"}, - }, - { - name: "firefox uses parent dir", - configs: []types.BrowserConfig{ - {Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: "/wrong"}, - }, - pickName: "firefox", - profilePath: filepath.Join(firefoxDir, "abc123.default-release"), - wantNames: []string{"Firefox"}, - wantProfiles: []string{"abc123.default-release"}, - }, - { - name: "ignored when name is all", - configs: []types.BrowserConfig{ - {Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: chromeDir}, - }, - pickName: "all", - profilePath: "/some/override", - wantNames: []string{"Chrome", "Chrome"}, - wantProfiles: []string{"Default", "Profile 1"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - browsers, err := pickFromConfigs(tt.configs, PickOptions{Name: tt.pickName, ProfilePath: tt.profilePath}) - require.NoError(t, err) - assertBrowsers(t, browsers, tt.wantNames, tt.wantProfiles) - }) - } -} - -// --------------------------------------------------------------------------- -// newBrowsers dispatcher -// --------------------------------------------------------------------------- - func TestNewBrowsersDispatch(t *testing.T) { chromiumDir := t.TempDir() mkFile(t, chromiumDir, "Default", "Preferences") @@ -267,10 +402,6 @@ func TestNewBrowsersDispatch(t *testing.T) { } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - // assertBrowsers verifies browser names and profiles match expectations (order-independent). func assertBrowsers(t *testing.T, browsers []Browser, wantNames, wantProfiles []string) { t.Helper() diff --git a/browser/browser_windows.go b/browser/browser_windows.go index c335e94..6248b0b 100644 --- a/browser/browser_windows.go +++ b/browser/browser_windows.go @@ -98,6 +98,18 @@ func platformBrowsers() []types.BrowserConfig { Kind: types.Chromium, UserDataDir: homeDir + "/AppData/Local/Sogou/SogouExplorer/User Data", }, + { + Key: "arc", + Name: arcName, + Kind: types.Chromium, + UserDataDir: homeDir + "/AppData/Local/Packages/TheBrowserCompany.Arc_*/LocalCache/Local/Arc/User Data", + }, + { + Key: "duckduckgo", + Name: duckduckgoName, + Kind: types.Chromium, + UserDataDir: homeDir + "/AppData/Local/Packages/DuckDuckGo.DesktopBrowser_*/LocalState/EBWebView", + }, { Key: "firefox", Name: firefoxName, diff --git a/browser/consts.go b/browser/consts.go index 7e53e4e..98ff89a 100644 --- a/browser/consts.go +++ b/browser/consts.go @@ -25,4 +25,5 @@ const ( dcName = "DC" sogouName = "Sogou" arcName = "Arc" + duckduckgoName = "DuckDuckGo" )