feat: support MSIX/UWP browsers on Windows (Arc, DuckDuckGo) (#563)

* chore: remove redundant separator comments in browser_test.go
This commit is contained in:
Roger
2026-04-11 00:07:58 +08:00
committed by GitHub
parent b3bbc0dadf
commit 454834c06c
4 changed files with 307 additions and 133 deletions
+30
View File
@@ -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) {
+264 -133
View File
@@ -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()
+12
View File
@@ -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,
+1
View File
@@ -25,4 +25,5 @@ const (
dcName = "DC"
sogouName = "Sogou"
arcName = "Arc"
duckduckgoName = "DuckDuckGo"
)