mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-31 18:11:54 +08:00
feat(rustdesk): 完整实现RustDesk协议和P2P连接
重大变更: - 从prost切换到protobuf 3.4实现完整的RustDesk协议栈 - 新增P2P打洞模块(punch.rs)支持直连和中继回退 - 重构加密系统:临时Curve25519密钥对+Ed25519签名 - 完善HID适配器:支持CapsLock状态同步和修饰键映射 - 添加音频流支持:Opus编码+音频帧适配器 - 优化视频流:改进帧适配器和编码器协商 - 移除pacer.rs简化视频管道 扩展系统: - 在设置向导中添加扩展步骤(ttyd/rustdesk切换) - 扩展可用性检测和自动启动 - 新增WebConfig handler用于Web服务器配置 前端改进: - SetupView增加第4步扩展配置 - 音频设备列表和配置界面 - 新增多语言支持(en-US/zh-CN) - TypeScript类型生成更新 文档: - 更新系统架构文档 - 完善config/hid/rustdesk/video/webrtc模块文档
This commit is contained in:
@@ -76,7 +76,8 @@ const videoErrorMessage = ref('')
|
||||
const videoRestarting = ref(false) // Track if video is restarting due to config change
|
||||
|
||||
// Video aspect ratio (dynamically updated from actual video dimensions)
|
||||
const videoAspectRatio = ref<number | null>(null)
|
||||
// Using string format "width/height" to let browser handle the ratio calculation
|
||||
const videoAspectRatio = ref<string | null>(null)
|
||||
|
||||
// Backend-provided FPS (received from WebSocket stream.stats_update events)
|
||||
const backendFps = ref(0)
|
||||
@@ -346,7 +347,7 @@ function handleVideoLoad() {
|
||||
// Update aspect ratio from MJPEG image dimensions
|
||||
const img = videoRef.value
|
||||
if (img && img.naturalWidth && img.naturalHeight) {
|
||||
videoAspectRatio.value = img.naturalWidth / img.naturalHeight
|
||||
videoAspectRatio.value = `${img.naturalWidth}/${img.naturalHeight}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,7 +1058,7 @@ watch(webrtc.stats, (stats) => {
|
||||
systemStore.setStreamOnline(true)
|
||||
// Update aspect ratio from WebRTC video dimensions
|
||||
if (stats.frameWidth && stats.frameHeight) {
|
||||
videoAspectRatio.value = stats.frameWidth / stats.frameHeight
|
||||
videoAspectRatio.value = `${stats.frameWidth}/${stats.frameHeight}`
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
@@ -1804,7 +1805,7 @@ onUnmounted(() => {
|
||||
ref="videoContainerRef"
|
||||
class="relative bg-black overflow-hidden flex items-center justify-center"
|
||||
:style="{
|
||||
aspectRatio: videoAspectRatio ? String(videoAspectRatio) : '16/9',
|
||||
aspectRatio: videoAspectRatio ?? '16/9',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
minWidth: '320px',
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
atxConfigApi,
|
||||
extensionsApi,
|
||||
rustdeskConfigApi,
|
||||
webConfigApi,
|
||||
systemApi,
|
||||
type EncoderBackendInfo,
|
||||
type User as UserType,
|
||||
type RustDeskConfigResponse,
|
||||
type RustDeskStatusResponse,
|
||||
type RustDeskPasswordResponse,
|
||||
type WebConfig,
|
||||
} from '@/api'
|
||||
import type {
|
||||
ExtensionsStatus,
|
||||
@@ -120,6 +123,7 @@ const navGroups = computed(() => [
|
||||
{
|
||||
title: t('settings.system'),
|
||||
items: [
|
||||
{ id: 'web-server', label: t('settings.webServer'), icon: Globe },
|
||||
{ id: 'users', label: t('settings.users'), icon: Users },
|
||||
{ id: 'about', label: t('settings.about'), icon: Info },
|
||||
]
|
||||
@@ -186,8 +190,20 @@ const rustdeskLocalConfig = ref({
|
||||
enabled: false,
|
||||
rendezvous_server: '',
|
||||
relay_server: '',
|
||||
relay_key: '',
|
||||
})
|
||||
|
||||
// Web server config state
|
||||
const webServerConfig = ref<WebConfig>({
|
||||
http_port: 8080,
|
||||
https_port: 8443,
|
||||
bind_address: '0.0.0.0',
|
||||
https_enabled: false,
|
||||
})
|
||||
const webServerLoading = ref(false)
|
||||
const showRestartDialog = ref(false)
|
||||
const restarting = ref(false)
|
||||
|
||||
// Config
|
||||
interface DeviceConfig {
|
||||
video: Array<{
|
||||
@@ -236,6 +252,19 @@ const config = ref({
|
||||
// 跟踪服务器是否已配置 TURN 密码
|
||||
const hasTurnPassword = ref(false)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// ATX config state
|
||||
const atxConfig = ref({
|
||||
enabled: false,
|
||||
@@ -456,13 +485,22 @@ async function saveConfig() {
|
||||
|
||||
// HID 配置
|
||||
if (activeSection.value === 'hid') {
|
||||
savePromises.push(
|
||||
hidConfigApi.update({
|
||||
backend: config.value.hid_backend as any,
|
||||
ch9329_port: config.value.hid_serial_device || undefined,
|
||||
ch9329_baudrate: config.value.hid_serial_baudrate,
|
||||
})
|
||||
)
|
||||
const hidUpdate: any = {
|
||||
backend: config.value.hid_backend as any,
|
||||
ch9329_port: config.value.hid_serial_device || undefined,
|
||||
ch9329_baudrate: config.value.hid_serial_baudrate,
|
||||
}
|
||||
// 如果是 OTG 后端,添加描述符配置
|
||||
if (config.value.hid_backend === 'otg') {
|
||||
hidUpdate.otg_descriptor = {
|
||||
vendor_id: parseInt(otgVendorIdHex.value, 16) || 0x1d6b,
|
||||
product_id: parseInt(otgProductIdHex.value, 16) || 0x0104,
|
||||
manufacturer: otgManufacturer.value || 'One-KVM',
|
||||
product: otgProduct.value || 'One-KVM USB Device',
|
||||
serial_number: otgSerialNumber.value || undefined,
|
||||
}
|
||||
}
|
||||
savePromises.push(hidConfigApi.update(hidUpdate))
|
||||
}
|
||||
|
||||
// MSD 配置
|
||||
@@ -517,6 +555,15 @@ async function loadConfig() {
|
||||
// 设置是否已配置 TURN 密码
|
||||
hasTurnPassword.value = stream.has_turn_password || false
|
||||
|
||||
// 加载 OTG 描述符配置
|
||||
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'
|
||||
otgManufacturer.value = hid.otg_descriptor.manufacturer || 'One-KVM'
|
||||
otgProduct.value = hid.otg_descriptor.product || 'One-KVM USB Device'
|
||||
otgSerialNumber.value = hid.otg_descriptor.serial_number || ''
|
||||
}
|
||||
|
||||
// 加载 web config(仍使用旧 API)
|
||||
try {
|
||||
const fullConfig = await configApi.get()
|
||||
@@ -806,6 +853,7 @@ async function loadRustdeskConfig() {
|
||||
enabled: config.enabled,
|
||||
rendezvous_server: config.rendezvous_server,
|
||||
relay_server: config.relay_server || '',
|
||||
relay_key: '',
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load RustDesk config:', e)
|
||||
@@ -822,6 +870,47 @@ async function loadRustdeskPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
// Web server config functions
|
||||
async function loadWebServerConfig() {
|
||||
try {
|
||||
const config = await webConfigApi.get()
|
||||
webServerConfig.value = config
|
||||
} catch (e) {
|
||||
console.error('Failed to load web server config:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWebServerConfig() {
|
||||
webServerLoading.value = true
|
||||
try {
|
||||
await webConfigApi.update(webServerConfig.value)
|
||||
showRestartDialog.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to save web server config:', e)
|
||||
} finally {
|
||||
webServerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
restarting.value = true
|
||||
try {
|
||||
await systemApi.restart()
|
||||
// Wait for server to restart, then reload page
|
||||
setTimeout(() => {
|
||||
const protocol = webServerConfig.value.https_enabled ? 'https' : 'http'
|
||||
const port = webServerConfig.value.https_enabled
|
||||
? webServerConfig.value.https_port
|
||||
: webServerConfig.value.http_port
|
||||
const newUrl = `${protocol}://${window.location.hostname}:${port}`
|
||||
window.location.href = newUrl
|
||||
}, 3000)
|
||||
} catch (e) {
|
||||
console.error('Failed to restart server:', e)
|
||||
restarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRustdeskConfig() {
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
@@ -830,8 +919,11 @@ async function saveRustdeskConfig() {
|
||||
enabled: rustdeskLocalConfig.value.enabled,
|
||||
rendezvous_server: rustdeskLocalConfig.value.rendezvous_server || undefined,
|
||||
relay_server: rustdeskLocalConfig.value.relay_server || undefined,
|
||||
relay_key: rustdeskLocalConfig.value.relay_key || undefined,
|
||||
})
|
||||
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)
|
||||
} catch (e) {
|
||||
@@ -869,6 +961,34 @@ async function regenerateRustdeskPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startRustdesk() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
// Enable and save config to start the service
|
||||
await rustdeskConfigApi.update({ enabled: true })
|
||||
rustdeskLocalConfig.value.enabled = true
|
||||
await loadRustdeskConfig()
|
||||
} catch (e) {
|
||||
console.error('Failed to start RustDesk:', e)
|
||||
} finally {
|
||||
rustdeskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRustdesk() {
|
||||
rustdeskLoading.value = true
|
||||
try {
|
||||
// Disable and save config to stop the service
|
||||
await rustdeskConfigApi.update({ enabled: false })
|
||||
rustdeskLocalConfig.value.enabled = false
|
||||
await loadRustdeskConfig()
|
||||
} catch (e) {
|
||||
console.error('Failed to stop RustDesk:', e)
|
||||
} finally {
|
||||
rustdeskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, type: 'id' | 'password') {
|
||||
const success = await clipboardCopy(text)
|
||||
if (success) {
|
||||
@@ -941,6 +1061,7 @@ onMounted(async () => {
|
||||
loadAtxDevices(),
|
||||
loadRustdeskConfig(),
|
||||
loadRustdeskPassword(),
|
||||
loadWebServerConfig(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
@@ -1224,6 +1345,114 @@ onMounted(async () => {
|
||||
<option :value="115200">115200</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- OTG Descriptor Settings -->
|
||||
<template v-if="config.hid_backend === 'otg'">
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">{{ t('settings.otgDescriptor') }}</h4>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.otgDescriptorDesc') }}</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="otg-vid">{{ t('settings.vendorId') }}</Label>
|
||||
<Input
|
||||
id="otg-vid"
|
||||
v-model="otgVendorIdHex"
|
||||
placeholder="1d6b"
|
||||
maxlength="4"
|
||||
@input="validateHex($event, 'vid')"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="otg-pid">{{ t('settings.productId') }}</Label>
|
||||
<Input
|
||||
id="otg-pid"
|
||||
v-model="otgProductIdHex"
|
||||
placeholder="0104"
|
||||
maxlength="4"
|
||||
@input="validateHex($event, 'pid')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="otg-manufacturer">{{ t('settings.manufacturer') }}</Label>
|
||||
<Input
|
||||
id="otg-manufacturer"
|
||||
v-model="otgManufacturer"
|
||||
placeholder="One-KVM"
|
||||
maxlength="126"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="otg-product">{{ t('settings.productName') }}</Label>
|
||||
<Input
|
||||
id="otg-product"
|
||||
v-model="otgProduct"
|
||||
placeholder="One-KVM USB Device"
|
||||
maxlength="126"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="otg-serial">{{ t('settings.serialNumber') }}</Label>
|
||||
<Input
|
||||
id="otg-serial"
|
||||
v-model="otgSerialNumber"
|
||||
:placeholder="t('settings.serialNumberAuto')"
|
||||
maxlength="126"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">
|
||||
{{ t('settings.descriptorWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Web Server Section -->
|
||||
<div v-show="activeSection === 'web-server'" class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.webServer') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.webServerDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-0.5">
|
||||
<Label>{{ t('settings.httpsEnabled') }}</Label>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.httpsEnabledDesc') }}</p>
|
||||
</div>
|
||||
<Switch v-model="webServerConfig.https_enabled" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.httpPort') }}</Label>
|
||||
<Input v-model.number="webServerConfig.http_port" type="number" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.httpsPort') }}</Label>
|
||||
<Input v-model.number="webServerConfig.https_port" type="number" min="1" max="65535" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('settings.bindAddress') }}</Label>
|
||||
<Input v-model="webServerConfig.bind_address" placeholder="0.0.0.0" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.bindAddressDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ t('common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1795,7 +2024,7 @@ onMounted(async () => {
|
||||
<CardDescription>{{ t('extensions.rustdesk.desc') }}</CardDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge :variant="rustdeskStatus?.service_status === 'Running' ? 'default' : 'secondary'">
|
||||
<Badge :variant="rustdeskStatus?.service_status === 'running' ? 'default' : 'secondary'">
|
||||
{{ getRustdeskServiceStatusText(rustdeskStatus?.service_status) }}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="loadRustdeskConfig" :disabled="rustdeskLoading">
|
||||
@@ -1816,6 +2045,27 @@ onMounted(async () => {
|
||||
<span class="text-sm text-muted-foreground">{{ getRustdeskRendezvousStatusText(rustdeskStatus?.rendezvous_status) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="rustdeskStatus?.service_status !== 'running'"
|
||||
size="sm"
|
||||
@click="startRustdesk"
|
||||
:disabled="rustdeskLoading"
|
||||
>
|
||||
<Play class="h-4 w-4 mr-1" />
|
||||
{{ t('extensions.start') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="stopRustdesk"
|
||||
:disabled="rustdeskLoading"
|
||||
>
|
||||
<Square class="h-4 w-4 mr-1" />
|
||||
{{ t('extensions.stop') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
@@ -1866,6 +2116,17 @@ onMounted(async () => {
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayServerHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label class="text-right">{{ t('extensions.rustdesk.relayKey') }}</Label>
|
||||
<div class="col-span-3 space-y-1">
|
||||
<Input
|
||||
v-model="rustdeskLocalConfig.relay_key"
|
||||
type="password"
|
||||
:placeholder="rustdeskStatus?.config?.has_relay_key ? t('extensions.rustdesk.relayKeySet') : t('extensions.rustdesk.relayKeyPlaceholder')"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayKeyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
@@ -2128,5 +2389,26 @@ onMounted(async () => {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Restart Confirmation Dialog -->
|
||||
<Dialog v-model:open="showRestartDialog">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('settings.restartRequired') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground py-4">
|
||||
{{ t('settings.restartMessage') }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showRestartDialog = false" :disabled="restarting">
|
||||
{{ t('common.later') }}
|
||||
</Button>
|
||||
<Button @click="restartServer" :disabled="restarting">
|
||||
<RefreshCw v-if="restarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||
{{ restarting ? t('settings.restarting') : t('common.restartNow') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Monitor,
|
||||
Eye,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
Check,
|
||||
HelpCircle,
|
||||
Languages,
|
||||
Puzzle,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -58,9 +60,9 @@ function switchLanguage(lang: SupportedLocale) {
|
||||
setLanguage(lang)
|
||||
}
|
||||
|
||||
// Steps: 1 = Account, 2 = Video, 3 = HID
|
||||
// Steps: 1 = Account, 2 = Audio/Video, 3 = HID, 4 = Extensions
|
||||
const step = ref(1)
|
||||
const totalSteps = 3
|
||||
const totalSteps = 4
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const slideDirection = ref<'forward' | 'backward'>('forward')
|
||||
@@ -85,12 +87,22 @@ 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)
|
||||
const otgUdc = ref('')
|
||||
|
||||
// Extension settings
|
||||
const ttydEnabled = ref(false)
|
||||
const rustdeskEnabled = ref(false)
|
||||
const ttydAvailable = ref(false)
|
||||
const rustdeskAvailable = ref(true) // RustDesk is built-in, always available
|
||||
|
||||
// Encoder backend settings
|
||||
const encoderBackend = ref('auto')
|
||||
const availableBackends = ref<EncoderBackendInfo[]>([])
|
||||
@@ -110,13 +122,25 @@ interface VideoDeviceInfo {
|
||||
fps: number[]
|
||||
}>
|
||||
}>
|
||||
usb_bus: string | null
|
||||
}
|
||||
|
||||
interface AudioDeviceInfo {
|
||||
name: string
|
||||
description: string
|
||||
is_hdmi: boolean
|
||||
usb_bus: string | null
|
||||
}
|
||||
|
||||
interface DeviceInfo {
|
||||
video: VideoDeviceInfo[]
|
||||
serial: Array<{ path: string; name: string }>
|
||||
audio: Array<{ name: string; description: string }>
|
||||
audio: AudioDeviceInfo[]
|
||||
udc: Array<{ name: string }>
|
||||
extensions: {
|
||||
ttyd_available: boolean
|
||||
rustdesk_available: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const devices = ref<DeviceInfo>({
|
||||
@@ -124,6 +148,10 @@ const devices = ref<DeviceInfo>({
|
||||
serial: [],
|
||||
audio: [],
|
||||
udc: [],
|
||||
extensions: {
|
||||
ttyd_available: false,
|
||||
rustdesk_available: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Password strength calculation
|
||||
@@ -182,8 +210,9 @@ const baudRates = [9600, 19200, 38400, 57600, 115200]
|
||||
// Step labels for the indicator
|
||||
const stepLabels = computed(() => [
|
||||
t('setup.stepAccount'),
|
||||
t('setup.stepVideo'),
|
||||
t('setup.stepAudioVideo'),
|
||||
t('setup.stepHid'),
|
||||
t('setup.stepExtensions'),
|
||||
])
|
||||
|
||||
// Real-time validation functions
|
||||
@@ -224,8 +253,8 @@ function validateConfirmPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch video device change to auto-select first format
|
||||
watch(videoDevice, () => {
|
||||
// Watch video device change to auto-select first format and matching audio device
|
||||
watch(videoDevice, (newDevice) => {
|
||||
videoFormat.value = ''
|
||||
videoResolution.value = ''
|
||||
videoFps.value = null
|
||||
@@ -234,6 +263,28 @@ watch(videoDevice, () => {
|
||||
const mjpeg = availableFormats.value.find((f) => f.format.toUpperCase().includes('MJPEG'))
|
||||
videoFormat.value = mjpeg?.format || availableFormats.value[0]?.format || ''
|
||||
}
|
||||
|
||||
// Auto-select matching audio device based on USB bus
|
||||
if (newDevice && audioEnabled.value) {
|
||||
const video = devices.value.video.find((d) => d.path === newDevice)
|
||||
if (video?.usb_bus) {
|
||||
// Find audio device on the same USB bus
|
||||
const matchedAudio = devices.value.audio.find(
|
||||
(a) => a.usb_bus && a.usb_bus === video.usb_bus
|
||||
)
|
||||
if (matchedAudio) {
|
||||
audioDevice.value = matchedAudio.name
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fallback: select first HDMI audio device
|
||||
const hdmiAudio = devices.value.audio.find((a) => a.is_hdmi)
|
||||
if (hdmiAudio) {
|
||||
audioDevice.value = hdmiAudio.name
|
||||
} else if (devices.value.audio.length > 0 && devices.value.audio[0]) {
|
||||
audioDevice.value = devices.value.audio[0].name
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch format change to auto-select best resolution
|
||||
@@ -289,6 +340,19 @@ onMounted(async () => {
|
||||
if (result.udc.length > 0 && result.udc[0]) {
|
||||
otgUdc.value = result.udc[0].name
|
||||
}
|
||||
|
||||
// Auto-select audio device if available (and no video device to trigger watch)
|
||||
if (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 || ''
|
||||
}
|
||||
|
||||
// Set extension availability from devices API
|
||||
if (result.extensions) {
|
||||
ttydAvailable.value = result.extensions.ttyd_available
|
||||
rustdeskAvailable.value = result.extensions.rustdesk_available
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
@@ -435,6 +499,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
|
||||
setupData.rustdesk_enabled = rustdeskEnabled.value
|
||||
|
||||
const success = await authStore.setup(setupData)
|
||||
|
||||
if (success) {
|
||||
@@ -449,7 +522,7 @@ async function handleSetup() {
|
||||
}
|
||||
|
||||
// Step icon component helper
|
||||
const stepIcons = [User, Video, Keyboard]
|
||||
const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -615,9 +688,9 @@ const stepIcons = [User, Video, Keyboard]
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Video Settings -->
|
||||
<!-- Step 2: Audio/Video Settings -->
|
||||
<div v-else-if="step === 2" key="step2" class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-center">{{ t('setup.stepVideo') }}</h3>
|
||||
<h3 class="text-lg font-medium text-center">{{ t('setup.stepAudioVideo') }}</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -709,6 +782,38 @@ const stepIcons = [User, Video, Keyboard]
|
||||
{{ t('setup.noVideoDevices') }}
|
||||
</p>
|
||||
|
||||
<!-- Audio Device Selection -->
|
||||
<div class="space-y-2 pt-2 border-t">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="audioDevice">{{ t('setup.audioDevice') }}</Label>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger as-child>
|
||||
<button type="button" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<HelpCircle class="w-4 h-4" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-64 text-sm">
|
||||
{{ t('setup.audioDeviceHelp') }}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<Select v-model="audioDevice" :disabled="!audioEnabled">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('setup.selectAudioDevice')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{{ t('setup.noAudio') }}</SelectItem>
|
||||
<SelectItem v-for="dev in devices.audio" :key="dev.name" :value="dev.name">
|
||||
{{ dev.description }}
|
||||
<span v-if="dev.is_hdmi" class="text-xs text-muted-foreground ml-1">(HDMI)</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!devices.audio.length" class="text-xs text-muted-foreground">
|
||||
{{ t('setup.noAudioDevices') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced: Encoder Backend (Collapsible) -->
|
||||
<div class="mt-4 border rounded-lg">
|
||||
<button
|
||||
@@ -827,6 +932,47 @@ const stepIcons = [User, Video, Keyboard]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Extensions Settings -->
|
||||
<div v-else-if="step === 4" key="step4" class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-center">{{ t('setup.stepExtensions') }}</h3>
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{{ t('setup.extensionsDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- ttyd -->
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border" :class="{ 'opacity-50': !ttydAvailable }">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-base font-medium">{{ t('setup.ttydTitle') }}</Label>
|
||||
<span v-if="!ttydAvailable" class="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
{{ t('setup.notInstalled') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('setup.ttydDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="ttydEnabled" :disabled="!ttydAvailable" />
|
||||
</div>
|
||||
|
||||
<!-- RustDesk -->
|
||||
<div class="flex items-center justify-between p-4 rounded-lg border">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-base font-medium">{{ t('setup.rustdeskTitle') }}</Label>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('setup.rustdeskDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="rustdeskEnabled" />
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground text-center pt-2">
|
||||
{{ t('setup.extensionsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Error Message -->
|
||||
|
||||
Reference in New Issue
Block a user