Merge remote-tracking branch 'origin/main' into ka-review

# Conflicts:
#	README.md
#	crates/ui-egui/src/i18n/lang.rs
#	crates/ui-egui/src/i18n/tests.rs
This commit is contained in:
Dylan M. Taylor committed 2026-11-03 08:26:37 -05:00
commit 65f12df0a8
40 files changed
+2303 -138

No files matched your search

+21 -17
View File
@@ -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
+31 -1
View File
@@ -61,6 +61,23 @@ pub struct Installed {
/// The package that installed it, for package-manager installs (`photocraft-bin`).
#[serde(default)]
pub package: Option<String>,
/// 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`, `<app>-origin`), when known.
#[serde(default)]
pub origin: Option<String>,
/// The installation scope or name (`user`, `system`, or a named installation), for Flatpak installs.
#[serde(default)]
pub installation: Option<String>,
}
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());
}
}
+73 -5
View File
@@ -516,6 +516,37 @@ fn run_install(
approved: Option<&str>,
source: Option<&dyn ReleaseSource>,
) -> Result<String, String> {
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<Vec<Release>, 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]
+88 -15
View File
@@ -67,6 +67,9 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option<Installed> {
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<Installed> {
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<Installed> {
if let Some((path, version)) = appimages(app).into_iter().next() {
return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None });
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<Installed> {
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,
})
}
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
+290 -23
View File
@@ -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::<PackageKind>::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() };
+9 -1
View File
@@ -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 {
+165 -7
View File
@@ -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`, `<app>-origin`, `flathub`), when known.
pub origin: Option<String>,
/// The installation scope or name (`user`, `system`, or a named installation), for Flatpak installs.
pub installation: Option<String>,
}
/// `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<String>, Option<String>)> {
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 (<name>)`, 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 <id>`.
pub fn parse_flatpak_info(text: &str) -> Option<String> {
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<Package> {
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"));
}
}
+65
View File
@@ -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<std::process::Output, String> {
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");
}
}
@@ -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);
+41 -5
View File
@@ -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<String>)>,
}
/// 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::<Vec<_>, _>(|(_, 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()
}
+9 -1
View File
@@ -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
}
+109 -1
View File
@@ -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<Item = char>, wanted: &st
fn candidates(script: Script) -> Vec<Candidate> {
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<Candidate> {
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<Candidate> {
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<Candidate> {
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"));
+10
View File
@@ -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.
1 # Czech (cs) catalog for ArtCraft Launcher.
109
110
111
112
113
114
115
116
117
118
119
120
385 materialcraft
386 materialcraft
387
388
389
390
391
+10
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 11 and column 16.
+10
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 5 and column 48.
+10
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 5 and column 50.
+10
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 11 and column 16.
+10
View File
@@ -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 検証は無効になっています。
Can't render this file because it contains an unexpected character in line 14 and column 44.
+35 -17
View File
@@ -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<Lang> {
}
}
/// 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<Lang> {
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
+10
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 11 and column 15.
+10
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 265 and column 66.
+10
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 13 and column 44.
+67 -33
View File
@@ -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");
+10
View File
@@ -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 验证已禁用。
Can't render this file because it contains an unexpected character in line 4 and column 3.
+383
View File
@@ -0,0 +1,383 @@
# Traditional Chinese (Taiwan) catalog for ArtCraft Launcher.
# Format: context<TAB>English source<TAB>translation.
# 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 模型上預覽材質。
Can't render this file because it contains an unexpected character in line 4 and column 3.
+115
View File
@@ -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<String, u32>,
/// Catalog ids pinned to the top of the grid/table, in the order the user chose.
pub pinned: Vec<String>,
}
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<usize> {
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));
}
}
+147 -9
View File
@@ -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)];
+30 -3
View File
@@ -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> {
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)
+3
View File
@@ -95,6 +95,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -69,6 +69,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -94,6 +94,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -77,6 +77,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -95,6 +95,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -69,6 +69,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -67,6 +67,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -69,6 +69,9 @@ fn events() -> Vec<Event> {
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 });
}
+3
View File
@@ -69,6 +69,9 @@ fn events() -> Vec<Event> {
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 });
}
+208
View File
@@ -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=<dir>` 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<Event>,
sent: Rc<RefCell<Vec<Command>>>,
manage: bool,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, command: Command) {
self.sent.borrow_mut().push(command);
}
fn poll(&mut self) -> Vec<Event> {
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<Platform> {
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<Event> {
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<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
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<LauncherUi>| {
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<LauncherUi>>, 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);
}
+198
View File
@@ -189,6 +189,9 @@ fn events() -> Vec<Event> {
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.
+89
View File
@@ -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.