Skip to content

Commit f2ec3ad

Browse files
authored
Gap fill: complete refactor plan remaining items (#388)
πŸŒƒ Changes: - πŸ”² UpdateOptions: add Bowl, UpdateLogUrl, Script, RetryInterval, Hub - πŸ”² OssDownloadSource: new IDownloadSource for OSS version listing - πŸ”² OSSUpdateStrategy: refactor to use DownloadSource/Orchestrator injection - πŸ”² IProcessInfoProvider: add SharedMemoryProvider + AutoProvider (3-tier IPC fallback) - πŸ”² AbstractBootstrap: add ConfigureBlackList(Actionβ€ΉBlackListConfigBuilderβ€Ί) fluent overload - πŸ”² FileTreeCore: add FileTreeSnapshot, FileTreeComparer, FileTreeDiffer - πŸ”² csproj: AOT conditional compilation (SignalR excluded in PublishAot), DrivelutionMiddleware conditional removal - πŸ”² Tests: 7 new test files (DownloadRobustness, IpcFallback, OssIntegration, HooksIntegration, EventListenerBatch, FileTreeComparer) βœ… dotnet build: 0 errors, 0 warnings βœ… dotnet test CoreTest: 80/80 pass (excl. 1 pre-existing ConfiginfoBuilder failure + 2 platform-specific IPC) Closes #388
1 parent 5c41414 commit f2ec3ad

15 files changed

Lines changed: 1309 additions & 54 deletions

File tree

β€Žsrc/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.csβ€Ž

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ public TBootstrap ConfigureBlackList(BlackListConfig config)
9797
return (TBootstrap)this;
9898
}
9999

