Skip to content
Closed
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
73 changes: 73 additions & 0 deletions apps/server/src/device/DeviceHubOperations.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
}),
);
38 changes: 37 additions & 1 deletion apps/server/src/device/DeviceHubOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, string>();
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<string, string>()),
);

const list: DeviceOperations["list"] = Effect.gen(function* () {
const hubList = yield* hubJson(
HttpClientRequest.get(`${origin}/api/devices`),
HubDeviceList,
"list",
);
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.androidAvailable;
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 @@ -220,7 +223,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 @@ -230,7 +233,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 @@ -416,7 +419,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 @@ -78,6 +78,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
26 changes: 26 additions & 0 deletions apps/web/src/components/device/deviceModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,39 @@ import type { DevicePlatform } from "@t3tools/contracts";
import iphoneDuo from "./models/iphone-duo.glb?url";
import iphone18Pro from "./models/iphone-18-pro.glb?url";
import iphone18ProMax from "./models/iphone-18-pro-max.glb?url";
import iphone17 from "./models/iphone-17.glb?url";
import iphoneAir from "./models/iphone-air.glb?url";
import iphone17e from "./models/iphone-17e.glb?url";
import iphone17Pro from "./models/iphone-17-pro.glb?url";
import iphone17ProMax from "./models/iphone-17-pro-max.glb?url";
import iphone16Pro from "./models/iphone-16-pro.glb?url";
import iphone16ProMax from "./models/iphone-16-pro-max.glb?url";
import iphone16 from "./models/iphone-16.glb?url";
import iphone16Plus from "./models/iphone-16-plus.glb?url";
import iphone15 from "./models/iphone-15.glb?url";
import iphone15Plus from "./models/iphone-15-plus.glb?url";
import iphone14 from "./models/iphone-14.glb?url";
import iphone14Plus from "./models/iphone-14-plus.glb?url";
import magicKeyboard from "./models/ipad-pro-13-m5-magic-keyboard.glb?url";
import ipadPro13M5 from "./models/ipad-pro-13-m5.glb?url";

const models: Record<DeviceModelSource["id"], DeviceModelSource> = {
"iphone-duo": { id: "iphone-duo", url: iphoneDuo },
"iphone-18-pro": { id: "iphone-18-pro", url: iphone18Pro },
"iphone-18-pro-max": { id: "iphone-18-pro-max", url: iphone18ProMax },
"iphone-17": { id: "iphone-17", url: iphone17 },
"iphone-air": { id: "iphone-air", url: iphoneAir },
"iphone-17e": { id: "iphone-17e", url: iphone17e },
"iphone-17-pro": { id: "iphone-17-pro", url: iphone17Pro },
"iphone-17-pro-max": { id: "iphone-17-pro-max", url: iphone17ProMax },
"iphone-16-pro": { id: "iphone-16-pro", url: iphone16Pro },
"iphone-16-pro-max": { id: "iphone-16-pro-max", url: iphone16ProMax },
"iphone-16": { id: "iphone-16", url: iphone16 },
"iphone-16-plus": { id: "iphone-16-plus", url: iphone16Plus },
"iphone-15": { id: "iphone-15", url: iphone15 },
"iphone-15-plus": { id: "iphone-15-plus", url: iphone15Plus },
"iphone-14": { id: "iphone-14", url: iphone14 },
"iphone-14-plus": { id: "iphone-14-plus", url: iphone14Plus },
"ipad-pro-13-m5": { id: "ipad-pro-13-m5", url: ipadPro13M5 },
};

Expand Down
Binary file not shown.
Binary file modified apps/web/src/components/device/models/ipad-pro-13-m5.glb
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified apps/web/src/components/device/models/iphone-18-pro-max.glb
Binary file not shown.
Binary file modified apps/web/src/components/device/models/iphone-18-pro.glb
Binary file not shown.
Binary file not shown.
Binary file modified apps/web/src/components/device/models/iphone-duo.glb
Binary file not shown.
Loading
Loading