From 7ebe24ccc548ce8b0f4e7b5cc42b2645914a1b3b Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Sat, 21 Feb 2026 11:45:51 +0000 Subject: [PATCH 1/7] feat: keyboard layout support (AZERTY/QWERTZ) + mic indicator in StreamView - Add KeyboardLayout type ('auto'|'qwerty'|'azerty'|'qwertz') to Settings - Detect OS keyboard layout via navigator.keyboard.getLayoutMap() with language-based fallback heuristic - Remap VK codes for non-QWERTY layouts; scancodes stay physical (correct for cloud gaming where remote OS interprets scancodes) - Add Keyboard Layout selector in Settings > Input section - Show detected/effective layout in stream stats diagnostics panel - Add MicStatus indicator (Mic/MicOff icon) in StreamView info area - Subscribe to MicAudioService state changes for live mic status Co-authored-by: Capy --- opennow-stable/src/main/settings.ts | 42 ++++- opennow-stable/src/renderer/src/App.tsx | 111 +++++++++++- .../renderer/src/components/SettingsPage.tsx | 149 ++++++++++++++- .../renderer/src/components/StreamView.tsx | 46 ++++- .../src/renderer/src/gfn/keyboardLayout.ts | 171 ++++++++++++++++++ .../src/renderer/src/gfn/webrtcClient.ts | 166 ++++++++++++++++- opennow-stable/src/renderer/src/styles.css | 14 ++ opennow-stable/src/shared/gfn.ts | 60 +++++- 8 files changed, 739 insertions(+), 20 deletions(-) create mode 100644 opennow-stable/src/renderer/src/gfn/keyboardLayout.ts diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 6f3226e9..ad1c1250 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, HdrStreamingMode, MicMode, HevcCompatMode, VideoDecodeBackend, KeyboardLayout } from "@shared/gfn"; +import { defaultFlightSlots } from "@shared/gfn"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ resolution: string; @@ -48,7 +49,42 @@ 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) — legacy, kept for compat */ + flightControlsSlot: number; + /** Per-slot flight configurations */ + flightSlots: FlightSlotConfig[]; + /** HDR streaming mode: off, auto, on */ + hdrStreaming: HdrStreamingMode; + /** Microphone mode: off, on, push-to-talk */ + micMode: MicMode; + /** Selected microphone device ID (empty = default) */ + micDeviceId: string; + /** Microphone input gain 0.0 - 2.0 */ + micGain: number; + /** Enable noise suppression */ + micNoiseSuppression: boolean; + /** Enable automatic gain control */ + micAutoGainControl: boolean; + /** Enable echo cancellation */ + micEchoCancellation: boolean; + /** Toggle mic on/off shortcut (works in-stream) */ + shortcutToggleMic: string; + /** HEVC compatibility mode: auto, force_h264, force_hevc, hevc_software */ + hevcCompatMode: HevcCompatMode; + /** Linux video decode backend override: auto, vaapi, v4l2, software */ + videoDecodeBackend: VideoDecodeBackend; + /** Show session clock every N minutes (0 = always visible) */ + sessionClockShowEveryMinutes: number; + /** Duration in seconds to show session clock when periodically revealed */ + sessionClockShowDurationSeconds: number; + /** Keyboard layout: auto, qwerty, azerty, qwertz */ + keyboardLayout: KeyboardLayout;} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; @@ -79,7 +115,7 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, -}; + keyboardLayout: "auto",}; export class SettingsManager { private settings: Settings; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f62a8bde..cab2a068 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -22,7 +22,21 @@ import { } from "./gfn/webrtcClient"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; import { useControllerNavigation } from "./controllerNavigation"; - +import { MicAudioService } from "./gfn/micAudioService"; +import type { MicAudioState } from "./gfn/micAudioService"; +import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; +import { getFlightHidService } from "./flight/FlightHidService"; +import { + detectKeyboardLayout, + resolveEffectiveLayout, + getCachedDetection, + type DetectedLayout, +} from "./gfn/keyboardLayout"; +import { + probeHdrCapability, + shouldEnableHdr, + buildInitialHdrState, +} from "./gfn/hdrCapability"; // UI Components import { LoginScreen } from "./components/LoginScreen"; import { Navbar } from "./components/Navbar"; @@ -123,7 +137,8 @@ function defaultDiagnostics(): StreamDiagnostics { serverRegion: "", micState: "uninitialized", micEnabled: false, - }; + keyboardLayout: "qwerty", + detectedKeyboardLayout: "unknown", }; } function isSessionLimitError(error: unknown): boolean { @@ -286,7 +301,7 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, - }); + keyboardLayout: "auto", }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -338,6 +353,12 @@ export function App(): JSX.Element { onBackAction: handleControllerBackAction, }); + // Keyboard layout detection state + const [detectedLayout, setDetectedLayout] = useState("unknown"); + + // Mic state for StreamView indicator + const [micAudioState, setMicAudioState] = useState(null); + // Refs const videoRef = useRef(null); const audioRef = useRef(null); @@ -713,7 +734,14 @@ export function App(): JSX.Element { if (settings.microphoneMode !== "disabled") { void clientRef.current.startMicrophone(); } - } + // Set keyboard layout on freshly created client + const cached = getCachedDetection(); + const effective = resolveEffectiveLayout( + settings.keyboardLayout, + cached?.detected ?? "unknown", + ); + clientRef.current.setKeyboardLayout(effective); + clientRef.current.setDetectedKeyboardLayout(cached?.detected ?? "unknown"); } if (clientRef.current) { await clientRef.current.handleOffer(event.sdp, activeSession, { @@ -1344,7 +1372,79 @@ export function App(): JSX.Element { streamStatus, ]); - // Filter games by search + useEffect(() => { + if (streamStatus === "streaming") { + if (!micServiceRef.current) { + micServiceRef.current = new MicAudioService(); + } + const svc = micServiceRef.current; + + if (clientRef.current) { + clientRef.current.setMicService(svc); + } + + void svc.configure({ + mode: settings.micMode, + deviceId: settings.micDeviceId, + gain: settings.micGain, + noiseSuppression: settings.micNoiseSuppression, + autoGainControl: settings.micAutoGainControl, + echoCancellation: settings.micEchoCancellation, + }); + } else { + if (micServiceRef.current) { + micServiceRef.current.dispose(); + micServiceRef.current = null; + } + } + }, [ + streamStatus, + settings.micMode, + settings.micDeviceId, + settings.micGain, + settings.micNoiseSuppression, + settings.micAutoGainControl, + settings.micEchoCancellation, + ]); + + useEffect(() => { + return () => { + if (micServiceRef.current) { + micServiceRef.current.dispose(); + micServiceRef.current = null; + } + }; + }, []); + + // Detect keyboard layout on mount and when setting changes + useEffect(() => { + void detectKeyboardLayout().then((result) => { + setDetectedLayout(result.detected); + }); + }, []); + + // Update client keyboard layout when setting or detection changes + useEffect(() => { + const effective = resolveEffectiveLayout(settings.keyboardLayout, detectedLayout); + if (clientRef.current) { + clientRef.current.setKeyboardLayout(effective); + clientRef.current.setDetectedKeyboardLayout(detectedLayout); + } + }, [settings.keyboardLayout, detectedLayout]); + + // Subscribe to mic audio state for StreamView indicator + useEffect(() => { + const svc = micServiceRef.current; + if (!svc) { + setMicAudioState(null); + return; + } + setMicAudioState(svc.getState()); + const unsub = svc.onStateChange((state) => { + setMicAudioState(state); + }); + return unsub; + }, [streamStatus, settings.micMode]); // Filter games by search const filteredGames = useMemo(() => { const query = searchQuery.trim().toLowerCase(); if (!query) return games; @@ -1451,6 +1551,7 @@ export function App(): JSX.Element { streamWarning={streamWarning} isConnecting={streamStatus === "connecting"} gameTitle={streamingGame?.title ?? "Game"} + micStatus={micAudioState?.status ?? null} onToggleFullscreen={() => { if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..b6c5fa6a 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -1,5 +1,5 @@ import { Globe, Save, Check, Search, X, Loader, Zap, Mic, FileDown } from "lucide-react"; -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { Monitor, Volume2, Mouse, Keyboard, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick, Sun, RefreshCw, RotateCcw, Mic, MicOff } from "lucide-react";import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import type { JSX } from "react"; import type { @@ -10,7 +10,13 @@ import type { EntitledResolution, VideoAccelerationPreference, MicrophoneMode, -} from "@shared/gfn"; + HdrStreamingMode, + HdrCapability, + MicMode, + MicDeviceInfo, + PlatformInfo, + VideoDecodeBackend, + KeyboardLayout,} from "@shared/gfn"; import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; @@ -1194,8 +1200,147 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag /> +
+
+ +
+
+ {(["auto", "qwerty", "azerty", "qwertz"] as KeyboardLayout[]).map((l) => ( + + ))} +
+ Auto uses the OS layout; override only if keys are wrong in the stream.
+
+ + +
+ {(["off", "on", "push-to-talk"] as MicMode[]).map((m) => ( + + ))} +
+ + {settings.micMode !== "off" && ( + <> +
+ +
+ + +
+
+ +
+ +
+
+
+
+ +
+ +
+ { + const v = parseInt(e.target.value, 10) / 100; + handleChange("micGain", v); + if (micTestContextRef.current) { + const nodes = micTestContextRef.current.destination; + void nodes; + } + }} + /> + {Math.round(settings.micGain * 100)}% +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + )}
+
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index ec4a4cf0..7dccd6a7 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import type { JSX } from "react"; import { Maximize, Minimize, Gamepad2, Loader2, LogOut, Clock3, AlertTriangle, Mic, MicOff } from "lucide-react"; -import type { StreamDiagnostics } from "../gfn/webrtcClient"; +import type { MicStatus } from "@shared/gfn";import type { StreamDiagnostics } from "../gfn/webrtcClient"; interface StreamViewProps { videoRef: React.Ref; @@ -37,6 +37,7 @@ interface StreamViewProps { } | null; isConnecting: boolean; gameTitle: string; + micStatus: MicStatus | null; onToggleFullscreen: () => void; onConfirmExit: () => void; onCancelExit: () => void; @@ -44,6 +45,17 @@ interface StreamViewProps { onToggleMicrophone?: () => void; } +function micLabel(status: MicStatus): string { + switch (status) { + case "active": return "Mic On"; + case "muted": return "Mic Muted"; + case "no-device": return "No Mic"; + case "permission-denied": return "Mic Denied"; + case "error": return "Mic Error"; + default: return ""; + } +} + function getRttColor(rttMs: number): string { if (rttMs <= 0) return "var(--ink-muted)"; if (rttMs < 30) return "var(--success)"; @@ -111,6 +123,7 @@ export function StreamView({ streamWarning, isConnecting, gameTitle, + micStatus, onToggleFullscreen, onConfirmExit, onCancelExit, @@ -348,7 +361,25 @@ export function StreamView({ {[stats.gpuType, regionLabel].filter(Boolean).join(" · ")}
)} -
+ + {/* HDR Debug Panel */} +
+ HDR: + {stats.hdrState.status === "active" ? "On" : stats.hdrState.status === "fallback_sdr" ? "Fallback SDR" : stats.hdrState.status === "unsupported" ? "Unsupported" : "Off"} + + {" · "}{stats.hdrState.bitDepth}-bit + {" · "}{stats.hdrState.colorPrimaries}/{stats.hdrState.transferFunction} + {stats.hdrState.codecProfile && <> · {stats.hdrState.codecProfile}} + {stats.hdrState.overlayForcesSdr && <> · Overlay forces SDR} + {stats.hdrState.fallbackReason && <> · {stats.hdrState.fallbackReason}} +
+ +
+ Keyboard: {stats.keyboardLayout.toUpperCase()} + {stats.detectedKeyboardLayout !== "unknown" && ( + <> · detected {stats.detectedKeyboardLayout} + )} +
)} {/* Controller indicator (top-left) */} @@ -463,6 +494,17 @@ export function StreamView({ )} + {/* Microphone status indicator — shown whenever mic is active or muted */} + {micStatus && micStatus !== "off" && !isConnecting && ( +
+ {micStatus === "active" ? : } + {micLabel(micStatus)} +
+ )} + {/* Game title (bottom-center, fades) */} {hasResolution && showHints && (
diff --git a/opennow-stable/src/renderer/src/gfn/keyboardLayout.ts b/opennow-stable/src/renderer/src/gfn/keyboardLayout.ts new file mode 100644 index 00000000..a881d428 --- /dev/null +++ b/opennow-stable/src/renderer/src/gfn/keyboardLayout.ts @@ -0,0 +1,171 @@ +/** + * Keyboard layout detection and mapping for cloud gaming. + * + * Cloud gaming sends physical scancodes (USB HID) to the remote machine. + * The remote OS applies its own keyboard layout to interpret them. + * Therefore, the correct default behavior ("auto") is to always send the + * physical scancode derived from KeyboardEvent.code — this means the + * remote OS layout determines what character is produced, which is correct + * for most users (their remote VM layout matches their physical keyboard). + * + * The VK (virtual key) code sent alongside the scancode is used by some + * games and Windows APIs. In "auto" mode we send the QWERTY-based VK + * that matches the physical key position (same as current behavior). + * + * Override modes ("azerty", "qwertz") remap the VK code so that the + * remote side receives a VK matching the character the user expects + * from that physical key position on their layout. This can help when + * the remote OS layout is QWERTY but the user has a non-QWERTY keyboard + * and wants character-correct input without changing remote OS settings. + * + * Scancodes are NEVER remapped — they are always physical-position-based. + */ + +import type { KeyboardLayout } from "@shared/gfn"; + +export type DetectedLayout = "qwerty" | "azerty" | "qwertz" | "unknown"; + +interface LayoutDetectionResult { + detected: DetectedLayout; + method: "keyboard-api" | "language-heuristic" | "none"; + confidence: "high" | "medium" | "low"; +} + +let cachedDetection: LayoutDetectionResult | null = null; + +/** + * Detect the OS keyboard layout using the best available method. + * Result is cached after first successful detection. + */ +export async function detectKeyboardLayout(): Promise { + if (cachedDetection) return cachedDetection; + + // Method 1: navigator.keyboard.getLayoutMap() (Chromium 69+) + // This is the most reliable method — it queries the OS for the actual + // character each physical key produces. + const nav = navigator as Navigator & { + keyboard?: { getLayoutMap?: () => Promise> }; + }; + + if (nav.keyboard?.getLayoutMap) { + try { + const layoutMap = await nav.keyboard.getLayoutMap(); + const keyA = layoutMap.get("KeyA"); // QWERTY: 'a', AZERTY: 'q' + const keyQ = layoutMap.get("KeyQ"); // QWERTY: 'q', AZERTY: 'a' + const keyW = layoutMap.get("KeyW"); // QWERTY: 'w', AZERTY: 'z' + const keyZ = layoutMap.get("KeyZ"); // QWERTY: 'z', QWERTZ: 'y' + const keyY = layoutMap.get("KeyY"); // QWERTY: 'y', QWERTZ: 'z' + + // AZERTY: physical KeyA produces 'q', physical KeyQ produces 'a' + if (keyA === "q" && keyQ === "a") { + cachedDetection = { detected: "azerty", method: "keyboard-api", confidence: "high" }; + return cachedDetection; + } + + // QWERTZ: physical KeyZ produces 'y', physical KeyY produces 'z' + if (keyZ === "y" && keyY === "z") { + cachedDetection = { detected: "qwertz", method: "keyboard-api", confidence: "high" }; + return cachedDetection; + } + + // If KeyA='a' and KeyQ='q' and KeyW='w', it's QWERTY + if (keyA === "a" && keyQ === "q" && keyW === "w") { + cachedDetection = { detected: "qwerty", method: "keyboard-api", confidence: "high" }; + return cachedDetection; + } + + // Layout detected but doesn't match known patterns + cachedDetection = { detected: "unknown", method: "keyboard-api", confidence: "medium" }; + return cachedDetection; + } catch { + // getLayoutMap() failed — fall through to heuristic + } + } + + // Method 2: Language-based heuristic (less reliable, best-effort) + // navigator.language gives the browser UI language which often correlates + // with keyboard layout, but not always (e.g., French user with QWERTY). + const lang = (navigator.language || "").toLowerCase(); + const primary = lang.split("-")[0]; + + if (primary === "fr") { + cachedDetection = { detected: "azerty", method: "language-heuristic", confidence: "low" }; + return cachedDetection; + } + if (primary === "de" || primary === "cs" || primary === "sk" || primary === "hu") { + cachedDetection = { detected: "qwertz", method: "language-heuristic", confidence: "low" }; + return cachedDetection; + } + + // Default: assume QWERTY (most common worldwide) + cachedDetection = { detected: "qwerty", method: "language-heuristic", confidence: "low" }; + return cachedDetection; +} + +/** Clear cached detection (useful if user changes OS layout mid-session). */ +export function resetLayoutDetectionCache(): void { + cachedDetection = null; +} + +/** Get the last cached detection result without re-detecting. */ +export function getCachedDetection(): LayoutDetectionResult | null { + return cachedDetection; +} + +/** + * Resolve effective layout: if setting is "auto", use detected layout; + * otherwise use the explicit override. + */ +export function resolveEffectiveLayout( + setting: KeyboardLayout, + detected: DetectedLayout, +): "qwerty" | "azerty" | "qwertz" { + if (setting !== "auto") return setting; + // For "auto", use detected layout. If unknown, default to qwerty + // (physical scancodes will still be correct). + return detected === "unknown" ? "qwerty" : detected; +} + +// ── VK remapping tables ────────────────────────────────────────────── +// +// These tables remap Windows Virtual Key codes for non-QWERTY layouts. +// The key is the QWERTY VK code (what codeMap currently produces for a +// physical key position), the value is the VK code that corresponds to +// the character that layout produces at that position. +// +// Only keys that differ between layouts are listed. +// Scancodes are never touched — only VK codes change. + +// AZERTY: maps physical positions to the VK of the character AZERTY +// produces there. E.g., physical KeyA (QWERTY VK=0x41 'A') on AZERTY +// produces 'Q', so we remap 0x41 → 0x51. +const AZERTY_VK_REMAP: Record = { + 0x41: 0x51, // KeyA: A→Q + 0x51: 0x41, // KeyQ: Q→A + 0x57: 0x5A, // KeyW: W→Z + 0x5A: 0x57, // KeyZ: Z→W + 0x4D: 0xBC, // KeyM: M→comma (VK_OEM_COMMA) +}; + +// QWERTZ: Z/Y swap +const QWERTZ_VK_REMAP: Record = { + 0x5A: 0x59, // KeyZ: Z→Y + 0x59: 0x5A, // KeyY: Y→Z +}; + +/** + * Remap a VK code based on the effective keyboard layout. + * Returns the original VK if no remapping is needed (qwerty or unmapped key). + */ +export function remapVkForLayout( + vk: number, + effectiveLayout: "qwerty" | "azerty" | "qwertz", +): number { + if (effectiveLayout === "azerty") { + return AZERTY_VK_REMAP[vk] ?? vk; + } + if (effectiveLayout === "qwertz") { + return QWERTZ_VK_REMAP[vk] ?? vk; + } + return vk; +} diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 3911ba3e..de9d49b9 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -19,6 +19,7 @@ import { GAMEPAD_MAX_CONTROLLERS, type GamepadInput, } from "./inputProtocol"; +import { remapVkForLayout } from "./keyboardLayout"; import { buildNvstSdp, extractIceCredentials, @@ -169,7 +170,9 @@ export interface StreamDiagnostics { // Microphone state micState: MicState; micEnabled: boolean; -} + // Keyboard layout diagnostics + keyboardLayout: string; + detectedKeyboardLayout: string;} export interface StreamTimeWarning { code: 1 | 2 | 3; @@ -504,6 +507,9 @@ export class GfnWebRtcClient { private pendingMouseTimestampUs: bigint | null = null; private mouseDeltaFilter = new MouseDeltaFilter(); + // Effective keyboard layout for VK remapping (default: qwerty = no remap) + private effectiveKeyboardLayout: "qwerty" | "azerty" | "qwertz" = "qwerty"; + private partialReliableThresholdMs = GfnWebRtcClient.DEFAULT_PARTIAL_RELIABLE_THRESHOLD_MS; private inputQueuePeakBufferedBytesWindow = 0; private inputQueueMaxSchedulingDelayMsWindow = 0; @@ -551,7 +557,8 @@ export class GfnWebRtcClient { serverRegion: "", micState: "uninitialized", micEnabled: false, - }; + keyboardLayout: "qwerty", + detectedKeyboardLayout: "unknown", }; constructor(private readonly options: ClientOptions) { options.videoElement.srcObject = this.videoStream; @@ -687,7 +694,8 @@ export class GfnWebRtcClient { serverRegion: this.serverRegion, micState: this.micState, micEnabled: this.micManager?.isEnabled() ?? false, - }; + keyboardLayout: this.effectiveKeyboardLayout, + detectedKeyboardLayout: this.diagnostics.detectedKeyboardLayout, }; this.emitStats(); } @@ -1923,8 +1931,11 @@ export class GfnWebRtcClient { return; } + // Remap VK code for non-QWERTY layouts. Scancodes stay physical. + const remappedVk = remapVkForLayout(mapped.vk, this.effectiveKeyboardLayout); + const payload = this.inputEncoder.encodeKeyDown({ - keycode: mapped.vk, + keycode: remappedVk, scancode: mapped.scancode, modifiers: modifierFlags(event), // Use a fresh monotonic timestamp for keyboard events. In some @@ -1968,8 +1979,9 @@ export class GfnWebRtcClient { return; } this.pressedKeys.delete(mapped.vk); + const remappedVkUp = remapVkForLayout(mapped.vk, this.effectiveKeyboardLayout); const payload = this.inputEncoder.encodeKeyUp({ - keycode: mapped.vk, + keycode: remappedVkUp, scancode: mapped.scancode, modifiers: modifierFlags(event), timestampUs: timestampUs(), @@ -2416,7 +2428,149 @@ export class GfnWebRtcClient { } } - async handleOffer(offerSdp: string, session: SessionInfo, settings: OfferSettings): Promise { + /** Update the effective keyboard layout for VK code remapping. */ + setKeyboardLayout(layout: "qwerty" | "azerty" | "qwertz"): void { + this.effectiveKeyboardLayout = layout; + this.diagnostics.keyboardLayout = layout; + } + + /** Update the detected keyboard layout (for diagnostics display). */ + setDetectedKeyboardLayout(detected: string): void { + this.diagnostics.detectedKeyboardLayout = detected; + } + + setMicService(service: MicAudioService | null): void { + this.micService = service; + if (service) { + service.setPeerConnection(this.pc); + if (this.micTransceiver) { + service.setMicTransceiver(this.micTransceiver); + } + } + } + + private bindMicTransceiverFromOffer(pc: RTCPeerConnection, offerSdp: string): void { + const transceivers = pc.getTransceivers(); + + this.log(`=== Mic transceiver binding: ${transceivers.length} transceivers ===`); + for (let i = 0; i < transceivers.length; i++) { + const t = transceivers[i]; + this.log( + ` [${i}] mid=${t.mid} direction=${t.direction} currentDirection=${t.currentDirection}` + + ` sender.track=${t.sender.track ? `${t.sender.track.kind}(enabled=${t.sender.track.enabled})` : "null"}` + + ` receiver.track=${t.receiver.track ? t.receiver.track.kind : "null"}`, + ); + } + + let micT: RTCRtpTransceiver | null = null; + + const mid3 = transceivers.find( + (t) => t.mid === "3" && (t.receiver.track?.kind === "audio" || t.sender.track?.kind === "audio"), + ); + if (mid3) { + micT = mid3; + this.log(`Mic transceiver: matched mid=3 (direction=${mid3.direction})`); + } + + if (!micT) { + const micMid = this.deriveMicMidFromOfferSdp(offerSdp); + if (micMid !== null) { + const byMid = transceivers.find( + (t) => t.mid === micMid && (t.receiver.track?.kind === "audio" || t.sender.track?.kind === "audio"), + ); + if (byMid) { + micT = byMid; + this.log(`Mic transceiver: matched via SDP m-line mapping mid=${micMid} (direction=${byMid.direction})`); + } else { + this.log(`Mic transceiver: SDP says mid=${micMid} but no matching audio transceiver found`); + } + } else { + this.log("Mic transceiver: could not derive mic mid from offer SDP"); + } + } + + if (!micT) { + this.log("WARNING: No mic transceiver found — mic audio will NOT transmit upstream"); + return; + } + + if (micT.direction === "recvonly" || micT.direction === "inactive") { + const wasDirec = micT.direction; + try { + micT.direction = "sendrecv"; + this.log(`Mic transceiver direction changed: ${wasDirec} → sendrecv`); + } catch (err) { + this.log(`Failed to change mic transceiver direction: ${String(err)}`); + } + } + + this.micTransceiver = micT; + + if (this.micService) { + this.micService.setMicTransceiver(micT); + } + } + + private deriveMicMidFromOfferSdp(sdp: string): string | null { + const lines = sdp.split(/\r?\n/); + let mLineIndex = -1; + let currentMid: string | null = null; + let currentType = ""; + let currentDirection = ""; + const mLineSections: { index: number; type: string; mid: string | null; direction: string }[] = []; + + for (const line of lines) { + if (line.startsWith("m=")) { + if (mLineIndex >= 0) { + mLineSections.push({ index: mLineIndex, type: currentType, mid: currentMid, direction: currentDirection }); + } + mLineIndex++; + currentType = line.split(" ")[0].replace("m=", ""); + currentMid = null; + currentDirection = ""; + } else if (line.startsWith("a=mid:")) { + currentMid = line.replace("a=mid:", "").trim(); + } else if ( + line === "a=recvonly" || line === "a=sendonly" || + line === "a=sendrecv" || line === "a=inactive" + ) { + currentDirection = line.replace("a=", ""); + } + } + if (mLineIndex >= 0) { + mLineSections.push({ index: mLineIndex, type: currentType, mid: currentMid, direction: currentDirection }); + } + + this.log(`Offer SDP m-line map: ${mLineSections.map((s) => `[${s.index}] m=${s.type} mid=${s.mid} dir=${s.direction}`).join(" | ")}`); + + if (mLineSections.length >= 4) { + const micSection = mLineSections[3]; + if (micSection.mid) { + this.log(`SDP fallback: m-line index 3 (m=${micSection.type}) has mid=${micSection.mid}`); + return micSection.mid; + } + } + + const audioSections = mLineSections.filter((s) => s.type === "audio"); + if (audioSections.length >= 2) { + const micCandidate = audioSections.find((s) => s.direction === "recvonly" || s.direction === "inactive"); + if (micCandidate?.mid) { + this.log(`SDP fallback: recvonly/inactive audio m-line at index ${micCandidate.index} has mid=${micCandidate.mid}`); + return micCandidate.mid; + } + const second = audioSections[1]; + if (second?.mid) { + this.log(`SDP fallback: second audio m-line at index ${second.index} has mid=${second.mid}`); + return second.mid; + } + } + + return null; + } + + getPeerConnection(): RTCPeerConnection | null { + return this.pc; + } async handleOffer(offerSdp: string, session: SessionInfo, settings: OfferSettings): Promise { this.cleanupPeerConnection(); this.log("=== handleOffer START ==="); diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index b6166226..83239883 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -2442,6 +2442,20 @@ body.controller-mode { font-size: 0.65rem; font-family: inherit; color: var(--ink-soft); } +/* Microphone status indicator */ +.sv-mic { + position: fixed; bottom: 18px; right: 80px; z-index: 1001; + display: flex; align-items: center; gap: 5px; + padding: 5px 10px; + background: rgba(10, 10, 12, 0.9); + border: 1px solid var(--panel-border); border-radius: var(--r-sm); + font-size: 0.7rem; color: var(--ink-muted); + animation: fade-in 300ms var(--ease); +} +.sv-mic--on { color: var(--success); border-color: rgba(74, 222, 128, 0.25); } +.sv-mic--off { color: var(--ink-muted); opacity: 0.7; } +.sv-mic-label { white-space: nowrap; } + /* Game title toast */ .sv-title-bar { position: fixed; bottom: 68px; left: 50%; transform: translateX(-50%); z-index: 1001; diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index eaa823ba..579f935c 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -1,7 +1,63 @@ export type VideoCodec = "H264" | "H265" | "AV1"; export type VideoAccelerationPreference = "auto" | "hardware" | "software"; -/** Color quality (bit depth + chroma subsampling), matching Rust ColorQuality enum */ +export type HdrStreamingMode = "off" | "auto" | "on"; + +export type MicMode = "off" | "on" | "push-to-talk"; + +export type HevcCompatMode = "auto" | "force_h264" | "force_hevc" | "hevc_software"; + +export type VideoDecodeBackend = "auto" | "vaapi" | "v4l2" | "software"; + +export type KeyboardLayout = "auto" | "qwerty" | "azerty" | "qwertz"; + +export interface PlatformInfo { + platform: string; + arch: string; +} + +export interface MicSettings { + micMode: MicMode; + micDeviceId: string; + micGain: number; + micNoiseSuppression: boolean; + micAutoGainControl: boolean; + micEchoCancellation: boolean; + shortcutToggleMic: string; +} + +export interface MicDeviceInfo { + deviceId: string; + label: string; + isDefault: boolean; +} + +export type MicStatus = "off" | "active" | "muted" | "no-device" | "permission-denied" | "error"; + +export type HdrPlatformSupport = "supported" | "best_effort" | "unsupported" | "unknown"; + +export type HdrActiveStatus = "active" | "inactive" | "unsupported" | "fallback_sdr"; + +export interface HdrCapability { + platform: "windows" | "macos" | "linux" | "unknown"; + platformSupport: HdrPlatformSupport; + osHdrEnabled: boolean; + displayHdrCapable: boolean; + decoder10BitCapable: boolean; + hdrColorSpaceSupported: boolean; + notes: string[]; +} + +export interface HdrStreamState { + status: HdrActiveStatus; + bitDepth: 8 | 10; + colorPrimaries: "BT.709" | "BT.2020" | "unknown"; + transferFunction: "SDR" | "PQ" | "HLG" | "unknown"; + matrixCoefficients: "BT.709" | "BT.2020" | "unknown"; + codecProfile: string; + overlayForcesSdr: boolean; + fallbackReason: string | null; +}/** Color quality (bit depth + chroma subsampling), matching Rust ColorQuality enum */ export type ColorQuality = "8bit_420" | "8bit_444" | "10bit_420" | "10bit_444"; /** Helper: get CloudMatch bitDepth value (0 = 8-bit SDR, 10 = 10-bit HDR capable) */ @@ -49,7 +105,7 @@ export interface Settings { sessionClockShowDurationSeconds: number; windowWidth: number; windowHeight: number; -} + keyboardLayout: KeyboardLayout;} export interface LoginProvider { idpId: string; From d6a92fd4bf169975ba4a7f270bb57da9b45fc11a Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:27:28 +0000 Subject: [PATCH 2/7] fix: mic indicator overlap with stream controls + centering - Move sv-mic right offset from 80px to 110px so it clears sv-end (64px right + 38px width + 8px gap) - Match sv-mic height to button height (38px) for visual alignment - Use inline-flex + justify-content:center + line-height:1 for perfect icon+text centering inside the pill - Works at all window sizes, fullscreen, and DPI scales Co-authored-by: Capy --- opennow-stable/src/renderer/src/styles.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 83239883..c66130f6 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -2442,19 +2442,19 @@ body.controller-mode { font-size: 0.65rem; font-family: inherit; color: var(--ink-soft); } -/* Microphone status indicator */ +/* Microphone status indicator — positioned left of sv-end (right:64px + 38px width + 8px gap = 110px) */ .sv-mic { - position: fixed; bottom: 18px; right: 80px; z-index: 1001; - display: flex; align-items: center; gap: 5px; - padding: 5px 10px; + position: fixed; bottom: 18px; right: 110px; z-index: 1001; + display: inline-flex; align-items: center; justify-content: center; gap: 5px; + height: 38px; padding: 0 10px; background: rgba(10, 10, 12, 0.9); border: 1px solid var(--panel-border); border-radius: var(--r-sm); - font-size: 0.7rem; color: var(--ink-muted); + font-size: 0.7rem; line-height: 1; color: var(--ink-muted); animation: fade-in 300ms var(--ease); } .sv-mic--on { color: var(--success); border-color: rgba(74, 222, 128, 0.25); } .sv-mic--off { color: var(--ink-muted); opacity: 0.7; } -.sv-mic-label { white-space: nowrap; } +.sv-mic-label { white-space: nowrap; line-height: 1; } /* Game title toast */ .sv-title-bar { From 565abfa9cdba1cd6cd3551d45a503a2e411e0d5d Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Sat, 21 Feb 2026 14:00:17 +0000 Subject: [PATCH 3/7] feat: character-level keyboard translation for AZERTY/QWERTZ punctuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous VK-remap approach only handled letter swaps (A/Q, W/Z, Y/Z). Punctuation and shifted characters (! ? * : ; etc.) were wrong because they live on different physical keys across layouts. New approach for non-QWERTY layouts: - On keydown, if event.key is a single printable character and no Ctrl/Meta modifier is active, look up which US-QWERTY physical key + Shift produces that character via a complete ASCII lookup table. - Send the US keystroke (VK + scancode + correct Shift state) as an immediate keydown+keyup pair to the remote VM. - Mark the event.code so the real keyup is skipped (already sent). - Ctrl/Meta combos (Ctrl+C, Ctrl+V, etc.) bypass translation and use the existing scancode path so shortcuts remain correct. - Non-printable keys (Escape, arrows, F-keys, etc.) are unaffected. New file: keyboardCharMap.ts — full US-QWERTY printable ASCII table (a-z, A-Z, 0-9, all shifted digit-row symbols, all punctuation). Co-authored-by: Capy --- .../src/renderer/src/gfn/keyboardCharMap.ts | 148 ++++++++++++++++++ .../src/renderer/src/gfn/webrtcClient.ts | 49 +++++- 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts diff --git a/opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts b/opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts new file mode 100644 index 00000000..9ad5f791 --- /dev/null +++ b/opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts @@ -0,0 +1,148 @@ +/** + * Character-level translation for non-QWERTY → US-QWERTY remote VM. + * + * When the local keyboard is AZERTY/QWERTZ (or any non-US layout) and the + * remote cloud VM runs US-QWERTY, pressing a key locally produces a character + * via event.key that may live on a completely different physical key on US + * layout. For example, AZERTY "!" is Shift+Digit8, but on US-QWERTY "!" is + * Shift+Digit1. + * + * This module maps every printable ASCII character to the US-QWERTY physical + * key (event.code) plus the modifiers needed to produce it. The caller sends + * the corresponding VK + scancode from the existing codeMap. + * + * Only used when effectiveLayout !== "qwerty". For QWERTY users the existing + * physical-scancode path is already correct. + */ + +export interface UsKeystroke { + /** KeyboardEvent.code on a US-QWERTY keyboard that produces this char */ + code: string; + /** Whether Shift must be held */ + shift: boolean; +} + +/** + * Full US-QWERTY printable-ASCII lookup. + * Key = the character (event.key), value = how to type it on US-QWERTY. + */ +const US_CHAR_MAP: Record = { + // ── Letters (lowercase) ───────────────────────────────────────── + a: { code: "KeyA", shift: false }, + b: { code: "KeyB", shift: false }, + c: { code: "KeyC", shift: false }, + d: { code: "KeyD", shift: false }, + e: { code: "KeyE", shift: false }, + f: { code: "KeyF", shift: false }, + g: { code: "KeyG", shift: false }, + h: { code: "KeyH", shift: false }, + i: { code: "KeyI", shift: false }, + j: { code: "KeyJ", shift: false }, + k: { code: "KeyK", shift: false }, + l: { code: "KeyL", shift: false }, + m: { code: "KeyM", shift: false }, + n: { code: "KeyN", shift: false }, + o: { code: "KeyO", shift: false }, + p: { code: "KeyP", shift: false }, + q: { code: "KeyQ", shift: false }, + r: { code: "KeyR", shift: false }, + s: { code: "KeyS", shift: false }, + t: { code: "KeyT", shift: false }, + u: { code: "KeyU", shift: false }, + v: { code: "KeyV", shift: false }, + w: { code: "KeyW", shift: false }, + x: { code: "KeyX", shift: false }, + y: { code: "KeyY", shift: false }, + z: { code: "KeyZ", shift: false }, + + // ── Letters (uppercase = Shift) ───────────────────────────────── + A: { code: "KeyA", shift: true }, + B: { code: "KeyB", shift: true }, + C: { code: "KeyC", shift: true }, + D: { code: "KeyD", shift: true }, + E: { code: "KeyE", shift: true }, + F: { code: "KeyF", shift: true }, + G: { code: "KeyG", shift: true }, + H: { code: "KeyH", shift: true }, + I: { code: "KeyI", shift: true }, + J: { code: "KeyJ", shift: true }, + K: { code: "KeyK", shift: true }, + L: { code: "KeyL", shift: true }, + M: { code: "KeyM", shift: true }, + N: { code: "KeyN", shift: true }, + O: { code: "KeyO", shift: true }, + P: { code: "KeyP", shift: true }, + Q: { code: "KeyQ", shift: true }, + R: { code: "KeyR", shift: true }, + S: { code: "KeyS", shift: true }, + T: { code: "KeyT", shift: true }, + U: { code: "KeyU", shift: true }, + V: { code: "KeyV", shift: true }, + W: { code: "KeyW", shift: true }, + X: { code: "KeyX", shift: true }, + Y: { code: "KeyY", shift: true }, + Z: { code: "KeyZ", shift: true }, + + // ── Digits (unshifted) ────────────────────────────────────────── + "1": { code: "Digit1", shift: false }, + "2": { code: "Digit2", shift: false }, + "3": { code: "Digit3", shift: false }, + "4": { code: "Digit4", shift: false }, + "5": { code: "Digit5", shift: false }, + "6": { code: "Digit6", shift: false }, + "7": { code: "Digit7", shift: false }, + "8": { code: "Digit8", shift: false }, + "9": { code: "Digit9", shift: false }, + "0": { code: "Digit0", shift: false }, + + // ── Shifted digit row ─────────────────────────────────────────── + "!": { code: "Digit1", shift: true }, + "@": { code: "Digit2", shift: true }, + "#": { code: "Digit3", shift: true }, + "$": { code: "Digit4", shift: true }, + "%": { code: "Digit5", shift: true }, + "^": { code: "Digit6", shift: true }, + "&": { code: "Digit7", shift: true }, + "*": { code: "Digit8", shift: true }, + "(": { code: "Digit9", shift: true }, + ")": { code: "Digit0", shift: true }, + + // ── Punctuation (unshifted) ───────────────────────────────────── + "-": { code: "Minus", shift: false }, + "=": { code: "Equal", shift: false }, + "[": { code: "BracketLeft", shift: false }, + "]": { code: "BracketRight", shift: false }, + "\\": { code: "Backslash", shift: false }, + ";": { code: "Semicolon", shift: false }, + "'": { code: "Quote", shift: false }, + "`": { code: "Backquote", shift: false }, + ",": { code: "Comma", shift: false }, + ".": { code: "Period", shift: false }, + "/": { code: "Slash", shift: false }, + + // ── Punctuation (shifted) ─────────────────────────────────────── + "_": { code: "Minus", shift: true }, + "+": { code: "Equal", shift: true }, + "{": { code: "BracketLeft", shift: true }, + "}": { code: "BracketRight", shift: true }, + "|": { code: "Backslash", shift: true }, + ":": { code: "Semicolon", shift: true }, + "\"": { code: "Quote", shift: true }, + "~": { code: "Backquote", shift: true }, + "<": { code: "Comma", shift: true }, + ">": { code: "Period", shift: true }, + "?": { code: "Slash", shift: true }, + + // ── Space ─────────────────────────────────────────────────────── + " ": { code: "Space", shift: false }, +}; + +/** + * Look up how to produce a given character on a US-QWERTY keyboard. + * + * @param char The printable character (event.key when event.key.length === 1) + * @returns The US-QWERTY code + shift requirement, or null if unknown. + */ +export function charToUsKeystroke(char: string): UsKeystroke | null { + return US_CHAR_MAP[char] ?? null; +} diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index de9d49b9..5bf9a542 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -20,6 +20,7 @@ import { type GamepadInput, } from "./inputProtocol"; import { remapVkForLayout } from "./keyboardLayout"; +import { charToUsKeystroke } from "./keyboardCharMap"; import { buildNvstSdp, extractIceCredentials, @@ -486,6 +487,8 @@ export class GfnWebRtcClient { // Track currently pressed keys (VK codes) for synthetic Escape detection private pressedKeys: Set = new Set(); + // Keys handled via character-level translation — skip their keyup in the normal path + private charTranslatedCodes: Set = new Set(); // Video element reference for pointer lock re-acquisition private videoElement: HTMLVideoElement | null = null; // Timer for synthetic Escape on pointer lock loss @@ -1931,7 +1934,44 @@ export class GfnWebRtcClient { return; } - // Remap VK code for non-QWERTY layouts. Scancodes stay physical. + // ── Character-level translation for non-QWERTY layouts ────────── + // When the local layout isn't QWERTY, the physical scancode maps to + // the wrong character on the US-QWERTY remote VM. For printable + // characters we look up which US key + modifiers produce that char + // and send those instead. Ctrl/Meta combos (shortcuts) bypass this + // so Ctrl+C / Ctrl+V etc. stay scancode-based and work correctly. + if ( + this.effectiveKeyboardLayout !== "qwerty" + && event.key.length === 1 + && !event.ctrlKey + && !event.metaKey + ) { + const usKey = charToUsKeystroke(event.key); + if (usKey) { + const usMapped = mapKeyboardEvent({ code: usKey.code } as KeyboardEvent); + if (usMapped) { + // Build modifier flags: carry over Alt from the real event (AltGr + // may be active on the local side), then set/clear Shift to match + // what the US layout requires for this character. + let mods = 0; + if (usKey.shift) mods |= 0x01; + if (event.altKey && !usKey.shift) mods |= 0x04; + if (event.getModifierState("CapsLock")) mods |= 0x10; + if (event.getModifierState("NumLock")) mods |= 0x20; + + // Send the key using the US-QWERTY VK + scancode. + // We send both keydown and keyup immediately so the remote side + // sees a clean press/release for this character. The real keyup + // will be ignored via charTranslatedCodes. + this.sendKeyPacket(usMapped.vk, usMapped.scancode, mods, true); + this.sendKeyPacket(usMapped.vk, usMapped.scancode, mods, false); + this.charTranslatedCodes.add(event.code); + return; + } + } + } + + // ── Default path: physical scancode + VK remap ────────────────── const remappedVk = remapVkForLayout(mapped.vk, this.effectiveKeyboardLayout); const payload = this.inputEncoder.encodeKeyDown({ @@ -1950,6 +1990,13 @@ export class GfnWebRtcClient { return; } + // If this key was handled via character-level translation on keydown, + // we already sent both keydown+keyup there — skip the real keyup. + if (this.charTranslatedCodes.delete(event.code)) { + event.preventDefault(); + return; + } + const isEscapeEvent = event.key === "Escape" || event.key === "Esc" From 28b711f450c40b9dd67dd3a2e920304823b48006 Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:11:40 +0000 Subject: [PATCH 4/7] Fix: Preserve hold behavior for character-translated non-QWERTY keys --- .../src/renderer/src/gfn/webrtcClient.ts | 94 ++++++++++++++----- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 5bf9a542..e743716c 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -487,8 +487,14 @@ export class GfnWebRtcClient { // Track currently pressed keys (VK codes) for synthetic Escape detection private pressedKeys: Set = new Set(); - // Keys handled via character-level translation — skip their keyup in the normal path - private charTranslatedCodes: Set = new Set(); + // Keys handled via character-level translation — track held state for proper keydown/keyup + private heldTranslatedKeys = new Map(); // Video element reference for pointer lock re-acquisition private videoElement: HTMLVideoElement | null = null; // Timer for synthetic Escape on pointer lock loss @@ -1638,12 +1644,14 @@ export class GfnWebRtcClient { private releasePressedKeys(reason: string): void { this.clearEscapeAutoKeyUpTimer(); - if (this.pressedKeys.size === 0 || !this.inputReady) { + const totalKeys = this.pressedKeys.size + this.heldTranslatedKeys.size; + if (totalKeys === 0 || !this.inputReady) { this.pressedKeys.clear(); + this.heldTranslatedKeys.clear(); return; } - this.log(`Releasing ${this.pressedKeys.size} key(s): ${reason}`); + this.log(`Releasing ${totalKeys} key(s): ${reason}`); for (const vk of this.pressedKeys) { const payload = this.inputEncoder.encodeKeyUp({ keycode: vk, @@ -1654,6 +1662,18 @@ export class GfnWebRtcClient { this.sendReliable(payload); } this.pressedKeys.clear(); + + for (const [, held] of this.heldTranslatedKeys) { + this.sendKeyPacket(held.vk, held.scancode, 0, false); + if (held.pressedAltGr) { + this.sendKeyPacket(0xa4, 0xe2, 0, false); + this.sendKeyPacket(0xa2, 0xe0, 0, false); + } + if (held.pressedShift) { + this.sendKeyPacket(0xa0, 0xe1, 0, false); + } + } + this.heldTranslatedKeys.clear(); } private sendKeyPacket(vk: number, scancode: number, modifiers: number, isDown: boolean): void { @@ -1946,26 +1966,46 @@ export class GfnWebRtcClient { && !event.ctrlKey && !event.metaKey ) { + if (this.heldTranslatedKeys.has(event.code)) { + return; + } const usKey = charToUsKeystroke(event.key); if (usKey) { const usMapped = mapKeyboardEvent({ code: usKey.code } as KeyboardEvent); if (usMapped) { - // Build modifier flags: carry over Alt from the real event (AltGr - // may be active on the local side), then set/clear Shift to match - // what the US layout requires for this character. - let mods = 0; - if (usKey.shift) mods |= 0x01; - if (event.altKey && !usKey.shift) mods |= 0x04; - if (event.getModifierState("CapsLock")) mods |= 0x10; - if (event.getModifierState("NumLock")) mods |= 0x20; - - // Send the key using the US-QWERTY VK + scancode. - // We send both keydown and keyup immediately so the remote side - // sees a clean press/release for this character. The real keyup - // will be ignored via charTranslatedCodes. + const needShift = usKey.shift; + const needAltGr = false; + const pressedShift = needShift && !event.shiftKey; + const pressedAltGr = needAltGr + && !event.getModifierState?.("AltGraph") + && !(event.ctrlKey && event.altKey); + + const lockMods = + (event.getModifierState("CapsLock") ? 0x10 : 0) + | (event.getModifierState("NumLock") ? 0x20 : 0); + + if (pressedShift) { + this.sendKeyPacket(0xa0, 0xe1, lockMods, true); + } + if (pressedAltGr) { + this.sendKeyPacket(0xa2, 0xe0, lockMods, true); + this.sendKeyPacket(0xa4, 0xe2, lockMods | 0x02, true); + } + + let mods = lockMods; + if (needShift) mods |= 0x01; + if (pressedAltGr) mods |= 0x02 | 0x04; + this.sendKeyPacket(usMapped.vk, usMapped.scancode, mods, true); - this.sendKeyPacket(usMapped.vk, usMapped.scancode, mods, false); - this.charTranslatedCodes.add(event.code); + + this.pressedKeys.delete(mapped.vk); + this.heldTranslatedKeys.set(event.code, { + vk: usMapped.vk, + scancode: usMapped.scancode, + mods, + pressedShift, + pressedAltGr, + }); return; } } @@ -1990,9 +2030,19 @@ export class GfnWebRtcClient { return; } - // If this key was handled via character-level translation on keydown, - // we already sent both keydown+keyup there — skip the real keyup. - if (this.charTranslatedCodes.delete(event.code)) { + const heldEntry = this.heldTranslatedKeys.get(event.code); + if (heldEntry) { + this.sendKeyPacket(heldEntry.vk, heldEntry.scancode, heldEntry.mods, false); + if (heldEntry.pressedAltGr) { + const lm = heldEntry.mods & 0x30; + this.sendKeyPacket(0xa4, 0xe2, lm | 0x02, false); + this.sendKeyPacket(0xa2, 0xe0, lm, false); + } + if (heldEntry.pressedShift) { + const lm = heldEntry.mods & 0x30; + this.sendKeyPacket(0xa0, 0xe1, lm, false); + } + this.heldTranslatedKeys.delete(event.code); event.preventDefault(); return; } From 8feb2908d45b56392a4435219b8f264ecbe513ae Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:34:40 +0000 Subject: [PATCH 5/7] Enable hold behavior for translated printable keys on non-QWERTY layouts. --- opennow-stable/src/renderer/src/gfn/webrtcClient.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index e743716c..138f0657 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -2006,6 +2006,7 @@ export class GfnWebRtcClient { pressedShift, pressedAltGr, }); + event.stopPropagation(); return; } } @@ -2044,6 +2045,7 @@ export class GfnWebRtcClient { } this.heldTranslatedKeys.delete(event.code); event.preventDefault(); + event.stopPropagation(); return; } From edc0dfe4c5bfbd5ca8b73aa504d1afa6ce4e6c1a Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:33:51 +0000 Subject: [PATCH 6/7] Revert keyboard input to raw scancode/VK path by removing layout translation. --- opennow-stable/src/main/settings.ts | 5 +- opennow-stable/src/renderer/src/App.tsx | 29 +-- .../renderer/src/components/SettingsPage.tsx | 6 +- .../renderer/src/components/StreamView.tsx | 3 +- .../src/renderer/src/gfn/keyboardCharMap.ts | 148 --------------- .../src/renderer/src/gfn/keyboardLayout.ts | 171 ------------------ .../src/renderer/src/gfn/webrtcClient.ts | 131 ++------------ opennow-stable/src/shared/gfn.ts | 3 +- 8 files changed, 23 insertions(+), 473 deletions(-) delete mode 100644 opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts delete mode 100644 opennow-stable/src/renderer/src/gfn/keyboardLayout.ts diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index ad1c1250..5e841f7c 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -3,7 +3,7 @@ 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, HdrStreamingMode, MicMode, HevcCompatMode, VideoDecodeBackend, KeyboardLayout } from "@shared/gfn"; -import { defaultFlightSlots } from "@shared/gfn"; +import type { VideoCodec, ColorQuality, VideoAccelerationPreference, FlightSlotConfig, HdrStreamingMode, MicMode, HevcCompatMode, VideoDecodeBackend } from "@shared/gfn";import { defaultFlightSlots } from "@shared/gfn"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ resolution: string; @@ -86,6 +86,7 @@ export interface Settings { /** Keyboard layout: auto, qwerty, azerty, qwertz */ keyboardLayout: KeyboardLayout;} +} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; const defaultMicShortcut = "Ctrl+Shift+M"; @@ -116,7 +117,7 @@ const DEFAULT_SETTINGS: Settings = { windowWidth: 1400, windowHeight: 900, keyboardLayout: "auto",}; - +}; export class SettingsManager { private settings: Settings; private readonly settingsPath: string; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index cab2a068..15976740 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -26,12 +26,6 @@ import { MicAudioService } from "./gfn/micAudioService"; import type { MicAudioState } from "./gfn/micAudioService"; import { formatShortcutForDisplay, isShortcutMatch, normalizeShortcut } from "./shortcuts"; import { getFlightHidService } from "./flight/FlightHidService"; -import { - detectKeyboardLayout, - resolveEffectiveLayout, - getCachedDetection, - type DetectedLayout, -} from "./gfn/keyboardLayout"; import { probeHdrCapability, shouldEnableHdr, @@ -139,7 +133,7 @@ function defaultDiagnostics(): StreamDiagnostics { micEnabled: false, keyboardLayout: "qwerty", detectedKeyboardLayout: "unknown", }; -} + };} function isSessionLimitError(error: unknown): boolean { if (error && typeof error === "object" && "gfnErrorCode" in error) { @@ -302,7 +296,7 @@ export function App(): JSX.Element { windowWidth: 1400, windowHeight: 900, keyboardLayout: "auto", }); - const [settingsLoaded, setSettingsLoaded] = useState(false); + }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -353,8 +347,6 @@ export function App(): JSX.Element { onBackAction: handleControllerBackAction, }); - // Keyboard layout detection state - const [detectedLayout, setDetectedLayout] = useState("unknown"); // Mic state for StreamView indicator const [micAudioState, setMicAudioState] = useState(null); @@ -742,7 +734,7 @@ export function App(): JSX.Element { ); clientRef.current.setKeyboardLayout(effective); clientRef.current.setDetectedKeyboardLayout(cached?.detected ?? "unknown"); } - + } if (clientRef.current) { await clientRef.current.handleOffer(event.sdp, activeSession, { codec: settings.codec, @@ -1416,21 +1408,6 @@ export function App(): JSX.Element { }; }, []); - // Detect keyboard layout on mount and when setting changes - useEffect(() => { - void detectKeyboardLayout().then((result) => { - setDetectedLayout(result.detected); - }); - }, []); - - // Update client keyboard layout when setting or detection changes - useEffect(() => { - const effective = resolveEffectiveLayout(settings.keyboardLayout, detectedLayout); - if (clientRef.current) { - clientRef.current.setKeyboardLayout(effective); - clientRef.current.setDetectedKeyboardLayout(detectedLayout); - } - }, [settings.keyboardLayout, detectedLayout]); // Subscribe to mic audio state for StreamView indicator useEffect(() => { diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index b6c5fa6a..9dec9c03 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 { Monitor, Volume2, Mouse, Keyboard, Settings2, Globe, Save, Check, Search, X, Loader, Cpu, Zap, MessageSquare, Joystick, Sun, RefreshCw, RotateCcw, Mic, MicOff } 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, Sun, RefreshCw, RotateCcw, Mic, MicOff } from "lucide-react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react";import type { JSX } from "react"; import type { Settings, @@ -17,7 +18,7 @@ import type { PlatformInfo, VideoDecodeBackend, KeyboardLayout,} from "@shared/gfn"; -import { colorQualityRequiresHevc } from "@shared/gfn"; +} from "@shared/gfn";import { colorQualityRequiresHevc } from "@shared/gfn"; import { formatShortcutForDisplay, normalizeShortcut } from "../shortcuts"; interface SettingsPageProps { @@ -1220,7 +1221,6 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag
Auto uses the OS layout; override only if keys are wrong in the stream. -
diff --git a/opennow-stable/src/renderer/src/components/StreamView.tsx b/opennow-stable/src/renderer/src/components/StreamView.tsx index 7dccd6a7..de1bfb89 100644 --- a/opennow-stable/src/renderer/src/components/StreamView.tsx +++ b/opennow-stable/src/renderer/src/components/StreamView.tsx @@ -380,7 +380,8 @@ export function StreamView({ <> · detected {stats.detectedKeyboardLayout} )}
- )} + + )} {/* Controller indicator (top-left) */} {connectedControllers > 0 && !isConnecting && ( diff --git a/opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts b/opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts deleted file mode 100644 index 9ad5f791..00000000 --- a/opennow-stable/src/renderer/src/gfn/keyboardCharMap.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Character-level translation for non-QWERTY → US-QWERTY remote VM. - * - * When the local keyboard is AZERTY/QWERTZ (or any non-US layout) and the - * remote cloud VM runs US-QWERTY, pressing a key locally produces a character - * via event.key that may live on a completely different physical key on US - * layout. For example, AZERTY "!" is Shift+Digit8, but on US-QWERTY "!" is - * Shift+Digit1. - * - * This module maps every printable ASCII character to the US-QWERTY physical - * key (event.code) plus the modifiers needed to produce it. The caller sends - * the corresponding VK + scancode from the existing codeMap. - * - * Only used when effectiveLayout !== "qwerty". For QWERTY users the existing - * physical-scancode path is already correct. - */ - -export interface UsKeystroke { - /** KeyboardEvent.code on a US-QWERTY keyboard that produces this char */ - code: string; - /** Whether Shift must be held */ - shift: boolean; -} - -/** - * Full US-QWERTY printable-ASCII lookup. - * Key = the character (event.key), value = how to type it on US-QWERTY. - */ -const US_CHAR_MAP: Record = { - // ── Letters (lowercase) ───────────────────────────────────────── - a: { code: "KeyA", shift: false }, - b: { code: "KeyB", shift: false }, - c: { code: "KeyC", shift: false }, - d: { code: "KeyD", shift: false }, - e: { code: "KeyE", shift: false }, - f: { code: "KeyF", shift: false }, - g: { code: "KeyG", shift: false }, - h: { code: "KeyH", shift: false }, - i: { code: "KeyI", shift: false }, - j: { code: "KeyJ", shift: false }, - k: { code: "KeyK", shift: false }, - l: { code: "KeyL", shift: false }, - m: { code: "KeyM", shift: false }, - n: { code: "KeyN", shift: false }, - o: { code: "KeyO", shift: false }, - p: { code: "KeyP", shift: false }, - q: { code: "KeyQ", shift: false }, - r: { code: "KeyR", shift: false }, - s: { code: "KeyS", shift: false }, - t: { code: "KeyT", shift: false }, - u: { code: "KeyU", shift: false }, - v: { code: "KeyV", shift: false }, - w: { code: "KeyW", shift: false }, - x: { code: "KeyX", shift: false }, - y: { code: "KeyY", shift: false }, - z: { code: "KeyZ", shift: false }, - - // ── Letters (uppercase = Shift) ───────────────────────────────── - A: { code: "KeyA", shift: true }, - B: { code: "KeyB", shift: true }, - C: { code: "KeyC", shift: true }, - D: { code: "KeyD", shift: true }, - E: { code: "KeyE", shift: true }, - F: { code: "KeyF", shift: true }, - G: { code: "KeyG", shift: true }, - H: { code: "KeyH", shift: true }, - I: { code: "KeyI", shift: true }, - J: { code: "KeyJ", shift: true }, - K: { code: "KeyK", shift: true }, - L: { code: "KeyL", shift: true }, - M: { code: "KeyM", shift: true }, - N: { code: "KeyN", shift: true }, - O: { code: "KeyO", shift: true }, - P: { code: "KeyP", shift: true }, - Q: { code: "KeyQ", shift: true }, - R: { code: "KeyR", shift: true }, - S: { code: "KeyS", shift: true }, - T: { code: "KeyT", shift: true }, - U: { code: "KeyU", shift: true }, - V: { code: "KeyV", shift: true }, - W: { code: "KeyW", shift: true }, - X: { code: "KeyX", shift: true }, - Y: { code: "KeyY", shift: true }, - Z: { code: "KeyZ", shift: true }, - - // ── Digits (unshifted) ────────────────────────────────────────── - "1": { code: "Digit1", shift: false }, - "2": { code: "Digit2", shift: false }, - "3": { code: "Digit3", shift: false }, - "4": { code: "Digit4", shift: false }, - "5": { code: "Digit5", shift: false }, - "6": { code: "Digit6", shift: false }, - "7": { code: "Digit7", shift: false }, - "8": { code: "Digit8", shift: false }, - "9": { code: "Digit9", shift: false }, - "0": { code: "Digit0", shift: false }, - - // ── Shifted digit row ─────────────────────────────────────────── - "!": { code: "Digit1", shift: true }, - "@": { code: "Digit2", shift: true }, - "#": { code: "Digit3", shift: true }, - "$": { code: "Digit4", shift: true }, - "%": { code: "Digit5", shift: true }, - "^": { code: "Digit6", shift: true }, - "&": { code: "Digit7", shift: true }, - "*": { code: "Digit8", shift: true }, - "(": { code: "Digit9", shift: true }, - ")": { code: "Digit0", shift: true }, - - // ── Punctuation (unshifted) ───────────────────────────────────── - "-": { code: "Minus", shift: false }, - "=": { code: "Equal", shift: false }, - "[": { code: "BracketLeft", shift: false }, - "]": { code: "BracketRight", shift: false }, - "\\": { code: "Backslash", shift: false }, - ";": { code: "Semicolon", shift: false }, - "'": { code: "Quote", shift: false }, - "`": { code: "Backquote", shift: false }, - ",": { code: "Comma", shift: false }, - ".": { code: "Period", shift: false }, - "/": { code: "Slash", shift: false }, - - // ── Punctuation (shifted) ─────────────────────────────────────── - "_": { code: "Minus", shift: true }, - "+": { code: "Equal", shift: true }, - "{": { code: "BracketLeft", shift: true }, - "}": { code: "BracketRight", shift: true }, - "|": { code: "Backslash", shift: true }, - ":": { code: "Semicolon", shift: true }, - "\"": { code: "Quote", shift: true }, - "~": { code: "Backquote", shift: true }, - "<": { code: "Comma", shift: true }, - ">": { code: "Period", shift: true }, - "?": { code: "Slash", shift: true }, - - // ── Space ─────────────────────────────────────────────────────── - " ": { code: "Space", shift: false }, -}; - -/** - * Look up how to produce a given character on a US-QWERTY keyboard. - * - * @param char The printable character (event.key when event.key.length === 1) - * @returns The US-QWERTY code + shift requirement, or null if unknown. - */ -export function charToUsKeystroke(char: string): UsKeystroke | null { - return US_CHAR_MAP[char] ?? null; -} diff --git a/opennow-stable/src/renderer/src/gfn/keyboardLayout.ts b/opennow-stable/src/renderer/src/gfn/keyboardLayout.ts deleted file mode 100644 index a881d428..00000000 --- a/opennow-stable/src/renderer/src/gfn/keyboardLayout.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Keyboard layout detection and mapping for cloud gaming. - * - * Cloud gaming sends physical scancodes (USB HID) to the remote machine. - * The remote OS applies its own keyboard layout to interpret them. - * Therefore, the correct default behavior ("auto") is to always send the - * physical scancode derived from KeyboardEvent.code — this means the - * remote OS layout determines what character is produced, which is correct - * for most users (their remote VM layout matches their physical keyboard). - * - * The VK (virtual key) code sent alongside the scancode is used by some - * games and Windows APIs. In "auto" mode we send the QWERTY-based VK - * that matches the physical key position (same as current behavior). - * - * Override modes ("azerty", "qwertz") remap the VK code so that the - * remote side receives a VK matching the character the user expects - * from that physical key position on their layout. This can help when - * the remote OS layout is QWERTY but the user has a non-QWERTY keyboard - * and wants character-correct input without changing remote OS settings. - * - * Scancodes are NEVER remapped — they are always physical-position-based. - */ - -import type { KeyboardLayout } from "@shared/gfn"; - -export type DetectedLayout = "qwerty" | "azerty" | "qwertz" | "unknown"; - -interface LayoutDetectionResult { - detected: DetectedLayout; - method: "keyboard-api" | "language-heuristic" | "none"; - confidence: "high" | "medium" | "low"; -} - -let cachedDetection: LayoutDetectionResult | null = null; - -/** - * Detect the OS keyboard layout using the best available method. - * Result is cached after first successful detection. - */ -export async function detectKeyboardLayout(): Promise { - if (cachedDetection) return cachedDetection; - - // Method 1: navigator.keyboard.getLayoutMap() (Chromium 69+) - // This is the most reliable method — it queries the OS for the actual - // character each physical key produces. - const nav = navigator as Navigator & { - keyboard?: { getLayoutMap?: () => Promise> }; - }; - - if (nav.keyboard?.getLayoutMap) { - try { - const layoutMap = await nav.keyboard.getLayoutMap(); - const keyA = layoutMap.get("KeyA"); // QWERTY: 'a', AZERTY: 'q' - const keyQ = layoutMap.get("KeyQ"); // QWERTY: 'q', AZERTY: 'a' - const keyW = layoutMap.get("KeyW"); // QWERTY: 'w', AZERTY: 'z' - const keyZ = layoutMap.get("KeyZ"); // QWERTY: 'z', QWERTZ: 'y' - const keyY = layoutMap.get("KeyY"); // QWERTY: 'y', QWERTZ: 'z' - - // AZERTY: physical KeyA produces 'q', physical KeyQ produces 'a' - if (keyA === "q" && keyQ === "a") { - cachedDetection = { detected: "azerty", method: "keyboard-api", confidence: "high" }; - return cachedDetection; - } - - // QWERTZ: physical KeyZ produces 'y', physical KeyY produces 'z' - if (keyZ === "y" && keyY === "z") { - cachedDetection = { detected: "qwertz", method: "keyboard-api", confidence: "high" }; - return cachedDetection; - } - - // If KeyA='a' and KeyQ='q' and KeyW='w', it's QWERTY - if (keyA === "a" && keyQ === "q" && keyW === "w") { - cachedDetection = { detected: "qwerty", method: "keyboard-api", confidence: "high" }; - return cachedDetection; - } - - // Layout detected but doesn't match known patterns - cachedDetection = { detected: "unknown", method: "keyboard-api", confidence: "medium" }; - return cachedDetection; - } catch { - // getLayoutMap() failed — fall through to heuristic - } - } - - // Method 2: Language-based heuristic (less reliable, best-effort) - // navigator.language gives the browser UI language which often correlates - // with keyboard layout, but not always (e.g., French user with QWERTY). - const lang = (navigator.language || "").toLowerCase(); - const primary = lang.split("-")[0]; - - if (primary === "fr") { - cachedDetection = { detected: "azerty", method: "language-heuristic", confidence: "low" }; - return cachedDetection; - } - if (primary === "de" || primary === "cs" || primary === "sk" || primary === "hu") { - cachedDetection = { detected: "qwertz", method: "language-heuristic", confidence: "low" }; - return cachedDetection; - } - - // Default: assume QWERTY (most common worldwide) - cachedDetection = { detected: "qwerty", method: "language-heuristic", confidence: "low" }; - return cachedDetection; -} - -/** Clear cached detection (useful if user changes OS layout mid-session). */ -export function resetLayoutDetectionCache(): void { - cachedDetection = null; -} - -/** Get the last cached detection result without re-detecting. */ -export function getCachedDetection(): LayoutDetectionResult | null { - return cachedDetection; -} - -/** - * Resolve effective layout: if setting is "auto", use detected layout; - * otherwise use the explicit override. - */ -export function resolveEffectiveLayout( - setting: KeyboardLayout, - detected: DetectedLayout, -): "qwerty" | "azerty" | "qwertz" { - if (setting !== "auto") return setting; - // For "auto", use detected layout. If unknown, default to qwerty - // (physical scancodes will still be correct). - return detected === "unknown" ? "qwerty" : detected; -} - -// ── VK remapping tables ────────────────────────────────────────────── -// -// These tables remap Windows Virtual Key codes for non-QWERTY layouts. -// The key is the QWERTY VK code (what codeMap currently produces for a -// physical key position), the value is the VK code that corresponds to -// the character that layout produces at that position. -// -// Only keys that differ between layouts are listed. -// Scancodes are never touched — only VK codes change. - -// AZERTY: maps physical positions to the VK of the character AZERTY -// produces there. E.g., physical KeyA (QWERTY VK=0x41 'A') on AZERTY -// produces 'Q', so we remap 0x41 → 0x51. -const AZERTY_VK_REMAP: Record = { - 0x41: 0x51, // KeyA: A→Q - 0x51: 0x41, // KeyQ: Q→A - 0x57: 0x5A, // KeyW: W→Z - 0x5A: 0x57, // KeyZ: Z→W - 0x4D: 0xBC, // KeyM: M→comma (VK_OEM_COMMA) -}; - -// QWERTZ: Z/Y swap -const QWERTZ_VK_REMAP: Record = { - 0x5A: 0x59, // KeyZ: Z→Y - 0x59: 0x5A, // KeyY: Y→Z -}; - -/** - * Remap a VK code based on the effective keyboard layout. - * Returns the original VK if no remapping is needed (qwerty or unmapped key). - */ -export function remapVkForLayout( - vk: number, - effectiveLayout: "qwerty" | "azerty" | "qwertz", -): number { - if (effectiveLayout === "azerty") { - return AZERTY_VK_REMAP[vk] ?? vk; - } - if (effectiveLayout === "qwertz") { - return QWERTZ_VK_REMAP[vk] ?? vk; - } - return vk; -} diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 138f0657..1104dfca 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -19,8 +19,7 @@ import { GAMEPAD_MAX_CONTROLLERS, type GamepadInput, } from "./inputProtocol"; -import { remapVkForLayout } from "./keyboardLayout"; -import { charToUsKeystroke } from "./keyboardCharMap"; + import { buildNvstSdp, extractIceCredentials, @@ -175,6 +174,7 @@ export interface StreamDiagnostics { keyboardLayout: string; detectedKeyboardLayout: string;} +} export interface StreamTimeWarning { code: 1 | 2 | 3; secondsLeft?: number; @@ -487,14 +487,7 @@ export class GfnWebRtcClient { // Track currently pressed keys (VK codes) for synthetic Escape detection private pressedKeys: Set = new Set(); - // Keys handled via character-level translation — track held state for proper keydown/keyup - private heldTranslatedKeys = new Map(); + // Video element reference for pointer lock re-acquisition private videoElement: HTMLVideoElement | null = null; // Timer for synthetic Escape on pointer lock loss @@ -516,8 +509,7 @@ export class GfnWebRtcClient { private pendingMouseTimestampUs: bigint | null = null; private mouseDeltaFilter = new MouseDeltaFilter(); - // Effective keyboard layout for VK remapping (default: qwerty = no remap) - private effectiveKeyboardLayout: "qwerty" | "azerty" | "qwertz" = "qwerty"; + private partialReliableThresholdMs = GfnWebRtcClient.DEFAULT_PARTIAL_RELIABLE_THRESHOLD_MS; private inputQueuePeakBufferedBytesWindow = 0; @@ -569,6 +561,7 @@ export class GfnWebRtcClient { keyboardLayout: "qwerty", detectedKeyboardLayout: "unknown", }; + }; constructor(private readonly options: ClientOptions) { options.videoElement.srcObject = this.videoStream; options.audioElement.srcObject = this.audioStream; @@ -705,7 +698,8 @@ export class GfnWebRtcClient { micEnabled: this.micManager?.isEnabled() ?? false, keyboardLayout: this.effectiveKeyboardLayout, detectedKeyboardLayout: this.diagnostics.detectedKeyboardLayout, }; - this.emitStats(); + + }; this.emitStats(); } private resetInputState(): void { @@ -1644,14 +1638,12 @@ export class GfnWebRtcClient { private releasePressedKeys(reason: string): void { this.clearEscapeAutoKeyUpTimer(); - const totalKeys = this.pressedKeys.size + this.heldTranslatedKeys.size; - if (totalKeys === 0 || !this.inputReady) { + if (this.pressedKeys.size === 0 || !this.inputReady) { this.pressedKeys.clear(); - this.heldTranslatedKeys.clear(); return; } - this.log(`Releasing ${totalKeys} key(s): ${reason}`); + this.log(`Releasing ${this.pressedKeys.size} key(s): ${reason}`); for (const vk of this.pressedKeys) { const payload = this.inputEncoder.encodeKeyUp({ keycode: vk, @@ -1662,18 +1654,6 @@ export class GfnWebRtcClient { this.sendReliable(payload); } this.pressedKeys.clear(); - - for (const [, held] of this.heldTranslatedKeys) { - this.sendKeyPacket(held.vk, held.scancode, 0, false); - if (held.pressedAltGr) { - this.sendKeyPacket(0xa4, 0xe2, 0, false); - this.sendKeyPacket(0xa2, 0xe0, 0, false); - } - if (held.pressedShift) { - this.sendKeyPacket(0xa0, 0xe1, 0, false); - } - } - this.heldTranslatedKeys.clear(); } private sendKeyPacket(vk: number, scancode: number, modifiers: number, isDown: boolean): void { @@ -1954,69 +1934,8 @@ export class GfnWebRtcClient { return; } - // ── Character-level translation for non-QWERTY layouts ────────── - // When the local layout isn't QWERTY, the physical scancode maps to - // the wrong character on the US-QWERTY remote VM. For printable - // characters we look up which US key + modifiers produce that char - // and send those instead. Ctrl/Meta combos (shortcuts) bypass this - // so Ctrl+C / Ctrl+V etc. stay scancode-based and work correctly. - if ( - this.effectiveKeyboardLayout !== "qwerty" - && event.key.length === 1 - && !event.ctrlKey - && !event.metaKey - ) { - if (this.heldTranslatedKeys.has(event.code)) { - return; - } - const usKey = charToUsKeystroke(event.key); - if (usKey) { - const usMapped = mapKeyboardEvent({ code: usKey.code } as KeyboardEvent); - if (usMapped) { - const needShift = usKey.shift; - const needAltGr = false; - const pressedShift = needShift && !event.shiftKey; - const pressedAltGr = needAltGr - && !event.getModifierState?.("AltGraph") - && !(event.ctrlKey && event.altKey); - - const lockMods = - (event.getModifierState("CapsLock") ? 0x10 : 0) - | (event.getModifierState("NumLock") ? 0x20 : 0); - - if (pressedShift) { - this.sendKeyPacket(0xa0, 0xe1, lockMods, true); - } - if (pressedAltGr) { - this.sendKeyPacket(0xa2, 0xe0, lockMods, true); - this.sendKeyPacket(0xa4, 0xe2, lockMods | 0x02, true); - } - - let mods = lockMods; - if (needShift) mods |= 0x01; - if (pressedAltGr) mods |= 0x02 | 0x04; - - this.sendKeyPacket(usMapped.vk, usMapped.scancode, mods, true); - - this.pressedKeys.delete(mapped.vk); - this.heldTranslatedKeys.set(event.code, { - vk: usMapped.vk, - scancode: usMapped.scancode, - mods, - pressedShift, - pressedAltGr, - }); - event.stopPropagation(); - return; - } - } - } - - // ── Default path: physical scancode + VK remap ────────────────── - const remappedVk = remapVkForLayout(mapped.vk, this.effectiveKeyboardLayout); - const payload = this.inputEncoder.encodeKeyDown({ - keycode: remappedVk, + keycode: mapped.vk, scancode: mapped.scancode, modifiers: modifierFlags(event), // Use a fresh monotonic timestamp for keyboard events. In some @@ -2031,24 +1950,6 @@ export class GfnWebRtcClient { return; } - const heldEntry = this.heldTranslatedKeys.get(event.code); - if (heldEntry) { - this.sendKeyPacket(heldEntry.vk, heldEntry.scancode, heldEntry.mods, false); - if (heldEntry.pressedAltGr) { - const lm = heldEntry.mods & 0x30; - this.sendKeyPacket(0xa4, 0xe2, lm | 0x02, false); - this.sendKeyPacket(0xa2, 0xe0, lm, false); - } - if (heldEntry.pressedShift) { - const lm = heldEntry.mods & 0x30; - this.sendKeyPacket(0xa0, 0xe1, lm, false); - } - this.heldTranslatedKeys.delete(event.code); - event.preventDefault(); - event.stopPropagation(); - return; - } - const isEscapeEvent = event.key === "Escape" || event.key === "Esc" @@ -2078,9 +1979,8 @@ export class GfnWebRtcClient { return; } this.pressedKeys.delete(mapped.vk); - const remappedVkUp = remapVkForLayout(mapped.vk, this.effectiveKeyboardLayout); const payload = this.inputEncoder.encodeKeyUp({ - keycode: remappedVkUp, + keycode: mapped.vk, scancode: mapped.scancode, modifiers: modifierFlags(event), timestampUs: timestampUs(), @@ -2527,16 +2427,7 @@ export class GfnWebRtcClient { } } - /** Update the effective keyboard layout for VK code remapping. */ - setKeyboardLayout(layout: "qwerty" | "azerty" | "qwertz"): void { - this.effectiveKeyboardLayout = layout; - this.diagnostics.keyboardLayout = layout; - } - /** Update the detected keyboard layout (for diagnostics display). */ - setDetectedKeyboardLayout(detected: string): void { - this.diagnostics.detectedKeyboardLayout = detected; - } setMicService(service: MicAudioService | null): void { this.micService = service; diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index 579f935c..b5a71c8e 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -9,7 +9,6 @@ export type HevcCompatMode = "auto" | "force_h264" | "force_hevc" | "hevc_softwa export type VideoDecodeBackend = "auto" | "vaapi" | "v4l2" | "software"; -export type KeyboardLayout = "auto" | "qwerty" | "azerty" | "qwertz"; export interface PlatformInfo { platform: string; @@ -106,7 +105,7 @@ export interface Settings { windowWidth: number; windowHeight: number; keyboardLayout: KeyboardLayout;} - +} export interface LoginProvider { idpId: string; code: string; From 62ef5cae02ea5ffb93a603531bc472857ac415e2 Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:46:51 +0000 Subject: [PATCH 7/7] Enable manual start/stop for mic test meter to reduce resource usage and enhance feedback. --- .../renderer/src/components/SettingsPage.tsx | 155 +++++++++++++++++- opennow-stable/src/renderer/src/styles.css | 46 ++++++ 2 files changed, 195 insertions(+), 6 deletions(-) diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 9dec9c03..b6fa23ea 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -493,6 +493,130 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag const [toggleAntiAfkError, setToggleAntiAfkError] = useState(false); const [toggleMicrophoneError, setToggleMicrophoneError] = useState(false); + const [micDevices, setMicDevices] = useState([]); + const [micDevicesLoading, setMicDevicesLoading] = useState(false); + const [micToggleShortcutInput, setMicToggleShortcutInput] = useState(settings.shortcutToggleMic); + const [micToggleShortcutError, setMicToggleShortcutError] = useState(false); + const micLevelRef = useRef(null); + const [micTestLevel, setMicTestLevel] = useState(0); + const [micTestRunning, setMicTestRunning] = useState(false); + const [micTestError, setMicTestError] = useState(null); + const micTestStreamRef = useRef(null); + const micTestContextRef = useRef(null); + const micTestTimerRef = useRef | null>(null); + const micTestAnalyserRef = useRef(null); + const micTestBufferRef = useRef | null>(null); + + const refreshMicDevices = useCallback(async () => { + setMicDevicesLoading(true); + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter((d) => d.kind === "audioinput" && d.deviceId !== ""); + const defaultDev = audioInputs.find((d) => d.deviceId === "default"); + const defaultGroupId = defaultDev?.groupId; + const mapped: MicDeviceInfo[] = audioInputs.map((d) => ({ + deviceId: d.deviceId, + label: d.label || `Microphone (${d.deviceId.slice(0, 8)})`, + isDefault: d.deviceId === "default" || (!!defaultGroupId && d.groupId === defaultGroupId && d.deviceId !== "default"), + })); + setMicDevices(mapped); + } catch { + setMicDevices([]); + } finally { + setMicDevicesLoading(false); + } + }, []); + + useEffect(() => { + void refreshMicDevices(); + const handler = (): void => { void refreshMicDevices(); }; + navigator.mediaDevices.addEventListener("devicechange", handler); + return () => navigator.mediaDevices.removeEventListener("devicechange", handler); + }, [refreshMicDevices]); + + useEffect(() => { + setMicToggleShortcutInput(settings.shortcutToggleMic); + }, [settings.shortcutToggleMic]); + + const startMicTest = useCallback(async () => { + setMicTestError(null); + try { + const constraints: MediaStreamConstraints = { + audio: { + deviceId: settings.micDeviceId ? { exact: settings.micDeviceId } : undefined, + noiseSuppression: { ideal: settings.micNoiseSuppression }, + autoGainControl: { ideal: settings.micAutoGainControl }, + echoCancellation: { ideal: settings.micEchoCancellation }, + }, + video: false, + }; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + micTestStreamRef.current = stream; + + const ctx = new AudioContext({ sampleRate: 48000, latencyHint: "interactive" }); + micTestContextRef.current = ctx; + + const src = ctx.createMediaStreamSource(stream); + const gain = ctx.createGain(); + gain.gain.value = settings.micGain; + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.3; + micTestAnalyserRef.current = analyser; + micTestBufferRef.current = new Float32Array(analyser.fftSize) as Float32Array; + + src.connect(gain); + gain.connect(analyser); + + micTestTimerRef.current = setInterval(() => { + if (!micTestAnalyserRef.current || !micTestBufferRef.current) return; + micTestAnalyserRef.current.getFloatTimeDomainData(micTestBufferRef.current); + let sum = 0; + for (let i = 0; i < micTestBufferRef.current.length; i++) { + const v = micTestBufferRef.current[i]; + sum += v * v; + } + const rms = Math.sqrt(sum / micTestBufferRef.current.length); + const db = 20 * Math.log10(Math.max(rms, 1e-10)); + setMicTestLevel(Math.max(0, Math.min(1, (db + 60) / 60))); + }, 50); + + setMicTestRunning(true); + } catch (err: unknown) { + setMicTestLevel(0); + setMicTestRunning(false); + if (err instanceof DOMException && (err.name === "NotAllowedError" || err.name === "PermissionDeniedError")) { + setMicTestError("Microphone permission denied"); + } else if (err instanceof DOMException && (err.name === "NotFoundError" || err.name === "OverconstrainedError")) { + setMicTestError("No microphone found"); + } else { + setMicTestError("Failed to access microphone"); + } + } + }, [settings.micDeviceId, settings.micGain, settings.micNoiseSuppression, settings.micAutoGainControl, settings.micEchoCancellation]); + + const stopMicTest = useCallback(() => { + if (micTestTimerRef.current) { + clearInterval(micTestTimerRef.current); + micTestTimerRef.current = null; + } + if (micTestContextRef.current) { + void micTestContextRef.current.close().catch(() => {}); + micTestContextRef.current = null; + } + if (micTestStreamRef.current) { + for (const track of micTestStreamRef.current.getTracks()) track.stop(); + micTestStreamRef.current = null; + } + micTestAnalyserRef.current = null; + micTestBufferRef.current = null; + setMicTestLevel(0); + setMicTestRunning(false); + }, []); + + useEffect(() => { + return () => stopMicTest(); + }, [stopMicTest]); // Dynamic entitled resolutions from MES API const [entitledResolutions, setEntitledResolutions] = useState([]); const [subscriptionLoading, setSubscriptionLoading] = useState(true); @@ -1270,13 +1394,32 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag
-
-
+
+
+
+
+
+ {micTestError && ( + {micTestError} + )}
diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index c66130f6..c8ab7da0 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -1809,9 +1809,55 @@ body.controller-mode { background: var(--card-hover); border-color: rgba(255,255,255,0.12); transform: translateY(-1px); + +.mic-device-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-surface); +} + +.mic-refresh-btn { + flex-shrink: 0; + padding: 6px 8px !important; +} + +.mic-test-meter-wrap { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; } + +.mic-level-meter { + flex: 1; + height: 8px; + background: var(--chip); + border-radius: 4px; + overflow: hidden; + min-width: 120px; + max-width: 260px; +} + +.mic-level-meter-fill { + width: 100%; + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--warning)); + border-radius: 4px; + transform-origin: left; + transition: transform 60ms linear;} .settings-export-logs-btn:active { transform: translateY(0); } +.mic-test-btn { + flex-shrink: 0; + white-space: nowrap; +} + +.mic-test-error { + font-size: 0.72rem; + color: var(--error); + margin-top: 2px; +} + /* Codec Diagnostics */ .codec-test-btn { display: flex; align-items: center; gap: 8px;