diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml index 6b9bd0bc20..a7153e43fa 100644 --- a/.github/workflows/studio.yml +++ b/.github/workflows/studio.yml @@ -177,6 +177,68 @@ jobs: node perf/narrow-rail.mjs node perf/selfcheck.mjs branch-tooltip + # What a hosted page does over the relay, end to end: the production bundle, + # the real Studio host and kernel, a real Chromium; only the accounts API and + # the relay are stand-ins (tools/relayfake). Event-transport changes are + # judged here, because a unit test of one side cannot see the other. + relay-harness: + name: relay harness + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Transport changed? + id: scope + run: | + paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host" + if [ "${{ github.event_name }}" = "pull_request" ] && + git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- $paths; then + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + - if: steps.scope.outputs.run == 'true' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + cache: true + + - if: steps.scope.outputs.run == 'true' + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10 + run_install: false + + - if: steps.scope.outputs.run == 'true' + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml + + - if: steps.scope.outputs.run == 'true' + name: Build the host, the stand-ins and the page + working-directory: desktop/frontend-next + run: | + go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host + go build -o relay/bin/relayfake ../../tools/relayfake + pnpm install --frozen-lockfile + pnpm exec vite build --outDir dist-relay --emptyOutDir + pnpm exec playwright install --with-deps chromium + + - if: steps.scope.outputs.run == 'true' + name: Scenarios + working-directory: desktop/frontend-next + env: + RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host + RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake + RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay + run: node relay/live.mjs + # A pull request with no platform-relevant change (scripts/ci-platform-scope.sh) # moves the Windows and macOS legs onto an ubuntu runner with every step # skipped, so the check names still report without a scarce runner. A push to diff --git a/.gitignore b/.gitignore index e5cc91e956..bce610b82f 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/ # running, which on Windows is every rebuild while Studio is open. *.exe *.exe~ +/relayfake diff --git a/desktop/frontend-next/.gitignore b/desktop/frontend-next/.gitignore index c581aecf3a..b7de7f20d4 100644 --- a/desktop/frontend-next/.gitignore +++ b/desktop/frontend-next/.gitignore @@ -4,3 +4,5 @@ dist-perf* *.png !public/icons/*.png tsconfig.tsbuildinfo +dist-relay* +relay/bin diff --git a/desktop/frontend-next/relay/README.md b/desktop/frontend-next/relay/README.md new file mode 100644 index 0000000000..09eadd1bbd --- /dev/null +++ b/desktop/frontend-next/relay/README.md @@ -0,0 +1,29 @@ +--- +owner: @esengine +backup: @SivanCola +status: active +reviewed: 2026-10-17 +--- + +# Relay harness + +A hosted page driving a real Studio through a stand-in relay. + +- Real: the production page bundle, its encrypted channel, `reasonix-studio-host` with its kernel, and Chromium. +- Replaced: the accounts API and the message relay (`tools/relayfake`), and the model provider (a scripted stream in `stack.mjs`). + +The page still dials `id.reasonix.io` and `remote.reasonix.io`; the browser +context answers those two hosts with the stand-ins. + +``` +go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host +go build -o relay/bin/relayfake ../../tools/relayfake +pnpm exec vite build --outDir dist-relay --emptyOutDir +RELAY_HOST_BIN=$PWD/relay/bin/studio-host RELAY_FAKE_BIN=$PWD/relay/bin/relayfake \ + RELAY_DIST=$PWD/dist-relay node relay/live.mjs +``` + +`CHROMIUM_PATH` selects a browser other than the one Playwright installed. +A change to how events travel between a page and a kernel is judged by this +run, not only by a unit test of one side. Add a scenario to `live.mjs` rather +than a second harness. diff --git a/desktop/frontend-next/relay/live.mjs b/desktop/frontend-next/relay/live.mjs new file mode 100644 index 0000000000..7bad9f575e --- /dev/null +++ b/desktop/frontend-next/relay/live.mjs @@ -0,0 +1,61 @@ +// A hosted page driving a real Studio through a stand-in relay. What a shipped +// page does over that link is the claim: the production bundle, the encrypted +// channel, the Studio's own host and a real kernel are all real; only the two +// Reasonix services between them are replaced (tools/relayfake). +import { chromium } from "playwright"; +import { startStack, routeServices, until } from "./stack.mjs"; + +const need = (name) => { + if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`); + return process.env[name]; +}; + +const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST") }); +const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}); +const failures = []; +const check = (name, ok, detail = "") => { + console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`); + if (!ok) failures.push(name); +}; + +try { + const context = await browser.newContext({ viewport: { width: 1300, height: 850 } }); + await routeServices(context, stack); + const page = await context.newPage(); + const pageErrors = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await page.goto(stack.pageUrl()); + await page.evaluate(() => { window.__survivor = true; }); + await page.keyboard.press("Escape"); + const pane = await stack.api("/runtimes", "POST", { root: stack.workspace }); + check("a pane opens on the Studio", pane.status === 200, `status ${pane.status}`); + + const composer = page.getByLabel("Task input").first(); + await composer.waitFor({ timeout: 30_000 }); + const text = () => page.evaluate(() => document.body.innerText); + const count = async (word) => ((await text()).match(new RegExp(word, "g")) ?? []).length; + const send = async (message) => { + await composer.fill(message); + await composer.press("Enter"); + }; + + await send("first"); + const live = await until("the reply to stream onto the page", async () => (await count("word11")) >= 1, 20_000).then(() => true, () => false); + check("a reply streams onto the page over the relay", live, `provider saw ${stack.provider.requests.length} request(s)`); + + const dropped = await stack.ctl("drop-controllers", "POST"); + check("the relay drops the page's socket", dropped.dropped === 1, JSON.stringify(dropped)); + await until("the page to reconnect", async () => (await stack.ctl("state")).controllers === 1, 30_000).catch(() => {}); + await send("second"); + const after = await until("a reply after the reconnect", async () => (await count("word11")) >= 2, 40_000).then(() => true, () => false); + check("a reply streams onto the page after the relay drops the socket", after); + + check("the page was never reloaded", await page.evaluate(() => window.__survivor === true)); + check("the page threw no error", pageErrors.length === 0, pageErrors.join(" | ")); +} catch (error) { + check("the scenario ran to the end", false, String(error)); +} finally { + await browser.close(); + await stack.stop(); +} +process.exit(failures.length ? 1 : 0); diff --git a/desktop/frontend-next/relay/stack.mjs b/desktop/frontend-next/relay/stack.mjs new file mode 100644 index 0000000000..0e9b9dca1c --- /dev/null +++ b/desktop/frontend-next/relay/stack.mjs @@ -0,0 +1,172 @@ +import http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function until(what, probe, ms = 20_000) { + const end = Date.now() + ms; + for (;;) { + const value = await probe(); + if (value) return value; + if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); + await wait(100); + } +} + +function firstLine(child, what) { + return new Promise((resolve, reject) => { + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk; + const at = buffer.indexOf("\n"); + if (at >= 0) resolve(buffer.slice(0, at)); + }); + child.once("exit", (code) => reject(new Error(`${what} exited (${code}) before it was ready`))); + }); +} + +function scriptedProvider() { + const requests = []; + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + if (!req.url.includes("chat/completions")) { + res.writeHead(404); + res.end("{}"); + return; + } + requests.push({ at: Date.now(), body }); + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta) => + `data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta }] })}\n\n`; + let n = 0; + const timer = setInterval(() => { + if (n < 12) { + res.write(chunk({ content: `word${n++} ` })); + return; + } + clearInterval(timer); + res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } })}\n\n`); + res.write("data: [DONE]\n\n"); + res.end(); + }, 120); + res.on("close", () => clearInterval(timer)); + }); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, requests, port: server.address().port }))); +} + +function pageServer(dist) { + const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".json": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png" }; + const server = http.createServer((req, res) => { + const target = path.join(dist, decodeURIComponent(new URL(req.url, "http://x").pathname)); + const file = target.startsWith(dist) && fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(dist, "index.html"); + res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" }); + fs.createReadStream(file).pipe(res); + }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, origin: `http://127.0.0.1:${server.address().port}` }))); +} + +/** + * Starts everything between a hosted page and a Studio except the page's browser: + * the real studio-host binary on a fresh data home, a stand-in accounts API and + * relay, a scripted model provider, and a static server for the built page. + */ +export async function startStack({ hostBin, fakeBin, dist }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "relay-stack-")); + const home = path.join(root, "home"); + const workspace = path.join(root, "workspace"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(workspace, { recursive: true }); + const provider = await scriptedProvider(); + fs.writeFileSync(path.join(home, "config.toml"), [ + 'language = "en"', + 'default_model = "fake"', + `providers = [{ name = "fake", kind = "openai", base_url = "http://127.0.0.1:${provider.port}/v1", model = "fake-m", api_key_env = "FAKE_KEY" }]`, + "", + ].join("\n")); + fs.writeFileSync(path.join(home, ".env"), "FAKE_KEY=fixture\n"); + + const fake = spawn(fakeBin, [], { stdio: ["ignore", "pipe", "inherit"] }); + const urls = JSON.parse(await firstLine(fake, "relayfake")); + const ctl = async (route, method = "GET") => (await fetch(`${urls.accounts}/ctl/${route}`, { method })).json(); + + const host = spawn(hostBin, [], { + stdio: ["pipe", "pipe", "inherit"], + env: { + ...process.env, HOME: root, USERPROFILE: root, REASONIX_HOME: home, REASONIX_STATE_HOME: home, + REASONIX_ACCOUNTS_URL: urls.accounts, REASONIX_REMOTE_URL: urls.relay, REASONIX_ACCOUNT_TOKEN: "fixture-token", + FAKE_KEY: "fixture", + }, + }); + const handshake = JSON.parse(await firstLine(host, "studio-host")); + const state = await until("the Studio to reach the relay", async () => { + const s = await ctl("state"); + return s.deviceId && s.deviceOnline ? s : null; + }); + const page = await pageServer(dist); + + const api = async (route, method = "GET", body) => { + const response = await fetch(handshake.origin + route, { + method, + headers: { cookie: `reasonix_token=${handshake.token}`, origin: handshake.origin, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: response.status, json: await response.json().catch(() => null) }; + }; + + return { + urls, deviceId: state.deviceId, pageOrigin: page.origin, workspace, provider, ctl, api, + pageUrl: () => `${page.origin}/?device=${state.deviceId}`, + async stop() { + const gone = new Promise((resolve) => host.once("exit", resolve)); + host.stdin.end(); + host.kill(); + await gone; + fake.kill(); + page.server.close(); + provider.server.close(); + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** + * Points a browser context's two Reasonix services at the stand-ins. The page is + * the production build, so it still dials id.reasonix.io and remote.reasonix.io; + * the requests and the socket are answered here. The socket is a plain pipe: + * the page and the Studio speak their own encrypted protocol across it. + */ +export async function routeServices(context, stack) { + await context.route("https://id.reasonix.io/**", async (route) => { + const request = route.request(); + const cors = { + "access-control-allow-origin": request.headers().origin ?? "*", + "access-control-allow-credentials": "true", + "access-control-allow-headers": "content-type", + }; + if (request.method() === "OPTIONS") { + await route.fulfill({ status: 204, headers: cors }); + return; + } + const answer = await fetch(stack.urls.accounts + new URL(request.url()).pathname, { + method: request.method(), + headers: { "content-type": "application/json" }, + body: request.postData() ?? undefined, + }); + await route.fulfill({ status: answer.status, headers: { ...cors, "content-type": "application/json" }, body: await answer.text() }); + }); + await context.routeWebSocket(/remote\.reasonix\.io/, (page) => { + const upstream = new WebSocket(`${stack.urls.relay}/v1/sessions/connect?fixture=1`, "reasonix.remote.v1"); + const queued = []; + upstream.onopen = () => queued.splice(0).forEach((message) => upstream.send(message)); + upstream.onmessage = (event) => page.send(String(event.data)); + upstream.onclose = (event) => page.close({ code: event.code >= 1000 && event.code < 5000 ? event.code : 1011, reason: event.reason }); + page.onMessage((message) => (upstream.readyState === 1 ? upstream.send(message) : queued.push(message))); + page.onClose(() => upstream.close()); + }); +} diff --git a/tools/relayfake/main.go b/tools/relayfake/main.go new file mode 100644 index 0000000000..8d1251c46d --- /dev/null +++ b/tools/relayfake/main.go @@ -0,0 +1,300 @@ +// Command relayfake stands in for the two services between a hosted page and a +// Studio: the accounts API that registers the Studio and mints a controller's +// grant, and the message relay that carries ciphertext between them. It +// understands routing and nothing else, so everything above it — the page's +// handshake, the encrypted channel, the Studio's host — is the real code. +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strings" + "sync" + + "github.com/gorilla/websocket" +) + +type device struct { + ID string `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + PublicKey string `json:"publicKey"` + Capabilities []string `json:"capabilities"` + RevokedAt *string `json:"revokedAt"` +} + +type controller struct { + id string + conn *websocket.Conn + mu sync.Mutex +} + +func (c *controller) write(kind int, payload []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + return c.conn.WriteMessage(kind, payload) +} + +type fake struct { + mu sync.Mutex + device *device + credential string + tickets map[string]bool + hostConn *websocket.Conn + hostWrite sync.Mutex + controllers map[string]*controller + upgrader websocket.Upgrader +} + +func main() { + f := &fake{ + tickets: map[string]bool{}, + controllers: map[string]*controller{}, + upgrader: websocket.Upgrader{Subprotocols: []string{"reasonix.remote.v1"}, CheckOrigin: func(*http.Request) bool { return true }}, + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + mux := http.NewServeMux() + mux.HandleFunc("GET /me", f.me) + mux.HandleFunc("GET /me/devices", f.listDevices) + mux.HandleFunc("POST /me/devices", f.registerDevice) + mux.HandleFunc("OPTIONS /me/remote-grants", f.cors(func(http.ResponseWriter, *http.Request) {})) + mux.HandleFunc("POST /me/remote-grants", f.cors(f.grant)) + mux.HandleFunc("GET /v1/devices/{id}/connect", f.deviceConnect) + mux.HandleFunc("GET /v1/sessions/connect", f.controllerConnect) + mux.HandleFunc("POST /ctl/drop-controllers", f.dropControllers) + mux.HandleFunc("POST /ctl/drop-device", f.dropDevice) + mux.HandleFunc("GET /ctl/state", f.state) + addr := listener.Addr().String() + out, _ := json.Marshal(map[string]string{"accounts": "http://" + addr, "relay": "ws://" + addr}) + fmt.Println(string(out)) + if err := http.Serve(listener, mux); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func token() string { + raw := make([]byte, 16) + _, _ = rand.Read(raw) + return hex.EncodeToString(raw) +} + +func reply(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func (f *fake) cors(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "content-type") + w.Header().Set("Vary", "Origin") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next(w, r) + } +} + +func (f *fake) me(w http.ResponseWriter, _ *http.Request) { + reply(w, http.StatusOK, map[string]any{"user": map[string]any{"id": 1, "handle": "fixture", "email": "fixture@example.test", "emailVerified": true}}) +} + +func (f *fake) listDevices(w http.ResponseWriter, _ *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + devices := []*device{} + if f.device != nil { + devices = append(devices, f.device) + } + reply(w, http.StatusOK, map[string]any{"devices": devices}) +} + +func (f *fake) registerDevice(w http.ResponseWriter, r *http.Request) { + var in device + if json.NewDecoder(r.Body).Decode(&in) != nil || in.PublicKey == "" { + reply(w, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "bad_request"}}) + return + } + f.mu.Lock() + defer f.mu.Unlock() + in.ID = token() + token() + f.device, f.credential = &in, token() + reply(w, http.StatusOK, map[string]any{"device": f.device, "deviceCredential": f.credential}) +} + +func (f *fake) grant(w http.ResponseWriter, r *http.Request) { + var in struct { + TargetDeviceID string `json:"targetDeviceId"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + f.mu.Lock() + defer f.mu.Unlock() + if f.device == nil || f.device.ID != in.TargetDeviceID { + reply(w, http.StatusNotFound, map[string]any{"error": map[string]string{"code": "device_not_found"}}) + return + } + ticket := token() + f.tickets[ticket] = true + reply(w, http.StatusOK, map[string]any{"grant": map[string]string{"ticket": ticket}, "device": f.device}) +} + +func (f *fake) deviceConnect(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + known := f.device != nil && f.device.ID == r.PathValue("id") && r.Header.Get("Authorization") == "Bearer "+f.credential + f.mu.Unlock() + if !known { + http.Error(w, "unknown device", http.StatusUnauthorized) + return + } + conn, err := f.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + f.mu.Lock() + f.hostConn = conn + f.mu.Unlock() + f.hello() + defer func() { + f.mu.Lock() + if f.hostConn == conn { + f.hostConn = nil + } + f.mu.Unlock() + conn.Close() + }() + for { + _, payload, err := conn.ReadMessage() + if err != nil { + return + } + var directed struct { + To string `json:"to"` + Payload string `json:"payload"` + } + if json.Unmarshal(payload, &directed) != nil || directed.Payload == "" { + continue + } + f.mu.Lock() + target := f.controllers[directed.To] + f.mu.Unlock() + if target != nil { + _ = target.write(websocket.TextMessage, []byte(directed.Payload)) + } + } +} + +func (f *fake) toHost(message map[string]any) { + wire, _ := json.Marshal(message) + f.mu.Lock() + conn := f.hostConn + f.mu.Unlock() + if conn == nil { + return + } + f.hostWrite.Lock() + defer f.hostWrite.Unlock() + _ = conn.WriteMessage(websocket.TextMessage, wire) +} + +func (f *fake) hello() { + f.mu.Lock() + ids := make([]string, 0, len(f.controllers)) + for id := range f.controllers { + ids = append(ids, id) + } + f.mu.Unlock() + f.toHost(map[string]any{"type": "relay_hello", "controllers": ids, "heartbeatMs": 20000}) +} + +func (f *fake) controllerConnect(w http.ResponseWriter, r *http.Request) { + ticket := "" + for _, offered := range websocket.Subprotocols(r) { + if rest, ok := strings.CutPrefix(offered, "reasonix.auth."); ok { + ticket = rest + } + } + f.mu.Lock() + granted, online := f.tickets[ticket] || r.URL.Query().Get("fixture") == "1", f.hostConn != nil + f.mu.Unlock() + if !granted || !online { + http.Error(w, "no grant or device offline", http.StatusServiceUnavailable) + return + } + conn, err := f.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + c := &controller{id: token(), conn: conn} + f.mu.Lock() + f.controllers[c.id] = c + f.mu.Unlock() + f.toHost(map[string]any{"type": "controller_connected", "connectionId": c.id}) + f.hello() + defer func() { + f.mu.Lock() + delete(f.controllers, c.id) + f.mu.Unlock() + conn.Close() + f.toHost(map[string]any{"type": "controller_disconnected", "connectionId": c.id}) + f.hello() + }() + for { + kind, payload, err := conn.ReadMessage() + if err != nil { + return + } + if kind != websocket.TextMessage { + continue + } + f.toHost(map[string]any{ + "type": "controller_message", "connectionId": c.id, + "scopes": []string{"desktop"}, "payload": string(payload), + }) + } +} + +func (f *fake) dropControllers(w http.ResponseWriter, _ *http.Request) { + f.mu.Lock() + all := make([]*controller, 0, len(f.controllers)) + for _, c := range f.controllers { + all = append(all, c) + } + f.mu.Unlock() + for _, c := range all { + _ = c.conn.Close() + } + reply(w, http.StatusOK, map[string]int{"dropped": len(all)}) +} + +func (f *fake) dropDevice(w http.ResponseWriter, _ *http.Request) { + f.mu.Lock() + conn := f.hostConn + f.mu.Unlock() + if conn != nil { + _ = conn.Close() + } + reply(w, http.StatusOK, map[string]bool{"dropped": conn != nil}) +} + +func (f *fake) state(w http.ResponseWriter, _ *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + id := "" + if f.device != nil { + id = f.device.ID + } + reply(w, http.StatusOK, map[string]any{"deviceId": id, "deviceOnline": f.hostConn != nil, "controllers": len(f.controllers)}) +}