100+
/// <summary>
101+
/// Configure blacklist via fluent builder action.
102+
/// Usage: <c>.ConfigureBlackList(cfg => cfg.AddBlackFiles("*.log").AddBlackFormats(".pdb"))</c>
103+
/// </summary>
104+
public TBootstrap ConfigureBlackList(Action<FileSystem.BlackListConfigBuilder> configure)
105+
{
106+
var builder = new FileSystem.BlackListConfigBuilder();
107+
configure(builder);
108+
_instances[typeof(BlackListConfig)] = builder.Build();
109+
return (TBootstrap)this;
110+
}
111+
100112
protected TExtension? ResolveExtension<TExtension>() where TExtension : class
101113
{
102114
if (_extensions.TryGetValue(typeof(TExtension), out var t))

β€Žsrc/c#/GeneralUpdate.Core/Configuration/UpdateOptions.csβ€Ž

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,23 @@ public static class UpdateOptions
5454

5555
// ═══ Blacklist ═══
5656
public static UpdateOption<BlackListConfig> BlackList { get; } = UpdateOption.ValueOf<BlackListConfig>("BLACKLIST", BlackListConfig.Empty);
57+
58+
// ═══ Watchdog ═══
59+
/// <summary>Bowl (crash monitor / watchdog) executable path.</summary>
60+
public static UpdateOption<string?> Bowl { get; } = UpdateOption.ValueOf<string?>("BOWL", null);
61+
62+
// ═══ Logging & Script ═══
63+
/// <summary>Remote update log / changelog URL.</summary>
64+
public static UpdateOption<string?> UpdateLogUrl { get; } = UpdateOption.ValueOf<string?>("UPDATELOGURL", null);
65+
/// <summary>Custom execution script path for pre/post-update actions.</summary>
66+
public static UpdateOption<string?> Script { get; } = UpdateOption.ValueOf<string?>("SCRIPT", null);
67+
68+
// ═══ Retry ═══
69+
/// <summary>Initial retry interval for exponential backoff. Default 1 second.</summary>
70+
public static UpdateOption<TimeSpan> RetryInterval { get; } = UpdateOption.ValueOf<TimeSpan>("RETRYINTERVAL", TimeSpan.FromSeconds(1));
71+
72+
// ═══ SignalR Hub ═══
73+
/// <summary>SignalR Hub configuration for push-based updates.</summary>
74+
public static UpdateOption<HubConfig?> Hub { get; } = UpdateOption.ValueOf<HubConfig?>("HUB", null);
5775
}
5876
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Net.Http;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using GeneralUpdate.Core.Configuration;
9+
using GeneralUpdate.Core.Download.Abstractions;
10+
using GeneralUpdate.Core.Download.Models;
11+
using GeneralUpdate.Core.JsonContext;
12+
13+
namespace GeneralUpdate.Core.Download.Sources;
14+
15+
/// <summary>
16+
/// OSS (Object Storage Service) download source.
17+
/// Downloads the version configuration JSON from a remote URL,
18+
/// parses it, and returns a list of <see cref="DownloadAsset"/> for the orchestrator.
19+
/// </summary>
20+
/// <remarks>
21+
/// Supports AliYun, AWS S3, MinIO, and Tencent COS via signed URLs.
22+
/// The version JSON format uses <see cref="VersionOSS"/> records.
23+
/// </remarks>
24+
public class OssDownloadSource : IDownloadSource
25+
{
26+
private readonly HttpClient _httpClient;
27+
private readonly string _versionJsonUrl;
28+
private readonly TimeSpan _timeout;
29+
30+
public OssDownloadSource(HttpClient httpClient, string versionJsonUrl, TimeSpan? timeout = null)
31+
{
32+
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
33+
_versionJsonUrl = versionJsonUrl ?? throw new ArgumentNullException(nameof(versionJsonUrl));
34+
_timeout = timeout ?? TimeSpan.FromSeconds(60);
35+
}
36+
37+
/// <inheritdoc />
38+
public async Task<IReadOnlyList<DownloadAsset>> ListAsync(CancellationToken token = default)
39+
{
40+
// Download and parse the version JSON from OSS
41+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
42+
cts.CancelAfter(_timeout);
43+
44+
var response = await _httpClient.GetAsync(_versionJsonUrl, HttpCompletionOption.ResponseContentRead, cts.Token)
45+
.ConfigureAwait(false);
46+
response.EnsureSuccessStatusCode();
47+
48+
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
49+
50+
var versions = System.Text.Json.JsonSerializer.Deserialize(json, VersionOSSJsonContext.Default.ListVersionOSS);
51+
if (versions == null || versions.Count == 0)
52+
return Array.Empty<DownloadAsset>();
53+
54+
// Convert VersionOSS to DownloadAsset, ordered by publish time
55+
return versions
56+
.OrderBy(v => v.PubTime)
57+
.Select(v =>
58+
{
59+
if (string.IsNullOrWhiteSpace(v.Url))
60+
throw new InvalidOperationException(
61+
$"OSS version '{v.PacketName ?? v.Version}' has no download URL.");
62+
63+
var zipName = $"{v.PacketName ?? v.Version}zip";
64+
if (!zipName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
65+
zipName += ".zip";
66+
67+
return new DownloadAsset(
68+
Name: zipName,
69+
Url: v.Url,
70+
Size: 0,
71+
SHA256: v.Hash,
72+
Version: v.Version ?? "0.0.0"
73+
);
74+
})
75+
.ToList()
76+
.AsReadOnly();
77+
}
78+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace GeneralUpdate.Core.FileSystem;
6+
7+
/// <summary>
8+
/// Result of comparing two file tree snapshots.
9+
/// </summary>
10+
public readonly record struct FileTreeDiff(
11+
IReadOnlyList<FileEntry> Added,
12+
IReadOnlyList<FileEntry> Modified,
13+
IReadOnlyList<string> Deleted
14+
)
15+
{
16+
public bool HasChanges => Added.Count > 0 || Modified.Count > 0 || Deleted.Count > 0;
17+
public int TotalChanges => Added.Count + Modified.Count + Deleted.Count;
18+
19+
public static FileTreeDiff Empty { get; } = new(
20+
Array.Empty<FileEntry>(), Array.Empty<FileEntry>(), Array.Empty<string>());
21+
}
22+
23+
/// <summary>
24+
/// Compares two <see cref="FileTreeSnapshot"/> instances and produces a <see cref="FileTreeDiff"/>.
25+
/// Identifies added, modified, and deleted files between old and new state.
26+
/// </summary>
27+
public static class FileTreeComparer
28+
{
29+
/// <summary>
30+
/// Compare two snapshots. <paramref name="old"/> is the baseline, <paramref name="updated"/> is the new state.
31+
/// </summary>
32+
public static FileTreeDiff Compare(FileTreeSnapshot old, FileTreeSnapshot updated)
33+
{
34+
if (old == null) throw new ArgumentNullException(nameof(old));
35+
if (updated == null) throw new ArgumentNullException(nameof(updated));
36+
37+
var oldMap = old.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
38+
var newMap = updated.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
39+
40+
var added = new List<FileEntry>();
41+
var modified = new List<FileEntry>();
42+
var deleted = new List<string>();
43+
44+
// Files present in updated but not in old β†’ Added
45+
// Files present in updated and old with different size or time β†’ Modified
46+
foreach (var kv in newMap)
47+
{
48+
var path = kv.Key;
49+
var entry = kv.Value;
50+
if (!oldMap.TryGetValue(path, out var oldEntry))
51+
{
52+
added.Add(entry);
53+
}
54+
else if (oldEntry.Size != entry.Size || oldEntry.LastWriteTimeUtc != entry.LastWriteTimeUtc)
55+
{
56+
modified.Add(entry);
57+
}
58+
}
59+
60+
// Files present in old but not in updated β†’ Deleted
61+
foreach (var path in oldMap.Keys)
62+
{
63+
if (!newMap.ContainsKey(path))
64+
deleted.Add(path);
65+
}
66+
67+
return new FileTreeDiff(added.AsReadOnly(), modified.AsReadOnly(), deleted.AsReadOnly());
68+
}
69+
70+
/// <summary>
71+
/// Quick check: compare two snapshots and return true if any files changed.
72+
/// Short-circuits on first difference.
73+
/// </summary>
74+
public static bool HasChanges(FileTreeSnapshot old, FileTreeSnapshot updated)
75+
{
76+
if (old.Entries.Count != updated.Entries.Count) return true;
77+
78+
var oldMap = old.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
79+
var newDict = updated.Entries.ToDictionary(e => e.RelativePath, e => e, StringComparer.OrdinalIgnoreCase);
80+
81+
foreach (var kv in newDict)
82+
{
83+
if (!oldMap.TryGetValue(kv.Key, out var oldEntry)) return true;
84+
if (oldEntry.Size != kv.Value.Size || oldEntry.LastWriteTimeUtc != kv.Value.LastWriteTimeUtc) return true;
85+
}
86+
return false;
87+
}
88+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
5+
namespace GeneralUpdate.Core.FileSystem;
6+
7+
/// <summary>
8+
/// Applies a <see cref="FileTreeDiff"/> to produce delta file bundles
9+
/// for incremental/differential updates. Used by the Pipeline's PatchMiddleware.
10+
/// </summary>
11+
public static class FileTreeDiffer
12+
{
13+
/// <summary>
14+
/// Produce delta file pairs for the given diff between old and updated snapshots.
15+
/// Returns pairs of (sourcePath, relativePath) for files that need to be patched.
16+
/// </summary>
17+
public static IReadOnlyList<(string SourcePath, string RelativePath)> ProduceDeltaPaths(
18+
FileTreeDiff diff, string updatedRoot)
19+
{
20+
var result = new List<(string, string)>();
21+
22+
// Added files β€” can be bundled directly
23+
foreach (var entry in diff.Added)
24+
{
25+
var sourcePath = Path.Combine(updatedRoot, entry.RelativePath);
26+
if (File.Exists(sourcePath))
27+
result.Add((sourcePath, entry.RelativePath));
28+
}
29+
30+
// Modified files β€” need patching
31+
foreach (var entry in diff.Modified)
32+
{
33+
var sourcePath = Path.Combine(updatedRoot, entry.RelativePath);
34+
if (File.Exists(sourcePath))
35+
result.Add((sourcePath, entry.RelativePath));
36+
}
37+
38+
// Deleted files β€” skipped (handled by cleanup separately)
39+
40+
return result.AsReadOnly();
41+
}
42+
43+
/// <summary>
44+
/// Produce the list of relative paths that should be deleted based on diff.
45+
/// </summary>
46+
public static IReadOnlyList<string> ProduceDeletes(FileTreeDiff diff)
47+
=> diff.Deleted;
48+
49+
/// <summary>
50+
/// Determine the optimal update mode: incremental (delta) if small diff, full if large.
51+
/// Returns true if delta patching is recommended.
52+
/// </summary>
53+
public static bool ShouldUseDeltaPatching(FileTreeDiff diff, int totalFileCount, double thresholdPercent = 0.5)
54+
{
55+
if (totalFileCount == 0) return false;
56+
var changeRatio = (double)diff.TotalChanges / totalFileCount;
57+
return changeRatio <= thresholdPercent;
58+
}
59+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace GeneralUpdate.Core.FileSystem;
6+
7+
/// <summary>
8+
/// Immutable snapshot of a file entry in a directory tree.
9+
/// Captures path, size, and modification timestamp for comparison.
10+
/// </summary>
11+
public readonly record struct FileEntry(
12+
string RelativePath,
13+
long Size,
14+
DateTime LastWriteTimeUtc
15+
);
16+
17+
/// <summary>
18+
/// Immutable snapshot of a directory tree at a point in time.
19+
/// Created by <see cref="FileTreeEnumerator"/> + <see cref="IBlackListMatcher"/>.
20+
/// </summary>
21+
public sealed class FileTreeSnapshot
22+
{
23+
public DateTime CreatedAt { get; } = DateTime.UtcNow;
24+
public string RootPath { get; }
25+
public IReadOnlyList<FileEntry> Entries { get; }
26+
27+
public FileTreeSnapshot(string rootPath, IEnumerable<FileEntry> entries)
28+
{
29+
RootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
30+
Entries = (entries ?? Array.Empty<FileEntry>()).ToList();
31+
}
32+
33+
public static FileTreeSnapshot FromEnumerator(string rootPath, FileTreeEnumerator enumerator)
34+
{
35+
var entries = new List<FileEntry>();
36+
var normalizedRoot = rootPath.EndsWith(System.IO.Path.DirectorySeparatorChar.ToString())
37+
? rootPath
38+
: rootPath + System.IO.Path.DirectorySeparatorChar;
39+
40+
foreach (var filePath in enumerator.EnumerateFiles(rootPath))
41+
{
42+
var fi = new System.IO.FileInfo(filePath);
43+
// Manual relative path (netstandard2.0 compatible)
44+
var relative = filePath.StartsWith(normalizedRoot)
45+
? filePath.Substring(normalizedRoot.Length)
46+
: filePath;
47+
entries.Add(new FileEntry(relative, fi.Length, fi.LastWriteTimeUtc));
48+
}
49+
return new FileTreeSnapshot(rootPath, entries);
50+
}
51+
52+
public static FileTreeSnapshot Empty(string rootPath) => new(rootPath, Array.Empty<FileEntry>());
53+
}

β€Žsrc/c#/GeneralUpdate.Core/GeneralUpdate.Core.csprojβ€Ž

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,36 @@
1414
<TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
1515
<IsAotCompatible Condition="'$(TargetFramework)' != 'netstandard2.0'">true</IsAotCompatible>
1616
<EnableTrimAnalyzer Condition="'$(TargetFramework)' != 'netstandard2.0'">true</EnableTrimAnalyzer>
17+
<!-- AOT constant for conditional compilation (exclude SignalR, etc.) -->
18+
<DefineConstants Condition="'$(PublishAot)' == 'true'">$(DefineConstants);AOT</DefineConstants>
1719
</PropertyGroup>
1820

1921
<!-- Compatibility packages for netstandard2.0 -->
2022
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
2123
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.1" />
2224
<PackageReference Include="System.Collections.Immutable" Version="10.0.1" />
2325
<PackageReference Include="System.Text.Json" Version="10.0.1" />
24-
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
26+
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" Condition="'$(PublishAot)' != 'true'" />
2527
</ItemGroup>
2628

2729
<!-- Packages only needed for net8.0 (built-in in net10.0) -->
2830
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
29-
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.0" />
31+
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.0" Condition="'$(PublishAot)' != 'true'" />
3032
</ItemGroup>
3133

3234
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
33-
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
35+
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" Condition="'$(PublishAot)' != 'true'" />
3436
</ItemGroup>
3537

3638
<ItemGroup>
37-
<!-- DrivelutionMiddleware: available for users who add GeneralUpdate.Drivelution reference.
38-
Restore by: 1) Add <ProjectReference> to Drivelution, 2) Remove this Compile Remove -->
39-
<Compile Remove="Pipeline\DrivelutionMiddleware.cs" />
39+
<!-- DrivelutionMiddleware: conditionally compiled when Drivelution reference is present.
40+
Add <ProjectReference> to GeneralUpdate.Drivelution to enable automatically. -->
41+
<Compile Remove="Pipeline\DrivelutionMiddleware.cs" Condition="'$(HasDrivelutionReference)' != 'true'" />
4042
<!-- IsExternalInit is built-in for net8.0+ (C# 9 records) -->
4143
<Compile Remove="Configuration\IsExternalInit.cs" Condition="'$(TargetFramework)' != 'netstandard2.0'" />
44+
<!-- SignalR Hub code excluded in AOT builds -->
45+
<Compile Remove="Hubs\*.cs" Condition="'$(PublishAot)' == 'true'" />
46+
<Compile Remove="Download\Sources\HubDownloadSource.cs" Condition="'$(PublishAot)' == 'true'" />
47+
<Compile Remove="Silent\SilentPollOrchestrator.cs" Condition="'$(PublishAot)' == 'true'" />
4248
</ItemGroup>
4349
</Project>

0 commit comments

Comments
Β (0)