v1.1.5 — self-hosted auto-updater + flicker-free startup update prompt

- Updater now queries the project's own update server instead of GitLab/GitHub
- Update install hardened: retry on transient file lock (antivirus), install on
  the UI thread, and a guarded single-thread fallback that can't hard-crash
- Startup update prompt renders inline on the main window when no game is running,
  removing the cold-start dialog flicker (the separate overlay window is only used
  while a game is running, where it is required above the native render surface)
- Drop the startup wait band-aid; keep a one-shot guard so the prompt shows once
- Changelog links point at the Beast Roofer Gitea releases
This commit is contained in:
2026-06-22 17:21:18 -04:00
parent cd3b228037
commit c08dec9579
5 changed files with 125 additions and 67 deletions
+1 -3
View File
@@ -28,9 +28,7 @@ namespace Ryujinx.Common
public static string Version => IsValid ? BuildVersion : Assembly.GetEntryAssembly()!.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
public static string GetChangelogUrl(Version currentVersion, Version newVersion) =>
IsCanaryBuild
? $"https://git.ryujinx.app/projects/Ryubing/compare/Canary-{currentVersion}...Canary-{newVersion}"
: $"https://git.ryujinx.app/projects/Ryubing/releases/tag/{newVersion}";
$"https://thebeastroofer-edition.com/rooferdev/the-beast-roofer-edition/releases/tag/v{newVersion}";
}
+35 -57
View File
@@ -1,58 +1,35 @@
using Gommon;
// The Beast Roofer Edition -- update check.
// Original Ryubing flow talked to their UpdateServer via a client library.
// This fork talks to our OWN update server (server.py) with a simple JSON
// response: { "version": "...", "url": "...", "notes": "..." }.
using Gommon;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Common;
using Ryujinx.Common.Helper;
using Ryujinx.Common.Logging;
using Ryujinx.Systems.Update.Client;
using Ryujinx.Systems.Update.Common;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Ryujinx.Ava.Systems
{
internal static partial class Updater
{
private static VersionResponse _versionResponse;
private static UpdateClient _updateClient;
// Our own update server (fronted by Caddy on the Beast Roofer host).
private const string UpdateEndpoint = "https://thebeastroofer-edition.com/updates/latest";
private static async Task<Return<VersionResponse>> QueryLatestVersionAsync()
// The download URL of the latest build artifact, set by CheckVersionAsync.
private static string _artifactUrl;
// Minimal shape returned by server.py /latest.
private sealed class BeastUpdateInfo
{
_updateClient ??= UpdateClient.Builder()
.WithServerEndpoint("https://update.ryujinx.app") // This is the default, and doesn't need to be provided; it's here for transparency.
.WithLogger((format, args, caller) =>
Logger.Info?.Print(
LogClass.Application,
args.Length is 0 ? format : format.Format(args),
caller: caller)
);
try
{
return await _updateClient.QueryLatestAsync(ReleaseInformation.IsCanaryBuild
? ReleaseChannel.Canary
: ReleaseChannel.Stable);
}
catch (HttpRequestException hre)
when (hre.HttpRequestError is HttpRequestError.ConnectionError)
{
return Return<VersionResponse>.Failure(
new MessageError("Connection error occurred. Is your internet down?"));
}
catch (HttpRequestException hre)
when (hre.HttpRequestError is HttpRequestError.NameResolutionError)
{
return Return<VersionResponse>.Failure(
new MessageError("DNS resolution error occurred. Is your internet down?"));
}
catch (HttpRequestException hre)
when (hre.StatusCode is HttpStatusCode.BadGateway)
{
return Return<VersionResponse>.Failure(
new MessageError("Could not connect to the update server, but it appears like you have internet. It seems like the update server is offline, try again later."));
}
[JsonPropertyName("version")] public string Version { get; set; }
[JsonPropertyName("url")] public string Url { get; set; }
[JsonPropertyName("notes")] public string Notes { get; set; }
}
public static async Task<Optional<(Version Current, Version Incoming)>> CheckVersionAsync(bool showVersionUpToDate = false)
@@ -71,34 +48,33 @@ namespace Ryujinx.Ava.Systems
return default;
}
BeastUpdateInfo info;
try
{
_versionResponse = await QueryLatestVersionAsync().Then(x => x.Unwrap());
using HttpClient client = ConstructHttpClient();
client.Timeout = TimeSpan.FromSeconds(10);
info = await client.GetFromJsonAsync<BeastUpdateInfo>(UpdateEndpoint);
}
catch (Exception e)
{
Logger.Error?.Print(LogClass.Application, $"{e.GetType().AsPrettyString()} thrown when requesting updates: {e.Message}");
Logger.Error?.Print(LogClass.Application,
$"{e.GetType().AsPrettyString()} thrown when requesting updates: {e.Message}");
_running = false;
return default;
}
if (_versionResponse == null)
{
// logging is done via the UpdateClient library
_running = false;
return default;
}
// If build URL not found, assume no new update is available.
if (string.IsNullOrEmpty(_versionResponse.ArtifactUrl))
// If no artifact URL was returned, assume no update is available.
if (info == null || string.IsNullOrEmpty(info.Url))
{
if (showVersionUpToDate)
{
await ContentDialogHelper.CreateUpdaterUpToDateInfoDialog(
LocaleManager.Instance[LocaleKeys.DialogUpdaterAlreadyOnLatestVersionMessage],
string.Empty,
_versionResponse.ReleaseUrlFormat.Format(currentVersion));
string.Empty,
ReleaseInformation.GetChangelogUrl(currentVersion, currentVersion));
}
Logger.Info?.Print(LogClass.Application, "Up to date.");
@@ -108,8 +84,7 @@ namespace Ryujinx.Ava.Systems
return default;
}
if (!Version.TryParse(_versionResponse.Version, out Version newVersion))
if (!Version.TryParse(info.Version, out Version newVersion))
{
Logger.Error?.Print(LogClass.Application,
$"Failed to convert the received {RyujinxApp.FullAppName} version from the update server!");
@@ -123,7 +98,10 @@ namespace Ryujinx.Ava.Systems
return default;
}
_connectionCount = (int)_versionResponse.MaxConcurrency;
_artifactUrl = info.Url;
// Single download stream -- simplest and most reliable.
_connectionCount = 1;
return (currentVersion, newVersion);
}
+66 -7
View File
@@ -63,7 +63,7 @@ namespace Ryujinx.Ava.Systems
await ContentDialogHelper.CreateUpdaterUpToDateInfoDialog(
LocaleManager.Instance[LocaleKeys.DialogUpdaterAlreadyOnLatestVersionMessage],
string.Empty,
changelogUrl: _versionResponse.ReleaseUrlFormat.Format(currentVersion));
changelogUrl: ReleaseInformation.GetChangelogUrl(currentVersion, currentVersion));
}
Logger.Info?.Print(LogClass.Application, "Up to date.");
@@ -75,6 +75,10 @@ namespace Ryujinx.Ava.Systems
await Dispatcher.UIThread.InvokeAsync(async () =>
{
// Surface a persistent "update available" indicator in the status bar, so the
// user can still grab this update later even if they dismiss this prompt.
RyujinxApp.MainWindow.ViewModel.UpdateAvailable = true;
string newVersionString = ReleaseInformation.IsCanaryBuild
? $"Canary {currentVersion} → Canary {newVersion}"
: $"{currentVersion} → {newVersion}";
@@ -91,7 +95,7 @@ namespace Ryujinx.Ava.Systems
switch (shouldUpdate)
{
case UserResult.Yes:
await UpdateRyujinx(_versionResponse.ArtifactUrl);
await UpdateRyujinx(_artifactUrl);
break;
default:
_running = false;
@@ -150,7 +154,7 @@ namespace Ryujinx.Ava.Systems
// Forgejo instance is located in Ukraine. Connection times will vary across the world.
buildSizeClient.Timeout = TimeSpan.FromSeconds(10);
HttpResponseMessage message = await buildSizeClient.GetAsync(new Uri(_versionResponse.ArtifactUrl), HttpCompletionOption.ResponseHeadersRead);
HttpResponseMessage message = await buildSizeClient.GetAsync(new Uri(_artifactUrl), HttpCompletionOption.ResponseHeadersRead);
_buildSize = message.Content.Headers.ContentRange.Length.Value;
}
@@ -365,7 +369,7 @@ namespace Ryujinx.Ava.Systems
using HttpResponseMessage response = client.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead).Result;
using Stream remoteFileStream = response.Content.ReadAsStreamAsync().Result;
using FileStream updateFileStream = File.Open(updateFile, FileMode.Create);
using FileStream updateFileStream = RetryOnTransientFileLock(() => File.Open(updateFile, FileMode.Create));
long totalBytes = response.Content.Headers.ContentLength.Value;
long bytesWritten = 0;
@@ -389,7 +393,11 @@ namespace Ryujinx.Ava.Systems
updateFileStream.Write(buffer, 0, readSize);
}
InstallUpdate(taskDialog, updateFile);
// Install on the UI thread: InstallUpdate touches the task dialog, and
// Avalonia UI calls must run on the UI thread. (The multi-threaded path
// already runs InstallUpdate on the UI thread via its WebClient callback;
// this keeps the single-threaded fallback consistent and crash-free.)
Dispatcher.UIThread.Post(() => InstallUpdate(taskDialog, updateFile));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -398,9 +406,57 @@ namespace Ryujinx.Ava.Systems
return max == 0 ? 0 : value / max * 100;
}
/// <summary>
/// Runs a file operation that can transiently fail because the freshly-downloaded
/// update archive is momentarily locked by another process. On Windows this is almost
/// always the antivirus (e.g. Defender) scanning the just-written file; the lock clears
/// within a second or two. Retries briefly before giving up.
/// </summary>
private static T RetryOnTransientFileLock<T>(Func<T> operation)
{
const int maxAttempts = 20;
const int delayMs = 250;
for (int attempt = 1; ; attempt++)
{
try
{
return operation();
}
catch (IOException ex) when (attempt < maxAttempts)
{
Logger.Warning?.Print(LogClass.Application,
$"Update file is locked (likely antivirus), retry {attempt}/{maxAttempts}: {ex.Message}");
Thread.Sleep(delayMs);
}
}
}
private static void DoUpdateWithSingleThread(TaskDialog taskDialog, string downloadUrl, string updateFile)
{
Thread worker = new(() => DoUpdateWithSingleThreadWorker(taskDialog, downloadUrl, updateFile))
Thread worker = new(() =>
{
try
{
DoUpdateWithSingleThreadWorker(taskDialog, downloadUrl, updateFile);
}
catch (Exception ex)
{
// An unhandled exception on this background thread would otherwise hard-crash
// the whole application. Surface it as a normal error dialog instead.
Logger.Error?.Print(LogClass.Application, $"Update failed: {ex}");
_running = false;
Dispatcher.UIThread.Post(() =>
{
taskDialog.Hide();
_ = ContentDialogHelper.CreateErrorDialog(
$"{LocaleManager.Instance[LocaleKeys.RyujinxUpdater]}: {ex.Message}");
});
}
})
{
Name = "Updater.SingleThreadWorker",
};
@@ -435,7 +491,10 @@ namespace Ryujinx.Ava.Systems
private static void Extract7ZipFile(string archivePath, string outputDirectoryPath)
{
IArchive archive = ArchiveFactory.OpenArchive(archivePath);
// Retry the open: a freshly-downloaded archive is often momentarily locked by
// antivirus (e.g. Windows Defender) scanning it. `using` releases the handle so
// the file can be deleted afterwards (the previous code leaked it).
using IArchive archive = RetryOnTransientFileLock(() => ArchiveFactory.OpenArchive(archivePath));
archive.WriteToDirectory(outputDirectoryPath);
}
@@ -485,6 +485,19 @@ namespace Ryujinx.Ava.UI.Helpers
isTopDialog = false;
}
// When no game is running, host the dialog INLINE on the main window's OverlayLayer
// (FluentAvalonia's native hosting -- the same ShowAsync(visual) API used below). This
// avoids spawning a separate transparent top-level window per dialog, which is what
// produces the brief compositor flash at cold startup (the empty first frame of a fresh
// transparent OS window). The dedicated overlay window is only required WHILE A GAME IS
// RUNNING, because the native Vulkan/OpenGL render surface draws on top of any inline
// Avalonia overlay and would hide the dialog.
if (isTopDialog && parent is MainWindow inlineHost && !inlineHost.ViewModel.IsGameRunning)
{
inlineHost.Activate();
return await contentDialog.ShowAsync(inlineHost);
}
if (parent is MainWindow window)
{
parent.Activate();
@@ -49,6 +49,7 @@ namespace Ryujinx.Ava.UI.Windows
private bool _isLoading;
private bool _applicationsLoadedOnce;
private static bool _startupUpdatePromptShown;
private double _windowStartupWidthDelta;
private double _windowStartupHeightDelta;
@@ -417,6 +418,15 @@ namespace Ryujinx.Ava.UI.Windows
switch (ConfigurationState.Instance.UpdateCheckerType.Value)
{
case UpdaterType.PromptAtStartup:
// Only auto-prompt once per launch. The persistent status-bar indicator is the
// fallback if the user dismisses the prompt.
if (_startupUpdatePromptShown)
{
break;
}
_startupUpdatePromptShown = true;
await Updater.BeginUpdateAsync()
.Catch(task => Logger.Error?.Print(LogClass.Application, $"Updater Error: {task.Exception}"));
break;