mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
试试
This commit is contained in:
@@ -1,29 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using PostHog;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
{
|
||||
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||
private const string PostHogHost = "https://us.i.posthog.com/capture/";
|
||||
private const string PostHogHostUrl = "https://us.i.posthog.com";
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly HttpClient _httpClient = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
private readonly Queue<TelemetryEvent> _eventQueue = new();
|
||||
private readonly object _queueLock = new();
|
||||
private readonly PostHogClient _client;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private Timer? _flushTimer;
|
||||
private bool _isInitialized;
|
||||
@@ -39,6 +33,14 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
|
||||
_client = new PostHogClient(new PostHogOptions
|
||||
{
|
||||
ProjectApiKey = PostHogApiKey,
|
||||
HostUrl = new Uri(PostHogHostUrl),
|
||||
FlushAt = 20,
|
||||
FlushInterval = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
}
|
||||
|
||||
public bool IsUsageEnabled => _isUsageEnabled;
|
||||
@@ -56,7 +58,7 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
RefreshEnabledState(forceSessionStart: true);
|
||||
|
||||
_flushTimer = new Timer(
|
||||
_ => FlushEvents(),
|
||||
_ => _ = _client.FlushAsync(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(30));
|
||||
@@ -88,14 +90,12 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
ClearQueuedEvents();
|
||||
StopSessionWithoutSending();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to refresh usage analytics enabled state.", ex);
|
||||
_isUsageEnabled = false;
|
||||
ClearQueuedEvents();
|
||||
StopSessionWithoutSending();
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
EndSession(source, isRestart);
|
||||
}
|
||||
|
||||
FlushEvents();
|
||||
_ = _client.FlushAsync();
|
||||
AppLogger.Info(
|
||||
"PostHogUsage",
|
||||
$"Usage telemetry shutdown complete. Source='{source}'; Restart='{isRestart}'; Enabled={_isUsageEnabled}.");
|
||||
@@ -291,16 +291,13 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
_flushTimer?.Dispose();
|
||||
_settingsService.Changed -= OnSettingsChanged;
|
||||
Shutdown(isRestart: false, source: "Dispose");
|
||||
FlushEvents();
|
||||
_cts.Cancel();
|
||||
_client.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Error disposing usage telemetry service.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureBaselineEventSent()
|
||||
@@ -313,66 +310,35 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (SendBaselineEventToPostHog(identity.InstallId, now))
|
||||
var distinctId = identity.InstallId;
|
||||
var personProps = new Dictionary<string, object?>
|
||||
{
|
||||
identity.MarkBaselineReported();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendBaselineEventToPostHog(string installId, DateTimeOffset timestamp)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new Dictionary<string, object?>
|
||||
{
|
||||
["api_key"] = PostHogApiKey,
|
||||
["event"] = "app_first_launch",
|
||||
["distinct_id"] = installId,
|
||||
["timestamp"] = timestamp.ToString("o"),
|
||||
["properties"] = new Dictionary<string, object?>
|
||||
{
|
||||
["install_id"] = installId,
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
|
||||
["launch_time_utc"] = timestamp.ToString("o")
|
||||
}
|
||||
["install_id"] = identity.InstallId,
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
_ = _client.IdentifyAsync(distinctId, personProps, null, _cts.Token);
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
_client.Capture(
|
||||
distinctId,
|
||||
"app_first_launch",
|
||||
personProps,
|
||||
groups: null,
|
||||
sendFeatureFlags: false);
|
||||
|
||||
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PostHogUsage",
|
||||
$"PostHog baseline event failed: {response.StatusCode} - {responseBody}");
|
||||
return false;
|
||||
}
|
||||
|
||||
AppLogger.Info("PostHogUsage", "Sent first-launch baseline event.");
|
||||
return true;
|
||||
_ = _client.FlushAsync();
|
||||
identity.MarkBaselineReported();
|
||||
AppLogger.Info("PostHogUsage", "Sent first-launch baseline event via SDK.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,137 +445,60 @@ public sealed class PostHogUsageTelemetryService : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var eventData = new TelemetryEvent(
|
||||
eventName,
|
||||
TelemetryIdentityService.Instance.TelemetryId,
|
||||
TelemetryIdentityService.Instance.InstallId,
|
||||
TelemetryIdentityService.Instance.TelemetryId,
|
||||
_sessionId,
|
||||
Interlocked.Increment(ref _sequence),
|
||||
DateTimeOffset.UtcNow,
|
||||
payload ?? new Dictionary<string, object?>(),
|
||||
stateBefore,
|
||||
stateAfter);
|
||||
var identity = TelemetryIdentityService.Instance;
|
||||
var distinctId = identity.TelemetryId;
|
||||
var seq = Interlocked.Increment(ref _sequence);
|
||||
|
||||
lock (_queueLock)
|
||||
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
_eventQueue.Enqueue(eventData);
|
||||
["install_id"] = identity.InstallId,
|
||||
["telemetry_id"] = identity.TelemetryId,
|
||||
["session_id"] = _sessionId,
|
||||
["sequence"] = seq,
|
||||
["timestamp_utc"] = DateTimeOffset.UtcNow.ToString("o"),
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage()
|
||||
};
|
||||
|
||||
if (payload is not null)
|
||||
{
|
||||
foreach (var kvp in payload)
|
||||
{
|
||||
properties[$"payload_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateBefore is not null && stateBefore.Count > 0)
|
||||
{
|
||||
foreach (var kvp in stateBefore)
|
||||
{
|
||||
properties[$"state_before_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateAfter is not null && stateAfter.Count > 0)
|
||||
{
|
||||
foreach (var kvp in stateAfter)
|
||||
{
|
||||
properties[$"state_after_{kvp.Key}"] = kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
_client.Capture(
|
||||
distinctId,
|
||||
eventName,
|
||||
properties,
|
||||
groups: null,
|
||||
sendFeatureFlags: false);
|
||||
|
||||
if (forceFlush)
|
||||
{
|
||||
FlushEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldFlush = false;
|
||||
lock (_queueLock)
|
||||
{
|
||||
shouldFlush = _eventQueue.Count >= 20;
|
||||
}
|
||||
|
||||
if (shouldFlush)
|
||||
{
|
||||
FlushEvents();
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushEvents()
|
||||
{
|
||||
List<TelemetryEvent> eventsToSend;
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_eventQueue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
eventsToSend = new List<TelemetryEvent>();
|
||||
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
|
||||
{
|
||||
eventsToSend.Add(_eventQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var telemetryEvent in eventsToSend)
|
||||
{
|
||||
if (!SendEventToPostHog(telemetryEvent, flushImmediately: false))
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to send PostHog event '{telemetryEvent.EventName}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", "Failed to send queued events to PostHog.", ex);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
foreach (var evt in eventsToSend)
|
||||
{
|
||||
if (_eventQueue.Count >= 100)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_eventQueue.Enqueue(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendEventToPostHog(TelemetryEvent telemetryEvent, bool flushImmediately)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestBody = new Dictionary<string, object?>
|
||||
{
|
||||
["api_key"] = PostHogApiKey,
|
||||
["event"] = telemetryEvent.EventName,
|
||||
["distinct_id"] = telemetryEvent.DistinctId,
|
||||
["timestamp"] = telemetryEvent.Timestamp.ToString("o"),
|
||||
["properties"] = telemetryEvent.ToPostHogProperties()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
using var content = new ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"PostHogUsage",
|
||||
$"PostHog event '{telemetryEvent.EventName}' failed: {response.StatusCode} - {responseBody}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flushImmediately)
|
||||
{
|
||||
AppLogger.Info("PostHogUsage", $"Sent event '{telemetryEvent.EventName}' immediately.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PostHogUsage", $"Failed to send PostHog event '{telemetryEvent.EventName}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearQueuedEvents()
|
||||
{
|
||||
lock (_queueLock)
|
||||
{
|
||||
_eventQueue.Clear();
|
||||
_ = _client.FlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed record TelemetryEvent(
|
||||
string EventName,
|
||||
string DistinctId,
|
||||
string InstallId,
|
||||
string TelemetryId,
|
||||
string SessionId,
|
||||
long Sequence,
|
||||
DateTimeOffset Timestamp,
|
||||
IReadOnlyDictionary<string, object?> Payload,
|
||||
IReadOnlyDictionary<string, object?>? StateBefore = null,
|
||||
IReadOnlyDictionary<string, object?>? StateAfter = null)
|
||||
{
|
||||
public Dictionary<string, object?> ToPostHogProperties()
|
||||
{
|
||||
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["install_id"] = InstallId,
|
||||
["telemetry_id"] = TelemetryId,
|
||||
["session_id"] = SessionId,
|
||||
["sequence"] = Sequence,
|
||||
["timestamp_utc"] = Timestamp.ToString("o"),
|
||||
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
|
||||
["payload"] = Copy(Payload)
|
||||
};
|
||||
|
||||
if (StateBefore is not null && StateBefore.Count > 0)
|
||||
{
|
||||
properties["state_before"] = Copy(StateBefore);
|
||||
}
|
||||
|
||||
if (StateAfter is not null && StateAfter.Count > 0)
|
||||
{
|
||||
properties["state_after"] = Copy(StateAfter);
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> Copy(IReadOnlyDictionary<string, object?> source)
|
||||
{
|
||||
return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
@@ -47,6 +49,13 @@ public sealed class UpdateWorkflowService
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly string _updatesDirectory;
|
||||
|
||||
private const string LauncherDirectoryName = ".launcher";
|
||||
private const string UpdateDirectoryName = "update";
|
||||
private const string IncomingDirectoryName = "incoming";
|
||||
private const string DeltaManifestFileName = "files.json";
|
||||
private const string DeltaSignatureFileName = "files.json.sig";
|
||||
private const string DeltaArchiveFileName = "update.zip";
|
||||
|
||||
public UpdateWorkflowService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
@@ -56,6 +65,175 @@ public sealed class UpdateWorkflowService
|
||||
"Updates");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the Launcher's incoming update directory where delta packages should be placed.
|
||||
/// </summary>
|
||||
public static string GetLauncherIncomingDirectory()
|
||||
{
|
||||
// The app runs from app-{version}/ subdirectory; Launcher root is one level up.
|
||||
var appBaseDir = AppContext.BaseDirectory;
|
||||
var launcherRoot = Path.GetDirectoryName(appBaseDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.IsNullOrWhiteSpace(launcherRoot))
|
||||
{
|
||||
launcherRoot = appBaseDir;
|
||||
}
|
||||
return Path.Combine(launcherRoot, LauncherDirectoryName, UpdateDirectoryName, IncomingDirectoryName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a GitHub Release contains delta update assets (files.json, files.json.sig, update.zip).
|
||||
/// </summary>
|
||||
public static bool IsDeltaUpdateAvailable(GitHubReleaseInfo release)
|
||||
{
|
||||
if (release is null || release.Assets is null || release.Assets.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var assetNames = release.Assets.Select(a => a.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return assetNames.Contains(DeltaManifestFileName)
|
||||
&& assetNames.Contains(DeltaSignatureFileName)
|
||||
&& assetNames.Contains(DeltaArchiveFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the delta update package (files.json, files.json.sig, update.zip) from a GitHub Release
|
||||
/// and places them in the Launcher's incoming directory for the Launcher to apply on next startup.
|
||||
/// </summary>
|
||||
public async Task<UpdateDownloadResult> DownloadDeltaUpdateAsync(
|
||||
UpdateCheckResult checkResult,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(checkResult);
|
||||
|
||||
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "No update available for delta download.");
|
||||
}
|
||||
|
||||
if (!IsDeltaUpdateAvailable(checkResult.Release))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "Release does not contain delta update assets.");
|
||||
}
|
||||
|
||||
var incomingDir = GetLauncherIncomingDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(incomingDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, $"Failed to create incoming directory: {ex.Message}");
|
||||
}
|
||||
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var downloadSource = state.UpdateDownloadSource;
|
||||
var downloadThreads = state.UpdateDownloadThreads;
|
||||
|
||||
var requiredAssets = new Dictionary<string, GitHubReleaseAsset>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[DeltaManifestFileName] = null!,
|
||||
[DeltaSignatureFileName] = null!,
|
||||
[DeltaArchiveFileName] = null!
|
||||
};
|
||||
|
||||
foreach (var asset in checkResult.Release.Assets)
|
||||
{
|
||||
if (requiredAssets.ContainsKey(asset.Name))
|
||||
{
|
||||
requiredAssets[asset.Name] = asset;
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredAssets.Any(kvp => kvp.Value is null))
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, "One or more delta assets not found in release.");
|
||||
}
|
||||
|
||||
var totalAssets = requiredAssets.Count;
|
||||
var completedAssets = 0;
|
||||
|
||||
foreach (var (name, asset) in requiredAssets)
|
||||
{
|
||||
var destinationPath = Path.Combine(incomingDir, name);
|
||||
|
||||
// Skip if already downloaded and file exists
|
||||
if (File.Exists(destinationPath))
|
||||
{
|
||||
var existingHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(destinationPath, cancellationToken);
|
||||
if (asset.Sha256 is not null && string.Equals(existingHash, asset.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", $"Delta asset {name} already downloaded with matching hash, skipping.");
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var assetProgress = progress is null ? null : new Progress<double>(p =>
|
||||
{
|
||||
var overallProgress = ((double)completedAssets + p) / totalAssets;
|
||||
progress.Report(overallProgress);
|
||||
});
|
||||
|
||||
var result = await _settingsFacade.Update.DownloadAssetAsync(
|
||||
asset,
|
||||
destinationPath,
|
||||
downloadSource,
|
||||
downloadThreads,
|
||||
assetProgress,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
// Clean up partially downloaded files
|
||||
foreach (var file in requiredAssets.Keys)
|
||||
{
|
||||
try { File.Delete(Path.Combine(incomingDir, file)); } catch { }
|
||||
}
|
||||
return new UpdateDownloadResult(false, null, $"Failed to download delta asset {name}: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
completedAssets++;
|
||||
progress?.Report((double)completedAssets / totalAssets);
|
||||
}
|
||||
|
||||
// Save state indicating a delta update is pending
|
||||
SaveState(state with
|
||||
{
|
||||
PendingUpdateInstallerPath = Path.Combine(incomingDir, DeltaManifestFileName),
|
||||
PendingUpdateVersion = checkResult.LatestVersionText,
|
||||
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
|
||||
? null
|
||||
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
|
||||
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
PendingUpdateSha256 = null
|
||||
});
|
||||
|
||||
AppLogger.Info("UpdateWorkflow", $"Delta update package downloaded to {incomingDir}. Will be applied by Launcher on next startup.");
|
||||
|
||||
return new UpdateDownloadResult(true, Path.Combine(incomingDir, DeltaManifestFileName), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the pending update is a delta update (files.json in incoming dir) vs a full installer.
|
||||
/// </summary>
|
||||
public bool IsPendingDeltaUpdate()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
var pendingPath = state.PendingUpdateInstallerPath?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(pendingPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delta updates are identified by the manifest file path
|
||||
return pendingPath.EndsWith(DeltaManifestFileName, StringComparison.OrdinalIgnoreCase)
|
||||
|| pendingPath.Contains(IncomingDirectoryName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public UpdatePendingInfo? GetPendingUpdate()
|
||||
{
|
||||
var state = _settingsFacade.Update.Get();
|
||||
@@ -261,7 +439,7 @@ public sealed class UpdateWorkflowService
|
||||
{
|
||||
// Always check for updates on startup (removed AutoCheckUpdates check)
|
||||
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
|
||||
if (!result.Success || !result.IsUpdateAvailable || result.Release is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -272,7 +450,16 @@ public sealed class UpdateWorkflowService
|
||||
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
|
||||
// Prefer delta update if available (smaller download, faster)
|
||||
if (IsDeltaUpdateAvailable(result.Release))
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", "Delta update available, downloading incremental package.");
|
||||
await DownloadDeltaUpdateAsync(result, cancellationToken: cancellationToken);
|
||||
}
|
||||
else if (result.PreferredAsset is not null)
|
||||
{
|
||||
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
// For "Manual" mode, just check but don't download
|
||||
}
|
||||
@@ -302,6 +489,15 @@ public sealed class UpdateWorkflowService
|
||||
return false;
|
||||
}
|
||||
|
||||
// For delta updates, the files are already in .launcher/update/incoming/.
|
||||
// Just exit the app - the Launcher will detect and apply the update on next startup.
|
||||
if (IsPendingDeltaUpdate())
|
||||
{
|
||||
AppLogger.Info("UpdateWorkflow", "Delta update pending in incoming directory. Exiting to let Launcher apply on next startup.");
|
||||
ClearPendingUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
var result = LaunchPendingInstaller(silent: true, exitApplicationAfterLaunch: false);
|
||||
if (!result.Success && !string.IsNullOrWhiteSpace(result.ErrorMessage))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user