Add bundles, relinking and repair of updated entries

- bundle: move a binary and its companion files into their own folder,
  picking the main executable with --main, zenity or kdialog
- relink: point an entry at a different executable
- repair: follow AppImages that were renamed or replaced by an update,
  matched by update info, embedded desktop id or version-stripped name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maruandClaude Opus 5.5 committed 2026-11-05 09:47:45 +02:00
1 parent 296b987e3b
commit aab55369f6
8 files changed
+766 -57

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ optdepends=(
'thunar: context menu integration' 'thunar: context menu integration'
'desktop-file-utils: refresh the menu cache after changes' 'desktop-file-utils: refresh the menu cache after changes'
'libnotify: notifications from context menu actions' 'libnotify: notifications from context menu actions'
'zenity: choose the main binary when a bundle has several executables'
) )
options=('!lto') options=('!lto')
+103 -14
View File
@@ -2,7 +2,7 @@ use std::{
collections::HashMap, collections::HashMap,
ffi::OsStr, ffi::OsStr,
fs::File, fs::File,
io::{BufReader, Read}, io::{BufReader, Read, Seek, SeekFrom},
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
}; };
@@ -22,13 +22,7 @@ pub struct Embedded {
} }
pub fn read(path: &Path, offset: u64) -> Result<Embedded> { pub fn read(path: &Path, offset: u64) -> Result<Embedded> {
let file = BufReader::new(File::open(path)?); let fs = open(path, offset)?;
let fs = FilesystemReader::from_reader_with_offset(file, offset).with_context(|| {
format!(
"cannot read the squashfs image embedded in {}",
path.display()
)
})?;
let image = Image::new(&fs); let image = Image::new(&fs);
let desktop = image.root_desktop(); let desktop = image.root_desktop();
let icon_name = desktop.as_ref().and_then(|d| d.get("Icon")); let icon_name = desktop.as_ref().and_then(|d| d.get("Icon"));
@@ -36,6 +30,100 @@ pub fn read(path: &Path, offset: u64) -> Result<Embedded> {
Ok(Embedded { desktop, icon }) Ok(Embedded { desktop, icon })
} }
pub fn desktop_id(path: &Path, offset: u64) -> Option<String> {
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<String> {
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<u64> {
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<Vec<u8>> {
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<FilesystemReader<'static>> {
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> { struct Image<'a, 'b> {
fs: &'a FilesystemReader<'b>, fs: &'a FilesystemReader<'b>,
nodes: HashMap<PathBuf, &'a InnerNode<SquashfsFileReader>>, nodes: HashMap<PathBuf, &'a InnerNode<SquashfsFileReader>>,
@@ -69,14 +157,15 @@ impl<'a, 'b> Image<'a, 'b> {
None None
} }
fn root_desktop(&self) -> Option<DesktopEntry> { fn root_desktop_path(&self) -> Option<&PathBuf> {
let mut candidates: Vec<&PathBuf> = self self.nodes
.nodes
.keys() .keys()
.filter(|p| p.components().count() == 1 && p.extension() == Some(OsStr::new("desktop"))) .filter(|p| p.components().count() == 1 && p.extension() == Some(OsStr::new("desktop")))
.collect(); .min()
candidates.sort(); }
let data = self.read(candidates.first()?)?;
fn root_desktop(&self) -> Option<DesktopEntry> {
let data = self.read(self.root_desktop_path()?)?;
Some(DesktopEntry::parse(&String::from_utf8_lossy(&data))) Some(DesktopEntry::parse(&String::from_utf8_lossy(&data)))
} }
+67
View File
@@ -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::<Vec<_>>()
.join("-");
format!("{NAME}{}", if base.is_empty() { stem } else { base })
}
fn update(path: &Path) -> Option<String> {
appimage::update_info(path).map(|info| format!("{UPDATE}{info}"))
}
fn desktop(path: &Path) -> Option<String> {
match detect::detect(path).ok()? {
Kind::AppImage(Some(offset)) => {
appimage::desktop_id(path, offset).map(|id| format!("{DESKTOP}{id}"))
}
_ => None,
}
}
+54 -2
View File
@@ -3,6 +3,8 @@ mod config;
mod desktop; mod desktop;
mod detect; mod detect;
mod fastfetch; mod fastfetch;
mod identity;
mod picker;
mod store; mod store;
mod thunar; mod thunar;
mod tui; mod tui;
@@ -37,6 +39,16 @@ enum Command {
#[arg(required = true)] #[arg(required = true)]
files: Vec<PathBuf>, files: Vec<PathBuf>,
}, },
/// 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<PathBuf>,
#[arg(required = true)]
files: Vec<PathBuf>,
},
/// Remove the menu entries of integrated files /// Remove the menu entries of integrated files
Remove { Remove {
#[arg(long)] #[arg(long)]
@@ -47,6 +59,15 @@ enum Command {
#[arg(required = true)] #[arg(required = true)]
files: Vec<PathBuf>, files: Vec<PathBuf>,
}, },
/// 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 integrated files
List, List,
/// Open the interactive manager (default) /// Open the interactive manager (default)
@@ -84,6 +105,16 @@ fn run(cli: Cli) -> Result<ExitCode> {
let apps_dir = Config::load()?.apps_dir()?; let apps_dir = Config::load()?.apps_dir()?;
store::integrate(&apps_dir, file).map(|i| i.name()) 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 { Command::Remove {
notify, notify,
delete, delete,
@@ -91,6 +122,23 @@ fn run(cli: Cli) -> Result<ExitCode> {
} => Ok(batch("Removed", notify, &files, |file| { } => Ok(batch("Removed", notify, &files, |file| {
store::remove_path(file, delete) 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 => { Command::List => {
for integration in store::list()? { for integration in store::list()? {
println!( println!(
@@ -133,10 +181,14 @@ fn batch(
files: &[PathBuf], files: &[PathBuf],
op: impl Fn(&Path) -> Result<String>, op: impl Fn(&Path) -> Result<String>,
) -> ExitCode { ) -> ExitCode {
report(verb, notify, files.iter().map(|file| op(file)).collect())
}
fn report(verb: &str, notify: bool, results: Vec<Result<String>>) -> ExitCode {
let mut done = Vec::new(); let mut done = Vec::new();
let mut failed = Vec::new(); let mut failed = Vec::new();
for file in files { for result in results {
match op(file) { match result {
Ok(name) => { Ok(name) => {
println!("{verb}: {name}"); println!("{verb}: {name}");
done.push(name); done.push(name);
+67
View File
@@ -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<PathBuf> {
let names: Vec<String> = 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::<usize>()
.ok()
.and_then(|index| candidates.get(index).cloned())
.context("the selection dialog returned an unknown choice")
}
fn zenity(names: &[String]) -> io::Result<Output> {
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<Output> {
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()
}
+319 -27
View File
@@ -1,21 +1,27 @@
use std::{ use std::{
collections::HashSet,
ffi::OsStr,
fs, io, fs, io,
os::unix::fs::PermissionsExt, os::unix::fs::{PermissionsExt, symlink},
path::{Path, PathBuf}, path::{Path, PathBuf},
process::{Command, Stdio}, process::{Command, Stdio},
time::SystemTime,
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, anyhow, bail};
use crate::{ use crate::{
appimage, config, appimage, config,
desktop::{self, DesktopEntry}, desktop::{self, DesktopEntry},
detect::{self, Kind}, detect::{self, Kind},
identity,
}; };
const KEY_SOURCE: &str = "X-Ratbin-Source"; const KEY_SOURCE: &str = "X-Ratbin-Source";
const KEY_ARGS: &str = "X-Ratbin-Args"; const KEY_ARGS: &str = "X-Ratbin-Args";
const KEY_KIND: &str = "X-Ratbin-Kind"; 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-"; const PREFIX: &str = "ratbin-";
pub struct Integration { pub struct Integration {
@@ -50,6 +56,10 @@ impl Integration {
self.entry.get(KEY_KIND).unwrap_or_default() self.entry.get(KEY_KIND).unwrap_or_default()
} }
pub fn bundle(&self) -> Option<PathBuf> {
self.entry.get(KEY_BUNDLE).map(PathBuf::from)
}
pub fn fields(&self) -> Fields { pub fn fields(&self) -> Fields {
let get = |key: &str| self.entry.get(key).unwrap_or_default(); let get = |key: &str| self.entry.get(key).unwrap_or_default();
Fields { Fields {
@@ -119,18 +129,75 @@ pub fn list() -> Result<Vec<Integration>> {
Ok(out) Ok(out)
} }
fn find(source: &Path) -> Result<Option<Integration>> { fn find(path: &Path) -> Result<Option<Integration>> {
Ok(list()?.into_iter().find(|i| i.source() == source)) Ok(list()?
.into_iter()
.find(|i| i.source() == path || i.bundle().as_deref() == Some(path)))
} }
pub fn integrate(apps_dir: &Path, path: &Path) -> Result<Integration> { pub fn find_entry(query: &str) -> Result<Integration> {
let src = path let path = fs::canonicalize(query).or_else(|_| std::path::absolute(query))?;
.canonicalize() let mut matches: Vec<Integration> = list()?
.with_context(|| format!("cannot access {}", path.display()))?; .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::<Vec<_>>()
.join(", ")
),
}
}
pub fn relink_candidates(integration: &Integration) -> Result<Vec<PathBuf>> {
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<PathBuf> = 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() { if !src.is_file() {
bail!("{} is not a file", src.display()); 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!( bail!(
"{} is already integrated as \"{}\"", "{} is already integrated as \"{}\"",
src.display(), src.display(),
@@ -138,10 +205,185 @@ pub fn integrate(apps_dir: &Path, path: &Path) -> Result<Integration> {
); );
} }
let kind = detect::detect(&src)?; let kind = detect::detect(&src)?;
let target = place(apps_dir, &src)?; make_executable(&src)?;
make_executable(&target)?;
let id = unique_id(&target)?; let bundle = integration.bundle();
let entry = build_entry(&kind, &target, &id)?; 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<Vec<Result<String>>> {
Ok(list()?
.into_iter()
.filter_map(|mut integration| repair_one(&mut integration).transpose())
.collect())
}
fn repair_one(integration: &mut Integration) -> Result<Option<String>> {
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<Integration> {
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<PathBuf>,
) -> Result<Integration> {
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<PathBuf> = 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<Kind> {
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<Integration> {
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 { let integration = Integration {
desktop_path: config::applications_dir()?.join(format!("{id}.desktop")), desktop_path: config::applications_dir()?.join(format!("{id}.desktop")),
entry, entry,
@@ -151,9 +393,7 @@ pub fn integrate(apps_dir: &Path, path: &Path) -> Result<Integration> {
} }
pub fn remove_path(path: &Path, delete_file: bool) -> Result<String> { pub fn remove_path(path: &Path, delete_file: bool) -> Result<String> {
let src = path let src = canonical(path)?;
.canonicalize()
.with_context(|| format!("cannot access {}", path.display()))?;
let integration = let integration =
find(&src)?.with_context(|| format!("{} is not integrated", src.display()))?; find(&src)?.with_context(|| format!("{} is not integrated", src.display()))?;
remove(&integration, delete_file)?; remove(&integration, delete_file)?;
@@ -168,7 +408,23 @@ pub fn remove(integration: &Integration, delete_file: bool) -> Result<()> {
remove_if_exists(&icon)?; remove_if_exists(&icon)?;
} }
if delete_file { 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(); refresh_menu();
Ok(()) Ok(())
@@ -222,27 +478,57 @@ fn build_entry(kind: &Kind, target: &Path, id: &str) -> Result<DesktopEntry> {
entry.set("Icon", &icon); entry.set("Icon", &icon);
entry.set(KEY_SOURCE, &target.to_string_lossy()); entry.set(KEY_SOURCE, &target.to_string_lossy());
entry.set(KEY_KIND, kind.label()); entry.set(KEY_KIND, kind.label());
entry.set(KEY_IDENTITY, &identity::identify(target));
entry.set(KEY_ARGS, &args); entry.set(KEY_ARGS, &args);
entry.set("Exec", &desktop::build_exec(target, &args)); entry.set("Exec", &desktop::build_exec(target, &args));
Ok(entry) Ok(entry)
} }
fn place(apps_dir: &Path, src: &Path) -> Result<PathBuf> { fn canonical(path: &Path) -> Result<PathBuf> {
path.canonicalize()
.with_context(|| format!("cannot access {}", path.display()))
}
fn prepare_apps_dir(apps_dir: &Path) -> Result<PathBuf> {
fs::create_dir_all(apps_dir) fs::create_dir_all(apps_dir)
.with_context(|| format!("cannot create {}", apps_dir.display()))?; .with_context(|| format!("cannot create {}", apps_dir.display()))?;
let apps_dir = apps_dir.canonicalize()?; Ok(apps_dir.canonicalize()?)
if src.starts_with(&apps_dir) { }
return Ok(src.to_path_buf());
} fn file_name(path: &Path) -> Result<&OsStr> {
let dest = apps_dir.join(src.file_name().context("path has no file name")?); path.file_name().context("path has no file name")
}
fn move_to(src: &Path, dest: &Path) -> Result<()> {
if dest.exists() { if dest.exists() {
bail!("{} already exists", dest.display()); bail!("{} already exists", dest.display());
} }
if fs::rename(src, &dest).is_err() { if fs::rename(src, dest).is_ok() {
fs::copy(src, &dest).with_context(|| format!("cannot copy to {}", dest.display()))?; return Ok(());
fs::remove_file(src).with_context(|| format!("cannot remove {}", src.display()))?; }
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<()> { fn make_executable(path: &Path) -> Result<()> {
@@ -302,6 +588,12 @@ fn file_stem(path: &Path) -> String {
.unwrap_or_default() .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<()> { fn remove_if_exists(path: &Path) -> Result<()> {
match fs::remove_file(path) { match fs::remove_file(path) {
Err(e) if e.kind() != io::ErrorKind::NotFound => { Err(e) if e.kind() != io::ErrorKind::NotFound => {
+22 -4
View File
@@ -3,7 +3,9 @@ use std::{fs, path::PathBuf};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
const SKELETON: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<actions>\n</actions>\n"; const SKELETON: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<actions>\n</actions>\n";
const IDS: [&str; 2] = ["ratbin-integrate", "ratbin-remove"]; const IDS: [&str; 3] = ["ratbin-integrate", "ratbin-bundle", "ratbin-remove"];
const FILE_CONDITIONS: &str = "\t<other-files/>\n\t<text-files/>\n";
const ANY_CONDITIONS: &str = "\t<directories/>\n\t<audio-files/>\n\t<image-files/>\n\t<other-files/>\n\t<text-files/>\n\t<video-files/>\n";
pub fn install() -> Result<PathBuf> { pub fn install() -> Result<PathBuf> {
let exe = std::env::current_exe()?.canonicalize()?; let exe = std::env::current_exe()?.canonicalize()?;
@@ -15,13 +17,23 @@ pub fn install() -> Result<PathBuf> {
"application-x-executable", "application-x-executable",
&format!("{exe} integrate --notify %F"), &format!("{exe} integrate --notify %F"),
"Move to the applications folder and add a menu entry", "Move to the applications folder and add a menu entry",
FILE_CONDITIONS,
), ),
action( action(
IDS[1], 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", "Remove menu integration",
"edit-delete", "edit-delete",
&format!("{exe} remove --notify %F"), &format!("{exe} remove --notify %F"),
"Remove the menu entry created by ratbin", "Remove the menu entry created by ratbin",
ANY_CONDITIONS,
), ),
]; ];
let mut doc = stripped()?; let mut doc = stripped()?;
@@ -90,7 +102,14 @@ fn strip(doc: &str) -> String {
out 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!( format!(
"<action>\n\ "<action>\n\
\t<icon>{icon}</icon>\n\ \t<icon>{icon}</icon>\n\
@@ -101,8 +120,7 @@ fn action(id: &str, name: &str, icon: &str, command: &str, description: &str) ->
\t<description>{description}</description>\n\ \t<description>{description}</description>\n\
\t<range></range>\n\ \t<range></range>\n\
\t<patterns>*</patterns>\n\ \t<patterns>*</patterns>\n\
\t<other-files/>\n\ {conditions}\
\t<text-files/>\n\
</action>\n", </action>\n",
icon = xml_escape(icon), icon = xml_escape(icon),
name = xml_escape(name), name = xml_escape(name),
+133 -10
View File
@@ -1,4 +1,7 @@
use std::{io, path::PathBuf}; use std::{
io,
path::{Path, PathBuf},
};
use anyhow::Result; use anyhow::Result;
use ratatui::{ use ratatui::{
@@ -40,6 +43,7 @@ enum Mode {
Browse, Browse,
Edit(Form), Edit(Form),
Confirm, Confirm,
Relink(Picker),
} }
struct App { struct App {
@@ -59,7 +63,9 @@ impl App {
status: None, status: None,
quit: false, quit: false,
}; };
let repaired = store::repair();
app.reload(None)?; app.reload(None)?;
app.show_repairs(repaired);
Ok(app) Ok(app)
} }
@@ -102,6 +108,13 @@ impl App {
KeyCode::Char('s') if ctrl => self.save(), KeyCode::Char('s') if ctrl => self.save(),
_ => form.on_key(key), _ => 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; self.mode = Mode::Confirm;
} }
} }
KeyCode::Char('f') => self.open_relink(),
KeyCode::Char('r') => { KeyCode::Char('r') => {
let repaired = store::repair();
let result = self.reload(None); let result = self.reload(None);
self.report(result.map(|()| "Reloaded".into())); self.report(result.map(|()| "Reloaded".into()));
self.show_repairs(repaired);
} }
_ => {} _ => {}
} }
@@ -145,6 +161,52 @@ impl App {
self.report(result.map(|()| format!("Removed {name}"))); 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) { fn save(&mut self) {
let Mode::Edit(form) = std::mem::replace(&mut self.mode, Mode::Browse) else { let Mode::Edit(form) = std::mem::replace(&mut self.mode, Mode::Browse) else {
return; return;
@@ -171,6 +233,18 @@ impl App {
} }
} }
fn show_repairs(&mut self, repaired: Result<Vec<Result<String>>>) {
match repaired {
Ok(results) => {
let fixed: Vec<String> = 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<String>) { fn report(&mut self, result: Result<String>) {
self.status = Some(match result { self.status = Some(match result {
Ok(message) => (message, false), Ok(message) => (message, false),
@@ -211,10 +285,11 @@ impl App {
frame.render_widget(self.details(), right); frame.render_widget(self.details(), right);
frame.render_widget(self.footer(), footer); frame.render_widget(self.footer(), footer);
match &self.mode { match &mut self.mode {
Mode::Browse => {} Mode::Browse => {}
Mode::Edit(form) => form.draw(frame, main), Mode::Edit(form) => form.draw(frame, main),
Mode::Confirm => self.draw_confirm(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("File", source.display().to_string()),
row("Entry", item.desktop_path.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() { if !source.exists() {
lines.push(Line::default()); lines.push(Line::default());
lines.push(Line::styled( 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), Style::default().fg(Color::Red),
)); ));
} }
@@ -268,9 +346,10 @@ impl App {
return Line::styled(message.clone(), Style::default().fg(color)); return Line::styled(message.clone(), Style::default().fg(color));
} }
let hints = match self.mode { 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::Edit(_) => "tab/↑↓ field space toggle ctrl+u clear enter save esc cancel",
Mode::Confirm => "e entry only d entry + file 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)) Line::styled(hints, Style::default().fg(Color::DarkGray))
} }
@@ -279,11 +358,11 @@ impl App {
let Some(item) = self.selected() else { let Some(item) = self.selected() else {
return; return;
}; };
let file_name = item let (path, suffix) = match item.bundle() {
.source() Some(bundle) => (bundle, "/ and everything in it"),
.file_name() None => (item.source(), ""),
.map(|n| n.to_string_lossy().into_owned()) };
.unwrap_or_default(); let file_name = file_name(&path);
let rect = popup(area, 64, 6); let rect = popup(area, 64, 6);
frame.render_widget(Clear, rect); frame.render_widget(Clear, rect);
frame.render_widget( frame.render_widget(
@@ -291,7 +370,7 @@ impl App {
Line::from(format!("Remove \"{}\"?", item.name())), Line::from(format!("Remove \"{}\"?", item.name())),
Line::default(), Line::default(),
Line::from("e remove the menu entry only"), 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(
Block::bordered() Block::bordered()
@@ -303,6 +382,44 @@ impl App {
} }
} }
struct Picker {
files: Vec<PathBuf>,
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<ListItem> = 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 { struct Form {
inputs: [Input; 5], inputs: [Input; 5],
terminal: bool, 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 { fn popup(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width); let width = width.min(area.width);
let height = height.min(area.height); let height = height.min(area.height);