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
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ body:
description: Which browser were you migrating from?
options:
- Arc
- Dia
- Chrome
- Microsoft Edge
- Brave
Expand Down
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (`<profile>/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 |

Expand All @@ -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`)

Expand Down Expand Up @@ -141,7 +146,7 @@ wires the chosen extractor's paths through.
(`window.pywebview.api.<method>`). 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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<method>`.
- `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,
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions app/frontend/assets/sources/dia.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions app/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
Expand Down
3 changes: 3 additions & 0 deletions app/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions build/browser2zen.spec
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ a = Analysis(
"extractors.brave",
"extractors.chrome",
"extractors.chromium",
"extractors.dia",
"extractors.edge",
"extractors.firefox",
"extractors.safari",
Expand Down Expand Up @@ -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.",
},
Expand Down
3 changes: 3 additions & 0 deletions src/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
)
from .brave import BraveExtractor
from .chrome import ChromeExtractor
from .dia import DiaExtractor
from .edge import EdgeExtractor
from .firefox import FirefoxExtractor
from .safari import SafariExtractor

# Order matters: this is the order the source picker presents them.
EXTRACTORS: tuple[type[BrowserExtractor], ...] = (
ArcExtractor,
DiaExtractor,
ChromeExtractor,
EdgeExtractor,
BraveExtractor,
Expand All @@ -41,6 +43,7 @@ def by_name(name: str) -> type[BrowserExtractor]:
"BrowserExtractor",
"BrowserExtractorError",
"ChromeExtractor",
"DiaExtractor",
"EXTRACTORS",
"EdgeExtractor",
"ExportData",
Expand Down
21 changes: 14 additions & 7 deletions src/extractors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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": [
{
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/extractors/chromium.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading