Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AppScope/app.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions entry/src/main/ets/components/StreamMenuManager.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);
});
}

/**
* 获取主串流菜单配置
*/
Expand Down Expand Up @@ -144,6 +194,11 @@ export class StreamMenuManager {
subtitle: '切换屏幕虚拟控制器',
value: 'toggle_controller'
},
{
title: '🔌 USB 直通主机',
subtitle: this.getUsbForwardingStatusLine(),
value: 'usb_forwarding'
},
{
title: '🔌 断开连接',
subtitle: '结束串流但保持游戏运行',
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions entry/src/main/ets/pages/SettingsPageV2.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 和蓝牙/系统手柄',
Expand Down
3 changes: 3 additions & 0 deletions entry/src/main/ets/service/SettingsService.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -209,6 +210,7 @@ export interface InputSettings {
usbDriverEnabled: boolean; // 默认启用 USB 手柄驱动
forceUsbDriverOnly: boolean; // 强制纯 USB 驱动模式
ddkHighSpeedPolling: boolean; // USB 驱动高速轮询(DDK)
usbForwardingEnabled: boolean; // USB/IP 反向隧道转发(OTG 手柄直通主机)

// 体感助手
gyroAssistEnabled: boolean;
Expand Down Expand Up @@ -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),
Expand Down
65 changes: 65 additions & 0 deletions entry/src/main/ets/service/streaming/NvHttp.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -304,6 +325,50 @@ export class NvHttp {
}
}

/**
* 查询 Sunshine USB 转发能力(反向隧道端口与一次性令牌)
* 对应 Sunshine 的 GET /api/v1/usb-forwarding,需要已配对的客户端证书
*/
async getUsbForwardingCapability(): Promise<UsbForwardingCapability> {
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;
}

/**
* 获取或创建设备唯一标识
* 优先使用缓存,其次从文件读取,最后生成新的并保存
Expand Down
15 changes: 15 additions & 0 deletions entry/src/main/ets/service/streaming/StreamingSession.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 初始化没有依赖,
Expand Down Expand Up @@ -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();
Expand Down
23 changes: 23 additions & 0 deletions entry/src/main/ets/service/usbdriver/UsbDriverService.ets
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export class UsbDriverService implements UsbDriverListener {
private pendingResetWait: Promise<void> | null = null;
// Device keys awaiting kernel HID rebind after a controller release.
private pendingResetDeviceKeys: Set<string> = new Set();
// 由 UsbForwardingService 接管、经 USB/IP 反向隧道转发给主机的设备。
// 这些设备不允许本地驱动 claim,避免与 DDK 接口独占冲突及主机双重输入。
private forwardedDeviceKeys: Set<string> = new Set();

private constructor() {
console.info(`${TAG} 创建 USB 驱动服务实例`);
Expand Down Expand Up @@ -816,10 +819,30 @@ export class UsbDriverService implements UsbDriverListener {
console.info(`${TAG} 控制器已启动: ID=${controller.getControllerId()}, Key=${deviceKey}`);
}

/**
* 设置由 USB/IP 转发接管的设备 key 集合
* 这些设备在本地驱动枚举时被跳过;清空集合后 refreshDevices() 可重新接管
*/
setForwardedDeviceKeys(keys: Set<string>): 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;
}

Expand Down
Loading
Loading