diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 69566c9..b4b0007 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -1,7 +1,8 @@ # Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 -# runner, unsigned here), then install that per-user MSI on a Windows 11 ARM64 runner, check the -# installed binary is ARM64 and the Start Menu shortcut exists, and uninstall. The launcher is a -# GUI app, so it isn't run here. +# runner, unsigned here), then install that per-user MSI on a Windows 11 ARM64 runner: once with +# the defaults (default folder, Start Menu and desktop shortcuts) and once into a chosen folder +# without the desktop shortcut, checking the binary is ARM64 and that each uninstall removes +# everything. The launcher is a GUI app, so it isn't run here. name: Windows ARM64 # Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that @@ -71,21 +72,43 @@ jobs: run: | $ErrorActionPreference = 'Stop' $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - # Per-user install: %LOCALAPPDATA%\Programs\ArtCraft Launcher. - $dir = Join-Path $env:LOCALAPPDATA 'Programs\ArtCraft Launcher' - $path = Join-Path $dir 'artcraft-launcher.exe' - if (-not (Test-Path $path)) { Get-Content install.log -Tail 60; throw "artcraft-launcher.exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) + $startLnk = Join-Path ([Environment]::GetFolderPath('Programs')) 'ArtCraft Launcher.lnk' + $deskLnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'ArtCraft Launcher.lnk' + $key = 'HKCU:\Software\Learning Machines LLC\ArtCraft Launcher' + function Install([string[]] $props) { + $p = Start-Process msiexec.exe -ArgumentList (@('/i', "`"$msi`"", '/qn', '/l*v', 'install.log') + $props) -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } + } + function Uninstall([string] $exe) { + $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } + foreach ($left in $exe, $startLnk, $deskLnk) { if (Test-Path $left) { throw "uninstall left $left behind" } } + if ((Get-ItemProperty $key -ErrorAction SilentlyContinue).InstallDir) { throw "uninstall left InstallDir in $key" } + Write-Output 'ok uninstalled' + } + function Expect([bool] $ok, [string] $what) { if (-not $ok) { Get-Content install.log -Tail 60; throw $what }; Write-Output "ok $what" } + + # 1. A first install with the defaults, as the wizard's Next, Next, Install does: + # %LOCALAPPDATA%\Programs\ArtCraft Launcher, with the Start Menu and desktop shortcuts. + Install @() + $exe = Join-Path $env:LOCALAPPDATA 'Programs\ArtCraft Launcher\artcraft-launcher.exe' + Expect (Test-Path $exe) "installed in the default folder: $exe" + $bytes = [System.IO.File]::ReadAllBytes($exe) $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "artcraft-launcher.exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - $lnk = Join-Path ([Environment]::GetFolderPath('Programs')) 'ArtCraft Launcher.lnk' - if (-not (Test-Path $lnk)) { throw "Start Menu shortcut missing: $lnk" } - Write-Output "ok $lnk" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path $path) { throw 'uninstall left artcraft-launcher.exe behind' } - if (Test-Path $lnk) { throw 'uninstall left the Start Menu shortcut behind' } - Write-Output 'ok uninstalled' + Expect ($machine -eq 0xAA64) "the exe is ARM64 (machine 0x$('{0:X}' -f $machine))" + Expect (Test-Path $startLnk) "Start Menu shortcut: $startLnk" + Expect (Test-Path $deskLnk) "desktop shortcut (ticked by default): $deskLnk" + Expect ((Get-ItemProperty $key).DesktopShortcutChoice -eq '1') 'the desktop-shortcut choice is recorded as 1' + Uninstall $exe + + # 2. A folder of the user's choosing, without the desktop shortcut. + $dir = Join-Path $env:LOCALAPPDATA 'ArtCraft Launcher Test Folder' + Install @("INSTALLFOLDER=`"$dir\`"", 'INSTALLDESKTOPSHORTCUT=0') + $exe = Join-Path $dir 'artcraft-launcher.exe' + Expect (Test-Path $exe) "installed in the chosen folder: $exe" + Expect ((Get-ItemProperty $key).InstallDir.TrimEnd('\') -eq $dir) 'the chosen folder is recorded for updates and self-update' + Expect (Test-Path $startLnk) "Start Menu shortcut: $startLnk" + Expect (-not (Test-Path $deskLnk)) 'no desktop shortcut when it is not wanted' + Expect ((Get-ItemProperty $key).DesktopShortcutChoice -eq '0') 'the desktop-shortcut choice is recorded as 0' + Uninstall $exe + Expect (-not (Test-Path $dir)) 'uninstall removed the chosen folder' diff --git a/AGENTS.md b/AGENTS.md index 494e961..bbe7331 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,11 +116,15 @@ Release assets (keep these names exact; the launcher's own self-update and the R Packaging facts the code relies on: -- The Windows MSI is **per-user** (`%LOCALAPPDATA%\Programs\ArtCraft Launcher`, no UAC prompt). - Double-clicked it opens a WixUI setup wizard (Welcome, Ready, progress, Finish with "Launch"), - whose art `package.ps1` draws at build time; self-update runs it with `/passive`, no wizard. - Its Start Menu shortcut sets AppUserModelID `ai.storyteller.artcraft-launcher`, which toast - notifications need. `portable.txt` beside the exe switches on portable mode +- The Windows MSI is **per-user** (no UAC prompt), by default into + `%LOCALAPPDATA%\Programs\ArtCraft Launcher`. Double-clicked it opens a WixUI setup wizard + (Welcome; a folder page with a "Create a desktop shortcut" box; Ready; progress; Finish with + "Launch"), whose art `package.ps1` draws at build time; self-update runs it with `/passive`, no + wizard. The folder and the shortcut choice are recorded under + `HKCU\Software\Learning Machines LLC\ArtCraft Launcher` (`InstallDir`, + `DesktopShortcutChoice`) and reused by the next version, and `self_update.rs` reads `InstallDir` + to know the running copy came from the MSI. Its shortcuts set AppUserModelID + `ai.storyteller.artcraft-launcher`, which toast notifications need. `portable.txt` beside the exe switches on portable mode (`ArtCraftLauncherData\` next to the exe). - The macOS bundle is `ArtCraft Launcher.app` (executable `Contents/MacOS/ArtCraft Launcher`), a normal Dock app that can also sit in the menu bar: `LSUIElement` must never be set. diff --git a/Cargo.lock b/Cargo.lock index 15008a0..8261c3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,7 +276,7 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "artcraft-launcher" -version = "0.1.1" +version = "0.1.2" dependencies = [ "artcraft-launcher-core", "artcraft-launcher-native", @@ -296,7 +296,7 @@ dependencies = [ [[package]] name = "artcraft-launcher-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "semver", "serde", @@ -305,7 +305,7 @@ dependencies = [ [[package]] name = "artcraft-launcher-native" -version = "0.1.1" +version = "0.1.2" dependencies = [ "artcraft-launcher-core", "dirs 6.0.0", @@ -324,7 +324,7 @@ dependencies = [ [[package]] name = "artcraft-launcher-ui-egui" -version = "0.1.1" +version = "0.1.2" dependencies = [ "artcraft-launcher-core", "egui", @@ -337,7 +337,7 @@ dependencies = [ [[package]] name = "artcraft-launcher-web" -version = "0.1.1" +version = "0.1.2" dependencies = [ "artcraft-launcher-core", "artcraft-launcher-ui-egui", @@ -5749,7 +5749,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.1.1" +version = "0.1.2" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 41bf860..80df8ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.1.1" +version = "0.1.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/crates/native/src/self_update.rs b/crates/native/src/self_update.rs index 10f16f0..89baec4 100644 --- a/crates/native/src/self_update.rs +++ b/crates/native/src/self_update.rs @@ -9,7 +9,7 @@ //! | Installed as | Swap | //! |---|---| //! | macOS `.app` | copy the new bundle from the DMG over this one (fine while running), reopen it | -//! | Windows MSI (per-user) | after exit, run the new MSI, then start the exe | +//! | Windows MSI (per-user, any folder) | after exit, run the new MSI (it reinstalls into the same folder), then start the exe | //! | Windows portable zip | after exit, unzip over this folder (settings in `ArtCraftLauncherData` stay) | //! | Linux AppImage | rename the new AppImage over this one (fine while running), start it | //! | Linux `.deb` / `.rpm` | the system package manager, with the password prompt; then restart | @@ -106,10 +106,23 @@ fn detect(exe: &Path) -> Option { if dir.join("portable.txt").is_file() { return Some(Install::Portable(exe.to_path_buf())); } - // The MSI installs per user into %LOCALAPPDATA%\Programs\ArtCraft Launcher. - let programs = dirs::data_local_dir()?.join("Programs").join(LAUNCHER.name); - let programs = programs.canonicalize().unwrap_or(programs); - (dir == programs).then(|| Install::Msi(exe.to_path_buf())) + // The MSI installs per user, into the folder chosen in its wizard (which it records in the + // registry) or by default %LOCALAPPDATA%\Programs\ArtCraft Launcher. + let canonical = |p: PathBuf| p.canonicalize().unwrap_or(p); + let recorded = msi_install_dir().map(canonical); + let default = dirs::data_local_dir().map(|d| canonical(d.join("Programs").join(LAUNCHER.name))); + [recorded, default].into_iter().flatten().any(|d| d == dir).then(|| Install::Msi(exe.to_path_buf())) +} + +/// The folder the MSI installed into, as it records it (`packaging/windows/artcraft-launcher.wxs`). +#[cfg(windows)] +fn msi_install_dir() -> Option { + use winreg::RegKey; + use winreg::enums::HKEY_CURRENT_USER; + let key = RegKey::predef(HKEY_CURRENT_USER).open_subkey(r"Software\Learning Machines LLC\ArtCraft Launcher").ok()?; + let dir: String = key.get_value("InstallDir").ok()?; + let dir = dir.trim(); + (!dir.is_empty()).then(|| PathBuf::from(dir)) } #[cfg(target_os = "linux")] diff --git a/packaging/windows/artcraft-launcher.en-us.wxl b/packaging/windows/artcraft-launcher.en-us.wxl index 45c72c3..23e2866 100644 --- a/packaging/windows/artcraft-launcher.en-us.wxl +++ b/packaging/windows/artcraft-launcher.en-us.wxl @@ -7,6 +7,11 @@ + + + + + diff --git a/packaging/windows/artcraft-launcher.wxs b/packaging/windows/artcraft-launcher.wxs index 66354a2..fecba72 100644 --- a/packaging/windows/artcraft-launcher.wxs +++ b/packaging/windows/artcraft-launcher.wxs @@ -8,15 +8,18 @@ -culture en-US -loc artcraft-launcher.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1 -d BinDir=... -d IconPath=... -d BannerBmp=... -d DialogBmp=... -o out.msi - Per-user install (no UAC prompt) into %LOCALAPPDATA%\Programs\ArtCraft Launcher, so the - launcher can update itself without elevation, plus a Start Menu shortcut. The shortcut carries - the AppUserModelID ai.storyteller.artcraft-launcher, which Windows needs to show toast + Per-user install (no UAC prompt), by default into %LOCALAPPDATA%\Programs\ArtCraft Launcher, so + the launcher can update itself without elevation, plus a Start Menu shortcut. The shortcut + carries the AppUserModelID ai.storyteller.artcraft-launcher, which Windows needs to show toast notifications from an unpackaged app and to group the taskbar button. No file associations. - Double-clicked, it opens a setup wizard that names the version: Welcome, Ready, a progress page - and a Finish page with a ticked "Launch ArtCraft Launcher" box. There is no licence page (the - code is MIT OR Apache-2.0; nothing to accept) and no folder page (self-update needs the per-user - folder). The launcher's own self-update runs it with /passive, which skips the wizard. + Double-clicked, it opens a setup wizard that names the version: Welcome; a folder page to pick + where it goes, with a "Create a desktop shortcut" box; Ready; a progress page; and a Finish page + with a ticked "Launch ArtCraft Launcher" box. There is no licence page (the code is MIT OR + Apache-2.0; nothing to accept). The install folder and the desktop-shortcut choice are recorded + under HKCU and reused by later versions, so an update (the launcher's own self-update runs this + with /passive, which skips the wizard) lands in the same folder and keeps the user's choice; the + launcher reads the recorded folder to know it was installed by this MSI. Per-user packages can't use a file as a component's KeyPath (ICE38), so each component is keyed on an HKCU value, and the folders we create are removed on uninstall (ICE64). @@ -51,19 +54,74 @@ + + + + + + + + + + + + + + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -86,7 +144,9 @@ + + @@ -102,6 +162,18 @@ + + + + + + + +