Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions scripts/streamdeck-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
8 changes: 8 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
121 changes: 121 additions & 0 deletions src/main/services/obs/replayHotkey.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
return null;
}
const bindings = (parsed.bindings ?? parsed['ReplayBuffer.Save']) as
| Array<Record<string, unknown>>
| 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 };
}
Loading
Loading