Stamp release versions and harden launcher

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

View File

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

View File

@@ -119,6 +119,13 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview' 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 - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}
@@ -364,6 +371,13 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview' 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 - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}
@@ -545,6 +559,13 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview' 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 - name: Restore
run: dotnet restore ${{ env.Solution_Name }} run: dotnet restore ${{ env.Solution_Name }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,9 @@ public partial class App : Application
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate(); private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow; 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 ISettingsPageRegistry? _settingsPageRegistry;
private ISettingsWindowService? _settingsWindowService; private ISettingsWindowService? _settingsWindowService;
private WeatherLocationRefreshService? _weatherLocationRefreshService; private WeatherLocationRefreshService? _weatherLocationRefreshService;
@@ -67,12 +70,7 @@ public partial class App : Application
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop; private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent; private ShutdownIntent _shutdownIntent;
private TrayIcon? _trayIcon; private DesktopTrayService? _desktopTrayService;
private NativeMenuItem? _trayShowDesktopMenuItem;
private NativeMenuItem? _traySettingsMenuItem;
private NativeMenuItem? _trayComponentLibraryMenuItem;
private NativeMenuItem? _trayRestartMenuItem;
private NativeMenuItem? _trayExitMenuItem;
private PluginRuntimeService? _pluginRuntimeService; private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow; private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow; private TransparentOverlayWindow? _transparentOverlayWindow;
@@ -108,6 +106,15 @@ public partial class App : Application
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle; public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService; internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
internal INotificationService? NotificationService => _notificationService; 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) internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
{ {
@@ -470,128 +477,90 @@ public partial class App : Application
private void InitializeTrayIcon() private void InitializeTrayIcon()
{ {
try EnsureDesktopTrayService();
_trayInitialized = _desktopTrayService?.EnsureReady("Startup") == true;
if (_trayInitialized)
{ {
if (_trayIcon is null) ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
{
_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;
AppLogger.Info("TrayIcon", $"Tray initialized successfully. Pid={Environment.ProcessId}."); AppLogger.Info("TrayIcon", $"Tray initialized successfully. Pid={Environment.ProcessId}.");
return;
} }
catch (Exception ex)
{ AppLogger.Warn("TrayIcon", "Tray initialization did not reach the ready state.");
_trayInitialized = false;
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
}
} }
private void RefreshTrayIconContent() private void RefreshTrayIconContent()
{ {
if (_trayIcon is not null) EnsureDesktopTrayService();
{ _desktopTrayService?.Refresh("RefreshTrayContent");
_trayIcon.IsVisible = true; _trayInitialized = _desktopTrayService?.IsReady == 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");
}
} }
private void RefreshFusedDesktopMenuItemVisibility() private void RefreshFusedDesktopMenuItemVisibility()
{ {
if (_trayComponentLibraryMenuItem is null) RefreshTrayIconContent();
{
return;
}
if (!OperatingSystem.IsWindows())
{
_trayComponentLibraryMenuItem.IsVisible = false;
return;
}
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
if (_trayComponentLibraryMenuItem.IsVisible)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
} }
private void DisposeTrayIcon() private void DisposeTrayIcon()
{ {
if (_trayIcon is null) _desktopTrayService?.Dispose();
_trayInitialized = false;
}
private void EnsureDesktopTrayService()
{
if (_desktopTrayService is not null)
{ {
return; return;
} }
try _desktopTrayService = new DesktopTrayService(
this,
_appLogoService,
L,
ShouldShowTrayComponentLibraryMenuItem,
OnTrayShowDesktopClick,
OnTraySettingsClick,
OnTrayComponentLibraryClick,
OnTrayRestartClick,
OnTrayExitClick);
_desktopTrayService.StateChanged += OnTrayAvailabilityStateChanged;
}
private bool EnsureTrayReady(string reason)
{
EnsureDesktopTrayService();
var ready = _desktopTrayService?.EnsureReady(reason) == true;
_trayInitialized = ready;
if (ready)
{ {
_trayIcon.IsVisible = false; ReportStartupProgress(StartupStage.TrayReady, 75, "Tray ready.");
} }
catch (Exception ex)
return ready;
}
private void OnTrayAvailabilityStateChanged(TrayAvailabilityState state)
{
_trayInitialized = state == TrayAvailabilityState.Ready;
if (state == TrayAvailabilityState.Failed && _desktopShellState == DesktopShellState.TrayOnly)
{ {
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex); RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayAvailabilityFailed");
} }
} }
private bool ShouldShowTrayComponentLibraryMenuItem()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
return appSnapshot.EnableFusedDesktop;
}
private void EnsureSettingsWindowService() private void EnsureSettingsWindowService()
{ {
_settingsPageRegistry ??= new SettingsPageRegistry( _settingsPageRegistry ??= new SettingsPageRegistry(
@@ -764,6 +733,7 @@ public partial class App : Application
mainWindow.PlayEnterAnimation(); mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background); }, DispatcherPriority.Background);
_desktopTrayService?.StopWatchdog();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}"); SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info( AppLogger.Info(
"DesktopShell", "DesktopShell",
@@ -872,6 +842,7 @@ public partial class App : Application
if (themeChanged) if (themeChanged)
{ {
ApplyThemeFromSettings(); ApplyThemeFromSettings();
RefreshTrayIconContent();
} }
if (languageChanged) if (languageChanged)
@@ -898,7 +869,11 @@ public partial class App : Application
_ = sender; _ = sender;
_ = e; _ = e;
Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background); Dispatcher.UIThread.Post(() =>
{
ApplyThemeFromSettings();
RefreshTrayIconContent();
}, DispatcherPriority.Background);
} }
private void ApplyAdaptiveThemeResources() private void ApplyAdaptiveThemeResources()
@@ -1144,18 +1119,56 @@ public partial class App : Application
{ {
mainWindow.Opened -= OnMainWindowOpened; mainWindow.Opened -= OnMainWindowOpened;
_mainWindowOpened = true; _mainWindowOpened = true;
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
if (TryApplyStartupPresentation(mainWindow))
{
AppLogger.Info(
"App",
$"Main window opened and startup presentation was applied. LaunchSource='{_launchSource}'; RestartPresentation='{_requestedRestartPresentationMode?.ToString() ?? "<none>"}'; ShellState='{_desktopShellState}'.");
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
_loadingStateReporter?.Stop();
return;
}
AppLogger.Info( AppLogger.Info(
"App", "App",
$"Main window opened. Reporting DesktopVisible. TrayInitialized={_trayInitialized}; ShellState='{_desktopShellState}'."); $"Main window opened. Reporting DesktopVisible. TrayInitialized={_trayInitialized}; ShellState='{_desktopShellState}'.");
_loadingStateManager?.CompleteItem("system.init", "System initialization completed.");
ReportStartupProgressSync(StartupStage.DesktopVisible, 100, "Desktop visible."); ReportStartupProgressSync(StartupStage.DesktopVisible, 100, "Desktop visible.");
ReportStartupProgressSync(StartupStage.Ready, 100, "Ready."); ReportStartupProgressSync(StartupStage.Ready, 100, "Ready.");
_loadingStateReporter?.Stop(); _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( private MainWindow GetOrCreateMainWindow(
IClassicDesktopStyleApplicationLifetime desktop, IClassicDesktopStyleApplicationLifetime desktop,
string reason) string reason)
@@ -1242,7 +1255,15 @@ public partial class App : Application
if (_shutdownIntent == ShutdownIntent.None) 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 try
{ {
if (!EnsureTrayReady($"HideToTray:{source}"))
{
RecoverFromTrayUnavailable(mainWindow, source);
return;
}
mainWindow.ShowInTaskbar = false; mainWindow.ShowInTaskbar = false;
mainWindow.Hide(); mainWindow.Hide();
_desktopTrayService?.StartWatchdog();
SetDesktopShellState(DesktopShellState.TrayOnly, source); SetDesktopShellState(DesktopShellState.TrayOnly, source);
ReportStartupProgress(StartupStage.BackgroundReady, 95, "Background ready.");
AppLogger.Info( AppLogger.Info(
"DesktopShell", "DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'."); $"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
@@ -1293,9 +1322,56 @@ public partial class App : Application
catch (Exception ex) catch (Exception ex)
{ {
AppLogger.Warn("DesktopShell", $"Failed to hide main window to tray. Source='{source}'.", 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() private bool ShouldShowMainWindowInTaskbar()
{ {
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar; return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
@@ -1364,17 +1440,19 @@ public partial class App : Application
try try
{ {
var version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0"; var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
_publicIpcHostService = new PublicIpcHostService(); _publicIpcHostService = new PublicIpcHostService();
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors; _publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>( _publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
new PublicAppInfoService(version, "Administrate", _startupAt)); new PublicAppInfoService(_startupAt));
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>( _publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
new PublicShellControlService()); new PublicShellControlService());
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>( _publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
new PublicPluginCatalogService(_publicIpcHostService)); new PublicPluginCatalogService(_publicIpcHostService));
_publicIpcHostService.Start(); _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) catch (Exception ex)
{ {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -920,8 +920,12 @@ public partial class MainWindow : Window
if (useSlide) if (useSlide)
{ {
var screenWidth = Screens.ScreenFromVisual(this)?.Bounds.Width ?? 3840; var screen = Screens.ScreenFromVisual(this);
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidth; 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; DesktopPage.Transitions = savedTransitions;

View File

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

View File

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