1/**
2 * Preview - Schemas for the in-app browser preview surface.
3 *
4 * The preview is desktop-only (Chromium <webview>); the server tracks per-thread
5 * tab metadata so it survives client reconnects and multi-window. The desktop
6 * renderer mediates: it owns the actual <webview> and reports navigation back to
7 * the server via these RPCs, the server fans events to all subscribers.
8 *
9 * @module Preview
10 */
11import { Schema } from "effect";
12import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
13import { BROWSER_PROFILE_MAX_COUNT, BrowserProfile, BrowserProfileId } from "./browserProfile.ts";
14
15export const PREVIEW_URL_MAX_LENGTH = 2_048;
16export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32;
17
18const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(PREVIEW_URL_MAX_LENGTH));
19
20export const ConfiguredLocalServerUrls = Schema.Array(Url).check(
21 Schema.isMaxLength(CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS),
22);
23const Title = Schema.String.check(Schema.isMaxLength(512));
24
25export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
26export type PreviewTabId = typeof PreviewTabId.Type;
27
28export const PREVIEW_VIEWPORT_MIN_DIMENSION = 240;
29export const PREVIEW_VIEWPORT_MAX_DIMENSION = 3840;
30export const PREVIEW_VIEWPORT_MAX_AREA = 3840 * 2160;
31
32const PreviewViewportDimension = Schema.Int.check(
33 Schema.isBetween({
34 minimum: PREVIEW_VIEWPORT_MIN_DIMENSION,
35 maximum: PREVIEW_VIEWPORT_MAX_DIMENSION,
36 }),
37);
38
39const viewportAreaFilter = Schema.makeFilter(
40 ({ width, height }: { readonly width: number; readonly height: number }) =>
41 width * height <= PREVIEW_VIEWPORT_MAX_AREA ||
42 `Viewport area must not exceed ${PREVIEW_VIEWPORT_MAX_AREA} pixels.`,
43);
44
45export const PreviewViewportSize = Schema.Struct({
46 width: PreviewViewportDimension,
47 height: PreviewViewportDimension,
48}).check(viewportAreaFilter);
49export type PreviewViewportSize = typeof PreviewViewportSize.Type;
50
51/**
52 * The page's measured viewport can be smaller than the minimum selectable
53 * fixed size while fill mode follows a narrow panel. Keep measurement
54 * validation separate from the stricter user-selectable size constraints.
55 */
56export const PreviewRenderedViewportSize = Schema.Struct({
57 width: Schema.Int.check(Schema.isGreaterThan(0)),
58 height: Schema.Int.check(Schema.isGreaterThan(0)),
59});
60export type PreviewRenderedViewportSize = typeof PreviewRenderedViewportSize.Type;
61
62export const PREVIEW_VIEWPORT_PRESET_IDS = [
63 "iphone-se",
64 "iphone-xr",
65 "iphone-12-pro",
66 "iphone-14-pro-max",
67 "pixel-7",
68 "samsung-galaxy-s8-plus",
69 "samsung-galaxy-s20-ultra",
70 "ipad-mini",
71 "ipad-air",
72 "ipad-pro",
73 "surface-pro-7",
74 "surface-duo",
75 "galaxy-z-fold-5",
76 "asus-zenbook-fold",
77 "samsung-galaxy-a51-71",
78 "nest-hub",
79 "nest-hub-max",
80] as const;
81
82export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS);
83export type PreviewViewportPresetId = typeof PreviewViewportPresetId.Type;
84
85/**
86 * Preset IDs shipped before the Chrome-compatible catalog. Existing sessions
87 * can still reconnect with these values, but new resize requests only expose
88 * PREVIEW_VIEWPORT_PRESET_IDS.
89 */
90const LEGACY_PREVIEW_VIEWPORT_PRESET_IDS = [
91 "desktop-1920x1080",
92 "desktop-1440x900",
93 "laptop-1366x768",
94 "laptop-1280x800",
95 "ipad-pro-11",
96 "iphone-15-pro",
97 "pixel-8",
98 "galaxy-s24",
99] as const;
100
101const StoredPreviewViewportPresetId = Schema.Literals([
102 ...PREVIEW_VIEWPORT_PRESET_IDS,
103 ...LEGACY_PREVIEW_VIEWPORT_PRESET_IDS,
104]);
105
106export const PreviewViewportSetting = Schema.Union([
107 Schema.TaggedStruct("fill", {}),
108 Schema.TaggedStruct("freeform", {
109 ...PreviewViewportSize.fields,
110 }).check(viewportAreaFilter),
111 Schema.TaggedStruct("preset", {
112 ...PreviewViewportSize.fields,
113 presetId: StoredPreviewViewportPresetId,
114 }).check(viewportAreaFilter),
115]);
116export type PreviewViewportSetting = typeof PreviewViewportSetting.Type;
117
118export const FILL_PREVIEW_VIEWPORT = {
119 _tag: "fill",
120} as const satisfies PreviewViewportSetting;
121
122/**
123 * Discrete zoom levels mirroring Chrome's preset ladder. Zoom is applied by the
124 * desktop main process to the Chromium guest, but the ladder lives here so the
125 * settings UI can offer exactly the steps the zoom controls step through.
126 */
127export const PREVIEW_ZOOM_LEVELS = [
128 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,
129] as const;
130
131export const PreviewZoomFactor = Schema.Literals(PREVIEW_ZOOM_LEVELS);
132export type PreviewZoomFactor = typeof PreviewZoomFactor.Type;
133
134export const DEFAULT_PREVIEW_ZOOM_FACTOR: PreviewZoomFactor = 1.0;
135
136/**
137 * Preferred `prefers-color-scheme` for preview guests. `system` clears the
138 * emulation override so the guest follows the OS. Structurally identical to
139 * `DesktopPreviewColorScheme`, which is the IPC-layer spelling of the same set.
140 */
141export const PreviewAppearancePreference = Schema.Literals(["system", "light", "dark"]);
142export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Type;
143
144export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system";
145
146/** A file a server tab downloaded, served by the tab's download route by this id. */
147const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String });
148
149export const PreviewNavStatus = Schema.Union([
150 Schema.TaggedStruct("Idle", {}),
151 Schema.TaggedStruct("Loading", {
152 url: Url,
153 title: Title,
154 }),
155 Schema.TaggedStruct("Success", {
156 url: Url,
157 title: Title,
158 }),
159 Schema.TaggedStruct("LoadFailed", {
160 url: Url,
161 title: Title,
162 code: Schema.Int,
163 description: Schema.String,
164 /**
165 * Set when the address was a file the browser downloaded instead of a page
166 * it could show (a PDF in a server tab). The server tab's download route
167 * serves it by this id.
168 */
169 download: Schema.optional(PreviewFileDownload),
170 }),
171]);
172export type PreviewNavStatus = typeof PreviewNavStatus.Type;
173
174/**
175 * Where a tab's page runs. `desktop` is an Electron <webview> owned by one
176 * desktop client; `server` is headless Chromium owned by the environment
177 * server, viewed by any client through `/api/preview-stream` and driven by
178 * agents with no client attached. Absent means `desktop`.
179 */
180export const PreviewRuntime = Schema.Literals(["desktop", "server"]);
181export type PreviewRuntime = typeof PreviewRuntime.Type;
182
183/**
184 * Host setup the server's browser is missing, sent as JSON in the reason when
185 * the preview stream closes with code 4503. Fixing it needs the host's
186 * operator, so viewers stop retrying and show `command`, the one line that
187 * fixes it. Close reasons are capped at 123 bytes, which this fits.
188 */
189export const PreviewStreamHostSetup = Schema.Struct({
190 need: Schema.Literals(["sandbox", "libraries"]),
191 /** For example `sudo npx t3 browser setup`, matching how the server was launched. */
192 command: Schema.String,
193});
194export type PreviewStreamHostSetup = typeof PreviewStreamHostSetup.Type;
195export const PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE = 4503;
196
197export const PreviewSessionSnapshot = Schema.Struct({
198 threadId: TrimmedNonEmptyString,
199 tabId: PreviewTabId,
200 navStatus: PreviewNavStatus,
201 canGoBack: Schema.Boolean,
202 canGoForward: Schema.Boolean,
203 /** Missing snapshots from older servers are treated as fill-panel mode. */
204 viewport: Schema.optional(PreviewViewportSetting),
205 /** Server tabs only; the desktop keeps its own tabs' appearance. Absent means `system`. */
206 colorScheme: Schema.optional(PreviewAppearancePreference),
207 /** Server tabs only. Absent means 1. */
208 zoomFactor: Schema.optional(PreviewZoomFactor),
209 /**
210 * Browser profile the tab's Chromium partition is derived from. Fixed at
211 * open: Electron only honours a `<webview>`'s partition before attach, so
212 * switching would require tearing the guest down and losing page state.
213 */
214 profileId: Schema.optional(BrowserProfileId),
215 runtime: Schema.optional(PreviewRuntime),
216 /** Authenticated provider session owning an isolated server tab. */
217 automationOwner: Schema.optional(Schema.String),
218 /** An agent opened this tab and asked to show it, so viewers float it. */
219 reveal: Schema.optional(Schema.Boolean),
220 /** A fresh presentation request, including whether it overrides automatic-floating settings. */
221 revealRequest: Schema.optional(Schema.Struct({ id: Schema.String, force: Schema.Boolean })),
222 updatedAt: Schema.String,
223});
224export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type;
225
226export const PreviewOpenInput = Schema.Struct({
227 threadId: ThreadId,
228 /** Omit to create an empty (Idle) tab the user can type into. */
229 url: Schema.optional(Url),
230 /**
231 * Initial viewport for the new tab. Omitting it keeps the historical
232 * fill-panel behaviour; clients that have a configured default send it here
233 * so the session is born at the right size instead of being resized a frame
234 * later (which the user would see as a visible reflow).
235 */
236 viewport: Schema.optional(PreviewViewportSetting),
237 /** Omit to open under the client's configured default profile. */
238 profileId: Schema.optional(BrowserProfileId),
239 /** Omit for a desktop tab. `server` requires the `serverBrowser` capability. */
240 runtime: Schema.optional(PreviewRuntime),
241 /** Set by agent opens that should float for viewers; see the snapshot field. */
242 reveal: Schema.optional(Schema.Boolean),
243});
244export type PreviewOpenInput = typeof PreviewOpenInput.Type;
245
246export const PreviewNavigateInput = Schema.Struct({
247 threadId: ThreadId,
248 tabId: PreviewTabId,
249 url: Url,
250 resolvedTitle: Schema.optional(Title),
251});
252export type PreviewNavigateInput = typeof PreviewNavigateInput.Type;
253
254export const PreviewReportStatusInput = Schema.Struct({
255 threadId: ThreadId,
256 tabId: PreviewTabId,
257 navStatus: PreviewNavStatus,
258 canGoBack: Schema.Boolean,
259 canGoForward: Schema.Boolean,
260});
261export type PreviewReportStatusInput = typeof PreviewReportStatusInput.Type;
262
263export const PreviewRefreshInput = Schema.Struct({
264 threadId: ThreadId,
265 tabId: PreviewTabId,
266});
267export type PreviewRefreshInput = typeof PreviewRefreshInput.Type;
268
269export const PreviewResizeInput = Schema.Struct({
270 threadId: ThreadId,
271 tabId: PreviewTabId,
272 viewport: PreviewViewportSetting,
273});
274export type PreviewResizeInput = typeof PreviewResizeInput.Type;
275
276/**
277 * Changes how a server tab renders, or clears its profile's site data, for any
278 * client. Unlike page input, these need no control of the tab.
279 */
280export const PreviewAdjustInput = Schema.Struct({
281 threadId: ThreadId,
282 tabId: PreviewTabId,
283 colorScheme: Schema.optional(PreviewAppearancePreference),
284 zoomFactor: Schema.optional(PreviewZoomFactor),
285 /** Reloads the page past its cache, as Chrome's Shift+Reload does. */
286 hardReload: Schema.optional(Schema.Boolean),
287 /** Clears the tab's profile's cookies or HTTP cache; its other storage stays. */
288 clear: Schema.optional(Schema.Literals(["cookies", "cache"])),
289});
290export type PreviewAdjustInput = typeof PreviewAdjustInput.Type;
291
292export const PreviewCloseInput = Schema.Struct({
293 threadId: ThreadId,
294 tabId: Schema.optional(PreviewTabId),
295});
296export type PreviewCloseInput = typeof PreviewCloseInput.Type;
297
298/** A client's browser profiles, which agents choose from when they open a tab. */
299export const PreviewReportProfilesInput = Schema.Struct({
300 /** The user's own profiles; the server adds the built-ins. */
301 profiles: Schema.Array(BrowserProfile).check(Schema.isMaxLength(BROWSER_PROFILE_MAX_COUNT)),
302 defaultProfileId: BrowserProfileId,
303});
304export type PreviewReportProfilesInput = typeof PreviewReportProfilesInput.Type;
305
306export const PreviewClearProfileInput = Schema.Struct({
307 profileId: BrowserProfileId,
308});
309export type PreviewClearProfileInput = typeof PreviewClearProfileInput.Type;
310
311export const PreviewListInput = Schema.Struct({
312 threadId: ThreadId,
313});
314export type PreviewListInput = typeof PreviewListInput.Type;
315
316export const PreviewListResult = Schema.Struct({
317 sessions: Schema.Array(PreviewSessionSnapshot),
318 /** Identifies the current server process so revision resets are safe. */
319 serverEpoch: TrimmedNonEmptyString,
320 /** Monotonic server state revision used to reject stale list responses. */
321 revision: NonNegativeInt,
322});
323export type PreviewListResult = typeof PreviewListResult.Type;
324
325const PreviewEventBaseSchema = Schema.Struct({
326 threadId: TrimmedNonEmptyString,
327 tabId: PreviewTabId,
328 createdAt: Schema.String,
329 /** Identifies the server process that emitted this event. */
330 serverEpoch: TrimmedNonEmptyString,
331 /** Monotonic server state revision shared with PreviewListResult. */
332 revision: PositiveInt,
333});
334
335const PreviewOpenedEvent = Schema.Struct({
336 ...PreviewEventBaseSchema.fields,
337 type: Schema.Literal("opened"),
338 snapshot: PreviewSessionSnapshot,
339});
340
341const PreviewNavigatedEvent = Schema.Struct({
342 ...PreviewEventBaseSchema.fields,
343 type: Schema.Literal("navigated"),
344 snapshot: PreviewSessionSnapshot,
345});
346
347const PreviewResizedEvent = Schema.Struct({
348 ...PreviewEventBaseSchema.fields,
349 type: Schema.Literal("resized"),
350 snapshot: PreviewSessionSnapshot,
351 /** A one-off action for the server's browser, such as a hard reload. */
352 request: Schema.optional(
353 Schema.Struct({
354 hardReload: Schema.optional(Schema.Boolean),
355 clear: Schema.optional(Schema.Literals(["cookies", "cache"])),
356 }),
357 ),
358});
359
360const PreviewFailedEvent = Schema.Struct({
361 ...PreviewEventBaseSchema.fields,
362 type: Schema.Literal("failed"),
363 url: Url,
364 title: Title,
365 code: Schema.Int,
366 description: Schema.String,
367 download: Schema.optional(PreviewFileDownload),
368});
369
370const PreviewClosedEvent = Schema.Struct({
371 ...PreviewEventBaseSchema.fields,
372 type: Schema.Literal("closed"),
373});
374
375export const PreviewEvent = Schema.Union([
376 PreviewOpenedEvent,
377 PreviewNavigatedEvent,
378 PreviewResizedEvent,
379 PreviewFailedEvent,
380 PreviewClosedEvent,
381]);
382export type PreviewEvent = typeof PreviewEvent.Type;
383
384/**
385 * A localhost server detected by the port scanner. Used to populate the
386 * "Local" recommendations in the empty-state of the preview panel.
387 */
388export const DiscoveredLocalServer = Schema.Struct({
389 host: TrimmedNonEmptyString,
390 port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)),
391 url: Url,
392 processName: Schema.NullOr(TrimmedNonEmptyString),
393 pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))),
394 terminal: Schema.NullOr(
395 Schema.Struct({
396 threadId: ThreadId,
397 terminalId: TrimmedNonEmptyString,
398 }),
399 ),
400});
401export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;
402
403export const DiscoveredLocalServerList = Schema.Struct({
404 servers: Schema.Array(DiscoveredLocalServer),
405 scannedAt: Schema.String,
406 configuredUrlProbing: Schema.optional(Schema.Literal(true)),
407});
408export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type;
409
410export class PreviewSessionLookupError extends Schema.TaggedError<PreviewSessionLookupError>()(
411 "PreviewSessionLookupError",
412 {
413 threadId: Schema.String,
414 tabId: Schema.String,
415 },
416) {
417 override get message() {
418 return `Unknown preview session: thread=${this.threadId}, tab=${this.tabId}`;
419 }
420}
421
422export class PreviewInvalidUrlError extends Schema.TaggedError<PreviewInvalidUrlError>()(
423 "PreviewInvalidUrlError",
424 {
425 inputLength: Schema.Number,
426 reason: Schema.Literals(["empty", "parse", "unsupported-protocol", "unexpected"]),
427 protocol: Schema.optional(Schema.String),
428 cause: Schema.Defect(),
429 },
430) {
431 override get message() {
432 const protocol = this.protocol === undefined ? "" : `: ${this.protocol}`;
433 return `Invalid preview URL (${this.reason}${protocol}; input length ${this.inputLength}).`;
434 }
435}
436
437export class PreviewControlRequiredError extends Schema.TaggedError<PreviewControlRequiredError>()(
438 "PreviewControlRequiredError",
439 { tabId: Schema.String },
440) {
441 override get message() {
442 return "Take control of the server browser and use its viewer controls.";
443 }
444}
445
446export class PreviewClearProfileError extends Schema.TaggedError<PreviewClearProfileError>()(
447 "PreviewClearProfileError",
448 { profileId: Schema.String, cause: Schema.Defect() },
449) {
450 override get message() {
451 return "The environment could not delete this browser profile's data.";
452 }
453}
454
455export const PreviewError = Schema.Union([
456 PreviewSessionLookupError,
457 PreviewInvalidUrlError,
458 PreviewControlRequiredError,
459]);
460export type PreviewError = typeof PreviewError.Type;