From f79a67bb15ad7408b5bd065ecd2175b985734146 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 21:13:13 -0400 Subject: [PATCH] refactor(ext/setup): extract pure helpers to setup-helpers.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup wizard was 1205 lines in a single file. Extract the state-independent helpers (escapeHtml, ratePassphrase, scheduleRate, entropyText, STRENGTH_LABELS, the Strength interface) into a sibling setup-helpers.ts. updateStrengthUi stays in setup.ts since it walks the live wizard state object and would force every caller to thread that state through. setup.ts: 1205 → 1137 lines. Pure mechanical extraction; no behavior change. Existing tests are the safety net (24 vitest files, all pass). Co-Authored-By: Claude Opus 4.7 --- extension/src/setup/setup-helpers.ts | 84 ++++++++++++++++++++++++++++ extension/src/setup/setup.ts | 84 +++------------------------- 2 files changed, 92 insertions(+), 76 deletions(-) create mode 100644 extension/src/setup/setup-helpers.ts diff --git a/extension/src/setup/setup-helpers.ts b/extension/src/setup/setup-helpers.ts new file mode 100644 index 0000000..31087c3 --- /dev/null +++ b/extension/src/setup/setup-helpers.ts @@ -0,0 +1,84 @@ +/// Pure helpers for the setup wizard. Anything that doesn't read or mutate +/// the wizard state lives here so setup.ts can focus on flow and rendering. +/// +/// `updateStrengthUi` deliberately stays in setup.ts because it walks the +/// live `state` object — keeping it here would force every caller to pass +/// the entire wizard state through. + +export interface Strength { + score: number; + guessesLog10: number; +} + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4] +/// and guesses_log10, or -1 on both if the round-trip failed. +export function ratePassphrase(passphrase: string): Promise { + return new Promise((resolve) => { + try { + chrome.runtime.sendMessage( + { type: 'rate_passphrase', passphrase }, + (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { + if (chrome.runtime.lastError) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); + resolve({ score: -1, guessesLog10: -1 }); return; + } + if (!response?.ok) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase rejected by SW:', response); + resolve({ score: -1, guessesLog10: -1 }); return; + } + resolve({ + score: response.data?.score ?? -1, + guessesLog10: response.data?.guesses_log10 ?? -1, + }); + }, + ); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[relicario setup] rate_passphrase threw:', err); + resolve({ score: -1, guessesLog10: -1 }); + } + }); +} + +/// 150ms debounce around the rate_passphrase call so we don't hammer the SW +/// on every keystroke. The last invocation wins. +let rateDebounceTimer: ReturnType | null = null; +export function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void { + if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); + rateDebounceTimer = setTimeout(async () => { + rateDebounceTimer = null; + if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; } + onResult(await ratePassphrase(passphrase)); + }, 150); +} + +export const STRENGTH_LABELS: Record = { + 0: { text: 'very weak', cls: 's-very-weak' }, + 1: { text: 'weak', cls: 's-weak' }, + 2: { text: 'fair', cls: 's-fair' }, + 3: { text: 'good', cls: 's-good' }, + 4: { text: 'strong', cls: 's-strong' }, +}; + +/// Render the entropy readout as "~10^N guesses to crack" or a friendlier +/// shorthand for large values. Returns empty string when no data. +export function entropyText(guessesLog10: number): string { + if (guessesLog10 < 0) return ''; + const rounded = Math.round(guessesLog10); + if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`; + if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`; + if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`; + if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`; + if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`; + return `~10^${rounded} guesses — effectively uncrackable`; +} diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index db73702..90eec22 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -9,6 +9,13 @@ import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host'; import { addDevice } from '../service-worker/devices'; import { probeVault } from './probe'; +import { + escapeHtml, + ratePassphrase, + scheduleRate, + STRENGTH_LABELS, + entropyText, +} from './setup-helpers'; import type { VaultConfig } from '../shared/types'; import type { SessionHandle } from 'relicario-wasm'; @@ -85,82 +92,7 @@ const state: WizardState = { deviceName: '', }; -// --- Helpers --- - -function escapeHtml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -interface Strength { score: number; guessesLog10: number } - -/// Call the SW to score a passphrase with zxcvbn. Returns score in [0, 4] -/// and guesses_log10, or -1 on both if the round-trip failed. -function ratePassphrase(passphrase: string): Promise { - return new Promise((resolve) => { - try { - chrome.runtime.sendMessage( - { type: 'rate_passphrase', passphrase }, - (response: { ok: boolean; data?: { score: number; guesses_log10: number }; error?: string }) => { - if (chrome.runtime.lastError) { - // eslint-disable-next-line no-console - console.warn('[relicario setup] rate_passphrase lastError:', chrome.runtime.lastError); - resolve({ score: -1, guessesLog10: -1 }); return; - } - if (!response?.ok) { - // eslint-disable-next-line no-console - console.warn('[relicario setup] rate_passphrase rejected by SW:', response); - resolve({ score: -1, guessesLog10: -1 }); return; - } - resolve({ - score: response.data?.score ?? -1, - guessesLog10: response.data?.guesses_log10 ?? -1, - }); - }, - ); - } catch (err) { - // eslint-disable-next-line no-console - console.warn('[relicario setup] rate_passphrase threw:', err); - resolve({ score: -1, guessesLog10: -1 }); - } - }); -} - -/// 150ms debounce around the rate_passphrase call so we don't hammer the SW -/// on every keystroke. The last invocation wins. -let rateDebounceTimer: ReturnType | null = null; -function scheduleRate(passphrase: string, onResult: (s: Strength) => void): void { - if (rateDebounceTimer !== null) clearTimeout(rateDebounceTimer); - rateDebounceTimer = setTimeout(async () => { - rateDebounceTimer = null; - if (!passphrase) { onResult({ score: -1, guessesLog10: -1 }); return; } - onResult(await ratePassphrase(passphrase)); - }, 150); -} - -const STRENGTH_LABELS: Record = { - 0: { text: 'very weak', cls: 's-very-weak' }, - 1: { text: 'weak', cls: 's-weak' }, - 2: { text: 'fair', cls: 's-fair' }, - 3: { text: 'good', cls: 's-good' }, - 4: { text: 'strong', cls: 's-strong' }, -}; - -/// Render the entropy readout as "~10^N guesses to crack" or a friendlier -/// shorthand for large values. Returns empty string when no data. -function entropyText(guessesLog10: number): string { - if (guessesLog10 < 0) return ''; - const rounded = Math.round(guessesLog10); - if (rounded < 6) return `~10^${rounded} guesses — trivially crackable`; - if (rounded < 9) return `~10^${rounded} guesses — minutes on a single GPU`; - if (rounded < 12) return `~10^${rounded} guesses — hours to days on a GPU`; - if (rounded < 15) return `~10^${rounded} guesses — years on consumer hardware`; - if (rounded < 20) return `~10^${rounded} guesses — beyond consumer-hardware reach`; - return `~10^${rounded} guesses — effectively uncrackable`; -} +// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- /// Update just the meter DOM without a full re-render (so the input keeps /// focus and the user's cursor position is preserved). Also updates the