diff --git a/CLAUDE.md b/CLAUDE.md index 0a390f8..abe0015 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -622,17 +622,21 @@ unless the user turns it on**, in Settings, Connections. Windows redirects its `%APPDATA%` into `%LOCALAPPDATA%/Packages/Claude_/LocalCache/Roaming`. Writing the documented path on a Store install produces a file the app never reads. -### A Stream Deck plugin, and the second port +### A Stream Deck plugin, on a named pipe `src/main/services/streamdeck/` is a small HTTP server for the plugin in `streamdeck-plugin/`. -**Off unless the user turns it on**, in Settings, Connections, and built exactly like the MCP server -because that is the precedent for opening a port here honestly: `127.0.0.1` only, a bearer token -checked in constant time before routing, and **a Host and Origin check written by hand** in -`auth.ts`, because the MCP SDK does that for its server and a plain `http.createServer` does not. -Without it a web page the user merely has open can post to the port, and a page on a name it has -re-pointed at `127.0.0.1` arrives with a foreign `Host`. `tests/unit/main/streamdeckAuth.spec.ts` -owns the predicates, since a bug there is a security bug; `scripts/streamdeck-check.mjs` proves the -401, both 403s, and that the port answers on no address but loopback. +**Off unless the user turns it on**, in Settings, Connections. It was a port on `127.0.0.1` behind a +bearer token and a hand-written Host and Origin check, because a loopback port is reachable by any +web page the user has open. **It is a named pipe now** (`pipe.ts`), the same move the internal API +made: a browser cannot open one, and Windows' default security descriptor lets only this account +(plus administrators and SYSTEM) write to it. That retired the token, which only ever stopped web +pages and other machines: anything running as the user could read it from the plugin anyway, and +that is exactly who can still reach the pipe. It is still HTTP, spoken over the pipe with +`socketPath`, so the routes and replies did not change. **The pipe name carries the profile**: the +default profile gets `\\.\pipe\goodbit-streamdeck`, a profile moved with `GOODBIT_USER_DATA` +gets a hashed suffix, and the plugin learns which from `connection.json`. `scripts/streamdeck-check.mjs` +proves the plugin gets in with no token and that the app under test listens on no TCP port; that +another Windows account cannot write to the pipe rests on the default descriptor and is not benched. - **The handlers are a transport.** Each finds a clip and hands it to an Action that already exists: `BatchAddTagsAction`, `PublishClipAction`, `BatchDeleteAction`. @@ -651,9 +655,9 @@ owns the predicates, since a bug there is a security bug; `scripts/streamdeck-ch four reasons. `replayHotkey.ts` maps OBS key names to virtual keys and `tests/unit` owns it, because a wrong entry presses a different key in somebody's game. - **One press installs the plugin.** It ships in `resources/streamdeck/` (`build:streamdeck`, run - by `build:win`); `shell.openPath` hands it to the Stream Deck app, and GoodBit then writes the - address and token into the installed plugin's own `connection.json`, rewritten whenever the - server starts. A key's own settings win over the file. + by `build:win`); `shell.openPath` hands it to the Stream Deck app, and GoodBit then writes which + pipe to use into the installed plugin's own `connection.json`, rewritten whenever the server + starts. There is nothing secret in it, and nothing for anybody to paste. ### Steam, off the local disk diff --git a/scripts/streamdeck-check.mjs b/scripts/streamdeck-check.mjs index 8b875cf..4092a95 100644 --- a/scripts/streamdeck-check.mjs +++ b/scripts/streamdeck-check.mjs @@ -1,15 +1,19 @@ /** * Start the built app with the Stream Deck server on, and knock on its door. * - * This reopens a port the internal API was moved off on purpose, so the three - * things that make that acceptable are proved here as output rather than - * claimed in a comment: + * The door is a named pipe now, not a port, so what is proved here as output + * rather than claimed in a comment: * - * - **No token is a 401**, before any route is even looked at. - * - **A web page is a 403**, whether it sends a foreign `Origin` or rebinds its - * own name onto `127.0.0.1` and arrives with a foreign `Host`. - * - **Nothing off this machine can connect at all**, because it binds - * `127.0.0.1` and never `0.0.0.0`. + * - **The plugin gets in with no token at all**, over the pipe for this + * profile, speaking plain HTTP through `socketPath`. + * - **No port is open**: the app under test listens on no TCP port at all. + * - **The pipe is this profile's own**: the installed app's default pipe name + * is not the one a moved profile listens on, so a test never answers for a + * real GoodBit and the other way round. + * + * What is not proved here, because it needs a second Windows account: that + * another account cannot write to the pipe. That rests on the default + * security descriptor Windows gives a pipe, described in `pipe.ts`. * * Then the keys themselves, and the one that needed deciding: discarding from * a physical key is refused while it is switched off, refused without the long @@ -25,27 +29,23 @@ * Recycle Bin, because that is where the app sends it. */ import { execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, utimesSync } from 'node:fs'; -import { connect, createServer } from 'node:net'; -import { networkInterfaces, tmpdir } from 'node:os'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'; +import { request } from 'node:http'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { _electron as electron } from 'playwright'; const ffmpeg = (await import('ffmpeg-static')).default; -const PORT = await new Promise((resolve, reject) => { - const probe = createServer(); - probe.on('error', reject); - probe.listen(0, '127.0.0.1', () => { - const { port } = probe.address(); - probe.close(() => resolve(port)); - }); -}); - const data = mkdtempSync(join(tmpdir(), 'goodbit-deck-data-')); const library = mkdtempSync(join(tmpdir(), 'goodbit-deck-lib-')); mkdirSync(join(library, 'Battlefield 6'), { recursive: true }); +// The same rule as `services/streamdeck/pipe.ts`, for a profile moved with GOODBIT_USER_DATA. +const PIPE = String.raw`\\.\pipe\goodbit-streamdeck-` + createHash('sha256').update(data.toLowerCase()).digest('hex').slice(0, 10); +const DEFAULT_PIPE = String.raw`\\.\pipe\goodbit-streamdeck`; + // Two clips a minute apart, so "the latest" is unambiguous. const clips = ['older.mp4', 'newest.mp4']; clips.forEach((name, index) => { @@ -59,7 +59,6 @@ clips.forEach((name, index) => { utimesSync(file, when, when); }); -const TOKEN = 'deck-check-token-deck-check-token'; const writeSettings = (extra) => writeFileSync( join(data, 'settings.json'), @@ -67,8 +66,6 @@ const writeSettings = (extra) => videosRoot: library, audioRoot: join(library, '.audio'), streamDeckEnabled: true, - streamDeckPort: PORT, - streamDeckToken: TOKEN, ...extra, }), 'utf-8', @@ -81,121 +78,105 @@ const ok = (label, passed, detail = '') => { if (!passed) failures.push(label); }; -const app = await electron.launch({ - args: ['out/main/index.js', '--hidden'], - env: { ...process.env, GOODBIT_USER_DATA: data }, -}); - -const base = `http://127.0.0.1:${PORT}`; -const auth = { Authorization: `Bearer ${TOKEN}` }; - -async function call(path, { method = 'GET', headers = {}, body } = {}) { - const response = await fetch(`${base}${path}`, { - method, - headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers }, - body: body ? JSON.stringify(body) : undefined, +const launch = () => + electron.launch({ args: ['out/main/index.js', '--hidden'], env: { ...process.env, GOODBIT_USER_DATA: data } }); + +/** What the plugin does: HTTP over the pipe, no headers of its own. */ +function call(path, { method = 'GET', body, pipe = PIPE } = {}) { + const payload = body ? JSON.stringify(body) : undefined; + return new Promise((resolve, reject) => { + const req = request( + { + socketPath: pipe, + path, + method, + headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}, + timeout: 20_000, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + let json = null; + try { + json = JSON.parse(Buffer.concat(chunks).toString('utf-8')); + } catch { + /* no body */ + } + resolve({ status: res.statusCode, body: json }); + }); + }, + ); + req.on('timeout', () => req.destroy(new Error('timeout'))); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); }); - let json = null; - try { - json = await response.json(); - } catch { - /* no body */ - } - return { status: response.status, body: json }; } -// Wait for the server and for the scan to have indexed both clips. -const deadline = Date.now() + 40_000; -let ready = null; -while (Date.now() < deadline) { - try { - ready = await call('/v1/stats', { headers: auth }); - if (ready.status === 200 && ready.body?.clips >= 2) break; - } catch { - /* not listening yet */ +async function waitFor(check, ms) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + try { + if (await check()) return true; + } catch { + /* not listening yet */ + } + await new Promise((resolve) => setTimeout(resolve, 500)); } - await new Promise((resolve) => setTimeout(resolve, 500)); + return false; } +let app = await launch(); +const ready = await waitFor(async () => (await call('/v1/stats')).body?.clips >= 2, 40_000); + try { /* --------------------------------------------------------------- the door */ console.log('\nthe door'); - ok('the plugin gets in', ready?.status === 200, String(ready?.status)); - - const noToken = await call('/v1/stats'); - ok('no token is a 401', noToken.status === 401, String(noToken.status)); - - const wrongToken = await call('/v1/stats', { headers: { Authorization: 'Bearer nope' } }); - ok('a wrong token is a 401', wrongToken.status === 401, String(wrongToken.status)); - - const page = await call('/v1/stats', { headers: { ...auth, Origin: 'https://evil.example' } }); - ok('a web page is a 403, even holding the token', page.status === 403, String(page.status)); - - // fetch will not let a script set Host, so this one goes over a raw socket. - const rebound = await new Promise((resolve) => { - const socket = connect(PORT, '127.0.0.1', () => { - socket.write( - `GET /v1/stats HTTP/1.1\r\nHost: evil.example:${PORT}\r\nAuthorization: Bearer ${TOKEN}\r\nConnection: close\r\n\r\n`, - ); - }); - let reply = ''; - socket.on('data', (chunk) => (reply += chunk)); - socket.on('end', () => resolve(reply.split(' ')[1])); - socket.on('error', () => resolve('error')); - }); - ok('DNS rebinding is a 403', rebound === '403', rebound); - - const unknown = await call('/v1/nothing', { headers: auth }); - ok('an unknown key is a 404, but only once you are in', unknown.status === 404); - const unknownNoToken = await call('/v1/nothing'); - ok('and a 401 before that, so paths are not discoverable', unknownNoToken.status === 401); - - /* - * Nothing off this machine. Tried against every non-loopback address this - * machine has, because a server bound to 0.0.0.0 would answer on all of them. - */ - const outside = Object.values(networkInterfaces()) - .flat() - .filter((entry) => entry && entry.family === 'IPv4' && !entry.internal) - .map((entry) => entry.address); - let reachableFromOutside = false; - for (const address of outside) { - try { - await fetch(`http://${address}:${PORT}/v1/health`, { - headers: auth, - signal: AbortSignal.timeout(1500), - }); - reachableFromOutside = true; - } catch { - /* refused, which is the answer wanted */ - } + ok('the plugin gets in over the pipe, with no token', ready); + + const unknown = await call('/v1/nothing'); + ok('an unknown key is a 404', unknown.status === 404, String(unknown.status)); + + // Not "nothing answers on 43120": another GoodBit on this machine, on an + // older build, may well be there. The claim is about this one: the app under + // test listens on no TCP port at all. + const pid = app.process().pid; + const listening = execFileSync('netstat', ['-ano', '-p', 'TCP']) + .toString() + .split(/\r?\n/) + .filter((line) => /LISTENING/.test(line) && line.trim().endsWith(` ${pid}`)); + ok('the app under test listens on no TCP port', listening.length === 0, listening.join(' | ') || `pid ${pid}`); + + let otherPipe = 'refused'; + try { + await call('/v1/health', { pipe: DEFAULT_PIPE }); + otherPipe = 'answered'; + } catch { + /* nothing there, or the installed app, which is not this one */ } ok( - 'and it cannot be reached on any other address this machine has', - !reachableFromOutside, - outside.join(', ') || 'no other interfaces', + "this profile's pipe is its own, not the installed app's", + PIPE !== DEFAULT_PIPE, + `${PIPE} (default pipe ${otherPipe})`, ); /* ---------------------------------------------------------------- the keys */ console.log('\nthe keys'); - const stats = await call('/v1/stats', { headers: auth }); + const stats = await call('/v1/stats'); console.log(` ${JSON.stringify(stats.body)}`); ok('stats name the newest clip', stats.body?.latest?.title === 'newest.mp4', stats.body?.latest?.title); - const discardOff = await call('/v1/latest/discard', { - method: 'POST', - headers: auth, - body: { confirm: true }, - }); + const discardOff = await call('/v1/latest/discard', { method: 'POST', body: { confirm: true } }); ok('discard is refused while it is switched off', discardOff.status === 403, String(discardOff.status)); // The save key presses a real key, so this bench only runs it where it // cannot: with OBS closed it must say so, and press nothing. const obsUp = execFileSync('tasklist', ['/FI', 'IMAGENAME eq obs64.exe', '/NH']).toString().includes('obs64.exe'); if (!obsUp) { - const save = await call('/v1/replay/save', { method: 'POST', headers: auth }); + const save = await call('/v1/replay/save', { method: 'POST' }); ok( 'the save key says OBS is off rather than pretending', save.status === 409 && save.body?.reason === 'obs-off', @@ -205,13 +186,13 @@ try { console.log(' (OBS is running, so the save key is not pressed by this bench)'); } - const tagged = await call('/v1/latest/tag', { method: 'POST', headers: auth, body: { tag: 'clutch' } }); + const tagged = await call('/v1/latest/tag', { method: 'POST', body: { tag: 'clutch' } }); ok('a key tags the newest clip', tagged.status === 200 && tagged.body?.tag === 'clutch', JSON.stringify(tagged.body)); - const noTag = await call('/v1/latest/tag', { method: 'POST', headers: auth, body: {} }); + const noTag = await call('/v1/latest/tag', { method: 'POST', body: {} }); ok('a tag key with no tag set says so', noTag.status === 400); - const publishNoPublisher = await call('/v1/latest/publish', { method: 'POST', headers: auth }); + const publishNoPublisher = await call('/v1/latest/publish', { method: 'POST' }); ok( 'publish says there is no publisher rather than failing slowly', publishNoPublisher.status === 409, @@ -225,45 +206,27 @@ try { console.log('\ndiscard, switched on'); writeSettings({ streamDeckAllowDiscard: true }); - -const app2 = await electron.launch({ - args: ['out/main/index.js', '--hidden'], - env: { ...process.env, GOODBIT_USER_DATA: data }, -}); +app = await launch(); try { - const until = Date.now() + 30_000; - while (Date.now() < until) { - try { - const probe = await call('/v1/health', { headers: auth }); - if (probe.status === 200) break; - } catch { - /* not yet */ - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } + await waitFor(async () => (await call('/v1/health')).status === 200, 30_000); - const noConfirm = await call('/v1/latest/discard', { method: 'POST', headers: auth, body: {} }); + const noConfirm = await call('/v1/latest/discard', { method: 'POST', body: {} }); ok('a tap is not enough, it needs the long press', noConfirm.status === 400, String(noConfirm.status)); - const keptTagged = await call('/v1/latest/discard', { - method: 'POST', - headers: auth, - body: { confirm: true }, - }); + const keptTagged = await call('/v1/latest/discard', { method: 'POST', body: { confirm: true } }); ok( 'a clip somebody tagged is kept', keptTagged.status === 409 && keptTagged.body?.reason === 'tagged', JSON.stringify(keptTagged.body), ); } finally { - await app2.close(); + await app.close(); } /* - * A plain clip, which is the one case discard is for. The tagged one is taken - * out of the way by giving `older.mp4` the newest date, so the latest clip is - * one nobody has written anything about. + * A plain clip, which is the one case discard is for: a newer recording that + * nobody has written anything about. */ const fresh = join(library, 'Battlefield 6', 'plain.mp4'); execFileSync(ffmpeg, [ @@ -272,41 +235,27 @@ execFileSync(ffmpeg, [ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', fresh, ]); -const app3 = await electron.launch({ - args: ['out/main/index.js', '--hidden'], - env: { ...process.env, GOODBIT_USER_DATA: data }, -}); +app = await launch(); try { - const until = Date.now() + 40_000; let latest = null; - while (Date.now() < until) { - try { - const probe = await call('/v1/stats', { headers: auth }); - latest = probe.body?.latest?.title; - if (latest === 'plain.mp4') break; - } catch { - /* not yet */ - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } + await waitFor(async () => { + latest = (await call('/v1/stats')).body?.latest?.title; + return latest === 'plain.mp4'; + }, 40_000); ok('a new plain clip is now the latest', latest === 'plain.mp4', String(latest)); - const discarded = await call('/v1/latest/discard', { - method: 'POST', - headers: auth, - body: { confirm: true }, - }); + const discarded = await call('/v1/latest/discard', { method: 'POST', body: { confirm: true } }); ok( 'and a long press on it sends it to the Recycle Bin', discarded.status === 200 && discarded.body?.discarded === true, JSON.stringify(discarded.body), ); - const after = await call('/v1/stats', { headers: auth }); + const after = await call('/v1/stats'); ok('and it is gone from the library', after.body?.latest?.title !== 'plain.mp4', after.body?.latest?.title); } finally { - await app3.close(); + await app.close(); } if (failures.length) { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 218846e..8977d2c 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -129,15 +129,14 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { }); ipcMain.handle('streamdeck:state', async () => { - const { streamDeckRunning, streamDeckUrl, streamDeckToken } = await import( + const { streamDeckRunning, streamDeckPipe } = await import( '../services/streamdeck/server.js' ); const settings = loadSettings(); return { enabled: settings.streamDeckEnabled === true, running: streamDeckRunning(), - url: streamDeckUrl(), - token: streamDeckToken(), + pipe: streamDeckPipe(), allowDiscard: settings.streamDeckAllowDiscard === true, pluginInstalled: pluginInstalled(), pluginAvailable: pluginPackage() !== null, @@ -145,8 +144,8 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { }); ipcMain.handle('streamdeck:installPlugin', async () => { - const { streamDeckUrl, streamDeckToken } = await import('../services/streamdeck/server.js'); - return installPlugin(() => ({ url: streamDeckUrl(), token: streamDeckToken() })); + const { streamDeckPipe } = await import('../services/streamdeck/server.js'); + return installPlugin(() => ({ pipe: streamDeckPipe() })); }); ipcMain.handle('streamdeck:enable', async (_event, enabled: boolean) => { diff --git a/src/main/services/streamdeck/auth.ts b/src/main/services/streamdeck/auth.ts index 58754bf..caac61f 100644 --- a/src/main/services/streamdeck/auth.ts +++ b/src/main/services/streamdeck/auth.ts @@ -1,92 +1,12 @@ -import { timingSafeEqual } from 'node:crypto'; - /** - * Who may talk to the Stream Deck server, as pure functions. + * The rule a discard key has to pass, as a pure function. * - * **This reopens a port on purpose**, after the internal API was moved off one - * precisely because every other program on the machine could reach a port and - * that API deletes clips. It is acceptable because the MCP server already - * showed the honest way to do it, and this follows that precedent: bound to - * `127.0.0.1`, a bearer token checked before anything else, and a Host and - * Origin check, which the MCP server gets from its SDK and this one has to - * write by hand. - * - * Kept apart from the server so `tests/unit/main/streamdeckAuth.spec.ts` owns - * it. This is the one part of the feature where a bug is a security bug, and a - * predicate over four strings is exactly what the unit suite is for. - * - * What the token is not: it lives in the plugin's own settings, so anything - * already running as this user can read it. It stops web pages and other - * machines, which is what a desktop app can honestly promise. - */ - -export interface StreamDeckRequest { - method: string; - host?: string | undefined; - origin?: string | undefined; - authorization?: string | undefined; -} - -export type AuthVerdict = - | { ok: true } - | { ok: false; status: 401 | 403 | 405; error: string }; - -const METHODS = new Set(['GET', 'POST']); - -/** - * Whether a request may reach a handler. - * - * In this order, each for its own reason: - * - * 1. **Host**, which is what stops DNS rebinding. A page on `evil.example` - * that re-points its own name at `127.0.0.1` reaches this port with - * `Host: evil.example`; only our own two spellings are accepted. - * 2. **Origin**, which a browser always sends on a cross-origin request and - * the plugin never does: it is a Node process inside the Stream Deck app, - * not a page. So any Origin at all that is not our own is a web page, and - * it is refused before the token is even looked at. - * 3. **The token**, compared in constant time, because a comparison that - * returns early on the first wrong character tells a patient caller how - * many it got right. + * This file used to hold who may talk to the Stream Deck server at all: a + * bearer token, and Host and Origin checks for a port on loopback. The server + * is on a named pipe now, which a browser cannot reach and Windows only lets + * this account write to, so those went (see `pipe.ts`). What is left is the + * part where a bug would cost somebody a clip, which `tests/unit` owns. */ -export function checkRequest( - request: StreamDeckRequest, - expected: { token: string; port: number }, -): AuthVerdict { - if (!METHODS.has(request.method.toUpperCase())) { - return { ok: false, status: 405, error: 'GET or POST only' }; - } - - const hosts = new Set([`127.0.0.1:${expected.port}`, `localhost:${expected.port}`]); - if (!request.host || !hosts.has(request.host.toLowerCase())) { - return { ok: false, status: 403, error: 'Not a request to this machine' }; - } - - if (request.origin) { - const origins = new Set([`http://127.0.0.1:${expected.port}`, `http://localhost:${expected.port}`]); - if (!origins.has(request.origin.toLowerCase())) { - return { ok: false, status: 403, error: 'Web pages may not reach GoodBit' }; - } - } - - if (!expected.token || !bearerMatches(request.authorization, expected.token)) { - return { ok: false, status: 401, error: 'GoodBit needs the token from Settings, Connections' }; - } - - return { ok: true }; -} - -/** `Bearer `, compared without leaking how much of it matched. */ -export function bearerMatches(header: string | undefined, token: string): boolean { - if (!header?.startsWith('Bearer ')) return false; - const given = Buffer.from(header.slice('Bearer '.length), 'utf-8'); - const wanted = Buffer.from(token, 'utf-8'); - // `timingSafeEqual` throws on a length mismatch, and the length is not a - // secret worth protecting: every token this app makes is the same length. - if (given.length !== wanted.length) return false; - return timingSafeEqual(given, wanted); -} - /** * Whether a clip may be thrown away from a physical key. * diff --git a/src/main/services/streamdeck/pipe.ts b/src/main/services/streamdeck/pipe.ts new file mode 100644 index 0000000..d3da798 --- /dev/null +++ b/src/main/services/streamdeck/pipe.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto'; + +/** + * Where the Stream Deck plugin reaches GoodBit: a named pipe, not a port. + * + * It was `127.0.0.1:43120` behind a bearer token, a Host check and an Origin + * check, because a port on loopback is reachable by every program on the + * machine and by any web page the user has open. A pipe is neither: + * + * - **A browser cannot open one.** There is no URL for it, so the three + * checks that stopped web pages have nothing left to stop. + * - **Windows decides who can write to it.** A pipe created without a + * security descriptor gets the default one, which gives write access to the + * account that created it, administrators and SYSTEM, and read access only + * to everybody else. A request is a write, so another account on the same + * machine cannot send one. + * - **So the token goes.** It only ever stopped web pages and other machines; + * anything running as the user could read it out of the plugin's settings. + * That is exactly who can still reach the pipe, so it bought nothing the + * operating system does not now give for free. + * + * The same shape as the app's own internal API, which moved off a port for + * the same reason. That one keeps a secret on top because it deletes clips + * wholesale and is reached by the app's own renderer; this is a handful of + * keys, each of which re-checks its own rules (discard needs its own setting, + * a long press and a clip with nothing on it). + */ +const BASE = 'goodbit-streamdeck'; + +/** + * The pipe for this profile. + * + * The default profile gets the plain name, which is what an installed plugin + * assumes when nothing has told it otherwise. A profile moved with + * `GOODBIT_USER_DATA` (a test, or a dev build beside the installed app) gets + * its own, so the two never answer for each other; `connection.json` tells the + * plugin which. + */ +export function streamDeckPipeName(customDataDir: string | null | undefined): string { + if (!customDataDir) return `\\\\.\\pipe\\${BASE}`; + const suffix = createHash('sha256').update(customDataDir.toLowerCase()).digest('hex').slice(0, 10); + return `\\\\.\\pipe\\${BASE}-${suffix}`; +} diff --git a/src/main/services/streamdeck/plugin.ts b/src/main/services/streamdeck/plugin.ts index 70ee0ad..3542d34 100644 --- a/src/main/services/streamdeck/plugin.ts +++ b/src/main/services/streamdeck/plugin.ts @@ -11,15 +11,11 @@ import path from 'node:path'; * handing the `.streamDeckPlugin` to Windows, which gives it to the Stream * Deck app, which asks the user. There is no other install API. * - * Then GoodBit writes `connection.json`, the address and the token, into the - * folder the Stream Deck app unpacked the plugin into. The plugin reads it on - * every press, so nobody pastes a forty character token and a new port or - * token reaches it at once. What is typed into a key's own settings still - * wins, so a hand-set connection is never overwritten. - * - * The token in that file is readable by anything running as the user, which - * is exactly as true of the plugin's own settings store, and is the limit the - * Settings text already states. + * Then GoodBit writes `connection.json`, which pipe to use, into the folder + * the Stream Deck app unpacked the plugin into. The plugin reads it on every + * press. There is no secret in it: the pipe is the whole connection, and + * Windows decides who may write to it (see `pipe.ts`). Without the file the + * plugin uses the default profile's pipe, which is the installed app's. */ export const PLUGIN_UUID = 'io.github.darrellvs.goodbit'; @@ -51,7 +47,7 @@ export function pluginPackage(): string | null { } /** Write the address and token where the installed plugin reads them. False when it is not installed. */ -export function writePluginConnection(connection: { url: string; token: string }): boolean { +export function writePluginConnection(connection: { pipe: string }): boolean { const dir = installedPluginDir(); if (!existsSync(dir)) return false; try { @@ -72,7 +68,7 @@ export function writePluginConnection(connection: { url: string; token: string } * Deck server writes the file anyway. */ export async function installPlugin( - connection: () => { url: string; token: string }, + connection: () => { pipe: string }, ): Promise<{ ok: true } | { ok: false; error: string }> { const file = pluginPackage(); if (!file) return { ok: false, error: 'This build of GoodBit does not carry the Stream Deck plugin.' }; diff --git a/src/main/services/streamdeck/server.ts b/src/main/services/streamdeck/server.ts index a7798ba..9753191 100644 --- a/src/main/services/streamdeck/server.ts +++ b/src/main/services/streamdeck/server.ts @@ -1,27 +1,25 @@ /** - * A small HTTP server the Stream Deck plugin talks to. + * A small HTTP server the Stream Deck plugin talks to, on a named pipe. * - * Off by default, and when it is on, it is the same shape as the MCP server - * because that is the precedent for opening a port in this app honestly: + * Off by default. HTTP because it is the simplest request and reply there is + * and Node serves it on a pipe as happily as on a port; a pipe rather than a + * port because a browser cannot reach one and Windows only lets this account + * write to it. See `pipe.ts` for why that retired the token and the Host and + * Origin checks the port needed. * - * - **127.0.0.1 only**, never `0.0.0.0`. Nothing off this machine can reach it. - * - **Host and Origin checked by hand** (`auth.ts`), which the MCP server gets - * from its SDK and a plain HTTP server does not. Without them a web page the - * user merely has open can post to this port. - * - **A bearer token**, generated once and kept in settings, checked before a - * request reaches a handler. - * - **Never throws**: a server that will not start is a feature that is off, - * not a reason for the library not to open. + * **Never throws**: a server that will not start is a feature that is off, + * not a reason for the library not to open. That includes the pipe name + * already being taken, which is what a second GoodBit on the same profile, or + * a program squatting the name, looks like. * * The routes are versioned (`/v1/...`) because the plugin ships separately and * will be a version behind the app sooner or later. */ import { writePluginConnection } from './plugin.js'; import { prepareReplayKey } from '../obs/saveReplay.js'; +import { streamDeckPipeName } from './pipe.js'; import { createServer, type IncomingMessage, type Server } from 'node:http'; -import { randomBytes } from 'node:crypto'; import { loadSettings, saveSettings } from '../../settings.js'; -import { checkRequest } from './auth.js'; import { discardLatest, health, @@ -32,25 +30,14 @@ import { type KeyResult, } from './actions.js'; -const DEFAULT_PORT = 43120; /** A key sends a few bytes. Anything bigger is not the plugin. */ const BODY_LIMIT = 4 * 1024; let http: Server | null = null; -let listeningOn: number | null = null; -export function streamDeckToken(): string { - const settings = loadSettings(); - if (settings.streamDeckToken) return settings.streamDeckToken; - - const token = randomBytes(24).toString('base64url'); - saveSettings({ streamDeckToken: token }); - return token; -} - -export function streamDeckUrl(): string { - const port = listeningOn ?? loadSettings().streamDeckPort ?? DEFAULT_PORT; - return `http://127.0.0.1:${port}`; +/** The pipe for this profile. See `pipe.ts`. */ +export function streamDeckPipe(): string { + return streamDeckPipeName(process.env.GOODBIT_USER_DATA); } export function streamDeckRunning(): boolean { @@ -89,8 +76,7 @@ export async function startStreamDeck(): Promise { const settings = loadSettings(); if (!settings.streamDeckEnabled) return; - const token = streamDeckToken(); - const port = settings.streamDeckPort ?? DEFAULT_PORT; + const pipe = streamDeckPipe(); try { http = createServer((req, res) => { @@ -99,19 +85,6 @@ export async function startStreamDeck(): Promise { res.end(JSON.stringify(body)); }; - // Before anything else, including routing, so an unauthenticated caller - // learns nothing about which paths exist. - const verdict = checkRequest( - { - method: req.method ?? '', - host: req.headers.host, - origin: req.headers.origin, - authorization: req.headers.authorization, - }, - { token, port }, - ); - if (!verdict.ok) return send(verdict.status, { error: verdict.error }); - const path = (req.url ?? '').split('?')[0]; const route = ROUTES[`${req.method} ${path}`]; if (!route) return send(404, { error: 'No such key' }); @@ -127,16 +100,13 @@ export async function startStreamDeck(): Promise { await new Promise((resolve, reject) => { http?.once('error', reject); - // 127.0.0.1, never 0.0.0.0: nothing off this machine, ever. - http?.listen(port, '127.0.0.1', resolve); + http?.listen(pipe, resolve); }); - listeningOn = port; - if (settings.streamDeckPort !== port) saveSettings({ streamDeckPort: port }); - console.log(`[streamdeck] listening on ${streamDeckUrl()}`); - // An installed plugin learns the address and token from GoodBit itself, - // and the key helper is compiled now rather than on the first press. - writePluginConnection({ url: streamDeckUrl(), token }); + console.log(`[streamdeck] listening on ${pipe}`); + // An installed plugin learns which pipe from GoodBit itself, and the key + // helper is compiled now rather than on the first press. + writePluginConnection({ pipe }); prepareReplayKey(); } catch (error) { console.error('[streamdeck] could not start:', error instanceof Error ? error.message : error); @@ -147,7 +117,6 @@ export async function startStreamDeck(): Promise { export async function stopStreamDeck(): Promise { const closing = http; http = null; - listeningOn = null; await new Promise((resolve) => (closing ? closing.close(() => resolve()) : resolve())); } diff --git a/src/main/settings.ts b/src/main/settings.ts index da5ede8..a5255d6 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -126,13 +126,11 @@ export interface Settings { mcpPort?: number; mcpToken?: string; /** - * The Stream Deck plugin's server. Off unless switched on, for the same - * reason the MCP server is: it is a port into a library holding the only - * copy of somebody's tags and notes. See `services/streamdeck/server.ts`. + * The Stream Deck plugin's server, on a named pipe. Off unless switched on: + * it is a way into a library holding the only copy of somebody's tags and + * notes. See `services/streamdeck/pipe.ts`. */ streamDeckEnabled?: boolean; - streamDeckPort?: number; - streamDeckToken?: string; /** * Whether a Stream Deck key may throw the latest clip away. * diff --git a/src/renderer/src/components/Settings/StreamDeckCard.vue b/src/renderer/src/components/Settings/StreamDeckCard.vue index 14d5246..e9cb574 100644 --- a/src/renderer/src/components/Settings/StreamDeckCard.vue +++ b/src/renderer/src/components/Settings/StreamDeckCard.vue @@ -10,10 +10,10 @@ import BaseButton from '@renderer/components/Base/BaseButton.vue'; /** * The Stream Deck plugin's door into the library. * - * Built the way the Claude block beside it is, because it is the same kind of - * thing: a server on this machine only, behind a token, off until somebody - * turns it on, with the address and the token shown so they can be pasted into - * the plugin. + * Off until somebody turns it on. It used to show an address and a token to + * paste into the plugin; the connection is a named pipe now, which Windows + * only lets this account write to and which GoodBit tells the plugin about + * itself, so there is nothing to copy and nothing secret to show. * * **Discard is its own switch, and it starts off.** A key pressed mid-game by * somebody not looking at a screen has no room for a confirmation, so even @@ -25,8 +25,7 @@ import BaseButton from '@renderer/components/Base/BaseButton.vue'; interface DeckState { enabled: boolean; running: boolean; - url: string; - token: string; + pipe: string; allowDiscard: boolean; pluginInstalled: boolean; pluginAvailable: boolean; @@ -38,7 +37,6 @@ const { save } = useAppSettings(); const state = ref(null); const working = ref(false); -const showToken = ref(false); async function load(): Promise { state.value = (await window.goodbit?.streamDeckState()) ?? null; @@ -86,15 +84,6 @@ async function toggleDiscard(allow: boolean): Promise { await save({ streamDeckAllowDiscard: allow }); await load(); } - -async function copy(value: string, what: string): Promise { - try { - await navigator.clipboard.writeText(value); - toast.success(`${what} copied`); - } catch { - toast.error(`Could not copy the ${what.toLowerCase()}`); - } -}