This commit is contained in:
lincube
2026-04-16 14:17:46 +08:00
parent 2f0c178df2
commit 1aaf6cd0e9
21 changed files with 1856 additions and 611 deletions

View File

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

View File

@@ -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);
}
}

View File

@@ -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))
{