Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
git tag triggers the GitHub Actions release workflow.
"""

VERSION = "1.2.6"
VERSION = "1.2.7"
158 changes: 102 additions & 56 deletions src/arc_pinned_tab_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uuid>``. 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]:
Expand Down
75 changes: 49 additions & 26 deletions src/extractors/chromium.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down
97 changes: 97 additions & 0 deletions tests/test_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uuid>``.
"""
_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

Expand Down Expand Up @@ -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)."""
Expand Down
Loading