Skip to content
Open
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
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,19 @@ BrowserExtractor.extract() → ExportData → to_legacy_dict()
├─── ZenBookmarkImporter → places.sqlite (moz_bookmarks)
├─── FaviconImporter → favicons.sqlite + inline data URIs
├─── HistoryImporter → places.sqlite (moz_places, moz_historyvisits)
└─── CookiesImporter → cookies.sqlite (incl. per-container dupes)
├─── CookiesImporter → cookies.sqlite (incl. per-container dupes)
└─── ZenPasswordImporter → logins.json + key4.db (via Zen's own importer)
```

The password step is the one writer that does not hand-write a Zen file.
Zen encrypts logins with its NSS key, so `ZenPasswordImporter` briefly
launches Zen headless with Marionette and drives Zen's built-in
`ChromeProfileMigrator`, restricted to passwords, against the Chromium
source's `User Data`. No password plaintext passes through browser2zen,
and nothing reverse-engineers Zen's credential crypto. It runs only for
Chromium sources and refuses while Zen is open, since Zen must own the
login store during the write.

### Source extractors (`src/extractors/`)

| File | Source | Notes |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ your profile, then click **Recheck** in browser2zen and continue.
| **Favicons** | The source's cached icons are inlined so tabs show their icons immediately, with no waiting for refetch. |
| **History** | Optional. Browsing history with original timestamps is copied over. |
| **Login state** | Optional. Chromium-format cookies are decrypted (via macOS Keychain or Windows DPAPI, depending on platform) and re-encrypted into Zen so you stay logged in to Gmail, Twitter, and the rest. |
| **Saved passwords** | Optional, Chromium sources only. Zen opens for a few seconds and imports your saved logins through its own importer, so each password is re-encrypted with Zen's key. macOS asks once to let Zen read the source's Keychain entry. |

Every step writes a timestamped backup beside your Zen profile before
it changes anything, and source-browser data is read-only. The Backups screen
Expand Down
1 change: 1 addition & 0 deletions app/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,4 +627,5 @@ def _parse_options(opts_json: str) -> MigrationOptions:
include_open_tabs=bool(data.get("includeOpenTabs", False)),
include_history=bool(data.get("includeHistory", False)),
include_cookies=bool(data.get("includeCookies", False)),
include_passwords=bool(data.get("includePasswords", False)),
)
35 changes: 35 additions & 0 deletions app/env_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,41 @@ def is_zen_running() -> bool:
return False


# ---------- zen binary ----------


def zen_binary_path() -> Path | None:
"""The Zen executable this machine would launch, or ``None``.

The password step drives Zen's own importer, so it needs the binary
rather than the profile. Mirrors the running-process path lists so the
two can never disagree about where Zen lives.
"""
if sys.platform == "darwin":
for p in _ZEN_PROCESS_PATHS:
if Path(p).is_file():
return Path(p)
return None
if os.name == "nt":
home = Path.home()
candidates: list[Path] = []
for prof in list_zen_profiles():
root = prof.path.parent.parent
candidates += [root / "zen.exe", root / "zen-bin.exe"]
candidates += [
home / "AppData/Local/zen/zen.exe",
Path("C:/Program Files/Zen Browser/zen.exe"),
Path("C:/Program Files (x86)/Zen Browser/zen.exe"),
]
return next((c for c in candidates if c.is_file()), None)
for rel in ("zen/zen", "zen-bin"):
cand = Path("/usr/lib") / rel
if cand.is_file():
return cand
found = shutil.which("zen")
return Path(found) if found else None


# ---------- previous migration marker ----------


Expand Down
57 changes: 53 additions & 4 deletions app/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ const state = {
includeOpenTabs: true,
includeHistory: false,
includeCookies: false,
includePasswords: false,
excludedSpaces: [],
},
steps: [],
stepLabels: {},
stepStates: {},
stepSummaries: {},
stepErrors: {},
logLines: [],
pollHandle: null,
startedAt: 0,
Expand Down Expand Up @@ -484,6 +486,10 @@ function renderPreview(p) {
`Copy ~${formatRows(p.historyRowsEstimate)} history rows`));
toggles.appendChild(makeToggle("includeCookies", "Cookies & login state",
`Copy ~${formatRows(p.cookiesEstimate)} cookies (Keychain prompt)`));
if (p.passwordsEstimate > 0) {
toggles.appendChild(makeToggle("includePasswords", "Saved passwords",
`Import ~${formatRows(p.passwordsEstimate)} logins (Zen opens briefly, Keychain prompt)`));
}
toggles.appendChild(makeToggle("foldersCollapsed", "Collapse folders",
"Imported folders start collapsed"));
}
Expand Down Expand Up @@ -626,6 +632,11 @@ const STEP_ICON_BUILDERS = {
svgChild(s, "path", {d: "M14 9 L14 12", "stroke-linecap": "round"});
svgChild(s, "path", {d: "M16 9 L16 11.5", "stroke-linecap": "round"});
},
passwords: (s) => {
svgChild(s, "circle", {cx: 6, cy: 9, r: 3.5});
svgChild(s, "path", {d: "M9 9 L16 9 L16 12", "stroke-linecap": "round"});
svgChild(s, "path", {d: "M13 9 L13 11.5", "stroke-linecap": "round"});
},
finalize: (s) => svgChild(s, "path", {d: "M3 9 L7.5 13 L15 5",
"stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round"}),
};
Expand Down Expand Up @@ -669,6 +680,8 @@ function stepSubtitle(step) {
return "Copying browsing history into places.sqlite";
case "cookies":
return `Decrypting ${src} cookies and writing to cookies.sqlite`;
case "passwords":
return `Opening Zen to import ${src} saved passwords`;
case "finalize":
return "Marking migration complete";
default:
Expand Down Expand Up @@ -739,6 +752,7 @@ async function goToProgress() {
state.stepLabels = meta.labels;
state.stepStates = Object.fromEntries(state.steps.map(s => [s, "pending"]));
state.stepSummaries = {};
state.stepErrors = {};
state.logLines = [];
state.activeStep = null;
state.activeDetail = "";
Expand Down Expand Up @@ -784,7 +798,9 @@ function handleEvent(ev) {
state.activeDetail = "";
state.activeProgress = null;
} else if (ev.kind === "step_done") {
state.stepStates[ev.step] = "done";
// A step that finished with a warning (e.g. some password profiles
// failed) stays visibly distinct from a clean success.
state.stepStates[ev.step] = (ev.summary && ev.summary.warning) ? "warn" : "done";
if (ev.summary) state.stepSummaries[ev.step] = ev.summary;
if (state.activeStep === ev.step) {
state.activeStep = null;
Expand All @@ -793,6 +809,7 @@ function handleEvent(ev) {
}
} else if (ev.kind === "step_error") {
state.stepStates[ev.step] = "error";
state.stepErrors[ev.step] = ev.detail || ev.message || "";
} else {
state.logLines.push({kind: ev.kind, message: ev.message, step: ev.step});
if (state.logLines.length > 500) state.logLines.shift();
Expand All @@ -812,6 +829,7 @@ function visibleSteps() {
return state.steps.filter(s => {
if (s === "history") return state.options.includeHistory;
if (s === "cookies") return state.options.includeCookies;
if (s === "passwords") return state.options.includePasswords;
if (s === "containers" || s === "sessions") return state.options.includeWorkspaces || state.options.includePinnedTabs;
if (s === "bookmarks") return state.options.includeBookmarks;
if (s === "favicons") return state.options.includeFavicons;
Expand All @@ -833,7 +851,7 @@ function renderProgress() {
const total = steps.length;
const meta = $("progress-meta");
if (activeIdx >= 0) meta.textContent = `Step ${activeIdx + 1} of ${total}`;
else if (steps.every(s => state.stepStates[s] === "done")) meta.textContent = `${total} of ${total} done`;
else if (steps.every(s => ["done", "warn"].includes(state.stepStates[s]))) meta.textContent = `${total} of ${total} done`;
else meta.textContent = "Working";

// unified timeline
Expand All @@ -849,7 +867,7 @@ function renderProgress() {
}

function makeRow(step, st) {
const summary = (st === "done") ? stepSummaryText(step, state.stepSummaries[step]) : "";
const summary = (st === "done" || st === "warn") ? stepSummaryText(step, state.stepSummaries[step]) : "";
return el("li", {class: "tl-step", dataset: {step, state: st}}, [
el("div", {class: "ico"}, [makeStepIcon(step)]),
el("span", {class: "lbl", text: state.stepLabels[step] || step}),
Expand Down Expand Up @@ -883,6 +901,13 @@ function stepSummaryText(step, s) {
}
if (step === "history") return `${formatRows(s.places_added ?? 0)} places · ${formatRows(s.visits_added ?? 0)} visits`;
if (step === "cookies") return `${formatRows(s.imported ?? 0)} imported · ${formatRows(s.merged ?? 0)} merged`;
if (step === "passwords") {
if (s.skipped) return "skipped";
const imported = `${formatRows(s.imported ?? 0)} passwords imported`;
const bad = Object.keys(s.failed || {}).length + (s.missing || []).length;
if (bad) return `${imported}, ${bad} ${bad === 1 ? "profile" : "profiles"} not imported`;
return imported;
}
if (step === "sessions") {
if (s.pinned == null && s.open == null) return s.ok ? "ok" : "";
const parts = [];
Expand Down Expand Up @@ -936,7 +961,9 @@ async function finishOk(finalState) {
summary.title = "";
summary.onclick = null;
} else {
headline.textContent = "All set.";
const problems = renderDoneNotes();
headline.textContent = problems ? "Finished with problems." : "All set.";
$("screen-done").dataset.outcome = problems ? "warn" : "ok";
const ext = state.stepSummaries.extract || {};
summary.textContent = ext.pinned
? `${ext.pinned} pinned tabs across ${ext.spaces} spaces. Backups saved next to your Zen profile.`
Expand All @@ -946,6 +973,10 @@ async function finishOk(finalState) {
summary.onclick = null;
}

if (isBackup) {
$("screen-done").dataset.outcome = "ok";
$("done-notes").style.display = "none";
}
launch.style.display = isBackup ? "none" : "";
backupsSection.style.display = isBackup ? "none" : "";
$("done-back").style.display = isBackup ? "" : "none";
Expand All @@ -963,6 +994,22 @@ async function finishOk(finalState) {
}
}

// Lists warnings and failed steps on the Done screen so a partial
// migration never reads as a clean one. Returns how many it listed.
function renderDoneNotes() {
const list = $("done-notes"); clear(list);
for (const s of visibleSteps()) {
const label = state.stepLabels[s] || s;
let text = "";
// step_error details come from format_exception_only; drop the class name.
if (state.stepStates[s] === "error") text = `${label} failed. ${(state.stepErrors[s] || "").replace(/^\w+: /, "")}`;
else if (state.stepStates[s] === "warn") text = (state.stepSummaries[s] || {}).notice || `${label} finished with warnings.`;
if (text) list.appendChild(el("li", {dataset: {step: s}, text: text.trim()}));
}
list.style.display = list.children.length ? "" : "none";
return list.children.length;
}

$("done-launch").addEventListener("click", async () => {
const api = Bridge(); if (!api) return;
// Don't await quit_app — calling window.destroy() while a JS-Python promise
Expand Down Expand Up @@ -1121,6 +1168,7 @@ function currentOptionsJson() {
includeOpenTabs: state.options.includeOpenTabs,
includeHistory: state.options.includeHistory,
includeCookies: state.options.includeCookies,
includePasswords: state.options.includePasswords,
});
}

Expand Down Expand Up @@ -1524,6 +1572,7 @@ function startProgressPolling() {
setScreen("progress");
state.stepStates = {};
state.stepSummaries = {};
state.stepErrors = {};
state.logLines = [];
state.activeStep = null;
state.activeDetail = "";
Expand Down
1 change: 1 addition & 0 deletions app/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ <h1 class="display">Migrating</h1>
</div>
<h1 class="display" id="done-headline">All set.</h1>
<p class="summary" id="done-summary">Your browser setup now lives in Zen.</p>
<ul class="notes" id="done-notes" style="display:none;"></ul>
<div class="actions">
<button class="btn btn-primary" id="done-launch">Launch Zen</button>
<button class="btn btn-soft" id="done-back" style="display:none;">Back to home</button>
Expand Down
19 changes: 19 additions & 0 deletions app/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ body[data-platform="mac"] main {
box-shadow: 0 0 12px rgba(94, 134, 255, 0.32);
}
.meter-segment[data-state="error"] { background: var(--error); }
.meter-segment[data-state="warn"] { background: var(--warn); }

/* Timeline: one unified vertical list. Active row expands inline. */
.timeline {
Expand Down Expand Up @@ -731,6 +732,10 @@ body[data-platform="mac"] main {
flex: 0 0 auto;
}

/* state: warn (finished, but part of the step did not land) */
.tl-step[data-state="warn"] .ico { color: var(--warn); }
.tl-step[data-state="warn"] .summary { color: var(--warn); }

/* state: error */
.tl-step[data-state="error"] .ico { color: var(--error); }
.tl-step[data-state="error"] .lbl { color: var(--error); }
Expand Down Expand Up @@ -861,12 +866,26 @@ body[data-platform="mac"] main {
animation: drawcheck 600ms 100ms cubic-bezier(0.2, 0, 0, 1) forwards;
}
@keyframes drawcheck { to { stroke-dashoffset: 0; } }
#screen-done[data-outcome="warn"] .check { background: var(--warn-soft); }
#screen-done[data-outcome="warn"] .check svg path { stroke: var(--warn); }
#screen-done .summary {
margin-top: var(--space-2);
font-size: var(--fs-body);
color: var(--text-secondary);
max-width: 480px;
}
#screen-done .notes {
list-style: none;
margin: var(--space-4) 0 0;
padding: var(--space-3) var(--space-4);
max-width: 540px;
text-align: left;
font-size: var(--fs-small);
color: var(--warn);
background: var(--warn-soft);
border-radius: var(--radius-sm);
}
#screen-done .notes li + li { margin-top: var(--space-2); }
#screen-done .actions { margin-top: var(--space-6); display: flex; gap: var(--space-3); }
#screen-done .backups {
margin-top: var(--space-6);
Expand Down
Loading
Loading