diff --git a/README.md b/README.md index 2e541fb..883d001 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ cargo bundle-macos --out-dir "$HOME/Applications" The first bundle command writes `target/release/IronStorage.app`. The install form replaces `~/Applications/IronStorage.app` with the freshly built bundle. +The bundle contains the matching `ironstorage` CLI and `ironstorage-tui` binaries; +Settings can install links to them in `~/.local/bin`. Build only the shared storage library or the Rust Apple bridge: diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index e9b3007..de03367 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -11,7 +11,12 @@ product-name = "IronStorage" identifier = "de.rfc1437.ironstorage" category = "utility" icons = ["../../assets/icon-candidates/vault-classic@2x.png"] -before-packaging-command = { script = "cargo build --release --package ironstorage-desktop", dir = "../.." } +before-packaging-command = { script = "cargo build --release --package ironstorage-desktop --package ironstorage-cli --package ironstorage-tui", dir = "../.." } +binaries = [ + { path = "ironstorage-desktop", main = true }, + { path = "ironstorage" }, + { path = "ironstorage-tui" }, +] [package.metadata.packager.macos] signing-identity = "-" diff --git a/apps/desktop/src/launcher.rs b/apps/desktop/src/launcher.rs new file mode 100644 index 0000000..e7820e3 --- /dev/null +++ b/apps/desktop/src/launcher.rs @@ -0,0 +1,167 @@ +#![forbid(unsafe_code)] + +use std::{ + env, fs, io, + path::{Path, PathBuf}, +}; + +const COMMANDS: [&str; 2] = ["ironstorage", "ironstorage-tui"]; + +pub(crate) fn install_packaged_launchers() -> io::Result<[PathBuf; 2]> { + let executable = env::current_exe()?; + let bundle_directory = executable.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "the desktop executable has no parent directory", + ) + })?; + let home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))?; + install_launchers(bundle_directory, &home) +} + +fn install_launchers(bundle_directory: &Path, home: &Path) -> io::Result<[PathBuf; 2]> { + let sources = COMMANDS + .map(|command| bundle_directory.join(format!("{command}{}", env::consts::EXE_SUFFIX))); + for source in &sources { + if !source.is_file() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("packaged command not found: {}", source.display()), + )); + } + } + let sources = [sources[0].canonicalize()?, sources[1].canonicalize()?]; + + let directory = home.join(".local/bin"); + let targets = COMMANDS.map(|command| directory.join(command)); + for target in &targets { + match fs::symlink_metadata(target) { + Ok(metadata) if metadata.is_dir() => { + return Err(io::Error::new( + io::ErrorKind::IsADirectory, + format!("command link target is a directory: {}", target.display()), + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + + fs::create_dir_all(&directory)?; + for (source, target) in sources.iter().zip(&targets) { + if fs::symlink_metadata(target).is_ok() { + fs::remove_file(target)?; + } + symlink(source, target)?; + } + Ok(targets) +} + +#[cfg(unix)] +fn symlink(source: &Path, target: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(source, target) +} + +#[cfg(windows)] +fn symlink(source: &Path, target: &Path) -> io::Result<()> { + std::os::windows::fs::symlink_file(source, target) +} + +#[cfg(not(any(unix, windows)))] +fn symlink(_source: &Path, _target: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "command links are not supported on this platform", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn packaged_commands(directory: &Path) -> [PathBuf; 2] { + let sources = + COMMANDS.map(|command| directory.join(format!("{command}{}", env::consts::EXE_SUFFIX))); + for source in &sources { + fs::write(source, command_contents(source)).expect("write packaged command"); + } + sources + } + + fn command_contents(path: &Path) -> String { + path.file_name() + .expect("command name") + .to_string_lossy() + .into_owned() + } + + #[test] + fn installs_both_packaged_commands_and_replaces_existing_files() { + let bundle = tempfile::tempdir().expect("bundle directory"); + let home = tempfile::tempdir().expect("home directory"); + let sources = packaged_commands(bundle.path()); + let bin = home.path().join(".local/bin"); + fs::create_dir_all(&bin).expect("bin directory"); + fs::write(bin.join("ironstorage"), "old command").expect("old command"); + + let targets = install_launchers(bundle.path(), home.path()).expect("install launchers"); + install_launchers(bundle.path(), home.path()).expect("reinstall launchers"); + + for ((source, target), expected_target) in sources + .iter() + .zip(&targets) + .zip([bin.join("ironstorage"), bin.join("ironstorage-tui")]) + { + assert_eq!(target, &expected_target); + assert_eq!( + fs::read_link(target).expect("read link"), + source.canonicalize().expect("canonical source") + ); + } + } + + #[test] + fn validates_all_sources_before_changing_existing_commands() { + let bundle = tempfile::tempdir().expect("bundle directory"); + let home = tempfile::tempdir().expect("home directory"); + let first = bundle + .path() + .join(format!("ironstorage{}", env::consts::EXE_SUFFIX)); + fs::write(&first, "new command").expect("packaged command"); + let bin = home.path().join(".local/bin"); + fs::create_dir_all(&bin).expect("bin directory"); + let existing = bin.join("ironstorage"); + fs::write(&existing, "old command").expect("old command"); + + let error = install_launchers(bundle.path(), home.path()).expect_err("missing TUI"); + + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_eq!( + fs::read_to_string(existing).expect("existing command"), + "old command" + ); + } + + #[test] + fn rejects_directory_targets_before_changing_other_commands() { + let bundle = tempfile::tempdir().expect("bundle directory"); + let home = tempfile::tempdir().expect("home directory"); + packaged_commands(bundle.path()); + let bin = home.path().join(".local/bin"); + fs::create_dir_all(bin.join("ironstorage-tui")).expect("directory target"); + let existing = bin.join("ironstorage"); + fs::write(&existing, "old command").expect("old command"); + + let error = install_launchers(bundle.path(), home.path()).expect_err("directory target"); + + assert_eq!(error.kind(), io::ErrorKind::IsADirectory); + assert_eq!( + fs::read_to_string(existing).expect("existing command"), + "old command" + ); + } +} diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index cc04488..04a5849 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -4,6 +4,7 @@ mod action; mod editor; mod folder_picker; +mod launcher; #[cfg(target_os = "macos")] mod native_menu; mod navigation; @@ -107,6 +108,7 @@ enum Message { SettingsTimeoutChanged(String), PickSettingsVault, SettingsVaultPicked(Result, String>), + InstallCommandLinks, SaveSettings, SettingsFinished { generation: u64, @@ -780,6 +782,7 @@ struct SettingsForm { authentication_timeout: String, saving: bool, error: Option, + command_links_status: Option, } impl SettingsForm { @@ -794,6 +797,7 @@ impl SettingsForm { .to_string(), saving: false, error: None, + command_links_status: None, } } @@ -1054,6 +1058,22 @@ impl App { } } } + Message::InstallCommandLinks => { + if let Some(UtilityView::Settings(form)) = &mut self.utility + && !form.saving + { + form.command_links_status = + Some(match launcher::install_packaged_launchers() { + Ok(targets) => format!( + "Installed {} and {}.", + targets[0].display(), + targets[1].display() + ), + Err(error) => format!("Command links were not installed: {error}"), + }); + self.status = form.command_links_status.clone().unwrap_or_default(); + } + } Message::SaveSettings => return self.begin_settings_save(), Message::SettingsFinished { generation, result } => { if generation != self.settings_generation { @@ -3918,7 +3938,19 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa text_input("Timeout seconds", &form.authentication_timeout) .on_input(Message::SettingsTimeoutChanged) .on_submit(Message::SaveSettings), - ); + ) + .push(text("Command-line tools")) + .push(text( + "Install links to the CLI and TUI embedded in this application bundle in ~/.local/bin. Existing command files or links are replaced.", + )) + .push(if form.saving { + button("Install CLI and TUI Links") + } else { + button("Install CLI and TUI Links").on_press(Message::InstallCommandLinks) + }); + if let Some(status) = &form.command_links_status { + content = content.push(text(status)); + } if let Some(storage) = &app.storage { content = content .push(text(format!(