diff --git a/AppScope/app.json5 b/AppScope/app.json5 index ad4c7b4..1e3c971 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "com.alkaidlab.sdream", "vendor": "Moonlight", - "versionCode": 1000807, - "versionName": "1.0.0.807", + "versionCode": 1000812, + "versionName": "1.0.0.812", "icon": "$media:layered_icon", "label": "$string:app_name", "bundleType": "app", diff --git a/entry/src/main/ets/components/StreamMenuManager.ets b/entry/src/main/ets/components/StreamMenuManager.ets index 2e6dc14..efc6ecb 100644 --- a/entry/src/main/ets/components/StreamMenuManager.ets +++ b/entry/src/main/ets/components/StreamMenuManager.ets @@ -19,6 +19,7 @@ import { StreamingSession } from '../service/streaming/StreamingSession'; import { StreamViewModel, TouchMode } from '../viewmodel/StreamViewModel'; import { OptionPickerDialogConfig, OptionItem } from './dialogs/OptionPickerDialog'; import { ToastQueue } from '../utils/ToastQueue'; +import { UsbForwardingService } from '../service/usbdriver/UsbForwardingService'; // VirtualKey 统一定义在 CustomKeyTypes 中,此处 re-export 保持向后兼容 import { VirtualKey } from '../service/customkey/CustomKeyTypes'; @@ -99,6 +100,55 @@ export class StreamMenuManager { } } + /** + * 获取 USB 直通状态行(串流菜单展示) + */ + getUsbForwardingStatusLine(): string { + const status = UsbForwardingService.getInstance().getStatus(); + switch (status.phase) { + case 'disabled': + return '未启用,点按开启并转发 OTG 手柄'; + case 'no-device': + return '未检测到 OTG 手柄,点按重试'; + case 'starting': + return `连接中: ${status.deviceName}`; + case 'ready': + return `已直通: ${status.deviceName},点按停止`; + case 'error': { + const brief = status.message.length > 36 + ? `${status.message.substring(0, 36)}…` : status.message; + return `失败: ${brief},点按重试`; + } + default: + return '未转发,点按开始'; + } + } + + /** + * 处理 USB 直通菜单动作(ready→停止,其余→开启/重试) + */ + private handleUsbForwardingAction(): void { + const service = UsbForwardingService.getInstance(); + const phase = service.getStatus().phase; + if (phase === 'starting') { + this.showToast('USB 直通连接中,请稍候'); + return; + } + if (phase === 'ready') { + service.stopForwardingFromMenu(); + this.showToast('已停止 USB 直通,手柄交还本地处理'); + return; + } + const enableSetting = phase === 'disabled'; + service.startForwardingFromMenu(enableSetting) + .then((): void => { + this.showToast(enableSetting ? '已开启 USB 直通,正在转发' : '正在重试 USB 直通'); + }) + .catch((err: Error): void => { + console.warn(`USB 直通菜单操作失败: ${err.message}`); + }); + } + /** * 获取主串流菜单配置 */ @@ -144,6 +194,11 @@ export class StreamMenuManager { subtitle: '切换屏幕虚拟控制器', value: 'toggle_controller' }, + { + title: '🔌 USB 直通主机', + subtitle: this.getUsbForwardingStatusLine(), + value: 'usb_forwarding' + }, { title: '🔌 断开连接', subtitle: '结束串流但保持游戏运行', @@ -202,6 +257,9 @@ export class StreamMenuManager { case 'toggle_controller': this.viewModel.toggleController(); break; + case 'usb_forwarding': + this.handleUsbForwardingAction(); + break; case 'disconnect': this.callbacks.onDisconnect(); break; diff --git a/entry/src/main/ets/pages/SettingsPageV2.ets b/entry/src/main/ets/pages/SettingsPageV2.ets index b70e3a6..baefd37 100644 --- a/entry/src/main/ets/pages/SettingsPageV2.ets +++ b/entry/src/main/ets/pages/SettingsPageV2.ets @@ -207,6 +207,7 @@ struct SettingsPageV2 { @State usbDriverEnabled: boolean = false; // 默认关闭 USB 手柄驱动 @State forceUsbDriverOnly: boolean = false; // 强制纯 USB 驱动模式 @State ddkHighSpeedPolling: boolean = false; // USB 驱动高速轮询 + @State usbForwardingEnabled: boolean = false; // USB/IP 反向隧道转发 // 输入 - 屏幕控制器 @State enableOnscreenControls: boolean = false; @@ -563,6 +564,7 @@ struct SettingsPageV2 { this.usbDriverEnabled = await this.loadBoolean(SettingsKeys.USB_DRIVER_ENABLED, false); this.forceUsbDriverOnly = await this.loadBoolean(SettingsKeys.FORCE_USB_DRIVER_ONLY, false); this.ddkHighSpeedPolling = await this.loadBoolean(SettingsKeys.DDK_HIGH_SPEED_POLLING, false); + this.usbForwardingEnabled = await this.loadBoolean(SettingsKeys.USB_FORWARDING_ENABLED, false); // 输入 - 屏幕控制器 this.enableOnscreenControls = await this.loadBoolean(SettingsKeys.ENABLE_ONSCREEN_CONTROLS, false); @@ -1727,6 +1729,20 @@ struct SettingsPageV2 { }); } }, + { + title: 'USB 直通主机(实验)', + subtitle: '通过 OTG 连接的手柄经 USB/IP 反向隧道直通到 Sunshine 主机,作为原生 USB 设备工作(陀螺仪、触控板完整支持)。需 Sunshine 开启 USB 转发并安装 usbip-win2 驱动,每次串流转发一个手柄', + type: 'toggle', + value: this.usbForwardingEnabled, + action: () => { + this.usbForwardingEnabled = !this.usbForwardingEnabled; + this.saveSetting(SettingsKeys.USB_FORWARDING_ENABLED, this.usbForwardingEnabled); + ToastQueue.show({ + message: '下次串流时生效', + duration: 2500 + }); + } + }, { title: '手柄测试', subtitle: '测试 USB 和蓝牙/系统手柄', diff --git a/entry/src/main/ets/service/SettingsService.ets b/entry/src/main/ets/service/SettingsService.ets index 0c5c14c..c312c77 100644 --- a/entry/src/main/ets/service/SettingsService.ets +++ b/entry/src/main/ets/service/SettingsService.ets @@ -99,6 +99,7 @@ export class SettingsKeys { static readonly USB_DRIVER_ENABLED: string = 'settings_usb_driver_enabled'; // 默认启用 USB 手柄驱动 static readonly FORCE_USB_DRIVER_ONLY: string = 'settings_force_usb_driver_only'; // 强制纯 USB 驱动模式(禁用 GCK) static readonly DDK_HIGH_SPEED_POLLING: string = 'settings_ddk_high_speed_polling'; // USB 驱动高速轮询(DDK) + static readonly USB_FORWARDING_ENABLED: string = 'settings_usb_forwarding_enabled'; // USB/IP 反向隧道转发(OTG 手柄直通主机) // 输入 - 屏幕控制器 static readonly ENABLE_ONSCREEN_CONTROLS: string = 'settings_enable_onscreen_controls'; @@ -209,6 +210,7 @@ export interface InputSettings { usbDriverEnabled: boolean; // 默认启用 USB 手柄驱动 forceUsbDriverOnly: boolean; // 强制纯 USB 驱动模式 ddkHighSpeedPolling: boolean; // USB 驱动高速轮询(DDK) + usbForwardingEnabled: boolean; // USB/IP 反向隧道转发(OTG 手柄直通主机) // 体感助手 gyroAssistEnabled: boolean; @@ -795,6 +797,7 @@ export class SettingsService { 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), + usbForwardingEnabled: await this.getBoolean(SettingsKeys.USB_FORWARDING_ENABLED, false), // 体感助手 gyroAssistEnabled: await this.getBoolean(SettingsKeys.GYRO_ASSIST_ENABLED, false), diff --git a/entry/src/main/ets/service/streaming/NvHttp.ets b/entry/src/main/ets/service/streaming/NvHttp.ets index 5d4e85c..1399551 100644 --- a/entry/src/main/ets/service/streaming/NvHttp.ets +++ b/entry/src/main/ets/service/streaming/NvHttp.ets @@ -192,6 +192,27 @@ export class NetworkProbeSession { } } +/** + * Sunshine USB 转发能力(/api/v1/usb-forwarding) + * available 为 true 时 port/token 用于建立反向隧道 + */ +export interface UsbForwardingCapability { + available: boolean; + reason: string; + port: number; + token: string; +} + +/** /api/v1/usb-forwarding 的原始 JSON 结构 */ +interface UsbForwardingCapabilityResponse { + version?: number; + enabled?: boolean; + available?: boolean; + reason?: string; + port?: number; + token?: string; +} + export class NvHttp { private address: string; private serverCert: string | null; @@ -304,6 +325,50 @@ export class NvHttp { } } + /** + * 查询 Sunshine USB 转发能力(反向隧道端口与一次性令牌) + * 对应 Sunshine 的 GET /api/v1/usb-forwarding,需要已配对的客户端证书 + */ + async getUsbForwardingCapability(): Promise { + const baseUrl = await this.getHttpsBaseUrl(); + const url = `${baseUrl}/api/v1/usb-forwarding`; + const response = await this.doRequest(url, { + useClientCert: true, + connectTimeout: 5000, + transferTimeout: 5000 + }); + return NvHttp.parseUsbForwardingCapability(response); + } + + /** + * 解析 USB 转发能力响应(与 moonlight-qt UsbForwarding::Capability 对齐) + * {version:1, enabled:bool, available:bool, reason?:string, port?:number, token?:string(64hex)} + */ + static parseUsbForwardingCapability(body: string): UsbForwardingCapability { + const parsed = JSON.parse(body) as UsbForwardingCapabilityResponse; + if (parsed.version !== 1) { + throw new Error('USB 转发能力响应版本不受支持'); + } + const enabled = !!parsed.enabled; + const available = enabled && !!parsed.available; + const result: UsbForwardingCapability = { + available: available, + reason: parsed.reason ?? '', + port: 0, + token: '' + }; + if (available) { + const port = parsed.port ?? 0; + const token = parsed.token ?? ''; + if (!Number.isInteger(port) || port < 1 || port > 65535 || !/^[0-9a-fA-F]{64}$/.test(token)) { + throw new Error('USB 转发能力响应格式无效'); + } + result.port = port; + result.token = token; + } + return result; + } + /** * 获取或创建设备唯一标识 * 优先使用缓存,其次从文件读取,最后生成新的并保存 diff --git a/entry/src/main/ets/service/streaming/StreamingSession.ets b/entry/src/main/ets/service/streaming/StreamingSession.ets index 96c1d9d..2bd3f4f 100644 --- a/entry/src/main/ets/service/streaming/StreamingSession.ets +++ b/entry/src/main/ets/service/streaming/StreamingSession.ets @@ -25,6 +25,7 @@ import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.Abil import { display } from '@kit.ArkUI'; import { GamepadManager } from '../input/GamepadManager'; import { MoonlightButton } from '../input/GamepadTypes'; +import { UsbForwardingService } from '../usbdriver/UsbForwardingService'; import { MicrophoneStream } from '../microphone/MicrophoneStream'; import { AudioHapticFrame } from '../AudioHapticFrame'; import { AudioVibrationService } from '../AudioVibrationService'; @@ -704,6 +705,18 @@ export class StreamingSession implements Ds5TouchpadInputSink { try { await this.resolveComputer(computerId, context); + // USB/IP 反向隧道:OTG 手柄直通主机。必须先于本地 USB 驱动标记排除, + // 异步进行(含权限弹窗与主机能力查询),不阻塞会话启动 + if (this.computer && this.nvHttp) { + const fwdComputer = this.computer; + const fwdHttp = this.nvHttp; + const fwdHost = this.connectionAddress; + UsbForwardingService.getInstance() + .beginStreamForwarding(fwdComputer, fwdHost, fwdHttp, context) + .catch((err: Error) => { + console.warn(`StreamingSession: USB 转发启动异常: ${err.message}`); + }); + } await this.applyInitialBitratePolicy(); this.generateInputKey(); // 并行执行:网络请求(fetchServerInfo)与本地 native 初始化没有依赖, @@ -2270,6 +2283,8 @@ export class StreamingSession implements Ds5TouchpadInputSink { console.info('清理串流资源'); this.isRunning = false; this.stopBrightnessUpdates(); + // USB/IP 反向隧道随会话回收(stop/quit/启动失败共用此路径) + UsbForwardingService.getInstance().endStreamForwarding(); // LiStopConnection() suppresses connectionTerminated for an explicit stop, // so clear controller output here as well as in the termination callback. GamepadManager.getInstance().stopAllVibration(); diff --git a/entry/src/main/ets/service/usbdriver/UsbDriverService.ets b/entry/src/main/ets/service/usbdriver/UsbDriverService.ets index d3403dd..fbeea0b 100644 --- a/entry/src/main/ets/service/usbdriver/UsbDriverService.ets +++ b/entry/src/main/ets/service/usbdriver/UsbDriverService.ets @@ -97,6 +97,9 @@ export class UsbDriverService implements UsbDriverListener { private pendingResetWait: Promise | null = null; // Device keys awaiting kernel HID rebind after a controller release. private pendingResetDeviceKeys: Set = new Set(); + // 由 UsbForwardingService 接管、经 USB/IP 反向隧道转发给主机的设备。 + // 这些设备不允许本地驱动 claim,避免与 DDK 接口独占冲突及主机双重输入。 + private forwardedDeviceKeys: Set = new Set(); private constructor() { console.info(`${TAG} 创建 USB 驱动服务实例`); @@ -816,10 +819,30 @@ export class UsbDriverService implements UsbDriverListener { console.info(`${TAG} 控制器已启动: ID=${controller.getControllerId()}, Key=${deviceKey}`); } + /** + * 设置由 USB/IP 转发接管的设备 key 集合 + * 这些设备在本地驱动枚举时被跳过;清空集合后 refreshDevices() 可重新接管 + */ + setForwardedDeviceKeys(keys: Set): void { + this.forwardedDeviceKeys = new Set(keys); + let listed = ''; + keys.forEach((key: string): void => { + listed = listed === '' ? key : `${listed}, ${key}`; + }); + console.info(`${TAG} 转发排除列表更新: ${listed === '' ? '(空)' : listed}`); + } + /** * 检查是否应该驱动该设备 */ private shouldClaimDevice(device: usbManager.USBDevice): boolean { + if (this.forwardedDeviceKeys.size > 0) { + const deviceKey = `${device.vendorId}:${device.productId}:${device.busNum}:${device.devAddress}`; + if (this.forwardedDeviceKeys.has(deviceKey)) { + console.info(`${TAG} 设备 ${deviceKey} 已由 USB/IP 转发接管,跳过本地驱动`); + return false; + } + } return UsbDriverService.identifyKnownGamepad(device) !== null; } diff --git a/entry/src/main/ets/service/usbdriver/UsbForwardingService.ets b/entry/src/main/ets/service/usbdriver/UsbForwardingService.ets new file mode 100644 index 0000000..253ffb1 --- /dev/null +++ b/entry/src/main/ets/service/usbdriver/UsbForwardingService.ets @@ -0,0 +1,501 @@ +/* + * Moonlight for HarmonyOS + * Copyright (C) 2025 Moonlight/AlkaidLab + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +/** + * USB/IP 反向隧道转发服务 + * + * 把通过 OTG 连接的 USB 手柄经 USB/IP 协议反向隧道直通到 Sunshine 主机: + * 1. 本地 native usbip::Server 在 127.0.0.1 导出设备(USB DDK 实现) + * 2. GET /api/v1/usb-forwarding(配对客户端证书)拿到隧道端口与一次性令牌 + * 3. usbip::Tunnel 连接 Sunshine TLS 端点(47996), + * 发送 {"op":"forward","token":...,"busid":...},之后透传 USB/IP 字节流 + * 4. 主机侧 Sunshine 通过 usbip-win2/vhci 把手柄挂载为原生 USB 设备 + * + * 与 moonlight-qt UsbForwarding / moonlight-android usbip-backend 同源契约。 + * + * 被转发的设备由 UsbDriverService 排除(setForwardedDeviceKeys), + * 避免本地驱动抢占 DDK 接口与主机侧双重输入。 + */ + +import { usbManager } from '@kit.BasicServicesKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { common } from '@kit.AbilityKit'; +import { util } from '@kit.ArkTS'; +import { ComputerInfo } from '../../model/ComputerInfo'; +import { SettingsService, SettingsKeys } from '../SettingsService'; +import { UsbDriverService } from './UsbDriverService'; +import { NvHttp } from '../streaming/NvHttp'; +import { ToastQueue } from '../../utils/ToastQueue'; +import { PreferencesUtil } from '../../utils/PreferencesUtil'; +import nativeLib from 'libmoonlight_nativelib.so'; + +/** + * USB 转发对外状态(串流菜单展示用,同步可查) + * phase: + * disabled 设置开关未启用 + * no-device 开关已启用但没有可转发的 OTG 手柄 + * starting 设备已选定,隧道建立中 + * ready 隧道已建立,主机端手柄已挂载 + * error 失败(message 携带原因) + * idle 停止/未转发状态 + */ +export interface UsbForwardingStatus { + phase: string; + deviceName: string; + message: string; +} + +// ==================== native UsbIp 接口 ==================== + +interface NativeUsbIpStartServerResult { + code: number; + port?: number; + error?: string; +} + +interface NativeUsbIpAddDeviceResult { + code: number; + busId?: string; + vendorId?: number; + productId?: number; + interfaces?: number; + hasIsochronous?: number; + error?: string; +} + +interface NativeUsbIpTunnelConfig { + host: string; + port: number; + token: string; + busId: string; + clientCertPem: string; + clientKeyPem: string; + serverCertPem: string; +} + +interface NativeUsbIpResult { + code: number; + error?: string; +} + +interface NativeTunnelStateResult { + state: string; + message: string; +} + +interface NativeUsbIp { + startServer(): NativeUsbIpStartServerResult; + stopServer(): NativeUsbIpResult; + addDevice(busNum: number, devAddress: number, name: string): NativeUsbIpAddDeviceResult; + removeDevice(busId: string): NativeUsbIpResult; + startTunnel(config: NativeUsbIpTunnelConfig, onState: (state: string, message: string) => void): NativeUsbIpResult; + stopTunnel(): NativeUsbIpResult; + tunnelState(): NativeTunnelStateResult; +} + +interface NativeLibWithUsbIp { + UsbIp?: NativeUsbIp; +} + +const usbIp = (nativeLib as NativeLibWithUsbIp).UsbIp; + +const TAG = '[USB-FORWARD]'; + +/** + * USB/IP 反向隧道转发服务(单例) + * 串流会话启动时调用 beginStreamForwarding(),结束时调用 endStreamForwarding() + */ +export class UsbForwardingService { + private static instance: UsbForwardingService; + + /** 生命周期代数:begin/end 递增使旧的异步流程失效 */ + private generation: number = 0; + private active: boolean = false; + private activeBusId: string = ''; + private activeDeviceKey: string = ''; + private heldPipe: usbManager.USBDevicePipe | null = null; + private lastMessage: string = ''; + /** 状态机与展示信息 */ + private phase: string = 'idle'; + private deviceName: string = ''; + private settingEnabled: boolean = false; + /** 菜单重试所需上下文(仅当前串流会话内有效) */ + private lastComputer: ComputerInfo | null = null; + private lastHost: string = ''; + private lastNvHttp: NvHttp | null = null; + private lastContext: common.UIAbilityContext | null = null; + + private constructor() { + } + + static getInstance(): UsbForwardingService { + if (!UsbForwardingService.instance) { + UsbForwardingService.instance = new UsbForwardingService(); + } + return UsbForwardingService.instance; + } + + isActive(): boolean { + return this.active; + } + + getLastMessage(): string { + return this.lastMessage; + } + + /** 隧道当前状态(idle/connecting/ready/closed/error) */ + getTunnelState(): string { + if (!usbIp || !this.active) { + return 'idle'; + } + try { + return usbIp.tunnelState().state; + } catch (err) { + return 'idle'; + } + } + + /** + * 串流会话开始:挑选一个 OTG 手柄转发到主机(v1 单设备) + * 不抛异常、不阻塞会话启动;失败时回退为本地驱动处理。 + */ + async beginStreamForwarding( + computer: ComputerInfo, + host: string, + nvHttp: NvHttp, + context?: common.UIAbilityContext + ): Promise { + const gen = ++this.generation; + this.active = false; + this.lastMessage = ''; + this.phase = 'idle'; + // 缓存重试上下文(菜单「重试/开启」按最近一次会话参数重新发起) + this.lastComputer = computer; + this.lastHost = host; + this.lastNvHttp = nvHttp; + this.lastContext = context ?? null; + + try { + const settings = await SettingsService.getInstance().getInputSettings(); + if (gen !== this.generation) { + return; + } + this.settingEnabled = settings.usbForwardingEnabled; + if (!settings.usbForwardingEnabled) { + this.phase = 'disabled'; + return; + } + if (!usbIp) { + console.warn(`${TAG} native UsbIp 模块不可用,跳过 USB 转发`); + this.phase = 'error'; + this.lastMessage = 'native UsbIp 模块不可用'; + return; + } + if (!context) { + console.warn(`${TAG} 缺少 UIAbilityContext,跳过 USB 转发`); + return; + } + if (!host) { + console.warn(`${TAG} 缺少主机地址,跳过 USB 转发`); + return; + } + + // 入口统一回收上一轮资源:上一轮 begin 可能仍停在 await(权限弹窗、 + // capability 查询),其代数守卫返回时不会自行 teardown,这里补齐, + // 避免旧 USBDevicePipe/native server 泄漏或被新一轮覆盖。 + if (this.active || this.activeDeviceKey !== '' || this.heldPipe !== null) { + this.teardown(false); + } + + // 挑选转发目标:第一个未被本地驱动占用的已知手柄 + const activeKeys = UsbDriverService.getInstance().getActiveDeviceKeys(); + let target: usbManager.USBDevice | null = null; + let devices: Array = []; + try { + devices = usbManager.getDevices() ?? []; + } catch (err) { + console.warn(`${TAG} usbManager.getDevices 失败: ${err}`); + } + for (let i = 0; i < devices.length; i++) { + const device = devices[i]; + const key = `${device.vendorId}:${device.productId}:${device.busNum}:${device.devAddress}`; + if (activeKeys.has(key)) { + continue; + } + if (UsbDriverService.identifyKnownGamepad(device) !== null) { + target = device; + break; + } + } + if (!target) { + console.info(`${TAG} 没有可转发的 OTG 手柄`); + this.phase = 'no-device'; + return; + } + const deviceKey = `${target.vendorId}:${target.productId}:${target.busNum}:${target.devAddress}`; + const deviceName = UsbDriverService.identifyKnownGamepad(target) ?? target.name ?? 'USB 设备'; + this.deviceName = deviceName; + this.phase = 'starting'; + + // 标记排除必须先于任何 await:本地驱动的枚举/重扫描不得抢占该设备 + UsbDriverService.getInstance().setForwardedDeviceKeys(new Set([deviceKey])); + this.activeDeviceKey = deviceKey; + console.info(`${TAG} 转发目标: ${deviceName} (${deviceKey})`); + + // 启动 native USB/IP 服务 + const started = usbIp.startServer(); + if (started.code !== 0) { + this.failForwarding(gen, `USB/IP 服务启动失败: ${started.error ?? '未知错误'}`, true); + return; + } + + // USB 权限(首次使用弹系统授权框) + if (!usbManager.hasRight(target.name)) { + console.info(`${TAG} 请求 USB 设备权限: ${target.name}`); + const granted = await usbManager.requestRight(target.name); + if (gen !== this.generation) { + return; + } + if (!granted) { + this.failForwarding(gen, 'USB 设备权限被拒绝', true); + return; + } + } + + // 占住 usbManager 管道(同时使内核 HID 驱动让出接口),DDK 才能声明 + try { + this.heldPipe = usbManager.connectDevice(target); + } catch (err) { + this.failForwarding(gen, `连接 USB 设备失败: ${err}`, true); + return; + } + + // 注册到 native 服务(读描述符、生成 busid) + const added = usbIp.addDevice(target.busNum, target.devAddress, deviceName); + if (added.code !== 0) { + this.failForwarding(gen, `注册设备到 USB/IP 服务失败: ${added.error ?? '未知错误'}`, true); + return; + } + this.activeBusId = added.busId ?? ''; + if ((added.hasIsochronous ?? 0) !== 0) { + console.warn(`${TAG} 设备含同步端点(如音频),主机侧该部分功能不可用`); + } + this.active = true; + + // 查询主机能力(需要已配对) + const capability = await nvHttp.getUsbForwardingCapability(); + if (gen !== this.generation) { + return; + } + if (!capability.available) { + const reason = capability.reason || '主机未启用 USB 转发或驱动未安装'; + this.failForwarding(gen, `主机 USB 转发不可用: ${reason}`, true); + return; + } + + // 配对证书:客户端证书/私钥是 PEM 文件,服务器证书是 base64 DER + const certDir = context.filesDir; + const clientCertPem = await UsbForwardingService.readTextFile(`${certDir}/client_cert.pem`); + const clientKeyPem = await UsbForwardingService.readTextFile(`${certDir}/client_key.pem`); + if (gen !== this.generation) { + return; + } + if (!clientCertPem || !clientKeyPem || !computer.serverCert) { + this.failForwarding(gen, '配对证书缺失,请先与主机完成配对', true); + return; + } + const serverCertPem = UsbForwardingService.base64DerToPem(computer.serverCert); + + // 建立反向隧道 + const tunnelResult = usbIp.startTunnel({ + host: host, + port: capability.port, + token: capability.token, + busId: this.activeBusId, + clientCertPem: clientCertPem, + clientKeyPem: clientKeyPem, + serverCertPem: serverCertPem + }, (state: string, message: string): void => { + this.onTunnelState(gen, state, message); + }); + if (tunnelResult.code !== 0) { + this.failForwarding(gen, `反向隧道启动失败: ${tunnelResult.error ?? '未知错误'}`, true); + return; + } + console.info(`${TAG} 隧道建立中: ${host}:${capability.port} busid=${this.activeBusId}`); + } catch (err) { + // 含 capability 查询抛出的网络/解析错误 + const message = err instanceof Error ? err.message : String(err); + this.failForwarding(gen, `USB 转发失败: ${message}`, this.activeDeviceKey !== ''); + } + } + + /** 串流会话结束:释放隧道、设备与排除标记 */ + endStreamForwarding(): void { + this.generation++; + if (!this.active && this.activeDeviceKey === '' && this.heldPipe === null) { + return; + } + console.info(`${TAG} 结束 USB 转发`); + this.teardown(false); + this.phase = 'idle'; + } + + // ==================== 串流菜单接口 ==================== + + /** 当前转发状态(同步,菜单展示用) */ + getStatus(): UsbForwardingStatus { + return { + phase: this.phase, + deviceName: this.deviceName, + message: this.lastMessage + }; + } + + /** + * 菜单「停止」:断开隧道并把设备交还本地手柄驱动 + */ + stopForwardingFromMenu(): void { + this.generation++; + this.teardown(true); + this.phase = 'idle'; + } + + /** + * 菜单「重试/开启」:按最近一次会话参数重新发起转发 + * @param enableSetting 为 true 时先持久化打开设置开关(从「未启用」状态点进来的场景) + */ + async startForwardingFromMenu(enableSetting: boolean): Promise { + if (!this.lastComputer || !this.lastNvHttp || !this.lastHost) { + ToastQueue.show({ message: '串流会话信息不足,无法转发', duration: 2500 }); + return; + } + if (enableSetting) { + try { + await PreferencesUtil.put(SettingsKeys.USB_FORWARDING_ENABLED, true); + this.settingEnabled = true; + } catch (err) { + console.warn(`${TAG} 保存设置失败: ${err}`); + } + } + // 若上一轮仍在活动中(例如重试),先回收避免 DDK 接口冲突 + if (this.active || this.activeDeviceKey !== '') { + this.teardown(false); + } + await this.beginStreamForwarding( + this.lastComputer, this.lastHost, this.lastNvHttp, + this.lastContext ?? undefined + ); + } + + // ==================== 内部 ==================== + + private onTunnelState(gen: number, state: string, message: string): void { + if (gen !== this.generation) { + return; + } + if (state === 'ready') { + this.lastMessage = ''; + this.phase = 'ready'; + console.info(`${TAG} USB 直通已建立,主机端手柄已挂载`); + ToastQueue.show({ message: 'USB 手柄已直通主机', duration: 2500 }); + } else if (state === 'error') { + this.failForwarding(gen, message || 'USB 隧道中断', true); + } else if (state === 'closed') { + // 主机主动结束(游戏退出/设备移除):静默回收,允许本地驱动接管 + this.teardown(true); + this.phase = 'idle'; + } + } + + private failForwarding(gen: number, message: string, releaseToLocalDriver: boolean): void { + if (gen !== this.generation) { + return; + } + console.error(`${TAG} ${message}`); + this.lastMessage = message; + this.phase = 'error'; + this.teardown(releaseToLocalDriver); + ToastQueue.show({ message: message, duration: 3500 }); + } + + private teardown(releaseToLocalDriver: boolean): void { + if (usbIp) { + try { + usbIp.stopTunnel(); + } catch (err) { + console.warn(`${TAG} stopTunnel 异常: ${err}`); + } + if (this.activeBusId) { + try { + usbIp.removeDevice(this.activeBusId); + } catch (err) { + console.warn(`${TAG} removeDevice 异常: ${err}`); + } + } + try { + usbIp.stopServer(); + } catch (err) { + console.warn(`${TAG} stopServer 异常: ${err}`); + } + } + this.activeBusId = ''; + this.active = false; + + if (this.heldPipe !== null) { + try { + usbManager.closePipe(this.heldPipe); + } catch (err) { + console.warn(`${TAG} closePipe 异常: ${err}`); + } + this.heldPipe = null; + } + + if (this.activeDeviceKey !== '') { + UsbDriverService.getInstance().setForwardedDeviceKeys(new Set()); + if (releaseToLocalDriver) { + // 释放回本地驱动(失败回退场景);串流结束时保持设备闲置 + UsbDriverService.getInstance().refreshDevices(); + } + this.activeDeviceKey = ''; + } + } + + private static async readTextFile(path: string): Promise { + try { + if (!fileIo.accessSync(path)) { + return ''; + } + const file = await fileIo.open(path, fileIo.OpenMode.READ_ONLY); + try { + const stat = await fileIo.stat(path); + const buffer = new ArrayBuffer(stat.size); + await fileIo.read(file.fd, buffer); + const decoder = util.TextDecoder.create('utf-8'); + return decoder.decodeToString(new Uint8Array(buffer)); + } finally { + await fileIo.close(file); + } + } catch (err) { + console.warn(`${TAG} 读取 ${path} 失败: ${err}`); + return ''; + } + } + + /** base64 DER 证书 → PEM 文本(native 隧道按 PEM 解析) */ + private static base64DerToPem(base64Der: string): string { + const clean = base64Der.replace(/\s/g, ''); + const lines: string[] = []; + for (let i = 0; i < clean.length; i += 64) { + lines.push(clean.substring(i, i + 64)); + } + return `-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----\n`; + } +} diff --git a/entry/src/main/ets/service/usbdriver/index.ets b/entry/src/main/ets/service/usbdriver/index.ets index da6be22..e641df5 100644 --- a/entry/src/main/ets/service/usbdriver/index.ets +++ b/entry/src/main/ets/service/usbdriver/index.ets @@ -16,6 +16,7 @@ export { AbstractController } from './AbstractController'; export { UsbDriverListener, UsbDriverStateListener } from './UsbDriverListener'; export { UsbDriverService } from './UsbDriverService'; export type { KnownUsbGamepadInfo } from './UsbDriverService'; +export { UsbForwardingService } from './UsbForwardingService'; export { Xbox360Controller } from './Xbox360Controller'; export { XboxOneController } from './XboxOneController'; export { Dualshock4Controller } from './Dualshock4Controller'; diff --git a/entry/src/main/resources/rawfile/CHANGELOG.md b/entry/src/main/resources/rawfile/CHANGELOG.md index 2b53933..ae87386 100644 --- a/entry/src/main/resources/rawfile/CHANGELOG.md +++ b/entry/src/main/resources/rawfile/CHANGELOG.md @@ -25,6 +25,37 @@ - 最新版本放在最前面 --> +## [1.0.0.812] - 2026-09-11 +OTG 手柄 USB 直通主机(USB/IP 反向隧道,实验性) + +### 新增 +- 通过 OTG 连接的手柄可经 USB/IP 反向隧道直通 Sunshine 主机,作为原生 USB 设备工作:陀螺仪、触控板等主机驱动支持的特性可直接使用,输入不经网络手柄协议转译(同步端点如音频通道暂不支持;真机链路验证进行中)。 +- 主机侧通过配对证书 + 一次性令牌建立 TLS 反向隧道(Sunshine `usbip-forwarding` 能力),与 moonlight-qt / moonlight-android 的 USB 转发同源契约。 +- 本地 USB/IP 服务仅接受隧道专用回环端口连接,其他本机进程无法访问被导出的设备。 +- 设置中新增「USB 直通主机(实验)」开关;被转发的手柄自动从本地 USB 驱动中排除,转发失败时回退为本地驱动处理。 +- 串流菜单新增「USB 直通主机」状态项:实时显示转发状态(连接中/已直通/失败原因),可在串流中直接开启、重试或停止转发。 +- 当前每次串流转发一个手柄;需 Sunshine 开启 USB 转发并安装 usbip-win2 驱动。 + +## [1.0.0.811] - 2026-09-04 +输入法三指手势收起稳定性修复 + +### 修复 +- 修复三指手势收起输入法后因触摸事件循环导致输入法反复弹出的问题(#128)。 + +## [1.0.0.810] - 2026-08-31 +网络调度、连接信息与 DualSense 输入体验优化 + +### 新增 +- 支持将 DualSense 电量与原生触控板输入转发至串流主机(#121)。 +- 为串流中的不同网络数据流提供更精细的调度优化,提升复杂网络环境下的稳定性(#127)。 + +### 优化 +- 主机卡片与详情页会正确显示自定义 HTTP/HTTPS 端口,IPv6 地址展示更加清晰(#125)。 +- 性能浮层改为显示真实网络丢包,避免将其他类型的丢帧计入网络质量统计(#126)。 + +### 修复 +- 修复体感数据上报间隔单位不准确的问题,提升动作传感器输入稳定性(#123)。 +