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
8 changes: 8 additions & 0 deletions src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ public class GeneralUpdateBootstrap : AbstractBootstrap<GeneralUpdateBootstrap,
private Func<UpdateInfoEventArgs, bool>? _updatePrecheck;
private CancellationTokenSource? _cts;
private DiffPipelineBuilder? _diffPipelineBuilder;
private Silent.SilentPollOrchestrator? _silentOrchestrator;

/// <summary>
/// When silent mode is active, provides access to the background polling orchestrator.
/// Returns <c>null</c> in non-silent modes or before <see cref="LaunchAsync"/> is called.
/// </summary>
public Silent.SilentPollOrchestrator? SilentOrchestrator => _silentOrchestrator;

public GeneralUpdateBootstrap()
{
Expand Down Expand Up @@ -383,6 +390,7 @@ private async Task<GeneralUpdateBootstrap> LaunchSilentAsync()
};

var orchestrator = new Silent.SilentPollOrchestrator(strategy, _configInfo, silentOptions);
_silentOrchestrator = orchestrator;
await orchestrator.StartAsync().ConfigureAwait(false);
GeneralTracer.Info("GeneralUpdateBootstrap: silent update mode started, returning to caller.");

Expand Down
19 changes: 13 additions & 6 deletions src/c#/GeneralUpdate.Core/Configuration/AppMetadataDiscoverer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,26 @@ public static void Discover(UpdateContext context)

if (manifest == null) return;

// Only fill empty fields — caller-provided values take precedence.
if (string.IsNullOrWhiteSpace(context.MainAppName) && !string.IsNullOrWhiteSpace(manifest.MainAppName))
// Identity fields whose defaults are mere fallbacks, not explicit
// user choices. If the manifest has a value, it MUST take precedence —
// otherwise the default blocks the manifest value and causes issues:
// • MainAppName "Client" → can't find the real executable
// • UpdateAppName "Update.exe" → can't launch the upgrade process
// • ClientVersion "1.0.0.0" → endless update loop (version never updates)
if (!string.IsNullOrWhiteSpace(manifest.MainAppName))
context.MainAppName = manifest.MainAppName;
if (string.IsNullOrWhiteSpace(context.UpdateAppName) && !string.IsNullOrWhiteSpace(manifest.UpdateAppName))
if (!string.IsNullOrWhiteSpace(manifest.UpdateAppName))
context.UpdateAppName = manifest.UpdateAppName;
if (string.IsNullOrWhiteSpace(context.ClientVersion) && !string.IsNullOrWhiteSpace(manifest.ClientVersion))
if (!string.IsNullOrWhiteSpace(manifest.UpdatePath))
context.UpdatePath = manifest.UpdatePath;
if (!string.IsNullOrWhiteSpace(manifest.ClientVersion))
context.ClientVersion = manifest.ClientVersion;

// Remaining fields — only fill when empty (caller-provided values win).
if (string.IsNullOrWhiteSpace(context.UpgradeClientVersion) && !string.IsNullOrWhiteSpace(manifest.UpgradeClientVersion))
context.UpgradeClientVersion = manifest.UpgradeClientVersion;
if (string.IsNullOrWhiteSpace(context.ProductId) && !string.IsNullOrWhiteSpace(manifest.ProductId))
context.ProductId = manifest.ProductId;
if (string.IsNullOrWhiteSpace(context.UpdatePath) && !string.IsNullOrWhiteSpace(manifest.UpdatePath))
context.UpdatePath = manifest.UpdatePath;
if (context.AppType == null && !string.IsNullOrWhiteSpace(manifest.AppType)
&& Enum.TryParse<AppType>(manifest.AppType, out var at))
context.AppType = at;
Expand Down
38 changes: 38 additions & 0 deletions src/c#/GeneralUpdate.Core/Silent/SilentPollOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,44 @@ private void OnProcessExit(object? sender, EventArgs e)
}
}

/// <summary>
/// Explicitly attempts to launch the upgrade process if an update was prepared.
/// Call this before process exit as a fallback for environments where
/// <see cref="AppDomain.ProcessExit"/> does not fire reliably (e.g. console
/// apps terminated via Ctrl+C).
/// </summary>
/// <returns><c>true</c> if the upgrade process was launched; <c>false</c> otherwise.</returns>
public bool TryLaunchUpgrade()
{
if (Volatile.Read(ref _prepared) != 1 || Interlocked.Exchange(ref _updaterStarted, 1) == 1)
return false;

try
{
if (!_strategy.HasPreparedClientUpdate)
{
GeneralTracer.Info("SilentPollOrchestrator: no client packages staged, skipping upgrade launch.");
return false;
}

_strategy.LaunchUpgradeProcessSync();
GeneralTracer.Info("SilentPollOrchestrator: upgrade process launched via ClientStrategy (explicit).");
return true;
}
catch (Exception ex)
{
GeneralTracer.Error("SilentPollOrchestrator: TryLaunchUpgrade failed.", ex);
Console.Error.WriteLine($"[SilentPollOrchestrator] Failed to launch upgrade: {ex.Message}");
return false;
}
}

