diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 7e719806c174f..7a20b73095d2f 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -92,6 +92,7 @@ const fixture = Effect.fn("fixture")(function* ( readonly routesStreams?: boolean; readonly onClose?: Effect.Effect<"ok" | "missing" | "reject" | "http_error" | "malformed">; readonly onPlatformAvailability?: Effect.Effect; + readonly onSummary?: Effect.Effect; } = {}, ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); @@ -104,6 +105,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: "" }); + let running = false; let booted = false; let shutDown = false; const connection = { online: true, kind: "local" as "local" | "ssh", failShutdown: false }; @@ -232,12 +234,12 @@ const fixture = Effect.fn("fixture")(function* ( kind: connection.kind, label: "Test server", platforms: [ - { platform: "android", available: true }, - { platform: "ios", available: true }, + { platform: "android" as const, available: true }, + { platform: "ios" as const, available: true }, ], hubInstalled: true, agentDeviceInstalled: false, - })), + })).pipe(Effect.tap(() => options.onSummary ?? Effect.void)), platformAvailability: (platform) => (options.onPlatformAvailability ?? Effect.void).pipe( Effect.as({ platform, available: true }), @@ -246,6 +248,7 @@ const fixture = Effect.fn("fixture")(function* ( Effect.gen(function* () { if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") return yield* runtimeFailure; + running = true; starts.push("start"); yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); return ready; @@ -261,7 +264,7 @@ const fixture = Effect.fn("fixture")(function* ( }; }), current: Effect.sync(() => - connection.online + connection.online && running ? { ...ready, ...(agentStarts.length > agentStops.length @@ -279,6 +282,7 @@ const fixture = Effect.fn("fixture")(function* ( agentStops.push("stop"); }), stop: Effect.sync(() => { + running = false; starts.push("stop"); }), }; @@ -452,6 +456,29 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); + it.effect( + "turning local simulators off stops the hub, hides its devices, and refuses opens", + () => + Effect.gen(function* () { + const { service, starts, settings } = yield* fixture(); + expect((yield* service.configure({ enabled: true })).devices).toHaveLength(1); + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false })); + const off = yield* service.list; + expect(off.devices).toEqual([]); + expect(off.hostStatuses[LOCAL_DEVICE_HOST_ID]).toBeUndefined(); + expect(starts).toEqual(["start", "stop"]); + yield* service.list; + expect(starts).toEqual(["start", "stop"]); + expect(yield* service.readinessIfSupported()).toBeNull(); + const open = yield* service.readiness().pipe(Effect.flip); + expect(open).toMatchObject({ reason: expect.stringContaining("Settings → Devices") }); + + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: true })); + expect((yield* service.list).devices.map((device) => device.id)).toEqual(["Pixel_API_35"]); + expect(starts).toEqual(["start", "stop", "start"]); + }).pipe(Effect.scoped), + ); + it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () => Effect.gen(function* () { const { service, requests } = yield* fixture(); @@ -673,32 +700,76 @@ it.effect.each([1, 9009])( ), ); -it.effect("publishes boot progress and does not restore sessions after support is disabled", () => +it.effect("does not publish a late local listing failure after the source is disabled", () => Effect.gen(function* () { - const started = yield* Deferred.make(); + const entered = yield* Deferred.make(); const finish = yield* Deferred.make(); - const { service } = yield* fixture( - Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))), + let pause = false; + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + undefined, + { + onSummary: Effect.suspend(() => + pause + ? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(finish))) + : Effect.void, + ), + }, ); yield* service.configure({ enabled: true }); - const opening = yield* service - .open({ threadId: ThreadId.make("thread-1"), deviceId: "Pixel_API_35", platform: "android" }) - .pipe(Effect.result, Effect.forkChild); - yield* Deferred.await(started); - expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([ - "Pixel_API_35", - ]); - yield* service.configure({ enabled: false }); + pause = true; + const listing = yield* service.list.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false })); + yield* service.list; yield* Deferred.succeed(finish, undefined); - expect((yield* Fiber.join(opening))._tag).toBe("Failure"); - const state = yield* service.state; - expect(state.hostStatus).toBe("disabled"); - expect(state.devices).toEqual([]); - expect(state.sessions).toEqual([]); - expect(state.bootingDevices).toEqual([]); + const after = yield* Fiber.join(listing); + expect(after.hostStatus).toBe("idle"); + expect(after.hostStatuses[LOCAL_DEVICE_HOST_ID]).toBeUndefined(); + expect(after.devices).toEqual([]); }).pipe(Effect.scoped), ); +it.effect.each(["all", "local"] as const)( + "does not restore sessions after %s support is disabled", + (disabled) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const { service, settings } = yield* fixture( + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))), + ); + yield* service.configure({ enabled: true }); + const opening = yield* service + .open({ + threadId: ThreadId.make("thread-1"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(started); + expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([ + "Pixel_API_35", + ]); + if (disabled === "all") yield* service.configure({ enabled: false }); + else { + yield* Ref.update(settings, (current) => ({ ...current, localDevicesEnabled: false })); + yield* service.list; + } + yield* Deferred.succeed(finish, undefined); + expect((yield* Fiber.join(opening))._tag).toBe("Failure"); + const state = yield* service.state; + expect(state.hostStatus).toBe(disabled === "all" ? "disabled" : "idle"); + expect(state.devices).toEqual([]); + expect(state.sessions).toEqual([]); + expect(state.bootingDevices).toEqual([]); + }).pipe(Effect.scoped), +); + it.effect.each([ { case: "drops it on a host that ends devices itself", endsDevices: true, kept: 0 }, { diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index c860f652c4fe4..603d1bb12b92c 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -225,6 +225,7 @@ export class DeviceService extends Context.Service< Option.Option, DeviceHostAmbiguousError | DeviceOnOtherHostError >; + readonly ensureEnabled: (hostId?: DeviceHostId) => Effect.Effect; readonly readiness: (hostId?: DeviceHostId) => Effect.Effect; readonly readinessIfSupported: ( hostId?: DeviceHostId, @@ -284,6 +285,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* enabled: value.enableDeviceSupport, agentAccessEnabled: value.enableAgentDeviceAccess, onboardingCompleted: value.deviceOnboardingCompleted, + localEnabled: value.localDevicesEnabled, })), Effect.mapError( (cause) => @@ -413,17 +415,32 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* })), ); + const ensureEnabled: DeviceService["Service"]["ensureEnabled"] = Effect.fn( + "DeviceService.ensureEnabled", + )(function* (hostId) { + const host = yield* resolveHost(hostId); + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled) { + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: + "Device support is off. Enable it in the Device panel before installing or starting device tools.", + }); + } + if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) { + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + hostLabel: host.label, + reason: "Local simulators are turned off. Turn them on in Settings → Devices.", + }); + } + }); + const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( function* (hostId) { const host = yield* resolveHost(hostId); - if (!(yield* readDeviceSettings).enabled) { - return yield* new DeviceHostUnavailableError({ - hostId: host.id, - hostLabel: host.label, - reason: - "Device support is off. Enable it in the Device panel before installing or starting device tools.", - }); - } + yield* ensureEnabled(host.id); const ready = yield* host .ensureReady((status, detail) => setHostStatus(host.id, { status, detail }).pipe(Effect.asVoid), @@ -471,8 +488,10 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const readinessIfSupported: DeviceService["Service"]["readinessIfSupported"] = Effect.fn( "DeviceService.readinessIfSupported", )(function* (hostId) { - if (!(yield* readDeviceSettings).enabled) return null; + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled) return null; const host = yield* resolveHost(hostId); + if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) return null; const summary = yield* host.summary; if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; @@ -484,6 +503,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const deviceSettings = yield* readDeviceSettings; if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null; const host = yield* resolveHost(hostId); + if (host.id === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) return null; const summary = yield* host.summary; if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) return null; @@ -554,7 +574,13 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); return yield* lifecycleLock.withPermit( Effect.gen(function* () { - if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host) + const deviceSettings = yield* readDeviceSettings; + if ( + !deviceSettings.enabled || + (ready.hostId === LOCAL_DEVICE_HOST_ID && !deviceSettings.localEnabled) || + !host || + hosts.get(ready.hostId) !== host + ) return (yield* SynchronizedRef.get(stateRef)).state; return yield* publish((state) => ({ ...state, @@ -588,8 +614,41 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); }); + /** Stops the local hub and forgets what it listed, once, after local simulators are turned off. */ + const retireLocalHost = lifecycleLock.withPermit( + Effect.gen(function* () { + const { state } = yield* SynchronizedRef.get(stateRef); + const isLocal = (entry: { readonly hostId: DeviceHostId }) => + entry.hostId === LOCAL_DEVICE_HOST_ID; + const local = hosts.get(LOCAL_DEVICE_HOST_ID); + if ( + !(local && (yield* local.current)) && + !state.bootingDevices?.some(isLocal) && + !state.devices.some(isLocal) && + !state.sessions.some(isLocal) && + state.hostStatuses[LOCAL_DEVICE_HOST_ID] === undefined + ) + return; + if (local) yield* local.stop; + yield* publish((current) => { + const { [LOCAL_DEVICE_HOST_ID]: _retired, ...hostStatuses } = current.hostStatuses; + return { + ...current, + hostStatus: "idle", + hostStatusDetail: undefined, + hostStatuses, + devices: current.devices.filter((device) => !isLocal(device)), + sessions: current.sessions.filter((session) => !isLocal(session)), + bootingDevices: current.bootingDevices?.filter((device) => !isLocal(device)), + }; + }); + }), + ); + const list: DeviceService["Service"]["list"] = Effect.gen(function* () { - if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled) return (yield* SynchronizedRef.get(stateRef)).state; + if (!deviceSettings.localEnabled) yield* retireLocalHost; yield* Effect.forEach( hosts.values(), (host) => @@ -598,10 +657,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (ready) yield* refresh(ready); }).pipe( Effect.catch((error) => - setHostStatus(host.id, { - status: "failed", - detail: error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, - }), + lifecycleLock.withPermit( + Effect.gen(function* () { + const current = yield* readDeviceSettings; + if (!current.enabled || (host.id === LOCAL_DEVICE_HOST_ID && !current.localEnabled)) + return; + yield* setHostStatus(host.id, { + status: "failed", + detail: + error._tag === "DeviceHostUnavailableError" ? error.reason : error.message, + }); + }), + ), ), ), { concurrency: 4 }, @@ -737,6 +804,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")( function* (input, context) { const host = yield* resolveHost(input.hostId); + yield* ensureEnabled(host.id); yield* ensurePlatform(host, input.platform); const ready = yield* readiness(host.id); let state = yield* refresh(ready); @@ -823,6 +891,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* hostLabel: host.label, reason: "Device support was turned off while the device was opening.", }); + yield* ensureEnabled(host.id); yield* publish((current) => ({ ...current, sessions: [ @@ -1191,6 +1260,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* screenshot, locateDevice, locateSession, + ensureEnabled, readiness, readinessIfSupported, agentReadinessIfSupported, diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 0dbd6cbe1c177..f198e8fb36d4d 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -115,6 +115,7 @@ const openSession = { const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ state: Effect.succeed(state), list: Effect.succeed(state), + ensureEnabled: () => Effect.void, agentReadinessIfSupported: () => Effect.succeed(readiness), open: (input) => Effect.succeed({ @@ -245,6 +246,7 @@ it.effect("registers the device tools and returns the screenshot as image conten it.effect("rejects unavailable agent access before booting or opening a device", () => { const layerUnavailable = Layer.mock(DeviceService.DeviceService)({ list: Effect.succeed(state), + ensureEnabled: () => Effect.void, agentReadinessIfSupported: () => Effect.fail( new DeviceHostUnavailableError({ hostId: "local", reason: "Agent access is disabled." }), @@ -354,6 +356,7 @@ describe("device targeting across hosts", () => { const layerDevices = Layer.mock(DeviceService.DeviceService)({ state: Effect.succeed(multiHostState), list: Effect.succeed(multiHostState), + ensureEnabled: () => Effect.void, agentReadinessIfSupported: () => Effect.succeed(readiness), open: (input) => Effect.sync(() => { @@ -520,6 +523,33 @@ describe("device targeting across hosts", () => { ), ); + it.effect("explains disabled local devices before selecting a device", () => + run( + [], + ({ opened, call }) => + Effect.gen(function* () { + for (const input of [{ hostId: "local", platform: "android" }, { platform: "android" }]) { + const result = yield* call("device_open", input); + expect(result.isError).toBe(true); + expect(text(result)).toContain( + "Local simulators are turned off. Turn them on in Settings → Devices.", + ); + } + expect(opened).toEqual([]); + }), + { + list: Effect.succeed({ ...multiHostState, devices: [macIphone] }), + ensureEnabled: () => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: "local", + reason: "Local simulators are turned off. Turn them on in Settings → Devices.", + }), + ), + }, + ), + ); + it.effect("says no host has a device id, naming the host it was asked on", () => run([], ({ opened, call }) => Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index b73661899c85f..4f271e43fea94 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -228,6 +228,8 @@ const handlers = { }); } const hostId = yield* resolveHostId(state.hosts, input.hostId); + if (hostId !== undefined || input.deviceId === undefined) + yield* devices.ensureEnabled(hostId); const target = yield* pickDevice(state, { ...input, hostId }); // Resolve consent and agent connectivity before booting or registering a session. if (!(yield* devices.agentReadinessIfSupported(target.hostId))) { diff --git a/apps/web/src/components/settings/DeviceSourcesSettings.tsx b/apps/web/src/components/settings/DeviceSourcesSettings.tsx index 09ccc32fd61b9..064af8bf24436 100644 --- a/apps/web/src/components/settings/DeviceSourcesSettings.tsx +++ b/apps/web/src/components/settings/DeviceSourcesSettings.tsx @@ -27,6 +27,7 @@ import { Button, InlineButton } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { DeviceHostEditor } from "./DeviceHostEditor"; import { updateDeviceHosts, groupDeviceHosts } from "./deviceHostsSettings.logic"; @@ -351,15 +352,43 @@ export function DeviceSourcesSettings(props: { const rows: SourceRow[] = []; + const localEnabled = (member: (typeof members)[number]) => + member.settings?.localDevicesEnabled !== false; + const anyLocalEnabled = active.some(localEnabled); + const setLocalEnabled = async (enabled: boolean) => { + await write( + members.map((member) => ({ + environmentId: member.environmentId, + patch: { localDevicesEnabled: enabled }, + })), + enabled + ? "Local simulators not turned on everywhere" + : "Local simulators not turned off everywhere", + ); + // Listing again stops or starts the local hub now instead of on the next panel open. + for (const member of active) void list({ environmentId: member.environmentId, input: {} }); + }; + rows.push({ key: "local", shared: false, title: "Local simulators", - description: - members.length > 1 + description: !anyLocalEnabled + ? "Off. Simulators and emulators on this machine stay out of the Device panel and agent tools." + : members.length > 1 ? "Simulators and emulators installed on each environment's own machine." : "Simulators and emulators installed on this environment's machine.", + actions: ( + void setLocalEnabled(Boolean(checked))} + aria-label="Local simulators" + /> + ), environments: active.map((member) => { + if (!localEnabled(member)) + return { environmentId: member.environmentId, label: member.label, added: false }; const local = member.state?.hosts.find((host) => host.kind === "local"); const status = local ? hostStatus(member.state, local.id) : hostStatus(member.state, ""); return { diff --git a/docs/user/devices.md b/docs/user/devices.md index ac053b36c081f..ab79e41a0748a 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -24,7 +24,8 @@ window by its handle, resize it from any edge, and use **Open in right panel** to bring it back. Turn off **Use devices** in **Settings → Devices** to stop the helper processes; simulators and emulators keep running until you power them -off. +off. To use only SSH, EAS, or Simlock sources, turn off **Local simulators** +under **Device sources**; the panel and agents then skip this machine's devices. Simulators run on the machine that hosts the environment server. iOS needs macOS with Xcode. Android needs the SDK Platform-Tools, Android Emulator, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 798e076495a95..aa97cbc742348 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1242,6 +1242,8 @@ export const ServerSettings = Schema.Struct({ /** Enabling the Device panel must not grant providers device control. */ enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Off, the server neither starts nor lists this machine's simulators and emulators. */ + localDevicesEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), easDeviceHosts: EasDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), simlockDeviceHosts: SimlockDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), @@ -1645,6 +1647,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), enableDeviceSupport: Schema.optionalKey(Schema.Boolean), deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), + localDevicesEnabled: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), easDeviceHosts: Schema.optionalKey(EasDeviceHostConfigs), simlockDeviceHosts: Schema.optionalKey(SimlockDeviceHostConfigs),