From 7ea974aa0850060e4fbfb491bddbeb35500e5e70 Mon Sep 17 00:00:00 2001 From: Sree Date: Thu, 16 Jul 2026 16:31:34 +0400 Subject: [PATCH 01/15] feat: add DualSense gaming shortcut gesture engine --- .../src/main/evdev-input-reader.test.ts | 17 ++++ .../companion/src/main/evdev-input-reader.ts | 14 ++- .../gaming-shortcuts/gesture-engine.test.ts | 33 +++++++ .../main/gaming-shortcuts/gesture-engine.ts | 88 +++++++++++++++++++ .../companion/src/shared/controller-input.ts | 5 ++ .../src/shared/trigger-modifier-eval.ts | 2 + 6 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts create mode 100644 ds5-bridge/companion/src/shared/controller-input.ts diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts index 8d6b37d..85a0911 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts @@ -19,6 +19,7 @@ const EV_KEY = 1; const EV_ABS = 3; const ABS_RZ = 5; const BTN_TL = 0x136; +const NEW_CODES = [0x13a, 0x13b, 0x13c, 0x14a, 248, 0x220, 0x221, 0x222, 0x223]; async function collect(reader: EvdevInputReader, count: number): Promise { const states: ControllerInputState[] = []; @@ -31,6 +32,15 @@ async function collect(reader: EvdevInputReader, count: number): Promise { + it('normalizes all supported DualSense extra buttons and ignores unknown codes', async () => { + const stream = new PassThrough(); const reader = new EvdevInputReader({ devicePath: '/fake', openStream: () => stream }); reader.start(); + const pending = collect(reader, 2); + stream.write(Buffer.concat([...NEW_CODES.map((code) => event(EV_KEY, code, 1)), event(EV_KEY, 999, 1), event(EV_SYN, 0, 0)])); + stream.write(Buffer.concat([...NEW_CODES.map((code) => event(EV_KEY, code, 0)), event(EV_SYN, 0, 0)])); + const [pressed, released] = await pending; + expect(pressed.buttons).toEqual(new Set(['create', 'options', 'ps', 'touchpad', 'mute', 'dpad-up', 'dpad-down', 'dpad-left', 'dpad-right'])); + expect(released.buttons).toEqual(new Set()); + }); it('accumulates axis and button events and emits state on EV_SYN', async () => { const stream = new PassThrough(); const reader = new EvdevInputReader({ devicePath: '/fake', openStream: () => stream }); @@ -134,6 +144,13 @@ describe('EvdevInputReader', () => { expect(third.buttons.has('dpad-up')).toBe(false); }); + it('clears buffered data and held state on stop', async () => { + let stream = new PassThrough(); const reader = new EvdevInputReader({ devicePath: '/fake', openStream: () => stream }); reader.start(); + const pending = collect(reader, 1); stream.write(Buffer.concat([event(EV_KEY, BTN_TL, 1), event(EV_SYN, 0, 0)])); + const [first] = await pending; expect(first.buttons.has('l1')).toBe(true); reader.stop(); + stream = new PassThrough(); const next = collect(reader, 1); reader.start(); stream.write(event(EV_SYN, 0, 0)); const [reset] = await next; expect(reset.buttons).toEqual(new Set()); + }); + it('resolves the device path lazily on each start() rather than once at construction', () => { const stream = new PassThrough(); let resolved: string | null = null; diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.ts b/ds5-bridge/companion/src/main/evdev-input-reader.ts index c2e82f2..f55ab7d 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.ts @@ -13,6 +13,9 @@ const ABS_RZ = 5; const ABS_HAT0X = 16; const ABS_HAT0Y = 17; +// Codes verified against /usr/include/linux/input-event-codes.h. DualSense's +// physical gamepad node reports its extra controls using these generic evdev +// names; the node selector below excludes the touchpad/sensor/headset nodes. const BUTTON_NAMES: Record = { 0x130: 'cross', 0x131: 'circle', @@ -24,7 +27,13 @@ const BUTTON_NAMES: Record = { 0x13b: 'options', 0x13c: 'ps', 0x13d: 'l3', - 0x13e: 'r3' + 0x13e: 'r3', + 0x14a: 'touchpad', + 248: 'mute', + 0x220: 'dpad-up', + 0x221: 'dpad-down', + 0x222: 'dpad-left', + 0x223: 'dpad-right' }; // Lowest word of the abs capability bitmask; bit 2 = ABS_Z (L2), @@ -131,6 +140,9 @@ export class EvdevInputReader extends EventEmitter { } this.stream = null; this.pending = Buffer.alloc(0); + this.buttons.clear(); + this.l2 = 0; + this.r2 = 0; } private consume(chunk: Buffer): void { diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts new file mode 100644 index 0000000..b15e370 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GestureEngine } from './gesture-engine'; + +describe('GestureEngine', () => { + beforeEach(() => vi.useFakeTimers()); + it('delays a single press until the double window', () => { + const output: unknown[] = []; const engine = new GestureEngine({ emit: (event) => output.push(event) }); + engine.update(new Set(['ps']), 0); engine.update(new Set(), 1); + expect(output).toEqual([]); vi.advanceTimersByTime(299); expect(output).toEqual([]); + vi.advanceTimersByTime(1); expect(output).toEqual([{ type: 'single-press', button: 'ps' }]); + }); + it('emits only double press inside the boundary', () => { + const emit = vi.fn(); const engine = new GestureEngine({ emit }); + engine.update(new Set(['ps']), 0); engine.update(new Set(), 1); engine.update(new Set(['ps']), 301); engine.update(new Set(), 302); + vi.runAllTimers(); expect(emit).toHaveBeenCalledTimes(1); expect(emit).toHaveBeenCalledWith({ type: 'double-press', button: 'ps' }); + }); + it('emits long press at threshold and no short gesture', () => { + const emit = vi.fn(); const engine = new GestureEngine({ emit }); + engine.update(new Set(['ps']), 0); vi.advanceTimersByTime(650); expect(emit).toHaveBeenCalledWith({ type: 'long-press', button: 'ps', durationMs: 650 }); + engine.update(new Set(), 651); vi.runAllTimers(); expect(emit).toHaveBeenCalledTimes(1); + }); + it('recognizes either chord order and suppresses PS output', () => { + const emit = vi.fn(); const engine = new GestureEngine({ emit }); + engine.update(new Set(['ps']), 0); engine.update(new Set(['ps', 'create']), 150); engine.update(new Set(), 151); vi.runAllTimers(); + expect(emit).toHaveBeenCalledWith({ type: 'chord', modifier: 'ps', button: 'create' }); expect(emit).toHaveBeenCalledTimes(1); + engine.update(new Set(['create']), 1000); engine.update(new Set(['ps', 'create']), 1100); expect(emit).toHaveBeenCalledTimes(2); + }); + it('reset clears stale timers and held state', () => { + const emit = vi.fn(); const engine = new GestureEngine({ emit }); + engine.update(new Set(['ps']), 0); engine.reset(); vi.runAllTimers(); expect(emit).not.toHaveBeenCalled(); + }); + it('rejects invalid timing', () => { expect(() => new GestureEngine({ chordWindowMs: 0 })).toThrow(); }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts new file mode 100644 index 0000000..35b2657 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts @@ -0,0 +1,88 @@ +import type { ControllerButton } from '../../shared/controller-input'; + +export type ControllerGesture = + | { type: 'single-press'; button: ControllerButton } + | { type: 'double-press'; button: ControllerButton } + | { type: 'long-press'; button: ControllerButton; durationMs: number } + | { type: 'chord'; modifier: 'ps'; button: ControllerButton }; + +export type GestureTimer = ReturnType; +export type GestureScheduler = Pick; +export interface GestureEngineOptions { + doublePressWindowMs?: number; + longPressThresholdMs?: number; + chordWindowMs?: number; + scheduler?: GestureScheduler; + emit?: (gesture: ControllerGesture) => void; +} + +const DEFAULTS = { doublePressWindowMs: 300, longPressThresholdMs: 650, chordWindowMs: 150 }; +const SECONDARY = new Set([ + 'cross', 'circle', 'square', 'triangle', 'l1', 'r1', 'l2', 'r2', 'l3', 'r3', + 'create', 'options', 'touchpad', 'mute', 'dpad-up', 'dpad-down', 'dpad-left', 'dpad-right' +]); + +export class GestureEngine { + private readonly scheduler: GestureScheduler; + private readonly emitGesture: (gesture: ControllerGesture) => void; + private readonly doubleWindow: number; + private readonly longThreshold: number; + private readonly chordWindow: number; + private held = new Set(); + private pressedAt = new Map(); + private longTimer: GestureTimer | null = null; + private singleTimer: GestureTimer | null = null; + private longEmitted = false; + private chordEmitted = false; + private chordCycle = false; + private psPressAt: number | null = null; + + constructor(options: GestureEngineOptions = {}) { + this.doubleWindow = options.doublePressWindowMs ?? DEFAULTS.doublePressWindowMs; + this.longThreshold = options.longPressThresholdMs ?? DEFAULTS.longPressThresholdMs; + this.chordWindow = options.chordWindowMs ?? DEFAULTS.chordWindowMs; + if (![this.doubleWindow, this.longThreshold, this.chordWindow].every(Number.isFinite) || + this.doubleWindow <= 0 || this.longThreshold <= 0 || this.chordWindow <= 0) throw new Error('Gesture timing values must be positive finite numbers.'); + this.scheduler = options.scheduler ?? globalThis; + this.emitGesture = options.emit ?? (() => undefined); + } + + update(buttons: ReadonlySet, now = Date.now()): void { + const next = new Set([...buttons].filter((button) => button === 'ps' || SECONDARY.has(button))); + for (const button of next) if (!this.held.has(button)) this.press(button, now); + for (const button of this.held) if (!next.has(button)) this.release(button); + this.held = next; + } + + reset(): void { this.clearTimers(); this.held.clear(); this.pressedAt.clear(); this.longEmitted = false; this.chordEmitted = false; this.chordCycle = false; this.psPressAt = null; } + stop(): void { this.reset(); } + + private press(button: ControllerButton, now: number): void { + this.pressedAt.set(button, now); + if (button === 'ps') { + if (this.singleTimer) this.clearSingleTimer(); + this.psPressAt = now; this.longEmitted = false; this.chordEmitted = false; this.chordCycle = false; + this.longTimer = this.scheduler.setTimeout(() => { + if (this.held.has('ps') && !this.chordEmitted) { this.longEmitted = true; this.emitGesture({ type: 'long-press', button: 'ps', durationMs: this.longThreshold }); } + }, this.longThreshold); + for (const secondary of this.held) { + const secondaryAt = this.pressedAt.get(secondary); + if (secondaryAt !== undefined && now - secondaryAt <= this.chordWindow) { this.chord(secondary); break; } + } + return; + } + if (this.held.has('ps') && this.psPressAt !== null && now - this.psPressAt <= this.chordWindow) this.chord(button); + } + + private chord(button: ControllerButton): void { if (this.chordEmitted) return; this.chordEmitted = true; this.chordCycle = true; this.clearLongTimer(); this.clearSingleTimer(); this.emitGesture({ type: 'chord', modifier: 'ps', button }); } + private release(button: ControllerButton): void { + if (button !== 'ps') { this.pressedAt.delete(button); if (this.held.has('ps')) this.chordEmitted = false; return; } + this.clearLongTimer(); + if (this.chordCycle || this.longEmitted) return; + if (this.singleTimer) { this.scheduler.clearTimeout(this.singleTimer); this.singleTimer = null; this.emitGesture({ type: 'double-press', button: 'ps' }); } + else this.singleTimer = this.scheduler.setTimeout(() => { this.singleTimer = null; this.emitGesture({ type: 'single-press', button: 'ps' }); }, this.doubleWindow); + } + private clearLongTimer(): void { if (this.longTimer) { this.scheduler.clearTimeout(this.longTimer); this.longTimer = null; } } + private clearSingleTimer(): void { if (this.singleTimer) { this.scheduler.clearTimeout(this.singleTimer); this.singleTimer = null; } } + private clearTimers(): void { this.clearLongTimer(); this.clearSingleTimer(); } +} diff --git a/ds5-bridge/companion/src/shared/controller-input.ts b/ds5-bridge/companion/src/shared/controller-input.ts new file mode 100644 index 0000000..31d0092 --- /dev/null +++ b/ds5-bridge/companion/src/shared/controller-input.ts @@ -0,0 +1,5 @@ +export type ControllerButton = + | 'cross' | 'circle' | 'square' | 'triangle' + | 'l1' | 'r1' | 'l2' | 'r2' | 'l3' | 'r3' + | 'create' | 'options' | 'ps' | 'touchpad' | 'mute' + | 'dpad-up' | 'dpad-down' | 'dpad-left' | 'dpad-right'; diff --git a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts index 6f7cde2..2d29363 100644 --- a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts +++ b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts @@ -1,4 +1,6 @@ import type { ModifierCondition, TriggerEffectSpec, TriggerProfile, TriggerSlotConfig } from './trigger-profiles'; +import type { ControllerButton } from './controller-input'; +export type { ControllerButton } from './controller-input'; export interface ControllerInputState { timestampMs: number; From 689f6a08689549b8ea7956d99fac6997d92244d3 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:01:20 +0400 Subject: [PATCH 02/15] feat: add safe gaming shortcut action foundation --- .../gaming-shortcuts/action-executor.test.ts | 50 +++++++ .../main/gaming-shortcuts/action-executor.ts | 48 +++++++ .../gaming-shortcuts/gesture-engine.test.ts | 9 ++ .../main/gaming-shortcuts/gesture-engine.ts | 15 +- .../gaming-shortcuts/process-runner.test.ts | 63 +++++++++ .../main/gaming-shortcuts/process-runner.ts | 62 +++++++++ .../companion/src/main/settings-store.ts | 7 +- .../companion/src/shared/controller-input.ts | 10 ++ .../shared/gaming-shortcuts-settings.test.ts | 22 +++ .../src/shared/gaming-shortcuts.test.ts | 27 ++++ .../companion/src/shared/gaming-shortcuts.ts | 128 ++++++++++++++++++ .../src/shared/trigger-modifier-eval.ts | 3 +- ds5-bridge/companion/src/shared/types.ts | 2 + 13 files changed, 438 insertions(+), 8 deletions(-) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.ts create mode 100644 ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts create mode 100644 ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts create mode 100644 ds5-bridge/companion/src/shared/gaming-shortcuts.ts diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts new file mode 100644 index 0000000..414e2dc --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ActionExecutor } from './action-executor'; + +describe('ActionExecutor', () => { + it('runs explicit executable arguments', async () => { + const run = vi.fn().mockResolvedValue({ code: 0, signal: null }); + const executor = new ActionExecutor({ runner: { run } }); + await expect(executor.execute({ type: 'custom-executable', executable: 'notify-send', args: ['hello world'] })).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledWith('notify-send', ['hello world']); + }); + + it('reports process failures without throwing', async () => { + const executor = new ActionExecutor({ runner: { run: vi.fn().mockResolvedValue({ code: 1, signal: null }) } }); + await expect(executor.execute({ type: 'launch-app', executable: 'game', args: [] })).resolves.toMatchObject({ ok: false, reason: 'failed' }); + }); + + it('reports process timeouts distinctly', async () => { + const executor = new ActionExecutor({ runner: { run: vi.fn().mockResolvedValue({ code: null, signal: 'SIGKILL', timedOut: true }) } }); + await expect(executor.execute({ type: 'launch-app', executable: 'game', args: [] })).resolves.toEqual({ ok: false, reason: 'failed', error: 'Process timed out' }); + }); + + it('converts runner errors into structured failures', async () => { + const executor = new ActionExecutor({ runner: { run: vi.fn().mockRejectedValue(new Error('not found')) } }); + await expect(executor.execute({ type: 'custom-executable', executable: 'missing', args: [] })).resolves.toEqual({ ok: false, reason: 'failed', error: 'not found' }); + }); + + it('repairs malformed runtime input before execution', async () => { + const run = vi.fn(); + const executor = new ActionExecutor({ runner: { run } }); + await expect(executor.execute({ type: 'custom-executable', executable: 'bad\0name', args: [] })).resolves.toEqual({ ok: true }); + expect(run).not.toHaveBeenCalled(); + }); + + it('does not invoke a runner for unavailable provider actions', async () => { + const run = vi.fn(); + const executor = new ActionExecutor({ runner: { run } }); + await expect(executor.execute({ type: 'volume', direction: 'up' })).resolves.toEqual({ ok: false, reason: 'unavailable' }); + expect(run).not.toHaveBeenCalled(); + }); + + it('opens OpenDS5 through its injected callback', async () => { + const openOpenDS5 = vi.fn(); + await expect(new ActionExecutor({ openOpenDS5 }).execute({ type: 'open-opends5' })).resolves.toEqual({ ok: true }); + expect(openOpenDS5).toHaveBeenCalledOnce(); + }); + + it('reports OpenDS5 unavailable when no callback is configured', async () => { + await expect(new ActionExecutor().execute({ type: 'open-opends5' })).resolves.toEqual({ ok: false, reason: 'unavailable' }); + }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts new file mode 100644 index 0000000..3c9bc57 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts @@ -0,0 +1,48 @@ +import { validateGamingShortcutAction, type GamingShortcutAction } from '../../shared/gaming-shortcuts'; +import { createProcessRunner, type ProcessRunner } from './process-runner'; + +export type ActionExecutionResult = + | { ok: true } + | { ok: false; reason: 'unavailable' | 'failed'; error?: string }; + +export interface ActionExecutorOptions { + runner?: ProcessRunner; + openOpenDS5?: () => Promise | void; +} + +/** Executes only validated actions; desktop-specific actions are provider work. */ +export class ActionExecutor { + private readonly runner: ProcessRunner; + private readonly openOpenDS5: (() => Promise | void) | null; + + constructor(options: ActionExecutorOptions = {}) { + this.runner = options.runner ?? createProcessRunner(); + this.openOpenDS5 = options.openOpenDS5 ?? null; + } + + async execute(rawAction: unknown): Promise { + const action: GamingShortcutAction = validateGamingShortcutAction(rawAction); + try { + switch (action.type) { + case 'none': + case 'passthrough': + return { ok: true }; + case 'open-opends5': + if (!this.openOpenDS5) return { ok: false, reason: 'unavailable' }; + await this.openOpenDS5(); + return { ok: true }; + case 'launch-app': + case 'custom-executable': { + const result = await this.runner.run(action.executable, action.args); + return result.code === 0 && result.signal === null && !result.timedOut + ? { ok: true } + : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with code ${result.code ?? 'unknown'}` }; + } + default: + return { ok: false, reason: 'unavailable' }; + } + } catch (error) { + return { ok: false, reason: 'failed', error: error instanceof Error ? error.message : String(error) }; + } + } +} diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts index b15e370..1359b54 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts @@ -29,5 +29,14 @@ describe('GestureEngine', () => { const emit = vi.fn(); const engine = new GestureEngine({ emit }); engine.update(new Set(['ps']), 0); engine.reset(); vi.runAllTimers(); expect(emit).not.toHaveBeenCalled(); }); + it('does not turn a chord after a pending single into a later double press', () => { + const emit = vi.fn(); const engine = new GestureEngine({ emit }); + engine.update(new Set(['ps']), 0); engine.update(new Set(), 1); + engine.update(new Set(['ps']), 100); engine.update(new Set(['ps', 'create']), 150); engine.update(new Set(), 151); + engine.update(new Set(['ps']), 1000); engine.update(new Set(), 1001); vi.runAllTimers(); + expect(emit).toHaveBeenCalledWith({ type: 'chord', modifier: 'ps', button: 'create' }); + expect(emit).toHaveBeenCalledWith({ type: 'single-press', button: 'ps' }); + expect(emit).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'double-press' })); + }); it('rejects invalid timing', () => { expect(() => new GestureEngine({ chordWindowMs: 0 })).toThrow(); }); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts index 35b2657..cf4f627 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts @@ -32,6 +32,7 @@ export class GestureEngine { private pressedAt = new Map(); private longTimer: GestureTimer | null = null; private singleTimer: GestureTimer | null = null; + private doublePressCycle = false; private longEmitted = false; private chordEmitted = false; private chordCycle = false; @@ -54,16 +55,20 @@ export class GestureEngine { this.held = next; } - reset(): void { this.clearTimers(); this.held.clear(); this.pressedAt.clear(); this.longEmitted = false; this.chordEmitted = false; this.chordCycle = false; this.psPressAt = null; } + reset(): void { this.clearTimers(); this.held.clear(); this.pressedAt.clear(); this.longEmitted = false; this.chordEmitted = false; this.chordCycle = false; this.doublePressCycle = false; this.psPressAt = null; } stop(): void { this.reset(); } private press(button: ControllerButton, now: number): void { this.pressedAt.set(button, now); if (button === 'ps') { - if (this.singleTimer) this.clearSingleTimer(); + if (this.singleTimer) { this.clearSingleTimer(); this.doublePressCycle = true; } this.psPressAt = now; this.longEmitted = false; this.chordEmitted = false; this.chordCycle = false; this.longTimer = this.scheduler.setTimeout(() => { - if (this.held.has('ps') && !this.chordEmitted) { this.longEmitted = true; this.emitGesture({ type: 'long-press', button: 'ps', durationMs: this.longThreshold }); } + if (this.held.has('ps') && !this.chordEmitted) { + this.doublePressCycle = false; + this.longEmitted = true; + this.emitGesture({ type: 'long-press', button: 'ps', durationMs: this.longThreshold }); + } }, this.longThreshold); for (const secondary of this.held) { const secondaryAt = this.pressedAt.get(secondary); @@ -74,12 +79,12 @@ export class GestureEngine { if (this.held.has('ps') && this.psPressAt !== null && now - this.psPressAt <= this.chordWindow) this.chord(button); } - private chord(button: ControllerButton): void { if (this.chordEmitted) return; this.chordEmitted = true; this.chordCycle = true; this.clearLongTimer(); this.clearSingleTimer(); this.emitGesture({ type: 'chord', modifier: 'ps', button }); } + private chord(button: ControllerButton): void { if (this.chordEmitted) return; this.chordEmitted = true; this.chordCycle = true; this.doublePressCycle = false; this.clearLongTimer(); this.clearSingleTimer(); this.emitGesture({ type: 'chord', modifier: 'ps', button }); } private release(button: ControllerButton): void { if (button !== 'ps') { this.pressedAt.delete(button); if (this.held.has('ps')) this.chordEmitted = false; return; } this.clearLongTimer(); if (this.chordCycle || this.longEmitted) return; - if (this.singleTimer) { this.scheduler.clearTimeout(this.singleTimer); this.singleTimer = null; this.emitGesture({ type: 'double-press', button: 'ps' }); } + if (this.doublePressCycle) { this.doublePressCycle = false; this.emitGesture({ type: 'double-press', button: 'ps' }); } else this.singleTimer = this.scheduler.setTimeout(() => { this.singleTimer = null; this.emitGesture({ type: 'single-press', button: 'ps' }); }, this.doubleWindow); } private clearLongTimer(): void { if (this.longTimer) { this.scheduler.clearTimeout(this.longTimer); this.longTimer = null; } } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.test.ts new file mode 100644 index 0000000..61cbd66 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.test.ts @@ -0,0 +1,63 @@ +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import { describe, expect, it, vi } from 'vitest'; +import { createProcessRunner } from './process-runner'; + +function fakeChild(): ChildProcess & { kill: ReturnType } { + const child = new EventEmitter() as ChildProcess & { kill: ReturnType }; + child.kill = vi.fn(); + return child; +} + +describe('ProcessRunner', () => { + it('returns the child exit result', async () => { + const child = fakeChild(); + const runner = createProcessRunner({ spawn: () => child }); + const resultPromise = runner.run('test-program', ['--arg'], 1000); + + child.emit('exit', 0, null); + + await expect(resultPromise).resolves.toEqual({ code: 0, signal: null, timedOut: false }); + }); + + it('force-kills a child that ignores SIGTERM and resolves with a timeout outcome', async () => { + vi.useFakeTimers(); + try { + const child = fakeChild(); + const runner = createProcessRunner({ spawn: () => child, killGracePeriodMs: 25 }); + const resultPromise = runner.run('stubborn-program', [], 100); + + await vi.advanceTimersByTimeAsync(100); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + expect(resultPromise).toBeInstanceOf(Promise); + + await vi.advanceTimersByTimeAsync(24); + expect(child.kill).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + + expect(child.kill).toHaveBeenLastCalledWith('SIGKILL'); + await expect(resultPromise).resolves.toEqual({ code: null, signal: 'SIGKILL', timedOut: true }); + } finally { + vi.useRealTimers(); + } + }); + + it('reports a timeout when SIGTERM lets the child exit during the grace period', async () => { + vi.useFakeTimers(); + try { + const child = fakeChild(); + const runner = createProcessRunner({ spawn: () => child, killGracePeriodMs: 25 }); + const resultPromise = runner.run('slow-program', [], 100); + + await vi.advanceTimersByTimeAsync(100); + child.emit('exit', null, 'SIGTERM'); + + await expect(resultPromise).resolves.toEqual({ code: null, signal: 'SIGTERM', timedOut: true }); + expect(child.kill).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(25); + expect(child.kill).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.ts new file mode 100644 index 0000000..150748b --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/process-runner.ts @@ -0,0 +1,62 @@ +import { spawn, type ChildProcess } from 'node:child_process'; + +export interface ProcessResult { + code: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; +} + +export interface ProcessRunner { + run(executable: string, args: readonly string[], timeoutMs?: number): Promise; +} + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_KILL_GRACE_PERIOD_MS = 250; + +interface ProcessRunnerOptions { + spawn?: (executable: string, args: string[], options: { shell: false; stdio: 'ignore' }) => ChildProcess; + killGracePeriodMs?: number; +} + +export function createProcessRunner(options: ProcessRunnerOptions = {}): ProcessRunner { + const spawnProcess = options.spawn ?? spawn; + const killGracePeriodMs = options.killGracePeriodMs ?? DEFAULT_KILL_GRACE_PERIOD_MS; + + return { + run(executable, args, timeoutMs = DEFAULT_TIMEOUT_MS) { + return new Promise((resolve, reject) => { + const child = spawnProcess(executable, [...args], { shell: false, stdio: 'ignore' }); + let settled = false; + let timedOut = false; + let forceKillTimer: ReturnType | undefined; + const finish = (result: Omit): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (forceKillTimer !== undefined) clearTimeout(forceKillTimer); + resolve({ ...result, timedOut }); + }; + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + forceKillTimer = setTimeout(() => { + if (settled) return; + child.kill('SIGKILL'); + finish({ code: null, signal: 'SIGKILL' }); + }, killGracePeriodMs); + }, timeoutMs); + child.once('error', (error) => { + clearTimeout(timer); + if (forceKillTimer !== undefined) clearTimeout(forceKillTimer); + if (!settled) { + settled = true; + reject(error); + } + }); + child.once('exit', (code, signal) => { + finish({ code, signal }); + }); + }); + } + }; +} diff --git a/ds5-bridge/companion/src/main/settings-store.ts b/ds5-bridge/companion/src/main/settings-store.ts index 51a7877..2ecc323 100644 --- a/ds5-bridge/companion/src/main/settings-store.ts +++ b/ds5-bridge/companion/src/main/settings-store.ts @@ -38,6 +38,7 @@ import type { RemapButtonId } from '../shared/protocol'; import type { CompanionSettings, UiScalePercent, UiThemePreset } from '../shared/types'; +import { DEFAULT_GAMING_SHORTCUTS_SETTINGS, normalizeGamingShortcutsSettings } from '../shared/gaming-shortcuts'; const DEFAULT_CONTROLLER_PROFILE_SETTINGS: ControllerProfileSettings = { hapticsEnabled: true, @@ -199,7 +200,8 @@ export const DEFAULT_SETTINGS: CompanionSettings = { buttonRemappingProfiles: [DEFAULT_BUTTON_REMAP_PROFILE], buttonRemappingDraft: { ...DEFAULT_BUTTON_REMAP_PROFILE.mappings }, chordFunctions: [], - chordAssignments: [] + chordAssignments: [], + gamingShortcuts: DEFAULT_GAMING_SHORTCUTS_SETTINGS }; function normalizeColor(value: unknown): string { @@ -1014,7 +1016,8 @@ function normalizeSettings(value: Partial | null | undefined) buttonRemappingProfiles, buttonRemappingDraft: normalizeRemapMap(value?.buttonRemappingDraft), chordFunctions, - chordAssignments: normalizeChordAssignments(value?.chordAssignments, chordFunctions) + chordAssignments: normalizeChordAssignments(value?.chordAssignments, chordFunctions), + gamingShortcuts: normalizeGamingShortcutsSettings(value?.gamingShortcuts) }; } diff --git a/ds5-bridge/companion/src/shared/controller-input.ts b/ds5-bridge/companion/src/shared/controller-input.ts index 31d0092..b3d92de 100644 --- a/ds5-bridge/companion/src/shared/controller-input.ts +++ b/ds5-bridge/companion/src/shared/controller-input.ts @@ -3,3 +3,13 @@ export type ControllerButton = | 'l1' | 'r1' | 'l2' | 'r2' | 'l3' | 'r3' | 'create' | 'options' | 'ps' | 'touchpad' | 'mute' | 'dpad-up' | 'dpad-down' | 'dpad-left' | 'dpad-right'; + +const CONTROLLER_BUTTONS: ReadonlySet = new Set([ + 'cross', 'circle', 'square', 'triangle', 'l1', 'r1', 'l2', 'r2', 'l3', 'r3', + 'create', 'options', 'ps', 'touchpad', 'mute', + 'dpad-up', 'dpad-down', 'dpad-left', 'dpad-right' +]); + +export function isControllerButton(value: unknown): value is ControllerButton { + return typeof value === 'string' && CONTROLLER_BUTTONS.has(value); +} diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts new file mode 100644 index 0000000..741af7a --- /dev/null +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_GAMING_SHORTCUTS_SETTINGS, normalizeGamingShortcutsSettings } from './gaming-shortcuts'; + +describe('normalizeGamingShortcutsSettings', () => { + it('provides disabled, harmless defaults', () => { + expect(normalizeGamingShortcutsSettings(undefined)).toEqual(DEFAULT_GAMING_SHORTCUTS_SETTINGS); + }); + + it('clamps timing and repairs malformed chords', () => { + expect(normalizeGamingShortcutsSettings({ + enabled: true, + doublePressWindowMs: 1, + longPressThresholdMs: 99999, + chordWindowMs: 151, + singlePress: { type: 'open-opends5' }, + chords: [ + { button: 'create', action: { type: 'passthrough' } }, + { button: 'not-a-button', action: { type: 'custom-executable', executable: 'bad', args: [] } } + ] + })).toMatchObject({ enabled: true, doublePressWindowMs: 100, longPressThresholdMs: 2000, chordWindowMs: 151, chords: [{ button: 'create', action: { type: 'passthrough' } }] }); + }); +}); diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts new file mode 100644 index 0000000..0910bd6 --- /dev/null +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { validateGamingShortcutAction } from './gaming-shortcuts'; + +describe('validateGamingShortcutAction', () => { + it('preserves valid typed actions', () => { + expect(validateGamingShortcutAction({ type: 'custom-executable', executable: 'notify-send', args: ['hello'] })) + .toEqual({ type: 'custom-executable', executable: 'notify-send', args: ['hello'] }); + }); + + it.each([ + undefined, + { type: 'unknown' }, + { type: 'launch-app', executable: '', args: [] }, + { type: 'launch-app', executable: 'bad\0name', args: [] }, + { type: 'volume', direction: 'sideways' }, + { type: 'screenshot', provider: 'missing' }, + { type: 'quit-active-game', confirmation: false }, + { type: 'custom-executable', executable: 'tool', args: ['bad\0arg'] } + ])('repairs malformed data to none: %j', (value) => { + expect(validateGamingShortcutAction(value)).toEqual({ type: 'none' }); + }); + + it('allows empty argument arrays but caps oversized arrays', () => { + expect(validateGamingShortcutAction({ type: 'launch-app', executable: 'game', args: [] })).toEqual({ type: 'launch-app', executable: 'game', args: [] }); + expect(validateGamingShortcutAction({ type: 'launch-app', executable: 'game', args: Array(65).fill('arg') })).toEqual({ type: 'none' }); + }); +}); diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts new file mode 100644 index 0000000..5675822 --- /dev/null +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts @@ -0,0 +1,128 @@ +import type { ControllerButton } from './controller-input'; + +export type CaptureProvider = 'auto' | 'portal' | 'grim' | 'gnome-screenshot' | 'spectacle' | 'scrot'; +export type HudProvider = 'auto' | 'gamescope' | 'mangohud'; +export type KeyboardProvider = 'auto' | 'portal' | 'wvkbd' | 'onboard' | 'matchbox-keyboard'; + +export type GamingShortcutAction = + | { type: 'none' } + | { type: 'passthrough' } + | { type: 'open-opends5' } + | { type: 'launch-app'; executable: string; args: string[] } + | { type: 'focus-app'; appId: string } + | { type: 'volume'; direction: 'up' | 'down' | 'mute' } + | { type: 'microphone-mute-toggle' } + | { type: 'screenshot'; provider: CaptureProvider } + | { type: 'recording-toggle'; provider: CaptureProvider } + | { type: 'performance-hud-toggle'; provider: HudProvider } + | { type: 'on-screen-keyboard'; provider: KeyboardProvider } + | { type: 'switch-application'; direction: 'next' | 'previous' } + | { type: 'quit-active-game'; confirmation: true } + | { type: 'custom-executable'; executable: string; args: string[] }; + +export interface GamingShortcutBindings { + singlePress: GamingShortcutAction; + doublePress: GamingShortcutAction; + longPress: GamingShortcutAction; + chords: Array<{ button: Exclude; action: GamingShortcutAction }>; +} + +export interface GamingShortcutsSettings extends GamingShortcutBindings { + enabled: boolean; + doublePressWindowMs: number; + longPressThresholdMs: number; + chordWindowMs: number; +} + +export const DEFAULT_GAMING_SHORTCUTS_SETTINGS: GamingShortcutsSettings = { + enabled: false, + doublePressWindowMs: 300, + longPressThresholdMs: 650, + chordWindowMs: 150, + singlePress: { type: 'none' }, + doublePress: { type: 'none' }, + longPress: { type: 'none' }, + chords: [] +}; + +const CAPTURE_PROVIDERS = new Set(['auto', 'portal', 'grim', 'gnome-screenshot', 'spectacle', 'scrot']); +const HUD_PROVIDERS = new Set(['auto', 'gamescope', 'mangohud']); +const KEYBOARD_PROVIDERS = new Set(['auto', 'portal', 'wvkbd', 'onboard', 'matchbox-keyboard']); + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function string(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && !value.includes('\0'); +} + +function args(value: unknown): value is string[] { + return Array.isArray(value) && value.length <= 64 && value.every((arg) => typeof arg === 'string' && !arg.includes('\0')); +} + +function member(set: ReadonlySet, value: unknown): value is T { + return typeof value === 'string' && set.has(value as T); +} + +/** Repairs malformed persisted data to a harmless action. */ +export function validateGamingShortcutAction(value: unknown): GamingShortcutAction { + if (!record(value) || typeof value.type !== 'string') return { type: 'none' }; + switch (value.type) { + case 'none': return { type: 'none' }; + case 'passthrough': return { type: 'passthrough' }; + case 'open-opends5': return { type: 'open-opends5' }; + case 'launch-app': + return string(value.executable) && args(value.args) ? { type: 'launch-app', executable: value.executable, args: value.args } : { type: 'none' }; + case 'focus-app': + return string(value.appId) ? { type: 'focus-app', appId: value.appId } : { type: 'none' }; + case 'volume': + return value.direction === 'up' || value.direction === 'down' || value.direction === 'mute' ? { type: 'volume', direction: value.direction } : { type: 'none' }; + case 'microphone-mute-toggle': return { type: 'microphone-mute-toggle' }; + case 'screenshot': + return member(CAPTURE_PROVIDERS, value.provider) ? { type: 'screenshot', provider: value.provider } : { type: 'none' }; + case 'recording-toggle': + return member(CAPTURE_PROVIDERS, value.provider) ? { type: 'recording-toggle', provider: value.provider } : { type: 'none' }; + case 'performance-hud-toggle': + return member(HUD_PROVIDERS, value.provider) ? { type: 'performance-hud-toggle', provider: value.provider } : { type: 'none' }; + case 'on-screen-keyboard': + return member(KEYBOARD_PROVIDERS, value.provider) ? { type: 'on-screen-keyboard', provider: value.provider } : { type: 'none' }; + case 'switch-application': + return value.direction === 'next' || value.direction === 'previous' ? { type: 'switch-application', direction: value.direction } : { type: 'none' }; + case 'quit-active-game': return value.confirmation === true ? { type: 'quit-active-game', confirmation: true } : { type: 'none' }; + case 'custom-executable': + return string(value.executable) && args(value.args) ? { type: 'custom-executable', executable: value.executable, args: value.args } : { type: 'none' }; + default: return { type: 'none' }; + } +} + +const SECONDARY_BUTTONS = new Set>([ + 'cross', 'circle', 'square', 'triangle', 'l1', 'r1', 'l2', 'r2', 'l3', 'r3', + 'create', 'options', 'touchpad', 'mute', 'dpad-up', 'dpad-down', 'dpad-left', 'dpad-right' +]); + +function timing(value: unknown, fallback: number, min: number, max: number): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(min, Math.min(max, Math.round(value))) + : fallback; +} + +export function normalizeGamingShortcutsSettings(value: unknown): GamingShortcutsSettings { + if (!record(value)) return { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS }; + const chords = Array.isArray(value.chords) + ? value.chords.flatMap((entry) => { + if (!record(entry) || typeof entry.button !== 'string' || !SECONDARY_BUTTONS.has(entry.button as Exclude)) return []; + return [{ button: entry.button as Exclude, action: validateGamingShortcutAction(entry.action) }]; + }).slice(0, 32) + : []; + return { + enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_GAMING_SHORTCUTS_SETTINGS.enabled, + doublePressWindowMs: timing(value.doublePressWindowMs, 300, 100, 1000), + longPressThresholdMs: timing(value.longPressThresholdMs, 650, 300, 2000), + chordWindowMs: timing(value.chordWindowMs, 150, 50, 500), + singlePress: validateGamingShortcutAction(value.singlePress), + doublePress: validateGamingShortcutAction(value.doublePress), + longPress: validateGamingShortcutAction(value.longPress), + chords + }; +} diff --git a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts index 2d29363..4e2e6ea 100644 --- a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts +++ b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts @@ -1,4 +1,5 @@ import type { ModifierCondition, TriggerEffectSpec, TriggerProfile, TriggerSlotConfig } from './trigger-profiles'; +import { isControllerButton } from './controller-input'; import type { ControllerButton } from './controller-input'; export type { ControllerButton } from './controller-input'; @@ -130,7 +131,7 @@ export class ModifierEvaluator { case 'trigger-full-pull': return value >= FULL_PULL_THRESHOLD; case 'button-held': - return when.button !== undefined && state.buttons.has(when.button); + return isControllerButton(when.button) && state.buttons.has(when.button); case 'rapid-fire': { const required = when.pressesPerSecond ?? DEFAULT_PRESSES_PER_SECOND; return timing.pressTimestampsMs.length >= required; diff --git a/ds5-bridge/companion/src/shared/types.ts b/ds5-bridge/companion/src/shared/types.ts index 4de94fc..c272e9e 100644 --- a/ds5-bridge/companion/src/shared/types.ts +++ b/ds5-bridge/companion/src/shared/types.ts @@ -21,6 +21,7 @@ import type { PollingRateMode, TriggerTestMode } from './protocol'; +import type { GamingShortcutsSettings } from './gaming-shortcuts'; export type UiScalePercent = 75 | 100 | 125 | 150; export type UiThemePreset = 'light' | 'dark' | 'bubble-gum' | 'pomegranate' | 'kiwi'; @@ -89,6 +90,7 @@ export interface CompanionSettings { buttonRemappingDraft: ButtonRemapMap; chordFunctions: ChordFunction[]; chordAssignments: ChordAssignment[]; + gamingShortcuts: GamingShortcutsSettings; } export interface HidDeviceSummary { From cdb2c1eb43c13636e1c0461d54e31a5da54aba13 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:02:59 +0400 Subject: [PATCH 03/15] feat: add gaming shortcuts coordinator --- .../main/gaming-shortcuts/coordinator.test.ts | 58 ++++++++++ .../src/main/gaming-shortcuts/coordinator.ts | 107 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts new file mode 100644 index 0000000..e63ae44 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_GAMING_SHORTCUTS_SETTINGS, type GamingShortcutsSettings } from '../../shared/gaming-shortcuts'; +import type { ControllerInputState } from '../../shared/trigger-modifier-eval'; +import { ActionExecutor } from './action-executor'; +import { GamingShortcutsCoordinator } from './coordinator'; + +async function flushQueue(): Promise { + for (let index = 0; index < 4; index += 1) await Promise.resolve(); +} + +class FakeInput extends EventEmitter { + emitInput(buttons: ControllerInputState['buttons'], timestampMs: number): void { + this.emit('input', { buttons, timestampMs, l2: 0, r2: 0 }); + } +} + +describe('GamingShortcutsCoordinator', () => { + beforeEach(() => vi.useFakeTimers()); + + it('resolves a global PS binding and dispatches it asynchronously', async () => { + const input = new FakeInput(); + const settings: GamingShortcutsSettings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, singlePress: { type: 'open-opends5' } }; + const execute = vi.fn().mockResolvedValue({ ok: true }); + const coordinator = new GamingShortcutsCoordinator({ + input, + settingsStore: { get: () => ({ gamingShortcuts: settings }) }, + executor: { execute } as unknown as ActionExecutor + }); + const result = vi.fn(); coordinator.on('result', result); coordinator.start(); + input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(), 1); + vi.advanceTimersByTime(300); + await flushQueue(); + expect(execute).toHaveBeenCalledWith({ type: 'open-opends5' }); + expect(result).toHaveBeenCalledOnce(); + coordinator.stop(); + }); + + it('does not dispatch while disabled and removes its listener on stop', () => { + const input = new FakeInput(); + const execute = vi.fn(); + const coordinator = new GamingShortcutsCoordinator({ input, settingsStore: { get: () => ({ gamingShortcuts: DEFAULT_GAMING_SHORTCUTS_SETTINGS }) }, executor: { execute } as unknown as ActionExecutor }); + coordinator.start(); input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(), 1); vi.advanceTimersByTime(300); coordinator.stop(); + expect(execute).not.toHaveBeenCalled(); + }); + + it('serializes actions so a second gesture waits for the first', async () => { + const input = new FakeInput(); let release!: () => void; + const first = new Promise((resolve) => { release = resolve; }); + const execute = vi.fn().mockReturnValueOnce(first.then(() => ({ ok: true }))).mockResolvedValueOnce({ ok: true }); + const settings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, singlePress: { type: 'open-opends5' } as const }; + const coordinator = new GamingShortcutsCoordinator({ input, settingsStore: { get: () => ({ gamingShortcuts: settings }) }, executor: { execute } as unknown as ActionExecutor }); coordinator.start(); + input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(), 1); vi.advanceTimersByTime(300); await Promise.resolve(); + input.emitInput(new Set(['ps']), 1000); input.emitInput(new Set(), 1001); vi.advanceTimersByTime(300); await flushQueue(); + expect(execute).toHaveBeenCalledTimes(1); release(); await flushQueue(); expect(execute).toHaveBeenCalledTimes(2); + coordinator.stop(); + }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts new file mode 100644 index 0000000..62e9914 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts @@ -0,0 +1,107 @@ +import { EventEmitter } from 'node:events'; +import type { ControllerInputState } from '../../shared/trigger-modifier-eval'; +import type { GamingShortcutAction, GamingShortcutsSettings } from '../../shared/gaming-shortcuts'; +import type { ControllerButton } from '../../shared/controller-input'; +import type { ActionExecutionResult } from './action-executor'; +import { ActionExecutor } from './action-executor'; +import { GestureEngine, type ControllerGesture } from './gesture-engine'; + +export interface InputSource { + on(event: 'input', listener: (state: ControllerInputState) => void): this; + off(event: 'input', listener: (state: ControllerInputState) => void): this; +} + +export interface ShortcutSettingsSource { + get(): { gamingShortcuts: GamingShortcutsSettings }; +} + +export interface ShortcutExecutionResult { + gesture: ControllerGesture; + action: GamingShortcutAction; + result: ActionExecutionResult; +} + +/** Connects normalized controller input to validated, serialized shortcut actions. */ +export class GamingShortcutsCoordinator extends EventEmitter { + private readonly input: InputSource; + private readonly settingsStore: ShortcutSettingsSource; + private readonly executor: ActionExecutor; + private readonly onInputBound: (state: ControllerInputState) => void; + private gestureEngine: GestureEngine; + private settings: GamingShortcutsSettings; + private running = false; + private dispatchQueue: Promise = Promise.resolve(); + + constructor(options: { + input: InputSource; + settingsStore: ShortcutSettingsSource; + executor?: ActionExecutor; + }) { + super(); + this.input = options.input; + this.settingsStore = options.settingsStore; + this.executor = options.executor ?? new ActionExecutor(); + this.settings = this.settingsStore.get().gamingShortcuts; + this.gestureEngine = this.createGestureEngine(this.settings); + this.onInputBound = (state) => this.gestureEngine.update(state.buttons, state.timestampMs); + } + + start(): void { + if (this.running) return; + this.running = true; + this.input.on('input', this.onInputBound); + } + + stop(): void { + if (!this.running) return; + this.running = false; + this.input.off('input', this.onInputBound); + this.gestureEngine.reset(); + } + + reload(): void { + const next = this.settingsStore.get().gamingShortcuts; + const wasRunning = this.running; + if (wasRunning) this.stop(); + this.settings = next; + this.gestureEngine = this.createGestureEngine(next); + if (wasRunning) this.start(); + } + + private createGestureEngine(settings: GamingShortcutsSettings): GestureEngine { + return new GestureEngine({ + doublePressWindowMs: settings.doublePressWindowMs, + longPressThresholdMs: settings.longPressThresholdMs, + chordWindowMs: settings.chordWindowMs, + emit: (gesture) => this.handleGesture(gesture) + }); + } + + private handleGesture(gesture: ControllerGesture): void { + if (!this.settings.enabled) return; + const action = this.actionFor(gesture); + if (!action || action.type === 'none' || action.type === 'passthrough') return; + this.dispatchQueue = this.dispatchQueue + .then(async () => { + const result = await this.executor.execute(action); + this.emit('result', { gesture, action, result } satisfies ShortcutExecutionResult); + }) + .catch((error: unknown) => { + this.emit('error', error instanceof Error ? error : new Error(String(error))); + }); + } + + private actionFor(gesture: ControllerGesture): GamingShortcutAction | null { + switch (gesture.type) { + case 'single-press': return gesture.button === 'ps' ? this.settings.singlePress : null; + case 'double-press': return gesture.button === 'ps' ? this.settings.doublePress : null; + case 'long-press': return gesture.button === 'ps' ? this.settings.longPress : null; + case 'chord': { + const binding = this.settings.chords.find((candidate) => candidate.button === gesture.button); + return binding?.action ?? null; + } + } + } +} + +export type { ControllerButton }; From 752f7449e3fc15ca40197e14ea621b6fe05fe551 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:19:55 +0400 Subject: [PATCH 04/15] feat: wire gaming shortcuts IPC and Cachix Codex subagents Add Linux provider capability detection and runtime shortcut settings control. Track the OpenDS5 Cachix substituter and Codex subagent configuration alongside the feature integration. --- .../providers/detect-environment.test.ts | 33 ++++++++ .../providers/detect-environment.ts | 79 +++++++++++++++++++ ds5-bridge/companion/src/main/main.ts | 48 +++++++++++ ds5-bridge/companion/src/preload.ts | 11 +++ flake.nix | 9 +++ 5 files changed, 180 insertions(+) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts new file mode 100644 index 0000000..c6944ea --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { detectDesktopEnvironment, detectProviderCapabilities } from './detect-environment'; + +describe('detectDesktopEnvironment', () => { + it('prioritizes explicit compositor signals', () => { + expect(detectDesktopEnvironment({ env: { XDG_CURRENT_DESKTOP: 'KDE', HYPRLAND_INSTANCE_SIGNATURE: '1', WAYLAND_DISPLAY: 'wayland-1' } })).toBe('hyprland'); + expect(detectDesktopEnvironment({ env: { SWAYSOCK: '/run/sway.sock', DISPLAY: ':0' } })).toBe('sway'); + expect(detectDesktopEnvironment({ env: { GAMESCOPE_WAYLAND_DISPLAY: 'gamescope-0' } })).toBe('gamescope'); + }); + + it('recognizes desktop names and generic sessions', () => { + expect(detectDesktopEnvironment({ env: { XDG_CURRENT_DESKTOP: 'KDE', WAYLAND_DISPLAY: 'wayland-0' } })).toBe('kde'); + expect(detectDesktopEnvironment({ env: { XDG_SESSION_DESKTOP: 'gnome', WAYLAND_DISPLAY: 'wayland-0' } })).toBe('gnome'); + expect(detectDesktopEnvironment({ env: { WAYLAND_DISPLAY: 'wayland-0' } })).toBe('wayland'); + expect(detectDesktopEnvironment({ env: { DISPLAY: ':0' } })).toBe('x11'); + }); +}); + +describe('detectProviderCapabilities', () => { + it('reports only commands available through the injected probe', () => { + const available = new Set(['grim', 'wf-recorder', 'mangohud', 'wvkbd']); + expect(detectProviderCapabilities({ + env: { HYPRLAND_INSTANCE_SIGNATURE: '1', WAYLAND_DISPLAY: 'wayland-0' }, + hasExecutable: (name) => available.has(name) + })).toEqual({ + environment: 'hyprland', + screenshot: ['grim'], + recording: ['wf-recorder'], + hud: ['mangohud'], + keyboard: ['wvkbd'] + }); + }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts new file mode 100644 index 0000000..37ec9d4 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts @@ -0,0 +1,79 @@ +export type DesktopEnvironment = + | 'hyprland' + | 'sway' + | 'kde' + | 'gnome' + | 'gamescope' + | 'wayland' + | 'x11' + | 'unknown'; + +export interface EnvironmentProbe { + env?: NodeJS.ProcessEnv; + hasExecutable?: (executable: string) => boolean; +} + +export interface ProviderCapabilities { + environment: DesktopEnvironment; + screenshot: string[]; + recording: string[]; + hud: string[]; + keyboard: string[]; +} + +function defaultHasExecutable(executable: string): boolean { + try { + execFileSync('which', [executable], { stdio: 'ignore', timeout: 750 }); + return true; + } catch { + return false; + } +} + +function desktopValue(env: NodeJS.ProcessEnv): string { + return `${env.XDG_CURRENT_DESKTOP ?? ''}:${env.XDG_SESSION_DESKTOP ?? ''}`.toLowerCase(); +} + +/** Detects only environment facts; command availability is injected for tests. */ +export function detectDesktopEnvironment({ env = process.env }: EnvironmentProbe = {}): DesktopEnvironment { + if (env.GAMESCOPE_WAYLAND_DISPLAY) return 'gamescope'; + if (env.HYPRLAND_INSTANCE_SIGNATURE) return 'hyprland'; + if (env.SWAYSOCK) return 'sway'; + + const desktop = desktopValue(env); + if (desktop.includes('hyprland')) return 'hyprland'; + if (desktop.includes('sway')) return 'sway'; + if (desktop.includes('kde') || desktop.includes('plasma')) return 'kde'; + if (desktop.includes('gnome')) return 'gnome'; + if (env.WAYLAND_DISPLAY) return 'wayland'; + if (env.DISPLAY) return 'x11'; + return 'unknown'; +} + +export function detectProviderCapabilities(options: EnvironmentProbe = {}): ProviderCapabilities { + const env = options.env ?? process.env; + const hasExecutable = options.hasExecutable ?? defaultHasExecutable; + const environment = detectDesktopEnvironment({ env, hasExecutable }); + + const screenshot: string[] = []; + if (hasExecutable('grim')) screenshot.push('grim'); + if (hasExecutable('gnome-screenshot')) screenshot.push('gnome-screenshot'); + if (hasExecutable('spectacle')) screenshot.push('spectacle'); + if (hasExecutable('scrot')) screenshot.push('scrot'); + + const recording: string[] = []; + if (hasExecutable('wf-recorder')) recording.push('wf-recorder'); + if (hasExecutable('obs')) recording.push('obs'); + + const hud: string[] = []; + if (environment === 'gamescope') hud.push('gamescope'); + if (hasExecutable('mangohud')) hud.push('mangohud'); + + const keyboard: string[] = []; + for (const executable of ['wvkbd', 'onboard', 'matchbox-keyboard']) { + if (hasExecutable(executable)) keyboard.push(executable); + } + + return { environment, screenshot, recording, hud, keyboard }; +} +import { execFileSync } from 'node:child_process'; diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index 1237ef5..cb07db9 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -27,6 +27,9 @@ import { EvdevInputReader } from './evdev-input-reader'; import { TriggerProfileEngine, type DraftPreviewTriggers, type EngineStatus } from './trigger-profile-engine'; import { GameSettingsCoordinator, type GameSettingsStatus } from './game-settings-coordinator'; import { GameArtworkStore } from './game-artwork'; +import { GamingShortcutsCoordinator } from './gaming-shortcuts/coordinator'; +import { detectProviderCapabilities } from './gaming-shortcuts/providers/detect-environment'; +import { normalizeGamingShortcutsSettings } from '../shared/gaming-shortcuts'; import { InstalledGamesScanner, defaultScannerRoots, @@ -78,6 +81,8 @@ let tray: Tray | null = null; let trayDefaultIcon: Electron.NativeImage | null = null; let bridgeService: BridgeService | null = null; let triggerProfileEngine: TriggerProfileEngine | null = null; +let gamingShortcutsCoordinator: GamingShortcutsCoordinator | null = null; +let gamingShortcutsReader: EvdevInputReader | null = null; let isQuitting = false; let shutdownComplete = false; // One-time DS5 Bridge -> OpenDS5 rebrand migration: carry legacy userData @@ -1088,12 +1093,29 @@ function fileToDataUrl(filePath: string): string | null { function registerIpc( service: BridgeService, + settingsStore: SettingsStore, + gamingShortcuts: GamingShortcutsCoordinator | null, triggerProfileStore: TriggerProfileStore, triggerProfileEngine: TriggerProfileEngine, profileLibrary: ProfileLibrary, gameSettingsCoordinator: GameSettingsCoordinator, gameArtworkStore: GameArtworkStore ): void { + ipcMain.handle('bridge:getGamingShortcutsSettings', () => settingsStore.get().gamingShortcuts); + ipcMain.handle('bridge:saveGamingShortcutsSettings', (_event, value: unknown) => { + const next = normalizeGamingShortcutsSettings(value); + const saved = settingsStore.update({ gamingShortcuts: next }); + gamingShortcuts?.reload(); + if (next.enabled) { + gamingShortcuts?.start(); + gamingShortcutsReader?.start(); + } else { + gamingShortcuts?.stop(); + gamingShortcutsReader?.stop(); + } + return saved.gamingShortcuts; + }); + ipcMain.handle('bridge:getGamingShortcutProviders', () => detectProviderCapabilities()); ipcMain.handle('bridge:listTriggerProfiles', () => triggerProfileStore.list()); ipcMain.handle('bridge:saveTriggerProfile', (_event, profile: TriggerProfile) => { const saved = triggerProfileStore.save(profile); @@ -1673,6 +1695,24 @@ app.whenReady().then(async () => { watcher: new GameWatcher({}), reader: new EvdevInputReader() }); + if (process.platform === 'linux') { + const shortcutReader = new EvdevInputReader(); + gamingShortcutsReader = shortcutReader; + shortcutReader.on('error', (error) => { + console.error('[gaming-shortcuts] input reader error', error); + }); + gamingShortcutsCoordinator = new GamingShortcutsCoordinator({ + input: shortcutReader, + settingsStore + }); + gamingShortcutsCoordinator.on('error', (error) => { + console.error('[gaming-shortcuts] action error', error); + }); + if (settingsStore.get().gamingShortcuts.enabled) { + gamingShortcutsCoordinator.start(); + shortcutReader.start(); + } + } triggerProfileEngine.refreshProfiles(); // Game Profile: rides the trigger engine's detection to swap the whole settings set // (controller profile + button remapping) per game. Recovery and subscription happen @@ -1710,6 +1750,8 @@ app.whenReady().then(async () => { }); registerIpc( bridgeService, + settingsStore, + gamingShortcutsCoordinator, triggerProfileStore, triggerProfileEngine, profileLibrary, @@ -1785,11 +1827,17 @@ app.on('before-quit', (event) => { isQuitting = true; const service = bridgeService; const engine = triggerProfileEngine; + const shortcuts = gamingShortcutsCoordinator; + const shortcutReader = gamingShortcutsReader; bridgeService = null; triggerProfileEngine = null; + gamingShortcutsCoordinator = null; + gamingShortcutsReader = null; void (async () => { try { await engine?.setEnabled(false); + shortcuts?.stop(); + shortcutReader?.stop(); await service?.stop(); } finally { shutdownComplete = true; diff --git a/ds5-bridge/companion/src/preload.ts b/ds5-bridge/companion/src/preload.ts index fcf76d0..80aa211 100644 --- a/ds5-bridge/companion/src/preload.ts +++ b/ds5-bridge/companion/src/preload.ts @@ -25,6 +25,8 @@ import type { EngineStatus, TriggerProfile, TriggerSlotConfig } from './shared/t import type { GameSettingsStatus } from './main/game-settings-coordinator'; import type { GameArtworkEntry, GameArtworkSearchResult } from './main/game-artwork'; import type { InstalledGame } from './main/installed-games'; +import type { ProviderCapabilities } from './main/gaming-shortcuts/providers/detect-environment'; +import type { GamingShortcutsSettings } from './shared/gaming-shortcuts'; export interface InstalledGamesList { games: Array; @@ -52,6 +54,15 @@ const SETUP_CHANNELS = { const api = { getStatus: (): Promise => ipcRenderer.invoke('bridge:getStatus'), + getGamingShortcutsSettings: (): Promise => ( + ipcRenderer.invoke('bridge:getGamingShortcutsSettings') + ), + saveGamingShortcutsSettings: (value: unknown): Promise => ( + ipcRenderer.invoke('bridge:saveGamingShortcutsSettings', value) + ), + getGamingShortcutProviders: (): Promise => ( + ipcRenderer.invoke('bridge:getGamingShortcutProviders') + ), listDevices: () => ipcRenderer.invoke('bridge:listDevices'), listAudioHapticsSessions: (): Promise => ( ipcRenderer.invoke('bridge:listAudioHapticsSessions') diff --git a/flake.nix b/flake.nix index 947ce79..f189f61 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,15 @@ { description = "OpenDS5 — DualSense companion application and virtual DualSense stack"; + nixConfig = { + extra-substituters = [ + "https://opends5.cachix.org" + ]; + extra-trusted-public-keys = [ + "opends5.cachix.org-1:IzUxvZYBhuASyX7O7c1AkqNT3NiLtbF5X0eAwFCM95g=" + ]; + }; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = { From 382975f8122605ff768b28272ebce9154d6c4588 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:25:34 +0400 Subject: [PATCH 05/15] feat: execute safe Linux gaming shortcut actions --- .../gaming-shortcuts/action-executor.test.ts | 10 +++- .../main/gaming-shortcuts/action-executor.ts | 13 +++++ .../providers/linux-actions.test.ts | 32 +++++++++++++ .../providers/linux-actions.ts | 47 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts index 414e2dc..c8ed15d 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { ActionExecutor } from './action-executor'; +import { LinuxActionProvider } from './providers/linux-actions'; describe('ActionExecutor', () => { it('runs explicit executable arguments', async () => { @@ -31,9 +32,16 @@ describe('ActionExecutor', () => { expect(run).not.toHaveBeenCalled(); }); + it('executes volume actions through the fixed Linux provider command', async () => { + const run = vi.fn().mockResolvedValue({ code: 0, signal: null, timedOut: false }); + const executor = new ActionExecutor({ runner: { run }, linuxProvider: new LinuxActionProvider({ hasExecutable: () => true }) }); + await expect(executor.execute({ type: 'volume', direction: 'up' })).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledWith('wpctl', ['set-volume', '-l', '1.5', '@DEFAULT_AUDIO_SINK@', '5%+']); + }); + it('does not invoke a runner for unavailable provider actions', async () => { const run = vi.fn(); - const executor = new ActionExecutor({ runner: { run } }); + const executor = new ActionExecutor({ runner: { run }, linuxProvider: new LinuxActionProvider({ hasExecutable: () => false }) }); await expect(executor.execute({ type: 'volume', direction: 'up' })).resolves.toEqual({ ok: false, reason: 'unavailable' }); expect(run).not.toHaveBeenCalled(); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts index 3c9bc57..5f4a1ac 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts @@ -1,5 +1,6 @@ import { validateGamingShortcutAction, type GamingShortcutAction } from '../../shared/gaming-shortcuts'; import { createProcessRunner, type ProcessRunner } from './process-runner'; +import { LinuxActionProvider } from './providers/linux-actions'; export type ActionExecutionResult = | { ok: true } @@ -8,16 +9,19 @@ export type ActionExecutionResult = export interface ActionExecutorOptions { runner?: ProcessRunner; openOpenDS5?: () => Promise | void; + linuxProvider?: LinuxActionProvider; } /** Executes only validated actions; desktop-specific actions are provider work. */ export class ActionExecutor { private readonly runner: ProcessRunner; private readonly openOpenDS5: (() => Promise | void) | null; + private readonly linuxProvider: LinuxActionProvider; constructor(options: ActionExecutorOptions = {}) { this.runner = options.runner ?? createProcessRunner(); this.openOpenDS5 = options.openOpenDS5 ?? null; + this.linuxProvider = options.linuxProvider ?? new LinuxActionProvider(); } async execute(rawAction: unknown): Promise { @@ -38,6 +42,15 @@ export class ActionExecutor { ? { ok: true } : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with code ${result.code ?? 'unknown'}` }; } + case 'volume': + case 'microphone-mute-toggle': { + const command = this.linuxProvider.resolve(action); + if (!command) return { ok: false, reason: 'unavailable' }; + const result = await this.runner.run(command.executable, command.args); + return result.code === 0 && result.signal === null && !result.timedOut + ? { ok: true } + : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with ${result.code ?? 'unknown'}` }; + } default: return { ok: false, reason: 'unavailable' }; } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts new file mode 100644 index 0000000..f378a19 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { LinuxActionProvider } from './linux-actions'; + +describe('LinuxActionProvider', () => { + const provider = new LinuxActionProvider({ hasExecutable: (name) => name === 'wpctl' }); + + it('resolves volume changes to fixed wpctl arguments', () => { + expect(provider.resolve({ type: 'volume', direction: 'up' })).toEqual({ + executable: 'wpctl', + args: ['set-volume', '-l', '1.5', '@DEFAULT_AUDIO_SINK@', '5%+'] + }); + expect(provider.resolve({ type: 'volume', direction: 'down' })).toEqual({ + executable: 'wpctl', + args: ['set-volume', '-l', '1.5', '@DEFAULT_AUDIO_SINK@', '5%-'] + }); + expect(provider.resolve({ type: 'volume', direction: 'mute' })).toEqual({ + executable: 'wpctl', + args: ['set-mute', '@DEFAULT_AUDIO_SINK@', 'toggle'] + }); + }); + + it('resolves the default microphone toggle', () => { + expect(provider.resolve({ type: 'microphone-mute-toggle' })).toEqual({ + executable: 'wpctl', + args: ['set-mute', '@DEFAULT_AUDIO_SOURCE@', 'toggle'] + }); + }); + + it('reports unavailable when wpctl is missing', () => { + expect(new LinuxActionProvider({ hasExecutable: () => false }).resolve({ type: 'volume', direction: 'up' })).toBeNull(); + }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts new file mode 100644 index 0000000..0a58fe0 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts @@ -0,0 +1,47 @@ +import { execFileSync } from 'node:child_process'; +import type { GamingShortcutAction } from '../../../shared/gaming-shortcuts'; + +export interface ResolvedLinuxAction { + executable: string; + args: string[]; +} + +export interface LinuxActionProviderOptions { + hasExecutable?: (executable: string) => boolean; +} + +function defaultHasExecutable(executable: string): boolean { + try { + execFileSync('which', [executable], { stdio: 'ignore', timeout: 750 }); + return true; + } catch { + return false; + } +} + +/** Resolves only fixed, argument-array Linux actions; it never invokes a shell. */ +export class LinuxActionProvider { + private readonly hasExecutable: (executable: string) => boolean; + + constructor(options: LinuxActionProviderOptions = {}) { + this.hasExecutable = options.hasExecutable ?? defaultHasExecutable; + } + + resolve(action: GamingShortcutAction): ResolvedLinuxAction | null { + switch (action.type) { + case 'volume': + if (!this.hasExecutable('wpctl')) return null; + if (action.direction === 'mute') return { executable: 'wpctl', args: ['set-mute', '@DEFAULT_AUDIO_SINK@', 'toggle'] }; + return { + executable: 'wpctl', + args: ['set-volume', '-l', '1.5', '@DEFAULT_AUDIO_SINK@', action.direction === 'up' ? '5%+' : '5%-'] + }; + case 'microphone-mute-toggle': + return this.hasExecutable('wpctl') + ? { executable: 'wpctl', args: ['set-mute', '@DEFAULT_AUDIO_SOURCE@', 'toggle'] } + : null; + default: + return null; + } + } +} From d8de8f29b40ba9b94c68c00754696f68226a62c3 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:32:43 +0400 Subject: [PATCH 06/15] feat: add Linux screenshot shortcut provider --- .../gaming-shortcuts/action-executor.test.ts | 15 ++++ .../main/gaming-shortcuts/action-executor.ts | 13 ++++ .../providers/screenshot.test.ts | 34 +++++++++ .../gaming-shortcuts/providers/screenshot.ts | 74 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts index c8ed15d..b963945 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ActionExecutor } from './action-executor'; import { LinuxActionProvider } from './providers/linux-actions'; +import { ScreenshotProvider } from './providers/screenshot'; describe('ActionExecutor', () => { it('runs explicit executable arguments', async () => { @@ -39,6 +40,20 @@ describe('ActionExecutor', () => { expect(run).toHaveBeenCalledWith('wpctl', ['set-volume', '-l', '1.5', '@DEFAULT_AUDIO_SINK@', '5%+']); }); + it('executes screenshots through the selected provider', async () => { + const run = vi.fn().mockResolvedValue({ code: 0, signal: null, timedOut: false }); + const executor = new ActionExecutor({ + runner: { run }, + screenshotProvider: new ScreenshotProvider({ + env: { HOME: '/home/test', XDG_PICTURES_DIR: '/tmp' }, + now: () => new Date('2026-07-16T12:34:56.789Z'), + hasExecutable: (name) => name === 'grim' + }) + }); + await expect(executor.execute({ type: 'screenshot', provider: 'grim' })).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledWith('grim', ['/tmp/OpenDS5-2026-07-16T12-34-56-789Z.png']); + }); + it('does not invoke a runner for unavailable provider actions', async () => { const run = vi.fn(); const executor = new ActionExecutor({ runner: { run }, linuxProvider: new LinuxActionProvider({ hasExecutable: () => false }) }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts index 5f4a1ac..aff8c35 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts @@ -1,6 +1,7 @@ import { validateGamingShortcutAction, type GamingShortcutAction } from '../../shared/gaming-shortcuts'; import { createProcessRunner, type ProcessRunner } from './process-runner'; import { LinuxActionProvider } from './providers/linux-actions'; +import { ScreenshotProvider } from './providers/screenshot'; export type ActionExecutionResult = | { ok: true } @@ -10,6 +11,7 @@ export interface ActionExecutorOptions { runner?: ProcessRunner; openOpenDS5?: () => Promise | void; linuxProvider?: LinuxActionProvider; + screenshotProvider?: ScreenshotProvider; } /** Executes only validated actions; desktop-specific actions are provider work. */ @@ -17,11 +19,13 @@ export class ActionExecutor { private readonly runner: ProcessRunner; private readonly openOpenDS5: (() => Promise | void) | null; private readonly linuxProvider: LinuxActionProvider; + private readonly screenshotProvider: ScreenshotProvider; constructor(options: ActionExecutorOptions = {}) { this.runner = options.runner ?? createProcessRunner(); this.openOpenDS5 = options.openOpenDS5 ?? null; this.linuxProvider = options.linuxProvider ?? new LinuxActionProvider(); + this.screenshotProvider = options.screenshotProvider ?? new ScreenshotProvider(); } async execute(rawAction: unknown): Promise { @@ -51,6 +55,15 @@ export class ActionExecutor { ? { ok: true } : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with ${result.code ?? 'unknown'}` }; } + case 'screenshot': { + const command = this.screenshotProvider.resolve(action.provider); + if (!command) return { ok: false, reason: 'unavailable' }; + this.screenshotProvider.ensureOutputDirectory(command); + const result = await this.runner.run(command.executable, command.args); + return result.code === 0 && result.signal === null && !result.timedOut + ? { ok: true } + : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with ${result.code ?? 'unknown'}` }; + } default: return { ok: false, reason: 'unavailable' }; } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts new file mode 100644 index 0000000..6192793 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { ScreenshotProvider } from './screenshot'; + +describe('ScreenshotProvider', () => { + const now = () => new Date('2026-07-16T12:34:56.789Z'); + + it('prefers grim automatically on Wayland', () => { + const provider = new ScreenshotProvider({ + env: { HOME: '/home/test', WAYLAND_DISPLAY: 'wayland-1' }, now, + hasExecutable: (name) => name === 'grim' + }); + expect(provider.resolve('auto')).toEqual({ + executable: 'grim', + args: ['/home/test/Pictures/OpenDS5-2026-07-16T12-34-56-789Z.png'], + outputPath: '/home/test/Pictures/OpenDS5-2026-07-16T12-34-56-789Z.png' + }); + }); + + it('uses the explicit desktop provider and XDG pictures directory', () => { + const provider = new ScreenshotProvider({ + env: { HOME: '/home/test', XDG_PICTURES_DIR: '/mnt/captures' }, now, + hasExecutable: (name) => name === 'spectacle' + }); + expect(provider.resolve('spectacle')).toEqual({ + executable: 'spectacle', + args: ['-b', '-n', '-o', '/mnt/captures/OpenDS5-2026-07-16T12-34-56-789Z.png'], + outputPath: '/mnt/captures/OpenDS5-2026-07-16T12-34-56-789Z.png' + }); + }); + + it('returns unavailable when auto detection finds no tool', () => { + expect(new ScreenshotProvider({ env: { HOME: '/home/test' }, now, hasExecutable: () => false }).resolve('auto')).toBeNull(); + }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts new file mode 100644 index 0000000..3261112 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts @@ -0,0 +1,74 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { CaptureProvider } from '../../../shared/gaming-shortcuts'; + +export interface ScreenshotCommand { + executable: string; + args: string[]; + outputPath: string; +} + +export interface ScreenshotProviderOptions { + env?: NodeJS.ProcessEnv; + now?: () => Date; + hasExecutable?: (executable: string) => boolean; +} + +function defaultHasExecutable(executable: string): boolean { + try { + execFileSync('which', [executable], { stdio: 'ignore', timeout: 750 }); + return true; + } catch { + return false; + } +} + +function picturesDirectory(env: NodeJS.ProcessEnv): string { + const configured = env.XDG_PICTURES_DIR; + if (configured && path.isAbsolute(configured)) return configured; + return path.join(env.HOME || os.homedir(), 'Pictures'); +} + +function filename(now: Date): string { + const stamp = now.toISOString().replace(/[:.]/g, '-'); + return `OpenDS5-${stamp}.png`; +} + +/** Builds screenshot commands without invoking a shell or accepting raw command text. */ +export class ScreenshotProvider { + private readonly env: NodeJS.ProcessEnv; + private readonly now: () => Date; + private readonly hasExecutable: (executable: string) => boolean; + + constructor(options: ScreenshotProviderOptions = {}) { + this.env = options.env ?? process.env; + this.now = options.now ?? (() => new Date()); + this.hasExecutable = options.hasExecutable ?? defaultHasExecutable; + } + + resolve(provider: CaptureProvider): ScreenshotCommand | null { + const selected = provider === 'auto' ? this.autoProvider() : provider; + const outputPath = path.join(picturesDirectory(this.env), filename(this.now())); + switch (selected) { + case 'grim': return { executable: 'grim', args: [outputPath], outputPath }; + case 'gnome-screenshot': return { executable: 'gnome-screenshot', args: ['-f', outputPath], outputPath }; + case 'spectacle': return { executable: 'spectacle', args: ['-b', '-n', '-o', outputPath], outputPath }; + case 'scrot': return { executable: 'scrot', args: [outputPath], outputPath }; + default: return null; + } + } + + ensureOutputDirectory(command: ScreenshotCommand): void { + fs.mkdirSync(path.dirname(command.outputPath), { recursive: true }); + } + + private autoProvider(): CaptureProvider | null { + const wayland = Boolean(this.env.WAYLAND_DISPLAY || this.env.HYPRLAND_INSTANCE_SIGNATURE || this.env.SWAYSOCK); + const candidates = wayland + ? ['grim', 'gnome-screenshot', 'spectacle', 'scrot'] + : ['gnome-screenshot', 'spectacle', 'scrot', 'grim']; + return candidates.find((candidate) => this.hasExecutable(candidate)) as CaptureProvider | undefined ?? null; + } +} From cae27d23b3cf53b70c105b88aee03461df37e2b7 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:50:17 +0400 Subject: [PATCH 07/15] feat: add gaming shortcuts settings UI --- ds5-bridge/companion/src/renderer/App.tsx | 6 +- .../src/renderer/GamingShortcuts.tsx | 119 ++++++++++++++++++ ds5-bridge/companion/src/renderer/styles.css | 108 ++++++++++++++++ 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 ds5-bridge/companion/src/renderer/GamingShortcuts.tsx diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 1c5b300..3535118 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -175,8 +175,9 @@ import { filterLibrary } from './library-search'; import { matchGameInLibrary, nativeGameFeatureMap, nativeGameFeatures } from './game-library-match'; import { TriggerEffectEditor } from './TriggerEffectEditor'; import { StickWheelEditor, type StickSample } from './StickWheelEditor'; +import { GamingShortcuts } from './GamingShortcuts'; -type ControlTab = 'game-profile' | 'overview' | 'haptics' | 'audio' | 'triggers' | 'trigger-profiles' | 'lighting' | 'remapping' | 'chords' | 'system'; +type ControlTab = 'game-profile' | 'overview' | 'haptics' | 'audio' | 'triggers' | 'trigger-profiles' | 'lighting' | 'remapping' | 'chords' | 'gaming-shortcuts' | 'system'; type StartupTutorialStep = 'feature-toggle' | 'done'; type ControllerType = BridgeStatusPayload['controllerType']; type KnownControllerType = Exclude; @@ -742,6 +743,7 @@ const CONTROL_TABS: Array<{ id: ControlTab; label: string; Icon: TablerIcon }> = { id: 'trigger-profiles', label: 'Trigger Profiles', Icon: IconTargetArrow }, { id: 'lighting', label: 'Lighting', Icon: IconBulb }, { id: 'remapping', label: 'Button Remapping', Icon: IconDeviceGamepad3 }, + { id: 'gaming-shortcuts', label: 'Gaming Shortcuts', Icon: Zap }, { id: 'system', label: 'System', Icon: IconCpu } ]; @@ -10422,6 +10424,8 @@ export function App() { + +
; label: string }> = [ + { value: 'create', label: 'Create' }, { value: 'options', label: 'Options' }, { value: 'touchpad', label: 'Touchpad' }, { value: 'mute', label: 'Mute' }, + { value: 'cross', label: 'Cross' }, { value: 'circle', label: 'Circle' }, { value: 'square', label: 'Square' }, { value: 'triangle', label: 'Triangle' }, + { value: 'dpad-up', label: 'D-pad Up' }, { value: 'dpad-down', label: 'D-pad Down' }, { value: 'dpad-left', label: 'D-pad Left' }, { value: 'dpad-right', label: 'D-pad Right' } +]; + +type ActionKind = GamingShortcutAction['type']; + +const ACTIONS: Array<{ value: ActionKind; label: string }> = [ + { value: 'none', label: 'No action' }, { value: 'passthrough', label: 'Pass through only' }, { value: 'open-opends5', label: 'Open or focus OpenDS5' }, + { value: 'launch-app', label: 'Open or focus an application' }, { value: 'focus-app', label: 'Focus application by ID' }, + { value: 'screenshot', label: 'Take screenshot' }, { value: 'recording-toggle', label: 'Toggle recording' }, + { value: 'performance-hud-toggle', label: 'Toggle performance HUD' }, { value: 'volume', label: 'Volume' }, + { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'on-screen-keyboard', label: 'Open on-screen keyboard' }, + { value: 'switch-application', label: 'Switch application' }, { value: 'quit-active-game', label: 'Quit active game (confirm)' }, + { value: 'custom-executable', label: 'Custom executable' } +]; + +function actionForKind(kind: ActionKind): GamingShortcutAction { + switch (kind) { + case 'volume': return { type: 'volume', direction: 'up' }; + case 'launch-app': return { type: 'launch-app', executable: '', args: [] }; + case 'focus-app': return { type: 'focus-app', appId: '' }; + case 'screenshot': return { type: 'screenshot', provider: 'auto' }; + case 'recording-toggle': return { type: 'recording-toggle', provider: 'auto' }; + case 'performance-hud-toggle': return { type: 'performance-hud-toggle', provider: 'auto' }; + case 'on-screen-keyboard': return { type: 'on-screen-keyboard', provider: 'auto' }; + case 'switch-application': return { type: 'switch-application', direction: 'next' }; + case 'quit-active-game': return { type: 'quit-active-game', confirmation: true }; + case 'custom-executable': return { type: 'custom-executable', executable: '', args: [] }; + default: return { type: kind } as GamingShortcutAction; + } +} + +function actionLabel(action: GamingShortcutAction): string { + return ACTIONS.find((item) => item.value === action.type)?.label ?? 'No action'; +} + +function isProviderAction(action: GamingShortcutAction): action is Extract { + return action.type === 'screenshot' || action.type === 'recording-toggle' || action.type === 'performance-hud-toggle' || action.type === 'on-screen-keyboard'; +} + +function ActionEditor({ action, onChange, capabilities }: { + action: GamingShortcutAction; + onChange: (next: GamingShortcutAction) => void; + capabilities: ProviderCapabilities | null; +}) { + const providerOptions = action.type === 'screenshot' ? capabilities?.screenshot ?? [] + : action.type === 'recording-toggle' ? capabilities?.recording ?? [] + : action.type === 'performance-hud-toggle' ? capabilities?.hud ?? [] + : capabilities?.keyboard ?? []; + return
+ + {action.type === 'volume' && } + {action.type === 'switch-application' && } + {isProviderAction(action) && } + {action.type === 'custom-executable' && <> + onChange({ ...action, executable: event.target.value })} /> + onChange({ ...action, args: event.target.value.trim() ? event.target.value.trim().split(/\s+/) : [] })} /> + } + {action.type === 'launch-app' && <> + onChange({ ...action, executable: event.target.value })} /> + onChange({ ...action, args: event.target.value.trim() ? event.target.value.trim().split(/\s+/) : [] })} /> + } + {action.type === 'focus-app' && onChange({ ...action, appId: event.target.value })} />} +
; +} + +export function GamingShortcuts({ active }: { active: boolean }) { + const [settings, setSettings] = useState(DEFAULT_GAMING_SHORTCUTS_SETTINGS); + const [capabilities, setCapabilities] = useState(null); + const [status, setStatus] = useState(''); + useEffect(() => { + void window.bridge.getGamingShortcutsSettings().then(setSettings); + void window.bridge.getGamingShortcutProviders().then(setCapabilities); + }, []); + const update = (next: GamingShortcutsSettings) => { + setSettings(next); + setStatus('Saving…'); + void window.bridge.saveGamingShortcutsSettings(next).then((saved) => { setSettings(saved); setStatus('Saved'); }).catch(() => setStatus('Could not save settings')); + }; + const setBinding = (key: 'singlePress' | 'doublePress' | 'longPress', action: GamingShortcutAction) => update({ ...settings, [key]: action }); + const defaultPreset = useMemo(() => ({ ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, + singlePress: { type: 'open-opends5' } as GamingShortcutAction, + doublePress: { type: 'switch-application', direction: 'previous' } as GamingShortcutAction, + chords: [{ button: 'create' as const, action: { type: 'screenshot', provider: 'auto' } as GamingShortcutAction }] + }), []); + return
+

Gaming Shortcuts

Turn the PS button into a configurable Linux gaming shortcut layer.

{status}
+
+
Enable Gaming ShortcutsActions are observational: Steam or a game may also receive the PS button.
+
+ {([['singlePress', 'PS button · Press'], ['doublePress', 'PS button · Double press'], ['longPress', 'PS button · Hold']] as const).map(([key, label]) => )} +
+
+
PS chords

Hold PS and press another controller button. Chords take precedence over PS press gestures.

+ {settings.chords.length === 0 ?

No chords configured.

: settings.chords.map((chord, index) =>
{ const chords = [...settings.chords]; chords[index] = { ...chord, action }; update({ ...settings, chords }); }} capabilities={capabilities} />
)} +
+
Timing
+
Environment: {capabilities?.environment ?? 'Detecting…'}Screenshot: {capabilities?.screenshot.join(', ') || 'Unavailable'}Recording: {capabilities?.recording.join(', ') || 'Unavailable'}HUD: {capabilities?.hud.join(', ') || 'Unavailable'}Keyboard: {capabilities?.keyboard.join(', ') || 'Unavailable'}
+
; +} diff --git a/ds5-bridge/companion/src/renderer/styles.css b/ds5-bridge/companion/src/renderer/styles.css index 98e580c..a89836a 100644 --- a/ds5-bridge/companion/src/renderer/styles.css +++ b/ds5-bridge/companion/src/renderer/styles.css @@ -2455,6 +2455,114 @@ button { pointer-events: auto; } +.gaming-shortcuts-page { + grid-template-rows: auto auto auto auto auto; +} + +.gaming-shortcuts-card { + padding: 18px 20px; +} + +.gaming-shortcuts-toggle, +.gaming-shortcut-row, +.gaming-provider-status { + display: flex; + align-items: center; + gap: 12px; +} + +.gaming-shortcuts-toggle { + justify-content: space-between; +} + +.gaming-shortcuts-toggle div, +.gaming-shortcut-row > span { + display: grid; + gap: 4px; +} + +.gaming-shortcuts-toggle span, +.gaming-shortcuts-help, +.gaming-shortcuts-save-status, +.gaming-provider-status span, +.muted-copy { + color: var(--text-muted); + font-size: 0.86rem; +} + +.gaming-shortcut-grid { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.gaming-shortcut-row { + min-height: 42px; +} + +.gaming-shortcut-row > span, +.gaming-shortcut-row > select:first-child { + min-width: 170px; +} + +.gaming-shortcut-action-editor { + display: flex; + flex: 1; + flex-wrap: wrap; + gap: 8px; +} + +.gaming-shortcut-action-editor select, +.gaming-shortcut-action-editor input, +.gaming-shortcut-row > select, +.gaming-timing-grid input { + min-height: 34px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--surface-raised); + color: var(--text-primary); + padding: 0 9px; +} + +.gaming-shortcut-action-editor select { + min-width: 190px; + flex: 1; +} + +.gaming-shortcuts-card > .feature-card-title { + justify-content: space-between; +} + +.gaming-timing-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.gaming-timing-grid label { + display: grid; + gap: 7px; + color: var(--text-muted); + font-size: 0.86rem; +} + +.gaming-provider-status { + flex-wrap: wrap; + padding: 4px 2px 18px; +} + +@media (max-width: 799px) { + .gaming-timing-grid { + grid-template-columns: 1fr; + } + + .gaming-shortcut-row { + align-items: stretch; + flex-direction: column; + } +} + .overview-page { height: 100%; min-height: 0; From a6eaa56a48e7b976d03e4fd525e4a5f02526bdbb Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:55:18 +0400 Subject: [PATCH 08/15] fix: normalize DualSense hat d-pad events --- .../src/main/evdev-input-reader.test.ts | 18 ++++++++++++ .../companion/src/main/evdev-input-reader.ts | 29 +++++++++++-------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts index 85a0911..1c48933 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts @@ -18,6 +18,8 @@ const EV_SYN = 0; const EV_KEY = 1; const EV_ABS = 3; const ABS_RZ = 5; +const ABS_HAT0X = 16; +const ABS_HAT0Y = 17; const BTN_TL = 0x136; const NEW_CODES = [0x13a, 0x13b, 0x13c, 0x14a, 248, 0x220, 0x221, 0x222, 0x223]; @@ -83,6 +85,22 @@ describe('EvdevInputReader', () => { expect(state.ly).toBe(128); }); + it('normalizes the DualSense hat axes into held D-pad buttons', async () => { + const stream = new PassThrough(); + const reader = new EvdevInputReader({ devicePath: '/fake', openStream: () => stream }); + reader.start(); + const pending = collect(reader, 3); + stream.write(Buffer.concat([ + event(EV_ABS, ABS_HAT0Y, -1), event(EV_SYN, 0, 0), + event(EV_ABS, ABS_HAT0Y, 0), event(EV_ABS, ABS_HAT0X, 1), event(EV_SYN, 0, 0), + event(EV_ABS, ABS_HAT0X, 0), event(EV_SYN, 0, 0) + ])); + const [up, right, released] = await pending; + expect(up.buttons).toEqual(new Set(['dpad-up'])); + expect(right.buttons).toEqual(new Set(['dpad-right'])); + expect(released.buttons).toEqual(new Set()); + }); + it('handles packets split across chunk boundaries', async () => { const stream = new PassThrough(); const reader = new EvdevInputReader({ devicePath: '/fake', openStream: () => stream }); diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.ts b/ds5-bridge/companion/src/main/evdev-input-reader.ts index f55ab7d..7494db4 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.ts @@ -107,7 +107,9 @@ export class EvdevInputReader extends EventEmitter { private r2 = 0; private lx = 128; private ly = 128; - private buttons = new Set(); + private dpadX = 0; + private dpadY = 0; + private buttons = new Set(); constructor(options: ReaderOptions = {}) { super(); @@ -143,6 +145,8 @@ export class EvdevInputReader extends EventEmitter { this.buttons.clear(); this.l2 = 0; this.r2 = 0; + this.dpadX = 0; + this.dpadY = 0; } private consume(chunk: Buffer): void { @@ -154,23 +158,24 @@ export class EvdevInputReader extends EventEmitter { } } - // The d-pad is a hat axis, not key events; -1/0/1 per axis becomes a pair of - // synthetic button names so the switch/modifier layers see one vocabulary. - private setHatButtons(negative: string, positive: string, value: number): void { - this.buttons.delete(negative); - this.buttons.delete(positive); - if (value < 0) this.buttons.add(negative); - if (value > 0) this.buttons.add(positive); - } - private handleEvent(type: number, code: number, value: number): void { if (type === EV_ABS) { if (code === ABS_X) this.lx = value; if (code === ABS_Y) this.ly = value; if (code === ABS_Z) this.l2 = value; if (code === ABS_RZ) this.r2 = value; - if (code === ABS_HAT0X) this.setHatButtons('dpad-left', 'dpad-right', value); - if (code === ABS_HAT0Y) this.setHatButtons('dpad-up', 'dpad-down', value); + if (code === ABS_HAT0X || code === ABS_HAT0Y) { + if (code === ABS_HAT0X) this.dpadX = Math.max(-1, Math.min(1, value)); + else this.dpadY = Math.max(-1, Math.min(1, value)); + this.buttons.delete('dpad-left'); + this.buttons.delete('dpad-right'); + this.buttons.delete('dpad-up'); + this.buttons.delete('dpad-down'); + if (this.dpadX < 0) this.buttons.add('dpad-left'); + if (this.dpadX > 0) this.buttons.add('dpad-right'); + if (this.dpadY < 0) this.buttons.add('dpad-up'); + if (this.dpadY > 0) this.buttons.add('dpad-down'); + } return; } if (type === EV_KEY) { From 7ccb081227fd4d733a5caa6403e6b75dd8911ca1 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 17:59:09 +0400 Subject: [PATCH 09/15] feat: read DualSense touchpad button node --- .../src/main/evdev-input-reader.test.ts | 24 ++++++- .../companion/src/main/evdev-input-reader.ts | 71 ++++++++++++------- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts index 1c48933..014e256 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { EvdevInputReader, findDualSenseEventNode } from './evdev-input-reader'; +import { EvdevInputReader, findDualSenseEventNode, findDualSenseEventNodes } from './evdev-input-reader'; import type { ControllerInputState } from '../shared/trigger-modifier-eval'; function event(type: number, code: number, value: number): Buffer { @@ -101,6 +101,21 @@ describe('EvdevInputReader', () => { expect(released.buttons).toEqual(new Set()); }); + it('merges touchpad-click events from the DualSense auxiliary node', async () => { + const gamepad = new PassThrough(); + const touchpad = new PassThrough(); + const reader = new EvdevInputReader({ + findNodes: () => ['/dev/input/event-gamepad', '/dev/input/event-touchpad'], + openStream: (path) => path.endsWith('touchpad') ? touchpad : gamepad + }); + reader.start(); + const pending = collect(reader, 1); + touchpad.write(Buffer.concat([event(EV_KEY, 272, 1), event(EV_SYN, 0, 0)])); + const [state] = await pending; + expect(state.buttons).toEqual(new Set(['touchpad'])); + reader.stop(); + }); + it('handles packets split across chunk boundaries', async () => { const stream = new PassThrough(); const reader = new EvdevInputReader({ devicePath: '/fake', openStream: () => stream }); @@ -223,6 +238,13 @@ describe('findDualSenseEventNode', () => { expect(findDualSenseEventNode(sysDir)).toBe('/dev/input/event29'); }); + it('returns the gamepad and touchpad nodes as one DualSense input group', () => { + sysDir = mkdtempSync(path.join(tmpdir(), 'sys-input-')); + addNode('event29', 'Sony Interactive Entertainment DualSense Wireless Controller', '3003f', '7fdb000000000000 0 0 0 0'); + addNode('event31', 'Sony Interactive Entertainment DualSense Wireless Controller Touchpad', '2608000 3', '2420 10000 0 0 0 0'); + expect(findDualSenseEventNodes(sysDir)).toEqual(['/dev/input/event29', '/dev/input/event31']); + }); + it('returns null when no node has both trigger axes and buttons', () => { sysDir = mkdtempSync(path.join(tmpdir(), 'sys-input-')); addNode('event256', 'Sony Interactive Entertainment DualSense Wireless Controller Headset Jack', '0', '0'); diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.ts b/ds5-bridge/companion/src/main/evdev-input-reader.ts index 7494db4..19ffcb1 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.ts @@ -29,6 +29,7 @@ const BUTTON_NAMES: Record = { 0x13d: 'l3', 0x13e: 'r3', 0x14a: 'touchpad', + 272: 'touchpad', 248: 'mute', 0x220: 'dpad-up', 0x221: 'dpad-down', @@ -62,18 +63,24 @@ function hasAnyCapabilityBit(raw: string): boolean { }); } -export function findDualSenseEventNode(sysInputDir = '/sys/class/input'): string | null { +export function findDualSenseEventNodes(sysInputDir = '/sys/class/input'): string[] { let entries: string[]; try { entries = readdirSync(sysInputDir); } catch { - return null; + return []; } + let gamepad: string | null = null; + let touchpad: string | null = null; for (const entry of entries) { if (!entry.startsWith('event')) continue; try { const name = readFileSync(`${sysInputDir}/${entry}/device/name`, 'utf8').trim(); if (!name.toLowerCase().includes('dualsense')) continue; + if (name.toLowerCase().includes('touchpad')) { + touchpad ??= `/dev/input/${entry}`; + continue; + } // The DualSense exposes several nodes that all match by name (gamepad, // touchpad, motion sensors, headset jack). Only the gamepad has both // trigger axes and button capabilities. @@ -83,26 +90,31 @@ export function findDualSenseEventNode(sysInputDir = '/sys/class/input'): string if ((abs & TRIGGER_ABS_MASK) !== TRIGGER_ABS_MASK) continue; const key = readFileSync(`${sysInputDir}/${entry}/device/capabilities/key`, 'utf8'); if (!hasAnyCapabilityBit(key)) continue; - return `/dev/input/${entry}`; + gamepad ??= `/dev/input/${entry}`; } catch { // ignore unreadable nodes } } - return null; + return gamepad ? [gamepad, ...(touchpad ? [touchpad] : [])] : []; +} + +export function findDualSenseEventNode(sysInputDir = '/sys/class/input'): string | null { + return findDualSenseEventNodes(sysInputDir)[0] ?? null; } type ReaderOptions = { devicePath?: string; openStream?: (path: string) => NodeJS.ReadableStream; findNode?: () => string | null; + findNodes?: () => string[]; }; export class EvdevInputReader extends EventEmitter { private readonly explicitDevicePath: string | null; private readonly openStream: (path: string) => NodeJS.ReadableStream; private readonly findNode: () => string | null; - private stream: NodeJS.ReadableStream | null = null; - private pending: Buffer = Buffer.alloc(0); + private readonly findNodes: (() => string[]) | null; + private streams: Array<{ stream: NodeJS.ReadableStream; pending: Buffer }> = []; private l2 = 0; private r2 = 0; private lx = 128; @@ -116,32 +128,37 @@ export class EvdevInputReader extends EventEmitter { this.explicitDevicePath = options.devicePath ?? null; this.openStream = options.openStream ?? ((path) => createReadStream(path)); this.findNode = options.findNode ?? findDualSenseEventNode; + this.findNodes = options.findNodes ?? (options.findNode ? null : findDualSenseEventNodes); } start(): void { - if (this.stream) return; - const devicePath = this.explicitDevicePath ?? this.findNode(); - if (!devicePath) { + if (this.streams.length > 0) return; + const devicePaths = this.explicitDevicePath + ? [this.explicitDevicePath] + : this.findNodes?.() ?? (() => { const node = this.findNode(); return node ? [node] : []; })(); + if (devicePaths.length === 0) { this.emit('error', new Error('No DualSense evdev node found.')); return; } - const stream = this.openStream(devicePath); - this.stream = stream; - stream.on('data', (chunk: Buffer) => this.consume(chunk)); - stream.on('error', (error: Error) => { - if (this.stream === stream) { - this.stream = null; - } - this.emit('error', error); - }); + for (const devicePath of devicePaths) { + const stream = this.openStream(devicePath); + const source = { stream, pending: Buffer.alloc(0) }; + this.streams.push(source); + stream.on('data', (chunk: Buffer) => this.consume(source, chunk)); + stream.on('error', (error: Error) => { + this.streams = this.streams.filter((current) => current !== source); + if (this.streams.length === 0) this.emit('error', error); + }); + } } stop(): void { - if (this.stream && 'destroy' in this.stream) { - (this.stream as NodeJS.ReadableStream & { destroy(): void }).destroy(); + for (const { stream } of this.streams) { + if ('destroy' in stream) { + (stream as NodeJS.ReadableStream & { destroy(): void }).destroy(); + } } - this.stream = null; - this.pending = Buffer.alloc(0); + this.streams = []; this.buttons.clear(); this.l2 = 0; this.r2 = 0; @@ -149,11 +166,11 @@ export class EvdevInputReader extends EventEmitter { this.dpadY = 0; } - private consume(chunk: Buffer): void { - this.pending = this.pending.length === 0 ? chunk : (Buffer.concat([this.pending, chunk]) as Buffer); - while (this.pending.length >= EVENT_SIZE) { - const record = this.pending.subarray(0, EVENT_SIZE); - this.pending = this.pending.subarray(EVENT_SIZE); + private consume(source: { pending: Buffer }, chunk: Buffer): void { + source.pending = source.pending.length === 0 ? chunk : (Buffer.concat([source.pending, chunk]) as Buffer); + while (source.pending.length >= EVENT_SIZE) { + const record = source.pending.subarray(0, EVENT_SIZE); + source.pending = source.pending.subarray(EVENT_SIZE); this.handleEvent(record.readUInt16LE(16), record.readUInt16LE(18), record.readInt32LE(20)); } } From 35f86475297b831efd6e6c567a458b68c3bf28af Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 18:47:32 +0400 Subject: [PATCH 10/15] feat: complete gaming shortcuts integration --- .../gaming-shortcuts/action-executor.test.ts | 32 +++++++++ .../main/gaming-shortcuts/action-executor.ts | 6 ++ .../src/main/gaming-shortcuts/coordinator.ts | 14 ++-- .../providers/detect-environment.test.ts | 7 +- .../providers/detect-environment.ts | 13 +--- .../gaming-shortcuts/providers/recording.ts | 66 +++++++++++++++++++ .../providers/screenshot.test.ts | 12 ++++ .../gaming-shortcuts/providers/screenshot.ts | 9 ++- ds5-bridge/companion/src/main/main.ts | 3 +- .../src/main/trigger-profile-engine.ts | 7 ++ ds5-bridge/companion/src/renderer/App.tsx | 6 +- .../src/renderer/GamingShortcuts.tsx | 42 ++++++++++-- ds5-bridge/companion/src/renderer/styles.css | 11 ++++ .../shared/gaming-shortcuts-settings.test.ts | 23 ++++++- .../companion/src/shared/gaming-shortcuts.ts | 54 +++++++++++++-- 15 files changed, 268 insertions(+), 37 deletions(-) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/providers/recording.ts diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts index b963945..b6ce7fb 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ActionExecutor } from './action-executor'; import { LinuxActionProvider } from './providers/linux-actions'; import { ScreenshotProvider } from './providers/screenshot'; +import { GpuScreenRecorderProvider } from './providers/recording'; describe('ActionExecutor', () => { it('runs explicit executable arguments', async () => { @@ -54,6 +55,20 @@ describe('ActionExecutor', () => { expect(run).toHaveBeenCalledWith('grim', ['/tmp/OpenDS5-2026-07-16T12-34-56-789Z.png']); }); + it('executes an explicit hyprshot screenshot provider', async () => { + const run = vi.fn().mockResolvedValue({ code: 0, signal: null, timedOut: false }); + const executor = new ActionExecutor({ + runner: { run }, + screenshotProvider: new ScreenshotProvider({ + env: { HOME: '/home/test', XDG_PICTURES_DIR: '/tmp' }, + now: () => new Date('2026-07-16T12:34:56.789Z'), + hasExecutable: (name) => name === 'hyprshot' + }) + }); + await expect(executor.execute({ type: 'screenshot', provider: 'hyprshot' })).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledWith('hyprshot', ['-m', 'window', '-m', 'active', '-o', '/tmp', '-f', 'OpenDS5-2026-07-16T12-34-56-789Z.png']); + }); + it('does not invoke a runner for unavailable provider actions', async () => { const run = vi.fn(); const executor = new ActionExecutor({ runner: { run }, linuxProvider: new LinuxActionProvider({ hasExecutable: () => false }) }); @@ -70,4 +85,21 @@ describe('ActionExecutor', () => { it('reports OpenDS5 unavailable when no callback is configured', async () => { await expect(new ActionExecutor().execute({ type: 'open-opends5' })).resolves.toEqual({ ok: false, reason: 'unavailable' }); }); + + it('starts and stops only its owned GPU Screen Recorder process', async () => { + const kill = vi.fn(); + const child = { kill, once: vi.fn((event: string, listener: () => void) => { if (event === 'exit') void listener; return child; }) } as never; + const spawn = vi.fn(() => child); + const recordingProvider = new GpuScreenRecorderProvider({ + env: { HOME: '/tmp/opends5-gaming-shortcuts-test' }, + now: () => new Date('2026-07-16T12:34:56.789Z'), + hasExecutable: (name) => name === 'gpu-screen-recorder', + spawn + }); + const executor = new ActionExecutor({ recordingProvider }); + await expect(executor.execute({ type: 'recording-toggle', provider: 'auto' })).resolves.toEqual({ ok: true }); + expect(spawn).toHaveBeenCalledWith('gpu-screen-recorder', ['-w', 'portal', '-f', '60', '-o', '/tmp/opends5-gaming-shortcuts-test/Videos/OpenDS5-2026-07-16T12-34-56-789Z.mp4']); + await expect(executor.execute({ type: 'recording-toggle', provider: 'gpu-screen-recorder' })).resolves.toEqual({ ok: true }); + expect(kill).toHaveBeenCalledWith('SIGINT'); + }); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts index aff8c35..6ff718f 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts @@ -2,6 +2,7 @@ import { validateGamingShortcutAction, type GamingShortcutAction } from '../../s import { createProcessRunner, type ProcessRunner } from './process-runner'; import { LinuxActionProvider } from './providers/linux-actions'; import { ScreenshotProvider } from './providers/screenshot'; +import { GpuScreenRecorderProvider } from './providers/recording'; export type ActionExecutionResult = | { ok: true } @@ -12,6 +13,7 @@ export interface ActionExecutorOptions { openOpenDS5?: () => Promise | void; linuxProvider?: LinuxActionProvider; screenshotProvider?: ScreenshotProvider; + recordingProvider?: GpuScreenRecorderProvider; } /** Executes only validated actions; desktop-specific actions are provider work. */ @@ -20,12 +22,14 @@ export class ActionExecutor { private readonly openOpenDS5: (() => Promise | void) | null; private readonly linuxProvider: LinuxActionProvider; private readonly screenshotProvider: ScreenshotProvider; + private readonly recordingProvider: GpuScreenRecorderProvider; constructor(options: ActionExecutorOptions = {}) { this.runner = options.runner ?? createProcessRunner(); this.openOpenDS5 = options.openOpenDS5 ?? null; this.linuxProvider = options.linuxProvider ?? new LinuxActionProvider(); this.screenshotProvider = options.screenshotProvider ?? new ScreenshotProvider(); + this.recordingProvider = options.recordingProvider ?? new GpuScreenRecorderProvider(); } async execute(rawAction: unknown): Promise { @@ -64,6 +68,8 @@ export class ActionExecutor { ? { ok: true } : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with ${result.code ?? 'unknown'}` }; } + case 'recording-toggle': + return this.recordingProvider.toggle(action.provider); default: return { ok: false, reason: 'unavailable' }; } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts index 62e9914..16c3d41 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import type { ControllerInputState } from '../../shared/trigger-modifier-eval'; -import type { GamingShortcutAction, GamingShortcutsSettings } from '../../shared/gaming-shortcuts'; +import { resolveGamingShortcutBindings, type GamingShortcutAction, type GamingShortcutsSettings } from '../../shared/gaming-shortcuts'; import type { ControllerButton } from '../../shared/controller-input'; import type { ActionExecutionResult } from './action-executor'; import { ActionExecutor } from './action-executor'; @@ -26,6 +26,7 @@ export class GamingShortcutsCoordinator extends EventEmitter { private readonly input: InputSource; private readonly settingsStore: ShortcutSettingsSource; private readonly executor: ActionExecutor; + private readonly activeGameId: () => string | null; private readonly onInputBound: (state: ControllerInputState) => void; private gestureEngine: GestureEngine; private settings: GamingShortcutsSettings; @@ -36,11 +37,13 @@ export class GamingShortcutsCoordinator extends EventEmitter { input: InputSource; settingsStore: ShortcutSettingsSource; executor?: ActionExecutor; + activeGameId?: () => string | null; }) { super(); this.input = options.input; this.settingsStore = options.settingsStore; this.executor = options.executor ?? new ActionExecutor(); + this.activeGameId = options.activeGameId ?? (() => null); this.settings = this.settingsStore.get().gamingShortcuts; this.gestureEngine = this.createGestureEngine(this.settings); this.onInputBound = (state) => this.gestureEngine.update(state.buttons, state.timestampMs); @@ -92,12 +95,13 @@ export class GamingShortcutsCoordinator extends EventEmitter { } private actionFor(gesture: ControllerGesture): GamingShortcutAction | null { + const bindings = resolveGamingShortcutBindings(this.settings, this.activeGameId()); switch (gesture.type) { - case 'single-press': return gesture.button === 'ps' ? this.settings.singlePress : null; - case 'double-press': return gesture.button === 'ps' ? this.settings.doublePress : null; - case 'long-press': return gesture.button === 'ps' ? this.settings.longPress : null; + case 'single-press': return gesture.button === 'ps' ? bindings.singlePress : null; + case 'double-press': return gesture.button === 'ps' ? bindings.doublePress : null; + case 'long-press': return gesture.button === 'ps' ? bindings.longPress : null; case 'chord': { - const binding = this.settings.chords.find((candidate) => candidate.button === gesture.button); + const binding = bindings.chords.find((candidate) => candidate.button === gesture.button); return binding?.action ?? null; } } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts index c6944ea..1e4c7df 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts @@ -18,16 +18,15 @@ describe('detectDesktopEnvironment', () => { describe('detectProviderCapabilities', () => { it('reports only commands available through the injected probe', () => { - const available = new Set(['grim', 'wf-recorder', 'mangohud', 'wvkbd']); + const available = new Set(['grim', 'gpu-screen-recorder', 'mangohud']); expect(detectProviderCapabilities({ env: { HYPRLAND_INSTANCE_SIGNATURE: '1', WAYLAND_DISPLAY: 'wayland-0' }, hasExecutable: (name) => available.has(name) })).toEqual({ environment: 'hyprland', screenshot: ['grim'], - recording: ['wf-recorder'], - hud: ['mangohud'], - keyboard: ['wvkbd'] + recording: ['gpu-screen-recorder'], + hud: ['mangohud'] }); }); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts index 37ec9d4..62fa8bf 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts @@ -18,7 +18,6 @@ export interface ProviderCapabilities { screenshot: string[]; recording: string[]; hud: string[]; - keyboard: string[]; } function defaultHasExecutable(executable: string): boolean { @@ -62,18 +61,10 @@ export function detectProviderCapabilities(options: EnvironmentProbe = {}): Prov if (hasExecutable('scrot')) screenshot.push('scrot'); const recording: string[] = []; - if (hasExecutable('wf-recorder')) recording.push('wf-recorder'); - if (hasExecutable('obs')) recording.push('obs'); + if (hasExecutable('gpu-screen-recorder')) recording.push('gpu-screen-recorder'); const hud: string[] = []; - if (environment === 'gamescope') hud.push('gamescope'); if (hasExecutable('mangohud')) hud.push('mangohud'); - - const keyboard: string[] = []; - for (const executable of ['wvkbd', 'onboard', 'matchbox-keyboard']) { - if (hasExecutable(executable)) keyboard.push(executable); - } - - return { environment, screenshot, recording, hud, keyboard }; + return { environment, screenshot, recording, hud }; } import { execFileSync } from 'node:child_process'; diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/recording.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/recording.ts new file mode 100644 index 0000000..bec5743 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/recording.ts @@ -0,0 +1,66 @@ +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { RecordingProvider } from '../../../shared/gaming-shortcuts'; + +export interface RecordingProviderOptions { + env?: NodeJS.ProcessEnv; + now?: () => Date; + hasExecutable?: (executable: string) => boolean; + spawn?: (executable: string, args: string[]) => ChildProcess; +} + +function defaultHasExecutable(executable: string): boolean { + try { + execFileSync('which', [executable], { stdio: 'ignore', timeout: 750 }); + return true; + } catch { + return false; + } +} + +function videosDirectory(env: NodeJS.ProcessEnv): string { + return path.join(env.HOME || os.homedir(), 'Videos'); +} + +function filename(now: Date): string { + return `OpenDS5-${now.toISOString().replace(/[:.]/g, '-')}.mp4`; +} + +/** Owns the recorder process so stopping a shortcut cannot affect another recorder. */ +export class GpuScreenRecorderProvider { + private readonly env: NodeJS.ProcessEnv; + private readonly now: () => Date; + private readonly hasExecutable: (executable: string) => boolean; + private readonly spawnProcess: (executable: string, args: string[]) => ChildProcess; + private process: ChildProcess | null = null; + + constructor(options: RecordingProviderOptions = {}) { + this.env = options.env ?? process.env; + this.now = options.now ?? (() => new Date()); + this.hasExecutable = options.hasExecutable ?? defaultHasExecutable; + this.spawnProcess = options.spawn ?? ((executable, args) => spawn(executable, args, { shell: false, stdio: 'ignore' })); + } + + async toggle(provider: RecordingProvider): Promise<{ ok: true } | { ok: false; reason: 'unavailable' | 'failed'; error?: string }> { + if (provider !== 'auto' && provider !== 'gpu-screen-recorder') return { ok: false, reason: 'unavailable' }; + if (this.process) { + this.process.kill('SIGINT'); + this.process = null; + return { ok: true }; + } + if (!this.hasExecutable('gpu-screen-recorder')) return { ok: false, reason: 'unavailable' }; + const outputDirectory = videosDirectory(this.env); + fs.mkdirSync(outputDirectory, { recursive: true }); + const child = this.spawnProcess('gpu-screen-recorder', ['-w', 'portal', '-f', '60', '-o', path.join(outputDirectory, filename(this.now()))]); + this.process = child; + child.once('exit', () => { + if (this.process === child) this.process = null; + }); + child.once('error', () => { + if (this.process === child) this.process = null; + }); + return { ok: true }; + } +} diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts index 6192793..00a945d 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts @@ -16,6 +16,18 @@ describe('ScreenshotProvider', () => { }); }); + it('prefers active-window hyprshot capture on Hyprland', () => { + const provider = new ScreenshotProvider({ + env: { HOME: '/home/test', HYPRLAND_INSTANCE_SIGNATURE: '1', WAYLAND_DISPLAY: 'wayland-1' }, now, + hasExecutable: (name) => name === 'hyprshot' + }); + expect(provider.resolve('auto')).toEqual({ + executable: 'hyprshot', + args: ['-m', 'window', '-m', 'active', '-o', '/home/test/Pictures', '-f', 'OpenDS5-2026-07-16T12-34-56-789Z.png'], + outputPath: '/home/test/Pictures/OpenDS5-2026-07-16T12-34-56-789Z.png' + }); + }); + it('uses the explicit desktop provider and XDG pictures directory', () => { const provider = new ScreenshotProvider({ env: { HOME: '/home/test', XDG_PICTURES_DIR: '/mnt/captures' }, now, diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts index 3261112..2b0fda1 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts @@ -52,6 +52,11 @@ export class ScreenshotProvider { const selected = provider === 'auto' ? this.autoProvider() : provider; const outputPath = path.join(picturesDirectory(this.env), filename(this.now())); switch (selected) { + case 'hyprshot': return { + executable: 'hyprshot', + args: ['-m', 'window', '-m', 'active', '-o', path.dirname(outputPath), '-f', path.basename(outputPath)], + outputPath + }; case 'grim': return { executable: 'grim', args: [outputPath], outputPath }; case 'gnome-screenshot': return { executable: 'gnome-screenshot', args: ['-f', outputPath], outputPath }; case 'spectacle': return { executable: 'spectacle', args: ['-b', '-n', '-o', outputPath], outputPath }; @@ -66,7 +71,9 @@ export class ScreenshotProvider { private autoProvider(): CaptureProvider | null { const wayland = Boolean(this.env.WAYLAND_DISPLAY || this.env.HYPRLAND_INSTANCE_SIGNATURE || this.env.SWAYSOCK); - const candidates = wayland + const candidates = this.env.HYPRLAND_INSTANCE_SIGNATURE + ? ['hyprshot', 'grim', 'gnome-screenshot', 'spectacle', 'scrot'] + : wayland ? ['grim', 'gnome-screenshot', 'spectacle', 'scrot'] : ['gnome-screenshot', 'spectacle', 'scrot', 'grim']; return candidates.find((candidate) => this.hasExecutable(candidate)) as CaptureProvider | undefined ?? null; diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index cb07db9..562dd89 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -1703,7 +1703,8 @@ app.whenReady().then(async () => { }); gamingShortcutsCoordinator = new GamingShortcutsCoordinator({ input: shortcutReader, - settingsStore + settingsStore, + activeGameId: () => triggerProfileEngine?.getActiveGameId() ?? null }); gamingShortcutsCoordinator.on('error', (error) => { console.error('[gaming-shortcuts] action error', error); diff --git a/ds5-bridge/companion/src/main/trigger-profile-engine.ts b/ds5-bridge/companion/src/main/trigger-profile-engine.ts index 55a24fd..d562f33 100644 --- a/ds5-bridge/companion/src/main/trigger-profile-engine.ts +++ b/ds5-bridge/companion/src/main/trigger-profile-engine.ts @@ -3,6 +3,7 @@ import type { AdaptiveTriggerEffectV2Targeted, AdaptiveTriggerPreviewEffect } fr import { ModifierEvaluator, type ControllerInputState } from '../shared/trigger-modifier-eval'; import { effectSpecEquals, + DEFAULT_PROFILE_ID, type EngineStatus, type TriggerEffectSpec, type TriggerProfile, @@ -131,6 +132,12 @@ export class TriggerProfileEngine extends EventEmitter { this.emitStatus(); } + /** Returns the watcher identity for consumers such as per-game shortcuts. */ + getActiveGameId(): string | null { + const profileId = this.watcher.getActive().profileId; + return profileId === DEFAULT_PROFILE_ID ? null : profileId; + } + private scheduleReaderRetry(): void { if (!this.enabled || this.readerRetryTimer) return; this.readerRetryTimer = setTimeout(() => { diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 3535118..0bb59c8 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -10424,7 +10424,11 @@ export function App() {
- +
; label: string }> = [ { value: 'create', label: 'Create' }, { value: 'options', label: 'Options' }, { value: 'touchpad', label: 'Touchpad' }, { value: 'mute', label: 'Mute' }, @@ -20,7 +21,7 @@ const ACTIONS: Array<{ value: ActionKind; label: string }> = [ { value: 'launch-app', label: 'Open or focus an application' }, { value: 'focus-app', label: 'Focus application by ID' }, { value: 'screenshot', label: 'Take screenshot' }, { value: 'recording-toggle', label: 'Toggle recording' }, { value: 'performance-hud-toggle', label: 'Toggle performance HUD' }, { value: 'volume', label: 'Volume' }, - { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'on-screen-keyboard', label: 'Open on-screen keyboard' }, + { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'switch-application', label: 'Switch application' }, { value: 'quit-active-game', label: 'Quit active game (confirm)' }, { value: 'custom-executable', label: 'Custom executable' } ]; @@ -33,7 +34,6 @@ function actionForKind(kind: ActionKind): GamingShortcutAction { case 'screenshot': return { type: 'screenshot', provider: 'auto' }; case 'recording-toggle': return { type: 'recording-toggle', provider: 'auto' }; case 'performance-hud-toggle': return { type: 'performance-hud-toggle', provider: 'auto' }; - case 'on-screen-keyboard': return { type: 'on-screen-keyboard', provider: 'auto' }; case 'switch-application': return { type: 'switch-application', direction: 'next' }; case 'quit-active-game': return { type: 'quit-active-game', confirmation: true }; case 'custom-executable': return { type: 'custom-executable', executable: '', args: [] }; @@ -46,7 +46,7 @@ function actionLabel(action: GamingShortcutAction): string { } function isProviderAction(action: GamingShortcutAction): action is Extract { - return action.type === 'screenshot' || action.type === 'recording-toggle' || action.type === 'performance-hud-toggle' || action.type === 'on-screen-keyboard'; + return action.type === 'screenshot' || action.type === 'recording-toggle' || action.type === 'performance-hud-toggle'; } function ActionEditor({ action, onChange, capabilities }: { @@ -56,8 +56,7 @@ function ActionEditor({ action, onChange, capabilities }: { }) { const providerOptions = action.type === 'screenshot' ? capabilities?.screenshot ?? [] : action.type === 'recording-toggle' ? capabilities?.recording ?? [] - : action.type === 'performance-hud-toggle' ? capabilities?.hud ?? [] - : capabilities?.keyboard ?? []; + : capabilities?.hud ?? []; return
{ const chords = [...settings.chords]; chords[index] = { ...chord, button: event.target.value as Exclude }; update({ ...settings, chords }); }}>{BUTTONS.map(({ value, label }) => )} { const chords = [...settings.chords]; chords[index] = { ...chord, action }; update({ ...settings, chords }); }} capabilities={capabilities} />
)}
Timing
-
Environment: {capabilities?.environment ?? 'Detecting…'}Screenshot: {capabilities?.screenshot.join(', ') || 'Unavailable'}Recording: {capabilities?.recording.join(', ') || 'Unavailable'}HUD: {capabilities?.hud.join(', ') || 'Unavailable'}Keyboard: {capabilities?.keyboard.join(', ') || 'Unavailable'}
+
Per-game overrides{selectedOverride && }
+ {gameProfiles.length === 0 ?

Create a Game Profile to customize shortcuts for a specific game.

: <> + + {selectedGameId && <> +

Unset actions inherit the global Gaming Shortcuts binding.

+ {([['singlePress', 'PS button · Press'], ['doublePress', 'PS button · Double press'], ['longPress', 'PS button · Hold']] as const).map(([key, label]) => )} +
PS chords
+ {(selectedOverride?.chords ?? []).map((chord, index) =>
{ const chords = [...(selectedOverride?.chords ?? [])]; chords[index] = { ...chord, action }; setGameOverride({ ...selectedOverride, chords }); }} capabilities={capabilities} />
)} + } + } +
+
Environment: {capabilities?.environment ?? 'Detecting…'}Screenshot: {capabilities?.screenshot.join(', ') || 'Unavailable'}Recording: {capabilities?.recording.join(', ') || 'Unavailable'}HUD: {capabilities?.hud.join(', ') || 'Unavailable'}
; } diff --git a/ds5-bridge/companion/src/renderer/styles.css b/ds5-bridge/companion/src/renderer/styles.css index a89836a..1eca49b 100644 --- a/ds5-bridge/companion/src/renderer/styles.css +++ b/ds5-bridge/companion/src/renderer/styles.css @@ -2530,9 +2530,20 @@ button { } .gaming-shortcuts-card > .feature-card-title { + display: flex; + align-items: center; + gap: 12px; justify-content: space-between; } +.gaming-shortcuts-card .feature-card-title .secondary-action { + width: auto; + flex: 0 0 auto; + min-height: 32px; + padding: 0 12px; + font-size: 12px; +} + .gaming-timing-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts index 741af7a..df4bbeb 100644 --- a/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { DEFAULT_GAMING_SHORTCUTS_SETTINGS, normalizeGamingShortcutsSettings } from './gaming-shortcuts'; +import { DEFAULT_GAMING_SHORTCUTS_SETTINGS, normalizeGamingShortcutsSettings, resolveGamingShortcutBindings } from './gaming-shortcuts'; describe('normalizeGamingShortcutsSettings', () => { it('provides disabled, harmless defaults', () => { @@ -19,4 +19,25 @@ describe('normalizeGamingShortcutsSettings', () => { ] })).toMatchObject({ enabled: true, doublePressWindowMs: 100, longPressThresholdMs: 2000, chordWindowMs: 151, chords: [{ button: 'create', action: { type: 'passthrough' } }] }); }); + + it('normalizes and resolves a per-game binding without changing global timing', () => { + const settings = normalizeGamingShortcutsSettings({ + singlePress: { type: 'open-opends5' }, + perGameOverrides: { + 'steam:123': { + singlePress: { type: 'volume', direction: 'mute' }, + chords: [{ button: 'create', action: { type: 'screenshot', provider: 'grim' } }] + }, + broken: { singlePress: { type: 'custom-executable', executable: 'bad\0name', args: [] } } + } + }); + expect(settings.perGameOverrides.broken).toEqual({ singlePress: { type: 'none' } }); + expect(resolveGamingShortcutBindings(settings, 'steam:123')).toEqual({ + singlePress: { type: 'volume', direction: 'mute' }, + doublePress: { type: 'none' }, + longPress: { type: 'none' }, + chords: [{ button: 'create', action: { type: 'screenshot', provider: 'grim' } }] + }); + expect(resolveGamingShortcutBindings(settings, null).singlePress).toEqual({ type: 'open-opends5' }); + }); }); diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts index 5675822..6d1844f 100644 --- a/ds5-bridge/companion/src/shared/gaming-shortcuts.ts +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts @@ -1,6 +1,7 @@ import type { ControllerButton } from './controller-input'; -export type CaptureProvider = 'auto' | 'portal' | 'grim' | 'gnome-screenshot' | 'spectacle' | 'scrot'; +export type CaptureProvider = 'auto' | 'portal' | 'grim' | 'hyprshot' | 'gnome-screenshot' | 'spectacle' | 'scrot'; +export type RecordingProvider = 'auto' | 'gpu-screen-recorder'; export type HudProvider = 'auto' | 'gamescope' | 'mangohud'; export type KeyboardProvider = 'auto' | 'portal' | 'wvkbd' | 'onboard' | 'matchbox-keyboard'; @@ -13,7 +14,7 @@ export type GamingShortcutAction = | { type: 'volume'; direction: 'up' | 'down' | 'mute' } | { type: 'microphone-mute-toggle' } | { type: 'screenshot'; provider: CaptureProvider } - | { type: 'recording-toggle'; provider: CaptureProvider } + | { type: 'recording-toggle'; provider: RecordingProvider } | { type: 'performance-hud-toggle'; provider: HudProvider } | { type: 'on-screen-keyboard'; provider: KeyboardProvider } | { type: 'switch-application'; direction: 'next' | 'previous' } @@ -27,11 +28,14 @@ export interface GamingShortcutBindings { chords: Array<{ button: Exclude; action: GamingShortcutAction }>; } +export type GamingShortcutOverride = Partial; + export interface GamingShortcutsSettings extends GamingShortcutBindings { enabled: boolean; doublePressWindowMs: number; longPressThresholdMs: number; chordWindowMs: number; + perGameOverrides: Record; } export const DEFAULT_GAMING_SHORTCUTS_SETTINGS: GamingShortcutsSettings = { @@ -42,10 +46,12 @@ export const DEFAULT_GAMING_SHORTCUTS_SETTINGS: GamingShortcutsSettings = { singlePress: { type: 'none' }, doublePress: { type: 'none' }, longPress: { type: 'none' }, - chords: [] + chords: [], + perGameOverrides: {} }; -const CAPTURE_PROVIDERS = new Set(['auto', 'portal', 'grim', 'gnome-screenshot', 'spectacle', 'scrot']); +const CAPTURE_PROVIDERS = new Set(['auto', 'portal', 'grim', 'hyprshot', 'gnome-screenshot', 'spectacle', 'scrot']); +const RECORDING_PROVIDERS = new Set(['auto', 'gpu-screen-recorder']); const HUD_PROVIDERS = new Set(['auto', 'gamescope', 'mangohud']); const KEYBOARD_PROVIDERS = new Set(['auto', 'portal', 'wvkbd', 'onboard', 'matchbox-keyboard']); @@ -65,6 +71,21 @@ function member(set: ReadonlySet, value: unknown): value is return typeof value === 'string' && set.has(value as T); } +function normalizeBindings(value: unknown): GamingShortcutOverride { + if (!record(value)) return {}; + const result: GamingShortcutOverride = {}; + if ('singlePress' in value) result.singlePress = validateGamingShortcutAction(value.singlePress); + if ('doublePress' in value) result.doublePress = validateGamingShortcutAction(value.doublePress); + if ('longPress' in value) result.longPress = validateGamingShortcutAction(value.longPress); + if (Array.isArray(value.chords)) { + result.chords = value.chords.flatMap((entry) => { + if (!record(entry) || typeof entry.button !== 'string' || !SECONDARY_BUTTONS.has(entry.button as Exclude)) return []; + return [{ button: entry.button as Exclude, action: validateGamingShortcutAction(entry.action) }]; + }).slice(0, 32); + } + return result; +} + /** Repairs malformed persisted data to a harmless action. */ export function validateGamingShortcutAction(value: unknown): GamingShortcutAction { if (!record(value) || typeof value.type !== 'string') return { type: 'none' }; @@ -82,7 +103,7 @@ export function validateGamingShortcutAction(value: unknown): GamingShortcutActi case 'screenshot': return member(CAPTURE_PROVIDERS, value.provider) ? { type: 'screenshot', provider: value.provider } : { type: 'none' }; case 'recording-toggle': - return member(CAPTURE_PROVIDERS, value.provider) ? { type: 'recording-toggle', provider: value.provider } : { type: 'none' }; + return member(RECORDING_PROVIDERS, value.provider) ? { type: 'recording-toggle', provider: value.provider } : { type: 'none' }; case 'performance-hud-toggle': return member(HUD_PROVIDERS, value.provider) ? { type: 'performance-hud-toggle', provider: value.provider } : { type: 'none' }; case 'on-screen-keyboard': @@ -115,6 +136,12 @@ export function normalizeGamingShortcutsSettings(value: unknown): GamingShortcut return [{ button: entry.button as Exclude, action: validateGamingShortcutAction(entry.action) }]; }).slice(0, 32) : []; + const perGameOverrides: Record = {}; + if (record(value.perGameOverrides)) { + for (const [gameId, override] of Object.entries(value.perGameOverrides)) { + if (gameId.length > 0 && gameId.length <= 256) perGameOverrides[gameId] = normalizeBindings(override); + } + } return { enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_GAMING_SHORTCUTS_SETTINGS.enabled, doublePressWindowMs: timing(value.doublePressWindowMs, 300, 100, 1000), @@ -123,6 +150,21 @@ export function normalizeGamingShortcutsSettings(value: unknown): GamingShortcut singlePress: validateGamingShortcutAction(value.singlePress), doublePress: validateGamingShortcutAction(value.doublePress), longPress: validateGamingShortcutAction(value.longPress), - chords + chords, + perGameOverrides + }; +} + +/** Resolves bindings without changing global timing or enablement settings. */ +export function resolveGamingShortcutBindings( + settings: GamingShortcutsSettings, + gameId: string | null +): GamingShortcutBindings { + const override = gameId === null ? undefined : settings.perGameOverrides[gameId]; + return { + singlePress: override?.singlePress ?? settings.singlePress, + doublePress: override?.doublePress ?? settings.doublePress, + longPress: override?.longPress ?? settings.longPress, + chords: override?.chords ?? settings.chords }; } From 5e0c8ea84d4a88a661d666ce1d44c8301120904b Mon Sep 17 00:00:00 2001 From: retr0astic Date: Thu, 16 Jul 2026 21:32:20 +0400 Subject: [PATCH 11/15] Complete PS gaming shortcut actions --- .../companion/native/audio-helper-linux.mjs | 19 ++- .../companion/src/main/bridge-service.ts | 1 + .../gaming-shortcuts/action-executor.test.ts | 35 ++++ .../main/gaming-shortcuts/action-executor.ts | 14 +- .../main/gaming-shortcuts/coordinator.test.ts | 62 +++++++- .../src/main/gaming-shortcuts/coordinator.ts | 149 ++++++++++++++---- .../gaming-shortcuts/gesture-engine.test.ts | 17 ++ .../main/gaming-shortcuts/gesture-engine.ts | 5 +- .../gaming-shortcuts/notifications.test.ts | 27 ++++ .../main/gaming-shortcuts/notifications.ts | 63 ++++++++ .../providers/detect-environment.test.ts | 3 +- .../providers/detect-environment.ts | 5 +- .../providers/linux-actions.test.ts | 8 + .../providers/linux-actions.ts | 9 ++ .../gaming-shortcuts/providers/screenshot.ts | 4 + ds5-bridge/companion/src/main/main.ts | 35 +++- ds5-bridge/companion/src/preload.ts | 1 + ds5-bridge/companion/src/renderer/App.tsx | 8 +- .../src/renderer/GamingShortcuts.tsx | 43 +++-- ds5-bridge/companion/src/renderer/styles.css | 2 + .../shared/gaming-shortcuts-settings.test.ts | 10 ++ .../companion/src/shared/gaming-shortcuts.ts | 26 ++- nix/opends5.nix | 2 + 23 files changed, 488 insertions(+), 60 deletions(-) create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/notifications.test.ts create mode 100644 ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts diff --git a/ds5-bridge/companion/native/audio-helper-linux.mjs b/ds5-bridge/companion/native/audio-helper-linux.mjs index a925822..4342b83 100755 --- a/ds5-bridge/companion/native/audio-helper-linux.mjs +++ b/ds5-bridge/companion/native/audio-helper-linux.mjs @@ -54,11 +54,22 @@ function isAudioSink(object) { function isBridgeSink(object) { const props = nodeProps(object); - return isAudioSink(object) && ( - BRIDGE_NODE_PATTERN.test(props['node.name'] ?? '') - || BRIDGE_NODE_PATTERN.test(props['node.description'] ?? '') - || BRIDGE_NODE_PATTERN.test(props['device.product.name'] ?? '') + if (!isAudioSink(object)) { + return false; + } + const bridgeName = [ + props['node.name'], + props['node.description'], + props['node.nick'], + props['device.product.name'], + props['device.description'], + props['device.nick'] + ].some((value) => BRIDGE_NODE_PATTERN.test(value ?? '')); + const wirelessController = /wireless controller/i.test( + `${props['node.description'] ?? ''} ${props['node.nick'] ?? ''} ${props['device.description'] ?? ''} ${props['device.nick'] ?? ''}` ); + const fourChannel = Number(props['audio.channels']) === 4; + return bridgeName || (wirelessController && fourChannel); } async function findBridgeSink() { diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 981d120..8e0a70b 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -180,6 +180,7 @@ type HostPersonaDefaultRenderRestore = { export type BridgeToast = { title: string; body: string; + replaceGroup?: string; }; const PRESET_SETTINGS: Record, Partial> = { diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts index b6ce7fb..753cebc 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { describe, expect, it, vi } from 'vitest'; import { ActionExecutor } from './action-executor'; import { LinuxActionProvider } from './providers/linux-actions'; @@ -41,6 +42,13 @@ describe('ActionExecutor', () => { expect(run).toHaveBeenCalledWith('wpctl', ['set-volume', '-l', '1.5', '@DEFAULT_AUDIO_SINK@', '5%+']); }); + it('toggles MangoHud by sending F12 to the focused game', async () => { + const run = vi.fn().mockResolvedValue({ code: 0, signal: null, timedOut: false }); + const executor = new ActionExecutor({ runner: { run }, linuxProvider: new LinuxActionProvider({ hasExecutable: (name) => name === 'wtype' }) }); + await expect(executor.execute({ type: 'performance-hud-toggle', provider: 'mangohud' })).resolves.toEqual({ ok: true }); + expect(run).toHaveBeenCalledWith('wtype', ['-k', 'F12']); + }); + it('executes screenshots through the selected provider', async () => { const run = vi.fn().mockResolvedValue({ code: 0, signal: null, timedOut: false }); const executor = new ActionExecutor({ @@ -69,6 +77,27 @@ describe('ActionExecutor', () => { expect(run).toHaveBeenCalledWith('hyprshot', ['-m', 'window', '-m', 'active', '-o', '/tmp', '-f', 'OpenDS5-2026-07-16T12-34-56-789Z.png']); }); + it('accepts a screenshot when the image was saved despite a non-zero exit code', async () => { + const outputPath = '/tmp/OpenDS5-2026-07-16T12-34-56-789Z.png'; + const run = vi.fn().mockImplementation(async () => { + fs.writeFileSync(outputPath, 'screenshot'); + return { code: 1, signal: null, timedOut: false }; + }); + try { + const executor = new ActionExecutor({ + runner: { run }, + screenshotProvider: new ScreenshotProvider({ + env: { HOME: '/home/test', XDG_PICTURES_DIR: '/tmp' }, + now: () => new Date('2026-07-16T12:34:56.789Z'), + hasExecutable: (name) => name === 'grim' + }) + }); + await expect(executor.execute({ type: 'screenshot', provider: 'grim' })).resolves.toEqual({ ok: true }); + } finally { + fs.rmSync(outputPath, { force: true }); + } + }); + it('does not invoke a runner for unavailable provider actions', async () => { const run = vi.fn(); const executor = new ActionExecutor({ runner: { run }, linuxProvider: new LinuxActionProvider({ hasExecutable: () => false }) }); @@ -86,6 +115,12 @@ describe('ActionExecutor', () => { await expect(new ActionExecutor().execute({ type: 'open-opends5' })).resolves.toEqual({ ok: false, reason: 'unavailable' }); }); + it('routes confirmed quit requests through the guarded callback', async () => { + const quitActiveGame = vi.fn().mockResolvedValue({ ok: true }); + await expect(new ActionExecutor({ quitActiveGame }).execute({ type: 'quit-active-game', confirmation: true })).resolves.toEqual({ ok: true }); + expect(quitActiveGame).toHaveBeenCalledOnce(); + }); + it('starts and stops only its owned GPU Screen Recorder process', async () => { const kill = vi.fn(); const child = { kill, once: vi.fn((event: string, listener: () => void) => { if (event === 'exit') void listener; return child; }) } as never; diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts index 6ff718f..f1e020e 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts @@ -11,6 +11,7 @@ export type ActionExecutionResult = export interface ActionExecutorOptions { runner?: ProcessRunner; openOpenDS5?: () => Promise | void; + quitActiveGame?: () => Promise | ActionExecutionResult; linuxProvider?: LinuxActionProvider; screenshotProvider?: ScreenshotProvider; recordingProvider?: GpuScreenRecorderProvider; @@ -20,6 +21,7 @@ export interface ActionExecutorOptions { export class ActionExecutor { private readonly runner: ProcessRunner; private readonly openOpenDS5: (() => Promise | void) | null; + private readonly quitActiveGame: (() => Promise | ActionExecutionResult) | null; private readonly linuxProvider: LinuxActionProvider; private readonly screenshotProvider: ScreenshotProvider; private readonly recordingProvider: GpuScreenRecorderProvider; @@ -27,6 +29,7 @@ export class ActionExecutor { constructor(options: ActionExecutorOptions = {}) { this.runner = options.runner ?? createProcessRunner(); this.openOpenDS5 = options.openOpenDS5 ?? null; + this.quitActiveGame = options.quitActiveGame ?? null; this.linuxProvider = options.linuxProvider ?? new LinuxActionProvider(); this.screenshotProvider = options.screenshotProvider ?? new ScreenshotProvider(); this.recordingProvider = options.recordingProvider ?? new GpuScreenRecorderProvider(); @@ -51,7 +54,9 @@ export class ActionExecutor { : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with code ${result.code ?? 'unknown'}` }; } case 'volume': - case 'microphone-mute-toggle': { + case 'microphone-mute-toggle': + case 'on-screen-keyboard': + case 'performance-hud-toggle': { const command = this.linuxProvider.resolve(action); if (!command) return { ok: false, reason: 'unavailable' }; const result = await this.runner.run(command.executable, command.args); @@ -64,12 +69,17 @@ export class ActionExecutor { if (!command) return { ok: false, reason: 'unavailable' }; this.screenshotProvider.ensureOutputDirectory(command); const result = await this.runner.run(command.executable, command.args); - return result.code === 0 && result.signal === null && !result.timedOut + // Some desktop capture helpers save the image before returning a + // non-zero status (for example after a portal notification issue). + // The file is the reliable success signal for screenshot actions. + return (result.code === 0 && result.signal === null && !result.timedOut) || this.screenshotProvider.outputExists(command) ? { ok: true } : { ok: false, reason: 'failed', error: result.timedOut ? 'Process timed out' : `Process exited with ${result.code ?? 'unknown'}` }; } case 'recording-toggle': return this.recordingProvider.toggle(action.provider); + case 'quit-active-game': + return this.quitActiveGame ? await this.quitActiveGame() : { ok: false, reason: 'unavailable' }; default: return { ok: false, reason: 'unavailable' }; } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts index e63ae44..e501a8e 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts @@ -6,7 +6,7 @@ import { ActionExecutor } from './action-executor'; import { GamingShortcutsCoordinator } from './coordinator'; async function flushQueue(): Promise { - for (let index = 0; index < 4; index += 1) await Promise.resolve(); + for (let index = 0; index < 12; index += 1) await Promise.resolve(); } class FakeInput extends EventEmitter { @@ -20,7 +20,7 @@ describe('GamingShortcutsCoordinator', () => { it('resolves a global PS binding and dispatches it asynchronously', async () => { const input = new FakeInput(); - const settings: GamingShortcutsSettings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, singlePress: { type: 'open-opends5' } }; + const settings: GamingShortcutsSettings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, psPressAction: 'open-opends5' }; const execute = vi.fn().mockResolvedValue({ ok: true }); const coordinator = new GamingShortcutsCoordinator({ input, @@ -36,6 +36,20 @@ describe('GamingShortcutsCoordinator', () => { coordinator.stop(); }); + it('dispatches a configured single-press action', async () => { + const input = new FakeInput(); + const action = { type: 'screenshot' as const, provider: 'auto' as const }; + const execute = vi.fn().mockResolvedValue({ ok: true }); + const settings: GamingShortcutsSettings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, singlePress: action }; + const coordinator = new GamingShortcutsCoordinator({ input, settingsStore: { get: () => ({ gamingShortcuts: settings }) }, executor: { execute } as unknown as ActionExecutor }); + coordinator.start(); + input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(), 1); + vi.advanceTimersByTime(300); + await flushQueue(); + expect(execute).toHaveBeenCalledWith(action); + coordinator.stop(); + }); + it('does not dispatch while disabled and removes its listener on stop', () => { const input = new FakeInput(); const execute = vi.fn(); @@ -48,11 +62,49 @@ describe('GamingShortcutsCoordinator', () => { const input = new FakeInput(); let release!: () => void; const first = new Promise((resolve) => { release = resolve; }); const execute = vi.fn().mockReturnValueOnce(first.then(() => ({ ok: true }))).mockResolvedValueOnce({ ok: true }); - const settings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, singlePress: { type: 'open-opends5' } as const }; + const settings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, chords: [{ button: 'create' as const, action: { type: 'screenshot', provider: 'auto' as const } }] }; const coordinator = new GamingShortcutsCoordinator({ input, settingsStore: { get: () => ({ gamingShortcuts: settings }) }, executor: { execute } as unknown as ActionExecutor }); coordinator.start(); - input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(), 1); vi.advanceTimersByTime(300); await Promise.resolve(); - input.emitInput(new Set(['ps']), 1000); input.emitInput(new Set(), 1001); vi.advanceTimersByTime(300); await flushQueue(); + input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(['ps', 'create']), 1); input.emitInput(new Set(['ps']), 2); input.emitInput(new Set(), 3); await Promise.resolve(); + input.emitInput(new Set(['ps']), 1000); input.emitInput(new Set(['ps', 'create']), 1001); input.emitInput(new Set(['ps']), 1002); input.emitInput(new Set(), 1003); await flushQueue(); expect(execute).toHaveBeenCalledTimes(1); release(); await flushQueue(); expect(execute).toHaveBeenCalledTimes(2); coordinator.stop(); }); + + it('enters shortcut mode, ignores the activation press, and executes the next mapped button once', async () => { + const input = new FakeInput(); + const execute = vi.fn().mockResolvedValue({ ok: true }); + const notifications = { + showShortcutMode: vi.fn().mockResolvedValue(undefined), + showShortcutReference: vi.fn().mockResolvedValue(undefined), + showActionResult: vi.fn().mockResolvedValue(undefined), + showActionError: vi.fn().mockResolvedValue(undefined), + dismissShortcutNotification: vi.fn().mockResolvedValue(undefined) + }; + const settings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, chords: [{ button: 'create' as const, action: { type: 'screenshot', provider: 'auto' as const } }] }; + const coordinator = new GamingShortcutsCoordinator({ input, settingsStore: { get: () => ({ gamingShortcuts: settings }) }, executor: { execute } as unknown as ActionExecutor, notifications }); + coordinator.start(); + input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(), 1); + vi.advanceTimersByTime(300); + expect(coordinator.getMode().state).toBe('awaiting-selection'); + expect(notifications.showShortcutMode).toHaveBeenCalledOnce(); + input.emitInput(new Set(['create']), 400); input.emitInput(new Set(), 401); + await flushQueue(); + expect(execute).toHaveBeenCalledWith({ type: 'screenshot', provider: 'auto' }); + expect(coordinator.getMode().state).toBe('inactive'); + coordinator.stop(); + }); + + it('cancels shortcut mode on Circle and on timeout', () => { + const input = new FakeInput(); + const notifications = { showShortcutMode: vi.fn(), showShortcutReference: vi.fn(), showActionResult: vi.fn(), showActionError: vi.fn(), dismissShortcutNotification: vi.fn() }; + const coordinator = new GamingShortcutsCoordinator({ input, settingsStore: { get: () => ({ gamingShortcuts: { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true } }) }, executor: { execute: vi.fn() } as unknown as ActionExecutor, notifications }); + coordinator.start(); input.emitInput(new Set(['ps']), 0); input.emitInput(new Set(), 1); vi.advanceTimersByTime(300); + input.emitInput(new Set(['circle']), 400); input.emitInput(new Set(), 401); + expect(coordinator.getMode().state).toBe('inactive'); + input.emitInput(new Set(['ps']), 1000); input.emitInput(new Set(), 1001); vi.advanceTimersByTime(300); + expect(coordinator.getMode().state).toBe('awaiting-selection'); + vi.advanceTimersByTime(3000); + expect(coordinator.getMode().state).toBe('inactive'); + coordinator.stop(); + }); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts index 16c3d41..199b128 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts @@ -2,18 +2,21 @@ import { EventEmitter } from 'node:events'; import type { ControllerInputState } from '../../shared/trigger-modifier-eval'; import { resolveGamingShortcutBindings, type GamingShortcutAction, type GamingShortcutsSettings } from '../../shared/gaming-shortcuts'; import type { ControllerButton } from '../../shared/controller-input'; -import type { ActionExecutionResult } from './action-executor'; -import { ActionExecutor } from './action-executor'; +import { ActionExecutor, type ActionExecutionResult } from './action-executor'; import { GestureEngine, type ControllerGesture } from './gesture-engine'; +import { actionResult, type GamingShortcutNotifications, type ShortcutBinding } from './notifications'; export interface InputSource { on(event: 'input', listener: (state: ControllerInputState) => void): this; off(event: 'input', listener: (state: ControllerInputState) => void): this; } -export interface ShortcutSettingsSource { - get(): { gamingShortcuts: GamingShortcutsSettings }; -} +export interface ShortcutSettingsSource { get(): { gamingShortcuts: GamingShortcutsSettings }; } + +export type GamingShortcutMode = + | { state: 'inactive' } + | { state: 'awaiting-selection'; activatedAt: number; expiresAt: number } + | { state: 'executing'; actionId: string }; export interface ShortcutExecutionResult { gesture: ControllerGesture; @@ -21,47 +24,53 @@ export interface ShortcutExecutionResult { result: ActionExecutionResult; } -/** Connects normalized controller input to validated, serialized shortcut actions. */ +/** Routes controller gestures to actions and notification-driven temporary mode. */ export class GamingShortcutsCoordinator extends EventEmitter { private readonly input: InputSource; private readonly settingsStore: ShortcutSettingsSource; private readonly executor: ActionExecutor; private readonly activeGameId: () => string | null; + private readonly notifications: GamingShortcutNotifications | null; private readonly onInputBound: (state: ControllerInputState) => void; private gestureEngine: GestureEngine; private settings: GamingShortcutsSettings; private running = false; private dispatchQueue: Promise = Promise.resolve(); + private previousButtons = new Set(); + private mode: GamingShortcutMode = { state: 'inactive' }; + private modeTimer: ReturnType | null = null; constructor(options: { input: InputSource; settingsStore: ShortcutSettingsSource; executor?: ActionExecutor; activeGameId?: () => string | null; + notifications?: GamingShortcutNotifications; }) { super(); this.input = options.input; this.settingsStore = options.settingsStore; this.executor = options.executor ?? new ActionExecutor(); this.activeGameId = options.activeGameId ?? (() => null); + this.notifications = options.notifications ?? null; this.settings = this.settingsStore.get().gamingShortcuts; this.gestureEngine = this.createGestureEngine(this.settings); - this.onInputBound = (state) => this.gestureEngine.update(state.buttons, state.timestampMs); + this.onInputBound = (state) => this.handleInput(state); } - start(): void { - if (this.running) return; - this.running = true; - this.input.on('input', this.onInputBound); - } + start(): void { if (!this.running) { this.running = true; this.input.on('input', this.onInputBound); } } stop(): void { if (!this.running) return; this.running = false; this.input.off('input', this.onInputBound); this.gestureEngine.reset(); + this.previousButtons.clear(); + this.exitShortcutMode(); } + disconnect(): void { this.previousButtons.clear(); this.gestureEngine.reset(); this.exitShortcutMode(); } + reload(): void { const next = this.settingsStore.get().gamingShortcuts; const wasRunning = this.running; @@ -71,6 +80,25 @@ export class GamingShortcutsCoordinator extends EventEmitter { if (wasRunning) this.start(); } + getMode(): GamingShortcutMode { return this.mode; } + + previewShortcutNotification(): Promise { + return this.showShortcutReference(); + } + + private handleInput(state: ControllerInputState): void { + this.gestureEngine.update(state.buttons, state.timestampMs); + const justPressed = [...state.buttons].filter((button) => !this.previousButtons.has(button)); + this.previousButtons = new Set(state.buttons); + if (!this.settings.enabled || this.mode.state !== 'awaiting-selection') return; + for (const button of justPressed) { + if (button === 'ps') { this.exitShortcutMode(); return; } + if (button === 'circle') { this.exitShortcutMode(); return; } + const action = this.actionForButton(button); + if (action) { this.exitShortcutMode(); this.dispatchAction(action, { type: 'chord', modifier: 'ps', button }); return; } + } + } + private createGestureEngine(settings: GamingShortcutsSettings): GestureEngine { return new GestureEngine({ doublePressWindowMs: settings.doublePressWindowMs, @@ -82,29 +110,86 @@ export class GamingShortcutsCoordinator extends EventEmitter { private handleGesture(gesture: ControllerGesture): void { if (!this.settings.enabled) return; - const action = this.actionFor(gesture); - if (!action || action.type === 'none' || action.type === 'passthrough') return; - this.dispatchQueue = this.dispatchQueue - .then(async () => { - const result = await this.executor.execute(action); - this.emit('result', { gesture, action, result } satisfies ShortcutExecutionResult); - }) - .catch((error: unknown) => { - this.emit('error', error instanceof Error ? error : new Error(String(error))); - }); + if (gesture.type === 'chord') { + if (!this.settings.directChordsEnabled) return; + this.exitShortcutMode(); + const action = this.actionFor(gesture); + if (action) this.dispatchAction(action, gesture); + return; + } + if (gesture.button !== 'ps') return; + if (gesture.type === 'single-press') { + const action = this.bindings().singlePress; + if (action.type !== 'none' && action.type !== 'passthrough') { + this.dispatchAction(action, gesture); + return; + } + switch (this.settings.psPressAction) { + case 'show-shortcut-reference': void this.showShortcutReference(); return; + case 'enter-shortcut-mode': if (this.settings.shortcutModeEnabled) { this.enterShortcutMode(); return; } return; + case 'open-opends5': this.dispatchAction({ type: 'open-opends5' }, gesture); return; + default: return; + } + } + const action = gesture.type === 'double-press' ? this.bindings().doublePress : this.bindings().longPress; + if (action.type !== 'none' && action.type !== 'passthrough') this.dispatchAction(action, gesture); } - private actionFor(gesture: ControllerGesture): GamingShortcutAction | null { - const bindings = resolveGamingShortcutBindings(this.settings, this.activeGameId()); - switch (gesture.type) { - case 'single-press': return gesture.button === 'ps' ? bindings.singlePress : null; - case 'double-press': return gesture.button === 'ps' ? bindings.doublePress : null; - case 'long-press': return gesture.button === 'ps' ? bindings.longPress : null; - case 'chord': { - const binding = bindings.chords.find((candidate) => candidate.button === gesture.button); - return binding?.action ?? null; + private bindings() { return resolveGamingShortcutBindings(this.settings, this.activeGameId()); } + private actionFor(gesture: Extract): GamingShortcutAction | null { + return this.bindings().chords.find((candidate) => candidate.button === gesture.button)?.action ?? null; + } + private actionForButton(button: ControllerButton): GamingShortcutAction | null { + return this.bindings().chords.find((candidate) => candidate.button === button)?.action ?? null; + } + + private shortcutBindings(): ShortcutBinding[] { + const bindings = this.bindings(); + return [ + { button: 'ps' as const, label: 'PS press', action: bindings.singlePress }, + { button: 'ps' as const, label: 'PS double press', action: bindings.doublePress }, + { button: 'ps' as const, label: 'PS hold', action: bindings.longPress }, + ...bindings.chords.map(({ button, action }) => ({ button, action })) + ]; + } + + private enterShortcutMode(): void { + this.clearModeTimer(); + const now = Date.now(); + this.mode = { state: 'awaiting-selection', activatedAt: now, expiresAt: now + this.settings.shortcutModeTimeoutMs }; + this.modeTimer = setTimeout(() => this.exitShortcutMode(), this.settings.shortcutModeTimeoutMs); + void this.notifications?.showShortcutMode(this.shortcutBindings(), this.settings.shortcutModeTimeoutMs); + this.emit('mode', this.mode); + } + + private exitShortcutMode(): void { + this.clearModeTimer(); + if (this.mode.state === 'inactive') return; + this.mode = { state: 'inactive' }; + void this.notifications?.dismissShortcutNotification(); + this.emit('mode', this.mode); + } + + private clearModeTimer(): void { if (this.modeTimer) clearTimeout(this.modeTimer); this.modeTimer = null; } + + private async showShortcutReference(): Promise { + await this.notifications?.showShortcutReference(this.shortcutBindings()); + } + + private dispatchAction(action: GamingShortcutAction, gesture: ControllerGesture): void { + if (action.type === 'none' || action.type === 'passthrough') return; + this.dispatchQueue = this.dispatchQueue.then(async () => { + const result = await this.executor.execute(action); + const notification = actionResult(action, result); + if (result.ok) { + if (this.settings.showSuccessNotifications) await this.notifications?.showActionResult(notification); + } else if (this.settings.showErrorNotifications) { + await this.notifications?.showActionError(notification); } - } + this.emit('result', { gesture, action, result } satisfies ShortcutExecutionResult); + }).catch((error: unknown) => { + this.emit('error', error instanceof Error ? error : new Error(String(error))); + }); } } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts index 1359b54..fd0e587 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts @@ -25,6 +25,23 @@ describe('GestureEngine', () => { expect(emit).toHaveBeenCalledWith({ type: 'chord', modifier: 'ps', button: 'create' }); expect(emit).toHaveBeenCalledTimes(1); engine.update(new Set(['create']), 1000); engine.update(new Set(['ps', 'create']), 1100); expect(emit).toHaveBeenCalledTimes(2); }); + it('recognizes a chord after PS has been held beyond the chord window', () => { + const emit = vi.fn(); const engine = new GestureEngine({ emit }); + engine.update(new Set(['ps']), 0); + engine.update(new Set(['ps']), 500); + engine.update(new Set(['ps', 'create']), 1000); + expect(emit).toHaveBeenCalledWith({ type: 'chord', modifier: 'ps', button: 'create' }); + }); + it('repeats chords when the secondary button is released and pressed again', () => { + const emit = vi.fn(); const engine = new GestureEngine({ emit }); + engine.update(new Set(['ps']), 0); + engine.update(new Set(['ps', 'dpad-up']), 10); + engine.update(new Set(['ps']), 20); + engine.update(new Set(['ps', 'dpad-up']), 30); + engine.update(new Set(['ps']), 40); + expect(emit).toHaveBeenNthCalledWith(1, { type: 'chord', modifier: 'ps', button: 'dpad-up' }); + expect(emit).toHaveBeenNthCalledWith(2, { type: 'chord', modifier: 'ps', button: 'dpad-up' }); + }); it('reset clears stale timers and held state', () => { const emit = vi.fn(); const engine = new GestureEngine({ emit }); engine.update(new Set(['ps']), 0); engine.reset(); vi.runAllTimers(); expect(emit).not.toHaveBeenCalled(); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts index cf4f627..f75beac 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts @@ -76,7 +76,10 @@ export class GestureEngine { } return; } - if (this.held.has('ps') && this.psPressAt !== null && now - this.psPressAt <= this.chordWindow) this.chord(button); + // PS is a modifier while held. The chord window still applies when the + // secondary button is pressed first, but a held PS button must remain + // usable after the initial detection window has elapsed. + if (this.held.has('ps')) this.chord(button); } private chord(button: ControllerButton): void { if (this.chordEmitted) return; this.chordEmitted = true; this.chordCycle = true; this.doublePressCycle = false; this.clearLongTimer(); this.clearSingleTimer(); this.emitGesture({ type: 'chord', modifier: 'ps', button }); } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.test.ts new file mode 100644 index 0000000..97c6226 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { formatShortcutBindings } from './notifications'; + +describe('gaming shortcut notifications', () => { + it('formats configured bindings and omits unassigned actions', () => { + expect(formatShortcutBindings([ + { button: 'create', action: { type: 'screenshot', provider: 'auto' } }, + { button: 'options', action: { type: 'none' } }, + { button: 'dpad-up', action: { type: 'volume', direction: 'up' } } + ])).toBe('Create Screenshot\nD-pad Up Volume'); + }); + + it('truncates deterministically', () => { + expect(formatShortcutBindings([ + { button: 'create', action: { type: 'screenshot', provider: 'auto' } }, + { button: 'triangle', action: { type: 'performance-hud-toggle', provider: 'auto' } }, + { button: 'mute', action: { type: 'microphone-mute-toggle' } } + ], 2)).toContain('More shortcuts configured'); + }); + + it('formats PS gesture bindings with their configured labels', () => { + expect(formatShortcutBindings([ + { button: 'ps', label: 'PS press', action: { type: 'screenshot', provider: 'auto' } }, + { button: 'ps', label: 'PS double press', action: { type: 'none' } } + ])).toBe('PS press Screenshot'); + }); +}); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts new file mode 100644 index 0000000..e8211de --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts @@ -0,0 +1,63 @@ +import type { ControllerButton } from '../../shared/controller-input'; +import type { GamingShortcutAction } from '../../shared/gaming-shortcuts'; + +export interface ShortcutBinding { + button: ControllerButton; + action: GamingShortcutAction; + label?: string; +} + +export type GamingShortcutResult = { + status: 'success' | 'unavailable' | 'failure'; + title: string; + body?: string; + replaceGroup?: string; +}; + +export interface GamingShortcutNotifications { + showShortcutReference(bindings: ShortcutBinding[]): Promise; + showShortcutMode(bindings: ShortcutBinding[], timeoutMs: number): Promise; + showActionResult(result: GamingShortcutResult): Promise; + showActionError(error: GamingShortcutResult): Promise; + dismissShortcutNotification(): Promise; +} + +const BUTTON_LABELS: Record = { + cross: 'Cross', circle: 'Circle', square: 'Square', triangle: 'Triangle', + l1: 'L1', r1: 'R1', l2: 'L2', r2: 'R2', l3: 'L3', r3: 'R3', + create: 'Create', options: 'Options', ps: 'PS', touchpad: 'Touchpad', mute: 'Mute', + 'dpad-up': 'D-pad Up', 'dpad-down': 'D-pad Down', 'dpad-left': 'D-pad Left', 'dpad-right': 'D-pad Right' +}; + +const ACTION_LABELS: Record = { + screenshot: 'Screenshot', 'recording-toggle': 'Recording', 'performance-hud-toggle': 'HUD', + 'microphone-mute-toggle': 'Microphone', 'on-screen-keyboard': 'Keyboard', + 'switch-application': 'Switch app', volume: 'Volume', 'open-opends5': 'OpenDS5', + 'quit-active-game': 'Quit game', 'custom-executable': 'Custom action', 'launch-app': 'Launch app', 'focus-app': 'Focus app' +}; + +export function buttonLabel(button: ControllerButton): string { + return BUTTON_LABELS[button] ?? button; +} + +export function actionLabel(action: GamingShortcutAction): string { + return ACTION_LABELS[action.type] ?? 'Action'; +} + +export function formatShortcutBindings(bindings: ShortcutBinding[], maxBindings = 8): string { + const visible = bindings.filter(({ action }) => action.type !== 'none' && action.type !== 'passthrough').slice(0, maxBindings); + const lines = visible.map(({ button, action, label }) => `${label ?? buttonLabel(button)} ${actionLabel(action)}`); + if (visible.length < bindings.filter(({ action }) => action.type !== 'none' && action.type !== 'passthrough').length) lines.push('More shortcuts configured'); + return lines.length > 0 ? lines.join('\n') : 'No shortcuts configured'; +} + +export function actionResult(action: GamingShortcutAction, result: { ok: boolean; reason?: string; error?: string }): GamingShortcutResult { + const label = actionLabel(action); + if (result.ok) { + return { status: 'success', title: label === 'Screenshot' ? 'Screenshot saved' : `${label} complete`, replaceGroup: action.type === 'volume' ? 'gaming-volume' : `gaming-${action.type}` }; + } + if (result.reason === 'unavailable') { + return { status: 'unavailable', title: `${label} unavailable`, body: result.error ?? 'Install the required provider or select another one.', replaceGroup: `gaming-${action.type}` }; + } + return { status: 'failure', title: `${label} failed`, body: result.error ?? 'The action could not be completed.', replaceGroup: `gaming-${action.type}` }; +} diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts index 1e4c7df..fc278ed 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts @@ -26,7 +26,8 @@ describe('detectProviderCapabilities', () => { environment: 'hyprland', screenshot: ['grim'], recording: ['gpu-screen-recorder'], - hud: ['mangohud'] + hud: ['mangohud'], + keyboard: [] }); }); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts index 62fa8bf..3d30d52 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts @@ -18,6 +18,7 @@ export interface ProviderCapabilities { screenshot: string[]; recording: string[]; hud: string[]; + keyboard: string[]; } function defaultHasExecutable(executable: string): boolean { @@ -65,6 +66,8 @@ export function detectProviderCapabilities(options: EnvironmentProbe = {}): Prov const hud: string[] = []; if (hasExecutable('mangohud')) hud.push('mangohud'); - return { environment, screenshot, recording, hud }; + const keyboard: string[] = []; + for (const provider of ['wvkbd', 'onboard', 'squeekboard']) if (hasExecutable(provider)) keyboard.push(provider); + return { environment, screenshot, recording, hud, keyboard }; } import { execFileSync } from 'node:child_process'; diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts index f378a19..3960ef8 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts @@ -26,6 +26,14 @@ describe('LinuxActionProvider', () => { }); }); + it('resolves MangoHud toggle to the focused-window F12 key', () => { + const provider = new LinuxActionProvider({ hasExecutable: (executable) => executable === 'wtype' }); + expect(provider.resolve({ type: 'performance-hud-toggle', provider: 'mangohud' })).toEqual({ + executable: 'wtype', + args: ['-k', 'F12'] + }); + }); + it('reports unavailable when wpctl is missing', () => { expect(new LinuxActionProvider({ hasExecutable: () => false }).resolve({ type: 'volume', direction: 'up' })).toBeNull(); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts index 0a58fe0..a837b34 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts @@ -40,6 +40,15 @@ export class LinuxActionProvider { return this.hasExecutable('wpctl') ? { executable: 'wpctl', args: ['set-mute', '@DEFAULT_AUDIO_SOURCE@', 'toggle'] } : null; + case 'on-screen-keyboard': { + const providers = action.provider === 'auto' ? ['wvkbd', 'onboard', 'squeekboard'] : [action.provider]; + const executable = providers.find((candidate) => this.hasExecutable(candidate)); + return executable ? { executable, args: [] } : null; + } + case 'performance-hud-toggle': + // MangoHud handles the toggle in the game; send its default hotkey to + // the focused application rather than launching mangohud again. + return this.hasExecutable('wtype') ? { executable: 'wtype', args: ['-k', 'F12'] } : null; default: return null; } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts index 2b0fda1..d02a585 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts @@ -69,6 +69,10 @@ export class ScreenshotProvider { fs.mkdirSync(path.dirname(command.outputPath), { recursive: true }); } + outputExists(command: ScreenshotCommand): boolean { + return fs.existsSync(command.outputPath); + } + private autoProvider(): CaptureProvider | null { const wayland = Boolean(this.env.WAYLAND_DISPLAY || this.env.HYPRLAND_INSTANCE_SIGNATURE || this.env.SWAYSOCK); const candidates = this.env.HYPRLAND_INSTANCE_SIGNATURE diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index 562dd89..e014049 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -28,7 +28,9 @@ import { TriggerProfileEngine, type DraftPreviewTriggers, type EngineStatus } fr import { GameSettingsCoordinator, type GameSettingsStatus } from './game-settings-coordinator'; import { GameArtworkStore } from './game-artwork'; import { GamingShortcutsCoordinator } from './gaming-shortcuts/coordinator'; +import { ActionExecutor } from './gaming-shortcuts/action-executor'; import { detectProviderCapabilities } from './gaming-shortcuts/providers/detect-environment'; +import { formatShortcutBindings, type GamingShortcutNotifications, type GamingShortcutResult } from './gaming-shortcuts/notifications'; import { normalizeGamingShortcutsSettings } from '../shared/gaming-shortcuts'; import { InstalledGamesScanner, @@ -801,7 +803,13 @@ function ensureWindowsNotificationShortcut(): void { } } +const activeNotifications = new Map(); + function showBridgeNotification(toast: BridgeToast): void { + const replaceGroup = toast.replaceGroup; + if (replaceGroup) { + activeNotifications.get(replaceGroup)?.close(); + } if (Notification.isSupported()) { const notification = new Notification({ title: toast.title, @@ -812,10 +820,24 @@ function showBridgeNotification(toast: BridgeToast): void { notification.once('failed', (_event, error) => { console.warn('Windows notification failed:', error); }); + if (replaceGroup) activeNotifications.set(replaceGroup, notification); notification.show(); } } +function gamingShortcutNotifications(service: BridgeService): GamingShortcutNotifications { + return { + showShortcutReference: async (bindings) => { service.emit('toast', { title: 'OpenDS5 Gaming Shortcuts', body: formatShortcutBindings(bindings), replaceGroup: 'gaming-shortcut-mode' } satisfies BridgeToast); }, + showShortcutMode: async (bindings, timeoutMs) => { service.emit('toast', { title: 'OpenDS5 Gaming Shortcuts', body: `${formatShortcutBindings(bindings)}\n\nSelect a shortcut within ${Math.ceil(timeoutMs / 1000)}s`, replaceGroup: 'gaming-shortcut-mode' } satisfies BridgeToast); }, + showActionResult: async (result: GamingShortcutResult) => { service.emit('toast', { title: result.title, body: result.body ?? '', replaceGroup: result.replaceGroup } satisfies BridgeToast); }, + showActionError: async (result: GamingShortcutResult) => { service.emit('toast', { title: result.title, body: result.body ?? '', replaceGroup: result.replaceGroup } satisfies BridgeToast); }, + dismissShortcutNotification: async () => { + activeNotifications.get('gaming-shortcut-mode')?.close(); + activeNotifications.delete('gaming-shortcut-mode'); + } + }; +} + async function addAudioHapticsSessionIcons(sessions: AudioHapticsSession[]): Promise { return Promise.all(sessions.map(async (session) => ({ ...session, @@ -1116,6 +1138,7 @@ function registerIpc( return saved.gamingShortcuts; }); ipcMain.handle('bridge:getGamingShortcutProviders', () => detectProviderCapabilities()); + ipcMain.handle('bridge:previewGamingShortcutNotification', () => gamingShortcuts?.previewShortcutNotification()); ipcMain.handle('bridge:listTriggerProfiles', () => triggerProfileStore.list()); ipcMain.handle('bridge:saveTriggerProfile', (_event, profile: TriggerProfile) => { const saved = triggerProfileStore.save(profile); @@ -1704,11 +1727,21 @@ app.whenReady().then(async () => { gamingShortcutsCoordinator = new GamingShortcutsCoordinator({ input: shortcutReader, settingsStore, - activeGameId: () => triggerProfileEngine?.getActiveGameId() ?? null + activeGameId: () => triggerProfileEngine?.getActiveGameId() ?? null, + executor: new ActionExecutor({ + quitActiveGame: () => { + const processName = triggerProfileEngine?.getStatus().matchedName; + if (!processName || processName === 'OpenDS5') return { ok: false, reason: 'unavailable' as const }; + const result = spawnSync('pkill', ['-TERM', '-x', processName], { stdio: 'ignore', timeout: 1000 }); + return result.status === 0 ? { ok: true as const } : { ok: false, reason: 'unavailable' as const }; + } + }), + notifications: gamingShortcutNotifications(bridgeService) }); gamingShortcutsCoordinator.on('error', (error) => { console.error('[gaming-shortcuts] action error', error); }); + shortcutReader.on('error', () => gamingShortcutsCoordinator?.disconnect()); if (settingsStore.get().gamingShortcuts.enabled) { gamingShortcutsCoordinator.start(); shortcutReader.start(); diff --git a/ds5-bridge/companion/src/preload.ts b/ds5-bridge/companion/src/preload.ts index 80aa211..505a02d 100644 --- a/ds5-bridge/companion/src/preload.ts +++ b/ds5-bridge/companion/src/preload.ts @@ -63,6 +63,7 @@ const api = { getGamingShortcutProviders: (): Promise => ( ipcRenderer.invoke('bridge:getGamingShortcutProviders') ), + previewGamingShortcutNotification: (): Promise => ipcRenderer.invoke('bridge:previewGamingShortcutNotification'), listDevices: () => ipcRenderer.invoke('bridge:listDevices'), listAudioHapticsSessions: (): Promise => ( ipcRenderer.invoke('bridge:listAudioHapticsSessions') diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 0bb59c8..527e8f7 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -2944,6 +2944,7 @@ export function App() { const [edgeRemapControlLayout, setEdgeRemapControlLayout] = useState | null>(null); const [hoveredRemapButton, setHoveredRemapButton] = useState(null); const [pendingAction, setPendingAction] = useState(null); + const [feedbackTestError, setFeedbackTestError] = useState(null); const [showBridgeSettings, setShowBridgeSettings] = useState(false); const [settingsFocusTarget, setSettingsFocusTarget] = useState(null); const [notificationFocusTarget, setNotificationFocusTarget] = useState(null); @@ -4408,10 +4409,14 @@ export function App() { return; } setPendingAction(label); + if (label === 'test' || label === 'test-rumble') setFeedbackTestError(null); try { const next = await action(); setSnapshot(next); - } catch { + } catch (error) { + if (label === 'test' || label === 'test-rumble') { + setFeedbackTestError(error instanceof Error ? error.message : String(error)); + } const next = await window.bridge.getStatus(); setSnapshot(next); } finally { @@ -8180,6 +8185,7 @@ export function App() { {activeFeedbackStatusLabel} + {feedbackTestError &&

{feedbackTestError}

} )} diff --git a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx index bcd2651..fdc53cf 100644 --- a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx +++ b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx @@ -21,7 +21,7 @@ const ACTIONS: Array<{ value: ActionKind; label: string }> = [ { value: 'launch-app', label: 'Open or focus an application' }, { value: 'focus-app', label: 'Focus application by ID' }, { value: 'screenshot', label: 'Take screenshot' }, { value: 'recording-toggle', label: 'Toggle recording' }, { value: 'performance-hud-toggle', label: 'Toggle performance HUD' }, { value: 'volume', label: 'Volume' }, - { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, + { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'on-screen-keyboard', label: 'Open on-screen keyboard' }, { value: 'switch-application', label: 'Switch application' }, { value: 'quit-active-game', label: 'Quit active game (confirm)' }, { value: 'custom-executable', label: 'Custom executable' } ]; @@ -34,6 +34,7 @@ function actionForKind(kind: ActionKind): GamingShortcutAction { case 'screenshot': return { type: 'screenshot', provider: 'auto' }; case 'recording-toggle': return { type: 'recording-toggle', provider: 'auto' }; case 'performance-hud-toggle': return { type: 'performance-hud-toggle', provider: 'auto' }; + case 'on-screen-keyboard': return { type: 'on-screen-keyboard', provider: 'auto' }; case 'switch-application': return { type: 'switch-application', direction: 'next' }; case 'quit-active-game': return { type: 'quit-active-game', confirmation: true }; case 'custom-executable': return { type: 'custom-executable', executable: '', args: [] }; @@ -46,7 +47,7 @@ function actionLabel(action: GamingShortcutAction): string { } function isProviderAction(action: GamingShortcutAction): action is Extract { - return action.type === 'screenshot' || action.type === 'recording-toggle' || action.type === 'performance-hud-toggle'; + return action.type === 'screenshot' || action.type === 'recording-toggle' || action.type === 'performance-hud-toggle' || action.type === 'on-screen-keyboard'; } function ActionEditor({ action, onChange, capabilities }: { @@ -56,7 +57,7 @@ function ActionEditor({ action, onChange, capabilities }: { }) { const providerOptions = action.type === 'screenshot' ? capabilities?.screenshot ?? [] : action.type === 'recording-toggle' ? capabilities?.recording ?? [] - : capabilities?.hud ?? []; + : action.type === 'performance-hud-toggle' ? capabilities?.hud ?? [] : capabilities?.keyboard ?? []; return
update({ ...settings, psPressAction: event.target.value as GamingShortcutsSettings['psPressAction'] })}> + + + + + + +
{([['singlePress', 'PS button · Press'], ['doublePress', 'PS button · Double press'], ['longPress', 'PS button · Hold']] as const).map(([key, label]) => )}
-
PS chords

Hold PS and press another controller button. Chords take precedence over PS press gestures.

+
PS chords

Hold PS and press another controller button. Direct chords take precedence over PS press gestures.

{settings.chords.length === 0 ?

No chords configured.

: settings.chords.map((chord, index) =>
{ const chords = [...settings.chords]; chords[index] = { ...chord, action }; update({ ...settings, chords }); }} capabilities={capabilities} />
)}
-
Timing
+
Timing
Per-game overrides{selectedOverride && }
{gameProfiles.length === 0 ?

Create a Game Profile to customize shortcuts for a specific game.

: <> {selectedGameId && <>

Unset actions inherit the global Gaming Shortcuts binding.

{([['singlePress', 'PS button · Press'], ['doublePress', 'PS button · Double press'], ['longPress', 'PS button · Hold']] as const).map(([key, label]) => )} -
PS chords
+
PS chords
{(selectedOverride?.chords ?? []).map((chord, index) =>
{ const chords = [...(selectedOverride?.chords ?? [])]; chords[index] = { ...chord, action }; setGameOverride({ ...selectedOverride, chords }); }} capabilities={capabilities} />
)} } } diff --git a/ds5-bridge/companion/src/renderer/styles.css b/ds5-bridge/companion/src/renderer/styles.css index 1eca49b..5020bad 100644 --- a/ds5-bridge/companion/src/renderer/styles.css +++ b/ds5-bridge/companion/src/renderer/styles.css @@ -2459,6 +2459,8 @@ button { grid-template-rows: auto auto auto auto auto; } +.test-error { margin: 10px 0 0; color: #ffaaa7; font-size: 12px; line-height: 1.4; } + .gaming-shortcuts-card { padding: 18px 20px; } diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts index df4bbeb..bb67219 100644 --- a/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts @@ -40,4 +40,14 @@ describe('normalizeGamingShortcutsSettings', () => { }); expect(resolveGamingShortcutBindings(settings, null).singlePress).toEqual({ type: 'open-opends5' }); }); + + it('repairs the removed overlay action without affecting unrelated bindings', () => { + const settings = normalizeGamingShortcutsSettings({ + singlePress: { type: 'open-overlay' }, + chords: [{ button: 'create', action: { type: 'screenshot', provider: 'grim' } }] + }); + expect(settings.singlePress).toEqual({ type: 'none' }); + expect(settings.chords[0]).toEqual({ button: 'create', action: { type: 'screenshot', provider: 'grim' } }); + expect(settings.shortcutModeTimeoutMs).toBe(3000); + }); }); diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts index 6d1844f..0b24475 100644 --- a/ds5-bridge/companion/src/shared/gaming-shortcuts.ts +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts @@ -3,7 +3,8 @@ import type { ControllerButton } from './controller-input'; export type CaptureProvider = 'auto' | 'portal' | 'grim' | 'hyprshot' | 'gnome-screenshot' | 'spectacle' | 'scrot'; export type RecordingProvider = 'auto' | 'gpu-screen-recorder'; export type HudProvider = 'auto' | 'gamescope' | 'mangohud'; -export type KeyboardProvider = 'auto' | 'portal' | 'wvkbd' | 'onboard' | 'matchbox-keyboard'; +export type GamingShortcutPressAction = 'show-shortcut-reference' | 'enter-shortcut-mode' | 'open-opends5' | 'none'; +export type KeyboardProvider = 'auto' | 'portal' | 'wvkbd' | 'onboard' | 'matchbox-keyboard' | 'squeekboard'; export type GamingShortcutAction = | { type: 'none' } @@ -32,6 +33,12 @@ export type GamingShortcutOverride = Partial; export interface GamingShortcutsSettings extends GamingShortcutBindings { enabled: boolean; + directChordsEnabled: boolean; + shortcutModeEnabled: boolean; + shortcutModeTimeoutMs: number; + showSuccessNotifications: boolean; + showErrorNotifications: boolean; + psPressAction: GamingShortcutPressAction; doublePressWindowMs: number; longPressThresholdMs: number; chordWindowMs: number; @@ -40,6 +47,12 @@ export interface GamingShortcutsSettings extends GamingShortcutBindings { export const DEFAULT_GAMING_SHORTCUTS_SETTINGS: GamingShortcutsSettings = { enabled: false, + directChordsEnabled: true, + shortcutModeEnabled: true, + shortcutModeTimeoutMs: 3000, + showSuccessNotifications: true, + showErrorNotifications: true, + psPressAction: 'enter-shortcut-mode', doublePressWindowMs: 300, longPressThresholdMs: 650, chordWindowMs: 150, @@ -53,7 +66,7 @@ export const DEFAULT_GAMING_SHORTCUTS_SETTINGS: GamingShortcutsSettings = { const CAPTURE_PROVIDERS = new Set(['auto', 'portal', 'grim', 'hyprshot', 'gnome-screenshot', 'spectacle', 'scrot']); const RECORDING_PROVIDERS = new Set(['auto', 'gpu-screen-recorder']); const HUD_PROVIDERS = new Set(['auto', 'gamescope', 'mangohud']); -const KEYBOARD_PROVIDERS = new Set(['auto', 'portal', 'wvkbd', 'onboard', 'matchbox-keyboard']); +const KEYBOARD_PROVIDERS = new Set(['auto', 'portal', 'wvkbd', 'onboard', 'matchbox-keyboard', 'squeekboard']); function record(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -100,6 +113,7 @@ export function validateGamingShortcutAction(value: unknown): GamingShortcutActi case 'volume': return value.direction === 'up' || value.direction === 'down' || value.direction === 'mute' ? { type: 'volume', direction: value.direction } : { type: 'none' }; case 'microphone-mute-toggle': return { type: 'microphone-mute-toggle' }; + case 'on-screen-keyboard': return member(KEYBOARD_PROVIDERS, value.provider) ? { type: 'on-screen-keyboard', provider: value.provider } : { type: 'none' }; case 'screenshot': return member(CAPTURE_PROVIDERS, value.provider) ? { type: 'screenshot', provider: value.provider } : { type: 'none' }; case 'recording-toggle': @@ -144,6 +158,14 @@ export function normalizeGamingShortcutsSettings(value: unknown): GamingShortcut } return { enabled: typeof value.enabled === 'boolean' ? value.enabled : DEFAULT_GAMING_SHORTCUTS_SETTINGS.enabled, + directChordsEnabled: typeof value.directChordsEnabled === 'boolean' ? value.directChordsEnabled : DEFAULT_GAMING_SHORTCUTS_SETTINGS.directChordsEnabled, + shortcutModeEnabled: typeof value.shortcutModeEnabled === 'boolean' ? value.shortcutModeEnabled : DEFAULT_GAMING_SHORTCUTS_SETTINGS.shortcutModeEnabled, + shortcutModeTimeoutMs: timing(value.shortcutModeTimeoutMs, 3000, 1000, 10000), + showSuccessNotifications: typeof value.showSuccessNotifications === 'boolean' ? value.showSuccessNotifications : DEFAULT_GAMING_SHORTCUTS_SETTINGS.showSuccessNotifications, + showErrorNotifications: typeof value.showErrorNotifications === 'boolean' ? value.showErrorNotifications : DEFAULT_GAMING_SHORTCUTS_SETTINGS.showErrorNotifications, + psPressAction: value.psPressAction === 'show-shortcut-reference' || value.psPressAction === 'enter-shortcut-mode' || value.psPressAction === 'open-opends5' || value.psPressAction === 'none' + ? value.psPressAction + : DEFAULT_GAMING_SHORTCUTS_SETTINGS.psPressAction, doublePressWindowMs: timing(value.doublePressWindowMs, 300, 100, 1000), longPressThresholdMs: timing(value.longPressThresholdMs, 650, 300, 2000), chordWindowMs: timing(value.chordWindowMs, 150, 50, 500), diff --git a/nix/opends5.nix b/nix/opends5.nix index 6e4cdeb..8dd6fa6 100644 --- a/nix/opends5.nix +++ b/nix/opends5.nix @@ -7,6 +7,7 @@ libusb1, makeWrapper, pipewire, + glib, version, }: buildNpmPackage { @@ -77,6 +78,7 @@ buildNpmPackage { --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath [ libusb1 stdenv.cc.cc.lib + glib ]}" \ --prefix PATH : "${lib.makeBinPath [ pipewire From 65c7cd53c461f3a42291a57f9a41afa99a0d4547 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Fri, 17 Jul 2026 15:08:10 +0400 Subject: [PATCH 12/15] feat: complete GameShortcuts companion UI --- AGENTS.md | 705 ++++++++++++++++++ .../companion/src/main/bridge-service.test.ts | 8 + .../companion/src/main/bridge-service.ts | 21 +- .../main/controller-source-identity.test.ts | 22 + .../src/main/controller-source-identity.ts | 38 + .../src/main/evdev-input-reader.test.ts | 96 ++- .../companion/src/main/evdev-input-reader.ts | 148 ++-- .../main/gaming-shortcuts/coordinator.test.ts | 36 +- .../src/main/gaming-shortcuts/coordinator.ts | 104 ++- .../main/gaming-shortcuts/gesture-engine.ts | 25 +- ds5-bridge/companion/src/main/main.ts | 11 +- ds5-bridge/companion/src/renderer/App.tsx | 1 + .../src/renderer/GamingShortcuts.test.ts | 42 ++ .../src/renderer/GamingShortcuts.tsx | 182 ++--- ds5-bridge/companion/src/renderer/styles.css | 224 ++++++ .../src/shared/trigger-modifier-eval.ts | 2 + 16 files changed, 1440 insertions(+), 225 deletions(-) create mode 100644 AGENTS.md create mode 100644 ds5-bridge/companion/src/main/controller-source-identity.test.ts create mode 100644 ds5-bridge/companion/src/main/controller-source-identity.ts create mode 100644 ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af3dd49 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,705 @@ +# AGENTS.md + +## Purpose + +This repository uses specialized subagents to separate planning, implementation, investigation, testing, and review. + +The primary agent acts as the orchestrator. It should delegate substantial work instead of performing the entire task in the main thread. + +These rules apply to all repository work unless a more specific `AGENTS.md` exists in a subdirectory. + +--- + +## Core rule + +Use the appropriate subagent for each stage of work. + +The primary agent should coordinate, summarize, and make final decisions. It should not perform implementation, detailed code review, or broad architectural investigation itself when a suitable subagent is available. + +Preferred workflow: + +```text +Existing plan review + ↓ +Planner, only when needed + ↓ +Implementer + ↓ +Test and validation pass + ↓ +Reviewer + ↓ +Implementer fixes findings + ↓ +Reviewer verifies fixes + ↓ +Primary agent reports results +``` + +--- + +## Available roles + +Use the repository’s configured agent names where available. + +Expected roles: + +```text +planner +implementer +reviewer +investigator +tester +``` + +When spawning the planner, use the `gpt-5.6-sol` model. If that model is not +available, use the closest available compatible model and report the fallback. + +If the environment uses different names, select the closest equivalent specialized agent. + +Do not claim that a subagent was used unless it was actually invoked. + +--- + +## Primary agent responsibilities + +The primary agent must: + +* Read applicable instructions. +* Inspect the worktree status. +* Determine whether an existing plan already covers the task. +* Select and invoke the appropriate subagents. +* Provide subagents with precise scope and repository context. +* Prevent overlapping edits between agents. +* Review subagent summaries and tool results. +* Ensure tests are run. +* Ensure review findings are fixed. +* Present the final user-facing summary. +* Avoid making commits or pushing unless explicitly requested. + +The primary agent may perform small coordination tasks such as: + +* Reading `AGENTS.md` +* Reading an existing `plan.md` +* Checking `git status` +* Checking branch names +* Listing relevant files +* Running a final consolidated validation command +* Applying a trivial one-line correction after review + +The primary agent should not use these exceptions to avoid delegation. + +--- + +## Plan sufficiency and planner rule + +Before invoking the planner, determine whether the user’s request or an +existing accepted plan is already sufficiently detailed to implement safely. +The planner is not required when the prompt itself defines the requested +behavior, scope, constraints, architecture boundaries, and validation +criteria—as a detailed feature specification can do. + +Search for an existing accepted plan when the prompt does not already provide +that information. + +Check, in order: + +1. A plan explicitly supplied by the user. +2. `plan.md` in the repository root. +3. A relevant plan in `docs/`, `.codex/`, or a feature directory. +4. A detailed GitHub issue or task description already available in the session. +5. A prior implementation plan clearly referenced by the user. + +If the prompt or an existing plan is clear, current, and sufficiently specific: + +* Do not invoke the planner. +* Give the prompt or plan directly to the implementer. +* Ask the implementer to report conflicts before deviating. + +Invoke the planner only when: + +* The prompt and any existing plan are materially incomplete. +* The prompt or plan conflicts with the current architecture and the conflict + cannot be resolved by a scoped implementation decision. +* The user requests a new plan. +* The task requires significant design choices not covered by the prompt or + plan. +* The scope crosses several subsystems without defined boundaries. +* A reviewer identifies an architectural issue requiring replanning. + +Do not invoke the planner merely because the task is large or because no +separate plan file exists when the user’s prompt is already implementation +ready. + +If a configured role model is unavailable in the current environment, retry +that role with an available compatible model override when the tooling allows +it. Report the fallback only if it materially changes the work; model +availability is an environment constraint, not a repository-planning reason. + +--- + +## Planner role + +The planner is responsible for design and decomposition, not implementation. + +The planner should: + +* Inspect the relevant architecture. +* Identify affected subsystems. +* Locate repository conventions. +* Define scope and non-goals. +* Identify compatibility risks. +* Define implementation phases. +* Define tests and acceptance criteria. +* Identify decisions requiring maintainer input. +* Recommend a reviewable PR boundary. + +The planner must not: + +* Edit production code. +* Perform the implementation. +* Expand the task beyond the user’s request. +* Replace a clear accepted plan without explaining why. +* Produce a generic plan that ignores the current repository. + +Planner output should include: + +```text +1. Current architecture +2. Proposed approach +3. Files or subsystems affected +4. Scope +5. Non-goals +6. Risks +7. Test strategy +8. Acceptance criteria +9. Open questions +``` + +--- + +## Implementer role + +The implementer is the default agent for code changes. + +Invoke the implementer automatically when the user asks to: + +* Implement a plan. +* Add a feature. +* Fix a bug. +* Refactor code. +* Update tests. +* Remove obsolete functionality. +* Modify configuration. +* Apply reviewer feedback. +* Complete an existing task. + +Do not keep implementation in the primary thread merely because the user used wording such as: + +```text +implement this +do the plan +make the changes +continue +fix it +apply this +``` + +Those requests should invoke the implementer. + +The implementer must: + +* Read applicable instructions and plans. +* Inspect existing code before editing. +* Preserve unrelated local changes. +* Follow repository conventions. +* Keep the diff within scope. +* Add or update tests. +* Run focused validation. +* Report architectural conflicts before broad deviation. +* Report files changed and checks run. +* Avoid commits and pushes unless explicitly requested. + +The implementer must not: + +* Re-plan a clear task without cause. +* Perform unrelated cleanup. +* Reformat unrelated files. +* Update dependencies without necessity. +* Change release versions. +* Modify generated files. +* Silence failing tests without explaining the root cause. +* Claim hardware validation that was not performed. + +--- + +## Investigator role + +Use an investigator for focused technical research inside the repository. + +Suitable tasks include: + +* Mapping an unfamiliar subsystem. +* Tracing an input or data flow. +* Finding where a feature is implemented. +* Locating all references before deleting code. +* Investigating a regression. +* Comparing two implementation paths. +* Inspecting daemon, kernel, IPC, audio, or device interactions. +* Determining why a test or runtime behavior fails. + +The investigator should remain read-only unless explicitly told otherwise. + +The investigator should return: + +```text +1. Relevant files +2. Current flow +3. Root cause or likely cause +4. Constraints +5. Recommended implementation point +6. Risks +``` + +Use the investigator before the implementer when the task depends on uncertain architecture or a difficult root cause. + +Do not use the primary thread for a broad repository investigation when an investigator is available. + +--- + +## Tester role + +Use a tester when validation is substantial or spans several components. + +Suitable cases: + +* Native daemon and Electron code both changed. +* Input timing or gesture behavior changed. +* Audio, haptics, controller output, or device lifecycle changed. +* Settings migrations changed. +* Multiple test suites must be run. +* Hardware/manual test instructions must be prepared. +* A regression requires reproduction. + +The tester should: + +* Inspect actual repository scripts. +* Run the narrowest relevant tests first. +* Run broader validation afterward. +* Distinguish implementation failures from environment failures. +* Avoid claiming tests passed when they were not run. +* Report exact commands and outcomes. +* Identify unverified hardware or platform behavior. + +The tester should not modify production code unless explicitly acting as an implementer afterward. + +--- + +## Reviewer role + +A reviewer must be invoked after every material code change. + +Material changes include: + +* New features. +* Bug fixes affecting behavior. +* Refactors across multiple files. +* Input handling. +* Haptics or controller output. +* IPC. +* Persistence or migrations. +* Process execution. +* Security-sensitive changes. +* Device handling. +* Removal of an existing feature. +* Changes intended for a pull request. + +The reviewer must inspect the actual diff. + +The reviewer should check: + +* Correctness. +* Regressions. +* Scope compliance. +* Architecture consistency. +* Race conditions. +* Lifecycle cleanup. +* Error handling. +* Security. +* Test quality. +* Missing tests. +* Misleading naming. +* Dead code. +* Unhandled compatibility cases. +* User-facing behavior. +* Documentation accuracy. + +The reviewer must not implement the feature during the first review pass. + +Reviewer output should classify findings: + +```text +Blocking +Major +Minor +Optional +``` + +Every blocking or major finding must be addressed before final completion. + +--- + +## Review-fix loop + +After the reviewer reports findings: + +1. Send blocking and major findings to the implementer. +2. Ask the implementer to apply focused fixes. +3. Run relevant tests again. +4. Invoke the reviewer again to verify the fixes. +5. Repeat until no blocking or major findings remain. + +Do not mark the task complete while material review findings remain unresolved. + +Minor findings may remain only when: + +* They are explicitly documented. +* They are outside the accepted scope. +* Fixing them would cause unrelated churn. +* The user or maintainer accepts them. + +--- + +## Parallel work + +Subagents may work in parallel only when their scopes do not overlap. + +Safe examples: + +```text +Investigator A: +Inspect existing notification infrastructure. + +Investigator B: +Trace controller haptic output. + +Tester: +Inspect available test commands. +``` + +Unsafe examples: + +```text +Implementer A edits settings.ts. +Implementer B also edits settings.ts. +``` + +Do not allow multiple agents to edit the same subsystem concurrently unless the worktree and merge strategy are explicitly isolated. + +Prefer sequential implementation when changes share files or types. + +--- + +## Task routing + +Use this routing guide. + +### Planning request + +Examples: + +```text +Create a plan. +Design this feature. +How should we implement this? +``` + +Action: + +```text +Invoke planner. +``` + +### Clear existing plan + +Examples: + +```text +Implement plan.md. +Continue the accepted plan. +Build phase 2. +``` + +Action: + +```text +Skip planner. +Invoke implementer. +``` + +### Unclear bug + +Examples: + +```text +This stopped working. +Find why this crashes. +The controller is detected but no haptics play. +``` + +Action: + +```text +Invoke investigator. +Then invoke implementer if a code fix is identified. +Then invoke reviewer. +``` + +### Direct implementation + +Examples: + +```text +Add this feature. +Remove the overlay. +Add shortcut notifications. +``` + +Action: + +```text +Invoke implementer. +Then invoke reviewer. +``` + +### Review request + +Examples: + +```text +Review this diff. +Check my PR. +Does this satisfy the comments? +``` + +Action: + +```text +Invoke reviewer. +Do not inspect the implementation in the primary thread unless needed to coordinate. +``` + +### Test request + +Examples: + +```text +Run the tests. +Check whether this works. +Validate this implementation. +``` + +Action: + +```text +Invoke tester. +``` + +### Small trivial edit + +Examples: + +```text +Fix one typo. +Rename one label. +Change one literal. +``` + +Action: + +```text +Primary agent may perform directly. +Reviewer is optional only when the change is genuinely trivial. +``` + +--- + +## OpenDS5-specific routing + +Use an investigator before implementation for changes involving: + +* `vdsd` +* `vds_hcd` +* Physical-to-virtual controller routing +* DualSense audio haptics +* PipeWire or WirePlumber +* HID reports +* evdev device selection +* Adaptive triggers +* Native game haptic mixing +* Bluetooth controller ownership +* Multiple controller identities + +Use a reviewer with explicit OpenDS5 checks for: + +* Legacy rumble accidentally labelled HD haptics. +* Wrong audio channel routing. +* Haptic output leaking into speaker channels. +* Adaptive-trigger state being reset. +* Wrong-controller targeting. +* Controller disconnect cleanup. +* Steam Input compatibility. +* Native DualSense game compatibility. +* Input gestures emitting duplicate actions. +* Timers surviving application shutdown. +* `vdsd` and companion protocol mismatches. + +--- + +## Instructions passed to subagents + +Every subagent prompt should include: + +* Exact task. +* Relevant plan. +* Scope. +* Non-goals. +* Files or subsystems likely involved. +* Required tests. +* Constraints. +* Expected report format. +* Whether edits are permitted. +* Whether commits or pushes are permitted. + +Do not send vague instructions such as: + +```text +Look into this. +Fix everything. +Improve the code. +``` + +Prefer: + +```text +Inspect the existing shortcut feedback implementation and determine why it +uses legacy rumble instead of the DualSense audio-haptics path. Remain +read-only. Identify the output path, channel mapping, lifecycle hooks, +tests, and the smallest safe implementation point. +``` + +--- + +## Worktree safety + +Before any editing agent runs: + +```bash +git status --short +git branch --show-current +``` + +Rules: + +* Preserve unrelated modifications. +* Do not reset the worktree. +* Do not use destructive Git commands without explicit permission. +* Do not delete untracked files unless they were created by the current task. +* Do not force-push unless explicitly instructed. +* Use `--force-with-lease`, never plain `--force`. +* Do not commit or push unless the user asks. + +When a dirty worktree contains overlapping files, stop the editing agent and report the conflict. + +--- + +## Validation rules + +Agents must use actual repository scripts rather than guessing. + +Typical companion checks may include: + +```bash +cd ds5-bridge/companion +npm run typecheck +npm run test:companion +npm run build:app +``` + +Native, daemon, kernel, or Nix changes may require additional checks. + +Rules: + +* Run focused tests first. +* Run broader tests after focused tests pass. +* Report exact commands. +* Report exact failures. +* Separate environment failures from implementation failures. +* Do not claim hardware behavior without hardware testing. +* Do not conceal skipped checks. + + +## Nix validation + +For changes affecting OpenDS5’s Nix support, run: + +```bash +nix flake check -L +nix build -L +``` + +Also verify the built output contains the expected OpenDS5 binaries and integration files. Report exact failures and do not claim Nix support works unless the flake build was run. + + +--- + +## Completion criteria + +A material implementation task is complete only when: + +* The implementer finished the scoped work. +* Relevant tests were run. +* A reviewer inspected the diff. +* Blocking and major findings were fixed. +* The reviewer verified the fixes. +* Remaining limitations are documented. +* No unrelated changes were introduced. +* The final response accurately reports what was and was not verified. + +--- + +## Final response format + +The primary agent should summarize: + +```text +1. What changed +2. Subagents used +3. Files changed +4. Architectural decisions +5. Tests and builds run +6. Review findings and fixes +7. Unverified behavior +8. Remaining limitations +9. Suggested commit title +10. Suggested PR title +``` + +Do not expose hidden reasoning or raw subagent transcripts. + +Summarize their findings and results clearly. + +--- + +## Fallback when subagents are unavailable + +If the environment does not expose subagent tools: + +* State that specialized subagents are unavailable. +* Perform the stages sequentially in the primary thread. +* Preserve the same planner → implementer → reviewer separation conceptually. +* Do not pretend agents were invoked. +* Keep the implementation and review passes distinct. +* Perform a fresh diff review after implementation. + +Do not use subagent unavailability as a reason to skip testing or review. diff --git a/ds5-bridge/companion/src/main/bridge-service.test.ts b/ds5-bridge/companion/src/main/bridge-service.test.ts index 9b18237..12722f1 100644 --- a/ds5-bridge/companion/src/main/bridge-service.test.ts +++ b/ds5-bridge/companion/src/main/bridge-service.test.ts @@ -510,6 +510,14 @@ describe('BridgeService', () => { expect(service.getSnapshot().message).toBe('No bridge detected'); }); + it('fails closed for a Linux vdsd socket identity', () => { + const service = serviceFixture(); + (service as unknown as { snapshot: { status: { controllerConnected: boolean }; diagnostics: { hidPath: string } } }).snapshot = { + status: { controllerConnected: true }, diagnostics: { hidPath: '/run/vdsd.sock' } + }; + expect(service.getGamingShortcutControllerIdForInputSource('/run/vdsd.sock')).toBeNull(); + }); + it('reports normal firmware when only the game-facing DualSense HID exists', async () => { const service = serviceFixture(); hidMock.state.devicesList = [normalFirmwareDeviceInfo()]; diff --git a/ds5-bridge/companion/src/main/bridge-service.ts b/ds5-bridge/companion/src/main/bridge-service.ts index 8e0a70b..2b72816 100644 --- a/ds5-bridge/companion/src/main/bridge-service.ts +++ b/ds5-bridge/companion/src/main/bridge-service.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { @@ -92,6 +92,7 @@ import { CompanionDebugConfig } from './debug-config'; import { HidDiscoveryClient } from './hid-discovery-client'; import { SettingsStore, normalizeUiScalePercent, normalizeUiThemePreset } from './settings-store'; import { openCompanionTransport, type CompanionTransport } from './companion-transport'; +import { normalizeControllerSysfsPath, resolveHidSourceIdentity } from './controller-source-identity'; const POLL_INTERVAL_MS = 500; const SHORTCUT_POLL_INTERVAL_MS = 50; @@ -3019,6 +3020,24 @@ export class BridgeService extends EventEmitter { return this.getSnapshot(); } + getGamingShortcutControllerId(): string | null { + return this.snapshot.status?.controllerConnected && this.snapshot.diagnostics.hidPath + ? this.snapshot.diagnostics.hidPath : null; + } + + getGamingShortcutControllerIdForInputSource(sourceId: string | null): string | null { + if (!sourceId) return null; + const controllerId = this.getGamingShortcutControllerId(); + if (!controllerId) return null; + if (process.platform === 'linux' && !controllerId.includes('/hidraw')) return null; + // The evdev and bridge nodes are uniquely associated when their sysfs + // physical controller nodes match. No path metadata means no target. + const hidSourceId = resolveHidSourceIdentity(controllerId) ?? (() => { + try { return normalizeControllerSysfsPath(realpathSync(controllerId)); } catch { return null; } + })(); + return hidSourceId === sourceId ? controllerId : null; + } + async testAdaptiveTriggers( mode = this.settingsStore.get().triggerTestMode, target: TriggerTestTarget = 'both' diff --git a/ds5-bridge/companion/src/main/controller-source-identity.test.ts b/ds5-bridge/companion/src/main/controller-source-identity.test.ts new file mode 100644 index 0000000..096b00d --- /dev/null +++ b/ds5-bridge/companion/src/main/controller-source-identity.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { controllerSourceIdsMatch, normalizeControllerSysfsPath } from './controller-source-identity'; + +describe('controller source identity', () => { + it('matches HID and evdev nodes sharing one physical parent', () => { + expect(controllerSourceIdsMatch( + '/sys/devices/pci0000:00/usb1/1-2/input/input7', + '/sys/devices/pci0000:00/usb1/1-2/hidraw/hidraw3' + )).toBe(true); + }); + + it('rejects nodes from different physical parents', () => { + expect(controllerSourceIdsMatch('/sys/devices/controller-a/input/input7', '/sys/devices/controller-b/hidraw/hidraw3')).toBe(false); + }); + + it('fails closed for malformed or missing identities', () => { + expect(normalizeControllerSysfsPath('')).toBeNull(); + expect(normalizeControllerSysfsPath('not-a-sysfs-path/input/input7')).toBeNull(); + expect(controllerSourceIdsMatch('/sys/devices/controller-a/input/input7', '')).toBe(false); + expect(controllerSourceIdsMatch('/sys/devices/controller-a/input/input7', 'malformed')).toBe(false); + }); +}); diff --git a/ds5-bridge/companion/src/main/controller-source-identity.ts b/ds5-bridge/companion/src/main/controller-source-identity.ts new file mode 100644 index 0000000..7a8e920 --- /dev/null +++ b/ds5-bridge/companion/src/main/controller-source-identity.ts @@ -0,0 +1,38 @@ +import { realpathSync } from 'node:fs'; +import path from 'node:path'; + +/** Returns the stable sysfs controller node shared by input and hidraw nodes. */ +export function normalizeControllerSysfsPath(value: string): string | null { + if (!value.startsWith('/')) return null; + const normalized = value.replace(/\\/g, '/').replace(/\/+$|^\/+/, ''); + const withoutClassNode = normalized + .replace(/\/input\/input\d+$/, '') + .replace(/\/hidraw\/hidraw\d+$/, ''); + return withoutClassNode || null; +} + +export function controllerSourceIdsMatch(evdevPath: string, hidPath: string): boolean { + const evdevSourceId = normalizeControllerSysfsPath(evdevPath); + const hidSourceId = normalizeControllerSysfsPath(hidPath); + return evdevSourceId !== null && hidSourceId !== null && evdevSourceId === hidSourceId; +} + +export function resolveEvdevSourceIdentity(devicePath: string): string | null { + const eventNode = path.basename(devicePath); + if (!eventNode.startsWith('event')) return null; + try { + return normalizeControllerSysfsPath(realpathSync(`/sys/class/input/${eventNode}/device`)); + } catch { + return null; + } +} + +export function resolveHidSourceIdentity(hidPath: string): string | null { + const hidNode = path.basename(hidPath); + if (!hidNode.startsWith('hidraw')) return null; + try { + return normalizeControllerSysfsPath(realpathSync(`/sys/class/hidraw/${hidNode}/device`)); + } catch { + return null; + } +} diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts index 014e256..b175de3 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; @@ -116,6 +116,88 @@ describe('EvdevInputReader', () => { reader.stop(); }); + it('merges auxiliary input without clearing the gamepad trigger or buttons', async () => { + const gamepad = new PassThrough(); + const touchpad = new PassThrough(); + const reader = new EvdevInputReader({ + findNodes: () => ['/dev/input/gamepad', '/dev/input/touchpad'], + openStream: (devicePath) => devicePath.endsWith('touchpad') ? touchpad : gamepad, + sourceIdentity: () => 'physical-A' + }); + reader.start(); + const pending = collect(reader, 2); + gamepad.write(Buffer.concat([event(EV_ABS, ABS_RZ, 220), event(EV_KEY, BTN_TL, 1), event(EV_SYN, 0, 0)])); + touchpad.write(Buffer.concat([event(EV_KEY, 272, 1), event(EV_SYN, 0, 0)])); + const [, auxiliary] = await pending; + expect(auxiliary.sourceId).toBe('physical-A'); + expect(auxiliary.r2).toBe(220); + expect(auxiliary.buttons).toEqual(new Set(['l1', 'touchpad'])); + reader.stop(); + }); + + it('removes an auxiliary node without disconnecting the physical source until the last node closes', () => { + const gamepad = new PassThrough(); + const touchpad = new PassThrough(); + const reader = new EvdevInputReader({ + findNodes: () => ['/dev/input/gamepad', '/dev/input/touchpad'], + openStream: (devicePath) => devicePath.endsWith('touchpad') ? touchpad : gamepad, + sourceIdentity: () => 'physical-A' + }); + const disconnect = vi.fn(); reader.on('disconnect', disconnect); reader.start(); + touchpad.emit('close'); + expect(disconnect).not.toHaveBeenCalled(); + gamepad.emit('close'); + expect(disconnect).toHaveBeenCalledOnce(); + reader.stop(); + }); + + it('keeps button and axis state isolated for simultaneous streams', async () => { + const streams = new Map([['A', new PassThrough()], ['B', new PassThrough()]]); + const reader = new EvdevInputReader({ + findNodes: () => ['/dev/input/A', '/dev/input/B'], + openStream: (devicePath) => streams.get(devicePath.endsWith('A') ? 'A' : 'B')!, + sourceIdentity: (devicePath) => devicePath.endsWith('A') ? 'source-A' : 'source-B' + }); + reader.start(); + const pending = collect(reader, 2); + streams.get('A')!.write(Buffer.concat([event(EV_ABS, ABS_RZ, 200), event(EV_KEY, BTN_TL, 1), event(EV_SYN, 0, 0)])); + streams.get('B')!.write(event(EV_SYN, 0, 0)); + const states = await pending; + expect(states[0]).toMatchObject({ sourceId: 'source-A', r2: 200 }); + expect(states[0].buttons).toEqual(new Set(['l1'])); + expect(states[1]).toMatchObject({ sourceId: 'source-B', r2: 0 }); + expect(states[1].buttons).toEqual(new Set()); + reader.stop(); + }); + + it.each(['close', 'end', 'error'] as const)('clears a removed gamepad contribution when its touchpad node remains (%s)', async (removal) => { + const gamepad = new PassThrough(); + const touchpad = new PassThrough(); + const reader = new EvdevInputReader({ + findNodes: () => ['/dev/input/gamepad', '/dev/input/touchpad'], + openStream: (devicePath) => devicePath.endsWith('touchpad') ? touchpad : gamepad, + sourceIdentity: () => 'physical-A' + }); + reader.start(); + const pending = collect(reader, 2); + gamepad.write(Buffer.concat([ + event(EV_ABS, ABS_RZ, 220), + event(EV_KEY, BTN_TL, 1), + event(EV_KEY, 0x13c, 1), + event(EV_KEY, 0x13d, 1), + event(EV_KEY, 0x130, 1), + event(EV_SYN, 0, 0) + ])); + if (removal === 'error') gamepad.emit('error', new Error('gamepad closed with error')); + else gamepad.emit(removal); + const [, cleared] = await pending; + expect(cleared.sourceId).toBe('physical-A'); + expect(cleared.r2).toBe(0); + expect(cleared.buttons).toEqual(new Set()); + touchpad.write(event(EV_SYN, 0, 0)); + reader.stop(); + }); + it('handles packets split across chunk boundaries', async () => { const stream = new PassThrough(); const reader = new EvdevInputReader({ devicePath: '/fake', openStream: () => stream }); @@ -220,10 +302,12 @@ describe('findDualSenseEventNode', () => { // Values captured from real hardware: the DualSense exposes four evdev // nodes whose names all contain "DualSense" — only the gamepad has both // trigger axes (abs bits 2 and 5) and button (key) capabilities. - function addNode(entry: string, name: string, abs: string, key: string): void { - const capsDir = path.join(sysDir, entry, 'device', 'capabilities'); + function addNode(entry: string, name: string, abs: string, key: string, physicalPath?: string): void { + const devicePath = path.join(sysDir, entry, 'device'); + if (physicalPath) { mkdirSync(physicalPath, { recursive: true }); mkdirSync(path.dirname(devicePath), { recursive: true }); symlinkSync(physicalPath, devicePath); } + const capsDir = path.join(physicalPath ?? devicePath, 'capabilities'); mkdirSync(capsDir, { recursive: true }); - writeFileSync(path.join(sysDir, entry, 'device', 'name'), `${name}\n`); + writeFileSync(path.join(physicalPath ?? devicePath, 'name'), `${name}\n`); writeFileSync(path.join(capsDir, 'abs'), `${abs}\n`); writeFileSync(path.join(capsDir, 'key'), `${key}\n`); } @@ -240,8 +324,8 @@ describe('findDualSenseEventNode', () => { it('returns the gamepad and touchpad nodes as one DualSense input group', () => { sysDir = mkdtempSync(path.join(tmpdir(), 'sys-input-')); - addNode('event29', 'Sony Interactive Entertainment DualSense Wireless Controller', '3003f', '7fdb000000000000 0 0 0 0'); - addNode('event31', 'Sony Interactive Entertainment DualSense Wireless Controller Touchpad', '2608000 3', '2420 10000 0 0 0 0'); + addNode('event29', 'Sony Interactive Entertainment DualSense Wireless Controller', '3003f', '7fdb000000000000 0 0 0 0', path.join(sysDir, 'physical', 'input', 'input29')); + addNode('event31', 'Sony Interactive Entertainment DualSense Wireless Controller Touchpad', '2608000 3', '2420 10000 0 0 0 0', path.join(sysDir, 'physical', 'input', 'input31')); expect(findDualSenseEventNodes(sysDir)).toEqual(['/dev/input/event29', '/dev/input/event31']); }); diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.ts b/ds5-bridge/companion/src/main/evdev-input-reader.ts index 19ffcb1..099bb51 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.ts @@ -1,13 +1,13 @@ import { EventEmitter } from 'node:events'; -import { createReadStream, readdirSync, readFileSync } from 'node:fs'; +import { createReadStream, readdirSync, readFileSync, realpathSync } from 'node:fs'; import type { ControllerInputState } from '../shared/trigger-modifier-eval'; +import type { ControllerButton } from '../shared/controller-input'; +import { resolveEvdevSourceIdentity } from './controller-source-identity'; const EVENT_SIZE = 24; const EV_SYN = 0; const EV_KEY = 1; const EV_ABS = 3; -const ABS_X = 0; -const ABS_Y = 1; const ABS_Z = 2; const ABS_RZ = 5; const ABS_HAT0X = 16; @@ -16,18 +16,18 @@ const ABS_HAT0Y = 17; // Codes verified against /usr/include/linux/input-event-codes.h. DualSense's // physical gamepad node reports its extra controls using these generic evdev // names; the node selector below excludes the touchpad/sensor/headset nodes. -const BUTTON_NAMES: Record = { +const BUTTON_NAMES: Record = { 0x130: 'cross', 0x131: 'circle', 0x133: 'triangle', 0x134: 'square', 0x136: 'l1', 0x137: 'r1', + 0x13d: 'l3', + 0x13e: 'r3', 0x13a: 'create', 0x13b: 'options', 0x13c: 'ps', - 0x13d: 'l3', - 0x13e: 'r3', 0x14a: 'touchpad', 272: 'touchpad', 248: 'mute', @@ -70,15 +70,18 @@ export function findDualSenseEventNodes(sysInputDir = '/sys/class/input'): strin } catch { return []; } - let gamepad: string | null = null; - let touchpad: string | null = null; + const groups = new Map(); for (const entry of entries) { if (!entry.startsWith('event')) continue; try { const name = readFileSync(`${sysInputDir}/${entry}/device/name`, 'utf8').trim(); if (!name.toLowerCase().includes('dualsense')) continue; + const devicePath = `${sysInputDir}/${entry}/device`; + const identity = (() => { try { return realpathSync(devicePath).replace(/\/input\/input\d+$/, ''); } catch { return devicePath; } })(); + const group = groups.get(identity) ?? { gamepad: null, touchpad: null }; if (name.toLowerCase().includes('touchpad')) { - touchpad ??= `/dev/input/${entry}`; + group.touchpad ??= `/dev/input/${entry}`; + groups.set(identity, group); continue; } // The DualSense exposes several nodes that all match by name (gamepad, @@ -90,12 +93,13 @@ export function findDualSenseEventNodes(sysInputDir = '/sys/class/input'): strin if ((abs & TRIGGER_ABS_MASK) !== TRIGGER_ABS_MASK) continue; const key = readFileSync(`${sysInputDir}/${entry}/device/capabilities/key`, 'utf8'); if (!hasAnyCapabilityBit(key)) continue; - gamepad ??= `/dev/input/${entry}`; + group.gamepad ??= `/dev/input/${entry}`; + groups.set(identity, group); } catch { // ignore unreadable nodes } } - return gamepad ? [gamepad, ...(touchpad ? [touchpad] : [])] : []; + return [...groups.values()].flatMap(({ gamepad, touchpad }) => gamepad ? [gamepad, ...(touchpad ? [touchpad] : [])] : []); } export function findDualSenseEventNode(sysInputDir = '/sys/class/input'): string | null { @@ -107,6 +111,7 @@ type ReaderOptions = { openStream?: (path: string) => NodeJS.ReadableStream; findNode?: () => string | null; findNodes?: () => string[]; + sourceIdentity?: (path: string) => string | null; }; export class EvdevInputReader extends EventEmitter { @@ -114,14 +119,9 @@ export class EvdevInputReader extends EventEmitter { private readonly openStream: (path: string) => NodeJS.ReadableStream; private readonly findNode: () => string | null; private readonly findNodes: (() => string[]) | null; - private streams: Array<{ stream: NodeJS.ReadableStream; pending: Buffer }> = []; - private l2 = 0; - private r2 = 0; - private lx = 128; - private ly = 128; - private dpadX = 0; - private dpadY = 0; - private buttons = new Set(); + private readonly sourceIdentity: (path: string) => string | null; + private streams: Array<{ stream: NodeJS.ReadableStream; pending: Buffer; removed: boolean; intentionalStop: boolean; group: InputGroup; state: NodeInputState }> = []; + private stopping = false; constructor(options: ReaderOptions = {}) { super(); @@ -129,6 +129,7 @@ export class EvdevInputReader extends EventEmitter { this.openStream = options.openStream ?? ((path) => createReadStream(path)); this.findNode = options.findNode ?? findDualSenseEventNode; this.findNodes = options.findNodes ?? (options.findNode ? null : findDualSenseEventNodes); + this.sourceIdentity = options.sourceIdentity ?? resolveEvdevSourceIdentity; } start(): void { @@ -140,78 +141,109 @@ export class EvdevInputReader extends EventEmitter { this.emit('error', new Error('No DualSense evdev node found.')); return; } + this.stopping = false; + const groups = new Map(); for (const devicePath of devicePaths) { const stream = this.openStream(devicePath); - const source = { stream, pending: Buffer.alloc(0) }; + const sourceId = this.sourceIdentity(devicePath) ?? `evdev:${devicePath}`; + const group = groups.get(sourceId) ?? { sourceId, nodes: new Set() }; + groups.set(sourceId, group); + const state: NodeInputState = { l2: 0, r2: 0, dpadX: 0, dpadY: 0, buttons: new Set() }; + group.nodes.add(state); + const source = { stream, pending: Buffer.alloc(0), removed: false, intentionalStop: false, group, state }; this.streams.push(source); stream.on('data', (chunk: Buffer) => this.consume(source, chunk)); - stream.on('error', (error: Error) => { - this.streams = this.streams.filter((current) => current !== source); - if (this.streams.length === 0) this.emit('error', error); - }); + const remove = (error?: Error) => this.removeStream(source, error); + stream.on('error', remove); + stream.on('close', () => remove()); + stream.on('end', () => remove()); } } stop(): void { - for (const { stream } of this.streams) { + this.stopping = true; + for (const source of this.streams) { + source.intentionalStop = true; + const { stream } = source; if ('destroy' in stream) { (stream as NodeJS.ReadableStream & { destroy(): void }).destroy(); } } this.streams = []; - this.buttons.clear(); - this.l2 = 0; - this.r2 = 0; - this.dpadX = 0; - this.dpadY = 0; } - private consume(source: { pending: Buffer }, chunk: Buffer): void { + getConnectedSourceCount(): number { return new Set(this.streams.map((source) => source.group.sourceId)).size; } + + private removeStream(source: (typeof this.streams)[number], error?: Error): void { + if (source.removed) return; + source.removed = true; + this.streams = this.streams.filter((current) => current !== source); + source.group.nodes.delete(source.state); + const groupRemains = this.streams.some((current) => current.group === source.group); + if (groupRemains && !this.stopping && !source.intentionalStop) this.emit('input', this.snapshot(source.group)); + if (!groupRemains && !this.stopping && !source.intentionalStop) this.emit('disconnect', source.group.sourceId); + if (error && !this.stopping && !source.intentionalStop && this.streams.length === 0) this.emit('error', error); + } + + private consume(source: (typeof this.streams)[number], chunk: Buffer): void { + if (source.removed) return; source.pending = source.pending.length === 0 ? chunk : (Buffer.concat([source.pending, chunk]) as Buffer); while (source.pending.length >= EVENT_SIZE) { const record = source.pending.subarray(0, EVENT_SIZE); source.pending = source.pending.subarray(EVENT_SIZE); - this.handleEvent(record.readUInt16LE(16), record.readUInt16LE(18), record.readInt32LE(20)); + this.handleEvent(source, record.readUInt16LE(16), record.readUInt16LE(18), record.readInt32LE(20)); } } - private handleEvent(type: number, code: number, value: number): void { + private handleEvent(source: (typeof this.streams)[number], type: number, code: number, value: number): void { if (type === EV_ABS) { - if (code === ABS_X) this.lx = value; - if (code === ABS_Y) this.ly = value; - if (code === ABS_Z) this.l2 = value; - if (code === ABS_RZ) this.r2 = value; + if (code === ABS_Z) source.state.l2 = value; + if (code === ABS_RZ) source.state.r2 = value; if (code === ABS_HAT0X || code === ABS_HAT0Y) { - if (code === ABS_HAT0X) this.dpadX = Math.max(-1, Math.min(1, value)); - else this.dpadY = Math.max(-1, Math.min(1, value)); - this.buttons.delete('dpad-left'); - this.buttons.delete('dpad-right'); - this.buttons.delete('dpad-up'); - this.buttons.delete('dpad-down'); - if (this.dpadX < 0) this.buttons.add('dpad-left'); - if (this.dpadX > 0) this.buttons.add('dpad-right'); - if (this.dpadY < 0) this.buttons.add('dpad-up'); - if (this.dpadY > 0) this.buttons.add('dpad-down'); + if (code === ABS_HAT0X) source.state.dpadX = Math.max(-1, Math.min(1, value)); + else source.state.dpadY = Math.max(-1, Math.min(1, value)); + source.state.buttons.delete('dpad-left'); source.state.buttons.delete('dpad-right'); source.state.buttons.delete('dpad-up'); source.state.buttons.delete('dpad-down'); + if (source.state.dpadX < 0) source.state.buttons.add('dpad-left'); + if (source.state.dpadX > 0) source.state.buttons.add('dpad-right'); + if (source.state.dpadY < 0) source.state.buttons.add('dpad-up'); + if (source.state.dpadY > 0) source.state.buttons.add('dpad-down'); } return; } if (type === EV_KEY) { const name = BUTTON_NAMES[code]; if (!name) return; - if (value !== 0) this.buttons.add(name); - else this.buttons.delete(name); + if (value !== 0) source.state.buttons.add(name); + else source.state.buttons.delete(name); return; } if (type === EV_SYN) { - const state: ControllerInputState = { - timestampMs: Date.now(), - l2: this.l2, - r2: this.r2, - lx: this.lx, - ly: this.ly, - buttons: new Set(this.buttons) - }; - this.emit('input', state); + this.emit('input', this.snapshot(source.group)); } } + + private snapshot(group: InputGroup): ControllerInputState { + return { + timestampMs: Date.now(), + sourceId: group.sourceId, + l2: Math.max(...[...group.nodes].map((node) => node.l2), 0), + r2: Math.max(...[...group.nodes].map((node) => node.r2), 0), + lx: 128, + ly: 128, + buttons: new Set([...group.nodes].flatMap((node) => [...node.buttons])) + }; + } } + +type InputGroup = { + sourceId: string; + nodes: Set; +}; + +type NodeInputState = { + l2: number; + r2: number; + dpadX: number; + dpadY: number; + buttons: Set; +}; diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts index e501a8e..1abc1c1 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts @@ -10,8 +10,8 @@ async function flushQueue(): Promise { } class FakeInput extends EventEmitter { - emitInput(buttons: ControllerInputState['buttons'], timestampMs: number): void { - this.emit('input', { buttons, timestampMs, l2: 0, r2: 0 }); + emitInput(buttons: ControllerInputState['buttons'], timestampMs: number, sourceId: string | null = null): void { + this.emit('input', { buttons, timestampMs, sourceId, l2: 0, r2: 0 }); } } @@ -94,6 +94,38 @@ describe('GamingShortcutsCoordinator', () => { coordinator.stop(); }); + it('does not accept shortcut selections from another physical source', async () => { + const input = new FakeInput(); + const execute = vi.fn().mockResolvedValue({ ok: true }); + const settings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, chords: [{ button: 'create' as const, action: { type: 'screenshot', provider: 'auto' as const } }] }; + const coordinator = new GamingShortcutsCoordinator({ + input, settingsStore: { get: () => ({ gamingShortcuts: settings }) }, executor: { execute } as unknown as ActionExecutor, + controllerIdForSource: (sourceId) => sourceId + }); + coordinator.start(); + input.emitInput(new Set(['ps']), 0, 'source-A'); input.emitInput(new Set(), 1, 'source-A'); + vi.advanceTimersByTime(300); + expect(coordinator.getMode().state).toBe('awaiting-selection'); + input.emitInput(new Set(['create']), 400, 'source-B'); input.emitInput(new Set(), 401, 'source-B'); + await flushQueue(); + expect(execute).not.toHaveBeenCalled(); + expect(coordinator.getMode().state).toBe('awaiting-selection'); + coordinator.stop(); + }); + + it('does not emit a result when the source disconnects during deferred execution', async () => { + const input = new FakeInput(); + let resolveExecution!: (value: { ok: true }) => void; + const execute = vi.fn().mockReturnValue(new Promise<{ ok: true }>((resolve) => { resolveExecution = resolve; })); + const settings = { ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, psPressAction: 'open-opends5' as const }; + const coordinator = new GamingShortcutsCoordinator({ input, settingsStore: { get: () => ({ gamingShortcuts: settings }) }, executor: { execute } as unknown as ActionExecutor, controllerIdForSource: (sourceId) => sourceId }); + const result = vi.fn(); coordinator.on('result', result); coordinator.start(); + input.emitInput(new Set(['ps']), 0, 'source-A'); input.emitInput(new Set(), 1, 'source-A'); vi.advanceTimersByTime(300); await flushQueue(); + input.emit('disconnect', 'source-A'); resolveExecution({ ok: true }); await flushQueue(); + expect(result).not.toHaveBeenCalled(); + coordinator.stop(); + }); + it('cancels shortcut mode on Circle and on timeout', () => { const input = new FakeInput(); const notifications = { showShortcutMode: vi.fn(), showShortcutReference: vi.fn(), showActionResult: vi.fn(), showActionError: vi.fn(), dismissShortcutNotification: vi.fn() }; diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts index 199b128..d30aedd 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events'; import type { ControllerInputState } from '../../shared/trigger-modifier-eval'; import { resolveGamingShortcutBindings, type GamingShortcutAction, type GamingShortcutsSettings } from '../../shared/gaming-shortcuts'; -import type { ControllerButton } from '../../shared/controller-input'; +import { isControllerButton, type ControllerButton } from '../../shared/controller-input'; import { ActionExecutor, type ActionExecutionResult } from './action-executor'; import { GestureEngine, type ControllerGesture } from './gesture-engine'; import { actionResult, type GamingShortcutNotifications, type ShortcutBinding } from './notifications'; @@ -9,6 +9,8 @@ import { actionResult, type GamingShortcutNotifications, type ShortcutBinding } export interface InputSource { on(event: 'input', listener: (state: ControllerInputState) => void): this; off(event: 'input', listener: (state: ControllerInputState) => void): this; + on(event: 'disconnect', listener: (sourceId: string) => void): this; + off(event: 'disconnect', listener: (sourceId: string) => void): this; } export interface ShortcutSettingsSource { get(): { gamingShortcuts: GamingShortcutsSettings }; } @@ -18,7 +20,13 @@ export type GamingShortcutMode = | { state: 'awaiting-selection'; activatedAt: number; expiresAt: number } | { state: 'executing'; actionId: string }; -export interface ShortcutExecutionResult { +export interface GamingShortcutInvocation { + controllerId: string; + gesture: ControllerGesture; +} + +export interface ShortcutExecutionResult extends GamingShortcutInvocation { + controllerId: string; gesture: ControllerGesture; action: GamingShortcutAction; result: ActionExecutionResult; @@ -31,14 +39,19 @@ export class GamingShortcutsCoordinator extends EventEmitter { private readonly executor: ActionExecutor; private readonly activeGameId: () => string | null; private readonly notifications: GamingShortcutNotifications | null; + private readonly controllerIdForSource: (sourceId: string | null) => string | null; + private inputControllerId: string | null = null; private readonly onInputBound: (state: ControllerInputState) => void; - private gestureEngine: GestureEngine; + private readonly onDisconnectBound: (sourceId: string) => void; + private gestureEngines = new Map(); private settings: GamingShortcutsSettings; private running = false; private dispatchQueue: Promise = Promise.resolve(); - private previousButtons = new Set(); + private previousButtons = new Map>(); private mode: GamingShortcutMode = { state: 'inactive' }; + private modeSourceKey: string | null = null; private modeTimer: ReturnType | null = null; + private readonly sourceGenerations = new Map(); constructor(options: { input: InputSource; @@ -46,6 +59,7 @@ export class GamingShortcutsCoordinator extends EventEmitter { executor?: ActionExecutor; activeGameId?: () => string | null; notifications?: GamingShortcutNotifications; + controllerIdForSource?: (sourceId: string | null) => string | null; }) { super(); this.input = options.input; @@ -53,30 +67,44 @@ export class GamingShortcutsCoordinator extends EventEmitter { this.executor = options.executor ?? new ActionExecutor(); this.activeGameId = options.activeGameId ?? (() => null); this.notifications = options.notifications ?? null; + this.controllerIdForSource = options.controllerIdForSource ?? (() => null); this.settings = this.settingsStore.get().gamingShortcuts; - this.gestureEngine = this.createGestureEngine(this.settings); this.onInputBound = (state) => this.handleInput(state); + this.onDisconnectBound = (sourceId) => this.disconnectSourceForInput(sourceId); } - start(): void { if (!this.running) { this.running = true; this.input.on('input', this.onInputBound); } } + start(): void { if (!this.running) { this.running = true; this.input.on('input', this.onInputBound); this.input.on('disconnect', this.onDisconnectBound); } } stop(): void { if (!this.running) return; this.running = false; + this.invalidateAllSources(); this.input.off('input', this.onInputBound); - this.gestureEngine.reset(); - this.previousButtons.clear(); + this.input.off('disconnect', this.onDisconnectBound); + for (const engine of this.gestureEngines.values()) engine.reset(); + this.gestureEngines.clear(); this.previousButtons.clear(); this.exitShortcutMode(); } - disconnect(): void { this.previousButtons.clear(); this.gestureEngine.reset(); this.exitShortcutMode(); } + disconnect(): void { this.invalidateAllSources(); this.inputControllerId = null; for (const engine of this.gestureEngines.values()) engine.reset(); this.gestureEngines.clear(); this.previousButtons.clear(); this.exitShortcutMode(); } + + disconnectSourceForInput(sourceId: string): void { + const sourceKey = sourceId || 'legacy-input'; + this.sourceGenerations.set(sourceKey, (this.sourceGenerations.get(sourceKey) ?? 0) + 1); + const controllerId = this.controllerIdForSource(sourceId); + this.gestureEngines.get(sourceKey)?.reset(); + this.gestureEngines.delete(sourceKey); + this.previousButtons.delete(sourceKey); + if (this.modeSourceKey === sourceKey) this.exitShortcutMode(); + if (this.inputControllerId === controllerId) this.inputControllerId = null; + } reload(): void { const next = this.settingsStore.get().gamingShortcuts; const wasRunning = this.running; if (wasRunning) this.stop(); this.settings = next; - this.gestureEngine = this.createGestureEngine(next); + this.gestureEngines.clear(); if (wasRunning) this.start(); } @@ -87,52 +115,61 @@ export class GamingShortcutsCoordinator extends EventEmitter { } private handleInput(state: ControllerInputState): void { - this.gestureEngine.update(state.buttons, state.timestampMs); - const justPressed = [...state.buttons].filter((button) => !this.previousButtons.has(button)); - this.previousButtons = new Set(state.buttons); - if (!this.settings.enabled || this.mode.state !== 'awaiting-selection') return; + const sourceKey = state.sourceId ?? 'legacy-input'; + const sourceControllerId = this.controllerIdForSource(state.sourceId ?? null); + this.inputControllerId = sourceControllerId; + const engine = this.gestureEngines.get(sourceKey) ?? this.createGestureEngine(this.settings, sourceKey); + this.gestureEngines.set(sourceKey, engine); + const buttons = new Set([...state.buttons].filter(isControllerButton)); + engine.update(buttons, state.timestampMs, sourceControllerId); + const previousButtons = this.previousButtons.get(sourceKey) ?? new Set(); + const justPressed = [...buttons].filter((button) => !previousButtons.has(button)); + this.previousButtons.set(sourceKey, buttons); + if (!this.settings.enabled || this.mode.state !== 'awaiting-selection' || this.modeSourceKey !== sourceKey) return; for (const button of justPressed) { if (button === 'ps') { this.exitShortcutMode(); return; } if (button === 'circle') { this.exitShortcutMode(); return; } const action = this.actionForButton(button); - if (action) { this.exitShortcutMode(); this.dispatchAction(action, { type: 'chord', modifier: 'ps', button }); return; } + if (action) { this.exitShortcutMode(); this.dispatchAction(action, { type: 'chord', modifier: 'ps', button }, sourceControllerId, sourceKey); return; } } } - private createGestureEngine(settings: GamingShortcutsSettings): GestureEngine { + private createGestureEngine(settings: GamingShortcutsSettings, sourceKey: string): GestureEngine { return new GestureEngine({ doublePressWindowMs: settings.doublePressWindowMs, longPressThresholdMs: settings.longPressThresholdMs, chordWindowMs: settings.chordWindowMs, - emit: (gesture) => this.handleGesture(gesture) + emit: (gesture, sourceControllerId) => this.handleGesture(gesture, sourceControllerId, sourceKey) }); } - private handleGesture(gesture: ControllerGesture): void { + private handleGesture(gesture: ControllerGesture, sourceControllerId: string | null | undefined, sourceKey: string): void { + sourceControllerId ??= null; if (!this.settings.enabled) return; + if (this.mode.state === 'awaiting-selection' && this.modeSourceKey !== sourceKey) return; if (gesture.type === 'chord') { if (!this.settings.directChordsEnabled) return; this.exitShortcutMode(); const action = this.actionFor(gesture); - if (action) this.dispatchAction(action, gesture); + if (action) this.dispatchAction(action, gesture, sourceControllerId, sourceKey); return; } if (gesture.button !== 'ps') return; if (gesture.type === 'single-press') { const action = this.bindings().singlePress; if (action.type !== 'none' && action.type !== 'passthrough') { - this.dispatchAction(action, gesture); + this.dispatchAction(action, gesture, sourceControllerId, sourceKey); return; } switch (this.settings.psPressAction) { case 'show-shortcut-reference': void this.showShortcutReference(); return; - case 'enter-shortcut-mode': if (this.settings.shortcutModeEnabled) { this.enterShortcutMode(); return; } return; - case 'open-opends5': this.dispatchAction({ type: 'open-opends5' }, gesture); return; + case 'enter-shortcut-mode': if (this.settings.shortcutModeEnabled) { this.enterShortcutMode(sourceKey); return; } return; + case 'open-opends5': this.dispatchAction({ type: 'open-opends5' }, gesture, sourceControllerId, sourceKey); return; default: return; } } const action = gesture.type === 'double-press' ? this.bindings().doublePress : this.bindings().longPress; - if (action.type !== 'none' && action.type !== 'passthrough') this.dispatchAction(action, gesture); + if (action.type !== 'none' && action.type !== 'passthrough') this.dispatchAction(action, gesture, sourceControllerId, sourceKey); } private bindings() { return resolveGamingShortcutBindings(this.settings, this.activeGameId()); } @@ -153,9 +190,10 @@ export class GamingShortcutsCoordinator extends EventEmitter { ]; } - private enterShortcutMode(): void { + private enterShortcutMode(sourceKey: string): void { this.clearModeTimer(); const now = Date.now(); + this.modeSourceKey = sourceKey; this.mode = { state: 'awaiting-selection', activatedAt: now, expiresAt: now + this.settings.shortcutModeTimeoutMs }; this.modeTimer = setTimeout(() => this.exitShortcutMode(), this.settings.shortcutModeTimeoutMs); void this.notifications?.showShortcutMode(this.shortcutBindings(), this.settings.shortcutModeTimeoutMs); @@ -166,27 +204,35 @@ export class GamingShortcutsCoordinator extends EventEmitter { this.clearModeTimer(); if (this.mode.state === 'inactive') return; this.mode = { state: 'inactive' }; + this.modeSourceKey = null; void this.notifications?.dismissShortcutNotification(); this.emit('mode', this.mode); } private clearModeTimer(): void { if (this.modeTimer) clearTimeout(this.modeTimer); this.modeTimer = null; } + private invalidateAllSources(): void { + for (const sourceKey of this.gestureEngines.keys()) { + this.sourceGenerations.set(sourceKey, (this.sourceGenerations.get(sourceKey) ?? 0) + 1); + } + this.sourceGenerations.set('legacy-input', (this.sourceGenerations.get('legacy-input') ?? 0) + 1); + } + private async showShortcutReference(): Promise { await this.notifications?.showShortcutReference(this.shortcutBindings()); } - private dispatchAction(action: GamingShortcutAction, gesture: ControllerGesture): void { + private dispatchAction(action: GamingShortcutAction, gesture: ControllerGesture, sourceControllerId = this.inputControllerId, sourceKey = 'legacy-input'): void { if (action.type === 'none' || action.type === 'passthrough') return; + const generation = this.sourceGenerations.get(sourceKey) ?? 0; this.dispatchQueue = this.dispatchQueue.then(async () => { const result = await this.executor.execute(action); + if ((this.sourceGenerations.get(sourceKey) ?? 0) !== generation) return; const notification = actionResult(action, result); if (result.ok) { if (this.settings.showSuccessNotifications) await this.notifications?.showActionResult(notification); - } else if (this.settings.showErrorNotifications) { - await this.notifications?.showActionError(notification); - } - this.emit('result', { gesture, action, result } satisfies ShortcutExecutionResult); + } else if (this.settings.showErrorNotifications) await this.notifications?.showActionError(notification); + this.emit('result', { controllerId: sourceControllerId ?? '', gesture, action, result } satisfies ShortcutExecutionResult); }).catch((error: unknown) => { this.emit('error', error instanceof Error ? error : new Error(String(error))); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts index f75beac..8b2ac49 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts @@ -13,7 +13,7 @@ export interface GestureEngineOptions { longPressThresholdMs?: number; chordWindowMs?: number; scheduler?: GestureScheduler; - emit?: (gesture: ControllerGesture) => void; + emit?: (gesture: ControllerGesture, sourceControllerId?: string | null) => void; } const DEFAULTS = { doublePressWindowMs: 300, longPressThresholdMs: 650, chordWindowMs: 150 }; @@ -24,7 +24,7 @@ const SECONDARY = new Set([ export class GestureEngine { private readonly scheduler: GestureScheduler; - private readonly emitGesture: (gesture: ControllerGesture) => void; + private readonly emitGesture: NonNullable; private readonly doubleWindow: number; private readonly longThreshold: number; private readonly chordWindow: number; @@ -37,6 +37,7 @@ export class GestureEngine { private chordEmitted = false; private chordCycle = false; private psPressAt: number | null = null; + private sourceControllerId: string | null | undefined; constructor(options: GestureEngineOptions = {}) { this.doubleWindow = options.doublePressWindowMs ?? DEFAULTS.doublePressWindowMs; @@ -48,14 +49,15 @@ export class GestureEngine { this.emitGesture = options.emit ?? (() => undefined); } - update(buttons: ReadonlySet, now = Date.now()): void { + update(buttons: ReadonlySet, now = Date.now(), sourceControllerId?: string | null): void { + this.sourceControllerId = sourceControllerId; const next = new Set([...buttons].filter((button) => button === 'ps' || SECONDARY.has(button))); for (const button of next) if (!this.held.has(button)) this.press(button, now); for (const button of this.held) if (!next.has(button)) this.release(button); this.held = next; } - reset(): void { this.clearTimers(); this.held.clear(); this.pressedAt.clear(); this.longEmitted = false; this.chordEmitted = false; this.chordCycle = false; this.doublePressCycle = false; this.psPressAt = null; } + reset(): void { this.clearTimers(); this.held.clear(); this.pressedAt.clear(); this.longEmitted = false; this.chordEmitted = false; this.chordCycle = false; this.doublePressCycle = false; this.psPressAt = null; this.sourceControllerId = undefined; } stop(): void { this.reset(); } private press(button: ControllerButton, now: number): void { @@ -67,7 +69,7 @@ export class GestureEngine { if (this.held.has('ps') && !this.chordEmitted) { this.doublePressCycle = false; this.longEmitted = true; - this.emitGesture({ type: 'long-press', button: 'ps', durationMs: this.longThreshold }); + this.emit({ type: 'long-press', button: 'ps', durationMs: this.longThreshold }, this.sourceControllerId); } }, this.longThreshold); for (const secondary of this.held) { @@ -82,13 +84,20 @@ export class GestureEngine { if (this.held.has('ps')) this.chord(button); } - private chord(button: ControllerButton): void { if (this.chordEmitted) return; this.chordEmitted = true; this.chordCycle = true; this.doublePressCycle = false; this.clearLongTimer(); this.clearSingleTimer(); this.emitGesture({ type: 'chord', modifier: 'ps', button }); } + private chord(button: ControllerButton): void { if (this.chordEmitted) return; this.chordEmitted = true; this.chordCycle = true; this.doublePressCycle = false; this.clearLongTimer(); this.clearSingleTimer(); this.emit({ type: 'chord', modifier: 'ps', button }, this.sourceControllerId); } private release(button: ControllerButton): void { if (button !== 'ps') { this.pressedAt.delete(button); if (this.held.has('ps')) this.chordEmitted = false; return; } this.clearLongTimer(); if (this.chordCycle || this.longEmitted) return; - if (this.doublePressCycle) { this.doublePressCycle = false; this.emitGesture({ type: 'double-press', button: 'ps' }); } - else this.singleTimer = this.scheduler.setTimeout(() => { this.singleTimer = null; this.emitGesture({ type: 'single-press', button: 'ps' }); }, this.doubleWindow); + if (this.doublePressCycle) { this.doublePressCycle = false; this.emit({ type: 'double-press', button: 'ps' }, this.sourceControllerId); } + else { + const sourceControllerId = this.sourceControllerId; + this.singleTimer = this.scheduler.setTimeout(() => { this.singleTimer = null; this.emit({ type: 'single-press', button: 'ps' }, sourceControllerId); }, this.doubleWindow); + } + } + private emit(gesture: ControllerGesture, sourceControllerId: string | null | undefined): void { + if (sourceControllerId === undefined) this.emitGesture(gesture); + else this.emitGesture(gesture, sourceControllerId); } private clearLongTimer(): void { if (this.longTimer) { this.scheduler.clearTimeout(this.longTimer); this.longTimer = null; } } private clearSingleTimer(): void { if (this.singleTimer) { this.scheduler.clearTimeout(this.singleTimer); this.singleTimer = null; } } diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index e014049..d5617ec 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -1361,9 +1361,6 @@ function registerIpc( } }); ipcMain.handle('bridge:getTriggerProfileEngineStatus', () => triggerProfileEngine.getStatus()); - ipcMain.handle('bridge:selectTriggerProfileState', (_event, name: string) => ( - triggerProfileEngine.selectState(String(name)) - )); ipcMain.handle('bridge:previewTriggerProfileDraft', async (_event, triggers: DraftPreviewTriggers | null) => { await triggerProfileEngine.setDraftPreview(triggers); return triggerProfileEngine.getStatus(); @@ -1736,7 +1733,8 @@ app.whenReady().then(async () => { return result.status === 0 ? { ok: true as const } : { ok: false, reason: 'unavailable' as const }; } }), - notifications: gamingShortcutNotifications(bridgeService) + notifications: gamingShortcutNotifications(bridgeService), + controllerIdForSource: (sourceId) => bridgeService?.getGamingShortcutControllerIdForInputSource(sourceId) ?? null }); gamingShortcutsCoordinator.on('error', (error) => { console.error('[gaming-shortcuts] action error', error); @@ -1772,11 +1770,6 @@ app.whenReady().then(async () => { if (persistedEngineState.enabled) { void triggerProfileEngine.setEnabled(true); } - triggerProfileEngine.on('stickSample', (sample: { lx: number; ly: number }) => { - for (const window of BrowserWindow.getAllWindows()) { - window.webContents.send('bridge:stickSample', sample); - } - }); triggerProfileEngine.on('status', (status: EngineStatus) => { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('bridge:triggerProfileEngineStatus', status); diff --git a/ds5-bridge/companion/src/renderer/App.tsx b/ds5-bridge/companion/src/renderer/App.tsx index 527e8f7..350d9ce 100644 --- a/ds5-bridge/companion/src/renderer/App.tsx +++ b/ds5-bridge/companion/src/renderer/App.tsx @@ -10434,6 +10434,7 @@ export function App() { active={activeControlTab === 'gaming-shortcuts'} profiles={triggerProfiles} activeProfileId={triggerProfileEngineStatus?.activeProfileId ?? null} + snapshot={snapshot} />
{ + it('keeps the renderer preview static and binds only connection state from the existing snapshot', () => { + expect(component).toContain('snapshot?: BridgeSnapshot | null'); + expect(component).toContain('Static controller preview'); + expect(component).toContain('Live hardware illumination is unavailable here'); + expect(component).toContain('hardware output is never sent by this preview'); + expect(component).toContain('controllerConnected'); + expect(component).toContain('controllerImage'); + expect(component).not.toContain('Live preview'); + expect(component).not.toContain('onController'); + expect(component).not.toContain('buttonState'); + expect(component).not.toContain('testHaptics'); + expect(component).not.toContain('testClassicRumble'); + }); + + it('binds persisted settings and cleans up the preview notification timer', () => { + expect(component).toContain('getGamingShortcutsSettings().then(setSettings)'); + expect(component).toContain('saveGamingShortcutsSettings(next)'); + expect(component).toContain('return () => window.clearTimeout(timeout);'); + expect(component).toContain('previewOpened'); + }); + + it('renders every supported shortcut action and secondary controller button', () => { + expect(component).toContain("value: 'quit-active-game'"); + expect(component).toContain("value: 'custom-executable'"); + expect(component).toContain("value: 'l1'"); + expect(component).toContain("value: 'r3'"); + }); + + it('has a responsive rail, sticky preview, and reduced-motion fallback', () => { + expect(css).toContain('.gaming-shortcuts-layout {'); + expect(css).toContain('.gaming-shortcuts-preview {'); + expect(css).toContain('@media (prefers-reduced-motion: reduce)'); + expect(css).toContain('.gaming-controller-stage img'); + }); +}); diff --git a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx index fdc53cf..67b86f9 100644 --- a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx +++ b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import controllerImage from '../../../assets/controllers/dualsense-edge-front.svg'; import type { ProviderCapabilities } from '../main/gaming-shortcuts/providers/detect-environment'; import { DEFAULT_GAMING_SHORTCUTS_SETTINGS, @@ -6,26 +7,32 @@ import { type GamingShortcutsSettings } from '../shared/gaming-shortcuts'; import type { ControllerButton } from '../shared/controller-input'; +import type { BridgeSnapshot } from '../shared/types'; import type { TriggerProfile } from '../shared/trigger-profiles'; const BUTTONS: Array<{ value: Exclude; label: string }> = [ { value: 'create', label: 'Create' }, { value: 'options', label: 'Options' }, { value: 'touchpad', label: 'Touchpad' }, { value: 'mute', label: 'Mute' }, { value: 'cross', label: 'Cross' }, { value: 'circle', label: 'Circle' }, { value: 'square', label: 'Square' }, { value: 'triangle', label: 'Triangle' }, - { value: 'dpad-up', label: 'D-pad Up' }, { value: 'dpad-down', label: 'D-pad Down' }, { value: 'dpad-left', label: 'D-pad Left' }, { value: 'dpad-right', label: 'D-pad Right' } + { value: 'l1', label: 'L1' }, { value: 'r1', label: 'R1' }, { value: 'l2', label: 'L2' }, { value: 'r2', label: 'R2' }, + { value: 'l3', label: 'L3' }, { value: 'r3', label: 'R3' }, { value: 'dpad-up', label: 'D-pad Up' }, { value: 'dpad-down', label: 'D-pad Down' }, + { value: 'dpad-left', label: 'D-pad Left' }, { value: 'dpad-right', label: 'D-pad Right' } ]; type ActionKind = GamingShortcutAction['type']; - const ACTIONS: Array<{ value: ActionKind; label: string }> = [ { value: 'none', label: 'No action' }, { value: 'passthrough', label: 'Pass through only' }, { value: 'open-opends5', label: 'Open or focus OpenDS5' }, - { value: 'launch-app', label: 'Open or focus an application' }, { value: 'focus-app', label: 'Focus application by ID' }, - { value: 'screenshot', label: 'Take screenshot' }, { value: 'recording-toggle', label: 'Toggle recording' }, - { value: 'performance-hud-toggle', label: 'Toggle performance HUD' }, { value: 'volume', label: 'Volume' }, - { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'on-screen-keyboard', label: 'Open on-screen keyboard' }, - { value: 'switch-application', label: 'Switch application' }, { value: 'quit-active-game', label: 'Quit active game (confirm)' }, - { value: 'custom-executable', label: 'Custom executable' } + { value: 'launch-app', label: 'Open or focus an application' }, { value: 'focus-app', label: 'Focus application by ID' }, { value: 'screenshot', label: 'Take screenshot' }, + { value: 'recording-toggle', label: 'Toggle recording' }, { value: 'performance-hud-toggle', label: 'Toggle performance HUD' }, { value: 'volume', label: 'Volume' }, + { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'on-screen-keyboard', label: 'Open on-screen keyboard' }, { value: 'switch-application', label: 'Switch application' }, + { value: 'quit-active-game', label: 'Quit active game (confirm)' }, { value: 'custom-executable', label: 'Custom executable' } ]; +const GESTURES = [ + ['singlePress', 'Press', 'Tap PS once'], + ['doublePress', 'Double press', 'Tap PS twice'], + ['longPress', 'Hold', 'Hold PS'] +] as const; + function actionForKind(kind: ActionKind): GamingShortcutAction { switch (kind) { case 'volume': return { type: 'volume', direction: 'up' }; @@ -43,6 +50,8 @@ function actionForKind(kind: ActionKind): GamingShortcutAction { } function actionLabel(action: GamingShortcutAction): string { + if (action.type === 'volume') return `Volume ${action.direction}`; + if (action.type === 'switch-application') return `Switch ${action.direction}`; return ACTIONS.find((item) => item.value === action.type)?.label ?? 'No action'; } @@ -50,121 +59,70 @@ function isProviderAction(action: GamingShortcutAction): action is Extract void; - capabilities: ProviderCapabilities | null; -}) { - const providerOptions = action.type === 'screenshot' ? capabilities?.screenshot ?? [] - : action.type === 'recording-toggle' ? capabilities?.recording ?? [] - : action.type === 'performance-hud-toggle' ? capabilities?.hud ?? [] : capabilities?.keyboard ?? []; +function ActionEditor({ action, onChange, capabilities }: { action: GamingShortcutAction; onChange: (next: GamingShortcutAction) => void; capabilities: ProviderCapabilities | null }) { + const providerOptions = action.type === 'screenshot' ? capabilities?.screenshot ?? [] : action.type === 'recording-toggle' ? capabilities?.recording ?? [] : action.type === 'performance-hud-toggle' ? capabilities?.hud ?? [] : capabilities?.keyboard ?? []; + const textInput = (label: string, value: string, change: (value: string) => void) => change(event.target.value)} />; return
- - {action.type === 'volume' && } - {action.type === 'switch-application' && } - {isProviderAction(action) && } - {action.type === 'custom-executable' && <> - onChange({ ...action, executable: event.target.value })} /> - onChange({ ...action, args: event.target.value.trim() ? event.target.value.trim().split(/\s+/) : [] })} /> - } - {action.type === 'launch-app' && <> - onChange({ ...action, executable: event.target.value })} /> - onChange({ ...action, args: event.target.value.trim() ? event.target.value.trim().split(/\s+/) : [] })} /> - } - {action.type === 'focus-app' && onChange({ ...action, appId: event.target.value })} />} + + {action.type === 'volume' && } + {action.type === 'switch-application' && } + {isProviderAction(action) && } + {(action.type === 'launch-app' || action.type === 'custom-executable') && <>{textInput('Executable', action.executable, (executable) => onChange({ ...action, executable }))}{textInput('Arguments', action.args.join(' '), (value) => onChange({ ...action, args: value.trim() ? value.trim().split(/\s+/) : [] }))}} + {action.type === 'focus-app' && textInput('Application ID', action.appId, (appId) => onChange({ ...action, appId }))}
; } -export function GamingShortcuts({ active, profiles, activeProfileId }: { active: boolean; profiles: TriggerProfile[]; activeProfileId: string | null }) { +function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: () => void }) { + return ; +} + +function ControllerPreview({ settings, status, selectedGameName }: { settings: GamingShortcutsSettings; status: BridgeSnapshot | null; selectedGameName?: string }) { + const connection = status?.state !== 'connected' ? 'Bridge offline' : status.status?.controllerConnected ? 'Controller connected' : 'Controller not connected'; + const type = status?.status?.controllerType === 'dualsense-edge' ? 'DualSense Edge' : status?.status?.controllerType === 'dualsense' ? 'DualSense' : 'DualSense controller'; + return
+
Static controller preview

{selectedGameName ? `${selectedGameName} shortcuts` : 'Your PS button layout'}

+
+ {`${type} +
PS{actionLabel(settings.singlePress)}Press
+
+
+
PS ×2{actionLabel(settings.doublePress)}
PS hold{actionLabel(settings.longPress)}
+
+

Preview updates as you configure actions. Live hardware illumination is unavailable here; hardware output is never sent by this preview.

+
; +} + +export function GamingShortcuts({ active, profiles, activeProfileId, snapshot = null }: { active: boolean; profiles: TriggerProfile[]; activeProfileId: string | null; snapshot?: BridgeSnapshot | null }) { const [settings, setSettings] = useState(DEFAULT_GAMING_SHORTCUTS_SETTINGS); const [capabilities, setCapabilities] = useState(null); const [status, setStatus] = useState(''); const [selectedGameId, setSelectedGameId] = useState(null); + const [previewOpened, setPreviewOpened] = useState(false); const gameProfiles = profiles.filter((profile) => profile.id !== 'default'); + useEffect(() => { void window.bridge.getGamingShortcutsSettings().then(setSettings); void window.bridge.getGamingShortcutProviders().then(setCapabilities); }, []); useEffect(() => { - void window.bridge.getGamingShortcutsSettings().then(setSettings); - void window.bridge.getGamingShortcutProviders().then(setCapabilities); - }, []); - useEffect(() => { - if (selectedGameId && gameProfiles.some((profile) => profile.id === selectedGameId)) return; - setSelectedGameId(activeProfileId && gameProfiles.some((profile) => profile.id === activeProfileId) ? activeProfileId : gameProfiles[0]?.id ?? null); - }, [activeProfileId, profiles, selectedGameId]); - const update = (next: GamingShortcutsSettings) => { - setSettings(next); - setStatus('Saving…'); - void window.bridge.saveGamingShortcutsSettings(next).then((saved) => { setSettings(saved); setStatus('Saved'); }).catch(() => setStatus('Could not save settings')); - }; - const setBinding = (key: 'singlePress' | 'doublePress' | 'longPress', action: GamingShortcutAction) => update({ ...settings, [key]: action }); - const addChord = (existing: GamingShortcutsSettings['chords']) => { - const button = BUTTONS.find((candidate) => !existing.some((chord) => chord.button === candidate.value))?.value; - return button ? [...existing, { button, action: { type: 'none' } as GamingShortcutAction }] : existing; - }; + if (!previewOpened) return; + const timeout = window.setTimeout(() => setPreviewOpened(false), 2600); + return () => window.clearTimeout(timeout); + }, [previewOpened]); + useEffect(() => { if (selectedGameId && gameProfiles.some((profile) => profile.id === selectedGameId)) return; setSelectedGameId(activeProfileId && gameProfiles.some((profile) => profile.id === activeProfileId) ? activeProfileId : gameProfiles[0]?.id ?? null); }, [activeProfileId, profiles, selectedGameId]); + const update = (next: GamingShortcutsSettings) => { setSettings(next); setStatus('Saving…'); void window.bridge.saveGamingShortcutsSettings(next).then((saved) => { setSettings(saved); setStatus('Saved'); }).catch(() => setStatus('Could not save settings')); }; + const addChord = (existing: GamingShortcutsSettings['chords']) => { const button = BUTTONS.find((candidate) => !existing.some((chord) => chord.button === candidate.value))?.value; return button ? [...existing, { button, action: { type: 'none' } as GamingShortcutAction }] : existing; }; const selectedOverride = selectedGameId ? settings.perGameOverrides[selectedGameId] : undefined; - const setGameOverride = (next: NonNullable) => { - if (!selectedGameId) return; - const perGameOverrides = { ...settings.perGameOverrides, [selectedGameId]: next }; - update({ ...settings, perGameOverrides }); - }; - const clearGameOverride = () => { - if (!selectedGameId) return; - const perGameOverrides = { ...settings.perGameOverrides }; - delete perGameOverrides[selectedGameId]; - update({ ...settings, perGameOverrides }); - }; - const defaultPreset = useMemo(() => ({ ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, psPressAction: 'enter-shortcut-mode' as const, - directChordsEnabled: true, shortcutModeEnabled: true, - doublePress: { type: 'switch-application', direction: 'previous' } as GamingShortcutAction, - chords: [ - { button: 'create' as const, action: { type: 'screenshot', provider: 'auto' } as GamingShortcutAction }, - { button: 'triangle' as const, action: { type: 'performance-hud-toggle', provider: 'auto' } as GamingShortcutAction }, - { button: 'mute' as const, action: { type: 'microphone-mute-toggle' } as GamingShortcutAction }, - { button: 'dpad-up' as const, action: { type: 'volume', direction: 'up' } as GamingShortcutAction }, - { button: 'dpad-down' as const, action: { type: 'volume', direction: 'down' } as GamingShortcutAction }, - { button: 'dpad-left' as const, action: { type: 'switch-application', direction: 'previous' } as GamingShortcutAction }, - { button: 'dpad-right' as const, action: { type: 'switch-application', direction: 'next' } as GamingShortcutAction } - ,{ button: 'touchpad' as const, action: { type: 'on-screen-keyboard', provider: 'auto' } as GamingShortcutAction } - ] - }), []); + const setGameOverride = (next: NonNullable) => { if (selectedGameId) update({ ...settings, perGameOverrides: { ...settings.perGameOverrides, [selectedGameId]: next } }); }; + const clearGameOverride = () => { if (selectedGameId) { const perGameOverrides = { ...settings.perGameOverrides }; delete perGameOverrides[selectedGameId]; update({ ...settings, perGameOverrides }); } }; + const defaultPreset = useMemo(() => ({ ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, psPressAction: 'enter-shortcut-mode' as const, directChordsEnabled: true, shortcutModeEnabled: true, doublePress: { type: 'switch-application', direction: 'previous' } as GamingShortcutAction, chords: [{ button: 'create' as const, action: { type: 'screenshot', provider: 'auto' } as GamingShortcutAction }, { button: 'triangle' as const, action: { type: 'performance-hud-toggle', provider: 'auto' } as GamingShortcutAction }, { button: 'mute' as const, action: { type: 'microphone-mute-toggle' } as GamingShortcutAction }, { button: 'dpad-up' as const, action: { type: 'volume', direction: 'up' } as GamingShortcutAction }, { button: 'dpad-down' as const, action: { type: 'volume', direction: 'down' } as GamingShortcutAction }, { button: 'dpad-left' as const, action: { type: 'switch-application', direction: 'previous' } as GamingShortcutAction }, { button: 'dpad-right' as const, action: { type: 'switch-application', direction: 'next' } as GamingShortcutAction }] }), []); + const selectedGameName = gameProfiles.find((profile) => profile.id === selectedGameId)?.name; return
-

Gaming Shortcuts

Turn the PS button into a configurable Linux gaming shortcut layer.

{status}
-
-
Enable Gaming ShortcutsSteam or a game may also receive the PS button.
-
- - - - - - - -
-
- {([['singlePress', 'PS button · Press'], ['doublePress', 'PS button · Double press'], ['longPress', 'PS button · Hold']] as const).map(([key, label]) => )} -
-
-
PS chords

Hold PS and press another controller button. Direct chords take precedence over PS press gestures.

- {settings.chords.length === 0 ?

No chords configured.

: settings.chords.map((chord, index) =>
{ const chords = [...settings.chords]; chords[index] = { ...chord, action }; update({ ...settings, chords }); }} capabilities={capabilities} />
)} -
-
Timing
-
Per-game overrides{selectedOverride && }
- {gameProfiles.length === 0 ?

Create a Game Profile to customize shortcuts for a specific game.

: <> - - {selectedGameId && <> -

Unset actions inherit the global Gaming Shortcuts binding.

- {([['singlePress', 'PS button · Press'], ['doublePress', 'PS button · Double press'], ['longPress', 'PS button · Hold']] as const).map(([key, label]) => )} -
PS chords
- {(selectedOverride?.chords ?? []).map((chord, index) =>
{ const chords = [...(selectedOverride?.chords ?? [])]; chords[index] = { ...chord, action }; setGameOverride({ ...selectedOverride, chords }); }} capabilities={capabilities} />
)} - } - } -
-
Environment: {capabilities?.environment ?? 'Detecting…'}Screenshot: {capabilities?.screenshot.join(', ') || 'Unavailable'}Recording: {capabilities?.recording.join(', ') || 'Unavailable'}HUD: {capabilities?.hud.join(', ') || 'Unavailable'}
+
Controller customization

Gaming Shortcuts

Turn the PS button into a configurable Linux gaming shortcut layer.

{status}
+
+
Enable Gaming Shortcuts

Use PS gestures and chords for quick actions while gaming.

update({ ...settings, enabled: !settings.enabled })} />
+
PS button gestures

Choose what each gesture does. Actions use the existing safe execution and notification paths.

{GESTURES.map(([key, label, hint]) =>
{label}{hint}
update({ ...settings, [key]: action })} capabilities={capabilities} />
)}
+
Shortcut mode

Choose the single PS press behavior used when no direct chord matches.

+
PS chords

Hold PS and press another button. Direct chords take precedence over PS gestures.

{settings.chords.length === 0 ?

No chords configured.

: settings.chords.map((chord, index) =>
{ const chords = [...settings.chords]; chords[index] = { ...chord, action }; update({ ...settings, chords }); }} capabilities={capabilities} />
)}
+
Timing & notifications

Fine-tune gesture recognition and feedback.

+
Per-game overrides

Override gesture and chord bindings for a game profile.

{selectedOverride && }
{gameProfiles.length === 0 ?

Create a Game Profile to customize shortcuts for a specific game.

: }{selectedGameId && <>
{GESTURES.map(([key, label]) =>
{label}Inherits global setting until changed
setGameOverride({ ...selectedOverride, [key]: action })} capabilities={capabilities} />
)}
Game PS chords
{(selectedOverride?.chords ?? []).map((chord, index) =>
{ const chords = [...(selectedOverride?.chords ?? [])]; chords[index] = { ...chord, action }; setGameOverride({ ...selectedOverride, chords }); }} capabilities={capabilities} />
)}}
+
{capabilities?.environment ?? 'Detecting environment…'}Screenshot: {capabilities?.screenshot.join(', ') || 'Unavailable'}Recording: {capabilities?.recording.join(', ') || 'Unavailable'}HUD: {capabilities?.hud.join(', ') || 'Unavailable'}
+ {previewOpened &&
Shortcut notification preview sent.
}
; } diff --git a/ds5-bridge/companion/src/renderer/styles.css b/ds5-bridge/companion/src/renderer/styles.css index 5020bad..b6d9531 100644 --- a/ds5-bridge/companion/src/renderer/styles.css +++ b/ds5-bridge/companion/src/renderer/styles.css @@ -2465,6 +2465,203 @@ button { padding: 18px 20px; } +.gaming-shortcuts-page { + overflow: auto; + padding-bottom: 28px; +} + +.gaming-shortcuts-page .eyebrow { + color: var(--accent-color); + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.gaming-shortcuts-layout { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(280px, .8fr); + align-items: start; + gap: 16px; +} + +.gaming-shortcuts-config, +.gaming-shortcuts-rail { + display: grid; + align-content: start; + gap: 16px; + min-width: 0; +} + +.gaming-shortcuts-overview, +.gaming-preview-heading, +.gaming-gesture-row, +.gaming-shortcut-options label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.gaming-shortcuts-overview p, +.gaming-preview-heading h3, +.gaming-shortcuts-card .feature-card-title p { + margin: 5px 0 0; +} + +.gaming-gesture-list { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.gaming-gesture-row { + min-width: 0; + padding: 10px 0; + border-top: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); +} + +.gaming-gesture-row > div:first-child { + display: grid; + flex: 0 0 145px; + gap: 3px; +} + +.gaming-gesture-row > div:first-child span, +.gaming-shortcuts-overview p { + color: var(--text-muted); + font-size: .82rem; +} + +.gaming-shortcut-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.gaming-shortcut-options label { + min-height: 38px; + color: var(--text-muted); + font-size: .86rem; +} + +.gaming-timing-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.gaming-timing-grid label { + position: relative; +} + +.gaming-timing-grid label span { + position: absolute; + right: 9px; + bottom: 9px; + color: var(--text-muted); + font-size: .76rem; +} + +.gaming-timing-grid input { + width: 100%; + box-sizing: border-box; + padding-right: 28px; +} + +.gaming-preview-button { + width: auto; + margin-top: 14px; +} + +.gaming-shortcuts-preview { + position: sticky; + top: 0; + overflow: hidden; + padding: 18px; +} + +.gaming-preview-heading { + align-items: flex-start; +} + +.gaming-preview-heading h3 { + font-size: 1rem; +} + +.gaming-controller-state { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: .75rem; + white-space: nowrap; +} + +.gaming-controller-state span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-muted); +} + +.gaming-controller-state.connected span { background: var(--success-color, #35cf82); } + +.gaming-controller-stage { + position: relative; + display: grid; + min-height: 240px; + place-items: center; + margin: 14px -8px 0; + border-radius: 12px; + background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--accent-color) 18%, transparent), transparent 65%), var(--surface-raised); +} + +.gaming-controller-stage img { + display: block; + width: min(90%, 280px); + max-height: 220px; + object-fit: contain; + filter: drop-shadow(0 14px 15px rgb(0 0 0 / 24%)); +} + +.gaming-ps-chip { + position: absolute; + right: 12%; + bottom: 14%; + display: grid; + gap: 2px; + max-width: 130px; + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--accent-color) 55%, var(--border-color)); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-raised) 88%, transparent); + box-shadow: 0 4px 12px rgb(0 0 0 / 18%); + font-size: .72rem; +} + +.gaming-ps-chip span { color: var(--accent-color); font-size: .68rem; font-weight: 700; } +.gaming-ps-chip small { color: var(--text-muted); } + +.gaming-preview-bindings { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 12px; +} + +.gaming-preview-bindings div { + display: grid; + gap: 4px; + min-width: 0; + padding: 9px; + border-radius: 8px; + background: var(--surface-raised); +} + +.gaming-preview-bindings span { color: var(--text-muted); font-size: .72rem; } +.gaming-preview-bindings strong { overflow: hidden; font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; } +.gaming-preview-note { position: fixed; right: 24px; bottom: 24px; z-index: 5; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-raised); box-shadow: 0 8px 22px rgb(0 0 0 / 22%); } + .gaming-shortcuts-toggle, .gaming-shortcut-row, .gaming-provider-status { @@ -2566,6 +2763,14 @@ button { } @media (max-width: 799px) { + .gaming-shortcuts-layout { + grid-template-columns: 1fr; + } + + .gaming-shortcuts-preview { + position: static; + } + .gaming-timing-grid { grid-template-columns: 1fr; } @@ -2574,6 +2779,20 @@ button { align-items: stretch; flex-direction: column; } + + .gaming-gesture-row, + .gaming-shortcut-options label { + align-items: stretch; + flex-direction: column; + } + + .gaming-gesture-row > div:first-child { + flex-basis: auto; + } + + .gaming-shortcut-options { + grid-template-columns: 1fr; + } } .overview-page { @@ -9049,6 +9268,11 @@ button.trigger-lab-chip { } } +@media (prefers-reduced-motion: reduce) { + .gaming-controller-stage img { filter: none; } + .gaming-preview-note { transition: none; } +} + .update-toast-head { display: flex; gap: 11px; diff --git a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts index 4e2e6ea..7ccc147 100644 --- a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts +++ b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts @@ -4,6 +4,8 @@ import type { ControllerButton } from './controller-input'; export type { ControllerButton } from './controller-input'; export interface ControllerInputState { + /** Physical evdev source identity; absent only for synthetic/legacy callers. */ + sourceId?: string | null; timestampMs: number; l2: number; r2: number; From ad6c33a8ab4a49b9f691f65ac7a05582c30c6d45 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Fri, 17 Jul 2026 16:22:45 +0400 Subject: [PATCH 13/15] fix: harden GameShortcuts actions on dev --- .../src/main/evdev-input-reader.test.ts | 15 ++++++++-- .../companion/src/main/evdev-input-reader.ts | 29 +++++++++++++++++-- .../gaming-shortcuts/action-executor.test.ts | 6 ---- .../main/gaming-shortcuts/action-executor.ts | 5 ---- .../main/gaming-shortcuts/notifications.ts | 4 +-- ds5-bridge/companion/src/main/main.ts | 10 +++---- .../src/renderer/GamingShortcuts.test.ts | 4 ++- .../src/renderer/GamingShortcuts.tsx | 13 ++------- .../src/shared/gaming-shortcuts.test.ts | 4 ++- .../companion/src/shared/gaming-shortcuts.ts | 10 ------- 10 files changed, 53 insertions(+), 47 deletions(-) diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts index b175de3..fabdf9e 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts @@ -17,6 +17,8 @@ function event(type: number, code: number, value: number): Buffer { const EV_SYN = 0; const EV_KEY = 1; const EV_ABS = 3; +const ABS_X = 0; +const ABS_Y = 1; const ABS_RZ = 5; const ABS_HAT0X = 16; const ABS_HAT0Y = 17; @@ -65,8 +67,8 @@ describe('EvdevInputReader', () => { reader.start(); const pending = collect(reader, 1); stream.write(Buffer.concat([ - event(EV_ABS, 0, 255), - event(EV_ABS, 1, 10), + event(EV_ABS, ABS_X, 255), + event(EV_ABS, ABS_Y, 10), event(EV_SYN, 0, 0) ])); const [state] = await pending; @@ -126,11 +128,18 @@ describe('EvdevInputReader', () => { }); reader.start(); const pending = collect(reader, 2); - gamepad.write(Buffer.concat([event(EV_ABS, ABS_RZ, 220), event(EV_KEY, BTN_TL, 1), event(EV_SYN, 0, 0)])); + gamepad.write(Buffer.concat([ + event(EV_ABS, ABS_RZ, 220), + event(EV_ABS, ABS_X, 255), + event(EV_KEY, BTN_TL, 1), + event(EV_SYN, 0, 0) + ])); touchpad.write(Buffer.concat([event(EV_KEY, 272, 1), event(EV_SYN, 0, 0)])); const [, auxiliary] = await pending; expect(auxiliary.sourceId).toBe('physical-A'); expect(auxiliary.r2).toBe(220); + expect(auxiliary.lx).toBe(255); + expect(auxiliary.ly).toBe(128); expect(auxiliary.buttons).toEqual(new Set(['l1', 'touchpad'])); reader.stop(); }); diff --git a/ds5-bridge/companion/src/main/evdev-input-reader.ts b/ds5-bridge/companion/src/main/evdev-input-reader.ts index 099bb51..d169425 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.ts @@ -10,6 +10,8 @@ const EV_KEY = 1; const EV_ABS = 3; const ABS_Z = 2; const ABS_RZ = 5; +const ABS_X = 0; +const ABS_Y = 1; const ABS_HAT0X = 16; const ABS_HAT0Y = 17; @@ -148,7 +150,15 @@ export class EvdevInputReader extends EventEmitter { const sourceId = this.sourceIdentity(devicePath) ?? `evdev:${devicePath}`; const group = groups.get(sourceId) ?? { sourceId, nodes: new Set() }; groups.set(sourceId, group); - const state: NodeInputState = { l2: 0, r2: 0, dpadX: 0, dpadY: 0, buttons: new Set() }; + const state: NodeInputState = { + l2: 0, + r2: 0, + lx: 128, + ly: 128, + dpadX: 0, + dpadY: 0, + buttons: new Set() + }; group.nodes.add(state); const source = { stream, pending: Buffer.alloc(0), removed: false, intentionalStop: false, group, state }; this.streams.push(source); @@ -197,6 +207,8 @@ export class EvdevInputReader extends EventEmitter { private handleEvent(source: (typeof this.streams)[number], type: number, code: number, value: number): void { if (type === EV_ABS) { + if (code === ABS_X) source.state.lx = value; + if (code === ABS_Y) source.state.ly = value; if (code === ABS_Z) source.state.l2 = value; if (code === ABS_RZ) source.state.r2 = value; if (code === ABS_HAT0X || code === ABS_HAT0Y) { @@ -228,8 +240,8 @@ export class EvdevInputReader extends EventEmitter { sourceId: group.sourceId, l2: Math.max(...[...group.nodes].map((node) => node.l2), 0), r2: Math.max(...[...group.nodes].map((node) => node.r2), 0), - lx: 128, - ly: 128, + lx: aggregateStickAxis(group.nodes, 'lx'), + ly: aggregateStickAxis(group.nodes, 'ly'), buttons: new Set([...group.nodes].flatMap((node) => [...node.buttons])) }; } @@ -243,7 +255,18 @@ type InputGroup = { type NodeInputState = { l2: number; r2: number; + lx: number; + ly: number; dpadX: number; dpadY: number; buttons: Set; }; + +function aggregateStickAxis(nodes: Set, axis: 'lx' | 'ly'): number { + // Auxiliary nodes start centered and do not have stick events. Select the + // strongest displacement so those centered nodes cannot dilute the gamepad + // node's actual stick position. + return [...nodes].reduce((selected, node) => ( + Math.abs(node[axis] - 128) > Math.abs(selected - 128) ? node[axis] : selected + ), 128); +} diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts index 753cebc..c6afadf 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts @@ -115,12 +115,6 @@ describe('ActionExecutor', () => { await expect(new ActionExecutor().execute({ type: 'open-opends5' })).resolves.toEqual({ ok: false, reason: 'unavailable' }); }); - it('routes confirmed quit requests through the guarded callback', async () => { - const quitActiveGame = vi.fn().mockResolvedValue({ ok: true }); - await expect(new ActionExecutor({ quitActiveGame }).execute({ type: 'quit-active-game', confirmation: true })).resolves.toEqual({ ok: true }); - expect(quitActiveGame).toHaveBeenCalledOnce(); - }); - it('starts and stops only its owned GPU Screen Recorder process', async () => { const kill = vi.fn(); const child = { kill, once: vi.fn((event: string, listener: () => void) => { if (event === 'exit') void listener; return child; }) } as never; diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts index f1e020e..453519a 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts @@ -11,7 +11,6 @@ export type ActionExecutionResult = export interface ActionExecutorOptions { runner?: ProcessRunner; openOpenDS5?: () => Promise | void; - quitActiveGame?: () => Promise | ActionExecutionResult; linuxProvider?: LinuxActionProvider; screenshotProvider?: ScreenshotProvider; recordingProvider?: GpuScreenRecorderProvider; @@ -21,7 +20,6 @@ export interface ActionExecutorOptions { export class ActionExecutor { private readonly runner: ProcessRunner; private readonly openOpenDS5: (() => Promise | void) | null; - private readonly quitActiveGame: (() => Promise | ActionExecutionResult) | null; private readonly linuxProvider: LinuxActionProvider; private readonly screenshotProvider: ScreenshotProvider; private readonly recordingProvider: GpuScreenRecorderProvider; @@ -29,7 +27,6 @@ export class ActionExecutor { constructor(options: ActionExecutorOptions = {}) { this.runner = options.runner ?? createProcessRunner(); this.openOpenDS5 = options.openOpenDS5 ?? null; - this.quitActiveGame = options.quitActiveGame ?? null; this.linuxProvider = options.linuxProvider ?? new LinuxActionProvider(); this.screenshotProvider = options.screenshotProvider ?? new ScreenshotProvider(); this.recordingProvider = options.recordingProvider ?? new GpuScreenRecorderProvider(); @@ -78,8 +75,6 @@ export class ActionExecutor { } case 'recording-toggle': return this.recordingProvider.toggle(action.provider); - case 'quit-active-game': - return this.quitActiveGame ? await this.quitActiveGame() : { ok: false, reason: 'unavailable' }; default: return { ok: false, reason: 'unavailable' }; } diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts index e8211de..00d51c7 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/notifications.ts @@ -32,8 +32,8 @@ const BUTTON_LABELS: Record = { const ACTION_LABELS: Record = { screenshot: 'Screenshot', 'recording-toggle': 'Recording', 'performance-hud-toggle': 'HUD', 'microphone-mute-toggle': 'Microphone', 'on-screen-keyboard': 'Keyboard', - 'switch-application': 'Switch app', volume: 'Volume', 'open-opends5': 'OpenDS5', - 'quit-active-game': 'Quit game', 'custom-executable': 'Custom action', 'launch-app': 'Launch app', 'focus-app': 'Focus app' + volume: 'Volume', 'open-opends5': 'OpenDS5', + 'custom-executable': 'Custom action', 'launch-app': 'Launch app' }; export function buttonLabel(button: ControllerButton): string { diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index d5617ec..3f69e4c 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -1361,6 +1361,9 @@ function registerIpc( } }); ipcMain.handle('bridge:getTriggerProfileEngineStatus', () => triggerProfileEngine.getStatus()); + ipcMain.handle('bridge:selectTriggerProfileState', (_event, name: string) => ( + triggerProfileEngine.selectState(name) + )); ipcMain.handle('bridge:previewTriggerProfileDraft', async (_event, triggers: DraftPreviewTriggers | null) => { await triggerProfileEngine.setDraftPreview(triggers); return triggerProfileEngine.getStatus(); @@ -1726,12 +1729,7 @@ app.whenReady().then(async () => { settingsStore, activeGameId: () => triggerProfileEngine?.getActiveGameId() ?? null, executor: new ActionExecutor({ - quitActiveGame: () => { - const processName = triggerProfileEngine?.getStatus().matchedName; - if (!processName || processName === 'OpenDS5') return { ok: false, reason: 'unavailable' as const }; - const result = spawnSync('pkill', ['-TERM', '-x', processName], { stdio: 'ignore', timeout: 1000 }); - return result.status === 0 ? { ok: true as const } : { ok: false, reason: 'unavailable' as const }; - } + openOpenDS5: showMainWindow }), notifications: gamingShortcutNotifications(bridgeService), controllerIdForSource: (sourceId) => bridgeService?.getGamingShortcutControllerIdForInputSource(sourceId) ?? null diff --git a/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts b/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts index 49d8a97..3f7cf5a 100644 --- a/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts +++ b/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts @@ -27,10 +27,12 @@ describe('Gaming Shortcuts page', () => { }); it('renders every supported shortcut action and secondary controller button', () => { - expect(component).toContain("value: 'quit-active-game'"); expect(component).toContain("value: 'custom-executable'"); expect(component).toContain("value: 'l1'"); expect(component).toContain("value: 'r3'"); + expect(component).not.toContain("value: 'focus-app'"); + expect(component).not.toContain("value: 'switch-application'"); + expect(component).not.toContain("value: 'quit-active-game'"); }); it('has a responsive rail, sticky preview, and reduced-motion fallback', () => { diff --git a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx index 67b86f9..b5bf297 100644 --- a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx +++ b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx @@ -21,10 +21,9 @@ const BUTTONS: Array<{ value: Exclude; label: string }> type ActionKind = GamingShortcutAction['type']; const ACTIONS: Array<{ value: ActionKind; label: string }> = [ { value: 'none', label: 'No action' }, { value: 'passthrough', label: 'Pass through only' }, { value: 'open-opends5', label: 'Open or focus OpenDS5' }, - { value: 'launch-app', label: 'Open or focus an application' }, { value: 'focus-app', label: 'Focus application by ID' }, { value: 'screenshot', label: 'Take screenshot' }, + { value: 'launch-app', label: 'Open or focus an application' }, { value: 'screenshot', label: 'Take screenshot' }, { value: 'recording-toggle', label: 'Toggle recording' }, { value: 'performance-hud-toggle', label: 'Toggle performance HUD' }, { value: 'volume', label: 'Volume' }, - { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'on-screen-keyboard', label: 'Open on-screen keyboard' }, { value: 'switch-application', label: 'Switch application' }, - { value: 'quit-active-game', label: 'Quit active game (confirm)' }, { value: 'custom-executable', label: 'Custom executable' } + { value: 'microphone-mute-toggle', label: 'Toggle microphone mute' }, { value: 'on-screen-keyboard', label: 'Open on-screen keyboard' }, { value: 'custom-executable', label: 'Custom executable' } ]; const GESTURES = [ @@ -37,13 +36,10 @@ function actionForKind(kind: ActionKind): GamingShortcutAction { switch (kind) { case 'volume': return { type: 'volume', direction: 'up' }; case 'launch-app': return { type: 'launch-app', executable: '', args: [] }; - case 'focus-app': return { type: 'focus-app', appId: '' }; case 'screenshot': return { type: 'screenshot', provider: 'auto' }; case 'recording-toggle': return { type: 'recording-toggle', provider: 'auto' }; case 'performance-hud-toggle': return { type: 'performance-hud-toggle', provider: 'auto' }; case 'on-screen-keyboard': return { type: 'on-screen-keyboard', provider: 'auto' }; - case 'switch-application': return { type: 'switch-application', direction: 'next' }; - case 'quit-active-game': return { type: 'quit-active-game', confirmation: true }; case 'custom-executable': return { type: 'custom-executable', executable: '', args: [] }; default: return { type: kind } as GamingShortcutAction; } @@ -51,7 +47,6 @@ function actionForKind(kind: ActionKind): GamingShortcutAction { function actionLabel(action: GamingShortcutAction): string { if (action.type === 'volume') return `Volume ${action.direction}`; - if (action.type === 'switch-application') return `Switch ${action.direction}`; return ACTIONS.find((item) => item.value === action.type)?.label ?? 'No action'; } @@ -65,10 +60,8 @@ function ActionEditor({ action, onChange, capabilities }: { action: GamingShortc return
{action.type === 'volume' && } - {action.type === 'switch-application' && } {isProviderAction(action) && } {(action.type === 'launch-app' || action.type === 'custom-executable') && <>{textInput('Executable', action.executable, (executable) => onChange({ ...action, executable }))}{textInput('Arguments', action.args.join(' '), (value) => onChange({ ...action, args: value.trim() ? value.trim().split(/\s+/) : [] }))}} - {action.type === 'focus-app' && textInput('Application ID', action.appId, (appId) => onChange({ ...action, appId }))}
; } @@ -111,7 +104,7 @@ export function GamingShortcuts({ active, profiles, activeProfileId, snapshot = const selectedOverride = selectedGameId ? settings.perGameOverrides[selectedGameId] : undefined; const setGameOverride = (next: NonNullable) => { if (selectedGameId) update({ ...settings, perGameOverrides: { ...settings.perGameOverrides, [selectedGameId]: next } }); }; const clearGameOverride = () => { if (selectedGameId) { const perGameOverrides = { ...settings.perGameOverrides }; delete perGameOverrides[selectedGameId]; update({ ...settings, perGameOverrides }); } }; - const defaultPreset = useMemo(() => ({ ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, psPressAction: 'enter-shortcut-mode' as const, directChordsEnabled: true, shortcutModeEnabled: true, doublePress: { type: 'switch-application', direction: 'previous' } as GamingShortcutAction, chords: [{ button: 'create' as const, action: { type: 'screenshot', provider: 'auto' } as GamingShortcutAction }, { button: 'triangle' as const, action: { type: 'performance-hud-toggle', provider: 'auto' } as GamingShortcutAction }, { button: 'mute' as const, action: { type: 'microphone-mute-toggle' } as GamingShortcutAction }, { button: 'dpad-up' as const, action: { type: 'volume', direction: 'up' } as GamingShortcutAction }, { button: 'dpad-down' as const, action: { type: 'volume', direction: 'down' } as GamingShortcutAction }, { button: 'dpad-left' as const, action: { type: 'switch-application', direction: 'previous' } as GamingShortcutAction }, { button: 'dpad-right' as const, action: { type: 'switch-application', direction: 'next' } as GamingShortcutAction }] }), []); + const defaultPreset = useMemo(() => ({ ...DEFAULT_GAMING_SHORTCUTS_SETTINGS, enabled: true, psPressAction: 'enter-shortcut-mode' as const, directChordsEnabled: true, shortcutModeEnabled: true, doublePress: { type: 'none' } as const, chords: [{ button: 'create' as const, action: { type: 'screenshot', provider: 'auto' } as GamingShortcutAction }, { button: 'triangle' as const, action: { type: 'performance-hud-toggle', provider: 'auto' } as GamingShortcutAction }, { button: 'mute' as const, action: { type: 'microphone-mute-toggle' } as GamingShortcutAction }, { button: 'dpad-up' as const, action: { type: 'volume', direction: 'up' } as GamingShortcutAction }, { button: 'dpad-down' as const, action: { type: 'volume', direction: 'down' } as GamingShortcutAction }] }), []); const selectedGameName = gameProfiles.find((profile) => profile.id === selectedGameId)?.name; return
Controller customization

Gaming Shortcuts

Turn the PS button into a configurable Linux gaming shortcut layer.

{status}
diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts index 0910bd6..7015abc 100644 --- a/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts @@ -14,7 +14,9 @@ describe('validateGamingShortcutAction', () => { { type: 'launch-app', executable: 'bad\0name', args: [] }, { type: 'volume', direction: 'sideways' }, { type: 'screenshot', provider: 'missing' }, - { type: 'quit-active-game', confirmation: false }, + { type: 'focus-app', appId: 'org.example.App' }, + { type: 'switch-application', direction: 'next' }, + { type: 'quit-active-game', confirmation: true }, { type: 'custom-executable', executable: 'tool', args: ['bad\0arg'] } ])('repairs malformed data to none: %j', (value) => { expect(validateGamingShortcutAction(value)).toEqual({ type: 'none' }); diff --git a/ds5-bridge/companion/src/shared/gaming-shortcuts.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts index 0b24475..33b071c 100644 --- a/ds5-bridge/companion/src/shared/gaming-shortcuts.ts +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts @@ -11,15 +11,12 @@ export type GamingShortcutAction = | { type: 'passthrough' } | { type: 'open-opends5' } | { type: 'launch-app'; executable: string; args: string[] } - | { type: 'focus-app'; appId: string } | { type: 'volume'; direction: 'up' | 'down' | 'mute' } | { type: 'microphone-mute-toggle' } | { type: 'screenshot'; provider: CaptureProvider } | { type: 'recording-toggle'; provider: RecordingProvider } | { type: 'performance-hud-toggle'; provider: HudProvider } | { type: 'on-screen-keyboard'; provider: KeyboardProvider } - | { type: 'switch-application'; direction: 'next' | 'previous' } - | { type: 'quit-active-game'; confirmation: true } | { type: 'custom-executable'; executable: string; args: string[] }; export interface GamingShortcutBindings { @@ -108,8 +105,6 @@ export function validateGamingShortcutAction(value: unknown): GamingShortcutActi case 'open-opends5': return { type: 'open-opends5' }; case 'launch-app': return string(value.executable) && args(value.args) ? { type: 'launch-app', executable: value.executable, args: value.args } : { type: 'none' }; - case 'focus-app': - return string(value.appId) ? { type: 'focus-app', appId: value.appId } : { type: 'none' }; case 'volume': return value.direction === 'up' || value.direction === 'down' || value.direction === 'mute' ? { type: 'volume', direction: value.direction } : { type: 'none' }; case 'microphone-mute-toggle': return { type: 'microphone-mute-toggle' }; @@ -120,11 +115,6 @@ export function validateGamingShortcutAction(value: unknown): GamingShortcutActi return member(RECORDING_PROVIDERS, value.provider) ? { type: 'recording-toggle', provider: value.provider } : { type: 'none' }; case 'performance-hud-toggle': return member(HUD_PROVIDERS, value.provider) ? { type: 'performance-hud-toggle', provider: value.provider } : { type: 'none' }; - case 'on-screen-keyboard': - return member(KEYBOARD_PROVIDERS, value.provider) ? { type: 'on-screen-keyboard', provider: value.provider } : { type: 'none' }; - case 'switch-application': - return value.direction === 'next' || value.direction === 'previous' ? { type: 'switch-application', direction: value.direction } : { type: 'none' }; - case 'quit-active-game': return value.confirmation === true ? { type: 'quit-active-game', confirmation: true } : { type: 'none' }; case 'custom-executable': return string(value.executable) && args(value.args) ? { type: 'custom-executable', executable: value.executable, args: value.args } : { type: 'none' }; default: return { type: 'none' }; From c420d45390cd491b4b12e0f0ed0199882ad521d5 Mon Sep 17 00:00:00 2001 From: retr0astic Date: Fri, 17 Jul 2026 16:44:15 +0400 Subject: [PATCH 14/15] fix: restore stick sample preview forwarding --- ds5-bridge/companion/src/main/main.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index 3f69e4c..eb3c90a 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -1768,6 +1768,11 @@ app.whenReady().then(async () => { if (persistedEngineState.enabled) { void triggerProfileEngine.setEnabled(true); } + triggerProfileEngine.on('stickSample', (sample: { lx: number; ly: number }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('bridge:stickSample', sample); + } + }); triggerProfileEngine.on('status', (status: EngineStatus) => { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('bridge:triggerProfileEngineStatus', status); From fc20ed52b3ff2567e3d93c3ca5b5b03e56339dcd Mon Sep 17 00:00:00 2001 From: retr0astic Date: Fri, 17 Jul 2026 17:50:11 +0400 Subject: [PATCH 15/15] fix: polish Wayland gaming shortcuts UI --- .codex/agents/implementer.toml | 25 ++++++++++++++++ .codex/agents/planner.toml | 25 ++++++++++++++++ .codex/agents/reviewer.toml | 27 +++++++++++++++++ .codex/config.toml | 3 ++ .../providers/detect-environment.test.ts | 11 +++++++ .../providers/detect-environment.ts | 1 + .../src/main/main-window-behavior.test.ts | 3 +- ds5-bridge/companion/src/main/main.ts | 14 ++++++++- .../src/renderer/GamingShortcuts.test.ts | 14 +++++++-- .../src/renderer/GamingShortcuts.tsx | 7 ++--- ds5-bridge/companion/src/renderer/styles.css | 7 +++++ flake.nix | 29 +++++++++++++++++++ 12 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 .codex/agents/implementer.toml create mode 100644 .codex/agents/planner.toml create mode 100644 .codex/agents/reviewer.toml create mode 100644 .codex/config.toml diff --git a/.codex/agents/implementer.toml b/.codex/agents/implementer.toml new file mode 100644 index 0000000..8bb2bf0 --- /dev/null +++ b/.codex/agents/implementer.toml @@ -0,0 +1,25 @@ +name = "implementer" +description = """ +Automatically use after the planner for scoped code implementation, +documentation, tests, and corrections requested by the reviewer. +""" + +model = "gpt-5.6-luna" +model_reasoning_effort = "medium" +sandbox_mode = "workspace-write" + +developer_instructions = """ +Implement the supplied plan precisely. + +Read all applicable AGENTS.md files. Preserve unrelated worktree changes. +Do not redesign the solution unless the plan is unsafe or impossible. +Update tests and documentation when required. Run the requested checks. + +Return: +- files changed; +- behavior implemented; +- commands and tests run; +- failures or warnings; +- deviations from the plan; +- remaining concerns. +""" diff --git a/.codex/agents/planner.toml b/.codex/agents/planner.toml new file mode 100644 index 0000000..c9d9072 --- /dev/null +++ b/.codex/agents/planner.toml @@ -0,0 +1,25 @@ +name = "planner" +description = """ +Automatically use before standard or high-risk implementation tasks. +Plans architecture, scope, affected files, risks, acceptance criteria, and +verification. Does not edit files. +""" + +model = "gpt-5.6" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +Inspect the request, repository instructions, implementation, tests, and plans. + +Return a decision-complete implementation plan containing: +- task classification and rationale; +- relevant files and execution paths; +- ordered implementation steps; +- risks and edge cases; +- acceptance criteria; +- exact verification commands. + +Do not edit files. Resolve ambiguity from the repository where possible. +For high-risk work, address privacy, data integrity, recovery, and regressions. +""" diff --git a/.codex/agents/reviewer.toml b/.codex/agents/reviewer.toml new file mode 100644 index 0000000..d53d35e --- /dev/null +++ b/.codex/agents/reviewer.toml @@ -0,0 +1,27 @@ +name = "reviewer" +description = """ +Automatically use after implementation for independent review of correctness, +security, regressions, privacy, financial integrity, and missing tests. +""" + +model = "gpt-5.6-terra" +model_reasoning_effort = "high" +sandbox_mode = "read-only" + +developer_instructions = """ +Inspect the actual diff and relevant surrounding code. + +Compare it against: +- the original request; +- the planner's plan; +- applicable AGENTS.md instructions; +- repository conventions; +- tests and verification output; +- privacy and data-integrity boundaries. + +For each blocking issue, provide severity, file and line, impact, and correction. +Ignore purely stylistic preferences unless they conceal a real risk. + +Return APPROVED only when no blocking findings remain. +Do not edit files. +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..1952f84 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[agents] +max_threads = 3 +max_depth = 1 diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts index fc278ed..c1bb5aa 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts @@ -30,4 +30,15 @@ describe('detectProviderCapabilities', () => { keyboard: [] }); }); + + it('reports hyprshot on Hyprland when it is installed', () => { + const available = new Set(['hyprshot']); + expect(detectProviderCapabilities({ + env: { HYPRLAND_INSTANCE_SIGNATURE: '1', WAYLAND_DISPLAY: 'wayland-1' }, + hasExecutable: (name) => available.has(name) + })).toMatchObject({ + environment: 'hyprland', + screenshot: ['hyprshot'] + }); + }); }); diff --git a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts index 3d30d52..c72d3b1 100644 --- a/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts @@ -56,6 +56,7 @@ export function detectProviderCapabilities(options: EnvironmentProbe = {}): Prov const environment = detectDesktopEnvironment({ env, hasExecutable }); const screenshot: string[] = []; + if (environment === 'hyprland' && hasExecutable('hyprshot')) screenshot.push('hyprshot'); if (hasExecutable('grim')) screenshot.push('grim'); if (hasExecutable('gnome-screenshot')) screenshot.push('gnome-screenshot'); if (hasExecutable('spectacle')) screenshot.push('spectacle'); diff --git a/ds5-bridge/companion/src/main/main-window-behavior.test.ts b/ds5-bridge/companion/src/main/main-window-behavior.test.ts index 1be6461..96280d2 100644 --- a/ds5-bridge/companion/src/main/main-window-behavior.test.ts +++ b/ds5-bridge/companion/src/main/main-window-behavior.test.ts @@ -39,7 +39,8 @@ describe('main window behavior', () => { expect(createWindow).not.toContain('maxWidth'); expect(createWindow).not.toContain('maxHeight'); expect(createWindow).toContain('resolveWindowBounds('); - expect(createWindow).toContain("window.on('resize', scheduleWindowStateSave)"); + expect(createWindow).toContain("window.on('resize', () => {"); + expect(createWindow).toContain('repaintWindowAfterResize(window);'); expect(createWindow).toContain("window.on('move', scheduleWindowStateSave)"); expect(createWindow).toContain('persistWindowState()'); diff --git a/ds5-bridge/companion/src/main/main.ts b/ds5-bridge/companion/src/main/main.ts index eb3c90a..1323cbc 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -166,6 +166,12 @@ function scheduleWindowStateSave(): void { }, 500); } +function repaintWindowAfterResize(window: BrowserWindow): void { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.invalidate(); + } +} + async function createTrayIcon(): Promise { const icon = createImageAsset(APP_TRAY_ICON_ICO); if (!icon.isEmpty()) { @@ -509,7 +515,13 @@ function createWindow(uiScalePercent: UiScalePercent): BrowserWindow { }); window.on('will-move', () => bridgeService?.pausePollingFor(1200)); window.on('move', () => bridgeService?.pausePollingFor(700)); - window.on('resize', scheduleWindowStateSave); + window.on('resize', () => { + scheduleWindowStateSave(); + // Frameless windows can retain undamaged transparent compositor regions + // during native resize on Linux/Wayland. Force the renderer to repaint the + // full surface so the desktop cannot show through stale regions. + repaintWindowAfterResize(window); + }); window.on('move', scheduleWindowStateSave); window.on('maximize', scheduleWindowStateSave); window.on('unmaximize', scheduleWindowStateSave); diff --git a/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts b/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts index 3f7cf5a..a587836 100644 --- a/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts +++ b/ds5-bridge/companion/src/renderer/GamingShortcuts.test.ts @@ -7,9 +7,9 @@ const css = readFileSync(new URL('./styles.css', import.meta.url), 'utf8'); describe('Gaming Shortcuts page', () => { it('keeps the renderer preview static and binds only connection state from the existing snapshot', () => { expect(component).toContain('snapshot?: BridgeSnapshot | null'); - expect(component).toContain('Static controller preview'); - expect(component).toContain('Live hardware illumination is unavailable here'); - expect(component).toContain('hardware output is never sent by this preview'); + expect(component).toContain('aria-label="Controller preview"'); + expect(component).not.toContain('Static controller preview'); + expect(component).not.toContain('Live hardware illumination is unavailable here'); expect(component).toContain('controllerConnected'); expect(component).toContain('controllerImage'); expect(component).not.toContain('Live preview'); @@ -40,5 +40,13 @@ describe('Gaming Shortcuts page', () => { expect(css).toContain('.gaming-shortcuts-preview {'); expect(css).toContain('@media (prefers-reduced-motion: reduce)'); expect(css).toContain('.gaming-controller-stage img'); + expect(css).toContain('.gaming-preview-heading > div'); + expect(css).toContain('overflow-wrap: anywhere'); + }); + + it('keeps compositor details out of the provider status card', () => { + expect(component).toContain('aria-label="Provider status"'); + expect(component).not.toContain('capabilities?.environment'); + expect(component).not.toContain('Environment and provider status'); }); }); diff --git a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx index b5bf297..8859020 100644 --- a/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx +++ b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx @@ -72,8 +72,8 @@ function Toggle({ label, checked, onChange }: { label: string; checked: boolean; function ControllerPreview({ settings, status, selectedGameName }: { settings: GamingShortcutsSettings; status: BridgeSnapshot | null; selectedGameName?: string }) { const connection = status?.state !== 'connected' ? 'Bridge offline' : status.status?.controllerConnected ? 'Controller connected' : 'Controller not connected'; const type = status?.status?.controllerType === 'dualsense-edge' ? 'DualSense Edge' : status?.status?.controllerType === 'dualsense' ? 'DualSense' : 'DualSense controller'; - return
-
Static controller preview

{selectedGameName ? `${selectedGameName} shortcuts` : 'Your PS button layout'}

+ return
+

{selectedGameName ? `${selectedGameName} shortcuts` : 'Your PS button layout'}

{`${type}
PS{actionLabel(settings.singlePress)}Press
@@ -81,7 +81,6 @@ function ControllerPreview({ settings, status, selectedGameName }: { settings: G
PS ×2{actionLabel(settings.doublePress)}
PS hold{actionLabel(settings.longPress)}
-

Preview updates as you configure actions. Live hardware illumination is unavailable here; hardware output is never sent by this preview.

; } @@ -115,7 +114,7 @@ export function GamingShortcuts({ active, profiles, activeProfileId, snapshot =
PS chords

Hold PS and press another button. Direct chords take precedence over PS gestures.

{settings.chords.length === 0 ?

No chords configured.

: settings.chords.map((chord, index) =>
{ const chords = [...settings.chords]; chords[index] = { ...chord, action }; update({ ...settings, chords }); }} capabilities={capabilities} />
)}
Timing & notifications

Fine-tune gesture recognition and feedback.

Per-game overrides

Override gesture and chord bindings for a game profile.

{selectedOverride && }
{gameProfiles.length === 0 ?

Create a Game Profile to customize shortcuts for a specific game.

: }{selectedGameId && <>
{GESTURES.map(([key, label]) =>
{label}Inherits global setting until changed
setGameOverride({ ...selectedOverride, [key]: action })} capabilities={capabilities} />
)}
Game PS chords
{(selectedOverride?.chords ?? []).map((chord, index) =>
{ const chords = [...(selectedOverride?.chords ?? [])]; chords[index] = { ...chord, action }; setGameOverride({ ...selectedOverride, chords }); }} capabilities={capabilities} />
)}}
-
{capabilities?.environment ?? 'Detecting environment…'}Screenshot: {capabilities?.screenshot.join(', ') || 'Unavailable'}Recording: {capabilities?.recording.join(', ') || 'Unavailable'}HUD: {capabilities?.hud.join(', ') || 'Unavailable'}
+
Screenshot: {capabilities?.screenshot.join(', ') || 'Unavailable'}Recording: {capabilities?.recording.join(', ') || 'Unavailable'}HUD: {capabilities?.hud.join(', ') || 'Unavailable'}
{previewOpened &&
Shortcut notification preview sent.
} ; } diff --git a/ds5-bridge/companion/src/renderer/styles.css b/ds5-bridge/companion/src/renderer/styles.css index b6d9531..c021012 100644 --- a/ds5-bridge/companion/src/renderer/styles.css +++ b/ds5-bridge/companion/src/renderer/styles.css @@ -2584,8 +2584,13 @@ button { align-items: flex-start; } +.gaming-preview-heading > div { + min-width: 0; +} + .gaming-preview-heading h3 { font-size: 1rem; + overflow-wrap: anywhere; } .gaming-controller-state { @@ -2641,6 +2646,7 @@ button { .gaming-ps-chip span { color: var(--accent-color); font-size: .68rem; font-weight: 700; } .gaming-ps-chip small { color: var(--text-muted); } +.gaming-ps-chip strong { overflow-wrap: anywhere; } .gaming-preview-bindings { display: grid; @@ -2685,6 +2691,7 @@ button { .gaming-shortcuts-save-status, .gaming-provider-status span, .muted-copy { + min-width: 0; color: var(--text-muted); font-size: 0.86rem; } diff --git a/flake.nix b/flake.nix index f189f61..9615972 100644 --- a/flake.nix +++ b/flake.nix @@ -66,6 +66,35 @@ self.packages.${pkgs.stdenv.hostPlatform.system}.vds self.packages.${pkgs.stdenv.hostPlatform.system}.opends5 ]; + + packages = with pkgs; [ + fish + nodejs + git + ripgrep + fd + jq + tree + pkg-config + cmake + ninja + gnumake + gcc + chromium + evtest + wayland-utils + pipewire + wireplumber + gpu-screen-recorder + ]; + + shellHook = '' + export OPENDS5_REPO_ROOT="''${OPENDS5_REPO_ROOT:-$PWD}" + export npm_config_update_notifier=false + if [[ $- == *i* && "$(${pkgs.coreutils}/bin/readlink /proc/$$/exe)" != */fish ]]; then + exec ${pkgs.fish}/bin/fish + fi + ''; }; } );