diff --git a/.github/VERSION_SYNC_INFO.md b/.github/VERSION_SYNC_INFO.md
index 6de0e51..0fe8885 100644
--- a/.github/VERSION_SYNC_INFO.md
+++ b/.github/VERSION_SYNC_INFO.md
@@ -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" | Set-Content file.csproj
-```
+## Release 工作流怎么做
-**Linux/macOS (Bash)**:
-```bash
-VERSION="1.0.1"
-sed -i "s/.*<\/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` | 项目文件版本 | ✅ 是 |
-| 程序集版本 | 编译时读取 | ✅ 是 |
-| 应用内显示 | 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 工作流里。
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c8147c2..ceeb801 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/.trae/specs/launcher-shell-hardening/checklist.md b/.trae/specs/launcher-shell-hardening/checklist.md
new file mode 100644
index 0000000..449b659
--- /dev/null
+++ b/.trae/specs/launcher-shell-hardening/checklist.md
@@ -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、主窗口、通知动画正常。
diff --git a/.trae/specs/launcher-shell-hardening/spec.md b/.trae/specs/launcher-shell-hardening/spec.md
new file mode 100644
index 0000000..2e724e6
--- /dev/null
+++ b/.trae/specs/launcher-shell-hardening/spec.md
@@ -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-` 部署目录
+- Release 工作流必须显式打版本补丁,避免仓库默认占位值被误当成正式版本。
+
+### 4. 高分屏动画
+
+- 主窗口、通知、Launcher OOBE 的动画位移必须使用 DIP 或基于缩放换算后的尺寸。
+- 不允许直接把 `PixelRect` 宽高当作 `TranslateTransform` 或 `DesiredSize` 的输入。
+- 淡入和位移动画应并行执行,避免先淡入后滑动造成观感异常。
+
+## 验收
+
+- 已在托盘中的实例再次通过 Launcher 启动时,只激活已有实例。
+- 设置页重启和插件升级重启后,不再出现“窗口未显示但后台已有多个进程”。
+- 托盘失败时应用仍保持可恢复。
+- Launcher 与应用设置页显示相同版本。
+- 100% / 150% / 200% / 250% 缩放下,Launcher OOBE、主窗口入场、通知位置与动画正常。
diff --git a/.trae/specs/launcher-shell-hardening/tasks.md b/.trae/specs/launcher-shell-hardening/tasks.md
new file mode 100644
index 0000000..9644627
--- /dev/null
+++ b/.trae/specs/launcher-shell-hardening/tasks.md
@@ -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] 补充规格与版本同步说明文档。
+- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
diff --git a/Directory.Build.props b/Directory.Build.props
index 58cb0ec..87ee614 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,6 @@
- 1.0.0
+ 0.0.0-dev
net10.0
enable
enable
diff --git a/LanMountainDesktop.DesktopHost/DesktopShellHost.cs b/LanMountainDesktop.DesktopHost/DesktopShellHost.cs
index 2dc756d..aeadc83 100644
--- a/LanMountainDesktop.DesktopHost/DesktopShellHost.cs
+++ b/LanMountainDesktop.DesktopHost/DesktopShellHost.cs
@@ -46,8 +46,8 @@ public sealed class DesktopShellHost : IDesktopShellHost
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Exit += (_, _) => _performExitCleanup();
- _createAndAssignMainWindow(desktop);
_startActivationListener();
+ _createAndAssignMainWindow(desktop);
}
_startWeatherRefresh();
diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs
index f57c05a..37439a0 100644
--- a/LanMountainDesktop.Launcher/CommandContext.cs
+++ b/LanMountainDesktop.Launcher/CommandContext.cs
@@ -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];
diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
index 01e8e07..d22016c 100644
--- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
+++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj
@@ -8,7 +8,7 @@
net10.0
enable
enable
- 1.0.0
+ 0.0.0-dev
$(Version)
true
diff --git a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
index d5c47bf..981c70b 100644
--- a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
+++ b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs
@@ -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 标记的排前面
- .ThenByDescending(x => x.Version) // 然后按版本号降序
+ .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. 开发模式:如果启用了开发模式,优先使用保存的自定义路径
+ // 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
@@ -315,7 +315,7 @@ internal sealed class DeploymentLocator
}
}
- // 5. 开发模式:查找主程序项目的输出目录
+ // 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
@@ -329,21 +329,21 @@ internal sealed class DeploymentLocator
}
///
- /// 扫描开发路径(开发模式)
+ /// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
///
private static string? ScanDevelopmentPaths(string executable)
{
var possiblePaths = new[]
{
- // ?Launcher 项目运行
+ // 浠?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 目录
+ // dev-test 鐩綍
Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable),
};
@@ -359,22 +359,22 @@ internal sealed class DeploymentLocator
}
///
- /// 获取开发环境可能的主程序路? ///
+ /// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? ///
private static IEnumerable GetDevelopmentPaths(string executable)
{
var launcherDir = AppContext.BaseDirectory;
var possiblePaths = new[]
{
- // ?Launcher 项目运行?.\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),
- // 从解决方案根目录运行: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 目录运行
+ // 浠?dev-test 鐩綍杩愯
Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable),
};
@@ -409,8 +409,8 @@ internal sealed class DeploymentLocator
}
///
- /// 清理旧版本部署,保留最近的N个版? ///
- /// 最少保留版本数,默??/param>
+ /// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? ///
+ /// 鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
@@ -438,10 +438,10 @@ internal sealed class DeploymentLocator
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
- // 确定要保留的版本
+ // 纭畾瑕佷繚鐣欑殑鐗堟湰
var versionsToKeep = new HashSet();
- // 1. 总是保留当前版本
+ // 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. 保留最近的N个有效版本(不包括已标记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. 保留有快照的版本(用于回滚)
+ // 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots");
if (Directory.Exists(snapshotDir))
{
@@ -485,17 +485,17 @@ internal sealed class DeploymentLocator
}
catch
{
- // 忽略快照解析错误
+ // 蹇界暐蹇収瑙f瀽閿欒
}
}
}
catch
{
- // 忽略快照目录访问错误
+ // 蹇界暐蹇収鐩綍璁块棶閿欒
}
}
- // 清理不需要的版本
+ // 娓呯悊涓嶉渶瑕佺殑鐗堟湰
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
@@ -509,7 +509,7 @@ internal sealed class DeploymentLocator
}
catch
{
- // 忽略取消标记失败
+ // 蹇界暐鍙栨秷鏍囪澶辫触
}
}
continue;
@@ -524,11 +524,11 @@ internal sealed class DeploymentLocator
}
catch
{
- // 忽略标记失败
+ // 蹇界暐鏍囪澶辫触
}
}
- // 尝试删除
+ // 灏濊瘯鍒犻櫎
try
{
Directory.Delete(deployment.Path, recursive: true);
@@ -536,7 +536,7 @@ internal sealed class DeploymentLocator
}
catch
{
- // 忽略删除失败(可能文件被占?,下次启动再试
+ // 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
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}");
- // 忽略清理失败
+ // 蹇界暐娓呯悊澶辫触
}
}
///
- /// 仅清理已标记?destroy的部署(兼容旧方法)
+ /// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
///
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
@@ -581,36 +581,17 @@ internal sealed class DeploymentLocator
}
///
- /// 从部署目录读取版本信? ///
+ /// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? ///
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;
+ }
}
diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
index 448c0c2..085e1ae 100644
--- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
+++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs
@@ -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(TaskCreationOptions.RunContinuationsAsynchronously);
+ var successTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var activationFailedTcs = new TaskCompletionSource(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
{
["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
+ {
+ ["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
{
["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 TryRecoverWithPublicActivationAsync(
+ LanMountainDesktopIpcClient ipcClient,
+ Process hostProcess,
+ Task successTask,
+ StartupSuccessTracker startupSuccessTracker)
+ {
+ try
+ {
+ var shellProxy = ipcClient.CreateProxy();
+ 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 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
+ }
}
diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml
index 98cd5a5..0138473 100644
--- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml
+++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml
@@ -21,7 +21,11 @@
-
+
+
+
+
diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
index cb04ee7..0c1e17a 100644
--- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
@@ -9,26 +9,18 @@ using Avalonia.Styling;
namespace LanMountainDesktop.Launcher.Views;
-///
-/// OOBE(首次使用体验)窗口 - 欢迎页面
-///
public partial class OobeWindow : Window
{
private readonly TaskCompletionSource _completionSource = new();
- private bool _isTransitioning = false;
+ private bool _isTransitioning;
public OobeWindow()
{
AvaloniaXamlLoader.Load(this);
-
- // 延迟到窗口加载完成后再初始化
- this.Loaded += OnWindowLoaded;
- this.Opened += OnWindowOpened;
+ Loaded += OnWindowLoaded;
+ Opened += OnWindowOpened;
}
- ///
- /// 窗口加载完成事件
- ///
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
Console.WriteLine("[OobeWindow] Window loaded, initializing components...");
@@ -45,31 +37,29 @@ public partial class OobeWindow : Window
}
}
- ///
- /// 窗口打开事件 - 播放入场动画
- ///
private async void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
await PlayEntranceAnimationAsync();
}
- ///
- /// 播放入场动画
- ///
private async Task PlayEntranceAnimationAsync()
{
try
{
- // 获取内容元素
var contentGrid = this.FindControl("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
}
}
- ///
- /// 等待用户点击开始按钮
- ///
public Task WaitForEnterAsync() => _completionSource.Task;
- ///
- /// 进入按钮点击事件
- ///
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
}
}
- ///
- /// 播放退出动画
- ///
private async Task PlayExitAnimationAsync()
{
try
@@ -161,12 +141,10 @@ public partial class OobeWindow : Window
var contentGrid = this.FindControl("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);
+ }
}
diff --git a/LanMountainDesktop.Launcher/app.manifest b/LanMountainDesktop.Launcher/app.manifest
index 87cff0b..fd9bc41 100644
--- a/LanMountainDesktop.Launcher/app.manifest
+++ b/LanMountainDesktop.Launcher/app.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj b/LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj
index 03c0829..1b05865 100644
--- a/LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj
+++ b/LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj
@@ -3,7 +3,7 @@
net10.0
enable
enable
- 1.0.0
+ 0.0.0-dev
LanMountainDesktop.Shared.Contracts
true
LanMountainDesktop
diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/AppVersionProvider.cs b/LanMountainDesktop.Shared.Contracts/Launcher/AppVersionProvider.cs
new file mode 100644
index 0000000..8c57fca
--- /dev/null
+++ b/LanMountainDesktop.Shared.Contracts/Launcher/AppVersionProvider.cs
@@ -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? 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(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 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;
+ }
+ }
+}
diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
index c35ae94..aea0b84 100644
--- a/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
+++ b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs
@@ -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";
}
diff --git a/LanMountainDesktop.Shared.Contracts/Launcher/LauncherRuntimeMetadata.cs b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherRuntimeMetadata.cs
new file mode 100644
index 0000000..b2ef07f
--- /dev/null
+++ b/LanMountainDesktop.Shared.Contracts/Launcher/LauncherRuntimeMetadata.cs
@@ -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? 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? commandLineArgs = null)
+ {
+ return !string.IsNullOrWhiteSpace(GetOptionValue(key, commandLineArgs));
+ }
+
+ public static string? GetPackageRoot(IReadOnlyList? commandLineArgs = null)
+ {
+ return FirstNonEmpty(
+ Environment.GetEnvironmentVariable(LauncherIpcConstants.PackageRootEnvVar),
+ GetOptionValue(LauncherIpcConstants.PackageRootEnvVar, commandLineArgs));
+ }
+
+ public static string? GetForwardedVersion(IReadOnlyList? commandLineArgs = null)
+ {
+ return FirstNonEmpty(
+ Environment.GetEnvironmentVariable(LauncherIpcConstants.VersionEnvVar),
+ GetOptionValue(LauncherIpcConstants.VersionEnvVar, commandLineArgs));
+ }
+
+ public static string? GetForwardedCodename(IReadOnlyList? commandLineArgs = null)
+ {
+ return FirstNonEmpty(
+ Environment.GetEnvironmentVariable(LauncherIpcConstants.CodenameEnvVar),
+ GetOptionValue(LauncherIpcConstants.CodenameEnvVar, commandLineArgs));
+ }
+
+ public static string? GetLaunchSource(IReadOnlyList? commandLineArgs = null)
+ {
+ return GetOptionValue(LauncherIpcConstants.LaunchSourceOptionName, commandLineArgs);
+ }
+
+ public static int? GetLauncherProcessId(IReadOnlyList? commandLineArgs = null)
+ {
+ var rawValue = FirstNonEmpty(
+ Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar),
+ GetOptionValue(LauncherIpcConstants.LauncherPidEnvVar, commandLineArgs));
+
+ return TryParsePositiveInt(rawValue);
+ }
+
+ public static int? GetRestartParentProcessId(IReadOnlyList? commandLineArgs = null)
+ {
+ var rawValue = GetOptionValue(LauncherIpcConstants.RestartParentPidOptionName, commandLineArgs);
+ return TryParsePositiveInt(rawValue);
+ }
+
+ public static RestartPresentationMode? GetRestartPresentationMode(IReadOnlyList? 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;
+ }
+}
diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs
index 9918373..bbeeb87 100644
--- a/LanMountainDesktop/App.axaml.cs
+++ b/LanMountainDesktop/App.axaml.cs
@@ -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(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(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() ?? ""}'; 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(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(
- new PublicAppInfoService(version, "Administrate", _startupAt));
+ new PublicAppInfoService(_startupAt));
_publicIpcHostService.RegisterPublicService(
new PublicShellControlService());
_publicIpcHostService.RegisterPublicService(
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)
{
diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj
index ab5dee9..333eb43 100644
--- a/LanMountainDesktop/LanMountainDesktop.csproj
+++ b/LanMountainDesktop/LanMountainDesktop.csproj
@@ -4,7 +4,7 @@
net10.0
LatestMajor
enable
- 1.0.0
+ 0.0.0-dev
app.manifest
Assets\logo_nightly.ico
true
diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs
index 685fd75..826ef12 100644
--- a/LanMountainDesktop/Program.cs
+++ b/LanMountainDesktop/Program.cs
@@ -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)
diff --git a/LanMountainDesktop/Services/AppRestartService.cs b/LanMountainDesktop/Services/AppRestartService.cs
index a4d28fc..cc054d2 100644
--- a/LanMountainDesktop/Services/AppRestartService.cs
+++ b/LanMountainDesktop/Services/AppRestartService.cs
@@ -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 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 commandLineArgs)
+ {
+ ArgumentNullException.ThrowIfNull(commandLineArgs);
+ return LauncherRuntimeMetadata.GetRestartPresentationMode(commandLineArgs);
}
private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath,
string? entryAssemblyPath,
- IReadOnlyList commandLineArgs)
+ IReadOnlyList 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 commandLineArgs)
+ IReadOnlyList 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 commandLineArgs)
+ private static ProcessStartInfo? TryCreateLauncherStartInfo(
+ IReadOnlyList 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 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 commandLineArgs)
+ private static IEnumerable GetPackageRootCandidates(
+ IReadOnlyList 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 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 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 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))
diff --git a/LanMountainDesktop/Services/DesktopTrayService.cs b/LanMountainDesktop/Services/DesktopTrayService.cs
new file mode 100644
index 0000000..4c86823
--- /dev/null
+++ b/LanMountainDesktop/Services/DesktopTrayService.cs
@@ -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 _localize;
+ private readonly Func _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 localize,
+ Func 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? 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;
+ }
+}
diff --git a/LanMountainDesktop/Services/ExternalIpc/PublicAppInfoService.cs b/LanMountainDesktop/Services/ExternalIpc/PublicAppInfoService.cs
index 197455c..e421902 100644
--- a/LanMountainDesktop/Services/ExternalIpc/PublicAppInfoService.cs
+++ b/LanMountainDesktop/Services/ExternalIpc/PublicAppInfoService.cs
@@ -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);
diff --git a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs
index 81bfa25..fd4d7fc 100644
--- a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs
+++ b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs
@@ -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.")
diff --git a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs b/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
index 675398a..983a0f7 100644
--- a/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
+++ b/LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs
@@ -7,9 +7,7 @@ using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Launcher;
///
-/// Launcher IPC 客户端 - 向 Launcher 报告启动进度
-/// 采用持久连接 + 长度前缀协议,在同一连接上可多次发送消息。
-/// 跨平台实现:Windows 使用命名管道,Linux/macOS 使用 Unix 域套接字
+/// Launcher IPC 客户端,用于向 Launcher 报告启动进度。
///
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();
- ///
- /// 是否已连接到 Launcher
- ///
public bool IsConnected => _isConnected && _pipeClient?.IsConnected == true;
- ///
- /// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
- ///
- private const int LengthPrefixSize = 4;
-
- ///
- /// 连接到 Launcher 的 IPC 服务端
- ///
public async Task 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
}
}
- ///
- /// 报告启动进度(在同一连接上可多次调用)
- ///
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
}
}
- ///
- /// 检查是否从 Launcher 启动
- /// 优先检查环境变量,回退到命令行参数(UseShellExecute=true 时环境变量仍可继承,
- /// 命令行参数作为备选确保兼容性)
- ///
public static bool IsLaunchedByLauncher()
{
- // 优先检查环境变量
- if (!string.IsNullOrEmpty(
- Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar)))
- {
- return true;
- }
-
- // 回退到命令行参数检查(格式: --LMD_LAUNCHER_PID=)
- 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()
diff --git a/LanMountainDesktop/Services/NotificationService.cs b/LanMountainDesktop/Services/NotificationService.cs
index 8c67ee2..05d4af5 100644
--- a/LanMountainDesktop/Services/NotificationService.cs
+++ b/LanMountainDesktop/Services/NotificationService.cs
@@ -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)
};
}
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index 6e24d2b..201759a 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -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))
diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs
index 06ae999..e82ef65 100644
--- a/LanMountainDesktop/Views/MainWindow.axaml.cs
+++ b/LanMountainDesktop/Views/MainWindow.axaml.cs
@@ -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;
diff --git a/LanMountainDesktop/app.manifest b/LanMountainDesktop/app.manifest
index e46fabd..aed3e1e 100644
--- a/LanMountainDesktop/app.manifest
+++ b/LanMountainDesktop/app.manifest
@@ -3,7 +3,7 @@
-
+
diff --git a/scripts/Set-ReleaseVersion.ps1 b/scripts/Set-ReleaseVersion.ps1
new file mode 100644
index 0000000..4ca9b0e
--- /dev/null
+++ b/scripts/Set-ReleaseVersion.ps1
@@ -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"