From 001d77968fa98d8b162e890bfa88432bd0a8eda3 Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 23 Apr 2026 00:27:01 +0800 Subject: [PATCH] Stamp release versions and harden launcher Add automatic release version stamping and multiple launcher reliability improvements. The Release workflow now runs scripts/Set-ReleaseVersion.ps1 in build jobs to inject tag-derived Version/AssemblyVersion into project metadata; several .csproj/Directory.Build.props and app.manifest files were changed to use a dev placeholder. Introduced AppVersionProvider (and related runtime metadata) to centralize version resolution and updated DeploymentLocator to use it and to prefer package-root/version.json. Launcher startup flow was hardened: added startup success tracking, public-activation recovery path, improved success/fallback semantics, and related IPC handling. UI/UX fixes include OOBE entrance/exit animation improvements (scaling-aware, concurrent fade+translate) and minor window lifecycle reorder in DesktopShellHost. CommandContext now recognizes restart and key=value args. New DesktopTrayService and .trae spec files (spec, checklist, tasks) document shell/tray hardening work. Miscellaneous logging, comments and housekeeping edits across launcher and shared contracts to support the above. --- .github/VERSION_SYNC_INFO.md | 152 +++----- .github/workflows/release.yml | 21 + .../launcher-shell-hardening/checklist.md | 10 + .trae/specs/launcher-shell-hardening/spec.md | 67 ++++ .trae/specs/launcher-shell-hardening/tasks.md | 14 + Directory.Build.props | 2 +- .../DesktopShellHost.cs | 2 +- LanMountainDesktop.Launcher/CommandContext.cs | 8 + .../LanMountainDesktop.Launcher.csproj | 2 +- .../Services/DeploymentLocator.cs | 95 ++--- .../Services/LauncherFlowCoordinator.cs | 208 +++++++++- .../Views/OobeWindow.axaml | 6 +- .../Views/OobeWindow.axaml.cs | 67 ++-- LanMountainDesktop.Launcher/app.manifest | 2 +- ...LanMountainDesktop.Shared.Contracts.csproj | 2 +- .../Launcher/AppVersionProvider.cs | 362 ++++++++++++++++++ .../Launcher/LauncherIpc.cs | 8 + .../Launcher/LauncherRuntimeMetadata.cs | 148 +++++++ LanMountainDesktop/App.axaml.cs | 296 ++++++++------ LanMountainDesktop/LanMountainDesktop.csproj | 2 +- LanMountainDesktop/Program.cs | 2 +- .../Services/AppRestartService.cs | 272 ++++++++++--- .../Services/DesktopTrayService.cs | 274 +++++++++++++ .../ExternalIpc/PublicAppInfoService.cs | 12 +- .../HostApplicationLifecycleService.cs | 11 +- .../Services/Launcher/LauncherIpcClient.cs | 51 +-- .../Services/NotificationService.cs | 35 +- .../Settings/SettingsDomainServices.cs | 8 + LanMountainDesktop/Views/MainWindow.axaml.cs | 8 +- LanMountainDesktop/app.manifest | 2 +- scripts/Set-ReleaseVersion.ps1 | 56 +++ 31 files changed, 1727 insertions(+), 478 deletions(-) create mode 100644 .trae/specs/launcher-shell-hardening/checklist.md create mode 100644 .trae/specs/launcher-shell-hardening/spec.md create mode 100644 .trae/specs/launcher-shell-hardening/tasks.md create mode 100644 LanMountainDesktop.Shared.Contracts/Launcher/AppVersionProvider.cs create mode 100644 LanMountainDesktop.Shared.Contracts/Launcher/LauncherRuntimeMetadata.cs create mode 100644 LanMountainDesktop/Services/DesktopTrayService.cs create mode 100644 scripts/Set-ReleaseVersion.ps1 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"