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
78 changes: 78 additions & 0 deletions apps/server/src/device/DeviceHubOperations.test.ts
Original file line number Diff line number Diff line change
@@ -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],
]);
}
}),
);
37 changes: 36 additions & 1 deletion apps/server/src/device/DeviceHubOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, string>();
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<string, string>()),
);

const list: DeviceOperations["list"] = Effect.gen(function* () {
const listed = yield* fetchJson(
HttpClientRequest.get(`${origin}/api/devices`),
Expand All @@ -136,7 +163,15 @@ export const makeDeviceHubOperations = (options: {
};
}
const hubList = listed.value;
const devices: Array<HostDevice> = [...hubList.simulators, ...hubList.emulators];
const simulatorModels =
hubList.simulators.length > 0 ? yield* listSimulatorModels() : new Map<string, string>();
const devices: Array<HostDevice> = [
...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) =>
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/components/device/DeviceStreamView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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";
Expand All @@ -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),
});

Expand Down Expand Up @@ -424,7 +427,7 @@ export function DeviceStreamView(props: {
) : session && showPhone ? (
<DevicePhoneViewport
profile={profile}
model={deviceModel(props.platform, props.deviceName ?? "")}
model={model}
accessory={keyboardAttached ? keyboardSource : null}
source={session.canvas}
onFrameListener={session.setFrameListener}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/device/DeviceWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function DeviceWorkspace(props: {
environmentId={props.environmentId}
platform={props.device.platform}
deviceName={props.device.name}
deviceModel={props.device.model}
deviceDescription={`${props.hostLabel} · ${props.device.version}`}
deviceId={props.device.id}
hostId={props.device.hostId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ function DeviceMiniPlayer({
deviceId={source.deviceId}
hostId={source.hostId}
deviceName={device?.name ?? source.name}
deviceModel={device?.model}
deviceDescription={`${hostLabel} · ${device?.version ?? source.platform}`}
visible
viewId={viewId}
Expand Down
10 changes: 6 additions & 4 deletions docs/user/devices.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ unless you power it off. Closed tabs stay closed after a reload. To watch the
device again, choose it from **+ → Device**.

Choose **3D view** to inspect supported devices while the live screen stays
interactive. On iPhone Duo, use the fold and stance controls to change its
physical pose, or pinch over the device to adjust the hinge. Turning the model
to the other screen switches the live display and touch input to that screen.
**Restore 3D view** returns the device to a screen-facing position.
interactive. The 3D body follows the simulator's device type, so a renamed
simulator still looks like its hardware. On iPhone Duo, use the fold and stance
controls to change its physical pose, or pinch over the device to adjust the
hinge. Turning the model to the other screen switches the live display and touch
input to that screen. **Restore 3D view** returns the device to a screen-facing
position.
On supported Android foldables, use **Fold device** and **Unfold device** beside
the screen to change its posture in either view.

Expand Down
11 changes: 10 additions & 1 deletion packages/client-runtime/src/device/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, it, vi } from "vite-plus/test";
import { createDeviceModelSlot, type DeviceModelSource } from "./model.ts";
import { createDeviceModelSlot, resolveDeviceModelId, type DeviceModelSource } from "./model.ts";

const pro: DeviceModelSource = { id: "iphone-18-pro", url: "/pro.glb" };
const max: DeviceModelSource = { id: "iphone-18-pro-max", url: "/max.glb" };
Expand Down Expand Up @@ -96,3 +96,12 @@ it("releases an invalid model and restores the fallback if scene preparation rej
expect(install).toHaveBeenLastCalledWith(recovered);
slot.dispose();
});

it.each([
["ios", "iPhone 18 Pro", "iphone-18-pro"],
["ios", "iPad Pro 13-inch (M5) (16GB)", "ipad-pro-13-m5"],
["ios", "iPhone (clean)", null],
["android", "iPhone 18 Pro", null],
] as const)("maps %s %s to %s", (platform, name, id) => {
expect(resolveDeviceModelId(platform, name)).toBe(id);
});
3 changes: 2 additions & 1 deletion packages/client-runtime/src/device/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading