Merge branch 'main' into main

This commit is contained in:
SilentWind
2026-02-20 14:19:38 +08:00
committed by GitHub
111 changed files with 7290 additions and 1787 deletions

View File

@@ -136,6 +136,15 @@ export const msdConfigApi = {
// ===== ATX 配置 API =====
import type { AtxDevices } from '@/types/generated'
export interface WolHistoryEntry {
mac_address: string
updated_at: number
}
export interface WolHistoryResponse {
history: WolHistoryEntry[]
}
export const atxConfigApi = {
/**
* 获取 ATX 配置
@@ -166,6 +175,13 @@ export const atxConfigApi = {
method: 'POST',
body: JSON.stringify({ mac_address: macAddress }),
}),
/**
* 获取 WOL 历史记录(服务端持久化)
* @param limit 返回条数1-50
*/
getWolHistory: (limit = 5) =>
request<WolHistoryResponse>(`/atx/wol/history?limit=${Math.max(1, Math.min(50, limit))}`),
}
// ===== Audio 配置 API =====
@@ -330,6 +346,49 @@ export const rustdeskConfigApi = {
}),
}
// ===== RTSP 配置 API =====
export type RtspCodec = 'h264' | 'h265'
export interface RtspConfigResponse {
enabled: boolean
bind: string
port: number
path: string
allow_one_client: boolean
codec: RtspCodec
username?: string | null
has_password: boolean
}
export interface RtspConfigUpdate {
enabled?: boolean
bind?: string
port?: number
path?: string
allow_one_client?: boolean
codec?: RtspCodec
username?: string
password?: string
}
export interface RtspStatusResponse {
config: RtspConfigResponse
service_status: string
}
export const rtspConfigApi = {
get: () => request<RtspConfigResponse>('/config/rtsp'),
update: (config: RtspConfigUpdate) =>
request<RtspConfigResponse>('/config/rtsp', {
method: 'PATCH',
body: JSON.stringify(config),
}),
getStatus: () => request<RtspStatusResponse>('/config/rtsp/status'),
}
// ===== Web 服务器配置 API =====
/** Web 服务器配置 */

View File

