Skip to content
Merged
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
13 changes: 7 additions & 6 deletions .github/actions/build-sidecar/action.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Builds the PTY sidecar (crates/linkcode-pty) for both release arches of the current platform
# and stages the binaries under apps/desktop/sidecar/${arch}, where electron-builder's
# extraResources expects them. Must run in the same job as the packaging step — the staged files
# feed it directly. The per-platform cross-target matrix lives in stage-sidecar.mts.
name: Build PTY sidecar
description: Cargo-build linkcode-pty for both arches and stage it for electron-builder
# Builds the sidecar binaries (crates/linkcode-pty everywhere; crates/linkcode-sim on macOS) for
# both release arches of the current platform and stages them under apps/desktop/sidecar/${arch},
# where electron-builder's extraResources expects them. Must run in the same job as the packaging
# step — the staged files feed it directly. The per-platform cross-target matrix and the
# per-platform crate list live in stage-sidecar.mts.
name: Build sidecars
description: Cargo-build the sidecar crates for both arches and stage them for electron-builder

runs:
using: composite
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["crates/linkcode-pty"]
members = ["crates/linkcode-pty", "crates/linkcode-sim"]
resolver = "2"

[workspace.package]
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The rules below are desktop-only — the **system plane**.

- **`electron-builder.yml` is the packaging config** (YAML, not a `package.json` build key). `node scripts/build.mts` (three plain-Vite builds — `vite.{main,preload,renderer}.config.ts`, in that order; `scripts/dev.mts` is the dev counterpart) compiles into `out/`; electron-builder only wraps `out/**` + `package.json`. `directories.output=release` must equal `OUTPUT_DIR` in `build-desktop.yml`; `buildResources=build-resources` (NOT `build/`, which is a gitignored turbo output). `electronVersion` is hardcoded (electron-builder can't read pnpm's `catalog:` protocol) — the pinned number and its sync rule live in [`docs/RELEASE.md`](../../docs/RELEASE.md).
- **Packaging always goes through `scripts/package-app.mts` (CODE-107), never a bare `electron-builder`.** It `pnpm --prod deploy`s the app's production closure into a self-contained staging dir **outside** the workspace, then runs electron-builder with `--projectDir` pointed there so `appDir === projectDir === workspaceRoot`. This is what makes `@electron/rebuild` find better-sqlite3 (pnpm hoists it to the repo-root `node_modules`, which electron-builder's Windows workspace-root probe can't locate — the pre-CODE-107 daemon shipped an un-rebuilt binding and died at boot on Windows) and makes the asar module collector see one importer instead of the whole monorepo. `package:devshell` uses `electron-builder.devshell.yml` + `--dir`; `package` is the production variant; both run through the script, which redirects `directories.output` back to `release/` and the icons to the shared `assets/`. There is no `dist` script (release packaging is CI-only).
- **`extraResources` ship real executables OUTSIDE the asar — the OS cannot exec an asar member:** `sidecar/${arch}` (the `linkcode-pty` PTY sidecar). `sidecar/` and `release/` are gitignored, so a fresh clone has none until staged; `pnpm -F @linkcode/desktop stage:host-runtime` builds the daemon + stages the sidecar. **Agent CLI binaries do not ship** (CODE-114): `files` globs exclude the SDK platform packages from the asar (each is ~220 MB, host-arch only — broken cross-arch anyway), and the daemon spawns a detected user install or a managed download instead (resolution in [`packages/host/agent-adapter/AGENTS.md`](../../packages/host/agent-adapter/AGENTS.md)); The pi npm closure is likewise absent (CODE-219): its SDK is a devDependency of agent-adapter, so the `--prod deploy` staging never contains it — the daemon downloads it into the asset store on first use. `verify-artifacts.mts` fails the build if a platform package, a pi-closure package, or `Resources/agent-bin` sneaks back in, or any artifact exceeds 200 MB.
- **`extraResources` ship real executables OUTSIDE the asar — the OS cannot exec an asar member:** `sidecar/${arch}` (the `linkcode-pty` PTY sidecar everywhere, plus the `linkcode-sim` iOS Simulator sidecar on macOS — the crate list lives in `scripts/stage-sidecar.mts`). `sidecar/` and `release/` are gitignored, so a fresh clone has none until staged; `pnpm -F @linkcode/desktop stage:host-runtime` builds the daemon + stages the sidecars. **Agent CLI binaries do not ship** (CODE-114): `files` globs exclude the SDK platform packages from the asar (each is ~220 MB, host-arch only — broken cross-arch anyway), and the daemon spawns a detected user install or a managed download instead (resolution in [`packages/host/agent-adapter/AGENTS.md`](../../packages/host/agent-adapter/AGENTS.md)); The pi npm closure is likewise absent (CODE-219): its SDK is a devDependency of agent-adapter, so the `--prod deploy` staging never contains it — the daemon downloads it into the asset store on first use. `verify-artifacts.mts` fails the build if a platform package, a pi-closure package, or `Resources/agent-bin` sneaks back in, or any artifact exceeds 200 MB.
- **The bundled daemon comes from the `bundle-daemon-artifact` plugin** in `vite.main.config.ts`: it copies `apps/daemon/dist/index.js` → `out/daemon/index.mjs` (and `instrument.js` → `instrument.mjs` for Sentry preload; both renamed `.mjs` because they leave the daemon's `type=module` scope) and `apps/daemon/drizzle` → `out/drizzle`. It throws `` apps/daemon/dist is missing — run `pnpm -F @linkcode/daemon build` first `` if the daemon wasn't built; turbo `^build` guarantees ordering.
- **`linkcode://` deep-link registration differs by build path.** Packaged builds register it via the `electron-builder.yml` `protocols` block (macOS Info.plist + Windows installer); dev shells register it at runtime via `setAsDefaultProtocolClient` (`cloud-auth/client.ts`). OAuth callback routing therefore depends on how the app was shipped.

Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ files:
- '!node_modules/@linkcode/**'
- '!node_modules/better-sqlite3/deps/**'

# The PTY sidecar (crates/linkcode-pty), staged per arch by scripts/stage-sidecar.mjs. It is a
# real executable, so it must live outside the asar; the daemon supervisor points
# LINKCODE_PTY_SIDECAR_PATH at it under process.resourcesPath (src/main/daemon-supervisor.ts).
# The sidecar binaries (crates/linkcode-pty everywhere; crates/linkcode-sim on macOS), staged per
# arch by scripts/stage-sidecar.mts. They are real executables, so they must live outside the
# asar; the daemon supervisor points LINKCODE_PTY_SIDECAR_PATH at the PTY one under
# process.resourcesPath (src/main/daemon-supervisor.ts).
extraResources:
- from: sidecar/${arch}
to: .
Expand Down
19 changes: 12 additions & 7 deletions apps/desktop/scripts/stage-sidecar.mts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env node
/**
* Build the PTY sidecar (crates/linkcode-pty) and stage it where electron-builder's
* `extraResources: sidecar/${arch}` (electron-builder.yml) picks it up. Default = host arch
* Build the sidecar binaries (crates/linkcode-pty everywhere; crates/linkcode-sim on macOS,
* where Apple's simulator runs) and stage them where electron-builder's
* `extraResources: sidecar/${arch}` (electron-builder.yml) picks them up. Default = host arch
* (local `package`); `--all` adds the cross arch (CI, .github/actions/build-sidecar).
*/
import { execFileSync } from 'node:child_process';
Expand Down Expand Up @@ -30,21 +31,25 @@ const CROSS_BUILDS: Partial<Record<NodeJS.Platform, CrossBuild>> = {

const desktopDir = join(import.meta.dirname, '..');
const repoRoot = join(desktopDir, '..', '..');
const binary = process.platform === 'win32' ? 'linkcode-pty.exe' : 'linkcode-pty';
/** linkcode-sim drives Apple's iOS Simulator, which exists only on macOS. */
const crates = process.platform === 'darwin' ? ['linkcode-pty', 'linkcode-sim'] : ['linkcode-pty'];

function stage(arch: string, cross?: CrossBuild): void {
const cargoArgs = ['build', '-p', 'linkcode-pty', '--release'];
const cargoArgs = ['build', '--release', ...crates.flatMap((crate) => ['-p', crate])];
if (cross) cargoArgs.push('--target', cross.target);
execFileSync('cargo', cargoArgs, {
cwd: repoRoot,
stdio: 'inherit',
env: { ...process.env, ...cross?.env },
});
const built = join(repoRoot, 'target', ...(cross ? [cross.target] : []), 'release', binary);
const destDir = join(desktopDir, 'sidecar', arch);
mkdirSync(destDir, { recursive: true });
cpSync(built, join(destDir, binary));
console.log(`staged ${built} -> ${join(destDir, binary)}`);
for (const crate of crates) {
const binary = process.platform === 'win32' ? `${crate}.exe` : crate;
const built = join(repoRoot, 'target', ...(cross ? [cross.target] : []), 'release', binary);
cpSync(built, join(destDir, binary));
console.log(`staged ${built} -> ${join(destDir, binary)}`);
}
}

const { values } = parseArgs({ options: { all: { type: 'boolean' } } });
Expand Down
18 changes: 18 additions & 0 deletions crates/linkcode-sim/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "linkcode-sim"
version.workspace = true
edition.workspace = true
publish.workspace = true
authors.workspace = true
description.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true

[[bin]]
name = "linkcode-sim"
path = "src/main.rs"

[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
99 changes: 99 additions & 0 deletions crates/linkcode-sim/PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# LinkCode iOS Simulator sidecar protocol

Private stdin/stdout IPC protocol for `linkcode-sim`. For build and development notes, see [`README.md`](./README.md).

The Rust implementation is in [`src/proto.rs`](src/proto.rs) and [`src/rpc.rs`](src/rpc.rs); the TypeScript counterpart lives in `@linkcode/sim` (`packages/host/sim`, CODE-393). Stderr is reserved for diagnostics and must not carry protocol data.

## Transport

Identical framing to `linkcode-pty`: each frame has a 5-byte header followed by a body:

```text
[u32 little-endian total][u8 type][body]
```

`total = 1 + body.length`, must be non-zero and at most `16 MiB`. Multi-byte integers are little-endian. If frame decoding fails, the stream is considered corrupt — the receiver should stop using that sidecar instance instead of resynchronizing mid-stream.

## Frame types

Daemon to sidecar:

| Type | Name | Body |
| ---: | --- | --- |
| `0x01` | `REQUEST` | JSON [`Request`](#request) |

Sidecar to daemon:

| Type | Name | Body |
| ---: | --- | --- |
| `0x81` | `RESULT` | JSON [`Result`](#result) |
| `0x82` | `SCREENSHOT` | binary [`Screenshot body`](#screenshot-body) |

Unknown frame types are ignored. A malformed `REQUEST` fails only that request — the sidecar replies with an `invalidRequest` `RESULT` for its `requestId` and keeps serving. If the `requestId` itself cannot be recovered, the sidecar logs to stderr and the daemon's pending request is reclaimed by its own timeout.

## Request

```json
{
"requestId": "r-1",
"op": { "type": "boot", "udid": "615920B7-…" }
}
```

`requestId` is daemon-generated, non-empty UTF-8, max `65535` bytes, and unique among in-flight requests. Each request runs on its own sidecar thread: a slow `boot` never delays a concurrent `screenshot`, and responses arrive in completion order, not request order.

### Ops (P0 — everything shells out to `xcrun simctl`)

| `type` | Params | Result |
| --- | --- | --- |
| `probe` | — | `{ simctlPath, developerDir }` |
| `list` | — | `{ devices: [{ udid, name, state, runtime, runtimeName?, deviceType }] }` |
| `boot` | `udid` | `{}` — waits for full boot (`bootstatus -b`); already-booted succeeds |
| `shutdown` | `udid` | `{}` — already-shutdown succeeds |
| `install` | `udid`, `appPath` | `{}` |
| `launch` | `udid`, `bundleId` | `{ pid }` (`pid` is `null` if simctl's output had no parsable pid) |
| `terminate` | `udid`, `bundleId` | `{}` |
| `openUrl` | `udid`, `url` | `{}` |
| `screenshot` | `udid`, `format?` (`jpeg` default, `png`) | bytes on a [`SCREENSHOT`](#screenshot-body) frame |

Per-op deadlines are enforced sidecar-side (`boot` 180s, `install` 120s, `screenshot` 30s, others 60s); a child that outlives its deadline is killed and reported as `timeout`.

## Result

Success:

```json
{ "requestId": "r-1", "ok": true, "result": {} }
```

Failure:

```json
{ "requestId": "r-1", "ok": false, "error": { "code": "xcodeMissing", "message": "…" } }
```

Error codes:

| Code | Meaning |
| --- | --- |
| `xcodeMissing` | `xcrun`/simctl not found — Xcode (with the iOS platform) is not installed or not selected. The daemon should gate the simulator capability on this, not retry. |
| `simctlFailed` | simctl ran and reported failure; `message` carries its stderr. |
| `timeout` | The operation's deadline elapsed. |
| `invalidRequest` | The request body could not be parsed. |
| `io` | Spawning simctl or reading its output failed at the OS level. |

## Screenshot body

A successful `screenshot` responds with raw image bytes instead of JSON, so captures never pay base64 on this private pipe:

```text
[u16 little-endian request_id_length][request_id UTF-8 bytes][image bytes]
```

A failed `screenshot` responds with a normal `RESULT` error. Exactly one of the two arrives per request.

## Lifecycle expectations

- One sidecar process serves many concurrent requests; the daemon lazily starts it on first use.
- When daemon stdin closes, the sidecar stops writing and exits without waiting for in-flight simctl calls — a mid-boot device keeps booting server-side in CoreSimulatorService either way. The daemon owns device cleanup (per-session shutdown, CODE-393).
- If the sidecar exits or its stream becomes corrupt, the daemon rejects pending requests and restarts the sidecar with a fresh frame decoder.
14 changes: 14 additions & 0 deletions crates/linkcode-sim/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# linkcode-sim

The Link Code iOS Simulator host: one long-lived process the daemon spawns to drive Apple's iOS Simulator. P0 wraps the public `xcrun simctl` CLI only (lifecycle, install/launch, screenshot); the streaming/HID phase is CODE-396. The stdio wire contract is [`PROTOCOL.md`](./PROTOCOL.md); the TypeScript client is `@linkcode/sim` (`packages/host/sim`).

macOS-only at runtime (it needs Xcode's simulator tooling); it compiles and answers `probe` with a structured `xcodeMissing` error everywhere else, so workspace-wide `cargo test` stays green on any platform.

## Development

```sh
cargo test -p linkcode-sim # unit + protocol smoke tests
cargo test -p linkcode-sim --test device_loop -- --ignored # full boot→install→launch→screenshot loop (needs Xcode, boots a simulator)
```

Release binaries are staged by `apps/desktop/scripts/stage-sidecar.mts` into `apps/desktop/sidecar/${arch}` (macOS only) and shipped via electron-builder `extraResources`.
Loading