Compare commits

..

2 Commits

19 changed files with 341 additions and 144 deletions

View File

@@ -32,6 +32,7 @@ jobs:
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}
@@ -66,12 +67,15 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \ libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \ libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \ libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev clang zlib1g-dev \
libportaudio2 libasound2 \
libwebkit2gtk-4.1-dev
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}
@@ -98,10 +102,14 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}
@@ -132,6 +140,7 @@ jobs:
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Pack SDK and template packages - name: Pack SDK and template packages
shell: pwsh shell: pwsh

View File

@@ -31,6 +31,7 @@ jobs:
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}

View File

@@ -88,6 +88,7 @@ jobs:
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}
@@ -515,12 +516,15 @@ jobs:
libx11-6 libxrandr2 libxinerama1 \ libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \ libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \ libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev clang zlib1g-dev \
libportaudio2 libasound2 \
libwebkit2gtk-4.1-dev
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}
@@ -707,10 +711,14 @@ jobs:
submodules: recursive submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }} ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}

View File

@@ -0,0 +1,23 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -56,7 +56,11 @@
<!-- 允许 IL 警告 --> <!-- 允许 IL 警告 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn> <TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- FluentAvaloniaUI 需要启用反射序列化AOT 兼容模式) --> <!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault> <!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -149,10 +149,7 @@ internal static class Commands
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
} }
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
{
WriteIndented = true
});
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false); await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
} }

View File

