From b9e18d08d91035a652185756483492ec29b0970e Mon Sep 17 00:00:00 2001 From: Meganugger Date: Wed, 18 Feb 2026 22:42:16 +0000 Subject: [PATCH 1/5] feat(input): Implement flight control (HOTAS) support with advanced mapping and profiles. --- opennow-stable/package-lock.json | 67 ++- opennow-stable/package.json | 4 +- .../src/main/flight/FlightControlsService.ts | 464 ++++++++++++++++++ .../src/main/flight/FlightDeviceDefaults.ts | 249 ++++++++++ .../src/main/flight/FlightProfiles.ts | 110 +++++ .../src/main/flight/inputConstants.ts | 15 + opennow-stable/src/main/index.ts | 63 ++- opennow-stable/src/main/settings.ts | 14 +- opennow-stable/src/preload/index.ts | 38 +- opennow-stable/src/renderer/src/App.tsx | 65 ++- .../src/components/FlightControlsPanel.tsx | 445 +++++++++++++++++ .../src/renderer/src/components/GameCard.tsx | 19 +- .../renderer/src/components/SettingsPage.tsx | 18 +- .../src/renderer/src/gfn/webrtcClient.ts | 73 ++- opennow-stable/src/renderer/src/styles.css | 383 +++++++++++++++ opennow-stable/src/shared/gfn.ts | 126 ++++- opennow-stable/src/shared/ipc.ts | 13 +- 17 files changed, 2132 insertions(+), 34 deletions(-) create mode 100644 opennow-stable/src/main/flight/FlightControlsService.ts create mode 100644 opennow-stable/src/main/flight/FlightDeviceDefaults.ts create mode 100644 opennow-stable/src/main/flight/FlightProfiles.ts create mode 100644 opennow-stable/src/main/flight/inputConstants.ts create mode 100644 opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index 1afd9256..d187b0a9 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -9,12 +9,14 @@ "version": "0.2.4", "dependencies": { "lucide-react": "^0.563.0", + "node-hid": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "ws": "^8.18.3" }, "devDependencies": { "@types/node": "^22.10.5", + "@types/node-hid": "^1.3.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.5.13", @@ -1813,6 +1815,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-hid": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/node-hid/-/node-hid-1.3.4.tgz", + "integrity": "sha512-0ootpsYetN9vjqkDSwm/cA4fk/9yGM/PO0X8SLPE/BzXlUaBelImMWMymtF9QEoEzxY0pnhcROIJM0CNSUqO8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -1999,7 +2011,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2009,7 +2020,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2808,7 +2818,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -2846,7 +2855,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2859,7 +2867,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -3805,7 +3812,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -3946,7 +3952,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4218,7 +4223,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4719,7 +4723,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5497,6 +5500,29 @@ "node": ">=10" } }, + "node_modules/node-hid": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-3.3.0.tgz", + "integrity": "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==", + "hasInstallScript": true, + "license": "(MIT OR X11)", + "dependencies": { + "node-addon-api": "^3.2.1", + "pkg-prebuilds": "^1.0.0" + }, + "bin": { + "hid-showdevices": "src/show-devices.js" + }, + "engines": { + "node": ">=10.16" + } + }, + "node_modules/node-hid/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5753,6 +5779,22 @@ "dev": true, "license": "ISC" }, + "node_modules/pkg-prebuilds": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pkg-prebuilds/-/pkg-prebuilds-1.0.0.tgz", + "integrity": "sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==", + "license": "MIT", + "dependencies": { + "yargs": "^17.7.2" + }, + "bin": { + "pkg-prebuilds-copy": "bin/copy.mjs", + "pkg-prebuilds-verify": "bin/verify.mjs" + }, + "engines": { + "node": ">= 14.15.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -5973,7 +6015,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6420,7 +6461,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6451,7 +6491,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6875,7 +6914,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -6950,7 +6988,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -6967,7 +7004,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -6986,7 +7022,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/opennow-stable/package.json b/opennow-stable/package.json index 65fcb59a..fd81622d 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -24,12 +24,14 @@ }, "dependencies": { "lucide-react": "^0.563.0", + "node-hid": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "ws": "^8.18.3" }, "devDependencies": { "@types/node": "^22.10.5", + "@types/node-hid": "^1.3.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.5.13", @@ -52,7 +54,7 @@ } ], "icon": "../logo.png", - "npmRebuild": false, + "npmRebuild": true, "nodeGypRebuild": false, "buildDependenciesFromSource": false, "directories": { diff --git a/opennow-stable/src/main/flight/FlightControlsService.ts b/opennow-stable/src/main/flight/FlightControlsService.ts new file mode 100644 index 00000000..675fa544 --- /dev/null +++ b/opennow-stable/src/main/flight/FlightControlsService.ts @@ -0,0 +1,464 @@ +import { BrowserWindow } from "electron"; +import HID from "node-hid"; +import type { + FlightDeviceInfo, + FlightControlsState, + FlightGamepadState, + FlightProfile, + FlightHidReportLayout, +} from "@shared/gfn"; +import { IPC_CHANNELS } from "@shared/ipc"; +import { FlightProfileManager } from "./FlightProfiles"; +import { getDeviceConfig, makeVidPid } from "./FlightDeviceDefaults"; +import { + GAMEPAD_DPAD_UP, + GAMEPAD_DPAD_DOWN, + GAMEPAD_DPAD_LEFT, + GAMEPAD_DPAD_RIGHT, +} from "./inputConstants"; + +const JOYSTICK_USAGE_PAGE = 0x01; +const JOYSTICK_USAGE = 0x04; +const GAMEPAD_USAGE = 0x05; +const HOTPLUG_SCAN_MS = 3000; + +interface ParsedReport { + axes: number[]; + buttons: boolean[]; + hatSwitch: number; +} + +export class FlightControlsService { + private device: HID.HID | null = null; + private devicePath: string | null = null; + private deviceVendorId = 0; + private deviceProductId = 0; + private deviceName = ""; + private enabled: boolean; + private controllerSlot: number; + private mainWindow: BrowserWindow | null = null; + private hotplugTimer: NodeJS.Timeout | null = null; + private disposed = false; + private lastRawBytes: number[] = []; + + private reportLayout: FlightHidReportLayout | null = null; + private profile: FlightProfile | null = null; + private lastGamepadState: FlightGamepadState | null = null; + + readonly profileManager: FlightProfileManager; + + constructor(enabled: boolean, slot: number) { + this.enabled = enabled; + this.controllerSlot = Math.max(0, Math.min(3, slot)); + this.profileManager = new FlightProfileManager(); + } + + setMainWindow(win: BrowserWindow | null): void { + this.mainWindow = win; + } + + updateConfig(enabled: boolean, slot: number): void { + const wasEnabled = this.enabled; + this.enabled = enabled; + this.controllerSlot = Math.max(0, Math.min(3, slot)); + + if (!enabled && wasEnabled) { + this.stopCapture(); + this.stopHotplugScan(); + } + if (enabled && !wasEnabled) { + this.startHotplugScan(); + } + } + + initialize(): void { + if (this.enabled) { + this.startHotplugScan(); + } + console.log(`[Flight] Service initialized (enabled=${this.enabled}, slot=${this.controllerSlot})`); + } + + getDevices(): FlightDeviceInfo[] { + try { + const devices = HID.devices(); + return devices + .filter((d) => + d.usagePage === JOYSTICK_USAGE_PAGE && + (d.usage === JOYSTICK_USAGE || d.usage === GAMEPAD_USAGE), + ) + .map((d) => ({ + path: d.path ?? "", + vendorId: d.vendorId ?? 0, + productId: d.productId ?? 0, + product: d.product ?? "Unknown Device", + manufacturer: d.manufacturer ?? "", + serialNumber: d.serialNumber ?? "", + release: d.release ?? 0, + interface: d.interface ?? -1, + usagePage: d.usagePage ?? 0, + usage: d.usage ?? 0, + })) + .filter((d) => d.path !== ""); + } catch (error) { + console.warn("[Flight] Failed to enumerate HID devices:", error instanceof Error ? error.message : error); + return []; + } + } + + startCapture(devicePath: string): boolean { + if (!this.enabled) { + console.log("[Flight] Cannot start capture: flight controls disabled"); + return false; + } + + this.stopCapture(); + + try { + const allDevices = HID.devices(); + const deviceInfo = allDevices.find((d) => d.path === devicePath); + if (!deviceInfo) { + console.warn("[Flight] Device not found:", devicePath); + return false; + } + + const device = new HID.HID(devicePath); + this.device = device; + this.devicePath = devicePath; + this.deviceVendorId = deviceInfo.vendorId ?? 0; + this.deviceProductId = deviceInfo.productId ?? 0; + this.deviceName = deviceInfo.product ?? "Unknown Device"; + + const vidPid = makeVidPid(this.deviceVendorId, this.deviceProductId); + const knownConfig = getDeviceConfig(this.deviceVendorId, this.deviceProductId); + + this.profile = this.profileManager.getOrCreateProfile( + this.deviceVendorId, + this.deviceProductId, + this.deviceName, + ); + + this.reportLayout = this.profile.reportLayout ?? knownConfig?.layout ?? null; + + if (!this.reportLayout) { + console.warn(`[Flight] No report layout for ${vidPid}, using auto-detect mode`); + } + + console.log(`[Flight] Opened device: ${this.deviceName} (${vidPid}) at ${devicePath}`); + if (knownConfig) { + console.log(`[Flight] Known device: ${knownConfig.name}`); + } + + device.on("data", (data: Buffer) => { + this.onHidData(data); + }); + + device.on("error", (err: Error) => { + console.warn("[Flight] HID device error:", err.message); + this.handleDeviceDisconnect(); + }); + + this.sendConnectedState(true); + return true; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn("[Flight] Failed to open device:", msg); + this.device = null; + this.devicePath = null; + return false; + } + } + + stopCapture(): void { + if (this.device) { + try { + this.device.close(); + } catch { + // ignore + } + this.device = null; + } + + if (this.devicePath) { + this.sendConnectedState(false); + this.devicePath = null; + this.lastGamepadState = null; + this.lastRawBytes = []; + console.log("[Flight] Device capture stopped"); + } + } + + isCapturing(): boolean { + return this.device !== null; + } + + dispose(): void { + this.disposed = true; + this.stopCapture(); + this.stopHotplugScan(); + } + + private startHotplugScan(): void { + this.stopHotplugScan(); + this.hotplugTimer = setInterval(() => { + if (this.disposed || !this.enabled) return; + if (this.device) return; + + const devices = this.getDevices(); + if (devices.length > 0 && !this.device) { + const firstDevice = devices[0]!; + const vidPid = makeVidPid(firstDevice.vendorId, firstDevice.productId); + const knownConfig = getDeviceConfig(firstDevice.vendorId, firstDevice.productId); + console.log( + `[Flight] Auto-detected device: ${knownConfig?.name ?? firstDevice.product} (${vidPid})`, + ); + } + }, HOTPLUG_SCAN_MS); + } + + private stopHotplugScan(): void { + if (this.hotplugTimer) { + clearInterval(this.hotplugTimer); + this.hotplugTimer = null; + } + } + + private handleDeviceDisconnect(): void { + console.log("[Flight] Device disconnected"); + this.stopCapture(); + } + + private onHidData(data: Buffer): void { + if (!this.reportLayout || !this.profile) { + this.lastRawBytes = Array.from(data); + this.sendRawState(data); + return; + } + + const parsed = this.parseReport(data, this.reportLayout); + this.lastRawBytes = Array.from(data); + + const rawState: FlightControlsState = { + connected: true, + deviceName: this.deviceName, + axes: parsed.axes, + buttons: parsed.buttons, + hatSwitch: parsed.hatSwitch, + rawBytes: this.lastRawBytes, + }; + this.emitStateUpdate(rawState); + + const gamepadState = this.mapToGamepad(parsed, this.profile); + if (this.hasGamepadStateChanged(gamepadState)) { + this.lastGamepadState = gamepadState; + this.emitGamepadState(gamepadState); + } + } + + private parseReport(data: Buffer, layout: FlightHidReportLayout): ParsedReport { + let offset = layout.skipReportId ? 1 : 0; + const bytes = data; + + const axes: number[] = []; + for (const axisDef of layout.axes) { + const byteIdx = offset + axisDef.byteOffset; + if (byteIdx + axisDef.byteCount > bytes.length) { + axes.push(0); + continue; + } + + let rawValue: number; + if (axisDef.byteCount === 2) { + rawValue = axisDef.littleEndian + ? bytes.readUInt16LE(byteIdx) + : bytes.readUInt16BE(byteIdx); + if (!axisDef.unsigned && rawValue > 32767) { + rawValue = rawValue - 65536; + } + } else { + rawValue = bytes.readUInt8(byteIdx); + if (!axisDef.unsigned && rawValue > 127) { + rawValue = rawValue - 256; + } + } + + const range = axisDef.rangeMax - axisDef.rangeMin; + const normalized = range > 0 ? (rawValue - axisDef.rangeMin) / range : 0; + axes.push(Math.max(0, Math.min(1, normalized))); + } + + const buttons: boolean[] = []; + for (const btnDef of layout.buttons) { + const byteIdx = offset + btnDef.byteOffset; + if (byteIdx >= bytes.length) { + buttons.push(false); + continue; + } + const byte = bytes.readUInt8(byteIdx); + buttons.push((byte & (1 << btnDef.bitIndex)) !== 0); + } + + let hatSwitch = -1; + if (layout.hat) { + const byteIdx = offset + layout.hat.byteOffset; + if (byteIdx < bytes.length) { + const byte = bytes.readUInt8(byteIdx); + const hatValue = (byte >> layout.hat.bitOffset) & ((1 << layout.hat.bitCount) - 1); + hatSwitch = hatValue === layout.hat.centerValue ? -1 : hatValue; + } + } + + return { axes, buttons, hatSwitch }; + } + + private mapToGamepad(parsed: ParsedReport, profile: FlightProfile): FlightGamepadState { + const state: FlightGamepadState = { + controllerId: this.controllerSlot, + buttons: 0, + leftTrigger: 0, + rightTrigger: 0, + leftStickX: 0, + leftStickY: 0, + rightStickX: 0, + rightStickY: 0, + connected: true, + }; + + for (const mapping of profile.axisMappings) { + if (mapping.sourceIndex >= parsed.axes.length) continue; + const rawNormalized = parsed.axes[mapping.sourceIndex]!; + + let value: number; + if (mapping.target === "leftTrigger" || mapping.target === "rightTrigger") { + value = mapping.inverted ? 1 - rawNormalized : rawNormalized; + value = this.applyDeadzone(value, mapping.deadzone); + value = this.applyCurve(value, mapping.sensitivity, mapping.curve); + const clamped = Math.max(0, Math.min(255, Math.round(value * 255))); + if (mapping.target === "leftTrigger") state.leftTrigger = clamped; + else state.rightTrigger = clamped; + } else { + value = (rawNormalized * 2) - 1; + if (mapping.inverted) value = -value; + value = this.applyStickDeadzone(value, mapping.deadzone); + value = this.applyStickCurve(value, mapping.sensitivity, mapping.curve); + const clamped = Math.max(-32768, Math.min(32767, Math.round(value * 32767))); + switch (mapping.target) { + case "leftStickX": state.leftStickX = clamped; break; + case "leftStickY": state.leftStickY = clamped; break; + case "rightStickX": state.rightStickX = clamped; break; + case "rightStickY": state.rightStickY = clamped; break; + } + } + } + + for (const mapping of profile.buttonMappings) { + if (mapping.sourceIndex >= parsed.buttons.length) continue; + if (parsed.buttons[mapping.sourceIndex]) { + state.buttons |= mapping.targetButton; + } + } + + if (parsed.hatSwitch >= 0) { + const hat = parsed.hatSwitch; + if (hat === 0 || hat === 1 || hat === 7) state.buttons |= GAMEPAD_DPAD_UP; + if (hat === 1 || hat === 2 || hat === 3) state.buttons |= GAMEPAD_DPAD_RIGHT; + if (hat === 3 || hat === 4 || hat === 5) state.buttons |= GAMEPAD_DPAD_DOWN; + if (hat === 5 || hat === 6 || hat === 7) state.buttons |= GAMEPAD_DPAD_LEFT; + } + + return state; + } + + private applyDeadzone(value: number, deadzone: number): number { + if (value < deadzone) return 0; + return (value - deadzone) / (1 - deadzone); + } + + private applyStickDeadzone(value: number, deadzone: number): number { + const abs = Math.abs(value); + if (abs < deadzone) return 0; + const sign = value >= 0 ? 1 : -1; + return sign * ((abs - deadzone) / (1 - deadzone)); + } + + private applyCurve(value: number, sensitivity: number, curve: string): number { + if (curve === "expo") { + return Math.pow(value, 2) * sensitivity; + } + return value * sensitivity; + } + + private applyStickCurve(value: number, sensitivity: number, curve: string): number { + const sign = value >= 0 ? 1 : -1; + const abs = Math.abs(value); + if (curve === "expo") { + return sign * Math.pow(abs, 2) * sensitivity; + } + return value * sensitivity; + } + + private hasGamepadStateChanged(newState: FlightGamepadState): boolean { + if (!this.lastGamepadState) return true; + const prev = this.lastGamepadState; + return ( + prev.buttons !== newState.buttons || + prev.leftTrigger !== newState.leftTrigger || + prev.rightTrigger !== newState.rightTrigger || + prev.leftStickX !== newState.leftStickX || + prev.leftStickY !== newState.leftStickY || + prev.rightStickX !== newState.rightStickX || + prev.rightStickY !== newState.rightStickY + ); + } + + private sendConnectedState(connected: boolean): void { + if (!this.mainWindow || this.mainWindow.isDestroyed()) return; + + const state: FlightControlsState = { + connected, + deviceName: this.deviceName, + axes: [], + buttons: [], + hatSwitch: -1, + rawBytes: [], + }; + this.mainWindow.webContents.send(IPC_CHANNELS.FLIGHT_STATE_UPDATE, state); + + if (!connected) { + const disconnectState: FlightGamepadState = { + controllerId: this.controllerSlot, + buttons: 0, + leftTrigger: 0, + rightTrigger: 0, + leftStickX: 0, + leftStickY: 0, + rightStickX: 0, + rightStickY: 0, + connected: false, + }; + this.mainWindow.webContents.send(IPC_CHANNELS.FLIGHT_GAMEPAD_STATE, disconnectState); + } + } + + private sendRawState(data: Buffer): void { + if (!this.mainWindow || this.mainWindow.isDestroyed()) return; + const state: FlightControlsState = { + connected: true, + deviceName: this.deviceName, + axes: [], + buttons: [], + hatSwitch: -1, + rawBytes: Array.from(data), + }; + this.mainWindow.webContents.send(IPC_CHANNELS.FLIGHT_STATE_UPDATE, state); + } + + private emitStateUpdate(state: FlightControlsState): void { + if (!this.mainWindow || this.mainWindow.isDestroyed()) return; + this.mainWindow.webContents.send(IPC_CHANNELS.FLIGHT_STATE_UPDATE, state); + } + + private emitGamepadState(state: FlightGamepadState): void { + if (!this.mainWindow || this.mainWindow.isDestroyed()) return; + this.mainWindow.webContents.send(IPC_CHANNELS.FLIGHT_GAMEPAD_STATE, state); + } +} diff --git a/opennow-stable/src/main/flight/FlightDeviceDefaults.ts b/opennow-stable/src/main/flight/FlightDeviceDefaults.ts new file mode 100644 index 00000000..4bb0db3b --- /dev/null +++ b/opennow-stable/src/main/flight/FlightDeviceDefaults.ts @@ -0,0 +1,249 @@ +import type { + FlightHidReportLayout, + FlightAxisMapping, + FlightButtonMapping, + FlightProfile, +} from "@shared/gfn"; + +import { + GAMEPAD_A, + GAMEPAD_B, + GAMEPAD_X, + GAMEPAD_Y, + GAMEPAD_START, + GAMEPAD_BACK, + GAMEPAD_LSHOULDER, + GAMEPAD_RSHOULDER, +} from "./inputConstants"; + +export interface KnownDeviceConfig { + name: string; + layout: FlightHidReportLayout; + axisMappings: FlightAxisMapping[]; + buttonMappings: FlightButtonMapping[]; +} + +const THRUSTMASTER_HOTAS_ONE_LAYOUT: FlightHidReportLayout = { + skipReportId: true, + reportLength: 14, + axes: [ + { byteOffset: 0, byteCount: 2, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 65535 }, + { byteOffset: 2, byteCount: 2, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 65535 }, + { byteOffset: 4, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + { byteOffset: 5, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + { byteOffset: 6, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + ], + buttons: [ + { byteOffset: 8, bitIndex: 0 }, + { byteOffset: 8, bitIndex: 1 }, + { byteOffset: 8, bitIndex: 2 }, + { byteOffset: 8, bitIndex: 3 }, + { byteOffset: 8, bitIndex: 4 }, + { byteOffset: 8, bitIndex: 5 }, + { byteOffset: 8, bitIndex: 6 }, + { byteOffset: 8, bitIndex: 7 }, + { byteOffset: 9, bitIndex: 0 }, + { byteOffset: 9, bitIndex: 1 }, + { byteOffset: 9, bitIndex: 2 }, + { byteOffset: 9, bitIndex: 3 }, + { byteOffset: 9, bitIndex: 4 }, + { byteOffset: 9, bitIndex: 5 }, + { byteOffset: 9, bitIndex: 6 }, + { byteOffset: 9, bitIndex: 7 }, + ], + hat: { byteOffset: 7, bitOffset: 0, bitCount: 4, centerValue: 8 }, +}; + +const THRUSTMASTER_HOTAS_ONE_AXES: FlightAxisMapping[] = [ + { sourceIndex: 0, target: "leftStickX", inverted: false, deadzone: 0.05, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 1, target: "leftStickY", inverted: true, deadzone: 0.05, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 2, target: "rightStickX", inverted: false, deadzone: 0.08, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 3, target: "rightTrigger", inverted: true, deadzone: 0.0, sensitivity: 1.0, curve: "linear" }, +]; + +const THRUSTMASTER_HOTAS_ONE_BUTTONS: FlightButtonMapping[] = [ + { sourceIndex: 0, targetButton: GAMEPAD_A }, + { sourceIndex: 1, targetButton: GAMEPAD_B }, + { sourceIndex: 2, targetButton: GAMEPAD_X }, + { sourceIndex: 3, targetButton: GAMEPAD_Y }, + { sourceIndex: 4, targetButton: GAMEPAD_LSHOULDER }, + { sourceIndex: 5, targetButton: GAMEPAD_RSHOULDER }, + { sourceIndex: 6, targetButton: GAMEPAD_BACK }, + { sourceIndex: 7, targetButton: GAMEPAD_START }, +]; + +const LOGITECH_X52_LAYOUT: FlightHidReportLayout = { + skipReportId: true, + reportLength: 14, + axes: [ + { byteOffset: 0, byteCount: 2, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 65535 }, + { byteOffset: 2, byteCount: 2, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 65535 }, + { byteOffset: 4, byteCount: 2, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 65535 }, + { byteOffset: 6, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + { byteOffset: 7, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + { byteOffset: 8, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + ], + buttons: [ + { byteOffset: 10, bitIndex: 0 }, + { byteOffset: 10, bitIndex: 1 }, + { byteOffset: 10, bitIndex: 2 }, + { byteOffset: 10, bitIndex: 3 }, + { byteOffset: 10, bitIndex: 4 }, + { byteOffset: 10, bitIndex: 5 }, + { byteOffset: 10, bitIndex: 6 }, + { byteOffset: 10, bitIndex: 7 }, + { byteOffset: 11, bitIndex: 0 }, + { byteOffset: 11, bitIndex: 1 }, + { byteOffset: 11, bitIndex: 2 }, + { byteOffset: 11, bitIndex: 3 }, + { byteOffset: 11, bitIndex: 4 }, + { byteOffset: 11, bitIndex: 5 }, + { byteOffset: 11, bitIndex: 6 }, + { byteOffset: 11, bitIndex: 7 }, + { byteOffset: 12, bitIndex: 0 }, + { byteOffset: 12, bitIndex: 1 }, + { byteOffset: 12, bitIndex: 2 }, + { byteOffset: 12, bitIndex: 3 }, + { byteOffset: 12, bitIndex: 4 }, + { byteOffset: 12, bitIndex: 5 }, + { byteOffset: 12, bitIndex: 6 }, + { byteOffset: 12, bitIndex: 7 }, + { byteOffset: 13, bitIndex: 0 }, + { byteOffset: 13, bitIndex: 1 }, + { byteOffset: 13, bitIndex: 2 }, + { byteOffset: 13, bitIndex: 3 }, + { byteOffset: 13, bitIndex: 4 }, + { byteOffset: 13, bitIndex: 5 }, + { byteOffset: 13, bitIndex: 6 }, + { byteOffset: 13, bitIndex: 7 }, + ], + hat: { byteOffset: 9, bitOffset: 0, bitCount: 4, centerValue: 8 }, +}; + +const LOGITECH_X52_AXES: FlightAxisMapping[] = [ + { sourceIndex: 0, target: "leftStickX", inverted: false, deadzone: 0.05, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 1, target: "leftStickY", inverted: true, deadzone: 0.05, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 2, target: "rightStickX", inverted: false, deadzone: 0.08, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 3, target: "rightTrigger", inverted: true, deadzone: 0.0, sensitivity: 1.0, curve: "linear" }, +]; + +const LOGITECH_X52_BUTTONS: FlightButtonMapping[] = [ + { sourceIndex: 0, targetButton: GAMEPAD_A }, + { sourceIndex: 1, targetButton: GAMEPAD_B }, + { sourceIndex: 2, targetButton: GAMEPAD_X }, + { sourceIndex: 3, targetButton: GAMEPAD_Y }, + { sourceIndex: 4, targetButton: GAMEPAD_LSHOULDER }, + { sourceIndex: 5, targetButton: GAMEPAD_RSHOULDER }, + { sourceIndex: 6, targetButton: GAMEPAD_BACK }, + { sourceIndex: 7, targetButton: GAMEPAD_START }, +]; + +const GENERIC_LAYOUT: FlightHidReportLayout = { + skipReportId: true, + reportLength: 8, + axes: [ + { byteOffset: 0, byteCount: 2, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 65535 }, + { byteOffset: 2, byteCount: 2, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 65535 }, + { byteOffset: 4, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + { byteOffset: 5, byteCount: 1, littleEndian: true, unsigned: true, rangeMin: 0, rangeMax: 255 }, + ], + buttons: [ + { byteOffset: 6, bitIndex: 0 }, + { byteOffset: 6, bitIndex: 1 }, + { byteOffset: 6, bitIndex: 2 }, + { byteOffset: 6, bitIndex: 3 }, + { byteOffset: 6, bitIndex: 4 }, + { byteOffset: 6, bitIndex: 5 }, + { byteOffset: 6, bitIndex: 6 }, + { byteOffset: 6, bitIndex: 7 }, + { byteOffset: 7, bitIndex: 0 }, + { byteOffset: 7, bitIndex: 1 }, + { byteOffset: 7, bitIndex: 2 }, + { byteOffset: 7, bitIndex: 3 }, + { byteOffset: 7, bitIndex: 4 }, + { byteOffset: 7, bitIndex: 5 }, + { byteOffset: 7, bitIndex: 6 }, + { byteOffset: 7, bitIndex: 7 }, + ], +}; + +const GENERIC_AXES: FlightAxisMapping[] = [ + { sourceIndex: 0, target: "leftStickX", inverted: false, deadzone: 0.05, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 1, target: "leftStickY", inverted: true, deadzone: 0.05, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 2, target: "rightStickX", inverted: false, deadzone: 0.08, sensitivity: 1.0, curve: "linear" }, + { sourceIndex: 3, target: "rightTrigger", inverted: true, deadzone: 0.0, sensitivity: 1.0, curve: "linear" }, +]; + +const GENERIC_BUTTONS: FlightButtonMapping[] = [ + { sourceIndex: 0, targetButton: GAMEPAD_A }, + { sourceIndex: 1, targetButton: GAMEPAD_B }, + { sourceIndex: 2, targetButton: GAMEPAD_X }, + { sourceIndex: 3, targetButton: GAMEPAD_Y }, + { sourceIndex: 4, targetButton: GAMEPAD_LSHOULDER }, + { sourceIndex: 5, targetButton: GAMEPAD_RSHOULDER }, + { sourceIndex: 6, targetButton: GAMEPAD_BACK }, + { sourceIndex: 7, targetButton: GAMEPAD_START }, +]; + +export const KNOWN_DEVICES: Record = { + "044F:B67B": { + name: "Thrustmaster T.Flight HOTAS One", + layout: THRUSTMASTER_HOTAS_ONE_LAYOUT, + axisMappings: THRUSTMASTER_HOTAS_ONE_AXES, + buttonMappings: THRUSTMASTER_HOTAS_ONE_BUTTONS, + }, + "044F:B679": { + name: "Thrustmaster T.Flight HOTAS One (PC)", + layout: THRUSTMASTER_HOTAS_ONE_LAYOUT, + axisMappings: THRUSTMASTER_HOTAS_ONE_AXES, + buttonMappings: THRUSTMASTER_HOTAS_ONE_BUTTONS, + }, + "044F:B67D": { + name: "Thrustmaster T.Flight HOTAS 4", + layout: THRUSTMASTER_HOTAS_ONE_LAYOUT, + axisMappings: THRUSTMASTER_HOTAS_ONE_AXES, + buttonMappings: THRUSTMASTER_HOTAS_ONE_BUTTONS, + }, + "06A3:0762": { + name: "Logitech X52", + layout: LOGITECH_X52_LAYOUT, + axisMappings: LOGITECH_X52_AXES, + buttonMappings: LOGITECH_X52_BUTTONS, + }, + "06A3:0255": { + name: "Logitech X52 Pro", + layout: LOGITECH_X52_LAYOUT, + axisMappings: LOGITECH_X52_AXES, + buttonMappings: LOGITECH_X52_BUTTONS, + }, +}; + +export function getDeviceConfig(vendorId: number, productId: number): KnownDeviceConfig | null { + const key = `${vendorId.toString(16).toUpperCase().padStart(4, "0")}:${productId.toString(16).toUpperCase().padStart(4, "0")}`; + return KNOWN_DEVICES[key] ?? null; +} + +export function getGenericConfig(deviceName: string): KnownDeviceConfig { + return { + name: deviceName || "Generic Joystick", + layout: GENERIC_LAYOUT, + axisMappings: GENERIC_AXES, + buttonMappings: GENERIC_BUTTONS, + }; +} + +export function makeVidPid(vendorId: number, productId: number): string { + return `${vendorId.toString(16).toUpperCase().padStart(4, "0")}:${productId.toString(16).toUpperCase().padStart(4, "0")}`; +} + +export function buildDefaultProfile(vendorId: number, productId: number, deviceName: string): FlightProfile { + const config = getDeviceConfig(vendorId, productId) ?? getGenericConfig(deviceName); + return { + name: config.name, + vidPid: makeVidPid(vendorId, productId), + deviceName: deviceName || config.name, + axisMappings: config.axisMappings.map((m) => ({ ...m })), + buttonMappings: config.buttonMappings.map((m) => ({ ...m })), + reportLayout: config.layout, + }; +} diff --git a/opennow-stable/src/main/flight/FlightProfiles.ts b/opennow-stable/src/main/flight/FlightProfiles.ts new file mode 100644 index 00000000..635548ea --- /dev/null +++ b/opennow-stable/src/main/flight/FlightProfiles.ts @@ -0,0 +1,110 @@ +import { app } from "electron"; +import { join } from "node:path"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import type { FlightProfile } from "@shared/gfn"; +import { buildDefaultProfile } from "./FlightDeviceDefaults"; + +const PROFILES_FILENAME = "flight-profiles.json"; + +interface ProfileStore { + profiles: FlightProfile[]; +} + +export class FlightProfileManager { + private readonly profilesPath: string; + private store: ProfileStore; + + constructor() { + this.profilesPath = join(app.getPath("userData"), PROFILES_FILENAME); + this.store = this.load(); + } + + private load(): ProfileStore { + try { + if (!existsSync(this.profilesPath)) { + return { profiles: [] }; + } + const content = readFileSync(this.profilesPath, "utf-8"); + const parsed = JSON.parse(content) as Partial; + return { profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [] }; + } catch (error) { + console.error("[Flight] Failed to load profiles:", error); + return { profiles: [] }; + } + } + + private save(): void { + try { + const dir = join(app.getPath("userData")); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(this.profilesPath, JSON.stringify(this.store, null, 2), "utf-8"); + } catch (error) { + console.error("[Flight] Failed to save profiles:", error); + } + } + + getProfile(vidPid: string, gameId?: string): FlightProfile | null { + if (gameId) { + const gameProfile = this.store.profiles.find( + (p) => p.vidPid === vidPid && p.gameId === gameId, + ); + if (gameProfile) return gameProfile; + } + return this.store.profiles.find( + (p) => p.vidPid === vidPid && !p.gameId, + ) ?? null; + } + + setProfile(profile: FlightProfile): void { + const idx = this.store.profiles.findIndex( + (p) => p.vidPid === profile.vidPid && p.gameId === profile.gameId, + ); + if (idx >= 0) { + this.store.profiles[idx] = profile; + } else { + this.store.profiles.push(profile); + } + this.save(); + } + + deleteProfile(vidPid: string, gameId?: string): void { + this.store.profiles = this.store.profiles.filter( + (p) => !(p.vidPid === vidPid && p.gameId === gameId), + ); + this.save(); + } + + getAllProfiles(): FlightProfile[] { + return [...this.store.profiles]; + } + + resetProfile(vidPid: string): FlightProfile | null { + const parts = vidPid.split(":"); + if (parts.length !== 2) return null; + const vendorId = parseInt(parts[0]!, 16); + const productId = parseInt(parts[1]!, 16); + if (!Number.isFinite(vendorId) || !Number.isFinite(productId)) return null; + + this.store.profiles = this.store.profiles.filter( + (p) => !(p.vidPid === vidPid && !p.gameId), + ); + + const defaultProfile = buildDefaultProfile(vendorId, productId, ""); + this.store.profiles.push(defaultProfile); + this.save(); + return defaultProfile; + } + + getOrCreateProfile(vendorId: number, productId: number, deviceName: string): FlightProfile { + const vidPid = `${vendorId.toString(16).toUpperCase().padStart(4, "0")}:${productId.toString(16).toUpperCase().padStart(4, "0")}`; + const existing = this.getProfile(vidPid); + if (existing) return existing; + + const profile = buildDefaultProfile(vendorId, productId, deviceName); + this.store.profiles.push(profile); + this.save(); + return profile; + } +} diff --git a/opennow-stable/src/main/flight/inputConstants.ts b/opennow-stable/src/main/flight/inputConstants.ts new file mode 100644 index 00000000..c0955f23 --- /dev/null +++ b/opennow-stable/src/main/flight/inputConstants.ts @@ -0,0 +1,15 @@ +export const GAMEPAD_DPAD_UP = 0x0001; +export const GAMEPAD_DPAD_DOWN = 0x0002; +export const GAMEPAD_DPAD_LEFT = 0x0004; +export const GAMEPAD_DPAD_RIGHT = 0x0008; +export const GAMEPAD_START = 0x0010; +export const GAMEPAD_BACK = 0x0020; +export const GAMEPAD_LTHUMB = 0x0040; +export const GAMEPAD_RTHUMB = 0x0080; +export const GAMEPAD_LSHOULDER = 0x0100; +export const GAMEPAD_RSHOULDER = 0x0200; +export const GAMEPAD_A = 0x1000; +export const GAMEPAD_B = 0x2000; +export const GAMEPAD_X = 0x4000; +export const GAMEPAD_Y = 0x8000; +export const GAMEPAD_MAX_CONTROLLERS = 4; diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index c15821f8..4ecffe07 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -29,7 +29,8 @@ import type { VideoAccelerationPreference, SubscriptionFetchRequest, SessionConflictChoice, -} from "@shared/gfn"; + DiscordPresencePayload, + FlightProfile,} from "@shared/gfn"; import { getSettingsManager, type SettingsManager } from "./settings"; @@ -44,7 +45,8 @@ import { import { fetchSubscription, fetchDynamicRegions } from "./gfn/subscription"; import { GfnSignalingClient } from "./gfn/signaling"; import { isSessionError, SessionError, GfnErrorCode } from "./gfn/errorCodes"; - +import { DiscordPresenceService } from "./discord/DiscordPresenceService"; +import { FlightControlsService } from "./flight/FlightControlsService"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -184,7 +186,8 @@ let signalingClient: GfnSignalingClient | null = null; let signalingClientKey: string | null = null; let authService: AuthService; let settingsManager: SettingsManager; - +let discordService: DiscordPresenceService; +let flightService: FlightControlsService; function emitToRenderer(event: MainToRendererSignalingEvent): void { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(IPC_CHANNELS.SIGNALING_EVENT, event); @@ -489,7 +492,14 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.SETTINGS_SET, async (_event: Electron.IpcMainInvokeEvent, key: K, value: Settings[K]) => { settingsManager.set(key, value); - }); + if (key === "discordPresenceEnabled" || key === "discordClientId") { + const all = settingsManager.getAll(); + void discordService.updateConfig(all.discordPresenceEnabled, all.discordClientId); + } + if (key === "flightControlsEnabled" || key === "flightControlsSlot") { + const all = settingsManager.getAll(); + flightService.updateConfig(all.flightControlsEnabled, all.flightControlsSlot); + } }); ipcMain.handle(IPC_CHANNELS.SETTINGS_RESET, async (): Promise => { return settingsManager.reset(); @@ -500,6 +510,39 @@ function registerIpcHandlers(): void { return exportLogs(format); }); + // Flight Controls IPC handlers + ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_DEVICES, () => { + return flightService.getDevices(); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_START_CAPTURE, (_event, devicePath: string) => { + return flightService.startCapture(devicePath); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_STOP_CAPTURE, () => { + flightService.stopCapture(); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_PROFILE, (_event, vidPid: string, gameId?: string) => { + return flightService.profileManager.getProfile(vidPid, gameId); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_SET_PROFILE, (_event, profile: FlightProfile) => { + flightService.profileManager.setProfile(profile); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, (_event, vidPid: string, gameId?: string) => { + flightService.profileManager.deleteProfile(vidPid, gameId); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES, () => { + return flightService.profileManager.getAllProfiles(); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_RESET_PROFILE, (_event, vidPid: string) => { + return flightService.profileManager.resetProfile(vidPid); + }); + // Save window size when it changes mainWindow?.on("resize", () => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -569,8 +612,17 @@ app.whenReady().then(async () => { return allowedPermissions.has(permission); }); + flightService = new FlightControlsService( + allSettings.flightControlsEnabled, + allSettings.flightControlsSlot, + ); + flightService.initialize(); + registerIpcHandlers(); await createMainWindow(); + if (mainWindow) { + flightService.setMainWindow(mainWindow); + } app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -589,7 +641,8 @@ app.on("before-quit", () => { signalingClient?.disconnect(); signalingClient = null; signalingClientKey = null; -}); + void discordService.dispose(); + flightService.dispose();}); // Export for use by other modules export { showSessionConflictDialog, isSessionConflictError }; diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 6f3226e9..3b47cd19 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -48,7 +48,14 @@ export interface Settings { windowWidth: number; /** Window height */ windowHeight: number; -} + /** Enable Discord Rich Presence */ + discordPresenceEnabled: boolean; + /** Discord Application Client ID */ + discordClientId: string; + /** Enable flight controls (HOTAS/joystick) */ + flightControlsEnabled: boolean; + /** Controller slot for flight controls (0-3) */ + flightControlsSlot: number;} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; @@ -79,7 +86,10 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, -}; + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3,}; export class SettingsManager { private settings: Settings; diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 155321c2..30d20a69 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -18,7 +18,10 @@ import type { IceCandidatePayload, Settings, SubscriptionFetchRequest, -} from "@shared/gfn"; + DiscordPresencePayload, + FlightProfile, + FlightControlsState, + FlightGamepadState,} from "@shared/gfn"; // Extend the OpenNowApi interface for internal preload use type PreloadApi = OpenNowApi; @@ -74,6 +77,37 @@ const api: PreloadApi = { ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_SET, key, value), resetSettings: () => ipcRenderer.invoke(IPC_CHANNELS.SETTINGS_RESET), exportLogs: (format?: "text" | "json") => ipcRenderer.invoke(IPC_CHANNELS.LOGS_EXPORT, format), -}; + updateDiscordPresence: (state: DiscordPresencePayload) => + ipcRenderer.invoke(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, state), + clearDiscordPresence: () => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE), + flightGetDevices: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_DEVICES), + flightStartCapture: (devicePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_START_CAPTURE, devicePath), + flightStopCapture: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_STOP_CAPTURE), + flightGetProfile: (vidPid: string, gameId?: string) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_PROFILE, vidPid, gameId), + flightSetProfile: (profile: FlightProfile) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_SET_PROFILE, profile), + flightDeleteProfile: (vidPid: string, gameId?: string) => + ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, vidPid, gameId), + flightGetAllProfiles: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES), + flightResetProfile: (vidPid: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_RESET_PROFILE, vidPid), + onFlightStateUpdate: (listener: (state: FlightControlsState) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, payload: FlightControlsState) => { + listener(payload); + }; + ipcRenderer.on(IPC_CHANNELS.FLIGHT_STATE_UPDATE, wrapped); + return () => { + ipcRenderer.off(IPC_CHANNELS.FLIGHT_STATE_UPDATE, wrapped); + }; + }, + onFlightGamepadState: (listener: (state: FlightGamepadState) => void) => { + const wrapped = (_event: Electron.IpcRendererEvent, payload: FlightGamepadState) => { + listener(payload); + }; + ipcRenderer.on(IPC_CHANNELS.FLIGHT_GAMEPAD_STATE, wrapped); + return () => { + ipcRenderer.off(IPC_CHANNELS.FLIGHT_GAMEPAD_STATE, wrapped); + }; + },}; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f62a8bde..4f77c58e 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -13,7 +13,8 @@ import type { SubscriptionInfo, StreamRegion, VideoCodec, -} from "@shared/gfn"; + DiscordPresencePayload, + FlightGamepadState,} from "@shared/gfn"; import { GfnWebRtcClient, @@ -286,7 +287,10 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, - }); + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -658,7 +662,64 @@ export function App(): JSX.Element { return () => window.clearInterval(timer); }, [sessionStartedAtMs, streamStatus]); + // Discord Rich Presence updates + useEffect(() => { + if (!settings.discordPresenceEnabled || !settings.discordClientId) { + return; + } + + let payload: DiscordPresencePayload; + + if (streamStatus === "idle") { + payload = { type: "idle" }; + } else if (streamStatus === "queue" || streamStatus === "setup") { + const queueTitle = streamingGame?.title?.trim() || lastStreamGameTitleRef.current || undefined; + payload = { + type: "queue", + gameName: queueTitle, + queuePosition, + }; + } else { + const hasDiag = diagnostics.resolution !== "" || diagnostics.bitrateKbps > 0; + const gameTitle = streamingGame?.title?.trim() || lastStreamGameTitleRef.current || undefined; + payload = { + type: "streaming", + gameName: gameTitle, + startTimestamp: sessionStartedAtMs ?? undefined, + ...(hasDiag && diagnostics.resolution ? { resolution: diagnostics.resolution } : {}), + ...(hasDiag && diagnostics.decodeFps > 0 ? { fps: diagnostics.decodeFps } : {}), + ...(hasDiag && diagnostics.bitrateKbps > 0 ? { bitrateMbps: Math.round(diagnostics.bitrateKbps / 100) / 10 } : {}), + }; + } + + window.openNow.updateDiscordPresence(payload).catch(() => {}); + }, [ + streamStatus, + streamingGame?.title, + sessionStartedAtMs, + queuePosition, + diagnostics.resolution, + diagnostics.decodeFps, + diagnostics.bitrateKbps, + settings.discordPresenceEnabled, + settings.discordClientId, + ]); + + // Clear Discord presence on logout useEffect(() => { + if (!authSession) { + window.openNow.clearDiscordPresence().catch(() => {}); + } + }, [authSession]); + + // Flight controls: forward gamepad state from main process to WebRTC client + useEffect(() => { + if (!settings.flightControlsEnabled) return; + const unsubscribe = window.openNow.onFlightGamepadState((state: FlightGamepadState) => { + clientRef.current?.injectExternalGamepad(state); + }); + return unsubscribe; + }, [settings.flightControlsEnabled]); useEffect(() => { if (!streamWarning) return; const warning = streamWarning; const timer = window.setTimeout(() => { diff --git a/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx b/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx new file mode 100644 index 00000000..2aad2c95 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx @@ -0,0 +1,445 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import type { JSX } from "react"; +import { Joystick, RefreshCw, Save, Trash2, RotateCcw, Check } from "lucide-react"; +import type { + Settings, + FlightDeviceInfo, + FlightProfile, + FlightControlsState, + FlightAxisTarget, + FlightSensitivityCurve, +} from "@shared/gfn"; + +interface FlightControlsPanelProps { + settings: Settings; + onSettingChange: (key: K, value: Settings[K]) => void; +} + +const AXIS_TARGETS: { value: FlightAxisTarget; label: string }[] = [ + { value: "leftStickX", label: "Left Stick X (Roll)" }, + { value: "leftStickY", label: "Left Stick Y (Pitch)" }, + { value: "rightStickX", label: "Right Stick X (Yaw)" }, + { value: "rightStickY", label: "Right Stick Y" }, + { value: "leftTrigger", label: "Left Trigger" }, + { value: "rightTrigger", label: "Right Trigger (Throttle)" }, +]; + +const CURVE_OPTIONS: { value: FlightSensitivityCurve; label: string }[] = [ + { value: "linear", label: "Linear" }, + { value: "expo", label: "Exponential" }, +]; + +const SLOT_OPTIONS = [ + { value: 0, label: "Slot 0" }, + { value: 1, label: "Slot 1" }, + { value: 2, label: "Slot 2" }, + { value: 3, label: "Slot 3" }, +]; + +function makeVidPid(vendorId: number, productId: number): string { + return `${vendorId.toString(16).toUpperCase().padStart(4, "0")}:${productId.toString(16).toUpperCase().padStart(4, "0")}`; +} + +export function FlightControlsPanel({ settings, onSettingChange }: FlightControlsPanelProps): JSX.Element { + const [devices, setDevices] = useState([]); + const [selectedPath, setSelectedPath] = useState(""); + const [capturing, setCapturing] = useState(false); + const [liveState, setLiveState] = useState(null); + const [profile, setProfile] = useState(null); + const [profiles, setProfiles] = useState([]); + const [savedIndicator, setSavedIndicator] = useState(false); + const [isScanning, setIsScanning] = useState(false); + const stateCleanupRef = useRef<(() => void) | null>(null); + + const scanDevices = useCallback(async () => { + setIsScanning(true); + try { + const found = await window.openNow.flightGetDevices(); + setDevices(found); + if (found.length > 0 && !selectedPath) { + setSelectedPath(found[0]!.path); + } + } catch (error) { + console.warn("[Flight UI] Failed to scan devices:", error); + } finally { + setIsScanning(false); + } + }, [selectedPath]); + + const loadProfiles = useCallback(async () => { + try { + const all = await window.openNow.flightGetAllProfiles(); + setProfiles(all); + } catch (error) { + console.warn("[Flight UI] Failed to load profiles:", error); + } + }, []); + + useEffect(() => { + if (settings.flightControlsEnabled) { + void scanDevices(); + void loadProfiles(); + } + }, [settings.flightControlsEnabled, scanDevices, loadProfiles]); + + useEffect(() => { + if (!settings.flightControlsEnabled) return; + const cleanup = window.openNow.onFlightStateUpdate((state: FlightControlsState) => { + setLiveState(state); + }); + stateCleanupRef.current = cleanup; + return () => { + cleanup(); + stateCleanupRef.current = null; + }; + }, [settings.flightControlsEnabled]); + + const handleStartCapture = useCallback(async () => { + if (!selectedPath) return; + try { + const success = await window.openNow.flightStartCapture(selectedPath); + setCapturing(success); + if (success) { + const device = devices.find((d) => d.path === selectedPath); + if (device) { + const vidPid = makeVidPid(device.vendorId, device.productId); + const p = await window.openNow.flightGetProfile(vidPid); + setProfile(p); + } + } + } catch (error) { + console.warn("[Flight UI] Failed to start capture:", error); + } + }, [selectedPath, devices]); + + const handleStopCapture = useCallback(async () => { + try { + await window.openNow.flightStopCapture(); + setCapturing(false); + setLiveState(null); + } catch (error) { + console.warn("[Flight UI] Failed to stop capture:", error); + } + }, []); + + const handleSaveProfile = useCallback(async () => { + if (!profile) return; + try { + await window.openNow.flightSetProfile(profile); + setSavedIndicator(true); + setTimeout(() => setSavedIndicator(false), 1500); + void loadProfiles(); + } catch (error) { + console.warn("[Flight UI] Failed to save profile:", error); + } + }, [profile, loadProfiles]); + + const handleResetProfile = useCallback(async () => { + if (!profile) return; + try { + const reset = await window.openNow.flightResetProfile(profile.vidPid); + if (reset) setProfile(reset); + void loadProfiles(); + } catch (error) { + console.warn("[Flight UI] Failed to reset profile:", error); + } + }, [profile, loadProfiles]); + + const handleDeleteProfile = useCallback(async (vidPid: string, gameId?: string) => { + try { + await window.openNow.flightDeleteProfile(vidPid, gameId); + void loadProfiles(); + } catch (error) { + console.warn("[Flight UI] Failed to delete profile:", error); + } + }, [loadProfiles]); + + const updateAxisMapping = useCallback((sourceIndex: number, field: string, value: unknown) => { + if (!profile) return; + const updated = { ...profile }; + updated.axisMappings = updated.axisMappings.map((m) => { + if (m.sourceIndex !== sourceIndex) return m; + return { ...m, [field]: value }; + }); + setProfile(updated); + }, [profile]); + + const _selectedDevice = devices.find((d) => d.path === selectedPath); + + return ( +
+
+ + +
+ + {settings.flightControlsEnabled && ( + <> +
+ + +
+ +
+
+

Detected Devices

+ +
+ + {devices.length === 0 ? ( +
+ No flight controllers detected. Connect a device and click Scan. +
+ ) : ( +
+ {devices.map((device) => ( + + ))} +
+ )} + +
+ {!capturing ? ( + + ) : ( + + )} +
+
+ + {capturing && liveState && ( +
+

Live Input Tester

+
+ + {liveState.connected ? liveState.deviceName : "Disconnected"} +
+ + {liveState.axes.length > 0 && ( +
+ {liveState.axes.map((value, i) => ( +
+ Axis {i} +
+
+
+ {(value * 100).toFixed(0)}% +
+ ))} +
+ )} + + {liveState.buttons.length > 0 && ( +
+ {liveState.buttons.map((pressed, i) => ( +
+ {i} +
+ ))} +
+ )} + + {liveState.hatSwitch >= 0 && ( +
+ Hat: {liveState.hatSwitch} +
+ )} + + {liveState.rawBytes.length > 0 && ( +
+ Raw Bytes ({liveState.rawBytes.length}) + + {liveState.rawBytes.map((b) => b.toString(16).padStart(2, "0")).join(" ")} + +
+ )} +
+ )} + + {profile && ( +
+
+

Axis Mapping — {profile.name}

+
+ + +
+
+ +
+ {profile.axisMappings.map((mapping) => ( +
+
+ Axis {mapping.sourceIndex} +
+
+ + + + + +
+
+ ))} +
+
+ )} + + {profiles.length > 0 && ( +
+

Saved Profiles

+
+ {profiles.map((p) => ( +
+
+ {p.name} + + {p.vidPid} + {p.gameId ? ` · Game: ${p.gameId}` : " · Global"} + +
+ +
+ ))} +
+
+ )} + + )} +
+ ); +} diff --git a/opennow-stable/src/renderer/src/components/GameCard.tsx b/opennow-stable/src/renderer/src/components/GameCard.tsx index 584725e5..32b685e2 100644 --- a/opennow-stable/src/renderer/src/components/GameCard.tsx +++ b/opennow-stable/src/renderer/src/components/GameCard.tsx @@ -1,4 +1,4 @@ -import { Play, Monitor } from "lucide-react"; +import { Play, Monitor, Joystick } from "lucide-react"; import { memo } from "react"; import type { JSX } from "react"; import type { GameInfo, GameVariant } from "@shared/gfn"; @@ -138,8 +138,20 @@ function getUniqueStores(game: GameInfo): string[] { return stores; } +function hasFlightControls(game: GameInfo): boolean { + const flightKeywords = ["flight", "hotas", "joystick", "flightstick", "flight_stick"]; + for (const v of game.variants) { + for (const ctrl of v.supportedControls) { + const lower = ctrl.toLowerCase(); + if (flightKeywords.some((kw) => lower.includes(kw))) return true; + } + } + return false; +} + export const GameCard = memo(function GameCard({ game, isSelected = false, onPlay, onSelect }: GameCardProps): JSX.Element { const stores = getUniqueStores(game); + const flightSupported = hasFlightControls(game); const handlePlayClick = (event: React.MouseEvent): void => { event.stopPropagation(); @@ -204,6 +216,11 @@ export const GameCard = memo(function GameCard({ game, isSelected = false, onPla })}
)} + {flightSupported && ( + + + + )} ); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..fb0dec8d 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1,6 +1,7 @@ import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown } from "lucide-react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import type { JSX } from "react"; +import { Monitor, Volume2, Mouse, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick } from "lucide-react"; +import { useState, useCallback, useMemo, useEffect } from "react";import type { JSX } from "react"; import type { Settings, @@ -13,6 +14,7 @@ import type { } from "@shared/gfn"; import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; +import { FlightControlsPanel } from "./FlightControlsPanel"; interface SettingsPageProps { settings: Settings; @@ -1418,6 +1420,20 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag + + {/* ── Flight Controls ──────────────────────────────── */} +
+
+ +

