Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c79ebaa
feat(sim): stream frames + interactive ops in the @linkcode/sim client
AprilNEA Jul 22, 2026
9257071
feat(engine): simulator tap/swipe/button + framebuffer stream in the …
AprilNEA Jul 22, 2026
4f9ddf2
feat(schema,transport,engine,client-core): simulator interactive + st…
AprilNEA Jul 22, 2026
a610778
feat(ui,i18n): simulator screen canvas + optional panel-section vocab…
AprilNEA Jul 22, 2026
45b5aa5
feat(workbench): simulator stream registry + panel container
AprilNEA Jul 22, 2026
c477228
feat(desktop,ui): simulator as an on-demand right-panel section
AprilNEA Jul 22, 2026
1557909
test(desktop): simulator-panel e2e (summon, device list, live stream)
AprilNEA Jul 22, 2026
1ed8033
feat(ui): device-style bezel around the simulator screen
AprilNEA Jul 22, 2026
9af3b83
feat(sim-sidecar): screenMask op rendering the devicetype framebuffer…
AprilNEA Jul 22, 2026
e4e1faf
feat(schema,engine,client-core): simulator screen-mask wire (wire 46)
AprilNEA Jul 22, 2026
cbcc70e
feat(workbench,ui): clip the simulator screen with the real device mask
AprilNEA Jul 22, 2026
f5489c6
feat(ui): composite a realistic device chassis in canvas native space
AprilNEA Jul 22, 2026
3b76326
fix(ui): grow the chassis from the real mask for even band and matchi…
AprilNEA Jul 22, 2026
c0aee6d
feat(sim-sidecar): hardware H.264 streaming via VideoToolbox (zero-co…
AprilNEA Jul 22, 2026
4a7e54e
feat(schema,engine,client-core): H.264 stream codec plumbing (wire 47)
AprilNEA Jul 22, 2026
29a27b8
feat(ui,workbench): decode H.264 simulator streams with WebCodecs
AprilNEA Jul 22, 2026
09410ba
feat(sim,schema,engine,client,ui): streamed touch, wheel scroll, HID …
AprilNEA Jul 22, 2026
fcb83ad
docs(sim-sidecar): document the touch and key ops
AprilNEA Jul 22, 2026
e4c6bb4
feat(sim,schema,engine,client,ui): two-finger pinch + IME pasteboard …
AprilNEA Jul 22, 2026
09152d4
perf(sim): 30fps stream + fire-and-forget touch/pinch move to kill pe…
AprilNEA Jul 23, 2026
88e126b
refactor(ui): split SimulatorScreen into geometry/compositor/decoder …
AprilNEA Jul 23, 2026
b4ec389
perf(sim): layer static chassis + per-frame screen so 60 fps stays sm…
AprilNEA Jul 23, 2026
777a3a7
test(sim): drive the screen layer in the panel E2E after the chassis/…
AprilNEA Jul 23, 2026
769d9ac
fix(sim): re-plant the wheel-scroll finger at screen edges so long sc…
AprilNEA Jul 23, 2026
e506db4
docs(sim): record the native-scroll-injection dead end in the input m…
AprilNEA Jul 23, 2026
b575f2c
fix(sim,engine,ui,schema): resolve iOS Simulator panel review finding…
AprilNEA Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/daemon/src/__tests__/sim-mcp-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const S2 = 'session-2' as SessionId;

function fakeBackend(): SimulatorBackend {
return {
probe: vi.fn(() => Promise.resolve({ simctlPath: '/usr/bin/simctl', developerDir: '/dev' })),
probe: vi.fn(() =>
Promise.resolve({ simctlPath: '/usr/bin/simctl', developerDir: '/dev', interactive: true }),
),
list: vi.fn(() =>
Promise.resolve([
{
Expand All @@ -33,6 +35,19 @@ function fakeBackend(): SimulatorBackend {
terminate: vi.fn(asyncNoop),
openUrl: vi.fn(asyncNoop),
screenshot: vi.fn(() => Promise.resolve(new Uint8Array([0xff, 0xd8, 0x02]))),
screenMask: vi.fn(() => Promise.resolve(new Uint8Array([0x89, 0x50]))),
tap: vi.fn(asyncNoop),
touch: vi.fn(asyncNoop),
pinch: vi.fn(asyncNoop),
paste: vi.fn(asyncNoop),
key: vi.fn(asyncNoop),
swipe: vi.fn(asyncNoop),
button: vi.fn(asyncNoop),
streamStart: vi.fn(() =>
Promise.resolve({ streaming: true as const, fps: 60, scale: 1, codec: 'jpeg' as const }),
),
streamStop: vi.fn(asyncNoop),
onFrame: vi.fn(() => noop),
close: vi.fn(noop),
};
}
Expand Down
175 changes: 175 additions & 0 deletions apps/desktop/e2e/simulator-live.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Live simulator panel — NOT a test. Boots an isolated daemon (with the linkcode-sim sidecar) and
* the built desktop app, seeds a claim session, summons the Simulator section, and then holds the
* window open so you can drive the real booted device by hand (tap, drag, pinch with ⌥, scroll,
* type). Ctrl-C to tear it all down. Run: `pnpm -F @linkcode/desktop exec node e2e/simulator-live.mts`
*/

import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { noop } from 'foxts/noop';
import { wait } from 'foxts/wait';
import type { ElectronApplication } from 'playwright-core';
import { _electron } from 'playwright-core';
import { io } from 'socket.io-client';

const require = createRequire(import.meta.url);
const desktopDir = resolve(import.meta.dirname, '..');
const daemonDir = resolve(desktopDir, '../daemon');
const repoRoot = resolve(desktopDir, '../..');
const simSidecar = join(repoRoot, 'target', 'release', 'linkcode-sim');
const electronBinary = require('electron') as unknown as string;

const PORT = 43000 + (process.pid % 1000);
const WIRE_VERSION = 51;

async function waitForDaemon(): Promise<void> {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
try {
await fetch(`http://127.0.0.1:${PORT}/socket.io/?EIO=4&transport=polling`);
return;
} catch {
await wait(250);
}
}
throw new Error(`daemon did not come up on :${PORT}`);
}

function seedPiSession(cwd: string): Promise<string> {
const socket = io(`http://127.0.0.1:${PORT}`, { transports: ['websocket'] });
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('session.start timed out')), 60000);
socket.on('frame', (raw: unknown) => {
const reply = (raw as { payload?: Record<string, unknown> }).payload;
if (reply?.replyTo !== 'live-session') return;
clearTimeout(timer);
if (reply.kind === 'session.started' && typeof reply.sessionId === 'string') {
resolve(reply.sessionId);
} else {
reject(new Error(`session.start failed: ${JSON.stringify(reply)}`));
}
});
socket.on('connect', () => {
socket.emit('frame', {
v: WIRE_VERSION,
id: `live-${Date.now().toString(36)}`,
ts: Date.now(),
payload: { kind: 'session.start', clientReqId: 'live-session', opts: { kind: 'pi', cwd } },
});
});
socket.on('connect_error', (error: Error) => {
clearTimeout(timer);
reject(error);
});
}).finally(() => socket.close());
}

async function main(): Promise<void> {
if (!existsSync(join(daemonDir, 'dist/index.js'))) throw new Error('build the daemon first');
if (!existsSync(join(desktopDir, 'out/main/index.js'))) throw new Error('build desktop first');
if (!existsSync(simSidecar)) throw new Error('build target/release/linkcode-sim first');

const home = mkdtempSync(join(tmpdir(), 'linkcode-live-home-'));
const userData = mkdtempSync(join(tmpdir(), 'linkcode-live-userdata-'));
const chatRoot = join(home, 'LinkCode');
mkdirSync(chatRoot, { recursive: true });
const profile = `live-sim-${process.pid}`;
const appSupport = join(
process.env.HOME ?? home,
'Library',
'Application Support',
`LinkCode Development (${profile})`,
);
mkdirSync(appSupport, { recursive: true });
writeFileSync(
join(appSupport, 'settings.json'),
`${JSON.stringify({ locale: 'en', historyImportOnboardingHandled: true }, null, 2)}\n`,
);

let daemon: ChildProcess | null = null;
let app: ElectronApplication | null = null;
const teardown = (): void => {
void app?.close().catch(noop);
daemon?.kill('SIGTERM');
};
process.on('SIGINT', () => {
teardown();
process.exit(0);
});
process.on('SIGTERM', () => {
teardown();
process.exit(0);
});

daemon = spawn(process.execPath, ['dist/index.js'], {
cwd: daemonDir,
env: {
...process.env,
HOME: home,
LINKCODE_PORT: String(PORT),
LINKCODE_PROFILE: profile,
LINKCODE_SIM_SIDECAR_PATH: simSidecar,
},
stdio: 'ignore',
});
await waitForDaemon();
console.log(`daemon up on :${PORT}`);

const sessionId = await seedPiSession(chatRoot);
console.log(`seeded claim session ${sessionId}`);

app = await _electron.launch({
executablePath: electronBinary,
args: [desktopDir, `--user-data-dir=${userData}`, '--use-mock-keychain'],
env: { ...process.env, HOME: home, LINKCODE_PROFILE: profile },
});
const win = await app.firstWindow();
await win
.locator('button[aria-label="Toggle side panel"]:visible')
.first()
.waitFor({ state: 'visible', timeout: 30000 });
await win.waitForTimeout(2000);

// Open the right panel, summon the Simulator section, activate the seeded thread.
const toggle = win.locator('button[aria-label="Toggle side panel"]:visible').first();
if ((await toggle.getAttribute('aria-pressed')) !== 'true') {
await toggle.click();
await win.waitForTimeout(1200);
}
const plus = win.locator('button[aria-label="Open window"]:visible');
for (const candidate of await plus.all()) {
try {
await candidate.click({ timeout: 2000 });
break;
} catch {
// try the next matching + trigger
}
}
await win.getByRole('menuitem', { name: 'Simulator' }).click().catch(noop);
await win.waitForTimeout(1000);
const row = win.getByText(/ in LinkCode$/).first();
await row.waitFor({ state: 'visible', timeout: 15000 }).catch(noop);
await row.click().catch(noop);

console.log('\n─────────────────────────────────────────────');
console.log('Live simulator panel is up. In the window:');
console.log(' • tap / long-press / drag — single finger');
console.log(' • ⌥ (Option) + drag — pinch to zoom');
console.log(' • scroll — trackpad/wheel');
console.log(' • click the screen, then type — English keys + IME (中文/emoji via paste)');
console.log('Ctrl-C here to shut it all down.');
console.log('─────────────────────────────────────────────\n');

// Hold forever; the SIGINT/SIGTERM handlers tear everything down.
await new Promise<never>(noop);
}

void main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
Loading
Loading