@botiverse/porthole 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +5 -3
  8. package/dist/compile/policy.js +8 -4
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +8 -0
  12. package/dist/config/schema.js +5 -0
  13. package/dist/config/validate.js +15 -1
  14. package/dist/conformance/clipboard.d.ts +2 -0
  15. package/dist/conformance/clipboard.js +167 -0
  16. package/dist/conformance/kit.d.ts +1 -1
  17. package/dist/conformance/probes.js +2 -0
  18. package/dist/guide/guide.js +8 -4
  19. package/dist/host/clipboard.d.ts +11 -0
  20. package/dist/host/clipboard.js +43 -0
  21. package/dist/host/create-host.js +141 -0
  22. package/dist/host/outcome.d.ts +1 -0
  23. package/dist/host/outcome.js +19 -6
  24. package/dist/host/types.d.ts +34 -5
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +8 -3
  27. package/dist/mcp/adapter.js +10 -1
  28. package/dist/presets/presets.d.ts +3 -1
  29. package/dist/presets/presets.js +1 -0
  30. package/dist/protocol/mcp-apps.d.ts +5 -0
  31. package/dist/protocol/mcp-apps.js +5 -0
  32. package/dist/protocol/shell-adapter.js +6 -2
  33. package/dist/react/dialog.js +2 -0
  34. package/dist/react/frame.d.ts +18 -0
  35. package/dist/react/frame.js +82 -2
  36. package/dist/react/index.d.ts +2 -1
  37. package/dist/react/index.js +2 -1
  38. package/dist/react/toast.d.ts +33 -0
  39. package/dist/react/toast.js +42 -0
  40. package/dist/server/crypto.d.ts +19 -0
  41. package/dist/server/crypto.js +144 -0
  42. package/dist/server/index.d.ts +12 -4
  43. package/dist/server/index.js +11 -3
  44. package/dist/server/keys.js +12 -9
  45. package/dist/server/naming.d.ts +17 -0
  46. package/dist/server/naming.js +34 -0
  47. package/dist/server/pages.d.ts +1 -1
  48. package/dist/server/pages.js +13 -5
  49. package/dist/server/server.js +162 -18
  50. package/dist/server/tickets.d.ts +24 -0
  51. package/dist/server/tickets.js +46 -0
  52. package/dist/server/types.d.ts +76 -5
  53. package/dist/shell/annotate.d.ts +20 -0
  54. package/dist/shell/annotate.js +669 -0
  55. package/dist/shell/document.js +4 -1
  56. package/dist/shell/plan.d.ts +2 -0
  57. package/dist/shell/plan.js +2 -1
  58. package/dist/shell/runtime.d.ts +4 -1
  59. package/dist/shell/runtime.js +38 -2
  60. package/package.json +2 -1
  61. package/src/annotate/types.ts +126 -0
  62. package/src/annotate/validate.ts +186 -0
  63. package/src/bridge/bridge.ts +13 -0
  64. package/src/compile/policy.ts +8 -4
  65. package/src/config/capabilities.ts +33 -3
  66. package/src/config/schema.ts +10 -0
  67. package/src/config/validate.ts +16 -1
  68. package/src/conformance/clipboard.ts +184 -0
  69. package/src/conformance/kit.ts +1 -1
  70. package/src/conformance/probes.ts +2 -0
  71. package/src/guide/guide.ts +19 -4
  72. package/src/host/clipboard.ts +41 -0
  73. package/src/host/create-host.ts +155 -0
  74. package/src/host/outcome.ts +25 -7
  75. package/src/host/types.ts +39 -5
  76. package/src/index.ts +31 -1
  77. package/src/mcp/adapter.ts +10 -1
  78. package/src/presets/presets.ts +4 -0
  79. package/src/protocol/mcp-apps.ts +6 -0
  80. package/src/protocol/shell-adapter.ts +11 -2
  81. package/src/react/dialog.tsx +2 -0
  82. package/src/react/frame.tsx +117 -1
  83. package/src/react/index.ts +9 -1
  84. package/src/react/toast.tsx +96 -0
  85. package/src/server/crypto.ts +166 -0
  86. package/src/server/index.ts +20 -3
  87. package/src/server/keys.ts +21 -13
  88. package/src/server/naming.ts +42 -0
  89. package/src/server/pages.ts +13 -4
  90. package/src/server/server.ts +190 -20
  91. package/src/server/tickets.ts +65 -0
  92. package/src/server/types.ts +84 -7
  93. package/src/shell/annotate.ts +669 -0
  94. package/src/shell/document.ts +6 -1
  95. package/src/shell/plan.ts +4 -1
  96. package/src/shell/runtime.ts +38 -2
