Skip to content
4 changes: 2 additions & 2 deletions entry/src/main/ets/pages/SettingsPageV2.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 24 additions & 3 deletions entry/src/main/ets/pages/StreamPage.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -845,6 +849,7 @@ struct StreamPage {
// 停止按键拦截器、鼠标 Monitor 和 DisplaySync
InputInterceptorService.getInstance().stop();
this.inputHandler.stopMouseInterceptor();
this.inputHandler.stopModifierWatchdog();
this.stopMouseKeepAlive();

// 停止体感助手
Expand Down Expand Up @@ -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 兜底
Expand Down Expand Up @@ -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();
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
await this.startClipboardSync();
Logger.info('StreamPage', '[StreamPage] 重连串流成功');

Expand Down
2 changes: 1 addition & 1 deletion entry/src/main/ets/service/SettingsService.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment thread
qiin2333 marked this conversation as resolved.
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),
Expand Down
162 changes: 162 additions & 0 deletions entry/src/main/ets/service/streaming/StreamInputHandler.ets
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export class StreamInputHandler {
private modifierFlags: number = 0;
// ── 当前通过 ArkUI onKeyEvent 按下的 VK 键集合(用于焦点丢失时释放残留键)──
private activeVkKeys: Set<number> = new Set<number>();
// ── 修饰键卡死自愈看门狗(#153:系统快捷键吞掉修饰键 KEY_UP 时兜底)──
private modifierWatchdogTimer: number = -1;
private static readonly MODIFIER_WATCHDOG_INTERVAL: number = 300; // ms

// ── 鼠标拦截器(绕过 ArkUI 帧率限制,全速鼠标轮询)──
private mouseInterceptorActive: boolean = false;
Expand Down Expand Up @@ -132,6 +135,9 @@ export class StreamInputHandler {
*/
resetState(): void {
this.releaseAllKeys();
this.stopModifierWatchdog();
// 拦截器是消费型的,串流结束后必须停止,否则应用界面收不到物理键盘
this.stopKeyInterceptor();
Comment thread
qiin2333 marked this conversation as resolved.
// 对账记录不跨串流残留:新串流主机端状态全新,旧记录无意义
this.releasedVkKeys.clear();
this.modifierFlags = 0;
Expand Down Expand Up @@ -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: 修饰键自愈看门狗已停止');
}
}

// ==================== 鼠标高速轮询 ====================

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 方向键过滤)
Expand Down Expand Up @@ -652,6 +804,16 @@ export class StreamInputHandler {
}
}

/**
* 停止按键事件拦截器
*
* 拦截器是消费型的:被它接管的按键不会再传给应用自身。因此串流结束后必须
* 停止,否则停留在应用界面时物理键盘会全部失效(事件被吞掉且无处转发)。
*/
stopKeyInterceptor(): void {
InputInterceptorService.getInstance().stop();
}

// ==================== 映射工具 ====================

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 16 additions & 2 deletions entry/src/main/ets/viewmodel/StreamViewModel.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -293,6 +300,7 @@ export class StreamViewModel {
useVdd?: boolean
): Promise<void> {
this.connectionState = StreamConnectionState.CONNECTING;
this.isStopping = false;
this.connectionProgress = 0;
this.connectionStageText = '';
this.errorMessage = '';
Expand Down Expand Up @@ -458,6 +466,8 @@ export class StreamViewModel {
* 停止串流
*/
async stopStreaming(): Promise<void> {
this.isStopping = true;

// 重置旋转状态
this.resetRotationState();

Expand All @@ -476,8 +486,9 @@ export class StreamViewModel {
await this.streamingSession.stop();
this.streamingSession = null;
}

this.connectionState = StreamConnectionState.DISCONNECTED;
this.isStopping = false;
}

private stopFirstFrameWatcher(): void {
Expand Down Expand Up @@ -663,6 +674,8 @@ export class StreamViewModel {
* 调用服务器 API 退出当前运行的游戏并停止串流
*/
async quitGame(): Promise<void> {
this.isStopping = true;

// 调用 StreamingSession 的 quitApp 方法退出远程游戏
try {
if (this.streamingSession) {
Expand All @@ -677,8 +690,9 @@ export class StreamViewModel {
} catch (err) {
Logger.error('StreamViewModel', '退出游戏失败:', err);
}

this.connectionState = StreamConnectionState.DISCONNECTED;
this.isStopping = false;
}

/**
Expand Down
Loading