feat: 初步增加 Windows 支持

This commit is contained in:
mofeng-git
2026-05-18 22:43:28 +08:00
parent 0b9d94f53f
commit 935fa823f2
163 changed files with 11419 additions and 7581 deletions

View File

@@ -46,6 +46,30 @@ export interface DeviceInfo {
memory_total: number
memory_used: number
network_addresses: NetworkAddress[]
serial_ports: string[]
}
export interface FeatureCapability {
available: boolean
backends: string[]
selected_backend?: string
reason?: string
}
export interface PlatformCapabilities {
mode: 'linux' | 'windows'
mode_label: string
video_capture: FeatureCapability
encoder: FeatureCapability
hid: FeatureCapability
atx: FeatureCapability
msd: FeatureCapability
otg: FeatureCapability
audio: FeatureCapability
rustdesk: FeatureCapability
diagnostics: FeatureCapability
extensions: FeatureCapability
service_installation: FeatureCapability
}
export const systemApi = {
@@ -54,12 +78,14 @@ export const systemApi = {
version: string
build_date: string
initialized: boolean
platform: PlatformCapabilities
capabilities: {
video: { available: boolean; backend?: string }
hid: { available: boolean; backend?: string }
msd: { available: boolean }
atx: { available: boolean; backend?: string }
audio: { available: boolean; backend?: string }
video: { available: boolean; backend?: string; reason?: string }
hid: { available: boolean; backend?: string; reason?: string }
msd: { available: boolean; backend?: string; reason?: string }
atx: { available: boolean; backend?: string; reason?: string }
audio: { available: boolean; backend?: string; reason?: string }
rustdesk: { available: boolean; backend?: string; reason?: string }
}
disk_space?: {
total: number
@@ -72,7 +98,7 @@ export const systemApi = {
health: () => request<{ status: string; version: string }>('/health'),
setupStatus: () =>
request<{ initialized: boolean; needs_setup: boolean }>('/setup'),
request<{ initialized: boolean; needs_setup: boolean; platform: PlatformCapabilities }>('/setup'),
setup: (data: {
username: string

View File

@@ -63,6 +63,7 @@ const props = defineProps<{
mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode
ttydRunning?: boolean
showTerminal?: boolean
}>()
const emit = defineEmits<{
@@ -194,6 +195,7 @@ const RIGHT_FIXED_PX = 120
const collapsibleItems = computed(() => {
const items = ITEM_SPECS.slice(3).filter(item => {
if (item.id === 'msd' && !showMsd.value) return false
if (item.id === 'extension' && props.showTerminal === false) return false
return true
})
return items
@@ -339,7 +341,7 @@ const hasOverflow = computed(() => {
</div>
<!-- Extension Menu - Adaptive -->
<div v-if="isVisible('extension')">
<div v-if="props.showTerminal !== false && isVisible('extension')">
<Popover v-model:open="extensionOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
@@ -449,7 +451,7 @@ const hasOverflow = computed(() => {
{{ t('actionbar.paste') }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="(!isVisible('msd') || !isVisible('atx') || !isVisible('paste')) && (!isVisible('stats') || !isVisible('extension') || !isVisible('settings'))" />
<DropdownMenuSeparator v-if="(!isVisible('msd') || !isVisible('atx') || !isVisible('paste')) && (!isVisible('stats') || (props.showTerminal !== false && !isVisible('extension')) || !isVisible('settings'))" />
<!-- Stats -->
<DropdownMenuItem v-if="!isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
@@ -459,7 +461,7 @@ const hasOverflow = computed(() => {
<!-- Extension -->
<DropdownMenuItem
v-if="!isVisible('extension')"
v-if="props.showTerminal !== false && !isVisible('extension')"
:disabled="!props.ttydRunning"
@click="openFromOverflow(() => emit('openTerminal'))"
>

View File

@@ -29,6 +29,7 @@ import {
} from '@/api'
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { useConfigStore } from '@/stores/config'
import { useRouter } from 'vue-router'
@@ -782,7 +783,7 @@ watch(
:value="device.path"
class="text-xs"
>
{{ device.name }} ({{ device.path }})
{{ formatVideoDeviceLabel(device) }}
</SelectItem>
</SelectContent>
</Select>

View File

@@ -274,8 +274,10 @@ export default {
extensionsDescription: 'Choose which extensions to auto-start',
ttydTitle: 'Web Terminal (ttyd)',
ttydDescription: 'Access device command line in browser',
ttydWindowsUnsupportedDescription: 'ttyd Web Terminal is not supported on Windows and will not be enabled.',
extensionsHint: 'These settings can be changed later in Settings',
notInstalled: 'Not installed',
notSupportedOnWindows: 'Unsupported on Windows',
passwordStrength: 'Password Strength',
passwordWeak: 'Weak',
passwordMedium: 'Medium',
@@ -969,13 +971,16 @@ export default {
desc: 'NAT traversal via GOSTC',
addr: 'Server Address',
addrPlaceholder: 'Hostname or IP (required)',
addrRequired: 'Enter the GOSTC server address',
key: 'Client Key',
keyRequired: 'Enter the GOSTC client key',
tls: 'Enable TLS',
},
easytier: {
title: 'Easytier Network',
desc: 'P2P VPN networking via EasyTier',
networkName: 'Network Name',
networkNameRequired: 'Enter the EasyTier network name',
networkSecret: 'Network Secret',
peers: 'Peer Nodes',
addPeer: 'Add Peer',
@@ -989,6 +994,7 @@ export default {
rendezvousServer: 'ID Server',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: 'Configure your RustDesk server address (port optional, defaults to 21116)',
rendezvousServerRequired: 'Enter the RustDesk ID server',
relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',

View File

@@ -274,8 +274,10 @@ export default {
extensionsDescription: '选择要自动启动的扩展服务',
ttydTitle: 'Web 终端 (ttyd)',
ttydDescription: '在浏览器中访问设备的命令行终端',
ttydWindowsUnsupportedDescription: 'Windows 平台暂不支持 ttyd Web 终端,此扩展不会启用。',
extensionsHint: '这些设置可以在设置页面中随时更改',
notInstalled: '未安装',
notSupportedOnWindows: 'Windows 不支持',
passwordStrength: '密码强度',
passwordWeak: '弱',
passwordMedium: '中',
@@ -968,13 +970,16 @@ export default {
desc: '通过 GOSTC 实现内网穿透',
addr: '服务器地址',
addrPlaceholder: '主机名或 IP必填',
addrRequired: '请填写 GOSTC 服务器地址',
key: '客户端密钥',
keyRequired: '请填写 GOSTC 客户端密钥',
tls: '启用 TLS',
},
easytier: {
title: 'Easytier 组网',
desc: '通过 EasyTier 实现 P2P VPN 组网',
networkName: '网络名称',
networkNameRequired: '请填写 EasyTier 网络名称',
networkSecret: '网络密钥',
peers: '对等节点',
addPeer: '添加节点',
@@ -988,6 +993,7 @@ export default {
rendezvousServer: 'ID 服务器',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: '请配置您的 RustDesk 服务器地址(端口可省略,默认 21116',
rendezvousServerRequired: '请填写 RustDesk ID 服务器',
relayServer: '中继服务器',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: '中继服务器地址(端口可省略,默认 21117留空则自动从 ID 服务器推导',

View File

@@ -0,0 +1,51 @@
export interface VideoDeviceLabelSource {
name?: string | null
path: string
}
const WINDOWS_USB_ID_RE = /vid[_-]([0-9a-f]{4}).*pid[_-]([0-9a-f]{4})/i
const WINDOWS_SYMBOLIC_LINK_RE = /^\\\\\?\\/
const WINDOWS_DIRECTSHOW_RE = /^dshow:/i
function shortDevicePath(path: string): string {
const normalized = path.replace(/\\/g, '/')
const parts = normalized.split('/').filter(Boolean)
return parts[parts.length - 1] || path
}
function isWindowsSymbolicLink(path: string): boolean {
return WINDOWS_SYMBOLIC_LINK_RE.test(path)
}
function isWindowsDirectShowPath(path: string): boolean {
return WINDOWS_DIRECTSHOW_RE.test(path)
}
function cleanWindowsDirectShowLabel(value: string): string {
return value.replace(WINDOWS_DIRECTSHOW_RE, '').trim()
}
function fallbackDeviceName(path: string): string {
if (isWindowsDirectShowPath(path)) return cleanWindowsDirectShowLabel(path) || 'Windows Capture Device'
if (isWindowsSymbolicLink(path)) return 'Windows Capture Device'
return shortDevicePath(path)
}
export function formatVideoDeviceLabel(device: VideoDeviceLabelSource): string {
const path = device.path.trim()
const rawName = device.name?.trim() || fallbackDeviceName(path)
const name = isWindowsDirectShowPath(rawName) ? cleanWindowsDirectShowLabel(rawName) : rawName
const usbId = path.match(WINDOWS_USB_ID_RE)
if (usbId?.[1] && usbId[2]) {
return `${name} (${usbId[1].toLowerCase()}:${usbId[2].toLowerCase()})`
}
if (!path) return name
if (isWindowsDirectShowPath(path)) return cleanWindowsDirectShowLabel(name) || fallbackDeviceName(path)
if (isWindowsSymbolicLink(path)) return name
return `${name} (${shortDevicePath(path)})`
}

View File

@@ -1,13 +1,13 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo } from '@/api'
import { systemApi, streamApi, hidApi, atxApi, msdApi, type DeviceInfo, type PlatformCapabilities } from '@/api'
interface SystemCapabilities {
video: { available: boolean; backend?: string }
hid: { available: boolean; backend?: string }
msd: { available: boolean }
atx: { available: boolean; backend?: string }
audio: { available: boolean; backend?: string }
video: { available: boolean; backend?: string; reason?: string }
hid: { available: boolean; backend?: string; reason?: string }
msd: { available: boolean; backend?: string; reason?: string }
atx: { available: boolean; backend?: string; reason?: string }
audio: { available: boolean; backend?: string; reason?: string }
}
interface DiskSpaceInfo {
@@ -149,6 +149,7 @@ export interface DeviceInfoEvent {
export const useSystemStore = defineStore('system', () => {
const version = ref<string>('')
const buildDate = ref<string>('')
const platform = ref<PlatformCapabilities | null>(null)
const capabilities = ref<SystemCapabilities | null>(null)
const diskSpace = ref<DiskSpaceInfo | null>(null)
const deviceInfo = ref<DeviceInfo | null>(null)
@@ -171,6 +172,7 @@ export const useSystemStore = defineStore('system', () => {
const info = await systemApi.info()
version.value = info.version
buildDate.value = info.build_date
platform.value = info.platform
capabilities.value = info.capabilities
diskSpace.value = info.disk_space ?? null
deviceInfo.value = info.device_info ?? null
@@ -424,6 +426,7 @@ export const useSystemStore = defineStore('system', () => {
return {
version,
buildDate,
platform,
capabilities,
diskSpace,
deviceInfo,

View File

@@ -19,6 +19,7 @@ import { toast } from 'vue-sonner'
import { cn, generateUUID } from '@/lib/utils'
import { formatFpsValue } from '@/lib/fps'
import { videoDebugLog } from '@/lib/debugLog'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
import type { StreamDeviceLostEventData } from '@/types/websocket'
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
@@ -170,6 +171,7 @@ const changingPassword = ref(false)
const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
const showTerminalDialog = ref(false)
const showTerminal = computed(() => ttydStatus.value?.available !== false)
const isDark = ref(document.documentElement.classList.contains('dark'))
@@ -233,7 +235,7 @@ const videoDetails = computed<StatusDetail[]>(() => {
: 'error'
const details: StatusDetail[] = [
{ label: t('statusCard.device'), value: stream.device || '-' },
{ label: t('statusCard.device'), value: stream.device ? formatVideoDeviceLabel({ path: stream.device }) : '-' },
{ label: t('statusCard.format'), value: formatDisplay },
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
{ label: t('statusCard.fpsTarget'), value: targetFpsValue },
@@ -1865,11 +1867,14 @@ async function handleChangePassword() {
}
function openTerminal() {
if (!showTerminal.value) return
if (!ttydStatus.value?.running) return
showTerminalDialog.value = true
}
function openTerminalInNewTab() {
if (!showTerminal.value) return
if (!ttydStatus.value?.running) return
window.open('/api/terminal/', '_blank')
}
@@ -2497,6 +2502,7 @@ onMounted(async () => {
}, { immediate: true })
await systemStore.startStream().catch(() => {})
await systemStore.fetchSystemInfo().catch(() => {})
await systemStore.fetchAllStates()
await configStore.refreshHid().then(() => {
syncMouseModeFromConfig()
@@ -2688,6 +2694,7 @@ onUnmounted(() => {
:mouse-mode="mouseMode"
:video-mode="videoMode"
:ttyd-running="ttydStatus?.running"
:show-terminal="showTerminal"
@toggle-fullscreen="toggleFullscreen"
@toggle-stats="statsSheetOpen = true"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
@@ -2929,7 +2936,7 @@ onUnmounted(() => {
:ws-latency="0"
:webrtc-stats="webrtc.stats.value"
/>
<Dialog v-model:open="showTerminalDialog">
<Dialog v-if="showTerminal" v-model:open="showTerminalDialog">
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
<DialogTitle class="flex items-center justify-between w-full">

View File

@@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { toast } from 'vue-sonner'
import { useSystemStore } from '@/stores/system'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
@@ -42,6 +43,7 @@ import type {
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { useClipboard } from '@/composables/useClipboard'
import { getVideoFormatState } from '@/lib/video-format-support'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import AppLayout from '@/components/AppLayout.vue'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import { Button } from '@/components/ui/button'
@@ -109,6 +111,8 @@ const systemStore = useSystemStore()
const configStore = useConfigStore()
const authStore = useAuthStore()
const isWindows = computed(() => systemStore.platform?.mode === 'windows')
const activeSection = ref('appearance')
const mobileMenuOpen = ref(false)
const loading = ref(false)
@@ -203,6 +207,12 @@ function normalizeSettingsSection(value: unknown): string | null {
return SETTINGS_SECTION_IDS.has(value) ? value : null
}
function ensureVisibleSection() {
if (!SETTINGS_SECTION_IDS.has(activeSection.value)) {
activeSection.value = 'appearance'
}
}
const theme = ref<'light' | 'dark' | 'system'>('system')
const usernameInput = ref('')
@@ -247,6 +257,17 @@ const extConfig = ref({
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
})
const gostcValidationMessage = computed(() => {
if (!extConfig.value.gostc.addr?.trim()) return t('extensions.gostc.addrRequired')
if (!extConfig.value.gostc.key) return t('extensions.gostc.keyRequired')
return ''
})
const easytierValidationMessage = computed(() => {
if (!extConfig.value.easytier.network_name?.trim()) return t('extensions.easytier.networkNameRequired')
return ''
})
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
@@ -260,6 +281,13 @@ const rustdeskLocalConfig = ref({
relay_key: '',
})
const rustdeskValidationMessage = computed(() => {
if (!rustdeskLocalConfig.value.rendezvous_server?.trim()) {
return t('extensions.rustdesk.rendezvousServerRequired')
}
return ''
})
const rtspStatus = ref<RtspStatusResponse | null>(null)
const rtspLoading = ref(false)
const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
@@ -871,6 +899,18 @@ const ch9329ReservedSerialDevice = computed(() => {
return config.value.hid_serial_device.trim()
})
const atxDriverOptions = computed(() => {
const options = [
{ value: 'none' as AtxDriverType, label: t('settings.atxDriverNone') },
{ value: 'gpio' as AtxDriverType, label: t('settings.atxDriverGpio') },
{ value: 'usbrelay' as AtxDriverType, label: t('settings.atxDriverUsbRelay') },
{ value: 'serial' as AtxDriverType, label: t('settings.atxDriverSerial') },
]
return isWindows.value
? options.filter(option => ['none', 'serial'].includes(option.value))
: options
})
const isSharedAtxSerialRelay = computed(() => {
const power = atxConfig.value.power
const reset = atxConfig.value.reset
@@ -1005,6 +1045,19 @@ function formatBytes(bytes: number): string {
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
const hasDeviceCpuUsage = computed(() => {
return !!systemStore.deviceInfo
})
const hasDeviceMemoryUsage = computed(() => {
const info = systemStore.deviceInfo
return !!info && info.memory_total > 0
})
const hasDeviceNetworkAddresses = computed(() => {
return (systemStore.deviceInfo?.network_addresses.length ?? 0) > 0
})
function setTheme(newTheme: 'light' | 'dark' | 'system') {
theme.value = newTheme
localStorage.setItem('theme', newTheme)
@@ -1272,6 +1325,8 @@ async function loadExtensions() {
}
async function startExtension(id: 'ttyd' | 'gostc' | 'easytier') {
if ((id === 'gostc' || id === 'easytier') && !validateExtensionConfig(id)) return
try {
await extensionsApi.start(id)
await loadExtensions()
@@ -1299,6 +1354,8 @@ async function refreshExtensionLogs(id: 'ttyd' | 'gostc' | 'easytier') {
}
async function saveExtensionConfig(id: 'ttyd' | 'gostc' | 'easytier') {
if ((id === 'gostc' || id === 'easytier') && extConfig.value[id].enabled && !validateExtensionConfig(id)) return
loading.value = true
try {
if (id === 'ttyd') {
@@ -1548,6 +1605,26 @@ function normalizeRustdeskRelayKey(value: string): string | undefined {
return cleaned || undefined
}
function showValidationError(message: string): boolean {
toast.error(t('api.operationFailed'), {
description: message,
duration: 4000,
})
return false
}
function validateExtensionConfig(id: 'gostc' | 'easytier'): boolean {
const message = id === 'gostc'
? gostcValidationMessage.value
: easytierValidationMessage.value
return !message || showValidationError(message)
}
function validateRustdeskConfig(): boolean {
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
}
function normalizeRtspPath(path: string): string {
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
}
@@ -1864,6 +1941,8 @@ function updateStatusBadgeText(): string {
}
async function saveRustdeskConfig() {
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
loading.value = true
saved.value = false
try {
@@ -1918,6 +1997,8 @@ async function regenerateRustdeskPassword() {
}
async function startRustdesk() {
if (!validateRustdeskConfig()) return
rustdeskLoading.value = true
try {
await configStore.updateRustdesk({ enabled: true })
@@ -2119,6 +2200,7 @@ onMounted(async () => {
refreshUpdateStatus(),
fetchUsbDevices(),
])
ensureVisibleSection()
usernameInput.value = authStore.user || ''
if (updateRunning.value) {
@@ -2141,6 +2223,10 @@ watch(() => route.query.tab, (tab) => {
selectSection(section)
}
}, { immediate: true })
watch(isWindows, () => {
ensureVisibleSection()
})
</script>
<template>
@@ -2370,7 +2456,7 @@ watch(() => route.query.tab, (tab) => {
<Label for="video-device">{{ t('settings.videoDevice') }}</Label>
<select id="video-device" v-model="config.video_device" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="">{{ t('settings.selectDevice') }}</option>
<option v-for="dev in devices.video" :key="dev.path" :value="dev.path">{{ dev.name }} ({{ dev.path }})</option>
<option v-for="dev in devices.video" :key="dev.path" :value="dev.path">{{ formatVideoDeviceLabel(dev) }}</option>
</select>
</div>
<div class="space-y-2">
@@ -3369,10 +3455,9 @@ watch(() => route.query.tab, (tab) => {
<div class="space-y-2">
<Label for="power-driver">{{ t('settings.atxDriver') }}</Label>
<select id="power-driver" v-model="atxConfig.power.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="none">{{ t('settings.atxDriverNone') }}</option>
<option value="gpio">{{ t('settings.atxDriverGpio') }}</option>
<option value="usbrelay">{{ t('settings.atxDriverUsbRelay') }}</option>
<option value="serial">{{ t('settings.atxDriverSerial') }}</option>
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="space-y-2">
@@ -3433,10 +3518,9 @@ watch(() => route.query.tab, (tab) => {
<div class="space-y-2">
<Label for="reset-driver">{{ t('settings.atxDriver') }}</Label>
<select id="reset-driver" v-model="atxConfig.reset.driver" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
<option value="none">{{ t('settings.atxDriverNone') }}</option>
<option value="gpio">{{ t('settings.atxDriverGpio') }}</option>
<option value="usbrelay">{{ t('settings.atxDriverUsbRelay') }}</option>
<option value="serial">{{ t('settings.atxDriverSerial') }}</option>
<option v-for="option in atxDriverOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="space-y-2">
@@ -3495,7 +3579,7 @@ watch(() => route.query.tab, (tab) => {
</Card>
<!-- LED Sensing Config -->
<Card v-if="atxConfig.enabled">
<Card v-if="atxConfig.enabled && !isWindows">
<CardHeader>
<CardTitle>{{ t('settings.atxLedSensing') }}</CardTitle>
<CardDescription>{{ t('settings.atxLedSensingDesc') }}</CardDescription>
@@ -3583,7 +3667,7 @@ watch(() => route.query.tab, (tab) => {
</CardHeader>
<CardContent class="space-y-4">
<div v-if="!extensions?.ttyd?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
{{ t('extensions.binaryNotFound', { path: '/usr/bin/ttyd' }) }}
{{ t('extensions.binaryNotFound', { path: isWindows ? 'ttyd.win32.exe' : '/usr/bin/ttyd' }) }}
</div>
<template v-else>
<!-- Status and controls -->
@@ -3632,7 +3716,7 @@ watch(() => route.query.tab, (tab) => {
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.ttyd.shell') }}</Label>
<Input v-model="extConfig.ttyd.shell" class="sm:col-span-3" placeholder="/bin/bash" :disabled="isExtRunning(extensions?.ttyd?.status)" />
<Input v-model="extConfig.ttyd.shell" class="sm:col-span-3" :placeholder="isWindows ? 'cmd' : '/bin/bash'" :disabled="isExtRunning(extensions?.ttyd?.status)" />
</div>
</div>
<!-- Logs -->
@@ -3676,7 +3760,7 @@ watch(() => route.query.tab, (tab) => {
</CardHeader>
<CardContent class="space-y-4">
<div v-if="!extensions?.gostc?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
{{ t('extensions.binaryNotFound', { path: '/usr/bin/gostc' }) }}
{{ t('extensions.binaryNotFound', { path: isWindows ? 'gostc.exe' : '/usr/bin/gostc' }) }}
</div>
<template v-else>
<!-- Status and controls -->
@@ -3690,7 +3774,7 @@ watch(() => route.query.tab, (tab) => {
v-if="!isExtRunning(extensions?.gostc?.status)"
size="sm"
@click="startExtension('gostc')"
:disabled="extensionsLoading || !extConfig.gostc.key || !extConfig.gostc.addr?.trim()"
:disabled="extensionsLoading || !!gostcValidationMessage"
>
<Play class="h-4 w-4 mr-1" />
{{ t('extensions.start') }}
@@ -3716,11 +3800,17 @@ watch(() => route.query.tab, (tab) => {
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
<div class="sm:col-span-3 space-y-1">
<Input v-model="extConfig.gostc.addr" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
<p v-if="extConfig.gostc.enabled && !extConfig.gostc.addr?.trim()" class="text-xs text-destructive">{{ t('extensions.gostc.addrRequired') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
<Input v-model="extConfig.gostc.key" type="password" class="sm:col-span-3" :disabled="isExtRunning(extensions?.gostc?.status)" />
<div class="sm:col-span-3 space-y-1">
<Input v-model="extConfig.gostc.key" type="password" :disabled="isExtRunning(extensions?.gostc?.status)" />
<p v-if="extConfig.gostc.enabled && !extConfig.gostc.key" class="text-xs text-destructive">{{ t('extensions.gostc.keyRequired') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.gostc.tls') }}</Label>
@@ -3767,7 +3857,7 @@ watch(() => route.query.tab, (tab) => {
</CardHeader>
<CardContent class="space-y-4">
<div v-if="!extensions?.easytier?.available" class="text-sm text-muted-foreground bg-muted p-3 rounded-md">
{{ t('extensions.binaryNotFound', { path: '/usr/bin/easytier-core' }) }}
{{ t('extensions.binaryNotFound', { path: isWindows ? 'easytier-core.exe' : '/usr/bin/easytier-core' }) }}
</div>
<template v-else>
<!-- Status and controls -->
@@ -3781,7 +3871,7 @@ watch(() => route.query.tab, (tab) => {
v-if="!isExtRunning(extensions?.easytier?.status)"
size="sm"
@click="startExtension('easytier')"
:disabled="extensionsLoading || !extConfig.easytier.network_name"
:disabled="extensionsLoading || !!easytierValidationMessage"
>
<Play class="h-4 w-4 mr-1" />
{{ t('extensions.start') }}
@@ -3807,7 +3897,10 @@ watch(() => route.query.tab, (tab) => {
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.easytier.networkName') }}</Label>
<Input v-model="extConfig.easytier.network_name" class="sm:col-span-3" :disabled="isExtRunning(extensions?.easytier?.status)" />
<div class="sm:col-span-3 space-y-1">
<Input v-model="extConfig.easytier.network_name" :disabled="isExtRunning(extensions?.easytier?.status)" />
<p v-if="extConfig.easytier.enabled && !extConfig.easytier.network_name?.trim()" class="text-xs text-destructive">{{ t('extensions.easytier.networkNameRequired') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.easytier.networkSecret') }}</Label>
@@ -4045,6 +4138,7 @@ watch(() => route.query.tab, (tab) => {
:placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')"
/>
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.rendezvousServerHint') }}</p>
<p v-if="rustdeskLocalConfig.enabled && rustdeskValidationMessage" class="text-xs text-destructive">{{ rustdeskValidationMessage }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
@@ -4249,24 +4343,21 @@ watch(() => route.query.tab, (tab) => {
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.cpuModel') }}</span>
<span class="text-sm font-medium truncate max-w-[60%] text-right">{{ systemStore.deviceInfo.cpu_model }}</span>
</div>
<div class="flex justify-between items-center py-2 border-b">
<div v-if="hasDeviceCpuUsage" class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-muted-foreground">{{ t('settings.cpuUsage') }}</span>
<span class="text-sm font-medium">{{ systemStore.deviceInfo.cpu_usage.toFixed(1) }}%</span>
</div>
<div class="flex justify-between items-center py-2 border-b">
<div v-if="hasDeviceMemoryUsage" class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-muted-foreground">{{ t('settings.memoryUsage') }}</span>
<span class="text-sm font-medium">{{ formatBytes(systemStore.deviceInfo.memory_used) }} / {{ formatBytes(systemStore.deviceInfo.memory_total) }}</span>
</div>
<div class="py-2">
<div v-if="hasDeviceNetworkAddresses" class="py-2">
<span class="text-sm text-muted-foreground">{{ t('settings.networkAddresses') }}</span>
<div class="mt-2 space-y-1">
<div v-for="addr in systemStore.deviceInfo.network_addresses" :key="addr.interface" class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ addr.interface }}</span>
<code class="font-mono bg-muted px-2 py-0.5 rounded">{{ addr.ip }}</code>
</div>
<div v-if="systemStore.deviceInfo.network_addresses.length === 0" class="text-sm text-muted-foreground">
{{ t('common.unknown') }}
</div>
</div>
</div>
</div>

View File

@@ -3,8 +3,9 @@ import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
import { configApi, streamApi, type EncoderBackendInfo, type PlatformCapabilities } from '@/api'
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
import BrandMark from '@/components/BrandMark.vue'
import { Button } from '@/components/ui/button'
@@ -43,7 +44,6 @@ const router = useRouter()
const authStore = useAuthStore()
const step = ref(1)
const totalSteps = 4
const loading = ref(false)
const error = ref('')
const slideDirection = ref<'forward' | 'backward'>('forward')
@@ -67,6 +67,10 @@ const videoFps = ref<number | null>(null)
const audioDevice = ref('')
const audioEnabled = ref(true)
const platform = ref<PlatformCapabilities | null>(null)
const isWindows = computed(() => platform.value?.mode === 'windows')
const audioSupported = computed(() => platform.value?.audio.available ?? true)
const totalSteps = 4
const hidBackend = ref('ch9329')
const ch9329Port = ref('')
@@ -298,7 +302,7 @@ watch(videoDevice, (newDevice) => {
}
// Auto-select matching audio device based on USB bus
if (newDevice && audioEnabled.value) {
if (newDevice && audioEnabled.value && audioSupported.value) {
const video = devices.value.video.find((d) => d.path === newDevice)
if (video?.usb_bus) {
// Find audio device on the same USB bus
@@ -356,6 +360,20 @@ watch(otgUdc, () => {
})
onMounted(async () => {
try {
const status = await authStore.checkSetupStatus()
platform.value = status.platform
if (isWindows.value) {
hidBackend.value = 'ch9329'
otgMsdEnabled.value = false
}
if (!audioSupported.value) {
audioEnabled.value = false
audioDevice.value = '__none__'
}
} catch {
}
try {
const result = await configApi.listDevices()
devices.value = result
@@ -370,13 +388,13 @@ onMounted(async () => {
ch9329Port.value = result.serial[0].path
}
if (result.udc.length > 0 && result.udc[0]) {
if (!isWindows.value && result.udc.length > 0 && result.udc[0]) {
otgUdc.value = result.udc[0].name
}
applyOtgDefaults()
// Auto-select audio device if available (and no video device to trigger watch)
if (result.audio.length > 0 && !audioDevice.value) {
if (audioSupported.value && result.audio.length > 0 && !audioDevice.value) {
// Prefer HDMI audio device
const hdmiAudio = result.audio.find((a) => a.is_hdmi)
audioDevice.value = hdmiAudio?.name || result.audio[0]?.name || ''
@@ -535,7 +553,7 @@ async function handleSetup() {
setupData.encoder_backend = encoderBackend.value
}
if (audioDevice.value && audioDevice.value !== '__none__') {
if (audioSupported.value && audioDevice.value && audioDevice.value !== '__none__') {
setupData.audio_device = audioDevice.value
}
@@ -722,7 +740,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</SelectTrigger>
<SelectContent>
<SelectItem v-for="dev in devices.video" :key="dev.path" :value="dev.path">
{{ dev.name }} ({{ dev.path }})
{{ formatVideoDeviceLabel(dev) }}
</SelectItem>
</SelectContent>
</Select>
@@ -801,7 +819,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</p>
<!-- Audio Device Selection -->
<div class="space-y-2 pt-2 border-t">
<div v-if="!isWindows" class="space-y-2 pt-2 border-t">
<div class="flex items-center gap-2">
<Label for="audioDevice">{{ t('setup.audioDevice') }}</Label>
<HoverCard>
@@ -882,7 +900,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
<SelectItem value="ch9329">
CH9329 ({{ t('setup.serialHid') }})
</SelectItem>
<SelectItem value="otg">USB OTG</SelectItem>
<SelectItem v-if="!isWindows" value="otg">USB OTG</SelectItem>
</SelectContent>
</Select>
</div>
@@ -927,7 +945,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
</div>
<!-- OTG Settings -->
<div v-if="hidBackend === 'otg'" class="space-y-4 p-4 rounded-lg bg-muted/50">
<div v-if="hidBackend === 'otg' && !isWindows" class="space-y-4 p-4 rounded-lg bg-muted/50">
<div class="flex items-start gap-2 text-sm text-muted-foreground mb-2">
<HelpCircle class="w-4 h-4 mt-0.5 shrink-0" />
<p>{{ t('setup.otgHelp') }}</p>