1- import type { Session } from "electron" ;
2- import { session } from "electron" ;
1+ import type { Session , WebContents } from "electron" ;
2+ import { BrowserWindow , session } from "electron" ;
33import * as Context from "effect/Context" ;
44import * as Crypto from "effect/Crypto" ;
55import * as Effect from "effect/Effect" ;
@@ -9,6 +9,8 @@ import * as PlatformError from "effect/PlatformError";
99import * as Schema from "effect/Schema" ;
1010import * as SynchronizedRef from "effect/SynchronizedRef" ;
1111
12+ import * as ElectronDialog from "../electron/ElectronDialog.ts" ;
13+
1214const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-" ;
1315/**
1416 * Incognito partitions deliberately omit the `persist:` prefix, which is what
@@ -33,12 +35,51 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet<string> = new Set([
3335 "clipboard-sanitized-write" ,
3436 "notifications" ,
3537 "geolocation" ,
38+ // The Fullscreen API. `DesktopWindow` disables HTML fullscreen window resizing
39+ // for the app window and every preview guest, so a page that goes fullscreen
40+ // fills its own webview instead of taking over the whole T3 window.
41+ "fullscreen" ,
3642 // Deliberately NOT local-fonts: preview sessions run untrusted web content,
3743 // and silently granting it would hand every page the user's installed-font
3844 // fingerprint (and font file bytes via FontData.blob()). The app's own font
3945 // picker runs in the main window session, which is unaffected by this list.
4046] ) ;
4147
48+ /**
49+ * Schemes a preview page may never hand to the OS, even with the user's
50+ * consent: they read local files, run script, or are web pages that belong in
51+ * the preview itself.
52+ */
53+ const NEVER_EXTERNAL_PROTOCOLS : ReadonlySet < string > = new Set ( [
54+ "about:" ,
55+ "blob:" ,
56+ "chrome:" ,
57+ "data:" ,
58+ "devtools:" ,
59+ "file:" ,
60+ "filesystem:" ,
61+ "http:" ,
62+ "https:" ,
63+ "javascript:" ,
64+ "view-source:" ,
65+ ] ) ;
66+
67+ /**
68+ * The URL to offer the user when a preview page navigates to a custom scheme
69+ * such as `slack://` or `zoom://`, or `null` when it must stay denied.
70+ */
71+ const externalProtocolPromptUrl = ( rawUrl : string | undefined ) : string | null => {
72+ if ( ! rawUrl ) return null ;
73+ try {
74+ const url = new URL ( rawUrl ) ;
75+ return NEVER_EXTERNAL_PROTOCOLS . has ( url . protocol ) ? null : url . href ;
76+ } catch {
77+ return null ;
78+ }
79+ } ;
80+
81+ const MAX_PROMPT_URL_LENGTH = 300 ;
82+
4283export class BrowserSessionPartitionDerivationError extends Schema . TaggedError < BrowserSessionPartitionDerivationError > ( ) (
4384 "BrowserSessionPartitionDerivationError" ,
4485 {
@@ -159,9 +200,68 @@ const encodeScopeForDigest = (scope: string): Uint8Array =>
159200 ) ,
160201 ) ;
161202
203+ /**
204+ * The window showing a preview guest. A `<webview>` guest has no window of its
205+ * own, so look it up through the page that embeds it.
206+ */
207+ const previewHostWindow = ( guest : WebContents | null ) : BrowserWindow | undefined => {
208+ if ( guest === null || guest . isDestroyed ( ) ) return undefined ;
209+ const host = guest . hostWebContents ?? guest ;
210+ if ( host . isDestroyed ( ) ) return undefined ;
211+ return BrowserWindow . fromWebContents ( host ) ?? undefined ;
212+ } ;
213+
162214/** @public Service construction is part of the canonical Effect module API. */
163215export const make = Effect . gen ( function * BrowserSessionMake ( ) {
164216 const crypto = yield * Crypto . Crypto ;
217+ const electronDialog = yield * ElectronDialog . ElectronDialog ;
218+ const runFork = Effect . runForkWith ( yield * Effect . context < never > ( ) ) ;
219+ // One prompt at a time, so a page cannot stack dialogs by looping a deep link.
220+ let externalPromptOpen = false ;
221+ /**
222+ * Electron grants an external-protocol navigation through the `openExternal`
223+ * permission and then launches the OS handler itself. Ask first: previews run
224+ * untrusted pages, and a silent grant would let any of them launch apps.
225+ */
226+ const confirmOpenExternal = (
227+ requester : WebContents | null ,
228+ rawUrl : string | undefined ,
229+ callback : ( granted : boolean ) => void ,
230+ ) : void => {
231+ const url = externalProtocolPromptUrl ( rawUrl ) ;
232+ if ( url === null || externalPromptOpen ) {
233+ callback ( false ) ;
234+ return ;
235+ }
236+ externalPromptOpen = true ;
237+ const settle = ( granted : boolean ) =>
238+ Effect . sync ( ( ) => {
239+ externalPromptOpen = false ;
240+ callback ( granted ) ;
241+ } ) ;
242+ const shownUrl =
243+ url . length > MAX_PROMPT_URL_LENGTH ? `${ url . slice ( 0 , MAX_PROMPT_URL_LENGTH ) } …` : url ;
244+ runFork (
245+ electronDialog
246+ . showMessageBox (
247+ {
248+ type : "question" ,
249+ buttons : [ "Open" , "Cancel" ] ,
250+ defaultId : 0 ,
251+ cancelId : 1 ,
252+ message : `Open this ${ new URL ( url ) . protocol . slice ( 0 , - 1 ) } link?` ,
253+ detail : `A page in the browser wants to open another application:\n\n${ shownUrl } ` ,
254+ } ,
255+ previewHostWindow ( requester ) ,
256+ )
257+ . pipe (
258+ Effect . map ( ( { response } ) => response === 0 ) ,
259+ Effect . orElseSucceed ( ( ) => false ) ,
260+ Effect . flatMap ( settle ) ,
261+ Effect . onInterrupt ( ( ) => settle ( false ) ) ,
262+ ) ,
263+ ) ;
264+ } ;
165265 const sessionsRef = yield * SynchronizedRef . make < ReadonlyMap < string , Session > > ( new Map ( ) ) ;
166266
167267 const getPartition = Effect . fn ( "BrowserSession.getPartition" ) ( function * (
@@ -203,9 +303,19 @@ export const make = Effect.gen(function* BrowserSessionMake() {
203303 // the challenge every few seconds, so logins behind it never complete
204304 // (#5002). Re-setting the unchanged native string is harmless, so it
205305 // is the rewritten string itself that trips the check.
206- browserSession . setPermissionRequestHandler ( ( _webContents , permission , callback ) => {
207- callback ( ALLOWED_PREVIEW_PERMISSIONS . has ( permission ) ) ;
208- } ) ;
306+ browserSession . setPermissionRequestHandler (
307+ ( webContents , permission , callback , details ) => {
308+ if ( permission === "openExternal" ) {
309+ confirmOpenExternal (
310+ webContents ,
311+ "externalURL" in details ? details . externalURL : undefined ,
312+ callback ,
313+ ) ;
314+ return ;
315+ }
316+ callback ( ALLOWED_PREVIEW_PERMISSIONS . has ( permission ) ) ;
317+ } ,
318+ ) ;
209319 browserSession . setPermissionCheckHandler ( ( _webContents , permission ) =>
210320 ALLOWED_PREVIEW_PERMISSIONS . has ( permission ) ,
211321 ) ;
0 commit comments