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()}`);
- }
-}
@@ -107,8 +96,9 @@ async function copy(value: string, what: string): Promise {
nothing in OBS changes.
- It listens on this machine only, behind a token. The token stops web pages and other
- machines; anything already running as you could read it from the plugin's settings.
+ It listens on this computer only, through a named pipe that web pages cannot reach and
+ other Windows accounts cannot write to. There is no token to copy: GoodBit tells the plugin
+ where to find it.
@@ -156,33 +146,12 @@ async function copy(value: string, what: string): Promise {
- {{ state.running ? 'Listening' : 'Not listening' }}
- {{ state.url }}
-
- Copy address
-
-
-
-
-
- Token
-
- {{ showToken ? state.token : '•'.repeat(16) }}
+
+ {{ state.running ? 'Listening, on this computer only' : 'Not listening' }}
+
+
+ {{ state.pipe }}
-
- {{ showToken ? 'Hide' : 'Show' }}
-
-
- Copy token
-
Promise<{
enabled: boolean;
running: boolean;
- url: string;
- token: string;
+ pipe: string;
allowDiscard: boolean;
pluginInstalled: boolean;
pluginAvailable: boolean;
@@ -144,8 +143,6 @@ interface AppSettingsWire {
mcpPort?: number;
mcpToken?: string;
streamDeckEnabled?: boolean;
- streamDeckPort?: number;
- streamDeckToken?: string;
streamDeckAllowDiscard?: boolean;
migratedFromWebApp: boolean;
/** The app version that last booted against this database. Main's, not the window's. */
diff --git a/src/renderer/src/utils/settingsCatalog.ts b/src/renderer/src/utils/settingsCatalog.ts
index 8f6b7ac..f569828 100644
--- a/src/renderer/src/utils/settingsCatalog.ts
+++ b/src/renderer/src/utils/settingsCatalog.ts
@@ -374,15 +374,15 @@ export const SETTINGS_CATALOG: readonly SettingEntry[] = [
{
label: 'Stream Deck',
description:
- "Keys that tag or publish the clip you just saved, and show today's count, without leaving the game. Use Elgato's own OBS plugin for the key that saves the replay.",
+ "Keys that save the replay, tag, publish or throw away the clip you just saved, and show today's count, without leaving the game. Install the plugin from here.",
section: 'connections',
- keywords: ['stream deck', 'elgato', 'keys', 'buttons', 'hotkey', 'macro', 'plugin'],
+ keywords: ['stream deck', 'elgato', 'keys', 'buttons', 'hotkey', 'macro', 'plugin', 'replay', 'install'],
},
{
label: 'Let the Stream Deck reach this library',
description: 'Off by default. Nothing is listening until you turn this on.',
section: 'connections',
- keywords: ['stream deck', 'elgato', 'server', 'port', 'token'],
+ keywords: ['stream deck', 'elgato', 'server', 'connection', 'pipe'],
shownWhen: 'always, in the Stream Deck block',
},
{
diff --git a/streamdeck-plugin/README.md b/streamdeck-plugin/README.md
index 49095b9..ff17645 100644
--- a/streamdeck-plugin/README.md
+++ b/streamdeck-plugin/README.md
@@ -20,9 +20,8 @@ says why: *OBS off*, *No key in OBS*, *Key not supported* (a mouse button), or *
1. In GoodBit: **Settings, Connections, Stream Deck**, turn on *Let the Stream Deck reach this
library*, then press **Install the plugin**. The Stream Deck app asks; say yes.
-2. Drag GoodBit keys onto the Stream Deck. GoodBit writes the address and the token into the plugin's
- own folder (`connection.json`), so there is nothing to paste. Anything you do paste into a key's
- settings wins over that file.
+2. Drag GoodBit keys onto the Stream Deck. There is nothing to set up: GoodBit tells the plugin which
+ pipe to use by writing `connection.json` into the plugin's own folder.
## Discard, and why it is careful
@@ -35,12 +34,12 @@ A key pressed mid-game by somebody not looking at a screen has no room for a con
tags, notes and marks cannot, and at that point somebody already decided the clip was worth
keeping.
-## What the token is, and is not
+## How it reaches GoodBit
-GoodBit listens on `127.0.0.1` only, so nothing off this computer can reach it, and it refuses any
-request that comes from a web page. The token stops web pages and other machines. It lives in this
-plugin's settings, so **anything already running as you can read it**: that is the honest limit of
-what a desktop app can promise, and the same as the Claude connection beside it.
+Over a named pipe, `\\.\pipe\goodbit-streamdeck`, speaking plain HTTP. No port and no token: a web
+page cannot open a pipe, and Windows lets only your own account write to it. Anything already
+running as you can reach it, which was just as true of the token it replaced, since that lived in
+this plugin's settings.
## Building
diff --git a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json
index 32d3469..509bcdf 100644
--- a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json
+++ b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json
@@ -2,7 +2,7 @@
"$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
"UUID": "io.github.darrellvs.goodbit",
"Name": "GoodBit",
- "Version": "0.2.0.0",
+ "Version": "0.3.0.0",
"Author": "GoodBit",
"Description": "Save the replay, then tag, publish or discard the clip you just saved, and see today's count, without leaving the game. Needs GoodBit running with the Stream Deck connection switched on in Settings, Connections.",
"URL": "https://darrellvs.github.io/goodbit/",
@@ -30,7 +30,6 @@
"Name": "Save the replay",
"Tooltip": "Saves the replay buffer by pressing the key OBS already has for it, and ticks only once the clip has landed.",
"Icon": "imgs/actions/save",
- "PropertyInspectorPath": "ui/connection.html",
"Controllers": [
"Keypad"
],
@@ -62,7 +61,6 @@
"Name": "Publish the last clip",
"Tooltip": "Uploads the clip you just saved to your publisher.",
"Icon": "imgs/actions/publish",
- "PropertyInspectorPath": "ui/connection.html",
"Controllers": [
"Keypad"
],
@@ -78,7 +76,6 @@
"Name": "Discard the last clip (hold)",
"Tooltip": "Hold to send the clip you just saved to the Recycle Bin. Off in GoodBit until you allow it, and it keeps any clip you have tagged, named, starred, marked, noted or published.",
"Icon": "imgs/actions/discard",
- "PropertyInspectorPath": "ui/connection.html",
"Controllers": [
"Keypad"
],
@@ -94,7 +91,6 @@
"Name": "Today's clips",
"Tooltip": "How many clips you saved today, and how many there are altogether.",
"Icon": "imgs/actions/stats",
- "PropertyInspectorPath": "ui/connection.html",
"Controllers": [
"Keypad"
],
diff --git a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/ui/connection.html b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/ui/connection.html
deleted file mode 100644
index c4adf24..0000000
--- a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/ui/connection.html
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Both are in GoodBit, Settings, Connections, Stream Deck.
-
-
-
diff --git a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/ui/tag.html b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/ui/tag.html
index 2697df1..48460d9 100644
--- a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/ui/tag.html
+++ b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/ui/tag.html
@@ -2,20 +2,15 @@
+
-
-
-
-
-
-
-
- The address and token are in GoodBit, Settings, Connections, Stream Deck.
-
diff --git a/streamdeck-plugin/src/goodbit.ts b/streamdeck-plugin/src/goodbit.ts
index 4b066fc..71f1684 100644
--- a/streamdeck-plugin/src/goodbit.ts
+++ b/streamdeck-plugin/src/goodbit.ts
@@ -1,4 +1,5 @@
import streamDeck from '@elgato/streamdeck';
+import { request } from 'node:http';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -6,83 +7,88 @@ import { fileURLToPath } from 'node:url';
/**
* The one place this plugin talks to GoodBit.
*
- * GoodBit listens on `127.0.0.1` behind a bearer token, off until somebody
- * switches it on in Settings, Connections.
+ * **Over a named pipe, with no token.** GoodBit serves its Stream Deck API on
+ * `\\.\pipe\goodbit-streamdeck`, which a browser cannot reach and Windows only
+ * lets the signed-in account write to, so there is nothing to paste and
+ * nothing secret to keep. It is still HTTP, spoken over the pipe with
+ * `socketPath`, so the routes and replies are the ones they always were.
*
- * **Where the address and token come from.** GoodBit's *Install the plugin*
- * button writes them into `connection.json` in this plugin's own folder, so
- * nobody has to paste a forty character token. What is pasted into a key's
- * settings wins over the file, so a hand-set connection is never overwritten
- * by a GoodBit that happens to be running a different profile.
+ * **Which pipe.** GoodBit writes `connection.json` beside `bin/` when it
+ * installs this plugin and whenever its server starts, naming the pipe for
+ * the profile it runs; a dev build beside the installed app has its own.
+ * Without the file this uses the installed app's pipe.
*
* **Every call has a short timeout.** A key that waits thirty seconds for an
* app that is not running looks exactly like a broken key, so after four
* seconds it gives up and shows the alert triangle instead.
*/
-export interface GoodBitSettings {
- [key: string]: string | undefined;
- url?: string;
- token?: string;
-}
-
export interface GoodBitReply {
status: number;
body: Record;
}
-const DEFAULT_URL = 'http://127.0.0.1:43120';
+const DEFAULT_PIPE = String.raw`\\.\pipe\goodbit-streamdeck`;
const TIMEOUT_MS = 4000;
-/** `connection.json`, written by GoodBit beside `bin/`. Read on every press, so a new token is picked up at once. */
-function fromFile(): GoodBitSettings {
+/** Read on every press, so a GoodBit started on another profile is picked up at once. */
+function pipe(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
- return JSON.parse(readFileSync(join(here, '..', 'connection.json'), 'utf-8')) as GoodBitSettings;
+ const file = JSON.parse(readFileSync(join(here, '..', 'connection.json'), 'utf-8')) as {
+ pipe?: string;
+ };
+ return file.pipe || DEFAULT_PIPE;
} catch {
- return {};
+ return DEFAULT_PIPE;
}
}
-async function connection(): Promise<{ url: string; token: string }> {
- const settings = await streamDeck.settings.getGlobalSettings();
- const file = fromFile();
- return {
- url: (settings.url || file.url || DEFAULT_URL).replace(/\/$/, ''),
- token: settings.token || file.token || '',
- };
-}
-
-export async function goodbit(
+export function goodbit(
path: string,
options: { method?: 'GET' | 'POST'; body?: Record; timeoutMs?: number } = {},
): Promise {
- const { url, token } = await connection();
- if (!token) return { status: 0, body: { error: 'No token set' } };
+ const payload = options.body ? JSON.stringify(options.body) : undefined;
- try {
- const response = await fetch(`${url}${path}`, {
- method: options.method ?? 'GET',
- headers: {
- Authorization: `Bearer ${token}`,
- ...(options.body ? { 'Content-Type': 'application/json' } : {}),
+ return new Promise((resolve) => {
+ const req = request(
+ {
+ socketPath: pipe(),
+ path,
+ method: options.method ?? 'GET',
+ headers: payload
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
+ : {},
+ timeout: options.timeoutMs ?? TIMEOUT_MS,
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
+ res.on('end', () => {
+ let body: Record = {};
+ try {
+ body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as Record;
+ } catch {
+ /* no body */
+ }
+ resolve({ status: res.statusCode ?? 0, body });
+ });
},
- body: options.body ? JSON.stringify(options.body) : undefined,
- signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
+ );
+ // Not running, switched off, or on another profile. All read the same
+ // from a key, and the log says which.
+ const fail = (why: string): void => {
+ streamDeck.logger.warn(`GoodBit did not answer: ${why}`);
+ resolve({ status: 0, body: { error: 'GoodBit is not listening' } });
+ };
+ req.on('timeout', () => {
+ req.destroy();
+ fail('timeout');
});
- let body: Record = {};
- try {
- body = (await response.json()) as Record;
- } catch {
- /* no body */
- }
- return { status: response.status, body };
- } catch (error) {
- // Not running, switched off, or the wrong port. All read the same from a
- // key, and the log says which.
- streamDeck.logger.warn(`GoodBit did not answer: ${(error as Error).name}`);
- return { status: 0, body: { error: 'GoodBit is not listening' } };
- }
+ req.on('error', (error) => fail(error.name));
+ if (payload) req.write(payload);
+ req.end();
+ });
}
/** Whether a reply means the thing worked. 202 counts: publishing runs on. */
diff --git a/tests/unit/main/streamdeckAuth.spec.ts b/tests/unit/main/streamdeckAuth.spec.ts
index 3b36523..260d5b7 100644
--- a/tests/unit/main/streamdeckAuth.spec.ts
+++ b/tests/unit/main/streamdeckAuth.spec.ts
@@ -1,96 +1,29 @@
import { describe, expect, it } from 'vitest';
-import {
- bearerMatches,
- checkRequest,
- discardableFromAKey,
-} from '../../../src/main/services/streamdeck/auth.js';
+import { discardableFromAKey } from '../../../src/main/services/streamdeck/auth.js';
+import { streamDeckPipeName } from '../../../src/main/services/streamdeck/pipe.js';
/**
- * Who may reach the Stream Deck server.
+ * The Stream Deck connection, and the rule a discard key has to pass.
*
- * This reopens a port the internal API was moved off on purpose, so every
- * case here is a way for something that is not the plugin to drive a library
- * that holds the only copy of somebody's tags and notes.
+ * The connection is a named pipe now, so what is left to hold here is which
+ * pipe a profile gets and what a key is allowed to throw away.
*/
-const PORT = 43111;
-const TOKEN = 'a-token-that-is-long-enough-to-matter';
-const expected = { token: TOKEN, port: PORT };
-
-const good = {
- method: 'POST',
- host: `127.0.0.1:${PORT}`,
- authorization: `Bearer ${TOKEN}`,
-};
-
-describe('checkRequest', () => {
- it('lets the plugin through', () => {
- // The plugin is a Node process inside the Stream Deck app: no Origin.
- expect(checkRequest(good, expected)).toEqual({ ok: true });
- expect(checkRequest({ ...good, host: `localhost:${PORT}` }, expected)).toEqual({ ok: true });
+describe('streamDeckPipeName', () => {
+ it('is the plain name for the default profile, which an installed plugin assumes', () => {
+ expect(streamDeckPipeName(undefined)).toBe(String.raw`\\.\pipe\goodbit-streamdeck`);
+ expect(streamDeckPipeName(null)).toBe(streamDeckPipeName(''));
});
- it('refuses a request with no token, before anything else is looked at', () => {
- expect(checkRequest({ ...good, authorization: undefined }, expected)).toMatchObject({
- ok: false,
- status: 401,
- });
+ it('is its own for a moved profile, so a dev build never answers for the installed app', () => {
+ const dev = streamDeckPipeName(String.raw`C:\Users\me\GoodBit-dev-test`);
+ expect(dev).toMatch(/^\\\\\.\\pipe\\goodbit-streamdeck-[0-9a-f]{10}$/);
+ expect(dev).not.toBe(streamDeckPipeName(undefined));
+ expect(dev).not.toBe(streamDeckPipeName(String.raw`C:\Users\me\Other`));
});
- it('refuses the wrong token, and a token of the wrong shape', () => {
- expect(checkRequest({ ...good, authorization: 'Bearer nope' }, expected)).toMatchObject({
- status: 401,
- });
- expect(checkRequest({ ...good, authorization: TOKEN }, expected)).toMatchObject({
- status: 401,
- });
- expect(checkRequest({ ...good, authorization: `Basic ${TOKEN}` }, expected)).toMatchObject({
- status: 401,
- });
- });
-
- it('refuses a web page, even one holding the token', () => {
- // A browser always sends an Origin on a cross-origin request. The plugin
- // never does, so any foreign Origin is a page and is refused first.
- expect(
- checkRequest({ ...good, origin: 'https://evil.example' }, expected),
- ).toMatchObject({ ok: false, status: 403 });
- });
-
- it('refuses DNS rebinding, where the Host is a name that now points here', () => {
- // A page on evil.example re-points its own name at 127.0.0.1, and its
- // request then arrives here with that name in the Host header.
- expect(checkRequest({ ...good, host: `evil.example:${PORT}` }, expected)).toMatchObject({
- status: 403,
- });
- expect(checkRequest({ ...good, host: undefined }, expected)).toMatchObject({ status: 403 });
- });
-
- it('refuses the right host on the wrong port', () => {
- expect(checkRequest({ ...good, host: '127.0.0.1:80' }, expected)).toMatchObject({
- status: 403,
- });
- });
-
- it('refuses every method but GET and POST', () => {
- expect(checkRequest({ ...good, method: 'DELETE' }, expected)).toMatchObject({ status: 405 });
- expect(checkRequest({ ...good, method: 'PUT' }, expected)).toMatchObject({ status: 405 });
- });
-
- it('refuses everything when no token has been made', () => {
- // An empty token must not mean "Bearer " with nothing after it gets in.
- expect(
- checkRequest({ ...good, authorization: 'Bearer ' }, { token: '', port: PORT }),
- ).toMatchObject({ status: 401 });
- });
-});
-
-describe('bearerMatches', () => {
- it('matches only the exact token', () => {
- expect(bearerMatches(`Bearer ${TOKEN}`, TOKEN)).toBe(true);
- expect(bearerMatches(`Bearer ${TOKEN}x`, TOKEN)).toBe(false);
- expect(bearerMatches(`Bearer ${TOKEN.slice(0, -1)}`, TOKEN)).toBe(false);
- expect(bearerMatches(undefined, TOKEN)).toBe(false);
+ it('ignores the case of the folder, as Windows does', () => {
+ expect(streamDeckPipeName(String.raw`C:\A\B`)).toBe(streamDeckPipeName(String.raw`c:\a\b`));
});
});