diff --git a/README.md b/README.md index 5c6768c..ab2c4a5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,46 @@ The UI is navigatable via keyboard shortcuts. To change the focus area, hit `Tab - `r`: Restore selected file or folder +## Arguments + +### Usage +``` +Restic-Browser [OPTIONS] +``` + +### Options +``` +-h, --help + Print help information + +--insecure-tls + skip TLS certificate verification when connecting to the repo (insecure) + +--password + password for the repository - NOT RECOMMENDED - USE password-file/command instead. (default: $RESTIC_PASSWORD) + +--password-command + shell command to obtain the repository password from (default: $RESTIC_PASSWORD_COMMAND) + +--password-file + file to read the repository password from (default: $RESTIC_PASSWORD_FILE) + +-r, --repo + repository to show or restore from (default: $RESTIC_REPOSITORY) + +--rclone + ABS path to the rclone executable that should be used for rclone locations. (default: 'rclone') + +--repository-file + file to read the repository location from (default: $RESTIC_REPOSITORY_FILE) + +--restic + ABS path to the restic executable that should be used. (default: find in $PATH) + +-V, --version + Print version information +``` + ## System Requirements #### All platforms diff --git a/package-lock.json b/package-lock.json index e421c34..26d5a02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@tauri-apps/cli": "^1.5.2", + "@types/node": "^20.10.6", "typescript": "^5.2.2", "vite": "^4.4.11" } @@ -655,6 +656,15 @@ "node": ">= 10" } }, + "node_modules/@types/node": { + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz", + "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.4.tgz", @@ -1172,6 +1182,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/vite": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz", diff --git a/package.json b/package.json index 85d9a3e..4339f74 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@tauri-apps/cli": "^1.5.2", + "@types/node": "^20.10.6", "typescript": "^5.2.2", "vite": "^4.4.11" }, diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index fbdbf99..6245e91 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -16,11 +16,7 @@ pub struct AppState { } impl AppState { - pub fn new( - restic: restic::Program, - location: restic::Location, - temp_dir: PathBuf, - ) -> Self { + pub fn new(restic: restic::Program, location: restic::Location, temp_dir: PathBuf) -> Self { let snapshot_ids = HashSet::default(); Self { restic, @@ -35,17 +31,17 @@ impl AppState { } pub fn verify_restic_path(&self) -> Result<(), String> { - if self.restic.path.as_os_str().is_empty() { + if self.restic.restic_path().as_os_str().is_empty() { return Err("No restic executable set".to_string()); - } else if !self.restic.path.exists() { + } else if !self.restic.restic_path().exists() { return Err(format!( "Restic executable '{}' does not exist or can not be accessed.", - self.restic.path.to_string_lossy() + self.restic.restic_path().to_string_lossy() )); - } else if self.restic.version == [0, 0, 0] { + } else if self.restic.restic_version() == [0, 0, 0] { return Err(format!( "Failed to query restic version. Is '{}' a valid restic application?", - self.restic.path.to_string_lossy() + self.restic.restic_path().to_string_lossy() )); } Ok(()) @@ -144,7 +140,7 @@ pub fn verify_restic_path( ) -> Result<(), String> { // verify that restic binary is set let state = app_state.get()?; - if !state.restic.path.exists() { + if !state.restic.restic_path().exists() { // aks user to resolve restic path MessageDialogBuilder::new( "Restic Binary Missing", @@ -163,7 +159,8 @@ Please select your installed restic binary manually in the following dialog.", restic_path.clone().unwrap_or_default().display() ); if let Some(restic_path) = restic_path { - app_state.update_restic(restic::Program::new(restic_path))?; + let rclone_path = state.restic.rclone_path().clone(); + app_state.update_restic(restic::Program::new(restic_path, rclone_path))?; } } Ok(()) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f423e9b..50a05b1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -54,8 +54,16 @@ fn initialize_app(app: &mut tauri::App) -> Result<(), Box // initialize CombinedLogger::init(loggers).unwrap_or_else(|err| eprintln!("Failed to create logger: {err}")); + // common bin directories on macOS + #[cfg(target_os = "macos")] + let common_path = format!( + "/usr/local/bin:/opt/local/bin:/opt/homebrew/bin:{}/bin", + env::var("HOME").unwrap_or("~".into()) + ); + // get restic from args or find restic in path let mut restic_path = None; + let mut rclone_path = None; match app.get_cli_matches() { Ok(matches) => { if let Some(arg) = matches.args.get("restic") { @@ -64,6 +72,12 @@ fn initialize_app(app: &mut tauri::App) -> Result<(), Box log::info!("Got restic as arg {}", path.to_string_lossy()); } } + if let Some(arg) = matches.args.get("rclone") { + rclone_path = arg.value.as_str().map(PathBuf::from); + if let Some(ref path) = rclone_path { + log::info!("Got rclone as arg {}", path.to_string_lossy()); + } + } } Err(err) => log::error!("{}", err.to_string()), } @@ -79,11 +93,8 @@ fn initialize_app(app: &mut tauri::App) -> Result<(), Box if restic_path.is_none() { if let Ok(restic) = which_in( restic::RESTIC_EXECTUABLE_NAME, - Some(format!( - "/usr/local/bin:/opt/local/bin:/opt/homebrew/bin:{}/bin", - env::var("HOME").unwrap_or("~".to_string()) - )), - "/", + Some(common_path.clone()), + env::current_dir().unwrap_or("/".into()), ) { restic_path = Some(restic.clone()); log::info!( @@ -97,6 +108,22 @@ fn initialize_app(app: &mut tauri::App) -> Result<(), Box } } + #[cfg(target_os = "macos")] + // on macOS, try to resolve rclone path in common PATH if it can't be found in path + if rclone_path.is_none() && which(restic::RCLONE_EXECTUABLE_NAME).is_err() { + if let Ok(rclone) = which_in( + restic::RCLONE_EXECTUABLE_NAME, + Some(common_path), + env::current_dir().unwrap_or("/".into()), + ) { + rclone_path = Some(rclone.clone()); + log::info!( + "Found rclone binary in common PATH at '{}'", + rclone.to_string_lossy() + ); + } + } + // get default restic location from args or env let mut location; if let Ok(matches) = app.get_cli_matches() { @@ -126,7 +153,7 @@ fn initialize_app(app: &mut tauri::App) -> Result<(), Box // create new app state app.manage(app::SharedAppState::new(app::AppState::new( - restic::Program::new(restic_path.unwrap_or(PathBuf::new())), + restic::Program::new(restic_path.unwrap_or_default(), rclone_path), location, temp_dir, ))); diff --git a/src-tauri/src/restic/command.rs b/src-tauri/src/restic/command.rs index 242aea7..e30f450 100644 --- a/src-tauri/src/restic/command.rs +++ b/src-tauri/src/restic/command.rs @@ -1,131 +1,21 @@ use std::{ + borrow::Cow, collections::HashMap, + ffi::{OsStr, OsString}, fs, path::PathBuf, process::{Command, Output, Stdio}, - sync::RwLock, }; -use lazy_static::lazy_static; use scopeguard::defer; use crate::restic::*; // ------------------------------------------------------------------------------------------------- -#[cfg(target_os = "windows")] -pub static RESTIC_EXECTUABLE_NAME: &str = "restic.exe"; -#[cfg(not(target_os = "windows"))] -pub static RESTIC_EXECTUABLE_NAME: &str = "restic"; - -/// Exit code a process gets killed with via kill_process_with_id. -#[cfg(target_os = "windows")] -const COMMAND_TERMINATED_EXIT_CODE: u32 = 288; - -// ------------------------------------------------------------------------------------------------- - -/// Tries to gracefully terminate a process with the provided process ID. -#[cfg(target_os = "windows")] -fn terminate_process_with_id(pid: u32) -> Result<(), String> { - use windows_sys::Win32::{ - Foundation::{CloseHandle, GetLastError, BOOL, FALSE, HANDLE, WIN32_ERROR}, - System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}, - }; - log::info!("Killing process with PID {}", pid); - - unsafe { - // Open the process handle with intent to terminate - let handle: HANDLE = OpenProcess(PROCESS_TERMINATE, FALSE, pid); - if handle == 0 { - let error: WIN32_ERROR = GetLastError(); - return Err(format!( - "Failed to obtain handle to process {}: {:#x}", - pid, error - )); - } - // Terminate the process - let result: BOOL = TerminateProcess(handle, COMMAND_TERMINATED_EXIT_CODE); - // Close the handle now that its no longer needed - CloseHandle(handle); - if result == FALSE { - let error: WIN32_ERROR = GetLastError(); - return Err(format!("Failed to terminate process {}: {:#x}", pid, error)); - } - } - Ok(()) -} - -#[cfg(not(target_os = "windows"))] -fn terminate_process_with_id(pid: u32) -> Result<(), String> { - use nix::{ - sys::signal::{self, Signal}, - unistd::Pid, - }; - log::info!("Killing process with PID {}", pid); - signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM).map_err(|err| err.to_string()) -} - -// ------------------------------------------------------------------------------------------------- - -lazy_static! { - /// Currently running restic command processes mapped by command group names. - static ref RUNNING_RESTIC_COMMANDS: RwLock>> = - RwLock::new(HashMap::new()); -} - -/// Kill all running commands from the given command group. -fn terminate_all_commands_in_group(command_group: &str) -> Result<(), String> { - let running_child_ids = { - if let Some(child_ids) = RUNNING_RESTIC_COMMANDS - .write() - .map_err(|err| err.to_string())? - .get_mut(command_group) - { - std::mem::take(child_ids) - } else { - vec![] - } - }; - if !running_child_ids.is_empty() { - log::debug!( - "Terminating {} processes in group '{}'...", - running_child_ids.len(), - command_group - ); - for child_id in running_child_ids { - if let Err(err) = terminate_process_with_id(child_id) { - log::warn!("Failed to kill command with PID {}: {}", child_id, err); - } - } - } - Ok(()) -} - -/// Register the given child it with a command group. -fn add_command_to_group(command_group: &str, child_id: u32) -> Result<(), String> { - log::debug!("Process in group '{command_group}' with PID '{child_id}' started..."); - let mut running_child_ids = RUNNING_RESTIC_COMMANDS - .write() - .map_err(|err| err.to_string())?; - if let Some(child_ids) = running_child_ids.get_mut(command_group) { - child_ids.push(child_id); - } else { - running_child_ids.insert(command_group.to_string(), vec![child_id]); - } - Ok(()) -} - -// Unregister the given child from a command group. -fn remove_command_from_group(command_group: &str, child_id: u32) -> Result<(), String> { - log::debug!("Process in group '{command_group}' with PID '{child_id}' finished"); - let mut running_child_ids = RUNNING_RESTIC_COMMANDS - .write() - .map_err(|err| err.to_string())?; - if let Some(commands) = running_child_ids.get_mut(command_group) { - commands.retain(|id| *id != child_id); - } - Ok(()) -} +/// Command group handling +mod group; +use group::*; // ------------------------------------------------------------------------------------------------- @@ -143,22 +33,53 @@ pub fn new_command(program: &PathBuf) -> Command { // ------------------------------------------------------------------------------------------------- +#[cfg(target_os = "windows")] +pub const RESTIC_EXECTUABLE_NAME: &str = "restic.exe"; +#[cfg(target_os = "windows")] +#[allow(dead_code)] +pub const RCLONE_EXECTUABLE_NAME: &str = "rclone.exe"; + +#[cfg(not(target_os = "windows"))] +pub const RESTIC_EXECTUABLE_NAME: &str = "restic"; +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +pub const RCLONE_EXECTUABLE_NAME: &str = "rclone"; + +// ------------------------------------------------------------------------------------------------- + /// Restic command executable wrapper. #[derive(Debug, Default, Clone)] pub struct Program { - pub version: [i32; 3], // major, minor, rev - pub path: PathBuf, // path to the restic executable + restic_version: [i32; 3], // restic version [major, minor, rev] + restic_path: PathBuf, // path to the restic executable + rclone_path: Option, // optional path to rclone executable } impl Program { - /// Create a new Restic program with the given path. - pub fn new(path: PathBuf) -> Self { + /// Create a new Restic program with the given path and optional path to rclone. + pub fn new(restic_path: PathBuf, rclone_path: Option) -> Self { Self { - version: Self::version(&path), - path, + restic_version: Self::query_restic_version(&restic_path), + restic_path, + rclone_path, } } + /// Restic program's versiion (major, minor, rev). + pub fn restic_version(&self) -> [i32; 3] { + self.restic_version + } + + /// Path to the restic executable. + pub fn restic_path(&self) -> &PathBuf { + &self.restic_path + } + + /// Optional path to the rclone executable. + pub fn rclone_path(&self) -> &Option { + &self.rclone_path + } + /// Run a restic command for the given location with the given args. /// when @param command_group is some, all commands in the same group are /// killed before starting the new command. @@ -176,9 +97,9 @@ impl Program { } } // start a new restic command - let args = Self::args(args, &location); - let envs = Self::envs(&location); - let child = new_command(&self.path) + let args = self.args(args, &location); + let envs = self.envs(&location); + let child = new_command(&self.restic_path) .envs(envs) .args(args.clone()) .stdout(Stdio::piped()) @@ -229,9 +150,9 @@ impl Program { } } // start a new restic command - let args = Self::args(args, &location); - let envs = Self::envs(&location); - let child = new_command(&self.path) + let args = self.args(args, &location); + let envs = self.envs(&location); + let child = new_command(&self.restic_path) .envs(envs) .args(args.clone()) .stdout(std::process::Stdio::from(file)) @@ -262,8 +183,51 @@ impl Program { } } + // Create restic specific args for the given base args and location. + fn args<'a>(&self, args: Vec<&'a str>, location: &Location) -> Vec> { + let mut args = args + .into_iter() + .map(|s| Cow::Borrowed(OsStr::new(s))) + .collect::>(); + if location.prefix.starts_with("rclone") { + if let Some(rclone_path) = &self.rclone_path { + args.push(Cow::Borrowed(OsStr::new("--option"))); + args.push(Cow::Owned(OsString::from(format!( + "rclone.program={}", + &rclone_path.to_str().unwrap_or("[invalid path]") + )))); + } + } + if location.insecure_tls { + args.push(Cow::Borrowed(OsStr::new("--insecure-tls"))); + } + args + } + + // Create restic specific environment variables for the given location + fn envs(&self, location: &Location) -> HashMap { + let mut envs = HashMap::new(); + if !location.path.is_empty() { + if !location.prefix.is_empty() { + envs.insert( + "RESTIC_REPOSITORY".to_string(), + location.prefix.clone() + ":" + &location.path, + ); + } else { + envs.insert("RESTIC_REPOSITORY".to_string(), location.path.clone()); + } + } + if !location.password.is_empty() { + envs.insert("RESTIC_PASSWORD".to_string(), location.password.clone()); + } + for credential in location.credentials.clone() { + envs.insert(credential.name, credential.value); + } + envs + } + /// Log and return error from a restic run command. - fn handle_run_error(args: Vec<&str>, output: Output) -> String { + fn handle_run_error + std::fmt::Debug>(args: Vec, output: Output) -> String { // guess if this is a command which got aborted #[cfg(target_os = "windows")] if output @@ -300,7 +264,7 @@ impl Program { } /// Run restic command to query its version number. - fn version(path: &PathBuf) -> [i32; 3] { + fn query_restic_version(path: &PathBuf) -> [i32; 3] { let mut version = [0, 0, 0]; if !path.exists() { return version; @@ -328,37 +292,4 @@ impl Program { } version } - - // Create restic specific args for the given base args and location. - fn args<'a>(args: Vec<&'a str>, location: &Location) -> Vec<&'a str> { - if location.insecure_tls { - let mut args = args.clone(); - args.push("--insecure-tls"); - args - } else { - args - } - } - - // Create restic specific environment variables for the given location - fn envs(location: &Location) -> HashMap { - let mut envs = HashMap::new(); - if !location.path.is_empty() { - if !location.prefix.is_empty() { - envs.insert( - "RESTIC_REPOSITORY".to_string(), - location.prefix.clone() + ":" + &location.path, - ); - } else { - envs.insert("RESTIC_REPOSITORY".to_string(), location.path.clone()); - } - } - if !location.password.is_empty() { - envs.insert("RESTIC_PASSWORD".to_string(), location.password.clone()); - } - for credential in location.credentials.clone() { - envs.insert(credential.name, credential.value); - } - envs - } } diff --git a/src-tauri/src/restic/command/group.rs b/src-tauri/src/restic/command/group.rs new file mode 100644 index 0000000..90c0977 --- /dev/null +++ b/src-tauri/src/restic/command/group.rs @@ -0,0 +1,112 @@ +use std::{collections::HashMap, sync::RwLock}; + +use lazy_static::lazy_static; + +// ------------------------------------------------------------------------------------------------- + +/// Exit code a process gets killed with via kill_process_with_id. +#[cfg(target_os = "windows")] +pub const COMMAND_TERMINATED_EXIT_CODE: u32 = 288; + +/// Tries to gracefully terminate a process with the provided process ID. +#[cfg(target_os = "windows")] +fn terminate_process_with_id(pid: u32) -> Result<(), String> { + use windows_sys::Win32::{ + Foundation::{CloseHandle, GetLastError, BOOL, FALSE, HANDLE, WIN32_ERROR}, + System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}, + }; + log::info!("Killing process with PID {}", pid); + + unsafe { + // Open the process handle with intent to terminate + let handle: HANDLE = OpenProcess(PROCESS_TERMINATE, FALSE, pid); + if handle == 0 { + let error: WIN32_ERROR = GetLastError(); + return Err(format!( + "Failed to obtain handle to process {}: {:#x}", + pid, error + )); + } + // Terminate the process + let result: BOOL = TerminateProcess(handle, COMMAND_TERMINATED_EXIT_CODE); + // Close the handle now that its no longer needed + CloseHandle(handle); + if result == FALSE { + let error: WIN32_ERROR = GetLastError(); + return Err(format!("Failed to terminate process {}: {:#x}", pid, error)); + } + } + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn terminate_process_with_id(pid: u32) -> Result<(), String> { + use nix::{ + sys::signal::{self, Signal}, + unistd::Pid, + }; + log::info!("Killing process with PID {}", pid); + signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM).map_err(|err| err.to_string()) +} + +// ------------------------------------------------------------------------------------------------- + +lazy_static! { + /// Currently running processes mapped by command group names. + static ref RUNNING_RESTIC_COMMANDS: RwLock>> = + RwLock::new(HashMap::new()); +} + +/// Kill all running commands from the given command group. +pub fn terminate_all_commands_in_group(command_group: &str) -> Result<(), String> { + let running_child_ids = { + if let Some(child_ids) = RUNNING_RESTIC_COMMANDS + .write() + .map_err(|err| err.to_string())? + .get_mut(command_group) + { + std::mem::take(child_ids) + } else { + vec![] + } + }; + if !running_child_ids.is_empty() { + log::debug!( + "Terminating {} processes in group '{}'...", + running_child_ids.len(), + command_group + ); + for child_id in running_child_ids { + if let Err(err) = terminate_process_with_id(child_id) { + log::warn!("Failed to kill command with PID {}: {}", child_id, err); + } + } + } + Ok(()) +} + +/// Register the given child it with a command group. +pub fn add_command_to_group(command_group: &str, child_id: u32) -> Result<(), String> { + log::debug!("Process in group '{command_group}' with PID '{child_id}' started..."); + let mut running_child_ids = RUNNING_RESTIC_COMMANDS + .write() + .map_err(|err| err.to_string())?; + if let Some(child_ids) = running_child_ids.get_mut(command_group) { + child_ids.push(child_id); + } else { + running_child_ids.insert(command_group.to_string(), vec![child_id]); + } + Ok(()) +} + +// Unregister the given child from a command group. +pub fn remove_command_from_group(command_group: &str, child_id: u32) -> Result<(), String> { + log::debug!("Process in group '{command_group}' with PID '{child_id}' finished"); + let mut running_child_ids = RUNNING_RESTIC_COMMANDS + .write() + .map_err(|err| err.to_string())?; + if let Some(commands) = running_child_ids.get_mut(command_group) { + commands.retain(|id| *id != child_id); + } + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4b4a38d..371f973 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -59,6 +59,11 @@ "takesValue": true, "longDescription": "ABS path to the restic executable that should be used. (default: find in $PATH)" }, + { + "name": "rclone", + "takesValue": true, + "longDescription": "ABS path to the rclone executable that should be used for rclone locations. (default: 'rclone')" + }, { "name": "repo", "short": "r", diff --git a/src/components/app-footer.ts b/src/components/app-footer.ts index 169e74f..b60d67a 100644 --- a/src/components/app-footer.ts +++ b/src/components/app-footer.ts @@ -20,7 +20,7 @@ export class ResticBrowserAppFooter extends MobxLitElement { constructor() { super(); - let messageTimeoutId: number | undefined = undefined; + let messageTimeoutId: NodeJS.Timeout | undefined = undefined; mobx.autorun(() => { let newMessage = ""; if (appState.pendingFileDumps.length) {