Repository: moonD4rk/HackBrowserData (Go)

Issue:
[Windows] Support MSIX/UWP browsers (Arc, DuckDuckGo)
## Background

Some browsers on Windows are distributed as MSIX/UWP packages (Microsoft Store apps) instead of traditional installers. Their user data paths differ from standard Chromium browsers:

**Traditional browsers** — fixed, predictable paths:
```
AppData\Local\Google\Chrome\User Data
AppData\Local\BraveSoftware\Brave-Browser\User Data
```

**MSIX browsers** — paths contain a publisher hash suffix:
```
AppData\Local\Packages\TheBrowserCompany.Arc_ttt1ap7aakyb4\LocalCache\Local\Arc\User Data
AppData\Local\Packages\DuckDuckGo.DesktopBrowser_ya2fgkz3nks94\LocalState\EBWebView
```

The suffix (e.g. `_ttt1ap7aakyb4`) is derived from the publisher's signing certificate. It is stable for the same app across machines, but could change if the publisher rotates their certificate.

## Affected Browsers

| Browser | Engine | Data Location |
|---------|--------|---------------|
| Arc | Chromium | `Packages\TheBrowserCompany.Arc_ttt1ap7aakyb4\LocalCache\Local\Arc\User Data` |
| DuckDuckGo | Edge WebView2 (Chromium) | `Packages\DuckDuckGo.DesktopBrowser_ya2fgkz3nks94\LocalState\EBWebView` |

Both have standard Chromium profile structure (`Preferences`, `Login Data`, `History`, `Cookies`, etc.) and have been verified working on Windows 10.

## Current Limitation

`BrowserConfig.UserDataDir` only supports exact paths. To support MSIX browsers robustly, we need a way to handle dynamic path components in the package directory name.


Discussion:

Patch:
diff --git a/browser/browser.go b/browser/browser.go
index 3326a741..7c5e0b6c 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_windows.go b/browser/browser_windows.go
index c335e941..6248b0bb 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 7e53e4ef..98ff89a3 100644
--- a/browser/consts.go
+++ b/browser/consts.go
@@ -25,4 +25,5 @@ const (
 	dcName         = "DC"
 	sogouName      = "Sogou"
 	arcName        = "Arc"
+	duckduckgoName = "DuckDuckGo"
 )


Test patch:
diff --git a/browser/browser_test.go b/browser/browser_test.go
index b838b411..f5e92c1b 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"),
 			},
 		},
-	}
-
-	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)
-		})
-	}
-}
-
-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",
+			name: "glob no match preserved",
 			configs: []types.BrowserConfig{
-				{Key: "chrome", Name: "Chrome", Kind: types.Chromium, UserDataDir: "/wrong"},
+				{Key: "nomatch", UserDataDir: filepath.Join(base, "NoSuch_*", "UserData")},
 			},
-			pickName:     "chrome",
-			profilePath:  filepath.Join(chromeDir, "Default"),
-			wantNames:    []string{"Chrome"},
-			wantProfiles: []string{"Default"},
+			wantDirs: []string{filepath.Join(base, "NoSuch_*", "UserData")},
 		},
 		{
-			name: "firefox uses parent dir",
+			name: "mixed literal and glob",
 			configs: []types.BrowserConfig{
-				{Key: "firefox", Name: "Firefox", Kind: types.Firefox, UserDataDir: "/wrong"},
+				{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"),
 			},
-			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"},
+			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: tt.pickName, ProfilePath: tt.profilePath})
-			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)
+			}
 		})
 	}
 }
 
-// ---------------------------------------------------------------------------
-// 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()