Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions Engine/crates/engine-desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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),
);
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
}

Expand Down
71 changes: 63 additions & 8 deletions Engine/crates/engine-platform/src/desktop_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<uuid::Uuid>,
}

impl DesktopUpdateClient {
Expand Down Expand Up @@ -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))]
Expand Down Expand Up @@ -97,15 +102,23 @@ 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.
pub fn request_install(&self, update: &DesktopUpdate) -> io::Result<()> {
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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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();
Expand All @@ -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()
}));
Expand All @@ -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);
}
Expand All @@ -276,13 +319,25 @@ 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"),
format!("{}\n../bad\n", "a".repeat(40)),
)
.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 {
Expand Down
21 changes: 20 additions & 1 deletion Engine/docs/AUTOMATIC_UPDATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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\<installation-key>\`. `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
Expand Down
29 changes: 28 additions & 1 deletion Engine/tools/test-desktop-updates.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -133,6 +140,26 @@ 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
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 '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'
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')
Expand Down
Loading
Loading