From 44d16fa02bacefa6b4113b8cdd195911720ef322 Mon Sep 17 00:00:00 2001 From: Kaloyan Stoyanov Date: Sat, 31 Oct 2026 03:31:17 +0200 Subject: [PATCH 1/2] fix(web): pick the 3D device body from the simulator's hardware type A renamed simulator (for example "iPhone (clean)") lost its 3D body because the panel matched on the user's simulator name. The server now reads each simulator's device type from `xcrun simctl list -j` and reports it as `DeviceSummary.model`; the web panel prefers it over the name. Done with Claude Opus 5.5 in T3 Code. --- .../src/device/DeviceHubOperations.test.ts | 73 +++++++++++++++++++ apps/server/src/device/DeviceHubOperations.ts | 38 +++++++++- .../components/device/DeviceStreamView.tsx | 11 ++- .../src/components/device/DeviceWorkspace.tsx | 1 + .../preview/ThreadPreviewMiniPlayer.tsx | 1 + docs/user/devices.md | 10 ++- .../client-runtime/src/device/model.test.ts | 11 ++- packages/client-runtime/src/device/model.ts | 3 +- packages/contracts/src/device.ts | 2 + 9 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/device/DeviceHubOperations.test.ts diff --git a/apps/server/src/device/DeviceHubOperations.test.ts b/apps/server/src/device/DeviceHubOperations.test.ts new file mode 100644 index 0000000000000..7f6589febe748 --- /dev/null +++ b/apps/server/src/device/DeviceHubOperations.test.ts @@ -0,0 +1,73 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; + +const simulator = (id: string, name: string) => ({ + id, + name, + version: "iOS 27.0", + platform: "ios", + booted: true, + physical: false, +}); + +const operations = (simctl: { stdout: string; code: number }) => + makeDeviceHubOperations({ + hostId: "local", + origin: "http://hub", + http: HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + simulators: [simulator("A", "iPhone (clean)"), simulator("B", "Unknown type")], + emulators: [], + }), + ), + ), + ), + run: (command, args) => + Effect.succeed( + command === "xcrun" && args.join(" ") === "simctl list -j" + ? { ...simctl, stderr: "" } + : { stdout: "", stderr: "", code: 1 }, + ), + androidAvailable: Effect.succeed(false), + }); + +it.effect("names a renamed simulator's hardware from its device type", () => + Effect.gen(function* () { + const { devices } = yield* operations({ + code: 0, + stdout: JSON.stringify({ + devicetypes: [ + { + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro", + name: "iPhone 18 Pro", + }, + ], + devices: { + "com.apple.CoreSimulator.SimRuntime.iOS-27-0": [ + { + udid: "A", + deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-18-Pro", + }, + { udid: "B", deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.Gone" }, + ], + }, + }), + }).list; + expect(devices.map((device) => [device.name, device.model])).toEqual([ + ["iPhone (clean)", "iPhone 18 Pro"], + ["Unknown type", undefined], + ]); + }), +); + +it.effect("still lists simulators when simctl cannot describe them", () => + Effect.gen(function* () { + const { devices } = yield* operations({ code: 1, stdout: "" }).list; + expect(devices.map((device) => device.model)).toEqual([undefined, undefined]); + }), +); diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 131e3b95241ae..47840eba4259c 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -39,6 +39,17 @@ const HubActionResult = Schema.Struct({ ), }); +const SimctlList = Schema.fromJsonString( + Schema.Struct({ + devicetypes: Schema.Array(Schema.Struct({ identifier: Schema.String, name: Schema.String })), + devices: Schema.Record( + Schema.String, + Schema.Array(Schema.Struct({ udid: Schema.String, deviceTypeIdentifier: Schema.String })), + ), + }), +); +const decodeSimctlList = Schema.decodeUnknownEffect(SimctlList); + const vendorPrefix = (platform: DevicePlatform) => platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; @@ -90,13 +101,38 @@ export const makeDeviceHubOperations = (options: { Effect.flatMap((request) => hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT)), ); + // The hub reports only the user's simulator name; the device type survives renames. + const listSimulatorModels = Effect.fn("DeviceHubOperations.listSimulatorModels")( + function* () { + const models = new Map(); + const result = yield* run("xcrun", ["simctl", "list", "-j"]); + if (result.code !== 0) return models; + const { devicetypes, devices } = yield* decodeSimctlList(result.stdout); + const names = new Map(devicetypes.map((type) => [type.identifier, type.name])); + for (const device of Object.values(devices).flat()) { + const name = names.get(device.deviceTypeIdentifier); + if (name) models.set(device.udid, name); + } + return models; + }, + Effect.orElseSucceed(() => new Map()), + ); + const list: DeviceOperations["list"] = Effect.gen(function* () { const hubList = yield* hubJson( HttpClientRequest.get(`${origin}/api/devices`), HubDeviceList, "list", ); - const devices: Array = [...hubList.simulators, ...hubList.emulators]; + const simulatorModels = + hubList.simulators.length > 0 ? yield* listSimulatorModels() : new Map(); + const devices: Array = [ + ...hubList.simulators.map((device) => { + const model = simulatorModels.get(device.id); + return model ? { ...device, model } : device; + }), + ...hubList.emulators, + ]; const androidAvailable = yield* options.androidAvailable; const warnings = hubList.errors?.filter( (error) => diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index 33d7ca9d76884..14bfbb9185695 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -85,6 +85,8 @@ export function DeviceStreamView(props: { readonly platform: DevicePlatform; readonly deviceId: string; readonly deviceName?: string; + /** Hardware product name, which picks the 3D body when the simulator was renamed. */ + readonly deviceModel?: string | undefined; readonly deviceDescription?: string; readonly visible: boolean; readonly hostId: string; @@ -96,7 +98,8 @@ export function DeviceStreamView(props: { /** The host serves only serve-sim's stream and input socket (Simlock). */ readonly serveSimOnly?: boolean; }) { - const model = deviceModel(props.platform, props.deviceName ?? ""); + const hardwareName = props.deviceModel ?? props.deviceName ?? ""; + const model = deviceModel(props.platform, hardwareName); const isDuo = model?.id === "iphone-duo"; const [presentation, setPresentation] = useState<"phone" | "flat">("phone"); const [keyboardAttached, setKeyboardAttached] = useState(false); @@ -235,7 +238,7 @@ export function DeviceStreamView(props: { ? "Turn off accessibility frames to use 3D" : null; - const keyboardSource = deviceKeyboard(props.platform, props.deviceName ?? ""); + const keyboardSource = deviceKeyboard(props.platform, hardwareName); const resetView = () => { if (!isDuo) { const orientation = keyboardAttached ? "landscape_right" : "portrait"; @@ -245,7 +248,7 @@ export function DeviceStreamView(props: { }; const profile = resolveDeviceShape({ platform: props.platform, - name: props.deviceName ?? "", + name: hardwareName, portraitAspect: Math.min(aspect, 1 / aspect), }); @@ -424,7 +427,7 @@ export function DeviceStreamView(props: { ) : session && showPhone ? ( { + expect(resolveDeviceModelId(platform, name)).toBe(id); +}); diff --git a/packages/client-runtime/src/device/model.ts b/packages/client-runtime/src/device/model.ts index 4036c2d741abc..735502163d53b 100644 --- a/packages/client-runtime/src/device/model.ts +++ b/packages/client-runtime/src/device/model.ts @@ -15,12 +15,13 @@ export interface DeviceAccessorySource { export type DeviceAssetSource = DeviceModelSource | DeviceAccessorySource; +/** Matches a hardware name; simulator types can add a memory suffix such as " (16GB)". */ export function resolveDeviceModelId(platform: DevicePlatform, name: string): DeviceModelId | null { if (platform !== "ios") return null; if (/^iPhone Duo$/i.test(name)) return "iphone-duo"; if (/^iPhone 18 Pro Max$/i.test(name)) return "iphone-18-pro-max"; if (/^iPhone 18 Pro$/i.test(name)) return "iphone-18-pro"; - if (/^iPad Pro 13-inch \(M5\)$/i.test(name)) return "ipad-pro-13-m5"; + if (/^iPad Pro 13-inch \(M5\)(?: \(\d+GB\))?$/i.test(name)) return "ipad-pro-13-m5"; return null; } diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 24b72dbe26d7a..4353164846566 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -173,6 +173,8 @@ export const DeviceSummary = Schema.Struct({ id: DeviceId, platform: DevicePlatform, name: TrimmedNonEmptyString, + /** Hardware product name, such as "iPhone 18 Pro", which a renamed simulator keeps. */ + model: Schema.optional(Schema.String), version: Schema.String, booted: Schema.Boolean, physical: Schema.Boolean, From fa97b9a0d425ec5cd8a29bf02cd8714ffa901c89 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 02:18:21 +0000 Subject: [PATCH 2/2] fix(server): bound simulator hardware lookup timeout --- apps/server/src/device/DeviceHubOperations.test.ts | 9 +++++++-- apps/server/src/device/DeviceHubOperations.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/device/DeviceHubOperations.test.ts b/apps/server/src/device/DeviceHubOperations.test.ts index 7f6589febe748..c10036d65daf1 100644 --- a/apps/server/src/device/DeviceHubOperations.test.ts +++ b/apps/server/src/device/DeviceHubOperations.test.ts @@ -67,7 +67,12 @@ it.effect("names a renamed simulator's hardware from its device type", () => it.effect("still lists simulators when simctl cannot describe them", () => Effect.gen(function* () { - const { devices } = yield* operations({ code: 1, stdout: "" }).list; - expect(devices.map((device) => device.model)).toEqual([undefined, undefined]); + for (const code of [1, 124]) { + const { devices } = yield* operations({ code, stdout: "" }).list; + expect(devices.map((device) => [device.id, device.name, device.model])).toEqual([ + ["A", "iPhone (clean)", undefined], + ["B", "Unknown type", undefined], + ]); + } }), ); diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 97daa29f7d2ae..628f94659e951 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -119,7 +119,7 @@ export const makeDeviceHubOperations = (options: { const listSimulatorModels = Effect.fn("DeviceHubOperations.listSimulatorModels")( function* () { const models = new Map(); - const result = yield* run("xcrun", ["simctl", "list", "-j"]); + const result = yield* run("xcrun", ["simctl", "list", "-j"], { timeoutMs: 5_000 }); if (result.code !== 0) return models; const { devicetypes, devices } = yield* decodeSimctlList(result.stdout); const names = new Map(devicetypes.map((type) => [type.identifier, type.name]));