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 ? (
+ <>
+
+ onRemoveSource(source)}>
+ Remove source…
+
+ >
+ ) : 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 (
-
-
- {service !== "home" ? (
-
- Import a library
- / {service === "zotero" ? "Zotero" : "Readwise Reader"}
-
- ) : 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 (
+
+
+ {service !== "home" ? (
+
+ Import a library
+ / {service === "zotero" ? "Zotero" : "Readwise Reader"}
+
+ ) : 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 ? (
+ <>
+
+ actions.onRemove?.(source), true)}
+ >
+ Remove source…
+
+ >
+ ) : null}