diff --git a/apps/extension/offscreen.html b/apps/extension/offscreen.html new file mode 100644 index 00000000..840cb5de --- /dev/null +++ b/apps/extension/offscreen.html @@ -0,0 +1,10 @@ + + + + + mdbase Reader + + + + + diff --git a/apps/extension/package.json b/apps/extension/package.json index 127ba2f8..52cc9f70 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@mdbase-dev/ui": "0.1.0-beta.125", + "@mdbase-dev/ui": "file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz", "@mdbase-reader/connect": "workspace:*", "@mdbase-reader/core": "workspace:*", "@mdbase-reader/platform": "workspace:*", diff --git a/apps/extension/scripts/extension-manifest.d.mts b/apps/extension/scripts/extension-manifest.d.mts index c471890b..8e00dfa0 100644 --- a/apps/extension/scripts/extension-manifest.d.mts +++ b/apps/extension/scripts/extension-manifest.d.mts @@ -8,3 +8,4 @@ export function manifestVersion(version?: string): { version_name?: string; }; export function extensionManifest(environment: ExtensionEnvironment): Record; +export const storeExtensionId: string; diff --git a/apps/extension/scripts/extension-manifest.mjs b/apps/extension/scripts/extension-manifest.mjs index 43dc9309..83d3629c 100644 --- a/apps/extension/scripts/extension-manifest.mjs +++ b/apps/extension/scripts/extension-manifest.mjs @@ -2,18 +2,42 @@ import { readFileSync } from "node:fs"; import { readerDeploymentFor } from "../../reader/scripts/deployment-environment.mjs"; +/** The Chrome Web Store build's ID; its origin is what Connect registers for the extension. */ +export const storeExtensionId = "kimdfjefhbfgfecconmaiaaindjccidp"; + +/** + * The SDK's log service for each environment, which the extension reaches from its pages. + * Production has no SDK release trust yet, so it has no log origin to declare. + */ +const sdkLogOrigins = Object.freeze({ + lab: "https://mdbase-next-log-lab-20261005.callumalpass.workers.dev", +}); + +const chromeExtensionId = /^[a-p]{32}$/u; + /** * The extension targets exactly one mdbase environment, chosen at build time with - * `MDBASE_ENV` (lab by default), like Reader's own deployments. + * `MDBASE_ENV` (lab by default), like Reader's own deployments. A LAB build may also + * declare an unpacked build's ID (`MDBASE_EXTENSION_DEV_ID`, from chrome://extensions) + * so that build can register with LAB; other targets only declare the Web Store ID. */ export function extensionEnvironment(environment = process.env) { const { target, deployment } = readerDeploymentFor(environment); + const devId = environment.MDBASE_EXTENSION_DEV_ID; + if (devId && (target !== "lab" || !chromeExtensionId.test(devId))) { + throw new Error("MDBASE_EXTENSION_DEV_ID must be a Chrome extension ID, for LAB builds only."); + } return { target, label: target === "production" ? "" : target.toUpperCase(), connectUrl: deployment.connectUrl, + logUrl: sdkLogOrigins[target] ?? null, loopbackUrl: deployment.loopbackUrl, readerOrigin: deployment.origin, + extensionOrigins: [ + storeExtensionId, + ...(devId && devId !== storeExtensionId ? [devId] : []), + ].map((id) => `chrome-extension://${id}`), }; } @@ -39,7 +63,6 @@ const icons = Object.fromEntries( export function extensionManifest(environment) { const suffix = environment.label ? ` (${environment.label})` : ""; - const loopback = new URL(environment.loopbackUrl); return { manifest_version: 3, name: `mdbase Reader${suffix}`, @@ -48,18 +71,26 @@ export function extensionManifest(environment) { homepage_url: `${environment.readerOrigin}/`, icons, minimum_chrome_version: "123", - permissions: ["activeTab", "scripting", "storage", "contextMenus", "sidePanel"], - // The Connect API, and HTTPS pages: the side panel follows the active tab and saved - // pages are marked, which needs to read each page without a click on the toolbar. - host_permissions: [`${environment.connectUrl}/*`, "https://*/*"], - optional_host_permissions: [`${loopback.protocol}//${loopback.hostname}/*`], + permissions: ["activeTab", "scripting", "storage", "contextMenus", "sidePanel", "offscreen"], + // The mdbase control plane and log service the SDK talks to from the extension's + // pages, and HTTPS pages: the side panel follows the active tab and saved pages are + // marked, which needs to read each page without a click on the toolbar. + host_permissions: [ + `${environment.connectUrl}/*`, + ...(environment.logUrl ? [`${environment.logUrl}/*`] : []), + "https://*/*", + ], + // The SDK's native runtime is WebAssembly. + content_security_policy: { + extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", + }, action: { default_title: `Save to mdbase Reader${suffix}`, default_icon: { 16: icons["16"], 32: icons["32"] }, }, // One panel per window; it follows the window's active tab. side_panel: { default_path: "capture.html" }, - // Classic, not a module: it loads page-status.js with importScripts() on demand. + // Classic, with no SDK: the SDK runtime lives in the extension's pages. background: { service_worker: "background.js" }, options_ui: { page: "options.html", open_in_tab: true }, commands: { diff --git a/apps/extension/scripts/write-mdbase-manifest.mjs b/apps/extension/scripts/write-mdbase-manifest.mjs index 0444e858..f958d594 100644 --- a/apps/extension/scripts/write-mdbase-manifest.mjs +++ b/apps/extension/scripts/write-mdbase-manifest.mjs @@ -8,16 +8,18 @@ import { buildReaderManifest } from "../../reader/scripts/reader-manifest.mjs"; const root = resolve(import.meta.dirname, ".."); const reader = await buildReaderManifest(); -const shared = { ...reader }; -delete shared.homepage; -delete shared.icon; -delete shared.redirect_uris; +const environment = extensionEnvironment(); +const sdkEnvironment = environment.target === "lab" ? "lab" : "production"; +// A web app manifest whose pages are the extension's: Connect registers the extension's +// exact chrome-extension origins under installation_origins for this environment. const manifest = { - ...shared, - distribution: "portable", + ...reader, id: "dev.mdbase.reader.extension", name: "mdbase Reader browser extension", - project_url: `${extensionEnvironment().readerOrigin}/`, + homepage: `${environment.readerOrigin}/`, + icon: `${environment.readerOrigin}/favicon.svg`, + redirect_uris: [`${environment.readerOrigin}/`], + installation_origins: { [sdkEnvironment]: { "app-runtime": environment.extensionOrigins } }, }; const target = resolve(root, "src/generated/mdbase-app.json"); await mkdir(resolve(target, ".."), { recursive: true }); diff --git a/apps/extension/src/CaptureApp.test.tsx b/apps/extension/src/CaptureApp.test.tsx index 20952ffb..20253198 100644 --- a/apps/extension/src/CaptureApp.test.tsx +++ b/apps/extension/src/CaptureApp.test.tsx @@ -8,7 +8,7 @@ import type { PanelTab } from "./PanelTabs.js"; function markup(changes: Partial = {}, tab?: PanelTab): string { const controller = { - snapshot: { status: "unselected", connections: [] }, + snapshot: { status: "signed_out", collections: [] }, capture: { kind: "html", pageTitle: "[test] Article", @@ -52,8 +52,8 @@ it("asks a new user only to connect, with editable metadata and no status noise" it("picks among connected collections with Reader's select", () => { const html = markup({ snapshot: { - status: "unselected", - connections: [{ collectionId: "c1", displayName: "[test] Papers" }], + status: "choosing", + collections: [{ collectionId: "c1", displayName: "[test] Papers" }], } as unknown as ExtensionCaptureController["snapshot"], }); expect(html).toContain("Save to collection"); @@ -67,7 +67,7 @@ it("links the brand to the Reader library on the selected collection", () => { snapshot: { status: "ready", collectionId: "c1", - connections: [{ collectionId: "c1", displayName: "[test] Papers" }], + collections: [{ collectionId: "c1", displayName: "[test] Papers" }], } as unknown as ExtensionCaptureController["snapshot"], }); expect(html).toMatch( @@ -144,7 +144,7 @@ it("names the connected collection in the header, with its controls behind it", snapshot: { status: "ready", collectionId: "c1", - connections: [{ collectionId: "c1", displayName: "[test] Papers" }], + collections: [{ collectionId: "c1", displayName: "[test] Papers" }], } as unknown as ExtensionCaptureController["snapshot"], }); expect(html).toMatch( @@ -165,25 +165,26 @@ it("shows one status line at a time, with problems taking precedence", () => { expect(failing).toContain("offline"); expect(failing).not.toContain("Highlight saved."); }); -it.each(["unavailable", "start_failed", "blocked"])( - "offers connection retry, not reapproval, for %s", - (status) => { - const html = markup({ - snapshot: { - status, - collectionId: "c1", - connections: [{ collectionId: "c1", displayName: "Papers" }], - problem: { message: "Temporarily blocked" }, - } as unknown as ExtensionCaptureController["snapshot"], - problem: "Request timed out", - problemKind: "save", - saveAttempted: true, - }); - expect(html).toContain("Retry connection"); - expect(html).not.toContain("approve access again"); - expect(html).not.toContain("Review access"); - }, -); +it("offers the SDK's recovery, not reapproval, when the collection has a problem", () => { + const html = markup({ + snapshot: { + status: "problem", + collectionId: "c1", + collections: [{ collectionId: "c1", displayName: "Papers" }], + problem: { + title: "Can't reach mdbase", + message: "Check your connection.", + action: "retry", + actionLabel: "Try again", + }, + } as unknown as ExtensionCaptureController["snapshot"], + problem: "Request timed out", + problemKind: "save", + saveAttempted: true, + }); + expect(html).toContain("Try again"); + expect(html).not.toContain("Review access"); +}); it("does not display stale write progress after a successful save", () => { const html = markup({ status: "saved", @@ -350,7 +351,7 @@ it("asks for a title from the save action when the draft has none", () => { snapshot: { status: "ready", collectionId: "c1", - connections: [{ collectionId: "c1", displayName: "[test] Papers" }], + collections: [{ collectionId: "c1", displayName: "[test] Papers" }], } as unknown as ExtensionCaptureController["snapshot"], draft: { title: " ", @@ -459,7 +460,10 @@ it("titles the page as it will be saved, not as the tab names it", () => { }); it("says why a selected passage cannot be saved yet", () => { const html = markup({ - snapshot: { status: "unselected", connections: [] }, + snapshot: { + status: "signed_out", + collections: [], + } as unknown as ExtensionCaptureController["snapshot"], capture: { kind: "html", pageTitle: "[test] Article", @@ -467,6 +471,6 @@ it("says why a selected passage cannot be saved yet", () => { selection: { exact: "a passage" }, } as ExtensionCaptureController["capture"], }); - expect(html).toContain("Connect a collection before saving."); + expect(html).toContain("Sign in to mdbase before saving."); expect(html).toMatch(/class="swatch swatch-yellow[^"]*"[^>]*disabled=""/u); }); diff --git a/apps/extension/src/CaptureApp.tsx b/apps/extension/src/CaptureApp.tsx index 36c2f155..78b78a05 100644 --- a/apps/extension/src/CaptureApp.tsx +++ b/apps/extension/src/CaptureApp.tsx @@ -42,7 +42,7 @@ export function CaptureApp({
; -/** Connection states the user resolves by approving access again in mdbase Connect. */ -const needsApproval = new Set(["authorization_required"]); -const needsRetry = new Set(["unavailable", "blocked", "start_failed"]); - -function showAuthorization(connected: boolean, status: string): boolean { - return (!connected && !needsRetry.has(status)) || needsApproval.has(status); -} - export function ConnectionPanel({ compact = false, expanded = false, @@ -42,8 +26,8 @@ export function ConnectionPanel({ readonly expanded?: boolean; readonly onDone?: () => void; }): React.JSX.Element | null { - const { snapshot, deviceCode } = props.controller; - const collapsible = compact && snapshot.status === "ready" && !deviceCode; + const { snapshot } = props.controller; + const collapsible = compact && snapshot.status === "ready"; if (collapsible && !expanded) { return null; } @@ -74,120 +58,170 @@ interface ConnectionPanelProps { /** The selected collection's name, once one is connected and chosen. */ export function collectionName(controller: ConnectionControls): string | null { - const { snapshot, deviceCode } = controller; - if (snapshot.status !== "ready" || deviceCode) { + const { snapshot } = controller; + if (snapshot.status !== "ready") { return null; } return ( - snapshot.connections.find((connection) => connection.collectionId === snapshot.collectionId) + snapshot.collections.find((collection) => collection.collectionId === snapshot.collectionId) ?.displayName ?? "your collection" ); } -function ConnectionControlsPanel({ +/** The collections this extension may use, once signed in. */ +function CollectionChoice({ controller: c, - label = "Save to collection", - intro = "Choose the mdbase collection to save into. Nothing is saved until you press Save.", - done, -}: ConnectionPanelProps & { readonly done?: React.ReactNode }): React.JSX.Element { + label, +}: { + readonly controller: ConnectionControls; + readonly label: string; +}): React.JSX.Element { const { snapshot, busy } = c; - const selected = "collectionId" in snapshot ? snapshot.collectionId : ""; - const connected = snapshot.connections.length > 0; - const target = environment.label ? ` ${environment.label}` : ""; return ( -
- {done} - {connected ? ( + <> + + {label} + + ({ - value: connection.collectionId, - label: connection.displayName, - }))} - onChange={(id) => c.select(id)} - /> - - ) : ( + ); + case "choosing": + return snapshot.collections.length ? null : ( <> -

Connect a collection

+

Choose a collection

{intro}

+ {primary("Choose a collection", () => c.connect(true))} - )} - {showAuthorization(connected, snapshot.status) ? ( + ); + case "approval": + return ( <> - {connected ?

This collection needs you to approve access again.

: null} - +

This collection needs you to approve mdbase Reader for it.

+ {primary("Approve access", () => c.connect())} - ) : null} - {needsRetry.has(snapshot.status) ? ( + ); + case "setup": + return ( <> -

{connectionUnavailableMessage(snapshot)}

- +

Prepare this collection

+

Reader needs its source and annotation definitions in this collection.

+ {primary("Set up collection", c.applySetup)} - ) : null} - {snapshot.status === "setup_review_required" ? ( + ); + case "unchecked": + return ( <> -

Prepare this collection

-

Reader needs these source and annotation definitions:

-
    - {snapshot.update.typePacks.map((pack) => ( -
  • - {pack.name}: {pack.currentVersion ?? "not installed"} → {pack.desiredVersion} -
  • - ))} -
- +

This collection hasn’t been checked yet. Check it before saving.

+ {primary("Check again", c.retry)} + ); + case "problem": + return ; + default: + return null; + } +} + +function ProblemScreen({ + problem, + controller: c, +}: { + readonly problem: ConnectionView["problem"]; + readonly controller: ConnectionControls; +}): React.JSX.Element { + return ( +
+

{problem?.title ?? "This collection can’t be used right now"}

+

{problem?.message}

+ {problem?.action && problem.actionLabel ? ( + ) : null} - {c.directAccess ? ( - - ) : null} - {c.deviceCode ? ( -

- Confirm {c.deviceCode} in mdbase Connect. Return here after approving. -

- ) : null} +
+ ); +} + +function ConnectionControlsPanel({ + controller: c, + label = "Save to collection", + intro = "Choose the mdbase collection to save into. Nothing is saved until you press Save.", + done, +}: ConnectionPanelProps & { readonly done?: React.ReactNode }): React.JSX.Element { + const { snapshot } = c; + const choose = + snapshot.collections.length > 0 && + !["opening", "waiting", "signed_out", "closed"].includes(snapshot.status); + return ( +
+ {done} + {choose ? : null} +
); } @@ -238,32 +272,49 @@ export function ConnectionProblem({ Your selection and note are kept for this browser session, even if you close the panel.

)} -
- {kind === "save" ? ( - - ) : null} - {kind === "connection" ? ( - - ) : null} - {kind === "save" && needsRetry.has(c.snapshot.status) ? ( - - ) : null} - {kind !== "page" && !needsRetry.has(c.snapshot.status) ? ( - - ) : null} -
+
); } +function ProblemActions({ + controller: c, + kind, +}: { + readonly controller: ExtensionCaptureController; + readonly kind: NonNullable; +}): React.JSX.Element { + // The SDK's own problem names its one way out; Review access would not help. + const sdkProblem = c.snapshot.status === "problem" ? c.snapshot.problem : null; + return ( +
+ {kind === "save" ? ( + + ) : null} + {kind === "page" ? null : sdkProblem?.action && sdkProblem.actionLabel ? ( + + ) : kind === "connection" ? ( + + ) : null} + {kind !== "page" && !sdkProblem ? ( + + ) : null} +
+ ); +} + function problemTitle( c: ExtensionCaptureController, kind: NonNullable, diff --git a/apps/extension/src/DirectAccessPanel.test.tsx b/apps/extension/src/DirectAccessPanel.test.tsx deleted file mode 100644 index 558cbe26..00000000 --- a/apps/extension/src/DirectAccessPanel.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// @vitest-environment happy-dom -/* eslint-disable @typescript-eslint/require-await -- async act flushes React updates */ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, vi } from "vitest"; - -import { DirectAccessPanel, localConnectorPermission } from "./DirectAccessPanel.js"; - -import type { ReaderDirectAccessController } from "@mdbase-reader/connect"; - -it("requires explicit host approval followed by a user-gesture SDK request, preserving relay on denial", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - const permission = vi.fn(() => Promise.resolve(true)); - vi.stubGlobal("chrome", { - permissions: { contains: () => Promise.resolve(false), request: permission }, - }); - const snapshot = { - authority: "connector", - route: "relay", - status: "permission_required", - } as const; - const request = vi.fn(() => Promise.resolve({ ok: true, value: "denied", diagnostics: [] })); - const disable = vi.fn(); - const onUnavailable = vi.fn(() => Promise.resolve()); - const controller = { - disable, - getSnapshot: () => snapshot, - subscribe: () => () => undefined, - request, - check: vi.fn(), - } as unknown as ReaderDirectAccessController; - const host = document.createElement("div"); - const root = createRoot(host); - try { - await act(async () => { - root.render( - , - ); - }); - expect(request).not.toHaveBeenCalled(); - expect(permission).not.toHaveBeenCalled(); - expect(host.textContent).toContain("Allow local connector access"); - await act(async () => { - host.querySelector("button")!.click(); - }); - expect(permission).toHaveBeenCalledWith(localConnectorPermission()); - expect(localConnectorPermission()).toEqual({ origins: ["http://127.0.0.1/*"] }); - expect(request).not.toHaveBeenCalled(); - expect(host.textContent).toContain("Connect directly"); - await act(async () => { - host.querySelector("button")!.click(); - expect(request).toHaveBeenCalledOnce(); // synchronous in the click, not a later effect - }); - expect(host.textContent).toContain("The relay is still available"); - expect(disable).toHaveBeenCalledOnce(); - expect(onUnavailable).toHaveBeenCalledOnce(); - } finally { - await act(async () => { - root.unmount(); - }); - vi.unstubAllGlobals(); - } -}); diff --git a/apps/extension/src/DirectAccessPanel.tsx b/apps/extension/src/DirectAccessPanel.tsx deleted file mode 100644 index c7816b52..00000000 --- a/apps/extension/src/DirectAccessPanel.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; - -import { environment } from "./environment.js"; - -import type { ReaderDirectAccessController } from "@mdbase-reader/connect"; - -export function localConnectorPermission(): { origins: string[] } { - const url = new URL(environment.loopbackUrl); - return { origins: [`${url.protocol}//${url.hostname}/*`] }; -} - -/** Host access and Chrome's local-network permission are separate, explicit approvals. */ -export function DirectAccessPanel({ - controller, - busy, - onUnavailable, -}: { - readonly controller: ReaderDirectAccessController; - readonly busy: boolean; - readonly onUnavailable: () => Promise; -}): React.JSX.Element | null { - const subscribe = useCallback( - (listener: () => void) => controller.subscribe(listener), - [controller], - ); - const snapshot = useSyncExternalStore(subscribe, controller.getSnapshot, controller.getSnapshot); - const [granted, setGranted] = useState(false); - const [working, setWorking] = useState(false); - const [problem, setProblem] = useState(null); - useEffect(() => { - if (controller.getSnapshot()?.authority !== "connector") { - return; - } - let active = true; - void chrome.permissions - .contains(localConnectorPermission()) - .then((allowed) => { - if (active) { - setGranted(allowed); - } - if (active && allowed) { - void controller.check().catch(() => undefined); - } - }) - .catch(() => undefined); - return () => { - active = false; - }; - }, [controller]); - - if (snapshot?.authority !== "connector") { - return null; - } - const request = (): void => { - setWorking(true); - setProblem(null); - // Call the relevant permission API synchronously in the user gesture. - const operation = granted - ? controller.request().then(async (outcome) => { - if (!outcome.ok || outcome.value !== "available") { - controller.disable(); - await onUnavailable(); - setProblem( - "Local access is not available. Check Chrome's local-network permission and that mdbase Connect is running. The relay is still available.", - ); - } - }) - : chrome.permissions.request(localConnectorPermission()).then((allowed) => { - setGranted(allowed); - setProblem( - allowed - ? "Connector permission allowed. Click Connect directly to finish local-network approval." - : "Local access was not granted. You can continue using the relay.", - ); - }); - void operation - .catch(async () => { - controller.disable(); - await onUnavailable(); - setProblem("Could not connect locally. You can continue using the relay."); - }) - .finally(() => setWorking(false)); - }; - return ( -
-

- {snapshot.status === "available" - ? "Direct local connection available." - : snapshot.status === "disabled" - ? "Using the cloud relay. Direct access could not be enabled; check connector compatibility and browser permissions before retrying." - : "Using the cloud relay. Connect directly for faster access on this computer."} -

- {snapshot.status !== "available" ? ( - - ) : null} - {problem ?

{problem}

: null} -
- ); -} diff --git a/apps/extension/src/ExtensionPages.test.tsx b/apps/extension/src/ExtensionPages.test.tsx index 5a80af1d..b90cbc9c 100644 --- a/apps/extension/src/ExtensionPages.test.tsx +++ b/apps/extension/src/ExtensionPages.test.tsx @@ -18,7 +18,7 @@ it("gathers collection, site access, shortcuts and version on the settings page" stubManifest(); const html = renderToStaticMarkup(); expect(html).toContain("Collection"); - expect(html).toContain("Connect a collection"); + expect(html).toContain("Opening mdbase"); expect(html).toContain("Pages you visit"); expect(html).not.toContain("Mark pages I’ve saved"); expect(html).toContain("Keyboard shortcuts"); diff --git a/apps/extension/src/OptionsApp.tsx b/apps/extension/src/OptionsApp.tsx index c7d1ec6a..4efd0a54 100644 --- a/apps/extension/src/OptionsApp.tsx +++ b/apps/extension/src/OptionsApp.tsx @@ -14,7 +14,7 @@ import { useCollectionConnection } from "./use-collection-connection.js"; export function OptionsApp(): React.JSX.Element { const connection = useCollectionConnection(); const snapshot = connection.snapshot; - const collectionId = "collectionId" in snapshot ? snapshot.collectionId : null; + const collectionId = snapshot.status === "ready" ? snapshot.collectionId : null; useEffect(() => { document.title = "mdbase Reader settings"; }, []); diff --git a/apps/extension/src/SaveBar.tsx b/apps/extension/src/SaveBar.tsx index 2c9fb159..ad721d5c 100644 --- a/apps/extension/src/SaveBar.tsx +++ b/apps/extension/src/SaveBar.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { readerSourceUrl } from "./capture-model.js"; -import { connectionUnavailableMessage } from "./connection-status.js"; +import { connectionUnavailableMessage } from "./connection-view.js"; import type { ExtensionCaptureController } from "./capture-controller.js"; @@ -19,10 +19,8 @@ export function saveBlocker(c: ExtensionCaptureController): string | null { return null; } if (c.snapshot.status !== "ready") { - // While Connect starts or checks setup there is nothing for the reader to do yet. - return ["not_started", "starting", "checking_setup", "destroyed"].includes(c.snapshot.status) - ? null - : connectionUnavailableMessage(c.snapshot); + // While mdbase starts or checks the collection there is nothing for the reader to do yet. + return c.snapshot.status === "opening" ? null : connectionUnavailableMessage(c.snapshot); } return c.draft.title.trim() ? null : "Give the source a title under Note to save it."; } diff --git a/apps/extension/src/WelcomeApp.tsx b/apps/extension/src/WelcomeApp.tsx index 23916827..db6f12ee 100644 --- a/apps/extension/src/WelcomeApp.tsx +++ b/apps/extension/src/WelcomeApp.tsx @@ -19,7 +19,7 @@ export function WelcomeApp(): React.JSX.Element { }, []); return (
- +

mdbase Reader is installed

diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 97881e8c..55d651b4 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -1,5 +1,6 @@ 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 { forgetTab } from "./tab-cleanup.js"; @@ -59,6 +60,25 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { } }); +// Extension pages ask for the SDK host here: the worker owns the offscreen document. +chrome.runtime.onMessage.addListener((message: unknown, sender, sendResponse) => { + const fromExtension = sender.id === chrome.runtime.id && !sender.tab; + if ( + !fromExtension || + typeof message !== "object" || + message === null || + !("type" in message) || + message.type !== ensureHostMessage + ) { + return false; + } + ensureOffscreenDocument().then( + () => sendResponse({ ok: true }), + (reason: unknown) => sendResponse({ error: String(reason) }), + ); + return true; +}); + chrome.tabs.onUpdated.addListener((tabId, change, tab) => { if (change.status === "complete") { void updatePageStatus(tabId, tab.url, loadPageStatusConnect); diff --git a/apps/extension/src/capture-controller.ts b/apps/extension/src/capture-controller.ts index 99ec2bcc..f7152cb6 100644 --- a/apps/extension/src/capture-controller.ts +++ b/apps/extension/src/capture-controller.ts @@ -1,11 +1,11 @@ import type { CitationPreview } from "./capture-citation.js"; +import type { ConnectionView } from "./connection-view.js"; import type { CaptureIntent } from "./messages.js"; import type { ProjectionReport, QuoteOutcome } from "./page-annotations.js"; import type { PageCapture } from "./page-capture.js"; import type { CaptureDraft } from "./save-capture.js"; import type { ProblemKind } from "./use-action-lock.js"; import type { SourceNoteController } from "./use-source-note.js"; -import type { ReaderConnectSnapshot, ReaderDirectAccessController } from "@mdbase-reader/connect"; import type { Annotation, AnnotationDeletionPlan, @@ -22,7 +22,7 @@ export interface PageHighlights { readonly outcomes: ReadonlyMap; } export interface ExtensionCaptureController { - readonly snapshot: ReaderConnectSnapshot; + readonly snapshot: ConnectionView; readonly capture: PageCapture | null; readonly draft: CaptureDraft; readonly setDraft: Dispatch>; @@ -35,8 +35,6 @@ export interface ExtensionCaptureController { readonly annotations: readonly Annotation[]; readonly citation: CitationPreview | null; readonly citationPending: boolean; - readonly deviceCode: string | null; - readonly directAccess: ReaderDirectAccessController | null; readonly projection: PageHighlights | null; readonly progress: SourceImportProgress | null; readonly busy: boolean; diff --git a/apps/extension/src/capture-model.ts b/apps/extension/src/capture-model.ts index f122bc0c..e272f77c 100644 --- a/apps/extension/src/capture-model.ts +++ b/apps/extension/src/capture-model.ts @@ -1,3 +1,4 @@ +import { describeError } from "@mdbase-reader/connect/native"; import { normalizedSourceUrl, sameSourceUrl, type SourceSummary } from "@mdbase-reader/core"; import { environment } from "./environment.js"; @@ -68,6 +69,13 @@ export function problemMessage(reason: unknown): string { if (!(reason instanceof Error)) { return String(reason); } + // SDK errors carry codes; people see the SDK's description, never the code. + if (reason.name === "MdbaseError") { + const described = describeError(reason); + if (described) { + return described.message; + } + } const messages = [reason.message]; let cause: unknown = reason.cause; while (cause instanceof Error && messages.length < 4) { diff --git a/apps/extension/src/connect-session.test.ts b/apps/extension/src/connect-session.test.ts new file mode 100644 index 00000000..ebe88666 --- /dev/null +++ b/apps/extension/src/connect-session.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "vitest"; + +import { extensionDeclaration } from "./sdk-declaration.js"; + +it("declares the extension's own origin, which its manifest lists for LAB", () => { + const declaration = extensionDeclaration("kimdfjefhbfgfecconmaiaaindjccidp"); + expect(declaration).toMatchObject({ + origin: "chrome-extension://kimdfjefhbfgfecconmaiaaindjccidp", + environment: "lab", + installationKind: "app-runtime", + }); + const { manifest } = declaration; + expect(manifest).not.toHaveProperty("distribution", "portable"); + expect(manifest).toMatchObject({ + installation_origins: { lab: { "app-runtime": [declaration.origin] } }, + }); +}); diff --git a/apps/extension/src/connect-session.ts b/apps/extension/src/connect-session.ts index b7300c24..8d5dbff0 100644 --- a/apps/extension/src/connect-session.ts +++ b/apps/extension/src/connect-session.ts @@ -1,58 +1,60 @@ -import { - ReaderPortableApplicationSession, - type MdbaseAppManifest, - type ReaderConnectSnapshot, -} from "@mdbase-reader/connect"; - import { chromeStorageMirror } from "./chrome-storage.js"; -import { lastCollectionKey } from "./collection-memory.js"; -import { environment } from "./environment.js"; -import manifest from "./generated/mdbase-app.json"; +import { requestSdkHost, sdkHostGone } from "./offscreen-document.js"; +import { createSdkClient, type RemoteApplication, type SdkClient } from "./sdk-client.js"; +import { broadcastLink } from "./sdk-rpc.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. + */ export interface ExtensionSession { - readonly session: ReaderPortableApplicationSession; - /** Reader's mutation journal, kept beside the grants so any extension context can recover it. */ + readonly application: RemoteApplication; + /** Reader's repositories over the host's current READY connection, or null. */ + 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; } -export async function createExtensionSession(): Promise { - const storage = await chromeStorageMirror(); - const session = new ReaderPortableApplicationSession({ - serverUrl: environment.connectUrl, - loopbackUrl: environment.loopbackUrl, - manifest: manifest as MdbaseAppManifest, - storage, - // Interactive capture should not wait the SDK's ten-minute file-index default. - timeouts: { watchStartMs: 60_000, fileIndexMs: 30_000, uploadMs: 120_000 }, +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 { - session, + ...client, + dispose() { + client.dispose(); + link.close(); + }, + }; +}; + +export async function createExtensionSession( + create: CreateClient = createClient, +): Promise { + const [storage, client] = await Promise.all([chromeStorageMirror(), create()]); + return { + application: client.application, + connectedCollection: () => client.connectedCollection(), + hostAvailable: () => client.hostAvailable(), 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(), }; } - -/** Selects the collection chosen last time, if it is still authorized. */ -export async function restoreCollection( - session: ReaderPortableApplicationSession, -): Promise { - const snapshot = session.getSnapshot(); - if (snapshot.status !== "unselected") { - return snapshot; - } - const { [lastCollectionKey]: remembered } = await chrome.storage.local.get(lastCollectionKey); - if ( - typeof remembered === "string" && - snapshot.connections.some((connection) => connection.collectionId === remembered) - ) { - session.select(remembered); - } - return session.getSnapshot(); -} diff --git a/apps/extension/src/connection-status.test.ts b/apps/extension/src/connection-status.test.ts deleted file mode 100644 index eecfbfce..00000000 --- a/apps/extension/src/connection-status.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "vitest"; - -import { connectionUnavailableMessage } from "./connection-status.js"; - -import type { ReaderConnectSnapshot } from "@mdbase-reader/connect"; - -it.each([ - ["unavailable", "temporarily unavailable"], - ["authorization_required", "Approve access"], - ["setup_review_required", "Review and apply"], - ["starting", "getting ready"], - ["unselected", "Choose a collection"], -])("explains %s without calling an existing collection unconnected", (status, expected) => { - const snapshot = { - status, - collectionId: "c", - connections: [{ collectionId: "c", displayName: "Local" }], - } as unknown as ReaderConnectSnapshot; - expect(connectionUnavailableMessage(snapshot)).toContain(expected); - expect(connectionUnavailableMessage(snapshot)).not.toContain( - "Connect a collection before saving", - ); -}); - -it("preserves the actionable blocked reason", () => { - const snapshot = { - status: "blocked", - problem: { message: "Collection requires an update" }, - } as ReaderConnectSnapshot; - expect(connectionUnavailableMessage(snapshot)).toBe("Collection requires an update"); -}); diff --git a/apps/extension/src/connection-status.ts b/apps/extension/src/connection-status.ts deleted file mode 100644 index 6e10e577..00000000 --- a/apps/extension/src/connection-status.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ReaderConnectSnapshot } from "@mdbase-reader/connect"; - -/** A selected collection is not necessarily available for requests. */ -export function connectionUnavailableMessage(snapshot: ReaderConnectSnapshot): string { - switch (snapshot.status) { - case "authorization_required": - return "Approve access to this collection before saving."; - case "setup_review_required": - return "Review and apply this collection's setup before saving."; - case "blocked": - return snapshot.problem.message; - case "start_failed": - return `Could not connect: ${snapshot.problem.message}. Retry the connection; your draft is kept.`; - case "unavailable": - return "This collection is temporarily unavailable. Check mdbase Connect and retry the connection; your draft is kept."; - case "unselected": - return snapshot.connections.length - ? "Choose a collection before saving." - : "Connect a collection before saving."; - default: - return "mdbase Connect is still getting ready. Try again in a moment."; - } -} diff --git a/apps/extension/src/connection-view.ts b/apps/extension/src/connection-view.ts new file mode 100644 index 00000000..9e4636e5 --- /dev/null +++ b/apps/extension/src/connection-view.ts @@ -0,0 +1,225 @@ +import { describeError, type MdbaseApplicationSnapshot } from "@mdbase-reader/connect/native"; + +/** + * What the extension's pages show for the SDK's application snapshot. The SDK owns + * sign-in, selection, setup and readiness; this only names the screen to show. + */ +export type ConnectionStatus = + /** Starting, opening or checking the collection. */ + | "opening" + | "signed_out" + /** Waiting for sign-in or approval in the mdbase tab. */ + | "waiting" + /** Signed in without a usable selected collection. */ + | "choosing" + /** The selected collection needs this extension approved for it. */ + | "approval" + | "setup" + /** Open but not yet checked; offer the SDK's readiness check. */ + | "unchecked" + /** A settled problem with the SDK's copy and one action. */ + | "problem" + | "ready" + | "closed"; + +/** The SDK's suggested way out of a settled problem. */ +export type RecoveryAction = + | "sign_in" + | "approve" + | "set_up" + | "choose_collection" + | "update_app" + | "retry" + | "reload" + | "check_again" + | null; + +export interface ConnectionProblemView { + readonly title: string; + readonly message: string; + readonly action: RecoveryAction; + readonly actionLabel: string | null; + readonly updateUrl: string | null; +} + +export interface ConnectionCollection { + readonly collectionId: string; + readonly displayName: string; +} + +export interface ConnectionView { + readonly status: ConnectionStatus; + readonly collections: readonly ConnectionCollection[]; + /** The SDK's selected collection, usable or not. */ + readonly collectionId: string | null; + readonly signedIn: boolean; + readonly identity: string | null; + readonly problem: ConnectionProblemView | null; +} + +export const startingView: ConnectionView = { + status: "opening", + collections: [], + collectionId: null, + signedIn: false, + identity: null, + problem: null, +}; + +const retired: ConnectionProblemView = { + title: "Choose the collection again", + message: "The connection to this collection has changed. Choose the collection again.", + action: "choose_collection", + actionLabel: "Choose a collection", + updateUrl: null, +}; + +/** The SDK's description of a settled problem, as one extension control. */ +function sdkProblem( + snapshot: MdbaseApplicationSnapshot, + readiness: boolean, +): ConnectionProblemView | null { + const described = describeError(snapshot); + if (!described) { + return null; + } + const { action } = described; + // A readiness problem is re-checked; restarting would not change it. + const mapped: RecoveryAction = + readiness && (action === "retry" || action === "check_connection" || action === "reload") + ? "check_again" + : action === "check_connection" + ? "retry" + : action === "sign_in" || + action === "approve" || + action === "set_up" || + action === "choose_collection" || + action === "update_app" || + action === "retry" || + action === "reload" + ? action + : null; + return { + title: described.title, + message: described.message, + action: mapped, + actionLabel: mapped === "check_again" ? "Check again" : described.actionLabel, + updateUrl: described.updateUrl, + }; +} + +function identityLabel(signIn: MdbaseApplicationSnapshot["signIn"]): string | null { + if (signIn?.state !== "paired") { + return null; + } + if (signIn.accountEmail) { + return `Signed in as ${signIn.accountEmail}`; + } + return signIn.accountProvider === "github" && signIn.accountLogin + ? `Signed in as ${signIn.accountLogin} on GitHub` + : null; +} + +type Screen = Pick; + +const plain = (status: ConnectionStatus): Screen => ({ status, problem: null }); + +/** Screens that win over everything else: closed, waiting in the mdbase tab, a problem. */ +function interruption(snapshot: MdbaseApplicationSnapshot): Screen | null { + const { status, readiness, selection } = snapshot; + if (status === "closed") { + return plain("closed"); + } + const signInProblem = sdkProblem(snapshot, false); + if (signInProblem?.action === "sign_in") { + return { status: "problem", problem: signInProblem }; + } + if (status === "authorizing" || selection.state === "approving") { + return plain("waiting"); + } + const readinessProblem = readiness.problem !== null || readiness.assessment?.problem != null; + if (status === "blocked" || readinessProblem || readiness.assessment?.status === "incompatible") { + const problem = sdkProblem(snapshot, readinessProblem && status !== "blocked"); + return problem ? { status: "problem", problem } : plain("opening"); + } + return null; +} + +/** The step the reader (or the SDK) has to take next. */ +function nextStep(snapshot: MdbaseApplicationSnapshot): ConnectionStatus | null { + const { status, readiness, selection } = snapshot; + if (selection.state === "approval_required") { + return "approval"; + } + if (status === "setup_required" || readiness.assessment?.status === "setup_required") { + return "setup"; + } + if (["not_started", "starting", "opening"].includes(status) || readiness.checking) { + return "opening"; + } + if (status === "signed_out") { + return "signed_out"; + } + return status === "unselected" || selection.state === "invalid" ? "choosing" : null; +} + +function screen(snapshot: MdbaseApplicationSnapshot, usable: boolean): Screen { + const interrupted = interruption(snapshot); + if (interrupted) { + return interrupted; + } + const step = nextStep(snapshot); + if (step) { + return plain(step); + } + if (usable) { + return plain("ready"); + } + return snapshot.readiness.assessment === null + ? plain("unchecked") + : { status: "problem", problem: retired }; +} + +/** + * `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: MdbaseApplicationSnapshot, + usable: boolean, + hostAvailable = true, +): ConnectionView { + const signedIn = snapshot.status !== "closed" && snapshot.signIn?.state === "paired"; + return { + ...(hostAvailable ? screen(snapshot, usable) : plain("opening")), + collections: snapshot.status === "closed" ? [] : snapshot.collections, + collectionId: snapshot.status === "closed" ? null : snapshot.selectedCollectionId, + signedIn, + identity: identityLabel(snapshot.signIn), + }; +} + +/** Why saving cannot happen yet, for the save bar and a refused save. */ +export function connectionUnavailableMessage(view: ConnectionView): string { + switch (view.status) { + case "problem": + return view.problem?.message ?? "This collection can't be used right now."; + case "signed_out": + return "Sign in to mdbase before saving."; + case "waiting": + return "Finish signing in or approving in the mdbase tab, then save."; + case "choosing": + return "Choose a collection before saving."; + case "approval": + return "Approve access to this collection before saving."; + case "setup": + return "Set up this collection for Reader before saving."; + case "unchecked": + return "Check this collection before saving."; + case "closed": + return "Reader has signed out. Sign in again before saving; your draft is kept."; + default: + return "mdbase is still getting ready. Try again in a moment."; + } +} diff --git a/apps/extension/src/environment.ts b/apps/extension/src/environment.ts index 07fe5ee9..2d408cbf 100644 --- a/apps/extension/src/environment.ts +++ b/apps/extension/src/environment.ts @@ -3,8 +3,12 @@ export interface ExtensionEnvironment { /** Shown in the panel header; empty for production. */ readonly label: string; readonly connectUrl: string; + /** The SDK's log service, when this environment has SDK release trust. */ + readonly logUrl: string | null; readonly loopbackUrl: string; readonly readerOrigin: string; + /** The extension origins this build declares to mdbase (Web Store, and a LAB dev ID). */ + readonly extensionOrigins: readonly string[]; } declare const __READER_EXTENSION_ENVIRONMENT__: ExtensionEnvironment; diff --git a/apps/extension/src/extension-manifest.test.ts b/apps/extension/src/extension-manifest.test.ts index 81ea0cae..9f177109 100644 --- a/apps/extension/src/extension-manifest.test.ts +++ b/apps/extension/src/extension-manifest.test.ts @@ -19,14 +19,36 @@ describe("per-environment extension manifest", () => { connectUrl: "https://connect-lab.mdbase.dev", readerOrigin: "https://lab.mdbase-reader.pages.dev", }); + // The SDK runs in the extension's pages: it reaches the CP and LAB's log service. expect(manifest["host_permissions"]).toEqual([ "https://connect-lab.mdbase.dev/*", + "https://mdbase-next-log-lab-20261005.callumalpass.workers.dev/*", "https://*/*", ]); - expect(manifest["optional_host_permissions"]).toEqual(["http://127.0.0.1/*"]); + expect(manifest["optional_host_permissions"]).toBeUndefined(); + // The SDK's one owner is an offscreen document. + expect(manifest["permissions"]).toContain("offscreen"); + expect(manifest["content_security_policy"]).toEqual({ + extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", + }); + expect(environment.extensionOrigins).toEqual([ + "chrome-extension://kimdfjefhbfgfecconmaiaaindjccidp", + ]); expect(manifest["name"]).toBe("mdbase Reader (LAB)"); }); + it("declares an unpacked build's ID for LAB only", () => { + const devId = "abcdefghijklmnopabcdefghijklmnop"; + expect(extensionEnvironment({ MDBASE_EXTENSION_DEV_ID: devId }).extensionOrigins).toEqual([ + "chrome-extension://kimdfjefhbfgfecconmaiaaindjccidp", + `chrome-extension://${devId}`, + ]); + expect(() => + extensionEnvironment({ MDBASE_ENV: "production", MDBASE_EXTENSION_DEV_ID: devId }), + ).toThrow(/LAB builds only/u); + expect(() => extensionEnvironment({ MDBASE_EXTENSION_DEV_ID: "not-an-id" })).toThrow(); + }); + it("targets production without an environment label", () => { const environment = extensionEnvironment({ MDBASE_ENV: "production" }); expect(environment.label).toBe(""); diff --git a/apps/extension/src/generated/mdbase-app.json b/apps/extension/src/generated/mdbase-app.json index 6a9b23cf..a3337fac 100644 --- a/apps/extension/src/generated/mdbase-app.json +++ b/apps/extension/src/generated/mdbase-app.json @@ -2,6 +2,9 @@ "manifest_version": 1, "id": "dev.mdbase.reader.extension", "name": "mdbase Reader browser extension", + "homepage": "https://lab.mdbase-reader.pages.dev/", + "icon": "https://lab.mdbase-reader.pages.dev/favicon.svg", + "redirect_uris": ["https://lab.mdbase-reader.pages.dev/"], "requirements": { "access": "full_collection", "contracts": [ @@ -25,7 +28,10 @@ "contract_version": 2, "required": ["collection.read", "records.create", "records.edit", "records.delete"] }, - "files": { "required": ["list", "read", "add"], "scope": { "kind": "collection" } } + "files": { + "required": ["list", "read", "add", "replace", "move", "delete"], + "scope": { "kind": "collection" } + } }, "provisions": { "type_packs": [ @@ -186,6 +192,7 @@ } ] }, - "distribution": "portable", - "project_url": "https://reader.mdbase.dev/" + "installation_origins": { + "lab": { "app-runtime": ["chrome-extension://kimdfjefhbfgfecconmaiaaindjccidp"] } + } } diff --git a/apps/extension/src/offscreen-document.ts b/apps/extension/src/offscreen-document.ts new file mode 100644 index 00000000..312dcdc4 --- /dev/null +++ b/apps/extension/src/offscreen-document.ts @@ -0,0 +1,62 @@ +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 + * creates it on demand; pages ask the service worker, which owns chrome.offscreen. + */ +export const offscreenPath = "offscreen.html"; +export const ensureHostMessage = "mdbase-reader/ensure-sdk-host"; + +let creating: Promise | null = null; + +/** In the service worker: creates the offscreen host unless it is already open. */ +export async function ensureOffscreenDocument(): Promise { + const url = chrome.runtime.getURL(offscreenPath); + const open = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [url], + }); + if (open.length) { + return; + } + creating ??= chrome.offscreen + .createDocument({ + url: offscreenPath, + reasons: [chrome.offscreen.Reason.WORKERS], + justification: "Runs the mdbase SDK's worker for saving pages and showing saved highlights.", + }) + .catch((reason: unknown) => { + // Another caller created it first. + if (!/single offscreen document/iu.test(String(reason))) { + throw reason; + } + }) + .finally(() => { + creating = null; + }); + await creating; +} + +/** In an extension page: asks the service worker for the offscreen host. */ +export async function requestSdkHost(): Promise { + const reply: unknown = await chrome.runtime.sendMessage({ type: ensureHostMessage }); + if (typeof reply === "object" && reply !== null && "error" in reply) { + 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 new file mode 100644 index 00000000..537abc88 --- /dev/null +++ b/apps/extension/src/offscreen.ts @@ -0,0 +1,30 @@ +import { MdbaseConnect } from "@mdbase-reader/connect/native"; + +import { credentiallessFetch } from "./credentialless-fetch.js"; +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"; + +/* + * 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. + */ +globalThis.fetch = credentiallessFetch(globalThis.fetch.bind(globalThis)); + +async function start(): Promise { + await holdSdkHostLock(); + const hostId = crypto.randomUUID(); + let host: SdkHost | null = null; + const sdk = new MdbaseConnect(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(); diff --git a/apps/extension/src/page-status-connect.ts b/apps/extension/src/page-status-connect.ts index e411099c..2377f7a2 100644 --- a/apps/extension/src/page-status-connect.ts +++ b/apps/extension/src/page-status-connect.ts @@ -1,20 +1,15 @@ import { sourceForUrl } from "./capture-model.js"; -import { - createExtensionSession, - rememberedCollection, - restoreCollection, -} from "./connect-session.js"; +import { ensureOffscreenDocument, sdkHostGone } 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 type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; /** - * The part of page status that needs Connect. The service worker loads it (with the - * Connect SDK) 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 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. */ export interface PageStatusConnect { /** The selected collection, or null when none is connected. */ @@ -29,30 +24,64 @@ export interface PageStatusConnect { drawPageQuotes(tabId: number, quotes: readonly PageQuote[], url: string): Promise; } -let session: Promise | null = null; +let client: Promise | null = null; +/** How long a page load waits for the host to open the selected collection. */ +const openTimeoutMs = 10_000; -/** One Connect session per service-worker lifetime, sharing the panel's stored grants. */ +/** Waits until the host's selected collection is READY, or settles without one. */ +function readyCollection(current: SdkClient): 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(); + if (collection || !opening()) { + resolve(collection); + return true; + } + return false; + }; + if (check()) { + return; + } + const unsubscribe = application.subscribe(() => { + if (check()) { + finish(); + } + }); + const timeout = setTimeout(() => { + resolve(null); + finish(); + }, openTimeoutMs); + function finish(): void { + clearTimeout(timeout); + unsubscribe(); + } + }); +} + +/** + * The selected collection, from the extension's one SDK host (offscreen.ts), started on + * demand. The worker never starts sign-in or changes the selection. + */ async function selectedCollection(): Promise { - session ??= (async () => { - const created = (await createExtensionSession()).session; - const outcome = await created.start(); - if (!outcome.ok) { - return null; + 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 restoreCollection(created); - return created; - })().catch(() => null); - const current = await session; - if (!current) { - session = null; + return await readyCollection(current); + } catch { + client = null; return null; } - const remembered = await rememberedCollection(); - const snapshot = current.getSnapshot(); - if (remembered && (!("collectionId" in snapshot) || snapshot.collectionId !== remembered)) { - current.select(remembered); - } - return current.connectedCollection(); } async function savedSourceUrls(collection: ReaderConnectedCollection): Promise { diff --git a/apps/extension/src/save-capture.test.ts b/apps/extension/src/save-capture.test.ts index 2c679858..47654c62 100644 --- a/apps/extension/src/save-capture.test.ts +++ b/apps/extension/src/save-capture.test.ts @@ -34,7 +34,6 @@ describe("explicit source and highlight saves", () => { ); const reopened = new CaptureWriter(f.storage); await reopened.save({ - session: f.session, collection: f.collection, capture, draft: { ...draft, highlight: false }, @@ -62,7 +61,6 @@ describe("explicit source and highlight saves", () => { await expect(f.save({ highlight: false })).rejects.toThrow("Response lost"); const reopened = new CaptureWriter(f.storage); const recovered = await reopened.save({ - session: f.session, collection: f.collection, capture, draft: { ...draft, highlight: false }, @@ -103,7 +101,6 @@ describe("saved source and annotation retries", () => { const reopened = new CaptureWriter(f.storage); await expect( reopened.save({ - session: f.session, collection: f.collection, capture, draft, @@ -154,7 +151,6 @@ describe("saved source and annotation retries", () => { await expect(f.save()).rejects.toThrow("Outcome unknown"); const reopened = new CaptureWriter(f.storage); const result = await reopened.save({ - session: f.session, collection: f.collection, capture, draft, diff --git a/apps/extension/src/save-capture.ts b/apps/extension/src/save-capture.ts index 2b69ff4c..e2eb6c9b 100644 --- a/apps/extension/src/save-capture.ts +++ b/apps/extension/src/save-capture.ts @@ -36,10 +36,7 @@ import { pageText } from "./page-annotations.js"; import { rememberSavedUrls } from "./saved-url-index.js"; import type { PageCapture, PdfCapture, SelectedWebCapture } from "./page-capture.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; export const highlightColors = ["yellow", "green", "blue", "pink", "purple"] as const; export type HighlightColor = (typeof highlightColors)[number]; @@ -61,7 +58,6 @@ export interface SavedCapture { readonly notices: readonly string[]; } export interface SaveCaptureInput { - readonly session: ReaderPortableApplicationSession; readonly collection: ReaderConnectedCollection; readonly capture: PageCapture; readonly draft: CaptureDraft; @@ -100,12 +96,8 @@ export class CaptureWriter { } async save(input: SaveCaptureInput): Promise { - const { session, collection, capture, draft, onSource } = input; - const outcomes = await session.recoverPendingMutations(); - const failure = outcomes.find((outcome) => !outcome.ok); - if (failure) { - throw new Error(failure.problem.message); - } + // The native collection re-adopts this session's unconfirmed writes when it opens. + const { collection, capture, draft, onSource } = input; const notices: string[] = []; const recoveryKey = importRecoveryKey(input); // An import that was interrupted may have committed after the panel looked. diff --git a/apps/extension/src/sdk-client.ts b/apps/extension/src/sdk-client.ts new file mode 100644 index 00000000..57dc8394 --- /dev/null +++ b/apps/extension/src/sdk-client.ts @@ -0,0 +1,347 @@ +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 { MdbaseApplication, MdbaseApplicationSnapshot } from "@mdbase-reader/connect/native"; + +/** The parts of the SDK application the extension's pages use. */ +export type RemoteApplication = Pick< + MdbaseApplication, + | "getSnapshot" + | "subscribe" + | "start" + | "authorize" + | "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 const hostRestartedMessage = + "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: MdbaseApplicationSnapshot | 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(): MdbaseApplicationSnapshot { + if (!this.#snapshot) { + throw new Error("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("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"), + authorize: command("authorize"), + 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("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-declaration.ts b/apps/extension/src/sdk-declaration.ts new file mode 100644 index 00000000..79567312 --- /dev/null +++ b/apps/extension/src/sdk-declaration.ts @@ -0,0 +1,27 @@ +import { environment } from "./environment.js"; +import manifest from "./generated/mdbase-app.json"; + +import type { MdbaseApplicationDeclaration } from "@mdbase-reader/connect/native"; + +/** This build's environment as the SDK names it; staging has no SDK environment. */ +export function sdkEnvironment(): MdbaseApplicationDeclaration["environment"] { + return environment.target === "lab" ? "lab" : "production"; +} + +/** + * The extension's declaration: its own origin (the Web Store ID, or an unpacked build's ID + * when that build declares it) and the extension manifest that lists that origin under + * `installation_origins`. + */ +export function extensionDeclaration( + runtimeId: string = chrome.runtime.id, +): MdbaseApplicationDeclaration { + return { + id: manifest.id, + name: manifest.name, + origin: `chrome-extension://${runtimeId}`, + environment: sdkEnvironment(), + installationKind: "app-runtime", + manifest, + }; +} diff --git a/apps/extension/src/sdk-host-source.ts b/apps/extension/src/sdk-host-source.ts new file mode 100644 index 00000000..5d19579b --- /dev/null +++ b/apps/extension/src/sdk-host-source.ts @@ -0,0 +1,56 @@ +import { + nativeReaderCollection, + type MdbaseApplicationConnection, + type MdbaseApplicationEntry, + type MdbaseApplicationSnapshot, + type NativeReaderCollection, +} from "@mdbase-reader/connect/native"; + +import type { SdkHostSource } from "./sdk-host.js"; + +function readyEvidence(snapshot: MdbaseApplicationSnapshot): boolean { + const { readiness } = snapshot; + return ( + snapshot.status === "ready" && + !!snapshot.selectedCollectionId && + !readiness.checking && + readiness.problem === null && + readiness.assessment?.status === "ready" && + readiness.assessment.problem === null + ); +} + +/** The SDK's current READY connection for the selected collection, or null. */ +export function readyConnection( + sdk: MdbaseApplicationEntry, + snapshot: MdbaseApplicationSnapshot, +): MdbaseApplicationConnection | null { + const selected = snapshot.selectedCollectionId; + if (!selected || !readyEvidence(snapshot)) { + return null; + } + const connection = sdk.connection(selected); + 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: MdbaseApplicationEntry, hostId: string): SdkHostSource { + const application = sdk.application(); + let opened: { readonly key: string; readonly collection: NativeReaderCollection } | null = null; + return { + application, + currentCollection() { + const connection = readyConnection(sdk, application.getSnapshot()); + const key = connection ? `${hostId}:${connection.id}:${connection.generation}` : null; + if (opened?.key !== key) { + opened?.collection.dispose(); + opened = connection && key ? { key, collection: nativeReaderCollection(connection) } : null; + } + return opened; + }, + }; +} diff --git a/apps/extension/src/sdk-host.ts b/apps/extension/src/sdk-host.ts new file mode 100644 index 00000000..51afe539 --- /dev/null +++ b/apps/extension/src/sdk-host.ts @@ -0,0 +1,185 @@ +import { + collectionCommands, + decodeArgs, + failedRequestId, + isApplicationCommand, + isClientMessage, + isCollectionCommand, + serializeError, + type CallTarget, + type ClientMessage, + type CollectionShape, + type HostMessage, + type SdkLink, +} from "./sdk-rpc.js"; + +import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; +import type { MdbaseApplication } from "@mdbase-reader/connect/native"; + +export interface SdkHostSource { + readonly application: MdbaseApplication; + /** 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(["authorize", "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("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: 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 }); + } 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 new file mode 100644 index 00000000..02841421 --- /dev/null +++ b/apps/extension/src/sdk-rpc-lifetime.test.ts @@ -0,0 +1,99 @@ +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["entry"]["application"]>; + } +> { + // 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.entry.application(); + 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.authorize({ 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.authorize().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 new file mode 100644 index 00000000..af43fdd0 --- /dev/null +++ b/apps/extension/src/sdk-rpc.test.ts @@ -0,0 +1,231 @@ +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 } from "./sdk-rpc.js"; +import { memoryBus } from "./testing/memory-bus.js"; +import { applicationFixture } from "./testing/sdk-fixture.js"; + +import type { MdbaseApplicationSnapshot } from "@mdbase-reader/connect/native"; + +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.entry.application(), + 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.authorize(); + 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.authorize(); + 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.authorize().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.entry.application(), + 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); + }); +}); diff --git a/apps/extension/src/sdk-rpc.ts b/apps/extension/src/sdk-rpc.ts new file mode 100644 index 00000000..4cff8bc3 --- /dev/null +++ b/apps/extension/src/sdk-rpc.ts @@ -0,0 +1,355 @@ +import type { MdbaseApplicationSnapshot } from "@mdbase-reader/connect/native"; + +type MdbaseApplicationReadinessSnapshot = MdbaseApplicationSnapshot["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", + "authorize", + "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"], +} 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 + ); +} + +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 MdbaseApplicationSnapshot { + 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/sign-in-recovery.test.tsx b/apps/extension/src/sign-in-recovery.test.tsx new file mode 100644 index 00000000..88e5a6bf --- /dev/null +++ b/apps/extension/src/sign-in-recovery.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { describeError } from "@mdbase-reader/connect/native"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { connectionView } from "./connection-view.js"; +import { ConnectionPanel, type ConnectionControls } from "./ConnectionPanel.js"; +import { applicationFixture } from "./testing/sdk-fixture.js"; + +let host: HTMLDivElement; +let root: ReturnType; +const owned: { close(): void }[] = []; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + host.remove(); + for (const controls of owned.splice(0)) { + controls.close(); + } + vi.unstubAllGlobals(); +}); + +async function renderAndApprove( + h: ReturnType, + copy: ReturnType, +): Promise { + owned.push(h.controls); + const application = h.entry.application(); + const scope = new AbortController(); + const approve = async (): Promise => { + await application.approve(undefined, { signal: scope.signal }); + }; + const controller: ConnectionControls = { + snapshot: connectionView(application.getSnapshot(), false), + busy: true, + connect: approve, + retry: approve, + applySetup: async () => { + await application.configure({ signal: scope.signal }); + }, + select: (id) => { + void application.select(id, { signal: scope.signal }); + }, + }; + await act(async () => { + root.render(); + await Promise.resolve(); + }); + if (copy) { + expect(host.textContent).toContain(copy.title); + expect(host.textContent).toContain(copy.message); + } + const label = copy?.actionLabel ?? "Sign in"; + const button = [...host.querySelectorAll("button")].find((item) => item.textContent === label); + if (!button) { + throw new Error("Missing SDK sign-in action"); + } + expect(button.disabled).toBe(false); + await act(async () => { + button.click(); + await Promise.resolve(); + }); + expect(h.controls.observedActions().filter((call) => call.action === "approve")).toHaveLength(1); + expect(h.controls.observedActions().filter((call) => call.action === "authorize")).toHaveLength( + 0, + ); + scope.abort(); +} + +for (const status of ["blocked", "authorizing"] as const) { + for (const code of ["refused", "expired"] as const) { + it(`${status}/${code} keeps SDK copy and an enabled approval gesture`, async () => { + const h = applicationFixture({ status, problem: code }); + const copy = describeError(h.entry.application().getSnapshot()); + if (!copy?.actionLabel) { + throw new Error("Missing public SDK sign-in description"); + } + await renderAndApprove(h, copy); + }); + } +} + +it("authorizing without a problem still offers enabled Sign in", async () => { + await renderAndApprove(applicationFixture({ status: "authorizing" }), null); +}); diff --git a/apps/extension/src/testing/memory-bus.ts b/apps/extension/src/testing/memory-bus.ts new file mode 100644 index 00000000..af33b17d --- /dev/null +++ b/apps/extension/src/testing/memory-bus.ts @@ -0,0 +1,35 @@ +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/testing/save-capture-fixture.ts b/apps/extension/src/testing/save-capture-fixture.ts index 0432bde4..d89bf215 100644 --- a/apps/extension/src/testing/save-capture-fixture.ts +++ b/apps/extension/src/testing/save-capture-fixture.ts @@ -18,10 +18,7 @@ import { } from "../save-capture.js"; import type { PageCapture, SelectedWebCapture } from "../page-capture.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; export const capture: SelectedWebCapture = { kind: "html", @@ -117,9 +114,6 @@ export function fixture() { listForSource: vi.fn(() => Promise.resolve([...annotations.values()])), }, } as unknown as ReaderConnectedCollection; - const session = { - recoverPendingMutations: vi.fn(() => Promise.resolve([])), - } as unknown as ReaderPortableApplicationSession; const onSource = vi.fn(); const journal = new Map(); const storage = { @@ -134,7 +128,6 @@ export function fixture() { extra: Partial = {}, ): Promise => writer.save({ - session, collection, capture: page, draft: { ...draft, ...changes }, @@ -146,7 +139,6 @@ export function fixture() { storage, save, collection, - session, commit, create, read, diff --git a/apps/extension/src/testing/sdk-fixture.ts b/apps/extension/src/testing/sdk-fixture.ts new file mode 100644 index 00000000..0f7eefba --- /dev/null +++ b/apps/extension/src/testing/sdk-fixture.ts @@ -0,0 +1,52 @@ +import { createApplicationFixture } from "@mdbase-reader/connect/testing"; + +import manifest from "../generated/mdbase-app.json"; + +import type { MdbaseApplicationSnapshot } from "@mdbase-reader/connect/native"; + +const facet = { checking: false, assessment: null, problem: null }; + +/** A complete signed-out public snapshot, for the SDK's own application fixture. */ +export const signedOutSnapshot: MdbaseApplicationSnapshot = { + status: "signed_out", + signIn: null, + collections: [], + selectedCollectionId: null, + problem: null, + timing: null, + selection: { + state: "none", + requested: { collectionId: null, source: "none", displayName: null, approved: false }, + usable: null, + problem: null, + }, + readiness: { + ...facet, + facets: { + capabilities: facet, + contracts: facet, + configuration: facet, + files: facet, + people: facet, + notifications: facet, + }, + }, +}; + +/** The SDK's public application fixture: test metadata only, never runtime evidence. */ +export function applicationFixture( + initial: Partial = {}, +): ReturnType { + return createApplicationFixture({ + declaration: { + id: "reader.extension.fixture", + name: manifest.name, + origin: "chrome-extension://kimdfjefhbfgfecconmaiaaindjccidp", + environment: "lab", + installationKind: "app-runtime", + manifest, + }, + connections: [], + initialSnapshot: { ...signedOutSnapshot, ...initial }, + }); +} diff --git a/apps/extension/src/use-collection-connection.ts b/apps/extension/src/use-collection-connection.ts index f103b800..48f72dc2 100644 --- a/apps/extension/src/use-collection-connection.ts +++ b/apps/extension/src/use-collection-connection.ts @@ -27,8 +27,6 @@ export function useCollectionConnection(): CollectionConnection { }, [open, release, setProblem]); return { snapshot: connection.snapshot, - deviceCode: connection.deviceCode, - directAccess: connection.directAccess, busy: lock.busy, problem: lock.problem, connect: connection.connect, diff --git a/apps/extension/src/use-connect.ts b/apps/extension/src/use-connect.ts index c5fd795e..f8276528 100644 --- a/apps/extension/src/use-connect.ts +++ b/apps/extension/src/use-connect.ts @@ -1,127 +1,182 @@ -import { - connectProblemMessage, - type ReaderConnectSnapshot, - type ReaderDirectAccessController, - type ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { problemMessage } from "./capture-model.js"; import { createExtensionSession, rememberCollection, - restoreCollection, type ExtensionSession, } from "./connect-session.js"; +import { connectionView, startingView, type ConnectionView } from "./connection-view.js"; import type { ActionLock } from "./use-action-lock.js"; -const notStarted: ReaderConnectSnapshot = { status: "not_started", connections: [] }; - export interface ConnectLink { readonly extension: ExtensionSession | null; - readonly snapshot: ReaderConnectSnapshot; - readonly deviceCode: string | null; - readonly directAccess: ReaderDirectAccessController | null; - /** Creates the session (grants load from extension storage) and restores the last collection. */ - readonly open: () => Promise; + readonly snapshot: ConnectionView; + /** Creates this page's SDK entry and starts it (sign-in state loads from its storage). */ + readonly open: () => Promise; + /** Sign in, or with `choose`, approve this extension for another collection. */ readonly connect: (choose?: boolean) => Promise; + /** The settled problem's action (or the readiness check), as the SDK suggests it. */ readonly retry: () => Promise; readonly applySetup: () => Promise; readonly select: (id: string) => void; } +type Run = (signal: AbortSignal) => Promise; + +/** The recovery for the current screen; null when there is nothing to do here. */ +function recovery( + extension: ExtensionSession, + view: ConnectionView, +): ((signal: AbortSignal) => Promise) | null { + const { application } = extension; + if (view.status === "unchecked") { + return (signal) => application.assess({ signal }); + } + switch (view.problem?.action) { + case "sign_in": + // A refused or expired sign-in starts again through approve (the SDK's sign_in). + return (signal) => application.approve(undefined, { signal }); + case "approve": + return (signal) => application.approve(undefined, { signal }); + case "set_up": + return (signal) => application.configure({ signal }); + case "choose_collection": + return (signal) => application.chooseAnother({ signal }); + case "check_again": + return (signal) => application.assess({ signal }); + case "retry": + return (signal) => application.start({ signal }); + case "update_app": { + const url = view.problem.updateUrl; + return url ? () => chrome.tabs.create({ url }) : () => Promise.resolve(location.reload()); + } + case "reload": + return () => Promise.resolve(location.reload()); + default: + return null; + } +} + export function useConnect(lock: ActionLock): ConnectLink { const [extension, setExtension] = useState(null); - const [snapshot, setSnapshot] = useState(notStarted); - const [deviceCode, setDeviceCode] = useState(null); - const { run, setProblem, locked } = lock; + const [snapshot, setSnapshot] = useState(startingView); + const { run, locked, setProblem } = lock; + const [scope] = useState(() => new AbortController()); + const currentSession = useRef(null); + const signInAction = useRef(0); useEffect(() => { if (!extension) { return; } - const { session } = extension; - const unsubscribe = session.subscribe(() => setSnapshot(session.getSnapshot())); + const { application } = extension; + currentSession.current = extension; + const publish = (): void => { + const usable = extension.connectedCollection() !== null; + const view = connectionView(application.getSnapshot(), usable, extension.hostAvailable()); + if (view.status === "ready" && view.collectionId) { + rememberCollection(view.collectionId); + } + setSnapshot(view); + }; + // The SDK follows page visibility itself (followVisibility). + const unsubscribe = application.subscribe(publish); + publish(); return () => { unsubscribe(); - session.destroy(); + currentSession.current = null; + extension.dispose(); }; }, [extension]); + useEffect(() => () => scope.abort(), [scope]); - const open = useCallback(async (): Promise => { + const open = useCallback(async (): Promise => { const created = await createExtensionSession(); setExtension(created); - const problem = connectProblemMessage(await created.session.start()); - if (problem) { - setProblem(problem); + const { application } = created; + if (application.getSnapshot().status === "not_started") { + await application.start({ signal: scope.signal }); } - setSnapshot(await restoreCollection(created.session)); - return created.session; - }, [setProblem]); + return created; + }, [scope]); - const connect = (choose = false): Promise => + const act = (action: (session: ExtensionSession) => Run | null): Promise => run(async () => { - const session = extension?.session; - if (!session) { - throw new Error("mdbase Connect is still starting. Try again in a moment."); - } - const selected = "collectionId" in session.getSnapshot(); - try { - const outcome = await session.authorize(choose || !selected ? "choose" : "selected", { - timeoutMs: 10 * 60_000, - onDeviceCode: ({ userCode }) => setDeviceCode(userCode), - openVerification: async ({ verificationUriComplete }) => { - await chrome.tabs.create({ url: verificationUriComplete }); - }, - }); - setProblem(connectProblemMessage(outcome)); - } finally { - setDeviceCode(null); + if (!extension) { + throw new Error("mdbase is still starting. Try again in a moment."); } - const next = session.getSnapshot(); - if ("collectionId" in next) { - rememberCollection(next.collectionId); + const next = action(extension); + if (next) { + await next(scope.signal); } - setSnapshot(next); - }); - const retry = (): Promise => - run(async () => { - // SDK start() is idempotent once a base session exists: it returns the - // current unavailable snapshot rather than reconnecting. Recreate the - // session from persisted grants; the effect disposes the old transport. - await open(); }); + const approveSignIn = (): Promise => { + if (!extension || scope.signal.aborted) { + return Promise.resolve(); + } + const ordinal = ++signInAction.current; + const current = (): boolean => + !scope.signal.aborted && + currentSession.current === extension && + ordinal === signInAction.current; + // Invoke from the gesture even while authorizing; renewal belongs to the SDK. + return extension.application.approve(undefined, { signal: scope.signal }).then( + () => { + if (current()) { + setProblem(null); + } + }, + (reason: unknown) => { + if (current()) { + setProblem(problemMessage(reason)); + } + }, + ); + }; + const connect = (choose = false): Promise => + choose && snapshot.signedIn + ? act( + ({ application }) => + (signal) => + application.approve(null, { signal }), + ) + : snapshot.status === "waiting" || snapshot.problem?.action === "sign_in" + ? approveSignIn() + : act( + ({ application }) => + (signal) => + application.approve(undefined, { signal }), + ); + // Without an SDK suggestion, retry starts again (or reopens, if opening failed). + const retry = (): Promise => { + if (!extension) { + return run(async () => { + await open(); + }); + } + if (snapshot.problem?.action === "sign_in") { + return approveSignIn(); + } + return act( + (session) => + recovery(session, snapshot) ?? ((signal) => session.application.start({ signal })), + ); + }; const applySetup = (): Promise => - run(async () => { - const session = extension?.session; - if (session) { - setProblem(connectProblemMessage(await session.applyCollectionSetup())); - } - }); + act( + ({ application }) => + (signal) => + application.configure({ signal }), + ); const select = (id: string): void => { - const session = extension?.session; - if (locked() || !session) { + if (locked() || !extension) { return; } - const outcome = session.select(id); - setProblem(connectProblemMessage(outcome)); - if (outcome.ok) { - rememberCollection(id); - } - }; - const directAccess = - snapshot.status === "ready" - ? (extension?.session.connectedCollection()?.directAccess ?? null) - : null; - return { - extension, - snapshot, - deviceCode, - directAccess, - open, - connect, - retry, - applySetup, - select, + void run(async () => { + await extension.application.select(id, { signal: scope.signal }); + }); }; + return { extension, snapshot, open, connect, retry, applySetup, select }; } diff --git a/apps/extension/src/use-extension-capture.test.tsx b/apps/extension/src/use-extension-capture.test.tsx index 36a5b368..7ec95599 100644 --- a/apps/extension/src/use-extension-capture.test.tsx +++ b/apps/extension/src/use-extension-capture.test.tsx @@ -26,20 +26,50 @@ const mocks = vi.hoisted(() => ({ readSelection: vi.fn(), prepareCitation: vi.fn(), rememberCollection: vi.fn(), - snapshot: { - status: "ready", - collectionId: "test", - connections: [], - info: { displayName: "[test] Library" }, - }, + authorize: vi.fn(), + approve: vi.fn(), + listeners: new Set<() => void>(), })); -const session = { +let current: Record = {}; +/** The SDK's public application snapshot with `selected` READY. */ +function readySnapshot(selected: string): Record { + return { + status: "ready", + signIn: { state: "paired", accountEmail: "test@example.com" }, + collections: [ + { collectionId: "test", displayName: "[test] Library" }, + { collectionId: "other", displayName: "[test] Other" }, + ], + selectedCollectionId: selected, + selection: { state: "selected" }, + readiness: { checking: false, problem: null, assessment: { status: "ready", problem: null } }, + }; +} +function publish(snapshot: Record): void { + current = snapshot; + for (const listener of mocks.listeners) { + listener(); + } +} +const application = { start: mocks.start, select: mocks.select, - getSnapshot: (): typeof mocks.snapshot => mocks.snapshot, - subscribe: (): (() => void) => vi.fn(), - destroy: vi.fn(), - connectedCollection: () => ({ collectionId: "test", annotations: { listForSource: mocks.list } }), + authorize: mocks.authorize, + approve: mocks.approve, + getSnapshot: (): Record => current, + subscribe: (listener: () => void): (() => void) => { + mocks.listeners.add(listener); + return () => mocks.listeners.delete(listener); + }, +}; +// Like the real session: one adapter per READY connection. +const ready = { collectionId: "test", annotations: { listForSource: mocks.list } }; +const extension = { + application, + connectedCollection: () => (current["status"] === "ready" ? ready : null), + hostAvailable: () => true, + journalStorage: {}, + dispose: vi.fn(), }; vi.mock("@mdbase-reader/connect", () => ({ connectProblemMessage: (outcome: { problem?: { message: string } }) => @@ -47,7 +77,6 @@ vi.mock("@mdbase-reader/connect", () => ({ })); vi.mock("./connect-session.js", () => ({ createExtensionSession: mocks.createSession, - restoreCollection: () => Promise.resolve(mocks.snapshot), rememberCollection: mocks.rememberCollection, })); vi.mock("./capture-model.js", () => ({ @@ -106,9 +135,15 @@ beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); chromeFake = fakeChrome(); vi.stubGlobal("chrome", chromeFake.chrome); - mocks.createSession.mockImplementation(() => Promise.resolve({ session, journalStorage: {} })); - mocks.start.mockResolvedValue({ ok: true }); - mocks.select.mockReturnValue({ ok: true }); + current = readySnapshot("test"); + mocks.createSession.mockImplementation(() => Promise.resolve(extension)); + mocks.start.mockResolvedValue(undefined); + mocks.authorize.mockResolvedValue(undefined); + mocks.approve.mockResolvedValue(undefined); + mocks.select.mockImplementation((id: string) => { + publish(readySnapshot(id)); + return Promise.resolve(); + }); mocks.sourceForUrl.mockResolvedValue(null); mocks.list.mockResolvedValue([]); mocks.captureTab.mockResolvedValue(page); @@ -136,18 +171,39 @@ it("does not auto-save on opening or changing the destination, and remembers the await act(async () => { controller.select("other"); }); - expect(mocks.select).toHaveBeenCalledWith("other"); + expect(mocks.select).toHaveBeenCalledWith("other", expect.anything()); expect(mocks.rememberCollection).toHaveBeenCalledWith("other"); expect(mocks.save).not.toHaveBeenCalled(); }); -it("recreates the transport on connection retry instead of restarting a stale session", async () => { +it("offers sign-in again from a refused sign-in, even while the first is still authorizing", async () => { await mount(); - expect(mocks.createSession).toHaveBeenCalledTimes(1); + // The first sign-in is still running, so the panel's action lock is held. + let finish = (): void => undefined; + mocks.approve.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + let first: Promise = Promise.resolve(); + await act(async () => { + first = controller.connect(); + }); + expect(controller.busy).toBe(true); + await act(async () => { + publish({ + ...readySnapshot("test"), + status: "blocked", + signIn: null, + problem: { code: "refused" }, + }); + }); + expect(controller.snapshot.problem?.action).toBe("sign_in"); + expect(JSON.stringify(controller.snapshot)).not.toContain("refused"); await act(async () => { await controller.retry(); }); - expect(mocks.createSession).toHaveBeenCalledTimes(2); - expect(session.destroy).toHaveBeenCalledTimes(1); + expect(mocks.approve).toHaveBeenCalledTimes(2); + expect(mocks.authorize).not.toHaveBeenCalled(); + await act(async () => { + finish(); + await first; + }); }); it("retains a failed draft and releases the action lock for an explicit retry", async () => { await mount(); diff --git a/apps/extension/src/use-extension-capture.ts b/apps/extension/src/use-extension-capture.ts index f4d8c701..0f6af712 100644 --- a/apps/extension/src/use-extension-capture.ts +++ b/apps/extension/src/use-extension-capture.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { prepareCitation, type CitationPreview } from "./capture-citation.js"; import { problemMessage, sourceForUrl } from "./capture-model.js"; -import { connectionUnavailableMessage } from "./connection-status.js"; +import { connectionUnavailableMessage } from "./connection-view.js"; import { rememberColor } from "./drafts.js"; import { annotationQuotes } from "./page-annotations.js"; import { markSavedPage } from "./page-badge.js"; @@ -116,8 +116,9 @@ export function useExtensionCapture( useEffect(() => { const epoch = ++discovery.current; refreshEpoch.current++; - const collection = extension?.session.connectedCollection(); - if (!collection || !pageUrl) { + const collection = extension?.connectedCollection(); + // Only once the panel shows that collection as ready, so the lookup runs once. + if (!collection || !pageUrl || collection.collectionId !== collectionId) { return; } void (async () => { @@ -200,11 +201,9 @@ export function useExtensionCapture( const save = (changes?: Partial): Promise => lock.run(async () => { - const collection = extension?.session.connectedCollection(); - if (!collection || !writer || !extension) { - throw new Error( - connectionUnavailableMessage(extension?.session.getSnapshot() ?? connection.snapshot), - ); + const collection = extension?.connectedCollection(); + if (!collection || !writer) { + throw new Error(connectionUnavailableMessage(connection.snapshot)); } if (!capture) { throw new Error("The page is not ready. Reopen Reader on this page before saving."); @@ -221,7 +220,6 @@ export function useExtensionCapture( setStatus("saving"); try { const result = await writer.save({ - session: extension.session, collection, capture, draft, @@ -280,7 +278,7 @@ export function useExtensionCapture( } async function refreshAnnotations(saved: SourceSummary, render: boolean): Promise { - const collection = extension?.session.connectedCollection(); + const collection = extension?.connectedCollection(); if (!collection || !capture) { setNotice(connectionUnavailableMessage(connection.snapshot)); return; @@ -361,7 +359,7 @@ export function useExtensionCapture( } }; const editing = (): { collection: ReaderConnectedCollection; writer: CaptureWriter } => { - const collection = extension?.session.connectedCollection(); + const collection = extension?.connectedCollection(); if (!collection || !writer) { throw new Error(connectionUnavailableMessage(connection.snapshot)); } @@ -419,7 +417,7 @@ export function useExtensionCapture( const tags = useKnownTags(extension, annotations); const connectedCollection = useCallback( - () => extension?.session.connectedCollection() ?? null, + () => extension?.connectedCollection() ?? null, [extension], ); const note = useSourceNote(connectedCollection, source, setSource); @@ -470,8 +468,6 @@ export function useExtensionCapture( annotations, citation, citationPending, - deviceCode: connection.deviceCode, - directAccess: connection.directAccess, projection, progress, refreshing, diff --git a/apps/extension/src/use-known-tags.ts b/apps/extension/src/use-known-tags.ts index 87b88a9c..1b9e19f4 100644 --- a/apps/extension/src/use-known-tags.ts +++ b/apps/extension/src/use-known-tags.ts @@ -10,7 +10,7 @@ export function useKnownTags( extension: ExtensionSession | null, annotations: readonly Annotation[], ): { readonly knownTags: readonly string[]; readonly loadTags: () => void } { - const collection = extension?.session.connectedCollection() ?? null; + const collection = extension?.connectedCollection() ?? null; const [known, setKnown] = useState<{ readonly collectionId: string; readonly tags: readonly string[]; diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts index 12b274b5..610f0372 100644 --- a/apps/extension/vite.config.ts +++ b/apps/extension/vite.config.ts @@ -21,10 +21,10 @@ function manifest(): Plugin { } /** - * The service worker is two classic scripts: background.js, which runs on every wake, and - * page-status.js with the Connect SDK, which it loads with importScripts() only when a - * page may be saved (see src/page-status-loader.ts). Each is a self-contained IIFE, built - * after the pages so neither shares chunks with them. + * 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). */ function serviceWorker(): Plugin { const scripts = { @@ -47,7 +47,6 @@ function serviceWorker(): Plugin { outDir: "dist", emptyOutDir: false, copyPublicDir: false, - // Connect's lazily loaded modules are inlined: workers cannot import() them. modulePreload: false, rolldownOptions: { input: resolve(import.meta.dirname, entry), @@ -77,6 +76,19 @@ function serviceWorker(): Plugin { export default defineConfig({ plugins: [react(), manifest(), serviceWorker()], + // The SDK starts its own module Worker from a module-relative URL; keep it out of + // dev pre-bundling so that URL still names the SDK's files, and emit ES workers. + optimizeDeps: { exclude: ["@mdbase-dev/sdk"] }, + worker: { + format: "es", + rolldownOptions: { + output: { + entryFileNames: "assets/[name]-[hash].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name]-[hash][extname]", + }, + }, + }, define: { __READER_EXTENSION_ENVIRONMENT__: JSON.stringify(environment) }, build: { target: "es2022", @@ -88,6 +100,7 @@ export default defineConfig({ capture: resolve(import.meta.dirname, "capture.html"), options: resolve(import.meta.dirname, "options.html"), welcome: resolve(import.meta.dirname, "welcome.html"), + offscreen: resolve(import.meta.dirname, "offscreen.html"), }, output: { entryFileNames: "assets/[name]-[hash].js", diff --git a/apps/reader/package.json b/apps/reader/package.json index 7281ea58..a5e3e217 100644 --- a/apps/reader/package.json +++ b/apps/reader/package.json @@ -6,6 +6,7 @@ "scripts": { "icons": "node scripts/write-pwa-icons.mjs", "build": "node scripts/package-zotero.mjs && pnpm manifest && pnpm manifest:validate && vite build", + "build:lab": "VITE_MDBASE_ENV=lab pnpm build", "deploy:dev": "node scripts/deploy-pages.mjs", "deploy:prod": "MDBASE_READER_DEPLOY_TARGET=production node scripts/deploy-pages.mjs", "dev": "node scripts/package-zotero.mjs && pnpm manifest && vite --host 127.0.0.1", @@ -22,7 +23,9 @@ }, "dependencies": { "@citation-js/plugin-csl": "0.8.2", - "@mdbase-dev/ui": "0.1.0-beta.125", + "@mdbase-dev/sdk": "file:../../packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz", + "@mdbase-dev/sdk-legacy-729b5391": "file:../../vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz", + "@mdbase-dev/ui": "file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz", "@mdbase-reader/connect": "workspace:*", "@mdbase-reader/core": "workspace:*", "@mdbase-reader/markdown-editor": "workspace:*", diff --git a/apps/reader/public/.well-known/mdbase-app.json b/apps/reader/public/.well-known/mdbase-app.json index 3b398a28..d45ef447 100644 --- a/apps/reader/public/.well-known/mdbase-app.json +++ b/apps/reader/public/.well-known/mdbase-app.json @@ -28,7 +28,10 @@ "contract_version": 2, "required": ["collection.read", "records.create", "records.edit", "records.delete"] }, - "files": { "required": ["list", "read", "add"], "scope": { "kind": "collection" } } + "files": { + "required": ["list", "read", "add", "replace", "move", "delete"], + "scope": { "kind": "collection" } + } }, "provisions": { "type_packs": [ diff --git a/apps/reader/public/404.html b/apps/reader/public/404.html new file mode 100644 index 00000000..f0802ab9 --- /dev/null +++ b/apps/reader/public/404.html @@ -0,0 +1,89 @@ + + + + + + + Page not found · mdbase reader + + + + +

+

404

+

Page not found

+

There is nothing at this address.

+ Open mdbase reader +
+ + diff --git a/apps/reader/public/_redirects b/apps/reader/public/_redirects new file mode 100644 index 00000000..d4e07ea4 --- /dev/null +++ b/apps/reader/public/_redirects @@ -0,0 +1,8 @@ +# Client routes served by the app shell. 404.html disables the Pages SPA +# fallback, so every client route is listed; other paths get 404.html. +/import / 200 +/import/ / 200 +/import/zotero / 200 +/import/zotero/ / 200 +/import/readwise / 200 +/import/readwise/ / 200 diff --git a/apps/reader/scripts/browser-install-test.mjs b/apps/reader/scripts/browser-install-test.mjs new file mode 100644 index 00000000..056c20ea --- /dev/null +++ b/apps/reader/scripts/browser-install-test.mjs @@ -0,0 +1,69 @@ +// Loopback-only interface preview: installation event is controlled, collection data stays in memory. +import assert from "node:assert/strict"; +import { chromium } from "@playwright/test"; +const base = process.argv[2] ?? "http://127.0.0.1:48334/"; +const origin = new URL(base).origin; +assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(new URL(base).hostname)); +const browser = await chromium.launch(); +const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); +await context.route("**/*", (route) => + new URL(route.request().url()).origin === origin ? route.continue() : route.abort(), +); +const page = await context.newPage(); +try { + await page.goto(`${base}?preview=1`); + await page + .getByRole("button", { name: "Continue reading The Principles of Psychology", exact: true }) + .click(); + await page.getByRole("navigation", { name: "Source views", exact: true }).waitFor(); + await page.evaluate(() => { + const event = new Event("beforeinstallprompt", { cancelable: true }); + Object.assign(event, { + prompt: async () => {}, + userChoice: Promise.resolve({ outcome: "dismissed" }), + }); + globalThis.dispatchEvent(event); + }); + await page.locator(".pwa-install").waitFor({ timeout: 35000 }); + for (const [width, height] of [ + [390, 844], + [320, 568], + ]) { + await page.setViewportSize({ width, height }); + for (const name of ["Read", "Annotations", "Note", "Citation"]) { + await page + .getByRole("navigation", { name: "Source views", exact: true }) + .getByRole("button", { name, exact: true }) + .click(); + const geometry = await page.evaluate(() => { + const prompt = globalThis.document.querySelector(".pwa-install").getBoundingClientRect(); + const nav = globalThis.document + .querySelector(".mobile-source-views") + .getBoundingClientRect(); + return { + gap: nav.top - prompt.bottom, + fits: prompt.left >= 0 && prompt.right <= globalThis.innerWidth, + top: prompt.top, + }; + }); + assert.ok(geometry.gap >= 8, `${width}px ${name}: ${JSON.stringify(geometry)}`); + assert.equal(geometry.fits, true); + assert.ok(geometry.top >= 0); + } + console.log( + `PASS install invitation leaves all four source controls reachable at ${width}x${height}`, + ); + } + await page.setViewportSize({ width: 1280, height: 900 }); + await page.locator(".mobile-source-views").waitFor({ state: "detached" }); + assert.equal( + await page.locator(".pwa-install").evaluate((e) => globalThis.getComputedStyle(e).bottom), + "16px", + ); + await page.getByRole("button", { name: "Not now", exact: true }).click(); + assert.equal(await page.locator(".pwa-install").count(), 0); + console.log("PASS desktop positioning and dismissal"); +} finally { + await context.close(); + await browser.close(); +} diff --git a/apps/reader/scripts/reader-manifest.mjs b/apps/reader/scripts/reader-manifest.mjs index 882da6c6..1004392d 100644 --- a/apps/reader/scripts/reader-manifest.mjs +++ b/apps/reader/scripts/reader-manifest.mjs @@ -121,7 +121,9 @@ export async function buildReaderManifest({ required: ["collection.read", "records.create", "records.edit", "records.delete"], }, files: { - required: ["list", "read", "add"], + // Next accounts approve each record capability together with its file + // actions: editing needs replace and move, deleting needs delete. + required: ["list", "read", "add", "replace", "move", "delete"], scope: { kind: "collection" }, }, }, diff --git a/apps/reader/src/ConnectReader.tsx b/apps/reader/src/ConnectReader.tsx index da85c5a6..a65c5c4b 100644 --- a/apps/reader/src/ConnectReader.tsx +++ b/apps/reader/src/ConnectReader.tsx @@ -1,23 +1,12 @@ import { FeedbackButton, useFeedback } from "@mdbase-dev/ui/feedback"; -import { ConnectLayout, OpeningScreen } from "@mdbase-dev/ui/screens"; +import { ConnectLayout } from "@mdbase-dev/ui/screens"; import { connectProblemMessage, type ReaderConnectSnapshot } from "@mdbase-reader/connect"; -import { createReaderRuntimeServices, createWebPlatform } from "@mdbase-reader/platform"; import { ReaderButton } from "@mdbase-reader/ui"; -import { - lazy, - Suspense, - useEffect, - useMemo, - useState, - useSyncExternalStore, - type JSX, -} from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore, type JSX } from "react"; import { collectionSwitchUrl } from "./collection-switching.js"; import { CollectionSwitchingContext } from "./CollectionPicker.js"; -import { ConnectWorkspaceGateway } from "./connect-workspace.js"; import { readerSession } from "./connect.js"; -import { ConnectedDocument } from "./ConnectedDocument.js"; import { connectionProblemDetail, connectionStatus, @@ -29,18 +18,13 @@ import { import { ConnectionRetry, SelectedAuthorizationAction } from "./ConnectionLayout.js"; import { readerErrorMessage } from "./errors.js"; import { ChevronRightIcon, CollectionIcon, PlusIcon } from "./icons.js"; -import { requestedAnnotation, requestedSourceId } from "./SourceDeepLink.js"; +import { NativeSignIn } from "./NativeSignIn.js"; +import { loadReaderApp, ReaderWorkspace } from "./ReaderWorkspace.js"; import { startReaderStartupTiming } from "./startup-timing.js"; -import type { ReaderApp as ReaderAppComponent } from "./ReaderApp.js"; -const loadReaderApp = (): Promise<{ readonly ReaderApp: typeof ReaderAppComponent }> => - import("./ReaderApp.js"); -const ReaderApp = lazy(async () => ({ default: (await loadReaderApp()).ReaderApp })); - +import type { NativeSignInBinding } from "./native-signin-ui.js"; const subscribe = (listener: () => void): (() => void) => readerSession.subscribe(listener); const snapshot = (): ReaderConnectSnapshot => readerSession.getSnapshot(); -const readerPlatform = createWebPlatform(); -const runtimeServices = createReaderRuntimeServices(readerPlatform.storage); async function startSession(): Promise>> { const finish = startReaderStartupTiming("connection"); @@ -54,7 +38,17 @@ async function startSession(): Promise : ; +} + +function LegacyConnectReader(): JSX.Element { const { reportError } = useFeedback(); const session = useSyncExternalStore(subscribe, snapshot, snapshot); const [error, setError] = useState(null); @@ -143,26 +137,7 @@ export function ConnectReader(): JSX.Element { function OpenedReader({ collectionId }: { readonly collectionId: string }): JSX.Element { const opened = useMemo(() => readerSession.connectedCollection(collectionId), [collectionId]); - const gateway = useMemo( - () => - opened - ? new ConnectWorkspaceGateway( - opened.sources, - opened.annotations, - opened.annotationAssets, - opened.sourceImports, - opened.collectionId, - opened.collectionName, - runtimeServices, - opened.contentSearch, - opened.files, - opened.libraryViews, - opened.bodyRecovery, - ) - : null, - [opened], - ); - if (!opened || !gateway) { + if (!opened) { return ( ); } - return ( - - } - > - readerPlatform.saveFile(name, blob)} - pickSourceFile={() => - readerPlatform.pickFile([ - ".pdf", - ".epub", - ".html", - ".htm", - "application/pdf", - "application/epub+zip", - "text/html", - ]) - } - renderDocument={(source, onSurfaceChange) => ( - - )} - /> - - ); + return ; } interface ConnectionScreenProps { diff --git a/apps/reader/src/ContinueReading.tsx b/apps/reader/src/ContinueReading.tsx index 234e8908..1b9c8e67 100644 --- a/apps/reader/src/ContinueReading.tsx +++ b/apps/reader/src/ContinueReading.tsx @@ -89,6 +89,7 @@ function ContinueReadingRow({ className="continue-reading-item" type="button" aria-label={`Continue reading ${source.title}`} + title={source.title} onClick={() => onOpen(source.id)} > @@ -101,9 +102,9 @@ function ContinueReadingRow({ - ) : ( - {location || "Just started"} - )} + ) : !location ? ( + Just started + ) : null} ); } diff --git a/apps/reader/src/DockviewWorkspace.tsx b/apps/reader/src/DockviewWorkspace.tsx index 44bb0b7c..e0646333 100644 --- a/apps/reader/src/DockviewWorkspace.tsx +++ b/apps/reader/src/DockviewWorkspace.tsx @@ -201,7 +201,7 @@ function ReaderDockTab(props: IDockviewPanelHeaderProps<{ tab?: WorkspaceTab }>)
document.sourceWorkspace.dock.patch(props.api.id, { preview: false })} onKeyDown={(event) => { if (event.key === "Delete" && !side) { diff --git a/apps/reader/src/DocumentContextualToolbar.tsx b/apps/reader/src/DocumentContextualToolbar.tsx index 743297a0..24eca75c 100644 --- a/apps/reader/src/DocumentContextualToolbar.tsx +++ b/apps/reader/src/DocumentContextualToolbar.tsx @@ -10,6 +10,7 @@ import { MoreIcon, NoteIcon, ReadingModeIcon, + TrashIcon, } from "./icons.js"; import { Menu } from "./Menu.js"; import { DocumentStatus } from "./WorkspacePaneSupport.js"; @@ -34,6 +35,8 @@ export interface DocumentContextualToolbarProps { readonly canBookmark: boolean; readonly bookmarking: boolean; readonly sourceExport: SourceExportController; + /** Asks to remove the source, which confirms first; null when the collection cannot. */ + readonly onRemoveSource?: ((source: SourceSummary) => void) | null; readonly onToggleFocus: () => void; readonly onToggleAreaSelection: () => void; readonly onBookmark: () => void; @@ -55,6 +58,7 @@ export function DocumentContextualToolbar({ canBookmark, bookmarking, sourceExport, + onRemoveSource = null, onToggleFocus, onToggleAreaSelection, onBookmark, @@ -121,6 +125,7 @@ export function DocumentContextualToolbar({ {focusMode ? "Leave reading mode" : "Reading mode"} +
); @@ -150,6 +155,23 @@ function ExportItem({ ); } +function RemoveItem({ + source, + onRemoveSource, +}: { + readonly source: SourceSummary; + readonly onRemoveSource: ((source: SourceSummary) => void) | null; +}): JSX.Element | null { + return onRemoveSource ? ( + <> +
+ + + ) : null; +} + function BookmarkItem({ bookmarking, onBookmark, diff --git a/apps/reader/src/DocumentWorkspace.tsx b/apps/reader/src/DocumentWorkspace.tsx index 92f6301f..1b9c4788 100644 --- a/apps/reader/src/DocumentWorkspace.tsx +++ b/apps/reader/src/DocumentWorkspace.tsx @@ -19,6 +19,7 @@ import type { AnnotationComposerController } from "./use-annotation-composer.js" import type { DocumentAttachmentController } from "./use-document-attachment.js"; import type { ReadingResumeState } from "./use-reading-resume.js"; import type { SourceExportController } from "./use-source-export.js"; +import type { SourceRemovalController } from "./use-source-removal.js"; import type { SourceWorkspaceController } from "./use-source-workspace.js"; import type { SourceSummary } from "@mdbase-reader/core"; import type { ReadingSurface } from "@mdbase-reader/reading-surface"; @@ -31,6 +32,8 @@ export interface DocumentWorkspaceProps { readonly decorationProblem: string | null; readonly annotationComposer: AnnotationComposerController; readonly sourceExport: SourceExportController; + /** Offers removing the open source from its document actions when the collection can. */ + readonly sourceRemoval?: SourceRemovalController; readonly renderDocument: ( source: SourceSummary, paneId: WorkspacePaneId, @@ -137,6 +140,7 @@ export function DocumentToolbar({ bookmarking={composer.bookmarking} onBookmark={composer.bookmark} sourceExport={props.sourceExport} + onRemoveSource={props.sourceRemoval?.request ?? null} onToggleFocus={props.onToggleFocus} surfaces={props.surfaces} sessionId={tabId} diff --git a/apps/reader/src/ImportPage.tsx b/apps/reader/src/ImportPage.tsx index b0e447ca..f2a3f11c 100644 --- a/apps/reader/src/ImportPage.tsx +++ b/apps/reader/src/ImportPage.tsx @@ -1,17 +1,16 @@ -import { Wordmark } from "@mdbase-dev/ui/brand"; import { connectProblemMessage, type ReaderConnectSnapshot } from "@mdbase-reader/connect"; import { useEffect, useMemo, useState, useSyncExternalStore, type JSX } from "react"; import { readerSession } from "./connect.js"; import { ImportController } from "./import-controller.js"; -import { importHref } from "./import-navigation.js"; import { ImportDestination } from "./ImportDestination.js"; -import { ImportInput } from "./ImportInput.js"; -import { ImportConfirm, ImportPreview, ImportStatus } from "./ImportReview.js"; -import "./import.css"; +import { ImportPageView, type ImportService } from "./ImportPageView.js"; + const subscribe = (listener: () => void): (() => void) => readerSession.subscribe(listener); const snapshot = (): ReaderConnectSnapshot => readerSession.getSnapshot(); -export function ImportPage({ service }: { service: "home" | "zotero" | "readwise" }): JSX.Element { + +/** Existing production import session; the LAB native route never loads this module. */ +export function ImportPage({ service }: { service: ImportService }): JSX.Element { const session = useSyncExternalStore(subscribe, snapshot, snapshot); const [controller] = useState(() => new ImportController(readerSession)); const state = useSyncExternalStore( @@ -46,71 +45,18 @@ export function ImportPage({ service }: { service: "home" | "zotero" | "readwise }; }, [controller]); return ( -
-
- - Back to library -
- {service !== "home" ? ( - - ) : null} -

- {service === "home" - ? "Bring your library with you." - : `Import from ${service === "zotero" ? "Zotero" : "Readwise Reader"}`} -

-

- Your sources, notes and files, in a collection you control. Preview first. Nothing is - imported until you confirm the destination. -

- {service === "home" ? ( - - ) : ( - <> - - {state.plan ? : null} - controller.update({ error })} - /> - {opened ? : null} - - )} - {/* Connection problems matter once a service is chosen, not on the landing page. */} - {service !== "home" ? : null} -
- ); -} -function ImportTiles(): JSX.Element { - return ( - + controller.update({ error })} + /> + } + /> ); } diff --git a/apps/reader/src/ImportPageView.tsx b/apps/reader/src/ImportPageView.tsx new file mode 100644 index 00000000..024c627d --- /dev/null +++ b/apps/reader/src/ImportPageView.tsx @@ -0,0 +1,92 @@ +import { Wordmark } from "@mdbase-dev/ui/brand"; + +import { importHref } from "./import-navigation.js"; +import { ImportInput } from "./ImportInput.js"; +import { ImportConfirm, ImportPreview, ImportStatus } from "./ImportReview.js"; +import "./import.css"; + +import type { ImportController, ImportState } from "./import-controller.js"; +import type { ReaderConnectedCollection } from "@mdbase-reader/connect"; +import type { JSX, ReactNode } from "react"; + +export type ImportService = "home" | "zotero" | "readwise"; + +/** Shared import UI only: no sign-in/session/client construction. */ +export function ImportPageView({ + service, + state, + controller, + opened, + destination, +}: { + readonly service: ImportService; + readonly state: ImportState; + readonly controller: ImportController; + readonly opened: ReaderConnectedCollection | null; + readonly destination: ReactNode; +}): JSX.Element { + return ( +
+
+ + Back to library +
+ {service !== "home" ? ( + + ) : null} +

+ {service === "home" + ? "Bring your library with you." + : `Import from ${service === "zotero" ? "Zotero" : "Readwise Reader"}`} +

+

+ Your sources, notes and files, in a collection you control. Preview first. Nothing is + imported until you confirm the destination. +

+ {service === "home" ? ( + + ) : ( + <> + + {state.plan ? : null} + {destination} + {opened ? : null} + + )} + {service !== "home" ? : null} +
+ ); +} + +function ImportTiles(): JSX.Element { + return ( + + ); +} diff --git a/apps/reader/src/LibraryCells.tsx b/apps/reader/src/LibraryCells.tsx index 2ce0b497..e7e076cf 100644 --- a/apps/reader/src/LibraryCells.tsx +++ b/apps/reader/src/LibraryCells.tsx @@ -32,8 +32,8 @@ const builtinCells: Record = { {formatLabel(source)}
- {source.title} - {source.creators.join(", ") || "Unknown creator"} + {source.title} + {source.creators.length ? {source.creators.join(", ")} : null} ), diff --git a/apps/reader/src/LibraryRowMenu.tsx b/apps/reader/src/LibraryRowMenu.tsx index 9e4a450c..39a128c3 100644 --- a/apps/reader/src/LibraryRowMenu.tsx +++ b/apps/reader/src/LibraryRowMenu.tsx @@ -28,6 +28,8 @@ export interface LibraryRowMenuActions { readonly onChangeStatus?: (id: SourceId, status: ReadingStatus) => void; readonly onRename?: (source: SourceSummary) => void; readonly onExport?: (sources: readonly SourceSummary[]) => void; + /** Opens the removal confirmation; nothing is deleted from the menu itself. */ + readonly onRemove?: (source: SourceSummary) => void; } /** @@ -50,7 +52,7 @@ export function LibraryRowMenu({ const { source } = target; const status = source.reading?.status ?? source.readingStatus ?? "inbox"; const citekey = source.citation?.id; - // Opening and renaming move focus themselves; other actions return it to the row. + // Opening, renaming and removing move focus themselves; others return it to the row. const run = (action: () => void, movesFocus = false) => (): void => { @@ -116,6 +118,19 @@ export function LibraryRowMenu({ ))} ) : null} + {actions.onRemove ? ( + <> +
+ + + ) : null}
, document.querySelector("main") ?? document.body, ); diff --git a/apps/reader/src/LibraryTable.tsx b/apps/reader/src/LibraryTable.tsx index f5390ec8..42dfda22 100644 --- a/apps/reader/src/LibraryTable.tsx +++ b/apps/reader/src/LibraryTable.tsx @@ -48,6 +48,8 @@ export interface LibraryTableProps { /** Saves a field edited in its cell; editing is offered only when this is present. */ readonly onEditField?: (source: SourceSummary, key: string, text: string) => Promise; readonly onExport?: (sources: readonly SourceSummary[]) => void; + /** Asks to remove a source, which confirms before anything is deleted. */ + readonly onRemove?: (source: SourceSummary) => void; } interface EditingCell { @@ -205,6 +207,7 @@ function rowMenuActions( onOpenBeside: props.onOpenBeside, ...(props.onChangeStatus ? { onChangeStatus: props.onChangeStatus } : {}), ...(props.onExport ? { onExport: props.onExport } : {}), + ...(props.onRemove ? { onRemove: props.onRemove } : {}), ...(props.onEditField && props.layout.columns.includes("title") ? { onRename: rename } : {}), }; } diff --git a/apps/reader/src/LibraryWorkspace.tsx b/apps/reader/src/LibraryWorkspace.tsx index 578bda97..5e9e7ed3 100644 --- a/apps/reader/src/LibraryWorkspace.tsx +++ b/apps/reader/src/LibraryWorkspace.tsx @@ -83,7 +83,10 @@ export function LibraryWorkspace({ onSourceChanged, annotationCounts = noCounts, onOpenAnnotation, + onRemoveSource, }: { + /** Asks to remove a source; the caller confirms before anything is deleted. */ + readonly onRemoveSource?: (source: SourceSummary) => void; /** Opens an annotation in its source's document; defaults to opening the source. */ readonly onOpenAnnotation?: (annotation: Annotation, beside: boolean) => void; readonly annotationCounts?: ReadonlyMap; @@ -580,6 +583,7 @@ export function LibraryWorkspace({ onOpen={onOpenSource} onOpenBeside={onOpenBeside} onExport={bibliographyExport.runFor} + {...(onRemoveSource ? { onRemove: onRemoveSource } : {})} {...(changeStatus ? { onChangeStatus: changeStatus } : {})} {...(writes.saveField ? { diff --git a/apps/reader/src/NativeImportPage.test.tsx b/apps/reader/src/NativeImportPage.test.tsx new file mode 100644 index 00000000..2fd8ad92 --- /dev/null +++ b/apps/reader/src/NativeImportPage.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment happy-dom +import { createTestApp, type TestApp } from "@mdbase-reader/connect/testing"; +import * as migration from "@mdbase-reader/migration"; +import { act, StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import manifest from "./generated/mdbase-app.json"; +import { NativeImportPage } from "./NativeImportPage.js"; + +vi.mock("./connect.js", () => { + throw new Error("Native imports must not load the legacy Reader session"); +}); +vi.mock("@mdbase-dev/ui/feedback", () => ({ FeedbackButton: () => null })); +// Import parsing stand-ins only; SDK clients/currentness/collection scopes are real public fixtures. +vi.mock("@mdbase-reader/migration", async (original) => { + const module = await original(); + return { + ...module, + readZoteroBundle: vi.fn(module.readZoteroBundle), + planZotero: vi.fn(module.planZotero), + }; +}); + +const plan: migration.MigrationPlan = { + service: "zotero", + namespace: "owned-test", + files: [], + sources: [ + { + key: "owned", + id: "00000000-0000-4000-8000-000000000123", + path: "sources/owned.md", + fields: { title: "Owned source" }, + body: "Owned source", + documents: [], + }, + ], + annotations: [], + warnings: ["Owned preview retained"], + summary: { sources: 1, notes: 0, annotations: 0, files: 0, bytes: 0 }, +}; +const bundle: Awaited> = { + manifest: {}, + rows: { items: [], notes: [], annotations: [], attachments: [], collections: [] }, + files: new Map(), + payloads: [], + metadata: new Map(), +}; +let host: HTMLDivElement; +let root: ReturnType; +const owned: TestApp[] = []; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.mocked(migration.readZoteroBundle).mockResolvedValue(bundle); + vi.mocked(migration.planZotero).mockResolvedValue(plan); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(() => Promise.resolve(root.unmount())); + host.remove(); + await Promise.all(owned.splice(0).map((app) => app.close())); + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +async function fixture(ready = true): Promise { + const app = await createTestApp({ + declaration: { + id: "reader.fixture", + name: manifest.name, + origin: "https://reader.mdbase.dev", + environment: "lab", + manifest, + }, + collections: [{ name: "Reading" }], + phase: ready ? "ready" : "signed_out", + }); + owned.push(app); + return app; +} +function button(label: string): HTMLButtonElement { + const found = [...host.querySelectorAll("button")].find((item) => item.textContent === label); + if (!found) { + throw new Error(`Missing ${label}`); + } + return found; +} +async function selectInput(): Promise { + const input = host.querySelector('input[type="file"]'); + if (!input) { + throw new Error("Missing original Zotero input"); + } + const file = new File(["{}"], "manifest.json"); + Object.defineProperty(file, "webkitRelativePath", { value: "owned-bundle/manifest.json" }); + const transfer = new DataTransfer(); + transfer.items.add(file); + Object.defineProperty(input, "files", { value: transfer.files }); + await act(() => { + input.dispatchEvent(new Event("change", { bubbles: true })); + return Promise.resolve(); + }); +} + +it("native import home keeps the existing tiles without starting any SDK/legacy session", async () => { + const h = await fixture(false); + await act(() => Promise.resolve(root.render())); + expect(host.textContent).toContain("Bring your library with you."); + expect(host.querySelector('a[href$="import/zotero"]')).not.toBeNull(); + expect(host.querySelector('a[href$="import/readwise"]')).not.toBeNull(); + expect(h.controls.observedActions()).toHaveLength(0); +}); + +it("input/preview survives SDK destination changes without a nested main or borrowed client close", async () => { + const h = await fixture(); + const close = vi.spyOn(h.client(), "close"); + const create = vi.spyOn(h.client(), "create"); + h.controls.setHandler("chooseAnother", () => h.set("choosing")); + await act(() => + Promise.resolve( + root.render( + + + , + ), + ), + ); + await selectInput(); + expect(host.textContent).toContain("Owned preview retained"); + expect(host.textContent).toContain("Collection: Reading"); + expect(host.querySelectorAll("main")).toHaveLength(1); + expect(host.textContent).not.toContain("Import 1 sources into"); + await act(() => Promise.resolve(button("Choose another collection").click())); + expect( + h.controls.observedActions().filter((call) => call.action === "chooseAnother"), + ).toHaveLength(1); + expect(host.textContent).toContain("Owned preview retained"); + expect(host.textContent).not.toContain("Check destination:"); + await act(() => Promise.resolve(h.set("ready"))); + expect(host.textContent).toContain("Owned preview retained"); + expect(button("Check destination: Reading")).toBeTruthy(); + expect(create).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); +}); + +it("a late destination check cannot confirm a retired scope after readiness returns", async () => { + const h = await fixture(); + let finish!: (result: Awaited>) => void; + const preview = vi.spyOn(migration, "previewMigration").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await act(() => + Promise.resolve(root.render()), + ); + await selectInput(); + await act(() => Promise.resolve(button("Check destination: Reading").click())); + expect(preview).toHaveBeenCalledOnce(); + await act(() => Promise.resolve(h.set("choosing"))); + await act(() => Promise.resolve(h.set("ready"))); + await act(() => Promise.resolve(finish({ newRecords: 1, existingRecords: 0 }))); + expect(host.textContent).toContain("Stopped. Already committed data is kept"); + expect(host.textContent).not.toContain("Destination checked. Confirm"); + expect(host.textContent).not.toContain("Import 1 sources into"); + expect(host.textContent).toContain("Owned preview retained"); +}); diff --git a/apps/reader/src/NativeImportPage.tsx b/apps/reader/src/NativeImportPage.tsx new file mode 100644 index 00000000..013bed81 --- /dev/null +++ b/apps/reader/src/NativeImportPage.tsx @@ -0,0 +1,156 @@ +import { describeError, type NativeReaderCollection } from "@mdbase-reader/connect/native"; +import { useEffect, useRef, useState, useSyncExternalStore, type JSX } from "react"; + +import { ImportController } from "./import-controller.js"; +import { ImportPageView, type ImportService } from "./ImportPageView.js"; +import { SdkReaderRoot, type ReaderNativeScope, type ReaderSdk } from "./SdkReaderRoot.js"; + +/** App input/progress survives the SDK's chooser. The provider returns the actual + * current domain collection, never a session/client facade or bootstrap engine. */ +interface NativeImportFlow { + readonly controller: ImportController; + bind(scope: ReaderNativeScope): () => void; +} + +/** Imports write only to the collection the SDK still reports as open and ready. */ +function isReadyConnection({ mdbase, connection }: ReaderNativeScope): boolean { + const { phase } = mdbase.getSnapshot(); + return phase.kind === "ready" && phase.collectionId === connection.id && connection.isCurrent(); +} + +function importFlow(opened: (collection: NativeReaderCollection | null) => void): NativeImportFlow { + let current: ReaderNativeScope | null = null; + const controller = new ImportController({ + connectedCollection: () => (current && isReadyConnection(current) ? current.collection : null), + }); + return { + controller, + bind(scope: ReaderNativeScope): () => void { + current = scope; + opened(scope.collection); + return () => { + if (current === scope) { + current = null; + controller.stop(); + opened(null); + } + }; + }, + }; +} + +export function NativeImportPage({ + sdk, + service, +}: { + readonly sdk: ReaderSdk; + readonly service: ImportService; +}): JSX.Element { + const [opened, setOpened] = useState(null); + const [flow] = useState(() => importFlow(setOpened)); + const { controller } = flow; + const state = useSyncExternalStore( + controller.subscribe, + controller.getSnapshot, + controller.getSnapshot, + ); + useEffect(() => { + const warn = (event: BeforeUnloadEvent): void => { + if (controller.getSnapshot().busy) { + event.preventDefault(); + } + }; + window.addEventListener("beforeunload", warn); + return () => { + window.removeEventListener("beforeunload", warn); + controller.dispose(); + }; + }, [controller]); + return ( + +

Choose a destination

+ } + /> + + ) + } + /> + ); +} + +function NativeImportDestination({ + scope, + flow, +}: { + readonly scope: ReaderNativeScope; + readonly flow: NativeImportFlow; +}): JSX.Element { + const { mdbase, connection, collection } = scope; + const { controller } = flow; + const state = useSyncExternalStore( + controller.subscribe, + controller.getSnapshot, + controller.getSnapshot, + ); + const caller = useRef(null); + const busy = useRef(false); + const [working, setWorking] = useState(false); + useEffect(() => { + const control = new AbortController(); + caller.current = control; + const unbind = flow.bind({ mdbase, connection, collection }); + return () => { + control.abort(); + unbind(); + }; + }, [flow, mdbase, connection, collection]); + const choose = async (): Promise => { + const control = caller.current; + if (!control || control.signal.aborted || busy.current || !connection.isCurrent()) { + return; + } + busy.current = true; + setWorking(true); + try { + await mdbase.chooseAnother({ signal: control.signal }); + } catch (error: unknown) { + if (!aborted(control)) { + controller.update({ + error: + describeError(error)?.message ?? "Could not change the collection. Please try again.", + }); + } + } finally { + if (!aborted(control)) { + busy.current = false; + setWorking(false); + } + } + }; + return ( +
+

Collection: {collection.collectionName}

+ +
+ ); +} + +function aborted(control: AbortController): boolean { + return control.signal.aborted; +} diff --git a/apps/reader/src/NativeSignIn.tsx b/apps/reader/src/NativeSignIn.tsx new file mode 100644 index 00000000..e9654b4b --- /dev/null +++ b/apps/reader/src/NativeSignIn.tsx @@ -0,0 +1,182 @@ +import { ConnectLayout } from "@mdbase-dev/ui/screens"; +import { useEffect, useRef, useState, useSyncExternalStore, type JSX } from "react"; + +import { + SignInTabBlocked, + authorizeNativeSignIn, + signInCopy, + signInPresentation, + type createNativeSignIn, +} from "./native-signin-ui.js"; + +interface NativeSignInProps { + /** Actual SDK instance plus its UI tab reservation; caller owns the lifetime. */ + binding: ReturnType; + /** Close succeeded. Owner may show signed-out UI; original stores stay. */ + onSignedOut: () => void; +} + +/** Presentation/actions only: no polling, custody, grants or session engine. */ +export function NativeSignIn(props: NativeSignInProps): JSX.Element { + return ; +} + +function observeForeground( + session: NativeSignInProps["binding"]["session"], + onError: () => void, +): () => void { + const foreground = (): void => { + if (session.getSnapshot().status === "closed") { + return; + } + try { + session.setForeground(document.visibilityState === "visible"); + } catch { + onError(); + } + }; + document.addEventListener("visibilitychange", foreground); + if (session.getSnapshot().status !== "closed") { + try { + session.setForeground(document.visibilityState === "visible"); + } catch { + /* Retired owner. The next explicit action reports its failure. */ + } + } + return () => { + document.removeEventListener("visibilitychange", foreground); + }; +} + +const portalCloseStatuses = new Set([ + "unselected", + "opening", + "setup_required", + "ready", + "blocked", + "closed", +]); + +function NativeSignInView({ binding, onSignedOut }: NativeSignInProps): JSX.Element { + const { session, portal } = binding; + const snapshot = useSyncExternalStore( + (listener) => session.subscribe(listener), + () => session.getSnapshot(), + () => session.getSnapshot(), + ); + const [problem, setProblem] = useState<"tab_blocked" | "interrupted" | null>(null); + const [working, setWorking] = useState(false); + const busy = useRef(false); + const mounted = useRef(false); + + useEffect(() => { + mounted.current = true; + const stop = observeForeground(session, () => { + if (mounted.current) { + setProblem("interrupted"); + } + }); + return () => { + mounted.current = false; + stop(); + portal.cancel(); + // Borrowed SDK session: its actual owner handles unmount/HMR shutdown. + }; + }, [session, portal]); + + useEffect(() => { + // An already paired account may still be authorizing additional consent. + if (portalCloseStatuses.has(snapshot.status)) { + portal.cancel(); + } + }, [portal, snapshot.status]); + + const act = async (action: () => Promise): Promise => { + if (busy.current) { + return; + } + busy.current = true; + setWorking(true); + setProblem(null); + try { + await action(); + } catch (reason) { + if (mounted.current) { + setProblem(reason instanceof SignInTabBlocked ? "tab_blocked" : "interrupted"); + } + } finally { + if (mounted.current) { + busy.current = false; + setWorking(false); + } + } + }; + const view = signInPresentation(snapshot, "mdbase Reader", problem); + const signOut = async (): Promise => { + await session.close(); + portal.cancel(); + if (mounted.current) { + onSignedOut(); + } + }; + const authorize = (): ReturnType => + authorizeNativeSignIn(session, portal); + + return ( + +
+ {view.identity ?

{view.identity}

: null} +

+ {view.message} +

+ {view.unavailable ?

{signInCopy.unavailable}

: null} +
+ {view.waiting ? ( + + ) : !view.paired || view.error ? ( + + ) : null} + {view.paired ? ( + + ) : null} + {snapshot.selectedCollectionId ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/reader/src/ReaderApp.tsx b/apps/reader/src/ReaderApp.tsx index 577f738b..30ec2bc1 100644 --- a/apps/reader/src/ReaderApp.tsx +++ b/apps/reader/src/ReaderApp.tsx @@ -24,6 +24,7 @@ import { useMdbaseLibraryViews } from "./use-mdbase-library-views.js"; import { useReaderWorkspace, type ReaderWorkspaceController } from "./use-reader-workspace.js"; import { useSourceAddition } from "./use-source-addition.js"; import { useSourceExport } from "./use-source-export.js"; +import { useSourceRemoval } from "./use-source-removal.js"; import { useSourceWorkspace } from "./use-source-workspace.js"; import type { SourceDocumentRenderer } from "./RenderedSourceDocument.js"; @@ -140,6 +141,7 @@ function OpenedReaderApp({ citationSources: library.sources, saveFile, }); + const sourceRemoval = useSourceRemoval(workspace, sourceWorkspace.close); useReaderShortcuts({ focusMode, @@ -172,6 +174,7 @@ function OpenedReaderApp({ documentAttachment, bibliographyExport, sourceExport, + sourceRemoval, renderDocument, onSurfaceChange, surfaces, diff --git a/apps/reader/src/ReaderHeader.tsx b/apps/reader/src/ReaderHeader.tsx index 8167ac3a..75e81c48 100644 --- a/apps/reader/src/ReaderHeader.tsx +++ b/apps/reader/src/ReaderHeader.tsx @@ -1,4 +1,5 @@ import { AppSwitcher } from "@mdbase-dev/ui/app-switcher"; +import { CollectionSaveWarning } from "@mdbase-dev/ui/collections"; import { shortcutLabel } from "@mdbase-dev/ui/command-palette"; import { FeedbackButton } from "@mdbase-dev/ui/feedback"; import { useMdbaseMarkBusy } from "@mdbase-dev/ui/mark-activity"; @@ -6,6 +7,7 @@ import { useMdbaseMarkBusy } from "@mdbase-dev/ui/mark-activity"; import { CollectionPicker } from "./CollectionPicker.js"; import { DisplayMenu } from "./DisplayMenu.js"; import { BackIcon, LeftPaneIcon, ReadingModeIcon, RightPaneIcon, SearchIcon } from "./icons.js"; +import { ReaderCollectionSwitcher, useReaderMdbase } from "./reader-collections.js"; import type { ReaderDirectAccessState } from "./use-direct-access.js"; import type { ThemePreference } from "@mdbase-dev/ui/theme"; @@ -34,7 +36,7 @@ interface ReaderHeaderProps { readonly onChangeDensity?: (density: "comfortable" | "compact") => void; readonly collectionName: string; readonly beforeCollectionSwitch?: () => boolean; - readonly connectionState: "connected" | "offline" | "syncing"; + readonly connectionState: ReaderConnectionState; readonly directAccess: ReaderDirectAccessState; readonly theme: ThemePreference; readonly onChangeTheme: (theme: ThemePreference) => void; @@ -103,6 +105,7 @@ function LibraryHeader({ onToggleInspector, display, }: ReaderHeaderProps & { readonly display: ReactNode }): JSX.Element { + const mdbase = useReaderMdbase(); return (
@@ -119,11 +122,16 @@ function LibraryHeader({
- + {mdbase ? ( + + ) : ( + + )} +
+ {problem && !plan ? ( + + ) : null} + +
+ + + ); +} diff --git a/apps/reader/src/connect-popup.test.ts b/apps/reader/src/connect-popup.test.ts new file mode 100644 index 00000000..bb233f53 --- /dev/null +++ b/apps/reader/src/connect-popup.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ConnectPopupBlocked, openConnectPopup } from "./connect-popup.js"; +import { createSignInPortalUi } from "./native-signin-ui.js"; + +afterEach(() => vi.unstubAllGlobals()); + +function handle(): { + opener: object | null; + closed: boolean; + location: { href: string }; + close: ReturnType; +} { + return { opener: {}, closed: false, location: { href: "" }, close: vi.fn() }; +} + +describe("UI-owned popup handles, not native authentication", () => { + it("uses a detached fallback tab if the popup throws", () => { + const tab = handle(); + const open = vi + .fn() + .mockImplementationOnce(() => { + throw new Error("blocked"); + }) + .mockReturnValue(tab); + vi.stubGlobal("window", { open }); + const portal = openConnectPopup(); + expect(open).toHaveBeenLastCalledWith("", "_blank"); + expect(tab.opener).toBeNull(); + portal.navigate("https://mdbase.test/approve"); + expect(tab.location.href).toBe("https://mdbase.test/approve"); + }); + it("reports an actually blocked popup and fallback", () => { + vi.stubGlobal("window", { open: () => null }); + expect(openConnectPopup).toThrow(ConnectPopupBlocked); + }); + it("closes idempotently and refuses late navigation of a disposed handle", () => { + const popup = handle(); + vi.stubGlobal("window", { open: () => popup }); + const portal = openConnectPopup(); + portal.close(); + portal.close(); + expect(popup.close).toHaveBeenCalledOnce(); + expect(() => portal.navigate("https://mdbase.test/late")).toThrow("Sign-in window closed."); + }); + it("keeps the original handle after SDK handoff and reuses it for reopen", () => { + const popup = handle(); + const open = vi.fn(() => popup); + vi.stubGlobal("window", { open }); + const ui = createSignInPortalUi(); + ui.reserve(); + const original = ui.open(); + ui.reserve(); + expect(ui.open()).toBe(original); + expect(open).toHaveBeenCalledOnce(); + ui.cancel(); + ui.cancel(); + expect(popup.close).toHaveBeenCalledOnce(); + }); + it("reopens a manually closed popup without sharing a retired owner's name", () => { + const first = handle(); + const second = handle(); + const open = vi.fn().mockReturnValueOnce(first).mockReturnValue(second); + vi.stubGlobal("window", { open }); + const ui = createSignInPortalUi(); + ui.reserve(); + const original = ui.open(); + first.closed = true; + ui.reserve(); + expect(ui.open()).not.toBe(original); + createSignInPortalUi().reserve(); + expect(open.mock.calls[0]?.[1]).toBe(open.mock.calls[1]?.[1]); + expect(open.mock.calls[2]?.[1]).not.toBe(open.mock.calls[0]?.[1]); + }); + it("does not turn display cleanup failure into an approval error", () => { + const popup = handle(); + popup.close.mockImplementation(() => { + throw new Error("display cleanup"); + }); + vi.stubGlobal("window", { open: () => popup }); + const portal = openConnectPopup(); + expect(() => portal.close()).not.toThrow(); + portal.close(); + expect(popup.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/reader/src/connect-popup.ts b/apps/reader/src/connect-popup.ts new file mode 100644 index 00000000..84f67183 --- /dev/null +++ b/apps/reader/src/connect-popup.ts @@ -0,0 +1,67 @@ +import type { AppWebSignInPortal } from "@mdbase-dev/sdk-legacy-729b5391/app-host"; + +export class ConnectPopupBlocked extends Error { + constructor() { + super("Your browser blocked the sign-in tab."); + } +} + +export interface ConnectPopup extends AppWebSignInPortal { + readonly closed: boolean; +} + +/** UI-only capability: callers pass only the SDK-validated Connect URI. + * Keep the production popup opener so its parent can close it across origins. + * The tab fallback detaches its opener before navigation. */ +export function openConnectPopup(name = "mdbase-connect-authorization"): ConnectPopup { + let popup: Window | null = null; + try { + popup = window.open("", name, "popup,width=620,height=760"); + } catch { + /* Try a tab. */ + } + if (!popup) { + try { + popup = window.open("", "_blank"); + // A literal noopener feature returns no usable blank handle. Detach + // immediately instead, before the SDK supplies the navigation URI. + if (popup) { + popup.opener = null; + } + } catch { + try { + popup?.close(); + } catch { + /* Display cleanup only. */ + } + popup = null; + } + } + if (!popup) { + throw new ConnectPopupBlocked(); + } + const original = popup; + let closed = false; + return { + get closed(): boolean { + return closed || original.closed; + }, + navigate(uri: string): void { + if (closed || original.closed) { + throw new Error("Sign-in window closed."); + } + original.location.href = uri; + }, + close(): void { + if (closed) { + return; + } + closed = true; + try { + original.close(); + } catch { + /* Display cleanup does not undo approval. */ + } + }, + }; +} diff --git a/apps/reader/src/connect-workspace-live-library.test.ts b/apps/reader/src/connect-workspace-live-library.test.ts new file mode 100644 index 00000000..f3abe00f --- /dev/null +++ b/apps/reader/src/connect-workspace-live-library.test.ts @@ -0,0 +1,65 @@ +import { sourceContract } from "@mdbase-reader/connect"; +import { nativeReaderCollection } from "@mdbase-reader/connect/native"; +import { memoryConnection } from "@mdbase-reader/connect/testing"; +import { createReaderRuntimeServices, MemoryStorage } from "@mdbase-reader/platform"; +import { afterEach, expect, it } from "vitest"; + +import { ConnectWorkspaceGateway } from "./connect-workspace.js"; + +const cleanups: (() => void)[] = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) { + cleanup(); + } +}); + +// A source added in another tab (or app, or device) reaches this tab's +// replica, but Reader kept its first library read forever. +it("rereads the library when a source arrives from outside this workspace", async () => { + const memory = await memoryConnection({ + contracts: [ + { + ...sourceContract, + type: "reader-source", + fields: { id: "id", title: "title", kind: "kind", saved_at: "saved_at" }, + }, + ], + }); + const opened = nativeReaderCollection(memory.connection); + cleanups.push(() => opened.dispose(), memory.close); + const gateway = new ConnectWorkspaceGateway( + opened.sources, + opened.annotations, + opened.annotationAssets, + opened.sourceImports, + opened.collectionId, + opened.collectionName, + createReaderRuntimeServices(new MemoryStorage()), + opened.contentSearch, + opened.files, + opened.libraryViews, + opened.bodyRecovery, + opened.subscribeSourceChanges, + ); + expect((await gateway.library()).sources).toEqual([]); + const changed = new Promise((resolve) => cleanups.push(gateway.subscribeLibrary(resolve))); + // Let the change watch start before the other tab writes. + await new Promise((resolve) => setTimeout(resolve, 20)); + + memory.replica.seed({ + path: "sources/other-tab.md", + types: ["reader-source"], + frontmatter: { + type: "reader-source", + id: "other-tab", + title: "Imported in another tab", + kind: "web", + saved_at: "2026-10-13T00:00:00Z", + }, + }); + await changed; + + expect((await gateway.library()).sources.map(({ title }) => title)).toEqual([ + "Imported in another tab", + ]); +}); diff --git a/apps/reader/src/connect-workspace.ts b/apps/reader/src/connect-workspace.ts index d01b3863..7d9987a7 100644 --- a/apps/reader/src/connect-workspace.ts +++ b/apps/reader/src/connect-workspace.ts @@ -57,6 +57,7 @@ import type { ReadingStatus, ReaderIdGenerator, Source, + SourceDeletionPlan, SourceId, SourceFileAttachmentRequest, SourceFileImportRequest, @@ -67,8 +68,12 @@ import type { SourceSummary, } from "@mdbase-reader/core"; +const noop = (): void => undefined; + export class ConnectWorkspaceGateway implements ReaderWorkspaceGateway { #library: ReaderLibrarySnapshot["sources"] | null = null; + /** Bumped when a change outside this workspace makes the cached library stale. */ + #libraryGeneration = 0; readonly #libraryLoads = new SharedRequests< string, ReaderLibrarySnapshot, @@ -114,15 +119,30 @@ export class ConnectWorkspaceGateway implements ReaderWorkspaceGateway { private readonly files?: CollectionFileRepository, private readonly libraryViewRepository?: LibraryViewRepository, private readonly bodyRecovery?: BodyUpdateRecovery, + private readonly sourceChanges?: (listener: () => void) => () => void, ) {} + /** Calls `listener` after another tab, app or device changed the library's + * sources; the next `library()` reads them again. */ + readonly subscribeLibrary = (listener: () => void): (() => void) => { + if (!this.sourceChanges) { + return noop; + } + return this.sourceChanges(() => { + this.#libraryGeneration += 1; + this.#library = null; + listener(); + }); + }; + async library(options: ReaderLibraryRequestOptions = {}): Promise { options.signal?.throwIfAborted(); if (this.#library) { return completeLibrarySnapshot(this.collectionName, this.#library); } + const generation = this.#libraryGeneration; return this.#libraryLoads.get( - "library", + `library:${String(generation)}`, options, async (signal, onProgress) => { const snapshot = await loadSourceLibrary({ @@ -133,7 +153,10 @@ export class ConnectWorkspaceGateway implements ReaderWorkspaceGateway { onProgress, }); signal.throwIfAborted(); - this.#library = snapshot.sources; + // A load that a later change overtook must not become the cached library. + if (generation === this.#libraryGeneration) { + this.#library = snapshot.sources; + } return snapshot; }, options.onProgress, @@ -647,6 +670,29 @@ export class ConnectWorkspaceGateway implements ReaderWorkspaceGateway { return updated; } + async planSourceRemoval(id: SourceId): Promise { + if (!this.sources.preflightDelete) { + throw new Error("This collection cannot remove sources."); + } + // Check the revision the collection holds now, not a copy cached when the source opened. + const source = await this.refreshSource(id); + if (!source) { + throw new Error("This source is no longer in the collection."); + } + return this.sources.preflightDelete(source); + } + + async removeSource(plan: SourceDeletionPlan): Promise { + if (!this.sources.delete) { + throw new Error("This collection cannot remove sources."); + } + await this.sources.delete(plan); + this.#sourcesById.delete(plan.sourceId); + if (this.#library) { + this.#library = this.#library.filter(({ id }) => id !== plan.sourceId); + } + } + #replaceSource(source: Source, prepend = false): void { this.#sourcesById.set(source.id, source); if (this.#library) { diff --git a/apps/reader/src/dockview-workspace.css b/apps/reader/src/dockview-workspace.css index 9b0f8247..46c43965 100644 --- a/apps/reader/src/dockview-workspace.css +++ b/apps/reader/src/dockview-workspace.css @@ -53,6 +53,11 @@ --dv-context-menu-border-color: var(--line); color: var(--ink); } +@media (pointer: coarse) { + .dockview-theme-reader { + --dv-tabs-and-actions-container-height: 44px; + } +} .reader-dock-sidebar { height: 100%; min-height: 0; @@ -234,12 +239,15 @@ padding: 0; pointer-events: auto; } -.reader-dock .dock-pane-actions .document-toolbar .icon-button { - width: 30px; - height: 30px; +.reader-dock .dock-pane-actions .document-toolbar :is(.icon-button, .document-area-action), +.reader-dock .dock-pane-actions .document-toolbar .reader-menu > summary { + width: 36px; + height: 36px; + color: var(--ink-soft); } -.reader-dock .dock-pane-actions .document-area-action { - height: 30px; +.reader-dock .dock-pane-actions .document-toolbar svg { + width: 20px; + height: 20px; } .dock-pane-menu { position: relative; @@ -247,8 +255,8 @@ font-size: 12px; } .dock-pane-menu > button { - width: 30px; - height: 30px; + width: 36px; + height: 36px; display: grid; place-items: center; padding: 0; @@ -256,7 +264,7 @@ border-radius: 7px; cursor: pointer; background: transparent; - color: var(--muted); + color: var(--ink-soft); } .dock-pane-menu > button:hover, .dock-pane-menu > button:focus-visible { @@ -264,8 +272,8 @@ background: var(--hover); } .dock-pane-menu > button svg { - width: 16px; - height: 16px; + width: 20px; + height: 20px; } .dock-pane-popover { position: fixed; diff --git a/apps/reader/src/environment-badge.css b/apps/reader/src/environment-badge.css index 6248e647..85726f97 100644 --- a/apps/reader/src/environment-badge.css +++ b/apps/reader/src/environment-badge.css @@ -1,12 +1,21 @@ +/* Reserve a separate environment strip; it never covers header controls. */ +#root:has(> .environment-badge) { + box-sizing: border-box; + padding-top: 24px; +} + .environment-badge { position: fixed; z-index: 10000; - top: 5px; - right: 5px; - padding: 4px 7px 3px; + top: 0; + left: 0; + right: 0; + height: 24px; + display: grid; + place-items: center; + box-sizing: border-box; border: 1px solid currentColor; background: #f4f7f7; - box-shadow: 2px 2px 0 rgb(20 34 35 / 14%); color: #34585d; font: 500 9px/1.2 "Azeret Mono", @@ -34,9 +43,6 @@ @media (max-width: 640px) { .environment-badge { - top: 3px; - right: 3px; - padding: 3px 5px 2px; font-size: 8px; } } diff --git a/apps/reader/src/generated/mdbase-app.json b/apps/reader/src/generated/mdbase-app.json index 3b398a28..d45ef447 100644 --- a/apps/reader/src/generated/mdbase-app.json +++ b/apps/reader/src/generated/mdbase-app.json @@ -28,7 +28,10 @@ "contract_version": 2, "required": ["collection.read", "records.create", "records.edit", "records.delete"] }, - "files": { "required": ["list", "read", "add"], "scope": { "kind": "collection" } } + "files": { + "required": ["list", "read", "add", "replace", "move", "delete"], + "scope": { "kind": "collection" } + } }, "provisions": { "type_packs": [ diff --git a/apps/reader/src/grant-callback.test.ts b/apps/reader/src/grant-callback.test.ts new file mode 100644 index 00000000..82c9c011 --- /dev/null +++ b/apps/reader/src/grant-callback.test.ts @@ -0,0 +1,32 @@ +import { expect, it, vi } from "vitest"; + +import { completeAppGrantCallback, isAppGrantCallback } from "./grant-callback.js"; + +it("recognises the consent popup's return, approved or declined, and nothing else", () => { + expect(isAppGrantCallback(new URL("https://reader.mdbase.dev/?state=s&code=c"))).toBe(true); + expect( + isAppGrantCallback(new URL("https://reader.mdbase.dev/?state=s&error=access_denied")), + ).toBe(true); + expect(isAppGrantCallback(new URL("https://reader.mdbase.dev/"))).toBe(false); + expect(isAppGrantCallback(new URL("https://reader.mdbase.dev/?collection=x"))).toBe(false); + expect(isAppGrantCallback(new URL("https://reader.mdbase.dev/?code=c"))).toBe(false); +}); + +it("delivers without preempting the original SDK popup lifetime, including failed delivery", async () => { + const url = new URL("http://127.0.0.1:48222/?state=s&code=c"); + const close = vi.fn();vi.stubGlobal("window",{close}); + const deliver = vi.fn(); + expect(completeAppGrantCallback(url, deliver)).toBe(true); + await new Promise(resolve=>setTimeout(resolve,0)); + expect(close).not.toHaveBeenCalled(); + expect(deliver).toHaveBeenCalledWith(url.href); + + expect( + completeAppGrantCallback(url, () => Promise.reject(new Error("no channel"))), + ).toBe(true); + await new Promise(resolve=>setTimeout(resolve,0)); + expect(close).not.toHaveBeenCalled(); + + expect(completeAppGrantCallback(new URL("http://127.0.0.1:48222/"), deliver)).toBe(false); + expect(close).not.toHaveBeenCalled();vi.unstubAllGlobals(); +}); diff --git a/apps/reader/src/grant-callback.ts b/apps/reader/src/grant-callback.ts new file mode 100644 index 00000000..264541a4 --- /dev/null +++ b/apps/reader/src/grant-callback.ts @@ -0,0 +1,32 @@ +/** + * The Private collection consent popup comes back to the app's redirect URI (the + * app root) with `state` and `code` or `error`. That window only hands the result + * to the waiting reader tab: its original SDK closes after Native finish and + * original custody ACK. It never starts a second mdbase entry. + */ +export function isAppGrantCallback(url: URL): boolean { + return ( + url.searchParams.has("state") && (url.searchParams.has("code") || url.searchParams.has("error")) + ); +} + +type Deliver = (href: string) => void | Promise; + +// Loaded only in the popup, so the grant code stays out of the reader's start-up. +const deliverWithSdk: Deliver = async (href) => { + (await import("@mdbase-dev/sdk/app-grant")).deliverAppGrantCallback(href); +}; + +/** Hand the callback to the original SDK owner. False when it is not one. */ +export function completeAppGrantCallback( + url: URL, + deliver: Deliver = deliverWithSdk, +): boolean { + if (!isAppGrantCallback(url)) { + return false; + } + void Promise.resolve() + .then(() => deliver(url.href)) + .catch(() => undefined); + return true; +} diff --git a/apps/reader/src/import-controller.ts b/apps/reader/src/import-controller.ts index e732ca46..f99eea21 100644 --- a/apps/reader/src/import-controller.ts +++ b/apps/reader/src/import-controller.ts @@ -12,7 +12,7 @@ import { import { markFraction, withMarkProgress } from "./mark-activity.js"; -import type { ReaderApplicationSession } from "@mdbase-reader/connect"; +import type { ReaderCollectionProvider } from "@mdbase-reader/connect"; export interface ImportState { plan: MigrationPlan | null; message: string; @@ -37,7 +37,7 @@ export class ImportController { private listeners = new Set<() => void>(); private active: AbortController | null = null; private client: ReadwiseClient | null = null; - constructor(private session: ReaderApplicationSession) {} + constructor(private session: ReaderCollectionProvider) {} getSnapshot = (): ImportState => this.state; subscribe = (fn: () => void): (() => void) => { this.listeners.add(fn); @@ -94,7 +94,11 @@ export class ImportController { } input.set(relative, file); } - const bundle = await readZoteroBundle(input, signal, (message) => this.update({ message })); + const bundle = await readZoteroBundle(input, signal, (message) => { + if (!signal.aborted) { + this.update({ message }); + } + }); const plan = await planZotero(bundle); signal.throwIfAborted(); this.update({ @@ -111,9 +115,12 @@ export class ImportController { this.client?.forget(); this.client = new ReadwiseClient(token.trim()); this.update({ plan: null, preview: null, result: null }); - const plan = await scanReadwise(this.client, includeFeed, signal, (message) => - this.update({ message }), - ); + const plan = await scanReadwise(this.client, includeFeed, signal, (message) => { + if (!signal.aborted) { + this.update({ message }); + } + }); + signal.throwIfAborted(); this.update({ plan, message: "Library scanned. Review the contents and choose a destination.", @@ -130,6 +137,7 @@ export class ImportController { void this.task(async (signal) => { this.update({ message: "Checking existing import identities…" }); const next = await previewMigration(plan, target, signal); + signal.throwIfAborted(); this.update({ preview: { ...next, collectionId: target.collectionId }, message: "Destination checked. Confirm below to begin writing.", @@ -156,6 +164,9 @@ export class ImportController { const result = await withMarkProgress( (mark) => runMigration(plan, target, signal, (progress) => { + if (signal.aborted) { + return; + } mark(markFraction(progress.completed, progress.total)); this.update({ progress, message: `${progress.phase} — ${name}` }); saveProgress(plan.service, progress); @@ -163,6 +174,7 @@ export class ImportController { undefined, signal, ); + signal.throwIfAborted(); this.client?.forget(); this.update({ result, diff --git a/apps/reader/src/library-columns.ts b/apps/reader/src/library-columns.ts index 47ba3113..32cd11d5 100644 --- a/apps/reader/src/library-columns.ts +++ b/apps/reader/src/library-columns.ts @@ -86,9 +86,9 @@ export function columnLabel( const defaultWidths: Record = { title: 380, creator: 190, - published: 104, - annotations: 116, - opened: 124, + published: 132, + annotations: 144, + opened: 148, status: 136, format: 88, tags: 190, diff --git a/apps/reader/src/main.tsx b/apps/reader/src/main.tsx index 109c8add..500994d0 100644 --- a/apps/reader/src/main.tsx +++ b/apps/reader/src/main.tsx @@ -9,16 +9,24 @@ import "./reader-shell.css"; import "@mdbase-dev/ui/feedback.css"; import "./feedback-shell.css"; -import { ConnectReader } from "./ConnectReader.js"; import { EnvironmentBadge } from "./EnvironmentBadge.js"; import { FeedbackRoot } from "./FeedbackRoot.js"; import { forgetOfflineCopies } from "./forget-offline-copies.js"; +import { completeAppGrantCallback } from "./grant-callback.js"; import { importService } from "./import-navigation.js"; import { keepFocusedFieldInView } from "./keep-focused-field-in-view.js"; import "./environment-badge.css"; import { setupPwaInstall } from "./pwa-install.js"; + +import type { ImportService } from "./ImportPageView.js"; import "./pwa-install.css"; const PreviewReader = lazy(async () => ({ default: (await import("./preview.js")).PreviewReader })); +const ConnectReader = lazy(async () => ({ + default: (await import("./ConnectReader.js")).ConnectReader, +})); +const NativeImportPage = lazy(async () => ({ + default: (await import("./NativeImportPage.js")).NativeImportPage, +})); // Library imports bring PDF parsing and compression code that ordinary reading never needs. const ImportPage = lazy(async () => { @@ -26,6 +34,26 @@ const ImportPage = lazy(async () => { return { default: module.ImportPage }; }); +// LAB builds open collections through the public SDK entry, loaded only there. +// Staging and production keep Connect: the SDK has no production release trust yet. +const SdkReader = lazy(async () => { + const [{ SdkReaderRoot }, { readerSdk }] = await Promise.all([ + import("./SdkReaderRoot.js"), + import("./sdk-application.js"), + ]); + const sdk = readerSdk(); + return { + default: ({ service }: { service?: ImportService }) => + service ? ( + + + + ) : ( + + ), + }; +}); + const stopPwaInstall = setupPwaInstall("mdbase reader"); if (import.meta.hot) { import.meta.hot.dispose(stopPwaInstall); @@ -38,20 +66,37 @@ if (!root) { forgetOfflineCopies(globalThis.indexedDB); keepFocusedFieldInView(window); const migrationService = importService(location.pathname); +// LAB: the Private collection consent popup returns here. It hands the result to +// the waiting reader tab and closes, without starting a second mdbase entry. +// (Staging and production keep Connect, whose own sign-in return is untouched.) +const grantCallback = + import.meta.env.VITE_MDBASE_ENV === "lab" && completeAppGrantCallback(new URL(location.href)); createRoot(root).render( - {migrationService ? ( + {grantCallback ? ( +

You can close this window.

+ ) : migrationService ? ( - + {import.meta.env.VITE_MDBASE_ENV === "lab" ? ( + + ) : ( + + )} ) : new URL(location.href).searchParams.has("preview") ? ( + ) : import.meta.env.VITE_MDBASE_ENV === "lab" ? ( + + + ) : ( - + + + )}
, diff --git a/apps/reader/src/mobile-source-bar.tsx b/apps/reader/src/mobile-source-bar.tsx index 9b3ea54c..844e4341 100644 --- a/apps/reader/src/mobile-source-bar.tsx +++ b/apps/reader/src/mobile-source-bar.tsx @@ -48,6 +48,7 @@ export function mobileSourceBar( bookmarking={composer.bookmarking} onBookmark={composer.bookmark} sourceExport={model.sourceExport} + onRemoveSource={model.sourceRemoval.request} onToggleFocus={() => model.setFocusMode((value) => !value)} surfaces={model.surfaces} sessionId={tab.id} diff --git a/apps/reader/src/native-signin-ui.test.ts b/apps/reader/src/native-signin-ui.test.ts new file mode 100644 index 00000000..16eb8a53 --- /dev/null +++ b/apps/reader/src/native-signin-ui.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + openSignInPortal, + SignInTabBlocked, + signInCopy, + signInPresentation, + unavailableCollection, +} from "./native-signin-ui"; + +import type { AppWebSignInSnapshot } from "@mdbase-dev/sdk-legacy-729b5391/app-host"; + +function snapshot(patch: Partial = {}): AppWebSignInSnapshot { + return { + status: "signed_out", + signIn: null, + collections: [], + selectedCollectionId: null, + problem: null, + timing: null, + ...patch, + }; +} +const paired: NonNullable = { + requestId: "request", + installationId: "installation", + deviceId: "device", + appId: "app", + kind: "app-runtime", + verificationUri: "https://mdbase.test/approve", + state: "paired", + accountId: "account", + accountEmail: "writer@example.test", + collectionIds: [], + createCollections: false, + requestedCreateCollections: false, +}; + +describe("native sign-in presentation, not an authentication engine", () => { + it("uses canonical signed-out wording", () => { + expect(signInPresentation(snapshot(), "TaskNotes Planner").message).toBe( + "Sign in with your mdbase account to sync TaskNotes Planner across your devices.", + ); + }); + it("renders SDK authorizing without introducing an app poller", () => { + const view = signInPresentation(snapshot({ status: "authorizing" }), "Planner"); + expect(view.message).toBe("Waiting for approval in the mdbase tab…"); + expect(view.waiting).toBe(true); + }); + it("shows email only from a paired SDK view", () => { + expect( + signInPresentation(snapshot({ status: "unselected", signIn: paired }), "Planner").message, + ).toBe("Signed in as writer@example.test"); + expect( + signInPresentation( + snapshot({ + status: "authorizing", + signIn: { ...paired, state: "account_selected" }, + }), + "Planner", + ).identity, + ).toBeNull(); + }); + it("does not invent an email or use account IDs as labels", () => { + const view = signInPresentation( + snapshot({ + status: "unselected", + signIn: { ...paired, accountEmail: null }, + }), + "Planner", + ); + expect(view.identity).toBeNull(); + expect(view.message).toBe(signInCopy.unavailable); + expect(view.message).not.toContain(paired.accountId); + }); + it("uses actual selected collection metadata for opening", () => { + const view = signInPresentation( + snapshot({ + status: "opening", + signIn: paired, + selectedCollectionId: "selected", + collections: [{ collectionId: "selected", displayName: "Research", role: "owner" }], + }), + "Planner", + ); + expect(view.message).toBe("Signed in. Opening Research…"); + }); + it("refuses to invent a collection name from an identifier", () => { + const view = signInPresentation( + snapshot({ status: "opening", selectedCollectionId: "private-id" }), + "Planner", + ); + expect(view.message).not.toContain("private-id"); + }); + it("does not mistake a generic refusal for the user declining", () => { + expect( + signInPresentation(snapshot({ status: "blocked", problem: "refused" }), "Planner").message, + ).toBe(signInCopy.interrupted); + }); + it("does not imply saved/sync or reminders capability from authorization", () => { + const view = signInPresentation(snapshot({ status: "unselected", signIn: paired }), "Planner"); + expect(JSON.stringify(view)).not.toMatch( + /saved on this device|reminders|Set up|Continue setup/, + ); + expect(view.unavailable).toBe(true); + }); + it("clears visible identity after SDK closure", () => { + const view = signInPresentation(snapshot({ status: "closed", signIn: paired }), "Planner"); + expect(view.paired).toBe(false); + expect(view.identity).toBeNull(); + }); + it("has a distinct blocked-tab action without rendering raw errors", () => { + const view = signInPresentation(snapshot(), "Planner", "tab_blocked"); + expect(view.message).toBe("Your browser blocked the sign-in tab."); + expect(view.action).toBe("Open sign-in"); + }); +}); + +describe("public UI popup and unavailable collection boundary", () => { + it("opens the named production popup synchronously with its trusted opener", () => { + const opener = {}; + const popup = { opener, closed: false, location: { href: "" }, close: vi.fn() }; + vi.stubGlobal("window", { open: vi.fn(() => popup) }); + try { + const portal = openSignInPortal(); + expect(window.open).toHaveBeenCalledWith( + "", + "mdbase-connect-authorization", + "popup,width=620,height=760", + ); + expect(popup.opener).toBe(opener); + portal.navigate("https://mdbase.test/approve"); + expect(popup.location.href).toBe("https://mdbase.test/approve"); + portal.close(); + expect(popup.close).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("distinguishes an actually blocked tab", () => { + vi.stubGlobal("window", { open: () => null }); + try { + expect(openSignInPortal).toThrow(SignInTabBlocked); + } finally { + vi.unstubAllGlobals(); + } + }); + it("the unavailable collection callback never manufactures a connection", async () => { + // Only signal is observed; fake host/scope/collection data is not consumed. + await expect( + unavailableCollection({ + signal: new AbortController().signal, + } as Parameters[0]), + ).rejects.toThrow(signInCopy.unavailable); + }); + it("preserves original cancellation before the unavailable boundary", async () => { + const controller = new AbortController(); + const reason = new Error("original cancelled"); + controller.abort(reason); + await expect( + unavailableCollection({ signal: controller.signal } as Parameters< + typeof unavailableCollection + >[0]), + ).rejects.toBe(reason); + }); +}); diff --git a/apps/reader/src/native-signin-ui.ts b/apps/reader/src/native-signin-ui.ts new file mode 100644 index 00000000..f2b9c6ab --- /dev/null +++ b/apps/reader/src/native-signin-ui.ts @@ -0,0 +1,174 @@ +import { AppWebSignInSession } from "@mdbase-dev/sdk-legacy-729b5391/app-host"; + +import { openConnectPopup, type ConnectPopup } from "./connect-popup.js"; + +import type { + AppWebSignInSessionOptions, + AppWebSignInSnapshot, + AppWebSignInPortal, +} from "@mdbase-dev/sdk-legacy-729b5391/app-host"; +export { ConnectPopupBlocked as SignInTabBlocked } from "./connect-popup.js"; + +export type NativeSignInSession = Pick< + AppWebSignInSession, + | "getSnapshot" + | "subscribe" + | "start" + | "authorize" + | "renewExpiredPairing" + | "forget" + | "close" + | "setForeground" +>; +export const signInCopy = Object.freeze({ + signIn: "Sign in", + waiting: "Waiting for approval in the mdbase tab…", + reopen: "Open the mdbase tab again", + tabBlocked: "Your browser blocked the sign-in tab.", + openSignIn: "Open sign-in", + interrupted: "Sign-in was interrupted. Please try again.", + signOut: "Sign out", + unavailable: "Opening collections isn't available in this build yet.", +}); +/** UI portal only. Navigation URI comes from the SDK. */ +export const openSignInPortal = openConnectPopup; + +// Popup names are UI ownership identities, never native IDs or namespaces. +let nextPortalKey = 0; +export interface SignInPortalUi { + reserve: () => void; + open: () => AppWebSignInPortal; + cancel: () => void; +} +export function createSignInPortalUi(): SignInPortalUi { + const name = `mdbase-reader-connect-${String(++nextPortalKey)}`; + let active: ConnectPopup | null = null; + return { + reserve(): void { + if (!active || active.closed) { + active = openSignInPortal(name); + } + }, + open(): AppWebSignInPortal { + if (!active || active.closed) { + active = openSignInPortal(name); + } + return active; + }, + cancel(): void { + const original = active; + active = null; + original?.close(); + }, + }; +} +// React identity only, never a native actor/record/mutation ID. +let nextViewKey = 0; +export interface NativeSignInBinding { + session: NativeSignInSession; + portal: SignInPortalUi; + viewKey: number; +} +/** Public SDK constructor, no collection/SQL/auth engine. */ +export function createNativeSignIn( + options: Omit, "openPortal" | "openCollection" | "autoSelect">, +): NativeSignInBinding { + options.signal.throwIfAborted(); + const portal = createSignInPortalUi(); + const session = new AppWebSignInSession({ + ...options, + openPortal: portal.open, + openCollection: unavailableCollection, + autoSelect: "never", + }); + return { session, portal, viewKey: ++nextViewKey }; +} +/** Reservation precedes every asynchronous SDK action. */ +export async function authorizeNativeSignIn( + session: NativeSignInSession, + portal: SignInPortalUi, +): Promise { + portal.reserve(); + try { + if (session.getSnapshot().status === "not_started") { + await session.start(); + } + if (session.getSnapshot().problem === "expired") { + await session.renewExpiredPairing(); + } + return await session.authorize(); + } catch (error) { + portal.cancel(); + throw error; + } +} +/** Never manufactures a connection or setup result; original cancellation wins. */ +export const unavailableCollection: AppWebSignInSessionOptions["openCollection"] = ({ + signal, +}): Promise => + Promise.resolve().then(() => { + signal.throwIfAborted(); + throw new Error(signInCopy.unavailable); + }); + +interface SignInPresentation { + message: string; + identity: string | null; + waiting: boolean; + paired: boolean; + unavailable: boolean; + error: boolean; + action: string; + disabled: boolean; +} +function messageFor( + snapshot: AppWebSignInSnapshot, + appName: string, + problem: "tab_blocked" | "interrupted" | null, + identity: string | null, + paired: boolean, +): string { + if (problem === "tab_blocked") { + return signInCopy.tabBlocked; + } + if (problem === "interrupted" || snapshot.status === "blocked") { + return signInCopy.interrupted; + } + if (snapshot.status === "authorizing") { + return signInCopy.waiting; + } + const selected = snapshot.collections.find( + ({ collectionId }) => collectionId === snapshot.selectedCollectionId, + ); + if (snapshot.status === "opening" && selected) { + return `Signed in. Opening ${selected.displayName}…`; + } + if (identity) { + return identity; + } + return paired + ? signInCopy.unavailable + : `Sign in with your mdbase account to sync ${appName} across your devices.`; +} +export function signInPresentation( + snapshot: AppWebSignInSnapshot, + appName: string, + problem: "tab_blocked" | "interrupted" | null = null, +): SignInPresentation { + const signIn = + snapshot.status !== "closed" && snapshot.signIn?.state === "paired" ? snapshot.signIn : null; + const email = signIn?.accountEmail; + const identity = email ? `Signed in as ${email}` : null; + const paired = signIn !== null; + const message = messageFor(snapshot, appName, problem, identity, paired); + return { + message, + identity: identity === message ? null : identity, + waiting: snapshot.status === "authorizing" && problem === null, + paired, + unavailable: paired && message !== signInCopy.unavailable, + error: problem !== null || snapshot.status === "blocked", + action: problem === "tab_blocked" ? signInCopy.openSignIn : signInCopy.signIn, + disabled: ["starting", "opening", "closed"].includes(snapshot.status), + }; +} diff --git a/apps/reader/src/preview.tsx b/apps/reader/src/preview.tsx index 489f0829..6fa0cad6 100644 --- a/apps/reader/src/preview.tsx +++ b/apps/reader/src/preview.tsx @@ -17,6 +17,7 @@ import { type ReadingPosition, type ReadingStatus, type Source, + type SourceDeletionPlan, type SourceId, type SourceFileImportRequest, type SourceRecordCreationRequest, @@ -386,6 +387,24 @@ export class PreviewGateway implements ReaderWorkspaceGateway { this.#annotations = this.#annotations.filter((candidate) => candidate.id !== annotation.id); return Promise.resolve(); } + planSourceRemoval(id: SourceId): Promise { + const source = this.#sources.find((item) => item.id === id); + if (!source) { + return Promise.reject(new Error("This source is no longer in the collection.")); + } + return Promise.resolve({ + sourceId: id, + path: source.path, + expectedRevision: source.recordRevision, + brokenLinkPaths: this.#annotations + .filter((annotation) => annotation.sourceId === id) + .map((annotation) => annotation.path ?? `annotations/${annotation.id}.md`), + }); + } + removeSource(plan: SourceDeletionPlan): Promise { + this.#sources = this.#sources.filter((item) => item.id !== plan.sourceId); + return Promise.resolve(); + } transcludeAnnotation(source: Source, annotation: Annotation): Promise { const embed = `![[${annotation.path?.replace(/\.md$/u, "") ?? `annotations/${annotation.id}`}]]`; return this.saveSourceBody(source, `${source.body.trimEnd()}\n\n${embed}\n`); diff --git a/apps/reader/src/pwa-install.css b/apps/reader/src/pwa-install.css index 50df0fd2..75d1b58d 100644 --- a/apps/reader/src/pwa-install.css +++ b/apps/reader/src/pwa-install.css @@ -16,6 +16,12 @@ 14px/1.5 system-ui, sans-serif; } +/* Source controls include 8px padding and a 1px border above their safe area. */ +body:has(.mobile-source-views) .pwa-install { + bottom: calc( + var(--mobile-source-control-height, 48px) + 9px + env(safe-area-inset-bottom) + 16px + ); +} .pwa-install p { margin: 0 0 10px; font: inherit; diff --git a/apps/reader/src/pwa-install.test.ts b/apps/reader/src/pwa-install.test.ts new file mode 100644 index 00000000..b195a82c --- /dev/null +++ b/apps/reader/src/pwa-install.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { markPwaInstallEligible, setupPwaInstall } from "./pwa-install.js"; + +let stop: (() => void) | undefined; + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +}); + +afterEach(() => { + stop?.(); + stop = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function offerInstall(): Event { + const event = new Event("beforeinstallprompt", { cancelable: true }); + window.dispatchEvent(event); + return event; +} + +describe("Reader install engagement", () => { + it("retains the browser offer but waits for a document and a further delay", () => { + stop = setupPwaInstall("Reader"); + expect(offerInstall().defaultPrevented).toBe(true); + vi.advanceTimersByTime(120_000); + expect(document.querySelector(".pwa-install")).toBeNull(); + markPwaInstallEligible(); + vi.advanceTimersByTime(29_999); + expect(document.querySelector(".pwa-install")).toBeNull(); + vi.advanceTimersByTime(1); + expect(document.querySelector(".pwa-install")).not.toBeNull(); + }); + + it("allows an invitation on a later visit after document use", () => { + markPwaInstallEligible(); + stop = setupPwaInstall("Reader"); + offerInstall(); + vi.advanceTimersByTime(30_000); + expect(document.querySelector(".pwa-install")).not.toBeNull(); + }); + + it("honours dismissal across further document opens and visits", () => { + stop = setupPwaInstall("Reader"); + offerInstall(); + markPwaInstallEligible(); + vi.advanceTimersByTime(30_000); + const later = [...document.querySelectorAll(".pwa-install button")].find( + (button) => button.textContent === "Not now", + ); + later?.click(); + stop(); + stop = setupPwaInstall("Reader"); + markPwaInstallEligible(); + offerInstall(); + vi.advanceTimersByTime(30_000); + expect(document.querySelector(".pwa-install")).toBeNull(); + }); + + it("cleans up the engagement listener and pending delay", () => { + stop = setupPwaInstall("Reader"); + offerInstall(); + markPwaInstallEligible(); + stop(); + markPwaInstallEligible(); + vi.advanceTimersByTime(30_000); + expect(document.querySelector(".pwa-install")).toBeNull(); + }); +}); diff --git a/apps/reader/src/pwa-install.ts b/apps/reader/src/pwa-install.ts index c352c5aa..9bdf7036 100644 --- a/apps/reader/src/pwa-install.ts +++ b/apps/reader/src/pwa-install.ts @@ -1,4 +1,4 @@ -// Keep this small, dependency-free module aligned across writer, reader and editor. +// Installation is offered only after Reader has been used, never during first sign-in. interface InstallEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; @@ -7,6 +7,40 @@ interface InstallEvent extends Event { const COOLDOWN = 30 * 24 * 60 * 60 * 1000; const DELAY = 30_000; const STORAGE_KEY = "mdbase:pwa-install-dismissed"; +const USED_KEY = "mdbase:reader-used"; +const USED_EVENT = "mdbase:reader-document-opened"; + +/** Opening a document is useful engagement; signing in alone is not. */ +export function markPwaInstallEligible(): void { + try { + localStorage.setItem(USED_KEY, "1"); + } catch { + /* The current tab can still offer installation without persistent storage. */ + } + window.dispatchEvent(new Event(USED_EVENT)); +} + +/** A delay starts only after useful engagement, including a previous used visit. */ +function waitForReaderUse(ready: () => void): () => void { + let timer: number | null = null; + const used = (): void => { + timer ??= window.setTimeout(ready, DELAY); + }; + window.addEventListener(USED_EVENT, used); + try { + if (localStorage.getItem(USED_KEY) === "1") { + used(); + } + } catch { + /* Wait for this tab to open a document. */ + } + return () => { + if (timer !== null) { + window.clearTimeout(timer); + } + window.removeEventListener(USED_EVENT, used); + }; +} /** Installation is optional; no service worker or private-data cache is introduced. */ export function setupPwaInstall(appName: string): () => void { @@ -120,13 +154,13 @@ export function setupPwaInstall(appName: string): () => void { window.addEventListener("beforeinstallprompt", beforeInstall); window.addEventListener("appinstalled", installed); standalone.addEventListener("change", modeChanged); - const timer = window.setTimeout(() => { + const stopWaiting = waitForReaderUse(() => { ready = true; show(); - }, DELAY); + }); return () => { disposed = true; - window.clearTimeout(timer); + stopWaiting(); window.removeEventListener("beforeinstallprompt", beforeInstall); window.removeEventListener("appinstalled", installed); standalone.removeEventListener("change", modeChanged); diff --git a/apps/reader/src/reader-collections.css b/apps/reader/src/reader-collections.css new file mode 100644 index 00000000..37503926 --- /dev/null +++ b/apps/reader/src/reader-collections.css @@ -0,0 +1,29 @@ +/* Shared collection screens (@mdbase-dev/ui/collections) on Reader's tokens. */ +:root { + --mdbase-collections-accent: var(--accent); + --mdbase-collections-surface: var(--paper); + --mdbase-collections-canvas: var(--canvas); + --mdbase-collections-text: var(--ink); + --mdbase-collections-text-soft: var(--muted); + --mdbase-collections-border: var(--line); + --mdbase-collections-danger: var(--danger); +} + +/* In the header the switcher is a place, set apart from the app menu like Reader's old picker. */ +.reader-header-context .reader-collection-switcher { + min-width: 0; + margin-left: 4px; + padding-left: 6px; + border-left: 1px solid var(--reader-hairline); + color: var(--ink-soft); + font-size: var(--ui-control); + --mdbase-collections-control-height: var(--ui-control-height, 32px); +} + +@media (max-width: 680px) { + .reader-header-context .reader-collection-switcher { + margin-left: 0; + padding-left: 0; + border-left: 0; + } +} diff --git a/apps/reader/src/reader-collections.tsx b/apps/reader/src/reader-collections.tsx new file mode 100644 index 00000000..99f32871 --- /dev/null +++ b/apps/reader/src/reader-collections.tsx @@ -0,0 +1,37 @@ +import { MdbaseAppMark } from "@mdbase-dev/ui/brand"; +import { CollectionSwitcher, type CollectionAppIdentity } from "@mdbase-dev/ui/collections"; +import { createContext, useContext, type JSX } from "react"; + +import type { Mdbase } from "@mdbase-reader/connect/native"; + +/** Reader's identity in the shared collection screens. */ +export const readerCollectionApp: CollectionAppIdentity = { + name: "mdbase Reader", + mark: , +}; + +/** Set by the SDK root: the header then shows the shared collection switcher. */ +export const ReaderMdbaseContext = createContext(null); + +export function useReaderMdbase(): Mdbase | null { + return useContext(ReaderMdbaseContext); +} + +/** The shared switcher for the header; unsaved edits can keep Reader on this collection. */ +export function ReaderCollectionSwitcher({ + mdbase, + beforeSwitch, +}: { + readonly mdbase: Mdbase; + readonly beforeSwitch?: (() => boolean) | undefined; +}): JSX.Element { + return ( + beforeSwitch() : undefined} + /> + ); +} diff --git a/apps/reader/src/reader-command-list.ts b/apps/reader/src/reader-command-list.ts index 279bf29a..5e51448f 100644 --- a/apps/reader/src/reader-command-list.ts +++ b/apps/reader/src/reader-command-list.ts @@ -19,6 +19,8 @@ export interface ReaderCommandInput { readonly activeSource: SourceSummary | null; readonly workspace: SourceWorkspaceController; readonly sourceExport: SourceExportController; + /** Asks to remove a source, which confirms first; null when the collection cannot. */ + readonly removeSource?: ((source: SourceSummary) => void) | null; readonly bibliographyExport: BibliographyExportController; readonly focusMode: boolean; /** Bookmarks the focused document's current position; null without one. */ @@ -177,10 +179,12 @@ function workspaceCommands(input: ReaderCommandInput): readonly ReaderCommand[] } function sourceCommands(input: ReaderCommandInput): readonly ReaderCommand[] { - const sourceId = input.activeSource?.id; + const activeSource = input.activeSource; + const sourceId = activeSource?.id; if (!sourceId) { return []; } + const removeSource = input.removeSource; return [ { id: "export-source", @@ -218,6 +222,18 @@ function sourceCommands(input: ReaderCommandInput): readonly ReaderCommand[] { run: () => input.workspace.openBeside(sourceId, "citation"), }, }, + ...(removeSource + ? [ + { + id: "remove-source", + label: "Remove current source…", + detail: activeSource.title, + group: "Current source" as const, + keywords: "delete trash discard mistaken import", + run: () => removeSource(activeSource), + }, + ] + : []), ]; } diff --git a/apps/reader/src/reader-shell.css b/apps/reader/src/reader-shell.css index 5c224aca..8a6d6a7b 100644 --- a/apps/reader/src/reader-shell.css +++ b/apps/reader/src/reader-shell.css @@ -18,6 +18,7 @@ --ui-heading: 15px; --ui-control-height: 30px; --ui-radius: 6px; + --mobile-source-control-height: 48px; } /* Shared controls (@mdbase-dev/ui) follow the shell's density. */ @@ -160,6 +161,12 @@ cursor: default; } +.reader-menu-panel > button.is-danger, +.reader-menu-panel > button.is-danger:hover:not(:disabled), +.reader-menu-panel > button.is-danger svg { + color: var(--danger); +} + .reader-menu-panel svg { width: 15px; height: 15px; @@ -337,10 +344,16 @@ } .reader-header .connection-state { + min-width: 0; color: var(--muted); font-size: var(--ui-meta); } +.reader-header .connection-state > span { + overflow: hidden; + text-overflow: ellipsis; +} + .reader-header-actions { display: flex; align-items: center; @@ -1218,6 +1231,9 @@ } .continue-reading-title strong { + overflow: visible; + white-space: normal; + overflow-wrap: anywhere; font-size: var(--ui-body); font-weight: 650; } @@ -2229,6 +2245,37 @@ } } +/* The source title is live metadata; saved document text (and annotation anchors) stays intact. */ +.source-document { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.source-document-heading { + flex: 0 0 auto; + padding: 12px 20px; + border-bottom: 1px solid var(--line); +} + +.source-document-heading h1 { + margin: 0 0 4px; + color: var(--ink); + font-size: var(--ui-body); + overflow-wrap: anywhere; +} + +.source-document-heading small { + color: var(--muted); + font-size: var(--ui-meta); +} + +.source-document-surface { + flex: 1; + min-height: 0; +} + /* Document chrome ----------------------------------------------------------- */ .document-toolbar { @@ -2250,14 +2297,14 @@ .document-area-action, .document-contents-menu > summary, .document-actions-menu > summary { - width: 32px; - height: 32px; + width: 36px; + height: 36px; display: grid; place-items: center; padding: 0; border: 0; border-radius: var(--ui-radius); - color: var(--muted); + color: var(--ink-soft); background: color-mix(in oklch, var(--paper) 80%, transparent); cursor: pointer; } @@ -2277,8 +2324,19 @@ .document-area-action svg, .document-contents-menu > summary svg, .document-actions-menu > summary svg { - width: 16px; - height: 16px; + width: 20px; + height: 20px; +} + +@media (pointer: coarse) { + .document-toolbar :is(.icon-button, .document-area-action), + .document-toolbar .reader-menu > summary, + .reader-dock .dock-pane-actions .document-toolbar :is(.icon-button, .document-area-action), + .reader-dock .dock-pane-actions .document-toolbar .reader-menu > summary, + .reader-dock .dock-pane-menu > button { + width: 44px; + height: 44px; + } } /* Icon-only visually; the label stays as the accessible name. */ @@ -2439,7 +2497,7 @@ } .mobile-source-views button { min-width: 0; - min-height: 48px; + min-height: var(--mobile-source-control-height); display: grid; place-items: center; align-content: center; @@ -2492,7 +2550,7 @@ .mobile-source-views.is-selection .selection-toolbar > button.icon-button { width: auto; height: auto; - min-height: 48px; + min-height: var(--mobile-source-control-height); display: grid; gap: 3px; padding: 0 4px; @@ -3021,6 +3079,37 @@ text-transform: none; } +.source-removal-details { + display: grid; + gap: 8px; + padding: 0 24px; + color: var(--muted); + font-size: var(--ui-control); + line-height: 1.5; +} + +.source-removal-details p { + margin: 0; +} + +.source-removal-details ul { + max-height: 120px; + margin: 0; + overflow: auto; + padding-left: 18px; + font-family: var(--mono); + font-size: var(--ui-meta); +} + +.source-removal-problem { + color: var(--danger); +} + +.source-removal-dialog .mdbase-button.is-danger { + border-color: var(--danger); + background: var(--danger); +} + .capture-form { display: grid; } @@ -3771,3 +3860,11 @@ text-underline-offset: 2px; cursor: pointer; } + +/* The consent popup's return window, shown while it closes. */ +.reader-grant-callback { + margin: 3rem auto; + max-width: 32rem; + padding: 0 1rem; + text-align: center; +} diff --git a/apps/reader/src/reader-ux-polish.test.tsx b/apps/reader/src/reader-ux-polish.test.tsx new file mode 100644 index 00000000..cb60ace6 --- /dev/null +++ b/apps/reader/src/reader-ux-polish.test.tsx @@ -0,0 +1,84 @@ +import { + collectionId, + fileId, + fileRevision, + sourceId, + type SourceSummary, +} from "@mdbase-reader/core"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ContinueReading } from "./ContinueReading.js"; +import { ReadingStatus, TableValue } from "./LibraryCells.js"; +import { RenderedSourceDocument } from "./RenderedSourceDocument.js"; + +const source: SourceSummary = { + collectionId: collectionId("test"), + id: sourceId("test"), + path: "test.md", + title: "[test] A long source title RENAMED", + creators: [], + tags: [], + reading: { status: "reading", progress: 0 }, + documents: [ + { + fileId: fileId("test"), + file: "[[test.html]]", + mediaType: "text/html", + revision: fileRevision(`sha256:${"a".repeat(64)}`), + role: "primary", + }, + ], +}; + +describe("Reader title and library polish", () => { + it("uses live source metadata for its heading without rewriting saved document text", () => { + const render = (current: SourceSummary): string => + renderToStaticMarkup( + ( +
+

Original saved heading

+

Anchored text

+
+ )} + onSurfaceChange={() => undefined} + />, + ); + expect(render(source)).toContain(`

${source.title}

`); + const renamed = render({ ...source, title: "[test] Newly renamed" }); + expect(renamed).toContain("

[test] Newly renamed

"); + expect(renamed).not.toContain(source.title); + expect(renamed).toContain("

Original saved heading

"); + expect(renamed).toContain("original headings below are unchanged"); + }); + + it("shows zero progress once and exposes the full card title", () => { + const markup = renderToStaticMarkup( + undefined} />, + ); + expect(markup.match(/0%/g)).toHaveLength(1); + expect(markup).toContain(`title="${source.title}"`); + }); + + it("omits an unknown creator rather than inventing metadata", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain("Unknown creator"); + expect(markup).not.toContain(""); + }); + + it("shows reading status, not sharing status", () => { + expect( + renderToStaticMarkup(), + ).toContain("Queued"); + expect( + renderToStaticMarkup( + , + ), + ).toContain("Finished"); + }); +}); diff --git a/apps/reader/src/sdk-application.test.ts b/apps/reader/src/sdk-application.test.ts new file mode 100644 index 00000000..065cb288 --- /dev/null +++ b/apps/reader/src/sdk-application.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment happy-dom +// @vitest-environment-options {"url": "https://reader.mdbase.dev/"} +import { createReaderMdbase } from "@mdbase-reader/connect/native"; +import { afterEach, expect, it, vi } from "vitest"; + +import manifest from "./generated/mdbase-app.json"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it("constructs the live entry on its own origin without IO and leaves the phase to start()", async () => { + // Present but never used: construction must not allocate a Worker or channel. + const worker = vi.fn(() => { + throw new Error("No test Worker allocation"); + }); + const channel = vi.fn(() => { + throw new Error("No test channel allocation"); + }); + vi.stubGlobal("Worker", worker); + vi.stubGlobal("MessageChannel", channel); + const entry = createReaderMdbase({ environment: "lab", manifest }); + expect(entry.getSnapshot().status).toBe("not_started"); + expect(worker).not.toHaveBeenCalled(); + expect(channel).not.toHaveBeenCalled(); + await entry.close(); +}); diff --git a/apps/reader/src/sdk-application.ts b/apps/reader/src/sdk-application.ts new file mode 100644 index 00000000..8bd50db6 --- /dev/null +++ b/apps/reader/src/sdk-application.ts @@ -0,0 +1,15 @@ +import { createReaderMdbase, type Mdbase } from "@mdbase-reader/connect/native"; + +import bundledManifest from "./generated/mdbase-app.json"; + +let entry: Mdbase | null = null; + +/** + * The one mdbase entry for this page, from the bundled canonical manifest. It is + * created on first use (construction is IO-free) and never closed by the UI; + * registration, Worker and storage start on `start()`. + */ +export function readerSdk(): Mdbase { + entry ??= createReaderMdbase({ environment: "lab", manifest: bundledManifest }); + return entry; +} diff --git a/apps/reader/src/sdk-connection-state.test.ts b/apps/reader/src/sdk-connection-state.test.ts new file mode 100644 index 00000000..9a05d2f8 --- /dev/null +++ b/apps/reader/src/sdk-connection-state.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { sdkConnectionState } from "./sdk-connection-state.js"; + +import type { Mdbase } from "@mdbase-reader/connect/native"; + +type Snapshot = ReturnType; +const snapshot = (value: { saveState?: string; kind?: string }): Snapshot => + ({ + writes: value.saveState ? { saveState: value.saveState } : null, + sync: value.kind ? { kind: value.kind } : null, + }) as unknown as Snapshot; + +describe("SDK connection state", () => { + it("never reports connected while offline or waiting to sync", () => { + expect(sdkConnectionState(snapshot({ kind: "offline" }), "connected")).toBe("offline"); + expect( + sdkConnectionState(snapshot({ kind: "offline", saveState: "saved_on_device" }), "connected"), + ).toBe("saved_on_device"); + expect(sdkConnectionState(snapshot({ kind: "synced", saveState: "saved" }), "connected")).toBe( + "connected", + ); + expect(sdkConnectionState(null, "connected")).toBe("connected"); + }); +}); diff --git a/apps/reader/src/sdk-connection-state.ts b/apps/reader/src/sdk-connection-state.ts new file mode 100644 index 00000000..2bde6a0d --- /dev/null +++ b/apps/reader/src/sdk-connection-state.ts @@ -0,0 +1,34 @@ +import { useCallback, useSyncExternalStore } from "react"; + +import type { ReaderConnectionState } from "./ReaderHeader.js"; +import type { Mdbase } from "@mdbase-reader/connect/native"; + +const noop = (): void => undefined; + +/** The header's connection state from the SDK: never "connected" while it is + * offline or keeping changes on this device until it can sync them. */ +export function sdkConnectionState( + snapshot: ReturnType | null, + fallback: ReaderConnectionState, +): ReaderConnectionState { + if (snapshot?.writes?.saveState === "saved_on_device") { + return "saved_on_device"; + } + if (snapshot?.sync?.kind === "offline") { + return "offline"; + } + return fallback; +} + +export function useSdkConnectionState( + mdbase: Mdbase | null, + fallback: ReaderConnectionState, +): ReaderConnectionState { + const subscribe = useCallback( + (listener: () => void) => mdbase?.subscribe(listener) ?? noop, + [mdbase], + ); + return useSyncExternalStore(subscribe, () => + sdkConnectionState(mdbase?.getSnapshot() ?? null, fallback), + ); +} diff --git a/apps/reader/src/use-library-selection.ts b/apps/reader/src/use-library-selection.ts index 2e59d795..5fade9ca 100644 --- a/apps/reader/src/use-library-selection.ts +++ b/apps/reader/src/use-library-selection.ts @@ -15,6 +15,7 @@ import type { AsyncResource } from "./use-reader-workspace.js"; import type { ReaderLibrarySnapshot, ReaderWorkspaceGateway } from "./workspace-model.js"; import type { Source, + SourceDeletionPlan, SourceFileAttachmentRequest, SourceFileImportRequest, SourceId, @@ -49,6 +50,10 @@ export interface LibrarySelection { source: Source, citation: Readonly>, ) => Promise; + /** Present when sources can be removed: checks one, listing the records that link to it. */ + readonly planSourceRemoval?: (id: SourceId) => Promise; + /** Removes a checked source and drops it from the library; rejects with the problem. */ + readonly removeSource?: (plan: SourceDeletionPlan) => Promise; } export interface LibrarySelectionState extends LibrarySelection { @@ -74,17 +79,31 @@ export function useLibrarySelection( status: "loading", }); const [attempt, setAttempt] = useState(0); + // Bumped when another tab, app or device changes the sources. + const [libraryRevision, setLibraryRevision] = useState(0); + const libraryShown = useRef(false); const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId); const [importStatus, setImportStatus] = useState<"idle" | "importing">("idle"); const [importError, setImportError] = useState(null); const selectedSourceIdRef = useRef(initialSourceId); + useEffect( + () => gateway.subscribeLibrary?.(() => setLibraryRevision((value) => value + 1)), + [gateway], + ); + useEffect(() => { const controller = new AbortController(); + // A shown library stays as it is until the reread completes, rather than + // shrinking to the first page. + const progressive = !libraryShown.current; const updateLibrary = (snapshot: ReaderLibrarySnapshot): void => { if (controller.signal.aborted) { return; } + if (snapshot.sourceIndex?.complete !== false) { + libraryShown.current = true; + } setLibrary({ status: "ready", value: snapshot }); const current = selectedSourceIdRef.current; // An incomplete index cannot prove a requested/restored source is absent. @@ -98,7 +117,7 @@ export function useLibrarySelection( .library({ signal: controller.signal, replaceableFamily: "reader-library-load", - onProgress: updateLibrary, + ...(progressive ? { onProgress: updateLibrary } : {}), }) .then(updateLibrary) .catch((reason: unknown) => { @@ -114,7 +133,7 @@ export function useLibrarySelection( window.clearTimeout(timer); controller.abort(); }; - }, [attempt, gateway]); + }, [attempt, gateway, libraryRevision]); const selectedSource = useMemo(() => { if (library.status !== "ready" || !selectedSourceId) { @@ -145,6 +164,12 @@ export function useLibrarySelection( () => sourceWrites(gateway, writeSource, reconcileSource), [gateway, reconcileSource, writeSource], ); + const removal = useSourceRemovalWrites( + gateway, + setLibrary, + setSelectedSourceId, + selectedSourceIdRef, + ); return { library, selectedSource, @@ -153,6 +178,7 @@ export function useLibrarySelection( importStatus, importError, ...writes, + ...removal, selectedSourceId, reconcileSource, }; @@ -204,6 +230,31 @@ function sourceWrites( }; } +/** Source removal, when the collection offers it; a removed source leaves the library. */ +function useSourceRemovalWrites( + gateway: ReaderWorkspaceGateway, + setLibrary: Dispatch>>, + setSelectedSourceId: Dispatch>, + selectedSourceIdRef: RefObject, +): Pick { + const planSourceRemoval = useMemo(() => gateway.planSourceRemoval?.bind(gateway), [gateway]); + const removeSource = useCallback( + async (plan: SourceDeletionPlan): Promise => { + if (!gateway.removeSource) { + throw new Error("This collection cannot remove sources."); + } + await gateway.removeSource(plan); + setLibrary((current) => removeLibrarySource(current, plan.sourceId)); + if (selectedSourceIdRef.current === plan.sourceId) { + selectedSourceIdRef.current = null; + setSelectedSourceId(null); + } + }, + [gateway, selectedSourceIdRef, setLibrary, setSelectedSourceId], + ); + return planSourceRemoval && gateway.removeSource ? { planSourceRemoval, removeSource } : {}; +} + /** Runs one source mutation with shared busy state and errors, then updates the library. */ function useSourceWrite( setLibrary: Dispatch>>, @@ -272,3 +323,15 @@ export function replaceLibrarySource( value: { ...current.value, sources }, }; } + +function removeLibrarySource( + current: AsyncResource, + id: SourceId, +): AsyncResource { + return current.status === "ready" + ? { + status: "ready", + value: { ...current.value, sources: current.value.sources.filter((s) => s.id !== id) }, + } + : current; +} diff --git a/apps/reader/src/use-reader-workspace.ts b/apps/reader/src/use-reader-workspace.ts index 235505be..92832571 100644 --- a/apps/reader/src/use-reader-workspace.ts +++ b/apps/reader/src/use-reader-workspace.ts @@ -77,7 +77,14 @@ export interface ReaderSourceWorkspaceController { export interface ReaderWorkspaceController extends ReaderSourceWorkspaceController, - Pick { + Pick< + LibrarySelection, + | "createSource" + | "attachSourceFile" + | "saveNewSourceCitation" + | "planSourceRemoval" + | "removeSource" + > { readonly library: AsyncResource; readonly selectedSource: SourceSummary | null; readonly reconcileSource: (source: Source) => void; diff --git a/apps/reader/src/use-source-removal.ts b/apps/reader/src/use-source-removal.ts new file mode 100644 index 00000000..5b38ca4b --- /dev/null +++ b/apps/reader/src/use-source-removal.ts @@ -0,0 +1,97 @@ +import { useCallback, useRef, useState } from "react"; + +import { readerErrorMessage } from "./errors.js"; + +import type { SourceDeletionPlan, SourceId, SourceSummary } from "@mdbase-reader/core"; + +export interface PendingSourceRemoval { + readonly source: SourceSummary; + readonly status: "checking" | "confirming" | "removing"; + readonly plan: SourceDeletionPlan | null; + readonly problem: string | null; +} + +export interface SourceRemovalController { + /** Asks to remove a source; null when this collection cannot remove sources. */ + readonly request: ((source: SourceSummary) => void) | null; + readonly pending: PendingSourceRemoval | null; + readonly cancel: () => void; + readonly confirm: () => void; +} + +/** + * Removing a source cannot be undone from Reader, so it always takes two steps: a check of the + * source at its current revision, which lists the records linking to it, and then the reader's + * confirmation. Cancelling at any point before confirming writes nothing. + */ +export function useSourceRemoval( + writes: { + readonly planSourceRemoval?: ((id: SourceId) => Promise) | undefined; + readonly removeSource?: ((plan: SourceDeletionPlan) => Promise) | undefined; + }, + onRemoved: (id: SourceId) => void, +): SourceRemovalController { + const { planSourceRemoval: plan, removeSource: remove } = writes; + const [pending, setPending] = useState(null); + // Each request gets a number, so a check that finishes after a cancel is ignored. + const attempt = useRef(0); + const request = useCallback( + (source: SourceSummary): void => { + if (!plan) { + return; + } + const current = ++attempt.current; + setPending({ source, status: "checking", plan: null, problem: null }); + void plan(source.id).then( + (checked) => { + if (attempt.current === current) { + setPending({ source, status: "confirming", plan: checked, problem: null }); + } + }, + (reason: unknown) => { + if (attempt.current === current) { + setPending({ + source, + status: "confirming", + plan: null, + problem: readerErrorMessage(reason, "Reader could not check this source."), + }); + } + }, + ); + }, + [plan], + ); + const cancel = useCallback((): void => { + attempt.current += 1; + setPending((current) => (current?.status === "removing" ? current : null)); + }, []); + const confirm = (): void => { + if (!remove || pending?.status !== "confirming" || !pending.plan) { + return; + } + const { source, plan: checked } = pending; + const current = ++attempt.current; + setPending({ ...pending, status: "removing", problem: null }); + void remove(checked).then( + () => { + if (attempt.current === current) { + setPending(null); + } + onRemoved(source.id); + }, + (reason: unknown) => { + if (attempt.current === current) { + // The check is spent; asking again checks the source afresh. + setPending({ + source, + status: "confirming", + plan: null, + problem: readerErrorMessage(reason, "Reader could not remove this source."), + }); + } + }, + ); + }; + return { request: plan && remove ? request : null, pending, cancel, confirm }; +} diff --git a/apps/reader/src/workspace-model.ts b/apps/reader/src/workspace-model.ts index 2f971ef8..1201686f 100644 --- a/apps/reader/src/workspace-model.ts +++ b/apps/reader/src/workspace-model.ts @@ -15,6 +15,7 @@ import type { ReadingPosition, ReadingStatus, Source, + SourceDeletionPlan, SourceId, SourceSummary, SourceFileAttachmentRequest, @@ -43,6 +44,8 @@ export interface ReaderLibraryRequestOptions extends ReaderRequestOptions { export interface ReaderWorkspaceGateway { library(options?: ReaderLibraryRequestOptions): Promise; + /** Notifies when sources changed outside this workspace, so the library reloads. */ + subscribeLibrary?: (listener: () => void) => () => void; listLibraryViews(options?: ReaderRequestOptions): Promise; executeLibraryView( view: MdbaseLibraryView, @@ -119,6 +122,10 @@ export interface ReaderWorkspaceGateway { ): Promise; /** Moves a source through its reading lifecycle, e.g. from queued to finished. */ saveReadingStatus?(sourceId: SourceId, status: ReadingStatus): Promise; + /** Checks removing a source at its current revision; the plan lists what links to it. */ + planSourceRemoval?(id: SourceId): Promise; + /** Removes the checked source record. Its files and annotations stay in the collection. */ + removeSource?(plan: SourceDeletionPlan): Promise; /** Sets frontmatter fields by dotted path; null removes a field. */ saveSourceFields?(sourceId: SourceId, fields: Readonly>): Promise; } diff --git a/apps/reader/vite.config.ts b/apps/reader/vite.config.ts index 9b3cd79a..649f6c44 100644 --- a/apps/reader/vite.config.ts +++ b/apps/reader/vite.config.ts @@ -1,3 +1,4 @@ +import { offlineShellPlugin } from "@mdbase-dev/sdk/offline-shell/build"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; @@ -8,8 +9,14 @@ const buildId = (process.env["MDBASE_READER_BUILD_ID"] ?? "local").replaceAll( "-", ); +const buildOutput = { + entryFileNames: `assets/[name]-[hash]-${buildId}.js`, + chunkFileNames: `assets/[name]-[hash]-${buildId}.js`, + assetFileNames: `assets/[name]-[hash]-${buildId}.[ext]`, +}; + export default defineConfig({ - plugins: [react(), deploymentRevision(buildId)], + plugins: [react(), deploymentRevision(buildId), offlineShellPlugin()], resolve: { dedupe: [ "react", @@ -23,15 +30,16 @@ export default defineConfig({ "@codemirror/view", ], }, + // The SDK starts its own module Worker from a module-relative URL; keep it out of + // dev pre-bundling so that URL still names the SDK's files, and emit ES workers. + optimizeDeps: { exclude: ["@mdbase-dev/sdk"] }, + // Workers share the build-tagged names, so the SDK's native assets are emitted once. + worker: { format: "es", rolldownOptions: { output: buildOutput } }, build: { sourcemap: true, target: "es2022", rolldownOptions: { - output: { - entryFileNames: `assets/[name]-[hash]-${buildId}.js`, - chunkFileNames: `assets/[name]-[hash]-${buildId}.js`, - assetFileNames: `assets/[name]-[hash]-${buildId}.[ext]`, - }, + output: buildOutput, }, }, }); diff --git a/docs/deployment-reports/2026-10-15-lab-9a033b25.md b/docs/deployment-reports/2026-10-15-lab-9a033b25.md new file mode 100644 index 00000000..ed089c5b --- /dev/null +++ b/docs/deployment-reports/2026-10-15-lab-9a033b25.md @@ -0,0 +1,10 @@ +# Reader LAB: corrected atomic declared setup + +- Application source: `914cf1d`; SDK and Native source: `9a033b252860ec8624da0abb5ffdecbac56332d2`. +- Native LAB-feature binary SHA256: `af02282cf8ce755752f73c67d0f337351ce6b2f7bcd4e2e95bb2caffaaca5ed8`; original owned profile/account preserved. +- Guarded LAB deployment: `9291a4eb`, command exit 0. Loaded SDK root matched the exact build. +- Source gates: recursive remote typecheck/tests passed, including 441 Reader tests. +- **PASS:** original stored grant restored without another approval; one setup apply reached the workspace; disposable HTML source created; literature note edited and confirmed Saved; one exact edited Markdown file observed in the owned Native collection. +- **PASS after reload:** explicitly opening the original source returned the exact edited note. The initial harness expected a focused source immediately, but restored tabs had no focused source; the original read was reconciled without repeating setup or editing. +- Owned application page closed. Independent empty-profile read and process-crash durability were not tested here. +- No production/staging deployment, manifest widening, unsigned administration or Hosted fallback. diff --git a/docs/deployment-reports/2026-10-15-lab-d3559aaa.md b/docs/deployment-reports/2026-10-15-lab-d3559aaa.md new file mode 100644 index 00000000..e99f5f5a --- /dev/null +++ b/docs/deployment-reports/2026-10-15-lab-d3559aaa.md @@ -0,0 +1,10 @@ +# Reader LAB: initial declared-setup intake + +- Application source: `c986377`; SDK: `d3559aaacbffc4177929e93f9fc8649df835c14c`. +- Guarded deployment: `385bb4a1`, LAB branch only; deployment command exited 0. +- Source gates: recursive remote typecheck/tests passed, including 441 Reader tests. +- Live acceptance: exact built SDK root loaded; fresh disposable Private collection became ready; the original approval completed once. +- Result: **FAIL before setup**. The original page reported `collection_setup_conflict`; no setup apply, source creation or edit was attempted. +- Root cause reproduced with the real public declaration: two packs emitted duplicate writes of their shared lock with successive-overlay guards. Fix landed in Native/Core `9a033b252860ec8624da0abb5ffdecbac56332d2`. +- Original attempt retained for reconciliation; no approval or uncertain write was replayed. Corrected-code requalification is separate evidence. +- No production/staging deployment, manifest widening or Hosted fallback. diff --git a/docs/development.md b/docs/development.md index 1df7243e..97a33f49 100644 --- a/docs/development.md +++ b/docs/development.md @@ -190,6 +190,9 @@ while retaining the desktop arrangement. Reader limits resident document rendere Further implementation and validation notes: +- [Native SDK preparation](new-sdk.md): the separate read-only source/annotation + foundation, artifact pin, and blocked shared session/setup/files integration. + - [Source workspace architecture](architecture/source-workspace.md): session, persistence, and renderer-lifetime boundaries. - [Interface shell](interface-shell.md): header, menus, command palette, and stylesheet ownership. diff --git a/docs/new-sdk.md b/docs/new-sdk.md new file mode 100644 index 00000000..578b1b38 --- /dev/null +++ b/docs/new-sdk.md @@ -0,0 +1,167 @@ +# Reader on the new mdbase SDK + +## Current declared Private setup SDK intake — 2026-10-15 + +SDK `9a033b252860ec8624da0abb5ffdecbac56332d2` replaces the prior +`d3559aaa` intake. Native at this source compacts the shared lock from Reader's +two declared packs into one atomic write with the original CAS guard; no +manifest or permission changes. The SDK preserves actionable setup-conflict +messages in every consuming app. Original archive SHA256: +`90ef67523dfa11cb36efe0d996475f1a1c82ac71074e4cd4e95c6fbe02c18be9`. +Native owns bounded declaration-bound setup and atomic starter publication; +the SDK retains only the protected original MID for uncertain-result reconciliation. +No unsigned administration, widened manifest or Hosted fallback is introduced. +The packaged runtime is source `a4331ad466a0d16e87ab3bedeb0963a1e0d1867e`, +SHA256 `3c803e83f81a05855f6e30ca79036d6b19383048a6b11ba5c9a0b5ebf3a1bbe2`. +The ordinary Sign in / Add chooser owns one popup and the original Native +preparation, callback and custody ACK. The callback page delivers only; the +original SDK closes after completion. No app-side alternate protocol, scope, +key transfer or grant-store migration is introduced. Application declaration and +storage identities remain unchanged. Tests and LAB builds do not prove Native +admission, READ/edit/reload or production release qualification. + +## Prior intake notes (superseded SDK pin) + + +Reader is being ported from prod Connect (`@mdbase-dev/connect`) to the public application +entry of the new SDK (`@mdbase-dev/sdk`, `MdbaseConnect`). + +## Pin + +`packages/connect/vendor/mdbase-dev-sdk-dev-3f417b6d.tgz` is the original +`sdk-dev-3f417b6d` cut from SDK source +`3f417b6d941a42444f17568d7489071d23fcb141`, SHA256 +`0c5f04c6adad67d747055ebe12963403e374aefde0999861d759f35e4e118ba6` (4346749 bytes). +Its source, SRI, native pins and original bundled protocol are recorded in +`packages/connect/vendor/mdbase-next-sdk.json`. The SDK bundles beta129 +`c280cd194ffd`; the direct legacy protocol dependency remains `d765e675c3da` unchanged. + +This is a **DEV/LAB development intake, not release-qualified**. It includes the +public error/status helpers and portable/native-origin sign-in APIs in one pin. +The SDK owns registration, authorization, selection, readiness, Worker, runtime +and recovery. The original 3555291-byte native runtime matches the committed +`9f90af73e1591cafe9c35bdd42f18ae49d5a91a0186e78695e841fecabcd833a` pin, +including the public setup-assessment ABI and owner-binding fixes. +This cut delivers registration-refusal guidance, Private-collection availability +reasons and collection deletion APIs. The earlier unused-protocol exception is +not carried forward; this archive has no vendor warnings. Packaged integrity and +ordinary/frozen installation do not establish live LAB acceptance. + +The current cut adds SDK-owned lazy Worker startup and pairing only from a +sign-in gesture, native-return opening, and refused/expired sign-in recovery. +Refusal copy and the enabled Sign in action come from the SDK; clicking invokes +`approve()`, including while authorizing. No app-side pairing or renewal engine +is introduced. Writer's optional file-access consent remains a later SDK capability. + +## Design + +- Apps reach the SDK only through `@mdbase-reader/connect`. The native adapter is the + `@mdbase-reader/connect/native` subpath. Staging and production Reader web builds retain + legacy Connect. The extension loads the SDK only in its offscreen document, not in its + service worker or page-status bundle. +- `nativeReaderCollection(connection)` builds Reader's existing repositories over the + connection's borrowed `records` client and `files`: + - `native-client.ts` maps Reader's Connect-shaped reads, queries and writes onto native + `get`/`find`/`query`/`pages`/`queryContract` and `create`/`update`/`replaceDocument`/`delete`. + - Contract-scoped reads and writes use `getContract` and the contract options of + `create`/`update`/`delete`. As in prod Connect, a contract view carries contract fields and + no body. A contract write is read back through the same contract view. + - File descriptors take their media type from the replica (`FileView.mediaType`). + - Writes keep one mutation ID. A write the replica captured is saved: confirmed, or saved on + this device when the log hasn't confirmed it within 3 s. The SDK's write ledger keeps and + settles those across reloads, so Reader keeps no pending-write list of its own. Only a + refused write fails. An unknown outcome surfaces `operation_outcome_unknown` with the + original `request_id`, which can be recovered. + - `native-files.ts` resumes uploads with the same transfer and mutation IDs and checks + digests on download. + - Every await is fenced by `connection.isCurrent()`. +- Unsupported features are refused (`unsupported_operation`), never emulated. See the gap list + in the coordinator's `reader-sdk-gaps.md`. +- `SdkReaderRoot` renders the shared `CollectionGate` from `@mdbase-dev/ui/collections` + (vendored as `vendor/mdbase-dev-ui-dev-.tgz`, pinned in `vendor/mdbase-ui-dev.json`): + - The gate owns sign-in, choosing, approval, setup review, problems and sign-out + (`signOut()`, never `close()`). Reader supplies only its name and mark. + - It starts the entry once (StrictMode-safe); the entry follows page visibility. + - It renders the reading workspace only for the connection the gate hands over in + phase `ready`, and disposes its adapters, never the borrowed client. + - The header shows the shared `CollectionSwitcher`; unsaved edits are confirmed + before switching. Legacy `ConnectReader` keeps its own `CollectionPicker`. + - `apps/reader` depends on the same SDK cut as `packages/connect`, so app-ui's SDK + peer resolves to it. The legacy native sign-in imports the release SDK819 archive + under the alias `@mdbase-dev/sdk-legacy-729b5391`. + +## Live hookup + +- `src/sdk-application.ts`: `readerSdk()` is the page's single `createMdbase` entry. It uses the + bundled canonical manifest unchanged: logical ID `dev.mdbase.reader`, name, and homepage + origin. The SDK registers it and rebinds to the actual registered ID before any storage. + Construction does no IO. +- `src/main.tsx`: builds with `VITE_MDBASE_ENV=lab` (the LAB Pages deployment) lazily load + `SdkReaderRoot` over that entry. Staging and production still render `ConnectReader` until + the SDK has production release trust. +- `vite.config.ts` excludes `@mdbase-dev/sdk` from dev pre-bundling, emits ES-module workers, + and gives worker bundles the same build-tagged file names, so the SDK Worker, + `runtime-lab.wasm`, `sqlite3.wasm` and `sqlite3.js` are each emitted once. The SDK stays out of + the eager entry chunks. + +The previous production build passed at `38001079`; n1 checks are recorded +separately from live LAB acceptance. +Live LAB registration, Worker start and readiness have not been exercised. Old local data and +pending writes need no cutover handoff. + +## Extension SDK owner + +The extension runs one SDK entry in an on-demand offscreen document. The service worker +creates that document; extension pages and the worker use a same-origin BroadcastChannel +with explicit public application and Reader repository commands. Repository results are +structured clones of the host's typed values. Callers never close the SDK owner. + +Calls retain their original host generation. Already-aborted calls do not dispatch; +caller disposal and host replacement settle outstanding calls without retrying mutations. +The last SDK snapshot is retained unchanged during restart, with transport availability +reported separately. Uncertain body writes go through SDK recovery of the original request +and readback, never a fresh mutation. Saved-page marks and highlights work without an open +panel. Public SDK-fixture tests are not real-Chrome, authenticated or durability acceptance; +those checks remain pending an isolated LAB lease. + +## Library imports in SDK mode + +LAB `/import`, `/import/zotero` and `/import/readwise` use the same public SDK +entry and original native collection as the reading workspace. The import home +shows the existing tiles without starting sign-in. Service pages keep the existing +inputs, preview, confirmation and progress UI, with embedded SDK destination +actions instead of loading the legacy `readerSession`. Staging and production +retain their existing import session and UI. + +The app import controller retains the parsed input while the SDK chooses a +destination. Its narrow domain provider returns only the actual collection whose +original connection and aggregate SDK readiness are current. The existing native +migration target owns record/file operations through the public SDK, with original +identities, receipt recovery and confirmed readback unchanged. A destination check +is still required before import, and a started import stays bound to its confirmed +collection. Retired scopes stop the job; late checks, progress and completions +cannot become confirmation for a replacement scope. No SDK session, storage, +setup or recovery engine is supplied by this UI. + +## Launch-branch integration + +`next/new-sdk` brings together the public-entry/import line and the shared collection +screens. Setup review, problems and live notices (migration, update requirements, +account suspension) come from `@mdbase-dev/ui/collections` over the SDK's phase and +`describeError` copy, without raw or developer-only diagnostics. + +The older next-branch record-read foundation, provider rechecks and optional +native sign-in/owned-popup controls are preserved, not substituted for the LAB +public entry. Historical SDK819 artifacts are not the active dependency. + +The source-qualified Chrome Store identity is +`chrome-extension://kimdfjefhbfgfecconmaiaaindjccidp`, documented in +`docs/extension-releases.md` at `7ea5dded5b8d196763d4565224502c9840b2ced3`. +This is distinct from unpacked development installs; it does not prove SDK +extension acceptance or effective CORS configuration. + +## Checks + +The adapter, recovery and lifecycle tests run over the SDK's `MemoryReplica` and application +fixture (`@mdbase-reader/connect/testing`). They do not qualify native runtime, registration or +end-to-end LAB operation. diff --git a/docs/offline-shell.md b/docs/offline-shell.md new file mode 100644 index 00000000..5afa9c35 --- /dev/null +++ b/docs/offline-shell.md @@ -0,0 +1,11 @@ +# Offline app shell + +The production build uses `@mdbase-dev/sdk/offline-shell/build` to precache the +public app shell, scripts, lazy chunks, fonts and packaged WASM as one release. +A previously opened app can reload its shell without connectivity. This does +not grant collection access or make uncached files and remote services available +offline; collection recovery and sync remain the SDK's responsibility. + +Updates do not reload open tabs. An incomplete deployment does not replace the +last successfully cached release. Shell caching never stores authentication or +collection API responses. diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md new file mode 100644 index 00000000..a90a1fa8 --- /dev/null +++ b/docs/releases/unreleased.md @@ -0,0 +1,3 @@ +# Unreleased + +- Keep the optional install prompt above the phone's Read, Annotations, Note and Citation controls. diff --git a/packages/connect/package.json b/packages/connect/package.json index f8f79e44..7b2cc259 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -4,7 +4,9 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./native": "./src/native/index.ts", + "./testing": "./src/native/testing.ts" }, "scripts": { "test": "vitest run --passWithNoTests", @@ -12,6 +14,7 @@ }, "dependencies": { "@mdbase-dev/connect": "0.1.0-beta.125", + "@mdbase-dev/sdk": "file:vendor/mdbase-dev-sdk-dev-9a033b25.tgz", "@mdbase-reader/core": "workspace:*", "@mdbase-reader/migration": "workspace:*", "yaml": "^2.9.0" diff --git a/packages/connect/src/annotation-repository.ts b/packages/connect/src/annotation-repository.ts index 82a9de6b..300759d9 100644 --- a/packages/connect/src/annotation-repository.ts +++ b/packages/connect/src/annotation-repository.ts @@ -180,15 +180,19 @@ export class ConnectAnnotationRepository implements AnnotationRepository { ); } - async create(annotation: Annotation, _idempotencyKey: MutationId): Promise { + async create(annotation: Annotation, idempotencyKey: MutationId): Promise { const result = outcomeValue( - await this.client.create({ - path: `annotations/${annotation.id}.md`, - type: "reader-annotation", - frontmatter: annotationFrontmatter(annotation), - body: annotation.body, - includeDocument: true, - }), + await this.client.create( + { + path: `annotations/${annotation.id}.md`, + type: "reader-annotation", + frontmatter: annotationFrontmatter(annotation), + body: annotation.body, + includeDocument: true, + }, + // A retried save keeps its mutation ID, so it lands once instead of colliding. + { intent: idempotencyKey }, + ), "create annotation", ); this.#records.put(result); diff --git a/packages/connect/src/application-session.ts b/packages/connect/src/application-session.ts index afe2b415..0f1f0061 100644 --- a/packages/connect/src/application-session.ts +++ b/packages/connect/src/application-session.ts @@ -39,6 +39,8 @@ import { } from "./repositories.js"; import { connectSourceImportRepository } from "./source-imports.js"; +import type { MigrationTarget } from "@mdbase-reader/migration"; + export type ReaderConnectSnapshot = MdbaseApplicationSessionSnapshot; export interface ReaderDirectAccessSnapshot { @@ -47,6 +49,11 @@ export interface ReaderDirectAccessSnapshot { readonly status: DirectAccessStatus; } +/** Anything that can hand Reader its currently opened collection. */ +export interface ReaderCollectionProvider { + connectedCollection(): ReaderConnectedCollection | null; +} + export interface ReaderDirectAccessController { readonly getSnapshot: () => ReaderDirectAccessSnapshot | null; readonly subscribe: (listener: () => void) => () => void; @@ -60,14 +67,21 @@ export interface ReaderConnectedCollection { readonly collectionName: string; readonly sources: SourceRepository; readonly sourceImports: SourceImportRepository; - readonly migration: ConnectMigrationTarget; + readonly migration: MigrationTarget; readonly annotations: AnnotationRepository; readonly annotationAssets: AnnotationAssetRepository; readonly documents: DocumentRepository; readonly contentSearch: ContentSearchRepository; readonly files: CollectionFileRepository; readonly libraryViews: LibraryViewRepository; - readonly directAccess: ReaderDirectAccessController; + /** + * Calls `listener` when the source list may have changed outside this + * workspace's own writes: another tab, app or device added, edited or + * removed a source, or the change feed reset. Absent for Connect collections. + */ + readonly subscribeSourceChanges?: (listener: () => void) => () => void; + /** Connect loopback upgrade; absent for native SDK collections. */ + readonly directAccess?: ReaderDirectAccessController; readonly bodyRecovery: BodyUpdateRecovery; } diff --git a/packages/connect/src/body-update-recovery.test.ts b/packages/connect/src/body-update-recovery.test.ts index 7cb6bc8f..5738c77b 100644 --- a/packages/connect/src/body-update-recovery.test.ts +++ b/packages/connect/src/body-update-recovery.test.ts @@ -25,7 +25,7 @@ function connection(outcome: ConnectOutcome): { requestId === "lost" ? { requestId, operation: "update", recover } : null, ); return { - recovery: new ConnectBodyUpdateRecovery({ pendingMutation } as never), + recovery: new ConnectBodyUpdateRecovery({ pendingMutation }), recover, }; } diff --git a/packages/connect/src/body-update-recovery.ts b/packages/connect/src/body-update-recovery.ts index 52396271..2c3f1ed4 100644 --- a/packages/connect/src/body-update-recovery.ts +++ b/packages/connect/src/body-update-recovery.ts @@ -1,12 +1,17 @@ import { annotationFromDocument, sourceFromDocument } from "./mapping.js"; import { outcomeValue } from "./repository-client.js"; -import type { MdbaseConnection, RecordDocument } from "@mdbase-dev/connect"; +import type { ConnectOutcome, RecordDocument } from "@mdbase-dev/connect"; import type { Annotation, BodyUpdateRecovery, RecoverBodyInput, Source } from "@mdbase-reader/core"; +/** The original interrupted write, by request ID: a Connect connection or the native client. */ +export interface ReaderPendingMutations { + pendingMutation(requestId: string): { recover(): Promise> } | null; +} + /** Recovers interrupted body writes through the connection's durable pending mutations. */ export class ConnectBodyUpdateRecovery implements BodyUpdateRecovery { - constructor(private readonly connection: Pick) {} + constructor(private readonly connection: ReaderPendingMutations) {} async recoverSource(input: RecoverBodyInput): Promise { return sourceFromDocument(input.collectionId, await this.#recover(input.requestId)); @@ -21,7 +26,7 @@ export class ConnectBodyUpdateRecovery implements BodyUpdateRecovery { } async #recover(requestId: string): Promise { - const pending = this.connection.pendingMutation(requestId); + const pending = this.connection.pendingMutation(requestId); if (!pending) { throw new Error("The interrupted write is no longer pending. No new write was attempted."); } diff --git a/packages/connect/src/collection-reuse.test.ts b/packages/connect/src/collection-reuse.test.ts index d8ba1fe7..d37d0170 100644 --- a/packages/connect/src/collection-reuse.test.ts +++ b/packages/connect/src/collection-reuse.test.ts @@ -42,6 +42,9 @@ it("reuses repositories only for the same ready connection, without stale transp } as unknown as MdbaseConnection), ).not.toBe(first); const access = first.directAccess; + if (!access) { + throw new Error("Connect collections expose direct access."); + } expect(access.getSnapshot()).toBe(access.getSnapshot()); const a = vi.fn(), b = vi.fn(); diff --git a/packages/connect/src/index.ts b/packages/connect/src/index.ts index a399aaf1..0f1d3f55 100644 --- a/packages/connect/src/index.ts +++ b/packages/connect/src/index.ts @@ -8,6 +8,7 @@ export { readerDiagnostics, ReaderDiagnostics } from "./diagnostics.js"; export type { ReaderTiming } from "./diagnostics.js"; export * from "./library-views.js"; export * from "./mapping.js"; +export * from "./next-reader-records.js"; export * from "./portable-application-session.js"; export * from "./record-session.js"; export * from "./repositories.js"; diff --git a/packages/connect/src/native/index.ts b/packages/connect/src/native/index.ts new file mode 100644 index 00000000..54bed710 --- /dev/null +++ b/packages/connect/src/native/index.ts @@ -0,0 +1,41 @@ +import { createMdbase, type Mdbase, type MdbaseApplicationDeclaration } from "@mdbase-dev/sdk"; + +export { MdbaseConnect, describeError, isMdbaseError, summarizeStatus } from "@mdbase-dev/sdk"; + +export { nativeReaderCollection } from "./native-collection.js"; +export type { NativeReaderCollection } from "./native-collection.js"; +export type { + MdbaseApplication, + MdbaseApplicationConnection, + MdbaseApplicationDeclaration, + MdbaseApplicationEntry, + MdbaseApplicationSnapshot, + MdbaseApplicationSelectionSnapshot, + MdbaseErrorDescription, + MdbaseConnectOptions, + MdbaseSignInOpener, + MdbaseSetupPlan, + Mdbase, + MdbaseSnapshot, +} from "@mdbase-dev/sdk"; + +/** Reader's mdbase entry (`createMdbase`) from the unchanged canonical manifest + * (logical ID, name, homepage origin) and the deployment environment. The SDK + * registers the manifest and rebinds to the actual registered application ID before + * any storage; sign-in, consent, selection and setup belong to the SDK. No storage, + * runtime or trust overrides are passed. Construction performs no IO. */ +export function createReaderMdbase(input: { + readonly environment: MdbaseApplicationDeclaration["environment"]; + readonly manifest: { readonly id: string; readonly name: string; readonly homepage: string }; +}): Mdbase { + return createMdbase( + { + id: input.manifest.id, + name: input.manifest.name, + origin: new URL(input.manifest.homepage).origin, + environment: input.environment, + manifest: input.manifest, + }, + { followVisibility: true }, + ); +} diff --git a/packages/connect/src/native/native-client.test.ts b/packages/connect/src/native/native-client.test.ts new file mode 100644 index 00000000..4d7251f4 --- /dev/null +++ b/packages/connect/src/native/native-client.test.ts @@ -0,0 +1,176 @@ +import { connect, type MdbaseClient } from "@mdbase-dev/sdk"; +import { + MemoryReplica, + type MemoryContract, + type MemoryReplicaOptions, +} from "@mdbase-dev/sdk/testing"; +import { afterEach, describe, expect, it } from "vitest"; + +import { sourceContract } from "../contracts.js"; +import { outcomeValue } from "../repository-client.js"; + +import { nativeReaderClient, type NativeReaderClient } from "./native-client.js"; + +const memorySourceContract: MemoryContract = { + ...sourceContract, + type: "reader-source", + fields: { id: "id", title: "title", kind: "kind", saved_at: "saved_at" }, +}; + +const clients: MdbaseClient[] = []; +afterEach(() => { + for (const client of clients.splice(0)) { + client.close(); + } +}); + +async function native( + options: MemoryReplicaOptions = {}, + receiptWaitMs?: number, +): Promise<{ replica: MemoryReplica; client: NativeReaderClient }> { + const replica = new MemoryReplica({ confirmDelayMs: 0, ...options }); + const sdk = await connect({ + connector: replica.connector(), + app: { name: "reader-native-test", version: "test" }, + reconnect: false, + }); + clients.push(sdk); + return { + replica, + client: nativeReaderClient(sdk, () => true, receiptWaitMs ? { receiptWaitMs } : {}), + }; +} + +describe("native Reader client", () => { + it("pages every matching record through native cursors as Reader query pages", async () => { + const { replica, client } = await native(); + for (const index of ["0", "1", "2", "3", "4"]) { + replica.seed({ + path: `sources/s${index}.md`, + types: ["reader-source"], + frontmatter: { id: `s${index}`, title: `Source ${index}` }, + }); + } + replica.seed({ path: "notes/other.md", types: ["note"], frontmatter: { id: "n" } }); + + const pages = []; + for await (const outcome of client.queryPages( + { types: ["reader-source"], frontmatterMode: "effective" }, + { pageSize: 2 }, + )) { + pages.push(outcomeValue(outcome, "query")); + } + + expect(pages.map((page) => page.results.length)).toEqual([2, 2, 1]); + expect(pages.at(-1)).toMatchObject({ complete: true, loaded: 5 }); + expect( + pages.flatMap((page) => page.results.map((row) => row.effectiveFrontmatter?.["id"])), + ).toEqual(["s0", "s1", "s2", "s3", "s4"]); + expect(pages[0]?.results[0]).toMatchObject({ + path: "sources/s0.md", + file: { path: "sources/s0.md", name: "s0", folder: "sources" }, + }); + }); + + it("reports a CAS write saved only from the confirmed readback, removing null fields", async () => { + const { replica, client } = await native(); + replica.seed({ + path: "sources/a.md", + types: ["reader-source"], + frontmatter: { id: "a", title: "Old", note: "drop me" }, + body: "old body", + }); + const read = outcomeValue(await client.read({ path: "sources/a.md" }), "read"); + + const saved = outcomeValue( + await client.update({ + path: "sources/a.md", + ifRevision: read.revision, + patch: { title: "New", note: null }, + body: "new body", + }), + "update", + ); + + expect(saved.effectiveFrontmatter).toEqual({ id: "a", title: "New" }); + expect(saved.body).toBe("new body"); + expect(saved.revision).not.toBe(read.revision); + + const stale = await client.update({ + path: "sources/a.md", + ifRevision: read.revision, + patch: { title: "Stale" }, + }); + expect(stale.ok ? null : stale.problem.code).toBe("concurrent_modification"); + }); + + it("lands a create retried under the same intent once, keeping the first attempt", async () => { + const { replica, client } = await native(); + const create = (body: string): ReturnType => + client.create( + { path: "annotations/a.md", type: "reader-annotation", frontmatter: { id: "a" }, body }, + { intent: "save-a" }, + ); + + const first = outcomeValue(await create("first"), "create"); + const retried = outcomeValue(await create("retried"), "retried create"); + + expect(retried.body).toBe("first"); + expect(replica.allRecords.filter((record) => record.path === "annotations/a.md")).toHaveLength( + 1, + ); + expect(first.path).toBe("annotations/a.md"); + }); + + it("reports a write the log has not confirmed as saved on this device, with nothing to recover", async () => { + const { replica, client } = await native({ confirmDelayMs: null }, 50); + replica.seed({ path: "notes/a.md", types: ["reader-annotation"], frontmatter: { id: "a" } }); + + const saved = outcomeValue( + await client.update({ path: "notes/a.md", patch: {}, body: "kept" }), + "update", + ); + + expect(saved.body).toBe("kept"); + replica.confirmAll(); + expect(replica.allRecords.find((record) => record.path === "notes/a.md")?.body).toBe("kept"); + }); + + it("reads and updates a source through its contract view, reading back the contract view", async () => { + const { replica, client } = await native({ contracts: [memorySourceContract] }); + replica.seed({ + path: "sources/c.md", + types: ["reader-source"], + frontmatter: { + type: "reader-source", + id: "c", + title: "Old", + kind: "web", + saved_at: "2026-10-11T00:00:00Z", + }, + body: "kept body", + }); + + const read = outcomeValue( + await client.read({ path: "sources/c.md", contract: sourceContract }), + "contract read", + ); + expect(read.effectiveFrontmatter["title"]).toBe("Old"); + expect(read.body).toBeUndefined(); + + const saved = outcomeValue( + await client.update({ + path: "sources/c.md", + contract: sourceContract, + ifRevision: read.revision, + patch: { title: "New" }, + }), + "contract update", + ); + expect(saved.effectiveFrontmatter["title"]).toBe("New"); + expect(saved.contract?.id).toBe(sourceContract.id); + expect(replica.allRecords.find((record) => record.path === "sources/c.md")?.body).toBe( + "kept body", + ); + }); +}); diff --git a/packages/connect/src/native/native-client.ts b/packages/connect/src/native/native-client.ts new file mode 100644 index 00000000..0c63f732 --- /dev/null +++ b/packages/connect/src/native/native-client.ts @@ -0,0 +1,693 @@ +import { connectSuccess } from "@mdbase-dev/connect/advanced"; +import { isMdbaseError, MdbaseError, uuidv7 } from "@mdbase-dev/sdk"; + +import { + contractDocument, + failure, + fileMetadata, + metadataRecord, + nativeQuery, + outcome, + OutcomeUnknown, + plain, + queryRecord, + recordDocument, + resultMeta, + retired, + uniquePaths, + unsupported, +} from "./native-mapping.js"; + +import type { + ReaderConnectClient, + ReaderCreateOptions, + ReaderQueryPagesOptions, +} from "../repository-client.js"; +import type { + ConnectOutcome, + CreateInput, + DeleteInput, + DeletePreflightResult, + DeleteProgressOptions, + DeleteResult, + QueryInput, + QueryMetadataInput, + QueryMetadataPage, + QueryPage, + QueryRecord, + QueryResult, + ReadInput, + ReadManyOptions, + ReadManyResult, + RecordDocument, + UpdateInput, +} from "@mdbase-dev/connect"; +import type { + ContractQueryRecord, + ContractSelector, + Include, + MdbaseClient, + PlainValue, + QueryResult as NativeQueryResult, + RecordView, + Write, +} from "@mdbase-dev/sdk"; +import type { ReaderRequestOptions } from "@mdbase-reader/core"; + +/** The log refused the write: a plain failure, nothing to recover. */ +class Refused extends MdbaseError {} + +/** One native write, kept by ID while its outcome is unknown. */ +interface NativeMutation { + readonly mutationId: string; + readonly operation: "create" | "update" | "delete"; + /** The record's ID, or the path of a create whose ID the SDK derived from its intent. */ + readonly record: string | { readonly path: string }; + /** Contract writes are read back through the same contract view. */ + readonly contract?: ContractSelector; +} + +/** The recovery handle Reader's body/migration recovery needs, by original request ID. */ +export interface NativePendingMutation { + recover(options?: { readonly signal?: AbortSignal }): Promise>; +} + +export interface NativeReaderClient extends ReaderConnectClient { + /** The original native mutation for an `operation_outcome_unknown` request ID. */ + pendingMutation(requestId: string): NativePendingMutation | null; + dispose(): void; +} + +interface ContractPage { + readonly rows: QueryRecord[]; + readonly cursor?: string; + readonly totalCount?: number; +} + +type DataContract = NonNullable; +/** A write either takes a minted mutation ID or names its intent; the SDK derives the rest. */ +type Submit = ( + options: ({ mutationId: string } | { intent: string }) & { signal: AbortSignal }, +) => Promise; +type WriteTarget = Omit & { readonly intent?: string }; + +const MAX_CONTRACT_PAGE = 128; +const DEFAULT_PAGE = 200; +/** How long a save waits for the log before it counts as saved on this device (as `client.save()`). */ +const RECEIPT_WAIT_MS = 3_000; +const REQUEST_BUDGET_MS = 45_000; +const fullInclude: Include = { effective: true, body: true, document: true }; +const CONTRACT_REFUSED = [ + "types", + "where", + "projections", + "select", + "orderBy", + "groupBy", + "summaries", + "snapshot", +] as const; + +export function nativeReaderClient( + client: MdbaseClient, + isCurrent: () => boolean = () => true, + options: { readonly receiptWaitMs?: number } = {}, +): NativeReaderClient { + return new NativeRecords(client, isCurrent, options.receiptWaitMs ?? RECEIPT_WAIT_MS); +} + +/** + * Reader's record seam over one borrowed public SDK client. The SDK owns sign-in, + * selection and the client's lifetime; this adapter never closes it, never resubmits + * a write under a new identity, and stops at every await once `isCurrent` is false. + * A write the replica captured is saved: confirmed, or saved on this device when + * the log has not confirmed it within the wait. The SDK's write ledger keeps and + * settles those. Only a refused write fails, and only an unknown outcome keeps its + * original mutation ID for recovery. + */ +class NativeRecords implements NativeReaderClient { + readonly #lifetime = new AbortController(); + readonly #unknown = new Map(); + + constructor( + private readonly client: MdbaseClient, + private readonly isCurrent: () => boolean, + private readonly receiptWaitMs: number, + ) {} + + supportsAuthorityFeature(id: string): Promise> { + return Promise.resolve( + connectSuccess(id === "query-metadata-v1" || id === "read-many-documents-v1"), + ); + } + + read( + input: ReadInput, + options: ReaderRequestOptions = {}, + ): Promise> { + return outcome(async () => { + const signal = this.#scope(options.signal); + if (input.contract) { + // Contract views carry contract fields only; the body is never part of them. + const view = await this.client.getContract({ path: input.path }, input.contract, signal); + this.#check(signal); + return contractDocument(view); + } + const record = await this.client.get({ path: input.path }, fullInclude, signal); + this.#check(signal); + return recordDocument(record); + }); + } + + readMany( + paths: readonly string[], + options: ReadManyOptions = {}, + ): Promise> { + return outcome(async () => { + const signal = this.#scope(options.signal); + const types = options.types ? new Set(options.types) : null; + const include: Include = { effective: true, body: options.includeBody === true }; + const unique = [...new Set(paths)]; + const found = new Map(); + let next = 0; + const worker = async (): Promise => { + for (let path = unique[next++]; path !== undefined; path = unique[next++]) { + const record = await this.client.find({ path }, include, signal); + this.#check(signal); + if (record && (!types || record.types.some((type) => types.has(type)))) { + found.set(path, record); + } + } + }; + const width = Math.max(1, Math.min(options.concurrency ?? 4, 4, unique.length)); + await Promise.all(Array.from({ length: width }, worker)); + return { + results: paths.map((path) => { + const record = found.get(path); + return record + ? { status: "found" as const, path, record: queryRecord(record) } + : { status: "missing" as const, path }; + }), + errors: [], + }; + }); + } + + query( + input: QueryInput, + options: ReaderRequestOptions = {}, + ): Promise> { + return outcome(async () => { + const signal = this.#scope(options.signal); + if (input.contract) { + const rows: QueryRecord[] = []; + let last: ContractPage | undefined; + const limit = input.limit ?? DEFAULT_PAGE; + for await (const page of this.#contractPages(input, input.contract, limit, signal)) { + rows.push(...page.rows); + last = page; + if (input.limit === undefined) { + break; + } + } + return { results: rows, meta: last ? resultMeta(last) : { hasMore: false } }; + } + const { query, include } = nativeQuery(input); + const result = await this.client.query(query, include, signal); + this.#check(signal); + return { results: result.records.map(queryRecord), meta: resultMeta(result) }; + }); + } + + queryPages( + input: QueryMetadataInput, + options?: ReaderQueryPagesOptions, + ): AsyncIterable>; + queryPages( + input: QueryInput, + options?: ReaderQueryPagesOptions, + ): AsyncIterable>; + async *queryPages( + input: QueryInput | QueryMetadataInput, + options: ReaderQueryPagesOptions = {}, + ): AsyncIterable> { + const metadata = input.output === "metadata"; + const pageSize = options.firstPageSize ?? options.pageSize ?? DEFAULT_PAGE; + let page = 0; + let loaded = 0; + try { + const signal = this.#scope(options.signal); + const pages = + !metadata && input.contract + ? this.#contractPages(input, input.contract, pageSize, signal) + : this.#ordinaryPages(input, pageSize, signal); + for await (const result of pages) { + const results = + "rows" in result + ? result.rows + : result.records.map((record) => + metadata ? metadataRecord(record) : queryRecord(record), + ); + const offset = loaded; + loaded += results.length; + page += 1; + yield connectSuccess({ + results, + meta: resultMeta(result), + page, + offset, + loaded, + complete: !result.cursor, + ...(result.cursor ? { cursor: result.cursor } : {}), + ...(metadata ? { output: "metadata" as const } : {}), + } as QueryPage | QueryMetadataPage); + } + } catch (error) { + yield failure(error); + } + } + + create( + input: CreateInput, + options: ReaderCreateOptions = {}, + ): Promise> { + return outcome(async () => { + if (input.ifRevision !== undefined) { + throw unsupported("Conditional creates are not available natively."); + } + const { intent } = options; + // With an intent the SDK derives the record ID, so a retry creates the same record; + // it is read back by its path. + const record: NativeMutation["record"] | undefined = + intent === undefined + ? uuidv7() + : input.path === undefined + ? undefined + : { path: input.path }; + if (record === undefined) { + throw unsupported("A retryable create needs a path."); + } + const signal = this.#scope(); + const fields = { + ...(typeof record === "string" ? { id: record } : {}), + ...(input.contract ? { contract: input.contract } : {}), + ...(input.type ? { type: input.type } : {}), + ...(input.path ? { path: input.path } : {}), + ...(input.frontmatter + ? { frontmatter: input.frontmatter as Record } + : {}), + ...(input.body === undefined ? {} : { body: input.body }), + }; + return saved( + await this.#write( + { + operation: "create", + record, + ...(intent === undefined ? {} : { intent }), + ...(input.contract ? { contract: input.contract } : {}), + }, + (write) => this.client.create(fields, write), + signal, + ), + ); + }); + } + + update(input: UpdateInput): Promise> { + return outcome(async () => { + const signal = this.#scope(); + if (input.contract) { + return saved(await this.#contractUpdate(input, input.contract, signal)); + } + const base = await this.#current(input.path, input.ifRevision, signal); + if (input.document !== undefined) { + const document = input.document; + const ifRevision = input.ifRevision ?? base.revision; + const submit: Submit = (write) => + this.client.replaceDocument(base, document, { ...write, ifRevision }); + return saved(await this.#write({ operation: "update", record: base.id }, submit, signal)); + } + const changes = { + ...patchChanges(input.patch), + ...(input.body === undefined ? {} : { body: input.body }), + ...(input.ifRevision === undefined ? {} : { ifRevision: input.ifRevision }), + }; + const submit: Submit = (write) => this.client.update(base, changes, write); + return saved(await this.#write({ operation: "update", record: base.id }, submit, signal)); + }); + } + + preflightDelete(input: DeleteInput): Promise> { + return outcome(async () => { + const signal = this.#scope(); + const base = await this.#current(input.path, input.ifRevision, signal, input.contract); + const preflight = await this.client.preflightDelete(base); + this.#check(signal); + const brokenLinks = uniquePaths(preflight.broken); + return { + path: base.path, + deleted: false, + dryRun: true, + wouldDelete: true, + ...(brokenLinks.length > 0 ? { brokenLinks } : {}), + }; + }); + } + + deleteWithProgress( + input: DeleteInput, + options: DeleteProgressOptions = {}, + ): Promise> { + return outcome(async () => { + const started = Date.now(); + const progress = (state: "preflighting" | "applying" | "completed"): void => + options.onProgress?.({ + operation: "delete", + state, + elapsedMs: Date.now() - started, + cancellable: state !== "completed", + resumed: false, + completedUnits: state === "completed" ? 1 : 0, + }); + const signal = this.#scope(options.signal); + progress("preflighting"); + const base = await this.#current(input.path, input.ifRevision, signal, input.contract); + const brokenLinks = input.checkBacklinks + ? await this.#confirmedBreaks(base, options, signal) + : []; + progress("applying"); + const ifRevision = input.ifRevision ?? base.revision; + const contract = input.contract ? { contract: input.contract } : {}; + const submit: Submit = (write) => + this.client.delete(base, { ...write, ifRevision, ...contract }); + await this.#write({ operation: "delete", record: base.id }, submit, signal); + progress("completed"); + return { + path: base.path, + deleted: true, + ...(brokenLinks.length > 0 ? { brokenLinks } : {}), + }; + }); + } + + pendingMutation(requestId: string): NativePendingMutation | null { + const mutation = this.#unknown.get(requestId); + if (!mutation) { + return null; + } + return { + recover: (options = {}) => + outcome(async () => { + const document = await this.#confirm(mutation, this.#scope(options.signal)); + if (!document) { + throw unsupported("The recovered operation deleted its record."); + } + return document; + }), + }; + } + + dispose(): void { + this.#lifetime.abort(); + } + + #check(signal: AbortSignal): void { + if (!this.isCurrent()) { + throw retired(); + } + signal.throwIfAborted(); + } + + #scope(extra?: AbortSignal): AbortSignal { + this.#check(this.#lifetime.signal); + extra?.throwIfAborted(); + return AbortSignal.any([ + this.#lifetime.signal, + ...(extra ? [extra] : []), + AbortSignal.timeout(REQUEST_BUDGET_MS), + ]); + } + + /** The record at `path`; with a contract, it must also implement that contract. */ + async #current( + path: string, + ifRevision: string | undefined, + signal: AbortSignal, + contract?: ContractSelector, + ): Promise { + if (contract) { + await this.client.getContract({ path }, contract, signal); + this.#check(signal); + } + const record = await this.client.get({ path }, fullInclude, signal); + this.#check(signal); + if (ifRevision !== undefined && record.revision !== ifRevision) { + throw new MdbaseError({ + code: "conflict", + reason: "revision_mismatch", + recovery: "refresh", + message: "This record changed after it was read.", + }); + } + return record; + } + + /** Links the delete would break, refused when more than the user confirmed. */ + async #confirmedBreaks( + base: RecordView, + options: DeleteProgressOptions, + signal: AbortSignal, + ): Promise<{ path: string }[]> { + const brokenLinks = uniquePaths((await this.client.preflightDelete(base)).broken); + this.#check(signal); + const shown = new Set(options.preflight?.brokenLinks?.map(({ path }) => path) ?? []); + if (options.preflight && brokenLinks.some(({ path }) => !shown.has(path))) { + throw new MdbaseError({ + code: "conflict", + reason: "preflight_changed", + recovery: "refresh", + message: "More links would break than were confirmed. Review the deletion again.", + }); + } + return brokenLinks; + } + + async #write( + { intent, ...target }: WriteTarget, + submit: Submit, + signal: AbortSignal, + ): Promise { + this.#check(signal); + const identity = intent === undefined ? { mutationId: uuidv7() } : { intent }; + const minted = "mutationId" in identity ? identity.mutationId : undefined; + let mutationId = minted; + try { + const submitted = await submit({ ...identity, signal }); + if (minted !== undefined && submitted.mutationId !== minted) { + throw new Error("The native write changed its mutation identity."); + } + mutationId = submitted.mutationId; + } catch (error) { + // The SDK names the mutation an intent stood for on the error it reports. + mutationId ??= isMdbaseError(error) ? error.mutationId : undefined; + // An error code alone does not prove the write was not captured. Only the + // exact receipt settles it: no receipt for this ID means nothing was written. + if (mutationId === undefined || !(await this.#captured(mutationId))) { + throw error; + } + } + const mutation: NativeMutation = { mutationId, ...target }; + try { + return await this.#confirm(mutation, signal); + } catch (error) { + if (error instanceof Refused) { + throw error; + } + // Captured but not settled here (unknown, a dropped link, a retired scope): + // keep the original identity for recovery, never report a plain failure. + this.#unknown.set(mutation.mutationId, mutation); + throw error instanceof OutcomeUnknown ? error : new OutcomeUnknown(mutation.mutationId); + } + } + + async #captured(mutationId: string): Promise { + try { + await this.client.receipt(mutationId, AbortSignal.timeout(10_000)); + return true; + } catch (error) { + return !isMdbaseError(error, "not_found"); + } + } + + /** Settle a captured write: confirmed or saved on this device reads back; refused fails. */ + async #confirm(mutation: NativeMutation, signal: AbortSignal): Promise { + this.#check(signal); + // Still pending after the wait is saved on this device, not a failure. + const receipt = await this.client + .awaitReceipt(mutation.mutationId, this.receiptWaitMs, signal) + .catch((error: unknown) => { + if (isMdbaseError(error, "cancelled")) { + throw error; + } + this.#check(signal); + return this.client.receipt(mutation.mutationId, signal); + }); + this.#check(signal); + if (receipt.mutation !== mutation.mutationId) { + throw new Error("The native receipt changed its mutation identity."); + } + if (receipt.state === "unknown") { + throw new OutcomeUnknown(mutation.mutationId); + } + this.#unknown.delete(mutation.mutationId); + if (receipt.state === "rejected" || receipt.status === "conflicted") { + throw new Refused( + receipt.problem ?? { + code: "conflict", + recovery: "resolve_conflict", + message: "This change conflicts with another edit. Resolve it before continuing.", + }, + ); + } + if (mutation.operation === "delete") { + return null; + } + const record = await this.client.get(mutation.record, fullInclude, signal); + this.#check(signal); + if ( + typeof mutation.record === "string" + ? record.id !== mutation.record + : record.path !== mutation.record.path + ) { + throw new Error("The saved record changed its identity."); + } + if (record.state.hold || record.state.unresolved) { + throw new OutcomeUnknown(mutation.mutationId); + } + if (mutation.contract) { + const view = await this.client.getContract(record, mutation.contract, signal); + this.#check(signal); + return contractDocument(view); + } + return recordDocument(record); + } + + /** Contract fields only: the replica maps them to the implementing type's fields. */ + async #contractUpdate( + input: UpdateInput, + contract: ContractSelector, + signal: AbortSignal, + ): Promise { + if (input.document !== undefined || input.body !== undefined) { + throw unsupported("Contract updates change contract fields only, not the body."); + } + const view = await this.client.getContract({ path: input.path }, contract, signal); + this.#check(signal); + const changes = { + contract, + ...patchChanges(input.patch), + ifRevision: input.ifRevision ?? view.revision, + }; + const submit: Submit = (write) => this.client.update(view, changes, write); + return this.#write({ operation: "update", record: view.id, contract }, submit, signal); + } + + async *#ordinaryPages( + input: QueryInput | QueryMetadataInput, + pageSize: number, + signal: AbortSignal, + ): AsyncGenerator { + const { query, include } = nativeQuery({ ...input, limit: pageSize }); + for await (const page of this.client.pages(query, include, signal)) { + this.#check(signal); + yield page; + } + } + + async *#contractPages( + input: QueryInput, + contract: DataContract, + pageSize: number, + signal: AbortSignal, + ): AsyncGenerator { + if (CONTRACT_REFUSED.some((key) => input[key] !== undefined) || (input.offset ?? 0) > 0) { + throw unsupported( + "Semantic contract queries accept only pagination and frontmatter mode here.", + ); + } + const size = Math.max(1, Math.min(pageSize, MAX_CONTRACT_PAGE)); + const seen = new Set(); + let cursor = input.cursor; + let remaining = input.limit ?? Number.POSITIVE_INFINITY; + do { + const result = await this.client.queryContract( + { id: contract.id, version: contract.version }, + { pageSize: Math.min(size, remaining), signal, ...(cursor ? { cursor } : {}) }, + ); + this.#check(signal); + const views = new Map(result.page.records.map((view) => [view.id, view])); + const rows = result.records.map((record) => contractRow(record, views, contract)); + remaining -= rows.length; + cursor = remaining > 0 ? result.page.cursor : undefined; + if (cursor && seen.has(cursor)) { + throw new Error("The collection repeated a query page."); + } + if (cursor) { + seen.add(cursor); + } + yield { + rows, + ...(cursor ? { cursor } : {}), + ...(result.page.totalCount === undefined ? {} : { totalCount: result.page.totalCount }), + }; + } while (cursor); + } +} + +function contractRow( + record: ContractQueryRecord, + views: ReadonlyMap, + contract: DataContract, +): QueryRecord { + const view = views.get(record.id); + const projection = contract.type + ? record.implementations.find((item) => item.typeName === contract.type) + : record.implementations.length === 1 + ? record.implementations[0] + : undefined; + if (!view || !projection) { + throw unsupported( + "A record implements this contract more than once; choose its type explicitly.", + ); + } + const fields = plain(projection.fields); + return { + path: view.path, + revision: view.revision, + types: [...view.types], + frontmatter: fields, + effectiveFrontmatter: fields, + file: fileMetadata(view), + }; +} + +/** Connect patches remove a top-level field given null (mdbase §12.3). */ +function patchChanges(input: Readonly>): { + patch: Record; + unset?: string[]; +} { + const entries = Object.entries(input).filter(([, value]) => value !== undefined); + const patch = Object.fromEntries(entries.filter(([, value]) => value !== null)) as Record< + string, + PlainValue + >; + const unset = entries.filter(([, value]) => value === null).map(([key]) => key); + return { patch, ...(unset.length > 0 ? { unset } : {}) }; +} + +function saved(document: RecordDocument | null): RecordDocument { + if (!document) { + throw new Error("The collection did not return the saved record."); + } + return document; +} diff --git a/packages/connect/src/native/native-collection.ts b/packages/connect/src/native/native-collection.ts new file mode 100644 index 00000000..674d38a8 --- /dev/null +++ b/packages/connect/src/native/native-collection.ts @@ -0,0 +1,200 @@ +import { MdbaseError, toPlain } from "@mdbase-dev/sdk"; +import { collectionId } from "@mdbase-reader/core"; + +import { ConnectAnnotationAssetRepository } from "../annotation-assets.js"; +import { ConnectBodyUpdateRecovery } from "../body-update-recovery.js"; +import { ConnectCollectionFileRepository } from "../collection-files.js"; +import { ConnectDocumentRepository } from "../documents.js"; +import { + ConnectAnnotationRepository, + ConnectContentSearchRepository, + ConnectSourceRepository, +} from "../repositories.js"; +import { outcomeValue } from "../repository-client.js"; +import { ConnectSourceImportRepository } from "../source-imports.js"; + +import { nativeReaderClient } from "./native-client.js"; +import { nativeReaderFiles } from "./native-files.js"; +import { plain, recordDocument } from "./native-mapping.js"; +import { NativeMigrationTarget } from "./native-migration.js"; + +import type { ReaderConnectedCollection } from "../application-session.js"; +import type { LibraryViewRepository } from "../library-views.js"; +import type { NativeReaderClient } from "./native-client.js"; +import type { + ExecuteViewInput, + JsonObject, + SavedViewExecution, + SavedViewPresentation, +} from "@mdbase-dev/connect"; +import type { + Change, + MdbaseApplicationConnection, + MdbaseClient, + QueryResult, +} from "@mdbase-dev/sdk"; + +export interface NativeReaderCollection extends ReaderConnectedCollection { + /** Retire this scope's adapters. The borrowed SDK client stays open for its owner. */ + dispose(): void; +} + +/** + * Reader's opened collection over one borrowed public SDK connection. The existing + * domain repositories run unchanged over the native record and file seams; the + * SDK owns sign-in, selection, setup and the client lifetime. + */ +export function nativeReaderCollection( + connection: MdbaseApplicationConnection, +): NativeReaderCollection { + let disposed = false; + const current = (): boolean => !disposed && connection.isCurrent(); + const client = nativeReaderClient(connection.records, current); + const files = nativeReaderFiles(connection.files, current); + return { + collectionId: collectionId(connection.id), + collectionName: connection.displayName, + sources: new ConnectSourceRepository(client), + sourceImports: new ConnectSourceImportRepository(client, files), + migration: new NativeMigrationTarget(connection.id, client, files), + annotations: new ConnectAnnotationRepository(client), + annotationAssets: new ConnectAnnotationAssetRepository(files), + documents: new ConnectDocumentRepository(files), + contentSearch: new ConnectContentSearchRepository(client), + files: new ConnectCollectionFileRepository(files), + libraryViews: nativeLibraryViews(connection.records, client, current), + bodyRecovery: new ConnectBodyUpdateRecovery(client), + subscribeSourceChanges: (listener) => { + const watch = connection.records.watchChanges(undefined, (batch) => { + if (current() && (batch.reset || batch.changes.some(mayBeSource))) { + listener(); + } + }); + return () => { + watch(); + }; + }, + dispose: () => { + disposed = true; + client.dispose(); + }, + }; +} + +/** A record change that can add, change or remove a library source. Producers + * that cannot name the types are treated as possibly a source. */ +function mayBeSource(change: Change): boolean { + return change.entity !== "file" && (change.types?.includes("reader-source") ?? true); +} + +function nativeLibraryViews( + sdk: MdbaseClient, + records: NativeReaderClient, + isCurrent: () => boolean, +): LibraryViewRepository { + const check = (signal?: AbortSignal): void => { + if (!isCurrent()) { + throw new MdbaseError({ + code: "cancelled", + recovery: "none", + message: "This collection is no longer open in mdbase Reader.", + }); + } + signal?.throwIfAborted(); + }; + return { + async list(options = {}) { + check(options.signal); + const listed = await sdk.views.list(undefined, options.signal); + check(options.signal); + return { + views: listed.sources.map((source) => ({ + id: source.id, + name: source.name, + source: { ...source.source }, + views: source.views.map((view) => ({ + id: view.id, + name: view.name, + properties: view.properties.map((property) => ({ ...property })), + ...(view.presentation === undefined + ? {} + : { presentation: toPlain(view.presentation) as SavedViewPresentation }), + })), + })), + meta: { totalCount: listed.sources.length }, + }; + }, + async execute(input, options = {}) { + check(options.signal); + if (input.cursor !== undefined) { + throw new MdbaseError({ + code: "invalid_request", + recovery: "fix_request", + reason: "unsupported", + message: "Saved view cursors are not available natively.", + }); + } + const result = await sdk.views.execute({ path: input.path }, input.view, { + include: { effective: true }, + ...(input.context ? { context: { path: input.context.path } } : {}), + ...(input.limit === undefined ? {} : { limit: input.limit }), + ...(input.offset === undefined ? {} : { offset: input.offset }), + ...(input.timezone === undefined ? {} : { timezone: input.timezone }), + ...(options.signal ? { signal: options.signal } : {}), + }); + check(options.signal); + return viewExecution(result, input); + }, + async save({ path, frontmatter, replace }) { + const saved = replace + ? outcomeValue( + await records.update({ + path, + // A view is replaced whole, so fields the new definition drops are removed. + document: await viewDocument(frontmatter), + ...(replace.revision ? { ifRevision: replace.revision } : {}), + }), + "save library view", + ) + : outcomeValue(await records.create({ path, frontmatter }), "create library view"); + return { path: saved.path, revision: saved.revision }; + }, + }; +} + +function viewExecution(result: QueryResult, input: ExecuteViewInput): SavedViewExecution { + return { + results: result.records.map((record) => { + const document = recordDocument(record); + return { + path: document.path, + revision: document.revision, + types: document.types, + frontmatter: document.frontmatter, + effectiveFrontmatter: document.effectiveFrontmatter, + file: document.file, + ...(record.values ? { values: plain(record.values) } : {}), + }; + }), + meta: { + totalCount: result.totalCount ?? result.records.length, + hasMore: result.hasMore ?? result.cursor !== undefined, + view: { path: result.view?.path ?? input.path, id: result.view?.view ?? input.view }, + ...(input.context ? { context: { path: input.context.path } } : {}), + ...(result.groups + ? { + groups: result.groups.map((group) => ({ + values: plain(group.values), + count: group.count, + summaries: plain(group.summaries), + })), + } + : {}), + }, + }; +} + +async function viewDocument(frontmatter: JsonObject): Promise { + const { stringify } = await import("yaml"); + return `---\n${stringify(frontmatter).trimEnd()}\n---\n\n`; +} diff --git a/packages/connect/src/native/native-files.ts b/packages/connect/src/native/native-files.ts new file mode 100644 index 00000000..eea808bb --- /dev/null +++ b/packages/connect/src/native/native-files.ts @@ -0,0 +1,163 @@ +import { connectFailure, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { isMdbaseError, MdbaseError, uuidv7 } from "@mdbase-dev/sdk"; + +import { connectProblemFor } from "./native-mapping.js"; + +import type { ReaderAssetClient } from "../annotation-assets.js"; +import type { ReaderFileClient } from "../documents.js"; +import type { ReaderSourceFileClient } from "../source-files.js"; +import type { + CollectionFileDescriptor, + ConnectOutcome, + ConnectRequestOptions, + MdbaseFileListOptions, + MdbaseFileStatTarget, + MdbaseFileUploadOptions, +} from "@mdbase-dev/connect"; +import type { FilesApi, FileView } from "@mdbase-dev/sdk"; + +export type NativeReaderFileClient = ReaderFileClient & + ReaderAssetClient & + Required; + +/** + * Reader's file seams over one borrowed public SDK `FilesApi`. Every await is + * fenced by `isCurrent`. Uploads keep one transfer and mutation identity, so a + * retried transfer resumes or replays instead of writing a second file. + */ +export function nativeReaderFiles( + files: FilesApi, + isCurrent: () => boolean = () => true, +): NativeReaderFileClient { + const check = (signal?: AbortSignal): void => { + if (!isCurrent()) { + throw new MdbaseError({ + code: "cancelled", + recovery: "none", + message: "This collection is no longer open in mdbase Reader.", + }); + } + signal?.throwIfAborted(); + }; + + async function stat( + target: MdbaseFileStatTarget, + options: ConnectRequestOptions = {}, + ): Promise> { + try { + check(options.signal); + const view = await files.get( + target.path === undefined ? target.fileId : { path: target.path }, + options.signal, + ); + check(options.signal); + return connectSuccess(descriptor(view)); + } catch (error) { + return isMdbaseError(error, "not_found") + ? connectSuccess(null) + : connectFailure(connectProblemFor(error)); + } + } + + async function upload( + path: string, + source: Blob, + options: MdbaseFileUploadOptions = {}, + ): Promise { + check(options.signal); + const grant = files.grant; + if (grant && !grant.actions.includes("add")) { + throw new MdbaseError({ + code: "forbidden", + recovery: "reauthorize", + message: "This collection does not allow mdbase Reader to add files.", + }); + } + const transferId = options.transferId ?? uuidv7(); + const mutationId = uuidv7(); + const once = async (): Promise => { + const write = await files.upload(path, source, { + transferId, + mutationId, + ...(options.signal ? { signal: options.signal } : {}), + ...(options.ifRevision ? { ifRevision: options.ifRevision } : {}), + ...(options.onProgress + ? { + onProgress: (progress) => + options.onProgress?.({ + phase: "uploading", + transferredBytes: progress.done, + totalBytes: progress.total, + }), + } + : {}), + }); + check(options.signal); + await write.confirmed; + check(options.signal); + const view = await files.get(write.fileId, options.signal); + check(options.signal); + return descriptor(view); + }; + try { + return await once(); + } catch (error) { + check(options.signal); + // Resume the same transfer and mutation once after an unsettled outcome. + if (isMdbaseError(error, "outcome_unknown") || isMdbaseError(error, "unavailable")) { + return once(); + } + throw error; + } + } + + return { + stat, + async download(file, options = {}) { + check(options.signal); + const view = await files.get(file.fileId, options.signal); + check(options.signal); + if (view.digest !== file.contentDigest) { + throw new MdbaseError({ + code: "conflict", + reason: "file_revision_changed", + recovery: "refresh", + message: "This file changed. Reload it before opening.", + }); + } + const bytes = await files.download(view, options.signal ? { signal: options.signal } : {}); + check(options.signal); + return new Blob([bytes as BlobPart], file.mediaType ? { type: file.mediaType } : {}); + }, + upload, + async *list(options: MdbaseFileListOptions = {}) { + check(options.signal); + for await (const view of files.list({ + ...(options.folder ? { folder: options.folder } : {}), + ...(options.pageSize ? { pageSize: options.pageSize } : {}), + ...(options.signal ? { signal: options.signal } : {}), + })) { + check(options.signal); + yield descriptor(view); + } + }, + }; +} + +/** The media type is the replica's, derived from the file extension; absent when unknown. */ +function descriptor(view: FileView): CollectionFileDescriptor { + if (!view.digest.startsWith("sha256:")) { + throw new Error("The collection returned a file without its content digest."); + } + return { + fileId: view.id, + path: view.path, + revision: view.digest, + contentDigest: view.digest as `sha256:${string}`, + size: view.size, + mediaClass: view.media, + ...(view.mediaType ? { mediaType: view.mediaType } : {}), + // Consumers treat an empty time as unknown; no time is invented. + modifiedAt: view.mtime === undefined ? "" : new Date(view.mtime).toISOString(), + }; +} diff --git a/packages/connect/src/native/native-mapping.ts b/packages/connect/src/native/native-mapping.ts new file mode 100644 index 00000000..343fca8a --- /dev/null +++ b/packages/connect/src/native/native-mapping.ts @@ -0,0 +1,247 @@ +import { connectFailure, connectProblem, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { isMdbaseError, MdbaseError, toPlain } from "@mdbase-dev/sdk"; + +import type { + ConnectOutcome, + ConnectProblem, + ConnectProblemCode, + JsonObject, + QueryInput, + QueryMetadataInput, + QueryMetadataRecord, + QueryRecord, + QueryResult, + RecordDocument, +} from "@mdbase-dev/connect"; +import type { + ContractRecordView, + Include, + Query, + QueryResult as NativeQueryResult, + RecordView, + Value, +} from "@mdbase-dev/sdk"; + +/** A native write whose outcome is not yet settled; carries its original mutation ID. */ +export class OutcomeUnknown extends Error { + constructor(readonly mutationId: string) { + super("This change is not confirmed yet. Recover the original operation before retrying."); + this.name = "OutcomeUnknown"; + } +} + +export function unsupported(message: string): MdbaseError { + return new MdbaseError({ + code: "invalid_request", + recovery: "fix_request", + reason: "unsupported", + message, + }); +} + +export function retired(): MdbaseError { + return new MdbaseError({ + code: "cancelled", + recovery: "none", + message: "This collection is no longer open in mdbase Reader.", + }); +} + +function hasUnsupportedQueryMembers(input: QueryInput | QueryMetadataInput): boolean { + return ( + input.groupBy !== undefined || + input.summaries !== undefined || + input.summaryFunctions !== undefined || + input.snapshot !== undefined + ); +} + +function selection(input: QueryInput | QueryMetadataInput): Query { + const query: Query = {}; + if (input.projections) { + query["projections"] = Object.fromEntries( + Object.entries(input.projections).map(([name, projection]) => [ + name, + { expr: projection.expression }, + ]), + ); + } + if (input.select) { + query.select = input.select.map((item) => + typeof item === "string" + ? item + : { + name: item.name, + expr: item.expression, + ...(item.label === undefined ? {} : { label: item.label }), + }, + ); + } + if (input.orderBy) { + query.order_by = input.orderBy.map((order) => ({ + field: order.field, + direction: order.direction ?? "asc", + })); + } + return query; +} + +/** Translate Reader's Connect query shape to the native spec-11 query, or refuse. */ +export function nativeQuery(input: QueryInput | QueryMetadataInput): { + query: Query; + include: Include; +} { + if (hasUnsupportedQueryMembers(input)) { + throw unsupported("Grouped, summarized and snapshot queries are not used natively by Reader."); + } + const query: Query = { + ...(input.types ? { types: [...input.types] } : {}), + ...(input.where === undefined ? {} : { where: input.where }), + ...(input.timezone === undefined ? {} : { timezone: input.timezone }), + ...(input.context ? { context: { this: { path: input.context.this.path } } } : {}), + ...selection(input), + ...(input.limit === undefined ? {} : { limit: input.limit }), + ...(input.offset ? { offset: input.offset } : {}), + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + }; + const metadata = input.output === "metadata"; + return { + query, + include: { effective: true, body: !metadata && input.includeBody === true }, + }; +} + +export function plain(value: ReadonlyMap | undefined): JsonObject { + return value ? Object.fromEntries([...value].map(([key, item]) => [key, toPlain(item)])) : {}; +} + +export function fileMetadata( + record: Pick & Partial>, +): RecordDocument["file"] { + const slash = record.path.lastIndexOf("/"); + const base = record.path.slice(slash + 1); + const dot = base.lastIndexOf("."); + return { + path: record.path, + name: dot > 0 ? base.slice(0, dot) : base, + folder: slash === -1 ? "" : record.path.slice(0, slash), + ...(record.mtime === undefined ? {} : { mtime: new Date(record.mtime).toISOString() }), + }; +} + +export function recordDocument(record: RecordView): RecordDocument { + return { + path: record.path, + revision: record.revision, + types: [...record.types], + frontmatter: plain(record.frontmatter), + effectiveFrontmatter: plain(record.effective ?? record.frontmatter), + ...(record.body === undefined ? {} : { body: record.body }), + ...(record.document === undefined ? {} : { document: record.document }), + file: fileMetadata(record), + }; +} + +/** A contract view as Connect returns it: contract fields, no body (prod parity). */ +export function contractDocument(view: ContractRecordView): RecordDocument { + const fields = plain(view.fields); + return { + path: view.path, + revision: view.revision, + types: [view.contract.type], + frontmatter: fields, + effectiveFrontmatter: fields, + file: fileMetadata({ path: view.path }), + contract: { ...view.contract }, + }; +} + +export function queryRecord(record: RecordView): QueryRecord { + return { + path: record.path, + revision: record.revision, + types: [...record.types], + frontmatter: plain(record.frontmatter), + effectiveFrontmatter: plain(record.effective ?? record.frontmatter), + ...(record.body === undefined ? {} : { body: record.body }), + file: fileMetadata(record), + ...(record.values ? { values: plain(record.values) } : {}), + }; +} + +export function metadataRecord(record: RecordView): QueryMetadataRecord { + return { + path: record.path, + types: [...record.types], + revision: record.revision, + values: plain(record.values), + }; +} + +export function resultMeta( + result: Pick, +): NonNullable { + return { + hasMore: result.hasMore ?? result.cursor !== undefined, + ...(result.totalCount === undefined ? {} : { totalCount: result.totalCount }), + ...(result.cursor ? { cursor: result.cursor } : {}), + }; +} + +export function uniquePaths(values: readonly { path: string }[]): { path: string }[] { + return [...new Set(values.map(({ path }) => path))].map((path) => ({ path })); +} + +export async function outcome( + operation: () => Promise, +): Promise> { + try { + return connectSuccess(await operation()); + } catch (error) { + return failure(error); + } +} + +export function failure(error: unknown): ConnectOutcome { + return connectFailure(connectProblemFor(error)); +} + +const PROBLEM_CODES: Readonly> = { + not_found: "file_not_found", + conflict: "concurrent_modification", + cancelled: "operation_cancelled", + unavailable: "temporarily_unavailable", + rate_limited: "rate_limited", + forbidden: "not_authorized", + unauthenticated: "reconnect_required", + collection_invalid: "collection_incompatible", + upgrade_required: "connector_upgrade_required", +}; + +/** Map native SDK failures onto the Connect problem codes Reader already handles. */ +export function connectProblemFor(error: unknown): ConnectProblem { + if (error instanceof OutcomeUnknown) { + return connectProblem("operation_outcome_unknown", error.message, { + details: { request_id: error.mutationId }, + }); + } + if (error instanceof DOMException && error.name === "TimeoutError") { + return connectProblem("timeout", error.message); + } + if (error instanceof DOMException && error.name === "AbortError") { + return connectProblem("operation_cancelled", error.message); + } + if (!isMdbaseError(error)) { + return connectProblem( + "operation_failed", + error instanceof Error ? error.message : "The collection operation failed.", + ); + } + const code = + error.code === "invalid_request" + ? error.reason === "unsupported" + ? "unsupported_operation" + : "invalid_request" + : (PROBLEM_CODES[error.code] ?? "operation_failed"); + return connectProblem(code as "operation_failed", error.message); +} diff --git a/packages/connect/src/native/native-migration.ts b/packages/connect/src/native/native-migration.ts new file mode 100644 index 00000000..1e5bf3d0 --- /dev/null +++ b/packages/connect/src/native/native-migration.ts @@ -0,0 +1,168 @@ +import { annotationContract, sourceContract } from "../contracts.js"; +import { ConnectRepositoryError, outcomeValue } from "../repository-client.js"; + +import type { NativeReaderClient } from "./native-client.js"; +import type { NativeReaderFileClient } from "./native-files.js"; +import type { RecordDocument } from "@mdbase-dev/connect"; +import type { + Fields, + ImportedFile, + MigrationRecord, + MigrationTarget, +} from "@mdbase-reader/migration"; + +/** + * Import destination over the native SDK seams. Never modifies an existing record + * or file. An unsettled create keeps its original native mutation ID in a local + * journal, so a later run recovers that exact write instead of submitting another. + */ +export class NativeMigrationTarget implements MigrationTarget { + constructor( + public readonly collectionId: string, + private readonly records: NativeReaderClient, + private readonly fileClient: NativeReaderFileClient, + ) {} + + async existing(signal: AbortSignal): Promise> { + const result = new Map(); + const identitiesByPath = new Map(); + for (const contract of [sourceContract, annotationContract]) { + for await (const page of this.records.queryPages( + { contract, frontmatterMode: "effective" }, + { signal, pageSize: 128 }, + )) { + for (const row of outcomeValue(page, "inspect import identities").results) { + const fields = row.effectiveFrontmatter ?? row.frontmatter; + const id = fields?.["id"]; + if (typeof id !== "string") { + continue; + } + if (result.has(id)) { + throw new Error("The destination has duplicate record IDs. Import was stopped."); + } + result.set(id, fields as Fields); + identitiesByPath.set(row.path, id); + } + } + } + // Contract projections omit extension fields; join native provenance by path. + for await (const page of this.records.queryPages( + { frontmatterMode: "persisted" }, + { signal, pageSize: 1000 }, + )) { + for (const row of outcomeValue(page, "inspect native import provenance").results) { + const id = identitiesByPath.get(row.path); + if (id) { + result.set(id, { + ...result.get(id), + import: (row.frontmatter?.["import"] ?? null) as Fields["import"], + }); + } + } + } + return result; + } + + async files(signal: AbortSignal): Promise> { + const result = new Map(); + for await (const file of this.fileClient.list({ + folder: "files/reader/imports", + pageSize: 500, + signal, + })) { + result.set(file.path, file); + } + return result; + } + + upload( + path: string, + blob: Blob, + mediaType: string, + transferId: string, + signal: AbortSignal, + progress: (bytes: number) => void, + ): Promise { + return this.fileClient.upload(path, blob, { + signal, + mediaType, + transferId, + onProgress: (p) => progress(p.transferredBytes), + }); + } + + async create( + record: MigrationRecord, + fields: Fields, + type: "reader-source" | "reader-annotation", + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const journalKey = `reader:migration-pending:${this.collectionId}:${record.id}`; + const storage = typeof localStorage === "undefined" ? null : localStorage; + if (await this.recoverJournal(record, journalKey, storage, signal)) { + return; + } + const outcome = await this.records.create({ + path: record.path, + type, + frontmatter: fields, + body: record.body, + includeDocument: true, + }); + if (!outcome.ok && outcome.problem.code === "operation_outcome_unknown") { + const requestId = (outcome.problem.details as { request_id?: unknown } | undefined) + ?.request_id; + if (typeof requestId === "string") { + storage?.setItem(journalKey, requestId); + await this.recover(record, requestId, signal); + storage?.removeItem(journalKey); + return; + } + } + verify(record, outcomeValue(outcome, "create imported record")); + } + + private async recoverJournal( + record: MigrationRecord, + key: string, + storage: Storage | null, + signal: AbortSignal, + ): Promise { + const pendingId = storage?.getItem(key); + if (!pendingId) { + return false; + } + if (this.records.pendingMutation(pendingId)) { + await this.recover(record, pendingId, signal); + storage?.removeItem(key); + return true; + } + const read = await this.records.read({ path: record.path }, { signal }); + if (read.ok && read.value.effectiveFrontmatter["id"] === record.id) { + storage?.removeItem(key); + return true; + } + throw new Error( + "A previous import mutation needs recovery. Its original request will not be repeated as a new write.", + ); + } + + private async recover( + record: MigrationRecord, + requestId: string, + signal: AbortSignal, + ): Promise { + const pending = this.records.pendingMutation(requestId); + if (!pending) { + throw new ConnectRepositoryError("recover imported record", "pending_mutation_unresolved"); + } + verify(record, outcomeValue(await pending.recover({ signal }), "recover imported record")); + } +} + +function verify(record: MigrationRecord, document: RecordDocument): void { + if (document.effectiveFrontmatter["id"] !== record.id) { + throw new Error("Imported record identity mismatch."); + } +} diff --git a/packages/connect/src/native/testing.ts b/packages/connect/src/native/testing.ts new file mode 100644 index 00000000..92169af7 --- /dev/null +++ b/packages/connect/src/native/testing.ts @@ -0,0 +1,50 @@ +/** Test-only native fixtures for Reader apps: the SDK's public application and app + * fixtures and a real SDK client over MemoryReplica. None is native/LAB evidence. */ +import { connect, MdbasePeopleClient, type MdbaseApplicationConnection } from "@mdbase-dev/sdk"; +import { MemoryReplica, type MemoryReplicaOptions } from "@mdbase-dev/sdk/testing"; + +export { createApplicationFixture, createTestApp, MemoryReplica } from "@mdbase-dev/sdk/testing"; +export { mdbaseError } from "@mdbase-dev/sdk"; +export type { ApplicationFixture, TestApp } from "@mdbase-dev/sdk/testing"; + +export async function memoryConnection(options: MemoryReplicaOptions = {}): Promise<{ + readonly replica: MemoryReplica; + readonly connection: MdbaseApplicationConnection; + readonly setCurrent: (current: boolean) => void; + readonly close: () => void; +}> { + const replica = new MemoryReplica({ confirmDelayMs: 0, ...options }); + const client = await connect({ + connector: replica.connector(), + app: { name: "reader-native-test", version: "test" }, + reconnect: false, + }); + let current = true; + const isCurrent = (): boolean => current; + const connection: MdbaseApplicationConnection = { + id: replica.collection, + displayName: "Reading", + scope: { + account: "fixture-account", + installation: "fixture-installation", + collection: replica.collection, + }, + generation: "fixture-generation", + records: client, + files: client.files, + people: new MdbasePeopleClient(client, { + collection: replica.collection, + request: () => Promise.reject(new Error("No inferred metadata permission")), + isCurrent, + }), + isCurrent, + }; + return { + replica, + connection, + setCurrent: (next) => { + current = next; + }, + close: () => client.close(), + }; +} diff --git a/packages/connect/src/next-reader-records.test.ts b/packages/connect/src/next-reader-records.test.ts new file mode 100644 index 00000000..83c205ae --- /dev/null +++ b/packages/connect/src/next-reader-records.test.ts @@ -0,0 +1,448 @@ +import { connect, MdbaseError, toValue, type MdbaseClient, type wire } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { afterEach, describe, expect, it, vi, type MockInstance } from "vitest"; + +import { annotationContract, sourceContract } from "./contracts.js"; +import { NextReaderRecords, type NextReaderRecord } from "./next-reader-records.js"; + +const clients: MdbaseClient[] = []; +const digests = { source: `sha256:${"a".repeat(64)}`, annotation: `sha256:${"b".repeat(64)}` }; +const signal = (): AbortSignal => new AbortController().signal; + +interface Fixture { + replica: MemoryReplica; + source: ReturnType; + annotation: ReturnType; + client: MdbaseClient; + catalog: wire.DescribeResult; + describe: MockInstance; + records: NextReaderRecords; +} + +afterEach(() => { + for (const client of clients.splice(0)) { + client.close(); + } + vi.restoreAllMocks(); +}); + +async function fixture(): Promise { + const replica = new MemoryReplica(); + const source = replica.seed({ + path: "library/paper.md", + types: ["bibliography-item"], + frontmatter: { + work_id: "paper-1", + caption: "A paper", + medium: "article", + captured: "2026-10-10T10:00:00Z", + citation: toValue({ title: "A paper" }), + }, + body: "User-authored literature note\n", + }); + const annotation = replica.seed({ + path: "marks/highlight.md", + types: ["margin-mark"], + frontmatter: { + mark_id: "highlight-1", + for_work: "paper-1", + mark_kind: "highlight", + made_at: "2026-10-10T11:00:00Z", + }, + body: "A quotation and comment\n", + }); + replica.seed({ + path: "unrelated.md", + types: ["note"], + frontmatter: { id: "not-a-source", title: "Not admitted" }, + }); + const client = await connect({ + connector: replica.connector(), + app: { name: "reader-data-test", version: "test" }, + reconnect: false, + }); + clients.push(client); + // Synthetic exact contract/type metadata over actual SDK record frames. This + // does not qualify Reader's native manifest/parser/runtime or sign-in setup. + const catalog: wire.DescribeResult = { + specVersion: "0.3.0", + settings: new Map(), + inclusion: { include: [] }, + issues: [], + contracts: [ + { + ...sourceContract, + digest: digests.source, + path: "_contracts/source.md", + contractType: "record", + implementedBy: ["bibliography-item"], + }, + { + ...annotationContract, + digest: digests.annotation, + path: "_contracts/annotation.md", + contractType: "record", + implementedBy: ["margin-mark"], + }, + ], + types: [ + { + name: "bibliography-item", + path: "_types/bibliography-item.md", + implements: [ + { + contract: sourceContract.id, + version: sourceContract.version, + fields: new Map( + Object.entries({ + id: "work_id", + title: "caption", + kind: "medium", + saved_at: "captured", + csl: "citation", + }), + ), + }, + ], + }, + { + name: "margin-mark", + path: "_types/margin-mark.md", + implements: [ + { + contract: annotationContract.id, + version: annotationContract.version, + fields: new Map( + Object.entries({ + id: "mark_id", + source: "for_work", + annotation_type: "mark_kind", + created_at: "made_at", + }), + ), + }, + ], + }, + ], + }; + const describe = vi.spyOn(client, "describe").mockResolvedValue(catalog); + return { + replica, + source, + annotation, + client, + catalog, + describe, + records: new NextReaderRecords(client, digests), + }; +} + +async function collect( + pages: AsyncIterable, +): Promise { + const records: NextReaderRecord[] = []; + for await (const page of pages) { + records.push(...page); + } + return records; +} + +describe("NextReaderRecords (SDK stand-in, not native/LAB acceptance)", () => { + it("queries only admitted provider names and keeps native maps/state/identity", async () => { + const f = await fixture(); + const query = vi.spyOn(f.client, "query"); + const get = vi.spyOn(f.client, "get"); + const sources = await collect(f.records.sources(signal())); + expect(sources).toHaveLength(1); + expect(sources[0]?.record.id).toBe(f.source.id); + expect(sources[0]?.providerType).toBe("bibliography-item"); + expect(sources[0]?.fields.get("title")).toBe("A paper"); + expect(sources[0]?.fields.get("csl")).toBeInstanceOf(Map); + expect(sources[0]?.record.frontmatter.has("caption")).toBe(true); + expect(sources[0]?.record.body).toBeUndefined(); + expect(sources[0]?.record.document).toBeUndefined(); + expect(sources[0]?.record.state.state).toBe("confirmed"); + expect(query).toHaveBeenCalledWith( + { types: ["bibliography-item"], limit: 1000 }, + { effective: true }, + expect.any(AbortSignal), + ); + expect(get).not.toHaveBeenCalled(); + const annotations = await collect(f.records.annotations(signal())); + expect(annotations[0]?.record.id).toBe(f.annotation.id); + expect(annotations[0]?.fields.get("source")).toBe("paper-1"); + expect(annotations[0]?.fields.get("annotation_type")).toBe("highlight"); + }); + + it.each(["source", "annotation"] as const)( + "reads the genuine complete %s source without reconstructing it", + async (domain) => { + const f = await fixture(); + const id = domain === "source" ? f.source.id : f.annotation.id; + const expected = await f.client.get(id, { body: true, document: true, effective: true }); + vi.spyOn(f.client, "get").mockResolvedValue(expected); + const result = + domain === "source" + ? await f.records.getSource(id, signal()) + : await f.records.getAnnotation(id, signal()); + expect(result.record).toBe(expected); + expect(result.record.body).toContain(domain === "source" ? "literature note" : "quotation"); + expect(result.record.document).toBe(expected.document); + }, + ); + + it.each(["missing", "version", "digest", "duplicate", "kind"])( + "refuses %s contract metadata before querying", + async (failure) => { + const f = await fixture(); + const contract = f.catalog.contracts[0]!; + if (failure === "missing") { + f.catalog.contracts.shift(); + } + if (failure === "version") { + contract.version = "1.0.0-beta.2"; + } + if (failure === "digest") { + contract.digest = digests.annotation; + } + if (failure === "duplicate") { + f.catalog.contracts.push({ ...contract }); + } + if (failure === "kind") { + contract.contractType = "file"; + } + const query = vi.spyOn(f.client, "query"); + await expect(collect(f.records.sources(signal()))).rejects.toThrow( + "qualified Reader source contract", + ); + expect(query).not.toHaveBeenCalled(); + }, + ); + + it.each(["absent", "ambiguous", "required", "nested"])( + "refuses %s provider bindings without a starter fallback", + async (failure) => { + const f = await fixture(); + const implementation = f.catalog.types[0]!.implements[0]!; + if (failure === "absent") { + f.catalog.types.shift(); + } + if (failure === "ambiguous") { + f.catalog.types[0]!.implements.push(implementation); + } + if (failure === "required") { + implementation.fields.delete("id"); + } + if (failure === "nested") { + implementation.fields.set("title", "bibliography.title"); + } + const query = vi.spyOn(f.client, "query"); + await expect(collect(f.records.sources(signal()))).rejects.toThrow(); + expect(query).not.toHaveBeenCalled(); + }, + ); + + it("requires explicit native semantic digest pins, not an unqualified default", async () => { + const f = await fixture(); + expect(() => new NextReaderRecords(f.client, { ...digests, source: "not-a-digest" })).toThrow( + "qualified native source", + ); + const mutable = { ...digests }; + const records = new NextReaderRecords(f.client, mutable); + mutable.source = digests.annotation; + expect(await collect(records.sources(signal()))).toHaveLength(1); + }); +}); + +describe("NextReaderRecords full source (SDK stand-in, not native/LAB acceptance)", () => { + it.each(["body", "document", "revision"])( + "refuses missing or mismatched %s rather than synthesizing source", + async (failure) => { + const f = await fixture(); + const record = await f.client.get(f.source.id, { + body: true, + document: true, + effective: true, + }); + if (failure === "body") { + delete record.body; + } + if (failure === "document") { + delete record.document; + } + if (failure === "revision") { + record.revision = digests.annotation; + } + vi.spyOn(f.client, "get").mockResolvedValue(record); + await expect(f.records.getSource(f.source.id, signal())).rejects.toThrow( + "actual complete revision-consistent source", + ); + }, + ); + + it("retains native pending/protected state without calling it saved or resubmitting", async () => { + const f = await fixture(); + const record = await f.client.get(f.source.id, { body: true, document: true, effective: true }); + record.state = { + ...record.state, + state: "pending", + unresolved: 1, + hold: { id: crypto.randomUUID(), reason: "conflict" }, + }; + vi.spyOn(f.client, "get").mockResolvedValue(record); + const submit = vi.spyOn(f.client, "submit"); + expect((await f.records.getSource(record.id, signal())).record.state).toBe(record.state); + expect(submit).not.toHaveBeenCalled(); + }); + + it("uses effective fields but leaves the genuine record unchanged", async () => { + const f = await fixture(); + const record = await f.client.get(f.source.id, { body: true, document: true, effective: true }); + record.effective = new Map(record.frontmatter).set("caption", "Effective title"); + vi.spyOn(f.client, "get").mockResolvedValue(record); + const projected = await f.records.getSource(record.id, signal()); + expect(projected.fields.get("title")).toBe("Effective title"); + expect(projected.record.frontmatter.get("caption")).toBe("A paper"); + expect(projected.record).toBe(record); + }); + + it("captures a mutable caller path before catalog I/O", async () => { + const f = await fixture(); + const selected = { path: f.source.path }; + f.describe.mockImplementation(() => { + selected.path = f.annotation.path; + return Promise.resolve(f.catalog); + }); + const get = vi.spyOn(f.client, "get"); + expect((await f.records.getSource(selected, signal())).record.id).toBe(f.source.id); + expect(get).toHaveBeenCalledWith( + { path: f.source.path }, + { body: true, document: true, effective: true }, + expect.any(AbortSignal), + ); + }); +}); + +describe("NextReaderRecords identity/provider safety (SDK stand-in)", () => { + it("refuses an SDK readback that switches the selected UUID", async () => { + const f = await fixture(); + const record = await f.client.get(f.source.id, { body: true, document: true, effective: true }); + vi.spyOn(f.client, "get").mockResolvedValue({ ...record, id: f.annotation.id }); + await expect(f.records.getSource(f.source.id, signal())).rejects.toThrow( + "selected native identity", + ); + }); + + it("refuses records outside or ambiguous across admitted providers", async () => { + const f = await fixture(); + const record = await f.client.get(f.source.id, { body: true, document: true, effective: true }); + const get = vi.spyOn(f.client, "get"); + get.mockResolvedValue({ ...record, types: ["reader-source"] }); + await expect(f.records.getSource(record.id, signal())).rejects.toThrow( + "unique admitted provider", + ); + f.catalog.types.push({ ...f.catalog.types[0]!, name: "alternate-provider" }); + get.mockResolvedValue({ ...record, types: ["bibliography-item", "alternate-provider"] }); + await expect(f.records.getSource(record.id, signal())).rejects.toThrow( + "unique admitted provider", + ); + }); + + it("refuses bindings changed during a source read", async () => { + const f = await fixture(); + const get = f.client.get.bind(f.client); + vi.spyOn(f.client, "get").mockImplementation(async (...args) => { + const record = await get(...args); + f.catalog.types[0]!.implements[0]!.fields.set("title", "other_caption"); + return record; + }); + await expect(f.records.getSource(f.source.id, signal())).rejects.toThrow("bindings changed"); + }); + + it("refuses records missing required role values", async () => { + const f = await fixture(); + const record = await f.client.get(f.source.id, { body: true, document: true, effective: true }); + record.frontmatter.delete("work_id"); + record.effective?.delete("work_id"); + vi.spyOn(f.client, "get").mockResolvedValue(record); + await expect(f.records.getSource(record.id, signal())).rejects.toThrow( + "lacks required contract fields", + ); + }); +}); + +describe("NextReaderRecords pagination/lifetime (SDK stand-in)", () => { + it.each(["incomplete", "duplicate", "cut", "bindings"])( + "refuses %s metadata snapshots", + async (failure) => { + const f = await fixture(); + vi.spyOn(f.client, "pages").mockImplementation(async function* () { + const record = await f.client.get(f.source.id, { effective: true }); + yield { records: [record], complete: failure !== "incomplete", asOf: 1 }; + if (failure === "bindings") { + f.catalog.types[0]!.implements[0]!.fields.set("title", "other_caption"); + } + yield { + records: failure === "duplicate" ? [record] : [], + complete: true, + asOf: failure === "cut" ? 2 : 1, + }; + }); + await expect(collect(f.records.sources(signal()))).rejects.toThrow(); + }, + ); + + it("propagates cursor failure without restart or fabricated success", async () => { + const f = await fixture(); + const problem = new MdbaseError({ + code: "invalid_request", + recovery: "fix_request", + reason: "cursor_stale", + message: "Cursor changed", + }); + const query = vi.spyOn(f.client, "query").mockRejectedValue(problem); + await expect(collect(f.records.sources(signal()))).rejects.toBe(problem); + expect(query).toHaveBeenCalledTimes(1); + }); + + it("does not publish a body after the caller aborts during I/O", async () => { + const f = await fixture(); + const controller = new AbortController(); + const get = f.client.get.bind(f.client); + vi.spyOn(f.client, "get").mockImplementation(async (...args) => { + const record = await get(...args); + controller.abort(new Error("Reader closed")); + return record; + }); + await expect(f.records.getSource(f.source.id, controller.signal)).rejects.toThrow( + "Reader closed", + ); + }); + + // Clients' regression: cancellation at the public describe recheck's nested + // promise boundary, not native parser/runtime or security qualification. + it.each(["full-source", "metadata"] as const)( + "does not publish %s after cancellation between recheck and delivery", + async (mode) => { + const f = await fixture(); + const controller = new AbortController(); + let calls = 0; + f.describe.mockImplementation(() => { + if (++calls === 2) { + queueMicrotask(() => + queueMicrotask(() => + queueMicrotask(() => controller.abort(new Error("Reader closed after recheck"))), + ), + ); + } + return Promise.resolve(f.catalog); + }); + const result = + mode === "full-source" + ? f.records.getSource(f.source.id, controller.signal) + : collect(f.records.sources(controller.signal)); + await expect(result).rejects.toThrow("Reader closed after recheck"); + expect(controller.signal.aborted).toBe(true); + }, + ); +}); diff --git a/packages/connect/src/next-reader-records.ts b/packages/connect/src/next-reader-records.ts new file mode 100644 index 00000000..fbdb7dd9 --- /dev/null +++ b/packages/connect/src/next-reader-records.ts @@ -0,0 +1,212 @@ +import { revisionOf, type MdbaseClient, type RecordRef, type wire } from "@mdbase-dev/sdk"; + +import { annotationContract, sourceContract } from "./contracts.js"; + +/** Semantic contract digests supplied by qualified native manifest/setup inputs, + * NOT guessed from raw Markdown resource hashes or historical starter names. */ +export interface NextReaderContractDigests { + readonly source: wire.Hash; + readonly annotation: wire.Hash; +} + +export interface NextReaderRecord { + readonly record: wire.RecordView; + readonly providerType: string; + readonly fields: ReadonlyMap; +} + +type Domain = keyof NextReaderContractDigests; +type Providers = ReadonlyMap>; +const contracts = { source: sourceContract, annotation: annotationContract }; +const required = { + source: ["id", "title", "kind", "saved_at"], + annotation: ["id", "source", "annotation_type", "created_at"], +}; + +/** Native read-only source/annotation foundation, not a Connect-shaped SDK + * facade. Returns actual SDK views/tagged values, retaining native state and + * identities. Sign-in/collection lifetime and all writes remain SDK-owned. */ +export class NextReaderRecords { + private readonly digests: NextReaderContractDigests; + + constructor( + private readonly client: MdbaseClient, + digests: NextReaderContractDigests, + ) { + for (const domain of ["source", "annotation"] as const) { + if (!/^sha256:[0-9a-f]{64}$/.test(digests[domain])) { + throw new Error(`Reader needs a qualified native ${domain} contract digest.`); + } + } + this.digests = { ...digests }; + } + + sources(signal: AbortSignal): AsyncIterable { + return this.metadata("source", signal); + } + + annotations(signal: AbortSignal): AsyncIterable { + return this.metadata("annotation", signal); + } + + getSource(ref: RecordRef, signal: AbortSignal): Promise { + return this.read("source", ref, signal); + } + + getAnnotation(ref: RecordRef, signal: AbortSignal): Promise { + return this.read("annotation", ref, signal); + } + + private async providers(domain: Domain, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const catalog = await this.client.describe(signal); + signal.throwIfAborted(); + const expected = contracts[domain]; + const matching = catalog.contracts.filter( + (contract) => contract.id === expected.id && contract.version === expected.version, + ); + const contract = matching[0]; + if ( + matching.length !== 1 || + contract?.digest !== this.digests[domain] || + contract.contractType !== "record" + ) { + throw new Error(`This collection does not provide the qualified Reader ${domain} contract.`); + } + const providers = new Map>(); + for (const type of catalog.types) { + const bindings = type.implements.filter( + (binding) => binding.contract === expected.id && binding.version === expected.version, + ); + if (bindings.length > 1 || providers.has(type.name)) { + throw new Error(`The Reader ${domain} provider is ambiguous.`); + } + const binding = bindings[0]; + if (!binding) { + continue; + } + const fields = new Map(binding.fields); + if (required[domain].some((role) => !fields.get(role))) { + throw new Error(`The Reader ${domain} provider lacks required field bindings.`); + } + if ([...fields.values()].some((field) => field.includes(".") || field.includes("["))) { + throw new Error("This Reader binding needs shared SDK nested-field reference support."); + } + providers.set(type.name, fields); + } + if (!providers.size) { + throw new Error(`The Reader ${domain} contract has no implementing types.`); + } + return providers; + } + + private async *metadata( + domain: Domain, + signal: AbortSignal, + ): AsyncIterable { + const providers = await this.providers(domain, signal); + const identities = new Set(); + let asOf: number | undefined; + for await (const page of this.client.pages( + { types: [...providers.keys()], limit: 1000 }, + { effective: true }, + signal, + )) { + signal.throwIfAborted(); + if (!page.complete) { + throw new Error("Reader did not receive a complete native metadata page."); + } + if (asOf !== undefined && page.asOf !== asOf) { + throw new Error("The Reader metadata snapshot changed between pages. Reload it."); + } + asOf = page.asOf; + await this.assertProviders(domain, providers, signal); + signal.throwIfAborted(); + const records = page.records.map((record) => { + if (identities.has(record.id)) { + throw new Error("Reader metadata repeated a native record identity."); + } + identities.add(record.id); + return project(domain, record, providers); + }); + yield records; + } + } + + private async read( + domain: Domain, + ref: RecordRef, + signal: AbortSignal, + ): Promise { + // Capture the selected reference before awaiting catalog I/O. Do not let a + // caller-mutated path/UUID switch the record whose body is opened. + const selected: RecordRef = + typeof ref === "string" ? ref : "id" in ref ? ref.id : { path: ref.path }; + const providers = await this.providers(domain, signal); + const record = await this.client.get( + selected, + { body: true, document: true, effective: true }, + signal, + ); + signal.throwIfAborted(); + if (typeof selected === "string" ? record.id !== selected : record.path !== selected.path) { + throw new Error("The Reader record changed its selected native identity."); + } + if ( + record.body === undefined || + record.document === undefined || + revisionOf(record.document) !== record.revision + ) { + throw new Error( + "Reader did not receive the actual complete revision-consistent source. No source was reconstructed.", + ); + } + await this.assertProviders(domain, providers, signal); + signal.throwIfAborted(); + return project(domain, record, providers); + } + + private async assertProviders( + domain: Domain, + captured: Providers, + signal: AbortSignal, + ): Promise { + const current = await this.providers(domain, signal); + if ( + current.size !== captured.size || + [...captured].some(([type, fields]) => { + const fresh = current.get(type); + return ( + fresh?.size !== fields.size || + [...fields].some(([role, field]) => fresh.get(role) !== field) + ); + }) + ) { + throw new Error("The Reader provider bindings changed while reading. Reload the collection."); + } + } +} + +function project(domain: Domain, record: wire.RecordView, providers: Providers): NextReaderRecord { + const matching = record.types.filter((type) => providers.has(type)); + const providerType = matching[0]; + if (matching.length !== 1 || !providerType) { + throw new Error("Reader cannot select a unique admitted provider for this record."); + } + const values = record.effective ?? record.frontmatter; + const fields = new Map(); + const binding = providers.get(providerType); + if (!binding) { + throw new Error("The Reader provider is no longer admitted."); + } + for (const [role, field] of binding) { + const value = values.get(field); + if (value !== undefined) { + fields.set(role, value); + } + } + if (required[domain].some((role) => !fields.has(role))) { + throw new Error("The Reader record lacks required contract fields."); + } + return { record, providerType, fields }; +} diff --git a/packages/connect/src/repositories.test.ts b/packages/connect/src/repositories.test.ts index 233d9a4b..a270403c 100644 --- a/packages/connect/src/repositories.test.ts +++ b/packages/connect/src/repositories.test.ts @@ -198,9 +198,11 @@ describe("ConnectAnnotationRepository", () => { type: "reader-annotation", body: "> Selected text", }), + { intent: "mutation-1" }, ); expect(create).not.toHaveBeenCalledWith( expect.objectContaining({ contract: annotationContract }), + expect.anything(), ); }); }); diff --git a/packages/connect/src/repository-client.ts b/packages/connect/src/repository-client.ts index b432a3e8..566ee320 100644 --- a/packages/connect/src/repository-client.ts +++ b/packages/connect/src/repository-client.ts @@ -25,6 +25,14 @@ import type { ReaderRequestOptions } from "@mdbase-reader/core"; export const readerConnectBulkConcurrency = 4; +export interface ReaderCreateOptions { + /** + * A stable name for this one save, such as its client-minted mutation ID. A retry with the + * same intent lands once natively; the legacy Connect client ignores it. + */ + readonly intent?: string; +} + export interface ReaderQueryPagesOptions extends ReaderRequestOptions { readonly firstPageSize?: number; readonly pageSize?: number; @@ -49,7 +57,10 @@ export interface ReaderConnectClient { input: QueryInput, options?: ReaderQueryPagesOptions, ): AsyncIterable>; - create(input: CreateInput): Promise>; + create( + input: CreateInput, + options?: ReaderCreateOptions, + ): Promise>; update(input: UpdateInput): Promise>; preflightDelete(input: DeleteInput): Promise>; deleteWithProgress( diff --git a/packages/connect/src/source-repository.ts b/packages/connect/src/source-repository.ts index 9aee04e5..39f55ce8 100644 --- a/packages/connect/src/source-repository.ts +++ b/packages/connect/src/source-repository.ts @@ -10,6 +10,7 @@ import { type Page, type ReaderRequestOptions, type Source, + type SourceDeletionPlan, type SourceFieldChange, type SourceId, type SourceQuery, @@ -31,9 +32,11 @@ import { writeSourceFields } from "./source-fields.js"; import * as lookups from "./source-lookups.js"; import type { ReaderConnectClient } from "./repository-client.js"; -import type { QueryRecord } from "@mdbase-dev/connect"; +import type { DeletePreflightResult, QueryRecord } from "@mdbase-dev/connect"; export class ConnectSourceRepository implements SourceRepository { + readonly #deletePreflights = new Map(); + constructor(private readonly client: ReaderConnectClient) {} async list(query: SourceQuery, options: ReaderRequestOptions = {}): Promise> { @@ -224,6 +227,45 @@ export class ConnectSourceRepository implements SourceRepository { return recordRevision(updated.revision); } + async preflightDelete(source: Source): Promise { + const result = outcomeValue( + await this.client.preflightDelete({ path: source.path, ifRevision: source.recordRevision }), + "check source removal", + ); + this.#deletePreflights.set(deletionKey(source.path, source.recordRevision), result); + return { + sourceId: source.id, + path: source.path, + expectedRevision: source.recordRevision, + brokenLinkPaths: [...new Set(result.brokenLinks?.map(({ path }) => path) ?? [])].sort( + (left, right) => left.localeCompare(right), + ), + }; + } + + /** Deletes the source record only; its stored files and annotation records are left as is. */ + async delete(plan: SourceDeletionPlan): Promise { + const key = deletionKey(plan.path, plan.expectedRevision); + const preflight = this.#deletePreflights.get(key); + if (!preflight) { + throw new Error("Source removal requires a current preflight confirmation."); + } + try { + const result = outcomeValue( + await this.client.deleteWithProgress( + { path: plan.path, ifRevision: plan.expectedRevision, checkBacklinks: true }, + { preflight }, + ), + "remove source", + ); + if (!result.deleted) { + throw new Error(`mdbase Connect did not remove source ${plan.sourceId}.`); + } + } finally { + this.#deletePreflights.delete(key); + } + } + async #path(id: SourceId, operation: string): Promise { const path = await recordPathById(this.client, id); if (!path) { @@ -253,3 +295,7 @@ export class ConnectSourceRepository implements SourceRepository { return [...filterSources(sources, query.search)]; } } + +function deletionKey(path: string, revision: string): string { + return `${path}\n${revision}`; +} diff --git a/packages/connect/vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz b/packages/connect/vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz new file mode 100644 index 00000000..04dddacd Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-38001079.tgz b/packages/connect/vendor/mdbase-dev-sdk-38001079.tgz new file mode 100644 index 00000000..960b44b5 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-38001079.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-013c7e56.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-013c7e56.tgz new file mode 100644 index 00000000..19c9d43e Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-013c7e56.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-0aeaada6.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-0aeaada6.tgz new file mode 100644 index 00000000..357a9855 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-0aeaada6.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-0f5724ef.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-0f5724ef.tgz new file mode 100644 index 00000000..966cc494 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-0f5724ef.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-0f9a3614.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-0f9a3614.tgz new file mode 100644 index 00000000..967fd5ff Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-0f9a3614.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-106d36ff.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-106d36ff.tgz new file mode 100644 index 00000000..dd897e28 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-106d36ff.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-12c28ef1.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-12c28ef1.tgz new file mode 100644 index 00000000..2f9e5992 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-12c28ef1.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-1629d11e.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-1629d11e.tgz new file mode 100644 index 00000000..8625271f Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-1629d11e.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-1dddb04c.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-1dddb04c.tgz new file mode 100644 index 00000000..b79eb2ce Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-1dddb04c.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-1f7b4f7a.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-1f7b4f7a.tgz new file mode 100644 index 00000000..1ffc11be Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-1f7b4f7a.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-20c347ad.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-20c347ad.tgz new file mode 100644 index 00000000..31f374b4 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-20c347ad.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-23a10ee4.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-23a10ee4.tgz new file mode 100644 index 00000000..8d7845c5 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-23a10ee4.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-3491a619.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-3491a619.tgz new file mode 100644 index 00000000..43b0ff6d Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-3491a619.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-39ba2b8a.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-39ba2b8a.tgz new file mode 100644 index 00000000..6ab8bda8 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-39ba2b8a.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-3f417b6d.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-3f417b6d.tgz new file mode 100644 index 00000000..0aa6f0f0 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-3f417b6d.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-41c26ca7.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-41c26ca7.tgz new file mode 100644 index 00000000..a98028b7 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-41c26ca7.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-47225957.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-47225957.tgz new file mode 100644 index 00000000..59256519 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-47225957.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-4728a54c.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-4728a54c.tgz new file mode 100644 index 00000000..35bb2d88 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-4728a54c.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-48b9f094.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-48b9f094.tgz new file mode 100644 index 00000000..ea28b233 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-48b9f094.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-4c10b9a5.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-4c10b9a5.tgz new file mode 100644 index 00000000..1d03ae9b Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-4c10b9a5.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-4c484d3c.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-4c484d3c.tgz new file mode 100644 index 00000000..13624a73 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-4c484d3c.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-53643b29.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-53643b29.tgz new file mode 100644 index 00000000..98e067f8 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-53643b29.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-5710ab13.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-5710ab13.tgz new file mode 100644 index 00000000..1f03646a Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-5710ab13.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-5c7b9592.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-5c7b9592.tgz new file mode 100644 index 00000000..a28f59e3 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-5c7b9592.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-5f2c3d5d.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-5f2c3d5d.tgz new file mode 100644 index 00000000..a4d5c04e Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-5f2c3d5d.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-608bb0d5.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-608bb0d5.tgz new file mode 100644 index 00000000..fe3ccba6 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-608bb0d5.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-6a576a5f.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-6a576a5f.tgz new file mode 100644 index 00000000..9ff95073 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-6a576a5f.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-6f14ebe1.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-6f14ebe1.tgz new file mode 100644 index 00000000..d9aed32f Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-6f14ebe1.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-6f2b1e51.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-6f2b1e51.tgz new file mode 100644 index 00000000..56c50771 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-6f2b1e51.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-72f8ea6c.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-72f8ea6c.tgz new file mode 100644 index 00000000..7d4bf63c Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-72f8ea6c.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-8b18a6c7.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-8b18a6c7.tgz new file mode 100644 index 00000000..b962f781 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-8b18a6c7.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-8bc12525.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-8bc12525.tgz new file mode 100644 index 00000000..4fcf283b Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-8bc12525.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz new file mode 100644 index 00000000..733c0374 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-a8fdbdce.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-a8fdbdce.tgz new file mode 100644 index 00000000..cc7b9738 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-a8fdbdce.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-a965bd26.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-a965bd26.tgz new file mode 100644 index 00000000..791b073f Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-a965bd26.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-abc08f3e.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-abc08f3e.tgz new file mode 100644 index 00000000..cf41dac2 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-abc08f3e.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-ac3bff98.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-ac3bff98.tgz new file mode 100644 index 00000000..7396ddb3 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-ac3bff98.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-af29edc9.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-af29edc9.tgz new file mode 100644 index 00000000..a4cf5929 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-af29edc9.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-b1c47a32.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-b1c47a32.tgz new file mode 100644 index 00000000..0440e1bd Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-b1c47a32.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-bef3fe45.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-bef3fe45.tgz new file mode 100644 index 00000000..9be0c219 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-bef3fe45.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-ca8f0682.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-ca8f0682.tgz new file mode 100644 index 00000000..e52ddf50 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-ca8f0682.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-cf294bdb.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-cf294bdb.tgz new file mode 100644 index 00000000..825e955e Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-cf294bdb.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-d230034f.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-d230034f.tgz new file mode 100644 index 00000000..7912263c Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-d230034f.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-d3559aaa.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-d3559aaa.tgz new file mode 100644 index 00000000..a8d443e9 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-d3559aaa.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-e00c1ff8.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-e00c1ff8.tgz new file mode 100644 index 00000000..d4ef93b8 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-e00c1ff8.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-e4450c26.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-e4450c26.tgz new file mode 100644 index 00000000..c47979b3 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-e4450c26.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-f1aa67e3.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-f1aa67e3.tgz new file mode 100644 index 00000000..895da223 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-f1aa67e3.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-fb3308a8.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-fb3308a8.tgz new file mode 100644 index 00000000..aad7a912 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-fb3308a8.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-fb9691b0.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-fb9691b0.tgz new file mode 100644 index 00000000..dacb2b1e Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-fb9691b0.tgz differ diff --git a/packages/connect/vendor/mdbase-dev-sdk-dev-fd0c4a67.tgz b/packages/connect/vendor/mdbase-dev-sdk-dev-fd0c4a67.tgz new file mode 100644 index 00000000..ff885a31 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-dev-fd0c4a67.tgz differ diff --git a/packages/connect/vendor/mdbase-next-sdk.json b/packages/connect/vendor/mdbase-next-sdk.json new file mode 100644 index 00000000..702fd859 --- /dev/null +++ b/packages/connect/vendor/mdbase-next-sdk.json @@ -0,0 +1,46 @@ +{ + "package": "@mdbase-dev/sdk", + "version": "0.0.0", + "artifact": "mdbase-dev-sdk-dev-9a033b25.tgz", + "cutIdentity": "sdk-dev-9a033b25", + "sourceCommit": "9a033b252860ec8624da0abb5ffdecbac56332d2", + "label": "dev intake, not release-qualified", + "sha256": "90ef67523dfa11cb36efe0d996475f1a1c82ac71074e4cd4e95c6fbe02c18be9", + "integrity": "sha512-Th4bgqvvgDPQQ7hkEwyo6ZG2SFQ7L7J6qdeaoTVjsOTyop2vmEmuUpMZ/EkUPZA0Fo0oq6wbOcsRk+en5vQEOQ==", + "bytes": 4420118, + "native": { + "available": true, + "sourceCommit": "a4331ad466a0d16e87ab3bedeb0963a1e0d1867e", + "bytes": 3655948, + "sha256": "3c803e83f81a05855f6e30ca79036d6b19383048a6b11ba5c9a0b5ebf3a1bbe2" + }, + "releaseQualified": false, + "localDependencies": [ + { + "package": "@mdbase-dev/connect-protocol", + "version": "0.1.0-beta.129", + "artifact": "mdbase-dev-connect-protocol-0.1.0-beta.129-d765e675c3da.tgz", + "sourceCommit": "d765e675c3da29fe1693deab53c4c2a922bb86f1", + "sha256": "2769616d8ba1a9cbb3dd689fa1ca0b1e2b42e5442634635f2cd4a87f5ec9020f", + "integrity": "sha512-PFG1qmAzmcyJjmZLmGz4eoOOZRDgvTvDZATf6IGBccNHRc1eGZtMm7ZUA7gNAxuO5KerrN6mDIB9qQnpojyOAg==" + } + ], + "bundledOriginalDependencies": [ + { + "name": "@mdbase-dev/connect-protocol", + "file": "vendor/mdbase-dev-connect-protocol-0.1.0-beta.129-c280cd194ffd.tgz", + "bytes": 107322, + "sha256": "cd1e15236ab7e25c511a4ba13af6a9df23f4b41715161479bba97b0445e1c138", + "sri": "sha512-c0iOOWEcNJRstdyUJ6aqJ3puVR5C9jXW4MBHmQnHI2c4vgOeKi5VWfiuueWx3ArvtwB3nPuAaXHhll/OKH/+Yw==" + } + ], + "vendorWarnings": [], + "includesPublicApplicationEntry": true, + "includesPublicApplicationFixture": true, + "applicationRuntimeAvailable": true, + "sourceOnly": false, + "nativeRuntimeQualified": false, + "perAppRuntimeTrustOriginAndOperationsQualified": false, + "nativeAssetsPackaged": true, + "productionTrustAvailable": false +} diff --git a/packages/core/src/application/ports.ts b/packages/core/src/application/ports.ts index 99f14fb2..34628b38 100644 --- a/packages/core/src/application/ports.ts +++ b/packages/core/src/application/ports.ts @@ -18,6 +18,7 @@ import type { ReadingPosition, ReadingStatus, Source, + SourceDeletionPlan, SourceFieldChange, SourceQuery, SourceSummary, @@ -82,6 +83,10 @@ export interface SourceRepository extends SourceLookups { readonly sourceId: SourceId; readonly citation: CslItem; }): Promise; + /** Checks removing a source record at its current revision and lists what links to it. */ + preflightDelete?(source: Source): Promise; + /** Removes only the source record checked by `plan`; its files and annotations are kept. */ + delete?(plan: SourceDeletionPlan): Promise; appendAnnotationEmbed(input: { readonly collectionId: CollectionId; readonly sourceId: SourceId; diff --git a/packages/core/src/domain/source.ts b/packages/core/src/domain/source.ts index 0bc27900..2bc244e7 100644 --- a/packages/core/src/domain/source.ts +++ b/packages/core/src/domain/source.ts @@ -57,6 +57,17 @@ export interface Source extends SourceSummary { readonly frontmatter: Readonly>; } +/** + * A checked removal of one source record. It names the revision that was checked, so a source + * changed since then is not removed, and the records that link to it, which are kept. + */ +export interface SourceDeletionPlan { + readonly sourceId: SourceId; + readonly path: string; + readonly expectedRevision: RecordRevision; + readonly brokenLinkPaths: readonly string[]; +} + export interface SourceQuery { readonly collectionId: CollectionId; readonly search?: string; diff --git a/packages/markdown-editor/package.json b/packages/markdown-editor/package.json index 3f6222be..79d19ea4 100644 --- a/packages/markdown-editor/package.json +++ b/packages/markdown-editor/package.json @@ -21,7 +21,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.43.8", "@lezer/highlight": "^1.2.3", - "@mdbase-dev/ui": "0.1.0-beta.125", + "@mdbase-dev/ui": "file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz", "@mdbase-reader/core": "workspace:*", "react": "^19.2.8" }, diff --git a/packages/ui/package.json b/packages/ui/package.json index d5e608e0..73a8c67a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,7 +14,7 @@ "dependencies": { "@fontsource/atkinson-hyperlegible": "^5.3.0", "@fontsource/azeret-mono": "^5.3.0", - "@mdbase-dev/ui": "0.1.0-beta.125", + "@mdbase-dev/ui": "file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz", "@phosphor-icons/react": "^2.1.10", "react": "^19.2.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e10d11d1..4c27d455 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 8.66.0(eslint@9.39.5)(typescript@5.9.3) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@22.20.1)(happy-dom@20.11.2)(vite@8.3.0(@types/node@22.20.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@22.20.1)(happy-dom@20.11.2)(vite@8.3.4(@types/node@22.20.1)(yaml@2.9.0)) yaml: specifier: ^2.9.0 version: 2.9.0 @@ -58,23 +58,23 @@ importers: dependencies: '@capacitor/browser': specifier: latest - version: 8.0.4(@capacitor/core@8.5.2) + version: 8.0.5(@capacitor/core@8.5.3) '@capacitor/core': specifier: latest - version: 8.5.2 + version: 8.5.3 '@capacitor/filesystem': specifier: latest - version: 8.1.3(@capacitor/core@8.5.2) + version: 8.1.4(@capacitor/core@8.5.3) '@capacitor/preferences': specifier: latest - version: 8.0.1(@capacitor/core@8.5.2) + version: 8.0.1(@capacitor/core@8.5.3) '@mdbase-reader/platform': specifier: workspace:* version: link:../../packages/platform devDependencies: '@capacitor/cli': specifier: latest - version: 8.5.2 + version: 8.5.3 apps/electron: dependencies: @@ -83,13 +83,13 @@ importers: version: link:../../packages/platform electron: specifier: latest - version: 44.4.1 + version: 44.7.2 apps/extension: dependencies: '@mdbase-dev/ui': - specifier: 0.1.0-beta.125 - version: 0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8) + specifier: file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz + version: file:vendor/mdbase-dev-ui-dev-9553ad66.tgz(@codemirror/view@6.43.8)(@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@mdbase-reader/connect': specifier: workspace:* version: link:../../packages/connect @@ -123,19 +123,25 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: latest - version: 6.1.1(vite@8.3.0(@types/node@24.13.3)(yaml@2.9.1)) + version: 6.1.2(vite@8.3.4(@types/node@24.13.3)(yaml@2.9.1)) vite: specifier: latest - version: 8.3.0(@types/node@24.13.3)(yaml@2.9.1) + version: 8.3.4(@types/node@24.13.3)(yaml@2.9.1) apps/reader: dependencies: '@citation-js/plugin-csl': specifier: 0.8.2 version: 0.8.2(@citation-js/core@0.8.2) + '@mdbase-dev/sdk': + specifier: file:../../packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz + version: file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz + '@mdbase-dev/sdk-legacy-729b5391': + specifier: file:../../vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz + version: '@mdbase-dev/sdk@file:vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz' '@mdbase-dev/ui': - specifier: 0.1.0-beta.125 - version: 0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8) + specifier: file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz + version: file:vendor/mdbase-dev-ui-dev-9553ad66.tgz(@codemirror/view@6.43.8)(@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@mdbase-reader/connect': specifier: workspace:* version: link:../../packages/connect @@ -208,7 +214,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react': specifier: latest - version: 6.1.1(vite@8.3.0(@types/node@24.13.3)(yaml@2.9.0)) + version: 6.1.2(vite@8.3.4(@types/node@24.13.3)(yaml@2.9.0)) axe-core: specifier: ^4.13.0 version: 4.13.0 @@ -217,7 +223,7 @@ importers: version: 6.2.4 vite: specifier: latest - version: 8.3.0(@types/node@24.13.3)(yaml@2.9.0) + version: 8.3.4(@types/node@24.13.3)(yaml@2.9.0) apps/zotero-exporter: {} @@ -226,6 +232,9 @@ importers: '@mdbase-dev/connect': specifier: 0.1.0-beta.125 version: 0.1.0-beta.125 + '@mdbase-dev/sdk': + specifier: file:vendor/mdbase-dev-sdk-dev-9a033b25.tgz + version: file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz '@mdbase-reader/core': specifier: workspace:* version: link:../core @@ -271,8 +280,8 @@ importers: specifier: ^1.2.3 version: 1.2.3 '@mdbase-dev/ui': - specifier: 0.1.0-beta.125 - version: 0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8) + specifier: file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz + version: file:vendor/mdbase-dev-ui-dev-9553ad66.tgz(@codemirror/view@6.43.8)(@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@mdbase-reader/core': specifier: workspace:* version: link:../core @@ -411,8 +420,8 @@ importers: specifier: ^5.3.0 version: 5.3.0 '@mdbase-dev/ui': - specifier: 0.1.0-beta.125 - version: 0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8) + specifier: file:../../vendor/mdbase-dev-ui-dev-9553ad66.tgz + version: file:vendor/mdbase-dev-ui-dev-9553ad66.tgz(@codemirror/view@6.43.8)(@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -509,21 +518,21 @@ packages: resolution: {integrity: sha512-bXYhTqRqd928j9osTFGQTmzKG59W1pelmBV+UaEF1WOL+E5WEQVl4sBKvvdGvBVD9VMAq7IF1zJTkr3gqNb4Mw==} hasBin: true - '@capacitor/browser@8.0.4': - resolution: {integrity: sha512-ORZ4u/y+7aMuu6yVuk6SP529fUZyqcbnuHB1q5McpA3o2SYfVoi28iB8rsQjI2ad1qlKJd29ZUuGtspXLBDlWg==} + '@capacitor/browser@8.0.5': + resolution: {integrity: sha512-qqcEjUqs9kG1naF+tsD8rERuZ3wuIfXzvoIJEERHI4RNWwNSUqAHHduaKhkyb+t01VyVPa0ydx0mZ/eOskk2Ug==} peerDependencies: '@capacitor/core': '>=8.0.0' - '@capacitor/cli@8.5.2': - resolution: {integrity: sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==} + '@capacitor/cli@8.5.3': + resolution: {integrity: sha512-/G6xwna+p4GS3lthSdWN3OThCH2F85O0Ukdy8rv3oAXvgEMeyFgPEIA3PMAS++IkHwUuBYBAKOQJnZfcrh1n0w==} engines: {node: '>=22.0.0'} hasBin: true - '@capacitor/core@8.5.2': - resolution: {integrity: sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==} + '@capacitor/core@8.5.3': + resolution: {integrity: sha512-oBE1oGuYE4ZzYYrulC75OuHdSsD6zLUy7d5o1tFwDLG4govGoubY15cxpiI5l0+q87Lm5p0bhbehMdzJg4aFag==} - '@capacitor/filesystem@8.1.3': - resolution: {integrity: sha512-GNBlRkaXEEb+6WP/YsDXr2zygyOHoieuye6viyd2ZkEdBwfseG41jxfarVxLWH0r0owhyNUbwJ/Qi8fubqr6wA==} + '@capacitor/filesystem@8.1.4': + resolution: {integrity: sha512-8MXmt10cb+dGWoh8jUeO8on0U6iETQCX5k+fmdB278PZ3MYDEVGQo08+hroaOtUbK3+5stiRk4/K6QBHKepv0A==} peerDependencies: '@capacitor/core': '>=8.0.0' @@ -1015,6 +1024,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fontsource-variable/atkinson-hyperlegible-next@5.3.0': + resolution: {integrity: sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==} + '@fontsource/atkinson-hyperlegible@5.3.0': resolution: {integrity: sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==} @@ -1135,14 +1147,33 @@ packages: '@mdbase-dev/connect@0.1.0-beta.125': resolution: {integrity: sha512-vEH4NQfu3dBQgMw0YQxG7LDtOOLxDyxET4DQhrWuHqtgr9qoch/H0QLzel9tFSqkyH+QWxuvW3Bet6ae1+qVyQ==} - '@mdbase-dev/ui@0.1.0-beta.125': - resolution: {integrity: sha512-K2UAAzwW4yuPJxx65LofVDQXKLgs4lOgrAB61YMuiNwV/jOc1yuNhGugy7Fx++VwxBx5bZyG+Pk7tMQlOat+tg==} + '@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz': + resolution: {integrity: sha512-Th4bgqvvgDPQQ7hkEwyo6ZG2SFQ7L7J6qdeaoTVjsOTyop2vmEmuUpMZ/EkUPZA0Fo0oq6wbOcsRk+en5vQEOQ==, tarball: file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz} + version: 0.0.0 + engines: {node: '>=20'} + bundledDependencies: + - '@mdbase-dev/connect-protocol' + + '@mdbase-dev/sdk@file:vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz': + resolution: {integrity: sha512-2KKV/f44MbSePCpR6ACXxiiKNbw3c2Cj91cdXyItBeQcBGpz+DNviUl+6zw34ztzwyb7k7d6UpcaDgr5VfwFLw==, tarball: file:vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz} + version: 0.0.0 + engines: {node: '>=20'} + + '@mdbase-dev/ui@file:vendor/mdbase-dev-ui-dev-9553ad66.tgz': + resolution: {integrity: sha512-PqMWvJ2ZbNaLxWTum2m7vmcCbOGdM5qldnphPwqyGBo/mcpvjfk5tHkil/vXLc+LuG7HEyZOLh+tZyUXtMv7QQ==, tarball: file:vendor/mdbase-dev-ui-dev-9553ad66.tgz} + version: 0.1.0-beta.129 peerDependencies: '@codemirror/view': ^6.0.0 + '@mdbase-dev/sdk': '*' react: ^19.2.0 + react-dom: ^19.2.0 peerDependenciesMeta: '@codemirror/view': optional: true + '@mdbase-dev/sdk': + optional: true + react-dom: + optional: true '@mozilla/readability@0.6.0': resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} @@ -1155,11 +1186,23 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.4.0': + resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} - '@oxc-project/types@0.149.0': - resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} '@pdf-lib/standard-fonts@1.0.0': resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} @@ -1199,92 +1242,92 @@ packages: resolution: {integrity: sha512-j3N2dqCuFja5ToksYr+G4uKg5vnzNVSLlZy9C071jSkQeRUSIIdREPYo5Gx/XOptkvPwip9O28oaG/JXCW4Z1g==} engines: {node: '>=18'} - '@rolldown/binding-android-arm-eabi@1.2.8': - resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.8': - resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.8': - resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.8': - resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.8': - resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': - resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.8': - resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.2.8': - resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-ppc64-gnu@1.2.8': - resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@rolldown/binding-linux-s390x-gnu@1.2.8': - resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.2.8': - resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.2.8': - resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.2.8': - resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.8': - resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.8': - resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1553,13 +1596,13 @@ packages: cpu: [x64] os: [win32] - '@vitejs/plugin-react@6.1.1': - resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 - oxc-transform-react: ^0.145.0 + oxc-transform-react: ^0.152.0 vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': @@ -1628,6 +1671,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@zip.js/zip.js@2.8.36': resolution: {integrity: sha512-3CT63BXqYh781nZhNVOktg71oM1496BZhBHDjP5aOZTDmsL2nLkldbLDJFatFJt00j94TFnsZu2pWNijL4Eorw==} @@ -1920,8 +1964,8 @@ packages: electron-to-chromium@1.5.403: resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} - electron@44.4.1: - resolution: {integrity: sha512-gXJarQ2Utfn8G6bp/IqLXvckOSCtrxPWjFqQaqm02Gf4zYn1aP+RN9PKimoLH/DMqjKLfBrNC4C4CviPv0cXnQ==} + electron@44.7.2: + resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==} engines: {node: '>= 22.12.0'} hasBin: true @@ -2056,6 +2100,7 @@ packages: eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -2627,8 +2672,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2766,8 +2811,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} preact@10.29.8: @@ -2836,8 +2881,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown@1.2.8: - resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2931,8 +2976,8 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} split2@4.2.0: @@ -3151,8 +3196,8 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true - vite@8.3.0: - resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3475,11 +3520,11 @@ snapshots: ulid: 2.4.0 yaml: 2.9.1 - '@capacitor/browser@8.0.4(@capacitor/core@8.5.2)': + '@capacitor/browser@8.0.5(@capacitor/core@8.5.3)': dependencies: - '@capacitor/core': 8.5.2 + '@capacitor/core': 8.5.3 - '@capacitor/cli@8.5.2': + '@capacitor/cli@8.5.3': dependencies: '@ionic/cli-framework-output': 2.2.8 '@ionic/utils-subprocess': 3.0.1 @@ -3502,18 +3547,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@capacitor/core@8.5.2': + '@capacitor/core@8.5.3': dependencies: tslib: 2.8.1 - '@capacitor/filesystem@8.1.3(@capacitor/core@8.5.2)': + '@capacitor/filesystem@8.1.4(@capacitor/core@8.5.3)': dependencies: - '@capacitor/core': 8.5.2 + '@capacitor/core': 8.5.3 '@capacitor/synapse': 1.0.4 - '@capacitor/preferences@8.0.1(@capacitor/core@8.5.2)': + '@capacitor/preferences@8.0.1(@capacitor/core@8.5.3)': dependencies: - '@capacitor/core': 8.5.2 + '@capacitor/core': 8.5.3 '@capacitor/synapse@1.0.4': {} @@ -4120,6 +4165,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fontsource-variable/atkinson-hyperlegible-next@5.3.0': {} + '@fontsource/atkinson-hyperlegible@5.3.0': {} '@fontsource/azeret-mono@5.3.0': {} @@ -4299,13 +4346,27 @@ snapshots: dependencies: '@mdbase-dev/connect-protocol': 0.1.0-beta.125 - '@mdbase-dev/ui@0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8)': + '@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz': + dependencies: + '@noble/ciphers': 2.4.0 + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + + '@mdbase-dev/sdk@file:vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz': + dependencies: + '@noble/ciphers': 2.4.0 + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + + '@mdbase-dev/ui@file:vendor/mdbase-dev-ui-dev-9553ad66.tgz(@codemirror/view@6.43.8)(@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@fontsource/atkinson-hyperlegible': 5.3.0 + '@fontsource-variable/atkinson-hyperlegible-next': 5.3.0 '@fontsource/azeret-mono': 5.3.0 react: 19.2.8 optionalDependencies: '@codemirror/view': 6.43.8 + '@mdbase-dev/sdk': file:packages/connect/vendor/mdbase-dev-sdk-dev-9a033b25.tgz + react-dom: 19.2.8(react@19.2.8) '@mozilla/readability@0.6.0': {} @@ -4316,9 +4377,17 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/ciphers@2.4.0': {} + + '@noble/curves@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@noble/hashes@2.4.0': {} + '@nodable/entities@3.0.0': {} - '@oxc-project/types@0.149.0': {} + '@oxc-project/types@0.153.0': {} '@pdf-lib/standard-fonts@1.0.0': dependencies: @@ -4360,49 +4429,49 @@ snapshots: '@readium/shared@2.4.0': {} - '@rolldown/binding-android-arm-eabi@1.2.8': + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true - '@rolldown/binding-android-arm64@1.2.8': + '@rolldown/binding-android-arm64@1.2.13': optional: true - '@rolldown/binding-darwin-arm64@1.2.8': + '@rolldown/binding-darwin-arm64@1.2.13': optional: true - '@rolldown/binding-darwin-x64@1.2.8': + '@rolldown/binding-darwin-x64@1.2.13': optional: true - '@rolldown/binding-freebsd-x64@1.2.8': + '@rolldown/binding-freebsd-x64@1.2.13': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.8': + '@rolldown/binding-linux-arm64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.8': + '@rolldown/binding-linux-arm64-musl@1.2.13': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.8': + '@rolldown/binding-linux-ppc64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.8': + '@rolldown/binding-linux-s390x-gnu@1.2.13': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.8': + '@rolldown/binding-linux-x64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-x64-musl@1.2.8': + '@rolldown/binding-linux-x64-musl@1.2.13': optional: true - '@rolldown/binding-openharmony-arm64@1.2.8': + '@rolldown/binding-openharmony-arm64@1.2.13': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.8': + '@rolldown/binding-win32-arm64-msvc@1.2.13': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.8': + '@rolldown/binding-win32-x64-msvc@1.2.13': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -4662,15 +4731,15 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.3)(yaml@2.9.0))': + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@24.13.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0(@types/node@24.13.3)(yaml@2.9.0) + vite: 8.3.4(@types/node@24.13.3)(yaml@2.9.0) - '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.3)(yaml@2.9.1))': + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@24.13.3)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0(@types/node@24.13.3)(yaml@2.9.1) + vite: 8.3.4(@types/node@24.13.3)(yaml@2.9.1) '@vitest/expect@4.1.10': dependencies: @@ -4681,13 +4750,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.3.0(@types/node@22.20.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.3.4(@types/node@22.20.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.0(@types/node@22.20.1)(yaml@2.9.0) + vite: 8.3.4(@types/node@22.20.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -4719,7 +4788,7 @@ snapshots: '@vue/shared': 3.5.41 entities: 7.0.1 estree-walker: 2.0.2 - source-map-js: 1.2.1 + source-map-js: 1.2.2 '@vue/compiler-dom@3.5.41': dependencies: @@ -4735,8 +4804,8 @@ snapshots: '@vue/shared': 3.5.41 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.28 - source-map-js: 1.2.1 + postcss: 8.5.29 + source-map-js: 1.2.2 '@vue/compiler-ssr@3.5.41': dependencies: @@ -5040,7 +5109,7 @@ snapshots: electron-to-chromium@1.5.403: {} - electron@44.4.1: + electron@44.7.2: dependencies: '@electron-internal/extract-zip': 1.0.4 '@electron/get': 5.0.0 @@ -5811,7 +5880,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.18: {} + nanoid@3.3.20: {} napi-postinstall@0.3.4: {} @@ -5954,11 +6023,11 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.28: + postcss@8.5.29: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 preact@10.29.8: {} @@ -6019,26 +6088,26 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown@1.2.8: + rolldown@1.2.13: dependencies: - '@oxc-project/types': 0.149.0 + '@oxc-project/types': 0.153.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.8 - '@rolldown/binding-android-arm64': 1.2.8 - '@rolldown/binding-darwin-arm64': 1.2.8 - '@rolldown/binding-darwin-x64': 1.2.8 - '@rolldown/binding-freebsd-x64': 1.2.8 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 - '@rolldown/binding-linux-arm64-gnu': 1.2.8 - '@rolldown/binding-linux-arm64-musl': 1.2.8 - '@rolldown/binding-linux-ppc64-gnu': 1.2.8 - '@rolldown/binding-linux-s390x-gnu': 1.2.8 - '@rolldown/binding-linux-x64-gnu': 1.2.8 - '@rolldown/binding-linux-x64-musl': 1.2.8 - '@rolldown/binding-openharmony-arm64': 1.2.8 - '@rolldown/binding-win32-arm64-msvc': 1.2.8 - '@rolldown/binding-win32-x64-msvc': 1.2.8 + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 safe-array-concat@1.1.4: dependencies: @@ -6150,7 +6219,7 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} split2@4.2.0: {} @@ -6416,46 +6485,46 @@ snapshots: uuid@7.0.3: {} - vite@8.3.0(@types/node@22.20.1)(yaml@2.9.0): + vite@8.3.4(@types/node@22.20.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.2.8 + postcss: 8.5.29 + rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 yaml: 2.9.0 - vite@8.3.0(@types/node@24.13.3)(yaml@2.9.0): + vite@8.3.4(@types/node@24.13.3)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.2.8 + postcss: 8.5.29 + rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 fsevents: 2.3.3 yaml: 2.9.0 - vite@8.3.0(@types/node@24.13.3)(yaml@2.9.1): + vite@8.3.4(@types/node@24.13.3)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.2.8 + postcss: 8.5.29 + rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 fsevents: 2.3.3 yaml: 2.9.1 - vitest@4.1.10(@types/node@22.20.1)(happy-dom@20.11.2)(vite@8.3.0(@types/node@22.20.1)(yaml@2.9.0)): + vitest@4.1.10(@types/node@22.20.1)(happy-dom@20.11.2)(vite@8.3.4(@types/node@22.20.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.3.0(@types/node@22.20.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.3.4(@types/node@22.20.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -6472,7 +6541,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.3.0(@types/node@22.20.1)(yaml@2.9.0) + vite: 8.3.4(@types/node@22.20.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 diff --git a/vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz b/vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz new file mode 100644 index 00000000..6bec0770 Binary files /dev/null and b/vendor/mdbase-dev-sdk-0.0.0-729b5391.tgz differ diff --git a/vendor/mdbase-dev-ui-dev-9553ad66.tgz b/vendor/mdbase-dev-ui-dev-9553ad66.tgz new file mode 100644 index 00000000..e34254e2 Binary files /dev/null and b/vendor/mdbase-dev-ui-dev-9553ad66.tgz differ diff --git a/vendor/mdbase-next-sdk.json b/vendor/mdbase-next-sdk.json new file mode 100644 index 00000000..4eceaa61 --- /dev/null +++ b/vendor/mdbase-next-sdk.json @@ -0,0 +1,19 @@ +{ + "package": "@mdbase-dev/sdk", + "version": "0.0.0", + "artifact": "mdbase-dev-sdk-0.0.0-729b5391.tgz", + "sourceCommit": "729b5391f9e8e8c192d93a7112324ce65078d106", + "sha256": "fad2206e0a744ac5e801917831ee580ccbd2cf09df8bd4b3bd58bc76632bbeca", + "integrity": "sha512-2KKV/f44MbSePCpR6ACXxiiKNbw3c2Cj91cdXyItBeQcBGpz+DNviUl+6zw34ztzwyb7k7d6UpcaDgr5VfwFLw==", + "includesSessionFix808": true, + "includesRecordLeases809": true, + "includesMemoryDocumentCreate811": true, + "includesReadHelpers814": true, + "includesEnvironment816": true, + "includesSharedNativeFactory819": true, + "includesProviderReceipt827": true, + "sourceOnly": true, + "nativeFilesProducerQualified": false, + "peopleProducerCompatibilityQualified": false, + "perAppRuntimeTrustOriginAndOperationsQualified": false +} diff --git a/vendor/mdbase-ui-dev.json b/vendor/mdbase-ui-dev.json new file mode 100644 index 00000000..9124bd70 --- /dev/null +++ b/vendor/mdbase-ui-dev.json @@ -0,0 +1,14 @@ +{ + "package": "@mdbase-dev/ui", + "version": "0.1.0-beta.129", + "artifact": "mdbase-dev-ui-dev-9553ad66.tgz", + "cutIdentity": "ui-dev-9553ad66", + "sourceCommit": "9553ad660d0c05e035f3ca53b67cd799d3ece1d5", + "mergedAs": null, + "label": "dev intake, not release-qualified", + "sha256": "5e388db28365cf6a966017851ece1c32223ea8b55bc6015d2f1b4113d86a3206", + "integrity": "sha512-PqMWvJ2ZbNaLxWTum2m7vmcCbOGdM5qldnphPwqyGBo/mcpvjfk5tHkil/vXLc+LuG7HEyZOLh+tZyUXtMv7QQ==", + "bytes": 85693, + "releaseQualified": false, + "repository": "mdbase-dev/mdbase-connect" +}