Skip to content

Commit c59368d

Browse files
committed
feat: add support for updating multiple AppImages at once
1 parent 0c72d84 commit c59368d

File tree

2 files changed

+189
-42
lines changed

2 files changed

+189
-42
lines changed

src/main.rs

Lines changed: 185 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::HashMap;
2+
use std::fs;
13
use std::path::PathBuf;
24

35
use appimageupdate::config;
@@ -9,8 +11,8 @@ use clap::Parser;
911
#[command(about = "AppImage companion tool taking care of updates for the commandline.", long_about = None)]
1012
#[command(version)]
1113
struct Cli {
12-
#[arg(value_name = "APPIMAGE")]
13-
path: Option<PathBuf>,
14+
#[arg(value_name = "APPIMAGE", num_args(1..))]
15+
paths: Vec<PathBuf>,
1416

1517
#[arg(short = 'O', long)]
1618
overwrite: bool,
@@ -69,20 +71,129 @@ fn run(cli: Cli) -> Result<(), Error> {
6971
if !cli.github_api_proxy.is_empty() {
7072
config::set_proxies(cli.github_api_proxy.clone());
7173
}
72-
let path = cli.path.ok_or_else(|| {
73-
Error::AppImage("No AppImage path provided. Use --help for usage.".into())
74-
})?;
75-
let mut updater = if let Some(ref update_info) = cli.update_info {
76-
Updater::with_update_info(&path, update_info)?
77-
} else {
78-
Updater::new(&path)?
74+
if cli.paths.is_empty() {
75+
return Err(Error::AppImage(
76+
"No AppImage path provided. Use --help for usage.".into(),
77+
));
78+
}
79+
let appimages = collect_appimages(&cli.paths)?;
80+
if appimages.is_empty() {
81+
return Err(Error::AppImage("No AppImages found.".into()));
82+
}
83+
84+
let mut groups: HashMap<String, Vec<PathBuf>> = HashMap::new();
85+
let mut ungrouped: Vec<PathBuf> = Vec::new();
86+
87+
for path in &appimages {
88+
if let Ok(updater) = create_updater(&cli, path)
89+
&& let Ok(zsync_url) = updater.zsync_url()
90+
{
91+
groups.entry(zsync_url).or_default().push(path.clone());
92+
continue;
93+
}
94+
ungrouped.push(path.clone());
95+
}
96+
97+
let mut errors = Vec::new();
98+
let mut updated_files: HashMap<String, PathBuf> = HashMap::new();
99+
100+
for (zsync_url, paths) in &groups {
101+
if paths.len() > 1 {
102+
println!(
103+
"\n=== Group ({} AppImages, same update source) ===",
104+
paths.len()
105+
);
106+
}
107+
for path in paths {
108+
if appimages.len() > 1 {
109+
println!("\n=== {} ===", path.display());
110+
}
111+
if let Err(e) = handle_appimage(&cli, path, zsync_url, &mut updated_files) {
112+
eprintln!("Error updating {}: {}", path.display(), e);
113+
errors.push(path.clone());
114+
}
115+
}
116+
}
117+
118+
for path in &ungrouped {
119+
println!("\n=== {} ===", path.display());
120+
if let Err(e) = handle_appimage(&cli, path, "", &mut updated_files) {
121+
eprintln!("Error updating {}: {}", path.display(), e);
122+
errors.push(path.clone());
123+
}
124+
}
125+
126+
if !errors.is_empty() {
127+
eprintln!("\nFailed to update {} AppImage(s)", errors.len());
128+
std::process::exit(1);
129+
}
130+
Ok(())
131+
}
132+
133+
fn collect_appimages(paths: &[PathBuf]) -> Result<Vec<PathBuf>, Error> {
134+
let mut appimages = Vec::new();
135+
for path in paths {
136+
if path.is_dir() {
137+
for entry in fs::read_dir(path)? {
138+
let entry = entry?;
139+
let entry_path = entry.path();
140+
if entry_path.is_file() && is_appimage(&entry_path) {
141+
appimages.push(entry_path);
142+
}
143+
}
144+
} else if path.is_file() {
145+
appimages.push(path.clone());
146+
} else {
147+
return Err(Error::AppImage(format!(
148+
"Path does not exist: {}",
149+
path.display()
150+
)));
151+
}
152+
}
153+
appimages.sort();
154+
appimages.dedup();
155+
Ok(appimages)
156+
}
157+
158+
fn is_appimage(path: &PathBuf) -> bool {
159+
use std::fs::File;
160+
use std::io::{Read, Seek, SeekFrom};
161+
162+
let Ok(mut file) = File::open(path) else {
163+
return false;
79164
};
80-
if let Some(output_dir) = config::get_output_dir(cli.output_dir) {
165+
let mut magic = [0u8; 3];
166+
if file.seek(SeekFrom::Start(8)).is_err() {
167+
return false;
168+
}
169+
if file.read_exact(&mut magic).is_err() {
170+
return false;
171+
}
172+
&magic[0..2] == b"AI" && (magic[2] == 1 || magic[2] == 2)
173+
}
174+
175+
fn create_updater(cli: &Cli, path: &PathBuf) -> Result<Updater, Error> {
176+
if let Some(ref update_info) = cli.update_info {
177+
Updater::with_update_info(path, update_info)
178+
} else {
179+
Updater::new(path)
180+
}
181+
}
182+
183+
fn handle_appimage(
184+
cli: &Cli,
185+
path: &PathBuf,
186+
zsync_url: &str,
187+
updated_files: &mut HashMap<String, PathBuf>,
188+
) -> Result<(), Error> {
189+
let mut updater = create_updater(cli, path)?;
190+
if let Some(output_dir) = config::get_output_dir(cli.output_dir.clone()) {
81191
updater = updater.output_dir(&output_dir);
82192
}
83193
if cli.overwrite {
84194
updater = updater.overwrite(true);
85195
}
196+
86197
if cli.describe {
87198
let source_path = updater.source_path();
88199
let source_size = updater.source_size();
@@ -94,6 +205,7 @@ fn run(cli: Cli) -> Result<(), Error> {
94205
println!("Update Info: {}", updater.update_info());
95206
return Ok(());
96207
}
208+
97209
if cli.check_for_update {
98210
let has_update = updater.check_for_update()?;
99211
if has_update {
@@ -103,6 +215,7 @@ fn run(cli: Cli) -> Result<(), Error> {
103215
}
104216
std::process::exit(if has_update { 1 } else { 0 });
105217
}
218+
106219
let source_path = updater.source_path().to_path_buf();
107220
let source_size = updater.source_size();
108221
let (target_path, target_size) = updater.target_info()?;
@@ -117,41 +230,71 @@ fn run(cli: Cli) -> Result<(), Error> {
117230
format_size(target_size)
118231
);
119232
println!();
120-
if updater.check_for_update()? {
121-
println!("Performing delta update...");
122-
let (new_path, stats) = updater.perform_update()?;
123-
if stats.blocks_reused > 0 || stats.blocks_downloaded > 0 {
124-
println!();
125-
println!(
126-
"Reused: {:>10} ({} blocks)",
127-
format_size(stats.bytes_reused()),
128-
stats.blocks_reused
129-
);
130-
println!(
131-
"Downloaded: {:>10} ({} blocks)",
132-
format_size(stats.bytes_downloaded()),
133-
stats.blocks_downloaded
134-
);
135-
println!(
136-
"Saved: {:>10} ({}%)",
137-
format_size(stats.bytes_reused()),
138-
stats.saved_percentage()
139-
);
140-
}
141-
println!();
142-
println!("Updated: {}", new_path.display());
143-
let remove_old = config::get_remove_old(if cli.remove_old { Some(true) } else { None });
144-
if remove_old {
145-
if let Some(ref backup) = stats.backup_path {
146-
std::fs::remove_file(backup)?;
147-
println!("Removed old AppImage");
148-
} else if new_path != source_path {
149-
std::fs::remove_file(source_path)?;
233+
234+
if !updater.check_for_update()? {
235+
println!("Already up to date!");
236+
return Ok(());
237+
}
238+
239+
if let Some(existing) = updated_files
240+
.get(zsync_url)
241+
.filter(|_| !zsync_url.is_empty())
242+
{
243+
if existing == &target_path {
244+
println!("Already updated (same target)");
245+
} else {
246+
println!("Copying from {}...", existing.display());
247+
let perms = fs::metadata(&source_path).ok().map(|m| m.permissions());
248+
fs::copy(existing, &target_path)?;
249+
if let Some(perms) = perms {
250+
fs::set_permissions(&target_path, perms)?;
251+
}
252+
println!("Updated: {}", target_path.display());
253+
let remove_old = config::get_remove_old(if cli.remove_old { Some(true) } else { None });
254+
if remove_old && target_path != source_path {
255+
fs::remove_file(&source_path)?;
150256
println!("Removed old AppImage");
151257
}
152258
}
153-
} else {
154-
println!("Already up to date!");
259+
return Ok(());
260+
}
261+
262+
println!("Performing delta update...");
263+
let (new_path, stats) = updater.perform_update()?;
264+
if stats.blocks_reused > 0 || stats.blocks_downloaded > 0 {
265+
println!();
266+
println!(
267+
"Reused: {:>10} ({} blocks)",
268+
format_size(stats.bytes_reused()),
269+
stats.blocks_reused
270+
);
271+
println!(
272+
"Downloaded: {:>10} ({} blocks)",
273+
format_size(stats.bytes_downloaded()),
274+
stats.blocks_downloaded
275+
);
276+
println!(
277+
"Saved: {:>10} ({}%)",
278+
format_size(stats.bytes_reused()),
279+
stats.saved_percentage()
280+
);
281+
}
282+
println!();
283+
println!("Updated: {}", new_path.display());
284+
285+
if !zsync_url.is_empty() {
286+
updated_files.insert(zsync_url.to_string(), new_path.clone());
287+
}
288+
289+
let remove_old = config::get_remove_old(if cli.remove_old { Some(true) } else { None });
290+
if remove_old {
291+
if let Some(ref backup) = stats.backup_path {
292+
fs::remove_file(backup)?;
293+
println!("Removed old AppImage");
294+
} else if new_path != source_path {
295+
fs::remove_file(&source_path)?;
296+
println!("Removed old AppImage");
297+
}
155298
}
156299
Ok(())
157300
}

src/updater.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ impl Updater {
150150
self.update_info.raw()
151151
}
152152

153+
pub fn zsync_url(&self) -> Result<String> {
154+
self.update_info.zsync_url()
155+
}
156+
153157
pub fn target_info(&self) -> Result<(PathBuf, u64)> {
154158
let (control, _zsync_url) = self.fetch_control_file()?;
155159
let output_path = self.resolve_output_path(&control)?;

0 commit comments

Comments
 (0)