mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-06 18:34:09 +00:00
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:
27 files changed
+1811
-107
No files matched your search
Generated
+72
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,4 @@ plist = "1"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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("ed).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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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,
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user