ext(affordances): wireTotpQr (jsqr lazy-load) for QR -> otpauth:// fill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-01 22:14:05 -04:00
parent c91b31a7ca
commit bd8102c9ad
6 changed files with 3720 additions and 1 deletions

3528
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,9 @@
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"jsqr": "^1.4.0"
},
"devDependencies": {
"@types/chrome": "^0.1.40",
"copy-webpack-plugin": "^12.0",

View File

@@ -1432,3 +1432,20 @@ textarea {
.totp-countdown {
font-size: 11px;
}
.totp-qr-panel {
margin-top: 6px;
padding: 10px;
border: 1px dashed var(--border-subtle);
border-radius: 3px;
background: var(--bg-input);
}
.totp-qr-panel input[type="file"] {
display: block;
font-family: inherit;
color: var(--text-muted);
}
.totp-qr-error {
margin-top: 6px;
font-size: 11px;
color: var(--danger, #c75a4f);
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { wireTotpPreview } from '../totp-tools';
import { wireTotpPreview, wireTotpQr } from '../totp-tools';
describe('wireTotpPreview', () => {
let form: HTMLElement;
@@ -58,3 +58,68 @@ describe('wireTotpPreview', () => {
expect(sendMessage.mock.calls.length).toBe(callsBefore);
});
});
describe('wireTotpQr', () => {
let form: HTMLElement;
let decodeQrFromBlob: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useRealTimers();
form = document.createElement('div');
form.innerHTML = `
<input id="f-totp" type="text" value="" />
<button id="totp-qr-btn" class="glyph-btn" type="button" title="QR">◫</button>
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
<input id="totp-qr-file" type="file" accept="image/*" />
<div id="totp-qr-error" class="totp-qr-error"></div>
</div>
`;
document.body.appendChild(form);
decodeQrFromBlob = vi.fn();
});
it('toggles the panel on button click', () => {
wireTotpQr(form, { decodeQrFromBlob });
const btn = form.querySelector('#totp-qr-btn') as HTMLButtonElement;
const panel = form.querySelector('#totp-qr-panel') as HTMLElement;
expect(panel.hidden).toBe(true);
btn.click();
expect(panel.hidden).toBe(false);
btn.click();
expect(panel.hidden).toBe(true);
});
it('fills f-totp on successful decode of otpauth:// URI', async () => {
decodeQrFromBlob.mockResolvedValue('otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example');
wireTotpQr(form, { decodeQrFromBlob });
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
const fakeFile = new File(['x'], 'qr.png', { type: 'image/png' });
Object.defineProperty(fileInput, 'files', { value: [fakeFile] });
fileInput.dispatchEvent(new Event('change'));
await Promise.resolve(); await Promise.resolve();
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('JBSWY3DPEHPK3PXP');
});
it('shows error when QR decodes but is not otpauth://', async () => {
decodeQrFromBlob.mockResolvedValue('https://example.com/');
wireTotpQr(form, { decodeQrFromBlob });
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
fileInput.dispatchEvent(new Event('change'));
await Promise.resolve(); await Promise.resolve();
const err = form.querySelector('#totp-qr-error') as HTMLElement;
expect(err.textContent).toMatch(/not a totp uri/i);
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('');
});
it('shows error when decode returns null (no QR found)', async () => {
decodeQrFromBlob.mockResolvedValue(null);
wireTotpQr(form, { decodeQrFromBlob });
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
fileInput.dispatchEvent(new Event('change'));
await Promise.resolve(); await Promise.resolve();
const err = form.querySelector('#totp-qr-error') as HTMLElement;
expect(err.textContent).toMatch(/no qr found/i);
});
});

View File

@@ -51,3 +51,92 @@ export function wireTotpPreview(form: HTMLElement, opts: TotpPreviewOpts): () =>
row.hidden = true;
};
}
/// Lazy-load jsqr and decode a QR from a Blob/File. Returns the decoded
/// string, or null if no QR was found.
async function defaultDecodeQrFromBlob(blob: Blob): Promise<string | null> {
const [{ default: jsQR }] = await Promise.all([import('jsqr')]);
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const result = jsQR(imageData.data, imageData.width, imageData.height);
return result?.data ?? null;
}
export interface TotpQrOpts {
/// Inject a stub in tests where canvas + imports aren't available.
decodeQrFromBlob?: (blob: Blob) => Promise<string | null>;
}
export function wireTotpQr(form: HTMLElement, opts: TotpQrOpts = {}): void {
const btn = form.querySelector<HTMLButtonElement>('#totp-qr-btn');
const panel = form.querySelector<HTMLElement>('#totp-qr-panel');
const fileInput = form.querySelector<HTMLInputElement>('#totp-qr-file');
const errEl = form.querySelector<HTMLElement>('#totp-qr-error');
const totpInput = form.querySelector<HTMLInputElement>('#f-totp');
if (!btn || !panel || !fileInput || !errEl || !totpInput) return;
const decode = opts.decodeQrFromBlob ?? defaultDecodeQrFromBlob;
btn.addEventListener('click', () => {
panel.hidden = !panel.hidden;
errEl.textContent = '';
});
const handleBlob = async (blob: Blob) => {
errEl.textContent = '';
let decoded: string | null;
try {
decoded = await decode(blob);
} catch (e) {
errEl.textContent = `decode failed: ${e instanceof Error ? e.message : String(e)}`;
return;
}
if (!decoded) {
errEl.textContent = 'no QR found in image';
return;
}
if (!decoded.startsWith('otpauth://')) {
errEl.textContent = 'not a TOTP URI (expected otpauth://...)';
return;
}
try {
const u = new URL(decoded);
const secret = u.searchParams.get('secret');
if (!secret) {
errEl.textContent = 'TOTP URI missing secret';
return;
}
totpInput.value = secret;
totpInput.dispatchEvent(new Event('input', { bubbles: true })); // trigger preview
panel.hidden = true;
} catch {
errEl.textContent = 'TOTP URI did not parse';
}
};
fileInput.addEventListener('change', () => {
const f = fileInput.files?.[0];
if (f) void handleBlob(f);
});
panel.addEventListener('paste', (e) => {
const item = Array.from((e as ClipboardEvent).clipboardData?.items ?? []).find((i) => i.type.startsWith('image/'));
if (item) {
const blob = item.getAsFile();
if (blob) void handleBlob(blob);
}
});
panel.addEventListener('dragover', (e) => { e.preventDefault(); });
panel.addEventListener('drop', (e) => {
e.preventDefault();
const f = (e as DragEvent).dataTransfer?.files?.[0];
if (f) void handleBlob(f);
});
}

View File

@@ -1462,3 +1462,20 @@ textarea {
.totp-countdown {
font-size: 11px;
}
.totp-qr-panel {
margin-top: 6px;
padding: 10px;
border: 1px dashed var(--border-subtle);
border-radius: 3px;
background: var(--bg-input);
}
.totp-qr-panel input[type="file"] {
display: block;
font-family: inherit;
color: var(--text-muted);
}
.totp-qr-error {
margin-top: 6px;
font-size: 11px;
color: var(--danger, #c75a4f);
}