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/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/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.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 981d120..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; @@ -180,6 +181,7 @@ type HostPersonaDefaultRenderRestore = { export type BridgeToast = { title: string; body: string; + replaceGroup?: string; }; const PRESET_SETTINGS: Record, Partial> = { @@ -3018,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 8d6b37d..fabdf9e 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.test.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.test.ts @@ -1,9 +1,9 @@ -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'; 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 { @@ -17,8 +17,13 @@ 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; 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 +36,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 }); @@ -53,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; @@ -73,6 +87,126 @@ 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('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('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_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(); + }); + + 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 }); @@ -134,6 +268,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; @@ -170,10 +311,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`); } @@ -188,6 +331,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', 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']); + }); + 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 c2e82f2..d169425 100644 --- a/ds5-bridge/companion/src/main/evdev-input-reader.ts +++ b/ds5-bridge/companion/src/main/evdev-input-reader.ts @@ -1,30 +1,42 @@ 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_X = 0; +const ABS_Y = 1; const ABS_HAT0X = 16; const ABS_HAT0Y = 17; -const BUTTON_NAMES: Record = { +// 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', 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', + 0x220: 'dpad-up', + 0x221: 'dpad-down', + 0x222: 'dpad-left', + 0x223: 'dpad-right' }; // Lowest word of the abs capability bitmask; bit 2 = ABS_Z (L2), @@ -53,18 +65,27 @@ 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 []; } + 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')) { + group.touchpad ??= `/dev/input/${entry}`; + groups.set(identity, group); + 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. @@ -74,110 +95,178 @@ 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}`; + group.gamepad ??= `/dev/input/${entry}`; + groups.set(identity, group); } catch { // ignore unreadable nodes } } - return null; + return [...groups.values()].flatMap(({ gamepad, touchpad }) => 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[]; + sourceIdentity?: (path: string) => string | null; }; 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 l2 = 0; - private r2 = 0; - private lx = 128; - private ly = 128; - private buttons = new Set(); + private readonly findNodes: (() => string[]) | null; + 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(); 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); + this.sourceIdentity = options.sourceIdentity ?? resolveEvdevSourceIdentity; } 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); - }); + this.stopping = false; + const groups = new Map(); + for (const devicePath of devicePaths) { + const stream = this.openStream(devicePath); + 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, + 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); + stream.on('data', (chunk: Buffer) => this.consume(source, chunk)); + const remove = (error?: Error) => this.removeStream(source, error); + stream.on('error', remove); + stream.on('close', () => remove()); + stream.on('end', () => remove()); + } } stop(): void { - if (this.stream && 'destroy' in this.stream) { - (this.stream as NodeJS.ReadableStream & { destroy(): void }).destroy(); + 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.stream = null; - this.pending = Buffer.alloc(0); + this.streams = []; } - 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); - this.handleEvent(record.readUInt16LE(16), record.readUInt16LE(18), record.readInt32LE(20)); - } + 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); } - // 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 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(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_HAT0X) this.setHatButtons('dpad-left', 'dpad-right', value); - if (code === ABS_HAT0Y) this.setHatButtons('dpad-up', 'dpad-down', value); + 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) { + 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: aggregateStickAxis(group.nodes, 'lx'), + ly: aggregateStickAxis(group.nodes, 'ly'), + buttons: new Set([...group.nodes].flatMap((node) => [...node.buttons])) + }; + } +} + +type InputGroup = { + sourceId: string; + nodes: Set; +}; + +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 new file mode 100644 index 0000000..c6afadf --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.test.ts @@ -0,0 +1,134 @@ +import fs from 'node:fs'; +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 () => { + 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('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('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({ + 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('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('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 }) }); + 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' }); + }); + + 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 new file mode 100644 index 0000000..453519a --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/action-executor.ts @@ -0,0 +1,85 @@ +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'; +import { GpuScreenRecorderProvider } from './providers/recording'; + +export type ActionExecutionResult = + | { ok: true } + | { ok: false; reason: 'unavailable' | 'failed'; error?: string }; + +export interface ActionExecutorOptions { + runner?: ProcessRunner; + openOpenDS5?: () => Promise | void; + linuxProvider?: LinuxActionProvider; + screenshotProvider?: ScreenshotProvider; + recordingProvider?: GpuScreenRecorderProvider; +} + +/** 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; + 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 { + 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'}` }; + } + case 'volume': + 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); + 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'}` }; + } + 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); + // 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); + 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/coordinator.test.ts b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts new file mode 100644 index 0000000..1abc1c1 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.test.ts @@ -0,0 +1,142 @@ +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 < 12; index += 1) await Promise.resolve(); +} + +class FakeInput extends EventEmitter { + emitInput(buttons: ControllerInputState['buttons'], timestampMs: number, sourceId: string | null = null): void { + this.emit('input', { buttons, timestampMs, sourceId, 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, psPressAction: '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('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(); + 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, 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(['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('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() }; + 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 new file mode 100644 index 0000000..d30aedd --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/coordinator.ts @@ -0,0 +1,242 @@ +import { EventEmitter } from 'node:events'; +import type { ControllerInputState } from '../../shared/trigger-modifier-eval'; +import { resolveGamingShortcutBindings, type GamingShortcutAction, type GamingShortcutsSettings } from '../../shared/gaming-shortcuts'; +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'; + +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 }; } + +export type GamingShortcutMode = + | { state: 'inactive' } + | { state: 'awaiting-selection'; activatedAt: number; expiresAt: number } + | { state: 'executing'; actionId: string }; + +export interface GamingShortcutInvocation { + controllerId: string; + gesture: ControllerGesture; +} + +export interface ShortcutExecutionResult extends GamingShortcutInvocation { + controllerId: string; + gesture: ControllerGesture; + action: GamingShortcutAction; + result: ActionExecutionResult; +} + +/** 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 controllerIdForSource: (sourceId: string | null) => string | null; + private inputControllerId: string | null = null; + private readonly onInputBound: (state: ControllerInputState) => void; + private readonly onDisconnectBound: (sourceId: string) => void; + private gestureEngines = new Map(); + private settings: GamingShortcutsSettings; + private running = false; + private dispatchQueue: Promise = Promise.resolve(); + 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; + settingsStore: ShortcutSettingsSource; + executor?: ActionExecutor; + activeGameId?: () => string | null; + notifications?: GamingShortcutNotifications; + controllerIdForSource?: (sourceId: string | null) => string | null; + }) { + 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.controllerIdForSource = options.controllerIdForSource ?? (() => null); + this.settings = this.settingsStore.get().gamingShortcuts; + 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); this.input.on('disconnect', this.onDisconnectBound); } } + + stop(): void { + if (!this.running) return; + this.running = false; + this.invalidateAllSources(); + this.input.off('input', this.onInputBound); + 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.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.gestureEngines.clear(); + if (wasRunning) this.start(); + } + + getMode(): GamingShortcutMode { return this.mode; } + + previewShortcutNotification(): Promise { + return this.showShortcutReference(); + } + + private handleInput(state: ControllerInputState): void { + 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 }, sourceControllerId, sourceKey); return; } + } + } + + private createGestureEngine(settings: GamingShortcutsSettings, sourceKey: string): GestureEngine { + return new GestureEngine({ + doublePressWindowMs: settings.doublePressWindowMs, + longPressThresholdMs: settings.longPressThresholdMs, + chordWindowMs: settings.chordWindowMs, + emit: (gesture, sourceControllerId) => this.handleGesture(gesture, sourceControllerId, sourceKey) + }); + } + + 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, 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, 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(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, sourceControllerId, sourceKey); + } + + 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(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); + this.emit('mode', this.mode); + } + + private exitShortcutMode(): void { + 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, 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', { controllerId: sourceControllerId ?? '', gesture, action, result } satisfies ShortcutExecutionResult); + }).catch((error: unknown) => { + this.emit('error', error instanceof Error ? error : new Error(String(error))); + }); + } +} + +export type { ControllerButton }; 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..fd0e587 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.test.ts @@ -0,0 +1,59 @@ +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('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(); + }); + 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 new file mode 100644 index 0000000..8b2ac49 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/gesture-engine.ts @@ -0,0 +1,105 @@ +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, sourceControllerId?: string | null) => 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: NonNullable; + 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 doublePressCycle = false; + private longEmitted = false; + 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; + 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(), 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; this.sourceControllerId = undefined; } + 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.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.doublePressCycle = false; + this.longEmitted = true; + this.emit({ type: 'long-press', button: 'ps', durationMs: this.longThreshold }, this.sourceControllerId); + } + }, 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; + } + // 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.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.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; } } + private clearTimers(): void { this.clearLongTimer(); this.clearSingleTimer(); } +} 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..00d51c7 --- /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', + volume: 'Volume', 'open-opends5': 'OpenDS5', + 'custom-executable': 'Custom action', 'launch-app': 'Launch 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/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/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..c1bb5aa --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.test.ts @@ -0,0 +1,44 @@ +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', '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: ['gpu-screen-recorder'], + hud: ['mangohud'], + 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 new file mode 100644 index 0000000..c72d3b1 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/detect-environment.ts @@ -0,0 +1,74 @@ +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 (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'); + if (hasExecutable('scrot')) screenshot.push('scrot'); + + const recording: string[] = []; + if (hasExecutable('gpu-screen-recorder')) recording.push('gpu-screen-recorder'); + + const hud: string[] = []; + if (hasExecutable('mangohud')) hud.push('mangohud'); + 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 new file mode 100644 index 0000000..3960ef8 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.test.ts @@ -0,0 +1,40 @@ +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('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 new file mode 100644 index 0000000..a837b34 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/linux-actions.ts @@ -0,0 +1,56 @@ +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; + 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/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 new file mode 100644 index 0000000..00a945d --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.test.ts @@ -0,0 +1,46 @@ +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('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, + 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..d02a585 --- /dev/null +++ b/ds5-bridge/companion/src/main/gaming-shortcuts/providers/screenshot.ts @@ -0,0 +1,85 @@ +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 '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 }; + case 'scrot': return { executable: 'scrot', args: [outputPath], outputPath }; + default: return null; + } + } + + ensureOutputDirectory(command: ScreenshotCommand): void { + 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 + ? ['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-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 1237ef5..1323cbc 100644 --- a/ds5-bridge/companion/src/main/main.ts +++ b/ds5-bridge/companion/src/main/main.ts @@ -27,6 +27,11 @@ 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 { 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, defaultScannerRoots, @@ -78,6 +83,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 @@ -159,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()) { @@ -502,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); @@ -796,7 +815,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, @@ -807,10 +832,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, @@ -1088,12 +1127,30 @@ 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:previewGamingShortcutNotification', () => gamingShortcuts?.previewShortcutNotification()); ipcMain.handle('bridge:listTriggerProfiles', () => triggerProfileStore.list()); ipcMain.handle('bridge:saveTriggerProfile', (_event, profile: TriggerProfile) => { const saved = triggerProfileStore.save(profile); @@ -1317,7 +1374,7 @@ function registerIpc( }); ipcMain.handle('bridge:getTriggerProfileEngineStatus', () => triggerProfileEngine.getStatus()); ipcMain.handle('bridge:selectTriggerProfileState', (_event, name: string) => ( - triggerProfileEngine.selectState(String(name)) + triggerProfileEngine.selectState(name) )); ipcMain.handle('bridge:previewTriggerProfileDraft', async (_event, triggers: DraftPreviewTriggers | null) => { await triggerProfileEngine.setDraftPreview(triggers); @@ -1673,6 +1730,31 @@ 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, + activeGameId: () => triggerProfileEngine?.getActiveGameId() ?? null, + executor: new ActionExecutor({ + openOpenDS5: showMainWindow + }), + notifications: gamingShortcutNotifications(bridgeService), + controllerIdForSource: (sourceId) => bridgeService?.getGamingShortcutControllerIdForInputSource(sourceId) ?? null + }); + 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(); + } + } 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 +1792,8 @@ app.whenReady().then(async () => { }); registerIpc( bridgeService, + settingsStore, + gamingShortcutsCoordinator, triggerProfileStore, triggerProfileEngine, profileLibrary, @@ -1785,11 +1869,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/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/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/preload.ts b/ds5-bridge/companion/src/preload.ts index fcf76d0..505a02d 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,16 @@ 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') + ), + 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 1c5b300..350d9ce 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 } ]; @@ -2942,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); @@ -4406,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 { @@ -8178,6 +8185,7 @@ export function App() { {activeFeedbackStatusLabel} + {feedbackTestError &&

{feedbackTestError}

} )} @@ -10422,6 +10430,13 @@ export function App() { + +
{ + it('keeps the renderer preview static and binds only connection state from the existing snapshot', () => { + expect(component).toContain('snapshot?: BridgeSnapshot | null'); + 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'); + 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: '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', () => { + 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'); + 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 new file mode 100644 index 0000000..8859020 --- /dev/null +++ b/ds5-bridge/companion/src/renderer/GamingShortcuts.tsx @@ -0,0 +1,120 @@ +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, + type GamingShortcutAction, + 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: '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: '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: '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' }; + case 'launch-app': return { type: 'launch-app', executable: '', args: [] }; + 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 'custom-executable': return { type: 'custom-executable', executable: '', args: [] }; + default: return { type: kind } as GamingShortcutAction; + } +} + +function actionLabel(action: GamingShortcutAction): string { + if (action.type === 'volume') return `Volume ${action.direction}`; + 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 ?? []; + const textInput = (label: string, value: string, change: (value: string) => void) => change(event.target.value)} />; + return
+ + {action.type === 'volume' && } + {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+/) : [] }))}} +
; +} + +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
+

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

+
+ {`${type} +
PS{actionLabel(settings.singlePress)}Press
+
+
+
PS ×2{actionLabel(settings.doublePress)}
PS hold{actionLabel(settings.longPress)}
+
+
; +} + +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(() => { + 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) 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: '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}
+
+
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} />
)}}
+
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 98e580c..c021012 100644 --- a/ds5-bridge/companion/src/renderer/styles.css +++ b/ds5-bridge/companion/src/renderer/styles.css @@ -2455,6 +2455,353 @@ button { pointer-events: auto; } +.gaming-shortcuts-page { + 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; +} + +.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 > div { + min-width: 0; +} + +.gaming-preview-heading h3 { + font-size: 1rem; + overflow-wrap: anywhere; +} + +.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-ps-chip strong { overflow-wrap: anywhere; } + +.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 { + 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 { + min-width: 0; + 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 { + 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)); + 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-shortcuts-layout { + grid-template-columns: 1fr; + } + + .gaming-shortcuts-preview { + position: static; + } + + .gaming-timing-grid { + grid-template-columns: 1fr; + } + + .gaming-shortcut-row { + 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 { height: 100%; min-height: 0; @@ -8928,6 +9275,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/controller-input.ts b/ds5-bridge/companion/src/shared/controller-input.ts new file mode 100644 index 0000000..b3d92de --- /dev/null +++ b/ds5-bridge/companion/src/shared/controller-input.ts @@ -0,0 +1,15 @@ +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'; + +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..bb67219 --- /dev/null +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts-settings.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_GAMING_SHORTCUTS_SETTINGS, normalizeGamingShortcutsSettings, resolveGamingShortcutBindings } 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' } }] }); + }); + + 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' }); + }); + + 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.test.ts b/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts new file mode 100644 index 0000000..7015abc --- /dev/null +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.test.ts @@ -0,0 +1,29 @@ +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: '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' }); + }); + + 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..33b071c --- /dev/null +++ b/ds5-bridge/companion/src/shared/gaming-shortcuts.ts @@ -0,0 +1,182 @@ +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 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' } + | { type: 'passthrough' } + | { type: 'open-opends5' } + | { type: 'launch-app'; executable: string; args: 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: 'custom-executable'; executable: string; args: string[] }; + +export interface GamingShortcutBindings { + singlePress: GamingShortcutAction; + doublePress: GamingShortcutAction; + longPress: GamingShortcutAction; + chords: Array<{ button: Exclude; action: GamingShortcutAction }>; +} + +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; + perGameOverrides: Record; +} + +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, + singlePress: { type: 'none' }, + doublePress: { type: 'none' }, + longPress: { type: 'none' }, + chords: [], + perGameOverrides: {} +}; + +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', 'squeekboard']); + +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); +} + +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' }; + 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 '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': + 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 '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) + : []; + 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, + 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), + singlePress: validateGamingShortcutAction(value.singlePress), + doublePress: validateGamingShortcutAction(value.doublePress), + longPress: validateGamingShortcutAction(value.longPress), + 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 + }; +} diff --git a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts index 6f7cde2..7ccc147 100644 --- a/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts +++ b/ds5-bridge/companion/src/shared/trigger-modifier-eval.ts @@ -1,6 +1,11 @@ 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'; export interface ControllerInputState { + /** Physical evdev source identity; absent only for synthetic/legacy callers. */ + sourceId?: string | null; timestampMs: number; l2: number; r2: number; @@ -128,7 +133,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 { diff --git a/flake.nix b/flake.nix index 947ce79..9615972 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 = { @@ -57,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 + ''; }; } ); 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