diff --git a/CLAUDE.md b/CLAUDE.md
index bb62e43..f80bd80 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 |
diff --git a/README.md b/README.md
index b1cc61a..8b00d6f 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/bridge.py b/app/bridge.py
index 265c141..eb874cb 100644
--- a/app/bridge.py
+++ b/app/bridge.py
@@ -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)),
)
diff --git a/app/env_check.py b/app/env_check.py
index 25b4e13..5823079 100644
--- a/app/env_check.py
+++ b/app/env_check.py
@@ -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 ----------
diff --git a/app/frontend/app.js b/app/frontend/app.js
index eb422d1..c7b249b 100644
--- a/app/frontend/app.js
+++ b/app/frontend/app.js
@@ -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,
@@ -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"));
}
@@ -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"}),
};
@@ -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:
@@ -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 = "";
@@ -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;
@@ -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();
@@ -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;
@@ -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
@@ -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}),
@@ -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 = [];
@@ -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.`
@@ -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";
@@ -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
@@ -1121,6 +1168,7 @@ function currentOptionsJson() {
includeOpenTabs: state.options.includeOpenTabs,
includeHistory: state.options.includeHistory,
includeCookies: state.options.includeCookies,
+ includePasswords: state.options.includePasswords,
});
}
@@ -1524,6 +1572,7 @@ function startProgressPolling() {
setScreen("progress");
state.stepStates = {};
state.stepSummaries = {};
+ state.stepErrors = {};
state.logLines = [];
state.activeStep = null;
state.activeDetail = "";
diff --git a/app/frontend/index.html b/app/frontend/index.html
index 4b6d4b3..5607b19 100644
--- a/app/frontend/index.html
+++ b/app/frontend/index.html
@@ -247,6 +247,7 @@
Migrating
All set.
Your browser setup now lives in Zen.
+
diff --git a/app/frontend/styles.css b/app/frontend/styles.css
index 3173bf5..bc7ee28 100644
--- a/app/frontend/styles.css
+++ b/app/frontend/styles.css
@@ -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 {
@@ -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); }
@@ -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);
diff --git a/app/orchestrator.py b/app/orchestrator.py
index 118da1e..d35d769 100644
--- a/app/orchestrator.py
+++ b/app/orchestrator.py
@@ -37,6 +37,7 @@
from extractors import ArcExtractor, BrowserExtractor # noqa: E402
from zen_bookmark_importer import ZenBookmarkImporter # noqa: E402
from zen_favicon_importer import FaviconImporter, _iter_pinned_urls # noqa: E402
+from zen_password_importer import ZenPasswordImporter # noqa: E402
from zen_sessions_importer import ZenSessionsImporter # noqa: E402
from zen_space_importer import ZenProfile as _SrcZenProfile
from zen_space_importer import ZenSpaceImporter # noqa: E402
@@ -73,6 +74,7 @@ class PreviewReport:
favicon_match_estimate: int # how many URLs Arc has cached icons for
history_rows_estimate: int
cookies_estimate: int
+ passwords_estimate: int
@dataclass
@@ -88,6 +90,7 @@ class MigrationOptions:
include_open_tabs: bool = True
include_history: bool = False
include_cookies: bool = False
+ include_passwords: bool = False
# ----------- step ordering used in the GUI's progress list (left to right) ---
@@ -100,6 +103,7 @@ class MigrationOptions:
"favicons",
"history",
"cookies",
+ "passwords",
"finalize",
)
@@ -113,6 +117,7 @@ class MigrationOptions:
"favicons": "Importing favicons",
"history": "Importing browsing history",
"cookies": "Importing cookies",
+ "passwords": "Importing saved passwords",
"finalize": "Finalizing",
}
@@ -164,6 +169,64 @@ def _step_label(step: str, source_display: str) -> str:
}
+# Passwords ride Zen's own importer, so its failure modes differ from
+# cookies. ``{source}`` is filled with the active source display name.
+_PASSWORD_ERROR_MESSAGES = {
+ "no_source_passwords":
+ "{source} has no saved passwords to import.",
+ "passwords_unsupported_platform":
+ "Password import only supports macOS and Windows.",
+ "passwords_source_unsupported":
+ "Saved-password import is only available for Chromium sources "
+ "(Arc, Dia, Chrome, Edge, Brave).",
+ "zen_binary_missing":
+ "The Zen application could not be found to run its password importer.",
+ "zen_login_store_missing":
+ "Launch Zen once so it creates its password store, then click Recheck.",
+ "zen_running":
+ "Close Zen before importing passwords; Zen must own its login store "
+ "during the write.",
+ "password_import_failed":
+ "Zen's password importer did not finish; passwords skipped.",
+ "no_profiles_imported":
+ "Zen could not import passwords from any {source} profile.",
+}
+
+# Zen's per-profile errors can be long stack-ish strings; the UI shows a
+# sentence per profile, so keep each one readable.
+_PROFILE_ERROR_LIMIT = 160
+
+
+def _password_notice(summary: dict) -> str:
+ """Describe per-profile password outcomes in plain sentences.
+
+ Returns an empty string for a clean import. Profile ids are the
+ source's on-disk directory names (``Default``, ``Profile 1``).
+ """
+ profiles = summary.get("profiles") or {}
+ failed = summary.get("failed") or {}
+ missing = summary.get("missing") or []
+ total = len(profiles) + len(failed) + len(missing)
+ sentences: list[str] = []
+ if failed or missing:
+ if profiles:
+ sentences.append(
+ f"Imported {summary.get('imported', 0)} passwords from "
+ f"{len(profiles)} of {total} profiles."
+ )
+ for pid, err in failed.items():
+ err = err if len(err) <= _PROFILE_ERROR_LIMIT else err[:_PROFILE_ERROR_LIMIT] + "..."
+ sentences.append(f"{pid} failed ({err}).")
+ if missing:
+ sentences.append(f"Zen's importer did not find {', '.join(missing)}.")
+ elif summary.get("warning") == "no_passwords_imported":
+ sentences.append(
+ "No passwords were imported. Zen skips logins it already has, "
+ "and nothing imports if the Keychain prompt is denied."
+ )
+ return " ".join(sentences)
+
+
# ------------------------------------------------------------------ orchestrator
class MigrationOrchestrator:
@@ -234,6 +297,7 @@ def preview(self, opts: MigrationOptions) -> PreviewReport:
favicon_match_estimate = self._estimate_favicons(all_urls)
history_rows_estimate = self._estimate_history_rows()
cookies_estimate = self._estimate_cookies()
+ passwords_estimate = self._estimate_passwords()
return PreviewReport(
spaces=space_summaries,
@@ -244,6 +308,7 @@ def preview(self, opts: MigrationOptions) -> PreviewReport:
favicon_match_estimate=favicon_match_estimate,
history_rows_estimate=history_rows_estimate,
cookies_estimate=cookies_estimate,
+ passwords_estimate=passwords_estimate,
)
def _estimate_favicons(self, urls: list[str]) -> int:
@@ -298,6 +363,24 @@ def _estimate_cookies(self) -> int:
except Exception:
return 0
+ def _estimate_passwords(self) -> int:
+ try:
+ import sqlite3
+ total = 0
+ for db in self.source.login_data_paths():
+ try:
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2.0)
+ n = conn.execute(
+ "SELECT COUNT(*) FROM logins WHERE blacklisted_by_user = 0"
+ ).fetchone()[0]
+ conn.close()
+ total += int(n or 0)
+ except Exception:
+ continue
+ return total
+ except Exception:
+ return 0
+
# ---- migration ------------------------------------------------------
def migrate(self, opts: MigrationOptions) -> Iterator[ProgressEvent]:
@@ -535,7 +618,12 @@ def _run(self, opts: MigrationOptions) -> Iterator[ProgressEvent]:
self._error_step("cookies", exc)
yield from self._drain_yield()
- # 9: finalize ----------------------------------------------------
+ # 9: passwords ---------------------------------------------------
+ if opts.include_passwords:
+ self._run_passwords_step(zen_profile)
+ yield from self._drain_yield()
+
+ # 10: finalize ---------------------------------------------------
self._start_step("finalize")
try:
(zen_profile / ".browser2zen-migrated").write_text(
@@ -546,6 +634,54 @@ def _run(self, opts: MigrationOptions) -> Iterator[ProgressEvent]:
self._done_step("finalize", message="Migration complete")
yield from self._drain_yield()
+ def _run_passwords_step(self, zen_profile: Path) -> None:
+ self._start_step("passwords")
+ try:
+ summary = self._import_passwords(zen_profile)
+ except Exception as exc:
+ self._error_step("passwords", exc)
+ return
+ err = summary.get("error")
+ notice = _password_notice(summary)
+ if err in ("no_source_passwords", "passwords_source_unsupported"):
+ # Nothing to import is a clean skip, not a migration failure.
+ msg = _PASSWORD_ERROR_MESSAGES[err].format(source=self.source.display_name)
+ self._done_step("passwords", summary={"imported": 0, "skipped": msg})
+ elif err:
+ msg = _PASSWORD_ERROR_MESSAGES.get(
+ err, f"Password import failed: {err}"
+ ).format(source=self.source.display_name)
+ self._error_step("passwords", RuntimeError(f"{msg} {notice}".strip()))
+ else:
+ if notice:
+ summary = {**summary, "notice": notice}
+ self._emit({"kind": "warn", "step": "passwords", "message": notice})
+ self._done_step("passwords", summary=summary)
+
+ def _import_passwords(self, zen_profile: Path) -> dict:
+ user_data = self.source.user_data_root()
+ login_dbs = self.source.login_data_paths()
+ if user_data is None or not login_dbs:
+ # Firefox/Safari have no Chromium login store Zen can read.
+ code = ("no_source_passwords" if user_data is not None
+ else "passwords_source_unsupported")
+ return {"error": code}
+
+ from app.env_check import zen_binary_path
+ zen_binary = zen_binary_path()
+ if zen_binary is None:
+ return {"error": "zen_binary_missing"}
+
+ importer = ZenPasswordImporter(
+ zen_profile=zen_profile,
+ zen_binary=zen_binary,
+ source_user_data=user_data,
+ profile_ids=[db.parent.name for db in login_dbs],
+ keychain_service=getattr(self.source, "keychain_service", "Arc Safe Storage"),
+ keychain_account=getattr(self.source, "keychain_account", "Arc"),
+ )
+ return importer.import_passwords()
+
def _drain_yield(self) -> Iterator[ProgressEvent]:
yield from self.bus.drain()
@@ -585,4 +721,5 @@ def preview_to_dict(report: PreviewReport) -> dict:
"faviconMatchEstimate": report.favicon_match_estimate,
"historyRowsEstimate": report.history_rows_estimate,
"cookiesEstimate": report.cookies_estimate,
+ "passwordsEstimate": report.passwords_estimate,
}
diff --git a/docs/screenshots/mock-bridge.js b/docs/screenshots/mock-bridge.js
index 0d9a528..566efde 100644
--- a/docs/screenshots/mock-bridge.js
+++ b/docs/screenshots/mock-bridge.js
@@ -84,7 +84,16 @@ window.pywebview = {
get_step_metadata: async () => ({ steps: [], labels: {} }),
// Migration-side methods: tests don't drive these screens via the
// happy path, but app.js may call them on screen entry.
- preview: async () => ({ ok: true, spaces: [], folders: [], pinnedTabs: 0 }),
+ preview: async () => ({
+ spaces: [
+ { name: "Work", icon: "💼", pinnedCount: 8, openCount: 2, folderCount: 1, essentialCount: 2, color: [94, 134, 255] },
+ { name: "Reading", icon: "📚", pinnedCount: 5, openCount: 1, folderCount: 0, essentialCount: 1, color: [120, 90, 220] },
+ { name: "Hobby", icon: "🎧", pinnedCount: 4, openCount: 1, folderCount: 1, essentialCount: 2, color: [40, 170, 120] },
+ ],
+ pinnedTotal: 17, openTotal: 4, folderTotal: 2, bookmarkTotal: 19,
+ faviconMatchEstimate: 15, historyRowsEstimate: 42000,
+ cookiesEstimate: 12000, passwordsEstimate: 320,
+ }),
start_migration: async () => ({ ok: true }),
start_zen_export: async () => ({ ok: true }),
start_zen_restore: async () => ({ ok: true }),
diff --git a/docs/screenshots/preview.png b/docs/screenshots/preview.png
new file mode 100644
index 0000000..57b4ecc
Binary files /dev/null and b/docs/screenshots/preview.png differ
diff --git a/docs/screenshots/screens.spec.ts b/docs/screenshots/screens.spec.ts
index de4a0d2..387979b 100644
--- a/docs/screenshots/screens.spec.ts
+++ b/docs/screenshots/screens.spec.ts
@@ -78,6 +78,19 @@ test("detect", async ({ page }) => {
await shoot(page, "detect");
});
+test("preview", async ({ page }) => {
+ await bootHarness(page);
+ await page.locator("#welcome-go").click();
+ await waitForActiveScreen(page, "source");
+ await page.locator('.source-card[data-name="arc"]').click();
+ await page.locator("#source-next").click();
+ await waitForActiveScreen(page, "detect");
+ await page.locator("#detect-next").click();
+ await waitForActiveScreen(page, "preview");
+ await page.locator("#toggles .toggle").last().waitFor({ state: "visible" });
+ await shoot(page, "preview");
+});
+
test("backup-mode", async ({ page }) => {
await bootHarness(page);
await page.locator("#welcome-backup").click();
diff --git a/src/extractors/arc.py b/src/extractors/arc.py
index 3bc00b8..af37f7e 100644
--- a/src/extractors/arc.py
+++ b/src/extractors/arc.py
@@ -176,6 +176,16 @@ def cookie_db_paths(self) -> list[Path]:
break
return out
+ def login_data_paths(self) -> list[Path]:
+ root = self._user_data_dir()
+ if root is None:
+ return []
+ return [profile / "Login Data" for profile in sorted(root.iterdir())
+ if profile.is_dir() and (profile / "Login Data").is_file()]
+
+ def user_data_root(self) -> Path | None:
+ return self._user_data_dir()
+
def local_state_paths(self) -> list[Path]:
# Used on Windows for DPAPI key unwrap.
home = Path.home()
diff --git a/src/extractors/base.py b/src/extractors/base.py
index 968a935..8834146 100644
--- a/src/extractors/base.py
+++ b/src/extractors/base.py
@@ -315,6 +315,17 @@ def local_state_paths(self) -> list[Path]:
on Windows). Empty for Firefox/Safari and unused on macOS."""
return []
+ def login_data_paths(self) -> list[Path]:
+ """Chromium ``Login Data`` SQLite files, one per source profile.
+ Empty for sources whose passwords Zen cannot import directly."""
+ return []
+
+ def user_data_root(self) -> Path | None:
+ """The Chromium ``User Data`` root that holds the profile folders.
+ ``None`` for non-Chromium sources. Zen's own password importer reads
+ saved logins from here per profile."""
+ return None
+
# ---- cookie decryption ----
def cookie_master_key(self) -> bytes:
diff --git a/src/extractors/chromium.py b/src/extractors/chromium.py
index 0852f34..b3c1f79 100644
--- a/src/extractors/chromium.py
+++ b/src/extractors/chromium.py
@@ -307,6 +307,17 @@ def local_state_paths(self) -> list[Path]:
return []
return [root / "Local State"] if (root / "Local State").is_file() else []
+ def login_data_paths(self) -> list[Path]:
+ out: list[Path] = []
+ for prof in self.profile_paths():
+ cand = prof / "Login Data"
+ if cand.is_file():
+ out.append(cand)
+ return out
+
+ def user_data_root(self) -> Path | None:
+ return self._user_data_dir()
+
# ---------- cookie key ----------
def cookie_master_key(self) -> bytes:
diff --git a/src/zen_password_importer.py b/src/zen_password_importer.py
new file mode 100644
index 0000000..41c6265
--- /dev/null
+++ b/src/zen_password_importer.py
@@ -0,0 +1,388 @@
+#!/usr/bin/env python3
+"""
+Chromium → Zen saved-password importer.
+
+Zen stores passwords encrypted with its own NSS key (``key4.db``), so the
+only robust way to add logins is to let Zen encrypt them. This importer
+briefly launches Zen headless with Marionette and runs Zen's built-in
+``ChromeProfileMigrator`` pointed at the source browser's ``User Data``
+directory, restricted to passwords. Zen reads the source ``Login Data``,
+decrypts each entry through the OS keystore (macOS Keychain /
+Windows DPAPI), and writes it back encrypted with the target profile's
+key. No password plaintext ever passes through this process, and nothing
+here reverse-engineers Zen's credential crypto.
+
+The step is skipped cleanly for non-Chromium sources (``user_data_root``
+is ``None``) and refuses to run while Zen is open, since Zen must own the
+login store during the write.
+
+Backups of ``logins.json``, ``logins-backup.json`` and ``key4.db`` are
+written beside the profile before anything changes, unless ``dry_run``.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+# Resource-type bit for passwords in Zen's MigrationUtils. Stable across
+# Firefox/Zen releases; asserted in-browser below so a change surfaces
+# loudly rather than silently importing the wrong data.
+_PASSWORDS_RESOURCE_TYPE = 0x10
+
+# Launch env that lets Marionette reach Zen's privileged chrome context.
+_SYSTEM_ACCESS_ENV = "MOZ_REMOTE_ALLOW_SYSTEM_ACCESS"
+
+_LAUNCH_TIMEOUT_S = 90.0
+_QUIT_TIMEOUT_S = 30.0
+
+
+# The chrome-context script Zen runs. It receives the plan as JSON and
+# returns only counts, never a username or password. ``ChromeProfileMigrator``
+# does the decrypt/encrypt internally.
+_IMPORT_JS = r"""
+const done = arguments[arguments.length - 1];
+const plan = JSON.parse(arguments[0]);
+(async () => {
+ const { ChromeProfileMigrator } = ChromeUtils.importESModule(
+ "resource:///modules/ChromeProfileMigrator.sys.mjs");
+ const { MigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/MigrationUtils.sys.mjs");
+
+ const PASSWORDS = MigrationUtils.resourceTypes.PASSWORDS;
+ if (PASSWORDS !== plan.passwordsType) {
+ return { fatal: "passwords resource type changed: " + PASSWORDS };
+ }
+
+ const m = new ChromeProfileMigrator();
+ m._chromeUserDataPath = plan.userData;
+ m._keychainServiceName = plan.keychainService;
+ m._keychainAccountName = plan.keychainAccount;
+
+ const before = (await Services.logins.getAllLogins()).length;
+ const sourceProfiles = await m.getSourceProfiles();
+ const byId = new Map(sourceProfiles.map(p => [p.id, p]));
+ const report = { before, perProfile: {}, missing: [] };
+
+ for (const id of plan.profileIds) {
+ const profile = byId.get(id);
+ if (!profile) { report.missing.push(id); continue; }
+ const countBefore = (await Services.logins.getAllLogins()).length;
+ try {
+ await m.migrate(PASSWORDS, false, profile);
+ } catch (e) {
+ report.perProfile[id] = { error: String(e) };
+ continue;
+ }
+ const countAfter = (await Services.logins.getAllLogins()).length;
+ report.perProfile[id] = { added: countAfter - countBefore };
+ }
+
+ report.after = (await Services.logins.getAllLogins()).length;
+ report.imported = report.after - before;
+ return report;
+})().then(done, e => done({ fatal: String(e), stack: e && e.stack }));
+"""
+
+
+def summarize_report(report: dict) -> dict:
+ """Fold Zen's in-browser report into the result the UI renders.
+
+ Every requested profile lands in exactly one bucket. ``profiles`` maps
+ a migrated profile to the logins it added, ``failed`` maps a profile
+ whose migration threw to Zen's error, and ``missing`` lists profiles
+ Zen's migrator did not find. Partial outcomes carry a ``warning`` and a
+ run where no profile migrated carries an ``error``, so a partial import
+ never reads as a clean success.
+ """
+ per = report.get("perProfile") or {}
+ profiles = {
+ str(pid): int(v.get("added", 0))
+ for pid, v in per.items() if "error" not in v
+ }
+ failed = {str(pid): str(v["error"]) for pid, v in per.items() if "error" in v}
+ missing = [str(pid) for pid in report.get("missing") or []]
+ result: dict = {
+ "imported": int(report.get("imported", 0)),
+ "profiles": profiles,
+ "failed": failed,
+ "missing": missing,
+ }
+ if not profiles:
+ result["error"] = "no_profiles_imported"
+ elif failed or missing:
+ result["warning"] = "partial_import"
+ elif result["imported"] == 0:
+ # Every profile ran but nothing landed. Most often the Keychain or
+ # DPAPI prompt was declined, or Zen already had every login.
+ result["warning"] = "no_passwords_imported"
+ return result
+
+
+class ZenPasswordImporter:
+ """Import Chromium-format saved passwords into a Zen profile."""
+
+ def __init__(
+ self,
+ zen_profile: Path,
+ zen_binary: Path,
+ source_user_data: Path,
+ profile_ids: list[str],
+ *,
+ keychain_service: str = "Arc Safe Storage",
+ keychain_account: str = "Arc",
+ dry_run: bool = False,
+ ):
+ self.zen_profile = Path(zen_profile)
+ self.zen_binary = Path(zen_binary)
+ self.source_user_data = Path(source_user_data)
+ self.profile_ids = list(profile_ids)
+ self.keychain_service = keychain_service
+ self.keychain_account = keychain_account
+ self.dry_run = dry_run
+
+ # ---- guards ----
+
+ def _zen_is_running_on_profile(self) -> bool:
+ lock = self.zen_profile / ".parentlock"
+ if not lock.exists():
+ return False
+ if os.name == "nt":
+ # Windows holds the file open exclusively while Zen runs, so a
+ # rename to itself fails only then.
+ try:
+ lock.rename(lock)
+ return False
+ except OSError:
+ return True
+ import fcntl
+
+ try:
+ fd = os.open(lock, os.O_RDWR)
+ except OSError:
+ return False
+ try:
+ fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ fcntl.lockf(fd, fcntl.LOCK_UN)
+ return False
+ except OSError:
+ return True
+ finally:
+ os.close(fd)
+
+ # ---- backups ----
+
+ def _backup(self) -> Path | None:
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ dest = self.zen_profile / f"browser2zen-passwords-backup-{stamp}"
+ made = False
+ for name in ("logins.json", "logins-backup.json", "key4.db"):
+ src = self.zen_profile / name
+ if src.is_file():
+ dest.mkdir(exist_ok=True)
+ shutil.copy2(src, dest / name)
+ made = True
+ return dest if made else None
+
+ def _count_existing_logins(self) -> int:
+ store = self.zen_profile / "logins.json"
+ if not store.is_file():
+ return 0
+ try:
+ return len(json.loads(store.read_text(encoding="utf-8")).get("logins", []))
+ except Exception:
+ return 0
+
+ # ---- marionette ----
+
+ @staticmethod
+ def _free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+ def _run_in_zen(self, plan: dict, port: int) -> dict:
+ deadline = time.time() + _LAUNCH_TIMEOUT_S
+ sock: socket.socket | None = None
+ while sock is None:
+ try:
+ sock = socket.create_connection(("127.0.0.1", port))
+ except OSError:
+ if time.time() > deadline:
+ raise TimeoutError("Zen Marionette did not accept a connection")
+ time.sleep(0.5)
+ try:
+ sock.settimeout(_LAUNCH_TIMEOUT_S)
+ self._recv(sock) # server "hello"
+ self._cmd(sock, "WebDriver:NewSession",
+ {"capabilities": {"alwaysMatch": {"timeouts": {"script": 300000}}}})
+ self._cmd(sock, "Marionette:SetContext", {"value": "chrome"})
+ res = self._cmd(sock, "WebDriver:ExecuteAsyncScript",
+ {"script": _IMPORT_JS, "args": [json.dumps(plan)]})
+ report = res.get("value", res) if isinstance(res, dict) else res
+ try:
+ self._cmd(sock, "Marionette:Quit", {"flags": ["eAttemptQuit"]})
+ except Exception:
+ pass
+ return report
+ finally:
+ sock.close()
+
+ _msg_id = 0
+
+ def _cmd(self, sock: socket.socket, name: str, params: dict) -> dict:
+ self._msg_id += 1
+ body = json.dumps([0, self._msg_id, name, params]).encode()
+ sock.sendall(str(len(body)).encode() + b":" + body)
+ _, _, err, res = self._recv(sock)
+ if err:
+ raise RuntimeError(f"{name}: {err}")
+ return res
+
+ @staticmethod
+ def _recv(sock: socket.socket) -> list:
+ buf = b""
+ while b":" not in buf:
+ chunk = sock.recv(1)
+ if not chunk:
+ raise ConnectionError("Marionette closed the connection")
+ buf += chunk
+ length_str, rest = buf.split(b":", 1)
+ length = int(length_str)
+ while len(rest) < length:
+ chunk = sock.recv(length - len(rest))
+ if not chunk:
+ raise ConnectionError("Marionette closed the connection")
+ rest += chunk
+ return json.loads(rest)
+
+ # ---- temp prefs ----
+
+ def _apply_temp_prefs(self, port: int) -> str | None:
+ """Write Marionette prefs into ``user.js``; return prior contents."""
+ user_js = self.zen_profile / "user.js"
+ prior = user_js.read_text(encoding="utf-8") if user_js.is_file() else None
+ lines = (prior or "")
+ lines += (
+ '\nuser_pref("remote.prefs.recommended", false);'
+ f'\nuser_pref("marionette.port", {port});\n'
+ )
+ user_js.write_text(lines, encoding="utf-8")
+ return prior
+
+ def _restore_prefs(self, prior: str | None) -> None:
+ user_js = self.zen_profile / "user.js"
+ if prior is None:
+ user_js.unlink(missing_ok=True)
+ else:
+ user_js.write_text(prior, encoding="utf-8")
+ # Zen copies user.js values into prefs.js on launch, so strip the two
+ # we injected or they persist after the run.
+ prefs = self.zen_profile / "prefs.js"
+ if prefs.is_file():
+ kept = [
+ ln for ln in prefs.read_text(encoding="utf-8").splitlines(keepends=True)
+ if '"marionette.port"' not in ln
+ and '"remote.prefs.recommended"' not in ln
+ ]
+ prefs.write_text("".join(kept), encoding="utf-8")
+
+ # ---- orchestration ----
+
+ def import_passwords(self) -> dict:
+ result: dict = {"imported": 0, "profiles": {}, "failed": {}, "missing": []}
+
+ # Zen's Chromium importer decrypts through the macOS Keychain or
+ # Windows DPAPI; there is no supported keystore path elsewhere.
+ if sys.platform not in ("darwin", "win32"):
+ result["error"] = "passwords_unsupported_platform"
+ return result
+ if not self.profile_ids or not self.source_user_data.is_dir():
+ result["error"] = "no_source_passwords"
+ return result
+ if not self.zen_binary.is_file():
+ result["error"] = "zen_binary_missing"
+ return result
+ if not (self.zen_profile / "key4.db").is_file():
+ # Fresh profile that Zen has never launched has no login key yet.
+ result["error"] = "zen_login_store_missing"
+ return result
+ if self._zen_is_running_on_profile():
+ result["error"] = "zen_running"
+ return result
+
+ existing = self._count_existing_logins()
+ if self.dry_run:
+ result["would_import_from"] = list(self.profile_ids)
+ result["existing_logins"] = existing
+ return result
+
+ result["backup"] = str(self._backup() or "")
+
+ plan = {
+ "userData": str(self.source_user_data),
+ "profileIds": self.profile_ids,
+ "keychainService": self.keychain_service,
+ "keychainAccount": self.keychain_account,
+ "passwordsType": _PASSWORDS_RESOURCE_TYPE,
+ }
+ port = self._free_port()
+ prior_prefs = self._apply_temp_prefs(port)
+ proc: subprocess.Popen | None = None
+ try:
+ env = dict(os.environ, **{_SYSTEM_ACCESS_ENV: "1"})
+ proc = subprocess.Popen(
+ [str(self.zen_binary), "--marionette", "--headless",
+ "-no-remote", "-profile", str(self.zen_profile)],
+ env=env,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ report = self._run_in_zen(plan, port)
+ self._wait_for_exit(proc)
+ except Exception as exc:
+ logger.error("Password import failed: %s", exc)
+ result["error"] = "password_import_failed"
+ if proc is not None:
+ self._terminate(proc)
+ return result
+ finally:
+ self._restore_prefs(prior_prefs)
+
+ if isinstance(report, dict) and report.get("fatal"):
+ logger.error("Zen password import error: %s", report.get("fatal"))
+ result["error"] = "password_import_failed"
+ return result
+
+ if not isinstance(report, dict):
+ result["error"] = "password_import_failed"
+ return result
+ return {**summarize_report(report), "backup": result["backup"]}
+
+ def _wait_for_exit(self, proc: subprocess.Popen) -> None:
+ try:
+ proc.wait(timeout=_QUIT_TIMEOUT_S)
+ except subprocess.TimeoutExpired:
+ self._terminate(proc)
+
+ @staticmethod
+ def _terminate(proc: subprocess.Popen) -> None:
+ try:
+ proc.terminate()
+ proc.wait(timeout=10)
+ except Exception:
+ try:
+ proc.kill()
+ except Exception:
+ pass
diff --git a/tests/test_password_importer.py b/tests/test_password_importer.py
new file mode 100644
index 0000000..4597b38
--- /dev/null
+++ b/tests/test_password_importer.py
@@ -0,0 +1,439 @@
+"""Tests for the saved-password migration path.
+
+These never launch Zen. They cover the extractor data source, the
+preview estimate, the importer's guards and backups, and the
+non-Chromium skip. The actual in-browser import is exercised manually
+against a throwaway profile, since it needs a real Zen binary.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+import subprocess
+import sys
+import textwrap
+import time
+from pathlib import Path
+
+import pytest
+
+
+def _make_login_data(path: Path, *, usable: int, blacklisted: int = 0) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ conn = sqlite3.connect(path)
+ conn.execute(
+ "CREATE TABLE logins (origin_url TEXT, username_value TEXT, "
+ "password_value BLOB, blacklisted_by_user INTEGER)"
+ )
+ rows = [("https://a.test", "u", b"x", 0)] * usable
+ rows += [("https://b.test", "", b"", 1)] * blacklisted
+ conn.executemany("INSERT INTO logins VALUES (?,?,?,?)", rows)
+ conn.commit()
+ conn.close()
+
+
+# ---- extractor data source ----
+
+def test_chromium_login_data_and_root(chrome_home):
+ from extractors import ChromeExtractor
+
+ ext = ChromeExtractor()
+ root = ext.user_data_root()
+ assert root is not None
+ profile = ext.profile_paths()[0]
+ _make_login_data(profile / "Login Data", usable=3)
+
+ login_dbs = ext.login_data_paths()
+ assert login_dbs == [profile / "Login Data"]
+ assert login_dbs[0].parent.name == profile.name
+
+
+def test_arc_login_data_and_root(arc_home):
+ from extractors import ArcExtractor
+
+ user_data = arc_home / "Library/Application Support/Arc/User Data"
+ _make_login_data(user_data / "Default" / "Login Data", usable=4)
+ _make_login_data(user_data / "Profile 1" / "Login Data", usable=1)
+
+ ext = ArcExtractor()
+ assert ext.user_data_root() == user_data
+ assert {p.parent.name for p in ext.login_data_paths()} == {"Default", "Profile 1"}
+
+
+def test_non_chromium_has_no_login_store(firefox_home):
+ from extractors import FirefoxExtractor, SafariExtractor
+
+ for cls in (FirefoxExtractor, SafariExtractor):
+ assert cls().user_data_root() is None
+ assert cls().login_data_paths() == []
+
+
+def test_preview_estimate_counts_usable_logins(chrome_home, zen_profile):
+ from app.orchestrator import MigrationOptions, MigrationOrchestrator
+ from extractors import ChromeExtractor
+
+ profile = ChromeExtractor().profile_paths()[0]
+ _make_login_data(profile / "Login Data", usable=5, blacklisted=2)
+
+ o = MigrationOrchestrator(source=ChromeExtractor())
+ preview = o.preview(MigrationOptions(zen_profile_path=zen_profile))
+ assert preview.passwords_estimate == 5
+
+
+# ---- importer guards ----
+
+@pytest.fixture
+def macos(monkeypatch):
+ """Pin the platform the importer supports.
+
+ The importer refuses anything but macOS and Windows, so guard tests
+ that are about a later check must say which platform they run on.
+ Without this, Linux CI stops at the platform guard first.
+ """
+ monkeypatch.setattr(sys, "platform", "darwin")
+
+
+def _importer(zen_profile: Path, source_user_data: Path, **kw):
+ from zen_password_importer import ZenPasswordImporter
+
+ defaults = dict(
+ zen_profile=zen_profile,
+ zen_binary=Path(sys.executable),
+ source_user_data=source_user_data,
+ profile_ids=["Default"],
+ )
+ defaults.update(kw)
+ return ZenPasswordImporter(**defaults)
+
+
+def test_dry_run_reports_plan_without_launching(tmp_path, macos):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "key4.db").write_bytes(b"x")
+ src = tmp_path / "User Data"
+ (src / "Default").mkdir(parents=True)
+
+ res = _importer(zen, src, dry_run=True).import_passwords()
+ assert res["would_import_from"] == ["Default"]
+ assert "imported" in res and res["imported"] == 0
+
+
+def test_guard_no_source_passwords(tmp_path, macos):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "key4.db").write_bytes(b"x")
+ src = tmp_path / "User Data"
+ src.mkdir()
+ res = _importer(zen, src, profile_ids=[]).import_passwords()
+ assert res["error"] == "no_source_passwords"
+
+
+def test_guard_zen_binary_missing(tmp_path, macos):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "key4.db").write_bytes(b"x")
+ src = tmp_path / "User Data"
+ (src / "Default").mkdir(parents=True)
+ res = _importer(zen, src, zen_binary=tmp_path / "nope").import_passwords()
+ assert res["error"] == "zen_binary_missing"
+
+
+def test_guard_login_store_missing(tmp_path, macos):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ src = tmp_path / "User Data"
+ (src / "Default").mkdir(parents=True)
+ res = _importer(zen, src).import_passwords()
+ assert res["error"] == "zen_login_store_missing"
+
+
+@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX fcntl lock")
+def test_guard_zen_running_detects_lock(tmp_path, macos):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "key4.db").write_bytes(b"x")
+ lock = zen / ".parentlock"
+ lock.write_bytes(b"")
+ ready = tmp_path / "ready"
+ holder = subprocess.Popen([
+ sys.executable, "-c",
+ textwrap.dedent(f"""
+ import fcntl, os, time
+ fd = os.open({str(lock)!r}, os.O_RDWR)
+ fcntl.lockf(fd, fcntl.LOCK_EX)
+ open({str(ready)!r}, 'w').close()
+ time.sleep(30)
+ """),
+ ])
+ try:
+ for _ in range(100):
+ if ready.exists():
+ break
+ time.sleep(0.05)
+ src = tmp_path / "User Data"
+ (src / "Default").mkdir(parents=True)
+ res = _importer(zen, src).import_passwords()
+ assert res["error"] == "zen_running"
+ finally:
+ holder.terminate()
+ holder.wait(timeout=5)
+
+
+@pytest.mark.parametrize("platform", ["linux", "freebsd14"])
+def test_guard_unsupported_platform_runs_first(tmp_path, monkeypatch, platform):
+ """Off macOS/Windows there is no keystore Zen can decrypt with, so the
+ importer stops before touching the profile, even in dry-run mode."""
+ monkeypatch.setattr(sys, "platform", platform)
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "key4.db").write_bytes(b"x")
+ src = tmp_path / "User Data"
+ (src / "Default").mkdir(parents=True)
+
+ res = _importer(zen, src, dry_run=True).import_passwords()
+ assert res["error"] == "passwords_unsupported_platform"
+ assert "would_import_from" not in res
+ assert sorted(p.name for p in zen.iterdir()) == ["key4.db"]
+
+
+def test_guard_windows_is_supported(tmp_path, monkeypatch):
+ """Windows passes the platform guard and reaches the next check."""
+ monkeypatch.setattr(sys, "platform", "win32")
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ src = tmp_path / "User Data"
+ (src / "Default").mkdir(parents=True)
+ res = _importer(zen, src).import_passwords()
+ assert res["error"] == "zen_login_store_missing"
+
+
+# ---- per-profile outcomes ----
+
+def test_summarize_all_profiles_imported():
+ from zen_password_importer import summarize_report
+
+ res = summarize_report({
+ "imported": 7,
+ "perProfile": {"Default": {"added": 5}, "Profile 1": {"added": 2}},
+ "missing": [],
+ })
+ assert res["profiles"] == {"Default": 5, "Profile 1": 2}
+ assert res["failed"] == {} and res["missing"] == []
+ assert "warning" not in res and "error" not in res
+
+
+def test_summarize_mixed_success_is_partial():
+ from zen_password_importer import summarize_report
+
+ res = summarize_report({
+ "imported": 5,
+ "perProfile": {"Default": {"added": 5}, "Profile 1": {"error": "NS_ERROR_FAILURE"}},
+ "missing": [],
+ })
+ assert res["profiles"] == {"Default": 5}
+ assert res["failed"] == {"Profile 1": "NS_ERROR_FAILURE"}
+ assert res["warning"] == "partial_import"
+ assert "error" not in res
+
+
+def test_summarize_missing_profile_is_partial():
+ from zen_password_importer import summarize_report
+
+ res = summarize_report({
+ "imported": 3,
+ "perProfile": {"Default": {"added": 3}},
+ "missing": ["Profile 2"],
+ })
+ assert res["missing"] == ["Profile 2"]
+ assert res["warning"] == "partial_import"
+
+
+def test_summarize_no_profile_migrated_is_error():
+ from zen_password_importer import summarize_report
+
+ res = summarize_report({
+ "imported": 0,
+ "perProfile": {"Default": {"error": "boom"}},
+ "missing": ["Profile 1"],
+ })
+ assert res["error"] == "no_profiles_imported"
+ assert res["failed"] == {"Default": "boom"}
+ assert res["missing"] == ["Profile 1"]
+
+
+def test_summarize_zero_imports_warns():
+ from zen_password_importer import summarize_report
+
+ res = summarize_report({
+ "imported": 0,
+ "perProfile": {"Default": {"added": 0}},
+ "missing": [],
+ })
+ assert res["warning"] == "no_passwords_imported"
+ assert "error" not in res
+
+
+def _password_step_events(monkeypatch, zen_profile, summary):
+ from app.orchestrator import MigrationOrchestrator
+ from extractors import ChromeExtractor
+
+ o = MigrationOrchestrator(source=ChromeExtractor())
+ monkeypatch.setattr(o, "_import_passwords", lambda _zen: summary)
+ o._run_passwords_step(zen_profile)
+ return list(o.bus.drain())
+
+
+def test_step_surfaces_mixed_success(chrome_home, zen_profile, monkeypatch):
+ events = _password_step_events(monkeypatch, zen_profile, {
+ "imported": 5, "profiles": {"Default": 5},
+ "failed": {"Profile 1": "NS_ERROR_FAILURE"}, "missing": ["Profile 2"],
+ "warning": "partial_import",
+ })
+ done = next(e for e in events if e["kind"] == "step_done")
+ notice = done["summary"]["notice"]
+ assert "5 passwords from 1 of 3 profiles" in notice
+ assert "Profile 1 failed (NS_ERROR_FAILURE)" in notice
+ assert "did not find Profile 2" in notice
+ assert done["summary"]["warning"] == "partial_import"
+ assert any(e["kind"] == "warn" and e["message"] == notice for e in events)
+
+
+def test_step_surfaces_missing_profiles(chrome_home, zen_profile, monkeypatch):
+ events = _password_step_events(monkeypatch, zen_profile, {
+ "imported": 2, "profiles": {"Default": 2}, "failed": {},
+ "missing": ["Profile 3", "Profile 4"], "warning": "partial_import",
+ })
+ done = next(e for e in events if e["kind"] == "step_done")
+ assert "did not find Profile 3, Profile 4" in done["summary"]["notice"]
+
+
+def test_step_surfaces_zero_imports(chrome_home, zen_profile, monkeypatch):
+ events = _password_step_events(monkeypatch, zen_profile, {
+ "imported": 0, "profiles": {"Default": 0}, "failed": {}, "missing": [],
+ "warning": "no_passwords_imported",
+ })
+ done = next(e for e in events if e["kind"] == "step_done")
+ assert "No passwords were imported" in done["summary"]["notice"]
+ assert any(e["kind"] == "warn" for e in events)
+
+
+def test_step_errors_when_no_profile_migrated(chrome_home, zen_profile, monkeypatch):
+ events = _password_step_events(monkeypatch, zen_profile, {
+ "imported": 0, "profiles": {}, "failed": {"Default": "boom"},
+ "missing": [], "error": "no_profiles_imported",
+ })
+ assert not any(e["kind"] == "step_done" for e in events)
+ err = next(e for e in events if e["kind"] == "step_error")
+ assert "any Chrome profile" in err["detail"]
+ assert "Default failed (boom)" in err["detail"]
+
+
+def test_step_clean_import_has_no_notice(chrome_home, zen_profile, monkeypatch):
+ events = _password_step_events(monkeypatch, zen_profile, {
+ "imported": 4, "profiles": {"Default": 4}, "failed": {}, "missing": [],
+ })
+ done = next(e for e in events if e["kind"] == "step_done")
+ assert "notice" not in done["summary"]
+ assert not any(e["kind"] == "warn" for e in events)
+
+
+# ---- temp prefs + backup ----
+
+def test_temp_prefs_roundtrip_without_existing_user_js(tmp_path):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "prefs.js").write_text('user_pref("x", 1);\n', encoding="utf-8")
+ imp = _importer(zen, tmp_path)
+
+ prior = imp._apply_temp_prefs(2828)
+ assert prior is None
+ body = (zen / "user.js").read_text()
+ assert '"marionette.port", 2828' in body
+
+ # Simulate Zen persisting the injected prefs into prefs.js.
+ (zen / "prefs.js").write_text(
+ 'user_pref("x", 1);\n'
+ 'user_pref("marionette.port", 2828);\n'
+ 'user_pref("remote.prefs.recommended", false);\n',
+ encoding="utf-8",
+ )
+ imp._restore_prefs(prior)
+ assert not (zen / "user.js").exists()
+ kept = (zen / "prefs.js").read_text()
+ assert "marionette.port" not in kept
+ assert "remote.prefs.recommended" not in kept
+ assert 'user_pref("x", 1);' in kept
+
+
+def test_temp_prefs_preserves_existing_user_js(tmp_path):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "user.js").write_text('user_pref("keep.me", true);\n', encoding="utf-8")
+ imp = _importer(zen, tmp_path)
+
+ prior = imp._apply_temp_prefs(3000)
+ assert prior == 'user_pref("keep.me", true);\n'
+ imp._restore_prefs(prior)
+ assert (zen / "user.js").read_text() == 'user_pref("keep.me", true);\n'
+
+
+def test_backup_copies_login_store(tmp_path):
+ zen = tmp_path / "zen"
+ zen.mkdir()
+ (zen / "logins.json").write_text("{}", encoding="utf-8")
+ (zen / "key4.db").write_bytes(b"k")
+ imp = _importer(zen, tmp_path)
+
+ dest = imp._backup()
+ assert dest is not None and dest.is_dir()
+ assert (dest / "logins.json").is_file()
+ assert (dest / "key4.db").is_file()
+
+
+# ---- zen binary discovery ----
+
+def test_zen_binary_path_finds_macos_app(fake_home, monkeypatch, tmp_path):
+ import app.env_check as env_check
+
+ fake = tmp_path / "Zen.app/Contents/MacOS/zen"
+ fake.parent.mkdir(parents=True)
+ fake.write_text("#!/bin/sh\n")
+ monkeypatch.setattr(env_check, "_ZEN_PROCESS_PATHS", (str(fake),))
+ assert env_check.zen_binary_path() == fake
+
+
+def test_zen_binary_path_none_when_absent(fake_home, monkeypatch):
+ import app.env_check as env_check
+
+ monkeypatch.setattr(env_check, "_ZEN_PROCESS_PATHS", ("/nope/zen",))
+ assert env_check.zen_binary_path() is None
+
+
+# ---- orchestrator skip for non-Chromium ----
+
+def test_orchestrator_skips_non_chromium_passwords(firefox_home, zen_profile):
+ from app.orchestrator import MigrationOrchestrator
+ from extractors import FirefoxExtractor
+
+ o = MigrationOrchestrator(source=FirefoxExtractor())
+ res = o._import_passwords(zen_profile)
+ assert res["error"] == "passwords_source_unsupported"
+
+
+def test_orchestrator_chromium_reaches_importer(chrome_home, zen_profile, monkeypatch):
+ """A Chromium source must resolve the whole wiring (env_check import,
+ importer construction) and stop at a guard, never crash. Guards against
+ the bare-module import regression."""
+ import app.env_check as env_check
+ from app.orchestrator import MigrationOrchestrator
+ from extractors import ChromeExtractor
+
+ profile = ChromeExtractor().profile_paths()[0]
+ _make_login_data(profile / "Login Data", usable=2)
+ # Pin a resolvable binary and an empty login store so nothing launches.
+ monkeypatch.setattr(env_check, "zen_binary_path", lambda: Path(sys.executable))
+ (zen_profile / "key4.db").unlink(missing_ok=True)
+
+ o = MigrationOrchestrator(source=ChromeExtractor())
+ res = o._import_passwords(zen_profile)
+ assert res["error"] == "zen_login_store_missing"