feat(ext): sync now button + device register from popup; vault tab parity

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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 21:13:05 -04:00
parent 086b73b260
commit a7dbf35126
8 changed files with 200 additions and 7 deletions

View File

@@ -91,4 +91,42 @@ describe('devices view', () => {
expect(navigate).toHaveBeenCalledWith('list'); expect(navigate).toHaveBeenCalledWith('list');
}); });
it('clicking register button reveals an inline name input', async () => {
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: { devices: [{ name: 'CLI', public_key: 'k', added_at: 1 }] },
});
await renderDevices(app);
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
expect(app.querySelector<HTMLInputElement>('#register-name-input')).not.toBeNull();
expect(app.querySelector<HTMLButtonElement>('#register-confirm-btn')).not.toBeNull();
});
it('confirming register sends register_this_device with the entered name', async () => {
(chrome.storage.local.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Unknown' });
// Initial list_devices.
(sendMessage as ReturnType<typeof vi.fn>)
.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<typeof vi.fn>).mockResolvedValueOnce({ device_name: 'Test Browser' });
await renderDevices(app);
app.querySelector<HTMLButtonElement>('#register-btn')!.click();
const input = app.querySelector<HTMLInputElement>('#register-name-input')!;
input.value = 'Test Browser';
app.querySelector<HTMLButtonElement>('#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' });
});
}); });

View File

@@ -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<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, data: { settings: { captureEnabled: false, captureStyle: 'bar' } } })
.mockResolvedValueOnce({ ok: true, data: { blacklist: [] } });
}
describe('settings view', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
(sendMessage as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
await renderSettings(app);
app.querySelector<HTMLButtonElement>('#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<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
await renderSettings(app);
app.querySelector<HTMLButtonElement>('#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/);
});
});

View File

@@ -13,6 +13,20 @@ function relativeTime(unixSec: number): string {
return `${Math.floor(diff / 2592000)}mo ago`; 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 { export function teardown(): void {
// No cleanup needed // No cleanup needed
} }
@@ -64,11 +78,44 @@ export async function renderDevices(app: HTMLElement): Promise<void> {
// Wire handlers // Wire handlers
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('register-btn')?.addEventListener('click', async () => { document.getElementById('register-btn')?.addEventListener('click', () => {
// Generate keypair and register const banner = document.querySelector('.device-banner');
// This would need WASM access - for now, redirect to a registration flow if (!banner) return;
// The full implementation happens in Task 12 (setup wizard integration) const defaultName = detectDefaultDeviceName();
setState({ error: 'Device registration from here is not yet implemented. Use setup wizard.' }); banner.innerHTML = `
<label class="label" for="register-name-input" style="display:block;margin-bottom:4px;">
Name this device
</label>
<input
id="register-name-input"
type="text"
value="${escapeHtml(defaultName)}"
style="width:100%;margin-bottom:8px;"
>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary" id="register-confirm-btn">Register</button>
<button class="btn" id="register-cancel-btn">Cancel</button>
</div>
`;
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<HTMLButtonElement>('[data-revoke]').forEach((btn) => { document.querySelectorAll<HTMLButtonElement>('[data-revoke]').forEach((btn) => {

View File

@@ -57,6 +57,8 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button> <button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">🗑️ Trash</button>
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button> <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">🔐 Devices</button>
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button>
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
</div> </div>
<div> <div>
@@ -77,6 +79,18 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash')); document.getElementById('trash-btn')?.addEventListener('click', () => navigate('trash'));
document.getElementById('devices-btn')?.addEventListener('click', () => navigate('devices')); 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 // Capture enabled toggle
document.getElementById('capture-enabled')?.addEventListener('change', async (e) => { document.getElementById('capture-enabled')?.addEventListener('change', async (e) => {
const checked = (e.target as HTMLInputElement).checked; const checked = (e.target as HTMLInputElement).checked;

View File

@@ -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 { function makeExternalSender(): chrome.runtime.MessageSender {
return { url: 'https://evil.example/', id: 'some-other-extension' }; return { url: 'https://evil.example/', id: 'some-other-extension' };
} }
@@ -97,6 +101,10 @@ describe('router sender dispatch', () => {
const res = await route(msg, state, makePopupSender()); const res = await route(msg, state, makePopupSender());
expect(res).toMatchObject({ ok: true }); 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 () => { it(`rejects popup-only "${msg.type}" from content`, async () => {
const res = await route(msg, state, makeContentSender()); const res = await route(msg, state, makeContentSender());
expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); expect(res).toEqual({ ok: false, error: 'unauthorized_sender' });

View File

@@ -32,10 +32,11 @@ export async function route(
sender: chrome.runtime.MessageSender, sender: chrome.runtime.MessageSender,
): Promise<Response> { ): Promise<Response> {
const popupUrl = chrome.runtime.getURL('popup.html'); const popupUrl = chrome.runtime.getURL('popup.html');
const vaultUrl = chrome.runtime.getURL('vault.html');
const setupUrl = chrome.runtime.getURL('setup.html'); const setupUrl = chrome.runtime.getURL('setup.html');
const senderUrl = sender.url ?? ''; const senderUrl = sender.url ?? '';
const isPopup = senderUrl.startsWith(popupUrl); const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl);
const isSetup = senderUrl.startsWith(setupUrl); const isSetup = senderUrl.startsWith(setupUrl);
const isContent = sender.tab !== undefined const isContent = sender.tab !== undefined
&& sender.frameId === 0 && sender.frameId === 0

View File

@@ -310,6 +310,24 @@ export async function handle(
return { ok: true }; 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': { case 'revoke_device': {
if (!state.gitHost) return { ok: false, error: 'vault_locked' }; if (!state.gitHost) return { ok: false, error: 'vault_locked' };
await devices.revokeDevice(state.gitHost, msg.name); await devices.revokeDevice(state.gitHost, msg.name);

View File

@@ -40,6 +40,7 @@ export type PopupMessage =
| { type: 'download_attachment'; itemId: string; attachmentId: string } | { type: 'download_attachment'; itemId: string; attachmentId: string }
| { type: 'list_devices' } | { type: 'list_devices' }
| { type: 'add_device'; name: string; public_key: string } | { type: 'add_device'; name: string; public_key: string }
| { type: 'register_this_device'; name: string }
| { type: 'revoke_device'; name: string } | { type: 'revoke_device'; name: string }
| { type: 'list_trashed' } | { type: 'list_trashed' }
| { type: 'restore_item'; id: ItemId } | { type: 'restore_item'; id: ItemId }
@@ -148,7 +149,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'ack_autofill_origin', 'get_settings', 'update_settings', 'ack_autofill_origin', 'get_settings', 'update_settings',
'get_vault_settings', 'update_vault_settings', 'get_blacklist', 'get_vault_settings', 'update_vault_settings', 'get_blacklist',
'remove_blacklist', 'upload_attachment', 'download_attachment', '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', 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
'get_field_history', 'get_field_history',
'get_session_config', 'update_session_config', 'get_session_config', 'update_session_config',