@@ -322,7 +322,7 @@ internal sealed class DeploymentLocator
try try
{ {
var json = File.ReadAllText(snapshotFile); var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize<SnapshotMetadata>(json); var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory)) if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{ {
if (Directory.Exists(snapshot.SourceDirectory)) if (Directory.Exists(snapshot.SourceDirectory))
@@ -445,7 +445,7 @@ internal sealed class DeploymentLocator
try try
{ {
var json = File.ReadAllText(versionFile); var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize<AppVersionInfo>(json); var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
if (info is not null) if (info is not null)
{ {
return info; return info;

View File

@@ -159,7 +159,7 @@ namespace LanMountainDesktop.Launcher.Services;
try try
{ {
var json = File.ReadAllText(configPath); var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<HostDiscoveryConfig>(json); var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath)) if (config?.HostPath != null && File.Exists(config.HostPath))
{ {
return config.HostPath; return config.HostPath;
@@ -617,13 +617,13 @@ namespace LanMountainDesktop.Launcher.Services;
public required string AppRoot { get; set; } public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; } public required HostDiscoveryOptions Options { get; set; }
} }
}
/// <summary>
/// 发现配置文件 /// <summary>
/// </summary> /// 发现配置文件
private class HostDiscoveryConfig /// </summary>
{ internal class HostDiscoveryConfig
public string? HostPath { get; set; } {
public List<string>? AdditionalPaths { get; set; } public string? HostPath { get; set; }
} public List<string>? AdditionalPaths { get; set; }
} }

View File

@@ -143,7 +143,7 @@ public class LauncherIpcServer : IDisposable
// 3. 反序列化并回调 // 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength); var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json); var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
if (message is not null) if (message is not null)
{ {
_onProgress(message); _onProgress(message);

View File

@@ -9,6 +9,15 @@ namespace LanMountainDesktop.Launcher.Services;
internal sealed class LauncherFlowCoordinator internal sealed class LauncherFlowCoordinator
{ {
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
private readonly CommandContext _context; private readonly CommandContext _context;
private readonly DeploymentLocator _deploymentLocator; private readonly DeploymentLocator _deploymentLocator;
private readonly OobeStateService _oobeStateService; private readonly OobeStateService _oobeStateService;
@@ -167,11 +176,11 @@ internal sealed class LauncherFlowCoordinator
var processExitTask = hostProcess.WaitForExitAsync(); var processExitTask = hostProcess.WaitForExitAsync();
// 等待主程序就绪或进程退出(取先发生者) // 等待主程序就绪或进程退出(取先发生者)
// 延长超时到 120 秒,给主程序足够的加载时间 // 30 秒超时,宿主端有 10 秒兜底机制确保 Ready 信号发送
var readyOrTimeoutOrExit = Task.WhenAny( var readyOrTimeoutOrExit = Task.WhenAny(
hostReadyTcs.Task, hostReadyTcs.Task,
processExitTask, processExitTask,
Task.Delay(TimeSpan.FromSeconds(120))); Task.Delay(TimeSpan.FromSeconds(30)));
var completedTask = await readyOrTimeoutOrExit; var completedTask = await readyOrTimeoutOrExit;
@@ -315,32 +324,55 @@ internal sealed class LauncherFlowCoordinator
EnsureExecutable(hostPath); EnsureExecutable(hostPath);
} }
var hostWorkingDir = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot();
var versionInfo = _deploymentLocator.GetVersionInfo();
// 构建命令行参数:转发用户参数 + IPC 环境信息通过命令行传递
// UseShellExecute = true 确保 Shell 启动子进程,使其正确关联到交互式桌面窗口站(WinSta0)
// 避免子进程窗口创建成功但不可见的问题。
var arguments = new System.Text.StringBuilder();
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项)
// 只过滤 Launcher 专属的选项,保留宿主程序需要的参数(如 --restart-parent-pid
foreach (var arg in _context.RawArgs)
{
if (arg == _context.Command || arg == _context.SubCommand)
continue;
if (arg.StartsWith("--"))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0) key = key[..equalsIndex];
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
continue;
}
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append(QuoteArgument(arg));
}
// 通过命令行参数传递 IPC 连接信息UseShellExecute=true 时不支持 EnvironmentVariables
if (arguments.Length > 0) arguments.Append(' ');
arguments.Append($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Append($" --{LauncherIpcConstants.PackageRootEnvVar}={QuoteArgument(_deploymentLocator.GetAppRoot())}");
arguments.Append($" --{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Append($" --{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
var processStartInfo = new ProcessStartInfo var processStartInfo = new ProcessStartInfo
{ {
FileName = hostPath, FileName = hostPath,
UseShellExecute = false, UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot() WorkingDirectory = hostWorkingDir,
Arguments = arguments.ToString()
}; };
// 转发命令行参数给主程序(排除 Launcher 自己的命令和选项 // 同时设置环境变量作为备选(当 UseShellExecute=true 时 EnvironmentVariables 仍会被子进程继承
foreach (var arg in _context.RawArgs)
{
// 跳过 Launcher 自己的命令和选项,只传递用户原始参数
if (arg == _context.Command || arg == _context.SubCommand || arg.StartsWith("--"))
{
continue;
}
processStartInfo.ArgumentList.Add(arg);
}
// 传递环境变量供 IPC 使用
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] = processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
Environment.ProcessId.ToString(); Environment.ProcessId.ToString();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] = processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
_deploymentLocator.GetAppRoot(); _deploymentLocator.GetAppRoot();
// 传递版本信息
var versionInfo = _deploymentLocator.GetVersionInfo();
processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version; processStartInfo.EnvironmentVariables[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version;
processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename; processStartInfo.EnvironmentVariables[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename;
@@ -483,6 +515,36 @@ internal sealed class LauncherFlowCoordinator
return result; return result;
} }
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 void EnsureExecutable(string path) private static void EnsureExecutable(string path)
{ {
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())

View File

@@ -73,7 +73,7 @@ internal sealed class PluginInstallerService
using var stream = entries[0].Open(); using var stream = entries[0].Open();
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var json = reader.ReadToEnd(); var json = reader.ReadToEnd();
var manifest = JsonSerializer.Deserialize<PluginManifest>(json); var manifest = JsonSerializer.Deserialize(json, AppJsonContext.Default.PluginManifest);
if (manifest == null) if (manifest == null)
{ {
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'."); throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");

View File

@@ -29,7 +29,7 @@ internal sealed class PluginUpgradeQueueService
} }
var text = File.ReadAllText(pendingPath); var text = File.ReadAllText(pendingPath);
var pending = JsonSerializer.Deserialize<List<PendingUpgrade>>(text) ?? []; var pending = JsonSerializer.Deserialize(text, AppJsonContext.Default.ListPendingUpgrade) ?? [];
var failures = new List<string>(); var failures = new List<string>();
var succeeded = new List<PendingUpgrade>(); var succeeded = new List<PendingUpgrade>();
@@ -63,10 +63,7 @@ internal sealed class PluginUpgradeQueueService
} }
else else
{ {
File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, new JsonSerializerOptions File.WriteAllText(pendingPath, JsonSerializer.Serialize(remaining, AppJsonContext.Default.ListPendingUpgrade));
{
WriteIndented = true
}));
} }
return new LauncherResult return new LauncherResult
@@ -79,19 +76,19 @@ internal sealed class PluginUpgradeQueueService
: $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}." : $"Applied {succeeded.Count} upgrades, failed: {string.Join(", ", failures)}."
}; };
} }
}
private sealed record PendingUpgrade( internal sealed record PendingUpgrade(
string PluginId, string PluginId,
string SourcePackagePath, string SourcePackagePath,
string TargetVersion, string TargetVersion,
DateTimeOffset CreatedAt) DateTimeOffset CreatedAt)
{
public bool IsValid()
{ {
public bool IsValid() return !string.IsNullOrWhiteSpace(PluginId) &&
{ !string.IsNullOrWhiteSpace(SourcePackagePath) &&
return !string.IsNullOrWhiteSpace(PluginId) && !string.IsNullOrWhiteSpace(TargetVersion) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) && File.Exists(SourcePackagePath);
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
} }
} }

