@botiverse/porthole 0.1.6 → 0.1.7
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.
- package/dist/config/schema.d.ts +26 -3
- package/dist/config/validate.js +29 -4
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/kit.d.ts +2 -1
- package/dist/conformance/limits.d.ts +10 -0
- package/dist/conformance/limits.js +508 -0
- package/dist/conformance/probes.js +2 -0
- package/dist/conformance/served.js +13 -0
- package/dist/guide/guide.js +34 -37
- package/dist/guide/limits.d.ts +24 -0
- package/dist/guide/limits.js +152 -0
- package/dist/host/create-host.js +9 -3
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +4 -0
- package/dist/react/frame.js +6 -2
- package/dist/shell/plan.d.ts +1 -1
- package/dist/shell/runtime.js +1 -1
- package/package.json +1 -1
- package/src/config/schema.ts +26 -3
- package/src/config/validate.ts +44 -4
- package/src/conformance/index.ts +2 -0
- package/src/conformance/kit.ts +2 -1
- package/src/conformance/limits.ts +560 -0
- package/src/conformance/probes.ts +2 -0
- package/src/conformance/served.ts +18 -0
- package/src/guide/guide.ts +40 -45
- package/src/guide/limits.ts +224 -0
- package/src/host/create-host.ts +9 -3
- package/src/presets/presets.ts +7 -1
- package/src/react/frame.tsx +6 -2
- package/src/shell/plan.ts +1 -1
- package/src/shell/runtime.ts +1 -1
package/src/guide/guide.ts
CHANGED
|
@@ -11,12 +11,12 @@ import {
|
|
|
11
11
|
declaredCapabilities,
|
|
12
12
|
effectiveGates,
|
|
13
13
|
egressOrigins,
|
|
14
|
-
sandboxTokens,
|
|
15
14
|
type EgressKind,
|
|
16
15
|
} from "../compile/policy.ts";
|
|
17
16
|
import { shellPlan } from "../shell/plan.ts";
|
|
18
17
|
import { themeDefault } from "../theme/variables.ts";
|
|
19
18
|
import { GUIDE_EXAMPLES } from "./examples.ts";
|
|
19
|
+
import { manualLimits, sizeWords } from "./limits.ts";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* The agent manual and the tool description, generated from the config (PRINCIPLE 3, 10): the
|
|
@@ -129,30 +129,6 @@ const optsIn = (config: PortholeConfig): boolean =>
|
|
|
129
129
|
const loadsScripts = (config: PortholeConfig): boolean =>
|
|
130
130
|
egressOrigins(config, "script").length > 0;
|
|
131
131
|
|
|
132
|
-
/** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
|
|
133
|
-
function forbidden(config: PortholeConfig, c: Context): string {
|
|
134
|
-
const tokens = sandboxTokens(config);
|
|
135
|
-
const items = ["position: fixed", "full screen"];
|
|
136
|
-
if (!tokens.includes("allow-modals")) {
|
|
137
|
-
items.push("alert/confirm/prompt");
|
|
138
|
-
}
|
|
139
|
-
if (!tokens.includes("allow-forms")) {
|
|
140
|
-
items.push("forms that submit");
|
|
141
|
-
}
|
|
142
|
-
if (!tokens.includes("allow-popups")) {
|
|
143
|
-
items.push("popups and new windows");
|
|
144
|
-
}
|
|
145
|
-
if (!tokens.includes("allow-downloads")) {
|
|
146
|
-
items.push("downloads");
|
|
147
|
-
}
|
|
148
|
-
items.push(
|
|
149
|
-
c.global("copyText") ? "the clipboard (copy buttons call copyText)" : "the clipboard",
|
|
150
|
-
"frames (iframe, object, embed)",
|
|
151
|
-
"WebRTC",
|
|
152
|
-
);
|
|
153
|
-
return `Not allowed: ${items.join(", ")}.`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
132
|
function widget(c: Context): string {
|
|
157
133
|
const { config, noun } = c;
|
|
158
134
|
const { layout, limits } = config;
|
|
@@ -164,9 +140,10 @@ function widget(c: Context): string {
|
|
|
164
140
|
: config.mode === "mcp-app"
|
|
165
141
|
? "What you pass: an MCP Apps view, a whole HTML document that talks to its host through the official App SDK (@modelcontextprotocol/ext-apps) or the protocol's own messages."
|
|
166
142
|
: "What you pass: SVG that starts with <svg, or an HTML fragment: no <!doctype>, <html>, <head> or <body>. Order it styles, then markup, then scripts: people see the styled markup as you write it, and scripts run once all of it is written, in order, each external one loaded before the next runs.";
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
143
|
+
const stated = manualLimits(config, network(config));
|
|
144
|
+
const said = (id: string) => stated.find((limit) => limit.id === id)?.text ?? "";
|
|
145
|
+
const fills = layout.kind === "fill";
|
|
146
|
+
const themeless = config.theme.documents === "never";
|
|
170
147
|
const viewBox = layout.narrowWidthPx + 20;
|
|
171
148
|
const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
|
|
172
149
|
const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
|
|
@@ -180,7 +157,7 @@ function widget(c: Context): string {
|
|
|
180
157
|
"",
|
|
181
158
|
what,
|
|
182
159
|
"",
|
|
183
|
-
`Where it runs: in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the
|
|
160
|
+
`Where it runs: ${fills ? `in a sandboxed frame that fills the viewer, about ${layout.narrowWidthPx} px wide on a phone (${layout.minWidthPx} on a small one) and as wide as the window on a desktop` : `in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop`}; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the ${fills ? "window's innerWidth, again on resize" : "box's clientWidth, again from a ResizeObserver"}). ${said("layout")} ${said("theme")} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
|
|
184
161
|
"",
|
|
185
162
|
"Sizes that read on a phone:",
|
|
186
163
|
c.when(
|
|
@@ -190,15 +167,22 @@ function widget(c: Context): string {
|
|
|
190
167
|
`- The frame never scrolls sideways: nothing fixed wider than ${layout.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`,
|
|
191
168
|
"- 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, …).",
|
|
192
169
|
"- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.",
|
|
170
|
+
fills
|
|
171
|
+
? null
|
|
172
|
+
: "- No position: fixed: the frame grows with its content, so a fixed element covers it.",
|
|
193
173
|
"",
|
|
194
|
-
|
|
174
|
+
"The sandbox:",
|
|
175
|
+
...stated.filter((limit) => limit.topic === "sandbox").map((limit) => `- ${limit.text}`),
|
|
176
|
+
`Keep it under ${sizeWords(limits.maxBytes)}.`,
|
|
195
177
|
loadsScripts(config)
|
|
196
178
|
? "\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."
|
|
197
179
|
: null,
|
|
198
180
|
"",
|
|
199
|
-
|
|
200
|
-
? `Colors
|
|
201
|
-
:
|
|
181
|
+
themeless
|
|
182
|
+
? `Colors: these variables hold the app's light palette; use them, or colors of your own. Give the ${noun} its own background.`
|
|
183
|
+
: optsIn(config)
|
|
184
|
+
? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
|
|
185
|
+
: `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`,
|
|
202
186
|
c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`,
|
|
203
187
|
c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`,
|
|
204
188
|
c.names("--color-border-") === ""
|
|
@@ -225,7 +209,9 @@ function widget(c: Context): string {
|
|
|
225
209
|
c.when(
|
|
226
210
|
`Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`,
|
|
227
211
|
),
|
|
228
|
-
|
|
212
|
+
themeless
|
|
213
|
+
? null
|
|
214
|
+
: `A theme change updates the variables in place and fires window "${config.theme.event}": CSS that uses var(...) follows by itself; a canvas or a library you gave colors to must redraw.`,
|
|
229
215
|
config.mode === "inline"
|
|
230
216
|
? "Buttons and inputs already match the app (its borders, radius, colors and focus ring, 44 px tall): use them as they are, or restyle them with your own CSS."
|
|
231
217
|
: null,
|
|
@@ -272,7 +258,7 @@ function chart(c: Context): string {
|
|
|
272
258
|
/^--color-chart-seq-\d+$/u.test(name),
|
|
273
259
|
);
|
|
274
260
|
const libraries = loadsScripts(config)
|
|
275
|
-
? `Libraries (Chart.js, ECharts, d3) draw in px too, in a box with a set height (Chart.js responsive: true, maintainAspectRatio: false). They don't read CSS variables: pass in getComputedStyle(document.documentElement).getPropertyValue("${categorical[0] ?? "--color-text-primary"}").trim()
|
|
261
|
+
? `Libraries (Chart.js, ECharts, d3) draw in px too, in a box with a set height (Chart.js responsive: true, maintainAspectRatio: false). They don't read CSS variables: pass in getComputedStyle(document.documentElement).getPropertyValue("${categorical[0] ?? "--color-text-primary"}").trim()${config.theme.documents === "never" ? "" : `, redraw on window "${config.theme.event}"`}, and override their palette and white background.`
|
|
276
262
|
: "No chart library loads here: draw the SVG yourself, from a script, as below.";
|
|
277
263
|
return lines(
|
|
278
264
|
`# Charts (a chart is a ${c.noun})`,
|
|
@@ -362,8 +348,8 @@ const ENTRIES: { readonly [N in CapabilityName]: (c: Context, gates: readonly Ga
|
|
|
362
348
|
: `- 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")}`,
|
|
363
349
|
annotate: (c) =>
|
|
364
350
|
`- 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.`,
|
|
365
|
-
reportError: (c
|
|
366
|
-
`-
|
|
351
|
+
reportError: (c) =>
|
|
352
|
+
`- ${manualLimits(c.config, "").find((limit) => limit.id === "errors")?.text ?? ""}`,
|
|
367
353
|
resize: () => "",
|
|
368
354
|
};
|
|
369
355
|
|
|
@@ -375,17 +361,18 @@ function interactive(c: Context): string {
|
|
|
375
361
|
return own === undefined ? ENTRIES[name](c, effectiveGates(config, name)) : `- ${own}`;
|
|
376
362
|
})
|
|
377
363
|
.filter((entry) => entry !== "");
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
364
|
+
const stated = manualLimits(config, "");
|
|
365
|
+
const stored = stated.find((limit) => limit.id === "storage");
|
|
366
|
+
const state = stored === undefined ? null : `- ${stored.text}`;
|
|
367
|
+
// Without reportError the capability list has no errors entry: say that nothing reports them.
|
|
368
|
+
const errors = c.declared("reportError")
|
|
369
|
+
? null
|
|
370
|
+
: `- ${stated.find((limit) => limit.id === "errors")?.text ?? ""}`;
|
|
384
371
|
const none =
|
|
385
372
|
entries.length === 0
|
|
386
373
|
? `- It can only draw: it has no way to send anything to you or the app.`
|
|
387
374
|
: null;
|
|
388
|
-
return lines(`# Interactive ${noun}s`, "", ...entries, none, state);
|
|
375
|
+
return lines(`# Interactive ${noun}s`, "", ...entries, errors, none, state);
|
|
389
376
|
}
|
|
390
377
|
|
|
391
378
|
const TOPICS: Readonly<Record<BuiltInTopic, (c: Context) => string>> = {
|
|
@@ -459,7 +446,15 @@ export function generateToolDescription(config: PortholeConfig): string {
|
|
|
459
446
|
config.guide.guideToolName === undefined
|
|
460
447
|
? ""
|
|
461
448
|
: ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
|
|
462
|
-
|
|
449
|
+
const where =
|
|
450
|
+
layout.kind === "fill"
|
|
451
|
+
? `It fills the viewer in a sandbox about ${layout.narrowWidthPx} px wide on a phone, from the same code on every screen`
|
|
452
|
+
: `It runs in a sandbox about ${layout.narrowWidthPx} px wide on a phone and ${layout.wideWidthPx} on a desktop, from the same code${c.declared("resize") ? " (its height follows the content)" : ""}`;
|
|
453
|
+
const theme =
|
|
454
|
+
config.theme.documents === "never"
|
|
455
|
+
? "always in light (the app doesn't theme it)"
|
|
456
|
+
: "in the person's light or dark theme through CSS variables";
|
|
457
|
+
return `${what} ${where}, ${theme}, ${net}. ${globals.join(" ")}${read} Each time it is shown it runs again from this code.`.replace(
|
|
463
458
|
/ {2,}/gu,
|
|
464
459
|
" ",
|
|
465
460
|
);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { declaredCapabilities, egressOrigins, sandboxTokens } from "../compile/policy.ts";
|
|
2
|
+
import type { CapabilityName } from "../config/capabilities.ts";
|
|
3
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
4
|
+
import { shellPlan } from "../shell/plan.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The sandbox's limits as the manual states them (P064): each a short line, derived from the
|
|
8
|
+
* config, for what silently changes how content behaves. Every limit has an id, and the
|
|
9
|
+
* conformance suite has a named check for each (`LIMIT_CHECKS`), so the manual can't claim what
|
|
10
|
+
* no browser test proves. What differs by engine is stated as the behavior safe on all of them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const LIMIT_IDS = [
|
|
14
|
+
"layout",
|
|
15
|
+
"theme",
|
|
16
|
+
"silent",
|
|
17
|
+
"network",
|
|
18
|
+
"inline-assets",
|
|
19
|
+
"dialogs",
|
|
20
|
+
"print",
|
|
21
|
+
"downloads",
|
|
22
|
+
"forms",
|
|
23
|
+
"link-schemes",
|
|
24
|
+
"windows",
|
|
25
|
+
"devices",
|
|
26
|
+
"workers",
|
|
27
|
+
"sound",
|
|
28
|
+
"address",
|
|
29
|
+
"shell",
|
|
30
|
+
"storage",
|
|
31
|
+
"errors",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export type LimitId = (typeof LIMIT_IDS)[number];
|
|
35
|
+
|
|
36
|
+
/** Where the manual states a limit: where it runs, the sandbox list, or the interactive topic. */
|
|
37
|
+
export type LimitTopic = "where" | "sandbox" | "interactive";
|
|
38
|
+
|
|
39
|
+
export interface ManualLimit {
|
|
40
|
+
readonly id: LimitId;
|
|
41
|
+
readonly text: string;
|
|
42
|
+
readonly topic: LimitTopic;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TOPICS: { readonly [Id in LimitId]: LimitTopic } = {
|
|
46
|
+
layout: "where",
|
|
47
|
+
theme: "where",
|
|
48
|
+
storage: "interactive",
|
|
49
|
+
errors: "interactive",
|
|
50
|
+
silent: "sandbox",
|
|
51
|
+
network: "sandbox",
|
|
52
|
+
"inline-assets": "sandbox",
|
|
53
|
+
dialogs: "sandbox",
|
|
54
|
+
print: "sandbox",
|
|
55
|
+
downloads: "sandbox",
|
|
56
|
+
forms: "sandbox",
|
|
57
|
+
"link-schemes": "sandbox",
|
|
58
|
+
windows: "sandbox",
|
|
59
|
+
devices: "sandbox",
|
|
60
|
+
workers: "sandbox",
|
|
61
|
+
sound: "sandbox",
|
|
62
|
+
address: "sandbox",
|
|
63
|
+
shell: "sandbox",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const noun = (config: PortholeConfig): string =>
|
|
67
|
+
config.mode === "artifact" ? "page" : config.mode === "mcp-app" ? "view" : "widget";
|
|
68
|
+
|
|
69
|
+
/** Whether content may load `kind` from somewhere outside (listed origins, or any https site). */
|
|
70
|
+
const opens = (config: PortholeConfig, kind: "img" | "style" | "font" | "media"): boolean =>
|
|
71
|
+
egressOrigins(config, kind).length > 0 ||
|
|
72
|
+
(config.egress.anyHttps ?? []).some((each) => each === kind);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The limits the manual states for `config`, in the order it states them. `network` is the
|
|
76
|
+
* caller's (the egress line, from the CSP's own sources); `storage` goes in the interactive topic.
|
|
77
|
+
* Url mode has no shell and no manual: none.
|
|
78
|
+
*/
|
|
79
|
+
export function manualLimits(config: PortholeConfig, network: string): ManualLimit[] {
|
|
80
|
+
if (config.mode === "url") {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const tokens = sandboxTokens(config);
|
|
84
|
+
const declared = new Set<CapabilityName>(declaredCapabilities(config));
|
|
85
|
+
const globals = new Set(shellPlan(config)?.globals ?? []);
|
|
86
|
+
const it = noun(config);
|
|
87
|
+
const out: ManualLimit[] = [];
|
|
88
|
+
const add = (id: LimitId, text: string | null) => {
|
|
89
|
+
if (text !== null) {
|
|
90
|
+
out.push({ id, text, topic: TOPICS[id] });
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Where it runs (P065): a card that grows with its content, or the whole viewer.
|
|
95
|
+
add(
|
|
96
|
+
"layout",
|
|
97
|
+
config.layout.kind === "fill"
|
|
98
|
+
? "It is as tall as the window: the page scrolls itself, and position: fixed and sticky work."
|
|
99
|
+
: declared.has("resize")
|
|
100
|
+
? `Its height follows its content, at most ${config.limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
|
|
101
|
+
: "Its height is fixed by the app: keep it short, or let it scroll inside.",
|
|
102
|
+
);
|
|
103
|
+
const documents = config.theme.documents ?? "declared";
|
|
104
|
+
add(
|
|
105
|
+
"theme",
|
|
106
|
+
config.mode !== "artifact" || documents === "always"
|
|
107
|
+
? "It runs in the person's light or dark theme."
|
|
108
|
+
: documents === "never"
|
|
109
|
+
? "It always shows in light: the app doesn't theme it, so style it yourself; prefers-color-scheme may report the reader's system setting, so don't rely on it."
|
|
110
|
+
: 'It shows in light, as you style it; to follow the person\'s light or dark theme, declare it in its head (<meta name="color-scheme" content="light dark">) and draw with the variables below.',
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
add(
|
|
114
|
+
"silent",
|
|
115
|
+
"What the sandbox blocks fails silently: a blocked script, style or image just doesn't load, a blocked fetch rejects, and nothing shows an error.",
|
|
116
|
+
);
|
|
117
|
+
add("network", network);
|
|
118
|
+
// Assets: what isn't opened goes inline (Network: none already says "everything inline").
|
|
119
|
+
const anywhere = (kind: "img" | "font" | "media") =>
|
|
120
|
+
(config.egress.anyHttps ?? []).some((each) => each === kind);
|
|
121
|
+
const passive = (["img", "font", "media"] as const).filter((kind) => !anywhere(kind));
|
|
122
|
+
const listed = passive.some((kind) => opens(config, kind)) || opens(config, "style");
|
|
123
|
+
const anyOpen =
|
|
124
|
+
listed ||
|
|
125
|
+
egressOrigins(config, "script").length > 0 ||
|
|
126
|
+
egressOrigins(config, "connect").length > 0 ||
|
|
127
|
+
passive.length < 3;
|
|
128
|
+
if (anyOpen) {
|
|
129
|
+
const images =
|
|
130
|
+
passive.length === 0
|
|
131
|
+
? null
|
|
132
|
+
: passive.every((kind) => !opens(config, kind))
|
|
133
|
+
? "images, icons, fonts and sound go inline (data: URLs or SVG)"
|
|
134
|
+
: "images, fonts and sound from anywhere not listed go inline (data: URLs or SVG)";
|
|
135
|
+
const css = opens(config, "style") ? "other CSS goes in a <style>" : "CSS goes in a <style>";
|
|
136
|
+
const said = [images, css].filter((each) => each !== null).join("; ");
|
|
137
|
+
add("inline-assets", `${said.charAt(0).toUpperCase()}${said.slice(1)}.`);
|
|
138
|
+
}
|
|
139
|
+
if (!tokens.includes("allow-modals")) {
|
|
140
|
+
add(
|
|
141
|
+
"dialogs",
|
|
142
|
+
"alert, confirm and prompt show nothing: confirm returns false and prompt null. Draw your own dialogs.",
|
|
143
|
+
);
|
|
144
|
+
add("print", "window.print() does nothing.");
|
|
145
|
+
}
|
|
146
|
+
if (!tokens.includes("allow-downloads")) {
|
|
147
|
+
add(
|
|
148
|
+
"downloads",
|
|
149
|
+
`Downloads don't work: <a download>, data: and blob: links, saves from script.${declared.has("downloadFile") ? " To give the person a file, ask the app (ui/download-file; with the App SDK, app.downloadFile)." : ""}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!tokens.includes("allow-forms")) {
|
|
153
|
+
add(
|
|
154
|
+
"forms",
|
|
155
|
+
'Forms never submit, and some browsers fire no submit event: use them as UI, give buttons type="button" and handle their click.',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
add(
|
|
159
|
+
"link-schemes",
|
|
160
|
+
`mailto:, tel: and sms: links open nothing: show the address or number as text${globals.has("copyText") ? " (with a copy button)" : ""}.`,
|
|
161
|
+
);
|
|
162
|
+
if (!tokens.includes("allow-popups")) {
|
|
163
|
+
add(
|
|
164
|
+
"windows",
|
|
165
|
+
globals.has("openLink") || declared.has("openLink")
|
|
166
|
+
? "window.open and target=_blank open no window (window.open returns null or throws): links open in a new tab through the app (openLink; a click on <a href> does it)."
|
|
167
|
+
: "Links to other pages, window.open and target=_blank open nothing (window.open returns null or throws).",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
add(
|
|
171
|
+
"devices",
|
|
172
|
+
"Camera, microphone, location and sharing are denied without asking (they reject, or aren't there); file inputs and FileReader work.",
|
|
173
|
+
);
|
|
174
|
+
add(
|
|
175
|
+
"workers",
|
|
176
|
+
"Web Workers from blob: URLs work; service workers can't be registered (even reading navigator.serviceWorker can throw), and WebRTC is gone.",
|
|
177
|
+
);
|
|
178
|
+
add("sound", "Sound (and video with sound) plays only from a click: call play() in its handler.");
|
|
179
|
+
add(
|
|
180
|
+
"address",
|
|
181
|
+
config.isolation === "opaque"
|
|
182
|
+
? `The ${it}'s address is about:srcdoc: it has no query string or hash to read.`
|
|
183
|
+
: `The ${it}'s address is its own (with Porthole's one-time ticket), never the app's: read no parameters from it.`,
|
|
184
|
+
);
|
|
185
|
+
add(
|
|
186
|
+
"shell",
|
|
187
|
+
`document.write and document.open throw; nested frames (iframe, object, embed) are removed; full screen and the clipboard are closed${globals.has("copyText") ? " (copy buttons call copyText)" : ""}.`,
|
|
188
|
+
);
|
|
189
|
+
add(
|
|
190
|
+
"storage",
|
|
191
|
+
config.isolation === "opaque"
|
|
192
|
+
? `State isn't saved: localStorage works but lasts only while the ${it} is on screen, and a reload or another device starts fresh. Keep what matters in the ${it}'s code.`
|
|
193
|
+
: config.storage?.scope !== undefined
|
|
194
|
+
? `localStorage is kept for ${config.storage.scope}, in this browser; another browser or device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`
|
|
195
|
+
: config.storage?.shared === undefined
|
|
196
|
+
? `localStorage is kept for this ${it} in this browser; another device starts fresh. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`
|
|
197
|
+
: `localStorage is shared by every ${it} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${it}'s own ("budget:filters", not "filters"), or ${it}s overwrite each other. Reads and writes can throw (private windows, strict settings): wrap them in try/catch and keep only what can be lost.`,
|
|
198
|
+
);
|
|
199
|
+
// Where the content's errors go (P065): never promise feedback that doesn't come.
|
|
200
|
+
const reports = config.limits.maxErrorReports;
|
|
201
|
+
const rate = (config.capabilities.reportError?.gates ?? [{ rateLimit: { perMinute: 10 } }]).find(
|
|
202
|
+
(gate): gate is { readonly rateLimit: { readonly perMinute: number } } =>
|
|
203
|
+
typeof gate === "object",
|
|
204
|
+
);
|
|
205
|
+
const atMost = rate === undefined ? "" : ` At most ${rate.rateLimit.perMinute} a minute.`;
|
|
206
|
+
add(
|
|
207
|
+
"errors",
|
|
208
|
+
!declared.has("reportError")
|
|
209
|
+
? "Errors: nothing reports what your code throws, to you or the app: test what you write."
|
|
210
|
+
: config.errors === "agent"
|
|
211
|
+
? `Errors: what your code throws, and anything the sandbox blocks, comes back to you (at most ${reports} a run), so you can fix it.${atMost}`
|
|
212
|
+
: config.errors === "host"
|
|
213
|
+
? `Errors: what your code throws, and anything the sandbox blocks, goes to the app (at most ${reports} a run), not to you: test what you write.${atMost}`
|
|
214
|
+
: `Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${reports} a run).${atMost}`,
|
|
215
|
+
);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A byte limit in words: KB under a megabyte, MB above. */
|
|
220
|
+
export function sizeWords(bytes: number): string {
|
|
221
|
+
return bytes < 1_000_000
|
|
222
|
+
? `${Math.floor(bytes / 1000)} KB`
|
|
223
|
+
: `${Math.round((bytes / 1_048_576) * 10) / 10} MB`;
|
|
224
|
+
}
|
package/src/host/create-host.ts
CHANGED
|
@@ -108,7 +108,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
108
108
|
emit("error", { message, source: "host" });
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// A page the host doesn't theme (`documents: "never"`, P065) is always given light.
|
|
112
|
+
const themeless = config.theme.documents === "never";
|
|
113
|
+
let scheme: ColorScheme = themeless ? "light" : mount.scheme;
|
|
112
114
|
let height = mount.initialHeightPx ?? 0;
|
|
113
115
|
let surface: Surface | null = null;
|
|
114
116
|
let connection: C | null = null;
|
|
@@ -323,8 +325,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
323
325
|
referrerPolicy: compiled.referrerPolicy,
|
|
324
326
|
colorScheme: scheme,
|
|
325
327
|
// An app reports no size: its frame fills the host's container, at least the minimum;
|
|
326
|
-
// so does any display the host mounts to fill
|
|
327
|
-
|
|
328
|
+
// so does any display the host mounts to fill, or whose layout fills (P065): its
|
|
329
|
+
// content scrolls itself.
|
|
330
|
+
...(speaks && mount.fill !== true && config.layout.kind !== "fill"
|
|
328
331
|
? { initialHeightPx: height }
|
|
329
332
|
: { fill: true, initialHeightPx: config.limits.minHeightPx }),
|
|
330
333
|
});
|
|
@@ -443,6 +446,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
443
446
|
displayId: mount.displayId,
|
|
444
447
|
annotations,
|
|
445
448
|
setTheme(next: ColorScheme) {
|
|
449
|
+
if (themeless) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
446
452
|
scheme = next;
|
|
447
453
|
surface?.setColorScheme(next);
|
|
448
454
|
// Read the host's values two frames later: a host that restyles its page for the new
|
package/src/presets/presets.ts
CHANGED
|
@@ -37,7 +37,7 @@ export interface PresetOptions {
|
|
|
37
37
|
readonly theme?: {
|
|
38
38
|
readonly variables?: Readonly<Record<string, ThemeValue | null>>;
|
|
39
39
|
readonly event?: string;
|
|
40
|
-
readonly documents?: "declared" | "always";
|
|
40
|
+
readonly documents?: "declared" | "always" | "never";
|
|
41
41
|
};
|
|
42
42
|
readonly limits?: Partial<Limits>;
|
|
43
43
|
readonly layout?: Partial<LayoutConfig>;
|
|
@@ -48,6 +48,8 @@ export interface PresetOptions {
|
|
|
48
48
|
readonly embedder?: EmbedderConfig;
|
|
49
49
|
/** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
|
|
50
50
|
readonly storage?: StorageConfig;
|
|
51
|
+
/** Where the content's errors go (`PortholeConfig.errors`); `none` drops `reportError`. */
|
|
52
|
+
readonly errors?: "agent" | "host" | "none";
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** The url preset's options: where the app is served, and the embedding page's extra frames. */
|
|
@@ -156,6 +158,9 @@ function build(
|
|
|
156
158
|
const capabilities: { -readonly [N in CapabilityName]?: CapabilityConfig } = {
|
|
157
159
|
...BASE_CAPABILITIES,
|
|
158
160
|
};
|
|
161
|
+
if (options.errors === "none") {
|
|
162
|
+
delete capabilities.reportError;
|
|
163
|
+
}
|
|
159
164
|
for (const [name, value] of Object.entries(options.capabilities ?? {}) as [
|
|
160
165
|
CapabilityName,
|
|
161
166
|
CapabilityConfig | false,
|
|
@@ -182,6 +187,7 @@ function build(
|
|
|
182
187
|
contentOrigins: options.contentOrigins ?? [],
|
|
183
188
|
embedder: options.embedder ?? {},
|
|
184
189
|
...(options.storage === undefined ? {} : { storage: options.storage }),
|
|
190
|
+
...(options.errors === undefined ? {} : { errors: options.errors }),
|
|
185
191
|
});
|
|
186
192
|
}
|
|
187
193
|
|
package/src/react/frame.tsx
CHANGED
|
@@ -392,7 +392,11 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
|
|
|
392
392
|
position: "relative",
|
|
393
393
|
width: "100%",
|
|
394
394
|
// A display that fills takes its parent's height (the host sizes the parent, or this box).
|
|
395
|
-
...(props.fill === true ||
|
|
395
|
+
...(props.fill === true ||
|
|
396
|
+
props.config.mode === "url" ||
|
|
397
|
+
props.config.layout.kind === "fill"
|
|
398
|
+
? { height: "100%" }
|
|
399
|
+
: {}),
|
|
396
400
|
...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
|
|
397
401
|
...props.style,
|
|
398
402
|
}}
|
|
@@ -434,7 +438,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
434
438
|
// By value: a config or instance made inline (each render) is the same display.
|
|
435
439
|
const config = useByValue(frame.config);
|
|
436
440
|
const instance = useByValue(frame.instance);
|
|
437
|
-
const fills = config.mode === "url" || frame.fill === true;
|
|
441
|
+
const fills = config.mode === "url" || frame.fill === true || config.layout.kind === "fill";
|
|
438
442
|
|
|
439
443
|
// The latest props, read when the display calls back (never during render).
|
|
440
444
|
const latest = useEffectEvent(() => frame);
|
package/src/shell/plan.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface ShellPlan {
|
|
|
26
26
|
/** The window event fired after a theme change. */
|
|
27
27
|
readonly themeEvent: string;
|
|
28
28
|
/** Whether a whole document takes the person's scheme: when it declares it, or always. */
|
|
29
|
-
readonly themeDocuments: "declared" | "always";
|
|
29
|
+
readonly themeDocuments: "declared" | "always" | "never";
|
|
30
30
|
/** `memory`: an opaque origin has no storage, so a map stands in for the frame's life. */
|
|
31
31
|
readonly storage: "memory" | "native";
|
|
32
32
|
readonly maxErrorReports: number;
|
package/src/shell/runtime.ts
CHANGED
|
@@ -567,7 +567,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
567
567
|
document.body.replaceWith(document.importNode(parsed.body, true));
|
|
568
568
|
root = null;
|
|
569
569
|
const declaredScheme = parsed.querySelector('meta[name="color-scheme" i]');
|
|
570
|
-
themed = plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || ""));
|
|
570
|
+
themed = plan.themeDocuments !== "never" && (plan.themeDocuments === "always" || (declaredScheme !== null && /\bdark\b/i.test(declaredScheme.getAttribute("content") || "")));
|
|
571
571
|
schemeFollows();
|
|
572
572
|
await runScripts(scripts);
|
|
573
573
|
announceLoaded();
|