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">
|
<script setup lang="ts">
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
|
import HidDriverDialog from '@/components/HidDriverDialog.vue'
|
||||||
|
import { readPendingHid, type PendingHid } from '@/lib/hidGuide'
|
||||||
import '@/sonner-overrides.css'
|
import '@/sonner-overrides.css'
|
||||||
import { computed, KeepAlive, onMounted } from 'vue'
|
import { computed, KeepAlive, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { RouterView, useRouter } from 'vue-router'
|
import { RouterView, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -24,6 +26,10 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const pendingGuide = ref<PendingHid | null>(null)
|
||||||
|
watch(() => authStore.isAuthenticated, authenticated => {
|
||||||
|
pendingGuide.value = authenticated ? readPendingHid() : null
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -46,7 +52,8 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<KeepAlive v-if="authStore.isAuthenticated">
|
||||||
<component :is="Component" v-if="route.name === 'Console'" />
|
<component :is="Component" v-if="route.name === 'Console'" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ export const streamConfigApi = {
|
|||||||
export const hidConfigApi = {
|
export const hidConfigApi = {
|
||||||
get: () => request<HidConfig>('/config/hid'),
|
get: () => request<HidConfig>('/config/hid'),
|
||||||
|
|
||||||
update: (config: HidConfigUpdate) =>
|
update: (config: HidConfigUpdate, signal?: AbortSignal) =>
|
||||||
request<HidConfig>('/config/hid', {
|
request<HidConfig>('/config/hid', {
|
||||||
|
signal,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(config),
|
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">
|
<script setup lang="ts">
|
||||||
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -13,19 +13,13 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
import {
|
import { MousePointer, Move } from 'lucide-vue-next'
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
|
|
||||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
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 { useConfigStore } from '@/stores/config'
|
||||||
import { HidBackend } from '@/types/generated'
|
import { HidBackend } from '@/types/generated'
|
||||||
import type { HidConfigUpdate } from '@/types/generated'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -72,70 +66,17 @@ watch(showCursor, (newValue, oldValue) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// HID Device Settings (requires apply)
|
const guideOpen = ref(false)
|
||||||
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 buttonText = computed(() => t('actionbar.hidConfig'))
|
const buttonText = computed(() => t('actionbar.hidConfig'))
|
||||||
|
const { status, bluetooth, error } = useHidConnection(computed(() => props.open && !guideOpen.value), computed(() => configStore.hid?.backend))
|
||||||
// Available device paths based on backend type
|
async function configure() {
|
||||||
const availableDevicePaths = computed(() => {
|
emit('update:open', false)
|
||||||
if (hidBackend.value === HidBackend.Ch9329) {
|
await nextTick()
|
||||||
return serialDevices.value
|
guideOpen.value = true
|
||||||
} 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 = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMouseMode() {
|
function toggleMouseMode() {
|
||||||
|
if (configStore.hid?.backend === HidBackend.Bluetooth) return
|
||||||
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
|
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
|
||||||
emit('update:mouseMode', newMode)
|
emit('update:mouseMode', newMode)
|
||||||
|
|
||||||
@@ -158,75 +99,11 @@ function handleThrottleChange(value: number[] | undefined) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle backend change
|
watch(() => props.open, (open) => {
|
||||||
function handleBackendChange(backend: unknown) {
|
if (!open) return
|
||||||
if (typeof backend !== 'string') return
|
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
|
||||||
if (backend === HidBackend.Otg || backend === HidBackend.Ch9329 || backend === HidBackend.None) {
|
showCursor.value = localStorage.getItem('hidShowCursor') !== 'false'
|
||||||
hidBackend.value = backend
|
void configStore.refreshHid().catch(() => undefined)
|
||||||
} 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()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -269,6 +146,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
:variant="mouseMode === 'absolute' ? 'default' : 'outline'"
|
:variant="mouseMode === 'absolute' ? 'default' : 'outline'"
|
||||||
|
:disabled="configStore.hid?.backend === HidBackend.Bluetooth"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 h-8 text-xs"
|
class="flex-1 h-8 text-xs"
|
||||||
@click="toggleMouseMode"
|
@click="toggleMouseMode"
|
||||||
@@ -318,96 +196,11 @@ watch(() => props.open, (isOpen) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HID Device Settings (Requires Apply) -->
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<HidDeviceOverview :hid="configStore.hid" :status="status" :bluetooth="bluetooth" :error="error" />
|
||||||
<div class="space-y-3">
|
<Button variant="outline" class="w-full" @click="configure">{{ t('hidGuide.reconfigure') }}</Button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
<HidDriverDialog v-if="guideOpen" @close="guideOpen = false" />
|
||||||
</template>
|
</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 {
|
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: {
|
videoInput: {
|
||||||
format: 'Input Format',
|
format: 'Input Format',
|
||||||
resolution: 'Resolution',
|
resolution: 'Resolution',
|
||||||
|
|||||||
@@ -1,4 +1,86 @@
|
|||||||
export default {
|
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: {
|
videoInput: {
|
||||||
format: '输入格式',
|
format: '输入格式',
|
||||||
resolution: '分辨率',
|
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
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateHid(update: HidConfigUpdate) {
|
async function updateHid(update: HidConfigUpdate, signal?: AbortSignal) {
|
||||||
const response = await hidConfigApi.update(update)
|
const response = await hidConfigApi.update(update, signal)
|
||||||
hid.value = response
|
hid.value = response
|
||||||
return 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;
|
quality: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BluetoothHidConfig {
|
||||||
|
adapter: string;
|
||||||
|
name: string;
|
||||||
|
peer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export enum HidBackend {
|
export enum HidBackend {
|
||||||
Otg = "otg",
|
Otg = "otg",
|
||||||
Ch9329 = "ch9329",
|
Ch9329 = "ch9329",
|
||||||
|
Bluetooth = "bluetooth",
|
||||||
None = "none",
|
None = "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +61,7 @@ export interface Ch9329DescriptorConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HidConfig {
|
export interface HidConfig {
|
||||||
|
bluetooth: BluetoothHidConfig;
|
||||||
backend: HidBackend;
|
backend: HidBackend;
|
||||||
otg_udc?: string;
|
otg_udc?: string;
|
||||||
otg_descriptor?: OtgDescriptorConfig;
|
otg_descriptor?: OtgDescriptorConfig;
|
||||||
@@ -304,6 +312,13 @@ export interface WatchdogConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Configuration for the USB Audio Class microphone gadget. */
|
||||||
|
export interface UacConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
sample_rate: number;
|
||||||
|
channels: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
auth: AuthConfig;
|
auth: AuthConfig;
|
||||||
@@ -322,6 +337,7 @@ export interface AppConfig {
|
|||||||
rtsp: RtspConfig;
|
rtsp: RtspConfig;
|
||||||
redfish: RedfishConfig;
|
redfish: RedfishConfig;
|
||||||
watchdog: WatchdogConfig;
|
watchdog: WatchdogConfig;
|
||||||
|
uac: UacConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update for a single ATX output binding */
|
/** Update for a single ATX output binding */
|
||||||
@@ -548,6 +564,8 @@ export interface OtgHidFunctionsUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HidConfigUpdate {
|
export interface HidConfigUpdate {
|
||||||
|
bluetooth_reset_pairing?: boolean;
|
||||||
|
bluetooth?: BluetoothHidConfig;
|
||||||
backend?: HidBackend;
|
backend?: HidBackend;
|
||||||
ch9329_port?: string;
|
ch9329_port?: string;
|
||||||
ch9329_baudrate?: number;
|
ch9329_baudrate?: number;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/key
|
|||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { cn, generateUUID } from '@/lib/utils'
|
import { cn, generateUUID } from '@/lib/utils'
|
||||||
import { formatFpsValue } from '@/lib/fps'
|
import { formatFpsValue } from '@/lib/fps'
|
||||||
|
import { getHidStatus } from '@/lib/hidStatus'
|
||||||
import { videoDebugLog } from '@/lib/debugLog'
|
import { videoDebugLog } from '@/lib/debugLog'
|
||||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||||
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
|
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
|
||||||
@@ -212,7 +213,7 @@ const isConsoleActive = ref(false)
|
|||||||
function syncMouseModeFromConfig() {
|
function syncMouseModeFromConfig() {
|
||||||
const mouseAbsolute = configStore.hid?.mouse_absolute
|
const mouseAbsolute = configStore.hid?.mouse_absolute
|
||||||
if (typeof mouseAbsolute !== 'boolean') return
|
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) {
|
if (mouseMode.value !== nextMode) {
|
||||||
resetTouchInput()
|
resetTouchInput()
|
||||||
mouseMode.value = nextMode
|
mouseMode.value = nextMode
|
||||||
@@ -353,27 +354,15 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
|||||||
return details
|
return details
|
||||||
})
|
})
|
||||||
|
|
||||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
const hidStatus = computed(() => getHidStatus(systemStore.hid, {
|
||||||
const hid = systemStore.hid
|
useWebRtc: videoMode.value !== 'mjpeg',
|
||||||
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
|
dataChannelReady: webrtc.dataChannelReady.value,
|
||||||
if (hid?.error) return 'error'
|
rtcConnecting: webrtc.isConnecting.value,
|
||||||
|
rtcConnected: webrtc.isConnected.value,
|
||||||
if (videoMode.value !== 'mjpeg') {
|
wsConnected: hidWs.connected.value,
|
||||||
if (webrtc.dataChannelReady.value) return 'connected'
|
wsNetworkError: hidWs.networkError.value,
|
||||||
if (webrtc.isConnecting.value) return 'connecting'
|
wsHidUnavailable: hidWs.hidUnavailable.value,
|
||||||
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 hidQuickInfo = computed(() => {
|
const hidQuickInfo = computed(() => {
|
||||||
const hid = systemStore.hid
|
const hid = systemStore.hid
|
||||||
@@ -3072,7 +3061,7 @@ function handleToggleMouseMode() {
|
|||||||
exitPointerLock()
|
exitPointerLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
|
mouseMode.value = configStore.hid?.backend === 'bluetooth' ? 'relative' : (mouseMode.value === 'absolute' ? 'relative' : 'absolute')
|
||||||
pendingMouseMove = null
|
pendingMouseMove = null
|
||||||
accumulatedDelta = { x: 0, y: 0 }
|
accumulatedDelta = { x: 0, y: 0 }
|
||||||
lastMousePosition.value = { x: 0, y: 0 }
|
lastMousePosition.value = { x: 0, y: 0 }
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
@@ -46,6 +48,7 @@ import type {
|
|||||||
AtxDriverType,
|
AtxDriverType,
|
||||||
ActiveLevel,
|
ActiveLevel,
|
||||||
AtxDevices,
|
AtxDevices,
|
||||||
|
HidConfigUpdate,
|
||||||
OtgHidProfile,
|
OtgHidProfile,
|
||||||
OtgHidFunctions,
|
OtgHidFunctions,
|
||||||
Ch9329DescriptorConfig,
|
Ch9329DescriptorConfig,
|
||||||
@@ -1450,13 +1453,8 @@ async function saveConfig() {
|
|||||||
if (!isHidSettingsValid.value) {
|
if (!isHidSettingsValid.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hidUpdate: any = {
|
const hidUpdate: HidConfigUpdate = configStore.hid?.backend === 'ch9329'
|
||||||
backend: config.value.hid_backend as any,
|
? { ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse } : {}
|
||||||
ch9329_port: config.value.hid_serial_device || undefined,
|
|
||||||
ch9329_baudrate: config.value.hid_serial_baudrate,
|
|
||||||
ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse,
|
|
||||||
otg_udc: config.value.hid_otg_udc,
|
|
||||||
}
|
|
||||||
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
|
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
|
||||||
hidUpdate.ch9329_descriptor = {
|
hidUpdate.ch9329_descriptor = {
|
||||||
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
||||||
@@ -1474,32 +1472,37 @@ async function saveConfig() {
|
|||||||
product: otgProduct.value || 'One-KVM USB Device',
|
product: otgProduct.value || 'One-KVM USB Device',
|
||||||
serial_number: otgSerialNumber.value || undefined,
|
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_functions = { ...config.value.hid_otg_functions }
|
||||||
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
||||||
}
|
}
|
||||||
const otgEnabled = config.value.hid_backend === 'otg'
|
if (configStore.hid?.backend === 'otg') {
|
||||||
const response = await configStore.updateOtg({
|
const otgEnabled = config.value.hid_backend === 'otg'
|
||||||
hid: hidUpdate,
|
const response = await configStore.updateOtg({
|
||||||
msd: {
|
hid: hidUpdate,
|
||||||
enabled: otgEnabled && config.value.msd_enabled,
|
msd: {
|
||||||
msd_dir: config.value.msd_dir || undefined,
|
enabled: otgEnabled && config.value.msd_enabled,
|
||||||
flash_inquiry_string: config.value.msd_flash_inquiry_string,
|
msd_dir: config.value.msd_dir || undefined,
|
||||||
cdrom_inquiry_string: config.value.msd_cdrom_inquiry_string,
|
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,
|
network: {
|
||||||
driver_mode: config.value.otg_network_driver as any,
|
enabled: otgEnabled && config.value.otg_network_enabled,
|
||||||
bridge_interface: config.value.otg_network_interface,
|
driver_mode: config.value.otg_network_driver as any,
|
||||||
},
|
bridge_interface: config.value.otg_network_interface,
|
||||||
})
|
},
|
||||||
otgNetworkStatus.value = response.status
|
})
|
||||||
|
otgNetworkStatus.value = response.status
|
||||||
|
|
||||||
await uacApi.update({
|
await uacApi.update({
|
||||||
enabled: otgEnabled && config.value.uac_enabled,
|
enabled: otgEnabled && config.value.uac_enabled,
|
||||||
sample_rate: 48000,
|
sample_rate: 48000,
|
||||||
channels: 2,
|
channels: 2,
|
||||||
})
|
})
|
||||||
|
} else if (configStore.hid?.backend === 'ch9329') {
|
||||||
|
await configStore.updateHid(hidUpdate)
|
||||||
|
}
|
||||||
|
await loadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection.value !== 'hid') {
|
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() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const [video, stream, hid, msd, otgNetwork, uac] = await Promise.all([
|
const [video, stream, hid, msd, otgNetwork, uac] = await Promise.all([
|
||||||
@@ -1590,6 +1602,8 @@ async function loadConfig() {
|
|||||||
clearCh9329DescriptorState()
|
clearCh9329DescriptorState()
|
||||||
}
|
}
|
||||||
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
|
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
|
||||||
|
await nextTick()
|
||||||
|
hidFeatureBaseline.value = hidFeatureSnapshot()
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2826,34 +2840,12 @@ watch(isWindows, () => {
|
|||||||
:aria-pressed="consoleLayout === option.value"
|
:aria-pressed="consoleLayout === option.value"
|
||||||
@click="setConsoleLayout(option.value)"
|
@click="setConsoleLayout(option.value)"
|
||||||
>
|
>
|
||||||
<div class="mb-3 flex h-20 overflow-hidden rounded-md border bg-muted/40">
|
<ConsoleLayoutPreview :layout="option.value" class="mb-3" />
|
||||||
<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>
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<component :is="option.icon" class="size-4 text-muted-foreground" />
|
<component :is="option.icon" class="size-4 text-muted-foreground" />
|
||||||
<span class="text-sm font-medium">{{ t(`settings.consoleLayoutOptions.${option.value}`) }}</span>
|
<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" />
|
<Check v-if="consoleLayout === option.value" class="ml-auto size-4 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">{{ t(`settings.consoleLayoutHints.${option.value}`) }}</p>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -3125,64 +3117,10 @@ watch(isWindows, () => {
|
|||||||
|
|
||||||
<!-- HID Section -->
|
<!-- HID Section -->
|
||||||
<div v-show="activeSection === 'hid'" class="space-y-4">
|
<div v-show="activeSection === 'hid'" class="space-y-4">
|
||||||
<Card>
|
<HidDeviceSettings :active="activeSection === 'hid'" :dirty="hidFeaturesDirty" @applied="loadConfig" @discard="loadConfig" />
|
||||||
<CardHeader class="flex flex-row items-start justify-between space-y-0">
|
<Card v-if="configStore.hid?.backend === 'otg' || configStore.hid?.backend === 'ch9329'">
|
||||||
<div class="space-y-1.5">
|
<CardHeader><CardTitle>{{ t('hidGuide.features') }}</CardTitle></CardHeader>
|
||||||
<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>
|
|
||||||
<CardContent class="space-y-4">
|
<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'">
|
<template v-if="config.hid_backend === 'ch9329'">
|
||||||
<Separator class="my-4" />
|
<Separator class="my-4" />
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -5453,7 +5391,7 @@ watch(isWindows, () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Button (sticky) -->
|
<!-- 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">
|
<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">
|
<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" />
|
<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-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>
|
<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">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<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 { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -77,13 +80,8 @@ const audioSupported = computed(() => platform.value?.audio.available ?? true)
|
|||||||
const totalSteps = 4
|
const totalSteps = 4
|
||||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||||
|
|
||||||
const hidBackend = ref('ch9329')
|
const hidSelection = ref(readPendingHid()?.selection ?? selectionFrom())
|
||||||
const ch9329Port = ref('')
|
const hidSelectionValid = ref(false)
|
||||||
const ch9329Baudrate = ref(9600)
|
|
||||||
const otgUdc = ref('')
|
|
||||||
const hidOtgProfile = ref('full_no_consumer')
|
|
||||||
const otgMsdEnabled = ref(true)
|
|
||||||
const otgKeyboardLeds = ref(true)
|
|
||||||
|
|
||||||
const ttydEnabled = ref(false)
|
const ttydEnabled = ref(false)
|
||||||
const ttydAvailable = ref(false)
|
const ttydAvailable = ref(false)
|
||||||
@@ -154,15 +152,6 @@ const {
|
|||||||
refreshingInputStatus,
|
refreshingInputStatus,
|
||||||
} = videoConfiguration
|
} = 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(() => [
|
const stepLabels = computed(() => [
|
||||||
t('setup.stepAccount'),
|
t('setup.stepAccount'),
|
||||||
t('setup.stepAudioVideo'),
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const status = await authStore.checkSetupStatus()
|
const status = await authStore.checkSetupStatus()
|
||||||
platform.value = status.platform
|
platform.value = status.platform
|
||||||
if (isWindows.value) {
|
|
||||||
hidBackend.value = 'ch9329'
|
|
||||||
otgMsdEnabled.value = false
|
|
||||||
}
|
|
||||||
if (!audioSupported.value) {
|
if (!audioSupported.value) {
|
||||||
audioEnabled.value = false
|
audioEnabled.value = false
|
||||||
audioDevice.value = '__none__'
|
audioDevice.value = '__none__'
|
||||||
@@ -270,16 +240,6 @@ onMounted(async () => {
|
|||||||
videoDevice.value = result.video[0].path
|
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)
|
// Auto-select audio device if available (and no video device to trigger watch)
|
||||||
if (audioSupported.value && result.audio.length > 0 && !audioDevice.value) {
|
if (audioSupported.value && result.audio.length > 0 && !audioDevice.value) {
|
||||||
// Prefer HDMI audio device
|
// Prefer HDMI audio device
|
||||||
@@ -356,14 +316,11 @@ function validateStep2(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateStep3(): boolean {
|
function validateStep3(): boolean {
|
||||||
if (hidBackend.value === 'ch9329' && !ch9329Port.value) {
|
if (!hidSelectionValid.value) {
|
||||||
error.value = t('setup.selectSerialPort')
|
error.value = t('hidGuide.selectDevice')
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (hidBackend.value === 'otg' && !otgUdc.value) {
|
|
||||||
error.value = t('setup.selectUdc')
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
writePendingHid({ selection: hidSelection.value, phase: 'selected' })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +329,7 @@ function nextStep() {
|
|||||||
|
|
||||||
if (step.value === 1 && !validateStep1()) return
|
if (step.value === 1 && !validateStep1()) return
|
||||||
if (step.value === 2 && !validateStep2()) return
|
if (step.value === 2 && !validateStep2()) return
|
||||||
|
if (step.value === 3 && !validateStep3()) return
|
||||||
|
|
||||||
if (step.value < totalSteps) {
|
if (step.value < totalSteps) {
|
||||||
slideDirection.value = 'forward'
|
slideDirection.value = 'forward'
|
||||||
@@ -390,9 +348,22 @@ function prevStep() {
|
|||||||
async function handleSetup() {
|
async function handleSetup() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
if (!validateStep3()) return
|
if (!readPendingHid() || loading.value) return
|
||||||
|
|
||||||
loading.value = true
|
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)
|
const [width, height] = (videoResolution.value || '').split('x').map(Number)
|
||||||
|
|
||||||
@@ -415,17 +386,8 @@ async function handleSetup() {
|
|||||||
setupData.video_fps = toConfigFps(videoFps.value)
|
setupData.video_fps = toConfigFps(videoFps.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
setupData.hid_backend = hidBackend.value
|
setupData.hid_backend = 'none'
|
||||||
if (hidBackend.value === 'ch9329') {
|
setupData.msd_enabled = false
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encoder backend setting
|
// Encoder backend setting
|
||||||
if (encoderBackend.value !== 'auto') {
|
if (encoderBackend.value !== 'auto') {
|
||||||
@@ -441,8 +403,8 @@ async function handleSetup() {
|
|||||||
const success = await authStore.setup(setupData)
|
const success = await authStore.setup(setupData)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
await authStore.login(username.value, password.value)
|
const loggedIn = await authStore.login(username.value, password.value)
|
||||||
router.push('/')
|
router.push(loggedIn ? '/' : '/login')
|
||||||
} else {
|
} else {
|
||||||
error.value = authStore.error || t('setup.setupFailed')
|
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">
|
<div v-else-if="step === 3" key="step3" class="space-y-4">
|
||||||
<h3 class="text-lg font-medium text-center">{{ t('setup.stepHid') }}</h3>
|
<h3 class="text-lg font-medium text-center">{{ t('setup.stepHid') }}</h3>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<HidDriverForm v-model="hidSelection" @valid="hidSelectionValid = $event" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Extensions Settings -->
|
<!-- Step 4: Extensions Settings -->
|
||||||
@@ -860,8 +737,8 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
|||||||
{{ t('common.back') }}
|
{{ t('common.back') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button v-if="step < totalSteps" class="flex-1" @click="nextStep">
|
<Button v-if="step < totalSteps" class="flex-1" :disabled="step === 3 && !hidSelectionValid" @click="nextStep">
|
||||||
{{ t('common.next') }}
|
{{ t(step === 3 ? 'hidGuide.useConfiguration' : 'common.next') }}
|
||||||
<ChevronRight class="w-4 h-4 ml-2" />
|
<ChevronRight class="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -874,7 +751,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
|||||||
<!-- Keyboard shortcuts hint -->
|
<!-- Keyboard shortcuts hint -->
|
||||||
<p class="text-xs text-muted-foreground text-center">
|
<p class="text-xs text-muted-foreground text-center">
|
||||||
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Enter</kbd>
|
<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">
|
<span v-if="step > 1" class="ml-2">
|
||||||
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Esc</kbd>
|
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Esc</kbd>
|
||||||
{{ t('common.back') }}
|
{{ t('common.back') }}
|
||||||
|
|||||||
Reference in New Issue
Block a user