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/151.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Presenter BroadcastChannel traffic is now scoped to an ephemeral workbench session, and prompter popout URLs carry that scope so independent same-origin presentations do not drive each other.
17 changes: 11 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
createKeyboardPresenterSource,
createPracticeRenderer,
createPresenterBridge,
getPresenterSessionId,
} from './system/presenter';
import { defaultTransitions } from './system/transitions';
import { WORKBENCH_COMPOSITIONS, WORKBENCH_SCENES } from './workbench-graph';
Expand Down Expand Up @@ -372,13 +373,17 @@ const presenterKeyboard = createKeyboardPresenterSource({
},
});

// Cross-window bridge: same-origin pulsar windows (present + popped-
// out prompter) share `BroadcastChannel('pulsar-presenter')` so a
// keystroke in either window drives the same controller. Every local
// keyboard command is broadcast outbound; inbound commands fan into
// the loader alongside the local keyboard source via
// Cross-window bridge: same-origin pulsar windows in this workbench
// session (present + popped-out prompter) share a scoped presenter
// BroadcastChannel so a keystroke in either window drives the same
// controller without leaking to another local presentation. Every
// local keyboard command is broadcast outbound; inbound commands fan
// into the loader alongside the local keyboard source via
// `combinePresenterSources`.
const presenterBridge: PresenterBridgeHandle = createPresenterBridge();
const presenterSessionId = getPresenterSessionId();
const presenterBridge: PresenterBridgeHandle = createPresenterBridge({
sessionId: presenterSessionId,
});
presenterKeyboard.source.subscribe((cmd) => presenterBridge.send(cmd));
const combinedPresenterSource = combinePresenterSources(
presenterKeyboard.source,
Expand Down
67 changes: 57 additions & 10 deletions src/system/presenter/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Pulsar L2 presenter — cross-window command bridge.
//
// Same-origin windows (the present window + a popped-out prompter
// window) share a `BroadcastChannel('pulsar-presenter')` so a keystroke
// in either drives the same `PresenterController`. The bridge is
// symmetric: each window subscribes for inbound commands AND emits
// local keyboard events outbound, but a window does NOT re-broadcast
// commands it received over the channel (no echo loop).
// Same-origin windows in one workbench session (the present window + a
// popped-out prompter window) share a scoped presenter BroadcastChannel
// so a keystroke in either drives the same `PresenterController`. The
// bridge is symmetric: each window subscribes for inbound commands AND
// emits local keyboard events outbound, but a window does NOT re-
// broadcast commands it received over the channel (no echo loop).
//
// Use `combinePresenterSources` to merge multiple sources (keyboard +
// bridge.source) into one source the loader can subscribe to.
Expand All @@ -16,12 +16,55 @@ import {
isPresenterCommand,
} from '../../runtime/presenter';

/** Default channel name used by every pulsar workbench. */
/** Base channel prefix; runtime bridges append a per-session scope. */
export const DEFAULT_PRESENTER_CHANNEL = 'pulsar-presenter';
export const PRESENTER_SESSION_QUERY_PARAM = 'pulsar-presenter-session';

const PRESENTER_SESSION_ID_FORM = /^[A-Za-z0-9_-]{8,128}$/;
let resolvedPresenterSessionId: string | null = null;

export const isPresenterSessionId = (value: string): boolean =>
PRESENTER_SESSION_ID_FORM.test(value);

export const presenterChannelName = (sessionId: string): string => {
if (!isPresenterSessionId(sessionId)) {
throw new Error('presenter session id must be 8-128 URL-safe characters');
}
return `${DEFAULT_PRESENTER_CHANNEL}:${sessionId}`;
};

const presenterSessionIdFromLocation = (location: Pick<Location, 'href'>): string | null => {
let url: URL;
try {
url = new URL(location.href);
} catch {
return null;
}
const value = url.searchParams.get(PRESENTER_SESSION_QUERY_PARAM);
if (value === null || !isPresenterSessionId(value)) return null;
return value;
};

const createPresenterSessionId = (): string => {
const crypto = globalThis.crypto;
if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID();
throw new Error('secure random presenter session id source is unavailable');
};

export const getPresenterSessionId = (
location: Pick<Location, 'href'> | undefined = globalThis.window?.location,
): string => {
if (resolvedPresenterSessionId !== null) return resolvedPresenterSessionId;
const fromLocation = location === undefined ? null : presenterSessionIdFromLocation(location);
resolvedPresenterSessionId = fromLocation ?? createPresenterSessionId();
return resolvedPresenterSessionId;
};

export interface PresenterBridgeOptions {
/** Channel name. Defaults to {@link DEFAULT_PRESENTER_CHANNEL}. */
/** Exact channel name override. Supplying this bypasses session scoping. */
readonly channelName?: string;
/** Per-workbench session id used to scope the default presenter channel. */
readonly sessionId?: string;
/** Optional sink for non-fatal errors (bad message, channel close). */
readonly onError?: (err: unknown) => void;
}
Expand All @@ -48,12 +91,16 @@ export interface PresenterBridgeHandle {
export const createPresenterBridge = (
options: PresenterBridgeOptions = {},
): PresenterBridgeHandle => {
const channelName = options.channelName ?? DEFAULT_PRESENTER_CHANNEL;
const handlers = new Set<(cmd: PresenterCommand) => void>();
let disposed = false;
// Guard for older runtimes / SSR / Node tests.
const BC = (globalThis as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel;
const ch = typeof BC === 'function' ? new BC(channelName) : null;
const ch =
typeof BC === 'function'
? new BC(
options.channelName ?? presenterChannelName(options.sessionId ?? getPresenterSessionId()),
)
: null;
if (ch !== null) {
ch.onmessage = (event) => {
const data = (event as MessageEvent<unknown>).data;
Expand Down
5 changes: 5 additions & 0 deletions src/system/presenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ export { createPracticeRenderer, splitCaptionText } from './practice-renderer';
export type { PracticeRendererHandle, PracticeRendererHost } from './practice-renderer';

export { createChromePrompterRenderer, openPrompterWindow } from './prompter-window';
export type { PrompterWindowOptions } from './prompter-window';

export {
combinePresenterSources,
createPresenterBridge,
DEFAULT_PRESENTER_CHANNEL,
getPresenterSessionId,
isPresenterSessionId,
PRESENTER_SESSION_QUERY_PARAM,
presenterChannelName,
} from './bridge';
export type { PresenterBridgeHandle, PresenterBridgeOptions } from './bridge';
17 changes: 16 additions & 1 deletion src/system/presenter/prompter-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,23 @@
// prompter window just needs to open the same URL with mode=prompter.

import type { PrompterRenderer, PrompterScript } from '../../runtime/prompter';
import {
PRESENTER_SESSION_QUERY_PARAM,
getPresenterSessionId,
isPresenterSessionId,
} from './bridge';

const DEFAULT_PROMPTER_WINDOW_FEATURES = 'width=900,height=700,menubar=no,toolbar=no';
const POPUP_ISOLATION_FEATURES = ['noopener', 'noreferrer'] as const;

export interface PrompterWindowOptions {
readonly presenterSessionId?: string;
}

const buildPrompterUrl = (
baseUrl: string,
location: Pick<Location, 'href' | 'origin'>,
presenterSessionId: string,
): string | null => {
let url: URL;
try {
Expand All @@ -28,6 +38,7 @@ const buildPrompterUrl = (

if (url.origin !== location.origin) return null;
url.searchParams.set('mode', 'prompter');
url.searchParams.set(PRESENTER_SESSION_QUERY_PARAM, presenterSessionId);
return url.href;
};

Expand Down Expand Up @@ -62,11 +73,15 @@ const isolateOpenedWindow = (opened: Window | null): Window | null => {
export const openPrompterWindow = (
baseUrl: string,
features = DEFAULT_PROMPTER_WINDOW_FEATURES,
options: PrompterWindowOptions = {},
): Window | null => {
const win = globalThis.window;
if (win === undefined) return null;

const url = buildPrompterUrl(baseUrl, win.location);
const presenterSessionId = options.presenterSessionId ?? getPresenterSessionId();
if (!isPresenterSessionId(presenterSessionId)) return null;

const url = buildPrompterUrl(baseUrl, win.location, presenterSessionId);
if (url === null) return null;

const opened = win.open(url, '_blank', withPopupIsolationFeatures(features));
Expand Down
73 changes: 73 additions & 0 deletions tests/system/presenter-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ import {
DEFAULT_PRESENTER_CHANNEL,
combinePresenterSources,
createPresenterBridge,
presenterChannelName,
} from '../../src/system/presenter/bridge';

// BroadcastChannel delivers messages on a macrotask; wait one out.
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 10));

const advance: PresenterCommand = { kind: 'advance' };
const importFreshBridge = async (): Promise<typeof import('../../src/system/presenter/bridge')> => {
vi.resetModules();
return import('../../src/system/presenter/bridge');
};

describe('createPresenterBridge', () => {
const bridges: { dispose(): void }[] = [];
Expand All @@ -27,12 +32,53 @@ describe('createPresenterBridge', () => {
afterEach(() => {
for (const b of bridges) b.dispose();
bridges.length = 0;
vi.unstubAllGlobals();
});

it('exposes the default channel name', () => {
expect(DEFAULT_PRESENTER_CHANNEL).toBe('pulsar-presenter');
});

it('derives scoped channel names from the presenter session id', () => {
expect(presenterChannelName('session-a-123456')).toBe('pulsar-presenter:session-a-123456');
});

it('rejects invalid presenter session ids before deriving channel names', () => {
expect(() => presenterChannelName('short')).toThrow(
'presenter session id must be 8-128 URL-safe characters',
);
});

it('generates one ephemeral session id when no URL scope exists', async () => {
vi.stubGlobal('crypto', { randomUUID: () => 'generated-session-123456' });
const { getPresenterSessionId } = await importFreshBridge();
expect(getPresenterSessionId({ href: 'https://pulsar.test/?composition=demo' })).toBe(
'generated-session-123456',
);
expect(getPresenterSessionId({ href: 'https://pulsar.test/?composition=other' })).toBe(
'generated-session-123456',
);
});

it('falls back to a generated session id when the bootstrap URL is unreadable', async () => {
vi.stubGlobal('crypto', { randomUUID: () => 'generated-session-abcdef' });
const { getPresenterSessionId } = await importFreshBridge();
const location = {
get href(): string {
throw new Error('bad href');
},
};
expect(getPresenterSessionId(location)).toBe('generated-session-abcdef');
});

it('reports missing secure random support when no URL scope exists', async () => {
vi.stubGlobal('crypto', {});
const { getPresenterSessionId } = await importFreshBridge();
expect(() => getPresenterSessionId({ href: 'https://pulsar.test/?composition=demo' })).toThrow(
'secure random presenter session id source is unavailable',
);
});

it('delivers a command sent from one window to another', async () => {
const channelName = `pulsar-test-${Math.random()}`;
const sender = track(createPresenterBridge({ channelName }));
Expand All @@ -44,6 +90,33 @@ describe('createPresenterBridge', () => {
expect(received).toEqual([advance]);
});

it('delivers commands within a session scope but not across different scopes', async () => {
const inSessionSender = track(createPresenterBridge({ sessionId: 'session-a-123456' }));
const inSessionReceiver = track(createPresenterBridge({ sessionId: 'session-a-123456' }));
const otherSessionReceiver = track(createPresenterBridge({ sessionId: 'session-b-123456' }));
const inSessionReceived: PresenterCommand[] = [];
const otherSessionReceived: PresenterCommand[] = [];
inSessionReceiver.source.subscribe((cmd) => inSessionReceived.push(cmd));
otherSessionReceiver.source.subscribe((cmd) => otherSessionReceived.push(cmd));
inSessionSender.send(advance);
await flush();
expect(inSessionReceived).toEqual([advance]);
expect(otherSessionReceived).toEqual([]);
});

it('keeps presenter-command validation on scoped channels', async () => {
const sessionId = 'session-a-123456';
const scopedChannel = presenterChannelName(sessionId);
const receiver = track(createPresenterBridge({ sessionId }));
const received: PresenterCommand[] = [];
receiver.source.subscribe((cmd) => received.push(cmd));
new BroadcastChannel(scopedChannel).postMessage({ kind: 'not-a-command' });
new BroadcastChannel(scopedChannel).postMessage({ sessionId, command: advance });
new BroadcastChannel(scopedChannel).postMessage(advance);
await flush();
expect(received).toEqual([advance]);
});

it('does not echo a command back to the sending window', async () => {
const channelName = `pulsar-test-${Math.random()}`;
const sender = track(createPresenterBridge({ channelName }));
Expand Down
Loading
Loading