Merge phase-c-2-storage: Plan C Phase 2 (SW storage extract + itemToManifestEntry dedup)

P1.9 dedup landed: loadDeviceSettings, saveDeviceSettings, loadBlacklist,
saveBlacklist all live in service-worker/storage.ts; itemToManifestEntry
in service-worker/vault.ts. Both router files import from one home each.

popup-only.ts shrank 727 → 690 LOC; content-callable.ts shrank 204 → 171.
376/376 vitest tests pass (baseline 371 + 5 new storage.test.ts cases).

Phases 1 + 5 still running in parallel in their own worktrees.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-30 21:45:08 -04:00
5 changed files with 116 additions and 75 deletions

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist }
from '../storage';
function mockChromeStorage(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as { chrome: unknown }).chrome = {
storage: {
local: {
get: vi.fn((keys: string | string[]) => {
const arr = Array.isArray(keys) ? keys : [keys];
const out: Record<string, unknown> = {};
for (const k of arr) if (k in store) out[k] = store[k];
return Promise.resolve(out);
}),
set: vi.fn((kv: Record<string, unknown>) => {
Object.assign(store, kv);
return Promise.resolve();
}),
},
},
} as never;
return store;
}
describe('service-worker/storage', () => {
beforeEach(() => { mockChromeStorage(); });
it('loadDeviceSettings returns default when storage is empty', async () => {
const s = await loadDeviceSettings();
expect(s.captureEnabled).toBe(false);
expect(s.captureStyle).toBe('bar');
});
it('loadDeviceSettings returns stored value', async () => {
mockChromeStorage({ relicarioSettings: { captureEnabled: true, captureStyle: 'toast' } });
const s = await loadDeviceSettings();
expect(s.captureEnabled).toBe(true);
expect(s.captureStyle).toBe('toast');
});
it('saveDeviceSettings persists', async () => {
const store = mockChromeStorage();
await saveDeviceSettings({ captureEnabled: true, captureStyle: 'bar' });
expect(store.relicarioSettings).toEqual({ captureEnabled: true, captureStyle: 'bar' });
});
it('loadBlacklist returns empty array by default', async () => {
expect(await loadBlacklist()).toEqual([]);
});
it('saveBlacklist / loadBlacklist round-trips', async () => {
await saveBlacklist(['example.com', 'evil.test']);
expect(await loadBlacklist()).toEqual(['example.com', 'evil.test']);
});
});

View File

@@ -8,7 +8,9 @@ import type { ContentMessage, Response } from '../../shared/messages';
import type { Item, Manifest } from '../../shared/types'; import type { Item, Manifest } from '../../shared/types';
import type { GitHost } from '../git-host'; import type { GitHost } from '../git-host';
import * as vault from '../vault'; import * as vault from '../vault';
import { itemToManifestEntry } from '../vault';
import * as session from '../session'; import * as session from '../session';
import { loadDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
export interface ContentState { export interface ContentState {
manifest: Manifest | null; manifest: Manifest | null;
@@ -164,41 +166,6 @@ export async function handle(
} }
} }
// --- Manifest entry derivation (duplicated from popup-only for self-containment) ---
function itemToManifestEntry(item: Item) {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
async function loadDeviceSettings(): Promise<{ captureEnabled: boolean; captureStyle: 'bar' | 'toast' }> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as { captureEnabled: boolean; captureStyle: 'bar' | 'toast' })
?? { captureEnabled: false, captureStyle: 'bar' };
}
async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
function safeHostname(url: string): string | undefined { function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; } try { return new URL(url).hostname; } catch { return undefined; }
} }

View File

@@ -4,15 +4,16 @@
/// via sender.url === popup.html (or setup.html for save_setup). /// via sender.url === popup.html (or setup.html for save_setup).
import type { PopupMessage, Response } from '../../shared/messages'; import type { PopupMessage, Response } from '../../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types'; import type { Item, ItemId, Manifest, VaultConfig, SetupState, TotpConfig, AttachmentRef } from '../../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
import { base32Decode } from '../../shared/base32'; import { base32Decode } from '../../shared/base32';
import type { GitHost } from '../git-host'; import type { GitHost } from '../git-host';
import { createGitHost, base64ToUint8Array } from '../git-host'; import { createGitHost, base64ToUint8Array } from '../git-host';
import * as vault from '../vault'; import * as vault from '../vault';
import { itemToManifestEntry } from '../vault';
import * as session from '../session'; import * as session from '../session';
import * as devices from '../devices'; import * as devices from '../devices';
import * as sessionTimer from '../session-timer'; import * as sessionTimer from '../session-timer';
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
// --- Shared ambient state owned by the SW module --- // --- Shared ambient state owned by the SW module ---
// //
@@ -684,44 +685,6 @@ async function loadSetupState(): Promise<SetupState> {
return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null }; return { config, imageBase64, isConfigured: config !== null && imageBase64 !== null };
} }
async function loadDeviceSettings(): Promise<DeviceSettings> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}
async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: s });
}
async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}
// --- Manifest entry derivation (duplicated from index; kept here to keep handler self-contained) ---
function itemToManifestEntry(item: Item) {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
function safeHostname(url: string): string | undefined { function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; } try { return new URL(url).hostname; } catch { return undefined; }
} }

View File

@@ -0,0 +1,25 @@
/// Single home for chrome.storage.local reads/writes done by the service
/// worker. Both router files (popup-only.ts and content-callable.ts) import
/// from here — the duplicated definitions in those files were lifted out as
/// part of Plan C Phase 2 (P1.9).
import type { DeviceSettings } from '../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../shared/types';
export async function loadDeviceSettings(): Promise<DeviceSettings> {
const r = await chrome.storage.local.get('relicarioSettings');
return (r.relicarioSettings as DeviceSettings) ?? { ...DEFAULT_DEVICE_SETTINGS };
}
export async function saveDeviceSettings(s: DeviceSettings): Promise<void> {
await chrome.storage.local.set({ relicarioSettings: s });
}
export async function loadBlacklist(): Promise<string[]> {
const r = await chrome.storage.local.get('captureBlacklist');
return (r.captureBlacklist as string[]) ?? [];
}
export async function saveBlacklist(list: string[]): Promise<void> {
await chrome.storage.local.set({ captureBlacklist: list });
}

View File

@@ -395,3 +395,33 @@ export async function removeAttachmentsFromItem(
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`); await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
return removed; return removed;
} }
// --- Manifest entry derivation ---
/**
* Project a decrypted Item into its ManifestEntry shape for browse-without-
* decrypt views. Both router files use this; defined here (the SW's
* vault-orchestration home) instead of duplicated in each router. Moved out
* of popup-only.ts / content-callable.ts as part of Plan C Phase 2 (P1.9).
*/
export function itemToManifestEntry(item: Item): ManifestEntry {
return {
id: item.id,
type: item.type,
title: item.title,
tags: item.tags,
favorite: item.favorite,
group: item.group,
icon_hint: (item.core.type === 'login' && item.core.url)
? safeHostname(item.core.url) : undefined,
modified: item.modified,
trashed_at: item.trashed_at,
attachment_summaries: item.attachments.map((a) => ({
id: a.id, filename: a.filename, mime_type: a.mime_type, size: a.size,
})),
};
}
function safeHostname(url: string): string | undefined {
try { return new URL(url).hostname; } catch { return undefined; }
}