diff --git a/README.md b/README.md index 70285d16..2b1d5cc9 100644 --- a/README.md +++ b/README.md @@ -93,25 +93,29 @@ The web version shows the same catalog in the browser, with the latest download The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French (Français), Georgian (ქართული), German (Deutsch), Hungarian (Magyar), Japanese (日本語), Polish -(Polski), Russian (Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the default, -follows the system or browser language; **Settings → Language** picks one and switches at once, and -the choice is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, Georgian ones -(`ka`, `ka-GE`, `ka_GE.UTF-8`…) Georgian, German ones (`de-DE`, `de-AT`, `de-CH`, `de_DE.UTF-8`…) -German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones (`hu`, `hu-HU`, `hu_HU.UTF-8`…) -Hungarian, Japanese ones (`ja`, `ja-JP`, `ja_JP.UTF-8`…) Japanese, Polish ones (`pl`, `pl-PL`, -`pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, `es-419`…) Spanish, and Portuguese ones -(`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. Simplified Chinese locales (`zh-CN`, -`zh-SG`, `zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) select Chinese; Traditional Chinese ones -don't. To try a language without changing the setting, start the desktop app with +(Polski), Russian (Русский), Simplified Chinese (简体中文), Traditional Chinese (繁體中文) or Spanish +(Español). **Auto**, the default, follows the system or browser language; **Settings → Language** +picks one and switches at once, and the choice is kept between runs. Czech locales (`cs-CZ`, +`cs_CZ.UTF-8`…) select Czech, Georgian ones (`ka`, `ka-GE`, `ka_GE.UTF-8`…) Georgian, German ones +(`de-DE`, `de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian +ones (`hu`, `hu-HU`, `hu_HU.UTF-8`…) Hungarian, Japanese ones (`ja`, `ja-JP`, `ja_JP.UTF-8`…) +Japanese, Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, +`es-419`…) Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. +Simplified Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) +select Simplified Chinese; Traditional Chinese ones (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant`, and POSIX +forms such as `zh_TW.UTF-8`) select Traditional Chinese, with an explicit script winning over the +region. To try a language without changing the setting, start the desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the web address. -The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS, Yu -Gothic, Meiryo or Microsoft YaHei on Windows, Noto Sans CJK, IPA, Takao or WenQuanYi on Linux and -FreeBSD). Where the system has no font for one, and in the web version, the interface stays in -English and Settings says why. Georgian is drawn the same way, with the system's Georgian font (SF -Georgian or Arial Unicode on macOS, Sylfaen on Windows, Noto Sans Georgian or DejaVu Sans on Linux -and FreeBSD). Product names and release notes stay as published; text without a translation falls -back to English. +The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS, +Yu Gothic, Meiryo or Microsoft YaHei on Windows, Noto Sans CJK, IPA, Takao or WenQuanYi on Linux and +FreeBSD); Traditional Chinese prefers an installed Noto Sans CJK TC, then PingFang TC, Microsoft +JhengHei or MingLiU. Georgian is drawn the same way, with the system's Georgian font (SF Georgian or +Arial Unicode on macOS, Sylfaen on Windows, Noto Sans Georgian or DejaVu Sans on Linux and FreeBSD). +Where the system has no font for one, and in the web version, the interface stays in English and +Settings says why. Product names and release notes stay as published; text without a translation +falls back to English. See [`docs/localization-zh-hant.md`](docs/localization-zh-hant.md) for the +Traditional Chinese catalog and font details. ## Where apps are installed diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs index 03876852..0e5a5be7 100644 --- a/crates/core/src/status.rs +++ b/crates/core/src/status.rs @@ -61,6 +61,23 @@ pub struct Installed { /// The package that installed it, for package-manager installs (`photocraft-bin`). #[serde(default)] pub package: Option, + /// Whether this copy is managed externally (such as a Flatpak from FlatPark) and + /// will not be replaced with a downloaded bundle. + #[serde(default)] + pub externally_managed: bool, + /// The remote repository or origin (`flatpark`, `-origin`), when known. + #[serde(default)] + pub origin: Option, + /// The installation scope or name (`user`, `system`, or a named installation), for Flatpak installs. + #[serde(default)] + pub installation: Option, +} + +impl Installed { + /// Whether this copy is managed externally and should not be replaced by a downloaded bundle. + pub fn is_externally_managed(&self) -> bool { + self.externally_managed + } } /// The status shown on an app's card or row. @@ -111,6 +128,8 @@ pub fn status(installed: Option<&Installed>, latest: Latest<'_>) -> AppStatus { } (Some(_), Latest::Pending | Latest::Failed | Latest::NoRelease) => AppStatus::Installed, (Some(inst), Latest::Found { version: latest, .. }) => match (latest, inst.version.as_ref()) { + // A repository-managed copy updates to what its repository has, which GitHub doesn't say. + (Some(l), Some(i)) if version::is_update(l, i) && inst.externally_managed => AppStatus::Installed, (Some(l), Some(i)) if version::is_update(l, i) => AppStatus::UpdateAvailable, (Some(_), Some(_)) => AppStatus::UpToDate, // Version unknown on one side: we can't promise "up to date". @@ -124,7 +143,15 @@ mod tests { use super::*; fn inst(v: &str) -> Installed { - Installed { version: version::parse(v), source: InstallSource::Launcher, location: String::new(), package: None } + Installed { + version: version::parse(v), + source: InstallSource::Launcher, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + } } #[test] @@ -141,6 +168,9 @@ mod tests { assert_eq!(status(Some(&inst("0.6.0")), found(true)), AppStatus::UpToDate); assert_eq!(status(Some(&inst("0.4.0")), Latest::Failed), AppStatus::Installed); assert_eq!(status(Some(&inst("garbage")), found(true)), AppStatus::Installed); + let repo = Installed { externally_managed: true, ..inst("0.4.0") }; + assert_eq!(status(Some(&repo), found(true)), AppStatus::Installed); + assert_eq!(status(Some(&Installed { externally_managed: true, ..inst("0.5.0") }), found(true)), AppStatus::UpToDate); assert!(AppStatus::UpdateAvailable.is_installed() && !AppStatus::Available.is_installed()); } } diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs index 9e6e58d0..d91a5ba8 100644 --- a/crates/native/src/backend.rs +++ b/crates/native/src/backend.rs @@ -516,6 +516,37 @@ fn run_install( approved: Option<&str>, source: Option<&dyn ReleaseSource>, ) -> Result { + if let Some(inst) = existing + && inst.source == artcraft_launcher_core::InstallSource::Flatpak + && inst.externally_managed + { + // `flatpak update` only follows the repository's latest build, never a pinned or nightly track. + if !matches!(options.versions.get(app.id), None | Some(VersionTrack::Stable)) { + return Err(install::managed_elsewhere(app, inst, false).unwrap_or_else(|| "Update this app through its Flatpak repository".into())); + } + #[cfg(target_os = "linux")] + { + shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Installing }); + let mut phase = |p| shared.emit(Event::Progress { app: app.id.to_string(), phase: p }); + let default_id = app.bundle_id(); + let target_id = inst.package.as_deref().unwrap_or(&default_id); + let scope = crate::packages::flatpak_scope(inst.installation.as_deref()); + let deployed_commit = || { + crate::process::capture_flatpak(&["info", &scope, "--show-commit", target_id], std::time::Duration::from_secs(15)) + .filter(|value| !value.trim().is_empty()) + }; + let before = deployed_commit(); + install::linux::update_flatpak_app(app, target_id, inst.installation.as_deref(), inst.origin.as_deref(), stop, &mut phase)?; + let after = deployed_commit(); + return Ok(match (before, after) { + (Some(before), Some(after)) if before.trim() == after.trim() => format!("{} is already up to date", app.name), + (Some(_), Some(_)) => format!("{} was updated through its Flatpak repository", app.name), + _ => format!("Flatpak finished checking {} for updates", app.name), + }); + } + #[cfg(not(target_os = "linux"))] + return Err(format!("{} is a Flatpak, which is not supported on this platform", app.name)); + } if let Some(msg) = existing.and_then(|e| install::managed_elsewhere(app, e, false)) { return Err(msg); } @@ -1305,7 +1336,15 @@ mod tests { #[test] fn a_downgrade_is_only_a_version_chosen_by_name_below_the_installed_one() { - let installed = Installed { version: version::parse("0.14.0"), source: InstallSource::MacApp, location: String::new(), package: None }; + let installed = Installed { + version: version::parse("0.14.0"), + source: InstallSource::MacApp, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let pinned = VersionTrack::Pinned("v0.13.0".into()); assert!(is_chosen_downgrade(&pinned, &tagged("0.13.0"), Some(&installed))); assert!(!is_chosen_downgrade(&VersionTrack::Stable, &tagged("0.13.0"), Some(&installed))); @@ -1317,7 +1356,15 @@ mod tests { #[test] fn only_a_version_chosen_by_name_below_the_installed_one_looks_for_a_package_to_put_back() { let app = CraftApp::by_id("filmcraft").unwrap(); - let installed = Installed { version: version::parse("0.14.0"), source: InstallSource::Msi, location: String::new(), package: None }; + let installed = Installed { + version: version::parse("0.14.0"), + source: InstallSource::Msi, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let pinned = VersionTrack::Pinned("v0.13.0".into()); let never = || -> Result, String> { panic!("no list is needed") }; // An update, the latest release, nothing installed, or a stale release: no downgrade. @@ -1702,6 +1749,9 @@ mod tests { source: artcraft_launcher_core::InstallSource::Msi, location: r"C:\Apps\PhotoCraft\photocraft.exe".into(), package: None, + externally_managed: false, + origin: None, + installation: None, }; assert_eq!(planned_asset(app, &release, platform, &portable_options, Some(&msi), None).unwrap().1, PackageKind::Msi); let launcher = Installed { @@ -1709,6 +1759,9 @@ mod tests { source: artcraft_launcher_core::InstallSource::Launcher, location: r"D:\Craft Apps\PhotoCraft\photocraft.exe".into(), package: None, + externally_managed: false, + origin: None, + installation: None, }; let record = crate::manifest::Record { version: "0.4.0".into(), @@ -1740,8 +1793,15 @@ mod tests { 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 existing = Installed { + version: None, + source: artcraft_launcher_core::InstallSource::Deb, + location: "/usr/bin/photocraft".into(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let system = BackendOptions { kinds: options.kinds.clone(), ..Default::default() }; assert_eq!( planned_asset(app, &release, platform, &options, Some(&existing), None), @@ -1941,7 +2001,15 @@ mod tests { } fn copy(v: &str) -> Installed { - Installed { version: version::parse(v), source: InstallSource::Deb, location: String::new(), package: None } + Installed { + version: version::parse(v), + source: InstallSource::Deb, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + } } #[test] diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index 2f17e1b8..2275f255 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -67,6 +67,9 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option { source: InstallSource::Launcher, location: rec.path.display().to_string(), package: None, + externally_managed: false, + origin: None, + installation: None, }) } @@ -194,7 +197,15 @@ pub mod macos { let full_key = format!("{}Version", app.name); let text = [full_key.as_str(), "CFBundleShortVersionString", "CFBundleVersion"].iter().find_map(|k| dict.get(k).and_then(plist::Value::as_string)); - Some(Installed { version: text.and_then(version::parse), source: InstallSource::MacApp, location: path.display().to_string(), package: None }) + Some(Installed { + version: text.and_then(version::parse), + source: InstallSource::MacApp, + location: path.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }) } } @@ -421,7 +432,15 @@ pub mod windows { pub fn detect(app: &CraftApp) -> Option { let m = msi_installs(app).into_iter().next()?; let location = m.exe.or_else(|| exe_in_any_scope(app, m.version.as_ref())).map_or_else(|| app.name.to_string(), |p| location_text(&p)); - Some(Installed { version: m.version, source: InstallSource::Msi, location, package: Some(m.product_code) }) + Some(Installed { + version: m.version, + source: InstallSource::Msi, + location, + package: Some(m.product_code), + externally_managed: false, + origin: None, + installation: None, + }) } } @@ -447,7 +466,14 @@ pub mod linux { let t = Duration::from_secs(10); let mut out = Vec::new(); let mut add = |manager, user, list: Vec<(String, String)>| { - out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version, user })); + out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { + manager, + name, + version, + user, + origin: None, + installation: None, + })); }; if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) { add(InstallSource::Deb, false, packages::parse_dpkg(&list)); @@ -468,25 +494,31 @@ pub mod linux { // named ones included (`--system` is only the default one). let mut user_ids = Vec::new(); for scope in [&["--user"][..], &[]] { - let list_args = [&["list", "--app"][..], scope, &[&arch, "--columns=application,version"]].concat(); + let list_args = [&["list", "--app"][..], scope, &[&arch, "--columns=application,version,origin,installation"]].concat(); if let Some(list) = crate::process::capture_flatpak(&list_args, t) { - let mut flatpaks = packages::parse_flatpak_list(&list); + let mut flatpaks = packages::parse_flatpak_list_with_origin(&list); // The unscoped list repeats the user installs: the user pass already has them. - flatpaks.retain(|(id, _)| !user_ids.contains(id)); + flatpaks.retain(|(id, _, _, _)| !user_ids.contains(id)); if !scope.is_empty() { - user_ids.extend(flatpaks.iter().map(|(id, _)| id.clone())); + user_ids.extend(flatpaks.iter().map(|(id, _, _, _)| id.clone())); } // Bundles built from a release tarball often list no version: ask Flatpak directly. - for (id, version) in flatpaks + for (id, version, _, installation) in flatpaks .iter_mut() - .filter(|(id, v)| v.is_empty() && artcraft_launcher_core::SUITE.iter().any(|a| a.bundle_ids().any(|b| b == *id))) + .filter(|(id, v, _, _)| v.is_empty() && artcraft_launcher_core::SUITE.iter().any(|a| a.bundle_ids().any(|b| b == *id))) { - let info_args = [&["info"][..], scope, &[&arch, id]].concat(); + let scope_arg = packages::flatpak_scope(if scope.is_empty() { installation.as_deref() } else { Some("user") }); + let info_args = ["info", &scope_arg, &arch, id]; if let Some(v) = crate::process::capture_flatpak(&info_args, t).as_deref().and_then(packages::parse_flatpak_info) { *version = v; } } - add(InstallSource::Flatpak, !scope.is_empty(), flatpaks); + let is_user = !scope.is_empty(); + out.extend(flatpaks.into_iter().filter(|(n, _, _, _)| ours(n)).map(|(name, version, origin, installation)| { + let user_install = is_user || installation.as_deref() == Some("user"); + let resolved_installation = installation.or_else(|| if user_install { Some("user".into()) } else { Some("system".into()) }); + Package { manager: InstallSource::Flatpak, name, version, user: user_install, origin, installation: resolved_installation } + })); } } out @@ -560,13 +592,38 @@ pub mod linux { /// (deb, rpm, pacman or Flatpak), else a binary found on disk. pub fn detect(app: &CraftApp, snapshot: &Snapshot) -> Option { if let Some((path, version)) = appimages(app).into_iter().next() { - return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None }); + return Some(Installed { + version, + source: InstallSource::AppImage, + location: path.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }); } let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]; if let Some(p) = packages::find(app, &snapshot.packages, &order) { // A Flatpak runs under the id it was found under, which may be an older one. let location = if p.manager == InstallSource::Flatpak { p.name.clone() } else { binary_on_path(app.id) }; - return Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) }); + let externally_managed = p.manager == InstallSource::Flatpak + && p.origin.as_deref().is_none_or(|origin| { + if packages::is_flatpark_origin(origin) { + return true; + } + let scope = packages::flatpak_scope(p.installation.as_deref()); + crate::process::capture_flatpak(&["remotes", &scope, "--show-disabled", "--columns=name,url"], Duration::from_secs(10)) + .is_none_or(|remotes| packages::remote_manages_flatpak(origin, &remotes)) + }); + return Some(Installed { + version: packages::version_of(p), + source: p.manager, + location, + package: Some(p.name.clone()), + externally_managed, + origin: p.origin.clone(), + installation: p.installation.clone(), + }); } detect_system_install(app) } @@ -577,7 +634,15 @@ pub mod linux { /// to whatever installed it, since it can't tell which package manager that was. fn detect_system_install(app: &CraftApp) -> Option { let bin = find_binary(app.id)?; - Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None }) + Some(Installed { + version: binary_version(&bin, app), + source: InstallSource::Binary, + location: bin.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }) } /// The app's binary on `PATH`, else in `/usr/bin`, `/usr/local/bin` or `~/.local/bin`. @@ -607,7 +672,15 @@ pub mod freebsd { if !bin.exists() { return None; } - Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None }) + Some(Installed { + version: binary_version(&bin, app), + source: InstallSource::Binary, + location: bin.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }) } /// ` --version` prints ` 0.5.0 (sha, date)`. diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 55cc7479..6049e1be 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -121,6 +121,11 @@ pub fn install_at( { return Err(format!("{} is running. Quit it, then try again.", app.name)); } + if kind == PackageKind::Flatpak + && let Some(installed) = existing.filter(|installed| installed.source == InstallSource::Flatpak && installed.externally_managed) + { + return Err(managed_elsewhere(app, installed, false).unwrap_or_else(|| "Update this app through its Flatpak repository".into())); + } bind_artifact_version(app, release, asset)?; // A feed can't send an app back to an older build; only a version the person chose by name // (a downgrade with a way back if it fails) gets past this. @@ -210,12 +215,15 @@ pub fn install_at( } #[cfg(target_os = "linux")] PackageKind::Deb | PackageKind::Rpm | PackageKind::ArchPkg | PackageKind::Flatpak => { - linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| { - if kind == PackageKind::Flatpak { - linux::retire_former_flatpak(app, existing); - } - None - }) + linux::install_package(kind, &file, existing.and_then(|installed| installed.installation.as_deref()), job.stop, &mut *job.phase).map( + |_| { + if kind == PackageKind::Flatpak { + linux::configure_bundle_lookaside(app, existing.and_then(|installed| installed.installation.as_deref())); + linux::retire_former_flatpak(app, existing); + } + None + }, + ) } #[cfg(target_os = "linux")] PackageKind::AppImage => { @@ -524,6 +532,9 @@ pub(crate) fn choose_kinds_with( // opens and removes them but never replaces them. InstallSource::Pacman if is_release_arch_package(app, inst) => Some(PackageKind::ArchPkg), InstallSource::Pacman => return Vec::new(), + // Repository-managed Flatpaks are externally managed: the launcher will not + // replace them with a downloaded local .flatpak bundle. + InstallSource::Flatpak if inst.externally_managed => return Vec::new(), InstallSource::Flatpak => Some(PackageKind::Flatpak), InstallSource::AppImage => Some(PackageKind::AppImage), InstallSource::Binary => Some(PackageKind::TarGz), @@ -625,6 +636,12 @@ fn managed_elsewhere_with(app: &CraftApp, installed: &Installed, uninstall: bool let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string()); Some(pacman_update_hint(app, &pkg, repo_fn(&pkg).as_deref())) } + InstallSource::Flatpak if !uninstall && installed.externally_managed => { + let default_id = app.bundle_id(); + let pkg = installed.package.as_deref().unwrap_or(&default_id); + let scope = crate::packages::flatpak_scope(installed.installation.as_deref()); + Some(format!("{} was installed from a Flatpak repository. Update it with flatpak update {scope} {pkg}", app.name)) + } InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)), _ => None, } @@ -1133,27 +1150,41 @@ pub(crate) mod linux { } /// Installs (or upgrades) a downloaded package with the system's own tools. - pub fn install_package(kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + pub fn install_package( + kind: PackageKind, + file: &Path, + installation: Option<&str>, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + ) -> Result<(), String> { if kind == PackageKind::Deb && system_tool("apt-get").is_none() { return Err("this system has no apt-get to install a Debian package with".into()); } if kind == PackageKind::ArchPkg && system_tool("pacman").is_none() { return Err("this system has no pacman to install an Arch package with".into()); } - install_package_via(&SYSTEM, kind, file, stop, phase) + install_package_via(&SYSTEM, kind, file, installation, stop, phase) } - pub fn install_package_via(root: &Root<'_>, kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { + pub fn install_package_via( + root: &Root<'_>, + kind: PackageKind, + file: &Path, + installation: Option<&str>, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + ) -> Result<(), String> { let f = file.as_os_str(); if let Some(command) = install_command(kind) { return as_root(root, command, f, stop, phase, JobPhase::Installing); } match kind { PackageKind::Flatpak => { + let scope = installation.map_or_else(|| "--user".to_string(), |name| crate::packages::flatpak_scope(Some(name))); let install_one = || { log::info!("executing flatpak install --reinstall --bundle for {}", file.display()); let output = process::flatpak_command() - .args(["install", "--user", "-y", "--noninteractive", "--reinstall", "--bundle"]) + .args(["install", &scope, "-y", "--noninteractive", "--reinstall", "--bundle"]) .arg(f) .output() .map_err(|e| format!("couldn't start Flatpak ({e})"))?; @@ -1213,6 +1244,82 @@ pub(crate) mod linux { } } + /// Configure signature verification for a bundle-created ArtCraft OCI origin. + pub fn configure_bundle_lookaside(app: &CraftApp, installation: Option<&str>) { + let scope = installation.map_or_else(|| "--user".to_string(), |name| crate::packages::flatpak_scope(Some(name))); + let id = app.bundle_id(); + let Some(origin) = process::capture_flatpak(&["info", &scope, "--show-origin", &id], std::time::Duration::from_secs(10)) else { return }; + let origin = origin.trim(); + if !origin.ends_with("-origin") { + return; + } + let Some(remotes) = + process::capture_flatpak(&["remotes", &scope, "--show-disabled", "--columns=name,url"], std::time::Duration::from_secs(10)) + else { + return; + }; + if !crate::packages::is_artcraft_remote(origin, &remotes) { + return; + } + let lookaside = format!("--signature-lookaside={}", crate::packages::ARTCRAFT_SIGNATURE_LOOKASIDE); + if process::capture_flatpak(&["remote-modify", &scope, &lookaside, origin], std::time::Duration::from_secs(10)).is_none() { + log::warn!("Could not configure the Flatpak signature lookaside for {origin}"); + } + } + + /// Updates an installed Flatpak from its remote repository (such as FlatPark). + pub fn update_flatpak_app( + app: &CraftApp, + id: &str, + installation: Option<&str>, + origin: Option<&str>, + stop: &Stop, + phase: &mut dyn FnMut(JobPhase), + ) -> Result<(), String> { + phase(JobPhase::Installing); + stop.check()?; + let scope_arg = crate::packages::flatpak_scope(installation); + // Let Flatpak's system helper authorize system updates through polkit. + // watch-bus also terminates the host command if the sandbox proxy exits. + let flatpak = |args: &[&str]| { + let mut cmd = if process::in_sandbox() { + let mut command = process::command("flatpak-spawn"); + command.args(["--host", "--watch-bus", "flatpak"]); + command + } else { + process::command("flatpak") + }; + cmd.args(args); + process::cancellable_output(cmd, || stop.check()) + }; + let update = ["update", &scope_arg, "-y", "--noninteractive", id]; + let mut output = flatpak(&update)?; + // A signed bundle's origin can't carry the ArtCraft OCI signature lookaside, so its first + // update from the repository finds no signature: add the lookaside once and retry. + if !output.status.success() + && let Some(origin) = origin + && let Some(remotes) = process::capture_flatpak(&["remotes", &scope_arg, "--columns=name,url"], std::time::Duration::from_secs(10)) + && crate::packages::needs_signature_lookaside(&String::from_utf8_lossy(&output.stderr), origin, &remotes) + { + let lookaside = format!("--signature-lookaside={}", crate::packages::ARTCRAFT_SIGNATURE_LOOKASIDE); + if flatpak(&["remote-modify", &scope_arg, &lookaside, origin])?.status.success() { + output = flatpak(&update)?; + } + } + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let err = stderr.trim(); + let err = if err.is_empty() { String::from_utf8_lossy(&output.stdout).trim().to_string() } else { err.to_string() }; + if err.is_empty() { + Err(format!("Flatpak couldn't update {}", app.name)) + } else { + let clean_err = err.strip_prefix("error: ").or_else(|| err.strip_prefix("Error: ")).unwrap_or(&err); + Err(format!("Flatpak couldn't update {}: {clean_err}", app.name)) + } + } + /// The older Flatpak id `app` was found under before an update, if it's one of its own /// ([`CraftApp::former_bundles`]) and not the current id. pub(crate) fn former_flatpak<'a>(app: &CraftApp, existing: Option<&'a Installed>) -> Option<&'a str> { @@ -2288,6 +2395,9 @@ mod tests { source: InstallSource::Msi, location: dir.path().join("MSI/PhotoCraft/photocraft.exe").display().to_string(), package: None, + externally_managed: false, + origin: None, + installation: 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")); @@ -2299,8 +2409,24 @@ mod tests { 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 launcher = Installed { + version: None, + source: InstallSource::Launcher, + location: path.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; + let users = Installed { + version: None, + source: InstallSource::AppImage, + location: "/home/me/AppImages/p.AppImage".into(), + package: None, + externally_managed: false, + origin: None, + installation: 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() }; @@ -2526,6 +2652,9 @@ mod tests { source: InstallSource::Launcher, location: dir.path().join("photocraft.exe").display().to_string(), package: None, + externally_managed: false, + origin: None, + installation: None, }; let mut phases = vec![]; let error = { @@ -2548,7 +2677,15 @@ mod tests { #[test] fn each_way_back_is_allowed_only_for_the_install_it_can_undo() { - let installed = |source| Installed { version: version::parse("0.5.0"), source, location: String::new(), package: None }; + let installed = |source| Installed { + version: version::parse("0.5.0"), + source, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let (mac, msi, portable) = (installed(InstallSource::MacApp), installed(InstallSource::Msi), installed(InstallSource::Launcher)); let (release, asset) = (Release::default(), Asset::default()); let reinstall = Some(Downgrade::Reinstall { previous: &release, asset: &asset }); @@ -2624,7 +2761,15 @@ mod tests { let release = Release { tag: "v0.3.0".into(), version: version::parse("0.3.0"), assets: vec![asset.clone()], ..Release::default() }; let paths = Paths::under(&dir.path().join(name)); let http = Http::new().unwrap(); - let installed = Installed { version: version::parse("0.4.0"), source, location: location.display().to_string(), package: None }; + let installed = Installed { + version: version::parse("0.4.0"), + source, + location: location.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let run = |downgrade| { let mut phase = |_: JobPhase| {}; let mut job = Job { @@ -3015,7 +3160,7 @@ mod tests { waiting.send(()).unwrap(); } }; - linux::install_package_via(root, PackageKind::Deb, Path::new(file), &Stop::default(), &mut phase) + linux::install_package_via(root, PackageKind::Deb, Path::new(file), None, &Stop::default(), &mut phase) }) }); waits.recv().unwrap(); @@ -3064,7 +3209,8 @@ mod tests { } let queue = crate::queue::Queue::new(); let root = linux::Root { queue: &queue, run: &|_| Ok(()) }; - let result = linux::install_package_via(&root, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), &Stop::default(), &mut |_| ()); + let result = + linux::install_package_via(&root, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), None, &Stop::default(), &mut |_| ()); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.starts_with("Flatpak couldn't install the bundle: "), "unexpected error: {err}"); @@ -3143,7 +3289,15 @@ mod tests { #[test] fn updates_keep_the_installed_format() { - let inst = |source| Installed { version: None, source, location: String::new(), package: None }; + let inst = |source| Installed { + version: None, + source, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("photocraft").unwrap(); let available = available_kinds(); if let Some(first) = available.first() { @@ -3160,7 +3314,15 @@ mod tests { #[test] fn only_the_release_arch_package_is_updated_by_the_launcher() { - let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let pacman = |name: &str| Installed { + version: None, + source: InstallSource::Pacman, + location: String::new(), + package: Some(name.into()), + externally_managed: false, + origin: None, + installation: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("wordcraft").unwrap(); assert!(is_release_arch_package(app, &pacman("ai.storyteller.wordcraft"))); for aur in ["wordcraft", "wordcraft-bin", "wordcraft-git"] { @@ -3188,7 +3350,15 @@ mod tests { #[test] fn aur_packages_are_not_updated_as_arch_pkg() { - let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let pacman = |name: &str| Installed { + version: None, + source: InstallSource::Pacman, + location: String::new(), + package: Some(name.into()), + externally_managed: false, + origin: None, + installation: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); let installed = pacman("filmcraft-bin"); assert!(managed_elsewhere_with(app, &installed, false, |_| None).is_some()); @@ -3210,7 +3380,15 @@ mod tests { #[test] fn only_an_apps_own_older_flatpak_id_is_retired() { use super::linux::former_flatpak; - let flatpak = |id: &str| Installed { version: None, source: InstallSource::Flatpak, location: id.into(), package: Some(id.into()) }; + let flatpak = |id: &str| Installed { + version: None, + source: InstallSource::Flatpak, + location: id.into(), + package: Some(id.into()), + externally_managed: false, + origin: None, + installation: None, + }; let sc = artcraft_launcher_core::CraftApp::by_id("solidcraft").unwrap(); let old = flatpak("ai.storyteller.solvecraft"); assert_eq!(former_flatpak(sc, Some(&old)), Some("ai.storyteller.solvecraft")); @@ -3223,7 +3401,15 @@ mod tests { #[test] fn release_arch_packages_are_updated_as_arch_pkg() { - let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) }; + let pacman = |name: &str| Installed { + version: None, + source: InstallSource::Pacman, + location: String::new(), + package: Some(name.into()), + externally_managed: false, + origin: None, + installation: None, + }; let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); let installed = pacman("ai.storyteller.filmcraft"); assert_eq!(managed_elsewhere_with(app, &installed, false, |_| None), None); @@ -3231,6 +3417,68 @@ mod tests { assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[PackageKind::ArchPkg], false), vec![PackageKind::ArchPkg]); } + #[test] + fn flatpark_flatpaks_are_externally_managed_and_not_updated_via_bundle() { + let app = artcraft_launcher_core::CraftApp::by_id("photocraft").unwrap(); + let flatpark_installed = Installed { + version: version::parse("0.19.0"), + source: InstallSource::Flatpak, + location: "ai.storyteller.photocraft".into(), + package: Some("ai.storyteller.photocraft".into()), + externally_managed: true, + origin: Some("flatpark".into()), + installation: Some("user".into()), + }; + assert!(flatpark_installed.is_externally_managed()); + assert_eq!(choose_kinds_with(app, Some(&flatpark_installed), None, &[], &[PackageKind::Flatpak], false), Vec::::new()); + let hint = managed_elsewhere_with(app, &flatpark_installed, false, |_| None); + assert_eq!( + hint.as_deref(), + Some("PhotoCraft was installed from a Flatpak repository. Update it with flatpak update --user ai.storyteller.photocraft") + ); + // During uninstall, managed_elsewhere returns None so it can be removed + assert_eq!(managed_elsewhere_with(app, &flatpark_installed, true, |_| None), None); + + // When installed under a former bundle ID, the hint targets that detected ID + let former_installed = Installed { + version: version::parse("0.19.0"), + source: InstallSource::Flatpak, + location: "ai.storyteller.photocraft-old".into(), + package: Some("ai.storyteller.photocraft-old".into()), + externally_managed: true, + origin: Some("flatpark".into()), + installation: Some("system".into()), + }; + let hint_former = managed_elsewhere_with(app, &former_installed, false, |_| None); + assert_eq!( + hint_former.as_deref(), + Some("PhotoCraft was installed from a Flatpak repository. Update it with flatpak update --system ai.storyteller.photocraft-old") + ); + + let bundle_installed = Installed { + version: version::parse("0.19.0"), + source: InstallSource::Flatpak, + location: "ai.storyteller.photocraft".into(), + package: Some("ai.storyteller.photocraft".into()), + externally_managed: false, + origin: Some("photocraft-origin".into()), + installation: Some("user".into()), + }; + assert!(!bundle_installed.is_externally_managed()); + assert_eq!(choose_kinds_with(app, Some(&bundle_installed), None, &[], &[PackageKind::Flatpak], false), vec![PackageKind::Flatpak]); + assert_eq!(managed_elsewhere_with(app, &bundle_installed, false, |_| None), None); + } + + #[test] + #[cfg(target_os = "linux")] + fn update_flatpak_app_respects_cancellation() { + let app = artcraft_launcher_core::CraftApp::by_id("photocraft").unwrap(); + let stop = Stop::default(); + stop.cancel(); + let mut phase = |_| (); + assert_eq!(linux::update_flatpak_app(app, "ai.storyteller.photocraft", Some("user"), None, &stop, &mut phase), Err(CANCELLED.into())); + } + #[test] fn unavailable_arch_package_not_selected_for_new_install() { let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap(); @@ -3254,6 +3502,9 @@ mod tests { source: InstallSource::Launcher, location: dir.path().join("PhotoCraft.AppImage").display().to_string(), package: None, + externally_managed: false, + origin: None, + installation: None, }; let stop = Stop::default(); let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false, downgrade: None }; @@ -3481,7 +3732,15 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let apps = dir.path().join("Applications"); let current = bundle(&apps, "0.5.0"); - let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None }; + let existing = Installed { + version: None, + source: InstallSource::MacApp, + location: current.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let paths = Paths::under(dir.path()); let image = dmg(dir.path(), "0.6.0"); @@ -3514,7 +3773,15 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let apps = dir.path().join("Applications"); let current = bundle(&apps, "0.5.0"); - let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None }; + let existing = Installed { + version: None, + source: InstallSource::MacApp, + location: current.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let image = dmg(dir.path(), "0.6.0"); let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() }; diff --git a/crates/native/src/install/macos_verify.rs b/crates/native/src/install/macos_verify.rs index 95cbc947..eeb2e0c6 100644 --- a/crates/native/src/install/macos_verify.rs +++ b/crates/native/src/install/macos_verify.rs @@ -216,7 +216,15 @@ mod tests { std::fs::write(&old, b"old fixture bytes").unwrap(); std::fs::write(&manifest, b"old install record").unwrap(); std::fs::write(&download, b"cached download").unwrap(); - let found = Installed { version: None, source: InstallSource::MacApp, location: staged.display().to_string(), package: None }; + let found = Installed { + version: None, + source: InstallSource::MacApp, + location: staged.display().to_string(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }; let expect = Expect { team: app.mac_team, version: None }; // Injected tool outcomes exercise the real pre-swap boundary, not real signatures. for failed in 0..4 { diff --git a/crates/native/src/packages.rs b/crates/native/src/packages.rs index 65464c14..ace37628 100644 --- a/crates/native/src/packages.rs +++ b/crates/native/src/packages.rs @@ -15,6 +15,10 @@ pub struct Package { pub version: String, /// A per-user Flatpak install (`flatpak --user`), which the launcher installs and updates. pub user: bool, + /// The origin remote (`flatpark`, `-origin`, `flathub`), when known. + pub origin: Option, + /// The installation scope or name (`user`, `system`, or a named installation), for Flatpak installs. + pub installation: Option, } /// `dpkg-query -W -f '${Package}|${db:Status-Status}|${Version}\n'`: installed packages only. @@ -48,18 +52,48 @@ pub fn parse_pacman(text: &str) -> Vec<(String, String)> { .collect() } -/// `flatpak list --app --columns=application,version`: the version column is often empty for -/// bundles built from a release tarball. -pub fn parse_flatpak_list(text: &str) -> Vec<(String, String)> { +/// `flatpak list --app --columns=application,version,origin,installation`: parses id, version, origin, and installation. +pub fn parse_flatpak_list_with_origin(text: &str) -> Vec<(String, String, Option, Option)> { text.lines() .filter_map(|line| { let mut f = line.split('\t'); let id = f.next()?.trim(); - (!id.is_empty()).then(|| (id.to_string(), f.next().unwrap_or("").trim().to_string())) + if id.is_empty() { + return None; + } + let version = f.next().unwrap_or("").trim().to_string(); + let origin = f.next().map(str::trim).filter(|o| !o.is_empty()).map(ToString::to_string); + // A named installation lists as `system ()`, but `--installation=` takes the bare name. + let installation = f + .next() + .map(str::trim) + .filter(|i| !i.is_empty()) + .map(|i| i.strip_prefix("system (").and_then(|n| n.strip_suffix(')')).unwrap_or(i).to_string()); + Some((id.to_string(), version, origin, installation)) }) .collect() } +/// `flatpak list --app --columns=application,version`: the version column is often empty for +/// bundles built from a release tarball. +pub fn parse_flatpak_list(text: &str) -> Vec<(String, String)> { + parse_flatpak_list_with_origin(text).into_iter().map(|(id, v, _, _)| (id, v)).collect() +} + +/// Whether an origin string indicates an install from FlatPark. +pub fn is_flatpark_origin(origin: &str) -> bool { + origin.trim().eq_ignore_ascii_case("flatpark") +} + +/// The `flatpak` argument that selects an installation (`user`, `system`, or a named one). +pub fn flatpak_scope(installation: Option<&str>) -> String { + match installation { + Some("user") => "--user".into(), + Some("system") | None => "--system".into(), + Some(name) => format!("--installation={name}"), + } +} + /// The `Version:` line of `flatpak info `. pub fn parse_flatpak_info(text: &str) -> Option { text.lines().find_map(|l| l.trim().strip_prefix("Version:").map(|v| v.trim().to_string())).filter(|v| !v.is_empty()) @@ -101,7 +135,7 @@ mod tests { use super::*; fn pkgs(manager: InstallSource, list: Vec<(String, String)>) -> Vec { - list.into_iter().map(|(name, version)| Package { manager, name, version, user: false }).collect() + list.into_iter().map(|(name, version)| Package { manager, name, version, user: false, origin: None, installation: None }).collect() } #[test] @@ -137,10 +171,24 @@ mod tests { fn a_user_flatpak_beats_a_system_one_under_any_id() { let sc = CraftApp::by_id("solidcraft").unwrap(); let mut installed = pkgs(InstallSource::Flatpak, parse_flatpak_list("io.tangencylabs.solidcraft\t0.25.1\n")); - installed.push(Package { manager: InstallSource::Flatpak, name: "ai.storyteller.solidcraft".into(), version: "0.26.0".into(), user: true }); + installed.push(Package { + manager: InstallSource::Flatpak, + name: "ai.storyteller.solidcraft".into(), + version: "0.26.0".into(), + user: true, + origin: None, + installation: None, + }); assert_eq!(find(sc, &installed, &[InstallSource::Flatpak]).map(|p| p.name.as_str()), Some("ai.storyteller.solidcraft")); // Within the user scope the current id still wins. - installed.push(Package { manager: InstallSource::Flatpak, name: "io.tangencylabs.solidcraft".into(), version: "0.26.1".into(), user: true }); + installed.push(Package { + manager: InstallSource::Flatpak, + name: "io.tangencylabs.solidcraft".into(), + version: "0.26.1".into(), + user: true, + origin: None, + installation: None, + }); assert_eq!(find(sc, &installed, &[InstallSource::Flatpak]).map(|p| (p.name.as_str(), p.user)), Some(("io.tangencylabs.solidcraft", true))); } @@ -186,4 +234,114 @@ mod tests { assert_eq!(version_of(hit).unwrap().to_string(), "0.5.0"); assert_eq!(find(photo, &installed, &[InstallSource::Flatpak]).unwrap().version, "0.4.0"); } + + #[test] + fn parses_flatpak_list_with_origin_and_spots_flatpark() { + let text = "ai.storyteller.photocraft\t0.19.0\tflatpark\tuser\nai.storyteller.cadcraft\t0.16.0\tcadcraft-origin\tsystem\nai.storyteller.wordcraft\t0.14.0\t\t\nai.storyteller.filmcraft\t0.3.0\tflatpark\tsystem (extra)\n"; + let list = parse_flatpak_list_with_origin(text); + assert_eq!( + list, + [ + ("ai.storyteller.photocraft".into(), "0.19.0".into(), Some("flatpark".into()), Some("user".into())), + ("ai.storyteller.cadcraft".into(), "0.16.0".into(), Some("cadcraft-origin".into()), Some("system".into())), + ("ai.storyteller.wordcraft".into(), "0.14.0".into(), None, None), + ("ai.storyteller.filmcraft".into(), "0.3.0".into(), Some("flatpark".into()), Some("extra".into())), + ] + ); + assert_eq!(flatpak_scope(Some("extra")), "--installation=extra"); + assert_eq!(flatpak_scope(Some("user")), "--user"); + assert_eq!(flatpak_scope(None), "--system"); + assert!(is_flatpark_origin("flatpark")); + assert!(is_flatpark_origin("FlatPark")); + assert!(!is_flatpark_origin("flatpark-origin")); + assert!(!is_flatpark_origin("cadcraft-origin")); + assert!(!is_flatpark_origin("flathub")); + assert!(!is_flatpark_origin("")); + } +} + +/// Matches the OCI URL from https://getartcraft.com/artcraft-oci.flatpakrepo, +/// independently of its local remote name (normally `artcraft-oci`). +pub fn is_artcraft_remote(origin: &str, remotes: &str) -> bool { + remotes.lines().any(|line| { + let Some((name, url)) = line.split_once('\t') else { return false }; + let url = url.trim().trim_end_matches('/'); + name.trim() == origin && url == "oci+https://getartcraft.com" + }) +} + +/// Where Flatpak finds the ArtCraft OCI images' signatures (artcraft-oci.flatpakrepo's +/// `SignatureLookaside`), redirected to each release's `.oci.sig` asset. +pub const ARTCRAFT_SIGNATURE_LOOKASIDE: &str = "https://getartcraft.com/signatures"; + +/// Did an update fail only because `origin`, an ArtCraft OCI remote, has no signature lookaside +/// (a signed bundle's origin, which `flatpak build-bundle` can't give one)? +pub fn needs_signature_lookaside(stderr: &str, origin: &str, remotes: &str) -> bool { + stderr.contains(&format!("{origin}: no valid signatures for ")) && is_artcraft_remote(origin, remotes) +} + +#[cfg(test)] +mod remote_url_tests { + use super::*; + #[test] + fn recognizes_custom_names_and_rejects_unrelated_urls() { + assert!(is_artcraft_remote("artcraft-oci", "artcraft-oci\toci+https://getartcraft.com\n")); + assert!(is_artcraft_remote("custom", "custom\toci+https://getartcraft.com/\n")); + for url in [ + "oci+https://getartcraft.com.evil", + "oci+https://getartcraft.com/other", + "oci+http://getartcraft.com", + "https://getartcraft.com/linux", + "https://getartcraft.com/artcraft-oci.flatpakrepo", + "https://getartcraft.com", + ] { + assert!(!is_artcraft_remote("custom", &format!("custom\t{url}"))); + } + assert!(!is_artcraft_remote("other", "custom\toci+https://getartcraft.com")); + } + + #[test] + fn only_an_artcraft_origin_missing_its_signatures_gets_the_lookaside() { + // Flatpak 1.18's error for a verified OCI remote with no lookaside. + let stderr = "error: Failed to update ai.storyteller.photocraft: photocraft-origin: no valid signatures for \ + ghcr.io/storytold/photocraft@sha256:968d75c9d16d5f7d089775f14a3b418d72a4c70d86986811ee2dd818da956b92"; + let remotes = "photocraft-origin\toci+https://getartcraft.com\nflathub\thttps://dl.flathub.org/repo/\n"; + assert!(needs_signature_lookaside(stderr, "photocraft-origin", remotes)); + // Another remote, another origin's failure, or another error: leave the remote alone. + assert!(!needs_signature_lookaside(stderr, "photocraft-origin", "photocraft-origin\thttps://example.org\n")); + assert!(!needs_signature_lookaside(stderr, "flathub", remotes)); + assert!(!needs_signature_lookaside("error: Server returned status 404", "photocraft-origin", remotes)); + } +} + +/// A bundle origin permits replacement when its URL is empty or points at ArtCraft OCI. +/// Missing rows are unknown, so preserve remote ownership conservatively. +pub fn remote_manages_flatpak(origin: &str, remotes: &str) -> bool { + if origin.ends_with("-origin") && is_artcraft_remote(origin, remotes) { + return false; + } + remotes + .lines() + .find_map(|line| { + let mut fields = line.split('\t'); + if fields.next().map(str::trim) != Some(origin) { + return None; + } + Some(fields.next().is_some_and(|url| !url.trim().is_empty())) + }) + .unwrap_or(true) +} + +#[cfg(test)] +mod remote_ownership_tests { + use super::*; + #[test] + fn only_confirmed_empty_urls_allow_bundle_replacement() { + assert!(!remote_manages_flatpak("app-origin", "app-origin\t\n")); + assert!(remote_manages_flatpak("renamed", "renamed\thttps://example.org/repo\n")); + assert!(remote_manages_flatpak("renamed", "renamed\toci+https://getartcraft.com\n")); + assert!(!remote_manages_flatpak("app-origin", "app-origin\toci+https://getartcraft.com\n")); + assert!(remote_manages_flatpak("missing", "other\t\n")); + assert!(!remote_manages_flatpak("app-origin", "app-origin")); + } } diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs index a8c29744..2f653022 100644 --- a/crates/native/src/process.rs +++ b/crates/native/src/process.rs @@ -790,3 +790,68 @@ mod inherited_pipe_tests { } } } + +/// Runs a helper while polling cancellation and draining its bounded output. +#[cfg(target_os = "linux")] +pub(crate) fn cancellable_output(mut cmd: Command, check: impl Fn() -> Result<(), String>) -> Result { + check()?; + let mut child = + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run flatpak ({e})"))?; + let (stdout, stderr) = (read_all(child.stdout.take()), read_all(child.stderr.take())); + let started = Instant::now(); + let status = loop { + if started.elapsed() >= Duration::from_secs(60 * 60) { + let _ = child.kill(); + let _ = child.wait(); + return Err("Flatpak did not finish within one hour".into()); + } + if let Err(e) = check() { + let _ = child.kill(); + let _ = child.wait(); + return Err(e); + } + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("couldn't wait for flatpak ({e})")); + } + } + }; + let deadline = Instant::now(); + while !stdout.is_finished() || !stderr.is_finished() { + check()?; + if deadline.elapsed() > Duration::from_secs(5) { + return Err("Flatpak output did not close after completion".into()); + } + std::thread::sleep(Duration::from_millis(25)); + } + Ok(std::process::Output { status, stdout: stdout.join().unwrap_or_default(), stderr: stderr.join().unwrap_or_default() }) +} + +#[cfg(all(test, target_os = "linux"))] +mod update_cancel_tests { + use super::*; + + #[test] + fn cancels_a_running_helper_promptly() { + let mut cmd = command("sleep"); + cmd.arg("30"); + let start = Instant::now(); + let result = cancellable_output(cmd, || if start.elapsed() >= Duration::from_millis(100) { Err("cancelled".into()) } else { Ok(()) }); + assert_eq!(result.unwrap_err(), "cancelled"); + assert!(start.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn retains_both_output_streams_and_exit_status() { + let mut cmd = command("sh"); + cmd.args(["-c", "printf output; printf error >&2; exit 7"]); + let output = cancellable_output(cmd, || Ok(())).unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(output.stdout, b"output"); + assert_eq!(output.stderr, b"error"); + } +} diff --git a/crates/native/tests/windows_msi_context.rs b/crates/native/tests/windows_msi_context.rs index bcda3e4c..f41938fe 100644 --- a/crates/native/tests/windows_msi_context.rs +++ b/crates/native/tests/windows_msi_context.rs @@ -27,6 +27,9 @@ fn uncertain_msi_updates_do_not_download_or_start_an_installer() { source: InstallSource::Msi, location: root.path().join("not-running-context-regression.exe").display().to_string(), package: code.map(str::to_owned), + externally_managed: false, + origin: None, + installation: None, }; let mut phases = Vec::new(); let mut phase = |phase| phases.push(phase); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f6ee3b01..d906aee9 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -205,6 +205,9 @@ pub struct LauncherUi { /// Toasts created from logic (which may run with an old input time) or the UI. Their expiry is /// fixed when the next `ui` pass actually shows them. pending_toasts: Vec<(String, bool)>, + /// While the user drags a pin handle: dragged catalog id and the pinned order at drag start + /// (so Escape can restore it after live mid-drag reorders). + pub(crate) pin_dragging: Option<(String, Vec)>, } /// The language to draw in: the preference, or English where it needs a CJK font the system @@ -294,6 +297,7 @@ impl LauncherUi { frame: None, flushed: Vec::new(), pending_toasts: Vec::new(), + pin_dragging: None, } } @@ -844,6 +848,8 @@ impl LauncherUi { ViewMode::Table => egui::ScrollArea::both(), }; scroll.auto_shrink(false).show(ui, |ui| { + // Escape must run even when search/filter leaves no rows (grid/table skipped). + self.clear_pin_drag_on_escape(ui); if ids.is_empty() { ui.add_space(40.0); ui.vertical_centered(|ui| { @@ -856,6 +862,10 @@ impl LauncherUi { } } ui.add_space(16.0); + // The drop targets have seen this frame's pointer: a released drag ends here. + if !ui.input(|i| i.pointer.primary_down()) { + self.pin_dragging = None; + } }); }); self.details_drawer(&ctx); @@ -867,7 +877,28 @@ impl LauncherUi { } } - /// The ids to show, filtered and sorted. + /// Test hook for the filtered/sorted catalog ids currently shown. + pub fn visible_ids_for_test(&self) -> Vec<&'static str> { + self.visible_ids() + } + + /// Test hook: start a pin drag as if the grip had just been grabbed (stores a restore snapshot). + pub fn begin_pin_drag_for_test(&mut self, id: &str) { + self.pin_dragging = Some((id.to_string(), self.settings.pinned.clone())); + } + + /// Test hook: whether a pin reorder drag is in progress. + pub fn pin_dragging_for_test(&self) -> bool { + self.pin_dragging.is_some() + } + + /// Test hook: set the toolbar search string without driving the text field. + pub fn set_search_for_test(&mut self, needle: &str) { + self.search = needle.to_string(); + } + + /// The ids to show, filtered and sorted. Pinned apps stay first in the user's pin order; + /// the active sort (and the grid's installed / not-installed split) applies only to the rest. pub(crate) fn visible_ids(&self) -> Vec<&'static str> { let needle = self.search.trim().to_lowercase(); let mut list: Vec<(usize, &AppEntry)> = self @@ -895,7 +926,10 @@ impl LauncherUi { }; let installed = |e: &AppEntry| e.installed.as_ref().and_then(|i| i.version.clone()); let latest = |e: &AppEntry| e.release().and_then(|r| r.version.clone()); - list.sort_by(|(ia, a), (ib, b)| { + let (mut pinned, mut rest) = list.into_iter().partition::, _>(|(_, e)| self.settings.is_pinned(e.app.id)); + // Keep the user's pin order, skipping ids that are not in the filtered set. + pinned.sort_by_key(|(_, e)| self.settings.pinned.iter().position(|p| p == e.app.id).unwrap_or(usize::MAX)); + rest.sort_by(|(ia, a), (ib, b)| { let ord = match sort { SortKey::Suite => dir(ia.cmp(ib)), SortKey::Name => dir(a.app.name.cmp(b.app.name)), @@ -923,11 +957,13 @@ impl LauncherUi { // Ties keep suite order. ord.then(ia.cmp(ib)) }); - // The grid shows installed apps first, then the rest under "Not installed" (the sort holds - // within each group; the sort is stable). The table keeps the order its column gives. + // The grid shows installed apps first among the unpinned set, then the rest under + // "Not installed" (the sort holds within each group; the sort is stable). if grid { - list.sort_by_key(|(_, e)| !e.status(platform, manage).is_installed()); + rest.sort_by_key(|(_, e)| !e.status(platform, manage).is_installed()); } + list = pinned; + list.append(&mut rest); list.into_iter().map(|(_, e)| e.app.id).collect() } diff --git a/crates/ui-egui/src/card_parts.rs b/crates/ui-egui/src/card_parts.rs index 4fc3354d..e9cf97cb 100644 --- a/crates/ui-egui/src/card_parts.rs +++ b/crates/ui-egui/src/card_parts.rs @@ -57,7 +57,15 @@ mod tests { fn entry(installed: &str, latest: &str) -> AppEntry { let mut e = AppEntry::new(CraftApp::by_id("photocraft").unwrap()); - e.installed = Some(Installed { version: version::parse(installed), source: InstallSource::Msi, location: String::new(), package: None }); + e.installed = Some(Installed { + version: version::parse(installed), + source: InstallSource::Msi, + location: String::new(), + package: None, + externally_managed: false, + origin: None, + installation: None, + }); e.latest = Some(LatestRelease::Found(Release { tag: format!("v{latest}"), version: version::parse(latest), ..Default::default() })); e } diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs index e495cbd9..b42fe87f 100644 --- a/crates/ui-egui/src/cjk_font.rs +++ b/crates/ui-egui/src/cjk_font.rs @@ -44,6 +44,7 @@ fn face_metrics(data: &[u8], index: u32) -> (f32, f32) { fn script_name(script: Script) -> &'static str { match script { Script::Chinese => "Chinese", + Script::TraditionalChinese => "Traditional Chinese", Script::Japanese => "Japanese", Script::Georgian => "Georgian", } @@ -53,6 +54,7 @@ fn script_name(script: Script) -> &'static str { fn script_families(script: Script) -> &'static str { match script { Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC, WenQuanYi and MiSans", + Script::TraditionalChinese => "Noto Sans CJK TC, Noto Sans TC, PingFang TC, Heiti TC, Microsoft JhengHei and MingLiU", Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic", Script::Georgian => "SF Georgian, Arial Unicode MS, Sylfaen, Noto Sans Georgian and DejaVu Sans", } @@ -149,6 +151,7 @@ fn chars_eq_ignore_ascii_case(mut chars: impl Iterator, wanted: &st fn candidates(script: Script) -> Vec { let found = match script { Script::Chinese => chinese_candidates(), + Script::TraditionalChinese => traditional_chinese_candidates(), Script::Japanese => japanese_candidates(), Script::Georgian => georgian_candidates(), }; @@ -277,6 +280,72 @@ fn chinese_candidates() -> Vec { out } +/// The Noto files that carry a Traditional Chinese face, by family: the merged CJK collection, the +/// CJK TC-only OTF, the variable TC fonts, and the standalone Noto Sans TC. +const NOTO_TC_FILES: [(&str, &str); 7] = [ + ("NotoSansCJKtc-Regular.otf", "Noto Sans CJK TC"), + ("NotoSansCJK-Regular.ttc", "Noto Sans CJK TC"), + ("NotoSansCJK-VF.ttc", "Noto Sans CJK TC"), + ("NotoSansCJKtc-VF.otf", "Noto Sans CJK TC"), + ("NotoSansCJKtc-VF.ttf", "Noto Sans CJK TC"), + ("NotoSansTC-Regular.otf", "Noto Sans TC"), + ("NotoSansTC-VF.ttf", "Noto Sans TC"), +]; + +/// Where each system keeps a Traditional Chinese font: an installed Noto CJK TC first (the face +/// GridCraft prefers for Taiwan), then the system's own faces. +fn traditional_chinese_candidates() -> Vec { + let mut out = Vec::new(); + let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family }); + // A Noto installed by hand or by the system's font manager. The `NotoSansCJKtc` subdirectory + // lets a user keep an updated file apart from an older installed face. + for dir in user_font_dirs() { + for (file, family) in NOTO_TC_FILES { + // The override subdirectory is checked first, so an updated file wins over the older + // face at the root. + add(dir.join("NotoSansCJKtc").join(file), family); + add(dir.join(file), family); + } + } + #[cfg(all(unix, not(target_os = "macos")))] + { + // Debian/Ubuntu, Arch, Fedora (static and variable), openSUSE and FreeBSD's ports. + const NOTO_DIRS: [&str; 10] = [ + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/noto-cjk", + "/usr/share/fonts/google-noto-cjk", + "/usr/share/fonts/google-noto-sans-cjk-fonts", + "/usr/share/fonts/google-noto-sans-cjk-vf-fonts", + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/truetype", + "/usr/local/share/fonts/noto", + "/usr/local/share/fonts/noto-cjk", + ]; + for dir in NOTO_DIRS { + for (file, family) in NOTO_TC_FILES { + add(Path::new(dir).join(file), family); + } + } + } + #[cfg(target_os = "macos")] + { + // PingFang moved from the system fonts into a downloaded asset in later macOS versions. + add("/System/Library/Fonts/PingFang.ttc".into(), "PingFang TC"); + for path in mac_font_assets("PingFang.ttc") { + add(path, "PingFang TC"); + } + add("/System/Library/Fonts/STHeiti Medium.ttc".into(), "Heiti TC"); + } + #[cfg(target_os = "windows")] + { + let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + add(root.join("Fonts").join("msjh.ttc"), "Microsoft JhengHei"); + add(root.join("Fonts").join("mingliu.ttc"), "MingLiU"); + } + out +} + /// Where each system keeps a Japanese font, best first. fn japanese_candidates() -> Vec { let mut out = Vec::new(); @@ -512,6 +581,43 @@ mod tests { assert!(first_misans > 0, "the system's own fonts come first"); } + /// Traditional Chinese prefers an installed Noto CJK TC over the system's own face, matching + /// GridCraft's Taiwan font order. + #[test] + fn traditional_chinese_prefers_noto_cjk_tc() { + let found = traditional_chinese_candidates(); + let first = found.first().expect("at least one Traditional Chinese candidate"); + assert!(first.family.starts_with("Noto Sans"), "first is {:?}", first.family); + // macOS and Windows add their own face after the Noto candidates; the Unix list is all + // Noto, so there is no system face to order against there. + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + let first_system = found.iter().position(|c| !c.family.starts_with("Noto Sans")).expect("a system face comes after Noto"); + assert!(first_system > 0); + } + } + + /// openSUSE's `google-noto-sans-tc-fonts` installs the variable CJK TC font directly in + /// `/usr/share/fonts/truetype`. + #[cfg(target_os = "linux")] + #[test] + fn traditional_chinese_searches_the_opensuse_font_dir() { + let found = traditional_chinese_candidates(); + assert!(found.iter().any(|c| c.path == Path::new("/usr/share/fonts/truetype/NotoSansCJKtc-VF.otf") && c.family == "Noto Sans CJK TC")); + } + + /// The `NotoSansCJKtc` override folder is read before the older file at the font folder's root. + #[test] + fn the_tc_override_folder_comes_before_the_root_file() { + let found = traditional_chinese_candidates(); + let dir = user_font_dirs().into_iter().next().expect("a user font folder"); + let override_file = dir.join("NotoSansCJKtc").join("NotoSansCJKtc-Regular.otf"); + let root_file = dir.join("NotoSansCJKtc-Regular.otf"); + let override_at = found.iter().position(|c| c.path == override_file).expect("the override path"); + let root_at = found.iter().position(|c| c.path == root_file).expect("the root path"); + assert!(override_at < root_at); + } + /// Inside Flatpak the host's fonts are under `/run/host` (#620), each right after its own path. #[cfg(target_os = "linux")] #[test] @@ -525,7 +631,9 @@ mod tests { ] { assert_eq!(flatpak_host_path(Path::new(path)), host.map(PathBuf::from), "{path}"); } - for (script, family) in [(Script::Chinese, "Noto Sans CJK SC"), (Script::Japanese, "Noto Sans CJK JP")] { + for (script, family) in + [(Script::Chinese, "Noto Sans CJK SC"), (Script::TraditionalChinese, "Noto Sans CJK TC"), (Script::Japanese, "Noto Sans CJK JP")] + { let found = candidates(script); let at = found.iter().position(|c| c.path == Path::new("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc")).unwrap(); assert_eq!(found[at + 1].path, Path::new("/run/host/fonts/noto-cjk/NotoSansCJK-Regular.ttc")); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f91a311a..4e12dca0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -109,6 +109,12 @@ Apps on this computer Aplikace v tomto počítači Installed apps with a newer version Nainstalované aplikace s novější verzí Apps you can install Aplikace, které lze nainstalovat + Pin to top Připnout nahoru + Unpin Odepnout + Pinned Připnuté + Reorder {name} Změnit pořadí: {name} + Move up Posunout nahoru + Move down Posunout dolů Hide App Skrýt aplikaci Unhide App Zobrazit aplikaci Hidden Apps Skryté aplikace @@ -379,3 +385,7 @@ materialcraft Node graphs Uzlové grafy materialcraft Combine noises, patterns and filters. Kombinujte šumy, vzory a filtry. materialcraft Live previews Živé náhledy materialcraft Preview materials in 2D and on 3D models. Prohlížejte materiály ve 2D a na 3D modelech. + Advanced Pokročilé + Migrate Flatpak bundles to ArtCraft OCI (experimental) Převést balíčky Flatpak na ArtCraft OCI (experimentální) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Při aktualizaci převést aplikace ArtCraft, pokud je dostupný odpovídající balíček. Jinak nadále používat samostatné balíčky. + The OCI repository is a preview with GPG verification disabled. Repozitář OCI je předběžná verze s vypnutým ověřováním GPG. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 668c8258..50e9444e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -123,6 +123,12 @@ Apps on this computer Apps auf diesem Computer Installed apps with a newer version Installierte Apps mit neuerer Version Apps you can install Apps, die du installieren kannst + Pin to top Oben anheften + Unpin Lösen + Pinned Angeheftet + Reorder {name} {name} neu anordnen + Move up Nach oben + Move down Nach unten Hide App App ausblenden Unhide App App einblenden Hidden Apps Ausgeblendete Apps @@ -411,3 +417,7 @@ materialcraft Node graphs Knotengraphen materialcraft Combine noises, patterns and filters. Rauschen, Muster und Filter kombinieren. materialcraft Live previews Live-Vorschauen materialcraft Preview materials in 2D and on 3D models. Materialien in 2D und auf 3D-Modellen anzeigen. + Advanced Erweitert + Migrate Flatpak bundles to ArtCraft OCI (experimental) Flatpak-Bundles zu ArtCraft OCI migrieren (experimentell) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. ArtCraft-Apps beim Aktualisieren umstellen, wenn das passende Paket verfügbar ist. Andernfalls weiterhin Bundles verwenden. + The OCI repository is a preview with GPG verification disabled. Das OCI-Repository ist eine Vorschau mit deaktivierter GPG-Prüfung. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 524d1a2b..dd3a798b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -122,6 +122,12 @@ Apps on this computer Aplicaciones en este equipo Installed apps with a newer version Aplicaciones instaladas con una versión más reciente Apps you can install Aplicaciones que puedes instalar + Pin to top Fijar arriba + Unpin Quitar fijación + Pinned Fijadas + Reorder {name} Reordenar {name} + Move up Subir + Move down Bajar Hide App Ocultar aplicación Unhide App Mostrar aplicación Hidden Apps Aplicaciones ocultas @@ -392,3 +398,7 @@ materialcraft Node graphs Grafos de nodos materialcraft Combine noises, patterns and filters. Combina ruidos, patrones y filtros. materialcraft Live previews Vistas previas en vivo materialcraft Preview materials in 2D and on 3D models. Previsualiza materiales en 2D y sobre modelos 3D. + Advanced Avanzado + Migrate Flatpak bundles to ArtCraft OCI (experimental) Migrar paquetes Flatpak a ArtCraft OCI (experimental) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Al actualizar, migrar las aplicaciones de ArtCraft si está disponible el paquete correspondiente. De lo contrario, seguir usando paquetes independientes. + The OCI repository is a preview with GPG verification disabled. El repositorio OCI es una versión preliminar con la verificación GPG desactivada. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 80086d87..2289324f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -121,6 +121,12 @@ Apps on this computer Applications sur cet ordinateur Installed apps with a newer version Applications installées avec une version plus récente Apps you can install Applications que vous pouvez installer + Pin to top Épingler en haut + Unpin Désépingler + Pinned Épinglés + Reorder {name} Réordonner {name} + Move up Monter + Move down Descendre Hide App Masquer l'application Unhide App Afficher l'application Hidden Apps Applications masquées @@ -409,3 +415,7 @@ materialcraft Node graphs Graphes de nœuds materialcraft Combine noises, patterns and filters. Combinez bruits, motifs et filtres. materialcraft Live previews Aperçus en direct materialcraft Preview materials in 2D and on 3D models. Visualisez les matériaux en 2D et sur des modèles 3D. + Advanced Avancé + Migrate Flatpak bundles to ArtCraft OCI (experimental) Migrer les paquets Flatpak vers ArtCraft OCI (expérimental) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Lors des mises à jour, migrer les applications ArtCraft si le paquet correspondant est disponible. Sinon, continuer à utiliser les paquets autonomes. + The OCI repository is a preview with GPG verification disabled. Le dépôt OCI est une version préliminaire avec la vérification GPG désactivée. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2c20fd00..ae6650e4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -123,6 +123,12 @@ Apps on this computer Alkalmazások ezen a számítógépen Installed apps with a newer version Telepített alkalmazások, amelyekhez frissítés érhető el Apps you can install Telepíthető alkalmazások + Pin to top Rögzítés felülre + Unpin Rögzítés feloldása + Pinned Rögzítettek + Reorder {name} Átrendezés: {name} + Move up Feljebb + Move down Lejjebb Hide App Alkalmazás elrejtése Unhide App Alkalmazás megjelenítése Hidden Apps Rejtett alkalmazások @@ -412,3 +418,7 @@ materialcraft Node graphs Csomópontgráfok materialcraft Combine noises, patterns and filters. Kombinálj zajokat, mintákat és szűrőket. materialcraft Live previews Élő előnézetek materialcraft Preview materials in 2D and on 3D models. Tekintsd meg az anyagokat 2D-ben és 3D modelleken. + Advanced Haladó + Migrate Flatpak bundles to ArtCraft OCI (experimental) Flatpak-csomagok átállítása ArtCraft OCI-ra (kísérleti) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Frissítéskor állítsa át az ArtCraft alkalmazásait, ha elérhető a megfelelő csomag. Egyébként továbbra is önálló csomagokat használjon. + The OCI repository is a preview with GPG verification disabled. Az OCI-tároló előzetes verzió, kikapcsolt GPG-ellenőrzéssel. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0af50a21..0c1702fc 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -125,6 +125,12 @@ Apps on this computer このコンピューターのアプリ Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ Apps you can install インストールできるアプリ + Pin to top 上部にピン留め + Unpin ピン留めを外す + Pinned ピン留め + Reorder {name} {name}の順序を変更 + Move up 上へ + Move down 下へ Hide App アプリを非表示 Unhide App アプリを再表示 Hidden Apps 非表示のアプリ @@ -414,3 +420,7 @@ materialcraft Node graphs ノードグラフ materialcraft Combine noises, patterns and filters. ノイズ、パターン、フィルターを組み合わせます。 materialcraft Live previews リアルタイムプレビュー materialcraft Preview materials in 2D and on 3D models. マテリアルを2Dと3Dモデル上でプレビューします。 + Advanced 詳細設定 + Migrate Flatpak bundles to ArtCraft OCI (experimental) Flatpak バンドルを ArtCraft OCI に移行する(実験的) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. 更新時に対応するパッケージがあれば ArtCraft 公式アプリを移行します。なければ引き続きバンドルを使用します。 + The OCI repository is a preview with GPG verification disabled. OCI リポジトリはプレビュー版で、GPG 検証は無効になっています。 diff --git a/crates/ui-egui/src/i18n/lang.rs b/crates/ui-egui/src/i18n/lang.rs index 17e6ee86..92599c9b 100644 --- a/crates/ui-egui/src/i18n/lang.rs +++ b/crates/ui-egui/src/i18n/lang.rs @@ -12,18 +12,19 @@ use super::detect::system_lang; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Script { Chinese, + TraditionalChinese, Japanese, Georgian, } impl Script { /// Every script a language can be written in. - pub const ALL: [Script; 3] = [Script::Chinese, Script::Japanese, Script::Georgian]; + pub const ALL: [Script; 4] = [Script::Chinese, Script::TraditionalChinese, Script::Japanese, Script::Georgian]; } /// One supported UI language. pub struct LangInfo { - /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`, + /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `zh-TW`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`, /// `ka`). Also the `interfaceLanguage` value. pub code: &'static str, /// The language's name in itself, shown in the Preferences dropdown. @@ -96,7 +97,7 @@ pub(super) fn plural_polish(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 12] = [ +pub static LANGUAGES: [LangInfo; 13] = [ LangInfo { code: "en", name: "English", @@ -130,6 +131,17 @@ pub static LANGUAGES: [LangInfo; 12] = [ complete_menus: true, catalog: OnceLock::new(), }, + LangInfo { + code: "zh-TW", + name: "繁體中文", + english_name: "Traditional Chinese (Taiwan)", + script: Some(Script::TraditionalChinese), + source: include_str!("zh-TW.tsv"), + plural: plural_none, + plural_forms: 1, + complete_menus: true, + catalog: OnceLock::new(), + }, LangInfo { code: "de", name: "Deutsch", @@ -259,15 +271,16 @@ impl Lang { pub const EN: Lang = Lang(&LANGUAGES[0]); pub const RU: Lang = Lang(&LANGUAGES[1]); pub const ZH_CN: Lang = Lang(&LANGUAGES[2]); - pub const DE: Lang = Lang(&LANGUAGES[3]); - pub const FR: Lang = Lang(&LANGUAGES[4]); - pub const PT_BR: Lang = Lang(&LANGUAGES[5]); - pub const ES: Lang = Lang(&LANGUAGES[6]); - pub const CS: Lang = Lang(&LANGUAGES[7]); - pub const PL: Lang = Lang(&LANGUAGES[8]); - pub const HU: Lang = Lang(&LANGUAGES[9]); - pub const JA: Lang = Lang(&LANGUAGES[10]); - pub const KA: Lang = Lang(&LANGUAGES[11]); + pub const ZH_TW: Lang = Lang(&LANGUAGES[3]); + pub const DE: Lang = Lang(&LANGUAGES[4]); + pub const FR: Lang = Lang(&LANGUAGES[5]); + pub const PT_BR: Lang = Lang(&LANGUAGES[6]); + pub const ES: Lang = Lang(&LANGUAGES[7]); + pub const CS: Lang = Lang(&LANGUAGES[8]); + pub const PL: Lang = Lang(&LANGUAGES[9]); + pub const HU: Lang = Lang(&LANGUAGES[10]); + pub const JA: Lang = Lang(&LANGUAGES[11]); + pub const KA: Lang = Lang(&LANGUAGES[12]); pub fn code(self) -> &'static str { self.0.code @@ -367,14 +380,19 @@ pub fn lang_from_tag(tag: &str) -> Option { } } -/// Chinese by script: Simplified for `zh-Hans`, and for a bare `zh` or the regions that write -/// Simplified (mainland China, Singapore) when no script is given. An explicit script wins over -/// the region (`zh-Hant-CN` is Traditional); Traditional Chinese isn't offered. +/// Chinese by script: Traditional for an explicit `Hant`, and for Taiwan, Hong Kong and Macao when +/// no script is given; Simplified for an explicit `Hans`, a bare `zh`, and the regions that write +/// Simplified (mainland China, Singapore). An explicit script wins over the region +/// (`zh-Hant-CN` is Traditional). fn chinese(tag: &str) -> Option { let parts: Vec<&str> = tag.split('-').collect(); if parts.contains(&"hant") { - None - } else if parts.contains(&"hans") || parts.len() == 1 || parts.iter().any(|p| matches!(*p, "cn" | "sg")) { + Some(Lang::ZH_TW) + } else if parts.contains(&"hans") { + Some(Lang::ZH_CN) + } else if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { + Some(Lang::ZH_TW) + } else if parts.len() == 1 || parts.iter().any(|p| matches!(*p, "cn" | "sg")) { Some(Lang::ZH_CN) } else { None diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 880aa123..17ae818c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -124,6 +124,12 @@ Apps on this computer Aplikacje na tym komputerze Installed apps with a newer version Zainstalowane aplikacje z nowszą wersją Apps you can install Aplikacje, które możesz zainstalować + Pin to top Przypnij na górze + Unpin Odepnij + Pinned Przypięte + Reorder {name} Zmień kolejność: {name} + Move up W górę + Move down W dół Hide App Ukryj aplikację Unhide App Pokaż aplikację Hidden Apps Ukryte aplikacje @@ -413,3 +419,7 @@ materialcraft Node graphs Grafy węzłów materialcraft Combine noises, patterns and filters. Łącz szumy, wzory i filtry. materialcraft Live previews Podgląd na żywo materialcraft Preview materials in 2D and on 3D models. Przeglądaj materiały w 2D i na modelach 3D. + Advanced Zaawansowane + Migrate Flatpak bundles to ArtCraft OCI (experimental) Przenoś pakiety Flatpak do ArtCraft OCI (eksperymentalne) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Podczas aktualizacji przenoś aplikacje ArtCraft, gdy dostępny jest odpowiedni pakiet. W przeciwnym razie nadal używaj samodzielnych pakietów. + The OCI repository is a preview with GPG verification disabled. Repozytorium OCI jest wersją testową z wyłączoną weryfikacją GPG. diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv index 202b3de0..38b61111 100644 --- a/crates/ui-egui/src/i18n/pt-BR.tsv +++ b/crates/ui-egui/src/i18n/pt-BR.tsv @@ -111,6 +111,12 @@ Apps on this computer Aplicativos neste computador Installed apps with a newer version Aplicativos instalados com nova versão Apps you can install Aplicativos que você pode instalar + Pin to top Fixar no topo + Unpin Desafixar + Pinned Fixados + Reorder {name} Reordenar {name} + Move up Mover para cima + Move down Mover para baixo Hide App Ocultar aplicativo Unhide App Mostrar aplicativo Hidden Apps Aplicativos ocultos @@ -399,3 +405,7 @@ materialcraft Node graphs Grafos de nós materialcraft Combine noises, patterns and filters. Combine ruídos, padrões e filtros. materialcraft Live previews Pré-visualizações ao vivo materialcraft Preview materials in 2D and on 3D models. Visualize materiais em 2D e em modelos 3D. + Advanced Avançado + Migrate Flatpak bundles to ArtCraft OCI (experimental) Migrar pacotes Flatpak para ArtCraft OCI (experimental) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. Ao atualizar, migrar os aplicativos da ArtCraft quando o pacote correspondente estiver disponível. Caso contrário, continuar usando pacotes independentes. + The OCI repository is a preview with GPG verification disabled. O repositório OCI é uma prévia com a verificação GPG desativada. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c4294a2e..b24e14b0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -121,6 +121,12 @@ Apps on this computer Приложения на этом компьютере Installed apps with a newer version Установленные приложения, у которых есть новая версия Apps you can install Приложения, которые можно установить + Pin to top Закрепить сверху + Unpin Открепить + Pinned Закреплённые + Reorder {name} Изменить порядок: {name} + Move up Выше + Move down Ниже Hide App Скрыть приложение Unhide App Показать приложение Hidden Apps Скрытые приложения @@ -407,3 +413,7 @@ materialcraft Node graphs Графы узлов materialcraft Combine noises, patterns and filters. Комбинируйте шумы, узоры и фильтры. materialcraft Live previews Предпросмотр в реальном времени materialcraft Preview materials in 2D and on 3D models. Просматривайте материалы в 2D и на 3D-моделях. + Advanced Дополнительно + Migrate Flatpak bundles to ArtCraft OCI (experimental) Переводить пакеты Flatpak на ArtCraft OCI (экспериментально) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. При обновлении переводить приложения ArtCraft на репозиторий, если доступен соответствующий пакет. Иначе продолжать использовать отдельные пакеты. + The OCI repository is a preview with GPG verification disabled. Репозиторий OCI работает в предварительном режиме с отключённой проверкой GPG. diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 6932c3e6..74d862ad 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -372,15 +372,19 @@ fn polish_locales_resolve() { } #[test] -fn simplified_chinese_locales_respect_the_script() { +fn chinese_locales_respect_the_script() { for tag in ["zh", "zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "ZH_cn"] { assert_eq!(lang_from_tag(tag), Some(Lang::ZH_CN), "{tag}"); } - for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-CN", "zh-Hant-SG", "zh_TW.UTF-8"] { - assert_eq!(lang_from_tag(tag), None, "{tag}"); + for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-CN", "zh-Hant-SG", "zh_TW.UTF-8", "ZH_hant"] { + assert_eq!(lang_from_tag(tag), Some(Lang::ZH_TW), "{tag}"); } - // A Traditional Chinese system that also lists English gets English, not Simplified. - assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::EN)); + // An explicit script wins over the region. + assert_eq!(lang_from_tag("zh-Hans-TW"), Some(Lang::ZH_CN)); + // A Traditional Chinese system that also lists English gets Traditional Chinese. + assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::ZH_TW)); + // A region with no explicit script and no Traditional Chinese convention stays unsupported. + assert_eq!(lang_from_tag("zh-MY"), None); } #[test] @@ -394,6 +398,29 @@ fn chinese_counts_and_english_fallback_work() { assert_eq!(message(Lang::ZH_CN, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0"); } +#[test] +fn traditional_chinese_locales_and_counts_work() { + for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh_TW.UTF-8", "ZH_hant"] { + assert_eq!(lang_from_tag(tag), Some(Lang::ZH_TW), "{tag}"); + } + assert_eq!(super::detect::first_supported(["zh-Hant-TW", "en-US"]), Some(Lang::ZH_TW)); + // Traditional Chinese has one plural form. + for n in [0, 1, 2, 11, 100] { + assert_eq!(trn(Lang::ZH_TW, n, "{n} app", "{n} apps"), format!("{n} 個應用程式")); + assert_eq!(trn(Lang::ZH_TW, n, "{n} min ago", "{n} min ago"), format!("{n} 分鐘前")); + assert_eq!(trn(Lang::ZH_TW, n, "{n} day ago", "{n} days ago"), format!("{n} 天前")); + } + assert_eq!(trn(Lang::ZH_TW, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items"); + assert_eq!(tr(Lang::ZH_TW, "Untranslated diagnostic"), "Untranslated diagnostic"); + assert_eq!(tr(Lang::ZH_TW, "{month} {day}, {year}"), "{year}年{month}{day}日"); + assert_eq!(super::tr_ctx(Lang::ZH_TW, "month", "Mar"), "3月"); + assert_eq!(tr(Lang::ZH_TW, "Settings"), "設定"); + assert_eq!(tr(Lang::ZH_TW, "Uninstall…"), "解除安裝…"); + // The product's "Launcher" tag stays in English (AGENTS.md). + assert_eq!(tr(Lang::ZH_TW, "Launcher"), "Launcher"); + assert_eq!(message(Lang::ZH_TW, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0"); +} + #[test] fn japanese_locales_and_counts_work() { for tag in ["ja", "ja-JP", "ja_JP.UTF-8", "JA_jp"] { @@ -432,37 +459,42 @@ fn georgian_locales_and_counts_work() { assert!(!Lang::KA.0.source.chars().any(|c| ('\u{1c90}'..='\u{1cbf}').contains(&c))); } -/// With the system's Chinese font installed, every UI family can draw the whole Chinese catalog. -/// Machines without one (CI, the web) skip this; the UI then stays in English. +/// With the system's Chinese font installed, every UI family can draw the whole Simplified and +/// Traditional Chinese catalogs. Machines without one (CI, the web) skip this; the UI then stays +/// in English. #[test] fn system_font_covers_chinese_in_every_ui_family() { use egui::{FontFamily, FontId}; - if !crate::theme::cjk_font_available(Lang::ZH_CN) { - return; - } - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx, Some(Lang::ZH_CN)); - let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); - output.textures_delta.clear(); - let text: String = Lang::ZH_CN - .0 - .source - .lines() - .filter_map(|line| line.split('\t').nth(2)) - .flat_map(str::chars) - .filter(|c| ('\u{3400}'..='\u{9fff}').contains(c)) - .collect(); - assert!(text.chars().count() > 500); - for family in [ - FontFamily::Proportional, - FontFamily::Monospace, - crate::theme::medium(), - crate::theme::semibold(), - crate::theme::display(), - crate::theme::mono(), - crate::theme::mono_bold(), - ] { - ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}")); + for lang in [Lang::ZH_CN, Lang::ZH_TW] { + if !crate::theme::cjk_font_available(lang) { + continue; + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, Some(lang)); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + let text: String = lang + .0 + .source + .lines() + .filter_map(|line| line.split('\t').nth(2)) + .flat_map(str::chars) + .filter(|c| ('\u{3400}'..='\u{9fff}').contains(c)) + .collect(); + assert!(text.chars().count() > 500, "{}", lang.code()); + for family in [ + FontFamily::Proportional, + FontFamily::Monospace, + crate::theme::medium(), + crate::theme::semibold(), + crate::theme::display(), + crate::theme::mono(), + crate::theme::mono_bold(), + ] { + ctx.fonts_mut(|fonts| { + assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "{}: missing glyph in {family:?}", lang.code()); + }); + } } } @@ -544,6 +576,8 @@ fn language_labels_come_from_the_registry() { // in the font stack: no other face draws it. assert_eq!(Settings::language_label(Lang::ZH_CN.code(), None), "Simplified Chinese"); assert_eq!(Settings::language_label(Lang::ZH_CN.code(), Some(Lang::ZH_CN)), "Simplified Chinese (简体中文)"); + assert_eq!(Settings::language_label(Lang::ZH_TW.code(), None), "Traditional Chinese (Taiwan)"); + assert_eq!(Settings::language_label(Lang::ZH_TW.code(), Some(Lang::ZH_TW)), "Traditional Chinese (Taiwan) (繁體中文)"); assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::ZH_CN)), "Japanese"); assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::JA)), "Japanese (日本語)"); assert_eq!(Settings::language_label(Lang::KA.code(), None), "Georgian"); diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv index ad23924a..f3017b6a 100644 --- a/crates/ui-egui/src/i18n/zh-CN.tsv +++ b/crates/ui-egui/src/i18n/zh-CN.tsv @@ -271,6 +271,12 @@ figcraft Interface design and prototyping 界面设计与原型制作 Apps on this computer 此电脑上的应用 Installed apps with a newer version 有新版本的已安装应用 Apps you can install 可安装的应用 + Pin to top 置顶 + Unpin 取消置顶 + Pinned 已置顶 + Reorder {name} 重新排序 {name} + Move up 上移 + Move down 下移 Hide App 隐藏应用 Unhide App 取消隐藏应用 Hidden Apps 已隐藏的应用 @@ -379,3 +385,7 @@ materialcraft Node graphs 节点图 materialcraft Combine noises, patterns and filters. 组合噪声、图案和滤镜。 materialcraft Live previews 实时预览 materialcraft Preview materials in 2D and on 3D models. 在二维视图和三维模型上预览材质。 + Advanced 高级 + Migrate Flatpak bundles to ArtCraft OCI (experimental) 将 Flatpak 软件包迁移到 ArtCraft OCI(实验性) + On update, switch first party apps when the matching package is available. Otherwise, keep using bundles. 更新时,如果有对应的软件包,则迁移 ArtCraft 官方应用;否则继续使用独立软件包。 + The OCI repository is a preview with GPG verification disabled. OCI 仓库目前处于预览阶段,GPG 验证已禁用。 diff --git a/crates/ui-egui/src/i18n/zh-TW.tsv b/crates/ui-egui/src/i18n/zh-TW.tsv new file mode 100644 index 00000000..ee3d6acd --- /dev/null +++ b/crates/ui-egui/src/i18n/zh-TW.tsv @@ -0,0 +1,383 @@ +# Traditional Chinese (Taiwan) catalog for ArtCraft Launcher. +# Format: contextEnglish sourcetranslation. +# Original translations from the English UI. Product and app names (ArtCraft Launcher and its +# "Launcher" tag, Craft App Suite, PhotoCraft...) stay unchanged. +# Shown with the system's Traditional Chinese font (Noto Sans CJK TC, PingFang TC, Microsoft +# JhengHei...: see cjk_font.rs). Chinese has one plural form. Placeholders and trailing ellipses +# are preserved. + + Launcher Launcher + Craft App Suite Craft App Suite + by {name} 由 {name} 製作 + Open Source Tools for Creatives, Makers, and Builders 為創作者、自造者與打造者而生的開源工具 + Settings 設定 + Check for updates 檢查更新 + Grid 網格 + Table 表格 + Show apps as cards 以卡片檢視應用程式 + Show apps as a table 以表格檢視應用程式 + All 全部 + Updates 更新 + Not installed 未安裝 + Search apps 搜尋應用程式 + Sort 排序 + Suite order 系列順序 + Name 名稱 + Status 狀態 + Installed version 已安裝版本 + Latest version 最新版本 + Newest release 最新發行版 + Discord Discord + GitHub GitHub + Website 網站 + Classic apps 經典應用程式 + New apps 全新應用程式 + NEW 新 + Install 安裝 + Update to {v} 更新至 {v} + Open 開啟 + Download for {os} 下載 {os} 版 + Download 下載 + Coming soon 即將推出 + Checking… 檢查中… + Not available 無法使用 + Offline 離線 + Downloading {done} of {total} 正在下載 {done}/{total} + Downloading… 下載中… + Reconnecting… 重新連線中… + Verifying… 驗證中… + Installing, then restarting… 安裝中,隨後重新啟動… + Installing… 安裝中… + Removing… 移除中… + Pause download 暫停下載 + Cancel download 取消下載 + Resume download 繼續下載 + Discard download 捨棄下載 + Release notes 更新說明 + Reinstall 重新安裝 + Uninstall… 解除安裝… + All releases 所有版本 + ArtCraft Launcher {version} is available. ArtCraft Launcher {version} 已推出。 + Dismiss 關閉 + Update and restart 更新並重新啟動 + A new version is available. Would you like to relaunch with the updated version? 有新版本可以使用。要使用更新後的版本重新啟動嗎? + A new version is available. Download it to replace this version? 有新版本可以使用。要下載並取代目前版本嗎? + Later 稍後 + What's new 新功能 + No notes were published with this release. 此版本未提供任何更新說明。 + Released {date} 發行於 {date} + Open on GitHub 在 GitHub 上開啟 + Close 關閉 + ArtCraft Launcher {version} ArtCraft Launcher {version} + Only when I ask 僅在我要求時 + Automatically download and install updates 自動下載並安裝更新 + Notify me when an installed app has an update 已安裝的應用程式有更新時通知我 + Include pre-releases (betas and release candidates) 包含預先發行版(Beta 版與候選發行版) + 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. 更新一律沿用應用程式安裝時的格式。 + Running in the background 在背景執行 + Keep running in the tray when the window is closed 關閉視窗後仍在系統匣中執行 + ArtCraft Launcher is still running ArtCraft Launcher 仍在執行 + It's in the tray: open it or quit it from the tray icon, or turn this off in the settings. 它位於系統匣中:請從系統匣圖示開啟或結束,或在設定中關閉此功能。 + Show the window on the current desktop 在目前的桌面顯示視窗 + Start ArtCraft Launcher when I log in 登入時啟動 ArtCraft Launcher + Community 社群 + Questions, ideas and news from the ArtCraft team 來自 ArtCraft 團隊的提問、想法與最新消息 + Appearance 外觀 + Quit ArtCraft Launcher 結束 ArtCraft Launcher + Done 完成 + Uninstall {name}? 要解除安裝 {name} 嗎? + The app will be moved to the Trash. Your documents and settings stay. 應用程式會移到垃圾桶,你的文件與設定會保留。 + Windows will remove it like any installed program. Your documents and settings stay. Windows 會像移除任何已安裝程式一樣將它移除,你的文件與設定會保留。 + Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. 系統的套件管理員會將它移除,並可能要求你輸入密碼。你的文件與設定會保留。 + The app will be removed. Your documents and settings stay. 應用程式會被移除,你的文件與設定會保留。 + Uninstall 解除安裝 + Cancel 取消 + Checked {ago} 上次檢查:{ago} + just now 剛剛 + yesterday 昨天 + {month} {day}, {year} {year}年{month}{day}日 +month Jan 1月 +month Feb 2月 +month Mar 3月 +month Apr 4月 +month May 5月 +month Jun 6月 +month Jul 7月 +month Aug 8月 +month Sep 9月 +month Oct 10月 +month Nov 11月 +month Dec 12月 + Not checked yet 尚未檢查 + GitHub's hourly limit was reached; full details return in {min} min 已達到 GitHub 的每小時上限;完整資訊將在 {min} 分鐘後恢復 + GitHub limit hit; back in {min} min 已觸及 GitHub 上限;{min} 分鐘後恢復 + Installing for {platform} 正在為 {platform} 安裝 + Downloads for {platform} {platform} 適用的下載項目 + No downloads for this system 此系統沒有可下載的項目 + Join the ArtCraft community on Discord 在 Discord 加入 ArtCraft 社群 + ArtCraft on {name} ArtCraft 在 {name} + Available to install 可安裝 + Available 可用 + Installed · up to date 已安裝 · 已是最新版本 + Update available 有可用更新 + Installed 已安裝 + Not available here 此處無法使用 + Updating… 更新中… + Update paused 更新已暫停 + Download paused 下載已暫停 + {version} installed → {latest} released {version} 已安裝 → {latest} 已發行 + Version {version} 版本 {version} + {installed} installed · latest {latest} 已安裝 {installed} · 最新版本 {latest} + Installed · latest {latest} 已安裝 · 最新版本 {latest} + Version {version} installed 已安裝版本 {version} + Version {latest} 版本 {latest} + No release published yet 尚未發行任何版本 + ArtCraft Launcher ArtCraft Launcher + App 應用程式 + Latest 最新 + Released 發行日期 + Auto 自動 + Language 語言 + Ascending 遞增 + Descending 遞減 + Click to reverse the order 按一下以反轉順序 + Click to sort by this column 按一下以依此欄排序 + {done} of {total} {done}/{total} + Downloading 下載中 + Verifying checksum… 正在驗證檢查碼… + Resume 繼續 + {done} of {total} downloaded 已下載 {done},共 {total} + {done} downloaded 已下載 {done} + Checking for updates… 正在檢查更新… + Downloads the new version, replaces this one and opens it again 下載新版本、取代目前版本並重新開啟 + Couldn't check for updates: {f} 無法檢查更新:{f} + GitHub repository GitHub 儲存庫 + ArtCraft ArtCraft + Update 1 app 更新 1 個應用程式 + Update all ({count}) 全部更新({count}) + ⊞ Grid ⊞ 網格 + ☰ Table ☰ 表格 + 🔍 Search apps 🔍 搜尋應用程式 + {name} {version} {name} {version} + Match system 跟隨系統 + Dark 深色 + Light 淺色 + Full changelog 完整變更記錄 + link 連結 + No apps match 沒有符合的應用程式 + Open ArtCraft Launcher to update. 開啟 ArtCraft Launcher 以進行更新。 + {name} {v} is available {name} {v} 已推出 + {count} updates are available 有 {count} 項更新可用 + These notes weren't fetched (GitHub's request limit was reached). They're on the release page. 無法擷取這些說明(已達到 GitHub 的請求上限)。請至發行頁面查看。 + When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. 開啟時,從選單列開啟啟動器會將其視窗帶到你目前所在的桌面。關閉時,macOS 會將你切換到視窗所在的桌面。 + Couldn't change the login item: {e} 無法變更登入項目:{e} +column App 應用程式 +column Installed 已安裝 +column Latest 最新版本 +column Released 發行日期 +filter Installed 已安裝 + {name} details {name} 詳細資訊 +@plural {n} min ago|{n} min ago {n} 分鐘前 +@plural {n} h ago|{n} h ago {n} 小時前 +@plural {n} day ago|{n} days ago {n} 天前 +@id app.install 安裝 +@id app.update 更新至 {v} +@id app.open 開啟 +@id app.download 下載 {os} 版 +@id app.reveal 在 Finder 中顯示 +@id app.reinstall 重新安裝 +@id app.uninstall 解除安裝 +@id app.notes 更新說明 +@id app.website 網站 +@id app.github GitHub +@id app.releases 所有版本 +@id launcher.update 更新並重新啟動 +@id launcher.download 下載 +@id launcher.notes 更新說明 +@id launcher.dismiss 關閉 +@id settings.open 設定 +@id settings.quit 結束 ArtCraft Launcher +@id view.grid 網格 +@id view.table 表格 +@id filter.all 全部 +@id filter.installed 已安裝 +@id filter.updates 更新 +@id filter.notinstalled 未安裝 +@id sort.suite 系列順序 +@id sort.name 名稱 +@id sort.status 狀態 +@id sort.installed 已安裝版本 +@id sort.latest 最新版本 +@id sort.released 發行日期 +@id help.discord Discord +@id help.github GitHub +@id help.website 網站 +@id update.check 檢查更新 +@id update.check_auto 自動檢查更新 +@id update.notify 已安裝的應用程式有更新時通知我 +@id update.prereleases 包含預先發行版(Beta 版與候選發行版) +@id linux.preferred_kind 新應用程式安裝格式 +@id tray.close_to_tray 關閉視窗後繼續在系統匣執行 +@id tray.show_on_current_desktop 在目前桌面顯示視窗 +@id tray.login_item 登入時啟動 ArtCraft Launcher +@id appearance.theme 外觀 + Update 更新 + New 新 + Classic 經典 + Join the Discord 加入 Discord + Minimize 最小化 + Maximize 最大化 + Restore 還原 + Show in Finder 在 Finder 中顯示 + Show in Explorer 在檔案總管中顯示 + Show ArtCraft Launcher 顯示 ArtCraft Launcher + Check for Updates 檢查更新 + Visit getartcraft.com 前往 getartcraft.com + 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} 個更新可用 +@plural {n} app|{n} apps {n} 個應用程式 +photocraft Image editing 影像編輯 +vectorcraft Vector illustration 向量插畫 +filmcraft Video editing, color and sound 影片剪輯、調色與音效 +lightcraft Photo library and raw development 相片圖庫與 RAW 顯像 +pdfcraft Reading, organizing and protecting PDFs 閱讀、整理與保護 PDF +effectcraft Motion graphics and visual effects 動態圖像與視覺特效 +designcraft Page layout and publishing 版面配置與出版 +wordcraft Writing and document design 寫作與文件設計 +deckcraft Presentations and slide shows 簡報與幻燈片放映 +gridcraft Spreadsheets and data 試算表與資料 +craft-anything Find out how any software works, then craft your own 了解任何軟體如何運作,然後打造屬於你自己的版本 +solidcraft Parametric 3D CAD 參數式 3D CAD +cadcraft Computer-aided design and drafting 電腦輔助設計與製圖 +soundcraft Recording, editing and mixing audio 錄音、剪輯與混音 +figcraft Interface design and prototyping 介面設計與原型製作 + Show in Files 在檔案管理員中顯示 + Loading ArtCraft Launcher… 正在載入 ArtCraft Launcher… + Download cancelled 已取消下載 + Automatically 自動 + Checked {ago} · using {sources} 上次檢查:{ago} · 使用 {sources} + {a} and {b} {a} 與 {b} + Learn more 瞭解更多 + OK 確定 + Open link 開啟連結 + {name} icon {name} 圖示 + installed 已安裝 + Every Craft app 所有 Craft 應用程式 + Apps on this computer 這台電腦上的應用程式 + Installed apps with a newer version 有較新版本的已安裝應用程式 + Apps you can install 可安裝的應用程式 + Pin to top 置頂 + Unpin 取消置頂 + Pinned 已置頂 + Reorder {name} 重新排序 {name} + Move up 上移 + Move down 下移 + Hide App 隱藏應用程式 + Unhide App 取消隱藏應用程式 + Hidden Apps 已隱藏的應用程式 + Apps you hid 你隱藏的應用程式 + Cancel update 取消更新 + Stop waiting; the update can be started again later 停止等待;稍後可以再次開始更新 + Download again 重新下載 + Install cancelled 已取消安裝 + Install cancelled. The download is kept for the next try. 已取消安裝。下載的檔案會保留供下次嘗試使用。 + Update cancelled 已取消更新 + Uninstall cancelled 已取消解除安裝 + Retry install 重試安裝 + Retry update 重試更新 + Retry update to {version} 重試更新至 {version} + The download is still here. It's checked again, then installed. 下載的檔案仍在。會再次檢查,然後安裝。 + Waiting for another app… 正在等待另一個應用程式… + Waiting for other installs… 正在等待其他安裝… + Installer (.msi) 安裝程式(.msi) + Portable (.zip) 免安裝版(.zip) + Disk image (.dmg) 磁碟影像(.dmg) + Debian package (.deb) Debian 套件(.deb) + RPM package (.rpm) RPM 套件(.rpm) + Arch package (.pkg.tar.zst) Arch 套件(.pkg.tar.zst) + Flatpak bundle Flatpak 組合包 + Tarball (.tar.gz) Tarball(.tar.gz) + App details 應用程式詳細資訊 + More actions for {name} {name} 的其他動作 + Close (Esc) 關閉(Esc) + Features 功能 + Latest release 最新發行版 + Version 版本 + Platforms 平台 + Releases 發行版本 + Enlarge picture 放大圖片 + Click to enlarge 按一下以放大 + Previous picture (←) 上一張圖片(←) + Next picture (→) 下一張圖片(→) + Loading pictures… 正在載入圖片… + Pictures couldn't be loaded. Reopen this page to try again. 無法載入圖片。請重新開啟此頁面再試一次。 +@msg Couldn't open {name}: {e} 無法開啟 {name}:{e} +@msg Download cancelled 已取消下載 +@msg {name} updated to {version} {name} 已更新至 {version} +@msg {name} installed: {version} {name} 已安裝:{version} +@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} 已更新至 {version}(此版本未公布檢查碼,因此僅核對檔案大小) +@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} 正在執行。請結束後再試一次。 +@msg {name} was installed from the {repo} repository or the AUR ({pkg}). Update it with {cmd} or sudo pacman -Syu {name} 是從 {repo} 儲存庫或 AUR 安裝的({pkg})。請使用 {cmd} 或 sudo pacman -Syu 更新。 +@msg {name} was installed from the {repo} repository or the AUR ({pkg}). Update it with your AUR helper (for example: yay -S {pkg}) or sudo pacman -Syu {name} 是從 {repo} 儲存庫或 AUR 安裝的({pkg})。請使用你的 AUR 助手(例如:yay -S {pkg})或 sudo pacman -Syu 更新。 +@msg {name} was installed from the AUR ({pkg}). Update it with {cmd} {name} 是從 AUR 安裝的({pkg})。請使用 {cmd} 更新。 +@msg {name} was installed from the AUR ({pkg}). Update it with your AUR helper (for example: yay -S {pkg}) {name} 是從 AUR 安裝的({pkg})。請使用你的 AUR 助手(例如:yay -S {pkg})更新。 + 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。 + On Linux, custom folders use AppImage format. 在 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 取代 + Recently updated 最近更新 + Name (A–Z) 名稱(A–Z) + Name (Z–A) 名稱(Z–A) + Frequently used 常用 + Next app 下一個應用程式 + Previous app 上一個應用程式 + Show {n} more changes 再顯示 {n} 項變更 + Last updated: {date} 上次更新:{date} +column Author 作者 +column Version 版本 + Choose version 選擇版本 + latest {version} 最新 {version} + {version} (latest release) {version}(最新版本) + Launcher version 啟動器版本 + Nightly Nightly + Nightly (none published yet) Nightly(尚未發行) + Install version {version} 安裝版本 {version} + Download selected version 下載所選版本 + Older launcher versions require a manual reinstall. 較舊的啟動器版本需要手動重新安裝。 + Your files may not open in older versions. 你的檔案可能無法在較舊的版本中開啟。 + Enable automatic installs in Settings to follow new nightly builds. 在設定中啟用自動安裝,即可跟隨新的 Nightly 建置。 +texturecraft 3D texture painting 3D 紋理繪製 +texturecraft Paint materials on 3D models and export textures. 在 3D 模型上繪製材質並匯出紋理。 +texturecraft Materials 材質 +texturecraft Mesh maps 網格貼圖 +texturecraft Bake mesh maps and use smart materials. 烘焙網格貼圖並使用智慧材質。 +texturecraft Paint textures on 3D models. 在 3D 模型上繪製紋理。 + Hide community apps 隱藏社群應用程式 +materialcraft Procedural material design 程序化材質設計 +materialcraft Build materials with node graphs. 使用節點圖建構材質。 +materialcraft Create tileable PBR materials and export texture maps. 建立可拼接的 PBR 材質並匯出紋理貼圖。 +materialcraft Node graphs 節點圖 +materialcraft Combine noises, patterns and filters. 組合雜訊、圖樣與濾鏡。 +materialcraft Live previews 即時預覽 +materialcraft Preview materials in 2D and on 3D models. 在 2D 與 3D 模型上預覽材質。 diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index fe520ca1..f8be326a 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -127,6 +127,8 @@ pub struct Settings { pub tray_hint_shown: bool, /// How many times each app was opened from the launcher (catalog id → count), for "Frequently used". pub launches: BTreeMap, + /// Catalog ids pinned to the top of the grid/table, in the order the user chose. + pub pinned: Vec, } impl Settings { @@ -140,6 +142,54 @@ impl Settings { } } + /// Whether `id` is in the pinned list. + pub fn is_pinned(&self, id: &str) -> bool { + self.pinned.iter().any(|p| p == id) + } + + /// Pin `id` at the end of the pinned list (no-op if already pinned). + pub fn pin_app(&mut self, id: &str) { + if id.is_empty() || self.is_pinned(id) { + return; + } + self.pinned.push(id.to_string()); + } + + /// Remove `id` from the pinned list. + pub fn unpin_app(&mut self, id: &str) { + self.pinned.retain(|p| p != id); + } + + /// The index of the next pinned id above (`delta = -1`) or below (`delta = 1`) `id` that passes + /// `available`. Skips stale catalog ids left in `pinned` so Move up/down still change the + /// visible order. + pub fn pinned_neighbor(&self, id: &str, delta: isize, available: impl Fn(&str) -> bool) -> Option { + let mut j = self.pinned.iter().position(|p| p == id)?; + loop { + j = j.checked_add_signed(delta)?; + if available(self.pinned.get(j)?) { + return Some(j); + } + } + } + + /// Move `from_id` to insertion slot `to_index` (`0..=len`, before remove). Used by drag-and-drop. + pub fn reorder_pinned_to(&mut self, from_id: &str, mut to_index: usize) { + let Some(from) = self.pinned.iter().position(|p| p == from_id) else { + return; + }; + to_index = to_index.min(self.pinned.len()); + // Already in this slot (including the no-op "insert immediately after self"). + if to_index == from || to_index == from + 1 { + return; + } + let item = self.pinned.remove(from); + if from < to_index { + to_index -= 1; + } + self.pinned.insert(to_index, item); + } + /// Display name for a language `code`: a language's name in itself, from the registry /// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English, /// with its own name added only while `installed` — the CJK face in the current font stack — is @@ -212,6 +262,71 @@ impl Default for Settings { hidden: std::collections::BTreeSet::new(), tray_hint_shown: false, launches: BTreeMap::new(), + pinned: Vec::new(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pin_appends_once_and_unpin_removes() { + let mut s = Settings::default(); + s.pin_app("photocraft"); + s.pin_app("photocraft"); + s.pin_app("filmcraft"); + assert_eq!(s.pinned, vec!["photocraft", "filmcraft"]); + s.unpin_app("photocraft"); + assert_eq!(s.pinned, vec!["filmcraft"]); + s.unpin_app("missing"); + assert_eq!(s.pinned, vec!["filmcraft"]); + } + + #[test] + fn reorder_pinned_to_uses_insertion_slot() { + let mut s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + // Insert c before a (slot 0). + s.reorder_pinned_to("c", 0); + assert_eq!(s.pinned, vec!["c", "a", "b"]); + // Insert c after b (slot 3). + s.reorder_pinned_to("c", 3); + assert_eq!(s.pinned, vec!["a", "b", "c"]); + // Insert a before c (slot 2) → [b, a, c]. + s.reorder_pinned_to("a", 2); + assert_eq!(s.pinned, vec!["b", "a", "c"]); + // Insert a after itself (slot 2) is a no-op, and so is an unknown id; a slot past the end + // means the end. + s.reorder_pinned_to("a", 2); + s.reorder_pinned_to("missing", 0); + assert_eq!(s.pinned, vec!["b", "a", "c"]); + s.reorder_pinned_to("b", 99); + assert_eq!(s.pinned, vec!["a", "c", "b"]); + } + + #[test] + fn old_settings_json_loads_with_empty_pinned() { + let s: Settings = serde_json::from_str(r#"{"view":"Grid"}"#).expect("legacy settings"); + assert!(s.pinned.is_empty()); + } + + #[test] + fn pinned_neighbor_steps_within_bounds() { + let s = Settings { pinned: vec!["a".into(), "b".into(), "c".into()], ..Settings::default() }; + let any = |_: &str| true; + assert_eq!(s.pinned_neighbor("b", -1, any), Some(0)); + assert_eq!(s.pinned_neighbor("b", 1, any), Some(2)); + assert_eq!(s.pinned_neighbor("a", -1, any), None); + assert_eq!(s.pinned_neighbor("c", 1, any), None); + assert_eq!(s.pinned_neighbor("missing", 1, any), None); + } + + #[test] + fn pinned_neighbor_skips_unavailable_ids() { + let s = Settings { pinned: vec!["a".into(), "gone".into(), "b".into()], ..Settings::default() }; + let live = |id: &str| id != "gone"; + assert_eq!(s.pinned_neighbor("a", 1, live), Some(2)); + assert_eq!(s.pinned_neighbor("b", -1, live), Some(0)); + } +} diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs index ccdb34a2..b7085e4f 100644 --- a/crates/ui-egui/src/views.rs +++ b/crates/ui-egui/src/views.rs @@ -425,10 +425,21 @@ impl LauncherUi { // ---- grid ---------------------------------------------------------------------------- - /// Installed apps first (`ids` arrives that way), then the rest under a "Not installed" header. + /// Pinned apps first (user order), then installed, then the rest under a "Not installed" header. pub(crate) fn grid(&mut self, ui: &mut Ui, ids: &[&'static str]) { - let installed = ids.iter().take_while(|id| self.is_installed(id)).count(); - let (top, rest) = ids.split_at(installed); + let pinned_n = ids.iter().take_while(|id| self.settings.is_pinned(id)).count(); + let (pinned, rest) = ids.split_at(pinned_n); + if !pinned.is_empty() { + let t = Tokens::get(ui.ctx()); + widgets::hud(ui, tl!("Pinned"), t.faint); + ui.add_space(12.0); + self.grid_block(ui, pinned); + if !rest.is_empty() { + ui.add_space(28.0); + } + } + let installed = rest.iter().take_while(|id| self.is_installed(id)).count(); + let (top, rest) = rest.split_at(installed); self.grid_block(ui, top); if !top.is_empty() && !rest.is_empty() { let t = Tokens::get(ui.ctx()); @@ -464,6 +475,7 @@ impl LauncherUi { /// pointer is over it. fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) { let Some(entry) = self.entry(id).cloned() else { return }; + self.pin_drop_target(ui, id, rect, ui.clip_rect(), PinDropAxis::Horizontal); let t = Tokens::get(ui.ctx()); let app = entry.app; let hovered = ui.rect_contains_pointer(rect); @@ -598,6 +610,7 @@ impl LauncherUi { // clipped to the scroll area. let clip = ui.clip_rect(); let span = ui.available_rect_before_wrap().x_range(); + let grip_w = self.grip_w(ui, ids); egui::Frame::new().fill(t.bg).stroke(t.hairline()).inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| { TableBuilder::new(ui) @@ -609,7 +622,7 @@ impl LauncherUi { .column(Column::exact(150.0)) .column(Column::exact(132.0)) .column(Column::exact(104.0)) - .column(Column::exact(206.0)) + .column(Column::exact(206.0 + grip_w)) .header(42.0, |header| self.table_header(header, &mut sort_clicked, &t, span, clip)) .body(|body| self.table_rows(body, ids, &t, (title_h, tagline_h), now, (span, clip))); }); @@ -717,6 +730,7 @@ impl LauncherUi { rule: (egui::emath::Rangef, egui::Rect), ) { let last = ids.len().saturating_sub(1); + let grip_w = self.grip_w(body.ui_mut(), ids); for (i, id) in ids.iter().enumerate() { let Some(entry) = self.entry(id).cloned() else { continue }; let status = entry.status(self.platform, self.caps.manage_apps); @@ -728,7 +742,16 @@ impl LauncherUi { row.col(|ui| { ui.label(RichText::new(entry.app.publisher).color(t.muted)); }); - row.col(|ui| self.actions(ui, &entry, &status, 204.0)); + // A pinned row's grip leads its actions; the other rows leave its room empty, so + // every row's buttons stay in line. + row.col(|ui| { + if self.settings.is_pinned(id) { + self.actions(ui, &entry, &status, 204.0 + grip_w); + } else { + ui.add_space(grip_w); + self.actions(ui, &entry, &status, 204.0); + } + }); }); } } @@ -750,9 +773,12 @@ impl LauncherUi { if rule { Self::table_rule(ui, t, span, clip); } + let row_rect = ui.max_rect(); + // Full-row target (covers the grip column too); only this target reorders in the table. + self.pin_drop_target(ui, id, Rect::from_x_y_ranges(span, row_rect.y_range()), clip, PinDropAxis::Vertical); // Clicking the app's cell opens its page (the content is added after, so it wins any hit). if AppDetails::by_id(id).is_some() { - let open = ui.interact(ui.max_rect(), ui.scope_id().with(("row-open", id)), Sense::click()); + let open = ui.interact(row_rect, ui.scope_id().with(("row-open", id)), Sense::click()); open.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fmt(tl!("{name} details"), &[("name", entry.app.name)]))); if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { self.detail = Some(id.to_string()); @@ -869,11 +895,17 @@ impl LauncherUi { } /// The main button and the `⋯` menu beside it. `links_in_menu` is false where the page shows - /// the website, GitHub and releases links as buttons of its own. - pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32, links_in_menu: bool) { + /// the website, GitHub and releases links as buttons of its own. In the grid and the table + /// (`links_in_menu`), a pinned app's actions start with the handle that reorders it, which takes + /// one square of `width` (the table widens its column by [`Self::grip_w`] for it). + pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, mut width: f32, links_in_menu: bool) { let id = entry.app.id.to_string(); - // One square button (the menu) beside the main one. let gap = ui.spacing().item_spacing.x; + if links_in_menu && self.settings.is_pinned(entry.app.id) { + self.pin_grip(ui, entry); + width -= theme::CONTROL_H + gap; + } + // One square button (the menu) beside the main one. let main_w = (width - theme::CONTROL_H - gap).max(120.0); if entry.job.is_some() { self.job_actions(ui, entry, &id, main_w, width); @@ -886,6 +918,68 @@ impl LauncherUi { self.menu(ui, entry, status, links_in_menu); } + /// The room a pin grip takes: one square and a gap while the list shows a pinned app (they come + /// first), else none. + fn grip_w(&self, ui: &Ui, ids: &[&'static str]) -> f32 { + if ids.first().is_some_and(|id| self.settings.is_pinned(id)) { theme::CONTROL_H + ui.spacing().item_spacing.x } else { 0.0 } + } + + /// Cancel an in-progress pin reorder (Escape), restoring the pinned order from drag start + /// (live hover already mutated `settings.pinned`). Called from [`Self::ui`] before the + /// empty-results branch so Escape still works when search/filter hides every app mid-drag. + pub(crate) fn clear_pin_drag_on_escape(&mut self, ui: &Ui) { + if self.pin_dragging.is_some() + && ui.input(|i| i.key_pressed(egui::Key::Escape)) + && let Some((_, original)) = self.pin_dragging.take() + { + self.settings.pinned = original; + } + } + + /// Drag handle that reorders this pinned app among the other pinned apps. + fn pin_grip(&mut self, ui: &mut Ui, entry: &AppEntry) { + let tip = fmt(tl!("Reorder {name}"), &[("name", entry.app.name)]); + let resp = widgets::grip_button(ui, &tip); + // Only latch on drag start: after a reorder, the same egui widget id can sit on a + // different app's row, and `dragged()` would steal `pin_dragging` mid-gesture. + // Do not clear on `drag_stopped` here — drop targets must see the release first; + // `LauncherUi::ui` clears `pin_dragging` after the list once the pointer is up. + if resp.drag_started() { + self.pin_dragging = Some((entry.app.id.to_string(), self.settings.pinned.clone())); + } + } + + /// While a pin handle is dragged, hovering another pinned card/row moves the dragged app there. + /// Grid uses the horizontal half; table uses the vertical half. One target per surface — the + /// table's full-row target already covers the grip column, so the grip itself does not reorder. + /// A release over a target still counts: `LauncherUi::ui` ends the drag after the list. + fn pin_drop_target(&mut self, ui: &Ui, id: &str, rect: Rect, viewport: Rect, axis: PinDropAxis) { + let Some((from, _)) = self.pin_dragging.as_ref() else { + return; + }; + let from = from.clone(); + if from == id || !self.settings.is_pinned(id) { + return; + } + let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { + return; + }; + // Layout includes clipped-off entries. Only the scroll viewport accepts drops; the + // table passes its full viewport rather than the narrower first-column clip. + if !rect.contains(pos) || !viewport.contains(pos) { + return; + } + let Some(to_i) = self.settings.pinned.iter().position(|p| p == id) else { + return; + }; + let insert_before = match axis { + PinDropAxis::Horizontal => pos.x < rect.center().x, + PinDropAxis::Vertical => pos.y < rect.center().y, + }; + let insertion_index = if insert_before { to_i } else { to_i.saturating_add(1) }; + self.settings.reorder_pinned_to(&from, insertion_index); + } + /// A running job's row: one progress bar, with pause and cancel while its download is under /// way, or only a cancel while the job waits for another app's installer. fn job_actions(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str, main_w: f32, width: f32) { @@ -1067,10 +1161,43 @@ impl LauncherUi { link(ui, tl!("GitHub repository"), entry.app.repo_url()); link(ui, tl!("All releases"), entry.app.releases_url()); } + self.menu_pin(ui, entry, &id); self.menu_hide(ui, entry, &id); }); } + /// Pin — or unpin — the app at the top of the list. Hidden apps are not offered Pin to top + /// (they can still Unpin if somehow still pinned). Move up/down reorder for keyboard users. + fn menu_pin(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { + ui.separator(); + let pinned = self.settings.is_pinned(entry.app.id); + let hidden = self.settings.hidden.contains(entry.app.id); + if pinned { + if ui.button(tl!("Unpin")).clicked() { + self.settings.unpin_app(id); + ui.close(); + } + // Neighbors must be on screen under the current search/filter — catalog presence alone + // would let Move skip over a filtered pin with no visible change. + let visible: std::collections::HashSet<&str> = self.visible_ids().into_iter().collect(); + let known = |pid: &str| visible.contains(pid); + // Up: into the slot before the neighbour above; down: into the slot after the one below. + let up = self.settings.pinned_neighbor(id, -1, known); + let down = self.settings.pinned_neighbor(id, 1, known).map(|j| j + 1); + for (slot, label) in [(up, tl!("Move up")), (down, tl!("Move down"))] { + if let Some(slot) = slot + && ui.button(label).clicked() + { + self.settings.reorder_pinned_to(id, slot); + ui.close(); + } + } + } else if !hidden && ui.button(tl!("Pin to top")).clicked() { + self.settings.pin_app(id); + ui.close(); + } + } + /// The menu items for an installed app: open, reveal in the file manager, reinstall, and /// uninstall (which asks first). fn menu_installed(&mut self, ui: &mut Ui, status: &AppStatus, id: &str, reveal: &'static str) { @@ -1119,6 +1246,7 @@ impl LauncherUi { } /// Hide — or unhide — the app, keeping the Hidden filter sane when the hidden set empties. + /// Hiding also clears a pin so a hidden app cannot float back into the main list. fn menu_hide(&mut self, ui: &mut Ui, entry: &AppEntry, id: &str) { ui.separator(); let hidden = self.settings.hidden.contains(entry.app.id); @@ -1129,6 +1257,7 @@ impl LauncherUi { self.settings.filter = Filter::All; } } else { + self.settings.unpin_app(id); self.settings.hidden.insert(id.to_string()); } ui.close(); @@ -1136,6 +1265,15 @@ impl LauncherUi { } } +/// Which half of a pin drop target decides insert-before vs insert-after. +#[derive(Clone, Copy, Debug)] +enum PinDropAxis { + /// Grid cards: left/right of the card. + Horizontal, + /// Table rows: above/below the row midline. + Vertical, +} + /// What the grid's sort dropdown offers: the order and, for names, which way. The installed apps /// always sit above the rest. const GRID_SORTS: [(SortKey, bool); 4] = [(SortKey::Name, false), (SortKey::Name, true), (SortKey::Released, true), (SortKey::Frequent, true)]; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d9dac090..6ce08a91 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -557,6 +557,8 @@ pub enum Glyph { Close, Settings, Refresh, + /// Six-dot grip for reordering pinned apps. + Grip, } /// Paints one of the launcher's symbols with the painter, centred in `rect`. @@ -613,9 +615,34 @@ fn glyph_shapes(rect: Rect, glyph: Glyph, color: Color32) -> Vec { egui::Shape::convex_polygon(vec![tip + vec2(-2.4, -0.6), tip + vec2(1.6, -3.0), tip + vec2(1.2, 1.8)], color, Stroke::NONE), ] } + Glyph::Grip => { + let mut shapes = Vec::with_capacity(6); + for (dx, dy) in [(-3.0, -3.5), (3.0, -3.5), (-3.0, 0.0), (3.0, 0.0), (-3.0, 3.5), (3.0, 3.5)] { + shapes.push(egui::Shape::circle_filled(m + vec2(dx, dy), 1.35, color)); + } + shapes + } } } +/// A drag handle for reordering pinned apps: same outline as [`glyph_button`], grab cursor. +pub fn grip_button(ui: &mut Ui, tip: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(theme::CONTROL_H, theme::CONTROL_H), Sense::drag()); + let hovered = resp.hovered() || resp.dragged(); + let p = ui.painter(); + if hovered { + p.rect_filled(rect, CornerRadius::ZERO, t.invert_bg); + } else { + p.rect_stroke(rect, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside); + } + let c = if hovered { t.invert_fg } else { t.muted }; + paint_glyph(p, rect, Glyph::Grip, c); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + let cursor = if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }; + resp.on_hover_text(tip).on_hover_cursor(cursor) +} + /// A small outlined square button with a drawn glyph; inverts on hover. pub fn glyph_button(ui: &mut Ui, glyph: Glyph, tip: &str) -> Response { let t = Tokens::get(ui.ctx()); @@ -739,7 +766,7 @@ mod tests { /// the tool-button and header-cell sizes, or a click target would draw outside its hit rect. #[test] fn every_painter_glyph_stays_inside_its_control() { - for glyph in [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh] { + for glyph in [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh, Glyph::Grip] { for side in [crate::theme::CONTROL_H, 48.0] { let rect = Rect::from_min_size(egui::pos2(100.0, 200.0), egui::vec2(side, side)); let shapes = glyph_shapes(rect, glyph, Color32::WHITE); @@ -797,11 +824,11 @@ mod tests { assert_eq!(opened_urls(&ctx), vec![artcraft_launcher_core::catalog::DISCORD_APP_URL.to_string()]); } - /// Each glyph must be distinct geometry, not the same fallback shape for all four. + /// Each glyph must be distinct geometry, not the same fallback shape for every variant. #[test] fn each_glyph_has_its_own_shapes() { let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(crate::theme::CONTROL_H, crate::theme::CONTROL_H)); - let bounds: Vec<[u32; 4]> = [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh] + let bounds: Vec<[u32; 4]> = [Glyph::Pause, Glyph::Close, Glyph::Settings, Glyph::Refresh, Glyph::Grip] .into_iter() .map(|glyph| { let union = glyph_shapes(rect, glyph, Color32::WHITE) diff --git a/crates/ui-egui/tests/chinese.rs b/crates/ui-egui/tests/chinese.rs index c0669db5..6f580cb4 100644 --- a/crates/ui-egui/tests/chinese.rs +++ b/crates/ui-egui/tests/chinese.rs @@ -95,6 +95,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/french.rs b/crates/ui-egui/tests/french.rs index 89a4f15d..fdbdadb6 100644 --- a/crates/ui-egui/tests/french.rs +++ b/crates/ui-egui/tests/french.rs @@ -69,6 +69,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs index 846b0a3f..b72867ad 100644 --- a/crates/ui-egui/tests/german.rs +++ b/crates/ui-egui/tests/german.rs @@ -94,6 +94,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/hungarian.rs b/crates/ui-egui/tests/hungarian.rs index 6c1738d9..dfaaa3a0 100644 --- a/crates/ui-egui/tests/hungarian.rs +++ b/crates/ui-egui/tests/hungarian.rs @@ -77,6 +77,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/japanese.rs b/crates/ui-egui/tests/japanese.rs index 8a8f4d42..77bb057c 100644 --- a/crates/ui-egui/tests/japanese.rs +++ b/crates/ui-egui/tests/japanese.rs @@ -95,6 +95,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } diff --git a/crates/ui-egui/tests/polish.rs b/crates/ui-egui/tests/polish.rs index e4ea4e41..dd7ea75f 100644 --- a/crates/ui-egui/tests/polish.rs +++ b/crates/ui-egui/tests/polish.rs @@ -69,6 +69,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/portuguese.rs b/crates/ui-egui/tests/portuguese.rs index 1f3d8157..93229a7d 100644 --- a/crates/ui-egui/tests/portuguese.rs +++ b/crates/ui-egui/tests/portuguese.rs @@ -67,6 +67,9 @@ fn events() -> Vec { source: InstallSource::Msi, location: format!("C:\\Program Files\\{}\\{}.exe", app.name, app.id), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/russian.rs b/crates/ui-egui/tests/russian.rs index c96bc2d6..5b019ed3 100644 --- a/crates/ui-egui/tests/russian.rs +++ b/crates/ui-egui/tests/russian.rs @@ -69,6 +69,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/spanish.rs b/crates/ui-egui/tests/spanish.rs index ceb1bad2..b7501135 100644 --- a/crates/ui-egui/tests/spanish.rs +++ b/crates/ui-egui/tests/spanish.rs @@ -69,6 +69,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed }); } diff --git a/crates/ui-egui/tests/traditional_chinese.rs b/crates/ui-egui/tests/traditional_chinese.rs new file mode 100644 index 00000000..597e983e --- /dev/null +++ b/crates/ui-egui/tests/traditional_chinese.rs @@ -0,0 +1,208 @@ +//! The Traditional Chinese (Taiwan) UI, in its own test binary: the UI language is process-wide, +//! so it can't share one with the English UI tests. Traditional Chinese is drawn with the system's +//! Traditional Chinese font (Noto Sans CJK TC first, then PingFang TC or Microsoft JhengHei); +//! where there is none (CI, minimal Linux installs), the UI stays in English and says why. +//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=` also saves screenshots (run with `-- --test-threads=1`). + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source}; +use artcraft_launcher_core::release::{Asset, Release}; +use artcraft_launcher_core::status::InstallSource; +use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version}; +use artcraft_launcher_ui_egui::settings::ViewMode; +use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +const NOW: i64 = 1_791_500_000; + +struct Fake { + events: Vec, + sent: Rc>>, + manage: bool, + clock: Rc>, +} + +impl Backend for Fake { + fn send(&mut self, command: Command) { + self.sent.borrow_mut().push(command); + } + fn poll(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + fn capabilities(&self) -> Capabilities { + Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage } + } + fn platform(&self) -> Option { + Some(Platform { os: Os::MacOs, arch: Arch::Arm64 }) + } + fn now(&self) -> i64 { + self.clock.get() + } +} + +struct NoHost; +impl Host for NoHost {} + +fn release(id: &str, v: &str, at: i64) -> Release { + let name = format!("{id}-{v}-macos-universal.dmg"); + Release { + tag: format!("v{v}"), + version: version::parse(v), + name: format!("v{v}"), + published_at: Some(at), + html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"), + notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(), + prerelease: false, + assets: vec![Asset { + name: name.clone(), + size: 150 << 20, + url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"), + sha256: None, + }], + } +} + +/// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release. +fn events() -> Vec { + let mut ev = Vec::new(); + let versions = [ + ("photocraft", "0.5.0"), + ("vectorcraft", "0.7.0"), + ("filmcraft", "0.4.0"), + ("lightcraft", "0.4.0"), + ("pdfcraft", "0.4.0"), + ("effectcraft", "0.6.0"), + ("designcraft", "0.4.0"), + ("wordcraft", "0.3.0"), + ("deckcraft", "0.3.0"), + ("gridcraft", "0.3.0"), + ("cadcraft", "0.3.0"), + ]; + for (i, (id, v)) in versions.iter().enumerate() { + ev.push(Event::Latest { + app: (*id).into(), + latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)), + from: Origin::ArtCraftApi, + }); + } + ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi }); + let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")]; + for app in &SUITE { + let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed { + version: version::parse(v), + source: InstallSource::MacApp, + location: format!("/Applications/{}.app", app.name), + package: None, + externally_managed: false, + origin: None, + installation: None, + }); + ev.push(Event::Detected { app: app.id.into(), installed: inst }); + } + ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } }); + ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) }); + ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } }); + ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None }); + ev +} + +fn harness_language( + view: ViewMode, + theme: egui::Theme, + manage: bool, + language: &str, +) -> (Harness<'static, Option>, Rc>>) { + 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: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) }); + let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() }; + let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).build_ui_state( + move |ui, state: &mut Option| { + if state.is_none() + && let Some(b) = backend.take() + { + let mut s = settings.clone(); + s.theme = if theme == egui::Theme::Dark { + artcraft_launcher_ui_egui::theme::ThemeChoice::Dark + } else { + artcraft_launcher_ui_egui::theme::ThemeChoice::Light + }; + *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s)); + } + if let Some(l) = state.as_mut() { + l.logic(&ui.ctx().clone(), &mut NoHost); + l.ui(ui, &mut NoHost); + } + }, + None, + ); + (harness, sent) +} + +fn snapshot(h: &mut Harness<'_, Option>, name: &str) { + let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return }; + let img = h.render().unwrap(); + img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap(); +} + +// One test: the UI language is process-wide. +#[test] +fn traditional_chinese_switches_persists_and_keeps_install_actions() { + use artcraft_launcher_ui_egui::i18n::Lang; + let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en"); + h.run_steps(6); + h.get_by_label("Settings").click(); + h.run_steps(3); + h.get_by_value("English").click(); + h.run_steps(2); + // Opening Settings doesn't load the (large) Traditional Chinese font: the picker names it in + // English. + assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(Lang::ZH_TW)); + // The list is long enough to scroll, so the last option may be out of view: click it through + // accesskit, which reaches widgets that aren't visible. + h.get_by_label("Traditional Chinese (Taiwan)").click_accesskit(); + h.run_steps(3); + if !artcraft_launcher_ui_egui::theme::cjk_font_available(Lang::ZH_TW) { + // No font for Traditional Chinese: choosing it keeps the UI in English, and Settings says + // why. + assert_eq!(h.state().as_ref().unwrap().settings.language, "zh-TW"); + h.get_by_label_contains("No font for this language was found"); + h.get_by_label("Notify me when an installed app has an update"); + snapshot(&mut h, "settings-zh-tw-no-font"); + return; + } + // Choosing it loaded the font: now the picker shows both names. + h.get_by_value("Traditional Chinese (Taiwan) (繁體中文)"); + h.get_by_label("已安裝的應用程式有更新時通知我"); + snapshot(&mut h, "settings-zh-tw-dark"); + let settings = h.state().as_ref().unwrap().settings.clone(); + assert_eq!(settings.language, "zh-TW"); + let saved = serde_json::to_string(&settings).unwrap(); + let restored: Settings = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, "zh-TW"); + h.get_by_label("完成").click(); + h.run_steps(3); + h.get_by_label("影像編輯"); + h.get_by_label_contains("上次檢查:3 分鐘前"); + h.get_by_label("更新至 0.5.0"); + // Product names stay in English. + assert!(h.query_by_label("Launcher").is_none()); + snapshot(&mut h, "grid-zh-tw-dark"); + h.get_by_label("全部更新(2)").click(); + h.run_steps(2); + let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect(); + assert_eq!(installs, ["photocraft", "effectcraft"]); + + let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language); + table.run_steps(6); + table.get_by_label("版本"); + assert!(table.query_all_by_label_contains("年10月").count() >= 1); + snapshot(&mut table, "table-zh-tw-light"); + let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language); + web.run_steps(6); + assert!(web.query_all_by_label("下載 macOS 版").count() >= 3); +} diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index d316a6cf..cdd7f792 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -189,6 +189,9 @@ fn events() -> Vec { source: InstallSource::MacApp, location: format!("/Applications/{}.app", app.name), package: None, + externally_managed: false, + origin: None, + installation: None, }); ev.push(Event::Detected { app: app.id.into(), installed: inst }); } @@ -558,6 +561,9 @@ fn choosing_an_app_version_turns_its_main_action_into_install() { source: InstallSource::MacApp, location: "/Applications/PhotoCraft.app".into(), package: None, + externally_managed: false, + origin: None, + installation: None, }), }); h.run_steps(4); @@ -1415,6 +1421,198 @@ fn table_view_and_light_theme() { assert!(sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. }))); } +#[test] +/// Pin floats an app above Name sort; reorder via settings is reflected; Hide clears the pin. +fn pinning_an_app_keeps_it_on_top_until_unpinned() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + // Name sort would put CADCraft before PhotoCraft; a pin must still win. + h.state_mut().as_mut().unwrap().settings.sort = artcraft_launcher_ui_egui::settings::SortKey::Name; + h.state_mut().as_mut().unwrap().settings.sort_descending = false; + h.run_steps(2); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Pin to top").click(); + h.run_steps(4); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["photocraft".to_string()]); + let ids = h.state().as_ref().unwrap().visible_ids_for_test(); + assert_eq!(ids.first().copied(), Some("photocraft"), "pinned app stays first under Name sort: {ids:?}"); + h.state_mut().as_mut().unwrap().settings.pin_app("filmcraft"); + h.state_mut().as_mut().unwrap().settings.reorder_pinned_to("photocraft", 2); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["filmcraft".to_string(), "photocraft".to_string()]); + assert_eq!(&h.state().as_ref().unwrap().visible_ids_for_test()[..2], &["filmcraft", "photocraft"]); + h.get_all_by_label_contains("Reorder").next().expect("pinned rows expose reorder handles"); +} + +#[test] +/// Hiding a pinned app also removes it from the pin list. +fn hide_clears_pin() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + h.state_mut().as_mut().unwrap().settings.pin_app("photocraft"); + h.run_steps(2); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Hide App").click(); + h.run_steps(4); + assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); + assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft")); +} + +#[test] +/// After pinning from the menu, opening it again offers Unpin and clears the pin. +fn pinned_app_menu_offers_unpin() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + h.get_by_label("Pin to top").click(); + h.run_steps(4); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, vec!["photocraft".to_string()]); + h.get_by_label("More actions for PhotoCraft").click(); + h.run_steps(3); + assert!(h.query_by_label("Pin to top").is_none(), "pinned apps must not keep offering Pin to top"); + h.get_by_label("Unpin").click(); + h.run_steps(4); + assert!(h.state().as_ref().unwrap().settings.pinned.is_empty()); +} + +#[test] +/// Escape during a pin drag restores the order from drag start (live hover already mutated pins). +fn escape_cancels_pin_drag_and_restores_order() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + { + let ui = h.state_mut().as_mut().unwrap(); + ui.settings.pin_app("photocraft"); + ui.settings.pin_app("filmcraft"); + ui.settings.pin_app("wordcraft"); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); + ui.begin_pin_drag_for_test("photocraft"); + // Mid-drag: pointer has already reordered via drop targets. + ui.settings.pinned = vec!["filmcraft".into(), "photocraft".into(), "wordcraft".into()]; + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + let ui = h.state().as_ref().unwrap(); + assert!(!ui.pin_dragging_for_test()); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft", "wordcraft"]); +} + +#[test] +/// Escape still restores pin order when search hides every app (grid/table never run). +fn escape_cancels_pin_drag_with_empty_results() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + { + let ui = h.state_mut().as_mut().unwrap(); + ui.settings.pin_app("photocraft"); + ui.settings.pin_app("filmcraft"); + ui.begin_pin_drag_for_test("photocraft"); + ui.settings.pinned = vec!["filmcraft".into(), "photocraft".into()]; + // Apply search without an intervening empty frame (pointer-up would end the drag). + ui.set_search_for_test("zzzz-no-match"); + assert!(ui.visible_ids_for_test().is_empty()); + assert!(ui.pin_dragging_for_test()); + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + let ui = h.state().as_ref().unwrap(); + assert!(!ui.pin_dragging_for_test()); + assert_eq!(ui.settings.pinned, ["photocraft", "filmcraft"]); +} + +#[test] +/// Grid shows a Pinned section label once at least one app is pinned. +fn grid_shows_pinned_section_header() { + let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true); + h.run_steps(6); + assert!(h.query_by_label("Pinned").is_none()); + h.state_mut().as_mut().unwrap().settings.pin_app("photocraft"); + h.run_steps(4); + h.get_by_label("Pinned"); + h.get_all_by_label_contains("Reorder PhotoCraft").next().expect("pinned card exposes a reorder handle"); +} + +#[test] +/// A pinned table row's grip leads its actions without taking room from them: its Open button is +/// as wide as an unpinned row's, and the menus stay in one line (#956: a squeezed button pushes +/// the menu out of the column). +fn a_pinned_table_row_keeps_its_buttons_in_line() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + // LightCraft and WordCraft are both installed and up to date: each row offers Open. + h.state_mut().as_mut().unwrap().settings.pin_app("lightcraft"); + h.run_steps(4); + let grip = h.get_all_by_label_contains("Reorder LightCraft").next().expect("pinned row has a grip").rect(); + let open_in_row = |menu: egui::Rect| { + let row = menu.y_range(); + h.query_all_by_label("Open").map(|n| n.rect()).find(|r| row.contains(r.center().y)).expect("an Open button in the row") + }; + let (pinned_menu, other_menu) = (h.get_by_label("More actions for LightCraft").rect(), h.get_by_label("More actions for WordCraft").rect()); + let (pinned_open, other_open) = (open_in_row(pinned_menu), open_in_row(other_menu)); + assert!(grip.right() <= pinned_open.left(), "grip {grip:?} leads the row's Open {pinned_open:?}"); + assert!((pinned_open.width() - other_open.width()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); + assert!((pinned_open.left() - other_open.left()).abs() < 0.5, "pinned {pinned_open:?} vs unpinned {other_open:?}"); + assert!((pinned_menu.right() - other_menu.right()).abs() < 0.5, "pinned {pinned_menu:?} vs unpinned {other_menu:?}"); +} + +#[test] +fn pin_drag_ignores_targets_clipped_by_the_scroll_viewport() { + for view in [ViewMode::Grid, ViewMode::Table] { + let (mut h, _) = harness(view, egui::Theme::Dark, true); + h.run_steps(6); + let pins = vec!["photocraft".to_string(), "lightcraft".to_string(), "wordcraft".to_string()]; + h.state_mut().as_mut().unwrap().settings.pinned = pins.clone(); + h.run_steps(4); + let grip = h.get_by_label("Reorder PhotoCraft").rect().center(); + let target = h.get_by_label("WordCraft details").rect(); + let at = match view { + ViewMode::Grid => egui::pos2(target.right() - 10.0, target.bottom() - 20.0), + ViewMode::Table => egui::pos2(h.get_by_label("Reorder WordCraft").rect().center().x, target.bottom() - 10.0), + }; + h.hover_at(grip); + h.run_steps(2); + h.drag_at(grip); + h.run_steps(2); + h.hover_at(grip + egui::vec2(0.0, -8.0)); + h.run_steps(2); + assert!(h.state().as_ref().unwrap().pin_dragging_for_test(), "{view:?}: real grip starts drag"); + // Keep the target's layout position, but clip it behind the window footer. + h.set_size(egui::vec2(1180.0, at.y + 2.0)); + h.hover_at(at); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, pins, "{view:?}: clipped hover"); + h.drop_at(at); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, pins, "{view:?}: clipped release"); + assert!(!h.state().as_ref().unwrap().pin_dragging_for_test()); + } +} + +#[test] +fn pin_drag_accepts_the_table_grip_column() { + let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true); + h.run_steps(6); + h.state_mut().as_mut().unwrap().settings.pinned = vec!["photocraft".into(), "lightcraft".into(), "wordcraft".into()]; + h.run_steps(4); + let from = h.get_by_label("Reorder PhotoCraft").rect().center(); + let target = h.get_by_label("Reorder WordCraft").rect(); + h.hover_at(from); + h.run_steps(2); + h.drag_at(from); + h.run_steps(2); + h.hover_at(from + egui::vec2(0.0, -8.0)); + h.run_steps(2); + assert!(h.state().as_ref().unwrap().pin_dragging_for_test()); + // Release directly in the last column, outside the first app cell's clip rectangle. + h.drop_at(egui::pos2(target.center().x, target.bottom() - 2.0)); + h.run_steps(2); + assert_eq!(h.state().as_ref().unwrap().settings.pinned, ["lightcraft", "wordcraft", "photocraft"]); + assert!(!h.state().as_ref().unwrap().pin_dragging_for_test()); +} + #[test] /// Hide removes an app from All into Hidden Apps; Unhide restores it, clears the hidden set and /// returns the filter to All. diff --git a/docs/localization-zh-hant.md b/docs/localization-zh-hant.md new file mode 100644 index 00000000..54fa7355 --- /dev/null +++ b/docs/localization-zh-hant.md @@ -0,0 +1,89 @@ +# 繁體中文(台灣)介面 / Traditional Chinese (Taiwan) interface + +更新日期 / Updated: 2026-11-03 + +## 繁體中文 + +在 **設定 → 語言** 選擇「繁體中文」,或把介面語言設為 `zh-TW`。設定會保存,並在下次啟動時 +沿用。首次啟動時,系統語系為 `zh-TW`、`zh-HK`、`zh-MO` 或 `zh-Hant` 會自動選擇繁體中文; +明確指定的 `Hans` 或 `Hant` 文字系統優先於地區,因此 `zh-Hant-CN` 仍使用繁體中文, +`zh-Hans-TW` 仍使用簡體中文。原有的 `zh`、`zh-CN`、`zh-SG` 與 `zh-Hans` 設定維持簡體中文。 + +翻譯檔位於 `crates/ui-egui/src/i18n/zh-TW.tsv`,沿用現有的英文查表機制,涵蓋 368 個介面 +項目,包含按鈕、選單、狀態、錯誤訊息、月份、表格欄位與各應用程式標語。譯文由英文原始字串 +重新撰寫,採用「應用程式、資料夾、設定、解除安裝、下載、更新、系統匣、捷徑、儲存庫、檢查碼」 +等台灣用語,並經台灣用語審核;沒有複製其他語系的翻譯。未翻譯的文字仍顯示英文。產品名稱 +(ArtCraft Launcher、Launcher、Craft App Suite、PhotoCraft 等)與指令 ID 維持原文。 + +想在桌面版臨時試用而不改設定,可以設定環境變數: + +```powershell +$env:ARTCRAFT_LAUNCHER_LOCALE = "zh-TW" +``` + +網頁版可在網址加上 `?lang=zh-TW`。 + +### Noto CJK 繁中字型 + +桌面版繁中介面優先使用已安裝的 **Noto Sans CJK TC**,其次使用支援的繁中系統字型: +macOS 的 PingFang TC/Heiti TC、Windows 的 Microsoft JhengHei/MingLiU,以及 Linux 與 +FreeBSD 的 Noto Sans CJK TC。請從 [Noto CJK 官方專案](https://github.com/notofonts/noto-cjk) +下載 `Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf`,並保留其 SIL Open Font License。 +字型檔不放入 ArtCraft Launcher 原始碼庫,也不會在執行時自動下載。 + +- Windows:將檔案安裝到 `%LOCALAPPDATA%\Microsoft\Windows\Fonts` 或 `%WINDIR%\Fonts`, + 保留檔名 `NotoSansCJKtc-Regular.otf`。也可放在使用者字型目錄下的 `NotoSansCJKtc` + 子目錄,讓更新版本優先於系統中正在使用的舊字型。 +- macOS:放在 `~/Library/Fonts` 或 `/Library/Fonts`。 +- Linux:放在 `~/.local/share/fonts`、`~/.fonts` 或既有的 Noto 字型目錄。 + `NotoSansCJK-Regular.ttc` 會依字型家族名稱選用其中的台灣字形。 + +安裝後重新啟動 ArtCraft Launcher。找不到繁中字型時,介面會維持英文,並在設定中說明原因; +網頁版沒有系統字型可用,也維持英文。這項修改不新增瀏覽器字型下載或打包。 + +## English + +Choose **繁體中文** in **Settings → Language**, or set the interface language to `zh-TW`. The +preference is saved and kept between launches. On first launch, a `zh-TW`, `zh-HK`, `zh-MO` or +`zh-Hant` system locale selects Traditional Chinese. An explicit `Hans` or `Hant` script takes +precedence over the region, so `zh-Hant-CN` still selects Traditional Chinese and `zh-Hans-TW` +still selects Simplified Chinese. Existing `zh`, `zh-CN`, `zh-SG` and `zh-Hans` preferences remain +Simplified Chinese. + +The catalog is `crates/ui-egui/src/i18n/zh-TW.tsv`. It uses the existing English-keyed lookup +mechanism and covers 368 interface entries: buttons, menus, status and error messages, months, +table columns and app taglines. Translations were written from the English source strings with +Taiwan software terminology (應用程式, 資料夾, 設定, 解除安裝, 下載, 更新, 系統匣, 捷徑, 儲存庫, +檢查碼) and reviewed for Taiwan usage; no other catalog's translations were copied. Untranslated +strings fall back to English. Product names (ArtCraft Launcher, Launcher, Craft App Suite, +PhotoCraft and the other apps) and command IDs stay as published. + +To try it on the desktop without changing the setting: + +```powershell +$env:ARTCRAFT_LAUNCHER_LOCALE = "zh-TW" +``` + +For the web build, add `?lang=zh-TW` to the address. + +### Noto CJK fonts + +The desktop Traditional Chinese interface prefers an installed **Noto Sans CJK TC**, then +supported Traditional Chinese system faces: PingFang TC or Heiti TC on macOS, Microsoft JhengHei +or MingLiU on Windows, and Noto Sans CJK TC on Linux and FreeBSD. Download +`Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf` from the +[official Noto CJK repository](https://github.com/notofonts/noto-cjk) and retain its SIL Open Font +License. Font binaries stay outside the ArtCraft Launcher repository and are never downloaded at +runtime. + +- Windows: install the file in `%LOCALAPPDATA%\Microsoft\Windows\Fonts` or `%WINDIR%\Fonts`, + keeping the filename `NotoSansCJKtc-Regular.otf`. The `NotoSansCJKtc` subdirectory of the + per-user font directory is checked first, so an updated file can take precedence over a face + already in use. +- macOS: use `~/Library/Fonts` or `/Library/Fonts`. +- Linux: use `~/.local/share/fonts`, `~/.fonts` or an existing Noto font directory. The merged + `NotoSansCJK-Regular.ttc` is matched by its Traditional Chinese family name. + +Restart ArtCraft Launcher after installing. Without a Traditional Chinese face the interface stays +in English and Settings explains why; the web build has no system font and does so too. This +change adds no browser font download or packaging.