feat: 添加 RustDesk 协议支持和项目文档

- 新增 RustDesk 模块,支持与 RustDesk 客户端连接
  - 实现会合服务器协议和 P2P 连接
  - 支持 NaCl 加密和密钥交换
  - 添加视频帧和 HID 事件适配器
- 添加 Protobuf 协议定义 (message.proto, rendezvous.proto)
- 新增完整项目文档
  - 各功能模块文档 (video, hid, msd, otg, webrtc 等)
  - hwcodec 和 RustDesk 协议技术报告
  - 系统架构和技术栈文档
- 更新 Web 前端 RustDesk 配置界面和 API
This commit is contained in:
mofeng-git
2025-12-31 18:59:52 +08:00
parent 61323a7664
commit a8a3b6c66b
57 changed files with 20830 additions and 0 deletions

View File

@@ -253,3 +253,78 @@ export const extensionsApi = {
body: JSON.stringify(config),
}),
}
// ===== RustDesk 配置 API =====
/** RustDesk 配置响应 */
export interface RustDeskConfigResponse {
enabled: boolean
rendezvous_server: string
relay_server: string | null
device_id: string
has_password: boolean
has_keypair: boolean
}
/** RustDesk 状态响应 */
export interface RustDeskStatusResponse {
config: RustDeskConfigResponse
service_status: string
rendezvous_status: string | null
}
/** RustDesk 配置更新 */
export interface RustDeskConfigUpdate {
enabled?: boolean
rendezvous_server?: string
relay_server?: string
device_password?: string
}
/** RustDesk 密码响应 */
export interface RustDeskPasswordResponse {
device_id: string
device_password: string
}
export const rustdeskConfigApi = {
/**
* 获取 RustDesk 配置
*/
get: () => request<RustDeskConfigResponse>('/config/rustdesk'),
/**
* 更新 RustDesk 配置
*/
update: (config: RustDeskConfigUpdate) =>
request<RustDeskConfigResponse>('/config/rustdesk', {
method: 'PATCH',
body: JSON.stringify(config),
}),
/**
* 获取 RustDesk 完整状态
*/
getStatus: () => request<RustDeskStatusResponse>('/config/rustdesk/status'),
/**
* 获取设备密码(管理员专用)
*/
getPassword: () => request<RustDeskPasswordResponse>('/config/rustdesk/password'),
/**
* 重新生成设备 ID
*/
regenerateId: () =>
request<RustDeskConfigResponse>('/config/rustdesk/regenerate-id', {
method: 'POST',
}),
/**
* 重新生成设备密码
*/
regeneratePassword: () =>
request<RustDeskConfigResponse>('/config/rustdesk/regenerate-password', {
method: 'POST',
}),
}

View File

@@ -587,6 +587,11 @@ export {
atxConfigApi,
audioConfigApi,
extensionsApi,
rustdeskConfigApi,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskConfigUpdate,
type RustDeskPasswordResponse,
} from './config'
// 导出生成的类型

View File

