import { rnxPublicBrand } from './public-brand' // public sootsim menu protocol. this file is deliberately pure data and pure // functions so hosts can import it without pulling the engine or canvaskit graph. export const SOOTSIM_ENGINE_EVENT_NAME = 'sootsim:engine-event' export const SOOTSIM_SHELL_COMMAND_NAME = 'sootsim:shell-command' export type SootSimMenuPlatform = 'browser' | 'electron' export type SootSimScaleMode = 'physical' | 'point' | 'pixel' | 'fit' // ambient glow strength. one setting with three states rather than an on/off // pair plus a separate strength: every host that carries the preference (the // menus, the persisted setting, the ?ambientGlow= boot seed, the live // set-ambient-glow push, electron's shared config) then carries exactly one // value and cannot end up half-configured. export type SootSimAmbientGlowLevel = 'off' | 'soft' | 'full' export const SOOTSIM_AMBIENT_GLOW_LEVELS = ['off', 'soft', 'full'] as const export function isSootSimAmbientGlowLevel( value: unknown, ): value is SootSimAmbientGlowLevel { return value === 'off' || value === 'soft' || value === 'full' } export const SOOTSIM_AMBIENT_GLOW_LABELS: Record = { off: 'Off', soft: 'Soft', full: 'Full', } // which ios home screen the simulator portrays. the home screen is authored // twice (the flat ios 26 home and the ios 27 home measured off apple's art), // so the era is one value every host carries: the menus, the persisted // setting, the ?homeScreen= boot seed, the live set-home-screen push and // electron's shared config. export type SootSimHomeScreenVersion = 'ios26' | 'ios27' export const SOOTSIM_HOME_SCREEN_VERSIONS = ['ios26', 'ios27'] as const export function isSootSimHomeScreenVersion( value: unknown, ): value is SootSimHomeScreenVersion { return value === 'ios26' || value === 'ios27' } export const SOOTSIM_HOME_SCREEN_VERSION_LABELS: Record< SootSimHomeScreenVersion, string > = { ios26: 'iOS 26', ios27: 'iOS 27', } export type SootSimMenuItemDef = | { type: 'separator' } | { type?: 'normal' | 'checkbox' | 'radio' label: string accelerator?: string browserAccelerator?: boolean checked?: boolean enabled?: boolean action?: string actionPayload?: string shortcutLabel?: string showInShortcutDocs?: boolean items?: SootSimMenuItemDef[] hiddenIn?: SootSimMenuPlatform } export type SootSimMenuDef = { label: string items: SootSimMenuItemDef[] hiddenIn?: SootSimMenuPlatform notMinimal?: boolean brand?: boolean } export interface SootSimMenuState { currentDevice: string homeScreenVersion: SootSimHomeScreenVersion // whether this surface is a contributor development surface. unreleased // simulator surfaces (the iPhone Duo and its home-screen era switch) are // offered here and nowhere a user can reach. developmentMode: boolean showBezels: boolean showMenuBar: boolean showRail: boolean solidWindow: boolean ambientGlow: SootSimAmbientGlowLevel // presentation: the real rendering, or the clean structural wireframe wireframe: boolean screenshotMode: boolean threeMode: boolean inspectMode: boolean devtoolsOpen: boolean alwaysShowLiveWebViews: boolean // whether "send to box" hands the box the signed-in session it captured sendToBoxCarryAuth: boolean stayOnTop: boolean scaleMode: SootSimScaleMode } export interface SootSimMenuDeviceChoice { model: string label: string platform: string } export interface SootSimMenuShortcutEntry { menuLabel: string label: string accelerator: string action: string actionPayload?: string } export interface SootSimShortcutDocEntry { keys: string label: string } export interface SootSimShortcutDocSection { title: string entries: SootSimShortcutDocEntry[] } export type MenuItemDef = SootSimMenuItemDef export type MenuDef = SootSimMenuDef export type MenuState = SootSimMenuState export type AmbientGlowLevel = SootSimAmbientGlowLevel export type ShortcutPlatform = SootSimMenuPlatform export type MenuShortcutEntry = SootSimMenuShortcutEntry export type ShortcutDocEntry = SootSimShortcutDocEntry export type ShortcutDocSection = SootSimShortcutDocSection export function buildSootSimMenuDefs( state: SootSimMenuState, deviceChoices: readonly SootSimMenuDeviceChoice[], ): SootSimMenuDef[] { return [ appMenu(), fileMenu(state, deviceChoices), editMenu(), deviceMenu(state, deviceChoices), debugMenu(state), windowMenu(state), helpMenu(), ] } function appMenu(): SootSimMenuDef { return { label: rnxPublicBrand.name, brand: true, items: [ { label: `About ${rnxPublicBrand.name}`, action: 'about' }, { type: 'separator' }, { label: 'Preferences...', accelerator: 'CmdOrCtrl+,', action: 'preferences', browserAccelerator: true, shortcutLabel: 'settings', showInShortcutDocs: true, }, { label: 'Settings Pages', items: [ { label: 'Accessibility', action: 'open-settings-page', actionPayload: 'accessibility', }, { label: 'Display & Text Size', action: 'open-settings-page', actionPayload: 'display-and-text-size', }, { label: 'Notifications', action: 'open-settings-page', actionPayload: 'notifications', }, { label: 'Apps', action: 'open-settings-page', actionPayload: 'apps', }, { label: 'Privacy & Security', action: 'open-settings-page', actionPayload: 'privacy', }, ], }, { type: 'separator' }, { label: 'Hide rnx', accelerator: 'CmdOrCtrl+H', action: 'hide', hiddenIn: 'browser', }, { label: 'Hide Others', accelerator: 'CmdOrCtrl+Alt+H', action: 'hide-others', hiddenIn: 'browser', }, { label: 'Show All', action: 'unhide', hiddenIn: 'browser' }, { type: 'separator' }, { label: 'Quit rnx', accelerator: 'CmdOrCtrl+Q', action: 'quit', hiddenIn: 'browser', }, ], } } function fileMenu( state: SootSimMenuState, deviceChoices: readonly SootSimMenuDeviceChoice[], ): SootSimMenuDef { return { label: 'File', notMinimal: true, items: [ { label: 'New Window', accelerator: 'CmdOrCtrl+N', action: 'new-window' }, { label: 'New Simulator', hiddenIn: 'browser', items: buildSootSimDeviceChoiceDefs(state.currentDevice, deviceChoices, { action: 'new-simulator', }), }, { label: 'Close Window', accelerator: 'CmdOrCtrl+W', action: 'close-window', hiddenIn: 'browser', }, { type: 'separator' }, { label: 'Save Screen', accelerator: 'CmdOrCtrl+S', action: 'save-screen', }, { label: 'Copy Screen', accelerator: 'CmdOrCtrl+Alt+C', action: 'copy-screen', }, { type: 'separator' }, { label: 'Record', accelerator: 'Ctrl+CmdOrCtrl+R', action: 'toggle-recording', browserAccelerator: true, shortcutLabel: 'start/stop recording', showInShortcutDocs: true, }, { label: 'Send to Box', action: 'send-to-box', }, ], } } function editMenu(): SootSimMenuDef { return { label: 'Edit', hiddenIn: 'browser', items: [ { label: 'Undo', accelerator: 'CmdOrCtrl+Z', action: 'undo' }, { label: 'Redo', accelerator: 'CmdOrCtrl+Shift+Z', action: 'redo' }, { type: 'separator' }, { label: 'Cut', accelerator: 'CmdOrCtrl+X', action: 'cut' }, { label: 'Copy', accelerator: 'CmdOrCtrl+C', action: 'copy' }, { label: 'Paste', accelerator: 'CmdOrCtrl+V', action: 'paste' }, { label: 'Select All', accelerator: 'CmdOrCtrl+A', action: 'select-all' }, ], } } function deviceMenu( state: SootSimMenuState, deviceChoices: readonly SootSimMenuDeviceChoice[], ): SootSimMenuDef { return { label: 'Device', items: [ { label: 'Restart', accelerator: 'CmdOrCtrl+R', action: 'reload' }, { label: 'Erase All Content and Settings...', action: 'erase-all' }, { type: 'separator' }, { label: 'Home', accelerator: 'Shift+CmdOrCtrl+H', action: 'home', browserAccelerator: true, shortcutLabel: 'go home', showInShortcutDocs: true, }, { label: 'Lock', accelerator: 'CmdOrCtrl+L', action: 'lock', shortcutLabel: 'lock / unlock', showInShortcutDocs: true, }, { label: 'Shake', accelerator: 'Ctrl+CmdOrCtrl+Z', action: 'shake' }, { label: 'App Switcher', accelerator: 'Ctrl+Shift+CmdOrCtrl+H', action: 'app-switcher', browserAccelerator: true, shortcutLabel: 'app switcher', showInShortcutDocs: true, }, { type: 'separator' }, { label: 'Trigger Screenshot', action: 'screenshot' }, { type: 'separator' }, ...buildSootSimDeviceChoiceDefs(state.currentDevice, deviceChoices, { action: 'switch-device', }), ], } } function debugMenu(state: SootSimMenuState): SootSimMenuDef { return { label: 'Debug', items: [ // both toggles are stateful in the browser, where the open state lives // in the same document. electron's menu process cannot see it, so it // keeps the plain (stateless) items rather than a permanently // unchecked checkbox — same reasoning as the screenshot/3d entries // being hidden there. accelerators stay single-registered per surface. { label: 'Toggle Devtools', accelerator: 'Alt+I', action: 'toggle-devtools', showInShortcutDocs: true, hiddenIn: 'browser', }, { label: 'Toggle Devtools', type: 'checkbox', checked: state.devtoolsOpen, accelerator: 'Alt+I', action: 'toggle-devtools', browserAccelerator: true, showInShortcutDocs: true, hiddenIn: 'electron', }, { label: 'Inspect', accelerator: 'Shift+CmdOrCtrl+C', action: 'toggle-inspect', hiddenIn: 'browser', }, { label: 'Inspect', type: 'checkbox', checked: state.inspectMode, accelerator: 'Shift+CmdOrCtrl+C', action: 'toggle-inspect', browserAccelerator: true, hiddenIn: 'electron', }, { type: 'separator' }, { label: 'Open Chrome Devtools', accelerator: 'CmdOrCtrl+Alt+I', action: 'open-devtools', hiddenIn: 'browser', shortcutLabel: 'developer tools', showInShortcutDocs: true, }, { type: 'separator' }, { label: 'Always Show Live WebViews', type: 'checkbox', checked: state.alwaysShowLiveWebViews, action: 'toggle-live-webviews', }, { label: 'Send to Box Carries Sign-In', type: 'checkbox', checked: state.sendToBoxCarryAuth, action: 'toggle-send-to-box-auth', }, { type: 'separator' }, { label: 'Clear Cache and Reload', accelerator: 'CmdOrCtrl+Shift+R', action: 'clear-cache', }, { label: 'Clear Local Storage', action: 'clear-localstorage' }, { label: 'Clear IndexedDB', action: 'clear-indexeddb' }, { label: 'Clear Service Workers', action: 'clear-serviceworkers' }, { type: 'separator' }, { label: 'Open User Data Folder', action: 'open-userdata', hiddenIn: 'browser', }, ], } } function windowMenu(state: SootSimMenuState): SootSimMenuDef { return { label: 'Window', items: [ { label: 'Minimize', accelerator: 'CmdOrCtrl+M', action: 'minimize', hiddenIn: 'browser', }, { label: 'Close', accelerator: 'CmdOrCtrl+W', action: 'close-window', hiddenIn: 'browser', }, { type: 'separator' }, { label: 'Toggle Appearance', accelerator: 'Shift+CmdOrCtrl+A', action: 'toggle-appearance', browserAccelerator: true, shortcutLabel: 'toggle appearance', showInShortcutDocs: true, }, { type: 'separator' }, { label: 'Show Device Bezels', type: 'checkbox', checked: state.showBezels, action: 'toggle-bezels', }, { label: 'Show Menu Bar', type: 'checkbox', checked: state.showMenuBar, action: 'toggle-menubar', hiddenIn: 'electron', }, { label: 'Show Rail', type: 'checkbox', checked: state.showRail, action: 'toggle-rail', hiddenIn: 'electron', }, { // desktop only. the device window is frameless and transparent, which // is right for a phone floating on the desktop and wrong for the // full-window modes; this pins the solid style on all the time. 3d // mode switches to it on its own either way. label: 'Solid Window', type: 'checkbox', checked: state.solidWindow, action: 'toggle-solid-window', hiddenIn: 'browser', }, { // browser only: the wash needs several glow radii of stage around the // device to fade out in, and electron's transparent window is sized to // the device, so there it can only paint a clipped rectangle. label: 'Ambient Glow', hiddenIn: 'electron', items: SOOTSIM_AMBIENT_GLOW_LEVELS.map((level) => ({ label: SOOTSIM_AMBIENT_GLOW_LABELS[level], type: 'radio' as const, checked: state.ambientGlow === level, action: 'set-ambient-glow', actionPayload: level, })), }, // both eras stay selectable while the ios 27 home surfaces land one at a // time, and the device's own os picks the starting value. the switch is // the iPhone Duo's, so it is offered exactly where the Duo is. ...(state.developmentMode ? [ { label: 'Home Screen', items: SOOTSIM_HOME_SCREEN_VERSIONS.map((version) => ({ label: SOOTSIM_HOME_SCREEN_VERSION_LABELS[version], type: 'radio' as const, checked: state.homeScreenVersion === version, action: 'set-home-screen', actionPayload: version, })), }, ] : []), { label: 'Wireframe', type: 'checkbox', checked: state.wireframe, action: 'toggle-wireframe', hiddenIn: 'electron', }, { label: 'Screenshot', type: 'checkbox', checked: state.screenshotMode, action: 'toggle-screenshot-mode', hiddenIn: 'electron', }, { label: '3D', type: 'checkbox', checked: state.threeMode, action: 'toggle-three-mode', hiddenIn: 'electron', }, { label: 'Stay On Top', type: 'checkbox', checked: state.stayOnTop, action: 'toggle-stay-on-top', hiddenIn: 'browser', }, { type: 'separator' }, { label: 'Physical Size', accelerator: 'CmdOrCtrl+1', type: 'radio', checked: state.scaleMode === 'physical', action: 'scale-physical', }, { label: 'Point Accurate', accelerator: 'CmdOrCtrl+2', type: 'radio', checked: state.scaleMode === 'point', action: 'scale-point', }, { label: 'Pixel Accurate', accelerator: 'CmdOrCtrl+3', type: 'radio', checked: state.scaleMode === 'pixel', action: 'scale-pixel', }, { label: 'Fit Screen', accelerator: 'CmdOrCtrl+0', type: 'radio', checked: state.scaleMode === 'fit', action: 'scale-fit', }, ], } } function helpMenu(): SootSimMenuDef { return { label: 'Help', notMinimal: true, items: [ { label: `${rnxPublicBrand.name} Website`, action: 'open-website' }, { label: 'Send Feedback...', accelerator: 'CmdOrCtrl+Shift+I', action: 'open-devtools-report', showInShortcutDocs: true, }, ], } } export function buildSootSimDeviceChoiceDefs( currentDevice: string, deviceChoices: readonly SootSimMenuDeviceChoice[], options: { action: 'switch-device' | 'new-simulator' }, ): SootSimMenuItemDef[] { const items: SootSimMenuItemDef[] = [] let lastPlatform = '' const isStateful = options.action === 'switch-device' for (const choice of deviceChoices) { if (lastPlatform && choice.platform !== lastPlatform) items.push({ type: 'separator' }) lastPlatform = choice.platform if (isStateful) { items.push({ label: choice.label, type: 'radio', checked: choice.model === currentDevice, action: options.action, actionPayload: choice.model, }) } else { items.push({ label: choice.label, action: options.action, actionPayload: choice.model, }) } } return items } function shouldIncludeItemForPlatform( item: Exclude, platform: SootSimMenuPlatform, ) { if (item.hiddenIn === platform) return false if (!item.accelerator || !item.action) return false if (platform === 'browser') return item.browserAccelerator === true return true } function shouldIncludeItemInDocs( item: Exclude, platform: SootSimMenuPlatform, ) { if (!item.showInShortcutDocs) return false if (platform === 'browser' && !item.browserAccelerator) return false return true } function collectMenuShortcutsFromDefs( menus: readonly SootSimMenuDef[], platform: SootSimMenuPlatform, includeItem: ( item: Exclude, platform: SootSimMenuPlatform, ) => boolean, ) { const entries: SootSimMenuShortcutEntry[] = [] const visit = (menuLabel: string, items: readonly SootSimMenuItemDef[]) => { for (const item of items) { if (item.type === 'separator') continue if (item.items?.length) visit(menuLabel, item.items) if (!includeItem(item, platform)) continue const { accelerator, action } = item if (!accelerator || !action) continue entries.push({ menuLabel, label: item.shortcutLabel ?? item.label, accelerator, action, actionPayload: item.actionPayload, }) } } for (const menu of menus) { if (menu.hiddenIn === platform) continue visit(menu.label, menu.items) } return entries } export function getMenuShortcutEntriesFromDefs( menus: readonly SootSimMenuDef[], platform: SootSimMenuPlatform, ): SootSimMenuShortcutEntry[] { return collectMenuShortcutsFromDefs(menus, platform, shouldIncludeItemForPlatform) } export function getMenuShortcutEntries( state: SootSimMenuState, deviceChoices: readonly SootSimMenuDeviceChoice[], platform: SootSimMenuPlatform, ): SootSimMenuShortcutEntry[] { return getMenuShortcutEntriesFromDefs( buildSootSimMenuDefs(state, deviceChoices), platform, ) } export function getShortcutDocSections( state: SootSimMenuState, deviceChoices: readonly SootSimMenuDeviceChoice[], platform: SootSimMenuPlatform, ): SootSimShortcutDocSection[] { return getShortcutDocSectionsFromDefs( buildSootSimMenuDefs(state, deviceChoices), platform, ) } export function getShortcutDocSectionsFromDefs( menus: readonly SootSimMenuDef[], platform: SootSimMenuPlatform, ): SootSimShortcutDocSection[] { const sections: SootSimShortcutDocSection[] = [] const byMenu = new Map() for (const entry of collectMenuShortcutsFromDefs( menus, platform, shouldIncludeItemInDocs, )) { const items = byMenu.get(entry.menuLabel) ?? [] items.push({ keys: formatAccelerator(entry.accelerator), label: entry.label, }) byMenu.set(entry.menuLabel, items) } for (const [title, entries] of byMenu) { sections.push({ title: title.toLowerCase(), entries, }) } return sections } export function formatAccelerator(accel: string): string { return accel .replace(/CmdOrCtrl\+/g, '\u2318') .replace(/Ctrl\+/g, '\u2303') .replace(/Alt\+/g, '\u2325') .replace(/Shift\+/g, '\u21e7') .replace(/Left/g, '\u2190') .replace(/Right/g, '\u2192') .replace(/Up/g, '\u2191') .replace(/Down/g, '\u2193') } const ARROW_KEYS: Record = { Left: 'arrowleft', Right: 'arrowright', Up: 'arrowup', Down: 'arrowdown', } function normalizeAcceleratorKey(key: string) { return ARROW_KEYS[key] ?? key.toLowerCase() } function normalizeEventKey(key: string) { return key.toLowerCase() } type KeyboardEventLike = Pick< KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey' > export function matchesAccelerator(event: KeyboardEventLike, accelerator: string) { const tokens = accelerator.split('+') const keyToken = tokens[tokens.length - 1] const wantsCmdOrCtrl = tokens.includes('CmdOrCtrl') const wantsCtrl = tokens.includes('Ctrl') const wantsAlt = tokens.includes('Alt') const wantsShift = tokens.includes('Shift') if (!keyToken) return false if (event.altKey !== wantsAlt) return false if (event.shiftKey !== wantsShift) return false const hasCmdOrCtrl = event.metaKey || event.ctrlKey if (wantsCmdOrCtrl && !hasCmdOrCtrl) return false if (!wantsCmdOrCtrl && event.metaKey) return false if (wantsCtrl && !event.ctrlKey) return false if (!wantsCtrl && !wantsCmdOrCtrl && event.ctrlKey) return false return normalizeEventKey(event.key) === normalizeAcceleratorKey(keyToken) }