diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs
index 6cead03f..d202dffa 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs
@@ -97,6 +97,18 @@ public TBootstrap ConfigureBlackList(BlackListConfig config)
return (TBootstrap)this;
}
+ ///
+ /// Configure blacklist via fluent builder action.
+ /// Usage: .ConfigureBlackList(cfg => cfg.AddBlackFiles("*.log").AddBlackFormats(".pdb"))
+ ///
+ public TBootstrap ConfigureBlackList(Action configure)
+ {
+ var builder = new FileSystem.BlackListConfigBuilder();
+ configure(builder);
+ _instances[typeof(BlackListConfig)] = builder.Build();
+ return (TBootstrap)this;
+ }
+
protected TExtension? ResolveExtension() where TExtension : class
{
if (_extensions.TryGetValue(typeof(TExtension), out var t))
diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs
index 117c7e09..0e88aa80 100644
--- a/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs
+++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs
@@ -54,5 +54,23 @@ public static class UpdateOptions
// ═══ Blacklist ═══
public static UpdateOption BlackList { get; } = UpdateOption.ValueOf("BLACKLIST", BlackListConfig.Empty);
+
+ // ═══ Watchdog ═══
+ /// Bowl (crash monitor / watchdog) executable path.
+ public static UpdateOption Bowl { get; } = UpdateOption.ValueOf("BOWL", null);
+
+ // ═══ Logging & Script ═══
+ /// Remote update log / changelog URL.
+ public static UpdateOption UpdateLogUrl { get; } = UpdateOption.ValueOf("UPDATELOGURL", null);
+ /// Custom execution script path for pre/post-update actions.
+ public static UpdateOption Script { get; } = UpdateOption.ValueOf("SCRIPT", null);
+
+ // ═══ Retry ═══
+ /// Initial retry interval for exponential backoff. 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/Download/Sources/OssDownloadSource.cs b/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs
new file mode 100644
index 00000000..7c177322
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using GeneralUpdate.Core.Configuration;
+using GeneralUpdate.Core.Download.Abstractions;
+using GeneralUpdate.Core.Download.Models;
+using GeneralUpdate.Core.JsonContext;
+
+namespace GeneralUpdate.Core.Download.Sources;
+
+///
+/// OSS (Object Storage Service) download source.
+/// Downloads the version configuration JSON from a remote URL,
+/// parses it, and returns a list of for the orchestrator.
+///
+///
+/// Supports AliYun, AWS S3, MinIO, and Tencent COS via signed URLs.
+/// The version JSON format uses records.
+///
+public class OssDownloadSource : IDownloadSource
+{
+ private readonly HttpClient _httpClient;
+ private readonly string _versionJsonUrl;
+ private readonly TimeSpan _timeout;
+
+ public OssDownloadSource(HttpClient httpClient, string versionJsonUrl, TimeSpan? timeout = null)
+ {
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+ _versionJsonUrl = versionJsonUrl ?? throw new ArgumentNullException(nameof(versionJsonUrl));
+ _timeout = timeout ?? TimeSpan.FromSeconds(60);
+ }
+
+ ///
+ public async Task> ListAsync(CancellationToken token = default)
+ {
+ // Download and parse the version JSON from OSS
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
+ cts.CancelAfter(_timeout);
+
+ var response = await _httpClient.GetAsync(_versionJsonUrl, HttpCompletionOption.ResponseContentRead, cts.Token)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ var versions = System.Text.Json.JsonSerializer.Deserialize(json, VersionOSSJsonContext.Default.ListVersionOSS);
+ if (versions == null || versions.Count == 0)
+ return Array.Empty();
+
+ // Convert VersionOSS to DownloadAsset, ordered by publish time
+ return versions
+ .OrderBy(v => v.PubTime)
+ .Select(v =>
+ {
+ 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";
+
+ return new DownloadAsset(
+ Name: zipName,
+ Url: v.Url,
+ Size: 0,
+ SHA256: v.Hash,
+ Version: v.Version ?? "0.0.0"
+ );
+ })
+ .ToList()
+ .AsReadOnly();
+ }
+}
diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeComparer.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeComparer.cs
new file mode 100644
index 00000000..515c02b4
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeComparer.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GeneralUpdate.Core.FileSystem;
+
+///
+/// Result of comparing two file tree snapshots.
+///
+public readonly record struct FileTreeDiff(
+ IReadOnlyList Added,
+ IReadOnlyList Modified,
+ IReadOnlyList Deleted
+)
+{
+ public bool HasChanges => Added.Count > 0 || Modified.Count > 0 || Deleted.Count > 0;
+ public int TotalChanges => Added.Count + Modified.Count + Deleted.Count;
+
+ public static FileTreeDiff Empty { get; } = new(
+ Array.Empty(), Array.Empty(), Array.Empty());
+}
+
+///
+/// Compares two instances and produces a .
+/// Identifies added, modified, and deleted files between old and new state.
+///
+public static class FileTreeComparer
+{
+ ///
+ /// Compare two snapshots. is the baseline, is the new state.
+ ///
+ public static FileTreeDiff Compare(FileTreeSnapshot old, FileTreeSnapshot updated)
+ {
+ if (old == null) throw new ArgumentNullException(nameof(old));
+ if (updated == null) throw new ArgumentNullException(nameof(updated));
+
+ var oldMap = old.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
+ var newMap = updated.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
+
+ var added = new List();
+ var modified = new List();
+ var deleted = new List();
+
+ // Files present in updated but not in old → Added
+ // Files present in updated and old with different size or time → Modified
+ foreach (var kv in newMap)
+ {
+ var path = kv.Key;
+ var entry = kv.Value;
+ if (!oldMap.TryGetValue(path, out var oldEntry))
+ {
+ added.Add(entry);
+ }
+ else if (oldEntry.Size != entry.Size || oldEntry.LastWriteTimeUtc != entry.LastWriteTimeUtc)
+ {
+ modified.Add(entry);
+ }
+ }
+
+ // Files present in old but not in updated → Deleted
+ foreach (var path in oldMap.Keys)
+ {
+ if (!newMap.ContainsKey(path))
+ deleted.Add(path);
+ }
+
+ return new FileTreeDiff(added.AsReadOnly(), modified.AsReadOnly(), deleted.AsReadOnly());
+ }
+
+ ///
+ /// Quick check: compare two snapshots and return true if any files changed.
+ /// Short-circuits on first difference.
+ ///
+ public static bool HasChanges(FileTreeSnapshot old, FileTreeSnapshot updated)
+ {
+ if (old.Entries.Count != updated.Entries.Count) return true;
+
+ var oldMap = old.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
+ var newDict = updated.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var kv in newDict)
+ {
+ if (!oldMap.TryGetValue(kv.Key, out var oldEntry)) return true;
+ if (oldEntry.Size != kv.Value.Size || oldEntry.LastWriteTimeUtc != kv.Value.LastWriteTimeUtc) return true;
+ }
+ return false;
+ }
+}
diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeDiffer.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeDiffer.cs
new file mode 100644
index 00000000..f43292f4
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeDiffer.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+namespace GeneralUpdate.Core.FileSystem;
+
+///
+/// Applies a to produce delta file bundles
+/// for incremental/differential updates. Used by the Pipeline's PatchMiddleware.
+///
+public static class FileTreeDiffer
+{
+ ///
+ /// Produce delta file pairs for the given diff between old and updated snapshots.
+ /// Returns pairs of (sourcePath, relativePath) for files that need to be patched.
+ ///
+ public static IReadOnlyList<(string SourcePath, string RelativePath)> ProduceDeltaPaths(
+ FileTreeDiff diff, string updatedRoot)
+ {
+ var result = new List<(string, string)>();
+
+ // Added files — can be bundled directly
+ foreach (var entry in diff.Added)
+ {
+ var sourcePath = Path.Combine(updatedRoot, entry.RelativePath);
+ if (File.Exists(sourcePath))
+ result.Add((sourcePath, entry.RelativePath));
+ }
+
+ // Modified files — need patching
+ foreach (var entry in diff.Modified)
+ {
+ var sourcePath = Path.Combine(updatedRoot, entry.RelativePath);
+ if (File.Exists(sourcePath))
+ result.Add((sourcePath, entry.RelativePath));
+ }
+
+ // Deleted files — skipped (handled by cleanup separately)
+
+ return result.AsReadOnly();
+ }
+
+ ///
+ /// Produce the list of relative paths that should be deleted based on diff.
+ ///
+ public static IReadOnlyList ProduceDeletes(FileTreeDiff diff)
+ => diff.Deleted;
+
+ ///
+ /// Determine the optimal update mode: incremental (delta) if small diff, full if large.
+ /// Returns true if delta patching is recommended.
+ ///
+ public static bool ShouldUseDeltaPatching(FileTreeDiff diff, int totalFileCount, double thresholdPercent = 0.5)
+ {
+ if (totalFileCount == 0) return false;
+ var changeRatio = (double)diff.TotalChanges / totalFileCount;
+ return changeRatio <= thresholdPercent;
+ }
+}
diff --git a/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeSnapshot.cs b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeSnapshot.cs
new file mode 100644
index 00000000..0bfa8d00
--- /dev/null
+++ b/src/c#/GeneralUpdate.Core/FileSystem/FileTreeCore/FileTreeSnapshot.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GeneralUpdate.Core.FileSystem;
+
+///
+/// Immutable snapshot of a file entry in a directory tree.
+/// Captures path, size, and modification timestamp for comparison.
+///
+public readonly record struct FileEntry(
+ string RelativePath,
+ long Size,
+ DateTime LastWriteTimeUtc
+);
+
+///
+/// Immutable snapshot of a directory tree at a point in time.
+/// Created by + .
+///
+public sealed class FileTreeSnapshot
+{
+ public DateTime CreatedAt { get; } = DateTime.UtcNow;
+ public string RootPath { get; }
+ public IReadOnlyList Entries { get; }
+
+ public FileTreeSnapshot(string rootPath, IEnumerable entries)
+ {
+ RootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
+ Entries = (entries ?? Array.Empty()).ToList();
+ }
+
+ public static FileTreeSnapshot FromEnumerator(string rootPath, FileTreeEnumerator enumerator)
+ {
+ var entries = new List();
+ var normalizedRoot = rootPath.EndsWith(System.IO.Path.DirectorySeparatorChar.ToString())
+ ? rootPath
+ : rootPath + System.IO.Path.DirectorySeparatorChar;
+
+ foreach (var filePath in enumerator.EnumerateFiles(rootPath))
+ {
+ var fi = new System.IO.FileInfo(filePath);
+ // Manual relative path (netstandard2.0 compatible)
+ var relative = filePath.StartsWith(normalizedRoot)
+ ? filePath.Substring(normalizedRoot.Length)
+ : filePath;
+ entries.Add(new FileEntry(relative, fi.Length, fi.LastWriteTimeUtc));
+ }
+ return new FileTreeSnapshot(rootPath, entries);
+ }
+
+ public static FileTreeSnapshot Empty(string rootPath) => new(rootPath, Array.Empty());
+}
diff --git a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
index 61fa96bc..bf803b58 100644
--- a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
+++ b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
@@ -14,6 +14,8 @@
netstandard2.0;net8.0;net10.0
true
true
+
+ $(DefineConstants);AOT
@@ -21,23 +23,27 @@
-
+
-
+
-
+
-
-
+
+
+
+
+
+
diff --git a/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs b/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs
index 676dfbd5..58b0f136 100644
--- a/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs
+++ b/src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs
@@ -1,11 +1,13 @@
using System;
using System.IO;
+using System.IO.MemoryMappedFiles;
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using GeneralUpdate.Core;
using GeneralUpdate.Core.Configuration;
using GeneralUpdate.Core.JsonContext;
@@ -94,3 +96,113 @@ public Task SendAsync(ProcessInfo info, CancellationToken token = default)
finally { try { File.Delete(_filePath); } catch { } }
}
}
+
+/// Shared memory fallback IPC (Linux-friendly, no file residue).
+public class SharedMemoryProcessInfoProvider : IProcessInfoProvider
+{
+ private readonly string _mapName;
+ private const int MaxPayload = 4096;
+
+ public SharedMemoryProcessInfoProvider(string mapName = "GeneralUpdate.IPC.Shm")
+ => _mapName = mapName;
+
+ public Task SendAsync(ProcessInfo info, CancellationToken token = default)
+ {
+ var json = JsonSerializer.Serialize(info, ProcessInfoJsonContext.Default.ProcessInfo);
+ var bytes = Encoding.UTF8.GetBytes(json);
+ if (bytes.Length > MaxPayload - 4)
+ throw new InvalidOperationException($"ProcessInfo payload exceeds {MaxPayload - 4} bytes.");
+
+ using var mmf = MemoryMappedFile.CreateNew(_mapName, MaxPayload);
+ using var accessor = mmf.CreateViewAccessor(0, MaxPayload);
+ accessor.Write(0, bytes.Length);
+ accessor.WriteArray(4, bytes, 0, bytes.Length);
+ return Task.CompletedTask;
+ }
+
+ public Task ReceiveAsync(CancellationToken token = default)
+ {
+ try
+ {
+ using var mmf = MemoryMappedFile.OpenExisting(_mapName);
+ using var accessor = mmf.CreateViewAccessor(0, MaxPayload);
+ int length = accessor.ReadInt32(0);
+ if (length <= 0 || length > MaxPayload - 4)
+ return Task.FromResult(null);
+ var bytes = new byte[length];
+ accessor.ReadArray(4, bytes, 0, length);
+ var json = Encoding.UTF8.GetString(bytes);
+ return Task.FromResult(
+ JsonSerializer.Deserialize(json, ProcessInfoJsonContext.Default.ProcessInfo));
+ }
+ catch (FileNotFoundException)
+ {
+ return Task.FromResult(null);
+ }
+ }
+}
+
+///
+/// Auto-fallback IPC provider. Tries providers in order:
+/// NamedPipe → SharedMemory → EncryptedFile.
+/// On send, uses the first provider that succeeds.
+/// On receive, waits for data from the most reliable available provider.
+///
+public class AutoProcessInfoProvider : IProcessInfoProvider
+{
+ private readonly IProcessInfoProvider[] _providers;
+
+ public AutoProcessInfoProvider()
+ {
+ _providers = new IProcessInfoProvider[]
+ {
+ new NamedPipeProcessInfoProvider(),
+ new SharedMemoryProcessInfoProvider(),
+ new EncryptedFileProcessInfoProvider()
+ };
+ }
+
+ public AutoProcessInfoProvider(params IProcessInfoProvider[] providers)
+ => _providers = providers;
+
+ public async Task SendAsync(ProcessInfo info, CancellationToken token = default)
+ {
+ Exception? last = null;
+ foreach (var provider in _providers)
+ {
+ try
+ {
+ await provider.SendAsync(info, token).ConfigureAwait(false);
+ GeneralTracer.Debug($"AutoProcessInfoProvider: sent via {provider.GetType().Name}.");
+ return;
+ }
+ catch (Exception ex)
+ {
+ GeneralTracer.Warn($"AutoProcessInfoProvider: {provider.GetType().Name} failed: {ex.Message}");
+ last = ex;
+ }
+ }
+ throw new InvalidOperationException("All IPC providers failed to send.", last);
+ }
+
+ public async Task ReceiveAsync(CancellationToken token = default)
+ {
+ foreach (var provider in _providers)
+ {
+ try
+ {
+ var result = await provider.ReceiveAsync(token).ConfigureAwait(false);
+ if (result != null)
+ {
+ GeneralTracer.Debug($"AutoProcessInfoProvider: received via {provider.GetType().Name}.");
+ return result;
+ }
+ }
+ catch (Exception ex)
+ {
+ GeneralTracer.Warn($"AutoProcessInfoProvider: {provider.GetType().Name} receive failed: {ex.Message}");
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs b/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs
index 34fd2cd6..11c89de9 100644
--- a/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs
+++ b/src/c#/GeneralUpdate.Core/Strategy/OSSUpdateStrategy.cs
@@ -3,12 +3,15 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
+using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using GeneralUpdate.Core.Compress;
-using GeneralUpdate.Core.FileSystem;
using GeneralUpdate.Core.Download;
-using GeneralUpdate.Core.JsonContext;
+using GeneralUpdate.Core.Download.Abstractions;
+using GeneralUpdate.Core.Download.Models;
+using GeneralUpdate.Core.Download.Orchestrators;
+using GeneralUpdate.Core.Download.Sources;
using GeneralUpdate.Core.Configuration;
namespace GeneralUpdate.Core.Strategy;
@@ -32,6 +35,10 @@ public class OSSUpdateStrategy : IStrategy
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();
+ /// Download source for OSS version listing. Override via .DownloadSource<OssDownloadSource>().
+ public IDownloadSource? DownloadSource { get; set; }
+ /// Download orchestrator. Override via .DownloadOrchestrator<T>().
+ public IDownloadOrchestrator? DownloadOrchestrator { get; set; }
public void Create(GlobalConfigInfo parameter)
{
@@ -48,18 +55,10 @@ public async Task ExecuteAsync()
{
var versionFileName = $"{_configInfo.MainAppName ?? _configInfo.AppName}_versions.json";
- GeneralTracer.Debug("OSSUpdateStrategy: 1. Reading version configuration file.");
+ GeneralTracer.Debug("OSSUpdateStrategy: 1. Reading version configuration.");
var jsonPath = Path.Combine(_appPath, versionFileName);
- if (!File.Exists(jsonPath))
- throw new FileNotFoundException(jsonPath);
-
- GeneralTracer.Debug("OSSUpdateStrategy: 2. Parsing version configuration.");
- var versions = StorageManager.GetJson>(jsonPath,
- VersionOSSJsonContext.Default.ListVersionOSS);
- if (versions == null || versions.Count == 0)
- throw new InvalidOperationException("No versions found in OSS configuration.");
-
- versions = versions.OrderBy(v => v.PubTime).ToList();
+ if (!File.Exists(jsonPath) && DownloadSource == null)
+ throw new FileNotFoundException($"Version config not found: {jsonPath}");
// Hooks: allow cancellation before download
if (!await SafeOnBeforeUpdateAsync(ctx).ConfigureAwait(false))
@@ -71,11 +70,44 @@ public async Task ExecuteAsync()
// Report: update started
await SafeReportUpdateStartedAsync(ctx).ConfigureAwait(false);
- GeneralTracer.Debug($"OSSUpdateStrategy: 3. Downloading {versions.Count} version(s).");
- await DownloadVersionsAsync(versions);
+ List assets;
+ if (DownloadSource != null)
+ {
+ GeneralTracer.Debug("OSSUpdateStrategy: 2. Using injected IDownloadSource.");
+ var sourceAssets = await DownloadSource.ListAsync().ConfigureAwait(false);
+ assets = sourceAssets.ToList();
+ }
+ else
+ {
+ GeneralTracer.Debug("OSSUpdateStrategy: 2. Parsing version configuration from local JSON.");
+ var versions = System.Text.Json.JsonSerializer.Deserialize(
+ File.ReadAllText(jsonPath),
+ JsonContext.VersionOSSJsonContext.Default.ListVersionOSS);
+ if (versions == null || versions.Count == 0)
+ throw new InvalidOperationException("No versions found in OSS configuration.");
+
+ assets = versions.OrderBy(v => v.PubTime).Select(v =>
+ {
+ 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";
+ return new Download.Models.DownloadAsset(
+ Name: zipName, Url: v.Url, Size: 0,
+ SHA256: v.Hash, Version: v.Version ?? "0.0.0");
+ }).ToList();
+ }
+
+ if (assets.Count == 0)
+ throw new InvalidOperationException("No assets to download.");
+
+ GeneralTracer.Debug($"OSSUpdateStrategy: 3. Downloading {assets.Count} asset(s).");
+ await DownloadAssetsAsync(assets);
GeneralTracer.Debug("OSSUpdateStrategy: 4. Decompressing packages.");
- Decompress(versions);
+ DecompressAssets(assets);
// Report: update applied
await SafeReportUpdateAppliedAsync(ctx).ConfigureAwait(false);
@@ -115,48 +147,28 @@ public void StartApp()
#region Helpers
- private async Task DownloadVersionsAsync(List versions)
+ private async Task DownloadAssetsAsync(List assets)
{
- var assets = versions.Select(v =>
+ var plan = new DownloadPlan(assets, false);
+
+ if (DownloadOrchestrator != null)
{
- if (string.IsNullOrWhiteSpace(v.Url))
- throw new InvalidOperationException(
- $"OSS version '{v.PacketName ?? v.Version}' has no download URL.");
-
- // Use PacketName.zip as the filename to match Decompress() expectations.
- // The orchestrator falls back to {Name}.{Version} when URL-based extraction fails,
- // so we set Version to "zip" to produce e.g. "myapp.zip.zip".
- // Better: set Name to the zip filename directly.
- var zipName = $"{v.PacketName ?? v.Version}zip";
- if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
- zipName += ".zip";
-
- return new Download.Models.DownloadAsset(
- Name: zipName,
- Url: v.Url,
- Size: 0,
- SHA256: v.Hash,
- Version: v.Version ?? "0.0.0"
- );
- }).ToList();
-
- var plan = new Download.Models.DownloadPlan(assets, false);
-
- var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(TimeOut) };
- try
+ await DownloadOrchestrator.ExecuteAsync(plan, _appPath).ConfigureAwait(false);
+ }
+ else
{
- var orchestrator = new Download.Orchestrators.DefaultDownloadOrchestrator(httpClient);
+ using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(TimeOut) };
+ var orchestrator = new DefaultDownloadOrchestrator(httpClient);
await orchestrator.ExecuteAsync(plan, _appPath).ConfigureAwait(false);
}
- finally { httpClient.Dispose(); }
}
- private void Decompress(List versions)
+ private void DecompressAssets(List assets)
{
var encoding = Encoding.GetEncoding(_configInfo?.Encoding?.CodePage ?? Encoding.UTF8.CodePage);
- foreach (var version in versions)
+ foreach (var asset in assets)
{
- var zipFilePath = Path.Combine(_appPath, $"{version.PacketName}{Format.ZIP}");
+ var zipFilePath = Path.Combine(_appPath, $"{asset.Name}{Format.ZIP}");
CompressProvider.Decompress(Format.ZIP, zipFilePath, _appPath, encoding);
if (!File.Exists(zipFilePath)) continue;
diff --git a/tests/CoreTest/Download/DownloadRobustnessTests.cs b/tests/CoreTest/Download/DownloadRobustnessTests.cs
new file mode 100644
index 00000000..171c8694
--- /dev/null
+++ b/tests/CoreTest/Download/DownloadRobustnessTests.cs
@@ -0,0 +1,147 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using GeneralUpdate.Core.Download.Abstractions;
+using GeneralUpdate.Core.Download.Executors;
+using GeneralUpdate.Core.Download.Models;
+using GeneralUpdate.Core.Download.Policy;
+using Xunit;
+
+namespace CoreTest.Download;
+
+public class DownloadRobustnessTests
+{
+ [Fact]
+ public async Task DefaultRetryPolicy_RetriesOnTransientFailure()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(1));
+ int attempts = 0;
+
+ var result = await policy.ExecuteAsync(async _ =>
+ {
+ attempts++;
+ if (attempts < 3) throw new HttpRequestException("timeout");
+ return "ok";
+ }, CancellationToken.None);
+
+ Assert.Equal("ok", result);
+ Assert.Equal(3, attempts);
+ }
+
+ [Fact]
+ public async Task DefaultRetryPolicy_DoesNotRetryOnPermanentFailure()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(1));
+ int attempts = 0;
+
+ await Assert.ThrowsAsync(() =>
+ policy.ExecuteAsync(_ =>
+ {
+ attempts++;
+ throw new HttpRequestException("404 Not Found");
+ }, CancellationToken.None));
+
+ Assert.Equal(1, attempts);
+ }
+
+ [Fact]
+ public async Task DefaultRetryPolicy_ExponentialBackoff()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(10), backoffMultiplier: 2.0);
+ int attempts = 0;
+ var start = DateTime.UtcNow;
+
+ await Assert.ThrowsAsync(() =>
+ policy.ExecuteAsync(_ =>
+ {
+ attempts++;
+ throw new HttpRequestException("timeout");
+ }, CancellationToken.None));
+
+ var elapsed = DateTime.UtcNow - start;
+ // 3 attempts = 2 retries: delay 10ms + 20ms = at least 30ms
+ Assert.InRange(elapsed.TotalMilliseconds, 25, 500);
+ Assert.Equal(3, attempts);
+ }
+
+ [Fact]
+ public async Task RetryPolicy_RespectsCancellation()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 5, initialDelay: TimeSpan.FromSeconds(1));
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
+
+ await Assert.ThrowsAsync(() =>
+ policy.ExecuteAsync(_ =>
+ {
+ throw new HttpRequestException("timeout");
+ }, cts.Token));
+ }
+
+ [Fact]
+ public async Task RetryPolicy_RetriesOnIOException()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 2, initialDelay: TimeSpan.FromMilliseconds(1));
+ int attempts = 0;
+
+ var result = await policy.ExecuteAsync(async _ =>
+ {
+ attempts++;
+ if (attempts < 2) throw new IOException("Network stream error");
+ return "recovered";
+ }, CancellationToken.None);
+
+ Assert.Equal("recovered", result);
+ Assert.Equal(2, attempts);
+ }
+
+ [Fact]
+ public async Task RetryPolicy_ReturnsOnSuccessFirstTry()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 3, initialDelay: TimeSpan.FromMilliseconds(1));
+ int attempts = 0;
+
+ var result = await policy.ExecuteAsync(_ =>
+ {
+ attempts++;
+ return Task.FromResult("first");
+ }, CancellationToken.None);
+
+ Assert.Equal("first", result);
+ Assert.Equal(1, attempts);
+ }
+
+ [Fact]
+ public void DefaultRetryPolicy_RetryableStatusCodes()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 3);
+ int attempts = 0;
+
+ Assert.ThrowsAsync(() =>
+ policy.ExecuteAsync(_ =>
+ {
+ attempts++;
+ throw new HttpRequestException("503 Service Unavailable");
+ }, CancellationToken.None)).GetAwaiter().GetResult();
+
+ Assert.Equal(3, attempts);
+ }
+
+ [Fact]
+ public void DefaultRetryPolicy_NoRetryOn402()
+ {
+ var policy = new DefaultRetryPolicy(maxRetries: 3);
+ int attempts = 0;
+
+ Assert.ThrowsAsync(() =>
+ policy.ExecuteAsync(_ =>
+ {
+ attempts++;
+ throw new HttpRequestException("402 Payment Required");
+ }, CancellationToken.None)).GetAwaiter().GetResult();
+
+ Assert.Equal(1, attempts);
+ }
+}
diff --git a/tests/CoreTest/Event/EventListenerBatchTests.cs b/tests/CoreTest/Event/EventListenerBatchTests.cs
new file mode 100644
index 00000000..b5dd2748
--- /dev/null
+++ b/tests/CoreTest/Event/EventListenerBatchTests.cs
@@ -0,0 +1,139 @@
+using GeneralUpdate.Core.Download;
+using GeneralUpdate.Core.Download.Models;
+using GeneralUpdate.Core.Event;
+using Xunit;
+
+namespace CoreTest.Event;
+
+public class EventListenerBatchTests
+{
+ [Fact]
+ public void IUpdateEventListener_AllMethodsDefined()
+ {
+ var listener = new TestListener();
+ Assert.NotNull(listener);
+ Assert.IsAssignableFrom(listener);
+ }
+
+ [Fact]
+ public void UpdateEventListenerBase_AllDefaultNoOp()
+ {
+ var listener = new TestBaseListener();
+ var progress = new DownloadProgress("test.zip", 500, 1000, 50.0, DownloadStatus.Downloading);
+
+ listener.OnUpdateInfo(new UpdateInfoEventArgs());
+ listener.OnDownloadCompleted(new MultiDownloadCompletedEventArgs("1.0.0", true));
+ listener.OnAllDownloadCompleted(new MultiAllDownloadCompletedEventArgs(true, new List<(object, string)>()));
+ listener.OnDownloadError(new MultiDownloadErrorEventArgs(new System.Exception("e"), "1.0.0"));
+ listener.OnDownloadStatistics(new MultiDownloadStatisticsEventArgs("1.0.0", TimeSpan.Zero, "0 B/s", 1000, 500, 50.0));
+ listener.OnProgress(new ProgressEventArgs(progress));
+ listener.OnException(new ExceptionEventArgs(new System.Exception("test"), "test"));
+ }
+
+ [Fact]
+ public void ProgressEventArgs_WrapsDownloadProgress()
+ {
+ var progress = new DownloadProgress("test.zip", 500, 1000, 50.0, DownloadStatus.Downloading);
+ var args = new ProgressEventArgs(progress);
+
+ Assert.Same(progress, args.Progress);
+ Assert.Equal("test.zip", args.Progress.AssetName);
+ Assert.Equal(500, args.Progress.BytesDownloaded);
+ Assert.Equal(50.0, args.Progress.Percentage);
+ }
+
+ [Fact]
+ public void ExceptionEventArgs_HoldsException()
+ {
+ var ex = new System.InvalidOperationException("test error");
+ var args = new ExceptionEventArgs(ex, "Context message");
+
+ Assert.Same(ex, args.Exception);
+ Assert.Equal("Context message", args.Message);
+ }
+
+ [Fact]
+ public void EventManager_ConcurrentSubscribeUnsubscribe()
+ {
+ var manager = EventManager.Instance;
+ int callCount = 0;
+ void Handler(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref callCount);
+
+ var tasks = new Task[10];
+ for (int i = 0; i < tasks.Length; i++)
+ {
+ int idx = i;
+ tasks[i] = Task.Run(() =>
+ {
+ if (idx % 2 == 0)
+ manager.AddListener(Handler);
+ else
+ manager.RemoveListener(Handler);
+ });
+ }
+
+ Task.WaitAll(tasks);
+ }
+
+ [Fact]
+ public void EventManager_DispatchToMultipleListeners()
+ {
+ var manager = EventManager.Instance;
+ int count1 = 0, count2 = 0;
+ void H1(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref count1);
+ void H2(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref count2);
+
+ manager.AddListener(H1);
+ manager.AddListener(H2);
+
+ try
+ {
+ manager.Dispatch(this, System.EventArgs.Empty);
+ }
+ finally
+ {
+ manager.RemoveListener(H1);
+ manager.RemoveListener(H2);
+ }
+
+ Assert.Equal(1, count1);
+ Assert.Equal(1, count2);
+ }
+
+ [Fact]
+ public void EventManager_HandlerException_DoesNotBlockOthers()
+ {
+ var manager = EventManager.Instance;
+ int count = 0;
+ void FailingHandler(object? s, System.EventArgs e) => throw new System.InvalidOperationException("handler error");
+ void GoodHandler(object? s, System.EventArgs e) => System.Threading.Interlocked.Increment(ref count);
+
+ manager.AddListener(FailingHandler);
+ manager.AddListener(GoodHandler);
+
+ try
+ {
+ manager.Dispatch(this, System.EventArgs.Empty);
+ }
+ finally
+ {
+ manager.RemoveListener(FailingHandler);
+ manager.RemoveListener(GoodHandler);
+ }
+
+ Assert.Equal(1, count);
+ }
+
+ private class TestListener : IUpdateEventListener
+ {
+ public void OnAllDownloadCompleted(MultiAllDownloadCompletedEventArgs args) { }
+ public void OnDownloadCompleted(MultiDownloadCompletedEventArgs args) { }
+ public void OnDownloadError(MultiDownloadErrorEventArgs args) { }
+ public void OnDownloadStatistics(MultiDownloadStatisticsEventArgs args) { }
+ public void OnUpdateInfo(UpdateInfoEventArgs args) { }
+ public void OnException(ExceptionEventArgs args) { }
+ public void OnProgress(ProgressEventArgs args) { }
+ }
+
+ private class TestBaseListener : UpdateEventListenerBase { }
+}
diff --git a/tests/CoreTest/FileSystem/FileTreeComparerTests.cs b/tests/CoreTest/FileSystem/FileTreeComparerTests.cs
new file mode 100644
index 00000000..93238e74
--- /dev/null
+++ b/tests/CoreTest/FileSystem/FileTreeComparerTests.cs
@@ -0,0 +1,213 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using GeneralUpdate.Core.Configuration;
+using GeneralUpdate.Core.FileSystem;
+using Xunit;
+
+namespace CoreTest.FileSystem;
+
+public class FileTreeComparerTests
+{
+ [Fact]
+ public void Compare_TwoIdenticalSnapshots_ReturnsEmptyDiff()
+ {
+ var entries = new[] { new FileEntry("a.txt", 100, DateTime.UtcNow) };
+ var old = new FileTreeSnapshot("/root", entries);
+ var updated = new FileTreeSnapshot("/root", entries);
+
+ var diff = FileTreeComparer.Compare(old, updated);
+
+ Assert.False(diff.HasChanges);
+ Assert.Equal(0, diff.TotalChanges);
+ }
+
+ [Fact]
+ public void Compare_NewFile_DetectsAddition()
+ {
+ var old = new FileTreeSnapshot("/root", Array.Empty());
+ var entry = new FileEntry("new.txt", 50, DateTime.UtcNow);
+ var updated = new FileTreeSnapshot("/root", new[] { entry });
+
+ var diff = FileTreeComparer.Compare(old, updated);
+
+ Assert.True(diff.HasChanges);
+ Assert.Single(diff.Added);
+ Assert.Equal("new.txt", diff.Added[0].RelativePath);
+ Assert.Empty(diff.Modified);
+ Assert.Empty(diff.Deleted);
+ }
+
+ [Fact]
+ public void Compare_DeletedFile_DetectsDeletion()
+ {
+ var entry = new FileEntry("old.txt", 50, DateTime.UtcNow);
+ var old = new FileTreeSnapshot("/root", new[] { entry });
+ var updated = new FileTreeSnapshot("/root", Array.Empty());
+
+ var diff = FileTreeComparer.Compare(old, updated);
+
+ Assert.True(diff.HasChanges);
+ Assert.Empty(diff.Added);
+ Assert.Empty(diff.Modified);
+ Assert.Single(diff.Deleted);
+ Assert.Equal("old.txt", diff.Deleted[0]);
+ }
+
+ [Fact]
+ public void Compare_ModifiedFile_DetectsModification()
+ {
+ var oldEntry = new FileEntry("mod.txt", 100, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ var newEntry = new FileEntry("mod.txt", 200, new DateTime(2020, 1, 2, 0, 0, 0, DateTimeKind.Utc));
+ var old = new FileTreeSnapshot("/root", new[] { oldEntry });
+ var updated = new FileTreeSnapshot("/root", new[] { newEntry });
+
+ var diff = FileTreeComparer.Compare(old, updated);
+
+ Assert.True(diff.HasChanges);
+ Assert.Empty(diff.Added);
+ Assert.Single(diff.Modified);
+ Assert.Equal("mod.txt", diff.Modified[0].RelativePath);
+ Assert.Equal(200, diff.Modified[0].Size);
+ Assert.Empty(diff.Deleted);
+ }
+
+ [Fact]
+ public void Compare_MixedChanges_AllDetected()
+ {
+ var now = DateTime.UtcNow;
+ var old = new FileTreeSnapshot("/root", new[]
+ {
+ new FileEntry("keep.txt", 100, now),
+ new FileEntry("remove.txt", 200, now),
+ new FileEntry("change.txt", 50, now.AddDays(-1)),
+ });
+ var updated = new FileTreeSnapshot("/root", new[]
+ {
+ new FileEntry("keep.txt", 100, now),
+ new FileEntry("change.txt", 75, now),
+ new FileEntry("create.txt", 150, now),
+ });
+
+ var diff = FileTreeComparer.Compare(old, updated);
+
+ Assert.True(diff.HasChanges);
+ Assert.Single(diff.Added);
+ Assert.Equal("create.txt", diff.Added[0].RelativePath);
+ Assert.Single(diff.Modified);
+ Assert.Equal("change.txt", diff.Modified[0].RelativePath);
+ Assert.Single(diff.Deleted);
+ Assert.Equal("remove.txt", diff.Deleted[0]);
+ }
+
+ [Fact]
+ public void HasChanges_QuickCheck_ShortCircuits()
+ {
+ var now = DateTime.UtcNow;
+ var old = new FileTreeSnapshot("/root", new[] { new FileEntry("a.txt", 100, now) });
+ var updated = new FileTreeSnapshot("/root", new[] { new FileEntry("b.txt", 100, now) });
+
+ bool changed = FileTreeComparer.HasChanges(old, updated);
+ Assert.True(changed);
+
+ var same = FileTreeComparer.HasChanges(old, old);
+ Assert.False(same);
+ }
+
+ [Fact]
+ public void FileTreeDiffer_ShouldUseDelta_SmallChange()
+ {
+ var now = DateTime.UtcNow;
+ var diff = new FileTreeDiff(
+ new[] { new FileEntry("a.txt", 100, now) },
+ Array.Empty(),
+ Array.Empty()
+ );
+
+ bool useDelta = FileTreeDiffer.ShouldUseDeltaPatching(diff, totalFileCount: 100);
+ Assert.True(useDelta); // 1/100 = 1% < 50%
+ }
+
+ [Fact]
+ public void FileTreeDiffer_ShouldUseFull_WhenLargeChange()
+ {
+ var added = new List();
+ for (int i = 0; i < 60; i++)
+ added.Add(new FileEntry($"file_{i}.txt", 100, DateTime.UtcNow));
+
+ var diff = new FileTreeDiff(added.AsReadOnly(), Array.Empty(), Array.Empty());
+
+ bool useDelta = FileTreeDiffer.ShouldUseDeltaPatching(diff, totalFileCount: 100);
+ Assert.False(useDelta); // 60/100 = 60% > 50%
+ }
+
+ [Fact]
+ public void FileTreeDiffer_ProduceDeltaPaths()
+ {
+ var now = DateTime.UtcNow;
+ var diff = new FileTreeDiff(
+ new[] { new FileEntry("new.txt", 100, now) },
+ new[] { new FileEntry("mod.txt", 200, now) },
+ new[] { "del.txt" }
+ );
+
+ // ProduceDeltaPaths only returns files that exist on disk.
+ // Non-existent paths are skipped — this tests the logic, not disk state.
+ var paths = FileTreeDiffer.ProduceDeltaPaths(diff, "/nonexistent-root");
+ Assert.Empty(paths); // files don't exist on disk
+
+ var deletes = FileTreeDiffer.ProduceDeletes(diff);
+ Assert.Single(deletes);
+ Assert.Equal("del.txt", deletes[0]);
+
+ // Verify ShouldUseDeltaPatching logic separately
+ Assert.True(FileTreeDiffer.ShouldUseDeltaPatching(diff, totalFileCount: 100));
+ }
+
+ [Fact]
+ public void FileTreeSnapshot_FromEnumerator()
+ {
+ var safeDir = "GenUpdSnap_" + System.IO.Path.GetRandomFileName();
+ var rootPath = Path.Combine(Path.GetTempPath(), safeDir);
+ Directory.CreateDirectory(rootPath);
+ try
+ {
+ var filePath = Path.Combine(rootPath, "test.txt");
+ File.WriteAllText(filePath, "data");
+
+ var config = BlackListConfig.Empty;
+ var enumerator = FileTreeEnumerator.FromConfig(config);
+
+ var snapshot = FileTreeSnapshot.FromEnumerator(rootPath, enumerator);
+ Assert.Equal(rootPath, snapshot.RootPath);
+ Assert.NotNull(snapshot.Entries);
+ Assert.NotEmpty(snapshot.Entries);
+ Assert.Contains(snapshot.Entries, e => e.RelativePath.EndsWith("test.txt"));
+ Assert.True(snapshot.CreatedAt <= DateTime.UtcNow);
+ }
+ finally
+ {
+ try
+ {
+ if (Directory.Exists(rootPath))
+ {
+ foreach (var f in Directory.GetFiles(rootPath))
+ {
+ File.SetAttributes(f, FileAttributes.Normal);
+ File.Delete(f);
+ }
+ Directory.Delete(rootPath, false);
+ }
+ }
+ catch { }
+ }
+ }
+
+ [Fact]
+ public void FileTreeSnapshot_Empty()
+ {
+ var snapshot = FileTreeSnapshot.Empty("/root");
+ Assert.Empty(snapshot.Entries);
+ Assert.Equal("/root", snapshot.RootPath);
+ }
+}
diff --git a/tests/CoreTest/Hooks/HooksIntegrationTests.cs b/tests/CoreTest/Hooks/HooksIntegrationTests.cs
new file mode 100644
index 00000000..d110b733
--- /dev/null
+++ b/tests/CoreTest/Hooks/HooksIntegrationTests.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Threading.Tasks;
+using GeneralUpdate.Core.Hooks;
+using Xunit;
+
+namespace CoreTest.Hooks;
+
+public class HooksIntegrationTests
+{
+ [Fact]
+ public void NoOpUpdateHooks_AllReturnDefault()
+ {
+ var hooks = new NoOpUpdateHooks();
+ var ctx = new UpdateContext("TestApp", "/path", "1.0.0", "1.0.1", 1);
+
+ var beforeResult = hooks.OnBeforeUpdateAsync(ctx).GetAwaiter().GetResult();
+ Assert.True(beforeResult);
+
+ var dcx = new DownloadContext("pkg.zip", "1.0.1", 1000, TimeSpan.FromSeconds(1), null, true);
+ hooks.OnDownloadCompletedAsync(dcx).GetAwaiter().GetResult();
+ hooks.OnAfterUpdateAsync(ctx).GetAwaiter().GetResult();
+ hooks.OnUpdateErrorAsync(ctx, new Exception("test")).GetAwaiter().GetResult();
+ hooks.OnBeforeStartAppAsync(ctx).GetAwaiter().GetResult();
+ }
+
+ [Fact]
+ public async Task UnixPermissionHooks_BeforeStartApp_DoesNotThrow()
+ {
+ var hooks = new UnixPermissionHooks();
+ var ctx = new UpdateContext("non_existent_app", "/tmp/test", "1.0.0", null, 1);
+
+ await hooks.OnBeforeStartAppAsync(ctx);
+ }
+
+ [Fact]
+ public void CustomPermissionHooks_RequiresScriptPath()
+ {
+ Assert.Throws(() =>
+ new CustomPermissionHooks(null!));
+ }
+
+ [Fact]
+ public void CustomPermissionHooks_StoresScriptPath()
+ {
+ var hooks = new CustomPermissionHooks("/usr/local/bin/my-script.sh");
+ var ctx = new UpdateContext("app", "/path", "1.0.0", null, 1);
+
+ // CustomPermissionHooks throws when script fails
+ var ex = Assert.ThrowsAsync(() =>
+ hooks.OnBeforeStartAppAsync(ctx));
+ }
+
+ [Fact]
+ public void CustomPermissionHooks_BeforeUpdate_Allows()
+ {
+ var hooks = new CustomPermissionHooks("/bin/true");
+ var ctx = new UpdateContext("app", "/path", "1.0.0", "1.0.1", 1);
+
+ var result = hooks.OnBeforeUpdateAsync(ctx).GetAwaiter().GetResult();
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void UpdateContext_PropertiesSet()
+ {
+ var ctx = new UpdateContext("MyApp", "/opt/myapp", "1.0.0", "2.0.0", 1);
+
+ Assert.Equal("MyApp", ctx.AppName);
+ Assert.Equal("/opt/myapp", ctx.InstallPath);
+ Assert.Equal("1.0.0", ctx.CurrentVersion);
+ Assert.Equal("2.0.0", ctx.TargetVersion);
+ Assert.Equal(1, ctx.AppType);
+ }
+
+ [Fact]
+ public void DownloadContext_PropertiesSet()
+ {
+ var ctx = new DownloadContext("pkg.zip", "1.0.1", 5000, TimeSpan.FromSeconds(3), "/tmp/pkg.zip", true);
+
+ Assert.Equal("pkg.zip", ctx.AssetName);
+ Assert.Equal("1.0.1", ctx.Version);
+ Assert.Equal(5000, ctx.TotalBytes);
+ Assert.Equal(TimeSpan.FromSeconds(3), ctx.Duration);
+ Assert.Equal("/tmp/pkg.zip", ctx.LocalPath);
+ Assert.True(ctx.Success);
+ }
+}
diff --git a/tests/CoreTest/Integration/OssIntegrationTests.cs b/tests/CoreTest/Integration/OssIntegrationTests.cs
new file mode 100644
index 00000000..26626f51
--- /dev/null
+++ b/tests/CoreTest/Integration/OssIntegrationTests.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using GeneralUpdate.Core.Configuration;
+using GeneralUpdate.Core.Download.Abstractions;
+using GeneralUpdate.Core.Download.Reporting;
+using GeneralUpdate.Core.Download.Sources;
+using GeneralUpdate.Core.Hooks;
+using GeneralUpdate.Core.Strategy;
+using Xunit;
+
+namespace CoreTest.Integration;
+
+public class OssIntegrationTests
+{
+ [Fact]
+ public void OssDownloadSource_Creation_RequiresValidArgs()
+ {
+ var client = new HttpClient();
+ Assert.Throws(() =>
+ new OssDownloadSource(null!, "https://oss.example.com/versions.json"));
+ Assert.Throws(() =>
+ new OssDownloadSource(client, null!));
+
+ var source = new OssDownloadSource(client, "https://oss.example.com/versions.json");
+ Assert.NotNull(source);
+ }
+
+ [Fact]
+ public void OssDownloadSource_DefaultTimeout_Is60Seconds()
+ {
+ var client = new HttpClient();
+ var source = new OssDownloadSource(client, "https://oss.example.com/versions.json");
+ Assert.NotNull(source);
+ }
+
+ [Fact]
+ public void OssDownloadSource_CustomTimeout()
+ {
+ var client = new HttpClient();
+ var source = new OssDownloadSource(client, "https://oss.example.com/versions.json", TimeSpan.FromSeconds(30));
+ Assert.NotNull(source);
+ }
+
+ [Fact]
+ public void OSSUpdateStrategy_DownloadSource_IsInjected()
+ {
+ var strategy = new OSSUpdateStrategy();
+ Assert.Null(strategy.DownloadSource);
+ Assert.Null(strategy.DownloadOrchestrator);
+
+ var client = new HttpClient();
+ var source = new OssDownloadSource(client, "https://oss.example.com/versions.json");
+ strategy.DownloadSource = source;
+ Assert.Same(source, strategy.DownloadSource);
+ }
+
+ [Fact]
+ public void OSSUpdateStrategy_Hooks_DefaultToNoOp()
+ {
+ var strategy = new OSSUpdateStrategy();
+ Assert.IsType(strategy.Hooks);
+ Assert.IsType(strategy.Reporter);
+ }
+
+ [Fact]
+ public async Task OSSUpdateStrategy_WithoutConfig_Throws()
+ {
+ var strategy = new OSSUpdateStrategy();
+ await Assert.ThrowsAsync(() =>
+ strategy.ExecuteAsync());
+ }
+
+ [Fact]
+ public async Task OSSUpdateStrategy_RequiresConfig()
+ {
+ var strategy = new OSSUpdateStrategy();
+ var config = new GlobalConfigInfo
+ {
+ AppName = "TestOSS",
+ ClientVersion = "1.0.0",
+ InstallPath = "/test/oss"
+ };
+ strategy.Create(config);
+
+ await Assert.ThrowsAsync(() =>
+ strategy.ExecuteAsync());
+ }
+
+ [Fact]
+ public void OssDownloadSource_ImplementsIDownloadSource()
+ {
+ var source = new OssDownloadSource(new HttpClient(), "https://oss.example.com/versions.json");
+ Assert.IsAssignableFrom(source);
+ }
+}
diff --git a/tests/CoreTest/Ipc/IpcFallbackTests.cs b/tests/CoreTest/Ipc/IpcFallbackTests.cs
new file mode 100644
index 00000000..73cdf9b5
--- /dev/null
+++ b/tests/CoreTest/Ipc/IpcFallbackTests.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GeneralUpdate.Core.Configuration;
+using GeneralUpdate.Core.Ipc;
+using Xunit;
+
+namespace CoreTest.Ipc;
+
+public class IpcFallbackTests
+{
+ private static ProcessInfo CreateTestInfo(string appName, string currentVersion)
+ {
+ return new ProcessInfo
+ {
+ AppName = appName,
+ CurrentVersion = currentVersion,
+ LastVersion = "2.0.0",
+ InstallPath = "/test/path"
+ };
+ }
+
+ [Fact]
+ public async Task EncryptedFileProvider_RoundTrip()
+ {
+ var provider = new EncryptedFileProcessInfoProvider();
+ var info = CreateTestInfo("TestApp", "1.0.0");
+
+ await provider.SendAsync(info);
+ var received = await provider.ReceiveAsync();
+
+ Assert.NotNull(received);
+ Assert.Equal("TestApp", received!.AppName);
+ Assert.Equal("1.0.0", received.CurrentVersion);
+ }
+
+ [Fact]
+ public async Task EncryptedFileProvider_ReceiveWithoutSend_ReturnsNull()
+ {
+ var provider = new EncryptedFileProcessInfoProvider();
+ var received = await provider.ReceiveAsync();
+ Assert.Null(received);
+ }
+
+ [Fact]
+ public async Task NamedPipeProvider_DetectsTimeout()
+ {
+ var provider = new NamedPipeProcessInfoProvider("TestPipe.Timeout");
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
+
+ await Assert.ThrowsAsync(() =>
+ provider.ReceiveAsync(cts.Token));
+ }
+
+ [Fact]
+ public async Task SharedMemoryProvider_RoundTrip()
+ {
+ var mapName = $"GenUpd.Tests.{Guid.NewGuid():N}";
+
+ try
+ {
+ var provider = new SharedMemoryProcessInfoProvider(mapName);
+ var info = CreateTestInfo("TestApp.Shm", "2.0.0");
+
+ await provider.SendAsync(info);
+ var receiver = new SharedMemoryProcessInfoProvider(mapName);
+ var received = await receiver.ReceiveAsync();
+
+ Assert.NotNull(received);
+ Assert.Equal("TestApp.Shm", received!.AppName);
+ Assert.Equal("2.0.0", received.CurrentVersion);
+ }
+ catch (System.PlatformNotSupportedException)
+ {
+ // Shared memory not supported on this platform — skip
+ }
+ catch (System.IO.FileNotFoundException)
+ {
+ // Memory-mapped file already disposed — platform quirk, skip
+ }
+ }
+
+ [Fact]
+ public async Task SharedMemoryProvider_ReceiveWithoutSend_ReturnsNull()
+ {
+ var provider = new SharedMemoryProcessInfoProvider("NonExistent.Shm");
+ var received = await provider.ReceiveAsync();
+ Assert.Null(received);
+ }
+
+ [Fact]
+ public async Task AutoProvider_FallsBackToEncryptedFile()
+ {
+ var provider = new AutoProcessInfoProvider(
+ new EncryptedFileProcessInfoProvider()
+ );
+ var info = CreateTestInfo("TestApp.Auto", "3.0.0");
+
+ await provider.SendAsync(info);
+ var received = await provider.ReceiveAsync();
+
+ Assert.NotNull(received);
+ Assert.Equal("TestApp.Auto", received!.AppName);
+ }
+
+ [Fact]
+ public async Task AutoProvider_ThrowsWhenAllFail()
+ {
+ var provider = new AutoProcessInfoProvider(
+ new NamedPipeProcessInfoProvider("NonExistent." + Guid.NewGuid().ToString("N")),
+ new SharedMemoryProcessInfoProvider("NonExistent." + Guid.NewGuid().ToString("N"))
+ );
+ var info = CreateTestInfo("FailAll", "1.0.0");
+
+ await Assert.ThrowsAnyAsync(() =>
+ provider.SendAsync(info, new CancellationTokenSource(TimeSpan.FromMilliseconds(100)).Token));
+ }
+
+ [Fact]
+ public async Task EncryptedFileProvider_DataConfidentiality()
+ {
+ var provider = new EncryptedFileProcessInfoProvider();
+ var info = CreateTestInfo("SecureApp", "1.0.0");
+
+ await provider.SendAsync(info);
+
+ var received = await provider.ReceiveAsync();
+ Assert.NotNull(received);
+ Assert.Equal("SecureApp", received!.AppName);
+
+ // Second receive should return null (file was deleted)
+ var second = await provider.ReceiveAsync();
+ Assert.Null(second);
+ }
+}