@@ -21,15 +21,35 @@ const { fromPartition, sessions } = vi.hoisted(() => ({
2121 > ( ) ,
2222} ) ) ;
2323
24+ const { fromWebContents } = vi . hoisted ( ( ) => ( {
25+ fromWebContents : vi . fn < ( webContents : unknown ) => unknown > ( ( ) => null ) ,
26+ } ) ) ;
27+
2428vi . mock ( "electron" , ( ) => ( {
29+ BrowserWindow : { fromWebContents } ,
2530 session : {
2631 fromPartition,
2732 } ,
2833} ) ) ;
2934
35+ import * as ElectronDialog from "../electron/ElectronDialog.ts" ;
3036import * as BrowserSession from "./BrowserSession.ts" ;
3137
32- const layer = BrowserSession . layer . pipe ( Layer . provide ( NodeServices . layer ) ) ;
38+ const { showMessageBox } = vi . hoisted ( ( ) => ( {
39+ showMessageBox : vi . fn <
40+ ( options : Electron . MessageBoxOptions , owner ?: Electron . BrowserWindow ) => number
41+ > ( ( ) => 1 ) ,
42+ } ) ) ;
43+ const layerDialog = Layer . succeed ( ElectronDialog . ElectronDialog , {
44+ pickFolder : ( ) => Effect . die ( "unused" ) ,
45+ pickFiles : ( ) => Effect . die ( "unused" ) ,
46+ showMessageBox : ( options , owner ) =>
47+ Effect . sync ( ( ) => ( { response : showMessageBox ( options , owner ) , checkboxChecked : false } ) ) ,
48+ showErrorBox : ( ) => Effect . void ,
49+ } ) ;
50+ const layer = BrowserSession . layer . pipe (
51+ Layer . provide ( Layer . merge ( NodeServices . layer , layerDialog ) ) ,
52+ ) ;
3353
3454describe ( "BrowserSession" , ( ) => {
3555 beforeEach ( ( ) => {
@@ -168,6 +188,7 @@ describe("BrowserSession", () => {
168188 "clipboard-sanitized-write" ,
169189 "notifications" ,
170190 "geolocation" ,
191+ "fullscreen" ,
171192 ] ) {
172193 assert . isTrue ( requestAllows ( permission ) , `request handler should allow ${ permission } ` ) ;
173194 assert . isTrue (
@@ -189,6 +210,73 @@ describe("BrowserSession", () => {
189210 } ) . pipe ( Effect . provide ( layer ) ) ,
190211 ) ;
191212
213+ it . effect ( "opens custom-scheme links externally only after the user confirms" , ( ) =>
214+ Effect . gen ( function * ( ) {
215+ const browserSessions = yield * BrowserSession . BrowserSession ;
216+ const partition = yield * browserSessions . getPartition ( "scope-a" ) ;
217+ yield * browserSessions . getSession ( "scope-a" ) ;
218+ const requestHandler =
219+ sessions . get ( partition ) ?. setPermissionRequestHandler . mock . calls [ 0 ] ?. [ 0 ] ;
220+ assert . isFunction ( requestHandler ) ;
221+
222+ const request = ( externalURL : string ) =>
223+ Effect . callback < boolean > ( ( resume ) => {
224+ requestHandler (
225+ null ,
226+ "openExternal" ,
227+ ( granted : boolean ) => resume ( Effect . succeed ( granted ) ) ,
228+ { externalURL } ,
229+ ) ;
230+ } ) ;
231+
232+ showMessageBox . mockReset ( ) ;
233+ showMessageBox . mockReturnValueOnce ( 0 ) . mockReturnValueOnce ( 1 ) ;
234+ assert . isTrue ( yield * request ( "slack://open?team=T1" ) ) ;
235+ assert . isFalse ( yield * request ( "zoommtg://zoom.us/join" ) ) ;
236+ assert . strictEqual ( showMessageBox . mock . calls . length , 2 ) ;
237+
238+ for ( const url of [ "file:///etc/passwd" , "javascript:alert(1)" , "data:text/html,x" ] ) {
239+ assert . isFalse ( yield * request ( url ) , `${ url } must never open externally` ) ;
240+ }
241+ assert . strictEqual ( showMessageBox . mock . calls . length , 2 ) ;
242+ } ) . pipe ( Effect . provide ( layer ) ) ,
243+ ) ;
244+
245+ // A prompt without an owner can open behind the app window, leaving the one
246+ // prompt slot taken and every later link silently denied.
247+ it . effect ( "attaches the open-externally prompt to the window hosting the preview" , ( ) =>
248+ Effect . gen ( function * ( ) {
249+ const browserSessions = yield * BrowserSession . BrowserSession ;
250+ const partition = yield * browserSessions . getPartition ( "scope-a" ) ;
251+ yield * browserSessions . getSession ( "scope-a" ) ;
252+ const requestHandler =
253+ sessions . get ( partition ) ?. setPermissionRequestHandler . mock . calls [ 0 ] ?. [ 0 ] ;
254+ assert . isFunction ( requestHandler ) ;
255+
256+ const hostWindow = { id : 7 } as unknown as Electron . BrowserWindow ;
257+ const host = { isDestroyed : ( ) => false } ;
258+ const guest = { isDestroyed : ( ) => false , hostWebContents : host } ;
259+ fromWebContents . mockImplementation ( ( webContents ) =>
260+ webContents === host ? hostWindow : null ,
261+ ) ;
262+ showMessageBox . mockReset ( ) ;
263+ showMessageBox . mockReturnValueOnce ( 1 ) ;
264+
265+ yield * Effect . callback < boolean > ( ( resume ) => {
266+ requestHandler (
267+ guest ,
268+ "openExternal" ,
269+ ( granted : boolean ) => resume ( Effect . succeed ( granted ) ) ,
270+ {
271+ externalURL : "slack://open?team=T1" ,
272+ } ,
273+ ) ;
274+ } ) ;
275+
276+ assert . strictEqual ( showMessageBox . mock . calls [ 0 ] ?. [ 1 ] , hostWindow ) ;
277+ } ) . pipe ( Effect . provide ( layer ) ) ,
278+ ) ;
279+
192280 it . effect ( "preserves partition scope and the platform failure chain" , ( ) => {
193281 const nativeCause = new Error ( "native digest failed" ) ;
194282 const platformCause = PlatformError . systemError ( {
@@ -218,7 +306,11 @@ describe("BrowserSession", () => {
218306 "Failed to derive a desktop preview browser partition for scope environment-a." ,
219307 ) ;
220308 assert . notInclude ( error . message , nativeCause . message ) ;
221- } ) . pipe ( Effect . provide ( BrowserSession . layer . pipe ( Layer . provide ( layerFailingCrypto ) ) ) ) ;
309+ } ) . pipe (
310+ Effect . provide (
311+ BrowserSession . layer . pipe ( Layer . provide ( Layer . merge ( layerFailingCrypto , layerDialog ) ) ) ,
312+ ) ,
313+ ) ;
222314 } ) ;
223315
224316 it . effect ( "preserves session scope, partition, and the Electron failure" , ( ) =>
0 commit comments