diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 0797387..806eda5 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -22,6 +22,7 @@ body: description: Which browser were you migrating from? options: - Arc + - Dia - Chrome - Microsoft Edge - Brave diff --git a/CLAUDE.md b/CLAUDE.md index 8b72b65..bb62e43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ working on browser2zen. ## What this is A migration tool that moves your browser setup — workspaces, pinned -tabs, bookmarks, history, login state — from Arc / Chrome / Edge / +tabs, bookmarks, history, login state — from Arc / Dia / Chrome / Edge / Brave / Firefox / Safari into [Zen Browser](https://zen-browser.app/). The user-facing surface is a single-window PyWebView app. Power users @@ -46,6 +46,7 @@ BrowserExtractor.extract() → ExportData → to_legacy_dict() | `chromium.py` | _shared base_ | Profile discovery, Bookmarks-JSON walk, SNSS session read (`_snss.py`), Chromium SQLite paths, cookie-key dispatch | | `_snss.py` | _shared base_ | Parses Chromium's binary SessionService file (`/Sessions/Session_*`) to recover the real tab-strip **pinned + open tabs**. Bookmarks are NOT pinned tabs | | `chrome.py`, `edge.py`, `brave.py` | Chrome / Edge / Brave | Subclass `ChromiumExtractor`; only paths + Keychain service / process names differ | +| `dia.py` | Dia | Subclasses `ChromiumExtractor`. Dia keeps Spaces and pinned tabs in an encrypted `tabs.db`, so they are read live over AppleScript (`profiles` of a window are Dia's Spaces) and snapshotted before `quit()`; falls back to SNSS when Dia is not running. The snapshot is dropped if Dia's session files change after the quit. Space ids derive from the space name (Dia's AppleScript exposes no stable id), so renaming a space creates a new Zen workspace on a re-run | | `firefox.py` | Firefox | Reads `places.sqlite` `moz_bookmarks` directly; v1 is bookmarks-only (history+cookies need a Firefox→Firefox merger) | | `safari.py` | Safari | Parses `Bookmarks.plist`; bookmarks-only; surfaces a clean error code if Full Disk Access is missing on Sequoia | @@ -63,6 +64,10 @@ conflated, or you flood the sidebar with every bookmark. When an extractor leaves `bookmarks`/`bookmark_folders` as `None`, `to_legacy_dict()` falls them back to `pinned_tabs`/`folders`, so bookmark-only sources (Arc/Firefox/Safari) are unaffected. +`ExportData.profile_bookmarks` / `profile_bookmark_folders` hold +bookmarks that belong to no space (Dia's Chromium profile); +`to_legacy_dict()` attaches them to the first space left after the +Preview screen's exclusions, so unticking a space never drops them. ### Zen-side writers (`src/zen_*.py`) @@ -141,7 +146,7 @@ wires the chosen extractor's paths through. (`window.pywebview.api.`). Source picker, backups, preview, start/cancel migration. - `app/window.py` — PyWebView frameless-with-vibrancy window setup. -- `app/frontend/` — vanilla HTML+CSS+JS, no build step. Six brand +- `app/frontend/` — vanilla HTML+CSS+JS, no build step. Seven brand badges + Zen badge live under `assets/sources/`. ### Adding a new source diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 426a6ab..a8add7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ and prints log output to your terminal. - `app/` is the GUI. `app.orchestrator.MigrationOrchestrator` is the single migration entry point; `app.bridge.Bridge` exposes it to JavaScript via `window.pywebview.api.`. -- `src/extractors/` is one module per source browser (Arc, Chrome, Edge, +- `src/extractors/` is one module per source browser (Arc, Dia, Chrome, Edge, Brave, Firefox, Safari) implementing a small `BrowserExtractor` ABC. - `src/zen_*.py` are the source-agnostic Zen-side writers. - `src/chromium_*.py` are the generic Chromium-format readers (history, diff --git a/README.md b/README.md index 0cd8764..b1cc61a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Latest release](https://img.shields.io/github/v/release/tarikbc/browser2zen?display_name=tag&label=release)](./releases/latest) Move any browser to [Zen Browser](https://zen-browser.app/) in under a -minute. **Arc, Chrome, Edge, Brave, Firefox, and Safari** are all +minute. **Arc, Dia, Chrome, Edge, Brave, Firefox, and Safari** are all supported as sources. Workspaces, pinned tabs, bookmarks, browsing history, and login state come across. @@ -206,8 +206,8 @@ and writes to its corresponding Zen file with a backup taken first. | --- | --- | --- | | Spaces & pinned tabs | source bookmarks (`StorableSidebar.json` / Chromium `Bookmarks` / `places.sqlite` / `Bookmarks.plist`) | `zen-sessions.jsonlz4`, `containers.json` | | Bookmarks | same as above | `places.sqlite` | -| Favicons | Chromium `Favicons` SQLite (Arc/Chrome/Edge/Brave only) | `favicons.sqlite`, plus inline `image` data URIs in `zen-sessions.jsonlz4` | -| History | Chromium `History` SQLite (Arc/Chrome/Edge/Brave only) | `places.sqlite` | +| Favicons | Chromium `Favicons` SQLite (Arc/Dia/Chrome/Edge/Brave only) | `favicons.sqlite`, plus inline `image` data URIs in `zen-sessions.jsonlz4` | +| History | Chromium `History` SQLite (Arc/Dia/Chrome/Edge/Brave only) | `places.sqlite` | | Cookies | Chromium `Cookies` SQLite (AES-128-CBC on macOS, AES-256-GCM on Windows) | `cookies.sqlite` (incl. per-space containers) | Firefox and Safari are bookmarks-only in v1 — their history and cookies diff --git a/app/bridge.py b/app/bridge.py index fa3c00b..265c141 100644 --- a/app/bridge.py +++ b/app/bridge.py @@ -94,7 +94,8 @@ def set_source(self, name: str) -> dict: cls = by_name(name) except KeyError: return {"ok": False, "error": f"unknown source: {name!r}"} - inst = cls() + current = self.orchestrator.source + inst = current if type(current) is cls else cls() self.orchestrator = MigrationOrchestrator(source=inst) # Reset run state — the new source means a fresh detect/preview. with self._lock: diff --git a/app/frontend/app.js b/app/frontend/app.js index edaee92..eb422d1 100644 --- a/app/frontend/app.js +++ b/app/frontend/app.js @@ -116,7 +116,7 @@ function setLoading(btn, on, label = null) { // small — 32 px, used in the source picker + detect cards // tiny — 22 px, used inline (e.g. progress meta) -const SOURCE_NAMES = ["arc", "chrome", "edge", "brave", "firefox", "safari"]; +const SOURCE_NAMES = ["arc", "dia", "chrome", "edge", "brave", "firefox", "safari"]; function makeBrowserMark(name, size = "default") { const wrap = document.createElement("span"); diff --git a/app/frontend/assets/sources/dia.svg b/app/frontend/assets/sources/dia.svg new file mode 100644 index 0000000..64c104f --- /dev/null +++ b/app/frontend/assets/sources/dia.svg @@ -0,0 +1 @@ + diff --git a/app/frontend/styles.css b/app/frontend/styles.css index 34058a1..3173bf5 100644 --- a/app/frontend/styles.css +++ b/app/frontend/styles.css @@ -290,6 +290,8 @@ body[data-platform="mac"] main { primary brand identity — kept gentle so the icon stays the focus. */ .brand-mark.arc { background: linear-gradient(160deg, #FFF7F2 0%, #FFE9F0 60%, #FFD8E5 100%); box-shadow: 0 4px 14px rgba(214, 60, 110, 0.22), inset 0 0 0 1px rgba(255,255,255,0.6); } +.brand-mark.dia { background: linear-gradient(160deg, #FFFBEF 0%, #FFEFC4 100%); + box-shadow: 0 4px 14px rgba(245, 180, 20, 0.24), inset 0 0 0 1px rgba(255,255,255,0.6); } .brand-mark.chrome { background: linear-gradient(160deg, #FFFFFF 0%, #F4F8FB 100%); box-shadow: 0 4px 14px rgba(66, 133, 244, 0.22), inset 0 0 0 1px rgba(255,255,255,0.6); } .brand-mark.edge { background: linear-gradient(160deg, #E5F6FB 0%, #CDE9F2 100%); diff --git a/app/orchestrator.py b/app/orchestrator.py index 3d1a306..118da1e 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -226,6 +226,9 @@ def preview(self, opts: MigrationOptions) -> PreviewReport: if t.url: all_urls.append(t.url) + if spaces: + bookmark_total += len(data.profile_bookmarks) + # Cheap estimates for the heavy steps. Real counts emerge during the # run; the Preview screen just uses these for UX. favicon_match_estimate = self._estimate_favicons(all_urls) diff --git a/build/browser2zen.spec b/build/browser2zen.spec index 84533f8..98cf418 100644 --- a/build/browser2zen.spec +++ b/build/browser2zen.spec @@ -109,6 +109,7 @@ a = Analysis( "extractors.brave", "extractors.chrome", "extractors.chromium", + "extractors.dia", "extractors.edge", "extractors.firefox", "extractors.safari", @@ -172,6 +173,8 @@ if IS_MAC: "LSApplicationCategoryType": "public.app-category.utilities", "LSMinimumSystemVersion": "12.0", "NSSupportsAutomaticTermination": True, + "NSAppleEventsUsageDescription": + "browser2zen reads your Dia spaces and tabs to migrate them, and quits browsers before migrating.", "NSDesktopFolderUsageDescription": "browser2zen reads source-browser cookies from your library to migrate login state.", }, diff --git a/src/extractors/__init__.py b/src/extractors/__init__.py index 87f99ed..1096d35 100644 --- a/src/extractors/__init__.py +++ b/src/extractors/__init__.py @@ -13,6 +13,7 @@ ) from .brave import BraveExtractor from .chrome import ChromeExtractor +from .dia import DiaExtractor from .edge import EdgeExtractor from .firefox import FirefoxExtractor from .safari import SafariExtractor @@ -20,6 +21,7 @@ # Order matters: this is the order the source picker presents them. EXTRACTORS: tuple[type[BrowserExtractor], ...] = ( ArcExtractor, + DiaExtractor, ChromeExtractor, EdgeExtractor, BraveExtractor, @@ -41,6 +43,7 @@ def by_name(name: str) -> type[BrowserExtractor]: "BrowserExtractor", "BrowserExtractorError", "ChromeExtractor", + "DiaExtractor", "EXTRACTORS", "EdgeExtractor", "ExportData", diff --git a/src/extractors/base.py b/src/extractors/base.py index 6522bd2..968a935 100644 --- a/src/extractors/base.py +++ b/src/extractors/base.py @@ -29,7 +29,7 @@ import logging import os from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any, Optional @@ -139,6 +139,8 @@ class ExportData: """Unified intermediate representation handed to the Zen-side writers.""" source: str # "arc", "chrome", "edge", "brave", "firefox", "safari" spaces: list[SpaceRecord] = field(default_factory=list) + profile_bookmarks: list[TabRecord] = field(default_factory=list) + profile_bookmark_folders: list[FolderRecord] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: return { @@ -156,7 +158,15 @@ def to_legacy_dict(self) -> dict[str, Any]: new sources — every extractor lowers to this single shape. """ spaces_out: list[dict[str, Any]] = [] - for s in self.spaces: + for position, s in enumerate(self.spaces): + own_bookmarks = s.bookmarks if s.bookmarks is not None else s.pinned_tabs + own_bookmark_folders = s.bookmark_folders if s.bookmark_folders is not None else s.folders + is_first = position == 0 + space_bookmarks = [*own_bookmarks, *(self.profile_bookmarks if is_first else [])] + space_bookmark_folders = [ + *own_bookmark_folders, + *(replace(f, space_id=s.space_id) for f in (self.profile_bookmark_folders if is_first else [])), + ] spaces_out.append({ "space_id": s.space_id, "space_name": s.space_name, @@ -213,9 +223,7 @@ def to_legacy_dict(self) -> dict[str, Any]: "index": idx, "is_essential": t.is_essential, } - for idx, t in enumerate( - s.bookmarks if s.bookmarks is not None else s.pinned_tabs - ) + for idx, t in enumerate(space_bookmarks) ], "bookmark_folders": [ { @@ -226,8 +234,7 @@ def to_legacy_dict(self) -> dict[str, Any]: "children_ids": list(f.children_ids), "index": f.index, } - for f in (s.bookmark_folders - if s.bookmark_folders is not None else s.folders) + for f in space_bookmark_folders ], }) return { diff --git a/src/extractors/chromium.py b/src/extractors/chromium.py index 9acfe77..0852f34 100644 --- a/src/extractors/chromium.py +++ b/src/extractors/chromium.py @@ -1,5 +1,5 @@ """ -Shared base for Chromium-family browsers (Chrome, Edge, Brave). +Shared base for Chromium-family browsers (Chrome, Edge, Brave, Dia). What's common across the family: - profiles live under a single "User Data" root, diff --git a/src/extractors/dia.py b/src/extractors/dia.py new file mode 100644 index 0000000..3b11bfe --- /dev/null +++ b/src/extractors/dia.py @@ -0,0 +1,195 @@ +"""Dia browser extractor.""" + +from __future__ import annotations + +import logging +import subprocess +import time +import uuid +from pathlib import Path + +from .base import BrowserExtractorError, ExportData, SpaceRecord, TabRecord +from .chromium import ChromiumExtractor + +logger = logging.getLogger(__name__) + +_NS_DIA_SPACE = uuid.UUID("7c1f0b52-3d0e-4f7a-9a4e-5f0d6b8e2a91") +_FIELD_SEP = "\x1f" +_ROW_SEP = "\x1e" +_MIGRATABLE_SCHEMES = ("http://", "https://", "ftp://") + +_LIVE_SCRIPT = """ +on clean(value) + set AppleScript's text item delimiters to {character id 30, character id 31} + set parts to text items of value + set AppleScript's text item delimiters to " " + set cleaned to parts as text + set AppleScript's text item delimiters to "" + return cleaned +end clean + +set fieldSep to character id 31 +set rowSep to character id 30 +set rows to {} +if application "Dia" is running then + tell application "Dia" + repeat with w in windows + repeat with p in profiles of w + set spaceName to my clean(name of p as text) + repeat with t in tabs of p + try + set tabUrl to URL of t + set tabTitle to title of t + if tabUrl is missing value then set tabUrl to "" + if tabTitle is missing value then set tabTitle to "" + set tabUrl to tabUrl as text + set tabTitle to my clean(tabTitle as text) + set pinnedText to isPinned of t as text + set end of rows to spaceName & fieldSep & pinnedText & fieldSep & tabUrl & fieldSep & tabTitle + end try + end repeat + end repeat + end repeat + end tell +end if +set AppleScript's text item delimiters to rowSep +return rows as text +""" + + +def _space_id(name: str) -> str: + return str(uuid.uuid5(_NS_DIA_SPACE, name)) + + +def parse_live_spaces(output: str) -> list[SpaceRecord]: + order: list[str] = [] + pinned: dict[str, list[TabRecord]] = {} + opened: dict[str, list[TabRecord]] = {} + seen: set[tuple[str, str, bool]] = set() + + for row in output.strip("\r\n").split(_ROW_SEP): + parts = row.split(_FIELD_SEP, 3) + if len(parts) != 4: + continue + space, is_pinned_text, url, title = parts + is_pinned = is_pinned_text.strip() == "true" + if space not in pinned: + order.append(space) + pinned[space] = [] + opened[space] = [] + key = (space, url, is_pinned) + if key in seen or not url.lower().startswith(_MIGRATABLE_SCHEMES): + continue + seen.add(key) + target = pinned if is_pinned else opened + target[space] = [*target[space], TabRecord(url=url, title=title)] + + return [ + SpaceRecord( + space_id=_space_id(name), + space_name=name, + pinned_tabs=pinned[name], + open_tabs=opened[name], + bookmarks=[], + bookmark_folders=[], + ) + for name in order + if pinned[name] or opened[name] + ] + + +class DiaExtractor(ChromiumExtractor): + name = "dia" + display_name = "Dia" + + user_data_dirs_macos = ( + "Library/Application Support/Dia/User Data", + ) + + keychain_service = "Dia Safe Storage" + keychain_account = "Dia" + + macos_app_name = "Dia" + macos_process_paths = ("Dia.app/Contents/MacOS/Dia",) + + live_read_attempts = 5 + live_read_retry_delay = 1.0 + session_write_slack = 2.0 + + def __init__(self) -> None: + super().__init__() + self._live_spaces: list[SpaceRecord] = [] + self._quit_at: float | None = None + + def _run_live_script(self) -> str | None: + try: + result = subprocess.run( + ["osascript", "-e", _LIVE_SCRIPT], + capture_output=True, text=True, timeout=30, + ) + except Exception as exc: + logger.warning("reading Dia spaces over AppleScript failed: %s", exc) + return None + if result.returncode != 0: + logger.warning("reading Dia spaces over AppleScript failed: %s", + result.stderr.strip()) + return None + return result.stdout + + def _snapshot_live_spaces(self) -> None: + if not self.is_running(): + return + self._live_spaces = [] + self._quit_at = None + for attempt in range(self.live_read_attempts): + output = self._run_live_script() + if output is None: + return + spaces = parse_live_spaces(output) + if spaces: + self._live_spaces = spaces + return + if attempt + 1 < self.live_read_attempts: + time.sleep(self.live_read_retry_delay) + logger.warning("Dia is running but reported no spaces over AppleScript") + + def _latest_session_write(self) -> float: + sessions = [ + entry + for profile in self.profile_paths() + for entry in (Path(profile) / "Sessions").glob("*") + if entry.is_file() + ] + return max((entry.stat().st_mtime for entry in sessions), default=0.0) + + def _drop_snapshot_if_dia_ran_again(self) -> None: + if self._quit_at is None or self.is_running(): + return + if self._latest_session_write() > self._quit_at + self.session_write_slack: + logger.info("Dia ran again after the spaces were read; dropping the snapshot") + self._live_spaces = [] + self._quit_at = None + + def quit(self) -> dict: + self._snapshot_live_spaces() + result = super().quit() + if self._live_spaces and not self.is_running(): + self._quit_at = time.time() + return result + + def extract(self) -> ExportData: + self._drop_snapshot_if_dia_ran_again() + self._snapshot_live_spaces() + if not self._live_spaces: + return super().extract() + try: + session_spaces = super().extract().spaces + except BrowserExtractorError: + session_spaces = [] + + return ExportData( + source=self.name, + spaces=list(self._live_spaces), + profile_bookmarks=[b for s in session_spaces for b in (s.bookmarks or [])], + profile_bookmark_folders=[f for s in session_spaces for f in (s.bookmark_folders or [])], + ) diff --git a/tests/test_dia_spaces.py b/tests/test_dia_spaces.py new file mode 100644 index 0000000..f5a0760 --- /dev/null +++ b/tests/test_dia_spaces.py @@ -0,0 +1,284 @@ +"""Dia spaces and pinned tabs read live over AppleScript.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +US = "\x1f" +RS = "\x1e" + + +def _row(space, pinned, url, title): + return US.join([space, "true" if pinned else "false", url, title]) + + +LIVE_OUTPUT = RS.join([ + _row("Casual", True, "https://music.example.com/", "Music"), + _row("Work", True, "https://work.example.com/", "Tracker"), + _row("Work", False, "https://example.com/project", "project\twith a tab"), + _row("Work", True, "https://work.example.com/", "Tracker"), + _row("Work", False, "https://example.com/project", "duplicate from window 2"), + _row("Work", False, "https://example.com/second-window", "Second window"), + _row("School", True, "chrome://newtab/", "New Tab"), + _row("School", True, "https://draw.example.com/", "Draw"), +]) + +LIVE_SPACE_NAMES = ["Casual", "Work", "School"] + + +@pytest.fixture +def dia_home(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + profile = tmp_path / "Library/Application Support/Dia/User Data/Default" + profile.mkdir(parents=True) + (profile / "History").write_bytes(b"") + (profile / "Bookmarks").write_text(json.dumps({ + "roots": { + "bookmark_bar": { + "type": "folder", + "name": "Bookmarks bar", + "children": [ + {"type": "url", "name": "Zen", "url": "https://zen-browser.app/"}, + ], + }, + }, + })) + return tmp_path + + +def test_parse_live_spaces_groups_tabs_by_space_in_order(): + from extractors.dia import parse_live_spaces + + spaces = parse_live_spaces(LIVE_OUTPUT) + + assert [s.space_name for s in spaces] == LIVE_SPACE_NAMES + work = spaces[1] + assert [t.url for t in work.pinned_tabs] == ["https://work.example.com/"] + assert [t.url for t in work.open_tabs] == [ + "https://example.com/project", + "https://example.com/second-window", + ] + assert work.open_tabs[0].title == "project\twith a tab" + + +def test_parse_live_spaces_drops_internal_urls(): + from extractors.dia import parse_live_spaces + + school = parse_live_spaces(LIVE_OUTPUT)[2] + + assert [t.url for t in school.pinned_tabs] == ["https://draw.example.com/"] + + +def test_parse_live_spaces_gives_stable_distinct_space_ids(): + from extractors.dia import parse_live_spaces + + first = [s.space_id for s in parse_live_spaces(LIVE_OUTPUT)] + second = [s.space_id for s in parse_live_spaces(LIVE_OUTPUT)] + + assert first == second + assert len(set(first)) == len(first) + + +def test_parse_live_spaces_handles_empty_output(): + from extractors.dia import parse_live_spaces + + assert parse_live_spaces("") == [] + assert parse_live_spaces("\n") == [] + + +def test_extract_uses_live_spaces_while_dia_runs(dia_home, monkeypatch): + from extractors import DiaExtractor + + ext = DiaExtractor() + monkeypatch.setattr(ext, "is_running", lambda: True) + monkeypatch.setattr(ext, "_run_live_script", lambda: LIVE_OUTPUT) + + data = ext.extract() + + assert [s.space_name for s in data.spaces] == LIVE_SPACE_NAMES + assert sum(len(s.pinned_tabs) for s in data.spaces) == 3 + assert [t.url for t in data.profile_bookmarks] == ["https://zen-browser.app/"] + assert all(s.bookmarks == [] for s in data.spaces) + + +def test_extract_reuses_snapshot_after_quit(dia_home, monkeypatch): + from extractors import DiaExtractor + + ext = DiaExtractor() + running = {"value": True} + monkeypatch.setattr(ext, "is_running", lambda: running["value"]) + monkeypatch.setattr(ext, "_run_live_script", lambda: LIVE_OUTPUT) + ext.extract() + + running["value"] = False + monkeypatch.setattr(ext, "_run_live_script", lambda: pytest.fail("Dia is not running")) + data = ext.extract() + + assert [s.space_name for s in data.spaces] == LIVE_SPACE_NAMES + + +def test_quit_snapshots_spaces_before_quitting(dia_home, monkeypatch): + from extractors import DiaExtractor + from extractors.chromium import ChromiumExtractor + + ext = DiaExtractor() + running = {"value": True} + monkeypatch.setattr(ext, "is_running", lambda: running["value"]) + monkeypatch.setattr(ext, "_run_live_script", lambda: LIVE_OUTPUT) + + def fake_quit(self): + running["value"] = False + return {"ok": True, "running": False, "elapsed": 0.0} + + monkeypatch.setattr(ChromiumExtractor, "quit", fake_quit) + assert ext.quit()["ok"] + + data = ext.extract() + assert [s.space_name for s in data.spaces] == LIVE_SPACE_NAMES + + +def test_extract_falls_back_to_session_when_live_read_fails(dia_home, monkeypatch): + from extractors import DiaExtractor + + ext = DiaExtractor() + ext.live_read_retry_delay = 0 + calls = [] + monkeypatch.setattr(ext, "is_running", lambda: True) + monkeypatch.setattr(ext, "_run_live_script", lambda: calls.append(1)) + + data = ext.extract() + + assert len(calls) == 1 + assert [s.space_name for s in data.spaces] == ["Default"] + + +def test_live_spaces_do_not_mirror_pinned_tabs_into_bookmarks(dia_home, monkeypatch): + from extractors import DiaExtractor + + (dia_home / "Library/Application Support/Dia/User Data/Default/Bookmarks").unlink() + ext = DiaExtractor() + monkeypatch.setattr(ext, "is_running", lambda: True) + monkeypatch.setattr(ext, "_run_live_script", lambda: LIVE_OUTPUT) + + legacy = ext.extract().to_legacy_dict() + + assert all(not s.get("bookmarks") for s in legacy["spaces"]) + + +def test_extract_waits_for_dia_to_restore_its_windows(dia_home, monkeypatch): + from extractors import DiaExtractor + + ext = DiaExtractor() + ext.live_read_retry_delay = 0 + outputs = iter(["", "", LIVE_OUTPUT]) + monkeypatch.setattr(ext, "is_running", lambda: True) + monkeypatch.setattr(ext, "_run_live_script", lambda: next(outputs)) + + data = ext.extract() + + assert [s.space_name for s in data.spaces] == LIVE_SPACE_NAMES + + +def test_extract_gives_up_after_bounded_retries(dia_home, monkeypatch): + from extractors import DiaExtractor + + ext = DiaExtractor() + ext.live_read_retry_delay = 0 + calls = [] + monkeypatch.setattr(ext, "is_running", lambda: True) + monkeypatch.setattr(ext, "_run_live_script", lambda: calls.append(1) or "") + + data = ext.extract() + + assert len(calls) == ext.live_read_attempts + assert [s.space_name for s in data.spaces] == ["Default"] + + +def test_parse_live_spaces_keeps_a_last_tab_with_an_empty_title(): + from extractors.dia import parse_live_spaces + + output = _row("Solo", False, "https://example.com/loading", "") + "\n" + + spaces = parse_live_spaces(output) + + assert [t.url for s in spaces for t in s.open_tabs] == ["https://example.com/loading"] + + +def test_failed_live_read_after_running_drops_the_previous_snapshot(dia_home, monkeypatch): + from extractors import DiaExtractor + + ext = DiaExtractor() + ext.live_read_retry_delay = 0 + monkeypatch.setattr(ext, "is_running", lambda: True) + monkeypatch.setattr(ext, "_run_live_script", lambda: LIVE_OUTPUT) + ext.extract() + + monkeypatch.setattr(ext, "_run_live_script", lambda: "") + data = ext.extract() + + assert [s.space_name for s in data.spaces] == ["Default"] + + +def test_repicking_the_same_source_keeps_its_snapshot(): + from app.bridge import Bridge + + bridge = Bridge() + bridge.set_source("dia") + first = bridge.orchestrator.source + bridge.set_source("dia") + assert bridge.orchestrator.source is first + + bridge.set_source("chrome") + assert bridge.orchestrator.source.name == "chrome" + + +def test_parse_live_spaces_tolerates_separators_stripped_by_the_script(): + from extractors.dia import parse_live_spaces + + output = _row("Space", False, "https://example.com/a", "Title one") + RS + "\n" + + assert [t.title for s in parse_live_spaces(output) for t in s.open_tabs] == ["Title one"] + + +def test_live_script_replaces_separator_characters_in_text(): + from extractors.dia import _LIVE_SCRIPT + + assert "on clean(" in _LIVE_SCRIPT + assert "my clean(name of p" in _LIVE_SCRIPT + assert "my clean(tabTitle as text)" in _LIVE_SCRIPT + + +def test_snapshot_is_dropped_when_dia_ran_again_after_quitting(dia_home, monkeypatch): + import os + import time as time_module + + from extractors import DiaExtractor + from extractors.chromium import ChromiumExtractor + + session_dir = dia_home / "Library/Application Support/Dia/User Data/Default/Sessions" + session_dir.mkdir() + session = session_dir / "Session_1" + session.write_bytes(b"SNSS") + + ext = DiaExtractor() + running = {"value": True} + monkeypatch.setattr(ext, "is_running", lambda: running["value"]) + monkeypatch.setattr(ext, "_run_live_script", lambda: LIVE_OUTPUT) + + def fake_quit(self): + running["value"] = False + return {"ok": True, "running": False, "elapsed": 0.0} + + monkeypatch.setattr(ChromiumExtractor, "quit", fake_quit) + ext.quit() + assert [s.space_name for s in ext.extract().spaces] == LIVE_SPACE_NAMES + + later = time_module.time() + 120 + os.utime(session, (later, later)) + + assert [s.space_name for s in ext.extract().spaces] == ["Default"] diff --git a/tests/test_extractors.py b/tests/test_extractors.py index 40090ea..ec5ee20 100644 --- a/tests/test_extractors.py +++ b/tests/test_extractors.py @@ -408,6 +408,53 @@ def test_edge_extractor_registered(): assert by_name("edge") is EdgeExtractor +def test_dia_extractor_registered(): + from extractors import EXTRACTORS, DiaExtractor, by_name + assert by_name("dia") is DiaExtractor + assert EXTRACTORS.index(DiaExtractor) == 1 + + +def test_dia_extractor_reads_macos_user_data(tmp_path, monkeypatch): + import json + import sys + from pathlib import Path + + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + profile = tmp_path / "Library/Application Support/Dia/User Data/Default" + profile.mkdir(parents=True) + (profile / "Bookmarks").write_text(json.dumps({ + "roots": { + "bookmark_bar": { + "type": "folder", + "name": "Bookmarks bar", + "children": [ + {"type": "url", "name": "Zen", "url": "https://zen-browser.app/"}, + ], + }, + "other": {"type": "folder", "name": "Other", "children": []}, + "synced": {"type": "folder", "name": "Mobile", "children": []}, + }, + })) + + from extractors import DiaExtractor + + ext = DiaExtractor() + monkeypatch.setattr(ext, "is_running", lambda: False) + assert ext.is_installed() + data = ext.extract() + assert data.source == "dia" + urls = [t.url for s in data.spaces for t in (s.bookmarks or [])] + assert "https://zen-browser.app/" in urls + + +def test_dia_extractor_uses_dia_keychain_and_process(): + from extractors import DiaExtractor + assert DiaExtractor.keychain_service == "Dia Safe Storage" + assert DiaExtractor.keychain_account == "Dia" + assert DiaExtractor.macos_process_paths == ("Dia.app/Contents/MacOS/Dia",) + + def test_brave_extractor_registered(): from extractors import BraveExtractor, by_name assert by_name("brave") is BraveExtractor diff --git a/tests/test_legacy_dict.py b/tests/test_legacy_dict.py index d503c6a..6eed5c4 100644 --- a/tests/test_legacy_dict.py +++ b/tests/test_legacy_dict.py @@ -95,3 +95,42 @@ def test_explicit_bookmarks_stay_separate_from_pinned_tabs(): assert [t["url"] for t in legacy["pinned_tabs"]] == ["https://pin.example/"] assert [t["url"] for t in legacy["bookmarks"]] == ["https://bm.example/"] assert [f["title"] for f in legacy["bookmark_folders"]] == ["BF"] + + +def _profile_bookmark_export(space_names): + from extractors.base import ExportData, FolderRecord, SpaceRecord, TabRecord + + folder = FolderRecord(folder_id="f1", title="Reading", space_id="chromium-profile") + return ExportData( + source="x", + spaces=[ + SpaceRecord(space_id=f"id-{n}", space_name=n, bookmarks=[], bookmark_folders=[]) + for n in space_names + ], + profile_bookmarks=[TabRecord(url="https://example.com/", title="Example", folder_id="f1")], + profile_bookmark_folders=[folder], + ) + + +def test_profile_bookmarks_land_on_the_first_space(): + legacy = _profile_bookmark_export(["A", "B"]).to_legacy_dict()["spaces"] + + assert [b["url"] for b in legacy[0]["bookmarks"]] == ["https://example.com/"] + assert legacy[0]["bookmarks"][0]["space_id"] == "id-A" + assert legacy[0]["bookmark_folders"][0]["space_id"] == "id-A" + assert legacy[1]["bookmarks"] == [] + + +def test_profile_bookmarks_survive_excluding_the_first_space(): + export = _profile_bookmark_export(["A", "B"]) + kept = type(export)( + source=export.source, + spaces=[s for s in export.spaces if s.space_name != "A"], + profile_bookmarks=export.profile_bookmarks, + profile_bookmark_folders=export.profile_bookmark_folders, + ) + + legacy = kept.to_legacy_dict()["spaces"] + + assert [b["url"] for b in legacy[0]["bookmarks"]] == ["https://example.com/"] + assert legacy[0]["space_name"] == "B"