From 316204c46a9454497e6d99a1082c1556f6f6e553 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Wed, 21 Oct 2026 08:10:30 +0000 Subject: [PATCH 1/3] Make the title-bar Update button narrower and thinner --- Engine/crates/engine-desktop/src/lib.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Engine/crates/engine-desktop/src/lib.rs b/Engine/crates/engine-desktop/src/lib.rs index 963a1b37..dc7f3fc9 100644 --- a/Engine/crates/engine-desktop/src/lib.rs +++ b/Engine/crates/engine-desktop/src/lib.rs @@ -179,7 +179,7 @@ fn draw_title_bar( .show(ui, |ui| { let rect = ui.max_rect(); let controls_width = if custom { 159.0 } else { 0.0 }; - let update_width = if update_available { 90.0 } else { 0.0 }; + let update_width = if update_available { 56.0 } else { 0.0 }; let drag_rect = egui::Rect::from_min_max( rect.min, egui::pos2( @@ -208,6 +208,7 @@ fn draw_title_bar( } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 0.0; + ui.spacing_mut().button_padding = egui::vec2(5.0, 0.0); if custom { if caption_button(ui, icons::Icon::X, "Close").clicked() { context.send_viewport_cmd(ViewportCommand::Close); @@ -221,7 +222,10 @@ fn draw_title_bar( } if update_available { let response = ui - .add_sized([90.0, 24.0], egui::Button::new("Update")) + .add_sized( + [56.0, 16.0], + egui::Button::new(egui::RichText::new("Update").size(11.0)).small(), + ) .on_hover_text("Close Rustic and install the latest update"); update_rect = Some(response.rect); update = response.clicked(); @@ -238,6 +242,7 @@ fn caption_button(ui: &mut egui::Ui, icon: icons::Icon, tooltip: &str) -> egui:: let response = ui.add_sized( [53.0, 24.0], egui::Button::image(icon.image(egui::Color32::WHITE, 14.0)) + .small() .image_tint_follows_text_color(true) .frame(false), ); @@ -296,6 +301,11 @@ mod tests { #[test] fn update_button_stays_immediately_left_of_caption_controls_when_resized() { let context = egui::Context::default(); + context.set_theme(egui::Theme::Dark); + context.style_mut_of(egui::Theme::Dark, |style| { + style.spacing.interact_size.y = 30.0; + style.spacing.button_padding = egui::vec2(12.0, 6.0); + }); for width in [1280.0, 1080.0, 760.0] { let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( @@ -310,8 +320,9 @@ mod tests { }); let button = button.expect("ready updates must have a title-bar action"); assert!((button.right() - (width - 159.0)).abs() < 1.0); - assert!((button.width() - 90.0).abs() < 1.0); - assert!(button.top() < 2.0 && button.bottom() <= 26.0); + assert!((button.width() - 56.0).abs() < 1.0); + assert!((button.height() - 16.0).abs() < 1.0); + assert!((button.center().y - 12.0).abs() < 1.0); } } From b605700eeb13eb017c4fd2bdddbfad624a04c9ed Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Wed, 21 Oct 2026 08:21:09 +0000 Subject: [PATCH 2/3] Keep reopened editors usable while updates wait and reuse verified downloads --- .../engine-platform/src/desktop_update.rs | 71 ++++++++++++++++--- Engine/docs/AUTOMATIC_UPDATES.md | 21 +++++- Engine/tools/test-desktop-updates.ps1 | 27 ++++++- Engine/tools/update-desktop.ps1 | 60 ++++++++++++++-- 4 files changed, 164 insertions(+), 15 deletions(-) diff --git a/Engine/crates/engine-platform/src/desktop_update.rs b/Engine/crates/engine-platform/src/desktop_update.rs index 657bb4fe..66b25493 100644 --- a/Engine/crates/engine-platform/src/desktop_update.rs +++ b/Engine/crates/engine-platform/src/desktop_update.rs @@ -15,6 +15,8 @@ pub struct DesktopUpdate { pub struct DesktopUpdateClient { directory: PathBuf, build_commit: String, + // A close request already present at launch must never close this new window. + request_at_launch: Option, } impl DesktopUpdateClient { @@ -45,11 +47,14 @@ impl DesktopUpdateClient { "{:X}", Sha256::digest(root.to_string_lossy().to_lowercase().as_bytes()) ); + let directory = PathBuf::from(local) + .join("Rustic Game Engine/updates") + .join(key); + let request_at_launch = read_install_request(&directory).map(|(_, id)| id); Ok(Some(Self { - directory: PathBuf::from(local) - .join("Rustic Game Engine/updates") - .join(key), + directory, build_commit, + request_at_launch, })) } #[cfg(not(windows))] @@ -97,7 +102,7 @@ impl DesktopUpdateClient { } } - /// Requests a graceful close in all desktop windows for this installation. + /// Requests a graceful close in windows already open for this installation. /// /// # Errors /// Returns filesystem errors publishing the installation request. @@ -105,7 +110,15 @@ impl DesktopUpdateClient { if !valid_tag(&update.tag) { return Err(io::Error::other("Invalid update tag")); } - fs::write(self.directory.join("install-request.txt"), &update.tag) + // Joining an existing request must preserve its identity. Otherwise a + // late participant could close a window the user has just reopened. + if self.install_requested(update) { + return Ok(()); + } + fs::write( + self.directory.join("install-request.txt"), + format!("{}\n{}\n", update.tag, uuid::Uuid::new_v4()), + ) } /// Records this window's project for restoration after a successful update. @@ -135,10 +148,10 @@ impl DesktopUpdateClient { ) } - /// Whether another desktop window requested this same update. + /// Whether another window requested this update after this window launched. pub fn install_requested(&self, update: &DesktopUpdate) -> bool { - fs::read_to_string(self.directory.join("install-request.txt")) - .is_ok_and(|tag| tag.trim() == update.tag) + read_install_request(&self.directory) + .is_some_and(|(tag, id)| tag == update.tag && Some(id) != self.request_at_launch) } /// Cancels the shared graceful-close request without removing the update. @@ -161,6 +174,21 @@ impl DesktopUpdateClient { } } +fn read_install_request(directory: &Path) -> Option<(String, uuid::Uuid)> { + let contents = fs::read_to_string(directory.join("install-request.txt")).ok()?; + let mut lines = contents.lines(); + let tag = lines.next()?; + if !valid_tag(tag) { + return None; + } + // Legacy tag-only requests caused repeated closes and are deliberately ignored. + let id = uuid::Uuid::parse_str(lines.next()?).ok()?; + if lines.next().is_some() { + return None; + } + Some((tag.to_owned(), id)) +} + fn valid_tag(tag: &str) -> bool { !tag.is_empty() && tag.len() <= 80 @@ -231,6 +259,7 @@ mod tests { let client = DesktopUpdateClient { directory: temp.path().to_owned(), build_commit: "a".repeat(40), + request_at_launch: None, }; let update = DesktopUpdate { tag: "v2.0-Pre-Alpha".into(), @@ -246,6 +275,7 @@ mod tests { let other_window = DesktopUpdateClient { directory: temp.path().to_owned(), build_commit: "a".repeat(40), + request_at_launch: None, }; assert!(!other_window.claim_prompt(&update).unwrap()); client.prepare_restart(&update, Some(temp.path())).unwrap(); @@ -257,6 +287,18 @@ mod tests { assert_eq!(contents.lines().nth(2), Some(temp.path().to_str().unwrap())); client.request_install(&update).unwrap(); assert!(other_window.install_requested(&update)); + let reopened = DesktopUpdateClient { + directory: temp.path().to_owned(), + build_commit: "a".repeat(40), + request_at_launch: read_install_request(temp.path()).map(|(_, id)| id), + }; + assert!(!reopened.install_requested(&update)); + other_window.request_install(&update).unwrap(); + assert!(!reopened.install_requested(&update)); + // An explicit Update click in the reopened window starts a fresh request. + reopened.request_install(&update).unwrap(); + assert!(reopened.install_requested(&update)); + assert!(other_window.install_requested(&update)); assert!(!other_window.install_requested(&DesktopUpdate { tag: "v2.1-Pre-Alpha".into() })); @@ -266,6 +308,7 @@ mod tests { let new_build = DesktopUpdateClient { directory: temp.path().to_owned(), build_commit: "b".repeat(40), + request_at_launch: None, }; assert_eq!(new_build.pending().unwrap(), None); } @@ -276,6 +319,7 @@ mod tests { let client = DesktopUpdateClient { directory: temp.path().to_owned(), build_commit: "a".repeat(40), + request_at_launch: None, }; fs::write( temp.path().join("pending-update.txt"), @@ -283,6 +327,17 @@ mod tests { ) .unwrap(); assert_eq!(client.pending().unwrap(), None); + let update = DesktopUpdate { + tag: "v2.0-Pre-Alpha".into(), + }; + fs::write(temp.path().join("install-request.txt"), &update.tag).unwrap(); + assert!(!client.install_requested(&update)); + fs::write( + temp.path().join("install-request.txt"), + format!("{}\ninvalid-id\n", update.tag), + ) + .unwrap(); + assert!(!client.install_requested(&update)); assert!( client .claim_prompt(&DesktopUpdate { diff --git a/Engine/docs/AUTOMATIC_UPDATES.md b/Engine/docs/AUTOMATIC_UPDATES.md index 7bbdbcfd..7813629d 100644 --- a/Engine/docs/AUTOMATIC_UPDATES.md +++ b/Engine/docs/AUTOMATIC_UPDATES.md @@ -24,6 +24,12 @@ Editors ask to save unsaved scenes first; save failures leave the editor open. Active Play sessions stop and their runtime changes can be reviewed before closing. Canceling leaves the update available. Standalone games/workers must be closed separately: Setup waits until all Rustic processes from that installation exit. +Windows opened after an update request stay open, so you can return to work while +installation is waiting. If a requested update is still blocked after two minutes, +the updater cancels that close request and restores the windows it already closed, +using the currently installed version and original projects. The **Update** button +remains available; Setup can still run after a later normal exit. Processes whose +installation path cannot be read also block Setup rather than risking in-use files. After successful installation, the updater reopens each window closed for the update, restoring every editor's original project, including paths with spaces or @@ -47,13 +53,26 @@ with a published release waits rather than replacing itself with an older build. Logs and temporary downloads are under `%LOCALAPPDATA%\Rustic Game Engine\updates\\`. `update.log` records download, verification and Setup failures; `update-setup.log` contains -Setup's own diagnostics. A file lock prevents duplicate workers from the same +Setup's own diagnostics. While waiting, `update.log` lists remaining Rustic process +names and IDs once a minute. Downloads disable PowerShell's progress rendering; +retries reuse an existing installer only after rechecking its size and SHA-256. +A file lock prevents duplicate workers from the same user and installation. Network/API rate-limit and Setup failures leave the current application usable and retry after an hour while it remains open or on a later launch. If download completed but Setup has not started, close every Rustic process. If a release is missing its installer/checksum, use its release page or wait for a complete release. Do not edit `build-commit.txt` to force an update. +Older updater builds can leave a close request that immediately closes newly +opened editors. Close Project Manager and clear only that request in PowerShell, +then reopen Rustic or install a corrected release manually: + +```powershell +Get-ChildItem "$env:LOCALAPPDATA\Rustic Game Engine\updates" -Filter install-request.txt -Recurse -File | Remove-Item +``` + +This does not delete projects, downloaded installers or update settings. + Run the network-free updater regression suite with Windows PowerShell 5.1: ```powershell diff --git a/Engine/tools/test-desktop-updates.ps1 b/Engine/tools/test-desktop-updates.ps1 index 461a1e96..7e5ce37a 100644 --- a/Engine/tools/test-desktop-updates.ps1 +++ b/Engine/tools/test-desktop-updates.ps1 @@ -68,10 +68,17 @@ try { function Invoke-WebRequest { param($Uri, $Headers, $OutFile, $TimeoutSec, [switch]$UseBasicParsing) + $script:Downloads++ [IO.File]::WriteAllText($OutFile, 'abc', [Text.Encoding]::ASCII) } + $script:Downloads = 0 $installer = Save-VerifiedInstaller $asset $temp Assert-Equal (Test-Path -LiteralPath $installer) $true 'Verified download becomes an installer' + Assert-Equal (Save-VerifiedInstaller $asset $temp) $installer 'Reuse a verified cached installer' + Assert-Equal $script:Downloads 1 'Retry must not download the installer again' + [IO.File]::WriteAllText($installer, 'bad', [Text.Encoding]::ASCII) + Save-VerifiedInstaller $asset $temp | Out-Null + Assert-Equal $script:Downloads 2 'Recheck the checksum and replace corrupt cached installers' Remove-Item -LiteralPath $installer $asset.digest = 'sha256:' + ('0' * 64) Assert-Throws { Save-VerifiedInstaller $asset $temp } 'Reject checksum mismatch' @@ -90,7 +97,7 @@ try { } Assert-Equal (Test-RusticRunning $temp) $true 'Wait for open editor' Assert-Equal (Test-RusticRunning (Join-Path $temp 'another-install')) $false 'Other installations do not block' - function Get-Process { param($Name, $ErrorAction); [pscustomobject]@{ Path = $null } } + function Get-Process { param($Name, $ErrorAction); [pscustomobject]@{ Path = $null; ProcessName = 'rustic-runtime'; Id = 123 } } Assert-Equal (Test-RusticRunning $temp) $true 'Inaccessible process must block installation' # Exercise the download -> wait -> install path without starting any executable. @@ -134,6 +141,24 @@ try { Assert-Throws { Invoke-DesktopUpdateCheck $temp $temp } 'Checksum failure propagates before installation' Assert-Equal $script:Launches 0 'Never execute corrupt downloads' + # A remaining game/worker cannot keep closed desktop windows unavailable. + $script:Waits = 0 + function Test-RusticRunning { param($Directory); return $script:Waits -lt 122 } + $script:DeferredRestores = 0 + function Start-Process { + param($FilePath, $ArgumentList, $WorkingDirectory) + Assert-Equal $FilePath (Join-Path $temp 'rustic-project-manager.exe') 'Deferral restores the installed desktop, never Setup' + Assert-Equal ($script:Waits -ge 119) $true 'Allow graceful closure before deferring' + $script:DeferredRestores++ + } + Set-Content -LiteralPath (Join-Path $temp 'install-request.txt') -Value "$($next.tag_name)`n$([guid]::NewGuid())" + Set-Content -LiteralPath (Join-Path $temp 'resume-deferred.txt') -Value "$($next.tag_name)`nproject-manager" + Wait-DesktopExit $temp $temp $next.tag_name + Assert-Equal $script:DeferredRestores 1 'A blocked update restores closed windows exactly once' + Assert-Equal (Test-Path -LiteralPath (Join-Path $temp 'install-request.txt')) $false 'Deferral cancels the shared close request' + Assert-Equal (Test-Path -LiteralPath (Join-Path $temp 'pending-update.txt')) $true 'Deferral keeps the Update button available' + Assert-Equal ((Get-Content -LiteralPath (Join-Path $temp 'update.log') -Raw) -match 'rustic-runtime \(PID 123\)') $true 'Log the process delaying installation' + # Only sessions closed for this successful update are restored. Preserve spaces/Unicode. $project = Join-Path $temp ('M' + [char]0x0101 + 'ori game project') New-Item -ItemType Directory -Path $project | Out-Null diff --git a/Engine/tools/update-desktop.ps1 b/Engine/tools/update-desktop.ps1 index 74c4786f..56b39015 100644 --- a/Engine/tools/update-desktop.ps1 +++ b/Engine/tools/update-desktop.ps1 @@ -6,6 +6,9 @@ param( ) $ErrorActionPreference = 'Stop' +# Windows PowerShell 5.1's per-chunk progress rendering makes downloads very slow, +# even though this worker has no visible console. +$ProgressPreference = 'SilentlyContinue' Set-StrictMode -Version Latest $script:Repository = 'Rustic-Game-Engine/engine' $script:Headers = @{ 'User-Agent' = 'Rustic-Desktop-Updater'; Accept = 'application/vnd.github+json' } @@ -60,11 +63,19 @@ function Select-DesktopUpdate($Releases, [string]$InstalledCommit) { function Save-VerifiedInstaller($Asset, [string]$Directory) { $partial = Join-Path $Directory ($Asset.name + '.partial') $installer = Join-Path $Directory $Asset.name + $expected = $Asset.digest.Substring(7) + if (Test-Path -LiteralPath $installer -PathType Leaf) { + if ((Get-Item -LiteralPath $installer).Length -eq $Asset.size -and + (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash -eq $expected) { + Write-UpdateLog "Reusing verified installer $($Asset.name)." + return $installer + } + Remove-Item -LiteralPath $installer -Force + } try { Invoke-WebRequest -UseBasicParsing -Uri $Asset.browser_download_url -Headers $script:Headers ` -OutFile $partial -TimeoutSec 600 | Out-Null if ((Get-Item -LiteralPath $partial).Length -ne $Asset.size) { throw 'Installer size does not match GitHub.' } - $expected = $Asset.digest.Substring(7) if ((Get-FileHash -LiteralPath $partial -Algorithm SHA256).Hash -ne $expected) { throw 'Installer SHA-256 does not match GitHub.' } @@ -87,6 +98,44 @@ function Test-RusticRunning([string]$Directory) { return $false } +function Wait-DesktopExit([string]$Directory, [string]$CacheDirectory, [string]$Tag) { + $requestFile = Join-Path $CacheDirectory 'install-request.txt' + $lastRequest = '' + $requestWait = 0 + $ticks = 0 + while (Test-RusticRunning $Directory) { + $request = if (Test-Path -LiteralPath $requestFile) { + [IO.File]::ReadAllText($requestFile) + } else { '' } + if ($request -cne $lastRequest) { $requestWait = 0; $lastRequest = $request } + if ($request) { + $requestWait++ + if ($requestWait -ge 120) { + # A runtime/worker, inaccessible process or newly opened editor may + # still be running. Give closed windows back instead of trapping + # the user outside the editor while waiting indefinitely. + Remove-Item -LiteralPath $requestFile -Force -ErrorAction SilentlyContinue + Write-UpdateLog 'Update deferred after waiting two minutes for Rustic processes. Restoring closed windows; the Update button remains available.' + Restart-DesktopSessions $Directory $CacheDirectory $Tag + $lastRequest = '' + $requestWait = 0 + } + } + if (($ticks % 60) -eq 0) { + $blockers = @(Get-Process -Name 'rustic-*' -ErrorAction SilentlyContinue | ForEach-Object { + try { + if (-not $_.Path -or (Split-Path -Parent $_.Path) -eq $Directory) { + "$($_.ProcessName) (PID $($_.Id))" + } + } catch { "$($_.ProcessName) (PID $($_.Id), path unavailable)" } + }) + Write-UpdateLog "Waiting for Rustic to close: $($blockers -join ', '). Close standalone games/workers separately." + } + Start-Sleep -Seconds 1 + $ticks++ + } +} + function Install-DesktopUpdate([string]$Installer, [string]$Directory, [string]$LogDirectory) { $scope = (Get-Content -LiteralPath (Join-Path $Directory 'install-scope.txt') -Raw).Trim() if ($scope -notin @('allusers', 'currentuser')) { throw 'Unknown installation scope.' } @@ -123,7 +172,7 @@ function Restart-DesktopSessions([string]$Directory, [string]$CacheDirectory, [s } else { throw 'Unknown desktop session type.' } # This worker retains the original user's context even when Setup elevated. Start-Process @start | Out-Null - Write-UpdateLog "Reopened $($lines[1]) after updating to $Tag." + Write-UpdateLog "Restored $($lines[1]) for update $Tag." } catch { Write-UpdateLog "Could not restore a desktop session: $($_.Exception.Message)" } finally { Remove-Item -LiteralPath $record.FullName -Force -ErrorAction SilentlyContinue } } @@ -134,8 +183,7 @@ function Invoke-DesktopUpdateCheck([string]$Directory, [string]$CacheDirectory) $commit = (Get-Content -LiteralPath $commitFile -Raw).Trim() $asset = Select-DesktopUpdate @(Get-PublishedReleases) $commit if (-not $asset) { return $null } - Write-UpdateLog "Downloading $($asset.name). Close Rustic to install the update." - Remove-Item -LiteralPath (Join-Path $CacheDirectory 'pending-update.txt') -Force -ErrorAction SilentlyContinue + Write-UpdateLog "Preparing $($asset.name)." $installer = Save-VerifiedInstaller $asset $CacheDirectory $tag = ($asset.browser_download_url -split '/')[-2] # Publish only after verification. ASCII fields also decode identically in Rust. @@ -143,9 +191,11 @@ function Invoke-DesktopUpdateCheck([string]$Directory, [string]$CacheDirectory) $temporary = $pending + '.tmp' [IO.File]::WriteAllText($temporary, "$commit`n$tag`n", [Text.Encoding]::ASCII) Move-Item -LiteralPath $temporary -Destination $pending -Force - while (Test-RusticRunning $Directory) { Start-Sleep -Seconds 5 } + Write-UpdateLog 'Installer verified and ready. Waiting for Rustic to close.' + Wait-DesktopExit $Directory $CacheDirectory $tag if (-not (Test-Path -LiteralPath $commitFile)) { return [pscustomobject]@{ Installed = $false; Tag = $tag } } if ((Get-Content -LiteralPath $commitFile -Raw).Trim() -ne $commit) { return [pscustomobject]@{ Installed = $false; Tag = $tag } } + Write-UpdateLog 'Starting Setup. See update-setup.log for installation progress.' try { Install-DesktopUpdate $installer $Directory $CacheDirectory } catch { Remove-Item -LiteralPath (Join-Path $CacheDirectory 'install-request.txt') -Force -ErrorAction SilentlyContinue From 6c40d793f134b0f452b6bf1dcade7e6d74d4762d Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Wed, 21 Oct 2026 08:22:16 +0000 Subject: [PATCH 3/3] Keep update readiness gated on installer verification --- Engine/tools/test-desktop-updates.ps1 | 2 ++ Engine/tools/update-desktop.ps1 | 1 + 2 files changed, 3 insertions(+) diff --git a/Engine/tools/test-desktop-updates.ps1 b/Engine/tools/test-desktop-updates.ps1 index 7e5ce37a..7042f811 100644 --- a/Engine/tools/test-desktop-updates.ps1 +++ b/Engine/tools/test-desktop-updates.ps1 @@ -140,6 +140,7 @@ try { $asset.digest = 'sha256:' + ('0' * 64) Assert-Throws { Invoke-DesktopUpdateCheck $temp $temp } 'Checksum failure propagates before installation' Assert-Equal $script:Launches 0 'Never execute corrupt downloads' + Assert-Equal (Test-Path -LiteralPath (Join-Path $temp 'pending-update.txt')) $false 'Only verified installers are advertised as ready' # A remaining game/worker cannot keep closed desktop windows unavailable. $script:Waits = 0 @@ -152,6 +153,7 @@ try { $script:DeferredRestores++ } Set-Content -LiteralPath (Join-Path $temp 'install-request.txt') -Value "$($next.tag_name)`n$([guid]::NewGuid())" + Set-Content -LiteralPath (Join-Path $temp 'pending-update.txt') -Value "$commit`n$($next.tag_name)" Set-Content -LiteralPath (Join-Path $temp 'resume-deferred.txt') -Value "$($next.tag_name)`nproject-manager" Wait-DesktopExit $temp $temp $next.tag_name Assert-Equal $script:DeferredRestores 1 'A blocked update restores closed windows exactly once' diff --git a/Engine/tools/update-desktop.ps1 b/Engine/tools/update-desktop.ps1 index 56b39015..f9939314 100644 --- a/Engine/tools/update-desktop.ps1 +++ b/Engine/tools/update-desktop.ps1 @@ -184,6 +184,7 @@ function Invoke-DesktopUpdateCheck([string]$Directory, [string]$CacheDirectory) $asset = Select-DesktopUpdate @(Get-PublishedReleases) $commit if (-not $asset) { return $null } Write-UpdateLog "Preparing $($asset.name)." + Remove-Item -LiteralPath (Join-Path $CacheDirectory 'pending-update.txt') -Force -ErrorAction SilentlyContinue $installer = Save-VerifiedInstaller $asset $CacheDirectory $tag = ($asset.browser_download_url -split '/')[-2] # Publish only after verification. ASCII fields also decode identically in Rust.