diff --git a/packages/phoenix-event-display/src/event-display.ts b/packages/phoenix-event-display/src/event-display.ts index 216a2c36a..28b9afc31 100644 --- a/packages/phoenix-event-display/src/event-display.ts +++ b/packages/phoenix-event-display/src/event-display.ts @@ -20,6 +20,10 @@ import { buildEventSummaries, type EventSummary, } from './helpers/event-summary'; +import { + SessionManager, + type SessionManagerHost, +} from './managers/session-manager'; declare global { /** @@ -47,6 +51,14 @@ export class EventDisplay { private onDisplayedEventChange: ((nowDisplayingEvent: any) => void)[] = []; /** Generic event bus for integration with external frameworks. */ private eventBus: Map void>> = new Map(); + /** Wildcard subscribers fired on every emit (recorders, external bridges). */ + private eventBusWildcard: Set<(eventName: string, data: any) => void> = + new Set(); + /** Session recorder/player coordinator for #883 (lazy initialized). */ + private sessionManager: SessionManager | null = null; + /** Stored keydown handler for session recording shortcut (Shift+Ctrl+R). */ + private sessionRecordKeydownHandler: ((e: KeyboardEvent) => void) | null = + null; /** Three manager for three.js operations. */ private graphicsLibrary: ThreeManager; /** Info logger for storing event display logs. */ @@ -129,10 +141,18 @@ export class EventDisplay { document.removeEventListener('keydown', this.eventNavKeydownHandler); this.eventNavKeydownHandler = null; } + // Clean up session recording keyboard handler + if (this.sessionRecordKeydownHandler) { + document.removeEventListener('keydown', this.sessionRecordKeydownHandler); + this.sessionRecordKeydownHandler = null; + } + // Stop any active session recording or playback + this.sessionManager?.cleanup(); // Clear accumulated callbacks this.onEventsChange = []; this.onDisplayedEventChange = []; this.eventBus.clear(); + this.eventBusWildcard.clear(); // Reset singletons for clean view transition this.loadingManager?.reset(); this.stateManager?.resetForViewTransition(); @@ -170,6 +190,7 @@ export class EventDisplay { /** * Emit a named event on the integration event bus. + * Named subscribers fire first, then wildcard subscribers. * @param eventName The event name to emit. * @param data Data to pass to listeners. */ @@ -178,6 +199,84 @@ export class EventDisplay { if (listeners) { listeners.forEach((cb) => cb(data)); } + if (this.eventBusWildcard.size > 0) { + this.eventBusWildcard.forEach((cb) => cb(eventName, data)); + } + } + + /** + * Subscribe to every event-bus emission, regardless of name. + * Used by the session recorder (#883) and external bridges that want a + * single hook into all integration events. + * @param callback Invoked with (eventName, data) on every emit. + * @returns Unsubscribe function to remove the listener. + */ + public onAny(callback: (eventName: string, data: any) => void): () => void { + this.eventBusWildcard.add(callback); + return () => { + this.eventBusWildcard.delete(callback); + }; + } + + /** + * Get the SessionManager (#883) for recording and replaying exploration. + * Lazily instantiated so the manager is only created when the feature is used. + * @returns The SessionManager singleton for this EventDisplay. + */ + public getSessionManager(): SessionManager { + if (!this.sessionManager) { + this.sessionManager = new SessionManager(this.buildSessionHost()); + } + return this.sessionManager; + } + + /** + * Build the host adapter that bridges SessionManager to the live + * EventDisplay (event bus, state snapshot, camera apply). + */ + private buildSessionHost(): SessionManagerHost { + return { + onAny: (cb) => this.onAny(cb), + emit: (name, data) => this.emit(name, data), + getStateSnapshot: () => { + try { + return this.getStateManager()?.getStateAsJSON() ?? {}; + } catch { + return {}; + } + }, + applyStateSnapshot: (state) => { + try { + this.getStateManager()?.loadStateFromJSON(state); + } catch { + // ignore: replay still proceeds even if state apply fails + } + }, + getCameraSample: () => { + const stateManager = this.getStateManager(); + const activeCamera = stateManager?.activeCamera; + const controls = this.graphicsLibrary + ?.getControlsManager?.() + ?.getMainControls?.(); + if (!activeCamera || !controls) return null; + const pos = activeCamera.position; + const target = controls.target; + return { + pos: [pos.x, pos.y, pos.z], + target: [target.x, target.y, target.z], + }; + }, + applyCamera: (pos, target) => { + const activeCamera = this.getStateManager()?.activeCamera; + const controls = this.graphicsLibrary + ?.getControlsManager?.() + ?.getMainControls?.(); + if (!activeCamera || !controls) return; + activeCamera.position.set(pos[0], pos[1], pos[2]); + controls.target.set(target[0], target[1], target[2]); + controls.update(); + }, + }; } /** @@ -888,6 +987,26 @@ export class EventDisplay { } }; document.addEventListener('keydown', this.eventNavKeydownHandler); + + // Remove previous session recording listener if exists + if (this.sessionRecordKeydownHandler) { + document.removeEventListener('keydown', this.sessionRecordKeydownHandler); + } + + // Shift+Ctrl+R = toggle session recording (#883). Plain Ctrl+R is left + // alone so the browser refresh keeps working. + this.sessionRecordKeydownHandler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isTyping = ['input', 'textarea', 'select'].includes( + target?.tagName.toLowerCase(), + ); + if (isTyping) return; + if (!e.shiftKey || !(e.ctrlKey || e.metaKey)) return; + if (e.code !== 'KeyR') return; + e.preventDefault(); + this.getSessionManager().toggleRecording(); + }; + document.addEventListener('keydown', this.sessionRecordKeydownHandler); } /** diff --git a/packages/phoenix-event-display/src/index.ts b/packages/phoenix-event-display/src/index.ts index 2e73c8828..ad9025620 100644 --- a/packages/phoenix-event-display/src/index.ts +++ b/packages/phoenix-event-display/src/index.ts @@ -55,3 +55,4 @@ export * from './loaders/objects/phoenix-objects'; export * from './managers/state-manager'; export * from './managers/loading-manager'; export * from './managers/url-options-manager'; +export * from './managers/session-manager'; diff --git a/packages/phoenix-event-display/src/managers/session-manager/index.ts b/packages/phoenix-event-display/src/managers/session-manager/index.ts new file mode 100644 index 000000000..b4fe203c6 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/session-manager/index.ts @@ -0,0 +1,5 @@ +export * from './session-format'; +export * from './session-codec'; +export * from './session-recorder'; +export * from './session-player'; +export * from './session-manager'; diff --git a/packages/phoenix-event-display/src/managers/session-manager/session-codec.ts b/packages/phoenix-event-display/src/managers/session-manager/session-codec.ts new file mode 100644 index 000000000..58aa6649c --- /dev/null +++ b/packages/phoenix-event-display/src/managers/session-manager/session-codec.ts @@ -0,0 +1,168 @@ +import { + MAX_DECOMPRESSED_BYTES, + SessionV1, + validateSession, +} from './session-format'; + +/** + * Encode a session as a URL-safe base64 string of deflate-compressed JSON. + * Mirrors the share-link-dialog state encoding so the decoder can use the + * existing DecompressionStream('deflate') plumbing. + * @param session The session payload to encode. + * @returns Base64 string suitable for `?replay=` URLs. + */ +export async function encodeSessionToBase64( + session: SessionV1, +): Promise { + const json = JSON.stringify(session); + const input = new TextEncoder().encode(json); + const compressed = await pipeThroughStream( + input, + new CompressionStream('deflate'), + ); + let binary = ''; + for (let i = 0; i < compressed.length; i++) { + binary += String.fromCharCode(compressed[i]); + } + return btoa(binary); +} + +/** + * Pipe a Uint8Array through a transform stream and collect the output. + * Avoids Blob.stream() and Response, both of which jsdom lacks. + * @param input Source bytes. + * @param stream Transform stream (compression or decompression). + * @returns Concatenated output bytes. + */ +async function pipeThroughStream( + input: Uint8Array, + stream: CompressionStream | DecompressionStream, +): Promise { + const writer = stream.writable.getWriter(); + // Write+close concurrently with reading. Awaiting write/close before reading + // deadlocks on larger inputs: the transform's output buffer fills and write() + // blocks on backpressure waiting for a reader that has not started yet. + const writeDone = (async () => { + await writer.write(input); + await writer.close(); + })(); + const reader = stream.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.byteLength; + } + // Surface any write/close error now that the readable side has drained. + await writeDone; + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +/** + * Decode a base64 deflate-compressed session payload, validate it, and + * cap the decompressed output to defend against compression bombs. + * @param base64 Base64 input from a URL parameter or share link. + * @returns Validated SessionV1. + * @throws Error when the payload is malformed, oversized, or invalid. + */ +export async function decodeSessionFromBase64( + base64: string, +): Promise { + if (typeof base64 !== 'string' || base64.length === 0) { + throw new Error('Session payload is empty.'); + } + + let binary: string; + try { + binary = atob(base64); + } catch { + throw new Error('Session payload is not valid base64.'); + } + + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + + const ds = new DecompressionStream('deflate'); + const writer = ds.writable.getWriter(); + writer.write(bytes).catch(() => {}); + writer.close().catch(() => {}); + const reader = ds.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + let chunk: ReadableStreamReadResult; + try { + chunk = await reader.read(); + } catch (err) { + throw new Error('Session payload is not valid deflate-compressed data.'); + } + if (chunk.done) break; + total += chunk.value.byteLength; + if (total > MAX_DECOMPRESSED_BYTES) { + await reader.cancel().catch(() => {}); + throw new Error('Session payload exceeds decompressed size limit.'); + } + chunks.push(chunk.value); + } + + const decompressed = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + decompressed.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder().decode(decompressed); + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('Session payload is not valid JSON after decompression.'); + } + + const session = validateSession(parsed); + if (!session) { + throw new Error('Session payload failed schema validation.'); + } + return session; +} + +/** + * Serialize a session to a downloadable JSON Blob (compact, uncompressed) for + * sharing as a `.phnxreplay` file. Compact (not pretty-printed) keeps the file + * small while staying valid JSON that the `?session=` loader can re-read. + * @param session The session payload to serialize. + * @returns Blob with MIME type application/json. + */ +export function encodeSessionToBlob(session: SessionV1): Blob { + const json = JSON.stringify(session); + return new Blob([json], { type: 'application/json' }); +} + +/** + * Parse a raw JSON `.phnxreplay` file payload and validate it. + * @param text JSON text from a file upload. + * @returns Validated SessionV1. + * @throws Error when the payload is malformed or invalid. + */ +export function decodeSessionFromJson(text: string): SessionV1 { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('File is not valid JSON.'); + } + const session = validateSession(parsed); + if (!session) { + throw new Error('File failed session schema validation.'); + } + return session; +} diff --git a/packages/phoenix-event-display/src/managers/session-manager/session-format.ts b/packages/phoenix-event-display/src/managers/session-manager/session-format.ts new file mode 100644 index 000000000..ef99dd431 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/session-manager/session-format.ts @@ -0,0 +1,118 @@ +/** + * On-disk + URL-embeddable schema for Phoenix Sessions (issue #883). + * v1: deterministic semantic replay of event-bus emissions + camera samples. + * + * The replay is "semantic" (re-emits structured event-bus events on the + * receiving side) and not a pixel video, so the player keeps full 3D + * interactivity while events are scheduled at original timing. + */ + +/** Current session-format major version. Bumped on breaking schema change. */ +export const SESSION_VERSION = 1; + +/** Safety cap on a single decoded session size to defend against deflate bombs. */ +export const MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024; + +/** Hard cap on captured event count per recording. Auto-stop on overflow. */ +export const MAX_EVENTS_PER_SESSION = 50000; + +/** Hard cap on recording duration. Auto-stop on overflow. */ +export const MAX_RECORDING_DURATION_MS = 60 * 60 * 1000; + +/** Default camera sample interval. 10 samples/sec is smooth enough for 60fps replay. */ +export const CAMERA_SAMPLE_INTERVAL_MS = 100; + +/** Hard cap on a remote ?session=URL response size. */ +export const MAX_REMOTE_SESSION_BYTES = 10 * 1024 * 1024; + +/** A single recorded event-bus emission. */ +export interface SessionEvent { + /** Milliseconds since recording start. */ + t: number; + /** Event-bus event name passed to emit(). */ + name: string; + /** Event-bus payload, sanitized to be JSON-safe. */ + payload: any; +} + +/** A single recorded camera position + look-target sample. */ +export interface CameraSample { + /** Milliseconds since recording start. */ + t: number; + /** Camera position as [x, y, z]. */ + pos: [number, number, number]; + /** Controls target as [x, y, z]. */ + target: [number, number, number]; +} + +/** Metadata describing the dataset/route that produced the recording. */ +export interface SessionSource { + /** Experiment route identifier, e.g. 'atlas-masterclass'. */ + experiment?: string; + /** Event data file URL or name, if known. */ + file?: string; + /** Loader type ('json', 'jivexml', 'physlite', ...) if known. */ + type?: string; +} + +/** Complete session-replay payload (v1). */ +export interface SessionV1 { + /** Schema version. Must equal SESSION_VERSION for a v1 reader. */ + version: 1; + /** ISO timestamp of recording start. */ + createdAt: string; + /** Total recording duration in milliseconds. */ + duration: number; + /** Metadata about the dataset and route. */ + source: SessionSource; + /** State snapshot at recording start (camera, clipping, menu, cuts). */ + initialState: { [key: string]: any }; + /** Captured event-bus emissions, sorted by t. */ + events: SessionEvent[]; + /** Captured camera samples, sorted by t. */ + cameraSamples: CameraSample[]; +} + +/** Marker the player puts on payloads it emits so the recorder skips them. */ +export const REPLAY_PAYLOAD_MARKER = '__phoenixReplay'; + +/** + * Validate that an arbitrary value is a structurally-valid SessionV1. + * Defensive against malformed JSON, untrusted URL payloads and version + * skew from older or newer producers. + * @param value The parsed-JSON value to validate. + * @returns The value typed as SessionV1, or null when invalid. + */ +export function validateSession(value: unknown): SessionV1 | null { + if (!value || typeof value !== 'object') return null; + const s = value as Partial; + + if (s.version !== SESSION_VERSION) return null; + if (typeof s.createdAt !== 'string') return null; + if (typeof s.duration !== 'number' || !isFinite(s.duration) || s.duration < 0) + return null; + if (s.duration > MAX_RECORDING_DURATION_MS) return null; + if (!s.source || typeof s.source !== 'object') return null; + if (!s.initialState || typeof s.initialState !== 'object') return null; + if (!Array.isArray(s.events)) return null; + if (s.events.length > MAX_EVENTS_PER_SESSION) return null; + if (!Array.isArray(s.cameraSamples)) return null; + + for (const ev of s.events) { + if (!ev || typeof ev !== 'object') return null; + if (typeof ev.t !== 'number' || !isFinite(ev.t) || ev.t < 0) return null; + if (typeof ev.name !== 'string' || ev.name.length === 0) return null; + } + for (const cs of s.cameraSamples) { + if (!cs || typeof cs !== 'object') return null; + if (typeof cs.t !== 'number' || !isFinite(cs.t) || cs.t < 0) return null; + if (!Array.isArray(cs.pos) || cs.pos.length !== 3) return null; + if (!Array.isArray(cs.target) || cs.target.length !== 3) return null; + for (const n of cs.pos) + if (typeof n !== 'number' || !isFinite(n)) return null; + for (const n of cs.target) + if (typeof n !== 'number' || !isFinite(n)) return null; + } + + return s as SessionV1; +} diff --git a/packages/phoenix-event-display/src/managers/session-manager/session-manager.ts b/packages/phoenix-event-display/src/managers/session-manager/session-manager.ts new file mode 100644 index 000000000..57ecc57ae --- /dev/null +++ b/packages/phoenix-event-display/src/managers/session-manager/session-manager.ts @@ -0,0 +1,359 @@ +import { ActiveVariable } from '../../helpers/active-variable'; +import { + decodeSessionFromBase64, + decodeSessionFromJson, + encodeSessionToBase64, + encodeSessionToBlob, +} from './session-codec'; +import { SessionSource, SessionV1 } from './session-format'; +import { PlayerHost, SessionPlayer } from './session-player'; +import { RecorderHost, SessionRecorder } from './session-recorder'; + +/** + * Discriminated union the {@link SessionManager.state} observable carries. + * UI components subscribe to this to render the floating session pill. + */ +export type SessionState = + | { kind: 'idle' } + | { kind: 'recording'; recorder: SessionRecorder } + | { kind: 'recorded'; session: SessionV1 } + | { kind: 'pending'; session: SessionV1; sourceLabel: string } + | { kind: 'playing'; player: SessionPlayer; currentTime: number } + | { kind: 'paused'; player: SessionPlayer; currentTime: number } + | { kind: 'finished'; player: SessionPlayer } + | { kind: 'error'; message: string }; + +/** Host capabilities the SessionManager needs from the EventDisplay. */ +export interface SessionManagerHost extends RecorderHost, PlayerHost {} + +/** + * Coordinator that wires {@link SessionRecorder} and {@link SessionPlayer} + * to a single observable state. Exposed via EventDisplay.getSessionManager. + * Mirrors the singleton pattern of {@link StateManager}. + */ +export class SessionManager { + /** Reactive state for the UI pill. */ + public readonly state = new ActiveVariable({ kind: 'idle' }); + + /** Active recorder instance, or null when not recording. */ + private recorder: SessionRecorder | null = null; + /** Active player instance, or null when no replay is loaded. */ + private player: SessionPlayer | null = null; + /** Decoded-but-not-yet-played session awaiting explicit user confirmation. */ + private pendingSession: SessionV1 | null = null; + /** + * Share-link base64 precomputed when a recording stops, so the UI can copy + * it synchronously inside the click gesture (the Clipboard API rejects a + * write that happens after an await, which would consume the gesture). + */ + private sharableBase64Cache: string | null = null; + /** Unsubscribe for the active player's tick callback. */ + private tickUnsub: (() => void) | null = null; + /** Unsubscribe for the active player's complete callback. */ + private completeUnsub: (() => void) | null = null; + + /** + * Create a session manager bound to a host adapter. + * @param host Adapter exposing the event bus, state, and camera apply. + */ + constructor(private host: SessionManagerHost) {} + + /** True when a recording is currently capturing events. */ + public get isRecording(): boolean { + return this.recorder?.isRecording === true; + } + + /** True when a player is currently in the playing state. */ + public get isPlaying(): boolean { + return this.player?.isPlaying === true; + } + + /** Active recorder, if any. */ + public getRecorder(): SessionRecorder | null { + return this.recorder; + } + + /** Active player, if any. */ + public getPlayer(): SessionPlayer | null { + return this.player; + } + + /** + * Start a new recording (no-op if one is already running). + * @param source Optional dataset/route metadata recorded in the session. + */ + public startRecording(source?: SessionSource): void { + if (this.recorder?.isRecording) return; + this.stopPlaybackIfAny(); + this.recorder = new SessionRecorder(this.host); + this.recorder.start({ source }); + this.state.update({ kind: 'recording', recorder: this.recorder }); + } + + /** + * Stop the active recording and transition state to 'recorded' so the UI + * can offer download / share. Returns the finalized session. + */ + public stopRecording(): SessionV1 | null { + if (!this.recorder) return null; + const session = this.recorder.stop(); + this.recorder = null; + this.state.update({ kind: 'recorded', session }); + // Precompute the share link so the pill can copy it synchronously inside + // the user's click (compression is async and would otherwise expire the + // gesture, making navigator.clipboard.writeText reject). + this.sharableBase64Cache = null; + encodeSessionToBase64(session) + .then((b64) => { + this.sharableBase64Cache = b64; + }) + .catch(() => { + this.sharableBase64Cache = null; + }); + return session; + } + + /** + * Synchronously return the precomputed share-link base64 for the recorded + * session, or null if not recorded or the encode has not finished yet. + */ + public getCachedSharableBase64(): string | null { + if (this.state.value?.kind !== 'recorded') return null; + return this.sharableBase64Cache; + } + + /** + * Convenience: if recording, stop. Otherwise start. + * Bound to the Shift+Ctrl+R keyboard shortcut. + * @param source Optional source metadata used when starting a recording. + */ + public toggleRecording(source?: SessionSource): void { + if (this.recorder?.isRecording) { + this.stopRecording(); + } else { + this.startRecording(source); + } + } + + /** Discard the current recorded session and return to idle. */ + public clearRecorded(): void { + this.sharableBase64Cache = null; + if (this.state.value?.kind === 'recorded') { + this.state.update({ kind: 'idle' }); + } + } + + /** + * Decode + validate a base64 `?replay=` payload and stage it for playback, + * WITHOUT starting it. Sets state to 'pending' so the UI can require an + * explicit user click before replaying untrusted, link-supplied content. + * @param base64 Base64 input from the URL. + * @param sourceLabel Human-readable origin shown in the confirm UI. + * @throws Error if decoding/validation fails (also exposed via state.error). + */ + public async prepareFromBase64( + base64: string, + sourceLabel: string, + ): Promise { + try { + const session = await decodeSessionFromBase64(base64); + this.setPending(session, sourceLabel); + } catch (e) { + this.state.update({ kind: 'error', message: errorMessage(e) }); + throw e; + } + } + + /** + * Decode + validate a `.phnxreplay` JSON payload and stage it for playback + * without starting it (see {@link prepareFromBase64}). + * @param text JSON text contents. + * @param sourceLabel Human-readable origin shown in the confirm UI. + * @throws Error if validation fails. + */ + public prepareFromJsonText(text: string, sourceLabel: string): SessionV1 { + try { + const session = decodeSessionFromJson(text); + this.setPending(session, sourceLabel); + return session; + } catch (e) { + this.state.update({ kind: 'error', message: errorMessage(e) }); + throw e; + } + } + + /** + * Start the pending session after explicit user confirmation. No-op if the + * manager is not in the 'pending' state. + */ + public playPending(): void { + if (this.state.value?.kind !== 'pending' || !this.pendingSession) return; + const session = this.pendingSession; + this.pendingSession = null; + this.attachPlayer(new SessionPlayer(this.host, session)); + this.player?.play(); + } + + /** + * Load a session from a base64 ?replay= URL parameter and start playing. + * For programmatic/trusted callers; the URL path uses + * {@link prepareFromBase64} so link-supplied content is not auto-run. + * @param base64 Base64 input from the URL. + * @throws Error if decoding/validation fails (also exposed via state.error). + */ + public async loadAndPlayBase64(base64: string): Promise { + try { + const session = await decodeSessionFromBase64(base64); + this.attachPlayer(new SessionPlayer(this.host, session)); + this.player?.play(); + } catch (e) { + this.state.update({ kind: 'error', message: errorMessage(e) }); + throw e; + } + } + + /** + * Load a session from a `.phnxreplay` JSON file (e.g. uploaded by user). + * @param text JSON text contents of the file. + * @throws Error if validation fails. + */ + public loadFromJsonText(text: string): SessionV1 { + try { + const session = decodeSessionFromJson(text); + this.attachPlayer(new SessionPlayer(this.host, session)); + return session; + } catch (e) { + this.state.update({ kind: 'error', message: errorMessage(e) }); + throw e; + } + } + + /** Start playback on a loaded player (no-op if none loaded). */ + public play(): void { + this.player?.play(); + } + + /** Pause the active player. */ + public pause(): void { + if (!this.player) return; + this.player.pause(); + this.state.update({ + kind: 'paused', + player: this.player, + currentTime: this.player.currentTime, + }); + } + + /** Seek the active player to a specific playhead position. */ + public seek(t: number): void { + this.player?.seek(t); + } + + /** Change the active player's speed multiplier. */ + public setSpeed(speed: 0.5 | 1 | 2 | 4): void { + this.player?.setSpeed(speed); + } + + /** Stop and detach any active player. Resets state to idle. */ + public stopPlayback(): void { + this.stopPlaybackIfAny(); + this.pendingSession = null; + if (this.state.value?.kind !== 'recording') { + this.state.update({ kind: 'idle' }); + } + } + + /** + * Stage a decoded session as pending (awaiting user confirmation) and + * publish the 'pending' state for the UI. Stops any active playback first. + * @param session The validated session to stage. + * @param sourceLabel Human-readable origin (host or 'shared link'). + */ + private setPending(session: SessionV1, sourceLabel: string): void { + this.stopPlaybackIfAny(); + this.pendingSession = session; + this.state.update({ kind: 'pending', session, sourceLabel }); + } + + /** + * Encode the most recently recorded session as base64 for the share URL. + * @returns Base64 string, or null if there's no recorded session in state. + */ + public async getSharableBase64(): Promise { + if (this.state.value?.kind !== 'recorded') return null; + return encodeSessionToBase64(this.state.value.session); + } + + /** + * Produce a downloadable Blob of the most recently recorded session. + * @returns Blob (application/json) or null if no recorded session. + */ + public getDownloadBlob(): Blob | null { + if (this.state.value?.kind !== 'recorded') return null; + return encodeSessionToBlob(this.state.value.session); + } + + /** Release all resources. Called from EventDisplay.cleanup. */ + public cleanup(): void { + if (this.recorder?.isRecording) { + try { + this.recorder.stop(); + } catch { + // ignore + } + } + this.recorder = null; + this.pendingSession = null; + this.stopPlaybackIfAny(); + this.state.update({ kind: 'idle' }); + } + + /** + * Replace any active player with a new one and wire its tick/complete + * callbacks into the reactive state. Transitions state to 'playing'. + * @param player The player to attach. + */ + private attachPlayer(player: SessionPlayer): void { + this.stopPlaybackIfAny(); + this.player = player; + this.tickUnsub = player.onTick((t) => { + if (player.isPlaying) { + this.state.update({ kind: 'playing', player, currentTime: t }); + } + }); + this.completeUnsub = player.onComplete(() => { + this.state.update({ kind: 'finished', player }); + }); + this.state.update({ kind: 'playing', player, currentTime: 0 }); + } + + /** Stop and detach the active player and its callbacks, if any. */ + private stopPlaybackIfAny(): void { + if (this.player) { + try { + this.player.stop(); + } catch { + // ignore + } + } + if (this.tickUnsub) { + this.tickUnsub(); + this.tickUnsub = null; + } + if (this.completeUnsub) { + this.completeUnsub(); + this.completeUnsub = null; + } + this.player = null; + } +} + +/** + * Extract a human-readable message from an unknown thrown value. + * @param e The caught value. + * @returns The error message string. + */ +function errorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} diff --git a/packages/phoenix-event-display/src/managers/session-manager/session-player.ts b/packages/phoenix-event-display/src/managers/session-manager/session-player.ts new file mode 100644 index 000000000..e0d18d239 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/session-manager/session-player.ts @@ -0,0 +1,339 @@ +import { REPLAY_PAYLOAD_MARKER, SessionV1 } from './session-format'; + +/** + * Minimal subset of the EventDisplay surface the player needs. + * Decoupled so the player can be tested without a real Phoenix scene. + */ +export interface PlayerHost { + /** Re-emit an event onto the bus. Recorders should ignore replay-marked payloads. */ + emit(eventName: string, data?: any): void; + /** Restore the captured initial state snapshot before replay starts. */ + applyStateSnapshot(state: { [key: string]: any }): void; + /** Move the camera to position + look-target. Used between camera samples. */ + applyCamera( + pos: [number, number, number], + target: [number, number, number], + ): void; +} + +/** Playback speed multipliers exposed to the UI. */ +export type PlaybackSpeed = 0.5 | 1 | 2 | 4; + +/** + * Re-emits a recorded SessionV1 onto a PlayerHost (the live EventDisplay) at + * original timing. Supports scrubbing, time-warp, and graceful early stop. + * Player-emitted events carry a {@link REPLAY_PAYLOAD_MARKER} so the recorder + * does not capture them on a second pass. + */ +export class SessionPlayer { + /** Current playhead position in milliseconds. */ + private _currentTime = 0; + /** Active playback speed multiplier. */ + private _speed: PlaybackSpeed = 1; + /** Whether the player is actively advancing the playhead. */ + private _isPlaying = false; + /** Whether the playhead has reached the end at least once. */ + private _isFinished = false; + /** Index of the next event to emit from the session's events array. */ + private nextEventIndex = 0; + /** Wall-clock time when the current play run started. */ + private startWall = 0; + /** Playhead position when the current play run started. */ + private startSessionTime = 0; + /** Active animation-frame handle, or null when paused. */ + private rafId: number | null = null; + /** Subscribers notified on every playhead update. */ + private tickCallbacks: Set<(t: number) => void> = new Set(); + /** Subscribers notified once when the playhead reaches the end. */ + private completeCallbacks: Set<() => void> = new Set(); + /** Frame scheduler (injectable for tests / non-browser environments). */ + private rafImpl: (cb: FrameRequestCallback) => number; + /** Frame canceller paired with {@link rafImpl}. */ + private cancelImpl: (id: number) => void; + + /** + * Create a player for a recorded session. + * @param host Adapter that re-emits events and applies state/camera. + * @param session The validated session payload to replay. + * @param options Optional injectable requestAnimationFrame/cancel pair. + */ + constructor( + private host: PlayerHost, + public readonly session: SessionV1, + options?: { + raf?: typeof requestAnimationFrame; + cancelRaf?: typeof cancelAnimationFrame; + }, + ) { + this.rafImpl = + options?.raf ?? + (typeof requestAnimationFrame !== 'undefined' + ? requestAnimationFrame.bind(globalThis) + : (cb: FrameRequestCallback) => + setTimeout(() => cb(performance.now()), 16) as unknown as number); + this.cancelImpl = + options?.cancelRaf ?? + (typeof cancelAnimationFrame !== 'undefined' + ? cancelAnimationFrame.bind(globalThis) + : (id: number) => + clearTimeout(id as unknown as ReturnType)); + } + + /** Total recording length in milliseconds. */ + public get duration(): number { + return this.session.duration; + } + + /** Current playhead position in milliseconds. */ + public get currentTime(): number { + return this._currentTime; + } + + /** True between play() and pause()/stop()/end-of-stream. */ + public get isPlaying(): boolean { + return this._isPlaying; + } + + /** True after the playhead has reached duration once. */ + public get isFinished(): boolean { + return this._isFinished; + } + + /** Current playback speed multiplier. */ + public get speed(): PlaybackSpeed { + return this._speed; + } + + /** Number of events captured in the session (for the UI counter). */ + public get eventCount(): number { + return this.session.events.length; + } + + /** + * Start playback from the current playhead. If the playhead is at zero, + * the captured initial state is re-applied first. + */ + public play(): void { + if (this._isPlaying) return; + if (this._isFinished) { + this.seek(0); + this._isFinished = false; + } + if (this._currentTime === 0) { + this.applyInitialState(); + } + this._isPlaying = true; + this.startWall = Date.now(); + this.startSessionTime = this._currentTime; + this.scheduleTick(); + } + + /** Pause playback. Playhead position is preserved. */ + public pause(): void { + if (!this._isPlaying) return; + this._isPlaying = false; + if (this.rafId !== null) { + this.cancelImpl(this.rafId); + this.rafId = null; + } + } + + /** Stop playback and reset the playhead to zero. */ + public stop(): void { + this.pause(); + this._currentTime = 0; + this.nextEventIndex = 0; + this._isFinished = false; + } + + /** + * Move the playhead to t (clamped to [0, duration]). Forward seeks emit + * events between previous and new position. Backward seeks reapply the + * initial state and replay from zero up to t. + * @param t Target time in milliseconds. + */ + public seek(t: number): void { + const clamped = Math.max(0, Math.min(t, this.session.duration)); + const wasPlaying = this._isPlaying; + if (wasPlaying) this.pause(); + + const seekingFromStart = this._currentTime === 0 && clamped > 0; + const seekingBackwards = clamped < this._currentTime; + if (seekingFromStart || seekingBackwards) { + this.applyInitialState(); + this._currentTime = 0; + this.nextEventIndex = 0; + } + this.advanceTo(clamped); + this._currentTime = clamped; + this.applyCameraAt(clamped); + this._isFinished = clamped >= this.session.duration; + this.notifyTick(); + if (wasPlaying && !this._isFinished) this.play(); + } + + /** Change playback speed. Continuous if currently playing. */ + public setSpeed(speed: PlaybackSpeed): void { + if (this._speed === speed) return; + const wasPlaying = this._isPlaying; + if (wasPlaying) this.pause(); + this._speed = speed; + if (wasPlaying) this.play(); + } + + /** Subscribe to playhead updates. Returns an unsubscribe function. */ + public onTick(cb: (t: number) => void): () => void { + this.tickCallbacks.add(cb); + return () => this.tickCallbacks.delete(cb); + } + + /** Subscribe to end-of-stream. Returns an unsubscribe function. */ + public onComplete(cb: () => void): () => void { + this.completeCallbacks.add(cb); + return () => this.completeCallbacks.delete(cb); + } + + /** Schedule the next animation frame for the playback loop. */ + private scheduleTick(): void { + this.rafId = this.rafImpl(() => this.tick()); + } + + /** + * One iteration of the playback loop: advance the playhead by the elapsed + * wall-clock time scaled by the speed, emit due events, move the camera, + * and either schedule the next frame or finish. + */ + private tick(): void { + if (!this._isPlaying) return; + const elapsed = (Date.now() - this.startWall) * this._speed; + const target = Math.min( + this.startSessionTime + elapsed, + this.session.duration, + ); + this.advanceTo(target); + this._currentTime = target; + this.applyCameraAt(target); + this.notifyTick(); + if (target >= this.session.duration) { + this._isPlaying = false; + this._isFinished = true; + this.rafId = null; + this.completeCallbacks.forEach((cb) => cb()); + return; + } + this.scheduleTick(); + } + + /** Notify all tick subscribers of the current playhead position. */ + private notifyTick(): void { + this.tickCallbacks.forEach((cb) => cb(this._currentTime)); + } + + /** + * Emit every not-yet-emitted event whose timestamp is at or before target. + * @param target Playhead position in milliseconds. + */ + private advanceTo(target: number): void { + while ( + this.nextEventIndex < this.session.events.length && + this.session.events[this.nextEventIndex].t <= target + ) { + const ev = this.session.events[this.nextEventIndex]; + this.host.emit(ev.name, this.markPayload(ev.payload)); + this.nextEventIndex++; + } + } + + /** Re-apply the captured initial state, ignoring any apply failure. */ + private applyInitialState(): void { + try { + this.host.applyStateSnapshot(this.session.initialState); + } catch { + // ignore; replay still runs with whatever state is already loaded + } + } + + /** + * Position the camera for playhead t by interpolating between the two + * bracketing camera samples (or clamping to the first/last sample). + * @param t Playhead position in milliseconds. + */ + private applyCameraAt(t: number): void { + const samples = this.session.cameraSamples; + if (samples.length === 0) return; + + let beforeIdx = -1; + for (let i = 0; i < samples.length; i++) { + if (samples[i].t <= t) beforeIdx = i; + else break; + } + if (beforeIdx === -1) { + this.applyCameraSample(samples[0].pos, samples[0].target); + return; + } + const before = samples[beforeIdx]; + const after = samples[beforeIdx + 1]; + if (!after) { + this.applyCameraSample(before.pos, before.target); + return; + } + const span = after.t - before.t; + const alpha = span > 0 ? (t - before.t) / span : 0; + this.applyCameraSample( + [ + lerp(before.pos[0], after.pos[0], alpha), + lerp(before.pos[1], after.pos[1], alpha), + lerp(before.pos[2], after.pos[2], alpha), + ], + [ + lerp(before.target[0], after.target[0], alpha), + lerp(before.target[1], after.target[1], alpha), + lerp(before.target[2], after.target[2], alpha), + ], + ); + } + + /** + * Apply a single camera position/target to the host, ignoring failures. + * @param pos Camera position [x, y, z]. + * @param target Controls look-at target [x, y, z]. + */ + private applyCameraSample( + pos: [number, number, number], + target: [number, number, number], + ): void { + try { + this.host.applyCamera(pos, target); + } catch { + // ignore + } + } + + /** + * Wrap an event payload with the replay marker so the recorder ignores it + * if a recording is running during playback. Non-object payloads are + * boxed under a `value` key. + * @param payload The original recorded payload. + * @returns A marked payload safe to emit on the bus. + */ + private markPayload(payload: any): any { + if (payload === undefined || payload === null) { + return { [REPLAY_PAYLOAD_MARKER]: true }; + } + if (typeof payload === 'object' && !Array.isArray(payload)) { + return { ...payload, [REPLAY_PAYLOAD_MARKER]: true }; + } + return { value: payload, [REPLAY_PAYLOAD_MARKER]: true }; + } +} + +/** + * Linear interpolation between two numbers. + * @param a Start value. + * @param b End value. + * @param alpha Fraction in [0, 1]. + * @returns The interpolated value. + */ +function lerp(a: number, b: number, alpha: number): number { + return a + (b - a) * alpha; +} diff --git a/packages/phoenix-event-display/src/managers/session-manager/session-recorder.ts b/packages/phoenix-event-display/src/managers/session-manager/session-recorder.ts new file mode 100644 index 000000000..baa5156a9 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/session-manager/session-recorder.ts @@ -0,0 +1,238 @@ +import { + CAMERA_SAMPLE_INTERVAL_MS, + CameraSample, + MAX_EVENTS_PER_SESSION, + MAX_RECORDING_DURATION_MS, + REPLAY_PAYLOAD_MARKER, + SESSION_VERSION, + SessionEvent, + SessionSource, + SessionV1, +} from './session-format'; + +/** + * Minimal subset of the EventDisplay surface the recorder needs. + * Decoupled to keep the recorder framework-agnostic and testable without + * spinning up a full Phoenix instance. + */ +export interface RecorderHost { + /** Subscribe to every event-bus emission. */ + onAny(callback: (eventName: string, data: any) => void): () => void; + /** Read the current state snapshot (camera, clipping, menu, cuts). */ + getStateSnapshot(): { [key: string]: any }; + /** Read the current camera position + controls target, if available. */ + getCameraSample(): { + pos: [number, number, number]; + target: [number, number, number]; + } | null; +} + +/** Options accepted by {@link SessionRecorder.start}. */ +export interface RecorderStartOptions { + /** Metadata about the dataset and experiment route. */ + source?: SessionSource; + /** Override the camera sample interval. Tests use a shorter value. */ + cameraSampleIntervalMs?: number; +} + +/** + * Records event-bus emissions and periodic camera samples into a SessionV1 + * payload. Captures the initial state snapshot at start. Sanitizes payloads + * to JSON-safe values. Auto-stops on size or duration overflow. + */ +export class SessionRecorder { + /** Captured event-bus emissions in chronological order. */ + private events: SessionEvent[] = []; + /** Captured camera keyframes (deduplicated against the previous sample). */ + private cameraSamples: CameraSample[] = []; + /** State snapshot taken at the moment recording started. */ + private initialState: { [key: string]: any } = {}; + /** Dataset and experiment metadata recorded with the session. */ + private source: SessionSource = {}; + /** ISO timestamp of when recording started. */ + private createdAt = ''; + /** Wall-clock start time in milliseconds, used to compute event offsets. */ + private startWallTime = 0; + /** Handle for the camera sampling interval, or null when not recording. */ + private cameraIntervalId: ReturnType | null = null; + /** Unsubscribe function for the wildcard event-bus subscription. */ + private unsubscribeFromBus: (() => void) | null = null; + /** Whether the recorder is currently capturing. */ + private _isRecording = false; + /** Reason the recorder auto-stopped, or null if still running / stopped manually. */ + private _autoStopReason: 'cap-events' | 'cap-duration' | null = null; + /** Most recent camera sample, used to skip identical consecutive samples. */ + private lastSample: CameraSample | null = null; + + /** + * Create a recorder bound to a host adapter. + * @param host Adapter exposing the event bus, state snapshot, and camera. + */ + constructor(private host: RecorderHost) {} + + /** True while the recorder is actively capturing events and camera samples. */ + public get isRecording(): boolean { + return this._isRecording; + } + + /** Wall-clock milliseconds since the recording started. Zero if not recording. */ + public get duration(): number { + return this._isRecording ? Date.now() - this.startWallTime : 0; + } + + /** Number of event-bus emissions captured so far. */ + public get eventCount(): number { + return this.events.length; + } + + /** + * Reason recording auto-stopped, or null while still recording / stopped + * manually. Surfaces hard caps to the UI for an explanation toast. + */ + public get autoStopReason(): 'cap-events' | 'cap-duration' | null { + return this._autoStopReason; + } + + /** + * Begin a new recording. Snapshots the initial state, subscribes to the + * event bus, and starts the camera sampler. + * @param options Optional source metadata and sampler interval override. + * @throws Error if already recording. + */ + public start(options: RecorderStartOptions = {}): void { + if (this._isRecording) { + throw new Error( + 'SessionRecorder.start() called while already recording.', + ); + } + this._isRecording = true; + this._autoStopReason = null; + this.events = []; + this.cameraSamples = []; + this.source = options.source ?? {}; + this.createdAt = new Date().toISOString(); + this.startWallTime = Date.now(); + + try { + this.initialState = this.host.getStateSnapshot() ?? {}; + } catch { + this.initialState = {}; + } + + this.unsubscribeFromBus = this.host.onAny((name, data) => { + this.captureEvent(name, data); + }); + + const interval = + options.cameraSampleIntervalMs ?? CAMERA_SAMPLE_INTERVAL_MS; + this.cameraIntervalId = setInterval(() => this.sampleCamera(), interval); + this.sampleCamera(); + } + + /** + * Stop the recording and return the finalized session payload. + * Safe to call when not recording (returns an empty session). + */ + public stop(): SessionV1 { + if (this._isRecording) { + this.sampleCamera(); + } + this._isRecording = false; + if (this.unsubscribeFromBus) { + this.unsubscribeFromBus(); + this.unsubscribeFromBus = null; + } + if (this.cameraIntervalId !== null) { + clearInterval(this.cameraIntervalId); + this.cameraIntervalId = null; + } + + const duration = + this.events.length > 0 || this.cameraSamples.length > 0 + ? Math.max( + this.events.length ? this.events[this.events.length - 1].t : 0, + this.cameraSamples.length + ? this.cameraSamples[this.cameraSamples.length - 1].t + : 0, + ) + : 0; + + return { + version: SESSION_VERSION, + createdAt: this.createdAt || new Date().toISOString(), + duration, + source: this.source, + initialState: this.initialState, + events: this.events, + cameraSamples: this.cameraSamples, + }; + } + + /** + * Handle a single event-bus emission: skip replay-marked payloads, enforce + * size/duration caps, sanitize the payload to a JSON-safe value, and buffer it. + * @param name Event name from the bus. + * @param data Event payload (may be undefined or non-serializable). + */ + private captureEvent(name: string, data: any): void { + if (!this._isRecording) return; + if (data && typeof data === 'object' && data[REPLAY_PAYLOAD_MARKER]) return; + + const elapsed = Date.now() - this.startWallTime; + if (elapsed > MAX_RECORDING_DURATION_MS) { + this._autoStopReason = 'cap-duration'; + this.stop(); + return; + } + if (this.events.length >= MAX_EVENTS_PER_SESSION) { + this._autoStopReason = 'cap-events'; + this.stop(); + return; + } + + let safe: any; + try { + safe = data === undefined ? undefined : JSON.parse(JSON.stringify(data)); + } catch { + return; + } + this.events.push({ t: elapsed, name, payload: safe }); + } + + /** + * Read the current camera state and buffer it unless it is identical to the + * previous sample. Silently ignores failures (e.g. no active camera yet). + */ + private sampleCamera(): void { + if (!this._isRecording) return; + let sample: { + pos: [number, number, number]; + target: [number, number, number]; + } | null = null; + try { + sample = this.host.getCameraSample(); + } catch { + sample = null; + } + if (!sample) return; + + if (this.lastSample) { + const samePos = + this.lastSample.pos[0] === sample.pos[0] && + this.lastSample.pos[1] === sample.pos[1] && + this.lastSample.pos[2] === sample.pos[2]; + const sameTarget = + this.lastSample.target[0] === sample.target[0] && + this.lastSample.target[1] === sample.target[1] && + this.lastSample.target[2] === sample.target[2]; + if (samePos && sameTarget) return; + } + const next: CameraSample = { + t: Date.now() - this.startWallTime, + pos: sample.pos, + target: sample.target, + }; + this.cameraSamples.push(next); + this.lastSample = next; + } +} diff --git a/packages/phoenix-event-display/src/managers/url-options-manager.ts b/packages/phoenix-event-display/src/managers/url-options-manager.ts index 8e4b97dad..c2de5cf58 100644 --- a/packages/phoenix-event-display/src/managers/url-options-manager.ts +++ b/packages/phoenix-event-display/src/managers/url-options-manager.ts @@ -4,6 +4,7 @@ import type { Configuration } from '../lib/types/configuration'; import { EventDisplay } from '../event-display'; import { StateManager } from './state-manager'; import { readZipFile } from '../helpers/zip'; +import { MAX_REMOTE_SESSION_BYTES } from './session-manager'; /** * Model for Phoenix URL options. @@ -15,6 +16,10 @@ export const phoenixURLOptions = { state: '', hideWidgets: false, embed: false, + /** Inline base64-deflate session replay payload (#883). */ + replay: '', + /** Remote URL pointing at a `.phnxreplay` JSON file (#883). */ + session: '', }; /** @@ -50,6 +55,7 @@ export class URLOptionsManager { ); this.applyHideWidgetsOptions(); this.applyEmbedOption(); + this.applySessionReplayOption(); } /** @@ -330,4 +336,114 @@ export class URLOptionsManager { public getURLOptions() { return this.urlOptions; } + + /** + * Apply session replay options from the URL: `?replay=` for + * inline sessions, `?session=` for remote `.phnxreplay` JSON files. + * + * The payload is decoded and validated, then STAGED as pending; it does not + * auto-play. Link-supplied content is untrusted, so the floating pill shows + * the source and requires an explicit click before replaying. Deferred to + * the load listener so the event/config/state apply first. + */ + private applySessionReplayOption() { + const replayParam = this.urlOptions.get('replay'); + const sessionParam = this.urlOptions.get('session'); + if (!replayParam && !sessionParam) return; + + const stageReplay = async () => { + const sessionManager = this.eventDisplay.getSessionManager(); + try { + if (replayParam) { + await sessionManager.prepareFromBase64(replayParam, 'shared link'); + } else if (sessionParam) { + const { text, host } = await this.fetchRemoteSession(sessionParam); + sessionManager.prepareFromJsonText(text, host); + } + } catch (error) { + console.error('Failed to load session replay from URL.', error); + this.eventDisplay + .getInfoLogger() + .add('Could not load session replay from URL.', 'Error'); + } + }; + + this.eventDisplay.getLoadingManager().addLoadListenerWithCheck(() => { + setTimeout(stageReplay, 400); + }); + } + + /** + * Fetch a remote `.phnxreplay` JSON file for the `?session=` option. + * Hardened against abuse of the URL parameter: + * - scheme allowlist (http/https only), blocking javascript:/data:/file: + * - `credentials: 'omit'` so no cookies leak cross-origin + * - `referrerPolicy: 'no-referrer'` so the target learns nothing about us + * - a hard request timeout so a hung server cannot pin the loader + * - a streamed size cap so an oversized body is aborted mid-download + * instead of being fully buffered first + * @param rawUrl The user-supplied URL from `?session=`. + * @returns The raw JSON text and the resolved host (for the confirm UI). + * @throws Error on disallowed scheme, non-2xx, timeout, or oversize body. + */ + private async fetchRemoteSession( + rawUrl: string, + ): Promise<{ text: string; host: string }> { + let parsed: URL; + try { + parsed = new URL(rawUrl, window.location.origin); + } catch { + throw new Error('Invalid session URL.'); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error( + `Session URL scheme "${parsed.protocol}" is not allowed.`, + ); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + try { + const res = await fetch(parsed.toString(), { + credentials: 'omit', + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`Session fetch failed with status ${res.status}.`); + } + + // Stream the body and abort as soon as the running total exceeds the + // cap, rather than buffering the whole (possibly huge) response first. + const reader = res.body?.getReader(); + if (!reader) { + const text = await res.text(); + if (text.length > MAX_REMOTE_SESSION_BYTES) { + throw new Error('Session file is too large.'); + } + return { text, host: parsed.host }; + } + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_REMOTE_SESSION_BYTES) { + controller.abort(); + throw new Error('Session file is too large.'); + } + chunks.push(value); + } + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return { text: new TextDecoder().decode(merged), host: parsed.host }; + } finally { + clearTimeout(timeout); + } + } } diff --git a/packages/phoenix-event-display/src/tests/managers/session-manager/manager.test.ts b/packages/phoenix-event-display/src/tests/managers/session-manager/manager.test.ts new file mode 100644 index 000000000..89a5c9fed --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/session-manager/manager.test.ts @@ -0,0 +1,86 @@ +import { + SessionManager, + SessionManagerHost, +} from '../../../managers/session-manager/session-manager'; +import { SessionV1 } from '../../../managers/session-manager/session-format'; + +function makeHost() { + const emits: Array<{ n: string; d: any }> = []; + const host: SessionManagerHost = { + onAny: () => () => {}, + getStateSnapshot: () => ({}), + getCameraSample: () => null, + emit: (n, d) => emits.push({ n, d }), + applyStateSnapshot: () => {}, + applyCamera: () => {}, + }; + return { host, emits }; +} + +function sessionJson(): string { + const s: SessionV1 = { + version: 1, + createdAt: '2026-06-06T00:00:00.000Z', + duration: 500, + source: { experiment: 'atlas' }, + initialState: {}, + events: [{ t: 100, name: 'particle-tagged', payload: { uuid: 'a' } }], + cameraSamples: [], + }; + return JSON.stringify(s); +} + +describe('SessionManager pending (click-to-play) flow', () => { + it('prepareFromJsonText stages a pending session WITHOUT emitting events', () => { + const { host, emits } = makeHost(); + const sm = new SessionManager(host); + sm.prepareFromJsonText(sessionJson(), 'example.com'); + + const state = sm.state.value; + expect(state?.kind).toBe('pending'); + if (state?.kind === 'pending') { + expect(state.sourceLabel).toBe('example.com'); + expect(state.session.events.length).toBe(1); + } + // Critical: nothing replayed on the bus until the user confirms. + expect(emits.length).toBe(0); + }); + + it('playPending starts playback and re-emits the recorded event', () => { + const { host, emits } = makeHost(); + const sm = new SessionManager(host); + sm.prepareFromJsonText(sessionJson(), 'example.com'); + sm.playPending(); + + expect(['playing', 'finished']).toContain(sm.state.value?.kind); + // Drive past the event timestamp via a seek to flush the emission. + sm.seek(500); + expect(emits.map((e) => e.n)).toContain('particle-tagged'); + }); + + it('playPending is a no-op when there is no pending session', () => { + const { host, emits } = makeHost(); + const sm = new SessionManager(host); + sm.playPending(); + expect(sm.state.value?.kind).toBe('idle'); + expect(emits.length).toBe(0); + }); + + it('stopPlayback discards a pending session back to idle without playing', () => { + const { host, emits } = makeHost(); + const sm = new SessionManager(host); + sm.prepareFromJsonText(sessionJson(), 'example.com'); + sm.stopPlayback(); + expect(sm.state.value?.kind).toBe('idle'); + sm.playPending(); // must not resurrect the dismissed session + expect(emits.length).toBe(0); + expect(sm.state.value?.kind).toBe('idle'); + }); + + it('rejects malformed JSON and surfaces an error state', () => { + const { host } = makeHost(); + const sm = new SessionManager(host); + expect(() => sm.prepareFromJsonText('{not json', 'example.com')).toThrow(); + expect(sm.state.value?.kind).toBe('error'); + }); +}); diff --git a/packages/phoenix-event-display/src/tests/managers/session-manager/player.test.ts b/packages/phoenix-event-display/src/tests/managers/session-manager/player.test.ts new file mode 100644 index 000000000..59e99e037 --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/session-manager/player.test.ts @@ -0,0 +1,118 @@ +import { + REPLAY_PAYLOAD_MARKER, + SessionV1, +} from '../../../managers/session-manager/session-format'; +import { + PlayerHost, + SessionPlayer, +} from '../../../managers/session-manager/session-player'; + +function makeHost() { + const emits: Array<{ n: string; d: any }> = []; + const cameras: Array<{ pos: number[]; target: number[] }> = []; + let stateApplies = 0; + const host: PlayerHost = { + emit: (n, d) => emits.push({ n, d }), + applyStateSnapshot: () => { + stateApplies++; + }, + applyCamera: (pos, target) => + cameras.push({ pos: [...pos], target: [...target] }), + }; + return { + host, + emits, + cameras, + get stateApplies() { + return stateApplies; + }, + }; +} + +function makeSession(): SessionV1 { + return { + version: 1, + createdAt: '2026-06-02T00:00:00.000Z', + duration: 1000, + source: {}, + initialState: { ok: true }, + events: [ + { t: 100, name: 'a', payload: { x: 1 } }, + { t: 500, name: 'b', payload: { y: 2 } }, + { t: 900, name: 'c', payload: null }, + ], + cameraSamples: [ + { t: 0, pos: [0, 0, 0], target: [0, 0, 0] }, + { t: 500, pos: [10, 0, 0], target: [0, 0, 0] }, + { t: 1000, pos: [20, 0, 0], target: [0, 0, 0] }, + ], + }; +} + +describe('SessionPlayer', () => { + it('seek(end) re-emits every event with the replay marker', () => { + const ctx = makeHost(); + const player = new SessionPlayer(ctx.host, makeSession()); + player.seek(1000); + expect(ctx.emits.length).toBe(3); + for (const e of ctx.emits) { + expect(e.d[REPLAY_PAYLOAD_MARKER]).toBe(true); + } + expect(player.isFinished).toBe(true); + }); + + it('seek wraps non-object payloads under {value, __phoenixReplay}', () => { + const ctx = makeHost(); + const player = new SessionPlayer(ctx.host, makeSession()); + player.seek(1000); + const last = ctx.emits[2]; + expect(last.n).toBe('c'); + expect(last.d[REPLAY_PAYLOAD_MARKER]).toBe(true); + }); + + it('seek backwards re-applies initial state then replays up to t', () => { + const ctx = makeHost(); + const player = new SessionPlayer(ctx.host, makeSession()); + player.seek(1000); + expect(ctx.stateApplies).toBe(1); + player.seek(200); + expect(ctx.stateApplies).toBe(2); + const lastBatch = ctx.emits.slice(3); + expect(lastBatch.length).toBe(1); + expect(lastBatch[0].n).toBe('a'); + }); + + it('applyCamera interpolates between bracketing samples', () => { + const ctx = makeHost(); + const player = new SessionPlayer(ctx.host, makeSession()); + player.seek(250); + const last = ctx.cameras[ctx.cameras.length - 1]; + expect(last.pos[0]).toBeCloseTo(5, 1); + }); + + it('setSpeed before play does not throw and persists', () => { + const ctx = makeHost(); + const player = new SessionPlayer(ctx.host, makeSession()); + player.setSpeed(2); + expect(player.speed).toBe(2); + }); + + it('onTick fires on seek with current playhead', () => { + const ctx = makeHost(); + const player = new SessionPlayer(ctx.host, makeSession()); + const ticks: number[] = []; + player.onTick((t) => ticks.push(t)); + player.seek(500); + expect(ticks.pop()).toBe(500); + }); + + it('exposes session metadata on the public API', () => { + const ctx = makeHost(); + const session = makeSession(); + const player = new SessionPlayer(ctx.host, session); + expect(player.duration).toBe(1000); + expect(player.eventCount).toBe(3); + expect(player.speed).toBe(1); + expect(player.session).toBe(session); + }); +}); diff --git a/packages/phoenix-event-display/src/tests/managers/session-manager/session-codec.test.ts b/packages/phoenix-event-display/src/tests/managers/session-manager/session-codec.test.ts new file mode 100644 index 000000000..7598f177a --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/session-manager/session-codec.test.ts @@ -0,0 +1,188 @@ +/** + * @jest-environment jsdom + */ +import { + CompressionStream as NodeCompressionStream, + DecompressionStream as NodeDecompressionStream, +} from 'node:stream/web'; +import { + TextDecoder as NodeTextDecoder, + TextEncoder as NodeTextEncoder, +} from 'node:util'; + +// jsdom in the project's older test runtime lacks several Web APIs the codec +// uses in production browsers. Patch them from Node.js so the codec is +// exercisable end-to-end. None of these polyfills ship to runtime code. +beforeAll(() => { + if (typeof globalThis.CompressionStream === 'undefined') { + (globalThis as any).CompressionStream = NodeCompressionStream; + } + if (typeof globalThis.DecompressionStream === 'undefined') { + (globalThis as any).DecompressionStream = NodeDecompressionStream; + } + if (typeof globalThis.TextEncoder === 'undefined') { + (globalThis as any).TextEncoder = NodeTextEncoder; + } + if (typeof globalThis.TextDecoder === 'undefined') { + (globalThis as any).TextDecoder = NodeTextDecoder; + } +}); + +import { + decodeSessionFromBase64, + decodeSessionFromJson, + encodeSessionToBase64, + encodeSessionToBlob, +} from '../../../managers/session-manager/session-codec'; +import { + SESSION_VERSION, + SessionV1, +} from '../../../managers/session-manager/session-format'; + +function makeSession(): SessionV1 { + return { + version: SESSION_VERSION, + createdAt: '2026-06-02T00:00:00.000Z', + duration: 1234, + source: { experiment: 'atlas' }, + initialState: { foo: 1 }, + events: [ + { t: 100, name: 'particle-tagged', payload: { uuid: 'u1' } }, + { t: 500, name: 'result-recorded', payload: { mass: 91 } }, + ], + cameraSamples: [ + { t: 0, pos: [1, 2, 3], target: [0, 0, 0] }, + { t: 1000, pos: [2, 3, 4], target: [0, 0, 0] }, + ], + }; +} + +describe('session-codec', () => { + it('encodes and decodes a session round-trip via base64', async () => { + const session = makeSession(); + const b64 = await encodeSessionToBase64(session); + expect(typeof b64).toBe('string'); + expect(b64.length).toBeGreaterThan(0); + const decoded = await decodeSessionFromBase64(b64); + expect(decoded).toEqual(session); + }); + + it('produces a smaller payload via base64 than raw JSON', async () => { + const session = makeSession(); + const b64 = await encodeSessionToBase64(session); + const raw = JSON.stringify(session); + expect(b64.length).toBeLessThanOrEqual(raw.length + 32); + }); + + it('rejects invalid base64 input with a clear error', async () => { + await expect(decodeSessionFromBase64('!!!not-base64!!!')).rejects.toThrow( + /base64/i, + ); + }); + + it('rejects empty input', async () => { + await expect(decodeSessionFromBase64('')).rejects.toThrow(/empty/i); + }); + + it('rejects malformed JSON inside valid deflate', async () => { + const encoded = await encodeArbitraryToBase64('this is not json at all'); + await expect(decodeSessionFromBase64(encoded)).rejects.toThrow(/json/i); + }); + + it('rejects a payload that fails schema validation', async () => { + const encoded = await encodeArbitraryToBase64( + JSON.stringify({ version: 99 }), + ); + await expect(decodeSessionFromBase64(encoded)).rejects.toThrow(/schema/i); + }); + + // Helper that compresses arbitrary text via CompressionStream and returns + // the base64 of the deflate stream output. Used to fabricate edge-case + // payloads (malformed JSON, wrong version) for the decoder. + async function encodeArbitraryToBase64(text: string): Promise { + const input = new TextEncoder().encode(text); + const cs = new CompressionStream('deflate'); + const writer = cs.writable.getWriter(); + writer.write(input).catch(() => {}); + writer.close().catch(() => {}); + const reader = cs.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.byteLength; + } + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + let binary = ''; + for (let i = 0; i < merged.length; i++) + binary += String.fromCharCode(merged[i]); + return btoa(binary); + } + + it('encodes a large session without deadlocking, and round-trips it', async () => { + // A session whose JSON far exceeds the CompressionStream internal buffer. + // Regression guard: awaiting write/close before reading the output + // deadlocks here (the real Copy-link bug on big sessions). + const big = makeSession(); + big.initialState = { menu: 'x'.repeat(300 * 1024) }; + const b64 = await encodeSessionToBase64(big); + expect(b64.length).toBeGreaterThan(0); + const decoded = await decodeSessionFromBase64(b64); + expect(decoded.initialState).toEqual(big.initialState); + expect(decoded.events.length).toBe(big.events.length); + }); + + it('rejects a compression bomb that exceeds the decompressed size cap', async () => { + // ~60 MB of highly-compressible zeros deflates to a tiny payload but + // would blow past MAX_DECOMPRESSED_BYTES (50 MB) when inflated. + const huge = new Uint8Array(60 * 1024 * 1024); + const cs = new CompressionStream('deflate'); + const writer = cs.writable.getWriter(); + writer.write(huge).catch(() => {}); + writer.close().catch(() => {}); + const reader = cs.readable.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.byteLength; + } + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + let binary = ''; + for (let i = 0; i < merged.length; i++) + binary += String.fromCharCode(merged[i]); + await expect(decodeSessionFromBase64(btoa(binary))).rejects.toThrow( + /size limit/i, + ); + }); + + it('produces an application/json blob via encodeSessionToBlob', () => { + const blob = encodeSessionToBlob(makeSession()); + expect(blob.type).toBe('application/json'); + expect(blob.size).toBeGreaterThan(0); + }); + + it('decodes a raw JSON session text correctly', () => { + const session = makeSession(); + const text = JSON.stringify(session); + expect(decodeSessionFromJson(text)).toEqual(session); + }); + + it('rejects raw JSON of invalid shape', () => { + expect(() => decodeSessionFromJson('{"version":99}')).toThrow(); + }); +}); diff --git a/packages/phoenix-event-display/src/tests/managers/session-manager/session-format.test.ts b/packages/phoenix-event-display/src/tests/managers/session-manager/session-format.test.ts new file mode 100644 index 000000000..2a7f7c618 --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/session-manager/session-format.test.ts @@ -0,0 +1,111 @@ +import { + MAX_EVENTS_PER_SESSION, + MAX_RECORDING_DURATION_MS, + SESSION_VERSION, + SessionEvent, + CameraSample, + validateSession, +} from '../../../managers/session-manager/session-format'; + +function validBase(): { + version: number; + createdAt: string; + duration: number; + source: { experiment: string }; + initialState: Record; + events: SessionEvent[]; + cameraSamples: CameraSample[]; +} { + return { + version: SESSION_VERSION, + createdAt: new Date().toISOString(), + duration: 5000, + source: { experiment: 'atlas' }, + initialState: {}, + events: [{ t: 0, name: 'particle-tagged', payload: { uuid: 'u' } }], + cameraSamples: [{ t: 0, pos: [0, 0, 0], target: [0, 0, 0] }], + }; +} + +describe('session-format.validateSession', () => { + it('accepts a well-formed session', () => { + expect(validateSession(validBase())).not.toBeNull(); + }); + + it('rejects null and non-objects', () => { + expect(validateSession(null)).toBeNull(); + expect(validateSession(undefined)).toBeNull(); + expect(validateSession(42)).toBeNull(); + expect(validateSession('session')).toBeNull(); + }); + + it('rejects wrong version', () => { + const s = validBase(); + (s as any).version = 99; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects non-string createdAt', () => { + const s = validBase(); + (s as any).createdAt = 1234; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects negative duration', () => { + const s = validBase(); + s.duration = -1; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects oversized duration', () => { + const s = validBase(); + s.duration = MAX_RECORDING_DURATION_MS + 1; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects missing source', () => { + const s = validBase(); + (s as any).source = null; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects events not an array', () => { + const s = validBase(); + (s as any).events = 'not array'; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects too many events', () => { + const s = validBase(); + s.events = new Array(MAX_EVENTS_PER_SESSION + 1).fill({ + t: 0, + name: 'x', + payload: {}, + }); + expect(validateSession(s)).toBeNull(); + }); + + it('rejects event with empty name', () => { + const s = validBase(); + s.events = [{ t: 0, name: '', payload: {} }]; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects event with negative t', () => { + const s = validBase(); + s.events = [{ t: -1, name: 'x', payload: {} }]; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects camera sample with NaN component', () => { + const s = validBase(); + s.cameraSamples = [{ t: 0, pos: [0, NaN, 0], target: [0, 0, 0] }]; + expect(validateSession(s)).toBeNull(); + }); + + it('rejects camera sample with wrong pos length', () => { + const s = validBase(); + s.cameraSamples = [{ t: 0, pos: [0, 0] as any, target: [0, 0, 0] }]; + expect(validateSession(s)).toBeNull(); + }); +}); diff --git a/packages/phoenix-event-display/src/tests/managers/session-manager/session-recorder.test.ts b/packages/phoenix-event-display/src/tests/managers/session-manager/session-recorder.test.ts new file mode 100644 index 000000000..376bcc0f1 --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/session-manager/session-recorder.test.ts @@ -0,0 +1,134 @@ +import { + RecorderHost, + SessionRecorder, +} from '../../../managers/session-manager/session-recorder'; +import { + MAX_EVENTS_PER_SESSION, + MAX_RECORDING_DURATION_MS, + REPLAY_PAYLOAD_MARKER, +} from '../../../managers/session-manager/session-format'; + +function makeHost(): { + host: RecorderHost; + emit: (n: string, d: any) => void; + setSample: (s: any) => void; +} { + const wildcardSubs = new Set<(n: string, d: any) => void>(); + let sample: any = { pos: [0, 0, 0], target: [0, 0, 0] }; + const host: RecorderHost = { + onAny: (cb) => { + wildcardSubs.add(cb); + return () => wildcardSubs.delete(cb); + }, + getStateSnapshot: () => ({ snapshot: true }), + getCameraSample: () => sample, + }; + return { + host, + emit: (name, data) => wildcardSubs.forEach((cb) => cb(name, data)), + setSample: (s) => (sample = s), + }; +} + +describe('SessionRecorder', () => { + it('records events and returns a v1 session on stop', () => { + const { host, emit } = makeHost(); + const rec = new SessionRecorder(host); + rec.start({ source: { experiment: 'test' } }); + emit('a', { x: 1 }); + emit('b', { y: 2 }); + const session = rec.stop(); + expect(session.version).toBe(1); + expect(session.events.length).toBe(2); + expect(session.events[0].name).toBe('a'); + expect(session.source.experiment).toBe('test'); + expect(session.initialState).toEqual({ snapshot: true }); + }); + + it('throws when start is called while already recording', () => { + const { host } = makeHost(); + const rec = new SessionRecorder(host); + rec.start(); + expect(() => rec.start()).toThrow(); + rec.stop(); + }); + + it('returns an empty session when stop is called twice', () => { + const { host, emit } = makeHost(); + const rec = new SessionRecorder(host); + rec.start(); + emit('a', {}); + rec.stop(); + const s2 = rec.stop(); + expect(s2.events.length).toBe(1); + }); + + it('ignores replay-marked payloads to prevent feedback loops', () => { + const { host, emit } = makeHost(); + const rec = new SessionRecorder(host); + rec.start(); + emit('a', { [REPLAY_PAYLOAD_MARKER]: true }); + emit('b', { x: 1 }); + const session = rec.stop(); + expect(session.events.map((e) => e.name)).toEqual(['b']); + }); + + it('skips payloads that are not JSON-serializable', () => { + const { host, emit } = makeHost(); + const rec = new SessionRecorder(host); + rec.start(); + const circular: any = { name: 'x' }; + circular.self = circular; + emit('a', circular); + emit('b', { y: 2 }); + const session = rec.stop(); + expect(session.events.map((e) => e.name)).toEqual(['b']); + }); + + it('captures camera samples and deduplicates identical positions', () => { + jest.useFakeTimers(); + const { host } = makeHost(); + const rec = new SessionRecorder(host); + rec.start({ cameraSampleIntervalMs: 10 }); + jest.advanceTimersByTime(50); + const session = rec.stop(); + expect(session.cameraSamples.length).toBe(1); + jest.useRealTimers(); + }); + + it('updates duration as events arrive', () => { + jest.useFakeTimers(); + const { host, emit } = makeHost(); + const rec = new SessionRecorder(host); + rec.start(); + jest.advanceTimersByTime(120); + emit('a', { x: 1 }); + const session = rec.stop(); + expect(session.events[0].t).toBeGreaterThanOrEqual(120); + jest.useRealTimers(); + }); + + it('auto-stops when the event cap is exceeded', () => { + const { host, emit } = makeHost(); + const rec = new SessionRecorder(host); + rec.start(); + for (let i = 0; i < MAX_EVENTS_PER_SESSION + 5; i++) { + emit('e', { i }); + } + expect(rec.isRecording).toBe(false); + expect(rec.autoStopReason).toBe('cap-events'); + expect(rec.eventCount).toBe(MAX_EVENTS_PER_SESSION); + }); + + it('auto-stops when the duration cap is exceeded', () => { + jest.useFakeTimers(); + const { host, emit } = makeHost(); + const rec = new SessionRecorder(host); + rec.start(); + jest.advanceTimersByTime(MAX_RECORDING_DURATION_MS + 1000); + emit('late', { x: 1 }); + expect(rec.isRecording).toBe(false); + expect(rec.autoStopReason).toBe('cap-duration'); + jest.useRealTimers(); + }); +}); diff --git a/packages/phoenix-event-display/src/tests/managers/url-options-manager.test.ts b/packages/phoenix-event-display/src/tests/managers/url-options-manager.test.ts index 6085307b8..8d48b421a 100644 --- a/packages/phoenix-event-display/src/tests/managers/url-options-manager.test.ts +++ b/packages/phoenix-event-display/src/tests/managers/url-options-manager.test.ts @@ -1,6 +1,22 @@ /** * @jest-environment jsdom */ +import { + TextDecoder as NodeTextDecoder, + TextEncoder as NodeTextEncoder, +} from 'node:util'; + +// jsdom in this runtime lacks TextEncoder/TextDecoder, which the streamed +// remote-session reader uses. Patch from Node; not shipped to runtime code. +beforeAll(() => { + if (typeof globalThis.TextEncoder === 'undefined') { + (globalThis as any).TextEncoder = NodeTextEncoder; + } + if (typeof globalThis.TextDecoder === 'undefined') { + (globalThis as any).TextDecoder = NodeTextDecoder; + } +}); + import { EventDisplay } from '../../event-display'; import { URLOptionsManager } from '../../managers/url-options-manager'; import { Configuration } from '../../lib/types/configuration'; @@ -102,4 +118,76 @@ describe('URLOptionsManager', () => { it('should get options from URL set through query parameters', () => { expect(urlOptionsManager.getURLOptions()).toBeInstanceOf(URLSearchParams); }); + + describe('session replay (#883) remote fetch validation', () => { + it.each(['javascript:alert(1)', 'data:text/json,{}', 'file:///etc/passwd'])( + 'rejects disallowed URL scheme: %s', + async (badUrl) => { + await expect( + urlOptionsManagerPrivate.fetchRemoteSession(badUrl), + ).rejects.toThrow(/scheme|invalid session url/i); + }, + ); + + it('rejects a non-2xx response', async () => { + window.fetch = jest.fn().mockResolvedValue({ ok: false, status: 404 }); + await expect( + urlOptionsManagerPrivate.fetchRemoteSession( + 'https://example.com/s.phnxreplay', + ), + ).rejects.toThrow(/status 404/i); + }); + + // Build a mock Response whose streamed body yields `bytes` in one chunk. + const streamResponse = (bytes: Uint8Array) => ({ + ok: true, + status: 200, + body: { + getReader: () => { + let sent = false; + return { + read: () => + sent + ? Promise.resolve({ done: true, value: undefined }) + : ((sent = true), + Promise.resolve({ done: false, value: bytes })), + }; + }, + }, + }); + + it('aborts an oversized streamed body via the running size cap', async () => { + const huge = new Uint8Array(11 * 1024 * 1024); + window.fetch = jest.fn().mockResolvedValue(streamResponse(huge)); + await expect( + urlOptionsManagerPrivate.fetchRemoteSession( + 'https://example.com/huge.phnxreplay', + ), + ).rejects.toThrow(/too large/i); + }); + + it('returns body text + host for a valid https response within the cap', async () => { + const body = '{"version":1}'; + const bytes = new TextEncoder().encode(body); + window.fetch = jest.fn().mockResolvedValue(streamResponse(bytes)); + await expect( + urlOptionsManagerPrivate.fetchRemoteSession( + 'https://example.com/ok.phnxreplay', + ), + ).resolves.toEqual({ text: body, host: 'example.com' }); + }); + + it('passes credentials:omit + no-referrer + an abort signal to fetch', async () => { + const body = new TextEncoder().encode('{"version":1}'); + const fetchMock = jest.fn().mockResolvedValue(streamResponse(body)); + window.fetch = fetchMock; + await urlOptionsManagerPrivate.fetchRemoteSession( + 'https://example.com/ok.phnxreplay', + ); + const opts = fetchMock.mock.calls[0][1]; + expect(opts.credentials).toBe('omit'); + expect(opts.referrerPolicy).toBe('no-referrer'); + expect(opts.signal).toBeDefined(); + }); + }); }); diff --git a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts index 8f4c0cd9e..4bc06c9d0 100644 --- a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts @@ -13,6 +13,9 @@ describe('PlaygroundComponent', () => { const mockEventDisplay = { init: jest.fn(), + getSessionManager: jest.fn().mockReturnValue({ + state: { value: { kind: 'idle' }, onUpdate: jest.fn(() => jest.fn()) }, + }), getLoadingManager: jest.fn().mockReturnThis(), addProgressListener: jest.fn().mockImplementation(() => { component.loadingProgress = 100; diff --git a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts index 62f08e4f2..91d06ee9e 100644 --- a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts @@ -17,6 +17,9 @@ describe('TrackmlComponent', () => { const mockEventDisplay = { init: jest.fn(), + getSessionManager: jest.fn().mockReturnValue({ + state: { value: { kind: 'idle' }, onUpdate: jest.fn(() => jest.fn()) }, + }), getStateManager: jest.fn().mockReturnThis(), clippingEnabled: jest.fn().mockReturnThis(), startClippingAngle: jest.fn().mockReturnThis(), diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts index f9d8d0b65..d85c5446e 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts @@ -74,6 +74,7 @@ import { KinematicsPanelOverlayComponent, MasterclassPanelComponent, MasterclassPanelOverlayComponent, + SessionPillComponent, } from './ui-menu'; import { AttributePipe } from '../services/extras/attribute.pipe'; @@ -145,6 +146,7 @@ const PHOENIX_COMPONENTS: Type[] = [ KinematicsPanelOverlayComponent, MasterclassPanelComponent, MasterclassPanelOverlayComponent, + SessionPillComponent, ]; @NgModule({ diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.html new file mode 100644 index 000000000..4f61761bc --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.html @@ -0,0 +1,151 @@ +
+ + + + REC + {{ durationLabel }} + · + {{ eventCount }} events + + + + + + Saved + {{ durationLabel }} + · + {{ eventCount }} events + + + + + + + + Shared session + {{ pendingSummary }} + · + from {{ state.sourceLabel }} + + + + + + + + Replay + {{ playbackLabel }} + + + + + + + + + + Session error + {{ state.message }} + + +
diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.scss new file mode 100644 index 000000000..b563f48fa --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.scss @@ -0,0 +1,150 @@ +.session-pill { + position: fixed; + bottom: 80px; + left: 16px; + z-index: 1100; + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + background: rgba(20, 22, 30, 0.82); + color: #e8eaf2; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.2px; + user-select: none; + max-width: 80vw; + + .label { + font-weight: 600; + letter-spacing: 0.4px; + + &.error { + color: #ff7676; + } + } + + .time, + .count { + font-variant-numeric: tabular-nums; + opacity: 0.9; + } + + .dot-sep { + opacity: 0.4; + } + + .rec-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ff4040; + box-shadow: 0 0 6px rgba(255, 64, 64, 0.6); + animation: phoenixSessionPulse 1.2s infinite ease-in-out; + } + + .action { + background: rgba(255, 255, 255, 0.06); + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + padding: 4px 12px; + cursor: pointer; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.3px; + transition: + background 120ms ease, + border-color 120ms ease; + + &:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.18); + } + + &:disabled { + opacity: 0.6; + cursor: default; + } + + &.stop { + background: rgba(255, 64, 64, 0.18); + border-color: rgba(255, 64, 64, 0.4); + + &:hover { + background: rgba(255, 64, 64, 0.28); + } + } + + &.close { + width: 24px; + height: 24px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &.play { + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + } + } + + .scrub { + width: 180px; + accent-color: #80c1ff; + } + + .speeds { + display: inline-flex; + gap: 4px; + + .speed { + background: transparent; + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + padding: 3px 8px; + cursor: pointer; + font-size: 11px; + font-weight: 600; + opacity: 0.65; + transition: + opacity 120ms ease, + background 120ms ease; + + &:hover { + opacity: 1; + } + + &.selected { + background: rgba(128, 193, 255, 0.18); + border-color: rgba(128, 193, 255, 0.5); + opacity: 1; + } + } + } +} + +@keyframes phoenixSessionPulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.65; + transform: scale(0.85); + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.ts new file mode 100644 index 000000000..4d3840ff0 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/session-pill/session-pill.component.ts @@ -0,0 +1,289 @@ +import { + ChangeDetectorRef, + Component, + type OnDestroy, + type OnInit, +} from '@angular/core'; +import { type SessionManager, type SessionState } from 'phoenix-event-display'; +import { EventDisplayService } from '../../services/event-display.service'; + +const SPEEDS: ReadonlyArray<0.5 | 1 | 2 | 4> = [0.5, 1, 2, 4] as const; + +/** + * Floating session pill (#883). Renders only while the session manager is + * recording, paused, finished, or playing a replay. Idle state hides the + * pill entirely so it adds zero footprint to the toolbar. + */ +@Component({ + standalone: false, + selector: 'app-session-pill', + templateUrl: './session-pill.component.html', + styleUrls: ['./session-pill.component.scss'], +}) +export class SessionPillComponent implements OnInit, OnDestroy { + /** Current session-manager state, kept in component for template binding. */ + state: SessionState = { kind: 'idle' }; + + /** Cached duration label for the recording elapsed clock. */ + durationLabel = '00:00'; + /** Cached current/total label for the playback clock. */ + playbackLabel = '00:00 / 00:00'; + /** Cached event count for the recording badge. */ + eventCount = 0; + /** Last computed scrub percent (0-100). */ + scrubPercent = 0; + /** Saved copy-link feedback flag (resets after a tick). */ + linkCopied = false; + /** Available playback speeds for the speed selector. */ + speeds = SPEEDS; + + private sessionManager: SessionManager; + private unsubscribeState: (() => void) | null = null; + private recordingClockId: ReturnType | null = null; + private copiedResetTimer: ReturnType | null = null; + + constructor( + private eventDisplay: EventDisplayService, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit(): void { + this.sessionManager = this.eventDisplay.getSessionManager(); + this.state = this.sessionManager.state.value ?? { kind: 'idle' }; + this.unsubscribeState = this.sessionManager.state.onUpdate((next) => { + this.state = next; + this.refreshDerived(); + this.cdr.detectChanges(); + }); + this.refreshDerived(); + } + + ngOnDestroy(): void { + if (this.unsubscribeState) { + this.unsubscribeState(); + this.unsubscribeState = null; + } + if (this.recordingClockId) { + clearInterval(this.recordingClockId); + this.recordingClockId = null; + } + if (this.copiedResetTimer) { + clearTimeout(this.copiedResetTimer); + this.copiedResetTimer = null; + } + } + + /** True when the pill should be visible. Idle state hides it. */ + get visible(): boolean { + return this.state.kind !== 'idle'; + } + + /** Stop an active recording from the pill's stop button. */ + onStopRecording(): void { + this.sessionManager.stopRecording(); + } + + /** Dismiss a recorded/finished/errored state and return to idle. */ + onDismiss(): void { + if (this.state.kind === 'recorded') { + this.sessionManager.clearRecorded(); + } else { + this.sessionManager.stopPlayback(); + } + } + + /** Start a pending (link-supplied) session after explicit user confirmation. */ + onPlayPending(): void { + this.sessionManager.playPending(); + } + + /** Source label + duration/event summary for the pending confirm prompt. */ + get pendingSummary(): string { + if (this.state.kind !== 'pending') return ''; + const { session } = this.state; + return `${formatClock(session.duration)} · ${session.events.length} events`; + } + + /** Toggle play/pause on the active player. */ + onTogglePlayback(): void { + if (this.state.kind === 'playing') { + this.sessionManager.pause(); + } else if (this.state.kind === 'paused' || this.state.kind === 'finished') { + this.sessionManager.play(); + } + } + + /** Scrub to the playhead position the user clicked. */ + onScrub(value: number): void { + this.sessionManager.seek(value); + } + + /** Change the active player's speed. */ + onSpeed(speed: 0.5 | 1 | 2 | 4): void { + this.sessionManager.setSpeed(speed); + } + + /** Download the recorded session as a .phnxreplay JSON file. */ + onDownload(): void { + const blob = this.sessionManager.getDownloadBlob(); + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `phoenix-session-${Date.now()}.phnxreplay`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + /** + * Copy a ?replay=... share link to the clipboard. The base64 is precomputed + * when recording stops, so the common path writes synchronously inside the + * click gesture (the async Clipboard API rejects writes that happen after an + * await, which expires the user activation). Falls back to execCommand. + */ + onCopyLink(): void { + const cached = this.sessionManager.getCachedSharableBase64(); + if (cached) { + this.copyShareLink(cached); + return; + } + // Encode not finished yet (very fast click): compute then copy via the + // synchronous execCommand fallback, which tolerates the post-await case. + this.sessionManager.getSharableBase64().then((base64) => { + if (base64) this.copyShareLink(base64); + }); + } + + /** Build the share URL for a base64 payload and copy it to the clipboard. */ + private copyShareLink(base64: string): void { + const url = new URL(window.location.href); + url.searchParams.set('replay', base64); + const text = url.toString(); + + const onCopied = () => { + this.linkCopied = true; + if (this.copiedResetTimer) clearTimeout(this.copiedResetTimer); + this.copiedResetTimer = setTimeout(() => { + this.linkCopied = false; + this.cdr.detectChanges(); + }, 2000); + this.cdr.detectChanges(); + }; + + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(onCopied, () => { + if (this.legacyCopy(text)) onCopied(); + }); + } else if (this.legacyCopy(text)) { + onCopied(); + } + } + + /** + * Legacy clipboard copy via a temporary textarea + execCommand. Used as a + * fallback when the async Clipboard API is unavailable or rejected. Mirrors + * the share-link dialog's proven approach. + * @param text The text to copy. + * @returns True if the copy command reported success. + */ + private legacyCopy(text: string): boolean { + const el = document.createElement('textarea'); + el.value = text; + el.setAttribute('readonly', ''); + el.style.position = 'fixed'; + el.style.opacity = '0'; + document.body.appendChild(el); + el.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch { + ok = false; + } + document.body.removeChild(el); + return ok; + } + + /** Active player duration (ms) for the scrub bar max value. */ + get playerDuration(): number { + if ('player' in this.state && this.state.player) { + return this.state.player.duration; + } + if (this.state.kind === 'recorded') return this.state.session.duration; + return 0; + } + + /** Active player current speed for the selector highlight. */ + get currentSpeed(): number { + if ('player' in this.state && this.state.player) { + return this.state.player.speed; + } + return 1; + } + + private refreshDerived(): void { + if (this.state.kind === 'recording') { + this.startRecordingClock(); + this.updateRecordingDerived(); + return; + } + this.stopRecordingClock(); + if (this.state.kind === 'recorded') { + this.eventCount = this.state.session.events.length; + this.durationLabel = formatClock(this.state.session.duration); + this.playbackLabel = ''; + this.scrubPercent = 0; + return; + } + if ( + this.state.kind === 'playing' || + this.state.kind === 'paused' || + this.state.kind === 'finished' + ) { + const player = this.state.player; + const current = + this.state.kind === 'finished' ? player.duration : player.currentTime; + this.eventCount = player.eventCount; + this.playbackLabel = `${formatClock(current)} / ${formatClock(player.duration)}`; + this.scrubPercent = + player.duration > 0 ? (current / player.duration) * 100 : 0; + return; + } + } + + private startRecordingClock(): void { + if (this.recordingClockId) return; + this.recordingClockId = setInterval(() => { + this.updateRecordingDerived(); + this.cdr.detectChanges(); + }, 250); + } + + private stopRecordingClock(): void { + if (this.recordingClockId) { + clearInterval(this.recordingClockId); + this.recordingClockId = null; + } + } + + private updateRecordingDerived(): void { + if (this.state.kind !== 'recording') return; + const recorder = this.state.recorder; + this.durationLabel = formatClock(recorder.duration); + this.eventCount = recorder.eventCount; + } +} + +function formatClock(ms: number): string { + if (!isFinite(ms) || ms < 0) return '00:00'; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${pad(minutes)}:${pad(seconds)}`; +} + +function pad(n: number): string { + return n < 10 ? `0${n}` : String(n); +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts index 2445c9bb4..e3c90ffba 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts @@ -46,3 +46,4 @@ export * from './kinematics-panel/kinematics-panel.component'; export * from './kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component'; export * from './masterclass-panel/masterclass-panel.component'; export * from './masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component'; +export * from '../session-pill/session-pill.component'; diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html index 3f81e81ee..c3aefb251 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html @@ -14,3 +14,4 @@ +