feat: 初步支持 USB 网卡桥接;删除 OTG 端点预算管理

This commit is contained in:
mofeng-git
2026-07-16 19:19:24 +08:00
parent 63c2bb4ca2
commit 213359b6c8
35 changed files with 2367 additions and 727 deletions

View File

@@ -10,6 +10,12 @@ import type {
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
NetworkInterfaceInfo,
OtgNetworkConfig,
OtgNetworkConfigUpdate,
OtgNetworkStatus,
OtgConfigUpdate,
OtgConfigResponse,
AtxConfig,
AtxConfigUpdate,
AtxDevices,
@@ -86,6 +92,28 @@ export const msdConfigApi = {
}),
}
export const otgNetworkApi = {
get: () => request<OtgNetworkConfig>('/config/otg-network'),
update: (config: OtgNetworkConfigUpdate) =>
request<OtgNetworkConfig>('/config/otg-network', {
method: 'PATCH',
body: JSON.stringify(config),
}),
status: () => request<OtgNetworkStatus>('/otg/network/status'),
interfaces: () => request<NetworkInterfaceInfo[]>('/devices/network'),
}
export const otgConfigApi = {
update: (config: OtgConfigUpdate) =>
request<OtgConfigResponse>('/config/otg', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
export interface WolHistoryEntry {
mac_address: string
updated_at: number

View File

@@ -123,7 +123,6 @@ export const systemApi = {
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string
@@ -804,6 +803,8 @@ export {
streamConfigApi,
hidConfigApi,
msdConfigApi,
otgConfigApi,
otgNetworkApi,
atxConfigApi,
audioConfigApi,
extensionsApi,

View File

@@ -263,7 +263,6 @@ export default {
progress: 'Step {current} of {total}',
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
stepExtensions: 'Extensions',
@@ -510,7 +509,7 @@ export default {
accountSubtitle: 'Manage credentials and session policy',
networkSubtitle: 'Configure web server ports, listen addresses and SSL certificate',
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
hidSubtitle: 'Configure keyboard and mouse backend with USB gadget descriptors',
hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions',
msdSubtitle: 'Manage Mass Storage Device image directory',
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
environmentSubtitle: 'System runtime environment and USB device maintenance',
@@ -712,10 +711,12 @@ export default {
supportedFormats: 'Supported Codecs',
encoderHint: 'Hardware encoders deliver lower latency and CPU usage; software encoders offer broader compatibility at a higher resource cost.',
hidSettings: 'HID Settings',
hidSettingsDesc: 'Configure keyboard and mouse control',
hidSettingsDesc: 'Configure backend, device, descriptors, and functions in order',
hidBackend: 'HID Backend',
serialDevice: 'Serial Device',
baudRate: 'Baud Rate',
otgUdc: 'OTG Device Controller',
otgUdcDesc: 'Select the UDC used by the USB gadget, or leave blank for automatic selection',
ch9329Options: 'CH9329 Options',
ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip',
ch9329HybridMouse: 'Linux Absolute Mouse Compatibility',
@@ -728,14 +729,8 @@ export default {
ch9329DescriptorReadRequired: 'Read the CH9329 descriptor successfully before saving',
ch9329DescriptorWarning: 'Saving writes CH9329 parameters; changes may not show until the device is power-cycled or reconnected',
ch9329StringLengthWarning: 'CH9329 strings are limited to 23 bytes',
otgHidProfile: 'OTG HID Functions',
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
otgEndpointBudget: 'Max Endpoints',
otgEndpointBudgetUnlimited: 'Unlimited',
otgEndpointBudgetHint: 'This is a hardware limit. If the OTG selection exceeds the real hardware endpoint count, OTG will fail.',
otgEndpointUsage: 'Endpoint usage: {used} / {limit}',
otgEndpointUsageUnlimited: 'Endpoint usage: {used} / unlimited',
otgEndpointExceeded: 'The current OTG selection needs {used} endpoints, exceeding the limit {limit}.',
otgHidProfile: 'OTG Functions',
otgHidProfileDesc: 'Select which USB functions are exposed to the host',
otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
otgKeyboardLeds: 'Keyboard LED Status',
@@ -748,9 +743,14 @@ export default {
otgFunctionConsumerDesc: 'Consumer Control keys such as volume/play/pause',
otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.',
otgProfileWarning: 'Saving reconnects the USB device; unsupported combinations show an error and restore the previous configuration',
otgFunctionMinWarning: 'Enable at least one HID function before saving',
otgNetwork: 'USB Virtual Ethernet',
otgRuntimeDegraded: 'OTG runtime is degraded',
otgNetworkDesc: 'Bridge the USB link to an existing uplink',
otgNetworkDriver: 'Host Driver Mode',
otgNetworkInterface: 'Bridge Interface',
otgNetworkNone: 'None',
otgDescriptor: 'USB Device Descriptor',
otgDescriptorDesc: 'Configure USB device identification',
vendorId: 'Vendor ID (VID)',

View File

@@ -263,7 +263,6 @@ export default {
progress: '步骤 {current} / {total}',
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键盘。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
stepExtensions: '扩展设置',
@@ -509,7 +508,7 @@ export default {
accountSubtitle: '管理登录凭据与会话策略',
networkSubtitle: '配置 Web 服务端口、监听地址与 SSL 证书',
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
hidSubtitle: '配置键盘鼠标后端与 USB Gadget 描述符',
hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能',
msdSubtitle: '管理虚拟存储设备 (MSD) 镜像目录',
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
environmentSubtitle: '系统级运行环境与 USB 设备维护',
@@ -711,10 +710,12 @@ export default {
supportedFormats: '支持的编码格式',
encoderHint: '硬件编码器延迟和 CPU 占用比软件编码低,画质预设更好',
hidSettings: 'HID 设置',
hidSettingsDesc: '配置键盘和鼠标控制',
hidSettingsDesc: '按后端、设备、描述符和功能顺序配置',
hidBackend: 'HID 后端',
serialDevice: '串口设备',
baudRate: '波特率',
otgUdc: 'OTG 设备控制器',
otgUdcDesc: '选择用于 USB Gadget 的 UDC留空时自动选择',
ch9329Options: 'CH9329 选项',
ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性',
ch9329HybridMouse: 'Linux 绝对鼠标兼容模式',
@@ -727,14 +728,8 @@ export default {
ch9329DescriptorReadRequired: '需要先成功读取 CH9329 描述符才能保存',
ch9329DescriptorWarning: '保存会写入 CH9329 参数;需要重新上电或重新插拔后才会变化',
ch9329StringLengthWarning: 'CH9329 字符串最长为 23 字节',
otgHidProfile: 'OTG HID 功能',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
otgEndpointBudget: '最大端点数量',
otgEndpointBudgetUnlimited: '无限制',
otgEndpointBudgetHint: '此为硬件限制。若超出硬件端点数量OTG 功能将无法使用。',
otgEndpointUsage: '当前端点占用:{used} / {limit}',
otgEndpointUsageUnlimited: '当前端点占用:{used} / 不限',
otgEndpointExceeded: '当前 OTG 组合需要 {used} 个端点,已超出上限 {limit}。',
otgHidProfile: 'OTG 功能',
otgHidProfileDesc: '选择对目标主机暴露的 USB 功能',
otgFunctionKeyboard: '键盘',
otgFunctionKeyboardDesc: '标准 HID 键盘设备',
otgKeyboardLeds: '键盘状态灯',
@@ -747,9 +742,14 @@ export default {
otgFunctionConsumerDesc: '音量/播放/暂停等多媒体按键',
otgFunctionMsd: '虚拟媒体MSD',
otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键盘。',
otgProfileWarning: '保存后 USB 设备重新连接;若控制器不支持当前组合,将显示失败并恢复原配置',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
otgNetwork: 'USB 虚拟网卡',
otgRuntimeDegraded: 'OTG 运行时处于降级状态',
otgNetworkDesc: '桥接到现有上联网卡',
otgNetworkDriver: '目标机驱动模式',
otgNetworkInterface: '桥接网卡',
otgNetworkNone: '无',
otgDescriptor: 'USB 设备描述符',
otgDescriptorDesc: '配置 USB 设备标识信息',
vendorId: '厂商 ID (VID)',

View File

@@ -85,7 +85,6 @@ export const useAuthStore = defineStore('auth', () => {
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string

View File

@@ -6,6 +6,7 @@ import {
audioConfigApi,
hidConfigApi,
msdConfigApi,
otgConfigApi,
rtspConfigApi,
rustdeskConfigApi,
streamConfigApi,
@@ -24,6 +25,8 @@ import type {
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
OtgConfigResponse,
OtgConfigUpdate,
StreamConfigResponse,
StreamConfigUpdate,
VideoConfig,
@@ -525,6 +528,13 @@ export const useConfigStore = defineStore('config', () => {
return response
}
async function updateOtg(update: OtgConfigUpdate): Promise<OtgConfigResponse> {
const response = await otgConfigApi.update(update)
hid.value = response.hid
msd.value = response.msd
return response
}
async function updateStream(update: StreamConfigUpdate) {
const response = await streamConfigApi.update(update)
stream.value = response
@@ -642,6 +652,7 @@ export const useConfigStore = defineStore('config', () => {
updateAudio,
updateHid,
updateMsd,
updateOtg,
updateStream,
updateWeb,
updateAtx,

View File

@@ -40,13 +40,6 @@ export enum OtgHidProfile {
Custom = "custom",
}
export enum OtgEndpointBudget {
Auto = "auto",
Five = "five",
Six = "six",
Unlimited = "unlimited",
}
export interface OtgHidFunctions {
keyboard: boolean;
mouse_relative: boolean;
@@ -67,7 +60,6 @@ export interface HidConfig {
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfig;
otg_profile?: OtgHidProfile;
otg_endpoint_budget?: OtgEndpointBudget;
otg_functions?: OtgHidFunctions;
otg_keyboard_leds?: boolean;
ch9329_port: string;
@@ -77,6 +69,22 @@ export interface HidConfig {
mouse_absolute: boolean;
}
export enum OtgNetworkDriverMode {
Ncm = "ncm",
Ecm = "ecm",
Rndis = "rndis",
}
export interface OtgNetworkConfig {
enabled: boolean;
driver_mode: OtgNetworkDriverMode;
/** Empty means select the connected NetworkManager Ethernet interface. */
bridge_interface: string;
/** Empty values are resolved from the machine identity at runtime. */
host_mac: string;
device_mac: string;
}
export interface MsdConfig {
enabled: boolean;
msd_dir: string;
@@ -286,6 +294,7 @@ export interface AppConfig {
auth: AuthConfig;
video: VideoConfig;
hid: HidConfig;
otg_network: OtgNetworkConfig;
msd: MsdConfig;
atx: AtxConfig;
audio: AudioConfig;
@@ -537,7 +546,6 @@ export interface HidConfigUpdate {
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile;
otg_endpoint_budget?: OtgEndpointBudget;
otg_functions?: OtgHidFunctionsUpdate;
otg_keyboard_leds?: boolean;
mouse_absolute?: boolean;
@@ -548,6 +556,49 @@ export interface MsdConfigUpdate {
msd_dir?: string;
}
export interface NetworkInterfaceInfo {
name: string;
interface_type: string;
state: string;
connection: string;
addresses: string[];
has_default_route: boolean;
bridge_supported: boolean;
reason?: string;
}
export enum OtgRuntimeHealth {
Healthy = "healthy",
Applying = "applying",
Degraded = "degraded",
}
export interface OtgNetworkStatus {
health: OtgRuntimeHealth;
error?: string;
}
export interface OtgConfigResponse {
hid: HidConfig;
msd: MsdConfig;
network: OtgNetworkConfig;
status: OtgNetworkStatus;
}
export interface OtgNetworkConfigUpdate {
enabled?: boolean;
driver_mode?: OtgNetworkDriverMode;
bridge_interface?: string;
host_mac?: string;
device_mac?: string;
}
export interface OtgConfigUpdate {
hid?: HidConfigUpdate;
msd?: MsdConfigUpdate;
network?: OtgNetworkConfigUpdate;
}
export interface RtspConfigResponse {
enabled: boolean;
bind: string;

View File

@@ -10,6 +10,7 @@ import { useAuthStore } from '@/stores/auth'
import {
authApi,
configApi,
otgNetworkApi,
hidApi,
streamApi,
atxConfigApi,
@@ -42,11 +43,12 @@ import type {
AtxDriverType,
ActiveLevel,
AtxDevices,
OtgEndpointBudget,
OtgHidProfile,
OtgHidFunctions,
Ch9329DescriptorConfig,
Ch9329DescriptorState,
NetworkInterfaceInfo,
OtgNetworkStatus,
} from '@/types/generated'
import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
@@ -94,7 +96,6 @@ import {
EyeOff,
Save,
Check,
HardDrive,
Power,
Server,
Menu,
@@ -125,19 +126,18 @@ const authStore = useAuthStore()
const featureVisibility = useFeatureVisibility()
const isWindows = computed(() => systemStore.platform?.mode === 'windows')
const msdAvailable = computed(() => systemStore.platform?.msd.available ?? systemStore.capabilities?.msd.available ?? false)
const activeSection = ref<SettingsSectionId>('appearance')
const mobileMenuOpen = ref(false)
const loading = ref(false)
const saved = ref(false)
const saveError = ref('')
const SETTINGS_SECTION_IDS = [
'appearance',
'account',
'network',
'video',
'hid',
'msd',
'atx',
'environment',
'ext-ttyd',
@@ -150,19 +150,19 @@ const SETTINGS_SECTION_ID_SET = new Set<string>(SETTINGS_SECTION_IDS)
const navGroups = computed(() => [
{
title: t('settings.system'),
title: t('settings.software'),
items: [
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
{ id: 'account', label: t('settings.account'), icon: User },
{ id: 'network', label: t('settings.network'), icon: Globe },
{ id: 'about', label: t('settings.about'), icon: Info },
]
},
{
title: t('settings.hardware'),
title: t('settings.system'),
items: [
{ id: 'video', label: t('settings.video'), icon: Monitor, status: config.value.video_device ? t('settings.configured') : null },
{ id: 'hid', label: t('settings.hid'), icon: Keyboard, status: config.value.hid_backend.toUpperCase() },
...(msdAvailable.value ? [{ id: 'msd', label: t('settings.msd'), icon: HardDrive }] : []),
{ id: 'video', label: t('settings.video'), icon: Monitor },
{ id: 'hid', label: t('settings.hid'), icon: Keyboard },
{ id: 'atx', label: t('settings.atx'), icon: Power },
{ id: 'environment', label: t('settings.environment'), icon: Server },
]
@@ -174,12 +174,6 @@ const navGroups = computed(() => [
{ id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare },
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
]
},
{
title: t('settings.other'),
items: [
{ id: 'about', label: t('settings.about'), icon: Info },
]
}
])
@@ -225,6 +219,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
if (value === 'access-control') return 'account'
if (value === 'ext-frpc') return 'ext-remote-access'
if (value === 'redfish') return 'third-party-access'
if (value === 'msd') return 'hid'
if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access'
return isSettingsSectionId(value) ? value : null
}
@@ -253,7 +248,6 @@ async function loadSectionData(section: SettingsSectionId) {
])
return
case 'hid':
case 'msd':
await Promise.all([
loadConfig(),
loadDevices(),
@@ -591,7 +585,6 @@ const config = ref({
hid_serial_baudrate: 9600,
hid_otg_udc: '',
hid_otg_profile: 'custom' as OtgHidProfile,
hid_otg_endpoint_budget: 'six' as OtgEndpointBudget,
hid_otg_functions: {
keyboard: true,
mouse_relative: true,
@@ -602,6 +595,9 @@ const config = ref({
hid_ch9329_hybrid_mouse: false,
msd_enabled: false,
msd_dir: '',
otg_network_enabled: false,
otg_network_driver: 'ncm' as 'ncm' | 'ecm' | 'rndis',
otg_network_interface: '',
encoder_backend: 'auto',
stun_server: '',
turn_server: '',
@@ -609,6 +605,20 @@ const config = ref({
turn_password: '',
})
const otgNetworkInterfaces = ref<NetworkInterfaceInfo[]>([])
const otgNetworkInterfacesLoaded = ref(false)
const otgNetworkStatus = ref<OtgNetworkStatus | null>(null)
function syncOtgNetworkInterface() {
const firstInterface = otgNetworkInterfaces.value[0]?.name || ''
const selectedInterfaceExists = otgNetworkInterfaces.value.some(
item => item.name === config.value.otg_network_interface,
)
if (!selectedInterfaceExists) {
config.value.otg_network_interface = firstInterface
}
}
type OtgSelfCheckLevel = 'info' | 'warn' | 'error'
type OtgCheckGroupStatus = 'ok' | 'warn' | 'error' | 'skipped'
@@ -909,78 +919,12 @@ function usbSpeedLabel(speed?: string): string {
return map[speed] || `${speed} Mbps`
}
function defaultOtgEndpointBudgetForUdc(udc?: string): OtgEndpointBudget {
return /musb/i.test(udc || '') ? 'five' as OtgEndpointBudget : 'six' as OtgEndpointBudget
}
function normalizeOtgEndpointBudget(budget: OtgEndpointBudget | undefined, udc?: string): OtgEndpointBudget {
if (!budget || budget === 'auto') {
return defaultOtgEndpointBudgetForUdc(udc)
}
return budget
}
function endpointLimitForBudget(budget: OtgEndpointBudget): number | null {
if (budget === 'unlimited') return null
return budget === 'five' ? 5 : 6
}
const effectiveOtgFunctions = computed(() => ({ ...config.value.hid_otg_functions }))
const otgEndpointLimit = computed(() =>
endpointLimitForBudget(config.value.hid_otg_endpoint_budget)
)
const otgRequiredEndpoints = computed(() => {
if (config.value.hid_backend !== 'otg') return 0
const functions = effectiveOtgFunctions.value
let endpoints = 0
if (functions.keyboard) {
endpoints += 1
if (config.value.hid_otg_keyboard_leds) endpoints += 1
}
if (functions.mouse_relative) endpoints += 1
if (functions.mouse_absolute) endpoints += 1
if (functions.consumer) endpoints += 1
if (config.value.msd_enabled) endpoints += 2
return endpoints
})
const isOtgEndpointBudgetValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true
const limit = otgEndpointLimit.value
return limit === null || otgRequiredEndpoints.value <= limit
})
const otgEndpointUsageText = computed(() => {
const limit = otgEndpointLimit.value
if (limit === null) {
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
}
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
})
const showOtgEndpointBudgetHint = computed(() =>
config.value.hid_backend === 'otg'
)
const isKeyboardLedToggleDisabled = computed(() =>
config.value.hid_backend !== 'otg' || !effectiveOtgFunctions.value.keyboard
)
function describeEndpointBudget(budget: OtgEndpointBudget): string {
switch (budget) {
case 'five':
return '5'
case 'six':
return '6'
case 'unlimited':
return t('settings.otgEndpointBudgetUnlimited')
default:
return '6'
}
}
const isHidFunctionSelectionValid = computed(() => {
if (config.value.hid_backend !== 'otg') return true
const f = config.value.hid_otg_functions
@@ -1117,16 +1061,9 @@ const isCh9329DescriptorDirty = computed(() => {
const isHidSettingsValid = computed(() =>
isHidFunctionSelectionValid.value
&& isOtgEndpointBudgetValid.value
&& isCh9329DescriptorValid.value
)
watch(() => config.value.msd_enabled, (enabled) => {
if (!enabled && activeSection.value === 'msd') {
activeSection.value = 'hid'
}
})
watch(bindMode, (mode) => {
if (mode === 'custom' && bindAddressList.value.length === 0) {
bindAddressList.value = ['']
@@ -1201,8 +1138,6 @@ const selectedBackendFormats = computed(() => {
return backend?.supported_formats || []
})
const isCh9329Backend = computed(() => config.value.hid_backend === 'ch9329')
const selectedDevice = computed(() => {
return devices.value.video.find(d => d.path === config.value.video_device)
})
@@ -1408,6 +1343,7 @@ async function changePassword() {
async function saveConfig() {
loading.value = true
saved.value = false
saveError.value = ''
try {
if (activeSection.value === 'video') {
@@ -1436,6 +1372,7 @@ async function saveConfig() {
ch9329_port: config.value.hid_serial_device || undefined,
ch9329_baudrate: config.value.hid_serial_baudrate,
ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse,
otg_udc: config.value.hid_otg_udc,
}
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
hidUpdate.ch9329_descriptor = {
@@ -1455,22 +1392,23 @@ async function saveConfig() {
serial_number: otgSerialNumber.value || undefined,
}
hidUpdate.otg_profile = 'custom'
hidUpdate.otg_endpoint_budget = config.value.hid_otg_endpoint_budget
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
}
await configStore.updateHid(hidUpdate)
if (config.value.hid_backend === 'otg') {
await configStore.updateMsd({ enabled: config.value.msd_enabled })
} else {
await configStore.updateMsd({ enabled: false })
}
}
if (activeSection.value === 'msd') {
await configStore.updateMsd({
msd_dir: config.value.msd_dir || undefined,
const otgEnabled = config.value.hid_backend === 'otg'
const response = await configStore.updateOtg({
hid: hidUpdate,
msd: {
enabled: otgEnabled && config.value.msd_enabled,
msd_dir: config.value.msd_dir || undefined,
},
network: {
enabled: otgEnabled && config.value.otg_network_enabled,
driver_mode: config.value.otg_network_driver as any,
bridge_interface: config.value.otg_network_interface,
},
})
otgNetworkStatus.value = response.status
}
if (activeSection.value !== 'hid') {
@@ -1478,7 +1416,12 @@ async function saveConfig() {
}
saved.value = true
setTimeout(() => (saved.value = false), 2000)
} catch {
} catch (error) {
saveError.value = error instanceof Error ? error.message : t('api.operationFailedDesc')
if (activeSection.value === 'hid') {
await loadConfig().catch(() => undefined)
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
}
} finally {
loading.value = false
}
@@ -1486,11 +1429,18 @@ async function saveConfig() {
async function loadConfig() {
try {
const [video, stream, hid, msd] = await Promise.all([
const [video, stream, hid, msd, otgNetwork] = await Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
configStore.refreshHid(),
configStore.refreshMsd(),
otgNetworkApi.get().catch(() => ({
enabled: false,
driver_mode: 'ncm' as const,
bridge_interface: '',
host_mac: '',
device_mac: '',
})),
])
config.value = {
@@ -1504,7 +1454,6 @@ async function loadConfig() {
hid_serial_baudrate: hid.ch9329_baudrate || 9600,
hid_otg_udc: hid.otg_udc || '',
hid_otg_profile: 'custom' as OtgHidProfile,
hid_otg_endpoint_budget: normalizeOtgEndpointBudget(hid.otg_endpoint_budget, hid.otg_udc || ''),
hid_otg_functions: {
keyboard: hid.otg_functions?.keyboard ?? true,
mouse_relative: hid.otg_functions?.mouse_relative ?? true,
@@ -1515,12 +1464,18 @@ async function loadConfig() {
hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false,
msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '',
otg_network_enabled: otgNetwork.enabled,
otg_network_driver: otgNetwork.driver_mode,
otg_network_interface: otgNetwork.bridge_interface,
encoder_backend: stream.encoder || 'auto',
stun_server: stream.stun_server || '',
turn_server: stream.turn_server || '',
turn_username: stream.turn_username || '',
turn_password: stream.turn_password || '',
}
if (otgNetworkInterfacesLoaded.value) {
syncOtgNetworkInterface()
}
if (hid.otg_descriptor) {
otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b'
@@ -1539,14 +1494,21 @@ async function loadConfig() {
} else {
clearCh9329DescriptorState()
}
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
} catch {
}
}
async function loadDevices() {
try {
devices.value = await configApi.listDevices()
const [deviceConfig, networkInterfaces] = await Promise.all([
configApi.listDevices(),
otgNetworkApi.interfaces().catch(() => []),
])
devices.value = deviceConfig
otgNetworkInterfaces.value = networkInterfaces
otgNetworkInterfacesLoaded.value = true
syncOtgNetworkInterface()
} catch {
}
}
@@ -2701,7 +2663,6 @@ watch(isWindows, () => {
>
<component :is="item.icon" class="h-4 w-4" />
<span>{{ item.label }}</span>
<Badge v-if="item.status" variant="outline" :class="['ml-auto text-xs', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge>
</button>
</div>
</nav>
@@ -2738,7 +2699,6 @@ watch(isWindows, () => {
>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
<span class="truncate">{{ item.label }}</span>
<Badge v-if="item.status" variant="outline" :class="['ml-auto text-[10px] px-1.5 py-0 h-4', activeSection === item.id ? 'border-primary-foreground/50 text-primary-foreground' : '']">{{ item.status }}</Badge>
</button>
</div>
</nav>
@@ -3080,24 +3040,16 @@ watch(isWindows, () => {
<option :value="115200">115200</option>
</select>
</div>
<div v-if="config.hid_backend === 'otg'" class="space-y-2">
<Label for="otg-udc">{{ t('settings.otgUdc') }}</Label>
<select id="otg-udc" v-model="config.hid_otg_udc" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.autoRecommended') }}</option>
<option v-for="udc in devices.udc" :key="udc.name" :value="udc.name">{{ udc.name }}</option>
</select>
<p class="text-xs text-muted-foreground">{{ t('settings.otgUdcDesc') }}</p>
</div>
<template v-if="config.hid_backend === 'ch9329'">
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.ch9329Options') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.ch9329OptionsDesc') }}</p>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.ch9329HybridMouse') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.ch9329HybridMouseDesc') }}</p>
</div>
<Switch v-model="config.hid_ch9329_hybrid_mouse" />
</div>
</div>
</div>
<Separator class="my-4" />
<div class="space-y-4">
<div>
@@ -3182,88 +3134,26 @@ watch(isWindows, () => {
{{ t('settings.ch9329DescriptorWarning') }}
</p>
</div>
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.ch9329Options') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.ch9329OptionsDesc') }}</p>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.ch9329HybridMouse') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.ch9329HybridMouseDesc') }}</p>
</div>
<Switch v-model="config.hid_ch9329_hybrid_mouse" />
</div>
</div>
</div>
</template>
<!-- OTG Descriptor Settings -->
<template v-if="config.hid_backend === 'otg'">
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.otgHidProfile') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div>
<div class="space-y-2">
<Label for="otg-endpoint-budget">{{ t('settings.otgEndpointBudget') }}</Label>
<select id="otg-endpoint-budget" v-model="config.hid_otg_endpoint_budget" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="five">5</option>
<option value="six">6</option>
<option value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</option>
</select>
<p class="text-xs text-muted-foreground">{{ otgEndpointUsageText }}</p>
</div>
<div class="space-y-3">
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_relative" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.consumer" />
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_keyboard_leds" :disabled="isKeyboardLedToggleDisabled" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="config.msd_enabled" />
</div>
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
<p v-if="showOtgEndpointBudgetHint" class="text-xs text-muted-foreground">
{{ t('settings.otgEndpointBudgetHint') }}
</p>
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: describeEndpointBudget(config.hid_otg_endpoint_budget) }) }}
</p>
</div>
<Separator class="my-4" />
<div class="space-y-4">
<div>
@@ -3323,7 +3213,118 @@ watch(isWindows, () => {
{{ t('settings.descriptorWarning') }}
</p>
</div>
<Separator class="my-4" />
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium">{{ t('settings.otgHidProfile') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.otgHidProfileDesc') }}</p>
</div>
<div class="space-y-3">
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionMouseRelative') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseRelativeDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_relative" />
</div>
<Separator />
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionMouseAbsolute') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMouseAbsoluteDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.mouse_absolute" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionKeyboard') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionKeyboardDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.keyboard" />
</div>
<Separator />
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionConsumer') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionConsumerDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_functions.consumer" />
</div>
<Separator />
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
</div>
<Switch v-model="config.hid_otg_keyboard_leds" :disabled="isKeyboardLedToggleDisabled" />
</div>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
</div>
<Switch v-model="config.msd_enabled" />
</div>
<template v-if="config.msd_enabled">
<Separator />
<div class="space-y-2">
<Label for="msd-dir">{{ t('settings.msdDir') }}</Label>
<Input id="msd-dir" v-model="config.msd_dir" placeholder="/etc/one-kvm/msd" />
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirDesc') }}</p>
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirHint') }}</p>
</div>
</template>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.otgNetwork') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.otgNetworkDesc') }}</p>
</div>
<Switch v-model="config.otg_network_enabled" />
</div>
<template v-if="config.otg_network_enabled">
<Separator />
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="otg-network-driver">{{ t('settings.otgNetworkDriver') }}</Label>
<select id="otg-network-driver" v-model="config.otg_network_driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="ncm">NCM</option>
<option value="ecm">ECM</option>
<option value="rndis">RNDIS / Windows</option>
</select>
</div>
<div class="space-y-2">
<Label for="otg-network-interface">{{ t('settings.otgNetworkInterface') }}</Label>
<select id="otg-network-interface" v-model="config.otg_network_interface" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="otgNetworkInterfaces.length === 0">
<option v-if="otgNetworkInterfaces.length === 0" value="">{{ t('settings.otgNetworkNone') }}</option>
<option
v-for="item in otgNetworkInterfaces"
:key="item.name"
:value="item.name"
>
{{ item.name }} · {{ item.interface_type }}
</option>
</select>
</div>
</div>
</template>
<p v-if="otgNetworkStatus?.health === 'degraded'" class="text-xs text-destructive">
{{ t('settings.otgRuntimeDegraded') }}: {{ otgNetworkStatus.error || t('common.error') }}
</p>
</div>
</div>
<p class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.otgProfileWarning') }}
</p>
</div>
</template>
</CardContent>
</Card>
@@ -3918,42 +3919,6 @@ watch(isWindows, () => {
</Card>
</div>
<!-- MSD Section -->
<div v-show="activeSection === 'msd' && config.msd_enabled" class="space-y-6">
<Card>
<CardHeader>
<CardTitle>{{ t('settings.msdSettings') }}</CardTitle>
<CardDescription>{{ t('settings.msdDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="isCh9329Backend" class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
<p class="font-medium">{{ t('settings.msdCh9329Warning') }}</p>
<p class="text-xs text-amber-900/80">{{ t('settings.msdCh9329WarningDesc') }}</p>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label for="msd-dir">{{ t('settings.msdDir') }}</Label>
<Input id="msd-dir" v-model="config.msd_dir" placeholder="/etc/one-kvm/msd" :disabled="isCh9329Backend" />
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirDesc') }}</p>
</div>
<p class="text-xs text-muted-foreground">{{ t('settings.msdDirHint') }}</p>
</div>
<Separator />
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">{{ t('settings.msdStatus') }}</p>
<p class="text-xs text-muted-foreground">
{{ config.msd_enabled ? t('settings.willBeEnabledAfterSave') : t('settings.disabled') }}
</p>
</div>
<Badge :variant="config.msd_enabled ? 'default' : 'secondary'">
{{ config.msd_enabled ? t('common.enabled') : t('common.disabled') }}
</Badge>
</div>
</CardContent>
</Card>
</div>
<!-- ATX Section -->
<div v-show="activeSection === 'atx'" class="space-y-6">
<Card>
@@ -5311,7 +5276,7 @@ watch(isWindows, () => {
</div>
<!-- Save Button (sticky) -->
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div v-if="['video', 'hid'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
<div class="flex items-center justify-between gap-2 sm:gap-3">
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5 min-w-0">
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
@@ -5325,6 +5290,7 @@ watch(isWindows, () => {
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span>
</p>
<p v-if="saveError" class="text-xs text-destructive">{{ saveError }}</p>
<p v-else class="text-xs text-muted-foreground hidden sm:block">{{ t('settings.unsavedChangesHint') }}</p>
<Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidSettingsValid)" @click="saveConfig">
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}

View File

@@ -78,7 +78,6 @@ const ch9329Baudrate = ref(9600)
const otgUdc = ref('')
const hidOtgProfile = ref('full_no_consumer')
const otgMsdEnabled = ref(true)
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
const otgKeyboardLeds = ref(true)
const ttydEnabled = ref(false)
@@ -203,46 +202,11 @@ const availableFps = computed(() => {
return resolution?.fps || []
})
function defaultOtgEndpointBudgetForUdc(udc?: string): 'five' | 'six' {
return /musb/i.test(udc || '') ? 'five' : 'six'
}
function endpointLimitForBudget(budget: 'five' | 'six' | 'unlimited'): number | null {
if (budget === 'unlimited') return null
return budget === 'five' ? 5 : 6
}
const otgRequiredEndpoints = computed(() => {
if (hidBackend.value !== 'otg') return 0
const functions = {
keyboard: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_keyboard',
mouseRelative: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer' || hidOtgProfile.value === 'legacy_mouse_relative',
mouseAbsolute: hidOtgProfile.value === 'full' || hidOtgProfile.value === 'full_no_consumer',
consumer: hidOtgProfile.value === 'full',
}
let endpoints = 0
if (functions.keyboard) {
endpoints += 1
if (otgKeyboardLeds.value) endpoints += 1
}
if (functions.mouseRelative) endpoints += 1
if (functions.mouseAbsolute) endpoints += 1
if (functions.consumer) endpoints += 1
if (otgMsdEnabled.value) endpoints += 2
return endpoints
})
const isOtgEndpointBudgetValid = computed(() => {
const limit = endpointLimitForBudget(otgEndpointBudget.value)
return limit === null || otgRequiredEndpoints.value <= limit
})
function applyOtgDefaults() {
if (hidBackend.value !== 'otg') return
otgEndpointBudget.value = defaultOtgEndpointBudgetForUdc(otgUdc.value)
hidOtgProfile.value = 'full_no_consumer'
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
otgKeyboardLeds.value = true
}
const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -477,13 +441,6 @@ function validateStep3(): boolean {
error.value = t('setup.selectUdc')
return false
}
if (hidBackend.value === 'otg' && !isOtgEndpointBudgetValid.value) {
error.value = t('settings.otgEndpointExceeded', {
used: otgRequiredEndpoints.value,
limit: otgEndpointBudget.value === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget.value === 'five' ? '5' : '6',
})
return false
}
return true
}
@@ -543,7 +500,6 @@ async function handleSetup() {
if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value
setupData.hid_otg_endpoint_budget = otgEndpointBudget.value
setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value
setupData.msd_enabled = otgMsdEnabled.value
}