mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-03-15 07:26:44 +08:00
Merge branch 'main' into main
This commit is contained in:
@@ -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 服务器配置 */
|
||||
|
||||
@@ -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 使用域特定 API(videoConfigApi, 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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '连接统计',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user