@botiverse/porthole 0.1.0 → 0.1.1

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 (68) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +3 -3
  4. package/dist/compile/policy.js +6 -9
  5. package/dist/config/schema.d.ts +21 -0
  6. package/dist/config/schema.js +11 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +41 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +24 -0
  20. package/dist/host/outcome.js +134 -0
  21. package/dist/host/types.d.ts +34 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +3 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +9 -3
  31. package/dist/presets/presets.js +16 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/pages.d.ts +11 -6
  35. package/dist/server/pages.js +78 -12
  36. package/dist/server/server.js +34 -10
  37. package/dist/shell/document.d.ts +3 -2
  38. package/dist/shell/document.js +4 -3
  39. package/dist/shell/plan.d.ts +2 -0
  40. package/dist/shell/plan.js +1 -0
  41. package/dist/shell/runtime.js +20 -4
  42. package/package.json +1 -9
  43. package/src/bridge/bridge.ts +28 -6
  44. package/src/bridge/types.ts +21 -3
  45. package/src/compile/policy.ts +6 -9
  46. package/src/config/schema.ts +28 -0
  47. package/src/config/validate.ts +48 -14
  48. package/src/conformance/behavior.ts +55 -0
  49. package/src/conformance/index.ts +18 -4
  50. package/src/conformance/probes.ts +88 -52
  51. package/src/conformance/served.ts +37 -8
  52. package/src/guide/guide.ts +8 -2
  53. package/src/host/create-host.ts +47 -2
  54. package/src/host/locate.ts +6 -1
  55. package/src/host/outcome.ts +159 -0
  56. package/src/host/types.ts +33 -2
  57. package/src/index.ts +11 -1
  58. package/src/isolation/served.ts +24 -0
  59. package/src/isolation/types.ts +15 -1
  60. package/src/platform/types.ts +7 -0
  61. package/src/platform/web.ts +15 -2
  62. package/src/presets/presets.ts +27 -3
  63. package/src/react/frame.tsx +78 -14
  64. package/src/server/pages.ts +97 -11
  65. package/src/server/server.ts +48 -8
  66. package/src/shell/document.ts +7 -6
  67. package/src/shell/plan.ts +3 -0
  68. package/src/shell/runtime.ts +20 -4
