diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 4361b6be4..26c12081d 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -1140,9 +1140,25 @@ class UI extends EventListener { // decision message that may still be in flight. const CLOSE_GRACE_MS = 1000; + // The popup's messages arrive tagged with the browser's canonical + // serialization of its origin, while `defaultGUIOrigin` is + // configuration-supplied text — a trailing slash, an explicit default + // port, or a stray path would fail a raw comparison. A dropped message + // here doesn't just hang: an unseen `permissionPromptReady` makes the + // popup's close read as a severed opener, and an unseen decision then + // reports a permission the user granted as denied. Parse once and + // compare canonical-to-canonical. A configured origin that can't parse + // can't host the prompt at all, so deny up front. + let gui_origin; + try { + gui_origin = new URL(puter.defaultGUIOrigin).origin; + } catch (e) { + return false; + } + return new Promise((resolve) => { const msg_id = this.#messageID++; - const url = `${puter.defaultGUIOrigin}/action/request-permission?embedded_in_popup=true&msg_id=${msg_id}&permission=${encodeURIComponent(permission)}`; + const url = `${gui_origin}/action/request-permission?embedded_in_popup=true&msg_id=${msg_id}&permission=${encodeURIComponent(permission)}`; // Guards against settling more than once across the message, // popup-closed, and dialog-cancel code paths. @@ -1181,7 +1197,7 @@ class UI extends EventListener { // the popup we opened. Origin alone is insufficient (any frame // on the GUI domain could post), so also pin event.source. // msg_id binds the message to this request. - if ( e.origin !== puter.defaultGUIOrigin ) return; + if ( e.origin !== gui_origin ) return; if ( popupWindow && e.source !== popupWindow ) return; if ( e.data?.original_msg_id != msg_id ) return; // The popup reporting that it is up and can reach us. Carries diff --git a/src/puter-js/tests/e2e/specs/requestPermission.spec.js b/src/puter-js/tests/e2e/specs/requestPermission.spec.js index cee444371..8f182ecf2 100644 --- a/src/puter-js/tests/e2e/specs/requestPermission.spec.js +++ b/src/puter-js/tests/e2e/specs/requestPermission.spec.js @@ -453,6 +453,35 @@ test.describe('puter.ui.requestPermission (env=web popup)', () => { await expect(page.locator('#log [data-entry="perm:driver:true"]')).toBeVisible(); }); + test('a non-canonical configured origin still hears the decision', async ({ page }) => { + // `defaultGUIOrigin` is configuration-supplied text, while the popup's + // messages arrive tagged with the browser's canonical origin + // serialization. A trailing slash used to fail the raw comparison and + // drop both messages — and a dropped `permissionPromptReady` makes the + // popup's close read as a severed opener, reporting the grant the user + // just made as a denial. The SDK compares canonical-to-canonical now. + await page.goto('/'); + await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 }); + + await page.goto(PERMISSION_FIXTURE_URL); + await page.locator('body.ready').waitFor({ timeout: 60_000 }); + // Re-point the SDK at the same GUI through a non-canonical string. + // The getter reads `globalThis.PUTER_ORIGIN` on every access, so this + // takes effect for the request below. + await page.evaluate(() => { + globalThis.PUTER_ORIGIN = `${window.PUTER_ORIGIN}/`; + }); + + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.locator('#req-driver-perm').click(), + ]); + const dialog = popup.locator('dialog.perm-dialog'); + await expect(dialog).toBeVisible({ timeout: 60_000 }); + await dialog.locator('.perm-dialog-allow').click(); + await expect(page.locator('#log [data-entry="perm:driver:true"]')).toBeVisible(); + }); + test('without a user gesture, a consent dialog collects the click that opens the popup', async ({ page }) => { await page.goto('/'); await page.waitForFunction(() => !!window.puter?.authToken, null, { timeout: 60_000 });