diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 965219e3..9eb5bcaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,8 @@ jobs: - run: pnpm check - name: Install Chromium for PDF touch regression tests run: pnpm --filter @mdbase-reader/app exec playwright install --with-deps chromium + - name: Verify extension shared-session writes across separate bundles + run: pnpm --filter @mdbase-reader/extension test:browser - name: Verify PDF gestures and mobile highlight persistence run: pnpm --filter @mdbase-reader/app test:pdf-touch - name: Verify imported SVG rendering in the HTML sandbox diff --git a/apps/extension/package.json b/apps/extension/package.json index 66e8fbdc..95783fae 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -9,7 +9,8 @@ "package": "MDBASE_ENV=${MDBASE_ENV:-production} pnpm build && MDBASE_ENV=${MDBASE_ENV:-production} node scripts/package-extension.mjs", "manifest:mdbase": "node scripts/write-mdbase-manifest.mjs", "test": "pnpm manifest:mdbase && vitest run --passWithNoTests", - "typecheck": "tsc -p tsconfig.json" + "typecheck": "tsc -p tsconfig.json", + "test:browser": "node scripts/shared-session-browser.mjs" }, "dependencies": { "@fontsource-variable/source-serif-4": "^5.3.1", @@ -30,6 +31,7 @@ "react-dom": "^19.3.1" }, "devDependencies": { + "@playwright/test": "1.64.1", "@types/chrome": "^0.3.6", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/apps/extension/scripts/extension-manifest.mjs b/apps/extension/scripts/extension-manifest.mjs index db561a38..ad3befa8 100644 --- a/apps/extension/scripts/extension-manifest.mjs +++ b/apps/extension/scripts/extension-manifest.mjs @@ -153,8 +153,8 @@ export function extensionManifest(environment) { }, // One panel per window; it follows the window's active tab. side_panel: { default_path: "capture.html" }, - // Classic, with no SDK: the SDK runtime lives in the extension's pages. - background: { service_worker: "background.js" }, + // The SDK requires a module URL, including in a non-hosting context. + background: { service_worker: "background.js", type: "module" }, options_ui: { page: "options.html", open_in_tab: true }, commands: { _execute_action: { diff --git a/apps/extension/scripts/fixtures/shared-session.mjs b/apps/extension/scripts/fixtures/shared-session.mjs new file mode 100644 index 00000000..e1c64195 --- /dev/null +++ b/apps/extension/scripts/fixtures/shared-session.mjs @@ -0,0 +1,28 @@ +/* global __SESSION_HOST__ */ +import { createMdbase } from "@mdbase-dev/sdk"; + +const origin = globalThis.location.origin; +const manifest = { + manifest_version: 1, + id: "dev.mdbase.reader.extension.session-test", + name: "Reader shared session test", + homepage: `${origin}/`, + redirect_uris: [`${origin}/`], + requirements: { + access: "full_collection", + contracts: [], + capabilities: { contract_version: 2, required: [], optional: [] }, + }, +}; +const app = createMdbase( + { id: manifest.id, name: manifest.name, origin, environment: "production", manifest }, + { + session: { build: "reader-extension-session-test", host: __SESSION_HOST__ }, + unsynced: true, + followVisibility: false, + }, +); +globalThis.fixtureMdbase = app; +void app.start().catch((error) => { + globalThis.fixtureError = error.message; +}); diff --git a/apps/extension/scripts/shared-session-browser.mjs b/apps/extension/scripts/shared-session-browser.mjs new file mode 100644 index 00000000..945ad3d1 --- /dev/null +++ b/apps/extension/scripts/shared-session-browser.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, join, resolve } from "node:path"; + +import { chromium, expect } from "@playwright/test"; +import { build } from "vite"; + +// Two separately bundled browser contexts, using the pinned SDK's original Worker, +// WASM and SQLite against one local unsynced replica. Registration is a fixture; +// no account, Connect service, collection grant or log service is contacted. +const root = resolve(import.meta.dirname, ".."); +const scratch = resolve(root, "../../.tmp"); +await mkdir(scratch, { recursive: true }); +const directory = await mkdtemp(join(scratch, "extension-shared-session-")); +const dist = join(directory, "dist"); +let browser; +let server; +const errors = []; +const unexpected = []; +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) + .join(",")}}`; + return JSON.stringify(value); +} +try { + for (const [name, host] of [ + ["host", true], + ["follower", false], + ]) { + await build({ + root, + configFile: false, + publicDir: false, + logLevel: "error", + define: { __SESSION_HOST__: JSON.stringify(host) }, + worker: { format: "es" }, + build: { + outDir: dist, + emptyOutDir: host, + target: "es2022", + rolldownOptions: { + input: resolve(root, "scripts/fixtures/shared-session.mjs"), + output: { entryFileNames: `${name}.js` }, + }, + }, + }); + } + server = createServer(async (request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + if (["/host", "/follower"].includes(path)) { + response.setHeader("Content-Type", "text/html"); + response.end(``); + return; + } + const file = resolve(dist, `.${path}`); + if (!file.startsWith(`${dist}/`)) { + response.writeHead(403).end(); + return; + } + try { + response.setHeader( + "Content-Type", + extname(file) === ".wasm" ? "application/wasm" : "text/javascript", + ); + response.end(await readFile(file)); + } catch { + response.writeHead(404).end(); + } + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const origin = `http://127.0.0.1:${server.address().port}`; + browser = await chromium.launch(); + const context = await browser.newContext(); + await context.route("**/*", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === origin) return route.continue(); + if (url.href === "https://connect.mdbase.dev/v1/apps/register") { + const { manifest } = request.postDataJSON(); + const digest = createHash("sha256").update(canonical(manifest)).digest("hex"); + return route.fulfill({ + json: { + application: { + id: "55555555-5555-4555-8555-555555555555", + name: manifest.name, + manifest_digest: digest, + canonical_identity: `bundle:${manifest.id}:sha256:${digest}`, + family_identity: `bundle:${manifest.id}`, + distribution: "web", + application_declaration: manifest, + }, + }, + }); + } + unexpected.push(url.origin + url.pathname); + return route.abort(); + }); + const follower = await context.newPage(); + const host = await context.newPage(); + const workers = []; + for (const page of [host, follower]) { + page.on("pageerror", (error) => errors.push(error.message)); + page.on("worker", (worker) => workers.push(worker.url())); + } + await follower.goto(`${origin}/follower`); + await expect + .poll(() => follower.evaluate(() => globalThis.fixtureMdbase?.getSnapshot().status)) + .toBe("starting"); + assert.equal(workers.length, 0, "a host:false context never constructs a Worker"); + await host.goto(`${origin}/host`); + for (const page of [host, follower]) { + await expect + .poll( + () => + page.evaluate(() => ({ + phase: globalThis.fixtureMdbase?.getSnapshot().phase.kind, + error: globalThis.fixtureError, + problem: globalThis.fixtureMdbase?.getSnapshot().phase.problem, + })), + { timeout: 15000 }, + ) + .toEqual({ phase: "unsynced", error: undefined, problem: undefined }); + } + assert.equal(workers.length, 1, "one SDK Worker serves both bundles"); + const ids = await Promise.all( + [host, follower].map((page) => + page.evaluate(() => globalThis.fixtureMdbase.getSnapshot().phase.connection.id), + ), + ); + assert.equal(ids[0], ids[1], "both contexts open the same replica"); + await host.evaluate(async () => { + const records = globalThis.fixtureMdbase.getSnapshot().phase.connection.records; + await records.create({ + path: "shared.md", + frontmatter: { title: "From the host" }, + body: "Shared body", + }); + }); + const read = await follower.evaluate(async () => { + const records = globalThis.fixtureMdbase.getSnapshot().phase.connection.records; + const record = await records.get({ path: "shared.md" }, { include: { body: true } }); + await records.update(record, { patch: { title: "From the follower" } }); + return { title: record.frontmatter.title, body: record.body }; + }); + assert.deepEqual(read, { title: "From the host", body: "Shared body" }); + assert.equal( + await host.evaluate( + async () => + ( + await globalThis.fixtureMdbase + .getSnapshot() + .phase.connection.records.get({ path: "shared.md" }) + ).frontmatter.title, + ), + "From the follower", + ); + await follower.close(); + assert.equal( + await host.evaluate( + async () => + ( + await globalThis.fixtureMdbase + .getSnapshot() + .phase.connection.records.get({ path: "shared.md" }) + ).frontmatter.title, + ), + "From the follower", + ); + assert.deepEqual(errors, []); + assert.deepEqual(unexpected, []); + console.log( + "PASS: two separate bundles, one original SDK Worker and replica; writes visible in both directions; follower close leaves host usable.", + ); +} finally { + await browser?.close(); + if (server) await new Promise((resolveClose) => server.close(resolveClose)); + await rm(directory, { recursive: true, force: true }); +} diff --git a/apps/extension/src/NotePanel.test.tsx b/apps/extension/src/NotePanel.test.tsx index 7d291228..e15a0e3b 100644 --- a/apps/extension/src/NotePanel.test.tsx +++ b/apps/extension/src/NotePanel.test.tsx @@ -1,5 +1,8 @@ // @vitest-environment happy-dom /* eslint-disable @typescript-eslint/require-await -- async act flushes React's queued work */ +import { sourceFromDocument } from "@mdbase-reader/connect"; +import { createTestApp } from "@mdbase-reader/connect/testing"; +import { collectionId } from "@mdbase-reader/core"; import { act } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; @@ -9,29 +12,33 @@ import { fakeChrome } from "./testing/fake-chrome.js"; import { createRoot } from "./testing/i18n.js"; import type { ExtensionCaptureController } from "./capture-controller.js"; -import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; +import type { Mdbase, MdbaseClient } from "@mdbase-dev/sdk"; import type { Source } from "@mdbase-reader/core"; import type { Root } from "react-dom/client"; +import type { MockInstance } from "vitest"; let host: HTMLDivElement; let root: Root; beforeEach(() => { - vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("chrome", fakeChrome().chrome); host = document.createElement("div"); document.body.append(host); root = createRoot(host); }); -afterEach(() => { +const closes: (() => Promise)[] = []; +afterEach(async () => { act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); vi.useRealTimers(); + for (const close of closes.splice(0)) { + await close(); + } }); -const saved = { +let saved = { id: "s1", collectionId: "c1", path: "sources/s1.md", @@ -44,38 +51,37 @@ const saved = { recordRevision: "r1", } as unknown as Source; -/** One source; field writes go over whatever is current, as the repository's do. */ -function collection(): { - readonly collection: ReaderConnectedCollection; - readonly updateFields: ReturnType; - edit(changes: Partial): void; -} { - let current = saved; - let revision = 1; - const next = (changes: Partial): Source => { - revision += 1; - current = { ...current, ...changes, recordRevision: `r${String(revision)}` } as Source; - return current; - }; - const updateFields = vi.fn((input: { readonly fields: Partial }) => - Promise.resolve(next(input.fields)), +async function collection(): Promise<{ + mdbase: Mdbase; + updateFields: MockInstance; + edit: (changes: { title: string }) => void; +}> { + const app = await createTestApp({ + collections: [ + { + name: "Reading", + records: [ + { + path: "sources/s1.md", + types: ["reader-source"], + frontmatter: { id: "s1", title: "[test] Saved", tags: ["ml"] }, + body: "My note", + }, + ], + }, + ], + }); + closes.push(() => app.close()); + const client = app.client(); + saved = sourceFromDocument( + collectionId(client.collection), + await client.get({ path: "sources/s1.md" }, { include: { effective: true, body: true } }), ); - const sources = { - get: () => Promise.resolve(current), - updateFields, - updateBody: (input: { readonly expectedRevision: string; readonly body: string }) => - input.expectedRevision === current.recordRevision - ? Promise.resolve(next({ body: input.body })) - : Promise.reject(new Error("The source changed before saving.")), - }; return { - collection: { - collectionId: "c1", - sources, - bodyRecovery: { pending: () => false, recoverSource: vi.fn() }, - } as unknown as ReaderConnectedCollection, - updateFields, - edit: next, + mdbase: app.mdbase, + updateFields: vi.spyOn(client, "update"), + edit: (changes: { title: string }) => + app.replica().editElsewhere(saved.path, { frontmatter: changes }), }; } @@ -110,8 +116,10 @@ function type(input: HTMLInputElement | null, value: string): void { } it("autosaves a saved source's title and tags as they are typed", async () => { - const fake = collection(); - const session = new SourceNoteSession(saved, fake.collection, memory); + const fake = await collection(); + const session = new SourceNoteSession(fake.mdbase, saved, memory); + await session.start(); + vi.useFakeTimers(); await show(session); const title = host.querySelector("#saved-title"); const tags = host.querySelector("#saved-tags"); @@ -128,7 +136,14 @@ it("autosaves a saved source's title and tags as they are typed", async () => { await act(async () => vi.advanceTimersByTimeAsync(1100)); expect(fake.updateFields).toHaveBeenCalledWith( - expect.objectContaining({ fields: { title: "[test] Renamed", tags: ["ml", "vision"] } }), + expect.objectContaining({ path: saved.path }), + expect.objectContaining({ + patch: new Map([ + ["title", "[test] Renamed"], + ["tags", ["ml", "vision"]], + ] as [string, string | string[]][]), + }), + expect.anything(), ); expect(host.querySelector(".note-state")?.textContent).toBe("Saved"); // The trailing separator being typed stays. @@ -136,8 +151,10 @@ it("autosaves a saved source's title and tags as they are typed", async () => { }); it("keeps a cleared title out of the collection and says which one is kept", async () => { - const fake = collection(); - const session = new SourceNoteSession(saved, fake.collection, memory); + const fake = await collection(); + const session = new SourceNoteSession(fake.mdbase, saved, memory); + await session.start(); + vi.useFakeTimers(); await show(session); const title = host.querySelector("#saved-title"); act(() => { @@ -151,8 +168,10 @@ it("keeps a cleared title out of the collection and says which one is kept", asy }); it("stops on a conflicting title with both versions in reach, and flags the tab", async () => { - const fake = collection(); - const session = new SourceNoteSession(saved, fake.collection, memory); + const fake = await collection(); + const session = new SourceNoteSession(fake.mdbase, saved, memory); + await session.start(); + vi.useFakeTimers(); await show(session); const title = host.querySelector("#saved-title"); act(() => { @@ -174,7 +193,9 @@ it("stops on a conflicting title with both versions in reach, and flags the tab" await act(async () => keep?.click()); await act(async () => vi.advanceTimersByTimeAsync(1100)); expect(fake.updateFields).toHaveBeenCalledWith( - expect.objectContaining({ fields: { title: "[test] Mine" } }), + expect.objectContaining({ path: saved.path }), + expect.objectContaining({ patch: new Map([["title", "[test] Mine"]]) }), + expect.anything(), ); expect(host.querySelector(".conflict")).toBeNull(); expect(host.querySelector("#panel-tab-note")?.textContent).not.toContain("attention"); diff --git a/apps/extension/src/NotePanel.tsx b/apps/extension/src/NotePanel.tsx index eeb8d307..906bc2b3 100644 --- a/apps/extension/src/NotePanel.tsx +++ b/apps/extension/src/NotePanel.tsx @@ -381,6 +381,8 @@ function noteStateLabel(state: SourceNoteSnapshot["state"]): string { switch (state) { case "saved": return t({ message: "Saved", context: "Reader save status" }); + case "saved_on_device": + return t`Saved on this device; waiting for sync`; case "unsaved": return t`Unsaved changes`; case "saving": diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 6a0b9c69..13ebe391 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -5,16 +5,9 @@ import { backgroundLocaleReady } from "./background-i18n.js"; import { updatePageStatus } from "./background-page-status.js"; import { intentKey, type CaptureIntent, type InvokeMessage } from "./messages.js"; import { ensureHostMessage, ensureOffscreenDocument } from "./offscreen-document.js"; -import { loadPageStatusConnect, preloadPageStatusConnect } from "./page-status-loader.js"; +import { pageStatusConnect } from "./page-status-worker.js"; import { forgetTab } from "./tab-cleanup.js"; -/* - * The service worker wakes for every page load and toolbar click, so this bundle stays - * small: the Connect SDK lives in page-status.js, loaded only when a page may be saved - * (see page-status-loader.ts). Connect's credentialless fetch is installed there. - */ -self.addEventListener("install", preloadPageStatusConnect); - /** * Opens Reader's side panel in the tab's window; the panel follows the window's active * tab. Everything here starts synchronously inside the user gesture: Chrome only allows @@ -98,7 +91,9 @@ chrome.runtime.onMessage.addListener((message: unknown, sender, sendResponse) => chrome.tabs.onUpdated.addListener((tabId, change, tab) => { if (change.status === "complete") { - void backgroundLocaleReady.then(() => updatePageStatus(tabId, tab.url, loadPageStatusConnect)); + void backgroundLocaleReady.then(() => + updatePageStatus(tabId, tab.url, () => pageStatusConnect), + ); } }); const postUpdates = new Map(); @@ -135,7 +130,7 @@ async function refreshPosts(tabId: number, state: { dirty: boolean }): Promise pageStatusConnect); } while (postUpdates.get(tabId)?.dirty); } diff --git a/apps/extension/src/chrome-storage.ts b/apps/extension/src/chrome-storage.ts index 451d5225..bab2b2a4 100644 --- a/apps/extension/src/chrome-storage.ts +++ b/apps/extension/src/chrome-storage.ts @@ -1,11 +1,7 @@ /** - * The Connect SDK needs a synchronous Web Storage, but the service worker has no - * localStorage and must see the same grants as the side panel. This mirror loads - * `chrome.storage.local` once, answers synchronously, writes through, and follows - * changes made by other extension contexts. - * - * There is one mirror per storage area and context: every Connect session in it (a - * Retry creates a new one) shares it, so only one `storage.onChanged` listener exists. + * Reader's capture journal needs synchronous key/value access shared by extension pages. + * This mirror loads chrome.storage.local, writes through and follows other contexts. + * SDK session state and grants remain in the SDK's own storage. */ const prefix = "connect:"; diff --git a/apps/extension/src/connect-session.ts b/apps/extension/src/connect-session.ts index 8d5dbff0..97cdf070 100644 --- a/apps/extension/src/connect-session.ts +++ b/apps/extension/src/connect-session.ts @@ -1,60 +1,55 @@ +import { createMdbase, type Mdbase } from "@mdbase-reader/connect/native"; + import { chromeStorageMirror } from "./chrome-storage.js"; -import { requestSdkHost, sdkHostGone } from "./offscreen-document.js"; -import { createSdkClient, type RemoteApplication, type SdkClient } from "./sdk-client.js"; -import { broadcastLink } from "./sdk-rpc.js"; +import { requestSdkHost } from "./offscreen-document.js"; +import { extensionCollection } from "./sdk-collection.js"; +import { extensionDeclaration, sessionBuild } from "./sdk-declaration.js"; import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; import type { KeyValueStorage } from "@mdbase-reader/platform"; export { rememberCollection, rememberedCollection } from "./collection-memory.js"; -/** - * One extension page's link to the SDK. The SDK itself runs once, in the offscreen host - * (offscreen.ts); every page and the service worker share it, so none waits on another. - */ +/** One page's SDK entry and Reader repositories over its selected connection. */ export interface ExtensionSession { - readonly application: RemoteApplication; - /** Reader's repositories over the host's current READY connection, or null. */ + readonly application: Mdbase; connectedCollection(): ReaderConnectedCollection | null; - /** False while the SDK host restarts; the application snapshot is then the last one. */ - hostAvailable(): boolean; /** Reader's capture journal, in extension storage so any page can recover it. */ readonly journalStorage: KeyValueStorage; dispose(): void; } -type CreateClient = () => Promise; - -/** Sign-in opens mdbase in a tab, from the page whose action started it. */ -const createClient: CreateClient = async () => { - const link = broadcastLink(); - const client = await createSdkClient(link, { - ensureHost: requestSdkHost, - hostGone: sdkHostGone, - openSignIn: (url) => void chrome.tabs.create({ url }).catch(() => undefined), - }); - return { - ...client, - dispose() { - client.dispose(); - link.close(); +export async function createExtensionSession(): Promise { + const [storage] = await Promise.all([chromeStorageMirror(), requestSdkHost()]); + const application = createMdbase(extensionDeclaration(), { + session: { build: sessionBuild, host: false }, + followVisibility: true, + signIn: { + open: async (url) => { + await chrome.tabs.create({ url }); + }, }, - }; -}; - -export async function createExtensionSession( - create: CreateClient = createClient, -): Promise { - const [storage, client] = await Promise.all([chromeStorageMirror(), create()]); + }); + // The SDK re-enters starting when its host disappears (entry.ts leaderLost). + // Recreate Chrome's document; the SDK handles reconnecting its own session. + const stopHostRequest = application.subscribe(() => { + if (application.getSnapshot().status === "starting") { + void requestSdkHost().catch(() => undefined); + } + }); + const collection = extensionCollection(application); return { - application: client.application, - connectedCollection: () => client.connectedCollection(), - hostAvailable: () => client.hostAvailable(), + application, + connectedCollection: () => collection.connectedCollection(), journalStorage: { get: (key) => Promise.resolve(storage.getItem(key)), set: (key, value) => Promise.resolve(storage.setItem(key, value)), remove: (key) => Promise.resolve(storage.removeItem(key)), }, - dispose: () => client.dispose(), + dispose() { + stopHostRequest(); + collection.dispose(); + void application.close(); + }, }; } diff --git a/apps/extension/src/connection-view.ts b/apps/extension/src/connection-view.ts index a18dff3d..f8d1ec7a 100644 --- a/apps/extension/src/connection-view.ts +++ b/apps/extension/src/connection-view.ts @@ -203,19 +203,11 @@ function screen(snapshot: MdbaseSnapshot, usable: boolean): Screen { : { status: "problem", problem: retiredProblem() }; } -/** - * `usable` is whether the SDK hands out a current READY connection right now. While the - * SDK host restarts (`hostAvailable` false) its last snapshot is stale, so the page shows - * mdbase opening; the SDK's own status is not changed. - */ -export function connectionView( - snapshot: MdbaseSnapshot, - usable: boolean, - hostAvailable = true, -): ConnectionView { +/** `usable` means this context has a current READY SDK connection. */ +export function connectionView(snapshot: MdbaseSnapshot, usable: boolean): ConnectionView { const signedIn = snapshot.status !== "closed" && snapshot.signIn?.state === "paired"; return { - ...(hostAvailable ? screen(snapshot, usable) : plain("opening")), + ...screen(snapshot, usable), collections: snapshot.status === "closed" ? [] : snapshot.collections, collectionId: snapshot.status === "closed" ? null : snapshot.selectedCollectionId, signedIn, diff --git a/apps/extension/src/drafts.ts b/apps/extension/src/drafts.ts index 31c69015..71b03330 100644 --- a/apps/extension/src/drafts.ts +++ b/apps/extension/src/drafts.ts @@ -2,7 +2,7 @@ import { normalizedSourceUrl, type QuoteSelector } from "@mdbase-reader/core"; import { highlightColors, type CaptureDraft, type HighlightColor } from "./save-capture.js"; -import type { JsonObject } from "@mdbase-reader/connect"; +import type { JsonObject, RecordRecoveryRef } from "@mdbase-reader/connect"; /** * Unsaved text survives closing the panel, reloading the extension page or switching @@ -97,6 +97,7 @@ export interface NoteDraft { readonly patch?: JsonObject; readonly baseFrontmatter?: JsonObject; readonly requestId?: string; + readonly recovery?: RecordRecoveryRef; } function noteDraftKey(collectionId: string, sourceId: string): string { diff --git a/apps/extension/src/extension-manifest.test.ts b/apps/extension/src/extension-manifest.test.ts index f3b56f60..0a830d73 100644 --- a/apps/extension/src/extension-manifest.test.ts +++ b/apps/extension/src/extension-manifest.test.ts @@ -35,6 +35,7 @@ describe("per-environment extension manifest", () => { "https://*/*", ]); expect(manifest["optional_host_permissions"]).toBeUndefined(); + expect(manifest["background"]).toEqual({ service_worker: "background.js", type: "module" }); // The SDK's one owner is an offscreen document. expect(manifest["permissions"]).toContain("offscreen"); expect(manifest["content_security_policy"]).toEqual({ diff --git a/apps/extension/src/offscreen-document.ts b/apps/extension/src/offscreen-document.ts index 312dcdc4..9579bddb 100644 --- a/apps/extension/src/offscreen-document.ts +++ b/apps/extension/src/offscreen-document.ts @@ -1,5 +1,3 @@ -import { sdkHostLockName } from "./sdk-rpc.js"; - /** * The extension's one SDK host is an offscreen document (offscreen.html): a hidden page * that can run the SDK's Worker, which an MV3 service worker cannot. The service worker @@ -45,18 +43,3 @@ export async function requestSdkHost(): Promise { throw new Error(String(reply.error)); } } - -/** Settles once the running host has released its lifetime lock (it closed or crashed). */ -export function sdkHostGone(): Promise { - return navigator.locks.request(sdkHostLockName, () => undefined).then(() => undefined); -} - -/** In the offscreen host: holds the lifetime lock until the document goes away. */ -export function holdSdkHostLock(): Promise { - return new Promise((resolve) => { - void navigator.locks.request(sdkHostLockName, () => { - resolve(); - return new Promise(() => undefined); - }); - }); -} diff --git a/apps/extension/src/offscreen.ts b/apps/extension/src/offscreen.ts index 1f03eb63..cf227310 100644 --- a/apps/extension/src/offscreen.ts +++ b/apps/extension/src/offscreen.ts @@ -1,27 +1,12 @@ import { createMdbase } from "@mdbase-reader/connect/native"; -import { holdSdkHostLock } from "./offscreen-document.js"; -import { extensionDeclaration } from "./sdk-declaration.js"; -import { sdkHostSource } from "./sdk-host-source.js"; -import { serveSdk, type SdkHost } from "./sdk-host.js"; -import { broadcastLink } from "./sdk-rpc.js"; +import { extensionDeclaration, sessionBuild } from "./sdk-declaration.js"; -/* - * The extension's one SDK owner. The side panel, settings and welcome pages and the - * service worker reach it over sdk-rpc.ts; it has no UI. Offscreen documents only get - * chrome.runtime, so sign-in opens from the page whose action started it. - */ -async function start(): Promise { - await holdSdkHostLock(); - const hostId = crypto.randomUUID(); - let host: SdkHost | null = null; - const sdk = createMdbase(extensionDeclaration(), { - signIn: { open: (url) => host?.openSignIn(url) }, - }); - const source = sdkHostSource(sdk, hostId); - host = serveSdk(broadcastLink(), source, hostId); - // Restores sign-in and the selection from the SDK's own storage. - await source.application.start().catch(() => undefined); -} - -void start(); +// The only context that hosts the shared SDK Worker. It has no visible UI; +// sign-in is opened by the SDK entry in the page or service worker that requests it. +const mdbase = createMdbase(extensionDeclaration(), { + session: { build: sessionBuild }, + followVisibility: false, +}); +mdbase.setForeground(false); +void mdbase.start().catch(() => undefined); diff --git a/apps/extension/src/page-status-connect.ts b/apps/extension/src/page-status-connect.ts index 312f5721..4794fe34 100644 --- a/apps/extension/src/page-status-connect.ts +++ b/apps/extension/src/page-status-connect.ts @@ -1,16 +1,18 @@ +import { createMdbase, type Mdbase } from "@mdbase-reader/connect/native"; + import { sourceForUrl } from "./capture-model.js"; -import { ensureOffscreenDocument, sdkHostGone } from "./offscreen-document.js"; +import { rememberedCollection } from "./collection-memory.js"; +import { ensureOffscreenDocument } from "./offscreen-document.js"; import { annotationQuotes, drawPageQuotes, type PageQuote } from "./page-annotations.js"; -import { createSdkClient, type SdkClient } from "./sdk-client.js"; -import { broadcastLink } from "./sdk-rpc.js"; +import { extensionCollection } from "./sdk-collection.js"; +import { extensionDeclaration, sessionBuild } from "./sdk-declaration.js"; import { scopePostQuotes, storedSocialPost } from "./social-quotes.js"; import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; /** - * The part of page status that needs the SDK host. The service worker loads it only for - * pages its saved-URL index cannot rule out; see background-page-status.ts and - * page-status-worker.ts. + * The part of page status that starts the SDK only for + * pages its saved-URL index cannot rule out; see background-page-status.ts. */ export interface PageStatusConnect { /** The selected collection, or null when none is connected. */ @@ -25,19 +27,22 @@ export interface PageStatusConnect { drawPageQuotes(tabId: number, quotes: readonly PageQuote[], url: string): Promise; } -let client: Promise | null = null; +let client: { application: Mdbase; collection: ReturnType } | null = + null; /** How long a page load waits for the host to open the selected collection. */ const openTimeoutMs = 10_000; /** Waits until the host's selected collection is READY, or settles without one. */ -function readyCollection(current: SdkClient): Promise { +function readyCollection( + current: NonNullable, +): Promise { const { application } = current; const opening = (): boolean => ["not_started", "starting", "opening"].includes(application.getSnapshot().status) || application.getSnapshot().readiness.checking; return new Promise((resolve) => { const check = (): boolean => { - const collection = current.connectedCollection(); + const collection = current.collection.connectedCollection(); if (collection || !opening()) { resolve(collection); return true; @@ -63,24 +68,33 @@ function readyCollection(current: SdkClient): Promise { - client ??= createSdkClient(broadcastLink(), { - ensureHost: ensureOffscreenDocument, - hostGone: sdkHostGone, - openSignIn: () => undefined, - }); try { - const current = await client; - if (current.application.getSnapshot().status === "not_started") { - await current.application.start(); + await ensureOffscreenDocument(); + if (!client) { + const application = createMdbase(extensionDeclaration(), { + session: { build: sessionBuild, host: false }, + followVisibility: false, + signIn: { + open: async (url) => { + await chrome.tabs.create({ url }); + }, + }, + }); + application.setForeground(false); + client = { application, collection: extensionCollection(application) }; + } + const id = await rememberedCollection(); + if (!id) { + return null; + } + await client.application.start(); + if (client.application.getSnapshot().selectedCollectionId !== id) { + await client.application.select(id); } - return await readyCollection(current); + return await readyCollection(client); } catch { - client = null; return null; } } diff --git a/apps/extension/src/page-status-loader.ts b/apps/extension/src/page-status-loader.ts deleted file mode 100644 index 57cffc81..00000000 --- a/apps/extension/src/page-status-loader.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { PageStatusConnect } from "./page-status-connect.js"; - -/** - * The service worker is a classic script so that the Connect SDK (most of its code) can - * stay out of every wake-up: toolbar clicks, shortcuts and most page loads never need - * it. Chrome only lets a service worker `importScripts()` a file after installation if - * it was imported while installing, so the worker imports it once from `install` - * ({@link preloadPageStatusConnect}) and again, on demand, when a page needs Connect. - */ -export const pageStatusScript = "page-status.js"; -const globalName = "mdbaseReaderPageStatus"; - -interface WorkerGlobal { - importScripts?: (...urls: string[]) => void; - [globalName]?: PageStatusConnect; -} - -/** Called by page-status.js when it runs; the first definition wins. */ -export function definePageStatusConnect(connect: PageStatusConnect): boolean { - const scope = globalThis as WorkerGlobal; - if (scope[globalName]) { - return false; - } - scope[globalName] = connect; - return true; -} - -/** Loads page-status.js into this worker on first use. Throws when it cannot be loaded. */ -export function loadPageStatusConnect(): PageStatusConnect { - const scope = globalThis as WorkerGlobal; - if (!scope[globalName]) { - if (!scope.importScripts) { - throw new Error("The page status bundle can only be loaded by the service worker."); - } - scope.importScripts(pageStatusScript); - } - const connect = scope[globalName]; - if (!connect) { - throw new Error(`${pageStatusScript} did not define page status.`); - } - return connect; -} - -/** From the worker's `install` event: registers page-status.js for later on-demand loads. */ -export function preloadPageStatusConnect(): void { - try { - loadPageStatusConnect(); - } catch { - // Page status then stays unavailable until the next install; capture is unaffected. - } -} diff --git a/apps/extension/src/page-status-worker.ts b/apps/extension/src/page-status-worker.ts index d59cf29b..333825e8 100644 --- a/apps/extension/src/page-status-worker.ts +++ b/apps/extension/src/page-status-worker.ts @@ -1,10 +1,6 @@ import { credentiallessFetch } from "./credentialless-fetch.js"; -import { pageStatusConnect } from "./page-status-connect.js"; -import { definePageStatusConnect } from "./page-status-loader.js"; -// The entry of page-status.js, loaded into the service worker on demand (see -// page-status-loader.ts). Running it twice in one worker must change nothing. -if (definePageStatusConnect(pageStatusConnect)) { - // The extension uses signed grants, never ambient portal cookies. - globalThis.fetch = credentiallessFetch(globalThis.fetch.bind(globalThis)); -} +// Reader's own fetches keep omitting portal cookies in the module service worker. +globalThis.fetch = credentiallessFetch(globalThis.fetch.bind(globalThis)); + +export { pageStatusConnect } from "./page-status-connect.js"; diff --git a/apps/extension/src/sdk-client.ts b/apps/extension/src/sdk-client.ts deleted file mode 100644 index 7d56c37a..00000000 --- a/apps/extension/src/sdk-client.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { t as translate } from "@lingui/core/macro"; - -import { - deserializeError, - encodeArgs, - isApplicationSnapshot, - isHostMessage, - isReadinessSnapshot, - isValidSnapshotMessage, - type ApplicationCommand, - type CallTarget, - type CollectionShape, - type HostMessage, - type SdkLink, -} from "./sdk-rpc.js"; - -import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; -import type { Mdbase, MdbaseSnapshot } from "@mdbase-reader/connect/native"; - -/** The parts of the SDK application the extension's pages use. */ -export type RemoteApplication = Pick< - Mdbase, - | "getSnapshot" - | "subscribe" - | "start" - | "signIn" - | "approve" - | "select" - | "chooseAnother" - | "assess" - | "configure" ->; - -export interface SdkClient { - readonly application: RemoteApplication; - /** Reader's repositories over the host's current READY connection, or null. */ - connectedCollection(): ReaderConnectedCollection | null; - /** False while the host is gone and being restarted; the snapshot is then the last one. */ - hostAvailable(): boolean; - /** Settles this context's pending calls; the host and its SDK keep running. */ - dispose(): void; -} - -export interface SdkClientOptions { - /** Creates the offscreen host if it is not running. */ - ensureHost(): Promise; - /** Settles once the host `hostId` has gone (its lifetime lock was released). */ - hostGone(hostId: string): Promise; - /** Opens the SDK's sign-in page for an action this page started. */ - openSignIn(url: string): void; -} - -type Callback = (...args: unknown[]) => void; - -interface Inflight { - /** The host the call was sent to; only it may answer. */ - readonly host: string; - resolve(value: unknown): void; - reject(reason: unknown): void; - readonly callbacks: readonly Callback[]; -} - -const helloIntervalMs = 250; -const startTimeoutMs = 20_000; - -export function hostRestartedMessage(): string { - return translate`mdbase restarted in the background. Check whether your last change was saved, then try again.`; -} - -/** One context's connection to the host: requests, snapshots and reconnection. */ -class HostConnection { - readonly #client = crypto.randomUUID(); - readonly #inflight = new Map(); - readonly #listeners = new Set<() => void>(); - readonly #pendingBodies = new Map(); - readonly #unlisten: () => void; - #nextId = 0; - #host: string | null = null; - #snapshot: MdbaseSnapshot | null = null; - #shape: CollectionShape | null = null; - #proxy: { readonly key: string; readonly collection: ReaderConnectedCollection } | null = null; - #disposed = false; - #connected: Promise = Promise.resolve(); - #markConnected = (): void => undefined; - - constructor( - private readonly link: SdkLink, - private readonly options: SdkClientOptions, - ) { - this.#unlisten = link.listen((message) => { - if (isHostMessage(message)) { - this.#receive(message); - } - }); - } - - get snapshot(): MdbaseSnapshot { - if (!this.#snapshot) { - throw new Error(translate`mdbase has not started yet.`); - } - return this.#snapshot; - } - - subscribe(listener: () => void): () => void { - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - - /** Starts the host and waits for its snapshot, asking until it answers. */ - connect(): Promise { - this.#connected = new Promise((resolve, reject) => { - let hello: ReturnType | undefined; - const timeout = setTimeout(() => { - clearInterval(hello); - reject( - new Error(translate`mdbase could not start in the background. Reload the extension.`), - ); - }, startTimeoutMs); - this.#markConnected = () => { - clearInterval(hello); - clearTimeout(timeout); - resolve(); - }; - const sayHello = (): void => this.link.post({ kind: "hello", client: this.#client }); - this.options.ensureHost().then( - () => { - if (!this.#host) { - sayHello(); - hello = setInterval(sayHello, helloIntervalMs); - } - }, - (reason: unknown) => { - clearTimeout(timeout); - reject(reason instanceof Error ? reason : new Error(String(reason))); - }, - ); - }); - return this.#connected; - } - - async call(target: CallTarget, method: string, args: readonly unknown[]): Promise { - const callbacks: Callback[] = []; - const signals: AbortSignal[] = []; - // Throws for an already-aborted signal: that call never reaches the SDK. - const encoded = encodeArgs(args, callbacks, signals); - // A failed start is retried by the next call. - await this.#connected.catch(() => this.connect()); - for (const signal of signals) { - signal.throwIfAborted(); - } - const host = this.#host; - if (!host || this.#disposed) { - throw new Error(hostRestartedMessage()); - } - const id = ++this.#nextId; - return new Promise((resolve, reject) => { - this.#inflight.set(id, { host, resolve, reject, callbacks }); - for (const signal of signals) { - signal.addEventListener( - "abort", - () => this.link.post({ kind: "abort", client: this.#client, id }), - { once: true }, - ); - } - this.link.post({ kind: "call", client: this.#client, id, target, method, args: encoded }); - }); - } - - connectedCollection(): ReaderConnectedCollection | null { - if (!this.#host || !this.#shape) { - return null; - } - if (this.#proxy?.key !== this.#shape.key) { - this.#proxy = { key: this.#shape.key, collection: this.#collectionProxy(this.#shape) }; - } - return this.#proxy.collection; - } - - hostAvailable(): boolean { - return this.#host !== null; - } - - dispose(): void { - this.#disposed = true; - for (const id of this.#inflight.keys()) { - this.link.post({ kind: "abort", client: this.#client, id }); - } - this.#fail(() => true); - this.#unlisten(); - this.#listeners.clear(); - } - - /** Settles the matching calls with the restart outcome: they may or may not have run. */ - #fail(matches: (call: Inflight) => boolean): void { - for (const [id, call] of [...this.#inflight]) { - if (matches(call)) { - this.#inflight.delete(id); - call.reject(new Error(hostRestartedMessage())); - } - } - } - - #notify(): void { - for (const listener of [...this.#listeners]) { - listener(); - } - } - - #receive(message: HostMessage): void { - if (message.kind === "snapshot") { - this.#adopt(message); - return; - } - if (message.client !== this.#client) { - return; - } - if (message.kind === "result") { - this.#settle(message); - } else if (message.kind === "callback") { - const call = this.#inflight.get(message.id); - if (call?.host === message.host) { - call.callbacks[message.slot]?.(...message.args); - } - } else if (message.host === this.#host) { - this.options.openSignIn(message.url); - } - } - - #adopt(message: Extract): void { - const { snapshot } = message; - if (this.#disposed || !isValidSnapshotMessage(message) || !isApplicationSnapshot(snapshot)) { - return; - } - if (message.host !== this.#host) { - // A new host replaced the old one: the old one will never answer. - this.#fail((call) => call.host !== message.host); - this.#host = message.host; - this.#proxy = null; - void this.options.hostGone(message.host).then(() => this.#hostGone(message.host)); - } - this.#snapshot = snapshot; - this.#shape = message.collection; - this.#markConnected(); - this.#notify(); - } - - #settle(message: Extract): void { - const call = this.#inflight.get(message.id); - if (call?.host !== message.host) { - return; - } - this.#inflight.delete(message.id); - if (message.pending) { - this.#pendingBodies.set(message.pending.requestId, message.pending.pending); - } - if (message.ok) { - call.resolve(message.value); - } else { - call.reject(deserializeError(message.value)); - } - } - - /** The host closed or crashed: fail what it never answered, then start a new one. */ - #hostGone(gone: string): void { - if (this.#disposed) { - return; - } - this.#fail((call) => call.host === gone); - if (gone !== this.#host) { - return; - } - this.#host = null; - this.#shape = null; - this.#proxy = null; - this.#notify(); - void this.connect().catch(() => undefined); - } - - #collectionProxy(current: CollectionShape): ReaderConnectedCollection { - const collection: Record = { - collectionId: current.collectionId, - collectionName: current.collectionName, - }; - for (const [repository, methods] of Object.entries(current.repositories)) { - const stub: Record = {}; - for (const method of methods) { - stub[method] = (...args: unknown[]) => - this.call({ key: current.key, repository }, method, args); - } - if (repository === "bodyRecovery") { - // Synchronous in the port: the host's answer arrives with the failed write. - // Unknown: let the SDK's recovery read the outcome back rather than assume none. - stub["pending"] = (requestId: string) => this.#pendingBodies.get(requestId) ?? true; - } - if (methods.includes("invalidateRecord")) { - stub["invalidateRecord"] = (path: string): void => { - void this.call({ key: current.key, repository }, "invalidateRecord", [path]).catch( - () => undefined, - ); - }; - } - collection[repository] = stub; - } - // Trusted same-origin channel: allowlisted methods return structured clones of the host's typed Reader values. - return collection as unknown as ReaderConnectedCollection; - } -} - -/** - * Connects this context to the extension's one SDK host, starting it when needed and - * reconnecting when Chrome closes it. Settles once the host has sent its first snapshot. - */ -export async function createSdkClient( - link: SdkLink, - options: SdkClientOptions, -): Promise { - const connection = new HostConnection(link, options); - /** An application command whose answer must be an SDK snapshot. */ - const command = - (method: ApplicationCommand) => - async (...args: unknown[]): Promise => { - const answer = await connection.call({ app: true }, method, args); - if (!isApplicationSnapshot(answer)) { - throw new Error(`mdbase answered ${method} with something other than its state.`); - } - return answer; - }; - const application: RemoteApplication = { - getSnapshot: () => connection.snapshot, - subscribe: (listener) => connection.subscribe(listener), - start: command("start"), - signIn: command("signIn"), - approve: command("approve"), - select: command("select"), - chooseAnother: command("chooseAnother"), - configure: command("configure"), - async assess(...args) { - const answer = await connection.call({ app: true }, "assess", args); - if (!isReadinessSnapshot(answer)) { - throw new Error( - translate({ message: "mdbase answered the readiness check with something else." }), - ); - } - return answer; - }, - }; - await connection.connect(); - return { - application, - connectedCollection: () => connection.connectedCollection(), - hostAvailable: () => connection.hostAvailable(), - dispose: () => connection.dispose(), - }; -} diff --git a/apps/extension/src/sdk-collection.test.ts b/apps/extension/src/sdk-collection.test.ts new file mode 100644 index 00000000..cc19d4df --- /dev/null +++ b/apps/extension/src/sdk-collection.test.ts @@ -0,0 +1,92 @@ +import { memoryConnection } from "@mdbase-reader/connect/testing"; +import { afterEach, expect, it } from "vitest"; + +import { extensionCollection } from "./sdk-collection.js"; +import { applicationFixture, signedOutSnapshot } from "./testing/sdk-fixture.js"; + +import type { MdbaseApplicationConnection } from "@mdbase-reader/connect/native"; + +const cleanup: (() => void)[] = []; +afterEach(() => { + for (const close of cleanup.splice(0).reverse()) { + close(); + } +}); + +function context( + connection: MdbaseApplicationConnection, +): ReturnType & ReturnType { + const fixture = applicationFixture(); + fixture.controls.setConnections([connection]); + fixture.controls.publishSnapshot({ + ...signedOutSnapshot, + status: "ready", + selectedCollectionId: connection.id, + readiness: { + ...signedOutSnapshot.readiness, + assessment: { status: "ready", problem: null }, + }, + }); + const collection = extensionCollection(fixture.mdbase); + cleanup.push( + () => fixture.controls.close(), + () => collection.dispose(), + ); + return { ...fixture, ...collection }; +} + +it("independent contexts read a write through two clients of the same replica", async () => { + const memory = await memoryConnection(); + cleanup.push(memory.close); + const records = await memory.replica.client(); + cleanup.push(() => records.close()); + memory.replica.seed({ + path: "sources/article.md", + types: ["source"], + frontmatter: { id: "article", title: "Before" }, + body: "Original body", + }); + const first = context(memory.connection); + const second = context({ + ...memory.connection, + records, + files: records.files, + generation: "second", + }); + const firstCollection = first.connectedCollection(); + const secondCollection = second.connectedCollection(); + expect(firstCollection).not.toBeNull(); + expect(secondCollection).not.toBe(firstCollection); + const firstRecords = first.mdbase.connection(memory.connection.id)?.records; + const secondRecords = second.mdbase.connection(memory.connection.id)?.records; + if (!firstRecords || !secondRecords) { + throw new Error("Missing context connection"); + } + const before = await secondRecords.get({ path: "sources/article.md" }); + expect(before.frontmatter["title"]).toBe("Before"); + const saved = await firstRecords.settle( + firstRecords.update(before, { patch: { title: "After" } }), + ); + expect(saved.outcome).toBe("confirmed"); + expect((await secondRecords.get(before)).frontmatter["title"]).toBe("After"); + + // Retiring this page's adapters does not close the other context's borrowed client. + first.dispose(); + expect((await records.get({ path: "sources/article.md" })).frontmatter["title"]).toBe("After"); + expect(second.connectedCollection()).toBe(secondCollection); +}); + +it("retires only the adapters whose selected connection generation changes", async () => { + const memory = await memoryConnection(); + cleanup.push(memory.close); + const first = context(memory.connection); + const second = context(memory.connection); + const old = first.connectedCollection(); + const other = second.connectedCollection(); + first.controls.setConnections([{ ...memory.connection, generation: "reopened" }]); + expect(first.connectedCollection()).not.toBe(old); + expect(second.connectedCollection()).toBe(other); + first.controls.publishSnapshot(signedOutSnapshot); + expect(first.connectedCollection()).toBeNull(); + expect(second.connectedCollection()).toBe(other); +}); diff --git a/apps/extension/src/sdk-host-source.ts b/apps/extension/src/sdk-collection.ts similarity index 60% rename from apps/extension/src/sdk-host-source.ts rename to apps/extension/src/sdk-collection.ts index 7f4b10e9..d05d448b 100644 --- a/apps/extension/src/sdk-host-source.ts +++ b/apps/extension/src/sdk-collection.ts @@ -7,16 +7,7 @@ import { } from "@mdbase-reader/connect/native"; import { MemoryStorage, StorageCreateJournal } from "@mdbase-reader/platform"; -import type { SdkHostSource } from "./sdk-host.js"; - -/** - * Captures' record creates, followed through while this host runs: one refused because - * another device took its name is saved again at the next name. The extension keeps - * nothing between sessions and shows no "needs your attention" notice: it has no place - * to show one, and its storage is not the web app's. A capture that runs out of names, - * or is refused for another reason after it was reported saved, is not kept. - */ -const createStorage = new MemoryStorage(); +import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; function readyEvidence(snapshot: MdbaseSnapshot): boolean { const { readiness } = snapshot; @@ -31,7 +22,7 @@ function readyEvidence(snapshot: MdbaseSnapshot): boolean { } /** The SDK's current READY connection for the selected collection, or null. */ -export function readyConnection( +function readyConnection( sdk: Mdbase, snapshot: MdbaseSnapshot, ): MdbaseApplicationConnection | null { @@ -43,18 +34,24 @@ export function readyConnection( return connection?.id === selected && connection.isCurrent() ? connection : null; } -/** - * The host's application and Reader's adapters over its READY connection. The adapters are - * keyed by connection scope: a later snapshot may hand back a new facade for the same - * connection, which must not retire them. - */ -export function sdkHostSource(sdk: Mdbase, hostId: string): SdkHostSource { +/** Reader's adapters belong to this context's selection and connection generation. */ +export function extensionCollection(sdk: Mdbase): { + connectedCollection(): ReaderConnectedCollection | null; + dispose(): void; +} { + /** + * Captures' record creates, followed through while this context runs: one refused because + * another device took its name is saved again at the next name. The extension keeps + * nothing between sessions and shows no "needs your attention" notice: it has no place + * to show one, and its storage is not the web app's. A capture that runs out of names, + * or is refused for another reason after it was reported saved, is not kept. + */ + const createStorage = new MemoryStorage(); let opened: { readonly key: string; readonly collection: NativeReaderCollection } | null = null; return { - application: sdk, - currentCollection() { + connectedCollection() { const connection = readyConnection(sdk, sdk.getSnapshot()); - const key = connection ? `${hostId}:${connection.id}:${connection.generation}` : null; + const key = connection ? `${connection.id}:${connection.generation}` : null; if (opened?.key !== key) { opened?.collection.dispose(); opened = @@ -67,7 +64,11 @@ export function sdkHostSource(sdk: Mdbase, hostId: string): SdkHostSource { } : null; } - return opened; + return opened?.collection ?? null; + }, + dispose() { + opened?.collection.dispose(); + opened = null; }, }; } diff --git a/apps/extension/src/sdk-declaration.ts b/apps/extension/src/sdk-declaration.ts index 45256527..3ff162e4 100644 --- a/apps/extension/src/sdk-declaration.ts +++ b/apps/extension/src/sdk-declaration.ts @@ -7,10 +7,13 @@ import { import { environment } from "./environment.js"; import manifest from "./generated/mdbase-app.json"; +declare const __READER_EXTENSION_SESSION_BUILD__: string; + +/** Identical in every bundle of this package, different after a rebuild. */ +export const sessionBuild = __READER_EXTENSION_SESSION_BUILD__; + /** - * This build's environment as the SDK names it: the exact build environment, - * never mapped to another. The pinned SDK's types predate mdbase-next PR #941; - * `sdkApplicationEnvironment` is the one adapter (see packages/connect). + * This build's exact SDK environment, shared with Reader's native entry. */ export function sdkEnvironment( target: SdkApplicationEnvironment = environment.target, diff --git a/apps/extension/src/sdk-host.ts b/apps/extension/src/sdk-host.ts deleted file mode 100644 index 30bf2ce4..00000000 --- a/apps/extension/src/sdk-host.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { t as translate } from "@lingui/core/macro"; - -import { - collectionCommands, - decodeArgs, - failedRequestId, - isApplicationCommand, - isClientMessage, - isCollectionCommand, - serializeError, - transferable, - type CallTarget, - type ClientMessage, - type CollectionShape, - type HostMessage, - type SdkLink, -} from "./sdk-rpc.js"; - -import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; -import type { Mdbase } from "@mdbase-reader/connect/native"; - -export interface SdkHostSource { - readonly application: Mdbase; - /** The adapters over the SDK's current READY connection, keyed by connection scope. */ - currentCollection(): { - readonly key: string; - readonly collection: ReaderConnectedCollection; - } | null; -} - -export interface SdkHost { - /** The SDK's sign-in opener: the page whose action started sign-in opens the tab. */ - openSignIn(url: string): void; - close(): void; -} - -type CallMessage = Extract; -type Method = (...args: unknown[]) => unknown; - -/** Calls a person starts; a sign-in they lead to opens from that person's page. */ -const interactive = new Set(["signIn", "approve", "chooseAnother", "select", "configure"]); - -function member(owner: unknown, name: string): Method | null { - if (typeof owner !== "object" || owner === null) { - return null; - } - const value = (owner as Record)[name]; - return typeof value === "function" ? (value as Method).bind(owner) : null; -} - -/** The allowed commands this collection actually has, so optional ones stay absent. */ -function shapeOf(key: string, collection: ReaderConnectedCollection): CollectionShape { - const repositories: Record = {}; - for (const [repository, commands] of Object.entries(collectionCommands)) { - const owner = (collection as unknown as Record)[repository]; - const present = commands.filter((command) => member(owner, command)); - if (typeof owner === "object" && owner !== null) { - repositories[repository] = present; - } - } - return { - key, - collectionId: collection.collectionId, - collectionName: collection.collectionName, - repositories, - }; -} - -function refused(message: string, code: string): Error { - return Object.assign(new Error(message), { code }); -} - -function resolveTarget(source: SdkHostSource, target: CallTarget, method: string): Method { - if ("app" in target) { - const bound = isApplicationCommand(method) ? member(source.application, method) : null; - if (!bound) { - throw refused(`mdbase Reader does not send ${method}.`, "command_not_allowed"); - } - return bound; - } - if (!isCollectionCommand(target.repository, method)) { - throw refused( - `mdbase Reader does not send ${target.repository}.${method}.`, - "command_not_allowed", - ); - } - const current = source.currentCollection(); - if (current?.key !== target.key) { - throw refused( - translate({ message: "The collection connection changed. Try again." }), - "connection_retired", - ); - } - const repository = (current.collection as unknown as Record)[target.repository]; - const bound = member(repository, method); - if (!bound) { - throw refused(`The collection has no ${target.repository}.${method}.`, "command_not_allowed"); - } - return bound; -} - -/** Serves the offscreen document's SDK entry to every extension context over `link`. */ -export function serveSdk(link: SdkLink, source: SdkHostSource, hostId: string): SdkHost { - const { application } = source; - const controllers = new Map(); - let lastClient: string | null = null; - const post = (message: HostMessage): void => link.post(message); - - const announce = (): void => { - const current = source.currentCollection(); - post({ - kind: "snapshot", - host: hostId, - snapshot: transferable(application.getSnapshot()), - collection: current ? shapeOf(current.key, current.collection) : null, - }); - }; - - const failure = ( - reason: unknown, - ): Pick, "value" | "pending"> => { - const requestId = failedRequestId(reason); - const recovery = source.currentCollection()?.collection.bodyRecovery; - return { - value: serializeError(reason), - ...(requestId && recovery - ? { pending: { requestId, pending: recovery.pending(requestId) } } - : {}), - }; - }; - - const call = async (message: CallMessage): Promise => { - const controllerKey = `${message.client}:${String(message.id)}`; - const controller = new AbortController(); - controllers.set(controllerKey, controller); - if ("app" in message.target && interactive.has(message.method)) { - lastClient = message.client; - } - const reply = { kind: "result", host: hostId, client: message.client, id: message.id } as const; - const onCallback = (slot: number, args: unknown[]): void => - post({ kind: "callback", host: hostId, client: message.client, id: message.id, slot, args }); - try { - const target = resolveTarget(source, message.target, message.method); - const value: unknown = await target( - ...decodeArgs(message.args, controller.signal, onCallback), - ); - try { - post({ ...reply, ok: true, value: transferable(value) }); - } catch (reason) { - // The SDK's answer cannot cross the channel (it holds functions). - post({ ...reply, ok: false, value: serializeError(reason) }); - } - } catch (reason) { - post({ ...reply, ok: false, ...failure(reason) }); - } finally { - controllers.delete(controllerKey); - } - }; - - const unlisten = link.listen((message) => { - if (!isClientMessage(message)) { - return; - } - switch (message.kind) { - case "hello": - announce(); - return; - case "abort": - controllers.get(`${message.client}:${String(message.id)}`)?.abort(); - return; - case "call": - void call(message); - } - }); - const unsubscribe = application.subscribe(announce); - announce(); - return { - openSignIn(url) { - if (lastClient) { - post({ kind: "open", host: hostId, client: lastClient, url }); - } - }, - close() { - unsubscribe(); - unlisten(); - for (const controller of controllers.values()) { - controller.abort(); - } - }, - }; -} diff --git a/apps/extension/src/sdk-rpc-lifetime.test.ts b/apps/extension/src/sdk-rpc-lifetime.test.ts deleted file mode 100644 index 8f92b5be..00000000 --- a/apps/extension/src/sdk-rpc-lifetime.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { createApplicationFixture } from "@mdbase-reader/connect/testing"; -import { afterEach, expect, it, vi } from "vitest"; - -import manifest from "./generated/mdbase-app.json"; -import { createSdkClient, type SdkClient } from "./sdk-client.js"; -import { serveSdk, type SdkHost } from "./sdk-host.js"; -import { memoryBus } from "./testing/memory-bus.js"; -import { signedOutSnapshot } from "./testing/sdk-fixture.js"; - -const fixtures: ReturnType[] = []; -const clients: SdkClient[] = []; -const hosts: SdkHost[] = []; -afterEach(() => { - for (const client of clients.splice(0)) { - client.dispose(); - } - for (const host of hosts.splice(0)) { - host.close(); - } - for (const fixture of fixtures.splice(0)) { - fixture.controls.close(); - } -}); - -async function fixture(): Promise< - ReturnType & { - client: SdkClient; - application: ReturnType["mdbase"]; - } -> { - // Real public SDK lifecycle fixture, not a cast READY application/session. - // Metadata actions below do not constitute browser or authenticated acceptance. - const sdk = createApplicationFixture({ - declaration: { - id: "reader.rpc.fixture", - name: manifest.name, - origin: "https://reader.mdbase.dev", - environment: "lab", - manifest, - }, - connections: [], - // SDK 41c requires the initial snapshot. - initialSnapshot: signedOutSnapshot, - }); - fixtures.push(sdk); - const application = sdk.mdbase; - const bus = memoryBus(); - hosts.push( - serveSdk(bus.endpoint(), { application, currentCollection: () => null }, "owned-host"), - ); - const client = await createSdkClient(bus.endpoint(), { - ensureHost: () => Promise.resolve(), - hostGone: () => new Promise(() => undefined), - openSignIn: () => undefined, - }); - clients.push(client); - return { ...sdk, client, application }; -} - -it("an already-aborted caller cannot invoke a protected SDK action through the bridge", async () => { - const h = await fixture(); - h.controls.setHandler("authorize", () => h.application.getSnapshot()); - const caller = new AbortController(); - caller.abort(); - await h.client.application.signIn({ signal: caller.signal }).catch(() => undefined); - expect(h.controls.observedActions().filter((call) => call.action === "authorize")).toHaveLength( - 0, - ); -}); - -it("disposing only this caller rejects its pending call without closing the SDK owner", async () => { - const h = await fixture(); - let finish: (() => void) | undefined; - h.controls.setHandler( - "authorize", - () => - new Promise((resolve) => { - finish = () => resolve(h.application.getSnapshot()); - }), - ); - const result = h.client.application.signIn().then( - () => "resolved", - () => "rejected", - ); - await vi.waitFor(() => - expect(h.controls.observedActions().some((call) => call.action === "authorize")).toBe(true), - ); - h.client.dispose(); - try { - const state = await Promise.race([ - result, - new Promise((resolve) => setTimeout(() => resolve("unsettled"), 20)), - ]); - expect(state).toBe("rejected"); - expect(h.controls.observedActions().some((call) => call.action === "close")).toBe(false); - } finally { - finish?.(); - } -}); diff --git a/apps/extension/src/sdk-rpc.test.ts b/apps/extension/src/sdk-rpc.test.ts deleted file mode 100644 index 222ab3c0..00000000 --- a/apps/extension/src/sdk-rpc.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { describeError, nativeReaderCollection } from "@mdbase-reader/connect/native"; -import { mdbaseError, memoryConnection } from "@mdbase-reader/connect/testing"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { createSdkClient, hostRestartedMessage, type SdkClient } from "./sdk-client.js"; -import { serveSdk, type SdkHost, type SdkHostSource } from "./sdk-host.js"; -import { - decodeArgs, - deserializeError, - encodeArgs, - serializeError, - transferable, - withoutFunctions, -} from "./sdk-rpc.js"; -import { memoryBus } from "./testing/memory-bus.js"; -import { applicationFixture } from "./testing/sdk-fixture.js"; - -import type { ApplicationFixtureSnapshot } from "@mdbase-reader/connect/testing"; - -const owned: { close(): void }[] = []; -afterEach(() => { - for (const item of owned.splice(0).reverse()) { - item.close(); - } -}); - -function host( - bus: ReturnType, - id: string, - source: SdkHostSource, -): { host: SdkHost; endpoint: ReturnType["endpoint"]> } { - const endpoint = bus.endpoint(); - const served = serveSdk(endpoint, source, id); - owned.push(served, endpoint); - return { host: served, endpoint }; -} - -function fixtureHost( - bus: ReturnType, - id: string, - initial: Partial = {}, -): ReturnType & { served: ReturnType } { - const sdk = applicationFixture(initial); - owned.push(sdk.controls); - const served = host(bus, id, { - application: sdk.mdbase, - currentCollection: () => null, - }); - return { ...sdk, served }; -} - -async function client( - bus: ReturnType, - options: { - hostGone?: (id: string) => Promise; - ensureHost?: () => Promise; - openSignIn?: (url: string) => void; - } = {}, -): Promise { - const created = await createSdkClient(bus.endpoint(), { - ensureHost: options.ensureHost ?? (() => Promise.resolve()), - hostGone: options.hostGone ?? (() => new Promise(() => undefined)), - openSignIn: options.openSignIn ?? (() => undefined), - }); - owned.push({ close: () => created.dispose() }); - return created; -} - -const never = new Promise(() => undefined); - -describe("SDK RPC between extension pages and the offscreen host", () => { - it("serves only the public application commands, with the SDK's snapshot", async () => { - const bus = memoryBus(); - const sdk = fixtureHost(bus, "h1"); - const remote = await client(bus); - expect(remote.application.getSnapshot().status).toBe("signed_out"); - await remote.application.select("c2"); - expect(sdk.controls.observedActions()).toContainEqual( - expect.objectContaining({ action: "select", collectionId: "c2" }), - ); - // Not a command the extension may send, even though the application has it. - const raw = bus.endpoint(); - const replies: unknown[] = []; - raw.listen((message) => replies.push(message)); - raw.post({ - kind: "call", - client: "raw", - id: 1, - target: { app: true }, - method: "close", - args: [], - }); - await vi.waitFor(() => expect(replies).toContainEqual(expect.objectContaining({ ok: false }))); - expect(sdk.controls.observedActions().some((call) => call.action === "close")).toBe(false); - }); - - it("opens sign-in only from the page that started it", async () => { - const bus = memoryBus(); - const sdk = fixtureHost(bus, "h1"); - sdk.controls.setHandler("authorize", () => { - sdk.served.host.openSignIn("https://connect-lab.mdbase.dev/approve"); - }); - const bystander = vi.fn(); - await client(bus, { openSignIn: bystander }); - const opener = vi.fn(); - const remote = await client(bus, { openSignIn: opener }); - await remote.application.signIn(); - await vi.waitFor(() => - expect(opener).toHaveBeenCalledWith("https://connect-lab.mdbase.dev/approve"), - ); - expect(bystander).not.toHaveBeenCalled(); - }); - - it("fails calls the host never answered and reports the loss without inventing a status", async () => { - const bus = memoryBus(); - const first = fixtureHost(bus, "h1"); - first.controls.setHandler("authorize", () => never); - let gone = (): void => undefined; - const ensureHost = vi.fn(() => Promise.resolve()); - const remote = await client(bus, { - ensureHost, - hostGone: (id) => - new Promise((resolve) => (id === "h1" ? (gone = resolve) : undefined)), - }); - const pending = remote.application.signIn(); - await vi.waitFor(() => - expect(first.controls.observedActions().some((call) => call.action === "authorize")).toBe( - true, - ), - ); - const listener = vi.fn(); - remote.application.subscribe(listener); - - first.served.endpoint.close(); // Chrome closes the offscreen document. - gone(); - await expect(pending).rejects.toThrow(hostRestartedMessage()); - expect(remote.hostAvailable()).toBe(false); - expect(listener).toHaveBeenCalled(); - // The last snapshot the SDK sent, unchanged: no status the SDK did not report. - expect(remote.application.getSnapshot().status).toBe("signed_out"); - await vi.waitFor(() => expect(ensureHost).toHaveBeenCalledTimes(2)); - - const second = fixtureHost(bus, "h2", { status: "unselected" }); - await vi.waitFor(() => expect(remote.hostAvailable()).toBe(true)); - expect(remote.application.getSnapshot().status).toBe("unselected"); - await remote.application.select("c1"); - expect(second.controls.observedActions()).toContainEqual( - expect.objectContaining({ action: "select", collectionId: "c1" }), - ); - }); - - it("settles a call to a replaced host and ignores that host's late answer", async () => { - const bus = memoryBus(); - const first = fixtureHost(bus, "h1"); - let finish = (): void => undefined; - first.controls.setHandler( - "authorize", - () => - new Promise((resolve) => { - finish = resolve; - }), - ); - const remote = await client(bus); - const pending = remote.application.signIn().then( - () => "resolved", - (reason: unknown) => (reason instanceof Error ? reason.message : "rejected"), - ); - await vi.waitFor(() => - expect(first.controls.observedActions().some((call) => call.action === "authorize")).toBe( - true, - ), - ); - // A second host announces itself before the first is known to be gone. - fixtureHost(bus, "h2"); - await expect(pending).resolves.toBe(hostRestartedMessage()); - finish(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await expect(pending).resolves.toBe(hostRestartedMessage()); - }); -}); - -describe("the READY collection over the SDK RPC", () => { - it("serves the READY collection's public repository commands", async () => { - const native = await memoryConnection({ where: () => false }); - owned.push(native); - const collection = nativeReaderCollection(native.connection); - owned.push({ close: () => collection.dispose() }); - const bus = memoryBus(); - const sdk = applicationFixture(); - owned.push(sdk.controls); - host(bus, "h1", { - application: sdk.mdbase, - currentCollection: () => ({ key: "h1:c:1", collection }), - }); - const remote = await client(bus); - const proxy = remote.connectedCollection(); - expect(proxy?.collectionId).toBe(collection.collectionId); - // Streams cannot cross the channel; callers use the paged list instead. - expect(proxy?.sources.listPages).toBeUndefined(); - // The SDK's own outcome, unchanged by the channel (this replica refuses queries). - const query = { collectionId: collection.collectionId, limit: 10 }; - const direct = await collection.sources.list(query).catch((reason: unknown) => reason); - const bridged = await proxy?.sources.list(query).catch((reason: unknown) => reason); - expect(bridged).toBeInstanceOf(Error); - expect(bridged).toMatchObject({ - name: (direct as Error).name, - message: (direct as Error).message, - }); - // No pending answer from the host yet: recovery asks the SDK rather than assume. - expect(proxy?.bodyRecovery.pending("unknown-request")).toBe(true); - }); -}); - -describe("SDK RPC arguments and errors", () => { - it("carries bytes and callbacks, and refuses an already-aborted signal", () => { - const callbacks: ((...args: unknown[]) => void)[] = []; - const progress = vi.fn(); - const bytes = new Uint8Array([1, 2, 3]); - const encoded = structuredClone(encodeArgs([{ bytes }, { onProgress: progress }], callbacks)); - const posted: unknown[] = []; - const decoded = decodeArgs(encoded, new AbortController().signal, (slot, args) => - posted.push([slot, args]), - ) as [{ bytes: Uint8Array }, { onProgress: (value: number) => void }]; - expect(decoded[0].bytes).toEqual(bytes); - decoded[1].onProgress(3); - expect(posted).toEqual([[0, [3]]]); - const aborted = new AbortController(); - aborted.abort(); - expect(() => encodeArgs([{ signal: aborted.signal }], [])).toThrow(); - }); - - it("keeps an SDK error describable after it crosses the channel", () => { - const original = mdbaseError("unauthenticated", "Signed out."); - const crossed = deserializeError(structuredClone(serializeError(original))); - expect(crossed.name).toBe(original.name); - expect(describeError(crossed)?.title).toBe(describeError(original)?.title); - }); -}); - -describe("withoutFunctions", () => { - it("drops handles a snapshot holds so it can cross a BroadcastChannel", () => { - const snapshot = { - phase: { kind: "ready" }, - selection: { usable: { id: "c1", close: () => undefined } }, - items: [{ name: "a", close: () => undefined }], - at: new Date(0), - }; - const copy = withoutFunctions(snapshot); - expect(() => structuredClone(copy)).not.toThrow(); - expect(copy).toEqual({ - phase: { kind: "ready" }, - selection: { usable: { id: "c1" } }, - items: [{ name: "a" }], - at: new Date(0), - }); - }); -}); - -describe("transferable", () => { - it("keeps a ready phase's live connection in the host", () => { - const connection = { files: { client: { session: new Map() } }, close: () => undefined }; - const copy = transferable({ - status: "ready", - phase: { kind: "ready", collectionId: "c1", connection }, - }); - expect(() => structuredClone(copy)).not.toThrow(); - expect(copy).toEqual({ status: "ready", phase: { kind: "ready", collectionId: "c1" } }); - }); -}); diff --git a/apps/extension/src/sdk-rpc.ts b/apps/extension/src/sdk-rpc.ts deleted file mode 100644 index 664e57a1..00000000 --- a/apps/extension/src/sdk-rpc.ts +++ /dev/null @@ -1,398 +0,0 @@ -import type { MdbaseSnapshot } from "@mdbase-reader/connect/native"; - -type MdbaseApplicationReadinessSnapshot = MdbaseSnapshot["readiness"]; - -/** - * The extension's SDK channel. One offscreen document owns the SDK (sdk-host.ts); the side - * panel, settings and welcome pages and the service worker reach it through sdk-client.ts. - * - * The channel is a same-origin BroadcastChannel rather than chrome.runtime messaging: - * runtime messages are JSON, while captures carry bytes (structured clone keeps them). - */ -export interface SdkLink { - post(message: unknown): void; - listen(listener: (message: unknown) => void): () => void; -} - -export const sdkChannelName = "mdbase-reader-sdk"; -/** Held by the host for its lifetime; clients learn it has gone when they can take it. */ -export const sdkHostLockName = "mdbase-reader-sdk-host"; - -export function broadcastLink(name = sdkChannelName): SdkLink & { close(): void } { - const channel = new BroadcastChannel(name); - return { - post: (message) => channel.postMessage(message), - listen(listener) { - const handle = (event: MessageEvent): void => listener(event.data); - channel.addEventListener("message", handle); - return () => channel.removeEventListener("message", handle); - }, - close: () => channel.close(), - }; -} - -/** The application commands the extension sends; nothing else reaches the SDK. */ -export const applicationCommands = [ - "start", - "signIn", - "approve", - "select", - "chooseAnother", - "assess", - "configure", -] as const; -export type ApplicationCommand = (typeof applicationCommands)[number]; - -/** - * Reader's public repository commands (packages/core ports) that cross the channel. - * Streams (`listPages`) and handles with methods (`documents.open`) cannot, and callers - * fall back to their alternatives; `bodyRecovery.pending` is answered client-side. - */ -export const collectionCommands = { - sources: [ - "list", - "get", - "findByUrl", - "findByCitekeyPrefix", - "updateBody", - "updateReading", - "updateReadingStatus", - "updateFields", - "updateCitation", - "appendAnnotationEmbed", - ], - sourceImports: ["findExactDuplicate", "commitFile", "attachFile"], - annotations: [ - "invalidateRecord", - "sourceIdsWithAnnotations", - "annotationCountsBySource", - "listAll", - "listForSource", - "create", - "updateBody", - "preflightDelete", - "delete", - "get", - ], - annotationAssets: ["store", "start", "mark"], - contentSearch: ["search"], - files: ["read"], - bodyRecovery: ["recoverSource", "recoverAnnotation"], - // Plain data only: the settings store itself follows the change feed in the host. - settings: ["highlightColors"], -} as const satisfies Record; -export type CollectionRepository = keyof typeof collectionCommands; - -export function isApplicationCommand(method: string): method is ApplicationCommand { - return (applicationCommands as readonly string[]).includes(method); -} - -export function isCollectionCommand(repository: string, method: string): boolean { - const commands = (collectionCommands as Record)[ - repository - ]; - return Object.hasOwn(collectionCommands, repository) && !!commands?.includes(method); -} - -/** Which repository methods the host's collection has, so optional ones stay absent. */ -export interface CollectionShape { - readonly key: string; - readonly collectionId: string; - readonly collectionName: string; - readonly repositories: Readonly>; -} - -export type CallTarget = - { readonly app: true } | { readonly key: string; readonly repository: string }; - -/** A function argument, replaced by a slot the host calls back through. */ -export interface CallbackSlot { - readonly callbackSlot: number; -} - -export type ClientMessage = - | { readonly kind: "hello"; readonly client: string } - | { - readonly kind: "call"; - readonly client: string; - readonly id: number; - readonly target: CallTarget; - readonly method: string; - readonly args: readonly unknown[]; - } - | { readonly kind: "abort"; readonly client: string; readonly id: number }; - -export type HostMessage = - | { - readonly kind: "snapshot"; - readonly host: string; - readonly snapshot: unknown; - readonly collection: CollectionShape | null; - } - | { - readonly kind: "result"; - readonly host: string; - readonly client: string; - readonly id: number; - readonly ok: boolean; - readonly value: unknown; - /** For a failed body write: whether its request is still durably pending. */ - readonly pending?: { readonly requestId: string; readonly pending: boolean }; - } - | { - readonly kind: "callback"; - readonly host: string; - readonly client: string; - readonly id: number; - readonly slot: number; - readonly args: readonly unknown[]; - } - | { readonly kind: "open"; readonly host: string; readonly client: string; readonly url: string }; - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -export function isHostMessage(value: unknown): value is HostMessage { - return ( - isObject(value) && ["snapshot", "result", "callback", "open"].includes(String(value["kind"])) - ); -} - -export function isClientMessage(value: unknown): value is ClientMessage { - return isObject(value) && ["hello", "call", "abort"].includes(String(value["kind"])); -} - -export function isCallbackSlot(value: unknown): value is CallbackSlot { - return isObject(value) && typeof value["callbackSlot"] === "number"; -} - -/** Errors cross as plain data: name, message and the SDK's own fields (code, reason, …). */ -export interface SerializedError { - readonly serializedError: true; - readonly name: string; - readonly message: string; - readonly fields: Record; - readonly cause?: SerializedError; -} - -function cloneable(value: unknown): boolean { - try { - structuredClone(value); - return true; - } catch { - return false; - } -} - -export function serializeError(reason: unknown, depth = 0): SerializedError { - if (!(reason instanceof Error)) { - return { serializedError: true, name: "Error", message: String(reason), fields: {} }; - } - const fields: Record = {}; - for (const key of Object.getOwnPropertyNames(reason)) { - if (key === "stack" || key === "message" || key === "cause" || key === "name") { - continue; - } - const value = (reason as unknown as Record)[key]; - if (typeof value !== "function" && cloneable(value)) { - fields[key] = value; - } - } - return { - serializedError: true, - name: reason.name, - message: reason.message, - fields, - ...(reason.cause !== undefined && depth < 3 - ? { cause: serializeError(reason.cause, depth + 1) } - : {}), - }; -} - -export function deserializeError(value: unknown): Error { - if (!isObject(value) || value["serializedError"] !== true) { - return new Error(String(value)); - } - const data = value as unknown as SerializedError; - const error = new Error(data.message, data.cause ? { cause: deserializeError(data.cause) } : {}); - Object.assign(error, data.fields); - Object.defineProperty(error, "name", { value: data.name, configurable: true, writable: true }); - return error; -} - -/** The request ID an SDK write failure names, for body-write recovery. */ -export function failedRequestId(reason: unknown): string | null { - for (let current = reason, depth = 0; isObject(current) && depth < 4; depth++) { - const id = current["requestId"]; - if (typeof id === "string" && id) { - return id; - } - current = current["cause"]; - } - return null; -} - -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype - ); -} - -/** - * A copy that can cross the channel: drops function members (such as a handle's `close()`) - * from plain objects and arrays. Snapshots and results are data; their handles stay with - * the host. - */ -export function withoutFunctions(value: unknown, depth = 0): unknown { - if (depth > 32) { - return value; - } - if (Array.isArray(value)) { - return value.map((item) => - typeof item === "function" ? undefined : withoutFunctions(item, depth + 1), - ); - } - if (!isPlainObject(value)) { - return value; - } - const copy: Record = {}; - for (const [key, item] of Object.entries(value)) { - if (typeof item !== "function") { - copy[key] = withoutFunctions(item, depth + 1); - } - } - return copy; -} - -/** - * An application snapshot (or a call's result) as data pages can receive. A ready phase - * carries the SDK's live connection, which stays in the host: pages reach the collection - * through the host's collection shape instead. - */ -export function transferable(value: unknown): unknown { - const phase = isPlainObject(value) ? value["phase"] : undefined; - if (isPlainObject(value) && isPlainObject(phase) && "connection" in phase) { - const rest: Record = { ...phase }; - delete rest["connection"]; - return withoutFunctions({ ...value, phase: rest }); - } - return withoutFunctions(value); -} - -type Callback = (...args: unknown[]) => void; - -/** - * Replaces functions with callback slots and an AbortSignal with a marker the host turns - * into its own signal (aborted with this one). Throws an already-aborted signal's reason: - * such a call must never reach the SDK. - */ -export function encodeArgs( - args: readonly unknown[], - callbacks: Callback[], - signals: AbortSignal[] = [], -): unknown[] { - const slot = (fn: Callback): CallbackSlot => { - callbacks.push(fn); - return { callbackSlot: callbacks.length - 1 }; - }; - return args.map((arg) => { - if (typeof arg === "function") { - return slot(arg as Callback); - } - if (!isPlainObject(arg)) { - return arg; - } - const encoded: Record = {}; - for (const [key, value] of Object.entries(arg)) { - if (value instanceof AbortSignal) { - value.throwIfAborted(); - signals.push(value); - encoded["abortSignal"] = true; - } else if (typeof value === "function") { - encoded[key] = slot(value as Callback); - } else if (value !== undefined) { - encoded[key] = value; - } - } - return encoded; - }); -} - -/** The host's side of {@link encodeArgs}. */ -export function decodeArgs( - args: readonly unknown[], - signal: AbortSignal, - onCallback: (slot: number, args: unknown[]) => void, -): unknown[] { - const callback = - (slot: number) => - (...values: unknown[]): void => - onCallback(slot, values); - return args.map((arg) => { - if (isCallbackSlot(arg)) { - return callback(arg.callbackSlot); - } - if (!isPlainObject(arg)) { - return arg; - } - const decoded: Record = {}; - for (const [key, value] of Object.entries(arg)) { - if (key === "abortSignal") { - decoded["signal"] = signal; - } else { - decoded[key] = isCallbackSlot(value) ? callback(value.callbackSlot) : value; - } - } - return decoded; - }); -} - -/** The SDK readiness facet's shape, as `assess` answers it. */ -export function isReadinessSnapshot(value: unknown): value is MdbaseApplicationReadinessSnapshot { - return isObject(value) && typeof value["checking"] === "boolean"; -} - -/** The fields the client reads from a host snapshot; anything else is not one. */ -export function isApplicationSnapshot(value: unknown): value is MdbaseSnapshot { - if (!isObject(value)) { - return false; - } - const { status, collections, selection, readiness } = value; - return ( - typeof status === "string" && - Array.isArray(collections) && - isObject(selection) && - typeof selection["state"] === "string" && - isReadinessSnapshot(readiness) - ); -} - -function isCollectionShape(value: unknown): value is CollectionShape { - if (!isObject(value) || !isObject(value["repositories"])) { - return false; - } - return ( - typeof value["key"] === "string" && - typeof value["collectionId"] === "string" && - typeof value["collectionName"] === "string" && - Object.entries(value["repositories"]).every( - ([repository, methods]) => - Array.isArray(methods) && - methods.every( - (method) => typeof method === "string" && isCollectionCommand(repository, method), - ), - ) - ); -} - -/** A host snapshot message whose snapshot and collection shape have the expected form. */ -export function isValidSnapshotMessage( - message: Extract, -): boolean { - return ( - typeof message.host === "string" && - isApplicationSnapshot(message.snapshot) && - (message.collection === null || isCollectionShape(message.collection)) - ); -} diff --git a/apps/extension/src/source-note-session.test.ts b/apps/extension/src/source-note-session.test.ts index 80b7e8d2..4dd76d65 100644 --- a/apps/extension/src/source-note-session.test.ts +++ b/apps/extension/src/source-note-session.test.ts @@ -1,296 +1,130 @@ -import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { sourceFromDocument } from "@mdbase-reader/connect"; +import { createTestApp } from "@mdbase-reader/connect/testing"; +import { collectionId } from "@mdbase-reader/core"; +import { afterEach, expect, it, vi } from "vitest"; import { SourceNoteSession, type NoteDraftStore } from "./source-note-session.js"; import type { NoteDraft } from "./drafts.js"; -import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; +import type { MdbaseClient } from "@mdbase-dev/sdk"; +import type { TestApp } from "@mdbase-reader/connect/testing"; import type { Source } from "@mdbase-reader/core"; -beforeEach(() => { - vi.useFakeTimers(); -}); -afterEach(() => { +const cleanup: (() => void | Promise)[] = []; +afterEach(async () => { vi.useRealTimers(); + for (const close of cleanup.splice(0)) { + await close(); + } + vi.restoreAllMocks(); }); - -function source(body: string, revision: string): Source { - return { - id: "s1", - collectionId: "c1", - path: "sources/s1.md", - title: "[test] Source", - creators: [], - tags: [], - documents: [], - frontmatter: {}, - body, - recordRevision: revision, - } as unknown as Source; -} - -interface FakeCollection { - readonly collection: ReaderConnectedCollection; - readonly sources: { - readonly get: () => Promise; - readonly updateBody: ReturnType; - readonly updateFields: ReturnType; - }; - /** Another application edits the note. */ - edit(body: string): void; - /** Another application edits the source's title or tags. */ - editFields(fields: Partial>): void; -} - -/** A collection holding one source that refuses writes against a stale revision. */ -function fakeCollection(initial: Source): FakeCollection { - let current = initial; - let revision = 1; - const next = (changes: Partial): Source => { - revision += 1; - current = { ...current, ...changes, recordRevision: `r${String(revision)}` } as Source; - return current; - }; - const sources = { - get: vi.fn(() => Promise.resolve(current)), - updateBody: vi.fn( - (input: { readonly expectedRevision: string; readonly body: string }): Promise => { - if (input.expectedRevision !== current.recordRevision) { - return Promise.reject(new Error("The source changed before saving.")); - } - return Promise.resolve(next({ body: input.body })); +async function fixture(draft?: NoteDraft): Promise<{ + app: TestApp; + client: MdbaseClient; + source: Source; + session: SourceNoteSession; + store: NoteDraftStore; + publish: ReturnType; + stored: () => NoteDraft | null; +}> { + const app = await createTestApp({ + collections: [ + { + name: "Reading", + records: [ + { + path: "sources/note.md", + types: ["reader-source"], + frontmatter: { id: "note", title: "Title", tags: ["first"] }, + body: "Original", + }, + ], }, - ), - // Like the repository's, it writes over whatever is current. - updateFields: vi.fn((input: { readonly fields: Partial }): Promise => - Promise.resolve(next(input.fields)), - ), - }; - const collection = { - collectionId: "c1", - sources, - bodyRecovery: { pending: () => false, recoverSource: vi.fn() }, - } as unknown as ReaderConnectedCollection; - return { - collection, - sources, - edit(body: string): void { - next({ body }); - }, - editFields(fields): void { - next(fields); - }, - }; -} - -function memoryStore(initial?: NoteDraft): NoteDraftStore & { - readonly drafts: Map; -} { - const drafts = new Map(initial ? [["c1 s1", initial]] : []); - return { - drafts, - load: (collectionId, sourceId) => - Promise.resolve(drafts.get(`${collectionId} ${sourceId}`) ?? null), - save: (collectionId, sourceId, draft) => { - if (draft) { - drafts.set(`${collectionId} ${sourceId}`, draft); - } else { - drafts.delete(`${collectionId} ${sourceId}`); - } + ], + }); + cleanup.push(() => app.close()); + const client = app.client(); + const source = sourceFromDocument( + collectionId(client.collection), + await client.get({ path: "sources/note.md" }, { include: { body: true, effective: true } }), + ); + let stored = draft ?? null; + const store: NoteDraftStore = { + load: () => Promise.resolve(stored), + save: vi.fn((_collection, _source, value) => { + stored = value; return Promise.resolve(); - }, + }), }; -} - -it("autosaves typing against the loaded revision, keeping a browser copy until saved", async () => { - const fake = fakeCollection(source("First", "r1")); - const store = memoryStore(); - const session = new SourceNoteSession(source("First", "r1"), fake.collection, store); - await session.start(); - - session.edit("First, then more"); - expect(session.getSnapshot().state).toBe("unsaved"); - await vi.advanceTimersByTimeAsync(300); - expect(store.drafts.get("c1 s1")).toEqual({ body: "First, then more", baseBody: "First" }); - - await vi.advanceTimersByTimeAsync(1000); - expect(fake.sources.updateBody).toHaveBeenCalledWith( - expect.objectContaining({ expectedRevision: "r1", body: "First, then more" }), - ); - expect(session.getSnapshot().state).toBe("saved"); - expect(store.drafts.size).toBe(0); -}); - -it("brings back text left by a closed panel and saves it", async () => { - const fake = fakeCollection(source("Saved", "r1")); - const store = memoryStore({ body: "Saved and unsent", baseBody: "Saved" }); - const session = new SourceNoteSession(source("Saved", "r1"), fake.collection, store); - await session.start(); - - expect(session.getSnapshot()).toMatchObject({ body: "Saved and unsent", restored: true }); - await vi.advanceTimersByTimeAsync(1100); - expect(fake.sources.updateBody).toHaveBeenCalledWith( - expect.objectContaining({ body: "Saved and unsent" }), - ); - expect(session.getSnapshot().state).toBe("saved"); - expect(store.drafts.size).toBe(0); -}); - -it("asks before restoring text whose note changed elsewhere meanwhile", async () => { - const fake = fakeCollection(source("Edited in Reader", "r2")); - const store = memoryStore({ body: "Typed in the panel", baseBody: "Original" }); - const session = new SourceNoteSession(source("Edited in Reader", "r2"), fake.collection, store); - await session.start(); - - expect(session.getSnapshot().state).toBe("conflict"); - await vi.advanceTimersByTimeAsync(2000); - expect(fake.sources.updateBody).not.toHaveBeenCalled(); - - session.resolve("theirs"); - await vi.advanceTimersByTimeAsync(300); - expect(session.getSnapshot()).toMatchObject({ state: "saved", body: "Edited in Reader" }); - expect(store.drafts.size).toBe(0); -}); - -it("does not overwrite a note edited in Reader while the panel was typing", async () => { - const fake = fakeCollection(source("Shared", "r1")); - const session = new SourceNoteSession(source("Shared", "r1"), fake.collection, memoryStore()); - await session.start(); - - session.edit("Shared, from the panel"); - fake.edit("Shared, from Reader"); - await vi.advanceTimersByTimeAsync(1100); - - const note = session.getSnapshot(); - expect(note.state).toBe("conflict"); - expect(note.remote?.body).toBe("Shared, from Reader"); - expect(note.body).toBe("Shared, from the panel"); - - session.resolve("mine"); - await vi.advanceTimersByTimeAsync(1100); - expect(session.getSnapshot()).toMatchObject({ state: "saved", body: "Shared, from the panel" }); - expect(fake.sources.updateBody).toHaveBeenLastCalledWith( - expect.objectContaining({ body: "Shared, from the panel" }), - ); -}); - -it("autosaves a changed title and tags with the note, and publishes the saved source", async () => { - const fake = fakeCollection(source("Body", "r1")); const publish = vi.fn(); - const session = new SourceNoteSession( - source("Body", "r1"), - fake.collection, - memoryStore(), - publish, - ); + const session = new SourceNoteSession(app.mdbase, source, store, publish); await session.start(); - - session.editDetails({ title: "[test] Renamed", tags: ["ml"] }); - session.edit("Body, continued"); - expect(session.getSnapshot()).toMatchObject({ - state: "unsaved", - details: { title: "[test] Renamed", tags: ["ml"] }, - }); - await vi.advanceTimersByTimeAsync(1100); - - expect(fake.sources.updateFields).toHaveBeenCalledWith( - expect.objectContaining({ fields: { title: "[test] Renamed", tags: ["ml"] } }), - ); - // The note is written against the revision the fields write produced. - expect(fake.sources.updateBody).toHaveBeenLastCalledWith( - expect.objectContaining({ expectedRevision: "r2", body: "Body, continued" }), - ); - expect(session.getSnapshot().state).toBe("saved"); - expect(publish).toHaveBeenLastCalledWith( - expect.objectContaining({ title: "[test] Renamed", body: "Body, continued" }), - ); + return { app, client, source, session, store, publish, stored: () => stored }; +} +it("autosaves the note and details with a native record lease", async () => { + const { session, source, client, stored, publish } = await fixture(); + session.edit("Edited"); + session.editDetails({ title: "New title", tags: ["changed"] }); + await vi.waitFor(() => expect(stored()?.body).toBe("Edited")); + session.save(); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("saved")); + const record = await client.get({ path: source.path }, { include: { body: true } }); + expect(record.body).toBe("Edited"); + expect(record.frontmatter).toMatchObject({ title: "New title", tags: ["changed"] }); + expect(stored()).toBeNull(); + expect(publish).toHaveBeenCalled(); }); - -it("does not save a cleared title", async () => { - const fake = fakeCollection(source("Body", "r1")); - const session = new SourceNoteSession(source("Body", "r1"), fake.collection, memoryStore()); - await session.start(); - session.editDetails({ title: " " }); - await vi.advanceTimersByTimeAsync(1100); - expect(session.getSnapshot()).toMatchObject({ - state: "saved", - details: { title: "[test] Source" }, - }); - expect(fake.sources.updateFields).not.toHaveBeenCalled(); +it("restores unsent text and saves against its original base", async () => { + const { session, stored } = await fixture({ body: "Restored", baseBody: "Original" }); + expect(session.getSnapshot()).toMatchObject({ body: "Restored", restored: true }); + session.save(); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("saved")); + expect(stored()).toBeNull(); }); - -it("takes in a title changed elsewhere while only the note is edited here", async () => { - const fake = fakeCollection(source("Body", "r1")); - const session = new SourceNoteSession(source("Body", "r1"), fake.collection, memoryStore()); - await session.start(); - - session.edit("Body, from the panel"); - fake.editFields({ title: "[test] From Reader" }); - await vi.advanceTimersByTimeAsync(3000); - - expect(session.getSnapshot()).toMatchObject({ - state: "saved", - body: "Body, from the panel", - details: { title: "[test] From Reader" }, - }); -}); - -it("asks when the title changed both here and elsewhere, showing their version", async () => { - const fake = fakeCollection(source("Body", "r1")); - const session = new SourceNoteSession(source("Body", "r1"), fake.collection, memoryStore()); - await session.start(); - - session.editDetails({ title: "[test] Mine" }); - fake.editFields({ title: "[test] Theirs" }); - await vi.advanceTimersByTimeAsync(1100); - - expect(session.getSnapshot()).toMatchObject({ - state: "conflict", - details: { title: "[test] Mine" }, - remote: { title: "[test] Theirs" }, - }); - expect(fake.sources.updateFields).not.toHaveBeenCalled(); - - session.resolve("mine"); - await vi.advanceTimersByTimeAsync(1100); - expect(fake.sources.updateFields).toHaveBeenCalledWith( - expect.objectContaining({ fields: { title: "[test] Mine" } }), +it.each(["mine", "theirs"] as const)( + "asks before replacing a changed recovered base: %s", + async (choice) => { + const { session, client } = await fixture({ body: "Recovered", baseBody: "Old base" }); + const update = vi.spyOn(client, "update"); + expect(session.getSnapshot()).toMatchObject({ state: "conflict", body: "Recovered" }); + expect(update).not.toHaveBeenCalled(); + session.resolve(choice); + await vi.waitFor(() => expect(session.getSnapshot().state).toBe("saved")); + expect(session.getSnapshot().body).toBe(choice === "mine" ? "Recovered" : "Original"); + expect(update.mock.calls.length).toBe(choice === "mine" ? 1 : 0); + }, +); +it("observes remote body changes and retains conflicting local text", async () => { + const { session, app, source } = await fixture(); + session.edit("Local"); + app.replica().editElsewhere(source.path, { body: "Remote" }); + await vi.waitFor(() => + expect(session.getSnapshot()).toMatchObject({ + state: "conflict", + body: "Local", + remote: { body: "Remote" }, + }), ); - expect(session.getSnapshot().state).toBe("saved"); + session.resolve("theirs"); + expect(session.getSnapshot().body).toBe("Remote"); }); - -it("keeps an unsaved title for a closed panel and saves it when the panel returns", async () => { - const store = memoryStore(); - const fake = fakeCollection(source("Body", "r1")); - const first = new SourceNoteSession(source("Body", "r1"), fake.collection, store); - await first.start(); - first.editDetails({ title: "[test] Kept" }); - first.keepCopy(); - await vi.advanceTimersByTimeAsync(300); - expect(store.drafts.get("c1 s1")).toMatchObject({ - patch: { title: "[test] Kept" }, - baseFrontmatter: { title: "[test] Source", tags: [] }, - }); - - // A new panel, before the first one saved. - const fresh = fakeCollection(source("Body", "r1")); - const second = new SourceNoteSession(source("Body", "r1"), fresh.collection, store); - await second.start(); - expect(second.getSnapshot()).toMatchObject({ - restored: true, - details: { title: "[test] Kept" }, - }); - await vi.advanceTimersByTimeAsync(1100); - expect(fresh.sources.updateFields).toHaveBeenCalledWith( - expect.objectContaining({ fields: { title: "[test] Kept" } }), +it("keeps title and tag changes in the browser checkpoint when the panel closes", async () => { + const { session, stored } = await fixture(); + session.editDetails({ title: "Unsent title", tags: ["new"] }); + session.keepCopy(); + await vi.waitFor(() => + expect(stored()).toMatchObject({ + body: "Original", + patch: { title: "Unsent title", tags: ["new"] }, + }), ); }); - -it("says when the browser cannot keep a copy of unsaved text", async () => { - const fake = fakeCollection(source("Body", "r1")); - const session = new SourceNoteSession(source("Body", "r1"), fake.collection, { - load: () => Promise.reject(new Error("storage unavailable")), - save: () => Promise.reject(new Error("storage unavailable")), - }); - await session.start(); - expect(session.getSnapshot().localProblem).toContain("could not keep a copy"); +it("retains older request-only recovery without inventing a native witness", async () => { + const draft = { body: "Uncertain text", baseBody: "Original", requestId: "earlier-request" }; + const { session, stored, client } = await fixture(draft); + const update = vi.spyOn(client, "update"); + session.save(); + expect(session.getSnapshot().state).toBe("error"); + expect(stored()).toEqual(draft); + expect(update).not.toHaveBeenCalled(); }); diff --git a/apps/extension/src/source-note-session.ts b/apps/extension/src/source-note-session.ts index 8ff4b2d6..2ba71573 100644 --- a/apps/extension/src/source-note-session.ts +++ b/apps/extension/src/source-note-session.ts @@ -1,16 +1,16 @@ import { i18n } from "@lingui/core"; import { t as translate, msg } from "@lingui/core/macro"; import { - MdbaseRecordSession, - recordChanged, - recordFailure, - recordMissing, - recordOutcome, - recordSuccess, + openReaderRecord, + sourceFromDocument, + type Mdbase, type JsonObject, - type MdbaseRecordSessionState, - type ReaderConnectedCollection, + type RecordLease, + type RecordSession, + type RecordSessionState, + type RecordSessionSnapshot, } from "@mdbase-reader/connect"; +import { describeError } from "@mdbase-reader/connect/native"; import { loadNoteDraft, saveNoteDraft, type NoteDraft } from "./drafts.js"; @@ -23,7 +23,7 @@ export interface SourceDetails { } export interface SourceNoteSnapshot { - readonly state: MdbaseRecordSessionState; + readonly state: RecordSessionState; readonly body: string; /** The title and tags with this panel's unsaved edits. */ readonly details: SourceDetails; @@ -48,19 +48,19 @@ const localProblem = msg`Your browser could not keep a copy of this note. Keep t /** Typing is copied to browser storage after this pause, so closing the panel loses little. */ const storeDelayMs = 250; -type NoteCollection = Pick; - /** * A saved source's literature note, edited in the panel. The SDK record session owns * autosave, revision checks, conflicts and exact recovery; this adds the copy in * `chrome.storage.session` that survives closing the panel. */ export class SourceNoteSession { - readonly #record: MdbaseRecordSession; + private record: RecordSession | null = null; + private lease: RecordLease | null = null; + private base: Source; + private restoredRemote: Source | null = null; readonly #listeners = new Set<() => void>(); readonly #collectionId: string; readonly #sourceId: string; - readonly #pending: (requestId: string) => boolean; #restored = false; #localProblem: string | null = null; #storeTimer: ReturnType | undefined; @@ -69,107 +69,102 @@ export class SourceNoteSession { #snapshot: SourceNoteSnapshot; constructor( + private readonly mdbase: Mdbase, base: Source, - collection: NoteCollection, private readonly store: NoteDraftStore = browserSession, - /** Each newer copy of the source this session saves or reads, for the rest of the panel. */ private readonly publish: (source: Source) => void = () => undefined, ) { - this.#collectionId = collection.collectionId; + this.base = base; + this.#collectionId = base.collectionId; this.#sourceId = base.id; - const { collectionId, sources, bodyRecovery } = collection; - this.#pending = (requestId) => bodyRecovery.pending(requestId); - this.#record = new MdbaseRecordSession( - base, - { - revision: (source) => source.recordRevision, - body: (source) => source.body, - // Only these fields are compared, so an edit elsewhere to any other is never a conflict. - frontmatter: (source) => ({ title: source.title, tags: [...source.tags] }), - write: async (expected, change) => { - try { - let target = expected; - if (change.patch) { - // Field updates take no revision, so check first that nothing moved on. A change - // made between this read and the write is overwritten for these two fields only. - const current = await sources.get(collectionId, expected.id); - if (!current) { - return recordMissing(); - } - if (current.recordRevision !== expected.recordRevision) { - return recordChanged(translate({ message: "The source changed before saving." })); - } - if (!sources.updateFields) { - throw new Error( - translate({ - message: "This collection can’t edit a source’s title and tags here.", - }), - ); - } - target = await sources.updateFields({ - collectionId, - sourceId: expected.id, - fields: change.patch, - }); - if (change.body === undefined) { - return recordSuccess(target); - } - } - return recordSuccess( - await sources.updateBody({ - collectionId, - sourceId: target.id, - expectedRevision: target.recordRevision, - body: change.body ?? target.body, - }), - ); - } catch (error) { - return recordFailure(error); - } - }, - read: (current) => recordOutcome(() => sources.get(collectionId, current.id)), - recover: (requestId) => - recordOutcome(() => bodyRecovery.recoverSource({ collectionId, requestId })), - isPending: this.#pending, - }, - { autosave: { idleMs: 1000 } }, - ); - this.#snapshot = this.#project(); - this.#record.subscribe(() => this.#sync()); + this.#snapshot = { + state: "saved", + body: base.body, + details: { title: base.title, tags: base.tags }, + source: base, + remote: null, + problem: null, + restored: false, + localProblem: null, + }; } - /** Brings back text left unsaved by an earlier panel, and resumes an interrupted write. */ async start(): Promise { - let draft: NoteDraft | null; - try { - draft = await this.store.load(this.#collectionId, this.#sourceId); - } catch { - this.#update({ localProblem: i18n._(localProblem) }); + if (this.lease) { return; } - // Typing that began while the copy loaded is newer than the copy. - if (!draft || this.#record.snapshot.dirty) { - return; + try { + const draft = await this.store.load(this.#collectionId, this.#sourceId); + const lease = await openReaderRecord(this.mdbase, this.base.collectionId, this.base.path, { + autosave: { idleMs: 1000 }, + ...(draft?.recovery ? { recovery: draft.recovery } : {}), + }); + if ( + draft?.requestId && + !draft.recovery && + lease.session.getSnapshot().recovery?.mutationId !== draft.requestId + ) { + lease.release(); + throw new Error( + "This earlier edit needs its original recovery information. Your local text has been retained.", + ); + } + this.lease = lease; + this.record = lease.session; + this.base = sourceFromDocument(this.base.collectionId, this.record.getSnapshot().record); + if (draft) { + this.restoreDraft(draft, lease.session); + } + this.record.subscribe(() => this.#sync()); + if (this.restoredRemote) { + this.#emit(); + } else { + this.#sync(); + } + } catch (error) { + this.#snapshot = { + ...this.#snapshot, + state: "error", + problem: describeError(error)?.message ?? translate`Reader could not open the source note.`, + }; + this.#emit(); } + } + + private restoreDraft(draft: NoteDraft, record: RecordSession): void { this.#stored = true; - // An earlier panel's write whose outcome is unknown settles before anything new is sent. - if (draft.requestId && this.#pending(draft.requestId)) { - this.#record.resumeRecovery(draft.requestId); - } - if (draft.body === this.#record.snapshot.record.body && !draft.patch) { + if (draft.body !== this.base.body || draft.patch) { + this.#restored = true; + if ( + !draft.recovery && + (draft.baseBody !== this.base.body || + (draft.baseFrontmatter && + Object.entries(draft.baseFrontmatter).some( + ([key, value]) => + JSON.stringify(record.getSnapshot().frontmatter[key]) !== JSON.stringify(value), + ))) + ) { + this.restoredRemote = this.base; + this.#snapshot = { + ...this.#snapshot, + state: "conflict", + body: draft.body, + remote: this.base, + restored: true, + details: sourceDetails({ + title: this.base.title, + tags: [...this.base.tags], + ...draft.patch, + }), + }; + } else { + record.setBody(draft.body); + if (draft.patch) { + record.patchFrontmatter(draft.patch); + } + } + } else { this.#clearStored(); - return; - } - this.#restored = true; - this.#record.restore({ - body: draft.body, - baseBody: draft.baseBody, - ...(draft.patch ? { patch: draft.patch } : {}), - ...(draft.baseFrontmatter ? { baseFrontmatter: draft.baseFrontmatter } : {}), - }); - // A restored draft that conflicts waits for a decision; otherwise it saves itself. - if (this.#record.snapshot.state === "unsaved") { - this.#record.autosave(); } } @@ -184,7 +179,12 @@ export class SourceNoteSession { return; } this.#restored = false; - this.#record.setBody(body); + if (this.restoredRemote) { + this.#snapshot = { ...this.#snapshot, body }; + this.#emit(); + } else { + this.record?.setBody(body); + } } /** A changed title or tags, autosaved with the note. An empty title is not saved. */ @@ -196,28 +196,54 @@ export class SourceNoteSession { }); if (patch) { this.#restored = false; - this.#record.patchFrontmatter(patch); + if (this.restoredRemote) { + this.#snapshot = { + ...this.#snapshot, + details: sourceDetails({ + ...this.#snapshot.details, + tags: [...this.#snapshot.details.tags], + ...patch, + }), + }; + this.#emit(); + } else { + this.record?.patchFrontmatter(patch); + } } } /** Saves now instead of after the autosave pause. */ save(): void { - void this.#record.save(); + void this.record?.flush(); } /** Reads the collection's copy, as when the panel is shown again after a while. */ refresh(): void { - if (this.#snapshot.state !== "saving" && this.#snapshot.state !== "recovery") { - void this.#record.refresh(); + if (this.record && !this.restoredRemote) { + this.#sync(); } } - /** Keep this panel's text on top of the newer note, or take the newer note. */ resolve(keep: "mine" | "theirs"): void { + if (!this.record) { + return; + } this.#restored = false; - this.#record.resolve({ keep }); + if (this.restoredRemote) { + this.restoredRemote = null; + if (keep === "mine") { + this.record.setBody(this.#snapshot.body); + this.record.patchFrontmatter({ + title: this.#snapshot.details.title, + tags: [...this.#snapshot.details.tags], + }); + } + } else { + this.record.resolve({ keep }); + } + this.#sync(); if (keep === "mine") { - void this.#record.save(); + void this.record.flush(); } } @@ -231,8 +257,11 @@ export class SourceNoteSession { } #sync(): void { - const next = this.#project(); - if (!this.#record.snapshot.dirty && next.state === "saved") { + if (!this.record || this.restoredRemote) { + return; + } + const next = this.#project(this.record.getSnapshot()); + if (!this.record.getSnapshot().dirty && next.state === "saved") { this.#clearStored(); } else if ( next.body !== this.#snapshot.body || @@ -249,8 +278,7 @@ export class SourceNoteSession { } } - #project(): SourceNoteSnapshot { - const record = this.#record.snapshot; + #project(record: RecordSessionSnapshot): SourceNoteSnapshot { const previous = this.#snapshot as SourceNoteSnapshot | undefined; const details = sourceDetails(record.frontmatter); return { @@ -261,8 +289,8 @@ export class SourceNoteSession { previous?.details.title === details.title && sameTags(previous.details.tags, details.tags) ? previous.details : details, - source: record.record, - remote: record.remote, + source: sourceFromDocument(this.base.collectionId, record.record), + remote: record.remote ? sourceFromDocument(this.base.collectionId, record.remote) : null, problem: record.state === "deleted" ? translate`This source is no longer in the collection. Your text is kept in this panel.` @@ -283,18 +311,31 @@ export class SourceNoteSession { } #store(): void { - const record = this.#record.snapshot; + const record = this.record?.getSnapshot(); + if (!record) { + return; + } if (!record.dirty) { return; } this.#stored = true; - const base = { title: record.record.title, tags: [...record.record.tags] }; + const details = sourceDetails(record.record.frontmatter); + const base = { title: details.title, tags: [...details.tags] }; const patch = detailsPatch(base, sourceDetails(record.frontmatter)); const draft: NoteDraft = { body: record.body, - baseBody: record.record.body, + baseBody: record.record.body ?? "", ...(patch ? { patch, baseFrontmatter: base } : {}), - ...(record.pendingRequestId ? { requestId: record.pendingRequestId } : {}), + ...(record.recovery + ? { + requestId: record.recovery.mutationId, + recovery: { + mutationId: record.recovery.mutationId, + recordId: record.recovery.recordId, + baseRevision: record.recovery.baseRevision, + }, + } + : {}), }; this.store.save(this.#collectionId, this.#sourceId, draft).then( () => this.#update({ localProblem: null }), diff --git a/apps/extension/src/testing/memory-bus.ts b/apps/extension/src/testing/memory-bus.ts deleted file mode 100644 index af33b17d..00000000 --- a/apps/extension/src/testing/memory-bus.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SdkLink } from "../sdk-rpc.js"; - -/** An in-memory BroadcastChannel: every endpoint but the sender gets a structured clone. */ -export function memoryBus(): { endpoint(): SdkLink & { close(): void } } { - const listeners = new Set<(message: unknown) => void>(); - return { - endpoint() { - const own = new Set<(message: unknown) => void>(); - return { - post(message) { - const copy = structuredClone(message); - for (const listener of listeners) { - if (!own.has(listener)) { - queueMicrotask(() => listener(copy)); - } - } - }, - listen(listener) { - listeners.add(listener); - own.add(listener); - return () => { - listeners.delete(listener); - own.delete(listener); - }; - }, - close() { - for (const listener of own) { - listeners.delete(listener); - } - own.clear(); - }, - }; - }, - }; -} diff --git a/apps/extension/src/use-collection-connection.ts b/apps/extension/src/use-collection-connection.ts index 48f72dc2..f3035ff9 100644 --- a/apps/extension/src/use-collection-connection.ts +++ b/apps/extension/src/use-collection-connection.ts @@ -12,8 +12,8 @@ export interface CollectionConnection extends ConnectionControls { } /** - * Connect outside the capture panel (welcome and settings pages). Grants and the remembered - * collection live in extension storage, so the panel picks up what is chosen here. + * Connect outside the capture panel (welcome and settings pages). The SDK shares sign-in; each + * page owns its selection, and the worker follows the last ready page selection. */ export function useCollectionConnection(): CollectionConnection { const lock = useActionLock(); diff --git a/apps/extension/src/use-connect.ts b/apps/extension/src/use-connect.ts index f746b9d4..d7d52b96 100644 --- a/apps/extension/src/use-connect.ts +++ b/apps/extension/src/use-connect.ts @@ -76,7 +76,7 @@ export function useConnect(lock: ActionLock): ConnectLink { currentSession.current = extension; const publish = (): void => { const usable = extension.connectedCollection() !== null; - const view = connectionView(application.getSnapshot(), usable, extension.hostAvailable()); + const view = connectionView(application.getSnapshot(), usable); if (view.status === "ready" && view.collectionId) { rememberCollection(view.collectionId); } diff --git a/apps/extension/src/use-extension-capture.test.tsx b/apps/extension/src/use-extension-capture.test.tsx index 3ed3a93b..ff0a0f7c 100644 --- a/apps/extension/src/use-extension-capture.test.tsx +++ b/apps/extension/src/use-extension-capture.test.tsx @@ -75,7 +75,6 @@ const ready: { const extension = { application, connectedCollection: () => (current["status"] === "ready" ? ready : null), - hostAvailable: () => true, journalStorage: {}, dispose: vi.fn(), }; diff --git a/apps/extension/src/use-extension-capture.ts b/apps/extension/src/use-extension-capture.ts index 4af1ba7d..451ce3f6 100644 --- a/apps/extension/src/use-extension-capture.ts +++ b/apps/extension/src/use-extension-capture.ts @@ -59,7 +59,7 @@ function lookupFailedNotice(): string { // The controller coordinates one panel's explicit actions; I/O lives in the composed hooks // and CaptureWriter. -// eslint-disable-next-line max-lines-per-function +// eslint-disable-next-line max-lines-per-function, complexity -- Shared-session integration only changes the editor input here. export function useExtensionCapture( tabId: number, link: PanelConnection, @@ -629,7 +629,12 @@ export function useExtensionCapture( () => extension?.connectedCollection() ?? null, [extension], ); - const note = useSourceNote(connectedCollection, source, setSource); + const note = useSourceNote( + extension?.application ?? null, + connectedCollection, + source, + setSource, + ); useQuickSave({ invocation: page.invocation, diff --git a/apps/extension/src/use-source-note.ts b/apps/extension/src/use-source-note.ts index 0ffe4fc1..f9339b6e 100644 --- a/apps/extension/src/use-source-note.ts +++ b/apps/extension/src/use-source-note.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { problemMessage } from "./capture-model.js"; import { SourceNoteSession } from "./source-note-session.js"; -import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection, Mdbase } from "@mdbase-reader/connect"; import type { Source, SourceSummary } from "@mdbase-reader/core"; export interface SourceNoteController { @@ -25,6 +25,7 @@ type Loaded = * session per source for the panel's lifetime, so returning to a page resumes its text. */ export function useSourceNote( + mdbase: Mdbase | null, collection: () => ReaderConnectedCollection | null, source: SourceSummary | null, onSaved: (source: Source) => void, @@ -42,7 +43,7 @@ export function useSourceNote( const load = useCallback(() => { const connected = collection(); - if (!sourceId || !connected) { + if (!sourceId || !connected || !mdbase) { return; } const kept = sessions.current.get(sourceId); @@ -56,7 +57,7 @@ export function useSourceNote( if (!full) { throw new Error(t`This source is no longer in the collection.`); } - const created = new SourceNoteSession(full, connected, undefined, (saved) => { + const created = new SourceNoteSession(mdbase, full, undefined, (saved) => { if (shown.current.sourceId === saved.id) { shown.current.onSaved(saved); } @@ -68,7 +69,7 @@ export function useSourceNote( (reason: unknown) => setLoaded({ sourceId, status: "failed", problem: problemMessage(reason) }), ); - }, [collection, sourceId]); + }, [mdbase, collection, sourceId]); useEffect(() => { if (!session) { diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts index 12170861..c136be6a 100644 --- a/apps/extension/vite.config.ts +++ b/apps/extension/vite.config.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; import { lingui } from "@lingui/vite-plugin"; @@ -9,6 +10,12 @@ import { extensionSdk } from "./scripts/extension-sdk.mjs"; import { chromeLocales, chromeMessages, shellCatalogue } from "./scripts/localized-shell.mjs"; const environment = extensionEnvironment(); +// One identity for all bundles emitted by this build, including the separate worker build. +const sessionBuild = randomUUID(); +const defines = { + __READER_EXTENSION_ENVIRONMENT__: JSON.stringify(environment), + __READER_EXTENSION_SESSION_BUILD__: JSON.stringify(sessionBuild), +}; type SdkAliases = Awaited>["aliases"]; @@ -37,16 +44,10 @@ function manifest(): Plugin { }; } -/** - * The service worker's classic scripts, built after the pages as self-contained IIFEs so - * they share no chunks with them: background.js, and page-status.js, which it loads with - * importScripts() only when a page may be saved (see src/page-status-loader.ts). Neither - * includes the SDK, which runs in the offscreen host (src/offscreen.ts). - */ +/** Build the module service worker and the injected social-page classic script. */ function serviceWorker(alias: SdkAliases): Plugin { const scripts = { background: "src/background.ts", - "page-status": "src/page-status-worker.ts", "social-page": "src/social-page.ts", }; return { @@ -60,7 +61,8 @@ function serviceWorker(alias: SdkAliases): Plugin { root: import.meta.dirname, logLevel: "warn", resolve: { alias }, - define: { __READER_EXTENSION_ENVIRONMENT__: JSON.stringify(environment) }, + define: defines, + worker: { format: "es" }, build: { target: "es2022", sourcemap: true, @@ -70,7 +72,7 @@ function serviceWorker(alias: SdkAliases): Plugin { modulePreload: false, rolldownOptions: { input: resolve(import.meta.dirname, entry), - // Vite's preload helper mentions import.meta; with modulePreload off it is unused. + // The injected classic script cannot use import.meta. onLog(level, log, handler) { if (log.code !== "EMPTY_IMPORT_META") { handler(level, log); @@ -85,7 +87,10 @@ function serviceWorker(alias: SdkAliases): Plugin { }, } : {}), - output: { format: "iife", entryFileNames: `${name}.js` }, + output: { + format: name === "background" ? "es" : "iife", + entryFileNames: `${name}.js`, + }, }, }, }); @@ -115,7 +120,7 @@ export default defineConfig(async (): Promise => { }, }, }, - define: { __READER_EXTENSION_ENVIRONMENT__: JSON.stringify(environment) }, + define: defines, build: { target: "es2022", sourcemap: true, diff --git a/apps/reader/scripts/validate-manifest.mjs b/apps/reader/scripts/validate-manifest.mjs index 04c2d23e..ca3e1550 100644 --- a/apps/reader/scripts/validate-manifest.mjs +++ b/apps/reader/scripts/validate-manifest.mjs @@ -1,14 +1,9 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { - formatManifestValidationIssues, - validateAppManifest, -} from "@mdbase-dev/connect-protocol/manifest"; +import { formatManifestValidationIssues, validateAppManifest } from "@mdbase-dev/sdk/manifest"; -/** The SAME canonical schema and semantic origin checks for web and extension. - * TODO: restore connect-dev CLI usage once a published devkit carries this - * canonical installation_origins support. Never strip or hand-validate fields. */ +/** The SDK canonical schema and semantic origin checks for web and extension. */ export function assertValidReaderManifest(manifest, { allowLocal = false } = {}) { const validation = validateAppManifest(manifest, { allowLocal }); if (!validation.valid) { diff --git a/apps/reader/src/AnnotationCard.tsx b/apps/reader/src/AnnotationCard.tsx index 00d81308..2030c87d 100644 --- a/apps/reader/src/AnnotationCard.tsx +++ b/apps/reader/src/AnnotationCard.tsx @@ -35,29 +35,13 @@ import { HighlightSwatches } from "./HighlightSwatches.js"; import { FocusIcon, MoreIcon } from "./icons.js"; import { Menu } from "./Menu.js"; import { annotationLocationLabel } from "./passage-labels.js"; +import { useReaderMdbase } from "./reader-collections.js"; import { useAnnotationSession } from "./use-annotation-session.js"; import type { AnnotationTransclusionController } from "./use-annotation-transclusion.js"; const annotationDate = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }); -export function AnnotationCard({ - annotation, - editing, - active, - transclusion, - onEdit, - onCancel, - onSave, - onPlanDelete, - onDelete, - onOpen, - readFile, - owners, - onMoveOwner, - onRecolor, - showLocation = true, - showSource = false, -}: { +interface AnnotationCardProps { readonly annotation: Annotation; readonly owners?: readonly SourceSummary[] | undefined; readonly onMoveOwner?: @@ -79,19 +63,73 @@ export function AnnotationCard({ readonly showLocation?: boolean; /** True when the annotation belongs to a source other than the open one. */ readonly showSource?: boolean; -}): JSX.Element { - useLocale(); - // The list reveals explicit selection once; remounting a windowed selected card must not jump. - const cardRef = useScrollToEditing(editing); - const session = useAnnotationSession(annotation, onSave); +} + +export function AnnotationCard(props: AnnotationCardProps): JSX.Element { + const mdbase = useReaderMdbase(); + return mdbase ? ( + + ) : ( + + ); +} + +function NativeAnnotationCard(props: AnnotationCardProps): JSX.Element { + const session = useAnnotationSession(props.annotation); const snapshot = useSyncExternalStore( session.subscribe, session.getSnapshot, session.getSnapshot, ); + return ( + + ); +} + +function AnnotationCardView({ + annotation, + editing, + active, + transclusion, + onEdit, + onCancel, + onSave, + onPlanDelete, + onDelete, + onOpen, + readFile, + owners, + onMoveOwner, + onRecolor, + showLocation = true, + showSource = false, + needsAttention, + conflict, + editingElsewhere, +}: Omit & { + readonly onEdit: (() => void) | null; + readonly needsAttention: boolean; + readonly conflict: boolean; + readonly editingElsewhere: boolean; +}): JSX.Element { + useLocale(); + // The list reveals explicit selection once; remounting a windowed selected card must not jump. + const cardRef = useScrollToEditing(editing); const move = useAnnotationMove(annotation, onMoveOwner); - const needsAttention = - snapshot.status === "unsaved" || Boolean(snapshot.problem ?? snapshot.conflict); const linked = hasPassageAnchor(annotation.target); return ( // Pointer shortcut; keyboard users have explicit Edit and Show in document buttons. @@ -134,7 +172,7 @@ export function AnnotationCard({ showLocation={showLocation} // Without group headings, a card outside its chapter's pages says so itself. outsideChapter={showLocation && annotationOutsideChapter(annotation, owners ?? [])} - draft={needsAttention ? draftLabel(Boolean(snapshot.conflict)) : null} + draft={needsAttention ? draftLabel(conflict) : null} /> @@ -357,7 +395,7 @@ function AnnotationCardActions({ }: { readonly annotation: Annotation; readonly transclusion: AnnotationTransclusionController; - readonly onEdit: () => void; + readonly onEdit: (() => void) | null; readonly onOpen: () => void; readonly resume: boolean; readonly editingElsewhere: boolean; @@ -371,7 +409,7 @@ function AnnotationCardActions({