Skip to content
Source
Sign inSign up
Open History to see the last commit

BrowserSession.ts

744 lines27.8 KB

1import * as HostProcess from "@t3tools/shared/HostProcess";
2import type { Certificate, Session, WebContents } from "electron";
3import { BrowserWindow, session } from "electron";
4import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";
6import * as Digest from "effect/crypto/Digest";
7import * as Effect from "effect/Effect";
8import * as Hex from "effect/encoding/Hex";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";
13import * as Result from "effect/Result";
14import * as Schema from "effect/Schema";
15import * as Semaphore from "effect/Semaphore";
16import * as SynchronizedRef from "effect/SynchronizedRef";
17​
18import * as ElectronApp from "../electron/ElectronApp.ts";
19import * as ElectronDialog from "../electron/ElectronDialog.ts";
20​
21const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
22/** Ephemeral partitions omit persist: but retain a product prefix so the webview gate recognizes them. */
23const PREVIEW_EPHEMERAL_PARTITION_PREFIX = "t3code-preview-ephemeral-";
24const PROFILE_PARTITION_MARKER = "profile-";
25​
26export type BrowserSessionPartitionNamespace = "profile";
27​
28// Electron's clipboard API uses clipboard-sanitized-write and checks both permission handlers.
29const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
30 "clipboard-sanitized-write",
31 "geolocation",
32 // DesktopWindow confines HTML fullscreen to the guest's webview.
33 "fullscreen",
34 "pointerLock",
35 // Loopback requests already succeed in Electron 44; permission-aware sign-in widgets query first.
36 "loopback-network",
37 // Deny local-fonts to untrusted previews: it exposes installed-font fingerprints and file bytes.
38 // Deny notifications: pages could impersonate T3 Code; app notifications use the default session.
39]);
40​
41const NEVER_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set([
42 "about:",
43 "blob:",
44 "chrome:",
45 "data:",
46 "devtools:",
47 "file:",
48 "filesystem:",
49 "http:",
50 "https:",
51 "javascript:",
52 "view-source:",
53]);
54​
55const externalProtocolPromptUrl = (rawUrl: string | undefined): string | null => {
56 if (!rawUrl) return null;
57 try {
58 const url = new URL(rawUrl);
59 return NEVER_EXTERNAL_PROTOCOLS.has(url.protocol) ? null : url.href;
60 } catch {
61 return null;
62 }
63};
64​
65// Teams sends Electron User-Agents to an error page before sign-in. Electron allows one listener per session.
66const TEAMS_URL_PATTERNS = ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"];
67​
68const MAX_PROMPT_URL_LENGTH = 300;
69​
70const truncateForPrompt = (text: string): string =>
71 text.length > MAX_PROMPT_URL_LENGTH ? `${text.slice(0, MAX_PROMPT_URL_LENGTH)}…` : text;
72​
73const permissionOrigin = (rawUrl: string | undefined): string | null => {
74 if (!rawUrl) return null;
75 try {
76 const { origin } = new URL(rawUrl);
77 return origin === "null" ? null : origin;
78 } catch {
79 return null;
80 }
81};
82​
83/**
84 * Certificate errors a user may accept for a preview page, with the reason the
85 * prompt shows. Every other error keeps Electron's denial.
86 */
87const OVERRIDABLE_CERTIFICATE_ERRORS: ReadonlyMap<string, string> = new Map([
88 ["net::ERR_CERT_AUTHORITY_INVALID", "No trusted authority issued this certificate."],
89 ["net::ERR_CERT_COMMON_NAME_INVALID", "This certificate is not for this address."],
90 ["net::ERR_CERT_DATE_INVALID", "This certificate has expired or is not valid yet."],
91]);
92​
93/**
94 * A `wss:` socket reaches the same TLS endpoint as the page, so it shares the
95 * `https:` origin. It must: a socket is never a main frame, so it could not be
96 * approved on its own, and a page's dev-server socket would stay blocked.
97 */
98const certificateOrigin = (rawUrl: string): string | null => {
99 try {
100 const url = new URL(rawUrl);
101 return url.protocol === "https:" || url.protocol === "wss:" ? `https://${url.host}` : null;
102 } catch {
103 return null;
104 }
105};
106​
107const CertificateApproval = Schema.Struct({
108 origin: Schema.String,
109 fingerprint: Schema.String,
110 error: Schema.String,
111});
112type CertificateApproval = typeof CertificateApproval.Type;
113const CertificateApprovalsJson = Schema.fromJsonString(Schema.Array(CertificateApproval));
114const decodeCertificateApprovals = Schema.decodeEffect(CertificateApprovalsJson);
115const encodeCertificateApprovals = Schema.encodeEffect(CertificateApprovalsJson);
116​
117const certificateApprovalKey = ({ origin, fingerprint, error }: CertificateApproval): string =>
118 JSON.stringify([origin, fingerprint, error]);
119​
120/** Kept beside the profile's cookies, so it lives and dies with them. */
121const CERTIFICATE_APPROVALS_FILE = "t3code-certificate-approvals.json";
122​
123/** Server-chosen certificate names must not add lines or flood the prompt. */
124const promptCertificateName = (name: string): string => {
125 const flat = name.replace(/\s+/g, " ").trim();
126 return flat.length > 120 ? `${flat.slice(0, 120)}…` : flat;
127};
128​
129const promptCertificateDate = (seconds: number): string =>
130 DateTime.formatIsoDateUtc(DateTime.makeUnsafe(seconds * 1000));
131​
132/** The certificates a user accepted in one preview Session. */
133interface CertificateTrust {
134 /** `null` for an incognito Session, whose approvals end with the process. */
135 readonly file: string | null;
136 readonly approvals: Map<string, CertificateApproval>;
137 /** Bumped by each revocation, so an answer to an older prompt cannot restore trust. */
138 revocations: number;
140​
141export class BrowserSessionPartitionDerivationError extends Schema.TaggedError<BrowserSessionPartitionDerivationError>()(
142 "BrowserSessionPartitionDerivationError",
143 {
144 scope: Schema.String,
145 cause: Schema.instanceOf(PlatformError.PlatformError),
146 },
147) {
148 override get message(): string {
149 return `Failed to derive a desktop preview browser partition for scope ${this.scope}.`;
150 }
152​
153export class BrowserSessionCreationError extends Schema.TaggedError<BrowserSessionCreationError>()(
154 "BrowserSessionCreationError",
155 {
156 scope: Schema.String,
157 partition: Schema.String,
158 cause: Schema.Defect(),
159 },
160) {
161 override get message(): string {
162 return `Failed to create a desktop preview browser session for scope ${this.scope} (partition ${this.partition}).`;
163 }
165​
166export class BrowserSessionStorageClearError extends Schema.TaggedError<BrowserSessionStorageClearError>()(
167 "BrowserSessionStorageClearError",
168 {
169 partition: Schema.String,
170 cause: Schema.Defect(),
171 },
172) {
173 override get message(): string {
174 return `Failed to clear desktop preview browser storage for partition ${this.partition}.`;
175 }
177​
178export class BrowserSessionCacheClearError extends Schema.TaggedError<BrowserSessionCacheClearError>()(
179 "BrowserSessionCacheClearError",
180 {
181 partition: Schema.String,
182 cause: Schema.Defect(),
183 },
184) {
185 override get message(): string {
186 return `Failed to clear the desktop preview browser cache for partition ${this.partition}.`;
187 }
189​
190export const BrowserSessionGetSessionError = Schema.Union([
191 BrowserSessionPartitionDerivationError,
192 BrowserSessionCreationError,
193]);
194export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type;
195​
196export const BrowserSessionError = Schema.Union([
197 BrowserSessionPartitionDerivationError,
198 BrowserSessionCreationError,
199 BrowserSessionStorageClearError,
200 BrowserSessionCacheClearError,
201]);
202export type BrowserSessionError = typeof BrowserSessionError.Type;
203​
204export class BrowserSession extends Context.Service<
205 BrowserSession,
206 {
207 readonly getPartition: (
208 scope?: string,
209 persistent?: boolean,
210 namespace?: BrowserSessionPartitionNamespace,
211 ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;
212 readonly isPartition: (partition: string) => boolean;
213 readonly getSession: (
214 scope?: string,
215 persistent?: boolean,
216 namespace?: BrowserSessionPartitionNamespace,
217 ) => Effect.Effect<Session, BrowserSessionGetSessionError>;
218 /** Omit `partitions` to clear every known partition. */
219 readonly clearCookies: (
220 partitions?: ReadonlyArray<string>,
221 ) => Effect.Effect<void, BrowserSessionStorageClearError>;
222 /** For cookies cleared outside `clearCookies`, such as over a tab's CDP session. */
223 readonly cookiesCleared: (
224 browserSession: Session,
225 ) => Effect.Effect<void, BrowserSessionStorageClearError>;
226 readonly clearCache: (
227 partitions?: ReadonlyArray<string>,
228 ) => Effect.Effect<void, BrowserSessionCacheClearError>;
229 }
230>()("@t3tools/desktop/preview/BrowserSession") {}
231​
232const selectSessions = (
233 sessions: ReadonlyMap<string, Session>,
234 partitions: ReadonlyArray<string> | undefined,
235): ReadonlyArray<readonly [string, Session]> =>
236 [...sessions.entries()].filter(
237 ([partition]) => partitions === undefined || partitions.includes(partition),
238 );
239​
240/** Escape lone surrogates before UTF-8 hashing, which otherwise merges them with U+FFFD; escape backslashes too. */
241const encodeScopeForDigest = (scope: string): Uint8Array =>
242 new TextEncoder().encode(
243 scope
244 .replace(/\\/g, "\\\\")
245 .replace(
246 /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g,
247 (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`,
248 ),
249 );
250​
251// Webview guests have no window; use their embedding page.
252const previewHostWindow = (guest: WebContents | null): BrowserWindow | undefined => {
253 if (guest === null || guest.isDestroyed()) return undefined;
254 const host = guest.hostWebContents ?? guest;
255 if (host.isDestroyed()) return undefined;
256 return BrowserWindow.fromWebContents(host) ?? undefined;
257};
258​
259/** @public */
260export const make = Effect.gen(function* BrowserSessionMake() {
261 const crypto = yield* Digest.Digest;
262 const electronApp = yield* ElectronApp.ElectronApp;
263 const electronDialog = yield* ElectronDialog.ElectronDialog;
264 const fileSystem = yield* FileSystem.FileSystem;
265 const path = yield* Path.Path;
266 const platform = yield* HostProcess.Platform;
267 const runFork = Effect.runForkWith(yield* Effect.context<never>());
268 // One prompt at a time, so a page cannot stack dialogs by looping a deep link.
269 let permissionPromptOpen = false;
270 /** Settles with `undefined` when the user gave no answer: another prompt was open or the dialog failed. */
271 const promptPermission = (
272 requester: WebContents | null,
273 prompt: {
274 readonly buttons: readonly [allow: string, deny: string];
275 readonly message: string;
276 readonly detail?: string;
277 },
278 callback: (answer: boolean | undefined) => void,
279 ): void => {
280 if (permissionPromptOpen) {
281 callback(undefined);
282 return;
283 }
284 permissionPromptOpen = true;
285 const settle = (answer: boolean | undefined) =>
286 Effect.sync(() => {
287 permissionPromptOpen = false;
288 callback(answer);
289 });
290 runFork(
291 electronDialog
292 .showMessageBox(
293 {
294 type: "question",
295 buttons: [...prompt.buttons],
296 // Default to deny so an Enter typed into the page cannot accept a prompt it raised.
297 defaultId: 1,
298 cancelId: 1,
299 message: prompt.message,
300 ...(prompt.detail === undefined ? {} : { detail: prompt.detail }),
301 },
302 previewHostWindow(requester),
303 )
304 .pipe(
305 Effect.map(({ response }): boolean | undefined => response === 0),
306 Effect.orElseSucceed(() => undefined),
307 Effect.flatMap(settle),
308 Effect.onInterrupt(() => settle(undefined)),
309 ),
310 );
311 };
312 /** Electron launches external protocols after granting openExternal; untrusted pages require confirmation. */
313 const confirmOpenExternal = (
314 requester: WebContents | null,
315 rawUrl: string | undefined,
316 callback: (granted: boolean) => void,
317 ): void => {
318 const url = externalProtocolPromptUrl(rawUrl);
319 if (url === null) {
320 callback(false);
321 return;
322 }
323 promptPermission(
324 requester,
325 {
326 buttons: ["Open", "Cancel"],
327 message: `Open this ${new URL(url).protocol.slice(0, -1)} link?`,
328 detail: `A page in the browser wants to open another application:\n\n${truncateForPrompt(url)}`,
329 },
330 (answer) => callback(answer === true),
331 );
332 };
333​
334 const certificateTrust = new WeakMap<Session, CertificateTrust>();
335 const writeCertificateApprovals = yield* Semaphore.make(1);
336 const loadCertificateTrust = Effect.fn("BrowserSession.loadCertificateTrust")(function* (
337 browserSession: Session,
338 ) {
339 const file =
340 browserSession.storagePath == null
341 ? null
342 : path.join(browserSession.storagePath, CERTIFICATE_APPROVALS_FILE);
343 const approvals: ReadonlyArray<CertificateApproval> =
344 file === null
345 ? []
346 : yield* fileSystem.readFileString(file).pipe(
347 Effect.flatMap(decodeCertificateApprovals),
348 Effect.catchTags({
349 PlatformError: (error) =>
350 error.reason._tag === "NotFound" ? Effect.succeed([]) : Effect.fail(error),
351 }),
352 Effect.catch((cause) =>
353 Effect.logWarning("Ignored saved certificate approvals.", { file, cause }).pipe(
354 Effect.as([]),
355 ),
356 ),
357 );
358 const trust: CertificateTrust = {
359 file,
360 approvals: new Map(approvals.map((approval) => [certificateApprovalKey(approval), approval])),
361 revocations: 0,
362 };
363 return trust;
364 });
365 const writeApprovalsFile = Effect.fn("BrowserSession.writeApprovalsFile")(function* (
366 file: string,
367 approvals: ReadonlyMap<string, CertificateApproval>,
368 ) {
369 if (approvals.size === 0) return yield* fileSystem.remove(file, { force: true });
370 yield* fileSystem.makeDirectory(path.dirname(file), { recursive: true });
371 yield* fileSystem.writeFileString(
372 file,
373 yield* encodeCertificateApprovals([...approvals.values()]),
374 );
375 });
376 /**
377 * Saved before it is trusted, so a failed write cannot leave an approval that
378 * silently ends at restart. Writes share one lock, so a slower write cannot
379 * restore stale data.
380 */
381 const rememberCertificate = Effect.fn("BrowserSession.rememberCertificate")(function* (
382 trust: CertificateTrust,
383 key: string,
384 approval: CertificateApproval,
385 revocations: number,
386 ) {
387 if (trust.revocations !== revocations) return false;
388 if (trust.file !== null) {
389 yield* writeApprovalsFile(trust.file, new Map(trust.approvals).set(key, approval));
390 // A revocation during the write queues its own rewrite behind this one.
391 if (trust.revocations !== revocations) return false;
392 }
393 trust.approvals.set(key, approval);
394 return true;
395 }, writeCertificateApprovals.withPermits(1));
396 /**
397 * Rewrites the file from memory. Clearing cookies runs this even when memory
398 * is already empty, so retrying after a failed removal still deletes the file.
399 */
400 const saveCertificateApprovals = Effect.fn("BrowserSession.saveCertificateApprovals")(function* (
401 trust: CertificateTrust,
402 ) {
403 if (trust.file !== null) yield* writeApprovalsFile(trust.file, trust.approvals);
404 }, writeCertificateApprovals.withPermits(1));
405​
406 // One prompt at a time; tabs that hit the same certificate wait on it.
407 let certificatePrompt: {
408 readonly trust: CertificateTrust;
409 readonly key: string;
410 readonly callbacks: Array<(trusted: boolean) => void>;
411 } | null = null;
412 const confirmCertificate = (
413 contents: WebContents,
414 trust: CertificateTrust,
415 approval: CertificateApproval,
416 certificate: Certificate,
417 reason: string,
418 callback: (trusted: boolean) => void,
419 ): void => {
420 const key = certificateApprovalKey(approval);
421 if (certificatePrompt !== null) {
422 if (certificatePrompt.trust === trust && certificatePrompt.key === key) {
423 certificatePrompt.callbacks.push(callback);
424 } else {
425 callback(false);
426 }
427 return;
428 }
429 const prompt = { trust, key, callbacks: [callback] };
430 certificatePrompt = prompt;
431 // A prompt for a tab that closed or moved on would approve a page nobody is waiting for.
432 const dismiss = new AbortController();
433 const abandon = () => dismiss.abort();
434 const navigated = (
435 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
436 ) => {
437 if (event.isMainFrame && !event.isSameDocument) abandon();
438 };
439 contents.once("destroyed", abandon);
440 contents.on("did-start-navigation", navigated);
441 const settle = (trusted: boolean) =>
442 Effect.sync(() => {
443 certificatePrompt = null;
444 if (!contents.isDestroyed()) {
445 contents.off("destroyed", abandon);
446 contents.off("did-start-navigation", navigated);
447 }
448 for (const waiter of prompt.callbacks) waiter(trusted);
449 });
450 const host = new URL(approval.origin).host;
451 const revocations = trust.revocations;
452 runFork(
453 electronDialog
454 .showMessageBox(
455 {
456 type: "warning",
457 buttons: ["Trust and Remember", "Cancel"],
458 defaultId: 1,
459 cancelId: 1,
460 signal: dismiss.signal,
461 message: `${host} has an untrusted certificate`,
462 detail: [
463 reason,
464 "",
465 `Issued to: ${promptCertificateName(certificate.subjectName)}`,
466 `Issued by: ${promptCertificateName(certificate.issuerName)}`,
467 `Valid: ${promptCertificateDate(certificate.validStart)} – ${promptCertificateDate(certificate.validExpiry)}`,
468 `Fingerprint: ${approval.fingerprint}`,
469 "",
470 "Trust it only if you know this certificate belongs to the site. Clearing this browser profile's cookies forgets it.",
471 ].join("\n"),
472 },
473 // In the reporter's Windows test, a box owned by the host window
474 // blocked all mouse input, its own buttons included, until it closed.
475 platform === "win32" ? undefined : previewHostWindow(contents),
476 )
477 .pipe(
478 Effect.flatMap(({ response }) =>
479 response === 0 && !dismiss.signal.aborted
480 ? rememberCertificate(trust, key, approval, revocations).pipe(
481 Effect.catch((cause) =>
482 Effect.logWarning("Failed to save a certificate approval.", { cause }).pipe(
483 Effect.andThen(
484 electronDialog.showErrorBox(
485 `Could not trust the certificate for ${host}`,
486 "T3 Code could not save this approval, so the page stays blocked. Check that the disk is not full and its data folder is writable, then try again.",
487 ),
488 ),
489 Effect.as(false),
490 ),
491 ),
492 )
493 : Effect.succeed(false),
494 ),
495 Effect.orElseSucceed(() => false),
496 Effect.flatMap(settle),
497 Effect.onInterrupt(() => settle(false)),
498 ),
499 );
500 };
501 yield* electronApp.on(
502 "certificate-error",
503 (
504 event: Electron.Event,
505 contents: WebContents,
506 url: string,
507 error: string,
508 certificate: Certificate,
509 callback: (trusted: boolean) => void,
510 isMainFrame: boolean,
511 ) => {
512 if (contents.isDestroyed()) return;
513 // Sessions this service did not create keep Electron's denial.
514 const trust = certificateTrust.get(contents.session);
515 const origin = certificateOrigin(url);
516 const reason = OVERRIDABLE_CERTIFICATE_ERRORS.get(error);
517 if (trust === undefined || origin === null || reason === undefined) return;
518 const approval = { origin, fingerprint: certificate.fingerprint, error };
519 event.preventDefault();
520 if (trust.approvals.has(certificateApprovalKey(approval))) {
521 callback(true);
522 return;
523 }
524 // Only a page navigation asks; a subresource the user has not accepted stays blocked.
525 if (!isMainFrame) {
526 callback(false);
527 return;
528 }
529 confirmCertificate(contents, trust, approval, certificate, reason, callback);
530 },
531 );
532​
533 /** Clearing a profile's cookies also forgets the certificates its user trusted. */
534 const revokeCertificates = Effect.fn("BrowserSession.revokeCertificates")(function* (
535 partition: string,
536 browserSession: Session,
537 ) {
538 const trust = certificateTrust.get(browserSession);
539 if (trust === undefined) return;
540 const hadApprovals = trust.approvals.size > 0;
541 trust.revocations += 1;
542 trust.approvals.clear();
543 const saved = yield* saveCertificateApprovals(trust).pipe(Effect.result);
544 // An open connection already passed its certificate check.
545 if (hadApprovals) {
546 yield* Effect.tryPromise({
547 try: () => browserSession.closeAllConnections(),
548 catch: (cause) => new BrowserSessionStorageClearError({ partition, cause }),
549 });
550 }
551 if (Result.isFailure(saved)) {
552 return yield* new BrowserSessionStorageClearError({ partition, cause: saved.failure });
553 }
554 });
555​
556 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
557​
558 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (
559 scope = "shared",
560 persistent = true,
561 namespace?: BrowserSessionPartitionNamespace,
562 ) {
563 const digest = yield* crypto.digest(Digest.sha256, encodeScopeForDigest(scope)).pipe(
564 Effect.mapError(
565 (cause) =>
566 new BrowserSessionPartitionDerivationError({
567 scope,
568 cause,
569 }),
570 ),
571 );
572 const prefix = persistent ? PREVIEW_PARTITION_PREFIX : PREVIEW_EPHEMERAL_PARTITION_PREFIX;
573 return `${prefix}${namespace === "profile" ? PROFILE_PARTITION_MARKER : ""}${Hex.encode(digest).slice(0, 20)}`;
574 });
575​
576 const getSession = Effect.fn("BrowserSession.getSession")(function* (
577 scope = "shared",
578 persistent = true,
579 namespace?: BrowserSessionPartitionNamespace,
580 ) {
581 const partition = yield* getPartition(scope, persistent, namespace);
582 return yield* SynchronizedRef.modifyEffect(sessionsRef, (sessions) => {
583 const existing = sessions.get(partition);
584 if (existing) return Effect.succeed([existing, sessions] as const);
585 return Effect.try({
586 try: () => {
587 const browserSession = session.fromPartition(partition);
588 // Like Chrome, clipboard reads ask once per origin; both answers last for the session.
589 const clipboardReadDecisions = new Map<string, boolean>();
590 const confirmClipboardRead = (
591 requester: WebContents,
592 rawUrl: string | undefined,
593 callback: (granted: boolean) => void,
594 ): void => {
595 const origin = permissionOrigin(rawUrl);
596 if (origin === null) {
597 callback(false);
598 return;
599 }
600 const remembered = clipboardReadDecisions.get(origin);
601 if (remembered !== undefined) {
602 callback(remembered);
603 return;
604 }
605 promptPermission(
606 requester,
607 {
608 buttons: ["Allow", "Block"],
609 message: `${truncateForPrompt(origin)} wants to see text and images copied to the clipboard`,
610 },
611 (answer) => {
612 if (answer !== undefined) clipboardReadDecisions.set(origin, answer);
613 callback(answer === true);
614 },
615 );
616 };
617 // Rewriting Electron's native User-Agent makes Turnstile fail integrity checks with error 600010.
618 browserSession.setPermissionRequestHandler(
619 (webContents, permission, callback, details) => {
620 if (permission === "openExternal") {
621 confirmOpenExternal(
622 webContents,
623 "externalURL" in details ? details.externalURL : undefined,
624 callback,
625 );
626 return;
627 }
628 if (permission === "clipboard-read") {
629 confirmClipboardRead(
630 webContents,
631 "requestingUrl" in details ? details.requestingUrl : undefined,
632 callback,
633 );
634 return;
635 }
636 callback(ALLOWED_PREVIEW_PERMISSIONS.has(permission));
637 },
638 );
639 browserSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin) =>
640 permission === "clipboard-read"
641 ? clipboardReadDecisions.get(permissionOrigin(requestingOrigin) ?? "") === true
642 : ALLOWED_PREVIEW_PERMISSIONS.has(permission),
643 );
644 browserSession.webRequest.onBeforeSendHeaders(
645 { urls: TEAMS_URL_PATTERNS },
646 ({ requestHeaders }, callback) => {
647 const userAgent = Object.entries(requestHeaders).find(
648 ([name]) => name.toLowerCase() === "user-agent",
649 );
650 if (userAgent === undefined) {
651 callback({});
652 return;
653 }
654 const [name, value] = userAgent;
655 callback({
656 requestHeaders: { ...requestHeaders, [name]: value.replace(/ Electron\/\S+/, "") },
657 });
658 },
659 );
660 return browserSession;
661 },
662 catch: (cause) =>
663 new BrowserSessionCreationError({
664 scope,
665 partition,
666 cause,
667 }),
668 }).pipe(
669 Effect.flatMap((browserSession) =>
670 loadCertificateTrust(browserSession).pipe(
671 Effect.map((trust) => {
672 certificateTrust.set(browserSession, trust);
673 const next = new Map(sessions);
674 next.set(partition, browserSession);
675 return [browserSession, next] as const;
676 }),
677 ),
678 ),
679 );
680 });
681 });
682​
683 return BrowserSession.of({
684 getPartition,
685 isPartition: (partition) =>
686 partition.startsWith(PREVIEW_PARTITION_PREFIX) ||
687 partition.startsWith(PREVIEW_EPHEMERAL_PARTITION_PREFIX),
688 getSession,
689 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) {
690 const sessions = yield* SynchronizedRef.get(sessionsRef);
691 yield* Effect.forEach(
692 selectSessions(sessions, partitions),
693 ([partition, browserSession]) =>
694 Effect.gen(function* () {
695 const cleared = yield* Effect.tryPromise({
696 try: () =>
697 browserSession.clearStorageData({
698 storages: [
699 "cookies",
700 "localstorage",
701 "indexdb",
702 "serviceworkers",
703 "cachestorage",
704 ],
705 }),
706 catch: (cause) =>
707 new BrowserSessionStorageClearError({
708 partition,
709 cause,
710 }),
711 }).pipe(Effect.result);
712 // Approvals go even when the storage clear fails, since the user asked to forget them.
713 yield* revokeCertificates(partition, browserSession);
714 if (Result.isFailure(cleared)) return yield* cleared.failure;
715 }),
716 { concurrency: "unbounded", discard: true },
717 );
718 }),
719 cookiesCleared: Effect.fn("BrowserSession.cookiesCleared")(function* (browserSession) {
720 const sessions = yield* SynchronizedRef.get(sessionsRef);
721 for (const [partition, candidate] of sessions) {
722 if (candidate === browserSession) yield* revokeCertificates(partition, browserSession);
723 }
724 }),
725 clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) {
726 const sessions = yield* SynchronizedRef.get(sessionsRef);
727 yield* Effect.forEach(
728 selectSessions(sessions, partitions),
729 ([partition, browserSession]) =>
730 Effect.tryPromise({
731 try: () => browserSession.clearCache(),
732 catch: (cause) =>
733 new BrowserSessionCacheClearError({
734 partition,
735 cause,
736 }),
737 }),
738 { concurrency: "unbounded", discard: true },
739 );
740 }),
741 });
742}).pipe(Effect.withSpan("BrowserSession.make"));
743​
744export const layer = Layer.effect(BrowserSession, make);