diff --git a/changelog.d/150.fixed.md b/changelog.d/150.fixed.md new file mode 100644 index 0000000..7cb3d2d --- /dev/null +++ b/changelog.d/150.fixed.md @@ -0,0 +1 @@ +Prompter popout windows now build same-origin `mode=prompter` URLs with the platform URL parser and isolate the opened window from `window.opener`. diff --git a/src/system/presenter/prompter-window.ts b/src/system/presenter/prompter-window.ts index 4016a7d..0e89714 100644 --- a/src/system/presenter/prompter-window.ts +++ b/src/system/presenter/prompter-window.ts @@ -12,10 +12,45 @@ import type { PrompterRenderer, PrompterScript } from '../../runtime/prompter'; -const buildPrompterUrl = (baseUrl: string): string => { - if (baseUrl.includes('mode=')) return baseUrl.replace(/mode=[^&]*/, 'mode=prompter'); - if (baseUrl.includes('?')) return `${baseUrl}&mode=prompter`; - return `${baseUrl}?mode=prompter`; +const DEFAULT_PROMPTER_WINDOW_FEATURES = 'width=900,height=700,menubar=no,toolbar=no'; +const POPUP_ISOLATION_FEATURES = ['noopener', 'noreferrer'] as const; + +const buildPrompterUrl = ( + baseUrl: string, + location: Pick, +): string | null => { + let url: URL; + try { + url = new URL(baseUrl, location.href); + } catch { + return null; + } + + if (url.origin !== location.origin) return null; + url.searchParams.set('mode', 'prompter'); + return url.href; +}; + +const withPopupIsolationFeatures = (features: string): string => { + const tokens = features + .split(',') + .map((feature) => feature.trim()) + .filter((feature) => feature.length > 0); + const present = new Set(tokens.map((feature) => feature.toLowerCase())); + for (const feature of POPUP_ISOLATION_FEATURES) { + if (!present.has(feature)) tokens.push(feature); + } + return tokens.join(','); +}; + +const isolateOpenedWindow = (opened: Window | null): Window | null => { + if (opened === null) return null; + try { + opened.opener = null; + } catch { + // Some browsers return a WindowProxy that rejects opener mutation. + } + return opened; }; /** @@ -26,10 +61,16 @@ const buildPrompterUrl = (baseUrl: string): string => { */ export const openPrompterWindow = ( baseUrl: string, - features = 'width=900,height=700,menubar=no,toolbar=no', + features = DEFAULT_PROMPTER_WINDOW_FEATURES, ): Window | null => { - const url = buildPrompterUrl(baseUrl); - return globalThis.window?.open(url, '_blank', features) ?? null; + const win = globalThis.window; + if (win === undefined) return null; + + const url = buildPrompterUrl(baseUrl, win.location); + if (url === null) return null; + + const opened = win.open(url, '_blank', withPopupIsolationFeatures(features)); + return isolateOpenedWindow(opened); }; /** diff --git a/tests/system/prompter-window-url.test.ts b/tests/system/prompter-window-url.test.ts index 1417a0f..2d03646 100644 --- a/tests/system/prompter-window-url.test.ts +++ b/tests/system/prompter-window-url.test.ts @@ -7,16 +7,21 @@ describe('openPrompterWindow', () => { const realOpen = globalThis.window?.open ?? null; const originalWindow = globalThis.window; let opened: Array<{ url: string; target: string; features: string }> = []; + let openedWindow: Window & { opener: unknown }; beforeEach(() => { opened = []; + openedWindow = { closed: false, opener: { source: 'presenter' } } as Window & { + opener: unknown; + }; // Stub `window.open`. Vitest's default Node env has no window; // attach one minimally for this test. Object.defineProperty(globalThis, 'window', { value: { + location: new URL('https://pulsar.test/workbench/current?composition=current'), open: (url: string, target: string, features: string) => { opened.push({ url, target, features }); - return { closed: false } as unknown as Window; + return openedWindow; }, }, configurable: true, @@ -37,21 +42,61 @@ describe('openPrompterWindow', () => { it('replaces an existing mode= param', () => { openPrompterWindow('/?composition=demo&mode=present'); - expect(opened[0]?.url).toBe('/?composition=demo&mode=prompter'); + expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); }); it('appends mode=prompter to a URL with other params', () => { openPrompterWindow('/?composition=demo'); - expect(opened[0]?.url).toBe('/?composition=demo&mode=prompter'); + expect(opened[0]?.url).toBe('https://pulsar.test/?composition=demo&mode=prompter'); }); it('uses ?mode=prompter for a URL with no query', () => { openPrompterWindow('/'); - expect(opened[0]?.url).toBe('/?mode=prompter'); + expect(opened[0]?.url).toBe('https://pulsar.test/?mode=prompter'); + }); + + it('does not rewrite query values containing mode=', () => { + openPrompterWindow('/?composition=demo¬e=has-mode=inside'); + expect(opened[0]?.url).toBe( + 'https://pulsar.test/?composition=demo¬e=has-mode%3Dinside&mode=prompter', + ); + }); + + it('preserves hashes after the prompter mode query parameter', () => { + openPrompterWindow('/deck?composition=demo#speaker-notes'); + expect(opened[0]?.url).toBe( + 'https://pulsar.test/deck?composition=demo&mode=prompter#speaker-notes', + ); + }); + + it('resolves relative URLs against the current window location', () => { + openPrompterWindow('./notes?scene=intro'); + expect(opened[0]?.url).toBe('https://pulsar.test/workbench/notes?scene=intro&mode=prompter'); + }); + + it('accepts absolute same-origin URLs', () => { + openPrompterWindow('https://pulsar.test/presenter?composition=demo&mode=loop#notes'); + expect(opened[0]?.url).toBe( + 'https://pulsar.test/presenter?composition=demo&mode=prompter#notes', + ); + }); + + it('does not open cross-origin URLs', () => { + const result = openPrompterWindow('https://external.example/?composition=demo'); + expect(result).toBeNull(); + expect(opened).toEqual([]); }); it('forwards custom features arg', () => { openPrompterWindow('/', 'width=400'); - expect(opened[0]?.features).toBe('width=400'); + const features = opened[0]?.features.split(',').map((feature) => feature.trim()); + expect(features).toEqual(expect.arrayContaining(['width=400', 'noopener', 'noreferrer'])); + }); + + it('isolates the opened window from the opener', () => { + const result = openPrompterWindow('/'); + expect(result).toBe(openedWindow); + expect(opened[0]?.target).toBe('_blank'); + expect(openedWindow.opener).toBeNull(); }); });