diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs index 61e57009..b95a1d5b 100644 --- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs @@ -50,6 +50,14 @@ public GeneralUpdateBootstrap() public override async Task LaunchAsync() { int appType = GetOption(UpdateOptions.AppType); + + // Silent mode: start background poll and return immediately + if (appType == AppType.ClientApp && GetOption(UpdateOptions.Silent)) + { + await LaunchSilentAsync().ConfigureAwait(false); + return this; + } + return appType switch { AppType.ClientApp => await LaunchWithStrategy(new ClientUpdateStrategy()), @@ -250,6 +258,35 @@ private void ApplyRuntimeOptions() _configInfo.DownloadTimeOut = GetOption(UpdateOptions.DownloadTimeout) ?? 60; } + /// + /// Silent update mode — starts a background poll loop and returns immediately. + /// The orchestrator checks for updates periodically and prepares them. + /// When the host process exits, the prepared update is applied. + /// + private async Task LaunchSilentAsync() + { + GeneralTracer.Info("GeneralUpdateBootstrap: starting silent update mode."); + + var pollMinutes = GetOption(UpdateOptions.SilentPollIntervalMinutes); + var autoInstall = GetOption(UpdateOptions.SilentAutoInstall); + + var silentOptions = new Silent.SilentOptions + { + PollInterval = TimeSpan.FromMinutes(pollMinutes), + AutoInstall = autoInstall + }; + + var hooks = ResolveExtension() ?? new Hooks.NoOpUpdateHooks(); + var reporter = ResolveExtension() ?? new Download.Reporting.NoOpUpdateReporter(); + + var orchestrator = new Silent.SilentPollOrchestrator(_configInfo, silentOptions) + .WithHooks(hooks) + .WithReporter(reporter); + + await orchestrator.StartAsync().ConfigureAwait(false); + GeneralTracer.Info("GeneralUpdateBootstrap: silent update mode started, returning to caller."); + } + private void InitBlackList() { BlackListManager.Instance.AddBlackFiles(_configInfo.BlackFiles); diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs index 90f6b931..ec32ade5 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs @@ -34,6 +34,7 @@ public static class UpdateOptions public static UpdateOption UpgradeClientVersion { get; } = UpdateOption.ValueOf("UPGRADECLIENTVERSION", null); public static UpdateOption Platform { get; } = UpdateOption.ValueOf("PLATFORM", null); public static UpdateOption SilentAutoInstall { get; } = UpdateOption.ValueOf("SILENTAUTOINSTALL", false); + public static UpdateOption SilentPollIntervalMinutes { get; } = UpdateOption.ValueOf("SILENTPOLLINTERVALMINUTES", 60); public static UpdateOption MaxConcurrency { get; } = UpdateOption.ValueOf("MAXCONCURRENCY", 3); public static UpdateOption EnableResume { get; } = UpdateOption.ValueOf("ENABLERESUME", true); public static UpdateOption RetryCount { get; } = UpdateOption.ValueOf("RETRYCOUNT", 3); diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs index 42437231..038d0e4e 100644 --- a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using GeneralUpdate.Core.Download.Abstractions; using GeneralUpdate.Core.Download.Models; namespace GeneralUpdate.Core.Download; @@ -86,6 +87,25 @@ private static bool IsCompatible(string? minClientVersion, string currentVersion return cur >= min; } + /// Map a PacketDTO to a DownloadAsset. Public for use by download sources. + public static DownloadAsset MapToAsset(Abstractions.PacketDTO p) + { + return new DownloadAsset( + Name: p.Name ?? p.Version ?? "unknown", + Url: p.Url ?? string.Empty, + Size: p.Size ?? 0, + SHA256: p.Hash, + Version: p.Version ?? "0.0.0", + IsCrossVersion: p.IsCrossVersion == true, + FromVersion: p.FromVersion, + MinClientVersion: p.MinClientVersion, + SourceArchiveHash: p.SourceArchiveHash, + TargetArchiveHash: p.TargetArchiveHash, + IsForcibly: p.IsForcibly == true, + IsFreeze: p.IsFreeze == true + ); + } + /// Parse a version string, returning null on failure. private static Version? ParseVersion(string? version) { diff --git a/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs new file mode 100644 index 00000000..dc0e3a16 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Download.Abstractions; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Hubs; +using GeneralUpdate.Core.JsonContext; + +namespace GeneralUpdate.Core.Download.Sources; + +/// +/// SignalR Hub download source — receives update push notifications +/// and converts them to DownloadAssets for the orchestrator. +/// +public class HubDownloadSource : IDownloadSource, IDisposable +{ + private readonly string _hubUrl; + private readonly string? _token; + private readonly string? _appKey; + private readonly ConcurrentBag _assets = new(); + private readonly TaskCompletionSource _initializedTcs = new(); + private UpgradeHubService? _hub; + + public HubDownloadSource(string hubUrl, string? token = null, string? appKey = null) + { + _hubUrl = hubUrl; + _token = token; + _appKey = appKey; + } + + /// Start listening to the SignalR hub. + public async Task StartAsync() + { + try + { + _hub = new UpgradeHubService(_hubUrl, _token, _appKey); + _hub.AddListenerReceive(OnReceiveMessage); + await _hub.StartAsync().ConfigureAwait(false); + _initializedTcs.TrySetResult(true); + } + catch (Exception ex) + { + _initializedTcs.TrySetException(ex); + } + } + + private void OnReceiveMessage(string json) + { + try + { + var packet = System.Text.Json.JsonSerializer.Deserialize(json); + if (packet != null) + { + var asset = DownloadPlanBuilder.MapToAsset(packet); + _assets.Add(asset); + } + } + catch (Exception ex) + { + GeneralTracer.Warn($"HubDownloadSource: failed to parse message: {ex.Message}"); + } + } + + /// Get accumulated download assets from hub pushes. + public async Task> ListAsync(CancellationToken token = default) + { + // Wait for hub initialization + await _initializedTcs.Task.ConfigureAwait(false); + + // Wait a brief moment for any pending messages to arrive + try { await Task.Delay(100, token).ConfigureAwait(false); } + catch (OperationCanceledException) { } + + return _assets.ToList(); + } + + public void Dispose() + { + _hub?.DisposeAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs b/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs new file mode 100644 index 00000000..d74e4c28 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Download.Models; +using GeneralUpdate.Core.Download.Reporting; +using GeneralUpdate.Core.Download.Sources; +using GeneralUpdate.Core.Event; +using GeneralUpdate.Core.FileSystem; +using GeneralUpdate.Core.Hooks; +using GeneralUpdate.Core.JsonContext; +using GeneralUpdate.Core.Strategy; + +namespace GeneralUpdate.Core.Silent; + +/// +/// Silent update poll orchestrator — periodically checks for updates, +/// downloads them in the background, and optionally auto-installs. +/// Replaces the legacy SilentUpdateMode class. +/// +public class SilentPollOrchestrator : IDisposable +{ + private readonly GlobalConfigInfo _configInfo; + private readonly SilentOptions _options; + private CancellationTokenSource? _cts; + private Task? _pollingTask; + private int _prepared; + private int _updaterStarted; + private IUpdateHooks? _hooks; + private Download.Reporting.IUpdateReporter? _reporter; + + public SilentPollOrchestrator(GlobalConfigInfo configInfo, SilentOptions options) + { + _configInfo = configInfo ?? throw new ArgumentNullException(nameof(configInfo)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// Inject hooks and reporter. + public SilentPollOrchestrator WithHooks(IUpdateHooks? hooks) { _hooks = hooks; return this; } + public SilentPollOrchestrator WithReporter(Download.Reporting.IUpdateReporter? reporter) { _reporter = reporter; return this; } + + /// Start background polling loop. + public Task StartAsync() + { + GeneralTracer.Info($"SilentPollOrchestrator: starting. PollInterval={_options.PollInterval.TotalMinutes}min, AutoInstall={_options.AutoInstall}"); + + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; + _cts = new CancellationTokenSource(); + _pollingTask = Task.Run(() => PollLoopAsync(_cts.Token)); + + _pollingTask.ContinueWith(task => + { + if (task.Exception != null) + GeneralTracer.Error("SilentPollOrchestrator: polling exception.", task.Exception); + }, TaskContinuationOptions.OnlyOnFaulted); + + return Task.CompletedTask; + } + + /// Stop polling and cancel any in-flight operation. + public void Stop() + { + _cts?.Cancel(); + AppDomain.CurrentDomain.ProcessExit -= OnProcessExit; + } + + private async Task PollLoopAsync(CancellationToken token) + { + GeneralTracer.Info("SilentPollOrchestrator: polling loop started."); + while (!token.IsCancellationRequested && Volatile.Read(ref _prepared) == 0) + { + try + { + await PrepareUpdateIfNeededAsync(token).ConfigureAwait(false); + } + catch (Exception ex) + { + GeneralTracer.Error("SilentPollOrchestrator: poll cycle failed.", ex); + } + + if (Volatile.Read(ref _prepared) == 1) break; + + try { await Task.Delay(_options.PollInterval, token).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + } + } + + private async Task PrepareUpdateIfNeededAsync(CancellationToken token) + { + GeneralTracer.Info($"SilentPollOrchestrator: checking for updates. Url={_configInfo.UpdateUrl}"); + + // Use the new download source + var downloadSource = new HttpDownloadSource( + _configInfo.UpdateUrl, + _configInfo.ClientVersion, + _configInfo.UpgradeClientVersion, + _configInfo.AppSecretKey, + GetPlatform(), + _configInfo.ProductId, + _configInfo.Scheme, + _configInfo.Token); + + var assets = await downloadSource.ListAsync(token).ConfigureAwait(false); + var plan = DownloadPlanBuilder.Build(assets, _configInfo.ClientVersion); + + if (!plan.HasAssets) + { + GeneralTracer.Info("SilentPollOrchestrator: no update available."); + return; + } + + var latestVersion = plan.Assets.LastOrDefault()?.Version; + if (CheckFail(latestVersion)) + { + GeneralTracer.Warn($"SilentPollOrchestrator: version {latestVersion} is a known-failed upgrade, skipping."); + return; + } + + // Configure for update + BlackListManager.Instance?.AddBlackFiles(_configInfo.BlackFiles); + BlackListManager.Instance?.AddBlackFormats(_configInfo.BlackFormats); + BlackListManager.Instance?.AddSkipDirectorys(_configInfo.SkipDirectorys); + + _configInfo.LastVersion = latestVersion; + _configInfo.UpdateVersions = new List(); // legacy compat + _configInfo.TempPath = StorageManager.GetTempDirectory("silent_temp"); + _configInfo.BackupDirectory = Path.Combine(_configInfo.InstallPath, + $"{StorageManager.DirectoryName}{_configInfo.ClientVersion}"); + + // Backup + StorageManager.Backup(_configInfo.InstallPath, _configInfo.BackupDirectory, + BlackListManager.Instance.SkipDirectorys); + + // Build ProcessInfo + var processInfo = ConfigurationMapper.MapToProcessInfo( + _configInfo, new List(), + BlackListManager.Instance.BlackFormats.ToList(), + BlackListManager.Instance.BlackFiles.ToList(), + BlackListManager.Instance.SkipDirectorys.ToList()); + _configInfo.ProcessInfo = JsonSerializer.Serialize(processInfo, ProcessInfoJsonContext.Default.ProcessInfo); + + // Download using new orchestrator + GeneralTracer.Info($"SilentPollOrchestrator: downloading {plan.Assets.Count} asset(s)."); + var httpClient = new System.Net.Http.HttpClient(); + try + { + var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient); + var report = await orchestrator.ExecuteAsync(plan, _configInfo.TempPath, token: token).ConfigureAwait(false); + GeneralTracer.Info($"SilentPollOrchestrator: download complete. Success={report.SuccessCount}, Failed={report.FailedCount}"); + } + finally { httpClient.Dispose(); } + + // Execute pipeline + var strategy = CreateStrategy(); + strategy.Create(_configInfo); + await strategy.ExecuteAsync(); + + GeneralTracer.Info("SilentPollOrchestrator: update prepared."); + Interlocked.Exchange(ref _prepared, 1); + } + + private void OnProcessExit(object? sender, EventArgs e) + { + if (Volatile.Read(ref _prepared) != 1 || Interlocked.Exchange(ref _updaterStarted, 1) == 1) return; + + try + { + Environments.SetEnvironmentVariable("ProcessInfo", _configInfo.ProcessInfo ?? string.Empty); + var updaterPath = Path.Combine(_configInfo.InstallPath, _configInfo.AppName); + if (File.Exists(updaterPath)) + { + GeneralTracer.Info($"SilentPollOrchestrator: launching updater {updaterPath}"); + Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = updaterPath }); + } + } + catch (Exception ex) + { + GeneralTracer.Error("SilentPollOrchestrator: OnProcessExit failed.", ex); + } + } + + private static IStrategy CreateStrategy() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return new WindowsStrategy(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return new LinuxStrategy(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return new MacStrategy(); + throw new PlatformNotSupportedException(); + } + + private static int GetPlatform() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return PlatformType.Windows; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return PlatformType.Linux; + return -1; + } + + private static bool CheckFail(string? version) + { + if (string.IsNullOrEmpty(version)) return false; + var fail = Environments.GetEnvironmentVariable("UpgradeFail"); + if (string.IsNullOrEmpty(fail)) return false; + return new Version(fail) >= new Version(version); + } + + public void Dispose() + { + Stop(); + _cts?.Dispose(); + } +} + +/// Silent polling configuration. +public sealed class SilentOptions +{ + /// Polling interval (default 1 hour). + public TimeSpan PollInterval { get; set; } = TimeSpan.FromHours(1); + + /// Whether to auto-install after download. + public bool AutoInstall { get; set; } = false; +} diff --git a/src/c#/GeneralUpdate.Core/Silent/SilentUpdateMode.cs b/src/c#/GeneralUpdate.Core/Silent/SilentUpdateMode.cs index a2d539eb..e28b4479 100644 --- a/src/c#/GeneralUpdate.Core/Silent/SilentUpdateMode.cs +++ b/src/c#/GeneralUpdate.Core/Silent/SilentUpdateMode.cs @@ -21,6 +21,7 @@ namespace GeneralUpdate.Core; +[Obsolete("Use SilentPollOrchestrator instead. Will be removed in v11.")] internal sealed class SilentUpdateMode { private const string ProcessInfoEnvironmentKey = "ProcessInfo";