refactor: 删除部分多余的代码和注释

This commit is contained in:
mofeng-git
2026-05-01 17:31:04 +08:00
parent 74035f8e12
commit d8e7de74a6
165 changed files with 2960 additions and 9917 deletions

View File

@@ -8,7 +8,6 @@ const router = useRouter()
const authStore = useAuthStore()
const systemStore = useSystemStore()
// Check for dark mode preference
function initTheme() {
const stored = localStorage.getItem('theme')
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -16,11 +15,9 @@ function initTheme() {
}
}
// Initialize on mount
onMounted(async () => {
initTheme()
// Check setup status
try {
await authStore.checkSetupStatus()
if (authStore.needsSetup) {
@@ -28,22 +25,17 @@ onMounted(async () => {
return
}
} catch {
// Continue anyway
}
// Check auth status
try {
await authStore.checkAuth()
if (authStore.isAuthenticated) {
// Fetch system info
await systemStore.fetchSystemInfo()
}
} catch {
// Not authenticated
}
})
// Listen for dark mode changes
watch(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
(dark) => {

View File

@@ -19,6 +19,7 @@ import type {
MsdConfigUpdate,
AtxConfig,
AtxConfigUpdate,
AtxDevices,
AudioConfig,
AudioConfigUpdate,
ExtensionsStatus,
@@ -36,7 +37,6 @@ import type {
import { request } from './request'
// ===== 全局配置 API =====
export const configApi = {
/**
* 获取完整配置
@@ -44,7 +44,6 @@ export const configApi = {
getAll: () => request<AppConfig>('/config'),
}
// ===== Auth 配置 API =====
export const authConfigApi = {
/**
* 获取认证配置
@@ -62,7 +61,6 @@ export const authConfigApi = {
}),
}
// ===== Video 配置 API =====
export const videoConfigApi = {
/**
* 获取视频配置
@@ -80,7 +78,6 @@ export const videoConfigApi = {
}),
}
// ===== Stream 配置 API =====
export const streamConfigApi = {
/**
* 获取流配置
@@ -98,7 +95,6 @@ export const streamConfigApi = {
}),
}
// ===== HID 配置 API =====
export const hidConfigApi = {
/**
* 获取 HID 配置
@@ -116,7 +112,6 @@ export const hidConfigApi = {
}),
}
// ===== MSD 配置 API =====
export const msdConfigApi = {
/**
* 获取 MSD 配置
@@ -134,9 +129,6 @@ export const msdConfigApi = {
}),
}
// ===== ATX 配置 API =====
import type { AtxDevices } from '@/types/generated'
export interface WolHistoryEntry {
mac_address: string
updated_at: number
@@ -185,7 +177,6 @@ export const atxConfigApi = {
request<WolHistoryResponse>(`/atx/wol/history?limit=${Math.max(1, Math.min(50, limit))}`),
}
// ===== Audio 配置 API =====
export const audioConfigApi = {
/**
* 获取音频配置
@@ -203,7 +194,6 @@ export const audioConfigApi = {
}),
}
// ===== Extensions API =====
export const extensionsApi = {
/**
* 获取所有扩展状态
@@ -265,8 +255,6 @@ export const extensionsApi = {
}),
}
// ===== RustDesk 配置 API =====
/** RustDesk 配置响应 */
export interface RustDeskConfigResponse {
enabled: boolean
@@ -342,8 +330,6 @@ export const rustdeskConfigApi = {
}),
}
// ===== RTSP 配置 API =====
export type RtspCodec = 'h264' | 'h265'
export interface RtspConfigResponse {
@@ -385,9 +371,6 @@ export const rtspConfigApi = {
getStatus: () => request<RtspStatusResponse>('/config/rtsp/status'),
}
// ===== Web 服务器配置 API =====
// `/config/web` 使用 `WebConfigResponse` / `WebConfigUpdate`(由 typeshare 自 Rust 生成)。
/** REST `/config/web` 响应(`WebConfigResponse` 别名,兼容旧命名) */
export type WebConfig = WebConfigResponse
@@ -409,8 +392,6 @@ export const webConfigApi = {
}),
}
// ===== 系统控制 API =====
export const systemApi = {
/**
* 重启系统

View File

@@ -1,11 +1,9 @@
// API client for One-KVM backend
import { request, ApiError } from './request'
import type { CanonicalKey } from '@/types/generated'
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
const API_BASE = '/api'
// Auth API
export const authApi = {
login: (username: string, password: string) =>
request<{ success: boolean; message?: string }>(
@@ -36,7 +34,6 @@ export const authApi = {
}),
}
// System API
export interface NetworkAddress {
interface: string
ip: string
@@ -149,7 +146,6 @@ export const updateApi = {
request<UpdateStatusResponse>('/update/status'),
}
// Stream API
export interface VideoCodecInfo {
id: string
name: string
@@ -271,7 +267,6 @@ export const streamApi = {
}),
}
// WebRTC API
export interface IceCandidate {
candidate: string
sdpMid?: string
@@ -317,15 +312,9 @@ export const webrtcApi = {
request<{ ice_servers: IceServerConfig[]; mdns_mode: string }>('/webrtc/ice-servers'),
}
// HID API
// Import HID WebSocket composable
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
// Create shared HID WebSocket instance
const hidWs = useHidWebSocket()
let hidWsInitialized = false
// Initialize HID WebSocket connection
async function ensureHidConnection() {
if (!hidWsInitialized) {
hidWsInitialized = true
@@ -333,7 +322,6 @@ async function ensureHidConnection() {
}
}
// Map button string to number
function mapButton(button?: 'left' | 'right' | 'middle'): number | undefined {
if (!button) return undefined
const buttonMap = { left: 0, middle: 1, right: 2 }
@@ -403,7 +391,6 @@ export const hidApi = {
scroll?: number | null
}) => {
await ensureHidConnection()
// Ensure all values are properly typed (convert null to undefined)
const event: HidMouseEvent = {
type: data.type === 'move_abs' ? 'moveabs' : data.type,
x: data.x ?? undefined,
@@ -424,13 +411,11 @@ export const hidApi = {
return { success: true }
},
// WebSocket connection management
connectWebSocket: () => hidWs.connect(),
disconnectWebSocket: () => hidWs.disconnect(),
isWebSocketConnected: () => hidWs.connected.value,
}
// ATX API
export const atxApi = {
status: () =>
request<{
@@ -448,7 +433,6 @@ export const atxApi = {
}),
}
// MSD API
export interface MsdImage {
id: string
name: string
@@ -485,7 +469,6 @@ export const msdApi = {
}
}>('/msd/status'),
// Image management
listImages: () => request<MsdImage[]>('/msd/images'),
uploadImage: async (file: File, onProgress?: (progress: number) => void) => {
@@ -528,7 +511,6 @@ export const msdApi = {
disconnect: () =>
request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }),
// Virtual drive
driveInfo: () =>
request<{
size: number
@@ -590,7 +572,6 @@ export const msdApi = {
method: 'POST',
}),
// Download from URL
downloadFromUrl: (url: string, filename?: string) =>
request<{
download_id: string
@@ -632,7 +613,6 @@ function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOpt
})
}
// Config API
/** @deprecated 使用域特定 APIvideoConfigApi, hidConfigApi 等)替代 */
export const configApi = {
get: () => request<Record<string, unknown>>('/config'),
@@ -683,7 +663,6 @@ export const configApi = {
},
}
// 导出新的域分离配置 API
export {
authConfigApi,
videoConfigApi,
@@ -707,7 +686,6 @@ export {
type WebConfigUpdate,
} from './config'
// 导出生成的类型
export type {
AppConfig,
AuthConfig,
@@ -730,7 +708,6 @@ export type {
BitratePreset,
} from '@/types/generated'
// Audio API
export const audioApi = {
status: () =>
request<{
@@ -765,7 +742,6 @@ export const audioApi = {
}),
}
// USB API
export interface UsbDeviceInfo {
bus_num: number
dev_num: number

View File

@@ -51,7 +51,6 @@ const { t, locale } = useI18n()
const router = useRouter()
const systemStore = useSystemStore()
// Overflow menu state
const overflowMenuOpen = ref(false)
const hidBackend = computed(() => (systemStore.hid?.backend ?? '').toLowerCase())
@@ -79,7 +78,6 @@ const emit = defineEmits<{
(e: 'openTerminal'): void
}>()
// Desktop toolbar popover/dialog state
const pasteOpen = ref(false)
const atxOpen = ref(false)
const videoPopoverOpen = ref(false)
@@ -88,7 +86,6 @@ const audioPopoverOpen = ref(false)
const msdDialogOpen = ref(false)
const extensionOpen = ref(false)
// Mobile Sheet state
const mobileAtxOpen = ref(false)
const mobilePasteOpen = ref(false)
const mobileAtxOpenTime = ref(0)
@@ -117,7 +114,6 @@ const openMobilePaste = () => openFromOverflow(() => {
mobilePasteOpenTime.value = Date.now()
})
// ── Adaptive overflow: measure real width, show as many items as fit ──
const barRef = ref<HTMLElement | null>(null)
const measureRef = ref<HTMLElement | null>(null)
@@ -146,11 +142,9 @@ const ITEM_SPECS: ItemSpec[] = [
{ id: 'settings', side: 'right' },
]
// Measured widths from DOM (icon-only and with-label)
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
const measurementReady = ref(false)
// Measure button widths from hidden measurement container
const measureButtonWidths = async () => {
await nextTick()
if (!measureRef.value) return
@@ -162,7 +156,6 @@ const measureButtonWidths = async () => {
const labelEl = measureRef.value.querySelector(`[data-measure="${spec.id}-label"]`) as HTMLElement
if (iconEl && labelEl) {
// Add small buffer (8px) for gaps and rounding errors
newWidths.set(spec.id, {
icon: Math.ceil(iconEl.offsetWidth) + 8,
label: Math.ceil(labelEl.offsetWidth) + 8,
@@ -191,17 +184,13 @@ onUnmounted(() => {
resizeObserver?.disconnect()
})
// Re-measure when locale changes (different text widths)
watch(locale, () => {
measurementReady.value = false
measureButtonWidths()
})
// Fixed-width budget for always-visible items (right side):
// keyboard + fullscreen + potential overflow button + gaps
const RIGHT_FIXED_PX = 120
// First 3 items (video/audio/hid) are always visible
const collapsibleItems = computed(() => {
const items = ITEM_SPECS.slice(3).filter(item => {
if (item.id === 'msd' && !showMsd.value) return false
@@ -210,27 +199,22 @@ const collapsibleItems = computed(() => {
return items
})
// Determine which collapsible items are visible (icon-only or with label)
const visibleSet = computed(() => {
if (!measurementReady.value) {
// Fallback to hardcoded estimates during initial render
return new Map<CollapsibleItem, 'icon' | 'label'>()
}
const available = barWidth.value - RIGHT_FIXED_PX
// Measure actual width of always-visible items (video/audio/hid)
let used = 0
if (barRef.value) {
const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement
if (leftContainer) {
// Get width of first 3 children (video/audio/hid)
const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[]
used = children.reduce((sum, el) => sum + el.offsetWidth, 0)
}
}
// If measurement failed, use estimate
if (used === 0) used = 330
const result = new Map<CollapsibleItem, 'icon' | 'label'>()

View File

@@ -33,14 +33,12 @@ const { t } = useI18n()
const activeTab = ref('atx')
// ATX state
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
let powerStateTimer: number | null = null
// Decouple action data from dialog visibility to prevent race conditions
const pendingAction = ref<'short' | 'long' | 'reset' | null>(null)
const confirmDialogOpen = ref(false)
// WOL state
const wolMacAddress = ref('')
const wolHistory = ref<string[]>([])
const wolSending = ref(false)
@@ -95,10 +93,8 @@ const confirmDescription = computed(() => {
default: return ''
}
})
// MAC address validation
const isValidMac = computed(() => {
const mac = wolMacAddress.value.trim()
// Support formats: AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF or AABBCCDDEEFF
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/
return macRegex.test(mac)
})
@@ -107,7 +103,6 @@ function sendWol() {
if (!isValidMac.value) return
wolSending.value = true
// Normalize MAC address
let mac = wolMacAddress.value.trim().toUpperCase()
if (mac.length === 12) {
mac = mac.match(/.{2}/g)!.join(':')
@@ -117,7 +112,6 @@ function sendWol() {
emit('wol', mac)
// 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(() => {})

View File

@@ -42,10 +42,8 @@ const configStore = useConfigStore()
const systemStore = useSystemStore()
const unifiedAudio = getUnifiedAudio()
// === Playback Control (immediate effect) ===
const localVolume = ref([unifiedAudio.volume.value * 100])
// Volume change - immediate effect, also triggers connection if needed
async function handleVolumeChange(value: number[] | undefined) {
if (!value || value.length === 0 || value[0] === undefined) return
@@ -53,7 +51,6 @@ async function handleVolumeChange(value: number[] | undefined) {
unifiedAudio.setVolume(newVolume)
localVolume.value = value
// If backend is streaming but audio not connected, connect now (user gesture)
if (newVolume > 0 && systemStore.audio?.streaming && !unifiedAudio.connected.value) {
console.log('[Audio] User adjusted volume, connecting unified audio')
try {
@@ -64,17 +61,14 @@ async function handleVolumeChange(value: number[] | undefined) {
}
}
// === Device Settings (requires apply) ===
const devices = ref<AudioDevice[]>([])
const loadingDevices = ref(false)
const applying = ref(false)
// Config values
const audioEnabled = ref(false)
const selectedDevice = ref('')
const selectedQuality = ref<'voice' | 'balanced' | 'high'>('balanced')
// Load device list
async function loadDevices() {
loadingDevices.value = true
try {
@@ -87,7 +81,6 @@ async function loadDevices() {
}
}
// Initialize from current config
function initializeFromCurrent() {
const audio = configStore.audio
if (audio) {
@@ -96,46 +89,36 @@ function initializeFromCurrent() {
selectedQuality.value = (audio.quality as 'voice' | 'balanced' | 'high') || 'balanced'
}
// Sync playback control state
localVolume.value = [unifiedAudio.volume.value * 100]
}
// Apply device configuration
async function applyConfig() {
applying.value = true
try {
// Update config
await configStore.updateAudio({
enabled: audioEnabled.value,
device: selectedDevice.value,
quality: selectedQuality.value,
})
// If enabled and device is selected, try to start audio stream
if (audioEnabled.value && selectedDevice.value) {
try {
// Restore default volume BEFORE starting audio
// This ensures handleAudioStateChanged sees the correct volume
if (localVolume.value[0] === 0) {
localVolume.value = [100]
unifiedAudio.setVolume(1)
}
await audioApi.start()
// ConsoleView will react when system.device_info reflects streaming=true.
} catch (startError) {
// Audio start failed - config was saved but streaming not started
console.info('[AudioConfig] Audio start failed:', startError)
}
} else if (!audioEnabled.value) {
// Reset volume to 0 when disabling audio
localVolume.value = [0]
unifiedAudio.setVolume(0)
try {
await audioApi.stop()
} catch {
// Ignore stop errors
}
unifiedAudio.disconnect()
}
@@ -148,7 +131,6 @@ async function applyConfig() {
}
}
// Watch popover open state
watch(() => props.open, (isOpen) => {
if (!isOpen) return

View File

@@ -54,7 +54,6 @@ function loadMouseMoveSendIntervalFromStorage(): number {
)
}
// Mouse Settings (real-time)
const mouseThrottle = ref<number>(
loadMouseMoveSendIntervalFromStorage()
)
@@ -62,9 +61,7 @@ const showCursor = ref<boolean>(
localStorage.getItem('hidShowCursor') !== 'false' // default true
)
// Watch showCursor changes and sync to localStorage + notify ConsoleView
watch(showCursor, (newValue, oldValue) => {
// Only sync if value actually changed (avoid triggering on initialization)
if (newValue !== oldValue) {
localStorage.setItem('hidShowCursor', newValue ? 'true' : 'false')
window.dispatchEvent(new CustomEvent('hidCursorVisibilityChanged', {
@@ -78,7 +75,6 @@ const hidBackend = ref<HidBackend>(HidBackend.None)
const devicePath = ref<string>('')
const baudrate = ref<number>(9600)
// UI state
const applying = ref(false)
const loadingDevices = ref(false)
@@ -86,7 +82,6 @@ const loadingDevices = ref(false)
const serialDevices = ref<Array<{ path: string; name: string }>>([])
const udcDevices = ref<Array<{ name: string }>>([])
// Button display text - simplified to just show label
const buttonText = computed(() => t('actionbar.hidConfig'))
// Available device paths based on backend type
@@ -117,9 +112,7 @@ async function loadDevices() {
}
}
// Initialize from current config
function initializeFromCurrent() {
// Re-sync real-time settings from localStorage
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
@@ -140,7 +133,6 @@ function initializeFromCurrent() {
}
}
// Toggle mouse mode (real-time)
function toggleMouseMode() {
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
emit('update:mouseMode', newMode)
@@ -154,14 +146,11 @@ function toggleMouseMode() {
})
}
// Update mouse throttle (real-time)
function handleThrottleChange(value: number[] | undefined) {
if (!value || value.length === 0 || value[0] === undefined) return
const throttleValue = clampMouseMoveSendIntervalMs(value[0])
mouseThrottle.value = throttleValue
// Save to localStorage
localStorage.setItem('hidMouseThrottle', String(throttleValue))
// Notify ConsoleView (storage event doesn't fire in same tab)
window.dispatchEvent(new CustomEvent('hidMouseSendIntervalChanged', {
detail: { intervalMs: throttleValue },
}))
@@ -191,7 +180,6 @@ function handleDevicePathChange(path: unknown) {
devicePath.value = path
}
// Handle baudrate change
function handleBaudrateChange(rate: unknown) {
if (typeof rate !== 'string') return
baudrate.value = Number(rate)
@@ -219,13 +207,11 @@ async function applyHidConfig() {
// HID state will be updated via WebSocket device_info event
} catch (e) {
console.info('[HidConfig] Failed to apply config:', e)
// Error toast already shown by API layer
} finally {
applying.value = false
}
}
// Watch open state
watch(() => props.open, (isOpen) => {
if (!isOpen) return

View File

@@ -17,7 +17,6 @@ const props = defineProps<{
const { t } = useI18n()
// Key name mapping for friendly display
const keyNameMap: Record<string, string> = {
MetaLeft: 'Win', MetaRight: 'Win',
ControlLeft: 'Ctrl', ControlRight: 'Ctrl',

View File

@@ -60,29 +60,23 @@ const { t } = useI18n()
const systemStore = useSystemStore()
const { on, off } = useWebSocket()
// Tab state
const activeTab = ref('images')
// Image state
const images = ref<MsdImage[]>([])
const loadingImages = ref(false)
const uploadProgress = ref(0)
const uploading = ref(false)
// Mount options (using ToggleGroup)
const mountMode = ref<'cdrom' | 'flash'>('flash')
const accessMode = ref<'readonly' | 'readwrite'>('readonly')
// Computed properties for API compatibility
const cdromMode = computed(() => mountMode.value === 'cdrom')
const readOnly = computed(() => accessMode.value === 'readonly')
// Operation state flags
const connecting = ref(false)
const disconnecting = ref(false)
const deleting = ref(false)
// Drive state
const driveFiles = ref<DriveFile[]>([])
const currentPath = ref('/')
const loadingDrive = ref(false)
@@ -91,13 +85,11 @@ const driveInitialized = ref(false)
const uploadingFile = ref(false)
const fileUploadProgress = ref(0)
// Inner dialog state
const showDeleteDialog = ref(false)
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
// Drive init dialog state
const showDriveInitDialog = ref(false)
const showDeleteDriveDialog = ref(false)
const selectedDriveSize = ref(256) // Default 256MB
@@ -105,7 +97,6 @@ const customDriveSize = ref<number | undefined>(undefined)
const initializingDrive = ref(false)
const deletingDrive = ref(false)
// URL download state
const showUrlDialog = ref(false)
const downloadUrl = ref('')
const downloadFilename = ref('')
@@ -119,14 +110,11 @@ const downloadProgress = ref<{
status: string
} | null>(null)
// Constants
const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
// Computed
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
// Get currently connected image name
const connectedImageName = computed(() => {
if (!msdConnected.value) return null
if (msdMode.value === 'drive') return t('msd.drive')
@@ -136,7 +124,6 @@ const connectedImageName = computed(() => {
return image?.name ?? null
})
// Check if any operation is in progress
const operationInProgress = computed(() => {
return connecting.value ||
disconnecting.value ||
@@ -147,7 +134,6 @@ const operationInProgress = computed(() => {
deletingDrive.value
})
// Check if image is large (>2.2GB)
function isLargeFile(image: MsdImage): boolean {
return image.size > TWO_POINT_TWO_GB
}
@@ -163,7 +149,6 @@ const breadcrumbs = computed(() => {
return crumbs
})
// Load data when dialog opens
watch(() => props.open, async (isOpen) => {
if (isOpen) {
await loadData()
@@ -179,7 +164,6 @@ async function loadData() {
}
}
// Image functions
async function loadImages() {
loadingImages.value = true
try {
@@ -294,7 +278,6 @@ async function executeDelete() {
}
}
// Drive functions
async function loadDriveInfo() {
try {
driveInfo.value = await msdApi.driveInfo()
@@ -304,7 +287,6 @@ async function loadDriveInfo() {
}
}
// Drive size options - computed for i18n support
const driveSizeOptions = computed(() => [
{ value: 64, label: '64 MB' },
{ value: 128, label: '128 MB' },
@@ -316,17 +298,14 @@ const driveSizeOptions = computed(() => [
{ value: 8192, label: '8 GB' },
])
// Computed final drive size
const finalDriveSize = computed(() => {
return customDriveSize.value || selectedDriveSize.value
})
// Open drive init dialog
function initializeDrive() {
showDriveInitDialog.value = true
}
// Create drive with selected size
async function createDrive() {
initializingDrive.value = true
try {
@@ -342,7 +321,6 @@ async function createDrive() {
}
}
// Delete virtual drive
async function deleteDrive() {
deletingDrive.value = true
try {
@@ -422,7 +400,6 @@ async function createFolder() {
}
}
// URL download functions
async function startUrlDownload() {
if (!downloadUrl.value.trim()) return

View File

@@ -53,10 +53,8 @@ const emit = defineEmits<{
const { t } = useI18n()
const systemStore = useSystemStore()
// Tab state
const activeTab = ref('images')
// Image state
const images = ref<MsdImage[]>([])
const loadingImages = ref(false)
const uploadProgress = ref(0)
@@ -64,7 +62,6 @@ const uploading = ref(false)
const cdromMode = ref(true)
const readOnly = ref(true)
// Drive state
const driveFiles = ref<DriveFile[]>([])
const currentPath = ref('/')
const loadingDrive = ref(false)
@@ -73,13 +70,11 @@ const driveInitialized = ref(false)
const uploadingFile = ref(false)
const fileUploadProgress = ref(0)
// Dialog state
const showDeleteDialog = ref(false)
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
// Computed
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
@@ -94,7 +89,6 @@ const breadcrumbs = computed(() => {
return crumbs
})
// Load data when sheet opens
watch(() => props.open, async (isOpen) => {
if (isOpen) {
await loadData()
@@ -110,7 +104,6 @@ async function loadData() {
}
}
// Image functions
async function loadImages() {
loadingImages.value = true
try {
@@ -186,7 +179,6 @@ async function executeDelete() {
}
}
// Drive functions
async function loadDriveInfo() {
try {
driveInfo.value = await msdApi.driveInfo()

View File

@@ -23,11 +23,8 @@ const currentChar = ref(0)
const totalChars = ref(0)
const abortController = ref<AbortController | null>(null)
// Typing speed in milliseconds between characters
// Configurable delay to prevent target system from missing keystrokes
const typingDelay = ref(10)
// Text analysis for warning display
const textAnalysis = computed(() => {
if (!text.value) return null
return analyzeText(text.value)
@@ -38,14 +35,12 @@ const hasUntypableChars = computed(() => {
})
onMounted(() => {
// Auto focus the textarea
setTimeout(() => {
textareaRef.value?.focus()
}, 100)
})
onUnmounted(() => {
// Cancel any ongoing paste operation when component is unmounted
cancelPaste()
})
@@ -65,7 +60,6 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
const mapping = charToKey(char)
if (!mapping) {
// Skip untypable characters
return true
}
@@ -73,10 +67,8 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
const modifier = shift ? 0x02 : 0
try {
// Send keydown
await hidApi.keyboard('down', key, modifier)
// Small delay between down and up to ensure key is registered
await sleep(5)
if (signal.aborted) {
@@ -85,20 +77,16 @@ async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
return false
}
// Send keyup
await hidApi.keyboard('up', key, modifier)
// Additional small delay after keyup to ensure it's processed
await sleep(2)
return true
} catch (error) {
console.error('[Paste] Failed to type character:', char, error)
// Try to release the key even on error
try {
await hidApi.keyboard('up', key, modifier)
} catch {
// Ignore cleanup errors
}
return false
}
@@ -133,20 +121,17 @@ async function handlePaste() {
currentChar.value = charIndex
progress.value = Math.round((charIndex / totalLength) * 100)
// Handle CRLF: skip \r if followed by \n
if (char === '\r' && charIndex < totalLength && chars[charIndex] === '\n') {
continue
}
await typeChar(char, signal)
// Delay between characters (configurable)
if (typingDelay.value > 0 && charIndex < totalLength) {
await sleep(typingDelay.value)
}
}
// Success - close the modal after a brief delay
if (!signal.aborted) {
await sleep(200)
text.value = ''
@@ -155,11 +140,9 @@ async function handlePaste() {
} catch (error) {
console.error('[Paste] Error during paste operation:', error)
} finally {
// Reset HID to ensure no keys are stuck
try {
await hidApi.reset()
} catch {
// Ignore reset errors
}
isPasting.value = false
progress.value = 0
@@ -180,14 +163,12 @@ function cancelPaste() {
}
function handleKeydown(e: KeyboardEvent) {
// Ctrl/Cmd + Enter to paste
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
if (!isPasting.value) {
handlePaste()
}
}
// Escape to cancel or close
if (e.key === 'Escape') {
e.preventDefault()
if (isPasting.value) {
@@ -196,7 +177,6 @@ function handleKeydown(e: KeyboardEvent) {
emit('close')
}
}
// Stop propagation to prevent HID interference
e.stopPropagation()
}
</script>

View File

@@ -29,19 +29,16 @@ const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
// Chart containers
const stabilityChartRef = ref<HTMLDivElement | null>(null)
const delayChartRef = ref<HTMLDivElement | null>(null)
const packetLossChartRef = ref<HTMLDivElement | null>(null)
const fpsChartRef = ref<HTMLDivElement | null>(null)
// Chart instances
let stabilityChart: uPlot | null = null
let delayChart: uPlot | null = null
let packetLossChart: uPlot | null = null
let fpsChart: uPlot | null = null
// Data history (last 120 seconds)
const MAX_POINTS = 120
const timestamps = ref<number[]>([])
const jitterHistory = ref<number[]>([])
@@ -50,7 +47,6 @@ const packetLossHistory = ref<number[]>([])
const fpsHistory = ref<number[]>([])
const bitrateHistory = ref<number[]>([])
// For delta calculations
let lastBytesReceived = 0
let lastPacketsLost = 0
let lastTimestamp = 0
@@ -58,13 +54,11 @@ let lastTimestamp = 0
// Is WebRTC mode
const isWebRTC = computed(() => props.videoMode !== 'mjpeg')
// Format time for axis
function formatTime(ts: number): string {
const date = new Date(ts * 1000)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// Chart theme colors
const chartColors = {
line: '#3b82f6',
fill: 'rgba(59, 130, 246, 0.1)',
@@ -73,7 +67,6 @@ const chartColors = {
text: '#94a3b8',
}
// Chart options factory
function createChartOptions(
container: HTMLElement,
_yLabel: string,
@@ -129,7 +122,6 @@ function createChartOptions(
}
}
// Tooltip state for each chart
const activeTooltip = ref<{
chartId: string
time: string
@@ -186,12 +178,10 @@ function createTooltipPlugin(chartId: string, unit: string): uPlot.Plugin {
}
}
// Initialize charts
function initCharts() {
if (!props.open) return
nextTick(() => {
// Initialize timestamps if empty
if (timestamps.value.length === 0) {
const now = Date.now() / 1000
for (let i = MAX_POINTS - 1; i >= 0; i--) {
@@ -204,7 +194,6 @@ function initCharts() {
bitrateHistory.value = new Array(MAX_POINTS).fill(0)
}
// Network Stability (Jitter) Chart
if (stabilityChartRef.value && !stabilityChart) {
const opts = createChartOptions(stabilityChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
opts.plugins = [createTooltipPlugin('stability', 'ms')]
@@ -215,7 +204,6 @@ function initCharts() {
)
}
// Playback Delay Chart
if (delayChartRef.value && !delayChart) {
const opts = createChartOptions(delayChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
opts.plugins = [createTooltipPlugin('delay', 'ms')]
@@ -237,7 +225,6 @@ function initCharts() {
)
}
// FPS Chart
if (fpsChartRef.value && !fpsChart) {
const opts = createChartOptions(fpsChartRef.value, 'fps', (v) => `${v.toFixed(0)} fps`)
opts.plugins = [createTooltipPlugin('fps', 'fps')]
@@ -250,7 +237,6 @@ function initCharts() {
})
}
// Destroy charts
function destroyCharts() {
stabilityChart?.destroy()
stabilityChart = null
@@ -262,22 +248,18 @@ function destroyCharts() {
fpsChart = null
}
// Add data point
function addDataPoint() {
const now = Date.now() / 1000
// Shift timestamps
timestamps.value.push(now)
if (timestamps.value.length > MAX_POINTS) {
timestamps.value.shift()
}
if (isWebRTC.value && props.webrtcStats) {
// Jitter in ms
const jitter = (props.webrtcStats.jitter || 0) * 1000
jitterHistory.value.push(jitter)
// RTT (round trip time) as delay in ms
const rtt = (props.webrtcStats.roundTripTime || 0) * 1000
delayHistory.value.push(rtt)
@@ -287,10 +269,8 @@ function addDataPoint() {
lastPacketsLost = currentLost
packetLossHistory.value.push(lostDelta)
// FPS
fpsHistory.value.push(props.webrtcStats.framesPerSecond || 0)
// Calculate bitrate
const currentBytes = props.webrtcStats.bytesReceived || 0
const currentTime = Date.now()
if (lastTimestamp > 0 && currentBytes > lastBytesReceived) {
@@ -312,18 +292,15 @@ function addDataPoint() {
bitrateHistory.value.push(0)
}
// Trim arrays
if (jitterHistory.value.length > MAX_POINTS) jitterHistory.value.shift()
if (delayHistory.value.length > MAX_POINTS) delayHistory.value.shift()
if (packetLossHistory.value.length > MAX_POINTS) packetLossHistory.value.shift()
if (fpsHistory.value.length > MAX_POINTS) fpsHistory.value.shift()
if (bitrateHistory.value.length > MAX_POINTS) bitrateHistory.value.shift()
// Update charts
updateCharts()
}
// Update charts with new data
function updateCharts() {
stabilityChart?.setData([timestamps.value, jitterHistory.value])
delayChart?.setData([timestamps.value, delayHistory.value])
@@ -331,7 +308,6 @@ function updateCharts() {
fpsChart?.setData([timestamps.value, fpsHistory.value])
}
// Data collection interval
let dataInterval: number | null = null
function startDataCollection() {
@@ -346,7 +322,6 @@ function stopDataCollection() {
}
}
// Format candidate type for display
function formatCandidateType(type: string): string {
const typeMap: Record<string, string> = {
host: 'Host (Local)',
@@ -393,10 +368,8 @@ const currentStats = computed(() => {
}
})
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Reset data
timestamps.value = []
jitterHistory.value = []
delayHistory.value = []
@@ -417,7 +390,6 @@ watch(() => props.open, (isOpen) => {
}
})
// Resize handler
function handleResize() {
if (!props.open) return
destroyCharts()

View File

@@ -92,7 +92,6 @@ const statusIcon = computed(() => {
}
})
// Localized status text
const statusText = computed(() => {
switch (props.status) {
case 'connected':
@@ -108,7 +107,6 @@ const statusText = computed(() => {
}
})
// Localized status badge text (for hover card)
const statusBadgeText = computed(() => {
switch (props.status) {
case 'connected':

View File

@@ -68,7 +68,6 @@ const router = useRouter()
const devices = ref<VideoDevice[]>([])
const loadingDevices = ref(false)
// Codec list
const codecs = ref<VideoCodecInfo[]>([])
const loadingCodecs = ref(false)
@@ -135,7 +134,6 @@ function detectBrowserCodecSupport() {
if (capabilities?.codecs) {
for (const codec of capabilities.codecs) {
const mimeType = codec.mimeType.toLowerCase()
// Map MIME types to our codec IDs
if (mimeType.includes('h264') || mimeType.includes('avc')) {
supported.add('h264')
}
@@ -154,7 +152,6 @@ function detectBrowserCodecSupport() {
}
}
} else {
// Fallback: assume basic codecs are supported
supported.add('h264')
supported.add('vp8')
supported.add('vp9')
@@ -191,12 +188,10 @@ const translateBackendName = (backend: string | undefined): string => {
return backend
}
// Check if a format has fps >= 30 in any resolution
const hasHighFps = (format: { resolutions: { fps: number[] }[] }): boolean => {
return format.resolutions.some(res => res.fps.some(fps => fps >= 30))
}
// Check if a format is recommended based on video mode
const isFormatRecommended = (formatName: string): boolean => {
if (!isVideoFormatSelectable(formatName, props.videoMode, currentEncoderBackend.value)) {
return false
@@ -214,20 +209,16 @@ const isFormatRecommended = (formatName: string): boolean => {
const currentFormat = formats.find(f => f.format.toUpperCase() === upperFormat)
if (!currentFormat) return false
// Check if NV12 exists with fps >= 30
const nv12Format = formats.find(f => f.format.toUpperCase() === 'NV12')
const nv12HasHighFps = nv12Format && hasHighFps(nv12Format)
// Check if YUYV exists with fps >= 30
const yuyvFormat = formats.find(f => f.format.toUpperCase() === 'YUYV')
const yuyvHasHighFps = yuyvFormat && hasHighFps(yuyvFormat)
// Priority 1: NV12 with high fps
if (nv12HasHighFps) {
return upperFormat === 'NV12'
}
// Priority 2: YUYV with high fps (only if NV12 doesn't qualify)
if (yuyvHasHighFps) {
return upperFormat === 'YUYV'
}
@@ -235,13 +226,11 @@ const isFormatRecommended = (formatName: string): boolean => {
return false
}
// Check if a format is not recommended for current video mode
// In WebRTC mode, compressed formats (MJPEG/JPEG) are not recommended
const isFormatNotRecommended = (formatName: string): boolean => {
return getFormatState(formatName) === 'not_recommended'
}
// Selected values (mode comes from props)
const selectedDevice = ref<string>('')
const selectedFormat = ref<string>('')
const selectedResolution = ref<string>('')
@@ -249,11 +238,9 @@ const selectedFps = ref<number>(30)
const selectedBitratePreset = ref<'Speed' | 'Balanced' | 'Quality'>('Balanced')
const isDirty = ref(false)
// UI state
const applying = ref(false)
const applyingBitrate = ref(false)
// Current config from store
const currentConfig = computed(() => ({
device: configStore.video?.device || '',
format: configStore.video?.format || '',
@@ -262,7 +249,6 @@ const currentConfig = computed(() => ({
fps: configStore.video?.fps || 30,
}))
// Button display text - simplified to just show label
const buttonText = computed(() => t('actionbar.videoConfig'))
// Available codecs for selection (filtered by backend support and enriched with backend info)
@@ -305,7 +291,6 @@ const availableCodecs = computed(() => {
return backendFiltered.filter(codec => allowed.includes(codec.id))
})
// Cascading filters
const availableFormats = computed(() => {
const device = devices.value.find(d => d.path === selectedDevice.value)
return device?.formats || []
@@ -331,13 +316,11 @@ const availableFps = computed(() => {
return resolution?.fps || []
})
// Get selected format description for display in trigger
const selectedFormatInfo = computed(() => {
const format = availableFormatOptions.value.find(f => f.format === selectedFormat.value)
return format
})
// Get selected codec info for display in trigger
const selectedCodecInfo = computed(() => {
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
return codec || null
@@ -366,7 +349,6 @@ async function loadCodecs() {
backends.value = result.backends || []
} catch (e) {
console.info('[VideoConfig] Failed to load codecs')
// Fallback to default codecs
codecs.value = [
{ id: 'mjpeg', name: 'MJPEG / HTTP', protocol: 'http', hardware: false, backend: 'software', available: true },
{ id: 'h264', name: 'H.264 / WebRTC', protocol: 'webrtc', hardware: false, backend: 'software', available: true },
@@ -384,12 +366,10 @@ async function loadConstraints() {
}
}
// Navigate to settings page (video tab)
function goToSettings() {
router.push('/settings?tab=video')
}
// Initialize selected values from current config
function initializeFromCurrent() {
const config = currentConfig.value
selectedDevice.value = config.device
@@ -417,7 +397,6 @@ function syncFromCurrentIfChanged() {
isDirty.value = false
}
// Handle video mode change
function handleVideoModeChange(mode: unknown) {
if (typeof mode !== 'string') return
@@ -476,7 +455,6 @@ function handleDeviceChange(devicePath: unknown) {
selectedDevice.value = devicePath
isDirty.value = true
// Auto-select first format
const device = devices.value.find(d => d.path === devicePath)
const format = device ? findFirstSelectableFormat(device.formats) : undefined
if (!format) {
@@ -487,7 +465,6 @@ function handleDeviceChange(devicePath: unknown) {
selectFormatWithDefaults(format.format)
}
// Handle format change
function handleFormatChange(format: unknown) {
if (typeof format !== 'string') return
if (isFormatUnsupported(format)) return
@@ -496,13 +473,11 @@ function handleFormatChange(format: unknown) {
isDirty.value = true
}
// Handle resolution change
function handleResolutionChange(resolution: unknown) {
if (typeof resolution !== 'string') return
selectedResolution.value = resolution
isDirty.value = true
// Auto-select first FPS for this resolution
const resolutionData = availableResolutions.value.find(
r => `${r.width}x${r.height}` === resolution
)
@@ -511,14 +486,12 @@ function handleResolutionChange(resolution: unknown) {
}
}
// Handle FPS change
function handleFpsChange(fps: unknown) {
if (typeof fps !== 'string' && typeof fps !== 'number') return
selectedFps.value = typeof fps === 'string' ? Number(fps) : fps
isDirty.value = true
}
// Apply bitrate preset change
async function applyBitratePreset(preset: 'Speed' | 'Balanced' | 'Quality') {
if (applyingBitrate.value) return
applyingBitrate.value = true
@@ -532,7 +505,6 @@ async function applyBitratePreset(preset: 'Speed' | 'Balanced' | 'Quality') {
}
}
// Handle bitrate preset selection
function handleBitratePresetChange(preset: 'Speed' | 'Balanced' | 'Quality') {
selectedBitratePreset.value = preset
if (props.videoMode !== 'mjpeg') {
@@ -540,7 +512,6 @@ function handleBitratePresetChange(preset: 'Speed' | 'Balanced' | 'Quality') {
}
}
// Apply video configuration
async function applyVideoConfig() {
const [width, height] = selectedResolution.value.split('x').map(Number)
@@ -559,13 +530,11 @@ async function applyVideoConfig() {
// Stream state will be updated via WebSocket system.device_info event
} catch (e) {
console.info('[VideoConfig] Failed to apply config:', e)
// Error toast already shown by API layer
} finally {
applying.value = false
}
}
// Watch open state
watch(() => props.open, (isOpen) => {
if (!isOpen) {
isDirty.value = false

View File

@@ -38,20 +38,16 @@ const emit = defineEmits<{
const { t } = useI18n()
// State
const isAttached = ref(props.attached ?? true)
const selectedOs = ref<KeyboardOsType>('windows')
// Keyboard instances
const mainKeyboard = ref<Keyboard | null>(null)
const controlKeyboard = ref<Keyboard | null>(null)
const arrowsKeyboard = ref<Keyboard | null>(null)
// Pressed keys tracking
const pressedModifiers = ref<number>(0)
const keysDown = ref<CanonicalKey[]>([])
// Shift state for display
const isShiftActive = computed(() => {
return (pressedModifiers.value & 0x22) !== 0
})
@@ -64,7 +60,6 @@ const layoutName = computed(() => {
return isShiftActive.value ? 'shift' : 'default'
})
// Keys currently pressed (for highlighting)
const keyNamesForDownKeys = computed(() => {
const activeModifierMask = pressedModifiers.value || 0
const modifierNames = Object.entries(modifiers)
@@ -79,19 +74,15 @@ const keyNamesForDownKeys = computed(() => {
]))
})
// Dragging state (for floating mode)
const keyboardRef = ref<HTMLDivElement | null>(null)
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const position = ref({ x: 100, y: 100 })
// Unique ID for this keyboard instance
const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
// Get bottom row based on selected OS
const getBottomRow = () => osBottomRows[selectedOs.value].join(' ')
// Keyboard layouts - matching JetKVM style
const keyboardLayout = {
main: {
default: [
@@ -143,19 +134,15 @@ function setCompactLayout(active: boolean) {
updateKeyboardLayout()
}
// Key display mapping with Unicode symbols (JetKVM style)
const keyDisplayMap = computed<Record<string, string>>(() => {
// OS-specific Meta key labels
const metaLabel = selectedOs.value === 'windows' ? '⊞ Win'
: selectedOs.value === 'mac' ? '⌘ Cmd' : 'Meta'
return {
// Macros - compact format
CtrlAltDelete: 'Ctrl+Alt+Del',
AltMetaEscape: 'Alt+Meta+Esc',
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
// Modifiers - simplified
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
@@ -166,7 +153,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
MetaRight: metaLabel,
ContextMenu: 'Menu',
// Special keys
Escape: 'Esc',
Backspace: '⌫',
Tab: 'Tab',
@@ -174,7 +160,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
Enter: 'Enter',
Space: ' ',
// Navigation
Insert: 'Ins',
Delete: 'Del',
Home: 'Home',
@@ -182,23 +167,19 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
PageUp: 'PgUp',
PageDown: 'PgDn',
// Arrows
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
// Control cluster
PrintScreen: 'PrtSc',
ScrollLock: 'ScrLk',
Pause: 'Pause',
// Function keys
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
// Letters
KeyA: areLettersUppercase.value ? 'A' : 'a',
KeyB: areLettersUppercase.value ? 'B' : 'b',
KeyC: areLettersUppercase.value ? 'C' : 'c',
@@ -226,7 +207,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
KeyY: areLettersUppercase.value ? 'Y' : 'y',
KeyZ: areLettersUppercase.value ? 'Z' : 'z',
// Letter labels in the shifted layout follow CapsLock xor Shift too
'(KeyA)': areLettersUppercase.value ? 'A' : 'a',
'(KeyB)': areLettersUppercase.value ? 'B' : 'b',
'(KeyC)': areLettersUppercase.value ? 'C' : 'c',
@@ -254,15 +234,12 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
'(KeyY)': areLettersUppercase.value ? 'Y' : 'y',
'(KeyZ)': areLettersUppercase.value ? 'Z' : 'z',
// Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
// Shifted Numbers
'(Digit1)': '!', '(Digit2)': '@', '(Digit3)': '#', '(Digit4)': '$', '(Digit5)': '%',
'(Digit6)': '^', '(Digit7)': '&', '(Digit8)': '*', '(Digit9)': '(', '(Digit0)': ')',
// Symbols
Minus: '-', '(Minus)': '_',
Equal: '=', '(Equal)': '+',
BracketLeft: '[', '(BracketLeft)': '{',
@@ -277,7 +254,6 @@ const keyDisplayMap = computed<Record<string, string>>(() => {
}
})
// Handle media key press (Consumer Control)
async function onMediaKeyPress(key: string) {
if (key in consumerKeys) {
const usage = consumerKeys[key as ConsumerKeyName]
@@ -289,16 +265,12 @@ async function onMediaKeyPress(key: string) {
}
}
// Switch OS layout
function switchOsLayout(os: KeyboardOsType) {
selectedOs.value = os
// Save preference to localStorage
localStorage.setItem('vkb-os-layout', os)
// Update keyboard layout
updateKeyboardLayout()
}
// Update keyboard layout based on selected OS
function updateKeyboardLayout() {
const bottomRow = getBottomRow()
const baseLayout = isCompactLayout.value ? compactMainLayout : keyboardLayout.main
@@ -319,9 +291,7 @@ function updateKeyboardLayout() {
updateKeyboardButtonTheme()
}
// Key press handler
async function onKeyDown(key: string) {
// Handle macro keys
if (key === 'CtrlAltDelete') {
await executeMacro([
{ keys: ['Delete'], modifiers: ['ControlLeft', 'AltLeft'] },
@@ -343,10 +313,8 @@ async function onKeyDown(key: string) {
return
}
// Clean key name (remove parentheses for shifted keys)
const cleanKey = key.replace(/[()]/g, '')
// Check if key exists
if (!(cleanKey in keys)) {
console.warn(`[VirtualKeyboard] Unknown key: ${cleanKey}`)
return
@@ -354,7 +322,6 @@ async function onKeyDown(key: string) {
const keyCode = keys[cleanKey as KeyName]
// Handle latching keys (Caps Lock, etc.)
if (latchingKeys.some(latchingKey => latchingKey === keyCode)) {
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
@@ -366,7 +333,6 @@ async function onKeyDown(key: string) {
return
}
// Handle modifier keys (toggle)
const mask = modifiers[keyCode] ?? 0
if (mask !== 0) {
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
@@ -386,7 +352,6 @@ async function onKeyDown(key: string) {
return
}
// Regular key: press and release
keysDown.value.push(keyCode)
emit('keyDown', keyCode)
const currentMask = pressedModifiers.value & 0xff
@@ -401,7 +366,6 @@ async function onKeyDown(key: string) {
}
async function onKeyUp() {
// Not used for now - we handle up in onKeyDown with setTimeout
}
async function sendKeyPress(keyCode: CanonicalKey, press: boolean, modifierMask: number) {
@@ -453,7 +417,6 @@ async function executeMacro(steps: MacroStep[]) {
}
}
// Update keyboard button theme for pressed keys
function updateKeyboardButtonTheme() {
const downKeys = keyNamesForDownKeys.value.join(' ')
const buttonTheme = [
@@ -472,7 +435,6 @@ function updateKeyboardButtonTheme() {
arrowsKeyboard.value?.setOptions({ buttonTheme })
}
// Update layout when shift state changes
watch([layoutName, () => props.capsLock], ([name]) => {
mainKeyboard.value?.setOptions({
layoutName: name,
@@ -481,11 +443,9 @@ watch([layoutName, () => props.capsLock], ([name]) => {
updateKeyboardButtonTheme()
})
// Initialize keyboards with unique selectors
function initKeyboards() {
const id = keyboardId.value
// Check if elements exist - use full selector with #
const mainEl = document.querySelector(`#${id}-main`)
const controlEl = document.querySelector(`#${id}-control`)
const arrowsEl = document.querySelector(`#${id}-arrows`)
@@ -496,7 +456,6 @@ function initKeyboards() {
return
}
// Main keyboard - pass element directly instead of selector string
mainKeyboard.value = new Keyboard(mainEl, {
layout: isCompactLayout.value ? compactMainLayout : keyboardLayout.main,
layoutName: layoutName.value,
@@ -517,7 +476,6 @@ function initKeyboards() {
stopMouseUpPropagation: true,
})
// Control keyboard
controlKeyboard.value = new Keyboard(controlEl, {
layout: keyboardLayout.control,
layoutName: 'default',
@@ -532,7 +490,6 @@ function initKeyboards() {
stopMouseUpPropagation: true,
})
// Arrows keyboard
arrowsKeyboard.value = new Keyboard(arrowsEl, {
layout: keyboardLayout.arrows,
layoutName: 'default',
@@ -551,7 +508,6 @@ function initKeyboards() {
console.log('[VirtualKeyboard] Keyboards initialized:', id)
}
// Destroy keyboards
function destroyKeyboards() {
mainKeyboard.value?.destroy()
controlKeyboard.value?.destroy()
@@ -561,7 +517,6 @@ function destroyKeyboards() {
arrowsKeyboard.value = null
}
// Dragging handlers
function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null {
if ('touches' in e) {
const touch = e.touches[0]
@@ -609,11 +564,9 @@ async function toggleAttached() {
isAttached.value = !isAttached.value
emit('update:attached', isAttached.value)
// Wait for Teleport to move the component
await nextTick()
await nextTick() // Extra tick for Teleport
// Reinitialize keyboards in new location
setTimeout(() => {
initKeyboards()
}, 100)
@@ -623,7 +576,6 @@ function close() {
emit('update:visible', false)
}
// Watch visibility to init/destroy keyboards
watch(() => props.visible, async (visible) => {
console.log('[VirtualKeyboard] Visibility changed:', visible, 'attached:', isAttached.value, 'id:', keyboardId.value)
if (visible) {
@@ -641,7 +593,6 @@ watch(() => props.attached, (value) => {
})
onMounted(() => {
// Load saved OS layout preference
const savedOs = localStorage.getItem('vkb-os-layout') as KeyboardOsType | null
if (savedOs && ['windows', 'mac', 'android'].includes(savedOs)) {
selectedOs.value = savedOs

View File

@@ -1,20 +1,14 @@
// Audio player composable - handles WebSocket connection, Opus decoding, and Web Audio API playback
import { ref, watch } from 'vue'
import { OpusDecoder } from 'opus-decoder'
import { buildWsUrl } from '@/types/websocket'
// Binary protocol header format (15 bytes)
// [type:1][timestamp:4][duration:2][sequence:4][length:4][data:...]
export function useAudioPlayer() {
// State
const connected = ref(false)
const playing = ref(false)
const volume = ref(0) // Default to 0, user must adjust to enable audio (browser autoplay policy)
const error = ref<string | null>(null)
// Internal variables
let ws: WebSocket | null = null
let audioContext: AudioContext | null = null
let gainNode: GainNode | null = null
@@ -23,7 +17,6 @@ export function useAudioPlayer() {
let nextPlayTime = 0
let isConnecting = false // Prevent concurrent connection attempts
// Initialize decoder
async function initDecoder() {
const opusDecoder = new OpusDecoder({
channels: 2,
@@ -33,7 +26,6 @@ export function useAudioPlayer() {
decoder = opusDecoder
}
// Initialize audio context
function initAudioContext() {
audioContext = new AudioContext({ sampleRate: 48000 })
gainNode = audioContext.createGain()
@@ -41,14 +33,12 @@ export function useAudioPlayer() {
updateVolume()
}
// Connect to WebSocket
async function connect() {
// Prevent concurrent connection attempts (critical fix for multiple WS connections)
if (isConnecting) {
return
}
// Check if already connected
if (ws) {
if (ws.readyState === WebSocket.OPEN) {
return
@@ -64,7 +54,6 @@ export function useAudioPlayer() {
isConnecting = true
try {
// Initialize
if (!decoder) await initDecoder()
if (!audioContext) initAudioContext()
@@ -108,7 +97,6 @@ export function useAudioPlayer() {
}
}
// Disconnect
function disconnect() {
if (ws) {
ws.close()
@@ -140,14 +128,12 @@ export function useAudioPlayer() {
const samplesPerChannel = decoded.samplesDecoded
const channels = decoded.channelData.length
// Create audio buffer
const audioBuffer = audioContext.createBuffer(
channels,
samplesPerChannel,
48000
)
// Fill channel data
for (let ch = 0; ch < channels; ch++) {
const channelData = audioBuffer.getChannelData(ch)
const sourceData = decoded.channelData[ch]
@@ -156,7 +142,6 @@ export function useAudioPlayer() {
}
}
// Schedule playback
const source = audioContext.createBufferSource()
source.buffer = audioBuffer
source.connect(gainNode)
@@ -164,12 +149,10 @@ export function useAudioPlayer() {
const now = audioContext.currentTime
const scheduledAhead = nextPlayTime - now
// Reset if too far behind (audio was paused/lagged)
if (nextPlayTime < now) {
nextPlayTime = now + 0.02 // Start 20ms ahead
}
// Reset if buffer too large (> 500ms ahead)
if (scheduledAhead > 0.5) {
nextPlayTime = now + 0.05 // Keep 50ms buffer
}
@@ -177,33 +160,27 @@ export function useAudioPlayer() {
source.start(nextPlayTime)
nextPlayTime += audioBuffer.duration
} catch {
// Ignore decode errors
}
}
// Update volume
function updateVolume() {
if (gainNode) {
gainNode.gain.value = volume.value
}
}
// Set volume
function setVolume(v: number) {
volume.value = Math.max(0, Math.min(1, v))
updateVolume()
}
// Watch volume changes
watch(volume, updateVolume)
return {
// State
connected,
playing,
volume,
error,
// Methods
connect,
disconnect,
setVolume,

View File

@@ -1,5 +1,3 @@
// Config popover composable - shared logic for config popover components
// Provides common state management and lifecycle hooks
import { ref, watch, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -17,11 +15,9 @@ export interface UseConfigPopoverOptions {
export function useConfigPopover(options: UseConfigPopoverOptions) {
const { t } = useI18n()
// Common state
const applying = ref(false)
const loadingDevices = ref(false)
// Watch open state to initialize
watch(options.open, async (isOpen) => {
if (isOpen) {
options.initializeFromCurrent?.()
@@ -36,7 +32,6 @@ export function useConfigPopover(options: UseConfigPopoverOptions) {
}
})
// Apply config wrapper with loading state and toast
async function applyConfig(applyFn: () => Promise<void>) {
applying.value = true
try {
@@ -44,7 +39,6 @@ export function useConfigPopover(options: UseConfigPopoverOptions) {
toast.success(t('config.applied'))
} catch (e) {
console.info('[ConfigPopover] Apply failed:', e)
// Error toast is usually shown by API layer
} finally {
applying.value = false
}
@@ -62,11 +56,9 @@ export function useConfigPopover(options: UseConfigPopoverOptions) {
}
return {
// State
applying,
loadingDevices,
// Methods
applyConfig,
refreshDevices,
}

View File

@@ -1,6 +1,3 @@
// Console WebSocket events composable - handles all WebSocket event subscriptions
// Extracted from ConsoleView.vue for better separation of concerns
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
@@ -33,7 +30,7 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
const systemStore = useSystemStore()
const { on, off, connect } = useWebSocket()
const noop = () => {}
// Stream device monitoring handlers
function handleStreamDeviceLost(data: { device: string; reason: string }) {
if (systemStore.stream) {
systemStore.stream.online = false
@@ -66,20 +63,11 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
handlers.onStreamRecovered?.(_data)
}
function handleStreamStateChanged(data: { state: string }) {
if (data.state === 'error') {
// Handled by video stream composable
}
}
function handleStreamStateChangedForward(data: { state: string; device?: string | null }) {
handleStreamStateChanged(data)
handlers.onStreamStateChanged?.(data)
}
// Subscribe to all events
function subscribe() {
// Stream events
on('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
on('stream.config_applied', handlers.onStreamConfigApplied ?? noop)
on('stream.stats_update', handlers.onStreamStatsUpdate ?? noop)
@@ -92,14 +80,11 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) {
on('stream.reconnecting', handleStreamReconnecting)
on('stream.recovered', handleStreamRecovered)
// System events
on('system.device_info', handlers.onDeviceInfo ?? noop)
// Connect WebSocket
connect()
}
// Unsubscribe from all events
function unsubscribe() {
off('stream.config_changing', handlers.onStreamConfigChanging ?? noop)
off('stream.config_applied', handlers.onStreamConfigApplied ?? noop)

View File

@@ -1,6 +1,3 @@
// WebSocket HID channel for low-latency keyboard/mouse input (binary protocol)
// Uses the same binary format as WebRTC DataChannel for consistency
import { ref, onUnmounted } from 'vue'
import {
type HidKeyboardEvent,
@@ -23,26 +20,22 @@ const reconnectAttempts = ref(0)
const networkError = ref(false)
const networkErrorMessage = ref<string | null>(null)
let reconnectTimeout: number | null = null
const hidUnavailable = ref(false) // Track if HID is unavailable to prevent unnecessary reconnects
const hidUnavailable = ref(false)
// Connection promise to avoid race conditions
let connectionPromise: Promise<boolean> | null = null
let connectionResolved = false
function connect(): Promise<boolean> {
// If already connected, return immediately
if (wsInstance && wsInstance.readyState === WebSocket.OPEN && connectionResolved) {
return Promise.resolve(true)
}
// If connection is in progress, return the existing promise
if (connectionPromise && !connectionResolved) {
return connectionPromise
}
connectionResolved = false
connectionPromise = new Promise((resolve) => {
// Reset network error flag when attempting new connection
networkError.value = false
networkErrorMessage.value = null
hidUnavailable.value = false
@@ -60,7 +53,6 @@ function connect(): Promise<boolean> {
}
wsInstance.onmessage = (e) => {
// Handle binary response
if (e.data instanceof ArrayBuffer) {
const view = new DataView(e.data)
if (view.byteLength >= 1) {
@@ -126,14 +118,12 @@ function disconnect() {
}
if (wsInstance) {
// Close the websocket
wsInstance.close()
wsInstance = null
connected.value = false
networkError.value = false
}
// Reset connection state
connectionPromise = null
connectionResolved = false
}
@@ -154,7 +144,6 @@ function sendKeyboard(event: HidKeyboardEvent): Promise<void> {
})
}
// Internal function to actually send mouse event
function _sendMouseInternal(event: HidMouseEvent): Promise<void> {
return new Promise((resolve, reject) => {
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
@@ -175,7 +164,6 @@ function sendMouse(event: HidMouseEvent): Promise<void> {
return _sendMouseInternal(event)
}
// Send consumer control event (multimedia keys)
function sendConsumer(event: HidConsumerEvent): Promise<void> {
return new Promise((resolve, reject) => {
if (!wsInstance || wsInstance.readyState !== WebSocket.OPEN) {
@@ -194,8 +182,6 @@ function sendConsumer(event: HidConsumerEvent): Promise<void> {
export function useHidWebSocket() {
onUnmounted(() => {
// Don't disconnect on component unmount - WebSocket is shared
// Only disconnect when explicitly called or page unloads
})
return {
@@ -212,7 +198,6 @@ export function useHidWebSocket() {
}
}
// Global lifecycle - disconnect when page unloads
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
disconnect()

View File

@@ -1,4 +1,3 @@
// Unified Audio Manager
// Manages audio playback across different video modes (MJPEG/WebSocket and H264/WebRTC)
// Provides a single interface for volume control and audio source switching
@@ -17,7 +16,6 @@ export interface UnifiedAudioState {
}
export function useUnifiedAudio() {
// === State ===
const audioMode = ref<AudioMode>('ws')
const volume = ref(0) // 0-1, default muted (browser autoplay policy)
const muted = ref(false)
@@ -25,11 +23,9 @@ export function useUnifiedAudio() {
const playing = ref(false)
const error = ref<string | null>(null)
// === Internal References ===
const wsPlayer = getAudioPlayer()
let webrtcVideoElement: HTMLVideoElement | null = null
// === Methods ===
/**
* Set the WebRTC video element reference
@@ -39,9 +35,7 @@ export function useUnifiedAudio() {
// Only update if element is provided (don't clear on null to preserve reference)
if (el) {
webrtcVideoElement = el
// Sync current volume to video element
el.volume = volume.value
// Mute if volume is 0 or explicitly muted
const shouldMute = muted.value || volume.value === 0
el.muted = shouldMute
}
@@ -57,7 +51,6 @@ export function useUnifiedAudio() {
const wasConnected = connected.value
const wasPlaying = playing.value
// Disconnect old mode
if (audioMode.value === 'ws') {
wsPlayer.disconnect()
}
@@ -65,12 +58,10 @@ export function useUnifiedAudio() {
audioMode.value = mode
// If was connected/playing and volume > 0, auto-connect new mode
if ((wasConnected || wasPlaying) && volume.value > 0) {
await connect()
}
// Update connection state
updateConnectionState()
}
@@ -82,7 +73,6 @@ export function useUnifiedAudio() {
const newVolume = Math.max(0, Math.min(1, v))
volume.value = newVolume
// Sync to WS player
wsPlayer.setVolume(newVolume)
// Sync to WebRTC video element
@@ -99,7 +89,6 @@ export function useUnifiedAudio() {
function setMuted(m: boolean) {
muted.value = m
// WS player: control via volume (no separate mute)
if (audioMode.value === 'ws') {
wsPlayer.setVolume(m ? 0 : volume.value)
}
@@ -133,7 +122,6 @@ export function useUnifiedAudio() {
}
} else {
// WebRTC audio is automatically connected via video track
// Just ensure video element is not muted (if volume > 0)
if (webrtcVideoElement) {
webrtcVideoElement.muted = muted.value || volume.value === 0
connected.value = true
@@ -173,7 +161,6 @@ export function useUnifiedAudio() {
}
}
// Watch WS player state changes
watch(() => wsPlayer.connected.value, (newConnected) => {
if (audioMode.value === 'ws') {
connected.value = newConnected
@@ -193,7 +180,6 @@ export function useUnifiedAudio() {
})
return {
// State
audioMode,
volume,
muted,
@@ -201,7 +187,6 @@ export function useUnifiedAudio() {
playing,
error,
// Methods
setWebRTCElement,
switchMode,
setVolume,

View File

@@ -85,7 +85,6 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
}
// Fallback: for local connections, use no ICE servers (host candidates only)
// For remote connections, use Google STUN as fallback
const isLocalConnection = typeof window !== 'undefined' &&
(window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
@@ -105,7 +104,6 @@ async function fetchIceServers(): Promise<RTCIceServer[]> {
]
}
// Shared instance state
let peerConnection: RTCPeerConnection | null = null
let dataChannel: RTCDataChannel | null = null
let sessionId: string | null = null
@@ -146,7 +144,6 @@ const error = ref<string | null>(null)
const dataChannelReady = ref(false)
const connectStage = ref<WebRTCConnectStage>('idle')
// Create RTCPeerConnection with configuration
function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
const config: RTCConfiguration = {
iceServers,
@@ -155,7 +152,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
const pc = new RTCPeerConnection(config)
// Handle connection state changes
pc.onconnectionstatechange = () => {
switch (pc.connectionState) {
case 'connecting':
@@ -211,7 +207,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
}
}
// Handle incoming tracks
pc.ontrack = (event) => {
const track = event.track
@@ -222,7 +217,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
}
}
// Handle data channel from server
pc.ondatachannel = (event) => {
setupDataChannel(event.channel)
}
@@ -230,7 +224,6 @@ function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {
return pc
}
// Setup data channel event handlers
function setupDataChannel(channel: RTCDataChannel) {
dataChannel = channel
@@ -243,15 +236,12 @@ function setupDataChannel(channel: RTCDataChannel) {
}
channel.onerror = () => {
// Data channel errors handled silently
}
channel.onmessage = () => {
// Handle incoming messages from server (e.g., LED status)
}
}
// Create data channel for HID events
function createDataChannel(pc: RTCPeerConnection): RTCDataChannel {
const channel = pc.createDataChannel('hid', {
ordered: true,
@@ -307,7 +297,6 @@ async function handleRemoteIceComplete(data: WebRTCIceCompleteEvent) {
try {
await peerConnection.addIceCandidate(null)
} catch {
// End-of-candidates failures are non-fatal
}
}
@@ -348,7 +337,6 @@ async function flushPendingRemoteIce() {
try {
await peerConnection.addIceCandidate(null)
} catch {
// Ignore end-of-candidates errors
}
}
}
@@ -363,14 +351,12 @@ function startStatsCollection() {
try {
const report = await peerConnection.getStats()
// Collect candidate info
const candidates: Record<string, { type: IceCandidateType; protocol: string }> = {}
let selectedPairLocalId = ''
let selectedPairRemoteId = ''
let foundActivePair = false
report.forEach((stat) => {
// Collect all candidates
if (stat.type === 'local-candidate' || stat.type === 'remote-candidate') {
candidates[stat.id] = {
type: (stat.candidateType as IceCandidateType) || 'unknown',
@@ -378,10 +364,7 @@ function startStatsCollection() {
}
}
// Find the active candidate pair
// Priority: nominated > succeeded (for Chrome/Firefox compatibility)
if (stat.type === 'candidate-pair') {
// Check if this is the nominated/selected pair
const isActive = stat.nominated === true ||
(stat.state === 'succeeded' && stat.selected === true) ||
(stat.state === 'in-progress' && !foundActivePair)
@@ -425,8 +408,6 @@ function startStatsCollection() {
stats.value.remoteCandidateType = remoteCandidate.type
}
// Check if using TURN relay
// TURN relay is when either local or remote candidate is 'relay' type
stats.value.isRelay = stats.value.localCandidateType === 'relay' || stats.value.remoteCandidateType === 'relay'
} catch {
// Stats collection errors are non-fatal
@@ -473,7 +454,6 @@ async function connect(): Promise<boolean> {
connectInFlight = (async () => {
registerWebSocketHandlers()
// Prevent concurrent connection attempts
if (isConnecting) {
return state.value === 'connected'
}
@@ -484,7 +464,6 @@ async function connect(): Promise<boolean> {
isConnecting = true
// Clean up any existing connection first
if (peerConnection || sessionId) {
await disconnect()
}
@@ -505,20 +484,16 @@ async function connect(): Promise<boolean> {
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
@@ -526,7 +501,6 @@ async function connect(): Promise<boolean> {
// 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,
@@ -611,7 +585,6 @@ async function disconnect() {
try {
await webrtcApi.close(oldSessionId)
} catch {
// Ignore close errors
}
}
@@ -671,13 +644,11 @@ function sendMouse(event: HidMouseEvent): boolean {
}
}
// Get MediaStream for video element (cached to avoid recreating)
function getMediaStream(): MediaStream | null {
if (!videoTrack.value && !audioTrack.value) {
return null
}
// Reuse cached stream if tracks match
if (cachedMediaStream) {
const currentVideoTracks = cachedMediaStream.getVideoTracks()
const currentAudioTracks = cachedMediaStream.getAudioTracks()
@@ -693,19 +664,15 @@ function getMediaStream(): MediaStream | null {
return cachedMediaStream
}
// Tracks changed, update the cached stream
// Remove old tracks
currentVideoTracks.forEach(t => cachedMediaStream!.removeTrack(t))
currentAudioTracks.forEach(t => cachedMediaStream!.removeTrack(t))
// Add new tracks
if (videoTrack.value) cachedMediaStream.addTrack(videoTrack.value)
if (audioTrack.value) cachedMediaStream.addTrack(audioTrack.value)
return cachedMediaStream
}
// Create new cached stream
cachedMediaStream = new MediaStream()
if (videoTrack.value) {
cachedMediaStream.addTrack(videoTrack.value)
@@ -716,15 +683,11 @@ function getMediaStream(): MediaStream | null {
return cachedMediaStream
}
// Composable export
export function useWebRTC() {
onUnmounted(() => {
// Don't disconnect on unmount - keep connection alive
// Only disconnect when explicitly called
})
return {
// State
state: state as Ref<WebRTCState>,
videoTrack,
audioTrack,
@@ -734,14 +697,12 @@ export function useWebRTC() {
connectStage,
sessionId: computed(() => sessionId),
// Methods
connect,
disconnect,
sendKeyboard,
sendMouse,
getMediaStream,
// Computed
isConnected: computed(() => state.value === 'connected'),
isConnecting: computed(() => state.value === 'connecting'),
hasVideo: computed(() => videoTrack.value !== null),
@@ -749,7 +710,6 @@ export function useWebRTC() {
}
}
// Cleanup on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
disconnect()

View File

@@ -1,8 +1,3 @@
// WebSocket composable for real-time event streaming
//
// Usage:
// const { connected, on, off } = useWebSocket()
// on('stream.state_changed', (data) => { ... })
import { ref } from 'vue'
import { buildWsUrl, WS_RECONNECT_DELAY } from '@/types/websocket'
@@ -151,7 +146,6 @@ function handleEvent(payload: WsEvent) {
}
})
}
// Silently ignore events without handlers
}
export function useWebSocket() {
@@ -170,7 +164,6 @@ export function useWebSocket() {
}
}
// Global lifecycle - disconnect when page unloads
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
disconnect()

View File

@@ -109,7 +109,6 @@ export default {
settingsTip: 'System settings',
fullscreen: 'Fullscreen',
fullscreenTip: 'Toggle fullscreen mode',
// Video Config
videoConfig: 'Video',
streamSettings: 'Stream Settings',
deviceSettings: 'Device Settings',
@@ -141,7 +140,6 @@ export default {
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: 'HID',
mouseSettings: 'Mouse Settings',
hidDeviceSettings: 'HID Device Settings',
@@ -154,7 +152,6 @@ export default {
absolute: 'Absolute',
relative: 'Relative',
applying: 'Applying...',
// Audio Config
audioConfig: 'Audio',
playbackControl: 'Playback',
volume: 'Volume',
@@ -218,19 +215,16 @@ export default {
title: 'Initial Setup',
welcome: 'Welcome to One-KVM',
description: 'Complete the initial setup to get started',
// Step titles
stepAccount: 'Account Setup',
stepVideo: 'Video Setup',
stepAudioVideo: 'Audio/Video Setup',
stepHid: 'HID Setup',
// Account
setUsername: 'Set Admin Username',
usernameHint: 'Username must be at least 2 characters',
setPassword: 'Set Admin Password',
passwordHint: 'Password must be at least 4 characters',
confirmPassword: 'Confirm Password',
passwordMismatch: 'Passwords do not match',
// Video
videoDevice: 'Video Device',
selectVideoDevice: 'Select video capture device',
videoFormat: 'Video Format',
@@ -242,13 +236,11 @@ export default {
noVideoDevices: 'No video devices detected',
noSignalDetected: 'No HDMI signal detected. Please connect an HDMI cable and refresh.',
refreshDevices: 'Refresh Devices',
// Audio
audioDevice: 'Audio Device',
selectAudioDevice: 'Select audio capture device',
noAudio: 'No audio',
noAudioDevices: 'No audio devices detected',
audioDeviceHelp: 'Select the audio capture device for capturing remote host audio. Usually on the same USB device as the video capture card.',
// HID
hidBackend: 'HID Backend',
selectHidBackend: 'Select HID control method',
serialHid: 'Serial HID',
@@ -261,31 +253,25 @@ export default {
selectUdc: 'Select UDC',
noUdcDevices: 'No UDC devices detected',
hidDisabledHint: 'Disabling HID will prevent keyboard and mouse control of the remote host',
// Complete
complete: 'Complete Setup',
setupFailed: 'Setup failed',
// Advanced encoder
advancedEncoder: 'Advanced: Encoder Backend',
encoderHint: 'The default "Auto" option works for most cases. Only change if you need a specific encoder backend.',
autoRecommended: 'Auto (Recommended)',
hardware: 'Hardware',
software: 'Software',
// Progress
progress: 'Step {current} of {total}',
// Help tooltips
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
// Extensions
stepExtensions: 'Extensions',
extensionsDescription: 'Choose which extensions to auto-start',
ttydTitle: 'Web Terminal (ttyd)',
ttydDescription: 'Access device command line in browser',
extensionsHint: 'These settings can be changed later in Settings',
notInstalled: 'Not installed',
// Password strength
passwordStrength: 'Password Strength',
passwordWeak: 'Weak',
passwordMedium: 'Medium',
@@ -350,7 +336,6 @@ export default {
uvc_capture_stall: '',
},
},
// WebRTC
webrtcConnected: 'WebRTC Connected',
webrtcConnectedDesc: 'Using low-latency H.264 video stream',
webrtcFailed: 'WebRTC Connection Failed',
@@ -363,29 +348,23 @@ export default {
webrtcPhaseSetRemote: 'Applying remote description...',
webrtcPhaseApplyIce: 'Applying ICE candidates...',
webrtcPhaseNegotiating: 'Negotiating secure connection...',
// Pointer Lock
pointerLocked: 'Pointer Locked',
pointerLockedDesc: 'Press Escape to release the pointer',
pointerLockFailed: 'Failed to lock pointer',
relativeModeHint: 'Relative Mouse Mode',
relativeModeHintDesc: 'Click on the video area to lock the pointer, press Escape to release',
// Meta Key Hint
metaKeyHint: 'System Key Detected',
metaKeyHintDesc: 'Enter fullscreen mode to capture Win/Meta keys',
// Stream mode change
streamModeChanged: 'Video Mode Changed',
streamModeChangedDesc: 'Server switched to {mode} mode',
// Device monitoring
deviceLost: 'Video Device Lost',
deviceLostDesc: '{device}: {reason}',
deviceRecovering: 'Video Device Recovering',
deviceRecoveringDesc: 'Attempting to recover video device (attempt {attempt})',
deviceRecovered: 'Video Device Recovered',
deviceRecoveredDesc: 'Video device reconnected successfully',
// Loading state
pleaseWait: 'Please wait...',
retryCount: 'Retrying (attempt {count})',
// Error details
errorDetails: 'Error details',
},
hid: {
@@ -397,7 +376,6 @@ export default {
pasteText: 'Paste Text',
absoluteMouse: 'Absolute',
relativeMouse: 'Relative',
// Device monitoring
deviceLost: 'HID Device Lost',
deviceLostDesc: '{backend}: {reason}',
reconnecting: 'HID Reconnecting',
@@ -424,7 +402,6 @@ export default {
},
},
audio: {
// Device monitoring
deviceLost: 'Audio Device Lost',
deviceLostDesc: '{device}: {reason}',
reconnecting: 'Audio Reconnecting',
@@ -468,7 +445,6 @@ export default {
uploadImageHint: 'Click to upload ISO/IMG',
imageMounted: 'Image {name} mounted',
imageUnmounted: 'Image unmounted',
// URL download
downloadFromUrl: 'Download from URL',
downloadFromUrlDesc: 'Enter the URL of an image file (ISO/IMG supported)',
url: 'URL',
@@ -479,16 +455,13 @@ export default {
downloadFailed: 'Download failed',
largeFileWarning: '>2.2GB',
largeFileTooltip: 'File is larger than 2.2GB, please use Flash mode to mount',
// Device monitoring
error: 'MSD Error',
errorDesc: '{reason}',
recovered: 'MSD Recovered',
recoveredDesc: 'MSD operation completed successfully',
// Operation status
operationInProgress: 'Operation in progress, please wait',
driveConnected: 'Virtual USB drive connected',
imageConnected: 'Image {name} connected',
// Drive initialization
selectDriveSize: 'Select virtual drive size',
selectedSize: 'Selected size',
customSize: 'Custom size',
@@ -520,7 +493,6 @@ export default {
security: 'Security',
about: 'About',
aboutDesc: 'Open and Lightweight IP-KVM Solution',
// Device info
deviceInfo: 'Device Info',
deviceInfoDesc: 'Host system information',
hostname: 'Hostname',
@@ -545,11 +517,9 @@ export default {
networkSettings: 'Network Settings',
msdSettings: 'MSD Settings',
atxSettings: 'ATX Settings',
// Network tab
httpSettings: 'HTTP Settings',
httpPort: 'HTTP Port',
configureHttpPort: 'Configure HTTP server port',
// Web server
webServer: 'Access Address',
webServerDesc: 'Configure HTTP/HTTPS ports and listening addresses. Restart required for changes to take effect.',
httpsPort: 'HTTPS Port',
@@ -569,20 +539,17 @@ export default {
bindAddressListEmpty: 'Add at least one IP address.',
httpsEnabled: 'Enable HTTPS',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)',
// Port config
portConfig: 'Port & Protocol',
portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle',
httpPortReserved: 'HTTP port (reserved)',
httpsPortReserved: 'HTTPS port (reserved)',
previewUrl: 'Access URL preview',
// Listen address
listenAddress: 'Listen Address',
listenAddressDesc: 'Configure which network interfaces the web server listens on',
bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces',
bindModeLocalDesc: '127.0.0.1 — Allow local access only',
bindModeCustomDesc: 'Specify a list of IP addresses',
effectiveAddresses: 'Listen address preview',
// SSL certificate
sslCertificate: 'SSL Certificate',
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required',
sslCertCustom: 'Custom Certificate',
@@ -628,13 +595,11 @@ export default {
updateMsgVerifying: 'Verifying (SHA256)',
updateMsgInstalling: 'Replacing binary',
updateMsgRestarting: 'Restarting service',
// Auth
auth: 'Access',
authSettings: 'Access Settings',
authSettingsDesc: 'Single-user access and session behavior',
allowMultipleSessions: 'Allow multiple web sessions',
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
// User management
userManagement: 'User Management',
userManagementDesc: 'Manage user accounts and permissions',
addUser: 'Add User',
@@ -649,7 +614,6 @@ export default {
noUsers: 'No users found',
create: 'Create',
confirmDeleteUser: 'Are you sure you want to delete user "{name}"?',
// MSD/ATX status
msdStatus: 'MSD Status',
atxStatus: 'ATX Status',
available: 'Available',
@@ -665,7 +629,6 @@ export default {
disabled: 'Disabled',
msdDesc: 'Mass Storage Device allows you to mount ISO images and virtual drives to the target machine. Use the MSD panel on the main page to manage images.',
atxDesc: 'ATX power control allows you to remotely power on/off and reset the target machine. Use the ATX panel on the main page to control power.',
// ATX configuration
atxSettingsDesc: 'Configure ATX power control hardware bindings',
atxEnable: 'Enable ATX Control',
atxEnableDesc: 'Enable remote control of power and reset buttons',
@@ -693,16 +656,13 @@ export default {
atxLedPin: 'GPIO Pin',
atxLedInverted: 'Invert Logic',
atxLedInvertedDesc: 'GPIO is low when LED is on',
// WOL configuration
atxWolSettings: 'Wake-on-LAN Settings',
atxWolSettingsDesc: 'Configure WOL magic packet sending options',
atxWolInterface: 'Network Interface',
atxWolInterfacePlaceholder: 'e.g. eth0, enp0s3',
atxWolInterfaceHint: 'Specify network interface for WOL packets, leave empty for default routing',
// Basic tab descriptions
themeDesc: 'Choose your preferred color scheme',
languageDesc: 'Select your preferred language',
// Video tab
videoSettings: 'Video Settings',
videoSettingsDesc: 'Configure video capture device',
videoDevice: 'Video Device',
@@ -719,7 +679,6 @@ export default {
software: 'Software',
supportedFormats: 'Supported Formats',
encoderHint: 'Hardware encoders provide better performance with lower CPU usage. Software encoders are more compatible but require more CPU resources.',
// HID tab
hidSettings: 'HID Settings',
hidSettingsDesc: 'Configure keyboard and mouse control',
hidBackend: 'HID Backend',
@@ -748,7 +707,6 @@ export default {
otgProfileWarning: 'Changing HID functions will reconnect the USB device',
otgLowEndpointHint: 'Low-endpoint UDC detected; Consumer Control Keyboard will be disabled automatically.',
otgFunctionMinWarning: 'Enable at least one HID function before saving',
// OTG Descriptor
otgDescriptor: 'USB Device Descriptor',
otgDescriptorDesc: 'Configure USB device identification',
vendorId: 'Vendor ID (VID)',
@@ -862,7 +820,6 @@ export default {
resetConfirmDesc: 'This will reset USB device "{device}" by cycling its authorized attribute. All connections to this device will be temporarily interrupted. Continue?',
resetAction: 'Reset Device',
},
// WebRTC / ICE
webrtcSettings: 'WebRTC Settings',
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
publicIceServersHint: 'Empty uses Google public STUN, configure your own TURN for production',
@@ -931,7 +888,6 @@ export default {
notConnected: 'Not Connected',
connected: 'Connected',
image: 'Image',
// MSD status details
msdStatus: 'Status',
msdStandby: 'Idle',
msdImageMode: 'Image Mode',
@@ -941,7 +897,6 @@ export default {
msdNoImage: 'None',
},
extensions: {
// Common
available: 'Available',
unavailable: 'Unavailable',
running: 'Running',
@@ -958,7 +913,6 @@ export default {
title: 'Remote Access',
desc: 'GOSTC NAT traversal and Easytier networking',
},
// ttyd
ttyd: {
title: 'Ttyd Web Terminal',
desc: 'Web terminal access via ttyd',
@@ -967,7 +921,6 @@ export default {
port: 'Port',
shell: 'Shell',
},
// gostc
gostc: {
title: 'GOSTC NAT Traversal',
desc: 'NAT traversal via GOSTC',
@@ -976,7 +929,6 @@ export default {
key: 'Client Key',
tls: 'Enable TLS',
},
// easytier
easytier: {
title: 'Easytier Network',
desc: 'P2P VPN networking via EasyTier',
@@ -987,7 +939,6 @@ export default {
virtualIp: 'Virtual IP',
virtualIpHint: 'Leave empty for DHCP, or specify with CIDR (e.g., 10.0.0.1/24)',
},
// rustdesk
rustdesk: {
title: 'RustDesk Remote',
desc: 'Remote access via RustDesk client',
@@ -1078,31 +1029,24 @@ export default {
p2p: 'P2P Direct',
relay: 'TURN Relay',
},
// Help tooltip texts
help: {
// MSD related
flashMode: 'Flash mode mounts the image as a USB drive, compatible with most BIOS boot',
cdromMode: 'CDROM mode mounts the image as a CD drive, for systems requiring optical boot',
readOnlyMode: 'Read-only mode is safer, the target system cannot modify the image',
readWriteMode: 'Read-write mode allows writing data, useful for saving configurations',
driveSize: 'Virtual drive size. Larger drives can store more files but take longer to initialize',
// Video related
mjpegMode: 'MJPEG mode has best compatibility, works with all browsers, but higher latency',
webrtcMode: 'WebRTC mode has lower latency, but requires browser codec support',
videoBitratePreset: 'Speed: lowest latency, best for slow networks. Balanced: good quality and latency. Quality: best visual, needs good bandwidth',
encoderBackend: 'Hardware encoder has better performance and lower power. Software encoder has better compatibility',
// HID related
absoluteMode: 'Absolute mode maps mouse coordinates directly, suitable for most scenarios',
relativeMode: 'Relative mode sends mouse movement delta, for games or special software',
mouseThrottle: 'Send interval controls mouse event frequency. Higher values reduce network load',
hidBackend: 'OTG backend requires USB OTG hardware support. CH9329 is a serial HID chip solution',
// ATX related
atxActiveLevel: 'Active level depends on your hardware wiring. High means high voltage when triggered',
wolInterface: 'Network interface name for sending Wake-on-LAN magic packets, e.g., eth0 or br0',
// Network related
stunServer: 'STUN server for NAT traversal to establish P2P connections. Leave empty for public servers',
turnServer: 'TURN server provides relay when P2P fails. Requires more bandwidth but more reliable',
// Audio related
audioQuality: 'Higher quality means better audio but requires more network bandwidth',
},
}

View File

@@ -2,7 +2,6 @@ import { createI18n } from 'vue-i18n'
import zhCN from './zh-CN'
import enUS from './en-US'
// Supported languages
export const supportedLanguages = [
{ code: 'zh-CN', name: '中文', flag: '🇨🇳' },
{ code: 'en-US', name: 'English', flag: '🇺🇸' },
@@ -10,33 +9,26 @@ export const supportedLanguages = [
export type SupportedLocale = (typeof supportedLanguages)[number]['code']
// Detect browser language with improved logic
function detectLanguage(): SupportedLocale {
// 1. Check localStorage for saved preference
const stored = localStorage.getItem('language')
if (stored && supportedLanguages.some((l) => l.code === stored)) {
return stored as SupportedLocale
}
// 2. Check browser language list (navigator.languages is more comprehensive)
const languages = navigator.languages || [navigator.language]
for (const lang of languages) {
const normalizedLang = lang.toLowerCase()
// Check for Chinese variants (zh, zh-CN, zh-TW, zh-HK, etc.)
if (normalizedLang.startsWith('zh')) {
return 'zh-CN'
}
// Check for English variants
if (normalizedLang.startsWith('en')) {
return 'en-US'
}
}
// 3. Default to English
return 'en-US'
}
// Initialize language and set HTML lang attribute
function initializeLanguage(): SupportedLocale {
const lang = detectLanguage()
document.documentElement.setAttribute('lang', lang)

View File

@@ -109,7 +109,6 @@ export default {
settingsTip: '系统设置',
fullscreen: '全屏',
fullscreenTip: '切换全屏模式',
// Video Config
videoConfig: '视频配置',
streamSettings: '流设置',
deviceSettings: '设备配置',
@@ -141,7 +140,6 @@ export default {
notRecommended: '不推荐',
multiSourceCodecLocked: '{sources} 已启用,当前编码已锁定',
multiSourceVideoParamsWarning: '{sources} 已启用,修改视频设备和输入参数将导致流中断',
// HID Config
hidConfig: '鼠键配置',
mouseSettings: '鼠标设置',
hidDeviceSettings: 'HID 设备设置',
@@ -154,7 +152,6 @@ export default {
absolute: '绝对定位',
relative: '相对定位',
applying: '应用中...',
// Audio Config
audioConfig: '音频',
playbackControl: '播放控制',
volume: '音量',
@@ -218,19 +215,16 @@ export default {
title: '初始化设置',
welcome: '欢迎使用 One-KVM',
description: '请完成初始设置以开始使用',
// Step titles
stepAccount: '账号设置',
stepVideo: '视频设置',
stepAudioVideo: '音视频设置',
stepHid: '鼠键设置',
// Account
setUsername: '设置管理员用户名',
usernameHint: '用户名至少2个字符',
setPassword: '设置管理员密码',
passwordHint: '密码至少4个字符',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致',
// Video
videoDevice: '视频设备',
selectVideoDevice: '选择视频采集设备',
videoFormat: '画面格式',
@@ -242,13 +236,11 @@ export default {
noVideoDevices: '未检测到视频设备',
noSignalDetected: '未检测到 HDMI 信号,请连接 HDMI 线缆后刷新。',
refreshDevices: '刷新设备',
// Audio
audioDevice: '音频设备',
selectAudioDevice: '选择音频采集设备',
noAudio: '不使用音频',
noAudioDevices: '未检测到音频设备',
audioDeviceHelp: '选择用于捕获远程主机音频的设备。通常与视频采集卡在同一 USB 设备上。',
// HID
hidBackend: 'HID 后端',
selectHidBackend: '选择 HID 控制方式',
serialHid: '串口 HID',
@@ -261,31 +253,25 @@ export default {
selectUdc: '选择 UDC',
noUdcDevices: '未检测到 UDC 设备',
hidDisabledHint: '禁用 HID 后将无法控制远程主机的键盘和鼠标',
// Complete
complete: '完成设置',
setupFailed: '设置失败',
// Advanced encoder
advancedEncoder: '高级选项:编码器后端',
encoderHint: '默认的"自动"选项适用于大多数情况。仅在需要特定编码器后端时更改。',
autoRecommended: '自动(推荐)',
hardware: '硬件',
software: '软件',
// Progress
progress: '步骤 {current} / {total}',
// Help tooltips
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键盘。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
// Extensions
stepExtensions: '扩展设置',
extensionsDescription: '选择要自动启动的扩展服务',
ttydTitle: 'Web 终端 (ttyd)',
ttydDescription: '在浏览器中访问设备的命令行终端',
extensionsHint: '这些设置可以在设置页面中随时更改',
notInstalled: '未安装',
// Password strength
passwordStrength: '密码强度',
passwordWeak: '弱',
passwordMedium: '中',
@@ -349,7 +335,6 @@ export default {
uvc_capture_stall: '',
},
},
// WebRTC
webrtcConnected: 'WebRTC 已连接',
webrtcConnectedDesc: '正在使用 H.264 低延迟视频流',
webrtcFailed: 'WebRTC 连接失败',
@@ -362,29 +347,23 @@ export default {
webrtcPhaseSetRemote: '正在应用远端会话描述...',
webrtcPhaseApplyIce: '正在应用 ICE 候选...',
webrtcPhaseNegotiating: '正在协商安全连接...',
// Pointer Lock
pointerLocked: '鼠标已锁定',
pointerLockedDesc: '按 Escape 键释放鼠标',
pointerLockFailed: '鼠标锁定失败',
relativeModeHint: '相对鼠标模式',
relativeModeHintDesc: '点击视频区域以锁定鼠标,按 Escape 释放',
// Meta Key Hint
metaKeyHint: '检测到系统键',
metaKeyHintDesc: '请进入全屏模式以捕获 Win/Meta 键',
// Stream mode change
streamModeChanged: '视频模式已切换',
streamModeChangedDesc: '服务器已切换到 {mode} 模式',
// 设备监控
deviceLost: '视频设备丢失',
deviceLostDesc: '{device}: {reason}',
deviceRecovering: '视频设备恢复中',
deviceRecoveringDesc: '正在尝试恢复视频设备(第 {attempt} 次)',
deviceRecovered: '视频设备已恢复',
deviceRecoveredDesc: '视频设备已成功重连',
// 加载状态
pleaseWait: '请稍候...',
retryCount: '正在重试 (第 {count} 次)',
// 错误详情
errorDetails: '错误详情',
},
hid: {
@@ -396,7 +375,6 @@ export default {
pasteText: '粘贴文本',
absoluteMouse: '绝对定位',
relativeMouse: '相对定位',
// 设备监控
deviceLost: 'HID 设备丢失',
deviceLostDesc: '{backend}: {reason}',
reconnecting: 'HID 重连中',
@@ -423,7 +401,6 @@ export default {
},
},
audio: {
// 设备监控
deviceLost: '音频设备丢失',
deviceLostDesc: '{device}: {reason}',
reconnecting: '音频重连中',
@@ -467,7 +444,6 @@ export default {
uploadImageHint: '点击上传 ISO/IMG 镜像',
imageMounted: '镜像 {name} 已挂载',
imageUnmounted: '镜像已卸载',
// URL download
downloadFromUrl: '从 URL 下载',
downloadFromUrlDesc: '输入镜像文件的 URL 地址,支持 ISO/IMG 格式',
url: 'URL 地址',
@@ -478,16 +454,13 @@ export default {
downloadFailed: '下载失败',
largeFileWarning: '>2.2GB',
largeFileTooltip: '文件大于 2.2GB,请使用 Flash 模式挂载',
// 设备监控
error: 'MSD 错误',
errorDesc: '{reason}',
recovered: 'MSD 已恢复',
recoveredDesc: 'MSD 设备已恢复正常',
// 操作状态
operationInProgress: '操作进行中,请稍候',
driveConnected: '虚拟U盘已连接',
imageConnected: '镜像 {name} 已连接',
// 驱动器初始化
selectDriveSize: '选择虚拟驱动器大小',
selectedSize: '选定大小',
customSize: '自定义大小',
@@ -519,7 +492,6 @@ export default {
security: '安全',
about: '关于',
aboutDesc: '开放轻量的 IP-KVM 解决方案',
// Device info
deviceInfo: '设备信息',
deviceInfoDesc: '主机系统信息',
hostname: '主机名',
@@ -544,11 +516,9 @@ export default {
networkSettings: '网络设置',
msdSettings: 'MSD 设置',
atxSettings: 'ATX 设置',
// Network tab
httpSettings: 'HTTP 设置',
httpPort: 'HTTP 端口',
configureHttpPort: '配置 HTTP 服务器端口',
// Web server
webServer: '访问地址',
webServerDesc: '配置 HTTP/HTTPS 端口和监听地址,修改后需要重启生效',
httpsPort: 'HTTPS 端口',
@@ -568,20 +538,17 @@ export default {
bindAddressListEmpty: '请至少填写一个 IP 地址。',
httpsEnabled: '启用 HTTPS',
httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书)',
// Port config
portConfig: '端口与协议',
portConfigDesc: '服务一次只运行在一个端口上,由 HTTPS 开关决定使用哪个端口',
httpPortReserved: 'HTTP 端口(备用)',
httpsPortReserved: 'HTTPS 端口(备用)',
previewUrl: '访问地址预览',
// Listen address
listenAddress: '监听地址',
listenAddressDesc: '配置 Web 服务监听哪些网络接口',
bindModeAllDesc: '0.0.0.0 — 监听所有网络接口',
bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问',
bindModeCustomDesc: '指定一组 IP 地址',
effectiveAddresses: '监听地址预览',
// SSL certificate
sslCertificate: 'SSL 证书',
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需要重启生效',
sslCertCustom: '自定义证书',
@@ -627,13 +594,11 @@ export default {
updateMsgVerifying: '校验中SHA256',
updateMsgInstalling: '替换程序中',
updateMsgRestarting: '服务重启中',
// Auth
auth: '访问控制',
authSettings: '访问设置',
authSettingsDesc: '单用户访问与会话策略',
allowMultipleSessions: '允许多个 Web 会话',
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
// User management
userManagement: '用户管理',
userManagementDesc: '管理用户账号和权限',
addUser: '添加用户',
@@ -648,7 +613,6 @@ export default {
noUsers: '暂无用户',
create: '创建',
confirmDeleteUser: '确定要删除用户 "{name}" 吗?',
// MSD/ATX status
msdStatus: 'MSD 状态',
atxStatus: 'ATX 状态',
available: '可用',
@@ -664,7 +628,6 @@ export default {
disabled: '已禁用',
msdDesc: '虚拟存储设备允许您将 ISO 镜像和虚拟驱动器挂载到目标机器。请在主页面的 MSD 面板中管理镜像。',
atxDesc: 'ATX 电源控制允许您远程开关机和重启目标机器。请在主页面的 ATX 面板中控制电源。',
// ATX configuration
atxSettingsDesc: '配置 ATX 电源控制硬件绑定',
atxEnable: '启用 ATX 控制',
atxEnableDesc: '启用后可以远程控制电源和重启按钮',
@@ -692,16 +655,13 @@ export default {
atxLedPin: 'GPIO 引脚',
atxLedInverted: '反转逻辑',
atxLedInvertedDesc: 'LED 亮起时 GPIO 为低电平',
// WOL configuration
atxWolSettings: '网络唤醒设置',
atxWolSettingsDesc: '配置 Wake-on-LAN 魔术包发送选项',
atxWolInterface: '网络接口',
atxWolInterfacePlaceholder: '例如: eth0, enp0s3',
atxWolInterfaceHint: '指定发送 WOL 包的网络接口,留空则使用系统默认路由',
// Basic tab descriptions
themeDesc: '选择您喜欢的颜色方案',
languageDesc: '选择您的首选语言',
// Video tab
videoSettings: '视频设置',
videoSettingsDesc: '配置视频采集设备',
videoDevice: '视频设备',
@@ -718,7 +678,6 @@ export default {
software: '软件',
supportedFormats: '支持的格式',
encoderHint: '硬件编码器性能更好CPU 占用更低。软件编码器兼容性更好,但需要更多 CPU 资源。',
// HID tab
hidSettings: 'HID 设置',
hidSettingsDesc: '配置键盘和鼠标控制',
hidBackend: 'HID 后端',
@@ -747,7 +706,6 @@ export default {
otgProfileWarning: '修改 HID 功能将导致 USB 设备重新连接',
otgLowEndpointHint: '检测到低端点 UDC将自动禁用多媒体键盘。',
otgFunctionMinWarning: '请至少启用一个 HID 功能后再保存',
// OTG Descriptor
otgDescriptor: 'USB 设备描述符',
otgDescriptorDesc: '配置 USB 设备标识信息',
vendorId: '厂商 ID (VID)',
@@ -861,7 +819,6 @@ export default {
resetConfirmDesc: '将通过 authorized 属性复位 USB 设备「{device}」,该设备上的所有连接将短暂中断。确定继续?',
resetAction: '确认复位',
},
// WebRTC / ICE
webrtcSettings: 'WebRTC 设置',
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
publicIceServersHint: '留空将使用 Google 公共 STUN 服务器TURN 服务器需自行配置',
@@ -930,7 +887,6 @@ export default {
notConnected: '未连接',
connected: '已连接',
image: '镜像',
// MSD 状态详情
msdStatus: '状态',
msdStandby: '空闲',
msdImageMode: '镜像模式',
@@ -940,7 +896,6 @@ export default {
msdNoImage: '无',
},
extensions: {
// Common
available: '可用',
unavailable: '不可用',
running: '运行中',
@@ -957,7 +912,6 @@ export default {
title: '远程访问',
desc: 'GOSTC 内网穿透与 Easytier 组网',
},
// ttyd
ttyd: {
title: 'Ttyd 网页终端',
desc: '通过 ttyd 提供网页终端访问',
@@ -966,7 +920,6 @@ export default {
port: '端口',
shell: 'Shell',
},
// gostc
gostc: {
title: 'GOSTC 内网穿透',
desc: '通过 GOSTC 实现内网穿透',
@@ -975,7 +928,6 @@ export default {
key: '客户端密钥',
tls: '启用 TLS',
},
// easytier
easytier: {
title: 'Easytier 组网',
desc: '通过 EasyTier 实现 P2P VPN 组网',
@@ -986,7 +938,6 @@ export default {
virtualIp: '虚拟 IP',
virtualIpHint: '留空则自动分配,手动指定需包含网段(如 10.0.0.1/24',
},
// rustdesk
rustdesk: {
title: 'RustDesk 远程',
desc: '使用 RustDesk 客户端进行远程访问',
@@ -1077,31 +1028,24 @@ export default {
p2p: 'P2P 直连',
relay: 'TURN 中继',
},
// 帮助提示文本
help: {
// MSD 相关
flashMode: 'Flash 模式将镜像作为 U 盘挂载,支持大多数 BIOS 启动',
cdromMode: 'CDROM 模式将镜像作为光驱挂载,适用于需要光盘启动的系统',
readOnlyMode: '只读模式更安全,目标系统无法修改镜像内容',
readWriteMode: '读写模式允许目标系统写入数据,适用于需要保存配置的场景',
driveSize: '虚拟驱动器大小。较大的驱动器支持存放更多文件,但初始化时间更长',
// 视频相关
mjpegMode: 'MJPEG 模式兼容性最好,适用于所有浏览器,但延迟较高',
webrtcMode: 'WebRTC 模式延迟更低,但需要浏览器支持相应编解码器',
videoBitratePreset: '速度优先:最低延迟,适合网络较差的场景;均衡:画质和延迟平衡;质量优先:最佳画质,需要较好的网络带宽',
encoderBackend: '硬件编码器性能更好功耗更低,软件编码器兼容性更好',
// HID 相关
absoluteMode: '绝对定位模式直接映射鼠标坐标,适用于大多数场景',
relativeMode: '相对定位模式发送鼠标移动增量,适用于游戏或特殊软件',
mouseThrottle: '发送间隔控制鼠标事件的发送频率,较大的值可减少网络负载',
hidBackend: 'OTG 后端需要硬件支持 USB OTGCH9329 是串口 HID 芯片方案',
// ATX 相关
atxActiveLevel: '活跃电平取决于您的硬件接线方式。高电平表示触发时输出高电压,低电平相反',
wolInterface: '用于发送 Wake-on-LAN 魔术包的网络接口名称,如 eth0 或 br0',
// 网络相关
stunServer: 'STUN 服务器用于 NAT 穿透,帮助建立 P2P 连接。留空使用公共服务器',
turnServer: 'TURN 服务器在 P2P 连接失败时提供中继。需要更多带宽但连接更可靠',
// 音频相关
audioQuality: '更高的质量意味着更好的音频效果,但需要更多的网络带宽',
},
}

View File

@@ -1,5 +1,3 @@
// Character to HID usage mapping for text paste functionality.
// The table follows US QWERTY layout semantics.
import { type CanonicalKey } from '@/types/generated'
import { keys } from '@/lib/keyboardMappings'
@@ -10,7 +8,6 @@ export interface CharKeyMapping {
}
const charToKeyMap: Record<string, CharKeyMapping> = {
// Lowercase letters
a: { key: keys.KeyA, shift: false },
b: { key: keys.KeyB, shift: false },
c: { key: keys.KeyC, shift: false },
@@ -38,7 +35,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
y: { key: keys.KeyY, shift: false },
z: { key: keys.KeyZ, shift: false },
// Uppercase letters
A: { key: keys.KeyA, shift: true },
B: { key: keys.KeyB, shift: true },
C: { key: keys.KeyC, shift: true },
@@ -66,7 +62,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
Y: { key: keys.KeyY, shift: true },
Z: { key: keys.KeyZ, shift: true },
// Number row
'0': { key: keys.Digit0, shift: false },
'1': { key: keys.Digit1, shift: false },
'2': { key: keys.Digit2, shift: false },
@@ -78,7 +73,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
'8': { key: keys.Digit8, shift: false },
'9': { key: keys.Digit9, shift: false },
// Shifted number row symbols
')': { key: keys.Digit0, shift: true },
'!': { key: keys.Digit1, shift: true },
'@': { key: keys.Digit2, shift: true },
@@ -90,7 +84,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
'*': { key: keys.Digit8, shift: true },
'(': { key: keys.Digit9, shift: true },
// Punctuation and symbols
'-': { key: keys.Minus, shift: false },
'=': { key: keys.Equal, shift: false },
'[': { key: keys.BracketLeft, shift: false },
@@ -103,7 +96,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
'.': { key: keys.Period, shift: false },
'/': { key: keys.Slash, shift: false },
// Shifted punctuation and symbols
_: { key: keys.Minus, shift: true },
'+': { key: keys.Equal, shift: true },
'{': { key: keys.BracketLeft, shift: true },
@@ -116,7 +108,6 @@ const charToKeyMap: Record<string, CharKeyMapping> = {
'>': { key: keys.Period, shift: true },
'?': { key: keys.Slash, shift: true },
// Whitespace and control
' ': { key: keys.Space, shift: false },
'\t': { key: keys.Tab, shift: false },
'\n': { key: keys.Enter, shift: false },

View File

@@ -1,4 +1,3 @@
// Virtual keyboard layout data shared by the on-screen keyboard.
export type KeyboardOsType = 'windows' | 'mac' | 'android'

View File

@@ -122,7 +122,6 @@ export const keys = {
export type KeyName = keyof typeof keys
// Consumer Control Usage codes (for multimedia keys)
export const consumerKeys = {
PlayPause: 0x00cd,
Stop: 0x00b7,

View File

@@ -15,8 +15,6 @@ export function generateUUID(): string {
return crypto.randomUUID()
}
// Fallback: generate UUID v4 manually
// Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8

View File

@@ -42,17 +42,14 @@ function t(key: string, params?: Record<string, unknown>): string {
return String(i18n.global.t(key, params as any))
}
// Navigation guard
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore()
// Prevent access to setup after initialization
const shouldCheckSetup = to.name === 'Setup' || !authStore.initialized
if (shouldCheckSetup) {
try {
await authStore.checkSetupStatus()
} catch {
// Continue anyway
}
}
@@ -65,20 +62,17 @@ router.beforeEach(async (to, _from, next) => {
try {
await authStore.checkAuth()
} catch {
// Not authenticated
}
}
return next({ name: authStore.isAuthenticated ? 'Console' : 'Login' })
}
// Check authentication for protected routes
if (to.meta.requiresAuth !== false) {
if (!authStore.isAuthenticated) {
try {
await authStore.checkAuth()
} catch (e) {
// Not authenticated
if (e instanceof ApiError && e.status === 401 && !sessionExpiredNotified) {
const normalized = e.message.toLowerCase()
const isLoggedInElsewhere = normalized.includes('logged in elsewhere')

View File

@@ -311,43 +311,26 @@ export interface WebConfig {
ssl_key_path?: string;
}
/** ttyd configuration (Web Terminal) */
export interface TtydConfig {
/** Enable auto-start */
enabled: boolean;
/** Port to listen on */
port: number;
/** Shell to execute */
shell: string;
}
/** gostc configuration (NAT traversal based on FRP) */
export interface GostcConfig {
/** Enable auto-start */
enabled: boolean;
/** Server address (hostname or IP) */
addr: string;
/** Client key from GOSTC management panel */
key: string;
/** Enable TLS */
tls: boolean;
}
/** EasyTier configuration (P2P VPN) */
export interface EasytierConfig {
/** Enable auto-start */
enabled: boolean;
/** Network name */
network_name: string;
/** Network secret/password */
network_secret: string;
/** Peer node URLs */
peer_urls: string[];
/** Virtual IP address (optional, auto-assigned if not set) */
virtual_ip?: string;
}
/** Combined extensions configuration */
export interface ExtensionsConfig {
ttyd: TtydConfig;
gostc: GostcConfig;
@@ -483,78 +466,50 @@ export interface EasytierConfigUpdate {
virtual_ip?: string;
}
/** Extension running status */
export type ExtensionStatus =
/** Binary not found at expected path */
| { state: "unavailable", data?: undefined }
/** Extension is stopped */
| { state: "stopped", data?: undefined }
/** Extension is running */
| { state: "running", data: {
/** Process ID */
pid: number;
}}
/** Extension failed to start */
| { state: "failed", data: {
/** Error message */
error: string;
}};
/** easytier extension info */
export interface EasytierInfo {
/** Whether binary exists */
available: boolean;
/** Current status */
status: ExtensionStatus;
/** Configuration */
config: EasytierConfig;
}
/** Extension info with status and config */
export interface ExtensionInfo {
/** Whether binary exists */
available: boolean;
/** Current status */
status: ExtensionStatus;
}
/** Extension identifier (fixed set of supported extensions) */
export enum ExtensionId {
/** Web terminal (ttyd) */
Ttyd = "ttyd",
/** NAT traversal client (gostc) */
Gostc = "gostc",
/** P2P VPN (easytier) */
Easytier = "easytier",
}
/** Extension logs response */
export interface ExtensionLogs {
id: ExtensionId;
logs: string[];
}
/** ttyd extension info */
export interface TtydInfo {
/** Whether binary exists */
available: boolean;
/** Current status */
status: ExtensionStatus;
/** Configuration */
config: TtydConfig;
}
/** gostc extension info */
export interface GostcInfo {
/** Whether binary exists */
available: boolean;
/** Current status */
status: ExtensionStatus;
/** Configuration */
config: GostcConfig;
}
/** All extensions status response */
export interface ExtensionsStatus {
ttyd: TtydInfo;
gostc: GostcInfo;
@@ -677,7 +632,6 @@ export interface StreamConfigUpdate {
/** Update ttyd config */
export interface TtydConfigUpdate {
enabled?: boolean;
port?: number;
shell?: string;
}

View File

@@ -1,6 +1,3 @@
// Shared WebSocket types and utilities
// Used by useWebSocket, useHidWebSocket, and useAudioPlayer
import { ref, type Ref } from 'vue'
/** WebSocket connection state */

View File

@@ -20,7 +20,6 @@ import { generateUUID } from '@/lib/utils'
import { formatFpsValue } from '@/lib/fps'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
// Components
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
import ActionBar from '@/components/ActionBar.vue'
import InfoBar from '@/components/InfoBar.vue'
@@ -84,10 +83,8 @@ const consoleEvents = useConsoleEvents({
onDeviceInfo: handleDeviceInfo,
})
// Video mode state
const videoMode = ref<VideoMode>('mjpeg')
// Video state
const videoRef = ref<HTMLImageElement | null>(null)
const webrtcVideoRef = ref<HTMLVideoElement | null>(null)
const videoContainerRef = ref<HTMLDivElement | null>(null)
@@ -104,7 +101,6 @@ const streamSignalState = ref<StreamSignalState>('ok')
const streamSignalReason = ref<string | null>(null)
const streamNextRetryMs = ref<number | null>(null)
// Video aspect ratio (dynamically updated from actual video dimensions)
// Using string format "width/height" to let browser handle the ratio calculation
const videoAspectRatio = ref<string | null>(null)
@@ -123,7 +119,6 @@ const clientsStats = ref<Record<string, ClientStat>>({})
// This allows us to identify our own stats in the clients_stat map
const myClientId = generateUUID()
// HID state
const mouseMode = ref<'absolute' | 'relative'>('absolute')
const pressedKeys = ref<CanonicalKey[]>([])
const keyboardLed = computed(() => ({
@@ -140,7 +135,6 @@ const isPointerLocked = ref(false) // Track pointer lock state
/** Local overlay crosshair position (px, relative to video container); HID uses mousePosition separately */
const localCrosshairPos = ref<{ x: number; y: number } | null>(null)
// Mouse move throttling (60 Hz = ~16.67ms interval)
const DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS = 16
let mouseMoveSendIntervalMs = DEFAULT_MOUSE_MOVE_SEND_INTERVAL_MS
let mouseFlushTimer: ReturnType<typeof setTimeout> | null = null
@@ -148,7 +142,6 @@ let lastMouseMoveSendTime = 0
let pendingMouseMove: { type: 'move' | 'move_abs'; x: number; y: number } | null = null
let accumulatedDelta = { x: 0, y: 0 } // For relative mode: accumulate deltas between sends
// Cursor visibility (from localStorage, updated via storage event)
const cursorVisible = ref(localStorage.getItem('hidShowCursor') !== 'false')
let interactionListenersBound = false
const isConsoleActive = ref(false)
@@ -162,7 +155,6 @@ function syncMouseModeFromConfig() {
}
}
// Virtual keyboard state
const virtualKeyboardVisible = ref(false)
const virtualKeyboardAttached = ref(true)
const statsSheetOpen = ref(false)
@@ -173,18 +165,15 @@ const virtualKeyboardConsumerEnabled = computed(() => {
return hid.otg_functions?.consumer !== false
})
// Change password dialog state
const changePasswordDialogOpen = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const changingPassword = ref(false)
// ttyd (web terminal) state
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
const showTerminalDialog = ref(false)
// Theme
const isDark = ref(document.documentElement.classList.contains('dark'))
// Status computed (Device status removed - now only Video, Audio, HID, MSD)
@@ -207,9 +196,7 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
return 'disconnected'
})
// Convert resolution to short format (e.g., 720p, 1080p, 2K, 4K)
function getResolutionShortName(width: number, height: number): string {
// Common resolution mappings based on height
if (height === 2160 || (height === 2160 && width === 4096)) return '4K'
if (height === 1440) return '2K'
if (height === 1080) return '1080p'
@@ -218,11 +205,9 @@ function getResolutionShortName(width: number, height: number): string {
if (height === 600) return '600p'
if (height === 1024 && width === 1280) return '1024p'
if (height === 960) return '960p'
// Fallback: use height + 'p'
return `${height}p`
}
// Quick info for status card trigger
const videoQuickInfo = computed(() => {
const stream = systemStore.stream
if (!stream?.resolution) return ''
@@ -235,12 +220,10 @@ const videoDetails = computed<StatusDetail[]>(() => {
if (!stream) return []
const receivedFps = backendFps.value
// Input (capture) format → output (delivery) mode
const inputFmt = stream.format || 'MJPEG'
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt}${outputFmt}`
// Target / actual FPS combined
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
@@ -269,7 +252,6 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
}
// MJPEG mode or WebRTC fallback: check WebSocket HID status
// If HID WebSocket has network error, show connecting (yellow)
if (hidWs.networkError.value) return 'connecting'
// If HID WebSocket is not connected (disconnected without error), show disconnected
@@ -278,17 +260,14 @@ const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'
// If HID backend is unavailable (business error), show disconnected (gray)
if (hidWs.hidUnavailable.value) return 'disconnected'
// Normal status based on system state
if (hid?.available && hid.online) return 'connected'
if (hid?.available && hid.initialized) return 'connecting'
return 'disconnected'
})
// Quick info for HID status card trigger
const hidQuickInfo = computed(() => {
const hid = systemStore.hid
if (!hid?.available) return ''
// Show current mode, not hardware capability
return mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative')
})
@@ -387,7 +366,6 @@ const hidDetails = computed<StatusDetail[]>(() => {
}
}
// Channel (merged with availability / connection state)
let channelValue: string
let channelStatus: StatusDetail['status']
if (videoMode.value !== 'mjpeg') {
@@ -422,7 +400,6 @@ const hidDetails = computed<StatusDetail[]>(() => {
return details
})
// Audio status computed
const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const audio = systemStore.audio
if (!audio?.available) return 'disconnected'
@@ -431,7 +408,6 @@ const audioStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
return 'disconnected'
})
// Helper function to translate audio quality
function translateAudioQuality(quality: string | undefined): string {
if (!quality) return t('common.unknown')
const qualityLower = quality.toLowerCase()
@@ -463,7 +439,6 @@ const audioDetails = computed<StatusDetail[]>(() => {
]
})
// MSD status computed
const msdStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
const msd = systemStore.msd
if (!msd?.available) return 'disconnected'
@@ -568,7 +543,6 @@ const hidHoverAlign = computed<'start' | 'end'>(() => {
return showMsdStatusCard.value ? 'start' : 'end'
})
// Video handling
let retryTimeoutId: number | null = null
let retryCount = 0
let gracePeriodTimeoutId: number | null = null
@@ -618,10 +592,8 @@ async function captureFrameOverlay() {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
// Use JPEG to keep memory reasonable
frameOverlayUrl.value = canvas.toDataURL('image/jpeg', 0.7)
} catch {
// Best-effort only
}
}
@@ -729,7 +701,6 @@ function handleVideoLoad() {
gracePeriodTimeoutId = null
}
// Reset all error states
videoLoading.value = false
videoError.value = false
videoErrorMessage.value = ''
@@ -738,7 +709,6 @@ function handleVideoLoad() {
consecutiveErrors = 0
clearFrameOverlay()
// Auto-focus video container for immediate keyboard input
const container = videoContainerRef.value
if (container && typeof container.focus === 'function') {
container.focus()
@@ -787,13 +757,11 @@ function handleVideoError() {
return
}
// Clear any pending retries to avoid duplicate attempts
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
}
// Show loading state immediately
videoLoading.value = true
mjpegFrameReceived.value = false
@@ -821,7 +789,6 @@ function handleStreamDeviceLost(data: { device: string; reason: string }) {
}
function scheduleWebRTCRecovery() {
// Clear any previous timer
if (webrtcRecoveryTimerId !== null) {
clearTimeout(webrtcRecoveryTimerId)
webrtcRecoveryTimerId = null
@@ -862,7 +829,6 @@ function scheduleWebRTCRecovery() {
videoErrorMessage.value = ''
webrtcRecoveryAttempts = 0
} else {
// Retry
scheduleWebRTCRecovery()
}
} catch {
@@ -883,21 +849,17 @@ function handleStreamRecovered(_data: { device: string }) {
// Cancel any pending recovery timer backend is back
cancelWebRTCRecovery()
// Reset video error state
videoError.value = false
videoErrorMessage.value = ''
// Refresh video stream
refreshVideo()
}
async function handleAudioStateChanged(data: { streaming: boolean; device: string | null }) {
if (!data.streaming) {
// Audio stopped, disconnect
unifiedAudio.disconnect()
return
}
// Audio started streaming
if (videoMode.value !== 'mjpeg' && webrtc.isConnected.value) {
// WebRTC mode: check if we have an audio track
if (!webrtc.audioTrack.value) {
@@ -907,9 +869,7 @@ async function handleAudioStateChanged(data: { streaming: boolean; device: strin
await new Promise(resolve => setTimeout(resolve, 300))
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 {
// We have audio track, ensure it's in MediaStream
const currentStream = webrtcVideoRef.value?.srcObject as MediaStream | null
if (currentStream && currentStream.getAudioTracks().length === 0) {
currentStream.addTrack(webrtc.audioTrack.value)
@@ -934,7 +894,6 @@ function handleStreamConfigChanging(data: any) {
gracePeriodTimeoutId = null
}
// Reset all counters and states
videoRestarting.value = true
pendingWebRTCReadyGate = true
videoLoading.value = true
@@ -952,7 +911,6 @@ function handleStreamConfigChanging(data: any) {
}
async function handleStreamConfigApplied(data: any) {
// Reset consecutive error counter for new config
consecutiveErrors = 0
// Start grace period to ignore transient errors
@@ -961,7 +919,6 @@ async function handleStreamConfigApplied(data: any) {
consecutiveErrors = 0 // Also reset when grace period ends
}, GRACE_PERIOD)
// Refresh video based on current mode
videoRestarting.value = true
// 如果正在进行模式切换不需要在这里处理WebRTCReady 事件会处理)
@@ -1003,7 +960,6 @@ function handleStreamModeReady(data: { transition_id: string; mode: string }) {
}
function handleStreamModeSwitching(data: { transition_id: string; to_mode: string; from_mode: string }) {
// External mode switches: keep UI responsive and avoid black flash
if (!isModeSwitching.value) {
videoRestarting.value = true
videoLoading.value = true
@@ -1230,13 +1186,11 @@ function handleDeviceInfo(data: any) {
})
}
// Skip mode sync if video config is being changed
// This prevents false-positive mode changes during config switching
if (data.video?.config_changing) {
return
}
// Sync video mode from server's stream_mode
if (data.video?.stream_mode) {
const serverMode = normalizeServerMode(data.video.stream_mode)
if (!serverMode) return
@@ -1256,7 +1210,6 @@ function handleDeviceInfo(data: any) {
}
}
// Handle stream mode change event from server (WebSocket broadcast)
function handleStreamModeChanged(data: { mode: string; previous_mode: string }) {
const newMode = normalizeServerMode(data.mode)
if (!newMode) return
@@ -1267,7 +1220,6 @@ function handleStreamModeChanged(data: { mode: string; previous_mode: string })
return
}
// Show toast notification only if this is an external mode change
toast.info(t('console.streamModeChanged'), {
description: t('console.streamModeChangedDesc', { mode: data.mode.toUpperCase() }),
duration: 5000,
@@ -1300,7 +1252,6 @@ function refreshVideo() {
mjpegTimestamp.value = Date.now()
// For MJPEG streams, the 'load' event fires when first frame arrives
// But on reconnection it may not fire again, so use a timeout as fallback
setTimeout(() => {
isRefreshingVideo = false
// Clear loading state after timeout - if stream failed, error handler will show error
@@ -1508,7 +1459,6 @@ async function switchToMJPEG() {
}
} catch (e) {
console.error('Failed to switch to MJPEG mode:', e)
// Continue anyway - the mode might already be correct
}
// Step 2: Disconnect WebRTC if connected or session still exists
@@ -1542,7 +1492,6 @@ function syncToServerMode(mode: VideoMode) {
}
}
// Handle video mode change
async function handleVideoModeChange(mode: VideoMode) {
// 防止重复切换和竞态条件
if (mode === videoMode.value) return
@@ -1592,10 +1541,8 @@ watch(() => webrtc.videoTrack.value, async (track) => {
// Watch for WebRTC audio track changes - update MediaStream when audio arrives
watch(() => webrtc.audioTrack.value, async (track) => {
if (track && webrtcVideoRef.value && videoMode.value !== 'mjpeg') {
// Audio track arrived, update the MediaStream to include it
const currentStream = webrtcVideoRef.value.srcObject as MediaStream | null
if (currentStream && currentStream.getAudioTracks().length === 0) {
// Add audio track to existing stream
currentStream.addTrack(track)
}
}
@@ -1607,7 +1554,6 @@ watch(webrtcVideoRef, (el) => {
}, { immediate: true })
// Watch for WebRTC stats to update FPS display
// Watch the ref directly with deep: true to detect property changes
watch(webrtc.stats, (stats) => {
if (videoMode.value !== 'mjpeg' && stats.framesPerSecond > 0) {
backendFps.value = Math.round(stats.framesPerSecond)
@@ -1626,7 +1572,6 @@ let webrtcReconnectFailures = 0
watch(() => webrtc.state.value, (newState, oldState) => {
console.log('[WebRTC] State changed:', oldState, '->', newState)
// Clear any pending reconnect
if (webrtcReconnectTimeout) {
clearTimeout(webrtcReconnectTimeout)
webrtcReconnectTimeout = null
@@ -1644,7 +1589,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
})
}
} else if (newState === 'disconnected' || newState === 'failed') {
// Don't immediately set offline - wait for potential reconnect
// The device_info event will eventually sync the correct state
}
}
@@ -1653,7 +1597,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
return
}
// 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') {
@@ -1676,8 +1619,6 @@ watch(() => webrtc.state.value, (newState, oldState) => {
}
// Handle direct 'failed' state (ICE or DTLS failure)
// Allow one automatic retry before marking as failed, consistent with
// the disconnected->reconnect path that allows 2 failures.
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
webrtcReconnectFailures += 1
if (webrtcReconnectFailures >= 2) {
@@ -1706,20 +1647,17 @@ async function toggleFullscreen() {
}
}
// Theme toggle
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
}
// Logout
async function logout() {
await authStore.logout()
router.push('/login')
}
// Change password function
async function handleChangePassword() {
if (!newPassword.value || !confirmPassword.value) {
toast.error(t('auth.passwordRequired'))
@@ -1741,20 +1679,17 @@ async function handleChangePassword() {
await authApi.changePassword(currentPassword.value, newPassword.value)
toast.success(t('auth.passwordChanged'))
// Reset form and close dialog
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
changePasswordDialogOpen.value = false
} catch (e) {
// Error toast is shown by API layer
console.info('[ChangePassword] Failed:', e)
} finally {
changingPassword.value = false
}
}
// ttyd (web terminal) functions
function openTerminal() {
if (!ttydStatus.value?.running) return
showTerminalDialog.value = true
@@ -1764,13 +1699,11 @@ function openTerminalInNewTab() {
window.open('/api/terminal/', '_blank')
}
// ATX actions
async function handlePowerShort() {
try {
await atxApi.power('short')
await systemStore.fetchAtxState()
} catch {
// ATX action failed
}
}
@@ -1779,7 +1712,6 @@ async function handlePowerLong() {
await atxApi.power('long')
await systemStore.fetchAtxState()
} catch {
// ATX action failed
}
}
@@ -1788,7 +1720,6 @@ async function handleReset() {
await atxApi.power('reset')
await systemStore.fetchAtxState()
} catch {
// ATX action failed
}
}
@@ -1801,14 +1732,10 @@ async function handleWol(mac: string) {
}
}
// HID error handling - silently handle all HID errors
function handleHidError(_error: any, _operation: string) {
// All HID errors are silently ignored
}
// HID channel selection: use WebRTC DataChannel when available, fallback to WebSocket
function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, 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',
@@ -1817,14 +1744,11 @@ function sendKeyboardEvent(type: 'down' | 'up', key: CanonicalKey, modifier?: nu
}
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, 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 }) {
// In WebRTC mode with DataChannel ready, use DataChannel for lower latency
if (videoMode.value !== 'mjpeg' && webrtc.dataChannelReady.value) {
const event: HidMouseEvent = {
type: data.type === 'move_abs' ? 'moveabs' : data.type,
@@ -1835,50 +1759,37 @@ function sendMouseEvent(data: { type: 'move' | 'move_abs' | 'down' | 'up' | 'scr
}
const sent = webrtc.sendMouse(event)
if (sent) return
// Fallback to WebSocket if DataChannel send failed
}
// Use WebSocket as fallback or for MJPEG mode
hidApi.mouse(data).catch(err => handleHidError(err, `mouse ${data.type}`))
}
// Check if a key should be blocked (prevented from default behavior)
function shouldBlockKey(e: KeyboardEvent): boolean {
// In fullscreen mode, block all keys for maximum capture
if (isFullscreen.value) {
return true
}
// Don't block critical browser shortcuts in non-fullscreen mode
const key = e.key.toUpperCase()
// Don't block Ctrl+W (close tab), Ctrl+T (new tab), Ctrl+N (new window)
if (e.ctrlKey && ['W', 'T', 'N'].includes(key)) return false
// Don't block F11 (browser fullscreen toggle)
if (key === 'F11') return false
// Don't block Alt+Tab (already can't capture it anyway)
if (e.altKey && key === 'TAB') return false
// Block everything else
return true
}
// Keyboard/Mouse event handling
function handleKeyDown(e: KeyboardEvent) {
const container = videoContainerRef.value
if (!container) return
// Check focus in non-fullscreen mode
if (!isFullscreen.value && !container.contains(document.activeElement)) return
// Try to block the key if appropriate
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
}
// Show hint for Meta key in non-fullscreen mode
if (!isFullscreen.value && (e.metaKey || e.key === 'Meta')) {
toast.info(t('console.metaKeyHint'), {
description: t('console.metaKeyHintDesc'),
@@ -1905,10 +1816,8 @@ function handleKeyUp(e: KeyboardEvent) {
const container = videoContainerRef.value
if (!container) return
// Check focus in non-fullscreen mode
if (!isFullscreen.value && !container.contains(document.activeElement)) return
// Try to block the key if appropriate
if (shouldBlockKey(e)) {
e.preventDefault()
e.stopPropagation()
@@ -2049,20 +1958,16 @@ function handleMouseMove(e: MouseEvent) {
pendingMouseMove = { type: 'move_abs', x, y }
requestMouseMoveFlush()
} else {
// Relative mode: use movementX/Y when pointer is locked
if (isPointerLocked.value) {
const dx = e.movementX
const dy = e.movementY
// Only accumulate if there's actual movement
if (dx !== 0 || dy !== 0) {
// Accumulate deltas for throttled sending
accumulatedDelta.x += dx
accumulatedDelta.y += dy
requestMouseMoveFlush()
}
// Update display position (accumulated delta for display only)
mousePosition.value = {
x: mousePosition.value.x + dx,
y: mousePosition.value.y + dy,
@@ -2086,13 +1991,11 @@ function flushMouseMoveOnce(): boolean {
if (accumulatedDelta.x === 0 && accumulatedDelta.y === 0) return false
// Clamp to i8 range (-127 to 127)
const clampedDx = Math.max(-127, Math.min(127, accumulatedDelta.x))
const clampedDy = Math.max(-127, Math.min(127, accumulatedDelta.y))
sendMouseEvent({ type: 'move', x: clampedDx, y: clampedDy })
// Subtract sent amount (keep remainder for next send if clamped)
accumulatedDelta.x -= clampedDx
accumulatedDelta.y -= clampedDy
return true
@@ -2147,13 +2050,11 @@ function requestMouseMoveFlush() {
scheduleMouseMoveFlush()
}
// Track pressed mouse button for window-level mouseup handling
const pressedMouseButton = ref<'left' | 'right' | 'middle' | null>(null)
function handleMouseDown(e: MouseEvent) {
e.preventDefault()
// Auto-focus the video container to enable keyboard input
const container = videoContainerRef.value
if (container && document.activeElement !== container) {
if (typeof container.focus === 'function') {
@@ -2161,7 +2062,6 @@ function handleMouseDown(e: MouseEvent) {
}
}
// In relative mode, request pointer lock on first click
if (mouseMode.value === 'relative' && !isPointerLocked.value) {
requestPointerLock()
return
@@ -2186,7 +2086,6 @@ function handleMouseUp(e: MouseEvent) {
handleMouseUpInternal(e.button)
}
// Window-level mouseup handler (catches releases outside the container)
function handleWindowMouseUp(e: MouseEvent) {
if (pressedMouseButton.value !== null) {
handleMouseUpInternal(e.button)
@@ -2201,7 +2100,6 @@ function handleMouseUpInternal(rawButton: number) {
const button = rawButton === 0 ? 'left' : rawButton === 2 ? 'right' : 'middle'
// Only send if this button was actually pressed
if (pressedMouseButton.value !== button) {
return
}
@@ -2220,7 +2118,6 @@ function handleContextMenu(e: MouseEvent) {
e.preventDefault()
}
// Pointer Lock API for relative mouse mode
function requestPointerLock() {
const container = videoContainerRef.value
if (!container) return
@@ -2243,7 +2140,6 @@ function handlePointerLockChange() {
isPointerLocked.value = document.pointerLockElement === container
if (isPointerLocked.value) {
// Reset mouse position display when locked
mousePosition.value = { x: 0, y: 0 }
if (cursorVisible.value && container) {
const r = container.getBoundingClientRect()
@@ -2269,7 +2165,6 @@ function handleFullscreenChange() {
function handleBlur() {
pressedKeys.value = []
activeModifierMask.value = 0
// Release any pressed mouse button when window loses focus
if (pressedMouseButton.value !== null) {
const button = pressedMouseButton.value
pressedMouseButton.value = null
@@ -2277,7 +2172,6 @@ function handleBlur() {
}
}
// Handle cursor visibility change from HidConfigPopover
function handleCursorVisibilityChange(e: Event) {
const customEvent = e as CustomEvent<{ visible: boolean }>
cursorVisible.value = customEvent.detail.visible
@@ -2365,7 +2259,6 @@ async function activateConsoleView() {
void systemStore.fetchAllStates()
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
// Ensure HID WebSocket is connected when console becomes active
if (!hidWs.connected.value) {
hidWs.connect().catch(() => {})
}
@@ -2395,28 +2288,22 @@ function deactivateConsoleView() {
unregisterInteractionListeners()
}
// ActionBar handlers
// (MSD and Settings are now handled by ActionBar component directly)
function handleToggleVirtualKeyboard() {
virtualKeyboardVisible.value = !virtualKeyboardVisible.value
}
// Virtual keyboard key event handlers
function handleVirtualKeyDown(key: CanonicalKey) {
// Add to pressedKeys for InfoBar display
if (!pressedKeys.value.includes(key)) {
pressedKeys.value = [...pressedKeys.value, key]
}
}
function handleVirtualKeyUp(key: CanonicalKey) {
// Remove from pressedKeys
pressedKeys.value = pressedKeys.value.filter(k => k !== key)
}
function handleToggleMouseMode() {
// Exit pointer lock when switching away from relative mode
if (mouseMode.value === 'relative' && isPointerLocked.value) {
exitPointerLock()
}
@@ -2424,7 +2311,6 @@ function handleToggleMouseMode() {
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
pendingMouseMove = null
accumulatedDelta = { x: 0, y: 0 }
// Reset position when switching modes
lastMousePosition.value = { x: 0, y: 0 }
mousePosition.value = { x: 0, y: 0 }
@@ -2436,16 +2322,13 @@ function handleToggleMouseMode() {
}
}
// Lifecycle
onMounted(async () => {
// 1. 先订阅 WebSocket 事件,再连接(内部会 connect
consoleEvents.subscribe()
// 3. Watch WebSocket connection states and sync to store
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
systemStore.updateWsConnection(connected, netError)
// Auto-refresh video when network recovers (wsNetworkError: true -> false)
if (prevNetError === true && netError === false && connected === true) {
refreshVideo()
}
@@ -2476,7 +2359,6 @@ 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)
@@ -2504,13 +2386,11 @@ onUnmounted(() => {
initialModeRestoreDone = false
initialModeRestoreInProgress = false
// Clear mouse flush timer
if (mouseFlushTimer !== null) {
clearTimeout(mouseFlushTimer)
mouseFlushTimer = null
}
// Clear all timers
if (retryTimeoutId !== null) {
clearTimeout(retryTimeoutId)
retryTimeoutId = null
@@ -2522,7 +2402,6 @@ onUnmounted(() => {
cancelWebRTCRecovery()
videoSession.clearWaiters()
// Reset counters
retryCount = 0
consoleEvents.unsubscribe()
@@ -2533,7 +2412,6 @@ onUnmounted(() => {
void webrtc.disconnect()
}
// Exit pointer lock if active
exitPointerLock()
})
</script>

View File

@@ -106,7 +106,6 @@ const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
// Settings state
const activeSection = ref('appearance')
const mobileMenuOpen = ref(false)
const loading = ref(false)
@@ -127,7 +126,6 @@ const SETTINGS_SECTION_IDS = new Set([
'about',
])
// Navigation structure
const navGroups = computed(() => [
{
title: t('settings.system'),
@@ -175,10 +173,8 @@ function normalizeSettingsSection(value: unknown): string | null {
return SETTINGS_SECTION_IDS.has(value) ? value : null
}
// Theme
const theme = ref<'light' | 'dark' | 'system'>('system')
// Account settings
const usernameInput = ref('')
const usernamePassword = ref('')
const usernameSaving = ref(false)
@@ -192,7 +188,6 @@ const passwordSaved = ref(false)
const passwordError = ref('')
const showPasswords = ref(false)
// Auth config state
const authConfig = ref<AuthConfig>({
session_timeout_secs: 3600 * 24,
single_user_allow_multiple_sessions: false,
@@ -201,7 +196,6 @@ const authConfig = ref<AuthConfig>({
})
const authConfigLoading = ref(false)
// Extensions management
const extensions = ref<ExtensionsStatus | null>(null)
const extensionsLoading = ref(false)
const extensionLogs = ref<Record<string, string[]>>({
@@ -215,17 +209,14 @@ const showLogs = ref<Record<string, boolean>>({
easytier: false,
})
// Terminal dialog
const showTerminalDialog = ref(false)
// Extension config (local edit state)
const extConfig = ref({
ttyd: { enabled: false, shell: '/bin/bash' },
gostc: { enabled: false, addr: '', key: '', tls: true },
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
})
// RustDesk config state
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
@@ -239,7 +230,6 @@ const rustdeskLocalConfig = ref({
relay_key: '',
})
// RTSP config state
const rtspStatus = ref<RtspStatusResponse | null>(null)
const rtspLoading = ref(false)
const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
@@ -267,7 +257,6 @@ const rtspStreamUrl = computed(() => {
return `rtsp://${host}:${port}/${path}`
})
// Web server config state
const webServerConfig = ref<WebConfig>({
http_port: 8080,
https_port: 8443,
@@ -277,14 +266,12 @@ const webServerConfig = ref<WebConfig>({
has_custom_cert: false,
})
const webServerLoading = ref(false)
// SSL certificate state
const sslCertPem = ref('')
const sslKeyPem = ref('')
const certSaving = ref(false)
const certClearing = ref(false)
const showRestartDialog = ref(false)
const restarting = ref(false)
// Auto-restart flow (no dialog needed for web-config saves)
const autoRestarting = ref(false)
const autoRestartFailed = ref(false)
// For HTTPS targets: can't poll (self-signed cert), show manual link instead
@@ -339,7 +326,6 @@ const previewAccessUrl = computed(() => {
return `${scheme}://${host}:${port}`
})
// Config
interface DeviceConfig {
video: Array<{
path: string
@@ -389,14 +375,12 @@ const config = ref({
msd_enabled: false,
msd_dir: '',
encoder_backend: 'auto',
// STUN/TURN settings
stun_server: '',
turn_server: '',
turn_username: '',
turn_password: '',
})
// Tracks whether TURN password is configured on the server
const hasTurnPassword = ref(false)
type OtgSelfCheckLevel = 'info' | 'warn' | 'error'
@@ -658,7 +642,6 @@ async function onRunVideoEncoderSelfCheckClick() {
await runVideoEncoderSelfCheck()
}
// USB devices state
const usbDevices = ref<import('@/api').UsbDeviceInfo[]>([])
const usbDevicesLoading = ref(false)
const usbDevicesError = ref('')
@@ -683,11 +666,9 @@ async function confirmUsbReset() {
try {
await usbApi.resetDevice(usbResetTarget.value.bus_num, usbResetTarget.value.dev_num)
} catch {
// Error already shown by request helper toast
} finally {
usbResetLoading.value = false
usbResetTarget.value = null
// Refresh the list after a short delay for USB re-enumeration
setTimeout(() => fetchUsbDevices(), 1500)
}
}
@@ -782,14 +763,12 @@ const isHidFunctionSelectionValid = computed(() => {
return !!(f.keyboard || f.mouse_relative || f.mouse_absolute || f.consumer)
})
// OTG Descriptor settings
const otgVendorIdHex = ref('1d6b')
const otgProductIdHex = ref('0104')
const otgManufacturer = ref('One-KVM')
const otgProduct = ref('One-KVM USB Device')
const otgSerialNumber = ref('')
// Validate hex input
const validateHex = (event: Event, _field: string) => {
const input = event.target as HTMLInputElement
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase()
@@ -807,7 +786,6 @@ watch(bindMode, (mode) => {
}
})
// ATX config state
const atxConfig = ref({
enabled: false,
power: {
@@ -833,7 +811,6 @@ const atxConfig = ref({
wol_interface: '',
})
// ATX devices for discovery
const atxDevices = ref<AtxDevices>({
gpio_chips: [],
usb_relays: [],
@@ -856,7 +833,6 @@ const isSharedAtxSerialRelay = computed(() => {
)
})
// Encoder backend
const availableBackends = ref<EncoderBackendInfo[]>([])
const selectedBackendFormats = computed(() => {
@@ -921,7 +897,6 @@ const availableFps = computed(() => {
return currentRes ? currentRes.fps : []
})
// Keep the selected format aligned with currently selectable formats.
watch(
selectableFormats,
() => {
@@ -938,7 +913,6 @@ watch(
{ deep: true },
)
// Watch for format change to set default resolution
watch(() => config.value.video_format, () => {
if (availableResolutions.value.length > 0) {
const isValid = availableResolutions.value.some(
@@ -955,7 +929,6 @@ watch(() => config.value.video_format, () => {
}
})
// Watch for resolution change to set default FPS
watch(() => [config.value.video_width, config.value.video_height], () => {
const fpsList = availableFps.value
if (fpsList.length > 0) {
@@ -975,7 +948,6 @@ watch(() => authStore.user, (value) => {
})
// Format bytes to human readable string
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
@@ -984,7 +956,6 @@ function formatBytes(bytes: number): string {
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
// Theme handling
function setTheme(newTheme: 'light' | 'dark' | 'system') {
theme.value = newTheme
localStorage.setItem('theme', newTheme)
@@ -997,7 +968,6 @@ function setTheme(newTheme: 'light' | 'dark' | 'system') {
}
}
// Account updates
async function changeUsername() {
usernameError.value = ''
usernameSaved.value = false
@@ -1062,17 +1032,13 @@ async function changePassword() {
}
}
// Save config using domain-separated APIs
async function saveConfig() {
loading.value = true
saved.value = false
try {
// Save only config related to the active section.
// Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH
// requests could overwrite each other's section (last writer wins on full JSON).
// Video config (including encoder and WebRTC/STUN/TURN settings)
if (activeSection.value === 'video') {
await configStore.updateVideo({
device: config.value.video_device || undefined,
@@ -1100,7 +1066,6 @@ async function saveConfig() {
ch9329_port: config.value.hid_serial_device || undefined,
ch9329_baudrate: config.value.hid_serial_baudrate,
}
// Add descriptor config for OTG backend
if (config.value.hid_backend === 'otg') {
hidUpdate.otg_descriptor = {
vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b,
@@ -1120,7 +1085,6 @@ async function saveConfig() {
})
}
// MSD config
if (activeSection.value === 'msd') {
await configStore.updateMsd({
msd_dir: config.value.msd_dir || undefined,
@@ -1137,10 +1101,8 @@ async function saveConfig() {
}
}
// Load config using domain-separated APIs
async function loadConfig() {
try {
// Load all domain configs in parallel
const [video, stream, hid, msd] = await Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
@@ -1170,17 +1132,14 @@ async function loadConfig() {
msd_enabled: msd.enabled || false,
msd_dir: msd.msd_dir || '',
encoder_backend: stream.encoder || 'auto',
// STUN/TURN settings
stun_server: stream.stun_server || '',
turn_server: stream.turn_server || '',
turn_username: stream.turn_username || '',
turn_password: '', // Password is never returned from server; set-only field
}
// Track whether TURN password is configured
hasTurnPassword.value = stream.has_turn_password || false
// Load OTG descriptor config
if (hid.otg_descriptor) {
otgVendorIdHex.value = hid.otg_descriptor.vendor_id?.toString(16).padStart(4, '0') || '1d6b'
otgProductIdHex.value = hid.otg_descriptor.product_id?.toString(16).padStart(4, '0') || '0104'
@@ -1211,7 +1170,6 @@ async function loadBackends() {
}
}
// Auth config functions
async function loadAuthConfig() {
authConfigLoading.value = true
try {
@@ -1236,12 +1194,10 @@ async function saveAuthConfig() {
}
}
// Extension management functions
async function loadExtensions() {
extensionsLoading.value = true
try {
extensions.value = await extensionsApi.getAll()
// Sync config from server
if (extensions.value) {
const ttyd = extensions.value.ttyd.config
extConfig.value.ttyd = {
@@ -1359,7 +1315,6 @@ function removeEasytierPeer(index: number) {
}
}
// ATX management functions
async function loadAtxConfig() {
try {
const config = await configStore.refreshAtx()
@@ -1485,7 +1440,6 @@ watch(
},
)
// RustDesk management functions
async function loadRustdeskConfig() {
rustdeskLoading.value = true
try {
@@ -1573,7 +1527,6 @@ function removeBindAddress(index: number) {
}
}
// Web server config functions
async function loadWebServerConfig() {
try {
const config = await configStore.refreshWeb()
@@ -1668,7 +1621,6 @@ async function pollUntilReady(targetOrigin: string, maxMs = 30000): Promise<bool
clearTimeout(tid)
if (res.ok) return true
} catch {
// server still restarting — keep polling
}
}
return false
@@ -1836,7 +1788,6 @@ async function saveRustdeskConfig() {
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
})
await loadRustdeskConfig()
// Clear relay_key input after save (it's a password field)
rustdeskLocalConfig.value.relay_key = ''
saved.value = true
setTimeout(() => (saved.value = false), 2000)
@@ -1878,7 +1829,6 @@ async function regenerateRustdeskPassword() {
async function startRustdesk() {
rustdeskLoading.value = true
try {
// Enable and save config to start the service
await configStore.updateRustdesk({ enabled: true })
rustdeskLocalConfig.value.enabled = true
await loadRustdeskConfig()
@@ -1892,7 +1842,6 @@ async function startRustdesk() {
async function stopRustdesk() {
rustdeskLoading.value = true
try {
// Disable and save config to stop the service
await configStore.updateRustdesk({ enabled: false })
rustdeskLocalConfig.value.enabled = false
await loadRustdeskConfig()
@@ -1919,7 +1868,6 @@ function getRustdeskServiceStatusText(status: string | undefined): string {
case 'stopped': return t('extensions.stopped')
case 'not_initialized': return t('extensions.rustdesk.notInitialized')
default:
// Handle "error: xxx" format
if (status.startsWith('error:')) return t('extensions.failed')
return status
}
@@ -1933,7 +1881,6 @@ function getRustdeskRendezvousStatusText(status: string | null | undefined): str
case 'connecting': return t('extensions.rustdesk.connecting')
case 'disconnected': return t('extensions.rustdesk.disconnected')
default:
// Handle "error: xxx" format
if (status.startsWith('error:')) return t('extensions.failed')
return status
}
@@ -1950,7 +1897,6 @@ function getRustdeskStatusClass(status: string | null | undefined): string {
case 'not_initialized':
case 'disconnected': return 'bg-gray-400'
default:
// Handle "error: xxx" format
if (status?.startsWith('error:')) return 'bg-red-500'
return 'bg-gray-400'
}
@@ -2058,9 +2004,7 @@ function getRtspStatusClass(status: string | undefined): string {
}
}
// Lifecycle
onMounted(async () => {
// Load theme preference
const storedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null
if (storedTheme) {
theme.value = storedTheme

View File

@@ -42,20 +42,17 @@ const { t } = useI18n()
const router = useRouter()
const authStore = useAuthStore()
// Steps: 1 = Account, 2 = Audio/Video, 3 = HID, 4 = Extensions
const step = ref(1)
const totalSteps = 4
const loading = ref(false)
const error = ref('')
const slideDirection = ref<'forward' | 'backward'>('forward')
// Account settings
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const showPassword = ref(false)
// Form validation states
const usernameError = ref('')
const passwordError = ref('')
const confirmPasswordError = ref('')
@@ -63,17 +60,14 @@ const usernameTouched = ref(false)
const passwordTouched = ref(false)
const confirmPasswordTouched = ref(false)
// Video settings
const videoDevice = ref('')
const videoFormat = ref('')
const videoResolution = ref('')
const videoFps = ref<number | null>(null)
// Audio settings
const audioDevice = ref('')
const audioEnabled = ref(true)
// HID settings
const hidBackend = ref('ch9329')
const ch9329Port = ref('')
const ch9329Baudrate = ref(9600)
@@ -83,7 +77,6 @@ const otgMsdEnabled = ref(true)
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
const otgKeyboardLeds = ref(true)
// Extension settings
const ttydEnabled = ref(false)
const ttydAvailable = ref(false)
@@ -137,7 +130,6 @@ const devices = ref<DeviceInfo>({
},
})
// Password strength calculation
const passwordStrength = computed(() => {
const pwd = password.value
if (!pwd) return 0
@@ -183,7 +175,6 @@ async function refreshDeviceList() {
ttydAvailable.value = result.extensions.ttyd_available
}
} catch {
// keep current list
} finally {
refreshingDevices.value = false
}
@@ -195,13 +186,11 @@ const availableFormats = computed(() => {
return device?.formats || []
})
// Computed: available resolutions for selected format
const availableResolutions = computed(() => {
const format = availableFormats.value.find((f) => f.format === videoFormat.value)
return format?.resolutions || []
})
// Computed: available FPS for selected resolution
const availableFps = computed(() => {
const [width, height] = (videoResolution.value || '').split('x').map(Number)
const resolution = availableResolutions.value.find(
@@ -252,10 +241,8 @@ function applyOtgDefaults() {
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
}
// Common baud rates for CH9329
const baudRates = [9600, 19200, 38400, 57600, 115200]
// Step labels for the indicator
const stepLabels = computed(() => [
t('setup.stepAccount'),
t('setup.stepAudioVideo'),
@@ -263,7 +250,6 @@ const stepLabels = computed(() => [
t('setup.stepExtensions'),
])
// Real-time validation functions
function validateUsername() {
usernameTouched.value = true
if (username.value.length === 0) {
@@ -284,7 +270,6 @@ function validatePassword() {
} else {
passwordError.value = ''
}
// Also validate confirm password if it was touched
if (confirmPasswordTouched.value) {
validateConfirmPassword()
}
@@ -335,12 +320,10 @@ watch(videoDevice, (newDevice) => {
}
})
// Watch format change to auto-select best resolution
watch(videoFormat, () => {
videoResolution.value = ''
videoFps.value = null
if (availableResolutions.value.length > 0) {
// Prefer 1080p if available, otherwise highest resolution
const r1080 = availableResolutions.value.find((r) => r.width === 1920 && r.height === 1080)
const r720 = availableResolutions.value.find((r) => r.width === 1280 && r.height === 720)
const best = r1080 || r720 || availableResolutions.value[0]
@@ -350,11 +333,9 @@ watch(videoFormat, () => {
}
})
// Watch resolution change to auto-select FPS
watch(videoResolution, () => {
videoFps.value = null
if (availableFps.value.length > 0) {
// Prefer 30fps if available
videoFps.value = availableFps.value.includes(30) ? 30 : availableFps.value[0] || null
}
})
@@ -389,7 +370,6 @@ onMounted(async () => {
ch9329Port.value = result.serial[0].path
}
// Auto-select first UDC for OTG
if (result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name
}
@@ -407,7 +387,6 @@ onMounted(async () => {
ttydAvailable.value = result.extensions.ttyd_available
}
} catch {
// Use defaults
}
// Load encoder backends
@@ -415,10 +394,8 @@ onMounted(async () => {
const codecsResult = await streamApi.getCodecs()
availableBackends.value = codecsResult.backends || []
} catch {
// Use defaults
}
// Add keyboard navigation
document.addEventListener('keydown', handleKeyDown)
})
@@ -427,7 +404,6 @@ onUnmounted(() => {
})
function handleKeyDown(e: KeyboardEvent) {
// Don't interfere with input fields
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
@@ -446,7 +422,6 @@ function handleKeyDown(e: KeyboardEvent) {
}
function validateStep1(): boolean {
// Trigger validation for all fields
validateUsername()
validatePassword()
validateConfirmPassword()
@@ -521,7 +496,6 @@ async function handleSetup() {
loading.value = true
// Parse resolution
const [width, height] = (videoResolution.value || '').split('x').map(Number)
const setupData: Parameters<typeof authStore.setup>[0] = {
@@ -529,7 +503,6 @@ async function handleSetup() {
password: password.value,
}
// Video settings
if (videoDevice.value) {
setupData.video_device = videoDevice.value
}
@@ -544,7 +517,6 @@ async function handleSetup() {
setupData.video_fps = toConfigFps(videoFps.value)
}
// HID settings
setupData.hid_backend = hidBackend.value
if (hidBackend.value === 'ch9329') {
setupData.hid_ch9329_port = ch9329Port.value
@@ -563,18 +535,15 @@ async function handleSetup() {
setupData.encoder_backend = encoderBackend.value
}
// Audio settings
if (audioDevice.value && audioDevice.value !== '__none__') {
setupData.audio_device = audioDevice.value
}
// Extension settings
setupData.ttyd_enabled = ttydEnabled.value
const success = await authStore.setup(setupData)
if (success) {
// Auto login after setup
await authStore.login(username.value, password.value)
router.push('/')
} else {
@@ -584,7 +553,6 @@ async function handleSetup() {
loading.value = false
}
// Step icon component helper
const stepIcons = [User, Video, Keyboard, Puzzle]
</script>