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-"; // 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", ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( "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.TaggedErrorClass()( "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.TaggedErrorClass()( "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.TaggedErrorClass()( "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 isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, BrowserSessionCreationError, BrowserSessionStorageClearError, BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; export const isBrowserSessionError = Schema.is(BrowserSessionError); export class BrowserSession extends Context.Service< BrowserSession, { readonly getPartition: ( scope?: string, ) => Effect.Effect; readonly isPartition: (partition: string) => boolean; readonly getSession: (scope?: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserSession") {} 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") { const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( Effect.mapError( (cause) => new BrowserSessionPartitionDerivationError({ scope, cause, }), ), ); return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; }); const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") { const partition = yield* getPartition(scope); 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), getSession, clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( [...sessions.entries()].map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], }), catch: (cause) => new BrowserSessionStorageClearError({ partition, cause, }), }), ), { concurrency: "unbounded", discard: true }, ); }), clearCache: Effect.fn("BrowserSession.clearCache")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( [...sessions.entries()].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);