Bundle CLI launchers with desktop app
This commit is contained in:
@@ -127,6 +127,8 @@ cargo bundle-macos --out-dir "$HOME/Applications"
|
|||||||
|
|
||||||
The first bundle command writes `target/release/IronStorage.app`. The install
|
The first bundle command writes `target/release/IronStorage.app`. The install
|
||||||
form replaces `~/Applications/IronStorage.app` with the freshly built bundle.
|
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:
|
Build only the shared storage library or the Rust Apple bridge:
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ product-name = "IronStorage"
|
|||||||
identifier = "de.rfc1437.ironstorage"
|
identifier = "de.rfc1437.ironstorage"
|
||||||
category = "utility"
|
category = "utility"
|
||||||
icons = ["../../assets/icon-candidates/vault-classic@2x.png"]
|
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]
|
[package.metadata.packager.macos]
|
||||||
signing-identity = "-"
|
signing-identity = "-"
|
||||||
|
|||||||
167
apps/desktop/src/launcher.rs
Normal file
167
apps/desktop/src/launcher.rs
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
mod action;
|
mod action;
|
||||||
mod editor;
|
mod editor;
|
||||||
mod folder_picker;
|
mod folder_picker;
|
||||||
|
mod launcher;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod native_menu;
|
mod native_menu;
|
||||||
mod navigation;
|
mod navigation;
|
||||||
@@ -107,6 +108,7 @@ enum Message {
|
|||||||
SettingsTimeoutChanged(String),
|
SettingsTimeoutChanged(String),
|
||||||
PickSettingsVault,
|
PickSettingsVault,
|
||||||
SettingsVaultPicked(Result<Option<PathBuf>, String>),
|
SettingsVaultPicked(Result<Option<PathBuf>, String>),
|
||||||
|
InstallCommandLinks,
|
||||||
SaveSettings,
|
SaveSettings,
|
||||||
SettingsFinished {
|
SettingsFinished {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
@@ -780,6 +782,7 @@ struct SettingsForm {
|
|||||||
authentication_timeout: String,
|
authentication_timeout: String,
|
||||||
saving: bool,
|
saving: bool,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
command_links_status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsForm {
|
impl SettingsForm {
|
||||||
@@ -794,6 +797,7 @@ impl SettingsForm {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
saving: false,
|
saving: false,
|
||||||
error: None,
|
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::SaveSettings => return self.begin_settings_save(),
|
||||||
Message::SettingsFinished { generation, result } => {
|
Message::SettingsFinished { generation, result } => {
|
||||||
if generation != self.settings_generation {
|
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)
|
text_input("Timeout seconds", &form.authentication_timeout)
|
||||||
.on_input(Message::SettingsTimeoutChanged)
|
.on_input(Message::SettingsTimeoutChanged)
|
||||||
.on_submit(Message::SaveSettings),
|
.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 {
|
if let Some(storage) = &app.storage {
|
||||||
content = content
|
content = content
|
||||||
.push(text(format!(
|
.push(text(format!(
|
||||||
|
|||||||
Reference in New Issue
Block a user