Detect AUR installs, read distro versions, rescan installs

Ideas from the ArtCraft Suite Updater:

- Arch / AUR: detect each app through pacman under all three AUR names
  (<app>, <app>-bin, <app>-git; PdfCraft also as printcraft*). Updates
  and removals of AUR installs show the paru / yay / pacman command
  instead of installing a second copy
- Read package-manager versions the package-manager way: drop the
  revision (0.5.0-1), epoch (1:) and VCS suffix (0.3.0.r47.g…), and map
  Debian's ~. Fixes .deb installs of the latest release showing an
  update forever
- Flatpak installs that list no version are read with `flatpak info`
- Look again at what's installed every 5 minutes, and when the window
  regains focus (at most every 30 s), so apps installed or removed
  outside the launcher show up; one detection pass at a time
- Package-list parsing lives in crates/native/src/packages.rs, tested on
  every platform

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-09 23:16:14 -04:00
1 parent 52be9b1452
commit 8143a26014
10 files changed
+340 -44

No files matched your search

+8 -2
View File
@@ -17,6 +17,8 @@ pub enum InstallSource {
Deb,
/// An RPM package.
Rpm,
/// An Arch Linux package (from the AUR: `<app>`, `<app>-bin` or `<app>-git`).
Pacman,
/// A Flatpak.
Flatpak,
/// An AppImage file found on disk.
@@ -33,6 +35,7 @@ impl InstallSource {
Self::Msi => "Windows Installer",
Self::Deb => "Debian package",
Self::Rpm => "RPM package",
Self::Pacman => "Arch package (AUR)",
Self::Flatpak => "Flatpak",
Self::AppImage => "AppImage",
Self::Binary => "System binary",
@@ -52,8 +55,11 @@ pub struct Installed {
/// `None` when the copy is there but its version couldn't be read.
pub version: Option<Version>,
pub source: InstallSource,
/// Where it lives: the `.app`, the exe, the AppImage, or the package name.
/// Where it lives: the `.app`, the exe, the AppImage, or the binary of a package.
pub location: String,
/// The package that installed it, for package-manager installs (`photocraft-bin`).
#[serde(default)]
pub package: Option<String>,
}
/// The status shown on an app's card or row.
@@ -117,7 +123,7 @@ mod tests {
use super::*;
fn inst(v: &str) -> Installed {
Installed { version: version::parse(v), source: InstallSource::Launcher, location: String::new() }
Installed { version: version::parse(v), source: InstallSource::Launcher, location: String::new(), package: None }
}
#[test]
+48
View File
@@ -32,6 +32,35 @@ pub fn parse(text: &str) -> Option<Version> {
None
}
/// Reads a version as a system package manager reports it, dropping what the packaging adds:
/// an epoch (`1:`), the package revision (`-1`, `-2.fc40`), Debian's `~` pre-release spelling, and
/// a VCS snapshot suffix (`0.3.0.r47.g47f9306` from an AUR `-git` package, read as `0.3.0`).
pub fn parse_package(text: &str) -> Option<Version> {
let t = text.trim();
// Epoch.
let t = match t.split_once(':') {
Some((epoch, rest)) if !epoch.is_empty() && epoch.bytes().all(|b| b.is_ascii_digit()) => rest,
_ => t,
};
// Package revision: whatever follows the last '-' when the part before it is a version.
let t = match t.rsplit_once('-') {
Some((upstream, rev)) if !rev.is_empty() && rev.starts_with(|c: char| c.is_ascii_digit()) && upstream.contains('.') => upstream,
_ => t,
};
let t = t.replace('~', "-");
if let Some(v) = parse(&t) {
return Some(v);
}
// VCS snapshots and other suffixes: the leading X.Y.Z.
let core: String = t.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
let parts: Vec<&str> = core.split('.').filter(|p| !p.is_empty()).take(3).collect();
match parts.as_slice() {
[a, b, c] => Version::parse(&format!("{a}.{b}.{c}")).ok(),
[a, b] => Version::parse(&format!("{a}.{b}.0")).ok(),
_ => None,
}
}
/// A version that's a plain `X.Y.Z` with no pre-release suffix.
pub fn is_stable(v: &Version) -> bool {
v.pre.is_empty()
@@ -80,6 +109,25 @@ mod tests {
}
}
#[test]
fn reads_package_manager_versions() {
let p = |s: &str| parse_package(s).map(|v| v.to_string());
// dpkg and pacman add a package revision; it is not a pre-release.
assert_eq!(p("0.5.0-1").as_deref(), Some("0.5.0"));
assert_eq!(p("0.5.0-12").as_deref(), Some("0.5.0"));
assert_eq!(p("1:0.5.0-1").as_deref(), Some("0.5.0"));
assert_eq!(p("0.2.0~rc.1-1").as_deref(), Some("0.2.0-rc.1"));
assert_eq!(p("0.5.0-2.fc40").as_deref(), Some("0.5.0"));
// AUR -git packages: the release the snapshot follows.
assert_eq!(p("0.3.0.r47.g47f9306-1").as_deref(), Some("0.3.0"));
// Plain versions (rpm %{VERSION}, Flatpak) pass through.
assert_eq!(p("0.4.0").as_deref(), Some("0.4.0"));
assert_eq!(p("garbage"), None);
// The bug this guards: 0.5.0-1 used to read as a pre-release, so an installed .deb of the
// latest release always looked out of date.
assert!(!is_update(&v("0.5.0"), &parse_package("0.5.0-1").unwrap()));
}
#[test]
fn orders_versions_and_spots_updates() {
assert!(is_newer(&v("0.10.0"), &v("0.9.9")));
+10
View File
@@ -35,6 +35,8 @@ struct Shared {
manifest: Mutex<Manifest>,
options: Mutex<BackendOptions>,
checking: AtomicBool,
/// A detection pass is running; another request waits for the next one.
detecting: AtomicBool,
last_check: Mutex<Option<i64>>,
}
@@ -79,6 +81,7 @@ impl NativeBackend {
manifest: Mutex::new(Manifest::load(&paths.manifest())),
options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false),
detecting: AtomicBool::new(false),
last_check: Mutex::new(None),
});
let backend = Self { shared, paths, http, source, platform: native_platform() };
@@ -106,6 +109,9 @@ impl NativeBackend {
}
fn detect_all(&self) {
if self.shared.detecting.swap(true, Ordering::SeqCst) {
return;
}
let shared = self.shared.clone();
let paths = self.paths.clone();
let platform = self.platform;
@@ -130,6 +136,7 @@ impl NativeBackend {
for app in &SUITE {
report_partial(app, &shared, &paths, platform, &options);
}
shared.detecting.store(false, Ordering::SeqCst);
});
}
@@ -264,6 +271,9 @@ fn run_install(
record: Option<&crate::manifest::Record>,
stop: &Stop,
) -> Result<String, String> {
if let Some(msg) = existing.and_then(|e| install::managed_elsewhere(app, e, false)) {
return Err(msg);
}
let http = http.ok_or("secure connections aren't available on this system")?;
let platform = platform.ok_or("this system isn't supported")?;
let release = shared
+50 -36
View File
@@ -14,7 +14,7 @@ use crate::manifest::Manifest;
#[derive(Default)]
pub struct Snapshot {
#[allow(dead_code)]
packages: Vec<(String, String, artcraft_launcher_core::InstallSource)>,
packages: Vec<crate::packages::Package>,
}
impl Snapshot {
@@ -58,7 +58,12 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option<Installed> {
if let Some(found) = macos::read_bundle(app, &rec.path) {
return Some(found);
}
Some(Installed { version: version::parse(&rec.version), source: InstallSource::Launcher, location: rec.path.display().to_string() })
Some(Installed {
version: version::parse(&rec.version),
source: InstallSource::Launcher,
location: rec.path.display().to_string(),
package: None,
})
}
#[cfg(target_os = "macos")]
@@ -93,7 +98,7 @@ 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() })
Some(Installed { version: text.and_then(version::parse), source: InstallSource::MacApp, location: path.display().to_string(), package: None })
}
}
@@ -172,6 +177,7 @@ pub mod windows {
version: m.version,
source: InstallSource::Msi,
location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()),
package: Some(m.product_code),
})
}
}
@@ -184,46 +190,55 @@ pub mod linux {
use artcraft_launcher_core::platform::parse_asset_name;
use super::*;
use crate::packages::{self, Package};
use crate::process::capture;
/// Installed Craft App packages: (name, version, source). Read once per detection pass.
pub fn packages() -> Vec<(String, String, InstallSource)> {
let wanted = |name: &str| artcraft_launcher_core::SUITE.iter().any(|a| a.id == name);
let mut out = Vec::new();
/// Installed Craft App packages from every package manager on this system (dpkg, rpm,
/// pacman, Flatpak). Read once per detection pass.
pub fn packages() -> Vec<Package> {
let ours = |name: &str| {
artcraft_launcher_core::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
};
let t = Duration::from_secs(10);
let mut out = Vec::new();
let mut add = |manager, list: Vec<(String, String)>| {
out.extend(list.into_iter().filter(|(n, _)| ours(n)).map(|(name, version)| Package { manager, name, version }));
};
if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) {
for line in list.lines() {
let mut f = line.split('|');
if let (Some(name), Some("installed"), Some(ver)) = (f.next(), f.next(), f.next())
&& wanted(name)
{
out.push((name.to_string(), ver.to_string(), InstallSource::Deb));
}
}
add(InstallSource::Deb, packages::parse_dpkg(&list));
}
if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) {
for line in list.lines() {
if let Some((name, ver)) = line.split_once('|')
&& wanted(name)
{
out.push((name.to_string(), ver.to_string(), InstallSource::Rpm));
}
}
add(InstallSource::Rpm, packages::parse_rpm(&list));
}
if let Some(list) = capture("pacman", &["-Q"], t) {
add(InstallSource::Pacman, packages::parse_pacman(&list));
}
if let Some(list) = capture("flatpak", &["list", "--app", "--columns=application,version"], t) {
for line in list.lines() {
let mut f = line.split('\t');
if let (Some(id), Some(ver)) = (f.next(), f.next())
&& let Some(name) = id.strip_prefix("ai.storyteller.")
&& wanted(name)
{
out.push((name.to_string(), ver.to_string(), InstallSource::Flatpak));
let mut flatpaks = packages::parse_flatpak_list(&list);
// Bundles built from a release tarball often list no version: ask Flatpak directly.
for (id, version) in flatpaks.iter_mut().filter(|(id, v)| v.is_empty() && id.starts_with("ai.storyteller.")) {
if let Some(v) = capture("flatpak", &["info", id], t).as_deref().and_then(packages::parse_flatpak_info) {
*version = v;
}
}
add(InstallSource::Flatpak, flatpaks);
}
out
}
/// 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()
}
/// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention).
pub fn appimage_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Applications"))
@@ -251,13 +266,12 @@ pub mod linux {
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() });
return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None });
}
snapshot.packages.iter().find(|(name, _, _)| name == app.id).map(|(name, ver, source)| Installed {
version: version::parse(ver),
source: *source,
location: if *source == InstallSource::Flatpak { app.bundle_id() } else { format!("/usr/bin/{name}") },
})
let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];
let p = packages::find(app, &snapshot.packages, &order)?;
let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) })
}
}
@@ -273,7 +287,7 @@ pub mod freebsd {
if !bin.exists() {
return None;
}
Some(Installed { version: binary_version(&bin, app.id), source: InstallSource::Binary, location: bin.display().to_string() })
Some(Installed { version: binary_version(&bin, app.id), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
+27 -3
View File
@@ -96,6 +96,8 @@ pub fn choose_kinds(existing: Option<&Installed>, record: Option<&Record>, prefe
InstallSource::Flatpak => Some(PackageKind::Flatpak),
InstallSource::AppImage => Some(PackageKind::AppImage),
InstallSource::Binary => Some(PackageKind::TarGz),
// Updated by the user's AUR helper; see `managed_elsewhere`.
InstallSource::Pacman => None,
};
if let Some(k) = kind.filter(|k| available.contains(k)) {
return vec![k];
@@ -159,12 +161,34 @@ fn published_checksum(release: &Release, asset: &Asset, http: &Http) -> Result<O
Ok(release::parse_sha256sums(&text).into_iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h))
}
/// When `installed` is owned by a package manager the launcher doesn't drive (the AUR), what to
/// tell the user instead of installing a second copy.
pub fn managed_elsewhere(app: &CraftApp, installed: &Installed, uninstall: bool) -> Option<String> {
match installed.source {
InstallSource::Pacman => {
let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string());
Some(if uninstall {
format!("{} was installed from the AUR ({pkg}). Remove it with: sudo pacman -R {pkg}", app.name)
} else {
let helper = ["paru", "yay"].into_iter().find(|h| which(h).is_some());
let cmd = match helper {
Some(h) => format!("{h} -S {pkg}"),
None => format!("your AUR helper (for example: yay -S {pkg})"),
};
format!("{} was installed from the AUR ({pkg}). Update it with {cmd}", app.name)
})
}
InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)),
_ => None,
}
}
/// Removes an installed copy.
pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>) -> Result<(), String> {
let _ = record;
let path = PathBuf::from(&installed.location);
if installed.source == InstallSource::Binary {
return Err(format!("{} was installed outside the launcher. Remove it the way it was installed.", app.name));
if let Some(msg) = managed_elsewhere(app, installed, true) {
return Err(msg);
}
if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher)
&& process::is_running(&path)
@@ -727,7 +751,7 @@ mod tests {
#[test]
fn updates_keep_the_installed_format() {
let inst = |source| Installed { version: None, source, location: String::new() };
let inst = |source| Installed { version: None, source, location: String::new(), package: None };
let available = available_kinds();
if let Some(first) = available.first() {
assert_eq!(choose_kinds(None, None, &[]).first(), Some(first));
+1
View File
@@ -13,6 +13,7 @@ pub mod http;
pub mod install;
pub mod login;
pub mod manifest;
pub mod packages;
pub mod paths;
pub mod process;
pub mod single_instance;
+134
View File
@@ -0,0 +1,134 @@
//! Reading system package lists (dpkg, rpm, pacman, Flatpak) and matching them to Craft Apps.
//!
//! Pure functions over the tools' text output, so they're tested on every platform; only Linux
//! runs the tools (`detect::linux`).
use artcraft_launcher_core::{CraftApp, InstallSource, version};
/// One installed package that belongs to a Craft App.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Package {
pub manager: InstallSource,
/// The package's name (`photocraft-bin`) or Flatpak app id.
pub name: String,
/// As the package manager reports it (`0.5.0-1`); empty when it doesn't say.
pub version: String,
}
/// `dpkg-query -W -f '${Package}|${db:Status-Status}|${Version}\n'`: installed packages only.
pub fn parse_dpkg(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let mut f = line.split('|');
match (f.next(), f.next(), f.next()) {
(Some(name), Some("installed"), Some(ver)) => Some((name.to_string(), ver.to_string())),
_ => None,
}
})
.collect()
}
/// `rpm -qa --qf '%{NAME}|%{VERSION}\n'`.
pub fn parse_rpm(text: &str) -> Vec<(String, String)> {
text.lines().filter_map(|line| line.split_once('|').map(|(n, v)| (n.to_string(), v.to_string()))).collect()
}
/// `pacman -Q`: `name version` per line.
pub fn parse_pacman(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let mut f = line.split_whitespace();
Some((f.next()?.to_string(), f.next()?.to_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)> {
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()))
})
.collect()
}
/// 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())
}
/// The package names a Craft App may be installed under with `manager`. On Arch the AUR carries
/// each app three ways: built from source (`photocraft`), the release binary (`photocraft-bin`)
/// and the latest commit (`photocraft-git`).
pub fn candidate_names(app: &CraftApp, manager: InstallSource) -> Vec<String> {
match manager {
InstallSource::Flatpak => vec![app.bundle_id()],
InstallSource::Pacman => app.stems().flat_map(|s| [s.to_string(), format!("{s}-bin"), format!("{s}-git")]).collect(),
_ => app.stems().map(str::to_string).collect(),
}
}
/// The package of `app` among `installed`, preferring the manager listed first in `order`.
pub fn find<'a>(app: &CraftApp, installed: &'a [Package], order: &[InstallSource]) -> Option<&'a Package> {
order.iter().find_map(|manager| {
let names = candidate_names(app, *manager);
names.iter().find_map(|n| installed.iter().find(|p| p.manager == *manager && p.name == *n))
})
}
/// A package's version, read the package-manager way (`0.5.0-1` is 0.5.0).
pub fn version_of(p: &Package) -> Option<version::Version> {
version::parse_package(&p.version)
}
#[cfg(test)]
mod tests {
use super::*;
fn pkgs(manager: InstallSource, list: Vec<(String, String)>) -> Vec<Package> {
list.into_iter().map(|(name, version)| Package { manager, name, version }).collect()
}
#[test]
fn reads_each_package_managers_listing() {
assert_eq!(parse_dpkg("photocraft|installed|0.5.0-1\nwordcraft|config-files|0.3.0-1\n"), [("photocraft".into(), "0.5.0-1".into())]);
assert_eq!(parse_rpm("gridcraft|0.3.0\n"), [("gridcraft".into(), "0.3.0".into())]);
assert_eq!(parse_pacman("photocraft-bin 0.5.0-1\nlinux 6.11.1.arch1-1\n").len(), 2);
assert_eq!(
parse_flatpak_list("ai.storyteller.wordcraft\t\nai.storyteller.cadcraft\t0.3.0\n"),
[("ai.storyteller.wordcraft".into(), String::new()), ("ai.storyteller.cadcraft".into(), "0.3.0".into())]
);
let info = "\nWordCraft - Word processor\n\n ID: ai.storyteller.wordcraft\n Version: 0.3.0\n Branch: master\n";
assert_eq!(parse_flatpak_info(info).as_deref(), Some("0.3.0"));
assert_eq!(parse_flatpak_info("Version: \n"), None);
}
#[test]
fn finds_aur_packages_in_every_flavour() {
let photo = CraftApp::by_id("photocraft").unwrap();
let pdf = CraftApp::by_id("pdfcraft").unwrap();
let installed =
pkgs(InstallSource::Pacman, parse_pacman("linux 6.11.1.arch1-1\nphotocraft-git 0.3.0.r47.g47f9306-1\nprintcraft-bin 0.2.1-1\n"));
let hit = find(photo, &installed, &[InstallSource::Pacman]).unwrap();
assert_eq!(hit.name, "photocraft-git");
assert_eq!(version_of(hit).unwrap().to_string(), "0.3.0");
// PdfCraft under its old name.
assert_eq!(find(pdf, &installed, &[InstallSource::Pacman]).unwrap().name, "printcraft-bin");
assert!(find(CraftApp::by_id("wordcraft").unwrap(), &installed, &[InstallSource::Pacman]).is_none());
}
#[test]
fn prefers_managers_in_order_and_reads_revisions() {
let photo = CraftApp::by_id("photocraft").unwrap();
let mut installed = pkgs(InstallSource::Deb, parse_dpkg("photocraft|installed|0.5.0-1\n"));
installed.extend(pkgs(InstallSource::Flatpak, parse_flatpak_list("ai.storyteller.photocraft\t0.4.0\n")));
let hit = find(photo, &installed, &[InstallSource::Deb, InstallSource::Flatpak]).unwrap();
assert_eq!(hit.manager, InstallSource::Deb);
// 0.5.0-1 is the 0.5.0 release, not a pre-release of it.
assert_eq!(version_of(hit).unwrap().to_string(), "0.5.0");
assert_eq!(find(photo, &installed, &[InstallSource::Flatpak]).unwrap().version, "0.4.0");
}
}
+24
View File
@@ -62,8 +62,18 @@ pub struct LauncherUi {
/// weights before then would abort.
fonts_ready: bool,
sent_options: Option<BackendOptions>,
/// Unix seconds of the last look at what's installed (startup counts).
last_detect: i64,
/// Whether the window had focus last frame, to re-check when the user comes back to it.
was_focused: bool,
}
/// How often to look again at what's installed, to notice apps installed or removed outside
/// the launcher. A detection pass lists packages and reads a few files: cheap, but not free.
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;
impl LauncherUi {
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
theme::install_fonts(ctx);
@@ -87,6 +97,8 @@ impl LauncherUi {
applied_theme: None,
fonts_ready: false,
sent_options: None,
last_detect: 0,
was_focused: true,
}
}
@@ -99,6 +111,7 @@ impl LauncherUi {
pub fn check_now(&mut self) {
self.backend.send(Command::CheckForUpdates { force: true });
self.backend.send(Command::Detect);
self.last_detect = self.backend.now();
}
pub(crate) fn send(&mut self, command: Command) {
@@ -133,6 +146,17 @@ impl LauncherUi {
self.backend.send(Command::CheckForUpdates { force: false });
}
let now = self.backend.now();
if self.last_detect == 0 {
// The backend looks once at startup by itself.
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;
self.was_focused = focused;
if came_back || now - 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)
+3
View File
@@ -83,6 +83,9 @@ impl LauncherUi {
Some(artcraft_launcher_core::InstallSource::Deb | artcraft_launcher_core::InstallSource::Rpm) => {
"Your system's package manager will remove it, and may ask for your password. Your documents and settings stay."
}
Some(artcraft_launcher_core::InstallSource::Pacman) => {
"It was installed from the AUR, so the launcher will show you the pacman command to remove it."
}
_ => "The app will be removed. Your documents and settings stay.",
};
ui.label(RichText::new(how).color(t.text_muted));
+35 -3
View File
@@ -1,7 +1,7 @@
//! Drives the launcher UI against a fake backend: what's shown for each status, and which
//! commands the buttons send. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots.
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, PausedDownload, Source};
@@ -19,6 +19,7 @@ struct Fake {
events: Vec<Event>,
sent: Rc<RefCell<Vec<Command>>>,
manage: bool,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
@@ -35,7 +36,7 @@ impl Backend for Fake {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
NOW
self.clock.get()
}
}
@@ -87,6 +88,7 @@ fn events() -> Vec<Event> {
version: version::parse(v),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed: inst });
}
@@ -100,7 +102,7 @@ fn events() -> Vec<Event> {
fn harness(view: ViewMode, theme: egui::Theme, manage: bool) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events: events(), sent: sent2, manage });
let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
let settings = Settings { view, notify: false, ..Settings::default() };
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).wgpu().build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
@@ -343,6 +345,36 @@ fn links_open_the_right_pages() {
assert_eq!(click_opens(&mut h, "All releases").as_deref(), Some("https://github.com/storytold/photocraft/releases"));
}
#[test]
fn looks_again_at_installed_apps_every_few_minutes_not_every_frame() {
let sent = Rc::new(RefCell::new(Vec::new()));
let clock = Rc::new(Cell::new(NOW));
let mut backend = Some(Fake { events: events(), sent: sent.clone(), manage: true, clock: clock.clone() });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.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 NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
);
let detects = |sent: &Rc<RefCell<Vec<Command>>>| sent.borrow().iter().filter(|c| **c == Command::Detect).count();
h.run_steps(10);
assert_eq!(detects(&sent), 0, "the backend's own startup detection is enough");
clock.set(NOW + 4 * 60);
h.run_steps(10);
assert_eq!(detects(&sent), 0, "not before five minutes");
clock.set(NOW + 5 * 60);
h.run_steps(10);
assert_eq!(detects(&sent), 1, "once at five minutes, not once per frame");
}
#[test]
fn web_build_offers_downloads() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);