diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 7a0962e06f18b..ef0b48eefc80f 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -110,6 +110,8 @@ export interface DeviceOperations { readonly detail: string | undefined; /** Some Android virtual devices could not be listed; the rest are. */ readonly androidDiscoveryIncomplete?: boolean; + /** Platforms that could not be listed; their last known devices stay listed. */ + readonly incompletePlatforms?: ReadonlyArray; }, DeviceOperationError >; diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 772b626619487..3ecfb6ba4affa 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; @@ -13,6 +14,11 @@ import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost const BOOT_TIMEOUT = Duration.minutes(3); const SCREENSHOT_TIMEOUT = Duration.seconds(20); +// Let the hub's 30 s simctl timeout report its cause first. +const LIST_TIMEOUT_SECONDS = 40; +// expo-device-hub 0.19.1 reports a failed simulator listing only as text; its error ids are hashes. +const SIMULATOR_LIST_FAILED = + /^\[apple-utils\] (?:Failed to (?:run `xcrun simctl list devices|parse JSON output:|list devices:)|`xcrun simctl list devices --json` did not respond within )/; const HubDevice = Schema.Struct({ id: Schema.String, @@ -69,25 +75,30 @@ export const makeDeviceHubOperations = (options: { const { hostId, origin, run } = options; const httpClient = options.http.pipe(HttpClient.withScope); + const requestFailed = (operation: string) => (cause: unknown) => + new DeviceOperationError({ operation, reason: "request_failed", cause }); + + const fetchJson = ( + request: HttpClientRequest.HttpClientRequest, + schema: Schema.Codec, + ) => + httpClient + .execute(request) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + Effect.scoped, + ); + const hubJson = ( request: HttpClientRequest.HttpClientRequest, schema: Schema.Codec, operation: string, timeout: Duration.Input = Duration.seconds(15), ) => - httpClient.execute(request).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), - Effect.scoped, + fetchJson(request, schema).pipe( Effect.timeout(timeout), - Effect.mapError( - (cause) => - new DeviceOperationError({ - operation, - reason: "request_failed", - cause, - }), - ), + Effect.mapError(requestFailed(operation)), ); const postJson = (path: string, body: Record, operation: string) => @@ -106,11 +117,23 @@ export const makeDeviceHubOperations = (options: { ); const list: DeviceOperations["list"] = Effect.gen(function* () { - const hubList = yield* hubJson( + const listed = yield* fetchJson( HttpClientRequest.get(`${origin}/api/devices`), HubDeviceList, - "list", + ).pipe( + Effect.mapError(requestFailed("list")), + Effect.timeoutOption(Duration.seconds(LIST_TIMEOUT_SECONDS)), ); + if (Option.isNone(listed)) { + return { + devices: [], + detail: `The device hub did not answer within ${LIST_TIMEOUT_SECONDS} seconds.`, + incompletePlatforms: (yield* options.androidAvailable) + ? (["ios", "android"] as const) + : (["ios"] as const), + }; + } + const hubList = listed.value; const devices: Array = [...hubList.simulators, ...hubList.emulators]; const androidAvailable = yield* options.androidAvailable; const warnings = hubList.errors?.filter( @@ -155,10 +178,14 @@ export const makeDeviceHubOperations = (options: { } } } + const simulatorsUnlisted = + hubList.simulators.length === 0 && + (hubList.errors ?? []).some((error) => SIMULATOR_LIST_FAILED.test(error.message)); return { devices, detail: details.join("\n") || undefined, androidDiscoveryIncomplete, + ...(simulatorsUnlisted ? { incompletePlatforms: ["ios"] as const } : {}), }; }).pipe(Effect.withSpan("DeviceHubOperations.list")); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index cdcb100918beb..22bdc34930b7e 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -26,6 +26,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ServerConfig from "../config.ts"; @@ -100,6 +101,7 @@ const fixture = Effect.fn("fixture")(function* ( const discoveryErrors = yield* Ref.make>([]); const androidAvailable = yield* Ref.make(true); const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); + const listing = { hangs: false, requested: yield* Deferred.make() }; let booted = false; let shutDown = false; const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false }; @@ -157,13 +159,22 @@ const fixture = Effect.fn("fixture")(function* ( Response.json( bootError ? { ok: false, ...(typeof bootError === "string" ? { error: bootError } : bootError) } - : { ok: true, serial: "emulator-5554" }, + : // The hub reports a serial only for emulators; a simulator keeps its UDID. + request.body._tag === "Uint8Array" && + new TextDecoder().decode(request.body.body).includes('"platform":"ios"') + ? { ok: true } + : { ok: true, serial: "emulator-5554" }, ), ); } if (request.url.endsWith("/grid/api/start")) { return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); } + if (listing.hangs) { + return yield* Deferred.succeed(listing.requested, undefined).pipe( + Effect.andThen(Effect.never), + ); + } return HttpClientResponse.fromWeb( request, Response.json({ @@ -243,14 +254,16 @@ const fixture = Effect.fn("fixture")(function* ( if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") return yield* runtimeFailure; starts.push("start"); - yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); + // A running hub reports no phase, as the real hosts do. + if (starts.length === 1) + yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; }), ensureAgentReady: (onPhase) => Effect.gen(function* () { if (runtimeFailure) return yield* runtimeFailure; agentStarts.push("start"); - yield* onPhase("starting"); + if (agentStarts.length === 1) yield* onPhase("starting"); return { ...ready, agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" }, @@ -325,6 +338,7 @@ const fixture = Effect.fn("fixture")(function* ( encoderResult, discoveryErrors, androidAvailable, + listing, endDevice, }; }); @@ -339,12 +353,19 @@ describe("device setup consent", () => { reason: "daemon_lock_not_directory", cause: { stdout: "private startup diagnostics", code: 1 }, }); - const { service, settings } = yield* fixture(Effect.void, undefined, false, startupError); + const { service, settings, discoveryErrors } = yield* fixture( + Effect.void, + undefined, + false, + startupError, + ); yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true, enableAgentDeviceAccess: true, })); + yield* Ref.set(discoveryErrors, [{ message: simctlMessage }]); + yield* service.list; const error = yield* service.agentReadinessIfSupported().pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DeviceHostUnavailableError", @@ -356,6 +377,7 @@ describe("device setup consent", () => { expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ status: "failed", detail: startupError.message, + incompletePlatforms: ["ios"], }); }).pipe(Effect.scoped), ); @@ -689,6 +711,113 @@ it.effect("keeps the cause from the hub's error in discovery warnings, without t }).pipe(Effect.scoped), ); +it.effect( + "keeps the last simulators while simctl cannot list them, and drops them once it can", + () => + Effect.gen(function* () { + const { service, devices, discoveryErrors } = yield* fixture(); + const simulator = { + id: DeviceId.make("iphone"), + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: true, + physical: false, + } as const; + devices.push(simulator, { ...simulator, id: DeviceId.make("pixel"), platform: "android" }); + yield* service.configure({ enabled: true, agentAccessEnabled: true }); + yield* service.agentReadinessIfSupported(); + devices.length = 0; + yield* Ref.set(discoveryErrors, [{ message: simctlMessage }]); + const failed = yield* service.list; + expect(failed.devices.map((device) => device.id)).toEqual(["iphone", "Pixel_API_35"]); + expect(failed.hostStatuses.local).toEqual({ + status: "ready", + detail: simctlMessage, + incompletePlatforms: ["ios"], + }); + yield* service.agentReadinessIfSupported(); + expect((yield* service.state).hostStatuses.local).toEqual(failed.hostStatuses.local); + yield* Ref.set(discoveryErrors, []); + const recovered = yield* service.list; + expect(recovered.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); + expect(recovered.hostStatuses.local).toEqual({ status: "ready" }); + }).pipe(Effect.scoped), +); + +it.effect.each([ + { android: true, incompletePlatforms: ["ios", "android"], kept: ["iphone", "Pixel_API_35"] }, + { android: false, incompletePlatforms: ["ios"], kept: ["iphone"] }, +] as const)( + "keeps every device when the hub does not answer within 40 seconds (Android SDK: $android)", + ({ android, incompletePlatforms, kept }) => + Effect.gen(function* () { + const { service, devices, listing, androidAvailable } = yield* fixture(); + yield* Ref.set(androidAvailable, android); + devices.push({ + id: DeviceId.make("iphone"), + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: true, + physical: false, + }); + yield* service.configure({ enabled: true }); + listing.hangs = true; + const listed = yield* service.list.pipe(Effect.forkScoped); + yield* Deferred.await(listing.requested); + yield* TestClock.adjust("40 seconds"); + const timedOut = yield* Fiber.join(listed); + expect(timedOut.devices.map((device) => device.id)).toEqual(kept); + expect(timedOut.hostStatuses.local).toEqual({ + status: "ready", + detail: "The device hub did not answer within 40 seconds.", + incompletePlatforms, + }); + }).pipe(Effect.scoped), +); + +it.effect("does not open a retained pre-boot entry when the post-boot listing is incomplete", () => + Effect.gen(function* () { + const afterBoot = { run: Effect.void as Effect.Effect }; + const android = yield* fixture(Effect.suspend(() => afterBoot.run)); + afterBoot.run = Effect.sync(() => { + android.listing.hangs = true; + }); + yield* android.service.configure({ enabled: true }); + const opening = yield* android.service + .open({ + threadId: ThreadId.make("boot-timeout"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Deferred.await(android.listing.requested); + yield* TestClock.adjust("40 seconds"); + expect((yield* Fiber.join(opening))._tag).toBe("DeviceNotFoundError"); + expect((yield* android.service.state).sessions).toEqual([]); + + const ios = yield* fixture(Effect.suspend(() => afterBoot.run)); + afterBoot.run = Effect.sync(() => { + ios.devices.length = 0; + }).pipe(Effect.andThen(Ref.set(ios.discoveryErrors, [{ message: simctlMessage }]))); + ios.devices.push({ + id: DeviceId.make("iphone"), + name: "iPhone", + platform: "ios", + version: "iOS 26", + booted: false, + physical: false, + }); + yield* ios.service.configure({ enabled: true }); + const error = yield* ios.service + .open({ threadId: ThreadId.make("boot-simctl"), deviceId: "iphone", platform: "ios" }) + .pipe(Effect.flip); + expect(error._tag).toBe("DeviceNotFoundError"); + expect((yield* ios.service.state).sessions).toEqual([]); + }).pipe(Effect.scoped), +); + it.effect.each([ { diagnostic: "Insufficient disk space at /private/user/path", diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d91c9d8d0b53b..76a88f119f4c8 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -390,21 +390,36 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.suspend(() => !hosts.has(hostId) ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)) - : publish((state) => ({ - ...state, - ...(hostId === LOCAL_DEVICE_HOST_ID - ? { hostStatus: status.status, hostStatusDetail: status.detail } - : {}), - hostStatuses: { - ...state.hostStatuses, - [hostId]: { - ...(state.hostStatuses[hostId]?.androidDiscoveryIncomplete - ? { androidDiscoveryIncomplete: true } - : {}), - ...status, + : publish((state) => { + const previous = state.hostStatuses[hostId]; + // Discovery problems outlive readiness updates until a listing clears them. + const detail = + status.detail ?? + (status.status === "ready" && + previous?.status === "ready" && + previous.incompletePlatforms + ? previous.detail + : undefined); + return { + ...state, + ...(hostId === LOCAL_DEVICE_HOST_ID + ? { hostStatus: status.status, hostStatusDetail: detail } + : {}), + hostStatuses: { + ...state.hostStatuses, + [hostId]: { + ...(previous?.androidDiscoveryIncomplete + ? { androidDiscoveryIncomplete: true } + : {}), + ...(previous?.incompletePlatforms + ? { incompletePlatforms: previous.incompletePlatforms } + : {}), + ...status, + ...(detail ? { detail } : {}), + }, }, - }, - })), + }; + }), ); const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( @@ -529,11 +544,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); const fetchDevices = Effect.fn("DeviceService.fetchDevices")(function* (ready: DeviceReadiness) { - const { devices, detail, androidDiscoveryIncomplete } = yield* ready.devices.list; + const { devices, detail, androidDiscoveryIncomplete, incompletePlatforms } = + yield* ready.devices.list; return { devices: devices.map((device): DeviceSummary => ({ ...device, hostId: ready.hostId })), detail, androidDiscoveryIncomplete, + incompletePlatforms: incompletePlatforms ?? [], }; }); @@ -544,7 +561,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const sessionKey = (session: DeviceSession) => `${session.hostId}\u0000${session.deviceId}\u0000${session.threadId}\u0000${session.openedAt}`; const before = new Set((yield* SynchronizedRef.get(stateRef)).state.sessions.map(sessionKey)); - const { devices, detail, androidDiscoveryIncomplete } = yield* fetchDevices(ready); + const { devices, detail, androidDiscoveryIncomplete, incompletePlatforms } = + yield* fetchDevices(ready); const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); return yield* lifecycleLock.withPermit( Effect.gen(function* () { @@ -555,8 +573,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* hosts: hostSummaries, ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}), devices: [ - ...state.devices.filter((device) => device.hostId !== ready.hostId), - ...devices, + ...state.devices.filter( + (device) => + device.hostId !== ready.hostId || incompletePlatforms.includes(device.platform), + ), + ...devices.filter((device) => !incompletePlatforms.includes(device.platform)), ], // A host that ends devices itself (EAS stops sessions at their limits) // leaves sessions on devices it no longer lists with nothing to stream. @@ -575,6 +596,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* status: "ready", ...(detail ? { detail } : {}), ...(androidDiscoveryIncomplete ? { androidDiscoveryIncomplete } : {}), + ...(incompletePlatforms.length > 0 ? { incompletePlatforms } : {}), }, }, })); @@ -788,7 +810,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), ); state = yield* refresh(ready); - device = findDevice(state, host.id, bootedId) ?? findDevice(state, host.id, device.id); + // Retained entries predate the boot, so only a complete listing confirms the booted device. + device = state.hostStatuses[host.id]?.incompletePlatforms?.includes(device.platform) + ? undefined + : (findDevice(state, host.id, bootedId) ?? findDevice(state, host.id, device.id)); if (!device) { return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: bootedId }); } diff --git a/apps/web/src/components/device/DevicePanel.browser.test.tsx b/apps/web/src/components/device/DevicePanel.browser.test.tsx index 8e8e0d27eb966..d3d1f07cd7d90 100644 --- a/apps/web/src/components/device/DevicePanel.browser.test.tsx +++ b/apps/web/src/components/device/DevicePanel.browser.test.tsx @@ -213,3 +213,31 @@ describe("DevicePanel host probe", () => { ]); }); }); + +describe("DevicePanel discovery warning", () => { + it("keeps Details open across causes and a dismissal only for the cause it hid", async () => { + setup(); + const withCause = (detail: string): DeviceServiceState => ({ + ...deviceState, + hostStatuses: { local: { status: "ready", detail, incompletePlatforms: ["ios"] } }, + }); + const warning = "Some devices on This Mac could not be listed."; + currentState = withCause("first cause"); + await using screen = await render(devicePanel(threadA), { container }); + + await screen.getByRole("button", { name: "Details for This Mac" }).click(); + await expect.element(screen.getByText("first cause")).toBeVisible(); + + currentState = withCause("second cause"); + await screen.rerender(devicePanel(threadA)); + await expect.element(screen.getByText("second cause")).toBeVisible(); + + await screen.getByRole("button", { name: "Dismiss device warning for This Mac" }).click(); + await screen.rerender(devicePanel(threadA)); + await expect.element(screen.getByText(warning)).not.toBeInTheDocument(); + + currentState = withCause("third cause"); + await screen.rerender(devicePanel(threadA)); + await expect.element(screen.getByText(warning)).toBeVisible(); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index cee7a3e4e4bcd..e5405e9bebb59 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -16,6 +16,7 @@ import { type RightPanelSurface, } from "~/rightPanelStore"; import { Button } from "~/components/ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; import { DiscoveryList } from "~/components/ui/discovery-list"; import { Dialog } from "~/components/ui/dialog"; import { WizardPopup } from "~/components/ui/wizard"; @@ -178,7 +179,14 @@ export function DevicePanel(props: { const status = state.hostStatuses[host.id]; const detail = status?.detail ?? (host.kind === "local" ? state.hostStatusDetail : undefined); return detail && (status?.status ?? state.hostStatus) === "ready" - ? [{ hostId: host.id, label: host.label, detail }] + ? [ + { + hostId: host.id, + label: host.label, + detail, + incomplete: (status?.incompletePlatforms?.length ?? 0) > 0, + }, + ] : []; }); const unavailablePlatforms = state.hosts.flatMap((host) => @@ -214,9 +222,10 @@ export function DevicePanel(props: { @@ -228,9 +237,7 @@ export function DevicePanel(props: { ) : null} {operationError ? ( - setOperation(null)}> - {operationError} - + setOperation(null)}>{operationError} ) : null}
@@ -373,20 +380,11 @@ export function DevicePanel(props: { ); } -function DeviceNotice(props: { - readonly tone: "warning" | "error"; - readonly onDismiss: () => void; - readonly children: ReactNode; -}) { +function DeviceNotice(props: { readonly onDismiss: () => void; readonly children: ReactNode }) { return (
@@ -407,14 +405,53 @@ function DeviceNotice(props: { ); } -function DeviceDiscoveryWarning(props: { readonly label: string; readonly detail: string }) { - const [dismissed, setDismissed] = useState(false); - if (dismissed) return null; +function DeviceDiscoveryWarning(props: { + readonly label: string; + readonly detail: string; + readonly incomplete: boolean; +}) { + const [open, setOpen] = useState(false); + const [dismissedDetail, setDismissedDetail] = useState(null); + if (dismissedDetail === props.detail) return null; return ( - setDismissed(true)}> -

Device discovery warning · {props.label}

- {props.detail} -
+ +
+ + + {props.incomplete + ? `Some devices on ${props.label} could not be listed.` + : `Device discovery reported a warning on ${props.label}.`} + + } + > + Details + + +
+ +
+ {props.detail} +
+
+
); } diff --git a/apps/web/src/components/device/DeviceSetup.test.ts b/apps/web/src/components/device/DeviceSetup.test.ts index 322354763b8d0..12e757e239fe8 100644 --- a/apps/web/src/components/device/DeviceSetup.test.ts +++ b/apps/web/src/components/device/DeviceSetup.test.ts @@ -1,7 +1,11 @@ import type { DeviceServiceState } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { deviceHubReadiness, platformSetupStatus } from "./DeviceSetup"; +import { + deviceHubReadiness, + isAndroidDiscoveryUncertain, + platformSetupStatus, +} from "./DeviceSetup"; const deviceState = (overrides: Partial = {}): DeviceServiceState => ({ hosts: [ @@ -29,7 +33,7 @@ const deviceState = (overrides: Partial = {}): DeviceService }); describe("device setup guidance", () => { - it("waits for complete Android discovery before advising creation of an AVD", () => { + it("waits for complete discovery before advising device setup", () => { const state = deviceState({ hostStatus: "idle", hosts: [{ ...deviceState().hosts[0]!, id: "remote", kind: "ssh" }], @@ -41,6 +45,18 @@ describe("device setup guidance", () => { platformSetupStatus({ ...state, hostStatuses: { remote: { status: "ready" } } }, "android") .message, ).toContain("Device Manager"); + expect( + platformSetupStatus( + { ...state, hostStatuses: { remote: { status: "ready", incompletePlatforms: ["ios"] } } }, + "ios", + ).message, + ).toContain("discovery reported a problem"); + expect( + isAndroidDiscoveryUncertain({ + ...state, + hostStatuses: { remote: { status: "ready", incompletePlatforms: ["ios", "android"] } }, + }), + ).toBe(true); }); it("counts a remote device host when this machine has no simulator tooling", () => { diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 29b46e99319a4..69b30899d0413 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -26,7 +26,9 @@ const agentDeviceDescription = export const isAndroidDiscoveryUncertain = (state: DeviceServiceState) => Object.values(state.hostStatuses).some( - (status) => status.status === "ready" && status.androidDiscoveryIncomplete, + (status) => + status.status === "ready" && + (status.androidDiscoveryIncomplete || status.incompletePlatforms?.includes("android")), ); /** @@ -103,6 +105,16 @@ export function platformSetupStatus(state: DeviceServiceState, platform: DeviceP (host.kind === "local" && state.hostStatus === "ready"), ); if (readyHosts.length > 0 && !hasDevices) { + if ( + readyHosts.some((host) => + state.hostStatuses[host.id]?.incompletePlatforms?.includes(platform), + ) + ) { + return { + ready: false, + message: `No ${platformName(platform)} devices were listed because device discovery reported a problem.`, + }; + } return { ready: false, message: diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index 09ccc32fd61b9..3d586d9b28139 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -115,10 +115,13 @@ function hostStatus( if (!state) return { tone: "pending", label: "Loading" }; const status = state.hostStatuses[hostId]; switch (status?.status) { - case "ready": - return status.androidDiscoveryIncomplete - ? { tone: "warning", label: "Android list may be incomplete", detail: status.detail } - : undefined; + case "ready": { + const incomplete = new Set(status.incompletePlatforms); + if (status.androidDiscoveryIncomplete) incomplete.add("android"); + if (incomplete.size === 0) return undefined; + const list = incomplete.size > 1 ? "Device" : incomplete.has("ios") ? "iOS" : "Android"; + return { tone: "warning", label: `${list} list may be incomplete`, detail: status.detail }; + } case "installing": case "starting": return { tone: "pending", label: "Starting", detail: status.detail }; diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 24b72dbe26d7a..f5c911e02109c 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -252,6 +252,7 @@ export const DeviceHostStatuses = Schema.Record( status: DeviceHostStatus, detail: Schema.optional(Schema.String), androidDiscoveryIncomplete: Schema.optional(Schema.Boolean), + incompletePlatforms: Schema.optional(Schema.Array(DevicePlatform)), }), );