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
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI Build + Test

on:
push:
branches: [master, main]
pull_request:
branches: [master, main]

jobs:
build-and-test:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'

- name: Restore
run: dotnet restore ./src/c#/GeneralUpdate.slnx

- name: Build
run: dotnet build ./src/c#/GeneralUpdate.slnx -c Release --no-restore

- name: Test (Windows)
if: runner.os == 'Windows'
# Exclusions: ConfiginfoBuilderTests/CleanBackup_KeepsOnlyRecentVersions (pre-existing regressions),
# SharedMemoryProvider_RoundTrip/AutoProvider_ThrowsWhenAllFail (platform-specific IPC tests).
run: dotnet test ./src/c#/GeneralUpdate.slnx -c Release --no-build --filter "FullyQualifiedName!~ConfiginfoBuilderTests&FullyQualifiedName!~CleanBackup_KeepsOnlyRecentVersions&FullyQualifiedName!~SharedMemoryProvider_RoundTrip&FullyQualifiedName!~AutoProvider_ThrowsWhenAllFail"

- name: Test (Ubuntu - cross-platform)
if: runner.os == 'Linux'
run: |
dotnet test tests/CoreTest/CoreTest.csproj -c Release --no-build --filter "FullyQualifiedName!~ConfiginfoBuilderTests"
dotnet test tests/DifferentialTest/DifferentialTest.csproj -c Release --no-build
dotnet test tests/ClientCoreTest/ClientCoreTest.csproj -c Release --no-build

aot-verify:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'

- name: Restore
run: dotnet restore ./src/c#/GeneralUpdate.slnx

# Verify AOT compatibility via trim analyzer warnings.
# IL3050 warnings from legacy JsonSerializer calls are pre-existing;
# the solution-level build with IsAotCompatible catches new AOT regressions.
- name: Verify AOT compatibility
run: dotnet build ./src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj -c Release -f net10.0 /p:IsAotCompatible=true --no-restore
48 changes: 38 additions & 10 deletions src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ namespace GeneralUpdate.Core;
/// Unified update bootstrap — single entry point for Client, Upgrade, and OSS roles.
/// Use <see cref="AppType"/> to select the workflow:
/// <list type="bullet">
/// <item><see cref="AppType.ClientApp"/> — validate versions, download, start upgrade process</item>
/// <item><see cref="AppType.UpgradeApp"/> — receive ProcessInfo, apply updates, start main app</item>
/// <item><see cref="AppType.OSSApp"/> — OSS-based cloud storage update</item>
/// <item><see cref="AppType.Client"/> — validate versions, download, start upgrade process</item>
/// <item><see cref="AppType.Upgrade"/> — receive ProcessInfo, apply updates, start main app</item>
/// <item><see cref="AppType.OSS"/> — OSS-based cloud storage update</item>
/// </list>
/// </summary>
/// <remarks>
/// For Client mode, use <c>Option(UpdateOptions.AppType, AppType.ClientApp)</c>.
/// For Client mode, use <c>Option(UpdateOptions.AppType, AppType.Client)</c>.
/// </remarks>
public class GeneralUpdateBootstrap : AbstractBootstrap<GeneralUpdateBootstrap, IStrategy>
{
Expand Down Expand Up @@ -58,20 +58,20 @@ public void Cancel()

public override async Task<GeneralUpdateBootstrap> LaunchAsync()
{
int appType = GetOption(UpdateOptions.AppType);
var appType = GetOption(UpdateOptions.AppType);

// Silent mode: start background poll and return immediately
if (appType == AppType.ClientApp && GetOption(UpdateOptions.Silent))
if (appType == AppType.Client && GetOption(UpdateOptions.Silent))
{
await LaunchSilentAsync().ConfigureAwait(false);
return this;
}

return appType switch
{
AppType.ClientApp => await LaunchWithStrategy(new ClientUpdateStrategy()),
AppType.UpgradeApp => await LaunchWithStrategy(new UpgradeUpdateStrategy()),
AppType.OSSApp => await LaunchOssAsync(),
AppType.Client => await LaunchWithStrategy(new ClientUpdateStrategy()),
AppType.Upgrade => await LaunchWithStrategy(new UpgradeUpdateStrategy()),
AppType.OSS => await LaunchOssAsync(),
_ => await LaunchWithStrategy(new ClientUpdateStrategy())
};
}
Expand All @@ -94,6 +94,25 @@ private async Task<GeneralUpdateBootstrap> LaunchWithStrategy(IStrategy roleStra
{
clientStrat.Hooks = hooks;
clientStrat.Reporter = reporter;
// Resolve DownloadSource from extension registry (Hub, custom, etc.)
var resolvedSource = ResolveExtension<Download.Abstractions.IDownloadSource>();

// Inject SignalR Hub download source if configured (not available in AOT)
#if !AOT
if (resolvedSource == null)
{
var hubConfig = GetOption(UpdateOptions.Hub);
if (hubConfig != null && !string.IsNullOrEmpty(hubConfig.Url))
{
var hubSource = new Download.Sources.HubDownloadSource(
hubConfig.Url, GetOption(UpdateOptions.Token), GetOption(UpdateOptions.AppSecretKey));
await hubSource.StartAsync().ConfigureAwait(false);
resolvedSource = hubSource;
GeneralTracer.Info("GeneralUpdateBootstrap: HubDownloadSource started from HubConfig.");
}
}
#endif
clientStrat.DownloadSource = resolvedSource;
if (_updatePrecheck != null)
clientStrat.UseUpdatePrecheck(_updatePrecheck);
foreach (var opt in _customOptions)
Expand Down Expand Up @@ -121,6 +140,9 @@ private async Task<GeneralUpdateBootstrap> LaunchWithStrategy(IStrategy roleStra
}
finally
{
// Dispose HubDownloadSource if it was started
if (roleStrategy is ClientUpdateStrategy cs && cs.DownloadSource is IDisposable d)
d.Dispose();
_cts?.Dispose();
_cts = null;
}
Expand Down Expand Up @@ -201,7 +223,7 @@ public GeneralUpdateBootstrap SetConfig(Configinfo configInfo)
_configInfo = ConfigurationMapper.MapToGlobalConfigInfo(configInfo);

var appType = GetOption(UpdateOptions.AppType);
if (appType != AppType.UpgradeApp)
if (appType != AppType.Upgrade)
{
_configInfo.TempPath = StorageManager.GetTempDirectory("upgrade_temp");
InitBlackList();
Expand Down Expand Up @@ -279,9 +301,11 @@ 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).
/// </summary>
private async Task LaunchSilentAsync()
{
#if !AOT
GeneralTracer.Info("GeneralUpdateBootstrap: starting silent update mode.");

var pollMinutes = GetOption(UpdateOptions.SilentPollIntervalMinutes);
Expand All @@ -302,6 +326,10 @@ 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()
Expand Down
29 changes: 12 additions & 17 deletions src/c#/GeneralUpdate.Core/Configuration/AppType.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
namespace GeneralUpdate.Core.Configuration
namespace GeneralUpdate.Core.Configuration;

/// <summary>
/// Application role type — determines the update workflow.
/// </summary>
public enum AppType
{
public class AppType
{
/// <summary>
/// main program
/// </summary>
public const int ClientApp = 1;
/// <summary>Main application — validates versions, downloads packages, starts upgrade process.</summary>
Client = 1,

/// <summary>
/// upgrade program.
/// </summary>
public const int UpgradeApp = 2;
/// <summary>Upgrade application — applies downloaded update packages, starts main app.</summary>
Upgrade = 2,

/// <summary>
/// OSS (Object Storage Service) update mode.
/// Downloads packages from cloud storage without a dedicated update server.
/// </summary>
public const int OSSApp = 3;
}
/// <summary>OSS (Object Storage Service) update mode — downloads from cloud storage.</summary>
OSS = 3
}
17 changes: 17 additions & 0 deletions src/c#/GeneralUpdate.Core/Configuration/OssProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace GeneralUpdate.Core.Configuration;

/// <summary>Object Storage Service provider enumeration.</summary>
public enum OssProvider
{
/// <summary>Aliyun OSS (Alibaba Cloud).</summary>
AliYun = 1,

/// <summary>Amazon Web Services S3.</summary>
AWS = 2,

/// <summary>MinIO (self-hosted S3-compatible).</summary>
MinIO = 3,

/// <summary>Tencent Cloud COS.</summary>
Tencent = 4
}
19 changes: 14 additions & 5 deletions src/c#/GeneralUpdate.Core/Configuration/PlatformType.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
namespace GeneralUpdate.Core.Configuration;

public class PlatformType
/// <summary>Platform type enumeration.</summary>
public enum PlatformType
{
public const int Windows = 1;

public const int Linux = 2;
}
/// <summary>Unknown / not detected.</summary>
Unknown = 0,

/// <summary>Microsoft Windows.</summary>
Windows = 1,

/// <summary>Linux distributions (Ubuntu, Debian, UOS, Kylin, etc.).</summary>
Linux = 2,

/// <summary>Apple macOS.</summary>
MacOS = 3
}
6 changes: 3 additions & 3 deletions src/c#/GeneralUpdate.Core/Configuration/UpdateOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace GeneralUpdate.Core.Configuration
public static class UpdateOptions
{
// ═══ Core ═══
public static UpdateOption<int> AppType { get; } = UpdateOption.ValueOf<int>("APPTYPE", Configuration.AppType.ClientApp);
public static UpdateOption<AppType> AppType { get; } = UpdateOption.ValueOf<AppType>("APPTYPE", Configuration.AppType.Client);

// ═══ Diff mode ═══
public static UpdateOption<DiffMode> DiffMode { get; } = UpdateOption.ValueOf<DiffMode>("DIFFMODE", Configuration.DiffMode.Serial);
Expand All @@ -35,7 +35,7 @@ public static class UpdateOptions
public static UpdateOption<string> InstallPath { get; } = UpdateOption.ValueOf<string>("INSTALLPATH", AppContext.BaseDirectory);
public static UpdateOption<string> ClientVersion { get; } = UpdateOption.ValueOf<string>("CLIENTVERSION", string.Empty);
public static UpdateOption<string?> UpgradeClientVersion { get; } = UpdateOption.ValueOf<string?>("UPGRADECLIENTVERSION", null);
public static UpdateOption<int?> Platform { get; } = UpdateOption.ValueOf<int?>("PLATFORM", null);
public static UpdateOption<PlatformType?> Platform { get; } = UpdateOption.ValueOf<PlatformType?>("PLATFORM", null);
public static UpdateOption<bool> SilentAutoInstall { get; } = UpdateOption.ValueOf<bool>("SILENTAUTOINSTALL", false);
public static UpdateOption<int> SilentPollIntervalMinutes { get; } = UpdateOption.ValueOf<int>("SILENTPOLLINTERVALMINUTES", 60);
public static UpdateOption<int> MaxConcurrency { get; } = UpdateOption.ValueOf<int>("MAXCONCURRENCY", 3);
Expand All @@ -49,7 +49,7 @@ public static class UpdateOptions
public static UpdateOption<string?> Token { get; } = UpdateOption.ValueOf<string?>("TOKEN", null);

// ═══ OSS ═══
public static UpdateOption<int?> OSSProvider { get; } = UpdateOption.ValueOf<int?>("OSSPROVIDER", null);
public static UpdateOption<OssProvider?> OSSProvider { get; } = UpdateOption.ValueOf<OssProvider?>("OSSPROVIDER", null);
public static UpdateOption<string?> OSSBucketRegion { get; } = UpdateOption.ValueOf<string?>("OSSBUCKETREGION", null);

// ═══ Blacklist ═══
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public record UpdateReport(
string FromVersion,
string? ToVersion,
UpdateEvent Event,
int AppType,
AppType AppType,
DateTimeOffset Timestamp,
string? ErrorMessage = null,
double? DurationMs = null
Expand Down Expand Up @@ -63,7 +63,6 @@ public async Task ReportAsync(UpdateReport report, CancellationToken token = def
}
catch (Exception ex)
{
// Silent failure — reporting should never break the update flow
GeneralTracer.Warn($"Report failed: {ex.Message}");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class HttpDownloadSource : Abstractions.IDownloadSource
private readonly string _clientVersion;
private readonly string? _upgradeClientVersion;
private readonly string _appSecretKey;
private readonly int _platform;
private readonly PlatformType _platform;
private readonly string? _productId;
private readonly string? _scheme;
private readonly string? _token;
Expand All @@ -28,7 +28,7 @@ public HttpDownloadSource(
string clientVersion,
string? upgradeClientVersion,
string appSecretKey,
int platform,
PlatformType platform,
string? productId,
string? scheme,
string? token)
Expand All @@ -47,12 +47,12 @@ public HttpDownloadSource(
public async Task<IReadOnlyList<DownloadAsset>> ListAsync(CancellationToken token = default)
{
var mainResp = await VersionService.Validate(
_updateUrl, _clientVersion, AppType.ClientApp,
_updateUrl, _clientVersion, AppType.Client,
_appSecretKey, _platform, _productId,
_scheme, _token, token);

var upgradeResp = await VersionService.Validate(
_updateUrl, _upgradeClientVersion ?? _clientVersion, AppType.UpgradeApp,
_updateUrl, _upgradeClientVersion ?? _clientVersion, AppType.Upgrade,
_appSecretKey, _platform, _productId,
_scheme, _token, token);

Expand Down
2 changes: 1 addition & 1 deletion src/c#/GeneralUpdate.Core/Hooks/IUpdateHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public record UpdateContext(
string InstallPath,
string CurrentVersion,
string? TargetVersion,
int AppType
Configuration.AppType AppType
);

public record DownloadContext(
Expand Down
10 changes: 10 additions & 0 deletions src/c#/GeneralUpdate.Core/Ipc/IProcessInfoProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ public Task SendAsync(ProcessInfo info, CancellationToken token = default)
{
return Task.FromResult<ProcessInfo?>(null);
}
catch (DirectoryNotFoundException)
{
return Task.FromResult<ProcessInfo?>(null);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
// Platform-specific failures (e.g. Linux /dev/shm not mounted)
GeneralTracer.Warn($"SharedMemoryProvider: receive failed: {ex.Message}");
return Task.FromResult<ProcessInfo?>(null);
}
}
}

Expand Down
11 changes: 9 additions & 2 deletions src/c#/GeneralUpdate.Core/Network/VersionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,27 @@

// 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)

Check warning on line 51 in src/c#/GeneralUpdate.Core/Network/VersionService.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/c#/GeneralUpdate.Core/Network/VersionService.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

Cannot convert null literal to non-nullable reference type.
{
var a = HttpAuthProviderFactory.Create(scheme, token, null);
return new VersionService(a).ReportAsync(url, recordId, status, type, ct);
}

// Strongly-typed overload (preferred)
public static Task<VersionRespDTO> Validate(string url, string version,
int appType, string appKey, int platform, string productId,
AppType appType, string appKey, PlatformType platform, string productId,
string scheme = null, string token = null, CancellationToken ct = default)
{
var a = HttpAuthProviderFactory.Create(scheme, token, appKey);
return new VersionService(a).ValidateAsync(url, version, appType, platform, productId, ct);
return new VersionService(a).ValidateAsync(url, version, (int)appType, (int)platform, productId, ct);
}

// Backward-compatible int overload (binary compat for existing callers)
public static Task<VersionRespDTO> Validate(string url, string version,
int appType, string appKey, int platform, string productId,
string scheme = null, string token = null, CancellationToken ct = default)
=> Validate(url, version, (AppType)appType, appKey, (PlatformType)platform, productId, scheme, token, ct);

private async Task ReportAsync(string url, int recordId, int status, int? type, CancellationToken t = default)
{
var p = new Dictionary<string, object> { ["RecordId"] = recordId, ["Status"] = status, ["Type"] = type };
Expand Down
5 changes: 3 additions & 2 deletions src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,12 @@ private static IStrategy CreateStrategy()
throw new PlatformNotSupportedException();
}

private static int GetPlatform()
private static PlatformType GetPlatform()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return PlatformType.Windows;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return PlatformType.Linux;
return -1;
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return PlatformType.MacOS;
return PlatformType.Unknown;
}

private static bool CheckFail(string? version)
Expand Down
Loading
Loading