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:
1 parent
296b987e3b
commit
aab55369f6
8 files changed
+766
-57
No files matched your search
+103
-14
@@ -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<Embedded> {
|
||||
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<Embedded> {
|
||||
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> {
|
||||
fs: &'a FilesystemReader<'b>,
|
||||
nodes: HashMap<PathBuf, &'a InnerNode<SquashfsFileReader>>,
|
||||
@@ -69,14 +157,15 @@ impl<'a, 'b> Image<'a, 'b> {
|
||||
None
|
||||
}
|
||||
|
||||
fn root_desktop(&self) -> Option<DesktopEntry> {
|
||||
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<DesktopEntry> {
|
||||
let data = self.read(self.root_desktop_path()?)?;
|
||||
Some(DesktopEntry::parse(&String::from_utf8_lossy(&data)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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<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 {
|
||||
#[arg(long)]
|
||||
@@ -47,6 +59,15 @@ enum Command {
|
||||
#[arg(required = true)]
|
||||
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,
|
||||
/// Open the interactive manager (default)
|
||||
@@ -84,6 +105,16 @@ fn run(cli: Cli) -> Result<ExitCode> {
|
||||
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<ExitCode> {
|
||||
} => 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<String>,
|
||||
) -> 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 failed = Vec::new();
|
||||
for file in files {
|
||||
match op(file) {
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(name) => {
|
||||
println!("{verb}: {name}");
|
||||
done.push(name);
|
||||
|
||||
@@ -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
@@ -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<PathBuf> {
|
||||
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<Vec<Integration>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn find(source: &Path) -> Result<Option<Integration>> {
|
||||
Ok(list()?.into_iter().find(|i| i.source() == source))
|
||||
fn find(path: &Path) -> Result<Option<Integration>> {
|
||||
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> {
|
||||
let src = path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("cannot access {}", path.display()))?;
|
||||
pub fn find_entry(query: &str) -> Result<Integration> {
|
||||
let path = fs::canonicalize(query).or_else(|_| std::path::absolute(query))?;
|
||||
let mut matches: Vec<Integration> = 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::<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() {
|
||||
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<Integration> {
|
||||
);
|
||||
}
|
||||
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<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 {
|
||||
desktop_path: config::applications_dir()?.join(format!("{id}.desktop")),
|
||||
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> {
|
||||
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<DesktopEntry> {
|
||||
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<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)
|
||||
.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 => {
|
||||
|
||||
+22
-4
@@ -3,7 +3,9 @@ use std::{fs, path::PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
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> {
|
||||
let exe = std::env::current_exe()?.canonicalize()?;
|
||||
@@ -15,13 +17,23 @@ pub fn install() -> Result<PathBuf> {
|
||||
"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!(
|
||||
"<action>\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<range></range>\n\
|
||||
\t<patterns>*</patterns>\n\
|
||||
\t<other-files/>\n\
|
||||
\t<text-files/>\n\
|
||||
{conditions}\
|
||||
</action>\n",
|
||||
icon = xml_escape(icon),
|
||||
name = xml_escape(name),
|
||||
|
||||
+133
-10
@@ -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<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>) {
|
||||
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<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 {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user