mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-10-15 06:46:47 -04:00
Detect PdfCraft installs made by its PrintCraft-named MSI (#134)
* Match MSI entries and find app executables through pure functions Windows detection decided which uninstall entry belongs to an app, and which paths might hold its exe, inline in the registry-reading code under #[cfg(windows)]. Neither decision needs the registry or the disk, so move both into module-level functions, is_installer_entry and exe_candidates, that tests can call on every platform. The windows module keeps the registry reads and the exists() checks. No behaviour change: an entry still has to carry the app's display name and the Learning Machines LLC publisher, exactly, and the candidates are the same paths in the same order. The new tests pin that down for every app but PdfCraft, and for PdfCraft's own entry name. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Detect PdfCraft installs made by its PrintCraft-named MSI PdfCraft's Windows installer shipped as PrintCraft up to 0.2.1 (printcraft-0.2.1-windows-x64.msi). That MSI registers its uninstall entry with DisplayName "PrintCraft" and installs printcraft.exe into Program Files\PrintCraft, but Windows detection only accepted an entry named after the app's current name and looked only for pdfcraft.exe, in App Paths, the install folder and Program Files\PdfCraft. On a machine still on that install the launcher showed PdfCraft as not installed, with no version, and uninstall_msi found nothing to remove. The catalog already knows the old asset stem (former_ids); add former_names for the display names an app's installers registered, since the capitalisation can't be derived from the stem, and names() to go with stems(). Entry matching now accepts any of the app's names, still exactly and still only from Learning Machines LLC, and the exe is looked up under every stem in App Paths and under every name in Program Files, current first. Apps without former names see the same match and the same candidates as before. The impact is limited to early adopters: PdfCraft 0.4.0 and later register as PdfCraft, and their MSI keeps the same UpgradeCode, so installing from the launcher already upgraded a PrintCraft install in place. What this fixes is the wrong status and the missing Uninstall until then. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
2 files changed
+108
-15
No files matched your search
@@ -45,6 +45,8 @@ pub struct CraftApp {
|
||||
pub repo: &'static str,
|
||||
/// Older names its release assets may carry (PdfCraft shipped as PrintCraft until 0.2.1).
|
||||
pub former_ids: &'static [&'static str],
|
||||
/// Older display names its installers registered (PdfCraft's MSI said PrintCraft until 0.2.1).
|
||||
pub former_names: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl CraftApp {
|
||||
@@ -71,6 +73,11 @@ impl CraftApp {
|
||||
std::iter::once(self.id).chain(self.former_ids.iter().copied())
|
||||
}
|
||||
|
||||
/// Display names this app's installers may have registered, current first.
|
||||
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
std::iter::once(self.name).chain(self.former_names.iter().copied())
|
||||
}
|
||||
|
||||
pub fn by_id(id: &str) -> Option<&'static CraftApp> {
|
||||
SUITE.iter().find(|a| a.id == id)
|
||||
}
|
||||
@@ -84,7 +91,7 @@ const fn app(
|
||||
[color, ink_light, ink_dark]: [[u8; 3]; 3],
|
||||
repo: &'static str,
|
||||
) -> CraftApp {
|
||||
CraftApp { id, name, tagline, group, color, ink_light, ink_dark, repo, former_ids: &[] }
|
||||
CraftApp { id, name, tagline, group, color, ink_light, ink_dark, repo, former_ids: &[], former_names: &[] }
|
||||
}
|
||||
|
||||
/// The Craft App Suite, in display order: the seven classic apps, then the five new ones.
|
||||
@@ -123,6 +130,7 @@ pub const SUITE: [CraftApp; 12] = [
|
||||
),
|
||||
CraftApp {
|
||||
former_ids: &["printcraft"],
|
||||
former_names: &["PrintCraft"],
|
||||
..app(
|
||||
"pdfcraft",
|
||||
"PdfCraft",
|
||||
@@ -248,6 +256,7 @@ mod tests {
|
||||
fn pdfcraft_also_answers_to_printcraft() {
|
||||
let pdf = CraftApp::by_id("pdfcraft").unwrap();
|
||||
assert_eq!(pdf.stems().collect::<Vec<_>>(), ["pdfcraft", "printcraft"]);
|
||||
assert_eq!(pdf.names().collect::<Vec<_>>(), ["PdfCraft", "PrintCraft"]);
|
||||
assert_eq!(pdf.website(), "https://getartcraft.com/apps/pdfcraft");
|
||||
assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), "https://getartcraft.com/apps/wordcraft");
|
||||
}
|
||||
|
||||
+98
-14
@@ -8,6 +8,8 @@
|
||||
//! installs.
|
||||
//! - FreeBSD: tarball installs and `/usr/local/bin/<app>`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use artcraft_launcher_core::{CraftApp, InstallSource, Installed, version};
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
@@ -86,6 +88,27 @@ pub(crate) fn appimage_version(name: &str, stems: &[&str]) -> Option<Option<Stri
|
||||
(parsed.kind == artcraft_launcher_core::PackageKind::AppImage && parsed.token.starts_with("linux-")).then_some(Some(parsed.version))
|
||||
}
|
||||
|
||||
/// Who our Windows Installer packages name as their publisher.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
const PUBLISHER: &str = "Learning Machines LLC";
|
||||
|
||||
/// Whether an uninstall entry's `DisplayName` and `Publisher` are `app`'s, under any name its
|
||||
/// installers have registered.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn is_installer_entry(app: &CraftApp, display_name: &str, publisher: &str) -> bool {
|
||||
publisher == PUBLISHER && app.names().any(|n| n == display_name)
|
||||
}
|
||||
|
||||
/// Where a Windows install of `app` may keep its exe, in the order to try: the entry's
|
||||
/// `InstallLocation`, then `<App>` under each Program Files root for each name the app has had,
|
||||
/// and in each folder `<stem>.exe` for each stem, current first. Doesn't touch the disk.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn exe_candidates(app: &CraftApp, location: Option<&Path>, program_files: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let in_program_files = program_files.iter().flat_map(|root| app.names().map(move |n| root.join(n)));
|
||||
let dirs = location.map(Path::to_path_buf).into_iter().chain(in_program_files);
|
||||
dirs.flat_map(|d| app.stems().map(move |s| d.join(format!("{s}.exe")))).collect()
|
||||
}
|
||||
|
||||
/// Version from `<app> --version` output. Binaries print the machine id (`photocraft 0.7.0`),
|
||||
/// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft).
|
||||
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
|
||||
@@ -160,7 +183,6 @@ pub mod windows {
|
||||
use super::*;
|
||||
|
||||
const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
const PUBLISHER: &str = "Learning Machines LLC";
|
||||
|
||||
/// An MSI install found in the registry.
|
||||
pub struct MsiInstall {
|
||||
@@ -178,7 +200,7 @@ pub mod windows {
|
||||
let Ok(key) = root.open_subkey_with_flags(&code, KEY_READ | flags) else { continue };
|
||||
let name: String = key.get_value("DisplayName").unwrap_or_default();
|
||||
let publisher: String = key.get_value("Publisher").unwrap_or_default();
|
||||
if name != app.name || publisher != PUBLISHER || out.iter().any(|m: &MsiInstall| m.product_code == code) {
|
||||
if !is_installer_entry(app, &name, &publisher) || out.iter().any(|m: &MsiInstall| m.product_code == code) {
|
||||
continue;
|
||||
}
|
||||
let version = key.get_value::<String, _>("DisplayVersion").ok().and_then(|v| version::parse(&v));
|
||||
@@ -198,25 +220,26 @@ pub mod windows {
|
||||
|
||||
/// The app's exe: from `App Paths`, the install folder, or Program Files.
|
||||
fn exe_path(app: &CraftApp, location: Option<PathBuf>) -> Option<PathBuf> {
|
||||
let exe = format!("{}.exe", app.id);
|
||||
let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}");
|
||||
for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] {
|
||||
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags)
|
||||
&& let Ok(path) = key.get_value::<String, _>("")
|
||||
{
|
||||
let p = PathBuf::from(path.trim_matches('"'));
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
for stem in app.stems() {
|
||||
let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{stem}.exe");
|
||||
for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] {
|
||||
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags)
|
||||
&& let Ok(path) = key.get_value::<String, _>("")
|
||||
{
|
||||
let p = PathBuf::from(path.trim_matches('"'));
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = location.into_iter().collect();
|
||||
let mut roots = Vec::new();
|
||||
for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(pf) = std::env::var_os(var) {
|
||||
dirs.push(PathBuf::from(pf).join(app.name));
|
||||
roots.push(PathBuf::from(pf));
|
||||
}
|
||||
}
|
||||
dirs.into_iter().map(|d| d.join(&exe)).find(|p| p.exists())
|
||||
exe_candidates(app, location.as_deref(), &roots).into_iter().find(|p| p.exists())
|
||||
}
|
||||
|
||||
pub fn detect(app: &CraftApp) -> Option<Installed> {
|
||||
@@ -461,6 +484,8 @@ mod linux_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use artcraft_launcher_core::SUITE;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -525,4 +550,63 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
const LEARNING_MACHINES: &str = "Learning Machines LLC";
|
||||
|
||||
fn roots() -> Vec<PathBuf> {
|
||||
vec![PathBuf::from(r"C:\Program Files"), PathBuf::from(r"C:\Program Files (x86)")]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfcraft_matches_its_former_printcraft_installer_name() {
|
||||
let pdf = CraftApp::by_id("pdfcraft").unwrap();
|
||||
assert!(is_installer_entry(pdf, "PrintCraft", LEARNING_MACHINES), "a PrintCraft entry from {LEARNING_MACHINES} should be PdfCraft's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfcraft_still_matches_its_own_installer_name() {
|
||||
let pdf = CraftApp::by_id("pdfcraft").unwrap();
|
||||
assert!(is_installer_entry(pdf, "PdfCraft", LEARNING_MACHINES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printcraft_entries_match_nothing_else() {
|
||||
let pdf = CraftApp::by_id("pdfcraft").unwrap();
|
||||
assert!(!is_installer_entry(pdf, "PrintCraft", "Some Other Publisher"));
|
||||
assert!(!is_installer_entry(pdf, "PhotoCraft", LEARNING_MACHINES));
|
||||
for a in SUITE.iter().filter(|a| a.id != "pdfcraft") {
|
||||
assert!(!is_installer_entry(a, "PrintCraft", LEARNING_MACHINES), "PrintCraft matched {}", a.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfcraft_exe_is_sought_under_every_name_it_has_shipped_as() {
|
||||
let pdf = CraftApp::by_id("pdfcraft").unwrap();
|
||||
let location = PathBuf::from(r"D:\Apps\PrintCraft");
|
||||
let roots = roots();
|
||||
let mut want = vec![location.join("pdfcraft.exe"), location.join("printcraft.exe")];
|
||||
for root in &roots {
|
||||
for dir in ["PdfCraft", "PrintCraft"] {
|
||||
want.push(root.join(dir).join("pdfcraft.exe"));
|
||||
want.push(root.join(dir).join("printcraft.exe"));
|
||||
}
|
||||
}
|
||||
assert_eq!(exe_candidates(pdf, Some(&location), &roots), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apps_without_former_names_match_and_find_their_exe_as_before() {
|
||||
let roots = roots();
|
||||
let location = PathBuf::from(r"D:\Apps\Somewhere");
|
||||
for a in SUITE.iter().filter(|a| a.former_ids.is_empty() && a.former_names.is_empty()) {
|
||||
assert!(is_installer_entry(a, a.name, LEARNING_MACHINES), "{}", a.name);
|
||||
assert!(!is_installer_entry(a, a.name, "Some Other Publisher"), "{}", a.name);
|
||||
assert!(!is_installer_entry(a, &a.name.to_lowercase(), LEARNING_MACHINES), "{}", a.name);
|
||||
let exe = format!("{}.exe", a.id);
|
||||
let in_program_files: Vec<PathBuf> = roots.iter().map(|r| r.join(a.name).join(&exe)).collect();
|
||||
assert_eq!(exe_candidates(a, None, &roots), in_program_files);
|
||||
let with_location: Vec<PathBuf> = std::iter::once(location.join(&exe)).chain(in_program_files).collect();
|
||||
assert_eq!(exe_candidates(a, Some(&location), &roots), with_location);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user