@@ -0,0 +1,184 @@
1
+ import { expect, type Page } from "@playwright/test";
2
+ import type { LaunchedDisplay } from "./index.ts";
3
+ import { inline, type Probe } from "./kit.ts";
4
+ import { settle } from "./oracles.ts";
5
+
6
+ /**
7
+ * The clipboard (item 13, P051): content can't write it, whatever it tries in a person's click,
8
+ * and what a person copies themselves is what they selected. `copyText` is the way copy buttons
9
+ * work instead (item 12, P052). The oracle is the person's own clipboard: known text put on it
10
+ * from the host's page, then pasted back into a text area there.
11
+ */
12
+
13
+ const SEED = "porthole-seed";
14
+
15
+ /** A text area of the oracle's own on the host's page. */
16
+ async function area(page: Page, id: string): Promise<void> {
17
+ await page.evaluate((each) => {
18
+ if (document.getElementById(each) === null) {
19
+ const element = document.createElement("textarea");
20
+ element.id = each;
21
+ element.style.cssText = "display:block;width:200px;height:40px";
22
+ document.body.prepend(element);
23
+ }
24
+ }, id);
25
+ }
26
+
27
+ /** Puts `text` on the clipboard the way a person does: selects it on the host's page, copies. */
28
+ async function seed(page: Page, text: string): Promise<void> {
29
+ await area(page, "porthole-oracle-seed");
30
+ await page.evaluate((each) => {
31
+ (document.getElementById("porthole-oracle-seed") as HTMLTextAreaElement).value = each;
32
+ }, text);
33
+ await page.locator("#porthole-oracle-seed").click();
34
+ await page.keyboard.press("ControlOrMeta+A");
35
+ await page.keyboard.press("ControlOrMeta+C");
36
+ }
37
+
38
+ /** What is on the clipboard: pasted into a text area on the host's page. */
39
+ async function pasted(page: Page): Promise<string> {
40
+ await area(page, "porthole-oracle-paste");
41
+ await page.evaluate(() => {
42
+ (document.getElementById("porthole-oracle-paste") as HTMLTextAreaElement).value = "";
43
+ });
44
+ await page.locator("#porthole-oracle-paste").click();
45
+ await page.keyboard.press("ControlOrMeta+V");
46
+ await settle(200);
47
+ return page.evaluate(
48
+ () => (document.getElementById("porthole-oracle-paste") as HTMLTextAreaElement).value,
49
+ );
50
+ }
51
+
52
+ /** Clicks `selector` in the display (a real click: the user activation a person gives). */
53
+ async function click(display: LaunchedDisplay, selector: string): Promise<void> {
54
+ await (await display.frame()).locator(selector).click();
55
+ }
56
+
57
+ async function bodyData(display: LaunchedDisplay, name: string): Promise<string> {
58
+ const frame = await display.frame();
59
+ await expect
60
+ .poll(() => frame.locator("body").getAttribute(`data-${name}`), { timeout: 5_000 })
61
+ .not.toBeNull();
62
+ return (await frame.locator("body").getAttribute(`data-${name}`)) ?? "";
63
+ }
64
+
65
+ const settled = (name: string) =>
66
+ `(value) => (document.body.dataset.${name} = String(value)), (error) => (document.body.dataset.${name} = "refused: " + (error && error.name))`;
67
+
68
+ export const CLIPBOARD_PROBES: readonly Probe[] = [
69
+ {
70
+ id: "13.clipboard-api",
71
+ item: 13,
72
+ title:
73
+ "content can't write (or read) the clipboard through the async Clipboard API, even in a click",
74
+ async run(context) {
75
+ const display = await context.launch({
76
+ config: inline(),
77
+ content: `<button id="go">copy</button>
78
+ <script>
79
+ document.getElementById("go").addEventListener("click", () => {
80
+ navigator.clipboard.writeText("porthole-pwned-text").then(${settled("text")});
81
+ if (typeof ClipboardItem === "function") {
82
+ navigator.clipboard.write([new ClipboardItem({ "text/plain": new Blob(["porthole-pwned-item"], { type: "text/plain" }) })]).then(${settled("item")});
83
+ } else {
84
+ document.body.dataset.item = "refused: no ClipboardItem";
85
+ }
86
+ navigator.clipboard.readText().then(${settled("read")});
87
+ });
88
+ </script>`,
89
+ });
90
+ await seed(context.page, SEED);
91
+ await click(display, "#go");
92
+ expect(await bodyData(display, "text")).toMatch(/^refused/u);
93
+ expect(await bodyData(display, "item")).toMatch(/^refused/u);
94
+ expect(await bodyData(display, "read")).toMatch(/^refused/u);
95
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
96
+ },
97
+ },
98
+ {
99
+ id: "13.clipboard-command",
100
+ item: 13,
101
+ title:
102
+ "content can't copy or cut its own text onto the clipboard with execCommand, even in a click",
103
+ async run(context) {
104
+ const display = await context.launch({
105
+ config: inline(),
106
+ content: `<button id="go">copy</button>
107
+ <script>
108
+ document.getElementById("go").addEventListener("click", () => {
109
+ const area = document.createElement("textarea");
110
+ area.value = "porthole-pwned-command";
111
+ area.style.cssText = "position:absolute;left:-9999px";
112
+ document.body.append(area);
113
+ area.select();
114
+ let copied = "threw";
115
+ let cut = "threw";
116
+ try { copied = document.execCommand("copy"); } catch (_) {}
117
+ area.select();
118
+ try { cut = document.execCommand("cut"); } catch (_) {}
119
+ document.body.dataset.copied = String(copied) + "," + String(cut);
120
+ });
121
+ </script>`,
122
+ });
123
+ await seed(context.page, SEED);
124
+ await click(display, "#go");
125
+ expect(await bodyData(display, "copied")).not.toContain("true");
126
+ expect(await pasted(context.page), "the clipboard still holds the person's own").toBe(SEED);
127
+ },
128
+ },
129
+ {
130
+ id: "13.clipboard-event",
131
+ item: 13,
132
+ title:
133
+ "what a person copies is what they selected: no copy listener of the content's can swap it",
134
+ async run(context) {
135
+ const display = await context.launch({
136
+ config: inline(),
137
+ content: `<p id="safe">porthole-selected-text</p>
138
+ <script>
139
+ const swap = (event) => {
140
+ event.clipboardData.setData("text/plain", "porthole-pwned-event");
141
+ event.preventDefault();
142
+ document.body.dataset.ran = "yes";
143
+ };
144
+ document.addEventListener("copy", swap);
145
+ document.addEventListener("copy", swap, true);
146
+ window.addEventListener("copy", swap);
147
+ document.getElementById("safe").oncopy = swap;
148
+ </script>`,
149
+ });
150
+ await seed(context.page, SEED);
151
+ const frame = await display.frame();
152
+ await frame.locator("#safe").click({ clickCount: 3 });
153
+ await context.page.keyboard.press("ControlOrMeta+C");
154
+ await settle(200);
155
+ // The positive control: the person's own copy works, and copies what they selected.
156
+ expect((await pasted(context.page)).trim()).toBe("porthole-selected-text");
157
+ expect(await frame.locator("body").getAttribute("data-ran")).toBeNull();
158
+ },
159
+ },
160
+ {
161
+ id: "12.copy-text",
162
+ item: 12,
163
+ title:
164
+ "copyText reaches the host's handler from a click, with the whole text, never without one",
165
+ async run(context) {
166
+ const display = await context.launch({
167
+ config: inline({ capabilities: { copyText: {} } }),
168
+ content: `<button id="copy">Copy the command</button>
169
+ <script>
170
+ document.getElementById("copy").addEventListener("click", () => {
171
+ copyText("npm test -- --run").then(${settled("result")});
172
+ });
173
+ copyText("copied on load").then(${settled("onload")});
174
+ </script>`,
175
+ });
176
+ expect(await bodyData(display, "onload")).toMatch(/^refused/u);
177
+ await click(display, "#copy");
178
+ expect(await bodyData(display, "result")).toBe("true");
179
+ expect(await display.calls()).toEqual([
180
+ { capability: "copyText", args: { text: "npm test -- --run" } },
181
+ ]);
182
+ },
183
+ },
184
+ ];
@@ -29,7 +29,7 @@ export interface Probe {
29
29
  readonly id: string;
30
30
  /**
31
31
  * The DESIGN.md egress-table item it proves; 0 for the shell's own wiring and interop, 11 for
32
- * behavior (streaming, sizing, theming), 12 for host-function gates.
32
+ * behavior (streaming, sizing, theming), 12 for host-function gates, 13 for the clipboard.
33
33
  */
34
34
  readonly item: number;
35
35
  readonly title: string;
@@ -13,6 +13,7 @@ import { resolveTheme } from "../theme/variables.ts";
13
13
  import type { ConformanceHost, LaunchedDisplay, LeakSuiteOptions } from "./index.ts";
14
14
  import { attack, inline, js, type Probe, type ProbeContext } from "./kit.ts";
15
15
  import { BEHAVIOR_PROBES } from "./behavior.ts";
16
+ import { CLIPBOARD_PROBES } from "./clipboard.ts";
16
17
  import { readFileSync } from "node:fs";
17
18
  import { createRequire } from "node:module";
18
19
  import {
@@ -1025,6 +1026,7 @@ export const PROBES: readonly Probe[] = [
1025
1026
  },
1026
1027
  },
1027
1028
  ...BEHAVIOR_PROBES,
1029
+ ...CLIPBOARD_PROBES,
1028
1030
  ];
