mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-15 07:26:44 +08:00
Merge branch 'main' into main
This commit is contained in:
@@ -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
14
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
BIN
web/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: '••••••••',
|
||||
|
||||
@@ -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: '同时监听 ::1(IPv6 本地回环)。',
|
||||
bindAddressList: '地址列表',
|
||||
bindAddressListDesc: '每行一个 IP(IPv4 或 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: '••••••••',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
506
web/src/stores/config.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useAuthStore } from './auth'
|
||||
export { useConfigStore } from './config'
|
||||
export { useSystemStore } from './system'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user