mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-10-14 14:26:48 -04:00
Robust downloads: pause, resume, reconnect; fix every download failing
Every download failed at its last read: ureq's body limit errors once the limit is reached, and it was set to the exact file size. Sizes and checksums are now checked by the launcher, with headroom on the limit. - Pause keeps the partial file; Resume continues from the same byte (HTTP range requests). Cancel discards it. - Dropped or stalled connections retry with backoff from where they stopped, shown as "Reconnecting…"; if the connection stays down the download pauses itself instead of failing - Partial downloads survive quitting and show as paused next launch - Ask for identity encoding so bytes arrive exactly as published - SHA-256 from GitHub's digest or SHA256SUMS.txt; a mismatch refetches once from scratch. Releases with no checksum install with a size check and say so; a checksum list that can't be fetched is an error - Tests against a scripted local server for each failure mode Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ec69f0be2c
commit
4dc0602a19
9 files changed
+801
-113
No files matched your search
@@ -33,7 +33,11 @@ pub enum Command {
|
||||
Reveal {
|
||||
app: String,
|
||||
},
|
||||
/// Stop a running download.
|
||||
/// Pause a running download, keeping what's downloaded so far (`Install` resumes it).
|
||||
Pause {
|
||||
app: String,
|
||||
},
|
||||
/// Stop a running download, or throw away a paused one.
|
||||
Cancel {
|
||||
app: String,
|
||||
},
|
||||
@@ -67,12 +71,30 @@ pub enum JobPhase {
|
||||
done: u64,
|
||||
total: u64,
|
||||
},
|
||||
/// The connection dropped; trying again shortly, from where it stopped.
|
||||
Reconnecting {
|
||||
done: u64,
|
||||
total: u64,
|
||||
},
|
||||
Verifying,
|
||||
/// Waiting on the OS installer (MSI) or copying files.
|
||||
Installing,
|
||||
Removing,
|
||||
}
|
||||
|
||||
/// A partly downloaded file waiting to be resumed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PausedDownload {
|
||||
pub done: u64,
|
||||
/// 0 when unknown.
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// The `JobFinished` error a paused job ends with (not a failure).
|
||||
pub const PAUSED: &str = "paused";
|
||||
/// The `JobFinished` error a cancelled job ends with (not a failure).
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// Where release information came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Source {
|
||||
@@ -111,6 +133,12 @@ pub enum Event {
|
||||
app: String,
|
||||
phase: JobPhase,
|
||||
},
|
||||
/// A download is paused (by the user, by a lost connection, or left over from an earlier
|
||||
/// run) and can be resumed; `None` once there's no paused download.
|
||||
Paused {
|
||||
app: String,
|
||||
download: Option<PausedDownload>,
|
||||
},
|
||||
/// A job ended: `Ok` carries a short success message, `Err` what went wrong.
|
||||
JobFinished {
|
||||
app: String,
|
||||
|
||||
@@ -7,11 +7,13 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use artcraft_launcher_core::catalog::{LAUNCHER_ID, LAUNCHER_REPO};
|
||||
use artcraft_launcher_core::platform::{Arch, Os, Platform, pick_asset};
|
||||
use artcraft_launcher_core::protocol::{Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, Source};
|
||||
use artcraft_launcher_core::release::Release;
|
||||
use artcraft_launcher_core::{CraftApp, Installed, SUITE, version};
|
||||
use artcraft_launcher_core::protocol::{
|
||||
Backend, BackendOptions, Capabilities, Command, Event, JobPhase, LatestRelease, PAUSED, PausedDownload, Source,
|
||||
};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::{CraftApp, Installed, PackageKind, SUITE, version};
|
||||
|
||||
use crate::http::Http;
|
||||
use crate::http::{self, Http, Stop};
|
||||
use crate::install::{self, Job};
|
||||
use crate::manifest::Manifest;
|
||||
use crate::paths::Paths;
|
||||
@@ -29,7 +31,7 @@ struct Shared {
|
||||
waker: Waker,
|
||||
latest: Mutex<HashMap<String, Release>>,
|
||||
installed: Mutex<HashMap<String, Installed>>,
|
||||
jobs: Mutex<HashMap<String, Arc<AtomicBool>>>,
|
||||
jobs: Mutex<HashMap<String, Arc<Stop>>>,
|
||||
manifest: Mutex<Manifest>,
|
||||
options: Mutex<BackendOptions>,
|
||||
checking: AtomicBool,
|
||||
@@ -105,6 +107,9 @@ impl NativeBackend {
|
||||
|
||||
fn detect_all(&self) {
|
||||
let shared = self.shared.clone();
|
||||
let paths = self.paths.clone();
|
||||
let platform = self.platform;
|
||||
let options = self.options();
|
||||
std::thread::spawn(move || {
|
||||
let snapshot = crate::detect::Snapshot::take();
|
||||
let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
|
||||
@@ -119,6 +124,10 @@ impl NativeBackend {
|
||||
}
|
||||
shared.emit(Event::Detected { app: app.id.to_string(), installed: found });
|
||||
}
|
||||
// Downloads paused (or cut off by quitting) in an earlier run.
|
||||
for app in &SUITE {
|
||||
report_partial(app, &shared, &paths, platform, &options);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,13 +190,13 @@ impl NativeBackend {
|
||||
|
||||
fn start_job(&self, app_id: &str, uninstall: bool) {
|
||||
let Some(app) = CraftApp::by_id(app_id) else { return };
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let stop = Arc::new(Stop::default());
|
||||
{
|
||||
let mut jobs = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if jobs.contains_key(app.id) {
|
||||
return;
|
||||
}
|
||||
jobs.insert(app.id.to_string(), cancel.clone());
|
||||
jobs.insert(app.id.to_string(), stop.clone());
|
||||
}
|
||||
let shared = self.shared.clone();
|
||||
let paths = self.paths.clone();
|
||||
@@ -210,8 +219,16 @@ impl NativeBackend {
|
||||
None => Err(format!("{} isn't installed", app.name)),
|
||||
}
|
||||
} else {
|
||||
run_install(app, &shared, &paths, http.as_ref(), platform, &options, existing.as_ref(), record.as_ref(), &cancel)
|
||||
let r = run_install(app, &shared, &paths, http.as_ref(), platform, &options, existing.as_ref(), record.as_ref(), &stop);
|
||||
// Stopped by the connection rather than by the user: say so, and how to go on.
|
||||
match r {
|
||||
Err(e) if e == PAUSED && stop.check().is_ok() => {
|
||||
Err("The download paused because the connection keeps dropping. Press Resume to continue where it stopped.".into())
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
};
|
||||
report_partial(app, &shared, &paths, platform, &options);
|
||||
// Look again: the result is whatever is on disk now.
|
||||
let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();
|
||||
let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take());
|
||||
@@ -243,7 +260,7 @@ fn run_install(
|
||||
options: &BackendOptions,
|
||||
existing: Option<&Installed>,
|
||||
record: Option<&crate::manifest::Record>,
|
||||
cancel: &AtomicBool,
|
||||
stop: &Stop,
|
||||
) -> Result<String, String> {
|
||||
let http = http.ok_or("secure connections aren't available on this system")?;
|
||||
let platform = platform.ok_or("this system isn't supported")?;
|
||||
@@ -254,11 +271,8 @@ fn run_install(
|
||||
.get(app.id)
|
||||
.cloned()
|
||||
.ok_or("the latest release hasn't been found yet. Check for updates first.")?;
|
||||
let stems: Vec<&str> = app.stems().collect();
|
||||
let kinds = install::choose_kinds(existing, record, &options.kinds);
|
||||
let (asset, kind) = pick_asset(&release, &stems, platform, &kinds)
|
||||
let (asset, kind) = planned_asset(app, &release, platform, options, existing, record)
|
||||
.ok_or_else(|| format!("{} {} has no download for {}", app.name, release.version_label(), platform.label()))?;
|
||||
let asset = asset.clone();
|
||||
let mut last_emit = Instant::now() - Duration::from_secs(1);
|
||||
let mut phase = |p: JobPhase| {
|
||||
// Downloads report thousands of times; the UI needs ~20 a second.
|
||||
@@ -268,8 +282,8 @@ fn run_install(
|
||||
last_emit = Instant::now();
|
||||
shared.emit(Event::Progress { app: app.id.to_string(), phase: p });
|
||||
};
|
||||
let mut job = Job { http, paths, cancel, phase: &mut phase };
|
||||
let new_record = install::install(app, &release, &asset, kind, existing, &mut job)?;
|
||||
let mut job = Job { http, paths, stop, phase: &mut phase };
|
||||
let (new_record, verified) = install::install(app, &release, &asset, kind, existing, &mut job)?;
|
||||
{
|
||||
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let changed = match new_record {
|
||||
@@ -282,7 +296,42 @@ fn run_install(
|
||||
}
|
||||
}
|
||||
let verb = if existing.is_some() { "updated to" } else { "installed:" };
|
||||
Ok(format!("{} {verb} {}", app.name, release.version_label()))
|
||||
let note = match verified {
|
||||
http::Verified::Checksum => "",
|
||||
http::Verified::SizeOnly => " (this release publishes no checksum, so only its size was checked)",
|
||||
};
|
||||
Ok(format!("{} {verb} {}{note}", app.name, release.version_label()))
|
||||
}
|
||||
|
||||
/// The file an install of `app` would download now: same format as the installed copy, else
|
||||
/// the user's preference, else the best this system supports.
|
||||
fn planned_asset(
|
||||
app: &CraftApp,
|
||||
release: &Release,
|
||||
platform: Platform,
|
||||
options: &BackendOptions,
|
||||
existing: Option<&Installed>,
|
||||
record: Option<&crate::manifest::Record>,
|
||||
) -> Option<(Asset, PackageKind)> {
|
||||
let stems: Vec<&str> = app.stems().collect();
|
||||
let kinds = install::choose_kinds(existing, record, &options.kinds);
|
||||
pick_asset(release, &stems, platform, &kinds).map(|(a, k)| (a.clone(), k))
|
||||
}
|
||||
|
||||
/// Tells the UI whether `app` has a paused download waiting.
|
||||
fn report_partial(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<Platform>, options: &BackendOptions) {
|
||||
let download = partial_for(app, shared, paths, platform, options).map(|(asset, done)| PausedDownload { done, total: asset.size });
|
||||
shared.emit(Event::Paused { app: app.id.to_string(), download });
|
||||
}
|
||||
|
||||
/// The planned download of `app` and how many bytes of it are already on disk.
|
||||
fn partial_for(app: &CraftApp, shared: &Shared, paths: &Paths, platform: Option<Platform>, options: &BackendOptions) -> Option<(Asset, u64)> {
|
||||
let release = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned()?;
|
||||
let existing = shared.installed.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned();
|
||||
let record = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).apps.get(app.id).cloned();
|
||||
let (asset, _) = planned_asset(app, &release, platform?, options, existing.as_ref(), record.as_ref())?;
|
||||
let done = http::partial_bytes(&install::download_path(paths, &asset), asset.size)?;
|
||||
Some((asset, done))
|
||||
}
|
||||
|
||||
/// The machine's real architecture: an x64 launcher on Windows on ARM should still pick ARM64
|
||||
@@ -307,9 +356,24 @@ impl Backend for NativeBackend {
|
||||
Command::Detect => self.detect_all(),
|
||||
Command::Install { app } => self.start_job(&app, false),
|
||||
Command::Uninstall { app } => self.start_job(&app, true),
|
||||
Command::Pause { app } => {
|
||||
if let Some(stop) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app) {
|
||||
stop.pause();
|
||||
}
|
||||
}
|
||||
Command::Cancel { app } => {
|
||||
if let Some(flag) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app) {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
let running = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app).cloned();
|
||||
match running {
|
||||
Some(stop) => stop.cancel(),
|
||||
// A paused download: throw its partial file away.
|
||||
None => {
|
||||
if let Some(def) = CraftApp::by_id(&app)
|
||||
&& let Some((asset, _)) = partial_for(def, &self.shared, &self.paths, self.platform, &self.options())
|
||||
{
|
||||
http::discard_partial(&install::download_path(&self.paths, &asset), asset.size);
|
||||
}
|
||||
self.shared.emit(Event::Paused { app, download: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Launch { app } => {
|
||||
|
||||
+494
-44
@@ -2,7 +2,9 @@
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use artcraft_launcher_core::protocol::{CANCELLED, PAUSED};
|
||||
use std::time::Duration;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -59,6 +61,8 @@ impl Http {
|
||||
.tls_config(tls())
|
||||
.timeout_connect(Some(Duration::from_secs(20)))
|
||||
.timeout_recv_response(Some(Duration::from_secs(60)))
|
||||
// ureq has no idle timeout: cap each attempt instead, and resume where it stopped.
|
||||
.timeout_recv_body(Some(ATTEMPT_BODY_TIME))
|
||||
.http_status_as_error(false)
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
@@ -126,68 +130,276 @@ impl Http {
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads `url` to `dest`, checking the size (when known) and SHA-256 as it goes.
|
||||
/// `progress(done, total)` is called often; `cancel` stops the download.
|
||||
/// Downloads `url` to `dest`, checking it against `sha256` when a checksum is published (and
|
||||
/// always against `size`, when known).
|
||||
///
|
||||
/// Tolerant by design: an interrupted or stalled transfer resumes where it stopped (HTTP range
|
||||
/// requests) with backoff; a paused download, or one left by an earlier run, continues from
|
||||
/// its partial file; a file that fails its checksum is fetched once more from scratch before
|
||||
/// giving up. When the connection stays down, the download ends paused (`PAUSED`), never
|
||||
/// losing what it has. `stop` pauses or cancels it between reads.
|
||||
pub fn download(
|
||||
&self,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
size: u64,
|
||||
sha256: &str,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut dyn FnMut(u64, u64),
|
||||
) -> Result<(), String> {
|
||||
let mut resp = self.download.get(url).call().map_err(|e| format!("couldn't start the download ({e})"))?;
|
||||
let status = resp.status().as_u16();
|
||||
if status != 200 {
|
||||
return Err(format!("the download failed (HTTP {status})"));
|
||||
}
|
||||
let total = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok()).unwrap_or(size);
|
||||
if size > 0 && total != size {
|
||||
return Err(format!("the server offered {total} bytes, but the release lists {size}"));
|
||||
}
|
||||
let limit = if total > 0 { total } else { 8 << 30 };
|
||||
let mut reader = resp.body_mut().with_config().limit(limit).reader();
|
||||
sha256: Option<&str>,
|
||||
stop: &Stop,
|
||||
progress: &mut dyn FnMut(Transfer),
|
||||
) -> Result<Verified, String> {
|
||||
if let Some(dir) = dest.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
|
||||
}
|
||||
let part = dest.with_extension("part");
|
||||
let mut file = std::fs::File::create(&part).map_err(|e| format!("couldn't save the download ({e})"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 256 * 1024];
|
||||
let mut done: u64 = 0;
|
||||
progress(0, total);
|
||||
let verified = if sha256.is_some() { Verified::Checksum } else { Verified::SizeOnly };
|
||||
// A finished copy from an earlier attempt that never got installed.
|
||||
if dest.is_file() && matches_published(dest, size, sha256) {
|
||||
progress(Transfer::Bytes { done: size, total: size });
|
||||
return Ok(verified);
|
||||
}
|
||||
// The partial file is named after the release file and its size; if it still turns out
|
||||
// to belong to something else, the checksum catches it and the download starts over.
|
||||
let part = part_path(dest, size);
|
||||
let mut fresh_start_used = false;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err("cancelled".into());
|
||||
if let Err(e) = self.fetch_resuming(url, &part, size, stop, progress) {
|
||||
if e == CANCELLED {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
} else if e != PAUSED {
|
||||
log::warn!("download of {url} failed: {e}");
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
let n = reader.read(&mut buf).map_err(|e| format!("the download was interrupted ({e})"))?;
|
||||
if matches_published(&part, size, sha256) {
|
||||
std::fs::rename(&part, dest).map_err(|e| format!("couldn't save the download ({e})"))?;
|
||||
return Ok(verified);
|
||||
}
|
||||
let _ = std::fs::remove_file(&part);
|
||||
if fresh_start_used {
|
||||
return Err("the download doesn't match its published checksum, so it wasn't installed".into());
|
||||
}
|
||||
// A resumed file can be spliced wrongly by a misbehaving proxy: try once from zero.
|
||||
log::warn!("checksum mismatch for {url}; downloading again from the start");
|
||||
fresh_start_used = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches `url` into `part`, resuming from whatever `part` already holds. Retries while the
|
||||
/// transfer keeps making progress; after several attempts in a row get nowhere, it stops and
|
||||
/// reports `PAUSED` so the user can resume later.
|
||||
fn fetch_resuming(&self, url: &str, part: &Path, size: u64, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), String> {
|
||||
let mut fruitless = 0u32;
|
||||
loop {
|
||||
stop.check()?;
|
||||
let have = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0);
|
||||
if fruitless >= MAX_FRUITLESS_ATTEMPTS {
|
||||
log::warn!("{url}: no progress after {fruitless} attempts; pausing");
|
||||
return Err(PAUSED.into());
|
||||
}
|
||||
if fruitless > 0 {
|
||||
progress(Transfer::Reconnecting { done: have, total: size });
|
||||
// 1 s, 2 s, 4 s … 30 s, staying responsive to Pause and Cancel.
|
||||
let wait = Duration::from_secs((1u64 << (fruitless - 1).min(5)).min(30));
|
||||
let until = std::time::Instant::now() + wait;
|
||||
while std::time::Instant::now() < until {
|
||||
stop.check()?;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
if size > 0 && have == size {
|
||||
return Ok(());
|
||||
}
|
||||
if size > 0 && have > size {
|
||||
let _ = std::fs::remove_file(part);
|
||||
continue;
|
||||
}
|
||||
match self.attempt(url, part, have, size, stop, progress) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(AttemptError::Stopped(e)) => return Err(e),
|
||||
Err(AttemptError::Fatal(e)) => return Err(e),
|
||||
Err(AttemptError::Retry { error, progressed }) => {
|
||||
log::info!("{url}: {error}; retrying");
|
||||
fruitless = if progressed { 1 } else { fruitless + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request: appends to `part` from byte `have`.
|
||||
fn attempt(&self, url: &str, part: &Path, have: u64, size: u64, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), AttemptError> {
|
||||
let retry = |error: String, progressed: bool| AttemptError::Retry { error, progressed };
|
||||
// Binaries are already compressed; ask for the bytes exactly as published.
|
||||
let mut req = self.download.get(url).header("Accept-Encoding", "identity");
|
||||
if have > 0 {
|
||||
req = req.header("Range", &format!("bytes={have}-"));
|
||||
}
|
||||
let mut resp = match req.call() {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(retry(format!("couldn't connect: {e}"), false)),
|
||||
};
|
||||
let status = resp.status().as_u16();
|
||||
let header = |name: &str| resp.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||
let (start, total) = match status {
|
||||
200 => (0, header("content-length").and_then(|v| v.parse::<u64>().ok()).filter(|n| *n > 0).unwrap_or(size)),
|
||||
206 => match header("content-range").as_deref().and_then(parse_content_range) {
|
||||
Some((start, Some(total))) => (start, total),
|
||||
Some((start, None)) => (start, size),
|
||||
None => return Err(retry("the server sent an unreadable partial response".into(), false)),
|
||||
},
|
||||
// The range is past the end: the part file is already complete (or wrong; the
|
||||
// checksum decides).
|
||||
416 if have > 0 => return Ok(()),
|
||||
404 | 410 => return Err(AttemptError::Fatal("the file is no longer on the release page".into())),
|
||||
401 | 403 => return Err(retry(format!("the server refused the download (HTTP {status})"), false)),
|
||||
s => return Err(retry(format!("HTTP {s}"), false)),
|
||||
};
|
||||
if size > 0 && total != size {
|
||||
return Err(AttemptError::Fatal(format!("the server offered {total} bytes, but the release lists {size}")));
|
||||
}
|
||||
// Open the part file at the right place: a 200 restarts it, a 206 continues it.
|
||||
let mut file = match (status, start) {
|
||||
(206, s) if s == have => std::fs::OpenOptions::new().append(true).open(part),
|
||||
_ => std::fs::File::create(part),
|
||||
}
|
||||
.map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?;
|
||||
let mut done = if status == 206 && start == have { have } else { 0 };
|
||||
// We check the length and checksum ourselves; the reader's limit is only a backstop
|
||||
// against an endless body. (ureq errors once the limit is *reached*, so leave headroom.)
|
||||
let remaining = if total > 0 { total.saturating_sub(done) } else { MAX_DOWNLOAD };
|
||||
let mut reader = resp.body_mut().with_config().limit(remaining.saturating_add(1 << 20)).reader();
|
||||
let mut buf = vec![0u8; 256 * 1024];
|
||||
let at_start = done;
|
||||
progress(Transfer::Bytes { done, total });
|
||||
loop {
|
||||
stop.check().map_err(AttemptError::Stopped)?;
|
||||
let n = match reader.read(&mut buf) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
let _ = file.sync_all();
|
||||
return Err(retry(format!("the connection dropped: {e}"), done > at_start));
|
||||
}
|
||||
};
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk = buf.get(..n).ok_or("read past the buffer")?;
|
||||
hasher.update(chunk);
|
||||
file.write_all(chunk).map_err(|e| format!("couldn't save the download ({e})"))?;
|
||||
let chunk = buf.get(..n).ok_or_else(|| AttemptError::Fatal("read past the buffer".into()))?;
|
||||
file.write_all(chunk).map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e}). Is the disk full?")))?;
|
||||
done = done.saturating_add(n as u64);
|
||||
progress(done, total);
|
||||
if total > 0 && done > total {
|
||||
return Err(retry("the server sent more than the file's size".into(), false));
|
||||
}
|
||||
progress(Transfer::Bytes { done, total });
|
||||
}
|
||||
file.sync_all().map_err(|e| format!("couldn't save the download ({e})"))?;
|
||||
drop(file);
|
||||
if total > 0 && done != total {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(format!("the download stopped early ({done} of {total} bytes)"));
|
||||
file.sync_all().map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?;
|
||||
if total > 0 && done < total {
|
||||
return Err(retry(format!("the connection closed early ({done} of {total} bytes)"), done > at_start));
|
||||
}
|
||||
let got = hex(&hasher.finalize());
|
||||
if !got.eq_ignore_ascii_case(sha256) {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err("the download doesn't match its published checksum, so it wasn't installed".into());
|
||||
}
|
||||
std::fs::rename(&part, dest).map_err(|e| format!("couldn't save the download ({e})"))
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts in a row without any new bytes before pausing (about two minutes of backoff).
|
||||
const MAX_FRUITLESS_ATTEMPTS: u32 = 8;
|
||||
/// The longest one request may spend receiving; the next request resumes.
|
||||
const ATTEMPT_BODY_TIME: Duration = Duration::from_secs(5 * 60);
|
||||
/// Backstop when the server doesn't say how big the file is.
|
||||
const MAX_DOWNLOAD: u64 = 16 << 30;
|
||||
|
||||
/// What a download reports as it goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Transfer {
|
||||
Bytes {
|
||||
done: u64,
|
||||
total: u64,
|
||||
},
|
||||
/// The connection dropped; waiting before trying again.
|
||||
Reconnecting {
|
||||
done: u64,
|
||||
total: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// How a finished download was checked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Verified {
|
||||
/// Against the release's published SHA-256.
|
||||
Checksum,
|
||||
/// The release publishes no checksum: only its size could be checked.
|
||||
SizeOnly,
|
||||
}
|
||||
|
||||
/// Asks a running download to pause (keeping its partial file) or cancel (deleting it).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Stop(AtomicU8);
|
||||
|
||||
impl Stop {
|
||||
const RUN: u8 = 0;
|
||||
const PAUSE: u8 = 1;
|
||||
const CANCEL: u8 = 2;
|
||||
|
||||
pub fn pause(&self) {
|
||||
let _ = self.0.compare_exchange(Self::RUN, Self::PAUSE, Ordering::SeqCst, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(Self::CANCEL, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// `Err(PAUSED | CANCELLED)` once asked to stop.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
match self.0.load(Ordering::SeqCst) {
|
||||
Self::PAUSE => Err(PAUSED.into()),
|
||||
Self::CANCEL => Err(CANCELLED.into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a file on disk is the published one: its checksum when there is one, else its size.
|
||||
fn matches_published(path: &Path, size: u64, sha256: Option<&str>) -> bool {
|
||||
match sha256 {
|
||||
Some(sha) => sha256_file(path).is_ok_and(|h| h.eq_ignore_ascii_case(sha)),
|
||||
None => size > 0 && std::fs::metadata(path).is_ok_and(|m| m.len() == size),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a download is already on disk (a paused or interrupted one).
|
||||
pub fn partial_bytes(dest: &Path, size: u64) -> Option<u64> {
|
||||
std::fs::metadata(part_path(dest, size)).ok().map(|m| m.len()).filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
/// Throws away a paused download.
|
||||
pub fn discard_partial(dest: &Path, size: u64) {
|
||||
let _ = std::fs::remove_file(part_path(dest, size));
|
||||
}
|
||||
|
||||
enum AttemptError {
|
||||
/// Paused or cancelled.
|
||||
Stopped(String),
|
||||
/// Won't get better by trying again.
|
||||
Fatal(String),
|
||||
Retry {
|
||||
error: String,
|
||||
progressed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// `<dest>.<size>.part`: where an unfinished download of `dest` is kept.
|
||||
fn part_path(dest: &Path, size: u64) -> std::path::PathBuf {
|
||||
let tag = size.to_string();
|
||||
let mut name = dest.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
|
||||
name.push(format!(".{tag}.part"));
|
||||
dest.with_file_name(name)
|
||||
}
|
||||
|
||||
/// `bytes 100-199/1000` → (100, Some(1000)); `bytes 100-199/*` → (100, None).
|
||||
fn parse_content_range(v: &str) -> Option<(u64, Option<u64>)> {
|
||||
let rest = v.trim().strip_prefix("bytes ")?;
|
||||
let (range, total) = rest.split_once('/')?;
|
||||
let start = range.split_once('-')?.0.trim().parse().ok()?;
|
||||
let total = if total.trim() == "*" { None } else { Some(total.trim().parse().ok()?) };
|
||||
Some((start, total))
|
||||
}
|
||||
|
||||
/// The certificate authorities the operating system trusts.
|
||||
fn os_roots() -> Result<ureq::tls::RootCerts, String> {
|
||||
let found = rustls_native_certs::load_native_certs();
|
||||
@@ -217,3 +429,241 @@ pub fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
fn host(url: &str) -> &str {
|
||||
url.split("://").nth(1).and_then(|r| r.split('/').next()).unwrap_or(url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_content_ranges() {
|
||||
assert_eq!(parse_content_range("bytes 100-199/1000"), Some((100, Some(1000))));
|
||||
assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, None)));
|
||||
assert_eq!(parse_content_range("items 1-2/3"), None);
|
||||
assert_eq!(parse_content_range("bytes x-9/10"), None);
|
||||
}
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// How the fake server answers one request.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Reply {
|
||||
/// The whole file.
|
||||
Full,
|
||||
/// Promise the whole file, send `n` bytes, hang up.
|
||||
DropAfter(usize),
|
||||
/// Honour `Range` with a 206.
|
||||
Ranged,
|
||||
/// The right length, wrong bytes.
|
||||
Corrupt,
|
||||
/// 404.
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// Serves `body` over plain HTTP following `script`, one reply per connection; records the
|
||||
/// `Range` header each request carried.
|
||||
fn serve(body: Vec<u8>, script: Vec<Reply>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/file.bin", listener.local_addr().unwrap());
|
||||
let ranges = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = ranges.clone();
|
||||
std::thread::spawn(move || {
|
||||
for reply in script {
|
||||
let Ok((mut stream, _)) = listener.accept() else { return };
|
||||
let mut range = None;
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
if let Some(v) = line.to_ascii_lowercase().strip_prefix("range: bytes=") {
|
||||
range = v.trim().trim_end_matches('-').parse::<usize>().ok();
|
||||
}
|
||||
}
|
||||
seen.lock().unwrap().push(range.map(|r| r.to_string()));
|
||||
let len = body.len();
|
||||
let _ = match reply {
|
||||
Reply::Full => {
|
||||
write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n").and_then(|()| stream.write_all(&body))
|
||||
}
|
||||
Reply::DropAfter(n) => write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n")
|
||||
.and_then(|()| stream.write_all(&body[..n])),
|
||||
Reply::Corrupt => write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n")
|
||||
.and_then(|()| stream.write_all(&vec![b'x'; len])),
|
||||
Reply::Missing => write!(stream, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"),
|
||||
Reply::Ranged => {
|
||||
let from = range.unwrap_or(0);
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {from}-{}/{len}\r\nConnection: close\r\n\r\n",
|
||||
len - from,
|
||||
len - 1
|
||||
)
|
||||
.and_then(|()| stream.write_all(&body[from..]))
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
(url, ranges)
|
||||
}
|
||||
|
||||
fn body() -> (Vec<u8>, String) {
|
||||
let body: Vec<u8> = (0..700_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let sha = hex(&Sha256::digest(&body));
|
||||
(body, sha)
|
||||
}
|
||||
|
||||
fn run(script: Vec<Reply>) -> (Result<Vec<u8>, String>, Vec<Option<String>>) {
|
||||
let (body, sha) = body();
|
||||
let (url, ranges) = serve(body.clone(), script);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("file.bin");
|
||||
let http = Http::new().unwrap();
|
||||
let mut last = (0, 0);
|
||||
let result = http.download(&url, &dest, body.len() as u64, Some(&sha), &Stop::default(), &mut |t| {
|
||||
if let Transfer::Bytes { done, total } = t {
|
||||
last = (done, total);
|
||||
}
|
||||
});
|
||||
let result = result.map(|v| {
|
||||
assert_eq!(v, Verified::Checksum);
|
||||
std::fs::read(&dest).unwrap()
|
||||
});
|
||||
if result.is_ok() {
|
||||
assert_eq!(last, (body.len() as u64, body.len() as u64));
|
||||
}
|
||||
let seen = ranges.lock().unwrap().clone();
|
||||
(result, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_body_of_exactly_the_published_size_succeeds() {
|
||||
// The bug this guards: a reader limit equal to the size failed on the final read.
|
||||
let (result, _) = run(vec![Reply::Full]);
|
||||
assert_eq!(result.unwrap(), body().0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_connection_resumes_where_it_stopped() {
|
||||
let (result, ranges) = run(vec![Reply::DropAfter(300_000), Reply::Ranged]);
|
||||
assert_eq!(result.unwrap(), body().0);
|
||||
assert_eq!(ranges, [None, Some("300000".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_ignores_ranges_starts_over() {
|
||||
let (result, _) = run(vec![Reply::DropAfter(123_456), Reply::Full]);
|
||||
assert_eq!(result.unwrap(), body().0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_data_is_fetched_again_and_never_kept() {
|
||||
let (result, _) = run(vec![Reply::Corrupt, Reply::Full]);
|
||||
assert_eq!(result.unwrap(), body().0);
|
||||
let (result, _) = run(vec![Reply::Corrupt, Reply::Corrupt]);
|
||||
assert!(result.unwrap_err().contains("checksum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_checksum_the_size_is_still_checked() {
|
||||
let (body, _) = body();
|
||||
let (url, _) = serve(body.clone(), vec![Reply::Full]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("file.bin");
|
||||
let v = Http::new().unwrap().download(&url, &dest, body.len() as u64, None, &Stop::default(), &mut |_| {}).unwrap();
|
||||
assert_eq!(v, Verified::SizeOnly);
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pausing_keeps_the_partial_file_and_resuming_continues_it() {
|
||||
let (body, sha) = body();
|
||||
let len = body.len() as u64;
|
||||
let (url, ranges) = serve(body.clone(), vec![Reply::Full, Reply::Ranged]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("file.bin");
|
||||
let http = Http::new().unwrap();
|
||||
let stop = Stop::default();
|
||||
// Pause as soon as the first bytes arrive.
|
||||
let err = http
|
||||
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
|
||||
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
|
||||
stop.pause();
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(err, PAUSED);
|
||||
let kept = partial_bytes(&dest, len).unwrap();
|
||||
assert!(kept > 0 && kept < len, "{kept}");
|
||||
http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), body);
|
||||
assert_eq!(ranges.lock().unwrap()[1], Some(kept.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_throws_the_partial_file_away() {
|
||||
let (body, sha) = body();
|
||||
let len = body.len() as u64;
|
||||
let (url, _) = serve(body, vec![Reply::Full]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("file.bin");
|
||||
let stop = Stop::default();
|
||||
let err = Http::new()
|
||||
.unwrap()
|
||||
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
|
||||
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
|
||||
stop.cancel();
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(err, CANCELLED);
|
||||
assert_eq!(partial_bytes(&dest, len), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_fails_at_once() {
|
||||
let (result, ranges) = run(vec![Reply::Missing, Reply::Full]);
|
||||
assert!(result.unwrap_err().contains("no longer"));
|
||||
assert_eq!(ranges.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partial_file_from_an_earlier_run_is_continued() {
|
||||
let (body, sha) = body();
|
||||
let (url, ranges) = serve(body.clone(), vec![Reply::Ranged]);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("file.bin");
|
||||
std::fs::write(part_path(&dest, body.len() as u64), &body[..500_000]).unwrap();
|
||||
assert_eq!(partial_bytes(&dest, body.len() as u64), Some(500_000));
|
||||
Http::new().unwrap().download(&url, &dest, body.len() as u64, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), body);
|
||||
assert_eq!(*ranges.lock().unwrap(), [Some("500000".to_string())]);
|
||||
assert!(!part_path(&dest, body.len() as u64).exists());
|
||||
}
|
||||
|
||||
/// Live: a real Craft App release asset (`cargo test -p artcraft-launcher-native -- --ignored`).
|
||||
#[test]
|
||||
#[ignore = "needs network access"]
|
||||
fn downloads_a_real_release_asset() {
|
||||
let http = Http::new().unwrap();
|
||||
let Fetched::Body { text, .. } = http.get_api("https://api.github.com/repos/storytold/wordcraft/releases/latest", None).unwrap() else {
|
||||
panic!("no release")
|
||||
};
|
||||
let release = artcraft_launcher_core::release::parse_github_release(&text, "storytold/wordcraft").unwrap();
|
||||
let sums_url = &release.sums_asset().unwrap().url;
|
||||
let sums = artcraft_launcher_core::release::parse_sha256sums(&http.get_text(sums_url).unwrap().unwrap());
|
||||
let asset = release.assets.iter().find(|a| a.name.ends_with("freebsd-x86_64.tar.gz")).unwrap();
|
||||
let sha = asset.sha256.clone().or_else(|| sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone())).unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join(&asset.name);
|
||||
http.download(&asset.url, &dest, asset.size, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
|
||||
assert_eq!(std::fs::metadata(&dest).unwrap().len(), asset.size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_files_are_named_after_the_file_and_size() {
|
||||
assert_eq!(part_path(Path::new("/d/photocraft.dmg"), 9), Path::new("/d/photocraft.dmg.9.part"));
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,12 @@
|
||||
//! running app is never replaced, and a new copy is fully in place before the old one goes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use artcraft_launcher_core::protocol::JobPhase;
|
||||
use artcraft_launcher_core::release::{self, Asset, Release};
|
||||
use artcraft_launcher_core::{CraftApp, InstallSource, Installed, PackageKind};
|
||||
|
||||
use crate::http::Http;
|
||||
use crate::http::{Http, Stop, Transfer, Verified};
|
||||
use crate::manifest::Record;
|
||||
use crate::paths::Paths;
|
||||
use crate::process;
|
||||
@@ -28,7 +27,7 @@ use crate::process;
|
||||
pub struct Job<'a> {
|
||||
pub http: &'a Http,
|
||||
pub paths: &'a Paths,
|
||||
pub cancel: &'a AtomicBool,
|
||||
pub stop: &'a Stop,
|
||||
pub phase: &'a mut dyn FnMut(JobPhase),
|
||||
}
|
||||
|
||||
@@ -41,14 +40,15 @@ pub fn install(
|
||||
kind: PackageKind,
|
||||
existing: Option<&Installed>,
|
||||
job: &mut Job<'_>,
|
||||
) -> Result<Option<Record>, String> {
|
||||
) -> Result<(Option<Record>, Verified), String> {
|
||||
if let Some(inst) = existing
|
||||
&& inst.source.launcher_can_update()
|
||||
&& process::is_running(Path::new(&inst.location))
|
||||
{
|
||||
return Err(format!("{} is running. Quit it, then try again.", app.name));
|
||||
}
|
||||
let file = download(release, asset, job)?;
|
||||
let (file, verified) = download(release, asset, job)?;
|
||||
// Past this point the install can't be interrupted halfway.
|
||||
(job.phase)(JobPhase::Installing);
|
||||
#[allow(unused_variables)]
|
||||
let version = release.version_label();
|
||||
@@ -69,7 +69,12 @@ pub fn install(
|
||||
};
|
||||
// The download has served its purpose either way; a failed install starts fresh next time.
|
||||
let _ = std::fs::remove_file(&file);
|
||||
result
|
||||
result.map(|r| (r, verified))
|
||||
}
|
||||
|
||||
/// Where `asset` is downloaded to (and its partial file kept while unfinished).
|
||||
pub fn download_path(paths: &Paths, asset: &Asset) -> PathBuf {
|
||||
paths.downloads().join(&asset.name)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -119,29 +124,39 @@ pub fn available_kinds() -> Vec<PackageKind> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Fetches `asset` into the download folder and checks it against its SHA-256.
|
||||
fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<PathBuf, String> {
|
||||
/// Fetches `asset` into the download folder, checked against its published SHA-256: GitHub's
|
||||
/// per-file digest, else the release's `SHA256SUMS.txt`. A release with neither still installs,
|
||||
/// checked by size only, and says so.
|
||||
fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<(PathBuf, Verified), String> {
|
||||
if !release::valid_file_name(&asset.name) {
|
||||
return Err("the release has an unexpected file name".into());
|
||||
}
|
||||
let sha = match &asset.sha256 {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
// Older releases predate GitHub's per-asset digests: use SHA256SUMS.txt.
|
||||
let sums_asset = release.sums_asset().ok_or("this release publishes no checksums, so it can't be verified")?;
|
||||
let text = job.http.get_text(&sums_asset.url)?.ok_or("this release's checksum list is missing")?;
|
||||
release::parse_sha256sums(&text)
|
||||
.into_iter()
|
||||
.find(|(_, n)| *n == asset.name)
|
||||
.map(|(h, _)| h)
|
||||
.ok_or("this file isn't in the release's checksum list")?
|
||||
}
|
||||
Some(s) => Some(s.clone()),
|
||||
None => published_checksum(release, asset, job.http)?,
|
||||
};
|
||||
let dest = job.paths.downloads().join(&asset.name);
|
||||
if sha.is_none() {
|
||||
log::warn!("{}: no published checksum; checking the size only", asset.name);
|
||||
}
|
||||
let dest = download_path(job.paths, asset);
|
||||
let phase = &mut *job.phase;
|
||||
job.http.download(&asset.url, &dest, asset.size, &sha, job.cancel, &mut |done, total| phase(JobPhase::Downloading { done, total }))?;
|
||||
let verified = job.http.download(&asset.url, &dest, asset.size, sha.as_deref(), job.stop, &mut |t| match t {
|
||||
Transfer::Bytes { done, total } => phase(JobPhase::Downloading { done, total }),
|
||||
Transfer::Reconnecting { done, total } => phase(JobPhase::Reconnecting { done, total }),
|
||||
})?;
|
||||
(job.phase)(JobPhase::Verifying);
|
||||
Ok(dest)
|
||||
Ok((dest, verified))
|
||||
}
|
||||
|
||||
/// The file's line in the release's `SHA256SUMS.txt`. `Ok(None)` only when the release really
|
||||
/// publishes no checksum for it; a list that exists but can't be fetched is an error, so a
|
||||
/// network hiccup never downgrades the check.
|
||||
fn published_checksum(release: &Release, asset: &Asset, http: &Http) -> Result<Option<String>, String> {
|
||||
let Some(sums) = release.sums_asset() else { return Ok(None) };
|
||||
let Some(text) = http.get_text(&sums.url).map_err(|e| format!("couldn't fetch the release's checksums ({e}). Try again"))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(release::parse_sha256sums(&text).into_iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h))
|
||||
}
|
||||
|
||||
/// Removes an installed copy.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, BackendOptions, Capabilities, Command, Event, LatestRelease, Source};
|
||||
use artcraft_launcher_core::protocol::{Backend, BackendOptions, CANCELLED, Capabilities, Command, Event, LatestRelease, PAUSED, Source};
|
||||
use artcraft_launcher_core::release::Release;
|
||||
use artcraft_launcher_core::status::AppStatus;
|
||||
use artcraft_launcher_core::{Platform, SUITE};
|
||||
@@ -175,13 +175,21 @@ impl LauncherUi {
|
||||
}
|
||||
false
|
||||
}
|
||||
Event::Paused { app, download } => {
|
||||
if let Some(e) = self.entry_mut(&app) {
|
||||
e.paused = download;
|
||||
}
|
||||
false
|
||||
}
|
||||
Event::JobFinished { app, result } => {
|
||||
if let Some(e) = self.entry_mut(&app) {
|
||||
e.job = None;
|
||||
}
|
||||
match result {
|
||||
Ok(msg) => self.toast(ctx, msg, false),
|
||||
Err(e) if e == "cancelled" => self.toast(ctx, "Cancelled", false),
|
||||
// The paused card says it all.
|
||||
Err(e) if e == PAUSED => {}
|
||||
Err(e) if e == CANCELLED => self.toast(ctx, "Download cancelled", false),
|
||||
Err(e) => self.toast(ctx, e, true),
|
||||
}
|
||||
true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! What the UI knows about each app.
|
||||
|
||||
use artcraft_launcher_core::platform::{Platform, pick_asset};
|
||||
use artcraft_launcher_core::protocol::{JobPhase, LatestRelease};
|
||||
use artcraft_launcher_core::protocol::{JobPhase, LatestRelease, PausedDownload};
|
||||
use artcraft_launcher_core::release::Release;
|
||||
use artcraft_launcher_core::status::{self, AppStatus, Latest};
|
||||
use artcraft_launcher_core::{CraftApp, Installed};
|
||||
@@ -14,11 +14,13 @@ pub struct AppEntry {
|
||||
/// Detection has answered at least once.
|
||||
pub detected: bool,
|
||||
pub job: Option<JobPhase>,
|
||||
/// A download waiting to be resumed.
|
||||
pub paused: Option<PausedDownload>,
|
||||
}
|
||||
|
||||
impl AppEntry {
|
||||
pub fn new(app: &'static CraftApp) -> Self {
|
||||
Self { app, latest: None, installed: None, detected: false, job: None }
|
||||
Self { app, latest: None, installed: None, detected: false, job: None, paused: None }
|
||||
}
|
||||
|
||||
pub fn release(&self) -> Option<&Release> {
|
||||
@@ -87,6 +89,18 @@ pub fn status_text(status: &AppStatus, manage_apps: bool) -> (&'static str, Tone
|
||||
}
|
||||
}
|
||||
|
||||
/// [`status_text`], but saying so when a download is paused or reconnecting.
|
||||
pub fn entry_status_text(e: &AppEntry, status: &AppStatus, manage_apps: bool) -> (&'static str, Tone) {
|
||||
match (e.job, e.paused) {
|
||||
(Some(JobPhase::Reconnecting { .. }), _) => ("Reconnecting…", Tone::Warn),
|
||||
(Some(JobPhase::Downloading { .. }), _) if status.is_installed() => ("Updating…", Tone::Info),
|
||||
(Some(JobPhase::Downloading { .. }), _) => ("Downloading…", Tone::Info),
|
||||
(None, Some(_)) if status.is_installed() => ("Update paused", Tone::Warn),
|
||||
(None, Some(_)) => ("Download paused", Tone::Warn),
|
||||
_ => status_text(status, manage_apps),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort rank for "by status": things needing attention first.
|
||||
pub fn status_rank(status: &AppStatus) -> u8 {
|
||||
match status {
|
||||
|
||||
+67
-21
@@ -229,7 +229,11 @@ impl LauncherUi {
|
||||
|
||||
let status = entry.status(self.platform, self.caps.manage_apps);
|
||||
let inner = rect.shrink2(vec2(16.0, 14.0));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)), |ui| {
|
||||
// A detached child: the card's contents never move the next card along.
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
|
||||
child.set_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
{
|
||||
let ui = &mut child;
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(icon) = crate::icons::app(entry.app.id) {
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(54.0, 54.0)));
|
||||
@@ -250,7 +254,7 @@ impl LauncherUi {
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (text, tone) = model::status_text(&status, self.caps.manage_apps);
|
||||
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
|
||||
let tip = entry.failure().map(|f| format!("Couldn't check for updates: {f}"));
|
||||
let r = widgets::pill(ui, text, tone);
|
||||
if let Some(tip) = tip {
|
||||
@@ -262,7 +266,7 @@ impl LauncherUi {
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- table ---------------------------------------------------------------------------
|
||||
@@ -337,7 +341,7 @@ impl LauncherUi {
|
||||
});
|
||||
});
|
||||
row.col(|ui| {
|
||||
let (text, tone) = model::status_text(&status, self.caps.manage_apps);
|
||||
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
|
||||
let r = widgets::pill(ui, text, tone);
|
||||
if let Some(f) = entry.failure() {
|
||||
r.on_hover_text(format!("Couldn't check for updates: {f}"));
|
||||
@@ -410,27 +414,69 @@ impl LauncherUi {
|
||||
let id = entry.app.id.to_string();
|
||||
let main_w = (width - 40.0).max(120.0);
|
||||
if let Some(phase) = entry.job {
|
||||
let (fraction, caption) = match phase {
|
||||
JobPhase::Downloading { done, total } if total > 0 => {
|
||||
let f = done as f32 / total as f32;
|
||||
let caption = if main_w >= 200.0 {
|
||||
format!("Downloading {} of {} · {:.0}%", widgets::human_bytes(done), widgets::human_bytes(total), f * 100.0)
|
||||
} else {
|
||||
format!("{} of {} · {:.0}%", widgets::human_bytes(done), widgets::human_bytes(total), f * 100.0)
|
||||
};
|
||||
(Some(f), caption)
|
||||
}
|
||||
JobPhase::Downloading { done, .. } => (None, format!("Downloading {}", widgets::human_bytes(done))),
|
||||
JobPhase::Verifying => (None, "Verifying checksum…".into()),
|
||||
JobPhase::Installing => (None, "Installing…".into()),
|
||||
JobPhase::Removing => (None, "Removing…".into()),
|
||||
// Two small buttons (pause, cancel) beside the bar while downloading.
|
||||
let bar_w = (width - 88.0).max(100.0);
|
||||
let short = bar_w < 200.0;
|
||||
let amount = |done: u64, total: u64| {
|
||||
if total > 0 { format!("{} of {}", widgets::human_bytes(done), widgets::human_bytes(total)) } else { widgets::human_bytes(done) }
|
||||
};
|
||||
widgets::progress(ui, fraction, &caption, t.accent, main_w);
|
||||
if matches!(phase, JobPhase::Downloading { .. }) && widgets::icon_button(ui, "×", "Cancel").clicked() {
|
||||
self.send(Command::Cancel { app: id });
|
||||
let (fraction, caption, color) = match phase {
|
||||
JobPhase::Downloading { done, total } => {
|
||||
let f = (total > 0).then(|| done as f32 / total as f32);
|
||||
let pct = f.map(|f| format!(" · {:.0}%", f * 100.0)).unwrap_or_default();
|
||||
let caption = if bar_w < 150.0 {
|
||||
match f {
|
||||
Some(f) => format!("{:.0}% of {}", f * 100.0, widgets::human_bytes(total)),
|
||||
None => widgets::human_bytes(done),
|
||||
}
|
||||
} else if short {
|
||||
format!("{}{pct}", amount(done, total))
|
||||
} else {
|
||||
format!("Downloading {}{pct}", amount(done, total))
|
||||
};
|
||||
(f, caption, t.accent)
|
||||
}
|
||||
JobPhase::Reconnecting { done, total } => {
|
||||
let f = (total > 0).then(|| done as f32 / total as f32);
|
||||
(f, if short { "Reconnecting…".into() } else { format!("Reconnecting… {}", amount(done, total)) }, t.warn)
|
||||
}
|
||||
JobPhase::Verifying => (None, "Verifying checksum…".into(), t.accent),
|
||||
JobPhase::Installing => (None, "Installing…".into(), t.accent),
|
||||
JobPhase::Removing => (None, "Removing…".into(), t.accent),
|
||||
};
|
||||
let downloading = matches!(phase, JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. });
|
||||
widgets::progress(ui, fraction, &caption, color, if downloading { bar_w } else { main_w });
|
||||
if downloading {
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Pause, "Pause download").clicked() {
|
||||
self.send(Command::Pause { app: id.clone() });
|
||||
}
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, "Cancel download").clicked() {
|
||||
self.send(Command::Cancel { app: id });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(p) = entry.paused
|
||||
&& self.caps.manage_apps
|
||||
{
|
||||
let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() };
|
||||
// Resume, discard (32) and the menu (~40), with spacing, within `width`.
|
||||
let label = if width < 260.0 { format!("Resume{pct}") } else { format!("Resume download{pct}") };
|
||||
let resp = widgets::primary(ui, &label, t.accent, (width - 96.0).max(90.0));
|
||||
let tip = if p.total > 0 {
|
||||
format!("{} of {} downloaded", widgets::human_bytes(p.done), widgets::human_bytes(p.total))
|
||||
} else {
|
||||
format!("{} downloaded", widgets::human_bytes(p.done))
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
self.send(Command::Install { app: id.clone() });
|
||||
}
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, "Discard download").clicked() {
|
||||
self.send(Command::Cancel { app: id.clone() });
|
||||
}
|
||||
self.menu(ui, entry, status);
|
||||
return;
|
||||
}
|
||||
match self.primary_action(entry, status) {
|
||||
Primary::Install => {
|
||||
if widgets::primary(ui, "Install", t.accent, main_w).clicked() {
|
||||
|
||||
@@ -163,3 +163,41 @@ pub fn human_bytes(n: u64) -> String {
|
||||
format!("{:.0} KB", (n / 1024.0).max(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Which drawn glyph a [`glyph_button`] shows (drawn, so no font needs the symbol).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Glyph {
|
||||
Pause,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// A small square button with a drawn glyph.
|
||||
pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
|
||||
let hovered = resp.hovered();
|
||||
let p = ui.painter();
|
||||
if hovered {
|
||||
p.rect_filled(
|
||||
rect,
|
||||
CornerRadius::same(theme::RADIUS_SMALL),
|
||||
if t.dark { Color32::from_rgb(0x2b, 0x30, 0x3c) } else { Color32::from_rgb(0xe4, 0xe7, 0xee) },
|
||||
);
|
||||
}
|
||||
let c = if hovered { t.text } else { t.text_muted };
|
||||
let m = rect.center();
|
||||
match glyph {
|
||||
Glyph::Pause => {
|
||||
for dx in [-3.5, 3.5] {
|
||||
p.rect_filled(egui::Rect::from_center_size(m + vec2(dx, 0.0), vec2(3.0, 11.0)), CornerRadius::same(1), c);
|
||||
}
|
||||
}
|
||||
Glyph::Close => {
|
||||
let s = 4.5;
|
||||
p.line_segment([m + vec2(-s, -s), m + vec2(s, s)], Stroke::new(1.6, c));
|
||||
p.line_segment([m + vec2(-s, s), m + vec2(s, -s)], Stroke::new(1.6, c));
|
||||
}
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Source};
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, PausedDownload, Source};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::status::InstallSource;
|
||||
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
@@ -91,6 +91,8 @@ fn events() -> Vec<Event> {
|
||||
ev.push(Event::Detected { app: app.id.into(), installed: inst });
|
||||
}
|
||||
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } });
|
||||
ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });
|
||||
ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });
|
||||
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });
|
||||
ev
|
||||
}
|
||||
@@ -135,7 +137,7 @@ fn grid_shows_every_status_and_installs_on_click() {
|
||||
h.run_steps(8);
|
||||
snapshot(&mut h, "grid-dark");
|
||||
// Statuses the user asked for: available, installed and up to date, update with version.
|
||||
assert!(h.query_all_by_label("Available to install").count() >= 5);
|
||||
assert!(h.query_all_by_label("Available to install").count() >= 3);
|
||||
assert!(h.query_all_by_label("Installed · up to date").count() >= 2);
|
||||
h.get_by_label("Update to 0.5.0");
|
||||
h.get_by_label("Update to 0.6.0");
|
||||
@@ -149,6 +151,29 @@ fn grid_shows_every_status_and_installs_on_click() {
|
||||
assert!(installs.contains(&"photocraft".to_string()) && installs.contains(&"effectcraft".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_can_be_paused_resumed_and_discarded() {
|
||||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(4);
|
||||
// Bring the paused and reconnecting apps on screen.
|
||||
if let Some(l) = h.state_mut().as_mut() {
|
||||
l.settings.filter = artcraft_launcher_ui_egui::settings::Filter::NotInstalled;
|
||||
}
|
||||
h.run_steps(6);
|
||||
snapshot(&mut h, "grid-downloads-dark");
|
||||
h.get_by_label("Download paused");
|
||||
h.get_by_label("Reconnecting…");
|
||||
h.get_by_label_contains("· 25%").click();
|
||||
h.run_steps(2);
|
||||
assert!(sent.borrow().contains(&Command::Install { app: "deckcraft".into() }));
|
||||
h.get_by_label("Discard download").click();
|
||||
h.run_steps(2);
|
||||
assert!(sent.borrow().contains(&Command::Cancel { app: "deckcraft".into() }));
|
||||
h.get_all_by_label("Pause download").next().unwrap().click();
|
||||
h.run_steps(2);
|
||||
assert!(sent.borrow().iter().any(|c| matches!(c, Command::Pause { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_view_and_light_theme() {
|
||||
let (mut h, sent) = harness(ViewMode::Table, egui::Theme::Light, true);
|
||||
@@ -181,6 +206,6 @@ fn web_build_offers_downloads() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);
|
||||
h.run_steps(8);
|
||||
snapshot(&mut h, "web-grid-dark");
|
||||
assert!(h.query_all_by_label("Download for macOS").count() >= 10);
|
||||
assert!(h.query_all_by_label("Download for macOS").count() >= 8);
|
||||
assert!(h.query_by_label("Install").is_none());
|
||||
}
|
||||
Reference in new issue
Block a user