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" 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/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 6628f63..40090ea 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 @@ -91,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)."""