diff --git a/Engine/Cargo.lock b/Engine/Cargo.lock index 8b9b1aa6..df5e7cd8 100644 --- a/Engine/Cargo.lock +++ b/Engine/Cargo.lock @@ -1147,6 +1147,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "engine-desktop" +version = "0.1.0" +dependencies = [ + "eframe", + "engine-platform", +] + [[package]] name = "engine-editor" version = "0.1.0" @@ -1175,6 +1183,7 @@ dependencies = [ "parking_lot", "ron", "serde", + "sha2", "tempfile", "thiserror 2.0.20", "uuid", @@ -3513,6 +3522,7 @@ dependencies = [ "embed-resource", "engine-assets", "engine-core", + "engine-desktop", "engine-editor", "engine-platform", "engine-play", @@ -3534,6 +3544,7 @@ dependencies = [ "eframe", "embed-resource", "engine-core", + "engine-desktop", "engine-platform", "engine-project", "engine-scripting", diff --git a/Engine/Cargo.toml b/Engine/Cargo.toml index f91c1432..499e10a6 100644 --- a/Engine/Cargo.toml +++ b/Engine/Cargo.toml @@ -8,6 +8,7 @@ members = [ "apps/setup-helper", "crates/engine-assets", "crates/engine-core", + "crates/engine-desktop", "crates/engine-editor", "crates/engine-play", "crates/engine-platform", diff --git a/Engine/README.md b/Engine/README.md index f0736f6a..ab5d8d41 100644 --- a/Engine/README.md +++ b/Engine/README.md @@ -52,6 +52,16 @@ or import a project and starts the editor as a separate native process. ## Windows installer +Installed Windows builds check GitHub Releases for updates on launch and hourly +while Rustic is open. Newer releases download automatically and prompt once per +release; a small **Update** title-bar button remains after dismissal. Updating +asks to save unsaved scenes, closes Rustic gracefully, then reopens its windows +and projects after installation succeeds. Updates after a normal exit do not +reopen the app. Windows may request +elevation for an all-users installation. Older builds need one manual upgrade to +receive the updater. See [Automatic desktop updates](docs/AUTOMATIC_UPDATES.md) +for verification, logs and source-build limitations. + From PowerShell, build a self-contained `.exe` installer with: ```powershell diff --git a/Engine/apps/editor/Cargo.toml b/Engine/apps/editor/Cargo.toml index d5ad67a4..75610235 100644 --- a/Engine/apps/editor/Cargo.toml +++ b/Engine/apps/editor/Cargo.toml @@ -12,6 +12,7 @@ egui_extras.workspace = true eframe.workspace = true engine-assets = { path = "../../crates/engine-assets", version = "0.1.0" } engine-core = { path = "../../crates/engine-core", version = "0.1.0" } +engine-desktop = { path = "../../crates/engine-desktop", version = "0.1.0" } engine-editor = { path = "../../crates/engine-editor", version = "0.1.0" } engine-platform = { path = "../../crates/engine-platform", version = "0.1.0" } engine-play = { path = "../../crates/engine-play", version = "0.1.0" } diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index a273bcfd..c0c34348 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -75,10 +75,14 @@ fn main() -> ExitCode { if let Err(error) = install_user_agent_integrations() { eprintln!("Could not install user AI-agent skill/plugin: {error}"); } + if let Err(error) = engine_platform::start_desktop_updater() { + eprintln!("Could not start automatic updates: {error}"); + } let title = format!("{} — Rustic Editor", project.metadata().name); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title(title) + .with_decorations(!cfg!(target_os = "windows")) .with_inner_size([1_440.0, 900.0]) .with_min_inner_size([960.0, 600.0]) .with_icon(app_icon()), @@ -118,6 +122,8 @@ fn app_icon() -> egui::IconData { reason = "independent editor panel and interaction flags are not mutually exclusive states" )] struct EditorApp { + updates: engine_desktop::DesktopUpdates, + update_close_requested: bool, document: AuthoringDocument, project_lock: Option, dock: Tree, @@ -223,6 +229,8 @@ impl EditorApp { let missing_dependencies = missing_project_dependencies(document.project().root()); let show_dependency_prompt = !missing_dependencies.is_empty(); let mut app = Self { + updates: engine_desktop::DesktopUpdates::default(), + update_close_requested: false, document, project_lock, dock, @@ -608,6 +616,56 @@ impl EditorApp { } } + fn update_close_dialog(&mut self, ui: &mut egui::Ui) { + if !self.update_close_requested { + return; + } + if self.play.is_some() { + self.stop_play(); + } + if self.pending_runtime_changes.is_some() { + return; + } + let mut ready = !self.document.is_modified(); + let mut cancel = false; + if !ready { + egui::Window::new("Save before updating?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ui, |ui| { + ui.label( + "This scene has unsaved changes. Save it before installing the update.", + ); + ui.horizontal(|ui| { + if ui.button("Save and update").clicked() { + self.save(); + ready = !self.document.is_modified(); + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + } + if cancel { + self.update_close_requested = false; + self.updates.cancel_install(); + } else if ready { + if self + .updates + .request_install(Some(self.document.project().root())) + { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } else { + self.update_close_requested = false; + if let Some(error) = self.updates.error().map(str::to_owned) { + self.log(Severity::Error, "update", error); + } + } + } + } + fn autosave_if_needed(&mut self, scene_changed: bool) { if !self.game_settings.autosave || self.document.is_read_only() @@ -1286,6 +1344,16 @@ fn transform_mode_button( impl eframe::App for EditorApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.poll_background(ui.ctx()); + let title = format!( + "{} — Rustic Editor", + self.document.project().metadata().name + ); + if self.updates.show(ui, &title) { + self.update_close_requested = true; + } + if self.updates.take_cancellation() { + self.update_close_requested = false; + } let scene_was_modified = self.document.is_modified(); egui::Panel::top("editor-toolbar") .frame( @@ -1360,6 +1428,7 @@ impl eframe::App for EditorApp { self.runtime_changes_dialog(ui.ctx()); self.game_settings_dialog(ui.ctx()); self.dependency_prompt(ui.ctx()); + self.update_close_dialog(ui); let scene_changed = !scene_was_modified && self.document.is_modified(); self.autosave_if_needed(scene_changed); } diff --git a/Engine/apps/project-manager/Cargo.toml b/Engine/apps/project-manager/Cargo.toml index a3e257b5..692aa15c 100644 --- a/Engine/apps/project-manager/Cargo.toml +++ b/Engine/apps/project-manager/Cargo.toml @@ -10,6 +10,7 @@ publish = false crossbeam-channel.workspace = true eframe.workspace = true engine-core = { path = "../../crates/engine-core", version = "0.1.0" } +engine-desktop = { path = "../../crates/engine-desktop", version = "0.1.0" } engine-platform = { path = "../../crates/engine-platform", version = "0.1.0" } engine-project = { path = "../../crates/engine-project", version = "0.1.0" } engine-scripting = { path = "../../crates/engine-scripting", version = "0.1.0" } diff --git a/Engine/apps/project-manager/src/main.rs b/Engine/apps/project-manager/src/main.rs index ba8882a7..abaee72d 100644 --- a/Engine/apps/project-manager/src/main.rs +++ b/Engine/apps/project-manager/src/main.rs @@ -38,12 +38,16 @@ fn run() -> Result<(), String> { { return Ok(()); } + if let Err(error) = engine_platform::start_desktop_updater() { + eprintln!("Could not start automatic updates: {error}"); + } let platform_paths = NativePlatformPaths::discover() .map_err(|error| format!("could not discover platform paths: {error}"))?; let app = ProjectManagerApp::new(platform_paths)?; let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("Rustic Game Engine — Projects") + .with_decorations(!cfg!(target_os = "windows")) .with_inner_size([1_080.0, 700.0]) .with_min_inner_size([760.0, 480.0]) .with_icon(app_icon()), @@ -122,6 +126,7 @@ struct DialogVisibility { } struct ProjectManagerApp { + updates: engine_desktop::DesktopUpdates, platform_paths: NativePlatformPaths, process_launcher: NativeProcessLauncher, model: ProjectManagerModel, @@ -208,6 +213,7 @@ impl ProjectManagerApp { ), ); Ok(Self { + updates: engine_desktop::DesktopUpdates::default(), platform_paths, process_launcher: NativeProcessLauncher, model, @@ -315,6 +321,14 @@ impl eframe::App for ProjectManagerApp { }; ui_theme::apply(&context, dark); + if self.updates.show(ui, "Rustic Game Engine — Projects") { + if self.updates.request_install(None) { + context.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + self.ui_status = self.updates.error().map(str::to_owned); + } + } + egui::Panel::top("project_manager_header") .frame( egui::Frame::new() diff --git a/Engine/crates/engine-desktop/Cargo.toml b/Engine/crates/engine-desktop/Cargo.toml new file mode 100644 index 00000000..f52f2358 --- /dev/null +++ b/Engine/crates/engine-desktop/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "engine-desktop" +description = "Shared desktop title bar and update notifications" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +eframe.workspace = true +engine-platform = { path = "../engine-platform", version = "0.1.0" } + +[lints] +workspace = true diff --git a/Engine/crates/engine-desktop/src/lib.rs b/Engine/crates/engine-desktop/src/lib.rs new file mode 100644 index 00000000..1a295515 --- /dev/null +++ b/Engine/crates/engine-desktop/src/lib.rs @@ -0,0 +1,365 @@ +//! Shared chrome and update notifications for the native desktop applications. + +use eframe::egui::{self, Align, Layout, Sense, ViewportCommand}; +use engine_platform::{DesktopUpdate, DesktopUpdateClient}; +use std::time::{Duration, Instant}; + +/// Keeps an update available after its single notification has been dismissed. +pub struct DesktopUpdates { + client: Option, + pending: Option, + prompt_open: bool, + last_poll: Option, + error: Option, + install_requested: bool, + install_cancelled: bool, +} + +impl Default for DesktopUpdates { + fn default() -> Self { + let (client, error) = match DesktopUpdateClient::discover() { + Ok(client) => (client, None), + Err(error) => (None, Some(error.to_string())), + }; + Self { + client, + pending: None, + prompt_open: false, + last_poll: None, + error, + install_requested: false, + install_cancelled: false, + } + } +} + +impl DesktopUpdates { + /// Draws the title bar and one-time prompt; returns a graceful-update request. + pub fn show(&mut self, ui: &mut egui::Ui, title: &str) -> bool { + self.poll(ui.ctx()); + let mut install = title_bar(ui, title, self.pending.is_some()); + if self.prompt_open { + let mut open = true; + egui::Window::new("Update available") + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ui, |ui| { + if let Some(update) = &self.pending { + ui.label(format!("{} is ready to install.", update.tag)); + } + ui.label("Install the latest update now?"); + ui.label( + "Rustic will close and reopen after updating, restoring your projects. Unsaved editors will ask to save first.", + ); + ui.horizontal(|ui| { + if ui.button("Install update").clicked() { + install = true; + self.prompt_open = false; + } + if ui.button("Not now").clicked() { + self.prompt_open = false; + } + }); + }); + self.prompt_open &= open; + } + if install { + self.prompt_open = false; + } + install || self.install_requested + } + + fn poll(&mut self, context: &egui::Context) { + context.request_repaint_after(Duration::from_secs(1)); + if self + .last_poll + .is_some_and(|last| last.elapsed() < Duration::from_secs(1)) + { + return; + } + self.last_poll = Some(Instant::now()); + let Some(client) = &self.client else { return }; + match client.pending() { + Ok(pending) => { + if self.pending != pending { + self.prompt_open = + pending + .as_ref() + .is_some_and(|update| match client.claim_prompt(update) { + Ok(claimed) => claimed, + Err(error) => { + self.error = Some(error.to_string()); + false + } + }); + self.pending = pending; + } + } + Err(error) => self.error = Some(error.to_string()), + } + let requested = self + .pending + .as_ref() + .is_some_and(|update| client.install_requested(update)); + self.install_cancelled |= self.install_requested && !requested; + self.install_requested = requested; + } + + /// Coordinates graceful closure in other windows after the current editor is safe. + pub fn request_install(&mut self, project: Option<&std::path::Path>) -> bool { + let Some((client, update)) = self.client.as_ref().zip(self.pending.as_ref()) else { + return false; + }; + match client + .prepare_restart(update, project) + .and_then(|()| client.request_install(update)) + { + Ok(()) => { + self.install_requested = true; + true + } + Err(error) => { + self.error = Some(error.to_string()); + false + } + } + } + + /// Leaves the update button available if an editor cancels closing. + pub fn cancel_install(&mut self) { + self.install_requested = false; + if let Some(client) = &self.client + && let Err(error) = client.cancel_install() + { + self.error = Some(error.to_string()); + } + } + + /// Reports a cancellation from another editor once, so deferred closes stop too. + pub fn take_cancellation(&mut self) -> bool { + std::mem::take(&mut self.install_cancelled) + } + + /// Last coordination error, suitable for the application's diagnostics. + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } +} + +fn title_bar(ui: &mut egui::Ui, title: &str, update_available: bool) -> bool { + draw_title_bar(ui, title, update_available, cfg!(target_os = "windows")).0 +} + +fn draw_title_bar( + ui: &mut egui::Ui, + title: &str, + update_available: bool, + custom: bool, +) -> (bool, Option) { + if !custom && !update_available { + return (false, None); + } + let context = ui.ctx().clone(); + let mut update = false; + let mut update_rect = None; + egui::Panel::top("rustic-title-bar") + .exact_size(24.0) + .resizable(false) + .show_separator_line(false) + .frame( + egui::Frame::new() + .fill(ui.visuals().panel_fill) + .inner_margin(egui::Margin::ZERO), + ) + .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 drag_rect = egui::Rect::from_min_max( + rect.min, + egui::pos2( + (rect.right() - controls_width - update_width).max(rect.left()), + rect.bottom(), + ), + ); + if custom { + let drag = ui.interact( + drag_rect, + ui.id().with("window-drag"), + Sense::click_and_drag(), + ); + if drag.double_clicked() { + toggle_maximized(&context); + } else if drag.drag_started() { + context.send_viewport_cmd(ViewportCommand::StartDrag); + } + ui.painter().with_clip_rect(drag_rect).text( + drag_rect.left_center() + egui::vec2(8.0, 0.0), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(12.0), + ui.visuals().text_color(), + ); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if custom { + if caption_button(ui, "×", "Close").clicked() { + context.send_viewport_cmd(ViewportCommand::Close); + } + if caption_button(ui, "□", "Maximize or restore").clicked() { + toggle_maximized(&context); + } + if caption_button(ui, "−", "Minimize").clicked() { + context.send_viewport_cmd(ViewportCommand::Minimized(true)); + } + } + if update_available { + let response = ui + .add_sized([90.0, 24.0], egui::Button::new("Update")) + .on_hover_text("Close Rustic and install the latest update"); + update_rect = Some(response.rect); + update = response.clicked(); + } + }); + }); + if custom { + window_resize_handles(&context); + } + (update, update_rect) +} + +fn caption_button(ui: &mut egui::Ui, label: &str, tooltip: &str) -> egui::Response { + ui.add_sized([53.0, 24.0], egui::Button::new(label).frame(false)) + .on_hover_text(tooltip) +} + +fn toggle_maximized(context: &egui::Context) { + let maximized = context.input(|input| input.viewport().maximized.unwrap_or(false)); + context.send_viewport_cmd(ViewportCommand::Maximized(!maximized)); +} + +fn window_resize_handles(context: &egui::Context) { + use egui::{CursorIcon as C, ResizeDirection as R}; + if context.input(|input| input.viewport().maximized.unwrap_or(false)) { + return; + } + let rect = context.viewport_rect(); + let Some(position) = context.input(|input| input.pointer.hover_pos()) else { + return; + }; + let left = position.x < rect.left() + 5.0; + let right = position.x > rect.right() - 5.0; + let top = position.y < rect.top() + 5.0; + let bottom = position.y > rect.bottom() - 5.0; + let direction = match (left, right, top, bottom) { + (true, _, true, _) => Some((R::NorthWest, C::ResizeNwSe)), + (_, true, true, _) => Some((R::NorthEast, C::ResizeNeSw)), + (true, _, _, true) => Some((R::SouthWest, C::ResizeNeSw)), + (_, true, _, true) => Some((R::SouthEast, C::ResizeNwSe)), + (true, _, _, _) => Some((R::West, C::ResizeHorizontal)), + (_, true, _, _) => Some((R::East, C::ResizeHorizontal)), + (_, _, true, _) => Some((R::North, C::ResizeVertical)), + (_, _, _, true) => Some((R::South, C::ResizeVertical)), + _ => None, + }; + if let Some((direction, cursor)) = direction { + context.set_cursor_icon(cursor); + if context.input(|input| input.pointer.primary_pressed()) { + context.send_viewport_cmd(ViewportCommand::BeginResize(direction)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(context: &egui::Context, input: egui::RawInput, ui: impl FnMut(&mut egui::Ui)) { + let mut output = context.run_ui(input, ui); + output.textures_delta.clear(); + } + + #[test] + fn update_button_stays_immediately_left_of_caption_controls_when_resized() { + let context = egui::Context::default(); + for width in [1280.0, 1080.0, 760.0] { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(width, 700.0), + )), + ..Default::default() + }; + let mut button = None; + frame(&context, input, |ui| { + button = draw_title_bar(ui, "Rustic Editor", true, true).1; + }); + 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); + } + } + + #[test] + fn update_action_is_hidden_until_ready_and_clickable_after_prompt_dismissal() { + let context = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0)); + let mut button = None; + frame( + &context, + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ui| { + assert!(draw_title_bar(ui, "Rustic Editor", false, true).1.is_none()); + }, + ); + frame( + &context, + egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }, + |ui| { + button = draw_title_bar(ui, "Rustic Editor", true, true).1; + }, + ); + let position = button.unwrap().center(); + let events = vec![ + egui::Event::PointerMoved(position), + egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }, + ]; + let mut clicked = false; + frame( + &context, + egui::RawInput { + screen_rect: Some(screen), + events, + ..Default::default() + }, + |ui| { + clicked = draw_title_bar(ui, "Rustic Editor", true, true).0; + }, + ); + assert!( + clicked, + "the persistent Update button must still start the update flow" + ); + } +} diff --git a/Engine/crates/engine-platform/Cargo.toml b/Engine/crates/engine-platform/Cargo.toml index d1b14ff5..5ddecc20 100644 --- a/Engine/crates/engine-platform/Cargo.toml +++ b/Engine/crates/engine-platform/Cargo.toml @@ -12,6 +12,7 @@ engine-core = { path = "../engine-core", version = "0.1.0" } parking_lot.workspace = true ron.workspace = true serde.workspace = true +sha2.workspace = true thiserror.workspace = true uuid.workspace = true diff --git a/Engine/crates/engine-platform/src/desktop_update.rs b/Engine/crates/engine-platform/src/desktop_update.rs new file mode 100644 index 00000000..657bb4fe --- /dev/null +++ b/Engine/crates/engine-platform/src/desktop_update.rs @@ -0,0 +1,294 @@ +//! Start the packaged Windows updater independently of the desktop process. + +use std::fs::{self, OpenOptions}; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +/// A verified release waiting for the desktop applications to close. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DesktopUpdate { + pub tag: String, +} + +/// File-based coordination with the independently running Windows updater. +pub struct DesktopUpdateClient { + directory: PathBuf, + build_commit: String, +} + +impl DesktopUpdateClient { + /// Returns a client for a packaged Windows installation, if present. + /// + /// # Errors + /// Returns errors discovering the executable or reading its build identity. + pub fn discover() -> io::Result> { + #[cfg(windows)] + { + use sha2::{Digest as _, Sha256}; + let executable = std::env::current_exe()?; + let Some(root) = executable.parent() else { + return Ok(None); + }; + if !root.join("tools/update-desktop.ps1").is_file() + || !root.join("build-commit.txt").is_file() + { + return Ok(None); + } + let build_commit = fs::read_to_string(root.join("build-commit.txt"))? + .trim() + .to_owned(); + let Some(local) = std::env::var_os("LOCALAPPDATA") else { + return Ok(None); + }; + let key = format!( + "{:X}", + Sha256::digest(root.to_string_lossy().to_lowercase().as_bytes()) + ); + Ok(Some(Self { + directory: PathBuf::from(local) + .join("Rustic Game Engine/updates") + .join(key), + build_commit, + })) + } + #[cfg(not(windows))] + Ok(None) + } + + /// Reads a verified pending update belonging to this installed build. + /// + /// # Errors + /// Returns filesystem errors other than a missing status file. + pub fn pending(&self) -> io::Result> { + let contents = match fs::read_to_string(self.directory.join("pending-update.txt")) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let mut lines = contents.lines(); + let commit = lines.next().unwrap_or_default(); + let tag = lines.next().unwrap_or_default(); + if commit != self.build_commit || !valid_tag(tag) { + return Ok(None); + } + Ok(Some(DesktopUpdate { + tag: tag.to_owned(), + })) + } + + /// Atomically claims the one-time prompt across windows and app restarts. + /// + /// # Errors + /// Returns filesystem errors saving the prompt decision. + pub fn claim_prompt(&self, update: &DesktopUpdate) -> io::Result { + if !valid_tag(&update.tag) { + return Err(io::Error::other("Invalid update tag")); + } + fs::create_dir_all(&self.directory)?; + match OpenOptions::new() + .write(true) + .create_new(true) + .open(self.directory.join(format!("prompted-{}.txt", update.tag))) + { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false), + Err(error) => Err(error), + } + } + + /// Requests a graceful close in all desktop windows 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) + } + + /// Records this window's project for restoration after a successful update. + /// + /// # Errors + /// Returns filesystem errors or an invalid project path. + pub fn prepare_restart( + &self, + update: &DesktopUpdate, + project: Option<&Path>, + ) -> io::Result<()> { + if !valid_tag(&update.tag) { + return Err(io::Error::other("Invalid update tag")); + } + let session = if let Some(project) = project { + if !project.is_absolute() { + return Err(io::Error::other("Project path must be absolute")); + } + format!("{}\neditor\n{}\n", update.tag, project.to_string_lossy()) + } else { + format!("{}\nproject-manager\n", update.tag) + }; + fs::write( + self.directory + .join(format!("resume-{}.txt", std::process::id())), + session, + ) + } + + /// Whether another desktop window requested this same update. + 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) + } + + /// Cancels the shared graceful-close request without removing the update. + /// + /// # Errors + /// Returns filesystem errors other than a missing request file. + pub fn cancel_install(&self) -> io::Result<()> { + for entry in fs::read_dir(&self.directory)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("resume-") && name.ends_with(".txt") { + fs::remove_file(entry.path())?; + } + } + match fs::remove_file(self.directory.join("install-request.txt")) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + result => result, + } + } +} + +fn valid_tag(tag: &str) -> bool { + !tag.is_empty() + && tag.len() <= 80 + && tag + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) +} + +/// Checks for releases in the background when running an installed Windows build. +/// Source builds and other platforms do not start an updater. +/// +/// # Errors +/// Returns an error if the packaged updater cannot be launched. +pub fn start_desktop_updater() -> std::io::Result<()> { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + use std::process::{Command, Stdio}; + + let executable = std::env::current_exe()?; + let Some(directory) = executable.parent() else { + return Ok(()); + }; + let script = directory.join("tools/update-desktop.ps1"); + if !script.is_file() || !directory.join("build-commit.txt").is_file() { + return Ok(()); + } + let windows = std::env::var_os("SystemRoot").ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "SystemRoot is unavailable") + })?; + Command::new( + std::path::PathBuf::from(windows) + .join("System32/WindowsPowerShell/v1.0/powershell.exe"), + ) + .args([ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + ]) + .arg(script) + .arg("-InstallDirectory") + .arg(directory) + .args(["-StateDirectory"]) + .arg( + DesktopUpdateClient::discover()? + .ok_or_else(|| io::Error::other("Could not discover the installed updater"))? + .directory, + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + // CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP: survive the desktop exit. + .creation_flags(0x0800_0200) + .spawn()?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompt_is_claimed_once_and_install_requests_are_release_specific() { + let temp = tempfile::tempdir().unwrap(); + let client = DesktopUpdateClient { + directory: temp.path().to_owned(), + build_commit: "a".repeat(40), + }; + let update = DesktopUpdate { + tag: "v2.0-Pre-Alpha".into(), + }; + fs::write( + temp.path().join("pending-update.txt"), + format!("{}\n{}\n", "a".repeat(40), update.tag), + ) + .unwrap(); + assert_eq!(client.pending().unwrap(), Some(update.clone())); + assert!(client.claim_prompt(&update).unwrap()); + assert!(!client.claim_prompt(&update).unwrap()); + let other_window = DesktopUpdateClient { + directory: temp.path().to_owned(), + build_commit: "a".repeat(40), + }; + assert!(!other_window.claim_prompt(&update).unwrap()); + client.prepare_restart(&update, Some(temp.path())).unwrap(); + let resume = temp + .path() + .join(format!("resume-{}.txt", std::process::id())); + let contents = fs::read_to_string(&resume).unwrap(); + assert_eq!(contents.lines().nth(1), Some("editor")); + assert_eq!(contents.lines().nth(2), Some(temp.path().to_str().unwrap())); + client.request_install(&update).unwrap(); + assert!(other_window.install_requested(&update)); + assert!(!other_window.install_requested(&DesktopUpdate { + tag: "v2.1-Pre-Alpha".into() + })); + other_window.cancel_install().unwrap(); + assert!(!resume.exists()); + assert!(!client.install_requested(&update)); + let new_build = DesktopUpdateClient { + directory: temp.path().to_owned(), + build_commit: "b".repeat(40), + }; + assert_eq!(new_build.pending().unwrap(), None); + } + + #[test] + fn malformed_status_cannot_create_paths_or_trigger_updates() { + let temp = tempfile::tempdir().unwrap(); + let client = DesktopUpdateClient { + directory: temp.path().to_owned(), + build_commit: "a".repeat(40), + }; + fs::write( + temp.path().join("pending-update.txt"), + format!("{}\n../bad\n", "a".repeat(40)), + ) + .unwrap(); + assert_eq!(client.pending().unwrap(), None); + assert!( + client + .claim_prompt(&DesktopUpdate { + tag: "../bad".into() + }) + .is_err() + ); + } +} diff --git a/Engine/crates/engine-platform/src/lib.rs b/Engine/crates/engine-platform/src/lib.rs index 74483573..1f08f512 100644 --- a/Engine/crates/engine-platform/src/lib.rs +++ b/Engine/crates/engine-platform/src/lib.rs @@ -3,6 +3,7 @@ mod atomic_file; mod config; mod crash_marker; +mod desktop_update; mod paths; mod process; mod vfs; @@ -10,6 +11,7 @@ mod vfs; pub use atomic_file::{AtomicFileService, AtomicWriteError, AtomicWriteOptions, RecoveryAction}; pub use config::{ConfigDocument, ConfigError, ConfigLoad, ConfigSchema, ConfigStore}; pub use crash_marker::{CrashMarkerError, StartupMarker, StartupRecord}; +pub use desktop_update::{DesktopUpdate, DesktopUpdateClient, start_desktop_updater}; pub use paths::{NativePlatformPaths, PlatformError, PlatformPaths}; pub use process::{ NativeProcessLauncher, ProcessError, ProcessExit, ProcessLauncher, ProcessRequest, diff --git a/Engine/docs/AUTOMATIC_UPDATES.md b/Engine/docs/AUTOMATIC_UPDATES.md new file mode 100644 index 00000000..7bbdbcfd --- /dev/null +++ b/Engine/docs/AUTOMATIC_UPDATES.md @@ -0,0 +1,65 @@ +# Automatic desktop updates + +Windows installers that include `tools/update-desktop.ps1` enable automatic updates +when Project Manager or the editor starts. Older installations need one manual +upgrade from [GitHub Releases](https://github.com/Rustic-Game-Engine/engine/releases) +to receive this feature. No Rust toolchain or GitHub login is needed. + +The updater checks on launch and every hour while Rustic is open. It includes the +project's `vM.m-Pre-Alpha` releases, orders versions numerically, and also recognizes +`vM.m.p` stable releases. GitHub's latest-release shortcut is not used because it +excludes prereleases. Drafts and unrelated tag formats are ignored. + +A newer Windows x64 installer downloads in the background. Its byte count and +SHA-256 must match the asset metadata returned by GitHub's HTTPS API before Setup +can run. Releases without a checksum or exactly one matching installer are skipped. +When an update is ready while Rustic is open, one window asks whether to install +it. **Not now** or closing the prompt dismisses it for that release, including +across restarts. A small **Update** button remains in the top-right title bar, +immediately left of the minimize/maximize/close controls, in both Project Manager +and the editor. Click it to install later. Newer releases can show a new prompt. + +Choosing **Install update** or **Update** gracefully closes the desktop windows. +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. + +After successful installation, the updater reopens each window closed for the +update, restoring every editor's original project, including paths with spaces or +Unicode. It starts the newly installed executables as the original user. Failed +installations do not replay session records. An automatic installation after a +normal app exit does not reopen the app. The updater never force-closes an editor +or reboots Windows. Setup preserves its prior directory, installation scope, +language components and shortcut/PATH tasks. An all-users installation can require +Windows' elevation prompt; Windows security prompts cannot be suppressed. + +The build script embeds the Git commit in Setup, which writes `build-commit.txt` +alongside the installed executables and records `install-scope.txt`. The release +publisher already targets the exact built commit. The updater finds that release +before comparing versions; the Cargo package version and timestamp in installer +filenames are not release versions. Source builds, unpublished workflow artifacts, +Linux and macOS do not automatically install releases. A build not yet associated +with a published release waits rather than replacing itself with an older build. + +## Diagnose an update + +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 +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. + +Run the network-free updater regression suite with Windows PowerShell 5.1: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\tools\test-desktop-updates.ps1 +``` + +The Windows installer builder runs this suite before compiling the application. +PowerShell 7 can run the mocked suite on Linux, but that does not verify Windows +elevation, process visibility or the installed Setup executable. diff --git a/Engine/installer/RusticGameEngine.iss b/Engine/installer/RusticGameEngine.iss index 902853e3..302cd24f 100644 --- a/Engine/installer/RusticGameEngine.iss +++ b/Engine/installer/RusticGameEngine.iss @@ -7,6 +7,9 @@ #ifndef BuildSuffix #define BuildSuffix "" #endif +#ifndef BuildCommit + #define BuildCommit "development" +#endif #define AppName "Rustic Game Engine" #define AppPublisher "Rustic Game Engine Contributors" @@ -82,6 +85,7 @@ Source: "..\target\distribution\rustic-runtime.exe"; DestDir: "{app}"; Flags: ig Source: "..\target\distribution\rustic-asset-worker.exe"; DestDir: "{app}"; Flags: ignoreversion #endif Source: "..\tools\manage-language-toolchains.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion +Source: "..\tools\update-desktop.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion [Icons] Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}" @@ -95,6 +99,10 @@ Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; Value [Run] Filename: "{app}\{#AppExeName}"; WorkingDir: "{app}"; Description: "Launch {#AppName}"; Flags: nowait postinstall skipifsilent runasoriginaluser +[UninstallDelete] +Type: files; Name: "{app}\build-commit.txt" +Type: files; Name: "{app}\install-scope.txt" + [Code] procedure InstallUserIntegrations(); var @@ -193,9 +201,16 @@ begin end; procedure CurStepChanged(CurStep: TSetupStep); +var + Scope: String; begin if CurStep = ssPostInstall then begin + if IsAdminInstallMode then Scope := 'allusers' else Scope := 'currentuser'; + if not SaveStringToFile(ExpandConstant('{app}\build-commit.txt'), '{#BuildCommit}', False) then + RaiseException('Could not save installed build identity.'); + if not SaveStringToFile(ExpandConstant('{app}\install-scope.txt'), Scope, False) then + RaiseException('Could not save installation scope.'); InstallUserIntegrations(); InstallSelectedLanguageToolchains(); end; diff --git a/Engine/tools/build-windows-installer.ps1 b/Engine/tools/build-windows-installer.ps1 index 4e56874e..6d068e97 100644 --- a/Engine/tools/build-windows-installer.ps1 +++ b/Engine/tools/build-windows-installer.ps1 @@ -83,6 +83,14 @@ try { & powershell -NoProfile -ExecutionPolicy Bypass -File 'tools\test-language-toolchains.ps1' if ($LASTEXITCODE -ne 0) { throw "Toolchain manager tests failed with exit code $LASTEXITCODE." } + Write-Host 'Testing automatic desktop updates with Windows PowerShell...' + & powershell -NoProfile -ExecutionPolicy Bypass -File 'tools\test-desktop-updates.ps1' + if ($LASTEXITCODE -ne 0) { throw "Desktop updater tests failed with exit code $LASTEXITCODE." } + + # Release tags are allocated after the build; the commit identifies its release. + $buildCommit = (& git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $buildCommit -notmatch '^[0-9a-f]{40}$') { throw 'Could not determine the build commit.' } + Write-Host 'Building bundled Luau API host...' & $cargo build --locked --manifest-path 'apps/luau-host/Cargo.toml' --profile distribution --target-dir 'target' if ($LASTEXITCODE -ne 0) { throw "Luau host build failed with exit code $LASTEXITCODE." } @@ -110,7 +118,7 @@ try { $pathFlag = if ($InstallDirectoryOnPath) { '1' } else { '0' } $buildSuffix = '-' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') Write-Host 'Creating the Windows installer...' - & $iscc "/DAppVersion=$appVersion" "/DBuildSuffix=$buildSuffix" "/DAddToPathByDefault=$pathFlag" 'installer\RusticGameEngine.iss' + & $iscc "/DAppVersion=$appVersion" "/DBuildSuffix=$buildSuffix" "/DBuildCommit=$buildCommit" "/DAddToPathByDefault=$pathFlag" 'installer\RusticGameEngine.iss' if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed with exit code $LASTEXITCODE." } } finally { diff --git a/Engine/tools/test-desktop-updates.ps1 b/Engine/tools/test-desktop-updates.ps1 new file mode 100644 index 00000000..461a1e96 --- /dev/null +++ b/Engine/tools/test-desktop-updates.ps1 @@ -0,0 +1,160 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +. (Join-Path $PSScriptRoot 'update-desktop.ps1') -FunctionsOnly + +function Assert-Equal($Actual, $Expected, [string]$Message) { + if ($Actual -cne $Expected) { throw "$Message (expected '$Expected', got '$Actual')" } +} +function Assert-Throws([scriptblock]$Action, [string]$Message) { + $threw = $false + try { & $Action } catch { $threw = $true } + if (-not $threw) { throw $Message } +} +function New-Release([string]$Tag, [string]$Commit) { + [pscustomobject]@{ + tag_name = $Tag; target_commitish = $Commit; draft = $false; prerelease = $true + assets = @([pscustomobject]@{ + name = 'RusticGameEngine-Setup-0.1.0-x64-test.exe'; size = 3 + digest = 'sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + browser_download_url = "https://github.com/Rustic-Game-Engine/engine/releases/download/$Tag/RusticGameEngine-Setup-0.1.0-x64-test.exe" + }) + } +} + +$temp = Join-Path ([IO.Path]::GetTempPath()) ('rustic-update-test-' + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $temp | Out-Null +try { + $script:CacheDirectory = $temp + $commit = 'a' * 40 + $current = New-Release 'v0.9-Pre-Alpha' $commit + $next = New-Release 'v1.0-Pre-Alpha' ('b' * 40) + $releases = @($current, $next) + Assert-Equal (Select-DesktopUpdate $releases $commit).browser_download_url $next.assets[0].browser_download_url 'Select newer prerelease across rollover' + Assert-Equal (Select-DesktopUpdate $releases ('b' * 40)) $null 'Never reinstall current release' + Assert-Throws { Select-DesktopUpdate $releases ('c' * 40) } 'Unpublished source builds must not downgrade' + Assert-Throws { Select-DesktopUpdate $releases '../bad' } 'Invalid installed identity must fail' + Assert-Equal ((Get-ReleaseVersion 'v1.10-Pre-Alpha') -gt (Get-ReleaseVersion 'v1.9-Pre-Alpha')) $true 'Compare numeric versions' + Assert-Equal ((Get-ReleaseVersion 'v1.0.0') -gt (Get-ReleaseVersion 'v1.0-Pre-Alpha')) $true 'Stable follows prerelease' + Assert-Equal (Get-ReleaseVersion 'unrelated-tag') $null 'Ignore other release channels' + $next.draft = $true + Assert-Equal (Select-DesktopUpdate $releases $commit) $null 'Ignore draft releases' + $next.draft = $false + $asset = $next.assets[0] + $digest = $asset.digest + $asset.digest = $null + Assert-Equal (Select-DesktopUpdate $releases $commit) $null 'Require GitHub checksum' + $asset.digest = $digest + $next.assets = @($asset, $asset) + Assert-Equal (Select-DesktopUpdate $releases $commit) $null 'Reject ambiguous installers' + $next.assets = @($asset) + $url = $asset.browser_download_url + $asset.browser_download_url = 'https://example.com/installer.exe' + Assert-Throws { Select-DesktopUpdate $releases $commit } 'Reject foreign download URL' + $asset.browser_download_url = $url + + # No network is used. Match Invoke-RestMethod's non-enumerated array response. + $script:Pages = 0 + function Invoke-RestMethod { + param($Uri, $Headers, $TimeoutSec) + $script:Pages++ + if ($script:Pages -eq 1) { return ,(@($current) * 100) } + return ,@($next) + } + Assert-Equal @(Get-PublishedReleases).Count 101 'Read releases beyond the first API page' + Assert-Equal $script:Pages 2 'Stop pagination on the final page' + + function Invoke-WebRequest { + param($Uri, $Headers, $OutFile, $TimeoutSec, [switch]$UseBasicParsing) + [IO.File]::WriteAllText($OutFile, 'abc', [Text.Encoding]::ASCII) + } + $installer = Save-VerifiedInstaller $asset $temp + Assert-Equal (Test-Path -LiteralPath $installer) $true 'Verified download becomes an installer' + Remove-Item -LiteralPath $installer + $asset.digest = 'sha256:' + ('0' * 64) + Assert-Throws { Save-VerifiedInstaller $asset $temp } 'Reject checksum mismatch' + Assert-Equal (Test-Path -LiteralPath $installer) $false 'Corrupt installer is never staged' + Assert-Equal (Test-Path -LiteralPath ($installer + '.partial')) $false 'Clean up partial files' + $asset.digest = $digest + $asset.size = 4 + Assert-Throws { Save-VerifiedInstaller $asset $temp } 'Reject truncated installer' + $asset.size = 3 + function Invoke-WebRequest { throw 'Network unavailable' } + Assert-Throws { Save-VerifiedInstaller $asset $temp } 'Network failure cannot stage an update' + + function Get-Process { + param($Name, $ErrorAction) + [pscustomobject]@{ Path = (Join-Path $temp 'rustic-editor.exe') } + } + 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 } } + Assert-Equal (Test-RusticRunning $temp) $true 'Inaccessible process must block installation' + + # Exercise the download -> wait -> install path without starting any executable. + Set-Content -LiteralPath (Join-Path $temp 'build-commit.txt') -Value $commit + Set-Content -LiteralPath (Join-Path $temp 'install-scope.txt') -Value 'currentuser' + function Get-PublishedReleases { $releases } + function Invoke-WebRequest { + param($Uri, $Headers, $OutFile, $TimeoutSec, [switch]$UseBasicParsing) + [IO.File]::WriteAllText($OutFile, 'abc', [Text.Encoding]::ASCII) + } + $script:Waits = 0 + $script:Launches = 0 + $script:SetupExitCode = 0 + function Test-RusticRunning { param($Directory); return $script:Waits -eq 0 } + function Start-Sleep { param($Seconds); $script:Waits++ } + function Start-Process { + param($FilePath, $ArgumentList, [switch]$PassThru, [switch]$Wait) + Assert-Equal ($script:Waits -gt 0) $true 'Install only after editor closes' + Assert-Equal ($ArgumentList -contains '/NOCLOSEAPPLICATIONS') $true 'Never force close applications' + Assert-Equal ($ArgumentList -contains '/NORESTART') $true 'Never reboot' + Assert-Equal ($ArgumentList -contains '/currentuser') $true 'Preserve installation scope' + Assert-Equal ($ArgumentList -contains ('/DIR="' + $temp + '"')) $true 'Preserve installation directory' + $script:Launches++ + [pscustomobject]@{ ExitCode = $script:SetupExitCode } + } + Assert-Equal (Invoke-DesktopUpdateCheck $temp $temp).Installed $true 'Successful background update' + Assert-Equal $script:Launches 1 'Launch Setup exactly once' + Assert-Equal (Test-Path -LiteralPath $installer) $false 'Remove installer after success' + Assert-Equal (Test-Path -LiteralPath (Join-Path $temp 'pending-update.txt')) $false 'Clear pending UI status after success' + $script:SetupExitCode = 5 + Set-Content -LiteralPath (Join-Path $temp 'install-request.txt') -Value $next.tag_name + Set-Content -LiteralPath (Join-Path $temp 'resume-failure.txt') -Value 'must not replay' + Assert-Throws { Invoke-DesktopUpdateCheck $temp $temp } 'Report installer failure for retry' + Assert-Equal (Test-Path -LiteralPath (Join-Path $temp 'install-request.txt')) $false 'Failed Setup must not close the next desktop session' + Assert-Equal (Test-Path -LiteralPath (Join-Path $temp 'resume-failure.txt')) $false 'Failed Setup clears restart records' + $pending = Get-Content -LiteralPath (Join-Path $temp 'pending-update.txt') + Assert-Equal $pending[0] $commit 'UI status identifies the installed build' + Assert-Equal $pending[1] $next.tag_name 'UI status identifies the verified release' + $script:Launches = 0 + $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' + + # 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 + $utf8 = [Text.UTF8Encoding]::new($false) + [IO.File]::WriteAllText((Join-Path $temp 'resume-1.txt'), "$($next.tag_name)`nproject-manager`n", $utf8) + [IO.File]::WriteAllText((Join-Path $temp 'resume-2.txt'), "$($next.tag_name)`neditor`n$project`n", $utf8) + [IO.File]::WriteAllText((Join-Path $temp 'resume-3.txt'), "v0.1-Pre-Alpha`nproject-manager`n", $utf8) + [IO.File]::WriteAllText((Join-Path $temp 'resume-4.txt'), "$($next.tag_name)`narbitrary-executable`n", $utf8) + $script:Restored = @() + function Start-Process { + param($FilePath, $ArgumentList, $WorkingDirectory) + $script:Restored += [pscustomobject]@{ Path = $FilePath; Arguments = $ArgumentList } + Assert-Equal $WorkingDirectory $temp 'Restore using the installed application directory' + } + Restart-DesktopSessions $temp $temp $next.tag_name + Assert-Equal $script:Restored.Count 2 'Restore only matching supported desktop sessions' + Assert-Equal $script:Restored[0].Path (Join-Path $temp 'rustic-project-manager.exe') 'Reopen latest Project Manager' + Assert-Equal $script:Restored[1].Path (Join-Path $temp 'rustic-editor.exe') 'Reopen latest editor' + Assert-Equal $script:Restored[1].Arguments ('--project "' + $project + '"') 'Reopen the exact project with quoted Unicode/spaces' + Assert-Equal @(Get-ChildItem -LiteralPath $temp -Filter 'resume-*.txt').Count 0 'Consume session records exactly once' + Restart-DesktopSessions $temp $temp $next.tag_name + Assert-Equal $script:Restored.Count 2 'Ordinary closed-app updates do not reopen applications' + Write-Host 'Desktop updater regression tests passed.' +} finally { Remove-Item -LiteralPath $temp -Recurse -Force } diff --git a/Engine/tools/test-language-toolchains.ps1 b/Engine/tools/test-language-toolchains.ps1 index d680d3f4..37abc451 100644 --- a/Engine/tools/test-language-toolchains.ps1 +++ b/Engine/tools/test-language-toolchains.ps1 @@ -110,6 +110,11 @@ function Invoke-InstallerCase([string]$Mode, [string]$Components, [int]$Expected finally { $process.Dispose() } Assert-Equal (Test-Path (Join-Path $installRoot 'rustic-project-manager.exe')) $true 'Engine installed' Assert-Equal (Test-Path (Join-Path $installRoot 'rustic-setup-helper.exe')) $true 'Setup helper installed' + Assert-Equal (Test-Path (Join-Path $installRoot 'tools\update-desktop.ps1')) $true 'Desktop updater packaged' + $buildCommit = (Get-Content -LiteralPath (Join-Path $installRoot 'build-commit.txt') -Raw).Trim() + Assert-Equal ($buildCommit -match '^[0-9a-f]{40}$') $true 'Installed release identity' + $scope = (Get-Content -LiteralPath (Join-Path $installRoot 'install-scope.txt') -Raw).Trim() + Assert-Equal $scope 'currentuser' 'Installed update scope' Assert-Equal @(Get-Calls $info).Count $ExpectedCalls "$Mode Setup WinGet invocation count" $log = Get-Content -LiteralPath $setupLog -Raw Assert-Equal ($log.Contains('Agent integration installation exited with code')) $false 'Agent integrations must initialize successfully' diff --git a/Engine/tools/update-desktop.ps1 b/Engine/tools/update-desktop.ps1 new file mode 100644 index 00000000..74c4786f --- /dev/null +++ b/Engine/tools/update-desktop.ps1 @@ -0,0 +1,203 @@ +[CmdletBinding()] +param( + [string]$InstallDirectory = (Split-Path -Parent $PSScriptRoot), + [string]$StateDirectory, + [switch]$FunctionsOnly +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$script:Repository = 'Rustic-Game-Engine/engine' +$script:Headers = @{ 'User-Agent' = 'Rustic-Desktop-Updater'; Accept = 'application/vnd.github+json' } + +function Get-ReleaseVersion([string]$Tag) { + if ($Tag -match '^v(\d+)\.(\d+)(?:\.(\d+))?(-pre-alpha)?$') { + $patch = if ($Matches[3]) { [int]$Matches[3] } else { 0 } + # A stable release follows its pre-alpha at the same numeric version. + $stable = if ($Matches[4]) { 0 } else { 1 } + return [version]::new([int]$Matches[1], [int]$Matches[2], $patch, $stable) + } + return $null +} + +function Get-PublishedReleases { + $page = 1 + do { + $response = Invoke-RestMethod -Uri "https://api.github.com/repos/$script:Repository/releases?per_page=100&page=$page" ` + -Headers $script:Headers -TimeoutSec 60 + $batch = @($response) + foreach ($release in $batch) { + if (-not $release.draft -and (Get-ReleaseVersion $release.tag_name)) { $release } + } + $page++ + } while ($batch.Count -eq 100) +} + +function Select-DesktopUpdate($Releases, [string]$InstalledCommit) { + if ($InstalledCommit -notmatch '^[0-9a-f]{40}$') { throw 'Installed build commit is invalid.' } + $ordered = @($Releases | Where-Object { -not $_.draft -and (Get-ReleaseVersion $_.tag_name) } | + Sort-Object { Get-ReleaseVersion $_.tag_name } -Descending) + $installed = @($ordered | Where-Object { $_.target_commitish -eq $InstalledCommit }) + if ($installed.Count -eq 0) { + throw 'This build has no published release yet; skipping updates to avoid a downgrade.' + } + $currentVersion = Get-ReleaseVersion $installed[0].tag_name + foreach ($release in $ordered) { + if ((Get-ReleaseVersion $release.tag_name) -le $currentVersion) { return $null } + $assets = @($release.assets | Where-Object { $_.name -match '^RusticGameEngine-Setup-[\w.-]+-x64[\w.-]*\.exe$' }) + if ($assets.Count -ne 1) { continue } + $asset = $assets[0] + $digest = $asset.PSObject.Properties['digest'] + if (-not $digest -or $digest.Value -notmatch '^sha256:[0-9a-f]{64}$' -or $asset.size -le 0) { continue } + # Asset names must remain a filename and downloads must belong to this repository. + $expectedUrl = "https://github.com/$script:Repository/releases/download/$($release.tag_name)/$($asset.name)" + if ($asset.browser_download_url -cne $expectedUrl) { throw 'Unexpected installer download URL.' } + return $asset + } + return $null +} + +function Save-VerifiedInstaller($Asset, [string]$Directory) { + $partial = Join-Path $Directory ($Asset.name + '.partial') + $installer = Join-Path $Directory $Asset.name + 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.' + } + Move-Item -LiteralPath $partial -Destination $installer -Force + return $installer + } finally { + Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue + } +} + +function Test-RusticRunning([string]$Directory) { + $names = @('rustic-project-manager', 'rustic-editor', 'rustic-runtime', 'rustic-asset-worker', 'rustic-agent-backend', 'rustic-luau-host', 'rustic-setup-helper') + foreach ($process in @(Get-Process -Name $names -ErrorAction SilentlyContinue)) { + try { + $path = $process.Path + # An inaccessible process could belong to this installation. Wait safely. + if (-not $path -or (Split-Path -Parent $path) -eq $Directory) { return $true } + } catch { return $true } + } + return $false +} + +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.' } + # Preserve Setup's previous component/task selections and the installation directory. + # Never close an editor or reboot the computer from the updater. + $arguments = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/NOCLOSEAPPLICATIONS', + '/NORESTARTAPPLICATIONS', "/$scope", ('/DIR="' + $Directory + '"'), + ('/LOG="' + (Join-Path $LogDirectory 'update-setup.log') + '"')) + $process = Start-Process -FilePath $Installer -ArgumentList $arguments -PassThru -Wait + if ($process.ExitCode -ne 0) { throw "Update installer exited with code $($process.ExitCode)." } +} + +function Write-UpdateLog([string]$Message) { + Add-Content -LiteralPath (Join-Path $script:CacheDirectory 'update.log') ` + -Value (('{0:u} ' -f [DateTime]::UtcNow) + $Message) +} + +function Restart-DesktopSessions([string]$Directory, [string]$CacheDirectory, [string]$Tag) { + foreach ($record in @(Get-ChildItem -LiteralPath $CacheDirectory -Filter 'resume-*.txt' -File)) { + try { + $lines = @(Get-Content -LiteralPath $record.FullName -Encoding UTF8) + if ($lines.Count -lt 2 -or $lines[0] -cne $Tag) { continue } + $start = @{ WorkingDirectory = $Directory } + if ($lines[1] -eq 'project-manager') { + $start.FilePath = Join-Path $Directory 'rustic-project-manager.exe' + } elseif ($lines[1] -eq 'editor') { + if ($lines.Count -lt 3 -or -not [IO.Path]::IsPathRooted($lines[2]) -or + -not (Test-Path -LiteralPath $lines[2] -PathType Container)) { + throw 'The project to restore no longer exists.' + } + $start.FilePath = Join-Path $Directory 'rustic-editor.exe' + $quotedProject = $lines[2] -replace '(\\+)$', '$1$1' + $start.ArgumentList = '--project "' + $quotedProject + '"' + } 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." + } catch { Write-UpdateLog "Could not restore a desktop session: $($_.Exception.Message)" } + finally { Remove-Item -LiteralPath $record.FullName -Force -ErrorAction SilentlyContinue } + } +} + +function Invoke-DesktopUpdateCheck([string]$Directory, [string]$CacheDirectory) { + $commitFile = Join-Path $Directory 'build-commit.txt' + $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 + $installer = Save-VerifiedInstaller $asset $CacheDirectory + $tag = ($asset.browser_download_url -split '/')[-2] + # Publish only after verification. ASCII fields also decode identically in Rust. + $pending = Join-Path $CacheDirectory 'pending-update.txt' + $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 } + 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 } } + try { Install-DesktopUpdate $installer $Directory $CacheDirectory } + catch { + Remove-Item -LiteralPath (Join-Path $CacheDirectory 'install-request.txt') -Force -ErrorAction SilentlyContinue + Get-ChildItem -LiteralPath $CacheDirectory -Filter 'resume-*.txt' -File | Remove-Item -Force + throw + } + Write-UpdateLog 'Update installed.' + Remove-Item -LiteralPath $installer -Force + Remove-Item -LiteralPath $pending -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath (Join-Path $CacheDirectory 'install-request.txt') -Force -ErrorAction SilentlyContinue + return [pscustomobject]@{ Installed = $true; Tag = $tag } +} + +function Invoke-DesktopUpdater([string]$Directory) { + if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return } + $Directory = (Resolve-Path -LiteralPath $Directory).Path.TrimEnd('\') + $commitFile = Join-Path $Directory 'build-commit.txt' + if (-not (Test-Path -LiteralPath $commitFile)) { return } + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $hasher = [Security.Cryptography.SHA256]::Create() + try { $key = [BitConverter]::ToString($hasher.ComputeHash([Text.Encoding]::UTF8.GetBytes($Directory.ToLowerInvariant()))).Replace('-', '') } + finally { $hasher.Dispose() } + $script:CacheDirectory = if ($StateDirectory) { $StateDirectory } else { + Join-Path $env:LOCALAPPDATA "Rustic Game Engine\updates\$key" + } + New-Item -ItemType Directory -Force -Path $script:CacheDirectory | Out-Null + # FileShare.None permits only one worker per installation, across launcher/editor instances. + try { $lock = [IO.File]::Open((Join-Path $script:CacheDirectory 'updater.lock'), 'OpenOrCreate', 'ReadWrite', 'None') } + catch [IO.IOException] { return } + try { + do { + try { + $result = Invoke-DesktopUpdateCheck $Directory $script:CacheDirectory + if ($result) { + # Release the worker lock before reopening apps: their new updater must + # be able to acquire it and keep checking during the restored session. + $lock.Dispose() + $lock = $null + if ($result.Installed) { + try { Restart-DesktopSessions $Directory $script:CacheDirectory $result.Tag } + catch { Write-UpdateLog "Could not restore desktop sessions: $($_.Exception.Message)" } + } + return + } + } catch { Write-UpdateLog $_.Exception.Message } + # Check hourly while Rustic is open; a later launch retries network/Setup failures. + for ($tick = 0; $tick -lt 720; $tick++) { + if (-not (Test-RusticRunning $Directory)) { return } + Start-Sleep -Seconds 5 + } + } while ($true) + } finally { if ($lock) { $lock.Dispose() } } +} + +if (-not $FunctionsOnly) { Invoke-DesktopUpdater $InstallDirectory }