feat(install): choose where Craft apps are installed (#461)

* feat(install): choose where Craft apps are installed

Settings gets a default install folder, and a fresh install asks for the
app's folder (prefilled with the default). Updates stay beside the copy
they replace. Works for MSI (INSTALLFOLDER), DMG bundles, and AppImages;
on Linux a custom folder means AppImage. Flatpak and the web build keep
the system location and skip the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(windows): integrate portable installs with custom locations

* fix(install): ask before writing over something already in the install folder

Staging used a predictable `.<name>.partial` path beside the destination, so a
file the user kept under that name could be overwritten (AppImage) or deleted
(macOS). Stage in a fresh private folder instead.

A new install whose destination already exists now stops before downloading
and asks "Replace what's there?". Replace installs over it: the AppImage or
.app is swapped in, a portable install writes over same-named files only, and
the MSI installs into the existing folder. Cancel leaves everything as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(install): a planted .partial symlink can't redirect AppImage staging

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Skyfuller1303 <skyfullerdesign@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 20:46:32 -06:00
1 parent 3fd2d56999
commit 307efcf45a
27 files changed
+1811 -107

No files matched your search

Generated
+72 -2
View File
@@ -290,6 +290,7 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"ron",
"serde",
"tray-icon",
@@ -323,6 +324,7 @@ dependencies = [
"tempfile",
"ureq",
"winreg",
"zip",
]
[[package]]
@@ -1109,6 +1111,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
"objc2 0.6.5",
]
@@ -1187,7 +1191,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"pollster",
"pollster 1.0.1",
"profiling",
"raw-window-handle",
"ron",
@@ -1310,7 +1314,7 @@ dependencies = [
"kittest",
"log",
"open",
"pollster",
"pollster 1.0.1",
"serde",
"tempfile",
"toml 1.1.8+spec-1.1.0",
@@ -2940,6 +2944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -3496,6 +3501,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "pollster"
version = "1.0.1"
@@ -3807,6 +3818,33 @@ dependencies = [
"usvg",
]
[[package]]
name = "rfd"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
dependencies = [
"block2 0.6.2",
"dispatch2",
"js-sys",
"libc",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"percent-encoding",
"pollster 0.4.0",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"web-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rgb"
version = "0.8.53"
@@ -4745,6 +4783,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6014,6 +6058,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -6026,6 +6084,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+3
View File
@@ -60,6 +60,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies]
tray-icon = { version = "0.25", default-features = false, features = ["ksni"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { version = "=0.17.2", default-features = false, features = ["xdg-portal", "wayland"] }
[build-dependencies]
# Icon + VERSIONINFO for artcraft-launcher.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
+8
View File
@@ -432,6 +432,14 @@ impl Host for DesktopHost {
login::set_enabled(on)
}
fn pick_folder(&mut self, initial: Option<&str>) -> Option<String> {
let mut dialog = rfd::FileDialog::new();
if let Some(folder) = initial.filter(|p| std::path::Path::new(p).is_dir()) {
dialog = dialog.set_directory(folder);
}
dialog.pick_folder().and_then(|p| p.to_str().map(str::to_string))
}
fn reduce_motion(&self) -> bool {
motion::reduced()
}
+52
View File
@@ -4,6 +4,8 @@
//! The UI sends [`Command`]s and drains [`Event`]s once per frame, so backends do their work on
//! threads (desktop) or futures (web) and never block a frame.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::platform::{PackageKind, Platform};
@@ -23,6 +25,10 @@ pub enum Command {
Install {
app: String,
},
/// `Install`, writing over what [`Event::Occupied`] reported.
Replace {
app: String,
},
Uninstall {
app: String,
},
@@ -53,9 +59,25 @@ pub enum Command {
Configure(BackendOptions),
}
/// Parent folders for new installs. Existing installations keep their recorded location.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct InstallLocations {
pub root: Option<String>,
pub per_app: BTreeMap<String, String>,
}
impl InstallLocations {
pub fn folder(&self, app: &str) -> Option<&str> {
self.per_app.get(app).map(String::as_str).or(self.root.as_deref())
}
}
/// Settings the backend needs.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct BackendOptions {
pub install_locations: InstallLocations,
/// Package formats to install, best first (empty: the platform default).
pub kinds: Vec<PackageKind>,
/// Offer pre-releases (`0.6.0-rc.1`) as updates.
@@ -188,6 +210,12 @@ pub enum Event {
app: String,
ready: bool,
},
/// A new install stopped before downloading because `path` already exists and the launcher
/// didn't put it there. `Command::Replace` installs over it.
Occupied {
app: String,
path: String,
},
/// A job ended: `Ok` carries a short success message, `Err` what went wrong.
JobFinished {
app: String,
@@ -233,3 +261,27 @@ pub trait Backend {
/// Unix seconds now.
fn now(&self) -> i64;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_folders_inherit_the_default_unless_the_app_has_an_override() {
let mut locations = InstallLocations::default();
assert_eq!(locations.folder("photocraft"), None);
locations.root = Some("/Volumes/Craft".into());
locations.per_app.insert("photocraft".into(), "/Volumes/Photo".into());
assert_eq!(locations.folder("photocraft"), Some("/Volumes/Photo"));
assert_eq!(locations.folder("filmcraft"), Some("/Volumes/Craft"));
locations.per_app.remove("photocraft");
assert_eq!(locations.folder("photocraft"), Some("/Volumes/Craft"));
}
#[test]
fn old_backend_options_keep_system_install_locations() {
let options: BackendOptions = serde_json::from_str(r#"{"kinds":[],"prereleases":true}"#).unwrap();
assert!(options.prereleases);
assert_eq!(options.install_locations, InstallLocations::default());
}
}
+1
View File
@@ -37,3 +37,4 @@ plist = "1"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
zip = { version = "8", default-features = false, features = ["deflate"] }
+123 -9
View File
@@ -20,6 +20,7 @@ use crate::http::{self, Http, Stop};
use crate::install::{self, Job};
use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::process;
use crate::source::{GitHubSource, ReleaseSource};
/// The longest the launcher's own update waits for other installs to end before it gives up
@@ -310,7 +311,8 @@ impl NativeBackend {
});
}
fn start_job(&self, app_id: &str, uninstall: bool) {
fn start_job(&self, app_id: &str, work: Work) {
let uninstall = work == Work::Uninstall;
let Some(app) = CraftApp::by_id(app_id) else { return };
log::info!("{}: starting {} job", app.name, if uninstall { "uninstall" } else { "install/update" });
let stop = Arc::new(Stop::default());
@@ -352,7 +354,18 @@ impl NativeBackend {
None => Err(format!("{} isn't installed", app.name)),
}
} else {
let r = run_install(app, &shared, &paths, http.as_ref(), platform, &options, existing.as_ref(), record.as_ref(), &stop);
let r = run_install(
app,
&shared,
&paths,
http.as_ref(),
platform,
&options,
existing.as_ref(),
record.as_ref(),
&stop,
work == Work::Replace,
);
// 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() => {
@@ -443,6 +456,13 @@ impl NativeBackend {
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Work {
Install,
Replace,
Uninstall,
}
#[allow(clippy::too_many_arguments)]
fn run_install(
app: &CraftApp,
@@ -454,6 +474,7 @@ fn run_install(
existing: Option<&Installed>,
record: Option<&crate::manifest::Record>,
stop: &Stop,
replace: bool,
) -> Result<String, String> {
if let Some(msg) = existing.and_then(|e| install::managed_elsewhere(app, e, false)) {
return Err(msg);
@@ -467,8 +488,21 @@ fn run_install(
.get(app.id)
.cloned()
.ok_or("the latest release hasn't been found yet. Check for updates first.")?;
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, kind) = planned_asset(app, &release, platform, options, existing, record).ok_or_else(|| {
if platform.os == artcraft_launcher_core::Os::Linux
&& options.install_locations.folder(app.id).is_some()
&& existing.is_none()
&& record.is_none()
{
"this release has no AppImage for a custom install folder".to_string()
} else {
format!("{} {} has no download for {}", app.name, release.version_label(), platform.label())
}
})?;
if !replace && let Some(path) = install::occupied(app, &asset, kind, existing, record, &options.install_locations, paths)? {
shared.emit(Event::Occupied { app: app.id.to_string(), path: path.display().to_string() });
return Err(format!("{} already exists", path.display()));
}
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.
@@ -478,8 +512,16 @@ fn run_install(
last_emit = Instant::now();
shared.emit(Event::Progress { app: app.id.to_string(), phase: p });
};
let mut job = Job { http, paths, stop, phase: &mut phase };
let new_record = install::install(app, &release, &asset, kind, existing, record, &mut job)?;
let mut job = Job { http, paths, stop, phase: &mut phase, install_locations: &options.install_locations, replace };
let mut commit_record = |record: &crate::manifest::Record| {
let mut manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
let mut updated = manifest.clone();
updated.apps.insert(app.id.to_string(), record.clone());
updated.save(&paths.manifest())?;
*manifest = updated;
Ok(())
};
let new_record = install::install_at(app, &release, &asset, kind, existing, record, &mut job, Some(&mut commit_record))?;
{
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
let changed = match new_record {
@@ -577,7 +619,18 @@ fn planned_asset(
record: Option<&crate::manifest::Record>,
) -> Option<(Asset, PackageKind)> {
let stems: Vec<&str> = app.stems().collect();
let kinds = install::choose_kinds(existing, record, &options.kinds);
let custom_linux = platform.os == artcraft_launcher_core::Os::Linux
&& !process::in_sandbox()
&& existing.is_none()
&& record.is_none()
&& options.install_locations.folder(app.id).is_some();
let kinds = if existing.is_none() && record.is_some_and(|r| r.kind == PackageKind::PortableZip) {
vec![PackageKind::PortableZip]
} else if custom_linux {
vec![artcraft_launcher_core::PackageKind::AppImage]
} else {
install::choose_kinds(existing, record, &options.kinds)
};
pick_asset(release, &stems, platform, &kinds).map(|(a, k)| (a.clone(), k))
}
@@ -896,8 +949,9 @@ impl Backend for NativeBackend {
match command {
Command::CheckForUpdates { force } => self.check(force),
Command::Detect => self.detect_all(),
Command::Install { app } => self.start_job(&app, false),
Command::Uninstall { app } => self.start_job(&app, true),
Command::Install { app } => self.start_job(&app, Work::Install),
Command::Replace { app } => self.start_job(&app, Work::Replace),
Command::Uninstall { app } => self.start_job(&app, Work::Uninstall),
Command::Pause { app } => {
if let Some(stop) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app) {
stop.pause();
@@ -1217,6 +1271,66 @@ mod tests {
Release { assets, ..Release::default() }
}
#[cfg(windows)]
#[test]
fn windows_preference_selects_portable_and_existing_installs_keep_their_format() {
let app = CraftApp::by_id(APP).unwrap();
let platform = Platform { os: Os::Windows, arch: Arch::X64 };
let release = release("0.5.0");
let portable_options = BackendOptions { kinds: vec![PackageKind::PortableZip], ..Default::default() };
assert_eq!(planned_asset(app, &release, platform, &portable_options, None, None).unwrap().1, PackageKind::PortableZip);
let msi = Installed {
version: None,
source: artcraft_launcher_core::InstallSource::Msi,
location: r"C:\Apps\PhotoCraft\photocraft.exe".into(),
package: None,
};
assert_eq!(planned_asset(app, &release, platform, &portable_options, Some(&msi), None).unwrap().1, PackageKind::Msi);
let launcher = Installed {
version: None,
source: artcraft_launcher_core::InstallSource::Launcher,
location: r"D:\Craft Apps\PhotoCraft\photocraft.exe".into(),
package: None,
};
let record = crate::manifest::Record {
version: "0.4.0".into(),
kind: PackageKind::PortableZip,
path: launcher.location.clone().into(),
installed_at: 1,
files: vec![],
needs_root: false,
};
let msi_options = BackendOptions { kinds: vec![PackageKind::Msi], ..Default::default() };
assert_eq!(planned_asset(app, &release, platform, &msi_options, Some(&launcher), Some(&record)).unwrap().1, PackageKind::PortableZip);
assert_eq!(planned_asset(app, &release, platform, &msi_options, None, Some(&record)).unwrap().1, PackageKind::PortableZip);
}
#[cfg(target_os = "linux")]
#[test]
fn a_custom_linux_folder_requires_appimage_without_package_fallback() {
if process::in_sandbox() {
return;
}
let app = CraftApp::by_id(APP).unwrap();
let platform = Platform { os: Os::Linux, arch: Arch::X64 };
let options = BackendOptions {
kinds: vec![PackageKind::Deb],
install_locations: artcraft_launcher_core::protocol::InstallLocations { root: Some("/custom/apps".into()), ..Default::default() },
..Default::default()
};
let mut release = release("0.5.0");
assert_eq!(planned_asset(app, &release, platform, &options, None, None).unwrap().1, PackageKind::AppImage);
release.assets.retain(|a| !a.name.ends_with(".AppImage"));
assert!(planned_asset(app, &release, platform, &options, None, None).is_none());
let existing =
Installed { version: None, source: artcraft_launcher_core::InstallSource::Deb, location: "/usr/bin/photocraft".into(), package: None };
let system = BackendOptions { kinds: options.kinds.clone(), ..Default::default() };
assert_eq!(
planned_asset(app, &release, platform, &options, Some(&existing), None),
planned_asset(app, &release, platform, &system, Some(&existing), None)
);
}
/// A backend that knows `release("0.5.0")` and keeps its files under `root`.
fn backend(root: &std::path::Path, platform: Platform, source: Option<Arc<dyn ReleaseSource>>) -> NativeBackend {
let shared = shared();
+25 -8
View File
@@ -59,8 +59,8 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option<Installed> {
}
// On macOS the bundle itself is the truth (the app may have updated itself).
#[cfg(target_os = "macos")]
if let Some(found) = macos::read_bundle(app, &rec.path) {
return Some(found);
if rec.kind == artcraft_launcher_core::PackageKind::Dmg {
return macos::read_bundle(app, &rec.path);
}
Some(Installed {
version: version::parse(&rec.version),
@@ -184,6 +184,19 @@ pub mod windows {
const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
/// Reads VERSIONINFO without executing the downloaded application. The path is data in
/// a child-only environment variable, never interpolated into PowerShell source.
pub fn executable_version(path: &std::path::Path) -> Option<artcraft_launcher_core::version::Version> {
let path = std::fs::canonicalize(path).ok()?;
let mut command = crate::process::command("powershell.exe");
command.args([
"-NoLogo", "-NoProfile", "-NonInteractive", "-Command",
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [System.Diagnostics.FileVersionInfo]::GetVersionInfo($env:ARTCRAFT_VERSION_FILE).ProductVersion",
]).env("ARTCRAFT_VERSION_FILE", path);
let output = crate::process::run_command(command, "powershell.exe", std::time::Duration::from_secs(5)).ok()?;
version::parse(output.trim())
}
/// An MSI install found in the registry.
pub struct MsiInstall {
pub product_code: String,
@@ -205,7 +218,7 @@ pub mod windows {
}
let version = key.get_value::<String, _>("DisplayVersion").ok().and_then(|v| version::parse(&v));
let location: String = key.get_value("InstallLocation").unwrap_or_default();
let exe = exe_path(app, (!location.is_empty()).then(|| PathBuf::from(location)));
let exe = exe_path(app, (!location.is_empty()).then(|| PathBuf::from(location)).or_else(|| install_dir(app)));
out.push(MsiInstall { product_code: code, version, exe });
}
}
@@ -218,6 +231,15 @@ pub mod windows {
out
}
/// The folder the app's MSI remembers for its next upgrade, which is where it went when the
/// launcher chose the folder.
fn install_dir(app: &CraftApp) -> Option<PathBuf> {
[KEY_WOW64_64KEY, KEY_WOW64_32KEY].into_iter().find_map(|flags| {
let key = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(format!(r"SOFTWARE\{}", app.name), KEY_READ | flags).ok()?;
key.get_value::<String, _>("InstallDir").ok().filter(|d| !d.is_empty()).map(PathBuf::from)
})
}
/// The app's exe: from `App Paths`, the install folder, or Program Files.
fn exe_path(app: &CraftApp, location: Option<PathBuf>) -> Option<PathBuf> {
for stem in app.stems() {
@@ -367,11 +389,6 @@ pub mod linux {
appimages_in(app, &appimage_dirs())
}
/// AppImages of `app` in the launcher's own folder only (the ones an update replaces).
pub fn launcher_appimages(app: &CraftApp) -> Vec<PathBuf> {
appimage_dir().map(|d| appimages_in(app, &[d]).into_iter().map(|(p, _)| p).collect()).unwrap_or_default()
}
pub fn detect(app: &CraftApp, snapshot: &Snapshot) -> Option<Installed> {
if let Some((path, version)) = appimages(app).into_iter().next() {
return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None });
+459 -60
View File
@@ -21,7 +21,7 @@
use std::path::{Path, PathBuf};
use artcraft_launcher_core::protocol::{CANCELLED, INSTALL_CANCELLED, JobPhase};
use artcraft_launcher_core::protocol::{CANCELLED, INSTALL_CANCELLED, InstallLocations, JobPhase};
use artcraft_launcher_core::release::{self, Asset, Release};
use artcraft_launcher_core::{CraftApp, InstallSource, Installed, PackageKind};
@@ -36,10 +36,13 @@ pub struct Job<'a> {
pub paths: &'a Paths,
pub stop: &'a Stop,
pub phase: &'a mut dyn FnMut(JobPhase),
pub install_locations: &'a InstallLocations,
/// Write over whatever [`occupied`] found.
pub replace: bool,
}
/// Downloads and installs `asset` of `release`. Returns a record when the launcher owns the
/// result (portable zip, AppImage, tarball); MSI and macOS installs are found by detection.
/// result (portable ZIP, AppImage, macOS bundle, tarball); MSI installs are found in the registry.
/// `previous` is the launcher's record of the copy being replaced, if it installed it.
pub fn install(
app: &CraftApp,
@@ -50,7 +53,28 @@ pub fn install(
previous: Option<&Record>,
job: &mut Job<'_>,
) -> Result<Option<Record>, String> {
let _ = previous;
install_at(app, release, asset, kind, existing, previous, job, None)
}
type RecordCommit<'a> = Option<&'a mut dyn FnMut(&Record) -> Result<(), String>>;
#[allow(clippy::too_many_arguments)]
pub fn install_at(
app: &CraftApp,
release: &Release,
asset: &Asset,
kind: PackageKind,
existing: Option<&Installed>,
previous: Option<&Record>,
job: &mut Job<'_>,
commit: RecordCommit<'_>,
) -> Result<Option<Record>, String> {
#[cfg(windows)]
let mut commit = commit;
#[cfg(not(windows))]
let _ = commit;
#[allow(unused_variables)]
let destination = destination(app, kind, existing, previous, job.install_locations)?;
if let Some(inst) = existing
&& inst.source.launcher_can_update()
&& process::is_running(Path::new(&inst.location))
@@ -66,16 +90,34 @@ pub fn install(
let version = release.version_label();
let result = match kind {
#[cfg(target_os = "macos")]
PackageKind::Dmg => macos::install_dmg(app, &file, existing, job.paths, &macos::Expect::release(release)).map(|_| None),
PackageKind::Dmg => {
macos::install_dmg(app, &file, existing, destination.as_deref(), job.replace, job.paths, &macos::Expect::release(release))
.map(|path| Some(record(&version, kind, path, Vec::new(), false)))
}
#[cfg(windows)]
PackageKind::Msi => windows::run_msi(&file, job.stop, &mut *job.phase).map(|_| None),
PackageKind::Msi => windows::run_msi(&file, destination.as_deref(), job.stop, &mut *job.phase).map(|_| None),
#[cfg(windows)]
PackageKind::PortableZip => {
crate::portable::install(app, &file, &version, previous, job.paths, destination.as_deref(), job.replace, &mut |record| {
if release.version.is_some() && crate::detect::windows::executable_version(&record.path).as_ref() != release.version.as_ref() {
return Err("The portable executable does not match the selected version. The installation was rolled back.".into());
}
match commit.as_deref_mut() {
Some(commit) => commit(record),
None => Ok(()),
}
})
.map(Some)
}
#[cfg(target_os = "linux")]
PackageKind::Deb | PackageKind::Rpm | PackageKind::Flatpak => linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| None),
#[cfg(target_os = "linux")]
PackageKind::AppImage => linux::install_appimage(app, &file, &asset.name, existing).map(|(path, replaced)| {
let files = linux::integrate(app, &path, &replaced, previous);
Some(record(&version, kind, path, files, false))
}),
PackageKind::AppImage => {
linux::install_appimage(&file, &asset.name, destination.as_deref(), previous, job.replace).map(|(path, replaced)| {
let files = linux::integrate(app, &path, &replaced, previous);
Some(record(&version, kind, path, files, false))
})
}
#[cfg(target_os = "freebsd")]
PackageKind::TarGz => freebsd::install_tree(app, &file, job.paths).map(|(bin, files, root)| Some(record(&version, kind, bin, files, root))),
#[cfg(target_os = "linux")]
@@ -96,6 +138,100 @@ pub fn install(
result.map_err(|e| if e == CANCELLED { INSTALL_CANCELLED.to_string() } else { e })
}
/// What a new install would write over: something already at its destination that the launcher
/// didn't put there.
pub fn occupied(
app: &CraftApp,
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] asset: &Asset,
kind: PackageKind,
existing: Option<&Installed>,
previous: Option<&Record>,
locations: &InstallLocations,
paths: &Paths,
) -> Result<Option<PathBuf>, String> {
if existing.is_some() || previous.is_some() {
return Ok(None);
}
let folder = destination(app, kind, None, None, locations)?;
let target = match kind {
PackageKind::Msi => folder,
PackageKind::PortableZip => Some(folder.unwrap_or_else(|| paths.apps()).join(app.id)),
PackageKind::Dmg => folder.map(|f| f.join(format!("{}.app", app.name))),
#[cfg(target_os = "linux")]
PackageKind::AppImage => folder.or_else(crate::detect::linux::appimage_dir).map(|f| f.join(&asset.name)),
_ => None,
};
Ok(target.filter(|t| std::fs::symlink_metadata(t).is_ok()))
}
/// The folder a new copy goes in: beside the launcher's copy it replaces, else the user's choice,
/// else the platform default (`None`). macOS updates find their bundle's folder themselves.
fn destination(
app: &CraftApp,
kind: PackageKind,
existing: Option<&Installed>,
previous: Option<&Record>,
locations: &InstallLocations,
) -> Result<Option<PathBuf>, String> {
if kind == PackageKind::PortableZip && previous.is_some_and(|record| record.kind == PackageKind::PortableZip) {
return Ok(None);
}
if let Some(inst) = existing {
let exe = Path::new(&inst.location);
return Ok(match kind {
PackageKind::AppImage => previous.filter(|r| r.kind == kind).and_then(|r| r.path.parent()).map(Path::to_path_buf),
PackageKind::Msi => exe.parent().filter(|_| exe.is_absolute()).map(Path::to_path_buf),
_ => None,
});
}
let Some(folder) = locations.folder(app.id) else { return Ok(None) };
if !matches!(kind, PackageKind::Dmg | PackageKind::AppImage | PackageKind::Msi | PackageKind::PortableZip) || process::in_sandbox() {
return Err("custom install folders aren't supported on this system".into());
}
let parent = selected_folder(folder, kind != PackageKind::Msi)?;
Ok(Some(if kind == PackageKind::Msi { parent.join(app.name) } else { parent }))
}
fn selected_folder(folder: &str, writable: bool) -> Result<PathBuf, String> {
let path = if folder == "~" {
dirs::home_dir().ok_or("no home folder")?
} else if let Some(relative) = folder.strip_prefix("~/") {
dirs::home_dir().ok_or("no home folder")?.join(relative)
} else {
PathBuf::from(folder)
};
let invalid = |reason: &str| format!("couldn't use install folder {}: {reason}", path.display());
if folder.chars().any(|c| c.is_control() || (cfg!(windows) && c == '"')) {
return Err(invalid("the path contains invalid characters"));
}
if !path.is_absolute() {
return Err(invalid("choose an absolute path"));
}
if !path.is_dir() {
return Err(invalid("the folder is unavailable. Create it or reconnect its drive first"));
}
if writable {
let probe = path.join(format!(
".artcraft-launcher-write-test-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos())
));
let output = std::fs::OpenOptions::new().write(true).create_new(true).open(&probe).map_err(|e| invalid(&e.to_string()))?;
drop(output);
std::fs::remove_file(&probe).map_err(|e| invalid(&e.to_string()))?;
}
Ok(path)
}
#[cfg(any(windows, test))]
fn msi_folder_argument(path: &Path) -> Result<String, String> {
let value = path.to_str().ok_or("unusual characters in a path")?;
if value.chars().any(|c| c == '"' || c.is_control()) {
return Err(format!("couldn't use install folder {value}: the path contains invalid characters"));
}
Ok(format!("INSTALLFOLDER=\"{value}\""))
}
/// 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)
@@ -142,7 +278,7 @@ pub fn available_kinds() -> Vec<PackageKind> {
#[cfg(target_os = "macos")]
return vec![PackageKind::Dmg];
#[cfg(windows)]
return vec![PackageKind::Msi];
return vec![PackageKind::Msi, PackageKind::PortableZip];
#[cfg(target_os = "linux")]
return linux::available_kinds();
#[cfg(target_os = "freebsd")]
@@ -230,6 +366,10 @@ pub fn uninstall(
return Err(format!("{} is running. Quit it, then try again.", app.name));
}
match installed.source {
#[cfg(windows)]
InstallSource::Launcher if record.is_some_and(|r| r.kind == PackageKind::PortableZip) => {
crate::portable::remove(app, record.ok_or("the launcher has no record of these files")?)
}
#[cfg(windows)]
InstallSource::Msi => windows::uninstall_msi(app, stop, phase),
#[cfg(target_os = "macos")]
@@ -351,6 +491,16 @@ fn prepare_home(home: &Path) {
}
}
/// A new folder of our own inside `dir`, removed when dropped: a predictable name there may be
/// the user's.
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn staging_dir(dir: &Path) -> Result<tempfile::TempDir, String> {
tempfile::Builder::new()
.prefix(".artcraft-launcher-")
.tempdir_in(dir)
.map_err(|e| format!("couldn't create a staging folder in {} ({e})", dir.display()))
}
/// Moves `staged` into place at `dest`, keeping the old copy until the new one is there.
#[allow(dead_code)]
fn swap_into_place(staged: &Path, dest: &Path) -> Result<(), String> {
@@ -660,31 +810,36 @@ pub(crate) mod linux {
}
}
/// Copies an AppImage into `~/Applications` and makes it executable; removes the copy it
/// replaces when that one is the launcher's (in `~/Applications`). AppImages the user keeps
/// elsewhere (Gear Lever's `~/AppImages`, `~/.local/bin`) are left alone. Returns the new
/// path and the old copies removed.
pub fn install_appimage(app: &CraftApp, file: &Path, name: &str, existing: Option<&Installed>) -> Result<(PathBuf, Vec<PathBuf>), String> {
let dir = crate::detect::linux::appimage_dir().ok_or("no home folder")?;
std::fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
pub fn install_appimage(
file: &Path,
name: &str,
destination: Option<&Path>,
previous: Option<&Record>,
replace: bool,
) -> Result<(PathBuf, Vec<PathBuf>), String> {
let dir = match destination {
Some(dir) => dir.to_path_buf(),
None => {
let dir = crate::detect::linux::appimage_dir().ok_or("no home folder")?;
std::fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
dir
}
};
let dest = dir.join(name);
let partial = dir.join(format!(".{name}.partial"));
let ours = previous.is_some_and(|r| r.kind == PackageKind::AppImage && r.path == dest);
if std::fs::symlink_metadata(&dest).is_ok_and(|m| m.is_dir() || !(replace || ours && m.is_file())) {
return Err(format!("{} already exists and wasn't installed by ArtCraft Launcher", dest.display()));
}
let staging = super::staging_dir(&dir)?;
let partial = staging.path().join(name);
std::fs::copy(file, &partial).map_err(|e| format!("couldn't copy the AppImage ({e})"))?;
std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
std::fs::rename(&partial, &dest).map_err(|e| format!("couldn't put the AppImage in place ({e})"))?;
// Older AppImages of this app in the launcher's folder (the one we replaced, or earlier
// downloads) go, and the copy the launcher's record pointed at.
let mut old = crate::detect::linux::launcher_appimages(app);
if let Some(inst) = existing.filter(|i| i.source == InstallSource::Launcher) {
old.push(PathBuf::from(&inst.location));
}
old.sort();
old.dedup();
let mut removed = Vec::new();
for p in old.into_iter().filter(|p| *p != dest && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("AppImage"))) {
if std::fs::remove_file(&p).is_ok() {
removed.push(p);
}
if let Some(old) = previous.filter(|r| r.kind == PackageKind::AppImage && r.path != dest)
&& std::fs::remove_file(&old.path).is_ok()
{
removed.push(old.path.clone());
}
Ok((dest, removed))
}
@@ -862,12 +1017,15 @@ pub mod macos {
use super::*;
/// `/Applications` when we can write there, else `~/Applications`.
fn applications_dir(existing: Option<&Installed>) -> Result<PathBuf, String> {
fn applications_dir(existing: Option<&Installed>, destination: Option<&Path>) -> Result<PathBuf, String> {
if let Some(parent) =
existing.filter(|i| i.source == InstallSource::MacApp).and_then(|i| Path::new(&i.location).parent().map(Path::to_path_buf))
{
return Ok(parent);
}
if let Some(dir) = destination {
return Ok(dir.to_path_buf());
}
let system = PathBuf::from("/Applications");
let probe = system.join(".artcraft-launcher-write-test");
if std::fs::write(&probe, b"").is_ok() {
@@ -909,7 +1067,15 @@ pub mod macos {
const DITTO: &str = "/usr/bin/ditto";
const CODESIGN: &str = "/usr/bin/codesign";
pub fn install_dmg(app: &CraftApp, dmg: &Path, existing: Option<&Installed>, paths: &Paths, expect: &Expect<'_>) -> Result<PathBuf, String> {
pub fn install_dmg(
app: &CraftApp,
dmg: &Path,
existing: Option<&Installed>,
destination: Option<&Path>,
replace: bool,
paths: &Paths,
expect: &Expect<'_>,
) -> Result<PathBuf, String> {
// A new, empty mount point for every install. A fixed one can still hold an image from an
// attach that was interrupted or timed out, and hdiutil won't mount another over it.
let attempt = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
@@ -948,20 +1114,33 @@ pub mod macos {
let _ = std::fs::remove_dir(&mount);
return Err(format!("couldn't open the disk image: {reason}"));
}
let result = copy_app(app, &mount, existing, expect);
let result = copy_app(app, &mount, existing, destination, replace, expect);
let _ = detach(&mount);
let _ = std::fs::remove_dir(&mount);
result
}
fn copy_app(app: &CraftApp, mount: &Path, existing: Option<&Installed>, expect: &Expect<'_>) -> Result<PathBuf, String> {
fn copy_app(
app: &CraftApp,
mount: &Path,
existing: Option<&Installed>,
destination: Option<&Path>,
replace: bool,
expect: &Expect<'_>,
) -> Result<PathBuf, String> {
let bundle = format!("{}.app", app.name);
let src = mount.join(&bundle);
crate::detect::macos::read_bundle(app, &src).ok_or("the disk image doesn't contain the app")?;
let dir = applications_dir(existing)?;
let dest = dir.join(&bundle);
let staged = dir.join(format!(".{bundle}.partial"));
let _ = std::fs::remove_dir_all(&staged);
let dir = applications_dir(existing, destination)?;
let dest = existing.filter(|i| i.source == InstallSource::MacApp).map_or_else(|| dir.join(&bundle), |i| PathBuf::from(&i.location));
if !replace
&& std::fs::symlink_metadata(&dest).is_ok()
&& (std::fs::symlink_metadata(&dest).is_ok_and(|m| m.file_type().is_symlink()) || crate::detect::macos::read_bundle(app, &dest).is_none())
{
return Err(format!("{} already exists and wasn't installed by ArtCraft Launcher", dest.display()));
}
let staging = super::staging_dir(&dir)?;
let staged = staging.path().join(&bundle);
let (Some(src_s), Some(staged_s)) = (src.to_str(), staged.to_str()) else { return Err("unusual characters in a path".into()) };
// ditto keeps the code signature and symlinks intact. The disk image's volume adds Finder
// metadata (com.apple.FinderInfo) to the files; leave it and the extended attributes
@@ -972,10 +1151,7 @@ pub mod macos {
let checked = crate::detect::macos::read_bundle(app, &staged)
.ok_or_else(|| "the copied app is incomplete".to_string())
.and_then(|found| check(&staged, &found, expect));
if let Err(e) = checked {
let _ = std::fs::remove_dir_all(&staged);
return Err(e);
}
checked?;
swap_into_place(&staged, &dest)?;
Ok(dest)
}
@@ -1030,10 +1206,7 @@ pub mod macos {
if dest.exists() {
dest = trash.join(format!("{} {}", name.to_string_lossy(), crate::now()));
}
if std::fs::rename(path, &dest).is_ok() {
return Ok(());
}
std::fs::remove_dir_all(path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))
std::fs::rename(path, &dest).map_err(|e| format!("couldn't move {} to Trash ({e}). Remove it in Finder instead.", path.display()))
}
}
@@ -1190,14 +1363,15 @@ pub mod windows {
}
/// Runs an MSI with a progress window and no restart. Windows asks for permission itself.
pub fn run_msi(msi: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
pub fn run_msi(msi: &Path, destination: Option<&Path>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
in_turn(stop, phase, JobPhase::Installing, || {
let status = process::command(msiexec())
.arg("/i")
.arg(msi)
.args(["/passive", "/norestart"])
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
use std::os::windows::process::CommandExt;
let mut command = process::command(msiexec());
command.arg("/i").arg(msi).args(["/passive", "/norestart"]);
if let Some(dir) = destination {
command.raw_arg(msi_folder_argument(dir)?);
}
let status = command.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code())
})
}
@@ -1230,6 +1404,142 @@ pub mod windows {
mod tests {
use super::*;
#[test]
fn selected_folders_must_exist_and_leave_no_probe_files() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(selected_folder(dir.path().to_str().unwrap(), true).unwrap(), dir.path());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
#[cfg(unix)]
{
let quoted = dir.path().join("Craft \"Apps\"");
std::fs::create_dir(&quoted).unwrap();
assert_eq!(selected_folder(quoted.to_str().unwrap(), true).unwrap(), quoted);
}
let missing = dir.path().join("unavailable/Apps");
assert!(selected_folder(missing.to_str().unwrap(), true).is_err());
assert!(!missing.exists());
assert!(selected_folder("relative/folder", true).is_err());
assert!(selected_folder("bad\nfolder", true).is_err());
let app = CraftApp::by_id("photocraft").unwrap();
let locations = InstallLocations { root: Some(dir.path().display().to_string()), ..Default::default() };
assert_eq!(destination(app, PackageKind::Msi, None, None, &locations).unwrap(), Some(dir.path().join("PhotoCraft")));
assert_eq!(destination(app, PackageKind::PortableZip, None, None, &locations).unwrap(), Some(dir.path().to_path_buf()));
let old = record("0.5.0", PackageKind::PortableZip, dir.path().join("PhotoCraft/photocraft.exe"), vec![], false);
let unavailable = InstallLocations { root: Some(dir.path().join("missing").display().to_string()), ..Default::default() };
assert_eq!(destination(app, PackageKind::PortableZip, None, Some(&old), &unavailable).unwrap(), None);
let msi = Installed {
version: None,
source: InstallSource::Msi,
location: dir.path().join("MSI/PhotoCraft/photocraft.exe").display().to_string(),
package: None,
};
assert_eq!(destination(app, PackageKind::Msi, Some(&msi), Some(&old), &unavailable).unwrap(), Some(dir.path().join("MSI/PhotoCraft")));
assert!(destination(app, PackageKind::TarGz, None, None, &locations).unwrap_err().contains("aren't supported"));
}
#[test]
fn updates_stay_beside_the_launchers_copy_and_new_installs_use_the_chosen_folder() {
let dir = tempfile::tempdir().unwrap();
let app = CraftApp::by_id("photocraft").unwrap();
let path = dir.path().join("old.AppImage");
let rec = record("0.5.0", PackageKind::AppImage, path.clone(), vec![], false);
let launcher = Installed { version: None, source: InstallSource::Launcher, location: path.display().to_string(), package: None };
let users = Installed { version: None, source: InstallSource::AppImage, location: "/home/me/AppImages/p.AppImage".into(), package: None };
let chosen = dir.path().join("New apps");
std::fs::create_dir(&chosen).unwrap();
let mut locations = InstallLocations { root: Some(chosen.display().to_string()), ..Default::default() };
assert_eq!(destination(app, PackageKind::AppImage, Some(&launcher), Some(&rec), &locations).unwrap(), Some(dir.path().into()));
assert_eq!(destination(app, PackageKind::AppImage, Some(&users), None, &locations).unwrap(), None);
if !process::in_sandbox() {
assert_eq!(destination(app, PackageKind::AppImage, None, None, &locations).unwrap(), Some(chosen.clone()));
locations.per_app.insert(app.id.into(), dir.path().display().to_string());
assert_eq!(destination(app, PackageKind::Dmg, None, None, &locations).unwrap(), Some(dir.path().into()));
}
}
#[test]
fn msi_folder_assignment_preserves_spaces_unicode_and_root_separators() {
for (value, argument) in [
(r"D:\", r#"INSTALLFOLDER="D:\""#),
(r"D:\Craft Apps\PhotoCraft", r#"INSTALLFOLDER="D:\Craft Apps\PhotoCraft""#),
(r"\\server\apps\绘画", r#"INSTALLFOLDER="\\server\apps\绘画""#),
(r"D:\Craft Apps\", r#"INSTALLFOLDER="D:\Craft Apps\""#),
] {
assert_eq!(msi_folder_argument(Path::new(value)).unwrap(), argument);
}
assert!(msi_folder_argument(Path::new("D:\\bad\" TRANSFORMS=evil")).is_err());
assert!(msi_folder_argument(Path::new("D:\\bad\nfolder")).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn appimage_updates_replace_only_the_recorded_file_in_the_same_folder() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("Craft Apps");
std::fs::create_dir(&folder).unwrap();
let download = dir.path().join("download");
std::fs::write(&download, "first").unwrap();
let unrelated = folder.join("photocraft-unrelated.AppImage");
std::fs::write(&unrelated, "user copy").unwrap();
let (old, removed) = linux::install_appimage(&download, "photocraft-0.5.0.AppImage", Some(&folder), None, false).unwrap();
assert!(removed.is_empty());
assert_eq!(std::fs::read(&old).unwrap(), b"first");
let rec = record("0.5.0", PackageKind::AppImage, old.clone(), vec![], false);
std::fs::write(&download, "updated").unwrap();
let (new, removed) = linux::install_appimage(&download, "photocraft-0.6.0.AppImage", Some(&folder), Some(&rec), false).unwrap();
assert_eq!(new, folder.join("photocraft-0.6.0.AppImage"));
assert_eq!(std::fs::read(&new).unwrap(), b"updated");
assert_eq!(removed.as_slice(), std::slice::from_ref(&old));
assert!(!old.exists());
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy");
assert!(linux::install_appimage(&download, "photocraft-unrelated.AppImage", Some(&folder), Some(&rec), false).is_err());
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy");
let new_rec = record("0.6.0", PackageKind::AppImage, new.clone(), vec![], false);
assert!(linux::install_appimage(&download, "photocraft-0.6.0.AppImage", Some(&folder), Some(&new_rec), false).is_ok());
assert_eq!(std::fs::read_dir(&folder).unwrap().count(), 2);
let link = folder.join("linked.AppImage");
std::os::unix::fs::symlink(&unrelated, &link).unwrap();
let link_rec = record("0.6.0", PackageKind::AppImage, link, vec![], false);
assert!(linux::install_appimage(&download, "linked.AppImage", Some(&folder), Some(&link_rec), false).is_err());
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy");
}
#[cfg(target_os = "linux")]
#[test]
fn an_appimage_writes_over_a_users_file_only_when_asked_to() {
let dir = tempfile::tempdir().unwrap();
let download = dir.path().join("download");
std::fs::write(&download, "new").unwrap();
let folder = dir.path().join("Apps");
std::fs::create_dir(&folder).unwrap();
let theirs = folder.join("photocraft.AppImage");
let personal = dir.path().join("personal.txt");
std::fs::write(&theirs, "user copy").unwrap();
std::fs::write(&personal, "user file").unwrap();
std::os::unix::fs::symlink(&personal, folder.join(".photocraft.AppImage.partial")).unwrap();
assert!(linux::install_appimage(&download, "photocraft.AppImage", Some(&folder), None, false).is_err());
assert_eq!(std::fs::read(&theirs).unwrap(), b"user copy");
linux::install_appimage(&download, "photocraft.AppImage", Some(&folder), None, true).unwrap();
assert_eq!(std::fs::read(&theirs).unwrap(), b"new");
assert_eq!(std::fs::read(&personal).unwrap(), b"user file");
assert_eq!(std::fs::read_dir(&folder).unwrap().count(), 2, "staging was left behind");
}
#[test]
fn a_new_install_reports_what_it_would_write_over() {
let dir = tempfile::tempdir().unwrap();
let app = CraftApp::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let locations = InstallLocations { root: Some(dir.path().display().to_string()), ..Default::default() };
let asset = Asset { name: "photocraft.zip".into(), ..Default::default() };
let occupied = |previous| occupied(app, &asset, PackageKind::PortableZip, None, previous, &locations, &paths).unwrap();
assert_eq!(occupied(None), None);
std::fs::create_dir(dir.path().join("photocraft")).unwrap();
assert_eq!(occupied(None), Some(dir.path().join("photocraft")));
let old = record("0.5.0", PackageKind::PortableZip, dir.path().join("photocraft/photocraft.exe"), vec![], false);
assert_eq!(occupied(Some(&old)), None);
}
#[test]
fn swap_keeps_the_old_copy_until_the_new_one_is_in_place() {
let dir = tempfile::tempdir().unwrap();
@@ -1340,7 +1650,14 @@ mod tests {
let mut phases = Vec::new();
for _ in 0..2 {
let mut phase = |p: JobPhase| phases.push(p);
let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase };
let mut job = Job {
http: &http,
paths: &paths,
stop: &Stop::default(),
phase: &mut phase,
install_locations: &InstallLocations::default(),
replace: false,
};
let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
assert!(err.contains("isn't supported"), "{err}");
assert_eq!(std::fs::read(download_path(&paths, &asset)).unwrap(), body);
@@ -1349,6 +1666,29 @@ mod tests {
assert_eq!(phases.iter().filter(|p| **p == JobPhase::Installing).count(), 2);
}
#[test]
fn an_unavailable_custom_folder_is_rejected_before_downloading() {
use crate::http::tests::{Reply, body, serve};
let app = CraftApp::by_id("wordcraft").unwrap();
let (body, sha) = body();
let (url, requests) = serve(body.clone(), vec![Reply::Full]);
let asset = Asset { name: "wordcraft-0.3.0-linux-x86_64.AppImage".into(), size: body.len() as u64, url, sha256: Some(sha) };
let release = Release { assets: vec![asset.clone()], ..Release::default() };
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("unavailable/Apps");
let locations = InstallLocations { root: Some(missing.display().to_string()), ..Default::default() };
let paths = Paths::under(dir.path());
let http = Http::new().unwrap();
let mut phases = vec![];
let mut phase = |p| phases.push(p);
let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase, install_locations: &locations, replace: false };
let error = install(app, &release, &asset, PackageKind::AppImage, None, None, &mut job).unwrap_err();
assert!(error.contains("folder is unavailable") || (process::in_sandbox() && error.contains("aren't supported")), "{error}");
assert!(requests.lock().unwrap().is_empty());
assert!(phases.is_empty());
assert!(!missing.exists());
}
#[test]
fn a_release_without_a_checksum_is_refused_before_anything_is_fetched() {
use crate::http::tests::{Reply, body, serve};
@@ -1364,7 +1704,14 @@ mod tests {
let kind = if cfg!(windows) { PackageKind::Dmg } else { PackageKind::Msi };
let mut phases = Vec::new();
let mut phase = |p: JobPhase| phases.push(p);
let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase };
let mut job = Job {
http: &http,
paths: &paths,
stop: &Stop::default(),
phase: &mut phase,
install_locations: &InstallLocations::default(),
replace: false,
};
let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
assert!(err.contains("publishes no checksum"), "{err}");
assert!(!download_path(&paths, &asset).exists());
@@ -1748,6 +2095,52 @@ mod tests {
dmg
}
#[test]
fn custom_bundles_are_detected_and_updates_stay_in_their_actual_location() {
let dir = tempfile::tempdir().unwrap();
let apps = dir.path().join("Craft Apps");
std::fs::create_dir(&apps).unwrap();
let paths = Paths::under(dir.path());
let image = dmg(dir.path(), "0.6.0");
let expect = Expect { team: None, version: None };
let placed = install_dmg(photocraft(), &image, None, Some(&apps), false, &paths, &expect).unwrap();
assert_eq!(placed, apps.join("PhotoCraft.app"));
let mut manifest = crate::manifest::Manifest::default();
manifest.apps.insert("photocraft".into(), record("0.6.0", PackageKind::Dmg, placed.clone(), vec![], false));
let existing = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap();
assert_eq!(existing.source, InstallSource::MacApp);
assert_eq!(existing.location, placed.display().to_string());
assert_eq!(existing.version.unwrap().to_string(), "0.6.0");
let existing = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap();
let new_default = dir.path().join("Other Apps");
std::fs::create_dir(&new_default).unwrap();
let update = dmg(dir.path(), "0.7.0");
assert_eq!(install_dmg(photocraft(), &update, Some(&existing), Some(&new_default), false, &paths, &expect).unwrap(), placed);
assert!(!new_default.join("PhotoCraft.app").exists());
let detected = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap();
assert_eq!(detected.version.unwrap().to_string(), "0.7.0");
std::fs::write(placed.join("Contents/Info.plist"), "invalid").unwrap();
assert_ne!(
crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).map(|i| i.source),
Some(InstallSource::Launcher)
);
}
#[test]
fn an_unrelated_bundle_at_the_selected_destination_is_preserved() {
let dir = tempfile::tempdir().unwrap();
let placed = bundle(dir.path(), "0.5.0");
let plist = placed.join("Contents/Info.plist");
let unrelated = std::fs::read_to_string(&plist).unwrap().replace("ai.storyteller.photocraft", "com.example.other");
std::fs::write(&plist, &unrelated).unwrap();
let image = dmg(dir.path(), "0.6.0");
let result =
install_dmg(photocraft(), &image, None, Some(dir.path()), false, &Paths::under(dir.path()), &Expect { team: None, version: None });
assert!(result.unwrap_err().contains("already exists"));
assert_eq!(std::fs::read_to_string(plist).unwrap(), unrelated);
assert_eq!(std::fs::read(placed.join("Contents/MacOS/PhotoCraft")).unwrap(), b"0.5.0");
}
#[test]
fn only_the_teams_signature_passes() {
let dir = tempfile::tempdir().unwrap();
@@ -1777,21 +2170,24 @@ mod tests {
std::fs::write(current.join("Contents/MacOS/PhotoCraft"), "0.5.0").unwrap();
let want = version::parse(release).unwrap();
let expect = Expect { team: None, version: Some(&want) };
let result = install_dmg(photocraft(), &image, Some(&existing), &paths, &expect);
let result = install_dmg(photocraft(), &image, Some(&existing), None, false, &paths, &expect);
assert_eq!(result.is_ok(), ok, "release {release}: {result:?}");
let now = std::fs::read_to_string(current.join("Contents/MacOS/PhotoCraft")).unwrap();
assert_eq!(now, if ok { "0.6.0" } else { "0.5.0" }, "release {release}");
if !ok {
assert_eq!(result, Err(format!("the app inside isn't version {release}, so it wasn't installed")));
}
assert!(!apps.join(".PhotoCraft.app.partial").exists());
assert_eq!(
std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>(),
[std::ffi::OsString::from("PhotoCraft.app")]
);
}
// A bundle that names a pre-release must name the release's.
let rc = dmg(dir.path(), "0.8.0-rc.2");
let want = version::parse("0.8.0-rc.1").unwrap();
let expect = Expect { team: None, version: Some(&want) };
assert!(install_dmg(photocraft(), &rc, Some(&existing), &paths, &expect).is_err());
assert!(install_dmg(photocraft(), &rc, Some(&existing), None, false, &paths, &expect).is_err());
}
#[test]
@@ -1803,10 +2199,13 @@ mod tests {
let image = dmg(dir.path(), "0.6.0");
let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() };
let result = install_dmg(photocraft(), &image, Some(&existing), &Paths::under(dir.path()), &Expect::release(&release));
let result = install_dmg(photocraft(), &image, Some(&existing), None, false, &Paths::under(dir.path()), &Expect::release(&release));
assert_eq!(result, Err("the app isn't signed by its developer, so it wasn't installed".into()));
assert_eq!(std::fs::read_to_string(current.join("Contents/MacOS/PhotoCraft")).unwrap(), "0.5.0");
assert!(!apps.join(".PhotoCraft.app.partial").exists());
assert_eq!(
std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>(),
[std::ffi::OsString::from("PhotoCraft.app")]
);
}
/// A real release passes. Run with `CRAFT_APP=/path/to/PhotoCraft.app cargo test -p
+2
View File
@@ -17,6 +17,8 @@ pub mod login;
pub mod manifest;
pub mod packages;
pub mod paths;
#[cfg(windows)]
pub mod portable;
pub mod process;
pub mod proxy;
pub mod queue;
+491
View File
@@ -0,0 +1,491 @@
//! Portable Windows packages: bounded extraction and replacement of recorded files only.
use std::path::{Component, Path, PathBuf};
use crate::manifest::Record;
use crate::paths::Paths;
use artcraft_launcher_core::{CraftApp, PackageKind};
const MAX_FILES: usize = 10_000;
const MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
/// Custom destinations may have OS-managed links in their host ancestors.
pub fn validate_directory(path: &Path) -> Result<(), String> {
if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir | Component::CurDir)) {
return Err("Choose an absolute installation folder without parent-directory components.".into());
}
// Resolve the closest existing ancestor: /home and /var can be OS-managed
// symlinks, and a custom destination need not exist yet.
for ancestor in path.ancestors() {
match std::fs::canonicalize(ancestor) {
Ok(resolved) => {
if !resolved.is_dir() {
return Err("The installation folder contains a non-directory component.".into());
}
return Ok(());
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
// A dangling link is not a missing directory we can safely create.
if std::fs::symlink_metadata(ancestor).is_ok() {
return Err("The installation folder contains a dangling link.".into());
}
}
Err(error) => return Err(error.to_string()),
}
}
Err("The installation folder has no existing ancestor.".into())
}
/// Links are allowed above the ownership boundary, never inside it (including root).
fn validate_owned_directory(root: &Path, path: &Path) -> Result<(), String> {
validate_directory(path)?;
if !path.starts_with(root) {
return Err("An installation path points outside its folder.".into());
}
for ancestor in path.ancestors().take_while(|ancestor| ancestor.starts_with(root)) {
match std::fs::symlink_metadata(ancestor) {
Ok(meta) if linked(&meta) || !meta.is_dir() => {
return Err("The installation folder contains a link or a non-directory component.".into());
}
Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.to_string()),
_ => {}
}
}
Ok(())
}
fn resolve_missing(path: &Path) -> Result<PathBuf, String> {
for ancestor in path.ancestors() {
match std::fs::canonicalize(ancestor) {
Ok(resolved) => {
let suffix = path.strip_prefix(ancestor).map_err(|e| e.to_string())?;
return Ok(resolved.join(suffix));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if std::fs::symlink_metadata(ancestor).is_ok() {
return Err("The installation folder contains a dangling link.".into());
}
}
Err(error) => return Err(error.to_string()),
}
}
Err("The installation folder has no existing ancestor.".into())
}
fn same_windows_path(left: &Path, right: &Path) -> bool {
fn normalized(path: &Path) -> String {
let text = path.to_string_lossy();
let text = text.strip_prefix(r"\\?\").unwrap_or(&text);
text.trim_end_matches(['\\', '/']).to_lowercase()
}
normalized(left) == normalized(right)
}
fn linked(meta: &std::fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
meta.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
meta.file_type().is_symlink()
}
}
struct Staging(PathBuf);
impl Drop for Staging {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn extract(file: &Path, destination: &Path) -> Result<Vec<PathBuf>, String> {
let file = std::fs::File::open(file).map_err(|e| e.to_string())?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("couldn't read the portable ZIP ({e})"))?;
if archive.len() > MAX_FILES {
return Err("The portable ZIP contains too many files.".into());
}
let mut files = Vec::new();
let mut total = 0u64;
let mut seen = std::collections::BTreeSet::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|e| e.to_string())?;
let relative = entry.enclosed_name().ok_or("The portable ZIP contains an unsafe path.")?;
if relative.components().any(|c| !matches!(c, Component::Normal(_)))
|| entry.name().contains(['\\', ':'])
|| entry.unix_mode().is_some_and(|mode| mode & 0o170000 == 0o120000)
{
return Err("The portable ZIP contains an unsafe path or link.".into());
}
// Windows treats case variants as the same file; reject duplicate and colliding names.
if !seen.insert(relative.to_string_lossy().to_lowercase()) {
return Err("The portable ZIP contains duplicate paths.".into());
}
let target = destination.join(&relative);
if entry.is_dir() {
std::fs::create_dir_all(&target).map_err(|e| e.to_string())?;
continue;
}
total = total.checked_add(entry.size()).filter(|size| *size <= MAX_BYTES).ok_or("The portable ZIP is too large.")?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(&target).map_err(|e| e.to_string())?;
use std::io::Read;
let declared = entry.size();
let copied = std::io::copy(&mut (&mut entry).take(declared.saturating_add(1)), &mut output).map_err(|e| e.to_string())?;
if copied != declared {
return Err("The portable ZIP has an unexpected unpacked size.".into());
}
files.push(relative);
}
Ok(files)
}
fn recorded_files<'a>(app: &CraftApp, record: &'a Record) -> Result<(&'a Path, Vec<PathBuf>), String> {
let root = record.path.parent().ok_or("The portable installation has no parent folder.")?;
if record.kind != PackageKind::PortableZip || root.file_name().is_none_or(|n| n != app.id) {
return Err("The launcher has no valid ownership record for this portable folder.".into());
}
let resolved = resolve_missing(root)?;
if !same_windows_path(root, &resolved) {
return Err("The recorded portable folder resolves through a changed alias. Reinstall it to choose a safe location.".into());
}
validate_owned_directory(root, root)?;
let mut files = Vec::new();
for file in &record.files {
let relative = file.strip_prefix(root).map_err(|_| "An installation record points outside its folder.")?;
if relative.as_os_str().is_empty() || relative.components().any(|c| !matches!(c, Component::Normal(_))) {
return Err("The installation record contains an unsafe path.".into());
}
validate_owned_directory(root, file.parent().ok_or("A recorded file has no parent folder.")?)?;
if let Ok(meta) = std::fs::symlink_metadata(file)
&& (linked(&meta) || !meta.is_file())
{
return Err("A recorded file was replaced by a link or directory.".into());
}
files.push(relative.to_path_buf());
}
Ok((root, files))
}
/// Keeps existing portable installations at their location and preserves unrecorded user files,
/// unless `replace` allows writing over them.
#[allow(clippy::too_many_arguments)]
pub fn install(
app: &CraftApp,
file: &Path,
version: &str,
previous: Option<&Record>,
paths: &Paths,
directory: Option<&Path>,
replace: bool,
commit: &mut dyn FnMut(&Record) -> Result<(), String>,
) -> Result<Record, String> {
let (destination, mut old_files) = match previous {
Some(record) => {
let (root, files) = recorded_files(app, record)?;
(root.to_path_buf(), files)
}
None => {
let parent = directory.map(Path::to_path_buf).unwrap_or_else(|| paths.apps());
validate_directory(&parent)?;
let parent = resolve_missing(&parent)?;
(parent.join(app.id), Vec::new())
}
};
validate_owned_directory(&destination, &destination)?;
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_nanos();
let staging = paths.cache.join("staging").join(format!("{}-{}-{stamp}", app.id, std::process::id()));
validate_owned_directory(&paths.cache, &staging)?;
std::fs::create_dir_all(staging.parent().ok_or("no staging folder")?).map_err(|e| e.to_string())?;
std::fs::create_dir(&staging).map_err(|e| e.to_string())?;
let staging = Staging(staging);
let unpacked = staging.0.join("unpacked");
std::fs::create_dir(&unpacked).map_err(|e| e.to_string())?;
let files = extract(file, &unpacked)?;
let executable = files
.iter()
.find(|relative| {
relative.parent().is_some_and(|p| p.as_os_str().is_empty())
&& relative
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| app.stems().any(|stem| name.eq_ignore_ascii_case(&format!("{stem}.exe"))))
})
.ok_or("The portable ZIP does not contain the expected executable at its root.")?
.clone();
// Validate the entire transaction before moving any existing program file.
for relative in &files {
let target = destination.join(relative);
validate_owned_directory(&destination, target.parent().ok_or("no destination folder")?)?;
if let Ok(meta) = std::fs::symlink_metadata(&target) {
let recorded = old_files.iter().any(|old| same_windows_path(old, relative));
if linked(&meta) || !meta.is_file() || !(recorded || replace) {
return Err("The installation would overwrite an unrecorded file.".into());
}
if !recorded {
old_files.push(relative.clone());
}
}
}
std::fs::create_dir_all(&destination).map_err(|e| e.to_string())?;
let backup = staging.0.join("backup");
let record = Record {
version: version.to_string(),
kind: PackageKind::PortableZip,
path: destination.join(executable),
installed_at: crate::now(),
files: files.iter().map(|relative| destination.join(relative)).collect(),
needs_root: false,
};
let mut moved = Vec::new();
let mut copied = Vec::new();
let transaction = (|| {
for relative in &old_files {
let old = destination.join(relative);
if !old.exists() {
continue;
}
let saved = backup.join(relative);
std::fs::create_dir_all(saved.parent().ok_or("no backup folder")?).map_err(|e| e.to_string())?;
// Copy the rollback copy: cache and custom installation can be on different drives.
std::fs::copy(&old, &saved).map_err(|e| e.to_string())?;
std::fs::remove_file(&old).map_err(|e| e.to_string())?;
moved.push(relative.clone());
}
for relative in &files {
let target = destination.join(relative);
std::fs::create_dir_all(target.parent().ok_or("no destination folder")?).map_err(|e| e.to_string())?;
let mut input = std::fs::File::open(unpacked.join(relative)).map_err(|e| e.to_string())?;
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(&target).map_err(|e| e.to_string())?;
copied.push(relative.clone());
std::io::copy(&mut input, &mut output).map_err(|e| e.to_string())?;
}
commit(&record)?;
Ok::<(), String>(())
})();
if let Err(error) = transaction {
for relative in copied {
let _ = std::fs::remove_file(destination.join(relative));
}
for relative in moved {
if let Err(restore) = std::fs::copy(backup.join(&relative), destination.join(&relative)) {
// Preserve backups for manual recovery if the disk won't accept a rollback.
let backup_path = staging.0.clone();
std::mem::forget(staging);
return Err(format!("{error}; could not restore the previous version ({restore}). Recovery files: {}", backup_path.display()));
}
}
return Err(error);
}
Ok(record)
}
/// Removes only recorded program files. Personal settings and documents remain.
pub fn remove(app: &CraftApp, record: &Record) -> Result<(), String> {
let (root, files) = recorded_files(app, record)?;
for relative in &files {
let file = root.join(relative);
if file.exists() {
std::fs::remove_file(&file).map_err(|e| e.to_string())?;
}
}
for relative in &files {
let mut directory = root.join(relative).parent().map(Path::to_path_buf);
while let Some(path) = directory.filter(|p| p.starts_with(root)) {
if std::fs::remove_dir(&path).is_err() || path == root {
break;
}
directory = path.parent().map(Path::to_path_buf);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn archive(root: &Path, name: &str, files: &[(&str, &[u8])]) -> PathBuf {
let path = root.join(name);
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
for (name, content) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(content).unwrap();
}
zip.finish().unwrap();
path
}
#[cfg(unix)]
#[test]
fn host_symlinks_are_allowed_but_owned_links_are_rejected() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let host = dir.path().join("var-home");
std::fs::create_dir(&host).unwrap();
let home = dir.path().join("home");
symlink(&host, &home).unwrap();
let root = home.join("photocraft");
assert!(validate_directory(&root).is_ok());
assert!(validate_owned_directory(&root, &root).is_ok());
let app = CraftApp::by_id("photocraft").unwrap();
let paths = Paths::under(&home);
let zip = archive(dir.path(), "app.zip", &[("photocraft.exe", b"program"), ("lib/runtime.dll", b"library")]);
let record = install(app, &zip, "0.5.0", None, &paths, Some(&home), false, &mut |_| Ok(())).unwrap();
assert_eq!(std::fs::read(&record.path).unwrap(), b"program");
std::fs::remove_file(root.join("lib/runtime.dll")).unwrap();
std::fs::remove_dir(root.join("lib")).unwrap();
symlink(&host, root.join("lib")).unwrap();
assert!(validate_owned_directory(&root, &root.join("lib/nested")).is_err());
assert!(remove(app, &record).is_err());
assert!(install(app, &zip, "0.6.0", Some(&record), &paths, None, false, &mut |_| Ok(())).is_err());
assert_eq!(std::fs::read(&record.path).unwrap(), b"program");
let linked_root = home.join("linked-app");
symlink(&root, &linked_root).unwrap();
assert!(validate_owned_directory(&linked_root, &linked_root).is_err());
assert!(validate_owned_directory(&root, &home).is_err());
let dangling = home.join("dangling");
symlink(home.join("missing"), &dangling).unwrap();
assert!(validate_directory(&dangling.join("nested")).is_err());
}
#[test]
fn stale_junction_record_cannot_remove_files_after_retargeting() {
use std::process::Command;
fn junction(link: &Path, target: &Path) {
let status = Command::new("cmd.exe").args(["/C", "mklink", "/J"]).arg(link).arg(target).status().unwrap();
assert!(status.success());
}
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first");
let second = dir.path().join("second");
let link = dir.path().join("Apps junction");
std::fs::create_dir(&first).unwrap();
std::fs::create_dir(&second).unwrap();
junction(&link, &first);
let alias_root = link.join("photocraft");
std::fs::create_dir(&alias_root).unwrap();
std::fs::write(alias_root.join("photocraft.exe"), b"old program").unwrap();
let record = Record {
version: "0.5.0".into(),
kind: PackageKind::PortableZip,
path: alias_root.join("photocraft.exe"),
installed_at: 0,
files: vec![alias_root.join("photocraft.exe")],
needs_root: false,
};
std::process::Command::new("cmd.exe").args(["/C", "rmdir"]).arg(&link).status().unwrap();
junction(&link, &second);
let foreign_root = link.join("photocraft");
std::fs::create_dir(&foreign_root).unwrap();
std::fs::write(foreign_root.join("photocraft.exe"), b"foreign program").unwrap();
assert!(remove(CraftApp::by_id("photocraft").unwrap(), &record).is_err());
assert_eq!(std::fs::read(foreign_root.join("photocraft.exe")).unwrap(), b"foreign program");
}
#[test]
fn upgrade_downgrade_and_uninstall_preserve_personal_files_and_location() {
let dir = tempfile::tempdir().unwrap();
let app = CraftApp::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let parent = dir.path().join("My Tools");
let first = archive(dir.path(), "first.zip", &[("photocraft.exe", b"first"), ("obsolete.dll", b"old")]);
let mut record = install(app, &first, "0.5.0", None, &paths, Some(&parent), false, &mut |_| Ok(())).unwrap();
let personal = parent.join("photocraft").join("settings.json");
std::fs::write(&personal, b"personal").unwrap();
for (version, exe) in [("0.6.0", b"new".as_slice()), ("0.4.0", b"older".as_slice())] {
let executable = if version == "0.4.0" { "PhotoCraft.exe" } else { "photocraft.exe" };
let package = archive(dir.path(), "next.zip", &[(executable, exe), ("lib/runtime.dll", b"dll")]);
record = install(app, &package, version, Some(&record), &paths, Some(&dir.path().join("Ignored")), false, &mut |_| Ok(())).unwrap();
assert_eq!(std::fs::read(&record.path).unwrap(), exe);
assert_eq!(record.path.parent().unwrap(), std::fs::canonicalize(personal.parent().unwrap()).unwrap());
assert!(!parent.join("photocraft/obsolete.dll").exists());
assert_eq!(std::fs::read(&personal).unwrap(), b"personal");
let mut manifest = crate::manifest::Manifest::default();
manifest.apps.insert(app.id.into(), record.clone());
manifest.save(&paths.manifest()).unwrap();
let restored = crate::manifest::Manifest::load(&paths.manifest());
let detected = crate::detect::detect(app, &restored, &crate::detect::Snapshot::take()).unwrap();
assert_eq!(detected.version, artcraft_launcher_core::version::parse(version));
assert_eq!(detected.location, record.path.to_string_lossy());
}
remove(app, &record).unwrap();
assert!(!record.path.exists());
assert_eq!(std::fs::read(personal).unwrap(), b"personal");
}
#[test]
fn a_failed_manifest_commit_restores_the_previous_program() {
let dir = tempfile::tempdir().unwrap();
let app = CraftApp::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let old = archive(dir.path(), "old.zip", &[("photocraft.exe", b"old"), ("lib.dll", b"library")]);
let record = install(app, &old, "0.5.0", None, &paths, None, false, &mut |_| Ok(())).unwrap();
let next = archive(dir.path(), "next.zip", &[("photocraft.exe", b"new"), ("new.dll", b"new")]);
let result = install(app, &next, "0.6.0", Some(&record), &paths, None, false, &mut |_| Err("manifest disk full".into()));
assert_eq!(result.unwrap_err(), "manifest disk full");
assert_eq!(std::fs::read(record.path).unwrap(), b"old");
assert_eq!(std::fs::read(paths.apps().join("photocraft/lib.dll")).unwrap(), b"library");
assert!(!paths.apps().join("photocraft/new.dll").exists());
}
#[test]
fn collision_is_rejected_before_replacing_any_old_file() {
let dir = tempfile::tempdir().unwrap();
let app = CraftApp::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let old = archive(dir.path(), "old.zip", &[("photocraft.exe", b"old")]);
let record = install(app, &old, "0.5.0", None, &paths, None, false, &mut |_| Ok(())).unwrap();
let personal = paths.apps().join("photocraft/settings.json");
std::fs::write(&personal, b"keep").unwrap();
let next = archive(dir.path(), "next.zip", &[("photocraft.exe", b"new"), ("settings.json", b"overwrite")]);
assert!(install(app, &next, "0.6.0", Some(&record), &paths, None, false, &mut |_| Ok(())).is_err());
assert_eq!(std::fs::read(record.path).unwrap(), b"old");
assert_eq!(std::fs::read(personal).unwrap(), b"keep");
}
#[test]
fn a_new_install_writes_over_unrecorded_files_only_when_asked_to() {
let dir = tempfile::tempdir().unwrap();
let app = CraftApp::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let folder = paths.apps().join("photocraft");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(folder.join("photocraft.exe"), b"theirs").unwrap();
std::fs::write(folder.join("notes.txt"), b"keep").unwrap();
let zip = archive(dir.path(), "new.zip", &[("photocraft.exe", b"new")]);
assert!(install(app, &zip, "0.5.0", None, &paths, None, false, &mut |_| Ok(())).is_err());
assert_eq!(std::fs::read(folder.join("photocraft.exe")).unwrap(), b"theirs");
install(app, &zip, "0.5.0", None, &paths, None, true, &mut |_| Ok(())).unwrap();
assert_eq!(std::fs::read(folder.join("photocraft.exe")).unwrap(), b"new");
assert_eq!(std::fs::read(folder.join("notes.txt")).unwrap(), b"keep");
}
#[test]
fn rejects_traversal_and_case_collisions() {
let dir = tempfile::tempdir().unwrap();
for (name, files) in [
("traversal.zip", vec![("../escape.exe", b"bad".as_slice())]),
("colon.zip", vec![("data:stream", b"bad".as_slice())]),
("duplicate.zip", vec![("PhotoCraft.exe", b"one".as_slice()), ("photocraft.exe", b"two".as_slice())]),
] {
let zip = archive(dir.path(), name, &files);
let output = dir.path().join(name.replace('.', "-"));
std::fs::create_dir(&output).unwrap();
assert!(extract(&zip, &output).is_err(), "{name}");
}
assert!(!dir.path().join("escape.exe").exists());
assert!(validate_directory(Path::new("relative/folder")).is_err());
assert!(validate_directory(&dir.path().join("../escape")).is_err());
}
}
+9 -7
View File
@@ -51,13 +51,15 @@ pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String
/// Runs a helper and returns its standard output when it succeeds within `timeout`; otherwise
/// the reason, in the helper's own words (its standard error) where it gave one.
pub fn run(program: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
let mut child = command(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("couldn't start {program} ({e})"))?;
let mut cmd = command(program);
cmd.args(args);
run_command(cmd, program, timeout)
}
/// Runs a configured helper with bounded output and a timeout.
pub fn run_command(mut cmd: Command, program: &str, timeout: Duration) -> Result<String, String> {
let mut child =
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't start {program} ({e})"))?;
// Read both pipes while it runs, so a chatty helper can't block on a full one.
let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take()));
let start = Instant::now();
+7 -4
View File
@@ -168,7 +168,7 @@ pub fn update(
let (asset, _) = pick_asset(release, &[LAUNCHER.id], platform, &[install.kind()])
.ok_or_else(|| format!("ArtCraft Launcher {} has no {} for {}", release.version_label(), install.kind().label(), platform.label()))?;
let asset = asset.clone();
let mut job = Job { http, paths, stop, phase };
let mut job = Job { http, paths, stop, phase, install_locations: &Default::default(), replace: false };
let file = install::download(release, &asset, &mut job)?;
// The restart ends this process: nothing else may be under way.
last()?;
@@ -181,7 +181,7 @@ pub fn update(
#[cfg(target_os = "macos")]
Install::MacApp(bundle) => {
let existing = Installed { version: None, source: InstallSource::MacApp, location: bundle.display().to_string(), package: None };
let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), paths, &install::macos::Expect::release(release));
let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), None, paths, &install::macos::Expect::release(release));
let _ = std::fs::remove_file(&file);
let placed = placed?;
relaunch_after_exit(pid, &[Path::new("/usr/bin/open").as_os_str(), placed.as_os_str()])
@@ -371,10 +371,13 @@ mod tests {
let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };
// An unsigned test bundle: the signature check has its own tests in `install`.
let expect = install::macos::Expect { team: None, version: None };
let placed = install::macos::install_dmg(&LAUNCHER, &dmg, Some(&existing), &Paths::under(dir.path()), &expect).unwrap();
let placed = install::macos::install_dmg(&LAUNCHER, &dmg, Some(&existing), None, &Paths::under(dir.path()), &expect).unwrap();
assert_eq!(placed, current);
assert_eq!(std::fs::read_to_string(current.join("Contents/MacOS/ArtCraft Launcher")).unwrap(), "0.2.0");
assert!(!dir.path().join("Applications/.ArtCraft Launcher.app.partial").exists());
assert_eq!(
std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>(),
[std::ffi::OsString::from("ArtCraft Launcher.app")]
);
}
#[test]
+42 -4
View File
@@ -58,6 +58,9 @@ pub trait Host {
fn reduce_motion(&self) -> bool {
false
}
fn pick_folder(&mut self, _initial: Option<&str>) -> Option<String> {
None
}
/// Quit the launcher for real (not just hide to the tray).
fn quit(&mut self) {}
}
@@ -72,6 +75,8 @@ pub(crate) struct Toast {
pub(crate) enum Dialog {
Notes(String),
ConfirmUninstall(String),
Install { app: String, folder: String },
Replace { app: String, path: String },
Settings,
LauncherUpdate(artcraft_launcher_core::Release),
}
@@ -83,6 +88,8 @@ impl Dialog {
match self {
Dialog::Notes(id) => ("notes", id),
Dialog::ConfirmUninstall(id) => ("uninstall", id),
Dialog::Install { app, .. } => ("install", app),
Dialog::Replace { app, .. } => ("replace", app),
Dialog::Settings => ("settings", ""),
Dialog::LauncherUpdate(r) => ("launcher-update", &r.tag),
}
@@ -263,14 +270,39 @@ impl LauncherUi {
self.entries.iter_mut().find(|e| e.app.id == id)
}
/// Non-drawing work: runs every frame and also while the window is hidden in the tray.
pub fn logic(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
set_current(crate::i18n::Lang::from_pref(&self.settings.language));
let options = BackendOptions { kinds: self.settings.preferred_kinds.clone(), prereleases: self.settings.prereleases };
pub(crate) fn configure(&mut self) {
let options = BackendOptions {
kinds: self.settings.preferred_kinds.clone(),
prereleases: self.settings.prereleases,
install_locations: self.settings.install_locations.clone(),
};
if self.sent_options.as_ref() != Some(&options) {
self.backend.send(Command::Configure(options.clone()));
self.sent_options = Some(options);
}
}
pub(crate) fn custom_install_folders(&self) -> bool {
self.caps.manage_apps
&& self.platform.is_some_and(|p| {
matches!(p.os, artcraft_launcher_core::Os::Windows | artcraft_launcher_core::Os::MacOs | artcraft_launcher_core::Os::Linux)
})
&& !crate::settings::is_flatpak_sandbox()
}
pub(crate) fn install_app(&mut self, id: &str) {
if !self.custom_install_folders() || self.entry(id).is_some_and(|e| e.installed.is_some()) {
self.send(Command::Install { app: id.to_string() });
return;
}
self.dialog =
Some(Dialog::Install { app: id.to_string(), folder: self.settings.install_locations.per_app.get(id).cloned().unwrap_or_default() });
}
/// Non-drawing work: runs every frame and also while the window is hidden in the tray.
pub fn logic(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
set_current(crate::i18n::Lang::from_pref(&self.settings.language));
self.configure();
if !self.started {
self.started = true;
self.backend.send(Command::CheckForUpdates { force: false });
@@ -368,6 +400,10 @@ impl LauncherUi {
}
false
}
Event::Occupied { app, path } => {
self.dialog = Some(Dialog::Replace { app, path });
false
}
Event::JobFinished { app, result } => {
let launcher = app == artcraft_launcher_core::catalog::LAUNCHER_ID;
// What the job was doing when it ended says what a cancellation called off.
@@ -391,6 +427,8 @@ impl LauncherUi {
self.toast(ctx, tl!("Install cancelled. The download is kept for the next try."), false);
}
Err(e) if e == INSTALL_CANCELLED => self.toast(ctx, tl!("Install cancelled"), false),
// The Replace dialog says it all.
Err(_) if matches!(&self.dialog, Some(Dialog::Replace { app: a, .. }) if *a == app) => {}
Err(e) => self.toast(ctx, e, true),
}
true
+116 -2
View File
@@ -12,7 +12,7 @@ use crate::widgets::{self, Cell, Kind};
impl LauncherUi {
pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) {
let Some(dialog) = self.dialog.clone() else {
let Some(mut dialog) = self.dialog.clone() else {
self.alert_popup(ctx);
return;
};
@@ -22,14 +22,18 @@ impl LauncherUi {
let modal = egui::Modal::new(egui::Id::new("dialog").with(dialog.key()))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
let mut close = false;
let response = match &dialog {
let response = match &mut dialog {
Dialog::Notes(id) => modal.show(ctx, |ui| close = self.notes_dialog(ui, id)),
Dialog::ConfirmUninstall(id) => modal.show(ctx, |ui| close = self.uninstall_dialog(ui, id)),
Dialog::Install { app, folder } => modal.show(ctx, |ui| close = self.install_dialog(ui, app, folder, host)),
Dialog::Replace { app, path } => modal.show(ctx, |ui| close = self.replace_dialog(ui, app, path)),
Dialog::Settings => modal.show(ctx, |ui| close = self.settings_dialog(ui, host)),
Dialog::LauncherUpdate(r) => modal.show(ctx, |ui| close = self.launcher_update_dialog(ui, r)),
};
if close || response.should_close() {
self.dialog = None;
} else {
self.dialog = Some(dialog);
}
}
@@ -180,6 +184,80 @@ impl LauncherUi {
close
}
fn replace_dialog(&mut self, ui: &mut egui::Ui, id: &str, path: &str) -> bool {
let Some(app) = self.entry(id).map(|e| e.app) else { return true };
let t = Tokens::get(ui.ctx());
ui.set_width(440.0f32.min(ui.ctx().content_rect().width() - 80.0));
Self::title(ui, crate::icons::app(id), tl!("Replace what's there?"), "");
ui.label(
RichText::new(fmt(tl!("{path} already exists. Installing {name} will overwrite it."), &[("path", path), ("name", app.name)]))
.color(t.muted),
);
ui.add_space(20.0);
let mut close = false;
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, tl!("Replace"), Kind::Danger, 110.0).clicked() {
self.send(Command::Replace { app: id.to_string() });
close = true;
}
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
close = true;
}
});
});
close
}
fn install_dialog(&mut self, ui: &mut egui::Ui, id: &str, folder: &mut String, host: &mut dyn Host) -> bool {
let Some(app) = self.entry(id).map(|e| e.app) else { return true };
ui.set_width(440.0f32.min(ui.ctx().content_rect().width() - 80.0));
Self::title(ui, None, app.name, tl!("Install folder"));
let default = self.settings.install_locations.root.as_deref().unwrap_or(tl!("System default"));
Self::folder_field(ui, folder, Some(default), host);
if self.platform.is_some_and(|p| p.os == Os::Linux) {
ui.label(tl!("Custom folders use AppImage on Linux."));
}
ui.add_space(14.0);
let mut close = false;
ui.horizontal(|ui| {
if widgets::button(ui, tl!("Install"), Kind::Primary, 100.0).clicked() {
if folder.is_empty() {
self.settings.install_locations.per_app.remove(id);
} else {
self.settings.install_locations.per_app.insert(id.to_string(), folder.clone());
}
self.configure();
self.send(Command::Install { app: id.to_string() });
close = true;
}
close |= widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked();
});
close
}
fn folder_field(ui: &mut egui::Ui, folder: &mut String, default: Option<&str>, host: &mut dyn Host) {
let label = ui.label(tl!("Install folder"));
ui.add(
egui::TextEdit::singleline(folder)
.id_salt("install_folder")
.hint_text(default.unwrap_or(tl!("System default")))
.desired_width(f32::INFINITY),
)
.labelled_by(label.id);
ui.label(tl!("Apps are placed inside this folder."));
ui.horizontal(|ui| {
if widgets::button(ui, tl!("Browse…"), Kind::Secondary, 100.0).clicked()
&& let Some(selected) = host.pick_folder(if folder.is_empty() { default } else { Some(folder.as_str()) })
{
*folder = selected;
}
if widgets::button(ui, tl!("Use default"), Kind::Secondary, 120.0).clicked() {
folder.clear();
}
});
}
fn settings_dialog(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) -> bool {
// Leave room for the frame (48 px) and a 16 px gutter on each side.
let viewport = ui.ctx().content_rect();
@@ -285,6 +363,42 @@ impl LauncherUi {
ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update"));
ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)"));
if self.caps.manage_apps {
section(ui, tl!("Default install folder"));
if self.custom_install_folders() {
let mut folder = self.settings.install_locations.root.clone().unwrap_or_default();
Self::folder_field(ui, &mut folder, None, host);
self.settings.install_locations.root = (!folder.is_empty()).then_some(folder);
ui.label(RichText::new(tl!("New installs use this folder. Updates stay in their current location.")).size(12.0).color(t.muted));
if self.platform.is_some_and(|p| p.os == Os::Linux) {
ui.label(RichText::new(tl!("Custom folders use AppImage on Linux.")).size(12.0).color(t.muted));
}
} else {
ui.label(tl!("The system manages install locations here."));
}
}
if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Windows) {
section(ui, tl!("Installing on Windows"));
let mut choice = self
.settings
.preferred_kinds
.first()
.copied()
.filter(|kind| matches!(kind, PackageKind::Msi | PackageKind::PortableZip))
.or(Some(PackageKind::Msi));
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
let label = choice.map_or_else(|| crate::i18n::t(PackageKind::Msi.label()), |kind| crate::i18n::t(kind.label()));
egui::ComboBox::from_id_salt("windows-kind").selected_text(label).width(210.0).show_ui(ui, |ui| {
for kind in [PackageKind::Msi, PackageKind::PortableZip] {
ui.selectable_value(&mut choice, Some(kind), crate::i18n::t(kind.label()));
}
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
ui.label(RichText::new(tl!("Updates always use the format an app was installed with.")).size(12.0).color(t.muted));
}
if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Linux) {
section(ui, tl!("Installing on Linux"));
let is_flatpak = crate::settings::is_flatpak_sandbox();
+18
View File
@@ -76,6 +76,7 @@
Notify me when an installed app has an update Upozornit, když má nainstalovaná aplikace aktualizaci
Include pre-releases (betas and release candidates) Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
Installing on Linux Instalace v Linuxu
Installing on Windows Instalace ve Windows
Install new apps as Instalovat nové aplikace jako
System package (recommended) Systémový balíček (doporučeno)
Updates always use the format an app was installed with. Aktualizace vždy použijí formát, ve kterém byla aplikace nainstalována.
@@ -312,3 +313,20 @@ filter Installed Nainstalováno
ArtCraft on GitHub ArtCraft na GitHubu
Join the ArtCraft Discord Připojit se na ArtCraft Discord
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostupná aktualizace|ArtCraft Launcher · {n} dostupné aktualizace|ArtCraft Launcher · {n} dostupných aktualizací
Install folder Instalační složka
Default install folder Výchozí instalační složka
System default Výchozí nastavení systému
Browse… Procházet…
Use default Použít výchozí
New installs use this folder. Updates stay in their current location. Nové instalace používají tuto složku. Aktualizace zůstávají v aktuálním umístění.
Custom folders use AppImage on Linux. Vlastní složky používají v Linuxu formát AppImage.
The system manages install locations here. Umístění instalací zde spravuje systém.
@msg couldn't use install folder {path}: {reason} instalační složku {path} nelze použít: {reason}
@msg this release has no AppImage for a custom install folder toto vydání nemá AppImage pro vlastní instalační složku
@msg custom install folders aren't supported on this system vlastní instalační složky nejsou v tomto systému podporovány
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} již existuje a nebylo nainstalováno aplikací ArtCraft Launcher
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. {path} nelze přesunout do koše ({reason}). Odstraňte položku ve Finderu.
Apps are placed inside this folder. Aplikace se umístí do této složky.
Replace what's there? Nahradit, co tam je?
{path} already exists. Installing {name} will overwrite it. {path} už existuje. Instalace aplikace {name} ho přepíše.
Replace Nahradit
1 # Czech (cs) catalog for ArtCraft Launcher.
76
77
78
79
80
81
82
313
314 @plural
315
316
317
318
319
320
321
322
323 @msg
324 @msg
325 @msg
326 @msg
327 @msg
328
329
330
331
332
+18
View File
@@ -89,6 +89,7 @@
Notify me when an installed app has an update Benachrichtigen, wenn es für eine installierte App ein Update gibt
Include pre-releases (betas and release candidates) Vorabversionen einbeziehen (Betas und Release Candidates)
Installing on Linux Installation unter Linux
Installing on Windows Installation unter Windows
Install new apps as Neue Apps installieren als
System package (recommended) Systempaket (empfohlen)
Updates always use the format an app was installed with. Updates verwenden immer das Format, mit dem eine App installiert wurde.
@@ -344,3 +345,20 @@ filter Installed Installiert
Loading pictures… Bilder werden geladen…
Pictures couldn't be loaded. Reopen this page to try again. Die Bilder konnten nicht geladen werden. Beim erneuten Öffnen der Seite wird es noch einmal versucht.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} Update verfügbar|ArtCraft Launcher · {n} Updates verfügbar
Install folder Installationsordner
Default install folder Standardordner für Installationen
System default Systemstandard
Browse… Durchsuchen…
Use default Standard verwenden
New installs use this folder. Updates stay in their current location. Neue Installationen verwenden diesen Ordner. Updates bleiben am bisherigen Speicherort.
Custom folders use AppImage on Linux. Für eigene Ordner wird unter Linux AppImage verwendet.
The system manages install locations here. Das System verwaltet hier die Installationsorte.
@msg couldn't use install folder {path}: {reason} Installationsordner {path} konnte nicht verwendet werden: {reason}
@msg this release has no AppImage for a custom install folder diese Veröffentlichung enthält kein AppImage für einen eigenen Installationsordner
@msg custom install folders aren't supported on this system eigene Installationsordner werden auf diesem System nicht unterstützt
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} existiert bereits und wurde nicht von ArtCraft Launcher installiert
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. {path} konnte nicht in den Papierkorb verschoben werden ({reason}). Entfernen Sie es stattdessen im Finder.
Apps are placed inside this folder. Apps werden in diesem Ordner abgelegt.
Replace what's there? Vorhandenes ersetzen?
{path} already exists. Installing {name} will overwrite it. {path} existiert bereits. Die Installation von {name} überschreibt es.
Replace Ersetzen
Can't render this file because it contains an unexpected character in line 11 and column 16.
+18
View File
@@ -89,6 +89,7 @@
Notify me when an installed app has an update Avisarme cuando una aplicación instalada tenga una actualización
Include pre-releases (betas and release candidates) Incluir versiones preliminares (betas y versiones candidatas)
Installing on Linux Instalación en Linux
Installing on Windows Instalación en Windows
Install new apps as Instalar las aplicaciones nuevas como
System package (recommended) Paquete del sistema (recomendado)
Updates always use the format an app was installed with. Las actualizaciones siempre usan el formato con el que se instaló cada aplicación.
@@ -325,3 +326,20 @@ filter Installed Instaladas
ArtCraft on GitHub ArtCraft en GitHub
Join the ArtCraft Discord Únete al Discord de ArtCraft
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} actualización disponible|ArtCraft Launcher · {n} actualizaciones disponibles
Install folder Carpeta de instalación
Default install folder Carpeta de instalación predeterminada
System default Predeterminada del sistema
Browse… Examinar…
Use default Usar predeterminada
New installs use this folder. Updates stay in their current location. Las nuevas instalaciones usan esta carpeta. Las actualizaciones conservan su ubicación actual.
Custom folders use AppImage on Linux. Las carpetas personalizadas usan AppImage en Linux.
The system manages install locations here. El sistema gestiona las ubicaciones de instalación aquí.
@msg couldn't use install folder {path}: {reason} no se pudo usar la carpeta de instalación {path}: {reason}
@msg this release has no AppImage for a custom install folder esta versión no tiene un AppImage para una carpeta de instalación personalizada
@msg custom install folders aren't supported on this system este sistema no admite carpetas de instalación personalizadas
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} ya existe y no fue instalado por ArtCraft Launcher
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. no se pudo mover {path} a la Papelera ({reason}). Elimínalo desde Finder.
Apps are placed inside this folder. Las aplicaciones se guardan dentro de esta carpeta.
Replace what's there? ¿Reemplazar lo que hay?
{path} already exists. Installing {name} will overwrite it. {path} ya existe. Instalar {name} lo sobrescribirá.
Replace Reemplazar
Can't render this file because it contains an unexpected character in line 5 and column 48.
+18
View File
@@ -87,6 +87,7 @@
Notify me when an installed app has an update Me prévenir quand une application installée a une mise à jour
Include pre-releases (betas and release candidates) Inclure les préversions (bêtas et versions candidates)
Installing on Linux Installation sous Linux
Installing on Windows Installation sous Windows
Install new apps as Installer les nouvelles applications en tant que
System package (recommended) Paquet système (recommandé)
Updates always use the format an app was installed with. Les mises à jour utilisent toujours le format avec lequel l'application a été installée.
@@ -342,3 +343,20 @@ filter Installed Installées
Loading pictures… Chargement des images…
Pictures couldn't be loaded. Reopen this page to try again. Impossible de charger les images. Rouvrir la page permet de réessayer.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} mise à jour disponible|ArtCraft Launcher · {n} mises à jour disponibles
Install folder Dossier d’installation
Default install folder Dossier d’installation par défaut
System default Par défaut du système
Browse… Parcourir…
Use default Par défaut
New installs use this folder. Updates stay in their current location. Les nouvelles installations utilisent ce dossier. Les mises à jour conservent leur emplacement actuel.
Custom folders use AppImage on Linux. Les dossiers personnalisés utilisent AppImage sous Linux.
The system manages install locations here. Le système gère les emplacements d’installation ici.
@msg couldn't use install folder {path}: {reason} impossible d’utiliser le dossier d’installation {path} : {reason}
@msg this release has no AppImage for a custom install folder cette version ne propose pas d’AppImage pour un dossier d’installation personnalisé
@msg custom install folders aren't supported on this system les dossiers d’installation personnalisés ne sont pas pris en charge sur ce système
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} existe déjà et n’a pas été installé par ArtCraft Launcher
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. impossible de déplacer {path} vers la Corbeille ({reason}). Supprimez-le dans le Finder.
Apps are placed inside this folder. Les applications sont placées dans ce dossier.
Replace what's there? Remplacer l’existant ?
{path} already exists. Installing {name} will overwrite it. {path} existe déjà. L’installation de {name} l’écrasera.
Replace Remplacer
Can't render this file because it contains an unexpected character in line 5 and column 50.
+18
View File
@@ -89,6 +89,7 @@
Notify me when an installed app has an update Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
Include pre-releases (betas and release candidates) Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
Installing on Linux Telepítés Linuxon
Installing on Windows Telepítés Windowson
Install new apps as Új alkalmazások csomagformátuma
System package (recommended) Rendszercsomag (ajánlott)
Updates always use the format an app was installed with. A frissítések mindig az eredeti telepítés csomagformátumát használják.
@@ -345,3 +346,20 @@ filter Installed Telepítve
ArtCraft on GitHub ArtCraft a GitHubon
Join the ArtCraft Discord Csatlakozás az ArtCraft Discord-közösségéhez
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} frissítés elérhető|ArtCraft Launcher · {n} frissítés elérhető
Install folder Telepítési mappa
Default install folder Alapértelmezett telepítési mappa
System default Rendszer alapértelmezése
Browse… Tallózás…
Use default Alapértelmezett használata
New installs use this folder. Updates stay in their current location. Az új telepítések ezt a mappát használják. A frissítések a jelenlegi helyükön maradnak.
Custom folders use AppImage on Linux. Linuxon az egyéni mappákhoz AppImage szükséges.
The system manages install locations here. Itt a rendszer kezeli a telepítési helyeket.
@msg couldn't use install folder {path}: {reason} a(z) {path} telepítési mappa nem használható: {reason}
@msg this release has no AppImage for a custom install folder ez a kiadás nem tartalmaz AppImage-fájlt egyéni telepítési mappához
@msg custom install folders aren't supported on this system ezen a rendszeren nem támogatottak az egyéni telepítési mappák
@msg {path} already exists and wasn't installed by ArtCraft Launcher a(z) {path} már létezik, és nem az ArtCraft Launcher telepítette
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. a(z) {path} nem helyezhető a Kukába ({reason}). Távolítsa el a Finderben.
Apps are placed inside this folder. Az alkalmazások ebbe a mappába kerülnek.
Replace what's there? Lecseréli, ami ott van?
{path} already exists. Installing {name} will overwrite it. {path} már létezik. A(z) {name} telepítése felülírja.
Replace Csere
Can't render this file because it contains an unexpected character in line 11 and column 16.
+18
View File
@@ -91,6 +91,7 @@
Notify me when an installed app has an update インストール済みアプリに更新があるときに通知する
Include pre-releases (betas and release candidates) プレリリース(ベータ版とリリース候補)を含める
Installing on Linux Linux へのインストール
Installing on Windows Windows へのインストール
Install new apps as 新しいアプリのインストール形式
System package (recommended) システムパッケージ(推奨)
Updates always use the format an app was installed with. 更新は常に、アプリをインストールしたときの形式で行われます。
@@ -347,3 +348,20 @@ filter Installed インストール済み
ArtCraft on GitHub GitHub の ArtCraft
Join the ArtCraft Discord ArtCraft Discord に参加
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} 件の更新があります
Install folder インストール先フォルダー
Default install folder 既定のインストール先フォルダー
System default システムの既定
Browse… 参照…
Use default 既定を使用
New installs use this folder. Updates stay in their current location. 新しいインストールにはこのフォルダーを使用します。更新時は現在の場所を維持します。
Custom folders use AppImage on Linux. Linuxで任意のフォルダーにインストールする場合はAppImageを使用します。
The system manages install locations here. この環境ではシステムがインストール先を管理します。
@msg couldn't use install folder {path}: {reason} インストール先フォルダー {path} を使用できませんでした: {reason}
@msg this release has no AppImage for a custom install folder このリリースには任意のインストール先フォルダーで使用できるAppImageがありません
@msg custom install folders aren't supported on this system このシステムではインストール先フォルダーの変更に対応していません
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} は既に存在し、ArtCraft Launcherでインストールされたものではありません
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. {path} をゴミ箱に移動できませんでした({reason})。Finderで削除してください。
Apps are placed inside this folder. アプリはこのフォルダー内に配置されます。
Replace what's there? 既存の項目を置き換えますか?
{path} already exists. Installing {name} will overwrite it. {path} は既に存在します。{name} をインストールすると上書きされます。
Replace 置き換える
Can't render this file because it contains an unexpected character in line 14 and column 44.
+18
View File
@@ -90,6 +90,7 @@
Notify me when an installed app has an update Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
Include pre-releases (betas and release candidates) Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
Installing on Linux Instalowanie w systemie Linux
Installing on Windows Instalowanie w systemie Windows
Install new apps as Instaluj nowe aplikacje jako
System package (recommended) Pakiet systemowy (zalecany)
Updates always use the format an app was installed with. Aktualizacje zawsze używają formatu, w jakim pierwotnie zainstalowano aplikację.
@@ -346,3 +347,20 @@ filter Installed Zainstalowane
ArtCraft on GitHub ArtCraft na GitHubie
Join the ArtCraft Discord Dołącz do Discorda ArtCraft
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostępna aktualizacja|ArtCraft Launcher · {n} dostępne aktualizacje|ArtCraft Launcher · {n} dostępnych aktualizacji
Install folder Folder instalacji
Default install folder Domyślny folder instalacji
System default Domyślny systemowy
Browse… Przeglądaj…
Use default Użyj domyślnego
New installs use this folder. Updates stay in their current location. Nowe instalacje używają tego folderu. Aktualizacje pozostają w obecnej lokalizacji.
Custom folders use AppImage on Linux. W Linuksie własne foldery korzystają z formatu AppImage.
The system manages install locations here. W tym środowisku system zarządza lokalizacjami instalacji.
@msg couldn't use install folder {path}: {reason} nie można użyć folderu instalacji {path}: {reason}
@msg this release has no AppImage for a custom install folder to wydanie nie zawiera AppImage dla własnego folderu instalacji
@msg custom install folders aren't supported on this system własne foldery instalacji nie są obsługiwane w tym systemie
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} już istnieje i nie został zainstalowany przez ArtCraft Launcher
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. nie można przenieść {path} do Kosza ({reason}). Usuń go w Finderze.
Apps are placed inside this folder. Aplikacje są umieszczane w tym folderze.
Replace what's there? Zastąpić to, co tam jest?
{path} already exists. Installing {name} will overwrite it. {path} już istnieje. Instalacja {name} go nadpisze.
Replace Zastąp
Can't render this file because it contains an unexpected character in line 11 and column 15.
+18
View File
@@ -77,6 +77,7 @@
Notify me when an installed app has an update Notificar quando um aplicativo instalado tiver atualização
Include pre-releases (betas and release candidates) Incluir versões de pré-lançamento (betas e RC)
Installing on Linux Instalação no Linux
Installing on Windows Instalação no Windows
Install new apps as Instalar novos aplicativos como
System package (recommended) Pacote do sistema (recomendado)
Updates always use the format an app was installed with. As atualizações sempre usam o formato com o qual o aplicativo foi instalado.
@@ -332,3 +333,20 @@ filter Installed Instalados
Loading pictures… Carregando imagens…
Pictures couldn't be loaded. Reopen this page to try again. Não foi possível carregar as imagens. Abra a página novamente para tentar de novo.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} atualização disponível|ArtCraft Launcher · {n} atualizações disponíveis
Install folder Pasta de instalação
Default install folder Pasta de instalação padrão
System default Padrão do sistema
Browse… Procurar…
Use default Usar padrão
New installs use this folder. Updates stay in their current location. Novas instalações usam esta pasta. As atualizações mantêm o local atual.
Custom folders use AppImage on Linux. Pastas personalizadas usam AppImage no Linux.
The system manages install locations here. O sistema gerencia os locais de instalação neste ambiente.
@msg couldn't use install folder {path}: {reason} não foi possível usar a pasta de instalação {path}: {reason}
@msg this release has no AppImage for a custom install folder esta versão não tem um AppImage para uma pasta de instalação personalizada
@msg custom install folders aren't supported on this system pastas de instalação personalizadas não são compatíveis com este sistema
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} já existe e não foi instalado pelo ArtCraft Launcher
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. não foi possível mover {path} para o Lixo ({reason}). Remova-o pelo Finder.
Apps are placed inside this folder. Os aplicativos são colocados dentro desta pasta.
Replace what's there? Substituir o que já existe?
{path} already exists. Installing {name} will overwrite it. {path} já existe. Instalar o {name} vai substituí-lo.
Replace Substituir
Can't render this file because it contains an unexpected character in line 251 and column 66.
+18
View File
@@ -87,6 +87,7 @@
Notify me when an installed app has an update Уведомлять, когда у установленного приложения есть обновление
Include pre-releases (betas and release candidates) Включать предварительные релизы (бета и RC)
Installing on Linux Установка в Linux
Installing on Windows Установка в Windows
Install new apps as Устанавливать новые приложения как
System package (recommended) Системный пакет (рек.)
Updates always use the format an app was installed with. Обновления всегда используют формат, с которым приложение было установлено.
@@ -340,3 +341,20 @@ filter Installed Установлены
Loading pictures… Загрузка изображений…
Pictures couldn't be loaded. Reopen this page to try again. Не удалось загрузить изображения. Откройте страницу снова, чтобы повторить попытку.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} обновление|ArtCraft Launcher · {n} обновления|ArtCraft Launcher · {n} обновлений
Install folder Папка установки
Default install folder Папка установки по умолчанию
System default По умолчанию в системе
Browse… Обзор…
Use default Использовать по умолчанию
New installs use this folder. Updates stay in their current location. Новые приложения устанавливаются в эту папку. При обновлении текущее расположение сохраняется.
Custom folders use AppImage on Linux. В Linux для установки в выбранную папку используется AppImage.
The system manages install locations here. В этой среде расположением приложений управляет система.
@msg couldn't use install folder {path}: {reason} не удалось использовать папку установки {path}: {reason}
@msg this release has no AppImage for a custom install folder в этом выпуске нет AppImage для установки в выбранную папку
@msg custom install folders aren't supported on this system в этой системе выбор папки установки не поддерживается
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} уже существует и не был установлен через ArtCraft Launcher
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. не удалось переместить {path} в Корзину ({reason}). Удалите его через Finder.
Apps are placed inside this folder. Приложения размещаются внутри этой папки.
Replace what's there? Заменить существующее?
{path} already exists. Installing {name} will overwrite it. {path} уже существует. Установка {name} перезапишет его.
Replace Заменить
Can't render this file because it contains an unexpected character in line 13 and column 44.
+18
View File
@@ -71,6 +71,7 @@
Notify me when an installed app has an update 已安装应用有更新时通知我
Include pre-releases (betas and release candidates) 包含预发布版本(测试版和候选版)
Installing on Linux Linux 安装设置
Installing on Windows Windows 安装设置
Install new apps as 新应用安装格式
System package (recommended) 系统软件包(推荐)
Updates always use the format an app was installed with. 更新时始终使用应用原来的安装格式。
@@ -312,3 +313,20 @@ figcraft Interface design and prototyping 界面设计与原型制作
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} 已安装:{version}(此版本未发布校验和,因此只检查了文件大小)
@msg {name} was removed {name} 已卸载
@msg {name} is running. Quit it, then try again. {name} 正在运行。请先退出,然后重试。
Install folder 安装文件夹
Default install folder 默认安装文件夹
System default 系统默认
Browse… 浏览…
Use default 使用默认
New installs use this folder. Updates stay in their current location. 新安装的应用使用此文件夹。更新时保留当前位置。
Custom folders use AppImage on Linux. 在 Linux 上,自定义文件夹使用 AppImage。
The system manages install locations here. 此环境中的安装位置由系统管理。
@msg couldn't use install folder {path}: {reason} 无法使用安装文件夹 {path}:{reason}
@msg this release has no AppImage for a custom install folder 此版本没有可用于自定义安装文件夹的 AppImage
@msg custom install folders aren't supported on this system 此系统不支持自定义安装文件夹
@msg {path} already exists and wasn't installed by ArtCraft Launcher {path} 已存在,且并非由 ArtCraft Launcher 安装
@msg couldn't move {path} to Trash ({reason}). Remove it in Finder instead. 无法将 {path} 移到废纸篓({reason})。请在 Finder 中移除。
Apps are placed inside this folder. 应用会放置在此文件夹内。
Replace what's there? 替换现有内容?
{path} already exists. Installing {name} will overwrite it. {path} 已存在。安装 {name} 将覆盖它。
Replace 替换
Can't render this file because it contains an unexpected character in line 4 and column 3.
+2
View File
@@ -84,6 +84,7 @@ impl CheckEvery {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
pub install_locations: artcraft_launcher_core::protocol::InstallLocations,
pub view: ViewMode,
pub filter: Filter,
pub sort: SortKey,
@@ -169,6 +170,7 @@ pub fn default_preferred_kinds() -> Vec<PackageKind> {
impl Default for Settings {
fn default() -> Self {
Self {
install_locations: Default::default(),
view: ViewMode::Grid,
filter: Filter::All,
sort: SortKey::Suite,
+6 -6
View File
@@ -765,7 +765,7 @@ impl LauncherUi {
fmt(tl!("{done} downloaded"), &[("done", &widgets::human_bytes(p.done))])
};
if resp.on_hover_text(tip).clicked() {
self.send(Command::Install { app: id.clone() });
self.install_app(&id);
}
if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() {
self.send(Command::Cancel { app: id.clone() });
@@ -776,18 +776,18 @@ impl LauncherUi {
match self.primary_action(entry, status) {
Primary::Install => {
if widgets::button(ui, tl!("Install"), Kind::Primary, main_w).clicked() {
self.send(Command::Install { app: id.clone() });
self.install_app(&id);
}
}
Primary::Retry(label) => {
let tip = tl!("The download is still here. It's checked again, then installed.");
if widgets::button(ui, &label, Kind::Primary, main_w).on_hover_text(tip).clicked() {
self.send(Command::Install { app: id.clone() });
self.install_app(&id);
}
}
Primary::Update(v) => {
if widgets::button(ui, &fmt(tl!("Update to {v}"), &[("v", &v)]), Kind::Primary, main_w).clicked() {
self.send(Command::Install { app: id.clone() });
self.install_app(&id);
}
}
Primary::Open => {
@@ -825,7 +825,7 @@ impl LauncherUi {
manage && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable);
if can_download_again && ui.button(tl!("Download again")).clicked() {
self.send(Command::Cancel { app: id.clone() });
self.send(Command::Install { app: id.clone() });
self.install_app(&id);
ui.close();
}
if manage && status.is_installed() {
@@ -838,7 +838,7 @@ impl LauncherUi {
ui.close();
}
if *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() {
self.send(Command::Install { app: id.clone() });
self.install_app(&id);
ui.close();
}
ui.separator();
+213 -5
View File
@@ -93,6 +93,28 @@ impl Backend for Fake {
}
}
struct PlatformFake {
inner: Fake,
platform: Platform,
}
impl Backend for PlatformFake {
fn send(&mut self, command: Command) {
self.inner.send(command);
}
fn poll(&mut self) -> Vec<Event> {
self.inner.poll()
}
fn capabilities(&self) -> Capabilities {
self.inner.capabilities()
}
fn platform(&self) -> Option<Platform> {
Some(self.platform)
}
fn now(&self) -> i64 {
self.inner.now()
}
}
struct NoHost;
impl Host for NoHost {}
@@ -172,7 +194,7 @@ fn harness_with(
manage: bool,
scale: f32,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, Rc::default())
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, Rc::default(), None)
}
/// Like `harness_with`, with the details pictures answered as `pictures` says (changeable during the test).
@@ -184,7 +206,7 @@ fn harness_pictures(
scale: f32,
pictures: Rc<Cell<Pictures>>,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, pictures)
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, pictures, None)
}
fn harness_with_title_bar(
@@ -195,9 +217,10 @@ fn harness_with_title_bar(
scale: f32,
bar: artcraft_launcher_ui_egui::TitleBar,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
harness_full(events, view, theme, manage, scale, bar, Rc::default())
harness_full(events, view, theme, manage, scale, bar, Rc::default(), None)
}
#[allow(clippy::too_many_arguments)] // Test fixture inputs vary independently across existing cases.
fn harness_full(
events: Vec<Event>,
view: ViewMode,
@@ -206,6 +229,7 @@ fn harness_full(
scale: f32,
bar: artcraft_launcher_ui_egui::TitleBar,
pictures: Rc<Cell<Pictures>>,
platform: Option<Platform>,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
struct HeaderHost(artcraft_launcher_ui_egui::TitleBar);
impl Host for HeaderHost {
@@ -218,7 +242,11 @@ fn harness_full(
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events, sent: sent2, manage, pictures, asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
let fake = Fake { events, sent: sent2, manage, pictures, asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) };
let mut backend: Option<Box<dyn Backend>> = Some(match platform {
Some(platform) => Box::new(PlatformFake { inner: fake, platform }),
None => Box::new(fake),
});
let settings = Settings { view, notify: false, ..Settings::default() };
// No `.wgpu()`: it gives every test its own GPU device, and those segfault on parallel test threads (see above).
// The default renderer creates one only when a test renders.
@@ -233,7 +261,7 @@ fn harness_full(
} else {
artcraft_launcher_ui_egui::theme::ThemeChoice::Light
};
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s));
*state = Some(LauncherUi::new(ui.ctx(), b, s));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut HeaderHost(bar));
@@ -251,6 +279,161 @@ fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
fn folder_harness(picked: Rc<RefCell<Option<String>>>) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
struct Picker(Rc<RefCell<Option<String>>>);
impl Host for Picker {
fn pick_folder(&mut self, _initial: Option<&str>) -> Option<String> {
self.0.borrow_mut().take()
}
}
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let sent = Rc::new(RefCell::new(Vec::new()));
let mut backend = Some(Fake {
events: vec![
Event::Latest { app: "photocraft".into(), latest: LatestRelease::Found(release("photocraft", "0.6.0", NOW)), from: Origin::GitHub },
Event::Detected { app: "photocraft".into(), installed: None },
],
sent: sent.clone(),
manage: true,
pictures: Rc::default(),
asked: vec![],
clock: Rc::new(Cell::new(NOW)),
});
let mut host = Picker(picked);
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if let Some(backend) = backend.take() {
*state = Some(LauncherUi::new(ui.ctx(), Box::new(backend), Settings { notify: false, ..Default::default() }));
}
if let Some(launcher) = state {
launcher.logic(ui.ctx(), &mut host);
launcher.ui(ui, &mut host);
}
},
None,
);
(harness, sent)
}
fn install_commands(sent: &RefCell<Vec<Command>>) -> Vec<Command> {
sent.borrow().iter().filter(|c| !matches!(c, Command::CheckForUpdates { .. })).cloned().collect()
}
#[test]
fn a_fresh_install_confirms_the_folder_and_configures_before_installing() {
let picked = Rc::new(RefCell::new(None));
let (mut h, sent) = folder_harness(picked.clone());
h.run_steps(8);
h.get_by_label("Settings").click();
h.run_steps(4);
h.get_by_label("Browse…").scroll_to_me();
h.run_steps(4);
*picked.borrow_mut() = Some("/Volumes/Craft Apps".into());
h.get_by_label("Browse…").click();
h.run_steps(3);
assert_eq!(h.state().as_ref().unwrap().settings.install_locations.root.as_deref(), Some("/Volumes/Craft Apps"));
h.get_by_label("Done").click();
h.run_steps(3);
sent.borrow_mut().clear();
h.get_by_label("Install").click();
h.run_steps(4);
assert!(!sent.borrow().iter().any(|c| matches!(c, Command::Install { .. })));
h.get_by_label("Apps are placed inside this folder.");
snapshot(&mut h, "install-folder-confirmation");
*picked.borrow_mut() = Some("/Volumes/Photo".into());
h.get_by_label("Browse…").click();
h.run_steps(3);
assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());
h.get_by_label("Install").click();
h.run_steps(3);
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").click();
h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").type_text("/Volumes/Typed Photo");
h.run_steps(3);
h.get_by_label("Browse…").click();
h.run_steps(3);
assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Install folder").value().as_deref(), Some("/Volumes/Typed Photo"));
sent.borrow_mut().clear();
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
h.run_steps(2);
let commands = install_commands(&sent);
assert!(matches!(commands.first(), Some(Command::Configure(options))
if options.install_locations.root.as_deref() == Some("/Volumes/Craft Apps")
&& options.install_locations.folder("photocraft") == Some("/Volumes/Typed Photo")));
assert_eq!(commands.get(1), Some(&Command::Install { app: "photocraft".into() }));
}
#[test]
fn use_default_removes_an_app_override_and_settings_can_reset_the_root() {
let picked = Rc::new(RefCell::new(Some("/Volumes/Photo".into())));
let (mut h, sent) = folder_harness(picked);
h.run_steps(8);
h.state_mut().as_mut().unwrap().settings.install_locations.root = Some("/Volumes/Craft Apps".into());
h.state_mut().as_mut().unwrap().settings.install_locations.per_app.insert("photocraft".into(), "/Volumes/Old Photo".into());
h.run_steps(2);
h.get_by_label("Install").click();
h.run_steps(3);
h.get_by_label("Use default").click();
h.run_steps(2);
sent.borrow_mut().clear();
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
h.run_steps(2);
assert!(h.state().as_ref().unwrap().settings.install_locations.per_app.is_empty());
assert!(
matches!(install_commands(&sent).first(), Some(Command::Configure(options)) if options.install_locations.folder("photocraft") == Some("/Volumes/Craft Apps"))
);
h.get_by_label("Settings").click();
h.run_steps(3);
h.get_by_label("Use default").scroll_to_me();
h.run_steps(3);
h.get_by_label("Use default").click();
h.run_steps(2);
assert_eq!(h.state().as_ref().unwrap().settings.install_locations.root, None);
}
#[cfg(windows)]
#[test]
fn windows_package_choice_can_select_portable_and_configures_the_backend() {
let (mut h, sent) = harness_full(
events(),
ViewMode::Grid,
egui::Theme::Light,
true,
1.0,
artcraft_launcher_ui_egui::TitleBar::Native,
Rc::default(),
Some(Platform { os: Os::Windows, arch: Arch::X64 }),
);
h.run_steps(8);
h.get_by_label("Settings").click();
h.run_steps(4);
h.get_by_value("Installer (.msi)").click();
h.run_steps(2);
h.get_by_label("Portable (.zip)").click();
h.run_steps(3);
assert_eq!(h.state().as_ref().unwrap().settings.preferred_kinds, [artcraft_launcher_core::PackageKind::PortableZip]);
assert!(
sent.borrow()
.iter()
.any(|command| matches!(command, Command::Configure(options) if options.kinds == [artcraft_launcher_core::PackageKind::PortableZip]))
);
}
#[test]
fn install_locations_survive_settings_save_and_older_settings_use_system_default() {
let old: Settings = serde_json::from_str(r#"{"auto_update":true}"#).unwrap();
assert!(old.auto_update);
assert_eq!(old.install_locations, Default::default());
let mut settings = old;
settings.install_locations.root = Some("/Volumes/Craft Apps".into());
settings.install_locations.per_app.insert("photocraft".into(), "/Volumes/Photo".into());
let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(restored.install_locations.folder("photocraft"), Some("/Volumes/Photo"));
assert_eq!(restored.install_locations.folder("filmcraft"), Some("/Volumes/Craft Apps"));
}
#[test]
fn keeps_working_with_no_window_then_draws_in_a_new_one() {
let sent = Rc::new(RefCell::new(Vec::new()));
@@ -317,6 +500,8 @@ fn downloads_can_be_paused_resumed_and_discarded() {
h.get_by_label("Download paused");
h.get_by_label("Reconnecting…");
h.get_by_label_contains("· 25%").click();
h.run_steps(3);
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
h.run_steps(2);
assert!(sent.borrow().contains(&Command::Install { app: "deckcraft".into() }));
h.get_by_label("Discard download").click();
@@ -343,6 +528,9 @@ fn a_kept_download_offers_retry_install_and_download_again() {
let retry = h.get_by_label("Retry install");
let row = retry.rect();
retry.click();
h.run_steps(3);
assert!(sent.borrow().is_empty());
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
h.run_steps(2);
assert_eq!(*sent.borrow(), [Command::Install { app: "cadcraft".into() }]);
sent.borrow_mut().clear();
@@ -352,6 +540,9 @@ fn a_kept_download_offers_retry_install_and_download_again() {
h.run_steps(3);
snapshot(&mut h, "grid-retry-install-menu-dark");
h.get_by_label("Download again").click();
h.run_steps(3);
assert_eq!(*sent.borrow(), [Command::Cancel { app: "cadcraft".into() }]);
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Install").last().unwrap().click();
h.run_steps(2);
assert_eq!(*sent.borrow(), [Command::Cancel { app: "cadcraft".into() }, Command::Install { app: "cadcraft".into() }]);
// Apps without a kept download don't offer it.
@@ -1496,3 +1687,20 @@ fn with_animations_on_hovering_a_card_animates() {
let frames = frames_to_hover_the_next_card(&mut h);
assert!(frames >= 10, "{frames} frames");
}
#[test]
fn an_occupied_folder_asks_before_replacing_it() {
let path = "/Volumes/Craft Apps/PhotoCraft.app";
let mut ev = events();
ev.push(Event::Occupied { app: "photocraft".into(), path: path.into() });
ev.push(Event::JobFinished { app: "photocraft".into(), result: Err(format!("{path} already exists")) });
let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
h.run_steps(6);
h.get_by_label(&format!("{path} already exists. Installing PhotoCraft will overwrite it."));
assert!(h.query_by_label(&format!("{path} already exists")).is_none());
sent.borrow_mut().clear();
h.get_by_label("Replace").click();
h.run_steps(2);
assert_eq!(*sent.borrow(), [Command::Replace { app: "photocraft".into() }]);
assert!(h.query_by_label("Replace").is_none());
}