Compare commits

...

2 Commits

Author SHA1 Message Date
lincube
001d77968f 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.
2026-04-23 00:27:01 +08:00
lincube
e20462ac2b Make settings window independent and taskbar-aware
Convert the settings window into an independent top-level window with its own taskbar icon and open-or-focus semantics. Removed Owner/anchor/toggle semantics from SettingsWindowService and added ScreenReferenceWindow for centering; settings windows now ShowInTaskbar = true and are truly destroyed on close. Added SettingsWindowPlacementHelper and tests for placement/centering. Main window now respects an AppSettingsSnapshot.ShowInTaskbar flag (new setting exposed in GeneralSettings UI) and slide/visibility animations and "back to Windows" behavior no longer affect the independent settings window. Updated various callers to use OpenIndependentSettingsModule, adjusted window transitions/X offsets, and added/updated spec files documenting the feature and animation boundary.
2026-04-22 20:46:43 +08:00
44 changed files with 2092 additions and 656 deletions

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
- [x] 从桌面、托盘、IPC、组件库进入设置时都会落到同一个设置窗口
- [x] 设置已打开时再次触发设置入口,只会聚焦已有窗口,不会切换成关闭
- [x] 设置窗口始终拥有独立任务栏图标,不受“桌面主窗口在任务栏显示图标”开关影响
- [x] 点击“回到 Windows”后只隐藏或最小化桌面主窗口设置窗口保持可见
- [x] 启用滑入滑出动画后,只有主窗口参与动画,设置窗口不参与
- [x] 点击设置窗口关闭按钮后会真实关闭;再次打开时创建新的居中窗口

View File

@@ -0,0 +1,78 @@
# 独立设置窗口 Spec
## Why
- 当前设置窗口仍然带有桌面壳的 owner / anchor 语义,点击“回到 Windows”或触发桌面动画时容易被一起隐藏或重新定位。
- 产品新增了“在任务栏显示图标”和“启用滑入滑出动画”设置,需要明确边界:它们只影响桌面主窗口,不影响设置窗口。
- 桌面底栏、托盘菜单、IPC、组件库等入口应当始终打开同一个独立设置窗口而不是切换成附属浮窗或开关行为。
## What Changes
- 将设置窗口改为独立顶层窗口,始终使用自己的任务栏按钮和图标。
- `SettingsWindowService.Open` 改为幂等的 open-or-focus重复打开只聚焦已有窗口并在提供目标页时切换到对应页面。
- 移除 `Owner`、锚点定位和 `Toggle` 语义;首次打开按参考屏幕居中,关闭为真实关闭。
- 桌面壳的“回到 Windows”、最小化到托盘/任务栏、滑入滑出动画,只影响 `MainWindow`,不会影响设置窗口。
- 统一桌面、托盘、IPC、组件库等设置入口全部走 `OpenIndependentSettingsModule`
- 设置页文案明确“在任务栏显示图标”只控制桌面主窗口;设置窗口始终保留独立任务栏图标。
## Impact
- Affected code:
- `LanMountainDesktop/Services/Settings/SettingsWindowService.cs`
- `LanMountainDesktop/App.axaml.cs`
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
- `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml`
- Affected behavior:
- 设置窗口生命周期
- 设置入口一致性
- 任务栏图标与桌面壳显示边界
---
## ADDED Requirements
### Requirement: 设置窗口为独立顶层窗口
系统 SHALL 将设置窗口作为独立顶层窗口显示,而不是作为桌面主窗口的附属子窗。
#### Scenario: 设置窗口拥有独立任务栏图标
- **WHEN** 用户打开设置窗口
- **THEN** 设置窗口使用独立顶层窗口方式显示
- **AND THEN** 设置窗口在任务栏中保留自己的独立按钮和图标
- **AND THEN** “在任务栏显示图标”开关不会影响设置窗口的任务栏按钮
### Requirement: 设置入口统一为 open-or-focus
系统 SHALL 让所有设置入口打开或聚焦同一个设置窗口实例。
#### Scenario: 已打开时重复触发设置入口
- **WHEN** 设置窗口已经打开,用户再次从桌面、托盘或 IPC 触发打开设置
- **THEN** 系统只聚焦现有设置窗口
- **AND THEN** 如果请求包含目标页,则导航到目标页
- **AND THEN** 不会把已打开的设置窗口当作开关关闭
### Requirement: 设置窗口不参与桌面壳可见性切换
系统 SHALL 让桌面壳的隐藏、最小化和进出场动画只作用于主窗口。
#### Scenario: 回到 Windows 时设置窗口保持可见
- **WHEN** 主窗口执行“回到 Windows”并隐藏到托盘或最小化到任务栏
- **THEN** 设置窗口保持当前可见状态
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
#### Scenario: 桌面滑入滑出动画不作用于设置窗口
- **WHEN** 启用了滑入滑出动画并触发主窗口退场或入场
- **THEN** 只有主窗口参与动画
- **AND THEN** 设置窗口不会消失,也不会跟随主窗口做进出场动画
### Requirement: 关闭设置窗口时真实销毁实例
系统 SHALL 在用户关闭设置窗口时真实关闭该窗口实例。
#### Scenario: 关闭后再次打开
- **WHEN** 用户点击设置窗口右上角关闭按钮
- **THEN** 当前设置窗口实例被关闭并销毁
- **AND THEN** 下次再次打开设置时创建新的设置窗口实例
- **AND THEN** 新窗口按参考屏幕居中显示

