From 66223e978bc37f1bfea11c5e77b9929ac3eab679 Mon Sep 17 00:00:00 2001 From: ChuXunYu <504257689@qq.com> Date: Fri, 2 Oct 2026 18:32:56 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(input):=20=E4=BF=AE=E9=A5=B0=E9=94=AE?= =?UTF-8?q?=20KEY=5FUP=20=E4=B8=A2=E5=A4=B1=E6=97=B6=E8=87=AA=E6=84=88?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Alt+Tab=20=E5=90=8E=20Alt=20?= =?UTF-8?q?=E5=8D=A1=E4=BD=8F=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修饰键的 KEY_UP 可能被系统快捷键流程吞掉(典型 Alt+Tab),事件通道收不到该 UP,本地修饰键掩码与主机端都会永久保持「按下」,主机把后续所有按键当作 Alt+xxx,且只能靠反复按 Alt 恢复。 新增 verifyStuckModifiers():以 OH_Input_GetKeyState 的物理状态为准,只纠正 「本地认为按下、物理已抬起」这一个方向(反方向由焦点恢复时的 reconcileKeyState 负责),补发 KEY_UP 并同步掩码与 activeVkKeys。 触发时机: - 300ms 看门狗:停止输入时也能自愈,不必等下一次按键; - 非修饰键事件到达前:保证该次按键不被主机当成 Alt+xxx。 只查询本地认为仍按下的修饰键,无修饰键时零开销;虚拟键盘直接调用 sendKeyboardInput,不进入 activeVkKeys,粘滞修饰键不受影响;低版本设备 无 OH_Input_GetKeyState 时静默跳过。 验证:真机 MatePad Air 复现 Alt+Tab 场景,hilog 抓到 14 次自愈 (补发 1 个 KEY_UP,掩码=0x0),Alt 不再卡住。 --- entry/src/main/ets/pages/StreamPage.ets | 4 + .../service/streaming/StreamInputHandler.ets | 121 ++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 9a6f2e10..a2a1091c 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -845,6 +845,7 @@ struct StreamPage { // 停止按键拦截器、鼠标 Monitor 和 DisplaySync InputInterceptorService.getInstance().stop(); this.inputHandler.stopMouseInterceptor(); + this.inputHandler.stopModifierWatchdog(); this.stopMouseKeepAlive(); // 停止体感助手 @@ -1625,6 +1626,9 @@ struct StreamPage { this.inputHandler.startKeyInterceptor(); } + // 启动修饰键卡死自愈看门狗(#153:Alt+Tab 等系统快捷键吞掉修饰键 KEY_UP 时兜底) + this.inputHandler.startModifierWatchdog(); + // 启动鼠标全速轮询:优先 C++ Monitor,不可用时 DisplaySync 兜底 const monitorOk = this.inputHandler.startMouseInterceptor( this.getXComponentWidthVp(), diff --git a/entry/src/main/ets/service/streaming/StreamInputHandler.ets b/entry/src/main/ets/service/streaming/StreamInputHandler.ets index a80abc16..0383f5b9 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,7 @@ export class StreamInputHandler { */ resetState(): void { this.releaseAllKeys(); + this.stopModifierWatchdog(); // 对账记录不跨串流残留:新串流主机端状态全新,旧记录无意义 this.releasedVkKeys.clear(); this.modifierFlags = 0; @@ -256,6 +260,111 @@ 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]; + // 先更新掩码再发送,保证这条 KEY_UP 带的是纠正后的修饰位 + this.updateModifierFlags(vkCode, false); + session.sendKeyboardInput(vkCode, KEY_ACTION_UP, this.modifierFlags); + this.activeVkKeys.delete(vkCode); + released++; + } + if (released > 0) { + Logger.info('StreamInputHandler', + `[INPUT] 修饰键卡死自愈: 补发 ${released} 个 KEY_UP (掩码=0x${this.modifierFlags.toString(16)})`); + } + return released; + } + + /** + * 启动修饰键卡死自愈看门狗 + * + * 用户停止输入时(主机端 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 +591,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; @@ -559,6 +675,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 方向键过滤) From c4a6f5fdb92e5bfe6886288b05f9b6fa06a8b309 Mon Sep 17 00:00:00 2001 From: ChuXunYu <504257689@qq.com> Date: Fri, 2 Oct 2026 18:46:13 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(input):=20=E9=BB=98=E8=AE=A4=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E6=8C=89=E9=94=AE=E5=8F=8D=E5=8A=AB=E6=8C=81=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=20Alt+Tab=20=E7=AD=89=E7=B3=BB=E7=BB=9F=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E9=94=AE=E8=BD=AC=E5=8F=91=E5=88=B0=E4=B8=BB=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HarmonyOS 在事件到达应用之前就消费了 Alt+Tab:ArkUI onKeyEvent 只能收到 Alt, Tab 完全收不到,主机端因此只看到 Alt 按下/抬起——菜单栏被激活、无法切换窗口。 只有 OH_Input_AddKeyEventInterceptor(按键反劫持)能在系统之前接管这类按键。 真机实测(MatePad Air,物理键盘按 Alt+Tab): - 关闭反劫持:ArkUI 仅收到 Alt ×3、Tab ×0,主机无反应 - 开启反劫持:拦截器收到 Alt ×12 + Tab ×12,顺序为 Alt down → Tab down → Alt up → Tab up,主机正常切换窗口 改动: - enableKeyInterceptor 默认值 false → true(设置页与 SettingsService 同步); 无按键注入 API 的老设备仍由 keyInterceptorAvailable 自动禁用 - 新增 stopKeyInterceptor() 并在 resetState() 中调用:拦截器是消费型的,原先 只在页面销毁(aboutToDisappear)时停止;串流已结束但仍停留在串流页(战报 界面)期间,物理按键会被吞掉且无处转发 - 更新设置项说明文案 --- entry/src/main/ets/pages/SettingsPageV2.ets | 4 ++-- entry/src/main/ets/service/SettingsService.ets | 2 +- .../ets/service/streaming/StreamInputHandler.ets | 12 ++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) 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/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 0383f5b9..c9ee9194 100644 --- a/entry/src/main/ets/service/streaming/StreamInputHandler.ets +++ b/entry/src/main/ets/service/streaming/StreamInputHandler.ets @@ -136,6 +136,8 @@ export class StreamInputHandler { resetState(): void { this.releaseAllKeys(); this.stopModifierWatchdog(); + // 拦截器是消费型的,串流结束后必须停止,否则应用界面收不到物理键盘 + this.stopKeyInterceptor(); // 对账记录不跨串流残留:新串流主机端状态全新,旧记录无意义 this.releasedVkKeys.clear(); this.modifierFlags = 0; @@ -773,6 +775,16 @@ export class StreamInputHandler { } } + /** + * 停止按键事件拦截器 + * + * 拦截器是消费型的:被它接管的按键不会再传给应用自身。因此串流结束后必须 + * 停止,否则停留在应用界面时物理键盘会全部失效(事件被吞掉且无处转发)。 + */ + stopKeyInterceptor(): void { + InputInterceptorService.getInstance().stop(); + } + // ==================== 映射工具 ==================== /** From 1a8cf607d09c6dfbbc8a0d5ae37725e3347ca0cf Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Thu, 15 Oct 2026 16:08:19 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(input):=20=E5=A4=84=E7=90=86=20review?= =?UTF-8?q?=20=E4=B8=89=E9=A1=B9=EF=BC=9A=E6=96=AD=E8=BF=9E=E5=8D=B3?= =?UTF-8?q?=E5=81=9C=E6=8B=A6=E6=88=AA=E5=99=A8=E3=80=81=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=20gate=20=E6=94=B6=E6=95=9B=E3=80=81=E8=87=AA=E6=84=88?= =?UTF-8?q?=E6=8E=A9=E7=A0=81=E9=87=8D=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 连接终止时立即停用按键拦截器与修饰键看门狗:拦截器是消费型的, 异常断连后错误对话框等待确认期间,物理按键会被吞掉且无处转发 (isConnected 已为 false,回调直接丢弃事件,键盘无法操作对话框)。 新增 LifecycleCallbacks.stopKeyboardForwarding,在 handleConnectionTerminated 入口停用;reconnectStreaming 成功后 恢复(与 startStreaming 启动项保持一致)。 2. 按键拦截器的启动条件移入 startKeyInterceptor() 内部(设置开关 + isInjectApiAvailable 双检查):覆盖从未打开过设置页的老设备。 此前默认值改 true 后,无注入 API 的设备若不进设置页,每次串流 都会启用消费型拦截器,音量键被吃掉且无法回注。 3. verifyStuckModifiers 补发 KEY_UP 前先从 activeVkKeys 移除该键, 再按剩余按键重建修饰掩码(新增 rebuildModifierFlags):左右修饰键 共享一个修饰位,增量清位会把仍被按住的另一侧(LAlt/RAlt 同按、 仅一侧 KEY_UP 被吞的场景)的修饰状态一起丢掉。 Co-Authored-By: Claude Fable 5 --- entry/src/main/ets/pages/StreamPage.ets | 16 ++++-- .../service/streaming/StreamInputHandler.ets | 53 +++++++++++++++++-- .../streaming/StreamLifecycleManager.ets | 7 +++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index a2a1091c..2205c72c 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -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}`); @@ -1621,10 +1625,8 @@ struct StreamPage { await this.lifecycleManager.startBackgroundTask(this.context, this.appName || undefined); } - // 启动按键拦截器(拦截被系统劫持的手柄按键,如蓝牙手柄 Home 键) - if (this.viewModel.inputSettings?.enableKeyInterceptor) { - this.inputHandler.startKeyInterceptor(); - } + // 启动按键拦截器(设置开关与注入 API 可用性检查由 startKeyInterceptor 内部完成) + this.inputHandler.startKeyInterceptor(); // 启动修饰键卡死自愈看门狗(#153:Alt+Tab 等系统快捷键吞掉修饰键 KEY_UP 时兜底) this.inputHandler.startModifierWatchdog(); @@ -2123,6 +2125,12 @@ struct StreamPage { // 获取 surfaceId → 设置帧率 → 启动串流 await this.launchStream(); this.streamStartedAtMs = Date.now(); + + // 连接终止时已停用按键拦截器与自愈看门狗,重连成功后恢复 + //(与 startStreaming 的启动项保持一致;startKeyInterceptor 内部完成条件判断) + this.inputHandler.startKeyInterceptor(); + this.inputHandler.startModifierWatchdog(); + await this.startClipboardSync(); Logger.info('StreamPage', '[StreamPage] 重连串流成功'); diff --git a/entry/src/main/ets/service/streaming/StreamInputHandler.ets b/entry/src/main/ets/service/streaming/StreamInputHandler.ets index c9ee9194..9b519449 100644 --- a/entry/src/main/ets/service/streaming/StreamInputHandler.ets +++ b/entry/src/main/ets/service/streaming/StreamInputHandler.ets @@ -329,10 +329,12 @@ export class StreamInputHandler { for (let i = 0; i < vkCodes.length; i++) { if (states[i] !== 0) continue; // 0=已抬起;1=按下 / -1=查询失败,保守跳过 const vkCode = vkCodes[i]; - // 先更新掩码再发送,保证这条 KEY_UP 带的是纠正后的修饰位 - this.updateModifierFlags(vkCode, false); - session.sendKeyboardInput(vkCode, KEY_ACTION_UP, this.modifierFlags); + // 先移除该键再按剩余按键重建掩码:左右变体共享一个修饰位,若一对中只有 + // 一侧被自愈(如 LAlt/RAlt 同按、仅一侧的 KEY_UP 被吞),增量清位会把 + // 仍被按住的另一侧的修饰状态一起丢掉 this.activeVkKeys.delete(vkCode); + this.rebuildModifierFlags(); + session.sendKeyboardInput(vkCode, KEY_ACTION_UP, this.modifierFlags); released++; } if (released > 0) { @@ -342,6 +344,38 @@ export class StreamInputHandler { return released; } + /** + * 按当前仍按下的键(activeVkKeys)全量重建修饰键掩码 + * + * 左右变体(LAlt/RAlt 等)共用一个修饰位,updateModifierFlags 的增量清位 + * 无法表达「一对中只松开一侧」;自愈补发 KEY_UP 前用本方法重算, + * 保证掩码与剩余仍按下的修饰键一致。非修饰键不贡献任何位。 + */ + private rebuildModifierFlags(): void { + let flags = 0; + this.activeVkKeys.forEach((vkCode: number) => { + switch (vkCode) { + case 0xA0: // VK_LSHIFT + case 0xA1: // VK_RSHIFT + flags |= MODIFIER_SHIFT; + break; + case 0xA2: // VK_LCONTROL + case 0xA3: // VK_RCONTROL + flags |= MODIFIER_CTRL; + break; + case 0xA4: // VK_LMENU + case 0xA5: // VK_RMENU + flags |= MODIFIER_ALT; + break; + case 0x5B: // VK_LWIN + case 0x5C: // VK_RWIN + flags |= MODIFIER_META; + break; + } + }); + this.modifierFlags = flags; + } + /** * 启动修饰键卡死自愈看门狗 * @@ -667,8 +701,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; 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; From 6669cb55edae37e1574c825e1a08c5940d934b89 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Thu, 15 Oct 2026 16:46:01 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(input):=20=E5=90=AF=E5=8A=A8=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=A4=8D=E6=A0=B8=E9=94=80=E6=AF=81=E4=B8=8E=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E7=8A=B6=E6=80=81=EF=BC=8C=E9=87=8D=E8=BF=9E=E5=90=8E?= =?UTF-8?q?=E8=A1=A5=E5=AF=B9=E8=B4=A6=E6=8C=89=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 处理 CodeRabbit 两条意见: 1. 过期异步链路不得重启键盘转发:startStreaming/reconnectStreaming 的 await 期间页面可能已销毁、连接可能已终止(或 launchStream 因 pageDisposed 提前返回),清理回调停用拦截器后旧链路继续执行会重新 启用消费型拦截器与看门狗。两处启动点均加 pageDisposed/isConnected 复核。 2. 重连成功后补调用 reconcileKeyState():后台断连时用户仍按住的键已被 releaseAllKeys 清除追踪,回前台的对账因当时未连接被跳过,会话就绪后 补一次差量同步,否则需松开重按。 Co-Authored-By: Claude Fable 5 --- entry/src/main/ets/pages/StreamPage.ets | 26 +++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 2205c72c..849a19b6 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -1625,11 +1625,14 @@ struct StreamPage { await this.lifecycleManager.startBackgroundTask(this.context, this.appName || undefined); } - // 启动按键拦截器(设置开关与注入 API 可用性检查由 startKeyInterceptor 内部完成) - this.inputHandler.startKeyInterceptor(); - - // 启动修饰键卡死自愈看门狗(#153:Alt+Tab 等系统快捷键吞掉修饰键 KEY_UP 时兜底) - this.inputHandler.startModifierWatchdog(); + // 启动按键拦截器与修饰键自愈看门狗(设置开关与注入 API 检查在 + // startKeyInterceptor 内部完成)。启动链路的 await 期间页面可能已销毁、 + // 连接可能已终止:清理回调已停用键盘转发,过期链路重启会让物理按键 + // 再次被吞,故启动前必须复核 + if (!this.pageDisposed && this.viewModel.isConnected) { + this.inputHandler.startKeyInterceptor(); + this.inputHandler.startModifierWatchdog(); + } // 启动鼠标全速轮询:优先 C++ Monitor,不可用时 DisplaySync 兜底 const monitorOk = this.inputHandler.startMouseInterceptor( @@ -2127,9 +2130,16 @@ struct StreamPage { this.streamStartedAtMs = Date.now(); // 连接终止时已停用按键拦截器与自愈看门狗,重连成功后恢复 - //(与 startStreaming 的启动项保持一致;startKeyInterceptor 内部完成条件判断) - this.inputHandler.startKeyInterceptor(); - this.inputHandler.startModifierWatchdog(); + //(与 startStreaming 的启动项保持一致;startKeyInterceptor 内部完成条件判断)。 + // 重连的 await 期间页面可能已销毁、连接可能再次终止,启动前同样复核; + // launchStream 因 pageDisposed 提前返回时也不得启动键盘转发 + if (!this.pageDisposed && this.viewModel.isConnected) { + this.inputHandler.startKeyInterceptor(); + this.inputHandler.startModifierWatchdog(); + // 后台断连时用户可能仍按着按键:失焦已 releaseAllKeys 清除追踪, + // 回前台的对账因当时未连接被跳过,会话就绪后补一次差量同步 + this.inputHandler.reconcileKeyState(); + } await this.startClipboardSync(); Logger.info('StreamPage', '[StreamPage] 重连串流成功'); From 82c8eae50946179a5cfab984e2c6808e14a59816 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Thu, 15 Oct 2026 17:22:15 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(input):=20=E4=BF=AE=E6=AD=A3=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=A4=8D=E6=A0=B8=E6=9D=A1=E4=BB=B6=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E8=BF=9E=E5=AF=B9=E8=B4=A6=E6=97=B6=E6=9C=BA=EF=BC=88=E5=AF=B9?= =?UTF-8?q?=E6=8A=97=E6=80=A7=E8=87=AA=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6669cb5 引入两处缺陷: 1. 启动复核用 isConnected 会阻断正常启动:CONNECTED 由首帧 watcher 翻转(StreamViewModel 注释明言 start() resolve 时首帧常未上屏), 正常启动走到复核点 isConnected 多为 false,拦截器与看门狗会被跳过 且无任何机制补启动,Alt+Tab 转发直接失效。改用 connectionState !== DISCONNECTED——只需排除「启动链路 await 期间 已终止」,正常路径此时为 CONNECTING。 2. reconnect 中的 reconcileKeyState 同步调用同样被其内部 isConnected 检查拦住,实为无效调用。首帧最迟 1.5s(firstFrameWatcher deadline) 翻转,改为延迟 2s 调用(沿用 probeAiAvailableDeferred 的延迟惯例, 回调内复核 pageDisposed);期间再次断开由其内部检查自动跳过。 另:rebuildModifierFlags 去掉手写 VK→掩码映射,复用 updateModifierFlags 逐键置位,消除同一映射的第二份实现。 Co-Authored-By: Claude Fable 5 --- entry/src/main/ets/pages/StreamPage.ets | 29 ++++++++++++++----- .../service/streaming/StreamInputHandler.ets | 22 ++------------ 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 849a19b6..1f531c97 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, @@ -1628,8 +1628,12 @@ struct StreamPage { // 启动按键拦截器与修饰键自愈看门狗(设置开关与注入 API 检查在 // startKeyInterceptor 内部完成)。启动链路的 await 期间页面可能已销毁、 // 连接可能已终止:清理回调已停用键盘转发,过期链路重启会让物理按键 - // 再次被吞,故启动前必须复核 - if (!this.pageDisposed && this.viewModel.isConnected) { + // 再次被吞,故启动前必须复核。 + // 不能用 isConnected 判断:CONNECTED 要等首帧(firstFrameWatcher), + // 走到这里通常还是 CONNECTING,用 isConnected 会导致正常启动永不启动 + // 键盘转发;这里只需排除「已终止」,故比对 connectionState + if (!this.pageDisposed && + this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { this.inputHandler.startKeyInterceptor(); this.inputHandler.startModifierWatchdog(); } @@ -2132,13 +2136,22 @@ struct StreamPage { // 连接终止时已停用按键拦截器与自愈看门狗,重连成功后恢复 //(与 startStreaming 的启动项保持一致;startKeyInterceptor 内部完成条件判断)。 // 重连的 await 期间页面可能已销毁、连接可能再次终止,启动前同样复核; - // launchStream 因 pageDisposed 提前返回时也不得启动键盘转发 - if (!this.pageDisposed && this.viewModel.isConnected) { + // launchStream 因 pageDisposed 提前返回时也不得启动键盘转发。 + // 同 startStreaming:此处 isConnected 尚为 false(等首帧), + // 用 connectionState 排除「已终止」 + if (!this.pageDisposed && + this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { this.inputHandler.startKeyInterceptor(); this.inputHandler.startModifierWatchdog(); - // 后台断连时用户可能仍按着按键:失焦已 releaseAllKeys 清除追踪, - // 回前台的对账因当时未连接被跳过,会话就绪后补一次差量同步 - this.inputHandler.reconcileKeyState(); + // 后台断连时用户仍按着的键:失焦已 releaseAllKeys 清除追踪,回前台的 + // 对账因未连接被跳过。reconcileKeyState 内部要求 CONNECTED,而首帧 + // 最迟 1.5s(firstFrameWatcher deadline)才翻转,同步调用会被跳过, + // 故延迟到其后补差量同步;期间再次断开则由其内部检查自动跳过 + setTimeout(() => { + if (!this.pageDisposed) { + this.inputHandler.reconcileKeyState(); + } + }, 2000); } await this.startClipboardSync(); diff --git a/entry/src/main/ets/service/streaming/StreamInputHandler.ets b/entry/src/main/ets/service/streaming/StreamInputHandler.ets index 9b519449..2940ac58 100644 --- a/entry/src/main/ets/service/streaming/StreamInputHandler.ets +++ b/entry/src/main/ets/service/streaming/StreamInputHandler.ets @@ -352,28 +352,10 @@ export class StreamInputHandler { * 保证掩码与剩余仍按下的修饰键一致。非修饰键不贡献任何位。 */ private rebuildModifierFlags(): void { - let flags = 0; + this.modifierFlags = 0; this.activeVkKeys.forEach((vkCode: number) => { - switch (vkCode) { - case 0xA0: // VK_LSHIFT - case 0xA1: // VK_RSHIFT - flags |= MODIFIER_SHIFT; - break; - case 0xA2: // VK_LCONTROL - case 0xA3: // VK_RCONTROL - flags |= MODIFIER_CTRL; - break; - case 0xA4: // VK_LMENU - case 0xA5: // VK_RMENU - flags |= MODIFIER_ALT; - break; - case 0x5B: // VK_LWIN - case 0x5C: // VK_RWIN - flags |= MODIFIER_META; - break; - } + this.updateModifierFlags(vkCode, true); }); - this.modifierFlags = flags; } /** From 5716d1e4b1e368c347a1d59c9f96dea05118ac71 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Thu, 15 Oct 2026 17:27:41 +0800 Subject: [PATCH 6/7] =?UTF-8?q?refactor(input):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E5=AF=B9=E8=B4=A6=E4=B8=8E=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除重连后延迟 2s 的 reconcileKeyState:按键丢失属 master 既有行为 (reconcile 的设计本就依赖重连后的焦点事件补对账),非本 PR 引入; 计时器追赶 firstFrameWatcher deadline 的实现引入跨文件时序耦合, 不值得为一个 Minor 场景付出。正式修法应在 CONNECTED 翻转处触发对账, 留给后续单独处理。 - 精简两处启动复核的注释,保留关键时序警告。 Co-Authored-By: Claude Fable 5 --- entry/src/main/ets/pages/StreamPage.ets | 28 ++++++------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 1f531c97..78e0c085 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -1625,13 +1625,10 @@ struct StreamPage { await this.lifecycleManager.startBackgroundTask(this.context, this.appName || undefined); } - // 启动按键拦截器与修饰键自愈看门狗(设置开关与注入 API 检查在 - // startKeyInterceptor 内部完成)。启动链路的 await 期间页面可能已销毁、 - // 连接可能已终止:清理回调已停用键盘转发,过期链路重启会让物理按键 - // 再次被吞,故启动前必须复核。 - // 不能用 isConnected 判断:CONNECTED 要等首帧(firstFrameWatcher), - // 走到这里通常还是 CONNECTING,用 isConnected 会导致正常启动永不启动 - // 键盘转发;这里只需排除「已终止」,故比对 connectionState + // 启动按键拦截器与修饰键自愈看门狗(开关与注入 API 检查在 + // startKeyInterceptor 内部)。不能用 isConnected 复核:CONNECTED 等首帧 + // 才翻转,此处是 CONNECTING;用 connectionState 排除启动链路 await + // 期间已终止的过期链路,防止其重启已被清理回调停用的键盘转发 if (!this.pageDisposed && this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { this.inputHandler.startKeyInterceptor(); @@ -2133,25 +2130,12 @@ struct StreamPage { await this.launchStream(); this.streamStartedAtMs = Date.now(); - // 连接终止时已停用按键拦截器与自愈看门狗,重连成功后恢复 - //(与 startStreaming 的启动项保持一致;startKeyInterceptor 内部完成条件判断)。 - // 重连的 await 期间页面可能已销毁、连接可能再次终止,启动前同样复核; - // launchStream 因 pageDisposed 提前返回时也不得启动键盘转发。 - // 同 startStreaming:此处 isConnected 尚为 false(等首帧), - // 用 connectionState 排除「已终止」 + // 连接终止时已停用键盘转发,重连成功后恢复(条件判断在 startKeyInterceptor + // 内部;复核逻辑同 startStreaming,不能用等首帧才翻转的 isConnected) if (!this.pageDisposed && this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { this.inputHandler.startKeyInterceptor(); this.inputHandler.startModifierWatchdog(); - // 后台断连时用户仍按着的键:失焦已 releaseAllKeys 清除追踪,回前台的 - // 对账因未连接被跳过。reconcileKeyState 内部要求 CONNECTED,而首帧 - // 最迟 1.5s(firstFrameWatcher deadline)才翻转,同步调用会被跳过, - // 故延迟到其后补差量同步;期间再次断开则由其内部检查自动跳过 - setTimeout(() => { - if (!this.pageDisposed) { - this.inputHandler.reconcileKeyState(); - } - }, 2000); } await this.startClipboardSync(); From 9da88b78e8228aaba2714b5ab92c6e9d7e59112d Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Thu, 15 Oct 2026 17:44:41 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(input):=20=E5=90=AF=E5=8A=A8=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E6=8E=92=E9=99=A4=E5=81=9C=E6=AD=A2=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E4=BA=A4=E5=8F=A0=EF=BC=88isStopping=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stopStreaming/quitGame 在 await 原生 teardown 之后才翻转 DISCONNECTED, 而页面级停止入口(prepareStreamEndUi→resetState)先行停用了键盘转发。 此窗口内过期启动链路可通过 connectionState 复核重启拦截器/看门狗, 且停止流程收尾不再有 resetState 兜底,重启后将无人回收。 ViewModel 新增 isStopping:停止入口置位、startStreaming 入口与停止 正常收尾复位(teardown 异常中断时保持 true,由下一次启动解锁,保守 方向)。两处启动复核加 !isStopping。quitGame 与 stopStreaming 同构, 一并覆盖。 Co-Authored-By: Claude Fable 5 --- entry/src/main/ets/pages/StreamPage.ets | 12 +++++++----- .../src/main/ets/viewmodel/StreamViewModel.ets | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/entry/src/main/ets/pages/StreamPage.ets b/entry/src/main/ets/pages/StreamPage.ets index 78e0c085..2f9229b2 100644 --- a/entry/src/main/ets/pages/StreamPage.ets +++ b/entry/src/main/ets/pages/StreamPage.ets @@ -1627,9 +1627,10 @@ struct StreamPage { // 启动按键拦截器与修饰键自愈看门狗(开关与注入 API 检查在 // startKeyInterceptor 内部)。不能用 isConnected 复核:CONNECTED 等首帧 - // 才翻转,此处是 CONNECTING;用 connectionState 排除启动链路 await - // 期间已终止的过期链路,防止其重启已被清理回调停用的键盘转发 - if (!this.pageDisposed && + // 才翻转,此处是 CONNECTING。需排除两类过期链路:连接已终止 + // (DISCONNECTED);停止流程在原生 teardown 中尚未翻转状态(isStopping, + // 此时停止入口的 resetState 清理已先行执行,重启将无人回收) + if (!this.pageDisposed && !this.viewModel.isStopping && this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { this.inputHandler.startKeyInterceptor(); this.inputHandler.startModifierWatchdog(); @@ -2131,8 +2132,9 @@ struct StreamPage { this.streamStartedAtMs = Date.now(); // 连接终止时已停用键盘转发,重连成功后恢复(条件判断在 startKeyInterceptor - // 内部;复核逻辑同 startStreaming,不能用等首帧才翻转的 isConnected) - if (!this.pageDisposed && + // 内部;复核逻辑同 startStreaming:不能用等首帧才翻转的 isConnected, + // 并经 isStopping 排除与停止流程的交叠) + if (!this.pageDisposed && !this.viewModel.isStopping && this.viewModel.connectionState !== StreamConnectionState.DISCONNECTED) { this.inputHandler.startKeyInterceptor(); this.inputHandler.startModifierWatchdog(); 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; } /**