View File

@@ -15,7 +15,6 @@ internal sealed class UpdateCheckService
private readonly string _repoOwner; private readonly string _repoOwner;
private readonly string _repoName; private readonly string _repoName;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public UpdateCheckService(string repoOwner, string repoName) public UpdateCheckService(string repoOwner, string repoName)
{ {
@@ -24,12 +23,6 @@ internal sealed class UpdateCheckService
_httpClient = new HttpClient(); _httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher"); _httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); _httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
} }
/// <summary> /// <summary>
@@ -97,7 +90,7 @@ internal sealed class UpdateCheckService
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken); var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize<List<GitHubRelease>>(json, _jsonOptions); var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
return releases?.Select(r => new ReleaseInfo return releases?.Select(r => new ReleaseInfo
{ {
@@ -131,38 +124,38 @@ internal sealed class UpdateCheckService
var cleaned = ParseVersionString(versionString); var cleaned = ParseVersionString(versionString);
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0); return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
} }
}
// GitHub API 响应模型
private sealed class GitHubRelease // GitHub API 响应模型
{ internal sealed class GitHubRelease
[JsonPropertyName("tag_name")] {
public string? TagName { get; set; } [JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; } [JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; } [JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; } [JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; } [JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; } [JsonPropertyName("assets")]
} public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{ internal sealed class GitHubAsset
[JsonPropertyName("name")] {
public string? Name { get; set; } [JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; } [JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; } [JsonPropertyName("size")]
} public long Size { get; set; }
} }

View File