View File

@@ -0,0 +1,25 @@
# Tasks
- [x] Task 1: 简化设置窗口打开契约
- [x]`SettingsWindowOpenRequest` 从 owner / anchor 语义改为目标页 + 参考屏幕语义
- [x] 移除 `ISettingsWindowService.Toggle`
- [x] Task 2: 重做设置窗口服务行为
- [x] 设置窗口始终使用 `Show()` 打开
- [x] 设置窗口始终 `ShowInTaskbar = true`
- [x] 已打开时只聚焦并在需要时切页
- [x] 关闭后销毁实例,下次打开重新创建并居中
- [x] Task 3: 统一设置入口并解耦桌面壳
- [x] 桌面底栏设置按钮改为 open-or-focus
- [x] 组件库入口改为复用 `OpenIndependentSettingsModule`
- [x] 移除 `MainWindow` 上的设置窗口锚点逻辑
- [x] Task 4: 明确产品边界
- [x] 调整“在任务栏显示图标”文案,限定为桌面主窗口
- [x] 新增独立设置窗口 feature spec
- [x] 在窗口过渡动画 spec 中补充“设置窗口不参与动画”
- [x] Task 5: 验证
- [x] 运行 `dotnet build LanMountainDesktop.slnx -c Debug`
- [x] 运行与新 helper 相关的测试

View File

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

View File

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

View File

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

View File

@@ -113,6 +113,15 @@
- **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`320ms`FluttermotionToken.Duration.Intro`400ms - **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`320ms`FluttermotionToken.Duration.Intro`400ms
- **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`DecelerateBezier - **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`DecelerateBezier
### Requirement: 设置窗口不参与桌面壳过渡动画
系统 SHALL 将桌面壳进出场动画限制在主窗口范围内,不影响独立设置窗口。
#### Scenario: 设置窗口在桌面动画期间保持独立
- **WHEN** 主窗口执行滑入、滑出、最小化或恢复动画
- **THEN** 设置窗口不参与该动画
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
## MODIFIED Requirements ## MODIFIED Requirements
### Requirement: OnMinimizeClick 行为 ### Requirement: OnMinimizeClick 行为

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
using Avalonia;
using LanMountainDesktop.Services.Settings;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class SettingsWindowPlacementHelperTests
{
[Fact]
public void ResolveWorkingArea_PrefersReferenceScreen()
{
var referenceArea = new PixelRect(1920, 0, 2560, 1440);
var primaryArea = new PixelRect(0, 0, 1920, 1080);
var result = SettingsWindowPlacementHelper.ResolveWorkingArea(
referenceArea,
primaryArea,
fallbackWindowWidth: 1120,
fallbackWindowHeight: 760);
Assert.Equal(referenceArea, result);
}
[Fact]
public void ResolveWorkingArea_FallsBackToPrimaryScreenWhenReferenceIsMissing()
{
var primaryArea = new PixelRect(0, 0, 1920, 1080);
var result = SettingsWindowPlacementHelper.ResolveWorkingArea(
referenceWorkingArea: null,
primaryWorkingArea: primaryArea,
fallbackWindowWidth: 1120,
fallbackWindowHeight: 760);
Assert.Equal(primaryArea, result);
}
[Fact]
public void CalculateCenteredPosition_ReturnsCenteredPointInsideWorkingArea()
{
var workingArea = new PixelRect(1920, 40, 2560, 1400);
var result = SettingsWindowPlacementHelper.CalculateCenteredPosition(
workingArea,
windowWidth: 1120,
windowHeight: 760);
Assert.Equal(new PixelPoint(2640, 360), result);
}
}

