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
@@ -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
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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);
|
||||||
|
|||||||
@@ -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::{
|
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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user