From c08dec95792629f3e6ab21ca13807b4f30ae8a18 Mon Sep 17 00:00:00 2001 From: The Roofer Dev Date: Mon, 22 Jun 2026 17:21:18 -0400 Subject: [PATCH] =?UTF-8?q?v1.1.5=20=E2=80=94=20self-hosted=20auto-updater?= =?UTF-8?q?=20+=20flicker-free=20startup=20update=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/Ryujinx.Common/ReleaseInformation.cs | 4 +- src/Ryujinx/Systems/Updater/Updater.GitLab.cs | 92 +++++++------------ src/Ryujinx/Systems/Updater/Updater.cs | 73 +++++++++++++-- src/Ryujinx/UI/Helpers/ContentDialogHelper.cs | 13 +++ src/Ryujinx/UI/Windows/MainWindow.axaml.cs | 10 ++ 5 files changed, 125 insertions(+), 67 deletions(-) diff --git a/src/Ryujinx.Common/ReleaseInformation.cs b/src/Ryujinx.Common/ReleaseInformation.cs index 54525278b..84a0009fb 100644 --- a/src/Ryujinx.Common/ReleaseInformation.cs +++ b/src/Ryujinx.Common/ReleaseInformation.cs @@ -28,9 +28,7 @@ namespace Ryujinx.Common public static string Version => IsValid ? BuildVersion : Assembly.GetEntryAssembly()!.GetCustomAttribute()?.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}"; } diff --git a/src/Ryujinx/Systems/Updater/Updater.GitLab.cs b/src/Ryujinx/Systems/Updater/Updater.GitLab.cs index 1972e1422..a485f8fbf 100644 --- a/src/Ryujinx/Systems/Updater/Updater.GitLab.cs +++ b/src/Ryujinx/Systems/Updater/Updater.GitLab.cs @@ -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> 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.Failure( - new MessageError("Connection error occurred. Is your internet down?")); - } - catch (HttpRequestException hre) - when (hre.HttpRequestError is HttpRequestError.NameResolutionError) - { - return Return.Failure( - new MessageError("DNS resolution error occurred. Is your internet down?")); - } - catch (HttpRequestException hre) - when (hre.StatusCode is HttpStatusCode.BadGateway) - { - return Return.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> 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(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); } diff --git a/src/Ryujinx/Systems/Updater/Updater.cs b/src/Ryujinx/Systems/Updater/Updater.cs index e1b45e4b0..66577d52c 100644 --- a/src/Ryujinx/Systems/Updater/Updater.cs +++ b/src/Ryujinx/Systems/Updater/Updater.cs @@ -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; } + /// + /// 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. + /// + private static T RetryOnTransientFileLock(Func 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); } diff --git a/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs b/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs index 65de07e6e..48c824500 100644 --- a/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs +++ b/src/Ryujinx/UI/Helpers/ContentDialogHelper.cs @@ -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(); diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs index a7e928224..bc71ccf13 100644 --- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs @@ -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;