| 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 | */ |
| 11 | import { Schema } from "effect"; |
| 12 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 13 | import { BROWSER_PROFILE_MAX_COUNT, BrowserProfile, BrowserProfileId } from "./browserProfile.ts"; |
| 14 | |
| 15 | export const PREVIEW_URL_MAX_LENGTH = 2_048; |
| 16 | export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; |
| 17 | |
| 18 | const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(PREVIEW_URL_MAX_LENGTH)); |
| 19 | |
| 20 | export const ConfiguredLocalServerUrls = Schema.Array(Url).check( |
| 21 | Schema.isMaxLength(CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS), |
| 22 | ); |
| 23 | const Title = Schema.String.check(Schema.isMaxLength(512)); |
| 24 | |
| 25 | export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); |
| 26 | export type PreviewTabId = typeof PreviewTabId.Type; |
| 27 | |
| 28 | export const PREVIEW_VIEWPORT_MIN_DIMENSION = 240; |
| 29 | export const PREVIEW_VIEWPORT_MAX_DIMENSION = 3840; |
| 30 | export const PREVIEW_VIEWPORT_MAX_AREA = 3840 * 2160; |
| 31 | |
| 32 | const PreviewViewportDimension = Schema.Int.check( |
| 33 | Schema.isBetween({ |
| 34 | minimum: PREVIEW_VIEWPORT_MIN_DIMENSION, |
| 35 | maximum: PREVIEW_VIEWPORT_MAX_DIMENSION, |
| 36 | }), |
| 37 | ); |
| 38 | |
| 39 | const 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 | |
| 45 | export const PreviewViewportSize = Schema.Struct({ |
| 46 | width: PreviewViewportDimension, |
| 47 | height: PreviewViewportDimension, |
| 48 | }).check(viewportAreaFilter); |
| 49 | export 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 | */ |
| 56 | export const PreviewRenderedViewportSize = Schema.Struct({ |
| 57 | width: Schema.Int.check(Schema.isGreaterThan(0)), |
| 58 | height: Schema.Int.check(Schema.isGreaterThan(0)), |
| 59 | }); |
| 60 | export type PreviewRenderedViewportSize = typeof PreviewRenderedViewportSize.Type; |
| 61 | |
| 62 | export 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 | |
| 82 | export const PreviewViewportPresetId = Schema.Literals(PREVIEW_VIEWPORT_PRESET_IDS); |
| 83 | export 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 | */ |
| 90 | const 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 | |
| 101 | const StoredPreviewViewportPresetId = Schema.Literals([ |
| 102 | ...PREVIEW_VIEWPORT_PRESET_IDS, |
| 103 | ...LEGACY_PREVIEW_VIEWPORT_PRESET_IDS, |
| 104 | ]); |
| 105 | |
| 106 | export 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 | ]); |
| 116 | export type PreviewViewportSetting = typeof PreviewViewportSetting.Type; |
| 117 | |
| 118 | export 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 | */ |
| 127 | export 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 | |
| 131 | export const PreviewZoomFactor = Schema.Literals(PREVIEW_ZOOM_LEVELS); |
| 132 | export type PreviewZoomFactor = typeof PreviewZoomFactor.Type; |
| 133 | |
| 134 | export 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 | */ |
| 141 | export const PreviewAppearancePreference = Schema.Literals(["system", "light", "dark"]); |
| 142 | export type PreviewAppearancePreference = typeof PreviewAppearancePreference.Type; |
| 143 | |
| 144 | export const DEFAULT_PREVIEW_APPEARANCE: PreviewAppearancePreference = "system"; |
| 145 | |
| 146 | /** A file a server tab downloaded, served by the tab's download route by this id. */ |
| 147 | const PreviewFileDownload = Schema.Struct({ id: Schema.String, fileName: Schema.String }); |
| 148 | |
| 149 | export 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 | ]); |
| 172 | export 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 | */ |
| 180 | export const PreviewRuntime = Schema.Literals(["desktop", "server"]); |
| 181 | export 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 | */ |
| 189 | export 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 | }); |
| 194 | export type PreviewStreamHostSetup = typeof PreviewStreamHostSetup.Type; |
| 195 | export const PREVIEW_STREAM_HOST_SETUP_CLOSE_CODE = 4503; |
| 196 | |
| 197 | export 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 | }); |
| 224 | export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; |
| 225 | |
| 226 | export 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 | }); |
| 244 | export type PreviewOpenInput = typeof PreviewOpenInput.Type; |
| 245 | |
| 246 | export const PreviewNavigateInput = Schema.Struct({ |
| 247 | threadId: ThreadId, |
| 248 | tabId: PreviewTabId, |
| 249 | url: Url, |
| 250 | resolvedTitle: Schema.optional(Title), |
| 251 | }); |
| 252 | export type PreviewNavigateInput = typeof PreviewNavigateInput.Type; |
| 253 | |
| 254 | export const PreviewReportStatusInput = Schema.Struct({ |
| 255 | threadId: ThreadId, |
| 256 | tabId: PreviewTabId, |
| 257 | navStatus: PreviewNavStatus, |
| 258 | canGoBack: Schema.Boolean, |
| 259 | canGoForward: Schema.Boolean, |
| 260 | }); |
| 261 | export type PreviewReportStatusInput = typeof PreviewReportStatusInput.Type; |
| 262 | |
| 263 | export const PreviewRefreshInput = Schema.Struct({ |
| 264 | threadId: ThreadId, |
| 265 | tabId: PreviewTabId, |
| 266 | }); |
| 267 | export type PreviewRefreshInput = typeof PreviewRefreshInput.Type; |
| 268 | |
| 269 | export const PreviewResizeInput = Schema.Struct({ |
| 270 | threadId: ThreadId, |
| 271 | tabId: PreviewTabId, |
| 272 | viewport: PreviewViewportSetting, |
| 273 | }); |
| 274 | export 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 | */ |
| 280 | export 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 | }); |
| 290 | export type PreviewAdjustInput = typeof PreviewAdjustInput.Type; |
| 291 | |
| 292 | export const PreviewCloseInput = Schema.Struct({ |
| 293 | threadId: ThreadId, |
| 294 | tabId: Schema.optional(PreviewTabId), |
| 295 | }); |
| 296 | export type PreviewCloseInput = typeof PreviewCloseInput.Type; |
| 297 | |
| 298 | /** A client's browser profiles, which agents choose from when they open a tab. */ |
| 299 | export 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 | }); |
| 304 | export type PreviewReportProfilesInput = typeof PreviewReportProfilesInput.Type; |
| 305 | |
| 306 | export const PreviewClearProfileInput = Schema.Struct({ |
| 307 | profileId: BrowserProfileId, |
| 308 | }); |
| 309 | export type PreviewClearProfileInput = typeof PreviewClearProfileInput.Type; |
| 310 | |
| 311 | export const PreviewListInput = Schema.Struct({ |
| 312 | threadId: ThreadId, |
| 313 | }); |
| 314 | export type PreviewListInput = typeof PreviewListInput.Type; |
| 315 | |
| 316 | export 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 | }); |
| 323 | export type PreviewListResult = typeof PreviewListResult.Type; |
| 324 | |
| 325 | const 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 | |
| 335 | const PreviewOpenedEvent = Schema.Struct({ |
| 336 | ...PreviewEventBaseSchema.fields, |
| 337 | type: Schema.Literal("opened"), |
| 338 | snapshot: PreviewSessionSnapshot, |
| 339 | }); |
| 340 | |
| 341 | const PreviewNavigatedEvent = Schema.Struct({ |
| 342 | ...PreviewEventBaseSchema.fields, |
| 343 | type: Schema.Literal("navigated"), |
| 344 | snapshot: PreviewSessionSnapshot, |
| 345 | }); |
| 346 | |
| 347 | const 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 | |
| 360 | const 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 | |
| 370 | const PreviewClosedEvent = Schema.Struct({ |
| 371 | ...PreviewEventBaseSchema.fields, |
| 372 | type: Schema.Literal("closed"), |
| 373 | }); |
| 374 | |
| 375 | export const PreviewEvent = Schema.Union([ |
| 376 | PreviewOpenedEvent, |
| 377 | PreviewNavigatedEvent, |
| 378 | PreviewResizedEvent, |
| 379 | PreviewFailedEvent, |
| 380 | PreviewClosedEvent, |
| 381 | ]); |
| 382 | export 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 | */ |
| 388 | export 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 | }); |
| 401 | export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; |
| 402 | |
| 403 | export const DiscoveredLocalServerList = Schema.Struct({ |
| 404 | servers: Schema.Array(DiscoveredLocalServer), |
| 405 | scannedAt: Schema.String, |
| 406 | configuredUrlProbing: Schema.optional(Schema.Literal(true)), |
| 407 | }); |
| 408 | export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; |
| 409 | |
| 410 | export 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 | |
| 422 | export 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 | |
| 437 | export 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 | |
| 446 | export 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 | |
| 455 | export const PreviewError = Schema.Union([ |
| 456 | PreviewSessionLookupError, |
| 457 | PreviewInvalidUrlError, |
| 458 | PreviewControlRequiredError, |
| 459 | ]); |
| 460 | export type PreviewError = typeof PreviewError.Type; |