From 2a91cd59d1d68c13d25f2c328ebd6a1d1bf9cdd1 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 6 Oct 2026 18:02:10 +0200 Subject: [PATCH 1/5] feat(web): per-device Solenta Web tokens stored hashed (#1512 I2) Named device tokens in web-access.json (0600), shown once, sha256 on disk, constant-time authorize over every row. The single web-token is adopted as Legacy device. Adds qrcode-generator for the pairing QR. Co-Authored-By: Claude Opus 5.5 --- electron/test/webDevices.test.js | 88 +++++++++++++ electron/webDevices.js | 206 +++++++++++++++++++++++++++++++ package-lock.json | 7 ++ package.json | 1 + 4 files changed, 302 insertions(+) create mode 100644 electron/test/webDevices.test.js create mode 100644 electron/webDevices.js diff --git a/electron/test/webDevices.test.js b/electron/test/webDevices.test.js new file mode 100644 index 000000000..b0df23130 --- /dev/null +++ b/electron/test/webDevices.test.js @@ -0,0 +1,88 @@ +"use strict"; + +/** Solenta Web per-device tokens (#1512 I2): hashed store, legacy adoption. */ + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { createWebDevices, hashToken, FILE_NAME, LEGACY_NAME } = require("../webDevices.js"); + +describe("webDevices", () => { + let tmp; + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "solenta-web-devices-")); + }); + afterEach(() => fs.rmSync(tmp, { recursive: true, force: true })); + + it("add shows the token once and stores only its sha256 at 0600", () => { + const devices = createWebDevices(tmp); + const { device, token } = devices.add({ name: "Phone" }); + assert.equal(device.name, "Phone"); + assert.ok(token.length >= 40); + const raw = fs.readFileSync(path.join(tmp, FILE_NAME), "utf8"); + assert.ok(!raw.includes(token), "raw token must never reach disk"); + assert.ok(raw.includes(hashToken(token))); + if (process.platform !== "win32") { + assert.equal(fs.statSync(path.join(tmp, FILE_NAME)).mode & 0o777, 0o600); + } + assert.ok(!("token" in devices.list()[0]) && !("tokenHash" in devices.list()[0])); + }); + + it("authorize matches the right device and stamps lastSeenAt", () => { + let t = 1000; + const devices = createWebDevices(tmp, { now: () => t }); + const a = devices.add({ name: "A" }); + const b = devices.add({ name: "B" }); + t = 5000; + assert.deepEqual(devices.authorize(b.token), { id: b.device.id, name: "B" }); + assert.equal(devices.authorize("wrong"), null); + assert.equal(devices.authorize(""), null); + assert.equal(devices.authorize(undefined), null); + const seen = Object.fromEntries(devices.list().map((d) => [d.name, d.lastSeenAt])); + assert.deepEqual(seen, { A: null, B: 5000 }); + assert.ok(devices.authorize(a.token)); + }); + + it("revoked tokens stop authorizing and the change survives a reload", () => { + const devices = createWebDevices(tmp); + const { device, token } = devices.add({ name: "Tablet" }); + devices.revoke(device.id); + assert.equal(devices.authorize(token), null); + assert.equal(createWebDevices(tmp).authorize(token), null); + assert.throws(() => devices.revoke(device.id), /Unknown device/); + }); + + it("validates names", () => { + const devices = createWebDevices(tmp); + assert.throws(() => devices.add({ name: " " }), /Name the device/); + assert.throws(() => devices.add({ name: "x".repeat(61) }), /at most 60/); + }); + + it("adopts the single web-token as Legacy device, once", () => { + fs.writeFileSync(path.join(tmp, "web-token"), "old-token\n"); + const devices = createWebDevices(tmp); + assert.deepEqual( + devices.list().map((d) => [d.name, d.legacy]), + [[LEGACY_NAME, true]], + ); + assert.ok(devices.authorize("old-token")); + assert.equal(createWebDevices(tmp).list().length, 1, "no duplicate on reload"); + }); + + it("revoking Legacy device deletes the web-token file", () => { + fs.writeFileSync(path.join(tmp, "web-token"), "old-token"); + const devices = createWebDevices(tmp); + devices.revoke(devices.list()[0].id); + assert.ok(!fs.existsSync(path.join(tmp, "web-token"))); + assert.equal(createWebDevices(tmp).list().length, 0); + }); + + it("persists the server toggle, off and loopback-only by default", () => { + const devices = createWebDevices(tmp); + assert.deepEqual(devices.server(), { enabled: false, lan: false }); + devices.setServer({ enabled: true }); + assert.deepEqual(createWebDevices(tmp).server(), { enabled: true, lan: false }); + }); +}); diff --git a/electron/webDevices.js b/electron/webDevices.js new file mode 100644 index 000000000..28f7e388f --- /dev/null +++ b/electron/webDevices.js @@ -0,0 +1,206 @@ +"use strict"; + +/** + * Solenta Web device tokens (#1512 I2, #245). + * + * One named token per device. The raw token is shown once at mint; disk + * stores only the sha256, in /web-access.json at mode 0600 (same + * treatment as pairings.json). The same file remembers whether the user + * turned the web server on, and whether it may listen beyond loopback. + * + * The pre-device single token (/web-token) is adopted as + * "Legacy device". That file stays while the row is active: --serve-web + * prints it and Remote Connections reads it over SSH. Revoking the legacy + * row deletes the file. + */ + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const FILE_NAME = "web-access.json"; +const LEGACY_TOKEN_FILE = "web-token"; +const LEGACY_NAME = "Legacy device"; +const NAME_MAX = 60; +const MAX_DEVICES = 30; +// lastSeenAt only needs minute precision; skip the disk write otherwise. +const SEEN_WRITE_MS = 60 * 1000; + +function hashToken(token) { + return crypto.createHash("sha256").update(String(token), "utf8").digest("hex"); +} + +function emptyState() { + return { server: { enabled: false, lan: false }, devices: [] }; +} + +function load(file) { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (err) { + if (err && err.code === "ENOENT") return emptyState(); + throw new Error(`web access file at ${file} is not valid JSON`); + } + const server = parsed && typeof parsed.server === "object" && parsed.server ? parsed.server : {}; + const rows = Array.isArray(parsed && parsed.devices) ? parsed.devices : []; + return { + server: { enabled: server.enabled === true, lan: server.lan === true }, + devices: rows.filter( + (d) => d && typeof d.id === "string" && typeof d.tokenHash === "string", + ), + }; +} + +function toPublic(row) { + return { + id: row.id, + name: String(row.name || "Device"), + createdAt: Number.isFinite(row.createdAt) ? row.createdAt : 0, + lastSeenAt: Number.isFinite(row.lastSeenAt) ? row.lastSeenAt : null, + legacy: row.legacy === true, + }; +} + +/** + * @param {string} userDataPath + * @param {{ now?: () => number }} [opts] + */ +function createWebDevices(userDataPath, opts = {}) { + if (!userDataPath) throw new Error("userDataPath is required for web devices"); + const now = opts.now || Date.now; + const file = path.join(userDataPath, FILE_NAME); + const legacyFile = path.join(userDataPath, LEGACY_TOKEN_FILE); + const state = load(file); + + function save() { + fs.mkdirSync(userDataPath, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 }); + try { + fs.chmodSync(file, 0o600); + } catch { + // mode is best-effort on some filesystems + } + } + + /** Adopt a single plaintext token as the Legacy device row. Idempotent. */ + function adoptLegacy(token) { + const raw = String(token || "").trim(); + if (!raw) return null; + const hash = hashToken(raw); + const existing = state.devices.find((d) => d.tokenHash === hash); + if (existing) return toPublic(existing); + const row = { + id: crypto.randomUUID(), + name: LEGACY_NAME, + tokenHash: hash, + createdAt: now(), + lastSeenAt: null, + legacy: true, + }; + state.devices.push(row); + save(); + return toPublic(row); + } + + try { + adoptLegacy(fs.readFileSync(legacyFile, "utf8")); + } catch (err) { + if (!err || err.code !== "ENOENT") throw err; + } + + return { + list() { + return state.devices.map(toPublic); + }, + + server() { + return { ...state.server }; + }, + + /** @param {{ enabled?: boolean, lan?: boolean }} patch */ + setServer(patch) { + if (typeof patch.enabled === "boolean") state.server.enabled = patch.enabled; + if (typeof patch.lan === "boolean") state.server.lan = patch.lan; + save(); + return { ...state.server }; + }, + + adoptLegacy, + + /** @returns {{ device: ReturnType, token: string }} */ + add(input) { + const name = String((input && input.name) || "").trim(); + if (!name) throw new Error("Name the device first."); + if (name.length > NAME_MAX) { + throw new Error(`Device names are at most ${NAME_MAX} characters.`); + } + if (state.devices.length >= MAX_DEVICES) { + throw new Error(`At most ${MAX_DEVICES} devices. Revoke one first.`); + } + const token = crypto.randomBytes(32).toString("base64url"); + const row = { + id: crypto.randomUUID(), + name, + tokenHash: hashToken(token), + createdAt: now(), + lastSeenAt: null, + }; + state.devices.push(row); + save(); + return { device: toPublic(row), token }; + }, + + /** Delete the row. Callers must also drop that device's live sockets. */ + revoke(id) { + const i = state.devices.findIndex((d) => d.id === id); + if (i < 0) throw new Error(`Unknown device: ${id}`); + const [row] = state.devices.splice(i, 1); + save(); + if (row.legacy) { + try { + fs.unlinkSync(legacyFile); + } catch { + // already gone + } + } + return toPublic(row); + }, + + /** + * Constant-time match of a presented token against every stored hash. + * Compares fixed-length sha256 digests and never exits early, so the + * timing says nothing about which row (if any) matched. + * + * @returns {{ id: string, name: string } | null} + */ + authorize(token) { + if (typeof token !== "string" || !token) return null; + const presented = Buffer.from(hashToken(token), "hex"); + let found = null; + for (const row of state.devices) { + const stored = Buffer.from(row.tokenHash, "hex"); + if (stored.length !== presented.length) continue; + if (crypto.timingSafeEqual(stored, presented) && !found) found = row; + } + if (!found) return null; + const t = now(); + if (!found.lastSeenAt || t - found.lastSeenAt >= SEEN_WRITE_MS) { + found.lastSeenAt = t; + try { + save(); + } catch { + // a persist miss must not reject a valid device + } + } + return { id: found.id, name: found.name }; + }, + }; +} + +module.exports = { + FILE_NAME, + LEGACY_NAME, + createWebDevices, + hashToken, +}; diff --git a/package-lock.json b/package-lock.json index 69eab7821..140dbed68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dompurify": "^3.4.16", "highlight.js": "^11.12.0", "mermaid": "^12.1.0", + "qrcode-generator": "2.0.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -5131,6 +5132,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", diff --git a/package.json b/package.json index 873f504a0..91d2c65db 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dompurify": "^3.4.16", "highlight.js": "^11.12.0", "mermaid": "^12.1.0", + "qrcode-generator": "2.0.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", From 1c50496c65c8a52118d9e92e1072f47e1a432e0c Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 6 Oct 2026 18:06:53 +0200 Subject: [PATCH 2/5] feat(web): runtime web access with device auth, revoke and rate limit (#1512 I2) webBridge/webServer take an authorize(token) function; each authed socket remembers its device so revoke terminates it at once. Failed auth is rate-limited per address (10/min) across the socket and artifact routes. webAccess.js owns start/stop (loopback unless network access is on), listen URLs, and a detect-only Tailscale Serve helper. Co-Authored-By: Claude Opus 5.5 --- electron/test/webAccess.test.js | 305 ++++++++++++++++++++++++++++++++ electron/webAccess.js | 293 ++++++++++++++++++++++++++++++ electron/webBridge.js | 100 +++++++++-- electron/webServer.js | 60 ++++--- 4 files changed, 723 insertions(+), 35 deletions(-) create mode 100644 electron/test/webAccess.test.js create mode 100644 electron/webAccess.js diff --git a/electron/test/webAccess.test.js b/electron/test/webAccess.test.js new file mode 100644 index 000000000..31a45b26c --- /dev/null +++ b/electron/test/webAccess.test.js @@ -0,0 +1,305 @@ +"use strict"; + +/** + * Solenta Web access (#1512 I2) security properties, against a real server: + * loopback unless asked, revoke mid-session, failed-auth rate limit, + * constant-time token match, and the Tailscale Serve helper (fake CLI). + */ + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const http = require("node:http"); +const Module = require("node:module"); +const { WebSocket } = require("ws"); + +// webBridge → ipc.js requires("electron") at load. +{ + const origLoad = Module._load; + Module._load = function (request) { + if (request === "electron") { + return { + ipcMain: { handle() {} }, + BrowserWindow: { getAllWindows: () => [] }, + dialog: {}, + shell: {}, + app: { getPath: () => os.tmpdir() }, + }; + } + return origLoad.apply(this, arguments); + }; +} + +const { startWebServer } = require("../webServer.js"); +const { WS_PATH, CLOSE_RATE_LIMITED, createAuthLimiter } = require("../webBridge.js"); +const { createWebDevices } = require("../webDevices.js"); +const { createWebAccess, listenUrls } = require("../webAccess.js"); + +const IFACES = { + lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }], + en0: [ + { address: "192.168.1.20", family: "IPv4", internal: false }, + { address: "fe80::1", family: "IPv6", internal: false }, + ], +}; + +function socket(port) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}${WS_PATH}`); + ws.once("open", () => resolve(ws)); + ws.once("error", reject); + }); +} + +/** Auth and resolve with "ok" or the close code. */ +function auth(port, token) { + return socket(port).then( + (ws) => + new Promise((resolve) => { + ws.once("message", (data) => { + if (JSON.parse(String(data)).kind === "auth-ok") resolve({ ws, result: "ok" }); + }); + ws.once("close", (code) => resolve({ ws, result: code })); + ws.send(JSON.stringify({ kind: "auth", token })); + }), + ); +} + +function closed(ws) { + return new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) resolve(); + else ws.once("close", () => resolve()); + }); +} + +function get(url) { + return new Promise((resolve, reject) => { + http.get(url, (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode)); + }).on("error", reject); + }); +} + +describe("webAccess", () => { + let tmp; + let devices; + let access; + let calls; + let tsState; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "solenta-web-access-")); + const artifact = path.join(tmp, "a.txt"); + fs.writeFileSync(artifact, "hi"); + devices = createWebDevices(tmp); + calls = []; + tsState = { installed: true, backend: "Running", serve: {} }; + access = createWebAccess({ + devices, + defaultPort: 0, + networkInterfaces: () => IFACES, + startServer: (opts) => + startWebServer({ + ...opts, + ctx: {}, + handlers: { "echo:ping": async () => "pong" }, + artifactStore: { + open: async () => ({ info: { mimeType: "text/plain" }, size: 2, path: artifact }), + }, + }), + exec: async (bin, args) => { + calls.push([bin, ...args]); + if (!tsState.installed) throw Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }); + if (args[0] === "version") return { stdout: "1.0", stderr: "" }; + if (args[0] === "status") { + return { + stdout: JSON.stringify({ + BackendState: tsState.backend, + Self: { DNSName: "mac.tail1.ts.net." }, + }), + stderr: "", + }; + } + if (args[0] === "serve" && args[1] === "status") { + return { stdout: JSON.stringify(tsState.serve), stderr: "" }; + } + if (args[0] === "serve" && args.includes("off")) { + tsState.serve = {}; + return { stdout: "", stderr: "" }; + } + if (args[0] === "serve") { + tsState.serve = { + Web: { "mac.tail1.ts.net:443": { Handlers: { "/": { Proxy: args.at(-1) } } } }, + }; + return { stdout: "", stderr: "" }; + } + throw new Error(`unexpected ${args.join(" ")}`); + }, + }); + }); + + afterEach(async () => { + await access.stop(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("binds loopback unless the user turns on network access", async () => { + let st = await access.setEnabled({ enabled: true }); + assert.equal(st.running, true); + assert.equal(st.lan, false); + assert.deepEqual(st.urls.map((u) => u.kind), ["local"]); + assert.match(st.urls[0].url, /^http:\/\/127\.0\.0\.1:\d+$/); + + st = await access.setEnabled({ enabled: true, lan: true }); + assert.equal(st.lan, true); + assert.deepEqual( + st.urls.map((u) => u.url.replace(/:\d+$/, "")), + ["http://127.0.0.1", "http://192.168.1.20"], + ); + assert.deepEqual(devices.server(), { enabled: true, lan: true }); + + st = await access.setEnabled({ enabled: false }); + assert.equal(st.running, false); + assert.deepEqual(st.urls, []); + assert.deepEqual(devices.server(), { enabled: false, lan: true }); + }); + + it("restore() starts on loopback only when the switch was left on", async () => { + await access.restore(); + assert.equal(access.isRunning(), false); + devices.setServer({ enabled: true }); + await access.restore(); + const st = await access.status(); + assert.deepEqual(st.urls.map((u) => u.kind), ["local"]); + }); + + it("listenUrls never offers a LAN address for a loopback bind", () => { + assert.deepEqual(listenUrls("127.0.0.1", 1, IFACES), [ + { kind: "local", url: "http://127.0.0.1:1" }, + ]); + assert.deepEqual(listenUrls("10.0.0.4", 1, IFACES), [ + { kind: "lan", url: "http://10.0.0.4:1" }, + ]); + }); + + it("revoke disconnects that device's live sockets, and only those", async () => { + const st = await access.setEnabled({ enabled: true }); + const port = Number(st.urls[0].url.split(":").at(-1)); + const phone = await access.addDevice({ name: "Phone" }); + const laptop = await access.addDevice({ name: "Laptop" }); + const a = await auth(port, phone.token); + const b = await auth(port, laptop.token); + assert.equal(a.result, "ok"); + assert.equal(b.result, "ok"); + const artifactUrl = (token) => + `http://127.0.0.1:${port}/api/run-artifacts/t1/a1?token=${encodeURIComponent(token)}`; + assert.equal(await get(artifactUrl(phone.token)), 200); + + await access.revokeDevice({ id: phone.device.id }); + await closed(a.ws); + assert.equal(b.ws.readyState, WebSocket.OPEN, "other devices stay connected"); + + const again = await auth(port, phone.token); + assert.notEqual(again.result, "ok", "revoked token cannot re-auth"); + assert.equal(await get(artifactUrl(phone.token)), 401, "revoked token loses artifact links"); + assert.equal(await get(artifactUrl(laptop.token)), 200); + b.ws.close(); + }); + + it("rate-limits failed auth per address, even a later correct token", async () => { + const st = await access.setEnabled({ enabled: true }); + const port = Number(st.urls[0].url.split(":").at(-1)); + const { token } = await access.addDevice({ name: "Phone" }); + for (let i = 0; i < 10; i++) { + const r = await auth(port, `wrong-${i}`); + assert.notEqual(r.result, "ok"); + assert.notEqual(r.result, CLOSE_RATE_LIMITED); + } + const blocked = await auth(port, token); + assert.equal(blocked.result, CLOSE_RATE_LIMITED); + assert.equal( + await get(`http://127.0.0.1:${port}/api/run-artifacts/t/a?token=${token}`), + 429, + "the artifact route shares the limiter", + ); + }); + + it("the limiter forgets failures after its window", () => { + let t = 0; + const limiter = createAuthLimiter({ maxFailures: 2, windowMs: 1000, now: () => t }); + limiter.fail("ip"); + limiter.fail("ip"); + assert.equal(limiter.blocked("ip"), true); + assert.equal(limiter.blocked("other"), false); + t = 1001; + assert.equal(limiter.blocked("ip"), false); + }); + + it("token match is constant-time: one timingSafeEqual per device, hit or miss", () => { + const tokens = [1, 2, 3].map((i) => devices.add({ name: `D${i}` }).token); + const orig = crypto.timingSafeEqual; + let n = 0; + crypto.timingSafeEqual = (a, b) => { + n++; + assert.equal(a.length, 32, "compares fixed-length digests, never raw tokens"); + return orig(a, b); + }; + try { + for (const presented of [tokens[0], tokens[2], "miss", "x".repeat(5000)]) { + n = 0; + devices.authorize(presented); + assert.equal(n, 3, `scan does not stop early for ${presented.slice(0, 6)}`); + } + } finally { + crypto.timingSafeEqual = orig; + } + }); + + it("tailscale: reports not installed without trying to install", async () => { + tsState.installed = false; + const st = await access.status(); + assert.equal(st.tailscale.installed, false); + assert.ok(calls.every((c) => c[1] === "version"), "only probes, never installs"); + }); + + it("tailscale: needs a logged-in daemon and a running server", async () => { + tsState.backend = "NeedsLogin"; + assert.equal((await access.status()).tailscale.loggedIn, false); + await assert.rejects(access.setTailscale({ on: true }), /not running or not logged in/); + tsState.backend = "Running"; + await assert.rejects(access.setTailscale({ on: true }), /Turn on Solenta Web first/); + }); + + it("tailscale: serves loopback over HTTPS, reports the URL, and stops", async () => { + const st = await access.setEnabled({ enabled: true }); + const port = st.port; + const on = await access.setTailscale({ on: true }); + assert.deepEqual( + calls.find((c) => c.includes("--bg")).slice(1), + ["serve", "--bg", "--yes", "--https=443", `http://127.0.0.1:${port}`], + ); + assert.equal(on.serving, true); + assert.equal(on.url, "https://mac.tail1.ts.net"); + assert.equal((await access.status()).lan, false, "Serve never needs the LAN bind"); + + const off = await access.setTailscale({ on: false }); + assert.equal(off.serving, false); + }); + + it("tailscale: refuses to replace another HTTPS root, and turning Web off stops Serve", async () => { + await access.setEnabled({ enabled: true }); + tsState.serve = { + Web: { "mac.tail1.ts.net:443": { Handlers: { "/": { Proxy: "http://127.0.0.1:3000" } } } }, + }; + await assert.rejects(access.setTailscale({ on: true }), /already sends HTTPS to http:\/\/127\.0\.0\.1:3000/); + + tsState.serve = {}; + await access.setTailscale({ on: true }); + await access.setEnabled({ enabled: false }); + assert.deepEqual(tsState.serve, {}, "no tailnet URL left pointing at a closed port"); + }); +}); diff --git a/electron/webAccess.js b/electron/webAccess.js new file mode 100644 index 000000000..b3158ef00 --- /dev/null +++ b/electron/webAccess.js @@ -0,0 +1,293 @@ +"use strict"; + +/** + * Solenta Web at runtime (#1512 I2): the Settings switch, device tokens, + * and the optional Tailscale Serve helper. main.js owns one instance; the + * web:* IPC handlers (desktop only) call into it. + * + * Binding: loopback unless the user turns on "Allow devices on my network" + * (or passes --serve-host). Tailscale Serve proxies to loopback, so it never + * needs the LAN bind. + */ + +const os = require("node:os"); +const { execFile } = require("node:child_process"); + +const LOOPBACK = "127.0.0.1"; +const ANY = "0.0.0.0"; +const TAILSCALE_BINS = ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]; +const TAILSCALE_TIMEOUT_MS = 20_000; + +function defaultExec(file, args, opts) { + return new Promise((resolve, reject) => { + execFile(file, args, { timeout: TAILSCALE_TIMEOUT_MS, ...opts }, (err, stdout, stderr) => { + if (err) { + err.stdout = stdout; + err.stderr = stderr; + reject(err); + } else { + resolve({ stdout: String(stdout), stderr: String(stderr) }); + } + }); + }); +} + +function isLoopback(host) { + return host === LOOPBACK || host === "localhost" || host === "::1"; +} + +function isWildcard(host) { + return host === ANY || host === "::" || host === ""; +} + +/** + * @param {string} host bind address + * @param {number} port + * @param {ReturnType} ifaces + * @returns {{ kind: "local" | "lan", url: string }[]} + */ +function listenUrls(host, port, ifaces) { + if (isLoopback(host)) return [{ kind: "local", url: `http://${LOOPBACK}:${port}` }]; + if (!isWildcard(host)) return [{ kind: "lan", url: `http://${host}:${port}` }]; + const urls = [{ kind: "local", url: `http://${LOOPBACK}:${port}` }]; + for (const list of Object.values(ifaces || {})) { + for (const a of list || []) { + if (a.internal || (a.family !== "IPv4" && a.family !== 4)) continue; + urls.push({ kind: "lan", url: `http://${a.address}:${port}` }); + } + } + return urls; +} + +/** Proxy targets in `tailscale serve status --json`, keyed by "host:port". */ +function serveProxies(json) { + const out = []; + for (const [hostPort, web] of Object.entries((json && json.Web) || {})) { + for (const [mount, h] of Object.entries((web && web.Handlers) || {})) { + if (h && typeof h.Proxy === "string") out.push({ hostPort, mount, proxy: h.Proxy }); + } + } + return out; +} + +function pointsAtPort(proxy, port) { + return new RegExp(`^(https?://)?(127\\.0\\.0\\.1|localhost):${port}/?$`).test(proxy); +} + +function errorText(err) { + const msg = [err && err.stderr, err && err.stdout, err && err.message] + .map((s) => String(s || "").trim()) + .find(Boolean); + return msg || "tailscale failed"; +} + +/** + * @param {object} deps + * @param {ReturnType} deps.devices + * @param {(opts: { host: string, port: number, authorize: Function }) => Promise} deps.startServer + * @param {number} deps.defaultPort + * @param {typeof defaultExec} [deps.exec] + * @param {() => ReturnType} [deps.networkInterfaces] + * @param {(msg: string) => void} [deps.log] + */ +function createWebAccess(deps) { + const devices = deps.devices; + const exec = deps.exec || defaultExec; + const networkInterfaces = deps.networkInterfaces || os.networkInterfaces; + const log = deps.log || (() => {}); + /** @type {any} */ + let server = null; + let host = LOOPBACK; + /** @type {string | null | undefined} undefined = not probed yet */ + let tailscaleBin; + + async function start(opts) { + await stop(); + host = opts.host || LOOPBACK; + server = await deps.startServer({ + host, + port: opts.port ?? deps.defaultPort, + authorize: (token) => devices.authorize(token), + }); + return server; + } + + async function stop() { + const s = server; + server = null; + if (s) await s.close(); + } + + async function tailscale(cmdArgs) { + if (tailscaleBin === undefined) { + tailscaleBin = null; + for (const bin of TAILSCALE_BINS) { + try { + await exec(bin, ["version"]); + tailscaleBin = bin; + break; + } catch { + // not this one + } + } + } + if (!tailscaleBin) return null; + return exec(tailscaleBin, cmdArgs); + } + + /** Detect only. Never installs or logs in. */ + async function tailscaleStatus() { + const port = server ? server.port : null; + let st; + try { + st = await tailscale(["status", "--json"]); + } catch (err) { + // `status` exits non-zero when stopped or logged out. + return { installed: true, loggedIn: false, host: null, serving: false, url: null, error: errorText(err) }; + } + if (!st) return { installed: false, loggedIn: false, host: null, serving: false, url: null }; + let json = {}; + try { + json = JSON.parse(st.stdout); + } catch { + // treat as logged out + } + const loggedIn = json.BackendState === "Running"; + const dns = String((json.Self && json.Self.DNSName) || "").replace(/\.$/, ""); + const base = { installed: true, loggedIn, host: dns || null, serving: false, url: null }; + if (!loggedIn || port == null) return base; + try { + const sv = await tailscale(["serve", "status", "--json"]); + const ours = serveProxies(JSON.parse(sv.stdout || "{}")).find( + (p) => p.mount === "/" && pointsAtPort(p.proxy, port), + ); + if (ours) { + const [h, p] = ours.hostPort.split(":"); + return { ...base, serving: true, url: p === "443" ? `https://${h}` : `https://${ours.hostPort}` }; + } + } catch (err) { + log(`solenta-web: tailscale serve status failed: ${errorText(err)}`); + } + return base; + } + + async function setTailscale(on) { + const current = await tailscaleStatus(); + if (!current.installed) throw new Error("Tailscale is not installed."); + if (!current.loggedIn) throw new Error("Tailscale is not running or not logged in."); + if (on) { + if (!server) throw new Error("Turn on Solenta Web first."); + if (current.serving) return current; + // Refuse to replace someone else's HTTPS root on this machine. + const sv = await tailscale(["serve", "status", "--json"]); + const taken = serveProxies(JSON.parse(sv.stdout || "{}")).find( + (p) => p.mount === "/" && p.hostPort.endsWith(":443"), + ); + if (taken) { + throw new Error(`Tailscale Serve already sends HTTPS to ${taken.proxy}. Remove that first.`); + } + try { + await tailscale(["serve", "--bg", "--yes", "--https=443", `http://${LOOPBACK}:${server.port}`]); + } catch (err) { + throw new Error(errorText(err)); + } + } else if (current.serving) { + try { + await tailscale(["serve", "--yes", "--https=443", "off"]); + } catch (err) { + throw new Error(errorText(err)); + } + } + return tailscaleStatus(); + } + + async function status() { + const saved = devices.server(); + return { + running: Boolean(server), + lan: server ? !isLoopback(host) : saved.lan, + port: server ? server.port : deps.defaultPort, + urls: server ? listenUrls(host, server.port, networkInterfaces()) : [], + devices: devices.list(), + tailscale: await tailscaleStatus(), + }; + } + + return { + start, + stop, + status, + + /** Boot: start if the user left the switch on. Never throws. */ + async restore() { + const saved = devices.server(); + if (!saved.enabled) return; + try { + await start({ host: saved.lan ? ANY : LOOPBACK }); + } catch (err) { + log(`solenta-web: cannot start (${err && err.message ? err.message : err})`); + } + }, + + /** @param {{ enabled: boolean, lan?: boolean }} input */ + async setEnabled(input) { + const enabled = Boolean(input && input.enabled); + const lan = input && typeof input.lan === "boolean" ? input.lan : devices.server().lan; + if (!enabled) { + // Do not leave a tailnet URL proxying to a closed port. + try { + if (server && (await tailscaleStatus()).serving) await setTailscale(false); + } catch (err) { + log(`solenta-web: tailscale serve off failed: ${errorText(err)}`); + } + await stop(); + } else if (!server || host !== (lan ? ANY : LOOPBACK)) { + try { + await start({ host: lan ? ANY : LOOPBACK, port: server ? server.port : undefined }); + } catch (err) { + const code = err && err.code; + throw new Error( + code === "EADDRINUSE" + ? `Port ${deps.defaultPort} is already in use. Quit the other Solenta first.` + : String((err && err.message) || err), + ); + } + } + devices.setServer({ enabled, lan }); + return status(); + }, + + /** @param {{ name: string }} input */ + async addDevice(input) { + return devices.add(input); + }, + + /** Revoke, then drop that device's open sockets immediately. */ + async revokeDevice(input) { + const device = devices.revoke(String((input && input.id) || "")); + if (server && typeof server.disconnect === "function") server.disconnect(device.id); + return device; + }, + + /** @param {{ on: boolean }} input */ + async setTailscale(input) { + return setTailscale(Boolean(input && input.on)); + }, + + broadcast(channel, payload) { + if (server) server.broadcast(channel, payload); + }, + + isRunning() { + return Boolean(server); + }, + }; +} + +module.exports = { + createWebAccess, + listenUrls, + serveProxies, + LOOPBACK, + ANY, +}; diff --git a/electron/webBridge.js b/electron/webBridge.js index bd6394421..2ac0b3aac 100644 --- a/electron/webBridge.js +++ b/electron/webBridge.js @@ -8,6 +8,10 @@ * An invoke before auth-ok gets an error reply, then the socket is dropped. * After auth-ok, invoke is dispatched through IPC_HANDLERS (the same object * ipcMain registration iterates). + * + * Failed auth is rate-limited per remote address: past the limit the socket + * is closed before the token is even compared. Each authed socket remembers + * its device, so revoking a device drops its live sockets at once. */ const crypto = require("node:crypto"); @@ -27,6 +31,56 @@ function tokensEqual(a, b) { return crypto.timingSafeEqual(ba, bb); } +const AUTH_MAX_FAILURES = 10; +const AUTH_WINDOW_MS = 60 * 1000; +// Close code for "too many failed attempts" (4000-4999 is app-defined). +const CLOSE_RATE_LIMITED = 4429; + +/** + * Failed-auth counter per key (remote address), sliding window. + * + * @param {{ maxFailures?: number, windowMs?: number, now?: () => number }} [opts] + */ +function createAuthLimiter(opts = {}) { + const maxFailures = opts.maxFailures ?? AUTH_MAX_FAILURES; + const windowMs = opts.windowMs ?? AUTH_WINDOW_MS; + const now = opts.now || Date.now; + /** @type {Map} */ + const fails = new Map(); + function recent(key) { + const t = now(); + const list = (fails.get(key) || []).filter((x) => t - x < windowMs); + if (list.length) fails.set(key, list); + else fails.delete(key); + return list; + } + return { + blocked(key) { + return recent(String(key)).length >= maxFailures; + }, + fail(key) { + const k = String(key); + // ponytail: crude cap so a spray of addresses cannot grow the map forever + if (!fails.has(k) && fails.size >= 1000) fails.delete(fails.keys().next().value); + const list = recent(k); + list.push(now()); + fails.set(k, list); + }, + }; +} + +/** + * Accept either an authorize(token) → {id}|null function or one fixed token. + * + * @param {{ authorize?: (token: string) => { id: string } | null, token?: string }} opts + */ +function resolveAuthorize(opts) { + if (opts && typeof opts.authorize === "function") return opts.authorize; + const token = opts && opts.token; + if (!token) throw new Error("web auth requires a token or an authorize function"); + return (presented) => (tokensEqual(presented, token) ? { id: "token" } : null); +} + /** * @param {import("ws").WebSocket} ws * @param {object} obj @@ -42,7 +96,9 @@ function sendJson(ws, obj) { * * @param {import("node:http").Server} httpServer * @param {object} opts - * @param {string} opts.token + * @param {string} [opts.token] one fixed token (tests, legacy callers) + * @param {(token: string) => ({ id: string } | null)} [opts.authorize] + * @param {ReturnType} [opts.limiter] * @param {object} opts.ctx ctx passed as first arg to IPC_HANDLERS[channel] * @param {typeof IPC_HANDLERS} [opts.handlers] defaults to the exported map * @param {string} [opts.path] @@ -51,20 +107,21 @@ function sendJson(ws, obj) { * @returns {{ * wss: import("ws").WebSocketServer, * broadcast: (channel: string, payload: unknown) => void, + * disconnect: (deviceId: string) => number, * close: () => Promise, * }} */ function attachWebBridge(httpServer, opts) { - const token = opts && opts.token; - if (!token) throw new Error("attachWebBridge requires a token"); + const authorize = resolveAuthorize(opts); + const limiter = opts.limiter || createAuthLimiter(); const ctx = opts.ctx; const handlers = opts.handlers || IPC_HANDLERS; const path = opts.path || WS_PATH; const pingIntervalMs = opts.pingIntervalMs ?? 30000; const maxBufferedBytes = opts.maxBufferedBytes ?? 4 * 1024 * 1024; - /** @type {Set} */ - const authed = new Set(); + /** Authed socket → device id. @type {Map} */ + const authed = new Map(); const wss = new WebSocketServer({ server: httpServer, path }); @@ -85,7 +142,8 @@ function attachWebBridge(httpServer, opts) { clearInterval(interval); }); - wss.on("connection", (ws) => { + wss.on("connection", (ws, req) => { + const remote = (req && req.socket && req.socket.remoteAddress) || "unknown"; let sawFirst = false; let isAuthed = false; ws.isAlive = true; @@ -115,12 +173,18 @@ function attachWebBridge(httpServer, opts) { ws.close(); return; } - if (!tokensEqual(msg.token, token)) { + if (limiter.blocked(remote)) { + ws.close(CLOSE_RATE_LIMITED, "Too many failed attempts"); + return; + } + const device = authorize(msg.token); + if (!device) { + limiter.fail(remote); ws.close(); return; } isAuthed = true; - authed.add(ws); + authed.set(ws, device.id); sendJson(ws, { kind: "auth-ok" }); return; } @@ -177,7 +241,7 @@ function attachWebBridge(httpServer, opts) { return; } const frame = JSON.stringify({ kind: "push", channel, payload }); - for (const client of authed) { + for (const client of authed.keys()) { if (client.readyState !== WebSocket.OPEN) continue; if (client.bufferedAmount > maxBufferedBytes) { authed.delete(client); @@ -188,6 +252,18 @@ function attachWebBridge(httpServer, opts) { } } + /** Terminate every socket a device authed with. Returns how many. */ + function disconnect(deviceId) { + let n = 0; + for (const [client, id] of authed) { + if (id !== deviceId) continue; + authed.delete(client); + client.terminate(); + n++; + } + return n; + } + async function close() { clearInterval(interval); for (const client of wss.clients) { @@ -202,10 +278,14 @@ function attachWebBridge(httpServer, opts) { }); } - return { wss, broadcast, close }; + return { wss, broadcast, disconnect, close }; } module.exports = { attachWebBridge, + createAuthLimiter, + resolveAuthorize, + tokensEqual, + CLOSE_RATE_LIMITED, WS_PATH, }; diff --git a/electron/webServer.js b/electron/webServer.js index 9809d80a0..53d1c231d 100644 --- a/electron/webServer.js +++ b/electron/webServer.js @@ -3,9 +3,11 @@ /** * HTTP static server for Solenta Web, plus the --serve-web flag parser. * - * Binding: 127.0.0.1 by default. --serve-host widens it. - * There is no TLS in v1. Opening the bind beyond loopback puts the + * Binding: 127.0.0.1 by default. --serve-host, or the Settings "Allow + * devices on my network" switch, widens it; nothing else does. + * There is no TLS here. Opening the bind beyond loopback puts the * token-gated API on the LAN; that is the operator's informed choice. + * Tailscale Serve (webAccess.js) is the TLS path. * * Requiring this module creates ZERO listeners. Listeners exist only * after startWebServer() is called (main.js does that solely when @@ -17,7 +19,13 @@ const fs = require("node:fs"); const path = require("node:path"); const crypto = require("node:crypto"); const { pipeline } = require("node:stream/promises"); -const { attachWebBridge, WS_PATH } = require("./webBridge.js"); +const { + attachWebBridge, + createAuthLimiter, + resolveAuthorize, + tokensEqual, + WS_PATH, +} = require("./webBridge.js"); const { resolveByteRange } = require("./artifact-range.js"); const TOKEN_FILENAME = "web-token"; @@ -200,17 +208,6 @@ function serveStatic(req, res, staticDir) { fs.createReadStream(file).pipe(res); } -/** - * @param {string} a - * @param {string} b - */ -function tokensEqual(a, b) { - const ba = Buffer.from(String(a)); - const bb = Buffer.from(String(b)); - if (ba.length !== bb.length) return false; - return crypto.timingSafeEqual(ba, bb); -} - /** * @param {string} filePath * @param {{ start: number, end: number, length: number }} range @@ -288,13 +285,14 @@ async function pipeArtifactStream(req, res, stream) { * @param {import("node:http").IncomingMessage} req * @param {import("node:http").ServerResponse} res * @param {object} opts - * @param {string} opts.token + * @param {string} [opts.token] + * @param {(token: string) => ({ id: string } | null)} [opts.authorize] + * @param {ReturnType} [opts.limiter] * @param {{ open: Function } | null | undefined} opts.artifactStore * @param {URL} opts.url * @param {typeof fs.createReadStream} [opts.createReadStream] */ async function serveRunArtifact(req, res, opts) { - const token = opts.token; const artifactStore = opts.artifactStore; const url = opts.url; const createReadStream = opts.createReadStream || fs.createReadStream; @@ -315,8 +313,16 @@ async function serveRunArtifact(req, res, opts) { return true; } - const queryToken = url.searchParams.get("token"); - if (!tokensEqual(queryToken, token)) { + // Checked on every request, so a revoked device's links die at once. + const remote = (req.socket && req.socket.remoteAddress) || "unknown"; + if (opts.limiter && opts.limiter.blocked(remote)) { + res.writeHead(429, artifactSecurityHeaders()); + res.end(); + return true; + } + const authorize = resolveAuthorize(opts); + if (!authorize(url.searchParams.get("token") || "")) { + if (opts.limiter) opts.limiter.fail(remote); res.writeHead(401, artifactSecurityHeaders()); res.end(); return true; @@ -401,7 +407,8 @@ async function serveRunArtifact(req, res, opts) { * @param {object} opts * @param {string} [opts.host] * @param {number} [opts.port] - * @param {string} opts.token + * @param {string} [opts.token] one fixed token; or pass authorize + * @param {(token: string) => ({ id: string } | null)} [opts.authorize] * @param {object} opts.ctx * @param {object} [opts.handlers] * @param {string | null} [opts.staticDir] @@ -412,16 +419,16 @@ async function serveRunArtifact(req, res, opts) { function startWebServer(opts) { const host = (opts && opts.host) || DEFAULT_HOST; const port = opts && opts.port != null ? opts.port : DEFAULT_PORT; - const token = opts && opts.token; + const authorize = resolveAuthorize(opts); + // One limiter for both doors, so the artifact route is no side channel. + const limiter = createAuthLimiter(); const ctx = opts && opts.ctx; const staticDir = (opts && opts.staticDir) || null; const artifactStore = (opts && opts.artifactStore) || null; const createReadStream = (opts && opts.createReadStream) || fs.createReadStream; const log = (opts && opts.log) || (() => {}); - if (!token) throw new Error("startWebServer requires a token"); - - // No TLS. Default bind is loopback. --serve-host is an informed LAN choice. + // No TLS. Default bind is loopback. LAN is an informed, explicit choice. const server = http.createServer(async (req, res) => { let url; try { @@ -432,7 +439,8 @@ function startWebServer(opts) { return; } const handled = await serveRunArtifact(req, res, { - token, + authorize, + limiter, artifactStore, url, createReadStream, @@ -442,7 +450,8 @@ function startWebServer(opts) { }); const bridge = attachWebBridge(server, { - token, + authorize, + limiter, ctx, handlers: opts.handlers, path: WS_PATH, @@ -476,6 +485,7 @@ function startWebServer(opts) { server, bridge, broadcast: bridge.broadcast, + disconnect: bridge.disconnect, close, }; }); From dea99320062da8a5cf30d2adc0130a724cd4385b Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 6 Oct 2026 18:15:03 +0200 Subject: [PATCH 3/5] feat(web): web:* IPC and boot wiring for the Solenta Web switch (#1512 I2) main.js owns one webAccess: --serve-web still prints its token (now the Legacy device row); otherwise the saved Settings switch restores the server on loopback, or 0.0.0.0 only if network access was turned on. web:* handlers are desktop only. Declared in ipcChannels, CoderApi, preload, devCoder and fakeCoder. Co-Authored-By: Claude Opus 5.5 --- electron/ipc-web.js | 23 ++++++++++++ electron/ipc.js | 3 ++ electron/main.js | 59 ++++++++++++++++++----------- electron/preload.js | 5 +++ electron/test/web.test.js | 8 ++-- electron/test/webAccess.test.js | 13 +++++++ electron/webAccess.js | 5 +++ src/dev/settings.ts | 66 +++++++++++++++++++++++++++++++++ src/devCoder.ts | 3 +- src/shared/ipc.ts | 50 +++++++++++++++++++++++++ src/shared/ipcChannels.ts | 5 +++ test/support/fakeCoder.ts | 52 ++++++++++++++++++++++++++ 12 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 electron/ipc-web.js diff --git a/electron/ipc-web.js b/electron/ipc-web.js new file mode 100644 index 000000000..389f5a7ba --- /dev/null +++ b/electron/ipc-web.js @@ -0,0 +1,23 @@ +"use strict"; + +/** + * IPC_HANDLERS rows for web:* (#1512 I2): the Solenta Web switch, device + * tokens and Tailscale Serve. Desktop only: a paired browser must not be + * able to mint more tokens, revoke others, or turn the server off under + * itself. + */ + +function requireWebAccess(ctx) { + if (ctx.transport !== "desktop" || !ctx.webAccess) { + throw new Error("Solenta Web settings require the desktop app."); + } + return ctx.webAccess; +} + +module.exports = { + "web:status": async (ctx) => requireWebAccess(ctx).status(), + "web:setEnabled": async (ctx, input) => requireWebAccess(ctx).setEnabled(input || {}), + "web:addDevice": async (ctx, input) => requireWebAccess(ctx).addDevice(input || {}), + "web:revokeDevice": async (ctx, input) => requireWebAccess(ctx).revokeDevice(input || {}), + "web:setTailscale": async (ctx, input) => requireWebAccess(ctx).setTailscale(input || {}), +}; diff --git a/electron/ipc.js b/electron/ipc.js index 1dd1cc09f..145bb251d 100644 --- a/electron/ipc.js +++ b/electron/ipc.js @@ -23,6 +23,7 @@ const gitHandlers = require("./ipc-git.js"); const filesHandlers = require("./ipc-files.js"); const devserverHandlers = require("./ipc-devserver.js"); const simulatorHandlers = require("./ipc-simulator.js"); +const webHandlers = require("./ipc-web.js"); /** * Default window fan-out (desktop transport). main.js replaces this with a @@ -74,6 +75,7 @@ function makeCtx(deps) { : () => ({ running: false, port: null }), openRemoteConnection: deps.openRemoteConnection, forgetRemoteConnection: deps.forgetRemoteConnection, + webAccess: deps.webAccess || null, transport: "desktop", }; } @@ -107,6 +109,7 @@ const IPC_HANDLERS = { ...filesHandlers, ...devserverHandlers, ...simulatorHandlers, + ...webHandlers, }; /** diff --git a/electron/main.js b/electron/main.js index e650f17b3..a55a013c1 100644 --- a/electron/main.js +++ b/electron/main.js @@ -60,7 +60,10 @@ const { startWebServer, loadOrCreateToken, HOST_FLAG_HELP, + DEFAULT_PORT: WEB_DEFAULT_PORT, } = require("./webServer.js"); +const { createWebDevices } = require("./webDevices.js"); +const { createWebAccess } = require("./webAccess.js"); const { migrateLegacyUserData } = require("./legacy-migration.js"); const { configureDefaultSecrets } = require("./secrets.js"); const { installCrashGuard } = require("./crash-guard.js"); @@ -161,8 +164,9 @@ let postMergeScheduler = null; /** @type {ReturnType | null} */ let memoryConsolidateScheduler = null; -/** @type {Awaited> | null} */ -let webServer = null; +/** Solenta Web: Settings switch, devices, Tailscale (#1512 I2). */ +/** @type {ReturnType | null} */ +let webAccess = null; /** @type {InstanceType | null} */ let store = null; @@ -354,8 +358,8 @@ function broadcast(channel, payload) { win.webContents.send(channel, payload); } } - if (webServer) { - webServer.broadcast(channel, payload); + if (webAccess) { + webAccess.broadcast(channel, payload); } } @@ -747,7 +751,25 @@ app.whenReady().then(async () => { syncAttentionBadge(store); setInterval(() => syncAttentionBadge(store), 2000); - const registered = registerIpc({ + // ctx is read when the server starts, after registerIpc below returns. + let registered = null; + const webStaticDir = path.join(__dirname, "../dist"); + webAccess = createWebAccess({ + devices: createWebDevices(userData), + defaultPort: WEB_DEFAULT_PORT, + startServer: (opts) => + startWebServer({ + ...opts, + staticDir: fs.existsSync(webStaticDir) ? webStaticDir : null, + ctx: registered.ctx, + artifactStore, + log: (msg) => console.warn(msg), + }), + log: (msg) => console.warn(msg), + }); + + registered = registerIpc({ + webAccess, ipcMain, dialog, store, @@ -786,26 +808,19 @@ app.whenReady().then(async () => { if (serveOpts.enabled) { const token = loadOrCreateToken(userData); + // --serve-web keeps printing one token; it is the Legacy device row. + webAccess.adoptLegacy(token); // Contract: print the token to stdout when serve mode starts. process.stdout.write(`solenta-web: token ${token}\n`); if (serveOpts.host !== "127.0.0.1") { process.stdout.write(`solenta-web: ${HOST_FLAG_HELP}\n`); } - const staticDir = path.join(__dirname, "../dist"); // The port is fixed, so EADDRINUSE is routine (a previous instance still // holds it). Never let that reject out of whenReady: the rest of boot // (IPC ready push, schedulers) would be skipped even though the window // is already up. try { - webServer = await startWebServer({ - host: serveOpts.host, - port: serveOpts.port, - staticDir: fs.existsSync(staticDir) ? staticDir : null, - token, - ctx: registered.ctx, - artifactStore, - log: (msg) => console.warn(msg), - }); + await webAccess.start({ host: serveOpts.host, port: serveOpts.port }); } catch (err) { // No listener means nothing to keep the process alive headless either. serveOpts.enabled = false; @@ -813,6 +828,10 @@ app.whenReady().then(async () => { `solenta-web: cannot listen on ${serveOpts.host}:${serveOpts.port} (${err && err.message ? err.message : err}); continuing without web serve\n`, ); } + } else { + // The Settings switch, if the user left it on. Loopback unless they + // also allowed network access. Never throws. + await webAccess.restore(); } // Round 47: lazy PR-state freshness. Async/serialized/latched so a slow gh @@ -982,13 +1001,9 @@ shutdown = installShutdown({ /** Servers, schedulers, and child processes: last, after runs and the device. */ function teardownServices() { closeRemoteConnections(); - if (webServer) { - try { - void webServer.close(); - } catch { - // ignore - } - webServer = null; + if (webAccess) { + void webAccess.stop().catch(() => {}); + webAccess = null; } if (prStateRefresher) { try { diff --git a/electron/preload.js b/electron/preload.js index 914745d5f..013fc3029 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -99,6 +99,11 @@ const IPC_CHANNELS = Object.freeze([ { ns: "pairing", method: "revoke" }, { ns: "pairing", method: "approve" }, { ns: "pairing", method: "reject" }, + { ns: "web", method: "status" }, + { ns: "web", method: "setEnabled" }, + { ns: "web", method: "addDevice" }, + { ns: "web", method: "revokeDevice" }, + { ns: "web", method: "setTailscale" }, { ns: "skills", method: "list" }, { ns: "skills", method: "add" }, { ns: "skills", method: "remove" }, diff --git a/electron/test/web.test.js b/electron/test/web.test.js index de60d1bdf..04d83f4f0 100644 --- a/electron/test/web.test.js +++ b/electron/test/web.test.js @@ -494,7 +494,7 @@ describe("one map, two transports + packaging", () => { assert.match(verify, /webBridge\.js/); }); - it("main.js starts only on --serve-web via shared ctx", () => { + it("main.js starts on --serve-web or the saved switch, via shared ctx", () => { const main = fs.readFileSync(path.join(__dirname, "../main.js"), "utf8"); assert.match(main, /parseServeWebArgs/); assert.match(main, /startWebServer/); @@ -504,6 +504,8 @@ describe("one map, two transports + packaging", () => { assert.match(main, /HOST_FLAG_HELP/); assert.doesNotMatch(main, /parseServeArgs\(/); assert.match(main, /process\.stdout\.write\(`solenta-web: token/); + assert.match(main, /webAccess\.adoptLegacy\(token\)/); + assert.match(main, /\} else \{[^}]*await webAccess\.restore\(\);/); }); it("main.js survives a web-server bind failure and still opens a window", async () => { @@ -511,8 +513,8 @@ describe("one map, two transports + packaging", () => { // #618: createWindow is first-paint (via bootFirstPaint), so a bind // failure can no longer skip the window. The try/catch still matters: // without it whenReady rejects and IPC-ready / schedulers never run. - assert.match(main, /try \{\s*webServer = await startWebServer\(/); - const start = main.indexOf("webServer = await startWebServer("); + assert.match(main, /try \{\s*await webAccess\.start\(/); + const start = main.indexOf("await webAccess.start("); const guard = main.slice(start, start + 900); assert.match(guard, /\} catch \(err\) \{/); assert.match(guard, /serveOpts\.enabled = false/); diff --git a/electron/test/webAccess.test.js b/electron/test/webAccess.test.js index 31a45b26c..64a46c3cd 100644 --- a/electron/test/webAccess.test.js +++ b/electron/test/webAccess.test.js @@ -302,4 +302,17 @@ describe("webAccess", () => { await access.setEnabled({ enabled: false }); assert.deepEqual(tsState.serve, {}, "no tailnet URL left pointing at a closed port"); }); + + it("web:* handlers are desktop only, so a paired browser cannot mint or revoke", async () => { + const handlers = require("../ipc-web.js"); + for (const channel of Object.keys(handlers)) { + await assert.rejects( + handlers[channel]({ transport: "web", webAccess: access }, {}), + /require the desktop app/, + channel, + ); + } + const st = await handlers["web:status"]({ transport: "desktop", webAccess: access }); + assert.equal(st.running, false); + }); }); diff --git a/electron/webAccess.js b/electron/webAccess.js index b3158ef00..7aaf5169a 100644 --- a/electron/webAccess.js +++ b/electron/webAccess.js @@ -278,6 +278,11 @@ function createWebAccess(deps) { if (server) server.broadcast(channel, payload); }, + /** --serve-web's printed token is the Legacy device row. */ + adoptLegacy(token) { + return devices.adoptLegacy(token); + }, + isRunning() { return Boolean(server); }, diff --git a/src/dev/settings.ts b/src/dev/settings.ts index d16dc80e5..09d1ca0ce 100644 --- a/src/dev/settings.ts +++ b/src/dev/settings.ts @@ -12,6 +12,8 @@ import type { OtelSettings, WebhookSettings, ThreadInfo, + WebAccessStatus, + WebDeviceInfo, } from "../shared/ipc"; import { mergeMcpSettingsPatch, @@ -538,3 +540,67 @@ export function createPairing(ctx: DevCtx): Pick { }, }; } + +/** Solenta Web switch + devices for browser dev and screenshots (#1512 I2). */ +export function createWeb(): Pick { + const now = Date.now(); + let running = false; + let lan = false; + let serving = false; + let devices: WebDeviceInfo[] = [ + { id: "dev-legacy", name: "Legacy device", createdAt: now - 40 * 864e5, lastSeenAt: now - 3 * 864e5, legacy: true }, + { id: "dev-ipad", name: "iPad", createdAt: now - 9 * 864e5, lastSeenAt: now - 12 * 60e3, legacy: false }, + ]; + const status = async (): Promise => ({ + running, + lan, + port: 4620, + urls: running + ? [ + { kind: "local", url: "http://127.0.0.1:4620" }, + ...(lan ? [{ kind: "lan" as const, url: "http://192.168.1.20:4620" }] : []), + ] + : [], + devices, + tailscale: { + installed: true, + loggedIn: true, + host: "studio.tail1234.ts.net", + serving: running && serving, + url: running && serving ? "https://studio.tail1234.ts.net" : null, + }, + }); + return { + web: { + status, + async setEnabled(input) { + running = input.enabled; + if (typeof input.lan === "boolean") lan = input.lan; + if (!running) serving = false; + return status(); + }, + async addDevice(input) { + const device: WebDeviceInfo = { + id: `dev-${devices.length + 1}`, + name: input.name.trim(), + createdAt: Date.now(), + lastSeenAt: null, + legacy: false, + }; + devices = devices.concat(device); + return { device, token: "dEvT0kEn-scan-me-from-the-settings-pane-0123456789" }; + }, + async revokeDevice(input) { + const hit = devices.find((d) => d.id === input.id); + if (!hit) throw new Error(`Unknown device: ${input.id}`); + devices = devices.filter((d) => d.id !== input.id); + return hit; + }, + async setTailscale(input) { + if (input.on && !running) throw new Error("Turn on Solenta Web first."); + serving = input.on; + return (await status()).tailscale; + }, + }, + }; +} diff --git a/src/devCoder.ts b/src/devCoder.ts index 9adc99635..a1dbb747e 100644 --- a/src/devCoder.ts +++ b/src/devCoder.ts @@ -42,7 +42,7 @@ import { seedThreads, seedDetail, } from "./dev/seed.ts"; -import { createSettings, createPairing } from "./dev/settings.ts"; +import { createSettings, createPairing, createWeb } from "./dev/settings.ts"; import { createSkills } from "./dev/skills.ts"; import { createThreads } from "./dev/threads.ts"; import { createUsage } from "./dev/usage.ts"; @@ -473,6 +473,7 @@ function buildDevCoder(): CoderApi { }, }, ...createPairing(ctx), + ...createWeb(), ...createIssues(ctx), ...createWorkspace(), on(channel, cb) { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index ae099cf29..d5211df9a 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -3162,6 +3162,44 @@ export interface PairingCreated { pairingPrompt: string | null; } +/** A browser or phone allowed into Solenta Web (#1512 I2). Token never listed. */ +export interface WebDeviceInfo { + id: string; + name: string; + createdAt: number; + /** Last successful sign-in, minute precision. */ + lastSeenAt: number | null; + /** The single pre-device token (userData/web-token). */ + legacy: boolean; +} + +/** Detected, never installed. `url` is set while Serve points at Solenta Web. */ +export interface WebTailscaleStatus { + installed: boolean; + loggedIn: boolean; + /** MagicDNS name, without the trailing dot. */ + host: string | null; + serving: boolean; + url: string | null; + error?: string; +} + +export interface WebAccessStatus { + running: boolean; + /** Listening beyond loopback (0.0.0.0). Off unless the user turns it on. */ + lan: boolean; + port: number; + urls: { kind: "local" | "lan"; url: string }[]; + devices: WebDeviceInfo[]; + tailscale: WebTailscaleStatus; +} + +/** Returned once from addDevice. The token is stored only as a hash. */ +export interface WebDeviceCreated { + device: WebDeviceInfo; + token: string; +} + /** Whole-definition upsert input. Omitted secrets preserve existing values. */ export type McpServerSaveInput = | { @@ -3931,6 +3969,18 @@ export interface CoderApi { approve(input: { threadId: string }): Promise; reject(input: { threadId: string }): Promise; }; + /** + * Solenta Web (#1512 I2): the in-app server switch, per-device tokens and + * the optional Tailscale Serve helper. Desktop only; a web client gets an + * error. Revoking a device drops its open sockets at once. + */ + web: { + status(): Promise; + setEnabled(input: { enabled: boolean; lan?: boolean }): Promise; + addDevice(input: { name: string }): Promise; + revokeDevice(input: { id: string }): Promise; + setTailscale(input: { on: boolean }): Promise; + }; /** * Agent skills on disk (SKILL.md files). A skill is installed once and * mirrored into every active provider skills dir; list merges those into diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts index 80f1a6d8c..d5a471cce 100644 --- a/src/shared/ipcChannels.ts +++ b/src/shared/ipcChannels.ts @@ -67,6 +67,11 @@ export const IPC_CHANNELS = [ { ns: "pairing", method: "revoke" }, { ns: "pairing", method: "approve" }, { ns: "pairing", method: "reject" }, + { ns: "web", method: "status" }, + { ns: "web", method: "setEnabled" }, + { ns: "web", method: "addDevice" }, + { ns: "web", method: "revokeDevice" }, + { ns: "web", method: "setTailscale" }, { ns: "skills", method: "list" }, { ns: "skills", method: "add" }, { ns: "skills", method: "remove" }, diff --git a/test/support/fakeCoder.ts b/test/support/fakeCoder.ts index b0d158124..e9c018419 100644 --- a/test/support/fakeCoder.ts +++ b/test/support/fakeCoder.ts @@ -55,6 +55,8 @@ import type { PairingCreated, PairingInfo, PairingList, + WebAccessStatus, + WebDeviceInfo, MemoryEntryInfo, MemoryMaintenanceReport, PrChecksResult, @@ -291,6 +293,8 @@ export interface FakeOptions { insights?: FailureMode[]; /** Override usage.byDay (default: empty ledger). */ usage?: UsageReport; + /** Solenta Web (#1512 I2). Default: off, no devices, no Tailscale. */ + webAccess?: WebAccessStatus; } /** Idle terminal session for the harness; no shell exists under jsdom. */ @@ -674,6 +678,15 @@ export function createFakeCoder(opts: FakeOptions = {}): FakeCoder { code: "unsupported_platform", }); + let web: WebAccessStatus = opts.webAccess ?? { + running: false, + lan: false, + port: 4620, + urls: [], + devices: [], + tailscale: { installed: false, loggedIn: false, host: null, serving: false, url: null }, + }; + /** Record the call, then either reject (if configured) or resolve. */ function rec(channel: string, args: unknown[], value: T): Promise { calls.push({ channel, args }); @@ -3475,6 +3488,45 @@ export function createFakeCoder(opts: FakeOptions = {}): FakeCoder { reject: (input: unknown) => rec("pairing.reject", [input], threads[0] ?? ({} as ThreadInfo)), }, + web: { + status: () => rec("web.status", [], { ...web }), + setEnabled: (input: { enabled: boolean; lan?: boolean }) => { + web = { + ...web, + running: input.enabled, + lan: input.lan ?? web.lan, + urls: input.enabled ? [{ kind: "local", url: `http://127.0.0.1:${web.port}` }] : [], + }; + return rec("web.setEnabled", [input], { ...web }); + }, + addDevice: (input: { name: string }) => { + const device: WebDeviceInfo = { + id: `web-dev-${web.devices.length + 1}`, + name: input.name, + createdAt: 0, + lastSeenAt: null, + legacy: false, + }; + web = { ...web, devices: web.devices.concat(device) }; + return rec("web.addDevice", [input], { device, token: "t".repeat(43) }); + }, + revokeDevice: (input: { id: string }) => { + const hit = web.devices.find((d) => d.id === input.id); + web = { ...web, devices: web.devices.filter((d) => d.id !== input.id) }; + return rec("web.revokeDevice", [input], hit as WebDeviceInfo); + }, + setTailscale: (input: { on: boolean }) => { + web = { + ...web, + tailscale: { + ...web.tailscale, + serving: input.on, + url: input.on && web.tailscale.host ? `https://${web.tailscale.host}` : null, + }, + }; + return rec("web.setTailscale", [input], web.tailscale); + }, + }, vibeKanban: { preview: (input?: unknown) => rec("vibeKanban.preview", [input], { From e96f44c0c1edc6ee692055effe29732f5ce3228f Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 6 Oct 2026 18:15:03 +0200 Subject: [PATCH 4/5] feat(settings): Solenta Web section with devices, QR pairing and Tailscale (#1512 I2) Connections pane: serve switch, network-access switch with plain-HTTP warning, Add device with a one-time link and a locally rendered QR, device list with last seen and Revoke, and Expose over Tailscale (HTTPS) with a tailnet exposure note. Co-Authored-By: Claude Opus 5.5 --- src/components/SettingsModal.module.css | 11 + src/components/SettingsModal.tsx | 9 +- src/components/WebAccessSection.tsx | 354 ++++++++++++++++++++++++ test/webAccessSection.test.tsx | 128 +++++++++ 4 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 src/components/WebAccessSection.tsx create mode 100644 test/webAccessSection.test.tsx diff --git a/src/components/SettingsModal.module.css b/src/components/SettingsModal.module.css index 687a60aac..8e5897dd0 100644 --- a/src/components/SettingsModal.module.css +++ b/src/components/SettingsModal.module.css @@ -410,6 +410,17 @@ overflow: auto; } +.webQr { + width: 176px; + height: 176px; + align-self: flex-start; + border-radius: 8px; + border: 1px solid var(--border); + /* QR needs a light quiet zone in either theme to scan. */ + background: #fff; + image-rendering: pixelated; +} + .pairingProjectList { display: flex; flex-direction: column; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index fbd216d91..74d8c0199 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -23,6 +23,7 @@ import { SkillsManager, type SkillsManagerProps } from "./SkillsTab"; import styles from "./SettingsModal.module.css"; import { IntegrationsSection } from "./IntegrationsSection"; import { ConnectionsSection } from "./ConnectionsSection"; +import { WebAccessSection } from "./WebAccessSection"; import { isWebMode } from "../shared/wire"; import type { CoderApi } from "../shared/ipc"; import { PaneIcon } from "./settings/PaneIcon"; @@ -108,8 +109,8 @@ const PANE_META: Record< }, connections: { label: "Connections", - hint: "Run Solenta on another machine through SSH.", - keywords: "remote ssh host server tunnel workstation web token", + hint: "Open Solenta from your phone or a browser, or run it on another machine through SSH.", + keywords: "remote ssh host server tunnel workstation web token phone device qr pair tailscale https lan network browser", }, integrations: { label: "Integrations", @@ -751,7 +752,11 @@ export function SettingsModal({ )} {pane === "connections" && !isWebMode() && ( + <> + +

Remote hosts

+ )} {pane === "memory" && ( diff --git a/src/components/WebAccessSection.tsx b/src/components/WebAccessSection.tsx new file mode 100644 index 000000000..d8cffdb56 --- /dev/null +++ b/src/components/WebAccessSection.tsx @@ -0,0 +1,354 @@ +import { useCallback, useEffect, useState } from "react"; +import { resolveCoderApi } from "../coderApi"; +import { formatRelativeAge } from "../format"; +import type { + CoderApi, + WebAccessStatus, + WebDeviceCreated, + WebDeviceInfo, +} from "../shared/ipc"; +import styles from "./SettingsModal.module.css"; + +/** + * Solenta Web in Settings (#1512 I2): the server switch, per-device tokens + * with a pairing QR, and the optional Tailscale Serve helper. Desktop only; + * the Connections pane is hidden in web mode. + */ + +function resolveWeb(): CoderApi["web"] | null { + try { + const existing = (window as unknown as { coder?: CoderApi }).coder; + const api = existing ?? resolveCoderApi(); + return typeof api.web?.status === "function" ? api.web : null; + } catch { + return null; + } +} + +function message(err: unknown): string { + return err instanceof Error && err.message ? err.message : String(err); +} + +function seenLabel(d: WebDeviceInfo): string { + if (d.lastSeenAt == null) return "Not used yet"; + const r = formatRelativeAge(d.lastSeenAt); + return r === "now" ? "Seen just now" : `Seen ${r} ago`; +} + +/** The address a new device should open: tailnet HTTPS, then LAN, then local. */ +export function pairingBases(status: WebAccessStatus): string[] { + const out: string[] = []; + if (status.tailscale.serving && status.tailscale.url) out.push(status.tailscale.url); + for (const u of status.urls) if (u.kind === "lan") out.push(u.url); + for (const u of status.urls) if (u.kind === "local") out.push(u.url); + return out; +} + +export function pairingLink(base: string, token: string): string { + return `${base.replace(/\/+$/, "")}/?token=${encodeURIComponent(token)}`; +} + +/** Rendered locally from the link; nothing leaves the machine. */ +function useQrDataUrl(text: string | null): string | null { + const [url, setUrl] = useState(null); + useEffect(() => { + if (!text) { + setUrl(null); + return; + } + let live = true; + void import("qrcode-generator").then((mod) => { + if (!live) return; + const qr = mod.default(0, "M"); + qr.addData(text); + qr.make(); + const svg = qr.createSvgTag({ cellSize: 4, margin: 4, scalable: true }); + setUrl(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`); + }); + return () => { + live = false; + }; + }, [text]); + return url; +} + +export function WebAccessSection({ active }: { active: boolean }) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [name, setName] = useState(""); + const [created, setCreated] = useState(null); + const [base, setBase] = useState(null); + const [copied, setCopied] = useState(false); + + const load = useCallback(async () => { + const web = resolveWeb(); + if (!web) return; + try { + setStatus(await web.status()); + } catch (err) { + setError(message(err)); + } + }, []); + + useEffect(() => { + if (active) void load(); + }, [active, load]); + + const run = async (fn: (web: CoderApi["web"]) => Promise) => { + const web = resolveWeb(); + if (!web || busy) return; + setBusy(true); + setError(null); + try { + await fn(web); + } catch (err) { + setError(message(err)); + } finally { + setBusy(false); + } + }; + + const bases = status ? pairingBases(status) : []; + const chosen = base && bases.includes(base) ? base : (bases[0] ?? null); + const link = created && chosen ? pairingLink(chosen, created.token) : null; + const qr = useQrDataUrl(link); + + if (!status) { + return error ? ( +

+ {error} +

+ ) : null; + } + + const ts = status.tailscale; + + return ( +
+

Solenta Web

+
+ + {status.running ? ( + + Listening on{" "} + {status.urls.map((u, i) => ( + + {i > 0 ? ", " : ""} + {u.url} + + ))} + + ) : ( + Off. Turn it on to use Solenta from a browser or your phone. + )} +
+ + +

+ Off, only this computer can connect. On, anyone on your network can + reach the sign-in page over plain HTTP, and each device still needs + its own token. Use Tailscale below for HTTPS. +

+ + {error ? ( +

+ {error} +

+ ) : null} + +

Devices

+ {created ? ( +
+

Pair {created.device.name}

+

+ Scan with the device's camera, or open the link on it. This token + is shown once. +

+ {bases.length > 1 ? ( + + ) : null} + {!status.running ? ( +

Turn on Solenta Web to get a link and QR code.

+ ) : null} + {qr ? ( + {`QR + ) : null} +
+            {link ?? created.token}
+          
+
+ + +
+
+ ) : null} +
+ setName(e.target.value)} + /> + +
+ {status.devices.length > 0 ? ( +
+ {status.devices.map((d) => ( +
+
+

{d.name}

+

+ {seenLabel(d)} + {d.legacy ? " · the token from before devices" : ""} +

+
+ +
+ ))} +
+ ) : ( +

No devices yet. Revoking one signs it out at once.

+ )} + + {ts.installed ? ( +
+

Tailscale

+ {!ts.loggedIn ? ( +

Tailscale is installed but not running or not logged in.

+ ) : ts.serving && ts.url ? ( +
+ {ts.url} + +
+ ) : ( +
+ +
+ )} +

+ This runs Tailscale Serve, which makes Solenta Web reachable over + HTTPS from every device on your tailnet{ts.host ? ` at ${ts.host}` : ""}, + including anyone you share this machine with. Each device still + needs its own token. +

+
+ ) : null} +
+ ); +} diff --git a/test/webAccessSection.test.tsx b/test/webAccessSection.test.tsx new file mode 100644 index 000000000..91a47246d --- /dev/null +++ b/test/webAccessSection.test.tsx @@ -0,0 +1,128 @@ +/** + * Settings > Connections > Solenta Web (#1512 I2): switch, devices with a + * locally rendered pairing QR, revoke, and the Tailscale Serve helper. + */ +import assert from "node:assert/strict"; +import { describe, it, afterEach } from "node:test"; +import { mount, unmountAll } from "./support/dom.ts"; +import { createFakeCoder } from "./support/fakeCoder.ts"; +import { + WebAccessSection, + pairingBases, + pairingLink, +} from "../src/components/WebAccessSection"; +import type { WebAccessStatus } from "../src/shared/ipc"; + +/** The jsdom window exists only after the first mount. */ +async function install(webAccess?: WebAccessStatus) { + (await mount(
)).unmount(); + const fake = createFakeCoder({ webAccess }); + (window as unknown as { coder: unknown }).coder = fake.api; + return fake; +} + +const TS_READY = { installed: true, loggedIn: true, host: "mac.tail1.ts.net", serving: false, url: null }; + +describe("WebAccessSection", () => { + afterEach(() => { + unmountAll(); + if (typeof window !== "undefined") delete (window as unknown as { coder?: unknown }).coder; + }); + + it("starts off and loopback-only; the switch turns it on and shows the URL", async () => { + const fake = await install(); + const m = await mount(); + await m.flush(); + assert.equal(m.query("[data-web-state]")?.getAttribute("data-web-state"), "off"); + assert.equal((m.query("[data-web-lan]") as HTMLInputElement).checked, false); + + await m.click(m.query("[data-web-enabled]")); + await m.flush(); + assert.deepEqual( + fake.calls.find((c) => c.channel === "web.setEnabled")?.args, + [{ enabled: true, lan: false }], + ); + assert.equal(m.query("[data-web-url=local]")?.textContent, "http://127.0.0.1:4620"); + }); + + it("adds a device, shows its link and a local QR once, and revokes it", async () => { + const fake = await install(); + const m = await mount(); + await m.flush(); + await m.click(m.query("[data-web-enabled]")); + await m.flush(); + + await m.type(m.query("[data-web-device-name]"), "Phone"); + await m.click(m.query("[data-web-add-device]")); + for (let i = 0; i < 5 && !m.query("[data-web-qr]"); i++) await m.flush(); + + const link = m.query("[data-web-pair-link]")?.textContent ?? ""; + assert.equal(link, `http://127.0.0.1:4620/?token=${"t".repeat(43)}`); + const qr = m.query("[data-web-qr]") as HTMLImageElement | null; + assert.ok(qr, "QR rendered"); + assert.match(qr.getAttribute("src") ?? "", /^data:image\/svg\+xml/, "no network image"); + + const row = m.query("[data-web-devices] [data-web-revoke]") as HTMLElement; + assert.ok(m.text().includes("Not used yet")); + await m.click(row); + await m.flush(); + assert.ok(fake.calls.some((c) => c.channel === "web.revokeDevice")); + assert.equal(m.query("[data-web-reveal]"), null, "revoking hides its one-time token"); + assert.equal(m.query("[data-web-devices]"), null); + }); + + it("Tailscale: offered only once Web is on, explains the tailnet exposure, and stops", async () => { + const fake = await install({ + running: false, + lan: false, + port: 4620, + urls: [], + devices: [], + tailscale: TS_READY, + }); + const m = await mount(); + await m.flush(); + const start = () => m.query("[data-web-tailscale-start]") as HTMLButtonElement; + assert.equal(start().disabled, true); + assert.ok(m.text().includes("every device on your tailnet at mac.tail1.ts.net")); + + await m.click(m.query("[data-web-enabled]")); + await m.flush(); + await m.click(start()); + await m.flush(); + assert.deepEqual(fake.calls.find((c) => c.channel === "web.setTailscale")?.args, [{ on: true }]); + assert.equal(m.query("[data-web-tailscale-url]")?.textContent, "https://mac.tail1.ts.net"); + + await m.click(m.query("[data-web-tailscale-stop]")); + await m.flush(); + assert.ok(start(), "back to the start action"); + }); + + it("hides Tailscale when it is not installed, and uses no em-dashes", async () => { + await install(); + const m = await mount(); + await m.flush(); + assert.equal(m.query("[data-web-tailscale]"), null); + assert.ok(!m.text().includes("—")); + }); + + it("pairs over tailnet HTTPS first, then LAN, then this computer", () => { + const st: WebAccessStatus = { + running: true, + lan: true, + port: 4620, + urls: [ + { kind: "local", url: "http://127.0.0.1:4620" }, + { kind: "lan", url: "http://192.168.1.20:4620" }, + ], + devices: [], + tailscale: { ...TS_READY, serving: true, url: "https://mac.tail1.ts.net" }, + }; + assert.deepEqual(pairingBases(st), [ + "https://mac.tail1.ts.net", + "http://192.168.1.20:4620", + "http://127.0.0.1:4620", + ]); + assert.equal(pairingLink("https://x/", "a+b"), "https://x/?token=a%2Bb"); + }); +}); From 91a65d4f8912ad20b9720a047d1be8d8cfa1b5c6 Mon Sep 17 00:00:00 2001 From: Willem Date: Tue, 6 Oct 2026 18:20:36 +0200 Subject: [PATCH 5/5] fix(settings): mono URLs and tighter labels in the Solenta Web section Co-Authored-By: Claude Opus 5.5 --- src/components/SettingsModal.module.css | 5 +++++ src/components/WebAccessSection.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/SettingsModal.module.css b/src/components/SettingsModal.module.css index 8e5897dd0..a1fbea9e6 100644 --- a/src/components/SettingsModal.module.css +++ b/src/components/SettingsModal.module.css @@ -410,6 +410,11 @@ overflow: auto; } +.webUrl { + font-family: var(--mono); + font-size: 11.5px; +} + .webQr { width: 176px; height: 176px; diff --git a/src/components/WebAccessSection.tsx b/src/components/WebAccessSection.tsx index d8cffdb56..0b7047fcc 100644 --- a/src/components/WebAccessSection.tsx +++ b/src/components/WebAccessSection.tsx @@ -135,7 +135,7 @@ export function WebAccessSection({ active }: { active: boolean }) { {status.urls.map((u, i) => ( {i > 0 ? ", " : ""} - {u.url} + {u.url} ))} @@ -185,7 +185,7 @@ export function WebAccessSection({ active }: { active: boolean }) {

) : null} -

Devices

+ Devices {created ? (

Pair {created.device.name}

@@ -302,12 +302,12 @@ export function WebAccessSection({ active }: { active: boolean }) { {ts.installed ? (
-

Tailscale

+ Tailscale {!ts.loggedIn ? (

Tailscale is installed but not running or not logged in.

) : ts.serving && ts.url ? (
- {ts.url} + {ts.url}