mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-21 03:40:59 +03:00
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 <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>
This commit is contained in:
@@ -583,6 +583,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
|||||||
}
|
}
|
||||||
// record
|
// record
|
||||||
if (!(isDesktop || isWeb) &&
|
if (!(isDesktop || isWeb) &&
|
||||||
|
bind.mainGetLocalOption(key: kOptionHideRecordingButton) != 'Y' &&
|
||||||
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
(ffi.recordingModel.start || (perms["recording"] != false))) {
|
||||||
v.add(TTextMenu(
|
v.add(TTextMenu(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
|
|||||||
const String kOptionEnableHwcodec = "enable-hwcodec";
|
const String kOptionEnableHwcodec = "enable-hwcodec";
|
||||||
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
|
||||||
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
|
||||||
|
const String kOptionHideRecordingButton = "hide-recording-button";
|
||||||
const String kOptionVideoSaveDirectory = "video-save-directory";
|
const String kOptionVideoSaveDirectory = "video-save-directory";
|
||||||
const String kOptionAccessMode = "access-mode";
|
const String kOptionAccessMode = "access-mode";
|
||||||
const String kOptionEnableKeyboard = "enable-keyboard";
|
const String kOptionEnableKeyboard = "enable-keyboard";
|
||||||
|
|||||||
@@ -2740,7 +2740,9 @@ class _RecordMenu extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var ffi = Provider.of<FfiModel>(context);
|
var ffi = Provider.of<FfiModel>(context);
|
||||||
var recordingModel = Provider.of<RecordingModel>(context);
|
var recordingModel = Provider.of<RecordingModel>(context);
|
||||||
final visible =
|
final hideRecordingButton =
|
||||||
|
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
|
||||||
|
final visible = !hideRecordingButton &&
|
||||||
(recordingModel.start || ffi.permissions['recording'] != false);
|
(recordingModel.start || ffi.permissions['recording'] != false);
|
||||||
if (!visible) return Offstage();
|
if (!visible) return Offstage();
|
||||||
return _IconMenuButton(
|
return _IconMenuButton(
|
||||||
|
|||||||
Submodule libs/hbb_common updated: 7e1c392c62...559176122b
@@ -20,6 +20,22 @@ use webm::mux::{self, Segment, Track, VideoTrack, Writer};
|
|||||||
|
|
||||||
const MIN_SECS: u64 = 1;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RecorderContext {
|
pub struct RecorderContext {
|
||||||
pub server: bool,
|
pub server: bool,
|
||||||
@@ -45,7 +61,7 @@ impl RecorderContext2 {
|
|||||||
}
|
}
|
||||||
let file = if ctx.server { "incoming" } else { "outgoing" }.to_string()
|
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()
|
+ &chrono::Local::now().format("_%Y%m%d%H%M%S%3f_").to_string()
|
||||||
+ &format!(
|
+ &format!(
|
||||||
"{}{}_",
|
"{}{}_",
|
||||||
@@ -421,3 +437,24 @@ impl Drop for HwRecorder {
|
|||||||
self.ctx.tx.as_ref().map(|tx| tx.send(state));
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class Header: Reactor.Component {
|
|||||||
<span #action>{svg_action}</span>
|
<span #action>{svg_action}</span>
|
||||||
<span #display>{svg_display}</span>
|
<span #display>{svg_display}</span>
|
||||||
<span #keyboard>{svg_keyboard}</span>
|
<span #keyboard>{svg_keyboard}</span>
|
||||||
{recording_enabled ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""}
|
{recording_enabled && show_recording_button ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""}
|
||||||
{this.renderKeyboardPop()}
|
{this.renderKeyboardPop()}
|
||||||
{this.renderDisplayPop()}
|
{this.renderDisplayPop()}
|
||||||
{this.renderActionPop()}
|
{this.renderActionPop()}
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession {
|
|||||||
fn get_id();
|
fn get_id();
|
||||||
fn get_default_pi();
|
fn get_default_pi();
|
||||||
fn get_option(String);
|
fn get_option(String);
|
||||||
|
fn get_local_option(String);
|
||||||
fn t(String);
|
fn t(String);
|
||||||
fn set_option(String, String);
|
fn set_option(String, String);
|
||||||
fn input_os_password(String, bool);
|
fn input_os_password(String, bool);
|
||||||
@@ -638,6 +639,10 @@ impl SciterSession {
|
|||||||
crate::client::translate(name)
|
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 {
|
pub fn get_icon(&self) -> String {
|
||||||
super::get_icon()
|
super::get_icon()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var audio_enabled = true; // server side
|
|||||||
var file_enabled = true; // server side
|
var file_enabled = true; // server side
|
||||||
var restart_enabled = true; // server side
|
var restart_enabled = true; // server side
|
||||||
var recording_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 privacy_mode_enabled = true; // server side
|
||||||
var scroll_body = $(body);
|
var scroll_body = $(body);
|
||||||
var peer_platform = "";
|
var peer_platform = "";
|
||||||
|
|||||||
@@ -911,6 +911,29 @@ pub fn get_langs() -> String {
|
|||||||
json!(x).to_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]
|
#[inline]
|
||||||
pub fn video_save_directory(root: bool) -> String {
|
pub fn video_save_directory(root: bool) -> String {
|
||||||
let appname = crate::get_app_name();
|
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
|
// Currently, only installed windows run as root
|
||||||
#[cfg(windows)]
|
#[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 drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned());
|
||||||
let dir =
|
let dir =
|
||||||
std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",));
|
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);
|
let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY);
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||||
let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY);
|
let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY);
|
||||||
if !dir.is_empty() {
|
if let Some(dir) = trim_video_save_directory(&dir) {
|
||||||
return dir;
|
return dir.to_owned();
|
||||||
}
|
}
|
||||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||||
if let Ok(home) = config::APP_HOME_DIR.read() {
|
if let Ok(home) = config::APP_HOME_DIR.read() {
|
||||||
@@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option<bool> {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user