import type { Session } from "electron"; import { session } from "electron"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; /** * Incognito partitions deliberately omit the `persist:` prefix, which is what * makes Chromium keep them in memory and discard them with the process. They * still carry the product prefix so `isPartition` can admit them — the * `will-attach-webview` gate rejects anything it does not recognise. */ const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-"; const PROFILE_PARTITION_MARKER = "profile-"; export type BrowserSessionPartitionNamespace = "profile"; // Permissions granted to preview web content. `clipboard-sanitized-write` is the // Electron permission behind `navigator.clipboard.writeText()` — note it is NOT // `clipboard-write`, which is not a valid Electron permission name. Async // clipboard writes are gated by the permission *check* handler (not only the // request handler), so both handlers must allow it; otherwise built-in "Copy" // buttons — e.g. the Next.js / Vercel error overlay — fail with // `Failed to execute 'writeText' on 'Clipboard': Write permission denied`. const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-read", "clipboard-sanitized-write", "notifications", "geolocation", // Deliberately NOT local-fonts: preview sessions run untrusted web content, // and silently granting it would hand every page the user's installed-font // fingerprint (and font file bytes via FontData.blob()). The app's own font // picker runs in the main window session, which is unaffected by this list. ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedError()( "BrowserSessionPartitionDerivationError", { scope: Schema.String, cause: Schema.instanceOf(PlatformError.PlatformError), }, ) { override get message(): string { return `Failed to derive a desktop preview browser partition for scope ${this.scope}.`; } } export class BrowserSessionCreationError extends Schema.TaggedError()( "BrowserSessionCreationError", { scope: Schema.String, partition: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to create a desktop preview browser session for scope ${this.scope} (partition ${this.partition}).`; } } export class BrowserSessionStorageClearError extends Schema.TaggedError()( "BrowserSessionStorageClearError", { partition: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to clear desktop preview browser storage for partition ${this.partition}.`; } } export class BrowserSessionCacheClearError extends Schema.TaggedError()( "BrowserSessionCacheClearError", { partition: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to clear the desktop preview browser cache for partition ${this.partition}.`; } } export const BrowserSessionGetSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, BrowserSessionCreationError, ]); export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, BrowserSessionCreationError, BrowserSessionStorageClearError, BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; export class BrowserSession extends Context.Service< BrowserSession, { readonly getPartition: ( scope?: string, persistent?: boolean, namespace?: BrowserSessionPartitionNamespace, ) => Effect.Effect; readonly isPartition: (partition: string) => boolean; readonly getSession: ( scope?: string, persistent?: boolean, namespace?: BrowserSessionPartitionNamespace, ) => Effect.Effect; /** Omit `partitions` to clear every known partition. */ readonly clearCookies: ( partitions?: ReadonlyArray, ) => Effect.Effect; readonly clearCache: ( partitions?: ReadonlyArray, ) => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserSession") {} /** * Restricts a clear to the given partitions. Omitting them keeps the historical * "every partition" behaviour, which callers now only use for an explicit * "all profiles" action — a per-profile clear must never reach across profiles. */ const selectSessions = ( sessions: ReadonlyMap, partitions: ReadonlyArray | undefined, ): ReadonlyArray => [...sessions.entries()].filter( ([partition]) => partitions === undefined || partitions.includes(partition), ); /** * Scope bytes for the partition digest. * * `TextEncoder` replaces a lone UTF-16 surrogate with U+FFFD, so `"p\ud800"` * and `"p\ufffd"` would hash to the same partition and share cookies. Those * are distinct, supported ids, so lone surrogates are escaped to `\uXXXX` * first — and a literal backslash is doubled so the escape cannot be forged. * Every well-formed scope passes through byte-for-byte unchanged, which keeps * existing partitions (and the logins in them) where they are. */ const encodeScopeForDigest = (scope: string): Uint8Array => new TextEncoder().encode( scope .replace(/\\/g, "\\\\") .replace( /[\ud800-\udbff](?![\udc00-\udfff])|(? `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`, ), ); /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* BrowserSessionMake() { const crypto = yield* Crypto.Crypto; const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* ( scope = "shared", persistent = true, namespace?: BrowserSessionPartitionNamespace, ) { const digest = yield* crypto.digest("SHA-256", encodeScopeForDigest(scope)).pipe( Effect.mapError( (cause) => new BrowserSessionPartitionDerivationError({ scope, cause, }), ), ); const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX; // Legacy/default partitions are prefix + hex digest. The non-hex profile // marker creates a disjoint namespace while leaving every legacy default // partition byte-for-byte unchanged. return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Encoding.encodeHex(digest).slice(0, 20)}`; }); const getSession = Effect.fn("BrowserSession.getSession")(function* ( scope = "shared", persistent = true, namespace?: BrowserSessionPartitionNamespace, ) { const partition = yield* getPartition(scope, persistent, namespace); return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => { const existing = sessions.get(partition); if (existing) return Effect.succeed([existing, sessions] as const); return Effect.try({ try: () => { const browserSession = session.fromPartition(partition); const userAgent = browserSession .getUserAgent() .replace(/Electron\/[\d.]+ /, "") .replace(/\s*t3code\/[\d.]+/, ""); browserSession.setUserAgent(userAgent); browserSession.setPermissionRequestHandler((_webContents, permission, callback) => { callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission)); }); browserSession.setPermissionCheckHandler((_webContents, permission) => ALLOWED_PREVIEW_PERMISSIONS.has(permission), ); const next = new Map(sessions); next.set(partition, browserSession); return [browserSession, next] as const; }, catch: (cause) => new BrowserSessionCreationError({ scope, partition, cause, }), }); }); }); return BrowserSession.of({ getPartition, isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX) || partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX), getSession, clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( selectSessions(sessions, partitions).map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ storages: ["cookies", "localstorage", "indexdb", "serviceworkers"], }), catch: (cause) => new BrowserSessionStorageClearError({ partition, cause, }), }), ), { concurrency: "unbounded", discard: true }, ); }), clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( selectSessions(sessions, partitions).map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), catch: (cause) => new BrowserSessionCacheClearError({ partition, cause, }), }), ), { concurrency: "unbounded", discard: true }, ); }), }); }).pipe(Effect.withSpan("BrowserSession.make")); export const layer = Layer.effect(BrowserSession, make);