diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index 1afd9256..82f160e1 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -1111,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": ">=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": ">=18" + "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": { @@ -2287,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" } @@ -2939,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", @@ -3000,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", @@ -3029,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", @@ -3071,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", @@ -4783,19 +4754,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": { @@ -5216,9 +5187,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": { diff --git a/opennow-stable/package.json b/opennow-stable/package.json index 65fcb59a..ac7858fa 100644 --- a/opennow-stable/package.json +++ b/opennow-stable/package.json @@ -52,7 +52,7 @@ } ], "icon": "../logo.png", - "npmRebuild": false, + "npmRebuild": true, "nodeGypRebuild": false, "buildDependenciesFromSource": false, "directories": { diff --git a/opennow-stable/src/main/flight/FlightProfiles.ts b/opennow-stable/src/main/flight/FlightProfiles.ts new file mode 100644 index 00000000..d6182782 --- /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 "@shared/flightDefaults"; + +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/index.ts b/opennow-stable/src/main/index.ts index c15821f8..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 { @@ -29,7 +23,8 @@ import type { VideoAccelerationPreference, SubscriptionFetchRequest, SessionConflictChoice, -} from "@shared/gfn"; + DiscordPresencePayload, + FlightProfile,} from "@shared/gfn"; import { getSettingsManager, type SettingsManager } from "./settings"; @@ -43,13 +38,12 @@ import { } from "./gfn/games"; import { fetchSubscription, fetchDynamicRegions } from "./gfn/subscription"; import { GfnSignalingClient } from "./gfn/signaling"; -import { isSessionError, SessionError, GfnErrorCode } from "./gfn/errorCodes"; - -const __filename = fileURLToPath(import.meta.url); +import { isSessionError, SessionError } from "./gfn/errorCodes"; +import { DiscordPresenceService } from "./discord/DiscordPresenceService"; +import { FlightControlsService } from "./flight/FlightControlsService"; +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; @@ -88,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"); } @@ -123,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("/"), ); @@ -167,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; @@ -184,13 +174,54 @@ let signalingClient: GfnSignalingClient | null = null; let signalingClientKey: string | null = null; let authService: AuthService; let settingsManager: SettingsManager; +let discordService: DiscordPresenceService; +let flightService: FlightControlsService; +let flightProfileManager: FlightProfileManager; -function emitToRenderer(event: MainToRendererSignalingEvent): void { +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"); @@ -213,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(); @@ -225,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); @@ -255,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"; @@ -284,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(); @@ -327,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); }); @@ -467,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(); @@ -475,22 +491,27 @@ 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(); }); 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(); }); @@ -498,9 +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); + }); + + ipcMain.handle(IPC_CHANNELS.DISCORD_CLEAR_PRESENCE, async () => { + await discordService.clearPresence(); }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_PROFILE, (_event, vidPid: string, gameId?: string) => { + return flightProfileManager.getProfile(vidPid, gameId); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_SET_PROFILE, (_event, profile: FlightProfile) => { + flightProfileManager.setProfile(profile); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_DELETE_PROFILE, (_event, vidPid: string, gameId?: string) => { + flightProfileManager.deleteProfile(vidPid, gameId); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_GET_ALL_PROFILES, () => { + return flightProfileManager.getAllProfiles(); + }); + + ipcMain.handle(IPC_CHANNELS.FLIGHT_RESET_PROFILE, (_event, vidPid: string) => { + return flightProfileManager.resetProfile(vidPid); }); - // Save window size when it changes mainWindow?.on("resize", () => { if (mainWindow && !mainWindow.isDestroyed()) { const [width, height] = mainWindow.getSize(); @@ -569,6 +614,9 @@ app.whenReady().then(async () => { return allowedPermissions.has(permission); }); + flightProfileManager = new FlightProfileManager(); + + setupWebHidPermissions(); registerIpcHandlers(); await createMainWindow(); @@ -589,7 +637,7 @@ 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..7bbeb39d 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -2,7 +2,8 @@ import { app } from "electron"; import { join } from "node:path"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode } from "@shared/gfn"; - +import type { VideoCodec, ColorQuality, VideoAccelerationPreference, FlightSlotConfig } from "@shared/gfn"; +import { defaultFlightSlots } from "@shared/gfn"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ resolution: string; @@ -48,8 +49,19 @@ 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;} + /** Controller slot for flight controls (0-3) — legacy, kept for compat */ + flightControlsSlot: number; + /** Per-slot flight configurations */ + flightSlots: FlightSlotConfig[]; } - const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; const defaultMicShortcut = "Ctrl+Shift+M"; @@ -79,8 +91,13 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3,}; + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), }; - export class SettingsManager { private settings: Settings; private readonly settingsPath: string; @@ -108,6 +125,10 @@ export class SettingsManager { ...parsed, }; + if (!Array.isArray(merged.flightSlots) || merged.flightSlots.length !== 4) { + merged.flightSlots = defaultFlightSlots(); + } + const migrated = this.migrateLegacyShortcutDefaults(merged); if (migrated) { writeFileSync(this.settingsPath, JSON.stringify(merged, null, 2), "utf-8"); diff --git a/opennow-stable/src/preload/index.ts b/opennow-stable/src/preload/index.ts index 155321c2..e3321d34 100644 --- a/opennow-stable/src/preload/index.ts +++ b/opennow-stable/src/preload/index.ts @@ -18,9 +18,11 @@ import type { IceCandidatePayload, Settings, SubscriptionFetchRequest, + DiscordPresencePayload, + FlightProfile, + FlightControlsState, + FlightGamepadState,} from "@shared/gfn"; } from "@shared/gfn"; - -// Extend the OpenNowApi interface for internal preload use type PreloadApi = OpenNowApi; const api: PreloadApi = { @@ -74,6 +76,34 @@ 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), + 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..18826b44 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -13,16 +13,19 @@ import type { SubscriptionInfo, StreamRegion, VideoCodec, + DiscordPresencePayload, + FlightGamepadState,} from "@shared/gfn"; } from "@shared/gfn"; - -import { + FlightSlotConfig, +} from "@shared/gfn"; +import { defaultFlightSlots } from "@shared/gfn";import { GfnWebRtcClient, type StreamDiagnostics, type StreamTimeWarning, } 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"; @@ -286,8 +289,13 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, - }); - const [settingsLoaded, setSettingsLoaded] = useState(false); + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, }); + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -658,8 +666,92 @@ 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 (!streamWarning) return; + if (!authSession) { + window.openNow.clearDiscordPresence().catch(() => {}); + } + }, [authSession]); + + // Flight controls: forward WebHID gamepad state to WebRTC client (multi-slot) + const activeFlightSlotsRef = useRef>(new Set()); + useEffect(() => { + if (!settings.flightControlsEnabled) { + for (const s of activeFlightSlotsRef.current) { + clientRef.current?.releaseExternalGamepad(s); + } + activeFlightSlotsRef.current.clear(); + return; + } + + const slots: FlightSlotConfig[] = Array.isArray(settings.flightSlots) && settings.flightSlots.length === 4 + ? settings.flightSlots : defaultFlightSlots(); + + const wantedSlots = new Set(); + for (let i = 0; i < 4; i++) { + if (slots[i]!.enabled && slots[i]!.deviceKey) wantedSlots.add(i); + } + + for (const s of activeFlightSlotsRef.current) { + if (!wantedSlots.has(s)) { + clientRef.current?.releaseExternalGamepad(s); + } + } + activeFlightSlotsRef.current = wantedSlots; + + const service = getFlightHidService(); + const unsub = service.onGamepadState((state) => { + clientRef.current?.injectExternalGamepad(state); + }); + return unsubscribe; + }, [settings.flightControlsEnabled]); useEffect(() => { + return unsub; + }, [settings.flightControlsEnabled, settings.flightSlots]); + + 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 new file mode 100644 index 00000000..59fe4487 --- /dev/null +++ b/opennow-stable/src/renderer/src/components/FlightControlsPanel.tsx @@ -0,0 +1,809 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import type { JSX } from "react"; +import { Save, Trash2, RotateCcw, Check, Joystick, Eye, AlertTriangle } from "lucide-react"; +import type { + Settings, + FlightProfile, + FlightControlsState, + FlightSlotConfig, + FlightAxisTarget, + FlightSensitivityCurve, +} from "@shared/gfn"; +import { makeDeviceKey, defaultFlightSlots } from "@shared/gfn"; +import { makeVidPid } from "@shared/flightDefaults"; +import { FlightHidService, getFlightHidService, isFlightDevice } from "../flight/FlightHidService"; +import { useToast } from "./Toast"; + +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 BUTTON_LABELS: Record = { + 0x1000: "A", 0x2000: "B", 0x4000: "X", 0x8000: "Y", + 0x0100: "LB", 0x0200: "RB", 0x0040: "L3", 0x0080: "R3", + 0x0010: "Start", 0x0020: "Back", + 0x0001: "D-Up", 0x0002: "D-Down", 0x0004: "D-Left", 0x0008: "D-Right", +}; + +interface DeviceEntry { + device: HIDDevice; + vidPid: string; + deviceKey: string; + label: string; + isFlight: boolean; +} + +function toEntry(device: HIDDevice): DeviceEntry { + const vidPid = makeVidPid(device.vendorId, device.productId); + const name = device.productName || `HID ${vidPid}`; + const flight = isFlightDevice(device); + return { + device, + vidPid, + deviceKey: makeDeviceKey(device.vendorId, device.productId, device.productName || ""), + label: flight ? name : `${name} [other]`, + isFlight: flight, + }; +} + +function getSlots(settings: Settings): FlightSlotConfig[] { + if (Array.isArray(settings.flightSlots) && settings.flightSlots.length === 4) { + return settings.flightSlots; + } + return defaultFlightSlots(); +} + +function updateSlot( + settings: Settings, + slotIdx: number, + patch: Partial, + onSettingChange: (key: K, value: Settings[K]) => void, +): void { + const slots = getSlots(settings).map((s, i) => (i === slotIdx ? { ...s, ...patch } : { ...s })); + onSettingChange("flightSlots", slots); +} + +export function FlightControlsPanel({ settings, onSettingChange }: FlightControlsPanelProps): JSX.Element { + const { showToast } = useToast(); + const [activeTab, setActiveTab] = useState(0); + const [devices, setDevices] = useState([]); + const [showAllDevices, setShowAllDevices] = useState(false); + const [profiles, setProfiles] = useState>(new Map()); + const [editingProfile, setEditingProfile] = useState(null); + const [capturingSlots, setCapturingSlots] = useState>(new Set()); + const [liveStates, setLiveStates] = useState>(new Map()); + const [inputAlive, setInputAlive] = useState>(new Map()); + const [pairError, setPairError] = useState(null); + const [savedIndicator, setSavedIndicator] = useState(false); + const [allProfiles, setAllProfiles] = useState([]); + const inputAliveTimers = useRef>>(new Map()); + const stateUnsubRef = useRef<(() => void) | null>(null); + const gamepadUnsubRef = useRef<(() => void) | null>(null); + const profileLoadEpoch = useRef(0); + const webHidSupported = FlightHidService.isSupported(); + + const enabled = settings.flightControlsEnabled; + const slots = getSlots(settings); + const slotConfig = slots[activeTab]!; + + const refreshDevices = useCallback(async (showAll: boolean): Promise => { + const devs = await getFlightHidService().getDevices(showAll); + const entries = devs.map(toEntry); + setDevices(entries); + return entries; + }, []); + + const loadAllProfiles = useCallback(async () => { + setAllProfiles(await window.openNow.flightGetAllProfiles()); + }, []); + + const loadProfileForVidPid = useCallback(async (vidPid: string): Promise => { + const epoch = ++profileLoadEpoch.current; + let p = await window.openNow.flightGetProfile(vidPid); + if (!p) p = await window.openNow.flightResetProfile(vidPid); + if (profileLoadEpoch.current !== epoch) return null; + if (p) { + setProfiles((prev) => { + const next = new Map(prev); + next.set(vidPid, p); + return next; + }); + } + return p; + }, []); + + useEffect(() => { + if (!enabled || !webHidSupported) return; + void (async () => { + await refreshDevices(showAllDevices); + await loadAllProfiles(); + })(); + }, [enabled, webHidSupported]); + + useEffect(() => { + if (!enabled || !webHidSupported) return; + const vidPid = slotConfig.vidPid; + if (vidPid) { + void loadProfileForVidPid(vidPid).then((p) => { + if (p) setEditingProfile({ ...p }); + else setEditingProfile(null); + }); + } else { + setEditingProfile(null); + } + }, [activeTab, enabled, webHidSupported, slotConfig.vidPid]); + + useEffect(() => { + if (!enabled || !webHidSupported) return; + const service = getFlightHidService(); + + if (stateUnsubRef.current) stateUnsubRef.current(); + stateUnsubRef.current = service.onStateUpdate((slot, state) => { + setLiveStates((prev) => { + const next = new Map(prev); + next.set(slot, state); + return next; + }); + setInputAlive((prev) => { + const next = new Map(prev); + next.set(slot, true); + return next; + }); + const prev = inputAliveTimers.current.get(slot); + if (prev) clearTimeout(prev); + inputAliveTimers.current.set(slot, setTimeout(() => { + setInputAlive((p) => { + const n = new Map(p); + n.set(slot, false); + return n; + }); + }, 500)); + }); + + return () => { + if (stateUnsubRef.current) { stateUnsubRef.current(); stateUnsubRef.current = null; } + }; + }, [enabled, webHidSupported]); + + useEffect(() => { + return () => { + for (const t of inputAliveTimers.current.values()) clearTimeout(t); + }; + }, []); + + const handlePairDevice = useCallback(async () => { + setPairError(null); + const service = getFlightHidService(); + let newDevices: HIDDevice[]; + try { + newDevices = await service.requestDevice(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setPairError(`Pairing failed: ${msg}`); + showToast("Device pairing failed", "error"); + return; + } + if (newDevices.length === 0) { + setPairError("No device selected. Make sure your device is connected and try again."); + return; + } + const entries = await refreshDevices(showAllDevices); + const added = newDevices[0]!; + const key = makeDeviceKey(added.vendorId, added.productId, added.productName || ""); + const vidPid = makeVidPid(added.vendorId, added.productId); + const entry = entries.find((e) => e.deviceKey === key); + if (entry) { + updateSlot(settings, activeTab, { + deviceKey: key, + vidPid, + deviceName: added.productName || null, + }, onSettingChange); + } + showToast(`Device paired: ${added.productName || "HID device"}`, "success"); + setPairError(null); + }, [showAllDevices, refreshDevices, showToast, settings, activeTab, onSettingChange]); + + const handleSelectDevice = useCallback((deviceKey: string) => { + const entry = devices.find((d) => d.deviceKey === deviceKey); + if (!entry) { + updateSlot(settings, activeTab, { deviceKey: null, vidPid: null, deviceName: null }, onSettingChange); + return; + } + updateSlot(settings, activeTab, { + deviceKey: entry.deviceKey, + vidPid: entry.vidPid, + deviceName: entry.device.productName || null, + }, onSettingChange); + }, [devices, settings, activeTab, onSettingChange]); + + const handleStartCapture = useCallback(async (slotIdx: number) => { + const cfg = slots[slotIdx]!; + if (!cfg.deviceKey || !cfg.vidPid) return; + const entry = devices.find((d) => d.deviceKey === cfg.deviceKey); + if (!entry) { + showToast(`Device not found for slot ${slotIdx + 1}`, "error"); + return; + } + let profile = profiles.get(cfg.vidPid) ?? null; + if (!profile) { + profile = await loadProfileForVidPid(cfg.vidPid); + } + if (!profile) { + showToast("No profile available", "error"); + return; + } + const ok = await getFlightHidService().startCapture(slotIdx, entry.device, profile); + if (!ok) { + showToast(`Failed to start capture for slot ${slotIdx + 1}`, "error"); + return; + } + setCapturingSlots((prev) => new Set(prev).add(slotIdx)); + }, [slots, devices, profiles, loadProfileForVidPid, showToast]); + + const handleStopCapture = useCallback((slotIdx: number) => { + getFlightHidService().stopCapture(slotIdx); + setCapturingSlots((prev) => { + const next = new Set(prev); + next.delete(slotIdx); + return next; + }); + setLiveStates((prev) => { + const next = new Map(prev); + next.delete(slotIdx); + return next; + }); + setInputAlive((prev) => { + const next = new Map(prev); + next.delete(slotIdx); + return next; + }); + }, []); + + const handleToggleSlotEnabled = useCallback((slotIdx: number, value: boolean) => { + updateSlot(settings, slotIdx, { enabled: value }, onSettingChange); + if (!value) { + handleStopCapture(slotIdx); + } + }, [settings, onSettingChange, handleStopCapture]); + + const handleAxisMappingChange = useCallback( + (idx: number, field: string, value: string | number | boolean) => { + setEditingProfile((prev) => { + if (!prev) return prev; + return { ...prev, axisMappings: prev.axisMappings.map((m, i) => (i === idx ? { ...m, [field]: value } : m)) }; + }); + }, [], + ); + + const handleAddButtonMapping = useCallback(() => { + setEditingProfile((prev) => { + if (!prev) return prev; + return { ...prev, buttonMappings: [...prev.buttonMappings, { sourceIndex: prev.buttonMappings.length, targetButton: 0x1000 }] }; + }); + }, []); + + const handleRemoveButtonMapping = useCallback((idx: number) => { + setEditingProfile((prev) => { + if (!prev) return prev; + return { ...prev, buttonMappings: prev.buttonMappings.filter((_, i) => i !== idx) }; + }); + }, []); + + const handleButtonMappingChange = useCallback( + (idx: number, field: string, value: number) => { + setEditingProfile((prev) => { + if (!prev) return prev; + return { ...prev, buttonMappings: prev.buttonMappings.map((m, i) => (i === idx ? { ...m, [field]: value } : m)) }; + }); + }, [], + ); + + const handleSaveProfile = useCallback(async () => { + if (!editingProfile) return; + await window.openNow.flightSetProfile(editingProfile); + setProfiles((prev) => { + const next = new Map(prev); + next.set(editingProfile.vidPid, editingProfile); + return next; + }); + await loadAllProfiles(); + setSavedIndicator(true); + setTimeout(() => setSavedIndicator(false), 1500); + showToast("Flight profile saved", "success"); + }, [editingProfile, loadAllProfiles, showToast]); + + const handleResetProfile = useCallback(async () => { + if (!editingProfile) return; + const p = await window.openNow.flightResetProfile(editingProfile.vidPid); + if (p) { + setEditingProfile({ ...p }); + setProfiles((prev) => { const next = new Map(prev); next.set(p.vidPid, p); return next; }); + } + await loadAllProfiles(); + showToast("Profile reset to defaults", "info"); + }, [editingProfile, loadAllProfiles, showToast]); + + const handleDeleteProfile = useCallback( + async (vidPid: string, gameId?: string) => { + await window.openNow.flightDeleteProfile(vidPid, gameId); + await loadAllProfiles(); + showToast("Profile deleted", "error"); + }, [loadAllProfiles, showToast], + ); + + const isSlotCapturing = capturingSlots.has(activeTab); + const slotLiveState = liveStates.get(activeTab) ?? null; + const slotInputAlive = inputAlive.get(activeTab) ?? false; + const selectedDeviceEntry = slotConfig.deviceKey ? devices.find((d) => d.deviceKey === slotConfig.deviceKey) : undefined; + const mappingKey = `${slotConfig.vidPid || "none"}:${activeTab}`; + + return ( + <> + {/* Global enable */} +
+ + +
+ + {enabled && !webHidSupported && ( +
+ + WebHID is not available in this browser / Electron version. + +
+ )} + + {enabled && webHidSupported && ( + <> + {/* Slot tabs */} +
+ {[0, 1, 2, 3].map((s) => { + const cfg = slots[s]!; + const active = s === activeTab; + const isCapt = capturingSlots.has(s); + return ( + + ); + })} +
+ + {/* Slot status summary */} +
+ + {(() => { + const activeSlots = [0, 1, 2, 3].filter((s) => capturingSlots.has(s)); + if (activeSlots.length === 0) return "No slots are currently capturing input."; + return `Active: ${activeSlots.map((s) => `Slot ${s + 1}`).join(", ")}`; + })()} + +
+ + {/* Per-slot enable toggle */} +
+ + +
+ + {slotConfig.enabled && ( + <> + {/* Device selection */} +
+ + + {devices.length === 0 ? ( + <> + + + No paired devices. Connect your HOTAS / joystick and click above to pair it. + + + ) : ( +
+ + +
+ )} + + {pairError && ( +
+ + {pairError} +
+ )} + +
+ + + + Show all HID devices + +
+
+ + {/* Capture controls */} + {selectedDeviceEntry && ( +
+
+ +
+ {!isSlotCapturing ? ( + + ) : ( + + )} +
+
+ +
+ + + {!isSlotCapturing && "Not capturing"} + {isSlotCapturing && slotInputAlive && "Receiving input…"} + {isSlotCapturing && !slotInputAlive && "Capturing (waiting for input)"} + +
+ + {isSlotCapturing && ( + + Injecting flight input into slot {activeTab + 1} + + )} +
+ )} + + {/* Live Tester */} + {isSlotCapturing && slotLiveState && ( +
+

+ + Live Input — Slot {activeTab + 1} +

+ + {slotLiveState.axes.length > 0 && ( +
+ {slotLiveState.axes.map((v, i) => ( +
+ Axis {i} +
+
+
+ {v.toFixed(2)} +
+ ))} +
+ )} + + {slotLiveState.buttons.length > 0 && ( +
+ {slotLiveState.buttons.map((pressed, i) => ( +
+ {i} +
+ ))} +
+ )} + + {slotLiveState.hatSwitch >= 0 && ( +
Hat: {slotLiveState.hatSwitch}
+ )} + +
+ Raw bytes ({slotLiveState.rawBytes.length}) +
+ {slotLiveState.rawBytes.map((b, i) => ( + {b.toString(16).padStart(2, "0").toUpperCase()} + ))} +
+
+
+ )} + + {/* Mapping editor */} + {editingProfile && ( + <> +
+
+

Axis Mappings

+
+ + +
+
+ +
+ {editingProfile.axisMappings.map((mapping, idx) => ( +
+ Axis {mapping.sourceIndex} +
+
+ + +
+
+ + handleAxisMappingChange(idx, "deadzone", parseFloat(e.target.value))} + /> + {mapping.deadzone.toFixed(2)} +
+
+ + handleAxisMappingChange(idx, "sensitivity", parseFloat(e.target.value))} + /> + {mapping.sensitivity.toFixed(1)} +
+
+ + +
+
+ + handleAxisMappingChange(idx, "inverted", e.target.checked)} + /> +
+
+
+ ))} +
+ +
+

Button Mappings

+ +
+ +
+ {editingProfile.buttonMappings.map((mapping, idx) => ( +
+ Btn {mapping.sourceIndex} +
+
+ + handleButtonMappingChange(idx, "sourceIndex", parseInt(e.target.value) || 0)} + /> +
+
+ + +
+
+ +
+ ))} +
+
+ + {allProfiles.length > 0 && ( +
+

Saved Profiles ({allProfiles.length})

+
+ {allProfiles.map((p, i) => ( +
+
+ {p.name || p.vidPid} + + {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..5d981691 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,8 @@ import type { } from "@shared/gfn"; import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; +import { FlightControlsPanel } from "./FlightControlsPanel"; +import { useToast } from "./Toast"; interface SettingsPageProps { settings: Settings; @@ -434,6 +437,7 @@ async function testCodecSupport(): Promise { export function SettingsPage({ settings, regions, onSettingChange }: SettingsPageProps): JSX.Element { const [savedIndicator, setSavedIndicator] = useState(false); + const { showToast } = useToast(); const [regionSearch, setRegionSearch] = useState(""); const [regionDropdownOpen, setRegionDropdownOpen] = useState(false); @@ -1418,6 +1422,20 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag + + {/* ── Flight Controls ──────────────────────────────── */} +
+
+ +

Flight Controls

+
+
+ +
+
{/* Footer */} @@ -1425,8 +1443,7 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag