From a7dbf351262769e6e2202202dc06a74e3cb86291 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 21:13:05 -0400 Subject: [PATCH] feat(ext): sync now button + device register from popup; vault tab parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes three audit gaps in one pass: 1. Sync now button in the popup settings view (📤). Triggers the existing { type: 'sync' } SW message and surfaces success / failure inline. The SW message was already wired but had no UI entry point. 2. Device registration from the popup. The "Register this device" button on the devices view used to error out with a "not yet implemented" message; it now opens an inline name input (default = browser+OS), and on confirm sends a new register_this_device SW message that generates an ed25519 keypair via WASM, persists private_key + name to chrome.storage.local, and writes the public key to the remote devices.json. No setup-wizard detour. 3. Vault tab is now an authorized sender for popup-only SW messages. The router accepts vault.html alongside popup.html, so the fullscreen tab can drive the same flows. Test covers acceptance from the vault tab. New SW message: register_this_device { name }. Added to PopupMessage and POPUP_ONLY_TYPES, handled in router/popup-only.ts. Tests: 5 new vitest cases (3 in settings.test.ts, 2 in devices.test.ts) + 1 router test for vault-tab acceptance. All 194 extension tests pass. Co-Authored-By: Claude Opus 4.7 --- .../components/__tests__/devices.test.ts | 38 +++++++++++ .../components/__tests__/settings.test.ts | 66 +++++++++++++++++++ extension/src/popup/components/devices.ts | 57 ++++++++++++++-- extension/src/popup/components/settings.ts | 14 ++++ .../router/__tests__/router.test.ts | 8 +++ extension/src/service-worker/router/index.ts | 3 +- .../src/service-worker/router/popup-only.ts | 18 +++++ extension/src/shared/messages.ts | 3 +- 8 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 extension/src/popup/components/__tests__/settings.test.ts diff --git a/extension/src/popup/components/__tests__/devices.test.ts b/extension/src/popup/components/__tests__/devices.test.ts index 551091c..7e73520 100644 --- a/extension/src/popup/components/__tests__/devices.test.ts +++ b/extension/src/popup/components/__tests__/devices.test.ts @@ -91,4 +91,42 @@ describe('devices view', () => { expect(navigate).toHaveBeenCalledWith('list'); }); + + it('clicking register button reveals an inline name input', async () => { + (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] }, + }); + + await renderDevices(app); + app.querySelector('#register-btn')!.click(); + + expect(app.querySelector('#register-name-input')).not.toBeNull(); + expect(app.querySelector('#register-confirm-btn')).not.toBeNull(); + }); + + it('confirming register sends register_this_device with the entered name', async () => { + (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Unknown' }); + // Initial list_devices. + (sendMessage as ReturnType) + .mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] } }) + // register_this_device. + .mockResolvedValueOnce({ ok: true }) + // Re-render's list_devices. + .mockResolvedValueOnce({ ok: true, data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }, { name: 'Test Browser', public_key: 'q', added_at: 2 }] } }); + // Re-render also re-reads device_name from storage. + (chrome.storage.local.get as ReturnType).mockResolvedValueOnce({ device_name: 'Test Browser' }); + + await renderDevices(app); + app.querySelector('#register-btn')!.click(); + const input = app.querySelector('#register-name-input')!; + input.value = 'Test Browser'; + app.querySelector('#register-confirm-btn')!.click(); + // Wait a microtask for the async handler to run. + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + expect(sendMessage).toHaveBeenCalledWith({ type: 'register_this_device', name: 'Test Browser' }); + }); }); diff --git a/extension/src/popup/components/__tests__/settings.test.ts b/extension/src/popup/components/__tests__/settings.test.ts new file mode 100644 index 0000000..98262a9 --- /dev/null +++ b/extension/src/popup/components/__tests__/settings.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderSettings } from '../settings'; + +vi.mock('../../../shared/state', () => ({ + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: vi.fn(() => false), + openVaultTab: vi.fn(), +})); + +import { sendMessage } from '../../../shared/state'; + +function settingsResponses() { + // Two parallel calls in renderSettings: get_settings + get_blacklist. + (sendMessage as ReturnType) + .mockResolvedValueOnce({ ok: true, data: { settings: { captureEnabled: false, captureStyle: 'bar' } } }) + .mockResolvedValueOnce({ ok: true, data: { blacklist: [] } }); +} + +describe('settings view', () => { + let app: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + (sendMessage as ReturnType).mockReset(); + }); + + it('renders a Sync now button', async () => { + settingsResponses(); + + await renderSettings(app); + + expect(app.querySelector('#sync-now-btn')).not.toBeNull(); + }); + + it('clicking Sync now sends a sync message and shows feedback on success', async () => { + settingsResponses(); + (sendMessage as ReturnType).mockResolvedValueOnce({ ok: true }); + + await renderSettings(app); + app.querySelector('#sync-now-btn')!.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + expect(sendMessage).toHaveBeenCalledWith({ type: 'sync' }); + const status = app.querySelector('#sync-status')!; + expect(status.textContent).toMatch(/synced/i); + }); + + it('shows the error when sync fails', async () => { + settingsResponses(); + (sendMessage as ReturnType).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' }); + + await renderSettings(app); + app.querySelector('#sync-now-btn')!.click(); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + + const status = app.querySelector('#sync-status')!; + expect(status.textContent).toMatch(/remote_unreachable/); + }); +}); diff --git a/extension/src/popup/components/devices.ts b/extension/src/popup/components/devices.ts index 0574888..ee7a7fb 100644 --- a/extension/src/popup/components/devices.ts +++ b/extension/src/popup/components/devices.ts @@ -13,6 +13,20 @@ function relativeTime(unixSec: number): string { return `${Math.floor(diff / 2592000)}mo ago`; } +function detectDefaultDeviceName(): string { + const ua = navigator.userAgent ?? ''; + const platform = (navigator.platform ?? '').toLowerCase(); + const isFirefox = /firefox/i.test(ua); + const isEdge = /edg/i.test(ua); + const isChrome = /chrome/i.test(ua) && !isEdge; + const browser = isFirefox ? 'Firefox' : isEdge ? 'Edge' : isChrome ? 'Chrome' : 'Browser'; + const os = platform.includes('mac') ? 'macOS' + : platform.includes('win') ? 'Windows' + : platform.includes('linux') ? 'Linux' + : 'Unknown'; + return `${browser} on ${os}`; +} + export function teardown(): void { // No cleanup needed } @@ -64,11 +78,44 @@ export async function renderDevices(app: HTMLElement): Promise { // Wire handlers document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); - document.getElementById('register-btn')?.addEventListener('click', async () => { - // Generate keypair and register - // This would need WASM access - for now, redirect to a registration flow - // The full implementation happens in Task 12 (setup wizard integration) - setState({ error: 'Device registration from here is not yet implemented. Use setup wizard.' }); + document.getElementById('register-btn')?.addEventListener('click', () => { + const banner = document.querySelector('.device-banner'); + if (!banner) return; + const defaultName = detectDefaultDeviceName(); + banner.innerHTML = ` + + +
+ + +
+ `; + + document.getElementById('register-cancel-btn')?.addEventListener('click', () => { + renderDevices(app); + }); + + document.getElementById('register-confirm-btn')?.addEventListener('click', async () => { + const input = document.getElementById('register-name-input') as HTMLInputElement | null; + const name = input?.value.trim(); + if (!name) { + setState({ error: 'Device name is required' }); + return; + } + const result = await sendMessage({ type: 'register_this_device', name }); + if (result.ok) { + renderDevices(app); + } else { + setState({ error: result.error }); + } + }); }); document.querySelectorAll('[data-revoke]').forEach((btn) => { diff --git a/extension/src/popup/components/settings.ts b/extension/src/popup/components/settings.ts index 6584b77..d4cff97 100644 --- a/extension/src/popup/components/settings.ts +++ b/extension/src/popup/components/settings.ts @@ -57,6 +57,8 @@ export async function renderSettings(app: HTMLElement): Promise {
+ +
@@ -77,6 +79,18 @@ export async function renderSettings(app: HTMLElement): Promise { document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash')); document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices')); + // Sync now button + document.getElementById('sync-now-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('sync-now-btn') as HTMLButtonElement | null; + const status = document.getElementById('sync-status'); + if (!btn || !status) return; + btn.disabled = true; + status.textContent = 'syncing...'; + const result = await sendMessage({ type: 'sync' }); + btn.disabled = false; + status.textContent = result.ok ? 'synced ✓' : `sync failed: ${result.error}`; + }); + // Capture enabled toggle document.getElementById('capture-enabled')?.addEventListener('change', async (e) => { const checked = (e.target as HTMLInputElement).checked; diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 7da418c..8b0b01d 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -60,6 +60,10 @@ function makeContentSender(pageUrl = 'https://example.com/'): chrome.runtime.Mes }; } +function makeVaultSender(): chrome.runtime.MessageSender { + return { url: `chrome-extension://relicario-test-id/vault.html`, id: 'relicario-test-id' }; +} + function makeExternalSender(): chrome.runtime.MessageSender { return { url: 'https://evil.example/', id: 'some-other-extension' }; } @@ -97,6 +101,10 @@ describe('router sender dispatch', () => { const res = await route(msg, state, makePopupSender()); expect(res).toMatchObject({ ok: true }); }); + it(`accepts popup-only "${msg.type}" from vault tab`, async () => { + const res = await route(msg, state, makeVaultSender()); + expect(res).toMatchObject({ ok: true }); + }); it(`rejects popup-only "${msg.type}" from content`, async () => { const res = await route(msg, state, makeContentSender()); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); diff --git a/extension/src/service-worker/router/index.ts b/extension/src/service-worker/router/index.ts index 94dcb35..3f61589 100644 --- a/extension/src/service-worker/router/index.ts +++ b/extension/src/service-worker/router/index.ts @@ -32,10 +32,11 @@ export async function route( sender: chrome.runtime.MessageSender, ): Promise { const popupUrl = chrome.runtime.getURL('popup.html'); + const vaultUrl = chrome.runtime.getURL('vault.html'); const setupUrl = chrome.runtime.getURL('setup.html'); const senderUrl = sender.url ?? ''; - const isPopup = senderUrl.startsWith(popupUrl); + const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl); const isSetup = senderUrl.startsWith(setupUrl); const isContent = sender.tab !== undefined && sender.frameId === 0 diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 00da03d..16f45b3 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -310,6 +310,24 @@ export async function handle( return { ok: true }; } + case 'register_this_device': { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const keypair = JSON.parse(state.wasm.generate_device_keypair()) as { + public_key_hex: string; + private_key_base64: string; + }; + await chrome.storage.local.set({ + device_name: msg.name, + device_private_key: keypair.private_key_base64, + }); + await devices.addDevice(state.gitHost, { + name: msg.name, + public_key: keypair.public_key_hex, + added_at: Math.floor(Date.now() / 1000), + }); + return { ok: true }; + } + case 'revoke_device': { if (!state.gitHost) return { ok: false, error: 'vault_locked' }; await devices.revokeDevice(state.gitHost, msg.name); diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 934775a..a669523 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -40,6 +40,7 @@ export type PopupMessage = | { type: 'download_attachment'; itemId: string; attachmentId: string } | { type: 'list_devices' } | { type: 'add_device'; name: string; public_key: string } + | { type: 'register_this_device'; name: string } | { type: 'revoke_device'; name: string } | { type: 'list_trashed' } | { type: 'restore_item'; id: ItemId } @@ -148,7 +149,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'ack_autofill_origin', 'get_settings', 'update_settings', 'get_vault_settings', 'update_vault_settings', 'get_blacklist', 'remove_blacklist', 'upload_attachment', 'download_attachment', - 'list_devices', 'add_device', 'revoke_device', + 'list_devices', 'add_device', 'register_this_device', 'revoke_device', 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash', 'get_field_history', 'get_session_config', 'update_session_config',