Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions apps/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@linkcode/common": "workspace:*",
"@linkcode/engine": "workspace:*",
"@linkcode/schema": "workspace:*",
"@linkcode/sim": "workspace:*",
"@linkcode/transport": "workspace:*",
"@sentry/node": "10.62.0",
"@sentry/profiling-node": "10.62.0",
Expand Down
6 changes: 6 additions & 0 deletions apps/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@linkcode/engine';
import type { DaemonIdentity, DaemonListenerInfo, DaemonRuntimeInfo } from '@linkcode/schema';
import { DAEMON_EXIT_ALREADY_RUNNING, ManagedAssetIdSchema } from '@linkcode/schema';
import { SimSidecarClient } from '@linkcode/sim';
import { Hub } from '@linkcode/transport/server';
import * as Sentry from '@sentry/node';
import type { Runtime } from 'effect';
Expand All @@ -36,6 +37,7 @@ import {
} from './runtime';
import { createScheduleStore } from './schedule-store';
import { createSessionStore } from './session-store';
import { resolveSimSidecarPath } from './sim/backend';
import { createWorkspaceStore } from './workspace-store';

// After an uncaught exception the process state (live sessions, mid-writes) is untrustworthy —
Expand Down Expand Up @@ -206,9 +208,13 @@ async function main(): Promise<void> {
// status`) that take seconds on a cold machine — listener bind must not wait on them, or
// every client sits on ECONNREFUSED for the whole probe. The engine seeds from the promise.
const agentRuntimesReady = agentRuntimeProber.collect();
const simSidecarPath = resolveSimSidecarPath();
const EngineInfrastructureLive = makeEngineInfrastructureLayer(hub, {
providerStore: store,
ptyBackend: new SidecarPtyBackend(resolveSidecarPath()),
// Absent (not a rejecting stub) off macOS or unconfigured: the engine then has no
// simulator surface at all, which is what the capability gate reads.
simulatorBackend: simSidecarPath ? new SimSidecarClient(simSidecarPath) : undefined,
sessionStore: createSessionStore(databasePath()),
// After sessionStore so its migration-ledger reconcile runs before this store migrates.
scheduleStore: createScheduleStore(databasePath()),
Expand Down
28 changes: 28 additions & 0 deletions apps/daemon/src/sim/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { logger } from '../logger';

/**
* Resolve the `linkcode-sim` sidecar binary, mirroring the PTY sidecar's order: the explicit
* override (set by the desktop supervisor from `process.resourcesPath`) always wins; dev falls
* back to the workspace release build via this file's known depth under `src/`. That depth breaks
* in the flat tsup `dist/` bundle, so production trusts only the override. Returns `''` off
* macOS — Apple's simulator does not exist there, so nothing is logged either.
*/
export function resolveSimSidecarPath(): string {
if (process.platform !== 'darwin') return '';
const override = process.env.LINKCODE_SIM_SIDECAR_PATH;
if (override) return override;
const here = fileURLToPath(import.meta.url);
// tsx runs this file straight from source (`.ts`); a tsup bundle is emitted as `.js`/`.mjs`.
if (here.endsWith('.ts')) {
// Dev: this file lives at apps/daemon/src/sim, so the repo root is four levels up.
const repoRoot = join(dirname(here), '..', '..', '..', '..');
return join(repoRoot, 'target', 'release', 'linkcode-sim');
}
logger.warn(
{ operation: 'sim.resolve' },
'sim sidecar is not configured; simulators will be unavailable',
);
return '';
}
8 changes: 8 additions & 0 deletions apps/desktop/src/main/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ function spawnDaemon(): void {
const sidecar = sidecarPath();
if (existsSync(sidecar)) env.LINKCODE_PTY_SIDECAR_PATH = sidecar;
else log.warn(`[linkcode/desktop] pty sidecar missing at ${sidecar}; terminals unavailable`);
if (process.platform === 'darwin') {
const simSidecar = join(process.resourcesPath, 'linkcode-sim');
if (existsSync(simSidecar)) {
env.LINKCODE_SIM_SIDECAR_PATH = simSidecar;
} else {
log.warn(`[linkcode/desktop] sim sidecar missing at ${simSidecar}; simulators unavailable`);
}
}
// Same DSN the Electron main process inlined at build time (signed builds only). Publishable id.
const sentryDsn = import.meta.env.MAIN_VITE_SENTRY_DSN;
if (sentryDsn) env.LINKCODE_SENTRY_DSN = sentryDsn;
Expand Down
1 change: 1 addition & 0 deletions docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Read by the daemon, desktop, webview, or mobile at run time.
| `LINKCODE_PORT` | `apps/daemon/src/config.ts` | Overrides every configured listener's port. Must parse as an integer in `1..65535`, otherwise the config value stands. |
| `LINKCODE_HOST` | `apps/daemon/src/config.ts` | Overrides every listener's bind host. |
| `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/src/pty/sidecar.ts` | Absolute path to the `linkcode-pty` binary; always wins. Dev falls back to `target/release/linkcode-pty`; a bundled `dist/` daemon has no fallback and disables terminals. The packaged desktop supervisor sets it to `<resourcesPath>/sidecar/<arch>`. |
| `LINKCODE_SIM_SIDECAR_PATH` | `apps/daemon/src/sim/backend.ts` | Absolute path to the `linkcode-sim` iOS Simulator sidecar; always wins. macOS only — other platforms resolve to none regardless. Dev falls back to `target/release/linkcode-sim`; a bundled `dist/` daemon has no fallback and disables simulators. The packaged desktop supervisor sets it from `<resourcesPath>`. |
| `LINKCODE_AIGATEWAY_PATH` | `apps/daemon/src/ai-gateway.ts` | Path to the `aigateway` translation sidecar, overriding the managed-asset install. |
| `LINKCODE_ASSETS_DIR` | `packages/host/assets/src/paths.ts` | Redirects the managed-asset store root (default: `~/Library/Application Support/LinkCode/assets`, `%LOCALAPPDATA%/LinkCode/assets`, `$XDG_DATA_HOME/linkcode/assets`). Resolved per call, so tests can stub it. |
| `ELECTRON_RENDERER_URL` | `apps/desktop/src/main/window.ts` | Dev-server URL the main process loads instead of the packaged renderer. Written by `apps/desktop/scripts/dev.mts`. |
Expand Down
139 changes: 139 additions & 0 deletions packages/host/engine/src/__tests__/simulator-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { SessionId } from '@linkcode/schema';
import { Effect } from 'effect';
import { asyncNoop, noop } from 'foxts/noop';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SimulatorBackend, SimulatorDeviceInfo } from '../simulator/backend';
import { SimulatorService } from '../simulator/service';

const S1 = 'session-1' as SessionId;
const S2 = 'session-2' as SessionId;

function device(udid: string, state: string): SimulatorDeviceInfo {
return {
udid,
name: `iPhone ${udid}`,
state,
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
deviceType: null,
};
}

function fakeBackend(devices: SimulatorDeviceInfo[]) {
return {
probe: vi.fn(() => Promise.resolve({ simctlPath: '/usr/bin/simctl', developerDir: '/dev' })),
list: vi.fn(() => Promise.resolve(devices)),
boot: vi.fn(asyncNoop),
shutdownDevice: vi.fn(asyncNoop),
install: vi.fn(asyncNoop),
launch: vi.fn(() => Promise.resolve<number | null>(42)),
terminate: vi.fn(asyncNoop),
openUrl: vi.fn(asyncNoop),
screenshot: vi.fn(() => Promise.resolve(new Uint8Array([0xff, 0xd8]))),
close: vi.fn(noop),
} satisfies SimulatorBackend;
}

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

describe('SimulatorService', () => {
it('claims a device for the booting session and rejects other sessions', async () => {
const backend = fakeBackend([device('A', 'Shutdown')]);
const service = new SimulatorService(backend);

await service.boot(S1, 'A');
expect(backend.boot).toHaveBeenCalledWith('A');
expect(service.ownerOf('A')).toBe(S1);

await expect(service.launch(S2, 'A', 'com.example')).rejects.toMatchObject({
code: 'conflict',
});
await expect(service.launch(S1, 'A', 'com.example')).resolves.toBe(42);
});

it('caps a session at four devices', async () => {
const backend = fakeBackend([]);
const service = new SimulatorService(backend);

for (const udid of ['A', 'B', 'C', 'D']) {
await service.openUrl(S1, udid, 'https://example.com');
}
await expect(service.openUrl(S1, 'E', 'https://example.com')).rejects.toMatchObject({
code: 'limit_exceeded',
});
await expect(service.openUrl(S2, 'E', 'https://example.com')).resolves.toBeUndefined();
});

it('reclaims a service-booted device after the idle window', async () => {
const backend = fakeBackend([device('A', 'Shutdown')]);
const service = new SimulatorService(backend, { idleReclaimMs: 1000 });

await service.boot(S1, 'A');
service.releaseSession(S1);
// Still reserved during the window: another session cannot take it over.
await expect(service.launch(S2, 'A', 'com.example')).rejects.toMatchObject({
code: 'conflict',
});
expect(backend.shutdownDevice).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(1000);
expect(backend.shutdownDevice).toHaveBeenCalledWith('A');
expect(service.ownerOf('A')).toBeUndefined();
});

it('keeps the device when its session comes back within the window', async () => {
const backend = fakeBackend([device('A', 'Shutdown')]);
const service = new SimulatorService(backend, { idleReclaimMs: 1000 });

await service.boot(S1, 'A');
service.releaseSession(S1);
await service.screenshot(S1, 'A');

await vi.advanceTimersByTimeAsync(5000);
expect(backend.shutdownDevice).not.toHaveBeenCalled();
expect(service.ownerOf('A')).toBe(S1);
});

it('never shuts down a device the user booted', async () => {
const backend = fakeBackend([device('A', 'Booted')]);
const service = new SimulatorService(backend, { idleReclaimMs: 1000 });

await service.boot(S1, 'A');
expect(backend.boot).not.toHaveBeenCalled();
service.releaseSession(S1);
// Released immediately — no reservation window for a device that isn't ours to reclaim.
expect(service.ownerOf('A')).toBeUndefined();

await vi.advanceTimersByTimeAsync(5000);
expect(backend.shutdownDevice).not.toHaveBeenCalled();
});

it('shuts service-booted devices down and closes the backend on engine shutdown', async () => {
const backend = fakeBackend([device('A', 'Shutdown'), device('B', 'Booted')]);
const service = new SimulatorService(backend);

await service.boot(S1, 'A');
await service.boot(S2, 'B');
await Effect.runPromise(service.shutdown());

expect(backend.shutdownDevice).toHaveBeenCalledTimes(1);
expect(backend.shutdownDevice).toHaveBeenCalledWith('A');
expect(backend.close).toHaveBeenCalledTimes(1);
});

it('frees a device on owner-driven shutdown', async () => {
const backend = fakeBackend([device('A', 'Shutdown')]);
const service = new SimulatorService(backend);

await service.boot(S1, 'A');
await service.shutdownDevice(S1, 'A');
expect(service.ownerOf('A')).toBeUndefined();
// Freed for anyone: a different session can claim it next.
await expect(service.openUrl(S2, 'A', 'https://example.com')).resolves.toBeUndefined();
});
});
3 changes: 3 additions & 0 deletions packages/host/engine/src/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { LoopStore, ScheduleStore } from './automation';
import type { GitService } from './git/git-service';
import type { PreviewRouteRegistry } from './preview/route-registry';
import type { SessionStore } from './session/session-store';
import type { SimulatorBackend } from './simulator/backend';
import type { PtyBackend } from './terminal/pty-backend';
import type { FileSuggestService } from './workspace/file-suggest-service';
import type { WorkspaceStore } from './workspace/workspace-store';
Expand All @@ -17,6 +18,8 @@ export interface EngineDeps {
factory?: AdapterFactory;
sessionStore?: SessionStore;
ptyBackend?: PtyBackend;
/** iOS Simulator sidecar client (macOS hosts only); absent Engines have no simulator surface. */
simulatorBackend?: SimulatorBackend;
providerStore?: ProviderConfigStore;
git?: GitService;
fileSuggest?: FileSuggestService;
Expand Down
10 changes: 9 additions & 1 deletion packages/host/engine/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { SessionRequestHandler } from './session/request-handler';
import { SessionRecordRegistry } from './session/session-record-registry';
import { InMemorySessionStore } from './session/session-store';
import { SessionStartOptionsResolver } from './session/start-options-resolver';
import { SimulatorService } from './simulator/service';
import { TerminalRequestHandler } from './terminal/request-handler';
import { TerminalService } from './terminal/service';
import { WireRequestRouter } from './wire/request-router';
Expand Down Expand Up @@ -82,14 +83,20 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* (
runTask,
);
let terminals: TerminalService | undefined;
const simulators = deps.simulatorBackend
? new SimulatorService(deps.simulatorBackend)
: undefined;
const sessions = new SessionOrchestrator(
transport,
factory,
records,
runtimes,
scope,
runTask,
(sessionId) => terminals?.killBySession(sessionId),
(sessionId) => {
terminals?.killBySession(sessionId);
simulators?.releaseSession(sessionId);
},
);
terminals = deps.ptyBackend
? new TerminalService(deps.ptyBackend, transport, (id) => sessions.has(id))
Expand Down Expand Up @@ -273,6 +280,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* (
yield* finalize('artifacts.shutdown', () => artifacts.close());
yield* finalize('file-host.shutdown', () => fileHost.close());
yield* finalizeEffect('terminals.shutdown', terminals?.shutdown() ?? Effect.void);
yield* finalizeEffect('simulators.shutdown', simulators?.shutdown() ?? Effect.void);
yield* finalize('agent-login.shutdown', () => logins?.closeAll());
yield* finalize('translator.shutdown', () => translator?.closeAll());
yield* finalize('assets.shutdown', () => assets.close());
Expand Down
44 changes: 44 additions & 0 deletions packages/host/engine/src/simulator/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Host-side iOS Simulator boundary (interface-first, docs/ARCHITECTURE.md#core-principles): the
* daemon injects `@linkcode/sim`'s client, which satisfies this port structurally — neither
* package imports the other, mirroring the PTY seam. Shapes and error codes follow
* `crates/linkcode-sim/PROTOCOL.md`; rejections may carry a string `code` (e.g. `xcodeMissing`,
* on which the daemon gates the whole capability).
*/

/** One available simulator device. */
export interface SimulatorDeviceInfo {
udid: string;
name: string;
/** CoreSimulator state string: `Shutdown`, `Booted`, `Booting`, … */
state: string;
/** Runtime identifier, e.g. `com.apple.CoreSimulator.SimRuntime.iOS-26-5`. */
runtime: string;
/** Human-readable runtime name (`iOS 26.5`); absent when unknown. */
runtimeName?: string;
deviceType: string | null;
}

/** Where the host's simulator tooling lives; resolving it proves the capability exists. */
export interface SimulatorProbe {
simctlPath: string;
developerDir: string;
}

export type SimulatorImageFormat = 'jpeg' | 'png';

export interface SimulatorBackend {
probe(): Promise<SimulatorProbe>;
list(): Promise<SimulatorDeviceInfo[]>;
/** Boot and wait until the device is fully usable; an already-booted device succeeds. */
boot(udid: string): Promise<void>;
/** Shut a device down; an already-shutdown device succeeds. */
shutdownDevice(udid: string): Promise<void>;
install(udid: string, appPath: string): Promise<void>;
launch(udid: string, bundleId: string): Promise<number | null>;
terminate(udid: string, bundleId: string): Promise<void>;
openUrl(udid: string, url: string): Promise<void>;
screenshot(udid: string, format?: SimulatorImageFormat): Promise<Uint8Array>;
/** Release the backend process (engine shutdown). Booted devices keep running host-side. */
close(): void;
}
Loading