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 {