@@ -48,7 +48,7 @@ internal sealed class UpdateEngineService
} }
var fileMapText = File.ReadAllText(fileMapPath); var fileMapText = File.ReadAllText(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText); var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null) if (fileMap is null)
{ {
return Failed("update.check", "invalid_manifest", "files.json is invalid."); return Failed("update.check", "invalid_manifest", "files.json is invalid.");
@@ -137,7 +137,7 @@ internal sealed class UpdateEngineService
} }
var fileMapText = await File.ReadAllTextAsync(fileMapPath); var fileMapText = await File.ReadAllTextAsync(fileMapPath);
var fileMap = JsonSerializer.Deserialize<SignedFileMap>(fileMapText); var fileMap = JsonSerializer.Deserialize(fileMapText, AppJsonContext.Default.SignedFileMap);
if (fileMap is null || fileMap.Files.Count == 0) if (fileMap is null || fileMap.Files.Count == 0)
{ {
return Failed("update.apply", "invalid_manifest", "No update file entries were found."); return Failed("update.apply", "invalid_manifest", "No update file entries were found.");
@@ -438,7 +438,7 @@ internal sealed class UpdateEngineService
return Failed("update.rollback", "no_snapshot", "No snapshot found."); return Failed("update.rollback", "no_snapshot", "No snapshot found.");
} }
var snapshot = JsonSerializer.Deserialize<SnapshotMetadata>(File.ReadAllText(snapshotPath)); var snapshot = JsonSerializer.Deserialize(File.ReadAllText(snapshotPath), AppJsonContext.Default.SnapshotMetadata);
if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory)) if (snapshot is null || string.IsNullOrWhiteSpace(snapshot.SourceDirectory))
{ {
return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata."); return Failed("update.rollback", "invalid_snapshot", "Invalid snapshot metadata.");
@@ -656,10 +656,7 @@ internal sealed class UpdateEngineService
private static void SaveSnapshot(string path, SnapshotMetadata snapshot) private static void SaveSnapshot(string path, SnapshotMetadata snapshot)
{ {
File.WriteAllText(path, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions File.WriteAllText(path, JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
{
WriteIndented = true
}));
} }
private static LauncherResult Failed(string stage, string code, string message) private static LauncherResult Failed(string stage, string code, string message)

View File

@@ -142,7 +142,7 @@ public partial class App : Application
EnsureNotificationService(); EnsureNotificationService();
} }
public override async void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
if (Design.IsDesignMode) if (Design.IsDesignMode)
{ {
@@ -152,12 +152,8 @@ public partial class App : Application
AppLogger.Info("App", "Framework initialization completed."); AppLogger.Info("App", "Framework initialization completed.");
// 初始化 Launcher IPC 客户端(如果从 Launcher 启动)
await InitializeLauncherIpcAsync();
RegisterUiUnhandledExceptionGuard(); RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled(); LinuxDesktopEntryInstaller.EnsureInstalled();
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell); DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (!Design.IsDesignMode && OperatingSystem.IsWindows()) if (!Design.IsDesignMode && OperatingSystem.IsWindows())
@@ -166,6 +162,10 @@ public partial class App : Application
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
// IPC 初始化移到窗口创建之后,避免 async void 中的 await 导致窗口创建延迟
// 使用 fire-and-forget 模式,不阻塞主流程
_ = InitializeLauncherIpcAsync();
} }
private async Task InitializeLauncherIpcAsync() private async Task InitializeLauncherIpcAsync()
@@ -189,9 +189,10 @@ public partial class App : Application
// 注册系统初始化加载项 // 注册系统初始化加载项
_loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件"); _loadingStateManager.RegisterItem("system.init", LoadingItemType.System, "系统初始化", "初始化系统核心组件");
_loadingStateManager.StartItem("system.init", "正在连接启动器..."); _loadingStateManager.StartItem("system.init", "连接启动器");
ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化..."); ReportStartupProgress(StartupStage.Initializing, 10, "正在初始化...");
ReportStartupProgress(StartupStage.LoadingSettings, 20, "正在加载设置...");
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -227,7 +228,7 @@ public partial class App : Application
} }
/// <summary> /// <summary>
/// 同步向 Launcher 报告启动进度,确保关键消息可靠送达 /// 向 Launcher 报告关键启动进度,使用后台线程避免阻塞 UI
/// 用于 Ready 等关键状态报告 /// 用于 Ready 等关键状态报告
/// </summary> /// </summary>
private void ReportStartupProgressSync(StartupStage stage, int percent, string message) private void ReportStartupProgressSync(StartupStage stage, int percent, string message)
@@ -237,27 +238,27 @@ public partial class App : Application
try try
{ {
// 使用同步等待确保消息发送完成 _ = Task.Run(async () =>
var task = _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{ {
Stage = stage, try
ProgressPercent = percent, {
Message = message await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = stage,
ProgressPercent = percent,
Message = message
});
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
}
catch (Exception ex)
{
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
}
}); });
// 等待最多 5 秒,确保消息发送成功
if (!task.Wait(TimeSpan.FromSeconds(5)))
{
AppLogger.Warn("LauncherIpc", "Report progress timeout after 5 seconds");
}
else
{
AppLogger.Info("LauncherIpc", $"Successfully reported stage: {stage}");
}
} }
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("LauncherIpc", $"Failed to report progress synchronously: {ex.Message}"); AppLogger.Warn("LauncherIpc", $"Failed to launch progress report task: {ex.Message}");
} }
} }
@@ -980,6 +981,27 @@ public partial class App : Application
// 使用 Opened 事件确保所有资源已加载完毕 // 使用 Opened 事件确保所有资源已加载完毕
mainWindow.Opened += OnMainWindowOpened; mainWindow.Opened += OnMainWindowOpened;
// 兜底机制:如果 Opened 事件 10 秒内未触发,强制发送 Ready 信号
// 防止因渲染问题导致 Opened 不触发,启动器 Splash 窗口一直显示
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(10));
if (_launcherIpcClient is not null && _launcherIpcClient.IsConnected)
{
try
{
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Ready,
ProgressPercent = 100,
Message = "就绪"
});
AppLogger.Warn("App", "Ready signal sent via fallback (Opened event did not fire within 10s)");
}
catch { }
}
});
return mainWindow; return mainWindow;
} }

