Stamp release versions and harden launcher

Add automatic release version stamping and multiple launcher reliability improvements. The Release workflow now runs scripts/Set-ReleaseVersion.ps1 in build jobs to inject tag-derived Version/AssemblyVersion into project metadata; several .csproj/Directory.Build.props and app.manifest files were changed to use a dev placeholder. Introduced AppVersionProvider (and related runtime metadata) to centralize version resolution and updated DeploymentLocator to use it and to prefer package-root/version.json. Launcher startup flow was hardened: added startup success tracking, public-activation recovery path, improved success/fallback semantics, and related IPC handling. UI/UX fixes include OOBE entrance/exit animation improvements (scaling-aware, concurrent fade+translate) and minor window lifecycle reorder in DesktopShellHost. CommandContext now recognizes restart and key=value args. New DesktopTrayService and .trae spec files (spec, checklist, tasks) document shell/tray hardening work. Miscellaneous logging, comments and housekeeping edits across launcher and shared contracts to support the above.
This commit is contained in:
lincube
2026-04-23 00:27:01 +08:00
parent e20462ac2b
commit 001d77968f
31 changed files with 1727 additions and 478 deletions

View File

@@ -1,127 +1,65 @@
# 版本号自动同步说明
# 版本同步说明
## 📋 概述
## 目标
从本次更新开始Release 工作流已配置为**自动同步版本号**,确保应用的每个版本号来源都保持一致。
发布版的用户可见版本必须统一指向“应用版本”,不能再出现:
## 🔄 版本号流转链路
- Launcher UI 显示 `1.0.0`
- 应用设置页显示 `0.8.x`
- `version.json`、安装包、Release 资产名称各写各的
```
Git Tag (v1.0.1)
[Release 工作流 prepare 任务]
提取版本号: 1.0.1
[Update version in .csproj] ✨ 新增步骤
自动更新 .csproj 文件版本号
dotnet restore/build
构建时读取更新后的版本号
应用内显示版本号 (MainWindow.Localization.cs 动态读取)
```
## 默认仓库状态
## 🎯 工作原理
仓库内的静态版本现在故意保留为开发占位值:
### 1. 版本号提取
当推送 Git Tag 时(如 `git tag v1.0.1`Release 工作流的 `prepare` 任务自动提取版本号:
- TAG: `v1.0.1` → VERSION: `1.0.1`
- `Directory.Build.props`
- `LanMountainDesktop/LanMountainDesktop.csproj`
- `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
- `LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj`
- `LanMountainDesktop/app.manifest`
- `LanMountainDesktop.Launcher/app.manifest`
### 2. 自动更新 .csproj
在三个平台的构建任务中,新增了 **"Update version in .csproj"** 步骤:
这些值只是提醒“当前不是正式注入构建”,不能代表发布版本。
**Windows (PowerShell)**:
```powershell
$VERSION = "1.0.1"
(Get-Content file.csproj) -replace '<Version>.*?</Version>', "<Version>$VERSION</Version>" | Set-Content file.csproj
```
## Release 工作流怎么做
**Linux/macOS (Bash)**:
```bash
VERSION="1.0.1"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" file.csproj
```
Release 工作流会先从 tag 提取版本:
### 3. 构建和发布
更新后的版本号被用于:
- 程序集版本 (`AssemblyVersion`)
- 包文件名 (`LanMountainDesktop-1.0.1-win-x64.zip`)
- 应用内显示 (About 页面)
- GitHub Release 标题
- `v0.8.5.2` -> `0.8.5.2`
- 程序集四段版本 -> `0.8.5.2`
## 📍 涉及的文件
随后显式执行:
自动更新的文件:
1. `LanMountainDesktop/LanMountainDesktop.csproj`
- `scripts/Set-ReleaseVersion.ps1`
## ✅ 使用流程
这个步骤会同步更新:
### 发布新版本
- 主程序 `.csproj``Version`
- Launcher `.csproj``Version`
- Shared.Contracts `.csproj``Version`
- `Directory.Build.props`
- 主程序 `app.manifest`
- Launcher `app.manifest`
```bash
# 1. 更新代码(可选:代码中的版本号现在会自动更新)
git add .
git commit -m "feat: Add new features"
之后构建和发布阶段继续通过 MSBuild 属性注入:
# 2. 创建版本标签
git tag v1.0.1
# 或带注释的标签
git tag -a v1.0.1 -m "Release v1.0.1"
- `Version`
- `AssemblyVersion`
- `FileVersion`
- `InformationalVersion`
# 3. 推送标签到 GitHub
git push origin v1.0.1
因此最终会统一落到:
# 4. Release 工作流自动运行:
# - 自动更新 .csproj 文件
# - 构建所有平台
# - 创建 GitHub Release
# - 附带所有平台的发布包
```
- Launcher UI 读取到的应用版本
- 应用设置页显示的版本
- `version.json`
- 程序集文件版本
- Windows manifest
- 安装包版本
- GitHub Release 资产名称
## 🔒 版本号一致性保证
## 维护规则
现在应用的三个版本号来源完全同步:
| 来源 | 说明 | 自动更新 |
|------|------|--------|
| `.csproj` <Version> | 项目文件版本 | ✅ 是 |
| 程序集版本 | 编译时读取 | ✅ 是 |
| 应用内显示 | About 页面 | ✅ 是 |
| 发布包文件名 | Release 工作流 | ✅ 是 |
| GitHub Release | Release 工作流 | ✅ 是 |
## ⚠️ 注意事项
### 不需要手动更新
- ❌ 不需要在 `.csproj` 中手动修改 Version
- ❌ 不需要修改多个地方的版本号
### 只需执行
- ✅ 创建 Git Tag: `git tag v1.0.1`
- ✅ 推送 Tag: `git push origin v1.0.1`
- ✅ 其他由工作流自动处理
## 📊 版本号格式
支持的格式:
-`v1.0.0` (builds -> 1.0.0)
-`v1.2.3` (builds -> 1.2.3)
-`v2.0.0-rc1` (builds -> 2.0.0-rc1, 如果需要)
## 🛠️ 工作流文件
更新的工作流文件:
- `.github/workflows/release.yml` - Release 工作流
## 📝 相关文件
- [MULTIPLATFORM_RELEASE_GUIDE.md](./MULTIPLATFORM_RELEASE_GUIDE.md) - 多平台发布指南
- [WORKFLOWS_GUIDE.md](./WORKFLOWS_GUIDE.md) - 工作流使用指南
---
**最后更新**: 2026-03-04
**工作流版本**: 2.0 (自动版本同步)
- 日常开发不要手动把仓库默认版本改成正式版本号。
- 正式发版只需要打 tag版本同步交给工作流。
- 如果新增新的版本承载点,必须同时补到 `Set-ReleaseVersion.ps1` 和 Release 工作流里。

View File

@@ -119,6 +119,13 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -364,6 +371,13 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -545,6 +559,13 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}

View File

@@ -0,0 +1,10 @@
# 验收清单
- [ ] 设置页重启后Launcher 能重新接管并恢复到正确展示形态。
- [ ] 插件升级辅助程序完成后,回拉的是 Launcher 而不是宿主 exe。
- [ ] 已在托盘中的实例再次启动时,不会出现第二个主进程。
- [ ] 托盘初始化失败时,应用不会进入无入口的 `TrayOnly`
- [ ] 托盘运行中丢失时watchdog 能重建或自动恢复前台。
- [ ] Launcher UI 版本与应用设置页版本一致。
- [ ] 发布 tag `vX.Y.Z.W`manifest、程序集、`version.json`、安装包和资产命名一致。
- [ ] 100% / 150% / 200% / 250% 缩放下Launcher OOBE、主窗口、通知动画正常。

View File

@@ -0,0 +1,67 @@
# Launcher 外壳托管、托盘兜底与高分屏动画修复
## 背景
当前桌面应用在以下场景存在明显不稳定性:
- 设置页或升级后的“重启”没有统一回到 Launcher。
- 已有实例处于托盘时,再次启动容易误报“窗口未显示”,甚至重复拉起。
- 托盘初始化失败或运行中丢失时,应用可能进入无恢复入口状态。
- Launcher 和宿主的版本来源不一致,发布后容易出现 UI 版本错乱。
- 高分屏和混合缩放环境下Launcher OOBE、主窗口入场和通知动画存在像素/DIP 混用问题。
## 目标
- Launcher 成为正式环境唯一的启动与重启入口。
- 进入 `TrayOnly` 前必须先确认托盘可恢复。
- Launcher UI 显示的版本号等于应用版本号。
- 发布工作流显式同步主程序、Launcher、manifest 和产物版本。
- 动画和定位统一按 DIP 与缩放计算。
## 行为要求
### 1. 重启接管
- 应用内重启、插件升级后的重启都必须优先回到 Launcher。
- Launcher 对 `SecondaryActivationSucceeded` 只认定为一次成功重定向,不允许再做 fallback 二次拉起。
- Launcher 启动成功判定区分三类场景:
- 前台启动:`DesktopVisible``ActivationRedirected`
- 重启到最小化:`BackgroundReady`
- 重启到托盘:`TrayReady + BackgroundReady`
### 2. 托盘硬约束
- 托盘状态机必须至少覆盖:
- `Unavailable`
- `Initializing`
- `Ready`
- `Recovering`
- `Failed`
- `HideMainWindowToTray`、关闭到托盘、重启恢复到托盘前都必须先执行托盘就绪检查。
- 如果托盘不可用:
- 优先回退到任务栏最小化
- 若任务栏入口也不可用,则强制恢复前台可见
- 托盘处于隐藏态期间必须运行 watchdog连续恢复失败时自动恢复主窗口。
### 3. 版本来源
- Launcher 只能显示应用版本,不能显示 Launcher 自身硬编码版本。
- 版本解析优先顺序:
- `version.json`
- 主程序文件版本 / 信息版本
- `app-<version>` 部署目录
- Release 工作流必须显式打版本补丁,避免仓库默认占位值被误当成正式版本。
### 4. 高分屏动画
- 主窗口、通知、Launcher OOBE 的动画位移必须使用 DIP 或基于缩放换算后的尺寸。
- 不允许直接把 `PixelRect` 宽高当作 `TranslateTransform``DesiredSize` 的输入。
- 淡入和位移动画应并行执行,避免先淡入后滑动造成观感异常。
## 验收
- 已在托盘中的实例再次通过 Launcher 启动时,只激活已有实例。
- 设置页重启和插件升级重启后,不再出现“窗口未显示但后台已有多个进程”。
- 托盘失败时应用仍保持可恢复。
- Launcher 与应用设置页显示相同版本。
- 100% / 150% / 200% / 250% 缩放下Launcher OOBE、主窗口入场、通知位置与动画正常。

View File

@@ -0,0 +1,14 @@
# 任务拆解
- [x] 为 Launcher/宿主共享新增重启来源、父进程和展示模式参数。
- [x] 修复 Launcher 对 `SecondaryActivationSucceeded` 的重复 fallback 拉起。
- [x] 让 Launcher 成功判定支持 `TrayReady``BackgroundReady`
- [x] 应用重启默认优先回到 Launcher而不是直接回拉宿主 exe。
- [x] 抽出独立托盘服务集中处理创建、刷新、watchdog 与状态流转。
- [x] 在进入 `TrayOnly` 前增加托盘就绪校验与回退策略。
- [x] 为运行中托盘丢失增加 watchdog 和自动恢复逻辑。
- [x] 统一公共 IPC、设置页与 Launcher 的版本读取入口。
- [x] 将仓库默认版本改为开发占位值,并在 Release 工作流中加入显式打版本步骤。
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
- [x] 补充规格与版本同步说明文档。
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。

View File

@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.0.0</Version>
<Version>0.0.0-dev</Version>
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>

View File

@@ -46,8 +46,8 @@ public sealed class DesktopShellHost : IDesktopShellHost
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Exit += (_, _) => _performExitCleanup();
_createAndAssignMainWindow(desktop);
_startActivationListener();
_createAndAssignMainWindow(desktop);
}
_startWeatherRefresh();

View File

@@ -121,6 +121,7 @@ internal sealed class CommandContext
return raw.Trim().ToLowerInvariant() switch
{
"normal" => "normal",
"restart" => "restart",
"postinstall" => "postinstall",
"apply-update" => "apply-update",
"plugin-install" => "plugin-install",
@@ -146,6 +147,13 @@ internal sealed class CommandContext
continue;
}
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++i];

View File

@@ -8,7 +8,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<Version>0.0.0-dev</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- 应用程序图标 -->

View File

@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -57,8 +57,8 @@ internal sealed class DeploymentLocator
Version = ParseVersionFromDirectory(path),
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
})
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 标记的æŽå‰<EFBFBD>é<EFBFBD>¢
.ThenByDescending(x => x.Version) // ç„¶å<EFBFBD>ŽæŒ‰ç‰ˆæœ¬å<EFBFBD>·é™<EFBFBD>åº<EFBFBD>
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 鏍囪鐨勬帓鍓嶉潰
.ThenByDescending(x => x.Version) // 鐒跺悗鎸夌増鏈彿闄嶅簭
.ToList();
if (validInstallations.Count == 0)
@@ -275,7 +275,7 @@ internal sealed class DeploymentLocator
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
// 1. 首先查找 app-{version} 目录(生产环境)
// 1. 棣栧厛鏌ユ壘 app-{version} 鐩綍锛堢敓浜х幆澧冿級
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
@@ -299,7 +299,7 @@ internal sealed class DeploymentLocator
return inParent;
}
// 4. å¼€å<EFBFBD>模å¼<EFBFBD>ï¼šå¦æžœå<EFBFBD>¯ç”¨äº†å¼€å<EFBFBD>模å¼<EFBFBD>,优先使用ä¿<EFBFBD>存的自定义路径
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
@@ -315,7 +315,7 @@ internal sealed class DeploymentLocator
}
}
// 5. å¼€å<EFBFBD>模å¼<EFBFBD>:查找主ç¨åº<EFBFBD>项ç®çš„输出ç®å½•
// 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
@@ -329,21 +329,21 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 扫æ<EFBFBD><EFBFBD>å¼€å<EFBFBD>路径(开å<EFBFBD>模å¼<EFBFBD>)
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
/// </summary>
private static string? ScanDevelopmentPaths(string executable)
{
var possiblePaths = new[]
{
// ä»?Launcher 项ç®è¿<EFBFBD>行
// ?Launcher 椤圭洰杩愯
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// ä»Žè§£å†³æ¹æ¡ˆæ ¹ç®å½•è¿<EFBFBD>行
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable),
Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable),
// dev-test 目录
// dev-test 鐩綍
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
@@ -359,22 +359,22 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 获å<EFBFBD>å¼€å<EFBFBD>环境å<EFBFBD>¯èƒ½çš„主ç¨åº<EFBFBD>è·¯å¾? /// </summary>
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
private static IEnumerable<string> GetDevelopmentPaths(string executable)
{
var launcherDir = AppContext.BaseDirectory;
var possiblePaths = new[]
{
// ä»?Launcher 项ç®è¿<EFBFBD>行ï¼?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
// ?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),
// ä»Žè§£å†³æ¹æ¡ˆæ ¹ç®å½•è¿<EFBFBD>行:LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe
// 浠庤В鍐虫柟妗堟牴鐩綍杩愯锛歀anMountainDesktop\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 ç®å½•è¿<EFBFBD>行
// ?dev-test 鐩綍杩愯
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
};
@@ -409,8 +409,8 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 清ç<EFBFBD>†æ—§ç‰ˆæœ¬éƒ¨ç½²ï¼Œä¿<EFBFBD>留最è¿çš„N个版æœ? /// </summary>
/// <param name="minVersionsToKeep">最å°ä¿<EFBFBD>留版本数,默è®?ä¸?/param>
/// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? /// </summary>
/// <param name="minVersionsToKeep">鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
@@ -438,10 +438,10 @@ internal sealed class DeploymentLocator
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
// 确定è¦<EFBFBD>ä¿<EFBFBD>留的版本
// 纭畾瑕佷繚鐣欑殑鐗堟湰
var versionsToKeep = new HashSet<string>();
// 1. 总是ä¿<EFBFBD>留当å‰<EFBFBD>版本
// 1. 鎬绘槸淇濈暀褰撳墠鐗堟湰
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion != null)
{
@@ -449,7 +449,7 @@ internal sealed class DeploymentLocator
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
}
// 2. ä¿<EFBFBD>留最è¿çš„N个有效版本(ä¸<EFBFBD>包æ¬å·²æ ‡è®°destroy的)
// 2. 淇濈暀鏈€杩戠殑N涓湁鏁堢増鏈紙涓嶅寘鎷凡鏍囪destroy鐨勶級
var activeVersions = validDeployments
.Where(d => !d.IsDestroyed)
.Take(minVersionsToKeep)
@@ -461,7 +461,7 @@ internal sealed class DeploymentLocator
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
}
// 3. ä¿<EFBFBD>ç•™æœ‰å¿«ç…§çš„ç‰ˆæœ¬ï¼ˆç”¨äºŽåžæ»šï¼‰
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
if (Directory.Exists(snapshotDir))
{
@@ -485,17 +485,17 @@ internal sealed class DeploymentLocator
}
catch
{
// 忽略快照解æž<EFBFBD>错误
// 蹇界暐蹇収瑙f瀽閿欒
}
}
}
catch
{
// 忽略快照目录访问错误
// 蹇界暐蹇収鐩綍璁块棶閿欒
}
}
// 清ç<EFBFBD>†ä¸<EFBFBD>需è¦<EFBFBD>的版本
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
@@ -509,7 +509,7 @@ internal sealed class DeploymentLocator
}
catch
{
// 忽略å<EFBFBD>消标记失败
// 蹇界暐鍙栨秷鏍囪澶辫触
}
}
continue;
@@ -524,11 +524,11 @@ internal sealed class DeploymentLocator
}
catch
{
// 忽略标记失败
// 蹇界暐鏍囪澶辫触
}
}
// å°<EFBFBD>试删除
// 灏濊瘯鍒犻櫎
try
{
Directory.Delete(deployment.Path, recursive: true);
@@ -536,7 +536,7 @@ internal sealed class DeploymentLocator
}
catch
{
// 忽略删除失败(å<>¯èƒ½æ‡ä»¶è¢«å<C2AB> ç”?,䏿¬¡å<C2A1>¯åЍå†<C3A5>试
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
}
}
@@ -544,12 +544,12 @@ internal sealed class DeploymentLocator
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
// 忽略清ç<EFBFBD>†å¤±è´¥
// 蹇界暐娓呯悊澶辫触
}
}
/// <summary>
/// 仅清ç<EFBFBD>†å·²æ ‡è®°ä¸?destroyçš„éƒ¨ç½²ï¼ˆå…¼å®¹æ—§æ¹æ³•)
/// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
/// </summary>
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
@@ -581,36 +581,17 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 从部署ç®å½•读å<EFBFBD>版本信æ<EFBFBD>? /// </summary>
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </summary>
public AppVersionInfo GetVersionInfo()
{
var deploymentDir = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var versionFile = Path.Combine(deploymentDir, "version.json");
if (File.Exists(versionFile))
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var resolved = AppVersionProvider.ResolveFromPackageRoot(_appRoot, executableName);
return string.IsNullOrWhiteSpace(resolved.Version)
? new AppVersionInfo
{
try
{
var json = File.ReadAllText(versionFile);
var info = JsonSerializer.Deserialize(json, AppJsonContext.Default.AppVersionInfo);
if (info is not null)
{
return info;
}
}
catch
{
}
Version = GetCurrentVersion(),
Codename = "Administrate"
}
}
return new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate"
};
}
: resolved;
}
}

View File

@@ -4,6 +4,7 @@ using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Services;
@@ -12,7 +13,7 @@ internal sealed class LauncherFlowCoordinator
private static readonly string[] LauncherOnlyOptions =
[
"debug", "show-loading-details", "plugins-dir", "source", "result",
"app-root", "launch-source",
"app-root",
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
@@ -65,6 +66,8 @@ internal sealed class LauncherFlowCoordinator
window.Show();
return window;
});
var versionInfo = _deploymentLocator.GetVersionInfo();
splashWindow.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
var reporter = (ISplashStageReporter)splashWindow;
LoadingDetailsWindow? loadingDetailsWindow = null;
@@ -77,10 +80,11 @@ internal sealed class LauncherFlowCoordinator
});
}
var visibilityTcs = new TaskCompletionSource<StartupStage>(TaskCreationOptions.RunContinuationsAsynchronously);
var successTcs = new TaskCompletionSource<StartupSuccessState>(TaskCreationOptions.RunContinuationsAsynchronously);
var activationFailedTcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
var lastStage = StartupStage.Initializing;
var lastStageMessage = "launcher-started";
var startupSuccessTracker = new StartupSuccessTracker(_context);
var loadingState = new LoadingStateMessage();
using var ipcClient = new LanMountainDesktopIpcClient();
@@ -105,15 +109,14 @@ internal sealed class LauncherFlowCoordinator
reporter.Report(MapStartupStageToSplashStage(message.Stage), message.Message ?? message.Stage.ToString());
loadingDetailsWindow?.UpdateLoadingState(loadingState);
switch (message.Stage)
if (startupSuccessTracker.TryResolve(message.Stage, out var successState))
{
case StartupStage.DesktopVisible:
case StartupStage.ActivationRedirected:
visibilityTcs.TrySetResult(message.Stage);
break;
case StartupStage.ActivationFailed:
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
break;
successTcs.TrySetResult(successState);
}
if (message.Stage == StartupStage.ActivationFailed)
{
activationFailedTcs.TrySetResult(message.Message ?? "activation_failed");
}
}
catch (Exception ex)
@@ -197,22 +200,20 @@ internal sealed class LauncherFlowCoordinator
var processExitTask = launchOutcome.Process.WaitForExitAsync();
var completedTask = await Task.WhenAny(
visibilityTcs.Task,
successTcs.Task,
activationFailedTcs.Task,
processExitTask,
Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false);
if (completedTask == visibilityTcs.Task)
if (completedTask == successTcs.Task)
{
var stage = await visibilityTcs.Task.ConfigureAwait(false);
var successState = await successTcs.Task.ConfigureAwait(false);
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: true,
stage: "launch",
code: stage == StartupStage.ActivationRedirected ? "activation_redirected" : "ok",
message: stage == StartupStage.ActivationRedirected
? "Launcher activation was redirected to the existing desktop instance."
: "Desktop is visible and ready.",
code: successState.Code,
message: successState.Message,
details: MergeDetails(launcherContextDetails, launchOutcome.Details));
}
@@ -230,7 +231,7 @@ internal sealed class LauncherFlowCoordinator
if (completedTask == processExitTask)
{
var exitCode = launchOutcome.Process.ExitCode;
Logger.Warn($"Host exited before desktop became visible. ExitCode={exitCode}.");
Logger.Warn($"Host exited before startup success criteria were met. ExitCode={exitCode}.");
if (exitCode is HostExitCodes.SecondaryActivationFailed or HostExitCodes.RestartLockNotAcquired)
{
@@ -249,19 +250,41 @@ internal sealed class LauncherFlowCoordinator
code: exitCode == HostExitCodes.SecondaryActivationSucceeded ? "activation_redirected" : "host_exited_early",
message: exitCode == HostExitCodes.SecondaryActivationSucceeded
? "Host redirected activation to the existing desktop instance."
: $"Host exited before the desktop became visible. ExitCode={exitCode}.",
: $"Host exited before the required startup state was reported. ExitCode={exitCode}.",
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
{
["exitCode"] = exitCode.ToString()
})));
}
if (connected && !launchOutcome.Process.HasExited)
{
var recoveryOutcome = await TryRecoverWithPublicActivationAsync(
ipcClient,
launchOutcome.Process,
successTcs.Task,
startupSuccessTracker).ConfigureAwait(false);
if (recoveryOutcome is not null)
{
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: true,
stage: "launch",
code: recoveryOutcome.Code,
message: recoveryOutcome.Message,
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
{
["recoveryActivationAttempted"] = bool.TrueString
})));
}
}
await CloseWindowsAsync(splashWindow, loadingDetailsWindow).ConfigureAwait(false);
return BuildResult(
success: false,
stage: "launch",
code: "desktop_not_visible",
message: "Host process started, but the desktop never became visible within 30 seconds.",
message: "Host process started, but it never reached the required startup state within 30 seconds.",
details: MergeDetails(launcherContextDetails, MergeDetails(launchOutcome.Details, new Dictionary<string, string>
{
["ipcStage"] = lastStage.ToString(),
@@ -452,6 +475,11 @@ internal sealed class LauncherFlowCoordinator
firstDetails);
}
if (firstAttempt.ExitCode == HostExitCodes.SecondaryActivationSucceeded)
{
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
}
if (fallbackMode is null)
{
return BuildOutcomeFromAttempt(resolution, firstAttempt, null);
@@ -749,8 +777,10 @@ internal sealed class LauncherFlowCoordinator
StartupStage.Initializing => "initializing",
StartupStage.LoadingSettings => "settings",
StartupStage.LoadingPlugins => "plugins",
StartupStage.TrayReady => "shell",
StartupStage.InitializingUI => "ui",
StartupStage.ShellInitialized => "shell",
StartupStage.BackgroundReady => "ready",
StartupStage.DesktopVisible => "ready",
StartupStage.ActivationRedirected => "activation",
StartupStage.ActivationFailed => "error",
@@ -936,6 +966,40 @@ internal sealed class LauncherFlowCoordinator
return true;
}
private static async Task<StartupSuccessState?> TryRecoverWithPublicActivationAsync(
LanMountainDesktopIpcClient ipcClient,
Process hostProcess,
Task<StartupSuccessState> successTask,
StartupSuccessTracker startupSuccessTracker)
{
try
{
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activationAccepted = await shellProxy.ActivateMainWindowAsync().ConfigureAwait(false);
if (!activationAccepted)
{
return null;
}
var completedTask = await Task.WhenAny(successTask, Task.Delay(TimeSpan.FromSeconds(5))).ConfigureAwait(false);
if (completedTask == successTask)
{
return await successTask.ConfigureAwait(false);
}
if (!hostProcess.HasExited)
{
return startupSuccessTracker.BuildRecoverySuccessState();
}
}
catch (Exception ex)
{
Logger.Warn($"Public activation recovery failed: {ex.Message}");
}
return null;
}
private enum HostStartMode
{
ShellExecute,
@@ -977,4 +1041,108 @@ internal sealed class LauncherFlowCoordinator
public static HostLaunchOutcome FromProcess(Process process, LauncherResult result, Dictionary<string, string> details) =>
new(result, process, null, details);
}
private sealed class StartupSuccessTracker
{
private readonly LaunchSuccessPolicy _policy;
private bool _trayReady;
private bool _backgroundReady;
public StartupSuccessTracker(CommandContext context)
{
var restartPresentation = LauncherRuntimeMetadata.GetRestartPresentationMode(context.RawArgs);
var isRestartLaunch = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase);
_policy = !isRestartLaunch
? LaunchSuccessPolicy.Foreground
: restartPresentation switch
{
RestartPresentationMode.Tray => LaunchSuccessPolicy.RestartTray,
RestartPresentationMode.Minimized => LaunchSuccessPolicy.RestartBackground,
_ => LaunchSuccessPolicy.Foreground
};
}
public bool TryResolve(StartupStage stage, out StartupSuccessState successState)
{
switch (stage)
{
case StartupStage.ActivationRedirected:
successState = new StartupSuccessState(
stage,
"activation_redirected",
"Launcher activation was redirected to the existing desktop instance.");
return true;
case StartupStage.DesktopVisible:
successState = new StartupSuccessState(
stage,
_policy == LaunchSuccessPolicy.Foreground ? "ok" : "desktop_visible_fallback",
_policy == LaunchSuccessPolicy.Foreground
? "Desktop is visible and ready."
: "Desktop recovered in a visible state.");
return true;
case StartupStage.TrayReady:
_trayReady = true;
break;
case StartupStage.BackgroundReady:
_backgroundReady = true;
break;
}
if (_policy == LaunchSuccessPolicy.RestartBackground && _backgroundReady)
{
successState = new StartupSuccessState(
StartupStage.BackgroundReady,
"background_ready",
"Desktop restart completed in the background.");
return true;
}
if (_policy == LaunchSuccessPolicy.RestartTray && _trayReady && _backgroundReady)
{
successState = new StartupSuccessState(
StartupStage.BackgroundReady,
"background_ready",
"Desktop restart completed with tray recovery ready.");
return true;
}
successState = default!;
return false;
}
public StartupSuccessState BuildRecoverySuccessState()
{
return _policy switch
{
LaunchSuccessPolicy.RestartTray => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery because the background restart never confirmed tray readiness."),
LaunchSuccessPolicy.RestartBackground => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery because the background restart never confirmed readiness."),
_ => new StartupSuccessState(
StartupStage.DesktopVisible,
"recovery_activation_requested",
"Launcher requested a visible recovery from the running desktop instance.")
};
}
}
private sealed record StartupSuccessState(
StartupStage Stage,
string Code,
string Message);
private enum LaunchSuccessPolicy
{
Foreground,
RestartBackground,
RestartTray
}
}

View File

@@ -21,7 +21,11 @@
<views:OobeWindow />
</Design.DataContext>
<Grid x:Name="ContentGrid">
<Grid x:Name="ContentGrid"
Opacity="0">
<Grid.RenderTransform>
<TranslateTransform Y="24" />
</Grid.RenderTransform>
<!-- 主内容区域 -->
<Grid Margin="48" RowDefinitions="*,Auto">
<!-- 中央内容区域 -->

View File

@@ -9,26 +9,18 @@ using Avalonia.Styling;
namespace LanMountainDesktop.Launcher.Views;
/// <summary>
/// OOBE首次使用体验窗口 - 欢迎页面
/// </summary>
public partial class OobeWindow : Window
{
private readonly TaskCompletionSource<bool> _completionSource = new();
private bool _isTransitioning = false;
private bool _isTransitioning;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
// 延迟到窗口加载完成后再初始化
this.Loaded += OnWindowLoaded;
this.Opened += OnWindowOpened;
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
}
/// <summary>
/// 窗口加载完成事件
/// </summary>
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
@@ -45,31 +37,29 @@ public partial class OobeWindow : Window
}
}
/// <summary>
/// 窗口打开事件 - 播放入场动画
/// </summary>
private async void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
await PlayEntranceAnimationAsync();
}
/// <summary>
/// 播放入场动画
/// </summary>
private async Task PlayEntranceAnimationAsync()
{
try
{
// 获取内容元素
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接返回
return;
}
// 创建淡入动画
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
contentGrid.RenderTransform = translateTransform;
var offset = ResolveEntranceOffset();
contentGrid.Opacity = 0;
translateTransform.Y = offset;
var fadeInAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
@@ -89,7 +79,6 @@ public partial class OobeWindow : Window
}
};
// 创建向上滑动动画
var slideUpAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
@@ -98,7 +87,7 @@ public partial class OobeWindow : Window
{
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 30.0) },
Setters = { new Setter(TranslateTransform.YProperty, offset) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
@@ -109,9 +98,9 @@ public partial class OobeWindow : Window
}
};
// 应用动画
await fadeInAnimation.RunAsync(contentGrid);
await slideUpAnimation.RunAsync(contentGrid);
await Task.WhenAll(
fadeInAnimation.RunAsync(contentGrid),
slideUpAnimation.RunAsync(translateTransform));
Console.WriteLine("[OobeWindow] Entrance animation completed");
}
@@ -121,27 +110,21 @@ public partial class OobeWindow : Window
}
}
/// <summary>
/// 等待用户点击开始按钮
/// </summary>
public Task WaitForEnterAsync() => _completionSource.Task;
/// <summary>
/// 进入按钮点击事件
/// </summary>
private async void OnEnterClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
_isTransitioning = true;
if (_isTransitioning)
{
return;
}
_isTransitioning = true;
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
try
{
// 播放退出动画
await PlayExitAnimationAsync();
// 完成 OOBE
_completionSource.TrySetResult(true);
}
catch (Exception ex)
@@ -151,9 +134,6 @@ public partial class OobeWindow : Window
}
}
/// <summary>
/// 播放退出动画
/// </summary>
private async Task PlayExitAnimationAsync()
{
try
@@ -161,12 +141,10 @@ public partial class OobeWindow : Window
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
// 如果没有命名网格,直接延迟后返回
await Task.Delay(200);
return;
}
// 创建淡出动画
var fadeOutAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(200),
@@ -194,4 +172,11 @@ public partial class OobeWindow : Window
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
}
}
private double ResolveEntranceOffset()
{
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
var scaledOffset = boundsHeight * 0.05;
return Math.Clamp(scaledOffset, 20, 48);
}
}

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Launcher"/>
<assemblyIdentity version="0.0.0.0" name="LanMountainDesktop.Launcher"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<Version>0.0.0-dev</Version>
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>

View File

@@ -0,0 +1,362 @@
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
namespace LanMountainDesktop.Shared.Contracts.Launcher;
public static class AppVersionProvider
{
private const string DefaultVersion = "0.0.0";
private const string DefaultCodename = "Administrate";
private const string VersionFileName = "version.json";
public static AppVersionInfo ResolveForCurrentProcess(
IReadOnlyList<string>? commandLineArgs = null,
string? executablePath = null,
string? deploymentDirectory = null)
{
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
return Resolve(
packageRoot: LauncherRuntimeMetadata.GetPackageRoot(args),
deploymentDirectory: deploymentDirectory ?? AppContext.BaseDirectory,
executablePath: executablePath ?? Environment.ProcessPath,
versionOverride: LauncherRuntimeMetadata.GetForwardedVersion(args),
codenameOverride: LauncherRuntimeMetadata.GetForwardedCodename(args));
}
public static AppVersionInfo ResolveFromDeploymentDirectory(
string? deploymentDirectory,
string? executablePath = null,
string? versionOverride = null,
string? codenameOverride = null)
{
return Resolve(
packageRoot: null,
deploymentDirectory: deploymentDirectory,
executablePath: executablePath,
versionOverride: versionOverride,
codenameOverride: codenameOverride);
}
public static AppVersionInfo ResolveFromPackageRoot(
string? packageRoot,
string executableName,
string? versionOverride = null,
string? codenameOverride = null)
{
if (string.IsNullOrWhiteSpace(packageRoot))
{
return CreateFallback(versionOverride, codenameOverride);
}
var deploymentDirectory = FindCurrentDeploymentDirectory(packageRoot, executableName);
var executablePath = !string.IsNullOrWhiteSpace(deploymentDirectory)
? Path.Combine(deploymentDirectory, executableName)
: null;
return Resolve(
packageRoot: packageRoot,
deploymentDirectory: deploymentDirectory,
executablePath: executablePath,
versionOverride: versionOverride,
codenameOverride: codenameOverride);
}
public static AppVersionInfo Resolve(
string? packageRoot,
string? deploymentDirectory,
string? executablePath,
string? versionOverride = null,
string? codenameOverride = null)
{
if (!string.IsNullOrWhiteSpace(versionOverride))
{
return Create(versionOverride, codenameOverride);
}
var normalizedDeploymentDirectory = NormalizeExistingDirectory(deploymentDirectory)
?? ResolveDeploymentFromPackageRoot(packageRoot, executablePath);
if (!string.IsNullOrWhiteSpace(normalizedDeploymentDirectory) &&
TryReadVersionFile(normalizedDeploymentDirectory, out var fileInfo))
{
return OverrideMissingParts(fileInfo, versionOverride, codenameOverride);
}
var normalizedExecutablePath = NormalizeExistingFile(executablePath)
?? ResolveExecutableFromDeployment(normalizedDeploymentDirectory, executablePath);
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath) &&
TryReadExecutableVersion(normalizedExecutablePath, out var executableInfo))
{
return OverrideMissingParts(executableInfo, versionOverride, codenameOverride);
}
var versionFromDirectory = TryParseVersionFromDeploymentDirectory(normalizedDeploymentDirectory);
if (!string.IsNullOrWhiteSpace(versionFromDirectory))
{
return Create(versionFromDirectory, codenameOverride);
}
return CreateFallback(versionOverride, codenameOverride);
}
public static string NormalizeVersionText(string? rawValue, string fallback = DefaultVersion)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return fallback;
}
var normalized = rawValue.Split('+', 2, StringSplitOptions.TrimEntries)[0].Trim();
return string.IsNullOrWhiteSpace(normalized)
? fallback
: normalized;
}
public static string NormalizeCodename(string? rawValue, string fallback = DefaultCodename)
{
return string.IsNullOrWhiteSpace(rawValue)
? fallback
: rawValue.Trim();
}
private static AppVersionInfo OverrideMissingParts(
AppVersionInfo source,
string? versionOverride,
string? codenameOverride)
{
return new AppVersionInfo
{
Version = NormalizeVersionText(versionOverride ?? source.Version),
Codename = NormalizeCodename(codenameOverride ?? source.Codename)
};
}
private static AppVersionInfo CreateFallback(string? versionOverride, string? codenameOverride)
{
return Create(versionOverride ?? DefaultVersion, codenameOverride ?? DefaultCodename);
}
private static AppVersionInfo Create(string version, string? codename)
{
return new AppVersionInfo
{
Version = NormalizeVersionText(version),
Codename = NormalizeCodename(codename)
};
}
private static bool TryReadVersionFile(string deploymentDirectory, out AppVersionInfo info)
{
info = default!;
var versionFilePath = Path.Combine(deploymentDirectory, VersionFileName);
if (!File.Exists(versionFilePath))
{
return false;
}
try
{
var json = File.ReadAllText(versionFilePath);
var parsedInfo = JsonSerializer.Deserialize<AppVersionInfo>(json);
if (parsedInfo is null || string.IsNullOrWhiteSpace(parsedInfo.Version))
{
return false;
}
info = new AppVersionInfo
{
Version = NormalizeVersionText(parsedInfo.Version),
Codename = NormalizeCodename(parsedInfo.Codename)
};
return true;
}
catch
{
return false;
}
}
private static bool TryReadExecutableVersion(string executablePath, out AppVersionInfo info)
{
info = default!;
try
{
var fileInfo = FileVersionInfo.GetVersionInfo(executablePath);
var version = NormalizeVersionText(fileInfo.ProductVersion);
if (string.Equals(version, DefaultVersion, StringComparison.Ordinal) &&
!string.IsNullOrWhiteSpace(fileInfo.FileVersion))
{
version = NormalizeVersionText(fileInfo.FileVersion);
}
if (string.Equals(version, DefaultVersion, StringComparison.Ordinal))
{
var assemblyNameVersion = AssemblyName.GetAssemblyName(executablePath).Version;
if (assemblyNameVersion is not null)
{
version = NormalizeVersionText(assemblyNameVersion.ToString());
}
}
info = new AppVersionInfo
{
Version = version,
Codename = DefaultCodename
};
return !string.Equals(version, DefaultVersion, StringComparison.Ordinal);
}
catch
{
return false;
}
}
private static string? ResolveDeploymentFromPackageRoot(string? packageRoot, string? executablePath)
{
var normalizedPackageRoot = NormalizeExistingDirectory(packageRoot);
if (string.IsNullOrWhiteSpace(normalizedPackageRoot))
{
return null;
}
var normalizedExecutablePath = NormalizeExistingFile(executablePath);
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath))
{
var executableDirectory = NormalizeExistingDirectory(Path.GetDirectoryName(normalizedExecutablePath));
if (!string.IsNullOrWhiteSpace(executableDirectory) &&
executableDirectory.StartsWith(normalizedPackageRoot, StringComparison.OrdinalIgnoreCase))
{
return executableDirectory;
}
}
var executableName = Path.GetFileName(normalizedExecutablePath);
return FindCurrentDeploymentDirectory(normalizedPackageRoot, executableName);
}
private static string? ResolveExecutableFromDeployment(string? deploymentDirectory, string? executablePath)
{
var normalizedExecutablePath = NormalizeExistingFile(executablePath);
if (!string.IsNullOrWhiteSpace(normalizedExecutablePath))
{
return normalizedExecutablePath;
}
var normalizedDeploymentDirectory = NormalizeExistingDirectory(deploymentDirectory);
if (string.IsNullOrWhiteSpace(normalizedDeploymentDirectory))
{
return null;
}
foreach (var candidateName in GetExecutableCandidates(executablePath))
{
var candidatePath = Path.Combine(normalizedDeploymentDirectory, candidateName);
if (File.Exists(candidatePath))
{
return candidatePath;
}
}
return null;
}
private static IReadOnlyList<string> GetExecutableCandidates(string? executablePath)
{
var fileName = Path.GetFileName(executablePath);
if (!string.IsNullOrWhiteSpace(fileName))
{
return [fileName];
}
return OperatingSystem.IsWindows()
? ["LanMountainDesktop.exe"]
: ["LanMountainDesktop"];
}
private static string? FindCurrentDeploymentDirectory(string packageRoot, string? executableName)
{
try
{
var candidates = Directory.GetDirectories(packageRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
IsCurrent = File.Exists(Path.Combine(path, ".current")),
HasExecutable = string.IsNullOrWhiteSpace(executableName) || File.Exists(Path.Combine(path, executableName)),
Version = TryParseVersionFromDeploymentDirectory(path)
})
.Where(item => item.HasExecutable)
.OrderByDescending(item => item.IsCurrent)
.ThenByDescending(item => item.Version, StringComparer.OrdinalIgnoreCase)
.ToArray();
return candidates.FirstOrDefault()?.Path;
}
catch
{
return null;
}
}
private static string? TryParseVersionFromDeploymentDirectory(string? deploymentDirectory)
{
if (string.IsNullOrWhiteSpace(deploymentDirectory))
{
return null;
}
var directoryName = Path.GetFileName(deploymentDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (string.IsNullOrWhiteSpace(directoryName) ||
!directoryName.StartsWith("app-", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var remaining = directoryName["app-".Length..];
var segments = remaining.Split('-', StringSplitOptions.RemoveEmptyEntries);
return segments.Length > 0
? NormalizeVersionText(segments[0])
: null;
}
private static string? NormalizeExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var fullPath = Path.GetFullPath(path);
return Directory.Exists(fullPath) ? fullPath : null;
}
catch
{
return null;
}
}
private static string? NormalizeExistingFile(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var fullPath = Path.GetFullPath(path);
return File.Exists(fullPath) ? fullPath : null;
}
catch
{
return null;
}
}
}

View File

@@ -5,8 +5,10 @@ public enum StartupStage
Initializing,
LoadingSettings,
LoadingPlugins,
TrayReady,
InitializingUI,
ShellInitialized,
BackgroundReady,
DesktopVisible,
ActivationRedirected,
ActivationFailed,
@@ -35,4 +37,10 @@ public static class LauncherIpcConstants
public const string VersionEnvVar = "LMD_VERSION";
public const string CodenameEnvVar = "LMD_CODENAME";
public const string LaunchSourceOptionName = "launch-source";
public const string RestartParentPidOptionName = "restart-parent-pid";
public const string RestartPresentationOptionName = "restart-presentation";
}

View File

@@ -0,0 +1,148 @@
using System.Globalization;
namespace LanMountainDesktop.Shared.Contracts.Launcher;
public enum RestartPresentationMode
{
Foreground = 0,
Minimized = 1,
Tray = 2
}
public static class LauncherRuntimeMetadata
{
public static string? GetOptionValue(string key, IReadOnlyList<string>? commandLineArgs = null)
{
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
var longPrefix = $"--{key}";
for (var index = 0; index < args.Count; index++)
{
var argument = args[index];
if (!argument.StartsWith(longPrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.Equals(argument, longPrefix, StringComparison.OrdinalIgnoreCase))
{
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
return args[index + 1];
}
return "true";
}
if (argument.Length > longPrefix.Length && argument[longPrefix.Length] == '=')
{
return argument[(longPrefix.Length + 1)..];
}
}
return null;
}
public static bool HasOption(string key, IReadOnlyList<string>? commandLineArgs = null)
{
return !string.IsNullOrWhiteSpace(GetOptionValue(key, commandLineArgs));
}
public static string? GetPackageRoot(IReadOnlyList<string>? commandLineArgs = null)
{
return FirstNonEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.PackageRootEnvVar),
GetOptionValue(LauncherIpcConstants.PackageRootEnvVar, commandLineArgs));
}
public static string? GetForwardedVersion(IReadOnlyList<string>? commandLineArgs = null)
{
return FirstNonEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.VersionEnvVar),
GetOptionValue(LauncherIpcConstants.VersionEnvVar, commandLineArgs));
}
public static string? GetForwardedCodename(IReadOnlyList<string>? commandLineArgs = null)
{
return FirstNonEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.CodenameEnvVar),
GetOptionValue(LauncherIpcConstants.CodenameEnvVar, commandLineArgs));
}
public static string? GetLaunchSource(IReadOnlyList<string>? commandLineArgs = null)
{
return GetOptionValue(LauncherIpcConstants.LaunchSourceOptionName, commandLineArgs);
}
public static int? GetLauncherProcessId(IReadOnlyList<string>? commandLineArgs = null)
{
var rawValue = FirstNonEmpty(
Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar),
GetOptionValue(LauncherIpcConstants.LauncherPidEnvVar, commandLineArgs));
return TryParsePositiveInt(rawValue);
}
public static int? GetRestartParentProcessId(IReadOnlyList<string>? commandLineArgs = null)
{
var rawValue = GetOptionValue(LauncherIpcConstants.RestartParentPidOptionName, commandLineArgs);
return TryParsePositiveInt(rawValue);
}
public static RestartPresentationMode? GetRestartPresentationMode(IReadOnlyList<string>? commandLineArgs = null)
{
var rawValue = GetOptionValue(LauncherIpcConstants.RestartPresentationOptionName, commandLineArgs);
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
return NormalizeRestartPresentation(rawValue);
}
public static string FormatRestartPresentation(RestartPresentationMode mode)
{
return mode switch
{
RestartPresentationMode.Minimized => "minimized",
RestartPresentationMode.Tray => "tray",
_ => "foreground"
};
}
public static RestartPresentationMode NormalizeRestartPresentation(string rawValue)
{
return rawValue.Trim().ToLowerInvariant() switch
{
"minimized" => RestartPresentationMode.Minimized,
"tray" => RestartPresentationMode.Tray,
_ => RestartPresentationMode.Foreground
};
}
private static int? TryParsePositiveInt(string? rawValue)
{
return int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedValue) &&
parsedValue > 0
? parsedValue
: null;
}
private static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
}

View File

@@ -59,6 +59,9 @@ public partial class App : Application
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
private readonly string _launchSource = LauncherRuntimeMetadata.GetLaunchSource(Environment.GetCommandLineArgs()) ?? "normal";
private readonly RestartPresentationMode? _requestedRestartPresentationMode =
LauncherRuntimeMetadata.GetRestartPresentationMode(Environment.GetCommandLineArgs());
private ISettingsPageRegistry? _settingsPageRegistry;
private ISettingsWindowService? _settingsWindowService;
private WeatherLocationRefreshService? _weatherLocationRefreshService;
@@ -67,12 +70,7 @@ public partial class App : Application
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent;
private TrayIcon? _trayIcon;
private NativeMenuItem? _trayShowDesktopMenuItem;
private NativeMenuItem? _traySettingsMenuItem;
private NativeMenuItem? _trayComponentLibraryMenuItem;
private NativeMenuItem? _trayRestartMenuItem;
private NativeMenuItem? _trayExitMenuItem;
private DesktopTrayService? _desktopTrayService;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow;
@@ -108,6 +106,15 @@ public partial class App : Application
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
internal INotificationService? NotificationService => _notificationService;
internal RestartPresentationMode GetCurrentRestartPresentationMode()
{
return _desktopShellState switch
{
DesktopShellState.TrayOnly => RestartPresentationMode.Tray,
DesktopShellState.MinimizedToTaskbar => RestartPresentationMode.Minimized,
_ => RestartPresentationMode.Foreground
};
}
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
{
@@ -470,128 +477,90 @@ public partial class App : Application
private void InitializeTrayIcon()
{
try
EnsureDesktopTrayService();
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
if (_trayInitialized)
{
if (_trayIcon is null)
{
_trayShowDesktopMenuItem = new NativeMenuItem();
_trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
_traySettingsMenuItem = new NativeMenuItem();
_traySettingsMenuItem.Click += OnTraySettingsClick;
_trayComponentLibraryMenuItem = new NativeMenuItem();
_trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
_trayRestartMenuItem = new NativeMenuItem();
_trayRestartMenuItem.Click += OnTrayRestartClick;
_trayExitMenuItem = new NativeMenuItem();
_trayExitMenuItem.Click += OnTrayExitClick;
var trayMenu = new NativeMenu();
trayMenu.Items.Add(_trayShowDesktopMenuItem);
trayMenu.Items.Add(_traySettingsMenuItem);
trayMenu.Items.Add(_trayComponentLibraryMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_trayRestartMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_trayExitMenuItem);
_trayIcon = new TrayIcon
{
Icon = _appLogoService.CreateTrayIcon(),
Menu = trayMenu,
IsVisible = true
};
TrayIcon.SetIcons(this, [_trayIcon]);
}
RefreshTrayIconContent();
_trayInitialized = true;
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
AppLogger.Info("TrayIcon", $"Tray initialized successfully. Pid={Environment.ProcessId}.");
return;
}
catch (Exception ex)
{
_trayInitialized = false;
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
}
AppLogger.Warn("TrayIcon", "Tray initialization did not reach the ready state.");
}
private void RefreshTrayIconContent()
{
if (_trayIcon is not null)
{
_trayIcon.IsVisible = true;
if (!OperatingSystem.IsLinux())
{
_trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
}
}
if (_trayShowDesktopMenuItem is not null)
{
_trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
}
if (_traySettingsMenuItem is not null)
{
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
}
RefreshFusedDesktopMenuItemVisibility();
if (_trayRestartMenuItem is not null)
{
_trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
}
if (_trayExitMenuItem is not null)
{
_trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
}
EnsureDesktopTrayService();
_desktopTrayService?.Refresh("RefreshTrayContent");
_trayInitialized = _desktopTrayService?.IsReady == true;
}
private void RefreshFusedDesktopMenuItemVisibility()
{
if (_trayComponentLibraryMenuItem is null)
{
return;
}
if (!OperatingSystem.IsWindows())
{
_trayComponentLibraryMenuItem.IsVisible = false;
return;
}
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
if (_trayComponentLibraryMenuItem.IsVisible)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
RefreshTrayIconContent();
}
private void DisposeTrayIcon()
{
if (_trayIcon is null)
_desktopTrayService?.Dispose();
_trayInitialized = false;
}
private void EnsureDesktopTrayService()
{
if (_desktopTrayService is not null)
{
return;
}
try
_desktopTrayService = new DesktopTrayService(
this,
_appLogoService,
L,
ShouldShowTrayComponentLibraryMenuItem,
OnTrayShowDesktopClick,
OnTraySettingsClick,
OnTrayComponentLibraryClick,
OnTrayRestartClick,
OnTrayExitClick);
_desktopTrayService.StateChanged += OnTrayAvailabilityStateChanged;
}
private bool EnsureTrayReady(string reason)
{
EnsureDesktopTrayService();
var ready = _desktopTrayService?.EnsureReady(reason) == true;
_trayInitialized = ready;
if (ready)
{
_trayIcon.IsVisible = false;
ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
}
catch (Exception ex)
return ready;
}
private void OnTrayAvailabilityStateChanged(TrayAvailabilityState state)
{
_trayInitialized = state == TrayAvailabilityState.Ready;
if (state == TrayAvailabilityState.Failed && _desktopShellState == DesktopShellState.TrayOnly)
{
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed");
}
}
private bool ShouldShowTrayComponentLibraryMenuItem()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
return appSnapshot.EnableFusedDesktop;
}
private void EnsureSettingsWindowService()
{
_settingsPageRegistry ??= new SettingsPageRegistry(
@@ -764,6 +733,7 @@ public partial class App : Application
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
_desktopTrayService?.StopWatchdog();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
@@ -872,6 +842,7 @@ public partial class App : Application
if (themeChanged)
{
ApplyThemeFromSettings();
RefreshTrayIconContent();
}
if (languageChanged)
@@ -898,7 +869,11 @@ public partial class App : Application
_ = sender;
_ = e;
Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background);
Dispatcher.UIThread.Post(() =>
{
ApplyThemeFromSettings();
RefreshTrayIconContent();
}, DispatcherPriority.Background);
}
private void ApplyAdaptiveThemeResources()
@@ -1144,18 +1119,56 @@ public partial class App : Application
{
mainWindow.Opened -= OnMainWindowOpened;
_mainWindowOpened = true;
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
if (TryApplyStartupPresentation(mainWindow))
{
AppLogger.Info(
"App",
$"Main window opened and startup presentation was applied. LaunchSource='{_launchSource}'; RestartPresentation='{_requestedRestartPresentationMode?.ToString() ?? "<none>"}'; ShellState='{_desktopShellState}'.");
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
_loadingStateReporter?.Stop();
return;
}
AppLogger.Info(
"App",
$"Main window opened. Reporting DesktopVisible. TrayInitialized={_trayInitialized}; ShellState='{_desktopShellState}'.");
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
ReportStartupProgressSync(StartupStage.DesktopVisible, 100, "Desktop visible.");
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
_loadingStateReporter?.Stop();
}
}
private bool TryApplyStartupPresentation(MainWindow mainWindow)
{
if (!string.Equals(_launchSource, "restart", StringComparison.OrdinalIgnoreCase) ||
_requestedRestartPresentationMode is null ||
_requestedRestartPresentationMode == RestartPresentationMode.Foreground)
{
return false;
}
switch (_requestedRestartPresentationMode)
{
case RestartPresentationMode.Minimized:
mainWindow.ShowInTaskbar = true;
mainWindow.WindowState = WindowState.Minimized;
_desktopTrayService?.StopWatchdog();
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "StartupRestartPresentation");
ReportStartupProgressSync(StartupStage.BackgroundReady, 95, "Background ready.");
return true;
case RestartPresentationMode.Tray:
HideMainWindowToTray(mainWindow, "StartupRestartPresentation");
return true;
default:
return false;
}
}
private MainWindow GetOrCreateMainWindow(
IClassicDesktopStyleApplicationLifetime desktop,
string reason)
@@ -1242,7 +1255,15 @@ public partial class App : Application
if (_shutdownIntent == ShutdownIntent.None)
{
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
if (EnsureTrayReady("MainWindowClosedUnexpected"))
{
_desktopTrayService?.StartWatchdog();
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
}
else
{
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowClosedUnexpectedWithoutTray");
}
}
}
@@ -1276,9 +1297,17 @@ public partial class App : Application
{
try
{
if (!EnsureTrayReady($"HideToTray:{source}"))
{
RecoverFromTrayUnavailable(mainWindow, source);
return;
}
mainWindow.ShowInTaskbar = false;
mainWindow.Hide();
_desktopTrayService?.StartWatchdog();
SetDesktopShellState(DesktopShellState.TrayOnly, source);
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready.");
AppLogger.Info(
"DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
@@ -1293,9 +1322,56 @@ public partial class App : Application
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to hide main window to tray. Source='{source}'.", ex);
RecoverFromTrayUnavailable(mainWindow, source);
}
}
private void RecoverFromTrayUnavailable(MainWindow mainWindow, string source)
{
AppLogger.Warn(
"DesktopShell",
$"Tray was unavailable. Recovering to a visible or taskbar-backed state instead of TrayOnly. Source='{source}'.");
var showInTaskbar = ShouldShowMainWindowInTaskbar();
if (showInTaskbar)
{
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
mainWindow.WindowState = WindowState.Minimized;
_desktopTrayService?.StopWatchdog();
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, $"TrayFallbackTaskbar:{source}");
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready via taskbar fallback.");
return;
}
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
_desktopTrayService?.StopWatchdog();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"TrayFallbackForeground:{source}");
ReportStartupProgress(StartupStage.DesktopVisible, 100, "Desktop restored because tray was unavailable.");
}
private bool ShouldShowMainWindowInTaskbar()
{
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
@@ -1364,17 +1440,19 @@ public partial class App : Application
try
{
var version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0";
var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
_publicIpcHostService = new PublicIpcHostService();
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
new PublicAppInfoService(version, "Administrate", _startupAt));
new PublicAppInfoService(_startupAt));
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
new PublicShellControlService());
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
new PublicPluginCatalogService(_publicIpcHostService));
_publicIpcHostService.Start();
AppLogger.Info("PublicIpc", $"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'.");
AppLogger.Info(
"PublicIpc",
$"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'; Version='{versionInfo.Version}'; Codename='{versionInfo.Codename}'.");
}
catch (Exception ex)
{

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<RollForward>LatestMajor</RollForward>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<Version>0.0.0-dev</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>

View File

@@ -24,7 +24,7 @@ public sealed class Program
AppLogger.Initialize();
DevPluginOptions.Parse(args);
RegisterGlobalExceptionLogging();
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
if (!singleInstance.IsPrimaryInstance)

View File

@@ -1,16 +1,13 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services;
public static class AppRestartService
{
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
public static bool TryRestartApplication()
{
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
@@ -42,19 +39,34 @@ public static class AppRestartService
public static ProcessStartInfo? CreateRestartStartInfo(
string[]? commandLineArgs = null,
string? processPath = null,
string? entryAssemblyLocation = null)
string? entryAssemblyLocation = null,
RestartPresentationMode? restartPresentationMode = null)
{
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
var resolvedEntryAssemblyPath = NormalizeExistingPath(
var resolvedProcessPath = NormalizeExistingFile(processPath ?? Environment.ProcessPath);
var resolvedEntryAssemblyPath = NormalizeExistingFile(
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
var normalizedRestartPresentation = restartPresentationMode
?? LauncherRuntimeMetadata.GetRestartPresentationMode(args)
?? RestartPresentationMode.Foreground;
var launcherStartInfo = TryCreateLauncherStartInfo(
args,
resolvedProcessPath,
resolvedEntryAssemblyPath,
normalizedRestartPresentation);
if (launcherStartInfo is not null)
{
return launcherStartInfo;
}
if (IsDotnetHost(resolvedProcessPath))
{
return CreateDotnetStartInfo(
resolvedProcessPath!,
resolvedEntryAssemblyPath,
args);
args,
normalizedRestartPresentation);
}
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
@@ -62,7 +74,8 @@ public static class AppRestartService
return CreateExecutableStartInfo(
resolvedProcessPath,
resolvedEntryAssemblyPath,
args);
args,
normalizedRestartPresentation);
}
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
@@ -71,7 +84,8 @@ public static class AppRestartService
return CreateDotnetStartInfo(
"dotnet",
resolvedEntryAssemblyPath,
args);
args,
normalizedRestartPresentation);
}
return null;
@@ -80,22 +94,20 @@ public static class AppRestartService
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
return LauncherRuntimeMetadata.GetRestartParentProcessId(commandLineArgs);
}
foreach (var argument in commandLineArgs)
{
if (TryParseRestartParentProcessId(argument, out var processId))
{
return processId;
}
}
return null;
public static RestartPresentationMode? TryGetRestartPresentationMode(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
return LauncherRuntimeMetadata.GetRestartPresentationMode(commandLineArgs);
}
private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
IReadOnlyList<string> commandLineArgs,
RestartPresentationMode restartPresentationMode)
{
var startInfo = new ProcessStartInfo
{
@@ -104,18 +116,17 @@ public static class AppRestartService
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
// UseShellExecute=true 时使用 Arguments 字符串而非 ArgumentList
var args = new System.Text.StringBuilder();
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
var arguments = new StringBuilder();
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
startInfo.Arguments = arguments.ToString();
return startInfo;
}
private static ProcessStartInfo? CreateDotnetStartInfo(
string dotnetHostPath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
IReadOnlyList<string> commandLineArgs,
RestartPresentationMode restartPresentationMode)
{
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
{
@@ -129,51 +140,182 @@ public static class AppRestartService
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
// UseShellExecute=true 时使用 Arguments 字符串
var args = new System.Text.StringBuilder();
args.Append(QuoteArgument(entryAssemblyPath));
AppendArgumentsToString(args, commandLineArgs);
AppendRestartParentProcessArgumentToString(args);
startInfo.Arguments = args.ToString();
var arguments = new StringBuilder();
arguments.Append(QuoteArgument(entryAssemblyPath));
AppendForwardedArguments(arguments, commandLineArgs, restartPresentationMode);
startInfo.Arguments = arguments.ToString();
return startInfo;
}
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
private static ProcessStartInfo? TryCreateLauncherStartInfo(
IReadOnlyList<string> commandLineArgs,
string? processPath,
string? entryAssemblyPath,
RestartPresentationMode restartPresentationMode)
{
for (var i = 1; i < commandLineArgs.Count; i++)
var launcherPath = ResolveLauncherPath(commandLineArgs, processPath, entryAssemblyPath);
if (string.IsNullOrWhiteSpace(launcherPath))
{
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
return null;
}
var arguments = new StringBuilder();
AppendFilteredArguments(arguments, commandLineArgs);
AppendRestartArguments(arguments, restartPresentationMode);
return new ProcessStartInfo
{
FileName = launcherPath,
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
Arguments = arguments.ToString()
};
}
private static string? ResolveLauncherPath(
IReadOnlyList<string> commandLineArgs,
string? processPath,
string? entryAssemblyPath)
{
var launcherFileName = OperatingSystem.IsWindows()
? "LanMountainDesktop.Launcher.exe"
: "LanMountainDesktop.Launcher";
foreach (var packageRootCandidate in GetPackageRootCandidates(commandLineArgs, processPath, entryAssemblyPath))
{
var normalizedRoot = NormalizeExistingDirectory(packageRootCandidate);
if (string.IsNullOrWhiteSpace(normalizedRoot))
{
continue;
}
startInfo.ArgumentList.Add(commandLineArgs[i]);
var directCandidate = Path.Combine(normalizedRoot, launcherFileName);
if (File.Exists(directCandidate))
{
return directCandidate;
}
}
return null;
}
private static void AppendArgumentsToString(System.Text.StringBuilder builder, IReadOnlyList<string> commandLineArgs)
private static IEnumerable<string?> GetPackageRootCandidates(
IReadOnlyList<string> commandLineArgs,
string? processPath,
string? entryAssemblyPath)
{
for (var i = 1; i < commandLineArgs.Count; i++)
yield return LauncherRuntimeMetadata.GetPackageRoot(commandLineArgs);
foreach (var path in new[] { entryAssemblyPath, processPath, AppContext.BaseDirectory })
{
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
var directory = GetDirectoryFromPath(path);
if (string.IsNullOrWhiteSpace(directory))
{
continue;
}
if (builder.Length > 0) builder.Append(' ');
builder.Append(QuoteArgument(commandLineArgs[i]));
yield return directory;
yield return Path.GetDirectoryName(directory);
}
}
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
private static string? GetDirectoryFromPath(string? path)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var fullPath = Path.GetFullPath(path);
if (Directory.Exists(fullPath))
{
return fullPath;
}
return File.Exists(fullPath)
? Path.GetDirectoryName(fullPath)
: null;
}
catch
{
return null;
}
}
private static void AppendRestartParentProcessArgumentToString(System.Text.StringBuilder builder)
private static void AppendForwardedArguments(
StringBuilder builder,
IReadOnlyList<string> commandLineArgs,
RestartPresentationMode restartPresentationMode)
{
if (builder.Length > 0) builder.Append(' ');
builder.Append($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
AppendFilteredArguments(builder, commandLineArgs);
AppendRestartArguments(builder, restartPresentationMode);
}
private static void AppendFilteredArguments(StringBuilder builder, IReadOnlyList<string> commandLineArgs)
{
for (var index = 1; index < commandLineArgs.Count; index++)
{
if (ShouldSkipArgument(commandLineArgs, ref index))
{
continue;
}
if (builder.Length > 0)
{
builder.Append(' ');
}
builder.Append(QuoteArgument(commandLineArgs[index]));
}
}
private static bool ShouldSkipArgument(IReadOnlyList<string> commandLineArgs, ref int index)
{
var argument = commandLineArgs[index];
if (!argument.StartsWith("--", StringComparison.Ordinal))
{
return false;
}
var key = argument[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
key = key[..equalsIndex];
}
var shouldSkip = string.Equals(key, LauncherIpcConstants.LaunchSourceOptionName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, LauncherIpcConstants.RestartParentPidOptionName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, LauncherIpcConstants.RestartPresentationOptionName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, LauncherIpcConstants.LauncherPidEnvVar, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, LauncherIpcConstants.PackageRootEnvVar, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, LauncherIpcConstants.VersionEnvVar, StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, LauncherIpcConstants.CodenameEnvVar, StringComparison.OrdinalIgnoreCase);
if (shouldSkip &&
equalsIndex < 0 &&
index + 1 < commandLineArgs.Count &&
!commandLineArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
{
index++;
}
return shouldSkip;
}
private static void AppendRestartArguments(StringBuilder builder, RestartPresentationMode restartPresentationMode)
{
if (builder.Length > 0)
{
builder.Append(' ');
}
builder.Append($"--{LauncherIpcConstants.LaunchSourceOptionName}=restart");
builder.Append($" --{LauncherIpcConstants.RestartParentPidOptionName}={Environment.ProcessId}");
builder.Append(
$" --{LauncherIpcConstants.RestartPresentationOptionName}={LauncherRuntimeMetadata.FormatRestartPresentation(restartPresentationMode)}");
}
private static string QuoteArgument(string value)
@@ -188,7 +330,7 @@ public static class AppRestartService
return value;
}
var builder = new System.Text.StringBuilder();
var builder = new StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
@@ -206,21 +348,7 @@ public static class AppRestartService
return builder.ToString();
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;
if (string.IsNullOrWhiteSpace(argument) ||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return int.TryParse(
argument[RestartParentPidArgumentPrefix.Length..],
out processId) && processId > 0;
}
private static string? NormalizeExistingPath(string? path)
private static string? NormalizeExistingFile(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
@@ -238,6 +366,24 @@ public static class AppRestartService
}
}
private static string? NormalizeExistingDirectory(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var fullPath = Path.GetFullPath(path);
return Directory.Exists(fullPath) ? fullPath : null;
}
catch
{
return null;
}
}
private static bool IsDotnetHost(string? processPath)
{
if (string.IsNullOrWhiteSpace(processPath))

View File

@@ -0,0 +1,274 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
internal enum TrayAvailabilityState
{
Unavailable = 0,
Initializing = 1,
Ready = 2,
Recovering = 3,
Failed = 4
}
internal sealed class DesktopTrayService : IDisposable
{
private readonly Application _application;
private readonly IAppLogoService _appLogoService;
private readonly Func<string, string, string> _localize;
private readonly Func<bool> _shouldShowComponentLibraryMenuItem;
private readonly EventHandler _onShowDesktop;
private readonly EventHandler _onSettings;
private readonly EventHandler _onComponentLibrary;
private readonly EventHandler _onRestart;
private readonly EventHandler _onExit;
private readonly DispatcherTimer _watchdogTimer;
private TrayIcon? _trayIcon;
private NativeMenuItem? _showDesktopMenuItem;
private NativeMenuItem? _settingsMenuItem;
private NativeMenuItem? _componentLibraryMenuItem;
private NativeMenuItem? _restartMenuItem;
private NativeMenuItem? _exitMenuItem;
private int _consecutiveRecoveryFailures;
public DesktopTrayService(
Application application,
IAppLogoService appLogoService,
Func<string, string, string> localize,
Func<bool> shouldShowComponentLibraryMenuItem,
EventHandler onShowDesktop,
EventHandler onSettings,
EventHandler onComponentLibrary,
EventHandler onRestart,
EventHandler onExit)
{
_application = application ?? throw new ArgumentNullException(nameof(application));
_appLogoService = appLogoService ?? throw new ArgumentNullException(nameof(appLogoService));
_localize = localize ?? throw new ArgumentNullException(nameof(localize));
_shouldShowComponentLibraryMenuItem = shouldShowComponentLibraryMenuItem ?? throw new ArgumentNullException(nameof(shouldShowComponentLibraryMenuItem));
_onShowDesktop = onShowDesktop ?? throw new ArgumentNullException(nameof(onShowDesktop));
_onSettings = onSettings ?? throw new ArgumentNullException(nameof(onSettings));
_onComponentLibrary = onComponentLibrary ?? throw new ArgumentNullException(nameof(onComponentLibrary));
_onRestart = onRestart ?? throw new ArgumentNullException(nameof(onRestart));
_onExit = onExit ?? throw new ArgumentNullException(nameof(onExit));
_watchdogTimer = new DispatcherTimer(TimeSpan.FromSeconds(5), DispatcherPriority.Background, OnWatchdogTick);
}
public TrayAvailabilityState State { get; private set; } = TrayAvailabilityState.Unavailable;
public bool IsReady => State == TrayAvailabilityState.Ready;
public event Action<TrayAvailabilityState>? StateChanged;
public bool EnsureReady(string reason)
{
if (HasHealthyTray())
{
_consecutiveRecoveryFailures = 0;
SetState(TrayAvailabilityState.Ready, reason);
return true;
}
return TryCreateOrRefreshTray(reason, isRecoveryAttempt: State != TrayAvailabilityState.Unavailable);
}
public void Refresh(string reason)
{
if (!EnsureReady(reason))
{
return;
}
ApplyTrayContent();
}
public void StartWatchdog()
{
if (!_watchdogTimer.IsEnabled)
{
_watchdogTimer.Start();
}
}
public void StopWatchdog()
{
if (_watchdogTimer.IsEnabled)
{
_watchdogTimer.Stop();
}
}
public void Dispose()
{
StopWatchdog();
try
{
if (_trayIcon is not null)
{
_trayIcon.IsVisible = false;
}
}
catch
{
}
SetState(TrayAvailabilityState.Unavailable, "Dispose");
}
private void OnWatchdogTick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
if (State == TrayAvailabilityState.Unavailable || State == TrayAvailabilityState.Failed)
{
return;
}
if (HasHealthyTray())
{
return;
}
TryCreateOrRefreshTray("Watchdog", isRecoveryAttempt: true);
}
private bool TryCreateOrRefreshTray(string reason, bool isRecoveryAttempt)
{
try
{
SetState(
isRecoveryAttempt ? TrayAvailabilityState.Recovering : TrayAvailabilityState.Initializing,
reason);
EnsureTrayObjects();
ApplyTrayContent();
TrayIcon.SetIcons(_application, [_trayIcon!]);
if (!HasHealthyTray())
{
throw new InvalidOperationException("Tray icon did not reach a healthy state after initialization.");
}
_consecutiveRecoveryFailures = 0;
SetState(TrayAvailabilityState.Ready, reason);
return true;
}
catch (Exception ex)
{
_consecutiveRecoveryFailures++;
SetState(TrayAvailabilityState.Failed, $"{reason}:{ex.GetType().Name}");
AppLogger.Warn("TrayIcon", $"Tray initialization/recovery failed. Reason='{reason}'. Attempt={_consecutiveRecoveryFailures}.", ex);
return false;
}
}
private void EnsureTrayObjects()
{
_showDesktopMenuItem ??= CreateMenuItem(_onShowDesktop);
_settingsMenuItem ??= CreateMenuItem(_onSettings);
_componentLibraryMenuItem ??= CreateMenuItem(_onComponentLibrary);
_restartMenuItem ??= CreateMenuItem(_onRestart);
_exitMenuItem ??= CreateMenuItem(_onExit);
if (_trayIcon is null)
{
var trayMenu = new NativeMenu();
trayMenu.Items.Add(_showDesktopMenuItem);
trayMenu.Items.Add(_settingsMenuItem);
trayMenu.Items.Add(_componentLibraryMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_restartMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_exitMenuItem);
_trayIcon = new TrayIcon
{
Menu = trayMenu
};
}
}
private void ApplyTrayContent()
{
if (_trayIcon is null)
{
return;
}
_trayIcon.Icon = _appLogoService.CreateTrayIcon();
_trayIcon.IsVisible = true;
if (!OperatingSystem.IsLinux())
{
_trayIcon.ToolTipText = _localize("tray.tooltip", "LanMountainDesktop");
}
if (_showDesktopMenuItem is not null)
{
_showDesktopMenuItem.Header = _localize("tray.menu.show_desktop", "Open Desktop");
}
if (_settingsMenuItem is not null)
{
_settingsMenuItem.Header = _localize("tray.menu.settings", "Settings");
}
if (_componentLibraryMenuItem is not null)
{
_componentLibraryMenuItem.IsVisible = _shouldShowComponentLibraryMenuItem();
if (_componentLibraryMenuItem.IsVisible)
{
_componentLibraryMenuItem.Header = _localize("tray.menu.component_library", "Component Library");
}
}
if (_restartMenuItem is not null)
{
_restartMenuItem.Header = _localize("tray.menu.restart", "Restart App");
}
if (_exitMenuItem is not null)
{
_exitMenuItem.Header = _localize("tray.menu.exit", "Exit App");
}
}
private bool HasHealthyTray()
{
return _trayIcon is not null &&
_trayIcon.Menu is not null &&
_trayIcon.Icon is not null &&
_trayIcon.IsVisible &&
_showDesktopMenuItem is not null &&
_settingsMenuItem is not null &&
_componentLibraryMenuItem is not null &&
_restartMenuItem is not null &&
_exitMenuItem is not null;
}
private void SetState(TrayAvailabilityState state, string reason)
{
if (State == state)
{
return;
}
var previous = State;
State = state;
AppLogger.Info("TrayIcon", $"Tray availability changed. Previous='{previous}'; Current='{state}'; Reason='{reason}'.");
StateChanged?.Invoke(state);
}
private static NativeMenuItem CreateMenuItem(EventHandler clickHandler)
{
var item = new NativeMenuItem();
item.Click += clickHandler;
return item;
}
}

View File

@@ -1,27 +1,25 @@
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.ExternalIpc;
internal sealed class PublicAppInfoService : IPublicAppInfoService
{
private readonly string _version;
private readonly string _codename;
private readonly DateTimeOffset _startedAt;
public PublicAppInfoService(string version, string codename, DateTimeOffset startedAt)
public PublicAppInfoService(DateTimeOffset startedAt)
{
_version = version;
_codename = codename;
_startedAt = startedAt;
}
public PublicAppInfoSnapshot GetAppInfo()
{
var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
return new PublicAppInfoSnapshot(
"LanMountainDesktop",
_version,
_codename,
versionInfo.Version,
versionInfo.Codename,
IpcConstants.DefaultPipeName,
Environment.ProcessId,
_startedAt);

View File

@@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services;
@@ -105,7 +106,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
"Extensions",
"Plugins");
var startInfo = AppRestartService.CreateRestartStartInfo();
var app = Application.Current as App;
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
var launchArgs = startInfo?.Arguments ?? "";
@@ -121,7 +124,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
Process.Start(helperStartInfo);
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
return TryExit(request);
@@ -129,7 +131,9 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
{
var startInfo = AppRestartService.CreateRestartStartInfo();
var app = Application.Current as App;
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
if (startInfo is null)
{
AppLogger.Warn(
@@ -139,7 +143,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
Process.Start(startInfo);
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")

View File

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

View File

@@ -414,7 +414,7 @@ internal sealed class NotificationWindowManager
var screen = GetPrimaryScreen();
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
var scale = 1d;
var scale = screen?.Scaling ?? 1d;
for (var i = 0; i < windows.Count; i++)
{
@@ -432,12 +432,19 @@ internal sealed class NotificationWindowManager
int stackIndex)
{
window.Measure(Size.Infinity);
var windowWidth = window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
var windowHeight = window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
var windowWidthDip = window.Bounds.Width > 0
? window.Bounds.Width
: window.DesiredSize.Width > 0 ? window.DesiredSize.Width : 320;
var windowHeightDip = window.Bounds.Height > 0
? window.Bounds.Height
: window.DesiredSize.Height > 0 ? window.DesiredSize.Height : 80;
var windowWidth = (int)Math.Round(windowWidthDip * scale);
var windowHeight = (int)Math.Round(windowHeightDip * scale);
var margin = (int)Math.Round(Margin * scale);
var spacing = (int)Math.Round(Spacing * scale);
var stackedOffset = stackIndex * ((int)Math.Round(windowHeight) + spacing);
var stackedOffset = stackIndex * (windowHeight + spacing);
return position switch
{
@@ -446,31 +453,31 @@ internal sealed class NotificationWindowManager
workingArea.Y + margin + stackedOffset),
NotificationPosition.TopRight => new PixelPoint(
workingArea.Right - (int)Math.Round(windowWidth) - margin,
workingArea.Right - windowWidth - margin,
workingArea.Y + margin + stackedOffset),
NotificationPosition.TopCenter => new PixelPoint(
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
workingArea.X + (workingArea.Width - windowWidth) / 2,
workingArea.Y + margin + stackedOffset),
NotificationPosition.BottomLeft => new PixelPoint(
workingArea.X + margin,
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
workingArea.Bottom - windowHeight - margin - stackedOffset),
NotificationPosition.BottomRight => new PixelPoint(
workingArea.Right - (int)Math.Round(windowWidth) - margin,
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
workingArea.Right - windowWidth - margin,
workingArea.Bottom - windowHeight - margin - stackedOffset),
NotificationPosition.BottomCenter => new PixelPoint(
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
workingArea.Bottom - (int)Math.Round(windowHeight) - margin - stackedOffset),
workingArea.X + (workingArea.Width - windowWidth) / 2,
workingArea.Bottom - windowHeight - margin - stackedOffset),
NotificationPosition.Center => new PixelPoint(
workingArea.X + (workingArea.Width - (int)Math.Round(windowWidth)) / 2,
workingArea.Y + (workingArea.Height - (int)Math.Round(windowHeight)) / 2),
workingArea.X + (workingArea.Width - windowWidth) / 2,
workingArea.Y + (workingArea.Height - windowHeight) / 2),
_ => new PixelPoint(
workingArea.Right - (int)Math.Round(windowWidth) - margin,
workingArea.Right - windowWidth - margin,
workingArea.Y + margin + stackedOffset)
};
}

View File

@@ -1290,6 +1290,10 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppVersionText()
{
return LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider
.ResolveForCurrentProcess()
.Version;
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envVersion = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.VersionEnvVar);
if (!string.IsNullOrWhiteSpace(envVersion))
@@ -1337,6 +1341,10 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
public string GetAppCodenameText()
{
return LanMountainDesktop.Shared.Contracts.Launcher.AppVersionProvider
.ResolveForCurrentProcess()
.Codename;
// 浼樺厛浠庣幆澧冨彉閲忚鍙栵紙Launcher 浼犻€掞級
var envCodename = Environment.GetEnvironmentVariable(LanMountainDesktop.Shared.Contracts.Launcher.LauncherIpcConstants.CodenameEnvVar);
if (!string.IsNullOrWhiteSpace(envCodename))

View File

@@ -920,8 +920,12 @@ public partial class MainWindow : Window
if (useSlide)
{
var screenWidth = Screens.ScreenFromVisual(this)?.Bounds.Width ?? 3840;
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidth;
var screen = Screens.ScreenFromVisual(this);
var scale = screen?.Scaling ?? 1d;
var screenWidthDip = screen is null
? 1920d
: screen.WorkingArea.Width / Math.Max(scale, 0.01d);
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidthDip;
}
DesktopPage.Transitions = savedTransitions;

View File

@@ -3,7 +3,7 @@
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="LanMountainDesktop.Desktop"/>
<assemblyIdentity version="0.0.0.0" name="LanMountainDesktop.Desktop"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>

View File

@@ -0,0 +1,56 @@
param(
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[string]$AssemblyVersion
)
$ErrorActionPreference = 'Stop'
function Update-XmlNodeValue {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$XPath,
[Parameter(Mandatory = $true)]
[string]$Value,
[hashtable]$NamespaceMap = @{}
)
[xml]$document = Get-Content -Path $Path -Raw
$navigator = $document.CreateNavigator()
$namespaceManager = New-Object System.Xml.XmlNamespaceManager($navigator.NameTable)
foreach ($entry in $NamespaceMap.GetEnumerator()) {
$namespaceManager.AddNamespace($entry.Key, $entry.Value)
}
$node = $document.SelectSingleNode($XPath, $namespaceManager)
if ($null -eq $node) {
throw "Node '$XPath' was not found in '$Path'."
}
$node.InnerText = $Value
$document.Save($Path)
}
$projectFiles = @(
'Directory.Build.props',
'LanMountainDesktop/LanMountainDesktop.csproj',
'LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj',
'LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj'
)
foreach ($projectFile in $projectFiles) {
Update-XmlNodeValue -Path $projectFile -XPath '/Project/PropertyGroup/Version' -Value $Version
}
$manifestNamespace = @{ asm = 'urn:schemas-microsoft-com:asm.v1' }
Update-XmlNodeValue -Path 'LanMountainDesktop/app.manifest' -XPath '/asm:assembly/asm:assemblyIdentity/@version' -Value $AssemblyVersion -NamespaceMap $manifestNamespace
Update-XmlNodeValue -Path 'LanMountainDesktop.Launcher/app.manifest' -XPath '/asm:assembly/asm:assemblyIdentity/@version' -Value $AssemblyVersion -NamespaceMap $manifestNamespace
Write-Host "Stamped release version metadata. Version=$Version AssemblyVersion=$AssemblyVersion"