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/.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/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/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;
+ }
+ }
+}
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;