View File

@@ -59,6 +59,9 @@ public partial class App : Application
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService(); private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate(); private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow; private readonly DateTimeOffset _startupAt = DateTimeOffset.UtcNow;
private readonly string _launchSource = LauncherRuntimeMetadata.GetLaunchSource(Environment.GetCommandLineArgs()) ?? "normal";
private readonly RestartPresentationMode? _requestedRestartPresentationMode =
LauncherRuntimeMetadata.GetRestartPresentationMode(Environment.GetCommandLineArgs());
private ISettingsPageRegistry? _settingsPageRegistry; private ISettingsPageRegistry? _settingsPageRegistry;
private ISettingsWindowService? _settingsWindowService; private ISettingsWindowService? _settingsWindowService;
private WeatherLocationRefreshService? _weatherLocationRefreshService; private WeatherLocationRefreshService? _weatherLocationRefreshService;
@@ -67,12 +70,7 @@ public partial class App : Application
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop; private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent; private ShutdownIntent _shutdownIntent;
private TrayIcon? _trayIcon; private DesktopTrayService? _desktopTrayService;
private NativeMenuItem? _trayShowDesktopMenuItem;
private NativeMenuItem? _traySettingsMenuItem;
private NativeMenuItem? _trayComponentLibraryMenuItem;
private NativeMenuItem? _trayRestartMenuItem;
private NativeMenuItem? _trayExitMenuItem;
private PluginRuntimeService? _pluginRuntimeService; private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow; private MainWindow? _mainWindow;
private TransparentOverlayWindow? _transparentOverlayWindow; private TransparentOverlayWindow? _transparentOverlayWindow;
@@ -108,6 +106,15 @@ public partial class App : Application
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle; public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService; internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
internal INotificationService? NotificationService => _notificationService; internal INotificationService? NotificationService => _notificationService;
internal RestartPresentationMode GetCurrentRestartPresentationMode()
{
return _desktopShellState switch
{
DesktopShellState.TrayOnly => RestartPresentationMode.Tray,
DesktopShellState.MinimizedToTaskbar => RestartPresentationMode.Minimized,
_ => RestartPresentationMode.Foreground
};
}
internal void OpenIndependentSettingsModule(string source, string? pageTag = null) internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
{ {
@@ -117,8 +124,8 @@ public partial class App : Application
$"Opening settings window. Source='{source}'; PageTag='{pageTag ?? "<default>"}'."); $"Opening settings window. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
_settingsWindowService?.Open(new SettingsWindowOpenRequest( _settingsWindowService?.Open(new SettingsWindowOpenRequest(
Source: source, Source: source,
Owner: _mainWindow is { IsVisible: true } ? _mainWindow : null, PageId: pageTag,
PageId: pageTag)); ScreenReferenceWindow: _mainWindow is { IsVisible: true } ? _mainWindow : null));
} }
public App() public App()
@@ -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(
@@ -738,7 +707,7 @@ public partial class App : Application
var mainWindow = GetOrCreateMainWindow(desktop, source); var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation(); mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true; mainWindow.ShowInTaskbar = ShouldShowMainWindowInTaskbar();
if (!mainWindow.IsVisible) if (!mainWindow.IsVisible)
{ {
@@ -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()
@@ -1106,7 +1081,7 @@ public partial class App : Application
var mainWindow = new MainWindow var mainWindow = new MainWindow
{ {
DataContext = new MainWindowViewModel(), DataContext = new MainWindowViewModel(),
ShowInTaskbar = true ShowInTaskbar = ShouldShowMainWindowInTaskbar()
}; };
_mainWindowOpened = false; _mainWindowOpened = false;
@@ -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,61 @@ 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()
{
return _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
}
private void SetDesktopShellState(DesktopShellState state, string source) private void SetDesktopShellState(DesktopShellState state, string source)
{ {
if (_desktopShellState == state) if (_desktopShellState == state)
@@ -1359,17 +1440,19 @@ public partial class App : Application
try try
{ {
var version = typeof(App).Assembly.GetName().Version?.ToString() ?? "1.0.0"; var versionInfo = AppVersionProvider.ResolveForCurrentProcess();
_publicIpcHostService = new PublicIpcHostService(); _publicIpcHostService = new PublicIpcHostService();
_publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors; _publicIpcHostService.PluginDescriptorProvider = BuildPublicPluginDescriptors;
_publicIpcHostService.RegisterPublicService<IPublicAppInfoService>( _publicIpcHostService.RegisterPublicService<IPublicAppInfoService>(
new PublicAppInfoService(version, "Administrate", _startupAt)); new PublicAppInfoService(_startupAt));
_publicIpcHostService.RegisterPublicService<IPublicShellControlService>( _publicIpcHostService.RegisterPublicService<IPublicShellControlService>(
new PublicShellControlService()); new PublicShellControlService());
_publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>( _publicIpcHostService.RegisterPublicService<IPublicPluginCatalogService>(
new PublicPluginCatalogService(_publicIpcHostService)); new PublicPluginCatalogService(_publicIpcHostService));
_publicIpcHostService.Start(); _publicIpcHostService.Start();
AppLogger.Info("PublicIpc", $"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'."); AppLogger.Info(
"PublicIpc",
$"Public IPC host started. PipeName='{IpcConstants.DefaultPipeName}'; Version='{versionInfo.Version}'; Codename='{versionInfo.Codename}'.");
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

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

View File

@@ -154,6 +154,8 @@ public sealed class AppSettingsSnapshot
public bool EnableSlideTransition { get; set; } = false; public bool EnableSlideTransition { get; set; } = false;
public bool ShowInTaskbar { get; set; } = false;
public bool EnableFusedDesktop { get; set; } = false; public bool EnableFusedDesktop { get; set; } = false;
public List<string> DisabledPluginIds { get; set; } = []; public List<string> DisabledPluginIds { get; set; } = [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,28 +14,10 @@ using LanMountainDesktop.Views;
namespace LanMountainDesktop.Services.Settings; namespace LanMountainDesktop.Services.Settings;
public enum SettingsWindowAnchorTarget
{
DesktopDockTrailingEdge = 0
}
public enum SettingsWindowFallbackMode
{
None = 0,
ScreenBottomRight = 1
}
public readonly record struct SettingsWindowOpenRequest( public readonly record struct SettingsWindowOpenRequest(
string Source, string Source,
Window? Owner = null,
string? PageId = null, string? PageId = null,
SettingsWindowAnchorTarget AnchorTarget = SettingsWindowAnchorTarget.DesktopDockTrailingEdge, Window? ScreenReferenceWindow = null);
SettingsWindowFallbackMode FallbackMode = SettingsWindowFallbackMode.ScreenBottomRight);
public interface ISettingsWindowAnchorProvider
{
bool TryGetSettingsWindowAnchorBounds(out PixelRect anchorBounds);
}
public interface ISettingsWindowService public interface ISettingsWindowService
{ {
@@ -46,8 +28,6 @@ public interface ISettingsWindowService
void Open(SettingsWindowOpenRequest request); void Open(SettingsWindowOpenRequest request);
void Close(); void Close();
void Toggle(SettingsWindowOpenRequest request);
} }
internal sealed class SettingsWindowService : ISettingsWindowService internal sealed class SettingsWindowService : ISettingsWindowService
@@ -92,27 +72,25 @@ internal sealed class SettingsWindowService : ISettingsWindowService
var appearanceSnapshot = _appearanceThemeService.GetCurrent(); var appearanceSnapshot = _appearanceThemeService.GetCurrent();
_window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome); _window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
ApplyTheme(_window); ApplyTheme(_window);
_window.ReloadPages(request.PageId);
PositionWindow(_window, request); var targetPageId = request.PageId ?? _window.ViewModel.CurrentPageId;
_window.ReloadPages(targetPageId);
if (!_window.IsVisible) if (!_window.IsVisible)
{ {
if (request.Owner is not null && request.Owner.IsVisible) CenterWindow(_window, request);
{ _window.Show();
_window.Show(request.Owner);
}
else
{
_window.Show();
}
NotifyStateChanged(); NotifyStateChanged();
PositionWindowLater(_window, request); CenterWindowLater(_window, request);
return; return;
} }
if (_window.WindowState == WindowState.Minimized)
{
_window.WindowState = WindowState.Normal;
}
_window.Activate(); _window.Activate();
PositionWindowLater(_window, request);
} }
public void Close() public void Close()
@@ -120,17 +98,6 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_window?.Close(); _window?.Close();
} }
public void Toggle(SettingsWindowOpenRequest request)
{
if (IsOpen)
{
Close();
return;
}
Open(request);
}
private SettingsWindow CreateWindow() private SettingsWindow CreateWindow()
{ {
var regionState = _settingsFacade.Region.Get(); var regionState = _settingsFacade.Region.Get();
@@ -147,7 +114,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_hostApplicationLifecycle, _hostApplicationLifecycle,
useSystemChrome); useSystemChrome);
ApplyTheme(window); ApplyTheme(window);
window.ShowInTaskbar = false; window.ShowInTaskbar = true;
window.Closed += (_, _) => window.Closed += (_, _) =>
{ {
_window = null; _window = null;
@@ -156,106 +123,87 @@ internal sealed class SettingsWindowService : ISettingsWindowService
return window; return window;
} }
private void PositionWindowLater(SettingsWindow window, SettingsWindowOpenRequest request) private void CenterWindowLater(SettingsWindow window, SettingsWindowOpenRequest request)
{ {
Dispatcher.UIThread.Post( Dispatcher.UIThread.Post(
() => () =>
{ {
if (!window.IsVisible) if (!ReferenceEquals(_window, window) || !window.IsVisible)
{ {
return; return;
} }
PositionWindow(window, request); CenterWindow(window, request);
}, },
DispatcherPriority.Background); DispatcherPriority.Background);
} }
private static void PositionWindow(SettingsWindow window, SettingsWindowOpenRequest request) private static void CenterWindow(SettingsWindow window, SettingsWindowOpenRequest request)
{ {
if (request.AnchorTarget == SettingsWindowAnchorTarget.DesktopDockTrailingEdge && var referenceWorkingArea =
request.Owner is ISettingsWindowAnchorProvider anchorProvider && request.ScreenReferenceWindow is { IsVisible: true } screenReferenceWindow &&
anchorProvider.TryGetSettingsWindowAnchorBounds(out var anchorBounds)) screenReferenceWindow.Screens?.ScreenFromWindow(screenReferenceWindow) is { } referenceScreen
{ ? referenceScreen.WorkingArea
PositionWindowAboveAnchor(window, anchorBounds, request); : (PixelRect?)null;
return; var width = ResolveWindowWidth(window, request.ScreenReferenceWindow);
} var height = ResolveWindowHeight(window, request.ScreenReferenceWindow);
var workingArea = SettingsWindowPlacementHelper.ResolveWorkingArea(
if (request.FallbackMode == SettingsWindowFallbackMode.ScreenBottomRight) referenceWorkingArea,
{ window.Screens?.Primary?.WorkingArea,
PositionWindowNearScreenBottomRight(window, request); width,
} height);
window.Position = SettingsWindowPlacementHelper.CalculateCenteredPosition(workingArea, width, height);
} }
private static void PositionWindowAboveAnchor(Window window, PixelRect anchorBounds, SettingsWindowOpenRequest request) private static int ResolveWindowWidth(Window window, Window? referenceWindow)
{ {
var workingArea = GetWorkingArea(window, request); var widthDip = ResolveWindowDimensionDip(window.Bounds.Width, window.Width, window.MinWidth, 1120d);
var scale = ResolveWindowScale(window, referenceWindow);
if (anchorBounds.Width <= 0 || anchorBounds.Height <= 0 ||
anchorBounds.Right < workingArea.X || anchorBounds.Y > workingArea.Bottom)
{
PositionWindowNearScreenBottomRight(window, request);
return;
}
var scale = window.RenderScaling > 0 ? window.RenderScaling : 1d;
var width = ResolveWindowWidth(window, scale);
var height = ResolveWindowHeight(window, scale);
var inset = (int)Math.Round(24 * scale);
var gap = (int)Math.Round(16 * scale);
var x = anchorBounds.Right - width - inset;
var y = anchorBounds.Y - height - gap;
x = Math.Clamp(x, workingArea.X + inset, Math.Max(workingArea.X + inset, workingArea.Right - width - inset));
y = Math.Clamp(y, workingArea.Y + inset, Math.Max(workingArea.Y + inset, workingArea.Bottom - height - inset));
window.Position = new PixelPoint(x, y);
}
private static void PositionWindowNearScreenBottomRight(Window window, SettingsWindowOpenRequest request)
{
var workingArea = GetWorkingArea(window, request);
var scale = window.RenderScaling > 0 ? window.RenderScaling : 1d;
var width = ResolveWindowWidth(window, scale);
var height = ResolveWindowHeight(window, scale);
var inset = (int)Math.Round(24 * scale);
var x = Math.Max(workingArea.X + inset, workingArea.Right - width - inset);
var y = Math.Max(workingArea.Y + inset, workingArea.Bottom - height - inset);
window.Position = new PixelPoint(x, y);
}
private static PixelRect GetWorkingArea(Window window, SettingsWindowOpenRequest request)
{
if (request.Owner is not null && request.Owner.Screens?.ScreenFromWindow(request.Owner) is { } ownerScreen)
{
return ownerScreen.WorkingArea;
}
if (window.Screens?.ScreenFromWindow(window) is { } windowScreen)
{
return windowScreen.WorkingArea;
}
return window.Screens?.Primary?.WorkingArea
?? new PixelRect(
0,
0,
Math.Max(1280, ResolveWindowWidth(window, 1d) + 96),
Math.Max(720, ResolveWindowHeight(window, 1d) + 96));
}
private static int ResolveWindowWidth(Window window, double scale)
{
var widthDip = window.Bounds.Width > 1 ? window.Bounds.Width : Math.Max(window.Width, window.MinWidth);
return Math.Max(320, (int)Math.Round(widthDip * scale)); return Math.Max(320, (int)Math.Round(widthDip * scale));
} }
private static int ResolveWindowHeight(Window window, double scale) private static int ResolveWindowHeight(Window window, Window? referenceWindow)
{ {
var heightDip = window.Bounds.Height > 1 ? window.Bounds.Height : Math.Max(window.Height, window.MinHeight); var heightDip = ResolveWindowDimensionDip(window.Bounds.Height, window.Height, window.MinHeight, 760d);
var scale = ResolveWindowScale(window, referenceWindow);
return Math.Max(240, (int)Math.Round(heightDip * scale)); return Math.Max(240, (int)Math.Round(heightDip * scale));
} }
private static double ResolveWindowScale(Window window, Window? referenceWindow)
{
if (referenceWindow is not null && referenceWindow.RenderScaling > 0)
{
return referenceWindow.RenderScaling;
}
if (window.RenderScaling > 0)
{
return window.RenderScaling;
}
return 1d;
}
private static double ResolveWindowDimensionDip(double boundsDip, double configuredDip, double minimumDip, double fallbackDip)
{
if (boundsDip > 1)
{
return boundsDip;
}
if (!double.IsNaN(configuredDip) && configuredDip > 1)
{
return configuredDip;
}
if (!double.IsNaN(minimumDip) && minimumDip > 1)
{
return minimumDip;
}
return fallbackDip;
}
private void NotifyStateChanged() private void NotifyStateChanged()
{ {
StateChanged?.Invoke(this, EventArgs.Empty); StateChanged?.Invoke(this, EventArgs.Empty);
@@ -363,3 +311,38 @@ internal sealed class SettingsWindowService : ISettingsWindowService
}, DispatcherPriority.Background); }, DispatcherPriority.Background);
} }
} }
internal static class SettingsWindowPlacementHelper
{
internal static PixelRect ResolveWorkingArea(
PixelRect? referenceWorkingArea,
PixelRect? primaryWorkingArea,
int fallbackWindowWidth,
int fallbackWindowHeight)
{
if (referenceWorkingArea is { } referenceArea)
{
return referenceArea;
}
if (primaryWorkingArea is { } primaryArea)
{
return primaryArea;
}
return new PixelRect(
0,
0,
Math.Max(1280, fallbackWindowWidth + 96),
Math.Max(720, fallbackWindowHeight + 96));
}
internal static PixelPoint CalculateCenteredPosition(PixelRect workingArea, int windowWidth, int windowHeight)
{
var horizontalOffset = Math.Max(0, (workingArea.Width - windowWidth) / 2);
var verticalOffset = Math.Max(0, (workingArea.Height - windowHeight) / 2);
return new PixelPoint(
workingArea.X + horizontalOffset,
workingArea.Y + verticalOffset);
}
}

