diff --git a/app/index.tsx b/app/index.tsx index 832c81a..fe4378a 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,10 +1,12 @@ import { Redirect, Stack, useRouter } from 'expo-router'; +import { useMemo } from 'react'; import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useEntitlement } from '@/billing'; import { EntitlementFooter } from '@/components/billing/EntitlementFooter'; import { DeviceRow } from '@/components/DeviceRow'; import { HeaderIconButton } from '@/components/HeaderIconButton'; +import { DEMO_DEVICE_ID } from '@/demo/demoBackend'; import { useDevicesStore, useSettingsStore, type DeviceEntry } from '@/state'; import { useTokens } from '@/theme'; @@ -15,11 +17,17 @@ export default function DevicesScreen() { const hasHydrated = useDevicesStore((s) => s.hasHydrated); const settingsHydrated = useSettingsStore((s) => s.hasHydrated); const hasOnboarded = useSettingsStore((s) => s.hasOnboarded); + const demoMode = useSettingsStore((s) => s.demoMode); const devices = useDevicesStore((s) => s.devices); const setActiveDevice = useDevicesStore((s) => s.setActiveDevice); const removeDevice = useDevicesStore((s) => s.removeDevice); const entitlement = useEntitlement(); + const visibleDevices = useMemo( + () => (demoMode ? devices.filter((d) => d.id === DEMO_DEVICE_ID) : devices.filter((d) => d.id !== DEMO_DEVICE_ID)), + [demoMode, devices], + ); + if (!hasHydrated || !settingsHydrated) return null; if (!hasOnboarded) return ; @@ -79,7 +87,7 @@ export default function DevicesScreen() { }} /> - {devices.length === 0 ? ( + {visibleDevices.length === 0 ? ( No devices yet @@ -88,7 +96,7 @@ export default function DevicesScreen() { ) : ( - {devices.map((d) => ( + {visibleDevices.map((d) => ( handleSelect(d.id)} - onLongPress={() => handleLongPress(d)} + onLongPress={demoMode && d.id === DEMO_DEVICE_ID ? () => {} : () => handleLongPress(d)} onRepair={() => handleRepair(d)} /> ))} diff --git a/app/settings.tsx b/app/settings.tsx index 7f2ab13..1736c30 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -12,6 +12,8 @@ export default function SettingsScreen() { const router = useRouter(); const useNerdFont = useSettingsStore((s) => s.useNerdFont); const setUseNerdFont = useSettingsStore((s) => s.setUseNerdFont); + const demoMode = useSettingsStore((s) => s.demoMode); + const setDemoMode = useSettingsStore((s) => s.setDemoMode); const hint = source === 'device' @@ -85,6 +87,25 @@ export default function SettingsScreen() { /> + + Demo + + + + Demo Mode + + Loads sample data so you can try the app without a Mac. Switching it off restores your real devices. + + + + + ); } diff --git a/src/demo/demoBackend.ts b/src/demo/demoBackend.ts new file mode 100644 index 0000000..160c588 --- /dev/null +++ b/src/demo/demoBackend.ts @@ -0,0 +1,529 @@ +import { base64ToString, stringToBase64 } from '@/lib/base64'; + +import type { + EventDataMap, + EventName, + MethodMap, + MethodName, + MethodParams, + MethodResult, + Pairing, + Project, + TabArea, + VCSBranches, + VCSStatus, + Workspace, + Worktree, +} from '@/transport'; + +export const DEMO_DEVICE_ID = '00000000-0000-0000-0000-0000000000DE'; +export const DEMO_DEVICE_NAME = 'Demo Mac'; +export const DEMO_DEVICE_HOST = '192.168.1.42'; +export const DEMO_DEVICE_PORT = 4865; +export const DEMO_CLIENT_ID = '00000000-0000-0000-0000-00000000C11D'; + +const DEMO_THEME = { + themeFg: 0xc9c2d9, + themeBg: 0x19171f, + themePalette: [ + 0x141219, 0xec4899, 0x34d399, 0xe0af68, 0xc370d3, 0x6366f1, 0x22d3ee, 0xa9b1d6, + 0x2e2b34, 0xf472b6, 0x6ee7b7, 0xfbbf24, 0xd99be5, 0x818cf8, 0x67e8f9, 0xc9c2d9, + ], +}; + +export const DEMO_PAIRING: Pairing = { + clientID: DEMO_CLIENT_ID, + deviceName: DEMO_DEVICE_NAME, + ...DEMO_THEME, +}; + +const MUXY_ID = '11111111-1111-1111-1111-111111111111'; +const WEB_ID = '22222222-2222-2222-2222-222222222222'; +const MUXY_WT_MAIN = 'aaaa0001-0000-0000-0000-000000000001'; +const MUXY_WT_FEATURE = 'aaaa0001-0000-0000-0000-000000000002'; +const WEB_WT_MAIN = 'bbbb0001-0000-0000-0000-000000000001'; + +const MUXY_AREA_ID = 'aaaaaaaa-0000-0000-0000-000000000aaa'; +const MUXY_TAB1_ID = 'aaaaaaaa-0000-0000-0000-000000000ab1'; +const MUXY_TAB2_ID = 'aaaaaaaa-0000-0000-0000-000000000ab2'; +const MUXY_PANE1_ID = 'aaaaaaaa-0000-0000-0000-000000000ac1'; +const MUXY_PANE2_ID = 'aaaaaaaa-0000-0000-0000-000000000ac2'; + +const WEB_AREA_ID = 'bbbbbbbb-0000-0000-0000-000000000bbb'; +const WEB_TAB1_ID = 'bbbbbbbb-0000-0000-0000-000000000bb1'; +const WEB_TAB2_ID = 'bbbbbbbb-0000-0000-0000-000000000bb2'; +const WEB_PANE1_ID = 'bbbbbbbb-0000-0000-0000-000000000bc1'; +const WEB_PANE2_ID = 'bbbbbbbb-0000-0000-0000-000000000bc2'; + +const NOW = '2026-01-01T00:00:00.000Z'; + +const GREETING_TEXT = + 'Demo Mode — this terminal is simulated.\r\n' + + 'Type any command and press Enter to see the demo response.\r\n' + + 'demo@muxy ~ % '; +const PROMPT_TEXT = 'demo@muxy ~ % '; +const NOTICE_TEXT = '[Demo Mode] Commands are not executed in demo mode.\r\n'; + +function delay(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function simulatedDelayMs(method: MethodName): number { + switch (method) { + case 'vcsPush': + case 'vcsPull': + case 'vcsCommit': + case 'vcsCreatePR': + case 'vcsAddWorktree': + case 'vcsRemoveWorktree': + case 'vcsSwitchBranch': + return 700; + case 'vcsCreateBranch': + case 'vcsStageFiles': + case 'vcsUnstageFiles': + case 'vcsDiscardFiles': + return 250; + default: + return 0; + } +} + +const utf8ToBase64 = stringToBase64; +const base64ToUtf8 = base64ToString; + +function makeWorkspace( + projectID: string, + worktreeID: string, + projectPath: string, + area: TabArea, +): Workspace { + return { projectID, worktreeID, focusedAreaID: area.id, root: { type: 'tabArea', tabArea: area } }; +} + +function buildProjects(): Project[] { + return [ + { + id: MUXY_ID, + name: 'muxy', + path: '/Users/demo/Projects/muxy', + sortOrder: 0, + createdAt: NOW, + icon: 'terminal', + iconColor: 'blue', + }, + { + id: WEB_ID, + name: 'web-app', + path: '/Users/demo/Projects/web-app', + sortOrder: 1, + createdAt: NOW, + icon: 'globe', + iconColor: 'green', + }, + ]; +} + +function buildWorktrees(): Record { + return { + [MUXY_ID]: [ + { + id: MUXY_WT_MAIN, + name: 'main', + path: '/Users/demo/Projects/muxy', + branch: 'main', + isPrimary: true, + canBeRemoved: false, + createdAt: NOW, + }, + { + id: MUXY_WT_FEATURE, + name: 'feature-search', + path: '/Users/demo/Projects/muxy-worktrees/feature-search', + branch: 'feature/search', + isPrimary: false, + canBeRemoved: true, + createdAt: NOW, + }, + ], + [WEB_ID]: [ + { + id: WEB_WT_MAIN, + name: 'main', + path: '/Users/demo/Projects/web-app', + branch: 'main', + isPrimary: true, + canBeRemoved: false, + createdAt: NOW, + }, + ], + }; +} + +function buildWorkspaces(): Record { + const muxyArea: TabArea = { + id: MUXY_AREA_ID, + projectPath: '/Users/demo/Projects/muxy', + activeTabID: MUXY_TAB1_ID, + tabs: [ + { id: MUXY_TAB1_ID, kind: 'terminal', title: 'zsh', isPinned: false, paneID: MUXY_PANE1_ID }, + { id: MUXY_TAB2_ID, kind: 'terminal', title: 'server', isPinned: false, paneID: MUXY_PANE2_ID }, + ], + }; + const webArea: TabArea = { + id: WEB_AREA_ID, + projectPath: '/Users/demo/Projects/web-app', + activeTabID: WEB_TAB1_ID, + tabs: [ + { id: WEB_TAB1_ID, kind: 'terminal', title: 'zsh', isPinned: false, paneID: WEB_PANE1_ID }, + { id: WEB_TAB2_ID, kind: 'terminal', title: 'dev', isPinned: false, paneID: WEB_PANE2_ID }, + ], + }; + return { + [MUXY_ID]: makeWorkspace(MUXY_ID, MUXY_WT_MAIN, '/Users/demo/Projects/muxy', muxyArea), + [WEB_ID]: makeWorkspace(WEB_ID, WEB_WT_MAIN, '/Users/demo/Projects/web-app', webArea), + }; +} + +function buildStatus(): Record { + return { + [MUXY_ID]: { + branch: 'main', + aheadCount: 1, + behindCount: 1, + hasUpstream: true, + stagedFiles: [ + { path: 'app/settings.tsx', status: 'modified', isUntracked: false }, + ], + changedFiles: [ + { path: 'src/transport/WSClient.ts', status: 'modified', isUntracked: false }, + { path: 'README.md', status: 'modified', isUntracked: false }, + { path: 'docs/demo.md', status: 'untracked', isUntracked: true }, + ], + defaultBranch: 'main', + }, + [WEB_ID]: { + branch: 'main', + aheadCount: 0, + behindCount: 0, + hasUpstream: true, + stagedFiles: [], + changedFiles: [], + defaultBranch: 'main', + }, + }; +} + +function buildBranches(): Record { + return { + [MUXY_ID]: { current: 'main', locals: ['main', 'feature/search', 'fix/scrolling'], defaultBranch: 'main' }, + [WEB_ID]: { current: 'main', locals: ['main'], defaultBranch: 'main' }, + }; +} + +export type DemoEmitter = (event: E, data: EventDataMap[E]) => void; + +export class DemoBackend { + private projects = buildProjects(); + private worktreesByProject = buildWorktrees(); + private workspaces = buildWorkspaces(); + private statusByProject = buildStatus(); + private branchesByProject = buildBranches(); + private greetedPanes = new Set(); + + constructor(private readonly emit: DemoEmitter) {} + + static get pairing(): Pairing { + return DEMO_PAIRING; + } + + async handle(method: M, params: MethodParams): Promise> { + const ms = simulatedDelayMs(method); + if (ms > 0) await delay(ms); + const result = this.dispatch(method, params); + return result as MethodResult; + } + + handleTerminalInput(paneID: string, base64Bytes: string): void { + const text = base64ToUtf8(base64Bytes); + const containsEnter = text.includes('\r') || text.includes('\n'); + + if (containsEnter) { + const echo = text.replace(/[\r\n]+/g, ''); + const response = `${echo}\r\n${NOTICE_TEXT}${PROMPT_TEXT}`; + this.emitOutput(paneID, response); + return; + } + this.emitOutput(paneID, text); + } + + private emitOutput(paneID: string, text: string): void { + this.emit('terminalOutput', { + type: 'terminalOutput', + value: { paneID, bytes: utf8ToBase64(text) }, + }); + } + + private scheduleGreeting(paneID: string): void { + if (this.greetedPanes.has(paneID)) { + this.emitOutput(paneID, PROMPT_TEXT); + return; + } + this.greetedPanes.add(paneID); + setTimeout(() => this.emitOutput(paneID, GREETING_TEXT), 150); + } + + private dispatch(method: M, params: MethodParams): MethodMap[M]['result'] { + switch (method) { + case 'authenticateDevice': + case 'pairDevice': + return { type: 'pairing', value: DEMO_PAIRING } as MethodMap[M]['result']; + + case 'registerDevice': + return { type: 'deviceInfo', value: DEMO_PAIRING } as MethodMap[M]['result']; + + case 'subscribe': + case 'unsubscribe': + return { type: 'ok' } as MethodMap[M]['result']; + + case 'listProjects': + return { type: 'projects', value: this.projects } as MethodMap[M]['result']; + + case 'selectProject': + return { type: 'ok' } as MethodMap[M]['result']; + + case 'listWorktrees': { + const p = (params as MethodParams<'listWorktrees'>)!.value; + const wts = this.worktreesByProject[p.projectID] ?? []; + return { type: 'worktrees', value: wts } as MethodMap[M]['result']; + } + + case 'selectWorktree': + return { type: 'ok' } as MethodMap[M]['result']; + + case 'getWorkspace': { + const p = (params as MethodParams<'getWorkspace'>)!.value; + const ws = this.workspaces[p.projectID]; + if (!ws) throw demoError(404, 'Project not found'); + return { type: 'workspace', value: ws } as MethodMap[M]['result']; + } + + case 'createTab': + case 'closeTab': + case 'selectTab': + case 'splitArea': + case 'closeArea': + case 'focusArea': + return { type: 'ok' } as MethodMap[M]['result']; + + case 'takeOverPane': { + const p = (params as MethodParams<'takeOverPane'>)!.value; + this.emit('paneOwnershipChanged', { + type: 'paneOwnership', + value: { + paneID: p.paneID, + owner: { remote: { deviceID: DEMO_CLIENT_ID, deviceName: 'iPhone (Demo)' } }, + }, + }); + this.scheduleGreeting(p.paneID); + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'releasePane': { + const p = (params as MethodParams<'releasePane'>)!.value; + this.emit('paneOwnershipChanged', { + type: 'paneOwnership', + value: { + paneID: p.paneID, + owner: { mac: { deviceName: DEMO_DEVICE_NAME } }, + }, + }); + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'terminalResize': + case 'terminalScroll': + return { type: 'ok' } as MethodMap[M]['result']; + + case 'terminalInput': + return { type: 'ok' } as MethodMap[M]['result']; + + case 'getTerminalContent': + throw demoError(404, 'Not available in demo mode'); + + case 'getProjectLogo': + throw demoError(404, 'Not available in demo mode'); + + case 'getVCSStatus': + case 'vcsRefresh': { + const p = (params as MethodParams<'getVCSStatus'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + return { type: 'vcsStatus', value: status } as MethodMap[M]['result']; + } + + case 'vcsListBranches': { + const p = (params as MethodParams<'vcsListBranches'>)!.value; + const branches = this.branchesByProject[p.projectID]; + if (!branches) throw demoError(404, 'Project not found'); + return { type: 'vcsBranches', value: branches } as MethodMap[M]['result']; + } + + case 'vcsSwitchBranch': { + const p = (params as MethodParams<'vcsSwitchBranch'>)!.value; + const current = this.branchesByProject[p.projectID]; + if (!current) throw demoError(404, 'Project not found'); + this.branchesByProject[p.projectID] = { ...current, current: p.branch }; + const status = this.statusByProject[p.projectID]; + if (status) { + this.statusByProject[p.projectID] = { + ...status, + branch: p.branch, + aheadCount: 0, + behindCount: 0, + pullRequest: undefined, + }; + } + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsCreateBranch': { + const p = (params as MethodParams<'vcsCreateBranch'>)!.value; + const current = this.branchesByProject[p.projectID]; + if (!current) throw demoError(404, 'Project not found'); + const locals = current.locals.includes(p.name) ? current.locals : [...current.locals, p.name]; + this.branchesByProject[p.projectID] = { ...current, current: p.name, locals }; + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsStageFiles': { + const p = (params as MethodParams<'vcsStageFiles'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + const moving = status.changedFiles.filter((f) => p.paths.includes(f.path)); + this.statusByProject[p.projectID] = { + ...status, + stagedFiles: [...status.stagedFiles, ...moving], + changedFiles: status.changedFiles.filter((f) => !p.paths.includes(f.path)), + }; + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsUnstageFiles': { + const p = (params as MethodParams<'vcsUnstageFiles'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + const moving = status.stagedFiles.filter((f) => p.paths.includes(f.path)); + this.statusByProject[p.projectID] = { + ...status, + stagedFiles: status.stagedFiles.filter((f) => !p.paths.includes(f.path)), + changedFiles: [...status.changedFiles, ...moving], + }; + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsDiscardFiles': { + const p = (params as MethodParams<'vcsDiscardFiles'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + const drop = new Set([...p.paths, ...p.untrackedPaths]); + this.statusByProject[p.projectID] = { + ...status, + changedFiles: status.changedFiles.filter((f) => !drop.has(f.path)), + }; + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsCommit': { + const p = (params as MethodParams<'vcsCommit'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + this.statusByProject[p.projectID] = { + ...status, + aheadCount: status.aheadCount + 1, + stagedFiles: [], + changedFiles: p.stageAll ? [] : status.changedFiles, + }; + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsPush': { + const p = (params as MethodParams<'vcsPush'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + this.statusByProject[p.projectID] = { ...status, aheadCount: 0, hasUpstream: true }; + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsPull': { + const p = (params as MethodParams<'vcsPull'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + this.statusByProject[p.projectID] = { ...status, behindCount: 0 }; + return { type: 'ok' } as MethodMap[M]['result']; + } + + case 'vcsCreatePR': { + const p = (params as MethodParams<'vcsCreatePR'>)!.value; + const status = this.statusByProject[p.projectID]; + if (!status) throw demoError(404, 'Project not found'); + const pr = { + url: 'https://github.com/muxy-app/demo/pull/42', + number: 42, + state: 'open', + isDraft: p.draft, + baseBranch: p.baseBranch ?? 'main', + }; + this.statusByProject[p.projectID] = { ...status, pullRequest: pr }; + return { type: 'vcsPRCreated', value: { url: pr.url, number: pr.number } } as MethodMap[M]['result']; + } + + case 'vcsMergePullRequest': + return { type: 'ok' } as MethodMap[M]['result']; + + case 'vcsAddWorktree': { + const p = (params as MethodParams<'vcsAddWorktree'>)!.value; + const list = this.worktreesByProject[p.projectID] ?? []; + const wt: Worktree = { + id: `${p.projectID}-${Date.now().toString(16)}`, + name: p.name, + path: `/Users/demo/Projects/${p.name}`, + branch: p.branch, + isPrimary: false, + canBeRemoved: true, + createdAt: new Date().toISOString(), + }; + const next = [...list, wt]; + this.worktreesByProject[p.projectID] = next; + + if (p.createBranch) { + const branches = this.branchesByProject[p.projectID]; + if (branches && !branches.locals.includes(p.branch)) { + this.branchesByProject[p.projectID] = { + ...branches, + locals: [...branches.locals, p.branch], + }; + } + } + return { type: 'worktrees', value: next } as MethodMap[M]['result']; + } + + case 'vcsRemoveWorktree': { + const p = (params as MethodParams<'vcsRemoveWorktree'>)!.value; + const list = this.worktreesByProject[p.projectID] ?? []; + this.worktreesByProject[p.projectID] = list.filter((w) => w.id !== p.worktreeID); + return { type: 'ok' } as MethodMap[M]['result']; + } + + default: + throw demoError(400, `Demo mode does not implement "${String(method)}"`); + } + } +} + +function demoError(code: number, message: string): Error & { code: number } { + const err = new Error(message) as Error & { code: number }; + err.name = 'DemoError'; + err.code = code; + return err; +} diff --git a/src/lib/base64.ts b/src/lib/base64.ts index 46e1db5..d21fed1 100644 --- a/src/lib/base64.ts +++ b/src/lib/base64.ts @@ -24,3 +24,30 @@ export function stringToBase64(input: string): string { const enc = new TextEncoder(); return bytesToBase64(enc.encode(input)); } + +export function base64ToBytes(b64: string): Uint8Array { + if (typeof globalThis.atob === 'function') { + const bin = globalThis.atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + const clean = b64.replace(/[^A-Za-z0-9+/=]/g, ''); + const len = (clean.length * 3) / 4 - (clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0); + const out = new Uint8Array(Math.max(0, Math.floor(len))); + let p = 0; + for (let i = 0; i < clean.length; i += 4) { + const c1 = TABLE.indexOf(clean[i]!); + const c2 = TABLE.indexOf(clean[i + 1]!); + const c3 = clean[i + 2] === '=' ? -1 : TABLE.indexOf(clean[i + 2]!); + const c4 = clean[i + 3] === '=' ? -1 : TABLE.indexOf(clean[i + 3]!); + out[p++] = (c1 << 2) | (c2 >> 4); + if (c3 >= 0) out[p++] = ((c2 & 0x0f) << 4) | (c3 >> 2); + if (c4 >= 0) out[p++] = ((c3 & 0x03) << 6) | c4; + } + return out; +} + +export function base64ToString(b64: string): string { + return new TextDecoder().decode(base64ToBytes(b64)); +} diff --git a/src/state/connection.ts b/src/state/connection.ts index 0fa4fbc..b0a583f 100644 --- a/src/state/connection.ts +++ b/src/state/connection.ts @@ -1,8 +1,16 @@ +import { + DEMO_DEVICE_HOST, + DEMO_DEVICE_ID, + DEMO_DEVICE_NAME, + DEMO_DEVICE_PORT, + DemoBackend, +} from '@/demo/demoBackend'; import { AppStateBinder, isWSError, WSClient } from '@/transport'; import { resolveDeviceName } from './deviceName'; import { useDevicesStore } from './devicesStore'; import { readInstallToken } from './secureTokens'; +import { useSettingsStore } from './settingsStore'; export const client = new WSClient({ url: 'ws://0.0.0.0:0', @@ -10,6 +18,48 @@ export const client = new WSClient({ requestTimeoutMs: 15_000, }); +let demoBackend: DemoBackend | null = null; + +function ensureDemoBackend(): DemoBackend { + if (!demoBackend) { + demoBackend = new DemoBackend((event, data) => client.emitDemoEvent(event, data)); + client.setDemoBackend(demoBackend); + } + return demoBackend; +} + +function clearDemoBackend(): void { + if (!demoBackend) return; + demoBackend = null; + client.setDemoBackend(null); +} + +export function applyDemoMode(enabled: boolean): void { + const s = useDevicesStore.getState(); + const hasDemoEntry = s.devices.some((d) => d.id === DEMO_DEVICE_ID); + const activeId = s.activeDeviceId; + + if (enabled) { + ensureDemoBackend(); + s.ensureInstallDeviceID(); + if (!hasDemoEntry) { + s.upsertDevice({ + id: DEMO_DEVICE_ID, + label: DEMO_DEVICE_NAME, + host: DEMO_DEVICE_HOST, + port: DEMO_DEVICE_PORT, + pairedAt: new Date().toISOString(), + }); + } + if (activeId && activeId !== DEMO_DEVICE_ID) { + s.setActiveDevice(null); + } + } else { + clearDemoBackend(); + if (hasDemoEntry) s.removeDevice(DEMO_DEVICE_ID); + } +} + let started = false; export function startConnectionLifecycle(): () => void { @@ -35,7 +85,8 @@ export function startConnectionLifecycle(): () => void { const targetEntryId = active.id; try { - const token = await readInstallToken(); + const isDemo = useSettingsStore.getState().demoMode && active.id === DEMO_DEVICE_ID; + const token = isDemo ? 'demo-token' : await readInstallToken(); if (!token) { s.setNeedsRepair(targetEntryId, true); s.setConnection('unauthorized', 'No saved credential — please pair again.'); diff --git a/src/state/settingsStore.ts b/src/state/settingsStore.ts index f15947d..ea43fe4 100644 --- a/src/state/settingsStore.ts +++ b/src/state/settingsStore.ts @@ -6,12 +6,14 @@ type State = { hasHydrated: boolean; hasOnboarded: boolean; useNerdFont: boolean; + demoMode: boolean; }; type Actions = { setHasHydrated: (value: boolean) => void; setOnboarded: (value: boolean) => void; setUseNerdFont: (value: boolean) => void; + setDemoMode: (value: boolean) => void; }; export type SettingsStore = State & Actions; @@ -22,9 +24,11 @@ export const useSettingsStore = create()( hasHydrated: false, hasOnboarded: false, useNerdFont: true, + demoMode: false, setHasHydrated: (value) => set({ hasHydrated: value }), setOnboarded: (value) => set({ hasOnboarded: value }), setUseNerdFont: (value) => set({ useNerdFont: value }), + setDemoMode: (value) => set({ demoMode: value }), }), { name: 'muxy.settings.v1', @@ -32,6 +36,7 @@ export const useSettingsStore = create()( partialize: (state) => ({ useNerdFont: state.useNerdFont, hasOnboarded: state.hasOnboarded, + demoMode: state.demoMode, }), onRehydrateStorage: () => (state) => { state?.setHasHydrated(true); diff --git a/src/state/useConnection.ts b/src/state/useConnection.ts index 899004b..b753973 100644 --- a/src/state/useConnection.ts +++ b/src/state/useConnection.ts @@ -1,10 +1,13 @@ import { useEffect } from 'react'; -import { applyActiveDevice, startConnectionLifecycle } from './connection'; +import { applyActiveDevice, applyDemoMode, startConnectionLifecycle } from './connection'; import { useDevicesStore } from './devicesStore'; +import { useSettingsStore } from './settingsStore'; export function useConnection(): void { const hasHydrated = useDevicesStore((s) => s.hasHydrated); + const settingsHydrated = useSettingsStore((s) => s.hasHydrated); + const demoMode = useSettingsStore((s) => s.demoMode); const activeDeviceId = useDevicesStore((s) => s.activeDeviceId); const activeHost = useDevicesStore((s) => s.activeDeviceId ? s.devices.find((d) => d.id === s.activeDeviceId)?.host : undefined, @@ -18,6 +21,11 @@ export function useConnection(): void { return startConnectionLifecycle(); }, []); + useEffect(() => { + if (!settingsHydrated) return; + applyDemoMode(demoMode); + }, [settingsHydrated, demoMode]); + useEffect(() => { if (!hasHydrated) return; applyActiveDevice(); diff --git a/src/transport/WSClient.ts b/src/transport/WSClient.ts index f381571..ed49106 100644 --- a/src/transport/WSClient.ts +++ b/src/transport/WSClient.ts @@ -10,6 +10,11 @@ import { } from './protocol'; import { BackoffScheduler, type BackoffOptions } from './reconnect'; +export interface DemoBackendLike { + handle(method: M, params: MethodParams): Promise>; + handleTerminalInput(paneID: string, base64Bytes: string): void; +} + export type ConnectionState = | 'idle' | 'connecting' @@ -53,6 +58,7 @@ export class WSClient { private readonly requestTimeoutMs: number; private readonly autoReconnect: boolean; private nextId = 1; + private demo: DemoBackendLike | null = null; constructor(opts: WSClientOptions) { this.url = opts.url; @@ -81,7 +87,25 @@ export class WSClient { return this.bus.on(event, listener); } + emitDemoEvent(event: E, data: EventDataMap[E]): void { + this.bus.emit(event, data as AnyEventMap[E]); + } + + setDemoBackend(backend: DemoBackendLike | null): void { + this.demo = backend; + } + connect(): void { + if (this.demo) { + this.intentionallyClosed = false; + this.clearReconnectTimer(); + this.closeSocket(1000, 'switching to demo'); + this.setState('connecting'); + Promise.resolve().then(() => { + if (this.demo) this.setState('open'); + }); + return; + } if (this.state === 'open' || this.state === 'connecting') return; this.intentionallyClosed = false; this.backoff.reset(); @@ -97,6 +121,20 @@ export class WSClient { } async request(method: M, params: MethodParams): Promise> { + if (this.demo) { + if (method === 'terminalInput') { + const v = (params as MethodParams<'terminalInput'>)!.value; + this.demo.handleTerminalInput(v.paneID, v.bytes); + return { type: 'ok' } as MethodResult; + } + try { + return await this.demo.handle(method, params); + } catch (err) { + const code = (err as { code?: number })?.code ?? 0; + const message = err instanceof Error ? err.message : String(err); + throw new WSError(code, message); + } + } if (this.state !== 'open' || !this.socket) { throw new WSError(0, `Cannot send "${method}": connection is ${this.state}`); } diff --git a/src/transport/index.ts b/src/transport/index.ts index f51ed5a..f139fe7 100644 --- a/src/transport/index.ts +++ b/src/transport/index.ts @@ -1,4 +1,4 @@ -export { WSClient, type ConnectionState, type WSClientOptions } from './WSClient'; +export { WSClient, type ConnectionState, type DemoBackendLike, type WSClientOptions } from './WSClient'; export { WSError, isWSError } from './errors'; export { AppStateBinder } from './AppStateBinder'; export { BackoffScheduler, type BackoffOptions } from './reconnect';