diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs index afb23486..140b4ec7 100644 --- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -96,8 +96,7 @@ private async Task LaunchWithStrategy(IStrategy roleStra // Resolve DownloadSource from extension registry (Hub, custom, etc.) var resolvedSource = ResolveExtension(); - // Inject SignalR Hub download source if configured (not available in AOT) -#if !AOT + // Inject SignalR Hub download source if configured if (resolvedSource == null) { var hubConfig = GetOption(UpdateOptions.Hub); @@ -110,7 +109,6 @@ private async Task LaunchWithStrategy(IStrategy roleStra GeneralTracer.Info("GeneralUpdateBootstrap: HubDownloadSource started from HubConfig."); } } -#endif clientStrat.DownloadSource = resolvedSource; if (_updatePrecheck != null) clientStrat.UseUpdatePrecheck(_updatePrecheck); @@ -128,6 +126,14 @@ private async Task LaunchWithStrategy(IStrategy roleStra } roleStrategy.Create(_configInfo); + + // Check custom skip condition before executing update + if (_customSkipOption?.Invoke() == true) + { + GeneralTracer.Info("GeneralUpdateBootstrap: update skipped by custom skip option."); + return this; + } + await roleStrategy.ExecuteAsync(); } catch (Exception ex) @@ -316,11 +322,9 @@ private void ApplyRuntimeOptions() /// Silent update mode — starts a background poll loop and returns immediately. /// The orchestrator checks for updates periodically and prepares them. /// When the host process exits, the prepared update is applied. - /// Not available in AOT builds (SignalR dependency). /// private async Task LaunchSilentAsync() { -#if !AOT GeneralTracer.Info("GeneralUpdateBootstrap: starting silent update mode."); var pollMinutes = GetOption(UpdateOptions.SilentPollIntervalMinutes); @@ -341,10 +345,6 @@ private async Task LaunchSilentAsync() await orchestrator.StartAsync().ConfigureAwait(false); GeneralTracer.Info("GeneralUpdateBootstrap: silent update mode started, returning to caller."); -#else - GeneralTracer.Warn("GeneralUpdateBootstrap: silent update not available in AOT builds."); - await Task.CompletedTask; -#endif } private void InitBlackList() @@ -396,12 +396,6 @@ private static bool IsOssUpgrade(string clientVersion, string serverVersion) // Strategy & Events // ════════════════════════════════════════════════════════════════ - protected override GeneralUpdateBootstrap StrategyFactory() - => throw new NotImplementedException("Role strategies handle this."); - - protected override Task ExecuteStrategyAsync() => throw new NotImplementedException(); - protected override void ExecuteStrategy() => throw new NotImplementedException(); - private GeneralUpdateBootstrap AddListener(Action action) where TArgs : EventArgs { if (action is null) throw new ArgumentNullException(nameof(action)); diff --git a/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs b/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs index 3c69dd41..b975ea0d 100644 --- a/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs +++ b/src/c#/GeneralUpdate.Core/Compress/CompressProvider.cs @@ -6,20 +6,18 @@ namespace GeneralUpdate.Core.Compress; public class CompressProvider { - private static ICompressionStrategy _compressionStrategy; - private CompressProvider() { } - public static void Compress(string compressType,string sourcePath, string destinationPath, bool includeRootDirectory, Encoding encoding) + public static void Compress(string compressType, string sourcePath, string destinationPath, bool includeRootDirectory, Encoding encoding) { - _compressionStrategy = GetCompressionStrategy(compressType); - _compressionStrategy.Compress(sourcePath, destinationPath, includeRootDirectory, encoding); + var strategy = GetCompressionStrategy(compressType); + strategy.Compress(sourcePath, destinationPath, includeRootDirectory, encoding); } public static void Decompress(string compressType, string archivePath, string destinationPath, Encoding encoding) { - _compressionStrategy = GetCompressionStrategy(compressType); - _compressionStrategy.Decompress(archivePath, destinationPath, encoding); + var strategy = GetCompressionStrategy(compressType); + strategy.Decompress(archivePath, destinationPath, encoding); } private static ICompressionStrategy GetCompressionStrategy(string compressType) => compressType switch diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs index d202dffa..d8c8b7f8 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs @@ -26,9 +26,6 @@ protected internal AbstractBootstrap() } public abstract Task LaunchAsync(); - protected abstract void ExecuteStrategy(); - protected abstract Task ExecuteStrategyAsync(); - protected abstract TBootstrap StrategyFactory(); public TBootstrap Option(UpdateOption option, T value) { diff --git a/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs b/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs index 6bb4d194..ab072d20 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ConfiginfoBuilder.cs @@ -29,7 +29,6 @@ public class ConfiginfoBuilder private string _updateLogUrl; private string _reportUrl; private string _bowl; - private string _script; private string _driverDirectory; private List _blackFiles; private List _blackFormats; @@ -313,14 +312,6 @@ public ConfiginfoBuilder SetBowl(string bowl) /// /// Sets the shell script content. /// - /// Shell script content used to grant file permissions on Linux/Unix systems. - /// The current ConfiginfoBuilder instance for method chaining. - public ConfiginfoBuilder SetScript(string script) - { - _script = script; - return this; - } - /// /// Sets the driver directory. /// diff --git a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs index e1401a38..c17de889 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/GlobalConfigInfo.cs @@ -99,8 +99,6 @@ public class GlobalConfigInfo : BaseConfigInfo /// Directory path containing driver files for update. /// Used when DriveEnabled is true to locate driver files for installation. /// - public string DriverDirectory { get; set; } - /// /// Indicates whether differential patch update is enabled. /// Computed from UpdateOption.Patch or defaults to true. diff --git a/src/c#/GeneralUpdate.Core/Configuration/ObjectTranslator.cs b/src/c#/GeneralUpdate.Core/Configuration/ObjectTranslator.cs index 460e0d60..41ee32f4 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ObjectTranslator.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ObjectTranslator.cs @@ -2,6 +2,10 @@ namespace GeneralUpdate.Core.Configuration; public sealed class ObjectTranslator { - public static string GetPacketHash(object version) => - !GeneralTracer.IsTracingEnabled() ? string.Empty : $"[PacketHash]:{(version as VersionInfo).Hash} "; + public static string GetPacketHash(object version) + { + if (!GeneralTracer.IsTracingEnabled()) return string.Empty; + if (version is VersionInfo vi) return $"[PacketHash]:{vi.Hash} "; + return string.Empty; + } } \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs b/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs index b4e8474f..376b8438 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ProcessInfo.cs @@ -42,7 +42,6 @@ public ProcessInfo() { } /// The process name to terminate before updating /// The URL scheme for update requests /// The authentication token - /// The Linux permission script /// The directory path containing driver files /// List of file format extensions to skip /// List of specific files to skip diff --git a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs index 70aa25ed..a1c92cda 100644 --- a/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs +++ b/src/c#/GeneralUpdate.Core/Download/Executors/HttpDownloadExecutor.cs @@ -11,7 +11,6 @@ namespace GeneralUpdate.Core.Download.Executors; /// /// HTTP-based download executor with optional Range/resume support. -/// Uses the shared HttpClient from VersionService for consistent SSL/auth handling. /// public class HttpDownloadExecutor : IDownloadExecutor { @@ -33,7 +32,6 @@ public async Task ExecuteAsync( { var sw = Stopwatch.StartNew(); int retries = 0; - long totalBytes = -1; long existingBytes = 0; // Check for existing partial file (resume support; skip when disabled) @@ -45,8 +43,6 @@ public async Task ExecuteAsync( try { using var request = new HttpRequestMessage(HttpMethod.Get, url); - - // Request resume from existing position (skip when resume is disabled) if (_enableResume && existingBytes > 0) request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(existingBytes, null); @@ -57,7 +53,6 @@ public async Task ExecuteAsync( request, HttpCompletionOption.ResponseHeadersRead, cts.Token) .ConfigureAwait(false); - // If server doesn't support Range, discard partial file if (_enableResume && existingBytes > 0 && response.StatusCode != System.Net.HttpStatusCode.PartialContent) { existingBytes = 0; @@ -65,43 +60,21 @@ public async Task ExecuteAsync( } response.EnsureSuccessStatusCode(); - totalBytes = response.Content.Headers.ContentLength ?? -1; + var totalBytes = response.Content.Headers.ContentLength ?? -1; - // Append or create file var mode = existingBytes > 0 ? FileMode.Append : FileMode.Create; using var fs = new FileStream(destPath, mode, FileAccess.Write, FileShare.None); using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var buffer = new byte[8192]; - long downloaded = existingBytes; - int read; - long lastReport = 0; - - while ((read = await stream.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false)) > 0) - { - await fs.WriteAsync(buffer, 0, read, token).ConfigureAwait(false); - downloaded += read; - - // Report progress every ~250ms - var now = sw.ElapsedMilliseconds; - if (now - lastReport >= 250 || downloaded == totalBytes + existingBytes) - { - lastReport = now; - var pct = totalBytes > 0 ? (double)downloaded / (totalBytes + existingBytes) * 100 : -1; - progress?.Report(new DownloadProgress( - Path.GetFileName(destPath), downloaded, - totalBytes > 0 ? totalBytes + existingBytes : null, - pct, DownloadStatus.Downloading)); - } - } + var (downloaded, elapsed) = await StreamDownloadAsync(stream, fs, totalBytes, existingBytes, + destPath, progress, sw, token).ConfigureAwait(false); - sw.Stop(); progress?.Report(new DownloadProgress( Path.GetFileName(destPath), downloaded, totalBytes > 0 ? totalBytes + existingBytes : null, 100, DownloadStatus.Completed)); - return new DownloadResult(url, destPath, downloaded, sw.Elapsed, retries, true, null); + return new DownloadResult(url, destPath, downloaded, elapsed, retries, true, null); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -109,4 +82,38 @@ public async Task ExecuteAsync( return new DownloadResult(url, null, existingBytes, sw.Elapsed, retries, false, ex.Message); } } + + /// + /// Shared download loop: reads from source stream, writes to dest, reports progress. + /// Used by both HTTP and OSS executors to avoid duplicated buffer/read/write/progress logic. + /// + internal static async Task<(long Downloaded, TimeSpan Elapsed)> StreamDownloadAsync( + Stream source, Stream dest, long totalBytes, long existingBytes, + string destPath, IProgress? progress, Stopwatch sw, CancellationToken token) + { + var buffer = new byte[8192]; + long downloaded = existingBytes; + long lastReport = 0; + int read; + + while ((read = await source.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false)) > 0) + { + await dest.WriteAsync(buffer, 0, read, token).ConfigureAwait(false); + downloaded += read; + + var now = sw.ElapsedMilliseconds; + if (now - lastReport >= 250 || downloaded == totalBytes + existingBytes) + { + lastReport = now; + var pct = totalBytes > 0 ? (double)downloaded / (totalBytes + existingBytes) * 100 : -1; + progress?.Report(new DownloadProgress( + Path.GetFileName(destPath), downloaded, + totalBytes > 0 ? totalBytes + existingBytes : null, + pct, DownloadStatus.Downloading)); + } + } + + sw.Stop(); + return (downloaded, sw.Elapsed); + } } diff --git a/src/c#/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs b/src/c#/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs index 8119e84d..5a4b565a 100644 --- a/src/c#/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs +++ b/src/c#/GeneralUpdate.Core/Download/Executors/OssDownloadExecutor.cs @@ -22,7 +22,6 @@ public async Task ExecuteAsync( CancellationToken token = default) { var sw = System.Diagnostics.Stopwatch.StartNew(); - long lastReport = 0; try { using var response = await _client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token) @@ -32,25 +31,13 @@ public async Task ExecuteAsync( Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); using var fs = new FileStream(destPath, FileMode.Create); using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var buffer = new byte[8192]; - long downloaded = 0; - int read; - while ((read = await stream.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false)) > 0) - { - await fs.WriteAsync(buffer, 0, read, token).ConfigureAwait(false); - downloaded += read; - var now = sw.ElapsedMilliseconds; - if (now - lastReport >= 250 || downloaded == total) - { - lastReport = now; - var pct = total > 0 ? (double)downloaded / total * 100 : -1; - progress?.Report(new DownloadProgress( - Path.GetFileName(destPath), downloaded, total > 0 ? total : null, pct, DownloadStatus.Downloading)); - } - } - sw.Stop(); - progress?.Report(new DownloadProgress(Path.GetFileName(destPath), downloaded, total > 0 ? total : null, 100, DownloadStatus.Completed)); - return new DownloadResult(url, destPath, downloaded, sw.Elapsed, 0, true, null); + + var (downloaded, elapsed) = await HttpDownloadExecutor.StreamDownloadAsync( + stream, fs, total, 0, destPath, progress, sw, token).ConfigureAwait(false); + + progress?.Report(new DownloadProgress( + Path.GetFileName(destPath), downloaded, total > 0 ? total : null, 100, DownloadStatus.Completed)); + return new DownloadResult(url, destPath, downloaded, elapsed, 0, true, null); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/src/c#/GeneralUpdate.Core/Download/MultiEventArgs/MutiDownloadCompletedEventArgs.cs b/src/c#/GeneralUpdate.Core/Download/MultiEventArgs/MutiDownloadCompletedEventArgs.cs index 04fa3700..97fdb7cb 100644 --- a/src/c#/GeneralUpdate.Core/Download/MultiEventArgs/MutiDownloadCompletedEventArgs.cs +++ b/src/c#/GeneralUpdate.Core/Download/MultiEventArgs/MutiDownloadCompletedEventArgs.cs @@ -2,10 +2,10 @@ namespace GeneralUpdate.Core.Download { - public class MultiDownloadCompletedEventArgs(object version, bool isComplated) : EventArgs + public class MultiDownloadCompletedEventArgs(object version, bool isCompleted) : EventArgs { public object Version { get; private set; } = version; - public bool IsComplated { get; private set; } = isComplated; + public bool IsCompleted { get; private set; } = isCompleted; } } \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs index 492f666f..48834363 100644 --- a/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Download/Orchestrators/DefaultDownloadOrchestrator.cs @@ -12,6 +12,7 @@ using GeneralUpdate.Core.Download.Policy; using GeneralUpdate.Core.Download.Models; using GeneralUpdate.Core.Download.Pipeline; +using GeneralUpdate.Core.Download.Progress; namespace GeneralUpdate.Core.Download.Orchestrators; @@ -119,6 +120,14 @@ public async Task ExecuteAsync( await Task.WhenAll(tasks).ConfigureAwait(false); sw.Stop(); + // Dispatch all-completed event ONCE after all assets finish (only failed results) + var failedDetails = results.Where(r => !r.Success) + .Select(r => ((object)r.Url, r.ErrorMessage ?? "failed")).ToList(); + DownloadProgressReporter.DispatchAllCompleted( + this, + results.All(r => r.Success), + failedDetails); + return new DownloadReport( results, totalBytes, diff --git a/src/c#/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs b/src/c#/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs index 557ece23..f6c5f422 100644 --- a/src/c#/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs +++ b/src/c#/GeneralUpdate.Core/Download/Progress/DownloadProgressReporter.cs @@ -11,16 +11,13 @@ public class DownloadProgressReporter : IProgress { private readonly Action? _onProgress; private readonly Action? _onCompleted; - private readonly Action? _onAllCompleted; public DownloadProgressReporter( Action? onProgress = null, - Action? onCompleted = null, - Action? onAllCompleted = null) + Action? onCompleted = null) { _onProgress = onProgress; _onCompleted = onCompleted; - _onAllCompleted = onAllCompleted; } public void Report(Models.DownloadProgress value) @@ -42,13 +39,16 @@ public void Report(Models.DownloadProgress value) EventManager.Instance.Dispatch(this, new MultiDownloadErrorEventArgs(new Exception("Download failed"), value.AssetName ?? "unknown")); } + } - if (value.Percentage >= 100) - { - _onAllCompleted?.Invoke(); - EventManager.Instance.Dispatch(this, - new MultiAllDownloadCompletedEventArgs(true, new List<(object, string)>())); - } + /// + /// Fires the all-completed event. Should be called once after all downloads finish, + /// not per-asset. Called from the download orchestrator. + /// + public static void DispatchAllCompleted(object sender, bool success, List<(object, string)> details) + { + EventManager.Instance.Dispatch(sender, + new MultiAllDownloadCompletedEventArgs(success, details ?? new List<(object, string)>())); } /// diff --git a/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs index dc0e3a16..1c7ebf63 100644 --- a/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs +++ b/src/c#/GeneralUpdate.Core/Download/Sources/HubDownloadSource.cs @@ -51,7 +51,7 @@ private void OnReceiveMessage(string json) { try { - var packet = System.Text.Json.JsonSerializer.Deserialize(json); + var packet = System.Text.Json.JsonSerializer.Deserialize(json, HttpParameterJsonContext.Default.PacketDTO); if (packet != null) { var asset = DownloadPlanBuilder.MapToAsset(packet); diff --git a/src/c#/GeneralUpdate.Core/Event/IEventManager.cs b/src/c#/GeneralUpdate.Core/Event/IEventManager.cs deleted file mode 100644 index d9eafd70..00000000 --- a/src/c#/GeneralUpdate.Core/Event/IEventManager.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; - -namespace GeneralUpdate.Core.Event -{ - /// - /// Event manager interface. - /// - public interface IEventManager - { - /// - /// Adding Event Listeners. - /// - /// Generic delegate. - /// New delegate that needs to be injected. - void AddListener(TDelegate newDelegate) where TDelegate : Delegate; - - /// - /// Removing Event Listening. - /// - /// Generic delegate. - /// Need to remove an existing delegate. - void RemoveListener(TDelegate oldDelegate) where TDelegate : Delegate; - - /// - /// Triggers notifications of the same event type based on the listening event type. - /// - /// generic delegate. - /// Event handler. - /// Event args. - void Dispatch(object sender, EventArgs eventArgs) where TDelegate : Delegate; - - /// - /// Remove all injected delegates. - /// - void Clear(); - } -} \ No newline at end of file diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileNode.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileNode.cs index 80a86060..1b67ce99 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/FileNode.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/FileNode.cs @@ -71,19 +71,6 @@ public void Add(FileNode node) } } - public void InfixOrder() - { - if (Left != null) - { - Left.InfixOrder(); - } - - if (Right != null) - { - Right.InfixOrder(); - } - } - public FileNode Search(long id) { if (id == Id) @@ -144,7 +131,7 @@ public override bool Equals(object obj) string.Equals(Name, tempNode.Name, StringComparison.OrdinalIgnoreCase); } - public override int GetHashCode() => base.GetHashCode(); + public override int GetHashCode() => (Name != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Name) : 0) ^ (Hash != null ? StringComparer.OrdinalIgnoreCase.GetHashCode(Hash) : 0); #endregion Public Methods } diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTree.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTree.cs index 73d00253..611486a5 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/FileTree.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/FileTree.cs @@ -40,18 +40,6 @@ public void Add(FileNode node) } } - public void InfixOrder() - { - if (_root != null) - { - _root.InfixOrder(); - } - else - { - GeneralTracer.Info("The binary sort tree is empty and cannot be traversed"); - } - } - public FileNode Search(long id) => _root == null ? null : _root.Search(id); public FileNode SearchParent(long id) => _root == null ? null : _root.SearchParent(id); diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeEnumerator.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeEnumerator.cs deleted file mode 100644 index f215a50a..00000000 --- a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeEnumerator.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Security.Cryptography; -using GeneralUpdate.Core.FileSystem; - -namespace GeneralUpdate.Core.FileSystem.FileTreeCore; - -/// Recursively enumerates files with blacklist filtering and SHA256 hashing. -public class FileTreeEnumerator -{ - private readonly IBlackListMatcher? _blacklist; - - public FileTreeEnumerator(IBlackListMatcher? blacklist = null) - => _blacklist = blacklist; - - public IEnumerable Enumerate(string rootPath) - { - if (!Directory.Exists(rootPath)) - yield break; - - foreach (var file in Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories)) - { - if (_blacklist != null) - { - var relativePath = GetRelativePath(rootPath, file); - if (_blacklist.IsBlacklisted(relativePath)) - continue; - var dir = Path.GetDirectoryName(relativePath); - if (dir != null && _blacklist.ShouldSkipDirectory(dir)) - continue; - } - - var fi = new FileInfo(file); - var hash = ComputeSha256(file); - var relative = GetRelativePath(rootPath, file); - yield return new FileEntry(relative, fi.Length, hash, fi.LastWriteTimeUtc); - } - } - - private static string ComputeSha256(string path) - { - try - { - using var sha = SHA256.Create(); - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var h = sha.ComputeHash(fs); - return BitConverter.ToString(h).Replace("-", "").ToLowerInvariant(); - } - catch { return string.Empty; } - } - - private static string GetRelativePath(string root, string fullPath) - { - if (!root.EndsWith(Path.DirectorySeparatorChar.ToString())) - root += Path.DirectorySeparatorChar; - return fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase) - ? fullPath.Substring(root.Length) - : fullPath; - } -} - -/// File tree snapshot for backup and comparison. -public record FileTreeSnapshot( - string RootPath, - IReadOnlyDictionary Files, - DateTime CapturedAt -); - -/// Individual file entry with hash. -public record FileEntry(string RelativePath, long SizeBytes, string SHA256, DateTime LastWriteUtc); - -/// Compares two file tree snapshots using SHA256 for content comparison. -public class FileTreeComparer -{ - public FileTreeDiff Compare(FileTreeSnapshot oldSnap, FileTreeSnapshot newSnap) - { - var added = new List(); - var modified = new List(); - var deleted = new List(); - - foreach (var kv in newSnap.Files) - { - var path = kv.Key; - var newEntry = kv.Value; - if (oldSnap.Files.TryGetValue(path, out var oldEntry)) - { - if (oldEntry.SHA256 != newEntry.SHA256) - modified.Add(path); - } - else - added.Add(path); - } - - foreach (var path in oldSnap.Files.Keys) - if (!newSnap.Files.ContainsKey(path)) - deleted.Add(path); - - return new FileTreeDiff(added, modified, deleted); - } -} - -public record FileTreeDiff( - IReadOnlyList Added, - IReadOnlyList Modified, - IReadOnlyList Deleted -); diff --git a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs index 5b18badb..ae45afd0 100644 --- a/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs +++ b/src/c#/GeneralUpdate.Core/FileSystem/StorageManager.cs @@ -153,7 +153,7 @@ private static List GetAllfiles(string path) } catch (Exception) { - return null; + return new List(); } } diff --git a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj index 5ab6a93e..cf93fe4e 100644 --- a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj +++ b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj @@ -14,8 +14,7 @@ netstandard2.0;net8.0;net10.0 true true - - $(DefineConstants);AOT + @@ -23,24 +22,21 @@ - + - + - + - - - - + diff --git a/src/c#/GeneralUpdate.Core/Hubs/UpgradeHubService.cs b/src/c#/GeneralUpdate.Core/Hubs/UpgradeHubService.cs index 3996e137..c7181b34 100644 --- a/src/c#/GeneralUpdate.Core/Hubs/UpgradeHubService.cs +++ b/src/c#/GeneralUpdate.Core/Hubs/UpgradeHubService.cs @@ -55,16 +55,9 @@ public void AddListenerClosed(Func closeCallback) public async Task StartAsync() { - try - { - GeneralTracer.Info($"UpgradeHubService.StartAsync: connecting to SignalR hub. State={_connection?.State}"); - await _connection!.StartAsync(); - GeneralTracer.Info($"UpgradeHubService.StartAsync: SignalR hub connection established. State={_connection?.State}"); - } - catch (Exception e) - { - GeneralTracer.Error("The StartAsync method in the UpgradeHubService class throws an exception." , e); - } + GeneralTracer.Info($"UpgradeHubService.StartAsync: connecting to SignalR hub. State={_connection?.State}"); + await _connection!.StartAsync(); + GeneralTracer.Info($"UpgradeHubService.StartAsync: SignalR hub connection established. State={_connection?.State}"); } public async Task StopAsync() diff --git a/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs b/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs index 9e19e68f..4e8083e7 100644 --- a/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs +++ b/src/c#/GeneralUpdate.Core/JsonContext/HttpParameterJsonContext.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using GeneralUpdate.Core.Download.Abstractions; namespace GeneralUpdate.Core.JsonContext; @@ -9,4 +10,6 @@ namespace GeneralUpdate.Core.JsonContext; [JsonSerializable(typeof(int?))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(PacketDTO))] +[JsonSerializable(typeof(List))] 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 5d605b34..acae4f09 100644 --- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs +++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs @@ -44,8 +44,6 @@ public VersionService(IHttpAuthProvider? auth = null, TimeSpan? timeout = null, _timeout = timeout ?? TimeSpan.FromSeconds(30); _maxRetries = maxRetries; } - private VersionService() { } - // Static API (backward-compatible, CancellationToken optional) public static Task Report(string url, int recordId, int status, int? type, string scheme = null, string token = null, CancellationToken ct = default) diff --git a/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs b/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs index 2668bac8..5b991d48 100644 --- a/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs +++ b/src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs @@ -35,7 +35,7 @@ public class SilentPollOrchestrator : IDisposable private int _prepared; private int _updaterStarted; private IUpdateHooks? _hooks; - private Download.Reporting.IUpdateReporter? _reporter; + private IUpdateReporter? _reporter; public SilentPollOrchestrator(GlobalConfigInfo configInfo, SilentOptions options) { @@ -43,9 +43,9 @@ public SilentPollOrchestrator(GlobalConfigInfo configInfo, SilentOptions options _options = options ?? throw new ArgumentNullException(nameof(options)); } - /// Inject hooks and reporter. + /// Inject hooks and reporter for lifecycle callbacks during silent polling. public SilentPollOrchestrator WithHooks(IUpdateHooks? hooks) { _hooks = hooks; return this; } - public SilentPollOrchestrator WithReporter(Download.Reporting.IUpdateReporter? reporter) { _reporter = reporter; return this; } + public SilentPollOrchestrator WithReporter(IUpdateReporter? reporter) { _reporter = reporter; return this; } /// Start background polling loop. public Task StartAsync() @@ -124,6 +124,27 @@ private async Task PrepareUpdateIfNeededAsync(CancellationToken token) return; } + // ═══ Hooks: allow cancellation before starting update ═══ + var updateCtx = new UpdateContext( + _configInfo.MainAppName ?? _configInfo.AppName, + _configInfo.InstallPath, + _configInfo.ClientVersion, + latestVersion, + AppType.Client); + + if (_hooks != null) + { + try + { + if (!await _hooks.OnBeforeUpdateAsync(updateCtx).ConfigureAwait(false)) + { + GeneralTracer.Info("SilentPollOrchestrator: update cancelled by hooks."); + return; + } + } + catch (Exception ex) { GeneralTracer.Warn($"Hook OnBeforeUpdateAsync failed: {ex.Message}"); } + } + // Configure for update BlackListManager.Instance?.AddBlackFiles(_configInfo.BlackFiles); BlackListManager.Instance?.AddBlackFormats(_configInfo.BlackFormats); @@ -147,24 +168,141 @@ private async Task PrepareUpdateIfNeededAsync(CancellationToken token) BlackListManager.Instance.SkipDirectorys.ToList()); _configInfo.ProcessInfo = JsonSerializer.Serialize(processInfo, ProcessInfoJsonContext.Default.ProcessInfo); + // ═══ Reporter: update started ═══ + var startTime = DateTimeOffset.UtcNow; + if (_reporter != null) + { + try + { + await _reporter.ReportAsync(new UpdateReport( + updateCtx.AppName, updateCtx.CurrentVersion, updateCtx.TargetVersion, + UpdateEvent.UpdateStarted, AppType.Client, startTime), token).ConfigureAwait(false); + } + catch (Exception ex) { GeneralTracer.Warn($"Reporter UpdateStarted failed: {ex.Message}"); } + } + // Download using new orchestrator GeneralTracer.Info($"SilentPollOrchestrator: downloading {plan.Assets.Count} asset(s)."); var httpClient = new System.Net.Http.HttpClient(); + 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); - GeneralTracer.Info($"SilentPollOrchestrator: download complete. Success={report.SuccessCount}, Failed={report.FailedCount}"); + downloadSuccessCount = report.SuccessCount; + downloadFailedCount = report.FailedCount; + downloadTotalBytes = report.TotalBytes; + downloadElapsed = report.TotalDuration; + GeneralTracer.Info($"SilentPollOrchestrator: download complete. Success={downloadSuccessCount}, Failed={downloadFailedCount}"); + + // ═══ Hooks + Reporter: download completed ═══ + if (_hooks != null) + { + try + { + var downloadCtx = new DownloadContext( + plan.Assets.FirstOrDefault()?.Name ?? "update", latestVersion ?? "", + downloadTotalBytes, downloadElapsed, + _configInfo.TempPath, downloadFailedCount == 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); + } + 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) + { + 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}"); } + } + return; } finally { httpClient.Dispose(); } // Execute pipeline - var strategy = CreateStrategy(); - strategy.Create(_configInfo); - await strategy.ExecuteAsync(); + try + { + var strategy = CreateStrategy(); + strategy.Create(_configInfo); + await strategy.ExecuteAsync(); - GeneralTracer.Info("SilentPollOrchestrator: update prepared."); - Interlocked.Exchange(ref _prepared, 1); + GeneralTracer.Info("SilentPollOrchestrator: update prepared."); + Interlocked.Exchange(ref _prepared, 1); + + // ═══ 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}"); } + } + } + catch (Exception ex) + { + 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}"); } + } + } } private void OnProcessExit(object? sender, EventArgs e) diff --git a/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs index 6daebbdc..6cdeb140 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/AbstractStrategy.cs @@ -7,6 +7,8 @@ using GeneralUpdate.Core; using GeneralUpdate.Core.Configuration; using GeneralUpdate.Core.Network; +using GeneralUpdate.Core.Hooks; +using IUpdateReporter = GeneralUpdate.Core.Download.Reporting.IUpdateReporter; namespace GeneralUpdate.Core.Strategy { @@ -14,6 +16,12 @@ 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; } + + /// Optional reporter for update status reporting. + protected IUpdateReporter? Reporter { get; set; } public virtual void Execute() => throw new NotImplementedException(); @@ -121,6 +129,12 @@ protected static string CheckPath(string path, string name) return File.Exists(tempPath) ? tempPath : string.Empty; } + // ═══ 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. + // The Hooks and Reporter properties are declared here so subclasses inherit them + // without redeclaring. + private static void Clear(string path) { if (Directory.Exists(path)) diff --git a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs index 41a5a6ac..09f305c1 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/ClientUpdateStrategy.cs @@ -31,8 +31,6 @@ public class ClientUpdateStrategy : IStrategy private IStrategy? _osStrategy; private Func? _updatePrecheck; private readonly Download.Abstractions.IDownloadOrchestrator? _orchestrator; - private readonly DiffMode _diffMode = DiffMode.Serial; - /// Lifecycle hooks injected by the bootstrap. public Hooks.IUpdateHooks Hooks { get; set; } = new Hooks.NoOpUpdateHooks(); /// Update status reporter injected by the bootstrap. diff --git a/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs index 2b67ba58..11b998bc 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/MacStrategy.cs @@ -11,26 +11,7 @@ namespace GeneralUpdate.Core.Strategy; /// macOS update strategy — follows Linux conventions. public class MacStrategy : AbstractStrategy { - public override void Execute() - { - GeneralTracer.Info("MacStrategy: executing macOS update"); - try - { - if (!string.IsNullOrEmpty(_configinfo.MainAppName)) - { - var mainApp = Path.Combine( - _configinfo.InstallPath ?? string.Empty, - _configinfo.MainAppName); - if (File.Exists(mainApp)) - StartApp(); - } - } - catch (Exception ex) - { - GeneralTracer.Error("MacStrategy.Execute failed", ex); - EventManager.Instance.Dispatch(this, new ExceptionEventArgs(ex, ex.Message)); - } - } + public override void Execute() => ExecuteAsync().GetAwaiter().GetResult(); public override async Task ExecuteAsync() { diff --git a/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs index a416c58a..8612ed3a 100644 --- a/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs +++ b/src/c#/GeneralUpdate.Core/Strategy/WindowsStrategy.cs @@ -13,6 +13,10 @@ namespace GeneralUpdate.Core.Strategy /// public class WindowsStrategy : AbstractStrategy { + public override void Execute() + { + ExecuteAsync().GetAwaiter().GetResult(); + } protected override PipelineContext CreatePipelineContext(VersionInfo version, string patchPath) { GeneralTracer.Info($"GeneralUpdate.Core.WindowsStrategy.CreatePipelineContext: building context for version={version.Version}, patchPath={patchPath}"); diff --git a/tests/CoreTest/Event/EventListenerTests.cs b/tests/CoreTest/Event/EventListenerTests.cs index 73b86495..0f32f51c 100644 --- a/tests/CoreTest/Event/EventListenerTests.cs +++ b/tests/CoreTest/Event/EventListenerTests.cs @@ -105,7 +105,7 @@ public void EventArgs_Types_Constructed() var downloadDone = new MultiDownloadCompletedEventArgs(new object(), true); Assert.NotNull(downloadDone.Version); - Assert.True(downloadDone.IsComplated); + Assert.True(downloadDone.IsCompleted); var ex = new Exception("boom"); var err = new MultiDownloadErrorEventArgs(ex, new object());