mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
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:
152
.github/VERSION_SYNC_INFO.md
vendored
152
.github/VERSION_SYNC_INFO.md
vendored
@@ -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 (自动版本同步)
|
|
||||||
|
|||||||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||||
|
|
||||||
|
|||||||
10
.trae/specs/launcher-shell-hardening/checklist.md
Normal file
10
.trae/specs/launcher-shell-hardening/checklist.md
Normal 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、主窗口、通知动画正常。
|
||||||
67
.trae/specs/launcher-shell-hardening/spec.md
Normal file
67
.trae/specs/launcher-shell-hardening/spec.md
Normal 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、主窗口入场、通知位置与动画正常。
|
||||||
14
.trae/specs/launcher-shell-hardening/tasks.md
Normal file
14
.trae/specs/launcher-shell-hardening/tasks.md
Normal 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] 补充规格与版本同步说明文档。
|
||||||
|
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
<!-- 应用程序图标 -->
|
<!-- 应用程序图标 -->
|
||||||
|
|||||||
@@ -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"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<!-- 中央内容区域 -->
|
<!-- 中央内容区域 -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
274
LanMountainDesktop/Services/DesktopTrayService.cs
Normal file
274
LanMountainDesktop/Services/DesktopTrayService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
56
scripts/Set-ReleaseVersion.ps1
Normal file
56
scripts/Set-ReleaseVersion.ps1
Normal 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"
|
||||||
Reference in New Issue
Block a user