1029
1031
 
1030
1032
  type AnyTest = TestType<
@@ -1,4 +1,9 @@
1
- import { capabilitySpec, type CapabilityName, type Gate } from "../config/capabilities.ts";
1
+ import {
2
+ MAX_COPY_CHARS,
3
+ capabilitySpec,
4
+ type CapabilityName,
5
+ type Gate,
6
+ } from "../config/capabilities.ts";
2
7
  import type { PortholeConfig } from "../config/schema.ts";
3
8
  import { BUILT_IN_TOPICS } from "../config/validate.ts";
4
9
  import {
@@ -115,7 +120,7 @@ const loadsScripts = (config: PortholeConfig): boolean =>
115
120
  egressOrigins(config, "script").length > 0;
116
121
 
117
122
  /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
118
- function forbidden(config: PortholeConfig): string {
123
+ function forbidden(config: PortholeConfig, c: Context): string {
119
124
  const tokens = sandboxTokens(config);
120
125
  const items = ["position: fixed", "full screen"];
121
126
  if (!tokens.includes("allow-modals")) {
@@ -130,7 +135,11 @@ function forbidden(config: PortholeConfig): string {
130
135
  if (!tokens.includes("allow-downloads")) {
131
136
  items.push("downloads");
132
137
  }
133
- items.push("clipboard writes", "frames (iframe, object, embed)", "WebRTC");
138
+ items.push(
139
+ c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard",
140
+ "frames (iframe, object, embed)",
141
+ "WebRTC",
142
+ );
134
143
  return `Not allowed: ${items.join(", ")}.`;
135
144
  }
136
145
 
@@ -172,7 +181,7 @@ function widget(c: Context): string {
172
181
  "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).",
173
182
  "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",
174
183
  "",
175
- `${forbidden(config)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
184
+ `${forbidden(config, c)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`,
176
185
  loadsScripts(config)
177
186
  ? "\nLibraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time."
178
187
  : null,
@@ -337,6 +346,12 @@ const ENTRIES: { readonly [N in CapabilityName]: (c: Context, gates: readonly Ga
337
346
  `- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
338
347
  setModelContext: (_c, gates) =>
339
348
  `- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
349
+ copyText: (c, gates) =>
350
+ c.global("copyText")
351
+ ? `- copyText(text): copies text to the person's clipboard (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters), and the app shows them what was copied. Use it for every copy button: nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")} It returns a promise: true if it was copied.`
352
+ : `- Copying: the request porthole/copy-text with { text } (at most ${MAX_COPY_CHARS.toLocaleString("en-US")} characters) copies text to the person's clipboard, and the app shows them what was copied; nothing else in the ${c.noun} can write the clipboard. ${gateWords(gates, "the text")}`,
353
+ annotate: (c) =>
354
+ `- Comments: the person may point at parts of the ${c.noun} to comment on them. Give headings and the parts that matter stable ids (id="revenue"), so comments stay on them when the ${c.noun} is written again.`,
340
355
  reportError: (c, gates) =>
341
356
  `- Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${c.config.limits.maxErrorReports} a run). ${gateWords(gates, "it")}`.trim(),
342
357
  resize: () => "",
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Writing the clipboard from the host's page, for a `copyText` handler (P052): content never
3
+ * writes it itself (P051). The person's click in the frame gives the host's page user activation
4
+ * (it propagates to ancestors; measured on Chromium, WebKit and Firefox), which the write needs.
5
+ */
6
+
7
+ /**
8
+ * Writes `text` to the clipboard: the async Clipboard API, else a selected text area and the copy
9
+ * command. Resolves whether it worked; it can fail without a recent gesture, so a host shows the
10
+ * text either way (and offers its own Copy button when it failed).
11
+ */
12
+ export async function writeClipboard(text: string, document: Document): Promise<boolean> {
13
+ const view = document.defaultView;
14
+ try {
15
+ if (view?.navigator.clipboard !== undefined) {
16
+ await view.navigator.clipboard.writeText(text);
17
+ return true;
18
+ }
19
+ } catch {
20
+ // Fall through to the copy command.
21
+ }
22
+ const focused = document.activeElement;
23
+ const area = document.createElement("textarea");
24
+ area.value = text;
25
+ area.setAttribute("readonly", "");
26
+ area.setAttribute("aria-hidden", "true");
27
+ area.style.cssText =
28
+ "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;border:0;padding:0";
29
+ try {
30
+ document.body.append(area);
31
+ area.select();
32
+ return document.execCommand("copy");
33
+ } catch {
34
+ return false;
35
+ } finally {
36
+ area.remove();
37
+ if (focused !== null && typeof (focused as HTMLElement).focus === "function") {
38
+ (focused as HTMLElement).focus({ preventScroll: true });
39
+ }
40
+ }
41
+ }
@@ -1,4 +1,12 @@
1
+ import {
2
+ ANNOTATE_METHODS,
3
+ type AnnotateMode,
4
+ type AnnotationMark,
5
+ type AnnotationReport,
6
+ } from "../annotate/types.ts";
7
+ import { annotateMode, checkMarks, parseReport } from "../annotate/validate.ts";
1
8
  import { createBridge, type BridgeEvent } from "../bridge/bridge.ts";
9
+ import type { HostFunctionBridge } from "../bridge/types.ts";
2
10
  import { compile } from "../compile/compile.ts";
3
11
  import { declaredCapabilities } from "../compile/policy.ts";
4
12
  import { capabilitySpec } from "../config/capabilities.ts";
@@ -17,6 +25,7 @@ import {
17
25
  import { shellAdapter } from "../protocol/shell-adapter.ts";
18
26
  import { resolveTheme } from "../theme/variables.ts";
19
27
  import type {
28
+ AnnotationControls,
20
29
  DisplayEvents,
21
30
  DisplayHandle,
22
31
  HostOptions,
@@ -31,6 +40,17 @@ const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
31
40
  const MAX_RESTARTS = 3;
32
41
 
33
42
  const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
43
+
44
+ /** The frame's annotation reports, by method. */
45
+ const REPORTS: Readonly<Record<string, AnnotationReport["type"]>> = {
46
+ [ANNOTATE_METHODS.selected]: "selected",
47
+ [ANNOTATE_METHODS.unselected]: "unselected",
48
+ [ANNOTATE_METHODS.cancelled]: "cancelled",
49
+ [ANNOTATE_METHODS.anchored]: "anchored",
50
+ };
51
+
52
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
53
+ value !== null && typeof value === "object" && !Array.isArray(value);
34
54
  const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
35
55
 
36
56
  /**
@@ -90,6 +110,129 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
90
110
  const view = container.ownerDocument.defaultView;
91
111
  // The adapter's own operations, the last call of each, replayed into every new frame.
92
112
  const replay = new Map<string, unknown[]>();
113
+ // Annotations: the host's state, given to each frame once it finished its handshake.
114
+ const annotates = config.capabilities.annotate !== undefined;
115
+ let annotating: Required<AnnotateMode> | null = null;
116
+ let marks: AnnotationMark[] = [];
117
+ let activeMark: string | null = null;
118
+ let handshaken = false;
119
+ const toFrame = (method: string, params: Record<string, unknown>) => {
120
+ if (handshaken && surface !== null) {
121
+ surface.transport.send({ jsonrpc: "2.0", method, params });
122
+ }
123
+ };
124
+ const giveAnnotations = () => {
125
+ if (marks.length > 0) {
126
+ toFrame(ANNOTATE_METHODS.show, {
127
+ marks,
128
+ ...(activeMark === null ? {} : { active: activeMark }),
129
+ });
130
+ }
131
+ if (annotating !== null) {
132
+ toFrame(ANNOTATE_METHODS.start, { ...annotating });
133
+ }
134
+ };
135
+ /**
136
+ * A report from the frame: checked, taken only while the host asked for it (a selection
137
+ * only in select mode, results only for its own marks), then through the bridge's gates.
138
+ */
139
+ const onAnnotation = (raw: unknown, bridge: HostFunctionBridge) => {
140
+ if (
141
+ !isRecord(raw) ||
142
+ raw.jsonrpc !== "2.0" ||
143
+ "id" in raw ||
144
+ typeof raw.method !== "string"
145
+ ) {
146
+ return;
147
+ }
148
+ const type = REPORTS[raw.method];
149
+ if (type === undefined) {
150
+ return;
151
+ }
152
+ let report = parseReport({ ...(isRecord(raw.params) ? raw.params : {}), type });
153
+ if (report === null) {
154
+ void bridge.call("annotate", null);
155
+ return;
156
+ }
157
+ switch (report.type) {
158
+ case "selected": {
159
+ const kind = report.anchor.kind;
160
+ if (
161
+ annotating === null ||
162
+ !(kind === "element" ? annotating.elements : annotating.text)
163
+ ) {
164
+ return;
165
+ }
166
+ if (kind === "element") {
167
+ annotating = annotating.text ? { elements: false, text: true } : null;
168
+ }
169
+ break;
170
+ }
171
+ case "unselected":
172
+ if (annotating?.text !== true) {
173
+ return;
174
+ }
175
+ break;
176
+ case "cancelled":
177
+ if (annotating?.elements !== true) {
178
+ return;
179
+ }
180
+ annotating = annotating.text ? { elements: false, text: true } : null;
181
+ break;
182
+ case "anchored": {
183
+ const known = new Set(marks.map((mark) => mark.id));
184
+ const results = report.results.filter((result) => known.has(result.id));
185
+ if (results.length === 0) {
186
+ return;
187
+ }
188
+ report = { type: "anchored", results };
189
+ break;
190
+ }
191
+ }
192
+ void bridge.call("annotate", report);
193
+ };
194
+ const needsAnnotate = (operation: string) => {
195
+ if (!annotates) {
196
+ throw new TypeError(`annotations.${operation}: the config doesn't declare annotate`);
197
+ }
198
+ };
199
+ const annotations: AnnotationControls = {
200
+ start(mode) {
201
+ needsAnnotate("start");
202
+ annotating = annotateMode(mode);
203
+ toFrame(ANNOTATE_METHODS.start, { ...annotating });
204
+ },
205
+ stop() {
206
+ needsAnnotate("stop");
207
+ annotating = null;
208
+ toFrame(ANNOTATE_METHODS.stop, {});
209
+ },
210
+ show(next) {
211
+ needsAnnotate("show");
212
+ marks = checkMarks(next);
213
+ if (activeMark !== null && !marks.some((mark) => mark.id === activeMark)) {
214
+ activeMark = null;
215
+ }
216
+ toFrame(ANNOTATE_METHODS.show, {
217
+ marks,
218
+ ...(activeMark === null ? {} : { active: activeMark }),
219
+ });
220
+ },
221
+ focus(id) {
222
+ needsAnnotate("focus");
223
+ if (!marks.some((mark) => mark.id === id)) {
224
+ throw new TypeError(`annotations.focus: no mark ${JSON.stringify(id)} is shown`);
225
+ }
226
+ activeMark = id;
227
+ toFrame(ANNOTATE_METHODS.focus, { id });
228
+ },
229
+ clear() {
230
+ needsAnnotate("clear");
231
+ marks = [];
232
+ activeMark = null;
233
+ toFrame(ANNOTATE_METHODS.clear, {});
234
+ },
235
+ };
93
236
 
94
237
  const hostContext = (): HostContext => {
95
238
  const width = container.getBoundingClientRect().width;
@@ -142,10 +285,14 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
142
285
  case "muted":
143
286
  emit("muted", undefined);
144
287
  return;
288
+ case "annotation":
289
+ emit("annotation", event.report);
290
+ return;
145
291
  }
146
292
  };
147
293
 
148
294
  const stop = () => {
295
+ handshaken = false;
149
296
  abort?.abort();
150
297
  abort = null;
151
298
  outcomeWatch?.stop();
@@ -197,6 +344,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
197
344
  userActivation: () => created.userActivation(),
198
345
  onEvent: onBridge,
199
346
  });
347
+ if (annotates) {
348
+ created.transport.listen((raw) => onAnnotation(raw, bridge));
349
+ }
200
350
  const opened = adapter.connect({
201
351
  display: { id: mount.displayId, title: mount.title },
202
352
  compiled,
@@ -207,6 +357,10 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
207
357
  emit: (event) => {
208
358
  if (event.type === "initialized") {
209
359
  watch.initialized();
360
+ if (!handshaken) {
361
+ handshaken = true;
362
+ giveAnnotations();
363
+ }
210
364
  emit("initialized", undefined);
211
365
  }
212
366
  },
@@ -274,6 +428,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
274
428
 
275
429
  const controls = {
276
430
  displayId: mount.displayId,
431
+ annotations,
277
432
  setTheme(next: ColorScheme) {
278
433
  scheme = next;
279
434
  surface?.setColorScheme(next);
@@ -25,6 +25,7 @@ const BLOCKS: readonly FrameBlock[] = [
25
25
  "cookies",
26
26
  "session",
27
27
  "upstream",
28
+ "denied",
28
29
  "not-found",
29
30
  "error",
30
31
  ];
@@ -33,6 +34,7 @@ interface FrameStatus {
33
34
  readonly state: "entering" | "blocked";
34
35
  readonly reason?: FrameBlock;
35
36
  readonly status?: number;
37
+ readonly code?: string;
36
38
  }
37
39
 
38
40
  /** A frame-status message, or null for anything else. */
@@ -44,7 +46,12 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
44
46
  if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
45
47
  return null;
46
48
  }
47
- const params = message.params as { state?: unknown; reason?: unknown; status?: unknown } | null;
49
+ const params = message.params as {
50
+ state?: unknown;
51
+ reason?: unknown;
52
+ status?: unknown;
53
+ code?: unknown;
54
+ } | null;
48
55
  if (params === null || typeof params !== "object") {
49
56
  return null;
50
57
  }
@@ -63,6 +70,9 @@ export function parseFrameStatus(data: unknown): FrameStatus | null {
63
70
  ...(typeof params.status === "number" && Number.isInteger(params.status)
64
71
  ? { status: params.status }
65
72
  : {}),
73
+ ...(typeof params.code === "string" && /^[a-z0-9-]{1,40}$/u.test(params.code)
74
+ ? { code: params.code }
75
+ : {}),
66
76
  };
67
77
  }
68
78
 
@@ -82,7 +92,8 @@ export function watchOutcome(options: {
82
92
  }): OutcomeWatch {
83
93
  const { surface, source, speaks } = options;
84
94
  const reports = source.kind === "url" && source.reports === true;
85
- let decided = false;
95
+ // The first outcome; after a load, only a later block (a status page, access revoked) is told.
96
+ let decided: FrameOutcome["state"] | null = null;
86
97
  let heard = false;
87
98
  let entering = false;
88
99
  let loads = 0;
@@ -94,18 +105,24 @@ export function watchOutcome(options: {
94
105
  }, ms);
95
106
  timers.add(timer);
96
107
  };
97
- const stop = () => {
108
+ const clearTimers = () => {
98
109
  for (const timer of timers) {
99
110
  clearTimeout(timer);
100
111
  }
101
112
  timers.clear();
113
+ };
114
+ const stop = () => {
115
+ clearTimers();
102
116
  unlisten();
103
117
  unload();
104
118
  };
105
119
  const decide = (outcome: FrameOutcome) => {
106
- if (!decided) {
107
- decided = true;
108
- stop();
120
+ if (decided === null) {
121
+ decided = outcome.state;
122
+ clearTimers();
123
+ options.onOutcome(outcome);
124
+ } else if (decided === "loaded" && outcome.state === "blocked") {
125
+ decided = "blocked";
109
126
  options.onOutcome(outcome);
110
127
  }
111
128
  };
@@ -119,6 +136,7 @@ export function watchOutcome(options: {
119
136
  state: "blocked",
120
137
  reason: status.reason ?? "error",
121
138
  ...(status.status === undefined ? {} : { status: status.status }),
139
+ ...(status.code === undefined ? {} : { code: status.code }),
122
140
  });
123
141
  }
124
142
  });
@@ -130,7 +148,7 @@ export function watchOutcome(options: {
130
148
  });
131
149
  const unload = surface.onLoad(() => {
132
150
  loads += 1;
133
- if (decided) {
151
+ if (decided !== null) {
134
152
  return;
135
153
  }
136
154
  if (speaks) {
package/src/host/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
1
2
  import type { Confirm, CapabilityHandlers, Refusal } from "../bridge/types.ts";
2
3
  import type { CompiledConfig } from "../compile/compile.ts";
3
4
  import type { CapabilityName } from "../config/capabilities.ts";
@@ -62,18 +63,27 @@ export type FrameBlock =
62
63
  | "session"
63
64
  /** Its app didn't answer. */
64
65
  | "upstream"
66
+ /** The host's own check refused it (revoked, no longer a member): `code` says which. */
67
+ | "denied"
65
68
  | "not-found"
66
69
  | "error";
67
70
 
68
71
  /**
69
- * How a display's first document went, once per run: it loaded (a shell or view finished its
70
- * handshake; a url-mode app's document loaded), it was blocked (and why), or nothing settled in
71
- * `limits.loadTimeoutMs`. A host shows its own fallback ("Open in a new tab") on anything but
72
- * loaded.
72
+ * How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
73
+ * app's document loaded), it was blocked (and why), or nothing settled in
74
+ * `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
75
+ * after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
76
+ * tab") on anything but loaded.
73
77
  */
74
78
  export type FrameOutcome =
75
79
  | { readonly state: "loaded" }
76
- | { readonly state: "blocked"; readonly reason: FrameBlock; readonly status?: number }
80
+ | {
81
+ readonly state: "blocked";
82
+ readonly reason: FrameBlock;
83
+ readonly status?: number;
84
+ /** The host's own reason code, for `denied` (`Authorizer`). */
85
+ readonly code?: string;
86
+ }
77
87
  | { readonly state: "timeout" };
78
88
 
79
89
  export interface DisplayEvents {
@@ -91,6 +101,28 @@ export interface DisplayEvents {
91
101
  readonly refused: { readonly capability: CapabilityName; readonly refusal: Refusal };
92
102
  /** Too many declines: the display stops asking until it runs again. */
93
103
  readonly muted: undefined;
104
+ /**
105
+ * What the frame reported about annotations (`annotate`, P053), checked and taken only while
106
+ * the host asked for it: a selection, its clearing, a cancelled pick, where marks are. The
107
+ * content's word: UI hints, never permissions.
108
+ */
109
+ readonly annotation: AnnotationReport;
110
+ }
111
+
112
+ /**
113
+ * Annotations on a display whose config declares `annotate` (P053). The host's state is kept and
114
+ * given to every frame the display runs (a reload, a navigation recovered from).
115
+ */
116
+ export interface AnnotationControls {
117
+ /** Lets the person point at elements, select text, or both (default both). */
118
+ start(mode?: AnnotateMode): void;
119
+ stop(): void;
120
+ /** Marks these anchors (replacing the marks before); the frame reports where they are. */
121
+ show(marks: readonly AnnotationMark[]): void;
122
+ /** Brings a mark into view in the frame and makes it the active one. */
123
+ focus(id: string): void;
124
+ /** Removes every mark. */
125
+ clear(): void;
94
126
  }
95
127
 
96
128
  export interface DisplayControls {
@@ -106,6 +138,8 @@ export interface DisplayControls {
106
138
  event: E,
107
139
  listener: (detail: DisplayEvents[E]) => void,
108
140
  ): () => void;
141
+ /** Annotations (`annotate`): throws on use where the config doesn't declare it. */
142
+ readonly annotations: AnnotationControls;
109
143
  /** Runs the content again from the start, in a new frame. */
110
144
  reload(): void;
111
145
  /** Tears the view down and removes the frame. */