mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-11-10 20:32:55 +00:00
Merge remote-tracking branch 'origin/main' into feat/traditional-chinese-taiwan
This commit is contained in:
51 files changed
+2471
-279
No files matched your search
Generated
+1
@@ -6226,6 +6226,7 @@ version = "0.14.0"
|
||||
dependencies = [
|
||||
"jiff",
|
||||
"semver",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! macOS only: closing the window to the menu bar hides the app, as ⌘H does, rather than ordering
|
||||
//! the window out.
|
||||
//!
|
||||
//! macOS brings a hidden app back by itself: clicking its Dock icon, opening it again from Finder
|
||||
//! or Spotlight, or picking it with ⌘Tab all unhide it, window and all. A window that was only
|
||||
//! ordered out stays gone, as the Dock asks the app to "reopen" instead, and neither winit nor
|
||||
//! eframe passes that on (issue #456). Handling that event here would take an Apple Event handler,
|
||||
//! which needs `unsafe`.
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
|
||||
/// Hides the app and its window. Must run on the main thread, as the UI does; elsewhere it does
|
||||
/// nothing.
|
||||
pub fn hide() {
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
NSApplication::sharedApplication(mtm).hide(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings the app back from [`hide`] and makes it the active app. Main thread only, as [`hide`].
|
||||
pub fn unhide() {
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
NSApplication::sharedApplication(mtm).unhide(None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
#![cfg_attr(windows, windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod hiding;
|
||||
mod logging;
|
||||
mod motion;
|
||||
mod settings_file;
|
||||
@@ -745,6 +747,9 @@ impl Launcher {
|
||||
struct Shell<'a> {
|
||||
launcher: &'a mut Launcher,
|
||||
window_hides: bool,
|
||||
/// Whether the launcher last showed its window or put it away. On macOS the Dock, Finder and
|
||||
/// ⌘Tab can bring the window back without the launcher knowing; that only matters for the
|
||||
/// tray's left-click toggle, which macOS never sends, as a left click opens the tray menu.
|
||||
visible: bool,
|
||||
quitting: bool,
|
||||
/// The "show on the current desktop" setting last applied to the window (macOS).
|
||||
@@ -758,7 +763,13 @@ impl<'a> Shell<'a> {
|
||||
// Started hidden at login but there's no tray to come back from: show the window.
|
||||
let visible = !hidden || launcher.tray.is_none();
|
||||
let mut shell = Self { launcher, window_hides, visible: true, quitting: false, applied_follow: None };
|
||||
if !visible {
|
||||
if !visible && window_hides {
|
||||
// Started in the tray: the window stays out of sight. On macOS this doesn't hide the
|
||||
// app as `put_away` does, since the app is still being activated as it starts, which
|
||||
// would unhide it, window and all.
|
||||
shell.visible = false;
|
||||
cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
||||
} else if !visible {
|
||||
shell.put_away(&cc.egui_ctx);
|
||||
} else if hidden {
|
||||
cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
||||
@@ -775,6 +786,8 @@ impl<'a> Shell<'a> {
|
||||
let on = self.launcher.ui.settings.show_on_current_desktop;
|
||||
spaces::follow_current_desktop(on);
|
||||
self.applied_follow = Some(on);
|
||||
// Back from `put_away`, which hides the whole app here.
|
||||
hiding::unhide();
|
||||
}
|
||||
if self.window_hides {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
||||
@@ -786,10 +799,14 @@ impl<'a> Shell<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the window away in the tray: hidden where it can hide, closed where it can't.
|
||||
/// Puts the window away in the tray: hidden where it can hide, closed where it can't. On macOS
|
||||
/// the whole app hides, so its Dock icon brings the window back (`hiding`).
|
||||
fn put_away(&mut self, ctx: &egui::Context) {
|
||||
if self.window_hides {
|
||||
self.visible = false;
|
||||
#[cfg(target_os = "macos")]
|
||||
hiding::hide();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
||||
} else {
|
||||
self.launcher.closed_to_tray = true;
|
||||
|
||||
+43
-13
@@ -90,7 +90,7 @@ pub struct Notice {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// Only `https://` / `http://` links survive parsing.
|
||||
/// Only `https://` links survive parsing.
|
||||
pub link: Option<String>,
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
@@ -120,7 +120,8 @@ impl Feed {
|
||||
alerts: raw.important_notices.alerts.into_iter().filter_map(|n| n.into_notice("alert")).take(MAX_NOTICES).collect(),
|
||||
mirror: None,
|
||||
};
|
||||
// App renames are news too: show them with the announcements.
|
||||
// App renames are news too: show them with the announcements, inside the cap.
|
||||
feed.announcements.truncate(MAX_NOTICES.saturating_sub(feed.app_renames.len()));
|
||||
for r in &feed.app_renames {
|
||||
feed.announcements.push(Notice {
|
||||
id: format!("rename:{}:{}", r.old_name_lower, r.new_name_lower),
|
||||
@@ -130,6 +131,7 @@ impl Feed {
|
||||
created_at: r.renamed_at,
|
||||
});
|
||||
}
|
||||
feed.announcements.truncate(MAX_NOTICES);
|
||||
Some(feed)
|
||||
}
|
||||
|
||||
@@ -183,9 +185,9 @@ impl Feed {
|
||||
|
||||
impl FeedRelease {
|
||||
/// The release with only the downloads that belong to `repo` (or sit under `mirror`). `None`
|
||||
/// when it doesn't hold up (no tag, or not a version).
|
||||
/// when it doesn't hold up (no tag, a tag with odd characters, or not a version).
|
||||
fn to_release(&self, repo: &str, mirror: Option<&str>) -> Option<Release> {
|
||||
let version = version::parse(&self.tag)?;
|
||||
let version = version::parse(&self.tag).filter(|_| release::valid_tag(&self.tag))?;
|
||||
let allowed = |name: &str, url: &str| {
|
||||
(release::download_url_under(url, &release::download_prefix(repo))
|
||||
&& url == format!("{}{}/{}", release::download_prefix(repo), self.tag, name))
|
||||
@@ -415,13 +417,7 @@ impl RawNotice {
|
||||
if self.title.trim().is_empty() && self.body.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The link may reach the system opener (whose WSL fallback shells out), so refuse any
|
||||
// shell/cmd metacharacter or control character in it as well as whitespace (CWE-78).
|
||||
let link = self.maybe_web_link.filter(|l| {
|
||||
(l.starts_with("https://") || l.starts_with("http://"))
|
||||
&& l.len() <= 2000
|
||||
&& !l.chars().any(|c| c.is_whitespace() || c.is_control() || release::is_shell_meta(c))
|
||||
});
|
||||
let link = self.maybe_web_link.filter(|l| release::safe_web_link(l));
|
||||
Some(Notice {
|
||||
id: format!("{kind}:{}:{}", self.created_at.as_deref().unwrap_or(""), self.title.trim()),
|
||||
title: truncate(self.title.trim(), 200),
|
||||
@@ -633,7 +629,10 @@ mod tests {
|
||||
r#""alerts": [
|
||||
{"title": "Security update", "body": "Update PdfCraft now.", "maybe_web_link": "https://getartcraft.com/security", "created_at": null},
|
||||
{"title": "Bad link", "body": "x", "maybe_web_link": "javascript:alert(1)"},
|
||||
{"title": "Shell link", "body": "x", "maybe_web_link": "https://getartcraft.com/a|calc.exe"}]"#,
|
||||
{"title": "Shell link", "body": "x", "maybe_web_link": "https://getartcraft.com/a|calc.exe"},
|
||||
{"title": "Plain http", "body": "x", "maybe_web_link": "http://getartcraft.com/security"},
|
||||
{"title": "User-info", "body": "x", "maybe_web_link": "https://getartcraft.com@evil.example/"},
|
||||
{"title": "File link", "body": "x", "maybe_web_link": "file:///etc/passwd"}]"#,
|
||||
)
|
||||
.replace(
|
||||
"\"app_renames\": []",
|
||||
@@ -641,13 +640,44 @@ mod tests {
|
||||
"new_name_lower": "docucraft", "description": "Same app, new name.", "renamed_at": "2026-10-09T08:00:00Z"}]"#,
|
||||
);
|
||||
let feed = Feed::parse(&with_notices).unwrap();
|
||||
assert_eq!(feed.alerts.len(), 3);
|
||||
assert_eq!(feed.alerts.len(), 6);
|
||||
assert_eq!(feed.alerts[0].link.as_deref(), Some("https://getartcraft.com/security"));
|
||||
assert_eq!(feed.alerts[1].link, None, "only web links are kept");
|
||||
assert_eq!(feed.alerts[2].link, None, "a link with shell metacharacters is dropped");
|
||||
assert_eq!(feed.alerts[3].link, None, "plain http is dropped");
|
||||
assert_eq!(feed.alerts[4].link, None, "a link with user-info is dropped");
|
||||
assert_eq!(feed.alerts[5].link, None, "a file link is dropped");
|
||||
let titles: Vec<&str> = feed.announcements.iter().map(|n| n.title.as_str()).collect();
|
||||
assert_eq!(titles, ["New: CADCraft 1.0", "PdfCraft is now DocuCraft"]);
|
||||
// Ids are stable across parses, so dismissals stick.
|
||||
assert_eq!(Feed::parse(&with_notices).unwrap().alerts[0].id, feed.alerts[0].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_notices_count_toward_the_cap() {
|
||||
let rename = |i: usize| {
|
||||
format!(
|
||||
r#"{{"old_name": "PdfCraft{i}", "old_name_lower": "pdfcraft{i}", "new_name": "DocuCraft{i}", "new_name_lower": "docucraft{i}",
|
||||
"description": "Same app, new name.", "renamed_at": "2026-10-09T08:00:00Z"}}"#
|
||||
)
|
||||
};
|
||||
for (news, renames) in [(MAX_NOTICES, 1), (1000, 1), (0, 30)] {
|
||||
let many: Vec<String> = (0..news).map(|i| format!(r#"{{"title": "News {i}", "body": "x"}}"#)).collect();
|
||||
let moved: Vec<String> = (0..renames).map(rename).collect();
|
||||
let full = sample()
|
||||
.replace("\"announcements\": []", &format!("\"announcements\": [{}]", many.join(",")))
|
||||
.replace("\"app_renames\": []", &format!("\"app_renames\": [{}]", moved.join(",")));
|
||||
let notices = Feed::parse(&full).unwrap().announcements;
|
||||
assert_eq!(notices.len(), MAX_NOTICES, "{news} announcements, {renames} renames");
|
||||
assert!(notices.iter().any(|n| n.title == "PdfCraft0 is now DocuCraft0"), "{news} announcements: the rename was cut");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_release_with_an_odd_tag_asks_github() {
|
||||
for odd in ["v0.6.0~beta-\\u202e", "v0.6.0~beta-a/b"] {
|
||||
let feed = sample().replace("\"tag\": \"v0.6.0\"", &format!("\"tag\": \"{odd}\""));
|
||||
assert_eq!(Feed::parse(&feed).unwrap().answer(app("photocraft"), NOW), FeedAnswer::AskGitHub, "{odd:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,12 @@ pub enum Command {
|
||||
Install {
|
||||
app: String,
|
||||
},
|
||||
/// `Install`, writing over what [`Event::Occupied`] reported.
|
||||
/// `Install`, writing over what [`Event::Occupied`] reported. `path` is the path the user
|
||||
/// agreed to overwrite: if the install would now write somewhere else, nothing is replaced and
|
||||
/// the user is asked again.
|
||||
Replace {
|
||||
app: String,
|
||||
path: String,
|
||||
},
|
||||
Uninstall {
|
||||
app: String,
|
||||
|
||||
+72
-12
@@ -143,7 +143,7 @@ fn download_suffix_ok(rest: &str) -> bool {
|
||||
}
|
||||
let mut parts = rest.split('/');
|
||||
let (Some(tag), Some(name), None) = (parts.next(), parts.next(), parts.next()) else { return false };
|
||||
!tag.is_empty() && tag != "." && tag != ".." && tag.len() <= 64 && !tag.contains('\\') && valid_file_name(name)
|
||||
valid_tag(tag) && valid_file_name(name)
|
||||
}
|
||||
|
||||
/// Any `.` or `..` path segment that could climb out of the repository.
|
||||
@@ -228,7 +228,7 @@ fn release_from_value(v: &serde_json::Value, repo: &str) -> Result<Release, Stri
|
||||
if let Some(msg) = v["message"].as_str() {
|
||||
return Err(if msg == "Not Found" { "no releases yet".into() } else { format!("GitHub says: {}", truncate(msg, 200)) });
|
||||
}
|
||||
let tag = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
|
||||
let tag = v["tag_name"].as_str().filter(|t| valid_tag(t)).ok_or("no release found")?.to_string();
|
||||
let releases = format!("https://github.com/{repo}/releases");
|
||||
let html_url = v["html_url"].as_str().and_then(|u| safe_release_html_url(repo, u)).unwrap_or_else(|| releases.clone());
|
||||
let prefix = download_prefix(repo);
|
||||
@@ -284,7 +284,18 @@ pub fn release_from_sums(repo: &str, tag: &str, sums: &[(String, String)]) -> Re
|
||||
/// The tag in a `https://github.com/<repo>/releases/tag/<tag>` redirect target.
|
||||
pub fn tag_from_redirect(location: &str, repo: &str) -> Option<String> {
|
||||
let rest = location.strip_prefix(&format!("https://github.com/{repo}/releases/tag/"))?;
|
||||
(!rest.is_empty() && rest.len() <= 64 && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))).then(|| rest.to_string())
|
||||
valid_tag(rest).then(|| rest.to_string())
|
||||
}
|
||||
|
||||
/// A release tag we'd put in a URL, a file or the UI: up to 64 of `[A-Za-z0-9._+-]`, not
|
||||
/// starting with a dot, so no separators, control or bidi characters and no `.`/`..`.
|
||||
pub fn valid_tag(tag: &str) -> bool {
|
||||
tag.len() <= 64 && !tag.starts_with('.') && plain_name(tag)
|
||||
}
|
||||
|
||||
/// Non-empty and only `[A-Za-z0-9._+-]`, the characters every Craft release tag and asset uses.
|
||||
fn plain_name(s: &str) -> bool {
|
||||
!s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
|
||||
}
|
||||
|
||||
/// Reads `sha256sum` output: `<hex> <name>` (or `<hex> *<name>`) per line.
|
||||
@@ -298,14 +309,16 @@ pub fn parse_sha256sums(text: &str) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A bare file name we'd be willing to save: no paths, no control characters.
|
||||
/// A bare file name we'd be willing to save: only `[A-Za-z0-9._+-]`, no leading or trailing dot,
|
||||
/// and not a name Windows reserves for a device (`CON`, `nul.txt`, `COM1.msi`).
|
||||
pub fn valid_file_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 200
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& !name.starts_with('.')
|
||||
&& !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*'))
|
||||
name.len() <= 200 && !name.starts_with('.') && !name.ends_with('.') && plain_name(name) && !windows_device(name)
|
||||
}
|
||||
|
||||
fn windows_device(name: &str) -> bool {
|
||||
let stem = name.split('.').next().unwrap_or_default().to_ascii_uppercase();
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| matches!(stem.as_bytes(), [b'C', b'O', b'M', b'1'..=b'9'] | [b'L', b'P', b'T', b'1'..=b'9'])
|
||||
}
|
||||
|
||||
/// `s` cut to at most `max` bytes (on a char boundary) with an ellipsis, for text from a
|
||||
@@ -524,9 +537,56 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn file_names_cannot_escape() {
|
||||
for bad in ["", "..", "a/b", "a\\b", ".hidden", "c:x", "x\u{0}"] {
|
||||
for bad in [
|
||||
"",
|
||||
"..",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
".hidden",
|
||||
"c:x",
|
||||
"x\u{0}",
|
||||
"a b.dmg",
|
||||
"a%2e.msi",
|
||||
"a#b",
|
||||
"evil\u{202e}gpj.exe",
|
||||
"x.msi.",
|
||||
"CON",
|
||||
"nul.txt",
|
||||
"COM1.msi",
|
||||
"lpt9",
|
||||
"prn",
|
||||
"aux.log",
|
||||
"a".repeat(201).as_str(),
|
||||
] {
|
||||
assert!(!valid_file_name(bad), "{bad:?}");
|
||||
}
|
||||
assert!(valid_file_name("photocraft-0.5.0-linux-x86_64.AppImage"));
|
||||
for good in [
|
||||
"photocraft-0.5.0-linux-x86_64.AppImage",
|
||||
"lightcraft_0.9.0_amd64.deb",
|
||||
"x-1.0+git.tar.gz",
|
||||
"console.zip",
|
||||
"COM10.txt",
|
||||
"COM0.msi",
|
||||
"lpt0",
|
||||
] {
|
||||
assert!(valid_file_name(good), "{good:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_are_plain() {
|
||||
for good in ["v0.6.0", "v1.0.0-beta.2", "nightly-2026-10-29", "v1+build.7"] {
|
||||
assert!(valid_tag(good), "{good:?}");
|
||||
}
|
||||
for bad in ["", ".", "..", ".v1", "v1/x", "v1\\x", "v1\nx", "v1\u{0}x", "v1\u{202e}", "v 1", "v1?x", "v".repeat(65).as_str()] {
|
||||
assert!(!valid_tag(bad), "{bad:?}");
|
||||
}
|
||||
let api = r#"{"tag_name": "v1\u202egpj", "html_url": "", "assets": []}"#;
|
||||
assert!(parse_github_release(api, REPO).is_err());
|
||||
let prefix = download_prefix(REPO);
|
||||
assert!(download_url_under(&format!("{prefix}v1/a.dmg"), &prefix));
|
||||
assert!(!download_url_under(&format!("{prefix}v1\u{202e}/a.dmg"), &prefix), "an odd tag in a download link");
|
||||
assert!(!download_url_under(&format!("{prefix}v1/a\u{202e}.dmg"), &prefix), "an odd name in a download link");
|
||||
assert_eq!(tag_from_redirect(&format!("https://github.com/{REPO}/releases/tag/v1\u{202e}"), REPO), None);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ winreg = "0.55"
|
||||
# Resolves junctions/symlinks without forcing short MSI paths into the verbatim namespace.
|
||||
dunce = "1"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
# Safe wrappers for waiting for another process and reading a file's VERSIONINFO.
|
||||
winsafe = { version = "0.0.29", features = ["kernel", "version"] }
|
||||
# Safe wrappers for waiting for another process, reading a file's VERSIONINFO and this account's SID.
|
||||
winsafe = { version = "0.0.29", features = ["kernel", "version", "advapi"] }
|
||||
# Authenticode checks through WinTrust: the update helper installs only files from the launcher's publisher.
|
||||
verifysign = "1"
|
||||
+128
-15
@@ -306,7 +306,7 @@ impl NativeBackend {
|
||||
}
|
||||
|
||||
fn start_job(&self, app_id: &str, work: Work) {
|
||||
let uninstall = work == Work::Uninstall;
|
||||
let uninstall = matches!(work, Work::Uninstall);
|
||||
let Some(app) = CraftApp::by_id(app_id) else { return };
|
||||
log::info!("{}: starting {} job", app.name, if uninstall { "uninstall" } else { "install/update" });
|
||||
let stop = Arc::new(Stop::default());
|
||||
@@ -361,7 +361,10 @@ impl NativeBackend {
|
||||
existing.as_ref(),
|
||||
record.as_ref(),
|
||||
&stop,
|
||||
work == Work::Replace,
|
||||
match &work {
|
||||
Work::Replace(path) => Some(path.as_str()),
|
||||
Work::Install | Work::Uninstall => None,
|
||||
},
|
||||
source.as_deref(),
|
||||
);
|
||||
// Stopped by the connection rather than by the user: say so, and how to go on.
|
||||
@@ -456,10 +459,11 @@ impl NativeBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum Work {
|
||||
Install,
|
||||
Replace,
|
||||
/// Install over the path the user agreed to overwrite (see [`Command::Replace`]).
|
||||
Replace(String),
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
@@ -509,9 +513,40 @@ fn run_install(
|
||||
existing: Option<&Installed>,
|
||||
record: Option<&crate::manifest::Record>,
|
||||
stop: &Stop,
|
||||
replace: bool,
|
||||
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);
|
||||
}
|
||||
@@ -566,10 +601,15 @@ fn run_install(
|
||||
} else {
|
||||
previous.as_ref().zip(previous_asset.as_ref()).map(|(previous, asset)| install::Downgrade::Reinstall { previous, asset })
|
||||
};
|
||||
if !replace && let Some(path) = install::occupied(app, &asset, kind, existing, record, &options.install_locations, paths)? {
|
||||
shared.emit(Event::Occupied { app: app.id.to_string(), path: path.display().to_string() });
|
||||
return Err(format!("{} already exists", path.display()));
|
||||
}
|
||||
let occupied = install::occupied(app, &asset, kind, existing, record, &options.install_locations, paths)?;
|
||||
let replace = match replace_consent(occupied.as_deref(), approved) {
|
||||
Consent::Free => false,
|
||||
Consent::Given => true,
|
||||
Consent::Ask(path) => {
|
||||
shared.emit(Event::Occupied { app: app.id.to_string(), path: path.display().to_string() });
|
||||
return Err(format!("{} already exists", path.display()));
|
||||
}
|
||||
};
|
||||
let mut last_emit = Instant::now() - Duration::from_secs(1);
|
||||
let mut phase = |p: JobPhase| {
|
||||
// Downloads report thousands of times; the UI needs ~20 a second.
|
||||
@@ -606,6 +646,29 @@ fn run_install(
|
||||
Ok(installed_text(app, existing.is_some(), &release))
|
||||
}
|
||||
|
||||
/// Whether an install may write over what is at its destination.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Consent<'a> {
|
||||
/// Nothing the launcher didn't put there is in the way.
|
||||
Free,
|
||||
/// The user agreed to overwrite exactly this destination.
|
||||
Given,
|
||||
/// Something is in the way and the user hasn't agreed to overwrite it: ask.
|
||||
Ask(&'a std::path::Path),
|
||||
}
|
||||
|
||||
/// The user's agreement to replace a file covers the path they were shown (`approved`), and only
|
||||
/// that one. The release can change between the question and the answer (a new version, another
|
||||
/// asset, another folder), and the install then writes somewhere else: it must not take the old
|
||||
/// "yes" as leave to overwrite a different file (CWE-367).
|
||||
fn replace_consent<'a>(occupied: Option<&'a std::path::Path>, approved: Option<&str>) -> Consent<'a> {
|
||||
match occupied {
|
||||
None => Consent::Free,
|
||||
Some(path) if approved == Some(path.display().to_string().as_str()) => Consent::Given,
|
||||
Some(path) => Consent::Ask(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a finished install tells the user.
|
||||
fn installed_text(app: &CraftApp, update: bool, release: &Release) -> String {
|
||||
let verb = if update { "updated to" } else { "installed:" };
|
||||
@@ -1146,7 +1209,7 @@ impl Backend for NativeBackend {
|
||||
});
|
||||
}
|
||||
Command::Install { app } => self.start_job(&app, Work::Install),
|
||||
Command::Replace { app } => self.start_job(&app, Work::Replace),
|
||||
Command::Replace { app, path } => self.start_job(&app, Work::Replace(path)),
|
||||
Command::Uninstall { app } => self.start_job(&app, Work::Uninstall),
|
||||
Command::Pause { app } => {
|
||||
if let Some(stop) = self.shared.jobs.lock().unwrap_or_else(PoisonError::into_inner).get(&app) {
|
||||
@@ -1258,9 +1321,30 @@ mod tests {
|
||||
Release { tag: format!("v{v}"), version: version::parse(v), ..Default::default() }
|
||||
}
|
||||
|
||||
/// A "Replace" answer covers the path that was shown, never a destination that changed since.
|
||||
#[test]
|
||||
fn replacing_needs_consent_for_the_exact_path_in_the_way() {
|
||||
let shown = std::path::Path::new("/home/a/Applications/photocraft-0.5.0-linux-x86_64.AppImage");
|
||||
let moved = std::path::Path::new("/home/a/Applications/photocraft-0.6.0-linux-x86_64.AppImage");
|
||||
let approved = shown.display().to_string();
|
||||
assert_eq!(replace_consent(None, None), Consent::Free);
|
||||
assert_eq!(replace_consent(None, Some(&approved)), Consent::Free, "nothing in the way: nothing to overwrite");
|
||||
assert_eq!(replace_consent(Some(shown), None), Consent::Ask(shown));
|
||||
assert_eq!(replace_consent(Some(shown), Some(&approved)), Consent::Given);
|
||||
assert_eq!(replace_consent(Some(moved), Some(&approved)), Consent::Ask(moved), "a yes for one file must not overwrite another");
|
||||
}
|
||||
|
||||
#[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)));
|
||||
@@ -1272,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.
|
||||
@@ -1657,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 {
|
||||
@@ -1664,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(),
|
||||
@@ -1695,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),
|
||||
@@ -1896,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]
|
||||
|
||||
+95
-21
@@ -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
|
||||
@@ -507,11 +539,7 @@ pub mod linux {
|
||||
|
||||
/// Where a package put the app's binary: on `PATH`, else `/usr/bin/<id>`.
|
||||
fn binary_on_path(id: &str) -> String {
|
||||
std::env::var_os("PATH")
|
||||
.and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()))
|
||||
.unwrap_or_else(|| PathBuf::from("/usr/bin").join(id))
|
||||
.display()
|
||||
.to_string()
|
||||
crate::process::which(id).unwrap_or_else(|| PathBuf::from("/usr/bin").join(id)).display().to_string()
|
||||
}
|
||||
|
||||
/// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention).
|
||||
@@ -560,15 +588,42 @@ pub mod linux {
|
||||
appimages_in(app, &appimage_dirs())
|
||||
}
|
||||
|
||||
/// How the app is installed, in this order: an AppImage the launcher put down, a package
|
||||
/// (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)
|
||||
}
|
||||
@@ -579,15 +634,26 @@ 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`.
|
||||
fn find_binary(id: &str) -> Option<PathBuf> {
|
||||
let on_path = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()));
|
||||
let on_path = crate::process::which(id);
|
||||
let usual = ["/usr/bin", "/usr/local/bin"].into_iter().map(PathBuf::from).chain(dirs::home_dir().map(|h| h.join(".local/bin")));
|
||||
on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file()))
|
||||
}
|
||||
|
||||
/// The version `bin --version` prints, or `None` when it can't be started, doesn't finish
|
||||
/// within 5 seconds (it is then killed), exits with an error, or prints no version.
|
||||
pub(crate) fn binary_version(bin: &Path, app: &CraftApp) -> Option<version::Version> {
|
||||
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
|
||||
parse_cli_version(out.trim(), app)
|
||||
@@ -606,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)`.
|
||||
|
||||
@@ -90,8 +90,11 @@ impl FeedThrottle {
|
||||
/// changes. A missing or malformed file just means no backoff is pending.
|
||||
pub fn load(file: &Path, now: i64) -> Self {
|
||||
let mut saved = std::fs::read(file).ok().and_then(|b| serde_json::from_slice::<Saved>(&b).ok()).unwrap_or_default();
|
||||
// A wait far in the future means a broken file or a clock that jumped back: drop it.
|
||||
if saved.not_before > now + MAX_THROTTLE_BACKOFF * 2 {
|
||||
// A saved wait outside the sane range means a broken file, an edited value or a clock
|
||||
// that jumped: drop it. A `not_before` near `i64::MIN` would otherwise wrap the
|
||||
// subtraction in `wait` into a centuries-long wait (#774). The upper limit covers the
|
||||
// longest accepted Retry-After stretched by the maximum 429 spread.
|
||||
if !(0..=now.saturating_add(MAX_RETRY_AFTER * 3 / 2)).contains(&saved.not_before) {
|
||||
saved.not_before = 0;
|
||||
}
|
||||
Self { saved, file: Some(file.to_path_buf()), ..Self::new() }
|
||||
@@ -112,7 +115,7 @@ impl FeedThrottle {
|
||||
let gap = WARM_GAPS.get(self.saved.failures as usize).or(WARM_GAPS.last()).copied().unwrap_or(WINDOW);
|
||||
at = at.max(last + gap);
|
||||
}
|
||||
(at - now).max(0)
|
||||
at.saturating_sub(now).max(0)
|
||||
}
|
||||
|
||||
/// Notes that a request is being sent now.
|
||||
@@ -375,4 +378,66 @@ mod tests {
|
||||
std::fs::write(&file, br#"{"unknown": true}"#).unwrap();
|
||||
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurdly_old_saved_not_before_is_no_wait() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("feed-throttle.json");
|
||||
// A hostile or corrupted `not_before` near i64::MIN once wrapped `at - now` into a
|
||||
// wait of centuries (a debug-build panic, #774).
|
||||
std::fs::write(&file, br#"{"not_before": -9223372036854775808, "throttles": 1}"#).unwrap();
|
||||
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
|
||||
std::fs::write(&file, br#"{"not_before": -60, "throttles": 1}"#).unwrap();
|
||||
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_not_before_at_the_maximum_retry_after_spread_survives_but_one_second_more_does_not() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("feed-throttle.json");
|
||||
let max_wait = MAX_RETRY_AFTER * 3 / 2;
|
||||
std::fs::write(&file, format!(r#"{{"not_before": {}, "throttles": 1}}"#, NOW + max_wait)).unwrap();
|
||||
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), max_wait);
|
||||
std::fs::write(&file, format!(r#"{{"not_before": {}, "throttles": 1}}"#, NOW + max_wait + 1)).unwrap();
|
||||
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximum_retry_after_with_maximum_429_spread_survives_a_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("feed-throttle.json");
|
||||
let mut t = FeedThrottle::load(&file, NOW);
|
||||
t.finished(Outcome::Throttled { retry_after: Some(MAX_RETRY_AFTER) }, NOW, 1.0);
|
||||
assert_eq!(t.wait(NOW), MAX_RETRY_AFTER * 3 / 2);
|
||||
let again = FeedThrottle::load(&file, NOW);
|
||||
assert_eq!(again.wait(NOW), MAX_RETRY_AFTER * 3 / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurdly_late_saved_not_before_is_no_wait_but_a_sane_one_stands() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("feed-throttle.json");
|
||||
std::fs::write(&file, br#"{"not_before": 9223372036854775807, "throttles": 1}"#).unwrap();
|
||||
assert_eq!(FeedThrottle::load(&file, NOW).wait(NOW), 0);
|
||||
std::fs::write(&file, format!(r#"{{"not_before": {}, "throttles": 1}}"#, NOW + 600)).unwrap();
|
||||
let t = FeedThrottle::load(&file, NOW);
|
||||
assert_eq!(t.wait(NOW), 600);
|
||||
assert!(t.throttled(NOW));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_counts_at_their_maximum_cap_the_backoff_without_overflowing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("feed-throttle.json");
|
||||
let saved = format!(r#"{{"failures": {m}, "throttles": {m}}}"#, m = u32::MAX);
|
||||
std::fs::write(&file, &saved).unwrap();
|
||||
let mut t = FeedThrottle::load(&file, NOW);
|
||||
t.warmed = true;
|
||||
t.finished(Outcome::Failed { retry_after: None }, NOW, 0.5);
|
||||
assert_eq!(t.wait(NOW), MAX_FAILURE_BACKOFF);
|
||||
std::fs::write(&file, &saved).unwrap();
|
||||
let mut t = FeedThrottle::load(&file, NOW);
|
||||
t.finished(Outcome::Throttled { retry_after: None }, NOW, 0.0);
|
||||
assert_eq!(t.wait(NOW), MAX_THROTTLE_BACKOFF);
|
||||
}
|
||||
}
|
||||
+290
-23
@@ -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() };
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Read-only MSI registration, limited to the current account and machine contexts.
|
||||
# $code is supplied by the caller only after strict braced-GUID validation.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installer = $null
|
||||
$products = $null
|
||||
try {
|
||||
$installer = New-Object -ComObject WindowsInstaller.Installer
|
||||
$products = $installer.ProductsEx($code, '', 7)
|
||||
foreach ($product in $products) {
|
||||
try {
|
||||
$context = $product.GetType().InvokeMember('Context', 'GetProperty', $null, $product, $null)
|
||||
Write-Output ([int]$context)
|
||||
} finally {
|
||||
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($product)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
foreach ($com in @($products, $installer)) {
|
||||
if ($null -ne $com) {
|
||||
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ pub(crate) enum ProbeError {
|
||||
Unknown,
|
||||
/// The same product is registered for this account and for everyone.
|
||||
Conflicting,
|
||||
/// The lookup could not run (a blocked shell, a timeout, a slow first start). Carries the real
|
||||
/// The lookup could not run (a registration key it may not read, no account identity). Carries the real
|
||||
/// error so it can be logged.
|
||||
Unavailable(String),
|
||||
}
|
||||
@@ -72,12 +72,14 @@ pub(crate) fn valid_product_code(code: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_contexts(output: &str) -> Result<MsiScope, ProbeError> {
|
||||
/// The one context the product is registered in: 1 and 2 (managed and unmanaged, this account) are
|
||||
/// per-user, 4 is the machine. None, any other value, or both kinds is never guessed from.
|
||||
fn scope_of(contexts: &[u32]) -> Result<MsiScope, ProbeError> {
|
||||
let mut scope = None;
|
||||
for line in output.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||
let context = match line {
|
||||
"1" | "2" => MsiScope::PerUser,
|
||||
"4" => MsiScope::PerMachine,
|
||||
for context in contexts {
|
||||
let context = match context {
|
||||
1 | 2 => MsiScope::PerUser,
|
||||
4 => MsiScope::PerMachine,
|
||||
_ => return Err(ProbeError::Unknown),
|
||||
};
|
||||
if scope.is_some_and(|previous| previous != context) {
|
||||
@@ -88,26 +90,59 @@ fn parse_contexts(output: &str) -> Result<MsiScope, ProbeError> {
|
||||
scope.ok_or(ProbeError::Unknown)
|
||||
}
|
||||
|
||||
/// Windows Installer's packed form of a validated product code, the name of its registration keys:
|
||||
/// the first three groups reversed, then each remaining byte's two digits swapped.
|
||||
fn packed_code(code: &str) -> Option<String> {
|
||||
if !valid_product_code(code) {
|
||||
return None;
|
||||
}
|
||||
let hex: Vec<char> = code.chars().filter(char::is_ascii_hexdigit).map(|c| c.to_ascii_uppercase()).collect();
|
||||
let (groups, rest) = hex.split_at_checked(16)?;
|
||||
let mut packed = String::with_capacity(32);
|
||||
for range in [0..8, 8..12, 12..16] {
|
||||
packed.extend(groups.get(range)?.iter().rev());
|
||||
}
|
||||
for pair in rest.chunks(2) {
|
||||
packed.extend(pair.iter().rev());
|
||||
}
|
||||
Some(packed)
|
||||
}
|
||||
|
||||
/// Reads the product's registration for this account and the machine, as `ProductsEx` does, from
|
||||
/// Windows Installer's own keys: no other account, and never `Win32_Product` (which repairs).
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn probe(product_code: &str) -> Result<MsiScope, ProbeError> {
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY};
|
||||
|
||||
if !valid_product_code(product_code) {
|
||||
return Err(ProbeError::Unknown);
|
||||
let packed = packed_code(product_code).ok_or(ProbeError::Unknown)?;
|
||||
let sid = current_user_sid().map_err(ProbeError::Unavailable)?;
|
||||
let keys = [
|
||||
(1, HKEY_LOCAL_MACHINE, format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Managed\{sid}\Installer\Products\{packed}")),
|
||||
(2, HKEY_CURRENT_USER, format!(r"Software\Microsoft\Installer\Products\{packed}")),
|
||||
(4, HKEY_LOCAL_MACHINE, format!(r"SOFTWARE\Classes\Installer\Products\{packed}")),
|
||||
];
|
||||
let mut contexts = Vec::new();
|
||||
for (context, hive, path) in keys {
|
||||
match RegKey::predef(hive).open_subkey_with_flags(&path, KEY_READ | KEY_WOW64_64KEY) {
|
||||
Ok(_) => contexts.push(context),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
// A key we may not read is a lookup that didn't run, not a product that isn't there.
|
||||
Err(e) => return Err(ProbeError::Unavailable(format!("couldn't read the Windows Installer registration ({e})"))),
|
||||
}
|
||||
}
|
||||
scope_of(&contexts)
|
||||
}
|
||||
|
||||
/// This account's SID, which names its managed (policy-installed) registrations.
|
||||
#[cfg(windows)]
|
||||
fn current_user_sid() -> Result<String, String> {
|
||||
use winsafe::{HACCESSTOKEN, TokenInfo, co};
|
||||
let failed = |e: winsafe::co::ERROR| format!("couldn't identify this account ({e})");
|
||||
match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(co::TOKEN_INFORMATION_CLASS::User).map_err(failed)? {
|
||||
TokenInfo::User(user) => user.User.Sid().map(|sid| sid.to_string()).ok_or_else(|| "couldn't identify this account".into()),
|
||||
_ => Err("couldn't identify this account".into()),
|
||||
}
|
||||
let root = std::env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.filter(|root| root.is_absolute())
|
||||
.ok_or_else(|| ProbeError::Unavailable("SystemRoot is not set to an absolute path".into()))?;
|
||||
let powershell = root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe");
|
||||
// Only a validated GUID is interpolated. ProductsEx reads registration for the current
|
||||
// account and the machine, never another account and never Win32_Product (which repairs).
|
||||
let script = format!("$code='{product_code}';\n{}", include_str!("msi_context.ps1"));
|
||||
let mut command = crate::process::command(powershell);
|
||||
command.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
|
||||
let output = crate::process::run_command(command, "Windows installation lookup", Duration::from_secs(10)).map_err(ProbeError::Unavailable)?;
|
||||
parse_contexts(&output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -118,23 +153,53 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn authoritative_machine_context_is_preserved() {
|
||||
assert_eq!(parse_contexts("4\r\n"), Ok(MsiScope::PerMachine));
|
||||
assert_eq!(scope_of(&[4]), Ok(MsiScope::PerMachine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_and_unmanaged_accounts_are_both_per_user() {
|
||||
assert_eq!(parse_contexts("1\n"), Ok(MsiScope::PerUser));
|
||||
assert_eq!(parse_contexts("2\n"), Ok(MsiScope::PerUser));
|
||||
assert_eq!(parse_contexts("1\n2\n"), Ok(MsiScope::PerUser));
|
||||
assert_eq!(scope_of(&[1]), Ok(MsiScope::PerUser));
|
||||
assert_eq!(scope_of(&[2]), Ok(MsiScope::PerUser));
|
||||
assert_eq!(scope_of(&[1, 2]), Ok(MsiScope::PerUser));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_unknown_or_conflicting_contexts_are_not_guessed() {
|
||||
for output in ["", " \r\n", "0", "7", "user", "2\n4", "4\n1", "4\nnoise"] {
|
||||
assert!(parse_contexts(output).is_err(), "{output:?}");
|
||||
for contexts in [&[][..], &[0], &[7], &[2, 4], &[4, 1], &[4, 3]] {
|
||||
assert!(scope_of(contexts).is_err(), "{contexts:?}");
|
||||
}
|
||||
assert_eq!(parse_contexts("4\n1"), Err(ProbeError::Conflicting));
|
||||
assert_eq!(parse_contexts("7"), Err(ProbeError::Unknown));
|
||||
assert_eq!(scope_of(&[4, 1]), Err(ProbeError::Conflicting));
|
||||
assert_eq!(scope_of(&[7]), Err(ProbeError::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_product_code_packs_the_way_windows_installer_names_its_keys() {
|
||||
assert_eq!(packed_code("{12345678-ABCD-EF01-2345-6789ABCDEF01}").as_deref(), Some("87654321DCBA10FE32547698BADCFE10"));
|
||||
assert_eq!(packed_code("{12345678-abcd-ef01-2345-6789abcdef01}").as_deref(), Some("87654321DCBA10FE32547698BADCFE10"));
|
||||
for code in ["", "12345678-ABCD-EF01-2345-6789ABCDEF01", "{12345678-ABCD-EF01-2345-6789ABCDEF0Z}", r"{1234567\-ABCD-EF01-2345-6789ABCDEF01}"]
|
||||
{
|
||||
assert_eq!(packed_code(code), None, "{code:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A product nobody installed is registered nowhere: the lookup runs and finds no context.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_product_that_is_not_installed_has_no_context() {
|
||||
assert_eq!(probe("{00000000-1111-2222-3333-444444444444}"), Err(ProbeError::Unknown));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn this_accounts_sid_is_found() {
|
||||
// Local and domain accounts are S-1-5-..., Microsoft Entra ID accounts S-1-12-1-...: only the
|
||||
// shape is the same, `S-1-` and then numbers.
|
||||
let sid = current_user_sid().unwrap();
|
||||
let parts: Vec<&str> = sid.split('-').collect();
|
||||
let numbers = parts.get(1..).unwrap_or_default();
|
||||
assert!(parts.first() == Some(&"S") && numbers.len() >= 3, "{sid}");
|
||||
assert!(numbers.iter().all(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())), "{sid}");
|
||||
assert_eq!(numbers.first(), Some(&"1"), "{sid}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -168,7 +233,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_product_codes_stop_before_any_shell_call() {
|
||||
fn malformed_product_codes_stop_before_any_lookup() {
|
||||
for code in ["", "not-a-guid", "{12345678-1234-1234-1234-123456789ABZ}", "';exit 0;#", "{12345678-1234-1234-1234-123456789ABC}\n"] {
|
||||
let called = std::cell::Cell::new(false);
|
||||
assert!(
|
||||
@@ -200,7 +265,7 @@ mod tests {
|
||||
let result = update_scope(
|
||||
true,
|
||||
Some(CODE),
|
||||
|_| Err(ProbeError::Unavailable("PowerShell is blocked".into())),
|
||||
|_| Err(ProbeError::Unavailable("the registry could not be read".into())),
|
||||
|code| {
|
||||
*seen.borrow_mut() = code.to_owned();
|
||||
Some(scope)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ pub fn is_flatpak_available() -> bool {
|
||||
if in_sandbox() {
|
||||
capture("flatpak-spawn", &["--host", "flatpak", "--version"], Duration::from_secs(3)).is_some()
|
||||
} else {
|
||||
which_on_path("flatpak").is_some()
|
||||
which("flatpak").is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,8 @@ fn detect_discord_scheme_handler() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn which_on_path(name: &str) -> Option<std::path::PathBuf> {
|
||||
/// Finds a program on `PATH`.
|
||||
pub(crate) fn which(name: &str) -> Option<std::path::PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
|
||||
}
|
||||
@@ -789,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");
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,11 @@
|
||||
//! answer: it can't be asked to quit, so the newcomer starts anyway.
|
||||
//!
|
||||
//! Launchers from before the handshake send a bare `show`, which is still understood. The worst
|
||||
//! another local program can do with any of this is bring the window forward or make the launcher
|
||||
//! quit.
|
||||
//! another local program can do with any of this is bring the window forward.
|
||||
//!
|
||||
//! A newcomer only steps aside (exits) for a running launcher that proved it holds this account's
|
||||
//! token. Anything else on the port, including a listener that answers like a launcher but fails
|
||||
//! or skips the proof, is treated as unrelated, and the newcomer starts anyway.
|
||||
//!
|
||||
//! ## Upgrading over the published 0.7.0 build
|
||||
//!
|
||||
@@ -23,9 +26,9 @@
|
||||
//! second word equals its owner-only token file. A newer build sends `hello <version> <nonce>`
|
||||
//! instead, so 0.7.0 reads the nonce as a wrong token, replies `running 0.7.0`, and merely shows
|
||||
//! its window. If that reply arrives in time, a newer newcomer requests takeover but the old
|
||||
//! process does not release its port; a same-version newcomer defers. The old reader and new
|
||||
//! client have similar timeouts, so a delayed reply can instead reach the legacy fallback and
|
||||
//! leave another process running without the listening socket. These timing paths need an
|
||||
//! process does not release its port; since that reply carries no proof, the newcomer then starts
|
||||
//! anyway without the listening socket. The old reader and new client have similar timeouts, so a
|
||||
//! delayed reply can instead reach the legacy fallback, with the same result. These timing paths need an
|
||||
//! old-release interoperability test. Quit the running 0.7.0 launcher from its tray menu before
|
||||
//! starting the new version. Do not make the new side
|
||||
//! send the token to force a `Replace`: that would restore the clear-text shared-secret handshake
|
||||
@@ -129,12 +132,17 @@ pub enum Instance {
|
||||
/// How the running launcher answered our hello.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Answer {
|
||||
/// It's as new as we are, or newer, and shows its window: we exit.
|
||||
/// It proved it is this account's launcher, as new as we are or newer, and shows its window:
|
||||
/// we exit.
|
||||
Defer,
|
||||
/// It's older and quits: we take over the port.
|
||||
TakeOver,
|
||||
/// It's older and quits: we take over the port. `proven` when it proved it is this account's
|
||||
/// launcher; only then do we exit if it keeps the port.
|
||||
TakeOver { proven: bool },
|
||||
/// It closed without an answer: a launcher from before the handshake.
|
||||
Legacy,
|
||||
/// It didn't prove it is this account's launcher (failed or skipped the proof, or answered
|
||||
/// something unreadable): another program, so we start anyway (CWE-287).
|
||||
Unrelated,
|
||||
}
|
||||
|
||||
type Wake = std::sync::Arc<std::sync::OnceLock<Box<dyn Fn() + Send + Sync>>>;
|
||||
@@ -154,7 +162,7 @@ pub fn claim(wake: Wake) -> Instance {
|
||||
}
|
||||
match ask(addr, ours.as_ref(), token.as_deref()) {
|
||||
Ok(Answer::Defer) => Instance::Secondary,
|
||||
Ok(Answer::TakeOver) => {
|
||||
Ok(Answer::TakeOver { proven }) => {
|
||||
let deadline = Instant::now() + TAKE_OVER_WITHIN;
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(listener) = TcpListener::bind(addr) {
|
||||
@@ -162,8 +170,18 @@ pub fn claim(wake: Wake) -> Instance {
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
// It stayed (busy installing) and showed its window instead.
|
||||
Instance::Secondary
|
||||
if proven && holder_proves_itself(addr, ours.as_ref(), token.as_deref()) {
|
||||
// It stayed (busy installing) and showed its window instead. The proof is asked
|
||||
// again because the proven launcher may have quit and something else taken the port.
|
||||
Instance::Secondary
|
||||
} else {
|
||||
log::warn!("whatever holds the launcher's port didn't prove it is ArtCraft Launcher and kept the port; starting anyway");
|
||||
Instance::Primary(channel().1)
|
||||
}
|
||||
}
|
||||
Ok(Answer::Unrelated) => {
|
||||
log::warn!("something on the launcher's port didn't prove it is this account's ArtCraft Launcher; starting anyway");
|
||||
Instance::Primary(channel().1)
|
||||
}
|
||||
Ok(Answer::Legacy) => {
|
||||
log::warn!("an older ArtCraft Launcher is running and can't be asked to quit; starting anyway");
|
||||
@@ -174,6 +192,34 @@ pub fn claim(wake: Wake) -> Instance {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether whatever holds the port now proves it is this account's launcher.
|
||||
///
|
||||
/// The probe greets with our own version, because launchers from 0.8.1 to 0.14.x only challenge a
|
||||
/// newer newcomer, and checks the holder's proof. It never answers the challenge, so the holder
|
||||
/// can't grant `Replace`: it can only bring its window forward.
|
||||
fn holder_proves_itself(addr: SocketAddr, ours: Option<&Version>, token: Option<&str>) -> bool {
|
||||
let (Some(ours), Some(tok), Some(client_nonce)) = (ours, token.filter(|t| !t.is_empty()), fresh_secret()) else { return false };
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(1)) else { return false };
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(1)));
|
||||
if stream.write_all(format!("{HELLO}{ours} {client_nonce}\n").as_bytes()).is_err() {
|
||||
return false;
|
||||
}
|
||||
let reply = read_message(&mut stream);
|
||||
let proven =
|
||||
std::str::from_utf8(&reply).ok().and_then(|r| r.trim().strip_prefix(CHALLENGE)).is_some_and(|rest| server_proved(rest, tok, &client_nonce));
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
proven
|
||||
}
|
||||
|
||||
/// Whether a `challenge <nonce> <proof>` (without its prefix) proves the sender holds `token`.
|
||||
fn server_proved(challenge_rest: &str, token: &str, client_nonce: &str) -> bool {
|
||||
let mut parts = challenge_rest.split_whitespace();
|
||||
let server_nonce = parts.next().unwrap_or("");
|
||||
let server_proof = parts.next().unwrap_or("");
|
||||
constant_time_eq(server_proof, &compute_proof("server", token, client_nonce, server_nonce))
|
||||
}
|
||||
|
||||
fn compute_proof(role: &str, token: &str, client_nonce: &str, server_nonce: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -272,24 +318,11 @@ fn handle_incoming(stream: &mut TcpStream, ours: Option<&Version>, token: Option
|
||||
|
||||
let reply_running = format!("{RUNNING}{}\n", ours.map_or_else(|| "unknown".to_string(), ToString::to_string));
|
||||
|
||||
if !newer {
|
||||
let _ = stream.write_all(reply_running.as_bytes());
|
||||
return Some(Request::Show);
|
||||
}
|
||||
|
||||
// Newcomer is newer and asks to replace us.
|
||||
// Mutual challenge-response: require proof of token possession before granting Replace.
|
||||
let Some(ours_token) = token.filter(|t| !t.is_empty()) else {
|
||||
let _ = stream.write_all(reply_running.as_bytes());
|
||||
return Some(Request::Show);
|
||||
};
|
||||
|
||||
let Some(client_nonce) = client_nonce else {
|
||||
let _ = stream.write_all(reply_running.as_bytes());
|
||||
return Some(Request::Show);
|
||||
};
|
||||
|
||||
let Some(server_nonce) = fresh_secret() else {
|
||||
// Mutual challenge-response whenever the newcomer offers a nonce, whatever its version: a
|
||||
// newer one must prove itself before we grant Replace, and every newcomer needs our proof
|
||||
// before it exits in our favour. Without a token or a nonce nothing can be proven: answer,
|
||||
// and only show the window.
|
||||
let (Some(ours_token), Some(client_nonce), Some(server_nonce)) = (token.filter(|t| !t.is_empty()), client_nonce, fresh_secret()) else {
|
||||
let _ = stream.write_all(reply_running.as_bytes());
|
||||
return Some(Request::Show);
|
||||
};
|
||||
@@ -307,7 +340,7 @@ fn handle_incoming(stream: &mut TcpStream, ours: Option<&Version>, token: Option
|
||||
|
||||
let _ = stream.write_all(reply_running.as_bytes());
|
||||
|
||||
if constant_time_eq(client_proof, &expected_client_proof) { Some(Request::Replace) } else { Some(Request::Show) }
|
||||
if newer && constant_time_eq(client_proof, &expected_client_proof) { Some(Request::Replace) } else { Some(Request::Show) }
|
||||
}
|
||||
|
||||
/// The new start's side: says hello to whatever holds the port and reads the answer.
|
||||
@@ -345,21 +378,18 @@ fn ask_once(addr: SocketAddr, ours: &Version, token: Option<&str>, with_nonce: b
|
||||
|
||||
let reply_str = match std::str::from_utf8(&reply) {
|
||||
Ok(s) => s.trim(),
|
||||
Err(_) => return Ok(Answer::Defer),
|
||||
Err(_) => return Ok(Answer::Unrelated),
|
||||
};
|
||||
|
||||
if let Some(challenge_rest) = reply_str.strip_prefix(CHALLENGE) {
|
||||
let mut parts = challenge_rest.split_whitespace();
|
||||
let server_nonce = parts.next().unwrap_or("");
|
||||
let server_proof = parts.next().unwrap_or("");
|
||||
let server_nonce = challenge_rest.split_whitespace().next().unwrap_or("");
|
||||
let (Some(client_nonce), Some(tok)) = (client_nonce.as_deref(), token) else {
|
||||
return Ok(Answer::Defer);
|
||||
return Ok(Answer::Unrelated);
|
||||
};
|
||||
let expected_server_proof = compute_proof("server", tok, client_nonce, server_nonce);
|
||||
if !constant_time_eq(server_proof, &expected_server_proof) {
|
||||
if !server_proved(challenge_rest, tok, client_nonce) {
|
||||
// Server failed to prove it knows the shared token (untrusted peer).
|
||||
// Do NOT disclose client proof; abort immediately.
|
||||
return Ok(Answer::Defer);
|
||||
// Do NOT disclose client proof; abort immediately, and don't step aside for it.
|
||||
return Ok(Answer::Unrelated);
|
||||
}
|
||||
|
||||
// Server is authentic. Prove our identity to server:
|
||||
@@ -368,22 +398,24 @@ fn ask_once(addr: SocketAddr, ours: &Version, token: Option<&str>, with_nonce: b
|
||||
stream.write_all(auth_msg.as_bytes())?;
|
||||
|
||||
let final_reply = read_message(&mut stream);
|
||||
return Ok(verdict(&final_reply, ours));
|
||||
return Ok(verdict(&final_reply, ours, true));
|
||||
}
|
||||
|
||||
Ok(verdict(&reply, ours))
|
||||
Ok(verdict(&reply, ours, false))
|
||||
}
|
||||
|
||||
/// Reads the running launcher's answer.
|
||||
fn verdict(reply: &[u8], ours: &Version) -> Answer {
|
||||
/// Reads the running launcher's answer. `proven` when it proved it holds this account's token:
|
||||
/// only then is an answer that it is as new as we are a reason to exit.
|
||||
fn verdict(reply: &[u8], ours: &Version, proven: bool) -> Answer {
|
||||
if reply.is_empty() {
|
||||
return Answer::Legacy;
|
||||
}
|
||||
let trimmed = std::str::from_utf8(reply).ok().map(str::trim);
|
||||
let running = trimmed.and_then(|r| r.strip_prefix(RUNNING)).and_then(version::parse);
|
||||
match running {
|
||||
Some(running) if version::is_newer(ours, &running) => Answer::TakeOver,
|
||||
_ => Answer::Defer,
|
||||
Some(running) if version::is_newer(ours, &running) => Answer::TakeOver { proven },
|
||||
_ if proven => Answer::Defer,
|
||||
_ => Answer::Unrelated,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +443,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_newer_start_replaces_an_older_running_launcher() {
|
||||
let (addr, rx) = primary("0.1.0");
|
||||
assert_eq!(ask(addr, Some(&v("0.1.2")), Some(TOK)).ok(), Some(Answer::TakeOver));
|
||||
assert_eq!(ask(addr, Some(&v("0.1.2")), Some(TOK)).ok(), Some(Answer::TakeOver { proven: true }));
|
||||
assert_eq!(next(&rx), Request::Replace);
|
||||
}
|
||||
|
||||
@@ -469,7 +501,7 @@ mod tests {
|
||||
});
|
||||
|
||||
let ans = ask(addr, Some(&v("0.1.2")), Some(TOK)).unwrap();
|
||||
assert_eq!(ans, Answer::Defer);
|
||||
assert_eq!(ans, Answer::Unrelated, "a peer that fails the proof must not make us exit");
|
||||
let bytes_received = handle.join().unwrap();
|
||||
assert_eq!(bytes_received, 0, "client must immediately disconnect and send no auth proof");
|
||||
}
|
||||
@@ -509,7 +541,7 @@ mod tests {
|
||||
stream.write_all(format!("{rest}\n").as_bytes()).unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).unwrap();
|
||||
assert_eq!(verdict(&reply, &v(version)), Answer::TakeOver);
|
||||
assert_eq!(verdict(&reply, &v(version), false), Answer::TakeOver { proven: false });
|
||||
assert_eq!(next(&rx), Request::Show);
|
||||
}
|
||||
}
|
||||
@@ -522,7 +554,7 @@ mod tests {
|
||||
stream.write_all(format!("{HELLO}0.1.2\n").as_bytes()).unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).unwrap();
|
||||
assert_eq!(verdict(&reply, &v("0.1.2")), Answer::TakeOver);
|
||||
assert_eq!(verdict(&reply, &v("0.1.2"), false), Answer::TakeOver { proven: false });
|
||||
assert_eq!(next(&rx), Request::Show);
|
||||
}
|
||||
|
||||
@@ -600,7 +632,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(ask(addr, Some(&v("0.6.2")), Some(TOK)).ok(), Some(Answer::TakeOver));
|
||||
assert_eq!(ask(addr, Some(&v("0.6.2")), Some(TOK)).ok(), Some(Answer::TakeOver { proven: false }));
|
||||
let _ = old.join();
|
||||
}
|
||||
|
||||
@@ -615,10 +647,105 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn verdict_reads_defensively() {
|
||||
assert_eq!(verdict(b"artcraft-launcher:running 0.1.0\n", &v("0.1.2")), Answer::TakeOver);
|
||||
assert_eq!(verdict(b"artcraft-launcher:running 0.1.2\n", &v("0.1.2")), Answer::Defer);
|
||||
assert_eq!(verdict(b"artcraft-launcher:running unknown\n", &v("0.1.2")), Answer::Defer);
|
||||
assert_eq!(verdict(b"garbage\n", &v("0.1.2")), Answer::Defer);
|
||||
assert_eq!(verdict(b"", &v("0.1.2")), Answer::Legacy);
|
||||
assert_eq!(verdict(b"artcraft-launcher:running 0.1.0\n", &v("0.1.2"), true), Answer::TakeOver { proven: true });
|
||||
assert_eq!(verdict(b"artcraft-launcher:running 0.1.2\n", &v("0.1.2"), true), Answer::Defer);
|
||||
assert_eq!(verdict(b"artcraft-launcher:running unknown\n", &v("0.1.2"), true), Answer::Defer);
|
||||
assert_eq!(verdict(b"garbage\n", &v("0.1.2"), true), Answer::Defer);
|
||||
assert_eq!(verdict(b"", &v("0.1.2"), true), Answer::Legacy);
|
||||
// Without the proof, nothing makes us exit.
|
||||
assert_eq!(verdict(b"artcraft-launcher:running 0.1.0\n", &v("0.1.2"), false), Answer::TakeOver { proven: false });
|
||||
assert_eq!(verdict(b"artcraft-launcher:running 0.1.2\n", &v("0.1.2"), false), Answer::Unrelated);
|
||||
assert_eq!(verdict(b"artcraft-launcher:running 9.9.9\n", &v("0.1.2"), false), Answer::Unrelated);
|
||||
assert_eq!(verdict(b"garbage\n", &v("0.1.2"), false), Answer::Unrelated);
|
||||
assert_eq!(verdict(b"", &v("0.1.2"), false), Answer::Legacy);
|
||||
}
|
||||
|
||||
/// After a proven takeover stalls, the newcomer exits only if the port's holder proves itself
|
||||
/// again, and the probe never asks that holder to quit, even though it greets as newer.
|
||||
#[test]
|
||||
fn the_port_holder_is_proven_again_before_stepping_aside() {
|
||||
let (addr, rx) = primary("0.1.0");
|
||||
assert!(holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK)));
|
||||
assert_eq!(next(&rx), Request::Show, "the probe must not ask the running launcher to quit");
|
||||
|
||||
// A holder of the same version (this build) is challenged too.
|
||||
let (addr, rx) = primary("0.1.2");
|
||||
assert!(holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK)));
|
||||
assert_eq!(next(&rx), Request::Show);
|
||||
|
||||
// Something that took the port without the token can't keep the newcomer from starting.
|
||||
let (addr, _rx) = primary("0.1.0");
|
||||
assert!(!holder_proves_itself(addr, Some(&v("0.1.2")), Some("another-accounts-token")));
|
||||
let (addr, _rx) = primary("0.1.0");
|
||||
assert!(!holder_proves_itself(addr, Some(&v("0.1.2")), None));
|
||||
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let imposter = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let _ = read_message(&mut s);
|
||||
let _ = s.write_all(b"artcraft-launcher:running 9.9.9\n");
|
||||
});
|
||||
assert!(!holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK)));
|
||||
imposter.join().unwrap();
|
||||
}
|
||||
|
||||
/// A busy 0.8.1 to 0.14.x launcher that stayed after a takeover only challenges a newer
|
||||
/// newcomer. The probe greets as newer, so it is still proven, and it never sends a client proof.
|
||||
#[test]
|
||||
fn the_probe_proves_a_running_launcher_that_only_challenges_newer_starts() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let old = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let hello = String::from_utf8(read_message(&mut s)).unwrap();
|
||||
let mut parts = hello.strip_prefix(HELLO).unwrap().split_whitespace();
|
||||
let theirs = v(parts.next().unwrap());
|
||||
let Some(client_nonce) = parts.next().filter(|_| version::is_newer(&theirs, &v("0.1.0"))) else {
|
||||
let _ = s.write_all(b"artcraft-launcher:running 0.1.0\n");
|
||||
return Vec::new();
|
||||
};
|
||||
let server_nonce = fresh_secret().unwrap();
|
||||
let proof = compute_proof("server", TOK, client_nonce, &server_nonce);
|
||||
s.write_all(format!("{CHALLENGE}{server_nonce} {proof}\n").as_bytes()).unwrap();
|
||||
let auth = read_message(&mut s);
|
||||
let _ = s.write_all(b"artcraft-launcher:running 0.1.0\n");
|
||||
auth
|
||||
});
|
||||
assert!(holder_proves_itself(addr, Some(&v("0.1.2")), Some(TOK)));
|
||||
assert!(old.join().unwrap().is_empty(), "the probe must never answer the challenge");
|
||||
}
|
||||
|
||||
/// A listener that answers like a newer launcher but never proves it holds our token (it
|
||||
/// skips the challenge) is unrelated: the newcomer doesn't exit for it (CWE-287).
|
||||
#[test]
|
||||
fn an_unproven_running_answer_does_not_make_a_start_exit() {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let imposter = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let _ = read_message(&mut s);
|
||||
let _ = s.write_all(b"artcraft-launcher:running 9.9.9\n");
|
||||
});
|
||||
assert_eq!(ask(addr, Some(&v("0.1.2")), Some(TOK)).ok(), Some(Answer::Unrelated));
|
||||
imposter.join().unwrap();
|
||||
}
|
||||
|
||||
/// A same-version start is challenged too, and steps aside only after the running launcher
|
||||
/// proved itself; it never gets to replace it.
|
||||
#[test]
|
||||
fn a_same_version_start_is_challenged_and_cannot_replace() {
|
||||
let (addr, rx) = primary("0.1.2");
|
||||
let nonce = fresh_secret().unwrap();
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
stream.write_all(format!("{HELLO}0.1.2 {nonce}\n").as_bytes()).unwrap();
|
||||
let challenge = read_message(&mut stream);
|
||||
let rest = std::str::from_utf8(&challenge).unwrap().strip_prefix(CHALLENGE).unwrap().to_string();
|
||||
let mut parts = rest.split_whitespace();
|
||||
let (server_nonce, server_proof) = (parts.next().unwrap(), parts.next().unwrap());
|
||||
assert_eq!(server_proof, compute_proof("server", TOK, &nonce, server_nonce));
|
||||
stream.write_all(format!("{AUTH}{}\n", compute_proof("client", TOK, &nonce, server_nonce)).as_bytes()).unwrap();
|
||||
assert!(std::str::from_utf8(&read_message(&mut stream)).unwrap().starts_with(RUNNING));
|
||||
assert_eq!(next(&rx), Request::Show, "a same-version start must never replace the running one");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -229,6 +232,13 @@ const RESCAN_EVERY: i64 = 5 * 60;
|
||||
/// Coming back to the window re-checks, but not more often than this.
|
||||
const RESCAN_ON_FOCUS_AFTER: i64 = 30;
|
||||
|
||||
/// A periodic update check is due `every` seconds after the last one. The saved time is
|
||||
/// hostile (an edited `app.ron`, a clock that ran fast and was later corrected): a time in
|
||||
/// the future or a corrupted `i64::MIN` never postpones the check (#774).
|
||||
fn update_check_due(last: Option<i64>, now: i64, every: i64) -> bool {
|
||||
last.is_none_or(|t| t > now || now.saturating_sub(t) >= every)
|
||||
}
|
||||
|
||||
impl LauncherUi {
|
||||
/// Builds the UI shell around `backend`: installs the bundled fonts and an available system
|
||||
/// CJK face when the effective language needs it, plus image loaders; snapshots capabilities and
|
||||
@@ -287,6 +297,7 @@ impl LauncherUi {
|
||||
frame: None,
|
||||
flushed: Vec::new(),
|
||||
pending_toasts: Vec::new(),
|
||||
pin_dragging: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,15 +509,15 @@ impl LauncherUi {
|
||||
self.last_detect = now;
|
||||
}
|
||||
let focused = ctx.input(|i| i.viewport().focused).unwrap_or(true);
|
||||
let came_back = focused && !self.was_focused && now - self.last_detect >= RESCAN_ON_FOCUS_AFTER;
|
||||
let came_back = focused && !self.was_focused && now.saturating_sub(self.last_detect) >= RESCAN_ON_FOCUS_AFTER;
|
||||
self.was_focused = focused;
|
||||
if came_back || now - self.last_detect >= RESCAN_EVERY {
|
||||
if came_back || now.saturating_sub(self.last_detect) >= RESCAN_EVERY {
|
||||
self.last_detect = now;
|
||||
self.backend.send(Command::Detect);
|
||||
}
|
||||
if let Some(every) = self.settings.check_every.seconds()
|
||||
&& !self.checking
|
||||
&& self.settings.last_check.is_none_or(|t| now - t >= every)
|
||||
&& update_check_due(self.settings.last_check, now, every)
|
||||
{
|
||||
self.backend.send(Command::CheckForUpdates { force: false });
|
||||
}
|
||||
@@ -837,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| {
|
||||
@@ -849,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);
|
||||
@@ -860,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
|
||||
@@ -888,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)),
|
||||
@@ -916,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()
|
||||
}
|
||||
|
||||
@@ -1342,4 +1385,23 @@ mod tests {
|
||||
let _guard = crate::i18n::StrictCjkDrawing::on(None);
|
||||
let _ = crate::i18n::tr(Lang::ZH_CN, "Settings");
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
const EVERY: i64 = 24 * 60 * 60;
|
||||
|
||||
#[test]
|
||||
fn a_check_is_due_after_its_interval() {
|
||||
assert!(update_check_due(None, NOW, EVERY));
|
||||
assert!(!update_check_due(Some(NOW - EVERY + 1), NOW, EVERY));
|
||||
assert!(update_check_due(Some(NOW - EVERY), NOW, EVERY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_time_in_the_future_or_at_i64_min_never_postpones_a_check() {
|
||||
// A clock that ran fast and was later corrected, or an edited `app.ron` (#774): the
|
||||
// launcher would sit in the tray for weeks; `i64::MIN` panicked the subtraction.
|
||||
assert!(update_check_due(Some(NOW + 60), NOW, EVERY));
|
||||
assert!(update_check_due(Some(i64::MAX), NOW, EVERY));
|
||||
assert!(update_check_due(Some(i64::MIN), NOW, EVERY));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ use crate::i18n::Script;
|
||||
const MAX_FONT_BYTES: u64 = 100 * 1024 * 1024;
|
||||
/// Faces looked at in one collection file (PingFang has 24).
|
||||
const MAX_FACES: u32 = 64;
|
||||
/// Name records compared per name ID in one face. Real fonts carry a few dozen translations of
|
||||
/// each name; the cap bounds the decoding a crafted name table can ask for.
|
||||
const MAX_NAME_RECORDS: usize = 256;
|
||||
|
||||
/// The bundled body font (Inter), whose baseline a fallback face is nudged onto.
|
||||
const BODY_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
@@ -101,18 +104,13 @@ fn read(path: &Path) -> Option<Vec<u8>> {
|
||||
/// The face of `family` in a font file or collection: its Regular face, else the first face of
|
||||
/// that family. Matches the (typographic) family name in any language the file gives it in.
|
||||
pub(crate) fn face_index(data: &[u8], family: &str) -> Option<u32> {
|
||||
let names = |font: &FontRef<'_>, ids: [StringId; 2]| -> Vec<String> {
|
||||
ids.into_iter().flat_map(|id| font.localized_strings(id).map(|s| s.to_string())).collect()
|
||||
};
|
||||
let mut first = None;
|
||||
for index in 0..MAX_FACES {
|
||||
let Ok(font) = FontRef::from_index(data, index) else { break };
|
||||
let families = names(&font, [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME]);
|
||||
if !families.iter().any(|f| f.eq_ignore_ascii_case(family)) {
|
||||
if !has_name(&font, [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME], family) {
|
||||
continue;
|
||||
}
|
||||
let styles = names(&font, [StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]);
|
||||
if styles.iter().any(|s| s.eq_ignore_ascii_case("Regular")) {
|
||||
if has_name(&font, [StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME], "Regular") {
|
||||
return Some(index);
|
||||
}
|
||||
first.get_or_insert(index);
|
||||
@@ -120,6 +118,25 @@ pub(crate) fn face_index(data: &[u8], family: &str) -> Option<u32> {
|
||||
first
|
||||
}
|
||||
|
||||
/// Whether one of the first [`MAX_NAME_RECORDS`] names of each of `ids` in `font` is `wanted`
|
||||
/// (ASCII case ignored). Each name is decoded only as far as it still matches, and never kept: a name table
|
||||
/// can point many records at the same long string.
|
||||
fn has_name(font: &FontRef<'_>, ids: [StringId; 2], wanted: &str) -> bool {
|
||||
ids.into_iter().any(|id| font.localized_strings(id).take(MAX_NAME_RECORDS).any(|name| chars_eq_ignore_ascii_case(name.chars(), wanted)))
|
||||
}
|
||||
|
||||
/// `chars` spells `wanted`, ignoring ASCII case. Reads at most one char past `wanted`'s length.
|
||||
fn chars_eq_ignore_ascii_case(mut chars: impl Iterator<Item = char>, wanted: &str) -> bool {
|
||||
let mut want = wanted.chars();
|
||||
loop {
|
||||
match (chars.next(), want.next()) {
|
||||
(None, None) => return true,
|
||||
(Some(a), Some(b)) if a.eq_ignore_ascii_case(&b) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where each system keeps a font for `script`, best first.
|
||||
fn candidates(script: Script) -> Vec<Candidate> {
|
||||
let found = match script {
|
||||
@@ -427,6 +444,28 @@ mod tests {
|
||||
assert_eq!(face_index(&[0u8; 64], "PingFang SC"), None);
|
||||
}
|
||||
|
||||
/// Names compare without regard to ASCII case, and a long name stops being read once it
|
||||
/// runs past the wanted one.
|
||||
#[test]
|
||||
fn names_compare_incrementally() {
|
||||
assert!(chars_eq_ignore_ascii_case("PingFang SC".chars(), "pingfang sc"));
|
||||
assert!(!chars_eq_ignore_ascii_case("PingFang".chars(), "PingFang SC"));
|
||||
assert!(!chars_eq_ignore_ascii_case("PingFang SCX".chars(), "PingFang SC"));
|
||||
let mut read = 0;
|
||||
let endless = std::iter::repeat('a').inspect(|_| read += 1);
|
||||
assert!(!chars_eq_ignore_ascii_case(endless, "aaa"));
|
||||
assert_eq!(read, 4);
|
||||
}
|
||||
|
||||
/// The bundled body font is found by its own family, in any ASCII case, and not by a prefix.
|
||||
#[test]
|
||||
fn face_index_finds_a_family_by_name() {
|
||||
assert_eq!(face_index(BODY_FONT, "Inter"), Some(0));
|
||||
assert_eq!(face_index(BODY_FONT, "inter"), Some(0));
|
||||
assert_eq!(face_index(BODY_FONT, "Inte"), None);
|
||||
assert_eq!(face_index(BODY_FONT, "PingFang SC"), None);
|
||||
}
|
||||
|
||||
/// The baseline nudge reads a face's ascent and height; the reference is Inter's.
|
||||
#[test]
|
||||
fn face_metrics_reads_font_metrics() {
|
||||
|
||||
@@ -115,7 +115,7 @@ impl LauncherUi {
|
||||
let Some(entry) = self.entry(id).cloned() else { return true };
|
||||
let Some(release) = entry.release().cloned() else { return true };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let size = crate::sheet::size(ui.ctx(), vec2(880.0, 760.0));
|
||||
let size = crate::sheet::size(ui.ctx(), vec2(880.0, 760.0) * self.notes_zoom.max(1.0));
|
||||
ui.set_width(size.x);
|
||||
ui.set_max_height(size.y);
|
||||
let date = release.published_at.map(|d| fmt(tl!("Released {date}"), &[("date", &crate::views::date_text(d))])).unwrap_or_default();
|
||||
@@ -248,7 +248,7 @@ impl LauncherUi {
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::button(ui, tl!("Replace"), Kind::Danger, 110.0).clicked() {
|
||||
self.act_command(Command::Replace { app: id.to_string() });
|
||||
self.act_command(Command::Replace { app: id.to_string(), path: path.to_string() });
|
||||
close = true;
|
||||
}
|
||||
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
|
||||
|
||||
@@ -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
|
||||
@@ -134,6 +140,7 @@
|
||||
Update cancelled Aktualizace zrušena
|
||||
Uninstall cancelled Odinstalace zrušena
|
||||
Retry install Zkusit instalaci znovu
|
||||
Retry update Zkusit aktualizaci znovu
|
||||
Retry update to {version} Zkusit aktualizaci na {version} znovu
|
||||
The download is still here. It's checked again, then installed. Stažený soubor je stále k dispozici. Znovu se ověří a poté nainstaluje.
|
||||
Waiting for another app… Čekání na jinou aplikaci…
|
||||
@@ -378,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.
|
||||
|
@@ -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
|
||||
@@ -148,6 +154,7 @@
|
||||
Update cancelled Update abgebrochen
|
||||
Uninstall cancelled Deinstallation abgebrochen
|
||||
Retry install Installation wiederholen
|
||||
Retry update Update wiederholen
|
||||
Retry update to {version} Update auf {version} wiederholen
|
||||
The download is still here. It's checked again, then installed. Der Download ist noch da. Er wird erneut geprüft und dann installiert.
|
||||
Waiting for another app… Warten auf eine andere App…
|
||||
@@ -410,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.
|
@@ -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
|
||||
@@ -147,6 +153,7 @@
|
||||
Update cancelled Actualización cancelada
|
||||
Uninstall cancelled Desinstalación cancelada
|
||||
Retry install Reintentar la instalación
|
||||
Retry update Reintentar la actualización
|
||||
Retry update to {version} Reintentar la actualización a {version}
|
||||
The download is still here. It's checked again, then installed. La descarga sigue aquí. Se vuelve a comprobar y luego se instala.
|
||||
Waiting for another app… Esperando a otra aplicación…
|
||||
@@ -391,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.
|
@@ -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
|
||||
@@ -146,6 +152,7 @@
|
||||
Update cancelled Mise à jour annulée
|
||||
Uninstall cancelled Désinstallation annulée
|
||||
Retry install Réessayer l'installation
|
||||
Retry update Réessayer la mise à jour
|
||||
Retry update to {version} Réessayer la mise à jour vers {version}
|
||||
The download is still here. It's checked again, then installed. Le téléchargement est toujours là. Il sera vérifié à nouveau, puis installé.
|
||||
Waiting for another app… En attente d'une autre application…
|
||||
@@ -408,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.
|
@@ -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
|
||||
@@ -148,6 +154,7 @@
|
||||
Update cancelled Frissítés megszakítva
|
||||
Uninstall cancelled Eltávolítás megszakítva
|
||||
Retry install Telepítés újrapróbálása
|
||||
Retry update Frissítés újrapróbálása
|
||||
Retry update to {version} Frissítés újrapróbálása: {version}
|
||||
The download is still here. It's checked again, then installed. A letöltött fájl megmaradt. Újra ellenőrizzük, majd telepítjük.
|
||||
Waiting for another app… Várakozás egy másik alkalmazásra…
|
||||
@@ -411,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.
|
@@ -62,7 +62,7 @@
|
||||
Downloading… ダウンロード中…
|
||||
Reconnecting… 再接続中…
|
||||
Verifying… 検証中…
|
||||
Installing, then restarting… インストール完了後再起動します…
|
||||
Installing, then restarting… インストールして再起動します…
|
||||
Installing… インストール中…
|
||||
Removing… アンインストール中…
|
||||
Pause download ダウンロードを一時停止
|
||||
@@ -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 非表示のアプリ
|
||||
@@ -150,6 +156,7 @@
|
||||
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… 他のアプリを待機中…
|
||||
@@ -366,14 +373,14 @@ filter Installed インストール済み
|
||||
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を使用します。
|
||||
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 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で削除してください。
|
||||
@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} をインストールすると上書きされます。
|
||||
@@ -384,7 +391,7 @@ filter Installed インストール済み
|
||||
Frequently used よく使う順
|
||||
Next app 次のアプリ
|
||||
Previous app 前のアプリ
|
||||
Show {n} more changes さらに{n}件の変更を表示
|
||||
Show {n} more changes さらに {n} 件の変更を表示
|
||||
Last updated: {date} 最終更新: {date}
|
||||
column Version バージョン
|
||||
column Author 作者
|
||||
@@ -413,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.
|
@@ -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
|
||||
@@ -149,6 +155,7 @@
|
||||
Update cancelled Anulowano aktualizację
|
||||
Uninstall cancelled Anulowano odinstalowanie
|
||||
Retry install Ponów instalację
|
||||
Retry update Ponów aktualizację
|
||||
Retry update to {version} Ponów aktualizację do {version}
|
||||
The download is still here. It's checked again, then installed. Pobrany plik wciąż tu jest. Zostanie sprawdzony ponownie, a następnie zainstalowany.
|
||||
Waiting for another app… Oczekiwanie na inną aplikację…
|
||||
@@ -412,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.
|
@@ -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
|
||||
@@ -136,6 +142,7 @@
|
||||
Update cancelled Atualização cancelada
|
||||
Uninstall cancelled Desinstalação cancelada
|
||||
Retry install Tentar instalar novamente
|
||||
Retry update Tentar atualizar novamente
|
||||
Retry update to {version} Tentar atualizar novamente para {version}
|
||||
The download is still here. It's checked again, then installed. O arquivo baixado ainda está aqui. Ele será verificado novamente e instalado.
|
||||
Waiting for another app… Aguardando outro aplicativo…
|
||||
@@ -398,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.
|
@@ -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 Скрытые приложения
|
||||
@@ -146,6 +152,7 @@
|
||||
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… Ожидание другого приложения…
|
||||
@@ -406,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.
|
@@ -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 已隐藏的应用
|
||||
@@ -283,6 +289,7 @@ figcraft Interface design and prototyping 界面设计与原型制作
|
||||
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… 正在等待其他应用…
|
||||
@@ -378,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.
|
@@ -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));
|
||||
}
|
||||
}
|
||||
+170
-18
@@ -39,6 +39,17 @@ fn ago_text(secs: i64, now: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `long`, or the first of `shorter` whose button fits `width` (the last one if none does): a
|
||||
/// wider button would push the `⋯` menu beside it out of the row (#956). The table's version
|
||||
/// column and the card's top strip show the version, so the shorter labels can leave it out.
|
||||
fn fitted<'a>(ui: &Ui, long: &'a str, shorter: &[&'a str], width: f32) -> &'a str {
|
||||
std::iter::once(long)
|
||||
.chain(shorter.iter().copied())
|
||||
.find(|label| widgets::button_width(ui, label, 0.0) <= width)
|
||||
.or_else(|| shorter.last().copied())
|
||||
.unwrap_or(long)
|
||||
}
|
||||
|
||||
/// Translate an absolute date ("Oct 8, 2026" → "8 окт. 2026") into the current language, on this
|
||||
/// computer's calendar (`local_time`). Every date the launcher shows is written by this.
|
||||
pub(crate) fn date_text(secs: i64) -> String {
|
||||
@@ -50,8 +61,9 @@ pub(crate) fn date_text(secs: i64) -> String {
|
||||
/// What the main button of an app does.
|
||||
enum Primary {
|
||||
Install,
|
||||
/// The download is already here; only the install step is left. Carries the button's label.
|
||||
Retry(String),
|
||||
/// The download is already here; only the install step is left. Carries the button's label
|
||||
/// and shorter ones for when it doesn't fit.
|
||||
Retry(String, Vec<&'static str>),
|
||||
Update(String),
|
||||
/// A pinned release only installs after the backend has checked that exact tag.
|
||||
SelectedVersion {
|
||||
@@ -413,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());
|
||||
@@ -452,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);
|
||||
@@ -586,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)
|
||||
@@ -597,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)));
|
||||
});
|
||||
@@ -705,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);
|
||||
@@ -716,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -738,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());
|
||||
@@ -837,10 +875,11 @@ impl LauncherUi {
|
||||
return Primary::SelectedVersion { tag: tag.clone(), checked };
|
||||
}
|
||||
match status {
|
||||
AppStatus::Available if entry.ready => Primary::Retry(tl!("Retry install").into()),
|
||||
AppStatus::UpdateAvailable if entry.ready => {
|
||||
Primary::Retry(fmt(tl!("Retry update to {version}"), &[("version", &entry.latest_label().unwrap_or_default())]))
|
||||
}
|
||||
AppStatus::Available if entry.ready => Primary::Retry(tl!("Retry install").into(), vec![tl!("Install")]),
|
||||
AppStatus::UpdateAvailable if entry.ready => Primary::Retry(
|
||||
fmt(tl!("Retry update to {version}"), &[("version", &entry.latest_label().unwrap_or_default())]),
|
||||
vec![tl!("Retry update"), tl!("Update")],
|
||||
),
|
||||
AppStatus::Available => Primary::Install,
|
||||
AppStatus::UpdateAvailable => Primary::Update(entry.latest_label().unwrap_or_default()),
|
||||
AppStatus::UpToDate | AppStatus::Installed => Primary::Open,
|
||||
@@ -856,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);
|
||||
@@ -873,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) {
|
||||
@@ -971,14 +1078,15 @@ impl LauncherUi {
|
||||
self.install_app(id);
|
||||
}
|
||||
}
|
||||
Primary::Retry(label) => {
|
||||
Primary::Retry(label, shorter) => {
|
||||
let tip = tl!("The download is still here. It's checked again, then installed.");
|
||||
if widgets::button(ui, &label, Kind::Primary, main_w).on_hover_text(tip).clicked() {
|
||||
if widgets::button(ui, fitted(ui, &label, &shorter, main_w), Kind::Primary, main_w).on_hover_text(tip).clicked() {
|
||||
self.install_app(id);
|
||||
}
|
||||
}
|
||||
Primary::Update(v) => {
|
||||
if widgets::button(ui, &fmt(tl!("Update to {v}"), &[("v", &v)]), Kind::Primary, main_w).clicked() {
|
||||
let label = fmt(tl!("Update to {v}"), &[("v", &v)]);
|
||||
if widgets::button(ui, fitted(ui, &label, &[tl!("Update")], main_w), Kind::Primary, main_w).clicked() {
|
||||
self.install_app(id);
|
||||
}
|
||||
}
|
||||
@@ -1053,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) {
|
||||
@@ -1105,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);
|
||||
@@ -1115,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();
|
||||
@@ -1122,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)];
|
||||
|
||||
@@ -173,14 +173,17 @@ enum WindowButton {
|
||||
pub fn window_buttons(ui: &mut Ui, height: f32) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
if window_button(ui, WindowButton::Close, height).clicked() {
|
||||
// The first frame after a restore hit-tests the maximized buttons, so an edge press can land on them.
|
||||
let inside = ctx.content_rect().shrink(resize_band(&ctx));
|
||||
let clicked = |resp: Response| resp.clicked() && resp.interact_pointer_pos().is_none_or(|p| inside.contains(p));
|
||||
if clicked(window_button(ui, WindowButton::Close, height)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
let size = if maximized { WindowButton::Restore } else { WindowButton::Maximize };
|
||||
if window_button(ui, size, height).clicked() {
|
||||
if clicked(window_button(ui, size, height)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized));
|
||||
}
|
||||
if window_button(ui, WindowButton::Minimize, height).clicked() {
|
||||
if clicked(window_button(ui, WindowButton::Minimize, height)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
|
||||
}
|
||||
rule(ui, height);
|
||||
@@ -255,6 +258,17 @@ pub fn window_edges(ctx: &egui::Context) {
|
||||
t.hairline(),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
// Above every layer, so egui's click tolerance can't hand a resize's press to what's nearby (#571).
|
||||
let e = RESIZE_EDGE;
|
||||
let bands = [r.with_max_y(r.top() + e), r.with_min_y(r.bottom() - e), r.with_max_x(r.left() + e), r.with_min_x(r.right() - e)];
|
||||
for (edge, rect) in bands.into_iter().enumerate() {
|
||||
egui::Area::new(egui::Id::unique(("window-edge", edge)))
|
||||
.order(egui::Order::Tooltip)
|
||||
.movable(false)
|
||||
.constrain(false)
|
||||
.fixed_pos(rect.min)
|
||||
.show(ctx, |ui| ui.allocate_response(rect.size(), egui::Sense::CLICK));
|
||||
}
|
||||
let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|p| r.contains(*p)) else { return };
|
||||
let near = |d: f32| d < RESIZE_EDGE;
|
||||
// A corner takes a little of each edge next to it.
|
||||
@@ -515,6 +529,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`.
|
||||
@@ -571,9 +587,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());
|
||||
@@ -697,7 +738,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);
|
||||
@@ -755,11 +796,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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -109,11 +112,14 @@ fn harness_language(
|
||||
theme: egui::Theme,
|
||||
manage: bool,
|
||||
language: &str,
|
||||
extra: Vec<Event>,
|
||||
) -> (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 mut events = events();
|
||||
events.extend(extra);
|
||||
let mut backend = Some(Fake { 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>| {
|
||||
@@ -147,7 +153,7 @@ fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
// One test: the UI language is process-wide.
|
||||
#[test]
|
||||
fn german_switches_persists_and_keeps_install_actions() {
|
||||
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");
|
||||
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en", Vec::new());
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(3);
|
||||
@@ -176,12 +182,27 @@ fn german_switches_persists_and_keeps_install_actions() {
|
||||
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);
|
||||
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language, Vec::new());
|
||||
table.run_steps(6);
|
||||
table.get_by_label("Datum");
|
||||
assert!(table.query_all_by_label_contains(". Okt. 2026").count() >= 1);
|
||||
snapshot(&mut table, "table-de-light");
|
||||
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
|
||||
// Kept downloads: German retry labels are long, but a button never outgrows its slot (#956).
|
||||
let kept = ["photocraft", "vectorcraft"].map(|app| Event::ReadyToInstall { app: app.into(), ready: true });
|
||||
let (mut retry, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language, kept.into());
|
||||
retry.run_steps(6);
|
||||
let slot = retry.query_all_by_label("Installieren").next().unwrap().rect().width();
|
||||
for app in ["PhotoCraft", "VectorCraft"] {
|
||||
let menu = retry.get_by_label(&format!("Weitere Aktionen für {app}")).rect();
|
||||
// The main button is the one ending just left of the menu (or overlapping it).
|
||||
let main = retry
|
||||
.query_all_by_role(egui::accesskit::Role::Button)
|
||||
.filter(|n| (n.rect().center().y - menu.center().y).abs() < 1.0 && n.rect().left() < menu.left() && n.rect().right() > menu.left() - 20.0)
|
||||
.find(|n| n.rect().height() < 40.0)
|
||||
.unwrap();
|
||||
assert!(main.rect().width() <= slot + 0.5, "{app}: {:?} wider than Installieren ({slot})", main.rect());
|
||||
}
|
||||
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language, Vec::new());
|
||||
web.run_steps(6);
|
||||
assert!(web.query_all_by_label("Für macOS herunterladen").count() >= 3);
|
||||
for width in [720.0, 800.0, 1180.0] {
|
||||
@@ -195,4 +216,33 @@ fn german_switches_persists_and_keeps_install_actions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
for language in &artcraft_launcher_ui_egui::i18n::LANGUAGES {
|
||||
main_buttons_fit_the_narrowest_window(language.code);
|
||||
}
|
||||
}
|
||||
|
||||
/// At the launcher's narrowest, no main button outgrows its slot or crowds out the `⋯` menu (#956):
|
||||
/// a kept update, a kept install and a plain update, in both views, whichever label `language` has.
|
||||
fn main_buttons_fit_the_narrowest_window(language: &str) {
|
||||
use artcraft_launcher_ui_egui::i18n::{fmt, t};
|
||||
for view in [ViewMode::Table, ViewMode::Grid] {
|
||||
let kept = ["photocraft", "vectorcraft"].map(|app| Event::ReadyToInstall { app: app.into(), ready: true });
|
||||
let (mut h, _) = harness_language(view, egui::Theme::Dark, true, language, kept.into());
|
||||
h.set_size(egui::vec2(720.0, 1600.0));
|
||||
h.run_steps(8);
|
||||
let slot = h.query_all_by_label(t("Install")).next().unwrap().rect().width();
|
||||
for app in ["PhotoCraft", "VectorCraft", "EffectCraft"] {
|
||||
let menu = h.get_by_label(&fmt(t("More actions for {name}"), &[("name", app)])).rect();
|
||||
let main = h
|
||||
.query_all_by_role(egui::accesskit::Role::Button)
|
||||
.filter(|n| (n.rect().center().y - menu.center().y).abs() < 1.0 && n.rect().left() < menu.left())
|
||||
.max_by(|a, b| a.rect().right().total_cmp(&b.rect().right()))
|
||||
.unwrap()
|
||||
.rect();
|
||||
assert!(main.width() <= slot + 0.5, "{language} {view:?} {app}: {main:?} wider than Install ({slot})");
|
||||
assert!(main.right() <= menu.left(), "{language} {view:?} {app}: {main:?} overlaps the menu {menu:?}");
|
||||
// The table scrolls sideways, with a fixed slot; cards have to fit the window.
|
||||
assert!(view == ViewMode::Table || menu.right() <= 720.0, "{language} {view:?} {app}: the menu left the window: {menu:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
+404
-5
@@ -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);
|
||||
@@ -1282,9 +1288,9 @@ fn a_dismissed_install_prompt_is_not_called_a_cancelled_download() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A ready update names its version and installs on Retry, while an up-to-date app offers
|
||||
/// Reinstall instead of Download again.
|
||||
fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again() {
|
||||
/// A ready update installs on Retry (the table's narrow button leaves the version to its column),
|
||||
/// while an up-to-date app offers Reinstall instead of Download again.
|
||||
fn a_kept_update_installs_on_retry_and_an_up_to_date_app_is_not_downloaded_again() {
|
||||
let mut ev = events();
|
||||
for app in ["effectcraft", "wordcraft"] {
|
||||
ev.push(Event::ReadyToInstall { app: app.into(), ready: true });
|
||||
@@ -1294,7 +1300,7 @@ fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again
|
||||
snapshot(&mut h, "table-retry-update-dark");
|
||||
sent.borrow_mut().clear();
|
||||
assert!(h.query_by_label("Update to 0.6.0").is_none());
|
||||
h.get_by_label("Retry update to 0.6.0").click();
|
||||
h.get_by_label("Retry update").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(*sent.borrow(), [Command::Install { app: "effectcraft".into() }]);
|
||||
// WordCraft is up to date: its menu offers Reinstall, which uses the kept file.
|
||||
@@ -1306,6 +1312,31 @@ fn a_kept_update_names_its_version_and_an_up_to_date_app_is_not_downloaded_again
|
||||
assert!(h.query_by_label("Download again").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A Retry update button too wide for its slot drops the version instead of pushing the menu out
|
||||
/// of the row (#956): it is no wider than an Install button in the same view. A card with room
|
||||
/// for the version keeps it.
|
||||
fn a_retry_update_button_keeps_to_its_slot_and_leaves_room_for_the_menu() {
|
||||
for view in [ViewMode::Table, ViewMode::Grid] {
|
||||
let mut ev = events();
|
||||
// Two-digit minor, as in the report: "Retry update to 0.12.0".
|
||||
ev.push(Event::Latest {
|
||||
app: "effectcraft".into(),
|
||||
latest: LatestRelease::Found(release("effectcraft", "0.12.0", NOW)),
|
||||
from: Origin::ArtCraftApi,
|
||||
});
|
||||
ev.push(Event::ReadyToInstall { app: "effectcraft".into(), ready: true });
|
||||
let (mut h, _) = harness_with(ev, view, egui::Theme::Dark, true, 1.0);
|
||||
h.run_steps(8);
|
||||
let label = if view == ViewMode::Table { "Retry update" } else { "Retry update to 0.12.0" };
|
||||
let retry = h.get_by_label(label).rect();
|
||||
let install = h.query_all_by_label("Install").next().unwrap().rect();
|
||||
let menu = h.get_by_label("More actions for EffectCraft").rect();
|
||||
assert!(retry.width() <= install.width() + 0.5, "{view:?}: retry {retry:?} wider than install {install:?}");
|
||||
assert!(retry.right() <= menu.left(), "{view:?}: retry {retry:?} overlaps menu {menu:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_install_waiting_for_another_says_so_and_can_be_cancelled() {
|
||||
let mut events = events();
|
||||
@@ -1390,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.
|
||||
@@ -2135,6 +2358,110 @@ fn maximized_the_close_button_reaches_the_screens_corner() {
|
||||
assert!(asked.iter().any(|c| matches!(c, egui::ViewportCommand::BeginResize(_))), "{asked:?}");
|
||||
}
|
||||
|
||||
/// A quick drag on an edge only resizes: egui sees it as one spot, so nothing nearby may click (#571).
|
||||
#[test]
|
||||
fn a_press_on_the_window_edge_only_resizes() {
|
||||
use egui::ViewportCommand as Asked;
|
||||
struct FramelessHost;
|
||||
impl Host for FramelessHost {
|
||||
fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar {
|
||||
artcraft_launcher_ui_egui::TitleBar::Custom
|
||||
}
|
||||
}
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let mut backend = Some(Fake {
|
||||
events: events(),
|
||||
sent: Rc::new(RefCell::new(Vec::new())),
|
||||
manage: true,
|
||||
pictures: Rc::default(),
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut h = test_harness_builder(egui::Theme::Dark).with_size(egui::vec2(1180.0, 1200.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { notify: false, ..Settings::default() }));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut FramelessHost);
|
||||
// The harness keeps its app 8 px in; a real header starts at the window's top.
|
||||
let window = ui.ctx().content_rect();
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(window), |ui| l.ui(ui, &mut FramelessHost));
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
h.run_steps(6);
|
||||
let press = |h: &mut Harness<'_, Option<LauncherUi>>, pos: egui::Pos2| -> Vec<Asked> {
|
||||
let mut asked = Vec::new();
|
||||
for pressed in [true, false] {
|
||||
let input = h.input_mut();
|
||||
input.events.push(egui::Event::PointerMoved(pos));
|
||||
input.events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.step();
|
||||
asked.extend(h.output().viewport_output.values().flat_map(|v| v.commands.iter().cloned()));
|
||||
}
|
||||
h.run_steps(2);
|
||||
asked
|
||||
};
|
||||
let settings_open =
|
||||
|h: &Harness<'_, Option<LauncherUi>>| h.query_by_role_and_label(egui::accesskit::Role::ComboBox, "Launcher version").is_some();
|
||||
for label in ["Minimize", "Maximize", "Close", "Settings"] {
|
||||
let x = h.get_by_label(label).rect().center().x;
|
||||
let asked = press(&mut h, egui::pos2(x, 2.0));
|
||||
assert!(asked.iter().any(|c| matches!(c, Asked::BeginResize(_))), "{label}: {asked:?}");
|
||||
assert!(!asked.iter().any(|c| matches!(c, Asked::Minimized(_) | Asked::Maximized(_) | Asked::Close)), "{label}: {asked:?}");
|
||||
}
|
||||
assert!(!settings_open(&h), "the gear was pressed through the edge");
|
||||
// The first frame at a new size already covers the new edge, or Close slips out from under it.
|
||||
h.set_size(egui::vec2(1300.0, 1200.0));
|
||||
h.step();
|
||||
let x = h.get_by_label("Close").rect().center().x;
|
||||
let asked = press(&mut h, egui::pos2(x, 2.0));
|
||||
assert!(!asked.iter().any(|c| matches!(c, Asked::Close)), "after growing: {asked:?}");
|
||||
// The first frame after a restore has no edge areas to hit yet, only last frame's full buttons.
|
||||
let press_on_restore = |h: &mut Harness<'_, Option<LauncherUi>>, label: &str, y: f32| -> Vec<Asked> {
|
||||
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true);
|
||||
h.run_steps(3);
|
||||
let x = h.get_by_label(label).rect().center().x;
|
||||
h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(false);
|
||||
press(h, egui::pos2(x, y))
|
||||
};
|
||||
for label in ["Minimize", "Restore", "Close"] {
|
||||
let asked = press_on_restore(&mut h, label, 4.5);
|
||||
assert!(!asked.iter().any(|c| matches!(c, Asked::Minimized(_) | Asked::Maximized(_) | Asked::Close)), "{label} after restoring: {asked:?}");
|
||||
}
|
||||
// Just below the band, that frame's click is the button's.
|
||||
let asked = press_on_restore(&mut h, "Minimize", 5.5);
|
||||
assert!(asked.contains(&Asked::Minimized(true)), "below the band after restoring: {asked:?}");
|
||||
// A key press still works with the pointer resting on the band, and so does a click below it.
|
||||
let x = h.get_by_label("Minimize").rect().center().x;
|
||||
press(&mut h, egui::pos2(x, 4.5));
|
||||
h.get_by_label("Minimize").focus();
|
||||
h.run_steps(2);
|
||||
h.key_down(egui::Key::Enter);
|
||||
h.step();
|
||||
let asked: Vec<_> = h.output().viewport_output.values().flat_map(|v| v.commands.iter().cloned()).collect();
|
||||
assert!(asked.contains(&Asked::Minimized(true)), "by key: {asked:?}");
|
||||
h.key_up(egui::Key::Enter);
|
||||
h.step();
|
||||
for (label, want) in [("Minimize", Asked::Minimized(true)), ("Maximize", Asked::Maximized(true)), ("Close", Asked::Close)] {
|
||||
let x = h.get_by_label(label).rect().center().x;
|
||||
let asked = press(&mut h, egui::pos2(x, 5.5));
|
||||
assert!(asked.contains(&want), "{label} below the band: {asked:?}");
|
||||
assert!(!asked.iter().any(|c| matches!(c, Asked::BeginResize(_))), "{label} below the band: {asked:?}");
|
||||
}
|
||||
// A dialog's backdrop reaches the edges too; the press doesn't dismiss it.
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(4);
|
||||
assert!(settings_open(&h));
|
||||
let asked = press(&mut h, egui::pos2(590.0, 1198.0));
|
||||
assert!(asked.iter().any(|c| matches!(c, Asked::BeginResize(_))), "{asked:?}");
|
||||
assert!(settings_open(&h), "the dialog closed on a resize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Clicking a card opens its details drawer with the page links, wrapping picture arrows and
|
||||
/// Esc-to-close.
|
||||
@@ -2784,6 +3111,78 @@ fn changelog_shortcuts_zoom_only_the_notes_text() {
|
||||
assert!(h.ctx.zoom_factor() > global, "main-UI keyboard zoom should still work after closing notes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The notes card grows with the zoomed text, stops at the window, and comes back on reset (#594).
|
||||
fn the_notes_card_follows_the_zoom_within_the_window() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("More actions for PhotoCraft").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Release notes").click();
|
||||
h.run_steps(4);
|
||||
let span = |h: &Harness<'_, Option<LauncherUi>>| h.get_by_label("Close").rect().right() - h.get_by_label("Open on GitHub").rect().left();
|
||||
let usual = span(&h);
|
||||
for _ in 0..3 {
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus);
|
||||
h.run_steps(3);
|
||||
}
|
||||
assert!(span(&h) > usual * 1.2, "the card kept its width: {usual} -> {}", span(&h));
|
||||
for _ in 0..30 {
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus);
|
||||
h.run_steps(3);
|
||||
}
|
||||
let in_window = |h: &Harness<'_, Option<LauncherUi>>| {
|
||||
let window = h.ctx.content_rect();
|
||||
for label in ["Close", "Open on GitHub"] {
|
||||
assert!(window.contains_rect(h.get_by_label(label).rect()), "{label} left {window:?}");
|
||||
}
|
||||
};
|
||||
in_window(&h);
|
||||
// The launcher's smallest window.
|
||||
let full = h.ctx.content_rect().size();
|
||||
h.set_size(egui::vec2(720.0, 500.0));
|
||||
h.run_steps(3);
|
||||
in_window(&h);
|
||||
h.set_size(full);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Num0);
|
||||
h.run_steps(3);
|
||||
assert!((span(&h) - usual).abs() < 0.5, "reset left the card at {}, was {usual}", span(&h));
|
||||
// Below 100% only the text shrinks.
|
||||
for _ in 0..3 {
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Minus);
|
||||
h.run_steps(3);
|
||||
}
|
||||
assert!((span(&h) - usual).abs() < 0.5, "zooming out left the card at {}, was {usual}", span(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Notes too long to fit take the zoom's extra height too (#594).
|
||||
fn long_notes_get_a_taller_card_when_zoomed() {
|
||||
let notes: String = (0..200).map(|n| format!("Line {n}\n\n")).collect();
|
||||
let mut ev = events();
|
||||
for e in &mut ev {
|
||||
if let Event::Latest { latest: LatestRelease::Found(r), .. } = e {
|
||||
r.notes.clone_from(¬es);
|
||||
}
|
||||
}
|
||||
let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);
|
||||
h.run_steps(4);
|
||||
h.get_by_label("More actions for PhotoCraft").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Release notes").click();
|
||||
h.run_steps(4);
|
||||
let bottom = |h: &Harness<'_, Option<LauncherUi>>| h.get_by_label("Close").rect().bottom();
|
||||
let usual = bottom(&h);
|
||||
for _ in 0..3 {
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Plus);
|
||||
h.run_steps(3);
|
||||
}
|
||||
assert!(bottom(&h) > usual + 60.0, "the card kept its height: {usual} -> {}", bottom(&h));
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Num0);
|
||||
h.run_steps(3);
|
||||
assert!((bottom(&h) - usual).abs() < 0.5, "reset left the card at {}, was {usual}", bottom(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_m_minimizes_the_mac_window_only() {
|
||||
use artcraft_launcher_ui_egui::TitleBar;
|
||||
@@ -2816,7 +3215,7 @@ fn an_occupied_folder_asks_before_replacing_it() {
|
||||
sent.borrow_mut().clear();
|
||||
h.get_by_label("Replace").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(*sent.borrow(), [Command::Replace { app: "photocraft".into() }]);
|
||||
assert_eq!(*sent.borrow(), [Command::Replace { app: "photocraft".into(), path: path.into() }]);
|
||||
assert!(h.query_by_label("Replace").is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ the route.
|
||||
- v0.11.0 was revoked, and its release page points users to v0.10.0. v0.10.0 also contains this
|
||||
route, so its users stay exposed until a release with #1077 ships.
|
||||
- #1076, an alternative that also removed the downgrade route, was closed in favour of #1077.
|
||||
- #1082: `msi_context.rs` ran a hidden, non-elevated PowerShell lookup on MSI updates. It now
|
||||
reads Windows Installer's own registration keys for this account and the machine instead, so no
|
||||
update starts PowerShell.
|
||||
|
||||
## Still open
|
||||
|
||||
@@ -93,9 +96,6 @@ the route.
|
||||
- **Confirmation:** test on a Windows VM with Kaspersky and behaviour detection on. Run Update all
|
||||
with two or more outdated all-users apps on an affected build, then on a build with #1077. The
|
||||
test only counts if the affected build reproduces the flag.
|
||||
- **Remaining lookup:** `msi_context.rs` runs a hidden, non-elevated PowerShell lookup on MSI
|
||||
updates. It runs for single-app updates too, which #1027 says are fine, so it was left in place.
|
||||
If flags continue, look there next.
|
||||
- **Affected users:** the revert does not repair installs the antivirus has already damaged. The
|
||||
"isn't where it was installed" errors are tracked in #993.
|
||||
|
||||
|
||||
+40
-3
@@ -8,7 +8,8 @@
|
||||
# (and whenever the AppImage's path changes) install the .desktop entry and the
|
||||
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
|
||||
# the launch untouched. AppImage managers can opt out with DESKTOPINTEGRATION=1,
|
||||
# and users with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1.
|
||||
# and users with ARTCRAFT_LAUNCHER_NO_DESKTOP_INTEGRATION=1. Nothing is installed
|
||||
# while another entry (appimaged, AppImageLauncher, Gear Lever) runs this AppImage.
|
||||
|
||||
set -u
|
||||
|
||||
@@ -38,10 +39,17 @@ integrate() {
|
||||
data="${XDG_DATA_HOME:-$home/.local/share}"
|
||||
src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop"
|
||||
[ -f "$src_desktop" ] || return 0
|
||||
apps="$data/applications"
|
||||
# A second entry would list the app twice; drop the one we wrote if it is unchanged.
|
||||
if other_entry_runs "$apps" "$appimage"; then
|
||||
if own_entry "$apps/$DESK_ID.desktop" && rm -f "$apps/$DESK_ID.desktop"; then
|
||||
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop
|
||||
# TryExec, which would hide the entry; everything else is shipped as packaged.
|
||||
apps="$data/applications"
|
||||
mkdir -p "$apps" 2>/dev/null || return 0
|
||||
tmp="$apps/.$DESK_ID.tmp.$$"
|
||||
esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')"
|
||||
@@ -97,7 +105,36 @@ own_entry() {
|
||||
[ "$existing_exec" = "$generated_exec" ] || return 1
|
||||
expected_body="$(sed -e '/^Exec=/d' -e '/^TryExec=/d' "$src_desktop")" || return 1
|
||||
existing_body="$(sed -e '/^Exec=/d' -e '/^X-ArtCraft-Launcher-Generated-Exec=/d' "$1")" || return 1
|
||||
[ "$expected_body" = "$existing_body" ]
|
||||
# Entries written before the StartupWMClass fix differ only in that line.
|
||||
old_body="$(printf '%s\n' "$expected_body" | sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/')"
|
||||
[ "$expected_body" = "$existing_body" ] || [ "$old_body" = "$existing_body" ]
|
||||
}
|
||||
|
||||
# True when a visible entry in $1 other than ours runs the AppImage $2 and has our window class.
|
||||
other_entry_runs() {
|
||||
grep -lF -e "$2" "$1"/*.desktop 2>/dev/null | (
|
||||
while IFS= read -r f; do
|
||||
[ "$f" = "$1/$DESK_ID.desktop" ] && continue
|
||||
main=false runs=false class=false
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
# Only the [Desktop Entry] group; an action's Exec isn't what the entry launches.
|
||||
case "$line" in
|
||||
"[Desktop Entry]") main=true && continue ;;
|
||||
"["*) main=false ;;
|
||||
esac
|
||||
[ "$main" = true ] || continue
|
||||
case "$line" in
|
||||
# Menu editors hide an entry this way; it lists nothing.
|
||||
Hidden=true | NoDisplay=true) runs=false && break ;;
|
||||
# A class from before an in-place update can't match the window, so ours has to stay.
|
||||
"StartupWMClass=$DESK_ID") class=true ;;
|
||||
Exec=*"$2" | Exec=*"$2 "* | Exec=*"$2\""* | Exec=*"$2'"*) runs=true ;;
|
||||
esac
|
||||
done <"$f"
|
||||
[ "$runs" = true ] && [ "$class" = true ] && exit 0
|
||||
done
|
||||
exit 1
|
||||
)
|
||||
}
|
||||
|
||||
integrate
|
||||
|
||||
@@ -181,4 +181,64 @@ ln -s "$TMP/manager-entry" "$DESKTOP"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch with desktop symlink failed"
|
||||
[[ -L "$DESKTOP" ]] || fail "desktop symlink replaced"
|
||||
|
||||
# 15. appimaged lists this AppImage under its own name: drop our entry, install nothing more.
|
||||
rm -rf "$XDG_DATA_HOME"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch before appimaged failed"
|
||||
apps="$XDG_DATA_HOME/applications"
|
||||
real="$(readlink -f "$APPIMAGE")"
|
||||
printf '[Desktop Entry]\nName=ArtCraft Launcher\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\nActions=Trash;\n\n[Desktop Action Trash]\nName=Move to Trash\nExec=gio trash "%s"\n' "$real" "$APP_ID" "$real" >"$apps/appimagekit_0123abcd.desktop"
|
||||
rm -rf "$XDG_DATA_HOME/icons"
|
||||
out="$("$APPDIR/AppRun" managed)" || fail "launch beside appimaged failed"
|
||||
[[ "$out" = "stub: managed" ]] || fail "launch beside appimaged lost arguments"
|
||||
[[ ! -e "$DESKTOP" ]] || fail "own entry kept beside appimaged's"
|
||||
[[ ! -e "$XDG_DATA_HOME/icons" ]] || fail "icons installed beside appimaged's entry"
|
||||
|
||||
# 15a. Gear Lever's entry (single-quoted path, no trailing newline) counts too.
|
||||
rm -rf "$XDG_DATA_HOME" && mkdir -p "$apps"
|
||||
spaced="$TMP/Art Craft.AppImage"
|
||||
: >"$spaced"
|
||||
printf "[Desktop Entry]\nName=ArtCraft Launcher\nStartupWMClass=%s\nExec=env DESKTOPINTEGRATION=1 '%s'" "$APP_ID" "$(readlink -f "$spaced")" >"$apps/artcraft_launcher.desktop"
|
||||
APPIMAGE="$spaced" "$APPDIR/AppRun" >/dev/null || fail "launch beside Gear Lever failed"
|
||||
[[ ! -e "$DESKTOP" ]] || fail "integrated beside Gear Lever's entry"
|
||||
|
||||
# 15b. Our entry with user changes stays beside a manager's entry.
|
||||
cp "$apps/artcraft_launcher.desktop" "$TMP/gear-lever-entry"
|
||||
sed "s|^Exec=.*|Exec=\"$(readlink -f "$APPIMAGE")\"|" "$TMP/gear-lever-entry" >"$apps/artcraft_launcher.desktop"
|
||||
sed 's/^Name=.*/Name=My Launcher/' "$TMP/generated-entry" >"$DESKTOP"
|
||||
cp "$DESKTOP" "$TMP/renamed-entry"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch with renamed entry failed"
|
||||
cmp -s "$DESKTOP" "$TMP/renamed-entry" || fail "user-changed entry removed"
|
||||
|
||||
# 15c. An entry for another file that shares our path as a prefix doesn't count.
|
||||
rm -rf "$XDG_DATA_HOME" && mkdir -p "$apps"
|
||||
printf '[Desktop Entry]\nStartupWMClass=%s\nExec="%s.old"\n' "$APP_ID" "$(readlink -f "$APPIMAGE")" >"$apps/old-copy.desktop"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch beside an older copy's entry failed"
|
||||
[[ -f "$DESKTOP" ]] || fail "an entry for another file blocked integration"
|
||||
|
||||
# 15d. An entry from a release with the old StartupWMClass is ours: refreshed, and dropped beside appimaged's.
|
||||
sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/' "$TMP/generated-entry" >"$DESKTOP"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch over an older release's entry failed"
|
||||
cmp -s "$DESKTOP" "$TMP/generated-entry" || fail "older release's entry not refreshed"
|
||||
sed 's/^StartupWMClass=.*/StartupWMClass=artcraft-launcher/' "$TMP/generated-entry" >"$DESKTOP"
|
||||
printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\n' "$real" "$APP_ID" >"$apps/appimagekit_0123abcd.desktop"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch over an older release's entry beside appimaged failed"
|
||||
[[ ! -e "$DESKTOP" ]] || fail "older release's entry kept beside appimaged's"
|
||||
|
||||
# 15e. A manager's entry hidden with a menu editor lists nothing: ours is installed and kept.
|
||||
for key in NoDisplay Hidden; do
|
||||
printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=%s\n%s=true\n' "$real" "$APP_ID" "$key" >"$apps/appimagekit_0123abcd.desktop"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch beside a hidden entry failed"
|
||||
[[ -f "$DESKTOP" ]] || fail "no entry of ours beside a $key=true manager entry"
|
||||
done
|
||||
|
||||
# 15f. A manager's entry left with the old window class by an in-place update: ours stays.
|
||||
printf '[Desktop Entry]\nExec="/usr/bin/appimaged" wrap "%s" %%F\nStartupWMClass=artcraft-launcher\n' "$real" >"$apps/appimagekit_0123abcd.desktop"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch beside an old-class manager entry failed"
|
||||
[[ -f "$DESKTOP" ]] || fail "own entry dropped beside a manager entry with the old class"
|
||||
|
||||
# 15g. Only the [Desktop Entry] group counts: an action that runs this AppImage doesn't.
|
||||
printf '[Desktop Entry]\nExec=other-app\nStartupWMClass=%s\nActions=Launcher;\n\n[Desktop Action Launcher]\nExec="%s"\n' "$APP_ID" "$real" >"$apps/appimagekit_0123abcd.desktop"
|
||||
"$APPDIR/AppRun" >/dev/null || fail "launch beside an entry that runs this AppImage from an action failed"
|
||||
[[ -f "$DESKTOP" ]] || fail "own entry dropped beside an entry that runs this AppImage only from an action"
|
||||
|
||||
echo "AppRun desktop integration ok"
|
||||
@@ -11,3 +11,6 @@ workspace = true
|
||||
[dependencies]
|
||||
jiff = { workspace = true }
|
||||
semver = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
+4
-2
@@ -787,7 +787,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn writes_a_feed_the_launcher_reads() {
|
||||
let mirror = std::env::temp_dir().join(format!("xtask-local-{}", std::process::id()));
|
||||
// A fresh private directory with an unpredictable name: a predictable one in the shared
|
||||
// temp root could be pre-created by another user, with symlinks where the fixture writes.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mirror = tmp.path().to_path_buf();
|
||||
let dir = mirror.join("photocraft").join("0.6.0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("photocraft-0.6.0-macos-aarch64.dmg"), b"dmg").unwrap();
|
||||
@@ -797,7 +800,6 @@ mod tests {
|
||||
std::fs::create_dir_all(mirror.join("photocraft").join("0.7.0")).unwrap();
|
||||
std::fs::write(dir.join(COMPLETE), "").unwrap();
|
||||
let json = feed(&mirror, "http://127.0.0.1:8787/");
|
||||
let _ = std::fs::remove_dir_all(&mirror);
|
||||
assert!(json.contains(r#""tag":"v0.6.0""#), "{json}");
|
||||
assert!(json.contains(r#""download_url":"http://127.0.0.1:8787/photocraft/0.6.0/photocraft-0.6.0-macos-aarch64.dmg""#));
|
||||
assert!(json.contains(r#""size_bytes":3"#) && json.contains(&"ab".repeat(32)));
|
||||
|
||||
Reference in new issue
Block a user