From 6bcb54bd22b48fda4d7cc3815219f941ea03a20c Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Fri, 27 Mar 2026 10:49:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=94=B9=E4=B8=BA=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20WebSocket=20=E6=8E=A8=E9=80=81=20ttyd=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=B9=B6=E6=B8=85=E7=90=86=E8=BD=AE=E8=AF=A2=E4=B8=8E?= =?UTF-8?q?=E5=86=97=E4=BD=99=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/events/mod.rs | 2 +- src/events/types.rs | 11 ++++ src/extensions/manager.rs | 59 ++++++++++++++++--- src/hid/ch9329.rs | 3 +- src/hid/datachannel.rs | 7 ++- src/main.rs | 2 + src/state.rs | 18 +++++- src/video/shared_video_pipeline.rs | 5 +- .../shared_video_pipeline/encoder_state.rs | 26 +++++--- src/web/handlers/extensions.rs | 26 +------- src/web/routes.rs | 4 -- web/src/api/config.ts | 6 -- web/src/stores/system.ts | 6 ++ web/src/types/generated.ts | 6 -- web/src/views/ConsoleView.vue | 22 +------ 15 files changed, 119 insertions(+), 84 deletions(-) diff --git a/src/events/mod.rs b/src/events/mod.rs index f317fd6b..907ad1cf 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -7,7 +7,7 @@ pub mod types; pub use types::{ AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, MsdDeviceInfo, SystemEvent, - VideoDeviceInfo, + TtydDeviceInfo, VideoDeviceInfo, }; use tokio::sync::broadcast; diff --git a/src/events/types.rs b/src/events/types.rs index 8a0aa4fa..df72c3dd 100644 --- a/src/events/types.rs +++ b/src/events/types.rs @@ -100,6 +100,15 @@ pub struct AudioDeviceInfo { pub error: Option, } +/// ttyd status information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TtydDeviceInfo { + /// Whether ttyd binary is available + pub available: bool, + /// Whether ttyd is currently running + pub running: bool, +} + /// Per-client statistics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientStats { @@ -325,6 +334,8 @@ pub enum SystemEvent { atx: Option, /// Audio device information (None if audio not enabled) audio: Option, + /// ttyd status information + ttyd: TtydDeviceInfo, }, /// WebSocket error notification (for connection-level errors like lag) diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 6feedbb2..baac3561 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -10,6 +10,7 @@ use tokio::process::{Child, Command}; use tokio::sync::RwLock; use super::types::*; +use crate::events::EventBus; /// Maximum number of log lines to keep per extension const LOG_BUFFER_SIZE: usize = 200; @@ -31,6 +32,7 @@ pub struct ExtensionManager { processes: RwLock>, /// Cached availability status (checked once at startup) availability: HashMap, + event_bus: RwLock>>, } impl Default for ExtensionManager { @@ -51,6 +53,22 @@ impl ExtensionManager { Self { processes: RwLock::new(HashMap::new()), availability, + event_bus: RwLock::new(None), + } + } + + /// Set event bus for ttyd status notifications. + pub async fn set_event_bus(&self, event_bus: Arc) { + *self.event_bus.write().await = Some(event_bus); + } + + async fn mark_ttyd_status_dirty(&self, id: ExtensionId) { + if id != ExtensionId::Ttyd { + return; + } + + if let Some(ref event_bus) = *self.event_bus.read().await { + event_bus.mark_device_info_dirty(); } } @@ -65,17 +83,38 @@ impl ExtensionManager { return ExtensionStatus::Unavailable; } - let processes = self.processes.read().await; - match processes.get(&id) { - Some(proc) => { - if let Some(pid) = proc.child.id() { - ExtensionStatus::Running { pid } - } else { - ExtensionStatus::Stopped + let mut processes = self.processes.write().await; + let exited = { + let Some(proc) = processes.get_mut(&id) else { + return ExtensionStatus::Stopped; + }; + + match proc.child.try_wait() { + Ok(Some(status)) => { + tracing::info!("Extension {} exited with status {}", id, status); + true + } + Ok(None) => { + return match proc.child.id() { + Some(pid) => ExtensionStatus::Running { pid }, + None => ExtensionStatus::Stopped, + }; + } + Err(e) => { + tracing::warn!("Failed to query status for {}: {}", id, e); + return match proc.child.id() { + Some(pid) => ExtensionStatus::Running { pid }, + None => ExtensionStatus::Stopped, + }; } } - None => ExtensionStatus::Stopped, + }; + + if exited { + processes.remove(&id); } + + ExtensionStatus::Stopped } /// Start an extension with the given configuration @@ -134,6 +173,8 @@ impl ExtensionManager { let mut processes = self.processes.write().await; processes.insert(id, ExtensionProcess { child, logs }); + drop(processes); + self.mark_ttyd_status_dirty(id).await; Ok(()) } @@ -146,6 +187,8 @@ impl ExtensionManager { if let Err(e) = proc.child.kill().await { tracing::warn!("Failed to kill {}: {}", id, e); } + drop(processes); + self.mark_ttyd_status_dirty(id).await; } Ok(()) } diff --git a/src/hid/ch9329.rs b/src/hid/ch9329.rs index f430cf4b..b689dad1 100644 --- a/src/hid/ch9329.rs +++ b/src/hid/ch9329.rs @@ -1371,8 +1371,7 @@ mod tests { // Test keyboard packet (8 bytes data) let data = [0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]; // 'A' key - let packet = - Ch9329Backend::build_packet(DEFAULT_ADDR, cmd::SEND_KB_GENERAL_DATA, &data); + let packet = Ch9329Backend::build_packet(DEFAULT_ADDR, cmd::SEND_KB_GENERAL_DATA, &data); assert_eq!(packet[0], 0x57); // Header assert_eq!(packet[1], 0xAB); // Header diff --git a/src/hid/datachannel.rs b/src/hid/datachannel.rs index 95401da3..4ab3c125 100644 --- a/src/hid/datachannel.rs +++ b/src/hid/datachannel.rs @@ -199,7 +199,12 @@ pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec { let modifiers = event.modifiers.to_hid_byte(); - vec![MSG_KEYBOARD, event_type, event.key.to_hid_usage(), modifiers] + vec![ + MSG_KEYBOARD, + event_type, + event.key.to_hid_usage(), + modifiers, + ] } /// Encode a mouse event to binary format (for sending to client if needed) diff --git a/src/main.rs b/src/main.rs index 2ce2dc01..ca82ad36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -576,6 +576,8 @@ async fn main() -> anyhow::Result<()> { data_dir.clone(), ); + extensions.set_event_bus(events.clone()).await; + // Start RustDesk service if enabled if let Some(ref service) = rustdesk { if let Err(e) = service.start().await { diff --git a/src/state.rs b/src/state.rs index 21469f9f..58ad3c30 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,9 +7,9 @@ use crate::auth::{SessionStore, UserStore}; use crate::config::ConfigStore; use crate::events::{ AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, MsdDeviceInfo, SystemEvent, - VideoDeviceInfo, + TtydDeviceInfo, VideoDeviceInfo, }; -use crate::extensions::ExtensionManager; +use crate::extensions::{ExtensionId, ExtensionManager}; use crate::hid::HidController; use crate::msd::MsdController; use crate::otg::OtgService; @@ -157,12 +157,13 @@ impl AppState { /// Uses tokio::join! to collect all device info in parallel for better performance. pub async fn get_device_info(&self) -> SystemEvent { // Collect all device info in parallel - let (video, hid, msd, atx, audio) = tokio::join!( + let (video, hid, msd, atx, audio, ttyd) = tokio::join!( self.collect_video_info(), self.collect_hid_info(), self.collect_msd_info(), self.collect_atx_info(), self.collect_audio_info(), + self.collect_ttyd_info(), ); SystemEvent::DeviceInfo { @@ -171,6 +172,7 @@ impl AppState { msd, atx, audio, + ttyd, } } @@ -262,4 +264,14 @@ impl AppState { error: status.error, }) } + + /// Collect ttyd status information + async fn collect_ttyd_info(&self) -> TtydDeviceInfo { + let status = self.extensions.status(ExtensionId::Ttyd).await; + + TtydDeviceInfo { + available: self.extensions.check_available(ExtensionId::Ttyd), + running: status.is_running(), + } + } } diff --git a/src/video/shared_video_pipeline.rs b/src/video/shared_video_pipeline.rs index 1c3f6f95..664da85c 100644 --- a/src/video/shared_video_pipeline.rs +++ b/src/video/shared_video_pipeline.rs @@ -196,7 +196,10 @@ fn log_encoding_error( if throttler.should_log(&key) { let suppressed = suppressed_errors.remove(&key).unwrap_or(0); if suppressed > 0 { - error!("Encoding failed: {} (suppressed {} repeats)", err, suppressed); + error!( + "Encoding failed: {} (suppressed {} repeats)", + err, suppressed + ); } else { error!("Encoding failed: {}", err); } diff --git a/src/video/shared_video_pipeline/encoder_state.rs b/src/video/shared_video_pipeline/encoder_state.rs index 978847ee..ab9799f8 100644 --- a/src/video/shared_video_pipeline/encoder_state.rs +++ b/src/video/shared_video_pipeline/encoder_state.rs @@ -159,7 +159,9 @@ impl MjpegDecoderKind { } } -pub(super) fn build_encoder_state(config: &SharedVideoPipelineConfig) -> Result { +pub(super) fn build_encoder_state( + config: &SharedVideoPipelineConfig, +) -> Result { let registry = EncoderRegistry::global(); let get_codec_name = @@ -408,8 +410,10 @@ pub(super) fn build_encoder_state(config: &SharedVideoPipelineConfig) -> Result< backend, codec_name ); } - let encoder = - VP8Encoder::with_codec(VP8Config::low_latency(config.resolution, config.bitrate_kbps()), &codec_name)?; + let encoder = VP8Encoder::with_codec( + VP8Config::low_latency(config.resolution, config.bitrate_kbps()), + &codec_name, + )?; info!("Created VP8 encoder: {}", encoder.codec_name()); Box::new(VP8EncoderWrapper(encoder)) } @@ -421,8 +425,10 @@ pub(super) fn build_encoder_state(config: &SharedVideoPipelineConfig) -> Result< backend, codec_name ); } - let encoder = - VP9Encoder::with_codec(VP9Config::low_latency(config.resolution, config.bitrate_kbps()), &codec_name)?; + let encoder = VP9Encoder::with_codec( + VP9Config::low_latency(config.resolution, config.bitrate_kbps()), + &codec_name, + )?; info!("Created VP9 encoder: {}", encoder.codec_name()); Box::new(VP9EncoderWrapper(encoder)) } @@ -505,7 +511,10 @@ pub(super) fn build_encoder_state(config: &SharedVideoPipelineConfig) -> Result< }) } -fn h264_direct_input_format(codec_name: &str, input_format: PixelFormat) -> Option { +fn h264_direct_input_format( + codec_name: &str, + input_format: PixelFormat, +) -> Option { if codec_name.contains("rkmpp") { match input_format { PixelFormat::Yuyv => Some(H264InputFormat::Yuyv422), @@ -531,7 +540,10 @@ fn h264_direct_input_format(codec_name: &str, input_format: PixelFormat) -> Opti } } -fn h265_direct_input_format(codec_name: &str, input_format: PixelFormat) -> Option { +fn h265_direct_input_format( + codec_name: &str, + input_format: PixelFormat, +) -> Option { if codec_name.contains("rkmpp") { match input_format { PixelFormat::Yuyv => Some(H265InputFormat::Yuyv422), diff --git a/src/web/handlers/extensions.rs b/src/web/handlers/extensions.rs index ac0bf39d..30e22d16 100644 --- a/src/web/handlers/extensions.rs +++ b/src/web/handlers/extensions.rs @@ -4,7 +4,7 @@ use axum::{ extract::{Path, Query, State}, Json, }; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::sync::Arc; use typeshare::typeshare; @@ -324,27 +324,3 @@ pub async fn update_easytier_config( Ok(Json(new_config.extensions.easytier.clone())) } - -// ============================================================================ -// Ttyd status for console (simplified) -// ============================================================================ - -/// Simple ttyd status for console view -#[typeshare] -#[derive(Debug, Serialize)] -pub struct TtydStatus { - pub available: bool, - pub running: bool, -} - -/// Get ttyd status for console view -/// GET /api/extensions/ttyd/status -pub async fn get_ttyd_status(State(state): State>) -> Json { - let mgr = &state.extensions; - let status = mgr.status(ExtensionId::Ttyd).await; - - Json(TtydStatus { - available: mgr.check_available(ExtensionId::Ttyd), - running: status.is_running(), - }) -} diff --git a/src/web/routes.rs b/src/web/routes.rs index db4145b1..8308d787 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -196,10 +196,6 @@ pub fn create_router(state: Arc) -> Router { "/extensions/ttyd/config", patch(handlers::extensions::update_ttyd_config), ) - .route( - "/extensions/ttyd/status", - get(handlers::extensions::get_ttyd_status), - ) .route( "/extensions/gostc/config", patch(handlers::extensions::update_gostc_config), diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 6198fb08..1fe4e919 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -30,7 +30,6 @@ import type { GostcConfigUpdate, EasytierConfig, EasytierConfigUpdate, - TtydStatus, } from '@/types/generated' import { request } from './request' @@ -236,11 +235,6 @@ export const extensionsApi = { logs: (id: string, lines = 100) => request(`/extensions/${id}/logs?lines=${lines}`), - /** - * 获取 ttyd 状态(简化版,用于控制台) - */ - getTtydStatus: () => request('/extensions/ttyd/status'), - /** * 更新 ttyd 配置 */ diff --git a/web/src/stores/system.ts b/web/src/stores/system.ts index 37772fc0..1511f725 100644 --- a/web/src/stores/system.ts +++ b/web/src/stores/system.ts @@ -118,12 +118,18 @@ export interface AudioDeviceInfo { error: string | null } +export interface TtydDeviceInfo { + available: boolean + running: boolean +} + export interface DeviceInfoEvent { video: VideoDeviceInfo hid: HidDeviceInfo msd: MsdDeviceInfo | null atx: AtxDeviceInfo | null audio: AudioDeviceInfo | null + ttyd: TtydDeviceInfo } export const useSystemStore = defineStore('system', () => { diff --git a/web/src/types/generated.ts b/web/src/types/generated.ts index 021c9048..ed4c5aa3 100644 --- a/web/src/types/generated.ts +++ b/web/src/types/generated.ts @@ -667,12 +667,6 @@ export interface TtydConfigUpdate { shell?: string; } -/** Simple ttyd status for console view */ -export interface TtydStatus { - available: boolean; - running: boolean; -} - export interface VideoConfigUpdate { device?: string; format?: string; diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index 9098fd72..c3c32fdd 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -11,7 +11,7 @@ import { useHidWebSocket } from '@/composables/useHidWebSocket' import { useWebRTC } from '@/composables/useWebRTC' import { useVideoSession } from '@/composables/useVideoSession' import { getUnifiedAudio } from '@/composables/useUnifiedAudio' -import { streamApi, hidApi, atxApi, extensionsApi, atxConfigApi, authApi } from '@/api' +import { streamApi, hidApi, atxApi, atxConfigApi, authApi } from '@/api' import { CanonicalKey } from '@/types/generated' import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid' import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings' @@ -162,7 +162,6 @@ const changingPassword = ref(false) // ttyd (web terminal) state const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null) const showTerminalDialog = ref(false) -let ttydPollInterval: ReturnType | null = null // Theme const isDark = ref(document.documentElement.classList.contains('dark')) @@ -965,6 +964,7 @@ function handleDeviceInfo(data: any) { const prevAudioStreaming = systemStore.audio?.streaming ?? false const prevAudioDevice = systemStore.audio?.device ?? null systemStore.updateFromDeviceInfo(data) + ttydStatus.value = data.ttyd ?? null const nextAudioStreaming = systemStore.audio?.streaming ?? false const nextAudioDevice = systemStore.audio?.device ?? null @@ -1484,14 +1484,6 @@ async function handleChangePassword() { } // ttyd (web terminal) functions -async function fetchTtydStatus() { - try { - ttydStatus.value = await extensionsApi.getTtydStatus() - } catch { - ttydStatus.value = null - } -} - function openTerminal() { if (!ttydStatus.value?.running) return showTerminalDialog.value = true @@ -2112,10 +2104,6 @@ onMounted(async () => { document.documentElement.classList.add('dark') } - // Fetch ttyd status initially and poll every 10 seconds - fetchTtydStatus() - ttydPollInterval = setInterval(fetchTtydStatus, 10000) - // Note: Video mode is now synced from server via device_info event // The handleDeviceInfo function will automatically switch to the server's mode // localStorage preference is only used when server mode matches @@ -2142,12 +2130,6 @@ onUnmounted(() => { mouseFlushTimer = null } - // Clear ttyd poll interval - if (ttydPollInterval) { - clearInterval(ttydPollInterval) - ttydPollInterval = null - } - // Clear all timers if (retryTimeoutId !== null) { clearTimeout(retryTimeoutId)