Merge branch 'main' into main

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -129,9 +129,11 @@ const statusBadgeText = computed(() => {
<HoverCard v-if="!prefersPopover" :open-delay="200" :close-delay="100">
<HoverCardTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
<button
type="button"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
@@ -147,7 +149,7 @@ const statusBadgeText = computed(() => {
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</div>
</button>
</HoverCardTrigger>
<HoverCardContent class="w-80" :align="hoverAlign">
@@ -228,9 +230,11 @@ const statusBadgeText = computed(() => {
<Popover v-else>
<PopoverTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
<button
type="button"
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
:class="cn(
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors',
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
@@ -246,7 +250,7 @@ const statusBadgeText = computed(() => {
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</div>
</button>
</PopoverTrigger>
<PopoverContent class="w-80" :align="hoverAlign">

View File

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

View File

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