feat: 新增 MJPEG/H.264 VNC 初步支持

This commit is contained in:
mofeng-git
2026-06-15 22:23:27 +08:00
parent 5c98aea7e3
commit c101ef1c80
34 changed files with 2270 additions and 354 deletions

View File

@@ -171,6 +171,7 @@ export const extensionsApi = {
export interface RustDeskConfigResponse {
enabled: boolean
codec: 'h264' | 'h265'
rendezvous_server: string
relay_server: string | null
device_id: string
@@ -187,6 +188,7 @@ export interface RustDeskStatusResponse {
export interface RustDeskConfigUpdate {
enabled?: boolean
codec?: 'h264' | 'h265'
rendezvous_server?: string
relay_server?: string
relay_key?: string
@@ -271,6 +273,50 @@ export const rtspConfigApi = {
stop: () => request<RtspStatusResponse>('/config/rtsp/stop', { method: 'POST' }),
}
export type VncEncoding = 'tight_jpeg' | 'h264'
export interface VncConfigResponse {
enabled: boolean
bind: string
port: number
encoding: VncEncoding
jpeg_quality: number
allow_one_client: boolean
has_password: boolean
}
export interface VncConfigUpdate {
enabled?: boolean
bind?: string
port?: number
encoding?: VncEncoding
jpeg_quality?: number
allow_one_client?: boolean
password?: string
}
export interface VncStatusResponse {
config: VncConfigResponse
service_status: string
connection_count: number
}
export const vncConfigApi = {
get: () => request<VncConfigResponse>('/config/vnc'),
update: (config: VncConfigUpdate) =>
request<VncConfigResponse>('/config/vnc', {
method: 'PATCH',
body: JSON.stringify(config),
}),
getStatus: () => request<VncStatusResponse>('/config/vnc/status'),
start: () => request<VncStatusResponse>('/config/vnc/start', { method: 'POST' }),
stop: () => request<VncStatusResponse>('/config/vnc/stop', { method: 'POST' }),
}
export type WebConfig = WebConfigResponse
export type { WebConfigUpdate }

View File

@@ -67,6 +67,7 @@ export interface PlatformCapabilities {
otg: FeatureCapability
audio: FeatureCapability
rustdesk: FeatureCapability
vnc: FeatureCapability
diagnostics: FeatureCapability
extensions: FeatureCapability
service_installation: FeatureCapability
@@ -86,6 +87,7 @@ export const systemApi = {
atx: { available: boolean; backend?: string; reason?: string }
audio: { available: boolean; backend?: string; reason?: string }
rustdesk: { available: boolean; backend?: string; reason?: string }
vnc: { available: boolean; backend?: string; reason?: string }
}
disk_space?: {
total: number
@@ -206,6 +208,7 @@ export interface StreamConstraintsResponse {
sources: {
rustdesk: boolean
rtsp: boolean
vnc: boolean
}
reason: string
current_mode: string
@@ -719,6 +722,7 @@ export {
redfishConfigApi,
rustdeskConfigApi,
rtspConfigApi,
vncConfigApi,
webConfigApi,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
@@ -729,6 +733,10 @@ export {
type RedfishConfigUpdate,
type RtspConfigUpdate,
type RtspStatusResponse,
type VncConfigResponse,
type VncConfigUpdate,
type VncEncoding,
type VncStatusResponse,
type WebConfig,
type WebConfigUpdate,
} from './config'

View File

@@ -522,8 +522,7 @@ export default {
environmentSubtitle: 'System runtime environment and USB device maintenance',
aboutSubtitle: 'Online upgrade, version info and hardware overview',
extTtydSubtitle: 'Open a host Shell terminal in the browser',
extRustdeskSubtitle: 'Remote graphical access via RustDesk',
extRtspSubtitle: 'Provide an RTSP video stream for external clients',
thirdPartyAccessSubtitle: 'Configure external RustDesk, VNC, and RTSP access',
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
extFrpcSubtitle: 'NAT traversal through the FRP client',
aboutDesc: 'Open and Lightweight IP-KVM Solution',
@@ -967,6 +966,10 @@ export default {
start: 'Start',
stop: 'Stop',
autoStart: 'Auto Start',
thirdPartyAccess: {
title: 'Third-party Access',
desc: 'Configure RustDesk, VNC, and RTSP in one place',
},
viewLogs: 'View Logs',
noLogs: 'No logs available',
binaryNotFound: '{path} not found, please install the required program',
@@ -1040,6 +1043,8 @@ export default {
relayServer: 'Relay Server',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayKey: 'Relay Key',
codec: 'Codec',
codecHint: 'Choose H.264 or H.265 before starting RustDesk. The codec is locked while running.',
deviceInfo: 'Device Info',
deviceId: 'Device ID',
deviceIdHint: 'Use this ID in RustDesk client to connect',
@@ -1073,7 +1078,7 @@ export default {
pathPlaceholder: 'live',
pathHint: 'Example: rtsp://device-ip:8554/live',
codec: 'Codec',
codecHint: 'Enabling RTSP locks codec to selected value and disables MJPEG.',
codecHint: 'RTSP locks output to the selected codec while running. If RustDesk is running, choose the same codec.',
allowOneClient: 'Allow One Client Only',
username: 'Username',
usernamePlaceholder: 'Empty means no authentication',
@@ -1081,6 +1086,26 @@ export default {
passwordPlaceholder: 'Enter new password',
urlPreview: 'RTSP URL Preview',
},
vnc: {
title: 'VNC Remote',
desc: 'Access via TigerVNC client',
bind: 'Bind Address',
port: 'Port',
encoding: 'Video Encoding',
encodingTightJpeg: 'Tight JPEG',
encodingH264: 'H.264',
encodingHint: 'VNC locks output while running. VNC cannot start under an H.265 lock; MJPEG blocks RTSP and RustDesk.',
jpegQuality: 'JPEG Quality',
allowOneClient: 'Allow One Client Only',
password: 'Password',
passwordPlaceholder: 'Leave empty to keep current',
passwordRequiredPlaceholder: 'Up to 8 characters',
passwordRequired: 'Set a VNC password',
passwordMaxLength: 'VNC passwords are limited to 8 characters',
passwordSaved: 'Password is saved; leaving this empty keeps it unchanged.',
clients: '{count} clients',
urlPreview: 'VNC Address Preview',
},
},
stats: {
title: 'Connection Stats',

View File

@@ -521,8 +521,7 @@ export default {
environmentSubtitle: '系统级运行环境与 USB 设备维护',
aboutSubtitle: '在线升级、版本信息与设备硬件概览',
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
extRustdeskSubtitle: '通过 RustDesk 实现远程图形访问',
extRtspSubtitle: '提供 RTSP 视频流以供其他客户端拉流',
thirdPartyAccessSubtitle: '集中配置 RustDesk、VNC 与 RTSP 外部接入',
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
aboutDesc: '开放轻量的 IP-KVM 解决方案',
@@ -966,6 +965,10 @@ export default {
start: '启动',
stop: '停止',
autoStart: '开机自启',
thirdPartyAccess: {
title: '第三方接入',
desc: '集中配置 RustDesk、VNC 与 RTSP',
},
viewLogs: '查看日志',
noLogs: '暂无日志',
binaryNotFound: '未找到 {path},请先安装对应程序',
@@ -1039,6 +1042,8 @@ export default {
relayServer: '中继服务器',
relayServerPlaceholder: 'hbbr.example.com:21117',
relayKey: '中继密钥',
codec: '编码格式',
codecHint: 'RustDesk 启动前需选择 H.264 或 H.265;运行时会锁定编码,不允许客户端切换。',
deviceInfo: '设备信息',
deviceId: '设备 ID',
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
@@ -1072,7 +1077,7 @@ export default {
pathPlaceholder: 'live',
pathHint: '访问路径,例如 rtsp://设备IP:8554/live',
codec: '编码格式',
codecHint: '启用 RTSP 后将锁定编码为所选项,并禁用 MJPEG。',
codecHint: 'RTSP 运行时会锁定为所选编码;若 RustDesk 已运行,只能选择相同编码。',
allowOneClient: '仅允许单客户端',
username: '用户名',
usernamePlaceholder: '留空表示无需认证',
@@ -1080,6 +1085,26 @@ export default {
passwordPlaceholder: '输入新密码',
urlPreview: 'RTSP 地址预览',
},
vnc: {
title: 'VNC 远程',
desc: '通过 TigerVNC 客户端访问',
bind: '监听地址',
port: '端口',
encoding: '视频编码',
encodingTightJpeg: 'Tight JPEG',
encodingH264: 'H.264',
encodingHint: 'VNC 运行时会锁定编码H.265 锁定时 VNC 无法启动MJPEG 锁定时 RTSP 与 RustDesk 无法启动。',
jpegQuality: 'JPEG 质量',
allowOneClient: '仅允许单客户端',
password: '密码',
passwordPlaceholder: '留空表示不修改',
passwordRequiredPlaceholder: '最多 8 个字符',
passwordRequired: '请设置 VNC 密码',
passwordMaxLength: 'VNC 密码最多 8 个字符',
passwordSaved: '已保存密码;留空不会修改。',
clients: '{count} 个客户端',
urlPreview: 'VNC 地址预览',
},
},
stats: {
title: '连接统计',

View File

@@ -9,6 +9,7 @@ import {
rtspConfigApi,
rustdeskConfigApi,
streamConfigApi,
vncConfigApi,
videoConfigApi,
webConfigApi,
} from '@/api'
@@ -36,6 +37,9 @@ import type {
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
RustDeskStatusResponse as ApiRustDeskStatusResponse,
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
VncConfigResponse as ApiVncConfigResponse,
VncConfigUpdate as ApiVncConfigUpdate,
VncStatusResponse as ApiVncStatusResponse,
WebConfig,
WebConfigUpdate,
} from '@/api'
@@ -57,6 +61,8 @@ export const useConfigStore = defineStore('config', () => {
const atx = ref<AtxConfig | null>(null)
const rtspConfig = ref<ApiRtspConfigResponse | null>(null)
const rtspStatus = ref<ApiRtspStatusResponse | null>(null)
const vncConfig = ref<ApiVncConfigResponse | null>(null)
const vncStatus = ref<ApiVncStatusResponse | null>(null)
const rustdeskConfig = ref<ApiRustDeskConfigResponse | null>(null)
const rustdeskStatus = ref<ApiRustDeskStatusResponse | null>(null)
const rustdeskPassword = ref<ApiRustDeskPasswordResponse | null>(null)
@@ -70,6 +76,7 @@ export const useConfigStore = defineStore('config', () => {
const webLoading = ref(false)
const atxLoading = ref(false)
const rtspLoading = ref(false)
const vncLoading = ref(false)
const rustdeskLoading = ref(false)
const authError = ref<string | null>(null)
@@ -81,6 +88,7 @@ export const useConfigStore = defineStore('config', () => {
const webError = ref<string | null>(null)
const atxError = ref<string | null>(null)
const rtspError = ref<string | null>(null)
const vncError = ref<string | null>(null)
const rustdeskError = ref<string | null>(null)
let authPromise: Promise<AuthConfig> | null = null
@@ -93,6 +101,8 @@ export const useConfigStore = defineStore('config', () => {
let atxPromise: Promise<AtxConfig> | null = null
let rtspPromise: Promise<ApiRtspConfigResponse> | null = null
let rtspStatusPromise: Promise<ApiRtspStatusResponse> | null = null
let vncPromise: Promise<ApiVncConfigResponse> | null = null
let vncStatusPromise: Promise<ApiVncStatusResponse> | null = null
let rustdeskPromise: Promise<ApiRustDeskConfigResponse> | null = null
let rustdeskStatusPromise: Promise<ApiRustDeskStatusResponse> | null = null
let rustdeskPasswordPromise: Promise<ApiRustDeskPasswordResponse> | null = null
@@ -318,6 +328,51 @@ export const useConfigStore = defineStore('config', () => {
return request
}
async function refreshVncConfig() {
if (vncLoading.value && vncPromise) return vncPromise
vncLoading.value = true
vncError.value = null
const request = vncConfigApi.get()
.then((response) => {
vncConfig.value = response
return response
})
.catch((error) => {
vncError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
vncLoading.value = false
vncPromise = null
})
vncPromise = request
return request
}
async function refreshVncStatus() {
if (vncLoading.value && vncStatusPromise) return vncStatusPromise
vncLoading.value = true
vncError.value = null
const request = vncConfigApi.getStatus()
.then((response) => {
vncStatus.value = response
vncConfig.value = response.config
return response
})
.catch((error) => {
vncError.value = normalizeErrorMessage(error)
throw error
})
.finally(() => {
vncLoading.value = false
vncStatusPromise = null
})
vncStatusPromise = request
return request
}
async function refreshRustdeskConfig() {
if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise
rustdeskLoading.value = true
@@ -430,6 +485,11 @@ export const useConfigStore = defineStore('config', () => {
return refreshRtspConfig()
}
function ensureVncConfig() {
if (vncConfig.value) return Promise.resolve(vncConfig.value)
return refreshVncConfig()
}
function ensureRustdeskConfig() {
if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value)
return refreshRustdeskConfig()
@@ -489,6 +549,12 @@ export const useConfigStore = defineStore('config', () => {
return response
}
async function updateVnc(update: ApiVncConfigUpdate) {
const response = await vncConfigApi.update(update)
vncConfig.value = response
return response
}
async function updateRustdesk(update: ApiRustDeskConfigUpdate) {
const response = await rustdeskConfigApi.update(update)
rustdeskConfig.value = response
@@ -518,6 +584,8 @@ export const useConfigStore = defineStore('config', () => {
atx,
rtspConfig,
rtspStatus,
vncConfig,
vncStatus,
rustdeskConfig,
rustdeskStatus,
rustdeskPassword,
@@ -530,6 +598,7 @@ export const useConfigStore = defineStore('config', () => {
webLoading,
atxLoading,
rtspLoading,
vncLoading,
rustdeskLoading,
authError,
videoError,
@@ -540,6 +609,7 @@ export const useConfigStore = defineStore('config', () => {
webError,
atxError,
rtspError,
vncError,
rustdeskError,
refreshAuth,
refreshVideo,
@@ -551,6 +621,8 @@ export const useConfigStore = defineStore('config', () => {
refreshAtx,
refreshRtspConfig,
refreshRtspStatus,
refreshVncConfig,
refreshVncStatus,
refreshRustdeskConfig,
refreshRustdeskStatus,
refreshRustdeskPassword,
@@ -563,6 +635,7 @@ export const useConfigStore = defineStore('config', () => {
ensureWeb,
ensureAtx,
ensureRtspConfig,
ensureVncConfig,
ensureRustdeskConfig,
updateAuth,
updateVideo,
@@ -573,6 +646,7 @@ export const useConfigStore = defineStore('config', () => {
updateWeb,
updateAtx,
updateRtsp,
updateVnc,
updateRustdesk,
regenerateRustdeskId,
regenerateRustdeskPassword,

View File

@@ -62,14 +62,6 @@ export interface Ch9329DescriptorConfig {
serial_number?: string;
}
export interface Ch9329DescriptorState {
descriptor: Ch9329DescriptorConfig;
manufacturer_enabled: boolean;
product_enabled: boolean;
serial_enabled: boolean;
config_mode_available: boolean;
}
export interface HidConfig {
backend: HidBackend;
otg_udc?: string;
@@ -234,11 +226,31 @@ export interface ExtensionsConfig {
export interface RustDeskConfig {
enabled: boolean;
codec: RustDeskCodec;
rendezvous_server: string;
relay_server?: string;
device_id: string;
}
export enum RustDeskCodec {
H264 = "h264",
H265 = "h265",
}
export enum VncEncoding {
TightJpeg = "tight_jpeg",
H264 = "h264",
}
export interface VncConfig {
enabled: boolean;
bind: string;
port: number;
encoding: VncEncoding;
jpeg_quality: number;
allow_one_client: boolean;
}
export enum RtspCodec {
H264 = "h264",
H265 = "h265",
@@ -270,6 +282,7 @@ export interface AppConfig {
web: WebConfig;
extensions: ExtensionsConfig;
rustdesk: RustDeskConfig;
vnc: VncConfig;
rtsp: RtspConfig;
redfish: RedfishConfig;
}
@@ -328,6 +341,14 @@ export interface Ch9329DescriptorConfigUpdate {
serial_number?: string;
}
export interface Ch9329DescriptorState {
descriptor: Ch9329DescriptorConfig;
manufacturer_enabled: boolean;
product_enabled: boolean;
serial_enabled: boolean;
config_mode_available: boolean;
}
export interface EasytierConfigUpdate {
enabled?: boolean;
network_name?: string;
@@ -480,6 +501,7 @@ export interface RtspStatusResponse {
export interface RustDeskConfigUpdate {
enabled?: boolean;
codec?: RustDeskCodec;
rendezvous_server?: string;
relay_server?: string;
relay_key?: string;
@@ -535,6 +557,32 @@ export interface VideoConfigUpdate {
quality?: number;
}
export interface VncConfigResponse {
enabled: boolean;
bind: string;
port: number;
encoding: VncEncoding;
jpeg_quality: number;
allow_one_client: boolean;
has_password: boolean;
}
export interface VncConfigUpdate {
enabled?: boolean;
bind?: string;
port?: number;
encoding?: VncEncoding;
jpeg_quality?: number;
allow_one_client?: boolean;
password?: string;
}
export interface VncStatusResponse {
config: VncConfigResponse;
service_status: string;
connection_count: number;
}
/**
* Web server settings returned by `GET` / `PATCH /api/config/web`.
*

View File

@@ -20,6 +20,7 @@ import {
systemApi,
updateApi,
usbApi,
vncConfigApi,
type EncoderBackendInfo,
type AuthConfig,
type RustDeskConfigResponse,
@@ -27,6 +28,8 @@ import {
type RustDeskPasswordResponse,
type RtspStatusResponse,
type RtspConfigUpdate,
type VncConfigUpdate,
type VncStatusResponse,
type WebConfig,
type UpdateOverviewResponse,
type UpdateStatusResponse,
@@ -105,7 +108,6 @@ import {
ExternalLink,
Copy,
ScreenShare,
Radio,
Globe,
Loader2,
AlertTriangle,
@@ -136,8 +138,7 @@ const SETTINGS_SECTION_IDS = [
'atx',
'environment',
'ext-ttyd',
'ext-rustdesk',
'ext-rtsp',
'third-party-access',
'ext-remote-access',
'about',
] as const
@@ -167,8 +168,7 @@ const navGroups = computed(() => [
title: t('settings.extensions'),
items: [
{ id: 'ext-ttyd', label: t('extensions.ttyd.title'), icon: Terminal },
{ id: 'ext-rustdesk', label: t('extensions.rustdesk.title'), icon: ScreenShare },
{ id: 'ext-rtsp', label: t('extensions.rtsp.title'), icon: Radio },
{ id: 'third-party-access', label: t('extensions.thirdPartyAccess.title'), icon: ScreenShare },
{ id: 'ext-remote-access', label: t('extensions.remoteAccess.title'), icon: ExternalLink },
]
},
@@ -200,8 +200,7 @@ const sectionMeta = computed(() => {
function sectionSubtitleKey(id: string): string {
switch (id) {
case 'ext-ttyd': return 'extTtydSubtitle'
case 'ext-rustdesk': return 'extRustdeskSubtitle'
case 'ext-rtsp': return 'extRtspSubtitle'
case 'third-party-access': return 'thirdPartyAccessSubtitle'
case 'ext-remote-access': return 'extRemoteAccessSubtitle'
default: return `${id}Subtitle`
}
@@ -222,6 +221,7 @@ function normalizeSettingsSection(value: unknown): SettingsSectionId | null {
if (typeof value !== 'string') return null
if (value === 'access-control') return 'account'
if (value === 'ext-frpc') return 'ext-remote-access'
if (value === 'ext-rustdesk' || value === 'ext-vnc' || value === 'ext-rtsp') return 'third-party-access'
return isSettingsSectionId(value) ? value : null
}
@@ -272,15 +272,14 @@ async function loadSectionData(section: SettingsSectionId) {
case 'ext-remote-access':
await loadExtensions()
return
case 'ext-rustdesk':
case 'third-party-access':
await Promise.all([
loadRustdeskConfig(),
loadRustdeskPassword(),
loadRtspConfig(),
loadVncConfig(),
])
return
case 'ext-rtsp':
await loadRtspConfig()
return
case 'about':
if (isAndroid.value) return
await Promise.all([
@@ -390,6 +389,7 @@ const rustdeskCopied = ref<'id' | 'password' | null>(null)
const { copy: clipboardCopy } = useClipboard()
const rustdeskLocalConfig = ref({
enabled: false,
codec: 'h264' as 'h264' | 'h265',
rendezvous_server: '',
relay_server: '',
relay_key: '',
@@ -415,6 +415,18 @@ const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
password: '',
})
const vncStatus = ref<VncStatusResponse | null>(null)
const vncLoading = ref(false)
const vncLocalConfig = ref<VncConfigUpdate & { password?: string }>({
enabled: false,
bind: '0.0.0.0',
port: 5900,
encoding: 'tight_jpeg',
jpeg_quality: 80,
allow_one_client: true,
password: '',
})
function formatHostForUrl(hostname: string): string {
if (!hostname) return '127.0.0.1'
return hostname.includes(':') && !hostname.startsWith('[')
@@ -429,6 +441,12 @@ const rtspStreamUrl = computed(() => {
return `rtsp://${host}:${port}/${path}`
})
const vncStreamUrl = computed(() => {
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
const port = Number(vncLocalConfig.value.port) || 5900
return `${host}:${port}`
})
const webServerConfig = ref<WebConfig>({
http_port: 8080,
https_port: 8443,
@@ -1839,6 +1857,7 @@ function applyRustdeskStatus(status: RustDeskStatusResponse) {
rustdeskStatus.value = status
rustdeskLocalConfig.value = {
enabled: config.enabled,
codec: config.codec || 'h264',
rendezvous_server: config.rendezvous_server,
relay_server: config.relay_server || '',
relay_key: config.relay_key || '',
@@ -1901,6 +1920,17 @@ function validateRustdeskConfig(): boolean {
return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value)
}
function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean {
const password = (vncLocalConfig.value.password || '').trim()
if (enabled && !vncStatus.value?.config.has_password && !password) {
return showValidationError(t('extensions.vnc.passwordRequired'))
}
if (password.length > 8) {
return showValidationError(t('extensions.vnc.passwordMaxLength'))
}
return true
}
function normalizeRtspPath(path: string): string {
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
}
@@ -2222,23 +2252,26 @@ function updateStatusBadgeText(): string {
|| updatePhaseText(updateStatus.value?.phase)
}
function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) {
return {
enabled,
codec: rustdeskLocalConfig.value.codec,
rendezvous_server: normalizeRustdeskServer(
rustdeskLocalConfig.value.rendezvous_server,
21116,
),
relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117),
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
}
}
async function saveRustdeskConfig() {
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
loading.value = true
saved.value = false
try {
const rendezvousServer = normalizeRustdeskServer(
rustdeskLocalConfig.value.rendezvous_server,
21116,
)
const relayServer = normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117)
await configStore.updateRustdesk({
enabled: rustdeskLocalConfig.value.enabled,
rendezvous_server: rendezvousServer,
relay_server: relayServer,
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
})
await configStore.updateRustdesk(rustdeskUpdatePayload())
await loadRustdeskConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
@@ -2279,6 +2312,7 @@ async function startRustdesk() {
rustdeskLoading.value = true
try {
await configStore.updateRustdesk(rustdeskUpdatePayload(true))
const status = await rustdeskConfigApi.start()
applyRustdeskStatus(status)
} catch {
@@ -2373,23 +2407,24 @@ async function loadRtspConfig() {
}
}
function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate {
return {
enabled,
bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(rtspLocalConfig.value.port) || 8554,
path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'),
allow_one_client: !!rtspLocalConfig.value.allow_one_client,
codec: rtspLocalConfig.value.codec || 'h264',
username: (rtspLocalConfig.value.username || '').trim(),
password: (rtspLocalConfig.value.password || '').trim(),
}
}
async function saveRtspConfig() {
loading.value = true
saved.value = false
try {
const update: RtspConfigUpdate = {
enabled: !!rtspLocalConfig.value.enabled,
bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(rtspLocalConfig.value.port) || 8554,
path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'),
allow_one_client: !!rtspLocalConfig.value.allow_one_client,
codec: rtspLocalConfig.value.codec || 'h264',
username: (rtspLocalConfig.value.username || '').trim(),
}
update.password = (rtspLocalConfig.value.password || '').trim()
await configStore.updateRtsp(update)
await configStore.updateRtsp(rtspUpdatePayload())
await loadRtspConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
@@ -2402,6 +2437,7 @@ async function saveRtspConfig() {
async function startRtsp() {
rtspLoading.value = true
try {
await configStore.updateRtsp(rtspUpdatePayload(true))
const status = await rtspConfigApi.start()
applyRtspStatus(status)
} catch {
@@ -2421,6 +2457,108 @@ async function stopRtsp() {
}
}
function applyVncStatus(status: VncStatusResponse) {
vncStatus.value = status
vncLocalConfig.value = {
enabled: status.config.enabled,
bind: status.config.bind,
port: status.config.port,
encoding: status.config.encoding,
jpeg_quality: status.config.jpeg_quality,
allow_one_client: status.config.allow_one_client,
password: '',
}
}
async function loadVncConfig() {
vncLoading.value = true
try {
const status = await configStore.refreshVncStatus()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate {
const update: VncConfigUpdate = {
enabled,
bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0',
port: Number(vncLocalConfig.value.port) || 5900,
encoding: vncLocalConfig.value.encoding || 'tight_jpeg',
jpeg_quality: Number(vncLocalConfig.value.jpeg_quality) || 80,
allow_one_client: !!vncLocalConfig.value.allow_one_client,
}
const password = (vncLocalConfig.value.password || '').trim()
if (password) update.password = password
return update
}
async function saveVncConfig() {
if (!validateVncConfig()) return
loading.value = true
saved.value = false
try {
await configStore.updateVnc(vncUpdatePayload())
await loadVncConfig()
saved.value = true
setTimeout(() => (saved.value = false), 2000)
} catch {
} finally {
loading.value = false
}
}
async function startVnc() {
if (!validateVncConfig(true)) return
vncLoading.value = true
try {
await configStore.updateVnc(vncUpdatePayload(true))
const status = await vncConfigApi.start()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
async function stopVnc() {
vncLoading.value = true
try {
const status = await vncConfigApi.stop()
applyVncStatus(status)
} catch {
} finally {
vncLoading.value = false
}
}
function getVncServiceStatusText(status: string | undefined): string {
if (!status) return t('extensions.stopped')
switch (status) {
case 'running': return t('extensions.running')
case 'starting': return t('extensions.starting')
case 'stopped': return t('extensions.stopped')
default:
if (status.startsWith('error:')) return t('extensions.failed')
return status
}
}
function getVncStatusClass(status: string | undefined): string {
switch (status) {
case 'running': return 'bg-green-500'
case 'starting': return 'bg-yellow-500'
case 'stopped': return 'bg-gray-400'
default:
if (status?.startsWith('error:')) return 'bg-red-500'
return 'bg-gray-400'
}
}
function getRtspServiceStatusText(status: string | undefined): string {
if (!status) return t('extensions.stopped')
switch (status) {
@@ -4506,7 +4644,7 @@ watch(isWindows, () => {
</div>
<!-- RTSP Section -->
<div v-show="activeSection === 'ext-rtsp'" class="space-y-6">
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
@@ -4631,8 +4769,134 @@ watch(isWindows, () => {
</div>
</div>
<!-- VNC Section -->
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div class="space-y-1.5">
<CardTitle>{{ t('extensions.vnc.title') }}</CardTitle>
<CardDescription>{{ t('extensions.vnc.desc') }}</CardDescription>
</div>
<div class="flex items-center gap-2">
<Badge :variant="vncStatus?.service_status === 'running' ? 'default' : 'secondary'">
{{ getVncServiceStatusText(vncStatus?.service_status) }}
</Badge>
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.refresh')" @click="loadVncConfig" :disabled="vncLoading">
<RefreshCw :class="['h-4 w-4', vncLoading ? 'animate-spin' : '']" />
</Button>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div :class="['w-2 h-2 rounded-full', getVncStatusClass(vncStatus?.service_status)]" />
<span class="text-sm">{{ getVncServiceStatusText(vncStatus?.service_status) }}</span>
<template v-if="vncStatus?.connection_count">
<span class="text-muted-foreground">|</span>
<span class="text-sm text-muted-foreground">{{ t('extensions.vnc.clients', { count: vncStatus.connection_count }) }}</span>
</template>
</div>
<div class="flex items-center gap-2">
<Button
v-if="vncStatus?.service_status !== 'running' && vncStatus?.service_status !== 'starting'"
size="sm"
@click="startVnc"
:disabled="vncLoading || vncStatus?.service_status === 'starting'"
>
<Play class="h-4 w-4 mr-1" />
{{ t('extensions.start') }}
</Button>
<Button
v-else
size="sm"
variant="outline"
@click="stopVnc"
:disabled="vncLoading"
>
<Square class="h-4 w-4 mr-1" />
{{ t('extensions.stop') }}
</Button>
</div>
</div>
<Separator />
<div class="grid gap-4">
<div class="flex items-center justify-between">
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="vncLocalConfig.enabled" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.bind') }}</Label>
<Input v-model="vncLocalConfig.bind" class="sm:col-span-3" placeholder="0.0.0.0" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.port') }}</Label>
<Input v-model.number="vncLocalConfig.port" class="sm:col-span-3" type="number" min="1" max="65535" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.encoding') }}</Label>
<div class="sm:col-span-3 space-y-1">
<select v-model="vncLocalConfig.encoding" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="vncStatus?.service_status === 'running'">
<option value="tight_jpeg">{{ t('extensions.vnc.encodingTightJpeg') }}</option>
<option value="h264">{{ t('extensions.vnc.encodingH264') }}</option>
</select>
<p class="text-xs text-muted-foreground">{{ t('extensions.vnc.encodingHint') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.jpegQuality') }}</Label>
<Input v-model.number="vncLocalConfig.jpeg_quality" class="sm:col-span-3" type="number" min="10" max="100" :disabled="vncStatus?.service_status === 'running' || vncLocalConfig.encoding !== 'tight_jpeg'" />
</div>
<div class="flex items-center justify-between">
<Label>{{ t('extensions.vnc.allowOneClient') }}</Label>
<Switch v-model="vncLocalConfig.allow_one_client" :disabled="vncStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.vnc.password') }}</Label>
<div class="sm:col-span-3 space-y-1">
<div class="relative">
<Input
v-model="vncLocalConfig.password"
:type="showPasswords ? 'text' : 'password'"
maxlength="8"
autocomplete="off"
:placeholder="vncStatus?.config.has_password ? t('extensions.vnc.passwordPlaceholder') : t('extensions.vnc.passwordRequiredPlaceholder')"
:disabled="vncStatus?.service_status === 'running'"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground"
:aria-label="showPasswords ? t('extensions.rustdesk.hidePassword') : t('extensions.rustdesk.showPassword')"
@click="showPasswords = !showPasswords"
>
<Eye v-if="!showPasswords" class="h-4 w-4" />
<EyeOff v-else class="h-4 w-4" />
</button>
</div>
<p v-if="vncStatus?.config.has_password" class="text-xs text-muted-foreground">{{ t('extensions.vnc.passwordSaved') }}</p>
</div>
</div>
</div>
<Separator />
<div class="rounded-md border p-3 bg-muted/20 space-y-1">
<p class="text-sm font-medium">{{ t('extensions.vnc.urlPreview') }}</p>
<code class="font-mono text-sm break-all">{{ vncStreamUrl }}</code>
</div>
</CardContent>
</Card>
<div class="flex justify-end">
<Button :disabled="loading || vncLoading || vncStatus?.service_status === 'running'" @click="saveVncConfig">
<Loader2 v-if="loading" class="h-4 w-4 mr-2 animate-spin" /><Check v-else-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
</Button>
</div>
</div>
<!-- RustDesk Section -->
<div v-show="activeSection === 'ext-rustdesk'" class="space-y-6">
<div v-show="activeSection === 'third-party-access'" class="space-y-6">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
@@ -4692,6 +4956,16 @@ watch(isWindows, () => {
<Label>{{ t('extensions.autoStart') }}</Label>
<Switch v-model="rustdeskLocalConfig.enabled" :disabled="rustdeskStatus?.service_status === 'running'" />
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.codec') }}</Label>
<div class="sm:col-span-3 space-y-1">
<select v-model="rustdeskLocalConfig.codec" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="rustdeskStatus?.service_status === 'running'">
<option value="h264">H.264</option>
<option value="h265">H.265</option>
</select>
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.codecHint') }}</p>
</div>
</div>
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
<div class="sm:col-span-3 space-y-1">