From 7efc0c0b558270de190849f97ac3906747960497 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sat, 25 Jul 2026 21:56:45 -0700 Subject: [PATCH] Poll for the decision when the popup's opener is severed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crossOriginIsolated was the test for "the popup can't message us back", but being isolated also requires COEP. A site sending COOP: same-origin on its own still has its opener relationship severed when it opens the Puter popup, and took the watch-the-window path instead — where the detached proxy reports closed === true on the first tick, so requestPermission resolved false about a second after the popup opened, while the user was still reading the dialog. Their "Allow" then had nowhere to go. Treat an already-closed popup as severed and poll. Pin the expected event.source before those early returns. popupWindow was assigned after them, so for the whole consent-dialog wait — as long as the user takes to click Continue — the handler accepted a decision from any window on the GUI origin. A forged answer is only advisory since the grant is written server-side, but the check may as well hold. Settle instead of rejecting when the consent dialog can't be appended: document.body is null in a script, and the throw both rejected a promise documented to resolve to a boolean and left the message listener behind. --- src/puter-js/src/modules/UI.js | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index ac327ceee..e52997c7b 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -1194,15 +1194,23 @@ class UI extends EventListener { settle(false); return; } - if ( window.crossOriginIsolated ) { - // COOP severs the opener relationship: the popup can't - // postMessage back and `popup.closed` is meaningless. - // Poll the permission check instead (mirrors signIn's - // /login/wait fallback), giving up after a timeout. + // Pin the expected event.source before anything can return + // early: until this is set the message handler accepts a + // decision from any window on the GUI origin, and the wait for + // the consent dialog's Continue click is user-paced. + popupWindow = popup; + // A severed opener relationship means the popup can't + // postMessage back and `popup.closed` tells us nothing about + // the window the user is looking at — it reads `true` for a + // detached proxy. Poll the permission check instead (mirrors + // signIn's /login/wait fallback), giving up after a timeout. + // `crossOriginIsolated` alone misses this: COOP severs the + // relationship on its own, while being isolated also requires + // COEP. + if ( window.crossOriginIsolated || popup.closed ) { pollDecision(); return; } - popupWindow = popup; checkClosed = setInterval(() => { if ( ! popup.closed ) return; // The GUI posts the decision and then closes the popup, @@ -1264,8 +1272,16 @@ class UI extends EventListener { onCancel: () => settle(false), }); consentDialog = dialog; - document.body.appendChild(dialog); - dialog.open(); + try { + document.body.appendChild(dialog); + dialog.open(); + } catch (e) { + // Nothing to show the user (e.g. called from a + // script, so there is no body yet). This resolves to a + // boolean for every other caller, so deny rather than + // reject — and let cleanup drop the message listener. + settle(false); + } } }); };