1import type { Session, WebContents } from "electron";
2import { BrowserWindow, session } from "electron";
3import * as Context from "effect/Context";
4import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
7import * as Layer from "effect/Layer";
8import * as PlatformError from "effect/PlatformError";
9import * as Schema from "effect/Schema";
10import * as SynchronizedRef from "effect/SynchronizedRef";
11
12import { browserChromeIdentity } from "@t3tools/shared/browserChromeIdentity";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14
15import * as ElectronDialog from "../electron/ElectronDialog.ts";
16
17const 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 */
24const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-";
25const PROFILE_PARTITION_MARKER = "profile-";
26
27export 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`.
36const 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 */
56const 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 */
74const 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
84const MAX_PROMPT_URL_LENGTH = 300;
85
86export 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
98export 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
111export 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
123export 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
135export const BrowserSessionGetSessionError = Schema.Union([
136 BrowserSessionPartitionDerivationError,
137 BrowserSessionCreationError,
138]);
139export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type;
140
141export const BrowserSessionError = Schema.Union([
142 BrowserSessionPartitionDerivationError,
143 BrowserSessionCreationError,
144 BrowserSessionStorageClearError,
145 BrowserSessionCacheClearError,
146]);
147export type BrowserSessionError = typeof BrowserSessionError.Type;
148
149export 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 */
178const 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 */
196const 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 */
210const 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. */
218export 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
381export const layer = Layer.effect(BrowserSession, make);