feat: 新增 Linux 绝对鼠标兼容模式 #266;新增 CH9329 描述符设置

This commit is contained in:
mofeng-git
2026-06-14 20:59:23 +08:00
parent da61644dbc
commit 5c98aea7e3
21 changed files with 1403 additions and 105 deletions

View File

@@ -1,5 +1,5 @@
import { request, ApiError } from './request'
import type { CanonicalKey } from '@/types/generated'
import type { CanonicalKey, Ch9329DescriptorState } from '@/types/generated'
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
const API_BASE = '/api'
@@ -435,6 +435,14 @@ export const hidApi = {
reset: () =>
request<{ success: boolean }>('/hid/reset', { method: 'POST' }),
ch9329Descriptor: (params?: { port?: string; baudRate?: number }) => {
const query = new URLSearchParams()
if (params?.port) query.set('port', params.port)
if (params?.baudRate) query.set('baud_rate', String(params.baudRate))
const suffix = query.toString()
return request<Ch9329DescriptorState>(`/hid/ch9329/descriptor${suffix ? `?${suffix}` : ''}`)
},
consumer: async (usage: number) => {
await ensureHidConnection()
await hidWs.sendConsumer({ usage })

View File

@@ -23,6 +23,10 @@ function t(key: string, params?: Record<string, unknown>): string {
return String(i18n.global.t(key, params as any))
}
function hasTranslation(key: string): boolean {
return i18n.global.te(key)
}
export class ApiError extends Error {
status: number
@@ -52,9 +56,73 @@ function getToastKey(endpoint: string, config?: ApiRequestConfig): string {
function getErrorMessage(data: unknown, fallback: string): string {
if (data && typeof data === 'object') {
const message = (data as any).message
if (typeof message === 'string' && message.trim()) return message
if (typeof message === 'string' && message.trim()) return localizeBackendErrorMessage(message)
}
return fallback
return localizeBackendErrorMessage(fallback)
}
function extractCh9329Command(reason: string): string {
const match = reason.match(/cmd 0x([0-9a-f]{2})/i)
const cmd = match?.[1]
return cmd ? `0x${cmd.toUpperCase()}` : ''
}
function localizeHidErrorMessage(raw: string): string | null {
const match = raw.match(/^HID error \[([^\]]+)\]: (.*) \(code: ([^)]+)\)$/)
if (!match) return null
const backend = match[1] ?? ''
const reason = match[2] ?? ''
const code = match[3] ?? ''
const command = extractCh9329Command(reason)
const keyByCode: Record<string, string> = {
udc_not_configured: 'hid.errorHints.udcNotConfigured',
disabled: 'hid.errorHints.disabled',
enoent: 'hid.errorHints.hidDeviceMissing',
not_opened: 'hid.errorHints.notOpened',
port_not_found: 'hid.errorHints.portNotFound',
invalid_config: 'hid.errorHints.invalidConfig',
no_response: command ? 'hid.errorHints.noResponseWithCmd' : 'hid.errorHints.noResponse',
protocol_error: 'hid.errorHints.protocolError',
invalid_response: 'hid.errorHints.protocolError',
enxio: 'hid.errorHints.deviceDisconnected',
enodev: 'hid.errorHints.deviceDisconnected',
serial_error: 'hid.errorHints.serialError',
init_failed: 'hid.errorHints.initFailed',
shutdown: 'hid.errorHints.shutdown',
reconnecting: 'hid.errorHints.reconnecting',
worker_stopped: 'hid.errorHints.workerStopped',
}
const ioErrorCodes = new Set([
'eio',
'epipe',
'eshutdown',
'io_error',
'write_failed',
'read_failed',
'device_unavailable',
])
const key = keyByCode[code]
?? (ioErrorCodes.has(code)
? backend === 'otg'
? 'hid.errorHints.otgIoError'
: backend === 'ch9329'
? 'hid.errorHints.ch9329IoError'
: 'hid.errorHints.ioError'
: '')
if (key && hasTranslation(key)) {
return t(key, { cmd: command })
}
return t('hid.errorHints.backendError', { backend })
}
function localizeBackendErrorMessage(raw: string): string {
return localizeHidErrorMessage(raw) ?? raw
}
export async function request<T>(

View File

@@ -415,6 +415,9 @@ export default {
serialError: 'Serial communication error, check CH9329 wiring and config',
initFailed: 'CH9329 initialization failed, check serial settings and power',
shutdown: 'HID backend has stopped',
reconnecting: 'CH9329 is reconnecting. Try again shortly',
workerStopped: 'CH9329 background communication has stopped. Check the device connection, then restart HID service or save HID settings again',
backendError: '{backend} HID backend error, check device connection and configuration',
},
},
audio: {
@@ -727,6 +730,18 @@ export default {
hidBackend: 'HID Backend',
serialDevice: 'Serial Device',
baudRate: 'Baud Rate',
ch9329Options: 'CH9329 Options',
ch9329OptionsDesc: 'Configure runtime compatibility for the CH9329 serial HID chip',
ch9329HybridMouse: 'Linux Absolute Mouse Compatibility',
ch9329HybridMouseDesc: 'Keep absolute movement on absolute packets, but send buttons and wheel through relative packets',
ch9329Descriptor: 'CH9329 USB Device Descriptor',
ch9329DescriptorDesc: 'Read USB identification fields from the CH9329 chip before editing',
ch9329DescriptorLoading: 'Reading CH9329 descriptor...',
ch9329DescriptorLoadFailed: 'Failed to read CH9329 descriptor',
ch9329ConfigModeUnavailable: 'CH9329 configuration mode is unavailable. Pull SET low to read or write chip parameters; showing the last saved descriptor.',
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',

View File

@@ -414,6 +414,9 @@ export default {
serialError: '串口通信异常,请检查 CH9329 接线与配置',
initFailed: 'CH9329 初始化失败,请检查串口参数与供电',
shutdown: 'HID 后端已停止',
reconnecting: 'CH9329 正在重连,请稍后重试',
workerStopped: 'CH9329 后台通信已停止,请检查设备连接后重启 HID 服务或重新保存 HID 设置',
backendError: '{backend} HID 后端异常,请检查设备连接与配置',
},
},
audio: {
@@ -726,6 +729,18 @@ export default {
hidBackend: 'HID 后端',
serialDevice: '串口设备',
baudRate: '波特率',
ch9329Options: 'CH9329 选项',
ch9329OptionsDesc: '配置 CH9329 串口 HID 芯片的运行兼容性',
ch9329HybridMouse: 'Linux 绝对鼠标兼容模式',
ch9329HybridMouseDesc: '绝对移动仍使用绝对鼠标包,点击和滚轮改用相对鼠标包发送',
ch9329Descriptor: 'CH9329 USB 设备描述符',
ch9329DescriptorDesc: '先从 CH9329 芯片读取 USB 标识信息,读取成功后再修改',
ch9329DescriptorLoading: '正在读取 CH9329 描述符...',
ch9329DescriptorLoadFailed: '读取 CH9329 描述符失败',
ch9329ConfigModeUnavailable: 'CH9329 配置模式不可用。读取或写入芯片参数需要将 SET 拉低;当前显示上次保存的描述符。',
ch9329DescriptorReadRequired: '需要先成功读取 CH9329 描述符才能保存',
ch9329DescriptorWarning: '保存会写入 CH9329 参数;需要重新上电或重新插拔后才会变化',
ch9329StringLengthWarning: 'CH9329 字符串最长为 23 字节',
otgHidProfile: 'OTG HID 功能',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
otgEndpointBudget: '最大端点数量',

View File

@@ -54,6 +54,22 @@ export interface OtgHidFunctions {
consumer: boolean;
}
export interface Ch9329DescriptorConfig {
vendor_id: number;
product_id: number;
manufacturer: string;
product: string;
serial_number?: string;
}
export interface Ch9329DescriptorState {
descriptor: Ch9329DescriptorConfig;
manufacturer_enabled: boolean;
product_enabled: boolean;
serial_enabled: boolean;
config_mode_available: boolean;
}
export interface HidConfig {
backend: HidBackend;
otg_udc?: string;
@@ -64,6 +80,8 @@ export interface HidConfig {
otg_keyboard_leds?: boolean;
ch9329_port: string;
ch9329_baudrate: number;
ch9329_hybrid_mouse?: boolean;
ch9329_descriptor?: Ch9329DescriptorConfig;
mouse_absolute: boolean;
}
@@ -175,6 +193,11 @@ export interface EasytierConfig {
virtual_ip?: string;
}
export enum FrpcConfigMode {
Quick = "quick",
Full = "full",
}
export enum FrpProxyType {
Tcp = "tcp",
Udp = "udp",
@@ -185,11 +208,6 @@ export enum FrpProxyType {
Xtcp = "xtcp",
}
export enum FrpcConfigMode {
Quick = "quick",
Full = "full",
}
export interface FrpcConfig {
enabled: boolean;
config_mode: FrpcConfigMode;
@@ -302,6 +320,14 @@ export interface AuthConfigUpdate {
single_user_allow_multiple_sessions?: boolean;
}
export interface Ch9329DescriptorConfigUpdate {
vendor_id?: number;
product_id?: number;
manufacturer?: string;
product?: string;
serial_number?: string;
}
export interface EasytierConfigUpdate {
enabled?: boolean;
network_name?: string;
@@ -310,23 +336,6 @@ export interface EasytierConfigUpdate {
virtual_ip?: string;
}
export interface FrpcConfigUpdate {
enabled?: boolean;
config_mode?: FrpcConfigMode;
proxy_name?: string;
proxy_type?: FrpProxyType;
server_addr?: string;
server_port?: number;
token?: string;
local_ip?: string;
local_port?: number;
remote_port?: number;
custom_domain?: string;
secret_key?: string;
tls?: boolean;
custom_toml?: string;
}
export type ExtensionStatus =
| { state: "unavailable", data?: undefined }
| { state: "stopped", data?: undefined }
@@ -382,6 +391,23 @@ export interface ExtensionsStatus {
frpc: FrpcInfo;
}
export interface FrpcConfigUpdate {
enabled?: boolean;
config_mode?: FrpcConfigMode;
proxy_name?: string;
proxy_type?: FrpProxyType;
server_addr?: string;
server_port?: number;
token?: string;
local_ip?: string;
local_port?: number;
remote_port?: number | null;
custom_domain?: string | null;
secret_key?: string;
tls?: boolean;
custom_toml?: string;
}
export interface GostcConfigUpdate {
enabled?: boolean;
addr?: string;
@@ -409,6 +435,8 @@ export interface HidConfigUpdate {
backend?: HidBackend;
ch9329_port?: string;
ch9329_baudrate?: number;
ch9329_hybrid_mouse?: boolean;
ch9329_descriptor?: Ch9329DescriptorConfigUpdate;
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile;

View File

@@ -42,6 +42,8 @@ import type {
OtgEndpointBudget,
OtgHidProfile,
OtgHidFunctions,
Ch9329DescriptorConfig,
Ch9329DescriptorState,
} from '@/types/generated'
import { FrpProxyType, FrpcConfigMode } from '@/types/generated'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
@@ -561,6 +563,7 @@ const config = ref({
consumer: true,
} as OtgHidFunctions,
hid_otg_keyboard_leds: false,
hid_ch9329_hybrid_mouse: false,
msd_enabled: false,
msd_dir: '',
encoder_backend: 'auto',
@@ -953,12 +956,135 @@ const otgProductIdHex = ref('0104')
const otgManufacturer = ref('One-KVM')
const otgProduct = ref('One-KVM USB Device')
const otgSerialNumber = ref('')
const ch9329VendorIdHex = ref('1a86')
const ch9329ProductIdHex = ref('e129')
const ch9329Manufacturer = ref('WCH.CN')
const ch9329Product = ref('CH9329')
const ch9329SerialNumber = ref('')
const ch9329DescriptorLoaded = ref(false)
const ch9329DescriptorLoading = ref(false)
const ch9329DescriptorError = ref('')
const ch9329DescriptorSource = ref<{ port: string; baudrate: number } | null>(null)
const ch9329DescriptorBaseline = ref<{
vendorId: string
productId: string
manufacturer: string
product: string
serialNumber: string
} | null>(null)
const utf8Encoder = new TextEncoder()
const validateHex = (event: Event, _field: string) => {
const input = event.target as HTMLInputElement
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase()
}
function utf8ByteLength(value: string): number {
return utf8Encoder.encode(value).length
}
function applyCh9329DescriptorForm(descriptor: Ch9329DescriptorConfig, defaults = false) {
ch9329VendorIdHex.value = descriptor.vendor_id?.toString(16).padStart(4, '0') || '1a86'
ch9329ProductIdHex.value = descriptor.product_id?.toString(16).padStart(4, '0') || 'e129'
ch9329Manufacturer.value = descriptor.manufacturer || (defaults ? 'WCH.CN' : '')
ch9329Product.value = descriptor.product || (defaults ? 'CH9329' : '')
ch9329SerialNumber.value = descriptor.serial_number || ''
}
function applyCh9329DescriptorState(state: Ch9329DescriptorState) {
applyCh9329DescriptorForm(state.descriptor)
ch9329DescriptorBaseline.value = currentCh9329DescriptorForm()
if (!state.config_mode_available) {
ch9329DescriptorError.value = t('settings.ch9329ConfigModeUnavailable')
}
}
function currentCh9329DescriptorForm() {
return {
vendorId: ch9329VendorIdHex.value.toLowerCase().padStart(4, '0'),
productId: ch9329ProductIdHex.value.toLowerCase().padStart(4, '0'),
manufacturer: ch9329Manufacturer.value,
product: ch9329Product.value,
serialNumber: ch9329SerialNumber.value,
}
}
function currentCh9329DescriptorSource() {
return {
port: config.value.hid_serial_device || '',
baudrate: Number(config.value.hid_serial_baudrate) || 9600,
}
}
function clearCh9329DescriptorState() {
ch9329DescriptorLoaded.value = false
ch9329DescriptorLoading.value = false
ch9329DescriptorError.value = ''
ch9329DescriptorSource.value = null
ch9329DescriptorBaseline.value = null
}
const isCh9329DescriptorSourceCurrent = computed(() => {
if (config.value.hid_backend !== 'ch9329') return false
const source = ch9329DescriptorSource.value
if (!source) return false
const current = currentCh9329DescriptorSource()
return source.port === current.port && source.baudrate === current.baudrate
})
async function loadCh9329Descriptor() {
if (config.value.hid_backend !== 'ch9329') return
const source = currentCh9329DescriptorSource()
ch9329DescriptorLoading.value = true
ch9329DescriptorLoaded.value = false
ch9329DescriptorSource.value = null
ch9329DescriptorError.value = ''
try {
const state = await hidApi.ch9329Descriptor({
port: source.port,
baudRate: source.baudrate,
})
applyCh9329DescriptorState(state)
ch9329DescriptorLoaded.value = true
ch9329DescriptorSource.value = source
} catch (e) {
ch9329DescriptorError.value = e instanceof Error ? e.message : t('settings.ch9329DescriptorLoadFailed')
} finally {
ch9329DescriptorLoading.value = false
}
}
const isCh9329DescriptorValid = computed(() => {
if (config.value.hid_backend !== 'ch9329') return true
return utf8ByteLength(ch9329Manufacturer.value) <= 23
&& utf8ByteLength(ch9329Product.value) <= 23
&& utf8ByteLength(ch9329SerialNumber.value) <= 23
})
const canEditCh9329Descriptor = computed(() =>
config.value.hid_backend === 'ch9329'
&& ch9329DescriptorLoaded.value
&& isCh9329DescriptorSourceCurrent.value
&& !ch9329DescriptorLoading.value
)
const isCh9329DescriptorDirty = computed(() => {
if (!canEditCh9329Descriptor.value || !ch9329DescriptorBaseline.value) return false
const current = currentCh9329DescriptorForm()
const baseline = ch9329DescriptorBaseline.value
return current.vendorId !== baseline.vendorId
|| current.productId !== baseline.productId
|| current.manufacturer !== baseline.manufacturer
|| current.product !== baseline.product
|| current.serialNumber !== baseline.serialNumber
})
const isHidSettingsValid = computed(() =>
isHidFunctionSelectionValid.value
&& isOtgEndpointBudgetValid.value
&& isCh9329DescriptorValid.value
)
watch(() => config.value.msd_enabled, (enabled) => {
if (!enabled && activeSection.value === 'msd') {
activeSection.value = 'hid'
@@ -1265,13 +1391,23 @@ async function saveConfig() {
}
if (activeSection.value === 'hid') {
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
if (!isHidSettingsValid.value) {
return
}
const hidUpdate: any = {
backend: config.value.hid_backend as any,
ch9329_port: config.value.hid_serial_device || undefined,
ch9329_baudrate: config.value.hid_serial_baudrate,
ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse,
}
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
hidUpdate.ch9329_descriptor = {
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
product_id: parseInt(ch9329ProductIdHex.value, 16) || 0xe129,
manufacturer: ch9329Manufacturer.value,
product: ch9329Product.value,
serial_number: ch9329SerialNumber.value || '',
}
}
if (config.value.hid_backend === 'otg') {
hidUpdate.otg_descriptor = {
@@ -1287,9 +1423,11 @@ async function saveConfig() {
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
}
await configStore.updateHid(hidUpdate)
await configStore.updateMsd({
enabled: config.value.hid_backend === 'otg' && config.value.msd_enabled,
})
if (config.value.hid_backend === 'otg') {
await configStore.updateMsd({ enabled: config.value.msd_enabled })
} else {
await configStore.updateMsd({ enabled: false })
}
}
if (activeSection.value === 'msd') {
@@ -1298,7 +1436,9 @@ async function saveConfig() {
})
}
await loadSectionData(activeSection.value)
if (activeSection.value !== 'hid') {
await loadSectionData(activeSection.value)
}
saved.value = true
setTimeout(() => (saved.value = false), 2000)
} catch {
@@ -1335,6 +1475,7 @@ async function loadConfig() {
consumer: hid.otg_functions?.consumer ?? true,
} as OtgHidFunctions,
hid_otg_keyboard_leds: hid.otg_keyboard_leds ?? false,
hid_ch9329_hybrid_mouse: hid.ch9329_hybrid_mouse ?? false,
msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '',
encoder_backend: stream.encoder || 'auto',
@@ -1351,6 +1492,16 @@ async function loadConfig() {
otgProduct.value = hid.otg_descriptor.product || 'One-KVM USB Device'
otgSerialNumber.value = hid.otg_descriptor.serial_number || ''
}
if (hid.ch9329_descriptor) {
if (hid.backend !== 'ch9329') {
applyCh9329DescriptorForm(hid.ch9329_descriptor, true)
}
}
if (hid.backend === 'ch9329') {
await loadCh9329Descriptor()
} else {
clearCh9329DescriptorState()
}
} catch {
}
@@ -2323,8 +2474,22 @@ watch(updateChannel, async () => {
watch(() => config.value.hid_backend, () => {
otgSelfCheckResult.value = null
otgSelfCheckError.value = ''
if (config.value.hid_backend === 'ch9329') {
void loadCh9329Descriptor()
} else {
clearCh9329DescriptorState()
}
})
watch(
() => [config.value.hid_serial_device, config.value.hid_serial_baudrate],
() => {
if (config.value.hid_backend === 'ch9329' && !isCh9329DescriptorSourceCurrent.value) {
clearCh9329DescriptorState()
}
},
)
watch(() => route.query.tab, (tab) => {
const section = normalizeSettingsSection(tab)
if (section && activeSection.value !== section) {
@@ -2725,6 +2890,109 @@ watch(isWindows, () => {
</select>
</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>
<div class="flex items-start justify-between gap-3">
<div>
<h4 class="text-sm font-medium">{{ t('settings.ch9329Descriptor') }}</h4>
<p class="text-sm text-muted-foreground">{{ t('settings.ch9329DescriptorDesc') }}</p>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0" :aria-label="t('common.refresh')" :disabled="ch9329DescriptorLoading" @click="loadCh9329Descriptor">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': ch9329DescriptorLoading }" />
</Button>
</div>
</div>
<p v-if="ch9329DescriptorLoading" class="text-sm text-muted-foreground flex items-center gap-2">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t('settings.ch9329DescriptorLoading') }}
</p>
<p v-else-if="ch9329DescriptorError" class="text-sm text-destructive">
{{ ch9329DescriptorError }}
</p>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="ch9329-vid">{{ t('settings.vendorId') }}</Label>
<Input
id="ch9329-vid"
v-model="ch9329VendorIdHex"
placeholder="1a86"
maxlength="4"
:disabled="!canEditCh9329Descriptor"
@input="validateHex($event, 'ch9329-vid')"
/>
</div>
<div class="space-y-2">
<Label for="ch9329-pid">{{ t('settings.productId') }}</Label>
<Input
id="ch9329-pid"
v-model="ch9329ProductIdHex"
placeholder="e129"
maxlength="4"
:disabled="!canEditCh9329Descriptor"
@input="validateHex($event, 'ch9329-pid')"
/>
</div>
</div>
<div class="space-y-2">
<Label for="ch9329-manufacturer">{{ t('settings.manufacturer') }}</Label>
<Input
id="ch9329-manufacturer"
v-model="ch9329Manufacturer"
placeholder="WCH.CN"
maxlength="23"
:disabled="!canEditCh9329Descriptor"
/>
</div>
<div class="space-y-2">
<Label for="ch9329-product">{{ t('settings.productName') }}</Label>
<Input
id="ch9329-product"
v-model="ch9329Product"
placeholder="CH9329"
maxlength="23"
:disabled="!canEditCh9329Descriptor"
/>
</div>
<div class="space-y-2">
<Label for="ch9329-serial">{{ t('settings.serialNumber') }}</Label>
<Input
id="ch9329-serial"
v-model="ch9329SerialNumber"
:placeholder="t('settings.serialNumberAuto')"
maxlength="23"
:disabled="!canEditCh9329Descriptor"
/>
</div>
<p v-if="!ch9329DescriptorLoading && !ch9329DescriptorLoaded && !ch9329DescriptorError" class="text-xs text-muted-foreground">
{{ t('settings.ch9329DescriptorReadRequired') }}
</p>
<p v-if="!isCh9329DescriptorValid" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('settings.ch9329StringLengthWarning') }}
</p>
<p class="text-sm text-amber-600 dark:text-amber-400">
{{ t('settings.ch9329DescriptorWarning') }}
</p>
</div>
</template>
<!-- OTG Descriptor Settings -->
<template v-if="config.hid_backend === 'otg'">
<Separator class="my-4" />
@@ -4678,8 +4946,16 @@ watch(isWindows, () => {
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ t('settings.otgFunctionMinWarning') }}</span>
</p>
<p v-else-if="activeSection === 'hid' && !isCh9329DescriptorValid" 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" />
<span class="truncate">{{ t('settings.ch9329StringLengthWarning') }}</span>
</p>
<p v-else-if="activeSection === 'hid' && config.hid_backend === 'ch9329' && ch9329DescriptorLoading" 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" />
<span class="truncate">{{ t('settings.ch9329DescriptorLoading') }}</span>
</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' && !isHidFunctionSelectionValid)" @click="saveConfig">
<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') }}
</Button>
</div>