Launcher (#4)

* 激进的更新

* 试试

* fix.可爱的我一直在修CI(

* fix.启动器一定要能够启动

* feat.尝试弄了AOT的启动器。

* fix.修CI,好像是因为Linux那边有个问题,反正修就对了。

* fix.ci难修,为什么liunx跑不起来呢?

* Update build.yml

* Update LanMountainDesktop.csproj

* changed.调整了启动逻辑,优化了更新页面。

* changed.优化了更新体验

* feat.依旧试增量更新这一块,看看velopack

* fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。

* fix.继续修ci,ci怎么天天炸

* changed.velopack,试试rust

* fix.修ci,修融合桌面,修启动器

* fix.GitHub Action工作流怎么天天出问题

* feat.引入velopack,不好,是rust(至少内存很安全了。

* chore: migrate release pipeline to signed filemap and wire rainyun s3

* fix: make optional s3 upload step workflow-parse safe

* fix: make delta pack generation robust for empty diffs and linux paths

* chore: rotate launcher update public key for pdc signing

* fix: restore stable launcher update public key

* fix: sync launcher public key with update signing secret

* fix: normalize PEM line endings in signing key validation

* fix: rotate launcher public key to match ci signing secret

* fix: compare signing keys by SPKI instead of PEM text

* refactor update backend to host-managed PDC pipeline

* fix release workflow env key collisions

* relax publish-pdc precheck to require S3 only

* set GH_TOKEN for PDCC installer step

* ci: add local pdc mock fallback for release publish

* ci: fix pdc mock process log redirection

* ci: fallback pdcc signing key to update private key

* ci: ensure pdcc signing passphrase env is always set

* ci: create pdcc publish root before invoking client

* ci: set pdcc version variable from release version

* ci: decouple pdcc installer version from publish config version

* ci: package pdcc subchannels with generated filemap and changelog

* ci: make local pdc mock diff return empty for fast fallback

* ci: fix pdcc variable mapping and pdc signing prechecks

* Update App.axaml.cs

* ci: wire aws cli credentials for rainyun s3

* ci: pin pdcc client version separately from app version

* ci: harden local pdc mock transport handling

* ci: publish pdcc subchannels in one pass

* ci: add pdcc publish heartbeat and timeout

* ci: fix pdcc publish workdir bootstrap

* feat.Penguin Logistics Online Network Distribution System

* ci: fix plonds s3 probe and signing fallback

* ci: validate signing key and quiet missing baselines

* ci: relax aws checksum mode for rainyun s3

* ci: avoid multipart uploads to rainyun s3

* ci: handle empty plonds baselines safely

* ci.plonds

* Rebuild release pipeline around PLONDS and DDSS

* Fix Windows installer script path in release workflow
This commit is contained in:
lincube
2026-04-21 20:59:52 +08:00
committed by GitHub
parent 03e32ee6cb
commit 4cb52e56c7
186 changed files with 44656 additions and 1248 deletions

View File

@@ -100,12 +100,15 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
var args = new System.Text.StringBuilder();
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -122,13 +125,16 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo
{
FileName = dotnetHostPath,
UseShellExecute = false,
UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
// UseShellExecute=true 时使用 Arguments 字符串
var args = new System.Text.StringBuilder();
args.Append(QuoteArgument(entryAssemblyPath));
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo;
}
@@ -145,11 +151,61 @@ public static class AppRestartService
}
}
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
if (builder.Length > 0) builder.Append(' ');
builder.Append(QuoteArgument(commandLineArgs[i]));
}
}
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
{
if (builder.Length > 0) builder.Append(' ');
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;

View File

@@ -117,8 +117,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
if (_widgetWindows.TryGetValue(placement.PlacementId, out var existingWindow))
{
// 已存在,可能只更新位置或尺寸
// 编辑完成后,已有小窗也要同步尺寸,否则会出现“布局已保存但窗口没变”的假象。
existingWindow.Position = new Avalonia.PixelPoint((int)placement.X, (int)placement.Y);
existingWindow.UpdateComponentLayout(placement.Width, placement.Height);
if (existingWindow.IsVisible == false)
{
existingWindow.Show();

View File

@@ -34,7 +34,20 @@ public sealed record UpdateCheckResult(
GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage,
bool ForceMode = false);
bool ForceMode = false,
PlondsUpdatePayload? PlondsPayload = null);
public sealed record PlondsUpdatePayload(
string DistributionId,
string ChannelId,
string SubChannel,
string? FileMapJson,
string? FileMapSignature,
string? FileMapJsonUrl,
string? FileMapSignatureUrl,
string? UpdateArchiveUrl = null,
string? UpdateArchiveSha256 = null,
long? UpdateArchiveSizeBytes = null);
public sealed record UpdateDownloadResult(
bool Success,
@@ -149,6 +162,9 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var preferredAsset = isUpdateAvailable
? SelectPreferredInstallerAsset(release.Assets)
: null;
var plondsPayload = isUpdateAvailable
? TryResolvePlondsPayload(release)
: null;
return new UpdateCheckResult(
Success: true,
@@ -157,7 +173,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null);
ErrorMessage: null,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -222,6 +239,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
: release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
var plondsPayload = TryResolvePlondsPayload(release);
return new UpdateCheckResult(
Success: true,
@@ -231,7 +249,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: true);
ForceMode: true,
PlondsPayload: plondsPayload);
}
catch (OperationCanceledException)
{
@@ -642,7 +661,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
if (assets is null || assets.Count == 0)
{
return null;
}
@@ -654,12 +673,95 @@ public sealed class GitHubReleaseUpdateService : IDisposable
_ => "x64"
};
var ranked = assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.ToList();
if (OperatingSystem.IsWindows())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return ranked.FirstOrDefault(x => x.Score > 0).Asset;
if (OperatingSystem.IsLinux())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreLinuxInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
if (OperatingSystem.IsMacOS())
{
return assets
.Select(asset => (Asset: asset, Score: ScoreMacInstallerAsset(asset.Name, architectureToken)))
.OrderByDescending(x => x.Score)
.FirstOrDefault(x => x.Score > 0)
.Asset;
}
return null;
}
private static PlondsUpdatePayload? TryResolvePlondsPayload(GitHubReleaseInfo release)
{
if (release.Assets is null || release.Assets.Count == 0)
{
return null;
}
var platformSuffix = GetPlatformAssetSuffix();
var fileMapAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json");
var signatureAsset = FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.json.sig")
?? FindAsset(release.Assets, $"plonds-filemap-{platformSuffix}.sig");
var archiveAsset = FindAsset(release.Assets, $"update-{platformSuffix}.zip");
if (fileMapAsset is null || signatureAsset is null || archiveAsset is null)
{
return null;
}
var distributionId = $"plonds-{release.TagName.Trim().TrimStart('v')}-{platformSuffix}";
var channelId = release.IsPrerelease
? UpdateSettingsValues.ChannelPreview
: UpdateSettingsValues.ChannelStable;
return new PlondsUpdatePayload(
DistributionId: distributionId,
ChannelId: channelId,
SubChannel: platformSuffix,
FileMapJson: null,
FileMapSignature: null,
FileMapJsonUrl: fileMapAsset.BrowserDownloadUrl,
FileMapSignatureUrl: signatureAsset.BrowserDownloadUrl,
UpdateArchiveUrl: archiveAsset.BrowserDownloadUrl,
UpdateArchiveSha256: archiveAsset.Sha256,
UpdateArchiveSizeBytes: archiveAsset.SizeBytes > 0 ? archiveAsset.SizeBytes : null);
}
private static GitHubReleaseAsset? FindAsset(IReadOnlyList<GitHubReleaseAsset> assets, string assetName)
{
return assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
}
private static string GetPlatformAssetSuffix()
{
var os = OperatingSystem.IsWindows()
? "windows"
: OperatingSystem.IsLinux()
? "linux"
: OperatingSystem.IsMacOS()
? "macos"
: "unknown";
var arch = RuntimeInformation.OSArchitecture switch
{
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => "x64"
};
return $"{os}-{arch}";
}
private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken)
@@ -709,6 +811,94 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return score;
}
private static int ScoreLinuxInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".deb", StringComparison.OrdinalIgnoreCase))
{
score += 220;
}
else if (assetName.EndsWith(".rpm", StringComparison.OrdinalIgnoreCase))
{
score += 180;
}
else if (assetName.EndsWith(".AppImage", StringComparison.OrdinalIgnoreCase))
{
score += 160;
}
else
{
return 0;
}
if (assetName.Contains("linux", StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase) ||
(architectureToken == "x64" && assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase)))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("amd64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static int ScoreMacInstallerAsset(string assetName, string architectureToken)
{
if (string.IsNullOrWhiteSpace(assetName))
{
return 0;
}
var score = 0;
if (assetName.EndsWith(".dmg", StringComparison.OrdinalIgnoreCase))
{
score += 220;
}
else if (assetName.EndsWith(".pkg", StringComparison.OrdinalIgnoreCase))
{
score += 180;
}
else
{
return 0;
}
if (assetName.Contains("mac", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("osx", StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase))
{
score += 40;
}
else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) ||
assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase))
{
score -= 30;
}
return score;
}
private static bool TryParseVersion(string? value, out Version? version)
{
version = null;

View File

@@ -0,0 +1,129 @@
using System.Buffers;
using System.Diagnostics;
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Launcher;
/// <summary>
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcClient : IDisposable
{
private NamedPipeClientStream? _pipeClient;
private bool _isConnected;
private readonly object _writeLock = new();
/// <summary>
/// 是否已连接到 Launcher
/// </summary>
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// </summary>
private const int LengthPrefixSize = 4;
/// <summary>
/// 连接到 Launcher 的 IPC 服务端
/// </summary>
public async Task<bool> ConnectAsync(CancellationToken cancellationToken = default)
{
try
{
_pipeClient = new NamedPipeClientStream(
".",
LauncherIpcConstants.PipeName,
PipeDirection.Out);
await _pipeClient.ConnectAsync(5000, cancellationToken);
_isConnected = true;
return true;
}
catch (TimeoutException)
{
// Launcher 可能没有启动 IPC 服务端,这是正常的
return false;
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to connect to Launcher IPC: {ex.Message}");
return false;
}
}
/// <summary>
/// 报告启动进度(在同一连接上可多次调用)
/// </summary>
public async Task ReportProgressAsync(StartupProgressMessage message)
{
if (!_isConnected || _pipeClient?.IsConnected != true)
return;
try
{
var json = JsonSerializer.Serialize(message);
var payload = System.Text.Encoding.UTF8.GetBytes(json);
// 长度前缀协议:[4字节长度][消息正文]
var lengthPrefix = BitConverter.GetBytes(payload.Length);
Debug.Assert(lengthPrefix.Length == LengthPrefixSize);
// 加锁保证单条消息的长度前缀和正文原子写入
lock (_writeLock)
{
_pipeClient.Write(lengthPrefix, 0, LengthPrefixSize);
_pipeClient.Write(payload, 0, payload.Length);
_pipeClient.Flush();
}
// 将同步写入包装为已完成的 Task
await Task.CompletedTask;
}
catch (IOException)
{
// 管道断开
_isConnected = false;
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
_isConnected = false;
}
}
/// <summary>
/// 检查是否从 Launcher 启动
/// 优先检查环境变量回退到命令行参数UseShellExecute=true 时环境变量仍可继承,
/// 命令行参数作为备选确保兼容性)
/// </summary>
public static bool IsLaunchedByLauncher()
{
// 优先检查环境变量
if (!string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
{
return true;
}
// 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=<value>
foreach (var arg in Environment.GetCommandLineArgs())
{
if (arg.StartsWith($"--{LauncherIpcConstants.LauncherPidEnvVar}=", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public void Dispose()
{
_isConnected = false;
_pipeClient?.Dispose();
}
}

View File

@@ -10,12 +10,12 @@ using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
internal sealed class PluginsInstallHelperClient
internal sealed class LauncherClient
{
private const int UserCanceledUacErrorCode = 1223;
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
private const string LauncherExecutableName = "LanMountainDesktop.Launcher.exe";
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
public async Task<LauncherInstallResult> InstallPackageAsync(
string packagePath,
string pluginsDirectory,
CancellationToken cancellationToken = default)
@@ -25,19 +25,19 @@ internal sealed class PluginsInstallHelperClient
if (!OperatingSystem.IsWindows())
{
return new PluginsInstallHelperResult(
return new LauncherInstallResult(
false,
null,
"Elevated helper install is only supported on Windows.");
}
var helperPath = ResolveHelperPath();
if (!File.Exists(helperPath))
var launcherPath = ResolveLauncherPath();
if (!File.Exists(launcherPath))
{
return new PluginsInstallHelperResult(
return new LauncherInstallResult(
false,
null,
$"Plugins install helper was not found at '{helperPath}'.");
$"Launcher executable was not found at '{launcherPath}'.");
}
var resultPath = Path.Combine(
@@ -50,38 +50,38 @@ internal sealed class PluginsInstallHelperClient
try
{
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
if (process is null)
{
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
return new LauncherInstallResult(false, null, "Failed to start launcher process.");
}
await process.WaitForExitAsync(cancellationToken);
var result = await ReadResultAsync(resultPath, cancellationToken);
if (result is not null)
{
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
return new LauncherInstallResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
}
if (process.ExitCode == 0)
{
return new PluginsInstallHelperResult(
return new LauncherInstallResult(
false,
null,
"Plugins install helper exited without producing a result file.");
"Launcher exited without producing a result file.");
}
return new PluginsInstallHelperResult(
return new LauncherInstallResult(
false,
null,
string.Format(
CultureInfo.InvariantCulture,
"Plugins install helper exited with code {0}.",
"Launcher exited with code {0}.",
process.ExitCode));
}
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
{
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
return new LauncherInstallResult(false, null, "Administrator permission request was canceled.");
}
finally
{
@@ -89,18 +89,18 @@ internal sealed class PluginsInstallHelperClient
}
}
private static Process? StartHelperProcess(
string helperPath,
private static Process? StartLauncherProcess(
string launcherPath,
string packagePath,
string pluginsDirectory,
string resultPath)
{
var startInfo = new ProcessStartInfo
{
FileName = helperPath,
FileName = launcherPath,
Verb = "runas",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
Arguments = string.Create(
CultureInfo.InvariantCulture,
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
@@ -120,9 +120,9 @@ internal sealed class PluginsInstallHelperClient
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
}
private static string ResolveHelperPath()
private static string ResolveLauncherPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
return Path.Combine(AppContext.BaseDirectory, "Launcher", LauncherExecutableName);
}
private static string QuoteArgument(string value)
@@ -180,7 +180,7 @@ internal sealed class PluginsInstallHelperClient
}
}
internal sealed record PluginsInstallHelperResult(
internal sealed record LauncherInstallResult(
bool Success,
string? InstalledPackagePath,
string? ErrorMessage);

View File

@@ -0,0 +1,380 @@
using System.Collections.Concurrent;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载状态管理器 - 管理所有加载项的状态
/// </summary>
public class LoadingStateManager : IDisposable
{
private readonly ConcurrentDictionary<string, LoadingItem> _items = new();
private readonly ConcurrentDictionary<string, DateTimeOffset> _startTimes = new();
private readonly object _lock = new();
private readonly CancellationTokenSource _cts = new();
/// <summary>
/// 状态变更事件
/// </summary>
public event EventHandler<LoadingStateChangedEventArgs>? StateChanged;
/// <summary>
/// 整体进度变更事件
/// </summary>
public event EventHandler<OverallProgressChangedEventArgs>? OverallProgressChanged;
/// <summary>
/// 当前启动阶段
/// </summary>
public StartupStage CurrentStage { get; private set; } = StartupStage.Initializing;
/// <summary>
/// 整体进度百分比
/// </summary>
public int OverallProgressPercent { get; private set; }
/// <summary>
/// 是否正在加载
/// </summary>
public bool IsLoading => _items.Values.Any(i => i.State == LoadingState.InProgress);
/// <summary>
/// 是否有错误
/// </summary>
public bool HasErrors => _items.Values.Any(i => i.State == LoadingState.Failed);
/// <summary>
/// 获取所有加载项
/// </summary>
public IReadOnlyCollection<LoadingItem> GetAllItems() => _items.Values.ToList();
/// <summary>
/// 获取活动的加载项
/// </summary>
public IReadOnlyCollection<LoadingItem> GetActiveItems() =>
_items.Values.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
/// <summary>
/// 注册加载项
/// </summary>
public LoadingItem RegisterItem(
string id,
LoadingItemType type,
string name,
string? description = null,
Dictionary<string, string>? metadata = null)
{
var item = new LoadingItem
{
Id = id,
Type = type,
Name = name,
Description = description,
State = LoadingState.Pending,
ProgressPercent = 0,
Metadata = metadata,
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = item;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = item,
PreviousState = null,
CurrentState = item.State
});
return item;
}
/// <summary>
/// 开始加载
/// </summary>
public void StartItem(string id, string? message = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var startTime = DateTimeOffset.UtcNow;
_startTimes[id] = startTime;
var updatedItem = item with
{
State = LoadingState.InProgress,
StartTime = startTime,
Message = message ?? $"正在加载 {item.Name}...",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 更新进度
/// </summary>
public void UpdateProgress(string id, int percent, string? message = null, int? estimatedRemainingSeconds = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var updatedItem = item with
{
ProgressPercent = Math.Clamp(percent, 0, 100),
Message = message ?? item.Message,
EstimatedRemainingSeconds = estimatedRemainingSeconds,
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = item.State,
CurrentState = updatedItem.State,
IsProgressUpdate = true
});
UpdateOverallProgress();
}
/// <summary>
/// 完成加载
/// </summary>
public void CompleteItem(string id, string? message = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var endTime = DateTimeOffset.UtcNow;
_startTimes.TryRemove(id, out _);
var updatedItem = item with
{
State = LoadingState.Completed,
ProgressPercent = 100,
EndTime = endTime,
Message = message ?? $"{item.Name} 加载完成",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 标记失败
/// </summary>
public void FailItem(string id, string errorMessage, string? details = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var endTime = DateTimeOffset.UtcNow;
_startTimes.TryRemove(id, out _);
var fullErrorMessage = string.IsNullOrEmpty(details)
? errorMessage
: $"{errorMessage}: {details}";
var updatedItem = item with
{
State = LoadingState.Failed,
ErrorMessage = fullErrorMessage,
EndTime = endTime,
Message = $"{item.Name} 加载失败",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 标记超时
/// </summary>
public void TimeoutItem(string id, string? message = null)
{
if (!_items.TryGetValue(id, out var item))
return;
var previousState = item.State;
var endTime = DateTimeOffset.UtcNow;
_startTimes.TryRemove(id, out _);
var updatedItem = item with
{
State = LoadingState.Timeout,
EndTime = endTime,
Message = message ?? $"{item.Name} 加载超时",
Timestamp = DateTimeOffset.UtcNow
};
_items[id] = updatedItem;
StateChanged?.Invoke(this, new LoadingStateChangedEventArgs
{
Item = updatedItem,
PreviousState = previousState,
CurrentState = updatedItem.State
});
UpdateOverallProgress();
}
/// <summary>
/// 设置当前启动阶段
/// </summary>
public void SetStage(StartupStage stage, string? message = null)
{
CurrentStage = stage;
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
{
Stage = stage,
OverallProgressPercent = OverallProgressPercent,
Message = message
});
}
/// <summary>
/// 更新整体进度
/// </summary>
private void UpdateOverallProgress()
{
lock (_lock)
{
var items = _items.Values.ToList();
if (items.Count == 0)
{
OverallProgressPercent = 0;
return;
}
// 计算加权进度
var totalWeight = items.Count;
var completedWeight = items.Count(i => i.State == LoadingState.Completed);
var inProgressWeight = items
.Where(i => i.State == LoadingState.InProgress)
.Sum(i => i.ProgressPercent / 100.0);
var progress = (int)((completedWeight + inProgressWeight) / totalWeight * 100);
OverallProgressPercent = Math.Clamp(progress, 0, 100);
OverallProgressChanged?.Invoke(this, new OverallProgressChangedEventArgs
{
Stage = CurrentStage,
OverallProgressPercent = OverallProgressPercent
});
}
}
/// <summary>
/// 获取加载状态消息
/// </summary>
public LoadingStateMessage GetLoadingStateMessage()
{
var items = _items.Values.ToList();
var activeItems = items.Where(i => i.State is LoadingState.InProgress or LoadingState.Pending).ToList();
var errorItems = items.Where(i => i.State == LoadingState.Failed).ToList();
return new LoadingStateMessage
{
Stage = CurrentStage,
OverallProgressPercent = OverallProgressPercent,
ActiveItems = activeItems,
CompletedCount = items.Count(i => i.State == LoadingState.Completed),
TotalCount = items.Count,
HasErrors = errorItems.Any(),
ErrorMessages = errorItems.Select(i => $"{i.Name}: {i.ErrorMessage}").ToList()
};
}
/// <summary>
/// 清理所有加载项
/// </summary>
public void Clear()
{
_items.Clear();
_startTimes.Clear();
OverallProgressPercent = 0;
}
/// <summary>
/// 检查超时项
/// </summary>
public void CheckTimeouts(TimeSpan timeout)
{
var now = DateTimeOffset.UtcNow;
var timeoutItems = _items.Values
.Where(i => i.State == LoadingState.InProgress && i.StartTime.HasValue)
.Where(i => now - i.StartTime.Value > timeout)
.ToList();
foreach (var item in timeoutItems)
{
TimeoutItem(item.Id, $"{item.Name} 加载超时(超过 {timeout.TotalSeconds} 秒)");
}
}
public void Dispose()
{
_cts.Cancel();
_items.Clear();
_startTimes.Clear();
}
}
/// <summary>
/// 加载状态变更事件参数
/// </summary>
public class LoadingStateChangedEventArgs : EventArgs
{
public required LoadingItem Item { get; init; }
public LoadingState? PreviousState { get; init; }
public required LoadingState CurrentState { get; init; }
public bool IsProgressUpdate { get; init; }
}
/// <summary>
/// 整体进度变更事件参数
/// </summary>
public class OverallProgressChangedEventArgs : EventArgs
{
public StartupStage Stage { get; init; }
public int OverallProgressPercent { get; init; }
public string? Message { get; init; }
}

View File

@@ -0,0 +1,360 @@
using System.Timers;
using LanMountainDesktop.Services.Launcher;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载状态上报器 - 将加载状态实时上报给 Launcher
/// </summary>
public class LoadingStateReporter : IDisposable
{
private readonly LoadingStateManager _manager;
private readonly LauncherIpcClient? _ipcClient;
private readonly System.Timers.Timer _reportTimer;
private readonly object _lock = new();
private bool _isDisposed;
/// <summary>
/// 上报间隔(毫秒)
/// </summary>
public int ReportIntervalMs { get; set; } = 100;
/// <summary>
/// 是否启用批量上报优化
/// </summary>
public bool EnableBatching { get; set; } = true;
/// <summary>
/// 最小上报间隔(毫秒),用于限制高频更新
/// </summary>
public int MinReportIntervalMs { get; set; } = 50;
private DateTimeOffset _lastReportTime = DateTimeOffset.MinValue;
private DetailedProgressMessage? _pendingMessage;
private bool _hasPendingMessage;
public LoadingStateReporter(
LoadingStateManager manager,
LauncherIpcClient? ipcClient = null)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_ipcClient = ipcClient;
// 创建定时上报定时器
_reportTimer = new System.Timers.Timer(ReportIntervalMs);
_reportTimer.Elapsed += OnReportTimerElapsed;
_reportTimer.AutoReset = true;
// 订阅状态变更事件
_manager.StateChanged += OnStateChanged;
_manager.OverallProgressChanged += OnOverallProgressChanged;
}
/// <summary>
/// 启动上报
/// </summary>
public void Start()
{
if (_isDisposed) return;
_reportTimer.Start();
AppLogger.Info("LoadingStateReporter", "Loading state reporter started");
}
/// <summary>
/// 停止上报
/// </summary>
public void Stop()
{
_reportTimer.Stop();
// 发送任何待处理的消息
FlushPendingMessage();
AppLogger.Info("LoadingStateReporter", "Loading state reporter stopped");
}
/// <summary>
/// 立即上报当前状态
/// </summary>
public async Task ReportImmediatelyAsync()
{
if (_isDisposed || _ipcClient == null) return;
var message = CreateDetailedProgressMessage();
await SendMessageAsync(message);
}
/// <summary>
/// 上报单个加载项的进度
/// </summary>
public async Task ReportItemProgressAsync(string itemId, int percent, string? message = null)
{
if (_isDisposed || _ipcClient == null) return;
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item == null) return;
var updatedItem = item with
{
ProgressPercent = percent,
Message = message ?? item.Message,
Timestamp = DateTimeOffset.UtcNow
};
var progressMessage = new DetailedProgressMessage
{
Stage = _manager.CurrentStage,
ProgressPercent = _manager.OverallProgressPercent,
CurrentItem = updatedItem,
AllItems = _manager.GetAllItems().ToList(),
Message = message,
IsMajorUpdate = false
};
await SendMessageAsync(progressMessage);
}
/// <summary>
/// 上报阶段变更
/// </summary>
public async Task ReportStageChangeAsync(StartupStage stage, string? message = null)
{
if (_isDisposed || _ipcClient == null) return;
var progressMessage = new DetailedProgressMessage
{
Stage = stage,
ProgressPercent = _manager.OverallProgressPercent,
AllItems = _manager.GetAllItems().ToList(),
Message = message ?? $"进入阶段: {stage}",
IsMajorUpdate = true
};
await SendMessageAsync(progressMessage);
}
/// <summary>
/// 上报错误
/// </summary>
public async Task ReportErrorAsync(string errorMessage, string? details = null)
{
if (_isDisposed || _ipcClient == null) return;
var fullMessage = string.IsNullOrEmpty(details)
? errorMessage
: $"{errorMessage}: {details}";
var progressMessage = new DetailedProgressMessage
{
Stage = _manager.CurrentStage,
ProgressPercent = _manager.OverallProgressPercent,
AllItems = _manager.GetAllItems().ToList(),
Message = fullMessage,
IsMajorUpdate = true
};
await SendMessageAsync(progressMessage);
}
/// <summary>
/// 状态变更事件处理
/// </summary>
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
{
if (_isDisposed) return;
// 重要状态变更立即上报
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Timeout)
{
_ = Task.Run(async () =>
{
try
{
await ReportImmediatelyAsync();
}
catch (Exception ex)
{
AppLogger.Warn("LoadingStateReporter", $"Failed to report state change: {ex.Message}");
}
});
}
else
{
// 其他状态变更标记为待处理
QueueMessage(CreateDetailedProgressMessage());
}
}
/// <summary>
/// 整体进度变更事件处理
/// </summary>
private void OnOverallProgressChanged(object? sender, OverallProgressChangedEventArgs e)
{
if (_isDisposed) return;
QueueMessage(CreateDetailedProgressMessage(e.Message));
}
/// <summary>
/// 定时上报处理
/// </summary>
private void OnReportTimerElapsed(object? sender, ElapsedEventArgs e)
{
FlushPendingMessage();
}
/// <summary>
/// 将消息加入待处理队列
/// </summary>
private void QueueMessage(DetailedProgressMessage message)
{
if (!EnableBatching)
{
// 如果不启用批量,立即发送
_ = Task.Run(async () => await SendMessageAsync(message));
return;
}
lock (_lock)
{
_pendingMessage = message;
_hasPendingMessage = true;
}
}
/// <summary>
/// 刷新待处理消息
/// </summary>
private void FlushPendingMessage()
{
DetailedProgressMessage? message;
lock (_lock)
{
if (!_hasPendingMessage) return;
message = _pendingMessage;
_pendingMessage = null;
_hasPendingMessage = false;
}
if (message != null)
{
_ = Task.Run(async () =>
{
try
{
await SendMessageAsync(message);
}
catch (Exception ex)
{
AppLogger.Warn("LoadingStateReporter", $"Failed to flush pending message: {ex.Message}");
}
});
}
}
/// <summary>
/// 创建详细的进度消息
/// </summary>
private DetailedProgressMessage CreateDetailedProgressMessage(string? message = null)
{
var activeItems = _manager.GetActiveItems().ToList();
var currentItem = activeItems.FirstOrDefault();
return new DetailedProgressMessage
{
Stage = _manager.CurrentStage,
ProgressPercent = _manager.OverallProgressPercent,
CurrentItem = currentItem,
AllItems = _manager.GetAllItems().ToList(),
Message = message ?? currentItem?.Message,
IsMajorUpdate = false
};
}
/// <summary>
/// 发送消息
/// </summary>
private async Task SendMessageAsync(DetailedProgressMessage message)
{
if (_ipcClient == null) return;
// 检查最小上报间隔
var now = DateTimeOffset.UtcNow;
var elapsed = now - _lastReportTime;
if (elapsed.TotalMilliseconds < MinReportIntervalMs)
{
await Task.Delay(MinReportIntervalMs - (int)elapsed.TotalMilliseconds);
}
try
{
// 转换为 StartupProgressMessage 以保持兼容性
var baseMessage = new StartupProgressMessage
{
Stage = message.Stage,
ProgressPercent = message.ProgressPercent,
Message = FormatMessage(message),
Timestamp = DateTimeOffset.UtcNow
};
await _ipcClient.ReportProgressAsync(baseMessage);
_lastReportTime = DateTimeOffset.UtcNow;
}
catch (Exception ex)
{
AppLogger.Warn("LoadingStateReporter", $"Failed to send message: {ex.Message}");
}
}
/// <summary>
/// 格式化消息
/// </summary>
private string FormatMessage(DetailedProgressMessage message)
{
var parts = new List<string>();
if (message.CurrentItem != null)
{
parts.Add($"[{message.CurrentItem.Type}] {message.CurrentItem.Name}");
if (message.CurrentItem.ProgressPercent > 0)
{
parts.Add($"{message.CurrentItem.ProgressPercent}%");
}
}
if (!string.IsNullOrEmpty(message.Message))
{
parts.Add(message.Message);
}
var completedCount = message.AllItems?.Count(i => i.State == LoadingState.Completed) ?? 0;
var totalCount = message.AllItems?.Count ?? 0;
if (totalCount > 0)
{
parts.Add($"({completedCount}/{totalCount})");
}
return string.Join(" - ", parts);
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
Stop();
_reportTimer.Elapsed -= OnReportTimerElapsed;
_reportTimer.Dispose();
_manager.StateChanged -= OnStateChanged;
_manager.OverallProgressChanged -= OnOverallProgressChanged;
}
}

View File

@@ -0,0 +1,201 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载状态管理使用示例
/// </summary>
public static class LoadingStateUsageExample
{
/// <summary>
/// 示例:插件加载
/// </summary>
public static async Task LoadPluginsExample(LoadingStateManager manager)
{
// 注册插件加载项
var pluginItem = manager.RegisterItem(
"plugins.core",
LoadingItemType.Plugin,
"核心插件",
"加载系统核心插件",
new Dictionary<string, string> { { "version", "1.0.0" } });
// 开始加载
manager.StartItem("plugins.core", "正在下载插件...");
try
{
// 模拟下载进度
for (int i = 0; i <= 100; i += 10)
{
manager.UpdateProgress(
"plugins.core",
i,
$"正在下载... {i}%",
estimatedRemainingSeconds: (100 - i) / 10);
await Task.Delay(100);
}
// 完成加载
manager.CompleteItem("plugins.core", "核心插件加载完成");
}
catch (Exception ex)
{
// 标记失败
manager.FailItem("plugins.core", "插件加载失败", ex.Message);
}
}
/// <summary>
/// 示例:组件加载
/// </summary>
public static async Task LoadComponentsExample(LoadingStateManager manager)
{
var components = new[]
{
("comp.weather", "天气组件"),
("comp.clock", "时钟组件"),
("comp.calendar", "日历组件")
};
foreach (var (id, name) in components)
{
// 注册组件
manager.RegisterItem(id, LoadingItemType.Component, name);
// 开始加载
manager.StartItem(id, $"正在加载 {name}...");
// 模拟加载过程
for (int i = 0; i <= 100; i += 20)
{
manager.UpdateProgress(id, i);
await Task.Delay(50);
}
// 完成
manager.CompleteItem(id, $"{name} 加载完成");
}
}
/// <summary>
/// 示例:网络资源加载
/// </summary>
public static async Task LoadNetworkResourcesExample(LoadingStateManager manager)
{
// 注册网络加载项
manager.RegisterItem(
"network.config",
LoadingItemType.Network,
"配置数据",
"从服务器获取最新配置");
manager.StartItem("network.config", "正在连接服务器...");
try
{
// 模拟网络请求
await Task.Delay(1000);
manager.UpdateProgress("network.config", 50, "正在下载数据...");
await Task.Delay(1000);
manager.CompleteItem("network.config", "配置数据已更新");
}
catch (Exception ex)
{
manager.FailItem("network.config", "网络请求失败", ex.Message);
}
}
/// <summary>
/// 示例:带超时的加载
/// </summary>
public static async Task LoadWithTimeoutExample(
LoadingStateManager manager,
LoadingTimeoutHandler timeoutHandler)
{
// 设置超时时间为 10 秒
timeoutHandler.SetItemTimeout("data.heavy", TimeSpan.FromSeconds(10));
// 注册加载项
manager.RegisterItem(
"data.heavy",
LoadingItemType.Data,
"大数据处理",
"处理大量数据,可能需要较长时间");
// 订阅超时事件
timeoutHandler.ItemTimeout += (s, e) =>
{
Console.WriteLine($"加载项 '{e.ItemName}' 超时!");
};
timeoutHandler.ItemRetry += (s, e) =>
{
Console.WriteLine($"正在重试 '{e.ItemName}' ({e.RetryCount}/{e.MaxRetryCount})...");
};
// 开始加载
manager.StartItem("data.heavy", "正在处理数据...");
// 模拟长时间操作
await Task.Delay(15000);
// 完成
manager.CompleteItem("data.heavy", "数据处理完成");
}
/// <summary>
/// 示例:完整启动流程
/// </summary>
public static async Task FullStartupExample(
LoadingStateManager manager,
LoadingStateReporter reporter,
LoadingTimeoutHandler timeoutHandler)
{
// 启动超时处理器
timeoutHandler.Start();
// 设置阶段
manager.SetStage(StartupStage.Initializing, "开始初始化...");
// 1. 系统初始化
manager.RegisterItem("system.init", LoadingItemType.System, "系统初始化");
manager.StartItem("system.init");
await Task.Delay(500);
manager.CompleteItem("system.init");
// 2. 加载设置
manager.SetStage(StartupStage.LoadingSettings, "正在加载设置...");
manager.RegisterItem("settings.load", LoadingItemType.Settings, "用户设置");
manager.StartItem("settings.load");
await Task.Delay(800);
manager.CompleteItem("settings.load");
// 3. 加载插件
manager.SetStage(StartupStage.LoadingPlugins, "正在加载插件...");
await LoadPluginsExample(manager);
// 4. 加载组件
await LoadComponentsExample(manager);
// 5. 加载网络资源
await LoadNetworkResourcesExample(manager);
// 6. 初始化界面
manager.SetStage(StartupStage.InitializingUI, "正在初始化界面...");
manager.RegisterItem("ui.init", LoadingItemType.System, "界面初始化");
manager.StartItem("ui.init");
await Task.Delay(600);
manager.CompleteItem("ui.init");
// 完成
manager.SetStage(StartupStage.Ready, "加载完成");
// 停止超时处理器
timeoutHandler.Stop();
}
}

View File

@@ -0,0 +1,275 @@
using System.Timers;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading;
/// <summary>
/// 加载超时处理器 - 监控加载项超时并执行相应处理
/// </summary>
public class LoadingTimeoutHandler : IDisposable
{
private readonly LoadingStateManager _manager;
private readonly System.Timers.Timer _checkTimer;
private readonly Dictionary<string, TimeSpan> _itemTimeouts = new();
private readonly Dictionary<string, int> _retryCounts = new();
private readonly object _lock = new();
private bool _isDisposed;
/// <summary>
/// 默认超时时间
/// </summary>
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// 最大重试次数
/// </summary>
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 检查间隔
/// </summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// 超时事件
/// </summary>
public event EventHandler<LoadingTimeoutEventArgs>? ItemTimeout;
/// <summary>
/// 重试事件
/// </summary>
public event EventHandler<LoadingRetryEventArgs>? ItemRetry;
/// <summary>
/// 最终失败事件(超过最大重试次数)
/// </summary>
public event EventHandler<LoadingTimeoutEventArgs>? ItemFailed;
public LoadingTimeoutHandler(LoadingStateManager manager)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_checkTimer = new System.Timers.Timer(CheckInterval.TotalMilliseconds);
_checkTimer.Elapsed += OnCheckTimerElapsed;
_checkTimer.AutoReset = true;
// 订阅状态变更事件
_manager.StateChanged += OnStateChanged;
}
/// <summary>
/// 启动监控
/// </summary>
public void Start()
{
if (_isDisposed) return;
_checkTimer.Start();
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler started");
}
/// <summary>
/// 停止监控
/// </summary>
public void Stop()
{
_checkTimer.Stop();
AppLogger.Info("LoadingTimeoutHandler", "Timeout handler stopped");
}
/// <summary>
/// 为特定加载项设置超时
/// </summary>
public void SetItemTimeout(string itemId, TimeSpan timeout)
{
lock (_lock)
{
_itemTimeouts[itemId] = timeout;
}
}
/// <summary>
/// 获取加载项的超时时间
/// </summary>
public TimeSpan GetItemTimeout(string itemId)
{
lock (_lock)
{
return _itemTimeouts.TryGetValue(itemId, out var timeout) ? timeout : DefaultTimeout;
}
}
/// <summary>
/// 重置重试计数
/// </summary>
public void ResetRetryCount(string itemId)
{
lock (_lock)
{
_retryCounts[itemId] = 0;
}
}
/// <summary>
/// 定时检查超时
/// </summary>
private void OnCheckTimerElapsed(object? sender, ElapsedEventArgs e)
{
if (_isDisposed) return;
try
{
var activeItems = _manager.GetActiveItems().ToList();
var now = DateTimeOffset.UtcNow;
foreach (var item in activeItems)
{
if (!item.StartTime.HasValue) continue;
var timeout = GetItemTimeout(item.Id);
var elapsed = now - item.StartTime.Value;
if (elapsed > timeout)
{
HandleTimeout(item.Id, elapsed);
}
}
}
catch (Exception ex)
{
AppLogger.Warn("LoadingTimeoutHandler", $"Error checking timeouts: {ex.Message}");
}
}
/// <summary>
/// 处理超时
/// </summary>
private void HandleTimeout(string itemId, TimeSpan elapsed)
{
lock (_lock)
{
var retryCount = _retryCounts.GetValueOrDefault(itemId, 0);
if (retryCount < MaxRetryCount)
{
// 重试
_retryCounts[itemId] = retryCount + 1;
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item != null)
{
AppLogger.Warn("LoadingTimeoutHandler",
$"Item '{item.Name}' timed out after {elapsed.TotalSeconds}s, retrying ({retryCount + 1}/{MaxRetryCount})...");
ItemRetry?.Invoke(this, new LoadingRetryEventArgs
{
ItemId = itemId,
ItemName = item.Name,
RetryCount = retryCount + 1,
MaxRetryCount = MaxRetryCount,
ElapsedTime = elapsed
});
// 重新启动该项
_manager.StartItem(itemId, $"第 {retryCount + 1} 次重试...");
}
}
else
{
// 最终失败
_retryCounts.Remove(itemId);
var item = _manager.GetAllItems().FirstOrDefault(i => i.Id == itemId);
if (item != null)
{
AppLogger.Error("LoadingTimeoutHandler",
$"Item '{item.Name}' failed after {MaxRetryCount} retries ({elapsed.TotalSeconds}s)");
var args = new LoadingTimeoutEventArgs
{
ItemId = itemId,
ItemName = item.Name,
ElapsedTime = elapsed,
RetryCount = MaxRetryCount,
IsFinalFailure = true
};
ItemTimeout?.Invoke(this, args);
ItemFailed?.Invoke(this, args);
// 标记为失败
_manager.FailItem(itemId,
$"加载超时(超过 {elapsed.TotalSeconds:F0} 秒)",
$"已重试 {MaxRetryCount} 次但仍失败");
}
}
}
}
/// <summary>
/// 状态变更事件处理
/// </summary>
private void OnStateChanged(object? sender, LoadingStateChangedEventArgs e)
{
// 当项完成或失败时,清除重试计数
if (e.CurrentState is LoadingState.Completed or LoadingState.Failed or LoadingState.Cancelled)
{
lock (_lock)
{
_retryCounts.Remove(e.Item.Id);
}
}
// 当项开始时,如果是第一次开始,初始化重试计数
if (e.CurrentState == LoadingState.InProgress &&
(e.PreviousState == null || e.PreviousState == LoadingState.Pending))
{
lock (_lock)
{
if (!_retryCounts.ContainsKey(e.Item.Id))
{
_retryCounts[e.Item.Id] = 0;
}
}
}
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
Stop();
_checkTimer.Elapsed -= OnCheckTimerElapsed;
_checkTimer.Dispose();
_manager.StateChanged -= OnStateChanged;
_itemTimeouts.Clear();
_retryCounts.Clear();
}
}
/// <summary>
/// 加载超时事件参数
/// </summary>
public class LoadingTimeoutEventArgs : EventArgs
{
public required string ItemId { get; init; }
public required string ItemName { get; init; }
public required TimeSpan ElapsedTime { get; init; }
public int RetryCount { get; init; }
public bool IsFinalFailure { get; init; }
}
/// <summary>
/// 加载重试事件参数
/// </summary>
public class LoadingRetryEventArgs : EventArgs
{
public required string ItemId { get; init; }
public required string ItemName { get; init; }
public required int RetryCount { get; init; }
public required int MaxRetryCount { get; init; }
public required TimeSpan ElapsedTime { get; init; }
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
/// <summary>
/// Release-backed PLONDS checker.
/// It only succeeds when the latest GitHub Release already exposes platform PLONDS assets.
/// If those assets are not ready yet, callers can fall back to the normal GitHub installer flow.
/// </summary>
public sealed class PlondsReleaseUpdateService : IDisposable
{
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
public Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public void Dispose()
{
_githubReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var releaseResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (!releaseResult.Success)
{
return releaseResult;
}
if (!isForce && !releaseResult.IsUpdateAvailable)
{
return releaseResult with { ForceMode = false };
}
if (releaseResult.PlondsPayload is not null)
{
return releaseResult with { ForceMode = isForce };
}
var latestVersion = string.IsNullOrWhiteSpace(releaseResult.LatestVersionText)
? "-"
: releaseResult.LatestVersionText;
var message = releaseResult.Release is null
? "GitHub Release data is unavailable for PLONDS."
: $"Release {latestVersion} does not expose platform PLONDS assets yet.";
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: releaseResult.IsUpdateAvailable,
CurrentVersionText: releaseResult.CurrentVersionText,
LatestVersionText: latestVersion,
Release: releaseResult.Release,
PreferredAsset: releaseResult.PreferredAsset,
ErrorMessage: message,
ForceMode: isForce,
PlondsPayload: null);
}
}

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

@@ -356,6 +356,7 @@ public interface IUpdateSettingsService
void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(Version currentVersion, bool includePrerelease, bool isForce = false, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,

View File

@@ -751,7 +751,8 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
{
private readonly ISettingsService _settingsService;
private readonly GitHubReleaseUpdateService _releaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly GitHubReleaseUpdateService _githubReleaseUpdateService = new("wwiinnddyy", "LanMountainDesktop");
private readonly PlondsReleaseUpdateService _plondsReleaseUpdateService = new();
public UpdateSettingsService(ISettingsService settingsService)
{
@@ -830,7 +831,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: false, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
@@ -838,7 +839,19 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return CheckForUpdatesCoreAsync(currentVersion, includePrerelease, isForce: true, cancellationToken);
}
public async Task<PlondsUpdatePayload?> GetPlondsUpdatePayloadAsync(
Version currentVersion,
bool includePrerelease,
bool isForce = false,
CancellationToken cancellationToken = default)
{
var result = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
return result.Success ? result.PlondsPayload : null;
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
@@ -849,7 +862,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.DownloadAssetAsync(
return _githubReleaseUpdateService.DownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -866,7 +879,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.RedownloadAssetAsync(
return _githubReleaseUpdateService.RedownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
@@ -877,7 +890,55 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
public void Dispose()
{
_releaseUpdateService.Dispose();
_githubReleaseUpdateService.Dispose();
_plondsReleaseUpdateService.Dispose();
}
private async Task<UpdateCheckResult> CheckForUpdatesCoreAsync(
Version currentVersion,
bool includePrerelease,
bool isForce,
CancellationToken cancellationToken)
{
var source = UpdateSettingsValues.NormalizeDownloadSource(_settingsService.Load().UpdateDownloadSource);
if (string.Equals(source, UpdateSettingsValues.DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
var plondsResult = isForce
? await _plondsReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _plondsReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (plondsResult.Success)
{
return plondsResult;
}
AppLogger.Warn(
"UpdateSettings",
$"PLONDS update check failed and will fallback to GitHub. Error: {plondsResult.ErrorMessage}");
var githubFallbackResult = isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
if (githubFallbackResult.Success)
{
AppLogger.Info(
"UpdateSettings",
$"GitHub fallback succeeded after PLONDS failure. Original PLONDS error: {plondsResult.ErrorMessage}");
}
else
{
AppLogger.Warn(
"UpdateSettings",
$"GitHub fallback also failed after PLONDS failure. PLONDS error: {plondsResult.ErrorMessage}; GitHub error: {githubFallbackResult.ErrorMessage}");
}
return githubFallbackResult;
}
return isForce
? await _githubReleaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken)
: await _githubReleaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
}
@@ -1225,10 +1286,18 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
internal sealed class ApplicationInfoService : IApplicationInfoService
{
private const string Codename = "Administrate";
private const string DefaultCodename = "Administrate";
public string GetAppVersionText()
{
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
if (!string.IsNullOrWhiteSpace(envVersion))
{
return envVersion;
}
// Fallback: read from application assembly.
var assembly = typeof(App).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
@@ -1268,7 +1337,15 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppCodenameText()
{
return Codename;
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
if (!string.IsNullOrWhiteSpace(envCodename))
{
return envCodename;
}
// Fallback: use default codename.
return DefaultCodename;
}
public AppRenderBackendInfo GetRenderBackendInfo()

View File

@@ -9,6 +9,10 @@ namespace LanMountainDesktop.Services;
public sealed class SingleInstanceService : IDisposable
{
private const byte ActivationRequestCode = 0x41; // 'A'
private const byte ActivationAckCode = 0x4B; // 'K'
private const byte ActivationNackCode = 0x4E; // 'N'
private readonly Mutex _mutex;
private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new();
@@ -56,13 +60,24 @@ public sealed class SingleInstanceService : IDisposable
return;
}
AppLogger.Info(
"SingleInstance",
$"Starting activation listener. Pipe='{_pipeName}'; Pid={Environment.ProcessId}; OwnsMutex={_ownsMutex}.");
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
{
return TryNotifyPrimaryInstance(timeout, out _);
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout, out string? failureReason)
{
if (_ownsMutex || _disposed)
{
failureReason = _ownsMutex
? "current_instance_is_primary"
: "single_instance_service_disposed";
return false;
}
@@ -71,16 +86,38 @@ public sealed class SingleInstanceService : IDisposable
using var client = new NamedPipeClientStream(
serverName: ".",
pipeName: _pipeName,
direction: PipeDirection.Out,
direction: PipeDirection.InOut,
options: PipeOptions.Asynchronous);
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
client.WriteByte(1);
client.WriteByte(ActivationRequestCode);
client.Flush();
var ack = client.ReadByte();
var acknowledged = ack == ActivationAckCode;
if (!acknowledged)
{
failureReason = ack switch
{
ActivationNackCode => "primary_rejected_activation",
-1 => "ack_not_received",
_ => $"unexpected_ack_code_{ack}"
};
AppLogger.Warn(
"SingleInstance",
$"Primary activation handshake failed. AckCode={ack}; Reason='{failureReason}'; Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return false;
}
failureReason = null;
AppLogger.Info(
"SingleInstance",
$"Primary activation acknowledged. Pipe='{_pipeName}'; Pid={Environment.ProcessId}.");
return true;
}
catch (Exception ex)
{
failureReason = "primary_activation_handshake_exception";
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
return false;
}
@@ -128,14 +165,40 @@ public sealed class SingleInstanceService : IDisposable
{
using var server = new NamedPipeServerStream(
_pipeName,
PipeDirection.In,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
onActivationRequested();
var buffer = new byte[1];
var readBytes = await server.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
var isActivationRequest = readBytes == 1 && buffer[0] == ActivationRequestCode;
var ackCode = ActivationAckCode;
if (!isActivationRequest)
{
ackCode = ActivationNackCode;
AppLogger.Warn(
"SingleInstance",
$"Received malformed activation request. ReadBytes={readBytes}; Value={(readBytes == 1 ? buffer[0] : -1)}; Pipe='{_pipeName}'.");
}
else
{
try
{
onActivationRequested();
}
catch (Exception ex)
{
ackCode = ActivationNackCode;
AppLogger.Warn("SingleInstance", "Activation callback failed.", ex);
}
}
var ackBuffer = new[] { ackCode };
await server.WriteAsync(ackBuffer, cancellationToken).ConfigureAwait(false);
await server.FlushAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{

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

@@ -11,6 +11,12 @@ public static class UpdateSettingsValues
public const string ModeDownloadThenConfirm = "download_then_confirm";
public const string ModeSilentOnExit = "silent_on_exit";
// NOTE: keep constant name for compatibility with existing call sites.
public const string DownloadSourcePlonds = "stcn";
public const string DownloadSourcePdc = DownloadSourcePlonds;
public const string DownloadSourceStcn = DownloadSourcePlonds;
public const string LegacyDownloadSourcePlonds = "pdc";
public const string LegacyDownloadSourcePdc = LegacyDownloadSourcePlonds;
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
@@ -51,9 +57,28 @@ public static class UpdateSettingsValues
public static string NormalizeDownloadSource(string? value)
{
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
? DownloadSourceGhProxy
: DownloadSourceGitHub;
if (string.Equals(value, LegacyDownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceStcn;
}
if (string.Equals(value, DownloadSourcePlonds, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourcePlonds;
}
if (string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGhProxy;
}
if (string.Equals(value, DownloadSourceGitHub, StringComparison.OrdinalIgnoreCase))
{
return DownloadSourceGitHub;
}
// Default to STCN(PLONDS/S3). Runtime will fallback to GitHub if STCN is unavailable.
return DownloadSourceStcn;
}
public static int NormalizeDownloadThreads(int value)

File diff suppressed because it is too large Load Diff