diff --git a/entry/src/main/ets/pages/SettingsPageV2.ets b/entry/src/main/ets/pages/SettingsPageV2.ets index 8152789b..f14e931b 100644 --- a/entry/src/main/ets/pages/SettingsPageV2.ets +++ b/entry/src/main/ets/pages/SettingsPageV2.ets @@ -550,7 +550,7 @@ struct SettingsPageV2 { this.gamepadMotionFallback = await this.loadBoolean(SettingsKeys.GAMEPAD_MOTION_FALLBACK, false); this.gamepadTouchpadMouse = await this.loadBoolean(SettingsKeys.GAMEPAD_TOUCHPAD_MOUSE, false); this.enableStartKeyMenu = await this.loadBoolean(SettingsKeys.ENABLE_START_KEY_MENU, true); - this.enableKeyInterceptor = await this.loadBoolean(SettingsKeys.ENABLE_KEY_INTERCEPTOR, false); + this.enableKeyInterceptor = await this.loadBoolean(SettingsKeys.ENABLE_KEY_INTERCEPTOR, true); // 检查按键注入 API 是否可用(API 13+),不可用则强制禁用反劫持 this.keyInterceptorAvailable = InputInterceptorService.getInstance().isInjectApiAvailable(); if (!this.keyInterceptorAvailable && this.enableKeyInterceptor) { @@ -1651,7 +1651,7 @@ struct SettingsPageV2 { }, { title: '按键反劫持', - subtitle: '拦截部分蓝牙手柄 Guide/Mode 键退出 App(需系统级签名,否则可能无效)', + subtitle: '在系统之前接管按键,使 Alt+Tab 等系统快捷键能转发到主机;同时拦截蓝牙手柄 Guide/Mode 键退出 App。关闭后这类按键会被系统吞掉', type: 'toggle', value: this.enableKeyInterceptor, visible: this.keyInterceptorAvailable, diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 9a6f2e10..2f9229b2 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -27,7 +27,7 @@ import { PerformanceOverlay } from '../components/PerformanceOverlay'; import { OptionPickerDialog, OptionPickerDialogConfig } from '../components/dialogs/OptionPickerDialog'; import { GameMenuDialog, GameMenuCallbacks } from '../components/dialogs/GameMenuDialog'; import { StreamMenuManager } from '../components/StreamMenuManager'; -import { StreamViewModel, TouchMode } from '../viewmodel/StreamViewModel'; +import { StreamViewModel, TouchMode, StreamConnectionState } from '../viewmodel/StreamViewModel'; import { TouchInputHandler, TouchInputMode } from '../service/input/TouchInputHandler'; import { DS5_TOUCH_FEEDBACK_CANCEL, @@ -479,6 +479,10 @@ struct StreamPage { reconcileKeyState: () => { this.inputHandler.reconcileKeyState(); }, + stopKeyboardForwarding: () => { + this.inputHandler.stopKeyInterceptor(); + this.inputHandler.stopModifierWatchdog(); + }, setNativeVSyncForeground: (foreground: boolean) => { try { MoonBridge.setNativeVSyncForeground(foreground); } catch (e) { Logger.warn('StreamPage', `NativeVSync visibility update failed: ${e}`); @@ -845,6 +849,7 @@ struct StreamPage { // 停止按键拦截器、鼠标 Monitor 和 DisplaySync InputInterceptorService.getInstance().stop(); this.inputHandler.stopMouseInterceptor(); + this.inputHandler.stopModifierWatchdog(); this.stopMouseKeepAlive(); // 停止体感助手 @@ -1620,9 +1625,15 @@ struct StreamPage { await this.lifecycleManager.startBackgroundTask(this.context, this.appName || undefined); } - // 启动按键拦截器(拦截被系统劫持的手柄按键,如蓝牙手柄 Home 键) - if (this.viewModel.inputSettings?.enableKeyInterceptor) { + // 启动按键拦截器与修饰键自愈看门狗(开关与注入 API 检查在 + // startKeyInterceptor 内部)。不能用 isConnected 复核:CONNECTED 等首帧 + // 才翻转,此处是 CONNECTING。需排除两类过期链路:连接已终止 + // (DISCONNECTED);停止流程在原生 teardown 中尚未翻转状态(isStopping, + // 此时停止入口的 resetState 清理已先行执行,重启将无人回收) + if (!this.pageDisposed && !this.viewModel.isStopping && + this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { this.inputHandler.startKeyInterceptor(); + this.inputHandler.startModifierWatchdog(); } // 启动鼠标全速轮询:优先 C++ Monitor,不可用时 DisplaySync 兜底 @@ -2119,6 +2130,16 @@ struct StreamPage { // 获取 surfaceId → 设置帧率 → 启动串流 await this.launchStream(); this.streamStartedAtMs = Date.now(); + + // 连接终止时已停用键盘转发,重连成功后恢复(条件判断在 startKeyInterceptor + // 内部;复核逻辑同 startStreaming:不能用等首帧才翻转的 isConnected, + // 并经 isStopping 排除与停止流程的交叠) + if (!this.pageDisposed && !this.viewModel.isStopping && + this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { + this.inputHandler.startKeyInterceptor(); + this.inputHandler.startModifierWatchdog(); + } + await this.startClipboardSync(); Logger.info('StreamPage', '[StreamPage] 重连串流成功'); diff --git a/entry/src/main/ets/service/SettingsService.ets b/entry/src/main/ets/service/SettingsService.ets index c312c77a..266288d0 100644 --- a/entry/src/main/ets/service/SettingsService.ets +++ b/entry/src/main/ets/service/SettingsService.ets @@ -793,7 +793,7 @@ export class SettingsService { gamepadMotionFallback: await this.getBoolean(SettingsKeys.GAMEPAD_MOTION_FALLBACK, false), gamepadTouchpadMouse: await this.getBoolean(SettingsKeys.GAMEPAD_TOUCHPAD_MOUSE, false), enableStartKeyMenu: await this.getBoolean(SettingsKeys.ENABLE_START_KEY_MENU, true), - enableKeyInterceptor: await this.getBoolean(SettingsKeys.ENABLE_KEY_INTERCEPTOR, false), + enableKeyInterceptor: await this.getBoolean(SettingsKeys.ENABLE_KEY_INTERCEPTOR, true), usbDriverEnabled: await this.getBoolean(SettingsKeys.USB_DRIVER_ENABLED, false), forceUsbDriverOnly: await this.getBoolean(SettingsKeys.FORCE_USB_DRIVER_ONLY, false), ddkHighSpeedPolling: await this.getBoolean(SettingsKeys.DDK_HIGH_SPEED_POLLING, false), diff --git a/entry/src/main/ets/service/streaming/StreamInputHandler.ets b/entry/src/main/ets/service/streaming/StreamInputHandler.ets index a80abc16..2940ac58 100644 --- a/entry/src/main/ets/service/streaming/StreamInputHandler.ets +++ b/entry/src/main/ets/service/streaming/StreamInputHandler.ets @@ -47,6 +47,9 @@ export class StreamInputHandler { private modifierFlags: number = 0; // ── 当前通过 ArkUI onKeyEvent 按下的 VK 键集合(用于焦点丢失时释放残留键)── private activeVkKeys: Set = new Set(); + // ── 修饰键卡死自愈看门狗(#153:系统快捷键吞掉修饰键 KEY_UP 时兜底)── + private modifierWatchdogTimer: number = -1; + private static readonly MODIFIER_WATCHDOG_INTERVAL: number = 300; // ms // ── 鼠标拦截器(绕过 ArkUI 帧率限制,全速鼠标轮询)── private mouseInterceptorActive: boolean = false; @@ -132,6 +135,9 @@ export class StreamInputHandler { */ resetState(): void { this.releaseAllKeys(); + this.stopModifierWatchdog(); + // 拦截器是消费型的,串流结束后必须停止,否则应用界面收不到物理键盘 + this.stopKeyInterceptor(); // 对账记录不跨串流残留:新串流主机端状态全新,旧记录无意义 this.releasedVkKeys.clear(); this.modifierFlags = 0; @@ -256,6 +262,127 @@ export class StreamInputHandler { } } + // ── 修饰键卡死自愈(#153)── + + /** + * 判断 HarmonyOS 键码是否为修饰键(Alt/Shift/Ctrl/Meta 左右) + */ + private static isModifierKeyCode(keyCode: number): boolean { + return StreamInputHandler.RECONCILE_KEY_CODES.indexOf(keyCode) >= 0; + } + + /** + * 修饰键 VK → HarmonyOS 键码 + * 复用焦点对账的映射表,避免同一份对应关系出现两份实现而走偏 + * + * @returns HarmonyOS 键码;非修饰键返回 -1 + */ + private static harmonyCodeForModifierVk(vkCode: number): number { + const index = StreamInputHandler.RECONCILE_VK_CODES.indexOf(vkCode); + return index >= 0 ? StreamInputHandler.RECONCILE_KEY_CODES[index] : -1; + } + + /** + * 修饰键卡死自愈(#153) + * + * 背景:修饰键的 KEY_UP 可能被系统快捷键流程吞掉(典型 Alt+Tab 切换应用), + * 事件通道收不到这个 UP,于是本地掩码与主机端都会永久保持「按下」, + * 表现为主机把后续所有按键都当成 Alt+xxx,且只能靠反复按 Alt 恢复。 + * + * 权威来源:OH_Input_GetKeyState(经 queryKeyStates 查询;低版本设备无该 API 时 + * 返回 null,静默跳过)。 + * + * 只纠正「本地认为按下、物理已抬起」这一个方向: + * - 这正是 #153 的失效形态,补发一条 KEY_UP 即可同时清掉本地掩码与主机端状态; + * - 反方向(物理按下、本地松开)由焦点恢复时的 reconcileKeyState 负责,放在这里 + * 会在失焦期间误重按,也会和虚拟键盘的粘滞修饰键互相打架。 + * + * 只查询本地认为仍按下的修饰键,没有修饰键时零开销(虚拟键盘直接调用 + * sendKeyboardInput,不进入 activeVkKeys,因此不受影响)。 + * + * @returns 本次纠正的修饰键数量 + */ + verifyStuckModifiers(): number { + if (!this.viewModel?.isConnected) return 0; + + // 1. 收集本地认为仍按下的修饰键(无则直接返回,正常输入路径无额外开销) + const vkCodes: number[] = []; + const harmonyCodes: number[] = []; + this.activeVkKeys.forEach((vkCode: number) => { + const harmony = StreamInputHandler.harmonyCodeForModifierVk(vkCode); + if (harmony > 0) { + vkCodes.push(vkCode); + harmonyCodes.push(harmony); + } + }); + if (vkCodes.length === 0) return 0; + + const session = this.viewModel.getSession(); + if (!session) return 0; + + // 2. 查询真实物理状态(null = API 不可用,维持现状) + const states = InputInterceptorService.getInstance().queryKeyStates(harmonyCodes); + if (states === null || states.length !== harmonyCodes.length) return 0; + + // 3. 仅对「物理已抬起」的修饰键补发 KEY_UP + let released = 0; + for (let i = 0; i < vkCodes.length; i++) { + if (states[i] !== 0) continue; // 0=已抬起;1=按下 / -1=查询失败,保守跳过 + const vkCode = vkCodes[i]; + // 先移除该键再按剩余按键重建掩码:左右变体共享一个修饰位,若一对中只有 + // 一侧被自愈(如 LAlt/RAlt 同按、仅一侧的 KEY_UP 被吞),增量清位会把 + // 仍被按住的另一侧的修饰状态一起丢掉 + this.activeVkKeys.delete(vkCode); + this.rebuildModifierFlags(); + session.sendKeyboardInput(vkCode, KEY_ACTION_UP, this.modifierFlags); + released++; + } + if (released > 0) { + Logger.info('StreamInputHandler', + `[INPUT] 修饰键卡死自愈: 补发 ${released} 个 KEY_UP (掩码=0x${this.modifierFlags.toString(16)})`); + } + return released; + } + + /** + * 按当前仍按下的键(activeVkKeys)全量重建修饰键掩码 + * + * 左右变体(LAlt/RAlt 等)共用一个修饰位,updateModifierFlags 的增量清位 + * 无法表达「一对中只松开一侧」;自愈补发 KEY_UP 前用本方法重算, + * 保证掩码与剩余仍按下的修饰键一致。非修饰键不贡献任何位。 + */ + private rebuildModifierFlags(): void { + this.modifierFlags = 0; + this.activeVkKeys.forEach((vkCode: number) => { + this.updateModifierFlags(vkCode, true); + }); + } + + /** + * 启动修饰键卡死自愈看门狗 + * + * 用户停止输入时(主机端 Alt 一直卡住、菜单栏被激活)也能在 + * MODIFIER_WATCHDOG_INTERVAL 内自愈,不必等下一次按键。 + */ + startModifierWatchdog(): void { + if (this.modifierWatchdogTimer >= 0) return; + this.modifierWatchdogTimer = setInterval(() => { + this.verifyStuckModifiers(); + }, StreamInputHandler.MODIFIER_WATCHDOG_INTERVAL); + Logger.info('StreamInputHandler', 'StreamInputHandler: 修饰键自愈看门狗已启动'); + } + + /** + * 停止修饰键卡死自愈看门狗 + */ + stopModifierWatchdog(): void { + if (this.modifierWatchdogTimer >= 0) { + clearInterval(this.modifierWatchdogTimer); + this.modifierWatchdogTimer = -1; + Logger.info('StreamInputHandler', 'StreamInputHandler: 修饰键自愈看门狗已停止'); + } + } + // ==================== 鼠标高速轮询 ==================== /** @@ -482,6 +609,13 @@ export class StreamInputHandler { const keyCode = event.keyCode; const isGamepadKey = KeyboardTranslator.isGamepadKey(keyCode); + // 修饰键卡死自愈(#153):非修饰键事件到达时先纠正残留的修饰键状态, + // 避免本次按键被主机当成 Alt+xxx。修饰键自身的事件交给正常路径处理, + // 否则每次松开修饰键都会重复补发一条 KEY_UP。 + if (!StreamInputHandler.isModifierKeyCode(keyCode)) { + this.verifyStuckModifiers(); + } + if (!this.viewModel?.isConnected) { // 即使未连接,也要拦截手柄按键,防止触发系统导航 return isGamepadKey; @@ -549,8 +683,21 @@ export class StreamInputHandler { /** * 启动按键事件拦截器 * 拦截被系统劫持的手柄按键和特殊键盘按键,转发给 GamepadManager / StreamingSession 处理 + * + * 启动条件在内部判断(而非调用方判断),保证所有启动路径口径一致: + * - 设置开关 enableKeyInterceptor 关闭时不启动; + * - 无按键注入 API(API 13 以下老设备)时强制跳过——拦截器是消费型的, + * 音量键被吃掉后无法回注。与设置页的 keyInterceptorAvailable 检查一致, + * 这里兜底覆盖从未打开过设置页的场景。 */ startKeyInterceptor(): void { + if (this.viewModel?.inputSettings?.enableKeyInterceptor !== true) { + return; + } + if (!InputInterceptorService.getInstance().isInjectApiAvailable()) { + Logger.warn('StreamInputHandler', 'StreamInputHandler: 无按键注入 API(API 13+),跳过按键拦截器'); + return; + } const result = InputInterceptorService.getInstance().start((event) => { if (!this.viewModel?.isConnected) { return; @@ -559,6 +706,11 @@ export class StreamInputHandler { // action: 1=KEY_DOWN, 2=KEY_UP const isPressed = event.action === 1; + // 修饰键卡死自愈(#153):与 ArkUI 路径同源处理,非修饰键事件先纠正残留修饰键 + if (!StreamInputHandler.isModifierKeyCode(keyCode)) { + this.verifyStuckModifiers(); + } + // 1. 手柄按键范围 (2300-2320 GC Kit / 110 标准 Mode) - 转发给 GamepadManager if ((keyCode >= 2300 && keyCode <= 2320) || keyCode === 110) { // 学习该 deviceId 为手柄设备(用于后续 DPad 方向键过滤) @@ -652,6 +804,16 @@ export class StreamInputHandler { } } + /** + * 停止按键事件拦截器 + * + * 拦截器是消费型的:被它接管的按键不会再传给应用自身。因此串流结束后必须 + * 停止,否则停留在应用界面时物理键盘会全部失效(事件被吞掉且无处转发)。 + */ + stopKeyInterceptor(): void { + InputInterceptorService.getInstance().stop(); + } + // ==================== 映射工具 ==================== /** diff --git a/entry/src/main/ets/service/streaming/StreamLifecycleManager.ets b/entry/src/main/ets/service/streaming/StreamLifecycleManager.ets index 242aaf82..1b7efe0d 100644 --- a/entry/src/main/ets/service/streaming/StreamLifecycleManager.ets +++ b/entry/src/main/ets/service/streaming/StreamLifecycleManager.ets @@ -42,6 +42,8 @@ export interface LifecycleCallbacks { releaseAllKeys: () => void; /** 对账按键状态(回前台时调用,补发仍物理按住的键) */ reconcileKeyState: () => void; + /** 连接终止时立即停用按键拦截器与修饰键看门狗(不等清理流程,让物理键盘回到应用界面) */ + stopKeyboardForwarding: () => void; /** Pause/resume the native high-rate VSync request with app visibility. */ setNativeVSyncForeground: (foreground: boolean) => void; } @@ -433,6 +435,11 @@ export class StreamLifecycleManager { handleConnectionTerminated(errorCode: number): void { Logger.info('StreamLifecycleManager', `StreamPage: 连接终止, errorCode=${errorCode}, inBackground=${this.wasInBackground}`); + // 拦截器是消费型的:连接已终止但停留在串流页期间(典型:断连错误对话框 + // 等待用户确认),物理按键会被吞掉且无处转发,键盘无法操作对话框。 + // 立即停用,不等 stopStreaming 的清理流程;重连成功后由 reconnectStreaming 重新启动 + this.callbacks?.stopKeyboardForwarding(); + const ML_ERROR_GRACEFUL_TERMINATION = 0; const ML_ERROR_NO_VIDEO_TRAFFIC = -100; const ML_ERROR_NO_VIDEO_FRAME = -101; diff --git a/entry/src/main/ets/viewmodel/StreamViewModel.ets b/entry/src/main/ets/viewmodel/StreamViewModel.ets index 630c07d8..7986ed92 100644 --- a/entry/src/main/ets/viewmodel/StreamViewModel.ets +++ b/entry/src/main/ets/viewmodel/StreamViewModel.ets @@ -118,6 +118,13 @@ export class StreamViewModel { // 连接状态 connectionState: StreamConnectionState = StreamConnectionState.IDLE; errorMessage: string = ''; + /** + * 停止流程进行中:原生 teardown 的 await 期间 connectionState 仍为旧值, + * 尚未翻转 DISCONNECTED,启动侧复核需据此排除与停止流程的交叠 + * (entry 置位、startStreaming 入口复位;停止异常中断时保持 true, + * 由下一次启动解锁,属保守方向) + */ + isStopping: boolean = false; // 连接进度(0.0 - 1.0) connectionProgress: number = 0; @@ -293,6 +300,7 @@ export class StreamViewModel { useVdd?: boolean ): Promise { this.connectionState = StreamConnectionState.CONNECTING; + this.isStopping = false; this.connectionProgress = 0; this.connectionStageText = ''; this.errorMessage = ''; @@ -458,6 +466,8 @@ export class StreamViewModel { * 停止串流 */ async stopStreaming(): Promise { + this.isStopping = true; + // 重置旋转状态 this.resetRotationState(); @@ -476,8 +486,9 @@ export class StreamViewModel { await this.streamingSession.stop(); this.streamingSession = null; } - + this.connectionState = StreamConnectionState.DISCONNECTED; + this.isStopping = false; } private stopFirstFrameWatcher(): void { @@ -663,6 +674,8 @@ export class StreamViewModel { * 调用服务器 API 退出当前运行的游戏并停止串流 */ async quitGame(): Promise { + this.isStopping = true; + // 调用 StreamingSession 的 quitApp 方法退出远程游戏 try { if (this.streamingSession) { @@ -677,8 +690,9 @@ export class StreamViewModel { } catch (err) { Logger.error('StreamViewModel', '退出游戏失败:', err); } - + this.connectionState = StreamConnectionState.DISCONNECTED; + this.isStopping = false; } /**