From cefff781d4994a306452dcd584336ee0896e2e15 Mon Sep 17 00:00:00 2001 From: 21pages Date: Sat, 25 Jul 2026 15:21:13 +0800 Subject: [PATCH] feat(recording): add visibility and service storage options (#15662) * feat(recording): add visibility and service storage options - support hide-recording-button in Flutter and Sciter - allow a custom save directory for Windows service recordings - sanitize peer IDs used in recording filenames Tested: - with hide-recording-button=Y and allow-auto-record-outgoing=Y, outgoing sessions are recorded automatically while the recording button remains hidden and cannot be stopped from the UI; verified on Flutter desktop, Sciter, and Android - windows-service-video-save-directory takes effect when the Windows client runs as an installed service - the Windows controlling side can save recordings for direct IP:port connections Signed-off-by: 21pages * update hbb_common Signed-off-by: 21pages * fix(recording): validate configured save directories - trim configured recording directory paths - reject non-absolute paths and fall back to defaults - warn when a non-empty path is invalid Signed-off-by: 21pages * fix(recording): validate configured save directories Signed-off-by: 21pages --------- Signed-off-by: 21pages --- flutter/lib/common/widgets/toolbar.dart | 1 + flutter/lib/consts.dart | 1 + .../lib/desktop/widgets/remote_toolbar.dart | 4 +- libs/hbb_common | 2 +- libs/scrap/src/common/record.rs | 39 +++++++++- src/ui/header.tis | 2 +- src/ui/remote.rs | 5 ++ src/ui/remote.tis | 1 + src/ui_interface.rs | 74 ++++++++++++++++++- 9 files changed, 123 insertions(+), 6 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 83638000b..0e4c5b7a5 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -583,6 +583,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { } // record if (!(isDesktop || isWeb) && + bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' && (ffi.recordingModel.start || (perms["recording"] != false))) { v.add(TTextMenu( child: Row( diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 69f4be59e..722f7a23c 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -104,6 +104,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout"; const String kOptionEnableHwcodec = "enable-hwcodec"; const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming"; const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing"; +const String kOptionHideRecordingButton = "hide-recording-button"; const String kOptionVideoSaveDirectory = "video-save-directory"; const String kOptionAccessMode = "access-mode"; const String kOptionEnableKeyboard = "enable-keyboard"; diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 75fdbe1f8..8f589b79a 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -2740,7 +2740,9 @@ class _RecordMenu extends StatelessWidget { Widget build(BuildContext context) { var ffi = Provider.of(context); var recordingModel = Provider.of(context); - final visible = + final hideRecordingButton = + bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y'; + final visible = !hideRecordingButton && (recordingModel.start || ffi.permissions['recording'] != false); if (!visible) return Offstage(); return _IconMenuButton( diff --git a/libs/hbb_common b/libs/hbb_common index 7e1c392c6..559176122 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 7e1c392c62d39c364127307cd408421dd5f8cfb0 +Subproject commit 559176122bdd5c8afa4e8fd5b706c3d901fb0c15 diff --git a/libs/scrap/src/common/record.rs b/libs/scrap/src/common/record.rs index d121984f1..ffeb25791 100644 --- a/libs/scrap/src/common/record.rs +++ b/libs/scrap/src/common/record.rs @@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer}; const MIN_SECS: u64 = 1; +// Replace characters that are invalid in Windows filename components so recordings remain portable. +// Control characters are also replaced because they can make filenames invalid +// on Windows or invisible and difficult to handle on Linux and macOS. +fn sanitize_filename_component(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { + '_' + } else { + c + } + }) + .collect() +} + #[derive(Debug, Clone)] pub struct RecorderContext { pub server: bool, @@ -45,7 +61,7 @@ impl RecorderContext2 { } let file = if ctx.server { "incoming" } else { "outgoing" }.to_string() + "_" - + &ctx.id.clone() + + &sanitize_filename_component(&ctx.id) + &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string() + &format!( "{}{}_", @@ -421,3 +437,24 @@ impl Drop for HwRecorder { self.ctx.tx.as_ref().map(|tx| tx.send(state)); } } + +#[cfg(test)] +mod tests { + use super::sanitize_filename_component; + + #[test] + fn sanitize_recording_filename_component() { + assert_eq!( + sanitize_filename_component("192.168.1.2:21118"), + "192.168.1.2_21118" + ); + assert_eq!( + sanitize_filename_component("[2001:db8::1]:21118"), + "[2001_db8__1]_21118" + ); + assert_eq!( + sanitize_filename_component("peer/name\\with?bad\nchars"), + "peer_name_with_bad_chars" + ); + } +} diff --git a/src/ui/header.tis b/src/ui/header.tis index 40ccbcbf2..231c71efe 100644 --- a/src/ui/header.tis +++ b/src/ui/header.tis @@ -151,7 +151,7 @@ class Header: Reactor.Component { {svg_action} {svg_display} {svg_keyboard} - {recording_enabled ? {recording ? svg_recording_on : svg_recording_off} : ""} + {recording_enabled && show_recording_button ? {recording ? svg_recording_on : svg_recording_off} : ""} {this.renderKeyboardPop()} {this.renderDisplayPop()} {this.renderActionPop()} diff --git a/src/ui/remote.rs b/src/ui/remote.rs index 1d5ceb139..3a2cca3e0 100644 --- a/src/ui/remote.rs +++ b/src/ui/remote.rs @@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession { fn get_id(); fn get_default_pi(); fn get_option(String); + fn get_local_option(String); fn t(String); fn set_option(String, String); fn input_os_password(String, bool); @@ -638,6 +639,10 @@ impl SciterSession { crate::client::translate(name) } + pub fn get_local_option(&self, key: String) -> String { + crate::ui_interface::get_local_option(key) + } + pub fn get_icon(&self) -> String { super::get_icon() } diff --git a/src/ui/remote.tis b/src/ui/remote.tis index 28fbc3763..87c543eb0 100644 --- a/src/ui/remote.tis +++ b/src/ui/remote.tis @@ -17,6 +17,7 @@ var audio_enabled = true; // server side var file_enabled = true; // server side var restart_enabled = true; // server side var recording_enabled = true; // server side +var show_recording_button = handler.get_local_option("hide-recording-button") != "Y"; var privacy_mode_enabled = true; // server side var scroll_body = $(body); var peer_platform = ""; diff --git a/src/ui_interface.rs b/src/ui_interface.rs index 1a8927840..94fde4392 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -911,6 +911,29 @@ pub fn get_langs() -> String { json!(x).to_string() } +// Preserve relative paths for existing configurations and only remove accidental +// surrounding whitespace. Config values are not shell-expanded (for example, `~`). +fn trim_video_save_directory(value: &str) -> Option<&str> { + let value = value.trim(); + if !value.is_empty() { + Some(value) + } else { + None + } +} + +// A Windows service typically runs with System32 as its working directory, so +// require an absolute path to avoid resolving recordings there unexpectedly. +#[cfg(any(windows, test))] +fn validate_windows_service_video_save_directory(value: &str) -> Option<&str> { + let value = trim_video_save_directory(value)?; + if std::path::Path::new(value).is_absolute() { + Some(value) + } else { + None + } +} + #[inline] pub fn video_save_directory(root: bool) -> String { let appname = crate::get_app_name(); @@ -930,6 +953,15 @@ pub fn video_save_directory(root: bool) -> String { // Currently, only installed windows run as root #[cfg(windows)] { + let dir = Config::get_option(OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY); + if let Some(dir) = validate_windows_service_video_save_directory(&dir) { + return dir.to_owned(); + } + if !dir.trim().is_empty() { + log::warn!( + "Ignoring {OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY}: path must be absolute" + ); + } let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned()); let dir = std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",)); @@ -941,8 +973,8 @@ pub fn video_save_directory(root: bool) -> String { let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY); #[cfg(not(any(target_os = "linux", target_os = "macos")))] let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY); - if !dir.is_empty() { - return dir; + if let Some(dir) = trim_video_save_directory(&dir) { + return dir.to_owned(); } #[cfg(any(target_os = "android", target_os = "ios"))] if let Ok(home) = config::APP_HOME_DIR.read() { @@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option { .lock() .unwrap() } + +#[cfg(test)] +mod tests { + use super::{trim_video_save_directory, validate_windows_service_video_save_directory}; + + #[test] + fn trim_configured_video_save_directory() { + assert_eq!( + trim_video_save_directory(" relative/recordings "), + Some("relative/recordings") + ); + assert_eq!(trim_video_save_directory(" "), None); + } + + #[test] + fn validate_service_video_save_directory() { + let absolute = if cfg!(windows) { + r"C:\recordings" + } else { + "/recordings" + }; + let padded = format!(" {absolute} "); + + assert_eq!( + validate_windows_service_video_save_directory(&padded), + Some(absolute) + ); + assert_eq!( + validate_windows_service_video_save_directory("recordings"), + None + ); + assert_eq!( + validate_windows_service_video_save_directory(&format!("\"{absolute}\"")), + None + ); + assert_eq!(validate_windows_service_video_save_directory(" "), None); + } +}