diff --git a/UniGetUI.iss b/UniGetUI.iss index 15933e662d..cb2f65ddc2 100644 --- a/UniGetUI.iss +++ b/UniGetUI.iss @@ -109,6 +109,7 @@ procedure KillRunningApps; begin TaskKill('WingetUI.exe'); TaskKill('UniGetUI.exe'); + TaskKill('UniGetUI.Avalonia.exe'); end; function CmdLineParamExists(const Value: string): Boolean; @@ -236,3 +237,4 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--migrate-wingetui-to-unigetui"; ; Filename: "{app}\{#MyAppExeName}"; Parameters: "--uninstall-unigetui"; Flags: skipifdoesntexist runhidden; Filename: {sys}\taskkill.exe; Parameters: "/f /im WingetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillWingetUI" Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUI" +Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.Avalonia.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIAvalonia" diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs index c8a5ba3261..7743957f9e 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaAutoUpdater.cs @@ -1,15 +1,15 @@ using System.Diagnostics; using System.Globalization; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; -using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; using Microsoft.Win32; -using UniGetUI.Avalonia.Views; +using UniGetUI.Avalonia.ViewModels; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; @@ -42,6 +42,11 @@ internal static partial class AvaloniaAutoUpdater "50f753333811ff11f1920274afde3ffd4468b210", ]; + private static readonly string[] DEVOLUTIONS_MAC_DEVELOPER_IDS = + [ + "N592S9ASDB", + ]; + #if !DEBUG private static readonly string[] RELEASE_IGNORED_REGISTRY_VALUES = [ @@ -64,6 +69,257 @@ internal static partial class AvaloniaAutoUpdater /// public static event Action? UpdateAvailable; + /// + /// Fired on the UI thread to surface progress/result of an update check or + /// install attempt to the UI banner. Mirrors the verbose feedback the WinUI + /// AutoUpdater shows in its InfoBar. + /// + public static event Action? StatusChanged; + + public sealed record UpdateStatusInfo( + string Title, + string Message, + InfoBarSeverity Severity, + bool IsClosable, + string? ActionButtonText = null, + Action? ActionButtonAction = null); + + private static void RaiseStatus( + string title, + string message, + InfoBarSeverity severity, + bool isClosable, + string? actionButtonText = null, + Action? actionButtonAction = null) + { + var info = new UpdateStatusInfo(title, message, severity, isClosable, actionButtonText, actionButtonAction); + Dispatcher.UIThread.Post(() => StatusChanged?.Invoke(info)); + } + + // ------------------------------------------------------------------ per-attempt log + // Captures auto-updater log entries for the current update attempt. We keep a + // dedicated buffer (in addition to the global session log) so the "View log" + // banner button can show the user only the entries relevant to their failed + // update, instead of dumping the entire noisy session log. + private static readonly Lock _updateLogLock = new(); + private static StringBuilder? _updateLogBuilder; + private static readonly string _updateLogPath = Path.Combine( + Path.GetTempPath(), + "UniGetUI", + "last-update-attempt.log" + ); + + private static void ResetUpdateLog(bool manualCheck, bool autoLaunch) + { + lock (_updateLogLock) + { + _updateLogBuilder = new StringBuilder() + .AppendLine($"=== UniGetUI update attempt started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===") + .AppendLine($"Current version: {CoreData.VersionName} (build {CoreData.BuildNumber})") + .AppendLine($"Manual check: {manualCheck}") + .AppendLine($"Auto-launch: {autoLaunch}") + .AppendLine($"Process architecture: {RuntimeInformation.ProcessArchitecture}") + .AppendLine(); + FlushUpdateLogToDiskNoLock(); + } + } + + private static void AppendToUpdateLog(string severity, string message) + { + lock (_updateLogLock) + { + if (_updateLogBuilder is null) return; + _updateLogBuilder.AppendLine($"[{DateTime.Now:HH:mm:ss}] [{severity}] {message}"); + FlushUpdateLogToDiskNoLock(); + } + } + + // Persists the current buffer to _updateLogPath. Caller MUST hold _updateLogLock. + // Failures are silently swallowed — a missing log file should never break the + // update flow itself. + private static void FlushUpdateLogToDiskNoLock() + { + if (_updateLogBuilder is null) return; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_updateLogPath)!); + File.WriteAllText(_updateLogPath, _updateLogBuilder.ToString()); + } + catch { /* see comment above */ } + } + + private const string AttemptFinishedMarker = "=== Attempt finished:"; + + // Appends a structured line indicating the update flow reached a terminal state. + // The presence/absence of this marker on disk lets a subsequent app launch tell + // whether the previous attempt completed cleanly or was killed mid-flow (e.g., + // by the installer terminating us during file replacement). + private static void MarkAttemptFinished(string outcome) + { + lock (_updateLogLock) + { + if (_updateLogBuilder is null) return; + _updateLogBuilder + .AppendLine() + .AppendLine($"{AttemptFinishedMarker} {outcome} at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); + FlushUpdateLogToDiskNoLock(); + } + } + + private static void RecordTargetVersion(string version) + { + lock (_updateLogLock) + { + _updateLogBuilder?.AppendLine($"Target version: {version}"); + FlushUpdateLogToDiskNoLock(); + } + } + + /// + /// On app startup, detects an interrupted update attempt — the log file + /// from the previous attempt has no , + /// indicating the app was killed mid-flow (almost always because the + /// installer terminated us during file replacement). + /// + /// If the running version equals the target version we recorded, the + /// install succeeded and we are now the new version — silently appends + /// a marker so we don't re-prompt next time. + /// + /// Otherwise, surfaces a Warning banner with a "View log" button so the + /// user can investigate what happened. + /// + public static void CheckForOrphanedUpdateAttempt() + { + try + { + if (!File.Exists(_updateLogPath)) return; + + var info = new FileInfo(_updateLogPath); + if ((DateTime.Now - info.LastWriteTime).TotalMinutes > 10) + return; + + string content = File.ReadAllText(_updateLogPath); + if (content.Contains(AttemptFinishedMarker)) + return; + + string currentVer = CoreData.VersionName; + string? targetVer = null; + foreach (string line in content.Split('\n')) + { + if (line.StartsWith("Target version: ")) + { + targetVer = line["Target version: ".Length..].Trim(); + break; + } + } + + if (targetVer is not null && targetVer == currentVer) + { + Logger.Info($"Previous update attempt killed mid-flow but install succeeded (running version {currentVer} matches target). Marking as finished."); + try + { + File.AppendAllText( + _updateLogPath, + $"{Environment.NewLine}{AttemptFinishedMarker} installer succeeded (detected on next launch — running version is {currentVer}) at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}"); + } + catch { /* swallow */ } + return; + } + + Logger.Warn($"Detected interrupted update attempt. Running={currentVer}, Target={targetVer ?? "(unknown)"}"); + + RaiseStatus( + CoreTools.Translate("Your last update attempt did not complete."), + CoreTools.Translate("UniGetUI could not confirm whether the update succeeded. Open the log to see what happened."), + InfoBarSeverity.Warning, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + } + catch (Exception ex) + { + Logger.Warn($"Could not check for orphaned update attempt: {ex.Message}"); + } + } + + private static void LogUpdateInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Info(message, caller); + AppendToUpdateLog("INFO ", message); + } + + private static void LogUpdateWarn(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Warn(message, caller); + AppendToUpdateLog("WARN ", message); + } + + private static void LogUpdateWarn(Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Warn(ex, caller); + AppendToUpdateLog("WARN ", ex.ToString()); + } + + private static void LogUpdateError(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Error(message, caller); + AppendToUpdateLog("ERROR", message); + } + + private static void LogUpdateError(Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Error(ex, caller); + AppendToUpdateLog("ERROR", ex.ToString()); + } + + private static void LogUpdateDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Debug(message, caller); + AppendToUpdateLog("DEBUG", message); + } + + private static void OpenUpdateLog() + { + // The buffer is flushed to disk on every append/reset, so the file should + // already be current. Only fall back to the full session log if no flow + // has ever run (button shouldn't appear in that case, but be defensive). + string pathToOpen = File.Exists(_updateLogPath) + ? _updateLogPath + : Logger.GetSessionLogPath(); + + try + { + Process.Start(new ProcessStartInfo + { + FileName = pathToOpen, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + Logger.Warn($"Could not open log file '{pathToOpen}': {ex.Message}"); + } + } + + /// + /// Translates an Inno Setup installer exit code into a short human-readable + /// reason. The codes come from the Inno Setup documentation + /// (https://jrsoftware.org/ishelp/index.php?topic=setupexitcodes). + /// + private static string DescribeInstallerExitCode(int code) => code switch + { + 0 => CoreTools.Translate("The installer reported success but did not restart UniGetUI."), + 1 => CoreTools.Translate("The installer failed to initialize."), + 2 => CoreTools.Translate("Setup was canceled before installation began."), + 3 => CoreTools.Translate("A fatal error occurred during the preparation phase."), + 4 => CoreTools.Translate("A fatal error occurred during installation."), + 5 => CoreTools.Translate("Installation was canceled while in progress."), + 6 => CoreTools.Translate("The installer was terminated by another process."), + 7 => CoreTools.Translate("The preparation phase determined the installation cannot proceed."), + 8 => CoreTools.Translate("The installer could not start. UniGetUI may already be running, or you do not have permission to install."), + _ => CoreTools.Translate("Unexpected installer error."), + }; + private static volatile bool _installRequested; private static string? _pendingInstallerPath; @@ -84,12 +340,16 @@ internal static partial class AvaloniaAutoUpdater /// /// Called by the user when they click "Update now" in the update banner. /// - public static void TriggerInstall() => _installRequested = true; + public static void TriggerInstall() + { + LogUpdateInfo("Auto-updater: TriggerInstall invoked (user clicked Update now)."); + _installRequested = true; + } public static async Task UpdateCheckLoopAsync() { if (Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) { - Logger.Warn("Auto-updater: disabled by user setting, skipping."); + LogUpdateWarn("Auto-updater: disabled by user setting, skipping."); return; } @@ -100,7 +360,7 @@ public static async Task UpdateCheckLoopAsync() { if (Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) { - Logger.Warn("Auto-updater: disabled by user setting, stopping loop."); + LogUpdateWarn("Auto-updater: disabled by user setting, stopping loop."); return; } @@ -112,25 +372,54 @@ public static async Task UpdateCheckLoopAsync() } // ------------------------------------------------------------------ core logic - internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = false) + internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = false, bool manualCheck = false) { + ResetUpdateLog(manualCheck, autoLaunch); UpdaterOverrides overrides = LoadUpdaterOverrides(); + bool wasCheckingForUpdates = true; try { + if (manualCheck) + { + RaiseStatus( + CoreTools.Translate("We are checking for updates."), + CoreTools.Translate("Please wait"), + InfoBarSeverity.Informational, + isClosable: false); + } + UpdateCandidate candidate = await GetUpdateCandidateAsync(overrides); - Logger.Info( + LogUpdateInfo( $"Auto-updater source '{candidate.SourceName}' returned version {candidate.VersionName} (upgradable={candidate.IsUpgradable})" ); if (!candidate.IsUpgradable) { + if (manualCheck) + { + RaiseStatus( + CoreTools.Translate("Great! You are on the latest version."), + CoreTools.Translate("There are no new UniGetUI versions to be installed"), + InfoBarSeverity.Success, + isClosable: true); + } + MarkAttemptFinished("no update available"); return true; } - Logger.Info($"Update to UniGetUI {candidate.VersionName} is available."); + wasCheckingForUpdates = false; + RecordTargetVersion(candidate.VersionName); + LogUpdateInfo($"Update to UniGetUI {candidate.VersionName} is available."); - string installerPath = Path.Join(CoreData.UniGetUIDataDirectory, "UniGetUI Updater.exe"); + string installerName; + if (OperatingSystem.IsWindows()) + installerName = "UniGetUI Updater.exe"; + else if (OperatingSystem.IsMacOS()) + installerName = "UniGetUI Updater.pkg"; + else + installerName = "UniGetUI Updater.AppImage"; + string installerPath = Path.Join(CoreData.UniGetUIDataDirectory, installerName); // Try cached installer first if ( @@ -139,14 +428,22 @@ internal static async Task CheckAndInstallUpdatesAsync(bool autoLaunch = f && CheckInstallerSignerThumbprint(installerPath, overrides) ) { - Logger.Info("Cached valid installer found, preparing to launch..."); - return await PrepareAndLaunchAsync(installerPath, candidate.VersionName, autoLaunch); + LogUpdateInfo("Cached valid installer found, preparing to launch..."); + return await PrepareAndLaunchAsync(installerPath, candidate.VersionName, autoLaunch, manualCheck); } // Delete invalid/outdated cached copy try { File.Delete(installerPath); } catch { } - Logger.Info("Downloading installer..."); + RaiseStatus( + CoreTools.Translate( + "UniGetUI version {0} is being downloaded.", + candidate.VersionName.ToString(CultureInfo.InvariantCulture)), + CoreTools.Translate("This may take a minute or two"), + InfoBarSeverity.Informational, + isClosable: false); + + LogUpdateInfo("Downloading installer..."); await DownloadInstallerAsync(candidate.InstallerDownloadUrl, installerPath, overrides); if ( @@ -154,17 +451,53 @@ await CheckInstallerHashAsync(installerPath, candidate.InstallerHash, overrides) && CheckInstallerSignerThumbprint(installerPath, overrides) ) { - Logger.Info("Downloaded installer is valid, preparing to launch..."); - return await PrepareAndLaunchAsync(installerPath, candidate.VersionName, autoLaunch); + LogUpdateInfo("Downloaded installer is valid, preparing to launch..."); + return await PrepareAndLaunchAsync(installerPath, candidate.VersionName, autoLaunch, manualCheck); } - Logger.Error("Installer authenticity could not be verified. Aborting update."); + LogUpdateError("Installer authenticity could not be verified. Aborting update."); + RaiseStatus( + CoreTools.Translate("The installer authenticity could not be verified."), + CoreTools.Translate("The update process has been aborted."), + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished("authenticity verification failed"); + return false; + } + catch (PlatformArtifactMissingException ex) + { + // A newer version exists in productinfo but no installer artifact is + // published for the current OS/arch yet. Surface this as a friendly + // "manual update required" notice rather than a generic error. + LogUpdateWarn(ex.Message); + if (manualCheck) + { + RaiseStatus( + CoreTools.Translate("Auto-update is not yet available on this platform."), + CoreTools.Translate("Please update UniGetUI manually."), + InfoBarSeverity.Warning, + isClosable: true); + } + MarkAttemptFinished("platform artifact missing"); return false; } catch (Exception ex) { - Logger.Error("An error occurred while checking for updates:"); - Logger.Error(ex); + LogUpdateError("An error occurred while checking for updates:"); + LogUpdateError(ex); + if (manualCheck || !wasCheckingForUpdates) + { + RaiseStatus( + CoreTools.Translate("An error occurred when checking for updates: "), + ex.Message, + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + } + MarkAttemptFinished($"exception: {ex.Message}"); return false; } } @@ -173,7 +506,8 @@ await CheckInstallerHashAsync(installerPath, candidate.InstallerHash, overrides) private static async Task PrepareAndLaunchAsync( string installerPath, string versionName, - bool autoLaunch) + bool autoLaunch, + bool manualCheck) { _pendingInstallerPath = installerPath; _installRequested = false; @@ -194,22 +528,35 @@ private static async Task PrepareAndLaunchAsync( // Wait until user requests install, clicks the toast, or the window is being closed while (!_installRequested && !ReleaseLockForAutoupdate_Window && !ReleaseLockForAutoupdate_Notification) { - if (Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) + if (!manualCheck && Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) { - Logger.Warn("Auto-updater: disabled while waiting for user \u2014 aborting."); + LogUpdateWarn("Auto-updater: disabled while waiting for user \u2014 aborting."); + MarkAttemptFinished("aborted - auto-update disabled while waiting"); return true; } await Task.Delay(500); } - Logger.Info("Installing update \u2014 launching installer and quitting."); - await LaunchInstallerAndQuitAsync(installerPath); + LogUpdateInfo("Installing update \u2014 launching installer."); + await LaunchInstallerAsync(installerPath); return true; } - private static async Task LaunchInstallerAndQuitAsync(string installerLocation) + private static async Task LaunchInstallerAsync(string installerLocation) { - Logger.Info($"Launching installer: {installerLocation}"); + if (OperatingSystem.IsMacOS()) + { + await LaunchMacInstallerAsync(installerLocation); + return; + } + + if (OperatingSystem.IsLinux()) + { + LaunchLinuxInstaller(installerLocation); + return; + } + + LogUpdateInfo($"Launching installer: {installerLocation}"); using Process p = new() { StartInfo = new ProcessStartInfo @@ -221,30 +568,321 @@ private static async Task LaunchInstallerAndQuitAsync(string installerLocation) }, }; - bool started = p.Start(); + bool started; + try + { + started = p.Start(); + } + catch (Exception ex) + { + LogUpdateError("Process.Start threw while launching the installer:"); + LogUpdateError(ex); + RaiseStatus( + CoreTools.Translate("The updater could not be launched."), + ex.Message, + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished($"installer launch threw: {ex.Message}"); + return; + } + if (!started) { - Logger.Error("Failed to start installer process."); + LogUpdateError("Failed to start installer process (Process.Start returned false)."); + RaiseStatus( + CoreTools.Translate("The updater could not be launched."), + CoreTools.Translate("The operating system did not start the installer process."), + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished("Process.Start returned false"); return; } - // Quit the app on the UI thread - Dispatcher.UIThread.Post(() => + LogUpdateInfo($"Installer process started (PID {p.Id}). The installer is expected to terminate UniGetUI before file replacement."); + + RaiseStatus( + CoreTools.Translate("UniGetUI is being updated..."), + CoreTools.Translate("This may take a minute or two"), + InfoBarSeverity.Informational, + isClosable: false); + + await p.WaitForExitAsync(); + + // If we reach here, the installer exited without terminating this process. + // Distinguish two cases: + // - Exit code 0: installer succeeded; the new version IS installed at the + // install location, but the running copy was not replaced (almost always + // because UniGetUI is running from outside the install location — typically + // a development build). This is not really an error. + // - Any other code: installer reported a failure; the update did not apply. + int exitCode = p.ExitCode; + string reason = DescribeInstallerExitCode(exitCode); + + if (exitCode == 0) { - if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) + string runningPath = Environment.ProcessPath ?? "(unknown)"; + LogUpdateWarn($"Installer reported success (exit code 0) but did not replace this running copy. Running from: {runningPath}"); + + RaiseStatus( + CoreTools.Translate("Update installed."), + CoreTools.Translate("UniGetUI was updated successfully, but this running copy was not replaced. This usually means you are running a development build. Close this copy and start the newly-installed version to finish."), + InfoBarSeverity.Warning, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished("installer succeeded but did not replace running copy"); + return; + } + + LogUpdateError($"Installer exited with code {exitCode} ({reason}) without restarting UniGetUI."); + + RaiseStatus( + CoreTools.Translate("The update could not be applied."), + CoreTools.Translate("Installer exit code {0}: {1}", exitCode, reason), + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished($"installer failed with code {exitCode}"); + } + + private static async Task LaunchMacInstallerAsync(string installerLocation) + { + LogUpdateInfo($"Launching macOS installer: {installerLocation}"); + + // Escape for inclusion in the AppleScript string literal. + string scriptPath = installerLocation.Replace("\\", "\\\\").Replace("\"", "\\\""); + string appleScript = + $"do shell script \"/usr/sbin/installer -pkg \\\"{scriptPath}\\\" -target /\" with administrator privileges"; + + using Process p = new() + { + StartInfo = new ProcessStartInfo { - if (lifetime.MainWindow is MainWindow mw) - { - mw.QuitApplication(); - } - else - { - lifetime.Shutdown(); - } - } - }); + FileName = "/usr/bin/osascript", + ArgumentList = { "-e", appleScript }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + bool started; + try + { + started = p.Start(); + } + catch (Exception ex) + { + LogUpdateError("osascript threw while launching the macOS installer:"); + LogUpdateError(ex); + RaiseStatus( + CoreTools.Translate("The updater could not be launched."), + ex.Message, + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished($"installer launch threw: {ex.Message}"); + return; + } + + if (!started) + { + LogUpdateError("Failed to start osascript process (Process.Start returned false)."); + RaiseStatus( + CoreTools.Translate("The updater could not be launched."), + CoreTools.Translate("The operating system did not start the installer process."), + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished("Process.Start returned false"); + return; + } + + RaiseStatus( + CoreTools.Translate("UniGetUI is being updated..."), + CoreTools.Translate("This may take a minute or two"), + InfoBarSeverity.Informational, + isClosable: false); + + string stderr = await p.StandardError.ReadToEndAsync(); await p.WaitForExitAsync(); + int exitCode = p.ExitCode; + + if (exitCode != 0) + { + // osascript exits 1 with stderr "User canceled." when the user dismisses + // the admin authentication prompt. Treat that as a normal cancellation. + bool userCancelled = stderr.Contains("User canceled", StringComparison.OrdinalIgnoreCase) + || stderr.Contains("(-128)"); + string trimmed = stderr.Trim(); + LogUpdateError( + userCancelled + ? "macOS installer cancelled at the authentication prompt." + : $"macOS installer failed (exit {exitCode}): {trimmed}" + ); + + RaiseStatus( + userCancelled + ? CoreTools.Translate("Update cancelled.") + : CoreTools.Translate("The update could not be applied."), + userCancelled + ? CoreTools.Translate("Authentication was cancelled.") + : (string.IsNullOrWhiteSpace(trimmed) + ? CoreTools.Translate("Installer exit code {0}", exitCode) + : trimmed), + userCancelled ? InfoBarSeverity.Warning : InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished( + userCancelled + ? "user cancelled authentication" + : $"installer failed with code {exitCode}" + ); + return; + } + + LogUpdateInfo("macOS installer completed successfully."); + + const string installedApp = "/Applications/UniGetUI.app"; + if (!Directory.Exists(installedApp)) + { + string runningPath = Environment.ProcessPath ?? "(unknown)"; + LogUpdateWarn( + $"Installer reported success but {installedApp} was not found. Running from: {runningPath}" + ); + RaiseStatus( + CoreTools.Translate("Update installed."), + CoreTools.Translate("UniGetUI was updated successfully, but this running copy was not replaced. This usually means you are running a development build. Close this copy and start the newly-installed version to finish."), + InfoBarSeverity.Warning, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished("installer succeeded but did not replace running copy"); + return; + } + + LogUpdateInfo($"Relaunching {installedApp} and exiting current process."); + + // Detach a tiny shell that waits a moment, then opens a *new* instance of the + // freshly-installed app. The brief sleep gives this process time to exit so + // `open -na` doesn't race against our termination. + try + { + Process.Start(new ProcessStartInfo + { + FileName = "/bin/sh", + ArgumentList = { "-c", $"sleep 1 && /usr/bin/open -na \"{installedApp}\"" }, + UseShellExecute = false, + CreateNoWindow = true, + }); + } + catch (Exception ex) + { + LogUpdateWarn("Could not schedule relaunch of new app instance:"); + LogUpdateWarn(ex); + } + + MarkAttemptFinished("macOS installer succeeded; relaunching"); + + // Match the Windows flow: the installer terminates the running copy. On macOS + // we do that ourselves so the relaunch picks up the freshly-installed bundle. + Environment.Exit(0); + } + + [SupportedOSPlatform("linux")] + private static void LaunchLinuxInstaller(string installerLocation) + { + LogUpdateInfo($"Applying Linux AppImage update from: {installerLocation}"); + + // The AppImage runtime sets APPIMAGE to the on-disk path of the running + // .AppImage file. Without it we have no reliable way to know which file + // to replace (e.g., when running from `dotnet run` during development). + string? runningApp = Environment.GetEnvironmentVariable("APPIMAGE"); + if (string.IsNullOrEmpty(runningApp) || !File.Exists(runningApp)) + { + LogUpdateWarn( + $"APPIMAGE env var is not set or points to a missing file (got '{runningApp}'). " + + "UniGetUI does not appear to be running from an AppImage; the running copy " + + "cannot be replaced automatically." + ); + RaiseStatus( + CoreTools.Translate("Update installed."), + CoreTools.Translate("UniGetUI was updated successfully, but this running copy was not replaced. This usually means you are running a development build. Close this copy and start the newly-installed version to finish."), + InfoBarSeverity.Warning, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished("not running from an AppImage; running copy not replaced"); + return; + } + + try + { + // Replace the running AppImage on disk. Linux allows renaming over a + // currently-executing file: the running process keeps its inode mapped, + // and future launches resolve the path to the new file. + File.Move(installerLocation, runningApp, overwrite: true); + + File.SetUnixFileMode( + runningApp, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + | UnixFileMode.GroupRead | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead | UnixFileMode.OtherExecute + ); + } + catch (Exception ex) + { + LogUpdateError("Failed to replace the running AppImage:"); + LogUpdateError(ex); + RaiseStatus( + CoreTools.Translate("The update could not be applied."), + ex.Message, + InfoBarSeverity.Error, + isClosable: true, + actionButtonText: CoreTools.Translate("View log"), + actionButtonAction: OpenUpdateLog); + MarkAttemptFinished($"AppImage replacement failed: {ex.Message}"); + return; + } + + LogUpdateInfo($"Replaced {runningApp}; relaunching new AppImage and exiting current process."); + + RaiseStatus( + CoreTools.Translate("UniGetUI is being updated..."), + CoreTools.Translate("This may take a minute or two"), + InfoBarSeverity.Informational, + isClosable: false); + + // Detach a shell that waits a moment, then runs the new AppImage. The brief + // sleep gives this process time to exit so the relaunched instance starts + // cleanly without lingering shared resources. + try + { + Process.Start(new ProcessStartInfo + { + FileName = "/bin/sh", + ArgumentList = { "-c", "sleep 1 && \"$1\" >/dev/null 2>&1 &", "sh", runningApp }, + UseShellExecute = false, + CreateNoWindow = true, + }); + } + catch (Exception ex) + { + LogUpdateWarn("Could not schedule relaunch of new AppImage:"); + LogUpdateWarn(ex); + } + + MarkAttemptFinished("Linux AppImage replaced; relaunching"); + Environment.Exit(0); } // ------------------------------------------------------------------ update check sources @@ -255,7 +893,7 @@ private static async Task GetUpdateCandidateAsync(UpdaterOverri private static async Task CheckFromProductInfoAsync(UpdaterOverrides overrides) { - Logger.Debug($"Checking updates via ProductInfo: {overrides.ProductInfoUrl}"); + LogUpdateDebug($"Checking updates via ProductInfo: {overrides.ProductInfoUrl}"); if (!IsSourceUrlAllowed(overrides.ProductInfoUrl, overrides.AllowUnsafeUrls)) { @@ -313,7 +951,7 @@ private static async Task CheckFromProductInfoAsync(UpdaterOver Version available = ParseVersionOrFallback(channel.Version, new Version(0, 0, 0, 0)); bool upgradable = available > current; - Logger.Debug( + LogUpdateDebug( $"ProductInfo check: current={current}, available={available}, upgradable={upgradable}" ); @@ -328,7 +966,7 @@ private static async Task CheckInstallerHashAsync( { if (overrides.SkipHashValidation) { - Logger.Warn("Registry override: skipping hash validation."); + LogUpdateWarn("Registry override: skipping hash validation."); return true; } @@ -339,11 +977,11 @@ private static async Task CheckInstallerHashAsync( if (actual == expectedHash.ToLowerInvariant()) { - Logger.Debug($"Hash match: {actual}"); + LogUpdateDebug($"Hash match: {actual}"); return true; } - Logger.Warn($"Hash mismatch. Expected: {expectedHash} Got: {actual}"); + LogUpdateWarn($"Hash mismatch. Expected: {expectedHash} Got: {actual}"); return false; } @@ -351,7 +989,29 @@ private static bool CheckInstallerSignerThumbprint(string path, UpdaterOverrides { if (overrides.SkipSignerThumbprintCheck) { - Logger.Warn("Registry override: skipping signer thumbprint validation."); + LogUpdateWarn("Registry override: skipping signer thumbprint validation."); + return true; + } + + if (OperatingSystem.IsMacOS()) + { + return CheckMacInstallerSignature(path); + } + + if (OperatingSystem.IsLinux()) + { + // AppImage has no built-in signing format equivalent to Authenticode/.pkg. + // Hash validation (verified separately, against the productinfo.json fetched + // over HTTPS from a trusted host) provides the integrity guarantee. A future + // extension could verify a detached GPG signature published alongside the + // .AppImage in productinfo. + LogUpdateWarn("Linux .AppImage signature validation is not implemented — relying on hash check."); + return true; + } + + if (!OperatingSystem.IsWindows()) + { + LogUpdateWarn("Skipping installer signature validation on unsupported platform."); return true; } @@ -365,23 +1025,80 @@ private static bool CheckInstallerSignerThumbprint(string path, UpdaterOverrides if (string.IsNullOrWhiteSpace(thumbprint)) { - Logger.Warn($"Could not read signer thumbprint for '{path}'"); + LogUpdateWarn($"Could not read signer thumbprint for '{path}'"); return false; } if (DEVOLUTIONS_CERT_THUMBPRINTS.Contains(thumbprint, StringComparer.OrdinalIgnoreCase)) { - Logger.Debug($"Installer signer thumbprint is trusted: {thumbprint}"); + LogUpdateDebug($"Installer signer thumbprint is trusted: {thumbprint}"); return true; } - Logger.Warn($"Installer signer thumbprint is NOT trusted: {thumbprint}"); + LogUpdateWarn($"Installer signer thumbprint is NOT trusted: {thumbprint}"); return false; } catch (Exception ex) { - Logger.Warn("Could not validate installer signer thumbprint."); - Logger.Warn(ex); + LogUpdateWarn("Could not validate installer signer thumbprint."); + LogUpdateWarn(ex); + return false; + } + } + + private static bool CheckMacInstallerSignature(string path) + { + if (DEVOLUTIONS_MAC_DEVELOPER_IDS.Length == 0) + { + LogUpdateWarn( + "No Devolutions macOS Developer Team IDs configured — skipping .pkg signature validation." + ); + return true; + } + + try + { + using Process p = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "/usr/sbin/pkgutil", + ArgumentList = { "--check-signature", path }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + p.Start(); + string stdout = p.StandardOutput.ReadToEnd(); + string stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + + if (p.ExitCode != 0) + { + LogUpdateWarn( + $"pkgutil --check-signature exited {p.ExitCode}; signature could not be verified. {stderr.Trim()}" + ); + return false; + } + + foreach (string teamId in DEVOLUTIONS_MAC_DEVELOPER_IDS) + { + if (stdout.Contains($"({teamId})", StringComparison.OrdinalIgnoreCase)) + { + LogUpdateDebug($"Installer is signed by trusted Developer Team ID {teamId}."); + return true; + } + } + + LogUpdateWarn("Installer signature does not match any trusted Devolutions Developer Team ID."); + return false; + } + catch (Exception ex) + { + LogUpdateWarn("Could not validate installer signature via pkgutil."); + LogUpdateWarn(ex); return false; } } @@ -397,7 +1114,7 @@ private static async Task DownloadInstallerAsync( throw new InvalidOperationException($"Download URL is not allowed: {url}"); } - Logger.Debug($"Downloading installer from {url}"); + LogUpdateDebug($"Downloading installer from {url}"); using HttpClient client = new(CreateHttpClientHandler(overrides)); client.Timeout = TimeSpan.FromSeconds(600); client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); @@ -408,7 +1125,7 @@ private static async Task DownloadInstallerAsync( using FileStream fs = new(destination, FileMode.OpenOrCreate); await response.Content.CopyToAsync(fs); - Logger.Debug("Installer download complete."); + LogUpdateDebug("Installer download complete."); } // ------------------------------------------------------------------ HTTP client @@ -417,7 +1134,7 @@ private static HttpClientHandler CreateHttpClientHandler(UpdaterOverrides overri var handler = new HttpClientHandler(); if (overrides.DisableTlsValidation) { - Logger.Warn("Registry override: TLS certificate validation is disabled for updater requests."); + LogUpdateWarn("Registry override: TLS certificate validation is disabled for updater requests."); handler.ServerCertificateCustomValidationCallback = static (_, _, _, _) => true; } return handler; @@ -433,7 +1150,7 @@ private static bool IsSourceUrlAllowed(string url, bool allowUnsafe) if (allowUnsafe) { - Logger.Warn($"Registry override: allowing potentially unsafe URL {url}"); + LogUpdateWarn($"Registry override: allowing potentially unsafe URL {url}"); return true; } @@ -456,6 +1173,30 @@ private static ProductInfoFile SelectInstallerFile(List files) _ => "x64", }; + if (OperatingSystem.IsMacOS()) + { + ProductInfoFile? mac = + files.FirstOrDefault(f => f.Type.Equals("pkg", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals(arch, StringComparison.OrdinalIgnoreCase)) + ?? files.FirstOrDefault(f => f.Type.Equals("pkg", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals("universal", StringComparison.OrdinalIgnoreCase)) + ?? files.FirstOrDefault(f => f.Type.Equals("pkg", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals("Any", StringComparison.OrdinalIgnoreCase)); + + return mac ?? throw new PlatformArtifactMissingException( + $"No compatible macOS installer (.pkg) found in productinfo for architecture '{arch}'" + ); + } + + if (OperatingSystem.IsLinux()) + { + ProductInfoFile? linux = + files.FirstOrDefault(f => f.Type.Equals("AppImage", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals(arch, StringComparison.OrdinalIgnoreCase)) + ?? files.FirstOrDefault(f => f.Type.Equals("AppImage", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals("universal", StringComparison.OrdinalIgnoreCase)) + ?? files.FirstOrDefault(f => f.Type.Equals("AppImage", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals("Any", StringComparison.OrdinalIgnoreCase)); + + return linux ?? throw new PlatformArtifactMissingException( + $"No compatible Linux installer (.AppImage) found in productinfo for architecture '{arch}'" + ); + } + ProductInfoFile? match = files.FirstOrDefault(f => f.Type.Equals("exe", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals(arch, StringComparison.OrdinalIgnoreCase)) ?? files.FirstOrDefault(f => f.Type.Equals("exe", StringComparison.OrdinalIgnoreCase) && f.Arch.Equals("Any", StringComparison.OrdinalIgnoreCase)) @@ -475,7 +1216,7 @@ private static Version ParseVersionOrFallback(string raw, Version fallback) return CoreTools.NormalizeVersionForComparison(parsed); } - Logger.Warn($"Could not parse version '{raw}', using fallback '{fallback}'"); + LogUpdateWarn($"Could not parse version '{raw}', using fallback '{fallback}'"); return fallback; } @@ -485,13 +1226,25 @@ private static string NormalizeThumbprint(string thumbprint) => // ------------------------------------------------------------------ registry private static UpdaterOverrides LoadUpdaterOverrides() { + if (!OperatingSystem.IsWindows()) + { + return new UpdaterOverrides( + DEFAULT_PRODUCTINFO_URL, + DEFAULT_PRODUCTINFO_KEY, + false, + false, + false, + false + ); + } + #pragma warning disable CA1416 using RegistryKey? key = Registry.LocalMachine.OpenSubKey(REGISTRY_PATH); #if DEBUG if (key is not null) { - Logger.Info($"Updater registry overrides loaded from HKLM\\{REGISTRY_PATH}"); + LogUpdateInfo($"Updater registry overrides loaded from HKLM\\{REGISTRY_PATH}"); } return new UpdaterOverrides( @@ -531,7 +1284,7 @@ private static void LogIgnoredReleaseOverrides(RegistryKey? key) { if (key.GetValue(valueName) is not null) { - Logger.Warn( + LogUpdateWarn( $"Release build is ignoring updater registry value HKLM\\{REGISTRY_PATH}\\{valueName}." ); } @@ -566,6 +1319,8 @@ private static bool GetRegistryBool(RegistryKey? key, string valueName) #endif // ------------------------------------------------------------------ data types + private sealed class PlatformArtifactMissingException(string message) : Exception(message); + private sealed record UpdateCandidate( bool IsUpgradable, string VersionName, diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 45a6fce0bc..34fcb3076f 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -187,6 +187,7 @@ public MainWindowViewModel() AvaloniaAutoUpdater.UpdateAvailable += version => Dispatcher.UIThread.Post(() => { + UpdatesBanner.Severity = InfoBarSeverity.Success; UpdatesBanner.Title = CoreTools.Translate("UniGetUI {0} is ready to be installed.", version); UpdatesBanner.Message = CoreTools.Translate("The update process will start after closing UniGetUI"); UpdatesBanner.ActionButtonText = CoreTools.Translate("Update now"); @@ -195,6 +196,24 @@ public MainWindowViewModel() UpdatesBanner.IsOpen = true; }); + AvaloniaAutoUpdater.StatusChanged += status => Dispatcher.UIThread.Post(() => + { + UpdatesBanner.Severity = status.Severity; + UpdatesBanner.Title = status.Title; + UpdatesBanner.Message = status.Message; + UpdatesBanner.ActionButtonText = status.ActionButtonText ?? ""; + UpdatesBanner.ActionButtonCommand = status.ActionButtonAction is { } action + ? new CommunityToolkit.Mvvm.Input.RelayCommand(action) + : null; + UpdatesBanner.IsClosable = status.IsClosable; + UpdatesBanner.IsOpen = true; + }); + + // If the previous update attempt was killed mid-flow (typically by the + // installer terminating us during file replacement), surface a banner now + // that subscriptions are wired up. + AvaloniaAutoUpdater.CheckForOrphanedUpdateAttempt(); + // Keep OperationsPanelVisible in sync with the live operations list Operations.CollectionChanged += (_, _) => OperationsPanelVisible = Operations.Count > 0; diff --git a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs index 914d947adf..1d2b1c7221 100644 --- a/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/SidebarViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.Views; using UniGetUI.Core.Data; using UniGetUI.Core.SettingsEngine; @@ -83,6 +84,10 @@ public void RequestNavigation(string? pageName) NavigationRequested?.Invoke(this, page); } + [RelayCommand] + private static Task CheckForUpdates() => + AvaloniaAutoUpdater.CheckAndInstallUpdatesAsync(autoLaunch: false, manualCheck: true); + public void SelectNavButtonForPage(PageType page) => SelectedPageType = page; diff --git a/src/UniGetUI.Avalonia/Views/SidebarView.axaml b/src/UniGetUI.Avalonia/Views/SidebarView.axaml index d49d1cd518..f1d5555a11 100644 --- a/src/UniGetUI.Avalonia/Views/SidebarView.axaml +++ b/src/UniGetUI.Avalonia/Views/SidebarView.axaml @@ -231,6 +231,9 @@ + + + diff --git a/src/UniGetUI/AutoUpdater.cs b/src/UniGetUI/AutoUpdater.cs index f5b392ca33..223a51b262 100644 --- a/src/UniGetUI/AutoUpdater.cs +++ b/src/UniGetUI/AutoUpdater.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Text; using System.Text.Json; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -37,8 +38,241 @@ public partial class AutoUpdater public static bool ReleaseLockForAutoupdate_UpdateBanner; public static bool UpdateReadyToBeInstalled { get; private set; } + // ------------------------------------------------------------------ per-attempt log + // Captures auto-updater log entries for the current update attempt. We keep a + // dedicated buffer (in addition to the global session log) so the "View log" + // banner button can show the user only the entries relevant to their failed + // update, instead of dumping the entire noisy session log. + private static readonly Lock _updateLogLock = new(); + private static StringBuilder? _updateLogBuilder; + private static readonly string _updateLogPath = Path.Combine( + Path.GetTempPath(), + "UniGetUI", + "last-update-attempt.log" + ); + + private static void ResetUpdateLog(bool manualCheck, bool autoLaunch) + { + lock (_updateLogLock) + { + _updateLogBuilder = new StringBuilder() + .AppendLine($"=== UniGetUI update attempt started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===") + .AppendLine($"Current version: {CoreData.VersionName} (build {CoreData.BuildNumber})") + .AppendLine($"Manual check: {manualCheck}") + .AppendLine($"Auto-launch: {autoLaunch}") + .AppendLine($"UI: WinUI") + .AppendLine(); + FlushUpdateLogToDiskNoLock(); + } + } + + private static void AppendToUpdateLog(string severity, string message) + { + lock (_updateLogLock) + { + if (_updateLogBuilder is null) return; + _updateLogBuilder.AppendLine($"[{DateTime.Now:HH:mm:ss}] [{severity}] {message}"); + FlushUpdateLogToDiskNoLock(); + } + } + + // Persists the current buffer to _updateLogPath. Caller MUST hold _updateLogLock. + // Failures are silently swallowed — a missing log file should never break the + // update flow itself. + private static void FlushUpdateLogToDiskNoLock() + { + if (_updateLogBuilder is null) return; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_updateLogPath)!); + File.WriteAllText(_updateLogPath, _updateLogBuilder.ToString()); + } + catch { /* see comment above */ } + } + + private const string AttemptFinishedMarker = "=== Attempt finished:"; + + // Appends a structured line indicating the update flow reached a terminal state. + // The presence/absence of this marker on disk lets a subsequent app launch tell + // whether the previous attempt completed cleanly or was killed mid-flow. + private static void MarkAttemptFinished(string outcome) + { + lock (_updateLogLock) + { + if (_updateLogBuilder is null) return; + _updateLogBuilder + .AppendLine() + .AppendLine($"{AttemptFinishedMarker} {outcome} at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); + FlushUpdateLogToDiskNoLock(); + } + } + + private static void RecordTargetVersion(string version) + { + lock (_updateLogLock) + { + _updateLogBuilder?.AppendLine($"Target version: {version}"); + FlushUpdateLogToDiskNoLock(); + } + } + + /// + /// On app startup, detects an interrupted update attempt — the log file + /// from the previous attempt has no , + /// indicating the app was killed mid-flow (almost always because the + /// installer terminated us during file replacement). + /// Caller must have already assigned and + /// so the banner can render. + /// + public static void CheckForOrphanedUpdateAttempt() + { + try + { + if (!File.Exists(_updateLogPath)) return; + + var info = new FileInfo(_updateLogPath); + if ((DateTime.Now - info.LastWriteTime).TotalMinutes > 10) + return; + + string content = File.ReadAllText(_updateLogPath); + if (content.Contains(AttemptFinishedMarker)) + return; + + string currentVer = CoreData.VersionName; + string? targetVer = null; + foreach (string line in content.Split('\n')) + { + if (line.StartsWith("Target version: ")) + { + targetVer = line["Target version: ".Length..].Trim(); + break; + } + } + + if (targetVer is not null && targetVer == currentVer) + { + Logger.Info($"Previous update attempt killed mid-flow but install succeeded (running version {currentVer} matches target). Marking as finished."); + try + { + File.AppendAllText( + _updateLogPath, + $"{Environment.NewLine}{AttemptFinishedMarker} installer succeeded (detected on next launch — running version is {currentVer}) at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}"); + } + catch { /* swallow */ } + return; + } + + Logger.Warn($"Detected interrupted update attempt. Running={currentVer}, Target={targetVer ?? "(unknown)"}"); + + ShowMessage_ThreadSafe( + CoreTools.Translate("Your last update attempt did not complete."), + CoreTools.Translate("UniGetUI could not confirm whether the update succeeded. Open the log to see what happened."), + InfoBarSeverity.Warning, + true, + CreateViewLogButton() + ); + } + catch (Exception ex) + { + Logger.Warn($"Could not check for orphaned update attempt: {ex.Message}"); + } + } + + private static void LogUpdateInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Info(message, caller); + AppendToUpdateLog("INFO ", message); + } + + private static void LogUpdateWarn(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Warn(message, caller); + AppendToUpdateLog("WARN ", message); + } + + private static void LogUpdateWarn(Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Warn(ex, caller); + AppendToUpdateLog("WARN ", ex.ToString()); + } + + private static void LogUpdateError(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Error(message, caller); + AppendToUpdateLog("ERROR", message); + } + + private static void LogUpdateError(Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Error(ex, caller); + AppendToUpdateLog("ERROR", ex.ToString()); + } + + private static void LogUpdateDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller = "") + { + Logger.Debug(message, caller); + AppendToUpdateLog("DEBUG", message); + } + + private static void OpenUpdateLog() + { + // The buffer is flushed to disk on every append/reset, so the file should + // already be current. Only fall back to the full session log if no flow + // has ever run (button shouldn't appear in that case, but be defensive). + string pathToOpen = File.Exists(_updateLogPath) + ? _updateLogPath + : Logger.GetSessionLogPath(); + + try + { + Process.Start(new ProcessStartInfo + { + FileName = pathToOpen, + UseShellExecute = true, + }); + } + catch (Exception ex) + { + Logger.Warn($"Could not open log file '{pathToOpen}': {ex.Message}"); + } + } + + /// + /// Translates an Inno Setup installer exit code into a short human-readable + /// reason. The codes come from the Inno Setup documentation + /// (https://jrsoftware.org/ishelp/index.php?topic=setupexitcodes). + /// + private static string DescribeInstallerExitCode(int code) => code switch + { + 0 => CoreTools.Translate("The installer reported success but did not restart UniGetUI."), + 1 => CoreTools.Translate("The installer failed to initialize."), + 2 => CoreTools.Translate("Setup was canceled before installation began."), + 3 => CoreTools.Translate("A fatal error occurred during the preparation phase."), + 4 => CoreTools.Translate("A fatal error occurred during installation."), + 5 => CoreTools.Translate("Installation was canceled while in progress."), + 6 => CoreTools.Translate("The installer was terminated by another process."), + 7 => CoreTools.Translate("The preparation phase determined the installation cannot proceed."), + 8 => CoreTools.Translate("The installer could not start. UniGetUI may already be running, or you do not have permission to install."), + _ => CoreTools.Translate("Unexpected installer error."), + }; + + private static Button CreateViewLogButton() + { + var btn = new Button { Content = CoreTools.Translate("View log") }; + btn.Click += (_, _) => OpenUpdateLog(); + return btn; + } + public static async Task UpdateCheckLoop(Window window, InfoBar banner) { + Window = window; + Banner = banner; + + // If the previous update attempt was killed mid-flow (typically by the + // installer terminating us during file replacement), surface a banner + // before either entering or short-circuiting the auto-update loop. + CheckForOrphanedUpdateAttempt(); + if (Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) { Logger.Warn("User has disabled updates"); @@ -46,8 +280,6 @@ public static async Task UpdateCheckLoop(Window window, InfoBar banner) } bool IsFirstLaunch = true; - Window = window; - Banner = banner; await CoreTools.WaitForInternetConnection(); while (true) @@ -83,6 +315,7 @@ public static async Task CheckAndInstallUpdates( Window = window; Banner = banner; bool WasCheckingForUpdates = true; + ResetUpdateLog(ManualCheck, AutoLaunch); UpdaterOverrides updaterOverrides = LoadUpdaterOverrides(); try @@ -97,14 +330,15 @@ public static async Task CheckAndInstallUpdates( // Check for updates UpdateCandidate updateCandidate = await GetUpdateCandidate(updaterOverrides); - Logger.Info( + LogUpdateInfo( $"Updater source '{updateCandidate.SourceName}' returned version {updateCandidate.VersionName} (upgradable={updateCandidate.IsUpgradable})" ); if (updateCandidate.IsUpgradable) { WasCheckingForUpdates = false; - Logger.Info( + RecordTargetVersion(updateCandidate.VersionName); + LogUpdateInfo( $"An update to UniGetUI version {updateCandidate.VersionName} is available" ); string InstallerPath = Path.Join( @@ -122,7 +356,7 @@ public static async Task CheckAndInstallUpdates( && CheckInstallerSignerThumbprint(InstallerPath, updaterOverrides) ) { - Logger.Info($"A cached valid installer was found, launching update process..."); + LogUpdateInfo($"A cached valid installer was found, launching update process..."); return await PrepairToLaunchInstaller( InstallerPath, updateCandidate.VersionName, @@ -158,7 +392,7 @@ await CheckInstallerHash( ) && CheckInstallerSignerThumbprint(InstallerPath, updaterOverrides) ) { - Logger.Info("The downloaded installer is valid, launching update process..."); + LogUpdateInfo("The downloaded installer is valid, launching update process..."); return await PrepairToLaunchInstaller( InstallerPath, updateCandidate.VersionName, @@ -171,8 +405,10 @@ await CheckInstallerHash( CoreTools.Translate("The installer authenticity could not be verified."), CoreTools.Translate("The update process has been aborted."), InfoBarSeverity.Error, - true + true, + CreateViewLogButton() ); + MarkAttemptFinished("authenticity verification failed"); return false; } @@ -183,20 +419,23 @@ await CheckInstallerHash( InfoBarSeverity.Success, true ); + MarkAttemptFinished("no update available"); return true; } catch (Exception e) { - Logger.Error("An error occurred while checking for updates: "); - Logger.Error(e); + LogUpdateError("An error occurred while checking for updates: "); + LogUpdateError(e); // We don't want an error popping if updates can't if (Verbose || !WasCheckingForUpdates) ShowMessage_ThreadSafe( CoreTools.Translate("An error occurred when checking for updates: "), e.Message, InfoBarSeverity.Error, - true + true, + CreateViewLogButton() ); + MarkAttemptFinished($"exception: {e.Message}"); return false; } } @@ -213,7 +452,7 @@ private static async Task CheckForUpdatesFromProductInfo( UpdaterOverrides updaterOverrides ) { - Logger.Debug( + LogUpdateDebug( $"Begin check for updates on productinfo source {updaterOverrides.ProductInfoUrl}" ); @@ -283,7 +522,7 @@ out ProductInfoProduct? product Version availableVersion = ParseVersionOrFallback(channel.Version, new Version(0, 0, 0, 0)); bool isUpgradable = availableVersion > currentVersion; - Logger.Debug( + LogUpdateDebug( $"Productinfo check result: current={currentVersion}, available={availableVersion}, upgradable={isUpgradable}" ); @@ -307,11 +546,11 @@ UpdaterOverrides updaterOverrides { if (updaterOverrides.SkipHashValidation) { - Logger.Warn("Registry override enabled: skipping updater hash validation."); + LogUpdateWarn("Registry override enabled: skipping updater hash validation."); return true; } - Logger.Debug($"Checking updater hash on location {installerLocation}"); + LogUpdateDebug($"Checking updater hash on location {installerLocation}"); using (FileStream stream = File.OpenRead(installerLocation)) { string hash = Convert @@ -319,10 +558,10 @@ UpdaterOverrides updaterOverrides .ToLower(); if (hash == expectedHash.ToLower()) { - Logger.Debug($"The hashes match ({hash})"); + LogUpdateDebug($"The hashes match ({hash})"); return true; } - Logger.Warn($"Hash mismatch.\nExpected: {expectedHash}\nGot: {hash}"); + LogUpdateWarn($"Hash mismatch.\nExpected: {expectedHash}\nGot: {hash}"); return false; } } @@ -334,7 +573,7 @@ UpdaterOverrides updaterOverrides { if (updaterOverrides.SkipSignerThumbprintCheck) { - Logger.Warn( + LogUpdateWarn( "Registry override enabled: skipping updater signer thumbprint validation." ); return true; @@ -352,7 +591,7 @@ UpdaterOverrides updaterOverrides string signerThumbprint = NormalizeThumbprint(cert.Thumbprint ?? string.Empty); if (string.IsNullOrWhiteSpace(signerThumbprint)) { - Logger.Warn( + LogUpdateWarn( $"Could not read signer thumbprint for installer '{installerLocation}'" ); return false; @@ -365,17 +604,17 @@ UpdaterOverrides updaterOverrides ) ) { - Logger.Debug($"Installer signer thumbprint is trusted: {signerThumbprint}"); + LogUpdateDebug($"Installer signer thumbprint is trusted: {signerThumbprint}"); return true; } - Logger.Warn($"Installer signer thumbprint is not trusted. Got: {signerThumbprint}"); + LogUpdateWarn($"Installer signer thumbprint is not trusted. Got: {signerThumbprint}"); return false; } catch (Exception ex) { - Logger.Warn("Could not validate installer signer thumbprint"); - Logger.Warn(ex); + LogUpdateWarn("Could not validate installer signer thumbprint"); + LogUpdateWarn(ex); return false; } } @@ -394,7 +633,7 @@ UpdaterOverrides updaterOverrides throw new InvalidOperationException($"Download URL is not allowed: {downloadUrl}"); } - Logger.Debug($"Downloading installer from {downloadUrl} to {installerLocation}"); + LogUpdateDebug($"Downloading installer from {downloadUrl} to {installerLocation}"); using (HttpClient client = new(CreateHttpClientHandler(updaterOverrides))) { client.Timeout = TimeSpan.FromSeconds(600); @@ -404,7 +643,7 @@ UpdaterOverrides updaterOverrides using FileStream fs = new(installerLocation, FileMode.OpenOrCreate); await result.Content.CopyToAsync(fs); } - Logger.Debug("The download has finished successfully"); + LogUpdateDebug("The download has finished successfully"); } /// @@ -417,7 +656,7 @@ private static async Task PrepairToLaunchInstaller( bool ManualCheck ) { - Logger.Debug("Starting the process to launch the installer."); + LogUpdateDebug("Starting the process to launch the installer."); UpdateReadyToBeInstalled = true; ReleaseLockForAutoupdate_Window = false; ReleaseLockForAutoupdate_Notification = false; @@ -428,7 +667,8 @@ bool ManualCheck { // Banner is a UI element; always touch it from the UI thread. Window.DispatcherQueue.TryEnqueue(() => Banner.IsOpen = false); - Logger.Warn("User disabled updates!"); + LogUpdateWarn("User disabled updates!"); + MarkAttemptFinished("aborted - auto-update disabled before launch"); return true; } @@ -472,11 +712,11 @@ bool ManualCheck if (AutoLaunch && !Window.Visible) { - Logger.Debug("AutoLaunch is enabled and the Window is hidden, launching installer..."); + LogUpdateDebug("AutoLaunch is enabled and the Window is hidden, launching installer..."); } else { - Logger.Debug( + LogUpdateDebug( "Waiting for mainWindow to be closed or for user to trigger the update from the notification..." ); while ( @@ -487,12 +727,13 @@ bool ManualCheck { await Task.Delay(100); } - Logger.Debug("Autoupdater lock released, launching installer..."); + LogUpdateDebug("Autoupdater lock released, launching installer..."); } if (!ManualCheck && Settings.Get(Settings.K.DisableAutoUpdateWingetUI)) { - Logger.Warn("User has disabled updates"); + LogUpdateWarn("User has disabled updates"); + MarkAttemptFinished("aborted - auto-update disabled while waiting"); return true; } @@ -501,11 +742,13 @@ bool ManualCheck } /// - /// Launches the installer located on the installerLocation argument and quits UniGetUI + /// Launches the installer located on the installerLocation argument. The installer + /// is expected to terminate UniGetUI before file replacement; if it returns control + /// to us, we surface the exit code so the user has something concrete to act on. /// private static async Task LaunchInstallerAndQuit(string installerLocation) { - Logger.Debug("Launching the updater..."); + LogUpdateInfo($"Launching installer: {installerLocation}"); using Process p = new() { StartInfo = new() @@ -517,20 +760,88 @@ private static async Task LaunchInstallerAndQuit(string installerLocation) CreateNoWindow = true, }, }; - p.Start(); + + bool started; + try + { + started = p.Start(); + } + catch (Exception ex) + { + LogUpdateError("Process.Start threw while launching the installer:"); + LogUpdateError(ex); + ShowMessage_ThreadSafe( + CoreTools.Translate("The updater could not be launched."), + ex.Message, + InfoBarSeverity.Error, + true, + CreateViewLogButton() + ); + MarkAttemptFinished($"installer launch threw: {ex.Message}"); + return; + } + + if (!started) + { + LogUpdateError("Failed to start installer process (Process.Start returned false)."); + ShowMessage_ThreadSafe( + CoreTools.Translate("The updater could not be launched."), + CoreTools.Translate("The operating system did not start the installer process."), + InfoBarSeverity.Error, + true, + CreateViewLogButton() + ); + MarkAttemptFinished("Process.Start returned false"); + return; + } + + LogUpdateInfo($"Installer process started (PID {p.Id}). The installer is expected to terminate UniGetUI before file replacement."); + ShowMessage_ThreadSafe( CoreTools.Translate("UniGetUI is being updated..."), CoreTools.Translate("This may take a minute or two"), InfoBarSeverity.Informational, false ); + await p.WaitForExitAsync(); + + // If we reach here, the installer exited without terminating this process. + // Distinguish two cases: + // - Exit code 0: installer succeeded; the new version IS installed at the + // install location, but the running copy was not replaced (almost always + // because UniGetUI is running from outside the install location — typically + // a development build). This is not really an error. + // - Any other code: installer reported a failure; the update did not apply. + int exitCode = p.ExitCode; + string reason = DescribeInstallerExitCode(exitCode); + + if (exitCode == 0) + { + string runningPath = Environment.ProcessPath ?? "(unknown)"; + LogUpdateWarn($"Installer reported success (exit code 0) but did not replace this running copy. Running from: {runningPath}"); + + ShowMessage_ThreadSafe( + CoreTools.Translate("Update installed."), + CoreTools.Translate("UniGetUI was updated successfully, but this running copy was not replaced. This usually means you are running a development build. Close this copy and start the newly-installed version to finish."), + InfoBarSeverity.Warning, + true, + CreateViewLogButton() + ); + MarkAttemptFinished("installer succeeded but did not replace running copy"); + return; + } + + LogUpdateError($"Installer exited with code {exitCode} ({reason}) without restarting UniGetUI."); + ShowMessage_ThreadSafe( - CoreTools.Translate("Something went wrong while launching the updater."), - CoreTools.Translate("Please try again later"), + CoreTools.Translate("The update could not be applied."), + CoreTools.Translate("Installer exit code {0}: {1}", exitCode, reason), InfoBarSeverity.Error, - true + true, + CreateViewLogButton() ); + MarkAttemptFinished($"installer failed with code {exitCode}"); } private static void ShowMessage_ThreadSafe( diff --git a/src/UniGetUI/Pages/MainView.xaml b/src/UniGetUI/Pages/MainView.xaml index 74edef0555..dc8b403864 100644 --- a/src/UniGetUI/Pages/MainView.xaml +++ b/src/UniGetUI/Pages/MainView.xaml @@ -47,6 +47,12 @@ IconName="megaphone" Text="Release notes" /> + _ = DialogHelper.ShowReleaseNotes(); + private void CheckForUpdates_Click(object sender, RoutedEventArgs e) + { + var mainWindow = MainApp.Instance.MainWindow; + _ = AutoUpdater.CheckAndInstallUpdates(mainWindow, mainWindow.UpdatesBanner, true, false, true); + } + private void OperationHistoryMenu_Click(object sender, RoutedEventArgs e) => NavigateTo(PageType.OperationHistory);