View File

@@ -100,12 +100,15 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = executablePath, FileName = executablePath,
UseShellExecute = false, UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath) WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
}; };
AppendArguments(startInfo, commandLineArgs); // UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
AppendRestartParentProcessArgument(startInfo); var args = new System.Text.StringBuilder();
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo; return startInfo;
} }
@@ -122,13 +125,16 @@ public static class AppRestartService
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {
FileName = dotnetHostPath, FileName = dotnetHostPath,
UseShellExecute = false, UseShellExecute = true,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath) WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
}; };
startInfo.ArgumentList.Add(entryAssemblyPath); // UseShellExecute=true 时使用 Arguments 字符串
AppendArguments(startInfo, commandLineArgs); var args = new System.Text.StringBuilder();
AppendRestartParentProcessArgument(startInfo); args.Append(QuoteArgument(entryAssemblyPath));
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
return startInfo; 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) private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{ {
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}"); 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) private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{ {
processId = 0; processId = 0;

View File

@@ -17,6 +17,11 @@ public class LauncherIpcClient : IDisposable
private bool _isConnected; private bool _isConnected;
private readonly object _writeLock = new(); private readonly object _writeLock = new();
/// <summary>
/// 是否已连接到 Launcher
/// </summary>
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
/// <summary> /// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。 /// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// </summary> /// </summary>
@@ -92,11 +97,28 @@ public class LauncherIpcClient : IDisposable
/// <summary> /// <summary>
/// 检查是否从 Launcher 启动 /// 检查是否从 Launcher 启动
/// 优先检查环境变量回退到命令行参数UseShellExecute=true 时环境变量仍可继承,
/// 命令行参数作为备选确保兼容性)
/// </summary> /// </summary>
public static bool IsLaunchedByLauncher() public static bool IsLaunchedByLauncher()
{ {
return !string.IsNullOrEmpty( // 优先检查环境变量
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)); 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() public void Dispose()

View File

@@ -1,4 +1,5 @@
using System.Timers; using System.Timers;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Loading; namespace LanMountainDesktop.Services.Loading;

View File

@@ -5,13 +5,18 @@
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests --> For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/> <assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 明确指定不需要管理员权限,以调用者权限运行 -->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application> <application>
<!-- A list of the Windows versions that this application has been tested on <!-- Windows 10/11 -->
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application> </application>
</compatibility> </compatibility>