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:
21pages
2026-07-25 15:21:13 +08:00
committed by GitHub
parent ad9dac1001
commit cefff781d4
9 changed files with 123 additions and 6 deletions

View File

@@ -151,7 +151,7 @@ class Header: Reactor.Component {
<span #action>{svg_action}</span>
<span #display>{svg_display}</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.renderDisplayPop()}
{this.renderActionPop()}

View File

@@ -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()
}

View File

@@ -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 = "";

View File

@@ -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<bool> {
.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);
}
}