mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-29 14:14:26 +08:00
Compare commits
1 Commits
v0.8.4.0
...
59c4824425
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59c4824425 |
805
.trae/documents/launcher_comprehensive_improvement_plan.md
Normal file
805
.trae/documents/launcher_comprehensive_improvement_plan.md
Normal file
@@ -0,0 +1,805 @@
|
|||||||
|
# LanMountainDesktop Launcher 全面改进计划
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本计划旨在将 LanMountainDesktop 的 Launcher 改进为符合原子化架构的独立启动器,参考 ClassIsland 的极简设计,同时保留阑山桌面的特色功能。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. **P0 (必须完成)**: 重写 Launcher 为极简模式,移除与主程序的耦合
|
||||||
|
2. **P1 (应该完成)**: 将 OOBE、Splash、更新、插件管理迁移到主程序
|
||||||
|
3. **P2 (推荐完成)**: 实现 Launcher 自更新机制
|
||||||
|
4. **P3 (可选优化)**: 性能优化和代码清理
|
||||||
|
5. **P4 (长期规划)**: 增强功能和可扩展性
|
||||||
|
|
||||||
|
## 当前问题
|
||||||
|
|
||||||
|
1. Launcher 是 Avalonia 应用,启动慢、内存占用高
|
||||||
|
2. Launcher 引用了 PluginSdk,与主程序有耦合
|
||||||
|
3. 主程序引用了 Launcher,构建关系复杂
|
||||||
|
4. Launcher 职责过多(OOBE + Splash + 更新 + 插件 + 启动)
|
||||||
|
5. 缺少 Launcher 自更新机制
|
||||||
|
6. GitHub Actions 工作流需要适配新的目录结构
|
||||||
|
|
||||||
|
## 改进后架构
|
||||||
|
|
||||||
|
```
|
||||||
|
安装根目录/
|
||||||
|
├── LanMountainDesktop.exe ← 启动器(唯一入口,极简,~100行代码)
|
||||||
|
├── app-1.0.0/ ← 版本目录
|
||||||
|
│ ├── .current ← 当前版本标记
|
||||||
|
│ ├── LanMountainDesktop.exe ← 主程序
|
||||||
|
│ └── ... (所有依赖)
|
||||||
|
└── .launcher/ ← 启动器数据(可选)
|
||||||
|
└── snapshots/ ← 版本快照
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细实施步骤
|
||||||
|
|
||||||
|
### P0: 基础架构重构
|
||||||
|
|
||||||
|
#### 1. 重写 Launcher 为极简模式
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/Program.cs`
|
||||||
|
|
||||||
|
**目标**:
|
||||||
|
- 代码量控制在 100 行以内
|
||||||
|
- 零外部依赖(不使用 Avalonia)
|
||||||
|
- 只负责:版本选择、启动主程序、清理旧版本
|
||||||
|
|
||||||
|
**完整实现代码**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// LanMountainDesktop.Launcher/Program.cs
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private const string HostExecutableName = "LanMountainDesktop.exe";
|
||||||
|
private const string HostExecutableNameLinux = "LanMountainDesktop";
|
||||||
|
|
||||||
|
[STAThread]
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
var rootDir = GetRootDirectory();
|
||||||
|
|
||||||
|
// 1. 查找最佳版本
|
||||||
|
var installation = FindBestVersion(rootDir);
|
||||||
|
if (installation == null)
|
||||||
|
{
|
||||||
|
ShowError("找不到有效的 LanMountainDesktop 版本,请重新安装。");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 清理旧版本(异步,不阻塞)
|
||||||
|
_ = Task.Run(() => CleanupOldVersions(rootDir));
|
||||||
|
|
||||||
|
// 3. 启动主程序
|
||||||
|
return LaunchHost(installation, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRootDirectory()
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(
|
||||||
|
Path.GetDirectoryName(Environment.ProcessPath) ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindBestVersion(string rootDir)
|
||||||
|
{
|
||||||
|
var exeName = OperatingSystem.IsWindows()
|
||||||
|
? HostExecutableName
|
||||||
|
: HostExecutableNameLinux;
|
||||||
|
|
||||||
|
return Directory.GetDirectories(rootDir)
|
||||||
|
.Where(x => IsValidVersionDirectory(x, exeName))
|
||||||
|
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1)
|
||||||
|
.ThenByDescending(x => ParseVersion(Path.GetFileName(x)))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsValidVersionDirectory(string path, string exeName)
|
||||||
|
{
|
||||||
|
var dirName = Path.GetFileName(path);
|
||||||
|
return dirName.StartsWith("app-") &&
|
||||||
|
!File.Exists(Path.Combine(path, ".destroy")) &&
|
||||||
|
!File.Exists(Path.Combine(path, ".partial")) &&
|
||||||
|
File.Exists(Path.Combine(path, exeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Version ParseVersion(string dirName)
|
||||||
|
{
|
||||||
|
// app-1.0.0 or app-1.0.0-123
|
||||||
|
var parts = dirName.Split('-');
|
||||||
|
if (parts.Length >= 2 && Version.TryParse(parts[1], out var v))
|
||||||
|
return v;
|
||||||
|
return new Version(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CleanupOldVersions(string rootDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var oldVersions = Directory.GetDirectories(rootDir)
|
||||||
|
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
|
||||||
|
|
||||||
|
foreach (var dir in oldVersions)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* 忽略清理失败 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LaunchHost(string installation, string[] args)
|
||||||
|
{
|
||||||
|
var exeName = OperatingSystem.IsWindows()
|
||||||
|
? HostExecutableName
|
||||||
|
: HostExecutableNameLinux;
|
||||||
|
var exePath = Path.Combine(installation, exeName);
|
||||||
|
|
||||||
|
// Linux/macOS: 确保可执行权限
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
EnsureExecutable(exePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = exePath,
|
||||||
|
WorkingDirectory = Path.GetDirectoryName(installation),
|
||||||
|
UseShellExecute = true
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var arg in args)
|
||||||
|
startInfo.ArgumentList.Add(arg);
|
||||||
|
|
||||||
|
// 传递环境变量
|
||||||
|
startInfo.EnvironmentVariables["LMD_PACKAGE_ROOT"] =
|
||||||
|
Path.GetDirectoryName(installation);
|
||||||
|
startInfo.EnvironmentVariables["LMD_VERSION"] =
|
||||||
|
Path.GetFileName(installation).Replace("app-", "");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(startInfo);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ShowError($"启动失败: {ex.Message}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureExecutable(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "chmod",
|
||||||
|
Arguments = $"+x \"{path}\"",
|
||||||
|
CreateNoWindow = true
|
||||||
|
})?.WaitForExit();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShowError(string message)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
// Win32 MessageBox
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MessageBox(IntPtr.Zero, message, "LanMountainDesktop", 0x10);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 修改 Launcher 项目文件
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||||
|
|
||||||
|
**完整内容**:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- 图标资源 -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 移除主程序对 Launcher 的引用
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||||
|
|
||||||
|
**修改**: 删除以下行
|
||||||
|
```xml
|
||||||
|
<!-- 删除这一行 -->
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 修改主程序支持新架构
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/Program.cs`
|
||||||
|
|
||||||
|
**修改**: 添加环境变量读取
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 Program.cs 中添加
|
||||||
|
internal static class LaunchContext
|
||||||
|
{
|
||||||
|
public static string? PackageRoot =>
|
||||||
|
Environment.GetEnvironmentVariable("LMD_PACKAGE_ROOT");
|
||||||
|
public static string? Version =>
|
||||||
|
Environment.GetEnvironmentVariable("LMD_VERSION");
|
||||||
|
public static bool IsLaunchedByLauncher =>
|
||||||
|
!string.IsNullOrEmpty(PackageRoot);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1: 功能迁移
|
||||||
|
|
||||||
|
#### 5. 将 OOBE 迁移到主程序
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Oobe/OobeService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Oobe;
|
||||||
|
|
||||||
|
public class OobeService
|
||||||
|
{
|
||||||
|
private readonly string _oobeStatePath;
|
||||||
|
|
||||||
|
public OobeService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
_oobeStatePath = Path.Combine(appData, "LanMountainDesktop", ".oobe_completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsFirstRun()
|
||||||
|
{
|
||||||
|
return !File.Exists(_oobeStatePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkCompleted()
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(_oobeStatePath);
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
File.WriteAllText(_oobeStatePath, DateTime.UtcNow.ToString("O"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LanMountainDesktop.Views.Oobe.OobeWindow"
|
||||||
|
Title="欢迎使用阑山桌面"
|
||||||
|
Width="800"
|
||||||
|
Height="600"
|
||||||
|
WindowStartupLocation="CenterScreen">
|
||||||
|
<Grid>
|
||||||
|
<!-- OOBE 界面内容 -->
|
||||||
|
<TextBlock Text="欢迎使用阑山桌面" FontSize="24" HorizontalAlignment="Center" Margin="0,50,0,0"/>
|
||||||
|
<Button Content="开始使用" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,50" Click="OnStartClick"/>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 OnFrameworkInitializationCompleted 中添加
|
||||||
|
private async Task InitializeOobeAsync()
|
||||||
|
{
|
||||||
|
var oobeService = new OobeService();
|
||||||
|
if (oobeService.IsFirstRun())
|
||||||
|
{
|
||||||
|
var oobeWindow = new Views.Oobe.OobeWindow();
|
||||||
|
await oobeWindow.ShowDialog();
|
||||||
|
oobeService.MarkCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. 将 Splash 迁移到主程序
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LanMountainDesktop.Views.Splash.SplashWindow"
|
||||||
|
Title="阑山桌面"
|
||||||
|
Width="400"
|
||||||
|
Height="300"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
SystemDecorations="None">
|
||||||
|
<Grid Background="{DynamicResource SystemAccentColor}">
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||||
|
<Image Source="/Assets/logo_nightly.png" Width="100" Height="100"/>
|
||||||
|
<TextBlock Text="阑山桌面" FontSize="20" Margin="0,20,0,0" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="StatusText" Text="正在启动..." Margin="0,10,0,0" HorizontalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在初始化时显示 Splash
|
||||||
|
private SplashWindow? _splashWindow;
|
||||||
|
|
||||||
|
private void ShowSplash()
|
||||||
|
{
|
||||||
|
_splashWindow = new SplashWindow();
|
||||||
|
_splashWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseSplash()
|
||||||
|
{
|
||||||
|
_splashWindow?.Close();
|
||||||
|
_splashWindow = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. 将更新逻辑迁移到主程序
|
||||||
|
|
||||||
|
**新建目录**: `LanMountainDesktop/Services/Update/`
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Update/UpdateService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public class UpdateService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly string _currentVersion;
|
||||||
|
|
||||||
|
public UpdateService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop");
|
||||||
|
_currentVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateCheckResult> CheckForUpdateAsync(UpdateChannel channel)
|
||||||
|
{
|
||||||
|
// 调用 GitHub Release API
|
||||||
|
var releases = await _httpClient.GetFromJsonAsync<List<GitHubRelease>>(
|
||||||
|
"https://api.github.com/repos/ClassIsland/LanMountainDesktop/releases");
|
||||||
|
|
||||||
|
var latest = channel == UpdateChannel.Stable
|
||||||
|
? releases?.FirstOrDefault(r => !r.Prerelease)
|
||||||
|
: releases?.FirstOrDefault();
|
||||||
|
|
||||||
|
if (latest == null)
|
||||||
|
return new UpdateCheckResult { HasUpdate = false };
|
||||||
|
|
||||||
|
var latestVersion = latest.TagName.TrimStart('v');
|
||||||
|
var hasUpdate = new Version(latestVersion) > new Version(_currentVersion);
|
||||||
|
|
||||||
|
return new UpdateCheckResult
|
||||||
|
{
|
||||||
|
HasUpdate = hasUpdate,
|
||||||
|
Version = latestVersion,
|
||||||
|
DownloadUrl = latest.Assets.FirstOrDefault()?.BrowserDownloadUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateCheckResult
|
||||||
|
{
|
||||||
|
public bool HasUpdate { get; set; }
|
||||||
|
public string? Version { get; set; }
|
||||||
|
public string? DownloadUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UpdateChannel { Stable, Preview }
|
||||||
|
|
||||||
|
public class GitHubRelease
|
||||||
|
{
|
||||||
|
public string TagName { get; set; } = "";
|
||||||
|
public bool Prerelease { get; set; }
|
||||||
|
public List<GitHubAsset> Assets { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GitHubAsset
|
||||||
|
{
|
||||||
|
public string BrowserDownloadUrl { get; set; } = "";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. 将插件管理迁移到主程序
|
||||||
|
|
||||||
|
**新建目录**: `LanMountainDesktop/Services/Plugins/`
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace LanMountainDesktop.Services.Plugins;
|
||||||
|
|
||||||
|
public class PluginUpdateService
|
||||||
|
{
|
||||||
|
private readonly string _pluginsDirectory;
|
||||||
|
|
||||||
|
public PluginUpdateService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
_pluginsDirectory = Path.Combine(appData, "LanMountainDesktop", "plugins");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckAndUpdatePluginsAsync()
|
||||||
|
{
|
||||||
|
// 检查插件更新
|
||||||
|
// 下载并安装更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2: 自更新机制
|
||||||
|
|
||||||
|
#### 9. 实现 Launcher 自更新
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop.Launcher/Program.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 在 Main 方法开头添加自更新检查
|
||||||
|
private static void CheckForLauncherUpdate()
|
||||||
|
{
|
||||||
|
var rootDir = GetRootDirectory();
|
||||||
|
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
|
||||||
|
|
||||||
|
if (File.Exists(updatePath))
|
||||||
|
{
|
||||||
|
// 有新版本 Launcher,替换自身
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentPath = Environment.ProcessPath;
|
||||||
|
var backupPath = currentPath + ".old";
|
||||||
|
|
||||||
|
// 重命名当前版本
|
||||||
|
if (File.Exists(backupPath))
|
||||||
|
File.Delete(backupPath);
|
||||||
|
File.Move(currentPath!, backupPath);
|
||||||
|
|
||||||
|
// 移动新版本
|
||||||
|
File.Move(updatePath, currentPath!);
|
||||||
|
|
||||||
|
// 删除备份
|
||||||
|
File.Delete(backupPath);
|
||||||
|
|
||||||
|
// 重启自己
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = currentPath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 回滚
|
||||||
|
Console.Error.WriteLine($"Launcher 更新失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10. 主程序支持更新 Launcher
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace LanMountainDesktop.Services.Update;
|
||||||
|
|
||||||
|
public class LauncherUpdateService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public LauncherUpdateService()
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateLauncherAsync(string downloadUrl)
|
||||||
|
{
|
||||||
|
var rootDir = LaunchContext.PackageRoot
|
||||||
|
?? Path.GetDirectoryName(Environment.ProcessPath)!;
|
||||||
|
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
|
||||||
|
|
||||||
|
// 下载新版本
|
||||||
|
var response = await _httpClient.GetAsync(downloadUrl);
|
||||||
|
await using var fs = File.Create(updatePath);
|
||||||
|
await response.Content.CopyToAsync(fs);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RestartWithNewLauncher()
|
||||||
|
{
|
||||||
|
var launcherPath = Path.Combine(
|
||||||
|
LaunchContext.PackageRoot ?? "",
|
||||||
|
"LanMountainDesktop.exe");
|
||||||
|
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = launcherPath,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 退出主程序,让 Launcher 接管
|
||||||
|
Environment.Exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3: 清理旧代码
|
||||||
|
|
||||||
|
#### 11. 删除文件清单
|
||||||
|
|
||||||
|
**删除以下文件/目录**:
|
||||||
|
|
||||||
|
```
|
||||||
|
LanMountainDesktop.Launcher/
|
||||||
|
├── App.axaml ← 删除
|
||||||
|
├── App.axaml.cs ← 删除
|
||||||
|
├── Views/ ← 删除整个目录
|
||||||
|
│ ├── OobeWindow.axaml
|
||||||
|
│ ├── OobeWindow.axaml.cs
|
||||||
|
│ ├── SplashWindow.axaml
|
||||||
|
│ └── SplashWindow.axaml.cs
|
||||||
|
├── Services/ ← 删除大部分
|
||||||
|
│ ├── LauncherFlowCoordinator.cs ← 删除
|
||||||
|
│ ├── OobeStateService.cs ← 删除
|
||||||
|
│ ├── UpdateCheckService.cs ← 删除
|
||||||
|
│ ├── UpdateEngineService.cs ← 删除
|
||||||
|
│ ├── PluginInstallerService.cs ← 删除
|
||||||
|
│ └── PluginUpgradeQueueService.cs ← 删除
|
||||||
|
└── Models/ ← 删除(如不再需要)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P4: GitHub Actions 工作流修改
|
||||||
|
|
||||||
|
#### 12. 修改 release.yml
|
||||||
|
|
||||||
|
**关键修改点**:
|
||||||
|
|
||||||
|
1. **Launcher 单独编译**:
|
||||||
|
```yaml
|
||||||
|
- name: Publish Launcher
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./publish/launcher-win-x64 `
|
||||||
|
--self-contained `
|
||||||
|
-r win-x64 `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:PublishTrimmed=false `
|
||||||
|
-p:DebugType=none
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **目录结构调整**:
|
||||||
|
```yaml
|
||||||
|
- name: Restructure for Launcher
|
||||||
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$publishDir = "publish/windows-x64"
|
||||||
|
$launcherDir = "publish/launcher-win-x64"
|
||||||
|
$appDir = "app-$version"
|
||||||
|
|
||||||
|
# 创建新结构
|
||||||
|
$newStructure = "publish-launcher/windows-x64"
|
||||||
|
New-Item -ItemType Directory -Path $newStructure -Force
|
||||||
|
|
||||||
|
# 移动主程序到 app-{version}/
|
||||||
|
$appPath = Join-Path $newStructure $appDir
|
||||||
|
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||||
|
|
||||||
|
# 复制 Launcher 到根目录
|
||||||
|
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
|
||||||
|
# 创建 .current 标记
|
||||||
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Linux/macOS 同样调整**:
|
||||||
|
- Linux: 修改 DEB 打包流程
|
||||||
|
- macOS: 修改 DMG 打包流程
|
||||||
|
|
||||||
|
#### 13. 修改 build.yml
|
||||||
|
|
||||||
|
**修改**: 移除 Launcher 相关构建步骤,因为 Launcher 现在完全独立
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P5: 图标资源处理
|
||||||
|
|
||||||
|
#### 14. Launcher 图标配置
|
||||||
|
|
||||||
|
**方案**: 使用链接方式引用主程序图标
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 链接主程序的图标 -->
|
||||||
|
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 15. 安装程序配置
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/installer/LanMountainDesktop.iss` (Inno Setup)
|
||||||
|
|
||||||
|
**关键配置**:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Setup]
|
||||||
|
AppName=阑山桌面
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
DefaultDirName={autopf}\LanMountainDesktop
|
||||||
|
OutputBaseFilename=LanMountainDesktop-Setup-{#MyAppVersion}-x64
|
||||||
|
SetupIconFile=..\Assets\logo_nightly.ico
|
||||||
|
UninstallDisplayIcon={app}\LanMountainDesktop.exe
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
; Launcher
|
||||||
|
Source: "..\..\publish\windows-x64\LanMountainDesktop.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
; 主程序版本目录
|
||||||
|
Source: "..\..\publish\windows-x64\app-{#MyAppVersion}\*"; DestDir: "{app}\app-{#MyAppVersion}"; Flags: ignoreversion recursesubdirs
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
; 桌面快捷方式
|
||||||
|
Name: "{autodesktop}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
|
||||||
|
; 开始菜单
|
||||||
|
Name: "{group}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更清单
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Launcher/Program.cs` - 完全重写
|
||||||
|
2. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 简化依赖
|
||||||
|
3. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
|
||||||
|
4. `LanMountainDesktop/Program.cs` - 添加 LaunchContext
|
||||||
|
5. `LanMountainDesktop/App.axaml.cs` - 添加 OOBE/Splash/更新入口
|
||||||
|
6. `.github/workflows/release.yml` - 调整打包流程
|
||||||
|
7. `.github/workflows/build.yml` - 适配新构建流程
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop/Services/Oobe/OobeService.cs`
|
||||||
|
2. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
|
||||||
|
3. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml.cs`
|
||||||
|
4. `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
|
||||||
|
5. `LanMountainDesktop/Views/Splash/SplashWindow.axaml.cs`
|
||||||
|
6. `LanMountainDesktop/Services/Update/UpdateService.cs`
|
||||||
|
7. `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
|
||||||
|
8. `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Launcher/App.axaml`
|
||||||
|
2. `LanMountainDesktop.Launcher/App.axaml.cs`
|
||||||
|
3. `LanMountainDesktop.Launcher/Views/` 目录
|
||||||
|
4. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||||
|
5. `LanMountainDesktop.Launcher/Services/OobeStateService.cs`
|
||||||
|
6. `LanMountainDesktop.Launcher/Services/UpdateCheckService.cs`
|
||||||
|
7. `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
|
||||||
|
8. `LanMountainDesktop.Launcher/Services/PluginInstallerService.cs`
|
||||||
|
9. `LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与回滚方案
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
1. **启动失败**: 新 Launcher 可能有 bug 导致无法启动
|
||||||
|
2. **更新中断**: 更新逻辑迁移可能导致更新失败
|
||||||
|
3. **图标丢失**: 图标配置错误导致快捷方式无图标
|
||||||
|
|
||||||
|
### 回滚方案
|
||||||
|
|
||||||
|
1. 保留原 Launcher 代码分支
|
||||||
|
2. 准备紧急修复版本
|
||||||
|
3. 用户可手动下载完整安装包恢复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- [ ] Launcher 能正常启动主程序
|
||||||
|
- [ ] 版本选择逻辑正确
|
||||||
|
- [ ] 旧版本清理正常
|
||||||
|
- [ ] OOBE 流程正常
|
||||||
|
- [ ] Splash 显示正常
|
||||||
|
- [ ] 更新检查正常
|
||||||
|
- [ ] 插件安装正常
|
||||||
|
- [ ] GitHub Actions 打包成功
|
||||||
|
- [ ] 安装程序图标正常
|
||||||
|
- [ ] 快捷方式图标正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施顺序建议
|
||||||
|
|
||||||
|
### 第一阶段(立即实施)
|
||||||
|
1. 重写 Launcher Program.cs
|
||||||
|
2. 修改 Launcher.csproj
|
||||||
|
3. 移除主程序对 Launcher 的引用
|
||||||
|
4. 测试基本启动功能
|
||||||
|
|
||||||
|
### 第二阶段(功能迁移)
|
||||||
|
1. 迁移 OOBE 到主程序
|
||||||
|
2. 迁移 Splash 到主程序
|
||||||
|
3. 迁移更新逻辑到主程序
|
||||||
|
4. 迁移插件管理到主程序
|
||||||
|
|
||||||
|
### 第三阶段(CI/CD)
|
||||||
|
1. 修改 release.yml
|
||||||
|
2. 修改 build.yml
|
||||||
|
3. 测试打包流程
|
||||||
|
4. 验证安装程序
|
||||||
|
|
||||||
|
### 第四阶段(优化)
|
||||||
|
1. 实现 Launcher 自更新
|
||||||
|
2. 性能优化
|
||||||
|
3. 清理旧代码
|
||||||
717
.trae/documents/launcher_improved_plan_v2.md
Normal file
717
.trae/documents/launcher_improved_plan_v2.md
Normal file
@@ -0,0 +1,717 @@
|
|||||||
|
# LanMountainDesktop Launcher 改进计划 V2
|
||||||
|
|
||||||
|
## 核心设计理念
|
||||||
|
|
||||||
|
**Launcher 是核心协调器,不是极简启动器**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Launcher 职责定位 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Launcher 负责(启动前 & 退出后): │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • OOBE 首次引导 │ │
|
||||||
|
│ │ • 启动动画 (Splash) │ │
|
||||||
|
│ │ • 插件安装 │ │
|
||||||
|
│ │ • 插件更新 │ │
|
||||||
|
│ │ • 应用增量更新安装(不是下载!) │ │
|
||||||
|
│ │ • 应用静默更新安装 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 主程序负责(运行时): │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 多线程下载(有完整 Downloader) │ │
|
||||||
|
│ │ • 更新渠道切换 │ │
|
||||||
|
│ │ • 下载管理 │ │
|
||||||
|
│ │ • 与 Launcher 通讯(启动进度) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 关键优势: │
|
||||||
|
│ • Launcher 在应用启动前运行 → 可以安装更新而不担心文件占用 │
|
||||||
|
│ • Launcher 在应用退出后运行 → 可以完成待处理的安装任务 │
|
||||||
|
│ • 主程序专注下载 → 利用完整的多线程下载器提高效率 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 为什么保留 Avalonia?
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 保留 Avalonia 的理由 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. 启动画面 (Splash) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 需要显示启动进度 │ │
|
||||||
|
│ │ • 需要显示品牌 Logo │ │
|
||||||
|
│ │ • 需要流畅的动画效果 │ │
|
||||||
|
│ │ • 纯 Win32 实现复杂且不易维护 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 2. OOBE 首次引导 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 需要多步骤向导界面 │ │
|
||||||
|
│ │ • 需要丰富的交互控件 │ │
|
||||||
|
│ │ • 需要与主程序一致的视觉风格 │ │
|
||||||
|
│ │ • Avalonia 提供完整的 UI 框架 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 3. 与主程序的技术栈一致 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ • 共享主题和资源 │ │
|
||||||
|
│ │ • 共享控件和样式 │ │
|
||||||
|
│ │ • 便于维护和迭代 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 改进后的架构设计
|
||||||
|
|
||||||
|
### 目录结构(保持不变)
|
||||||
|
|
||||||
|
```
|
||||||
|
安装根目录/
|
||||||
|
├── LanMountainDesktop.exe ← Launcher(Avalonia 应用)
|
||||||
|
├── app-1.0.0/ ← 版本目录
|
||||||
|
│ ├── .current ← 当前版本标记
|
||||||
|
│ ├── LanMountainDesktop.exe ← 主程序
|
||||||
|
│ └── ... (所有依赖)
|
||||||
|
└── .launcher/ ← Launcher 数据目录
|
||||||
|
├── update/ ← 更新缓存
|
||||||
|
│ └── incoming/ ← 下载的更新包(主程序下载到这里)
|
||||||
|
└── snapshots/ ← 版本快照
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心流程设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 启动流程(含通讯机制) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. 用户启动 LanMountainDesktop.exe (Launcher) │
|
||||||
|
│ ↓ │
|
||||||
|
│ 2. Launcher 检查是否有待处理的更新安装 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 3. 有更新?──Yes──▶ 显示 Splash "正在安装更新..." │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ No 安装更新(增量/静默) │
|
||||||
|
│ ↓ ↓ │
|
||||||
|
│ 4. 检查是否首次运行 ──Yes──▶ 显示 OOBE 窗口 │
|
||||||
|
│ ↓ No ↓ │
|
||||||
|
│ 5. 显示 Splash "正在启动..." 完成 OOBE │
|
||||||
|
│ ↓ │
|
||||||
|
│ 6. 启动主程序进程(带通讯参数) │
|
||||||
|
│ ↓ │
|
||||||
|
│ 7. Launcher 保持运行,监听主程序进度 ─────── IPC 通讯 ───────▶ 主程序 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 8. 主程序报告启动进度 ─────── IPC 通讯 ───────▶ Launcher 更新 Splash │
|
||||||
|
│ ↓ │
|
||||||
|
│ 9. 主程序完全启动 ──Yes──▶ Launcher 关闭 Splash,进入后台/退出 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 退出流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 退出流程(处理待安装任务) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. 主程序准备退出 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 2. 检查是否有待安装的更新/插件 ──Yes──▶ 重启 Launcher 并传递参数 │
|
||||||
|
│ ↓ No ↓ │
|
||||||
|
│ 3. 正常退出 Launcher 在应用退出后运行 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 安装待处理的任务 │
|
||||||
|
│ ↓ │
|
||||||
|
│ 完成后再次启动主程序 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Launcher 与主程序的通讯机制
|
||||||
|
|
||||||
|
### IPC 方案选择
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ IPC 通讯方案 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 方案 1: 命令行参数 + 退出码(推荐用于启动阶段) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Launcher 启动主程序: │ │
|
||||||
|
│ │ LanMountainDesktop.exe --launcher-pid 12345 --ipc-port 50000 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 主程序通过命名管道/HTTP 与 Launcher 通讯 │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 方案 2: 命名管道(推荐用于进度报告) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
|
||||||
|
│ │ 主程序连接并发送进度消息 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 消息格式: JSON │ │
|
||||||
|
│ │ {"stage": "initializing", "progress": 30, "message": "加载设置..."} │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 方案 3: 共享内存/文件(简单状态同步) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Launcher 和主程序读写同一个状态文件 │ │
|
||||||
|
│ │ .launcher/state/startup_status.json │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通讯协议设计
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 共享契约(LanMountainDesktop.Shared.Contracts)
|
||||||
|
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
public enum StartupStage
|
||||||
|
{
|
||||||
|
Initializing,
|
||||||
|
LoadingSettings,
|
||||||
|
LoadingPlugins,
|
||||||
|
InitializingUI,
|
||||||
|
Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StartupProgressMessage
|
||||||
|
{
|
||||||
|
public StartupStage Stage { get; init; }
|
||||||
|
public int ProgressPercent { get; init; } // 0-100
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LauncherIpc
|
||||||
|
{
|
||||||
|
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||||
|
public const string EnvironmentVariablePrefix = "LMD_";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细实施步骤
|
||||||
|
|
||||||
|
### P0: 架构调整(核心)
|
||||||
|
|
||||||
|
#### 1. 调整 Launcher 项目引用
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
|
||||||
|
|
||||||
|
**修改**:
|
||||||
|
- 保留 Avalonia 依赖
|
||||||
|
- 移除 PluginSdk 引用(Launcher 不需要)
|
||||||
|
- 添加 Shared.Contracts 引用(用于 IPC)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationIcon>Assetsogo_nightly.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- 保留 Avalonia -->
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
||||||
|
|
||||||
|
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- 图标资源 -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 移除主程序对 Launcher 的引用
|
||||||
|
|
||||||
|
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||||
|
|
||||||
|
**修改**: 删除 Launcher 引用
|
||||||
|
```xml
|
||||||
|
<!-- 删除 -->
|
||||||
|
<!-- <ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" /> -->
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 创建 IPC 通讯契约
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
public enum StartupStage
|
||||||
|
{
|
||||||
|
Initializing,
|
||||||
|
LoadingSettings,
|
||||||
|
LoadingPlugins,
|
||||||
|
InitializingUI,
|
||||||
|
Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StartupProgressMessage
|
||||||
|
{
|
||||||
|
public StartupStage Stage { get; init; }
|
||||||
|
public int ProgressPercent { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LauncherIpcConstants
|
||||||
|
{
|
||||||
|
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||||
|
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
|
||||||
|
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
|
||||||
|
public const string VersionEnvVar = "LMD_VERSION";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P1: Launcher 端实现
|
||||||
|
|
||||||
|
#### 4. 实现 IPC 服务端
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
|
|
||||||
|
public class LauncherIpcServer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private NamedPipeServerStream? _pipeServer;
|
||||||
|
private readonly Action<StartupProgressMessage> _onProgress;
|
||||||
|
|
||||||
|
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||||
|
{
|
||||||
|
_onProgress = onProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipeServer = new NamedPipeServerStream(
|
||||||
|
LauncherIpcConstants.PipeName,
|
||||||
|
PipeDirection.In,
|
||||||
|
1,
|
||||||
|
PipeTransmissionMode.Message);
|
||||||
|
|
||||||
|
await _pipeServer.WaitForConnectionAsync(_cts.Token);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(_pipeServer);
|
||||||
|
var json = await reader.ReadToEndAsync(_cts.Token);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||||
|
if (message != null)
|
||||||
|
{
|
||||||
|
_onProgress(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pipeServer.Disconnect();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"IPC error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_pipeServer?.Dispose();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. 修改 Launcher 启动流程
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<LauncherResult> RunAsync()
|
||||||
|
{
|
||||||
|
// 1. 清理旧版本
|
||||||
|
_deploymentLocator.CleanupDestroyedDeployments();
|
||||||
|
|
||||||
|
// 2. 检查并安装待处理的更新(主程序下载的)
|
||||||
|
var pendingUpdate = _updateEngine.CheckPendingUpdate();
|
||||||
|
if (pendingUpdate.HasUpdate)
|
||||||
|
{
|
||||||
|
_splashWindow?.UpdateStatus("正在安装更新...");
|
||||||
|
var updateResult = await _updateEngine.ApplyPendingUpdateAsync();
|
||||||
|
if (!updateResult.Success)
|
||||||
|
{
|
||||||
|
return updateResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查并安装待处理的插件更新
|
||||||
|
var pendingPlugins = _pluginUpgradeQueueService.CheckPendingUpgrades();
|
||||||
|
if (pendingPlugins.HasUpgrades)
|
||||||
|
{
|
||||||
|
_splashWindow?.UpdateStatus("正在更新插件...");
|
||||||
|
var pluginResult = _pluginUpgradeQueueService.ApplyPendingUpgrades();
|
||||||
|
if (!pluginResult.Success)
|
||||||
|
{
|
||||||
|
return pluginResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. OOBE
|
||||||
|
if (_oobeStateService.IsFirstRun())
|
||||||
|
{
|
||||||
|
_splashWindow?.Hide();
|
||||||
|
foreach (var step in _oobeSteps)
|
||||||
|
{
|
||||||
|
await step.RunAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
_splashWindow?.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 启动 IPC 服务端监听主程序进度
|
||||||
|
using var ipcServer = new LauncherIpcServer(msg =>
|
||||||
|
{
|
||||||
|
_splashWindow?.UpdateProgress(msg.ProgressPercent, msg.Message);
|
||||||
|
});
|
||||||
|
_ = ipcServer.StartAsync();
|
||||||
|
|
||||||
|
// 6. 启动主程序
|
||||||
|
_splashWindow?.UpdateStatus("正在启动...");
|
||||||
|
var hostResult = LaunchHostWithIpc();
|
||||||
|
if (!hostResult.Success)
|
||||||
|
{
|
||||||
|
return hostResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 等待主程序报告就绪或超时
|
||||||
|
await WaitForHostReadyOrTimeoutAsync(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
return new LauncherResult { Success = true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2: 主程序端实现
|
||||||
|
|
||||||
|
#### 6. 实现 IPC 客户端
|
||||||
|
|
||||||
|
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Launcher;
|
||||||
|
|
||||||
|
public class LauncherIpcClient : IDisposable
|
||||||
|
{
|
||||||
|
private NamedPipeClientStream? _pipeClient;
|
||||||
|
|
||||||
|
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_pipeClient = new NamedPipeClientStream(
|
||||||
|
".",
|
||||||
|
LauncherIpcConstants.PipeName,
|
||||||
|
PipeDirection.Out);
|
||||||
|
|
||||||
|
await _pipeClient.ConnectAsync(5000, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReportProgressAsync(StartupProgressMessage message)
|
||||||
|
{
|
||||||
|
if (_pipeClient?.IsConnected != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(message);
|
||||||
|
using var writer = new StreamWriter(_pipeClient, leaveOpen: true);
|
||||||
|
await writer.WriteAsync(json);
|
||||||
|
await writer.FlushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_pipeClient?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. 主程序启动时报告进度
|
||||||
|
|
||||||
|
**修改文件**: `LanMountainDesktop/App.axaml.cs`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override async void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
// 检查是否从 Launcher 启动
|
||||||
|
var launcherPid = Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar);
|
||||||
|
if (!string.IsNullOrEmpty(launcherPid))
|
||||||
|
{
|
||||||
|
// 连接到 Launcher 的 IPC 服务端
|
||||||
|
_launcherIpc = new LauncherIpcClient();
|
||||||
|
await _launcherIpc.ConnectAsync();
|
||||||
|
|
||||||
|
// 报告启动进度
|
||||||
|
await _launcherIpc.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.Initializing,
|
||||||
|
ProgressPercent = 10,
|
||||||
|
Message = "正在初始化..."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化设置
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.LoadingSettings,
|
||||||
|
ProgressPercent = 30,
|
||||||
|
Message = "正在加载设置..."
|
||||||
|
});
|
||||||
|
InitializeSettings();
|
||||||
|
|
||||||
|
// 加载插件
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.LoadingPlugins,
|
||||||
|
ProgressPercent = 50,
|
||||||
|
Message = "正在加载插件..."
|
||||||
|
});
|
||||||
|
await InitializePluginsAsync();
|
||||||
|
|
||||||
|
// 初始化 UI
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.InitializingUI,
|
||||||
|
ProgressPercent = 80,
|
||||||
|
Message = "正在初始化界面..."
|
||||||
|
});
|
||||||
|
InitializeUI();
|
||||||
|
|
||||||
|
// 就绪
|
||||||
|
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.Ready,
|
||||||
|
ProgressPercent = 100,
|
||||||
|
Message = "就绪"
|
||||||
|
});
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P3: 更新流程整合
|
||||||
|
|
||||||
|
#### 8. 主程序下载更新
|
||||||
|
|
||||||
|
**主程序职责**:
|
||||||
|
```csharp
|
||||||
|
// 主程序中的更新服务
|
||||||
|
public class AppUpdateService
|
||||||
|
{
|
||||||
|
public async Task DownloadUpdateAsync(string version, string downloadUrl)
|
||||||
|
{
|
||||||
|
// 使用多线程下载器下载更新包
|
||||||
|
var downloader = new MultiThreadedDownloader();
|
||||||
|
var targetPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"LanMountainDesktop",
|
||||||
|
".launcher",
|
||||||
|
"update",
|
||||||
|
"incoming",
|
||||||
|
$"update-{version}.zip");
|
||||||
|
|
||||||
|
await downloader.DownloadAsync(downloadUrl, targetPath);
|
||||||
|
|
||||||
|
// 标记为待安装
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(Path.GetDirectoryName(targetPath)!, ".pending"),
|
||||||
|
version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9. Launcher 安装更新
|
||||||
|
|
||||||
|
**Launcher 职责**:
|
||||||
|
```csharp
|
||||||
|
// Launcher 中的更新安装服务
|
||||||
|
public class UpdateInstallationService
|
||||||
|
{
|
||||||
|
public async Task<InstallResult> InstallPendingUpdateAsync()
|
||||||
|
{
|
||||||
|
var pendingPath = Path.Combine(
|
||||||
|
_appRoot,
|
||||||
|
".launcher",
|
||||||
|
"update",
|
||||||
|
"incoming",
|
||||||
|
".pending");
|
||||||
|
|
||||||
|
if (!File.Exists(pendingPath))
|
||||||
|
return InstallResult.NoUpdate;
|
||||||
|
|
||||||
|
var version = File.ReadAllText(pendingPath);
|
||||||
|
var updatePackagePath = Path.Combine(
|
||||||
|
Path.GetDirectoryName(pendingPath)!,
|
||||||
|
$"update-{version}.zip");
|
||||||
|
|
||||||
|
// 创建新版本目录
|
||||||
|
var newVersionDir = Path.Combine(_appRoot, $"app-{version}");
|
||||||
|
Directory.CreateDirectory(newVersionDir);
|
||||||
|
File.WriteAllText(Path.Combine(newVersionDir, ".partial"), "");
|
||||||
|
|
||||||
|
// 解压更新包
|
||||||
|
ZipFile.ExtractToDirectory(updatePackagePath, newVersionDir);
|
||||||
|
|
||||||
|
// 验证文件完整性
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// 切换版本标记
|
||||||
|
var currentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||||
|
if (currentDir != null)
|
||||||
|
{
|
||||||
|
File.Delete(Path.Combine(currentDir, ".current"));
|
||||||
|
File.WriteAllText(Path.Combine(currentDir, ".destroy"), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(Path.Combine(newVersionDir, ".current"), "");
|
||||||
|
File.Delete(Path.Combine(newVersionDir, ".partial"));
|
||||||
|
|
||||||
|
// 清理待安装标记
|
||||||
|
File.Delete(pendingPath);
|
||||||
|
File.Delete(updatePackagePath);
|
||||||
|
|
||||||
|
return InstallResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P4: GitHub Actions 工作流
|
||||||
|
|
||||||
|
#### 10. 修改 release.yml
|
||||||
|
|
||||||
|
**关键修改点**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 1. Launcher 单独编译(保留 Avalonia)
|
||||||
|
- name: Publish Launcher
|
||||||
|
run: |
|
||||||
|
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
|
||||||
|
-c Release `
|
||||||
|
-o ./publish/launcher-win-x64 `
|
||||||
|
--self-contained `
|
||||||
|
-r win-x64 `
|
||||||
|
-p:PublishSingleFile=false `
|
||||||
|
-p:DebugType=none
|
||||||
|
|
||||||
|
# 2. 目录结构调整
|
||||||
|
- name: Restructure for Launcher
|
||||||
|
run: |
|
||||||
|
$version = "${{ needs.prepare.outputs.version }}"
|
||||||
|
$publishDir = "publish/windows-x64"
|
||||||
|
$launcherDir = "publish/launcher-win-x64"
|
||||||
|
$appDir = "app-$version"
|
||||||
|
|
||||||
|
# 创建新结构
|
||||||
|
$newStructure = "publish-launcher/windows-x64"
|
||||||
|
New-Item -ItemType Directory -Path $newStructure -Force
|
||||||
|
|
||||||
|
# 移动主程序到 app-{version}/
|
||||||
|
$appPath = Join-Path $newStructure $appDir
|
||||||
|
Move-Item -Path $publishDir -Destination $appPath -Force
|
||||||
|
|
||||||
|
# 复制 Launcher 到根目录
|
||||||
|
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
|
||||||
|
|
||||||
|
# 创建 .current 标记
|
||||||
|
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件变更清单
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 调整引用
|
||||||
|
2. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
|
||||||
|
3. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` - 添加 IPC 和更新安装
|
||||||
|
4. `LanMountainDesktop/App.axaml.cs` - 添加 IPC 客户端和进度报告
|
||||||
|
5. `.github/workflows/release.yml` - 调整打包流程
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
|
||||||
|
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
|
||||||
|
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
|
||||||
|
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
|
||||||
|
|
||||||
|
### 删除文件
|
||||||
|
|
||||||
|
1. 主程序对 Launcher 的项目引用(已存在)
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
### 第一阶段:基础架构
|
||||||
|
1. 创建 IPC 契约(Shared.Contracts)
|
||||||
|
2. 调整 Launcher 项目引用
|
||||||
|
3. 移除主程序对 Launcher 的引用
|
||||||
|
4. 测试基本启动
|
||||||
|
|
||||||
|
### 第二阶段:IPC 实现
|
||||||
|
1. 实现 Launcher IPC 服务端
|
||||||
|
2. 实现主程序 IPC 客户端
|
||||||
|
3. 测试进度报告
|
||||||
|
|
||||||
|
### 第三阶段:更新流程
|
||||||
|
1. 主程序实现下载功能
|
||||||
|
2. Launcher 实现安装功能
|
||||||
|
3. 测试完整更新流程
|
||||||
|
|
||||||
|
### 第四阶段:CI/CD
|
||||||
|
1. 修改 GitHub Actions
|
||||||
|
2. 测试打包流程
|
||||||
|
3. 验证安装程序
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- [ ] Launcher 能正常启动主程序
|
||||||
|
- [ ] Launcher 显示 Splash 并接收进度更新
|
||||||
|
- [ ] 主程序能向 Launcher 报告启动进度
|
||||||
|
- [ ] 主程序能下载更新
|
||||||
|
- [ ] Launcher 能安装待处理的更新
|
||||||
|
- [ ] OOBE 流程正常
|
||||||
|
- [ ] 插件更新流程正常
|
||||||
|
- [ ] GitHub Actions 打包成功
|
||||||
|
- [ ] 安装程序图标正常
|
||||||
|
- [ ] 快捷方式图标正常
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<Application xmlns="https://github.com/avaloniaui"
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:sty="using:FluentAvalonia.Styling"
|
||||||
x:Class="LanMountainDesktop.Launcher.App"
|
x:Class="LanMountainDesktop.Launcher.App"
|
||||||
RequestedThemeVariant="Default">
|
RequestedThemeVariant="Default">
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<sty:FluentAvaloniaTheme />
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -10,13 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<!-- 只引用 Shared.Contracts(IPC 协议) -->
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
|
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
|
||||||
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.106" PrivateAssets="all" />
|
||||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ internal sealed class DeploymentLocator
|
|||||||
public string? ResolveHostExecutablePath()
|
public string? ResolveHostExecutablePath()
|
||||||
{
|
{
|
||||||
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||||
|
|
||||||
|
// 1. 首先查找 app-{version} 目录(生产环境)
|
||||||
var currentDeployment = FindCurrentDeploymentDirectory();
|
var currentDeployment = FindCurrentDeploymentDirectory();
|
||||||
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
if (!string.IsNullOrWhiteSpace(currentDeployment))
|
||||||
{
|
{
|
||||||
@@ -68,15 +70,98 @@ internal sealed class DeploymentLocator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 查找 Launcher 所在目录(开发环境 - 直接运行)
|
||||||
var inRoot = Path.Combine(_appRoot, executable);
|
var inRoot = Path.Combine(_appRoot, executable);
|
||||||
if (File.Exists(inRoot))
|
if (File.Exists(inRoot))
|
||||||
{
|
{
|
||||||
return inRoot;
|
return inRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 查找父目录(开发环境 - 从 Launcher 项目运行)
|
||||||
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
|
||||||
var inParent = Path.Combine(parent, executable);
|
var inParent = Path.Combine(parent, executable);
|
||||||
return File.Exists(inParent) ? inParent : null;
|
if (File.Exists(inParent))
|
||||||
|
{
|
||||||
|
return inParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 开发模式:如果启用了开发模式,优先扫描开发路径
|
||||||
|
if (Views.ErrorWindow.CheckDevModeEnabled())
|
||||||
|
{
|
||||||
|
var devPath = ScanDevelopmentPaths(executable);
|
||||||
|
if (!string.IsNullOrWhiteSpace(devPath))
|
||||||
|
{
|
||||||
|
return devPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 开发模式:查找主程序项目的输出目录
|
||||||
|
var devPaths = GetDevelopmentPaths(executable);
|
||||||
|
foreach (var devPath in devPaths)
|
||||||
|
{
|
||||||
|
if (File.Exists(devPath))
|
||||||
|
{
|
||||||
|
return devPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 扫描开发路径(开发模式)
|
||||||
|
/// </summary>
|
||||||
|
private static string? ScanDevelopmentPaths(string executable)
|
||||||
|
{
|
||||||
|
var possiblePaths = new[]
|
||||||
|
{
|
||||||
|
// 从 Launcher 项目运行
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// 从解决方案根目录运行
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// dev-test 目录
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取开发环境可能的主程序路径
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string> GetDevelopmentPaths(string executable)
|
||||||
|
{
|
||||||
|
// 获取 Launcher 所在目录
|
||||||
|
var launcherDir = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
// 可能的开发目录结构
|
||||||
|
var possiblePaths = new[]
|
||||||
|
{
|
||||||
|
// 从 Launcher 项目运行:..\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||||
|
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// 从解决方案根目录运行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
|
||||||
|
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
|
||||||
|
// 从 dev-test 目录运行
|
||||||
|
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||||
|
};
|
||||||
|
|
||||||
|
return possiblePaths.Select(Path.GetFullPath).Distinct();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetCurrentVersion()
|
public string GetCurrentVersion()
|
||||||
|
|||||||
109
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
109
LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
|
||||||
|
/// </summary>
|
||||||
|
public class LauncherIpcServer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private NamedPipeServerStream? _pipeServer;
|
||||||
|
private readonly Action<StartupProgressMessage> _onProgress;
|
||||||
|
private Task? _listenTask;
|
||||||
|
|
||||||
|
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
|
||||||
|
{
|
||||||
|
_onProgress = onProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动 IPC 服务端监听
|
||||||
|
/// </summary>
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_listenTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipeServer = new NamedPipeServerStream(
|
||||||
|
LauncherIpcConstants.PipeName,
|
||||||
|
PipeDirection.In,
|
||||||
|
1,
|
||||||
|
PipeTransmissionMode.Message);
|
||||||
|
|
||||||
|
await _pipeServer.WaitForConnectionAsync(_cts.Token);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(_pipeServer);
|
||||||
|
var json = await reader.ReadToEndAsync(_cts.Token);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
|
||||||
|
if (message != null)
|
||||||
|
{
|
||||||
|
_onProgress(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipeServer.Disconnect();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// 管道断开,继续监听
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"IPC error: {ex.Message}");
|
||||||
|
await Task.Delay(100, _cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 停止 IPC 服务端
|
||||||
|
/// </summary>
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pipeServer?.Disconnect();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
_pipeServer?.Dispose();
|
||||||
|
_cts.Dispose();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listenTask?.Wait(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
using LanMountainDesktop.Launcher.Services.Ipc;
|
||||||
using LanMountainDesktop.Launcher.Views;
|
using LanMountainDesktop.Launcher.Views;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
@@ -39,14 +41,7 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
// 清理待删除的旧版本
|
// 清理待删除的旧版本
|
||||||
_deploymentLocator.CleanupDestroyedDeployments();
|
_deploymentLocator.CleanupDestroyedDeployments();
|
||||||
|
|
||||||
if (_oobeStateService.IsFirstRun())
|
// 显示 Splash 窗口
|
||||||
{
|
|
||||||
foreach (var step in _oobeSteps)
|
|
||||||
{
|
|
||||||
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
|
var splashWindow = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
var window = new SplashWindow();
|
var window = new SplashWindow();
|
||||||
@@ -56,16 +51,28 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
|
|
||||||
var reporter = (ISplashStageReporter)splashWindow;
|
var reporter = (ISplashStageReporter)splashWindow;
|
||||||
|
|
||||||
|
// 启动 IPC 服务端监听主程序进度
|
||||||
|
using var ipcServer = new LauncherIpcServer(msg =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
reporter.Report(msg.Stage.ToString().ToLower(), msg.Message ?? "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ipcServer.Start();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
reporter.Report("silentUpdate", "update");
|
// 检查并安装待处理的更新(主程序下载的)
|
||||||
|
reporter.Report("update", "检查更新...");
|
||||||
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
|
||||||
if (!updateResult.Success)
|
if (!updateResult.Success)
|
||||||
{
|
{
|
||||||
return updateResult;
|
return updateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter.Report("pluginTasks", "plugins");
|
// 检查并安装待处理的插件更新
|
||||||
|
reporter.Report("plugins", "检查插件更新...");
|
||||||
var pluginsDir = _context.GetOption("plugins-dir")
|
var pluginsDir = _context.GetOption("plugins-dir")
|
||||||
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
?? Path.Combine(_deploymentLocator.GetAppRoot(), "plugins");
|
||||||
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir);
|
||||||
@@ -74,13 +81,28 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
return queueResult;
|
return queueResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
reporter.Report("launchHost", "launch");
|
// OOBE(首次运行引导)
|
||||||
var hostResult = LaunchHost();
|
if (_oobeStateService.IsFirstRun())
|
||||||
|
{
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Hide());
|
||||||
|
foreach (var step in _oobeSteps)
|
||||||
|
{
|
||||||
|
await step.RunAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => splashWindow.Show());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动主程序
|
||||||
|
reporter.Report("launch", "正在启动...");
|
||||||
|
var hostResult = await LaunchHostWithIpcAsync();
|
||||||
if (!hostResult.Success)
|
if (!hostResult.Success)
|
||||||
{
|
{
|
||||||
return hostResult;
|
return hostResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 等待主程序就绪或超时
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
@@ -107,11 +129,28 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private LauncherResult LaunchHost()
|
private async Task<LauncherResult> LaunchHostWithIpcAsync(string? customHostPath = null)
|
||||||
{
|
{
|
||||||
var hostPath = _deploymentLocator.ResolveHostExecutablePath();
|
// 优先使用自定义路径(调试模式选择的路径)
|
||||||
|
var hostPath = customHostPath ?? _deploymentLocator.ResolveHostExecutablePath();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(hostPath))
|
if (string.IsNullOrWhiteSpace(hostPath))
|
||||||
{
|
{
|
||||||
|
// 关闭 Splash 窗口
|
||||||
|
// 显示错误窗口而不是直接退出
|
||||||
|
var (errorResult, selectedPath) = await ShowHostNotFoundErrorAsync();
|
||||||
|
|
||||||
|
if (errorResult == ErrorWindowResult.Retry)
|
||||||
|
{
|
||||||
|
// 用户选择重试,如果有选择路径则使用,否则重新尝试
|
||||||
|
if (!string.IsNullOrWhiteSpace(selectedPath))
|
||||||
|
{
|
||||||
|
return await LaunchHostWithIpcAsync(selectedPath);
|
||||||
|
}
|
||||||
|
return await LaunchHostWithIpcAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户选择退出
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
@@ -133,6 +172,12 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
|
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? _deploymentLocator.GetAppRoot()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 传递环境变量供 IPC 使用
|
||||||
|
processStartInfo.EnvironmentVariables[LauncherIpcConstants.LauncherPidEnvVar] =
|
||||||
|
Environment.ProcessId.ToString();
|
||||||
|
processStartInfo.EnvironmentVariables[LauncherIpcConstants.PackageRootEnvVar] =
|
||||||
|
_deploymentLocator.GetAppRoot();
|
||||||
|
|
||||||
Process.Start(processStartInfo);
|
Process.Start(processStartInfo);
|
||||||
return new LauncherResult
|
return new LauncherResult
|
||||||
{
|
{
|
||||||
@@ -143,6 +188,26 @@ internal sealed class LauncherFlowCoordinator
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 显示找不到主程序的错误窗口
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(ErrorWindowResult Result, string? CustomPath)> ShowHostNotFoundErrorAsync()
|
||||||
|
{
|
||||||
|
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
var errorWindow = new ErrorWindow();
|
||||||
|
errorWindow.SetErrorMessage("找不到阑山桌面应用程序。");
|
||||||
|
errorWindow.Show();
|
||||||
|
|
||||||
|
var result = await errorWindow.WaitForChoiceAsync();
|
||||||
|
var customPath = errorWindow.GetCustomHostPath();
|
||||||
|
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => errorWindow.Close());
|
||||||
|
|
||||||
|
return (result, customPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static void EnsureExecutable(string path)
|
private static void EnsureExecutable(string path)
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using System.Text.Json;
|
||||||
using LanMountainDesktop.Launcher.Models;
|
using LanMountainDesktop.Launcher.Models;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Services;
|
namespace LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 插件安装服务 - 简化版,不依赖 PluginSdk
|
||||||
|
/// </summary>
|
||||||
internal sealed class PluginInstallerService
|
internal sealed class PluginInstallerService
|
||||||
{
|
{
|
||||||
|
private const string ManifestFileName = "manifest.json";
|
||||||
|
private const string PackageFileExtension = ".lmdp";
|
||||||
|
private const string RuntimeDirectoryName = "runtime";
|
||||||
|
|
||||||
private static readonly TimeSpan[] RetryDelays =
|
private static readonly TimeSpan[] RetryDelays =
|
||||||
[
|
[
|
||||||
TimeSpan.FromMilliseconds(120),
|
TimeSpan.FromMilliseconds(120),
|
||||||
@@ -48,33 +55,40 @@ internal sealed class PluginInstallerService
|
|||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(packagePath);
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
var entries = archive.Entries
|
var entries = archive.Entries
|
||||||
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
.Where(entry => string.Equals(entry.Name, ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (entries.Length == 0)
|
if (entries.Length == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
$"Plugin package '{packagePath}' does not contain '{ManifestFileName}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.Length > 1)
|
if (entries.Length > 1)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
$"Plugin package '{packagePath}' contains multiple '{ManifestFileName}' files.");
|
||||||
}
|
}
|
||||||
|
|
||||||
using var stream = entries[0].Open();
|
using var stream = entries[0].Open();
|
||||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
using var reader = new StreamReader(stream);
|
||||||
|
var json = reader.ReadToEnd();
|
||||||
|
var manifest = JsonSerializer.Deserialize<PluginManifest>(json);
|
||||||
|
if (manifest == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to deserialize manifest from '{packagePath}'.");
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
private void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||||
{
|
{
|
||||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), RuntimeDirectoryName));
|
||||||
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
|
||||||
Directory.CreateDirectory(pendingDeletionDir);
|
Directory.CreateDirectory(pendingDeletionDir);
|
||||||
|
|
||||||
foreach (var existingPackagePath in Directory
|
foreach (var existingPackagePath in Directory
|
||||||
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
.EnumerateFiles(pluginsDirectory, "*" + PackageFileExtension, SearchOption.AllDirectories)
|
||||||
.Select(Path.GetFullPath)
|
.Select(Path.GetFullPath)
|
||||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
@@ -188,7 +202,7 @@ internal sealed class PluginInstallerService
|
|||||||
{
|
{
|
||||||
var invalidChars = Path.GetInvalidFileNameChars();
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
return fileName + PluginSdkInfo.PackageFileExtension;
|
return fileName + PackageFileExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string EnsureTrailingSeparator(string path)
|
private static string EnsureTrailingSeparator(string path)
|
||||||
@@ -198,3 +212,15 @@ internal sealed class PluginInstallerService
|
|||||||
: path + Path.DirectorySeparatorChar;
|
: path + Path.DirectorySeparatorChar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 简化的插件清单模型
|
||||||
|
/// </summary>
|
||||||
|
public class PluginManifest
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Version { get; set; } = "";
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Author { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ internal sealed class UpdateEngineService
|
|||||||
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
var currentDeployment = _deploymentLocator.FindCurrentDeploymentDirectory();
|
||||||
if (string.IsNullOrWhiteSpace(currentDeployment))
|
if (string.IsNullOrWhiteSpace(currentDeployment))
|
||||||
{
|
{
|
||||||
return Failed("update.apply", "no_current_deployment", "Current deployment directory not found.");
|
// 全新安装场景:没有当前部署目录,但有更新包
|
||||||
|
// 这种情况下应该直接应用更新作为首次安装
|
||||||
|
return await ApplyInitialDeploymentAsync(fileMap, archivePath, fileMapPath, signaturePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentVersion = _deploymentLocator.GetCurrentVersion();
|
var currentVersion = _deploymentLocator.GetCurrentVersion();
|
||||||
@@ -258,6 +260,167 @@ internal sealed class UpdateEngineService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全新安装场景:直接应用更新包作为首次部署
|
||||||
|
/// </summary>
|
||||||
|
private async Task<LauncherResult> ApplyInitialDeploymentAsync(
|
||||||
|
SignedFileMap fileMap,
|
||||||
|
string archivePath,
|
||||||
|
string fileMapPath,
|
||||||
|
string signaturePath)
|
||||||
|
{
|
||||||
|
var targetVersion = string.IsNullOrWhiteSpace(fileMap.ToVersion) ? "1.0.0" : fileMap.ToVersion!;
|
||||||
|
var targetDeployment = _deploymentLocator.BuildNextDeploymentDirectory(targetVersion);
|
||||||
|
var partialMarker = Path.Combine(targetDeployment, ".partial");
|
||||||
|
var snapshotPath = Path.Combine(_snapshotsRoot, $"initial-{Guid.NewGuid():N}.json");
|
||||||
|
|
||||||
|
var extractRoot = Path.Combine(_incomingRoot, "extracted");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 保存快照(用于回滚,虽然首次安装回滚意义不大)
|
||||||
|
var snapshot = new SnapshotMetadata
|
||||||
|
{
|
||||||
|
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||||
|
SourceVersion = "0.0.0",
|
||||||
|
TargetVersion = targetVersion,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
SourceDirectory = "",
|
||||||
|
TargetDirectory = targetDeployment,
|
||||||
|
Status = "pending"
|
||||||
|
};
|
||||||
|
SaveSnapshot(snapshotPath, snapshot);
|
||||||
|
|
||||||
|
// 清理并解压更新包
|
||||||
|
if (Directory.Exists(extractRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(extractRoot, true);
|
||||||
|
}
|
||||||
|
Directory.CreateDirectory(extractRoot);
|
||||||
|
ZipFile.ExtractToDirectory(archivePath, extractRoot, overwriteFiles: true);
|
||||||
|
|
||||||
|
// 创建目标部署目录
|
||||||
|
Directory.CreateDirectory(targetDeployment);
|
||||||
|
File.WriteAllText(partialMarker, string.Empty);
|
||||||
|
|
||||||
|
// 应用所有文件(全新安装时,所有文件都是新增或替换)
|
||||||
|
foreach (var file in fileMap.Files)
|
||||||
|
{
|
||||||
|
ApplyInitialFileEntry(file, targetDeployment, extractRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件哈希
|
||||||
|
foreach (var file in fileMap.Files)
|
||||||
|
{
|
||||||
|
if (!NeedsVerification(file))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = Path.Combine(targetDeployment, file.Path);
|
||||||
|
var actualHash = ComputeSha256Hex(fullPath);
|
||||||
|
if (!string.Equals(actualHash, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"File hash mismatch for '{file.Path}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活部署(创建 .current 标记,删除 .partial 标记)
|
||||||
|
var currentMarker = Path.Combine(targetDeployment, ".current");
|
||||||
|
File.WriteAllText(currentMarker, string.Empty);
|
||||||
|
if (File.Exists(partialMarker))
|
||||||
|
{
|
||||||
|
File.Delete(partialMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理更新包
|
||||||
|
snapshot.Status = "applied";
|
||||||
|
SaveSnapshot(snapshotPath, snapshot);
|
||||||
|
CleanupIncomingArtifacts();
|
||||||
|
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Stage = "update.apply",
|
||||||
|
Code = "ok",
|
||||||
|
Message = $"Initial deployment to {targetVersion}.",
|
||||||
|
CurrentVersion = "0.0.0",
|
||||||
|
TargetVersion = targetVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 清理失败的目标目录
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(targetDeployment))
|
||||||
|
{
|
||||||
|
Directory.Delete(targetDeployment, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LauncherResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Stage = "update.apply",
|
||||||
|
Code = "initial_deploy_failed",
|
||||||
|
Message = "Failed to apply initial deployment.",
|
||||||
|
ErrorMessage = ex.Message,
|
||||||
|
CurrentVersion = "0.0.0",
|
||||||
|
TargetVersion = targetVersion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(extractRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(extractRoot, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 应用初始部署文件(全新安装场景,不需要源目录)
|
||||||
|
/// </summary>
|
||||||
|
private void ApplyInitialFileEntry(UpdateFileEntry file, string targetDeployment, string extractRoot)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizeRelativePath(file.Path);
|
||||||
|
|
||||||
|
// 删除操作在全新安装时忽略
|
||||||
|
if (string.Equals(file.Action, "delete", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetPath = Path.Combine(targetDeployment, normalizedPath);
|
||||||
|
EnsurePathWithinRoot(targetPath, targetDeployment);
|
||||||
|
var targetDir = Path.GetDirectoryName(targetPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(targetDir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无论是 add 还是 replace,都从压缩包复制
|
||||||
|
var archiveRelative = string.IsNullOrWhiteSpace(file.ArchivePath) ? normalizedPath : NormalizeRelativePath(file.ArchivePath);
|
||||||
|
var extractedPath = Path.Combine(extractRoot, archiveRelative);
|
||||||
|
EnsurePathWithinRoot(extractedPath, extractRoot);
|
||||||
|
|
||||||
|
if (!File.Exists(extractedPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Archive file '{archiveRelative}' not found for '{file.Path}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(extractedPath, targetPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
public LauncherResult RollbackLatest()
|
public LauncherResult RollbackLatest()
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(_snapshotsRoot))
|
if (!Directory.Exists(_snapshotsRoot))
|
||||||
|
|||||||
106
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
106
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="420"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.ErrorDebugWindow"
|
||||||
|
x:DataType="views:ErrorDebugWindow"
|
||||||
|
Title="调试模式"
|
||||||
|
Width="420"
|
||||||
|
Height="320"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:ErrorDebugWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid Margin="24" RowDefinitions="Auto,*,Auto">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
Text="调试设置"
|
||||||
|
FontSize="20"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
Margin="0,0,0,16" />
|
||||||
|
|
||||||
|
<!-- 设置内容 -->
|
||||||
|
<StackPanel Grid.Row="1" Spacing="16">
|
||||||
|
<!-- 开发模式开关 -->
|
||||||
|
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="开发模式"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
<TextBlock Text="启用后自动扫描开发目录"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
Margin="0,2,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
<ToggleSwitch x:Name="DevModeToggle"
|
||||||
|
Grid.Column="1"
|
||||||
|
OnContent="开"
|
||||||
|
OffContent="关" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 应用路径选择 -->
|
||||||
|
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Padding="16,12">
|
||||||
|
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||||
|
Text="应用路径"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
<TextBlock x:Name="PathTextBlock"
|
||||||
|
Grid.Row="1" Grid.Column="0"
|
||||||
|
Text="未选择"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Margin="0,4,12,0" />
|
||||||
|
<Button x:Name="BrowseButton"
|
||||||
|
Grid.Row="0" Grid.RowSpan="2" Grid.Column="1"
|
||||||
|
Content="浏览..."
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 提示信息 -->
|
||||||
|
<Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}"
|
||||||
|
CornerRadius="{DynamicResource ControlCornerRadius}"
|
||||||
|
Padding="12,10"
|
||||||
|
IsVisible="True">
|
||||||
|
<TextBlock Text="此功能仅供开发人员使用"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{DynamicResource SystemFillColorCautionBrush}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 按钮区域 -->
|
||||||
|
<StackPanel Grid.Row="2"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Spacing="12"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<Button x:Name="CancelButton"
|
||||||
|
Content="取消"
|
||||||
|
Width="80"
|
||||||
|
Height="32" />
|
||||||
|
<Button x:Name="OkButton"
|
||||||
|
Content="确定"
|
||||||
|
Width="80"
|
||||||
|
Height="32"
|
||||||
|
Theme="{DynamicResource AccentButtonTheme}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
128
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
128
LanMountainDesktop.Launcher/Views/ErrorDebugWindow.axaml.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误调试窗口 - 开发人员专用调试设置
|
||||||
|
/// </summary>
|
||||||
|
public partial class ErrorDebugWindow : Window
|
||||||
|
{
|
||||||
|
private string? _selectedHostPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用了开发模式
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDevModeEnabled { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 选择的主程序路径
|
||||||
|
/// </summary>
|
||||||
|
public string? SelectedHostPath => _selectedHostPath;
|
||||||
|
|
||||||
|
public ErrorDebugWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
InitializeComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ErrorDebugWindow(bool devModeEnabled, string? initialPath) : this()
|
||||||
|
{
|
||||||
|
IsDevModeEnabled = devModeEnabled;
|
||||||
|
_selectedHostPath = initialPath;
|
||||||
|
|
||||||
|
// 设置初始值
|
||||||
|
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||||
|
if (devModeToggle is not null)
|
||||||
|
{
|
||||||
|
devModeToggle.IsChecked = devModeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdatePathDisplay(initialPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponents()
|
||||||
|
{
|
||||||
|
// 开发模式开关
|
||||||
|
var devModeToggle = this.FindControl<ToggleSwitch>("DevModeToggle");
|
||||||
|
if (devModeToggle is not null)
|
||||||
|
{
|
||||||
|
devModeToggle.IsCheckedChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
IsDevModeEnabled = devModeToggle.IsChecked ?? false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 浏览按钮
|
||||||
|
var browseButton = this.FindControl<Button>("BrowseButton");
|
||||||
|
if (browseButton is not null)
|
||||||
|
{
|
||||||
|
browseButton.Click += OnBrowseClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定按钮
|
||||||
|
var okButton = this.FindControl<Button>("OkButton");
|
||||||
|
if (okButton is not null)
|
||||||
|
{
|
||||||
|
okButton.Click += (s, e) => Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消按钮
|
||||||
|
var cancelButton = this.FindControl<Button>("CancelButton");
|
||||||
|
if (cancelButton is not null)
|
||||||
|
{
|
||||||
|
cancelButton.Click += (s, e) =>
|
||||||
|
{
|
||||||
|
// 取消时恢复原始状态
|
||||||
|
IsDevModeEnabled = false;
|
||||||
|
_selectedHostPath = null;
|
||||||
|
Close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 浏览按钮点击
|
||||||
|
/// </summary>
|
||||||
|
private async void OnBrowseClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var storageProvider = StorageProvider;
|
||||||
|
if (storageProvider is null) return;
|
||||||
|
|
||||||
|
var options = new FilePickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "选择阑山桌面主程序",
|
||||||
|
AllowMultiple = false,
|
||||||
|
FileTypeFilter = new[]
|
||||||
|
{
|
||||||
|
new FilePickerFileType("可执行文件")
|
||||||
|
{
|
||||||
|
Patterns = OperatingSystem.IsWindows()
|
||||||
|
? new[] { "*.exe" }
|
||||||
|
: new[] { "*" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await storageProvider.OpenFilePickerAsync(options);
|
||||||
|
if (result.Count > 0)
|
||||||
|
{
|
||||||
|
_selectedHostPath = result[0].Path.LocalPath;
|
||||||
|
UpdatePathDisplay(_selectedHostPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新路径显示
|
||||||
|
/// </summary>
|
||||||
|
private void UpdatePathDisplay(string? path)
|
||||||
|
{
|
||||||
|
var pathTextBlock = this.FindControl<TextBlock>("PathTextBlock");
|
||||||
|
if (pathTextBlock is not null)
|
||||||
|
{
|
||||||
|
pathTextBlock.Text = string.IsNullOrEmpty(path) ? "未选择" : path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
84
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="480"
|
||||||
|
d:DesignHeight="320"
|
||||||
|
x:Class="LanMountainDesktop.Launcher.Views.ErrorWindow"
|
||||||
|
x:DataType="views:ErrorWindow"
|
||||||
|
Title="阑山桌面 - 启动失败"
|
||||||
|
Width="480"
|
||||||
|
Height="320"
|
||||||
|
CanResize="False"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:ErrorWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid Margin="40" RowDefinitions="Auto,*,Auto">
|
||||||
|
<!-- 错误图标和标题 -->
|
||||||
|
<StackPanel Grid.Row="0" HorizontalAlignment="Center">
|
||||||
|
<!-- 错误图标 - 可点击进入调试模式(隐藏功能,无提示) -->
|
||||||
|
<Border x:Name="ErrorIconBorder"
|
||||||
|
Width="64"
|
||||||
|
Height="64"
|
||||||
|
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
|
||||||
|
CornerRadius="32"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
|
<TextBlock x:Name="ErrorIconText"
|
||||||
|
Text="!"
|
||||||
|
FontSize="36"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="{DynamicResource SystemFillColorCriticalBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="启动失败"
|
||||||
|
FontSize="24"
|
||||||
|
FontWeight="Medium"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,20,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<StackPanel Grid.Row="1" VerticalAlignment="Center" Margin="0,20">
|
||||||
|
<TextBlock x:Name="ErrorMessageText"
|
||||||
|
Text="找不到阑山桌面应用程序。"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
LineHeight="22" />
|
||||||
|
|
||||||
|
<TextBlock Text="请确保应用程序已正确安装,或尝试重新安装。"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
LineHeight="20" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 按钮区域 -->
|
||||||
|
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Spacing="12">
|
||||||
|
<Button x:Name="RetryButton"
|
||||||
|
Content="重试"
|
||||||
|
Width="100"
|
||||||
|
Height="36"
|
||||||
|
FontSize="14"
|
||||||
|
Theme="{DynamicResource AccentButtonTheme}" />
|
||||||
|
|
||||||
|
<Button x:Name="ExitButton"
|
||||||
|
Content="退出"
|
||||||
|
Width="100"
|
||||||
|
Height="36"
|
||||||
|
FontSize="14" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
239
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
239
LanMountainDesktop.Launcher/Views/ErrorWindow.axaml.cs
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误窗口 - 显示启动失败信息,支持调试模式(隐藏入口)
|
||||||
|
/// </summary>
|
||||||
|
public partial class ErrorWindow : Window
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<ErrorWindowResult> _completionSource = new();
|
||||||
|
private int _iconClickCount = 0;
|
||||||
|
private const int DebugModeClickThreshold = 5;
|
||||||
|
private bool _isDebugMode = false;
|
||||||
|
private string? _customHostPath;
|
||||||
|
private bool _devModeEnabled;
|
||||||
|
|
||||||
|
public ErrorWindow()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
// 先加载保存的状态
|
||||||
|
_devModeEnabled = LoadDevModeStateInternal();
|
||||||
|
|
||||||
|
InitializeComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponents()
|
||||||
|
{
|
||||||
|
// 错误图标点击事件(进入调试模式 - 隐藏功能)
|
||||||
|
var errorIconBorder = this.FindControl<Border>("ErrorIconBorder");
|
||||||
|
if (errorIconBorder is not null)
|
||||||
|
{
|
||||||
|
errorIconBorder.PointerPressed += OnErrorIconClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按钮事件
|
||||||
|
var retryButton = this.FindControl<Button>("RetryButton");
|
||||||
|
var exitButton = this.FindControl<Button>("ExitButton");
|
||||||
|
|
||||||
|
if (retryButton is not null)
|
||||||
|
{
|
||||||
|
retryButton.Click += OnRetryClick;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exitButton is not null)
|
||||||
|
{
|
||||||
|
exitButton.Click += OnExitClick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置错误消息
|
||||||
|
/// </summary>
|
||||||
|
public void SetErrorMessage(string message)
|
||||||
|
{
|
||||||
|
var errorText = this.FindControl<TextBlock>("ErrorMessageText");
|
||||||
|
if (errorText is not null)
|
||||||
|
{
|
||||||
|
errorText.Text = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取用户选择的主程序路径
|
||||||
|
/// </summary>
|
||||||
|
public string? GetCustomHostPath() => _customHostPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用了开发模式
|
||||||
|
/// </summary>
|
||||||
|
public bool IsDevModeEnabled() => _devModeEnabled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等待用户选择
|
||||||
|
/// </summary>
|
||||||
|
public Task<ErrorWindowResult> WaitForChoiceAsync()
|
||||||
|
{
|
||||||
|
return _completionSource.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误图标点击事件 - 连续点击 5 次进入调试模式(隐藏功能)
|
||||||
|
/// </summary>
|
||||||
|
private void OnErrorIconClick(object? sender, Avalonia.Input.PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
_iconClickCount++;
|
||||||
|
|
||||||
|
if (_iconClickCount >= DebugModeClickThreshold && !_isDebugMode)
|
||||||
|
{
|
||||||
|
EnterDebugMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 进入调试模式 - 显示调试窗口
|
||||||
|
/// </summary>
|
||||||
|
private async void EnterDebugMode()
|
||||||
|
{
|
||||||
|
_isDebugMode = true;
|
||||||
|
|
||||||
|
// 创建并显示调试窗口
|
||||||
|
var debugWindow = new ErrorDebugWindow(_devModeEnabled, _customHostPath)
|
||||||
|
{
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner
|
||||||
|
};
|
||||||
|
|
||||||
|
// 订阅调试窗口关闭事件
|
||||||
|
debugWindow.Closed += (s, e) =>
|
||||||
|
{
|
||||||
|
// 更新状态
|
||||||
|
_devModeEnabled = debugWindow.IsDevModeEnabled;
|
||||||
|
_customHostPath = debugWindow.SelectedHostPath;
|
||||||
|
|
||||||
|
// 保存开发模式状态
|
||||||
|
SaveDevModeStateInternal(_devModeEnabled);
|
||||||
|
|
||||||
|
// 如果启用了开发模式且没有选择路径,自动扫描
|
||||||
|
if (_devModeEnabled && string.IsNullOrEmpty(_customHostPath))
|
||||||
|
{
|
||||||
|
ScanDevPaths();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await debugWindow.ShowDialog(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 扫描开发路径
|
||||||
|
/// </summary>
|
||||||
|
private void ScanDevPaths()
|
||||||
|
{
|
||||||
|
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
|
||||||
|
var possiblePaths = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
_customHostPath = path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 保存开发模式状态(内部方法)
|
||||||
|
/// </summary>
|
||||||
|
private static void SaveDevModeStateInternal(bool enabled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var devModeFile = GetDevModeFilePath();
|
||||||
|
var dir = Path.GetDirectoryName(devModeFile);
|
||||||
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
File.WriteAllText(devModeFile, enabled ? "1" : "0");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Failed to save dev mode state: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载开发模式状态(内部方法)
|
||||||
|
/// </summary>
|
||||||
|
private static bool LoadDevModeStateInternal()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var devModeFile = GetDevModeFilePath();
|
||||||
|
if (File.Exists(devModeFile))
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(devModeFile).Trim();
|
||||||
|
return content == "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Failed to load dev mode state: {ex.Message}");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取开发模式状态文件路径
|
||||||
|
/// </summary>
|
||||||
|
private static string GetDevModeFilePath()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
return Path.Combine(appData, "LanMountainDesktop", ".launcher", "devmode.config");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查是否启用了开发模式(静态方法,启动时调用)
|
||||||
|
/// </summary>
|
||||||
|
public static bool CheckDevModeEnabled()
|
||||||
|
{
|
||||||
|
return LoadDevModeStateInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRetryClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_completionSource.TrySetResult(ErrorWindowResult.Retry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExitClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_completionSource.TrySetResult(ErrorWindowResult.Exit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 错误窗口用户选择结果
|
||||||
|
/// </summary>
|
||||||
|
public enum ErrorWindowResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 重试
|
||||||
|
/// </summary>
|
||||||
|
Retry,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 退出
|
||||||
|
/// </summary>
|
||||||
|
Exit
|
||||||
|
}
|
||||||
@@ -1,22 +1,55 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="420"
|
||||||
|
d:DesignHeight="260"
|
||||||
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
|
||||||
Title="阑山桌面"
|
x:DataType="views:OobeWindow"
|
||||||
|
Title="欢迎使用阑山桌面"
|
||||||
Width="420"
|
Width="420"
|
||||||
Height="260"
|
Height="260"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen"
|
||||||
<Grid Margin="24" RowDefinitions="*,Auto">
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
<TextBlock Text="欢迎使用阑山桌面"
|
TransparencyLevelHint="None">
|
||||||
FontSize="26"
|
<Design.DataContext>
|
||||||
VerticalAlignment="Center"
|
<views:OobeWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid Margin="32" RowDefinitions="*,Auto">
|
||||||
|
<!-- 欢迎文本 -->
|
||||||
|
<StackPanel Grid.Row="0" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||||
|
<TextBlock Text="欢迎使用"
|
||||||
|
FontSize="18"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
HorizontalAlignment="Center" />
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Text="阑山桌面"
|
||||||
|
FontSize="32"
|
||||||
|
FontWeight="Light"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,8,0,0" />
|
||||||
|
<TextBlock Text="您的智能桌面助手"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,16,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 进入按钮 -->
|
||||||
<Button Grid.Row="1"
|
<Button Grid.Row="1"
|
||||||
x:Name="EnterButton"
|
x:Name="EnterButton"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
Width="64"
|
Width="80"
|
||||||
Height="40"
|
Height="36"
|
||||||
Content="→"
|
Content="开始使用"
|
||||||
FontSize="18" />
|
FontSize="14"
|
||||||
|
Background="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||||
|
CornerRadius="4" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ using Avalonia.Markup.Xaml;
|
|||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
internal partial class OobeWindow : Window
|
/// <summary>
|
||||||
|
/// OOBE(首次使用体验)窗口
|
||||||
|
/// </summary>
|
||||||
|
public partial class OobeWindow : Window
|
||||||
{
|
{
|
||||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||||
|
|
||||||
public OobeWindow()
|
public OobeWindow()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
var enterButton = this.FindControl<Button>("EnterButton");
|
var enterButton = this.FindControl<Button>("EnterButton");
|
||||||
if (enterButton is not null)
|
if (enterButton is not null)
|
||||||
{
|
{
|
||||||
@@ -18,6 +22,9 @@ internal partial class OobeWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等待用户点击开始按钮
|
||||||
|
/// </summary>
|
||||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||||
|
|
||||||
private void OnEnterClick(object? sender, RoutedEventArgs e)
|
private void OnEnterClick(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -1,40 +1,57 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
d:DesignWidth="400"
|
||||||
|
d:DesignHeight="200"
|
||||||
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
x:Class="LanMountainDesktop.Launcher.Views.SplashWindow"
|
||||||
|
x:DataType="views:SplashWindow"
|
||||||
Title="阑山桌面"
|
Title="阑山桌面"
|
||||||
Width="420"
|
Width="400"
|
||||||
Height="240"
|
Height="200"
|
||||||
CanResize="False"
|
CanResize="False"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
SystemDecorations="None">
|
SystemDecorations="None"
|
||||||
<Grid Margin="24" RowDefinitions="*,Auto,Auto,Auto">
|
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||||
|
TransparencyLevelHint="None">
|
||||||
|
<Design.DataContext>
|
||||||
|
<views:SplashWindow />
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="*,Auto,Auto">
|
||||||
|
<!-- 应用名称 -->
|
||||||
<TextBlock x:Name="AppNameText"
|
<TextBlock x:Name="AppNameText"
|
||||||
Text="阑山桌面"
|
Text="阑山桌面"
|
||||||
FontSize="34"
|
FontSize="36"
|
||||||
|
FontWeight="Light"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Grid.Row="0" />
|
Grid.Row="0"
|
||||||
|
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||||
|
|
||||||
|
<!-- 进度条 -->
|
||||||
<ProgressBar x:Name="ProgressIndicator"
|
<ProgressBar x:Name="ProgressIndicator"
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Minimum="0"
|
Minimum="0"
|
||||||
Maximum="100"
|
Maximum="100"
|
||||||
Value="0"
|
Value="0"
|
||||||
Height="4"
|
Height="3"
|
||||||
Margin="0,12,0,0"
|
Width="200"
|
||||||
IsIndeterminate="True" />
|
Margin="0,20,0,0"
|
||||||
<TextBlock x:Name="StageText"
|
IsIndeterminate="True"
|
||||||
|
Foreground="{DynamicResource AccentFillColorDefaultBrush}"
|
||||||
|
Background="{DynamicResource ControlStrokeColorDefaultBrush}" />
|
||||||
|
|
||||||
|
<!-- 状态文本 -->
|
||||||
|
<TextBlock x:Name="StatusText"
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Foreground="#999999"
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
Margin="0,8,0,0"
|
Margin="0,12,0,24"
|
||||||
Text="" />
|
Text="正在启动..." />
|
||||||
<TextBlock x:Name="DetailText"
|
|
||||||
Grid.Row="3"
|
|
||||||
FontSize="11"
|
|
||||||
Foreground="#BBBBBB"
|
|
||||||
HorizontalAlignment="Center"
|
|
||||||
Margin="0,2,0,0"
|
|
||||||
Text="" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,48 +1,92 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
using LanMountainDesktop.Launcher.Services;
|
using LanMountainDesktop.Launcher.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Launcher.Views;
|
namespace LanMountainDesktop.Launcher.Views;
|
||||||
|
|
||||||
internal partial class SplashWindow : Window, ISplashStageReporter
|
/// <summary>
|
||||||
|
/// 启动画面窗口 - 简洁设计
|
||||||
|
/// </summary>
|
||||||
|
public partial class SplashWindow : Window, ISplashStageReporter
|
||||||
{
|
{
|
||||||
private static readonly (string Stage, string Label, double Progress)[] StageMap =
|
|
||||||
[
|
|
||||||
("bootstrap", "正在初始化...", 10),
|
|
||||||
("silentUpdate", "正在应用更新...", 35),
|
|
||||||
("pluginTasks", "正在处理插件...", 65),
|
|
||||||
("launchHost", "正在启动...", 90),
|
|
||||||
];
|
|
||||||
|
|
||||||
public SplashWindow()
|
public SplashWindow()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新进度和状态
|
||||||
|
/// </summary>
|
||||||
public void Report(string stage, string message)
|
public void Report(string stage, string message)
|
||||||
{
|
{
|
||||||
var (label, progress) = ResolveStageInfo(stage);
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
var stageText = this.GetControl<TextBlock>("StageText");
|
var statusText = this.GetControl<TextBlock>("StatusText");
|
||||||
var detailText = this.GetControl<TextBlock>("DetailText");
|
|
||||||
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
|
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
|
||||||
|
|
||||||
stageText.Text = label;
|
// 更新状态文本
|
||||||
detailText.Text = message;
|
statusText.Text = message;
|
||||||
|
|
||||||
|
// 根据阶段更新进度
|
||||||
|
var progress = ResolveProgress(stage);
|
||||||
|
if (progress > 0)
|
||||||
|
{
|
||||||
progressIndicator.IsIndeterminate = false;
|
progressIndicator.IsIndeterminate = false;
|
||||||
progressIndicator.Value = progress;
|
progressIndicator.Value = progress;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
private static (string Label, double Progress) ResolveStageInfo(string stage)
|
|
||||||
{
|
{
|
||||||
foreach (var (s, label, progress) in StageMap)
|
progressIndicator.IsIndeterminate = true;
|
||||||
{
|
|
||||||
if (string.Equals(s, stage, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return (label, progress);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (stage, 0);
|
/// <summary>
|
||||||
|
/// 更新进度(0-100)
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateProgress(int percent, string? message = null)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.GetControl<TextBlock>("StatusText");
|
||||||
|
var progressIndicator = this.GetControl<ProgressBar>("ProgressIndicator");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
statusText.Text = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressIndicator.IsIndeterminate = false;
|
||||||
|
progressIndicator.Value = Math.Clamp(percent, 0, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 更新状态文本
|
||||||
|
/// </summary>
|
||||||
|
public void UpdateStatus(string message)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var statusText = this.GetControl<TextBlock>("StatusText");
|
||||||
|
statusText.Text = message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据阶段名称解析进度值
|
||||||
|
/// </summary>
|
||||||
|
private static int ResolveProgress(string stage)
|
||||||
|
{
|
||||||
|
return stage.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"initializing" => 10,
|
||||||
|
"update" => 30,
|
||||||
|
"plugins" => 50,
|
||||||
|
"launch" => 70,
|
||||||
|
"ready" => 100,
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
Normal file
84
LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动阶段枚举
|
||||||
|
/// </summary>
|
||||||
|
public enum StartupStage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化中
|
||||||
|
/// </summary>
|
||||||
|
Initializing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载设置中
|
||||||
|
/// </summary>
|
||||||
|
LoadingSettings,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 加载插件中
|
||||||
|
/// </summary>
|
||||||
|
LoadingPlugins,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化界面中
|
||||||
|
/// </summary>
|
||||||
|
InitializingUI,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 就绪
|
||||||
|
/// </summary>
|
||||||
|
Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动进度消息
|
||||||
|
/// </summary>
|
||||||
|
public record StartupProgressMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 当前阶段
|
||||||
|
/// </summary>
|
||||||
|
public StartupStage Stage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 进度百分比 (0-100)
|
||||||
|
/// </summary>
|
||||||
|
public int ProgressPercent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态消息
|
||||||
|
/// </summary>
|
||||||
|
public string? Message { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 时间戳
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launcher IPC 常量
|
||||||
|
/// </summary>
|
||||||
|
public static class LauncherIpcConstants
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 命名管道名称
|
||||||
|
/// </summary>
|
||||||
|
public const string PipeName = "LanMountainDesktop_Launcher";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launcher 进程 ID 环境变量
|
||||||
|
/// </summary>
|
||||||
|
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 包根目录环境变量
|
||||||
|
/// </summary>
|
||||||
|
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 版本环境变量
|
||||||
|
/// </summary>
|
||||||
|
public const string VersionEnvVar = "LMD_VERSION";
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ using LanMountainDesktop.DesktopHost;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.PluginSdk;
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Launcher;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
using LanMountainDesktop.Theme;
|
using LanMountainDesktop.Theme;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
using LanMountainDesktop.Views;
|
using LanMountainDesktop.Views;
|
||||||
@@ -71,6 +73,7 @@ public partial class App : Application
|
|||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
private bool _uiUnhandledExceptionHooked;
|
private bool _uiUnhandledExceptionHooked;
|
||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
|
private LauncherIpcClient? _launcherIpcClient;
|
||||||
|
|
||||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||||
@@ -136,7 +139,7 @@ public partial class App : Application
|
|||||||
EnsureNotificationService();
|
EnsureNotificationService();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override async void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
if (Design.IsDesignMode)
|
if (Design.IsDesignMode)
|
||||||
{
|
{
|
||||||
@@ -145,6 +148,10 @@ 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();
|
||||||
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
|
||||||
@@ -157,6 +164,35 @@ public partial class App : Application
|
|||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task InitializeLauncherIpcAsync()
|
||||||
|
{
|
||||||
|
if (!LauncherIpcClient.IsLaunchedByLauncher())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_launcherIpcClient = new LauncherIpcClient();
|
||||||
|
var connected = await _launcherIpcClient.ConnectAsync();
|
||||||
|
|
||||||
|
if (connected)
|
||||||
|
{
|
||||||
|
AppLogger.Info("LauncherIpc", "Connected to Launcher IPC server.");
|
||||||
|
|
||||||
|
// 报告初始化进度
|
||||||
|
await _launcherIpcClient.ReportProgressAsync(new StartupProgressMessage
|
||||||
|
{
|
||||||
|
Stage = StartupStage.Initializing,
|
||||||
|
ProgressPercent = 10,
|
||||||
|
Message = "正在初始化..."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("LauncherIpc", $"Failed to initialize Launcher IPC: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ApplyDesignTimeTheme()
|
private void ApplyDesignTimeTheme()
|
||||||
{
|
{
|
||||||
RequestedThemeVariant = ThemeVariant.Light;
|
RequestedThemeVariant = ThemeVariant.Light;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
|
<!-- Launcher 引用已移除 - Launcher 现在是独立应用 -->
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -80,33 +80,5 @@
|
|||||||
<PackageReference Include="log4net" Version="3.3.0" />
|
<PackageReference Include="log4net" Version="3.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="CopyLauncherToOutput" AfterTargets="Build">
|
<!-- Launcher 构建目标已移除 - Launcher 现在是独立应用,由 CI/CD 单独构建 -->
|
||||||
<PropertyGroup>
|
|
||||||
<_LauncherOutputPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherOutputPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<LauncherFiles Include="$(_LauncherOutputPath)**\*.*" />
|
|
||||||
</ItemGroup>
|
|
||||||
<Copy SourceFiles="@(LauncherFiles)" DestinationFiles="@(LauncherFiles->'$(OutDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherOutputPath)')" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
<Target Name="PublishLauncher" BeforeTargets="CopyLauncherToPublish" Condition="'$(PublishDir)' != '' and '$(RuntimeIdentifier)' != ''">
|
|
||||||
<PropertyGroup>
|
|
||||||
<_LauncherPublishPath>..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishPath>
|
|
||||||
</PropertyGroup>
|
|
||||||
<MSBuild Projects="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj"
|
|
||||||
Targets="Publish"
|
|
||||||
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=$(SelfContained);PublishDir=$(_LauncherPublishPath);PublishSingleFile=false;PublishTrimmed=false;PublishReadyToRun=false;DebugType=none;DebugSymbols=false" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
<Target Name="CopyLauncherToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
|
||||||
<PropertyGroup>
|
|
||||||
<_LauncherPublishSource Condition="'$(RuntimeIdentifier)' != '' and Exists('..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\')">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\$(RuntimeIdentifier)\publish\</_LauncherPublishSource>
|
|
||||||
<_LauncherPublishSource Condition="'$(_LauncherPublishSource)' == ''">..\LanMountainDesktop.Launcher\bin\$(Configuration)\net10.0\</_LauncherPublishSource>
|
|
||||||
</PropertyGroup>
|
|
||||||
<ItemGroup>
|
|
||||||
<LauncherPublishFiles Include="$(_LauncherPublishSource)**\*.*" />
|
|
||||||
</ItemGroup>
|
|
||||||
<Copy SourceFiles="@(LauncherPublishFiles)" DestinationFiles="@(LauncherPublishFiles->'$(PublishDir)Launcher\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" Condition="Exists('$(_LauncherPublishSource)')" />
|
|
||||||
</Target>
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
82
LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
Normal file
82
LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services.Launcher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
|
||||||
|
/// </summary>
|
||||||
|
public class LauncherIpcClient : IDisposable
|
||||||
|
{
|
||||||
|
private NamedPipeClientStream? _pipeClient;
|
||||||
|
private bool _isConnected;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
using var writer = new StreamWriter(_pipeClient, leaveOpen: true);
|
||||||
|
await writer.WriteAsync(json);
|
||||||
|
await writer.FlushAsync();
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// 管道断开
|
||||||
|
_isConnected = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("LauncherIpc", $"Failed to report progress: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 检查是否从 Launcher 启动
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsLaunchedByLauncher()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrEmpty(
|
||||||
|
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_pipeClient?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
72
scripts/Setup-DevEnvironment.ps1
Normal file
72
scripts/Setup-DevEnvironment.ps1
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 开发环境设置脚本
|
||||||
|
# 创建模拟的生产目录结构,方便测试 Launcher
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Configuration = "Debug",
|
||||||
|
[string]$Version = "1.0.0-dev"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# 获取项目根目录
|
||||||
|
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$LauncherOutput = Join-Path $ProjectRoot "LanMountainDesktop.Launcher\bin\$Configuration\net10.0"
|
||||||
|
$MainAppOutput = Join-Path $ProjectRoot "LanMountainDesktop\bin\$Configuration\net10.0"
|
||||||
|
$DevRoot = Join-Path $ProjectRoot "dev-test"
|
||||||
|
|
||||||
|
Write-Host "Setting up development environment..." -ForegroundColor Cyan
|
||||||
|
Write-Host "Project Root: $ProjectRoot"
|
||||||
|
Write-Host "Launcher Output: $LauncherOutput"
|
||||||
|
Write-Host "Main App Output: $MainAppOutput"
|
||||||
|
Write-Host "Dev Root: $DevRoot"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# 检查主程序是否已构建
|
||||||
|
if (-not (Test-Path (Join-Path $MainAppOutput "LanMountainDesktop.exe"))) {
|
||||||
|
Write-Host "Main application not found. Building..." -ForegroundColor Yellow
|
||||||
|
dotnet build "$ProjectRoot\LanMountainDesktop.slnx" -c $Configuration
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Build failed!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清理旧的开发环境
|
||||||
|
if (Test-Path $DevRoot) {
|
||||||
|
Write-Host "Cleaning old dev environment..." -ForegroundColor Yellow
|
||||||
|
Remove-Item -Path $DevRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建目录结构
|
||||||
|
$AppDir = Join-Path $DevRoot "app-$Version"
|
||||||
|
New-Item -ItemType Directory -Path $AppDir -Force | Out-Null
|
||||||
|
|
||||||
|
# 复制主程序到 app-{version} 目录
|
||||||
|
Write-Host "Copying main application to app-$Version..." -ForegroundColor Green
|
||||||
|
Copy-Item -Path "$MainAppOutput\*" -Destination $AppDir -Recurse -Force
|
||||||
|
|
||||||
|
# 创建 .current 标记文件
|
||||||
|
New-Item -ItemType File -Path (Join-Path $AppDir ".current") -Force | Out-Null
|
||||||
|
|
||||||
|
# 复制 Launcher
|
||||||
|
Write-Host "Copying Launcher..." -ForegroundColor Green
|
||||||
|
Copy-Item -Path "$LauncherOutput\LanMountainDesktop.Launcher.exe" -Destination (Join-Path $DevRoot "LanMountainDesktop.exe") -Force
|
||||||
|
|
||||||
|
# 复制 Launcher 依赖
|
||||||
|
$LauncherDeps = Get-ChildItem -Path $LauncherOutput -Filter "*.dll" -File
|
||||||
|
foreach ($dep in $LauncherDeps) {
|
||||||
|
Copy-Item -Path $dep.FullName -Destination $DevRoot -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# 复制 Avalonia 主题文件
|
||||||
|
$ThemeFiles = Get-ChildItem -Path $LauncherOutput -Filter "*.xaml" -File
|
||||||
|
foreach ($theme in $ThemeFiles) {
|
||||||
|
Copy-Item -Path $theme.FullName -Destination $DevRoot -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Development environment setup complete!" -ForegroundColor Green
|
||||||
|
Write-Host "Run the Launcher from: $DevRoot\LanMountainDesktop.exe" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Directory structure:" -ForegroundColor Gray
|
||||||
|
Get-ChildItem -Path $DevRoot | Format-Table Name, @{Label="Type"; Expression={if($_.PSIsContainer){"Directory"}else{"File"}}}
|
||||||
Reference in New Issue
Block a user