From dd21c46b3f07047c0e82189e795400ece7186468 Mon Sep 17 00:00:00 2001 From: Xein Date: Mon, 9 Nov 2026 13:57:40 +0100 Subject: [PATCH 1/2] Autoupdate --- .github/workflows/package-release.yml | 148 ++++++++++++++++++ .github/workflows/release-on-merge.yml | 29 +++- editor/App.axaml.cs | 21 ++- .../Components/Modals/LoadingPopup.axaml.cs | 4 + editor/Install/ShellIntegration.cs | 129 +++++++++++++++ editor/Install/Updater.cs | 114 ++++++++++++++ editor/Program.cs | 11 ++ editor/StartWindow/NewProjectWindow.axaml.cs | 14 +- editor/StartWindow/StartWindowViewModel.cs | 40 ++++- editor/Workspace/MainWindowViewModel.cs | 2 +- tools/installer/exclude.txt | 5 + tools/installer/linux/install.sh | 25 +++ tools/installer/toast-engine.iss | 79 ++++++++++ 13 files changed, 605 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/package-release.yml create mode 100644 editor/Install/ShellIntegration.cs create mode 100644 editor/Install/Updater.cs create mode 100644 tools/installer/exclude.txt create mode 100644 tools/installer/linux/install.sh create mode 100644 tools/installer/toast-engine.iss diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml new file mode 100644 index 000000000..78d6ed5bc --- /dev/null +++ b/.github/workflows/package-release.yml @@ -0,0 +1,148 @@ +name: package-release + +# Builds editor RelWithDebInfo +# Attaches the Windows installer and the Linux tarball to a release + +on: + workflow_call: + inputs: + tag: + description: Release tag, e.g. 2.8.0 + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: Release tag, e.g. 2.8.0 + required: true + type: string + +permissions: + contents: write + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + windows: + name: windows installer + runs-on: [self-hosted, windows] + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + ref: ${{ inputs.tag }} + fetch-depth: 1 + submodules: true + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup MSVC + uses: ilammy/msvc-dev-cmd@v1 + + - name: Setup vcpkg + id: vcpkg + uses: ./.github/actions/setup-vcpkg + + - name: Build editor + run: | + cmake -B build/package ` + -G Ninja ` + -DCMAKE_BUILD_TYPE=RelWithDebInfo ` + -DCMAKE_C_COMPILER=cl ` + -DCMAKE_CXX_COMPILER=cl ` + -DCMAKE_TOOLCHAIN_FILE="${{ steps.vcpkg.outputs.toolchain-file }}" ` + -DVCPKG_TARGET_TRIPLET=x64-windows + cmake --build build/package --target editor + + - name: Stage out/RelWithDebInfo + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $stage = Join-Path $env:RUNNER_TEMP 'toast-stage' + if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } + New-Item -ItemType Directory $stage | Out-Null + Copy-Item -Recurse -Force 'out/RelWithDebInfo/*' $stage + foreach ($name in Get-Content tools/installer/exclude.txt) { + if ($name.Trim()) { Remove-Item -Recurse -Force (Join-Path $stage $name.Trim()) -ErrorAction SilentlyContinue } + } + Set-Content -NoNewline -Path (Join-Path $stage 'version.txt') -Value '${{ inputs.tag }}' + "STAGE=$stage" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV + + - name: Install Inno Setup + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $dir = Join-Path $env:RUNNER_TOOL_CACHE 'InnoSetup' + if (-not (Test-Path (Join-Path $dir 'ISCC.exe'))) { + $setup = Join-Path $env:RUNNER_TEMP 'innosetup.exe' + Invoke-WebRequest -Uri 'https://jrsoftware.org/download.php/is.exe' -OutFile $setup + Start-Process -Wait -FilePath $setup -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER', "/DIR=$dir" + } + "ISCC=$(Join-Path $dir 'ISCC.exe')" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV + + - name: Build installer + shell: pwsh + run: | + & $env:ISCC "/DAppVersion=${{ inputs.tag }}" "/DSourceDir=$env:STAGE" "/O$env:RUNNER_TEMP" tools/installer/toast-engine.iss + if ($LASTEXITCODE -ne 0) { throw "iscc failed" } + + - name: Upload to release + shell: pwsh + run: gh release upload "${{ inputs.tag }}" "$env:RUNNER_TEMP/ToastEngine-${{ inputs.tag }}-setup.exe" --clobber --repo "${{ github.repository }}" + + linux: + name: linux tarball + runs-on: [self-hosted, Linux, X64] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + fetch-depth: 1 + submodules: true + + - name: Install Nix + uses: cachix/install-nix-action@v31.10.6 + + - name: Build editor + run: nix develop --command bash -c " + rustup toolchain install stable --no-self-update && + rustup default stable && + cmake -B build/package -G Ninja + -DCMAKE_C_COMPILER=clang + -DCMAKE_CXX_COMPILER=clang++ + -DCMAKE_BUILD_TYPE=RelWithDebInfo + -DTOAST_X86_MARCH=x86-64 + -DCMAKE_TOOLCHAIN_FILE=\$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake + -DCMAKE_C_COMPILER_LAUNCHER=ccache + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache && + cmake --build build/package --parallel \$(nproc) --target editor + " + + - name: Package + shell: bash + run: | + set -euo pipefail + name="toast-engine-${{ inputs.tag }}-linux-x64" + stage="${RUNNER_TEMP}/${name}" + rm -rf "${stage}" + mkdir -p "${stage}" + cp -r out/RelWithDebInfo/. "${stage}/" + while read -r entry; do + [[ -n "${entry}" ]] && rm -rf "${stage:?}/${entry}" + done < tools/installer/exclude.txt + printf '%s' "${{ inputs.tag }}" > "${stage}/version.txt" + cp tools/installer/linux/install.sh "${stage}/install.sh" + chmod +x "${stage}/install.sh" + tar -C "${RUNNER_TEMP}" -czf "${RUNNER_TEMP}/${name}.tar.gz" "${name}" + echo "TARBALL=${RUNNER_TEMP}/${name}.tar.gz" >> "${GITHUB_ENV}" + + - name: Upload to release + run: gh release upload "${{ inputs.tag }}" "${TARBALL}" --clobber --repo "${{ github.repository }}" diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index f7137a93a..5d923707c 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -14,6 +14,9 @@ jobs: release: name: tag and release runs-on: [self-hosted, Linux, X64] + outputs: + new_tag: ${{ steps.version.outputs.new_tag }} + create_release: ${{ steps.version.outputs.create_release }} if: github.event.pull_request.merged == true && github.event.pull_request.base.repo.fork == false && github.event.pull_request.base.repo.full_name == github.repository steps: - name: Checkout repository @@ -83,9 +86,33 @@ jobs: repo: context.repo.repo, tag_name: tag, name: `Toast Engine v${tag}`, - body: "" + body: "", + // Published once the installers are attached, editors only follow the latest published release + draft: true }); + package: + name: package installers + needs: release + if: needs.release.outputs.create_release == 'true' + permissions: + contents: write + uses: ./.github/workflows/package-release.yml + with: + tag: ${{ needs.release.outputs.new_tag }} + + publish: + name: publish release + needs: [release, package] + runs-on: [self-hosted, Linux, X64] + permissions: + contents: write + steps: + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${{ needs.release.outputs.new_tag }}" --repo "${{ github.repository }}" --draft=false --latest + deploy-docs: name: Deploy Doxygen docs needs: release diff --git a/editor/App.axaml.cs b/editor/App.axaml.cs index 84bb1eed9..228900b70 100644 --- a/editor/App.axaml.cs +++ b/editor/App.axaml.cs @@ -1,3 +1,6 @@ +using System; +using System.IO; +using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -24,10 +27,20 @@ public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; - desktop.MainWindow = new StartWindow.StartWindow { - DataContext = new StartWindowViewModel() - }; - desktop.MainWindow.Show(); + var vm = new StartWindowViewModel(); + + // Opened by double clicking a .toast file + var projectArg = desktop.Args?.FirstOrDefault(a => + a.EndsWith(".toast", StringComparison.OrdinalIgnoreCase) && File.Exists(a)); + + if (projectArg != null) { + vm.LaunchProject(Path.GetFullPath(projectArg), null); + } else { + desktop.MainWindow = new StartWindow.StartWindow { + DataContext = vm + }; + desktop.MainWindow.Show(); + } } base.OnFrameworkInitializationCompleted(); diff --git a/editor/Components/Modals/LoadingPopup.axaml.cs b/editor/Components/Modals/LoadingPopup.axaml.cs index a6920eccd..ca90b73ec 100644 --- a/editor/Components/Modals/LoadingPopup.axaml.cs +++ b/editor/Components/Modals/LoadingPopup.axaml.cs @@ -9,4 +9,8 @@ public LoadingPopup(string text) { InitializeComponent(); Message.Text = text; } + + public void SetText(string text) { + Message.Text = text; + } } diff --git a/editor/Install/ShellIntegration.cs b/editor/Install/ShellIntegration.cs new file mode 100644 index 000000000..ba352beb0 --- /dev/null +++ b/editor/Install/ShellIntegration.cs @@ -0,0 +1,129 @@ +// +// ShellIntegration.cs by Xein +// + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Win32; + +namespace editor.Install; + +public static class ShellIntegration { + private const string ProgId = "ToastEngine.Project"; + private const string BlockStart = "# >>> toast-engine >>>"; + private const string BlockEnd = "# <<< toast-engine <<<"; + + public static void Apply() { + try { + Environment.SetEnvironmentVariable("TOAST_PATH", BuildInfo.ToastPath); + if (OperatingSystem.IsWindows()) ApplyWindows(); + else if (OperatingSystem.IsLinux()) ApplyLinux(); + } catch (Exception e) { + Trace.WriteLine($"[ShellIntegration] {e}"); + } + } + + [DllImport("shell32.dll")] + private static extern void SHChangeNotify(int eventId, uint flags, IntPtr item1, IntPtr item2); + + private static void ApplyWindows() { + if (!OperatingSystem.IsWindows()) return; + + // Writes HKCU\Environment + if (Environment.GetEnvironmentVariable("TOAST_PATH", EnvironmentVariableTarget.User) != BuildInfo.ToastPath) + Environment.SetEnvironmentVariable("TOAST_PATH", BuildInfo.ToastPath, EnvironmentVariableTarget.User); + + var exe = BuildInfo.EditorExe; + var changed = false; + changed |= SetDefault(@"Software\Classes\.toast", ProgId); + changed |= SetDefault($@"Software\Classes\{ProgId}", "Toast Engine Project"); + changed |= SetDefault($@"Software\Classes\{ProgId}\DefaultIcon", $"\"{exe}\",0"); + changed |= SetDefault($@"Software\Classes\{ProgId}\shell\open\command", $"\"{exe}\" \"%1\""); + + // SHCNE_ASSOCCHANGED, SHCNF_IDLIST + if (changed) SHChangeNotify(0x08000000, 0, IntPtr.Zero, IntPtr.Zero); + } + + private static bool SetDefault(string subKey, string value) { + if (!OperatingSystem.IsWindows()) return false; + using var key = Registry.CurrentUser.CreateSubKey(subKey); + if (key.GetValue(null) as string == value) return false; + key.SetValue(null, value); + return true; + } + + private static void ApplyLinux() { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } c + ? c : Path.Combine(home, ".config"); + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME") is { Length: > 0 } d + ? d : Path.Combine(home, ".local", "share"); + var toastPath = BuildInfo.ToastPath; + + // systemd user sessions + WriteIfChanged(Path.Combine(configHome, "environment.d", "50-toast-engine.conf"), $"TOAST_PATH={toastPath}\n"); + + // Shells + var block = $"{BlockStart}\nexport TOAST_PATH=\"{toastPath}\"\n{BlockEnd}\n"; + foreach (var rc in new[] { ".profile", ".bashrc", ".zshenv" }) { + var path = Path.Combine(home, rc); + if (rc != ".profile" && !File.Exists(path)) continue; + ReplaceBlock(path, block); + } + + // .toast + var exe = BuildInfo.EditorExe; + var mimeChanged = WriteIfChanged(Path.Combine(dataHome, "mime", "packages", "toast-engine.xml"), + """ + + + + Toast Engine Project + + + + """ + "\n"); + + var desktopChanged = WriteIfChanged(Path.Combine(dataHome, "applications", "toast-engine.desktop"), + $"[Desktop Entry]\nType=Application\nName=Toast Engine\nExec=\"{exe}\" %f\n" + + "MimeType=application/x-toast-project;\nCategories=Development;\nTerminal=false\n"); + + if (mimeChanged) Run("update-mime-database", $"\"{Path.Combine(dataHome, "mime")}\""); + if (desktopChanged) { + Run("update-desktop-database", $"\"{Path.Combine(dataHome, "applications")}\""); + Run("xdg-mime", "default toast-engine.desktop application/x-toast-project"); + } + } + + private static bool WriteIfChanged(string path, string content) { + if (File.Exists(path) && File.ReadAllText(path) == content) return false; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content, new UTF8Encoding(false)); + return true; + } + + private static void ReplaceBlock(string path, string block) { + var text = File.Exists(path) ? File.ReadAllText(path) : ""; + var pattern = new Regex($@"{Regex.Escape(BlockStart)}.*?{Regex.Escape(BlockEnd)}\r?\n?", RegexOptions.Singleline); + var updated = pattern.IsMatch(text) + ? pattern.Replace(text, block.Replace("$", "$$"), 1) + : text + (text.Length == 0 || text.EndsWith('\n') ? "" : "\n") + block; + if (updated != text) File.WriteAllText(path, updated, new UTF8Encoding(false)); + } + + private static void Run(string file, string args) { + try { + using var p = Process.Start(new ProcessStartInfo(file, args) { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true + }); + p?.WaitForExit(5000); + } catch { + // Tool not installed + } + } +} diff --git a/editor/Install/Updater.cs b/editor/Install/Updater.cs new file mode 100644 index 000000000..12ba791db --- /dev/null +++ b/editor/Install/Updater.cs @@ -0,0 +1,114 @@ +// +// Updater.cs by Xein +// + +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Threading; + +namespace editor.Install; + +public sealed record UpdateInfo(Version Version, string AssetName, string DownloadUrl); + +public static class Updater { + private static readonly HttpClient Http = CreateClient(); + + private static HttpClient CreateClient() { + var c = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + c.DefaultRequestHeaders.UserAgent.ParseAdd("toast-engine-editor"); + c.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + return c; + } + + private static string ExpectedAsset(Version v) => + OperatingSystem.IsWindows() ? $"ToastEngine-{v}-setup.exe" : $"toast-engine-{v}-linux-x64.tar.gz"; + + public static async Task CheckAsync() { + var current = BuildInfo.Version; + if (current is null) return null; + + try { + var json = await Http.GetStringAsync($"https://api.github.com/repos/{BuildInfo.UpdateRepo}/releases/latest"); + using var doc = JsonDocument.Parse(json); + var tag = doc.RootElement.GetProperty("tag_name").GetString()?.TrimStart('v'); + if (!Version.TryParse(tag, out var latest) || latest <= current) return null; + + var wanted = ExpectedAsset(latest); + foreach (var asset in doc.RootElement.GetProperty("assets").EnumerateArray()) { + if (asset.GetProperty("name").GetString() == wanted) + return new UpdateInfo(latest, wanted, asset.GetProperty("browser_download_url").GetString()!); + } + } catch (Exception e) { + Trace.WriteLine($"[Updater] check failed: {e.Message}"); + } + + return null; + } + + public static async Task ApplyAsync(UpdateInfo info, IProgress? progress = null) { + var dir = Path.Combine(Path.GetTempPath(), "toast-engine-update", info.Version.ToString()); + Directory.CreateDirectory(dir); + var file = Path.Combine(dir, info.AssetName); + await Download(info.DownloadUrl, file, progress); + + if (OperatingSystem.IsWindows()) { + // The installer closes this editor and relaunches the new one + Process.Start(new ProcessStartInfo(file, "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS") { + UseShellExecute = true + }); + } else { + ApplyLinux(file, dir); + } + + Dispatcher.UIThread.Post(() => + (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown()); + } + + private static async Task Download(string url, string dest, IProgress? progress) { + using var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + var total = response.Content.Headers.ContentLength ?? -1; + + await using var src = await response.Content.ReadAsStreamAsync(); + await using var dst = File.Create(dest); + var buffer = new byte[81920]; + long read = 0; + int n; + while ((n = await src.ReadAsync(buffer)) > 0) { + await dst.WriteAsync(buffer.AsMemory(0, n)); + read += n; + if (total > 0) progress?.Report((double)read / total); + } + } + + private static void ApplyLinux(string tarball, string dir) { + var root = BuildInfo.InstallRoot.TrimEnd('/'); + var staged = root + ".new"; + var old = root + ".old"; + if (Directory.Exists(staged)) Directory.Delete(staged, true); + Directory.CreateDirectory(staged); + + using (var tar = Process.Start("tar", $"-xzf \"{tarball}\" -C \"{staged}\" --strip-components=1")) { + tar!.WaitForExit(); + if (tar.ExitCode != 0) throw new IOException("Failed to extract the update"); + } + + var script = Path.Combine(dir, "update.sh"); + File.WriteAllText(script, $""" + #!/bin/sh + while kill -0 {Environment.ProcessId} 2>/dev/null; do sleep 0.2; done + rm -rf "{old}" + mv "{root}" "{old}" && mv "{staged}" "{root}" && rm -rf "{old}" + chmod +x "{root}/editor/editor" 2>/dev/null + exec "{root}/editor/editor" + """.Replace("\r\n", "\n") + "\n"); + + Process.Start(new ProcessStartInfo("setsid", $"sh \"{script}\"") { UseShellExecute = false }); + } +} diff --git a/editor/Program.cs b/editor/Program.cs index c5a79b2fd..f30c30d0b 100644 --- a/editor/Program.cs +++ b/editor/Program.cs @@ -4,9 +4,12 @@ // using System; +using System.Linq; +using System.Threading.Tasks; using Avalonia; using editor.Engine; using editor.Git; +using editor.Install; namespace editor; @@ -16,6 +19,14 @@ public static int Main(string[] args) { // Launched by git or ssh as the askpass program if (GitAskpass.IsHelperInvocation) return GitAskpass.RunHelper(args); + // Used by the Linux install script + if (args.Contains("--register")) { + ShellIntegration.Apply(); + return 0; + } + + Task.Run(ShellIntegration.Apply); + return BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); } diff --git a/editor/StartWindow/NewProjectWindow.axaml.cs b/editor/StartWindow/NewProjectWindow.axaml.cs index 40add32fd..f4e4f04a7 100644 --- a/editor/StartWindow/NewProjectWindow.axaml.cs +++ b/editor/StartWindow/NewProjectWindow.axaml.cs @@ -67,6 +67,8 @@ private async void Folder_OnClick(object? sender, RoutedEventArgs e) { UpdateProjectData(); } + private static string Res(string relative) => Path.Combine(AppContext.BaseDirectory, "Resources", relative); + private async void Create_OnClick(object? sender, RoutedEventArgs e) { UpdateProjectData(); ProjectVersion = [1, 0, 0]; @@ -99,17 +101,17 @@ private async void Create_OnClick(object? sender, RoutedEventArgs e) { Directory.CreateDirectory(Path.Combine(m_projectFolder, "build")); // Gitignore - File.Copy("Resources/files/project.gitignore", Path.Combine(m_projectFolder, ".gitignore")); - File.Copy("Resources/files/project.luarc.json", Path.Combine(m_projectFolder, ".luarc.json")); + File.Copy(Res("files/project.gitignore"), Path.Combine(m_projectFolder, ".gitignore")); + File.Copy(Res("files/project.luarc.json"), Path.Combine(m_projectFolder, ".luarc.json")); // C++ library Directory.CreateDirectory(Path.Combine(m_projectFolder, "lib", "src")); Directory.CreateDirectory(Path.Combine(m_projectFolder, "lib", "src", "_detail")); - File.Copy("Resources/files/lib/CMakeLists.txt", Path.Combine(m_projectFolder, "lib", "CMakeLists.txt")); - File.Copy("Resources/files/lib/src/my_game.hpp", Path.Combine(m_projectFolder, "lib", "src", "my_game.hpp")); - File.Copy("Resources/files/lib/src/_detail/game.h", + File.Copy(Res("files/lib/CMakeLists.txt"), Path.Combine(m_projectFolder, "lib", "CMakeLists.txt")); + File.Copy(Res("files/lib/src/my_game.hpp"), Path.Combine(m_projectFolder, "lib", "src", "my_game.hpp")); + File.Copy(Res("files/lib/src/_detail/game.h"), Path.Combine(m_projectFolder, "lib", "src", "_detail", "game.h")); - File.Copy("Resources/files/lib/src/_detail/game.cpp", + File.Copy(Res("files/lib/src/_detail/game.cpp"), Path.Combine(m_projectFolder, "lib", "src", "_detail", "game.cpp")); Close(true); diff --git a/editor/StartWindow/StartWindowViewModel.cs b/editor/StartWindow/StartWindowViewModel.cs index a9cb40f64..a5b2e351b 100644 --- a/editor/StartWindow/StartWindowViewModel.cs +++ b/editor/StartWindow/StartWindowViewModel.cs @@ -20,18 +20,19 @@ using editor.Assets; using editor.Components.Modals; using editor.Engine; +using editor.Install; using Lucide.Avalonia; namespace editor.StartWindow; public partial class StartWindowViewModel : ViewModelBase { - private static readonly string ToastPath = - Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "../toast_engine")); + private static readonly string ToastPath = BuildInfo.ToastPath; private readonly ProjectList m_projectList = ProjectList.LoadList(); [ObservableProperty] private string m_searchText = ""; private Window? m_window; + private bool m_updateChecked; // Fake data for the previewer public StartWindowViewModel() { @@ -39,6 +40,36 @@ public StartWindowViewModel() { RebuildFilteredProjects(); } + private async Task CheckForUpdate(Window owner) { + if (m_updateChecked) return; + m_updateChecked = true; + + var info = await Updater.CheckAsync(); + if (info is null || !owner.IsVisible) return; + + var modal = new MessageModal(new ModalConfig( + "Update available", + $"Toast Engine v{info.Version} is available, you are on v{BuildInfo.Version}.\n" + + "Updating will restart the editor.", + ModalButtons.OkCancel, + LucideIconKind.Download, + new SolidColorBrush(Color.Parse("#4a9eff")), + "Update", + CancelLabel: "Later" + )); + if (await modal.ShowDialog(owner) != true) return; + + var popup = new LoadingPopup($"Downloading Toast Engine v{info.Version}..."); + popup.Show(owner); + try { + await Updater.ApplyAsync(info, new Progress(p => + popup.SetText($"Downloading Toast Engine v{info.Version}... {p:P0}"))); + } catch (Exception e) { + popup.Close(); + await App.Modals.ShowError("Update failed", e.Message); + } + } + public ObservableCollection Projects => m_projectList.Projects; public ObservableCollection FilteredProjects { get; } = []; @@ -68,9 +99,10 @@ private void RebuildFilteredProjects() { public void SetWindow(Window window) { m_window = window; + _ = CheckForUpdate(window); } - private void LaunchProject(string projectPath, Window parentWindow) { + internal void LaunchProject(string projectPath, Window? parentWindow) { m_projectList.Upsert(projectPath); m_projectList.SaveList(); @@ -169,7 +201,7 @@ private void LaunchProject(string projectPath, Window parentWindow) { var desktop = (IClassicDesktopStyleApplicationLifetime)Application.Current!.ApplicationLifetime!; desktop.MainWindow = new SplashLoaderWindow(vm); desktop.MainWindow.Show(); - parentWindow.Close(); + parentWindow?.Close(); } private async Task LaunchProjectFromList(string projectPath, Window parentWindow) { diff --git a/editor/Workspace/MainWindowViewModel.cs b/editor/Workspace/MainWindowViewModel.cs index a89caf1c9..a16c5e51a 100644 --- a/editor/Workspace/MainWindowViewModel.cs +++ b/editor/Workspace/MainWindowViewModel.cs @@ -1283,7 +1283,7 @@ await Task.Run(() => { LoaderTask.Run( $"cmake configure ({gameConfig})", "cmake", - $"lib/ -B {cmakeCache} {cmakeGenerator} -DTOAST_PATH={toastPath} -DCMAKE_BUILD_TYPE={gameConfig}" + $"lib/ -B {cmakeCache} {cmakeGenerator} -DTOAST_PATH=\"{toastPath}\" -DCMAKE_BUILD_TYPE={gameConfig}" ), // Build Game DLL LoaderTask.Run( diff --git a/tools/installer/exclude.txt b/tools/installer/exclude.txt new file mode 100644 index 000000000..18517dfc8 --- /dev/null +++ b/tools/installer/exclude.txt @@ -0,0 +1,5 @@ +cargo +tests +dummy_game +kenzo-build +.codegen.lock diff --git a/tools/installer/linux/install.sh b/tools/installer/linux/install.sh new file mode 100644 index 000000000..78597e6b9 --- /dev/null +++ b/tools/installer/linux/install.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Installs Toast Engine for the current user (no root needed) +set -eu + +src="$(cd "$(dirname "$0")" && pwd)" +dest="${XDG_DATA_HOME:-$HOME/.local/share}/toast-engine" + +if [ "$src" != "$dest" ]; then + rm -rf "$dest.old" + [ -d "$dest" ] && mv "$dest" "$dest.old" + mkdir -p "$(dirname "$dest")" + cp -r "$src" "$dest" + rm -rf "$dest.old" +fi + +rm -f "$dest/install.sh" +chmod +x "$dest/editor/editor" 2>/dev/null || true +for exe in "$dest"/reflection_generator/reflection_generator "$dest"/packer/* "$dest"/crash_reporter/crash_reporter "$dest"/toast_engine/bin/log_server; do + [ -f "$exe" ] && chmod +x "$exe" 2>/dev/null || true +done + +"$dest/editor/editor" --register + +echo "Toast Engine installed to $dest" +echo "Open a new shell (or log in again) for TOAST_PATH to take effect" diff --git a/tools/installer/toast-engine.iss b/tools/installer/toast-engine.iss new file mode 100644 index 000000000..285865e5d --- /dev/null +++ b/tools/installer/toast-engine.iss @@ -0,0 +1,79 @@ +; Build with: iscc /DAppVersion=2.8.0 /DSourceDir= toast-engine.iss + +#ifndef AppVersion + #error AppVersion not defined +#endif +#ifndef SourceDir + #error SourceDir not defined +#endif + +[Setup] +AppId={{6B0F2C1E-5D8A-4E3B-9C41-7A2F8E5D1B30} +AppName=Toast Engine +AppVersion={#AppVersion} +AppPublisher=nullptr Studios +DefaultDirName={localappdata}\Programs\Toast Engine +DefaultGroupName=Toast Engine +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +CloseApplications=yes +RestartApplications=no +ChangesAssociations=yes +ChangesEnvironment=yes +OutputBaseFilename=ToastEngine-{#AppVersion}-setup +Compression=lzma2 +SolidCompression=yes +UninstallDisplayIcon={app}\editor\editor.exe +WizardStyle=modern + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; Flags: unchecked + +[InstallDelete] +; Do not leave files of the previous version behind +Type: filesandordirs; Name: "{app}\editor" +Type: filesandordirs; Name: "{app}\toast_engine" +Type: filesandordirs; Name: "{app}\reflection_generator" +Type: filesandordirs; Name: "{app}\packer" +Type: filesandordirs; Name: "{app}\crash_reporter" +Type: filesandordirs; Name: "{app}\log_server" +Type: filesandordirs; Name: "{app}\player" + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion + +[Icons] +Name: "{autoprograms}\Toast Engine"; Filename: "{app}\editor\editor.exe"; WorkingDir: "{app}\editor" +Name: "{autodesktop}\Toast Engine"; Filename: "{app}\editor\editor.exe"; WorkingDir: "{app}\editor"; Tasks: desktopicon + +[Registry] +Root: HKCU; Subkey: "Software\Classes\.toast"; ValueType: string; ValueData: "ToastEngine.Project"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\ToastEngine.Project"; ValueType: string; ValueData: "Toast Engine Project"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\ToastEngine.Project\DefaultIcon"; ValueType: string; ValueData: """{app}\editor\editor.exe"",0" +Root: HKCU; Subkey: "Software\Classes\ToastEngine.Project\shell\open\command"; ValueType: string; ValueData: """{app}\editor\editor.exe"" ""%1""" +Root: HKCU; Subkey: "Environment"; ValueType: string; ValueName: "TOAST_PATH"; ValueData: "{app}\toast_engine"; Flags: uninsdeletevalue + +[Run] +; Interactive install +Filename: "{app}\editor\editor.exe"; WorkingDir: "{app}\editor"; Description: "Launch Toast Engine"; Flags: nowait postinstall skipifsilent +; Update started from the editor +Filename: "{app}\editor\editor.exe"; WorkingDir: "{app}\editor"; Flags: nowait; Check: WizardSilent + +[Code] +function DotNetDesktop8Installed: Boolean; +begin + Result := DirExists(ExpandConstant('{pf}\dotnet\shared\Microsoft.WindowsDesktop.App')) or + DirExists(ExpandConstant('{localappdata}\Microsoft\dotnet\shared\Microsoft.WindowsDesktop.App')); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + ErrorCode: Integer; +begin + if (CurStep = ssPostInstall) and (not WizardSilent) and (not DotNetDesktop8Installed) then + if MsgBox('Toast Engine needs the .NET 8 Desktop Runtime, which was not found.' + #13#10 + + 'Open the download page now?', mbConfirmation, MB_YESNO) = IDYES then + ShellExec('open', 'https://dotnet.microsoft.com/download/dotnet/8.0', '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode); +end; From d2a1cfe1ec16dbe1e18916f3a34d5057c99a588b Mon Sep 17 00:00:00 2001 From: Xein Date: Tue, 10 Nov 2026 00:32:47 +0100 Subject: [PATCH 2/2] gitignore fix --- .gitignore | 1 + editor/Install/BuildInfo.cs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 editor/Install/BuildInfo.cs diff --git a/.gitignore b/.gitignore index 7942c6b9c..734ed938f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ logs/ **[Oo]bj** **[Tt]arget** !**/CrashTarget.cs +!**/BuildInfo.cs [Oo]ut engine/include/ vcpkg_installed/ diff --git a/editor/Install/BuildInfo.cs b/editor/Install/BuildInfo.cs new file mode 100644 index 000000000..b6f51ab2e --- /dev/null +++ b/editor/Install/BuildInfo.cs @@ -0,0 +1,34 @@ +// +// BuildInfo.cs +// Where this editor is installed and which release it came from +// + +using System; +using System.IO; + +namespace editor.Install; + +public static class BuildInfo { + public static string InstallRoot { get; } = + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..")); + + public static string ToastPath { get; } = + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "toast_engine")); + + public static string EditorExe => Environment.ProcessPath ?? Path.Combine(AppContext.BaseDirectory, "editor"); + + public static Version? Version { get; } = ReadVersion(); + + public static string UpdateRepo => + Environment.GetEnvironmentVariable("TOAST_UPDATE_REPO") is { Length: > 0 } r ? r : "nullptr-studios/toast-engine"; + + private static Version? ReadVersion() { + try { + var file = Path.Combine(InstallRoot, "version.txt"); + if (!File.Exists(file)) return null; + return System.Version.TryParse(File.ReadAllText(file).Trim().TrimStart('v'), out var v) ? v : null; + } catch { + return null; + } + } +}