From 53da00fa893ee7d6b727ed973d9f615b2c048ddf Mon Sep 17 00:00:00 2001
From: mskomek <32844718+mskomek@users.noreply.github.com>
Date: Thu, 16 Jul 2026 14:01:42 +0300
Subject: [PATCH] GUI: add cross-platform release updater (#243)
---
src/SharpEmu.CLI/Program.cs | 5 +
src/SharpEmu.GUI/GuiSettings.cs | 2 +
src/SharpEmu.GUI/Languages/en.json | 17 +-
src/SharpEmu.GUI/Languages/tr.json | 17 +-
src/SharpEmu.GUI/MainWindow.axaml | 20 +++
src/SharpEmu.GUI/MainWindow.axaml.cs | 86 ++++++++-
src/SharpEmu.GUI/Updater.cs | 253 +++++++++++++++++++++++++++
7 files changed, 397 insertions(+), 3 deletions(-)
create mode 100644 src/SharpEmu.GUI/Updater.cs
diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs
index c0dd2bb9..8343f619 100644
--- a/src/SharpEmu.CLI/Program.cs
+++ b/src/SharpEmu.CLI/Program.cs
@@ -63,6 +63,11 @@ internal static partial class Program
private static int Run(string[] args)
{
+ if (Updater.TryApply(args, out var updateExitCode))
+ {
+ return updateExitCode;
+ }
+
args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild)
{
diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs
index d07a4949..441eb74f 100644
--- a/src/SharpEmu.GUI/GuiSettings.cs
+++ b/src/SharpEmu.GUI/GuiSettings.cs
@@ -48,6 +48,8 @@ public sealed class GuiSettings
/// Publish launcher/game status to Discord Rich Presence.
public bool DiscordRichPresence { get; set; } = true;
+ public bool CheckForUpdatesOnStartup { get; set; } = true;
+
/// Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.
public List EnvironmentToggles { get; set; } = new();
diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json
index 803dd63b..8129206b 100644
--- a/src/SharpEmu.GUI/Languages/en.json
+++ b/src/SharpEmu.GUI/Languages/en.json
@@ -144,5 +144,20 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Join the community, get support and follow development.",
"About.GithubButton": "Contribute in GitHub!",
- "About.DiscordButton": "Join our Discord!"
+ "About.DiscordButton": "Join our Discord!",
+
+ "Updater.Auto.Label": "Check for updates on startup",
+ "Updater.Auto.Desc": "Checks GitHub without delaying startup.",
+ "Updater.Label": "Updates",
+ "Updater.Check": "Check for updates",
+ "Updater.DownloadRestart": "Download and restart",
+ "Updater.Status.Ready": "Current build: {0}",
+ "Updater.Status.Checking": "Checking for updates…",
+ "Updater.Status.Current": "You are up to date ({0}).",
+ "Updater.Status.Available": "A new build is available: {0}",
+ "Updater.Status.Downloading": "Downloading update… {0}%",
+ "Updater.Status.Installing": "Installing update…",
+ "Updater.Status.Timeout": "Update check timed out after 10 seconds.",
+ "Updater.Status.Failed": "Could not check for updates.",
+ "Updater.Status.Unsupported": "Automatic updating requires a Windows, Linux or macOS x64 build."
}
diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json
index 22e76d5f..957fa806 100644
--- a/src/SharpEmu.GUI/Languages/tr.json
+++ b/src/SharpEmu.GUI/Languages/tr.json
@@ -125,5 +125,20 @@
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
- "Dialog.LogFiles": "Günlük Dosyaları"
+ "Dialog.LogFiles": "Günlük Dosyaları",
+
+ "Updater.Auto.Label": "Açılışta güncellemeleri denetle",
+ "Updater.Auto.Desc": "Açılışı geciktirmeden GitHub'ı denetler.",
+ "Updater.Label": "Güncellemeler",
+ "Updater.Check": "Güncellemeleri denetle",
+ "Updater.DownloadRestart": "İndir ve yeniden başlat",
+ "Updater.Status.Ready": "Mevcut build: {0}",
+ "Updater.Status.Checking": "Güncellemeler denetleniyor…",
+ "Updater.Status.Current": "Güncelsiniz ({0}).",
+ "Updater.Status.Available": "Yeni build mevcut: {0}",
+ "Updater.Status.Downloading": "Güncelleme indiriliyor… %{0}",
+ "Updater.Status.Installing": "Güncelleme kuruluyor…",
+ "Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
+ "Updater.Status.Failed": "Güncellemeler denetlenemedi.",
+ "Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
}
diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml
index 4b5b69ad..223e37c5 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml
+++ b/src/SharpEmu.GUI/MainWindow.axaml
@@ -315,6 +315,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
+
+
+
+
+
@@ -322,6 +332,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
+
+
+
+
+
+
+
+
diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs
index fab6c8a0..83ec3e68 100644
--- a/src/SharpEmu.GUI/MainWindow.axaml.cs
+++ b/src/SharpEmu.GUI/MainWindow.axaml.cs
@@ -57,6 +57,9 @@ public partial class MainWindow : Window
private bool _isRunning;
private int _autoScrollTicks;
private int _activePageIndex;
+ private Updater.UpdateInfo? _availableUpdate;
+ private string _updateStatusKey = "Updater.Status.Ready";
+ private object?[] _updateStatusArgs = [BuildInfo.CommitSha ?? "dev"];
// Discord Rich Presence state.
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -131,6 +134,9 @@ public partial class MainWindow : Window
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
UpdateDiscordPresence();
};
+ AutoUpdateToggle.IsCheckedChanged += (_, _) =>
+ _settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
+ UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
EnvBthidToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_BTHID_UNAVAILABLE", EnvBthidToggle.IsChecked == true);
@@ -375,6 +381,10 @@ public partial class MainWindow : Window
ApplySettingsToControls();
LocateEmulator();
UpdateDiscordPresence();
+ if (_settings.CheckForUpdatesOnStartup)
+ {
+ _ = CheckForUpdatesAsync();
+ }
await RescanLibraryAsync();
}
@@ -478,8 +488,10 @@ public partial class MainWindow : Window
DiscordLabel.Text = loc.Get("Options.Discord.Label");
DiscordDesc.Text = loc.Get("Options.Discord.Desc");
+ AutoUpdateLabel.Text = loc.Get("Updater.Auto.Label");
+ AutoUpdateDesc.Text = loc.Get("Updater.Auto.Desc");
- foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle })
+ foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
{
toggle.OnContent = loc.Get("Common.On");
toggle.OffContent = loc.Get("Common.Off");
@@ -503,6 +515,8 @@ public partial class MainWindow : Window
DiscordServerDesc.Text = loc.Get("About.Discord.Desc");
GithubButton.Content = loc.Get("About.GithubButton");
DiscordButton.Content = loc.Get("About.DiscordButton");
+ UpdateLabel.Text = loc.Get("Updater.Label");
+ RefreshUpdateText();
UpdateEmptyStateTexts();
UpdateSelectedGameTexts();
@@ -618,6 +632,7 @@ public partial class MainWindow : Window
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
+ AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
EnvLoopGuardToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD");
EnvWritableApp0Toggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_WRITABLE_APP0");
@@ -629,6 +644,75 @@ public partial class MainWindow : Window
UpdateLogFilePathText();
}
+ private async Task OnUpdateButtonAsync()
+ {
+ if (_availableUpdate is null)
+ {
+ await CheckForUpdatesAsync();
+ return;
+ }
+
+ UpdateButton.IsEnabled = false;
+ try
+ {
+ var progress = new Progress(value =>
+ SetUpdateStatus("Updater.Status.Downloading", value));
+ await Updater.DownloadAndRestartAsync(_availableUpdate, progress);
+ SetUpdateStatus("Updater.Status.Installing");
+ Close();
+ }
+ catch
+ {
+ SetUpdateStatus("Updater.Status.Failed");
+ UpdateButton.IsEnabled = true;
+ }
+ }
+
+ private async Task CheckForUpdatesAsync()
+ {
+ _availableUpdate = null;
+ UpdateButton.IsEnabled = false;
+ SetUpdateStatus("Updater.Status.Checking");
+ try
+ {
+ _availableUpdate = await Updater.CheckAsync(BuildInfo.CommitSha);
+ SetUpdateStatus(
+ _availableUpdate is null ? "Updater.Status.Current" : "Updater.Status.Available",
+ _availableUpdate?.Sha ?? BuildInfo.CommitSha ?? "dev");
+ }
+ catch (OperationCanceledException)
+ {
+ SetUpdateStatus("Updater.Status.Timeout");
+ }
+ catch (PlatformNotSupportedException)
+ {
+ SetUpdateStatus("Updater.Status.Unsupported");
+ }
+ catch
+ {
+ SetUpdateStatus("Updater.Status.Failed");
+ }
+ finally
+ {
+ UpdateButton.IsEnabled = true;
+ RefreshUpdateText();
+ }
+ }
+
+ private void SetUpdateStatus(string key, params object?[] args)
+ {
+ _updateStatusKey = key;
+ _updateStatusArgs = args;
+ RefreshUpdateText();
+ }
+
+ private void RefreshUpdateText()
+ {
+ UpdateStatusText.Text = Localization.Instance.Format(_updateStatusKey, _updateStatusArgs);
+ UpdateButton.Content = Localization.Instance.Get(
+ _availableUpdate is null ? "Updater.Check" : "Updater.DownloadRestart");
+ }
+
// Environment variables set on this process at the previous launch; children
// inherit the process environment, so stale names must be cleared explicitly.
private readonly HashSet _appliedEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase);
diff --git a/src/SharpEmu.GUI/Updater.cs b/src/SharpEmu.GUI/Updater.cs
new file mode 100644
index 00000000..57dc1333
--- /dev/null
+++ b/src/SharpEmu.GUI/Updater.cs
@@ -0,0 +1,253 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Diagnostics;
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Net.Http.Headers;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+
+namespace SharpEmu.GUI;
+
+/// Self-contained Windows updater; the emulator layers do not depend on it.
+public static class Updater
+{
+ private const string ApplyArgument = "--sharpemu-apply-update";
+ private const string LatestReleaseUrl = "https://api.github.com/repos/sharpemu/sharpemu/releases/latest";
+ private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
+ private static readonly HttpClient Http = CreateHttpClient();
+
+ public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size);
+
+ public static async Task CheckAsync(string? currentSha, CancellationToken cancellationToken = default)
+ {
+ var platform = CurrentPlatform();
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(CheckTimeout);
+
+ using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token);
+ response.EnsureSuccessStatusCode();
+ return ParseRelease(
+ await response.Content.ReadAsStringAsync(timeout.Token),
+ currentSha,
+ platform.Rid,
+ platform.Extension);
+ }
+
+ public static async Task DownloadAndRestartAsync(
+ UpdateInfo update,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ var root = Path.Combine(Path.GetTempPath(), "SharpEmu.Update");
+ var payload = Path.Combine(root, "payload");
+ if (Directory.Exists(root))
+ {
+ Directory.Delete(root, recursive: true);
+ }
+
+ Directory.CreateDirectory(root);
+ var archive = Path.Combine(root, update.Name);
+ using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
+ {
+ response.EnsureSuccessStatusCode();
+ await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
+ await using var output = File.Create(archive);
+ var buffer = new byte[81920];
+ long written = 0;
+ int read;
+ while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
+ {
+ await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
+ written += read;
+ progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size));
+ }
+
+ if (written != update.Size)
+ {
+ throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}.");
+ }
+ }
+
+ var platform = CurrentPlatform();
+ var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
+
+ var start = new ProcessStartInfo(stagedExe)
+ {
+ UseShellExecute = false,
+ WorkingDirectory = payload,
+ };
+ start.ArgumentList.Add(ApplyArgument);
+ start.ArgumentList.Add(Environment.ProcessId.ToString());
+ start.ArgumentList.Add(AppContext.BaseDirectory);
+ using var helper = Process.Start(start)
+ ?? throw new InvalidOperationException("The update installer could not be started.");
+ }
+
+ /// Runs from the downloaded executable after the old GUI exits.
+ public static bool TryApply(string[] args, out int exitCode)
+ {
+ exitCode = 0;
+ if (args.Length != 3 || args[0] != ApplyArgument)
+ {
+ return false;
+ }
+
+ try
+ {
+ if (int.TryParse(args[1], out var oldPid))
+ {
+ try
+ {
+ if (!Process.GetProcessById(oldPid).WaitForExit(30_000))
+ {
+ throw new TimeoutException("SharpEmu did not close within 30 seconds.");
+ }
+ }
+ catch (ArgumentException)
+ {
+ // The old process has already exited.
+ }
+ }
+
+ var source = AppContext.BaseDirectory;
+ var target = Path.GetFullPath(args[2]);
+ foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(source, file);
+ if (relative.Equals("gui-settings.json", StringComparison.OrdinalIgnoreCase) ||
+ relative.StartsWith("user" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
+ relative.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
+ relative.StartsWith("Languages" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var destination = Path.Combine(target, relative);
+ Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+ File.Copy(file, destination, overwrite: true);
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(destination, File.GetUnixFileMode(file));
+ }
+ }
+
+ using var restarted = Process.Start(new ProcessStartInfo(
+ Path.Combine(target, CurrentPlatform().ExecutableName))
+ {
+ UseShellExecute = false,
+ WorkingDirectory = target,
+ }) ?? throw new InvalidOperationException("The updated SharpEmu could not be started.");
+ }
+ catch (Exception ex)
+ {
+ exitCode = 1;
+ try
+ {
+ File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString());
+ }
+ catch
+ {
+ // Best-effort diagnostics only.
+ }
+ }
+
+ return true;
+ }
+
+ private static UpdateInfo? ParseRelease(
+ string json,
+ string? currentSha,
+ string rid,
+ string extension)
+ {
+ using var document = JsonDocument.Parse(json);
+ var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>();
+ foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray())
+ {
+ var name = asset.GetProperty("name").GetString() ?? "";
+ var marker = $"-{rid}-";
+ var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ||
+ markerIndex < 0)
+ {
+ continue;
+ }
+
+ var sha = name[(markerIndex + marker.Length)..^extension.Length];
+ if (sha.Length < 7 || !sha.All(Uri.IsHexDigit))
+ {
+ continue;
+ }
+
+ candidates.Add((
+ asset.GetProperty("created_at").GetDateTimeOffset(),
+ new UpdateInfo(
+ sha,
+ name,
+ asset.GetProperty("browser_download_url").GetString()!,
+ asset.GetProperty("size").GetInt64())));
+ }
+
+ var latest = candidates.OrderByDescending(candidate => candidate.Created).FirstOrDefault().Update;
+ return latest is null || string.Equals(latest.Sha, currentSha, StringComparison.OrdinalIgnoreCase)
+ ? null
+ : latest;
+ }
+
+ private static string ExtractArchive(
+ string archive,
+ string payload,
+ string extension,
+ string executableName)
+ {
+ if (extension == ".zip")
+ {
+ ZipFile.ExtractToDirectory(archive, payload);
+ }
+ else
+ {
+ Directory.CreateDirectory(payload);
+ using var compressed = File.OpenRead(archive);
+ using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
+ TarFile.ExtractToDirectory(gzip, payload, overwriteFiles: false);
+ }
+
+ var executable = Path.Combine(payload, executableName);
+ if (!File.Exists(executable))
+ {
+ throw new InvalidDataException($"The update archive does not contain {executableName}.");
+ }
+
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(executable, File.GetUnixFileMode(executable) | UnixFileMode.UserExecute);
+ }
+
+ return executable;
+ }
+
+ private static HttpClient CreateHttpClient()
+ {
+ var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
+ client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("SharpEmu", "0.0.1"));
+ client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ return client;
+ }
+
+ private static PlatformInfo CurrentPlatform()
+ {
+ if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
+ {
+ throw new PlatformNotSupportedException("SharpEmu releases require an x64 process.");
+ }
+
+ if (OperatingSystem.IsWindows()) return new("win-x64", ".zip", "SharpEmu.exe");
+ if (OperatingSystem.IsLinux()) return new("linux-x64", ".tar.gz", "SharpEmu");
+ if (OperatingSystem.IsMacOS()) return new("osx-x64", ".tar.gz", "SharpEmu");
+ throw new PlatformNotSupportedException();
+ }
+
+ private sealed record PlatformInfo(string Rid, string Extension, string ExecutableName);
+}