Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/150.fixed.md
Original file line number Diff line number Diff line change
@@ -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`.
55 changes: 48 additions & 7 deletions src/system/presenter/prompter-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Location, 'href' | 'origin'>,
): 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;
};

/**
Expand All @@ -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);
};

/**
Expand Down
55 changes: 50 additions & 5 deletions tests/system/prompter-window-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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&note=has-mode=inside');
expect(opened[0]?.url).toBe(
'https://pulsar.test/?composition=demo&note=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();
});
});
Loading