/// <summary>
/// Whether the orchestrator has prepared a client update and is waiting for
/// process exit to launch the upgrade process.
/// </summary>
public bool HasPreparedUpdate => Volatile.Read(ref _prepared) == 1 && _strategy.HasPreparedClientUpdate;

/// <summary>
/// Releases all resources used by the <see cref="SilentPollOrchestrator"/>.
/// </summary>
Expand Down
24 changes: 24 additions & 0 deletions tests/ClientTest/ClientTest.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,28 @@
<ProjectReference Include="..\..\src\c#\GeneralUpdate.Core\GeneralUpdate.Core.csproj" />
</ItemGroup>

<ItemGroup>
<None Update="generalupdate.manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

<!-- Copy UpgradeTest output as Update.exe into ClientTest output for silent update testing -->
<Target Name="CopyUpgradeTest" AfterTargets="Build">
<PropertyGroup>
<UpgradeTestDir>..\UpgradeTest\bin\$(Configuration)\net10.0</UpgradeTestDir>
</PropertyGroup>
<ItemGroup>
<UpgradeTestFiles Include="$(UpgradeTestDir)\**\*" Exclude="$(UpgradeTestDir)\**\*.pdb;$(UpgradeTestDir)\**\*.json" />
</ItemGroup>
<Copy SourceFiles="@(UpgradeTestFiles)"
DestinationFolder="$(OutputPath)"
SkipUnchangedFiles="true"
Condition="Exists('$(UpgradeTestDir)')" />
<Exec Command="copy /Y &quot;$(OutputPath)UpgradeTest.exe&quot; &quot;$(OutputPath)Update.exe&quot;"
Condition="Exists('$(OutputPath)UpgradeTest.exe') AND '$(OS)' == 'Windows_NT'" />
<Message Text="[ClientTest] UpgradeTest files copied to output (Update.exe ready)." Importance="high"
Condition="Exists('$(OutputPath)Update.exe')" />
</Target>

</Project>
61 changes: 56 additions & 5 deletions tests/ClientTest/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

try
{
Console.WriteLine("=== GeneralUpdate Client Test ===");
Console.WriteLine("=== GeneralUpdate Client Test (Silent Mode) ===");
Console.WriteLine($"Started at {DateTime.Now}");
Console.WriteLine($"Running from: {AppDomain.CurrentDomain.BaseDirectory}");

Expand All @@ -17,13 +17,15 @@
var appSecretKey = Environment.GetEnvironmentVariable("APP_SECRET_KEY") ?? "dfeb5833-975e-4afb-88f1-6278ee9aeff6";

Console.WriteLine($"UpdateUrl: {updateUrl}");
Console.WriteLine($"Silent mode: ENABLED (poll every 1 minute)");
Console.WriteLine();

// Identity metadata (MainAppName, ClientVersion, UpdateAppName, UpdatePath, …)
// is auto-discovered from generalupdate.manifest.json (generated by GeneralUpdate.Tools).
await new GeneralUpdateBootstrap()
// Silent mode: polls server in background, prepares update, launches Upgrade on exit.
var bootstrap = await new GeneralUpdateBootstrap()
.SetSource(updateUrl, appSecretKey, reportUrl)
.SetOption(Option.AppType, AppType.Client)
.SetOption(Option.Silent, true)
.SetOption(Option.SilentPollIntervalMinutes, 1)
.Hooks<ClientTestHooks>()
.AddListenerMultiDownloadStatistics(OnDownloadStatistics)
.AddListenerMultiDownloadCompleted(OnDownloadCompleted)
Expand All @@ -33,7 +35,56 @@
.AddListenerUpdateInfo(OnUpdateInfo)
.LaunchAsync();

Console.WriteLine("Client test completed.");
var orchestrator = bootstrap.SilentOrchestrator;

Console.WriteLine();
Console.WriteLine("╔════════════════════════════════════════════╗");
Console.WriteLine("║ Silent poll running in background. ║");
Console.WriteLine("║ Press Ctrl+C or Enter to exit. ║");
Console.WriteLine("║ On exit, Upgrade process will be launched ║");
Console.WriteLine("║ if an update has been prepared. ║");
Console.WriteLine("╚════════════════════════════════════════════╝");
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
cts.Cancel();
};

try
{
await Task.Delay(Timeout.Infinite, cts.Token);
}
catch (OperationCanceledException)
{
// 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)
{
var launched = orchestrator.TryLaunchUpgrade();
Console.WriteLine(launched
? "[Shutdown] Upgrade process launched successfully."
: "[Shutdown] No update prepared or upgrade already launched.");
}
else
{
Console.WriteLine("[Shutdown] No orchestrator or no update prepared.");
}

Console.WriteLine("[Shutdown] Client test exiting gracefully.");
}
catch (Exception ex)
{
Expand Down
9 changes: 9 additions & 0 deletions tests/ClientTest/generalupdate.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"mainAppName": "ClientTest.exe",
"clientVersion": "1.0.0",
"appType": "Client",
"updateAppName": "UpgradeTest.exe",
"upgradeClientVersion": "2.0.0.0",
"productId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a",
"updatePath": "update/"
}
Loading