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
47 changes: 39 additions & 8 deletions src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,33 @@ namespace GeneralUpdate.Core;
/// components, network policies, and OS strategy.</para>
/// </remarks>
/// <example>
/// <para><b>Standard flow (Client):</b></para>
/// <code>
/// var result = await new GeneralUpdateBootstrap()
/// .SetConfig(new UpdateRequest {
/// UpdateUrl = "https://api.example.com",
/// ClientVersion = "1.0.0",
/// InstallPath = @"C:\MyApp",
/// AppSecretKey = "my-key"
/// })
/// .SetSource("https://api.example.com", "my-key")
/// .SetOption(Option.AppType, AppType.Client)
/// .Hooks&lt;MyCustomHooks&gt;()
/// .LaunchAsync();
/// </code>
/// <para><b>OSS flow (Object Storage Service):</b></para>
/// <code>
/// // OssClient — only secrets in code; identity from generalupdate.manifest.json
/// var result = await new GeneralUpdateBootstrap()
/// .SetSource("https://oss.example.com/versions.json", "my-key")
/// .SetOption(Option.AppType, AppType.OssClient)
/// .Hooks&lt;MyCustomHooks&gt;()
/// .LaunchAsync();
///
/// // OssUpgrade — running from a subdirectory; pass installPath to locate
/// // generalupdate.manifest.json in the parent directory
/// var installPath = Path.GetFullPath(Path.Combine(baseDir, ".."));
/// var result = await new GeneralUpdateBootstrap()
/// .SetSource("https://oss.example.com/versions.json", "my-key",
/// installPath: installPath)
/// .SetOption(Option.AppType, AppType.OssUpgrade)
/// .Hooks&lt;MyCustomHooks&gt;()
/// .LaunchAsync();
/// </code>
/// </example>
public class GeneralUpdateBootstrap : AbstractBootstrap<GeneralUpdateBootstrap, IStrategy>
{
Expand Down Expand Up @@ -232,15 +247,29 @@ public GeneralUpdateBootstrap SetConfig(string filePath)
/// <summary>
/// Zero-config entry-point: supply only secrets. Provides sensible identity defaults
/// that pass <see cref="UpdateRequest.Validate"/>; the real identity metadata is
/// discovered later by <see cref="ClientStrategy"/> from <c>generalupdate.manifest.json</c>.
/// discovered later by <see cref="ClientStrategy"/> or <see cref="OssStrategy"/>
/// from <c>generalupdate.manifest.json</c>. Works with all <see cref="AppType"/>
/// modes (<c>Client</c>, <c>Upgrade</c>, <c>OssClient</c>, <c>OssUpgrade</c>).
/// </summary>
/// <param name="updateUrl">The remote endpoint for update validation or version config retrieval.</param>
/// <param name="appSecretKey">The application secret key used for authentication.</param>
/// <param name="reportUrl">Optional URL for reporting update status back to the server.</param>
/// <param name="scheme">Optional URL scheme override (e.g. <c>"https"</c>).</param>
/// <param name="token">Optional authentication token for API requests.</param>
/// <param name="installPath">
/// Optional override for the installation root directory. When <c>null</c>,
/// defaults to <see cref="AppDomain.CurrentDomain.BaseDirectory"/>. Set this when
/// the current process runs from a subdirectory (e.g. the OSS upgrade process in
/// <c>update/</c>) and <c>generalupdate.manifest.json</c> lives in the parent.
/// </param>
/// <returns>This bootstrap instance for chaining.</returns>
public GeneralUpdateBootstrap SetSource(
string updateUrl,
string appSecretKey,
string? reportUrl = null,
string? scheme = null,
string? token = null)
string? token = null,
string? installPath = null)
{
var config = new UpdateRequest
{
Expand All @@ -250,6 +279,8 @@ public GeneralUpdateBootstrap SetSource(
Scheme = scheme,
ReportUrl = reportUrl
};
if (installPath != null)
config.InstallPath = installPath;
return SetConfig(config);
}

Expand Down
9 changes: 0 additions & 9 deletions src/c#/GeneralUpdate.Core/Configuration/UpdateRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,8 @@ public void Validate()
if (!string.IsNullOrWhiteSpace(UpdateLogUrl) && !Uri.IsWellFormedUriString(UpdateLogUrl, UriKind.Absolute))
throw new ArgumentException("Invalid UpdateLogUrl");

if (string.IsNullOrWhiteSpace(UpdateAppName))
throw new ArgumentException("UpdateAppName cannot be empty");

if (string.IsNullOrWhiteSpace(MainAppName))
throw new ArgumentException("MainAppName cannot be empty");

if (string.IsNullOrWhiteSpace(AppSecretKey))
throw new ArgumentException("AppSecretKey cannot be empty");

if (string.IsNullOrWhiteSpace(ClientVersion))
throw new ArgumentException("ClientVersion cannot be empty");
}
}
}
99 changes: 83 additions & 16 deletions src/c#/GeneralUpdate.Core/Strategy/OssStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,12 @@ public async Task ExecuteAsync()
if (_configInfo == null)
throw new InvalidOperationException("OssStrategy not configured. Call Create() first.");

