Skip to content

feat(desktop,workbench,ui): iOS Simulator panel — on-demand section with live co-driving stream (CODE-397)#259

Open
AprilNEA wants to merge 25 commits into
xuan/code-396from
xuan/code-397
Open

feat(desktop,workbench,ui): iOS Simulator panel — on-demand section with live co-driving stream (CODE-397)#259
AprilNEA wants to merge 25 commits into
xuan/code-396from
xuan/code-397

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

Stacked on #255 (CODE-396). Closes CODE-397.

What

The desktop Simulator panel, end to end: an on-demand right-panel section summoned from a new "+" menu at the end of the section strip (mirroring the bottom panel's add-window flow), showing a live, touchable device screen.

  • Data plane (wire 45): simulator.tap/swipe/button/stream.start/stream.stop commands, simulator.stream.started reply, and session-scoped simulator.stream.frame push — the Hub fans frames out like agent.event, never as a global broadcast. @linkcode/sim gains the STREAM_FRAME decoder and per-udid onFrame; client-core gains subscribeSimulatorFrames.
  • Panel: device picker (booted-first) + Home/Lock buttons + live canvas. Interactions ride the claim of the session that started the stream, so the user co-drives the same device an agent is using; claim conflicts surface as a transient banner. Switching away from the section stops the stream (a udid-keyed registry with refcount + deferred stop bridges the docked↔maximized remount). Desktop shell state bumps v2→v3 (simulatorAdded membership, active-section fallback).
  • Real device chassis (wire 46): a screenMask sidecar op rasterizes the devicetype bundle's framebufferMask PDF at runtime from the local Xcode install (public simctl list devicetypesbundlePath; CoreGraphics render; nothing Apple-owned ships with Link Code). The renderer composites the whole device in native pixel space — mask-clipped screen, thin display band, titanium rim, side-button bumps — all concentric with the measured screen corners and scaling with the panel.

Verification

pnpm -F @linkcode/desktop e2e:simulator (new): isolated daemon (profile-isolated appData) + built desktop → summon the section from "+" → real device list → wire-seeded pi session → reload restores the persisted section (v3) → live frames paint the canvas end-to-endsimulator.open-url drives Safari to a real page → close restores the "+" menu. Full suite: 1798 tests, repo-wide typecheck/lint/format clean; sidecar cargo fmt/clippy/test clean.

AprilNEA added 12 commits July 22, 2026 23:37
Add STREAM_FRAME decode, tap/swipe/button/streamStart/streamStop, and a
per-udid onFrame subscription so the engine can consume the sidecar's live
framebuffer. Foundation for the CODE-397 simulator panel.
…backend port and service

Extend SimulatorBackend and SimulatorService with claim-gated interactive ops
and streamStart/streamStop plus a per-udid onFrame fan-out; dropping a device
stops its stream idempotently. SimSidecarClient satisfies the widened port
structurally. Transport wiring (wire variants, Hub targeting, client channel)
lands next.
…ream wire (wire 45)

Add tap/swipe/button/stream.start/stream.stop commands, a stream.started reply,
and a session-scoped simulator.stream.frame push (base64 JPEG) — bump wire
44->45 (Invariant 1). The engine routes the commands and fans a device's frames
out via the transport; the Hub scopes frames to attached sessions like
agent.event (never a global broadcast); client-core gains the methods and a
per-udid subscribeSimulatorFrames. Frames ride base64 in JSON like screenshots;
a binary side-channel stays a remote/high-fps concern.
@linear-code

linear-code Bot commented Jul 22, 2026

Copy link
Copy Markdown

CODE-397

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 289a0b2a52

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


/** Stop a device's framebuffer stream. Requires the session to hold the device. */
async streamStop(sessionId: SessionId, udid: string): Promise<void> {
this.claim(sessionId, udid);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid reacquiring devices when stopping a released stream

When releaseSession() has already run and the panel registry's deferred simulator.stream.stop arrives afterward, this call either recreates a claim for a user-booted device or clears the idle-reclaim timer for a service-booted device. Because the stopped session receives no second release callback, the device remains pinned to that dead session and subsequent sessions get conflict until the daemon restarts. streamStop should verify an existing owner without creating or refreshing a claim.

Useful? React with 👍 / 👎.

/** Fan the device's frames out to the transport as session-scoped `simulator.stream.frame`s.
* Idempotent: a second `streamStart` for a device already fanning out keeps the one subscription. */
private subscribeFrames(simulators: SimulatorService, sessionId: SessionId, udid: string): void {
if (this.frameSubs.has(udid)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace stale frame subscriptions after ownership changes

When a stream ends outside the simulator.stream.stop wire path—such as through session release or simulator shutdown—the backend stops, but this frameSubs entry is never removed. If another session later starts the same UDID, this early return retains the callback that captured the old sessionId, so every new frame is routed by the Hub to the old session and the current panel receives none. Remove these subscriptions during service lifecycle cleanup or replace them when the owning session changes.

Useful? React with 👍 / 👎.

const device = pickDevice(devices, selectedUdid);
const udid = device?.udid ?? null;
const booted = device?.state === 'Booted';
const canStream = sessionId !== null && udid !== null && booted;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate live streaming on the interactive capability probe

On macOS hosts where simctl is available but SimulatorKit/private framebuffer access is unavailable, simulatorStatus().available remains true and this condition enables the live panel. The sidecar probe already returns interactive: false, but SimProbeSchema discards that field and the status contract never propagates it, so every stream start fails and Retry can never succeed. Surface and check the interactive capability before mounting the stream UI.

Useful? React with 👍 / 👎.

(signal) => {
if (maskUrl == null) return;
const state = paintRef.current;
void fetch(maskUrl, { signal })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode data-URL masks without a CSP-blocked fetch

In the desktop renderer, maskUrl is constructed as a data:image/png;base64,… URL, but apps/desktop/src/renderer/index.html does not allow data: in connect-src, which governs fetch(). The request is therefore rejected and swallowed by .catch(noop), leaving state.mask null and making the advertised device-specific outline silently fall back to generic rounding. Decode the base64 into a Blob directly or load it through a CSP-permitted image path.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds an on-demand iOS Simulator panel with live co-driving. The main changes are:

  • Session-scoped simulator frame streaming and input commands.
  • A refcounted stream registry across panel remounts.
  • Device selection, touch controls, and a live canvas renderer.
  • Runtime device-mask rendering from the local Xcode installation.
  • Persisted simulator section state and end-to-end coverage.

Confidence Score: 5/5

This looks safe to merge.

No additional blocking issue was found in the updated stream lifecycle paths.

T-Rex T-Rex Logs

What T-Rex did

  • Reviewed the simulator-e2e-01-before.log, which records the platform and native prerequisite probe, including the missing sidecar and Apple toolchain.
  • Validated the simulator-e2e-02-after.log, which shows the exact requested command and the explicit 'FAIL: simulator E2E is macOS-only' result.
  • Noted that no screenshot or video was captured because the Electron application never reached the simulator panel.
  • Acknowledged that producing substitute UI was intentionally avoided.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
packages/host/engine/src/simulator/request-handler.ts Adds simulator command routing and session-scoped frame forwarding.
packages/client/workbench/src/simulator/stream-registry.ts Adds shared stream leases with refcounting and delayed shutdown.
packages/client/workbench/src/simulator/panel.tsx Adds device selection, stream state, controls, and conflict feedback.
packages/presentation/ui/src/shell/simulator/simulator-screen.tsx Adds live frame rendering and pointer, keyboard, and gesture input.
crates/linkcode-sim/src/mask.rs Adds runtime rasterization of Simulator device screen masks.

Reviews (8): Last reviewed commit: "docs(sim): record the native-scroll-inje..." | Re-trigger Greptile

Comment thread packages/host/engine/src/simulator/request-handler.ts
Comment on lines +86 to +101
if (entry) {
if (entry.closeTimer !== null) {
clearTimeout(entry.closeTimer);
entry.closeTimer = null;
}
entry.refCount += 1;
} else {
const created: RegistryEntry = {
refCount: 1,
closeTimer: null,
snapshot: { phase: 'starting', sessionId },
listeners: new Set(),
};
registry.set(udid, created);
entry = created;
startStream(client, registry, udid, created);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reacquire Retains Stale Session Claim

When the active thread changes while the same UDID remains acquired, this branch increments the refcount but keeps the first lease's sessionId. Later start and deferred-stop calls use that old claim, which can produce a claim conflict, stop the wrong session's stream, or leave the current panel without frames.

Comment thread packages/host/engine/src/simulator/request-handler.ts
AprilNEA added 6 commits July 23, 2026 14:41
…modules

Decouples the God component into framework-agnostic, unit-testable pure logic:
device-geometry (coordinate mapping), device-compositor (canvas assembly),
frame-decoder (H.264/JPEG decode). The component drops to event wiring + JSX.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant