Merge branch 'main' into main

This commit is contained in:
SilentWind
2026-02-10 14:31:17 +08:00
committed by GitHub
95 changed files with 7374 additions and 3732 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>One-KVM</title>
</head>

14
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "web",
"version": "0.0.0",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "web",
"version": "0.0.0",
"version": "0.1.4",
"dependencies": {
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
@@ -1368,6 +1368,7 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1782,6 +1783,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2448,6 +2450,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2495,6 +2498,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2787,7 +2791,8 @@
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -2841,6 +2846,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2906,6 +2912,7 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -2987,6 +2994,7 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",

View File

@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "0.1.1",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",

BIN
web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -7,6 +7,8 @@
import type {
AppConfig,
AuthConfig,
AuthConfigUpdate,
VideoConfig,
VideoConfigUpdate,
StreamConfigResponse,
@@ -41,6 +43,24 @@ export const configApi = {
getAll: () => request<AppConfig>('/config'),
}
// ===== Auth 配置 API =====
export const authConfigApi = {
/**
* 获取认证配置
*/
get: () => request<AuthConfig>('/config/auth'),
/**
* 更新认证配置
* @param config 要更新的字段
*/
update: (config: AuthConfigUpdate) =>
request<AuthConfig>('/config/auth', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== Video 配置 API =====
export const videoConfigApi = {
/**
@@ -316,6 +336,7 @@ export const rustdeskConfigApi = {
export interface WebConfig {
http_port: number
https_port: number
bind_addresses: string[]
bind_address: string
https_enabled: boolean
}
@@ -324,6 +345,7 @@ export interface WebConfig {
export interface WebConfigUpdate {
http_port?: number
https_port?: number
bind_addresses?: string[]
bind_address?: string
https_enabled?: boolean
}

View File

@@ -16,7 +16,19 @@ export const authApi = {
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
check: () =>
request<{ authenticated: boolean; user?: string; is_admin?: boolean }>('/auth/check'),
request<{ authenticated: boolean; user?: string }>('/auth/check'),
changePassword: (currentPassword: string, newPassword: string) =>
request<{ success: boolean }>('/auth/password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
changeUsername: (username: string, currentPassword: string) =>
request<{ success: boolean }>('/auth/username', {
method: 'POST',
body: JSON.stringify({ username, current_password: currentPassword }),
}),
}
// System API
@@ -72,6 +84,7 @@ export const systemApi = {
hid_ch9329_port?: string
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
@@ -121,8 +134,6 @@ export const streamApi = {
clients: number
target_fps: number
fps: number
frames_captured: number
frames_dropped: number
}>('/stream/status'),
start: () =>
@@ -200,7 +211,7 @@ export const webrtcApi = {
}),
getIceServers: () =>
request<{ ice_servers: IceServerConfig[] }>('/webrtc/ice-servers'),
request<{ ice_servers: IceServerConfig[]; mdns_mode: string }>('/webrtc/ice-servers'),
}
// HID API
@@ -516,6 +527,7 @@ export const configApi = {
// 导出新的域分离配置 API
export {
authConfigApi,
videoConfigApi,
streamConfigApi,
hidConfigApi,
@@ -535,6 +547,8 @@ export {
// 导出生成的类型
export type {
AppConfig,
AuthConfig,
AuthConfigUpdate,
VideoConfig,
VideoConfigUpdate,
StreamConfig,
@@ -588,53 +602,4 @@ export const audioApi = {
}),
}
// User Management API
export interface User {
id: string
username: string
role: 'admin' | 'user'
created_at: string
}
interface UserApiResponse {
id: string
username: string
is_admin: boolean
created_at: string
}
export const userApi = {
list: async () => {
const rawUsers = await request<UserApiResponse[]>('/users')
const users: User[] = rawUsers.map(u => ({
id: u.id,
username: u.username,
role: u.is_admin ? 'admin' : 'user',
created_at: u.created_at,
}))
return { success: true, users }
},
create: (username: string, password: string, role: 'admin' | 'user' = 'user') =>
request<UserApiResponse>('/users', {
method: 'POST',
body: JSON.stringify({ username, password, is_admin: role === 'admin' }),
}),
update: (id: string, data: { username?: string; role?: 'admin' | 'user' }) =>
request<{ success: boolean }>(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify({ username: data.username, is_admin: data.role === 'admin' }),
}),
delete: (id: string) =>
request<{ success: boolean }>(`/users/${id}`, { method: 'DELETE' }),
changePassword: (id: string, newPassword: string, currentPassword?: string) =>
request<{ success: boolean }>(`/users/${id}/password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword, current_password: currentPassword }),
}),
}
export { ApiError }

View File

@@ -81,7 +81,12 @@ export async function request<T>(
// Handle HTTP errors (in case backend returns non-2xx)
if (!response.ok) {
const message = getErrorMessage(data, `HTTP ${response.status}`)
if (toastOnError && shouldShowToast(toastKey)) {
const normalized = message.toLowerCase()
const isNotAuthenticated = normalized.includes('not authenticated')
const isSessionExpired = normalized.includes('session expired')
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
const isAuthIssue = response.status === 401 && (isNotAuthenticated || isSessionExpired || isLoggedInElsewhere)
if (toastOnError && shouldShowToast(toastKey) && !isAuthIssue) {
toast.error(t('api.operationFailed'), {
description: message,
duration: 4000,
@@ -130,4 +135,3 @@ export async function request<T>(
throw new ApiError(0, t('api.networkError'))
}
}

View File

@@ -52,14 +52,13 @@ const overflowMenuOpen = ref(false)
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
const showMsd = computed(() => {
return props.isAdmin && !isCh9329Backend.value
return !!systemStore.msd?.available && !isCh9329Backend.value
})
const props = defineProps<{
mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode
ttydRunning?: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -86,25 +85,23 @@ const extensionOpen = ref(false)
<template>
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between gap-2 px-4 py-1.5">
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
<!-- Left side buttons -->
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
<!-- Video Config - Always visible -->
<VideoConfigPopover
v-model:open="videoPopoverOpen"
:video-mode="props.videoMode || 'mjpeg'"
:is-admin="props.isAdmin"
@update:video-mode="emit('update:videoMode', $event)"
/>
<!-- Audio Config - Always visible -->
<AudioConfigPopover v-model:open="audioPopoverOpen" :is-admin="props.isAdmin" />
<AudioConfigPopover v-model:open="audioPopoverOpen" />
<!-- HID Config - Always visible -->
<HidConfigPopover
v-model:open="hidPopoverOpen"
:mouse-mode="mouseMode"
:is-admin="props.isAdmin"
@update:mouse-mode="emit('toggleMouseMode')"
/>
@@ -125,7 +122,7 @@ const extensionOpen = ref(false)
</TooltipProvider>
<!-- ATX Power Control - Hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="atxOpen" class="hidden sm:block">
<Popover v-model:open="atxOpen" class="hidden sm:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" />
@@ -158,9 +155,9 @@ const extensionOpen = ref(false)
</div>
<!-- Right side buttons -->
<div class="flex items-center gap-1.5 shrink-0">
<!-- Extension Menu - Admin only, hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="extensionOpen" class="hidden lg:block">
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
<!-- Extension Menu - Hidden on small screens -->
<Popover v-model:open="extensionOpen" class="hidden lg:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" />
@@ -183,8 +180,8 @@ const extensionOpen = ref(false)
</PopoverContent>
</Popover>
<!-- Settings - Admin only, hidden on small screens -->
<TooltipProvider v-if="props.isAdmin" class="hidden lg:block">
<!-- Settings - Hidden on small screens -->
<TooltipProvider class="hidden lg:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
@@ -270,7 +267,7 @@ const extensionOpen = ref(false)
</DropdownMenuItem>
<!-- ATX - Mobile only -->
<DropdownMenuItem v-if="props.isAdmin" class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<Power class="h-4 w-4 mr-2" />
{{ t('actionbar.power') }}
</DropdownMenuItem>
@@ -291,7 +288,6 @@ const extensionOpen = ref(false)
<!-- Extension - Tablet and below -->
<DropdownMenuItem
v-if="props.isAdmin"
class="lg:hidden"
:disabled="!props.ttydRunning"
@click="emit('openTerminal'); overflowMenuOpen = false"
@@ -301,7 +297,7 @@ const extensionOpen = ref(false)
</DropdownMenuItem>
<!-- Settings - Tablet and below -->
<DropdownMenuItem v-if="props.isAdmin" class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<Settings class="h-4 w-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select'
import { Volume2, RefreshCw, Loader2 } from 'lucide-vue-next'
import { audioApi, configApi } from '@/api'
import { useConfigStore } from '@/stores/config'
import { useSystemStore } from '@/stores/system'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
@@ -30,7 +31,6 @@ interface AudioDevice {
const props = defineProps<{
open: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -38,6 +38,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const configStore = useConfigStore()
const systemStore = useSystemStore()
const unifiedAudio = getUnifiedAudio()
@@ -88,9 +89,9 @@ async function loadDevices() {
// Initialize from current config
function initializeFromCurrent() {
const audio = systemStore.audio
const audio = configStore.audio
if (audio) {
audioEnabled.value = audio.available && audio.streaming
audioEnabled.value = audio.enabled
selectedDevice.value = audio.device || ''
selectedQuality.value = (audio.quality as 'voice' | 'balanced' | 'high') || 'balanced'
}
@@ -105,12 +106,10 @@ async function applyConfig() {
try {
// Update config
await configApi.update({
audio: {
enabled: audioEnabled.value,
device: selectedDevice.value,
quality: selectedQuality.value,
},
await configStore.updateAudio({
enabled: audioEnabled.value,
device: selectedDevice.value,
quality: selectedQuality.value,
})
// If enabled and device is selected, try to start audio stream
@@ -152,12 +151,19 @@ async function applyConfig() {
// Watch popover open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
if (devices.value.length === 0) {
loadDevices()
}
initializeFromCurrent()
if (!isOpen) return
if (devices.value.length === 0) {
loadDevices()
}
configStore.refreshAudio()
.then(() => {
initializeFromCurrent()
})
.catch(() => {
initializeFromCurrent()
})
})
</script>
@@ -203,11 +209,10 @@ watch(() => props.open, (isOpen) => {
</div>
</div>
<!-- Device Settings (requires apply) - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<!-- Device Settings (requires apply) -->
<Separator />
<div class="space-y-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.audioDeviceSettings') }}
@@ -311,7 +316,6 @@ watch(() => props.open, (isOpen) => {
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>

View File

@@ -22,12 +22,13 @@ import {
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi } from '@/api'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { HidBackend } from '@/types/generated'
import type { HidConfigUpdate } from '@/types/generated'
const props = defineProps<{
open: boolean
mouseMode?: 'absolute' | 'relative'
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -36,7 +37,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
@@ -73,7 +74,7 @@ watch(showCursor, (newValue, oldValue) => {
})
// HID Device Settings (requires apply)
const hidBackend = ref<'otg' | 'ch9329' | 'none'>('none')
const hidBackend = ref<HidBackend>(HidBackend.None)
const devicePath = ref<string>('')
const baudrate = ref<number>(9600)
@@ -90,9 +91,9 @@ const buttonText = computed(() => t('actionbar.hidConfig'))
// Available device paths based on backend type
const availableDevicePaths = computed(() => {
if (hidBackend.value === 'ch9329') {
if (hidBackend.value === HidBackend.Ch9329) {
return serialDevices.value
} else if (hidBackend.value === 'otg') {
} else if (hidBackend.value === HidBackend.Otg) {
// For OTG, we show UDC devices
return udcDevices.value.map(udc => ({
path: udc.name,
@@ -125,9 +126,17 @@ function initializeFromCurrent() {
showCursor.value = storedCursor
// Initialize HID device settings from system state
const hid = systemStore.hid
const hid = configStore.hid
if (hid) {
hidBackend.value = (hid.backend as 'otg' | 'ch9329' | 'none') || 'none'
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 = ''
}
}
}
@@ -137,10 +146,8 @@ function toggleMouseMode() {
emit('update:mouseMode', newMode)
// Update backend config
configApi.update({
hid: {
mouse_absolute: newMode === 'absolute',
},
configStore.updateHid({
mouse_absolute: newMode === 'absolute',
}).catch(_e => {
console.info('[HidConfig] Failed to update mouse mode')
toast.error(t('config.updateFailed'))
@@ -163,7 +170,11 @@ function handleThrottleChange(value: number[] | undefined) {
// Handle backend change
function handleBackendChange(backend: unknown) {
if (typeof backend !== 'string') return
hidBackend.value = backend as 'otg' | 'ch9329' | 'none'
if (backend === HidBackend.Otg || backend === HidBackend.Ch9329 || backend === HidBackend.None) {
hidBackend.value = backend
} else {
return
}
// Clear device path when changing backend
devicePath.value = ''
@@ -190,18 +201,18 @@ function handleBaudrateChange(rate: unknown) {
async function applyHidConfig() {
applying.value = true
try {
const config: Record<string, unknown> = {
const config: HidConfigUpdate = {
backend: hidBackend.value,
}
if (hidBackend.value === 'ch9329') {
if (hidBackend.value === HidBackend.Ch9329) {
config.ch9329_port = devicePath.value
config.ch9329_baudrate = baudrate.value
} else if (hidBackend.value === 'otg') {
} else if (hidBackend.value === HidBackend.Otg) {
config.otg_udc = devicePath.value
}
await configApi.update({ hid: config })
await configStore.updateHid(config)
toast.success(t('config.applied'))
@@ -216,14 +227,20 @@ async function applyHidConfig() {
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Load devices on first open
if (serialDevices.value.length === 0) {
loadDevices()
}
// Initialize from current config
initializeFromCurrent()
if (!isOpen) return
// Load devices on first open
if (serialDevices.value.length === 0) {
loadDevices()
}
configStore.refreshHid()
.then(() => {
initializeFromCurrent()
})
.catch(() => {
initializeFromCurrent()
})
})
</script>
@@ -304,11 +321,10 @@ watch(() => props.open, (isOpen) => {
</div>
</div>
<!-- HID Device Settings (Requires Apply) - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<!-- HID Device Settings (Requires Apply) -->
<Separator />
<div class="space-y-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
<Button
@@ -333,15 +349,15 @@ watch(() => props.open, (isOpen) => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="otg" class="text-xs">USB OTG</SelectItem>
<SelectItem value="ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
<SelectItem value="none" class="text-xs">{{ t('common.disabled') }}</SelectItem>
<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 !== 'none'" class="space-y-2">
<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"
@@ -365,7 +381,7 @@ watch(() => props.open, (isOpen) => {
</div>
<!-- Baudrate (CH9329 only) -->
<div v-if="hidBackend === 'ch9329'" class="space-y-2">
<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)"
@@ -393,8 +409,7 @@ watch(() => props.open, (isOpen) => {
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</div>
</PopoverContent>
</Popover>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/lib/utils'
import {
@@ -7,6 +7,11 @@ import {
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
@@ -28,8 +33,18 @@ const props = withDefaults(defineProps<{
errorMessage?: string
details?: StatusDetail[]
hoverAlign?: 'start' | 'center' | 'end' // HoverCard alignment
compact?: boolean
}>(), {
hoverAlign: 'start',
compact: false,
})
const prefersPopover = ref(false)
onMounted(() => {
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const coarsePointer = window.matchMedia?.('(pointer: coarse)')?.matches
prefersPopover.value = hasTouch || !!coarsePointer
})
const statusColor = computed(() => {
@@ -111,19 +126,20 @@ const statusBadgeText = computed(() => {
</script>
<template>
<HoverCard :open-delay="200" :close-delay="100">
<HoverCard v-if="!prefersPopover" :open-delay="200" :close-delay="100">
<HoverCardTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
:class="cn(
'flex flex-col gap-0.5 px-3 py-1.5 rounded-md border text-sm cursor-pointer transition-colors min-w-[100px]',
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs">{{ title }}</span>
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
@@ -208,4 +224,103 @@ const statusBadgeText = computed(() => {
</div>
</HoverCardContent>
</HoverCard>
<Popover v-else>
<PopoverTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</div>
</PopoverTrigger>
<PopoverContent class="w-80" :align="hoverAlign">
<div class="space-y-3">
<!-- Header -->
<div class="flex items-center gap-3">
<div :class="cn(
'p-2 rounded-lg',
status === 'connected' ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
status === 'error' ? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' :
'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
)">
<component :is="StatusIcon" class="h-5 w-5" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-sm">{{ title }}</h4>
<div class="flex items-center gap-1.5 mt-0.5">
<component
v-if="statusIcon"
:is="statusIcon"
:class="cn(
'h-3.5 w-3.5',
status === 'connected' ? 'text-green-500' :
status === 'connecting' ? 'text-yellow-500 animate-spin' :
status === 'error' ? 'text-red-500' :
'text-slate-400'
)"
/>
<Badge
:variant="status === 'connected' ? 'default' : status === 'error' ? 'destructive' : 'secondary'"
class="text-[10px] px-1.5 py-0"
>
{{ statusBadgeText }}
</Badge>
</div>
</div>
</div>
<!-- Error Message -->
<div
v-if="status === 'error' && errorMessage"
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<p class="text-xs text-red-600 dark:text-red-400">
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
{{ errorMessage }}
</p>
</div>
<!-- Details -->
<div v-if="details && details.length > 0" class="space-y-2">
<Separator />
<div class="space-y-1.5">
<div
v-for="(detail, index) in details"
:key="index"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground">{{ detail.label }}</span>
<span
:class="cn(
'font-medium',
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-foreground'
)"
>
{{ detail.value }}
</span>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -20,7 +20,7 @@ import {
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo, type BitratePreset } from '@/api'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useRouter } from 'vue-router'
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
@@ -43,7 +43,6 @@ interface VideoDevice {
const props = defineProps<{
open: boolean
videoMode: VideoMode
isAdmin?: boolean
}>()
const emit = defineEmits<{
@@ -52,7 +51,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const router = useRouter()
// Device list
@@ -65,7 +64,7 @@ const loadingCodecs = ref(false)
// Backend list
const backends = ref<EncoderBackendInfo[]>([])
const currentEncoderBackend = ref<string>('auto')
const currentEncoderBackend = computed(() => configStore.stream?.encoder || 'auto')
// Browser supported codecs (WebRTC receive capabilities)
const browserSupportedCodecs = ref<Set<string>>(new Set())
@@ -198,11 +197,11 @@ const applyingBitrate = ref(false)
// Current config from store
const currentConfig = computed(() => ({
device: systemStore.stream?.device || '',
format: systemStore.stream?.format || '',
width: systemStore.stream?.resolution?.[0] || 1920,
height: systemStore.stream?.resolution?.[1] || 1080,
fps: systemStore.stream?.targetFps || 30,
device: configStore.video?.device || '',
format: configStore.video?.format || '',
width: configStore.video?.width || 1920,
height: configStore.video?.height || 1080,
fps: configStore.video?.fps || 30,
}))
// Button display text - simplified to just show label
@@ -304,19 +303,6 @@ async function loadCodecs() {
}
}
// Load current encoder backend from config
async function loadEncoderBackend() {
try {
const config = await configApi.get()
// Access nested stream.encoder
const streamConfig = config.stream as { encoder?: string } | undefined
currentEncoderBackend.value = streamConfig?.encoder || 'auto'
} catch (e) {
console.info('[VideoConfig] Failed to load encoder backend config')
currentEncoderBackend.value = 'auto'
}
}
// Navigate to settings page (video tab)
function goToSettings() {
router.push('/settings?tab=video')
@@ -441,14 +427,12 @@ async function applyVideoConfig() {
applying.value = true
try {
await configApi.update({
video: {
device: selectedDevice.value,
format: selectedFormat.value,
width,
height,
fps: selectedFps.value,
},
await configStore.updateVideo({
device: selectedDevice.value,
format: selectedFormat.value,
width,
height,
fps: selectedFps.value,
})
toast.success(t('config.applied'))
@@ -464,26 +448,32 @@ async function applyVideoConfig() {
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Detect browser codec support on first open
if (browserSupportedCodecs.value.size === 0) {
detectBrowserCodecSupport()
}
// Load devices on first open
if (devices.value.length === 0) {
loadDevices()
}
// Load codecs and backends on first open
if (codecs.value.length === 0) {
loadCodecs()
}
// Load encoder backend config
loadEncoderBackend()
// Initialize from current config
initializeFromCurrent()
} else {
if (!isOpen) {
isDirty.value = false
return
}
// Detect browser codec support on first open
if (browserSupportedCodecs.value.size === 0) {
detectBrowserCodecSupport()
}
// Load devices on first open
if (devices.value.length === 0) {
loadDevices()
}
// Load codecs and backends on first open
if (codecs.value.length === 0) {
loadCodecs()
}
Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
]).then(() => {
initializeFromCurrent()
}).catch(() => {
initializeFromCurrent()
})
})
// Sync selected values when backend config changes (e.g., auto format switch on mode change)
@@ -619,9 +609,7 @@ watch(currentConfig, () => {
</div>
</div>
<!-- Settings Link - Admin only -->
<Button
v-if="props.isAdmin"
variant="ghost"
size="sm"
class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0"
@@ -632,11 +620,10 @@ watch(currentConfig, () => {
</Button>
</div>
<!-- Device Settings Section - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<!-- Device Settings Section -->
<Separator />
<div class="space-y-3">
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
<Button
@@ -784,8 +771,7 @@ watch(currentConfig, () => {
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</div>
</PopoverContent>
</Popover>

View File

@@ -114,6 +114,21 @@ const keyboardLayout = {
},
}
const compactMainLayout = {
default: keyboardLayout.main.default.slice(2),
shift: keyboardLayout.main.shift.slice(2),
}
const isCompactLayout = ref(false)
let compactLayoutMedia: MediaQueryList | null = null
let compactLayoutListener: ((event: MediaQueryListEvent) => void) | null = null
function setCompactLayout(active: boolean) {
if (isCompactLayout.value === active) return
isCompactLayout.value = active
updateKeyboardLayout()
}
// Key display mapping with Unicode symbols (JetKVM style)
const keyDisplayMap = computed<Record<string, string>>(() => {
// OS-specific Meta key labels
@@ -233,14 +248,15 @@ function switchOsLayout(os: KeyboardOsType) {
// Update keyboard layout based on selected OS
function updateKeyboardLayout() {
const bottomRow = getBottomRow()
const baseLayout = isCompactLayout.value ? compactMainLayout : keyboardLayout.main
const newLayout = {
...keyboardLayout.main,
...baseLayout,
default: [
...keyboardLayout.main.default.slice(0, -1),
...baseLayout.default.slice(0, -1),
bottomRow,
],
shift: [
...keyboardLayout.main.shift.slice(0, -1),
...baseLayout.shift.slice(0, -1),
bottomRow,
],
}
@@ -422,7 +438,7 @@ function initKeyboards() {
// Main keyboard - pass element directly instead of selector string
mainKeyboard.value = new Keyboard(mainEl, {
layout: keyboardLayout.main,
layout: isCompactLayout.value ? compactMainLayout : keyboardLayout.main,
layoutName: layoutName.value,
display: keyDisplayMap.value,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
@@ -471,6 +487,7 @@ function initKeyboards() {
stopMouseUpPropagation: true,
})
updateKeyboardLayout()
console.log('[VirtualKeyboard] Keyboards initialized:', id)
}
@@ -570,6 +587,15 @@ onMounted(() => {
selectedOs.value = savedOs
}
if (window.matchMedia) {
compactLayoutMedia = window.matchMedia('(max-width: 640px)')
setCompactLayout(compactLayoutMedia.matches)
compactLayoutListener = (event: MediaQueryListEvent) => {
setCompactLayout(event.matches)
}
compactLayoutMedia.addEventListener('change', compactLayoutListener)
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('touchmove', onDrag)
document.addEventListener('mouseup', endDrag)
@@ -577,6 +603,9 @@ onMounted(() => {
})
onUnmounted(() => {
if (compactLayoutMedia && compactLayoutListener) {
compactLayoutMedia.removeEventListener('change', compactLayoutListener)
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('mouseup', endDrag)
@@ -1112,6 +1141,80 @@ html.dark .hg-theme-default .hg-button.down-key,
}
}
@media (max-width: 640px) {
.vkb .simple-keyboard .hg-button {
height: 30px;
font-size: 10px;
padding: 0 4px;
margin: 0 1px 3px 0;
min-width: 26px;
}
.vkb .simple-keyboard .hg-button.combination-key {
font-size: 9px;
height: 24px;
padding: 0 6px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
min-width: 60px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] {
min-width: 52px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
min-width: 60px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
min-width: 70px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
min-width: 80px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"],
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
min-width: 46px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 140px;
}
.kb-control-container .hg-button {
min-width: 44px !important;
}
.kb-arrows-container .hg-button {
min-width: 36px !important;
width: 36px !important;
}
.vkb-media-btn {
padding: 4px 8px;
font-size: 14px;
min-width: 32px;
}
}
/* Floating mode - slightly smaller keys but still readable */
.vkb--floating .vkb-body {
padding: 8px;

View File

@@ -32,6 +32,10 @@ function createVideoSession() {
resolve: (ready: boolean) => void
timer: ReturnType<typeof setTimeout>
} | null = null
let webrtcReadyAnyWaiter: {
resolve: (ready: boolean) => void
timer: ReturnType<typeof setTimeout>
} | null = null
let modeReadyWaiter: {
transitionId: string
@@ -62,6 +66,11 @@ function createVideoSession() {
webrtcReadyWaiter.resolve(false)
webrtcReadyWaiter = null
}
if (webrtcReadyAnyWaiter) {
clearTimeout(webrtcReadyAnyWaiter.timer)
webrtcReadyAnyWaiter.resolve(false)
webrtcReadyAnyWaiter = null
}
if (modeReadyWaiter) {
clearTimeout(modeReadyWaiter.timer)
modeReadyWaiter.resolve(null)
@@ -104,6 +113,28 @@ function createVideoSession() {
})
}
function waitForWebRTCReadyAny(timeoutMs = 3000): Promise<boolean> {
if (webrtcReadyAnyWaiter) {
clearTimeout(webrtcReadyAnyWaiter.timer)
webrtcReadyAnyWaiter.resolve(false)
webrtcReadyAnyWaiter = null
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (webrtcReadyAnyWaiter) {
webrtcReadyAnyWaiter = null
}
resolve(false)
}, timeoutMs)
webrtcReadyAnyWaiter = {
resolve,
timer,
}
})
}
function waitForModeReady(transitionId: string, timeoutMs = 5000): Promise<string | null> {
if (modeReadyWaiter) {
clearTimeout(modeReadyWaiter.timer)
@@ -156,6 +187,10 @@ function createVideoSession() {
clearTimeout(webrtcReadyWaiter.timer)
webrtcReadyWaiter.resolve(true)
webrtcReadyWaiter = null
} else if (!data.transition_id && webrtcReadyAnyWaiter) {
clearTimeout(webrtcReadyAnyWaiter.timer)
webrtcReadyAnyWaiter.resolve(true)
webrtcReadyAnyWaiter = null
}
}
@@ -170,6 +205,7 @@ function createVideoSession() {
clearWaiters,
registerTransition,
waitForWebRTCReady,
waitForWebRTCReadyAny,
waitForModeReady,
onModeSwitching,
onModeReady,

View File

@@ -2,7 +2,7 @@
// Provides low-latency video via WebRTC with DataChannel for HID
import { ref, onUnmounted, computed, type Ref } from 'vue'
import { webrtcApi } from '@/api'
import { webrtcApi, type IceCandidate } from '@/api'
import { generateUUID } from '@/lib/utils'
import {
type HidKeyboardEvent,
@@ -10,6 +10,7 @@ import {
encodeKeyboardEvent,
encodeMouseEvent,
} from '@/types/hid'
import { useWebSocket } from '@/composables/useWebSocket'
export type { HidKeyboardEvent, HidMouseEvent }
@@ -39,10 +40,25 @@ export interface WebRTCStats {
// Cached ICE servers from backend API
let cachedIceServers: RTCIceServer[] | null = null
interface WebRTCIceCandidateEvent {
session_id: string
candidate: IceCandidate
}
interface WebRTCIceCompleteEvent {
session_id: string
}
// Fetch ICE servers from backend API
async function fetchIceServers(): Promise<RTCIceServer[]> {
try {
const response = await webrtcApi.getIceServers()
if (response.mdns_mode) {
allowMdnsHostCandidates = response.mdns_mode !== 'disabled'
} else if (response.ice_servers) {
allowMdnsHostCandidates = response.ice_servers.length === 0
}
if (response.ice_servers && response.ice_servers.length > 0) {
cachedIceServers = response.ice_servers.map(server => ({
urls: server.urls,
@@ -65,6 +81,7 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
window.location.hostname.startsWith('10.'))
if (isLocalConnection) {
allowMdnsHostCandidates = false
console.log('[WebRTC] Local connection detected, using host candidates only')
return []
}
@@ -83,8 +100,16 @@ let sessionId: string | null = null
let statsInterval: number | null = null
let isConnecting = false // Lock to prevent concurrent connect calls
let pendingIceCandidates: RTCIceCandidate[] = [] // Queue for ICE candidates before sessionId is set
let pendingRemoteCandidates: WebRTCIceCandidateEvent[] = [] // Queue for server ICE candidates
let pendingRemoteIceComplete = new Set<string>() // Session IDs waiting for end-of-candidates
let seenRemoteCandidates = new Set<string>() // Deduplicate server ICE candidates
let cachedMediaStream: MediaStream | null = null // Cached MediaStream to avoid recreating
let allowMdnsHostCandidates = false
let wsHandlersRegistered = false
const { on: wsOn } = useWebSocket()
const state = ref<WebRTCState>('disconnected')
const videoTrack = ref<MediaStreamTrack | null>(null)
const audioTrack = ref<MediaStreamTrack | null>(null)
@@ -148,6 +173,7 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
// Handle ICE candidates
pc.onicecandidate = async (event) => {
if (!event.candidate) return
if (shouldSkipLocalCandidate(event.candidate)) return
const currentSessionId = sessionId
if (currentSessionId && pc.connectionState !== 'closed') {
@@ -218,6 +244,99 @@ function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
return channel
}
function registerWebSocketHandlers() {
if (wsHandlersRegistered) return
wsHandlersRegistered = true
wsOn('webrtc.ice_candidate', handleRemoteIceCandidate)
wsOn('webrtc.ice_complete', handleRemoteIceComplete)
}
function shouldSkipLocalCandidate(candidate: RTCIceCandidate): boolean {
if (allowMdnsHostCandidates) return false
const value = candidate.candidate || ''
return value.includes(' typ host') && value.includes('.local')
}
async function handleRemoteIceCandidate(data: WebRTCIceCandidateEvent) {
if (!data || !data.candidate) return
// Queue until session is ready and remote description is set
if (!sessionId) {
pendingRemoteCandidates.push(data)
return
}
if (data.session_id !== sessionId) return
if (!peerConnection || !peerConnection.remoteDescription) {
pendingRemoteCandidates.push(data)
return
}
await addRemoteIceCandidate(data.candidate)
}
async function handleRemoteIceComplete(data: WebRTCIceCompleteEvent) {
if (!data || !data.session_id) return
if (!sessionId) {
pendingRemoteIceComplete.add(data.session_id)
return
}
if (data.session_id !== sessionId) return
if (!peerConnection || !peerConnection.remoteDescription) {
pendingRemoteIceComplete.add(data.session_id)
return
}
try {
await peerConnection.addIceCandidate(null)
} catch {
// End-of-candidates failures are non-fatal
}
}
async function addRemoteIceCandidate(candidate: IceCandidate) {
if (!peerConnection) return
if (!candidate.candidate) return
if (seenRemoteCandidates.has(candidate.candidate)) return
seenRemoteCandidates.add(candidate.candidate)
const iceCandidate: RTCIceCandidateInit = {
candidate: candidate.candidate,
sdpMid: candidate.sdpMid ?? undefined,
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
usernameFragment: candidate.usernameFragment ?? undefined,
}
try {
await peerConnection.addIceCandidate(iceCandidate)
} catch {
// ICE candidate add failures are non-fatal
}
}
async function flushPendingRemoteIce() {
if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return
const remaining: WebRTCIceCandidateEvent[] = []
for (const event of pendingRemoteCandidates) {
if (event.session_id === sessionId) {
await addRemoteIceCandidate(event.candidate)
} else {
// Drop candidates for old sessions
}
}
pendingRemoteCandidates = remaining
if (pendingRemoteIceComplete.has(sessionId)) {
pendingRemoteIceComplete.delete(sessionId)
try {
await peerConnection.addIceCandidate(null)
} catch {
// Ignore end-of-candidates errors
}
}
}
// Start collecting stats
function startStatsCollection() {
if (statsInterval) return
@@ -315,6 +434,7 @@ async function flushPendingIceCandidates() {
pendingIceCandidates = []
for (const candidate of candidates) {
if (shouldSkipLocalCandidate(candidate)) continue
try {
await webrtcApi.addIceCandidate(sessionId, {
candidate: candidate.candidate,
@@ -330,6 +450,8 @@ async function flushPendingIceCandidates() {
// Connect to WebRTC server
async function connect(): Promise<boolean> {
registerWebSocketHandlers()
// Prevent concurrent connection attempts
if (isConnecting) {
return false
@@ -384,19 +506,13 @@ async function connect(): Promise<boolean> {
}
await peerConnection.setRemoteDescription(answer)
// Flush any pending server ICE candidates once remote description is set
await flushPendingRemoteIce()
// Add any ICE candidates from the response
if (response.ice_candidates && response.ice_candidates.length > 0) {
for (const candidateObj of response.ice_candidates) {
try {
const iceCandidate: RTCIceCandidateInit = {
candidate: candidateObj.candidate,
sdpMid: candidateObj.sdpMid ?? '0',
sdpMLineIndex: candidateObj.sdpMLineIndex ?? 0,
}
await peerConnection.addIceCandidate(iceCandidate)
} catch {
// ICE candidate add failures are non-fatal
}
await addRemoteIceCandidate(candidateObj)
}
}
@@ -440,6 +556,9 @@ async function disconnect() {
sessionId = null
isConnecting = false
pendingIceCandidates = []
pendingRemoteCandidates = []
pendingRemoteIceComplete.clear()
seenRemoteCandidates.clear()
if (dataChannel) {
dataChannel.close()

View File

@@ -76,6 +76,8 @@ export default {
passwordTooShort: 'Password must be at least 4 characters',
passwordChanged: 'Password changed successfully',
userNotFound: 'User not found',
sessionExpired: 'Session expired',
loggedInElsewhere: 'Logged in elsewhere',
},
status: {
connected: 'Connected',
@@ -263,6 +265,10 @@ export default {
// Help tooltips
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
otgAdvanced: 'Advanced: OTG Preset',
otgProfile: 'Initial HID Preset',
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
otgLowEndpointHint: 'Detected low-endpoint UDC; multimedia keys will be disabled automatically.',
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
// Extensions
@@ -270,8 +276,6 @@ export default {
extensionsDescription: 'Choose which extensions to auto-start',
ttydTitle: 'Web Terminal (ttyd)',
ttydDescription: 'Access device command line in browser',
rustdeskTitle: 'RustDesk Remote Desktop',
rustdeskDescription: 'Remote access via RustDesk client',
extensionsHint: 'These settings can be changed later in Settings',
notInstalled: 'Not installed',
// Password strength
@@ -427,6 +431,8 @@ export default {
basic: 'Basic',
general: 'General',
appearance: 'Appearance',
account: 'User',
access: 'Access',
video: 'Video',
encoder: 'Encoder',
hid: 'HID',
@@ -436,6 +442,7 @@ export default {
users: 'Users',
hardware: 'Hardware',
system: 'System',
other: 'Other',
extensions: 'Extensions',
configured: 'Configured',
security: 'Security',
@@ -457,6 +464,8 @@ export default {
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
usernameDesc: 'Change your login username',
passwordDesc: 'Change your login password',
version: 'Version',
buildInfo: 'Build Info',
detectDevices: 'Detect Devices',
@@ -470,16 +479,35 @@ export default {
httpPort: 'HTTP Port',
configureHttpPort: 'Configure HTTP server port',
// Web server
webServer: 'Basic',
webServerDesc: 'Configure HTTP/HTTPS ports and bind address. Restart required for changes to take effect.',
webServer: 'Access Address',
webServerDesc: 'Configure HTTP/HTTPS ports and listening addresses. Restart required for changes to take effect.',
httpsPort: 'HTTPS Port',
bindAddress: 'Bind Address',
bindAddressDesc: 'IP address the server listens on. 0.0.0.0 means all network interfaces.',
bindMode: 'Listening Address',
bindModeDesc: 'Choose which addresses the web server binds to.',
bindModeAll: 'All addresses',
bindModeLocal: 'Local only (127.0.0.1)',
bindModeCustom: 'Custom address list',
bindIpv6: 'Enable IPv6',
bindAllDesc: 'Also listen on :: (all IPv6 interfaces).',
bindLocalDesc: 'Also listen on ::1 (IPv6 loopback).',
bindAddressList: 'Address List',
bindAddressListDesc: 'One IP address per line (IPv4 or IPv6).',
addBindAddress: 'Add address',
bindAddressListEmpty: 'Add at least one IP address.',
httpsEnabled: 'Enable HTTPS',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
restartRequired: 'Restart Required',
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
restarting: 'Restarting...',
// Auth
auth: 'Access',
authSettings: 'Access Settings',
authSettingsDesc: 'Single-user access and session behavior',
allowMultipleSessions: 'Allow multiple web sessions',
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
singleUserSessionNote: 'Single-user mode is enforced; only session concurrency is configurable.',
// User management
userManagement: 'User Management',
userManagementDesc: 'Manage user accounts and permissions',
@@ -569,6 +597,29 @@ export default {
hidBackend: 'HID Backend',
serialDevice: 'Serial Device',
baudRate: 'Baud Rate',
otgHidProfile: 'OTG HID Profile',
otgHidProfileDesc: 'Select which HID functions are exposed to the host',
profile: 'Profile',
otgProfileFull: 'Keyboard + relative mouse + absolute mouse + multimedia + MSD',
otgProfileFullNoMsd: 'Keyboard + relative mouse + absolute mouse + multimedia (no MSD)',
otgProfileFullNoConsumer: 'Keyboard + relative mouse + absolute mouse + MSD (no multimedia)',
otgProfileFullNoConsumerNoMsd: 'Keyboard + relative mouse + absolute mouse (no multimedia, no MSD)',
otgProfileLegacyKeyboard: 'Keyboard only',
otgProfileLegacyMouseRelative: 'Relative mouse only',
otgProfileCustom: 'Custom',
otgFunctionKeyboard: 'Keyboard',
otgFunctionKeyboardDesc: 'Standard HID keyboard device',
otgFunctionMouseRelative: 'Relative Mouse',
otgFunctionMouseRelativeDesc: 'Traditional mouse movement (HID boot mouse)',
otgFunctionMouseAbsolute: 'Absolute Mouse',
otgFunctionMouseAbsoluteDesc: 'Absolute positioning (touchscreen-like)',
otgFunctionConsumer: 'Consumer Control',
otgFunctionConsumerDesc: 'Media keys like volume/play/pause',
otgFunctionMsd: 'Mass Storage (MSD)',
otgFunctionMsdDesc: 'Expose USB storage to the host',
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
otgLowEndpointHint: 'Low-endpoint UDC detected; multimedia keys will be disabled automatically.',
otgFunctionMinWarning: 'Enable at least one HID function before saving',
// OTG Descriptor
otgDescriptor: 'USB Device Descriptor',
otgDescriptorDesc: 'Configure USB device identification',
@@ -678,6 +729,10 @@ export default {
viewLogs: 'View Logs',
noLogs: 'No logs available',
binaryNotFound: '{path} not found, please install the required program',
remoteAccess: {
title: 'Remote Access',
desc: 'GOSTC NAT traversal and Easytier networking',
},
// ttyd
ttyd: {
title: 'Ttyd Web Terminal',
@@ -714,10 +769,10 @@ export default {
serverSettings: 'Server Settings',
rendezvousServer: 'ID Server',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: 'Configure your RustDesk server address',
rendezvousServerHint: 'Configure your RustDesk server address (port optional, defaults to 21116)',
relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: 'Relay server address, auto-derived from ID server if empty',
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
relayKey: 'Relay Key',
relayKeyPlaceholder: 'Enter relay server key',
relayKeySet: '••••••••',

View File

@@ -76,6 +76,8 @@ export default {
passwordTooShort: '密码至少需要4个字符',
passwordChanged: '密码修改成功',
userNotFound: '用户不存在',
sessionExpired: '会话已过期',
loggedInElsewhere: '已在别处登录',
},
status: {
connected: '已连接',
@@ -263,6 +265,10 @@ export default {
// Help tooltips
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
otgAdvanced: '高级OTG 预设',
otgProfile: '初始 HID 预设',
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
// Extensions
@@ -270,8 +276,6 @@ export default {
extensionsDescription: '选择要自动启动的扩展服务',
ttydTitle: 'Web 终端 (ttyd)',
ttydDescription: '在浏览器中访问设备的命令行终端',
rustdeskTitle: 'RustDesk 远程桌面',
rustdeskDescription: '通过 RustDesk 客户端远程访问设备',
extensionsHint: '这些设置可以在设置页面中随时更改',
notInstalled: '未安装',
// Password strength
@@ -427,6 +431,8 @@ export default {
basic: '基础',
general: '通用',
appearance: '外观',
account: '用户',
access: '访问',
video: '视频',
encoder: '编码器',
hid: 'HID',
@@ -436,6 +442,7 @@ export default {
users: '用户',
hardware: '硬件',
system: '系统',
other: '其他',
extensions: '扩展',
configured: '已配置',
security: '安全',
@@ -457,6 +464,8 @@ export default {
changePassword: '修改密码',
currentPassword: '当前密码',
newPassword: '新密码',
usernameDesc: '修改登录用户名',
passwordDesc: '修改登录密码',
version: '版本',
buildInfo: '构建信息',
detectDevices: '探测设备',
@@ -470,16 +479,35 @@ export default {
httpPort: 'HTTP 端口',
configureHttpPort: '配置 HTTP 服务器端口',
// Web server
webServer: '基础',
webServerDesc: '配置 HTTP/HTTPS 端口和绑定地址,修改后需要重启生效',
webServer: '访问地址',
webServerDesc: '配置 HTTP/HTTPS 端口和监听地址,修改后需要重启生效',
httpsPort: 'HTTPS 端口',
bindAddress: '绑定地址',
bindAddressDesc: '服务器监听的 IP 地址0.0.0.0 表示监听所有网络接口',
bindMode: '监听地址',
bindModeDesc: '选择 Web 服务监听哪些地址。',
bindModeAll: '所有地址',
bindModeLocal: '仅本地 (127.0.0.1)',
bindModeCustom: '自定义地址列表',
bindIpv6: '启用 IPv6',
bindAllDesc: '同时监听 ::(所有 IPv6 地址)。',
bindLocalDesc: '同时监听 ::1IPv6 本地回环)。',
bindAddressList: '地址列表',
bindAddressListDesc: '每行一个 IPIPv4 或 IPv6。',
addBindAddress: '添加地址',
bindAddressListEmpty: '请至少填写一个 IP 地址。',
httpsEnabled: '启用 HTTPS',
httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签名证书)',
restartRequired: '需要重启',
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
restarting: '正在重启...',
// Auth
auth: '访问控制',
authSettings: '访问设置',
authSettingsDesc: '单用户访问与会话策略',
allowMultipleSessions: '允许多个 Web 会话',
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
singleUserSessionNote: '系统固定为单用户模式,仅可配置会话并发方式。',
// User management
userManagement: '用户管理',
userManagementDesc: '管理用户账号和权限',
@@ -569,6 +597,29 @@ export default {
hidBackend: 'HID 后端',
serialDevice: '串口设备',
baudRate: '波特率',
otgHidProfile: 'OTG HID 组合',
otgHidProfileDesc: '选择对目标主机暴露的 HID 功能',
profile: '组合',
otgProfileFull: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体 + 虚拟媒体',
otgProfileFullNoMsd: '键盘 + 相对鼠标 + 绝对鼠标 + 多媒体(不含虚拟媒体)',
otgProfileFullNoConsumer: '键盘 + 相对鼠标 + 绝对鼠标 + 虚拟媒体(不含多媒体)',
otgProfileFullNoConsumerNoMsd: '键盘 + 相对鼠标 + 绝对鼠标(不含多媒体与虚拟媒体)',
otgProfileLegacyKeyboard: '仅键盘',
otgProfileLegacyMouseRelative: '仅相对鼠标',
otgProfileCustom: '自定义',
otgFunctionKeyboard: '键盘',
otgFunctionKeyboardDesc: '标准 HID 键盘设备',
otgFunctionMouseRelative: '相对鼠标',
otgFunctionMouseRelativeDesc: '传统鼠标移动HID 启动鼠标)',
otgFunctionMouseAbsolute: '绝对鼠标',
otgFunctionMouseAbsoluteDesc: '绝对定位(类似触控)',
otgFunctionConsumer: '多媒体控制',
otgFunctionConsumerDesc: '音量/播放/暂停等按键',
otgFunctionMsd: '虚拟媒体MSD',
otgFunctionMsdDesc: '向目标主机暴露 USB 存储',
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键。',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
// OTG Descriptor
otgDescriptor: 'USB 设备描述符',
otgDescriptorDesc: '配置 USB 设备标识信息',
@@ -678,6 +729,10 @@ export default {
viewLogs: '查看日志',
noLogs: '暂无日志',
binaryNotFound: '未找到 {path},请先安装对应程序',
remoteAccess: {
title: '远程访问',
desc: 'GOSTC 内网穿透与 Easytier 组网',
},
// ttyd
ttyd: {
title: 'Ttyd 网页终端',
@@ -714,10 +769,10 @@ export default {
serverSettings: '服务器设置',
rendezvousServer: 'ID 服务器',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: '请配置您的 RustDesk 服务器地址',
rendezvousServerHint: '请配置您的 RustDesk 服务器地址(端口可省略,默认 21116',
relayServer: '中继服务器',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导',
relayServerHint: '中继服务器地址(端口可省略,默认 21117,留空则自动从 ID 服务器推导',
relayKey: '中继密钥',
relayKeyPlaceholder: '输入中继服务器密钥',
relayKeySet: '••••••••',

View File

@@ -1,4 +1,7 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { toast } from 'vue-sonner'
import i18n from '@/i18n'
import { ApiError } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
@@ -24,7 +27,7 @@ const routes: RouteRecordRaw[] = [
path: '/settings',
name: 'Settings',
component: () => import('@/views/SettingsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
meta: { requiresAuth: true },
},
]
@@ -33,29 +36,62 @@ const router = createRouter({
routes,
})
let sessionExpiredNotified = false
function t(key: string, params?: Record<string, unknown>): string {
return String(i18n.global.t(key, params as any))
}
// Navigation guard
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
// Check if system needs setup
if (!authStore.initialized && to.name !== 'Setup') {
// Prevent access to setup after initialization
const shouldCheckSetup = to.name === 'Setup' || !authStore.initialized
if (shouldCheckSetup) {
try {
await authStore.checkSetupStatus()
if (authStore.needsSetup) {
return next({ name: 'Setup' })
}
} catch {
// Continue anyway
}
}
if (authStore.needsSetup) {
if (to.name !== 'Setup') {
return next({ name: 'Setup' })
}
} else if (authStore.initialized && to.name === 'Setup') {
if (!authStore.isAuthenticated) {
try {
await authStore.checkAuth()
} catch {
// Not authenticated
}
}
return next({ name: authStore.isAuthenticated ? 'Console' : 'Login' })
}
// Check authentication for protected routes
if (to.meta.requiresAuth !== false) {
if (!authStore.isAuthenticated) {
try {
await authStore.checkAuth()
} catch {
} catch (e) {
// Not authenticated
if (e instanceof ApiError && e.status === 401 && !sessionExpiredNotified) {
const normalized = e.message.toLowerCase()
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')
const isSessionExpired = normalized.includes('session expired')
if (isLoggedInElsewhere || isSessionExpired) {
sessionExpiredNotified = true
const titleKey = isLoggedInElsewhere ? 'auth.loggedInElsewhere' : 'auth.sessionExpired'
toast.error(t(titleKey), {
description: e.message,
duration: 3000,
})
}
}
}
if (!authStore.isAuthenticated) {
@@ -63,11 +99,6 @@ router.beforeEach(async (to, _from, next) => {
}
}
// Check admin requirement
if (to.meta.requiresAdmin && !authStore.isAdmin) {
// Redirect non-admin users to console
return next({ name: 'Console' })
}
}
next()

View File

@@ -4,7 +4,6 @@ import { authApi, systemApi } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const user = ref<string | null>(null)
const isAdmin = ref(false)
const isAuthenticated = ref(false)
const initialized = ref(false)
const needsSetup = ref(false)
@@ -30,12 +29,14 @@ export const useAuthStore = defineStore('auth', () => {
const result = await authApi.check()
isAuthenticated.value = result.authenticated
user.value = result.user || null
isAdmin.value = result.is_admin ?? false
return result
} catch {
} catch (e) {
isAuthenticated.value = false
user.value = null
isAdmin.value = false
error.value = e instanceof Error ? e.message : 'Not authenticated'
if (e instanceof Error) {
throw e
}
throw new Error('Not authenticated')
}
}
@@ -49,13 +50,6 @@ export const useAuthStore = defineStore('auth', () => {
if (result.success) {
isAuthenticated.value = true
user.value = username
// After login, fetch admin status
try {
const authResult = await authApi.check()
isAdmin.value = authResult.is_admin ?? false
} catch {
isAdmin.value = false
}
return true
} else {
error.value = result.message || 'Login failed'
@@ -75,7 +69,6 @@ export const useAuthStore = defineStore('auth', () => {
} finally {
isAuthenticated.value = false
user.value = null
isAdmin.value = false
}
}
@@ -91,6 +84,7 @@ export const useAuthStore = defineStore('auth', () => {
hid_ch9329_port?: string
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
@@ -119,7 +113,6 @@ export const useAuthStore = defineStore('auth', () => {
return {
user,
isAdmin,
isAuthenticated,
initialized,
needsSetup,

506
web/src/stores/config.ts Normal file
View File

@@ -0,0 +1,506 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
authConfigApi,
atxConfigApi,
audioConfigApi,
hidConfigApi,
msdConfigApi,
rustdeskConfigApi,
streamConfigApi,
videoConfigApi,
webConfigApi,
} from '@/api'
import type {
AtxConfig,
AtxConfigUpdate,
AudioConfig,
AudioConfigUpdate,
AuthConfig,
AuthConfigUpdate,
HidConfig,
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
StreamConfigResponse,
StreamConfigUpdate,
VideoConfig,
VideoConfigUpdate,
WebConfig,
WebConfigUpdate,
} from '@/types/generated'
import type {
RustDeskConfigResponse as ApiRustDeskConfigResponse,
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
RustDeskStatusResponse as ApiRustDeskStatusResponse,
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
} from '@/api'
function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'string') return error
return 'Unknown error'
}
export const useConfigStore = defineStore('config', () => {
const auth = ref<AuthConfig | null>(null)
const video = ref<VideoConfig | null>(null)
const audio = ref<AudioConfig | null>(null)
const hid = ref<HidConfig | null>(null)
const msd = ref<MsdConfig | null>(null)
const stream = ref<StreamConfigResponse | null>(null)
const web = ref<WebConfig | null>(null)
const atx = ref<AtxConfig | null>(null)
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
const authLoading = ref(false)
const videoLoading = ref(false)
const audioLoading = ref(false)
const hidLoading = ref(false)
const msdLoading = ref(false)
const streamLoading = ref(false)
const webLoading = ref(false)
const atxLoading = ref(false)
const rustdeskLoading = ref(false)
const authError = ref<string | null>(null)
const videoError = ref<string | null>(null)
const audioError = ref<string | null>(null)
const hidError = ref<string | null>(null)
const msdError = ref<string | null>(null)
const streamError = ref<string | null>(null)
const webError = ref<string | null>(null)
const atxError = ref<string | null>(null)
const rustdeskError = ref<string | null>(null)
let authPromise: Promise<AuthConfig> | null = null
let videoPromise: Promise<VideoConfig> | null = null
let audioPromise: Promise<AudioConfig> | null = null
let hidPromise: Promise<HidConfig> | null = null
let msdPromise: Promise<MsdConfig> | null = null
let streamPromise: Promise<StreamConfigResponse> | null = null
let webPromise: Promise<WebConfig> | null = null
let atxPromise: Promise<AtxConfig> | null = null
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
async function refreshAuth() {
if (authLoading.value && authPromise) return authPromise
authLoading.value = true
authError.value = null
const request = authConfigApi.get()
.then((response) => {
auth.value = response
return response
})
.catch((error) => {
authError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
authLoading.value = false
authPromise = null
})
authPromise = request
return request
}
async function refreshVideo() {
if (videoLoading.value && videoPromise) return videoPromise
videoLoading.value = true
videoError.value = null
const request = videoConfigApi.get()
.then((response) => {
video.value = response
return response
})
.catch((error) => {
videoError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
videoLoading.value = false
videoPromise = null
})
videoPromise = request
return request
}
async function refreshAudio() {
if (audioLoading.value && audioPromise) return audioPromise
audioLoading.value = true
audioError.value = null
const request = audioConfigApi.get()
.then((response) => {
audio.value = response
return response
})
.catch((error) => {
audioError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
audioLoading.value = false
audioPromise = null
})
audioPromise = request
return request
}
async function refreshHid() {
if (hidLoading.value && hidPromise) return hidPromise
hidLoading.value = true
hidError.value = null
const request = hidConfigApi.get()
.then((response) => {
hid.value = response
return response
})
.catch((error) => {
hidError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
hidLoading.value = false
hidPromise = null
})
hidPromise = request
return request
}
async function refreshMsd() {
if (msdLoading.value && msdPromise) return msdPromise
msdLoading.value = true
msdError.value = null
const request = msdConfigApi.get()
.then((response) => {
msd.value = response
return response
})
.catch((error) => {
msdError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
msdLoading.value = false
msdPromise = null
})
msdPromise = request
return request
}
async function refreshStream() {
if (streamLoading.value && streamPromise) return streamPromise
streamLoading.value = true
streamError.value = null
const request = streamConfigApi.get()
.then((response) => {
stream.value = response
return response
})
.catch((error) => {
streamError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
streamLoading.value = false
streamPromise = null
})
streamPromise = request
return request
}
async function refreshWeb() {
if (webLoading.value && webPromise) return webPromise
webLoading.value = true
webError.value = null
const request = webConfigApi.get()
.then((response) => {
web.value = response
return response
})
.catch((error) => {
webError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
webLoading.value = false
webPromise = null
})
webPromise = request
return request
}
async function refreshAtx() {
if (atxLoading.value && atxPromise) return atxPromise
atxLoading.value = true
atxError.value = null
const request = atxConfigApi.get()
.then((response) => {
atx.value = response
return response
})
.catch((error) => {
atxError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
atxLoading.value = false
atxPromise = null
})
atxPromise = request
return request
}
async function refreshRustdeskConfig() {
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.get()
.then((response) => {
rustdeskConfig.value = response
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskPromise = null
})
rustdeskPromise = request
return request
}
async function refreshRustdeskStatus() {
if (rustdeskLoading.value && rustdeskStatusPromise) return rustdeskStatusPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.getStatus()
.then((response) => {
rustdeskStatus.value = response
rustdeskConfig.value = response.config
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskStatusPromise = null
})
rustdeskStatusPromise = request
return request
}
async function refreshRustdeskPassword() {
if (rustdeskLoading.value && rustdeskPasswordPromise) return rustdeskPasswordPromise
rustdeskLoading.value = true
rustdeskError.value = null
const request = rustdeskConfigApi.getPassword()
.then((response) => {
rustdeskPassword.value = response
return response
})
.catch((error) => {
rustdeskError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rustdeskLoading.value = false
rustdeskPasswordPromise = null
})
rustdeskPasswordPromise = request
return request
}
function ensureAuth() {
if (auth.value) return Promise.resolve(auth.value)
return refreshAuth()
}
function ensureVideo() {
if (video.value) return Promise.resolve(video.value)
return refreshVideo()
}
function ensureAudio() {
if (audio.value) return Promise.resolve(audio.value)
return refreshAudio()
}
function ensureHid() {
if (hid.value) return Promise.resolve(hid.value)
return refreshHid()
}
function ensureMsd() {
if (msd.value) return Promise.resolve(msd.value)
return refreshMsd()
}
function ensureStream() {
if (stream.value) return Promise.resolve(stream.value)
return refreshStream()
}
function ensureWeb() {
if (web.value) return Promise.resolve(web.value)
return refreshWeb()
}
function ensureAtx() {
if (atx.value) return Promise.resolve(atx.value)
return refreshAtx()
}
function ensureRustdeskConfig() {
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
return refreshRustdeskConfig()
}
async function updateAuth(update: AuthConfigUpdate) {
const response = await authConfigApi.update(update)
auth.value = response
return response
}
async function updateVideo(update: VideoConfigUpdate) {
const response = await videoConfigApi.update(update)
video.value = response
return response
}
async function updateAudio(update: AudioConfigUpdate) {
const response = await audioConfigApi.update(update)
audio.value = response
return response
}
async function updateHid(update: HidConfigUpdate) {
const response = await hidConfigApi.update(update)
hid.value = response
return response
}
async function updateMsd(update: MsdConfigUpdate) {
const response = await msdConfigApi.update(update)
msd.value = response
return response
}
async function updateStream(update: StreamConfigUpdate) {
const response = await streamConfigApi.update(update)
stream.value = response
return response
}
async function updateWeb(update: WebConfigUpdate) {
const response = await webConfigApi.update(update)
web.value = response
return response
}
async function updateAtx(update: AtxConfigUpdate) {
const response = await atxConfigApi.update(update)
atx.value = response
return response
}
async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
const response = await rustdeskConfigApi.update(update)
rustdeskConfig.value = response
return response
}
async function regenerateRustdeskId() {
const response = await rustdeskConfigApi.regenerateId()
rustdeskConfig.value = response
return response
}
async function regenerateRustdeskPassword() {
const response = await rustdeskConfigApi.regeneratePassword()
rustdeskConfig.value = response
return response
}
return {
auth,
video,
audio,
hid,
msd,
stream,
web,
atx,
rustdeskConfig,
rustdeskStatus,
rustdeskPassword,
authLoading,
videoLoading,
audioLoading,
hidLoading,
msdLoading,
streamLoading,
webLoading,
atxLoading,
rustdeskLoading,
authError,
videoError,
audioError,
hidError,
msdError,
streamError,
webError,
atxError,
rustdeskError,
refreshAuth,
refreshVideo,
refreshAudio,
refreshHid,
refreshMsd,
refreshStream,
refreshWeb,
refreshAtx,
refreshRustdeskConfig,
refreshRustdeskStatus,
refreshRustdeskPassword,
ensureAuth,
ensureVideo,
ensureAudio,
ensureHid,
ensureMsd,
ensureStream,
ensureWeb,
ensureAtx,
ensureRustdeskConfig,
updateAuth,
updateVideo,
updateAudio,
updateHid,
updateMsd,
updateStream,
updateWeb,
updateAtx,
updateRustdesk,
regenerateRustdeskId,
regenerateRustdeskPassword,
}
})

View File

@@ -1,2 +1,3 @@
export { useAuthStore } from './auth'
export { useConfigStore } from './config'
export { useSystemStore } from './system'

View File

@@ -24,8 +24,6 @@ interface StreamState {
resolution: [number, number] | null
targetFps: number
clients: number
framesCaptured: number
framesDropped: number
streamMode: string // 'mjpeg' or 'webrtc'
error: string | null
}
@@ -277,8 +275,6 @@ export const useSystemStore = defineStore('system', () => {
resolution: data.video.resolution,
targetFps: data.video.fps,
clients: stream.value?.clients ?? 0,
framesCaptured: stream.value?.framesCaptured ?? 0,
framesDropped: stream.value?.framesDropped ?? 0,
streamMode: data.video.stream_mode || 'mjpeg',
error: data.video.error,
}

View File

@@ -6,6 +6,8 @@
export interface AuthConfig {
/** Session timeout in seconds */
session_timeout_secs: number;
/** Allow multiple concurrent web sessions (single-user mode) */
single_user_allow_multiple_sessions: boolean;
/** Enable 2FA */
totp_enabled: boolean;
/** TOTP secret (encrypted) */
@@ -52,6 +54,32 @@ export interface OtgDescriptorConfig {
serial_number?: string;
}
/** OTG HID function profile */
export enum OtgHidProfile {
/** Full HID device set (keyboard + relative mouse + absolute mouse + consumer control) */
Full = "full",
/** Full HID device set without MSD */
FullNoMsd = "full_no_msd",
/** Full HID device set without consumer control */
FullNoConsumer = "full_no_consumer",
/** Full HID device set without consumer control and MSD */
FullNoConsumerNoMsd = "full_no_consumer_no_msd",
/** Legacy profile: only keyboard */
LegacyKeyboard = "legacy_keyboard",
/** Legacy profile: only relative mouse */
LegacyMouseRelative = "legacy_mouse_relative",
/** Custom function selection */
Custom = "custom",
}
/** OTG HID function selection (used when profile is Custom) */
export interface OtgHidFunctions {
keyboard: boolean;
mouse_relative: boolean;
mouse_absolute: boolean;
consumer: boolean;
}
/** HID configuration */
export interface HidConfig {
/** HID backend type */
@@ -64,6 +92,10 @@ export interface HidConfig {
otg_udc?: string;
/** OTG USB device descriptor configuration */
otg_descriptor?: OtgDescriptorConfig;
/** OTG HID function profile */
otg_profile?: OtgHidProfile;
/** OTG HID function selection (used when profile is Custom) */
otg_functions?: OtgHidFunctions;
/** CH9329 serial port */
ch9329_port: string;
/** CH9329 baud rate */
@@ -250,7 +282,9 @@ export interface WebConfig {
http_port: number;
/** HTTPS port */
https_port: number;
/** Bind address */
/** Bind addresses (preferred) */
bind_addresses: string[];
/** Bind address (legacy) */
bind_address: string;
/** Enable HTTPS */
https_enabled: boolean;
@@ -392,6 +426,10 @@ export interface AudioConfigUpdate {
quality?: string;
}
export interface AuthConfigUpdate {
single_user_allow_multiple_sessions?: boolean;
}
/** Update easytier config */
export interface EasytierConfigUpdate {
enabled?: boolean;
@@ -496,12 +534,21 @@ export interface OtgDescriptorConfigUpdate {
serial_number?: string;
}
export interface OtgHidFunctionsUpdate {
keyboard?: boolean;
mouse_relative?: boolean;
mouse_absolute?: boolean;
consumer?: boolean;
}
export interface HidConfigUpdate {
backend?: HidBackend;
ch9329_port?: string;
ch9329_baudrate?: number;
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate;
otg_profile?: OtgHidProfile;
otg_functions?: OtgHidFunctionsUpdate;
mouse_absolute?: boolean;
}
@@ -580,6 +627,7 @@ export interface VideoConfigUpdate {
export interface WebConfigUpdate {
http_port?: number;
https_port?: number;
bind_addresses?: string[];
bind_address?: string;
https_enabled?: boolean;
}

View File

@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
import { useWebSocket } from '@/composables/useWebSocket'
import { useConsoleEvents } from '@/composables/useConsoleEvents'
@@ -10,7 +11,7 @@ import { useHidWebSocket } from '@/composables/useHidWebSocket'
import { useWebRTC } from '@/composables/useWebRTC'
import { useVideoSession } from '@/composables/useVideoSession'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, userApi } from '@/api'
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, authApi } from '@/api'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
@@ -59,6 +60,7 @@ import { setLanguage } from '@/i18n'
const { t, locale } = useI18n()
const router = useRouter()
const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
const { connected: wsConnected, networkError: wsNetworkError } = useWebSocket()
const hidWs = useHidWebSocket()
@@ -134,6 +136,15 @@ let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas be
// Cursor visibility (from localStorage, updated via storage event)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
function syncMouseModeFromConfig() {
const mouseAbsolute = configStore.hid?.mouse_absolute
if (typeof mouseAbsolute !== 'boolean') return
const nextMode: 'absolute' | 'relative' = mouseAbsolute ? 'absolute' : 'relative'
if (mouseMode.value !== nextMode) {
mouseMode.value = nextMode
}
}
// Virtual keyboard state
const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true)
@@ -641,7 +652,7 @@ function handleStreamConfigChanging(data: any) {
})
}
function handleStreamConfigApplied(data: any) {
async function handleStreamConfigApplied(data: any) {
// Reset consecutive error counter for new config
consecutiveErrors = 0
@@ -662,6 +673,10 @@ function handleStreamConfigApplied(data: any) {
if (videoMode.value !== 'mjpeg') {
// In WebRTC mode, reconnect WebRTC (session was closed due to config change)
const ready = await videoSession.waitForWebRTCReadyAny(3000)
if (!ready) {
console.warn('[WebRTC] Backend not ready after timeout (config change), attempting connection anyway')
}
switchToWebRTC(videoMode.value)
} else {
// In MJPEG mode, refresh the MJPEG stream
@@ -1259,16 +1274,7 @@ async function handleChangePassword() {
changingPassword.value = true
try {
// Get current user ID - we need to fetch user list first
const result = await userApi.list()
const currentUser = result.users.find(u => u.username === authStore.user)
if (!currentUser) {
toast.error(t('auth.userNotFound'))
return
}
await userApi.changePassword(currentUser.id, newPassword.value, currentPassword.value)
await authApi.changePassword(currentPassword.value, newPassword.value)
toast.success(t('auth.passwordChanged'))
// Reset form and close dialog
@@ -1792,6 +1798,9 @@ onMounted(async () => {
// 4. 其他初始化
await systemStore.startStream().catch(() => {})
await systemStore.fetchAllStates()
await configStore.refreshHid().then(() => {
syncMouseModeFromConfig()
}).catch(() => {})
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
@@ -1805,6 +1814,10 @@ onMounted(async () => {
window.addEventListener('hidMouseSendIntervalChanged', handleMouseSendIntervalChange as EventListener)
window.addEventListener('storage', handleMouseSendIntervalStorage)
watch(() => configStore.hid?.mouse_absolute, () => {
syncMouseModeFromConfig()
})
// Pointer Lock event listeners
document.addEventListener('pointerlockchange', handlePointerLockChange)
document.addEventListener('pointerlockerror', handlePointerLockError)
@@ -1886,104 +1899,161 @@ onUnmounted(() => {
<template>
<div class="h-screen flex flex-col bg-background">
<!-- Header -->
<header class="h-14 shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="h-full px-4 flex items-center justify-between">
<!-- Left: Logo -->
<div class="flex items-center gap-6">
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="px-4">
<div class="h-14 flex items-center justify-between">
<!-- Left: Logo -->
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Monitor class="h-6 w-6 text-primary" />
<span class="font-bold text-lg">One-KVM</span>
</div>
</div>
<!-- Right: Status Cards + User Menu -->
<div class="flex items-center gap-2">
<Monitor class="h-6 w-6 text-primary" />
<span class="font-bold text-lg">One-KVM</span>
<div class="hidden md:flex items-center gap-2">
<!-- Video Status -->
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
/>
<!-- Audio Status -->
<StatusCard
v-if="systemStore.audio?.available"
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
/>
<!-- HID Status -->
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
/>
<!-- MSD Status - Hidden when CH9329 backend (no USB gadget support) -->
<StatusCard
v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
/>
</div>
<!-- Separator -->
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleLanguage">
<Languages class="h-4 w-4" />
</Button>
<!-- User Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem class="md:hidden" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4 mr-2" />
<Moon v-else class="h-4 w-4 mr-2" />
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
</DropdownMenuItem>
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
<Languages class="h-4 w-4 mr-2" />
{{ locale === 'zh-CN' ? 'English' : '中文' }}
</DropdownMenuItem>
<DropdownMenuSeparator class="md:hidden" />
<DropdownMenuItem @click="changePasswordDialogOpen = true">
<KeyRound class="h-4 w-4 mr-2" />
{{ t('auth.changePassword') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('auth.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<!-- Right: Status Cards + User Menu -->
<div class="flex items-center gap-2">
<!-- Video Status -->
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
/>
<!-- Mobile Status Row -->
<div class="md:hidden pb-2">
<div class="flex items-center gap-2 overflow-x-auto">
<div class="shrink-0">
<StatusCard
:title="t('statusCard.video')"
type="video"
:status="videoStatus"
:quick-info="videoQuickInfo"
:error-message="videoErrorMessage"
:details="videoDetails"
compact
/>
</div>
<!-- Audio Status -->
<StatusCard
v-if="systemStore.audio?.available"
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
/>
<div v-if="systemStore.audio?.available" class="shrink-0">
<StatusCard
:title="t('statusCard.audio')"
type="audio"
:status="audioStatus"
:quick-info="audioQuickInfo"
:error-message="audioErrorMessage"
:details="audioDetails"
compact
/>
</div>
<!-- HID Status -->
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
/>
<div class="shrink-0">
<StatusCard
:title="t('statusCard.hid')"
type="hid"
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
compact
/>
</div>
<!-- MSD Status - Admin only, hidden when CH9329 backend (no USB gadget support) -->
<StatusCard
v-if="authStore.isAdmin && systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
/>
<!-- Separator -->
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden md:block mx-1" />
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" @click="toggleLanguage">
<Languages class="h-4 w-4" />
</Button>
<!-- User Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem class="md:hidden" @click="toggleTheme">
<Sun v-if="isDark" class="h-4 w-4 mr-2" />
<Moon v-else class="h-4 w-4 mr-2" />
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
</DropdownMenuItem>
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
<Languages class="h-4 w-4 mr-2" />
{{ locale === 'zh-CN' ? 'English' : '中文' }}
</DropdownMenuItem>
<DropdownMenuSeparator class="md:hidden" />
<DropdownMenuItem @click="changePasswordDialogOpen = true">
<KeyRound class="h-4 w-4 mr-2" />
{{ t('auth.changePassword') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('auth.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'" class="shrink-0">
<StatusCard
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
:quick-info="msdQuickInfo"
:error-message="msdErrorMessage"
:details="msdDetails"
hover-align="end"
compact
/>
</div>
</div>
</div>
</div>
</header>
@@ -1993,7 +2063,6 @@ onUnmounted(() => {
:mouse-mode="mouseMode"
:video-mode="videoMode"
:ttyd-running="ttydStatus?.running"
:is-admin="authStore.isAdmin"
@toggle-fullscreen="toggleFullscreen"
@toggle-stats="statsSheetOpen = true"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"

File diff suppressed because it is too large Load Diff

View File

@@ -96,12 +96,13 @@ const hidBackend = ref('ch9329')
const ch9329Port = ref('')
const ch9329Baudrate = ref(9600)
const otgUdc = ref('')
const hidOtgProfile = ref('full')
const otgProfileTouched = ref(false)
const showAdvancedOtg = ref(false)
// Extension settings
const ttydEnabled = ref(false)
const rustdeskEnabled = ref(false)
const ttydAvailable = ref(false)
const rustdeskAvailable = ref(true) // RustDesk is built-in, always available
// Encoder backend settings
const encoderBackend = ref('auto')
@@ -139,7 +140,6 @@ interface DeviceInfo {
udc: Array<{ name: string }>
extensions: {
ttyd_available: boolean
rustdesk_available: boolean
}
}
@@ -150,7 +150,6 @@ const devices = ref<DeviceInfo>({
udc: [],
extensions: {
ttyd_available: false,
rustdesk_available: true,
},
})
@@ -204,6 +203,26 @@ const availableFps = computed(() => {
return resolution?.fps || []
})
const isLowEndpointUdc = computed(() => {
if (otgUdc.value) {
return /musb/i.test(otgUdc.value)
}
return devices.value.udc.some((udc) => /musb/i.test(udc.name))
})
function applyOtgProfileDefault() {
if (otgProfileTouched.value) return
if (hidBackend.value !== 'otg') return
const preferred = isLowEndpointUdc.value ? 'full_no_consumer' : 'full'
if (hidOtgProfile.value === preferred) return
hidOtgProfile.value = preferred
}
function onOtgProfileChange(value: unknown) {
hidOtgProfile.value = typeof value === 'string' ? value : 'full'
otgProfileTouched.value = true
}
// Common baud rates for CH9329
const baudRates = [9600, 19200, 38400, 57600, 115200]
@@ -319,9 +338,16 @@ watch(hidBackend, (newBackend) => {
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
otgUdc.value = devices.value.udc[0]?.name || ''
}
if (newBackend === 'none') {
ch9329Port.value = ''
otgUdc.value = ''
applyOtgProfileDefault()
})
watch(otgUdc, () => {
applyOtgProfileDefault()
})
watch(showAdvancedOtg, (open) => {
if (open) {
applyOtgProfileDefault()
}
})
@@ -344,6 +370,7 @@ onMounted(async () => {
if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name
}
applyOtgProfileDefault()
// If no HID devices exist, default to disabled to avoid blocking setup
if (result.serial.length === 0 && result.udc.length === 0) {
@@ -360,7 +387,6 @@ onMounted(async () => {
// Set extension availability from devices API
if (result.extensions) {
ttydAvailable.value = result.extensions.ttyd_available
rustdeskAvailable.value = result.extensions.rustdesk_available
}
} catch {
// Use defaults
@@ -505,6 +531,7 @@ async function handleSetup() {
}
if (hidBackend.value === 'otg' && otgUdc.value) {
setupData.hid_otg_udc = otgUdc.value
setupData.hid_otg_profile = hidOtgProfile.value
}
// Encoder backend setting
@@ -519,7 +546,6 @@ async function handleSetup() {
// Extension settings
setupData.ttyd_enabled = ttydEnabled.value
setupData.rustdesk_enabled = rustdeskEnabled.value
const success = await authStore.setup(setupData)
@@ -539,7 +565,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<div class="min-h-screen flex items-start sm:items-center justify-center bg-background px-4 py-6 sm:py-10">
<Card class="w-full max-w-lg relative">
<!-- Language Switcher -->
<div class="absolute top-4 right-4">
@@ -566,28 +592,28 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</DropdownMenu>
</div>
<CardHeader class="text-center space-y-2 pt-12">
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
<div
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10"
>
<Monitor class="w-8 h-8 text-primary" />
</div>
<CardTitle class="text-2xl">{{ t('setup.welcome') }}</CardTitle>
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
<CardDescription>{{ t('setup.description') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<CardContent class="space-y-5 sm:space-y-6">
<!-- Progress Text -->
<p class="text-sm text-muted-foreground text-center">
{{ t('setup.progress', { current: step, total: totalSteps }) }}
</p>
<!-- Step Indicator with Labels -->
<div class="flex items-center justify-center gap-2 mb-6">
<div class="flex items-center justify-center gap-1.5 sm:gap-2 mb-5 sm:mb-6">
<template v-for="i in totalSteps" :key="i">
<div class="flex flex-col items-center gap-1">
<div
class="flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all duration-300"
class="flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-full border-2 transition-all duration-300"
:class="
step > i
? 'bg-primary border-primary text-primary-foreground scale-100'
@@ -596,11 +622,11 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
: 'border-muted text-muted-foreground scale-100'
"
>
<Check v-if="step > i" class="w-5 h-5" />
<component :is="stepIcons[i - 1]" v-else class="w-5 h-5" />
<Check v-if="step > i" class="w-4 h-4 sm:w-5 sm:h-5" />
<component :is="stepIcons[i - 1]" v-else class="w-4 h-4 sm:w-5 sm:h-5" />
</div>
<span
class="text-xs transition-colors duration-300 max-w-16 text-center leading-tight"
class="text-[10px] sm:text-xs transition-colors duration-300 max-w-14 sm:max-w-16 text-center leading-tight"
:class="step >= i ? 'text-foreground font-medium' : 'text-muted-foreground'"
>
{{ stepLabels[i - 1] }}
@@ -608,7 +634,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</div>
<div
v-if="i < totalSteps"
class="w-8 h-0.5 transition-colors duration-300 mb-6"
class="w-5 sm:w-8 h-0.5 transition-colors duration-300 mb-5 sm:mb-6"
:class="step > i ? 'bg-primary' : 'bg-muted'"
/>
</template>
@@ -944,6 +970,46 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
{{ t('setup.noUdcDevices') }}
</p>
</div>
<div class="mt-2 border rounded-lg">
<button
type="button"
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
@click="showAdvancedOtg = !showAdvancedOtg"
>
<span class="text-sm font-medium">
{{ t('setup.otgAdvanced') }} ({{ t('common.optional') }})
</span>
<ChevronRight
class="h-4 w-4 transition-transform duration-200"
:class="{ 'rotate-90': showAdvancedOtg }"
/>
</button>
<div v-if="showAdvancedOtg" class="px-3 pb-3 space-y-3">
<p class="text-xs text-muted-foreground">
{{ t('setup.otgProfileDesc') }}
</p>
<div class="space-y-2">
<Label for="otgProfile">{{ t('setup.otgProfile') }}</Label>
<Select :model-value="hidOtgProfile" @update:modelValue="onOtgProfileChange">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
<SelectItem value="full_no_msd">{{ t('settings.otgProfileFullNoMsd') }}</SelectItem>
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
<SelectItem value="full_no_consumer_no_msd">{{ t('settings.otgProfileFullNoConsumerNoMsd') }}</SelectItem>
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
</SelectContent>
</Select>
</div>
<p v-if="isLowEndpointUdc" class="text-xs text-amber-600 dark:text-amber-400">
{{ t('setup.otgLowEndpointHint') }}
</p>
</div>
</div>
</div>
<p v-if="hidBackend === 'none'" class="text-xs text-muted-foreground">
@@ -974,19 +1040,6 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<Switch v-model="ttydEnabled" :disabled="!ttydAvailable" />
</div>
<!-- RustDesk -->
<div class="flex items-center justify-between p-4 rounded-lg border">
<div class="space-y-1">
<div class="flex items-center gap-2">
<Label class="text-base font-medium">{{ t('setup.rustdeskTitle') }}</Label>
</div>
<p class="text-sm text-muted-foreground">
{{ t('setup.rustdeskDescription') }}
</p>
</div>
<Switch v-model="rustdeskEnabled" />
</div>
<p class="text-xs text-muted-foreground text-center pt-2">
{{ t('setup.extensionsHint') }}
</p>