// Dispatch by role �?no env-var detection needed.
// Fill missing identity fields from generalupdate.manifest.json,
// making the manifest the single source of configuration across
// all update flows (standard ClientStrategy and OssStrategy).
Configuration.AppMetadataDiscoverer.Discover(_configInfo);

// Dispatch by role — no env-var detection needed.
if (_role == AppType.OssUpgrade)
{
await ExecuteUpgradeAsync();
Expand Down Expand Up @@ -189,35 +194,63 @@ private async Task ExecuteClientAsync()
var versionFileName = $"{_configInfo.MainAppName ?? _configInfo.UpdateAppName}_versions.json";
var versionsFilePath = Path.Combine(installPath, versionFileName);

GeneralTracer.Info($"[OssClient] InstallPath={installPath}");
GeneralTracer.Info($"[OssClient] VersionFileName={versionFileName}");
GeneralTracer.Info($"[OssClient] ClientVersion={_configInfo.ClientVersion}");
GeneralTracer.Info($"[OssClient] MainAppName={_configInfo.MainAppName}");
GeneralTracer.Info($"[OssClient] UpdateAppName={_configInfo.UpdateAppName}");

if (!string.IsNullOrEmpty(_configInfo.UpdateUrl))
{
GeneralTracer.Info($"[OssClient] Downloading from {_configInfo.UpdateUrl} ...");
await DownloadVersionConfig(_configInfo.UpdateUrl, versionsFilePath).ConfigureAwait(false);
GeneralTracer.Info($"[OssClient] Downloaded -> {versionsFilePath}");
}

if (!File.Exists(versionsFilePath))
{
GeneralTracer.Info("OssStrategy: version config download failed, aborting.");
GeneralTracer.Info("[OssClient] FAIL: version config file not found after download!");
return;
}

var versions = JsonSerializer.Deserialize(
File.ReadAllText(versionsFilePath),
JsonContext.OssVersionRecordJsonContext.Default.ListOssVersionRecord);
var jsonText = File.ReadAllText(versionsFilePath);
GeneralTracer.Info($"[OssClient] JSON downloaded ({jsonText.Length} chars)");

List<OssVersionRecord> versions;
try
{
versions = JsonSerializer.Deserialize(
jsonText,
JsonContext.OssVersionRecordJsonContext.Default.ListOssVersionRecord);
}
catch (Exception ex)
{
GeneralTracer.Error($"[OssClient] JSON deserialize error: {ex.GetType().Name}: {ex.Message}", ex);
throw;
}

if (versions == null || versions.Count == 0)
{
GeneralTracer.Info("OssStrategy: no versions found, aborting.");
GeneralTracer.Info($"[OssClient] FAIL: no versions found (count={versions?.Count.ToString() ?? "null"})");
return;
}

GeneralTracer.Info($"[OssClient] Deserialized {versions.Count} version(s)");
foreach (var v in versions)
GeneralTracer.Info($" - {v.PacketName} v{v.Version} PubTime={v.PubTime:O}");

Comment on lines +238 to +241
versions = versions.OrderByDescending(x => x.PubTime).ToList();
var latest = versions.First();
GeneralTracer.Info($"[OssClient] Latest: {latest.Version} (PubTime={latest.PubTime:O})");

if (!IsOssUpgrade(_configInfo.ClientVersion, latest.Version))
{
GeneralTracer.Info("OssStrategy: no upgrade needed.");
GeneralTracer.Info($"[OssClient] No upgrade needed: {_configInfo.ClientVersion} >= {latest.Version}");
return;
}

GeneralTracer.Info($"[OssClient] Upgrade needed: {_configInfo.ClientVersion} -> {latest.Version}");

// Resolve upgrade exe: prefer UpdatePath, fall back to InstallPath
var upgradeDir = !string.IsNullOrWhiteSpace(_configInfo.UpdatePath)
? (Path.IsPathRooted(_configInfo.UpdatePath)
Expand All @@ -228,10 +261,28 @@ private async Task ExecuteClientAsync()
? _configInfo.UpdateAppName
: "GeneralUpdate.Upgrade.exe";
var appPath = Path.Combine(upgradeDir, upgradeAppName);
GeneralTracer.Info($"[OssClient] Resolved upgrade path: {appPath}");

// List exe files in the directory to help diagnose missing file issues
try
{
var dirFiles = Directory.GetFiles(upgradeDir, "*.exe").Select(f => Path.GetFileName(f));
GeneralTracer.Info($"[OssClient] *.exe files in {upgradeDir}: [{string.Join(", ", dirFiles)}]");
}
Comment on lines +269 to +271
catch (Exception ex)
{
GeneralTracer.Warn($"[OssClient] Could not list directory {upgradeDir}: {ex.Message}");
}

if (!File.Exists(appPath))
{
GeneralTracer.Error($"[OssClient] FAIL: Upgrade app NOT FOUND at {appPath}");
throw new FileNotFoundException($"Upgrade application not found: {appPath}");
}

GeneralTracer.Info($"[OssClient] Launching upgrade: {appPath}");
Process.Start(appPath);
GeneralTracer.Info("[OssClient] Upgrade launched, exiting.");
await GracefulExit.CurrentProcessAsync().ConfigureAwait(false);
}

Expand Down Expand Up @@ -273,15 +324,6 @@ private async Task ExecuteUpgradeAsync()
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))
{
GeneralTracer.Info("OssStrategy (upgrade): cancelled by hook.");
return;
}

await SafeReportUpdateStartedAsync(ctx).ConfigureAwait(false);

// Build download assets from version config or injected source
List<DownloadAsset> assets;
if (DownloadSource != null)
Expand Down Expand Up @@ -314,13 +356,38 @@ private async Task ExecuteUpgradeAsync()
if (assets.Count == 0)
throw new InvalidOperationException("No assets to download.");

// Compute LastVersion deterministically via Version comparison
// so hooks see the correct TargetVersion regardless of source ordering.
_configInfo.LastVersion = assets
.Select(a => new Version(a.Version))
.Max()!
.ToString();
ctx = BuildUpdateContext();

// Hooks: allow cancellation before download. Called after assets are
// built and LastVersion is set so OnBeforeUpdateAsync sees the real
// TargetVersion for decision-making.
if (!await SafeOnBeforeUpdateAsync(ctx).ConfigureAwait(false))
{
GeneralTracer.Info("OssStrategy (upgrade): cancelled by hook.");
return;
}

await SafeReportUpdateStartedAsync(ctx).ConfigureAwait(false);

GeneralTracer.Debug($"OssStrategy (upgrade): downloading {assets.Count} asset(s).");
await DownloadAssetsAsync(assets, installPath).ConfigureAwait(false);

GeneralTracer.Debug("OssStrategy (upgrade): decompressing.");
var encoding = Encoding.GetEncoding(_configInfo?.Encoding?.CodePage ?? Encoding.UTF8.CodePage);
DecompressAssets(assets, installPath, encoding);

// Update generalupdate.manifest.json ClientVersion so the client
// reads the correct version on next startup, preventing infinite loops.
Configuration.ManifestInfo.TryUpdateVersion(
installPath,
clientVersion: _configInfo.LastVersion);

await SafeOnDownloadCompletedAsync(ctx).ConfigureAwait(false);
await SafeOnAfterUpdateAsync(ctx).ConfigureAwait(false);
await SafeReportUpdateAppliedAsync(ctx).ConfigureAwait(false);
Expand Down
83 changes: 71 additions & 12 deletions tests/ClientTest/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,68 @@
using GeneralUpdate.Core.Hooks;

