mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-12 18:44:25 +08:00
feat(web): 新增 HID 配置引导
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
28
web/src/components/BluetoothHidSettings.vue
Normal file
28
web/src/components/BluetoothHidSettings.vue
Normal 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>
|
||||
82
web/src/components/ConsoleLayoutPreview.vue
Normal file
82
web/src/components/ConsoleLayoutPreview.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
33
web/src/components/HidDeviceOverview.vue
Normal file
33
web/src/components/HidDeviceOverview.vue
Normal 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>
|
||||
28
web/src/components/HidDeviceSettings.vue
Normal file
28
web/src/components/HidDeviceSettings.vue
Normal 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>
|
||||
190
web/src/components/HidDriverDialog.vue
Normal file
190
web/src/components/HidDriverDialog.vue
Normal 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>
|
||||
84
web/src/components/HidDriverForm.vue
Normal file
84
web/src/components/HidDriverForm.vue
Normal 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>
|
||||
33
web/src/components/HidWireframeDevice.vue
Normal file
33
web/src/components/HidWireframeDevice.vue
Normal 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>
|
||||
87
web/src/components/HidWiringDiagram.vue
Normal file
87
web/src/components/HidWiringDiagram.vue
Normal 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>
|
||||
49
web/src/composables/useHidConnection.ts
Normal file
49
web/src/composables/useHidConnection.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 computer’s 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 1–64 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 computer’s 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',
|
||||
|
||||
@@ -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 通过串口连接 CH9329,CH9329 的 USB 连接被控机。",
|
||||
"wiring_bluetooth": "在被控机打开系统设置 → 添加蓝牙设备 → 选择广播的 HID 设备名称。",
|
||||
"disabledHelp": "禁用后 One-KVM 无法发送键盘和鼠标输入。",
|
||||
"selectDevice": "请选择具体设备",
|
||||
"noDevices": "未找到可用设备,请检查连接后刷新。",
|
||||
"nameInvalid": "名称须为 1–64 个 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
73
web/src/lib/hidGuide.ts
Normal 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
41
web/src/lib/hidStatus.ts
Normal 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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
7
web/src/types/bluetooth.ts
Normal file
7
web/src/types/bluetooth.ts
Normal 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 }>
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,10 +1472,11 @@ 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
|
||||
}
|
||||
if (configStore.hid?.backend === 'otg') {
|
||||
const otgEnabled = config.value.hid_backend === 'otg'
|
||||
const response = await configStore.updateOtg({
|
||||
hid: hidUpdate,
|
||||
@@ -1500,6 +1499,10 @@ async function saveConfig() {
|
||||
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>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
Reference in New Issue
Block a user