feat(web): 新增 HID 配置引导

This commit is contained in:
mofeng-git
2026-09-06 11:19:55 +08:00
parent 2c19208094
commit 5de5ee42c2
22 changed files with 1044 additions and 522 deletions

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import HidDriverDialog from '@/components/HidDriverDialog.vue'
import { readPendingHid, type PendingHid } from '@/lib/hidGuide'
import '@/sonner-overrides.css'
import { computed, KeepAlive, onMounted } from 'vue'
import { computed, KeepAlive, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
@@ -24,6 +26,10 @@ const router = useRouter()
const authStore = useAuthStore()
const systemStore = useSystemStore()
const { isDark } = useTheme()
const pendingGuide = ref<PendingHid | null>(null)
watch(() => authStore.isAuthenticated, authenticated => {
pendingGuide.value = authenticated ? readPendingHid() : null
}, { immediate: true })
onMounted(async () => {
try {
@@ -46,7 +52,8 @@ onMounted(async () => {
</script>
<template>
<RouterView v-slot="{ Component, route }">
<HidDriverDialog v-if="pendingGuide && authStore.isAuthenticated" :pending="pendingGuide" @close="pendingGuide = null" />
<RouterView v-if="!pendingGuide" v-slot="{ Component, route }">
<KeepAlive v-if="authStore.isAuthenticated">
<component :is="Component" v-if="route.name === 'Console'" />
</KeepAlive>

View File

@@ -87,8 +87,9 @@ export const streamConfigApi = {
export const hidConfigApi = {
get: () => request<HidConfig>('/config/hid'),
update: (config: HidConfigUpdate) =>
update: (config: HidConfigUpdate, signal?: AbortSignal) =>
request<HidConfig>('/config/hid', {
signal,
method: 'PATCH',
body: JSON.stringify(config),
}),

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { request } from '@/api/request'
import type { BluetoothStatus } from '@/types/bluetooth'
defineProps<{ bluetooth: BluetoothStatus | null }>()
const emit = defineEmits<{ reconfigure: []; refresh: [] }>()
const { t } = useI18n()
const busy = ref(false), error = ref('')
async function action(action: string) {
busy.value = true; error.value = ''
try { await request('/hid/bluetooth', { method: 'POST', body: JSON.stringify({ action, seconds: 120 }) }) }
catch (e) { error.value = e instanceof Error ? e.message : String(e) }
finally { busy.value = false; emit('refresh') }
}
</script>
<template>
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<Button size="sm" :disabled="busy || !bluetooth?.initialized || bluetooth?.connected" @click="action('pair')">{{ t('bluetoothHid.openPairing') }}</Button>
<Button v-if="bluetooth?.pairing_seconds" size="sm" variant="outline" :disabled="busy" @click="action('close')">{{ t('bluetoothHid.closePairing') }}</Button>
<Button size="sm" variant="outline" :disabled="busy || !bluetooth?.connected" @click="action('disconnect')">{{ t('bluetoothHid.disconnect') }}</Button>
<Button size="sm" variant="outline" :disabled="busy" @click="emit('reconfigure')">{{ t('hidGuide.repair') }}</Button>
</div>
<p v-if="error" role="alert" class="text-sm text-destructive">{{ error }}</p>
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
defineProps<{ layout: ConsoleLayout }>()
</script>
<template>
<svg
viewBox="0 0 240 96"
class="block h-24 w-full rounded-md border bg-background text-foreground"
aria-hidden="true"
>
<defs>
<linearGradient :id="`console-preview-${layout}`" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="currentColor" stop-opacity=".08" />
<stop offset="1" stop-color="currentColor" stop-opacity=".02" />
</linearGradient>
</defs>
<rect x=".5" y=".5" width="239" height="95" rx="7" fill="currentColor" opacity=".035" />
<path d="M1 7.5A7 7 0 0 1 8 .5h224a7 7 0 0 1 7 7" fill="none" stroke="currentColor" opacity=".12" />
<circle cx="10" cy="7" r="2" fill="#ef4444" opacity=".75" />
<circle cx="17" cy="7" r="2" fill="#f59e0b" opacity=".75" />
<circle cx="24" cy="7" r="2" fill="#22c55e" opacity=".75" />
<rect
:x="layout === 'sidebar' ? 32 : 8"
y="14"
:width="layout === 'sidebar' ? 200 : 224"
height="74"
rx="4"
fill="#09090b"
/>
<path
:d="layout === 'sidebar' ? 'M46 82 93 36l31 28 25-21 38 39Z' : 'M24 82 80 36l36 29 28-22 48 39Z'"
fill="none"
stroke="#71717a"
stroke-width="1"
opacity=".3"
/>
<rect
:x="layout === 'sidebar' ? 40 : 16"
y="22"
:width="layout === 'sidebar' ? 184 : 208"
height="58"
rx="2"
:fill="`url(#console-preview-${layout})`"
/>
<g v-if="layout === 'current'">
<rect x="8" y="14" width="224" height="16" rx="4" fill="var(--background)" />
<path d="M8 26h224" stroke="currentColor" opacity=".12" />
<circle cx="18" cy="22" r="3" fill="none" stroke="currentColor" opacity=".45" />
<path d="M26 20h28m-28 4h18" stroke="currentColor" stroke-width="1.5" opacity=".34" />
<g fill="none" stroke="currentColor" opacity=".5">
<rect x="139" y="18" width="14" height="8" rx="2" />
<path d="m160 19 4 3-4 3m8-6 4 3-4 3m10-6v6m7-6 5 6 5-6" />
<rect x="202" y="18" width="20" height="8" rx="4" />
</g>
</g>
<g v-else-if="layout === 'floating'">
<rect x="50" y="19" width="140" height="18" rx="9" fill="var(--background)" stroke="currentColor" stroke-opacity=".2" />
<circle cx="62" cy="28" r="4" fill="none" stroke="currentColor" opacity=".45" />
<path d="M72 26h26m-26 4h18m18-6v8m10-8 6 8 6-8m10 1 6 6 6-6m11-1v8" fill="none" stroke="currentColor" opacity=".5" />
<rect x="168" y="24" width="14" height="8" rx="4" fill="currentColor" opacity=".12" />
<circle cx="177" cy="28" r="2" fill="#22c55e" />
<path d="m114 84 6-4 6 4" fill="none" stroke="white" opacity=".45" />
</g>
<g v-else>
<rect x="8" y="14" width="24" height="74" rx="4" fill="var(--background)" />
<path d="M28 14v74" stroke="currentColor" opacity=".12" />
<circle cx="20" cy="24" r="4" fill="none" stroke="currentColor" opacity=".5" />
<g fill="none" stroke="currentColor" opacity=".52">
<rect x="15" y="35" width="10" height="7" rx="2" />
<path d="m16 52 4-4 4 4-4 4Zm0 11h8m-4-4v8" />
</g>
<circle cx="20" cy="81" r="2" fill="#22c55e" />
</g>
</svg>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
@@ -13,19 +13,13 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
import { MousePointer, Move } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi } from '@/api'
import HidDeviceOverview from '@/components/HidDeviceOverview.vue'
import HidDriverDialog from '@/components/HidDriverDialog.vue'
import { useHidConnection } from '@/composables/useHidConnection'
import { useConfigStore } from '@/stores/config'
import { HidBackend } from '@/types/generated'
import type { HidConfigUpdate } from '@/types/generated'
const props = defineProps<{
open: boolean
@@ -72,70 +66,17 @@ watch(showCursor, (newValue, oldValue) => {
}
})
// HID Device Settings (requires apply)
const hidBackend = ref<HidBackend>(HidBackend.None)
const devicePath = ref<string>('')
const baudrate = ref<number>(9600)
const applying = ref(false)
const loadingDevices = ref(false)
// Device lists
const serialDevices = ref<Array<{ path: string; name: string }>>([])
const udcDevices = ref<Array<{ name: string }>>([])
const guideOpen = ref(false)
const buttonText = computed(() => t('actionbar.hidConfig'))
// Available device paths based on backend type
const availableDevicePaths = computed(() => {
if (hidBackend.value === HidBackend.Ch9329) {
return serialDevices.value
} else if (hidBackend.value === HidBackend.Otg) {
// For OTG, we show UDC devices
return udcDevices.value.map(udc => ({
path: udc.name,
name: udc.name,
}))
}
return []
})
// Load devices
async function loadDevices() {
loadingDevices.value = true
try {
const result = await configApi.listDevices()
serialDevices.value = result.serial
udcDevices.value = result.udc
} catch (e) {
console.info('[HidConfig] Failed to load devices')
} finally {
loadingDevices.value = false
}
}
function initializeFromCurrent() {
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
showCursor.value = storedCursor
// Initialize HID device settings from system state
const hid = configStore.hid
if (hid) {
hidBackend.value = hid.backend || HidBackend.None
if (hidBackend.value === HidBackend.Ch9329) {
devicePath.value = hid.ch9329_port || ''
baudrate.value = hid.ch9329_baudrate || 9600
} else if (hidBackend.value === HidBackend.Otg) {
devicePath.value = hid.otg_udc || ''
} else {
devicePath.value = ''
}
}
const { status, bluetooth, error } = useHidConnection(computed(() => props.open && !guideOpen.value), computed(() => configStore.hid?.backend))
async function configure() {
emit('update:open', false)
await nextTick()
guideOpen.value = true
}
function toggleMouseMode() {
if (configStore.hid?.backend === HidBackend.Bluetooth) return
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
emit('update:mouseMode', newMode)
@@ -158,75 +99,11 @@ function handleThrottleChange(value: number[] | undefined) {
}))
}
// Handle backend change
function handleBackendChange(backend: unknown) {
if (typeof backend !== 'string') return
if (backend === HidBackend.Otg || backend === HidBackend.Ch9329 || backend === HidBackend.None) {
hidBackend.value = backend
} else {
return
}
// Clear device path when changing backend
devicePath.value = ''
// Auto-select first device if available
if (availableDevicePaths.value.length > 0 && availableDevicePaths.value[0]) {
devicePath.value = availableDevicePaths.value[0].path
}
}
// Handle device path change
function handleDevicePathChange(path: unknown) {
if (typeof path !== 'string') return
devicePath.value = path
}
function handleBaudrateChange(rate: unknown) {
if (typeof rate !== 'string') return
baudrate.value = Number(rate)
}
// Apply HID device configuration
async function applyHidConfig() {
applying.value = true
try {
const config: HidConfigUpdate = {
backend: hidBackend.value,
}
if (hidBackend.value === HidBackend.Ch9329) {
config.ch9329_port = devicePath.value
config.ch9329_baudrate = baudrate.value
} else if (hidBackend.value === HidBackend.Otg) {
config.otg_udc = devicePath.value
}
await configStore.updateHid(config)
// HID state will be updated via WebSocket device_info event
} catch (e) {
console.info('[HidConfig] Failed to apply config:', e)
} finally {
applying.value = false
}
}
watch(() => props.open, (isOpen) => {
if (!isOpen) return
// Load devices on first open
if (serialDevices.value.length === 0) {
loadDevices()
}
configStore.refreshHid()
.then(() => {
initializeFromCurrent()
})
.catch(() => {
initializeFromCurrent()
})
watch(() => props.open, (open) => {
if (!open) return
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
showCursor.value = localStorage.getItem('hidShowCursor') !== 'false'
void configStore.refreshHid().catch(() => undefined)
})
</script>
@@ -269,6 +146,7 @@ watch(() => props.open, (isOpen) => {
<div class="flex gap-2">
<Button
:variant="mouseMode === 'absolute' ? 'default' : 'outline'"
:disabled="configStore.hid?.backend === HidBackend.Bluetooth"
size="sm"
class="flex-1 h-8 text-xs"
@click="toggleMouseMode"
@@ -318,96 +196,11 @@ watch(() => props.open, (isOpen) => {
</div>
</div>
<!-- HID Device Settings (Requires Apply) -->
<Separator />
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
<Button
variant="ghost"
size="icon-xs"
:disabled="loadingDevices"
@click="loadDevices"
>
<RefreshCw :class="['size-3.5', loadingDevices && 'animate-spin']" />
</Button>
</div>
<!-- Backend Type -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.backend') }}</Label>
<Select
:model-value="hidBackend"
@update:model-value="handleBackendChange"
>
<SelectTrigger size="sm" class="w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="HidBackend.Otg" class="text-xs">USB OTG</SelectItem>
<SelectItem :value="HidBackend.Ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
<SelectItem :value="HidBackend.None" class="text-xs">{{ t('common.disabled') }}</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Device Path (OTG or CH9329) -->
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
<Select
:model-value="devicePath"
@update:model-value="handleDevicePathChange"
:disabled="availableDevicePaths.length === 0"
>
<SelectTrigger size="sm" class="w-full text-xs">
<SelectValue :placeholder="t('actionbar.selectDevice')" />
</SelectTrigger>
<SelectContent class="max-w-[min(360px,calc(100vw-2rem))]">
<SelectItem
v-for="device in availableDevicePaths"
:key="device.path"
:value="device.path"
:text-value="device.name"
class="text-xs"
>
<span class="block min-w-0 truncate" :title="device.name">{{ device.name }}</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Baudrate (CH9329 only) -->
<div v-if="hidBackend === HidBackend.Ch9329" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.baudrate') }}</Label>
<Select
:model-value="String(baudrate)"
@update:model-value="handleBaudrateChange"
>
<SelectTrigger size="sm" class="w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="9600" class="text-xs">9600</SelectItem>
<SelectItem value="19200" class="text-xs">19200</SelectItem>
<SelectItem value="38400" class="text-xs">38400</SelectItem>
<SelectItem value="57600" class="text-xs">57600</SelectItem>
<SelectItem value="115200" class="text-xs">115200</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Apply Button -->
<Button
class="w-full h-8 text-xs"
:disabled="applying"
@click="applyHidConfig"
>
<Loader2 v-if="applying" class="size-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
<HidDeviceOverview :hid="configStore.hid" :status="status" :bluetooth="bluetooth" :error="error" />
<Button variant="outline" class="w-full" @click="configure">{{ t('hidGuide.reconfigure') }}</Button>
</div>
</PopoverContent>
</Popover>
<HidDriverDialog v-if="guideOpen" @close="guideOpen = false" />
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { HidConfig } from '@/types/generated'
import type { BluetoothStatus } from '@/types/bluetooth'
import { hidDeviceError, hidDeviceStage, type HidDeviceStatus } from '@/lib/hidGuide'
const props = defineProps<{ hid: HidConfig | null; status?: HidDeviceStatus | null; bluetooth?: BluetoothStatus | null; error?: string }>()
const { t } = useI18n()
const connectionError = computed(() => props.error || hidDeviceError(props.status) || props.bluetooth?.error)
const stage = computed(() => hidDeviceStage(props.status, props.bluetooth ?? null, props.error))
</script>
<template>
<div class="space-y-2 text-sm">
<p class="font-medium">{{ !hid ? t('hidGuide.unconfigured') : hid.backend === 'none' ? t('hidGuide.disabled') : t(`hidGuide.driver_${hid.backend}`) }}</p>
<dl v-if="hid && hid.backend !== 'none'" class="space-y-1 break-words">
<div v-if="hid.backend === 'otg'"><dt class="inline text-muted-foreground">UDC: </dt><dd class="inline">{{ hid.otg_udc || status?.backend === 'otg' && t('hidGuide.legacyAuto') || '' }}</dd></div>
<template v-if="hid.backend === 'ch9329'">
<div><dt class="inline text-muted-foreground">{{ t('hidGuide.device_ch9329') }}: </dt><dd class="inline">{{ hid.ch9329_port || '—' }}</dd></div>
<div><dt class="inline text-muted-foreground">{{ t('actionbar.baudrate') }}: </dt><dd class="inline">{{ hid.ch9329_baudrate }}</dd></div>
</template>
<template v-if="hid.backend === 'bluetooth'">
<div><dt class="inline text-muted-foreground">{{ t('bluetoothHid.adapter') }}: </dt><dd class="inline">{{ hid.bluetooth.adapter }} <span v-if="bluetooth?.adapter_address">· {{ bluetooth.adapter_address }}</span></dd></div>
<div><dt class="inline text-muted-foreground">{{ t('bluetoothHid.name') }}: </dt><dd class="inline">{{ hid.bluetooth.name }}</dd></div>
<div v-if="bluetooth?.peer"><dt class="inline text-muted-foreground">{{ t('hidGuide.host') }}: </dt><dd class="inline">{{ bluetooth.devices.find(d => d.address === bluetooth?.peer)?.name }} · {{ bluetooth.peer }}</dd></div>
</template>
</dl>
<p v-if="hid && hid.backend !== 'none'" role="status" :class="!connectionError && stage === 'ready' ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'">
{{ t(`hidGuide.${stage}`) }}
<span v-if="bluetooth?.pairing_seconds"> · {{ bluetooth.pairing_seconds }}s</span>
</p>
<p v-if="connectionError" role="alert" class="text-destructive break-words">{{ connectionError }}</p>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfigStore } from '@/stores/config'
import { useHidConnection } from '@/composables/useHidConnection'
import { Button } from '@/components/ui/button'
import HidDeviceOverview from './HidDeviceOverview.vue'
import HidDriverDialog from './HidDriverDialog.vue'
import BluetoothHidSettings from './BluetoothHidSettings.vue'
const props = defineProps<{ active: boolean; dirty?: boolean }>()
const emit = defineEmits<{ applied: []; discard: [] }>()
const { t } = useI18n(), store = useConfigStore(), open = ref(false)
const active = computed(() => props.active && !open.value)
const backend = computed(() => store.hid?.backend)
const { status, bluetooth, error, restart } = useHidConnection(active, backend)
</script>
<template>
<section class="rounded-lg border p-5 space-y-4">
<h3 class="font-semibold">{{ t('hidGuide.deviceTitle') }}</h3>
<HidDeviceOverview :hid="store.hid" :status="status" :bluetooth="bluetooth" :error="error" />
<Button variant="outline" @click="open = true">{{ t(store.hid ? 'hidGuide.reconfigure' : 'hidGuide.configure') }}</Button>
</section>
<section v-if="store.hid?.backend === 'bluetooth'" class="rounded-lg border p-5 space-y-4">
<h3 class="font-semibold">{{ t('hidGuide.features') }}</h3>
<BluetoothHidSettings :bluetooth="bluetooth" @refresh="restart" @reconfigure="open = true" />
</section>
<HidDriverDialog v-if="open" :dirty="dirty" @close="open = false" @applied="emit('applied')" @discard="emit('discard')" />
</template>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useConfigStore } from '@/stores/config'
import { request } from '@/api/request'
import type { HidConfig, MsdConfig } from '@/types/generated'
import { useHidConnection } from '@/composables/useHidConnection'
import { deviceRequest, selectionFrom, matchesSelection, writePendingHid, pendingHidKey, type PendingHid, type HidSelection } from '@/lib/hidGuide'
import HidDriverForm from './HidDriverForm.vue'
import HidWiringDiagram from './HidWiringDiagram.vue'
import HidDeviceOverview from './HidDeviceOverview.vue'
const props = defineProps<{ dirty?: boolean; pending?: PendingHid | null }>()
const emit = defineEmits<{ close: []; applied: []; discard: [] }>()
const { t } = useI18n(), store = useConfigStore()
const draft = ref<HidSelection>(props.pending ? JSON.parse(JSON.stringify(props.pending.selection)) : selectionFrom(store.hid))
const valid = ref(false), busy = ref(false), error = ref(''), applied = ref(false), loaded = ref(false)
const acknowledged = ref(!props.dirty), uncertain = ref(false)
const pairingStarted = ref(false), autoPair = ref(false), hasApplied = ref(false)
const connectionStarted = ref(Date.now()), connectionTimedOut = ref(false)
const disabledUsb = ref<string[]>([])
const autoSubmitPending = ref(props.pending?.phase === 'selected')
let disposed = false, closed = false
async function cleanupPairing() {
if (hasApplied.value && store.hid?.backend === 'bluetooth') {
await request('/hid/bluetooth', { method: 'POST', body: JSON.stringify({ action: 'close' }) }, { toastOnError: false }).catch(() => undefined)
}
}
onUnmounted(() => {
disposed = true; autoPair.value = false; autoSubmitPending.value = false
if (!closed && !busy.value) void cleanupPairing()
})
const backend = computed(() => applied.value ? store.hid?.backend : undefined)
const active = computed(() => applied.value && !busy.value)
const { status, bluetooth, error: statusError, restart } = useHidConnection(active, backend)
watch(status, () => { connectionTimedOut.value = Date.now() - connectionStarted.value >= 120000 })
const ready = computed(() => store.hid?.backend === 'none' || (store.hid?.backend === 'bluetooth' ? bluetooth.value?.ready : status.value?.online))
function remember(phase: PendingHid['phase']) { if (props.pending) writePendingHid({ selection: draft.value, phase }) }
async function readConfig<T>(path: string): Promise<T> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try { return await request<T>(path, { signal: controller.signal }, { toastOnError: false }) }
finally { clearTimeout(timeout) }
}
async function refreshHid() {
const hid = await readConfig<HidConfig>('/config/hid')
if (!disposed) store.hid = hid
return hid
}
async function refreshMsd() {
const msd = await readConfig<MsdConfig>('/config/msd')
if (!disposed) store.msd = msd
return msd
}
async function refreshConfigs() {
const results = await Promise.allSettled([refreshHid(), refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
emit('applied')
const failure = results.find(r => r.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
async function apply() {
if (disposed || busy.value || !valid.value || !loaded.value) return
autoSubmitPending.value = false
busy.value = true; error.value = ''; uncertain.value = false
remember('applying')
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
await store.updateHid(deviceRequest(draft.value), controller.signal)
applied.value = true; hasApplied.value = true; connectionStarted.value = Date.now(); remember('applied'); autoPair.value = draft.value.backend === 'bluetooth'
await refreshConfigs()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
// Never repeat an ambiguous reset automatically, including after a browser refresh.
if (!applied.value) {
try {
const current = await refreshHid()
if (matchesSelection(current, draft.value)) {
uncertain.value = true
error.value += ` ${t('hidGuide.uncertain')}`
}
} catch { uncertain.value = true }
}
} finally { clearTimeout(timeout); busy.value = false; if (disposed && !closed) void cleanupPairing() }
}
async function action(action: 'pair' | 'close') {
if (disposed || busy.value) return
busy.value = true; error.value = ''
try {
await request('/hid/bluetooth', { method: 'POST', body: JSON.stringify({ action, seconds: 120 }) })
if (action === 'pair') pairingStarted.value = true
} catch (e) { error.value = e instanceof Error ? e.message : String(e) }
finally { busy.value = false; if (disposed && !closed) void cleanupPairing(); else restart() }
}
watch(() => bluetooth.value?.initialized, initialized => {
if (initialized && autoPair.value && !pairingStarted.value && !busy.value) {
autoPair.value = false
void action('pair')
}
})
function applyPending() {
if (!autoSubmitPending.value || !valid.value || !loaded.value || disposed) return
if (props.pending && JSON.stringify(deviceRequest(draft.value)) !== JSON.stringify(deviceRequest(props.pending.selection))) {
autoSubmitPending.value = false
error.value = t('hidGuide.resumeRetry')
return
}
void apply()
}
watch(valid, applyPending)
async function edit() {
if (store.hid?.backend === 'bluetooth' && bluetooth.value?.initialized) {
await action('close')
if (error.value) return
}
applied.value = false; autoPair.value = false; pairingStarted.value = false; uncertain.value = false
}
async function close() {
if (busy.value) return
autoPair.value = false
if (hasApplied.value && store.hid?.backend === 'bluetooth' && (bluetooth.value?.initialized || pairingStarted.value)) {
await action('close')
if (error.value) return
}
if (props.pending) sessionStorage.removeItem(pendingHidKey)
closed = true
emit('close')
}
async function load() {
loaded.value = false; error.value = ''
try {
await refreshHid()
if (!props.pending) draft.value = selectionFrom(store.hid)
if (store.hid?.backend === 'otg') {
const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
disabledUsb.value = [msd.enabled ? t('hidGuide.msd') : '', network.enabled ? t('hidGuide.network') : '', audio.enabled ? t('hidGuide.audio') : ''].filter(Boolean)
}
if (props.pending && props.pending.phase !== 'selected') {
if (store.hid && matchesSelection(store.hid, props.pending.selection)) {
applied.value = true; hasApplied.value = true; remember('applied')
// The previous reset may have succeeded. Resume observation, never clear again.
} else error.value = t('hidGuide.resumeRetry')
}
loaded.value = true
applyPending()
} catch (e) { error.value = e instanceof Error ? e.message : String(e) }
}
onMounted(load)
</script>
<template>
<Dialog :open="true" @update:open="value => { if (!value) void close() }">
<DialogContent :show-close-button="!busy" class="w-[calc(100vw-2rem)] sm:max-w-[520px] max-h-[calc(100dvh-2rem)] overflow-y-auto" @escape-key-down="event => { if (busy) event.preventDefault() }" @interact-outside="event => { if (busy) event.preventDefault() }">
<DialogHeader>
<DialogTitle>{{ t('hidGuide.configure') }}</DialogTitle>
<DialogDescription>{{ t(applied ? 'hidGuide.appliedHelp' : 'hidGuide.draftHelp') }}</DialogDescription>
</DialogHeader>
<div v-if="!acknowledged" class="space-y-4">
<p>{{ t('hidGuide.dirty') }}</p>
<div class="flex flex-wrap gap-2">
<Button variant="outline" @click="emit('close')">{{ t('hidGuide.returnSave') }}</Button>
<Button @click="acknowledged = true; emit('discard')">{{ t('hidGuide.discard') }}</Button>
</div>
</div>
<template v-else>
<HidDriverForm v-if="!applied" v-model="draft" :locked="busy || !loaded" @valid="valid = $event" />
<template v-else>
<HidWiringDiagram :backend="draft.backend" />
<HidDeviceOverview :hid="store.hid" :status="status" :bluetooth="bluetooth" :error="statusError" />
<template v-if="draft.backend === 'bluetooth'">
<p class="text-sm">{{ t('hidGuide.pairInstructions', { name: store.hid?.bluetooth.name }) }}</p>
<p v-if="pairingStarted && !bluetooth?.pairing_seconds && !bluetooth?.peer && !ready" class="text-sm">{{ t('hidGuide.pairTimeout') }}</p>
<Button v-if="!ready && !bluetooth?.pairing_seconds" variant="outline" :disabled="busy || !bluetooth?.initialized" @click="action('pair')">{{ t('hidGuide.reopenPairing') }}</Button>
</template>
<p v-if="connectionTimedOut && !ready && draft.backend !== 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.connectionTimeout') }}</p>
</template>
<p v-if="!applied && store.hid?.backend === 'otg' && draft.backend !== 'otg' && disabledUsb.length" class="text-sm text-warning">{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}</p>
<p v-if="!applied && draft.backend === 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.resetWarning') }}</p>
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
<DialogFooter class="gap-2">
<Button variant="outline" :disabled="busy" @click="close">{{ t(applied ? ready ? 'hidGuide.done' : 'hidGuide.later' : props.pending ? 'hidGuide.configureLater' : 'common.cancel') }}</Button>
<Button v-if="uncertain && !applied" variant="outline" :disabled="busy" @click="applied = true; hasApplied = true; remember('applied'); error = ''">{{ t('hidGuide.checkConnection') }}</Button>
<Button v-if="!loaded" variant="outline" @click="load">{{ t('common.refresh') }}</Button>
<Button v-if="!applied" :disabled="busy || !loaded || !valid" @click="apply">{{ t(busy ? 'actionbar.applying' : 'common.apply') }}</Button>
<Button v-else-if="!ready" variant="outline" :disabled="busy" @click="edit()">{{ t('hidGuide.reconfigure') }}</Button>
</DialogFooter>
</template>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { configApi } from '@/api'
import { request } from '@/api/request'
import type { BluetoothAdapter } from '@/types/bluetooth'
import { selectDevice, validName, type HidSelection, type Driver } from '@/lib/hidGuide'
import HidWiringDiagram from './HidWiringDiagram.vue'
const props = defineProps<{ modelValue: HidSelection; locked?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [HidSelection]; valid: [boolean] }>()
const { t } = useI18n()
const options = ref<Array<{ value: string; label: string }>>([])
const loading = ref(false), error = ref(''), missing = ref('')
let generation = 0
const drivers: Driver[] = ['otg', 'ch9329', 'bluetooth', 'none']
const selected = computed(() => props.modelValue.backend === 'otg' ? props.modelValue.otg_udc : props.modelValue.backend === 'ch9329' ? props.modelValue.ch9329_port : props.modelValue.bluetooth.adapter)
function device(value: string) {
const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } }
if (draft.backend === 'otg') draft.otg_udc = value
if (draft.backend === 'ch9329') draft.ch9329_port = value
if (draft.backend === 'bluetooth') draft.bluetooth.adapter = value
emit('update:modelValue', draft)
}
async function refresh() {
const own = ++generation
options.value = []; error.value = ''; missing.value = ''; loading.value = true
try {
if (props.modelValue.backend === 'none') return
let next: typeof options.value
if (props.modelValue.backend === 'bluetooth') {
next = (await request<BluetoothAdapter[]>('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` }))
} else {
const devices = await configApi.listDevices()
next = props.modelValue.backend === 'otg' ? devices.udc.map(d => ({ value: d.name, label: d.name })) : devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` }))
}
if (own !== generation) return
options.value = next
if (selected.value && !next.some(d => d.value === selected.value)) missing.value = t('hidGuide.missingDevice', { device: selected.value })
device(selectDevice(selected.value, next.map(d => d.value)))
if (!next.length) error.value = t('hidGuide.noDevices')
} catch (e) { if (own === generation) error.value = e instanceof Error ? e.message : String(e) }
finally { if (own === generation) loading.value = false }
}
const valid = computed(() => props.modelValue.backend === 'none' || (!loading.value && !error.value && options.value.some(d => d.value === selected.value)
&& (props.modelValue.backend !== 'bluetooth' || validName(props.modelValue.bluetooth.name))
&& (props.modelValue.backend !== 'ch9329' || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate))))
watch(valid, value => emit('valid', value), { immediate: true })
watch(() => props.modelValue.backend, refresh, { immediate: true })
onUnmounted(() => generation++)
</script>
<template>
<fieldset :disabled="locked" class="space-y-4 min-w-0">
<label class="block space-y-1 text-sm">
<span>{{ t('hidGuide.driver') }}</span>
<select class="w-full rounded-md border bg-background px-3 py-2" :value="modelValue.backend" @change="emit('update:modelValue', { ...modelValue, backend: ($event.target as HTMLSelectElement).value as Driver })">
<option v-for="driver in drivers" :key="driver" :value="driver">{{ t(`hidGuide.driver_${driver}`) }}</option>
</select>
</label>
<HidWiringDiagram :backend="modelValue.backend" />
<label v-if="modelValue.backend !== 'none'" class="block space-y-1 text-sm">
<span>{{ t(`hidGuide.device_${modelValue.backend}`) }}</span>
<select class="w-full rounded-md border bg-background px-3 py-2" :value="selected" :disabled="loading" @change="device(($event.target as HTMLSelectElement).value)">
<option value="" disabled>{{ t('hidGuide.selectDevice') }}</option>
<option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</label>
<label v-if="modelValue.backend === 'ch9329'" class="block space-y-1 text-sm">
<span>{{ t('actionbar.baudrate') }}</span>
<select class="w-full rounded-md border bg-background px-3 py-2" :value="modelValue.ch9329_baudrate" @change="emit('update:modelValue', { ...modelValue, ch9329_baudrate: Number(($event.target as HTMLSelectElement).value) })">
<option v-for="rate in [9600, 19200, 38400, 57600, 115200]" :key="rate">{{ rate }}</option>
</select>
</label>
<label v-if="modelValue.backend === 'bluetooth'" class="block space-y-1 text-sm">
<span>{{ t('bluetoothHid.name') }}</span>
<Input :model-value="modelValue.bluetooth.name" @update:model-value="emit('update:modelValue', { ...modelValue, bluetooth: { ...modelValue.bluetooth, name: String($event) } })" />
<span v-if="!validName(modelValue.bluetooth.name)" class="text-destructive text-xs">{{ t('hidGuide.nameInvalid') }}</span>
</label>
<p v-if="missing" class="text-sm text-warning">{{ missing }}</p>
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
<Button v-if="modelValue.backend !== 'none'" type="button" variant="outline" size="sm" :disabled="loading" @click="refresh">{{ t('common.refresh') }}</Button>
</fieldset>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
// Static orthographic line art; coordinates are shared by all wiring diagrams.
defineProps<{ kind: 'kvm' | 'computer' }>()
</script>
<template>
<g fill="none" stroke="currentColor" stroke-width="1.35" stroke-linejoin="round" stroke-linecap="round">
<template v-if="kind === 'kvm'">
<path d="M0 42 66 12 108 35 42 65Z" class="device-face" />
<path d="M0 42v22l42 23V65m0 22 66-30V35" />
<path d="m9 48 25 14v12L9 60Z" class="device-detail" />
<path d="m51 65 17-8v9l-17 8Zm23-11 17-8v9l-17 8Z" />
<path d="m20 39 33-15m-25 19 33-15m-25 19 33-15m-25 19 33-15" class="device-detail" />
<path d="m7 68 4 2m24 13 4 2m14-1 4-2m39-17 4-2" class="device-detail" />
<circle cx="98" cy="46" r="1.5" class="device-indicator" />
</template>
<template v-else-if="kind === 'computer'">
<path d="M19 4 100 22v59L19 63Z" class="device-face" />
<path d="m19 4 5-3 81 18v59l-5 3M100 22l5-3" />
<path d="m25 13 69 15v43L25 56Z" class="device-detail" />
<path d="M19 63 0 83l81 19 19-21" class="device-face" />
<path d="M0 83v4l81 19 19-21v-4m-19 21v4" />
<path d="m24 70 62 14-7 8-62-14Zm-1 5 59 13M36 73l-6 7m18-4-6 7m18-4-6 7m18-4-6 7" class="device-detail" />
<path d="m34 85 20 5-4 4-20-5Z" class="device-detail" />
<!-- USB socket on the laptop's left side plane. -->
<path d="m8 75 7-7v3l-7 7Z" stroke-width="1.6" />
</template>
</g>
</template>
<style scoped>
.device-face { fill: var(--background); }
.device-detail { opacity: .4; }
.device-indicator { fill: currentColor; stroke: none; }
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { Driver } from '@/lib/hidGuide'
import HidWireframeDevice from './HidWireframeDevice.vue'
defineProps<{ backend: Driver }>()
const { t } = useI18n()
</script>
<template>
<figure v-if="backend !== 'none'" class="wiring-diagram rounded-lg border border-border/60 bg-muted/20 p-3 space-y-2">
<svg viewBox="0 0 460 184" class="block w-full text-foreground" role="img" :aria-label="t(`hidGuide.wiring_${backend}`)">
<!-- A faint ground plane anchors the perspective without shadows or filters. -->
<g fill="none" stroke="currentColor" stroke-width=".65" opacity=".07" aria-hidden="true">
<path d="m7 87 101-46 75 38-101 46Zm270-5 98-45 79 39-98 46M31 99l101-46m-77 58 101-46M302 94l98-45m-71 58 98-45" />
</g>
<HidWireframeDevice kind="kvm" transform="translate(29 10)" />
<HidWireframeDevice kind="computer" transform="translate(322 0)" />
<g class="device-label" fill="currentColor" text-anchor="middle">
<text x="83" y="120">{{ backend === 'bluetooth' ? 'One-KVM HID' : backend === 'otg' ? t('hidGuide.otgPort') : 'One-KVM USB' }}</text>
<text x="375" y="120">{{ backend === 'bluetooth' ? t('hidGuide.host') : t('hidGuide.hostUsb') }}</text>
</g>
<g v-if="backend === 'otg'" class="connection" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<!-- Rectangular USB plugs terminate on the two side-mounted sockets. -->
<g class="usb-plug">
<path d="m112 61 9-4 10 5-9 4Z" />
<path d="m112 61v7l10 5v-7Z" />
<path d="m122 66 9-4v7l-9 4Z" />
<path d="m112 64-5 2" class="plug-blade" />
</g>
<g class="usb-plug">
<path d="m317 80 7-7 13-5-7 7Z" />
<path d="m317 80 13-5v3l-13 5Z" />
<path d="m324 73 13-5v3l-13 5Z" />
<path d="m330 75 7-7v3l-7 7Z" class="plug-blade" />
</g>
<path d="M131 68c27 11 29 29 55 40l27 12c34 15 66 9 82-8l22-31" />
<path d="m223 117 7 3-7 3" />
<path d="M230 129v12" class="leader" />
</g>
<text v-if="backend === 'otg'" x="230" y="162" class="connection-label" text-anchor="middle" fill="currentColor">{{ t('hidGuide.dataCable') }}</text>
<template v-else-if="backend === 'bluetooth'">
<g class="connection" fill="none" stroke="currentColor" stroke-linecap="round">
<path d="M137 48c9 8 9 22 0 30m9-39c15 13 15 35 0 48m164-37c-9 8-9 22 0 30m-9-39c-15 13-15 35 0 48" opacity=".45" />
<path d="M166 64h30m64 0h28" stroke-dasharray="2 6" />
<path d="m216 48 23 32-13 10V39l13 10-23 31" stroke-width="1.8" />
</g>
<text x="230" y="162" class="connection-label" text-anchor="middle" fill="currentColor">{{ t('hidGuide.wireless') }}</text>
</template>
<template v-else-if="backend === 'ch9329'">
<!-- CH340 and CH9329 are enclosed in the cable and have no visible module. -->
<g class="connection" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<g class="usb-plug">
<path d="m112 61 9-4 10 5-9 4Z" />
<path d="m112 61v7l10 5v-7Z" />
<path d="m122 66 9-4v7l-9 4Z" />
<path d="m112 64-5 2" class="plug-blade" />
</g>
<g class="usb-plug">
<path d="m317 80 7-7 13-5-7 7Z" />
<path d="m317 80 13-5v3l-13 5Z" />
<path d="m324 73 13-5v3l-13 5Z" />
<path d="m330 75 7-7v3l-7 7Z" class="plug-blade" />
</g>
<path d="M131 68c25 10 32 39 67 55 33 16 72 19 100-4l19-38" />
<path d="m224 132 7 2-5 5" />
<path d="M230 143v7" class="leader" />
</g>
<text x="230" y="169" class="connection-label" text-anchor="middle" fill="currentColor">{{ t('hidGuide.integratedCable') }}</text>
</template>
</svg>
<figcaption class="text-xs leading-relaxed text-muted-foreground">{{ t(`hidGuide.wiring_${backend}`) }}</figcaption>
</figure>
<p v-else class="text-sm text-muted-foreground">{{ t('hidGuide.disabledHelp') }}</p>
</template>
<style scoped>
.device-label { font-size: 13px; font-weight: 500; }
.connection { color: var(--wiring-accent); stroke-width: 1.8; }
.connection-label { color: var(--wiring-accent); font-size: 12px; }
.usb-plug { fill: var(--background); stroke-width: 1.25; }
.plug-blade { fill: none; opacity: .75; }
.leader { opacity: .4; stroke-width: 1; }
.wiring-diagram { --wiring-accent: #0369a1; }
:global(.dark .wiring-diagram) { --wiring-accent: #7dd3fc; }
</style>

View File

@@ -0,0 +1,49 @@
import { ref, watch, onMounted, onUnmounted, onDeactivated, onActivated, type Ref } from 'vue'
import type { hidApi } from '@/api'
import { request } from '@/api/request'
import type { BluetoothStatus } from '@/types/bluetooth'
export function useHidConnection(active: Ref<boolean>, backend: Ref<string | undefined>) {
const status = ref<Awaited<ReturnType<typeof hidApi.status>> | null>(null)
const bluetooth = ref<BluetoothStatus | null>(null)
const error = ref('')
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
let generation = 0, inFlight = false, mounted = false, deactivated = false
function stop() { generation++; clearTimeout(timer); controller?.abort(); status.value = null; bluetooth.value = null }
async function refresh() {
if (!mounted || deactivated || !active.value || document.hidden || inFlight) return
inFlight = true
const own = generation
controller = new AbortController()
const signal = controller.signal
const timeout = setTimeout(() => controller?.abort(), 5000)
try {
const [hidResult, btResult] = await Promise.allSettled([
request<Awaited<ReturnType<typeof hidApi.status>>>('/hid/status', { signal }, { toastOnError: false }),
backend.value === 'bluetooth' ? request<BluetoothStatus>('/hid/bluetooth', { signal }, { toastOnError: false }) : Promise.resolve(null),
])
if (own !== generation) return
if (hidResult.status === 'rejected') throw hidResult.reason
if (btResult.status === 'rejected') throw btResult.reason
const hid = hidResult.value, bt = btResult.value
status.value = hid.backend === backend.value ? hid : null
bluetooth.value = hid.backend === backend.value ? bt : null
error.value = ''
} catch (e) {
if (own !== generation) return
status.value = null; bluetooth.value = null
error.value = e instanceof Error ? e.message : String(e)
} finally {
clearTimeout(timeout)
inFlight = false
if (mounted && !deactivated && active.value && !document.hidden) timer = setTimeout(refresh, 2000)
}
}
function restart() { stop(); error.value = ''; void refresh() }
watch([active, backend], restart)
onMounted(() => { mounted = true; document.addEventListener('visibilitychange', restart); restart() })
onActivated(() => { deactivated = false; restart() })
onDeactivated(() => { deactivated = true; stop() })
onUnmounted(() => { mounted = false; stop(); document.removeEventListener('visibilitychange', restart) })
return { status, bluetooth, error, restart }
}

View File

@@ -1,4 +1,86 @@
export default {
hidGuide: {
missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computers USB port. You can connect later or reconfigure.',
"deviceTitle": "Driver and device",
"features": "Feature configuration",
"saveFeatures": "Save features",
"configure": "Configure driver",
"reconfigure": "Reconfigure",
"driver": "HID driver",
"driver_otg": "USB OTG",
"driver_ch9329": "CH9329",
"driver_bluetooth": "Bluetooth HID",
"driver_none": "Disabled",
"device_otg": "UDC",
"device_ch9329": "Serial port",
"device_bluetooth": "Local Bluetooth adapter",
"host": "Controlled computer",
"hostUsb": "Host USB",
"otgPort": "One-KVM OTG USB",
"serialLink": "CH340 → Serial connection → CH9329",
"integratedCable": "CH340 + CH9329 integrated cable",
"dataCable": "USB data cable",
"wireless": "Bluetooth connection",
"wiring_otg": "One-KVM OTG USB port → USB data cable → controlled computer USB port.",
"wiring_ch9329": "Connect One-KVM to CH9329 over serial, then connect CH9329 USB to the controlled computer.",
"wiring_bluetooth": "On the controlled computer, open System Settings → Add Bluetooth device → select the advertised HID name.",
"disabledHelp": "One-KVM cannot send keyboard or mouse input while HID is disabled.",
"selectDevice": "Select a device",
"noDevices": "No devices found. Check the connection and refresh.",
"nameInvalid": "Use 164 UTF-8 bytes without control characters.",
"unconfigured": "HID is not configured",
"disabled": "HID is disabled",
"legacyAuto": "Automatic (legacy configuration)",
"unknown": "Unknown or unable to read status",
"ready": "HID ready",
"preparing": "Connected, preparing HID",
"paired": "Paired, waiting for connection",
"pairing": "Waiting for pairing",
"waiting": "Applied, waiting for connection",
"initializing": "Initializing",
"appliedHelp": "Configuration applied. Closing keeps the configuration and completed pairing.",
"draftHelp": "Choose a driver and device. The running configuration changes only when you apply.",
"dirty": "There are unsaved feature changes. Return to save them, or discard them to continue.",
"returnSave": "Return to save",
"discard": "Discard changes and continue",
"pairInstructions": "Add a Bluetooth device in the controlled computers system settings and select “{name}”. The pairing window lasts 120 seconds.",
"pairTimeout": "The pairing window ended. You can open it again.",
"reopenPairing": "Reopen pairing",
"resetWarning": "Previous One-KVM HID bonds will be cleared. Remove the old device on the controlled computer before adding it again.",
"disableUsb": "Leaving USB OTG will disable: {functions}.",
"msd": "Virtual disk",
"network": "USB network",
"audio": "USB audio",
"done": "Done",
"later": "Connect later",
"configureLater": "Configure later",
"useConfiguration": "Use this configuration",
"repair": "Pair again",
"uncertain": "The previous request has an unknown result. Check the connection first; applying again will clear bonds again.",
"checkConnection": "Check applied configuration",
"resumeRetry": "The current configuration differs from the pending selection. Review it and retry."
},
bluetoothHid: {
paired: 'Paired; waiting for the computer to connect keyboard and mouse',
"description": "Classic Bluetooth keyboard and relative mouse.",
"adapter": "Bluetooth adapter",
"name": "Device name",
"peer": "Computer address (optional)",
"peerHelp": "Leave blank to reuse the only bonded device, or select a computer during pairing.",
"ready": "Keyboard and mouse ready",
"pairing": "Pairing open: {seconds}s",
"connected": "Connected; waiting for HID channels",
"waiting": "Waiting for the paired computer",
"unavailable": "Bluetooth unavailable or starting",
"openPairing": "Pair for 2 minutes",
"closePairing": "Close pairing",
"disconnect": "Disconnect",
"forget": "Forget computer",
"windows": "Windows 11: Settings → Bluetooth & devices → Add device → Bluetooth. After forgetting, remove the device in Windows before pairing again.",
"applyFirst": "Apply the HID settings first, then open pairing."
},
videoInput: {
format: 'Input Format',
resolution: 'Resolution',

View File

@@ -1,4 +1,86 @@
export default {
hidGuide: {
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
"deviceTitle": "驱动与设备",
"features": "功能配置",
"saveFeatures": "保存功能配置",
"configure": "配置驱动",
"reconfigure": "重新配置",
"driver": "HID 驱动",
"driver_otg": "USB OTG",
"driver_ch9329": "CH9329",
"driver_bluetooth": "蓝牙 HID",
"driver_none": "禁用",
"device_otg": "UDC",
"device_ch9329": "串口",
"device_bluetooth": "本机蓝牙适配器",
"host": "被控机",
"hostUsb": "被控机 USB",
"otgPort": "One-KVM OTG USB",
"serialLink": "CH340 → 串口连接 → CH9329",
"integratedCable": "CH340 + CH9329 一体线",
"dataCable": "USB 数据线",
"wireless": "蓝牙无线连接",
"wiring_otg": "One-KVM 的 OTG USB 端口 → USB 数据线 → 被控机 USB 端口。",
"wiring_ch9329": "One-KVM 通过串口连接 CH9329CH9329 的 USB 连接被控机。",
"wiring_bluetooth": "在被控机打开系统设置 → 添加蓝牙设备 → 选择广播的 HID 设备名称。",
"disabledHelp": "禁用后 One-KVM 无法发送键盘和鼠标输入。",
"selectDevice": "请选择具体设备",
"noDevices": "未找到可用设备,请检查连接后刷新。",
"nameInvalid": "名称须为 164 个 UTF-8 字节,且不能含控制字符。",
"unconfigured": "尚未配置 HID",
"disabled": "HID 已禁用",
"legacyAuto": "自动选择(旧配置)",
"unknown": "状态未知或读取失败",
"ready": "HID 可用",
"preparing": "已连接HID 准备中",
"paired": "已配对,等待连接",
"pairing": "等待配对",
"waiting": "已应用,等待连接",
"initializing": "正在初始化",
"appliedHelp": "配置已应用。关闭引导会保留配置和已完成的配对。",
"draftHelp": "选择驱动和设备,点击应用后才会更改当前配置。",
"dirty": "功能配置有未保存的修改。请返回保存,或放弃修改后继续。",
"returnSave": "返回保存",
"discard": "放弃修改并继续",
"pairInstructions": "请在被控机的系统设置中添加蓝牙设备,选择“{name}”。配对窗口为 120 秒。",
"pairTimeout": "配对窗口已结束,可重新开启。",
"reopenPairing": "重新开启配对",
"resetWarning": "将清除原 One-KVM HID 绑定;请在被控机中删除旧设备后重新添加。",
"disableUsb": "切出 USB OTG 将关闭:{functions}。",
"msd": "虚拟磁盘",
"network": "USB 网络",
"audio": "USB 音频",
"done": "完成",
"later": "稍后连接",
"configureLater": "稍后配置",
"useConfiguration": "使用此配置",
"repair": "重新配对",
"uncertain": "上次请求结果不明。可先检查连接;再次应用将重新清除绑定。",
"checkConnection": "检查已应用配置",
"resumeRetry": "已读取当前配置,与待应用选择不一致。请检查选择后重试。"
},
bluetoothHid: {
paired: '已配对,等待电脑建立键鼠连接',
"description": "经典蓝牙键盘和相对鼠标。",
"adapter": "蓝牙适配器",
"name": "设备名称",
"peer": "被控电脑地址(可选)",
"peerHelp": "留空时复用唯一的已绑定设备,或在配对时选择电脑。",
"ready": "键盘和鼠标已就绪",
"pairing": "配对窗口剩余 {seconds} 秒",
"connected": "已连接,等待 HID 通道建立",
"waiting": "等待已配对电脑连接",
"unavailable": "蓝牙不可用或正在启动",
"openPairing": "开启配对2 分钟)",
"closePairing": "关闭配对",
"disconnect": "断开连接",
"forget": "忘记电脑",
"windows": "Windows 11设置 → 蓝牙和其他设备 → 添加设备 → 蓝牙。忘记设备后,请同时在 Windows 删除设备再重新配对。",
"applyFirst": "先应用 HID 设置,再开启配对。"
},
videoInput: {
format: '输入格式',
resolution: '分辨率',

73
web/src/lib/hidGuide.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { HidConfig, HidConfigUpdate } from '../types/generated'
import type { BluetoothStatus } from '../types/bluetooth'
export type Driver = 'otg' | 'ch9329' | 'bluetooth' | 'none'
export interface HidSelection {
backend: Driver; otg_udc: string; ch9329_port: string; ch9329_baudrate: number
bluetooth: { adapter: string; name: string }
}
export function selectionFrom(hid?: HidConfig | null): HidSelection {
return { backend: hid?.backend ?? 'ch9329', otg_udc: hid?.otg_udc ?? '',
ch9329_port: hid?.ch9329_port ?? '', ch9329_baudrate: hid?.ch9329_baudrate ?? 9600,
bluetooth: { adapter: hid?.bluetooth.adapter ?? '', name: hid?.bluetooth.name ?? 'One-KVM HID' } }
}
export function selectDevice(saved: string, candidates: string[]): string {
return candidates.includes(saved) ? saved : candidates.length === 1 ? candidates[0]! : ''
}
export function validName(name: string): boolean {
const length = new TextEncoder().encode(name).length
return length > 0 && length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name)
}
export function deviceRequest(draft: HidSelection): HidConfigUpdate {
const backend = draft.backend as HidConfigUpdate['backend']
switch (draft.backend) {
case 'otg': return { backend, otg_udc: draft.otg_udc }
case 'ch9329': return { backend, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate }
case 'bluetooth': return { backend, bluetooth: { ...draft.bluetooth }, bluetooth_reset_pairing: true }
case 'none': return { backend }
}
}
export function matchesSelection(hid: HidConfig, draft: HidSelection): boolean {
if (hid.backend !== draft.backend) return false
switch (draft.backend) {
case 'otg': return hid.otg_udc === draft.otg_udc
case 'ch9329': return hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate
case 'bluetooth': return hid.bluetooth.adapter === draft.bluetooth.adapter && hid.bluetooth.name === draft.bluetooth.name
case 'none': return true
}
}
export function bluetoothStage(status: BluetoothStatus | null): string {
if (!status || status.error) return 'unknown'
if (status.ready) return 'ready'
if (status.connected) return 'preparing'
if (status.devices.some(d => d.address === status.peer && d.paired)) return 'paired'
if (status.pairing_seconds > 0) return 'pairing'
return status.initialized ? 'waiting' : 'initializing'
}
export interface HidDeviceStatus {
backend: string; online: boolean; error?: string | null; error_code?: string | null
}
export function hidDeviceError(status?: HidDeviceStatus | null): string | null {
// An unplugged OTG cable is an expected connection state, not a hardware fault.
if (status?.backend === 'otg' && status.error_code === 'udc_not_configured') return null
return status?.error ?? null
}
export function hidDeviceStage(status: HidDeviceStatus | null | undefined, bluetooth: BluetoothStatus | null, readError = ''): string {
if (!status || readError || hidDeviceError(status)) return 'unknown'
if (status.backend === 'bluetooth') return bluetoothStage(bluetooth)
if (status.backend === 'otg' && status.error_code === 'udc_not_configured') return 'waiting'
return status.online ? 'ready' : 'waiting'
}
export const pendingHidKey = 'one-kvm.pending-hid.v1'
export interface PendingHid { selection: HidSelection; phase: 'selected' | 'applying' | 'applied' }
export function readPendingHid(): PendingHid | null {
try {
const value = JSON.parse(sessionStorage.getItem(pendingHidKey) ?? 'null')
if (value && ['selected', 'applying', 'applied'].includes(value.phase)
&& ['otg', 'ch9329', 'bluetooth', 'none'].includes(value.selection?.backend)
&& typeof value.selection.otg_udc === 'string' && typeof value.selection.ch9329_port === 'string'
&& typeof value.selection.ch9329_baudrate === 'number'
&& typeof value.selection.bluetooth?.adapter === 'string' && typeof value.selection.bluetooth?.name === 'string') return value
} catch { /* Invalid or unavailable session storage is not an applied configuration. */ }
return null
}
export function writePendingHid(value: PendingHid) { sessionStorage.setItem(pendingHidKey, JSON.stringify(value)) }

41
web/src/lib/hidStatus.ts Normal file
View File

@@ -0,0 +1,41 @@
type Status = 'connected' | 'connecting' | 'disconnected' | 'error'
interface HidState {
available: boolean
initialized: boolean
online: boolean
backend: string
error?: string | null
errorCode?: string | null
}
interface InputTransport {
useWebRtc: boolean
dataChannelReady: boolean
rtcConnecting: boolean
rtcConnected: boolean
wsConnected: boolean
wsNetworkError: boolean
wsHidUnavailable: boolean
}
export function getHidStatus(hid: HidState | null, transport: InputTransport): Status {
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
if (hid?.error) return 'error'
if (!hid?.available) return 'disconnected'
// A browser DataChannel can stay open after the controlled computer disconnects.
// Both the HID backend and the browser input transport must be ready.
if (!hid.online) {
return hid.initialized && hid.backend !== 'bluetooth' ? 'connecting' : 'disconnected'
}
if (transport.useWebRtc) {
if (transport.dataChannelReady) return 'connected'
if (transport.rtcConnecting || transport.rtcConnected) return 'connecting'
}
if (transport.wsNetworkError) return 'connecting'
if (!transport.wsConnected || transport.wsHidUnavailable) return 'disconnected'
return 'connected'
}

View File

@@ -516,8 +516,8 @@ export const useConfigStore = defineStore('config', () => {
return response
}
async function updateHid(update: HidConfigUpdate) {
const response = await hidConfigApi.update(update)
async function updateHid(update: HidConfigUpdate, signal?: AbortSignal) {
const response = await hidConfigApi.update(update, signal)
hid.value = response
return response
}

View File

@@ -0,0 +1,7 @@
export interface BluetoothAdapter { name: string; address: string; powered: boolean }
export interface BluetoothStatus {
initialized: boolean; connected: boolean; ready: boolean; adapter: string; adapter_address: string
peer?: string | null; pairing_seconds: number; control_connected: boolean; interrupt_connected: boolean
error?: string | null
devices: Array<{ address: string; name: string; paired: boolean; connected: boolean }>
}

View File

@@ -16,9 +16,16 @@ export interface VideoConfig {
quality: number;
}
export interface BluetoothHidConfig {
adapter: string;
name: string;
peer?: string;
}
export enum HidBackend {
Otg = "otg",
Ch9329 = "ch9329",
Bluetooth = "bluetooth",
None = "none",
}
@@ -54,6 +61,7 @@ export interface Ch9329DescriptorConfig {
}
export interface HidConfig {
bluetooth: BluetoothHidConfig;
backend: HidBackend;
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfig;
@@ -304,6 +312,13 @@ export interface WatchdogConfig {
enabled: boolean;
}
/** Configuration for the USB Audio Class microphone gadget. */
export interface UacConfig {
enabled: boolean;
sample_rate: number;
channels: number;
}
export interface AppConfig {
initialized: boolean;
auth: AuthConfig;
@@ -322,6 +337,7 @@ export interface AppConfig {
rtsp: RtspConfig;
redfish: RedfishConfig;
watchdog: WatchdogConfig;
uac: UacConfig;
}
/** Update for a single ATX output binding */
@@ -548,6 +564,8 @@ export interface OtgHidFunctionsUpdate {
}
export interface HidConfigUpdate {
bluetooth_reset_pairing?: boolean;
bluetooth?: BluetoothHidConfig;
backend?: HidBackend;
ch9329_port?: string;
ch9329_baudrate?: number;

View File

@@ -25,6 +25,7 @@ import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/key
import { toast } from 'vue-sonner'
import { cn, generateUUID } from '@/lib/utils'
import { formatFpsValue } from '@/lib/fps'
import { getHidStatus } from '@/lib/hidStatus'
import { videoDebugLog } from '@/lib/debugLog'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
@@ -212,7 +213,7 @@ const isConsoleActive = ref(false)
function syncMouseModeFromConfig() {
const mouseAbsolute = configStore.hid?.mouse_absolute
if (typeof mouseAbsolute !== 'boolean') return
const nextMode: 'absolute' | 'relative' = mouseAbsolute ? 'absolute' : 'relative'
const nextMode: 'absolute' | 'relative' = mouseAbsolute && configStore.hid?.backend !== 'bluetooth' ? 'absolute' : 'relative'
if (mouseMode.value !== nextMode) {
resetTouchInput()
mouseMode.value = nextMode
@@ -353,27 +354,15 @@ const videoDetails = computed<StatusDetail[]>(() => {
return details
})
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const hid = systemStore.hid
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
if (hid?.error) return 'error'
if (videoMode.value !== 'mjpeg') {
if (webrtc.dataChannelReady.value) return 'connected'
if (webrtc.isConnecting.value) return 'connecting'
if (webrtc.isConnected.value) return 'connecting'
}
if (hidWs.networkError.value) return 'connecting'
if (!hidWs.connected.value) return 'disconnected'
if (hidWs.hidUnavailable.value) return 'disconnected'
if (hid?.available && hid.online) return 'connected'
if (hid?.available && hid.initialized) return 'connecting'
return 'disconnected'
})
const hidStatus = computed(() => getHidStatus(systemStore.hid, {
useWebRtc: videoMode.value !== 'mjpeg',
dataChannelReady: webrtc.dataChannelReady.value,
rtcConnecting: webrtc.isConnecting.value,
rtcConnected: webrtc.isConnected.value,
wsConnected: hidWs.connected.value,
wsNetworkError: hidWs.networkError.value,
wsHidUnavailable: hidWs.hidUnavailable.value,
}))
const hidQuickInfo = computed(() => {
const hid = systemStore.hid
@@ -3072,7 +3061,7 @@ function handleToggleMouseMode() {
exitPointerLock()
}
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
mouseMode.value = configStore.hid?.backend === 'bluetooth' ? 'relative' : (mouseMode.value === 'absolute' ? 'relative' : 'absolute')
pendingMouseMove = null
accumulatedDelta = { x: 0, y: 0 }
lastMousePosition.value = { x: 0, y: 0 }

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import HidDeviceSettings from '@/components/HidDeviceSettings.vue'
import ConsoleLayoutPreview from '@/components/ConsoleLayoutPreview.vue'
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
@@ -46,6 +48,7 @@ import type {
AtxDriverType,
ActiveLevel,
AtxDevices,
HidConfigUpdate,
OtgHidProfile,
OtgHidFunctions,
Ch9329DescriptorConfig,
@@ -1450,13 +1453,8 @@ async function saveConfig() {
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,
otg_udc: config.value.hid_otg_udc,
}
const hidUpdate: HidConfigUpdate = configStore.hid?.backend === 'ch9329'
? { 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,
@@ -1474,32 +1472,37 @@ async function saveConfig() {
product: otgProduct.value || 'One-KVM USB Device',
serial_number: otgSerialNumber.value || undefined,
}
hidUpdate.otg_profile = 'custom'
hidUpdate.otg_profile = 'custom' as OtgHidProfile
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
}
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,
flash_inquiry_string: config.value.msd_flash_inquiry_string,
cdrom_inquiry_string: config.value.msd_cdrom_inquiry_string,
},
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 (configStore.hid?.backend === 'otg') {
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,
flash_inquiry_string: config.value.msd_flash_inquiry_string,
cdrom_inquiry_string: config.value.msd_cdrom_inquiry_string,
},
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
await uacApi.update({
enabled: otgEnabled && config.value.uac_enabled,
sample_rate: 48000,
channels: 2,
})
await uacApi.update({
enabled: otgEnabled && config.value.uac_enabled,
sample_rate: 48000,
channels: 2,
})
} else if (configStore.hid?.backend === 'ch9329') {
await configStore.updateHid(hidUpdate)
}
await loadConfig()
}
if (activeSection.value !== 'hid') {
@@ -1518,6 +1521,15 @@ async function saveConfig() {
}
}
const hidFeatureBaseline = ref('')
function hidFeatureSnapshot() {
return JSON.stringify({
fields: Object.fromEntries(Object.entries(config.value).filter(([key]) => key.startsWith('msd_') || key.startsWith('otg_network_') || key.startsWith('uac_') || ['hid_otg_functions', 'hid_otg_keyboard_leds', 'hid_ch9329_hybrid_mouse'].includes(key))),
descriptor: [otgVendorIdHex.value, otgProductIdHex.value, otgManufacturer.value, otgProduct.value, otgSerialNumber.value],
})
}
const hidFeaturesDirty = computed(() => !!hidFeatureBaseline.value && (hidFeatureBaseline.value !== hidFeatureSnapshot() || isCh9329DescriptorDirty.value))
async function loadConfig() {
try {
const [video, stream, hid, msd, otgNetwork, uac] = await Promise.all([
@@ -1590,6 +1602,8 @@ async function loadConfig() {
clearCh9329DescriptorState()
}
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
await nextTick()
hidFeatureBaseline.value = hidFeatureSnapshot()
} catch {
}
}
@@ -2826,34 +2840,12 @@ watch(isWindows, () => {
:aria-pressed="consoleLayout === option.value"
@click="setConsoleLayout(option.value)"
>
<div class="mb-3 flex h-20 overflow-hidden rounded-md border bg-muted/40">
<div
v-if="option.value === 'sidebar'"
class="flex w-4 flex-col items-center gap-1 border-r bg-background p-1"
>
<span v-for="i in 4" :key="i" class="size-1.5 rounded-sm bg-muted-foreground/50" />
</div>
<div class="relative flex-1 bg-zinc-950">
<div
v-if="option.value === 'current'"
class="absolute inset-x-0 top-0 flex h-3 items-center gap-1 border-b bg-background px-1"
>
<span v-for="i in 5" :key="i" class="h-1 w-2 rounded-full bg-muted-foreground/50" />
</div>
<div
v-else-if="option.value === 'floating'"
class="absolute inset-x-2 top-2 flex h-3 items-center gap-1 rounded border bg-background/90 px-1 shadow"
>
<span v-for="i in 5" :key="i" class="h-1 w-2 rounded-full bg-muted-foreground/50" />
</div>
</div>
</div>
<ConsoleLayoutPreview :layout="option.value" class="mb-3" />
<div class="flex items-center gap-2">
<component :is="option.icon" class="size-4 text-muted-foreground" />
<span class="text-sm font-medium">{{ t(`settings.consoleLayoutOptions.${option.value}`) }}</span>
<Check v-if="consoleLayout === option.value" class="ml-auto size-4 text-primary" />
</div>
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">{{ t(`settings.consoleLayoutHints.${option.value}`) }}</p>
</button>
</div>
</CardContent>
@@ -3125,64 +3117,10 @@ watch(isWindows, () => {
<!-- HID Section -->
<div v-show="activeSection === 'hid'" class="space-y-4">
<Card>
<CardHeader class="flex flex-row items-start justify-between space-y-0">
<div class="space-y-1.5">
<CardTitle>{{ t('settings.hidSettings') }}</CardTitle>
<CardDescription>{{ t('settings.hidSettingsDesc') }}</CardDescription>
</div>
<Button variant="ghost" size="icon-sm" :aria-label="t('common.refresh')" @click="loadHidDeviceOptions">
<RefreshCw class="size-4" />
</Button>
</CardHeader>
<HidDeviceSettings :active="activeSection === 'hid'" :dirty="hidFeaturesDirty" @applied="loadConfig" @discard="loadConfig" />
<Card v-if="configStore.hid?.backend === 'otg' || configStore.hid?.backend === 'ch9329'">
<CardHeader><CardTitle>{{ t('hidGuide.features') }}</CardTitle></CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="hid-backend">{{ t('settings.hidBackend') }}</Label>
<Select v-model="config.hid_backend">
<SelectTrigger id="hid-backend" class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ch9329">CH9329 (Serial)</SelectItem>
<SelectItem value="otg">USB OTG</SelectItem>
<SelectItem value="none">{{ t('common.disabled') }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="config.hid_backend === 'ch9329'" class="space-y-2">
<Label for="serial-device">{{ t('settings.serialDevice') }}</Label>
<Select
:model-value="config.hid_serial_device"
@update:model-value="value => config.hid_serial_device = value === EMPTY_SELECT_VALUE ? '' : String(value)"
>
<SelectTrigger id="serial-device" class="w-full"><SelectValue :placeholder="t('settings.selectDevice')" /></SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('settings.selectDevice') }}</SelectItem>
<SelectItem v-for="dev in devices.serial" :key="dev.path" :value="dev.path">{{ dev.name }} ({{ dev.path }})</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="config.hid_backend === 'ch9329'" class="space-y-2">
<Label for="serial-baudrate">{{ t('settings.baudRate') }}</Label>
<Select :model-value="config.hid_serial_baudrate" @update:model-value="value => config.hid_serial_baudrate = Number(value)">
<SelectTrigger id="serial-baudrate" class="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem v-for="baud in [9600, 19200, 38400, 57600, 115200]" :key="baud" :value="baud">{{ baud }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="config.hid_backend === 'otg'" class="space-y-2">
<Label for="otg-udc">{{ t('settings.otgUdc') }}</Label>
<Select
:model-value="config.hid_otg_udc"
@update:model-value="value => config.hid_otg_udc = value === EMPTY_SELECT_VALUE ? '' : String(value)"
>
<SelectTrigger id="otg-udc" class="w-full"><SelectValue :placeholder="t('settings.autoRecommended')" /></SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('settings.autoRecommended') }}</SelectItem>
<SelectItem v-for="udc in devices.udc" :key="udc.name" :value="udc.name">{{ udc.name }}</SelectItem>
</SelectContent>
</Select>
</div>
<template v-if="config.hid_backend === 'ch9329'">
<Separator class="my-4" />
<div class="space-y-4">
@@ -5453,7 +5391,7 @@ watch(isWindows, () => {
</div>
<!-- Save Button (sticky) -->
<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 v-if="activeSection === 'video' || (activeSection === 'hid' && ['otg', 'ch9329'].includes(configStore.hid?.backend ?? ''))" 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="flex min-w-0 items-center gap-1.5 text-xs text-warning">
<AlertTriangle class="size-3.5 shrink-0" />
@@ -5470,7 +5408,7 @@ watch(isWindows, () => {
<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="size-4 mr-2 animate-spin" /><Check v-else-if="saved" class="size-4 mr-2" /><Save v-else class="size-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
<Loader2 v-if="loading" class="size-4 mr-2 animate-spin" /><Check v-else-if="saved" class="size-4 mr-2" /><Save v-else class="size-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t(activeSection === 'hid' ? 'hidGuide.saveFeatures' : 'common.save') }}
</Button>
</div>
</div>

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
import HidDriverForm from '@/components/HidDriverForm.vue'
import { selectionFrom, readPendingHid, writePendingHid } from '@/lib/hidGuide'
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -77,13 +80,8 @@ const audioSupported = computed(() => platform.value?.audio.available ?? true)
const totalSteps = 4
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
const hidBackend = ref('ch9329')
const ch9329Port = ref('')
const ch9329Baudrate = ref(9600)
const otgUdc = ref('')
const hidOtgProfile = ref('full_no_consumer')
const otgMsdEnabled = ref(true)
const otgKeyboardLeds = ref(true)
const hidSelection = ref(readPendingHid()?.selection ?? selectionFrom())
const hidSelectionValid = ref(false)
const ttydEnabled = ref(false)
const ttydAvailable = ref(false)
@@ -154,15 +152,6 @@ const {
refreshingInputStatus,
} = videoConfiguration
function applyOtgDefaults() {
if (hidBackend.value !== 'otg') return
hidOtgProfile.value = 'full_no_consumer'
otgKeyboardLeds.value = true
}
const baudRates = [9600, 19200, 38400, 57600, 115200]
const stepLabels = computed(() => [
t('setup.stepAccount'),
t('setup.stepAudioVideo'),
@@ -231,29 +220,10 @@ watch(videoDevice, (newDevice) => {
}
})
// Watch HID backend change to set defaults
watch(hidBackend, (newBackend) => {
if (newBackend === 'ch9329' && !ch9329Port.value && devices.value.serial.length > 0) {
ch9329Port.value = devices.value.serial[0]?.path || ''
}
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || ''
}
applyOtgDefaults()
})
watch(otgUdc, () => {
applyOtgDefaults()
})
onMounted(async () => {
try {
const status = await authStore.checkSetupStatus()
platform.value = status.platform
if (isWindows.value) {
hidBackend.value = 'ch9329'
otgMsdEnabled.value = false
}
if (!audioSupported.value) {
audioEnabled.value = false
audioDevice.value = '__none__'
@@ -270,16 +240,6 @@ onMounted(async () => {
videoDevice.value = result.video[0].path
}
// Auto-select first serial device for CH9329
if (result.serial.length > 0 && result.serial[0]) {
ch9329Port.value = result.serial[0].path
}
if (!isWindows.value && result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name
}
applyOtgDefaults()
// Auto-select audio device if available (and no video device to trigger watch)
if (audioSupported.value && result.audio.length > 0 && !audioDevice.value) {
// Prefer HDMI audio device
@@ -356,14 +316,11 @@ function validateStep2(): boolean {
}
function validateStep3(): boolean {
if (hidBackend.value === 'ch9329' && !ch9329Port.value) {
error.value = t('setup.selectSerialPort')
return false
}
if (hidBackend.value === 'otg' && !otgUdc.value) {
error.value = t('setup.selectUdc')
if (!hidSelectionValid.value) {
error.value = t('hidGuide.selectDevice')
return false
}
writePendingHid({ selection: hidSelection.value, phase: 'selected' })
return true
}
@@ -372,6 +329,7 @@ function nextStep() {
if (step.value === 1 && !validateStep1()) return
if (step.value === 2 && !validateStep2()) return
if (step.value === 3 && !validateStep3()) return
if (step.value < totalSteps) {
slideDirection.value = 'forward'
@@ -390,9 +348,22 @@ function prevStep() {
async function handleSetup() {
error.value = ''
if (!validateStep3()) return
if (!readPendingHid() || loading.value) return
loading.value = true
// Reconcile a previous timed-out account request before submitting again.
try {
const status = await authStore.checkSetupStatus()
if (status.initialized) {
loading.value = false
await router.push('/login')
return
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
loading.value = false
return
}
const [width, height] = (videoResolution.value || '').split('x').map(Number)
@@ -415,17 +386,8 @@ async function handleSetup() {
setupData.video_fps = toConfigFps(videoFps.value)
}
setupData.hid_backend = hidBackend.value
if (hidBackend.value === 'ch9329') {
setupData.hid_ch9329_port = ch9329Port.value
setupData.hid_ch9329_baudrate = ch9329Baudrate.value
}
if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value
setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value
setupData.msd_enabled = otgMsdEnabled.value
}
setupData.hid_backend = 'none'
setupData.msd_enabled = false
// Encoder backend setting
if (encoderBackend.value !== 'auto') {
@@ -441,8 +403,8 @@ async function handleSetup() {
const success = await authStore.setup(setupData)
if (success) {
await authStore.login(username.value, password.value)
router.push('/')
const loggedIn = await authStore.login(username.value, password.value)
router.push(loggedIn ? '/' : '/login')
} else {
error.value = authStore.error || t('setup.setupFailed')
}
@@ -728,92 +690,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<div v-else-if="step === 3" key="step3" class="space-y-4">
<h3 class="text-lg font-medium text-center">{{ t('setup.stepHid') }}</h3>
<div class="space-y-2">
<Label for="hidBackend">{{ t('setup.hidBackend') }}</Label>
<Select v-model="hidBackend">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ch9329">
CH9329 ({{ t('setup.serialHid') }})
</SelectItem>
<SelectItem v-if="!isWindows" value="otg">USB OTG</SelectItem>
</SelectContent>
</Select>
</div>
<!-- CH9329 Settings -->
<div v-if="hidBackend === 'ch9329'" class="space-y-4 p-4 rounded-lg bg-muted/50">
<div class="flex items-start gap-2 text-sm text-muted-foreground mb-2">
<HelpCircle class="w-4 h-4 mt-0.5 shrink-0" />
<p>{{ t('setup.ch9329Help') }}</p>
</div>
<div class="space-y-2">
<Label for="ch9329Port">{{ t('setup.serialPort') }}</Label>
<Select
:model-value="ch9329Port"
@update:model-value="value => ch9329Port = value === EMPTY_SELECT_VALUE ? '' : String(value)"
>
<SelectTrigger id="ch9329Port" class="w-full">
<SelectValue :placeholder="t('setup.selectSerialPort')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('setup.selectSerialPort') }}</SelectItem>
<SelectItem v-for="port in devices.serial" :key="port.path" :value="port.path">
{{ port.name }} ({{ port.path }})
</SelectItem>
</SelectContent>
</Select>
<p v-if="!devices.serial.length" class="text-xs text-muted-foreground">
{{ t('setup.noSerialDevices') }}
</p>
</div>
<div class="space-y-2">
<Label for="ch9329Baudrate">{{ t('setup.baudRate') }}</Label>
<Select :model-value="ch9329Baudrate" @update:model-value="value => ch9329Baudrate = Number(value)">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="rate in baudRates" :key="rate" :value="rate">
{{ rate }} bps
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- OTG Settings -->
<div v-if="hidBackend === 'otg' && !isWindows" class="space-y-4 p-4 rounded-lg bg-muted/50">
<div class="flex items-start gap-2 text-sm text-muted-foreground mb-2">
<HelpCircle class="w-4 h-4 mt-0.5 shrink-0" />
<p>{{ t('setup.otgHelp') }}</p>
</div>
<div class="space-y-2">
<Label for="otgUdc">{{ t('setup.udc') }}</Label>
<Select
:model-value="otgUdc"
@update:model-value="value => otgUdc = value === EMPTY_SELECT_VALUE ? '' : String(value)"
>
<SelectTrigger id="otgUdc" class="w-full">
<SelectValue :placeholder="t('setup.selectUdc')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('setup.selectUdc') }}</SelectItem>
<SelectItem v-for="udc in devices.udc" :key="udc.name" :value="udc.name">
{{ udc.name }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="!devices.udc.length" class="text-xs text-muted-foreground">
{{ t('setup.noUdcDevices') }}
</p>
</div>
</div>
<HidDriverForm v-model="hidSelection" @valid="hidSelectionValid = $event" />
</div>
<!-- Step 4: Extensions Settings -->
@@ -860,8 +737,8 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
{{ t('common.back') }}
</Button>
<Button v-if="step < totalSteps" class="flex-1" @click="nextStep">
{{ t('common.next') }}
<Button v-if="step < totalSteps" class="flex-1" :disabled="step === 3 && !hidSelectionValid" @click="nextStep">
{{ t(step === 3 ? 'hidGuide.useConfiguration' : 'common.next') }}
<ChevronRight class="w-4 h-4 ml-2" />
</Button>
@@ -874,7 +751,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<!-- Keyboard shortcuts hint -->
<p class="text-xs text-muted-foreground text-center">
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Enter</kbd>
{{ t('common.next') }}
{{ t(step === 3 ? 'hidGuide.useConfiguration' : 'common.next') }}
<span v-if="step > 1" class="ml-2">
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Esc</kbd>
{{ t('common.back') }}