mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
test(relay): drive a hosted page against a real Studio through a stand-in relay in CI
This commit is contained in:
1 parent
8bf412accd
commit
81729ee851
7 files changed
+627
No files matched your search
@@ -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
|
||||
|
||||
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
|
||||
# running, which on Windows is every rebuild while Studio is open.
|
||||
*.exe
|
||||
*.exe~
|
||||
/relayfake
|
||||
@@ -4,3 +4,5 @@ dist-perf*
|
||||
*.png
|
||||
!public/icons/*.png
|
||||
tsconfig.tsbuildinfo
|
||||
dist-relay*
|
||||
relay/bin
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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": "[email protected]", "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)})
|
||||
}
|
||||
Reference in new issue
Block a user