diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 325b17e..59c4a7e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -11,6 +11,7 @@ optdepends=( 'thunar: context menu integration' 'desktop-file-utils: refresh the menu cache after changes' 'libnotify: notifications from context menu actions' + 'zenity: choose the main binary when a bundle has several executables' ) options=('!lto') diff --git a/src/appimage.rs b/src/appimage.rs index d63848a..0af44d3 100644 --- a/src/appimage.rs +++ b/src/appimage.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, ffi::OsStr, fs::File, - io::{BufReader, Read}, + io::{BufReader, Read, Seek, SeekFrom}, path::{Component, Path, PathBuf}, }; @@ -22,13 +22,7 @@ pub struct Embedded { } pub fn read(path: &Path, offset: u64) -> Result { - let file = BufReader::new(File::open(path)?); - let fs = FilesystemReader::from_reader_with_offset(file, offset).with_context(|| { - format!( - "cannot read the squashfs image embedded in {}", - path.display() - ) - })?; + let fs = open(path, offset)?; let image = Image::new(&fs); let desktop = image.root_desktop(); let icon_name = desktop.as_ref().and_then(|d| d.get("Icon")); @@ -36,6 +30,100 @@ pub fn read(path: &Path, offset: u64) -> Result { Ok(Embedded { desktop, icon }) } +pub fn desktop_id(path: &Path, offset: u64) -> Option { + let fs = open(path, offset).ok()?; + let image = Image::new(&fs); + let name = image.root_desktop_path()?.file_name()?; + Some(name.to_string_lossy().into_owned()) +} + +pub fn update_info(path: &Path) -> Option { + let mut file = File::open(path).ok()?; + let mut header = [0u8; 64]; + file.read_exact(&mut header).ok()?; + if !header.starts_with(b"\x7fELF") { + return None; + } + let little_endian = header[5] == 1; + let wide = match header[4] { + 1 => false, + 2 => true, + _ => return None, + }; + let uint = |bytes: &[u8], offset: usize, len: usize| -> Option { + let bytes = bytes.get(offset..offset + len)?; + let fold = |acc: u64, &b: &u8| (acc << 8) | u64::from(b); + Some(if little_endian { + bytes.iter().rev().fold(0, fold) + } else { + bytes.iter().fold(0, fold) + }) + }; + let (shoff, shentsize, shnum, shstrndx) = if wide { + ( + uint(&header, 0x28, 8)?, + uint(&header, 0x3A, 2)?, + uint(&header, 0x3C, 2)?, + uint(&header, 0x3E, 2)?, + ) + } else { + ( + uint(&header, 0x20, 4)?, + uint(&header, 0x2E, 2)?, + uint(&header, 0x30, 2)?, + uint(&header, 0x32, 2)?, + ) + }; + let mut table = vec![0u8; usize::try_from(shentsize * shnum).ok()?]; + file.seek(SeekFrom::Start(shoff)).ok()?; + file.read_exact(&mut table).ok()?; + let section = |index: u64| -> Option<(u64, u64, u64)> { + let base = usize::try_from(index * shentsize).ok()?; + let name = uint(&table, base, 4)?; + Some(if wide { + ( + name, + uint(&table, base + 0x18, 8)?, + uint(&table, base + 0x20, 8)?, + ) + } else { + ( + name, + uint(&table, base + 0x10, 4)?, + uint(&table, base + 0x14, 4)?, + ) + }) + }; + let mut read_at = |offset: u64, size: u64| -> Option> { + let mut data = vec![0u8; usize::try_from(size.min(1 << 16)).ok()?]; + file.seek(SeekFrom::Start(offset)).ok()?; + file.read_exact(&mut data).ok()?; + Some(data) + }; + let (_, names_offset, names_size) = section(shstrndx)?; + let names = read_at(names_offset, names_size)?; + let (_, offset, size) = (0..shnum).filter_map(section).find(|(name, _, _)| { + usize::try_from(*name) + .ok() + .and_then(|start| names.get(start..)) + .is_some_and(|rest| rest.starts_with(b".upd_info\0")) + })?; + let data = read_at(offset, size)?; + let end = data.iter().position(|&b| b == 0).unwrap_or(data.len()); + let info = String::from_utf8_lossy(&data[..end]).trim().to_string(); + (!info.is_empty()).then_some(info) +} + +fn open(path: &Path, offset: u64) -> Result> { + let file = BufReader::new(File::open(path)?); + FilesystemReader::from_reader_with_offset(file, offset).with_context(|| { + format!( + "cannot read the squashfs image embedded in {}", + path.display() + ) + }) +} + struct Image<'a, 'b> { fs: &'a FilesystemReader<'b>, nodes: HashMap>, @@ -69,14 +157,15 @@ impl<'a, 'b> Image<'a, 'b> { None } - fn root_desktop(&self) -> Option { - let mut candidates: Vec<&PathBuf> = self - .nodes + fn root_desktop_path(&self) -> Option<&PathBuf> { + self.nodes .keys() .filter(|p| p.components().count() == 1 && p.extension() == Some(OsStr::new("desktop"))) - .collect(); - candidates.sort(); - let data = self.read(candidates.first()?)?; + .min() + } + + fn root_desktop(&self) -> Option { + let data = self.read(self.root_desktop_path()?)?; Some(DesktopEntry::parse(&String::from_utf8_lossy(&data))) } diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..2d45102 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,67 @@ +use std::path::Path; + +use crate::{ + appimage, + detect::{self, Kind}, +}; + +const UPDATE: &str = "update:"; +const DESKTOP: &str = "desktop:"; +const NAME: &str = "name:"; + +const ARCH_TOKENS: [&str; 8] = [ + "x86", "amd64", "aarch64", "arm64", "armhf", "i386", "i686", "linux", +]; + +pub fn identify(path: &Path) -> String { + update(path) + .or_else(|| desktop(path)) + .unwrap_or_else(|| name(path)) +} + +pub fn matches(identity: &str, path: &Path) -> bool { + let actual = if identity.starts_with(UPDATE) { + update(path) + } else if identity.starts_with(DESKTOP) { + desktop(path) + } else { + Some(name(path)) + }; + actual.as_deref() == Some(identity) +} + +pub fn is_strong(identity: &str) -> bool { + !identity.starts_with(NAME) +} + +pub fn name(path: &Path) -> String { + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let base = stem + .split(['-', '_', '.', ' ']) + .filter(|token| { + let versioned = token.starts_with(|c: char| c.is_ascii_digit()) + || token + .strip_prefix('v') + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())); + !token.is_empty() && !versioned && !ARCH_TOKENS.contains(token) + }) + .collect::>() + .join("-"); + format!("{NAME}{}", if base.is_empty() { stem } else { base }) +} + +fn update(path: &Path) -> Option { + appimage::update_info(path).map(|info| format!("{UPDATE}{info}")) +} + +fn desktop(path: &Path) -> Option { + match detect::detect(path).ok()? { + Kind::AppImage(Some(offset)) => { + appimage::desktop_id(path, offset).map(|id| format!("{DESKTOP}{id}")) + } + _ => None, + } +} diff --git a/src/main.rs b/src/main.rs index 5118fd6..ca5b3a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ mod config; mod desktop; mod detect; mod fastfetch; +mod identity; +mod picker; mod store; mod thunar; mod tui; @@ -37,6 +39,16 @@ enum Command { #[arg(required = true)] files: Vec, }, + /// Move a binary and its companion files into their own folder and create a menu entry + Bundle { + #[arg(long)] + notify: bool, + /// The file that starts the application, detected when omitted + #[arg(long)] + main: Option, + #[arg(required = true)] + files: Vec, + }, /// Remove the menu entries of integrated files Remove { #[arg(long)] @@ -47,6 +59,15 @@ enum Command { #[arg(required = true)] files: Vec, }, + /// Point an entry at a different executable, e.g. after an AppImage renamed itself + Relink { + /// The entry's id (ratbin-…), name, or current file + entry: String, + /// The executable the entry should start + file: PathBuf, + }, + /// Point entries whose file was renamed or replaced by an update at the new file + Repair, /// List integrated files List, /// Open the interactive manager (default) @@ -84,6 +105,16 @@ fn run(cli: Cli) -> Result { let apps_dir = Config::load()?.apps_dir()?; store::integrate(&apps_dir, file).map(|i| i.name()) })), + Command::Bundle { + notify, + main, + files, + } => { + let result = Config::load()?.apps_dir().and_then(|apps_dir| { + store::integrate_bundle(&apps_dir, &files, main.as_deref(), picker::choose_main) + }); + Ok(report("Integrated", notify, vec![result.map(|i| i.name())])) + } Command::Remove { notify, delete, @@ -91,6 +122,23 @@ fn run(cli: Cli) -> Result { } => Ok(batch("Removed", notify, &files, |file| { store::remove_path(file, delete) })), + Command::Relink { entry, file } => { + let mut integration = store::find_entry(&entry)?; + store::relink(&mut integration, &file)?; + println!( + "Relinked: {} → {}", + integration.name(), + integration.source().display() + ); + Ok(ExitCode::SUCCESS) + } + Command::Repair => { + let results = store::repair()?; + if results.is_empty() { + println!("Nothing to repair"); + } + Ok(report("Repaired", false, results)) + } Command::List => { for integration in store::list()? { println!( @@ -133,10 +181,14 @@ fn batch( files: &[PathBuf], op: impl Fn(&Path) -> Result, ) -> ExitCode { + report(verb, notify, files.iter().map(|file| op(file)).collect()) +} + +fn report(verb: &str, notify: bool, results: Vec>) -> ExitCode { let mut done = Vec::new(); let mut failed = Vec::new(); - for file in files { - match op(file) { + for result in results { + match result { Ok(name) => { println!("{verb}: {name}"); done.push(name); diff --git a/src/picker.rs b/src/picker.rs new file mode 100644 index 0000000..7969e8d --- /dev/null +++ b/src/picker.rs @@ -0,0 +1,67 @@ +use std::{ + io, + path::PathBuf, + process::{Command, Output}, +}; + +use anyhow::{Context, Result, bail}; + +const PROMPT: &str = "Which file starts the application?"; + +pub fn choose_main(candidates: &[PathBuf]) -> Result { + let names: Vec = candidates + .iter() + .map(|path| { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + }) + .collect(); + let output = match zenity(&names) { + Err(e) if e.kind() == io::ErrorKind::NotFound => kdialog(&names), + other => other, + }; + let output = match output { + Ok(output) => output, + Err(e) if e.kind() == io::ErrorKind::NotFound => bail!( + "several executables selected ({}); choose one with --main", + names.join(", ") + ), + Err(e) => return Err(e).context("cannot show the selection dialog"), + }; + if !output.status.success() { + bail!("no main executable chosen"); + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .ok() + .and_then(|index| candidates.get(index).cloned()) + .context("the selection dialog returned an unknown choice") +} + +fn zenity(names: &[String]) -> io::Result { + let mut command = Command::new("zenity"); + command.args([ + "--list", + "--title=ratbin", + &format!("--text={PROMPT}"), + "--column=#", + "--column=File", + "--hide-column=1", + "--print-column=1", + ]); + for (index, name) in names.iter().enumerate() { + command.arg(index.to_string()).arg(name); + } + command.output() +} + +fn kdialog(names: &[String]) -> io::Result { + let mut command = Command::new("kdialog"); + command.args(["--title", "ratbin", "--menu", PROMPT]); + for (index, name) in names.iter().enumerate() { + command.arg(index.to_string()).arg(name); + } + command.output() +} diff --git a/src/store.rs b/src/store.rs index 8f08dcd..4990ce5 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,21 +1,27 @@ use std::{ + collections::HashSet, + ffi::OsStr, fs, io, - os::unix::fs::PermissionsExt, + os::unix::fs::{PermissionsExt, symlink}, path::{Path, PathBuf}, process::{Command, Stdio}, + time::SystemTime, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use crate::{ appimage, config, desktop::{self, DesktopEntry}, detect::{self, Kind}, + identity, }; const KEY_SOURCE: &str = "X-Ratbin-Source"; const KEY_ARGS: &str = "X-Ratbin-Args"; const KEY_KIND: &str = "X-Ratbin-Kind"; +const KEY_BUNDLE: &str = "X-Ratbin-Bundle"; +const KEY_IDENTITY: &str = "X-Ratbin-Identity"; const PREFIX: &str = "ratbin-"; pub struct Integration { @@ -50,6 +56,10 @@ impl Integration { self.entry.get(KEY_KIND).unwrap_or_default() } + pub fn bundle(&self) -> Option { + self.entry.get(KEY_BUNDLE).map(PathBuf::from) + } + pub fn fields(&self) -> Fields { let get = |key: &str| self.entry.get(key).unwrap_or_default(); Fields { @@ -119,18 +129,75 @@ pub fn list() -> Result> { Ok(out) } -fn find(source: &Path) -> Result> { - Ok(list()?.into_iter().find(|i| i.source() == source)) +fn find(path: &Path) -> Result> { + Ok(list()? + .into_iter() + .find(|i| i.source() == path || i.bundle().as_deref() == Some(path))) } -pub fn integrate(apps_dir: &Path, path: &Path) -> Result { - let src = path - .canonicalize() - .with_context(|| format!("cannot access {}", path.display()))?; +pub fn find_entry(query: &str) -> Result { + let path = fs::canonicalize(query).or_else(|_| std::path::absolute(query))?; + let mut matches: Vec = list()? + .into_iter() + .filter(|i| { + i.source() == path + || i.bundle().as_deref() == Some(path.as_path()) + || i.desktop_path.file_stem() == Some(OsStr::new(query)) + || i.name().eq_ignore_ascii_case(query) + }) + .collect(); + match matches.len() { + 0 => bail!("no integrated entry matches \"{query}\""), + 1 => Ok(matches.remove(0)), + _ => bail!( + "\"{query}\" matches several entries ({}); use the entry id instead", + matches + .iter() + .map(|i| file_stem(&i.desktop_path)) + .collect::>() + .join(", ") + ), + } +} + +pub fn relink_candidates(integration: &Integration) -> Result> { + let source = integration.source(); + let dir = match integration + .bundle() + .or_else(|| source.parent().map(Path::to_path_buf)) + { + Some(dir) if dir.is_dir() => dir, + _ => config::Config::load()?.apps_dir()?, + }; + let taken: HashSet = list()? + .iter() + .filter(|i| i.desktop_path != integration.desktop_path) + .map(Integration::source) + .collect(); + let Ok(entries) = fs::read_dir(&dir) else { + return Ok(Vec::new()); + }; + let mut files: Vec<(PathBuf, SystemTime)> = entries + .flatten() + .map(|e| e.path()) + .filter(|path| !taken.contains(path) && is_candidate(path)) + .map(|path| { + let modified = modified(&path); + (path, modified) + }) + .collect(); + files.sort_by_key(|(_, modified)| std::cmp::Reverse(*modified)); + Ok(files.into_iter().map(|(path, _)| path).collect()) +} + +pub fn relink(integration: &mut Integration, path: &Path) -> Result<()> { + let src = canonical(path)?; if !src.is_file() { bail!("{} is not a file", src.display()); } - if let Some(existing) = find(&src)? { + if let Some(existing) = find(&src)? + && existing.desktop_path != integration.desktop_path + { bail!( "{} is already integrated as \"{}\"", src.display(), @@ -138,10 +205,185 @@ pub fn integrate(apps_dir: &Path, path: &Path) -> Result { ); } let kind = detect::detect(&src)?; - let target = place(apps_dir, &src)?; - make_executable(&target)?; - let id = unique_id(&target)?; - let entry = build_entry(&kind, &target, &id)?; + make_executable(&src)?; + + let bundle = integration.bundle(); + let entry = &mut integration.entry; + let fallbacks = [Kind::Elf, Kind::Script].map(|k| k.fallback_icon()); + if entry + .get("Icon") + .is_some_and(|icon| fallbacks.contains(&icon.as_str())) + { + entry.set("Icon", kind.fallback_icon()); + } + if bundle.is_some_and(|bundle| src.parent() != Some(bundle.as_path())) { + entry.remove(KEY_BUNDLE); + entry.remove("Path"); + } + entry.map_values("Exec", |exec| { + desktop::build_exec(&src, desktop::exec_args(exec)) + }); + let args = entry.get(KEY_ARGS).unwrap_or_default(); + entry.set("Exec", &desktop::build_exec(&src, &args)); + entry.set(KEY_SOURCE, &src.to_string_lossy()); + entry.set(KEY_KIND, kind.label()); + entry.set(KEY_IDENTITY, &identity::identify(&src)); + save(integration) +} + +pub fn repair() -> Result>> { + Ok(list()? + .into_iter() + .filter_map(|mut integration| repair_one(&mut integration).transpose()) + .collect()) +} + +fn repair_one(integration: &mut Integration) -> Result> { + let source = integration.source(); + let present = source.is_file(); + let identity = match integration.entry.get(KEY_IDENTITY) { + Some(identity) => identity, + None if present => { + let identity = identity::identify(&source); + integration.entry.set(KEY_IDENTITY, &identity); + save(integration)?; + identity + } + None => identity::name(&source), + }; + // Weak name-based identities only fix missing files, never replace a working one. + if present && !identity::is_strong(&identity) { + return Ok(None); + } + let current = modified(&source); + let replacement = relink_candidates(integration)? + .into_iter() + .take_while(|path| !present || modified(path) > current) + .find(|path| identity::matches(&identity, path)); + let Some(replacement) = replacement else { + if present { + return Ok(None); + } + return Err(anyhow!( + "{}: {} is missing and no replacement was found", + integration.name(), + source.display() + )); + }; + relink(integration, &replacement)?; + Ok(Some(format!( + "{} → {}", + integration.name(), + replacement + .file_name() + .unwrap_or_default() + .to_string_lossy() + ))) +} + +pub fn integrate(apps_dir: &Path, path: &Path) -> Result { + let src = canonical(path)?; + let kind = check_main(&src)?; + let apps_dir = prepare_apps_dir(apps_dir)?; + let target = if src.starts_with(&apps_dir) { + src + } else { + let dest = apps_dir.join(file_name(&src)?); + move_to(&src, &dest)?; + dest + }; + create(&kind, &target, None) +} + +pub fn integrate_bundle( + apps_dir: &Path, + paths: &[PathBuf], + main: Option<&Path>, + choose: impl FnOnce(&[PathBuf]) -> Result, +) -> Result { + let mut srcs = Vec::new(); + for path in paths.iter().map(PathBuf::as_path).chain(main) { + let src = canonical(path)?; + if !srcs.contains(&src) { + srcs.push(src); + } + } + let main = match main { + Some(main) => canonical(main)?, + None => { + let mut candidates: Vec = srcs + .iter() + .filter(|src| is_candidate(src)) + .cloned() + .collect(); + match candidates.len() { + 0 => bail!("the selection contains no AppImage, ELF binary or script"), + 1 => candidates.remove(0), + _ => choose(&candidates)?, + } + } + }; + let kind = check_main(&main)?; + let apps_dir = prepare_apps_dir(apps_dir)?; + let bundle = apps_dir.join(file_stem(&main)); + let in_place = srcs + .iter() + .all(|src| src.parent() == Some(bundle.as_path())); + if !in_place { + if bundle.exists() { + bail!("{} already exists", bundle.display()); + } + let mut names = HashSet::new(); + for src in &srcs { + let name = file_name(src)?; + if !names.insert(name) { + bail!( + "more than one selected item is named {}", + name.to_string_lossy() + ); + } + } + fs::create_dir(&bundle).with_context(|| format!("cannot create {}", bundle.display()))?; + for src in &srcs { + move_to(src, &bundle.join(file_name(src)?))?; + } + } + let target = bundle.join(file_name(&main)?); + create(&kind, &target, Some(&bundle)) +} + +fn check_main(src: &Path) -> Result { + if !src.is_file() { + bail!("{} is not a file", src.display()); + } + if let Some(existing) = find(src)? { + bail!( + "{} is already integrated as \"{}\"", + src.display(), + existing.name() + ); + } + detect::detect(src) +} + +fn is_candidate(path: &Path) -> bool { + let name = path + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + let shared_lib = name.ends_with(".so") || name.contains(".so."); + path.is_file() && !shared_lib && detect::detect(path).is_ok() +} + +fn create(kind: &Kind, target: &Path, bundle: Option<&Path>) -> Result { + make_executable(target)?; + let id = unique_id(target)?; + let mut entry = build_entry(kind, target, &id)?; + if let Some(bundle) = bundle { + let bundle = bundle.to_string_lossy(); + entry.set("Path", &bundle); + entry.set(KEY_BUNDLE, &bundle); + } let integration = Integration { desktop_path: config::applications_dir()?.join(format!("{id}.desktop")), entry, @@ -151,9 +393,7 @@ pub fn integrate(apps_dir: &Path, path: &Path) -> Result { } pub fn remove_path(path: &Path, delete_file: bool) -> Result { - let src = path - .canonicalize() - .with_context(|| format!("cannot access {}", path.display()))?; + let src = canonical(path)?; let integration = find(&src)?.with_context(|| format!("{} is not integrated", src.display()))?; remove(&integration, delete_file)?; @@ -168,7 +408,23 @@ pub fn remove(integration: &Integration, delete_file: bool) -> Result<()> { remove_if_exists(&icon)?; } if delete_file { - remove_if_exists(&integration.source())?; + let source = integration.source(); + match integration.bundle() { + Some(bundle) if source.parent() == Some(bundle.as_path()) => { + match fs::remove_dir_all(&bundle) { + Err(e) if e.kind() != io::ErrorKind::NotFound => { + Err(e).with_context(|| format!("cannot remove {}", bundle.display()))? + } + _ => {} + } + } + Some(bundle) => bail!( + "{} is not inside {}, refusing to delete the folder", + source.display(), + bundle.display() + ), + None => remove_if_exists(&source)?, + } } refresh_menu(); Ok(()) @@ -222,27 +478,57 @@ fn build_entry(kind: &Kind, target: &Path, id: &str) -> Result { entry.set("Icon", &icon); entry.set(KEY_SOURCE, &target.to_string_lossy()); entry.set(KEY_KIND, kind.label()); + entry.set(KEY_IDENTITY, &identity::identify(target)); entry.set(KEY_ARGS, &args); entry.set("Exec", &desktop::build_exec(target, &args)); Ok(entry) } -fn place(apps_dir: &Path, src: &Path) -> Result { +fn canonical(path: &Path) -> Result { + path.canonicalize() + .with_context(|| format!("cannot access {}", path.display())) +} + +fn prepare_apps_dir(apps_dir: &Path) -> Result { fs::create_dir_all(apps_dir) .with_context(|| format!("cannot create {}", apps_dir.display()))?; - let apps_dir = apps_dir.canonicalize()?; - if src.starts_with(&apps_dir) { - return Ok(src.to_path_buf()); - } - let dest = apps_dir.join(src.file_name().context("path has no file name")?); + Ok(apps_dir.canonicalize()?) +} + +fn file_name(path: &Path) -> Result<&OsStr> { + path.file_name().context("path has no file name") +} + +fn move_to(src: &Path, dest: &Path) -> Result<()> { if dest.exists() { bail!("{} already exists", dest.display()); } - if fs::rename(src, &dest).is_err() { - fs::copy(src, &dest).with_context(|| format!("cannot copy to {}", dest.display()))?; - fs::remove_file(src).with_context(|| format!("cannot remove {}", src.display()))?; + if fs::rename(src, dest).is_ok() { + return Ok(()); + } + copy_all(src, dest).with_context(|| format!("cannot copy to {}", dest.display()))?; + if src.is_dir() { + fs::remove_dir_all(src) + } else { + fs::remove_file(src) + } + .with_context(|| format!("cannot remove {}", src.display())) +} + +fn copy_all(src: &Path, dest: &Path) -> io::Result<()> { + let meta = fs::symlink_metadata(src)?; + if meta.is_symlink() { + symlink(fs::read_link(src)?, dest) + } else if meta.is_dir() { + fs::create_dir(dest)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + copy_all(&entry.path(), &dest.join(entry.file_name()))?; + } + fs::set_permissions(dest, meta.permissions()) + } else { + fs::copy(src, dest).map(drop) } - Ok(dest) } fn make_executable(path: &Path) -> Result<()> { @@ -302,6 +588,12 @@ fn file_stem(path: &Path) -> String { .unwrap_or_default() } +fn modified(path: &Path) -> SystemTime { + fs::metadata(path) + .and_then(|m| m.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) +} + fn remove_if_exists(path: &Path) -> Result<()> { match fs::remove_file(path) { Err(e) if e.kind() != io::ErrorKind::NotFound => { diff --git a/src/thunar.rs b/src/thunar.rs index 7e41109..b83873d 100644 --- a/src/thunar.rs +++ b/src/thunar.rs @@ -3,7 +3,9 @@ use std::{fs, path::PathBuf}; use anyhow::{Context, Result}; const SKELETON: &str = "\n\n\n"; -const IDS: [&str; 2] = ["ratbin-integrate", "ratbin-remove"]; +const IDS: [&str; 3] = ["ratbin-integrate", "ratbin-bundle", "ratbin-remove"]; +const FILE_CONDITIONS: &str = "\t\n\t\n"; +const ANY_CONDITIONS: &str = "\t\n\t\n\t\n\t\n\t\n\t\n"; pub fn install() -> Result { let exe = std::env::current_exe()?.canonicalize()?; @@ -15,13 +17,23 @@ pub fn install() -> Result { "application-x-executable", &format!("{exe} integrate --notify %F"), "Move to the applications folder and add a menu entry", + FILE_CONDITIONS, ), action( IDS[1], + "Integrate as bundle", + "folder-new", + &format!("{exe} bundle --notify %F"), + "Move the binary and the other selected files into their own folder and add a menu entry", + ANY_CONDITIONS, + ), + action( + IDS[2], "Remove menu integration", "edit-delete", &format!("{exe} remove --notify %F"), "Remove the menu entry created by ratbin", + ANY_CONDITIONS, ), ]; let mut doc = stripped()?; @@ -90,7 +102,14 @@ fn strip(doc: &str) -> String { out } -fn action(id: &str, name: &str, icon: &str, command: &str, description: &str) -> String { +fn action( + id: &str, + name: &str, + icon: &str, + command: &str, + description: &str, + conditions: &str, +) -> String { format!( "\n\ \t{icon}\n\ @@ -101,8 +120,7 @@ fn action(id: &str, name: &str, icon: &str, command: &str, description: &str) -> \t{description}\n\ \t\n\ \t*\n\ - \t\n\ - \t\n\ + {conditions}\ \n", icon = xml_escape(icon), name = xml_escape(name), diff --git a/src/tui.rs b/src/tui.rs index 2c0b2aa..0fd4542 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,4 +1,7 @@ -use std::{io, path::PathBuf}; +use std::{ + io, + path::{Path, PathBuf}, +}; use anyhow::Result; use ratatui::{ @@ -40,6 +43,7 @@ enum Mode { Browse, Edit(Form), Confirm, + Relink(Picker), } struct App { @@ -59,7 +63,9 @@ impl App { status: None, quit: false, }; + let repaired = store::repair(); app.reload(None)?; + app.show_repairs(repaired); Ok(app) } @@ -102,6 +108,13 @@ impl App { KeyCode::Char('s') if ctrl => self.save(), _ => form.on_key(key), }, + Mode::Relink(picker) => match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::Browse, + KeyCode::Down | KeyCode::Char('j') => picker.list.select_next(), + KeyCode::Up | KeyCode::Char('k') => picker.list.select_previous(), + KeyCode::Enter => self.relink(), + _ => {} + }, } } @@ -122,9 +135,12 @@ impl App { self.mode = Mode::Confirm; } } + KeyCode::Char('f') => self.open_relink(), KeyCode::Char('r') => { + let repaired = store::repair(); let result = self.reload(None); self.report(result.map(|()| "Reloaded".into())); + self.show_repairs(repaired); } _ => {} } @@ -145,6 +161,52 @@ impl App { self.report(result.map(|()| format!("Removed {name}"))); } + fn open_relink(&mut self) { + let Some(item) = self.selected() else { + return; + }; + let current = item.source(); + match store::relink_candidates(item) { + Ok(files) if files.is_empty() => { + self.status = Some(("No executables found next to this file".into(), true)); + } + Ok(files) => { + let index = files.iter().position(|f| *f != current).unwrap_or(0); + self.mode = Mode::Relink(Picker { + files, + list: ListState::default().with_selected(Some(index)), + current, + }); + } + Err(e) => self.report(Err(e)), + } + } + + fn relink(&mut self) { + let Mode::Relink(picker) = std::mem::replace(&mut self.mode, Mode::Browse) else { + return; + }; + let Some(file) = picker.list.selected().and_then(|i| picker.files.get(i)) else { + return; + }; + let Some(item) = self + .list + .selected() + .and_then(|index| self.items.get_mut(index)) + else { + return; + }; + let path = item.desktop_path.clone(); + let name = item.name(); + let result = store::relink(item, file); + let reloaded = self.reload(Some(path)); + self.report( + result + .and(reloaded) + .map(|()| format!("{name} now starts {}", file_name(file))), + ); + } + fn save(&mut self) { let Mode::Edit(form) = std::mem::replace(&mut self.mode, Mode::Browse) else { return; @@ -171,6 +233,18 @@ impl App { } } + fn show_repairs(&mut self, repaired: Result>>) { + match repaired { + Ok(results) => { + let fixed: Vec = results.into_iter().filter_map(Result::ok).collect(); + if !fixed.is_empty() { + self.status = Some((format!("Repaired {}", fixed.join(", ")), false)); + } + } + Err(e) => self.report(Err(e)), + } + } + fn report(&mut self, result: Result) { self.status = Some(match result { Ok(message) => (message, false), @@ -211,10 +285,11 @@ impl App { frame.render_widget(self.details(), right); frame.render_widget(self.footer(), footer); - match &self.mode { + match &mut self.mode { Mode::Browse => {} Mode::Edit(form) => form.draw(frame, main), Mode::Confirm => self.draw_confirm(frame, main), + Mode::Relink(picker) => picker.draw(frame, main), } } @@ -250,10 +325,13 @@ impl App { row("File", source.display().to_string()), row("Entry", item.desktop_path.display().to_string()), ]; + if let Some(bundle) = item.bundle() { + lines.insert(lines.len() - 1, row("Folder", bundle.display().to_string())); + } if !source.exists() { lines.push(Line::default()); lines.push(Line::styled( - "The file no longer exists, so this menu entry is broken.", + "The file no longer exists, so this menu entry is broken. Press f to choose its new file.", Style::default().fg(Color::Red), )); } @@ -268,9 +346,10 @@ impl App { return Line::styled(message.clone(), Style::default().fg(color)); } let hints = match self.mode { - Mode::Browse => "↑↓ select enter edit d remove r reload q quit", + Mode::Browse => "↑↓ select enter edit f change file d remove r reload q quit", Mode::Edit(_) => "tab/↑↓ field space toggle ctrl+u clear enter save esc cancel", Mode::Confirm => "e entry only d entry + file esc cancel", + Mode::Relink(_) => "↑↓ select enter use this file esc cancel", }; Line::styled(hints, Style::default().fg(Color::DarkGray)) } @@ -279,11 +358,11 @@ impl App { let Some(item) = self.selected() else { return; }; - let file_name = item - .source() - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); + let (path, suffix) = match item.bundle() { + Some(bundle) => (bundle, "/ and everything in it"), + None => (item.source(), ""), + }; + let file_name = file_name(&path); let rect = popup(area, 64, 6); frame.render_widget(Clear, rect); frame.render_widget( @@ -291,7 +370,7 @@ impl App { Line::from(format!("Remove \"{}\"?", item.name())), Line::default(), Line::from("e remove the menu entry only"), - Line::from(format!("d also delete {file_name}")), + Line::from(format!("d also delete {file_name}{suffix}")), ]) .block( Block::bordered() @@ -303,6 +382,44 @@ impl App { } } +struct Picker { + files: Vec, + list: ListState, + current: PathBuf, +} + +impl Picker { + fn draw(&mut self, frame: &mut Frame, area: Rect) { + let rect = popup(area, 80, self.files.len() as u16 + 2); + frame.render_widget(Clear, rect); + let dir = self + .files + .first() + .and_then(|file| file.parent()) + .map(|dir| dir.display().to_string()) + .unwrap_or_default(); + let items: Vec = self + .files + .iter() + .map(|file| { + let mut spans = vec![Span::raw(file_name(file))]; + if *file == self.current { + spans.push(Span::styled( + " current", + Style::default().fg(Color::DarkGray), + )); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + let list = List::new(items) + .block(Block::bordered().title(format!(" Choose the file in {dir} "))) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) + .highlight_symbol("› "); + frame.render_stateful_widget(list, rect, &mut self.list); + } +} + struct Form { inputs: [Input; 5], terminal: bool, @@ -451,6 +568,12 @@ impl Input { } } +fn file_name(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() +} + fn popup(area: Rect, width: u16, height: u16) -> Rect { let width = width.min(area.width); let height = height.min(area.height);