@@ -604,6 +604,7 @@ export default {
available: 'Available',
unavailable: 'Unavailable',
running: 'Running',
starting: 'Starting',
stopped: 'Stopped',
failed: 'Failed',
start: 'Start',
@@ -641,6 +642,41 @@ export default {
virtualIp: 'Virtual IP',
virtualIpHint: 'Leave empty for DHCP, or specify with CIDR (e.g., 10.0.0.1/24)',
},
// rustdesk
rustdesk: {
title: 'RustDesk Remote',
desc: 'Remote access via RustDesk client',
serverSettings: 'Server Settings',
rendezvousServer: 'ID Server',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: 'RustDesk ID server address (required)',
relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: 'Relay server address, auto-derived from ID server if empty',
deviceInfo: 'Device Info',
deviceId: 'Device ID',
deviceIdHint: 'Use this ID in RustDesk client to connect',
devicePassword: 'Device Password',
showPassword: 'Show Password',
hidePassword: 'Hide Password',
regenerateId: 'Regenerate ID',
regeneratePassword: 'Regenerate Password',
confirmRegenerateId: 'Are you sure you want to regenerate the device ID? Existing clients will need to reconnect with the new ID.',
confirmRegeneratePassword: 'Are you sure you want to regenerate the password? Existing clients will need to reconnect with the new password.',
serviceStatus: 'Service Status',
rendezvousStatus: 'Registration Status',
registered: 'Registered',
connected: 'Connected',
disconnected: 'Disconnected',
connecting: 'Connecting',
notConfigured: 'Not Configured',
notInitialized: 'Not Initialized',
copyId: 'Copy ID',
copyPassword: 'Copy Password',
copied: 'Copied',
keypairGenerated: 'Keypair Generated',
noKeypair: 'No Keypair',
},
},
stats: {
title: 'Connection Stats',

View File

@@ -604,6 +604,7 @@ export default {
available: '可用',
unavailable: '不可用',
running: '运行中',
starting: '启动中',
stopped: '已停止',
failed: '启动失败',
start: '启动',
@@ -641,6 +642,41 @@ export default {
virtualIp: '虚拟 IP',
virtualIpHint: '留空则自动分配,手动指定需包含网段(如 10.0.0.1/24',
},
// rustdesk
rustdesk: {
title: 'RustDesk 远程',
desc: '使用 RustDesk 客户端进行远程访问',
serverSettings: '服务器设置',
rendezvousServer: 'ID 服务器',
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
rendezvousServerHint: 'RustDesk ID 服务器地址(必填)',
relayServer: '中继服务器',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayServerHint: '中继服务器地址,留空则自动从 ID 服务器推导',
deviceInfo: '设备信息',
deviceId: '设备 ID',
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
devicePassword: '设备密码',
showPassword: '显示密码',
hidePassword: '隐藏密码',
regenerateId: '重新生成 ID',
regeneratePassword: '重新生成密码',
confirmRegenerateId: '确定要重新生成设备 ID 吗?现有客户端需要使用新 ID 重新连接。',
confirmRegeneratePassword: '确定要重新生成设备密码吗?现有客户端需要使用新密码重新连接。',
serviceStatus: '服务状态',
rendezvousStatus: '注册状态',
registered: '已注册',
connected: '已连接',
disconnected: '未连接',
connecting: '连接中',
notConfigured: '未配置',
notInitialized: '未初始化',
copyId: '复制 ID',
copyPassword: '复制密码',
copied: '已复制',
keypairGenerated: '密钥对已生成',
noKeypair: '密钥对未生成',
},
},
stats: {
title: '连接统计',

View File

@@ -12,8 +12,12 @@ import {
msdConfigApi,
atxConfigApi,
extensionsApi,
rustdeskConfigApi,
type EncoderBackendInfo,
type User as UserType,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskPasswordResponse,
} from '@/api'
import type {
ExtensionsStatus,
@@ -66,6 +70,8 @@ import {
ChevronRight,
Plus,
ExternalLink,
Copy,
ScreenShare,
} from 'lucide-vue-next'
const { t, locale } = useI18n()
@@ -97,6 +103,7 @@ const navGroups = computed(() => [
{
title: t('settings.extensions'),
items: [
{ id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare },
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
{ id: 'ext-gostc', label: t('extensions.gostc.title'), icon: Globe },
{ id: 'ext-easytier', label: t('extensions.easytier.title'), icon: Network },
@@ -160,6 +167,18 @@ const extConfig = ref({
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
})
// RustDesk config state
const rustdeskConfig = ref<RustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<RustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<RustDeskPasswordResponse | null>(null)
const rustdeskLoading = ref(false)
const rustdeskCopied = ref<'id' | 'password' | null>(null)
const rustdeskLocalConfig = ref({
enabled: false,
rendezvous_server: '',
relay_server: '',
})
// Config
interface DeviceConfig {
video: Array<{
@@ -764,6 +783,135 @@ function getAtxDevicesForDriver(driver: string): string[] {
return []
}
// RustDesk management functions
async function loadRustdeskConfig() {
rustdeskLoading.value = true
try {
const [config, status] = await Promise.all([
rustdeskConfigApi.get(),
rustdeskConfigApi.getStatus(),
])
rustdeskConfig.value = config
rustdeskStatus.value = status
rustdeskLocalConfig.value = {
enabled: config.enabled,
rendezvous_server: config.rendezvous_server,
relay_server: config.relay_server || '',
}
} catch (e) {
console.error('Failed to load RustDesk config:', e)
} finally {
rustdeskLoading.value = false
}
}
async function loadRustdeskPassword() {
try {
rustdeskPassword.value = await rustdeskConfigApi.getPassword()
} catch (e) {
console.error('Failed to load RustDesk password:', e)
}
}
async function saveRustdeskConfig() {
loading.value = true
saved.value = false
try {
await rustdeskConfigApi.update({
enabled: rustdeskLocalConfig.value.enabled,
rendezvous_server: rustdeskLocalConfig.value.rendezvous_server || undefined,
relay_server: rustdeskLocalConfig.value.relay_server || undefined,
})
await loadRustdeskConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
} catch (e) {
console.error('Failed to save RustDesk config:', e)
} finally {
loading.value = false
}
}
async function regenerateRustdeskId() {
if (!confirm(t('extensions.rustdesk.confirmRegenerateId'))) return
rustdeskLoading.value = true
try {
await rustdeskConfigApi.regenerateId()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch (e) {
console.error('Failed to regenerate RustDesk ID:', e)
} finally {
rustdeskLoading.value = false
}
}
async function regenerateRustdeskPassword() {
if (!confirm(t('extensions.rustdesk.confirmRegeneratePassword'))) return
rustdeskLoading.value = true
try {
await rustdeskConfigApi.regeneratePassword()
await loadRustdeskConfig()
await loadRustdeskPassword()
} catch (e) {
console.error('Failed to regenerate RustDesk password:', e)
} finally {
rustdeskLoading.value = false
}
}
function copyToClipboard(text: string, type: 'id' | 'password') {
navigator.clipboard.writeText(text).then(() => {
rustdeskCopied.value = type
setTimeout(() => (rustdeskCopied.value = null), 2000)
})
}
function getRustdeskServiceStatusText(status: string | undefined): string {
if (!status) return t('extensions.rustdesk.notConfigured')
switch (status) {
case 'running': return t('extensions.running')
case 'starting': return t('extensions.starting')
case 'stopped': return t('extensions.stopped')
case 'not_initialized': return t('extensions.rustdesk.notInitialized')
default:
// Handle "error: xxx" format
if (status.startsWith('error:')) return t('extensions.failed')
return status
}
}
function getRustdeskRendezvousStatusText(status: string | null | undefined): string {
if (!status) return '-'
switch (status) {
case 'registered': return t('extensions.rustdesk.registered')
case 'connected': return t('extensions.rustdesk.connected')
case 'connecting': return t('extensions.rustdesk.connecting')
case 'disconnected': return t('extensions.rustdesk.disconnected')
default:
// Handle "error: xxx" format
if (status.startsWith('error:')) return t('extensions.failed')
return status
}
}
function getRustdeskStatusClass(status: string | null | undefined): string {
switch (status) {
case 'running':
case 'registered':
case 'connected': return 'bg-green-500'
case 'starting':
case 'connecting': return 'bg-yellow-500'
case 'stopped':
case 'not_initialized':
case 'disconnected': return 'bg-gray-400'
default:
// Handle "error: xxx" format
if (status?.startsWith('error:')) return 'bg-red-500'
return 'bg-gray-400'
}
}
// Lifecycle
onMounted(async () => {
// Load theme preference
@@ -781,6 +929,8 @@ onMounted(async () => {
loadExtensions(),
loadAtxConfig(),
loadAtxDevices(),
loadRustdeskConfig(),
loadRustdeskPassword(),
])
})
</script>
@@ -1625,6 +1775,137 @@ onMounted(async () => {
</div>
</div>
<!-- RustDesk Section -->
<div v-show="activeSection === 'ext-rustdesk'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div class="space-y-1.5">
<CardTitle>{{ t('extensions.rustdesk.title') }}</CardTitle>
<CardDescription>{{ t('extensions.rustdesk.desc') }}</CardDescription>
</div>
<div class="flex items-center gap-2">
<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">
<RefreshCw :class="['h-4 w-4', rustdeskLoading ? 'animate-spin' : '']" />
</Button>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- Status and controls -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div :class="['w-2 h-2 rounded-full', getRustdeskStatusClass(rustdeskStatus?.service_status)]" />
<span class="text-sm">{{ getRustdeskServiceStatusText(rustdeskStatus?.service_status) }}</span>
<template v-if="rustdeskStatus?.rendezvous_status">
<span class="text-muted-foreground">|</span>
<div :class="['w-2 h-2 rounded-full', getRustdeskStatusClass(rustdeskStatus?.rendezvous_status)]" />
<span class="text-sm text-muted-foreground">{{ getRustdeskRendezvousStatusText(rustdeskStatus?.rendezvous_status) }}</span>
</template>
</div>
</div>
<Separator />
<!-- Config -->
<div class="grid gap-4">
<div class="flex items-center justify-between">
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="rustdeskLocalConfig.enabled" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
<div class="col-span-3 space-y-1">
<Input
v-model="rustdeskLocalConfig.rendezvous_server"
:placeholder="t('extensions.rustdesk.rendezvousServerPlaceholder')"
/>
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.rendezvousServerHint') }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.relayServer') }}</Label>
<div class="col-span-3 space-y-1">
<Input
v-model="rustdeskLocalConfig.relay_server"
:placeholder="t('extensions.rustdesk.relayServerPlaceholder')"
/>
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayServerHint') }}</p>
</div>
</div>
</div>
<Separator />
<!-- Device Info -->
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('extensions.rustdesk.deviceInfo') }}</h4>
<!-- Device ID -->
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.deviceId') }}</Label>
<div class="col-span-3 flex items-center gap-2">
<code class="font-mono text-lg bg-muted px-3 py-1 rounded">{{ rustdeskConfig?.device_id || '-' }}</code>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="copyToClipboard(rustdeskConfig?.device_id || '', 'id')"
:disabled="!rustdeskConfig?.device_id"
>
<Check v-if="rustdeskCopied === 'id'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="regenerateRustdeskId" :disabled="rustdeskLoading">
<RefreshCw class="h-4 w-4 mr-1" />
{{ t('extensions.rustdesk.regenerateId') }}
</Button>
</div>
</div>
<!-- Device Password (直接显示) -->
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.devicePassword') }}</Label>
<div class="col-span-3 flex items-center gap-2">
<code class="font-mono text-lg bg-muted px-3 py-1 rounded">{{ rustdeskPassword?.device_password || '-' }}</code>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="copyToClipboard(rustdeskPassword?.device_password || '', 'password')"
:disabled="!rustdeskPassword?.device_password"
>
<Check v-if="rustdeskCopied === 'password'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="regenerateRustdeskPassword" :disabled="rustdeskLoading">
<RefreshCw class="h-4 w-4 mr-1" />
{{ t('extensions.rustdesk.regeneratePassword') }}
</Button>
</div>
</div>
<!-- Keypair Status -->
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t('extensions.rustdesk.keypairGenerated') }}</Label>
<div class="col-span-3">
<Badge :variant="rustdeskConfig?.has_keypair ? 'default' : 'secondary'">
{{ rustdeskConfig?.has_keypair ? t('common.yes') : t('common.no') }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Save button -->
<div class="flex justify-end">
<Button :disabled="loading" @click="saveRustdeskConfig">
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
</Button>
</div>
</div>
<!-- About Section -->
<div v-show="activeSection === 'about'" class="space-y-6">
<Card>