@@ -0,0 +1,159 @@
1
+ import { FRAME_STATUS_METHOD, type FrameSource } from "../isolation/types.ts";
2
+ import type { Surface } from "../platform/types.ts";
3
+ import type { FrameBlock, FrameOutcome } from "./types.ts";
4
+
5
+ /**
6
+ * How a display's first document went (P040). A frame's content is another origin's, so the host
7
+ * can't look inside; it goes by what it can see:
8
+ *
9
+ * - a shell or view that speaks for itself is loaded when it finishes its handshake;
10
+ * - a page of Porthole's `/server` says how it went (`porthole/notifications/frame-status`): it is
11
+ * entering an app, or blocked (its ticket used, no cookie kept in a frame, the app down); a url
12
+ * app is loaded when its document loads after the entry page;
13
+ * - a load from which nothing ever spoke, where something should have, is a page that never ran:
14
+ * framing refused;
15
+ * - an app from a host's own gateway is loaded on its first load, unless its page says it is
16
+ * blocked (the gateway may send the same message);
17
+ * - nothing settled in `timeoutMs`: a timeout.
18
+ */
19
+
20
+ /** How long after a load the frame's word may still arrive. */
21
+ const SETTLE_MS = 400;
22
+
23
+ const BLOCKS: readonly FrameBlock[] = [
24
+ "framing",
25
+ "cookies",
26
+ "session",
27
+ "upstream",
28
+ "not-found",
29
+ "error",
30
+ ];
31
+
32
+ interface FrameStatus {
33
+ readonly state: "entering" | "blocked";
34
+ readonly reason?: FrameBlock;
35
+ readonly status?: number;
36
+ }
37
+
38
+ /** A frame-status message, or null for anything else. */
39
+ export function parseFrameStatus(data: unknown): FrameStatus | null {
40
+ if (data === null || typeof data !== "object") {
41
+ return null;
42
+ }
43
+ const message = data as { jsonrpc?: unknown; method?: unknown; params?: unknown };
44
+ if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
45
+ return null;
46
+ }
47
+ const params = message.params as { state?: unknown; reason?: unknown; status?: unknown } | null;
48
+ if (params === null || typeof params !== "object") {
49
+ return null;
50
+ }
51
+ if (params.state === "entering") {
52
+ return { state: "entering" };
53
+ }
54
+ if (params.state !== "blocked") {
55
+ return null;
56
+ }
57
+ const reason = BLOCKS.includes(params.reason as FrameBlock)
58
+ ? (params.reason as FrameBlock)
59
+ : "error";
60
+ return {
61
+ state: "blocked",
62
+ reason,
63
+ ...(typeof params.status === "number" && Number.isInteger(params.status)
64
+ ? { status: params.status }
65
+ : {}),
66
+ };
67
+ }
68
+
69
+ export interface OutcomeWatch {
70
+ /** The frame finished its handshake. */
71
+ initialized(): void;
72
+ stop(): void;
73
+ }
74
+
75
+ export function watchOutcome(options: {
76
+ readonly surface: Surface;
77
+ readonly source: FrameSource;
78
+ /** Whether the frame speaks for itself (a shell or a view): then its handshake is the proof. */
79
+ readonly speaks: boolean;
80
+ readonly timeoutMs: number;
81
+ readonly onOutcome: (outcome: FrameOutcome) => void;
82
+ }): OutcomeWatch {
83
+ const { surface, source, speaks } = options;
84
+ const reports = source.kind === "url" && source.reports === true;
85
+ let decided = false;
86
+ let heard = false;
87
+ let entering = false;
88
+ let loads = 0;
89
+ const timers = new Set<ReturnType<typeof setTimeout>>();
90
+ const later = (ms: number, run: () => void) => {
91
+ const timer = setTimeout(() => {
92
+ timers.delete(timer);
93
+ run();
94
+ }, ms);
95
+ timers.add(timer);
96
+ };
97
+ const stop = () => {
98
+ for (const timer of timers) {
99
+ clearTimeout(timer);
100
+ }
101
+ timers.clear();
102
+ unlisten();
103
+ unload();
104
+ };
105
+ const decide = (outcome: FrameOutcome) => {
106
+ if (!decided) {
107
+ decided = true;
108
+ stop();
109
+ options.onOutcome(outcome);
110
+ }
111
+ };
112
+ const unlisten = surface.transport.listen((data) => {
113
+ heard = true;
114
+ const status = parseFrameStatus(data);
115
+ if (status?.state === "entering") {
116
+ entering = true;
117
+ } else if (status?.state === "blocked") {
118
+ decide({
119
+ state: "blocked",
120
+ reason: status.reason ?? "error",
121
+ ...(status.status === undefined ? {} : { status: status.status }),
122
+ });
123
+ }
124
+ });
125
+ const neverRan = () =>
126
+ later(SETTLE_MS, () => {
127
+ if (!heard) {
128
+ decide({ state: "blocked", reason: "framing" });
129
+ }
130
+ });
131
+ const unload = surface.onLoad(() => {
132
+ loads += 1;
133
+ if (decided) {
134
+ return;
135
+ }
136
+ if (speaks) {
137
+ // A served shell or view says something before its first load ends; one that never did
138
+ // never ran. (An opaque srcdoc frame always runs: its handshake decides.)
139
+ if (source.kind === "url") {
140
+ neverRan();
141
+ }
142
+ return;
143
+ }
144
+ if (!reports) {
145
+ later(SETTLE_MS, () => decide({ state: "loaded" }));
146
+ return;
147
+ }
148
+ if (entering && loads >= 2) {
149
+ decide({ state: "loaded" });
150
+ } else if (!entering) {
151
+ neverRan();
152
+ }
153
+ });
154
+ later(options.timeoutMs, () => decide({ state: "timeout" }));
155
+ return {
156
+ initialized: () => decide({ state: "loaded" }),
157
+ stop,
158
+ };
159
+ }
package/src/host/types.ts CHANGED
@@ -52,9 +52,35 @@ export interface MountOptions {
52
52
  }
53
53
 