View File

@@ -202,6 +202,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase)) string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
?? RenderModes[0]; ?? RenderModes[0];
EnableSlideTransition = appSnapshot.EnableSlideTransition; EnableSlideTransition = appSnapshot.EnableSlideTransition;
ShowInTaskbar = appSnapshot.ShowInTaskbar;
_isInitializing = false; _isInitializing = false;
RefreshPreview(); RefreshPreview();
@@ -238,6 +239,11 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
{ {
EnableSlideTransition = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).EnableSlideTransition; EnableSlideTransition = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).EnableSlideTransition;
} }
if (changedKeys.Contains(nameof(AppSettingsSnapshot.ShowInTaskbar)))
{
ShowInTaskbar = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ShowInTaskbar;
}
} }
public event Action? RestartRequested; public event Action? RestartRequested;
@@ -260,6 +266,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
[ObservableProperty] [ObservableProperty]
private bool _enableSlideTransition; private bool _enableSlideTransition;
[ObservableProperty]
private bool _showInTaskbar;
public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
[ObservableProperty] [ObservableProperty]
@@ -367,6 +376,12 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value); SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value);
} }
partial void OnShowInTaskbarChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.ShowInTaskbar), value);
}
private void SaveField<T>(string key, T value) private void SaveField<T>(string key, T value)
{ {
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App); var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);

