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 =
+ '[1;32mDemo Mode[0m — 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 = '[33m[Demo Mode][0m 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';