refactor(ext/setup): extract pure helpers to setup-helpers.ts
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 <noreply@anthropic.com>
This commit is contained in:
84
extension/src/setup/setup-helpers.ts
Normal file
84
extension/src/setup/setup-helpers.ts
Normal file
@@ -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, '>')
|
||||||
|
.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<Strength> {
|
||||||
|
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<typeof setTimeout> | 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<number, { text: string; cls: string }> = {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -9,6 +9,13 @@
|
|||||||
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
|
import { createGitHost, uint8ArrayToBase64 } from '../service-worker/git-host';
|
||||||
import { addDevice } from '../service-worker/devices';
|
import { addDevice } from '../service-worker/devices';
|
||||||
import { probeVault } from './probe';
|
import { probeVault } from './probe';
|
||||||
|
import {
|
||||||
|
escapeHtml,
|
||||||
|
ratePassphrase,
|
||||||
|
scheduleRate,
|
||||||
|
STRENGTH_LABELS,
|
||||||
|
entropyText,
|
||||||
|
} from './setup-helpers';
|
||||||
import type { VaultConfig } from '../shared/types';
|
import type { VaultConfig } from '../shared/types';
|
||||||
import type { SessionHandle } from 'relicario-wasm';
|
import type { SessionHandle } from 'relicario-wasm';
|
||||||
|
|
||||||
@@ -85,82 +92,7 @@ const state: WizardState = {
|
|||||||
deviceName: '',
|
deviceName: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.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<Strength> {
|
|
||||||
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<typeof setTimeout> | 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<number, { text: string; cls: string }> = {
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update just the meter DOM without a full re-render (so the input keeps
|
/// 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
|
/// focus and the user's cursor position is preserved). Also updates the
|
||||||
|
|||||||
Reference in New Issue
Block a user