View File

@@ -256,18 +256,14 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e) private void OnFindMoreComponentsClick(object? sender, RoutedEventArgs e)
{ {
// 打开设置窗口并导航到插件目录页面 // 打开设置窗口并导航到插件目录页面
if (Application.Current is App app && app.SettingsWindowService is { } settingsWindowService) if (Application.Current is App app)
{ {
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow; app.OpenIndependentSettingsModule("FusedDesktopComponentLibrary", "plugin-catalog");
var request = new SettingsWindowOpenRequest(
Source: "FusedDesktopComponentLibrary",
Owner: mainWindow,
PageId: "plugin-catalog");
settingsWindowService.Open(request);
} }
// 关闭所在窗口 // 关闭所在窗口
var window = this.FindAncestorOfType<Window>(); var window = this.FindAncestorOfType<Window>();
window?.Close(); var componentLibraryWindow = this.FindAncestorOfType<Window>();
componentLibraryWindow?.Close();
} }
} }

View File

@@ -19,7 +19,6 @@ using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Host.Abstractions; using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core; using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Theme; using LanMountainDesktop.Theme;
using LanMountainDesktop.Views.Components; using LanMountainDesktop.Views.Components;
@@ -282,16 +281,7 @@ public partial class MainWindow
CloseComponentLibraryWindow(reopenSettings: false); CloseComponentLibraryWindow(reopenSettings: false);
} }
var app = Application.Current as App; (Application.Current as App)?.OpenIndependentSettingsModule("MainWindowTaskbar");
if (app?.SettingsWindowService is { } settingsWindowService)
{
settingsWindowService.Toggle(new SettingsWindowOpenRequest(
Source: "MainWindowTaskbar",
Owner: this));
return;
}
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
} }
private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e) private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e)
@@ -2861,34 +2851,6 @@ public partial class MainWindow
CloseDetachedComponentLibraryWindow(); CloseDetachedComponentLibraryWindow();
} }
public bool TryGetSettingsWindowAnchorBounds(out PixelRect anchorBounds)
{
anchorBounds = default;
if (!IsVisible || BottomTaskbarContainer is null)
{
return false;
}
var origin = BottomTaskbarContainer.TranslatePoint(new Point(0, 0), this);
if (origin is null)
{
return false;
}
var scale = RenderScaling > 0 ? RenderScaling : 1d;
var width = (int)Math.Round(BottomTaskbarContainer.Bounds.Width * scale);
var height = (int)Math.Round(BottomTaskbarContainer.Bounds.Height * scale);
if (width <= 0 || height <= 0)
{
return false;
}
var x = Position.X + (int)Math.Round(origin.Value.X * scale);
var y = Position.Y + (int)Math.Round(origin.Value.Y * scale);
anchorBounds = new PixelRect(x, y, width, height);
return true;
}
private void CollapseComponentLibraryPanel() private void CollapseComponentLibraryPanel()
{ {
// Animate component library panel collapsing downward // Animate component library panel collapsing downward

View File

@@ -79,6 +79,7 @@ public partial class MainWindow
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) || string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.ShowInTaskbar), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase))) string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase)))
{ {
return; return;
@@ -688,6 +689,10 @@ public partial class MainWindow
StatusBarShadowEnabled = _statusBarShadowEnabled, StatusBarShadowEnabled = _statusBarShadowEnabled,
StatusBarShadowColor = _statusBarShadowColor, StatusBarShadowColor = _statusBarShadowColor,
StatusBarShadowOpacity = _statusBarShadowOpacity, StatusBarShadowOpacity = _statusBarShadowOpacity,
EnableThreeFingerSwipe = existingSnapshot.EnableThreeFingerSwipe,
EnableSlideTransition = existingSnapshot.EnableSlideTransition,
ShowInTaskbar = existingSnapshot.ShowInTaskbar,
EnableFusedDesktop = existingSnapshot.EnableFusedDesktop,
DisabledPluginIds = existingSnapshot.DisabledPluginIds, DisabledPluginIds = existingSnapshot.DisabledPluginIds,
StudyFrameMs = existingSnapshot.StudyFrameMs, StudyFrameMs = existingSnapshot.StudyFrameMs,
StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs, StudyScoreThresholdDbfs = existingSnapshot.StudyScoreThresholdDbfs,

View File

@@ -20,6 +20,7 @@
UseLayoutRounding="True" UseLayoutRounding="True"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
Background="Transparent" Background="Transparent"
TransparencyLevelHint="Transparent"
Title="LanMountainDesktop"> Title="LanMountainDesktop">
<Design.DataContext> <Design.DataContext>
@@ -99,12 +100,17 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch">
<Grid.RenderTransform> <Grid.RenderTransform>
<TranslateTransform /> <TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Intro}" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Grid.RenderTransform> </Grid.RenderTransform>
<Grid.Transitions> <Grid.Transitions>
<Transitions> <Transitions>
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" Easing="0.05,0.75,0.10,1.00" /> <DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" Easing="0.05,0.75,0.10,1.00" />
<DoubleTransition Property="TranslateTransform.X" Duration="{StaticResource FluttermotionToken.Duration.Intro}" Easing="0.05,0.75,0.10,1.00" />
</Transitions> </Transitions>
</Grid.Transitions> </Grid.Transitions>

View File

@@ -29,7 +29,7 @@ using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
public partial class MainWindow : Window, ISettingsWindowAnchorProvider public partial class MainWindow : Window
{ {
private enum WallpaperMediaType private enum WallpaperMediaType
{ {
@@ -450,6 +450,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
MinShortSideCells, MinShortSideCells,
MaxShortSideCells); MaxShortSideCells);
ShowInTaskbar = snapshot.ShowInTaskbar;
_gridSpacingPreset = _gridSettingsService.NormalizeSpacingPreset(snapshot.GridSpacingPreset); _gridSpacingPreset = _gridSettingsService.NormalizeSpacingPreset(snapshot.GridSpacingPreset);
_desktopEdgeInsetPercent = Math.Clamp(snapshot.DesktopEdgeInsetPercent, MinEdgeInsetPercent, MaxEdgeInsetPercent); _desktopEdgeInsetPercent = Math.Clamp(snapshot.DesktopEdgeInsetPercent, MinEdgeInsetPercent, MaxEdgeInsetPercent);
@@ -884,7 +886,19 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
return; return;
} }
WindowState = WindowState.Minimized; var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (snapshot.ShowInTaskbar)
{
WindowState = WindowState.Minimized;
}
else if (Application.Current is App app)
{
app.HideMainWindowToTray(this, "MinimizeAction");
}
else
{
WindowState = WindowState.Minimized;
}
slideTransform.X = 0; slideTransform.X = 0;
DesktopPage.Opacity = 1; DesktopPage.Opacity = 1;
@@ -906,7 +920,12 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
if (useSlide) if (useSlide)
{ {
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : 1920; var screen = Screens.ScreenFromVisual(this);
var scale = screen?.Scaling ?? 1d;
var screenWidthDip = screen is null
? 1920d
: screen.WorkingArea.Width / Math.Max(scale, 0.01d);
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : screenWidthDip;
} }
DesktopPage.Transitions = savedTransitions; DesktopPage.Transitions = savedTransitions;
@@ -941,7 +960,27 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
return; return;
} }
if (WindowState is WindowState.Minimized or WindowState.FullScreen) var newState = (WindowState)e.NewValue!;
var oldState = (WindowState)e.OldValue!;
if (oldState == WindowState.Minimized && newState != WindowState.Minimized)
{
PrepareEnterAnimation();
if (newState != WindowState.FullScreen)
{
WindowState = WindowState.FullScreen;
}
Dispatcher.UIThread.Post(() =>
{
PlayEnterAnimation();
}, DispatcherPriority.Background);
return;
}
if (newState is WindowState.Minimized or WindowState.FullScreen)
{ {
return; return;
} }

View File

@@ -117,6 +117,16 @@
</ui:SettingsExpander.Footer> </ui:SettingsExpander.Footer>
</ui:SettingsExpander> </ui:SettingsExpander>
<ui:SettingsExpander Header="桌面主窗口在任务栏显示图标"
Description="仅控制桌面主窗口在系统任务栏中的图标显示;不会影响设置窗口,设置窗口打开时始终保留独立任务栏图标">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Window" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding ShowInTaskbar}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</UserControl> </UserControl>

View File

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

View File

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