@@ -101,6 +101,46 @@ export const systemApi = {
}),
}
export type UpdateChannel = 'stable' | 'beta'
export interface UpdateOverviewResponse {
success: boolean
current_version: string
channel: UpdateChannel
latest_version: string
upgrade_available: boolean
target_version?: string
notes_between: Array<{
version: string
published_at: string
notes: string[]
}>
}
export interface UpdateStatusResponse {
success: boolean
phase: 'idle' | 'checking' | 'downloading' | 'verifying' | 'installing' | 'restarting' | 'success' | 'failed'
progress: number
current_version: string
target_version?: string
message?: string
last_error?: string
}
export const updateApi = {
overview: (channel: UpdateChannel = 'stable') =>
request<UpdateOverviewResponse>(`/update/overview?channel=${encodeURIComponent(channel)}`),
upgrade: (payload: { channel?: UpdateChannel; target_version?: string }) =>
request<{ success: boolean; message?: string }>('/update/upgrade', {
method: 'POST',
body: JSON.stringify(payload),
}),
status: () =>
request<UpdateStatusResponse>('/update/status'),
}
// Stream API
export interface VideoCodecInfo {
id: string
@@ -124,6 +164,19 @@ export interface AvailableCodecsResponse {
codecs: VideoCodecInfo[]
}
export interface StreamConstraintsResponse {
success: boolean
allowed_codecs: string[]
locked_codec: string | null
disallow_mjpeg: boolean
sources: {
rustdesk: boolean
rtsp: boolean
}
reason: string
current_mode: string
}
export const streamApi = {
status: () =>
request<{
@@ -161,6 +214,9 @@ export const streamApi = {
getCodecs: () =>
request<AvailableCodecsResponse>('/stream/codecs'),
getConstraints: () =>
request<StreamConstraintsResponse>('/stream/constraints'),
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
request<{ success: boolean; message?: string }>('/stream/bitrate', {
method: 'POST',
@@ -186,10 +242,10 @@ export const webrtcApi = {
createSession: () =>
request<{ session_id: string }>('/webrtc/session', { method: 'POST' }),
offer: (sdp: string, clientId?: string) =>
offer: (sdp: string) =>
request<{ sdp: string; session_id: string; ice_candidates: IceCandidate[] }>('/webrtc/offer', {
method: 'POST',
body: JSON.stringify({ sdp, client_id: clientId }),
body: JSON.stringify({ sdp }),
}),
addIceCandidate: (sessionId: string, candidate: IceCandidate) =>
@@ -247,17 +303,34 @@ export const hidApi = {
screen_resolution: [number, number] | null
}>('/hid/status'),
keyboard: async (type: 'down' | 'up', key: number, modifiers?: {
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}) => {
otgSelfCheck: () =>
request<{
overall_ok: boolean
error_count: number
warning_count: number
hid_backend: string
selected_udc: string | null
bound_udc: string | null
udc_state: string | null
udc_speed: string | null
available_udcs: string[]
other_gadgets: string[]
checks: Array<{
id: string
ok: boolean
level: 'info' | 'warn' | 'error'
message: string
hint?: string
path?: string
}>
}>('/hid/otg/self-check'),
keyboard: async (type: 'down' | 'up', key: number, modifier?: number) => {
await ensureHidConnection()
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',
key,
modifiers,
modifier: (modifier ?? 0) & 0xff,
}
await hidWs.sendKeyboard(event)
return { success: true }
@@ -481,6 +554,25 @@ export const msdApi = {
}),
}
interface SerialDeviceOption {
path: string
name: string
}
function getSerialDevicePriority(path: string): number {
if (/^\/dev\/ttyUSB/i.test(path)) return 0
if (/^\/dev\/(ttyS|S)/i.test(path)) return 2
return 1
}
function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOption[] {
return [...serialDevices].sort((a, b) => {
const priorityDiff = getSerialDevicePriority(a.path) - getSerialDevicePriority(b.path)
if (priorityDiff !== 0) return priorityDiff
return a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' })
})
}
// Config API
/** @deprecated 使用域特定 APIvideoConfigApi, hidConfigApi 等)替代 */
export const configApi = {
@@ -493,8 +585,8 @@ export const configApi = {
body: JSON.stringify(updates),
}),
listDevices: () =>
request<{
listDevices: async () => {
const result = await request<{
video: Array<{
path: string
name: string
@@ -522,7 +614,13 @@ export const configApi = {
ttyd_available: boolean
rustdesk_available: boolean
}
}>('/devices'),
}>('/devices')
return {
...result,
serial: sortSerialDevices(result.serial),
}
},
}
// 导出新的域分离配置 API
@@ -536,11 +634,15 @@ export {
audioConfigApi,
extensionsApi,
rustdeskConfigApi,
rtspConfigApi,
webConfigApi,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskConfigUpdate,
type RustDeskPasswordResponse,
type RtspConfigResponse,
type RtspConfigUpdate,
type RtspStatusResponse,
type WebConfig,
} from './config'

View File

@@ -52,7 +52,7 @@ async function handleLogout() {
</script>
<template>
<div class="h-screen flex flex-col bg-background overflow-hidden">
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
<!-- Header -->
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex h-14 items-center px-4 max-w-full">
@@ -86,14 +86,14 @@ async function handleLogout() {
</span>
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" @click="toggleTheme">
<Button variant="ghost" size="icon" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" @click="toggleLanguage">
<Button variant="ghost" size="icon" :aria-label="t('common.toggleLanguage')" @click="toggleLanguage">
<Languages class="h-4 w-4" />
<span class="sr-only">{{ t('common.toggleLanguage') }}</span>
</Button>
@@ -101,7 +101,7 @@ async function handleLogout() {
<!-- Mobile Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child class="md:hidden">
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" :aria-label="t('common.menu')">
<Menu class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -119,7 +119,7 @@ async function handleLogout() {
</DropdownMenu>
<!-- Logout Button (Desktop) -->
<Button variant="ghost" size="icon" class="hidden md:flex" @click="handleLogout">
<Button variant="ghost" size="icon" class="hidden md:flex" :aria-label="t('nav.logout')" @click="handleLogout">
<LogOut class="h-4 w-4" />
<span class="sr-only">{{ t('nav.logout') }}</span>
</Button>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -18,6 +18,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
import { atxConfigApi } from '@/api/config'
const emit = defineEmits<{
(e: 'close'): void
@@ -41,6 +42,7 @@ const confirmDialogOpen = ref(false)
const wolMacAddress = ref('')
const wolHistory = ref<string[]>([])
const wolSending = ref(false)
const wolLoadingHistory = ref(false)
const powerStateColor = computed(() => {
switch (powerState.value) {
@@ -110,16 +112,11 @@ function sendWol() {
emit('wol', mac)
// Add to history if not exists
if (!wolHistory.value.includes(mac)) {
wolHistory.value.unshift(mac)
// Keep only last 5
if (wolHistory.value.length > 5) {
wolHistory.value.pop()
}
// Save to localStorage
localStorage.setItem('wol_history', JSON.stringify(wolHistory.value))
}
// Optimistic update, then sync from server after request likely completes
wolHistory.value = [mac, ...wolHistory.value.filter(item => item !== mac)].slice(0, 5)
setTimeout(() => {
loadWolHistory().catch(() => {})
}, 1200)
setTimeout(() => {
wolSending.value = false
@@ -130,15 +127,27 @@ function selectFromHistory(mac: string) {
wolMacAddress.value = mac
}
// Load WOL history on mount
const savedHistory = localStorage.getItem('wol_history')
if (savedHistory) {
async function loadWolHistory() {
wolLoadingHistory.value = true
try {
wolHistory.value = JSON.parse(savedHistory)
} catch (e) {
const response = await atxConfigApi.getWolHistory(5)
wolHistory.value = response.history.map(item => item.mac_address)
} catch {
wolHistory.value = []
} finally {
wolLoadingHistory.value = false
}
}
watch(
() => activeTab.value,
(tab) => {
if (tab === 'wol') {
loadWolHistory().catch(() => {})
}
},
{ immediate: true },
)
</script>
<template>
@@ -234,6 +243,10 @@ if (savedHistory) {
</p>
</div>
<p v-if="wolLoadingHistory" class="text-xs text-muted-foreground">
{{ t('common.loading') }}
</p>
<!-- History -->
<div v-if="wolHistory.length > 0" class="space-y-2">
<Separator />

View File

@@ -69,24 +69,24 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
return true
}
const { keyCode, shift } = mapping
const modifiers = shift ? { shift: true } : undefined
const { hidCode, shift } = mapping
const modifier = shift ? 0x02 : 0
try {
// Send keydown
await hidApi.keyboard('down', keyCode, modifiers)
await hidApi.keyboard('down', hidCode, modifier)
// Small delay between down and up to ensure key is registered
await sleep(5)
if (signal.aborted) {
// Even if aborted, still send keyup to release the key
await hidApi.keyboard('up', keyCode, modifiers)
await hidApi.keyboard('up', hidCode, modifier)
return false
}
// Send keyup
await hidApi.keyboard('up', keyCode, modifiers)
await hidApi.keyboard('up', hidCode, modifier)
// Additional small delay after keyup to ensure it's processed
await sleep(2)
@@ -96,7 +96,7 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
console.error('[Paste] Failed to type character:', char, error)
// Try to release the key even on error
try {
await hidApi.keyboard('up', keyCode, modifiers)
await hidApi.keyboard('up', hidCode, modifier)
} catch {
// Ignore cleanup errors
}

View File

@@ -442,7 +442,7 @@ onUnmounted(() => {
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
<SheetContent
side="right"
class="w-[400px] sm:w-[440px] p-0 border-l border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950"
class="w-[90vw] max-w-[440px] p-0 border-l border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950"
>
<!-- Header -->
<SheetHeader class="px-6 py-3 border-b border-slate-200 dark:border-slate-800">
@@ -454,7 +454,7 @@ onUnmounted(() => {
</div>
</SheetHeader>
<ScrollArea class="h-[calc(100vh-60px)]">
<ScrollArea class="h-[calc(100dvh-60px)]">
<div class="px-6 py-4 space-y-6">
<!-- Video Section Header -->
<div>

View File

@@ -129,9 +129,11 @@ const statusBadgeText = computed(() => {
<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
<button
type="button"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
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',
@@ -147,7 +149,7 @@ const statusBadgeText = computed(() => {
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</div>
</button>
</HoverCardTrigger>
<HoverCardContent class="w-80" :align="hoverAlign">
@@ -228,9 +230,11 @@ const statusBadgeText = computed(() => {
<Popover v-else>
<PopoverTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
<button
type="button"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
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',
@@ -246,7 +250,7 @@ const statusBadgeText = computed(() => {
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</div>
</button>
</PopoverTrigger>
<PopoverContent class="w-80" :align="hoverAlign">

View File

@@ -17,9 +17,16 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image } from 'lucide-vue-next'
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image, AlertTriangle } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo, type BitratePreset } from '@/api'
import {
configApi,
streamApi,
type VideoCodecInfo,
type EncoderBackendInfo,
type BitratePreset,
type StreamConstraintsResponse,
} from '@/api'
import { useConfigStore } from '@/stores/config'
import { useRouter } from 'vue-router'
@@ -64,7 +71,50 @@ const loadingCodecs = ref(false)
// Backend list
const backends = ref<EncoderBackendInfo[]>([])
const constraints = ref<StreamConstraintsResponse | null>(null)
const currentEncoderBackend = computed(() => configStore.stream?.encoder || 'auto')
const isRtspEnabled = computed(() => {
if (typeof configStore.rtspStatus?.config?.enabled === 'boolean') {
return configStore.rtspStatus.config.enabled
}
return !!configStore.rtspConfig?.enabled
})
const isRustdeskEnabled = computed(() => {
if (typeof configStore.rustdeskStatus?.config?.enabled === 'boolean') {
return configStore.rustdeskStatus.config.enabled
}
return !!configStore.rustdeskConfig?.enabled
})
const isRtspCodecLocked = computed(() => isRtspEnabled.value)
const isRustdeskWebrtcLocked = computed(() => !isRtspEnabled.value && isRustdeskEnabled.value)
const codecLockSources = computed(() => {
if (isRtspCodecLocked.value) {
return isRustdeskEnabled.value ? 'RTSP/RustDesk' : 'RTSP'
}
if (isRustdeskWebrtcLocked.value) return 'RustDesk'
return ''
})
const codecLockMessage = computed(() => {
if (!codecLockSources.value) return ''
return t('actionbar.multiSourceCodecLocked', { sources: codecLockSources.value })
})
const videoParamWarningSources = computed(() => {
if (isRtspEnabled.value && isRustdeskEnabled.value) return 'RTSP/RustDesk'
if (isRtspEnabled.value) return 'RTSP'
if (isRustdeskEnabled.value) return 'RustDesk'
return ''
})
const videoParamWarningMessage = computed(() => {
if (!videoParamWarningSources.value) return ''
return t('actionbar.multiSourceVideoParamsWarning', { sources: videoParamWarningSources.value })
})
const isCodecLocked = computed(() => !!codecLockMessage.value)
const isCodecOptionDisabled = (codecId: string): boolean => {
if (!isBrowserSupported(codecId)) return true
if (isRustdeskWebrtcLocked.value && codecId === 'mjpeg') return true
return false
}
// Browser supported codecs (WebRTC receive capabilities)
const browserSupportedCodecs = ref<Set<string>>(new Set())
@@ -220,7 +270,7 @@ const availableCodecs = computed(() => {
const backend = backends.value.find(b => b.id === currentEncoderBackend.value)
if (!backend) return allAvailable
return allAvailable
const backendFiltered = allAvailable
.filter(codec => {
// MJPEG is always available (doesn't require encoder)
if (codec.id === 'mjpeg') return true
@@ -238,6 +288,13 @@ const availableCodecs = computed(() => {
backend: backend.name,
}
})
const allowed = constraints.value?.allowed_codecs
if (!allowed || allowed.length === 0) {
return backendFiltered
}
return backendFiltered.filter(codec => allowed.includes(codec.id))
})
// Cascading filters
@@ -303,6 +360,14 @@ async function loadCodecs() {
}
}
async function loadConstraints() {
try {
constraints.value = await streamApi.getConstraints()
} catch {
constraints.value = null
}
}
// Navigate to settings page (video tab)
function goToSettings() {
router.push('/settings?tab=video')
@@ -339,6 +404,22 @@ function syncFromCurrentIfChanged() {
// Handle video mode change
function handleVideoModeChange(mode: unknown) {
if (typeof mode !== 'string') return
if (isRtspCodecLocked.value) {
toast.warning(codecLockMessage.value)
return
}
if (isRustdeskWebrtcLocked.value && mode === 'mjpeg') {
toast.warning(codecLockMessage.value)
return
}
if (constraints.value?.allowed_codecs?.length && !constraints.value.allowed_codecs.includes(mode)) {
toast.error(constraints.value.reason || t('actionbar.selectMode'))
return
}
emit('update:videoMode', mode as VideoMode)
}
@@ -466,9 +547,13 @@ watch(() => props.open, (isOpen) => {
loadCodecs()
}
loadConstraints()
Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
configStore.refreshRtspStatus(),
configStore.refreshRustdeskStatus(),
]).then(() => {
initializeFromCurrent()
}).catch(() => {
@@ -508,7 +593,7 @@ watch(currentConfig, () => {
<Select
:model-value="props.videoMode"
@update:model-value="handleVideoModeChange"
:disabled="loadingCodecs || availableCodecs.length === 0"
:disabled="loadingCodecs || availableCodecs.length === 0 || isRtspCodecLocked"
>
<SelectTrigger class="h-8 text-xs">
<div v-if="selectedCodecInfo" class="flex items-center gap-1.5 truncate">
@@ -530,8 +615,8 @@ watch(currentConfig, () => {
v-for="codec in availableCodecs"
:key="codec.id"
:value="codec.id"
:disabled="!isBrowserSupported(codec.id)"
:class="['text-xs', { 'opacity-50': !isBrowserSupported(codec.id) }]"
:disabled="isCodecOptionDisabled(codec.id)"
:class="['text-xs', { 'opacity-50': isCodecOptionDisabled(codec.id) }]"
>
<div class="flex items-center gap-2">
<span>{{ codec.name }}</span>
@@ -558,6 +643,9 @@ watch(currentConfig, () => {
<p v-if="props.videoMode !== 'mjpeg'" class="text-xs text-muted-foreground">
{{ t('actionbar.webrtcHint') }}
</p>
<p v-if="isCodecLocked" class="text-xs text-amber-600 dark:text-amber-400">
{{ codecLockMessage }}
</p>
</div>
<!-- Bitrate Preset - Only shown for WebRTC modes -->
@@ -624,6 +712,16 @@ watch(currentConfig, () => {
<Separator />
<div class="space-y-3">
<div
v-if="videoParamWarningMessage"
class="rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-2"
>
<p class="flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{{ videoParamWarningMessage }}</span>
</p>
</div>
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
<Button
@@ -655,7 +753,7 @@ watch(currentConfig, () => {
:value="device.path"
class="text-xs"
>
{{ device.name }}
{{ device.name }} ({{ device.path }})
</SelectItem>
</SelectContent>
</Select>

View File

@@ -9,6 +9,7 @@ import {
consumerKeys,
latchingKeys,
modifiers,
updateModifierMaskForHidKey,
type KeyName,
type ConsumerKeyName,
} from '@/lib/keyboardMappings'
@@ -304,9 +305,10 @@ async function onKeyDown(key: string) {
// Handle latching keys (Caps Lock, etc.)
if ((latchingKeys as readonly string[]).includes(cleanKey)) {
emit('keyDown', cleanKey)
await sendKeyPress(keyCode, true)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
setTimeout(() => {
sendKeyPress(keyCode, false)
sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey)
}, 100)
return
@@ -318,12 +320,14 @@ async function onKeyDown(key: string) {
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
if (isCurrentlyDown) {
pressedModifiers.value &= ~mask
await sendKeyPress(keyCode, false)
const nextMask = pressedModifiers.value & ~mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, false, nextMask)
emit('keyUp', cleanKey)
} else {
pressedModifiers.value |= mask
await sendKeyPress(keyCode, true)
const nextMask = pressedModifiers.value | mask
pressedModifiers.value = nextMask
await sendKeyPress(keyCode, true, nextMask)
emit('keyDown', cleanKey)
}
updateKeyboardButtonTheme()
@@ -333,11 +337,12 @@ async function onKeyDown(key: string) {
// Regular key: press and release
keysDown.value.push(cleanKey)
emit('keyDown', cleanKey)
await sendKeyPress(keyCode, true)
const currentMask = pressedModifiers.value & 0xff
await sendKeyPress(keyCode, true, currentMask)
updateKeyboardButtonTheme()
setTimeout(async () => {
keysDown.value = keysDown.value.filter(k => k !== cleanKey)
await sendKeyPress(keyCode, false)
await sendKeyPress(keyCode, false, currentMask)
emit('keyUp', cleanKey)
updateKeyboardButtonTheme()
}, 50)
@@ -347,16 +352,9 @@ async function onKeyUp() {
// Not used for now - we handle up in onKeyDown with setTimeout
}
async function sendKeyPress(keyCode: number, press: boolean) {
async function sendKeyPress(keyCode: number, press: boolean, modifierMask: number) {
try {
const mods = {
ctrl: (pressedModifiers.value & 0x11) !== 0,
shift: (pressedModifiers.value & 0x22) !== 0,
alt: (pressedModifiers.value & 0x44) !== 0,
meta: (pressedModifiers.value & 0x88) !== 0,
}
await hidApi.keyboard(press ? 'down' : 'up', keyCode, mods)
await hidApi.keyboard(press ? 'down' : 'up', keyCode, modifierMask & 0xff)
} catch (err) {
console.error('[VirtualKeyboard] Key send failed:', err)
}
@@ -368,16 +366,20 @@ interface MacroStep {
}
async function executeMacro(steps: MacroStep[]) {
let macroModifierMask = pressedModifiers.value & 0xff
for (const step of steps) {
for (const mod of step.modifiers) {
if (mod in keys) {
await sendKeyPress(keys[mod as KeyName], true)
const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, true)
await sendKeyPress(modHid, true, macroModifierMask)
}
}
for (const key of step.keys) {
if (key in keys) {
await sendKeyPress(keys[key as KeyName], true)
await sendKeyPress(keys[key as KeyName], true, macroModifierMask)
}
}
@@ -385,13 +387,15 @@ async function executeMacro(steps: MacroStep[]) {
for (const key of step.keys) {
if (key in keys) {
await sendKeyPress(keys[key as KeyName], false)
await sendKeyPress(keys[key as KeyName], false, macroModifierMask)
}
}
for (const mod of step.modifiers) {
if (mod in keys) {
await sendKeyPress(keys[mod as KeyName], false)
const modHid = keys[mod as KeyName]
macroModifierMask = updateModifierMaskForHidKey(macroModifierMask, modHid, false)
await sendKeyPress(modHid, false, macroModifierMask)
}
}
}

View File

@@ -5,6 +5,7 @@ import { ref, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { hidApi } from '@/api'
import { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings'
export interface HidInputState {
mouseMode: Ref<'absolute' | 'relative'>
@@ -32,6 +33,7 @@ export function useHidInput(options: UseHidInputOptions) {
numLock: false,
scrollLock: false,
})
const activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 })
const isPointerLocked = ref(false)
@@ -83,14 +85,14 @@ export function useHidInput(options: UseHidInputOptions) {
keyboardLed.value.numLock = e.getModifierState('NumLock')
keyboardLed.value.scrollLock = e.getModifierState('ScrollLock')
const modifiers = {
ctrl: e.ctrlKey,
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey,
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
return
}
hidApi.keyboard('down', e.keyCode, modifiers).catch(err => handleHidError(err, 'keyboard down'))
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, true)
activeModifierMask.value = modifierMask
hidApi.keyboard('down', hidKey, modifierMask).catch(err => handleHidError(err, 'keyboard down'))
}
function handleKeyUp(e: KeyboardEvent) {
@@ -107,7 +109,14 @@ export function useHidInput(options: UseHidInputOptions) {
const keyName = e.key === ' ' ? 'Space' : e.key
pressedKeys.value = pressedKeys.value.filter(k => k !== keyName)
hidApi.keyboard('up', e.keyCode).catch(err => handleHidError(err, 'keyboard up'))
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, false)
activeModifierMask.value = modifierMask
hidApi.keyboard('up', hidKey, modifierMask).catch(err => handleHidError(err, 'keyboard up'))
}
// Mouse handlers
@@ -233,6 +242,7 @@ export function useHidInput(options: UseHidInputOptions) {
function handleBlur() {
pressedKeys.value = []
activeModifierMask.value = 0
if (pressedMouseButton.value !== null) {
const button = pressedMouseButton.value
pressedMouseButton.value = null

View File

@@ -3,7 +3,6 @@
import { ref, onUnmounted, computed, type Ref } from 'vue'
import { webrtcApi, type IceCandidate } from '@/api'
import { generateUUID } from '@/lib/utils'
import {
type HidKeyboardEvent,
type HidMouseEvent,
@@ -15,6 +14,19 @@ import { useWebSocket } from '@/composables/useWebSocket'
export type { HidKeyboardEvent, HidMouseEvent }
export type WebRTCState = 'disconnected' | 'connecting' | 'connected' | 'failed'
export type WebRTCConnectStage =
| 'idle'
| 'fetching_ice_servers'
| 'creating_peer_connection'
| 'creating_data_channel'
| 'creating_offer'
| 'waiting_server_answer'
| 'setting_remote_description'
| 'applying_ice_candidates'
| 'waiting_connection'
| 'connected'
| 'disconnected'
| 'failed'
// ICE candidate type: host=P2P local, srflx=P2P STUN, relay=TURN relay
export type IceCandidateType = 'host' | 'srflx' | 'prflx' | 'relay' | 'unknown'
@@ -99,6 +111,7 @@ let dataChannel: RTCDataChannel | null = null
let sessionId: string | null = null
let statsInterval: number | null = null
let isConnecting = false // Lock to prevent concurrent connect calls
let connectInFlight: Promise<boolean> | null = null
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
@@ -131,6 +144,7 @@ const stats = ref<WebRTCStats>({
})
const error = ref<string | null>(null)
const dataChannelReady = ref(false)
const connectStage = ref<WebRTCConnectStage>('idle')
// Create RTCPeerConnection with configuration
function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
@@ -149,16 +163,19 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
break
case 'connected':
state.value = 'connected'
connectStage.value = 'connected'
error.value = null
startStatsCollection()
break
case 'disconnected':
case 'closed':
state.value = 'disconnected'
connectStage.value = 'disconnected'
stopStatsCollection()
break
case 'failed':
state.value = 'failed'
connectStage.value = 'failed'
error.value = 'Connection failed'
stopStatsCollection()
break
@@ -450,100 +467,123 @@ async function flushPendingIceCandidates() {
// Connect to WebRTC server
async function connect(): Promise<boolean> {
registerWebSocketHandlers()
// Prevent concurrent connection attempts
if (isConnecting) {
return false
if (connectInFlight) {
return connectInFlight
}
if (peerConnection && state.value === 'connected') {
return true
}
connectInFlight = (async () => {
registerWebSocketHandlers()
isConnecting = true
// Prevent concurrent connection attempts
if (isConnecting) {
return state.value === 'connected'
}
// Clean up any existing connection first
if (peerConnection || sessionId) {
await disconnect()
}
if (peerConnection && state.value === 'connected') {
return true
}
// Clear pending ICE candidates from previous attempt
pendingIceCandidates = []
isConnecting = true
// Clean up any existing connection first
if (peerConnection || sessionId) {
await disconnect()
}
// Clear pending ICE candidates from previous attempt
pendingIceCandidates = []
try {
state.value = 'connecting'
error.value = null
connectStage.value = 'fetching_ice_servers'
// Fetch ICE servers from backend API
const iceServers = await fetchIceServers()
connectStage.value = 'creating_peer_connection'
// Create peer connection with fetched ICE servers
peerConnection = createPeerConnection(iceServers)
connectStage.value = 'creating_data_channel'
// Create data channel before offer (for HID)
createDataChannel(peerConnection)
// Add transceiver for receiving video
peerConnection.addTransceiver('video', { direction: 'recvonly' })
peerConnection.addTransceiver('audio', { direction: 'recvonly' })
connectStage.value = 'creating_offer'
// Create offer
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)
connectStage.value = 'waiting_server_answer'
// Send offer to server and get answer
// Do not pass client_id here: each connect creates a fresh session.
const response = await webrtcApi.offer(offer.sdp!)
sessionId = response.session_id
// Send any ICE candidates that were queued while waiting for sessionId
await flushPendingIceCandidates()
// Set remote description (answer)
const answer: RTCSessionDescriptionInit = {
type: 'answer',
sdp: response.sdp,
}
connectStage.value = 'setting_remote_description'
await peerConnection.setRemoteDescription(answer)
// Flush any pending server ICE candidates once remote description is set
connectStage.value = 'applying_ice_candidates'
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) {
await addRemoteIceCandidate(candidateObj)
}
}
// 等待连接真正建立(最多等待 15 秒)
// 直接检查 peerConnection.connectionState 而不是 reactive state
// 因为 TypeScript 不知道 state 会被 onconnectionstatechange 回调异步修改
const connectionTimeout = 15000
const pollInterval = 100
let waited = 0
connectStage.value = 'waiting_connection'
while (waited < connectionTimeout && peerConnection) {
const pcState = peerConnection.connectionState
if (pcState === 'connected') {
connectStage.value = 'connected'
isConnecting = false
return true
}
if (pcState === 'failed' || pcState === 'closed') {
throw new Error('Connection failed during ICE negotiation')
}
await new Promise(resolve => setTimeout(resolve, pollInterval))
waited += pollInterval
}
// 超时
throw new Error('Connection timeout waiting for ICE negotiation')
} catch (err) {
state.value = 'failed'
connectStage.value = 'failed'
error.value = err instanceof Error ? err.message : 'Connection failed'
isConnecting = false
await disconnect()
return false
}
})()
try {
state.value = 'connecting'
error.value = null
// Fetch ICE servers from backend API
const iceServers = await fetchIceServers()
// Create peer connection with fetched ICE servers
peerConnection = createPeerConnection(iceServers)
// Create data channel before offer (for HID)
createDataChannel(peerConnection)
// Add transceiver for receiving video
peerConnection.addTransceiver('video', { direction: 'recvonly' })
peerConnection.addTransceiver('audio', { direction: 'recvonly' })
// Create offer
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)
// Send offer to server and get answer
const response = await webrtcApi.offer(offer.sdp!, generateUUID())
sessionId = response.session_id
// Send any ICE candidates that were queued while waiting for sessionId
await flushPendingIceCandidates()
// Set remote description (answer)
const answer: RTCSessionDescriptionInit = {
type: 'answer',
sdp: response.sdp,
}
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) {
await addRemoteIceCandidate(candidateObj)
}
}
// 等待连接真正建立(最多等待 15 秒)
// 直接检查 peerConnection.connectionState 而不是 reactive state
// 因为 TypeScript 不知道 state 会被 onconnectionstatechange 回调异步修改
const connectionTimeout = 15000
const pollInterval = 100
let waited = 0
while (waited < connectionTimeout && peerConnection) {
const pcState = peerConnection.connectionState
if (pcState === 'connected') {
isConnecting = false
return true
}
if (pcState === 'failed' || pcState === 'closed') {
throw new Error('Connection failed during ICE negotiation')
}
await new Promise(resolve => setTimeout(resolve, pollInterval))
waited += pollInterval
}
// 超时
throw new Error('Connection timeout waiting for ICE negotiation')
} catch (err) {
state.value = 'failed'
error.value = err instanceof Error ? err.message : 'Connection failed'
isConnecting = false
disconnect()
return false
return await connectInFlight
} finally {
connectInFlight = null
}
}
@@ -583,6 +623,7 @@ async function disconnect() {
audioTrack.value = null
cachedMediaStream = null // Clear cached stream on disconnect
state.value = 'disconnected'
connectStage.value = 'disconnected'
error.value = null
// Reset stats
@@ -694,6 +735,7 @@ export function useWebRTC() {
stats,
error,
dataChannelReady,
connectStage,
sessionId: computed(() => sessionId),
// Methods

View File

@@ -134,6 +134,8 @@ export default {
backendAuto: 'Auto',
recommended: 'Recommended',
notRecommended: 'Not Recommended',
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
// HID Config
hidConfig: 'Mouse & HID',
mouseSettings: 'Mouse Settings',
@@ -310,6 +312,14 @@ export default {
webrtcConnectedDesc: 'Using low-latency H.264 video stream',
webrtcFailed: 'WebRTC Connection Failed',
fallingBackToMjpeg: 'Falling back to MJPEG mode',
webrtcPhaseIceServers: 'Loading ICE servers...',
webrtcPhaseCreatePeer: 'Creating peer connection...',
webrtcPhaseCreateChannel: 'Creating data channel...',
webrtcPhaseCreateOffer: 'Creating local offer...',
webrtcPhaseWaitAnswer: 'Waiting for remote answer...',
webrtcPhaseSetRemote: 'Applying remote description...',
webrtcPhaseApplyIce: 'Applying ICE candidates...',
webrtcPhaseNegotiating: 'Negotiating secure connection...',
// Pointer Lock
pointerLocked: 'Pointer Locked',
pointerLockedDesc: 'Press Escape to release the pointer',
@@ -438,6 +448,7 @@ export default {
hid: 'HID',
msd: 'MSD',
atx: 'ATX',
environment: 'Environment',
network: 'Network',
users: 'Users',
hardware: 'Hardware',
@@ -452,7 +463,7 @@ export default {
deviceInfo: 'Device Info',
deviceInfoDesc: 'Host system information',
hostname: 'Hostname',
cpuModel: 'CPU Model',
cpuModel: 'Processor / Platform',
cpuUsage: 'CPU Usage',
memoryUsage: 'Memory Usage',
networkAddresses: 'Network Addresses',
@@ -501,6 +512,29 @@ export default {
restartRequired: 'Restart Required',
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
restarting: 'Restarting...',
onlineUpgrade: 'Online Upgrade',
onlineUpgradeDesc: 'Check and upgrade One-KVM',
updateChannel: 'Update Channel',
currentVersion: 'Current Version',
latestVersion: 'Latest Version',
updateStatus: 'Update Status',
updateStatusIdle: 'Idle',
releaseNotes: 'Release Notes',
noUpdates: 'No new version available for current channel',
startUpgrade: 'Start Upgrade',
updatePhaseIdle: 'Idle',
updatePhaseChecking: 'Checking',
updatePhaseDownloading: 'Downloading',
updatePhaseVerifying: 'Verifying',
updatePhaseInstalling: 'Installing',
updatePhaseRestarting: 'Restarting',
updatePhaseSuccess: 'Success',
updatePhaseFailed: 'Failed',
updateMsgChecking: 'Checking for updates',
updateMsgDownloading: 'Downloading binary',
updateMsgVerifying: 'Verifying (SHA256)',
updateMsgInstalling: 'Replacing binary',
updateMsgRestarting: 'Restarting service',
// Auth
auth: 'Access',
authSettings: 'Access Settings',
@@ -630,6 +664,86 @@ export default {
serialNumber: 'Serial Number',
serialNumberAuto: 'Auto-generated',
descriptorWarning: 'Changing these settings will reconnect the USB device',
otgSelfCheck: {
title: 'OTG Self-Check',
desc: 'Check UDC, gadget binding, and link status',
run: 'Run Self-Check',
failed: 'Failed to run OTG self-check',
overall: 'Overall Status',
ok: 'Healthy',
hasIssues: 'Issues Found',
summary: 'Issue Summary',
counts: '{errors} errors, {warnings} warnings',
groupCounts: '{ok} passed, {warnings} warnings, {errors} errors',
notRun: 'Not run',
status: {
ok: 'Healthy',
warn: 'Warning',
error: 'Error',
skipped: 'Skipped',
},
groups: {
udc: 'UDC Basics',
gadgetConfig: 'Gadget Config',
oneKvm: 'one-kvm Gadget',
functions: 'Functions & Nodes',
link: 'Link State',
},
values: {
missing: 'Missing',
notConfigured: 'Not configured',
mounted: 'Mounted',
unmounted: 'Unmounted',
available: 'Available',
unavailable: 'Unavailable',
exists: 'Exists',
none: 'None',
unbound: 'Unbound',
noConflict: 'No conflict',
conflict: 'Conflict',
unknown: 'Unknown',
normal: 'Normal',
abnormal: 'Abnormal',
},
selectedUdc: 'Target UDC',
boundUdc: 'Bound UDC',
messages: {
udc_dir_exists: 'UDC directory check',
udc_has_entries: 'UDC check',
configfs_mounted: 'configfs check',
usb_gadget_dir_exists: 'usb_gadget check',
libcomposite_loaded: 'libcomposite check',
one_kvm_gadget_exists: 'one-kvm gadget check',
other_gadgets: 'Other gadget check',
configured_udc_valid: 'Configured UDC check',
one_kvm_bound_udc: 'Bound UDC check',
hid_functions_present: 'HID function check',
config_c1_exists: 'configs/c.1 check',
function_links_ok: 'Function link check',
hid_device_nodes: 'HID node check',
udc_conflict: 'UDC conflict check',
udc_state: 'UDC state check',
udc_speed: 'UDC speed check',
},
hints: {
udc_dir_exists: 'Ensure UDC/OTG kernel drivers are enabled',
udc_has_entries: 'Ensure OTG controller is enabled in device tree',
configfs_mounted: 'Try: mount -t configfs none /sys/kernel/config',
usb_gadget_dir_exists: 'Ensure configfs and USB gadget support are enabled',
libcomposite_loaded: 'Try: modprobe libcomposite',
one_kvm_gadget_exists: 'Enable OTG HID or MSD to let one-kvm gadget be created automatically',
other_gadgets: 'Potential UDC contention with one-kvm; check other OTG services',
configured_udc_valid: 'Please reselect UDC in HID OTG settings',
one_kvm_bound_udc: 'Ensure HID/MSD is enabled and initialized successfully',
hid_functions_present: 'Check OTG HID config and enable at least one HID function',
config_c1_exists: 'Gadget structure is incomplete; try restarting One-KVM',
function_links_ok: 'Reinitialize OTG (toggle HID backend once or restart service)',
hid_device_nodes: 'Ensure gadget is bound and check kernel logs',
udc_conflict: 'Stop other OTG services or switch one-kvm to an idle UDC',
udc_state: 'Ensure target host is connected and has recognized the USB device',
udc_speed: 'Device may not be fully enumerated; try reconnecting USB',
},
},
// WebRTC / ICE
webrtcSettings: 'WebRTC Settings',
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
@@ -741,7 +855,6 @@ export default {
openInNewTab: 'Open in New Tab',
port: 'Port',
shell: 'Shell',
credential: 'Credential',
},
// gostc
gostc: {
@@ -801,6 +914,25 @@ export default {
keypairGenerated: 'Keypair Generated',
noKeypair: 'No Keypair',
},
rtsp: {
title: 'RTSP Streaming',
desc: 'Configure RTSP video output service (H.264/H.265)',
bind: 'Bind Address',
port: 'Port',
path: 'Stream Path',
pathPlaceholder: 'live',
pathHint: 'Example: rtsp://device-ip:8554/live',
codec: 'Codec',
codecHint: 'Enabling RTSP locks codec to selected value and disables MJPEG.',
allowOneClient: 'Allow One Client Only',
username: 'Username',
usernamePlaceholder: 'Empty means no authentication',
password: 'Password',
passwordPlaceholder: 'Enter new password',
passwordSet: '••••••••',
passwordHint: 'Leave empty to keep current password; enter a new value to update it.',
urlPreview: 'RTSP URL Preview',
},
},
stats: {
title: 'Connection Stats',

View File

@@ -134,6 +134,8 @@ export default {
backendAuto: '自动',
recommended: '推荐',
notRecommended: '不推荐',
multiSourceCodecLocked: '{sources} 已启用,当前编码已锁定',
multiSourceVideoParamsWarning: '{sources} 已启用,修改视频设备和输入参数将导致流中断',
// HID Config
hidConfig: '鼠键配置',
mouseSettings: '鼠标设置',
@@ -310,6 +312,14 @@ export default {
webrtcConnectedDesc: '正在使用 H.264 低延迟视频流',
webrtcFailed: 'WebRTC 连接失败',
fallingBackToMjpeg: '自动切换到 MJPEG 模式',
webrtcPhaseIceServers: '正在加载 ICE 服务器...',
webrtcPhaseCreatePeer: '正在创建点对点连接...',
webrtcPhaseCreateChannel: '正在创建数据通道...',
webrtcPhaseCreateOffer: '正在创建本地会话描述...',
webrtcPhaseWaitAnswer: '正在等待远端应答...',
webrtcPhaseSetRemote: '正在应用远端会话描述...',
webrtcPhaseApplyIce: '正在应用 ICE 候选...',
webrtcPhaseNegotiating: '正在协商安全连接...',
// Pointer Lock
pointerLocked: '鼠标已锁定',
pointerLockedDesc: '按 Escape 键释放鼠标',
@@ -438,6 +448,7 @@ export default {
hid: 'HID',
msd: 'MSD',
atx: 'ATX',
environment: '环境',
network: '网络',
users: '用户',
hardware: '硬件',
@@ -452,7 +463,7 @@ export default {
deviceInfo: '设备信息',
deviceInfoDesc: '主机系统信息',
hostname: '主机名',
cpuModel: 'CPU 型号',
cpuModel: '处理器 / 平台',
cpuUsage: 'CPU 利用率',
memoryUsage: '内存使用',
networkAddresses: '网络地址',
@@ -501,6 +512,29 @@ export default {
restartRequired: '需要重启',
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
restarting: '正在重启...',
onlineUpgrade: '在线升级',
onlineUpgradeDesc: '检查并升级 One-KVM',
updateChannel: '升级通道',
currentVersion: '当前版本',
latestVersion: '最新版本',
updateStatus: '升级状态',
updateStatusIdle: '空闲',
releaseNotes: '更新说明',
noUpdates: '当前通道暂无可升级新版本',
startUpgrade: '开始升级',
updatePhaseIdle: '空闲',
updatePhaseChecking: '检查中',
updatePhaseDownloading: '下载中',
updatePhaseVerifying: '校验中',
updatePhaseInstalling: '安装中',
updatePhaseRestarting: '重启中',
updatePhaseSuccess: '成功',
updatePhaseFailed: '失败',
updateMsgChecking: '检查更新中',
updateMsgDownloading: '下载中',
updateMsgVerifying: '校验中SHA256',
updateMsgInstalling: '替换程序中',
updateMsgRestarting: '服务重启中',
// Auth
auth: '访问控制',
authSettings: '访问设置',
@@ -630,6 +664,86 @@ export default {
serialNumber: '序列号',
serialNumberAuto: '自动生成',
descriptorWarning: '修改这些设置将导致 USB 设备重新连接',
otgSelfCheck: {
title: 'OTG 自检',
desc: '检查 UDC、gadget 绑定和连接状态',
run: '运行自检',
failed: '执行 OTG 自检失败',
overall: '总体状态',
ok: '正常',
hasIssues: '存在问题',
summary: '问题统计',
counts: '错误 {errors},警告 {warnings}',
groupCounts: '通过 {ok},警告 {warnings},错误 {errors}',
notRun: '未执行',
status: {
ok: '正常',
warn: '告警',
error: '异常',
skipped: '跳过',
},
groups: {
udc: 'UDC 基础',
gadgetConfig: 'gadget 配置',
oneKvm: 'one-kvm gadget',
functions: '功能与设备节点',
link: '连接状态',
},
values: {
missing: '不存在',
notConfigured: '未配置',
mounted: '已挂载',
unmounted: '未挂载',
available: '可用',
unavailable: '不可用',
exists: '存在',
none: '无',
unbound: '未绑定',
noConflict: '无冲突',
conflict: '冲突',
unknown: '未知',
normal: '正常',
abnormal: '异常',
},
selectedUdc: '目标 UDC',
boundUdc: '已绑定 UDC',
messages: {
udc_dir_exists: 'UDC 目录检查',
udc_has_entries: 'UDC 检查',
configfs_mounted: 'configfs 检查',
usb_gadget_dir_exists: 'usb_gadget 检查',
libcomposite_loaded: 'libcomposite 检查',
one_kvm_gadget_exists: 'one-kvm gadget 检查',
other_gadgets: '其他 gadget 检查',
configured_udc_valid: '配置 UDC 检查',
one_kvm_bound_udc: 'gadget 绑定 UDC 检查',
hid_functions_present: 'HID 函数检查',
config_c1_exists: 'configs/c.1 检查',
function_links_ok: 'functions 链接检查',
hid_device_nodes: 'HID 设备节点检查',
udc_conflict: 'UDC 冲突检查',
udc_state: 'UDC 状态检查',
udc_speed: 'UDC 速率检查',
},
hints: {
udc_dir_exists: '请确认内核已启用 UDC/OTG 驱动',
udc_has_entries: '请确认 OTG 控制器已在设备树中启用',
configfs_mounted: '可执行: mount -t configfs none /sys/kernel/config',
usb_gadget_dir_exists: '请确认 configfs 与 USB gadget 支持已启用',
libcomposite_loaded: '可执行: modprobe libcomposite',
one_kvm_gadget_exists: '启用 OTG HID 或 MSD 后会自动创建 one-kvm gadget',
other_gadgets: '可能与 one-kvm 抢占 UDC请检查是否有其他 OTG 服务',
configured_udc_valid: '请在 HID OTG 设置中重新选择 UDC',
one_kvm_bound_udc: '请确认 HID/MSD 已启用并成功初始化',
hid_functions_present: '请检查 OTG HID 配置是否至少启用了一个 HID 功能',
config_c1_exists: 'gadget 结构不完整,请尝试重启 One-KVM',
function_links_ok: '建议重新初始化 OTG切换一次 HID 后端或重启服务)',
hid_device_nodes: '请确认 gadget 已绑定并检查内核日志',
udc_conflict: '请停用其他 OTG 服务或切换 one-kvm 到空闲 UDC',
udc_state: '请确认已连接被控机,且被控机已识别 USB 设备',
udc_speed: '设备可能未完成枚举,可尝试重插 USB',
},
},
// WebRTC / ICE
webrtcSettings: 'WebRTC 设置',
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
@@ -741,7 +855,6 @@ export default {
openInNewTab: '在新标签页打开',
port: '端口',
shell: 'Shell',
credential: '认证凭据',
},
// gostc
gostc: {
@@ -801,6 +914,25 @@ export default {
keypairGenerated: '密钥对已生成',
noKeypair: '密钥对未生成',
},
rtsp: {
title: 'RTSP 视频流',
desc: '配置 RTSP 推流服务H.264/H.265',
bind: '监听地址',
port: '端口',
path: '流路径',
pathPlaceholder: 'live',
pathHint: '访问路径,例如 rtsp://设备IP:8554/live',
codec: '编码格式',
codecHint: '启用 RTSP 后将锁定编码为所选项,并禁用 MJPEG。',
allowOneClient: '仅允许单客户端',
username: '用户名',
usernamePlaceholder: '留空表示无需认证',
password: '密码',
passwordPlaceholder: '输入新密码',
passwordSet: '••••••••',
passwordHint: '留空表示保持当前密码;如需修改请输入新密码。',
urlPreview: 'RTSP 地址预览',
},
},
stats: {
title: '连接统计',

View File

@@ -1,136 +1,135 @@
// Character to JavaScript keyCode mapping for text paste functionality
// Maps printable ASCII characters to JavaScript keyCodes that the backend expects
// The backend (keymap.rs) will convert these JS keyCodes to USB HID keycodes
// Character to HID usage mapping for text paste functionality.
// The table follows US QWERTY layout semantics.
import { keys } from '@/lib/keyboardMappings'
export interface CharKeyMapping {
keyCode: number // JavaScript keyCode (same as KeyboardEvent.keyCode)
hidCode: number // USB HID usage code
shift: boolean // Whether Shift modifier is needed
}
// US QWERTY keyboard layout mapping
// Maps characters to their JavaScript keyCode and whether Shift is required
const charToKeyMap: Record<string, CharKeyMapping> = {
// Lowercase letters (no shift) - JS keyCodes 65-90
a: { keyCode: 65, shift: false },
b: { keyCode: 66, shift: false },
c: { keyCode: 67, shift: false },
d: { keyCode: 68, shift: false },
e: { keyCode: 69, shift: false },
f: { keyCode: 70, shift: false },
g: { keyCode: 71, shift: false },
h: { keyCode: 72, shift: false },
i: { keyCode: 73, shift: false },
j: { keyCode: 74, shift: false },
k: { keyCode: 75, shift: false },
l: { keyCode: 76, shift: false },
m: { keyCode: 77, shift: false },
n: { keyCode: 78, shift: false },
o: { keyCode: 79, shift: false },
p: { keyCode: 80, shift: false },
q: { keyCode: 81, shift: false },
r: { keyCode: 82, shift: false },
s: { keyCode: 83, shift: false },
t: { keyCode: 84, shift: false },
u: { keyCode: 85, shift: false },
v: { keyCode: 86, shift: false },
w: { keyCode: 87, shift: false },
x: { keyCode: 88, shift: false },
y: { keyCode: 89, shift: false },
z: { keyCode: 90, shift: false },
// Lowercase letters
a: { hidCode: keys.KeyA, shift: false },
b: { hidCode: keys.KeyB, shift: false },
c: { hidCode: keys.KeyC, shift: false },
d: { hidCode: keys.KeyD, shift: false },
e: { hidCode: keys.KeyE, shift: false },
f: { hidCode: keys.KeyF, shift: false },
g: { hidCode: keys.KeyG, shift: false },
h: { hidCode: keys.KeyH, shift: false },
i: { hidCode: keys.KeyI, shift: false },
j: { hidCode: keys.KeyJ, shift: false },
k: { hidCode: keys.KeyK, shift: false },
l: { hidCode: keys.KeyL, shift: false },
m: { hidCode: keys.KeyM, shift: false },
n: { hidCode: keys.KeyN, shift: false },
o: { hidCode: keys.KeyO, shift: false },
p: { hidCode: keys.KeyP, shift: false },
q: { hidCode: keys.KeyQ, shift: false },
r: { hidCode: keys.KeyR, shift: false },
s: { hidCode: keys.KeyS, shift: false },
t: { hidCode: keys.KeyT, shift: false },
u: { hidCode: keys.KeyU, shift: false },
v: { hidCode: keys.KeyV, shift: false },
w: { hidCode: keys.KeyW, shift: false },
x: { hidCode: keys.KeyX, shift: false },
y: { hidCode: keys.KeyY, shift: false },
z: { hidCode: keys.KeyZ, shift: false },
// Uppercase letters (with shift) - same keyCodes, just with Shift
A: { keyCode: 65, shift: true },
B: { keyCode: 66, shift: true },
C: { keyCode: 67, shift: true },
D: { keyCode: 68, shift: true },
E: { keyCode: 69, shift: true },
F: { keyCode: 70, shift: true },
G: { keyCode: 71, shift: true },
H: { keyCode: 72, shift: true },
I: { keyCode: 73, shift: true },
J: { keyCode: 74, shift: true },
K: { keyCode: 75, shift: true },
L: { keyCode: 76, shift: true },
M: { keyCode: 77, shift: true },
N: { keyCode: 78, shift: true },
O: { keyCode: 79, shift: true },
P: { keyCode: 80, shift: true },
Q: { keyCode: 81, shift: true },
R: { keyCode: 82, shift: true },
S: { keyCode: 83, shift: true },
T: { keyCode: 84, shift: true },
U: { keyCode: 85, shift: true },
V: { keyCode: 86, shift: true },
W: { keyCode: 87, shift: true },
X: { keyCode: 88, shift: true },
Y: { keyCode: 89, shift: true },
Z: { keyCode: 90, shift: true },
// Uppercase letters
A: { hidCode: keys.KeyA, shift: true },
B: { hidCode: keys.KeyB, shift: true },
C: { hidCode: keys.KeyC, shift: true },
D: { hidCode: keys.KeyD, shift: true },
E: { hidCode: keys.KeyE, shift: true },
F: { hidCode: keys.KeyF, shift: true },
G: { hidCode: keys.KeyG, shift: true },
H: { hidCode: keys.KeyH, shift: true },
I: { hidCode: keys.KeyI, shift: true },
J: { hidCode: keys.KeyJ, shift: true },
K: { hidCode: keys.KeyK, shift: true },
L: { hidCode: keys.KeyL, shift: true },
M: { hidCode: keys.KeyM, shift: true },
N: { hidCode: keys.KeyN, shift: true },
O: { hidCode: keys.KeyO, shift: true },
P: { hidCode: keys.KeyP, shift: true },
Q: { hidCode: keys.KeyQ, shift: true },
R: { hidCode: keys.KeyR, shift: true },
S: { hidCode: keys.KeyS, shift: true },
T: { hidCode: keys.KeyT, shift: true },
U: { hidCode: keys.KeyU, shift: true },
V: { hidCode: keys.KeyV, shift: true },
W: { hidCode: keys.KeyW, shift: true },
X: { hidCode: keys.KeyX, shift: true },
Y: { hidCode: keys.KeyY, shift: true },
Z: { hidCode: keys.KeyZ, shift: true },
// Numbers (no shift) - JS keyCodes 48-57
'0': { keyCode: 48, shift: false },
'1': { keyCode: 49, shift: false },
'2': { keyCode: 50, shift: false },
'3': { keyCode: 51, shift: false },
'4': { keyCode: 52, shift: false },
'5': { keyCode: 53, shift: false },
'6': { keyCode: 54, shift: false },
'7': { keyCode: 55, shift: false },
'8': { keyCode: 56, shift: false },
'9': { keyCode: 57, shift: false },
// Number row
'0': { hidCode: keys.Digit0, shift: false },
'1': { hidCode: keys.Digit1, shift: false },
'2': { hidCode: keys.Digit2, shift: false },
'3': { hidCode: keys.Digit3, shift: false },
'4': { hidCode: keys.Digit4, shift: false },
'5': { hidCode: keys.Digit5, shift: false },
'6': { hidCode: keys.Digit6, shift: false },
'7': { hidCode: keys.Digit7, shift: false },
'8': { hidCode: keys.Digit8, shift: false },
'9': { hidCode: keys.Digit9, shift: false },
// Shifted number row symbols (US layout)
')': { keyCode: 48, shift: true }, // Shift + 0
'!': { keyCode: 49, shift: true }, // Shift + 1
'@': { keyCode: 50, shift: true }, // Shift + 2
'#': { keyCode: 51, shift: true }, // Shift + 3
$: { keyCode: 52, shift: true }, // Shift + 4
'%': { keyCode: 53, shift: true }, // Shift + 5
'^': { keyCode: 54, shift: true }, // Shift + 6
'&': { keyCode: 55, shift: true }, // Shift + 7
'*': { keyCode: 56, shift: true }, // Shift + 8
'(': { keyCode: 57, shift: true }, // Shift + 9
// Shifted number row symbols
')': { hidCode: keys.Digit0, shift: true },
'!': { hidCode: keys.Digit1, shift: true },
'@': { hidCode: keys.Digit2, shift: true },
'#': { hidCode: keys.Digit3, shift: true },
'$': { hidCode: keys.Digit4, shift: true },
'%': { hidCode: keys.Digit5, shift: true },
'^': { hidCode: keys.Digit6, shift: true },
'&': { hidCode: keys.Digit7, shift: true },
'*': { hidCode: keys.Digit8, shift: true },
'(': { hidCode: keys.Digit9, shift: true },
// Punctuation and symbols (no shift) - US layout JS keyCodes
'-': { keyCode: 189, shift: false }, // Minus
'=': { keyCode: 187, shift: false }, // Equal
'[': { keyCode: 219, shift: false }, // Left bracket
']': { keyCode: 221, shift: false }, // Right bracket
'\\': { keyCode: 220, shift: false }, // Backslash
';': { keyCode: 186, shift: false }, // Semicolon
"'": { keyCode: 222, shift: false }, // Apostrophe/Quote
'`': { keyCode: 192, shift: false }, // Grave/Backtick
',': { keyCode: 188, shift: false }, // Comma
'.': { keyCode: 190, shift: false }, // Period
'/': { keyCode: 191, shift: false }, // Slash
// Punctuation and symbols
'-': { hidCode: keys.Minus, shift: false },
'=': { hidCode: keys.Equal, shift: false },
'[': { hidCode: keys.BracketLeft, shift: false },
']': { hidCode: keys.BracketRight, shift: false },
'\\': { hidCode: keys.Backslash, shift: false },
';': { hidCode: keys.Semicolon, shift: false },
"'": { hidCode: keys.Quote, shift: false },
'`': { hidCode: keys.Backquote, shift: false },
',': { hidCode: keys.Comma, shift: false },
'.': { hidCode: keys.Period, shift: false },
'/': { hidCode: keys.Slash, shift: false },
// Shifted punctuation and symbols (US layout)
_: { keyCode: 189, shift: true }, // Shift + Minus = Underscore
'+': { keyCode: 187, shift: true }, // Shift + Equal = Plus
'{': { keyCode: 219, shift: true }, // Shift + [ = {
'}': { keyCode: 221, shift: true }, // Shift + ] = }
'|': { keyCode: 220, shift: true }, // Shift + \ = |
':': { keyCode: 186, shift: true }, // Shift + ; = :
'"': { keyCode: 222, shift: true }, // Shift + ' = "
'~': { keyCode: 192, shift: true }, // Shift + ` = ~
'<': { keyCode: 188, shift: true }, // Shift + , = <
'>': { keyCode: 190, shift: true }, // Shift + . = >
'?': { keyCode: 191, shift: true }, // Shift + / = ?
// Shifted punctuation and symbols
_: { hidCode: keys.Minus, shift: true },
'+': { hidCode: keys.Equal, shift: true },
'{': { hidCode: keys.BracketLeft, shift: true },
'}': { hidCode: keys.BracketRight, shift: true },
'|': { hidCode: keys.Backslash, shift: true },
':': { hidCode: keys.Semicolon, shift: true },
'"': { hidCode: keys.Quote, shift: true },
'~': { hidCode: keys.Backquote, shift: true },
'<': { hidCode: keys.Comma, shift: true },
'>': { hidCode: keys.Period, shift: true },
'?': { hidCode: keys.Slash, shift: true },
// Whitespace and control characters
' ': { keyCode: 32, shift: false }, // Space
'\t': { keyCode: 9, shift: false }, // Tab
'\n': { keyCode: 13, shift: false }, // Enter (LF)
'\r': { keyCode: 13, shift: false }, // Enter (CR)
// Whitespace and control
' ': { hidCode: keys.Space, shift: false },
'\t': { hidCode: keys.Tab, shift: false },
'\n': { hidCode: keys.Enter, shift: false },
'\r': { hidCode: keys.Enter, shift: false },
}
/**
* Get the JavaScript keyCode and modifier state for a character
* Get HID usage code and modifier state for a character
* @param char - Single character to convert
* @returns CharKeyMapping or null if character is not mappable
*/
export function charToKey(char: string): CharKeyMapping | null {
if (char.length !== 1) return null
return charToKeyMap[char] || null
return charToKeyMap[char] ?? null
}
/**
@@ -138,7 +137,7 @@ export function charToKey(char: string): CharKeyMapping | null {
* @param char - Single character to check
*/
export function isTypableChar(char: string): boolean {
return char.length === 1 && char in charToKeyMap
return charToKey(char) !== null
}
/**

View File

@@ -191,6 +191,13 @@ export const hidKeyToModifierMask: Record<number, number> = {
0xe7: 0x80, // MetaRight
}
// Update modifier mask when a HID modifier key is pressed/released.
export function updateModifierMaskForHidKey(mask: number, hidKey: number, press: boolean): number {
const bit = hidKeyToModifierMask[hidKey] ?? 0
if (bit === 0) return mask
return press ? (mask | bit) : (mask & ~bit)
}
// Keys that latch (toggle state) instead of being held
export const latchingKeys = ['CapsLock', 'ScrollLock', 'NumLock'] as const
@@ -220,6 +227,23 @@ export function getModifierMask(keyName: string): number {
return 0
}
// Normalize browser-specific KeyboardEvent.code variants.
export function normalizeKeyboardCode(code: string, key: string): string {
if (code === 'IntlBackslash' && (key === '`' || key === '~')) return 'Backquote'
if (code === 'Backquote' && (key === '§' || key === '±')) return 'IntlBackslash'
if (code === 'IntlYen') return 'IntlBackslash'
if (code === 'OSLeft') return 'MetaLeft'
if (code === 'OSRight') return 'MetaRight'
if (code === '' && key === 'Shift') return 'ShiftRight'
return code
}
// Convert KeyboardEvent.code/key to USB HID usage code.
export function keyboardEventToHidCode(code: string, key: string): number | undefined {
const normalizedCode = normalizeKeyboardCode(code, key)
return keys[normalizedCode as KeyName]
}
// Decode modifier byte into individual states
export function decodeModifiers(modifier: number) {
return {

View File

@@ -6,6 +6,7 @@ import {
audioConfigApi,
hidConfigApi,
msdConfigApi,
rtspConfigApi,
rustdeskConfigApi,
streamConfigApi,
videoConfigApi,
@@ -30,6 +31,9 @@ import type {
WebConfigUpdate,
} from '@/types/generated'
import type {
RtspConfigResponse as ApiRtspConfigResponse,
RtspConfigUpdate as ApiRtspConfigUpdate,
RtspStatusResponse as ApiRtspStatusResponse,
RustDeskConfigResponse as ApiRustDeskConfigResponse,
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
RustDeskStatusResponse as ApiRustDeskStatusResponse,
@@ -51,6 +55,8 @@ export const useConfigStore = defineStore('config', () => {
const stream = ref<StreamConfigResponse | null>(null)
const web = ref<WebConfig | null>(null)
const atx = ref<AtxConfig | null>(null)
const rtspConfig = ref<ApiRtspConfigResponse | null>(null)
const rtspStatus = ref<ApiRtspStatusResponse | null>(null)
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
@@ -63,6 +69,7 @@ export const useConfigStore = defineStore('config', () => {
const streamLoading = ref(false)
const webLoading = ref(false)
const atxLoading = ref(false)
const rtspLoading = ref(false)
const rustdeskLoading = ref(false)
const authError = ref<string | null>(null)
@@ -73,6 +80,7 @@ export const useConfigStore = defineStore('config', () => {
const streamError = ref<string | null>(null)
const webError = ref<string | null>(null)
const atxError = ref<string | null>(null)
const rtspError = ref<string | null>(null)
const rustdeskError = ref<string | null>(null)
let authPromise: Promise<AuthConfig> | null = null
@@ -83,6 +91,8 @@ export const useConfigStore = defineStore('config', () => {
let streamPromise: Promise<StreamConfigResponse> | null = null
let webPromise: Promise<WebConfig> | null = null
let atxPromise: Promise<AtxConfig> | null = null
let rtspPromise: Promise<ApiRtspConfigResponse> | null = null
let rtspStatusPromise: Promise<ApiRtspStatusResponse> | null = null
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
@@ -263,6 +273,51 @@ export const useConfigStore = defineStore('config', () => {
return request
}
async function refreshRtspConfig() {
if (rtspLoading.value && rtspPromise) return rtspPromise
rtspLoading.value = true
rtspError.value = null
const request = rtspConfigApi.get()
.then((response) => {
rtspConfig.value = response
return response
})
.catch((error) => {
rtspError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rtspLoading.value = false
rtspPromise = null
})
rtspPromise = request
return request
}
async function refreshRtspStatus() {
if (rtspLoading.value && rtspStatusPromise) return rtspStatusPromise
rtspLoading.value = true
rtspError.value = null
const request = rtspConfigApi.getStatus()
.then((response) => {
rtspStatus.value = response
rtspConfig.value = response.config
return response
})
.catch((error) => {
rtspError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
rtspLoading.value = false
rtspStatusPromise = null
})
rtspStatusPromise = request
return request
}
async function refreshRustdeskConfig() {
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
rustdeskLoading.value = true
@@ -370,6 +425,11 @@ export const useConfigStore = defineStore('config', () => {
return refreshAtx()
}
function ensureRtspConfig() {
if (rtspConfig.value) return Promise.resolve(rtspConfig.value)
return refreshRtspConfig()
}
function ensureRustdeskConfig() {
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
return refreshRustdeskConfig()
@@ -423,6 +483,12 @@ export const useConfigStore = defineStore('config', () => {
return response
}
async function updateRtsp(update: ApiRtspConfigUpdate) {
const response = await rtspConfigApi.update(update)
rtspConfig.value = response
return response
}
async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
const response = await rustdeskConfigApi.update(update)
rustdeskConfig.value = response
@@ -450,6 +516,8 @@ export const useConfigStore = defineStore('config', () => {
stream,
web,
atx,
rtspConfig,
rtspStatus,
rustdeskConfig,
rustdeskStatus,
rustdeskPassword,
@@ -461,6 +529,7 @@ export const useConfigStore = defineStore('config', () => {
streamLoading,
webLoading,
atxLoading,
rtspLoading,
rustdeskLoading,
authError,
videoError,
@@ -470,6 +539,7 @@ export const useConfigStore = defineStore('config', () => {
streamError,
webError,
atxError,
rtspError,
rustdeskError,
refreshAuth,
refreshVideo,
@@ -479,6 +549,8 @@ export const useConfigStore = defineStore('config', () => {
refreshStream,
refreshWeb,
refreshAtx,
refreshRtspConfig,
refreshRtspStatus,
refreshRustdeskConfig,
refreshRustdeskStatus,
refreshRustdeskPassword,
@@ -490,6 +562,7 @@ export const useConfigStore = defineStore('config', () => {
ensureStream,
ensureWeb,
ensureAtx,
ensureRtspConfig,
ensureRustdeskConfig,
updateAuth,
updateVideo,
@@ -499,6 +572,7 @@ export const useConfigStore = defineStore('config', () => {
updateStream,
updateWeb,
updateAtx,
updateRtsp,
updateRustdesk,
regenerateRustdeskId,
regenerateRustdeskPassword,

View File

@@ -306,8 +306,6 @@ export interface TtydConfig {
port: number;
/** Shell to execute */
shell: string;
/** Credential in format "user:password" (optional) */
credential?: string;
}
/** gostc configuration (NAT traversal based on FRP) */
@@ -361,6 +359,30 @@ export interface RustDeskConfig {
device_id: string;
}
/** RTSP output codec */
export enum RtspCodec {
H264 = "h264",
H265 = "h265",
}
/** RTSP configuration */
export interface RtspConfig {
/** Enable RTSP output */
enabled: boolean;
/** Bind IP address */
bind: string;
/** RTSP TCP listen port */
port: number;
/** Stream path (without leading slash) */
path: string;
/** Allow only one client connection at a time */
allow_one_client: boolean;
/** Output codec (H264/H265) */
codec: RtspCodec;
/** Optional username for authentication */
username?: string;
}
/** Main application configuration */
export interface AppConfig {
/** Whether initial setup has been completed */
@@ -385,6 +407,8 @@ export interface AppConfig {
extensions: ExtensionsConfig;
/** RustDesk remote access settings */
rustdesk: RustDeskConfig;
/** RTSP streaming settings */
rtsp: RtspConfig;
}
/** Update for a single ATX key configuration */
@@ -564,6 +588,33 @@ export interface MsdConfigUpdate {
msd_dir?: string;
}
export interface RtspConfigResponse {
enabled: boolean;
bind: string;
port: number;
path: string;
allow_one_client: boolean;
codec: RtspCodec;
username?: string;
has_password: boolean;
}
export interface RtspConfigUpdate {
enabled?: boolean;
bind?: string;
port?: number;
path?: string;
allow_one_client?: boolean;
codec?: RtspCodec;
username?: string;
password?: string;
}
export interface RtspStatusResponse {
config: RtspConfigResponse;
service_status: string;
}
export interface RustDeskConfigUpdate {
enabled?: boolean;
rendezvous_server?: string;
@@ -613,7 +664,6 @@ export interface TtydConfigUpdate {
enabled?: boolean;
port?: number;
shell?: string;
credential?: string;
}
/** Simple ttyd status for console view */
@@ -638,4 +688,3 @@ export interface WebConfigUpdate {
bind_address?: string;
https_enabled?: boolean;
}

View File

@@ -5,12 +5,8 @@
export interface HidKeyboardEvent {
type: 'keydown' | 'keyup'
key: number
modifiers?: {
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
/** Raw HID modifier byte (bit0..bit7 = LCtrl..RMeta) */
modifier?: number
}
/** Mouse event for HID input */
@@ -57,13 +53,7 @@ export function encodeKeyboardEvent(event: HidKeyboardEvent): ArrayBuffer {
view.setUint8(1, event.type === 'keydown' ? KB_EVENT_DOWN : KB_EVENT_UP)
view.setUint8(2, event.key & 0xff)
// Build modifiers bitmask
let modifiers = 0
if (event.modifiers?.ctrl) modifiers |= 0x01 // Left Ctrl
if (event.modifiers?.shift) modifiers |= 0x02 // Left Shift
if (event.modifiers?.alt) modifiers |= 0x04 // Left Alt
if (event.modifiers?.meta) modifiers |= 0x08 // Left Meta
view.setUint8(3, modifiers)
view.setUint8(3, (event.modifier ?? 0) & 0xff)
return buffer
}

View File

@@ -13,6 +13,7 @@ import { useVideoSession } from '@/composables/useVideoSession'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, authApi } from '@/api'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
import { keyboardEventToHidCode, updateModifierMaskForHidKey } from '@/lib/keyboardMappings'
import { toast } from 'vue-sonner'
import { generateUUID } from '@/lib/utils'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
@@ -121,6 +122,7 @@ const pressedKeys = ref<string[]>([])
const keyboardLed = ref({
capsLock: false,
})
const activeModifierMask = ref(0)
const mousePosition = ref({ x: 0, y: 0 })
const lastMousePosition = ref({ x: 0, y: 0 }) // Track last position for relative mode
const isPointerLocked = ref(false) // Track pointer lock state
@@ -267,7 +269,6 @@ const hidDetails = computed<StatusDetail[]>(() => {
{ label: t('statusCard.device'), value: hid.device || '-' },
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.initialized ? 'ok' : 'warning' },
{ label: t('statusCard.mouseSupport'), value: hid.supportsAbsoluteMouse ? t('statusCard.absolute') : t('statusCard.relative'), status: hid.available ? 'ok' : undefined },
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
]
@@ -408,6 +409,45 @@ const msdDetails = computed<StatusDetail[]>(() => {
return details
})
const webrtcLoadingMessage = computed(() => {
if (videoMode.value === 'mjpeg') {
return videoRestarting.value ? t('console.videoRestarting') : t('console.connecting')
}
switch (webrtc.connectStage.value) {
case 'fetching_ice_servers':
return t('console.webrtcPhaseIceServers')
case 'creating_peer_connection':
return t('console.webrtcPhaseCreatePeer')
case 'creating_data_channel':
return t('console.webrtcPhaseCreateChannel')
case 'creating_offer':
return t('console.webrtcPhaseCreateOffer')
case 'waiting_server_answer':
return t('console.webrtcPhaseWaitAnswer')
case 'setting_remote_description':
return t('console.webrtcPhaseSetRemote')
case 'applying_ice_candidates':
return t('console.webrtcPhaseApplyIce')
case 'waiting_connection':
return t('console.webrtcPhaseNegotiating')
case 'connected':
return t('console.webrtcConnected')
case 'failed':
return t('console.webrtcFailed')
default:
return videoRestarting.value ? t('console.videoRestarting') : t('console.connecting')
}
})
const showMsdStatusCard = computed(() => {
return !!(systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329')
})
const hidHoverAlign = computed<'start' | 'end'>(() => {
return showMsdStatusCard.value ? 'start' : 'end'
})
// Video handling
let retryTimeoutId: number | null = null
let retryCount = 0
@@ -416,6 +456,8 @@ let consecutiveErrors = 0
const BASE_RETRY_DELAY = 2000
const GRACE_PERIOD = 2000 // Ignore errors for 2s after config change (reduced from 3s)
const MAX_CONSECUTIVE_ERRORS = 2 // If 2+ errors in grace period, it's a real problem
let pendingWebRTCReadyGate = false
let webrtcConnectTask: Promise<boolean> | null = null
// Last-frame overlay (prevents black flash during mode switches)
const frameOverlayUrl = ref<string | null>(null)
@@ -485,6 +527,52 @@ function waitForVideoFirstFrame(el: HTMLVideoElement, timeoutMs = 2000): Promise
})
}
function shouldSuppressAutoReconnect(): boolean {
return videoMode.value === 'mjpeg'
|| videoSession.localSwitching.value
|| videoSession.backendSwitching.value
|| videoRestarting.value
}
function markWebRTCFailure(reason: string, description?: string) {
pendingWebRTCReadyGate = false
videoError.value = true
videoErrorMessage.value = reason
videoLoading.value = false
systemStore.setStreamOnline(false)
toast.error(reason, {
description: description ?? '',
duration: 5000,
})
}
async function waitForWebRTCReadyGate(reason: string, timeoutMs = 3000): Promise<void> {
if (!pendingWebRTCReadyGate) return
const ready = await videoSession.waitForWebRTCReadyAny(timeoutMs)
if (!ready) {
console.warn(`[WebRTC] Ready gate timeout (${reason}), attempting connection anyway`)
}
pendingWebRTCReadyGate = false
}
async function connectWebRTCSerial(reason: string): Promise<boolean> {
if (webrtcConnectTask) {
return webrtcConnectTask
}
webrtcConnectTask = (async () => {
await waitForWebRTCReadyGate(reason)
return webrtc.connect()
})()
try {
return await webrtcConnectTask
} finally {
webrtcConnectTask = null
}
}
function handleVideoLoad() {
// MJPEG video frame loaded successfully - update stream online status
// This fixes the timing issue where device_info event may arrive before stream is fully active
@@ -605,9 +693,9 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
if (!webrtc.audioTrack.value) {
// No audio track - need to reconnect WebRTC to get one
// This happens when audio was enabled after WebRTC session was created
webrtc.disconnect()
await webrtc.disconnect()
await new Promise(resolve => setTimeout(resolve, 300))
await webrtc.connect()
await connectWebRTCSerial('audio track refresh')
// After reconnect, the new session will have audio track
// and the watch on audioTrack will add it to MediaStream
} else {
@@ -638,6 +726,7 @@ function handleStreamConfigChanging(data: any) {
// Reset all counters and states
videoRestarting.value = true
pendingWebRTCReadyGate = true
videoLoading.value = true
videoError.value = false
retryCount = 0
@@ -663,7 +752,7 @@ async function handleStreamConfigApplied(data: any) {
}, GRACE_PERIOD)
// Refresh video based on current mode
videoRestarting.value = false
videoRestarting.value = true
// 如果正在进行模式切换不需要在这里处理WebRTCReady 事件会处理)
if (isModeSwitching.value) {
@@ -673,16 +762,15 @@ async 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)
// connectWebRTCSerial() will wait on stream.webrtc_ready when gate is enabled.
await switchToWebRTC(videoMode.value)
} else {
// In MJPEG mode, refresh the MJPEG stream
refreshVideo()
}
videoRestarting.value = false
toast.success(t('console.videoRestarted'), {
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${data.fps}fps`,
duration: 3000,
@@ -692,11 +780,15 @@ async function handleStreamConfigApplied(data: any) {
// 处理 WebRTC 就绪事件 - 这是后端真正准备好接受 WebRTC 连接的信号
function handleWebRTCReady(data: { codec: string; hardware: boolean; transition_id?: string }) {
console.log(`[WebRTCReady] Backend ready: codec=${data.codec}, hardware=${data.hardware}, transition_id=${data.transition_id || '-'}`)
pendingWebRTCReadyGate = false
videoSession.onWebRTCReady(data)
}
function handleStreamModeReady(data: { transition_id: string; mode: string }) {
videoSession.onModeReady(data)
if (data.mode === 'mjpeg') {
pendingWebRTCReadyGate = false
}
videoRestarting.value = false
}
@@ -707,6 +799,7 @@ function handleStreamModeSwitching(data: { transition_id: string; to_mode: strin
videoLoading.value = true
captureFrameOverlay().catch(() => {})
}
pendingWebRTCReadyGate = true
videoSession.onModeSwitching(data)
}
@@ -751,6 +844,40 @@ function handleStreamStatsUpdate(data: any) {
// Track if we've received the initial device_info
let initialDeviceInfoReceived = false
let initialModeRestoreDone = false
let initialModeRestoreInProgress = false
function normalizeServerMode(mode: string | undefined): VideoMode | null {
if (!mode) return null
if (mode === 'webrtc') return 'h264'
if (mode === 'mjpeg' || mode === 'h264' || mode === 'h265' || mode === 'vp8' || mode === 'vp9') {
return mode
}
return null
}
async function restoreInitialMode(serverMode: VideoMode) {
if (initialModeRestoreDone || initialModeRestoreInProgress) return
initialModeRestoreInProgress = true
try {
initialDeviceInfoReceived = true
if (serverMode !== videoMode.value) {
videoMode.value = serverMode
localStorage.setItem('videoMode', serverMode)
}
if (serverMode !== 'mjpeg') {
await connectWebRTCOnly(serverMode)
} else if (mjpegTimestamp.value === 0) {
refreshVideo()
}
initialModeRestoreDone = true
} finally {
initialModeRestoreInProgress = false
}
}
function handleDeviceInfo(data: any) {
systemStore.updateFromDeviceInfo(data)
@@ -763,40 +890,28 @@ function handleDeviceInfo(data: any) {
// Sync video mode from server's stream_mode
if (data.video?.stream_mode) {
// Server returns: 'mjpeg', 'h264', 'h265', 'vp8', 'vp9', or 'webrtc'
const serverStreamMode = data.video.stream_mode
const serverMode = serverStreamMode === 'webrtc' ? 'h264' : serverStreamMode as VideoMode
const serverMode = normalizeServerMode(data.video.stream_mode)
if (!serverMode) return
if (!initialDeviceInfoReceived) {
// First device_info - initialize to server mode
initialDeviceInfoReceived = true
if (serverMode !== videoMode.value) {
// Server mode differs from default, sync to server mode without calling setMode
videoMode.value = serverMode
if (serverMode !== 'mjpeg') {
setTimeout(() => connectWebRTCOnly(serverMode), 100)
} else {
setTimeout(() => refreshVideo(), 100)
}
} else if (serverMode !== 'mjpeg') {
// Server is in WebRTC mode and client default matches, connect WebRTC (no setMode)
setTimeout(() => connectWebRTCOnly(serverMode), 100)
} else if (serverMode === 'mjpeg') {
// Server is in MJPEG mode and client default is also mjpeg, start MJPEG stream
setTimeout(() => refreshVideo(), 100)
if (!initialModeRestoreDone && !initialModeRestoreInProgress) {
void restoreInitialMode(serverMode)
return
}
} else if (serverMode !== videoMode.value) {
// Subsequent device_info with mode change - sync to server (no setMode)
syncToServerMode(serverMode as VideoMode)
}
if (initialModeRestoreInProgress) return
if (serverMode !== videoMode.value) {
syncToServerMode(serverMode)
}
}
}
// Handle stream mode change event from server (WebSocket broadcast)
function handleStreamModeChanged(data: { mode: string; previous_mode: string }) {
// Server returns: 'mjpeg', 'h264', 'h265', 'vp8', 'vp9', or 'webrtc'
const newMode = data.mode === 'webrtc' ? 'h264' : data.mode as VideoMode
const newMode = normalizeServerMode(data.mode)
if (!newMode) return
// 如果正在进行模式切换,忽略这个事件(这是我们自己触发的切换产生的)
if (isModeSwitching.value) {
@@ -812,7 +927,7 @@ function handleStreamModeChanged(data: { mode: string; previous_mode: string })
// Switch to new mode (external sync handled by device_info after mode_ready)
if (newMode !== videoMode.value) {
syncToServerMode(newMode as VideoMode)
syncToServerMode(newMode)
}
}
@@ -885,7 +1000,7 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
videoErrorMessage.value = ''
try {
const success = await webrtc.connect()
const success = await connectWebRTCSerial('connectWebRTCOnly')
if (success) {
toast.success(t('console.webrtcConnected'), {
description: t('console.webrtcConnectedDesc'),
@@ -903,7 +1018,7 @@ async function connectWebRTCOnly(codec: VideoMode = 'h264') {
throw new Error('WebRTC connection failed')
}
} catch {
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'))
markWebRTCFailure(t('console.webrtcFailed'))
}
}
@@ -954,6 +1069,7 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
pendingWebRTCReadyGate = true
try {
// Step 1: Disconnect existing WebRTC connection FIRST
@@ -988,7 +1104,7 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
let retries = 3
let success = false
while (retries > 0 && !success) {
success = await webrtc.connect()
success = await connectWebRTCSerial('switchToWebRTC')
if (!success) {
retries--
if (retries > 0) {
@@ -1014,30 +1130,7 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
throw new Error('WebRTC connection failed')
}
} catch {
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'), true)
}
}
async function fallbackToMJPEG(reason: string, description?: string, force = false) {
if (fallbackInProgress) return
if (videoMode.value === 'mjpeg') return
if (!force && (videoSession.localSwitching.value || videoSession.backendSwitching.value)) return
fallbackInProgress = true
videoError.value = true
videoErrorMessage.value = reason
videoLoading.value = false
toast.error(reason, {
description: description ?? '',
duration: 5000,
})
videoMode.value = 'mjpeg'
try {
await switchToMJPEG()
} finally {
fallbackInProgress = false
markWebRTCFailure(t('console.webrtcFailed'))
}
}
@@ -1045,6 +1138,7 @@ async function switchToMJPEG() {
videoLoading.value = true
videoError.value = false
videoErrorMessage.value = ''
pendingWebRTCReadyGate = false
// Step 1: Call backend API to switch mode FIRST
// This ensures the MJPEG endpoint will accept our request
@@ -1062,9 +1156,9 @@ async function switchToMJPEG() {
// Continue anyway - the mode might already be correct
}
// Step 2: Disconnect WebRTC if connected
if (webrtc.isConnected.value) {
webrtc.disconnect()
// Step 2: Disconnect WebRTC if connected or session still exists
if (webrtc.isConnected.value || webrtc.sessionId.value) {
await webrtc.disconnect()
}
// Clear WebRTC video
@@ -1174,10 +1268,19 @@ watch(webrtc.stats, (stats) => {
// Watch for WebRTC connection state changes - auto-reconnect on disconnect
let webrtcReconnectTimeout: ReturnType<typeof setTimeout> | null = null
let webrtcReconnectFailures = 0
let fallbackInProgress = false
watch(() => webrtc.state.value, (newState, oldState) => {
console.log('[WebRTC] State changed:', oldState, '->', newState)
// Clear any pending reconnect
if (webrtcReconnectTimeout) {
clearTimeout(webrtcReconnectTimeout)
webrtcReconnectTimeout = null
}
if (shouldSuppressAutoReconnect()) {
return
}
// Update stream online status based on WebRTC connection state
if (videoMode.value !== 'mjpeg') {
if (newState === 'connected') {
@@ -1189,28 +1292,22 @@ watch(() => webrtc.state.value, (newState, oldState) => {
}
}
// Clear any pending reconnect
if (webrtcReconnectTimeout) {
clearTimeout(webrtcReconnectTimeout)
webrtcReconnectTimeout = null
}
// Auto-reconnect when disconnected (but was previously connected)
if (newState === 'disconnected' && oldState === 'connected' && videoMode.value !== 'mjpeg') {
webrtcReconnectTimeout = setTimeout(async () => {
if (videoMode.value !== 'mjpeg' && webrtc.state.value === 'disconnected') {
try {
const success = await webrtc.connect()
const success = await connectWebRTCSerial('auto reconnect')
if (!success) {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 2) {
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'))
markWebRTCFailure(t('console.webrtcFailed'))
}
}
} catch {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 2) {
await fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg'))
markWebRTCFailure(t('console.webrtcFailed'))
}
}
}
@@ -1220,7 +1317,7 @@ watch(() => webrtc.state.value, (newState, oldState) => {
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 1) {
fallbackToMJPEG(t('console.webrtcFailed'), t('console.fallingBackToMjpeg')).catch(() => {})
markWebRTCFailure(t('console.webrtcFailed'))
}
}
})
@@ -1355,20 +1452,20 @@ function handleHidError(_error: any, _operation: string) {
}
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
function sendKeyboardEvent(type: 'down' | 'up', key: number, modifiers?: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean }) {
function sendKeyboardEvent(type: 'down' | 'up', key: number, modifier?: number) {
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',
key,
modifiers,
modifier,
}
const sent = webrtc.sendKeyboard(event)
if (sent) return
// Fallback to WebSocket if DataChannel send failed
}
// Use WebSocket as fallback or for MJPEG mode
hidApi.keyboard(type, key, modifiers).catch(err => handleHidError(err, `keyboard ${type}`))
hidApi.keyboard(type, key, modifier).catch(err => handleHidError(err, `keyboard ${type}`))
}
function sendMouseEvent(data: { type: 'move' | 'move_abs' | 'down' | 'up' | 'scroll'; x?: number; y?: number; button?: 'left' | 'right' | 'middle'; scroll?: number }) {
@@ -1441,14 +1538,15 @@ function handleKeyDown(e: KeyboardEvent) {
keyboardLed.value.capsLock = e.getModifierState('CapsLock')
const modifiers = {
ctrl: e.ctrlKey,
shift: e.shiftKey,
alt: e.altKey,
meta: e.metaKey,
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
console.warn(`[HID] Unmapped key down: code=${e.code}, key=${e.key}`)
return
}
sendKeyboardEvent('down', e.keyCode, modifiers)
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, true)
activeModifierMask.value = modifierMask
sendKeyboardEvent('down', hidKey, modifierMask)
}
function handleKeyUp(e: KeyboardEvent) {
@@ -1467,7 +1565,15 @@ function handleKeyUp(e: KeyboardEvent) {
const keyName = e.key === ' ' ? 'Space' : e.key
pressedKeys.value = pressedKeys.value.filter(k => k !== keyName)
sendKeyboardEvent('up', e.keyCode)
const hidKey = keyboardEventToHidCode(e.code, e.key)
if (hidKey === undefined) {
console.warn(`[HID] Unmapped key up: code=${e.code}, key=${e.key}`)
return
}
const modifierMask = updateModifierMaskForHidKey(activeModifierMask.value, hidKey, false)
activeModifierMask.value = modifierMask
sendKeyboardEvent('up', hidKey, modifierMask)
}
function handleMouseMove(e: MouseEvent) {
@@ -1686,6 +1792,7 @@ function handlePointerLockError() {
function handleBlur() {
pressedKeys.value = []
activeModifierMask.value = 0
// Release any pressed mouse button when window loses focus
if (pressedMouseButton.value !== null) {
const button = pressedMouseButton.value
@@ -1843,11 +1950,22 @@ onMounted(async () => {
// Note: Video mode is now synced from server via device_info event
// The handleDeviceInfo function will automatically switch to the server's mode
// localStorage preference is only used when server mode matches
try {
const modeResp = await streamApi.getMode()
const serverMode = normalizeServerMode(modeResp?.mode)
if (serverMode && !initialModeRestoreDone && !initialModeRestoreInProgress) {
await restoreInitialMode(serverMode)
}
} catch (err) {
console.warn('[Console] Failed to fetch stream mode on enter, fallback to WS events:', err)
}
})
onUnmounted(() => {
// Reset initial device info flag
initialDeviceInfoReceived = false
initialModeRestoreDone = false
initialModeRestoreInProgress = false
// Clear mouse flush timer
if (mouseFlushTimer !== null) {
@@ -1878,9 +1996,9 @@ onUnmounted(() => {
consoleEvents.unsubscribe()
consecutiveErrors = 0
// Disconnect WebRTC if connected
if (webrtc.isConnected.value) {
webrtc.disconnect()
// Disconnect WebRTC if connected or session still exists
if (webrtc.isConnected.value || webrtc.sessionId.value) {
void webrtc.disconnect()
}
// Exit pointer lock if active
@@ -1901,7 +2019,7 @@ onUnmounted(() => {
</script>
<template>
<div class="h-screen flex flex-col bg-background">
<div class="h-screen h-dvh flex flex-col bg-background">
<!-- Header -->
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="px-4">
@@ -1945,11 +2063,12 @@ onUnmounted(() => {
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
:hover-align="hidHoverAlign"
/>
<!-- MSD Status - Hidden when CH9329 backend (no USB gadget support) -->
<StatusCard
v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'"
v-if="showMsdStatusCard"
:title="t('statusCard.msd')"
type="msd"
:status="msdStatus"
@@ -1964,13 +2083,13 @@ onUnmounted(() => {
<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">
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" :aria-label="t('common.toggleTheme')" @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">
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" :aria-label="t('common.toggleLanguage')" @click="toggleLanguage">
<Languages class="h-4 w-4" />
</Button>
@@ -2041,11 +2160,12 @@ onUnmounted(() => {
:status="hidStatus"
:quick-info="hidQuickInfo"
:details="hidDetails"
:hover-align="hidHoverAlign"
compact
/>
</div>
<div v-if="systemStore.msd?.available && systemStore.hid?.backend !== 'ch9329'" class="shrink-0">
<div v-if="showMsdStatusCard" class="shrink-0">
<StatusCard
:title="t('statusCard.msd')"
type="msd"
@@ -2156,7 +2276,7 @@ onUnmounted(() => {
<Spinner class="h-16 w-16 text-white mb-4" />
<p class="text-white/90 text-lg font-medium">
{{ videoRestarting ? t('console.videoRestarting') : t('console.connecting') }}
{{ webrtcLoadingMessage }}
</p>
<p class="text-white/50 text-sm mt-2">
{{ t('console.pleaseWait') }}
@@ -2225,7 +2345,7 @@ onUnmounted(() => {
<!-- Terminal Dialog -->
<Dialog v-model:open="showTerminalDialog">
<DialogContent class="max-w-[95vw] w-[1200px] h-[600px] p-0 flex flex-col overflow-hidden">
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-4 py-3 border-b shrink-0">
<DialogTitle class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
@@ -2237,6 +2357,7 @@ onUnmounted(() => {
size="icon"
class="h-8 w-8 mr-8"
@click="openTerminalInNewTab"
:aria-label="t('extensions.ttyd.openInNewTab')"
:title="t('extensions.ttyd.openInNewTab')"
>
<ExternalLink class="h-4 w-4" />

View File

@@ -49,7 +49,7 @@ function handleKeydown(e: KeyboardEvent) {
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4">
<div class="w-full max-w-sm space-y-6">
<!-- Logo and Title -->
<div class="text-center space-y-2">
@@ -91,6 +91,7 @@ function handleKeydown(e: KeyboardEvent) {
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
:aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
@click="showPassword = !showPassword"
>
<Eye v-if="!showPassword" class="w-4 h-4" />

File diff suppressed because it is too large Load Diff

View File

@@ -565,7 +565,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</script>
<template>
<div class="min-h-screen flex items-start sm:items-center justify-center bg-background px-4 py-6 sm:py-10">
<div class="min-h-screen min-h-dvh 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">
@@ -686,6 +686,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
:aria-label="showPassword ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
@click="showPassword = !showPassword"
>
<Eye v-if="!showPassword" class="w-4 h-4" />
@@ -736,7 +737,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<Label for="videoDevice">{{ t('setup.videoDevice') }}</Label>
<HoverCard>
<HoverCardTrigger as-child>
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors">
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors" :aria-label="t('common.info')">
<HelpCircle class="w-4 h-4" />
</button>
</HoverCardTrigger>
@@ -762,7 +763,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<Label for="videoFormat">{{ t('setup.videoFormat') }}</Label>
<HoverCard>
<HoverCardTrigger as-child>
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors">
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors" :aria-label="t('common.info')">
<HelpCircle class="w-4 h-4" />
</button>
</HoverCardTrigger>
@@ -827,7 +828,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<Label for="audioDevice">{{ t('setup.audioDevice') }}</Label>
<HoverCard>
<HoverCardTrigger as-child>
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors">
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors" :aria-label="t('common.info')">
<HelpCircle class="w-4 h-4" />
</button>
</HoverCardTrigger>
@@ -858,6 +859,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<button
type="button"
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
:aria-label="t('setup.advancedEncoder')"
@click="showAdvancedEncoder = !showAdvancedEncoder"
>
<span class="text-sm font-medium">
@@ -975,6 +977,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<button
type="button"
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
:aria-label="t('setup.advancedOtg')"
@click="showAdvancedOtg = !showAdvancedOtg"
>
<span class="text-sm font-medium">