diff --git a/apps/server/src/device/DeviceHubOperations.test.ts b/apps/server/src/device/DeviceHubOperations.test.ts new file mode 100644 index 0000000000000..6ff6f6372c1fc --- /dev/null +++ b/apps/server/src/device/DeviceHubOperations.test.ts @@ -0,0 +1,78 @@ +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 }, + ), + platformAvailable: () => 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* () { + 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 2725e7ac4fe29..379c69247fc16 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -45,6 +45,16 @@ 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); // Hub diagnostics can carry a whole stderr dump. const boundDiagnostics = (text: string) => text.length > 2_000 ? `${text.slice(0, 1_999)}…` : text; @@ -116,6 +126,23 @@ 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"], { 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])); + 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 listed = yield* fetchJson( HttpClientRequest.get(`${origin}/api/devices`), @@ -136,7 +163,15 @@ export const makeDeviceHubOperations = (options: { }; } const hubList = listed.value; - 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.platformAvailable("android"); 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 ad52b213f10fe..751a83d0f78c0 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -177,6 +177,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,