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:
mofeng-git
2026-01-03 19:34:07 +08:00
parent cb7d9882a2
commit 0c82d1a840
49 changed files with 5470 additions and 1983 deletions

View File

@@ -270,6 +270,7 @@ export interface RustDeskConfigResponse {
device_id: string
has_password: boolean
has_keypair: boolean
has_relay_key: boolean
using_public_server: boolean
}
@@ -286,6 +287,7 @@ export interface RustDeskConfigUpdate {
enabled?: boolean
rendezvous_server?: string
relay_server?: string
relay_key?: string
device_password?: string
}
@@ -336,3 +338,49 @@ export const rustdeskConfigApi = {
method: 'POST',
}),
}
// ===== Web 服务器配置 API =====
/** Web 服务器配置 */
export interface WebConfig {
http_port: number
https_port: number
bind_address: string
https_enabled: boolean
}
/** Web 服务器配置更新 */
export interface WebConfigUpdate {
http_port?: number
https_port?: number
bind_address?: string
https_enabled?: boolean
}
export const webConfigApi = {
/**
* 获取 Web 服务器配置
*/
get: () => request<WebConfig>('/config/web'),
/**
* 更新 Web 服务器配置
*/
update: (config: WebConfigUpdate) =>
request<WebConfig>('/config/web', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== 系统控制 API =====
export const systemApi = {
/**
* 重启系统
*/
restart: () =>
request<{ success: boolean; message?: string }>('/system/restart', {
method: 'POST',
}),
}

View File

@@ -161,11 +161,19 @@ export const systemApi = {
hid_ch9329_baudrate?: number
hid_otg_udc?: string
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
rustdesk_enabled?: boolean
}) =>
request<{ success: boolean; message?: string }>('/setup/init', {
method: 'POST',
body: JSON.stringify(data),
}),
restart: () =>
request<{ success: boolean; message?: string }>('/system/restart', {
method: 'POST',
}),
}
// Stream API
@@ -577,10 +585,20 @@ export const configApi = {
fps: number[]
}>
}>
usb_bus: string | null
}>
serial: Array<{ path: string; name: string }>
audio: Array<{ name: string; description: string }>
audio: Array<{
name: string
description: string
is_hdmi: boolean
usb_bus: string | null
}>
udc: Array<{ name: string }>
extensions: {
ttyd_available: boolean
rustdesk_available: boolean
}
}>('/devices'),
}
@@ -594,10 +612,12 @@ export {
audioConfigApi,
extensionsApi,
rustdeskConfigApi,
webConfigApi,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskConfigUpdate,
type RustDeskPasswordResponse,
type WebConfig,
} from './config'
// 导出生成的类型

View File

