From 755b6bbb73580f9cbea219003104e3715f5efd91 Mon Sep 17 00:00:00 2001 From: Darrell van Swinderen Date: Wed, 23 Sep 2026 10:52:53 +0200 Subject: [PATCH] feat(streamdeck): a key that saves the replay, a one-press install, and real icons **Save the replay from the Stream Deck.** The plugin used to send people to Elgato's OBS plugin for this. GoodBit now presses the hotkey OBS already listens for, read from the profile it records with (the GoodBit one when it exists), through a small `SendInput` helper compiled on demand like the foreground helper. obs-websocket was the alternative and was not taken: it is a server OBS binds to every interface once enabled, and a config of OBS's own to write. Nothing in OBS changes. The key goes to the foreground, the game, exactly as the physical key would. **It answers on the file, not the press.** "Saved" only once a new recording lands in the folder OBS writes to (12 s), and otherwise one of four reasons the key shows in words: OBS off, no key bound, a key GoodBit cannot press (a mouse button), or a press that produced nothing, which is a buffer that is not running. `replayHotkey.ts` maps OBS key names to virtual keys and is unit-tested, because a wrong entry presses a different key in somebody's game. **One press installs the plugin.** It ships in the installer (`resources/streamdeck/`, built by `npm run build:streamdeck`, which `build:win` runs first). Settings, Connections gets *Install the plugin*: Windows hands the package to the Stream Deck app, which asks, and GoodBit then writes the address and token into the installed plugin's own `connection.json`, again whenever the server starts. Nothing to paste; a key's own settings still win over the file. **Icons.** Phosphor (MIT), bold weight, rendered by `streamdeck-plugin/scripts/render-icons.mjs` with resvg: key images on GoodBit's dark ground (the save key in the accent, discard in danger), list icons as a single light glyph on transparent, and the plugin icon is GoodBit's own app icon. Replaces the flat colour placeholders. Gates: `npm run check` 866 (17 new); `streamdeck-check.mjs` exit 0, including the save key answering "OBS is off" and pressing nothing; `streamdeck validate` successful; the helper compiles and Windows accepts its keypress (F24, which nothing listens to). Not verified: a real save with OBS running, and the install on your Stream Deck, which is the hand test. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 13 +- electron-builder.yml | 4 + package.json | 3 +- scripts/streamdeck-check.mjs | 14 + src/main/ipc/index.ts | 8 + src/main/services/obs/replayHotkey.ts | 121 ++++++++ src/main/services/obs/saveReplay.ts | 278 ++++++++++++++++++ src/main/services/streamdeck/actions.ts | 17 ++ src/main/services/streamdeck/plugin.ts | 93 ++++++ src/main/services/streamdeck/server.ts | 8 + src/preload/index.ts | 1 + .../components/Settings/StreamDeckCard.vue | 63 +++- src/renderer/src/env.d.ts | 3 + streamdeck-plugin/.gitignore | 1 + streamdeck-plugin/README.md | 22 +- .../imgs/actions/discard.png | Bin 0 -> 381 bytes .../imgs/actions/discard@2x.png | Bin 0 -> 651 bytes .../imgs/actions/publish.png | Bin 0 -> 576 bytes .../imgs/actions/publish@2x.png | Bin 0 -> 1006 bytes .../imgs/actions/save.png | Bin 0 -> 536 bytes .../imgs/actions/save@2x.png | Bin 0 -> 1035 bytes .../imgs/actions/stats.png | Bin 0 -> 363 bytes .../imgs/actions/stats@2x.png | Bin 0 -> 528 bytes .../imgs/actions/tag.png | Bin 0 -> 372 bytes .../imgs/actions/tag@2x.png | Bin 0 -> 680 bytes .../imgs/category.png | Bin 114 -> 437 bytes .../imgs/category@2x.png | Bin 153 -> 549 bytes .../imgs/discard.png | Bin 187 -> 0 bytes .../imgs/discard@2x.png | Bin 435 -> 0 bytes .../imgs/keys/discard.png | Bin 0 -> 784 bytes .../imgs/keys/discard@2x.png | Bin 0 -> 1370 bytes .../imgs/keys/publish.png | Bin 0 -> 1135 bytes .../imgs/keys/publish@2x.png | Bin 0 -> 2148 bytes .../imgs/keys/save.png | Bin 0 -> 1143 bytes .../imgs/keys/save@2x.png | Bin 0 -> 2132 bytes .../imgs/keys/stats.png | Bin 0 -> 631 bytes .../imgs/keys/stats@2x.png | Bin 0 -> 1087 bytes .../imgs/keys/tag.png | Bin 0 -> 677 bytes .../imgs/keys/tag@2x.png | Bin 0 -> 1244 bytes .../imgs/plugin.png | Bin 781 -> 5619 bytes .../imgs/plugin@2x.png | Bin 1901 -> 11362 bytes .../imgs/publish.png | Bin 187 -> 0 bytes .../imgs/publish@2x.png | Bin 435 -> 0 bytes .../imgs/stats.png | Bin 187 -> 0 bytes .../imgs/stats@2x.png | Bin 435 -> 0 bytes .../imgs/tag.png | Bin 187 -> 0 bytes .../imgs/tag@2x.png | Bin 435 -> 0 bytes .../manifest.json | 36 ++- streamdeck-plugin/package-lock.json | 237 +++++++++++++++ streamdeck-plugin/package.json | 2 + streamdeck-plugin/scripts/render-icons.mjs | 95 ++++++ streamdeck-plugin/src/actions/save-replay.ts | 38 +++ streamdeck-plugin/src/goodbit.ts | 31 +- streamdeck-plugin/src/plugin.ts | 9 +- tests/unit/main/replayHotkey.spec.ts | 63 ++++ 55 files changed, 1126 insertions(+), 34 deletions(-) create mode 100644 src/main/services/obs/replayHotkey.ts create mode 100644 src/main/services/obs/saveReplay.ts create mode 100644 src/main/services/streamdeck/plugin.ts create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/discard.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/discard@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/publish.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/publish@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/save.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/save@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/stats.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/stats@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/tag.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/actions/tag@2x.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/discard.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/discard@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/discard.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/discard@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/publish.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/publish@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/save.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/save@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/stats.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/stats@2x.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/tag.png create mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/tag@2x.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/publish.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/publish@2x.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/stats.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/stats@2x.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/tag.png delete mode 100644 streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/tag@2x.png create mode 100644 streamdeck-plugin/scripts/render-icons.mjs create mode 100644 streamdeck-plugin/src/actions/save-replay.ts create mode 100644 tests/unit/main/replayHotkey.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6e8a6cf8..0a390f84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -643,8 +643,17 @@ owns the predicates, since a bug there is a security bug; `scripts/streamdeck-ch plugin says to the server as `confirm: true`; and `discardableFromAKey`, which keeps any clip carrying something that exists only in GoodBit. The file comes back from the Recycle Bin, the row does not, and a key has no room for the question `clipDeleteQuestion.ts` asks. -- **There is no key to save the replay.** GoodBit has no channel into OBS to press it, obs-websocket - would mean writing OBS's config again, and Elgato's own OBS plugin already has that key. +- **The save key presses the hotkey OBS already has**, through a compiled `SendInput` helper + (`services/obs/saveReplay.ts`), and never obs-websocket: that is a server OBS binds to every + interface once enabled, and a config of OBS's own to write. Nothing in OBS changes. The key goes + to the foreground, the game, exactly as the physical key would. **It answers on the file, not the + press**: "saved" only once a new recording lands in the folder OBS writes to, and otherwise one of + 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. ### Steam, off the local disk diff --git a/electron-builder.yml b/electron-builder.yml index 0512d835..f79a7e27 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -36,6 +36,10 @@ asarUnpack: extraResources: - from: build/icon.png to: icon.png + # The Stream Deck plugin, so Settings, Connections can install it in one + # press. Built by `npm run build:streamdeck`, which `build:win` runs first. + - from: streamdeck-plugin/dist/io.github.darrellvs.goodbit.streamDeckPlugin + to: streamdeck/io.github.darrellvs.goodbit.streamDeckPlugin win: icon: build/icon.png diff --git a/package.json b/package.json index 5035ca4c..6fd114e4 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json", "typecheck": "npm run typecheck:node && npm run typecheck:web", "postinstall": "electron-builder install-app-deps", - "build:win": "npm run check:pre-release && electron-builder --win && npm run check:packaged", + "build:streamdeck": "cd streamdeck-plugin && npm ci && npm run check && npx streamdeck validate io.github.darrellvs.goodbit.sdPlugin && npx streamdeck pack io.github.darrellvs.goodbit.sdPlugin --output dist --force", + "build:win": "npm run check:pre-release && npm run build:streamdeck && electron-builder --win && npm run check:packaged", "test:e2e": "npm run build && playwright test", "check": "npm run typecheck && npm run test:unit", "check:pre-release": "npm run check && npm run test:e2e", diff --git a/scripts/streamdeck-check.mjs b/scripts/streamdeck-check.mjs index 04127ceb..8b875cff 100644 --- a/scripts/streamdeck-check.mjs +++ b/scripts/streamdeck-check.mjs @@ -191,6 +191,20 @@ try { }); 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 }); + ok( + 'the save key says OBS is off rather than pretending', + save.status === 409 && save.body?.reason === 'obs-off', + JSON.stringify(save.body), + ); + } else { + 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' } }); ok('a key tags the newest clip', tagged.status === 200 && tagged.body?.tag === 'clutch', JSON.stringify(tagged.body)); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index f3a6009f..218846e7 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,3 +1,4 @@ +import { installPlugin, pluginInstalled, pluginPackage } from '../services/streamdeck/plugin.js'; import { app, dialog, ipcMain, nativeImage, shell, BrowserWindow } from 'electron'; import { readFile } from 'node:fs/promises'; import { basename, normalize } from 'node:path'; @@ -138,9 +139,16 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { url: streamDeckUrl(), token: streamDeckToken(), allowDiscard: settings.streamDeckAllowDiscard === true, + pluginInstalled: pluginInstalled(), + pluginAvailable: pluginPackage() !== null, }; }); + ipcMain.handle('streamdeck:installPlugin', async () => { + const { streamDeckUrl, streamDeckToken } = await import('../services/streamdeck/server.js'); + return installPlugin(() => ({ url: streamDeckUrl(), token: streamDeckToken() })); + }); + ipcMain.handle('streamdeck:enable', async (_event, enabled: boolean) => { const { setStreamDeckEnabled } = await import('../services/streamdeck/server.js'); await setStreamDeckEnabled(enabled === true); diff --git a/src/main/services/obs/replayHotkey.ts b/src/main/services/obs/replayHotkey.ts new file mode 100644 index 00000000..76a7a238 --- /dev/null +++ b/src/main/services/obs/replayHotkey.ts @@ -0,0 +1,121 @@ +/** + * The key OBS saves the replay on, as something Windows can press. + * + * GoodBit has no channel into OBS, and opening one means obs-websocket: a + * server OBS binds to every interface once it is switched on, and a config of + * OBS's own to write. So the Stream Deck's save key presses the hotkey OBS + * already listens for, the one the setup bound (F8 unless somebody picked + * another) or whatever the user bound by hand. Nothing in OBS changes and + * nothing listens on the network. + * + * Pure, so the mapping from OBS's names to Windows virtual keys is owned by + * `tests/unit`: a wrong entry here presses a different key in somebody's game. + */ + +export interface KeyChord { + /** A Windows virtual-key code. */ + vk: number; + ctrl: boolean; + alt: boolean; + shift: boolean; + /** How OBS writes it, for messages. */ + label: string; +} + +/** OBS key names that are not a letter, a digit or F1 to F24. */ +const NAMED: Record = { + OBS_KEY_SPACE: 0x20, + OBS_KEY_RETURN: 0x0d, + OBS_KEY_ENTER: 0x0d, + OBS_KEY_TAB: 0x09, + OBS_KEY_BACKSPACE: 0x08, + OBS_KEY_ESCAPE: 0x1b, + OBS_KEY_INSERT: 0x2d, + OBS_KEY_DELETE: 0x2e, + OBS_KEY_HOME: 0x24, + OBS_KEY_END: 0x23, + OBS_KEY_PAGEUP: 0x21, + OBS_KEY_PAGEDOWN: 0x22, + OBS_KEY_LEFT: 0x25, + OBS_KEY_UP: 0x26, + OBS_KEY_RIGHT: 0x27, + OBS_KEY_DOWN: 0x28, + OBS_KEY_PAUSE: 0x13, + OBS_KEY_PRINT: 0x2c, + OBS_KEY_SCROLLLOCK: 0x91, + OBS_KEY_NUMLOCK: 0x90, + OBS_KEY_NUMASTERISK: 0x6a, + OBS_KEY_NUMPLUS: 0x6b, + OBS_KEY_NUMMINUS: 0x6d, + OBS_KEY_NUMPERIOD: 0x6e, + OBS_KEY_NUMSLASH: 0x6f, + OBS_KEY_MINUS: 0xbd, + OBS_KEY_EQUAL: 0xbb, + OBS_KEY_COMMA: 0xbc, + OBS_KEY_PERIOD: 0xbe, + OBS_KEY_SLASH: 0xbf, + OBS_KEY_SEMICOLON: 0xba, + OBS_KEY_APOSTROPHE: 0xde, + OBS_KEY_BRACKETLEFT: 0xdb, + OBS_KEY_BRACKETRIGHT: 0xdd, + OBS_KEY_BACKSLASH: 0xdc, + OBS_KEY_ASCIITILDE: 0xc0, + OBS_KEY_QUOTELEFT: 0xc0, +}; + +/** Windows' virtual key for an OBS key name, or null for one this does not know. */ +export function virtualKey(obsKey: string): number | null { + const letter = /^OBS_KEY_([A-Z])$/.exec(obsKey); + if (letter) return letter[1].charCodeAt(0); + + const digit = /^OBS_KEY_([0-9])$/.exec(obsKey); + if (digit) return 0x30 + Number(digit[1]); + + const numpad = /^OBS_KEY_NUM([0-9])$/.exec(obsKey); + if (numpad) return 0x60 + Number(numpad[1]); + + const fn = /^OBS_KEY_F([0-9]{1,2})$/.exec(obsKey); + if (fn) { + const n = Number(fn[1]); + return n >= 1 && n <= 24 ? 0x70 + n - 1 : null; + } + + return NAMED[obsKey] ?? null; +} + +/** + * The first binding for saving the replay, from the raw `[Hotkeys]` value. + * + * Both shapes OBS has used: the modern `{"bindings":[...]}` and the output's + * own `{"ReplayBuffer.Save":[...]}`, which is the one that works on OBS 31 and + * the one the setup writes. Null when nothing is bound, or when the key is one + * this cannot press, which the caller says in words rather than guessing. + */ +export function replayChord(raw: string | null): KeyChord | { unsupported: string } | null { + if (!raw) return null; + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + return null; + } + const bindings = (parsed.bindings ?? parsed['ReplayBuffer.Save']) as + | Array> + | undefined; + const first = bindings?.find((binding) => typeof binding?.key === 'string'); + if (!first) return null; + + const key = first.key as string; + const vk = virtualKey(key); + const label = [ + first.control ? 'Ctrl' : null, + first.alt ? 'Alt' : null, + first.shift ? 'Shift' : null, + key.replace(/^OBS_KEY_/, ''), + ] + .filter(Boolean) + .join(' + '); + + if (vk === null) return { unsupported: label }; + return { vk, ctrl: Boolean(first.control), alt: Boolean(first.alt), shift: Boolean(first.shift), label }; +} diff --git a/src/main/services/obs/saveReplay.ts b/src/main/services/obs/saveReplay.ts new file mode 100644 index 00000000..dbc512d4 --- /dev/null +++ b/src/main/services/obs/saveReplay.ts @@ -0,0 +1,278 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { userDataDir } from '../../settings.js'; +import { isVideoFile } from '@shared/constants/videoFiles.js'; +import { incomingDir } from '../capture/incoming.js'; +import { readObs } from './config.js'; +import { obsIsRunning } from './paths.js'; +import { replayChord, type KeyChord } from './replayHotkey.js'; + +const run = promisify(execFile); + +/** + * Save the replay buffer, from outside OBS, without talking to OBS. + * + * See `replayHotkey.ts` for why this presses a key rather than opening + * obs-websocket. What that costs, said plainly: **the key goes to whatever is + * in front**, which is the game, exactly as it does when the key on the + * keyboard is pressed. OBS hears it because its hotkeys are global. A game + * that also uses that key would see it too, and that is already true of the + * key the user presses by hand. + * + * **The answer is the file, not the keypress.** A key sent says a request was + * made, not that a clip exists: the buffer may not be running, the profile in + * OBS may be a different one, or OBS may be asking a question in a window + * behind the game. So this waits for a new recording to appear in the folder + * OBS writes to, and says "saved" only then. The same rule the clip toast + * follows: the receipt fires on the file, never on the key. + */ + +export type SaveReplayResult = + | { saved: true; file: string; key: string } + | { + saved: false; + reason: 'not-windows' | 'obs-off' | 'no-hotkey' | 'unsupported-key' | 'nothing-landed' | 'helper'; + message: string; + key?: string; + }; + +/** How long a replay has to land before the key says it did not. */ +const LAND_TIMEOUT_MS = 12_000; + +/** + * The shim: press a chord, and let go. + * + * `SendInput` rather than `keybd_event`, which Microsoft superseded, and a + * held key for 60 ms because OBS reads its hotkeys by polling the key state + * rather than from the message queue, so an instant down-and-up can fall + * between two of its samples. + */ +const SOURCE = String.raw` +using System; +using System.Runtime.InteropServices; +using System.Threading; + +public static class GoodBitKeys +{ + [StructLayout(LayoutKind.Sequential)] + struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + + [StructLayout(LayoutKind.Sequential)] + struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + + [StructLayout(LayoutKind.Explicit)] + struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; } + + [StructLayout(LayoutKind.Sequential)] + struct INPUT { public uint type; public INPUTUNION u; } + + [DllImport("user32.dll", SetLastError = true)] + static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + + [DllImport("user32.dll")] + static extern uint MapVirtualKey(uint uCode, uint uMapType); + + const uint KEYUP = 0x0002; + + static void Key(ushort vk, bool up) + { + INPUT input = new INPUT(); + input.type = 1; + input.u.ki.wVk = vk; + input.u.ki.wScan = (ushort)MapVirtualKey(vk, 0); + input.u.ki.dwFlags = up ? KEYUP : 0; + if (SendInput(1, new INPUT[] { input }, Marshal.SizeOf(typeof(INPUT))) != 1) + throw new Exception("SendInput refused the key: " + Marshal.GetLastWin32Error()); + } + + public static int Main(string[] args) + { + try + { + ushort vk = ushort.Parse(args[0]); + bool ctrl = args[1] == "1", alt = args[2] == "1", shift = args[3] == "1"; + if (ctrl) Key(0x11, false); + if (alt) Key(0x12, false); + if (shift) Key(0x10, false); + Key(vk, false); + Thread.Sleep(60); + Key(vk, true); + if (shift) Key(0x10, true); + if (alt) Key(0x12, true); + if (ctrl) Key(0x11, true); + return 0; + } + catch (Exception e) + { + Console.Error.WriteLine(e.Message); + return 1; + } + } +} +`; + +const STAMP = createHash('sha256').update(SOURCE).digest('hex').slice(0, 16); + +function binDir(): string { + const dir = path.join(userDataDir(), 'bin'); + mkdirSync(dir, { recursive: true }); + return dir; +} + +const helperPath = (): string => path.join(binDir(), 'gb-keys.exe'); +const stampPath = (): string => path.join(binDir(), 'gb-keys.stamp'); + +function current(): boolean { + if (!existsSync(helperPath())) return false; + try { + return readFileSync(stampPath(), 'utf-8').trim() === STAMP; + } catch { + return false; + } +} + +let building: Promise | null = null; + +/** Compiled on first use, the same way the foreground helper is. */ +async function ensureHelper(): Promise { + if (current()) return helperPath(); + if (building) return building; + building = (async () => { + const target = helperPath(); + const dir = mkdtempSync(path.join(os.tmpdir(), 'goodbit-keys-')); + const script = path.join(dir, 'build.ps1'); + try { + writeFileSync( + script, + [ + "$ErrorActionPreference = 'Stop'", + `$source = @'\n${SOURCE}\n'@`, + `Add-Type -TypeDefinition $source -OutputAssembly '${target}' -OutputType ConsoleApplication`, + ].join('\n'), + 'utf-8', + ); + await run( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script], + { windowsHide: true, timeout: 60_000 }, + ); + if (!existsSync(target)) throw new Error('Add-Type reported success and wrote nothing'); + writeFileSync(stampPath(), STAMP, 'utf-8'); + return target; + } catch (error) { + console.warn('[replay] could not build the key helper:', error instanceof Error ? error.message : error); + return null; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + })().finally(() => { + building = null; + }); + return building; +} + +/** Warm the helper at boot, so the first press in a game is not a compile. */ +export function prepareReplayKey(): void { + if (process.platform === 'win32') void ensureHelper(); +} + +/** + * Which profile OBS is recording with, as far as can be told from its files. + * + * GoodBit starts OBS on its own profile, so that one wins when it exists; + * otherwise the one OBS last had active. + */ +function recordingProfile() { + const obs = readObs(); + return obs.profiles.find((profile) => profile.name === 'GoodBit') ?? obs.activeProfile; +} + +async function newestSince(folder: string, since: number): Promise { + let names: string[]; + try { + names = await fs.readdir(folder); + } catch { + return null; + } + for (const name of names) { + if (!isVideoFile(name)) continue; + try { + const stat = await fs.stat(path.join(folder, name)); + if (Math.max(stat.birthtimeMs, stat.mtimeMs) >= since) return path.join(folder, name); + } catch { + // Filed away between the listing and the stat, which means it landed. + return path.join(folder, name); + } + } + return null; +} + +async function press(chord: KeyChord): Promise { + const helper = await ensureHelper(); + if (!helper) return false; + try { + await run(helper, [String(chord.vk), chord.ctrl ? '1' : '0', chord.alt ? '1' : '0', chord.shift ? '1' : '0'], { + windowsHide: true, + timeout: 5_000, + }); + return true; + } catch (error) { + console.warn('[replay] the key helper failed:', error instanceof Error ? error.message : error); + return false; + } +} + +export async function saveReplay(): Promise { + if (process.platform !== 'win32') { + return { saved: false, reason: 'not-windows', message: 'Saving the replay from here works on Windows only.' }; + } + if (!(await obsIsRunning())) { + return { saved: false, reason: 'obs-off', message: 'OBS is not running, so there is no replay to save.' }; + } + + const profile = recordingProfile(); + const chord = replayChord(profile?.saveReplayHotkey ?? null); + if (!chord) { + return { + saved: false, + reason: 'no-hotkey', + message: 'OBS has no key bound to Save Replay. Bind one in OBS, or run the setup in GoodBit.', + }; + } + if ('unsupported' in chord) { + return { + saved: false, + reason: 'unsupported-key', + key: chord.unsupported, + message: `OBS saves the replay on ${chord.unsupported}, which GoodBit cannot press. Bind a keyboard key.`, + }; + } + + // OBS writes the replay where the profile records; GoodBit's own setup + // points that at the staging folder, which files it after. + const folder = profile?.recordingPath || incomingDir(); + const since = Date.now() - 500; + + if (!(await press(chord))) { + return { saved: false, reason: 'helper', key: chord.label, message: 'GoodBit could not press the key.' }; + } + + const deadline = Date.now() + LAND_TIMEOUT_MS; + while (Date.now() < deadline) { + const file = await newestSince(folder, since); + if (file) return { saved: true, file, key: chord.label }; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + return { + saved: false, + reason: 'nothing-landed', + key: chord.label, + message: `Pressed ${chord.label} and no replay arrived. Is the replay buffer running in OBS?`, + }; +} diff --git a/src/main/services/streamdeck/actions.ts b/src/main/services/streamdeck/actions.ts index db02762e..bf2a79f3 100644 --- a/src/main/services/streamdeck/actions.ts +++ b/src/main/services/streamdeck/actions.ts @@ -8,6 +8,7 @@ import { incomingDir } from '../capture/incoming.js'; import { isVideoFile } from '@shared/constants/videoFiles.js'; import { discardableFromAKey } from './auth.js'; import { loadSettings } from '../../settings.js'; +import { saveReplay } from '../obs/saveReplay.js'; /** * What each Stream Deck key does, and nothing more. @@ -186,3 +187,19 @@ export async function discardLatest(input: { confirm?: unknown }): Promise 0) return refused(500, 'Could not move it to the Recycle Bin'); return ok({ id: found.id, title: title(found), discarded: true }); } + +/** + * Save the replay buffer: the key the plugin used to send people elsewhere for. + * + * `saveReplay` presses the hotkey OBS already has and answers only once a new + * recording has landed, so a tick on the key means a file exists. The four + * ways it does not are told apart for the key's title: OBS off, no key bound, + * a key GoodBit cannot press, and a press that produced nothing, which is + * almost always a replay buffer that is not running. + */ +export async function saveReplayFromKey(): Promise { + const result = await saveReplay(); + if (result.saved) return ok({ saved: true, key: result.key }); + const status = result.reason === 'nothing-landed' ? 504 : result.reason === 'helper' ? 500 : 409; + return refused(status, result.message, { reason: result.reason }); +} diff --git a/src/main/services/streamdeck/plugin.ts b/src/main/services/streamdeck/plugin.ts new file mode 100644 index 00000000..70ee0ad8 --- /dev/null +++ b/src/main/services/streamdeck/plugin.ts @@ -0,0 +1,93 @@ +import { app, shell } from 'electron'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * GoodBit's own Stream Deck plugin: where it is, whether it is installed, and + * how it learns where GoodBit is listening. + * + * **One press installs it and connects it.** The plugin ships inside GoodBit + * (`resources/streamdeck/`, from `electron-builder.yml`), and installing it is + * 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. + */ + +export const PLUGIN_UUID = 'io.github.darrellvs.goodbit'; +const PACKAGE = `${PLUGIN_UUID}.streamDeckPlugin`; + +/** Where the Stream Deck app unpacks a plugin, on Windows. */ +export function installedPluginDir(): string { + return path.join(app.getPath('appData'), 'Elgato', 'StreamDeck', 'Plugins', `${PLUGIN_UUID}.sdPlugin`); +} + +export function pluginInstalled(): boolean { + return existsSync(path.join(installedPluginDir(), 'manifest.json')); +} + +/** + * The packaged plugin, or null. + * + * Packaged, it is a resource. From a checkout it is whatever + * `streamdeck-plugin/` last packed into its `dist/`, found from the app path + * (`out/main` when Electron is started on the bundle) or the working folder. + */ +export function pluginPackage(): string | null { + const candidates = [ + path.join(process.resourcesPath ?? '', 'streamdeck', PACKAGE), + path.join(app.getAppPath(), '..', '..', 'streamdeck-plugin', 'dist', PACKAGE), + path.join(process.cwd(), 'streamdeck-plugin', 'dist', PACKAGE), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? 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 { + const dir = installedPluginDir(); + if (!existsSync(dir)) return false; + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'connection.json'), JSON.stringify(connection, null, 2), 'utf-8'); + return true; + } catch (error) { + console.warn('[streamdeck] could not write the plugin connection:', error instanceof Error ? error.message : error); + return false; + } +} + +/** + * Hand the plugin to the Stream Deck app, then connect it once it lands. + * + * The app asks before installing, so the folder appears whenever the user + * says yes. Watched for two minutes; after that the next start of the Stream + * Deck server writes the file anyway. + */ +export async function installPlugin( + connection: () => { url: string; token: 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.' }; + + const failure = await shell.openPath(file); + if (failure) { + return { + ok: false, + error: `Windows could not open the plugin (${failure}). Is the Stream Deck app installed?`, + }; + } + + const deadline = Date.now() + 120_000; + const timer = setInterval(() => { + if (writePluginConnection(connection()) || Date.now() > deadline) clearInterval(timer); + }, 1000); + return { ok: true }; +} diff --git a/src/main/services/streamdeck/server.ts b/src/main/services/streamdeck/server.ts index e2d08969..a7798ba2 100644 --- a/src/main/services/streamdeck/server.ts +++ b/src/main/services/streamdeck/server.ts @@ -16,6 +16,8 @@ * 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 { createServer, type IncomingMessage, type Server } from 'node:http'; import { randomBytes } from 'node:crypto'; import { loadSettings, saveSettings } from '../../settings.js'; @@ -24,6 +26,7 @@ import { discardLatest, health, publishLatest, + saveReplayFromKey, stats, tagLatest, type KeyResult, @@ -62,6 +65,7 @@ const ROUTES: Record = { 'POST /v1/latest/tag': (body) => tagLatest(body), 'POST /v1/latest/publish': () => publishLatest(), 'POST /v1/latest/discard': (body) => discardLatest(body), + 'POST /v1/replay/save': () => saveReplayFromKey(), }; async function readBody(req: IncomingMessage): Promise> { @@ -130,6 +134,10 @@ export async function startStreamDeck(): Promise { 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 }); + prepareReplayKey(); } catch (error) { console.error('[streamdeck] could not start:', error instanceof Error ? error.message : error); await stopStreamDeck(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 62f1ded9..bf505f65 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -34,6 +34,7 @@ const api = { mcpEnable: (enabled: boolean) => ipcRenderer.invoke('mcp:enable', enabled), streamDeckState: () => ipcRenderer.invoke('streamdeck:state'), streamDeckEnable: (enabled: boolean) => ipcRenderer.invoke('streamdeck:enable', enabled), + streamDeckInstallPlugin: () => ipcRenderer.invoke('streamdeck:installPlugin'), mcpRegister: (wanted: boolean, ids?: string[]) => ipcRenderer.invoke('mcp:register', wanted, ids), diff --git a/src/renderer/src/components/Settings/StreamDeckCard.vue b/src/renderer/src/components/Settings/StreamDeckCard.vue index 8a6887a2..14d52460 100644 --- a/src/renderer/src/components/Settings/StreamDeckCard.vue +++ b/src/renderer/src/components/Settings/StreamDeckCard.vue @@ -28,6 +28,8 @@ interface DeckState { url: string; token: string; allowDiscard: boolean; + pluginInstalled: boolean; + pluginAvailable: boolean; } const toast = useToastStore(); @@ -55,6 +57,31 @@ async function toggleServer(enabled: boolean): Promise { } } +/* + * One press: Windows hands the plugin to the Stream Deck app, which asks, and + * GoodBit writes the address and token into it once it lands. The state is + * polled for a while afterwards so the row turns to "Installed" by itself. + */ +const installing = ref(false); +async function installPlugin(): Promise { + installing.value = true; + try { + const result = await window.goodbit?.streamDeckInstallPlugin(); + if (!result) return; + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('The Stream Deck app asks to install it. Say yes and the keys connect by themselves.'); + for (let i = 0; i < 60 && !state.value?.pluginInstalled; i++) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + await load(); + } + } finally { + installing.value = false; + } +} + async function toggleDiscard(allow: boolean): Promise { await save({ streamDeckAllowDiscard: allow }); await load(); @@ -75,8 +102,9 @@ async function copy(value: string, what: string): Promise {

Stream Deck

- 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. Saving presses the key OBS already has for it, so + nothing in OBS changes.

It listens on this machine only, behind a token. The token stops web pages and other @@ -95,6 +123,37 @@ async function copy(value: string, what: string): Promise {