Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ public TBootstrap ConfigureBlackList(BlackListConfig config)
return (TBootstrap)this;
}

/// <summary>
/// Configure blacklist via fluent builder action.
/// Usage: <c>.ConfigureBlackList(cfg => cfg.AddBlackFiles("*.log").AddBlackFormats(".pdb"))</c>
/// </summary>
public TBootstrap ConfigureBlackList(Action<FileSystem.BlackListConfigBuilder> configure)
{
var builder = new FileSystem.BlackListConfigBuilder();
configure(builder);
_instances[typeof(BlackListConfig)] = builder.Build();
return (TBootstrap)this;
}

protected TExtension? ResolveExtension<TExtension>() where TExtension : class
{
if (_extensions.TryGetValue(typeof(TExtension), out var t))
Expand Down
18 changes: 18 additions & 0 deletions src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,23 @@ public static class UpdateOptions

// ═══ Blacklist ═══
public static UpdateOption<BlackListConfig> BlackList { get; } = UpdateOption.ValueOf<BlackListConfig>("BLACKLIST", BlackListConfig.Empty);

// ═══ Watchdog ═══
/// <summary>Bowl (crash monitor / watchdog) executable path.</summary>
public static UpdateOption<string?> Bowl { get; } = UpdateOption.ValueOf<string?>("BOWL", null);

// ═══ Logging & Script ═══
/// <summary>Remote update log / changelog URL.</summary>
public static UpdateOption<string?> UpdateLogUrl { get; } = UpdateOption.ValueOf<string?>("UPDATELOGURL", null);
/// <summary>Custom execution script path for pre/post-update actions.</summary>
public static UpdateOption<string?> Script { get; } = UpdateOption.ValueOf<string?>("SCRIPT", null);

// ═══ Retry ═══
/// <summary>Initial retry interval for exponential backoff. Default 1 second.</summary>
public static UpdateOption<TimeSpan> RetryInterval { get; } = UpdateOption.ValueOf<TimeSpan>("RETRYINTERVAL", TimeSpan.FromSeconds(1));

// ═══ SignalR Hub ═══
/// <summary>SignalR Hub configuration for push-based updates.</summary>
public static UpdateOption<HubConfig?> Hub { get; } = UpdateOption.ValueOf<HubConfig?>("HUB", null);
}
}
78 changes: 78 additions & 0 deletions src/c#/GeneralUpdate.Core/Download/Sources/OssDownloadSource.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// OSS (Object Storage Service) download source.
/// Downloads the version configuration JSON from a remote URL,
/// parses it, and returns a list of <see cref="DownloadAsset"/> for the orchestrator.
/// </summary>
/// <remarks>
/// Supports AliYun, AWS S3, MinIO, and Tencent COS via signed URLs.
/// The version JSON format uses <see cref="VersionOSS"/> records.
/// </remarks>
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);
}

/// <inheritdoc />
public async Task<IReadOnlyList<DownloadAsset>> 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<DownloadAsset>();

// 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace GeneralUpdate.Core.FileSystem;

/// <summary>
/// Result of comparing two file tree snapshots.
/// </summary>
public readonly record struct FileTreeDiff(
IReadOnlyList<FileEntry> Added,
IReadOnlyList<FileEntry> Modified,
IReadOnlyList<string> 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<FileEntry>(), Array.Empty<FileEntry>(), Array.Empty<string>());
}

/// <summary>
/// Compares two <see cref="FileTreeSnapshot"/> instances and produces a <see cref="FileTreeDiff"/>.
/// Identifies added, modified, and deleted files between old and new state.
/// </summary>
public static class FileTreeComparer
{
/// <summary>
/// Compare two snapshots. <paramref name="old"/> is the baseline, <paramref name="updated"/> is the new state.
/// </summary>
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);
Comment on lines +37 to +38

var added = new List<FileEntry>();
var modified = new List<FileEntry>();
var deleted = new List<string>();

// 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());
}

/// <summary>
/// Quick check: compare two snapshots and return true if any files changed.
/// Short-circuits on first difference.
/// </summary>
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);
Comment on lines +78 to +79

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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace GeneralUpdate.Core.FileSystem;

/// <summary>
/// Applies a <see cref="FileTreeDiff"/> to produce delta file bundles
/// for incremental/differential updates. Used by the Pipeline's PatchMiddleware.
/// </summary>
public static class FileTreeDiffer
{
/// <summary>
/// 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.
/// </summary>
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();
}

/// <summary>
/// Produce the list of relative paths that should be deleted based on diff.
/// </summary>
public static IReadOnlyList<string> ProduceDeletes(FileTreeDiff diff)
=> diff.Deleted;

/// <summary>
/// Determine the optimal update mode: incremental (delta) if small diff, full if large.
/// Returns true if delta patching is recommended.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace GeneralUpdate.Core.FileSystem;

/// <summary>
/// Immutable snapshot of a file entry in a directory tree.
/// Captures path, size, and modification timestamp for comparison.
/// </summary>
public readonly record struct FileEntry(
Comment on lines +5 to +11
string RelativePath,
long Size,
DateTime LastWriteTimeUtc
);

/// <summary>
/// Immutable snapshot of a directory tree at a point in time.
/// Created by <see cref="FileTreeEnumerator"/> + <see cref="IBlackListMatcher"/>.
/// </summary>
public sealed class FileTreeSnapshot
{
public DateTime CreatedAt { get; } = DateTime.UtcNow;
public string RootPath { get; }
public IReadOnlyList<FileEntry> Entries { get; }

public FileTreeSnapshot(string rootPath, IEnumerable<FileEntry> entries)
{
RootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
Entries = (entries ?? Array.Empty<FileEntry>()).ToList();
}

public static FileTreeSnapshot FromEnumerator(string rootPath, FileTreeEnumerator enumerator)
{
var entries = new List<FileEntry>();
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<FileEntry>());
}
18 changes: 12 additions & 6 deletions src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,36 @@
<TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
<IsAotCompatible Condition="'$(TargetFramework)' != 'netstandard2.0'">true</IsAotCompatible>
<EnableTrimAnalyzer Condition="'$(TargetFramework)' != 'netstandard2.0'">true</EnableTrimAnalyzer>
<!-- AOT constant for conditional compilation (exclude SignalR, etc.) -->
<DefineConstants Condition="'$(PublishAot)' == 'true'">$(DefineConstants);AOT</DefineConstants>
</PropertyGroup>

<!-- Compatibility packages for netstandard2.0 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.1" />
<PackageReference Include="System.Collections.Immutable" Version="10.0.1" />
<PackageReference Include="System.Text.Json" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" Condition="'$(PublishAot)' != 'true'" />
</ItemGroup>

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

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" Condition="'$(PublishAot)' != 'true'" />
</ItemGroup>

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