Flight Controls

+
+
+ +
+
{/* Footer */} diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 3911ba3e..801084d5 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -5,7 +5,7 @@ import type { SessionInfo, VideoCodec, MicrophoneMode, -} from "@shared/gfn"; + FlightGamepadState,} from "@shared/gfn"; import { InputEncoder, @@ -480,6 +480,7 @@ export class GfnWebRtcClient { private renderFpsCounter = { frames: 0, lastUpdate: 0, fps: 0 }; private connectedGamepads: Set = new Set(); private previousGamepadStates: Map = new Map(); + private externalGamepadSlots: Set = new Set(); // Track currently pressed keys (VK codes) for synthetic Escape detection private pressedKeys: Set = new Set(); @@ -1199,6 +1200,9 @@ export class GfnWebRtcClient { const nowMs = performance.now(); for (let i = 0; i < Math.min(gamepads.length, GAMEPAD_MAX_CONTROLLERS); i++) { + if (this.externalGamepadSlots.has(i)) { + continue; + } const gamepad = gamepads[i]; if (gamepad && gamepad.connected) { @@ -1685,6 +1689,73 @@ export class GfnWebRtcClient { return true; } + public injectExternalGamepad(raw: FlightGamepadState): void { + if (!this.inputReady) return; + + const slot = raw.controllerId; + if (slot < 0 || slot >= GAMEPAD_MAX_CONTROLLERS) return; + + const state: GamepadInput = { + controllerId: slot, + buttons: raw.buttons, + leftTrigger: raw.leftTrigger, + rightTrigger: raw.rightTrigger, + leftStickX: raw.leftStickX, + leftStickY: raw.leftStickY, + rightStickX: raw.rightStickX, + rightStickY: raw.rightStickY, + connected: raw.connected, + timestampUs: timestampUs(), + }; + + if (raw.connected && !this.externalGamepadSlots.has(slot)) { + this.externalGamepadSlots.add(slot); + this.connectedGamepads.add(slot); + this.gamepadBitmap |= (1 << slot); + this.log(`External gamepad connected on slot ${slot} (flight controls)`); + this.diagnostics.connectedGamepads = this.connectedGamepads.size; + this.emitStats(); + } else if (!raw.connected && this.externalGamepadSlots.has(slot)) { + this.externalGamepadSlots.delete(slot); + this.connectedGamepads.delete(slot); + this.previousGamepadStates.delete(slot); + this.gamepadBitmap &= ~(1 << slot); + this.log(`External gamepad disconnected from slot ${slot}`); + this.diagnostics.connectedGamepads = this.connectedGamepads.size; + this.emitStats(); + const usePR = this.mouseInputChannel?.readyState === "open"; + const bytes = this.inputEncoder.encodeGamepadState(state, this.gamepadBitmap, usePR); + this.sendGamepad(bytes); + return; + } + + if (!raw.connected) return; + + const stateChanged = this.hasGamepadStateChanged(slot, state); + const nowMs = performance.now(); + const needsKeepalive = + !stateChanged && (nowMs - this.lastGamepadSendMs) >= GfnWebRtcClient.GAMEPAD_KEEPALIVE_MS; + + if (stateChanged || needsKeepalive) { + const usePR = this.mouseInputChannel?.readyState === "open"; + const bytes = this.inputEncoder.encodeGamepadState(state, this.gamepadBitmap, usePR); + this.sendGamepad(bytes); + this.lastGamepadSendMs = nowMs; + + if (stateChanged) { + this.previousGamepadStates.set(slot, { ...state }); + this.lastGamepadActivityMs = nowMs; + } + + if (this.activeInputMode !== "gamepad") { + this.activeInputMode = "gamepad"; + this.pendingMouseDx = 0; + this.pendingMouseDy = 0; + this.log("Input mode → gamepad (flight controls)"); + } + } + } + public sendPasteShortcut(useMeta: boolean): boolean { if (!this.inputReady || !this.ensureKeyboardInputMode()) { return false; diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index b6166226..30f0f9c6 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1265,6 +1265,13 @@ body.controller-mode { color: var(--ink-soft); background: var(--panel-border-solid); } +.game-card-flight-badge { + display: flex; align-items: center; justify-content: center; + width: 22px; height: 22px; + background: rgba(59, 130, 246, 0.15); + border-radius: 4px; + color: #60a5fa; +} .store-svg { display: block; flex-shrink: 0; } @@ -2695,3 +2702,379 @@ body.controller-mode { animation-iteration-count: infinite !important; } } + +/* ── Flight Controls ─────────────────────────────── */ +.flight-controls-panel { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.flight-device-section { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} + +.flight-device-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.flight-device-header h3 { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--ink); +} + +.flight-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--panel-border-solid); + border-radius: 6px; + background: var(--chip); + color: var(--ink-soft); + font-size: 0.78rem; + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); +} +.flight-btn:hover { + background: var(--panel-border-solid); + color: var(--ink); +} +.flight-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.flight-btn--small { + padding: 4px 8px; + font-size: 0.75rem; +} +.flight-btn--primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.flight-btn--primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +.flight-btn--danger { + border-color: #ef4444; + color: #ef4444; +} +.flight-btn--danger:hover { + background: rgba(239, 68, 68, 0.15); +} + +.flight-spin { + animation: spin 1s linear infinite; +} + +.flight-empty { + padding: 16px; + text-align: center; + color: var(--ink-muted); + font-size: 0.8rem; + background: var(--panel); + border-radius: 8px; + border: 1px dashed var(--panel-border-solid); +} + +.flight-device-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.flight-device-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border: 1px solid var(--panel-border-solid); + border-radius: 6px; + cursor: pointer; + transition: border-color var(--t-fast), background var(--t-fast); +} +.flight-device-item:hover { + border-color: rgba(255, 255, 255, 0.12); +} +.flight-device-item.active { + border-color: var(--accent); + background: rgba(99, 102, 241, 0.06); +} +.flight-device-item input[type="radio"] { + accent-color: var(--accent); +} +.flight-device-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.flight-device-name { + font-size: 0.82rem; + font-weight: 500; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.flight-device-meta { + font-size: 0.72rem; + color: var(--ink-muted); + font-family: var(--font-mono, monospace); +} + +.flight-capture-controls { + display: flex; + gap: 8px; +} + +/* Live tester */ +.flight-tester { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + background: var(--panel); + border: 1px solid var(--panel-border-solid); + border-radius: 8px; + margin-top: 4px; +} +.flight-tester h3 { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--ink); +} +.flight-tester-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: var(--ink-soft); +} + +.flight-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ef4444; + flex-shrink: 0; +} +.flight-status-dot.connected { + background: #22c55e; +} + +.flight-axes-grid { + display: flex; + flex-direction: column; + gap: 6px; +} +.flight-axis-bar { + display: flex; + align-items: center; + gap: 8px; +} +.flight-axis-label { + font-size: 0.72rem; + color: var(--ink-muted); + min-width: 50px; + font-family: var(--font-mono, monospace); +} +.flight-axis-track { + flex: 1; + height: 8px; + background: rgba(255, 255, 255, 0.06); + border-radius: 4px; + overflow: hidden; +} +.flight-axis-fill { + height: 100%; + background: var(--accent); + border-radius: 4px; + transition: width 50ms linear; +} +.flight-axis-value { + font-size: 0.72rem; + color: var(--ink-muted); + min-width: 36px; + text-align: right; + font-family: var(--font-mono, monospace); +} + +.flight-buttons-grid { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.flight-button-indicator { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + border: 1px solid var(--panel-border-solid); + font-size: 0.7rem; + font-family: var(--font-mono, monospace); + color: var(--ink-muted); + transition: background 80ms, color 80ms; +} +.flight-button-indicator.pressed { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.flight-hat-indicator { + font-size: 0.78rem; + color: var(--ink-soft); + font-family: var(--font-mono, monospace); +} + +.flight-raw-bytes { + font-size: 0.75rem; + color: var(--ink-muted); +} +.flight-raw-bytes summary { + cursor: pointer; + user-select: none; +} +.flight-raw-bytes-data { + display: block; + margin-top: 6px; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + font-family: var(--font-mono, monospace); + font-size: 0.7rem; + word-break: break-all; + line-height: 1.5; +} + +/* Mapping section */ +.flight-mapping-section { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} +.flight-mapping-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.flight-mapping-header h3 { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--ink); +} +.flight-mapping-actions { + display: flex; + gap: 6px; +} + +.flight-mappings-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.flight-mapping-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--panel-border-solid); + border-radius: 6px; +} +.flight-mapping-source { + font-size: 0.78rem; + font-weight: 600; + color: var(--ink-soft); +} +.flight-mapping-fields { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} +.flight-mapping-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.75rem; + color: var(--ink-muted); +} +.flight-mapping-field select, +.flight-mapping-field input[type="range"] { + font-size: 0.78rem; +} +.flight-mapping-field--checkbox { + flex-direction: row; + align-items: center; + gap: 6px; +} +.flight-mapping-field--checkbox input[type="checkbox"] { + accent-color: var(--accent); +} +.flight-mapping-value { + font-size: 0.7rem; + color: var(--ink-muted); + font-family: var(--font-mono, monospace); + min-width: 32px; + text-align: right; +} + +/* Profiles section */ +.flight-profiles-section { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} +.flight-profiles-section h3 { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--ink); +} +.flight-profiles-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.flight-profile-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--panel); + border: 1px solid var(--panel-border-solid); + border-radius: 6px; +} +.flight-profile-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.flight-profile-name { + font-size: 0.8rem; + font-weight: 500; + color: var(--ink); +} +.flight-profile-meta { + font-size: 0.7rem; + color: var(--ink-muted); + font-family: var(--font-mono, monospace); +} diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index eaa823ba..0e74d597 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -49,7 +49,10 @@ export interface Settings { sessionClockShowDurationSeconds: number; windowWidth: number; windowHeight: number; -} + discordPresenceEnabled: boolean; + discordClientId: string; + flightControlsEnabled: boolean; + flightControlsSlot: number;} export interface LoginProvider { idpId: string; @@ -332,4 +335,123 @@ export interface OpenNowApi { resetSettings(): Promise; /** Export logs in redacted format */ exportLogs(format?: "text" | "json"): Promise; -} + updateDiscordPresence(state: DiscordPresencePayload): Promise; + clearDiscordPresence(): Promise; + flightGetDevices(): Promise; + flightStartCapture(devicePath: string): Promise; + flightStopCapture(): Promise; + flightGetProfile(vidPid: string, gameId?: string): Promise; + flightSetProfile(profile: FlightProfile): Promise; + flightDeleteProfile(vidPid: string, gameId?: string): Promise; + flightGetAllProfiles(): Promise; + flightResetProfile(vidPid: string): Promise; + onFlightStateUpdate(listener: (state: FlightControlsState) => void): () => void; + onFlightGamepadState(listener: (state: FlightGamepadState) => void): () => void; +} + +export type FlightAxisTarget = + | "leftStickX" + | "leftStickY" + | "rightStickX" + | "rightStickY" + | "leftTrigger" + | "rightTrigger"; + +export type FlightSensitivityCurve = "linear" | "expo"; + +export interface FlightHidAxisSource { + byteOffset: number; + byteCount: 1 | 2; + littleEndian: boolean; + unsigned: boolean; + rangeMin: number; + rangeMax: number; +} + +export interface FlightHidButtonSource { + byteOffset: number; + bitIndex: number; +} + +export interface FlightHidHatSource { + byteOffset: number; + bitOffset: number; + bitCount: 4 | 8; + centerValue: number; +} + +export interface FlightHidReportLayout { + skipReportId: boolean; + reportLength: number; + axes: FlightHidAxisSource[]; + buttons: FlightHidButtonSource[]; + hat?: FlightHidHatSource; +} + +export interface FlightAxisMapping { + sourceIndex: number; + target: FlightAxisTarget; + inverted: boolean; + deadzone: number; + sensitivity: number; + curve: FlightSensitivityCurve; +} + +export interface FlightButtonMapping { + sourceIndex: number; + targetButton: number; +} + +export interface FlightDeviceInfo { + path: string; + vendorId: number; + productId: number; + product: string; + manufacturer: string; + serialNumber: string; + release: number; + interface: number; + usagePage: number; + usage: number; +} + +export interface FlightProfile { + name: string; + vidPid: string; + deviceName: string; + axisMappings: FlightAxisMapping[]; + buttonMappings: FlightButtonMapping[]; + reportLayout?: FlightHidReportLayout; + gameId?: string; +} + +export interface FlightControlsState { + connected: boolean; + deviceName: string; + axes: number[]; + buttons: boolean[]; + hatSwitch: number; + rawBytes: number[]; +} + +export interface FlightGamepadState { + controllerId: number; + buttons: number; + leftTrigger: number; + rightTrigger: number; + leftStickX: number; + leftStickY: number; + rightStickX: number; + rightStickY: number; + connected: boolean; +} + +export interface DiscordPresencePayload { + type: "idle" | "queue" | "streaming"; + gameName?: string; + resolution?: string; + fps?: number; + bitrateMbps?: number; + region?: string; + startTimestamp?: number; + queuePosition?: number;} diff --git a/opennow-stable/src/shared/ipc.ts b/opennow-stable/src/shared/ipc.ts index 454dc467..de92821e 100644 --- a/opennow-stable/src/shared/ipc.ts +++ b/opennow-stable/src/shared/ipc.ts @@ -27,6 +27,17 @@ export const IPC_CHANNELS = { SETTINGS_RESET: "settings:reset", LOGS_EXPORT: "logs:export", LOGS_GET_RENDERER: "logs:get-renderer", -} as const; + DISCORD_UPDATE_PRESENCE: "discord:update-presence", + DISCORD_CLEAR_PRESENCE: "discord:clear-presence", + FLIGHT_GET_DEVICES: "flight:get-devices", + FLIGHT_START_CAPTURE: "flight:start-capture", + FLIGHT_STOP_CAPTURE: "flight:stop-capture", + FLIGHT_STATE_UPDATE: "flight:state-update", + FLIGHT_GAMEPAD_STATE: "flight:gamepad-state", + FLIGHT_GET_PROFILE: "flight:get-profile", + FLIGHT_SET_PROFILE: "flight:set-profile", + FLIGHT_DELETE_PROFILE: "flight:delete-profile", + FLIGHT_GET_ALL_PROFILES: "flight:get-all-profiles", + FLIGHT_RESET_PROFILE: "flight:reset-profile",} as const; export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; From 2b320f5da378032cf21c0b7ecf142b2ef42c9e9e Mon Sep 17 00:00:00 2001 From: Meganugger Date: Wed, 18 Feb 2026 23:21:02 +0000 Subject: [PATCH 2/5] Refactor(flight): Migrate controls to WebHID, dropping node-hid for simpler, native-free builds. --- opennow-stable/package-lock.json | 319 ++++---- opennow-stable/package.json | 2 - .../src/main/flight/FlightProfiles.ts | 2 +- opennow-stable/src/main/index.ts | 143 ++-- opennow-stable/src/preload/index.ts | 8 +- opennow-stable/src/renderer/src/App.tsx | 15 +- .../src/components/FlightControlsPanel.tsx | 705 ++++++++++-------- .../src/flight/FlightHidService.ts} | 325 ++++---- opennow-stable/src/renderer/src/webhid.d.ts | 120 +++ .../flightConstants.ts} | 0 .../flightDefaults.ts} | 4 +- opennow-stable/src/shared/gfn.ts | 18 - opennow-stable/src/shared/ipc.ts | 5 - 13 files changed, 867 insertions(+), 799 deletions(-) rename opennow-stable/src/{main/flight/FlightControlsService.ts => renderer/src/flight/FlightHidService.ts} (50%) create mode 100644 opennow-stable/src/renderer/src/webhid.d.ts rename opennow-stable/src/{main/flight/inputConstants.ts => shared/flightConstants.ts} (100%) rename opennow-stable/src/{main/flight/FlightDeviceDefaults.ts => shared/flightDefaults.ts} (99%) diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index d187b0a9..48f7cbe6 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -9,14 +9,12 @@ "version": "0.2.4", "dependencies": { "lucide-react": "^0.563.0", - "node-hid": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "ws": "^8.18.3" }, "devDependencies": { "@types/node": "^22.10.5", - "@types/node-hid": "^1.3.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.5.13", @@ -1113,13 +1111,106 @@ "license": "MIT" }, "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1815,16 +1906,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/node-hid": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@types/node-hid/-/node-hid-1.3.4.tgz", - "integrity": "sha512-0ootpsYetN9vjqkDSwm/cA4fk/9yGM/PO0X8SLPE/BzXlUaBelImMWMymtF9QEoEzxY0pnhcROIJM0CNSUqO8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -2011,6 +2092,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2020,6 +2102,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2297,14 +2380,11 @@ } }, "node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, "engines": { "node": "20 || >=22" } @@ -2818,6 +2898,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -2855,6 +2936,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2867,6 +2949,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/color-support": { @@ -2946,50 +3029,6 @@ "typescript": "^5.4.3" } }, - "node_modules/config-file-ts/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/config-file-ts/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/config-file-ts/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/config-file-ts/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3007,13 +3046,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/config-file-ts/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/config-file-ts/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -3036,22 +3068,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/config-file-ts/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/config-file-ts/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3078,58 +3094,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/config-file-ts/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/config-file-ts/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/config-file-ts/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -3421,7 +3385,6 @@ "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "25.1.8", "builder-util": "25.1.7", @@ -3812,6 +3775,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -3952,6 +3916,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4223,6 +4188,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4723,6 +4689,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4786,19 +4753,19 @@ "license": "ISC" }, "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" + "@isaacs/cliui": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jake": { @@ -5219,9 +5186,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", - "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", + "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5500,29 +5467,6 @@ "node": ">=10" } }, - "node_modules/node-hid": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/node-hid/-/node-hid-3.3.0.tgz", - "integrity": "sha512-j+dFgJLRAE0nufQKXk3IfS6T6YuHhCgMvz4TrG0sgtb6DSCdYpfJ1etcdmeCmPQjUgO+yo32ktVrRliNs/+fmg==", - "hasInstallScript": true, - "license": "(MIT OR X11)", - "dependencies": { - "node-addon-api": "^3.2.1", - "pkg-prebuilds": "^1.0.0" - }, - "bin": { - "hid-showdevices": "src/show-devices.js" - }, - "engines": { - "node": ">=10.16" - } - }, - "node_modules/node-hid/node_modules/node-addon-api": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", - "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", - "license": "MIT" - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5779,22 +5723,6 @@ "dev": true, "license": "ISC" }, - "node_modules/pkg-prebuilds": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/pkg-prebuilds/-/pkg-prebuilds-1.0.0.tgz", - "integrity": "sha512-D9wlkXZCmjxj2kBHTw3fGSyjoahr33breGBoJcoezpi7ouYS59DJVOHMZ+dgqacSrZiJo4qtkXxLQTE+BqXJmQ==", - "license": "MIT", - "dependencies": { - "yargs": "^17.7.2" - }, - "bin": { - "pkg-prebuilds-copy": "bin/copy.mjs", - "pkg-prebuilds-verify": "bin/verify.mjs" - }, - "engines": { - "node": ">= 14.15.0" - } - }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -6015,6 +5943,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6461,6 +6390,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6491,6 +6421,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6914,6 +6845,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -6988,6 +6920,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -7004,6 +6937,7 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -7022,6 +6956,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/opennow-stable/package.json b/opennow-stable/package.json index fd81622d..ac7858fa 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -24,14 +24,12 @@ }, "dependencies": { "lucide-react": "^0.563.0", - "node-hid": "^3.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "ws": "^8.18.3" }, "devDependencies": { "@types/node": "^22.10.5", - "@types/node-hid": "^1.3.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.5.13", diff --git a/opennow-stable/src/main/flight/FlightProfiles.ts b/opennow-stable/src/main/flight/FlightProfiles.ts index 635548ea..d6182782 100644 --- a/opennow-stable/src/main/flight/FlightProfiles.ts +++ b/opennow-stable/src/main/flight/FlightProfiles.ts @@ -2,7 +2,7 @@ import { app } from "electron"; import { join } from "node:path"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import type { FlightProfile } from "@shared/gfn"; -import { buildDefaultProfile } from "./FlightDeviceDefaults"; +import { buildDefaultProfile } from "@shared/flightDefaults"; const PROFILES_FILENAME = "flight-profiles.json"; diff --git a/opennow-stable/src/main/index.ts b/opennow-stable/src/main/index.ts index 4ecffe07..97df739a 100644 --- a/opennow-stable/src/main/index.ts +++ b/opennow-stable/src/main/index.ts @@ -1,14 +1,8 @@ import { app, BrowserWindow, ipcMain, dialog, systemPreferences, session } from "electron"; -import { fileURLToPath } from "node:url"; +import { app, BrowserWindow, ipcMain, dialog, session } from "electron";import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; -// Keyboard shortcuts reference (matching Rust implementation): -// F11 - Toggle fullscreen (handled in main process) -// F3 - Toggle stats overlay (handled in renderer) -// Ctrl+Shift+Q - Stop streaming (handled in renderer) -// F8 - Toggle mouse/pointer lock (handled in main process via IPC) - import { IPC_CHANNELS } from "@shared/ipc"; import { initLogCapture, exportLogs } from "@shared/logger"; import type { @@ -44,14 +38,12 @@ import { } from "./gfn/games"; import { fetchSubscription, fetchDynamicRegions } from "./gfn/subscription"; import { GfnSignalingClient } from "./gfn/signaling"; -import { isSessionError, SessionError, GfnErrorCode } from "./gfn/errorCodes"; +import { isSessionError, SessionError } from "./gfn/errorCodes"; import { DiscordPresenceService } from "./discord/DiscordPresenceService"; import { FlightControlsService } from "./flight/FlightControlsService"; -const __filename = fileURLToPath(import.meta.url); +import { FlightProfileManager } from "./flight/FlightProfiles";const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Configure Chromium video and WebRTC behavior before app.whenReady(). - interface BootstrapVideoPreferences { decoderPreference: VideoAccelerationPreference; encoderPreference: VideoAccelerationPreference; @@ -90,12 +82,10 @@ console.log( `[Main] Video acceleration preference: decode=${bootstrapVideoPrefs.decoderPreference}, encode=${bootstrapVideoPrefs.encoderPreference}`, ); -// --- Platform-specific HW video decode features --- const platformFeatures: string[] = []; const isLinuxArm = process.platform === "linux" && (process.arch === "arm64" || process.arch === "arm"); if (process.platform === "win32") { - // Windows: D3D11 + Media Foundation path for HW decode/encode acceleration if (bootstrapVideoPrefs.decoderPreference !== "software") { platformFeatures.push("D3D11VideoDecoder"); } @@ -125,34 +115,38 @@ if (process.platform === "win32") { ) { platformFeatures.push("VaapiIgnoreDriverChecks"); } + if (bootstrapVideoPrefs.decoderPreference !== "software") { + platformFeatures.push("VaapiVideoDecoder"); } + if (bootstrapVideoPrefs.encoderPreference !== "software") { + platformFeatures.push("VaapiVideoEncoder"); + } + if ( + bootstrapVideoPrefs.decoderPreference !== "software" || + bootstrapVideoPrefs.encoderPreference !== "software" + ) { + platformFeatures.push("VaapiIgnoreDriverChecks"); } } -// macOS: VideoToolbox handles HW acceleration natively, no extra feature flags needed app.commandLine.appendSwitch("enable-features", [ - // --- AV1 support (cross-platform) --- - "Dav1dVideoDecoder", // Fast AV1 software fallback via dav1d (if no HW decoder) - // --- Additional (cross-platform) --- + "Dav1dVideoDecoder", "HardwareMediaKeyHandling", - // --- Platform-specific HW decode/encode --- ...platformFeatures, ].join(","), ); const disableFeatures: string[] = [ - // Prevents mDNS candidate generation — faster ICE connectivity "WebRtcHideLocalIpsWithMdns", ]; if (process.platform === "linux" && !isLinuxArm) { // ChromeOS-only direct video decoder path interferes on regular Linux - disableFeatures.push("UseChromeOSDirectVideoDecoder"); +if (process.platform === "linux") { disableFeatures.push("UseChromeOSDirectVideoDecoder"); } app.commandLine.appendSwitch("disable-features", disableFeatures.join(",")); app.commandLine.appendSwitch("force-fieldtrials", [ - // Disable send-side pacing — we are receive-only, pacing adds latency to RTCP feedback "WebRTC-Video-Pacing/Disabled/", ].join("/"), ); @@ -169,16 +163,10 @@ if (bootstrapVideoPrefs.encoderPreference === "hardware") { app.commandLine.appendSwitch("disable-accelerated-video-encode"); } -// Ensure the GPU process doesn't blocklist our GPU for video decode app.commandLine.appendSwitch("ignore-gpu-blocklist"); -// --- Responsiveness flags --- -// Keep default compositor frame pacing (vsync + frame cap) to avoid runaway -// CPU usage from uncapped UI animations. -// Prevent renderer throttling when the window is backgrounded or occluded. app.commandLine.appendSwitch("disable-renderer-backgrounding"); app.commandLine.appendSwitch("disable-backgrounding-occluded-windows"); -// Remove getUserMedia FPS cap (not strictly needed for receive-only but avoids potential limits) app.commandLine.appendSwitch("max-gum-fps", "999"); let mainWindow: BrowserWindow | null = null; @@ -188,12 +176,52 @@ let authService: AuthService; let settingsManager: SettingsManager; let discordService: DiscordPresenceService; let flightService: FlightControlsService; -function emitToRenderer(event: MainToRendererSignalingEvent): void { +let flightProfileManager: FlightProfileManager; + +const grantedHidDeviceIds = new Set();function emitToRenderer(event: MainToRendererSignalingEvent): void { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(IPC_CHANNELS.SIGNALING_EVENT, event); } } +function setupWebHidPermissions(): void { + const ses = session.defaultSession; + + ses.setDevicePermissionHandler((details) => { + if (details.deviceType === "hid") { + return true; + } + return true; + }); + + ses.setPermissionCheckHandler((_webContents, permission) => { + if (permission === "hid") { + return true; + } + return true; + }); + + ses.on("select-hid-device", (event, details, callback) => { + event.preventDefault(); + const ungranted = details.deviceList.find((d) => !grantedHidDeviceIds.has(d.deviceId)); + const selected = ungranted ?? details.deviceList[0]; + if (selected) { + grantedHidDeviceIds.add(selected.deviceId); + callback(selected.deviceId); + } else { + callback(""); + } + }); + + ses.on("hid-device-added", (_event, _details) => { + // WebHID connect event handled in renderer via navigator.hid + }); + + ses.on("hid-device-removed", (_event, _details) => { + // WebHID disconnect event handled in renderer via navigator.hid + }); +} + async function createMainWindow(): Promise { const preloadMjsPath = join(__dirname, "../preload/index.mjs"); const preloadJsPath = join(__dirname, "../preload/index.js"); @@ -216,8 +244,6 @@ async function createMainWindow(): Promise { }, }); - // Handle F11 fullscreen toggle — send to renderer so it uses W3C Fullscreen API - // (which enables navigator.keyboard.lock for Escape key capture) mainWindow.webContents.on("before-input-event", (event, input) => { if (input.key === "F11" && input.type === "keyDown") { event.preventDefault(); @@ -228,8 +254,6 @@ async function createMainWindow(): Promise { }); if (process.platform === "win32") { - // Keep native window fullscreen in sync with HTML fullscreen so Windows treats - // stream playback like a real fullscreen window instead of only DOM fullscreen. mainWindow.webContents.on("enter-html-full-screen", () => { if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isFullScreen()) { mainWindow.setFullScreen(true); @@ -258,10 +282,6 @@ async function resolveJwt(token?: string): Promise { return authService.resolveJwtToken(token); } -/** - * Show a dialog asking the user how to handle a session conflict - * Returns the user's choice: "resume", "new", or "cancel" - */ async function showSessionConflictDialog(): Promise { if (!mainWindow || mainWindow.isDestroyed()) { return "cancel"; @@ -287,9 +307,6 @@ async function showSessionConflictDialog(): Promise { } } -/** - * Check if an error indicates a session conflict - */ function isSessionConflictError(error: unknown): boolean { if (isSessionError(error)) { return error.isSessionConflict(); @@ -330,10 +347,7 @@ function registerIpcHandlers(): void { const streamingBaseUrl = payload?.providerStreamingBaseUrl ?? authService.getSelectedProvider().streamingServiceUrl; const userId = payload.userId; - - // Fetch dynamic regions to get the VPC ID (handles Alliance partners correctly) const { vpcId } = await fetchDynamicRegions(token, streamingBaseUrl); - return fetchSubscription(token, userId, vpcId ?? undefined); }); @@ -470,7 +484,6 @@ function registerIpcHandlers(): void { return signalingClient.sendIceCandidate(payload); }); - // Toggle fullscreen via IPC (for completeness) ipcMain.handle(IPC_CHANNELS.TOGGLE_FULLSCREEN, async () => { if (mainWindow && !mainWindow.isDestroyed()) { const isFullScreen = mainWindow.isFullScreen(); @@ -478,14 +491,12 @@ function registerIpcHandlers(): void { } }); - // Toggle pointer lock via IPC (F8 shortcut) ipcMain.handle(IPC_CHANNELS.TOGGLE_POINTER_LOCK, async () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("app:toggle-pointer-lock"); } }); - // Settings IPC handlers ipcMain.handle(IPC_CHANNELS.SETTINGS_GET, async (): Promise => { return settingsManager.getAll(); }); @@ -500,7 +511,7 @@ function registerIpcHandlers(): void { const all = settingsManager.getAll(); flightService.updateConfig(all.flightControlsEnabled, all.flightControlsSlot); } }); - + }); ipcMain.handle(IPC_CHANNELS.SETTINGS_RESET, async (): Promise => { return settingsManager.reset(); }); @@ -508,42 +519,33 @@ function registerIpcHandlers(): void { // Logs export IPC handler ipcMain.handle(IPC_CHANNELS.LOGS_EXPORT, async (_event, format: "text" | "json" = "text"): Promise => { return exportLogs(format); + ipcMain.handle(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, async (_event, payload: DiscordPresencePayload) => { + await discordService.updatePresence(payload); }); - // Flight Controls IPC handlers - ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_DEVICES, () => { - return flightService.getDevices(); - }); - - ipcMain.handle(IPC_CHANNELS.FLIGHT_START_CAPTURE, (_event, devicePath: string) => { - return flightService.startCapture(devicePath); - }); - - ipcMain.handle(IPC_CHANNELS.FLIGHT_STOP_CAPTURE, () => { - flightService.stopCapture(); - }); + ipcMain.handle(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE, async () => { + await discordService.clearPresence(); }); ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_PROFILE, (_event, vidPid: string, gameId?: string) => { - return flightService.profileManager.getProfile(vidPid, gameId); + return flightProfileManager.getProfile(vidPid, gameId); }); ipcMain.handle(IPC_CHANNELS.FLIGHT_SET_PROFILE, (_event, profile: FlightProfile) => { - flightService.profileManager.setProfile(profile); + flightProfileManager.setProfile(profile); }); ipcMain.handle(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, (_event, vidPid: string, gameId?: string) => { - flightService.profileManager.deleteProfile(vidPid, gameId); + flightProfileManager.deleteProfile(vidPid, gameId); }); ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES, () => { - return flightService.profileManager.getAllProfiles(); + return flightProfileManager.getAllProfiles(); }); ipcMain.handle(IPC_CHANNELS.FLIGHT_RESET_PROFILE, (_event, vidPid: string) => { - return flightService.profileManager.resetProfile(vidPid); + return flightProfileManager.resetProfile(vidPid); }); - // Save window size when it changes mainWindow?.on("resize", () => { if (mainWindow && !mainWindow.isDestroyed()) { const [width, height] = mainWindow.getSize(); @@ -612,17 +614,11 @@ app.whenReady().then(async () => { return allowedPermissions.has(permission); }); - flightService = new FlightControlsService( - allSettings.flightControlsEnabled, - allSettings.flightControlsSlot, - ); - flightService.initialize(); + flightProfileManager = new FlightProfileManager(); + setupWebHidPermissions(); registerIpcHandlers(); await createMainWindow(); - if (mainWindow) { - flightService.setMainWindow(mainWindow); - } app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { @@ -643,6 +639,5 @@ app.on("before-quit", () => { signalingClientKey = null; void discordService.dispose(); flightService.dispose();}); - -// Export for use by other modules +}); export { showSessionConflictDialog, isSessionConflictError }; diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 30d20a69..e3321d34 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -22,8 +22,7 @@ import type { FlightProfile, FlightControlsState, FlightGamepadState,} from "@shared/gfn"; - -// Extend the OpenNowApi interface for internal preload use +} from "@shared/gfn"; type PreloadApi = OpenNowApi; const api: PreloadApi = { @@ -80,9 +79,6 @@ const api: PreloadApi = { updateDiscordPresence: (state: DiscordPresencePayload) => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_UPDATE_PRESENCE, state), clearDiscordPresence: () => ipcRenderer.invoke(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE), - flightGetDevices: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_DEVICES), - flightStartCapture: (devicePath: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_START_CAPTURE, devicePath), - flightStopCapture: () => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_STOP_CAPTURE), flightGetProfile: (vidPid: string, gameId?: string) => ipcRenderer.invoke(IPC_CHANNELS.FLIGHT_GET_PROFILE, vidPid, gameId), flightSetProfile: (profile: FlightProfile) => @@ -109,5 +105,5 @@ const api: PreloadApi = { ipcRenderer.off(IPC_CHANNELS.FLIGHT_GAMEPAD_STATE, wrapped); }; },}; - +}; contextBridge.exposeInMainWorld("openNow", api); diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 4f77c58e..38a3371d 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -15,7 +15,7 @@ import type { VideoCodec, DiscordPresencePayload, FlightGamepadState,} from "@shared/gfn"; - +} from "@shared/gfn"; import { GfnWebRtcClient, type StreamDiagnostics, @@ -23,7 +23,7 @@ import { } from "./gfn/webrtcClient"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; import { useControllerNavigation } from "./controllerNavigation"; - +import { getFlightHidService } from "./flight/FlightHidService"; // UI Components import { LoginScreen } from "./components/LoginScreen"; import { Navbar } from "./components/Navbar"; @@ -712,15 +712,20 @@ export function App(): JSX.Element { } }, [authSession]); - // Flight controls: forward gamepad state from main process to WebRTC client + // Flight controls: forward WebHID gamepad state to WebRTC client useEffect(() => { if (!settings.flightControlsEnabled) return; - const unsubscribe = window.openNow.onFlightGamepadState((state: FlightGamepadState) => { + const service = getFlightHidService(); + service.controllerSlot = settings.flightControlsSlot; + const unsub = service.onGamepadState((state) => { clientRef.current?.injectExternalGamepad(state); }); return unsubscribe; }, [settings.flightControlsEnabled]); useEffect(() => { - if (!streamWarning) return; + return unsub; + }, [settings.flightControlsEnabled, settings.flightControlsSlot]); + + useEffect(() => { if (!streamWarning) return; const warning = streamWarning; const timer = window.setTimeout(() => { setStreamWarning((current) => (current === warning ? null : current)); diff --git a/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx b/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx index 2aad2c95..7a202a4b 100644 --- a/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx +++ b/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx @@ -1,14 +1,15 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { JSX } from "react"; -import { Joystick, RefreshCw, Save, Trash2, RotateCcw, Check } from "lucide-react"; +import { Joystick, RefreshCw, Save, Trash2, RotateCcw, Check, Plus } from "lucide-react"; import type { Settings, - FlightDeviceInfo, FlightProfile, FlightControlsState, FlightAxisTarget, FlightSensitivityCurve, } from "@shared/gfn"; +import { makeVidPid } from "@shared/flightDefaults"; +import { FlightHidService, getFlightHidService } from "../flight/FlightHidService"; interface FlightControlsPanelProps { settings: Settings; @@ -21,7 +22,7 @@ const AXIS_TARGETS: { value: FlightAxisTarget; label: string }[] = [ { value: "rightStickX", label: "Right Stick X (Yaw)" }, { value: "rightStickY", label: "Right Stick Y" }, { value: "leftTrigger", label: "Left Trigger" }, - { value: "rightTrigger", label: "Right Trigger (Throttle)" }, + { value: "rightTrigger", label: "Right Trigger" }, ]; const CURVE_OPTIONS: { value: FlightSensitivityCurve; label: string }[] = [ @@ -29,395 +30,493 @@ const CURVE_OPTIONS: { value: FlightSensitivityCurve; label: string }[] = [ { value: "expo", label: "Exponential" }, ]; -const SLOT_OPTIONS = [ - { value: 0, label: "Slot 0" }, - { value: 1, label: "Slot 1" }, - { value: 2, label: "Slot 2" }, - { value: 3, label: "Slot 3" }, -]; - -function makeVidPid(vendorId: number, productId: number): string { - return `${vendorId.toString(16).toUpperCase().padStart(4, "0")}:${productId.toString(16).toUpperCase().padStart(4, "0")}`; +interface DeviceEntry { + device: HIDDevice; + vidPid: string; + label: string; } export function FlightControlsPanel({ settings, onSettingChange }: FlightControlsPanelProps): JSX.Element { - const [devices, setDevices] = useState([]); - const [selectedPath, setSelectedPath] = useState(""); - const [capturing, setCapturing] = useState(false); - const [liveState, setLiveState] = useState(null); + const [devices, setDevices] = useState([]); + const [selectedIdx, setSelectedIdx] = useState(-1); + const [isCapturing, setIsCapturing] = useState(false); + const [flightState, setFlightState] = useState(null); const [profile, setProfile] = useState(null); - const [profiles, setProfiles] = useState([]); + const [allProfiles, setAllProfiles] = useState([]); const [savedIndicator, setSavedIndicator] = useState(false); - const [isScanning, setIsScanning] = useState(false); - const stateCleanupRef = useRef<(() => void) | null>(null); - - const scanDevices = useCallback(async () => { - setIsScanning(true); - try { - const found = await window.openNow.flightGetDevices(); - setDevices(found); - if (found.length > 0 && !selectedPath) { - setSelectedPath(found[0]!.path); - } - } catch (error) { - console.warn("[Flight UI] Failed to scan devices:", error); - } finally { - setIsScanning(false); - } - }, [selectedPath]); - - const loadProfiles = useCallback(async () => { - try { - const all = await window.openNow.flightGetAllProfiles(); - setProfiles(all); - } catch (error) { - console.warn("[Flight UI] Failed to load profiles:", error); - } + const stateUnsubRef = useRef<(() => void) | null>(null); + const webHidSupported = FlightHidService.isSupported(); + + const enabled = settings.flightControlsEnabled; + const slot = settings.flightControlsSlot; + + const loadDevices = useCallback(async () => { + const service = getFlightHidService(); + const devs = await service.getDevices(); + const entries: DeviceEntry[] = devs.map((d) => ({ + device: d, + vidPid: makeVidPid(d.vendorId, d.productId), + label: d.productName || makeVidPid(d.vendorId, d.productId), + })); + setDevices(entries); }, []); + const loadAllProfiles = useCallback(async () => { + const api = window.openNow; + const profiles = await api.flightGetAllProfiles(); + setAllProfiles(profiles); + }, []); + + const handleRefresh = useCallback(async () => { + await loadDevices(); + await loadAllProfiles(); + }, [loadDevices, loadAllProfiles]); + useEffect(() => { - if (settings.flightControlsEnabled) { - void scanDevices(); - void loadProfiles(); + if (enabled && webHidSupported) { + void handleRefresh(); } - }, [settings.flightControlsEnabled, scanDevices, loadProfiles]); + }, [enabled, webHidSupported, handleRefresh]); + + useEffect(() => { + const service = getFlightHidService(); + service.controllerSlot = slot; + }, [slot]); useEffect(() => { - if (!settings.flightControlsEnabled) return; - const cleanup = window.openNow.onFlightStateUpdate((state: FlightControlsState) => { - setLiveState(state); - }); - stateCleanupRef.current = cleanup; return () => { - cleanup(); - stateCleanupRef.current = null; + if (stateUnsubRef.current) { + stateUnsubRef.current(); + stateUnsubRef.current = null; + } }; - }, [settings.flightControlsEnabled]); + }, []); + + const handleRequestDevice = useCallback(async () => { + const service = getFlightHidService(); + const device = await service.requestDevice(); + if (device) { + await loadDevices(); + } + }, [loadDevices]); const handleStartCapture = useCallback(async () => { - if (!selectedPath) return; - try { - const success = await window.openNow.flightStartCapture(selectedPath); - setCapturing(success); - if (success) { - const device = devices.find((d) => d.path === selectedPath); - if (device) { - const vidPid = makeVidPid(device.vendorId, device.productId); - const p = await window.openNow.flightGetProfile(vidPid); - setProfile(p); - } - } - } catch (error) { - console.warn("[Flight UI] Failed to start capture:", error); + if (selectedIdx < 0 || selectedIdx >= devices.length) return; + const entry = devices[selectedIdx]!; + + const api = window.openNow; + let p = await api.flightGetProfile(entry.vidPid); + if (!p) { + p = await api.flightResetProfile(entry.vidPid); } - }, [selectedPath, devices]); - - const handleStopCapture = useCallback(async () => { - try { - await window.openNow.flightStopCapture(); - setCapturing(false); - setLiveState(null); - } catch (error) { - console.warn("[Flight UI] Failed to stop capture:", error); + setProfile(p); + + const service = getFlightHidService(); + service.controllerSlot = slot; + + if (stateUnsubRef.current) { + stateUnsubRef.current(); + } + stateUnsubRef.current = service.onStateUpdate((state) => { + setFlightState(state); + }); + + const ok = p ? await service.startCapture(entry.device, p) : false; + setIsCapturing(ok); + }, [selectedIdx, devices, slot]); + + const handleStopCapture = useCallback(() => { + const service = getFlightHidService(); + service.stopCapture(); + setIsCapturing(false); + setFlightState(null); + if (stateUnsubRef.current) { + stateUnsubRef.current(); + stateUnsubRef.current = null; } }, []); + const handleAxisMappingChange = useCallback( + (index: number, field: string, value: unknown) => { + if (!profile) return; + const updated = { ...profile }; + updated.axisMappings = updated.axisMappings.map((m, i) => { + if (i !== index) return m; + return { ...m, [field]: value }; + }); + setProfile(updated); + }, + [profile], + ); + + const handleButtonMappingChange = useCallback( + (index: number, field: string, value: unknown) => { + if (!profile) return; + const updated = { ...profile }; + updated.buttonMappings = updated.buttonMappings.map((m, i) => { + if (i !== index) return m; + return { ...m, [field]: value }; + }); + setProfile(updated); + }, + [profile], + ); + const handleSaveProfile = useCallback(async () => { if (!profile) return; - try { - await window.openNow.flightSetProfile(profile); - setSavedIndicator(true); - setTimeout(() => setSavedIndicator(false), 1500); - void loadProfiles(); - } catch (error) { - console.warn("[Flight UI] Failed to save profile:", error); + const api = window.openNow; + await api.flightSetProfile(profile); + setSavedIndicator(true); + setTimeout(() => setSavedIndicator(false), 1500); + await loadAllProfiles(); + + if (isCapturing) { + const service = getFlightHidService(); + const activeDevice = service.getActiveDevice(); + if (activeDevice) { + await service.startCapture(activeDevice, profile); + } } - }, [profile, loadProfiles]); + }, [profile, isCapturing, loadAllProfiles]); const handleResetProfile = useCallback(async () => { - if (!profile) return; - try { - const reset = await window.openNow.flightResetProfile(profile.vidPid); - if (reset) setProfile(reset); - void loadProfiles(); - } catch (error) { - console.warn("[Flight UI] Failed to reset profile:", error); - } - }, [profile, loadProfiles]); - - const handleDeleteProfile = useCallback(async (vidPid: string, gameId?: string) => { - try { - await window.openNow.flightDeleteProfile(vidPid, gameId); - void loadProfiles(); - } catch (error) { - console.warn("[Flight UI] Failed to delete profile:", error); + if (selectedIdx < 0 || selectedIdx >= devices.length) return; + const entry = devices[selectedIdx]!; + const api = window.openNow; + const p = await api.flightResetProfile(entry.vidPid); + if (p) { + setProfile(p); + if (isCapturing) { + const service = getFlightHidService(); + const activeDevice = service.getActiveDevice(); + if (activeDevice) { + await service.startCapture(activeDevice, p); + } + } } - }, [loadProfiles]); + await loadAllProfiles(); + }, [selectedIdx, devices, isCapturing, loadAllProfiles]); - const updateAxisMapping = useCallback((sourceIndex: number, field: string, value: unknown) => { - if (!profile) return; - const updated = { ...profile }; - updated.axisMappings = updated.axisMappings.map((m) => { - if (m.sourceIndex !== sourceIndex) return m; - return { ...m, [field]: value }; - }); - setProfile(updated); - }, [profile]); - - const _selectedDevice = devices.find((d) => d.path === selectedPath); + const handleDeleteProfile = useCallback( + async (vidPid: string, gameId?: string) => { + const api = window.openNow; + await api.flightDeleteProfile(vidPid, gameId); + await loadAllProfiles(); + }, + [loadAllProfiles], + ); return ( -
-
- -