54
54
  /** What a display reports, by event name. */
55
+ /** Why a display's frame showed nothing of its content (`FrameOutcome`). */
56
+ export type FrameBlock =
57
+ /** The page that frames it isn't one its server allows (frame-ancestors), or it never ran. */
58
+ | "framing"
59
+ /** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
60
+ | "cookies"
61
+ /** Its ticket was used or its session ended. */
62
+ | "session"
63
+ /** Its app didn't answer. */
64
+ | "upstream"
65
+ | "not-found"
66
+ | "error";
67
+
68
+ /**
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.
73
+ */
74
+ export type FrameOutcome =
75
+ | { readonly state: "loaded" }
76
+ | { readonly state: "blocked"; readonly reason: FrameBlock; readonly status?: number }
77
+ | { readonly state: "timeout" };
78
+
55
79
  export interface DisplayEvents {
56
- /** The frame finished its handshake. */
80
+ /** The frame finished its handshake (url mode, which has none: its first document loaded). */
57
81
  readonly initialized: undefined;
82
+ /** How the frame's first document went (`FrameOutcome`). */
83
+ readonly outcome: FrameOutcome;
58
84
  /** The content's size, after the config's limits. */
59
85
  readonly resize: { readonly height: number; readonly width?: number };
60
86
  /** What the content threw, or what the sandbox blocked (its own words: untrusted). */
@@ -69,8 +95,13 @@ export interface DisplayEvents {
69
95
 
70
96
  export interface DisplayControls {
71
97
  readonly displayId: string;
72
- /** The theme flipped: the frame is re-themed in place, without a reload. */
98
+ /**
99
+ * The theme flipped: the frame is re-themed in place, without a reload. The host's values are
100
+ * read two animation frames later, once a host that restyles its page asynchronously has.
101
+ */
73
102
  setTheme(scheme: ColorScheme): void;
103
+ /** The host's theme values changed (same scheme): read them now and re-theme the frame. */
104
+ refreshTheme(): void;
74
105
  on<E extends keyof DisplayEvents>(
75
106
  event: E,
76
107
  listener: (detail: DisplayEvents[E]) => void,
package/src/index.ts CHANGED
@@ -31,7 +31,15 @@ export type {
31
31
  ThemeConfig,
32
32
  ThemeValue,
33
33
  } from "./config/schema.ts";
34
- export { BUILT_IN_TOPICS, ConfigError, isOriginSource, validateConfig } from "./config/validate.ts";
34
+ export {
35
+ BUILT_IN_TOPICS,
36
+ ConfigError,
37
+ isOriginSource,
38
+ originSourceProblem,
39
+ validateConfig,
40
+ } from "./config/validate.ts";
41
+ export { URL_SANDBOX_EXTRAS, type UrlSandboxExtra } from "./config/schema.ts";
42
+ export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
35
43
 
36
44
  // Presets.
37
45
  export {
@@ -103,7 +111,9 @@ export type {
103
111
  CapabilityHandler,
104
112
  CapabilityHandlers,
105
113
  Confirm,
114
+ ConfirmAnswer,
106
115
  ConfirmRequest,
116
+ Proceed,
107
117
  HostFunctionBridge,
108
118
  Refusal,
109
119
  } from "./bridge/types.ts";
@@ -32,6 +32,30 @@ export function isExactOrigin(origin: unknown): origin is string {
32
32
  return typeof origin === "string" && parseOrigin(origin) !== null;
33
33
  }
34
34
 
35
+ /**
36
+ * A host page's origin, exactly as `location.origin` and a message's `event.origin` give it:
37
+ * `scheme://host[:port]`, lowercase, any scheme (an Electron or Tauri app's own registered scheme,
38
+ * `ferry://app`, `app://local`), never `"null"` (an opaque origin isn't one origin: every opaque
39
+ * page says "null") and never a path. The shell takes messages only from these, exactly.
40
+ */
41
+ const HOST_ORIGIN =
42
+ /^[a-z][a-z0-9+.-]*:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(:\d{1,5})?$/u;
43
+
44
+ export function isHostOrigin(origin: unknown): origin is string {
45
+ return typeof origin === "string" && origin.length <= 300 && HOST_ORIGIN.test(origin);
46
+ }
47
+
48
+ /** Why `origin` can't be a host origin, in words a host's developer can act on; null if it can. */
49
+ export function hostOriginProblem(origin: unknown): string | null {
50
+ if (isHostOrigin(origin)) {
51
+ return null;
52
+ }
53
+ if (origin === "null") {
54
+ return 'the host page\'s origin is opaque ("null"): serve it from a real origin (an Electron custom protocol must be registered as standard, and file: pages have none)';
55
+ }
56
+ return `${JSON.stringify(origin)} is not an exact origin (scheme://host[:port], lowercase, no path)`;
57
+ }
58
+
35
59
  /**
36
60
  * Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
37
61
  * it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
@@ -57,8 +57,17 @@ export interface FrameLocation {
57
57
  readonly url: string;
58
58
  /** Its origin: the host takes messages only from this origin, from this frame. */
59
59
  readonly origin: string;
60
+ /**
61
+ * Whether the pages there report how loading went (`porthole/notifications/frame-status`):
62
+ * Porthole's `/server` does, so a page that never ran (refused framing) can be told apart from
63
+ * one that loaded. A host's own gateway may send the same message without this.
64
+ */
65
+ readonly reports?: boolean;
60
66
  }
61
67
 
68
+ /** The message a served page sends its host about loading (own-prefixed, P040). */
69
+ export const FRAME_STATUS_METHOD = "porthole/notifications/frame-status";
70
+
62
71
  /**
63
72
  * Turns an instance into the URL its frame loads. Implemented by the host, usually by asking its
64
73
  * own backend (which authenticates the viewer and calls `PortholeServer.issue`). Called on every
@@ -74,4 +83,9 @@ export interface InstanceLocator {
74
83
  */
75
84
  export type FrameSource =
76
85
  | { readonly kind: "srcdoc"; readonly html: string }
77
- | { readonly kind: "url"; readonly url: string; readonly origin: string };
86
+ | {
87
+ readonly kind: "url";
88
+ readonly url: string;
89
+ readonly origin: string;
90
+ readonly reports?: boolean;
91
+ };
@@ -17,6 +17,11 @@ export interface SurfaceSpec {
17
17
  readonly allow: string;
18
18
  readonly referrerPolicy: "no-referrer";
19
19
  readonly colorScheme: ColorScheme;
20
+ /**
21
+ * Fill the container's height (a url-mode app, which reports no size), at least
22
+ * `initialHeightPx`, instead of taking the content's.
23
+ */
24
+ readonly fill?: boolean;
20
25
  /** The height to start at (a remembered height, so a frame mounting again doesn't jump). */
21
26
  readonly initialHeightPx: number;
22
27
  }
@@ -38,6 +43,8 @@ export interface Surface {
38
43
  * framework starts it again from its content and reports it.
39
44
  */
40
45
  onNavigated(listener: () => void): () => void;
46
+ /** Every load of the frame's document, the first included. */
47
+ onLoad(listener: () => void): () => void;
41
48
  destroy(): void;
42
49
  }
43
50
 
@@ -26,7 +26,9 @@ export const webPlatform: PlatformHost = {
26
26
  border: "0",
27
27
  background: "transparent",
28
28
  colorScheme: spec.colorScheme,
29
- height: `${Math.max(0, Math.round(spec.initialHeightPx))}px`,
29
+ ...(spec.fill === true
30
+ ? { height: "100%", minHeight: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }
31
+ : { height: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }),
30
32
  });
31
33
  const origin = spec.source.kind === "srcdoc" ? "null" : spec.source.origin;
32
34
  if (spec.source.kind === "srcdoc") {
@@ -36,9 +38,13 @@ export const webPlatform: PlatformHost = {
36
38
  }
37
39
 
38
40
  const navigated = new Set<() => void>();
41
+ const loaded = new Set<() => void>();
39
42
  let loads = 0;
40
43
  frame.addEventListener("load", () => {
41
44
  loads += 1;
45
+ for (const listener of loaded) {
46
+ listener();
47
+ }
42
48
  if (loads > 1) {
43
49
  for (const listener of navigated) {
44
50
  listener();
@@ -53,7 +59,9 @@ export const webPlatform: PlatformHost = {
53
59
  transport,
54
60
  element: frame,
55
61
  setHeight(px: number) {
56
- frame.style.height = `${Math.max(0, Math.round(px))}px`;
62
+ if (spec.fill !== true) {
63
+ frame.style.height = `${Math.max(0, Math.round(px))}px`;
64
+ }
57
65
  },
58
66
  setColorScheme(scheme: ColorScheme) {
59
67
  frame.style.colorScheme = scheme;
@@ -64,12 +72,17 @@ export const webPlatform: PlatformHost = {
64
72
  ).userActivation;
65
73
  return activation === undefined ? undefined : activation.isActive;
66
74
  },
75
+ onLoad(listener) {
76
+ loaded.add(listener);
77
+ return () => loaded.delete(listener);
78
+ },
67
79
  onNavigated(listener) {
68
80
  navigated.add(listener);
69
81
  return () => navigated.delete(listener);
70
82
  },
71
83
  destroy() {
72
84
  navigated.clear();
85
+ loaded.clear();
73
86
  transport.close();
74
87
  frame.remove();
75
88
  },
@@ -10,6 +10,7 @@ import type {
10
10
  Limits,
11
11
  PortholeConfig,
12
12
  ThemeValue,
13
+ UrlSandboxExtra,
13
14
  } from "../config/schema.ts";
14
15
  import { validateConfig } from "../config/validate.ts";
15
16
  import { DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES } from "../theme/variables.ts";
@@ -27,10 +28,14 @@ export interface PresetOptions {
27
28
  readonly capabilities?: { readonly [N in CapabilityName]?: CapabilityConfig | false };
28
29
  /** Origins to open, added to the preset's (closed) egress. */
29
30
  readonly egress?: EgressConfig;
30
- /** Theme defaults to change or variables to add, and the theme event's name. */
31
+ /**
32
+ * Theme defaults to change, variables to add, or (null) defaults to drop; the theme event's
33
+ * name; and whether whole documents follow the person's scheme (`ThemeConfig.documents`).
34
+ */
31
35
  readonly theme?: {
32
- readonly variables?: Readonly<Record<string, ThemeValue>>;
36
+ readonly variables?: Readonly<Record<string, ThemeValue | null>>;
33
37
  readonly event?: string;
38
+ readonly documents?: "declared" | "always";
34
39
  };
35
40
  readonly limits?: Partial<Limits>;
36
41
  readonly layout?: Partial<LayoutConfig>;
@@ -44,6 +49,8 @@ export interface PresetOptions {
44
49
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
45
50
  export interface UrlPresetOptions {
46
51
  readonly contentOrigins: readonly string[];
52
+ /** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
53
+ readonly sandbox?: readonly UrlSandboxExtra[];
47
54
  readonly limits?: Partial<Limits>;
48
55
  readonly layout?: Partial<LayoutConfig>;
49
56
  readonly embedder?: EmbedderConfig;
@@ -116,6 +123,21 @@ const mergeEgress = (base: EgressConfig, extra: EgressConfig | undefined): Egres
116
123
  return merged;
117
124
  };
118
125
 
126
+ /** The default variables, with the host's changes and additions, less those it dropped (null). */
127
+ function themeVariables(
128
+ changes: Readonly<Record<string, ThemeValue | null>> | undefined,
129
+ ): Record<string, ThemeValue> {
130
+ const variables: Record<string, ThemeValue> = { ...DEFAULT_THEME_VARIABLES };
131
+ for (const [name, value] of Object.entries(changes ?? {})) {
132
+ if (value === null) {
133
+ delete variables[name];
134
+ } else {
135
+ variables[name] = value;
136
+ }
137
+ }
138
+ return variables;
139
+ }
140
+
119
141
  function build(
120
142
  mode: PortholeConfig["mode"],
121
143
  defaults: { readonly limits?: Partial<Limits> },
@@ -140,8 +162,9 @@ function build(
140
162
  capabilities,
141
163
  egress: mergeEgress({}, options.egress),
142
164
  theme: {
143
- variables: { ...DEFAULT_THEME_VARIABLES, ...options.theme?.variables },
165
+ variables: themeVariables(options.theme?.variables),
144
166
  event: options.theme?.event ?? DEFAULT_THEME_EVENT,
167
+ ...(options.theme?.documents === undefined ? {} : { documents: options.theme.documents }),
145
168
  },
146
169
  limits: { ...BASE_LIMITS, ...defaults.limits, ...options.limits },
147
170
  layout: { ...BASE_LAYOUT, ...options.layout },
@@ -187,6 +210,7 @@ export const presets = {
187
210
  guide: {},
188
211
  contentOrigins: options.contentOrigins,
189
212
  embedder: options.embedder ?? {},
213
+ ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
190
214
  }),
191
215
  } as const;
192
216
 
@@ -18,13 +18,15 @@ import type {
18
18
  CallContext,
19
19
  CapabilityHandlers,
20
20
  Confirm,
21
+ ConfirmAnswer,
21
22
  ConfirmRequest,
23
+ Proceed,
22
24
  Refusal,
23
25
  } from "../bridge/types.ts";
24
26
  import type { CapabilityName } from "../config/capabilities.ts";
25
27
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
26
28
  import { createHost } from "../host/create-host.ts";
27
- import type { DisplayEvents, DisplayHandle } from "../host/types.ts";
29
+ import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
28
30
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
29
31
  import { ConfirmDialog } from "./dialog.tsx";
30
32
  import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -72,7 +74,8 @@ export interface PortholeProviderProps {
72
74
 
73
75
  interface Pending {
74
76
  readonly request: ConfirmRequest;
75
- readonly resolve: (accepted: boolean) => void;
77
+ readonly resolve: (answer: ConfirmAnswer) => void;
78
+ readonly proceed: Proceed;
76
79
  }
77
80
 
78
81
  export function PortholeProvider(props: PortholeProviderProps): ReactElement {
@@ -85,11 +88,11 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
85
88
  own.current = props.confirm;
86
89
  });
87
90
  const confirm = useCallback<Confirm>(
88
- (request) =>
91
+ (request, proceed) =>
89
92
  own.current !== undefined
90
- ? own.current(request)
91
- : new Promise<boolean>((resolve) =>
92
- setQueue((waiting) => [...waiting, { request, resolve }]),
93
+ ? own.current(request, proceed)
94
+ : new Promise<ConfirmAnswer>((resolve) =>
95
+ setQueue((waiting) => [...waiting, { request, resolve, proceed }]),
93
96
  ),
94
97
  [],
95
98
  );
@@ -113,7 +116,11 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
113
116
  request={current?.request ?? null}
114
117
  {...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName })}
115
118
  onAnswer={(accepted) => {
116
- current?.resolve(accepted);
119
+ // A yes runs the effect inside this very click (a window it opens is the person's).
120
+ if (accepted) {
121
+ current?.proceed();
122
+ }
123
+ current?.resolve(accepted ? "accepted" : "declined");
117
124
  setQueue((waiting) => waiting.slice(1));
118
125
  }}
119
126
  />
@@ -164,6 +171,21 @@ export interface PortholeFrameProps {
164
171
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
165
172
  /** Too many declines: the display stops asking until it runs again. */
166
173
  readonly onMuted?: () => void;
174
+ /**
175
+ * How the frame's first document went: loaded, blocked (and why), or a timeout. A host shows its
176
+ * own fallback (a url-mode app "Open in a new tab") on anything but loaded.
177
+ */
178
+ readonly onOutcome?: (outcome: FrameOutcome) => void;
179
+ /**
180
+ * Other ids this display had (the call's id, before the display's own settled): its remembered
181
+ * height is found under them, for a frame that mounts again under its new id.
182
+ */
183
+ readonly aliases?: readonly string[];
184
+ /**
185
+ * Change it when the host's theme values change without a scheme flip (or after an async
186
+ * restyle): the running frame reads them again.
187
+ */
188
+ readonly themeKey?: string | number;
167
189
  /** Draws the placeholder instead of the default one. */
168
190
  readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
169
191
  readonly className?: string;
@@ -198,6 +220,21 @@ function DefaultPlaceholder({ state, title, run }: PlaceholderProps): ReactEleme
198
220
  );
199
221
  }
200
222
 
223
+ /** A display's remembered height: under its id, else under the first of its other ids that has one. */
224
+ function rememberedHeight(
225
+ heights: ReadonlyMap<string, number>,
226
+ displayId: string,
227
+ aliases: readonly string[] | undefined,
228
+ ): number {
229
+ for (const id of [displayId, ...(aliases ?? [])]) {
230
+ const height = heights.get(id);
231
+ if (height !== undefined) {
232
+ return height;
233
+ }
234
+ }
235
+ return 0;
236
+ }
237
+
201
238
  export function PortholeFrame(props: PortholeFrameProps): ReactElement {
202
239
  const page = useContext(Page);
203
240
  const key = useId();
@@ -240,7 +277,17 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
240
277
  };
241
278
  }, [page, key, max, eager]);
242
279
 
243
- const height = page.heights.get(props.displayId) ?? 0;
280
+ const height = rememberedHeight(page.heights, props.displayId, props.aliases);
281
+ // A display renamed while it runs (its id settled) keeps its height under its new id.
282
+ const previousId = useRef(props.displayId);
283
+ useLayoutEffect(() => {
284
+ const before = previousId.current;
285
+ previousId.current = props.displayId;
286
+ const kept = page.heights.get(before);
287
+ if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
288
+ page.heights.set(props.displayId, kept);
289
+ }
290
+ }, [page, props.displayId]);
244
291
  const run = () => page.live.admit(key, max);
245
292
  const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
246
293
  return (
@@ -290,6 +337,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
290
337
  // The height this display started at: later reports don't make it start again.
291
338
  const [startHeight] = useState(initialHeightPx);
292
339
  const { config, document: view, instance, content, partial, scheme } = frame;
340
+ const fills = config.mode === "url";
293
341
 
294
342
  // The latest props, read when the display calls back (never during render).
295
343
  const latest = useEffectEvent(() => frame);
@@ -320,6 +368,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
320
368
  case "muted":
321
369
  frame.onMuted?.();
322
370
  return;
371
+ case "outcome":
372
+ frame.onOutcome?.(detail as DisplayEvents["outcome"]);
373
+ return;
323
374
  }
324
375
  },
325
376
  );
@@ -353,11 +404,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
353
404
  handlers,
354
405
  ...(asks
355
406
  ? {
356
- confirm: (request: ConfirmRequest) =>
357
- (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({
358
- ...request,
359
- display: { id: latest().displayId, title: latest().title },
360
- }),
407
+ confirm: (request: ConfirmRequest, proceed: Proceed) =>
408
+ (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))(
409
+ { ...request, display: { id: latest().displayId, title: latest().title } },
410
+ proceed,
411
+ ),
361
412
  }
362
413
  : {}),
363
414
  theme: (each) => latest().theme?.(each) ?? {},
@@ -383,6 +434,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
383
434
  "navigated",
384
435
  "refused",
385
436
  "muted",
437
+ "outcome",
386
438
  ];
387
439
  for (const event of events) {
388
440
  display.on(event, (detail) => notify(event, detail));
@@ -406,16 +458,28 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
406
458
  useEffect(() => {
407
459
  handle.current?.setTheme(scheme);
408
460
  }, [scheme]);
461
+ const { themeKey } = frame;
462
+ useEffect(() => {
463
+ if (themeKey !== undefined) {
464
+ handle.current?.refreshTheme();
465
+ }
466
+ }, [themeKey]);
409
467
 
410
468
  return (
411
469
  <div
412
470
  data-porthole-ready={String(ready)}
413
471
  style={{
414
472
  position: "relative",
473
+ // A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
474
+ ...(fills ? { height: "100%" } : {}),
415
475
  ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
416
476
  }}
417
477
  >
418
- <div ref={holder} data-porthole-mount="" style={{ opacity: ready ? 1 : 0 }} />
478
+ <div
479
+ ref={holder}
480
+ data-porthole-mount=""
481
+ style={{ opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) }}
482
+ />
419
483
  {ready ? null : placeholder({ state: "loading", title: frame.title, run })}
420
484
  </div>
421
485
  );