@@ -19,6 +19,8 @@ export default {
off: 'Off',
enabled: 'Enabled',
disabled: 'Disabled',
later: 'Later',
restartNow: 'Restart Now',
connected: 'Connected',
disconnected: 'Disconnected',
connecting: 'Connecting...',
@@ -202,6 +204,7 @@ export default {
// Step titles
stepAccount: 'Account Setup',
stepVideo: 'Video Setup',
stepAudioVideo: 'Audio/Video Setup',
stepHid: 'HID Setup',
// Account
setUsername: 'Set Admin Username',
@@ -220,6 +223,12 @@ export default {
fps: 'Frame Rate',
selectFps: 'Select FPS',
noVideoDevices: 'No video devices detected',
// 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',
@@ -249,6 +258,15 @@ export default {
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
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',
rustdeskTitle: 'RustDesk Remote Desktop',
rustdeskDescription: 'Remote access via RustDesk client',
extensionsHint: 'These settings can be changed later in Settings',
notInstalled: 'Not installed',
// Password strength
passwordStrength: 'Password Strength',
passwordWeak: 'Weak',
@@ -436,7 +454,7 @@ export default {
buildInfo: 'Build Info',
detectDevices: 'Detect Devices',
detecting: 'Detecting...',
builtWith: 'Built with Rust + Vue 3 + shadcn-vue',
builtWith: "Copyright {'@'}2025 SilentWind",
networkSettings: 'Network Settings',
msdSettings: 'MSD Settings',
atxSettings: 'ATX Settings',
@@ -444,6 +462,17 @@ export default {
httpSettings: 'HTTP Settings',
httpPort: 'HTTP Port',
configureHttpPort: 'Configure HTTP server port',
// Web server
webServer: 'Basic',
webServerDesc: 'Configure HTTP/HTTPS ports and bind address. Restart required for changes to take effect.',
httpsPort: 'HTTPS Port',
bindAddress: 'Bind Address',
bindAddressDesc: 'IP address the server listens on. 0.0.0.0 means all network interfaces.',
httpsEnabled: 'Enable HTTPS',
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
restartRequired: 'Restart Required',
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
restarting: 'Restarting...',
// User management
userManagement: 'User Management',
userManagementDesc: 'Manage user accounts and permissions',
@@ -528,6 +557,16 @@ export default {
hidBackend: 'HID Backend',
serialDevice: 'Serial Device',
baudRate: 'Baud Rate',
// OTG Descriptor
otgDescriptor: 'USB Device Descriptor',
otgDescriptorDesc: 'Configure USB device identification',
vendorId: 'Vendor ID (VID)',
productId: 'Product ID (PID)',
manufacturer: 'Manufacturer',
productName: 'Product Name',
serialNumber: 'Serial Number',
serialNumberAuto: 'Auto-generated',
descriptorWarning: 'Changing these settings will reconnect the USB device',
// WebRTC / ICE
webrtcSettings: 'WebRTC Settings',
webrtcSettingsDesc: 'Configure STUN/TURN servers for NAT traversal',
@@ -626,7 +665,7 @@ export default {
binaryNotFound: '{path} not found, please install the required program',
// ttyd
ttyd: {
title: 'Web Terminal',
title: 'Ttyd Web Terminal',
desc: 'Web terminal access via ttyd',
open: 'Open Terminal',
openInNewTab: 'Open in New Tab',
@@ -636,7 +675,7 @@ export default {
},
// gostc
gostc: {
title: 'NAT Traversal',
title: 'GOSTC NAT Traversal',
desc: 'NAT traversal via GOSTC',
addr: 'Server Address',
key: 'Client Key',
@@ -644,7 +683,7 @@ export default {
},
// easytier
easytier: {
title: 'P2P Network',
title: 'Easytier Network',
desc: 'P2P VPN networking via EasyTier',
networkName: 'Network Name',
networkSecret: 'Network Secret',
@@ -664,6 +703,10 @@ export default {
relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: 'Relay server address, auto-derived from ID server if empty',
relayKey: 'Relay Key',
relayKeyPlaceholder: 'Enter relay server key',
relayKeySet: '••••••••',
relayKeyHint: 'Authentication key for relay server (if server uses -k option)',
publicServerInfo: 'Public Server Info',
publicServerAddress: 'Server Address',
publicServerKey: 'Connection Key',

View File

@@ -18,6 +18,8 @@ export default {
on: '开',
off: '关',
enabled: '已启用',
later: '稍后',
restartNow: '立即重启',
disabled: '已禁用',
connected: '已连接',
disconnected: '已断开',
@@ -202,6 +204,7 @@ export default {
// Step titles
stepAccount: '账号设置',
stepVideo: '视频设置',
stepAudioVideo: '音视频设置',
stepHid: '鼠键设置',
// Account
setUsername: '设置管理员用户名',
@@ -220,6 +223,12 @@ export default {
fps: '帧率',
selectFps: '选择帧率',
noVideoDevices: '未检测到视频设备',
// Audio
audioDevice: '音频设备',
selectAudioDevice: '选择音频采集设备',
noAudio: '不使用音频',
noAudioDevices: '未检测到音频设备',
audioDeviceHelp: '选择用于捕获远程主机音频的设备。通常与视频采集卡在同一 USB 设备上。',
// HID
hidBackend: 'HID 后端',
selectHidBackend: '选择 HID 控制方式',
@@ -249,6 +258,15 @@ export default {
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
videoFormatHelp: 'MJPEG 格式兼容性最好H.264/H.265 带宽占用更低但需要编码支持。',
// Extensions
stepExtensions: '扩展设置',
extensionsDescription: '选择要自动启动的扩展服务',
ttydTitle: 'Web 终端 (ttyd)',
ttydDescription: '在浏览器中访问设备的命令行终端',
rustdeskTitle: 'RustDesk 远程桌面',
rustdeskDescription: '通过 RustDesk 客户端远程访问设备',
extensionsHint: '这些设置可以在设置页面中随时更改',
notInstalled: '未安装',
// Password strength
passwordStrength: '密码强度',
passwordWeak: '弱',
@@ -436,7 +454,7 @@ export default {
buildInfo: '构建信息',
detectDevices: '探测设备',
detecting: '探测中...',
builtWith: '基于 Rust + Vue 3 + shadcn-vue 构建',
builtWith: "版权信息 {'@'}2025 SilentWind",
networkSettings: '网络设置',
msdSettings: 'MSD 设置',
atxSettings: 'ATX 设置',
@@ -444,6 +462,17 @@ export default {
httpSettings: 'HTTP 设置',
httpPort: 'HTTP 端口',
configureHttpPort: '配置 HTTP 服务器端口',
// Web server
webServer: '基础',
webServerDesc: '配置 HTTP/HTTPS 端口和绑定地址,修改后需要重启生效',
httpsPort: 'HTTPS 端口',
bindAddress: '绑定地址',
bindAddressDesc: '服务器监听的 IP 地址0.0.0.0 表示监听所有网络接口',
httpsEnabled: '启用 HTTPS',
httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签名证书)',
restartRequired: '需要重启',
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
restarting: '正在重启...',
// User management
userManagement: '用户管理',
userManagementDesc: '管理用户账号和权限',
@@ -528,6 +557,16 @@ export default {
hidBackend: 'HID 后端',
serialDevice: '串口设备',
baudRate: '波特率',
// OTG Descriptor
otgDescriptor: 'USB 设备描述符',
otgDescriptorDesc: '配置 USB 设备标识信息',
vendorId: '厂商 ID (VID)',
productId: '产品 ID (PID)',
manufacturer: '制造商',
productName: '产品名称',
serialNumber: '序列号',
serialNumberAuto: '自动生成',
descriptorWarning: '修改这些设置将导致 USB 设备重新连接',
// WebRTC / ICE
webrtcSettings: 'WebRTC 设置',
webrtcSettingsDesc: '配置 STUN/TURN 服务器以实现 NAT 穿透',
@@ -626,7 +665,7 @@ export default {
binaryNotFound: '未找到 {path},请先安装对应程序',
// ttyd
ttyd: {
title: '网页终端',
title: 'Ttyd 网页终端',
desc: '通过 ttyd 提供网页终端访问',
open: '打开终端',
openInNewTab: '在新标签页打开',
@@ -636,7 +675,7 @@ export default {
},
// gostc
gostc: {
title: '内网穿透',
title: 'GOSTC 内网穿透',
desc: '通过 GOSTC 实现内网穿透',
addr: '服务器地址',
key: '客户端密钥',
@@ -644,7 +683,7 @@ export default {
},
// easytier
easytier: {
title: 'P2P 组网',
title: 'Easytier 组网',
desc: '通过 EasyTier 实现 P2P VPN 组网',
networkName: '网络名称',
networkSecret: '网络密钥',
@@ -664,6 +703,10 @@ export default {
relayServer: '中继服务器',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导',
relayKey: '中继密钥',
relayKeyPlaceholder: '输入中继服务器密钥',
relayKeySet: '••••••••',
relayKeyHint: '中继服务器认证密钥(如果服务器使用 -k 选项)',
publicServerInfo: '公共服务器信息',
publicServerAddress: '服务器地址',
publicServerKey: '连接密钥',

View File

@@ -92,6 +92,9 @@ export const useAuthStore = defineStore('auth', () => {
hid_ch9329_baudrate?: number
hid_otg_udc?: string
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
rustdesk_enabled?: boolean
}) {
loading.value = true
error.value = null

View File

@@ -38,6 +38,20 @@ export enum HidBackend {
None = "none",
}
/** OTG USB device descriptor configuration */
export interface OtgDescriptorConfig {
/** USB Vendor ID (e.g., 0x1d6b) */
vendor_id: number;
/** USB Product ID (e.g., 0x0104) */
product_id: number;
/** Manufacturer string */
manufacturer: string;
/** Product string */
product: string;
/** Serial number (optional, auto-generated if not set) */
serial_number?: string;
}
/** HID configuration */
export interface HidConfig {
/** HID backend type */
@@ -48,6 +62,8 @@ export interface HidConfig {
otg_mouse: string;
/** OTG UDC (USB Device Controller) name */
otg_udc?: string;
/** OTG USB device descriptor configuration */
otg_descriptor?: OtgDescriptorConfig;
/** CH9329 serial port */
ch9329_port: string;
/** CH9329 baud rate */
@@ -470,11 +486,21 @@ export interface GostcConfigUpdate {
tls?: boolean;
}
/** OTG USB device descriptor configuration update */
export interface OtgDescriptorConfigUpdate {
vendor_id?: number;
product_id?: number;
manufacturer?: string;
product?: string;
serial_number?: string;
}
export interface HidConfigUpdate {
backend?: HidBackend;
ch9329_port?: string;
ch9329_baudrate?: number;
otg_udc?: string;
otg_descriptor?: OtgDescriptorConfigUpdate;
mouse_absolute?: boolean;
}
@@ -497,6 +523,7 @@ export interface RustDeskConfigUpdate {
enabled?: boolean;
rendezvous_server?: string;
relay_server?: string;
relay_key?: string;
device_password?: string;
}
@@ -549,3 +576,10 @@ export interface VideoConfigUpdate {
quality?: number;
}
export interface WebConfigUpdate {
http_port?: number;
https_port?: number;
bind_address?: string;
https_enabled?: boolean;
}

View File

@@ -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',

View File

@@ -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>

View File

@@ -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 -->