From 3ebf2704c159aa202fe77093d338eb01cf1a0cbc Mon Sep 17 00:00:00 2001 From: Tarik Caramanico Date: Mon, 14 Sep 2026 18:40:56 -0300 Subject: [PATCH 1/3] fix(arc): read space names from the local sidebar, not only sync data Space names, icons and colors were parsed exclusively from `firebaseSyncState.syncData.spaceModels`. Arc only writes that blob once the profile has synced to its cloud, so a machine that had never signed into Arc imported every space as `Space ` with no icon and no color. The local `sidebar.containers[1].spaces` array carries the same fields, is always present, and stays current across renames. Build the space lookup from the local sidebar and fall back to the sync blob field by field. Keeping the sync blob's key ordering first matters: `_extract_essential_tabs_distributed` collapses several spaces onto one profile key and keeps the last one, so re-ordering the dict would move a user's Essential tabs to a different space. Verified byte-identical output against a real six-space StorableSidebar.json. Also fixes Essential tabs being dropped entirely on unsynced machines, where the profile lookup table was previously empty. --- src/arc_pinned_tab_extractor.py | 158 +++++++++++++++++++++----------- tests/test_extractors.py | 49 ++++++++++ 2 files changed, 151 insertions(+), 56 deletions(-) diff --git a/src/arc_pinned_tab_extractor.py b/src/arc_pinned_tab_extractor.py index 21304b1..24da13c 100644 --- a/src/arc_pinned_tab_extractor.py +++ b/src/arc_pinned_tab_extractor.py @@ -111,67 +111,113 @@ def extract_pinned_tabs(self) -> list[ArcSpace]: logger.error(f"Failed to parse StorableSidebar.json: {e}") return [] - def _parse_local_sidebar_data(self, data: dict) -> list[ArcSpace]: - """Parse the local sidebar data structure (much simpler approach).""" - arc_spaces = [] - - # Get space information from sync data - space_models = data.get('firebaseSyncState', {}).get('syncData', {}).get('spaceModels', []) - spaces_info = {} - - # Build space lookup with icons + @staticmethod + def _iter_id_object_pairs(flat): + """Walk one of Arc's ``[id, object, id, object, ...]`` arrays.""" i = 0 - while i < len(space_models): - if isinstance(space_models[i], str) and i + 1 < len(space_models): - space_id = space_models[i] - space_data = space_models[i + 1].get('value', {}) - space_name = space_data.get('title', f'Space {space_id}') - - # Extract icon from customInfo if available - icon = None - custom_info = space_data.get('customInfo', {}) - icon_type = custom_info.get('iconType', {}) - if 'emoji_v2' in icon_type: - icon = icon_type['emoji_v2'] - logger.info(f" 🎨 Found icon for {space_name}: {icon}") - - # Extract profile information for Essential tabs mapping - profile = None - profile_data = space_data.get('profile', {}) - if 'custom' in profile_data and '_0' in profile_data['custom']: - custom_data = profile_data['custom']['_0'] - profile = custom_data.get('directoryBasename') - - # If no profile is set (Personal space), map to "Default" profile - if profile is None: - profile = "Default" - - # Extract color from windowTheme if available - color = None - window_theme = custom_info.get('windowTheme', {}) - if window_theme: - primary_palette = window_theme.get('primaryColorPalette', {}) - if primary_palette: - # Use midTone as the main color representation - mid_tone = primary_palette.get('midTone', {}) - if mid_tone and 'red' in mid_tone and 'green' in mid_tone and 'blue' in mid_tone: - # Extract RGB values (Arc uses extended sRGB with values that can be negative) - r = max(0, min(1, mid_tone['red'])) # Clamp to 0-1 range - g = max(0, min(1, mid_tone['green'])) - b = max(0, min(1, mid_tone['blue'])) - color = {'r': r, 'g': g, 'b': b} - logger.info(f" 🎨 Found color for {space_name}: RGB({r:.3f}, {g:.3f}, {b:.3f})") - - spaces_info[space_id] = { - 'name': space_name, - 'icon': icon, - 'profile': profile, - 'color': color - } + while i < len(flat): + if isinstance(flat[i], str) and i + 1 < len(flat): + entry = flat[i + 1] + yield flat[i], entry if isinstance(entry, dict) else {} i += 2 else: i += 1 + @staticmethod + def _space_attributes(space_data: dict) -> dict: + """Read name, icon, profile and color out of one space record. + + Arc stores the same shape in two places: the local sidebar's + ``spaces`` array and the cloud ``spaceModels`` blob. Anything + absent comes back as ``None`` so the caller can merge the two + sources field by field. + """ + custom_info = space_data.get('customInfo') or {} + + icon = (custom_info.get('iconType') or {}).get('emoji_v2') or None + + profile = None + custom = (space_data.get('profile') or {}).get('custom') or {} + if isinstance(custom.get('_0'), dict): + profile = custom['_0'].get('directoryBasename') + + color = None + window_theme = custom_info.get('windowTheme') or {} + mid_tone = (window_theme.get('primaryColorPalette') or {}).get('midTone') or {} + if {'red', 'green', 'blue'} <= set(mid_tone): + # Arc uses extended sRGB, whose components can sit outside 0-1. + color = { + 'r': max(0, min(1, mid_tone['red'])), + 'g': max(0, min(1, mid_tone['green'])), + 'b': max(0, min(1, mid_tone['blue'])), + } + + return { + 'name': space_data.get('title') or None, + 'icon': icon, + 'profile': profile, + 'color': color, + } + + def _build_spaces_info(self, data: dict) -> dict: + """Build the space lookup from the local sidebar, then the sync blob. + + The local sidebar is the copy that always exists and always matches + what the user sees. ``firebaseSyncState`` only appears once the + profile has synced to Arc's cloud, so a machine that never signed in + has no titles there and every space used to import as + ``Space ``. Local values win field by field; sync fills gaps. + """ + sync_info = {} + space_models = data.get('firebaseSyncState', {}).get('syncData', {}).get('spaceModels', []) + for space_id, entry in self._iter_id_object_pairs(space_models): + value = entry.get('value') + sync_info[space_id] = self._space_attributes(value if isinstance(value, dict) else {}) + + local_info = {} + containers = data.get('sidebar', {}).get('containers', []) + if len(containers) > 1 and 'spaces' in containers[1]: + for space_id, entry in self._iter_id_object_pairs(containers[1]['spaces']): + local_info[space_id] = self._space_attributes(entry) + + # Keep the sync blob's ordering ahead of the local-only spaces. + # ``_extract_essential_tabs_distributed`` collapses several spaces + # onto one profile key and keeps the last one, so re-ordering this + # dict would silently move a user's Essential tabs to another space. + ordered_ids = list(sync_info) + [sid for sid in local_info if sid not in sync_info] + + spaces_info = {} + for space_id in ordered_ids: + merged = dict(local_info.get(space_id) or {}) + for key, value in (sync_info.get(space_id) or {}).items(): + if merged.get(key) is None: + merged[key] = value + + if not merged.get('name'): + merged['name'] = f'Space {space_id}' + # Arc leaves ``profile`` unset on the default Personal space. + if merged.get('profile') is None: + merged['profile'] = 'Default' + + if merged['icon']: + logger.info(f" 🎨 Found icon for {merged['name']}: {merged['icon']}") + if merged['color']: + rgb = merged['color'] + logger.info( + f" 🎨 Found color for {merged['name']}: " + f"RGB({rgb['r']:.3f}, {rgb['g']:.3f}, {rgb['b']:.3f})" + ) + + spaces_info[space_id] = merged + + return spaces_info + + def _parse_local_sidebar_data(self, data: dict) -> list[ArcSpace]: + """Parse the local sidebar data structure (much simpler approach).""" + arc_spaces = [] + + spaces_info = self._build_spaces_info(data) + # Get all items from local sidebar containers = data.get('sidebar', {}).get('containers', []) if len(containers) > 1 and 'items' in containers[1]: diff --git a/tests/test_extractors.py b/tests/test_extractors.py index 6628f63..f1517f1 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -32,6 +32,55 @@ def test_arc_extractor_extract_shape(arc_home): assert urls == ["https://example.com/", "https://mozilla.org/"] +def _strip_arc_sync_state(home): + """Drop ``firebaseSyncState`` from the fixture's StorableSidebar.json. + + Arc only writes that blob once the profile has synced to the cloud. + A machine that never signed into Arc has spaces in the local sidebar + and no sync data at all. + """ + import json + + path = home / "Library/Application Support/Arc/StorableSidebar.json" + data = json.loads(path.read_text(encoding="utf-8")) + data.pop("firebaseSyncState", None) + path.write_text(json.dumps(data), encoding="utf-8") + + +def test_arc_space_name_survives_missing_sync_state(arc_home): + """Space names must come from the local sidebar, not only from sync data. + + Reported after an import on a machine that had never signed into Arc: + every space arrived named ``Space ``. + """ + _strip_arc_sync_state(arc_home) + + from extractors import ArcExtractor + + data = ArcExtractor().extract() + assert len(data.spaces) == 1 + space = data.spaces[0] + assert space.space_name == "Test Space" + assert space.icon == "\u2728" + urls = sorted(t.url for t in space.pinned_tabs) + assert urls == ["https://example.com/", "https://mozilla.org/"] + + +def test_arc_space_name_prefers_local_sidebar_over_stale_sync(arc_home): + """A rename lands in the local sidebar first; the sync blob can lag.""" + import json + + path = arc_home / "Library/Application Support/Arc/StorableSidebar.json" + data = json.loads(path.read_text(encoding="utf-8")) + models = data["firebaseSyncState"]["syncData"]["spaceModels"] + models[1]["value"]["title"] = "Stale Name" + path.write_text(json.dumps(data), encoding="utf-8") + + from extractors import ArcExtractor + + assert ArcExtractor().extract().spaces[0].space_name == "Test Space" + + def test_arc_extractor_legacy_dict(arc_home): from extractors import ArcExtractor From 7c975b0245e1baa15df717e5a3f7f74d851f3e27 Mon Sep 17 00:00:00 2001 From: Tarik Caramanico Date: Mon, 14 Sep 2026 18:47:46 -0300 Subject: [PATCH 2/3] fix(chromium): detect an install whose profile has no bookmarks yet `is_installed` required a profile directory holding a `Bookmarks` file, but Chromium writes that file only after the first bookmark change. A profile that has been browsed and never bookmarked has History, Cookies, Favicons and Preferences and no Bookmarks, so a real, working Chrome install reported as "not installed" (Reddit report: "It's showing that I don't have Google Chrome installed, but I do"). Verified by launching Chrome against a throwaway --user-data-dir: the fresh profile has History and Preferences and no Bookmarks. `profile_paths` already accepted `Bookmarks` or `History`, so the two disagreed. `is_installed` now delegates to `profile_paths` and cannot drift from it again. Root selection keeps the anti-shadowing behaviour it was protecting. `_root_has_profile` becomes `_root_profile_rank`: a root with bookmarks outranks one with only history, which outranks an empty leftover install dir. A launched-once-then-abandoned Brave-Browser still loses to a live Brave-Origin, and an empty install dir still reports not installed. --- src/extractors/chromium.py | 75 +++++++++++++++++++++++++------------- tests/test_extractors.py | 48 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 26 deletions(-) diff --git a/src/extractors/chromium.py b/src/extractors/chromium.py index 4634f22..9acfe77 100644 --- a/src/extractors/chromium.py +++ b/src/extractors/chromium.py @@ -49,25 +49,40 @@ _NS_SPACE = uuid.UUID("0ad3a9d9-95c0-4de1-87c1-2c41a9a3b1aa") -def _root_has_profile(root: Path) -> bool: - """A Chromium User Data root is live if it holds a profile dir with a - ``Bookmarks`` file. An empty leftover install dir (e.g. an abandoned - ``Brave-Browser`` next to a used ``Brave-Origin``) must not shadow the - real one. - - This predicate has to stay in sync with ``is_installed`` — pick a root - that ``is_installed`` then rejects and the browser reports "not found" - anyway. In particular ``Local State`` alone is NOT enough: an install - that was launched once and then abandoned has one, and would shadow - the root the user actually uses. +def _root_profile_rank(root: Path) -> int: + """Rank a Chromium User Data root by how much profile data it holds. + + ``2`` — a profile with a ``Bookmarks`` file. Chromium writes that only + once the bookmark model first saves, so it marks a root the user has + really used. + + ``1`` — a profile with a ``History`` file. Every profile that has been + launched has one, including a brand-new install that has never + bookmarked anything. Such an install is still installed, and is still + worth migrating: it has history, cookies and open tabs. + + ``0`` — no profile data at all. An empty leftover install dir (an + abandoned ``Brave-Browser`` next to a used ``Brave-Origin``) lands + here and must not shadow the real root. ``Local State`` alone is NOT + enough to rank above this: a launched-once-then-abandoned install has + one. + + Ranks 1 and 2 both count as installed, which keeps this in sync with + ``profile_paths`` and ``is_installed``. The rank only decides which + root wins when several exist. """ + best = 0 try: - return any( - entry.is_dir() and (entry / "Bookmarks").is_file() - for entry in root.iterdir() - ) + for entry in root.iterdir(): + if not entry.is_dir(): + continue + if (entry / "Bookmarks").is_file(): + return 2 + if (entry / "History").is_file(): + best = 1 except OSError: - return False + return 0 + return best class ChromiumExtractor(BrowserExtractor): @@ -126,20 +141,28 @@ def _user_data_dir(self) -> Path | None: else: paths = self._linux_user_data_paths(home) existing = [p for p in paths if p.is_dir()] - # Prefer the first root that actually holds profile data, so a - # leftover empty install dir can't shadow the one the user really - # uses. Fall back to the first existing root, then to None. + # Prefer the root holding the most profile data, so a leftover + # install dir can't shadow the one the user really uses. The first + # candidate wins a tie, matching the declared candidate order. + # Fall back to the first existing root, then to None. + best_root = None + best_rank = 0 for root in existing: - if _root_has_profile(root): - return root + rank = _root_profile_rank(root) + if rank > best_rank: + best_root, best_rank = root, rank + if best_root is not None: + return best_root return existing[0] if existing else None def is_installed(self) -> bool: - root = self._user_data_dir() - if root is None: - return False - # Need at least one profile dir with a Bookmarks file. - return any((p / "Bookmarks").is_file() for p in root.iterdir() if p.is_dir()) + # Delegate so this can never disagree with ``profile_paths``. + # Requiring a ``Bookmarks`` file here used to report a real Chrome + # install as "not installed": Chromium writes that file only after + # the first bookmark change, so a profile that has been browsed but + # never bookmarked has History, Cookies and Preferences but no + # Bookmarks. + return bool(self.profile_paths()) def profile_paths(self) -> list[Path]: root = self._user_data_dir() diff --git a/tests/test_extractors.py b/tests/test_extractors.py index f1517f1..40090ea 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -140,6 +140,54 @@ def test_chrome_cookie_db_paths(chrome_home): assert any(p.name == "Cookies" for p in paths) +def test_chrome_detected_without_a_bookmarks_file(tmp_path, monkeypatch): + """Chromium writes ``Bookmarks`` only after the first bookmark change. + + A profile that has been browsed but never bookmarked has History, + Cookies and Preferences and no Bookmarks. Requiring Bookmarks reported + a real install as "not installed" (Reddit report: "It's showing that I + don't have Google Chrome installed, but I do"). + """ + import sys + from pathlib import Path + + from extractors import ChromeExtractor + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setattr(sys, "platform", "darwin") + + profile = home / "Library/Application Support/Google/Chrome/Default" + profile.mkdir(parents=True) + (profile / "History").write_bytes(b"") + (profile / "Preferences").write_text("{}", encoding="utf-8") + assert not (profile / "Bookmarks").exists() + + ext = ChromeExtractor() + assert ext.is_installed() is True + assert [p.name for p in ext.profile_paths()] == ["Default"] + + +def test_chrome_not_detected_when_no_profile_data(tmp_path, monkeypatch): + """An install dir with no profile data still reports not installed.""" + import sys + from pathlib import Path + + from extractors import ChromeExtractor + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setattr(sys, "platform", "darwin") + + root = home / "Library/Application Support/Google/Chrome" + root.mkdir(parents=True) + (root / "Local State").write_text("{}", encoding="utf-8") + + assert ChromeExtractor().is_installed() is False + + def test_chromium_quit_escalates_to_force_kill(monkeypatch): """When a graceful quit is ignored, quit() must escalate to a forced kill rather than just timing out (regression: Brave wouldn't quit).""" From ddfebd336eab041b617dab75544b09577060cb5c Mon Sep 17 00:00:00 2001 From: Tarik Caramanico Date: Mon, 14 Sep 2026 19:06:42 -0300 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.2.7=20=E2=80=94=20Arc?= =?UTF-8?q?=20space=20names=20on=20unsynced=20machines,=20Chrome=20false?= =?UTF-8?q?=20'not=20installed'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/__version__.py b/app/__version__.py index e564180..9e3871f 100644 --- a/app/__version__.py +++ b/app/__version__.py @@ -9,4 +9,4 @@ git tag triggers the GitHub Actions release workflow. """ -VERSION = "1.2.6" +VERSION = "1.2.7"