Skip to content

Commit cefff78

Browse files
authored
feat(recording): add visibility and service storage options (rustdesk#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 <sunboeasy@gmail.com> * update hbb_common Signed-off-by: 21pages <sunboeasy@gmail.com> * 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 <sunboeasy@gmail.com> * fix(recording): validate configured save directories Signed-off-by: 21pages <sunboeasy@gmail.com> --------- Signed-off-by: 21pages <sunboeasy@gmail.com>
1 parent ad9dac1 commit cefff78

9 files changed

Lines changed: 123 additions & 6 deletions

File tree

flutter/lib/common/widgets/toolbar.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
583583
}
584584
// record
585585
if (!(isDesktop || isWeb) &&
586+
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
586587
(ffi.recordingModel.start || (perms["recording"] != false))) {
587588
v.add(TTextMenu(
588589
child: Row(

flutter/lib/consts.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
104104
const String kOptionEnableHwcodec = "enable-hwcodec";
105105
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
106106
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
107+
const String kOptionHideRecordingButton = "hide-recording-button";
107108
const String kOptionVideoSaveDirectory = "video-save-directory";
108109
const String kOptionAccessMode = "access-mode";
109110
const String kOptionEnableKeyboard = "enable-keyboard";

flutter/lib/desktop/widgets/remote_toolbar.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2740,7 +2740,9 @@ class _RecordMenu extends StatelessWidget {
27402740
Widget build(BuildContext context) {
27412741
var ffi = Provider.of<FfiModel>(context);
27422742
var recordingModel = Provider.of<RecordingModel>(context);
2743-
final visible =
2743+
final hideRecordingButton =
2744+
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
2745+
final visible = !hideRecordingButton &&
27442746
(recordingModel.start || ffi.permissions['recording'] != false);
27452747
if (!visible) return Offstage();
27462748
return _IconMenuButton(

libs/scrap/src/common/record.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer};
2020

2121
const MIN_SECS: u64 = 1;
2222

23+
// Replace characters that are invalid in Windows filename components so recordings remain portable.
24+
// Control characters are also replaced because they can make filenames invalid
25+
// on Windows or invisible and difficult to handle on Linux and macOS.
26+
fn sanitize_filename_component(value: &str) -> String {
27+
value
28+
.chars()
29+
.map(|c| {
30+
if c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
31+
'_'
32+
} else {
33+
c
34+
}
35+
})
36+
.collect()
37+
}
38+
2339
#[derive(Debug, Clone)]
2440
pub struct RecorderContext {
2541
pub server: bool,
@@ -45,7 +61,7 @@ impl RecorderContext2 {
4561
}
4662
let file = if ctx.server { "incoming" } else { "outgoing" }.to_string()
4763
+ "_"
48-
+ &ctx.id.clone()
64+
+ &sanitize_filename_component(&ctx.id)
4965
+ &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string()
5066
+ &format!(
5167
"{}{}_",
@@ -421,3 +437,24 @@ impl Drop for HwRecorder {
421437
self.ctx.tx.as_ref().map(|tx| tx.send(state));
422438
}
423439
}
440+
441+
#[cfg(test)]
442+
mod tests {
443+
use super::sanitize_filename_component;
444+
445+
#[test]
446+
fn sanitize_recording_filename_component() {
447+
assert_eq!(
448+
sanitize_filename_component("192.168.1.2:21118"),
449+
"192.168.1.2_21118"
450+
);
451+
assert_eq!(
452+
sanitize_filename_component("[2001:db8::1]:21118"),
453+
"[2001_db8__1]_21118"
454+
);
455+
assert_eq!(
456+
sanitize_filename_component("peer/name\\with?bad\nchars"),
457+
"peer_name_with_bad_chars"
458+
);
459+
}
460+
}

src/ui/header.tis

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ class Header: Reactor.Component {
151151
<span #action>{svg_action}</span>
152152
<span #display>{svg_display}</span>
153153
<span #keyboard>{svg_keyboard}</span>
154-
{recording_enabled ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""}
154+
{recording_enabled && show_recording_button ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""}
155155
{this.renderKeyboardPop()}
156156
{this.renderDisplayPop()}
157157
{this.renderActionPop()}

src/ui/remote.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession {
504504
fn get_id();
505505
fn get_default_pi();
506506
fn get_option(String);
507+
fn get_local_option(String);
507508
fn t(String);
508509
fn set_option(String, String);
509510
fn input_os_password(String, bool);
@@ -638,6 +639,10 @@ impl SciterSession {
638639
crate::client::translate(name)
639640
}
640641

642+
pub fn get_local_option(&self, key: String) -> String {
643+
crate::ui_interface::get_local_option(key)
644+
}
645+
641646
pub fn get_icon(&self) -> String {
642647
super::get_icon()
643648
}

src/ui/remote.tis

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ var audio_enabled = true; // server side
1717
var file_enabled = true; // server side
1818
var restart_enabled = true; // server side
1919
var recording_enabled = true; // server side
20+
var show_recording_button = handler.get_local_option("hide-recording-button") != "Y";
2021
var privacy_mode_enabled = true; // server side
2122
var scroll_body = $(body);
2223
var peer_platform = "";

src/ui_interface.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,29 @@ pub fn get_langs() -> String {
911911
json!(x).to_string()
912912
}
913913

914+
// Preserve relative paths for existing configurations and only remove accidental
915+
// surrounding whitespace. Config values are not shell-expanded (for example, `~`).
916+
fn trim_video_save_directory(value: &str) -> Option<&str> {
917+
let value = value.trim();
918+
if !value.is_empty() {
919+
Some(value)
920+
} else {
921+
None
922+
}
923+
}
924+
925+
// A Windows service typically runs with System32 as its working directory, so
926+
// require an absolute path to avoid resolving recordings there unexpectedly.
927+
#[cfg(any(windows, test))]
928+
fn validate_windows_service_video_save_directory(value: &str) -> Option<&str> {
929+
let value = trim_video_save_directory(value)?;
930+
if std::path::Path::new(value).is_absolute() {
931+
Some(value)
932+
} else {
933+
None
934+
}
935+
}
936+
914937
#[inline]
915938
pub fn video_save_directory(root: bool) -> String {
916939
let appname = crate::get_app_name();
@@ -930,6 +953,15 @@ pub fn video_save_directory(root: bool) -> String {
930953
// Currently, only installed windows run as root
931954
#[cfg(windows)]
932955
{
956+
let dir = Config::get_option(OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY);
957+
if let Some(dir) = validate_windows_service_video_save_directory(&dir) {
958+
return dir.to_owned();
959+
}
960+
if !dir.trim().is_empty() {
961+
log::warn!(
962+
"Ignoring {OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY}: path must be absolute"
963+
);
964+
}
933965
let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned());
934966
let dir =
935967
std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",));
@@ -941,8 +973,8 @@ pub fn video_save_directory(root: bool) -> String {
941973
let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY);
942974
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
943975
let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY);
944-
if !dir.is_empty() {
945-
return dir;
976+
if let Some(dir) = trim_video_save_directory(&dir) {
977+
return dir.to_owned();
946978
}
947979
#[cfg(any(target_os = "android", target_os = "ios"))]
948980
if let Ok(home) = config::APP_HOME_DIR.read() {
@@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option<bool> {
17051737
.lock()
17061738
.unwrap()
17071739
}
1740+
1741+
#[cfg(test)]
1742+
mod tests {
1743+
use super::{trim_video_save_directory, validate_windows_service_video_save_directory};
1744+
1745+
#[test]
1746+
fn trim_configured_video_save_directory() {
1747+
assert_eq!(
1748+
trim_video_save_directory(" relative/recordings "),
1749+
Some("relative/recordings")
1750+
);
1751+
assert_eq!(trim_video_save_directory(" "), None);
1752+
}
1753+
1754+
#[test]
1755+
fn validate_service_video_save_directory() {
1756+
let absolute = if cfg!(windows) {
1757+
r"C:\recordings"
1758+
} else {
1759+
"/recordings"
1760+
};
1761+
let padded = format!(" {absolute} ");
1762+
1763+
assert_eq!(
1764+
validate_windows_service_video_save_directory(&padded),
1765+
Some(absolute)
1766+
);
1767+
assert_eq!(
1768+
validate_windows_service_video_save_directory("recordings"),
1769+
None
1770+
);
1771+
assert_eq!(
1772+
validate_windows_service_video_save_directory(&format!("\"{absolute}\"")),
1773+
None
1774+
);
1775+
assert_eq!(validate_windows_service_video_save_directory(" "), None);
1776+
}
1777+
}

0 commit comments

Comments
 (0)