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 <[email protected]>
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, Deb,
/// An RPM package. /// An RPM package.
Rpm, Rpm,
/// An Arch Linux package (from the AUR: `<app>`, `<app>-bin` or `<app>-git`).
Pacman,
/// A Flatpak. /// A Flatpak.
Flatpak, Flatpak,
/// An AppImage file found on disk. /// An AppImage file found on disk.
@@ -33,6 +35,7 @@ impl InstallSource {
Self::Msi => "Windows Installer", Self::Msi => "Windows Installer",
Self::Deb => "Debian package", Self::Deb => "Debian package",
Self::Rpm => "RPM package", Self::Rpm => "RPM package",
Self::Pacman => "Arch package (AUR)",
Self::Flatpak => "Flatpak", Self::Flatpak => "Flatpak",
Self::AppImage => "AppImage", Self::AppImage => "AppImage",
Self::Binary => "System binary", Self::Binary => "System binary",
@@ -52,8 +55,11 @@ pub struct Installed {
/// `None` when the copy is there but its version couldn't be read. /// `None` when the copy is there but its version couldn't be read.
pub version: Option<Version>, pub version: Option<Version>,
pub source: InstallSource, 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, 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. /// The status shown on an app's card or row.
@@ -117,7 +123,7 @@ mod tests {
use super::*; use super::*;
fn inst(v: &str) -> Installed { 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] #[test]
+48
View File
@@ -32,6 +32,35 @@ pub fn parse(text: &str) -> Option<Version> {
None 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. /// A version that's a plain `X.Y.Z` with no pre-release suffix.
pub fn is_stable(v: &Version) -> bool { pub fn is_stable(v: &Version) -> bool {
v.pre.is_empty() 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] #[test]
fn orders_versions_and_spots_updates() { fn orders_versions_and_spots_updates() {
assert!(is_newer(&v("0.10.0"), &v("0.9.9"))); assert!(is_newer(&v("0.10.0"), &v("0.9.9")));
+10
View File
@@ -35,6 +35,8 @@ struct Shared {
manifest: Mutex<Manifest>, manifest: Mutex<Manifest>,
options: Mutex<BackendOptions>, options: Mutex<BackendOptions>,
checking: AtomicBool, checking: AtomicBool,
/// A detection pass is running; another request waits for the next one.
detecting: AtomicBool,
last_check: Mutex<Option<i64>>, last_check: Mutex<Option<i64>>,
} }
@@ -79,6 +81,7 @@ impl NativeBackend {
manifest: Mutex::new(Manifest::load(&paths.manifest())), manifest: Mutex::new(Manifest::load(&paths.manifest())),
options: Mutex::new(BackendOptions::default()), options: Mutex::new(BackendOptions::default()),
checking: AtomicBool::new(false), checking: AtomicBool::new(false),
detecting: AtomicBool::new(false),
last_check: Mutex::new(None), last_check: Mutex::new(None),
}); });
let backend = Self { shared, paths, http, source, platform: native_platform() }; let backend = Self { shared, paths, http, source, platform: native_platform() };
@@ -106,6 +109,9 @@ impl NativeBackend {
} }
fn detect_all(&self) { fn detect_all(&self) {
if self.shared.detecting.swap(true, Ordering::SeqCst) {
return;
}
let shared = self.shared.clone(); let shared = self.shared.clone();
let paths = self.paths.clone(); let paths = self.paths.clone();
let platform = self.platform; let platform = self.platform;
@@ -130,6 +136,7 @@ impl NativeBackend {
for app in &SUITE { for app in &SUITE {
report_partial(app, &shared, &paths, platform, &options); 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>, record: Option<&crate::manifest::Record>,
stop: &Stop, stop: &Stop,
) -> Result<String, String> { ) -> 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 http = http.ok_or("secure connections aren't available on this system")?;
let platform = platform.ok_or("this system isn't supported")?; let platform = platform.ok_or("this system isn't supported")?;
let release = shared let release = shared
+50 -36
View File
@@ -14,7 +14,7 @@ use crate::manifest::Manifest;
#[derive(Default)] #[derive(Default)]
pub struct Snapshot { pub struct Snapshot {
#[allow(dead_code)] #[allow(dead_code)]
packages: Vec<(String, String, artcraft_launcher_core::InstallSource)>, packages: Vec<crate::packages::Package>,
} }
impl Snapshot { 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) { if let Some(found) = macos::read_bundle(app, &rec.path) {
return Some(found); 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")] #[cfg(target_os = "macos")]
@@ -93,7 +98,7 @@ pub mod macos {
let full_key = format!("{}Version", app.name); let full_key = format!("{}Version", app.name);
let text = let text =
[full_key.as_str(), "CFBundleShortVersionString", "CFBundleVersion"].iter().find_map(|k| dict.get(k).and_then(plist::Value::as_string)); [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, version: m.version,
source: InstallSource::Msi, source: InstallSource::Msi,
location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()), 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 artcraft_launcher_core::platform::parse_asset_name;
use super::*; use super::*;
use crate::packages::{self, Package};
use crate::process::capture; use crate::process::capture;
/// Installed Craft App packages: (name, version, source). Read once per detection pass. /// Installed Craft App packages from every package manager on this system (dpkg, rpm,
pub fn packages() -> Vec<(String, String, InstallSource)> { /// pacman, Flatpak). Read once per detection pass.
let wanted = |name: &str| artcraft_launcher_core::SUITE.iter().any(|a| a.id == name); pub fn packages() -> Vec<Package> {
let mut out = Vec::new(); 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 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) { if let Some(list) = capture("dpkg-query", &["-W", "-f", "${Package}|${db:Status-Status}|${Version}\n"], t) {
for line in list.lines() { add(InstallSource::Deb, packages::parse_dpkg(&list));
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));
}
}
} }
if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) { if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) {
for line in list.lines() { add(InstallSource::Rpm, packages::parse_rpm(&list));
if let Some((name, ver)) = line.split_once('|') }
&& wanted(name) if let Some(list) = capture("pacman", &["-Q"], t) {
{ add(InstallSource::Pacman, packages::parse_pacman(&list));
out.push((name.to_string(), ver.to_string(), InstallSource::Rpm));
}
}
} }
if let Some(list) = capture("flatpak", &["list", "--app", "--columns=application,version"], t) { if let Some(list) = capture("flatpak", &["list", "--app", "--columns=application,version"], t) {
for line in list.lines() { let mut flatpaks = packages::parse_flatpak_list(&list);
let mut f = line.split('\t'); // Bundles built from a release tarball often list no version: ask Flatpak directly.
if let (Some(id), Some(ver)) = (f.next(), f.next()) for (id, version) in flatpaks.iter_mut().filter(|(id, v)| v.is_empty() && id.starts_with("ai.storyteller.")) {
&& let Some(name) = id.strip_prefix("ai.storyteller.") if let Some(v) = capture("flatpak", &["info", id], t).as_deref().and_then(packages::parse_flatpak_info) {
&& wanted(name) *version = v;
{
out.push((name.to_string(), ver.to_string(), InstallSource::Flatpak));
} }
} }
add(InstallSource::Flatpak, flatpaks);
} }
out 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). /// `~/Applications`, where the launcher puts AppImages (the AppImageLauncher convention).
pub fn appimage_dir() -> Option<PathBuf> { pub fn appimage_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join("Applications")) dirs::home_dir().map(|h| h.join("Applications"))
@@ -251,13 +266,12 @@ pub mod linux {
pub fn detect(app: &CraftApp, snapshot: &Snapshot) -> Option<Installed> { pub fn detect(app: &CraftApp, snapshot: &Snapshot) -> Option<Installed> {
if let Some((path, version)) = appimages(app).into_iter().next() { 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 { let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];
version: version::parse(ver), let p = packages::find(app, &snapshot.packages, &order)?;
source: *source, let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
location: if *source == InstallSource::Flatpak { app.bundle_id() } else { format!("/usr/bin/{name}") }, 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() { if !bin.exists() {
return None; 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)`. /// `<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::Flatpak => Some(PackageKind::Flatpak),
InstallSource::AppImage => Some(PackageKind::AppImage), InstallSource::AppImage => Some(PackageKind::AppImage),
InstallSource::Binary => Some(PackageKind::TarGz), 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)) { if let Some(k) = kind.filter(|k| available.contains(k)) {
return vec![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)) 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. /// Removes an installed copy.
pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>) -> Result<(), String> { pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>) -> Result<(), String> {
let _ = record; let _ = record;
let path = PathBuf::from(&installed.location); let path = PathBuf::from(&installed.location);
if installed.source == InstallSource::Binary { if let Some(msg) = managed_elsewhere(app, installed, true) {
return Err(format!("{} was installed outside the launcher. Remove it the way it was installed.", app.name)); return Err(msg);
} }
if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher) if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher)
&& process::is_running(&path) && process::is_running(&path)
@@ -727,7 +751,7 @@ mod tests {
#[test] #[test]
fn updates_keep_the_installed_format() { 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(); let available = available_kinds();
if let Some(first) = available.first() { if let Some(first) = available.first() {
assert_eq!(choose_kinds(None, None, &[]).first(), Some(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 install;
pub mod login; pub mod login;
pub mod manifest; pub mod manifest;
pub mod packages;
pub mod paths; pub mod paths;
pub mod process; pub mod process;
pub mod single_instance; 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. /// weights before then would abort.
fonts_ready: bool, fonts_ready: bool,
sent_options: Option<BackendOptions>, 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 { impl LauncherUi {
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self { pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
theme::install_fonts(ctx); theme::install_fonts(ctx);
@@ -87,6 +97,8 @@ impl LauncherUi {
applied_theme: None, applied_theme: None,
fonts_ready: false, fonts_ready: false,
sent_options: None, sent_options: None,
last_detect: 0,
was_focused: true,
} }
} }
@@ -99,6 +111,7 @@ impl LauncherUi {
pub fn check_now(&mut self) { pub fn check_now(&mut self) {
self.backend.send(Command::CheckForUpdates { force: true }); self.backend.send(Command::CheckForUpdates { force: true });
self.backend.send(Command::Detect); self.backend.send(Command::Detect);
self.last_detect = self.backend.now();
} }
pub(crate) fn send(&mut self, command: Command) { pub(crate) fn send(&mut self, command: Command) {
@@ -133,6 +146,17 @@ impl LauncherUi {
self.backend.send(Command::CheckForUpdates { force: false }); self.backend.send(Command::CheckForUpdates { force: false });
} }
let now = self.backend.now(); 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() if let Some(every) = self.settings.check_every.seconds()
&& !self.checking && !self.checking
&& self.settings.last_check.is_none_or(|t| now - t >= every) && 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) => { 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." "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.", _ => "The app will be removed. Your documents and settings stay.",
}; };
ui.label(RichText::new(how).color(t.text_muted)); 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 //! 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. //! 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 std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, PausedDownload, Source}; use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, PausedDownload, Source};
@@ -19,6 +19,7 @@ struct Fake {
events: Vec<Event>, events: Vec<Event>,
sent: Rc<RefCell<Vec<Command>>>, sent: Rc<RefCell<Vec<Command>>>,
manage: bool, manage: bool,
clock: Rc<Cell<i64>>,
} }
impl Backend for Fake { impl Backend for Fake {
@@ -35,7 +36,7 @@ impl Backend for Fake {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 }) Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
} }
fn now(&self) -> i64 { fn now(&self) -> i64 {
NOW self.clock.get()
} }
} }
@@ -87,6 +88,7 @@ fn events() -> Vec<Event> {
version: version::parse(v), version: version::parse(v),
source: InstallSource::MacApp, source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name), location: format!("/Applications/{}.app", app.name),
package: None,
}); });
ev.push(Event::Detected { app: app.id.into(), installed: inst }); 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>>>) { 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 sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone(); 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 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( 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>| { 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")); 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] #[test]
fn web_build_offers_downloads() { fn web_build_offers_downloads() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false); let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, false);