try
{
await RunOssClientAsync();
/*var isOssMode = args.Length > 0 && args[0] == "--oss";

if (isOssMode)
{
await RunOssClientAsync();
}
else
{
await RunStandardClientAsync();
}*/
}
catch (Exception ex)
{
Console.WriteLine($"FATAL: {ex}");
Console.WriteLine("Press Enter to exit...");
Console.ReadLine();
Environment.Exit(1);
}

Console.WriteLine("Press Enter to exit...");
Console.ReadLine();
Comment on lines +30 to +31

// ═══════════════════════════════════════════════════════════════════
// OSS Client mode — version JSON download → version compare → launch upgrade
// ═══════════════════════════════════════════════════════════════════
static async Task RunOssClientAsync()
{
Console.WriteLine("=== GeneralUpdate OSS Client Test ===");
Console.WriteLine($"Started at {DateTime.Now}");
Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}");

// Only secrets are supplied in code. Identity fields (MainAppName,
// ClientVersion, UpdateAppName, UpdatePath) are read from
// generalupdate.manifest.json by OssStrategy via
// AppMetadataDiscoverer.Discover() — same as the standard flow.
var updateUrl = "http://localhost:5000/packages/versions.json";
var appSecretKey = "dfeb5833-975e-4afb-88f1-6278ee9aeff6";

Console.WriteLine($"UpdateUrl: {updateUrl}");
Console.WriteLine();

await new GeneralUpdateBootstrap()
.SetSource(updateUrl, appSecretKey)
.SetOption(Option.AppType, AppType.OssClient)
.Hooks<ClientTestHooks>()
.AddListenerMultiDownloadStatistics(OnDownloadStatistics)
.AddListenerMultiDownloadCompleted(OnDownloadCompleted)
.AddListenerMultiAllDownloadCompleted(OnAllDownloadCompleted)
.AddListenerMultiDownloadError(OnDownloadError)
.AddListenerException(OnException)
.AddListenerUpdateInfo(OnUpdateInfo)
.LaunchAsync();

Console.WriteLine("OSS Client test completed.");
}

// ═══════════════════════════════════════════════════════════════════
// Standard Client mode — silent poll with IPC handoff to Upgrade
// ═══════════════════════════════════════════════════════════════════
static async Task RunStandardClientAsync()
{
Console.WriteLine("=== GeneralUpdate Client Test (Silent Mode) ===");
Console.WriteLine($"Started at {DateTime.Now}");
Expand Down Expand Up @@ -47,14 +109,12 @@
Console.WriteLine();

// Keep the process alive so the background poll loop can work.
// When the user presses Ctrl+C or Enter, the process exits and
// ProcessExit fires, which triggers the upgrade launch.
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
Console.WriteLine();
Console.WriteLine("[Shutdown] Ctrl+C pressed. Exiting...");
e.Cancel = true; // Prevent immediate kill — let ProcessExit fire
e.Cancel = true;
cts.Cancel();
};

Expand All @@ -67,10 +127,6 @@
// Expected on Ctrl+C — graceful shutdown
}

// Explicitly launch the upgrade process before exiting.
// ProcessExit may not fire reliably in all scenarios (e.g. console Ctrl+C),
// so we call TryLaunchUpgrade() directly as the primary launch path.
// If ProcessExit also fires later, the _updaterStarted guard prevents a double-launch.
Console.WriteLine("[Shutdown] Launching upgrade process...");
if (orchestrator != null && orchestrator.HasPreparedUpdate)
{
Expand All @@ -86,11 +142,10 @@

Console.WriteLine("[Shutdown] Client test exiting gracefully.");
}
catch (Exception ex)
{
Console.WriteLine($"FATAL: {ex}");
Environment.Exit(1);
}

// ═══════════════════════════════════════════════════════════════════
// Event handlers (shared across both modes)
// ═══════════════════════════════════════════════════════════════════

static void OnDownloadStatistics(object sender, MultiDownloadStatisticsEventArgs e)
{
Expand Down Expand Up @@ -136,6 +191,10 @@ static void OnUpdateInfo(object sender, UpdateInfoEventArgs e)
}
}

// ═══════════════════════════════════════════════════════════════════
// Hooks (shared across both modes)
// ═══════════════════════════════════════════════════════════════════

sealed class ClientTestHooks : IUpdateHooks
{
public async Task<bool> OnBeforeUpdateAsync(HookContext ctx)
Expand Down
Loading
Loading