| 1 | import type { Session, WebContents } from "electron"; |
| 2 | import { BrowserWindow, session } from "electron"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Crypto from "effect/Crypto"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Hex from "effect/encoding/Hex"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 11 | |
| 12 | import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity"; |
| 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 14 | |
| 15 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 16 | |
| 17 | const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; |
| 18 | /** |
| 19 | * Incognito partitions deliberately omit the `persist:` prefix, which is what |
| 20 | * makes Chromium keep them in memory and discard them with the process. They |
| 21 | * still carry the product prefix so `isPartition` can admit them — the |
| 22 | * `will-attach-webview` gate rejects anything it does not recognise. |
| 23 | */ |
| 24 | const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; |
| 25 | const PROFILE_PARTITION_MARKER = "profile-"; |
| 26 | |
| 27 | export type BrowserSessionPartitionNamespace = "profile"; |
| 28 | |
| 29 | // Permissions granted to preview web content. `clipboard-sanitized-write` is the |
| 30 | // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT |
| 31 | // `clipboard-write`, which is not a valid Electron permission name. Async |
| 32 | // clipboard writes are gated by the permission *check* handler (not only the |
| 33 | // request handler), so both handlers must allow it; otherwise built-in "Copy" |
| 34 | // buttons — e.g. the Next.js / Vercel error overlay — fail with |
| 35 | // `Failed to execute 'writeText' on 'Clipboard': Write permission denied`. |
| 36 | const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([ |
| 37 | "clipboard-read", |
| 38 | "clipboard-sanitized-write", |
| 39 | "notifications", |
| 40 | "geolocation", |
| 41 | // The Fullscreen API. `DesktopWindow` disables HTML fullscreen window resizing |
| 42 | // for the app window and every preview guest, so a page that goes fullscreen |
| 43 | // fills its own webview instead of taking over the whole T3 window. |
| 44 | "fullscreen", |
| 45 | // Deliberately NOT local-fonts: preview sessions run untrusted web content, |
| 46 | // and silently granting it would hand every page the user's installed-font |
| 47 | // fingerprint (and font file bytes via FontData.blob()). The app's own font |
| 48 | // picker runs in the main window session, which is unaffected by this list. |
| 49 | ]); |
| 50 | |
| 51 | /** |
| 52 | * Schemes a preview page may never hand to the OS, even with the user's |
| 53 | * consent: they read local files, run script, or are web pages that belong in |
| 54 | * the preview itself. |
| 55 | */ |
| 56 | const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set([ |
| 57 | "about:", |
| 58 | "blob:", |
| 59 | "chrome:", |
| 60 | "data:", |
| 61 | "devtools:", |
| 62 | "file:", |
| 63 | "filesystem:", |
| 64 | "http:", |
| 65 | "https:", |
| 66 | "javascript:", |
| 67 | "view-source:", |
| 68 | ]); |
| 69 | |
| 70 | /** |
| 71 | * The URL to offer the user when a preview page navigates to a custom scheme |
| 72 | * such as `slack://` or `zoom://`, or `null` when it must stay denied. |
| 73 | */ |
| 74 | const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => { |
| 75 | if (!rawUrl) return null; |
| 76 | try { |
| 77 | const url = new URL(rawUrl); |
| 78 | return NEVER_EXTERNAL_PROTOCOLS.has(url.protocol) ? null : url.href; |
| 79 | } catch { |
| 80 | return null; |
| 81 | } |
| 82 | }; |
| 83 | |
| 84 | const MAX_PROMPT_URL_LENGTH = 300; |
| 85 | |
| 86 | export class BrowserSessionPartitionDerivationError extends Schema.TaggedError<BrowserSessionPartitionDerivationError>()( |
| 87 | "BrowserSessionPartitionDerivationError", |
| 88 | { |
| 89 | scope: Schema.String, |
| 90 | cause: Schema.instanceOf(PlatformError.PlatformError), |
| 91 | }, |
| 92 | ) { |
| 93 | override get message(): string { |
| 94 | return `Failed to derive a desktop preview browser partition for scope ${this.scope}.`; |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | export class BrowserSessionCreationError extends Schema.TaggedError<BrowserSessionCreationError>()( |
| 99 | "BrowserSessionCreationError", |
| 100 | { |
| 101 | scope: Schema.String, |
| 102 | partition: Schema.String, |
| 103 | cause: Schema.Defect(), |
| 104 | }, |
| 105 | ) { |
| 106 | override get message(): string { |
| 107 | return `Failed to create a desktop preview browser session for scope ${this.scope} (partition ${this.partition}).`; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | export class BrowserSessionStorageClearError extends Schema.TaggedError<BrowserSessionStorageClearError>()( |
| 112 | "BrowserSessionStorageClearError", |
| 113 | { |
| 114 | partition: Schema.String, |
| 115 | cause: Schema.Defect(), |
| 116 | }, |
| 117 | ) { |
| 118 | override get message(): string { |
| 119 | return `Failed to clear desktop preview browser storage for partition ${this.partition}.`; |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | export class BrowserSessionCacheClearError extends Schema.TaggedError<BrowserSessionCacheClearError>()( |
| 124 | "BrowserSessionCacheClearError", |
| 125 | { |
| 126 | partition: Schema.String, |
| 127 | cause: Schema.Defect(), |
| 128 | }, |
| 129 | ) { |
| 130 | override get message(): string { |
| 131 | return `Failed to clear the desktop preview browser cache for partition ${this.partition}.`; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | export const BrowserSessionGetSessionError = Schema.Union([ |
| 136 | BrowserSessionPartitionDerivationError, |
| 137 | BrowserSessionCreationError, |
| 138 | ]); |
| 139 | export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; |
| 140 | |
| 141 | export const BrowserSessionError = Schema.Union([ |
| 142 | BrowserSessionPartitionDerivationError, |
| 143 | BrowserSessionCreationError, |
| 144 | BrowserSessionStorageClearError, |
| 145 | BrowserSessionCacheClearError, |
| 146 | ]); |
| 147 | export type BrowserSessionError = typeof BrowserSessionError.Type; |
| 148 | |
| 149 | export class BrowserSession extends Context.Service< |
| 150 | BrowserSession, |
| 151 | { |
| 152 | readonly getPartition: ( |
| 153 | scope?: string, |
| 154 | persistent?: boolean, |
| 155 | namespace?: BrowserSessionPartitionNamespace, |
| 156 | ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>; |
| 157 | readonly isPartition: (partition: string) => boolean; |
| 158 | readonly getSession: ( |
| 159 | scope?: string, |
| 160 | persistent?: boolean, |
| 161 | namespace?: BrowserSessionPartitionNamespace, |
| 162 | ) => Effect.Effect<Session, BrowserSessionGetSessionError>; |
| 163 | /** Omit `partitions` to clear every known partition. */ |
| 164 | readonly clearCookies: ( |
| 165 | partitions?: ReadonlyArray<string>, |
| 166 | ) => Effect.Effect<void, BrowserSessionStorageClearError>; |
| 167 | readonly clearCache: ( |
| 168 | partitions?: ReadonlyArray<string>, |
| 169 | ) => Effect.Effect<void, BrowserSessionCacheClearError>; |
| 170 | } |
| 171 | >()("@t3tools/desktop/preview/BrowserSession") {} |
| 172 | |
| 173 | /** |
| 174 | * Restricts a clear to the given partitions. Omitting them keeps the historical |
| 175 | * "every partition" behaviour, which callers now only use for an explicit |
| 176 | * "all profiles" action — a per-profile clear must never reach across profiles. |
| 177 | */ |
| 178 | const selectSessions = ( |
| 179 | sessions: ReadonlyMap<string, Session>, |
| 180 | partitions: ReadonlyArray<string> | undefined, |
| 181 | ): ReadonlyArray<readonly [string, Session]> => |
| 182 | [...sessions.entries()].filter( |
| 183 | ([partition]) => partitions === undefined || partitions.includes(partition), |
| 184 | ); |
| 185 | |
| 186 | /** |
| 187 | * Scope bytes for the partition digest. |
| 188 | * |
| 189 | * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` |
| 190 | * and `"p\ufffd"` would hash to the same partition and share cookies. Those |
| 191 | * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` |
| 192 | * first — and a literal backslash is doubled so the escape cannot be forged. |
| 193 | * Every well-formed scope passes through byte-for-byte unchanged, which keeps |
| 194 | * existing partitions (and the logins in them) where they are. |
| 195 | */ |
| 196 | const encodeScopeForDigest = (scope: string): Uint8Array => |
| 197 | new TextEncoder().encode( |
| 198 | scope |
| 199 | .replace(/\\/g, "\\\\") |
| 200 | .replace( |
| 201 | /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, |
| 202 | (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`, |
| 203 | ), |
| 204 | ); |
| 205 | |
| 206 | /** |
| 207 | * The window showing a preview guest. A `<webview>` guest has no window of its |
| 208 | * own, so look it up through the page that embeds it. |
| 209 | */ |
| 210 | const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => { |
| 211 | if (guest === null || guest.isDestroyed()) return undefined; |
| 212 | const host = guest.hostWebContents ?? guest; |
| 213 | if (host.isDestroyed()) return undefined; |
| 214 | return BrowserWindow.fromWebContents(host) ?? undefined; |
| 215 | }; |
| 216 | |
| 217 | /** @public Service construction is part of the canonical Effect module API. */ |
| 218 | export const make = Effect.gen(function* BrowserSessionMake() { |
| 219 | const crypto = yield* Crypto.Crypto; |
| 220 | const platform = yield* HostProcess.Platform; |
| 221 | const arch = yield* HostProcess.Architecture; |
| 222 | const electronDialog = yield* ElectronDialog.ElectronDialog; |
| 223 | const runFork = Effect.runForkWith(yield* Effect.context<never>()); |
| 224 | // One prompt at a time, so a page cannot stack dialogs by looping a deep link. |
| 225 | let externalPromptOpen = false; |
| 226 | /** |
| 227 | * Electron grants an external-protocol navigation through the `openExternal` |
| 228 | * permission and then launches the OS handler itself. Ask first: previews run |
| 229 | * untrusted pages, and a silent grant would let any of them launch apps. |
| 230 | */ |
| 231 | const confirmOpenExternal = ( |
| 232 | requester: WebContents | null, |
| 233 | rawUrl: string | undefined, |
| 234 | callback: (granted: boolean) => void, |
| 235 | ): void => { |
| 236 | const url = externalProtocolPromptUrl(rawUrl); |
| 237 | if (url === null || externalPromptOpen) { |
| 238 | callback(false); |
| 239 | return; |
| 240 | } |
| 241 | externalPromptOpen = true; |
| 242 | const settle = (granted: boolean) => |
| 243 | Effect.sync(() => { |
| 244 | externalPromptOpen = false; |
| 245 | callback(granted); |
| 246 | }); |
| 247 | const shownUrl = |
| 248 | url.length > MAX_PROMPT_URL_LENGTH ? `${url.slice(0, MAX_PROMPT_URL_LENGTH)}…` : url; |
| 249 | runFork( |
| 250 | electronDialog |
| 251 | .showMessageBox( |
| 252 | { |
| 253 | type: "question", |
| 254 | buttons: ["Open", "Cancel"], |
| 255 | defaultId: 0, |
| 256 | cancelId: 1, |
| 257 | message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`, |
| 258 | detail: `A page in the browser wants to open another application:\n\n${shownUrl}`, |
| 259 | }, |
| 260 | previewHostWindow(requester), |
| 261 | ) |
| 262 | .pipe( |
| 263 | Effect.map(({ response }) => response === 0), |
| 264 | Effect.orElseSucceed(() => false), |
| 265 | Effect.flatMap(settle), |
| 266 | Effect.onInterrupt(() => settle(false)), |
| 267 | ), |
| 268 | ); |
| 269 | }; |
| 270 | const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map()); |
| 271 | |
| 272 | const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( |
| 273 | scope = "shared", |
| 274 | persistent = true, |
| 275 | namespace?: BrowserSessionPartitionNamespace, |
| 276 | ) { |
| 277 | const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe( |
| 278 | Effect.mapError( |
| 279 | (cause) => |
| 280 | new BrowserSessionPartitionDerivationError({ |
| 281 | scope, |
| 282 | cause, |
| 283 | }), |
| 284 | ), |
| 285 | ); |
| 286 | const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX; |
| 287 | // Legacy/default partitions are prefix + hex digest. The non-hex profile |
| 288 | // marker creates a disjoint namespace while leaving every legacy default |
| 289 | // partition byte-for-byte unchanged. |
| 290 | return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Hex.encode(digest).slice(0, 20)}`; |
| 291 | }); |
| 292 | |
| 293 | const getSession = Effect.fn("BrowserSession.getSession")(function* ( |
| 294 | scope = "shared", |
| 295 | persistent = true, |
| 296 | namespace?: BrowserSessionPartitionNamespace, |
| 297 | ) { |
| 298 | const partition = yield* getPartition(scope, persistent, namespace); |
| 299 | return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { |
| 300 | const existing = sessions.get(partition); |
| 301 | if (existing) return Effect.succeed([existing, sessions] as const); |
| 302 | return Effect.try({ |
| 303 | try: () => { |
| 304 | const browserSession = session.fromPartition(partition); |
| 305 | const identity = browserChromeIdentity(browserSession.getUserAgent(), { platform, arch }); |
| 306 | if (identity) browserSession.setUserAgent(identity.userAgent); |
| 307 | browserSession.setPermissionRequestHandler( |
| 308 | (webContents, permission, callback, details) => { |
| 309 | if (permission === "openExternal") { |
| 310 | confirmOpenExternal( |
| 311 | webContents, |
| 312 | "externalURL" in details ? details.externalURL : undefined, |
| 313 | callback, |
| 314 | ); |
| 315 | return; |
| 316 | } |
| 317 | callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); |
| 318 | }, |
| 319 | ); |
| 320 | browserSession.setPermissionCheckHandler((_webContents, permission) => |
| 321 | ALLOWED_PREVIEW_PERMISSIONS.has(permission), |
| 322 | ); |
| 323 | const next = new Map(sessions); |
| 324 | next.set(partition, browserSession); |
| 325 | return [browserSession, next] as const; |
| 326 | }, |
| 327 | catch: (cause) => |
| 328 | new BrowserSessionCreationError({ |
| 329 | scope, |
| 330 | partition, |
| 331 | cause, |
| 332 | }), |
| 333 | }); |
| 334 | }); |
| 335 | }); |
| 336 | |
| 337 | return BrowserSession.of({ |
| 338 | getPartition, |
| 339 | isPartition: (partition) => |
| 340 | partition.startsWith(PREVIEW_PARTITION_PREFIX) || |
| 341 | partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX), |
| 342 | getSession, |
| 343 | clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) { |
| 344 | const sessions = yield* SynchronizedRef.get(sessionsRef); |
| 345 | yield* Effect.forEach( |
| 346 | selectSessions(sessions, partitions), |
| 347 | ([partition, browserSession]) => |
| 348 | Effect.tryPromise({ |
| 349 | try: () => |
| 350 | browserSession.clearStorageData({ |
| 351 | storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], |
| 352 | }), |
| 353 | catch: (cause) => |
| 354 | new BrowserSessionStorageClearError({ |
| 355 | partition, |
| 356 | cause, |
| 357 | }), |
| 358 | }), |
| 359 | { concurrency: "unbounded", discard: true }, |
| 360 | ); |
| 361 | }), |
| 362 | clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { |
| 363 | const sessions = yield* SynchronizedRef.get(sessionsRef); |
| 364 | yield* Effect.forEach( |
| 365 | selectSessions(sessions, partitions), |
| 366 | ([partition, browserSession]) => |
| 367 | Effect.tryPromise({ |
| 368 | try: () => browserSession.clearCache(), |
| 369 | catch: (cause) => |
| 370 | new BrowserSessionCacheClearError({ |
| 371 | partition, |
| 372 | cause, |
| 373 | }), |
| 374 | }), |
| 375 | { concurrency: "unbounded", discard: true }, |
| 376 | ); |
| 377 | }), |
| 378 | }); |
| 379 | }).pipe(Effect.withSpan("BrowserSession.make")); |
| 380 | |
| 381 | export const layer = Layer.effect(BrowserSession, make); |