diff --git a/src/c#/DifferentialTest/Pipeline/DiffPipelineOptionsTests.cs b/src/c#/DifferentialTest/Pipeline/DiffPipelineOptionsTests.cs index a3fe499d..4ab380f8 100644 --- a/src/c#/DifferentialTest/Pipeline/DiffPipelineOptionsTests.cs +++ b/src/c#/DifferentialTest/Pipeline/DiffPipelineOptionsTests.cs @@ -4,7 +4,7 @@ namespace DifferentialTest.Pipeline { /// /// 分支覆盖点: - /// 1. MaxDegreeOfParallelism — 默认 = Environment.ProcessorCount + /// 1. MaxDegreeOfParallelism — 默认 = 2 /// 2. StopOnFirstError — 默认 = false /// 3. DeletePatchAfterApply — 默认 = true /// 4. 属性 set/get — 修改后正确返回 @@ -15,12 +15,12 @@ namespace DifferentialTest.Pipeline /// public class DiffPipelineOptionsTests { - [Fact(DisplayName = "默认构造_MaxDegreeOfParallelism为Environment.ProcessorCount")] - public void DefaultConstructor_MaxDegreeOfParallelism_EqualsProcessorCount() + [Fact(DisplayName = "默认构造_MaxDegreeOfParallelism为2")] + public void DefaultConstructor_MaxDegreeOfParallelism_Equals2() { var options = new DiffPipelineOptions(); - Assert.Equal(Environment.ProcessorCount, options.MaxDegreeOfParallelism); + Assert.Equal(2, options.MaxDegreeOfParallelism); } [Fact(DisplayName = "默认构造_StopOnFirstError为false")] diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs index 12bc917b..f8c0c055 100644 --- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs @@ -65,6 +65,7 @@ public void Cancel() public override async Task LaunchAsync() { var appType = GetOption(UpdateOptions.AppType); + _configInfo.AppType = appType; // Silent mode: start background poll and return immediately if (appType == AppType.Client && GetOption(UpdateOptions.Silent)) @@ -75,9 +76,9 @@ public override async Task LaunchAsync() return appType switch { - AppType.Client => await LaunchWithStrategy(new ClientUpdateStrategy()), + AppType.Client => await LaunchWithStrategy(new ClientUpdateStrategy()), AppType.Upgrade => await LaunchWithStrategy(new UpgradeUpdateStrategy()), - AppType.OSSClient => await LaunchWithStrategy(new OSSUpdateStrategy(AppType.OSSClient)), + AppType.OSSClient => await LaunchWithStrategy(new OSSUpdateStrategy(AppType.OSSClient)), AppType.OSSUpgrade => await LaunchWithStrategy(new OSSUpdateStrategy(AppType.OSSUpgrade)), _ => await LaunchWithStrategy(new ClientUpdateStrategy()) }; @@ -94,68 +95,40 @@ private async Task LaunchWithStrategy(IStrategy roleStra // Resolve hooks and reporter from extensions var hooks = ResolveExtension() ?? new Hooks.NoOpUpdateHooks(); - var reporter = ResolveExtension() ?? new Download.Reporting.NoOpUpdateReporter(); + var reporter = ResolveExtension() ?? + new Download.Reporting.NoOpUpdateReporter(); - // Configure client-specific callbacks - if (roleStrategy is ClientUpdateStrategy clientStrat) - { - clientStrat.Hooks = hooks; - clientStrat.Reporter = reporter; - // Resolve DownloadSource from extension registry (Hub, custom, etc.) - var resolvedSource = ResolveExtension(); - - // Inject SignalR Hub download source if configured - if (resolvedSource == null) - { - var hubConfig = GetOption(UpdateOptions.Hub); - if (hubConfig != null && !string.IsNullOrEmpty(hubConfig.Url)) - { - var hubSource = new Download.Sources.HubDownloadSource( - hubConfig.Url, _configInfo.Token, _configInfo.AppSecretKey); - await hubSource.StartAsync().ConfigureAwait(false); - resolvedSource = hubSource; - GeneralTracer.Info("GeneralUpdateBootstrap: HubDownloadSource started from HubConfig."); - } - } - clientStrat.DownloadSource = resolvedSource; - if (_updatePrecheck != null) - clientStrat.UseUpdatePrecheck(_updatePrecheck); - await CallSmallBowlHomeAsync(_configInfo.Bowl).ConfigureAwait(false); - } - else if (roleStrategy is UpgradeUpdateStrategy upgradeStrat) - { - upgradeStrat.Hooks = hooks; - upgradeStrat.Reporter = reporter; - } - else if (roleStrategy is OSSUpdateStrategy ossStrat) - { - ossStrat.Hooks = hooks; - ossStrat.Reporter = reporter; - } + // ── Phase 1: inject all dependencies before Create ── + roleStrategy.Hooks = hooks; + roleStrategy.Reporter = reporter; - roleStrategy.Create(_configInfo); - var binaryDiffer = ResolveExtension(); var dirtyStrategy = ResolveExtension(); + var diffPipeline = BuildDiffPipeline(); - if (roleStrategy is ClientUpdateStrategy cs2) - { - if (binaryDiffer != null) cs2.SetBinaryDiffer(binaryDiffer); - if (dirtyStrategy != null) cs2.SetDirtyStrategy(dirtyStrategy); - } - else if (roleStrategy is UpgradeUpdateStrategy us2) + switch (roleStrategy) { - if (binaryDiffer != null) us2.SetBinaryDiffer(binaryDiffer); - if (dirtyStrategy != null) us2.SetDirtyStrategy(dirtyStrategy); + case ClientUpdateStrategy cs: + cs.DownloadSource = ResolveExtension(); + + if (_updatePrecheck != null) + cs.UseUpdatePrecheck(_updatePrecheck); + + await CallSmallBowlHomeAsync(_configInfo.Bowl).ConfigureAwait(false); + + if (binaryDiffer != null) cs.SetBinaryDiffer(binaryDiffer); + if (dirtyStrategy != null) cs.SetDirtyStrategy(dirtyStrategy); + cs.SetDiffPipeline(diffPipeline); + break; + + case UpgradeUpdateStrategy us: + if (binaryDiffer != null) us.SetBinaryDiffer(binaryDiffer); + if (dirtyStrategy != null) us.SetDirtyStrategy(dirtyStrategy); + us.SetDiffPipeline(diffPipeline); + break; } - // Build DiffPipeline — user‑configured or default with BsdiffDiffer, - // parallelism=2, and progress reporter wired to AddListenerProgress. - var diffPipeline = BuildDiffPipeline(); - if (roleStrategy is ClientUpdateStrategy cs3) - cs3.SetDiffPipeline(diffPipeline); - else if (roleStrategy is UpgradeUpdateStrategy us3) - us3.SetDiffPipeline(diffPipeline); + roleStrategy.Create(_configInfo); // Check custom skip condition before executing update if (_customSkipOption?.Invoke() == true) @@ -173,24 +146,21 @@ private async Task LaunchWithStrategy(IStrategy roleStra } finally { - // Dispose HubDownloadSource if it was started - if (roleStrategy is ClientUpdateStrategy cs && cs.DownloadSource is IAsyncDisposable ad) - await ad.DisposeAsync(); _cts?.Dispose(); _cts = null; } + return this; } - - + // ════════════════════════════════════════════════════════════════ // Configuration // ════════════════════════════════════════════════════════════════ public GeneralUpdateBootstrap SetConfig(Configinfo configInfo) - { - configInfo.Validate(); - _configInfo = ConfigurationMapper.MapToGlobalConfigInfo(configInfo); + { + configInfo.Validate(); + _configInfo = ConfigurationMapper.MapToGlobalConfigInfo(configInfo); var appType = GetOption(UpdateOptions.AppType); if (appType != AppType.Upgrade) @@ -223,7 +193,7 @@ public GeneralUpdateBootstrap SetConfig(string filePath) // Resolve filename-only paths to current directory var hasPathChar = filePath.Contains(Path.DirectorySeparatorChar) - || filePath.Contains(Path.AltDirectorySeparatorChar); + || filePath.Contains(Path.AltDirectorySeparatorChar); var fullPath = hasPathChar ? Path.GetFullPath(filePath) : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, filePath); @@ -277,16 +247,18 @@ private void InitializeFromEnvironment() LastVersion = processInfo.LastVersion, UpdateLogUrl = processInfo.UpdateLogUrl, Encoding = Encoding.GetEncoding(processInfo.CompressEncoding), - Format = processInfo.CompressFormat, + Format = ParseFormat(processInfo.CompressFormat), DownloadTimeOut = processInfo.DownloadTimeOut, AppSecretKey = processInfo.AppSecretKey, UpdateVersions = processInfo.UpdateVersions, - TempPath = StorageManager.GetTempDirectory("upgrade_temp"), + TempPath = processInfo.TempPath, ReportUrl = processInfo.ReportUrl, BackupDirectory = processInfo.BackupDirectory, Scheme = processInfo.Scheme, Token = processInfo.Token, DriverDirectory = processInfo.DriverDirectory, + UpdatePath = processInfo.UpdatePath, + LaunchClientAfterUpdate = processInfo.LaunchClientAfterUpdate, BlackFiles = processInfo.BlackFiles ?? BlackListDefaults.DefaultBlackFiles, BlackFormats = processInfo.BlackFileFormats ?? BlackListDefaults.DefaultBlackFormats, SkipDirectorys = processInfo.SkipDirectorys ?? BlackListDefaults.DefaultSkipDirectories @@ -306,11 +278,7 @@ private void ApplyRuntimeOptions() { // Preserve Upgrade path values set by InitializeFromEnvironment() _configInfo.Encoding ??= GetOption(UpdateOptions.Encoding); - _configInfo.Format ??= GetOption(UpdateOptions.Format); - // Normalize legacy "ZIP" default (UpdateOptions) to Format.ZIP (".zip") - // so the pipeline constructs correct paths and CompressProvider matches its switch. - if (_configInfo.Format == "ZIP") - _configInfo.Format = Format.ZIP; + _configInfo.Format = GetOption(UpdateOptions.Format); if (_configInfo.DownloadTimeOut <= 0) _configInfo.DownloadTimeOut = GetOption(UpdateOptions.DownloadTimeout) ?? 60; @@ -338,16 +306,15 @@ 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 + PollInterval = TimeSpan.FromMinutes(pollMinutes) }; var hooks = ResolveExtension() ?? new Hooks.NoOpUpdateHooks(); - var reporter = ResolveExtension() ?? new Download.Reporting.NoOpUpdateReporter(); + var reporter = ResolveExtension() ?? + new Download.Reporting.NoOpUpdateReporter(); var orchestrator = new Silent.SilentPollOrchestrator(_configInfo, silentOptions) .WithHooks(hooks) @@ -371,6 +338,16 @@ private DiffPipeline BuildDiffPipeline() .Build(); } + private static Format ParseFormat(string? compressFormat) + { + if (string.IsNullOrWhiteSpace(compressFormat)) return Format.Zip; + return compressFormat switch + { + ".zip" => Format.Zip, + _ => Format.Zip + }; + } + private void InitBlackList() { // Build blacklist matcher from GlobalConfigInfo and set on StorageManager. @@ -378,7 +355,9 @@ private void InitBlackList() var effectiveConfig = new BlackListConfig( _configInfo.BlackFiles?.Count > 0 ? _configInfo.BlackFiles : BlackListDefaults.DefaultBlackFiles, _configInfo.BlackFormats?.Count > 0 ? _configInfo.BlackFormats : BlackListDefaults.DefaultBlackFormats, - _configInfo.SkipDirectorys?.Count > 0 ? _configInfo.SkipDirectorys : BlackListDefaults.DefaultSkipDirectories + _configInfo.SkipDirectorys?.Count > 0 + ? _configInfo.SkipDirectorys + : BlackListDefaults.DefaultSkipDirectories ); StorageManager.BlackListMatcher = new DefaultBlackListMatcher(effectiveConfig); } @@ -449,4 +428,4 @@ public GeneralUpdateBootstrap AddListenerProgress( AddListener((s, e) => listener.OnProgress(e)); return this; } -} +} \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs b/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs index b975ea0d..d70db26a 100644 --- a/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs +++ b/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs @@ -8,21 +8,21 @@ public class CompressProvider { private CompressProvider() { } - public static void Compress(string compressType, string sourcePath, string destinationPath, bool includeRootDirectory, Encoding encoding) + public static void Compress(Format compressType, string sourcePath, string destinationPath, bool includeRootDirectory, Encoding encoding) { var strategy = GetCompressionStrategy(compressType); strategy.Compress(sourcePath, destinationPath, includeRootDirectory, encoding); } - public static void Decompress(string compressType, string archivePath, string destinationPath, Encoding encoding) + public static void Decompress(Format compressType, string archivePath, string destinationPath, Encoding encoding) { var strategy = GetCompressionStrategy(compressType); strategy.Decompress(archivePath, destinationPath, encoding); } - private static ICompressionStrategy GetCompressionStrategy(string compressType) => compressType switch + private static ICompressionStrategy GetCompressionStrategy(Format compressType) => compressType switch { - Format.ZIP => new ZipCompressionStrategy(), + Format.Zip => new ZipCompressionStrategy(), _ => throw new ArgumentException("Compression format is not supported!") }; } \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs index 2790c66c..2a364b0e 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs @@ -11,13 +11,14 @@ namespace GeneralUpdate.Core.Configuration { public abstract class AbstractBootstrap - where TBootstrap : AbstractBootstrap - where TStrategy : IStrategy + where TBootstrap : AbstractBootstrap + where TStrategy : IStrategy { private readonly ConcurrentDictionary _options; /// User-registered extension types for lazy instantiation. private readonly Dictionary _extensions = new(); + /// Registered singleton instances (e.g., BlackListConfig). private readonly Dictionary _instances = new(); @@ -48,47 +49,88 @@ protected T GetOption(UpdateOption? option) // ═══════════ Extension point registration ═══════════ public TBootstrap Strategy() where T : IStrategy, new() - { _extensions[typeof(IStrategy)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(IStrategy)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap Hooks() where T : Hooks.IUpdateHooks, new() - { _extensions[typeof(Hooks.IUpdateHooks)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Hooks.IUpdateHooks)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap SslPolicy() where T : Security.ISslValidationPolicy, new() - { _extensions[typeof(Security.ISslValidationPolicy)] = typeof(T); return (TBootstrap)this; } - - + { + _extensions[typeof(Security.ISslValidationPolicy)] = typeof(T); + return (TBootstrap)this; + } + public TBootstrap PipelineFactory() where T : Pipeline.IUpdatePipelineFactory, new() - { _extensions[typeof(Pipeline.IUpdatePipelineFactory)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Pipeline.IUpdatePipelineFactory)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap DownloadPolicy() where T : Download.Abstractions.IDownloadPolicy, new() - { _extensions[typeof(Download.Abstractions.IDownloadPolicy)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Download.Abstractions.IDownloadPolicy)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap DownloadExecutor() where T : Download.Abstractions.IDownloadExecutor, new() - { _extensions[typeof(Download.Abstractions.IDownloadExecutor)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Download.Abstractions.IDownloadExecutor)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap DownloadSource() where T : Download.Abstractions.IDownloadSource, new() - { _extensions[typeof(Download.Abstractions.IDownloadSource)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Download.Abstractions.IDownloadSource)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap DownloadPipeline() where T : Download.Abstractions.IDownloadPipeline, new() - { _extensions[typeof(Download.Abstractions.IDownloadPipeline)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Download.Abstractions.IDownloadPipeline)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap UpdateReporter() where T : Download.Reporting.IUpdateReporter, new() - { _extensions[typeof(Download.Reporting.IUpdateReporter)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Download.Reporting.IUpdateReporter)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap UpdateAuth() where T : Security.IHttpAuthProvider, new() - { _extensions[typeof(Security.IHttpAuthProvider)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Security.IHttpAuthProvider)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap DownloadOrchestrator() where T : Download.Abstractions.IDownloadOrchestrator, new() - { _extensions[typeof(Download.Abstractions.IDownloadOrchestrator)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Download.Abstractions.IDownloadOrchestrator)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap CleanStrategy() where T : Differential.ICleanStrategy, new() - { _extensions[typeof(Differential.ICleanStrategy)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Differential.ICleanStrategy)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap DirtyStrategy() where T : Differential.IDirtyStrategy, new() - { _extensions[typeof(Differential.IDirtyStrategy)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(Differential.IDirtyStrategy)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap BinaryDiffer() where T : IBinaryDiffer, new() - { _extensions[typeof(IBinaryDiffer)] = typeof(T); return (TBootstrap)this; } + { + _extensions[typeof(IBinaryDiffer)] = typeof(T); + return (TBootstrap)this; + } public TBootstrap ConfigureBlackList(BlackListConfig config) { @@ -117,4 +159,4 @@ public TBootstrap ConfigureBlackList(Action c return null; } } -} +} \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Configuration/BaseConfigInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/BaseConfigInfo.cs index d805fedd..fa497ec3 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/BaseConfigInfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/BaseConfigInfo.cs @@ -11,14 +11,14 @@ namespace GeneralUpdate.Core.Configuration public abstract class BaseConfigInfo { /// - /// The name of the application that needs to be started after update. - /// This is the executable name without extension (e.g., "MyApp" for MyApp.exe). + /// The name of the upgrade application executable (e.g., "Update.exe"). + /// Used when the client launches the upgrade process. /// Default value is "Update.exe". /// - public string AppName { get; set; } = "Update.exe"; + public string UpdateAppName { get; set; } = "Update.exe"; /// - /// The name of the main application without file extension. + /// The name of the main application executable. /// Used to identify the primary application process that will be updated. /// public string MainAppName { get; set; } @@ -30,6 +30,15 @@ public abstract class BaseConfigInfo /// public string InstallPath { get; set; } = AppDomain.CurrentDomain.BaseDirectory; + /// + /// Optional directory where the upgrade executable resides. + /// Can be an absolute path or a relative path (resolved against ). + /// When set, the upgrade process is launched from this directory instead of . + /// When null or empty, falls back to (backward-compatible). + /// Example: "Upgrade" → InstallPath/Upgrade/UpdateAppName + /// + public string UpdatePath { get; set; } + /// /// The URL address for the update log webpage. /// Users can view detailed changelog information at this address. @@ -95,5 +104,12 @@ public abstract class BaseConfigInfo /// Used when DriveEnabled is true to locate and install driver files during updates. /// public string DriverDirectory { get; set; } + + /// + /// Current update role — determines which app launches. + /// launches UpdateAppName (upgrade process). + /// launches MainAppName + Bowl. + /// + public AppType? AppType { get; set; } } } diff --git a/src/c#/GeneralUpdate.Core/Configuration/Configinfo.cs b/src/c#/GeneralUpdate.Core/Configuration/Configinfo.cs index 5b7a2626..f580b6bf 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/Configinfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/Configinfo.cs @@ -36,8 +36,8 @@ public void Validate() if (!string.IsNullOrWhiteSpace(UpdateLogUrl) && !Uri.IsWellFormedUriString(UpdateLogUrl, UriKind.Absolute)) throw new ArgumentException("Invalid UpdateLogUrl"); - if (string.IsNullOrWhiteSpace(AppName)) - throw new ArgumentException("AppName cannot be empty"); + if (string.IsNullOrWhiteSpace(UpdateAppName)) + throw new ArgumentException("UpdateAppName cannot be empty"); if (string.IsNullOrWhiteSpace(MainAppName)) throw new ArgumentException("MainAppName cannot be empty"); diff --git a/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs b/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs index ab072d20..f8c54823 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs @@ -14,7 +14,7 @@ namespace GeneralUpdate.Core.Configuration public class ConfiginfoBuilder { // Configurable default values - // Note: AppName and InstallPath defaults are set in Configinfo class itself + // Note: UpdateAppName and InstallPath defaults are set in Configinfo class itself // These are ConfiginfoBuilder-specific defaults to support the builder pattern private string _updateUrl; private string _token; @@ -91,8 +91,8 @@ private static ConfiginfoBuilder LoadFromConfigFile() builder.SetToken(config.Token); if (!string.IsNullOrWhiteSpace(config.Scheme)) builder.SetScheme(config.Scheme); - if (!string.IsNullOrWhiteSpace(config.AppName)) - builder.SetAppName(config.AppName); + if (!string.IsNullOrWhiteSpace(config.UpdateAppName)) + builder.SetUpgradeAppName(config.UpdateAppName); if (!string.IsNullOrWhiteSpace(config.MainAppName)) builder.SetMainAppName(config.MainAppName); if (!string.IsNullOrWhiteSpace(config.ClientVersion)) @@ -177,10 +177,10 @@ public ConfiginfoBuilder SetScheme(string scheme) /// /// The name of the application executable. /// The current ConfiginfoBuilder instance for method chaining. - public ConfiginfoBuilder SetAppName(string appName) + public ConfiginfoBuilder SetUpgradeAppName(string appName) { if (string.IsNullOrWhiteSpace(appName)) - throw new ArgumentException("AppName cannot be null or empty.", nameof(appName)); + throw new ArgumentException("UpdateAppName cannot be null or empty.", nameof(appName)); _appName = appName; return this; @@ -369,7 +369,7 @@ public Configinfo Build() UpdateUrl = _updateUrl, Token = _token, Scheme = _scheme, - AppName = _appName, + UpdateAppName = _appName, MainAppName = _mainAppName, ClientVersion = _clientVersion, UpgradeClientVersion = _upgradeClientVersion, diff --git a/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs b/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs index d4a89fdc..9b7ddbec 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs @@ -29,7 +29,7 @@ public static GlobalConfigInfo MapToGlobalConfigInfo(Configinfo source, GlobalCo return target; // Map common fields from base configuration - target.AppName = source.AppName; + target.UpdateAppName = source.UpdateAppName; target.MainAppName = source.MainAppName; target.ClientVersion = source.ClientVersion; target.InstallPath = source.InstallPath; @@ -43,6 +43,8 @@ public static GlobalConfigInfo MapToGlobalConfigInfo(Configinfo source, GlobalCo target.Scheme = source.Scheme; target.Token = source.Token; target.DriverDirectory = source.DriverDirectory; + target.AppType = source.AppType; + target.UpdatePath = source.UpdatePath; // Map GlobalConfigInfo-specific fields target.UpdateUrl = source.UpdateUrl; @@ -76,13 +78,13 @@ public static ProcessInfo MapToProcessInfo( // Create ProcessInfo with all required parameters in a single location // Centralized parameter mapping for ProcessInfo creation return new ProcessInfo( - appName: source.MainAppName, // Maps MainAppName to ProcessInfo.AppName + appName: source.MainAppName, // Maps MainAppName to ProcessInfo.UpdateAppName installPath: source.InstallPath, currentVersion: source.ClientVersion, // Maps ClientVersion to ProcessInfo.CurrentVersion lastVersion: source.LastVersion, // Computed value set before calling this method updateLogUrl: source.UpdateLogUrl, compressEncoding: source.Encoding, // Computed value set before calling this method - compressFormat: source.Format, // Computed value set before calling this method + compressFormat: source.Format.ToExtension(), // Computed value set before calling this method downloadTimeOut: source.DownloadTimeOut, // Computed value set before calling this method appSecretKey: source.AppSecretKey, updateVersions: updateVersions, // From API response @@ -92,9 +94,12 @@ public static ProcessInfo MapToProcessInfo( scheme: source.Scheme, token: source.Token, driverDirectory: source.DriverDirectory, // Driver directory for driver updates + tempPath: source.TempPath, // Client's download temp path so upgrade can find packages blackFileFormats: blackFileFormats, // From BlackListManager blackFiles: blackFiles, // From BlackListManager - skipDirectories: skipDirectories // From BlackListManager + skipDirectories: skipDirectories, // From BlackListManager + upgradePath: source.UpdatePath, // Custom upgrade directory + launchClient: source.LaunchClientAfterUpdate ); } @@ -113,7 +118,7 @@ public static void CopyBaseFields(TSource source, TTarget targ if (source == null || target == null) return; - target.AppName = source.AppName; + target.UpdateAppName = source.UpdateAppName; target.MainAppName = source.MainAppName; target.InstallPath = source.InstallPath; target.UpdateLogUrl = source.UpdateLogUrl; @@ -127,6 +132,8 @@ public static void CopyBaseFields(TSource source, TTarget targ target.Scheme = source.Scheme; target.Token = source.Token; target.DriverDirectory = source.DriverDirectory; + target.AppType = source.AppType; + target.UpdatePath = source.UpdatePath; } } } diff --git a/src/c#/GeneralUpdate.Core/Configuration/Format.cs b/src/c#/GeneralUpdate.Core/Configuration/Format.cs index b46115d5..aa434eec 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/Format.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/Format.cs @@ -1,11 +1,26 @@ namespace GeneralUpdate.Core.Configuration { /// - /// Compression format constants for update packages. + /// Compression format for update packages. /// - public class Format + public enum Format { - /// ZIP compression format extension. - public const string ZIP = ".zip"; + /// ZIP compression format. + Zip } -} \ No newline at end of file + + /// + /// Extension methods for . + /// + public static class FormatExtensions + { + /// + /// Returns the file extension for the format (including leading dot). + /// + public static string ToExtension(this Format format) => format switch + { + Format.Zip => ".zip", + _ => ".zip" + }; + } +} diff --git a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs index 03a478fe..8707782a 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs @@ -42,10 +42,10 @@ public class GlobalConfigInfo : BaseConfigInfo // Runtime computed fields (calculated during workflow execution) /// - /// The compression format of update packages (e.g., "ZIP", "7Z"). + /// The compression format of update packages. /// Computed from UpdateOption.Format or defaults to ZIP. /// - public string Format { get; set; } + public Format Format { get; set; } /// /// Indicates whether the upgrade application itself needs to be updated. @@ -59,6 +59,12 @@ public class GlobalConfigInfo : BaseConfigInfo /// public bool IsMainUpdate { get; set; } + /// + /// Whether to launch the client app after the upgrade process completes. + /// Default true. Set to false in silent mode when the caller wants to control restart timing. + /// + public bool LaunchClientAfterUpdate { get; set; } = true; + /// /// List of version information objects to be updated. /// Populated from the update server response based on IsUpgradeUpdate/IsMainUpdate flags. @@ -117,10 +123,10 @@ public class GlobalConfigInfo : BaseConfigInfo /// /// Maximum number of concurrent download operations. - /// Computed from UpdateOption.MaxConcurrency, defaults to 3. + /// Computed from UpdateOption.MaxConcurrency, defaults to 2. /// Valid range: 1 to * 2. /// - public int MaxConcurrency { get; set; } = 3; + public int MaxConcurrency { get; set; } = 2; /// /// Whether to resume interrupted downloads via HTTP Range requests. diff --git a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfoOSS.cs b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfoOSS.cs index 9cd3723b..9a4b31cb 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfoOSS.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfoOSS.cs @@ -8,8 +8,8 @@ public class GlobalConfigInfoOSS [JsonPropertyName("Url")] public string Url { get; set; } - [JsonPropertyName("AppName")] - public string AppName { get; set; } + [JsonPropertyName("UpdateAppName")] + public string UpdateAppName { get; set; } [JsonPropertyName("CurrentVersion")] public string CurrentVersion { get; set; } @@ -27,7 +27,7 @@ public GlobalConfigInfoOSS() public GlobalConfigInfoOSS(string url, string appName, string currentVersion, string versionFileName) { Url = url ?? throw new ArgumentNullException(nameof(url)); - AppName = appName ?? throw new ArgumentNullException(nameof(appName)); + UpdateAppName = appName ?? throw new ArgumentNullException(nameof(appName)); CurrentVersion = currentVersion ?? throw new ArgumentNullException(nameof(currentVersion)); VersionFileName = versionFileName ?? "versions.json"; } diff --git a/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs index 376b8438..8c5510d4 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs @@ -43,9 +43,12 @@ public ProcessInfo() { } /// The URL scheme for update requests /// The authentication token /// The directory path containing driver files + /// The temp directory where the client downloaded update packages /// List of file format extensions to skip /// List of specific files to skip /// List of directories to skip + /// Optional directory where the upgrade executable resides (defaults to InstallPath) + /// Whether to launch the client app after upgrade completes (default true) /// Thrown when required parameters are null /// Thrown when parameters fail validation public ProcessInfo(string appName @@ -64,9 +67,12 @@ public ProcessInfo(string appName , string scheme , string token , string driverDirectory + , string tempPath , List blackFileFormats , List blackFiles - , List skipDirectories) + , List skipDirectories + , string upgradePath = null + , bool launchClient = true) { // Validate required string parameters AppName = appName ?? throw new ArgumentNullException(nameof(appName)); @@ -98,18 +104,25 @@ public ProcessInfo(string appName Scheme = scheme; Token = token; DriverDirectory = driverDirectory; + TempPath = tempPath; // Set blacklist parameters BlackFileFormats = blackFileFormats; BlackFiles = blackFiles; SkipDirectorys = skipDirectories; + + // Set upgrade path (optional — defaults to InstallPath if not set) + UpdatePath = upgradePath; + + // Set launch flag (default true — backward compatible) + LaunchClientAfterUpdate = launchClient; } /// /// The name of the application to start after the update completes. /// Note: In ProcessInfo, this field holds the MainAppName value from other config classes. /// - [JsonPropertyName("AppName")] + [JsonPropertyName("UpdateAppName")] public string AppName { get; set; } /// @@ -229,5 +242,26 @@ public ProcessInfo(string appName /// [JsonPropertyName("DriverDirectory")] public string DriverDirectory { get; set; } + + /// + /// The temp directory where the client downloaded update packages. + /// The upgrade process reads packages from this path via the pipeline. + /// + [JsonPropertyName("TempPath")] + public string TempPath { get; set; } + + /// + /// Optional directory where the upgrade executable resides. + /// When set, the upgrade process is launched from this path instead of . + /// + [JsonPropertyName("UpdatePath")] + public string UpdatePath { get; set; } + + /// + /// Whether to launch the client application after the upgrade process finishes. + /// Default true. Set to false to keep the app stopped after update. + /// + [JsonPropertyName("LaunchClientAfterUpdate")] + public bool LaunchClientAfterUpdate { get; set; } = true; } } \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs index 171967e4..f99a1027 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs @@ -23,8 +23,8 @@ public static class UpdateOptions /// Compression encoding for update packages. public static UpdateOption Encoding { get; } = UpdateOption.ValueOf("COMPRESSENCODING", System.Text.Encoding.UTF8); - /// Compression format (e.g., "ZIP"). - public static UpdateOption Format { get; } = UpdateOption.ValueOf("COMPRESSFORMAT", "ZIP"); + /// Compression format for update packages. + public static UpdateOption Format { get; } = UpdateOption.ValueOf("COMPRESSFORMAT", Configuration.Format.Zip); /// Download timeout in seconds. public static UpdateOption DownloadTimeout { get; } = UpdateOption.ValueOf("DOWNLOADTIMEOUT", 30); @@ -39,9 +39,6 @@ public static class UpdateOptions public static UpdateOption Silent { get; } = UpdateOption.ValueOf("ENABLESILENTUPDATE", false); // ═══ Silent mode ═══ - /// Whether silent updates auto-install without user intervention. - public static UpdateOption SilentAutoInstall { get; } = UpdateOption.ValueOf("SILENTAUTOINSTALL", false); - /// Polling interval in minutes for silent update checks. public static UpdateOption SilentPollIntervalMinutes { get; } = UpdateOption.ValueOf("SILENTPOLLINTERVALMINUTES", 60); @@ -61,9 +58,5 @@ public static class UpdateOptions /// Initial retry interval for exponential back-off. Default 1 second. public static UpdateOption RetryInterval { get; } = UpdateOption.ValueOf("RETRYINTERVAL", TimeSpan.FromSeconds(1)); - - // ═══ SignalR Hub ═══ - /// SignalR Hub configuration for push-based updates. - public static UpdateOption Hub { get; } = UpdateOption.ValueOf("HUB", null); } } diff --git a/src/c#/GeneralUpdate.Core/Configuration/VersionInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/VersionInfo.cs index eb2f1457..7943390b 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/VersionInfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/VersionInfo.cs @@ -58,4 +58,28 @@ public class VersionInfo /// [JsonPropertyName("updateLog")] public string? UpdateLog { get; set; } + + /// URL expiry time (UTC) for signed download URLs. + [JsonPropertyName("urlExpireTimeUtc")] + public DateTime? UrlExpireTimeUtc { get; set; } + + /// Upgrade mode: 1=VersionChain, 2=CrossVersion. + [JsonPropertyName("upgradeMode")] + public int? UpgradeMode { get; set; } + + /// Whether this is a cross-version packet. + [JsonPropertyName("isCrossVersion")] + public bool? IsCrossVersion { get; set; } + + /// Source version for cross-version packets. + [JsonPropertyName("fromVersion")] + public string? FromVersion { get; set; } + + /// Target version for cross-version packets. + [JsonPropertyName("toVersion")] + public string? ToVersion { get; set; } + + /// Whether this packet is frozen (archived, not for active updates). + [JsonPropertyName("isFreeze")] + public bool? IsFreeze { get; set; } } \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadSource.cs index f5a989a1..4f9be7eb 100644 --- a/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadSource.cs +++ b/src/c#/GeneralUpdate.Core/Download/Abstractions/IDownloadSource.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using GeneralUpdate.Core.Download.Models; @@ -8,7 +7,7 @@ namespace GeneralUpdate.Core.Download.Abstractions; /// Source of download assets (e.g. HTTP API, OSS bucket, SignalR Hub). public interface IDownloadSource { - Task> ListAsync(CancellationToken token = default); + Task ListAsync(CancellationToken token = default); } /// Post-download processing pipeline (verify, decompress, decrypt). diff --git a/src/c#/GeneralUpdate.Core/Download/Abstractions/PacketDTO.cs b/src/c#/GeneralUpdate.Core/Download/Abstractions/PacketDTO.cs deleted file mode 100644 index acb67bf8..00000000 --- a/src/c#/GeneralUpdate.Core/Download/Abstractions/PacketDTO.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace GeneralUpdate.Core.Download.Abstractions; - -/// Server-side packet descriptor DTO (mirrors server contract). -public record PacketDTO -{ - public string? Name { get; set; } - public string? Hash { get; set; } - public DateTime? ReleaseDate { get; set; } - public string? Url { get; set; } - public string? Version { get; set; } - public int? AppType { get; set; } - public int? Platform { get; set; } - public string? ProductId { get; set; } - public bool? IsForcibly { get; set; } - public bool? IsFreeze { get; set; } - public string? Format { get; set; } - public long? Size { get; set; } - public string? FromVersion { get; set; } - public bool? IsCrossVersion { get; set; } - public string? MinClientVersion { get; set; } - public string? SourceArchiveHash { get; set; } - public string? TargetArchiveHash { get; set; } -} - -public record VersionRequest( - string AppName, - string ClientVersion, - string? UpgradeClientVersion, - int? Platform, - string? ProductId -); - -public record VersionResponse( - bool HasUpdate, - IReadOnlyList? Packets -); diff --git a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs index 37c11abd..36ae174e 100644 --- a/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Download/DownloadPlanBuilder.cs @@ -8,13 +8,16 @@ namespace GeneralUpdate.Core.Download; /// /// Builds a DownloadPlan from download assets. -/// Handles cross-version package selection, version chain building, -/// frozen package filtering, and forced update marking. +/// Handles frozen package filtering, forced update marking, and MinClientVersion compatibility. +/// Does not distinguish between cross-version and version-chain — each package carries its own metadata. /// public static class DownloadPlanBuilder { /// /// Build a download plan from a list of download assets. + /// Does not distinguish between cross-version and version-chain packages — + /// the server decides what to return, and each package carries its own + /// for downstream handling. /// /// Assets from the download source. /// Current client version string. @@ -34,45 +37,22 @@ public static DownloadPlan Build(IEnumerable assets, string curre // 2. Check for forced update var isForcibly = active.Any(a => a.IsForcibly); - // 3. Look for a cross-version package that matches our current version - var crossVersion = active - .Where(a => a.IsCrossVersion - && !string.IsNullOrEmpty(a.FromVersion) - && VersionEquals(a.FromVersion!, currentVersion)) - .OrderByDescending(a => ParseVersion(a.Version)) - .FirstOrDefault(); - - if (crossVersion != null) - { - // Single download — jump directly to target version - return new DownloadPlan(new[] { crossVersion }, isForcibly); - } - - // 4. Build version chain from non-cross-version packages - var chain = BuildVersionChain(active.Where(a => !a.IsCrossVersion), currentVersion); - if (chain.Count == 0) return DownloadPlan.Empty; - - return new DownloadPlan(chain, isForcibly); - } - - /// - /// Build a version chain: keep versions higher than current, - /// check MinClientVersion compatibility. - /// - private static List BuildVersionChain(IEnumerable assets, string currentVersion) - { - var current = ParseVersion(currentVersion); - - return assets + // 3. Filter and sort: keep only packages higher than current version, + // respecting MinClientVersion compatibility. + var candidates = active .Where(a => { var pv = ParseVersion(a.Version); if (pv == null) return false; - return pv > current; + return pv > ParseVersion(currentVersion); }) .Where(a => IsCompatible(a.MinClientVersion, currentVersion)) .OrderBy(a => ParseVersion(a.Version)) .ToList(); + + if (candidates.Count == 0) return DownloadPlan.Empty; + + return new DownloadPlan(candidates, isForcibly); } /// @@ -88,37 +68,10 @@ 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) { if (string.IsNullOrWhiteSpace(version)) return null; return Version.TryParse(version, out var v) ? v : null; } - - /// Compare two version strings for equality. - private static bool VersionEquals(string a, string b) - { - var va = ParseVersion(a); - var vb = ParseVersion(b); - return va != null && vb != null && va == vb; - } } diff --git a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs index fb0e7d62..916b5baf 100644 --- a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs +++ b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs @@ -46,6 +46,13 @@ public async Task ExecuteAsync( if (_enableResume && existingBytes > 0) request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(existingBytes, null); + // Apply per-asset auth if provided by server (e.g. GeneralSpacestation signed URLs or Bearer tokens) + if (!string.IsNullOrEmpty(asset.AuthScheme) && !string.IsNullOrEmpty(asset.AuthToken)) + { + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue(asset.AuthScheme, asset.AuthToken); + } + using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(_timeout); diff --git a/src/c#/GeneralUpdate.Core/Download/Models/DownloadAsset.cs b/src/c#/GeneralUpdate.Core/Download/Models/DownloadAsset.cs index b14ee548..e5369770 100644 --- a/src/c#/GeneralUpdate.Core/Download/Models/DownloadAsset.cs +++ b/src/c#/GeneralUpdate.Core/Download/Models/DownloadAsset.cs @@ -2,7 +2,7 @@ namespace GeneralUpdate.Core.Download.Models; -/// Download resource descriptor — maps from server PacketDTO. +/// Download resource descriptor — maps from server PacketDTO / VerificationResultDTO. public record DownloadAsset( string Name, string Url, @@ -16,7 +16,12 @@ public record DownloadAsset( string? SourceArchiveHash = null, string? TargetArchiveHash = null, bool IsForcibly = false, - bool IsFreeze = false + bool IsFreeze = false, + int RecordId = 0, + int? UpgradeMode = null, + int? AppType = null, + string? AuthScheme = null, + string? AuthToken = null ); /// Ordered download plan built from server response. diff --git a/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs b/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs index 7870df70..560ac95f 100644 --- a/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs +++ b/src/c#/GeneralUpdate.Core/Download/Models/DownloadOrchestratorOptions.cs @@ -12,9 +12,9 @@ public class DownloadOrchestratorOptions /// /// Maximum number of concurrent download operations. /// Valid range: 1 to * 2. - /// Default: 3. + /// Default: 2. /// - public int MaxConcurrency { get; set; } = 3; + public int MaxConcurrency { get; set; } = 2; /// /// Whether to resume interrupted downloads via HTTP Range requests. @@ -54,6 +54,13 @@ public class DownloadOrchestratorOptions /// public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// + /// File format for downloaded packages. + /// Ensures the downloaded filename matches what the pipeline expects. + /// Default: . + /// + public Configuration.Format Format { get; set; } = Configuration.Format.Zip; + /// /// Creates a from . /// @@ -68,6 +75,7 @@ public static DownloadOrchestratorOptions From(Configuration.GlobalConfigInfo co VerifyChecksum = config.VerifyChecksum, DiffMode = config.DiffMode, DownloadTimeout = TimeSpan.FromSeconds(config.DownloadTimeOut > 0 ? config.DownloadTimeOut : 30), + Format = config.Format, }; } diff --git a/src/c#/GeneralUpdate.Core/Download/Models/DownloadSourceResult.cs b/src/c#/GeneralUpdate.Core/Download/Models/DownloadSourceResult.cs new file mode 100644 index 00000000..1a35ddbe --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Download/Models/DownloadSourceResult.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace GeneralUpdate.Core.Download.Models; + +/// +/// Result from , +/// carrying the download assets plus flags indicating which side (main/upgrade) +/// the server returned version information for. +/// +public class DownloadSourceResult +{ + public IReadOnlyList Assets { get; init; } = Array.Empty(); + public bool HasMainUpdate { get; init; } + public bool HasUpgradeUpdate { get; init; } +} diff --git a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs index bafad077..fa1cdac4 100644 --- a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs @@ -145,8 +145,17 @@ public async Task ExecuteAsync( results.Count(r => !r.Success)); } - private static string GetFileName(DownloadAsset asset) + private string GetFileName(DownloadAsset asset) { + if (!string.IsNullOrEmpty(asset.Name)) + { + var name = asset.Name; + var ext = _options.Format.ToExtension(); + if (!name.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + name += ext; + return name; + } + try { var name = Path.GetFileName(new Uri(asset.Url).AbsolutePath); diff --git a/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs index 4fed106c..764fef69 100644 --- a/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs +++ b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs @@ -5,60 +5,42 @@ using System.Threading; using System.Threading.Tasks; using GeneralUpdate.Core; -using GeneralUpdate.Core.Configuration; namespace GeneralUpdate.Core.Download.Reporting; -/// Reports update lifecycle events to the server. +/// Reports update lifecycle events to the server, compatible with GeneralSpacestation API. public interface IUpdateReporter { Task ReportAsync(UpdateReport report, CancellationToken token = default); } -public enum UpdateEvent { UpdateStarted, DownloadCompleted, UpdateApplied, UpdateFailed, AppStarted } +/// Update status codes matching GeneralSpacestation ReportDTO contract (1=updating, 2=success, 3=failure). +public enum UpdateStatus { Updating = 1, Success = 2, Failure = 3 } -public record UpdateReport( - string AppName, - string FromVersion, - string? ToVersion, - UpdateEvent Event, - AppType AppType, - DateTimeOffset Timestamp, - string? ErrorMessage = null, - double? DurationMs = null -); +/// Spacestation-compatible update report: recordId from verification, status (1=updating,2=success,3=failure), type (1=upgrade,2=push). +public record UpdateReport(int RecordId, int Status = 1, int Type = 1); -/// HTTP POST reporter with optional HMAC signing. +/// HTTP POST reporter that serializes UpdateReport as JSON matching Spacestation ReportDTO. public class HttpUpdateReporter : IUpdateReporter { private readonly HttpClient _client; private readonly string _reportUrl; - private readonly string? _secretKey; - public HttpUpdateReporter(HttpClient client, string reportUrl, string? secretKey = null) + public HttpUpdateReporter(HttpClient client, string reportUrl) { _client = client; _reportUrl = reportUrl; - _secretKey = secretKey; } public async Task ReportAsync(UpdateReport report, CancellationToken token = default) { try { - var json = JsonSerializer.Serialize(report); + var json = JsonSerializer.Serialize(report, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); using var request = new HttpRequestMessage(HttpMethod.Post, _reportUrl); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - if (!string.IsNullOrEmpty(_secretKey)) - { - var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); - var sig = ComputeHmac($"{json}|{ts}", _secretKey); - request.Headers.Add("X-Update-Timestamp", ts); - request.Headers.Add("X-Update-Signature", sig); - } - await _client.SendAsync(request, token).ConfigureAwait(false); } catch (Exception ex) @@ -66,13 +48,6 @@ public async Task ReportAsync(UpdateReport report, CancellationToken token = def GeneralTracer.Warn($"Report failed: {ex.Message}"); } } - - private static string ComputeHmac(string data, string key) - { - var h = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(key)) - .ComputeHash(Encoding.UTF8.GetBytes(data)); - return BitConverter.ToString(h).Replace("-", "").ToLowerInvariant(); - } } /// No-op reporter used when ReportUrl is not configured. diff --git a/src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs index 8dc0d25c..b6f4342b 100644 --- a/src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs +++ b/src/c#/GeneralUpdate.Core/Download/Sources/HttpDownloadSource.cs @@ -43,8 +43,8 @@ public HttpDownloadSource( _token = token; } - /// Call version API and return download assets. - public async Task> ListAsync(CancellationToken token = default) + /// Call version API and return download assets with per-side validation flags. + public async Task ListAsync(CancellationToken token = default) { var mainResp = await VersionService.Validate( _updateUrl, _clientVersion, AppType.Client, @@ -56,6 +56,9 @@ public async Task> ListAsync(CancellationToken toke _appSecretKey, _platform, _productId, _scheme, _token, token); + var hasMainUpdate = mainResp?.Body?.Count > 0; + var hasUpgradeUpdate = upgradeResp?.Body?.Count > 0; + var assets = new List(); if (mainResp?.Body != null) @@ -70,7 +73,13 @@ public async Task> ListAsync(CancellationToken toke assets.Add(MapVersionInfo(v)); } - return assets; + // Deduplicate by URL — both Validate calls may return the same packages + return new DownloadSourceResult + { + Assets = assets.GroupBy(a => a.Url).Select(g => g.First()).ToList(), + HasMainUpdate = hasMainUpdate, + HasUpgradeUpdate = hasUpgradeUpdate + }; } private static DownloadAsset MapVersionInfo(VersionInfo v) @@ -81,7 +90,16 @@ private static DownloadAsset MapVersionInfo(VersionInfo v) Size: v.Size ?? 0, SHA256: v.Hash, Version: v.Version ?? "0.0.0", - IsForcibly: v.IsForcibly == true + IsForcibly: v.IsForcibly == true, + IsFreeze: v.IsFreeze == true, + RecordId: v.RecordId, + UpgradeMode: v.UpgradeMode, + AppType: v.AppType, + IsCrossVersion: v.IsCrossVersion == true, + FromVersion: v.FromVersion, + TargetArchiveHash: v.Hash, + AuthScheme: v.AuthScheme, + AuthToken: v.AuthToken ); } } diff --git a/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs deleted file mode 100644 index 73437270..00000000 --- a/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs +++ /dev/null @@ -1,85 +0,0 @@ -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, IAsyncDisposable -{ - 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, HttpParameterJsonContext.Default.PacketDTO); - 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 async ValueTask DisposeAsync() - { - if (_hub != null) - await _hub.DisposeAsync(); - } -} diff --git a/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs index 7c177322..2f371c25 100644 --- a/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs +++ b/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs @@ -35,7 +35,7 @@ public OssDownloadSource(HttpClient httpClient, string versionJsonUrl, TimeSpan? } /// - public async Task> ListAsync(CancellationToken token = default) + public async Task ListAsync(CancellationToken token = default) { // Download and parse the version JSON from OSS using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); @@ -49,10 +49,10 @@ public async Task> ListAsync(CancellationToken toke var versions = System.Text.Json.JsonSerializer.Deserialize(json, VersionOSSJsonContext.Default.ListVersionOSS); if (versions == null || versions.Count == 0) - return Array.Empty(); + return new DownloadSourceResult { Assets = Array.Empty() }; // Convert VersionOSS to DownloadAsset, ordered by publish time - return versions + var assets = versions .OrderBy(v => v.PubTime) .Select(v => { @@ -74,5 +74,7 @@ public async Task> ListAsync(CancellationToken toke }) .ToList() .AsReadOnly(); + + return new DownloadSourceResult { Assets = assets }; } } diff --git a/src/c#/GeneralUpdate.Core/FileSystem/BlackListDefaults.cs b/src/c#/GeneralUpdate.Core/FileSystem/BlackListDefaults.cs index 99a23e7c..972019b9 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/BlackListDefaults.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/BlackListDefaults.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using GeneralUpdate.Core.Configuration; namespace GeneralUpdate.Core.FileSystem; @@ -17,7 +18,7 @@ public static class BlackListDefaults /// Default blacklisted file extensions. public static readonly List DefaultBlackFormats = new() - { ".patch", ".pdb", ".rar", ".tar", ".json", Configuration.Format.ZIP }; + { ".patch", ".pdb", ".rar", ".tar", ".json", Configuration.Format.Zip.ToExtension() }; /// Default skipped directory prefixes. public static readonly List DefaultSkipDirectories = new() diff --git a/src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs b/src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs index 0175f066..8be7774c 100644 --- a/src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs +++ b/src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs @@ -17,7 +17,7 @@ public interface IUpdateHooks } public record UpdateContext( - string AppName, + string UpdateAppName, string InstallPath, string CurrentVersion, string? TargetVersion, @@ -48,7 +48,7 @@ public class UnixPermissionHooks : IUpdateHooks { public async Task OnBeforeStartAppAsync(UpdateContext ctx) { - var mainApp = Path.Combine(ctx.InstallPath, ctx.AppName); + var mainApp = Path.Combine(ctx.InstallPath, ctx.UpdateAppName); if (File.Exists(mainApp)) await Task.Run(() => Process.Start("chmod", $"+x \"{mainApp}\"").WaitForExit()); } diff --git a/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs b/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs index 994b6c52..842cebe5 100644 --- a/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs +++ b/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs @@ -18,22 +18,35 @@ public interface IProcessInfoProvider } /// -/// AES-encrypted temporary file IPC — simplest, most reliable cross-platform approach. -/// File lives in %TEMP%/GeneralUpdate/ipc/ with a random name, auto-deleted after read. +/// AES-encrypted temporary file IPC. Writes to a deterministic path under +/// %TEMP%/GeneralUpdate/ipc/ so that both the client (sender) and upgrade (receiver) +/// processes agree on the file location without needing out-of-band coordination. +/// File is deleted after a successful read. /// public class EncryptedFileProcessInfoProvider : IProcessInfoProvider { + private const string FileName = "process_info.enc"; + private static readonly byte[] Key = SHA256.Create() .ComputeHash(Encoding.UTF8.GetBytes("GeneralUpdate.ProcessInfo.IPC.v1")); private static readonly byte[] IV = new byte[16] { 0x47, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; private readonly string _filePath; + /// + /// Returns the deterministic IPC file path that both client and upgrade agree on. + /// + public static string GetDefaultFilePath(string? basePath = null) + { + var dir = basePath ?? Path.Combine(Path.GetTempPath(), "GeneralUpdate", "ipc"); + return Path.Combine(dir, FileName); + } + public EncryptedFileProcessInfoProvider(string? basePath = null) { var dir = basePath ?? Path.Combine(Path.GetTempPath(), "GeneralUpdate", "ipc"); Directory.CreateDirectory(dir); - _filePath = Path.Combine(dir, $"{Guid.NewGuid():N}.enc"); + _filePath = Path.Combine(dir, FileName); } public Task SendAsync(ProcessInfo info, CancellationToken token = default) @@ -42,7 +55,10 @@ public Task SendAsync(ProcessInfo info, CancellationToken token = default) return Task.CompletedTask; } - /// Synchronous send — all I/O is synchronous under the hood. + /// + /// Encrypt and write to the deterministic IPC file. + /// Overwrites any existing file from a previous (stale) session. + /// public void Send(ProcessInfo info) { var json = JsonSerializer.Serialize(info, ProcessInfoJsonContext.Default.ProcessInfo); @@ -53,11 +69,20 @@ public void Send(ProcessInfo info) public Task ReceiveAsync(CancellationToken token = default) => Task.FromResult(Receive()); - /// Synchronous receive — reads and deletes the encrypted file. + /// + /// Read and decrypt the IPC file, then delete it so a stale file is never re-read. + /// Returns null if the file does not exist or decryption fails. + /// public ProcessInfo? Receive() { + if (!File.Exists(_filePath)) return null; + var plain = IpcEncryption.DecryptFromFile(_filePath, Key, IV); if (plain == null) return null; + + try { File.Delete(_filePath); } + catch { /* best-effort cleanup */ } + var json = Encoding.UTF8.GetString(plain); return JsonSerializer.Deserialize(json, ProcessInfoJsonContext.Default.ProcessInfo); } diff --git a/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs b/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs index 1e89de87..45a1c56d 100644 --- a/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs +++ b/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs @@ -11,7 +11,5 @@ namespace GeneralUpdate.Core.JsonContext; [JsonSerializable(typeof(int?))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(PacketDTO))] -[JsonSerializable(typeof(List))] [JsonSerializable(typeof(Configinfo))] public partial class HttpParameterJsonContext: JsonSerializerContext; \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Network/VersionService.cs b/src/c#/GeneralUpdate.Core/Network/VersionService.cs index acae4f09..2825212e 100644 --- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs +++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs @@ -58,7 +58,7 @@ public static Task Validate(string url, string version, string scheme = null, string token = null, CancellationToken ct = default) { var a = HttpAuthProviderFactory.Create(scheme, token, appKey); - return new VersionService(a).ValidateAsync(url, version, (int)appType, (int)platform, productId, ct); + return new VersionService(a).ValidateAsync(url, version, (int)appType, appKey, (int)platform, productId, ct); } // Backward-compatible int overload (binary compat for existing callers) @@ -69,14 +69,14 @@ public static Task Validate(string url, string version, private async Task ReportAsync(string url, int recordId, int status, int? type, CancellationToken t = default) { - var p = new Dictionary { ["RecordId"] = recordId, ["Status"] = status, ["Type"] = type }; + var p = new Dictionary { ["recordId"] = recordId, ["status"] = status, ["type"] = type }; await PostAsync>(url, p, ReportRespJsonContext.Default.BaseResponseDTOBoolean, t); } - private async Task ValidateAsync(string url, string v, int at, int pf, string pid, + private async Task ValidateAsync(string url, string v, int at, string appKey, int pf, string pid, CancellationToken t = default) { - var p = new Dictionary { ["Version"] = v, ["AppType"] = at, ["Platform"] = pf, ["ProductId"] = pid }; + var p = new Dictionary { ["version"] = v, ["appType"] = at, ["appKey"] = appKey, ["platform"] = pf, ["productId"] = pid, ["upgradeMode"] = 1 }; return await PostAsync(url, p, VersionRespJsonContext.Default.VersionRespDTO, t); } diff --git a/src/c#/GeneralUpdate.Core/Pipeline/CompressMiddleware.cs b/src/c#/GeneralUpdate.Core/Pipeline/CompressMiddleware.cs index 17d1e6cd..ecc276ff 100644 --- a/src/c#/GeneralUpdate.Core/Pipeline/CompressMiddleware.cs +++ b/src/c#/GeneralUpdate.Core/Pipeline/CompressMiddleware.cs @@ -12,7 +12,7 @@ public Task InvokeAsync(PipelineContext context) { return Task.Run(() => { - var format = context.Get("Format"); + var format = context.Get("Format"); var sourcePath = context.Get("ZipFilePath"); var patchPath = context.Get("PatchPath"); var encoding = context.Get("Encoding"); diff --git a/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineBuilder.cs b/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineBuilder.cs index 814155e4..9d38d18c 100644 --- a/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineBuilder.cs @@ -15,7 +15,7 @@ namespace GeneralUpdate.Core.Pipeline; /// var pipeline = new DiffPipelineBuilder() /// .UseDiffer(new StreamingHdiffDiffer()) /// .UseCleanMatcher(new DefaultCleanMatcher()) -/// .WithParallelism(Environment.ProcessorCount) +/// .WithParallelism(2) /// .Build(); /// await pipeline.CleanAsync(src, tgt, patch); /// @@ -25,7 +25,7 @@ public class DiffPipelineBuilder private IBinaryDiffer? _differ; private ICleanMatcher? _cleanMatcher; private IDirtyMatcher? _dirtyMatcher; - private int _maxParallelism = Environment.ProcessorCount; + private int _maxParallelism = 2; private bool _stopOnFirstError; private IProgress? _progress; @@ -60,7 +60,7 @@ public DiffPipelineBuilder UseDirtyMatcher(IDirtyMatcher matcher) /// /// Sets the maximum degree of parallelism for file processing. - /// Default: . + /// Default: 2. /// public DiffPipelineBuilder WithParallelism(int maxDegreeOfParallelism) { diff --git a/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineOptions.cs b/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineOptions.cs index 64200856..ac1be88f 100644 --- a/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineOptions.cs +++ b/src/c#/GeneralUpdate.Core/Pipeline/DiffPipelineOptions.cs @@ -9,10 +9,10 @@ public sealed class DiffPipelineOptions { /// /// Gets or sets the maximum number of files to process concurrently. - /// Default: . + /// Default: 2. /// Set to 1 for sequential processing. /// - public int MaxDegreeOfParallelism { get; set; } = Environment.ProcessorCount; + public int MaxDegreeOfParallelism { get; set; } = 2; /// /// Gets or sets whether to stop processing on first error. diff --git a/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs b/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs index 527df4ac..72a04563 100644 --- a/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs @@ -4,13 +4,11 @@ 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; @@ -24,8 +22,12 @@ 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. +/// downloads them in the background, and defers application to process exit. +/// Follows the same AppType-split pattern as : +/// - Upgrade (AppType=2) packages are applied in place during the poll cycle +/// (they target UpdatePath, not the running app's InstallPath). +/// - Client (AppType=1) packages are deferred — stored in ProcessInfo and +/// handed off to the Upgrade process on exit. /// public class SilentPollOrchestrator : IDisposable { @@ -38,6 +40,7 @@ public class SilentPollOrchestrator : IDisposable private IUpdateHooks? _hooks; private IUpdateReporter? _reporter; private Configuration.ProcessInfo? _preparedProcessInfo; + private List _clientVersions = new(); public SilentPollOrchestrator(GlobalConfigInfo configInfo, SilentOptions options) { @@ -45,14 +48,12 @@ public SilentPollOrchestrator(GlobalConfigInfo configInfo, SilentOptions options _options = options ?? throw new ArgumentNullException(nameof(options)); } - /// Inject hooks and reporter for lifecycle callbacks during silent polling. public SilentPollOrchestrator WithHooks(IUpdateHooks? hooks) { _hooks = hooks; return this; } public SilentPollOrchestrator WithReporter(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}"); + GeneralTracer.Info($"SilentPollOrchestrator: starting. PollInterval={_options.PollInterval.TotalMinutes}min"); AppDomain.CurrentDomain.ProcessExit += OnProcessExit; _cts = new CancellationTokenSource(); @@ -67,7 +68,6 @@ public Task StartAsync() return Task.CompletedTask; } - /// Stop polling and cancel any in-flight operation. public void Stop() { _cts?.Cancel(); @@ -99,7 +99,6 @@ 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, @@ -110,8 +109,8 @@ private async Task PrepareUpdateIfNeededAsync(CancellationToken token) _configInfo.Scheme, _configInfo.Token); - var assets = await downloadSource.ListAsync(token).ConfigureAwait(false); - var plan = DownloadPlanBuilder.Build(assets, _configInfo.ClientVersion); + var sourceResult = await downloadSource.ListAsync(token).ConfigureAwait(false); + var plan = DownloadPlanBuilder.Build(sourceResult.Assets, _configInfo.ClientVersion); if (!plan.HasAssets) { @@ -126,9 +125,9 @@ private async Task PrepareUpdateIfNeededAsync(CancellationToken token) return; } - // ══— Hooks: allow cancellation before starting update ══— + // Hooks: allow cancellation before starting update var updateCtx = new UpdateContext( - _configInfo.MainAppName ?? _configInfo.AppName, + _configInfo.MainAppName ?? _configInfo.UpdateAppName, _configInfo.InstallPath, _configInfo.ClientVersion, latestVersion, @@ -147,166 +146,132 @@ private async Task PrepareUpdateIfNeededAsync(CancellationToken token) catch (Exception ex) { GeneralTracer.Warn($"Hook OnBeforeUpdateAsync failed: {ex.Message}"); } } - // Configure matcher from config with defaults - var effectiveConfig = new BlackListConfig( - _configInfo.BlackFiles?.Count > 0 ? _configInfo.BlackFiles : BlackListDefaults.DefaultBlackFiles, - _configInfo.BlackFormats?.Count > 0 ? _configInfo.BlackFormats : BlackListDefaults.DefaultBlackFormats, - _configInfo.SkipDirectorys?.Count > 0 ? _configInfo.SkipDirectorys : BlackListDefaults.DefaultSkipDirectories - ); - StorageManager.BlackListMatcher = new DefaultBlackListMatcher(effectiveConfig); + InitBlackList(); _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, - _configInfo.SkipDirectorys ?? BlackListDefaults.DefaultSkipDirectories); - - // Build ProcessInfo and store for IPC delivery on process exit - _preparedProcessInfo = ConfigurationMapper.MapToProcessInfo( - _configInfo, new List(), - _configInfo.BlackFormats ?? BlackListDefaults.DefaultBlackFormats, - _configInfo.BlackFiles ?? BlackListDefaults.DefaultBlackFiles, - _configInfo.SkipDirectorys ?? BlackListDefaults.DefaultSkipDirectories); - _configInfo.ProcessInfo = JsonSerializer.Serialize(_preparedProcessInfo, ProcessInfoJsonContext.Default.ProcessInfo); - - // ══— Reporter: update started ══— - var startTime = DateTimeOffset.UtcNow; + if (_configInfo.BackupEnabled != false) + { + StorageManager.Backup(_configInfo.InstallPath, _configInfo.BackupDirectory, + _configInfo.SkipDirectorys ?? BlackListDefaults.DefaultSkipDirectories); + } + + // Reporter: update started if (_reporter != null) { - try - { - await _reporter.ReportAsync(new UpdateReport( - updateCtx.AppName, updateCtx.CurrentVersion, updateCtx.TargetVersion, - UpdateEvent.UpdateStarted, AppType.Client, startTime), token).ConfigureAwait(false); - } + try { await _reporter.ReportAsync(new UpdateReport(0, (int)UpdateStatus.Updating, 1), token).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Reporter UpdateStarted failed: {ex.Message}"); } } - // Download using new orchestrator + // Download all packages in background GeneralTracer.Info($"SilentPollOrchestrator: downloading {plan.Assets.Count} asset(s)."); var httpClient = GeneralUpdate.Core.Network.HttpClientProvider.Shared; - var downloadSuccessCount = 0; - var downloadFailedCount = 0; - var downloadTotalBytes = 0L; - var downloadElapsed = TimeSpan.Zero; try { var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient); var report = await orchestrator.ExecuteAsync(plan, _configInfo.TempPath, token: token).ConfigureAwait(false); - downloadSuccessCount = report.SuccessCount; - downloadFailedCount = report.FailedCount; - downloadTotalBytes = report.TotalBytes; - downloadElapsed = report.TotalDuration; - GeneralTracer.Info($"SilentPollOrchestrator: download complete. Success={downloadSuccessCount}, Failed={downloadFailedCount}"); + GeneralTracer.Info($"SilentPollOrchestrator: download complete. Success={report.SuccessCount}, Failed={report.FailedCount}"); + + if (report.FailedCount > 0) + { + GeneralTracer.Error($"SilentPollOrchestrator: download had {report.FailedCount} failures, aborting update."); + return; + } - // ══— Hooks + Reporter: download completed ══— if (_hooks != null) { try { var downloadCtx = new DownloadContext( plan.Assets.FirstOrDefault()?.Name ?? "update", latestVersion ?? "", - downloadTotalBytes, downloadElapsed, - _configInfo.TempPath, downloadFailedCount == 0); + report.TotalBytes, report.TotalDuration, + _configInfo.TempPath, report.FailedCount == 0); await _hooks.OnDownloadCompletedAsync(downloadCtx).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Hook OnDownloadCompletedAsync failed: {ex.Message}"); } } - if (_reporter != null) { - try - { - await _reporter.ReportAsync(new UpdateReport( - updateCtx.AppName, updateCtx.CurrentVersion, updateCtx.TargetVersion, - UpdateEvent.DownloadCompleted, AppType.Client, DateTimeOffset.UtcNow, - DurationMs: downloadElapsed.TotalMilliseconds), token).ConfigureAwait(false); - } + try { await _reporter.ReportAsync(new UpdateReport(0, (int)UpdateStatus.Updating, 1), token).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Reporter DownloadCompleted failed: {ex.Message}"); } } - - if (downloadFailedCount > 0) - { - GeneralTracer.Error($"SilentPollOrchestrator: download had {downloadFailedCount} failures, aborting update."); - return; - } } catch (Exception ex) { GeneralTracer.Error("SilentPollOrchestrator: download failed.", ex); - if (_hooks != null) + TryReportError(updateCtx, ex); + return; + } + + // Split packages by AppType — mirrors ClientUpdateStrategy + var downloadVersions = plan.Assets.Select(a => new VersionInfo + { + Name = a.Name, + Hash = a.SHA256, + Url = a.Url, + Version = a.Version, + Format = _configInfo.Format.ToExtension(), + AppType = a.AppType ?? (int)AppType.Client + }).ToList(); + + var upgradeVersions = downloadVersions.Where(v => v.AppType == (int)AppType.Upgrade).ToList(); + _clientVersions = downloadVersions.Where(v => v.AppType == (int)AppType.Client).ToList(); + GeneralTracer.Info($"SilentPollOrchestrator: Upgrade packages={upgradeVersions.Count}, Client packages={_clientVersions.Count}"); + + // Apply Upgrade packages in place — safe because they target UpdatePath + if (upgradeVersions.Count > 0) + { + GeneralTracer.Info("SilentPollOrchestrator: applying Upgrade packages."); + try { - try { await _hooks.OnUpdateErrorAsync(updateCtx, ex).ConfigureAwait(false); } - catch (Exception hookEx) { GeneralTracer.Warn($"Hook OnUpdateErrorAsync failed: {hookEx.Message}"); } + _configInfo.UpdateVersions = upgradeVersions; + var strategy = CreateStrategy(); + strategy.Create(_configInfo); + await strategy.ExecuteAsync().ConfigureAwait(false); + GeneralTracer.Info("SilentPollOrchestrator: Upgrade packages applied."); } - if (_reporter != null) + catch (Exception ex) { - try - { - await _reporter.ReportAsync(new UpdateReport( - updateCtx.AppName, updateCtx.CurrentVersion, updateCtx.TargetVersion, - UpdateEvent.UpdateFailed, AppType.Client, DateTimeOffset.UtcNow, - ErrorMessage: ex.Message), token).ConfigureAwait(false); - } - catch (Exception reporterEx) { GeneralTracer.Warn($"Reporter UpdateFailed failed: {reporterEx.Message}"); } + GeneralTracer.Error("SilentPollOrchestrator: Upgrade package application failed.", ex); + TryReportError(updateCtx, ex); + return; } - return; } - finally { } - // Execute pipeline - try + // Build ProcessInfo with Client packages for IPC delivery on process exit + if (_clientVersions.Count > 0) { - var strategy = CreateStrategy(); - strategy.Create(_configInfo); - await strategy.ExecuteAsync(); + _configInfo.LaunchClientAfterUpdate = _options.LaunchClientAfterUpdate; + _preparedProcessInfo = ConfigurationMapper.MapToProcessInfo( + _configInfo, _clientVersions, + _configInfo.BlackFormats ?? BlackListDefaults.DefaultBlackFormats, + _configInfo.BlackFiles ?? BlackListDefaults.DefaultBlackFiles, + _configInfo.SkipDirectorys ?? BlackListDefaults.DefaultSkipDirectories); + _configInfo.ProcessInfo = JsonSerializer.Serialize(_preparedProcessInfo, ProcessInfoJsonContext.Default.ProcessInfo); - GeneralTracer.Info("SilentPollOrchestrator: update prepared."); Interlocked.Exchange(ref _prepared, 1); + GeneralTracer.Info("SilentPollOrchestrator: update prepared, waiting for process exit."); + } + else if (upgradeVersions.Count > 0) + { + // Upgrade-only: packages already applied, no handoff needed + Interlocked.Exchange(ref _prepared, 1); + GeneralTracer.Info("SilentPollOrchestrator: upgrade-only update applied, no client handoff needed."); + } - // ══— Hooks + Reporter: update applied ══— - if (_hooks != null) - { - try { await _hooks.OnAfterUpdateAsync(updateCtx).ConfigureAwait(false); } - catch (Exception ex) { GeneralTracer.Warn($"Hook OnAfterUpdateAsync failed: {ex.Message}"); } - } - - if (_reporter != null) - { - try - { - var elapsedMs = (DateTimeOffset.UtcNow - startTime).TotalMilliseconds; - await _reporter.ReportAsync(new UpdateReport( - updateCtx.AppName, updateCtx.CurrentVersion, updateCtx.TargetVersion, - UpdateEvent.UpdateApplied, AppType.Client, DateTimeOffset.UtcNow, - DurationMs: elapsedMs), token).ConfigureAwait(false); - } - catch (Exception ex) { GeneralTracer.Warn($"Reporter UpdateApplied failed: {ex.Message}"); } - } + if (_hooks != null) + { + try { await _hooks.OnAfterUpdateAsync(updateCtx).ConfigureAwait(false); } + catch (Exception ex) { GeneralTracer.Warn($"Hook OnAfterUpdateAsync failed: {ex.Message}"); } } - catch (Exception ex) + if (_reporter != null) { - GeneralTracer.Error("SilentPollOrchestrator: pipeline execution failed.", ex); - if (_hooks != null) - { - try { await _hooks.OnUpdateErrorAsync(updateCtx, ex).ConfigureAwait(false); } - catch (Exception hookEx) { GeneralTracer.Warn($"Hook OnUpdateErrorAsync failed: {hookEx.Message}"); } - } - if (_reporter != null) - { - try - { - await _reporter.ReportAsync(new UpdateReport( - updateCtx.AppName, updateCtx.CurrentVersion, updateCtx.TargetVersion, - UpdateEvent.UpdateFailed, AppType.Client, DateTimeOffset.UtcNow, - ErrorMessage: ex.Message), token).ConfigureAwait(false); - } - catch (Exception reporterEx) { GeneralTracer.Warn($"Reporter UpdateFailed failed: {reporterEx.Message}"); } - } + try { await _reporter.ReportAsync(new UpdateReport(0, (int)UpdateStatus.Success, 1), token).ConfigureAwait(false); } + catch (Exception ex) { GeneralTracer.Warn($"Reporter UpdateApplied failed: {ex.Message}"); } } } @@ -316,24 +281,29 @@ private void OnProcessExit(object? sender, EventArgs e) try { - var updaterPath = Path.Combine(_configInfo.InstallPath, _configInfo.AppName); - - // Start the upgrade process first — it will call ReceiveAsync in its constructor. - // We then call SendAsync which creates the NamedPipe server; the upgrade's - // client connects to it. Auto-fallback (SharedMemory > EncryptedFile) handles - // timing gaps where the named pipe handshake doesn't complete in time. - if (File.Exists(updaterPath)) + // Resolve updater location — prefers UpdatePath, falls back to InstallPath + var updaterDir = !string.IsNullOrWhiteSpace(_configInfo.UpdatePath) + ? (Path.IsPathRooted(_configInfo.UpdatePath) + ? _configInfo.UpdatePath + : Path.Combine(_configInfo.InstallPath, _configInfo.UpdatePath)) + : _configInfo.InstallPath; + var updaterPath = Path.Combine(updaterDir, _configInfo.UpdateAppName); + + if (!File.Exists(updaterPath)) { - GeneralTracer.Info($"SilentPollOrchestrator: launching updater {updaterPath}"); - Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = updaterPath }); + GeneralTracer.Warn($"SilentPollOrchestrator: updater not found at {updaterPath}, cannot launch."); + return; } - // Send ProcessInfo via AES-encrypted file IPC. - if (_preparedProcessInfo != null) + // Send ProcessInfo with Client packages via encrypted file IPC BEFORE starting Upgrade + if (_preparedProcessInfo != null && _clientVersions.Count > 0) { new EncryptedFileProcessInfoProvider().Send(_preparedProcessInfo); - GeneralTracer.Info("SilentPollOrchestrator: ProcessInfo sent via encrypted file IPC."); + GeneralTracer.Info($"SilentPollOrchestrator: ProcessInfo sent with {_clientVersions.Count} Client package(s)."); } + + Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = updaterPath }); + GeneralTracer.Info($"SilentPollOrchestrator: launched updater {updaterPath}"); } catch (Exception ex) { @@ -341,6 +311,30 @@ private void OnProcessExit(object? sender, EventArgs e) } } + private void TryReportError(UpdateContext ctx, Exception ex) + { + if (_hooks != null) + { + try { _hooks.OnUpdateErrorAsync(ctx, ex).GetAwaiter().GetResult(); } + catch (Exception hookEx) { GeneralTracer.Warn($"Hook OnUpdateErrorAsync failed: {hookEx.Message}"); } + } + if (_reporter != null) + { + try { _reporter.ReportAsync(new UpdateReport(0, (int)UpdateStatus.Failure, 1)).GetAwaiter().GetResult(); } + catch (Exception reporterEx) { GeneralTracer.Warn($"Reporter UpdateFailed failed: {reporterEx.Message}"); } + } + } + + private void InitBlackList() + { + var effectiveConfig = new BlackListConfig( + _configInfo.BlackFiles?.Count > 0 ? _configInfo.BlackFiles : BlackListDefaults.DefaultBlackFiles, + _configInfo.BlackFormats?.Count > 0 ? _configInfo.BlackFormats : BlackListDefaults.DefaultBlackFormats, + _configInfo.SkipDirectorys?.Count > 0 ? _configInfo.SkipDirectorys : BlackListDefaults.DefaultSkipDirectories + ); + StorageManager.BlackListMatcher = new DefaultBlackListMatcher(effectiveConfig); + } + private static IStrategy CreateStrategy() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return new WindowsStrategy(); @@ -372,12 +366,14 @@ public void 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; + /// + /// Whether to launch the client application after the upgrade process finishes. + /// Default: true (current behavior). Set to false when the caller wants to + /// manually control restart timing (e.g. maintenance windows, orchestrated rollouts). + /// + public bool LaunchClientAfterUpdate { get; set; } = true; } diff --git a/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs index 43976118..270d866b 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using GeneralUpdate.Core.Differential; using GeneralUpdate.Core.FileSystem; @@ -19,11 +20,11 @@ public abstract class AbstractStrategy : IStrategy private const string Patchs = "patchs"; protected GlobalConfigInfo _configinfo = new(); - /// Optional hooks for pre/post update callbacks. - protected IUpdateHooks? Hooks { get; set; } + /// Hooks for pre/post update callbacks. + public IUpdateHooks Hooks { get; set; } = new Hooks.NoOpUpdateHooks(); - /// Optional reporter for update status reporting. - protected IUpdateReporter? Reporter { get; set; } + /// Reporter for update status reporting. + public IUpdateReporter Reporter { get; set; } = new Download.Reporting.NoOpUpdateReporter(); /// Optional binary differ for differential patch updates. public IDirtyStrategy? DirtyStrategy { get; set; } @@ -33,7 +34,20 @@ public abstract class AbstractStrategy : IStrategy /// DiffPipeline for parallel patch application with progress reporting. public DiffPipeline? DiffPipeline { get; set; } - + + /// App to launch in . Set by the upper strategy. + public string? LaunchAppName { get; set; } + + /// Whether to also start the Bowl companion process. Windows only. Set by the upper strategy. + public bool LaunchBowl { get; set; } + + /// + /// When true, resolves the app from + /// first before falling back to . + /// Set by when launching the upgrade process. + /// + public bool UseUpdatePath { get; set; } + public virtual Task StartAppAsync() => throw new NotImplementedException(); public virtual async Task ExecuteAsync() @@ -65,11 +79,15 @@ await VersionService.Report(_configinfo.ReportUrl , version.AppType , _configinfo.Scheme , _configinfo.Token); + + // Delete only this version's zip file — other AppType packages + // in TempPath may still be needed by a downstream process. + DeleteVersionZip(version); } } Clear(patchPath); - Clear(_configinfo.TempPath); + TryCleanTempPath(); await OnExecuteCompleteAsync(); } catch (Exception e) @@ -88,7 +106,7 @@ protected virtual PipelineContext CreatePipelineContext(VersionInfo version, str { var context = new PipelineContext(); // Common parameters - context.Add("ZipFilePath", Path.Combine(_configinfo.TempPath, $"{version.Name}{_configinfo.Format}")); + context.Add("ZipFilePath", Path.Combine(_configinfo.TempPath, $"{version.Name}{_configinfo.Format.ToExtension()}")); // Hash middleware context.Add("Hash", version.Hash); // Zip middleware @@ -96,7 +114,9 @@ protected virtual PipelineContext CreatePipelineContext(VersionInfo version, str context.Add("Name", version.Name); context.Add("Encoding", _configinfo.Encoding); // Patch middleware - context.Add("SourcePath", _configinfo.InstallPath); + // For Upgrade packages, apply to UpdatePath if configured; otherwise fall back to InstallPath + var sourcePath = ResolveTargetPath(version); + context.Add("SourcePath", sourcePath); context.Add("PatchPath", patchPath); context.Add("PatchEnabled", _configinfo.PatchEnabled); // Binary differ for differential patching @@ -138,12 +158,56 @@ protected virtual void HandleExecuteException(Exception e) /// protected static string CheckPath(string path, string name) { - if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(name)) + if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(name)) return string.Empty; var tempPath = Path.Combine(path, name); return File.Exists(tempPath) ? tempPath : string.Empty; } + /// + /// Resolves the full path for an executable, optionally checking + /// before falling back to InstallPath. + /// + /// The executable name. + /// When true, checks UpdatePath first. + /// Full path if found, empty string otherwise. + protected string ResolveAppPath(string name, bool preferUpdatePath = false) + { + if (preferUpdatePath && !string.IsNullOrWhiteSpace(_configinfo.UpdatePath)) + { + var upgradeDir = ResolveUpdateDir(); + var path = CheckPath(upgradeDir, name); + if (!string.IsNullOrEmpty(path)) + return path; + } + + return CheckPath(_configinfo.InstallPath, name); + } + + /// + /// Resolves the target directory for applying a package. + /// For Upgrade (AppType=2) packages, uses if configured; + /// otherwise falls back to . + /// + protected string ResolveTargetPath(VersionInfo version) + { + if (version.AppType == 2 && !string.IsNullOrWhiteSpace(_configinfo.UpdatePath)) + return ResolveUpdateDir(); + + return _configinfo.InstallPath; + } + + /// + /// Resolves to an absolute path. + /// Relative paths are combined with . + /// + private string ResolveUpdateDir() + { + return Path.IsPathRooted(_configinfo.UpdatePath) + ? _configinfo.UpdatePath + : Path.Combine(_configinfo.InstallPath, _configinfo.UpdatePath); + } + // ═══ Safe hooks/reporter wrappers (shared by all strategy subclasses) ═══ // Note: Each subclass builds its own UpdateContext via BuildUpdateContext(). // Subclasses should call hooks/reporter through their own context-aware wrappers. @@ -177,5 +241,55 @@ private static void Clear(string path) if (Directory.Exists(path)) StorageManager.DeleteDirectory(path); } + + /// + /// Deletes the zip file for a processed version from TempPath. + /// Only removes the specific file — other packages in the same directory + /// may belong to a different AppType and must be kept for downstream processes. + /// + private void DeleteVersionZip(VersionInfo version) + { + if (string.IsNullOrWhiteSpace(_configinfo.TempPath)) return; + + var zipPath = Path.Combine(_configinfo.TempPath, $"{version.Name}{_configinfo.Format.ToExtension()}"); + try + { + if (File.Exists(zipPath)) + { + File.SetAttributes(zipPath, FileAttributes.Normal); + File.Delete(zipPath); + GeneralTracer.Info($"AbstractStrategy: deleted processed zip {zipPath}"); + } + } + catch (Exception ex) + { + GeneralTracer.Warn($"AbstractStrategy: failed to delete zip {zipPath}. {ex.Message}"); + } + } + + /// + /// Removes TempPath if it is empty after all processed zips have been deleted. + /// The last process in the chain (usually Upgrade) will find an empty directory + /// and clean it up. Earlier processes skip this because other AppType packages + /// still remain in the directory. + /// + private void TryCleanTempPath() + { + try + { + var tempPath = _configinfo.TempPath; + if (string.IsNullOrWhiteSpace(tempPath) || !Directory.Exists(tempPath)) return; + + if (!Directory.EnumerateFileSystemEntries(tempPath).Any()) + { + Directory.Delete(tempPath, false); + GeneralTracer.Info($"AbstractStrategy: cleaned empty temp directory {tempPath}"); + } + } + catch (Exception ex) + { + GeneralTracer.Warn($"AbstractStrategy: failed to clean temp directory. {ex.Message}"); + } + } } } diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs index b80167c9..dbaa4828 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs @@ -36,6 +36,17 @@ public class ClientUpdateStrategy : IStrategy private IStrategy? _osStrategy; private Func? _updatePrecheck; private readonly Download.Abstractions.IDownloadOrchestrator? _orchestrator; + private int _mainRecordId; + + /// Which side(s) need updating, determined by server validation. + private enum UpdateScenario + { + None, + UpgradeOnly, + MainOnly, + Both + } + /// Lifecycle hooks injected by the bootstrap. public Hooks.IUpdateHooks Hooks { get; set; } = new Hooks.NoOpUpdateHooks(); /// Update status reporter injected by the bootstrap. @@ -49,8 +60,12 @@ public void Create(GlobalConfigInfo parameter) { _configInfo = parameter ?? throw new ArgumentNullException(nameof(parameter)); _osStrategy = ResolveOsStrategy(); - if (_pendingDirtyStrategy != null && _osStrategy is AbstractStrategy abs) - abs.DirtyStrategy = _pendingDirtyStrategy; + if (_osStrategy is AbstractStrategy abs) + { + if (_pendingDirtyStrategy != null) abs.DirtyStrategy = _pendingDirtyStrategy; + if (_pendingBinaryDiffer != null) abs.BinaryDiffer = _pendingBinaryDiffer; + if (_pendingDiffPipeline != null) abs.DiffPipeline = _pendingDiffPipeline; + } } public async Task ExecuteAsync() @@ -74,6 +89,8 @@ public async Task ExecuteAsync() } private IDirtyStrategy? _pendingDirtyStrategy; + private IBinaryDiffer? _pendingBinaryDiffer; + private DiffPipeline? _pendingDiffPipeline; /// Sets the directory-level dirty strategy on the underlying OS-level strategy for differential patch updates. /// Safe to call before or after Create(). If called before, the strategy is cached and applied when Create() resolves _osStrategy. @@ -90,6 +107,8 @@ public void SetBinaryDiffer(IBinaryDiffer? binaryDiffer) { if (_osStrategy is AbstractStrategy abs) abs.BinaryDiffer = binaryDiffer; + else + _pendingBinaryDiffer = binaryDiffer; } /// Sets the DiffPipeline on the underlying OS-level strategy for parallel patch application. @@ -97,6 +116,8 @@ public void SetDiffPipeline(DiffPipeline? diffPipeline) { if (_osStrategy is AbstractStrategy abs) abs.DiffPipeline = diffPipeline; + else + _pendingDiffPipeline = diffPipeline; } public async Task StartAppAsync() @@ -116,7 +137,7 @@ public ClientUpdateStrategy UseUpdatePrecheck(Func fu private async Task ExecuteWorkflowAsync() { - // Standard mode — silent mode is handled by GeneralUpdateBootstrap.LaunchSilentAsync(). + // Standard mode �?silent mode is handled by GeneralUpdateBootstrap.LaunchSilentAsync(). // Runtime options (Encoding, Format, DownloadTimeOut, etc.) are already // populated on _configInfo by Bootstrap.ApplyRuntimeOptions(). await ExecuteStandardWorkflowAsync(); @@ -137,17 +158,54 @@ private async Task ExecuteStandardWorkflowAsync() _configInfo.Scheme, _configInfo.Token); - var assets = await downloadSource.ListAsync().ConfigureAwait(false); - var downloadPlan = Download.DownloadPlanBuilder.Build(assets, _configInfo.ClientVersion); + // Call server validation — returns assets plus per-side flags from the two Validate calls + var sourceResult = await downloadSource.ListAsync().ConfigureAwait(false); + var downloadPlan = Download.DownloadPlanBuilder.Build(sourceResult.Assets, _configInfo.ClientVersion); - // Detect update status - _configInfo.IsMainUpdate = downloadPlan.HasAssets; - _configInfo.IsUpgradeUpdate = assets.Any(a => a.Version != _configInfo.ClientVersion); + // Detect update status from SERVER validation results: IsMainUpdate is true only when + // the server returned version info for the Client call, IsUpgradeUpdate only when + // the server returned version info for the Upgrade call (requirement 1). + _configInfo.IsMainUpdate = sourceResult.HasMainUpdate; + _configInfo.IsUpgradeUpdate = sourceResult.HasUpgradeUpdate; _configInfo.LastVersion = downloadPlan.Assets.LastOrDefault()?.Version; - GeneralTracer.Info($"ClientUpdateStrategy: IsMainUpdate={_configInfo.IsMainUpdate}, IsUpgradeUpdate={_configInfo.IsUpgradeUpdate}, AssetCount={downloadPlan.Assets.Count}"); - // Dispatch update info event - var updateInfoArgs = new UpdateInfoEventArgs(null); + var scenario = (_configInfo.IsMainUpdate, _configInfo.IsUpgradeUpdate) switch + { + (false, false) => UpdateScenario.None, + (false, true) => UpdateScenario.UpgradeOnly, + (true, false) => UpdateScenario.MainOnly, + (true, true) => UpdateScenario.Both, + }; + GeneralTracer.Info($"ClientUpdateStrategy: Scenario={scenario}, AssetCount={downloadPlan.Assets.Count}"); + + // Dispatch update info event with populated version data (full GeneralSpacestation-compatible fields) + var versionInfos = downloadPlan.Assets.Select(a => new VersionInfo + { + RecordId = a.RecordId, + Name = a.Name, + Url = a.Url, + Size = a.Size, + Hash = a.SHA256, + Version = a.Version, + IsForcibly = a.IsForcibly, + IsFreeze = a.IsFreeze, + AppType = a.AppType, + UpgradeMode = a.UpgradeMode, + IsCrossVersion = a.IsCrossVersion, + FromVersion = a.FromVersion + }).ToList(); + + var versionResp = new VersionRespDTO + { + Code = versionInfos.Count > 0 ? 200 : 404, + Body = versionInfos, + Message = versionInfos.Count > 0 ? $"Found {versionInfos.Count} update(s)." : "No updates available." + }; + + var updateInfoArgs = new UpdateInfoEventArgs(versionResp); + + // Capture the first RecordId for status reporting to GeneralSpacestation + _mainRecordId = downloadPlan.Assets.FirstOrDefault().RecordId; EventManager.Instance.Dispatch(this, updateInfoArgs); var isForcibly = downloadPlan.IsForcibly; @@ -157,6 +215,13 @@ private async Task ExecuteStandardWorkflowAsync() return; } + // Scenario None: nothing to update — exit early + if (scenario == UpdateScenario.None) + { + GeneralTracer.Info("ClientUpdateStrategy: no update available for client or upgrade."); + return; + } + // Hooks: allow cancellation before download var hooksCtx = BuildUpdateContext(); if (!await SafeOnBeforeUpdateAsync(hooksCtx).ConfigureAwait(false)) @@ -169,17 +234,10 @@ private async Task ExecuteStandardWorkflowAsync() await SafeReportUpdateStartedAsync(hooksCtx).ConfigureAwait(false); InitBlackList(); - _configInfo.TempPath = StorageManager.GetTempDirectory("main_temp"); _configInfo.BackupDirectory = Path.Combine(_configInfo.InstallPath, $"{StorageManager.DirectoryName}{_configInfo.ClientVersion}"); - if (!_configInfo.IsMainUpdate) - { - GeneralTracer.Info("ClientUpdateStrategy: no update available."); - return; - } - // Check failed version if (!string.IsNullOrEmpty(_configInfo.LastVersion) && CheckFail(_configInfo.LastVersion)) { @@ -199,7 +257,8 @@ private async Task ExecuteStandardWorkflowAsync() _osStrategy!.Create(_configInfo); - // Download via orchestrator — wired with options from GlobalConfigInfo + // Download ALL packages via orchestrator (requirement 6: client downloads everything + // regardless of whether client or upgrade needs updating) var orchOptions = Download.Models.DownloadOrchestratorOptions.From(_configInfo); GeneralTracer.Info($"ClientUpdateStrategy: downloading {downloadPlan.Assets.Count} asset(s)."); if (_orchestrator != null) @@ -220,49 +279,66 @@ private async Task ExecuteStandardWorkflowAsync() await SafeReportDownloadCompletedAsync(hooksCtx).ConfigureAwait(false); await SafeOnDownloadCompletedAsync(hooksCtx).ConfigureAwait(false); - // Phase: apply Upgrade packages — update Upgrade.exe itself before launching it. - // Safe because MainApp and Upgrade.exe are different files (no lock conflict). - var allVersions = downloadPlan.Assets.Select(a => new VersionInfo - { - Name = a.Name, - Hash = a.SHA256, - Url = a.Url, - Version = a.Version, - Format = _configInfo.Format ?? "ZIP", - AppType = a.IsForcibly ? null : null // preserve original AppType - }).ToList(); - - // Rebuild the full VersionInfo list with AppType preserved from download source + // Build VersionInfo list with AppType preserved from server response. var downloadVersions = downloadPlan.Assets.Select(a => new VersionInfo { Name = a.Name, Hash = a.SHA256, Url = a.Url, Version = a.Version, - Format = _configInfo.Format ?? "ZIP", - AppType = _configInfo.IsUpgradeUpdate == true && a.Version != _configInfo.ClientVersion - ? (int)AppType.Upgrade : (int)AppType.Client + Format = _configInfo.Format.ToExtension(), + AppType = a.AppType ?? (int)AppType.Client }).ToList(); - // Split: Upgrade versions vs MainApp versions var upgradeVersions = downloadVersions.Where(v => v.AppType == (int)AppType.Upgrade).ToList(); - var clientVersions = downloadVersions.Where(v => v.AppType != (int)AppType.Upgrade).ToList(); - + var clientVersions = downloadVersions.Where(v => v.AppType == (int)AppType.Client).ToList(); GeneralTracer.Info($"ClientUpdateStrategy: Upgrade packages={upgradeVersions.Count}, MainApp packages={clientVersions.Count}"); - // Apply Upgrade packages now (update Upgrade.exe before launching it) - if (upgradeVersions.Count > 0) + // ── Dispatch by scenario — one switch, four states, zero nested if-else ── + switch (scenario) { - GeneralTracer.Info("ClientUpdateStrategy: applying Upgrade packages."); - _configInfo.UpdateVersions = upgradeVersions; - _osStrategy!.Create(_configInfo); - await _osStrategy.ExecuteAsync(); + case UpdateScenario.UpgradeOnly: + await ApplyUpgradePackagesAsync(upgradeVersions).ConfigureAwait(false); + await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false); + await SafeReportUpdateAppliedAsync(hooksCtx).ConfigureAwait(false); + GeneralTracer.Info("ClientUpdateStrategy: Upgrade-only update applied, client continues running."); + break; + + case UpdateScenario.MainOnly: + SendProcessIpc(clientVersions); + await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false); + await LaunchUpgradeProcessAsync().ConfigureAwait(false); + break; + + case UpdateScenario.Both: + await ApplyUpgradePackagesAsync(upgradeVersions).ConfigureAwait(false); + await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false); + await SafeReportUpdateAppliedAsync(hooksCtx).ConfigureAwait(false); + SendProcessIpc(clientVersions); + await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false); + await LaunchUpgradeProcessAsync().ConfigureAwait(false); + break; } + } + + #endregion + + #region Scenario actions - // Send IPC with remaining MainApp versions for the upgrade process + private async Task ApplyUpgradePackagesAsync(List upgradeVersions) + { + if (upgradeVersions.Count == 0) return; + GeneralTracer.Info("ClientUpdateStrategy: applying Upgrade packages in place."); + _configInfo!.UpdateVersions = upgradeVersions; + _osStrategy!.Create(_configInfo); + await _osStrategy.ExecuteAsync().ConfigureAwait(false); + } + + private void SendProcessIpc(List clientVersions) + { var processInfo = ConfigurationMapper.MapToProcessInfo( - _configInfo, clientVersions, - _configInfo.BlackFormats ?? BlackListDefaults.DefaultBlackFormats, + _configInfo!, clientVersions, + _configInfo!.BlackFormats ?? BlackListDefaults.DefaultBlackFormats, _configInfo.BlackFiles ?? BlackListDefaults.DefaultBlackFiles, _configInfo.SkipDirectorys ?? BlackListDefaults.DefaultSkipDirectories); @@ -270,20 +346,18 @@ private async Task ExecuteStandardWorkflowAsync() ProcessInfoJsonContext.Default.ProcessInfo); new EncryptedFileProcessInfoProvider().Send(processInfo); GeneralTracer.Info("ClientUpdateStrategy: ProcessInfo sent with MainApp versions only."); + } - await SafeOnAfterUpdateAsync(hooksCtx).ConfigureAwait(false); - await SafeReportUpdateAppliedAsync(hooksCtx).ConfigureAwait(false); - await SafeOnBeforeStartAppAsync(hooksCtx).ConfigureAwait(false); - - // Launch the upgrade process to apply MainApp updates - var updaterPath = Path.Combine(_configInfo.InstallPath, _configInfo.AppName); - if (!File.Exists(updaterPath)) - throw new FileNotFoundException($"Upgrade application not found: {updaterPath}"); - - GeneralTracer.Info($"ClientUpdateStrategy: launching upgrade process {updaterPath}"); - Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = updaterPath }); - GeneralTracer.Info("ClientUpdateStrategy: upgrade process launched, exiting."); - await GracefulExit.CurrentProcessAsync().ConfigureAwait(false); + private async Task LaunchUpgradeProcessAsync() + { + if (_osStrategy is AbstractStrategy abs) + { + abs.LaunchAppName = _configInfo!.UpdateAppName; + abs.LaunchBowl = false; + abs.UseUpdatePath = !string.IsNullOrWhiteSpace(_configInfo.UpdatePath); + } + GeneralTracer.Info($"ClientUpdateStrategy: launching upgrade process {_configInfo!.UpdateAppName} via OS strategy."); + await _osStrategy!.StartAppAsync(); } #endregion @@ -366,7 +440,7 @@ private async Task CallSmallBowlHomeAsync(string processName) private Hooks.UpdateContext BuildUpdateContext() { return new Hooks.UpdateContext( - _configInfo?.AppName ?? "unknown", + _configInfo?.UpdateAppName ?? "unknown", _configInfo?.InstallPath ?? AppDomain.CurrentDomain.BaseDirectory, _configInfo?.ClientVersion ?? "0.0.0", _configInfo?.LastVersion, @@ -403,7 +477,7 @@ private async Task SafeOnDownloadCompletedAsync(Hooks.UpdateContext ctx) try { var downloadCtx = new Hooks.DownloadContext( - _configInfo?.MainAppName ?? _configInfo?.AppName ?? "unknown", + _configInfo?.MainAppName ?? _configInfo?.UpdateAppName ?? "unknown", _configInfo?.LastVersion ?? "", 0, TimeSpan.Zero, _configInfo?.TempPath, true); await Hooks.OnDownloadCompletedAsync(downloadCtx).ConfigureAwait(false); @@ -415,10 +489,7 @@ private async Task SafeReportUpdateStartedAsync(Hooks.UpdateContext ctx) { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateStarted, ctx.AppType, DateTimeOffset.UtcNow - )).ConfigureAwait(false); + await Reporter.ReportAsync(new Download.Reporting.UpdateReport(_mainRecordId, (int)Download.Reporting.UpdateStatus.Updating, 1)).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Report UpdateStarted failed: {ex.Message}"); } } @@ -427,10 +498,7 @@ private async Task SafeReportDownloadCompletedAsync(Hooks.UpdateContext ctx) { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.DownloadCompleted, ctx.AppType, DateTimeOffset.UtcNow - )).ConfigureAwait(false); + await Reporter.ReportAsync(new Download.Reporting.UpdateReport(_mainRecordId, (int)Download.Reporting.UpdateStatus.Updating, 1)).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Report DownloadCompleted failed: {ex.Message}"); } } @@ -439,11 +507,7 @@ private async Task SafeReportUpdateFailedAsync(Hooks.UpdateContext ctx, Exceptio { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateFailed, ctx.AppType, DateTimeOffset.UtcNow, - ErrorMessage: error.Message - )).ConfigureAwait(false); + await Reporter.ReportAsync(new Download.Reporting.UpdateReport(_mainRecordId, (int)Download.Reporting.UpdateStatus.Failure, 1)).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Report UpdateFailed failed: {ex.Message}"); } } @@ -452,10 +516,7 @@ private async Task SafeReportUpdateAppliedAsync(Hooks.UpdateContext ctx) { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateApplied, ctx.AppType, DateTimeOffset.UtcNow - )).ConfigureAwait(false); + await Reporter.ReportAsync(new Download.Reporting.UpdateReport(_mainRecordId, (int)Download.Reporting.UpdateStatus.Success, 1)).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Report UpdateApplied failed: {ex.Message}"); } } diff --git a/src/c#/GeneralUpdate.Core/Strategy/IStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/IStrategy.cs index 64a3531b..791daf02 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/IStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/IStrategy.cs @@ -1,5 +1,7 @@ using System.Threading.Tasks; using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Hooks; +using IUpdateReporter = GeneralUpdate.Core.Download.Reporting.IUpdateReporter; namespace GeneralUpdate.Core.Strategy { @@ -8,6 +10,16 @@ namespace GeneralUpdate.Core.Strategy /// public interface IStrategy { + /// + /// Lifecycle hooks for pre/post update callbacks. + /// + IUpdateHooks Hooks { get; set; } + + /// + /// Update status reporter. + /// + IUpdateReporter Reporter { get; set; } + /// /// Execution strategy. /// diff --git a/src/c#/GeneralUpdate.Core/Strategy/LinuxStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/LinuxStrategy.cs index 90f3255f..5d4d6984 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/LinuxStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/LinuxStrategy.cs @@ -27,23 +27,18 @@ protected override PipelineBuilder BuildPipeline(PipelineContext context) return builder; } - protected override async Task OnExecuteCompleteAsync() - { - GeneralTracer.Info("GeneralUpdate.Core.LinuxStrategy.OnExecuteComplete: all versions processed, starting application."); - await StartAppAsync(); - } - public override async Task StartAppAsync() { try { - var mainAppPath = CheckPath(_configinfo.InstallPath, _configinfo.MainAppName); - if (string.IsNullOrEmpty(mainAppPath)) - throw new Exception($"Can't find the app {mainAppPath}!"); + var appName = LaunchAppName ?? throw new InvalidOperationException("LaunchAppName must be set before calling StartAppAsync."); + var appPath = ResolveAppPath(appName, UseUpdatePath); + if (string.IsNullOrEmpty(appPath)) + throw new Exception($"Can't find the app {appName}!"); - GeneralTracer.Info($"GeneralUpdate.Core.LinuxStrategy.StartApp: launching main app={mainAppPath}"); - Process.Start(mainAppPath); - GeneralTracer.Info("GeneralUpdate.Core.LinuxStrategy.StartApp: main app launched successfully."); + GeneralTracer.Info($"GeneralUpdate.Core.LinuxStrategy.StartApp: launching app={appPath}"); + Process.Start(appPath); + GeneralTracer.Info("GeneralUpdate.Core.LinuxStrategy.StartApp: app launched successfully."); } catch (Exception e) { @@ -58,5 +53,4 @@ public override async Task StartAppAsync() await GracefulExit.CurrentProcessAsync(); } } - } diff --git a/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs index 4373bcd3..08737b34 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs @@ -21,13 +21,12 @@ public override async Task StartAppAsync() { try { - var mainApp = Path.Combine( - _configinfo.InstallPath ?? string.Empty, - _configinfo.MainAppName ?? string.Empty); + var appName = LaunchAppName ?? throw new InvalidOperationException("LaunchAppName must be set before calling StartAppAsync."); + var mainApp = ResolveAppPath(appName, UseUpdatePath); - if (!string.IsNullOrEmpty(_configinfo.MainAppName) && File.Exists(mainApp)) + if (!string.IsNullOrEmpty(mainApp) && File.Exists(mainApp)) { - GeneralTracer.Info($"MacStrategy: starting {mainApp}"); + GeneralTracer.Info($"MacStrategy.StartApp: launching app={mainApp}"); System.Diagnostics.Process.Start(mainApp); } } diff --git a/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs index 122d1cde..74f12f0a 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs @@ -16,11 +16,11 @@ namespace GeneralUpdate.Core.Strategy; /// -/// OSS (Object Storage Service) update strategy — client/upgrade split via AppType. +/// OSS (Object Storage Service) update strategy -- client/upgrade split via AppType. /// -/// — downloads version config, checks for updates, +/// -- downloads version config, checks for updates, /// starts the upgrade process, and exits. -/// — reads version config, downloads packages from OSS, +/// -- reads version config, downloads packages from OSS, /// decompresses them, starts the main app, and exits. /// /// @@ -51,7 +51,7 @@ public async Task ExecuteAsync() if (_configInfo == null) throw new InvalidOperationException("OSSUpdateStrategy not configured. Call Create() first."); - // Dispatch by role — no env-var detection needed. + // Dispatch by role �?no env-var detection needed. if (_role == AppType.OSSUpgrade) { await ExecuteUpgradeAsync(); @@ -69,8 +69,9 @@ private async Task ExecuteClientAsync() { GeneralTracer.Debug("OSSUpdateStrategy (client): checking for updates."); - var versionFileName = $"{_configInfo!.MainAppName ?? _configInfo.AppName}_versions.json"; - var versionsFilePath = Path.Combine(_appPath, versionFileName); + var installPath = _configInfo!.InstallPath; + var versionFileName = $"{_configInfo.MainAppName ?? _configInfo.UpdateAppName}_versions.json"; + var versionsFilePath = Path.Combine(installPath, versionFileName); if (!string.IsNullOrEmpty(_configInfo.UpdateUrl)) { @@ -101,13 +102,18 @@ private async Task ExecuteClientAsync() return; } - // Use user-configured AppName or default upgrade exe - var upgradeAppName = !string.IsNullOrWhiteSpace(_configInfo.AppName) && _configInfo.AppName != "Update.exe" - ? _configInfo.AppName + // Resolve upgrade exe: prefer UpdatePath, fall back to InstallPath + var upgradeDir = !string.IsNullOrWhiteSpace(_configInfo.UpdatePath) + ? (Path.IsPathRooted(_configInfo.UpdatePath) + ? _configInfo.UpdatePath + : Path.Combine(installPath, _configInfo.UpdatePath)) + : installPath; + var upgradeAppName = !string.IsNullOrWhiteSpace(_configInfo.UpdateAppName) + ? _configInfo.UpdateAppName : "GeneralUpdate.Upgrade.exe"; - var appPath = Path.Combine(_appPath, upgradeAppName); + var appPath = Path.Combine(upgradeDir, upgradeAppName); if (!File.Exists(appPath)) - throw new FileNotFoundException($"Upgrade application not found: {upgradeAppName}"); + throw new FileNotFoundException($"Upgrade application not found: {appPath}"); Process.Start(appPath); await GracefulExit.CurrentProcessAsync().ConfigureAwait(false); @@ -122,8 +128,10 @@ private async Task ExecuteUpgradeAsync() var ctx = BuildUpdateContext(); try { - var versionFileName = $"{_configInfo!.MainAppName ?? _configInfo.AppName}_versions.json"; - var jsonPath = Path.Combine(_appPath, versionFileName); + // Client downloaded the version JSON to InstallPath; Upgrade reads it from there + var installPath = _configInfo!.InstallPath; + var versionFileName = $"{_configInfo.MainAppName ?? _configInfo.UpdateAppName}_versions.json"; + var jsonPath = Path.Combine(installPath, versionFileName); if (!File.Exists(jsonPath) && DownloadSource == null) throw new FileNotFoundException($"Version config not found: {jsonPath}"); @@ -141,7 +149,8 @@ private async Task ExecuteUpgradeAsync() List assets; if (DownloadSource != null) { - assets = (await DownloadSource.ListAsync().ConfigureAwait(false)).ToList(); + var sourceResult = await DownloadSource.ListAsync().ConfigureAwait(false); + assets = sourceResult.Assets.ToList(); } else { @@ -158,9 +167,7 @@ private async Task ExecuteUpgradeAsync() if (string.IsNullOrWhiteSpace(v.Url)) throw new InvalidOperationException( $"OSS version '{v.PacketName ?? v.Version}' has no download URL."); - var zipName = $"{v.PacketName ?? v.Version}zip"; - if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - zipName += ".zip"; + var zipName = $"{v.PacketName ?? v.Version}{Format.Zip.ToExtension()}"; return new DownloadAsset( Name: zipName, Url: v.Url, Size: 0, SHA256: v.Hash, Version: v.Version ?? "0.0.0"); @@ -171,10 +178,11 @@ private async Task ExecuteUpgradeAsync() throw new InvalidOperationException("No assets to download."); GeneralTracer.Debug($"OSSUpdateStrategy (upgrade): downloading {assets.Count} asset(s)."); - await DownloadAssetsAsync(assets).ConfigureAwait(false); + await DownloadAssetsAsync(assets, installPath).ConfigureAwait(false); GeneralTracer.Debug("OSSUpdateStrategy (upgrade): decompressing."); - DecompressAssets(assets); + var encoding = Encoding.GetEncoding(_configInfo?.Encoding?.CodePage ?? Encoding.UTF8.CodePage); + DecompressAssets(assets, installPath, encoding); await SafeOnDownloadCompletedAsync(ctx).ConfigureAwait(false); await SafeOnAfterUpdateAsync(ctx).ConfigureAwait(false); @@ -199,10 +207,11 @@ private async Task ExecuteUpgradeAsync() public Task StartAppAsync() { - var appName = _configInfo?.MainAppName ?? _configInfo?.AppName; + var appName = _configInfo?.MainAppName ?? _configInfo?.UpdateAppName; if (string.IsNullOrEmpty(appName)) return Task.CompletedTask; - var appPath = Path.Combine(_appPath, appName); + var targetDir = _configInfo?.InstallPath ?? _appPath; + var appPath = Path.Combine(targetDir, appName); if (!File.Exists(appPath)) throw new FileNotFoundException($"Application not found: {appPath}"); @@ -234,12 +243,12 @@ private static bool IsOssUpgrade(string clientVersion, string serverVersion) && cv < sv; } - private async Task DownloadAssetsAsync(List assets) + private async Task DownloadAssetsAsync(List assets, string targetPath) { var plan = new DownloadPlan(assets, false); if (DownloadOrchestrator != null) { - await DownloadOrchestrator.ExecuteAsync(plan, _appPath).ConfigureAwait(false); + await DownloadOrchestrator.ExecuteAsync(plan, targetPath).ConfigureAwait(false); } else { @@ -248,17 +257,16 @@ private async Task DownloadAssetsAsync(List assets) Timeout = TimeSpan.FromSeconds(_configInfo?.DownloadTimeOut > 0 ? _configInfo!.DownloadTimeOut : DefaultTimeOut) }; var orchestrator = new DefaultDownloadOrchestrator(httpClient); - await orchestrator.ExecuteAsync(plan, _appPath).ConfigureAwait(false); + await orchestrator.ExecuteAsync(plan, targetPath).ConfigureAwait(false); } } - private void DecompressAssets(List assets) + private static void DecompressAssets(List assets, string targetPath, Encoding encoding) { - var encoding = Encoding.GetEncoding(_configInfo?.Encoding?.CodePage ?? Encoding.UTF8.CodePage); foreach (var asset in assets) { - var zipFilePath = Path.Combine(_appPath, $"{asset.Name}{Format.ZIP}"); - CompressProvider.Decompress(Format.ZIP, zipFilePath, _appPath, encoding); + var zipFilePath = Path.Combine(targetPath, asset.Name); + CompressProvider.Decompress(Format.Zip, zipFilePath, targetPath, encoding); if (!File.Exists(zipFilePath)) continue; File.SetAttributes(zipFilePath, FileAttributes.Normal); @@ -269,7 +277,7 @@ private void DecompressAssets(List assets) private Hooks.UpdateContext BuildUpdateContext() { return new Hooks.UpdateContext( - _configInfo?.AppName ?? "unknown", + _configInfo?.UpdateAppName ?? "unknown", _configInfo?.InstallPath ?? _appPath, _configInfo?.ClientVersion ?? "0.0.0", _configInfo?.LastVersion, @@ -302,7 +310,7 @@ private async Task SafeOnDownloadCompletedAsync(Hooks.UpdateContext ctx) try { var downloadCtx = new Hooks.DownloadContext( - _configInfo?.MainAppName ?? _configInfo?.AppName ?? "unknown", + _configInfo?.MainAppName ?? _configInfo?.UpdateAppName ?? "unknown", _configInfo?.LastVersion ?? "", 0, TimeSpan.Zero, _appPath, true); await Hooks.OnDownloadCompletedAsync(downloadCtx).ConfigureAwait(false); } @@ -312,10 +320,7 @@ private async Task SafeReportUpdateStartedAsync(Hooks.UpdateContext ctx) { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateStarted, ctx.AppType, DateTimeOffset.UtcNow - )).ConfigureAwait(false); + await Reporter.ReportAsync(new Download.Reporting.UpdateReport(0, (int)Download.Reporting.UpdateStatus.Updating, 1)).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Report UpdateStarted failed: {ex.Message}"); } } @@ -323,10 +328,7 @@ private async Task SafeReportUpdateAppliedAsync(Hooks.UpdateContext ctx) { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateApplied, ctx.AppType, DateTimeOffset.UtcNow - )).ConfigureAwait(false); + await Reporter.ReportAsync(new Download.Reporting.UpdateReport(0, (int)Download.Reporting.UpdateStatus.Success, 1)).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Report UpdateApplied failed: {ex.Message}"); } } @@ -334,11 +336,7 @@ private async Task SafeReportUpdateFailedAsync(Hooks.UpdateContext ctx, Exceptio { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateFailed, ctx.AppType, DateTimeOffset.UtcNow, - ErrorMessage: error.Message - )).ConfigureAwait(false); + await Reporter.ReportAsync(new Download.Reporting.UpdateReport(0, (int)Download.Reporting.UpdateStatus.Failure, 1)).ConfigureAwait(false); } catch (Exception ex) { GeneralTracer.Warn($"Report UpdateFailed failed: {ex.Message}"); } } diff --git a/src/c#/GeneralUpdate.Core/Strategy/UpgradeUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/UpgradeUpdateStrategy.cs index be5218e9..f413288b 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/UpgradeUpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/UpgradeUpdateStrategy.cs @@ -21,7 +21,7 @@ namespace GeneralUpdate.Core.Strategy; /// Design: Upgrade does NOT validate versions or download packages. /// The client has already validated versions, downloaded all packages, and /// passed the results via ProcessInfo. Upgrade only applies updates and -/// starts the main application — zero network. +/// starts the main application -- zero network. /// public class UpgradeUpdateStrategy : IStrategy { @@ -30,6 +30,7 @@ public class UpgradeUpdateStrategy : IStrategy /// Lifecycle hooks injected by the bootstrap. public Hooks.IUpdateHooks Hooks { get; set; } = new Hooks.NoOpUpdateHooks(); + /// Update status reporter injected by the bootstrap. public Download.Reporting.IUpdateReporter Reporter { get; set; } = new Download.Reporting.NoOpUpdateReporter(); @@ -37,8 +38,12 @@ public void Create(GlobalConfigInfo parameter) { _configInfo = parameter ?? throw new ArgumentNullException(nameof(parameter)); _osStrategy = ResolveOsStrategy(); - if (_pendingDirtyStrategy != null && _osStrategy is AbstractStrategy abs) - abs.DirtyStrategy = _pendingDirtyStrategy; + if (_osStrategy is AbstractStrategy abs) + { + if (_pendingDirtyStrategy != null) abs.DirtyStrategy = _pendingDirtyStrategy; + if (_pendingBinaryDiffer != null) abs.BinaryDiffer = _pendingBinaryDiffer; + if (_pendingDiffPipeline != null) abs.DiffPipeline = _pendingDiffPipeline; + } } public async Task ExecuteAsync() @@ -59,10 +64,11 @@ public async Task ExecuteAsync() _osStrategy!.Create(_configInfo); - // Apply MainApp updates — Client already applied Upgrade packages, IPC only has MainApp versions + // Apply MainApp updates -- Client already applied Upgrade packages, IPC only has MainApp versions if (_configInfo.UpdateVersions?.Count > 0) { - GeneralTracer.Info("UpgradeUpdateStrategy: applying " + _configInfo.UpdateVersions.Count + " MainApp update(s)."); + GeneralTracer.Info("UpgradeUpdateStrategy: applying " + _configInfo.UpdateVersions.Count + + " MainApp update(s)."); await _osStrategy.ExecuteAsync(); } else @@ -79,7 +85,22 @@ public async Task ExecuteAsync() // Hooks: before starting main app (e.g. chmod +x on Linux/macOS) await SafeOnBeforeStartAppAsync(ctx).ConfigureAwait(false); - await _osStrategy.StartAppAsync(); + // Delegate to OS strategy: launch MainAppName + Bowl. + // Skip if silent mode requested no-launch (e.g. maintenance windows). + if (_configInfo.LaunchClientAfterUpdate) + { + if (_osStrategy is AbstractStrategy abs2) + { + abs2.LaunchAppName = _configInfo.MainAppName; + abs2.LaunchBowl = true; + } + + await _osStrategy.StartAppAsync(); + } + else + { + GeneralTracer.Info("UpgradeUpdateStrategy: LaunchClientAfterUpdate=false, skipping app launch."); + } } catch (Exception ex) { @@ -91,6 +112,8 @@ public async Task ExecuteAsync() } private IDirtyStrategy? _pendingDirtyStrategy; + private IBinaryDiffer? _pendingBinaryDiffer; + private DiffPipeline? _pendingDiffPipeline; /// Sets the directory-level dirty strategy on the underlying OS-level strategy for differential patch updates. /// Safe to call before or after Create(). If called before, the strategy is cached and applied when Create() resolves _osStrategy. @@ -107,6 +130,8 @@ public void SetBinaryDiffer(IBinaryDiffer? binaryDiffer) { if (_osStrategy is AbstractStrategy abs) abs.BinaryDiffer = binaryDiffer; + else + _pendingBinaryDiffer = binaryDiffer; } /// Sets the DiffPipeline on the underlying OS-level strategy for parallel patch application. @@ -114,6 +139,8 @@ public void SetDiffPipeline(DiffPipeline? diffPipeline) { if (_osStrategy is AbstractStrategy abs) abs.DiffPipeline = diffPipeline; + else + _pendingDiffPipeline = diffPipeline; } public async Task StartAppAsync() @@ -142,7 +169,7 @@ private static IStrategy ResolveOsStrategy() private Hooks.UpdateContext BuildUpdateContext() { return new Hooks.UpdateContext( - _configInfo?.AppName ?? "unknown", + _configInfo?.UpdateAppName ?? "unknown", _configInfo?.InstallPath ?? AppDomain.CurrentDomain.BaseDirectory, _configInfo?.ClientVersion ?? "0.0.0", _configInfo?.LastVersion, @@ -152,52 +179,81 @@ private Hooks.UpdateContext BuildUpdateContext() private async Task SafeOnBeforeUpdateAsync(Hooks.UpdateContext ctx) { - try { return await Hooks.OnBeforeUpdateAsync(ctx).ConfigureAwait(false); } - catch (Exception ex) { GeneralTracer.Warn($"OnBeforeUpdateAsync hook failed: {ex.Message}"); return true; } + try + { + return await Hooks.OnBeforeUpdateAsync(ctx).ConfigureAwait(false); + } + catch (Exception ex) + { + GeneralTracer.Warn($"OnBeforeUpdateAsync hook failed: {ex.Message}"); + return true; + } } private async Task SafeOnAfterUpdateAsync(Hooks.UpdateContext ctx) { - try { await Hooks.OnAfterUpdateAsync(ctx).ConfigureAwait(false); } - catch (Exception ex) { GeneralTracer.Warn($"OnAfterUpdateAsync hook failed: {ex.Message}"); } + try + { + await Hooks.OnAfterUpdateAsync(ctx).ConfigureAwait(false); + } + catch (Exception ex) + { + GeneralTracer.Warn($"OnAfterUpdateAsync hook failed: {ex.Message}"); + } } private async Task SafeOnBeforeStartAppAsync(Hooks.UpdateContext ctx) { - try { await Hooks.OnBeforeStartAppAsync(ctx).ConfigureAwait(false); } - catch (Exception ex) { GeneralTracer.Warn($"OnBeforeStartAppAsync hook failed: {ex.Message}"); } + try + { + await Hooks.OnBeforeStartAppAsync(ctx).ConfigureAwait(false); + } + catch (Exception ex) + { + GeneralTracer.Warn($"OnBeforeStartAppAsync hook failed: {ex.Message}"); + } } private async Task SafeOnUpdateErrorAsync(Hooks.UpdateContext ctx, Exception error) { - try { await Hooks.OnUpdateErrorAsync(ctx, error).ConfigureAwait(false); } - catch (Exception ex) { GeneralTracer.Warn($"OnUpdateErrorAsync hook failed: {ex.Message}"); } + try + { + await Hooks.OnUpdateErrorAsync(ctx, error).ConfigureAwait(false); + } + catch (Exception ex) + { + GeneralTracer.Warn($"OnUpdateErrorAsync hook failed: {ex.Message}"); + } } private async Task SafeReportUpdateAppliedAsync(Hooks.UpdateContext ctx) { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateApplied, ctx.AppType, DateTimeOffset.UtcNow - )).ConfigureAwait(false); + await Reporter + .ReportAsync(new Download.Reporting.UpdateReport(0, (int)Download.Reporting.UpdateStatus.Success, + 1)).ConfigureAwait(false); + } + catch (Exception ex) + { + GeneralTracer.Warn($"Report UpdateApplied failed: {ex.Message}"); } - catch (Exception ex) { GeneralTracer.Warn($"Report UpdateApplied failed: {ex.Message}"); } } private async Task SafeReportUpdateFailedAsync(Hooks.UpdateContext ctx, Exception error) { try { - await Reporter.ReportAsync(new Download.Reporting.UpdateReport( - ctx.AppName, ctx.CurrentVersion, ctx.TargetVersion, - Download.Reporting.UpdateEvent.UpdateFailed, ctx.AppType, DateTimeOffset.UtcNow, - ErrorMessage: error.Message - )).ConfigureAwait(false); + await Reporter + .ReportAsync( + new Download.Reporting.UpdateReport(0, (int)Download.Reporting.UpdateStatus.Failure, 1)) + .ConfigureAwait(false); + } + catch (Exception ex) + { + GeneralTracer.Warn($"Report UpdateFailed failed: {ex.Message}"); } - catch (Exception ex) { GeneralTracer.Warn($"Report UpdateFailed failed: {ex.Message}"); } } #endregion -} +} \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs index 72d5e613..bcec709c 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs @@ -30,29 +30,27 @@ protected override PipelineBuilder BuildPipeline(PipelineContext context) return builder; } - protected override async Task OnExecuteCompleteAsync() - { - GeneralTracer.Info("GeneralUpdate.Core.WindowsStrategy.OnExecuteComplete: all versions processed, starting application."); - await StartAppAsync(); - } - public override async Task StartAppAsync() { try { - var mainAppPath = CheckPath(_configinfo.InstallPath, _configinfo.MainAppName); - if (string.IsNullOrEmpty(mainAppPath)) - throw new Exception($"Can't find the app {mainAppPath}!"); + var appName = LaunchAppName ?? throw new InvalidOperationException("LaunchAppName must be set before calling StartAppAsync."); + var appPath = ResolveAppPath(appName, UseUpdatePath); + if (string.IsNullOrEmpty(appPath)) + throw new Exception($"Can't find the app {appName}!"); - GeneralTracer.Info($"GeneralUpdate.Core.WindowsStrategy.StartApp: launching main app={mainAppPath}"); - Process.Start(mainAppPath); - GeneralTracer.Info("GeneralUpdate.Core.WindowsStrategy.StartApp: main app launched successfully."); + GeneralTracer.Info($"GeneralUpdate.Core.WindowsStrategy.StartApp: launching app={appPath}"); + Process.Start(appPath); + GeneralTracer.Info("GeneralUpdate.Core.WindowsStrategy.StartApp: app launched successfully."); - var bowlAppPath = CheckPath(_configinfo.InstallPath, _configinfo.Bowl); - if (!string.IsNullOrEmpty(bowlAppPath)) + if (LaunchBowl) { - GeneralTracer.Info($"GeneralUpdate.Core.WindowsStrategy.StartApp: launching Bowl process={bowlAppPath}"); - Process.Start(bowlAppPath); + var bowlAppPath = CheckPath(_configinfo.InstallPath, _configinfo.Bowl); + if (!string.IsNullOrEmpty(bowlAppPath)) + { + GeneralTracer.Info($"GeneralUpdate.Core.WindowsStrategy.StartApp: launching Bowl process={bowlAppPath}"); + Process.Start(bowlAppPath); + } } } catch (Exception e) diff --git a/src/c#/GeneralUpdate.slnx b/src/c#/GeneralUpdate.slnx index 7ec27ed9..f0af67bd 100644 --- a/src/c#/GeneralUpdate.slnx +++ b/src/c#/GeneralUpdate.slnx @@ -9,9 +9,11 @@ + + diff --git a/tests/ClientTest/ClientTest.csproj b/tests/ClientTest/ClientTest.csproj new file mode 100644 index 00000000..191c3d61 --- /dev/null +++ b/tests/ClientTest/ClientTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + diff --git a/tests/ClientTest/Program.cs b/tests/ClientTest/Program.cs new file mode 100644 index 00000000..80c69cdc --- /dev/null +++ b/tests/ClientTest/Program.cs @@ -0,0 +1,134 @@ +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Event; +using GeneralUpdate.Core.Hooks; + +try +{ + Console.WriteLine("=== GeneralUpdate Client Test ==="); + Console.WriteLine($"Started at {DateTime.Now}"); + Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}"); + + var updateUrl = "http://localhost:5000/Upgrade/Verification"; + var reportUrl = "http://localhost:5000/Upgrade/Report"; + var appSecretKey = Environment.GetEnvironmentVariable("APP_SECRET_KEY") ?? "dfeb5833-975e-4afb-88f1-6278ee9aeff6"; + var productId = Environment.GetEnvironmentVariable("PRODUCT_ID") ?? "2d974e2a-31e6-4887-9bb1-b4689e98c77a"; + var clientVersion = Environment.GetEnvironmentVariable("CLIENT_VERSION") ?? "1.0.0.0"; + + Console.WriteLine($"UpdateUrl: {updateUrl}"); + Console.WriteLine($"ReportUrl: {reportUrl}"); + Console.WriteLine($"ClientVersion: {clientVersion}"); + Console.WriteLine($"ProductId: {productId}"); + Console.WriteLine(); + + var config = new Configinfo + { + UpdateUrl = updateUrl, + ReportUrl = reportUrl, + UpdatePath = "Upgrade", + UpdateAppName = "UpgradeTest.exe", + MainAppName = "ClientTest.exe", + InstallPath = AppDomain.CurrentDomain.BaseDirectory, + ClientVersion = clientVersion, + UpgradeClientVersion = "1.0.0.0", + ProductId = productId, + AppSecretKey = appSecretKey, + }; + + await new GeneralUpdateBootstrap() + .SetConfig(config) + .Option(UpdateOptions.AppType, AppType.Client) + .Hooks() + .AddListenerMultiDownloadStatistics(OnDownloadStatistics) + .AddListenerMultiDownloadCompleted(OnDownloadCompleted) + .AddListenerMultiAllDownloadCompleted(OnAllDownloadCompleted) + .AddListenerMultiDownloadError(OnDownloadError) + .AddListenerException(OnException) + .AddListenerUpdateInfo(OnUpdateInfo) + .LaunchAsync(); + + Console.WriteLine("Client test completed."); +} +catch (Exception ex) +{ + Console.WriteLine($"FATAL: {ex}"); + Environment.Exit(1); +} + +static void OnDownloadStatistics(object sender, MultiDownloadStatisticsEventArgs e) +{ + var v = e.Version as VersionInfo; + Console.WriteLine($"[Download] {v?.Version}: {e.ProgressPercentage}% | {e.Speed} | ETA: {e.Remaining}"); +} + +static void OnDownloadCompleted(object sender, MultiDownloadCompletedEventArgs e) +{ + var v = e.Version as VersionInfo; + Console.WriteLine($"[Download] {v?.Version}: {(e.IsCompleted ? "SUCCESS" : "FAILED")}"); +} + +static void OnAllDownloadCompleted(object sender, MultiAllDownloadCompletedEventArgs e) +{ + Console.WriteLine(e.IsAllDownloadCompleted + ? "[Download] All downloads completed." + : $"[Download] Downloads finished with {e.FailedVersions.Count} failure(s)."); +} + +static void OnDownloadError(object sender, MultiDownloadErrorEventArgs e) +{ + var v = e.Version as VersionInfo; + Console.WriteLine($"[Download] Error @ {v?.Version}: {e.Exception.Message}"); +} + +static void OnException(object sender, ExceptionEventArgs e) +{ + Console.WriteLine($"[Error] {e.Exception}"); +} + +static void OnUpdateInfo(object sender, UpdateInfoEventArgs e) +{ + Console.WriteLine($"[UpdateInfo] Code={e.Info?.Code}, Message={e.Info?.Message}"); + if (e.Info?.Body is { Count: > 0 }) + { + foreach (var vi in e.Info.Body) + Console.WriteLine($" - {vi.Version} ({vi.Name}) [{vi.Size} bytes] {(vi.IsForcibly == true ? "(forced)" : "")}"); + } + else + { + Console.WriteLine(" No updates available."); + } +} + +sealed class ClientTestHooks : IUpdateHooks +{ + public async Task OnBeforeUpdateAsync(UpdateContext ctx) + { + Console.WriteLine($"[Hook] OnBeforeUpdate: {ctx.CurrentVersion} -> {ctx.TargetVersion}"); + return await Task.FromResult(true); + } + + public async Task OnDownloadCompletedAsync(DownloadContext ctx) + { + Console.WriteLine($"[Hook] OnDownloadCompleted: {ctx.AssetName} v{ctx.Version} ({ctx.TotalBytes} bytes, {ctx.Duration}) {(ctx.Success ? "OK" : "FAIL")}"); + await Task.CompletedTask; + } + + public async Task OnAfterUpdateAsync(UpdateContext ctx) + { + Console.WriteLine($"[Hook] OnAfterUpdate: {ctx.CurrentVersion} -> {ctx.TargetVersion}"); + await Task.CompletedTask; + } + + public async Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex) + { + Console.WriteLine($"[Hook] OnUpdateError: {ctx.CurrentVersion} -> {ctx.TargetVersion} | {ex.Message}"); + await Task.CompletedTask; + } + + public async Task OnBeforeStartAppAsync(UpdateContext ctx) + { + Console.WriteLine($"[Hook] OnBeforeStartApp: {ctx.UpdateAppName} @ {ctx.InstallPath}"); + await Task.CompletedTask; + } +} diff --git a/tests/CoreTest/Bootstrap/BootstrapFullParameterMatrixTests.cs b/tests/CoreTest/Bootstrap/BootstrapFullParameterMatrixTests.cs index af50e332..0132fcd8 100644 --- a/tests/CoreTest/Bootstrap/BootstrapFullParameterMatrixTests.cs +++ b/tests/CoreTest/Bootstrap/BootstrapFullParameterMatrixTests.cs @@ -7,7 +7,9 @@ using System.Threading.Tasks; using GeneralUpdate.Core; using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download.Reporting; using GeneralUpdate.Core.FileSystem; +using GeneralUpdate.Core.Hooks; using Xunit; namespace CoreTest.Bootstrap @@ -53,7 +55,7 @@ public void Dispose() [Fact] public void DiffMode_Parallel() => Assert.NotNull(B().Option(UpdateOptions.DiffMode, DiffMode.Parallel)); [Fact] public void Encoding_Utf8() => Assert.NotNull(B().Option(UpdateOptions.Encoding, Encoding.UTF8)); [Fact] public void Encoding_Ascii() => Assert.NotNull(B().Option(UpdateOptions.Encoding, Encoding.ASCII)); - [Fact] public void Format_ZIP() => Assert.NotNull(B().Option(UpdateOptions.Format, "ZIP")); + [Fact] public void Format_ZIP() => Assert.NotNull(B().Option(UpdateOptions.Format, Format.Zip)); [Theory][InlineData(10)][InlineData(30)][InlineData(60)][InlineData(300)] public void DownloadTimeout_Various(int t) => Assert.NotNull(B().Option(UpdateOptions.DownloadTimeout, t)); [Fact] public void PatchEnabled_True() => Assert.NotNull(B().Option(UpdateOptions.PatchEnabled, true)); @@ -63,7 +65,6 @@ public void Dispose() #endregion #region Silent - [Fact] public void SilentAutoInstall_True() => Assert.NotNull(B().Option(UpdateOptions.SilentAutoInstall, true)); [Theory][InlineData(15)][InlineData(30)][InlineData(60)] public void SilentPollInterval_Various(int m) => Assert.NotNull(B().Option(UpdateOptions.SilentPollIntervalMinutes, m)); #endregion @@ -79,8 +80,6 @@ public void Dispose() #endregion #region Blacklist/Misc - [Fact] public void Hub_Configured() => Assert.NotNull(B().Option(UpdateOptions.Hub, - new HubConfig { Url = "https://signalr.example.com/hub" })); #endregion #region Extension Injection — Hooks / Strategy / Policy / Differ / Pipeline / etc. @@ -97,6 +96,8 @@ private sealed class StubHooks : GeneralUpdate.Core.Hooks.IUpdateHooks private sealed class StubStrategy : GeneralUpdate.Core.Strategy.IStrategy { public void Create(GlobalConfigInfo parameter) { } + public IUpdateHooks Hooks { get; set; } + public IUpdateReporter Reporter { get; set; } public Task ExecuteAsync() => Task.CompletedTask; public Task StartAppAsync() => Task.CompletedTask; } @@ -128,8 +129,11 @@ private sealed class StubDownloadExecutor : GeneralUpdate.Core.Download.Abstract private sealed class StubDownloadSource : GeneralUpdate.Core.Download.Abstractions.IDownloadSource { - public Task> ListAsync(CancellationToken token = default) - => Task.FromResult>(Array.Empty()); + public Task ListAsync(CancellationToken token = default) + => Task.FromResult(new GeneralUpdate.Core.Download.Models.DownloadSourceResult + { + Assets = Array.Empty() + }); } private sealed class StubDownloadPipeline : GeneralUpdate.Core.Download.Abstractions.IDownloadPipeline @@ -207,7 +211,7 @@ [Fact] public void Chain_AllFrameworkOptions() .Option(UpdateOptions.AppType, AppType.Client) .Option(UpdateOptions.DiffMode, DiffMode.Parallel) .Option(UpdateOptions.Encoding, Encoding.UTF8) - .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.Format, Format.Zip) .Option(UpdateOptions.DownloadTimeout, 120) .Option(UpdateOptions.PatchEnabled, true) .Option(UpdateOptions.BackupEnabled, true) @@ -217,7 +221,6 @@ [Fact] public void Chain_AllFrameworkOptions() .Option(UpdateOptions.RetryCount, 5) .Option(UpdateOptions.VerifyChecksum, true) .Option(UpdateOptions.RetryInterval, TimeSpan.FromSeconds(2)) - .Option(UpdateOptions.SilentAutoInstall, false) .Option(UpdateOptions.SilentPollIntervalMinutes, 30) .SetConfig(new Configinfo { @@ -237,7 +240,6 @@ [Fact] public void Chain_SilentClient() var b = new GeneralUpdateBootstrap() .Option(UpdateOptions.AppType, AppType.Client) .Option(UpdateOptions.Silent, true) - .Option(UpdateOptions.SilentAutoInstall, true) .Option(UpdateOptions.SilentPollIntervalMinutes, 15) .SetConfig(new Configinfo { UpdateUrl = "https://api.example.com", MainAppName = "MyApp.exe", ClientVersion = "1.0.0", InstallPath = _testDir, AppSecretKey = "key", Scheme = "https", Token = "token" }); Assert.NotNull(b); @@ -265,7 +267,7 @@ [Fact] public void Chain_ClientAndUpgrade_BothFullyConfigured() var sharedConfig = new Configinfo { UpdateUrl = "https://update.enterprise.com/api/v2", - AppName = "Update.exe", MainAppName = "EnterpriseApp.exe", + UpdateAppName = "Update.exe", MainAppName = "EnterpriseApp.exe", ClientVersion = "4.2.1", UpgradeClientVersion = "2.0.0", InstallPath = _testDir, AppSecretKey = "enterprise-prod-key-2026", ProductId = "enterprise-app-v4", @@ -281,7 +283,7 @@ [Fact] public void Chain_ClientAndUpgrade_BothFullyConfigured() .Option(UpdateOptions.AppType, AppType.Client) .Option(UpdateOptions.DiffMode, DiffMode.Parallel) .Option(UpdateOptions.Encoding, Encoding.UTF8) - .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.Format, Format.Zip) .Option(UpdateOptions.DownloadTimeout, 120) .Option(UpdateOptions.PatchEnabled, true) .Option(UpdateOptions.BackupEnabled, true) @@ -309,7 +311,7 @@ [Fact] public void Chain_ClientAndUpgrade_BothFullyConfigured() .Option(UpdateOptions.AppType, AppType.Upgrade) .Option(UpdateOptions.DiffMode, DiffMode.Parallel) .Option(UpdateOptions.Encoding, Encoding.UTF8) - .Option(UpdateOptions.Format, "ZIP") + .Option(UpdateOptions.Format, Format.Zip) .Option(UpdateOptions.DownloadTimeout, 30) .Option(UpdateOptions.PatchEnabled, true) .Option(UpdateOptions.BackupEnabled, false) diff --git a/tests/CoreTest/Bootstrap/BootstrapHooksAndExtensionsTests.cs b/tests/CoreTest/Bootstrap/BootstrapHooksAndExtensionsTests.cs index 9f447218..a4b34dbf 100644 --- a/tests/CoreTest/Bootstrap/BootstrapHooksAndExtensionsTests.cs +++ b/tests/CoreTest/Bootstrap/BootstrapHooksAndExtensionsTests.cs @@ -156,29 +156,30 @@ public async Task NoOpUpdateHooks_AllMethods_ReturnDefaults() [Fact] public void UpdateReport_StartedEvent() { - var report = new UpdateReport("MyApp.exe", "1.0.0", "2.0.0", UpdateEvent.UpdateStarted, AppType.Client, DateTimeOffset.UtcNow); - Assert.Equal(UpdateEvent.UpdateStarted, report.Event); - Assert.Equal("1.0.0", report.FromVersion); + var report = new UpdateReport(123, (int)UpdateStatus.Updating, 1); + Assert.Equal(123, report.RecordId); + Assert.Equal((int)UpdateStatus.Updating, report.Status); + Assert.Equal(1, report.Type); } [Fact] public void UpdateReport_FailedWithError() { - var report = new UpdateReport("App.exe", "1.0.0", "2.0.0", UpdateEvent.UpdateFailed, AppType.Client, - DateTimeOffset.UtcNow, ErrorMessage: "Disk full", DurationMs: 15000.0); - Assert.Equal("Disk full", report.ErrorMessage); - Assert.Equal(15000.0, report.DurationMs); + var report = new UpdateReport(456, (int)UpdateStatus.Failure, 1); + Assert.Equal(456, report.RecordId); + Assert.Equal((int)UpdateStatus.Failure, report.Status); + Assert.Equal(1, report.Type); } [Fact] - public void UpdateEvent_AllValues_AreDefined() + public void UpdateStatus_AllValues_AreDefined() { - var values = Enum.GetValues(); - Assert.Contains(UpdateEvent.UpdateStarted, values); - Assert.Contains(UpdateEvent.DownloadCompleted, values); - Assert.Contains(UpdateEvent.UpdateApplied, values); - Assert.Contains(UpdateEvent.UpdateFailed, values); - Assert.Contains(UpdateEvent.AppStarted, values); + var values = Enum.GetValues(); + Assert.Contains(UpdateStatus.Updating, values); + Assert.Contains(UpdateStatus.Updating, values); + Assert.Contains(UpdateStatus.Success, values); + Assert.Contains(UpdateStatus.Failure, values); + Assert.Contains(UpdateStatus.Success, values); } #endregion @@ -186,7 +187,7 @@ public void UpdateEvent_AllValues_AreDefined() #region IUpdateEventListener [Fact] - public void UpdateEventListener_AllMethods_AreCallable() + public void UpdateStatusListener_AllMethods_AreCallable() { var listener = new TestEventListener(); var vi = new VersionInfo { Version = "2.0.0", Url = "https://cdn.example.com/pkg.zip", Format = "ZIP" }; @@ -220,7 +221,7 @@ private sealed class TestEventListener : IUpdateEventListener public void UpdateContext_AllFields_SetCorrectly() { var ctx = new UpdateContext("MyApp.exe", "/opt/app", "3.2.1", "4.0.0", AppType.Client); - Assert.Equal("MyApp.exe", ctx.AppName); + Assert.Equal("MyApp.exe", ctx.UpdateAppName); Assert.Equal("3.2.1", ctx.CurrentVersion); Assert.Equal("4.0.0", ctx.TargetVersion); } diff --git a/tests/CoreTest/Bootstrap/ClientUpgradeIntegrationTests.cs b/tests/CoreTest/Bootstrap/ClientUpgradeIntegrationTests.cs index ba4b45c6..81fad062 100644 --- a/tests/CoreTest/Bootstrap/ClientUpgradeIntegrationTests.cs +++ b/tests/CoreTest/Bootstrap/ClientUpgradeIntegrationTests.cs @@ -48,7 +48,7 @@ public void ClientUpgrade_MutualUpdate_BothNeedUpdates_ConfiguresCorrectly() var config = new Configinfo { UpdateUrl = "https://api.example.com/updates", - AppName = "Update.exe", + UpdateAppName = "Update.exe", MainAppName = "MyApp.exe", ClientVersion = "1.0.0", UpgradeClientVersion = "0.5.0", @@ -387,7 +387,7 @@ public void ConfigurationMapper_MapToGlobalConfigInfo_MapsAllFields() var configInfo = new Configinfo { UpdateUrl = "https://api.example.com/updates", - AppName = "Update.exe", + UpdateAppName = "Update.exe", MainAppName = "MyApp.exe", ClientVersion = "1.0.0", UpgradeClientVersion = "0.5.0", @@ -409,7 +409,7 @@ public void ConfigurationMapper_MapToGlobalConfigInfo_MapsAllFields() Assert.NotNull(globalConfig); Assert.Equal("https://api.example.com/updates", globalConfig.UpdateUrl); - Assert.Equal("Update.exe", globalConfig.AppName); + Assert.Equal("Update.exe", globalConfig.UpdateAppName); Assert.Equal("MyApp.exe", globalConfig.MainAppName); Assert.Equal("1.0.0", globalConfig.ClientVersion); Assert.Equal("0.5.0", globalConfig.UpgradeClientVersion); @@ -510,7 +510,7 @@ public void DeveloperScenario_FullProductionSetup_CompleteChain() .SetConfig(new Configinfo { UpdateUrl = "https://update.mycompany.com/api", - AppName = "Update.exe", + UpdateAppName = "Update.exe", MainAppName = "MyProduct.exe", ClientVersion = "3.2.1", UpgradeClientVersion = "1.0.0", diff --git a/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs b/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs index 0c15789d..57c409e1 100644 --- a/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs +++ b/tests/CoreTest/Bootstrap/ParameterMatrixAndEventTests.cs @@ -492,7 +492,7 @@ public void Configinfo_FullConfiguration_AllFieldsValid() var config = new Configinfo { UpdateUrl = "https://update.mycompany.com/v2/api", - AppName = "Update.exe", + UpdateAppName = "Update.exe", MainAppName = "EnterpriseApp.exe", ClientVersion = "4.2.1-beta", UpgradeClientVersion = "1.5.0", diff --git a/tests/CoreTest/Compress/CompressProviderTests.cs b/tests/CoreTest/Compress/CompressProviderTests.cs index ea853494..0ac2d8cb 100644 --- a/tests/CoreTest/Compress/CompressProviderTests.cs +++ b/tests/CoreTest/Compress/CompressProviderTests.cs @@ -1,4 +1,5 @@ using GeneralUpdate.Core.Compress; +using GeneralUpdate.Core.Configuration; namespace CoreTest.Compress; @@ -14,7 +15,7 @@ public void Compress_ZipFormat_UsesZipStrategy() try { var ex = Record.Exception(() => - CompressProvider.Compress(".zip", tempDir, destZip, false, System.Text.Encoding.UTF8)); + CompressProvider.Compress(Format.Zip, tempDir, destZip, false, System.Text.Encoding.UTF8)); Assert.Null(ex); Assert.True(File.Exists(destZip)); } @@ -29,7 +30,7 @@ public void Compress_ZipFormat_UsesZipStrategy() public void Compress_UnknownFormat_ThrowsArgumentException() { Assert.Throws(() => - CompressProvider.Compress("RAR", "source", "dest", false, System.Text.Encoding.UTF8)); + CompressProvider.Compress((Format)99, "source", "dest", false, System.Text.Encoding.UTF8)); } [Fact] @@ -44,7 +45,7 @@ public void Decompress_ZipFormat_UsesZipStrategy() { System.IO.Compression.ZipFile.CreateFromDirectory(tempDir, zipPath); var ex = Record.Exception(() => - CompressProvider.Decompress(".zip", zipPath, destDir, System.Text.Encoding.UTF8)); + CompressProvider.Decompress(Format.Zip, zipPath, destDir, System.Text.Encoding.UTF8)); Assert.Null(ex); Assert.True(File.Exists(Path.Combine(destDir, "test.txt"))); } @@ -60,6 +61,6 @@ public void Decompress_ZipFormat_UsesZipStrategy() public void Decompress_UnknownFormat_ThrowsArgumentException() { Assert.Throws(() => - CompressProvider.Decompress("7z", "source", "dest", System.Text.Encoding.UTF8)); + CompressProvider.Decompress((Format)99, "source", "dest", System.Text.Encoding.UTF8)); } } diff --git a/tests/CoreTest/Configuration/BaseConfigInfoTests.cs b/tests/CoreTest/Configuration/BaseConfigInfoTests.cs index 201e8fb2..f73746b6 100644 --- a/tests/CoreTest/Configuration/BaseConfigInfoTests.cs +++ b/tests/CoreTest/Configuration/BaseConfigInfoTests.cs @@ -14,7 +14,7 @@ private class TestableConfig : BaseConfigInfo { } public void Ctor_AppName_DefaultsToUpdateExe() { var config = new TestableConfig(); - Assert.Equal("Update.exe", config.AppName); + Assert.Equal("Update.exe", config.UpdateAppName); } [Fact] @@ -114,7 +114,7 @@ public void AllProperties_CanBeSetAndGet() { var config = new TestableConfig { - AppName = "MyApp.exe", + UpdateAppName = "MyApp.exe", MainAppName = "MainApp", InstallPath = "C:\\MyApp", UpdateLogUrl = "https://logs.example.com", @@ -130,7 +130,7 @@ public void AllProperties_CanBeSetAndGet() DriverDirectory = "C:\\Drivers" }; - Assert.Equal("MyApp.exe", config.AppName); + Assert.Equal("MyApp.exe", config.UpdateAppName); Assert.Equal("MainApp", config.MainAppName); Assert.Equal("C:\\MyApp", config.InstallPath); Assert.Equal("https://logs.example.com", config.UpdateLogUrl); diff --git a/tests/CoreTest/Configuration/ConfiginfoBuilderTests.cs b/tests/CoreTest/Configuration/ConfiginfoBuilderTests.cs index 077fe7ef..df8ce7d7 100644 --- a/tests/CoreTest/Configuration/ConfiginfoBuilderTests.cs +++ b/tests/CoreTest/Configuration/ConfiginfoBuilderTests.cs @@ -51,11 +51,11 @@ public void SetScheme_InvalidValue_ThrowsArgumentException(string value) [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void SetAppName_InvalidValue_ThrowsArgumentException(string value) + public void SetUpgradeAppName_InvalidValue_ThrowsArgumentException(string value) { var builder = new ConfiginfoBuilder(); - var ex = Assert.Throws(() => builder.SetAppName(value)); - Assert.Contains("AppName", ex.Message); + var ex = Assert.Throws(() => builder.SetUpgradeAppName(value)); + Assert.Contains("UpdateAppName", ex.Message); } [Theory] @@ -189,7 +189,7 @@ public void SetMethods_ReturnsSameBuilder_ForChaining() .SetUpdateUrl("https://api.example.com") .SetToken("token") .SetScheme("https") - .SetAppName("MyApp") + .SetUpgradeAppName("MyApp") .SetMainAppName("MainApp") .SetClientVersion("1.0.0"); Assert.Same(builder, result); @@ -206,7 +206,7 @@ public void Build_WithRequiredFields_ReturnsConfiginfo() .SetUpdateUrl("https://api.example.com") .SetToken("token123") .SetScheme("https") - .SetAppName("MyApp.exe") + .SetUpgradeAppName("MyApp.exe") .SetMainAppName("MyApp") .SetClientVersion("1.0.0") .SetAppSecretKey("secret") @@ -217,7 +217,7 @@ public void Build_WithRequiredFields_ReturnsConfiginfo() Assert.Equal("https://api.example.com", config.UpdateUrl); Assert.Equal("token123", config.Token); Assert.Equal("https", config.Scheme); - Assert.Equal("MyApp.exe", config.AppName); + Assert.Equal("MyApp.exe", config.UpdateAppName); Assert.Equal("MyApp", config.MainAppName); Assert.Equal("1.0.0", config.ClientVersion); Assert.Equal("secret", config.AppSecretKey); @@ -231,7 +231,7 @@ public void Build_MissingRequiredFields_ThrowsInvalidOperationException() .SetUpdateUrl("https://api.example.com") .SetToken("token") .SetScheme("https"); - // Missing AppName, MainAppName, AppSecretKey, ClientVersion, InstallPath + // Missing UpdateAppName, MainAppName, AppSecretKey, ClientVersion, InstallPath var ex = Assert.Throws(() => builder.Build()); Assert.Contains("Failed to build valid Configinfo", ex.Message); Assert.IsType(ex.InnerException); @@ -244,7 +244,7 @@ public void Build_EmptyBlackLists_ReturnsEmptyListNotNll() .SetUpdateUrl("https://api.example.com") .SetToken("token123") .SetScheme("https") - .SetAppName("MyApp.exe") + .SetUpgradeAppName("MyApp.exe") .SetMainAppName("MyApp") .SetClientVersion("1.0.0") .SetAppSecretKey("secret") @@ -263,7 +263,7 @@ public void Build_WithOptionalFields_IncludesThem() .SetUpdateUrl("https://api.example.com") .SetToken("token123") .SetScheme("https") - .SetAppName("MyApp.exe") + .SetUpgradeAppName("MyApp.exe") .SetMainAppName("MyApp") .SetClientVersion("1.0.0") .SetAppSecretKey("secret") diff --git a/tests/CoreTest/Configuration/ConfiginfoTests.cs b/tests/CoreTest/Configuration/ConfiginfoTests.cs index 6c9cbb08..744c2a92 100644 --- a/tests/CoreTest/Configuration/ConfiginfoTests.cs +++ b/tests/CoreTest/Configuration/ConfiginfoTests.cs @@ -15,7 +15,7 @@ public void Validate_UpdateUrlNullOrWhitespace_ThrowsArgumentException(string up var config = new Configinfo { UpdateUrl = updateUrl, - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = "1.0.0", @@ -31,7 +31,7 @@ public void Validate_UpdateUrlNotWellFormedUri_ThrowsArgumentException() var config = new Configinfo { UpdateUrl = "not_a_valid_uri!!!", - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = "1.0.0", @@ -48,7 +48,7 @@ public void Validate_UpdateUrlValid_DoesNotThrowForUpdateUrl(string url) var config = new Configinfo { UpdateUrl = url, - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = "1.0.0", @@ -65,7 +65,7 @@ public void Validate_UpdateLogUrlNull_Allowed() { UpdateUrl = "https://api.example.com", UpdateLogUrl = null, - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = "1.0.0", @@ -82,7 +82,7 @@ public void Validate_UpdateLogUrlInvalid_ThrowsArgumentException() { UpdateUrl = "https://api.example.com", UpdateLogUrl = "not_a_uri!!!", - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = "1.0.0", @@ -95,12 +95,12 @@ public void Validate_UpdateLogUrlInvalid_ThrowsArgumentException() [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Validate_AppNameNullOrWhitespace_ThrowsArgumentException(string appName) + public void Validate_UpgradeAppNameNullOrWhitespace_ThrowsArgumentException(string appName) { var config = new Configinfo { UpdateUrl = "https://api.example.com", - AppName = appName, + UpdateAppName = appName, MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = "1.0.0", @@ -113,13 +113,13 @@ public void Validate_AppNameNullOrWhitespace_ThrowsArgumentException(string appN [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Validate_MainAppNameNullOrWhitespace_ThrowsArgumentException(string mainAppName) + public void Validate_MainAppNameNullOrWhitespace_ThrowsArgumentException(string mainUpgradeAppName) { var config = new Configinfo { UpdateUrl = "https://api.example.com", - AppName = "TestApp", - MainAppName = mainAppName, + UpdateAppName = "TestApp", + MainAppName = mainUpgradeAppName, AppSecretKey = "secret", ClientVersion = "1.0.0", InstallPath = "C:\\app" @@ -136,7 +136,7 @@ public void Validate_AppSecretKeyNullOrWhitespace_ThrowsArgumentException(string var config = new Configinfo { UpdateUrl = "https://api.example.com", - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = secretKey, ClientVersion = "1.0.0", @@ -154,7 +154,7 @@ public void Validate_ClientVersionNullOrWhitespace_ThrowsArgumentException(strin var config = new Configinfo { UpdateUrl = "https://api.example.com", - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = clientVersion, @@ -172,7 +172,7 @@ public void Validate_InstallPathNullOrWhitespace_ThrowsArgumentException(string var config = new Configinfo { UpdateUrl = "https://api.example.com", - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret", ClientVersion = "1.0.0", @@ -188,7 +188,7 @@ public void Validate_AllFieldsValid_NoExceptionThrown() { UpdateUrl = "https://api.example.com/update", UpdateLogUrl = "https://api.example.com/log", - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", AppSecretKey = "secret123", ClientVersion = "1.0.0", diff --git a/tests/CoreTest/Configuration/ConfigurationMapperExtendedTests.cs b/tests/CoreTest/Configuration/ConfigurationMapperExtendedTests.cs index de52d9c2..a358c518 100644 --- a/tests/CoreTest/Configuration/ConfigurationMapperExtendedTests.cs +++ b/tests/CoreTest/Configuration/ConfigurationMapperExtendedTests.cs @@ -31,7 +31,7 @@ private GlobalConfigInfo CreateValidSource() ClientVersion = "1.0.0", LastVersion = "2.0.0", Encoding = System.Text.Encoding.UTF8, - Format = ".zip", + Format = Format.Zip, AppSecretKey = "secret", ReportUrl = "https://report.example.com", BackupDirectory = Path.Combine(_tempInstallDir, "backup") @@ -81,14 +81,14 @@ public void MapToGlobalConfigInfo_PreservesExistingTargetFieldsNotInSource() BackupDirectory = "/custom/backup", MaxConcurrency = 8 }; - var source = new Configinfo { AppName = "NewApp.exe" }; + var source = new Configinfo { UpdateAppName = "NewApp.exe" }; var result = ConfigurationMapper.MapToGlobalConfigInfo(source, target); Assert.Equal("/custom/temp", result.TempPath); Assert.Equal("/custom/backup", result.BackupDirectory); Assert.Equal(8, result.MaxConcurrency); - Assert.Equal("NewApp.exe", result.AppName); + Assert.Equal("NewApp.exe", result.UpdateAppName); } #endregion @@ -148,19 +148,19 @@ public void MapToProcessInfo_StandardConfig_CheckAllMappedProperties() var source = CreateValidSource(); source.UpdateLogUrl = "https://logs.test.com"; source.Encoding = System.Text.Encoding.ASCII; - source.Format = ".tar"; + source.Format = Format.Zip; source.DownloadTimeOut = 120; var result = ConfigurationMapper.MapToProcessInfo(source, OneVersion(), new List(), new List(), new List()); - Assert.Equal("MainApp", result.AppName); // MainAppName -> AppName + Assert.Equal("MainApp", result.AppName); // MainAppName -> UpdateAppName Assert.Equal(source.InstallPath, result.InstallPath); Assert.Equal("1.0.0", result.CurrentVersion); Assert.Equal("2.0.0", result.LastVersion); Assert.Equal("secret", result.AppSecretKey); Assert.Equal("us-ascii", result.CompressEncoding); - Assert.Equal(".tar", result.CompressFormat); + Assert.Equal(".zip", result.CompressFormat); Assert.Equal(120, result.DownloadTimeOut); Assert.Equal("https://logs.test.com", result.UpdateLogUrl); Assert.Equal("https://report.example.com", result.ReportUrl); @@ -176,7 +176,7 @@ public void CopyBaseFields_ConfiginfoToGlobalConfigInfo_Works() { var source = new Configinfo { - AppName = "App.exe", + UpdateAppName = "App.exe", MainAppName = "Main", InstallPath = "C:\\app", ClientVersion = "v1", @@ -189,7 +189,7 @@ public void CopyBaseFields_ConfiginfoToGlobalConfigInfo_Works() ConfigurationMapper.CopyBaseFields(source, target); - Assert.Equal("App.exe", target.AppName); + Assert.Equal("App.exe", target.UpdateAppName); Assert.Equal("Main", target.MainAppName); Assert.Equal("C:\\app", target.InstallPath); Assert.Equal("v1", target.ClientVersion); @@ -204,7 +204,7 @@ public void CopyBaseFields_GraphCopy_ConfiginfoToNewConfiginfo_Works() { var source = new Configinfo { - AppName = "Source.exe", + UpdateAppName = "Source.exe", ClientVersion = "5.0.0", Scheme = "https" }; @@ -212,7 +212,7 @@ public void CopyBaseFields_GraphCopy_ConfiginfoToNewConfiginfo_Works() ConfigurationMapper.CopyBaseFields(source, target); - Assert.Equal("Source.exe", target.AppName); + Assert.Equal("Source.exe", target.UpdateAppName); Assert.Equal("5.0.0", target.ClientVersion); Assert.Equal("https", target.Scheme); } @@ -220,13 +220,13 @@ public void CopyBaseFields_GraphCopy_ConfiginfoToNewConfiginfo_Works() [Fact] public void CopyBaseFields_NullSource_DoesNotThrow() { - var target = new GlobalConfigInfo { AppName = "keep" }; + var target = new GlobalConfigInfo { UpdateAppName = "keep" }; var ex = Record.Exception(() => ConfigurationMapper.CopyBaseFields(null!, target)); Assert.Null(ex); - Assert.Equal("keep", target.AppName); + Assert.Equal("keep", target.UpdateAppName); } #endregion diff --git a/tests/CoreTest/Configuration/ConfigurationMapperTests.cs b/tests/CoreTest/Configuration/ConfigurationMapperTests.cs index 57da9121..c06bf462 100644 --- a/tests/CoreTest/Configuration/ConfigurationMapperTests.cs +++ b/tests/CoreTest/Configuration/ConfigurationMapperTests.cs @@ -10,7 +10,7 @@ public void MapToGlobalConfigInfo_TargetNull_CreatesNewInstance() var source = new Configinfo { UpdateUrl = "https://api.example.com", - AppName = "TestApp", + UpdateAppName = "TestApp", MainAppName = "MainApp", ClientVersion = "1.0.0", AppSecretKey = "secret", @@ -23,10 +23,10 @@ public void MapToGlobalConfigInfo_TargetNull_CreatesNewInstance() [Fact] public void MapToGlobalConfigInfo_SourceNull_ReturnsEmptyTarget() { - var target = new GlobalConfigInfo { AppName = "existing" }; + var target = new GlobalConfigInfo { UpdateAppName = "existing" }; var result = ConfigurationMapper.MapToGlobalConfigInfo(null, target); Assert.Same(target, result); - Assert.Equal("existing", result.AppName); // Unchanged + Assert.Equal("existing", result.UpdateAppName); // Unchanged } [Fact] @@ -42,7 +42,7 @@ public void MapToGlobalConfigInfo_MapsAllFields() var source = new Configinfo { UpdateUrl = "https://api.example.com", - AppName = "App.exe", + UpdateAppName = "App.exe", MainAppName = "MainApp", ClientVersion = "2.0.0", AppSecretKey = "key123", @@ -55,7 +55,7 @@ public void MapToGlobalConfigInfo_MapsAllFields() }; var result = ConfigurationMapper.MapToGlobalConfigInfo(source); Assert.Equal("https://api.example.com", result.UpdateUrl); - Assert.Equal("App.exe", result.AppName); + Assert.Equal("App.exe", result.UpdateAppName); Assert.Equal("MainApp", result.MainAppName); Assert.Equal("2.0.0", result.ClientVersion); Assert.Equal("key123", result.AppSecretKey); @@ -79,7 +79,7 @@ public void MapToProcessInfo_SourceNull_ThrowsArgumentNullException() } [Fact] - public void MapToProcessInfo_MapsAppNameToMainAppName() + public void MapToProcessInfo_MapsUpgradeAppNameToMainAppName() { var source = new GlobalConfigInfo { @@ -89,7 +89,7 @@ public void MapToProcessInfo_MapsAppNameToMainAppName() LastVersion = "2.0.0", AppSecretKey = "secret", Encoding = System.Text.Encoding.UTF8, - Format = ".zip", + Format = Format.Zip, DownloadTimeOut = 30, UpdateLogUrl = "https://log.example.com", ReportUrl = "https://report.example.com", @@ -127,7 +127,7 @@ public void CopyBaseFields_SourceNull_DoesNotThrow() [Fact] public void CopyBaseFields_TargetNull_DoesNotThrow() { - var source = new Configinfo { AppName = "source" }; + var source = new Configinfo { UpdateAppName = "source" }; var exception = Record.Exception(() => ConfigurationMapper.CopyBaseFields(source, null)); Assert.Null(exception); } @@ -137,7 +137,7 @@ public void CopyBaseFields_CopiesAllBaseProperties() { var source = new Configinfo { - AppName = "App.exe", + UpdateAppName = "App.exe", MainAppName = "Main", InstallPath = "C:\\path", UpdateLogUrl = "https://log", @@ -152,7 +152,7 @@ public void CopyBaseFields_CopiesAllBaseProperties() ConfigurationMapper.CopyBaseFields(source, target); - Assert.Equal("App.exe", target.AppName); + Assert.Equal("App.exe", target.UpdateAppName); Assert.Equal("Main", target.MainAppName); Assert.Equal("C:\\path", target.InstallPath); Assert.Equal("https://log", target.UpdateLogUrl); diff --git a/tests/CoreTest/Configuration/ConfigurationModelsTests.cs b/tests/CoreTest/Configuration/ConfigurationModelsTests.cs index 7940fd4a..a6d3f9bd 100644 --- a/tests/CoreTest/Configuration/ConfigurationModelsTests.cs +++ b/tests/CoreTest/Configuration/ConfigurationModelsTests.cs @@ -18,7 +18,7 @@ namespace CoreTest.Configuration /// - DownloadStatus / DownloadPriority enums /// - AppType / DiffMode / UpdateMode / PlatformType / OssProvider enums /// - UpdateOption<T> value semantics - /// - UpdateReport / UpdateEvent types + /// - UpdateReport / UpdateStatus types /// public class ConfigurationModelsTests { @@ -68,35 +68,6 @@ public void BlackListConfig_Partial_SingleListOnly() #endregion - #region HubConfig - - [Fact] - public void HubConfig_WithUrl_DefaultsReasonable() - { - var config = new HubConfig { Url = "https://signalr.example.com/hub" }; - - Assert.Equal("https://signalr.example.com/hub", config.Url); - Assert.Equal(TimeSpan.FromSeconds(5), config.ReconnectDelay); - Assert.Equal(10, config.MaxReconnectAttempts); - } - - [Fact] - public void HubConfig_AllFields_Customized() - { - var config = new HubConfig - { - Url = "wss://push.example.com/update-hub", - ReconnectDelay = TimeSpan.FromSeconds(10), - MaxReconnectAttempts = 20 - }; - - Assert.Equal("wss://push.example.com/update-hub", config.Url); - Assert.Equal(TimeSpan.FromSeconds(10), config.ReconnectDelay); - Assert.Equal(20, config.MaxReconnectAttempts); - } - - #endregion - #region DownloadAsset [Fact] @@ -300,14 +271,14 @@ public void DownloadPriority_ThreeValues() } [Fact] - public void UpdateEvent_FiveValues() + public void UpdateStatus_FiveValues() { - var values = Enum.GetValues(); - Assert.Contains(UpdateEvent.UpdateStarted, values); - Assert.Contains(UpdateEvent.DownloadCompleted, values); - Assert.Contains(UpdateEvent.UpdateApplied, values); - Assert.Contains(UpdateEvent.UpdateFailed, values); - Assert.Contains(UpdateEvent.AppStarted, values); + var values = Enum.GetValues(); + Assert.Contains(UpdateStatus.Updating, values); + Assert.Contains(UpdateStatus.Updating, values); + Assert.Contains(UpdateStatus.Success, values); + Assert.Contains(UpdateStatus.Failure, values); + Assert.Contains(UpdateStatus.Success, values); } #endregion diff --git a/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs b/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs index 4303913a..dd7981b1 100644 --- a/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs +++ b/tests/CoreTest/Configuration/GlobalConfigInfoWiringTests.cs @@ -16,10 +16,10 @@ public class GlobalConfigInfoWiringTests #region GlobalConfigInfo Default Values [Fact] - public void GlobalConfigInfo_MaxConcurrency_DefaultsTo3() + public void GlobalConfigInfo_MaxConcurrency_DefaultsTo2() { var config = new GlobalConfigInfo(); - Assert.Equal(3, config.MaxConcurrency); + Assert.Equal(2, config.MaxConcurrency); } [Fact] diff --git a/tests/CoreTest/Configuration/HubConfigTests.cs b/tests/CoreTest/Configuration/HubConfigTests.cs deleted file mode 100644 index 41304990..00000000 --- a/tests/CoreTest/Configuration/HubConfigTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using GeneralUpdate.Core.Configuration; - -namespace CoreTest.Configuration; - -/// -/// AAAT unit tests for — default values and property mutation. -/// Covers: default construction, property get/set, boundary time spans, negative reconnects. -/// -public class HubConfigTests -{ - [Fact] - public void Ctor_Default_UrlIsEmpty() - { - var config = new HubConfig(); - Assert.Equal(string.Empty, config.Url); - } - - [Fact] - public void Ctor_Default_ReconnectDelayIs5Seconds() - { - var config = new HubConfig(); - Assert.Equal(TimeSpan.FromSeconds(5), config.ReconnectDelay); - } - - [Fact] - public void Ctor_Default_MaxReconnectAttemptsIs10() - { - var config = new HubConfig(); - Assert.Equal(10, config.MaxReconnectAttempts); - } - - [Fact] - public void Url_SetAndGet_Works() - { - var config = new HubConfig { Url = "https://hub.example.com/update" }; - Assert.Equal("https://hub.example.com/update", config.Url); - } - - [Fact] - public void ReconnectDelay_SetToZero_Works() - { - var config = new HubConfig { ReconnectDelay = TimeSpan.Zero }; - Assert.Equal(TimeSpan.Zero, config.ReconnectDelay); - } - - [Fact] - public void ReconnectDelay_SetToMaxValue_Works() - { - var config = new HubConfig { ReconnectDelay = TimeSpan.MaxValue }; - Assert.Equal(TimeSpan.MaxValue, config.ReconnectDelay); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(100)] - [InlineData(-1)] - public void MaxReconnectAttempts_SetVarious_Works(int attempts) - { - var config = new HubConfig { MaxReconnectAttempts = attempts }; - Assert.Equal(attempts, config.MaxReconnectAttempts); - } - - [Fact] - public void MaxReconnectAttempts_IntMinValue_Works() - { - var config = new HubConfig { MaxReconnectAttempts = int.MinValue }; - Assert.Equal(int.MinValue, config.MaxReconnectAttempts); - } -} diff --git a/tests/CoreTest/Configuration/ProcessInfoTests.cs b/tests/CoreTest/Configuration/ProcessInfoTests.cs index a10b3bd2..01929a90 100644 --- a/tests/CoreTest/Configuration/ProcessInfoTests.cs +++ b/tests/CoreTest/Configuration/ProcessInfoTests.cs @@ -14,7 +14,7 @@ public void Ctor_AppNameNull_ThrowsArgumentNullException() var ex = Assert.Throws(() => new ProcessInfo(null, ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null)); Assert.Contains("appName", ex.Message); } @@ -24,7 +24,7 @@ public void Ctor_InstallPathDoesNotExist_ThrowsArgumentException() var ex = Assert.Throws(() => new ProcessInfo("app", "C:\\nonexistent_path_xyz", "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null)); Assert.Contains("path does not exist", ex.Message); } @@ -34,7 +34,7 @@ public void Ctor_CurrentVersionNull_ThrowsArgumentNullException() var ex = Assert.Throws(() => new ProcessInfo("app", ExistingDir, null, "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null)); Assert.Contains("currentVersion", ex.Message); } @@ -44,7 +44,7 @@ public void Ctor_LastVersionNull_ThrowsArgumentNullException() var ex = Assert.Throws(() => new ProcessInfo("app", ExistingDir, "1.0", null, null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null)); Assert.Contains("lastVersion", ex.Message); } @@ -54,7 +54,7 @@ public void Ctor_DownloadTimeOutNegative_ThrowsArgumentException() var ex = Assert.Throws(() => new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", -1, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null)); Assert.Contains("greater than 0", ex.Message); } @@ -63,7 +63,7 @@ public void Ctor_DownloadTimeOutZero_Allowed() { var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 0, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null); Assert.Equal(0, info.DownloadTimeOut); } @@ -73,7 +73,7 @@ public void Ctor_AppSecretKeyNull_ThrowsArgumentNullException() var ex = Assert.Throws(() => new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, null, - SingleVersion, "url", "backup", null, null, null, null, null, null, null)); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null)); Assert.Contains("appSecretKey", ex.Message); } @@ -86,7 +86,7 @@ public void Ctor_UpdateVersionsNullOrEmpty_ThrowsArgumentException(bool nullList var ex = Assert.Throws(() => new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - versions, "url", "backup", null, null, null, null, null, null, null)); + versions, "url", "backup", null, null, null, null, null, null, null, null)); Assert.Contains("Collection", ex.Message); } @@ -96,7 +96,7 @@ public void Ctor_ReportUrlNull_ThrowsArgumentNullException() var ex = Assert.Throws(() => new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, null, "backup", null, null, null, null, null, null, null)); + SingleVersion, null, "backup", null, null, null, null, null, null, null, null)); Assert.Contains("reportUrl", ex.Message); } @@ -106,7 +106,7 @@ public void Ctor_BackupDirectoryNull_ThrowsArgumentNullException() var ex = Assert.Throws(() => new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", null, null, null, null, null, null, null, null)); + SingleVersion, "url", null, null, null, null, null, null, null, null, null)); Assert.Contains("backupDirectory", ex.Message); } @@ -117,7 +117,7 @@ public void Ctor_AllParametersValid_AllPropertiesSet() "MyApp", ExistingDir, "1.0.0", "2.0.0", "https://log.example.com", Encoding.UTF8, ".zip", 60, "secret-key", SingleVersion, "https://report.example.com", "C:\\backup", - "BowlProcess", "https", "token-abc", "C:\\drivers", + "BowlProcess", "https", "token-abc", "C:\\drivers", "", new List { ".tmp" }, new List { "skip.dll" }, new List { "logs" }); Assert.Equal("MyApp", info.AppName); @@ -147,7 +147,7 @@ public void Ctor_EncodingUTF8_CompressEncodingWebNameIsUtf8() // Arrange & Act var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null); // Assert Assert.Equal("utf-8", info.CompressEncoding); @@ -159,7 +159,7 @@ public void Ctor_EncodingASCII_CompressEncodingWebNameIsAscii() // Arrange & Act var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.ASCII, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null); // Assert Assert.Equal("us-ascii", info.CompressEncoding); @@ -171,7 +171,7 @@ public void Ctor_EncodingUnicode_CompressEncodingWebNameIsUtf16() // Arrange & Act var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.Unicode, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null); // Assert Assert.Equal("utf-16", info.CompressEncoding); @@ -184,7 +184,7 @@ public void Ctor_NullableOptionalParams_AllowedAsNull() var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", SingleVersion, "url", "backup", - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null); // Assert Assert.Null(info.Bowl); @@ -224,7 +224,7 @@ public void Ctor_MultipleVersions_AllStored() // Act var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - versions, "url", "backup", null, null, null, null, null, null, null); + versions, "url", "backup", null, null, null, null, null, null, null, null); // Assert Assert.Equal(3, info.UpdateVersions.Count); @@ -239,7 +239,7 @@ public void Ctor_UpdateLogUrlNull_Allowed() // Arrange & Act var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, null, null, null); + SingleVersion, "url", "backup", null, null, null, null, null, null, null, null); // Assert — UpdateLogUrl is explicitly allowed to be null Assert.Null(info.UpdateLogUrl); @@ -256,7 +256,7 @@ public void Ctor_AllBlacklistParamsPopulated_PreservedInOrder() // Act var info = new ProcessInfo("app", ExistingDir, "1.0", "2.0", null, Encoding.UTF8, "ZIP", 30, "key", - SingleVersion, "url", "backup", null, null, null, null, + SingleVersion, "url", "backup", null, null, null, null, null, formats, files, dirs); // Assert diff --git a/tests/CoreTest/Configuration/UpdateOptionsStaticTests.cs b/tests/CoreTest/Configuration/UpdateOptionsStaticTests.cs index c3fd3105..b4dcc9fd 100644 --- a/tests/CoreTest/Configuration/UpdateOptionsStaticTests.cs +++ b/tests/CoreTest/Configuration/UpdateOptionsStaticTests.cs @@ -31,7 +31,7 @@ public void Encoding_HasCorrectDefault() [Fact] public void Format_HasCorrectDefault() { - Assert.Equal("ZIP", UpdateOptions.Format.DefaultValue); + Assert.Equal(Format.Zip, UpdateOptions.Format.DefaultValue); } [Fact] @@ -58,12 +58,6 @@ public void Silent_HasCorrectDefault() Assert.False(UpdateOptions.Silent.DefaultValue); } - [Fact] - public void SilentAutoInstall_HasCorrectDefault() - { - Assert.False(UpdateOptions.SilentAutoInstall.DefaultValue); - } - [Fact] public void SilentPollIntervalMinutes_HasCorrectDefault() { @@ -122,14 +116,4 @@ public void AllOptions_RepeatedAccess_ReturnsSameInstance() } #endregion - - #region Hub option - - [Fact] - public void Hub_NotSet_HasNullDefault() - { - Assert.Null(UpdateOptions.Hub.DefaultValue); - } - - #endregion } diff --git a/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs b/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs index 59a1f962..641c232c 100644 --- a/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs +++ b/tests/CoreTest/Download/DownloadOrchestratorOptionsTests.cs @@ -20,7 +20,7 @@ public void Defaults_AreAsSpecified() { var opts = new DownloadOrchestratorOptions(); - Assert.Equal(3, opts.MaxConcurrency); + Assert.Equal(2, opts.MaxConcurrency); Assert.True(opts.EnableResume); Assert.Equal(3, opts.RetryCount); Assert.Equal(TimeSpan.FromSeconds(1), opts.RetryInterval); @@ -105,7 +105,7 @@ public void From_DefaultsWhenConfigIsMinimal() var opts = DownloadOrchestratorOptions.From(config); - Assert.Equal(3, opts.MaxConcurrency); + Assert.Equal(2, opts.MaxConcurrency); Assert.True(opts.EnableResume); Assert.Equal(3, opts.RetryCount); } diff --git a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs index ac4e3f4e..39bde1ff 100644 --- a/tests/CoreTest/Download/DownloadPlanBuilderTests.cs +++ b/tests/CoreTest/Download/DownloadPlanBuilderTests.cs @@ -81,7 +81,7 @@ public void Build_NoAssetIsForcibly_IsForciblyFalse() } [Fact] - public void Build_CrossVersionMatch_ReturnsSingleAssetPlan() + public void Build_CrossVersionIncluded_ReturnsAllMatchingAssets() { var assets = new[] { @@ -90,8 +90,10 @@ public void Build_CrossVersionMatch_ReturnsSingleAssetPlan() }; var result = DownloadPlanBuilder.Build(assets, "1.0.0"); Assert.True(result.HasAssets); - Assert.Single(result.Assets); - Assert.Equal("5.0.0", result.Assets[0].Version); + Assert.Equal(3, result.Assets.Count); + Assert.Equal("2.0.0", result.Assets[0].Version); + Assert.Equal("3.0.0", result.Assets[1].Version); + Assert.Equal("5.0.0", result.Assets[2].Version); } [Fact] @@ -142,18 +144,4 @@ public void Build_MixedFrozenAndActive_FiltersFrozen() Assert.Single(result.Assets); Assert.Equal("2.0.0", result.Assets[0].Version); } - - [Fact] - public void MapToAsset_NullFields_HasSaneDefaults() - { - var packet = new GeneralUpdate.Core.Download.Abstractions.PacketDTO - { - Name = null, Url = null, Version = null, Hash = null - }; - var asset = DownloadPlanBuilder.MapToAsset(packet); - Assert.Equal("unknown", asset.Name); - Assert.Equal("", asset.Url); - Assert.Equal("0.0.0", asset.Version); - Assert.Equal(0, asset.Size); - } } diff --git a/tests/CoreTest/Download/PacketDTOTests.cs b/tests/CoreTest/Download/PacketDTOTests.cs deleted file mode 100644 index 7b1f6781..00000000 --- a/tests/CoreTest/Download/PacketDTOTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -namespace CoreTest.Download; - -using GeneralUpdate.Core.Download.Abstractions; - -/// -/// AAAT unit tests for and related DTO records. -/// Covers: default values, full assignment, nullable properties, VersionRequest, VersionResponse. -/// -public class PacketDTOTests -{ - #region PacketDTO - - [Fact] - public void PacketDTO_Default_AllNullablePropsAreNull() - { - var dto = new PacketDTO(); - - Assert.Null(dto.Name); - Assert.Null(dto.Hash); - Assert.Null(dto.ReleaseDate); - Assert.Null(dto.Url); - Assert.Null(dto.Version); - Assert.Null(dto.AppType); - Assert.Null(dto.Platform); - Assert.Null(dto.ProductId); - Assert.Null(dto.IsForcibly); - Assert.Null(dto.IsFreeze); - Assert.Null(dto.Format); - Assert.Null(dto.Size); - Assert.Null(dto.FromVersion); - Assert.Null(dto.IsCrossVersion); - Assert.Null(dto.MinClientVersion); - Assert.Null(dto.SourceArchiveHash); - Assert.Null(dto.TargetArchiveHash); - } - - [Fact] - public void PacketDTO_FullAssignment_AllPropsSet() - { - var dto = new PacketDTO - { - Name = "UpdatePack", - Hash = "hash123", - ReleaseDate = new DateTime(2025, 3, 15), - Url = "https://cdn.example.com/pack.zip", - Version = "2.0.0", - AppType = 1, - Platform = 0, - ProductId = "prod-1", - IsForcibly = true, - IsFreeze = false, - Format = ".zip", - Size = 2048, - FromVersion = "1.0.0", - IsCrossVersion = true, - MinClientVersion = "1.5.0", - SourceArchiveHash = "srcHash", - TargetArchiveHash = "tgtHash" - }; - - Assert.Equal("UpdatePack", dto.Name); - Assert.Equal("hash123", dto.Hash); - Assert.Equal(new DateTime(2025, 3, 15), dto.ReleaseDate); - Assert.Equal(".zip", dto.Format); - Assert.Equal(2048, dto.Size); - Assert.Equal("1.0.0", dto.FromVersion); - Assert.True(dto.IsCrossVersion); - Assert.Equal("1.5.0", dto.MinClientVersion); - Assert.Equal("srcHash", dto.SourceArchiveHash); - Assert.Equal("tgtHash", dto.TargetArchiveHash); - } - - [Fact] - public void PacketDTO_IsForcibly_NullableTriState() - { - var dto = new PacketDTO(); - Assert.Null(dto.IsForcibly); - - dto.IsForcibly = true; - Assert.True(dto.IsForcibly); - - dto.IsForcibly = null; - Assert.Null(dto.IsForcibly); - } - - [Fact] - public void PacketDTO_IsFreeze_NullableTriState() - { - var dto = new PacketDTO(); - Assert.Null(dto.IsFreeze); - - dto.IsFreeze = false; - Assert.False(dto.IsFreeze); - - dto.IsFreeze = null; - Assert.Null(dto.IsFreeze); - } - - #endregion - - #region VersionRequest - - [Fact] - public void VersionRequest_AllFieldsAssigned() - { - var req = new VersionRequest("MyApp", "1.0.0", "2.0.0", 1, "prod-001"); - - Assert.Equal("MyApp", req.AppName); - Assert.Equal("1.0.0", req.ClientVersion); - Assert.Equal("2.0.0", req.UpgradeClientVersion); - Assert.Equal(1, req.Platform); - Assert.Equal("prod-001", req.ProductId); - } - - [Fact] - public void VersionRequest_NullableFields_CanBeNull() - { - var req = new VersionRequest("App", "1.0", null, null, null); - - Assert.Null(req.UpgradeClientVersion); - Assert.Null(req.Platform); - Assert.Null(req.ProductId); - } - - #endregion - - #region VersionResponse - - [Fact] - public void VersionResponse_NoUpdate_EmptyPackets() - { - var resp = new VersionResponse(false, null); - - Assert.False(resp.HasUpdate); - Assert.Null(resp.Packets); - } - - [Fact] - public void VersionResponse_HasUpdate_WithPackets() - { - var packets = new List { new() { Name = "p1" }, new() { Name = "p2" } }; - var resp = new VersionResponse(true, packets); - - Assert.True(resp.HasUpdate); - Assert.Equal(2, resp.Packets!.Count); - } - - [Fact] - public void VersionResponse_HasUpdateTrue_ButNullPackets_Works() - { - var resp = new VersionResponse(true, null); - - Assert.True(resp.HasUpdate); - Assert.Null(resp.Packets); - } - - #endregion -} diff --git a/tests/CoreTest/FileSystem/BlackListDefaultsTests.cs b/tests/CoreTest/FileSystem/BlackListDefaultsTests.cs index 033e866e..01b4d712 100644 --- a/tests/CoreTest/FileSystem/BlackListDefaultsTests.cs +++ b/tests/CoreTest/FileSystem/BlackListDefaultsTests.cs @@ -38,7 +38,7 @@ public void DefaultBlackFormats_ContainsPatchPdbRarTarJsonZip() Assert.Contains(".rar", formats); Assert.Contains(".tar", formats); Assert.Contains(".json", formats); - Assert.Contains(Format.ZIP, formats); + Assert.Contains(Format.Zip.ToExtension(), formats); } [Fact] diff --git a/tests/CoreTest/Hooks/HooksIntegrationTests.cs b/tests/CoreTest/Hooks/HooksIntegrationTests.cs index 9de97b70..d587a2fa 100644 --- a/tests/CoreTest/Hooks/HooksIntegrationTests.cs +++ b/tests/CoreTest/Hooks/HooksIntegrationTests.cs @@ -66,7 +66,7 @@ public void UpdateContext_PropertiesSet() { var ctx = new UpdateContext("MyApp", "/opt/myapp", "1.0.0", "2.0.0", AppType.Client); - Assert.Equal("MyApp", ctx.AppName); + Assert.Equal("MyApp", ctx.UpdateAppName); Assert.Equal("/opt/myapp", ctx.InstallPath); Assert.Equal("1.0.0", ctx.CurrentVersion); Assert.Equal("2.0.0", ctx.TargetVersion); diff --git a/tests/CoreTest/Integration/OssIntegrationTests.cs b/tests/CoreTest/Integration/OssIntegrationTests.cs index 92e2560b..0962b2e8 100644 --- a/tests/CoreTest/Integration/OssIntegrationTests.cs +++ b/tests/CoreTest/Integration/OssIntegrationTests.cs @@ -78,7 +78,7 @@ public async Task OSSUpdateStrategy_WithoutConfig_ReturnsWithoutError() var strategy = new OSSUpdateStrategy(); var config = new GlobalConfigInfo { - AppName = "TestOSS", + UpdateAppName = "TestOSS", ClientVersion = "1.0.0", InstallPath = "/test/oss" }; diff --git a/tests/CoreTest/Ipc/IpcEncryptionTests.cs b/tests/CoreTest/Ipc/IpcEncryptionTests.cs index 1b7f51ff..edf4f446 100644 --- a/tests/CoreTest/Ipc/IpcEncryptionTests.cs +++ b/tests/CoreTest/Ipc/IpcEncryptionTests.cs @@ -80,7 +80,7 @@ public void SendAndReceive_RoundTrip_ProcessInfoPreserved() null, System.Text.Encoding.UTF8, ".zip", 30, "secret", new List { new() { Version = "2.0.0" } }, "https://report.example.com", "C:\\backup", - null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null); provider.Send(info); var received = provider.Receive(); diff --git a/tests/CoreTest/Ipc/ProcessInfoProviderTests.cs b/tests/CoreTest/Ipc/ProcessInfoProviderTests.cs index 09a743e6..9732baea 100644 --- a/tests/CoreTest/Ipc/ProcessInfoProviderTests.cs +++ b/tests/CoreTest/Ipc/ProcessInfoProviderTests.cs @@ -35,6 +35,7 @@ public async Task EncryptedFileProvider_SendReceive_RoundTrips() scheme: "", token: "", driverDirectory: "", + tempPath: "", blackFileFormats: new List { ".pdb" }, blackFiles: new List { "test.dll" }, skipDirectories: new List { "logs" } diff --git a/tests/CoreTest/Pipeline/CompressMiddlewareTests.cs b/tests/CoreTest/Pipeline/CompressMiddlewareTests.cs index 1c728edf..3a16217a 100644 --- a/tests/CoreTest/Pipeline/CompressMiddlewareTests.cs +++ b/tests/CoreTest/Pipeline/CompressMiddlewareTests.cs @@ -14,16 +14,15 @@ public class CompressMiddlewareTests { /// /// Tests that InvokeAsync requires necessary context values. + /// Missing required context keys cause the pipeline to fail. /// [Fact] public async Task InvokeAsync_WithMissingContextValues_ThrowsException() { - // Arrange var middleware = new CompressMiddleware(); var context = new PipelineContext(); - - // Act & Assert - await Assert.ThrowsAsync(() => middleware.InvokeAsync(context)); + + await Assert.ThrowsAsync(() => middleware.InvokeAsync(context)); } /// @@ -34,7 +33,7 @@ public void Context_CanStoreAndRetrieveValues() { // Arrange var context = new PipelineContext(); - var format = Format.ZIP; + var format = Format.Zip; var sourcePath = "/test/source.zip"; var patchPath = "/test/patch"; var encoding = Encoding.UTF8; @@ -50,7 +49,7 @@ public void Context_CanStoreAndRetrieveValues() context.Add("PatchEnabled", patchEnabled); // Assert - Assert.Equal(format, context.Get("Format")); + Assert.Equal(format, context.Get("Format")); Assert.Equal(sourcePath, context.Get("ZipFilePath")); Assert.Equal(patchPath, context.Get("PatchPath")); Assert.Equal(encoding, context.Get("Encoding")); diff --git a/tests/CoreTest/Shared/ConfiginfoBuilderTests.cs b/tests/CoreTest/Shared/ConfiginfoBuilderTests.cs index bc29ea32..b16a6551 100644 --- a/tests/CoreTest/Shared/ConfiginfoBuilderTests.cs +++ b/tests/CoreTest/Shared/ConfiginfoBuilderTests.cs @@ -28,7 +28,7 @@ private void CreateTestConfigFile() UpdateUrl = TestUpdateUrl, Token = TestToken, Scheme = TestScheme, - AppName = "Update.exe", + UpdateAppName = "Update.exe", MainAppName = "TestApp.exe", ClientVersion = "1.0.0", AppSecretKey = "test-secret-key", @@ -103,7 +103,7 @@ public void Create_ProducesConsistentResults() Assert.Equal(config1.UpdateUrl, config2.UpdateUrl); Assert.Equal(config1.Token, config2.Token); Assert.Equal(config1.Scheme, config2.Scheme); - Assert.Equal(config1.AppName, config2.AppName); + Assert.Equal(config1.UpdateAppName, config2.UpdateAppName); } finally { @@ -204,7 +204,7 @@ public void Build_WithMinimalParameters_ReturnsValidConfiginfo() Assert.Equal(TestUpdateUrl, config.UpdateUrl); Assert.Equal(TestToken, config.Token); Assert.Equal(TestScheme, config.Scheme); - Assert.NotNull(config.AppName); + Assert.NotNull(config.UpdateAppName); Assert.NotNull(config.MainAppName); Assert.NotNull(config.ClientVersion); Assert.NotNull(config.InstallPath); @@ -236,8 +236,8 @@ public void Build_GeneratesPlatformSpecificDefaults() // InstallPath should be the current application's base directory Assert.Equal(AppDomain.CurrentDomain.BaseDirectory, config.InstallPath); - // According to requirements, AppName default is "Update.exe" regardless of platform - Assert.Equal("Update.exe", config.AppName); + // According to requirements, UpdateAppName default is "Update.exe" regardless of platform + Assert.Equal("Update.exe", config.UpdateAppName); } finally { @@ -277,50 +277,50 @@ public void Build_InitializesCollectionProperties() #region Setter Method Tests /// - /// Tests that SetAppName correctly sets the application name. + /// Tests that SetUpgradeAppName correctly sets the application name. /// [Fact] - public void SetAppName_WithValidValue_SetsAppName() + public void SetUpgradeAppName_WithValidValue_SetsUpgradeAppName() { // Arrange var builder = CreateBuilderWithRequiredFields(); - var customAppName = "CustomApp.exe"; + var customUpgradeAppName = "CustomApp.exe"; // Act - var config = builder.SetAppName(customAppName).Build(); + var config = builder.SetUpgradeAppName(customUpgradeAppName).Build(); // Assert - Assert.Equal(customAppName, config.AppName); + Assert.Equal(customUpgradeAppName, config.UpdateAppName); } /// - /// Tests that SetAppName returns the builder for method chaining. + /// Tests that SetUpgradeAppName returns the builder for method chaining. /// [Fact] - public void SetAppName_ReturnsBuilder_ForMethodChaining() + public void SetUpgradeAppName_ReturnsBuilder_ForMethodChaining() { // Arrange var builder = CreateBuilderWithRequiredFields(); // Act - var result = builder.SetAppName("Test.exe"); + var result = builder.SetUpgradeAppName("Test.exe"); // Assert Assert.Same(builder, result); } /// - /// Tests that SetAppName throws ArgumentException when value is null. + /// Tests that SetUpgradeAppName throws ArgumentException when value is null. /// [Fact] - public void SetAppName_WithNullValue_ThrowsArgumentException() + public void SetUpgradeAppName_WithNullValue_ThrowsArgumentException() { // Arrange var builder = CreateBuilderWithRequiredFields(); // Act & Assert - var exception = Assert.Throws(() => builder.SetAppName(null)); - Assert.Contains("AppName", exception.Message); + var exception = Assert.Throws(() => builder.SetUpgradeAppName(null)); + Assert.Contains("UpdateAppName", exception.Message); } /// @@ -575,7 +575,7 @@ public void BuilderPattern_SupportsMethodChaining() // Arrange & Act CreateTestConfigFile(); var config = ConfiginfoBuilder.Create() - .SetAppName("CustomApp.exe") + .SetUpgradeAppName("CustomApp.exe") .SetMainAppName("MainCustomApp.exe") .SetClientVersion("2.0.0") .SetInstallPath("/custom/path") @@ -583,7 +583,7 @@ public void BuilderPattern_SupportsMethodChaining() .Build(); // Assert - Assert.Equal("CustomApp.exe", config.AppName); + Assert.Equal("CustomApp.exe", config.UpdateAppName); Assert.Equal("MainCustomApp.exe", config.MainAppName); Assert.Equal("2.0.0", config.ClientVersion); Assert.Equal("/custom/path", config.InstallPath); @@ -618,8 +618,8 @@ public void Build_OnWindows_GeneratesWindowsDefaults() var config = builder.Build(); // Assert - // According to requirements, AppName default is "Update.exe" regardless of platform - Assert.Equal("Update.exe", config.AppName); + // According to requirements, UpdateAppName default is "Update.exe" regardless of platform + Assert.Equal("Update.exe", config.UpdateAppName); // Should use the current application's base directory Assert.Equal(AppDomain.CurrentDomain.BaseDirectory, config.InstallPath); } @@ -643,8 +643,8 @@ public void Build_OnLinux_GeneratesLinuxDefaults() var config = builder.Build(); // Assert - // According to requirements, AppName default is "Update.exe" regardless of platform - Assert.Equal("Update.exe", config.AppName); + // According to requirements, UpdateAppName default is "Update.exe" regardless of platform + Assert.Equal("Update.exe", config.UpdateAppName); // Should use the current application's base directory Assert.Equal(AppDomain.CurrentDomain.BaseDirectory, config.InstallPath); } @@ -668,8 +668,8 @@ public void Build_OnMacOS_GeneratesMacOSDefaults() var config = builder.Build(); // Assert - // According to requirements, AppName default is "Update.exe" regardless of platform - Assert.Equal("Update.exe", config.AppName); + // According to requirements, UpdateAppName default is "Update.exe" regardless of platform + Assert.Equal("Update.exe", config.UpdateAppName); // Should use the current application's base directory Assert.Equal(AppDomain.CurrentDomain.BaseDirectory, config.InstallPath); } @@ -700,7 +700,7 @@ public void Build_ReturnsConfiginfoThatPassesValidation() /// and gracefully falls back to defaults if not found. /// [Fact] - public void Build_AttemptsToExtractAppNameFromProject() + public void Build_AttemptsToExtractUpgradeAppNameFromProject() { // Arrange var builder = CreateBuilderWithRequiredFields(); @@ -708,16 +708,16 @@ public void Build_AttemptsToExtractAppNameFromProject() // Act var config = builder.Build(); - // Assert - AppName should be set (either from project or fallback) - Assert.NotNull(config.AppName); - Assert.NotEmpty(config.AppName); + // Assert - UpdateAppName should be set (either from project or fallback) + Assert.NotNull(config.UpdateAppName); + Assert.NotEmpty(config.UpdateAppName); Assert.NotNull(config.MainAppName); Assert.NotEmpty(config.MainAppName); // On Windows, should have .exe extension if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.EndsWith(".exe", config.AppName); + Assert.EndsWith(".exe", config.UpdateAppName); } } @@ -758,7 +758,7 @@ public void CompleteScenario_BuildsValidConfiginfo() UpdateUrl = "https://api.example.com/updates", Token = "Bearer abc123xyz", Scheme = "https", - AppName = "MyApplication.exe", + UpgradeAppName = "MyApplication.exe", MainAppName = "MyApplication.exe", ClientVersion = "1.5.2", UpgradeClientVersion = "1.0.0", @@ -780,7 +780,7 @@ public void CompleteScenario_BuildsValidConfiginfo() Assert.Equal("https://api.example.com/updates", config.UpdateUrl); Assert.Equal("Bearer abc123xyz", config.Token); Assert.Equal("https", config.Scheme); - Assert.Equal("MyApplication.exe", config.AppName); + Assert.Equal("MyApplication.exe", config.UpdateAppName); Assert.Equal("1.5.2", config.ClientVersion); Assert.Equal("/opt/myapp", config.InstallPath); @@ -810,7 +810,7 @@ public void Create_WithConfigFile_LoadsFromFile() UpdateUrl = "https://config-file.example.com/updates", Token = "config-file-token", Scheme = "https", - AppName = "ConfigFileApp.exe", + UpgradeAppName = "ConfigFileApp.exe", MainAppName = "ConfigFileMain.exe", ClientVersion = "9.9.9", AppSecretKey = "config-file-secret", @@ -829,7 +829,7 @@ public void Create_WithConfigFile_LoadsFromFile() Assert.Equal("https://config-file.example.com/updates", config.UpdateUrl); Assert.Equal("config-file-token", config.Token); Assert.Equal("https", config.Scheme); - Assert.Equal("ConfigFileApp.exe", config.AppName); + Assert.Equal("ConfigFileApp.exe", config.UpdateAppName); Assert.Equal("ConfigFileMain.exe", config.MainAppName); Assert.Equal("9.9.9", config.ClientVersion); Assert.Equal("/config/file/path", config.InstallPath); @@ -866,7 +866,7 @@ public void Create_WithoutConfigFile_UsesParameters() Assert.Equal(TestUpdateUrl, config.UpdateUrl); Assert.Equal(TestToken, config.Token); Assert.Equal(TestScheme, config.Scheme); - Assert.Equal("Update.exe", config.AppName); // Default value + Assert.Equal("Update.exe", config.UpdateAppName); // Default value } finally { diff --git a/tests/CoreTest/Silent/SilentPollOrchestratorTests.cs b/tests/CoreTest/Silent/SilentPollOrchestratorTests.cs index 4ccd2ca8..3af2fefa 100644 --- a/tests/CoreTest/Silent/SilentPollOrchestratorTests.cs +++ b/tests/CoreTest/Silent/SilentPollOrchestratorTests.cs @@ -12,7 +12,7 @@ private GlobalConfigInfo CreateValidConfig() UpdateUrl = "https://api.example.com/update", ClientVersion = "1.0.0", AppSecretKey = "secret", - AppName = "Update.exe", + UpdateAppName = "Update.exe", MainAppName = "MainApp", InstallPath = Path.GetTempPath() }; @@ -97,24 +97,10 @@ public void SilentOptions_DefaultPollInterval_IsOneHour() Assert.Equal(TimeSpan.FromHours(1), options.PollInterval); } - [Fact] - public void SilentOptions_AutoInstall_DefaultFalse() - { - var options = new SilentOptions(); - Assert.False(options.AutoInstall); - } - [Fact] public void SilentOptions_CustomPollInterval_Stored() { var options = new SilentOptions { PollInterval = TimeSpan.FromMinutes(30) }; Assert.Equal(TimeSpan.FromMinutes(30), options.PollInterval); } - - [Fact] - public void SilentOptions_CustomAutoInstall_Stored() - { - var options = new SilentOptions { AutoInstall = true }; - Assert.True(options.AutoInstall); - } } diff --git a/tests/CoreTest/Strategy/StrategyCreationTests.cs b/tests/CoreTest/Strategy/StrategyCreationTests.cs index 3c0a4b46..74ba0b08 100644 --- a/tests/CoreTest/Strategy/StrategyCreationTests.cs +++ b/tests/CoreTest/Strategy/StrategyCreationTests.cs @@ -14,7 +14,7 @@ public void ClientUpdateStrategy_Create_ResolvesOsStrategy() ClientVersion = "1.0.0", InstallPath = Path.GetTempPath(), AppSecretKey = "key", - AppName = "app", + UpdateAppName = "app", MainAppName = "main" }; var ex = Record.Exception(() => strategy.Create(config)); @@ -59,7 +59,7 @@ public void UpgradeUpdateStrategy_Create_ResolvesOsStrategy() ClientVersion = "1.0.0", InstallPath = Path.GetTempPath(), AppSecretKey = "key", - AppName = "Upgrade", + UpdateAppName = "Upgrade", MainAppName = "MainApp" }; var ex = Record.Exception(() => strategy.Create(config)); @@ -84,7 +84,7 @@ public async Task UpgradeUpdateStrategy_ExecuteAsync_NotConfigured_Throws() public void OSSUpdateStrategy_Create_ClientRole_Succeeds() { var strategy = new OSSUpdateStrategy(AppType.OSSClient); - var config = new GlobalConfigInfo { ClientVersion = "1.0.0", AppName = "app" }; + var config = new GlobalConfigInfo { ClientVersion = "1.0.0", UpdateAppName = "app" }; var ex = Record.Exception(() => strategy.Create(config)); Assert.Null(ex); } @@ -93,7 +93,7 @@ public void OSSUpdateStrategy_Create_ClientRole_Succeeds() public void OSSUpdateStrategy_Create_UpgradeRole_Succeeds() { var strategy = new OSSUpdateStrategy(AppType.OSSUpgrade); - var config = new GlobalConfigInfo { ClientVersion = "1.0.0", AppName = "app" }; + var config = new GlobalConfigInfo { ClientVersion = "1.0.0", UpdateAppName = "app" }; var ex = Record.Exception(() => strategy.Create(config)); Assert.Null(ex); } @@ -109,20 +109,20 @@ public void OSSUpdateStrategy_Create_NullConfig_Throws() public void OSSUpdateStrategy_DefaultConstructor_UsesOSSClientRole() { var strategy = new OSSUpdateStrategy(); - var config = new GlobalConfigInfo { ClientVersion = "1.0.0", AppName = "app" }; + var config = new GlobalConfigInfo { ClientVersion = "1.0.0", UpdateAppName = "app" }; var ex = Record.Exception(() => strategy.Create(config)); Assert.Null(ex); } [Fact] - public async Task OSSUpdateStrategy_StartApp_NullAppName_ReturnsWithoutException() + public async Task OSSUpdateStrategy_StartApp_NullUpgradeAppName_ReturnsWithoutException() { var strategy = new OSSUpdateStrategy(); strategy.Create(new GeneralUpdate.Core.Configuration.GlobalConfigInfo { ClientVersion = "1.0.0", MainAppName = null, - AppName = null + UpdateAppName = null }); var ex = await Record.ExceptionAsync(() => strategy.StartAppAsync()); Assert.Null(ex); diff --git a/tests/UpgradeTest/Program.cs b/tests/UpgradeTest/Program.cs new file mode 100644 index 00000000..48ff4fff --- /dev/null +++ b/tests/UpgradeTest/Program.cs @@ -0,0 +1,92 @@ +using GeneralUpdate.Core; +using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Download; +using GeneralUpdate.Core.Event; +using GeneralUpdate.Core.Hooks; + +try +{ + Console.WriteLine("=== GeneralUpdate Upgrade Test ==="); + Console.WriteLine($"Started at {DateTime.Now}"); + Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}"); + + await new GeneralUpdateBootstrap() + .Option(UpdateOptions.AppType, AppType.Upgrade) + .Hooks() + .AddListenerMultiDownloadStatistics(OnDownloadStatistics) + .AddListenerMultiDownloadCompleted(OnDownloadCompleted) + .AddListenerMultiAllDownloadCompleted(OnAllDownloadCompleted) + .AddListenerMultiDownloadError(OnDownloadError) + .AddListenerException(OnException) + .LaunchAsync(); + + Console.WriteLine("Upgrade test completed."); +} +catch (Exception ex) +{ + Console.WriteLine($"FATAL: {ex}"); + Environment.Exit(1); +} + +static void OnDownloadStatistics(object sender, MultiDownloadStatisticsEventArgs e) +{ + var v = e.Version as VersionInfo; + Console.WriteLine($"[Apply] {v?.Version}: {e.ProgressPercentage}%"); +} + +static void OnDownloadCompleted(object sender, MultiDownloadCompletedEventArgs e) +{ + var v = e.Version as VersionInfo; + Console.WriteLine($"[Apply] {v?.Version}: {(e.IsCompleted ? "SUCCESS" : "FAILED")}"); +} + +static void OnAllDownloadCompleted(object sender, MultiAllDownloadCompletedEventArgs e) +{ + Console.WriteLine(e.IsAllDownloadCompleted + ? "[Apply] All patches applied." + : $"[Apply] Patches finished with {e.FailedVersions.Count} failure(s)."); +} + +static void OnDownloadError(object sender, MultiDownloadErrorEventArgs e) +{ + var v = e.Version as VersionInfo; + Console.WriteLine($"[Apply] Error @ {v?.Version}: {e.Exception.Message}"); +} + +static void OnException(object sender, ExceptionEventArgs e) +{ + Console.WriteLine($"[Error] {e.Exception}"); +} + +sealed class UpgradeTestHooks : IUpdateHooks +{ + public async Task OnBeforeUpdateAsync(UpdateContext ctx) + { + Console.WriteLine($"[Hook] OnBeforeUpdate: {ctx.CurrentVersion} -> {ctx.TargetVersion}"); + return await Task.FromResult(true); + } + + public async Task OnDownloadCompletedAsync(DownloadContext ctx) + { + Console.WriteLine($"[Hook] OnDownloadCompleted: {ctx.AssetName} v{ctx.Version} ({ctx.TotalBytes} bytes, {ctx.Duration}) {(ctx.Success ? "OK" : "FAIL")}"); + await Task.CompletedTask; + } + + public async Task OnAfterUpdateAsync(UpdateContext ctx) + { + Console.WriteLine($"[Hook] OnAfterUpdate: {ctx.CurrentVersion} -> {ctx.TargetVersion}"); + await Task.CompletedTask; + } + + public async Task OnUpdateErrorAsync(UpdateContext ctx, Exception ex) + { + Console.WriteLine($"[Hook] OnUpdateError: {ctx.CurrentVersion} -> {ctx.TargetVersion} | {ex.Message}"); + await Task.CompletedTask; + } + + public async Task OnBeforeStartAppAsync(UpdateContext ctx) + { + Console.WriteLine($"[Hook] OnBeforeStartApp: {ctx.UpdateAppName} @ {ctx.InstallPath}"); + await Task.CompletedTask; + } +} diff --git a/tests/UpgradeTest/UpgradeTest.csproj b/tests/UpgradeTest/UpgradeTest.csproj new file mode 100644 index 00000000..191c3d61 --- /dev/null +++ b/tests/UpgradeTest/UpgradeTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net10.0 + enable + enable + + + + + + +