Compare commits

...

9 Commits

Author SHA1 Message Date
lincube
8c88e305ee fix.在线安装器,启动器 2026-06-05 11:08:11 +08:00
lincube
bb4e90ea8d fix.依旧在调整我们的在线安装器 2026-06-03 12:32:56 +08:00
lincube
75c7aece4f fix.在线安装器 2026-06-03 07:30:54 +08:00
lincube
e888b0423a Create installer-build.yml 2026-06-03 01:19:48 +08:00
lincube
28b06031f7 feat.在线安装器,更好的Issue与pull request模板。 2026-06-03 00:50:52 +08:00
lincube
29bd47986c Merge branch 'main' of https://github.com/wwiinnddyy/LanMountainDesktop 2026-06-02 16:31:36 +08:00
lincube
b12c9bf11d fix.元素动画系统导致的调整组件闪现问题 2026-06-02 16:31:29 +08:00
lincube
dd73e02bce 更新 plonds-uploader.yml 2026-06-02 16:24:58 +08:00
lincube
ed66869c8d 更新 plonds-uploader.yml 2026-06-02 15:55:37 +08:00
82 changed files with 5143 additions and 394 deletions

View File

@@ -1,34 +0,0 @@
---
name: Bug Report
about: Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ''
---
## Describe the bug
A clear and concise description of what the bug is.
## Expected behavior
What did you expect to happen?
## Actual behavior
What actually happened?
## Steps to reproduce
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Environment
- OS: [e.g. Windows 10, Windows 11]
- Version: [e.g. 1.0.0]
- .NET Version: [e.g. 10.0]
## Screenshots
If applicable, add screenshots to help explain your problem.
## Additional context
Add any other context about the problem here.

122
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,122 @@
name: Bug 反馈 / Bug report
description: 报告 LanMountainDesktop 宿主、启动器、插件运行时、SDK 或共享契约中的可复现问题。
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢反馈问题。请用一句话写清标题,并尽量为每个独立 Bug 单独创建一个 Issue。
Thank you for reporting a bug. Please use a clear title and open one issue for each independent bug.
> [!IMPORTANT]
> 请不要上传未脱敏的日志、截图或配置。移除 token、密钥、Cookie、账号、学生/班级个人信息、绝对隐私路径等敏感内容。
>
> Do not share unredacted logs, screenshots, or configs. Remove tokens, secrets, cookies, accounts, student/class personal data, and private local paths.
- type: checkboxes
id: checklist
attributes:
label: 提交前检查 / Pre-flight checklist
description: 提交前请确认以下事项。
options:
- label: 我已经搜索过现有 Issues确认没有重复反馈。 / I searched existing issues and found no duplicate.
required: true
- label: 我已经确认该问题属于 LanMountainDesktop 仓库边界,而不是插件市场元数据或官方示例插件实现。 / I confirmed this belongs to LanMountainDesktop, not marketplace metadata or the sample plugin implementation.
required: true
- label: 我已尽量使用最新版本、最新构建或最新提交验证问题仍然存在。 / I reproduced this on the latest release, build, or commit available to me.
required: true
- label: 我已对所有附件和日志做脱敏处理。 / I redacted sensitive information from all attachments and logs.
required: true
- type: dropdown
id: area
attributes:
label: 影响区域 / Affected area
description: 选择最接近的问题区域。
options:
- 桌面宿主 / Desktop host
- 启动器、更新或打包 / Launcher, update, or packaging
- AirApp Runtime
- 插件运行时或安装 / Plugin runtime or installation
- Plugin SDK 或共享契约 / Plugin SDK or shared contracts
- 设置、主题或外观 / Settings, theme, or appearance
- 桌面组件系统 / Desktop component system
- 构建、测试或 CI / Build, test, or CI
- 文档 / Documentation
- 不确定 / Not sure
validations:
required: true
- type: textarea
id: summary
attributes:
label: 问题描述 / Summary
description: 清楚说明发生了什么,以及它为什么是问题。
placeholder: |
例如:打开设置窗口后,点击“外观”页会导致应用崩溃。
Example: Opening the Appearance settings page crashes the app.
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为 / Expected behavior
description: 说明你原本期望发生什么。
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为 / Actual behavior
description: 说明实际发生了什么,包括错误提示、异常表现或回归点。
validations:
required: true
- type: textarea
id: steps
attributes:
label: 复现步骤 / Steps to reproduce
description: 请提供能让维护者复现问题的最小步骤。
placeholder: |
1. 启动应用
2. 打开……
3. 点击……
4. 看到……
1. Launch the app
2. Open ...
3. Click ...
4. See ...
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息 / Environment
description: 请尽量完整填写。可粘贴 `dotnet --info` 中和问题相关的部分。
value: |
- OS / 操作系统:
- LanMountainDesktop version / 应用版本:
- Build channel or commit / 构建渠道或提交:
- .NET SDK / Runtime
- Install mode / 安装方式(源码运行、安装包、便携版等):
- Plugin SDK version if relevant / 如涉及插件SDK 版本:
validations:
required: true
- type: textarea
id: logs
attributes:
label: 日志、堆栈或截图 / Logs, stack traces, or screenshots
description: 请粘贴已脱敏的日志、异常堆栈,或附上截图/录屏。大文件请打包后通过 GitHub 附件上传。
render: shell
- type: textarea
id: extra
attributes:
label: 补充信息 / Additional context
description: 例如是否只在某个插件、主题、显示器缩放、系统语言或更新通道下出现。
- type: checkboxes
id: final
attributes:
label: 最后确认 / Final confirmation
options:
- label: 我确认以上信息足够维护者理解并尝试复现问题。 / I confirm the information above is enough for maintainers to understand and try to reproduce the issue.
required: true

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
blank_issues_enabled: true
contact_links:
- name: 插件市场元数据 / Plugin marketplace metadata
url: https://github.com/wwiinnddyy/LanAirApp/issues/new
about: 插件市场索引、生态材料、开发者门户内容请在 LanAirApp 仓库反馈。 / Report marketplace index, ecosystem materials, and developer portal content in LanAirApp.
- name: 官方示例插件 / Official sample plugin
url: https://github.com/wwiinnddyy/LanMountainDesktop.SamplePlugin/issues/new
about: 示例插件实现、示例包发布和示例插件使用问题请在 SamplePlugin 仓库反馈。 / Report sample plugin implementation, packages, and usage in the SamplePlugin repo.
- name: 贡献指南 / Contribution guide
url: https://github.com/wwiinnddyy/LanMountainDesktop/blob/main/docs/CONTRIBUTING.md
about: 提交 PR 前请阅读贡献、文档和 spec 更新规则。 / Read contribution, documentation, and spec update rules before opening a PR.

View File

@@ -1,36 +0,0 @@
---
name: Config Issue
about: Report configuration or build issues
title: "[CONFIG] "
labels: configuration
assignees: ''
---
## Describe the configuration issue
A clear description of the configuration or build problem.
## Environment Details
- OS: [e.g. Windows 10/11, Linux, macOS]
- .NET SDK Version: [output of `dotnet --version`]
- Visual Studio Version: [if applicable]
- Project Configuration: [e.g., Debug/Release]
## Steps to reproduce
1. ...
2. ...
## Expected result
What should happen?
## Actual result
What actually happens?
## Configuration files
If applicable, share relevant configuration:
- `.csproj` settings (without sensitive data)
- Build parameters
- Environment variables set
## Additional context
Add any other relevant information.

111
.github/ISSUE_TEMPLATE/config_issue.yml vendored Normal file
View File

@@ -0,0 +1,111 @@
name: 配置、构建或打包问题 / Configuration, build, or packaging issue
description: 报告还原、构建、测试、运行、打包、CI 或环境配置相关问题。
title: "[Config] "
labels: ["configuration"]
body:
- type: markdown
attributes:
value: |
这个模板用于环境、构建、测试、运行和打包问题。如果问题是应用运行后的具体功能异常,请优先使用 Bug 反馈。
Use this template for environment, build, test, run, and packaging issues. For runtime feature bugs, prefer the Bug report template.
> [!IMPORTANT]
> 请不要公开 NuGet 源凭据、签名证书、API token、私有路径、机器名、用户名或其他敏感配置。
>
> Do not expose NuGet credentials, signing certificates, API tokens, private paths, machine names, usernames, or other sensitive configuration.
- type: checkboxes
id: checklist
attributes:
label: 提交前检查 / Pre-flight checklist
options:
- label: 我已经阅读过 `docs/DEVELOPMENT.md` 中对应的构建、运行或测试说明。 / I read the relevant build, run, or test instructions in `docs/DEVELOPMENT.md`.
required: true
- label: 我已经运行过 `dotnet restore`,或说明了为什么无法运行。 / I ran `dotnet restore`, or explained why I could not.
required: true
- label: 我已经搜索过现有 Issues确认没有重复问题。 / I searched existing issues and found no duplicate.
required: true
- label: 我已对日志、路径和配置片段做脱敏处理。 / I redacted sensitive data from logs, paths, and config snippets.
required: true
- type: dropdown
id: category
attributes:
label: 问题类型 / Issue type
options:
- dotnet restore
- dotnet build
- dotnet test
- dotnet run
- Launcher 启动或维护命令 / Launcher startup or maintenance command
- 插件包生成 / Plugin package generation
- Windows 安装包或发布产物 / Windows installer or release artifact
- GitHub Actions / CI
- NuGet、SDK 或依赖版本 / NuGet, SDK, or dependency version
- 其他 / Other
validations:
required: true
- type: textarea
id: command
attributes:
label: 执行的命令 / Command executed
description: 请粘贴触发问题的最小命令。
render: shell
placeholder: |
dotnet build LanMountainDesktop.slnx -c Debug
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望结果 / Expected result
description: 你期望命令或流程产生什么结果?
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际结果 / Actual result
description: 实际输出、错误码、失败阶段或 CI 链接。
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息 / Environment
description: 请尽量完整填写。可粘贴 `dotnet --info` 中和问题相关的部分。
value: |
- OS / 操作系统:
- Shell / 终端:
- `dotnet --version`
- `dotnet --info` relevant parts / 相关片段:
- Repository branch or commit / 仓库分支或提交:
- Configuration / 构建配置Debug/Release
- Architecture / 架构x64/arm64 等):
validations:
required: true
- type: textarea
id: logs
attributes:
label: 已脱敏日志 / Redacted logs
description: 请粘贴关键错误日志。长日志建议只贴失败段落,或通过 GitHub 附件上传。
render: shell
validations:
required: true
- type: textarea
id: config
attributes:
label: 相关配置片段 / Relevant config snippets
description: 如 `.csproj`、`Directory.Packages.props`、workflow、环境变量名等。请先脱敏不要粘贴真实密钥。
render: xml
- type: textarea
id: extra
attributes:
label: 补充信息 / Additional context
description: 例如是否只在某个平台、某个 runner、某个 NuGet 源或某个安装路径下出现。
- type: checkboxes
id: final
attributes:
label: 最后确认 / Final confirmation
options:
- label: 我确认以上信息足够维护者定位失败阶段,并且没有包含敏感配置。 / I confirm the information above is enough to identify the failing stage and contains no sensitive configuration.
required: true

View File

@@ -1,25 +0,0 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
assignees: ''
---
## Is your feature request related to a problem?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the solution you'd like
A clear and concise description of what you want to happen.
## Describe alternatives you've considered
A clear and concise description of any alternative solutions or features you've considered.
## Additional context
Add any other context or screenshots about the feature request here.
## Priority
- [ ] Low - Nice to have
- [ ] Medium - Would improve usability
- [ ] High - Essential feature

View File

@@ -0,0 +1,103 @@
name: 功能请求 / Feature request
description: 提出新的能力、体验优化或行为调整建议。
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
感谢提出想法。请尽量描述真实场景和目标用户,而不是只描述一个实现方案。
Thanks for the idea. Please describe the real user scenario and target users, not only a proposed implementation.
> [!IMPORTANT]
> 如果是多项功能,请分别创建 Issue。若需求更适合插件市场、官方示例插件或第三方插件实现请转到对应仓库或讨论区。
>
> Please open separate issues for separate features. If the request belongs to marketplace metadata, sample plugins, or third-party plugins, use the related repository or discussion channel.
- type: checkboxes
id: checklist
attributes:
label: 提交前检查 / Pre-flight checklist
options:
- label: 我已经搜索过现有 Issues 和 `.trae/specs/`,确认没有相同或高度相似的需求。 / I searched existing issues and `.trae/specs/` and found no same or highly similar request.
required: true
- label: "我已经确认该需求属于本仓库边界桌面宿主、插件运行时、Plugin SDK、共享契约、外观或设置基础设施。 / I confirmed this belongs to this repo: desktop host, plugin runtime, Plugin SDK, shared contracts, appearance, or settings infrastructure."
required: true
- label: 我已考虑该能力是否可以由插件实现,并在下方说明。 / I considered whether this can be implemented as a plugin and explain it below.
required: true
- type: dropdown
id: area
attributes:
label: 需求区域 / Request area
options:
- 桌面宿主体验 / Desktop host UX
- 启动器、更新或安装 / Launcher, update, or installation
- AirApp Runtime
- 插件运行时或安装 / Plugin runtime or installation
- Plugin SDK 或共享契约 / Plugin SDK or shared contracts
- 设置、主题或外观 / Settings, theme, or appearance
- 桌面组件系统 / Desktop component system
- 开发、构建或 CI / Development, build, or CI
- 文档 / Documentation
- 不确定 / Not sure
validations:
required: true
- type: textarea
id: problem
attributes:
label: 背景与问题 / Background and problem
description: 你遇到了什么限制、低效或不清楚的地方?谁会受影响?
placeholder: |
例如:插件开发者在调试安装流程时无法判断包签名失败还是复制失败。
Example: Plugin developers cannot tell whether an install failure is caused by package signature validation or file copying.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: 想要的结果 / Desired outcome
description: 描述你希望用户或开发者最终能完成什么。
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 已考虑的替代方案 / Alternatives considered
description: 是否可以通过现有设置、插件、脚本、文档或外部仓库解决?为什么仍需要本仓库改动?
validations:
required: true
- type: textarea
id: scope
attributes:
label: 范围、边界与兼容性 / Scope, boundaries, and compatibility
description: 是否涉及 UI、设置持久化、Plugin SDK、共享契约、迁移、跨平台行为或破坏性变更
placeholder: |
- 是否需要更新 `.trae/specs/<feature>/`
- 是否影响已有插件或用户配置
- 是否仅适用于 Windows/Linux/macOS 某个平台
validations:
required: true
- type: textarea
id: references
attributes:
label: 参考资料、截图或草图 / References, screenshots, or sketches
description: 可附上截图、录屏、草图、相关 PR、文档链接或类似产品参考。
- type: dropdown
id: priority
attributes:
label: 优先级感知 / Priority signal
description: 这不是维护者承诺,仅帮助 triage。
options:
- 低:有帮助但不紧急 / Low: useful but not urgent
- 中:明显改善主要流程 / Medium: improves a main workflow
- 高:阻塞使用或开发 / High: blocks usage or development
validations:
required: true
- type: checkboxes
id: final
attributes:
label: 最后确认 / Final confirmation
options:
- label: 我确认这个请求描述的是一个清晰、可讨论的目标,而不是多个无关需求的集合。 / I confirm this request describes a clear discussable goal, not a bundle of unrelated requests.
required: true

View File

@@ -1,34 +1,92 @@
## Description
Please include a summary of the changes and related context. Describe the "why" behind your changes.
<!--
感谢贡献 LanMountainDesktop。
Thank you for contributing to LanMountainDesktop.
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
请不要在 PR、截图、日志或测试数据中提交 token、密钥、Cookie、真实账号、学生/班级个人信息或其他敏感内容。
Do not include tokens, secrets, cookies, real accounts, student/class personal data, or other sensitive information in this PR, screenshots, logs, or test data.
-->
## Related Issues
Fixes #(issue number)
## 这个 PR 做了什么? / What does this PR do?
## Testing
Please describe the testing you've done to verify the changes:
- [ ] Built successfully
- [ ] Tested on Windows
- [ ] No new warnings or errors introduced
- [ ] Backward compatible
<!--
用 2-5 句话说明改动内容和原因。请说明用户、开发者或维护者能得到什么。
Describe the change and the reason in 2-5 sentences. Mention what users, developers, or maintainers get from it.
-->
## Screenshots/Videos (if applicable)
If your changes include UI modifications, please attach screenshots or videos.
## 相关 Issue / Related issues
## Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have tested my changes thoroughly
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have added tests that prove my fix is effective or that my feature works
<!--
如果可以关闭 Issue请使用
Fixes #123
## Additional context
Add any other context about the PR here.
If this closes an issue, use:
Fixes #123
-->
## 影响范围 / Affected areas
<!-- 勾选所有适用项。Check all that apply. -->
- [ ] 桌面宿主 / Desktop host
- [ ] 启动器、更新或安装 / Launcher, update, or installation
- [ ] AirApp Runtime
- [ ] 插件运行时或安装 / Plugin runtime or installation
- [ ] Plugin SDK 或共享契约 / Plugin SDK or shared contracts
- [ ] 设置、主题或外观 / Settings, theme, or appearance
- [ ] 桌面组件系统 / Desktop component system
- [ ] 构建、测试、CI 或打包 / Build, test, CI, or packaging
- [ ] 文档或规格 / Documentation or specs
## 行为、兼容性与迁移 / Behavior, compatibility, and migration
<!--
说明是否改变用户可见行为、设置持久化、文件格式、公共 API、Plugin SDK、共享契约、打包产物或跨平台行为。
如果没有,请写“无 / None”。
Describe whether this changes user-visible behavior, persisted settings, file formats, public APIs, Plugin SDK, shared contracts, packaged artifacts, or cross-platform behavior.
If not, write "无 / None".
-->
## 验证 / Verification
<!-- 勾选已完成项并在下面补充实际命令、平台和结果。Check completed items and add commands, platforms, and results below. -->
- [ ] `dotnet restore`
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug`
- [ ] `dotnet test LanMountainDesktop.slnx -c Debug`
- [ ] 手动运行桌面宿主 / Manually ran the desktop host
- [ ] 验证插件安装、加载或 SDK 场景 / Verified plugin install, loading, or SDK scenarios
- [ ] 验证 Windows / Verified on Windows
- [ ] 验证 Linux / Verified on Linux
- [ ] 验证 macOS / Verified on macOS
- [ ] 未能运行的检查已说明原因 / Explained any checks that could not be run
实际验证说明 / Verification details:
```text
```
## 文档与 spec / Documentation and specs
<!-- 勾选所有适用项。Check all that apply. -->
- [ ] 本 PR 不需要更新文档或 `.trae/specs/` / No documentation or `.trae/specs/` update is needed
- [ ] 已更新权威文档 / Updated source-of-truth documentation
- [ ] 已新增或更新 `.trae/specs/<feature>/` / Added or updated `.trae/specs/<feature>/`
- [ ] 已更新 SDK 迁移说明或共享契约说明 / Updated SDK migration or shared contract notes
## UI 截图或录屏 / UI screenshots or videos
<!--
涉及 UI、主题、设置页、窗口生命周期或组件外观时请附截图或录屏。
Attach screenshots or videos when changing UI, theme, settings pages, window lifecycle, or component appearance.
-->
## 最终检查 / Final checklist
- [ ] 我已自查代码和文档,移除了调试残留和无关改动。 / I self-reviewed the code and docs and removed debug leftovers and unrelated changes.
- [ ] 我没有提交未脱敏的日志、凭据或个人信息。 / I did not commit unredacted logs, credentials, or personal information.
- [ ] 如果改动涉及 UI已遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`。 / If this changes UI, it follows `docs/VISUAL_SPEC.md` and `docs/CORNER_RADIUS_SPEC.md`.
- [ ] 如果改动涉及行为、流程、边界或命令,已同步对应文档。 / If this changes behavior, workflows, boundaries, or commands, the related docs are updated.
- [ ] 如果改动涉及新功能或行为调整,已补齐或更新 `.trae/specs/`,或说明无需更新的原因。 / If this adds a feature or behavior change, `.trae/specs/` is updated, or the reason for not updating is explained.

51
.github/workflows/installer-build.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: LanDesktopPLONDS Installer Build
on:
push:
tags-ignore:
- '*'
paths:
- '.github/workflows/installer-build.yml'
- 'Directory.Packages.props'
- 'LanDesktopPLONDS.installer/**'
- 'LanMountainDesktop.Shared.Contracts/**'
pull_request:
paths:
- '.github/workflows/installer-build.yml'
- 'Directory.Packages.props'
- 'LanDesktopPLONDS.installer/**'
- 'LanMountainDesktop.Shared.Contracts/**'
workflow_dispatch:
env:
DOTNET_VERSION: '10.0.x'
INSTALLER_PROJECT: LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
DOTNET_gcServer: 1
jobs:
build-installer:
runs-on: windows-latest
name: Build_Installer_${{ matrix.configuration }}
strategy:
fail-fast: false
matrix:
configuration: [Debug, Release]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Restore installer
run: dotnet restore ${{ env.INSTALLER_PROJECT }}
- name: Build installer
run: dotnet build ${{ env.INSTALLER_PROJECT }} --no-restore -c ${{ matrix.configuration }} -v minimal

View File

@@ -22,15 +22,15 @@ env:
PLONDS_S3_PREFIX: lanmountain/update/plonds
PLONDS_S3_PUBLIC_BASE_KEY_PREFIX: lanmountain/update
PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY: '4'
PLONDS_S3_MULTIPART_THRESHOLD_MB: '8'
PLONDS_S3_MULTIPART_PART_SIZE_MB: '5'
PLONDS_S3_MULTIPART_CONCURRENCY: '8'
PLONDS_S3_MULTIPART_THRESHOLD_MB: '10'
PLONDS_S3_MULTIPART_PART_SIZE_MB: '10'
PLONDS_S3_MULTIPART_CONCURRENCY: '4'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 360
permissions:
contents: write
actions: read

View File

@@ -164,3 +164,25 @@
* ~~搜索功能~~根据Windows 11小组件面板设计暂不提供搜索功能
## 2026-06 Fusion Desktop Editing Update
### Requirement: Library window controls edit mode
The fused desktop component library is the edit-mode boundary. Opening the independent Fluent-style library window enters fused desktop edit mode. Closing that window exits edit mode. While edit mode is active, component windows can be moved but their inner component UI is not hit-test interactive. After the library closes, component windows cannot be moved and their normal component UI interaction resumes.
### Requirement: Add button keeps the library open
The selected preview component can only be added through the library add button. Adding a component places it at the center of the library window's current screen and keeps the library open so the user can continue adding and placing components. Components must not be dragged out of the library.
### Requirement: Preview swipe changes the selected component
The right-side preview area maintains a selected component index for the current category. Selecting a category chooses the first component in that category. Vertical touch-style swipes in the preview area switch to the previous or next component in the same category with a 48 DIP threshold and wrap at the ends. Mouse wheel and Up/Down keys may provide equivalent desktop input.
### Requirement: Reuse existing desktop grid settings
Fusion desktop placement must reuse the existing Lan Mountain desktop grid settings exposed by the components settings page: short-side cell count, spacing preset, and desktop edge inset. No independent fused-desktop grid configuration source should be introduced. Adding a component and releasing a dragged component both resolve the current grid through the existing grid settings service.
### Requirement: Snap individual windows to the grid
Fusion desktop no longer displays or depends on a full-screen grid window. Each component window uses the grid only as an individual placement constraint. Dragging remains free while the pointer is moving; on release, the window snaps to the nearest cell that can contain its saved cell span, clamps inside the current screen grid, and persists `X`, `Y`, `GridRow`, `GridColumn`, `GridWidthCells`, and `GridHeightCells`.

View File

@@ -16,10 +16,13 @@ Make the Settings > Update page the single user-facing control surface for the h
- The page displays whether the current payload is an incremental update or reinstall/full installer.
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
- The page follows ClassIsland's durable-status vs working-status split: a transient check/download error must not be treated as an available update, and available/downloaded actions must stay visible while the worker is idle.
## Acceptance
- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel.
- `UpdateSettingsState` persists forced reinstall alongside other update preferences.
- Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply.
- After a successful check with an available update, the download action is visible even though no transfer is running.
- After a failed check, no download action is shown unless a valid update is still pending.
- Build succeeds for `LanMountainDesktop.slnx`.

View File

@@ -0,0 +1,202 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanDesktopPLONDS.Installer.App"
RequestedThemeVariant="Default">
<Application.Resources>
<ResourceDictionary>
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusLg">8</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusComponent">8</CornerRadius>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="InstallerPaneBackgroundBrush" Color="#F9F9F9" />
<SolidColorBrush x:Key="InstallerContentBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSurfaceAltBrush" Color="#F7F7F7" />
<SolidColorBrush x:Key="InstallerSubtleFillBrush" Color="#F5F5F5" />
<SolidColorBrush x:Key="InstallerSubtleFillHoverBrush" Color="#EFEFEF" />
<SolidColorBrush x:Key="InstallerSubtleFillPressedBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#14000000" />
<SolidColorBrush x:Key="InstallerStrongBorderBrush" Color="#29000000" />
<SolidColorBrush x:Key="InstallerTextPrimaryBrush" Color="#1A1A1A" />
<SolidColorBrush x:Key="InstallerTextSecondaryBrush" Color="#5D5D5D" />
<SolidColorBrush x:Key="InstallerTextTertiaryBrush" Color="#6B6B6B" />
<SolidColorBrush x:Key="InstallerDisabledTextBrush" Color="#8A8A8A" />
<SolidColorBrush x:Key="InstallerAccentBrush" Color="#0067C0" />
<SolidColorBrush x:Key="InstallerAccentHoverBrush" Color="#005A9E" />
<SolidColorBrush x:Key="InstallerAccentPressedBrush" Color="#004578" />
<SolidColorBrush x:Key="InstallerOnAccentBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSuccessBrush" Color="#0F7B0F" />
<SolidColorBrush x:Key="InstallerErrorBrush" Color="#B3261E" />
<SolidColorBrush x:Key="InstallerErrorBackgroundBrush" Color="#FFF4F3" />
<SolidColorBrush x:Key="InstallerErrorBorderBrush" Color="#F3B8B3" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#202020" />
<SolidColorBrush x:Key="InstallerPaneBackgroundBrush" Color="#272727" />
<SolidColorBrush x:Key="InstallerContentBackgroundBrush" Color="#1B1B1B" />
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="InstallerSurfaceAltBrush" Color="#252525" />
<SolidColorBrush x:Key="InstallerSubtleFillBrush" Color="#333333" />
<SolidColorBrush x:Key="InstallerSubtleFillHoverBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="InstallerSubtleFillPressedBrush" Color="#444444" />
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#24FFFFFF" />
<SolidColorBrush x:Key="InstallerStrongBorderBrush" Color="#3DFFFFFF" />
<SolidColorBrush x:Key="InstallerTextPrimaryBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="InstallerTextSecondaryBrush" Color="#C7C7C7" />
<SolidColorBrush x:Key="InstallerTextTertiaryBrush" Color="#A0A0A0" />
<SolidColorBrush x:Key="InstallerDisabledTextBrush" Color="#7A7A7A" />
<SolidColorBrush x:Key="InstallerAccentBrush" Color="#60CDFF" />
<SolidColorBrush x:Key="InstallerAccentHoverBrush" Color="#8AD7FF" />
<SolidColorBrush x:Key="InstallerAccentPressedBrush" Color="#4CC2FF" />
<SolidColorBrush x:Key="InstallerOnAccentBrush" Color="#000000" />
<SolidColorBrush x:Key="InstallerSuccessBrush" Color="#6CCB5F" />
<SolidColorBrush x:Key="InstallerErrorBrush" Color="#FFB4AB" />
<SolidColorBrush x:Key="InstallerErrorBackgroundBrush" Color="#442726" />
<SolidColorBrush x:Key="InstallerErrorBorderBrush" Color="#8C4A45" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="UserControl">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style>
<Style Selector="fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="Button.titlebar-icon-button">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="MinWidth" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="Button.titlebar-icon-button:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.titlebar-icon-button:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerStrongBorderBrush}" />
</Style>
<Style Selector="StackPanel.installer-page-container">
<Setter Property="Spacing" Value="20" />
<Setter Property="Margin" Value="0" />
<Setter Property="MaxWidth" Value="780" />
</Style>
<Style Selector="TextBlock.page-title-text">
<Setter Property="FontSize" Value="30" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LineHeight" Value="38" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.page-description-text">
<Setter Property="FontSize" Value="14" />
<Setter Property="LineHeight" Value="21" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock.caption-text">
<Setter Property="FontSize" Value="12" />
<Setter Property="LineHeight" Value="17" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextTertiaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="Button.primary-command">
<Setter Property="Background" Value="{DynamicResource InstallerAccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="18,9" />
<Setter Property="MinHeight" Value="38" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.primary-command:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerAccentHoverBrush}" />
</Style>
<Style Selector="Button.primary-command:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerAccentPressedBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.primary-command fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style>
<Style Selector="Button.primary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.secondary-command">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="16,9" />
<Setter Property="MinHeight" Value="38" />
</Style>
<Style Selector="Button.secondary-command:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
</Style>
<Style Selector="Button.secondary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="Button.secondary-command fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="TextBox">
<Setter Property="MinHeight" Value="38" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerStrongBorderBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="ProgressBar">
<Setter Property="Foreground" Value="{DynamicResource InstallerAccentBrush}" />
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="MinHeight" Value="6" />
</Style>
</Application.Styles>
</Application>

View File

@@ -0,0 +1,35 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LanDesktopPLONDS.Installer.Services;
using LanDesktopPLONDS.Installer.ViewModels;
using LanDesktopPLONDS.Installer.Views;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var privacyIdentity = new PrivacyDeviceIdentityProvider();
var installService = OnlineInstallService.CreateDefault(privacyIdentity);
var consentStore = new InstallerPrivacyConsentStore();
var mainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
};
desktop.MainWindow = mainWindow;
mainWindow.Show();
}
base.OnFrameworkInitializationCompleted();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,34 @@
<Project>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<OptimizationPreference>Size</OptimizationPreference>
<PublishReadyToRun>false</PublishReadyToRun>
</PropertyGroup>
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<TrimmerRootAssembly Include="Avalonia" />
<TrimmerRootAssembly Include="Avalonia.Desktop" />
<TrimmerRootAssembly Include="FluentAvalonia" />
<TrimmerRootAssembly Include="FluentIcons.Avalonia" />
<TrimmerRootAssembly Include="LanDesktopPLONDS.installer" />
<TrimmerRootAssembly Include="System.Text.Json" />
</ItemGroup>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<Import Project="LanDesktopPLONDS.installer.AOT.props" Condition="Exists('LanDesktopPLONDS.installer.AOT.props')" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.0-dev</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
<ApplicationManifest Condition="'$(Configuration)' == 'Debug'">app.Debug.manifest</ApplicationManifest>
<ApplicationManifest Condition="'$(Configuration)' != 'Debug'">app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\logo.ico" />
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace LanDesktopPLONDS.Installer.Models;
public sealed record InstallerDeployProgress(
string Stage,
string? TargetVersion,
double DownloadProgress,
double InstallProgress,
string? CurrentFile,
long BytesDownloaded,
long? TotalBytes);

View File

@@ -0,0 +1,10 @@
namespace LanDesktopPLONDS.Installer.Models;
public enum InstallerStepId
{
Welcome = 0,
InstallLocation = 1,
PrivacyConfirm = 2,
Deploy = 3,
Complete = 4
}

View File

@@ -0,0 +1,9 @@
namespace LanDesktopPLONDS.Installer.Models;
public sealed record InstallerWorkflowState(
InstallerStepId CurrentStep,
InstallerStepId MaxUnlockedStep,
string InstallPath,
bool PrivacyConfirmed,
string? TargetVersion,
string? ErrorMessage);

View File

@@ -0,0 +1,20 @@
using Avalonia;
namespace LanDesktopPLONDS.Installer;
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -0,0 +1,350 @@
using System.Diagnostics;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class FilesPackageInstaller
{
public async Task InstallAsync(
PreparedFilesPackage package,
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
await InstallAsync(package, installPath, OnlineInstallOptions.Default, progress, cancellationToken)
.ConfigureAwait(false);
}
public async Task InstallAsync(
PreparedFilesPackage package,
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(package);
var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath);
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version);
var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version);
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
Directory.CreateDirectory(launcherRoot);
await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report(new InstallerDeployProgress(
"Creating deployment",
package.Version,
1,
0.15,
null,
0,
null));
PrepareTargetDirectory(targetDeployment);
await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report(new InstallerDeployProgress(
"Activating deployment",
package.Version,
1,
0.92,
null,
0,
null));
ActivateInitialDeployment(launcherRoot, targetDeployment);
CreateWindowsShortcutsIfAvailable(launcherRoot, options);
progress?.Report(new InstallerDeployProgress(
"Completed",
package.Version,
1,
1,
null,
0,
null));
}
public static string BuildDeploymentDirectory(string launcherRoot, string version)
{
var sanitized = string.IsNullOrWhiteSpace(version) ? "0.0.0" : version.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(launcherRoot, $"app-{sanitized}-{index}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
public static string ResolveFullPackageAppDirectory(string filesDirectory, string version)
{
var root = Path.GetFullPath(filesDirectory);
if (!Directory.Exists(root))
{
throw new DirectoryNotFoundException($"PLONDS Files package directory is missing: {root}");
}
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var directExecutable = Path.Combine(root, executableName);
if (File.Exists(directExecutable))
{
return root;
}
var versionDirectory = Directory
.EnumerateDirectories(root, $"app-{version}*", SearchOption.TopDirectoryOnly)
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
if (!string.IsNullOrWhiteSpace(versionDirectory))
{
return versionDirectory;
}
var nested = Directory
.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
.FirstOrDefault(path => File.Exists(Path.Combine(path, executableName)));
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
throw new FileNotFoundException($"PLONDS Files package does not contain {executableName}.");
}
private static void PrepareTargetDirectory(string targetDeployment)
{
if (Directory.Exists(targetDeployment))
{
Directory.Delete(targetDeployment, recursive: true);
}
Directory.CreateDirectory(targetDeployment);
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
}
private static async Task CopyDirectoryAsync(
string sourceDirectory,
string targetDirectory,
string version,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var sourceFiles = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories).ToArray();
var total = Math.Max(1, sourceFiles.Length);
for (var index = 0; index < sourceFiles.Length; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = sourceFiles[index];
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(sourceDirectory, sourcePath));
if (IsDeploymentMarker(relativePath))
{
continue;
}
var targetPath = Path.GetFullPath(Path.Combine(targetDirectory, relativePath));
InstallerPathGuard.EnsureChildPath(targetDirectory, targetPath);
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
await using (var source = File.OpenRead(sourcePath))
await using (var target = File.Create(targetPath))
{
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
progress?.Report(new InstallerDeployProgress(
"Copying files",
version,
1,
0.18 + ((index + 1) * 0.70 / total),
relativePath,
index + 1,
total));
}
}
private static async Task CopyLauncherRootPayloadAsync(
string packageRoot,
string sourceAppDirectory,
string launcherRoot,
string version,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var resolvedPackageRoot = Path.GetFullPath(packageRoot);
var resolvedAppDirectory = Path.GetFullPath(sourceAppDirectory);
if (string.Equals(
resolvedPackageRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
resolvedAppDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
{
return;
}
var files = Directory
.EnumerateFiles(resolvedPackageRoot, "*", SearchOption.AllDirectories)
.Where(path => !InstallerPathGuard.IsSameOrChildPath(resolvedAppDirectory, path))
.Where(path =>
{
var relative = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, path));
return !relative.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
})
.ToArray();
var total = Math.Max(1, files.Length);
for (var index = 0; index < files.Length; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var sourcePath = files[index];
var relativePath = InstallerPathGuard.NormalizeRelativePath(Path.GetRelativePath(resolvedPackageRoot, sourcePath));
if (IsDeploymentMarker(relativePath))
{
continue;
}
var targetPath = Path.GetFullPath(Path.Combine(launcherRoot, relativePath));
InstallerPathGuard.EnsureChildPath(launcherRoot, targetPath);
var targetParent = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(targetParent))
{
Directory.CreateDirectory(targetParent);
}
await using (var source = File.OpenRead(sourcePath))
await using (var target = File.Create(targetPath))
{
await source.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
progress?.Report(new InstallerDeployProgress(
"Copying launcher files",
version,
1,
0.10 + ((index + 1) * 0.05 / total),
relativePath,
index + 1,
total));
}
}
private static void ActivateInitialDeployment(string launcherRoot, string targetDeployment)
{
foreach (var existingCurrent in Directory.EnumerateFiles(launcherRoot, ".current", SearchOption.AllDirectories))
{
try
{
File.Delete(existingCurrent);
}
catch
{
}
}
var partialMarker = Path.Combine(targetDeployment, ".partial");
if (File.Exists(partialMarker))
{
File.Delete(partialMarker);
}
File.WriteAllText(Path.Combine(targetDeployment, ".current"), string.Empty);
Directory.CreateDirectory(Path.Combine(launcherRoot, ".Launcher"));
}
private static long EstimateRequiredBytes(string sourceDirectory)
{
return Directory
.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
.Sum(path => new FileInfo(path).Length);
}
private static bool IsDeploymentMarker(string relativePath)
{
var name = Path.GetFileName(relativePath);
return name is ".current" or ".partial" or ".destroy";
}
private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, OnlineInstallOptions options)
{
try
{
if (!OperatingSystem.IsWindows())
{
return;
}
var launcherPath = Path.Combine(launcherRoot, "LanMountainDesktop.Launcher.exe");
if (!File.Exists(launcherPath))
{
var deployedLauncher = Directory
.EnumerateFiles(launcherRoot, "LanMountainDesktop.Launcher.exe", SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(deployedLauncher))
{
File.Copy(deployedLauncher, launcherPath, overwrite: true);
}
}
if (!File.Exists(launcherPath))
{
return;
}
var startMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
if (string.IsNullOrWhiteSpace(startMenu))
{
startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
}
if (string.IsNullOrWhiteSpace(startMenu))
{
return;
}
var programs = Path.Combine(startMenu, "Programs");
Directory.CreateDirectory(programs);
var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url");
WriteUrlShortcut(shortcutPath, launcherPath);
if (options.CreateDesktopShortcut)
{
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (!string.IsNullOrWhiteSpace(desktop))
{
Directory.CreateDirectory(desktop);
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
}
}
if (options.CreateStartupShortcut)
{
var startup = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
if (!string.IsNullOrWhiteSpace(startup))
{
Directory.CreateDirectory(startup);
WriteUrlShortcut(Path.Combine(startup, "LanMountainDesktop.url"), launcherPath);
}
}
}
catch
{
// Shortcut creation is best-effort; deployment itself must remain usable without shell integration.
}
}
private static void WriteUrlShortcut(string shortcutPath, string targetPath)
{
File.WriteAllText(
shortcutPath,
$"[InternetShortcut]{Environment.NewLine}URL=file:///{targetPath.Replace('\\', '/')}{Environment.NewLine}");
}
}

View File

@@ -0,0 +1,29 @@
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
public interface IOnlineInstallService
{
Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken);
Task InstallFreshAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip,
AllowTrailingCommas = true)]
[JsonSerializable(typeof(InstallerPlondsManifest))]
internal sealed partial class InstallerJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,153 @@
namespace LanDesktopPLONDS.Installer.Services;
public static class InstallerPathGuard
{
public const string ApplicationDirectoryName = "LanMountainDesktop";
public static string GetDefaultInstallPath()
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (string.IsNullOrWhiteSpace(programFiles))
{
programFiles = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs");
}
return Path.Combine(programFiles, ApplicationDirectoryName);
}
public static string GetInstallPathForSelectedFolder(string selectedFolder)
{
if (string.IsNullOrWhiteSpace(selectedFolder))
{
throw new ArgumentException("Selected folder is required.", nameof(selectedFolder));
}
var fullPath = Path.GetFullPath(selectedFolder.Trim());
var root = Path.GetPathRoot(fullPath);
var trimmedPath = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var trimmedRoot = root?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var basePath = string.Equals(trimmedPath, trimmedRoot, StringComparison.OrdinalIgnoreCase)
? fullPath
: trimmedPath;
var selectedName = Path.GetFileName(trimmedPath);
var installPath = string.Equals(selectedName, ApplicationDirectoryName, StringComparison.OrdinalIgnoreCase)
? trimmedPath
: Path.Combine(basePath, ApplicationDirectoryName);
return NormalizeInstallPath(installPath);
}
public static string NormalizeInstallPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Installation path is required.", nameof(path));
}
var fullPath = Path.GetFullPath(path.Trim());
ValidateInstallPath(fullPath);
return fullPath;
}
public static void ValidateInstallPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("Installation path is required.");
}
var fullPath = Path.GetFullPath(path);
var root = Path.GetPathRoot(fullPath);
if (string.Equals(
fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
root?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Choose a folder instead of a drive root.");
}
var blockedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Windows",
"System32",
"SysWOW64",
"Program Files",
"Program Files (x86)",
"Users"
};
var name = Path.GetFileName(fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (blockedNames.Contains(name))
{
throw new InvalidOperationException("Choose a dedicated application folder.");
}
}
public static void EnsureUsableInstallPath(string path, long requiredBytes)
{
var fullPath = NormalizeInstallPath(path);
var directory = Directory.Exists(fullPath)
? new DirectoryInfo(fullPath)
: Directory.CreateDirectory(fullPath);
var testPath = Path.Combine(directory.FullName, $".write-test-{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(testPath, string.Empty);
}
finally
{
if (File.Exists(testPath))
{
File.Delete(testPath);
}
}
var drive = new DriveInfo(directory.Root.FullName);
if (drive.AvailableFreeSpace > 0 && drive.AvailableFreeSpace < requiredBytes)
{
throw new InvalidOperationException("The selected drive does not have enough free space.");
}
}
public static void EnsureChildPath(string parent, string child)
{
if (!IsSameOrChildPath(parent, child))
{
throw new InvalidDataException($"Path escapes the expected root: {child}");
}
}
public static bool IsSameOrChildPath(string parent, string child)
{
var resolvedParent = Path.GetFullPath(parent)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var resolvedChild = Path.GetFullPath(child);
return string.Equals(
resolvedParent,
resolvedChild.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| resolvedChild.StartsWith(resolvedParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
public static string NormalizeRelativePath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new InvalidDataException("Package entry path is empty.");
}
var normalized = relativePath
.Replace('\\', Path.DirectorySeparatorChar)
.Replace('/', Path.DirectorySeparatorChar)
.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (Path.IsPathRooted(normalized) || normalized.Split(Path.DirectorySeparatorChar).Contains(".."))
{
throw new InvalidDataException($"Package entry path is invalid: {relativePath}");
}
return normalized;
}
}

View File

@@ -0,0 +1,391 @@
using System.Globalization;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagingRoot)
{
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static IReadOnlyList<InstallerPlondsSource> CreateBuiltInSources()
{
return
[
new("s3", "s3", ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl), 100),
new("github", "github", ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl), 50)
];
}
public async Task<InstallerPlondsCandidate> FindLatestAsync(CancellationToken cancellationToken)
{
var sources = CreateBuiltInSources().ToList();
var candidates = new List<InstallerPlondsCandidate>();
for (var index = 0; index < sources.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var source = sources[index];
InstallerPlondsManifest? manifest;
try
{
manifest = await GetManifestAsync(source, cancellationToken).ConfigureAwait(false);
}
catch
{
continue;
}
if (manifest is null)
{
continue;
}
AddManifestSources(sources, manifest.Sources);
var filesUrl = InstallerPlondsUrlResolver.ResolveFilesZipUrls(manifest, source).FirstOrDefault();
if (filesUrl is null)
{
continue;
}
candidates.Add(new InstallerPlondsCandidate(source, manifest, filesUrl));
}
return candidates
.Where(candidate => TryParseVersion(candidate.Manifest.CurrentVersion, out _))
.OrderByDescending(candidate => ParseVersion(candidate.Manifest.CurrentVersion))
.ThenByDescending(candidate => candidate.Source.Priority)
.FirstOrDefault()
?? throw new InvalidOperationException("No usable PLONDS full package source was found.");
}
public async Task<PreparedFilesPackage> DownloadAndPrepareFullPackageAsync(
InstallerPlondsCandidate candidate,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full");
var urls = new[] { candidate.FilesZipUrl }
.Concat(InstallerPlondsUrlResolver.ResolveFilesZipUrls(candidate.Manifest, candidate.Source))
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
Exception? lastError = null;
foreach (var filesZipUrl in urls)
{
cancellationToken.ThrowIfCancellationRequested();
if (Directory.Exists(packageRoot))
{
Directory.Delete(packageRoot, recursive: true);
}
Directory.CreateDirectory(packageRoot);
var zipPath = Path.Combine(packageRoot, "Files.zip");
var extractDirectory = Path.Combine(packageRoot, "Files");
Directory.CreateDirectory(extractDirectory);
var attempt = candidate with { FilesZipUrl = filesZipUrl };
try
{
await DownloadToFileAsync(attempt, zipPath, progress, cancellationToken).ConfigureAwait(false);
await VerifyPackageAsync(zipPath, attempt.Manifest, filesZipUrl, cancellationToken).ConfigureAwait(false);
ExtractZip(zipPath, extractDirectory);
progress?.Report(new InstallerDeployProgress(
"Files package prepared",
version,
1,
0.10,
"Files.zip",
new FileInfo(zipPath).Length,
new FileInfo(zipPath).Length));
return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastError = ex;
}
}
throw new InvalidOperationException("Failed to download and prepare the PLONDS Files package.", lastError);
}
public static long EstimateInstallBytes(InstallerPlondsManifest manifest)
{
var filesBytes = manifest.FilesMap?.Values.Sum(file => Math.Max(0, file.Size)) ?? 0;
var packageBytes = FindChecksumSizeHint(manifest.Checksums);
return Math.Max(filesBytes, packageBytes);
}
private async Task<InstallerPlondsManifest?> GetManifestAsync(
InstallerPlondsSource source,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(source.ManifestUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync(stream, InstallerJsonContext.Default.InstallerPlondsManifest, cancellationToken)
.ConfigureAwait(false);
}
private async Task DownloadToFileAsync(
InstallerPlondsCandidate candidate,
string destinationPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
using var response = await httpClient.GetAsync(candidate.FilesZipUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength;
var partialPath = $"{destinationPath}.partial";
long downloaded = 0;
try
{
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
await using (var target = File.Create(partialPath))
{
var buffer = new byte[128 * 1024];
while (true)
{
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
break;
}
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
downloaded += read;
var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
progress?.Report(new InstallerDeployProgress(
"Downloading Files.zip",
candidate.Manifest.CurrentVersion,
fraction,
0,
"Files.zip",
downloaded,
totalBytes));
}
}
File.Move(partialPath, destinationPath, overwrite: true);
}
finally
{
if (File.Exists(partialPath))
{
File.Delete(partialPath);
}
}
}
private static async Task VerifyPackageAsync(
string zipPath,
InstallerPlondsManifest manifest,
Uri filesZipUrl,
CancellationToken cancellationToken)
{
var checksum = FindChecksum(manifest.Checksums, GetChecksumKeys(filesZipUrl));
if (checksum is null)
{
throw new InvalidDataException("PLONDS manifest does not declare a checksum for Files.zip.");
}
var (algorithm, expectedHash) = ParseChecksum(checksum);
var actualHash = await ComputeHashAsync(zipPath, algorithm, cancellationToken).ConfigureAwait(false);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(
$"PLONDS Files.zip checksum mismatch. Expected {algorithm}:{expectedHash}, actual {algorithm}:{actualHash}.");
}
}
private static void ExtractZip(string zipPath, string destinationDirectory)
{
if (Directory.Exists(destinationDirectory))
{
Directory.Delete(destinationDirectory, recursive: true);
}
Directory.CreateDirectory(destinationDirectory);
using var archive = ZipFile.OpenRead(zipPath);
foreach (var entry in archive.Entries)
{
var normalizedName = InstallerPathGuard.NormalizeRelativePath(entry.FullName);
var destinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, normalizedName));
InstallerPathGuard.EnsureChildPath(destinationDirectory, destinationPath);
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
var parent = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrWhiteSpace(parent))
{
Directory.CreateDirectory(parent);
}
entry.ExtractToFile(destinationPath, overwrite: true);
}
}
private static void AddManifestSources(List<InstallerPlondsSource> sources, IEnumerable<InstallerPlondsSource>? manifestSources)
{
if (manifestSources is null)
{
return;
}
foreach (var source in manifestSources)
{
if (string.IsNullOrWhiteSpace(source.Id) || string.IsNullOrWhiteSpace(source.ManifestUrl))
{
continue;
}
if (sources.Any(existing => string.Equals(existing.Id, source.Id, StringComparison.OrdinalIgnoreCase) ||
string.Equals(existing.ManifestUrl, source.ManifestUrl, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
sources.Add(source with
{
Id = source.Id.Trim(),
Kind = string.IsNullOrWhiteSpace(source.Kind) ? "http" : source.Kind.Trim(),
ManifestUrl = source.ManifestUrl.Trim()
});
}
}
private static IReadOnlyList<string> GetChecksumKeys(Uri url)
{
var urlFileName = Path.GetFileName(url.LocalPath);
return new[] { "Files.zip", "files.zip", "files-windows-x64.zip", urlFileName }
.Where(key => !string.IsNullOrWhiteSpace(key))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string? FindChecksum(IReadOnlyDictionary<string, string>? checksums, IEnumerable<string> keys)
{
if (checksums is null || checksums.Count == 0)
{
return null;
}
foreach (var key in keys)
{
if (checksums.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
var match = checksums.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(match.Value))
{
return match.Value;
}
}
return null;
}
private static (string Algorithm, string Hash) ParseChecksum(string checksum)
{
var normalized = checksum.Trim();
var separatorIndex = normalized.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex > 0)
{
var algorithm = normalized[..separatorIndex].Trim().ToLowerInvariant();
var hash = NormalizeHash(normalized[(separatorIndex + 1)..]);
if (algorithm is "md5" or "sha256" && hash.Length > 0)
{
return (algorithm, hash);
}
}
var inferred = NormalizeHash(normalized);
return inferred.Length switch
{
32 => ("md5", inferred),
64 => ("sha256", inferred),
_ => throw new InvalidDataException($"Unsupported PLONDS checksum format: {checksum}")
};
}
private static async Task<string> ComputeHashAsync(string filePath, string algorithm, CancellationToken cancellationToken)
{
using HashAlgorithm hasher = algorithm switch
{
"md5" => MD5.Create(),
"sha256" => SHA256.Create(),
_ => throw new InvalidDataException($"Unsupported PLONDS checksum algorithm: {algorithm}")
};
await using var stream = File.OpenRead(filePath);
var hash = await hasher.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static long FindChecksumSizeHint(IReadOnlyDictionary<string, string>? checksums)
{
_ = checksums;
return 0;
}
private static Version ParseVersion(string version)
{
var normalized = version.Trim().TrimStart('v', 'V');
return Version.Parse(normalized);
}
private static bool TryParseVersion(string version, out Version parsed)
{
return Version.TryParse(version.Trim().TrimStart('v', 'V'), out parsed!);
}
private static string NormalizeHash(string value)
{
return value.Trim().Replace(" ", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
}
private static string ResolveManifestUrl(string environmentVariable, string fallback)
{
var value = Environment.GetEnvironmentVariable(environmentVariable);
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
private static string SanitizePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var chars = value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray();
var sanitized = new string(chars).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized;
}
}

View File

@@ -0,0 +1,51 @@
namespace LanDesktopPLONDS.Installer.Services;
internal static class InstallerPlondsUrlResolver
{
public static IReadOnlyList<Uri> ResolveFilesZipUrls(
InstallerPlondsManifest manifest,
InstallerPlondsSource source)
{
var urls = new List<string?>();
var sourceKind = source.Kind.Trim().ToLowerInvariant();
if (sourceKind is "s3")
{
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
}
else if (sourceKind is "github")
{
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
}
urls.Add(DerivePackageUrl(source.ManifestUrl));
urls.Add(manifest.Downloads?.S3?.FilesZipUrl);
urls.Add(manifest.Downloads?.GitHub?.FilesZipUrl);
return urls
.Where(url => !string.IsNullOrWhiteSpace(url))
.Select(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null)
.OfType<Uri>()
.Where(uri => uri.Scheme is "http" or "https")
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string? DerivePackageUrl(string manifestUrl)
{
if (!Uri.TryCreate(manifestUrl, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
return null;
}
var builder = new UriBuilder(uri);
var lastSlash = builder.Path.LastIndexOf('/');
builder.Path = lastSlash >= 0
? $"{builder.Path[..(lastSlash + 1)]}Files.zip"
: "Files.zip";
builder.Query = string.Empty;
builder.Fragment = string.Empty;
return builder.Uri.AbsoluteUri;
}
}

View File

@@ -0,0 +1,118 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services;
public sealed partial class InstallerPrivacyConsentStore
{
private const string ConsentFileName = "privacy-consent.json";
private readonly string _consentPath;
private readonly object _gate = new();
public InstallerPrivacyConsentStore(string? consentPath = null)
{
_consentPath = string.IsNullOrWhiteSpace(consentPath)
? GetDefaultConsentPath()
: Path.GetFullPath(consentPath);
}
public bool HasConfirmed(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return false;
}
lock (_gate)
{
var document = TryLoad();
return document is not null
&& string.Equals(document.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)
&& document.ConfirmedAtUtc <= DateTimeOffset.UtcNow;
}
}
public void SaveConfirmed(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
throw new ArgumentException("Device ID is required.", nameof(deviceId));
}
lock (_gate)
{
Save(new InstallerPrivacyConsentDocument(
SchemaVersion: 1,
DeviceId: deviceId,
ConfirmedAtUtc: DateTimeOffset.UtcNow,
Categories:
[
"anonymousDeviceId",
"systemAndArchitecture",
"targetVersion",
"serverReceivedIpAddress"
]));
}
}
public static string GetDefaultConsentPath()
{
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root))
{
root = AppContext.BaseDirectory;
}
return Path.Combine(root, "LanMountainDesktop", "Installer", ConsentFileName);
}
private InstallerPrivacyConsentDocument? TryLoad()
{
try
{
if (!File.Exists(_consentPath))
{
return null;
}
var json = File.ReadAllText(_consentPath);
return JsonSerializer.Deserialize(
json,
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
}
catch
{
return null;
}
}
private void Save(InstallerPrivacyConsentDocument document)
{
var directory = Path.GetDirectoryName(_consentPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var tempPath = $"{_consentPath}.{Guid.NewGuid():N}.tmp";
var json = JsonSerializer.Serialize(
document,
InstallerPrivacyConsentJsonContext.Default.InstallerPrivacyConsentDocument);
File.WriteAllText(tempPath, json);
File.Move(tempPath, _consentPath, overwrite: true);
}
private sealed record InstallerPrivacyConsentDocument(
int SchemaVersion,
string DeviceId,
DateTimeOffset ConfirmedAtUtc,
IReadOnlyList<string> Categories);
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(InstallerPrivacyConsentDocument))]
private sealed partial class InstallerPrivacyConsentJsonContext : JsonSerializerContext;
}

View File

@@ -0,0 +1,83 @@
using LanDesktopPLONDS.Installer.Models;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.Services;
internal sealed class OnlineInstallService(
InstallerPlondsClient plondsClient,
FilesPackageInstaller packageInstaller,
IPrivacyDeviceIdentityProvider privacyIdentity) : IOnlineInstallService
{
private InstallerPlondsCandidate? _latestCandidate;
public static OnlineInstallService CreateDefault(IPrivacyDeviceIdentityProvider privacyIdentity)
{
var httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(20)
};
var stagingRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Installer",
"PLONDS");
return new OnlineInstallService(
new InstallerPlondsClient(httpClient, stagingRoot),
new FilesPackageInstaller(),
privacyIdentity);
}
public async Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
{
var candidate = await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
_latestCandidate = candidate;
return new OnlineInstallPackageInfo(
candidate.Manifest.CurrentVersion,
candidate.Source.Id,
candidate.FilesZipUrl,
InstallerPlondsClient.EstimateInstallBytes(candidate.Manifest));
}
public async Task InstallFreshAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
await InstallFreshAsync(installPath, OnlineInstallOptions.Default, progress, cancellationToken)
.ConfigureAwait(false);
}
public async Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = privacyIdentity.GetOrCreateDeviceId();
var candidate = _latestCandidate ?? await plondsClient.FindLatestAsync(cancellationToken).ConfigureAwait(false);
var package = await plondsClient.DownloadAndPrepareFullPackageAsync(candidate, progress, cancellationToken).ConfigureAwait(false);
await packageInstaller.InstallAsync(package, installPath, options, progress, cancellationToken).ConfigureAwait(false);
}
public Task RepairAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = installPath;
_ = progress;
_ = cancellationToken;
throw new NotSupportedException("Repair is reserved for a later installer version.");
}
public Task UpdateIncrementalAsync(
string installPath,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
_ = installPath;
_ = progress;
_ = cancellationToken;
throw new NotSupportedException("Incremental update is reserved for a later installer version.");
}
}

View File

@@ -0,0 +1,83 @@
namespace LanDesktopPLONDS.Installer.Services;
internal sealed record InstallerPlondsSource(
string Id,
string Kind,
string ManifestUrl,
int Priority = 0);
internal sealed record InstallerPlondsManifest(
string FormatVersion,
string CurrentVersion,
string PreviousVersion,
bool IsFullUpdate,
bool RequiresCleanInstall,
string Channel,
string Platform,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, InstallerPlondsFileEntry> FilesMap,
IReadOnlyDictionary<string, InstallerPlondsChangedFileEntry> ChangedFilesMap,
IReadOnlyDictionary<string, string> Checksums,
InstallerPlondsDownloads? Downloads,
IReadOnlyList<InstallerPlondsSource>? Sources);
internal sealed record InstallerPlondsFileEntry(
string Action,
string Hash,
long Size,
string HashAlgorithm = "sha256");
internal sealed record InstallerPlondsChangedFileEntry(
string ArchivePath,
string Hash,
long Size,
string HashAlgorithm = "sha256");
internal sealed record InstallerPlondsDownloads(
InstallerPlondsGitHubDownloads? GitHub,
InstallerPlondsS3Downloads? S3);
internal sealed record InstallerPlondsGitHubDownloads(
string? ReleaseUrl,
string? ManifestUrl,
string? ChangedZipUrl,
string? FilesZipUrl);
internal sealed record InstallerPlondsS3Downloads(
string? Bucket,
string? Prefix,
string? ManifestKey,
string? ManifestUrl,
string? ChangedZipKey,
string? ChangedZipUrl,
string? ChangedFolderKey,
string? ChangedFolderUrl,
string? FilesZipKey,
string? FilesZipUrl,
string? FilesFolderKey,
string? FilesFolderUrl);
public sealed record OnlineInstallPackageInfo(
string Version,
string SourceId,
Uri FilesZipUrl,
long EstimatedBytes);
public sealed record OnlineInstallOptions(bool CreateDesktopShortcut, bool CreateStartupShortcut)
{
public static OnlineInstallOptions Default { get; } = new(
CreateDesktopShortcut: false,
CreateStartupShortcut: false);
}
internal sealed record InstallerPlondsCandidate(
InstallerPlondsSource Source,
InstallerPlondsManifest Manifest,
Uri FilesZipUrl);
internal sealed record PreparedFilesPackage(
string Version,
string SourceId,
string ZipPath,
string ExtractDirectory,
InstallerPlondsManifest Manifest);

View File

@@ -0,0 +1,23 @@
using CommunityToolkit.Mvvm.ComponentModel;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class InstallerStepViewModel(
InstallerStepId stepId,
string title,
Icon icon) : ObservableObject
{
[ObservableProperty]
private bool _isUnlocked;
[ObservableProperty]
private bool _isSelected;
public InstallerStepId StepId { get; } = stepId;
public string Title { get; } = title;
public Icon Icon { get; } = icon;
}

View File

@@ -0,0 +1,372 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models;
using LanDesktopPLONDS.Installer.Services;
using LanMountainDesktop.Shared.Contracts.Privacy;
namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class MainWindowViewModel : ObservableObject
{
private readonly IOnlineInstallService _installService;
private readonly IPrivacyDeviceIdentityProvider _privacyIdentity;
private readonly InstallerPrivacyConsentStore _privacyConsentStore;
private CancellationTokenSource? _installCts;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private InstallerStepId _currentStep = InstallerStepId.Welcome;
[ObservableProperty]
private InstallerStepId _maxUnlockedStep = InstallerStepId.Welcome;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private string _installPath = InstallerPathGuard.GetDefaultInstallPath();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
private bool _privacyConfirmed;
[ObservableProperty]
private string? _targetVersion;
[ObservableProperty]
private string? _sourceId;
[ObservableProperty]
private string? _errorMessage;
[ObservableProperty]
private string _statusText = "准备开始安装";
[ObservableProperty]
private double _downloadProgress;
[ObservableProperty]
private double _installProgress;
[ObservableProperty]
private string? _currentFile;
[ObservableProperty]
private string _downloadBytesText = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
private bool _isInstalling;
[ObservableProperty]
private bool _createDesktopShortcut;
[ObservableProperty]
private bool _createStartupShortcut;
public MainWindowViewModel(
IOnlineInstallService installService,
IPrivacyDeviceIdentityProvider privacyIdentity,
InstallerPrivacyConsentStore? privacyConsentStore = null)
{
_installService = installService;
_privacyIdentity = privacyIdentity;
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
Steps =
[
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", Icon.Play),
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", Icon.Folder),
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", Icon.Info),
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", Icon.ArrowDownload),
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", Icon.Circle)
];
SyncSteps();
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();
PrivacyConfirmed = _privacyConsentStore.HasConfirmed(DeviceIdPreview);
}
public ObservableCollection<InstallerStepViewModel> Steps { get; }
public Func<string, Task<string?>>? BrowseRequested { get; set; }
public string WindowTitle => "LanDesktopPLONDS Installer";
public string DeviceIdPreview { get; }
public bool IsWelcomeStep => CurrentStep == InstallerStepId.Welcome;
public bool IsLocationStep => CurrentStep == InstallerStepId.InstallLocation;
public bool IsPrivacyStep => CurrentStep == InstallerStepId.PrivacyConfirm;
public bool IsDeployStep => CurrentStep == InstallerStepId.Deploy;
public bool IsCompleteStep => CurrentStep == InstallerStepId.Complete;
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling;
public bool CanGoNext => CurrentStep switch
{
InstallerStepId.Welcome => !IsInstalling,
InstallerStepId.InstallLocation => !string.IsNullOrWhiteSpace(InstallPath) && !IsInstalling,
InstallerStepId.PrivacyConfirm => PrivacyConfirmed && !IsInstalling,
_ => false
};
public bool CanStartInstall => CurrentStep == InstallerStepId.Deploy &&
PrivacyConfirmed &&
!string.IsNullOrWhiteSpace(InstallPath) &&
!IsInstalling;
public InstallerWorkflowState Snapshot => new(
CurrentStep,
MaxUnlockedStep,
InstallPath,
PrivacyConfirmed,
TargetVersion,
ErrorMessage);
partial void OnCurrentStepChanged(InstallerStepId value)
{
OnPropertyChanged(nameof(IsWelcomeStep));
OnPropertyChanged(nameof(IsLocationStep));
OnPropertyChanged(nameof(IsPrivacyStep));
OnPropertyChanged(nameof(IsDeployStep));
OnPropertyChanged(nameof(IsCompleteStep));
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoNext));
OnPropertyChanged(nameof(CanStartInstall));
SyncSteps();
}
partial void OnErrorMessageChanged(string? value)
{
_ = value;
OnPropertyChanged(nameof(HasError));
}
partial void OnMaxUnlockedStepChanged(InstallerStepId value)
{
_ = value;
SyncSteps();
}
partial void OnIsInstallingChanged(bool value)
{
_ = value;
OnPropertyChanged(nameof(CanGoBack));
OnPropertyChanged(nameof(CanGoNext));
OnPropertyChanged(nameof(CanStartInstall));
}
[RelayCommand(CanExecute = nameof(CanGoNext))]
private async Task NextAsync()
{
ErrorMessage = null;
if (CurrentStep == InstallerStepId.InstallLocation)
{
try
{
InstallerPathGuard.ValidateInstallPath(InstallPath);
var info = await _installService.CheckLatestAsync(CancellationToken.None);
TargetVersion = info.Version;
SourceId = info.SourceId;
StatusText = $"准备安装 {info.Version}";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
return;
}
}
else if (CurrentStep == InstallerStepId.PrivacyConfirm)
{
_privacyConsentStore.SaveConfirmed(DeviceIdPreview);
}
UnlockAndNavigate(CurrentStep + 1);
}
[RelayCommand(CanExecute = nameof(CanGoBack))]
private void Back()
{
if (IsInstalling)
{
return;
}
if (CurrentStep > InstallerStepId.Welcome)
{
CurrentStep -= 1;
}
}
[RelayCommand]
private void SelectStep(InstallerStepViewModel? step)
{
if (step is null || IsInstalling || step.StepId > MaxUnlockedStep)
{
return;
}
CurrentStep = step.StepId;
}
[RelayCommand]
private async Task BrowseAsync()
{
ErrorMessage = null;
if (BrowseRequested is null)
{
return;
}
try
{
var selected = await BrowseRequested(InstallPath);
if (!string.IsNullOrWhiteSpace(selected))
{
InstallPath = InstallerPathGuard.GetInstallPathForSelectedFolder(selected);
}
}
catch (Exception ex)
{
ErrorMessage = $"选择安装位置失败:{ex.Message}";
}
}
[RelayCommand(CanExecute = nameof(CanStartInstall))]
private async Task StartInstallAsync()
{
ErrorMessage = null;
IsInstalling = true;
StartInstallCommand.NotifyCanExecuteChanged();
_installCts?.Dispose();
_installCts = new CancellationTokenSource();
try
{
var progress = new Progress<InstallerDeployProgress>(ApplyProgress);
var options = new OnlineInstallOptions(CreateDesktopShortcut, CreateStartupShortcut);
await _installService.InstallFreshAsync(InstallPath, options, progress, _installCts.Token);
UnlockAndNavigate(InstallerStepId.Complete);
StatusText = "安装完成";
}
catch (OperationCanceledException)
{
ErrorMessage = "安装已取消。";
StatusText = "安装已取消";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
StatusText = "安装失败";
}
finally
{
IsInstalling = false;
StartInstallCommand.NotifyCanExecuteChanged();
}
}
[RelayCommand]
private void CancelInstall()
{
_installCts?.Cancel();
}
[RelayCommand]
private void Launch()
{
LaunchCore();
}
private void LaunchCore()
{
var launcher = Path.Combine(InstallPath, OperatingSystem.IsWindows()
? "LanMountainDesktop.Launcher.exe"
: "LanMountainDesktop.Launcher");
if (!File.Exists(launcher))
{
ErrorMessage = "未找到 LanMountainDesktop.Launcher。";
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = launcher,
Arguments = "--launch-source postinstall",
WorkingDirectory = InstallPath,
UseShellExecute = true
});
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
}
}
private void UnlockAndNavigate(InstallerStepId step)
{
if (step > MaxUnlockedStep)
{
MaxUnlockedStep = step;
}
CurrentStep = step;
}
private void ApplyProgress(InstallerDeployProgress progress)
{
StatusText = progress.Stage;
TargetVersion = progress.TargetVersion ?? TargetVersion;
DownloadProgress = progress.DownloadProgress;
InstallProgress = progress.InstallProgress;
CurrentFile = progress.CurrentFile;
DownloadBytesText = FormatBytes(progress.BytesDownloaded, progress.TotalBytes);
}
private void SyncSteps()
{
foreach (var step in Steps)
{
step.IsUnlocked = step.StepId <= MaxUnlockedStep;
step.IsSelected = step.StepId == CurrentStep;
}
}
private static string FormatBytes(long downloaded, long? total)
{
if (downloaded <= 0 && total is not > 0)
{
return string.Empty;
}
var downloadedText = ToSize(downloaded);
return total is > 0 ? $"{downloadedText} / {ToSize(total.Value)}" : downloadedText;
}
private static string ToSize(long value)
{
string[] suffixes = ["B", "KB", "MB", "GB"];
var size = (double)value;
var suffix = 0;
while (size >= 1024 && suffix < suffixes.Length - 1)
{
size /= 1024;
suffix++;
}
return $"{size:0.##} {suffixes[suffix]}";
}
}

View File

@@ -0,0 +1,536 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Width="1040"
Height="680"
MinWidth="900"
MinHeight="620"
CanResize="True"
x:Name="Root"
Title="{Binding WindowTitle}"
Background="Transparent"
TransparencyLevelHint="Mica, AcrylicBlur, None"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="48"
WindowDecorations="None">
<Window.Styles>
<Style Selector="Grid.step-page">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="20" />
</Style>
<Style Selector="Button.step-nav-item">
<Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
</ControlTemplate>
</Setter>
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0,0,0,3" />
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="40" />
</Style>
<Style Selector="Button.step-nav-item:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.step-nav-item:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillPressedBrush}" />
</Style>
<Style Selector="Button.step-nav-item:disabled">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="Button.step-nav-item:disabled TextBlock.step-title">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.step-nav-item:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Border.step-nav-selected-fill">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
</Style>
<Style Selector="TextBlock.step-title">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
</Style>
<Style Selector="Border.info-panel">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceAltBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="12" />
</Style>
<Style Selector="Border.content-card">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
<Setter Property="Padding" Value="20" />
</Style>
<Style Selector="Border.error-bar">
<Setter Property="Background" Value="{DynamicResource InstallerErrorBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerErrorBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="12" />
</Style>
<Style Selector="TextBlock.meta-label">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextTertiaryBrush}" />
</Style>
<Style Selector="TextBlock.meta-value">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="Border.separator">
<Setter Property="Height" Value="1" />
<Setter Property="Background" Value="{DynamicResource InstallerBorderBrush}" />
</Style>
</Window.Styles>
<Border Background="{DynamicResource InstallerWindowBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusXl}"
ClipToBounds="True">
<Grid x:Name="RootGrid"
RowDefinitions="48,*"
Background="Transparent">
<Border Grid.Row="0"
Background="{DynamicResource InstallerWindowBackgroundBrush}"
PointerPressed="OnTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Orientation="Horizontal"
Margin="16,0,0,0"
Spacing="10"
VerticalAlignment="Center">
<Border Width="28"
Height="28"
Background="{DynamicResource InstallerAccentBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}">
<fi:FluentIcon Icon="ArrowDownload"
IconVariant="Regular"
Foreground="{DynamicResource InstallerOnAccentBrush}"
FontSize="16" />
</Border>
<TextBlock Text="{Binding WindowTitle}"
FontSize="13"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="2"
Margin="0,0,8,0"
VerticalAlignment="Center">
<Button Classes="titlebar-icon-button"
ToolTip.Tip="最小化"
Click="OnMinimizeClick">
<fi:FluentIcon Icon="Subtract"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button Classes="titlebar-icon-button"
ToolTip.Tip="关闭"
Click="OnCloseClick">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular"
FontSize="14" />
</Button>
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1"
ColumnDefinitions="260,10,*"
Margin="10,0,10,10">
<Border Grid.Column="0"
Background="{DynamicResource InstallerPaneBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="22,24">
<Grid RowDefinitions="Auto,*,Auto">
<StackPanel Spacing="8">
<TextBlock Text="阑山桌面"
FontSize="22"
FontWeight="SemiBold" />
<TextBlock Text="在线安装程序"
Classes="caption-text" />
</StackPanel>
<ItemsControl Grid.Row="1"
Margin="0,28,0,0"
ItemsSource="{Binding Steps}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:InstallerStepViewModel">
<Button Classes="step-nav-item"
Command="{Binding #Root.DataContext.SelectStepCommand}"
CommandParameter="{Binding}"
IsEnabled="{Binding IsUnlocked}">
<Grid MinHeight="40">
<Border Classes="step-nav-selected-fill"
IsVisible="{Binding IsSelected}" />
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10"
Margin="10,0">
<Grid Width="18"
VerticalAlignment="Center">
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Regular"
Foreground="{DynamicResource InstallerTextSecondaryBrush}"
FontSize="17"
IsVisible="{Binding !IsSelected}" />
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Filled"
Foreground="{DynamicResource InstallerTextPrimaryBrush}"
FontSize="17"
IsVisible="{Binding IsSelected}" />
</Grid>
<Grid Grid.Column="1"
VerticalAlignment="Center">
<TextBlock Classes="step-title"
Text="{Binding Title}"
IsVisible="{Binding !IsSelected}" />
<TextBlock Classes="step-title"
Text="{Binding Title}"
Foreground="{DynamicResource InstallerTextPrimaryBrush}"
IsVisible="{Binding IsSelected}" />
</Grid>
</Grid>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Grid.Row="2"
Classes="caption-text"
Text="安装期间请保持网络连接。下载失败时可返回上一步重新检查。" />
</Grid>
</Border>
<Border Grid.Column="2"
Background="{DynamicResource InstallerContentBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"
Background="Transparent">
<ScrollViewer Grid.Row="0"
Padding="36,34,42,24"
VerticalScrollBarVisibility="Auto">
<Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsWelcomeStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="安装阑山桌面" />
<TextBlock Classes="page-description-text"
Text="在线安装程序会获取最新完整包,并把应用部署到本机版本目录。" />
</StackPanel>
<Border Classes="content-card">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14">
<Border Width="40"
Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CloudArrowDown"
IconVariant="Regular"
FontSize="20" />
</Border>
<StackPanel Grid.Column="1"
Spacing="6">
<TextBlock Text="准备开始"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="安装器会检查最新版本、下载完整包、校验文件并激活部署。"
Classes="muted" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsLocationStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="选择安装位置" />
<TextBlock Classes="page-description-text"
Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装方式保持一致。" />
</StackPanel>
<Border Classes="content-card">
<StackPanel Spacing="16">
<StackPanel Spacing="6">
<TextBlock Text="安装目录"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="安装根目录下会创建 .Launcher 和 app-{version}-0。"
Classes="muted" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBox Text="{Binding InstallPath, Mode=TwoWay}"
PlaceholderText="安装路径" />
<Button Grid.Column="1"
Classes="secondary-command"
Command="{Binding BrowseCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="FolderOpen"
IconVariant="Regular" />
<TextBlock Text="浏览" />
</StackPanel>
</Button>
</Grid>
<CheckBox IsChecked="{Binding CreateDesktopShortcut}"
Content="创建桌面快捷方式" />
<CheckBox IsChecked="{Binding CreateStartupShortcut}"
Content="开机时自动启动阑山桌面" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsPrivacyStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="确认数据使用" />
<TextBlock Classes="page-description-text"
Text="安装阶段需要使用匿名设备码和基础请求信息,用于安装、风控和用户量统计。" />
</StackPanel>
<Border Classes="content-card">
<StackPanel Spacing="16">
<StackPanel Spacing="6">
<TextBlock Text="匿名设备码"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="{Binding DeviceIdPreview}"
TextWrapping="Wrap"
FontFamily="Consolas"
Foreground="{DynamicResource InstallerTextSecondaryBrush}" />
</StackPanel>
<Border Classes="info-panel">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<fi:FluentIcon Icon="Shield"
IconVariant="Regular"
Foreground="{DynamicResource InstallerAccentBrush}"
FontSize="18" />
<TextBlock Grid.Column="1"
Text="安装器会发送匿名设备码、系统与架构信息、目标版本和请求 IP不会上传用户名、机器名或安装目录。"
Classes="muted" />
</Grid>
</Border>
<CheckBox IsChecked="{Binding PrivacyConfirmed}"
Content="我确认上述匿名数据可用于安装、风控和用户量统计。" />
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsDeployStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="开始部署" />
<TextBlock Classes="page-description-text"
Text="安装时会下载完整包,并写入当前版本目录。" />
</StackPanel>
<Border Classes="content-card">
<StackPanel Spacing="18">
<Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="18"
RowSpacing="10">
<TextBlock Classes="meta-label"
Text="版本" />
<TextBlock Grid.Column="1"
Classes="meta-value"
Text="{Binding TargetVersion}" />
<Border Grid.Row="1"
Grid.ColumnSpan="2"
Classes="separator" />
<TextBlock Grid.Row="2"
Classes="meta-label"
Text="来源" />
<TextBlock Grid.Row="2"
Grid.Column="1"
Classes="meta-value"
Text="{Binding SourceId}" />
</Grid>
<StackPanel Spacing="8">
<TextBlock Text="{Binding StatusText}"
FontWeight="SemiBold" />
<ProgressBar Minimum="0"
Maximum="1"
Value="{Binding DownloadProgress}" />
<TextBlock Classes="caption-text"
Text="{Binding DownloadBytesText}" />
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="安装进度"
FontWeight="SemiBold" />
<ProgressBar Minimum="0"
Maximum="1"
Value="{Binding InstallProgress}" />
<TextBlock Classes="caption-text"
Text="{Binding CurrentFile}" />
</StackPanel>
<StackPanel Orientation="Horizontal"
Spacing="8">
<Button Classes="primary-command"
Command="{Binding StartInstallCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="ArrowDownload"
IconVariant="Regular" />
<TextBlock Text="开始安装" />
</StackPanel>
</Button>
<Button Classes="secondary-command"
Command="{Binding CancelInstallCommand}"
IsEnabled="{Binding IsInstalling}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="Dismiss"
IconVariant="Regular" />
<TextBlock Text="取消" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
<Grid Classes="step-page"
IsVisible="{Binding IsCompleteStep}">
<StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text"
Text="完成安装" />
<TextBlock Classes="page-description-text"
Text="阑山桌面已经部署完成。" />
</StackPanel>
<Border Classes="content-card">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14">
<Border Width="40"
Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CheckmarkCircle"
IconVariant="Regular"
Foreground="{DynamicResource InstallerSuccessBrush}"
FontSize="22" />
</Border>
<StackPanel Grid.Column="1"
Spacing="12">
<StackPanel Spacing="5">
<TextBlock Text="可以启动应用"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="使用 Launcher 进入首次启动流程。"
Classes="muted" />
</StackPanel>
<Button Classes="primary-command"
HorizontalAlignment="Left"
Command="{Binding LaunchCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="Play"
IconVariant="Regular" />
<TextBlock Text="打开阑山桌面" />
</StackPanel>
</Button>
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Grid>
</Grid>
</ScrollViewer>
<Border Grid.Row="1"
Background="Transparent"
Padding="36,16,42,18">
<Grid ColumnDefinitions="*,Auto,Auto"
ColumnSpacing="8">
<Border Classes="error-bar"
IsVisible="{Binding HasError}">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<fi:FluentIcon Icon="ErrorCircle"
IconVariant="Regular"
Foreground="{DynamicResource InstallerErrorBrush}"
FontSize="18" />
<TextBlock Grid.Column="1"
Text="{Binding ErrorMessage}"
Foreground="{DynamicResource InstallerErrorBrush}"
TextWrapping="Wrap"
VerticalAlignment="Center" />
</Grid>
</Border>
<Button Grid.Column="1"
Classes="secondary-command"
Command="{Binding BackCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<fi:FluentIcon Icon="ArrowLeft"
IconVariant="Regular" />
<TextBlock Text="上一步" />
</StackPanel>
</Button>
<Button Grid.Column="2"
Classes="primary-command"
Command="{Binding NextCommand}">
<StackPanel Orientation="Horizontal"
Spacing="6">
<TextBlock Text="下一步" />
<fi:FluentIcon Icon="ArrowRight"
IconVariant="Regular" />
</StackPanel>
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,81 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LanDesktopPLONDS.Installer.ViewModels;
namespace LanDesktopPLONDS.Installer.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is MainWindowViewModel viewModel)
{
viewModel.BrowseRequested = BrowseForFolderAsync;
}
}
private async Task<string?> BrowseForFolderAsync(string currentPath)
{
IStorageFolder? startFolder = null;
if (Directory.Exists(currentPath))
{
startFolder = await StorageProvider.TryGetFolderFromPathAsync(currentPath);
}
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "选择安装位置",
AllowMultiple = false,
SuggestedStartLocation = startFolder
});
if (result.Count == 0)
{
return null;
}
var path = result[0].TryGetLocalPath();
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("请选择本机文件夹作为安装位置。");
}
return path;
}
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (e.Source is Button)
{
return;
}
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
BeginMoveDrag(e);
}
}
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
WindowState = WindowState.Minimized;
}
private void OnCloseClick(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
Close();
}
}

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanDesktopPLONDS.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanDesktopPLONDS.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -132,6 +132,27 @@ internal sealed class DataLocationResolver
return ResolveDataRoot(config);
}
public string ResolveDataRoot(DataLocationMode mode, string? customPath = null)
{
return ResolveDataRoot(BuildConfig(mode, customPath));
}
public DataLocationConfig BuildConfig(DataLocationMode mode, string? customPath = null)
{
var targetDataRoot = mode == DataLocationMode.Portable
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath;
return new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
}
private string ResolveDataRoot(DataLocationConfig? config)
{
if (config is null)
@@ -193,18 +214,8 @@ internal sealed class DataLocationResolver
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{
var targetDataRoot = mode == DataLocationMode.Portable
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath;
var config = new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
var config = BuildConfig(mode, customPath);
var targetDataRoot = ResolveDataRoot(config);
// 先创建目录结构
try

View File

@@ -57,6 +57,23 @@ internal sealed class OobeCompletionResult
public string ErrorMessage { get; init; } = string.Empty;
}
internal sealed class OobeSessionDraft
{
public DataLocationMode DataLocationMode { get; init; } = DataLocationMode.System;
public bool MigrateExistingData { get; init; }
public HostAppSettingsStartupChoices StartupChoices { get; init; }
public PrivacyConfig PrivacyConfig { get; init; } = new();
public bool PrivacyAgreementAccepted { get; init; }
public string PrivacyUserId { get; init; } = string.Empty;
public string PrivacyDeviceId { get; init; } = string.Empty;
}
internal sealed record LauncherExecutionSnapshot(
bool IsElevated,
string UserName,

View File

@@ -1,67 +0,0 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed class DataLocationOobeStep : IOobeStep
{
private readonly DataLocationResolver _resolver;
public DataLocationOobeStep(DataLocationResolver resolver)
{
_resolver = resolver;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var existingConfig = _resolver.LoadConfig();
if (existingConfig is not null)
{
Logger.Info("DataLocation OOBE step skipped: config already exists.");
return;
}
DataLocationPromptWindow? window = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
window = new DataLocationPromptWindow(_resolver);
window.Show();
});
if (window is null)
{
Logger.Warn("DataLocation OOBE step failed: window could not be created.");
return;
}
try
{
var result = await window.WaitForChoiceAsync().ConfigureAwait(false);
if (result is null)
{
Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location.");
_resolver.ApplyLocationChoice(DataLocationMode.System, null, false);
}
else
{
var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData);
Logger.Info(
$"DataLocation OOBE step: user selected '{result.SelectedMode}'. " +
$"Migrate={result.MigrateExistingData}; Success={success}.");
}
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (window.IsVisible)
{
window.Close();
}
});
}
}
}

View File

@@ -1,6 +1,15 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed record OobeStepResult(bool ContinueLaunch, LauncherResult? Result = null)
{
public static OobeStepResult Continue { get; } = new(true);
public static OobeStepResult Complete(LauncherResult result) => new(false, result);
}
internal interface IOobeStep
{
Task RunAsync(CancellationToken cancellationToken);
Task<OobeStepResult> RunAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed class OobeSessionCommitService
{
private readonly DataLocationResolver _dataLocationResolver;
private readonly OobeStateService _oobeStateService;
private readonly CommandContext _context;
private readonly Func<bool, bool>? _setWindowsStartup;
public OobeSessionCommitService(
DataLocationResolver dataLocationResolver,
OobeStateService oobeStateService,
CommandContext context,
Func<bool, bool>? setWindowsStartup = null)
{
_dataLocationResolver = dataLocationResolver;
_oobeStateService = oobeStateService;
_context = context;
_setWindowsStartup = setWindowsStartup;
}
public OobeCompletionResult Commit(OobeSessionDraft draft)
{
ArgumentNullException.ThrowIfNull(draft);
if (!_dataLocationResolver.ApplyLocationChoice(
draft.DataLocationMode,
customPath: null,
draft.MigrateExistingData))
{
return Failure("data_location_save_failed", "Failed to save the selected data location.");
}
var dataRoot = _dataLocationResolver.ResolveDataRoot();
try
{
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataRoot);
HostAppSettingsOobeMerger.MergeStartupPresentation(settingsPath, draft.StartupChoices);
}
catch (Exception ex)
{
return Failure("startup_settings_save_failed", ex.Message);
}
var setWindowsStartup = _setWindowsStartup ?? new LauncherWindowsStartupService().SetEnabled;
if (OperatingSystem.IsWindows() &&
!setWindowsStartup(draft.StartupChoices.AutoStartWithWindows))
{
return Failure("windows_startup_save_failed", "Failed to save Windows startup preference.");
}
try
{
var launcherDataPath = _dataLocationResolver.ResolveLauncherDataPath();
Directory.CreateDirectory(launcherDataPath);
var privacyConfigPath = Path.Combine(launcherDataPath, "privacy-config.json");
var privacyJson = JsonSerializer.Serialize(draft.PrivacyConfig, AppJsonContext.Default.PrivacyConfig);
File.WriteAllText(privacyConfigPath, privacyJson);
var agreementService = new PrivacyAgreementService(launcherDataPath);
if (!agreementService.SaveAgreement(
draft.PrivacyAgreementAccepted,
draft.PrivacyUserId,
draft.PrivacyDeviceId))
{
return Failure("privacy_agreement_save_failed", "Failed to save privacy agreement state.");
}
}
catch (Exception ex)
{
return Failure("privacy_settings_save_failed", ex.Message);
}
var completion = _oobeStateService.MarkCompleted(_context, dataRoot);
return completion.Success
? completion
: Failure(completion.ResultCode, completion.ErrorMessage);
}
private static OobeCompletionResult Failure(string code, string message) =>
new()
{
Success = false,
ResultCode = code,
ErrorMessage = message
};
}

View File

@@ -7,10 +7,12 @@ internal sealed class OobeStateService
{
private const int CurrentSchemaVersion = 1;
private readonly string _appRoot;
private readonly string? _stateRootOverride;
private readonly string _stateDirectory;
private readonly string _statePath;
private readonly string _legacyStatePath;
private readonly string _legacyMarkerPath;
private readonly IReadOnlyList<string> _legacyStatePaths;
private readonly IReadOnlyList<string> _legacyMarkerPaths;
private readonly LauncherExecutionSnapshot _executionSnapshot;
public OobeStateService(
@@ -18,21 +20,17 @@ internal sealed class OobeStateService
string? stateRootOverride = null,
LauncherExecutionSnapshot? executionSnapshot = null)
{
_ = Path.GetFullPath(appRoot);
_appRoot = Path.GetFullPath(appRoot);
_stateRootOverride = string.IsNullOrWhiteSpace(stateRootOverride)
? null
: Path.GetFullPath(stateRootOverride);
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
? ResolveStateRoot(appRoot)
: Path.GetFullPath(stateRootOverride);
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
var stateRoot = ResolveCurrentStateRoot();
(_stateDirectory, _statePath) = BuildStatePaths(stateRoot);
var legacyRoot = string.IsNullOrWhiteSpace(stateRootOverride)
? Path.GetFullPath(appRoot)
: Path.GetFullPath(stateRootOverride);
var legacyStateDirectory = Path.Combine(legacyRoot, ".launcher", "state");
_legacyStatePath = Path.Combine(legacyStateDirectory, "oobe-state.json");
_legacyMarkerPath = Path.Combine(legacyStateDirectory, "first_run_completed");
_legacyStatePaths = BuildLegacyPaths("oobe-state.json");
_legacyMarkerPaths = BuildLegacyPaths("first_run_completed");
}
public OobeLaunchDecision Evaluate(CommandContext context)
@@ -47,10 +45,17 @@ internal sealed class OobeStateService
}
public OobeCompletionResult MarkCompleted(CommandContext context)
{
return MarkCompleted(context, null);
}
public OobeCompletionResult MarkCompleted(CommandContext context, string? stateRoot)
{
try
{
Directory.CreateDirectory(_stateDirectory);
var (stateDirectory, statePath) = BuildStatePaths(
string.IsNullOrWhiteSpace(stateRoot) ? ResolveCurrentStateRoot() : Path.GetFullPath(stateRoot));
Directory.CreateDirectory(stateDirectory);
var payload = new OobeStateFile
{
SchemaVersion = CurrentSchemaVersion,
@@ -60,14 +65,14 @@ internal sealed class OobeStateService
LaunchSource = context.LaunchSource
};
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
var tempPath = Path.Combine(stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
File.WriteAllText(tempPath, json);
File.Move(tempPath, _statePath, overwrite: true);
File.Move(tempPath, statePath, overwrite: true);
TryDeleteLegacyMarker();
Logger.Info(
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{statePath}'; " +
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
return new OobeCompletionResult
@@ -110,20 +115,27 @@ internal sealed class OobeStateService
return EvaluateStateFile(context, _statePath, migratedLegacyState: false);
}
if (File.Exists(_legacyStatePath))
foreach (var legacyStatePath in _legacyStatePaths)
{
return EvaluateStateFile(context, _legacyStatePath, migratedLegacyState: false);
if (File.Exists(legacyStatePath))
{
var decision = EvaluateStateFile(context, legacyStatePath, migratedLegacyState: true);
if (decision.Status == OobeStateStatus.Completed)
{
_ = MarkCompleted(context);
}
return decision;
}
}
if (File.Exists(_legacyMarkerPath))
foreach (var legacyMarkerPath in _legacyMarkerPaths)
{
migratedLegacyMarker = TryMigrateLegacyMarker(context);
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
}
if (_executionSnapshot.IsElevated)
{
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
if (File.Exists(legacyMarkerPath))
{
migratedLegacyMarker = TryMigrateLegacyMarker(context);
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
}
}
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
@@ -159,15 +171,18 @@ internal sealed class OobeStateService
private void TryDeleteLegacyMarker()
{
try
foreach (var legacyMarkerPath in _legacyMarkerPaths)
{
if (File.Exists(_legacyMarkerPath))
try
{
if (File.Exists(legacyMarkerPath))
{
File.Delete(legacyMarkerPath);
}
}
catch
{
File.Delete(_legacyMarkerPath);
}
}
catch
{
}
}
@@ -225,6 +240,44 @@ internal sealed class OobeStateService
};
}
private string ResolveCurrentStateRoot()
{
return _stateRootOverride ?? ResolveStateRoot(_appRoot);
}
private static (string StateDirectory, string StatePath) BuildStatePaths(string stateRoot)
{
var stateDirectory = Path.Combine(Path.GetFullPath(stateRoot), "Launcher", "state");
return (stateDirectory, Path.Combine(stateDirectory, "oobe-state.json"));
}
private IReadOnlyList<string> BuildLegacyPaths(string fileName)
{
var roots = new List<string>();
if (_stateRootOverride is not null)
{
roots.Add(_stateRootOverride);
}
else
{
roots.Add(ResolveDefaultSystemStateRoot());
roots.Add(_appRoot);
try
{
roots.Add(ResolveCurrentStateRoot());
}
catch
{
}
}
return roots
.Where(root => !string.IsNullOrWhiteSpace(root))
.Select(root => Path.Combine(Path.GetFullPath(root), ".launcher", "state", fileName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string ResolveStateRoot(string appRoot)
{
try
@@ -243,4 +296,15 @@ internal sealed class OobeStateService
return Path.Combine(appData, "LanMountainDesktop");
}
}
private static string ResolveDefaultSystemStateRoot()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(appData))
{
return string.Empty;
}
return Path.Combine(appData, "LanMountainDesktop");
}
}

View File

@@ -7,14 +7,19 @@ internal sealed class WelcomeOobeStep : IOobeStep
{
private readonly CommandContext _context;
private readonly OobeStateService _oobeStateService;
private readonly DataLocationResolver _dataLocationResolver;
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
public WelcomeOobeStep(
OobeStateService oobeStateService,
CommandContext context,
DataLocationResolver dataLocationResolver)
{
_oobeStateService = oobeStateService;
_context = context;
_dataLocationResolver = dataLocationResolver;
}
public async Task RunAsync(CancellationToken cancellationToken)
public async Task<OobeStepResult> RunAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -27,16 +32,32 @@ internal sealed class WelcomeOobeStep : IOobeStep
if (window is null)
{
return;
return BuildCancelledResult("OOBE window could not be created.");
}
await window.WaitForEnterAsync().ConfigureAwait(false);
var completion = _oobeStateService.MarkCompleted(_context);
var draft = await window.WaitForCompletionAsync().ConfigureAwait(false);
if (draft is null)
{
Logger.Info("OOBE was cancelled before completion; Host launch will be skipped.");
return BuildCancelledResult("OOBE was cancelled before completion.");
}
var completion = new OobeSessionCommitService(
_dataLocationResolver,
_oobeStateService,
_context)
.Commit(draft);
if (!completion.Success)
{
Logger.Warn(
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " +
$"OOBE session was not persisted. ResultCode='{completion.ResultCode}'; " +
$"Error='{completion.ErrorMessage}'.");
return OobeStepResult.Complete(LaunchResultBuilder.Build(
false,
"oobe",
completion.ResultCode,
"OOBE settings could not be saved.",
errorMessage: completion.ErrorMessage));
}
await Dispatcher.UIThread.InvokeAsync(() =>
@@ -46,5 +67,16 @@ internal sealed class WelcomeOobeStep : IOobeStep
window.Close();
}
});
return OobeStepResult.Continue;
}
private static OobeStepResult BuildCancelledResult(string message)
{
return OobeStepResult.Complete(LaunchResultBuilder.Build(
false,
"oobe",
"oobe_cancelled",
message));
}
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
@@ -24,11 +25,21 @@ internal sealed class AirAppRuntimeBridge
return;
}
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
_appRoot,
Environment.ProcessId,
0,
_dataRoot));
Process? process;
try
{
process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
_appRoot,
Environment.ProcessId,
0,
_dataRoot));
}
catch (Exception ex)
{
Logger.Warn($"AirApp Runtime start request failed. AppRoot='{_appRoot}'; Error='{ex.Message}'.");
return;
}
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)

View File

@@ -116,7 +116,7 @@ internal static class PreviewEntryHandler
{
try
{
await window.WaitForEnterAsync().ConfigureAwait(false);
await window.WaitForCompletionAsync().ConfigureAwait(false);
}
catch (Exception ex)
{

View File

@@ -24,8 +24,6 @@ internal static class LauncherGuiCoordinator
var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource,
@@ -123,6 +121,7 @@ internal static class LauncherGuiCoordinator
if (result.Success)
{
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
}

View File

@@ -35,8 +35,7 @@ internal sealed class LauncherOrchestrator
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
_oobeSteps =
[
new WelcomeOobeStep(_oobeStateService, _context),
new DataLocationOobeStep(_dataLocationResolver)
new WelcomeOobeStep(_oobeStateService, _context, _dataLocationResolver)
];
_pipeline = pipeline ?? new LaunchPipeline(
[

View File

@@ -108,6 +108,8 @@ internal sealed class HostLaunchService
return HostLaunchOutcome.FromResult(prerequisiteFailure);
}
await EnsureAirAppRuntimeStartedAsync(context.DeploymentLocator.GetAppRoot(), dataRoot).ConfigureAwait(false);
var hostPath = plan.HostPath;
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
@@ -204,6 +206,18 @@ internal sealed class HostLaunchService
details));
}
private static async Task EnsureAirAppRuntimeStartedAsync(string appRoot, string? dataRoot)
{
try
{
await new AirAppRuntimeBridge(appRoot, dataRoot).EnsureStartedAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"AirApp Runtime pre-start failed; Host fallback remains available. Error='{ex.Message}'.");
}
}
private static async Task<HostStartAttempt> StartHostProcessAsync(
HostLaunchPlan plan,
HostStartMode startMode,

View File

@@ -13,7 +13,18 @@ internal sealed class OobeGatePhase : ILaunchPhase
await LaunchUiPresenter.HideSplashAsync(context.SplashWindow).ConfigureAwait(false);
foreach (var step in context.OobeSteps)
{
await step.RunAsync(cancellationToken).ConfigureAwait(false);
var stepResult = await step.RunAsync(cancellationToken).ConfigureAwait(false);
if (!stepResult.ContinueLaunch)
{
context.WindowsClosingByOrchestrator = true;
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
stepResult.Result ?? LaunchResultBuilder.BuildFailure(
"oobe",
"oobe_cancelled",
"OOBE did not complete."));
}
}
await LaunchUiPresenter.ShowSplashAsync(context.SplashWindow).ConfigureAwait(false);

View File

@@ -126,7 +126,7 @@ public partial class DevDebugWindow : Window
try
{
// 等待用户点击开始按钮
await oobeWindow.WaitForEnterAsync();
await oobeWindow.WaitForCompletionAsync();
// 用户点击后窗口会自动关闭通过OobeWindow内部的动画和关闭逻辑
Console.WriteLine("[DevDebugWindow] OOBE completed by user");

View File

@@ -17,10 +17,11 @@ public partial class OobeWindow : Window
private const int AnimationDurationMs = 300;
private const int TypingDelayMs = 100;
private readonly TaskCompletionSource<bool> _completionSource = new();
private readonly TaskCompletionSource<OobeSessionDraft?> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly DataLocationResolver _resolver;
private bool _isTransitioning;
private bool _isDebugMode;
private bool _isCompleting;
private int _currentStep = 1;
// 数据位置选择
@@ -40,6 +41,7 @@ public partial class OobeWindow : Window
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
Closed += OnWindowClosed;
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
_resolver = new DataLocationResolver(appRoot);
@@ -51,7 +53,7 @@ public partial class OobeWindow : Window
_isDebugMode = isDebugMode;
}
public Task WaitForEnterAsync() => _completionSource.Task;
internal Task<OobeSessionDraft?> WaitForCompletionAsync() => _completionSource.Task;
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
@@ -261,6 +263,14 @@ public partial class OobeWindow : Window
await PlayTypingAnimationAsync();
}
private void OnWindowClosed(object? sender, EventArgs e)
{
if (!_isCompleting)
{
_completionSource.TrySetResult(null);
}
}
private async Task PlayTypingAnimationAsync()
{
var typingTextBlock = this.FindControl<TextBlock>("TypingTextBlock");
@@ -477,11 +487,6 @@ public partial class OobeWindow : Window
if (_isTransitioning) return;
// 应用数据位置选择
if (!_isDebugMode)
{
_resolver.ApplyLocationChoice(_selectedDataLocationMode, null, _migrateExistingData);
}
await NavigateToStep(4);
}
@@ -495,7 +500,6 @@ public partial class OobeWindow : Window
private async void OnStartupPresentationNextClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
SaveOobeStartupPresentation();
await NavigateToStep(5);
}
@@ -521,7 +525,7 @@ public partial class OobeWindow : Window
private void RefreshOobeStartupPresentationFromDisk()
{
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(_resolver.ResolveDataRoot());
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(ResolveSelectedDataRoot());
var defaults = HostAppSettingsOobeMerger.LoadStartupDefaults(path);
if (this.FindControl<Border>("OobeSlideTransitionSection") is { } slideSection)
@@ -675,8 +679,6 @@ public partial class OobeWindow : Window
if (_isTransitioning) return;
// 保存隐私设置
SavePrivacySettings();
await NavigateToStep(6);
}
@@ -725,13 +727,15 @@ public partial class OobeWindow : Window
try
{
await PlayExitAnimationAsync();
_completionSource.TrySetResult(true);
_isCompleting = true;
_completionSource.TrySetResult(BuildSessionDraft());
Close();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error: {ex.Message}");
_completionSource.TrySetResult(true);
_isCompleting = true;
_completionSource.TrySetResult(BuildSessionDraft());
Close();
}
}
@@ -978,6 +982,43 @@ public partial class OobeWindow : Window
}
}
private OobeSessionDraft BuildSessionDraft()
{
var privacy = BuildPrivacyConfig();
return new OobeSessionDraft
{
DataLocationMode = _selectedDataLocationMode,
MigrateExistingData = _migrateExistingData,
StartupChoices = CollectOobeStartupChoices(),
PrivacyConfig = privacy,
PrivacyAgreementAccepted = this.FindControl<CheckBox>("PrivacyAgreementCheckBox")?.IsChecked ?? false,
PrivacyUserId = privacy.TelemetryId,
PrivacyDeviceId = GetDeviceIdentifier()
};
}
private PrivacyConfig BuildPrivacyConfig()
{
return new PrivacyConfig
{
CrashTelemetryEnabled = this.FindControl<ToggleSwitch>("CrashTelemetryToggle")?.IsChecked ?? true,
UsageTelemetryEnabled = this.FindControl<ToggleSwitch>("UsageTelemetryToggle")?.IsChecked ?? true,
TelemetryId = this.FindControl<TextBox>("TelemetryIdTextBox")?.Text ?? Guid.NewGuid().ToString("N")
};
}
private string ResolveSelectedDataRoot()
{
try
{
return _resolver.ResolveDataRoot(_selectedDataLocationMode);
}
catch
{
return _resolver.DefaultSystemDataPath;
}
}
private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - t, 3);
private static double EaseOutQuad(double t) => 1 - Math.Pow(1 - t, 2);
private static double EaseOutBack(double t)

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Shared.Contracts.Privacy;
public interface IPrivacyDeviceIdentityProvider
{
string GetOrCreateDeviceId();
}

View File

@@ -0,0 +1,105 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LanMountainDesktop.Shared.Contracts.Privacy;
public sealed partial class PrivacyDeviceIdentityProvider : IPrivacyDeviceIdentityProvider
{
public const string DefaultIdentityFileName = "privacy-device.identity.json";
private readonly string _identityPath;
private readonly object _gate = new();
public PrivacyDeviceIdentityProvider(string? identityPath = null)
{
_identityPath = string.IsNullOrWhiteSpace(identityPath)
? GetDefaultIdentityPath()
: Path.GetFullPath(identityPath);
}
public string GetOrCreateDeviceId()
{
lock (_gate)
{
var existing = TryLoad();
if (!string.IsNullOrWhiteSpace(existing?.DeviceId))
{
return existing.DeviceId;
}
var created = new PrivacyDeviceIdentityDocument(
SchemaVersion: 1,
DeviceId: GenerateDeviceId(),
CreatedAtUtc: DateTimeOffset.UtcNow);
Save(created);
return created.DeviceId;
}
}
public static string GetDefaultIdentityPath()
{
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(root))
{
root = AppContext.BaseDirectory;
}
return Path.Combine(root, "LanMountainDesktop", DefaultIdentityFileName);
}
private PrivacyDeviceIdentityDocument? TryLoad()
{
try
{
if (!File.Exists(_identityPath))
{
return null;
}
var json = File.ReadAllText(_identityPath);
return JsonSerializer.Deserialize(
json,
PrivacyDeviceIdentityJsonContext.Default.PrivacyDeviceIdentityDocument);
}
catch
{
return null;
}
}
private void Save(PrivacyDeviceIdentityDocument document)
{
var directory = Path.GetDirectoryName(_identityPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var tempPath = $"{_identityPath}.{Guid.NewGuid():N}.tmp";
var json = JsonSerializer.Serialize(
document,
PrivacyDeviceIdentityJsonContext.Default.PrivacyDeviceIdentityDocument);
File.WriteAllText(tempPath, json);
File.Move(tempPath, _identityPath, overwrite: true);
}
private static string GenerateDeviceId()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private sealed record PrivacyDeviceIdentityDocument(
int SchemaVersion,
string DeviceId,
DateTimeOffset CreatedAtUtc);
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(PrivacyDeviceIdentityDocument))]
private sealed partial class PrivacyDeviceIdentityJsonContext : JsonSerializerContext;
}

View File

@@ -35,4 +35,37 @@ public sealed class DesktopEditOverlayPresenterTests
Assert.Equal(180, ghost.Width);
Assert.Equal(120, ghost.Height);
}
[Fact]
public void CandidateRectUsesCanvasPlacement()
{
var presenter = new DesktopEditOverlayPresenter(new CompositionVisualAnimationService(_ => null));
var root = Assert.IsType<Canvas>(presenter.Root);
presenter.SetCandidateRect(new Rect(44, 58, 240, 160));
var candidate = root.Children.OfType<Border>().Single(child => child is not DesktopEditGhostView);
Assert.Equal(44, Canvas.GetLeft(candidate));
Assert.Equal(58, Canvas.GetTop(candidate));
Assert.Equal(240, candidate.Width);
Assert.Equal(160, candidate.Height);
}
[Fact]
public void ShowPreservesPreviewAndCandidateCanvasPlacement()
{
var presenter = new DesktopEditOverlayPresenter(new CompositionVisualAnimationService(_ => null));
var root = Assert.IsType<Canvas>(presenter.Root);
presenter.SetPreviewRect(new Rect(16, 32, 180, 120));
presenter.SetCandidateRect(new Rect(24, 40, 200, 140));
presenter.Show();
var ghost = root.Children.OfType<DesktopEditGhostView>().Single();
var candidate = root.Children.OfType<Border>().Single(child => child is not DesktopEditGhostView);
Assert.Equal(16, Canvas.GetLeft(ghost));
Assert.Equal(32, Canvas.GetTop(ghost));
Assert.Equal(24, Canvas.GetLeft(candidate));
Assert.Equal(40, Canvas.GetTop(candidate));
}
}

View File

@@ -1,5 +1,6 @@
using Avalonia;
using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
@@ -170,4 +171,123 @@ public sealed class DesktopPlacementMathTests
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
Assert.True(resizeSession.CanCommit);
}
[Fact]
public void FusedCenteredPlacement_UsesGridCenterAndComponentSpan()
{
var grid = new DesktopGridGeometry(
Origin: new Point(12, 20),
CellSize: 80,
CellGap: 8,
ColumnCount: 8,
RowCount: 6);
var placement = FusedDesktopPlacementMath.CreateCenteredPlacement(
"placement-1",
"component-1",
grid,
widthCells: 4,
heightCells: 2);
Assert.Equal(2, placement.GridColumn);
Assert.Equal(2, placement.GridRow);
Assert.Equal(4, placement.GridWidthCells);
Assert.Equal(2, placement.GridHeightCells);
Assert.Equal(188, placement.X, 3);
Assert.Equal(196, placement.Y, 3);
Assert.Equal(344, placement.Width, 3);
Assert.Equal(168, placement.Height, 3);
}
[Fact]
public void FusedSnapToNearestCell_RoundsAndPersistsGridCoordinates()
{
var grid = new DesktopGridGeometry(
Origin: new Point(10, 10),
CellSize: 100,
CellGap: 12,
ColumnCount: 6,
RowCount: 5);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 212,
Height = 100,
GridWidthCells = 2,
GridHeightCells = 1
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(255, 135));
Assert.Equal(2, snapped.GridColumn);
Assert.Equal(1, snapped.GridRow);
Assert.Equal(234, snapped.X, 3);
Assert.Equal(122, snapped.Y, 3);
Assert.Equal(212, snapped.Width, 3);
Assert.Equal(100, snapped.Height, 3);
}
[Fact]
public void FusedSnapToNearestCell_ClampsInsideGridBounds()
{
var grid = new DesktopGridGeometry(
Origin: default,
CellSize: 80,
CellGap: 8,
ColumnCount: 4,
RowCount: 3);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 168,
Height = 168,
GridWidthCells = 2,
GridHeightCells = 2
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(900, 600));
Assert.Equal(2, snapped.GridColumn);
Assert.Equal(1, snapped.GridRow);
Assert.Equal(176, snapped.X, 3);
Assert.Equal(88, snapped.Y, 3);
}
[Fact]
public void FusedSnapToNearestCell_EstimatesMissingSpanFromPixelSize()
{
var grid = new DesktopGridGeometry(
Origin: default,
CellSize: 80,
CellGap: 8,
ColumnCount: 6,
RowCount: 6);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 168,
Height = 256
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(90, 180));
Assert.Equal(2, snapped.GridWidthCells);
Assert.Equal(3, snapped.GridHeightCells);
Assert.Equal(1, snapped.GridColumn);
Assert.Equal(2, snapped.GridRow);
Assert.Equal(168, snapped.Width, 3);
Assert.Equal(256, snapped.Height, 3);
}
}

View File

@@ -20,5 +20,6 @@
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
<ProjectReference Include="..\LanDesktopPLONDS.installer\LanDesktopPLONDS.installer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,397 @@
using System.IO.Compression;
using System.Security.Cryptography;
using LanDesktopPLONDS.Installer.Models;
using LanDesktopPLONDS.Installer.Services;
using LanDesktopPLONDS.Installer.ViewModels;
using LanMountainDesktop.Shared.Contracts.Privacy;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class OnlineInstallerCoreTests : IDisposable
{
private readonly string _tempRoot = Path.Combine(
AppContext.BaseDirectory,
"TestArtifacts",
"LanMountainDesktop.Tests",
nameof(OnlineInstallerCoreTests),
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
[Fact]
public void PrivacyDeviceIdentityProvider_ReturnsStableAnonymousId()
{
var path = Path.Combine(_tempRoot, "identity.json");
var first = new PrivacyDeviceIdentityProvider(path).GetOrCreateDeviceId();
var second = new PrivacyDeviceIdentityProvider(path).GetOrCreateDeviceId();
Assert.False(string.IsNullOrWhiteSpace(first));
Assert.Equal(first, second);
Assert.DoesNotContain(Environment.MachineName, first, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(Environment.UserName, first, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void InstallerPrivacyConsentStore_PersistsConfirmationForDeviceId()
{
var path = Path.Combine(_tempRoot, "privacy-consent.json");
var store = new InstallerPrivacyConsentStore(path);
Assert.False(store.HasConfirmed("device-a"));
store.SaveConfirmed("device-a");
Assert.True(new InstallerPrivacyConsentStore(path).HasConfirmed("device-a"));
Assert.False(new InstallerPrivacyConsentStore(path).HasConfirmed("device-b"));
}
[Fact]
public async Task InstallerWorkflowNavigation_AllowsOnlyUnlockedSteps()
{
var vm = new MainWindowViewModel(new FakeInstallService(), new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")));
var deployStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Deploy);
Assert.False(deployStep.IsUnlocked);
vm.SelectStepCommand.Execute(deployStep);
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
Assert.True(vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome).IsSelected);
await vm.NextCommand.ExecuteAsync(null);
vm.SelectStepCommand.Execute(vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome));
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
}
[Fact]
public async Task BrowseCommand_ReportsPickerFailuresWithoutChangingInstallPath()
{
var vm = new MainWindowViewModel(
new FakeInstallService(),
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
BrowseRequested = _ => throw new InvalidOperationException("picker failed")
};
var originalPath = vm.InstallPath;
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(originalPath, vm.InstallPath);
Assert.Contains("选择安装位置失败", vm.ErrorMessage);
Assert.Contains("picker failed", vm.ErrorMessage);
}
[Fact]
public async Task BrowseCommand_UsesSelectedLocalFolderAsInstallParent()
{
var selectedPath = Path.Combine(_tempRoot, "selected-install-root");
var vm = new MainWindowViewModel(
new FakeInstallService(),
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
BrowseRequested = _ => Task.FromResult<string?>(selectedPath)
};
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(Path.Combine(selectedPath, InstallerPathGuard.ApplicationDirectoryName), vm.InstallPath);
Assert.Null(vm.ErrorMessage);
}
[Fact]
public async Task BrowseCommand_DoesNotDuplicateApplicationFolder()
{
var selectedPath = Path.Combine(_tempRoot, InstallerPathGuard.ApplicationDirectoryName);
var vm = new MainWindowViewModel(
new FakeInstallService(),
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
BrowseRequested = _ => Task.FromResult<string?>(selectedPath)
};
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(selectedPath, vm.InstallPath);
Assert.Null(vm.ErrorMessage);
}
[Fact]
public async Task StartInstallCommand_PassesShortcutAndStartupOptions()
{
var installService = new FakeInstallService();
var vm = new MainWindowViewModel(
installService,
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
InstallPath = Path.Combine(_tempRoot, "install", "LanMountainDesktop"),
PrivacyConfirmed = true,
CreateDesktopShortcut = true,
CreateStartupShortcut = true
};
await vm.NextCommand.ExecuteAsync(null);
await vm.NextCommand.ExecuteAsync(null);
await vm.NextCommand.ExecuteAsync(null);
await vm.StartInstallCommand.ExecuteAsync(null);
Assert.NotNull(installService.LastOptions);
Assert.True(installService.LastOptions.CreateDesktopShortcut);
Assert.True(installService.LastOptions.CreateStartupShortcut);
}
[Fact]
public void FilesZipUrlResolver_PrefersSourceSpecificThenDerivedThenFallbacks()
{
var manifest = CreateManifest(
downloads: new InstallerPlondsDownloads(
new InstallerPlondsGitHubDownloads(null, null, null, "https://github.test/Files.zip"),
new InstallerPlondsS3Downloads(null, null, null, null, null, null, null, null, null, "https://s3.test/Files.zip", null, null)));
var urls = InstallerPlondsUrlResolver.ResolveFilesZipUrls(
manifest,
new InstallerPlondsSource("s3", "s3", "https://origin.test/releases/PLONDS.json", 100));
Assert.Equal("https://s3.test/Files.zip", urls[0].AbsoluteUri);
Assert.Contains(urls, uri => uri.AbsoluteUri == "https://origin.test/releases/Files.zip");
Assert.Contains(urls, uri => uri.AbsoluteUri == "https://github.test/Files.zip");
}
[Fact]
public async Task FindLatest_ParsesCamelCasePlondsManifest()
{
var client = new InstallerPlondsClient(
new HttpClient(new ManifestHandler("""
{
"formatVersion": "2.0",
"currentVersion": "1.2.4",
"previousVersion": "1.2.3",
"isFullUpdate": false,
"requiresCleanInstall": true,
"channel": "preview",
"platform": "windows-x64",
"updatedAt": "2026-06-03T00:00:00Z",
"filesMap": {},
"changedFilesMap": {},
"checksums": {
"Files.zip": "md5:00000000000000000000000000000000"
},
"downloads": {
"s3": {
"filesZipUrl": "https://s3.test/Files.zip"
},
"github": {
"filesZipUrl": "https://github.test/files-windows-x64.zip"
}
}
}
""")),
Path.Combine(_tempRoot, "staging"));
var candidate = await client.FindLatestAsync(CancellationToken.None);
Assert.Equal("1.2.4", candidate.Manifest.CurrentVersion);
Assert.Equal("preview", candidate.Manifest.Channel);
Assert.Equal("https://s3.test/Files.zip", candidate.FilesZipUrl.AbsoluteUri);
}
[Theory]
[InlineData("")]
[InlineData("C:\\")]
[InlineData("C:\\Windows")]
public void InstallerPathGuard_RejectsDangerousPaths(string path)
{
Assert.ThrowsAny<Exception>(() => InstallerPathGuard.NormalizeInstallPath(path));
}
[Fact]
public async Task FilesPackageInstaller_DeploysFullPackageWithCurrentMarker()
{
var packageRoot = Path.Combine(_tempRoot, "Files");
var appRoot = Path.Combine(packageRoot, "app-1.2.3");
Directory.CreateDirectory(appRoot);
File.WriteAllText(Path.Combine(packageRoot, "LanMountainDesktop.Launcher.exe"), "launcher");
File.WriteAllText(Path.Combine(appRoot, "LanMountainDesktop.exe"), "host");
File.WriteAllText(Path.Combine(appRoot, ".partial"), "old marker");
var package = new PreparedFilesPackage(
"1.2.3",
"s3",
Path.Combine(_tempRoot, "Files.zip"),
packageRoot,
CreateManifest());
var target = Path.Combine(_tempRoot, "install", "LanMountainDesktop");
await new FilesPackageInstaller().InstallAsync(package, target, null, CancellationToken.None);
var deployment = Directory.GetDirectories(target, "app-1.2.3-0").Single();
Assert.True(File.Exists(Path.Combine(target, "LanMountainDesktop.Launcher.exe")));
Assert.True(File.Exists(Path.Combine(deployment, "LanMountainDesktop.exe")));
Assert.True(File.Exists(Path.Combine(deployment, ".current")));
Assert.False(File.Exists(Path.Combine(deployment, ".partial")));
}
[Fact]
public async Task ZipExtraction_RejectsEscapingEntry()
{
var zipPath = Path.Combine(_tempRoot, "bad.zip");
Directory.CreateDirectory(_tempRoot);
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("../escape.txt");
using var writer = new StreamWriter(entry.Open());
writer.Write("bad");
}
var manifest = CreateManifest(checksums: new Dictionary<string, string>
{
["Files.zip"] = "sha256:" + Sha256(zipPath)
});
var client = new InstallerPlondsClient(new HttpClient(new FileHandler(zipPath)), Path.Combine(_tempRoot, "staging"));
var candidate = new InstallerPlondsCandidate(
new InstallerPlondsSource("s3", "s3", "https://s3.test/PLONDS.json", 100),
manifest,
new Uri("https://s3.test/Files.zip"));
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None));
Assert.IsType<InvalidDataException>(exception.InnerException);
}
[Fact]
public async Task DownloadAndPrepareFullPackage_FallsBackWhenFirstPackageUrlFails()
{
var zipPath = Path.Combine(_tempRoot, "Files.zip");
Directory.CreateDirectory(_tempRoot);
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("LanMountainDesktop.exe");
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync("host");
}
var manifest = CreateManifest(
downloads: new InstallerPlondsDownloads(
new InstallerPlondsGitHubDownloads(null, null, null, "https://github.test/files-windows-x64.zip"),
new InstallerPlondsS3Downloads(null, null, null, null, null, null, null, null, null, "https://s3.test/Files.zip", null, null)),
checksums: new Dictionary<string, string>
{
["Files.zip"] = "sha256:" + Sha256(zipPath)
});
var client = new InstallerPlondsClient(
new HttpClient(new FallbackPackageHandler(zipPath)),
Path.Combine(_tempRoot, "staging"));
var candidate = new InstallerPlondsCandidate(
new InstallerPlondsSource("s3", "s3", "https://origin.test/releases/PLONDS.json", 100),
manifest,
new Uri("https://s3.test/Files.zip"));
var package = await client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None);
Assert.True(File.Exists(Path.Combine(package.ExtractDirectory, "LanMountainDesktop.exe")));
}
private static InstallerPlondsManifest CreateManifest(
InstallerPlondsDownloads? downloads = null,
IReadOnlyDictionary<string, string>? checksums = null)
{
return new InstallerPlondsManifest(
"1",
"1.2.3",
"1.2.2",
true,
false,
"stable",
"windows-x64",
DateTimeOffset.UtcNow,
new Dictionary<string, InstallerPlondsFileEntry>(),
new Dictionary<string, InstallerPlondsChangedFileEntry>(),
checksums ?? new Dictionary<string, string>(),
downloads,
null);
}
private static string Sha256(string filePath)
{
using var sha = SHA256.Create();
using var stream = File.OpenRead(filePath);
return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant();
}
private sealed class FakeInstallService : IOnlineInstallService
{
public OnlineInstallOptions? LastOptions { get; private set; }
public Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
=> Task.FromResult(new OnlineInstallPackageInfo("1.2.3", "test", new Uri("https://test/Files.zip"), 1));
public Task InstallFreshAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task InstallFreshAsync(
string installPath,
OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken)
{
LastOptions = options;
return Task.CompletedTask;
}
public Task RepairAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task UpdateIncrementalAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
private sealed class FileHandler(string zipPath) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new ByteArrayContent(File.ReadAllBytes(zipPath))
};
response.Content.Headers.ContentLength = new FileInfo(zipPath).Length;
return Task.FromResult(response);
}
}
private sealed class FallbackPackageHandler(string zipPath) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri?.AbsoluteUri == "https://github.test/files-windows-x64.zip")
{
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new ByteArrayContent(File.ReadAllBytes(zipPath))
};
response.Content.Headers.ContentLength = new FileInfo(zipPath).Length;
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
}
}
private sealed class ManifestHandler(string manifestJson) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(manifestJson)
});
}
}
}

View File

@@ -0,0 +1,118 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class OobeSessionCommitServiceTests : IDisposable
{
private readonly string _tempRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.Tests",
nameof(OobeSessionCommitServiceTests),
Guid.NewGuid().ToString("N"));
[Fact]
public void ResolveDataRoot_ForChoice_DoesNotWriteConfigOrState()
{
var resolver = new DataLocationResolver(_tempRoot);
var dataRoot = resolver.ResolveDataRoot(DataLocationMode.Portable);
Assert.Equal(Path.Combine(_tempRoot, "Desktop"), dataRoot);
Assert.False(File.Exists(resolver.ResolveConfigPath()));
Assert.False(File.Exists(GetCompletedStatePath(dataRoot)));
}
[Fact]
public void Commit_WritesSettingsAndCompletedState_OnlyAfterFinalDraft()
{
var resolver = new DataLocationResolver(_tempRoot);
var oobeState = new OobeStateService(
_tempRoot,
executionSnapshot: new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var service = new OobeSessionCommitService(
resolver,
oobeState,
context,
setWindowsStartup: _ => true);
var draft = CreateDraft();
var result = service.Commit(draft);
var dataRoot = resolver.ResolveDataRoot();
Assert.True(result.Success);
Assert.True(File.Exists(resolver.ResolveConfigPath()));
Assert.True(File.Exists(HostAppSettingsOobeMerger.GetSettingsFilePath(dataRoot)));
Assert.True(File.Exists(Path.Combine(resolver.ResolveLauncherDataPath(), "privacy-config.json")));
Assert.True(File.Exists(Path.Combine(resolver.ResolveLauncherDataPath(), "privacy-agreement.state.json")));
Assert.True(File.Exists(GetCompletedStatePath(dataRoot)));
}
[Fact]
public void Commit_DoesNotWriteCompletedState_WhenFinalSaveFails()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var resolver = new DataLocationResolver(_tempRoot);
var oobeState = new OobeStateService(
_tempRoot,
executionSnapshot: new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var service = new OobeSessionCommitService(
resolver,
oobeState,
context,
setWindowsStartup: _ => false);
var result = service.Commit(CreateDraft());
var dataRoot = resolver.ResolveDataRoot();
Assert.False(result.Success);
Assert.Equal("windows_startup_save_failed", result.ResultCode);
Assert.False(File.Exists(GetCompletedStatePath(dataRoot)));
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
}
}
private static OobeSessionDraft CreateDraft() =>
new()
{
DataLocationMode = DataLocationMode.Portable,
MigrateExistingData = false,
StartupChoices = new HostAppSettingsStartupChoices(
ShowInTaskbar: true,
EnableFadeTransition: true,
EnableSlideTransition: false,
FusedPopupExperience: false,
AutoStartWithWindows: false),
PrivacyConfig = new PrivacyConfig
{
CrashTelemetryEnabled = false,
UsageTelemetryEnabled = false,
TelemetryId = "test-telemetry"
},
PrivacyAgreementAccepted = true,
PrivacyUserId = "test-telemetry",
PrivacyDeviceId = "test-device"
};
private static string GetCompletedStatePath(string dataRoot) =>
Path.Combine(dataRoot, "Launcher", "state", "oobe-state.json");
}

View File

@@ -66,16 +66,80 @@ public sealed class OobeStateServiceTests : IDisposable
}
[Fact]
public void Evaluate_SuppressesOobe_ForElevatedFirstRun()
public void Evaluate_ReturnsFirstRun_ForElevatedFirstRun()
{
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
Assert.True(decision.ShouldShowOobe);
Assert.True(decision.IsElevated);
}
[Fact]
public void Evaluate_ReturnsFirstRun_ForElevatedPostInstall()
{
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch", "--launch-source", "postinstall"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
Assert.True(decision.ShouldShowOobe);
Assert.Equal("postinstall", decision.LaunchSource);
}
[Fact]
public void Evaluate_SuppressesOobe_ForMaintenanceLaunch()
{
var service = CreateService();
var context = CommandContext.FromArgs(["plugin", "install", "--source", "x", "--plugins-dir", "p"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
Assert.False(decision.ShouldShowOobe);
Assert.Equal("oobe_suppressed_elevated", decision.ResultCode);
Assert.Equal("oobe_suppressed_maintenance", decision.ResultCode);
}
[Fact]
public void Evaluate_SuppressesOobe_ForDebugPreview()
{
var service = CreateService();
var context = CommandContext.FromArgs(["preview-oobe"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
Assert.False(decision.ShouldShowOobe);
Assert.Equal("oobe_suppressed_debug_preview", decision.ResultCode);
}
[Fact]
public void Evaluate_MigratesLegacyStateFile_ToCurrentStatePath()
{
var legacyStatePath = GetLegacyStatePath();
Directory.CreateDirectory(Path.GetDirectoryName(legacyStatePath)!);
var state = new OobeStateFile
{
SchemaVersion = 1,
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
UserName = "tester",
UserSid = "S-1-5-test",
LaunchSource = "normal"
};
File.WriteAllText(legacyStatePath, JsonSerializer.Serialize(state));
var service = CreateService();
var context = CommandContext.FromArgs(["launch"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Completed, decision.Status);
Assert.True(decision.MigratedLegacyMarker);
Assert.True(File.Exists(GetStatePath()));
}
[Fact]
@@ -119,5 +183,7 @@ public sealed class OobeStateServiceTests : IDisposable
private string GetStatePath() => Path.Combine(_tempRoot, "Launcher", "state", "oobe-state.json");
private string GetLegacyStatePath() => Path.Combine(_tempRoot, ".launcher", "state", "oobe-state.json");
private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed");
}

View File

@@ -0,0 +1,47 @@
using System.IO;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class UpdateInstallGatewayTests
{
[Fact]
public void GetDirectoryName_ReturnsNull_ForRootPath()
{
// 验证 Path.GetDirectoryName 在根路径场景下的行为
var rootPath = Path.GetPathRoot(Path.GetTempPath()) ?? "C:\\";
var installerPath = Path.Combine(rootPath, "installer.exe");
// 根路径下的文件GetDirectoryName 返回根路径本身(不是 null
var result = Path.GetDirectoryName(installerPath);
// 在 Windows 上,根路径文件返回根路径(如 "C:\"),不是 null
// 但如果 installerPath 本身就是根路径(无文件名),则返回 null
Assert.NotNull(result); // "C:\installer.exe" 的目录是 "C:\"
}
[Fact]
public void GetDirectoryName_ReturnsNull_ForPathWithoutDirectory()
{
// 验证极端场景:路径没有目录部分
// 这种情况在实际中很少发生,但代码应该能处理
var fileNameOnly = "installer.exe";
var result = Path.GetDirectoryName(fileNameOnly);
// 只有文件名没有路径时GetDirectoryName 返回 null
Assert.Null(result);
}
[Fact]
public void WorkingDirectoryFallback_ShouldUseValidDirectory()
{
// 验证修复后的逻辑:当 GetDirectoryName 返回 null 时,
// 应该使用 AppContext.BaseDirectory 作为后备值
var installerPath = "installer.exe"; // 模拟只有文件名的情况
var workingDir = Path.GetDirectoryName(installerPath) ?? AppContext.BaseDirectory;
// 后备值应该是有效的目录路径
Assert.NotNull(workingDir);
Assert.True(Directory.Exists(workingDir) || workingDir == AppContext.BaseDirectory);
}
}

View File

@@ -40,6 +40,8 @@ public sealed class UpdateSettingsInterfaceTests
Assert.Equal(1, update.CheckCalls);
Assert.Equal("1.2.3", viewModel.LatestVersionText);
Assert.True(viewModel.IsDeltaUpdate);
Assert.True(viewModel.CanDownload);
Assert.True(viewModel.IsProgressSectionVisible);
update.SetPhase(UpdatePhase.Checked);
await ((IAsyncRelayCommand)viewModel.DownloadCommand).ExecuteAsync(null);
@@ -62,6 +64,36 @@ public sealed class UpdateSettingsInterfaceTests
Assert.Equal(1, update.CancelCalls);
}
[Fact]
public async Task UpdateSettingsViewModel_WhenCheckFailsInCheckedPhase_DoesNotExposeDownload()
{
var update = new FakeUpdateSettingsService
{
CheckReport = new UpdateCheckReport(
false,
null,
"1.0.0",
null,
null,
null,
null,
null,
null,
"No usable update manifest was found.")
};
var viewModel = new UpdateSettingsViewModel(new FakeSettingsFacade(update));
viewModel.IsUpdateAvailable = true;
viewModel.LatestVersionText = "9.9.9";
await ((IAsyncRelayCommand)viewModel.CheckCommand).ExecuteAsync(null);
Assert.False(viewModel.IsUpdateAvailable);
Assert.Empty(viewModel.LatestVersionText);
Assert.False(viewModel.CanDownload);
Assert.False(viewModel.IsProgressSectionVisible);
Assert.Equal(0, update.DownloadCalls);
}
[Fact]
public void UpdateSettingsViewModel_SavesPreferencesThroughUpdateSettingsService()
{
@@ -140,6 +172,32 @@ public sealed class UpdateSettingsInterfaceTests
Assert.False(orchestratorCreated);
}
[Fact]
public async Task UpdateSettingsService_WhenPlondsCheckFails_ReturnsIdleAndNoDownload()
{
var settings = new FakeSettingsService
{
Snapshot =
{
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
}
};
var plonds = new FakePlondsService
{
LatestResult = PlondsLatestResult.Failed(new Version(1, 0, 0), "No usable PLONDS manifest was found.")
};
var service = new UpdateSettingsService(
settings,
orchestratorFactory: () => throw new InvalidOperationException("not used"),
plondsService: plonds);
var report = await service.CheckAsync(CancellationToken.None);
Assert.False(report.IsUpdateAvailable);
Assert.Equal("No usable PLONDS manifest was found.", report.ErrorMessage);
Assert.Equal(UpdatePhase.Idle, service.CurrentPhase);
}
[Fact]
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
{

View File

@@ -14,6 +14,7 @@
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
<Project Path="LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj" />
<Project Path="ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />

View File

@@ -0,0 +1,12 @@
using Avalonia;
using Avalonia.Threading;
namespace LanMountainDesktop.DesktopEditing;
internal static class DesktopEditAnimationRuntime
{
public static bool CanUseTransitions()
{
return Application.Current is not null && Dispatcher.UIThread.CheckAccess();
}
}

View File

@@ -52,7 +52,7 @@ internal sealed class DesktopEditGhostView : Border
ClipToBounds = true;
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
RenderTransform = _scaleTransform;
if (Dispatcher.UIThread.CheckAccess())
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
Transitions = new Transitions
{
@@ -301,6 +301,12 @@ internal sealed class DesktopEditGhostView : Border
internal void SetScaleTransitionDuration(TimeSpan duration)
{
if (!DesktopEditAnimationRuntime.CanUseTransitions())
{
_scaleTransform.Transitions = null;
return;
}
_scaleTransform.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, duration),
@@ -310,6 +316,12 @@ internal sealed class DesktopEditGhostView : Border
internal void SetOpacityTransitionDuration(TimeSpan duration)
{
if (!DesktopEditAnimationRuntime.CanUseTransitions())
{
Transitions = null;
return;
}
Transitions = new Transitions
{
CreateOpacityTransition(duration)

View File

@@ -33,8 +33,6 @@ internal sealed class DesktopEditOverlayPresenter
private Rect? _candidateRect;
private bool _isInvalid;
private bool _isVisible;
private bool _ghostUsesCompositionOffset;
private bool _candidateUsesCompositionOffset;
private int _dismissVersion;
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
@@ -68,7 +66,7 @@ internal sealed class DesktopEditOverlayPresenter
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
RenderTransform = _candidateScale
};
if (Dispatcher.UIThread.CheckAccess())
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
_candidateOutline.Transitions = new Transitions
{
@@ -102,7 +100,7 @@ internal sealed class DesktopEditOverlayPresenter
}
};
if (Dispatcher.UIThread.CheckAccess())
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
_root.Transitions = new Transitions
{
@@ -170,21 +168,24 @@ internal sealed class DesktopEditOverlayPresenter
targetGhostScale = 1.03;
}
_root.Transitions = new Transitions
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
CreateOpacityTransition(PickupDuration)
};
_ghostView.SetOpacityTransitionDuration(PickupDuration);
_ghostView.SetScaleTransitionDuration(PickupDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
_root.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
_ghostView.SetOpacityTransitionDuration(PickupDuration);
_ghostView.SetScaleTransitionDuration(PickupDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
}
_ghostView.SetRestingScale(initialGhostScale);
_candidateOutline.Opacity = 0;
_candidateScale.ScaleX = 0.97;
@@ -243,21 +244,24 @@ internal sealed class DesktopEditOverlayPresenter
var version = ++_dismissVersion;
_isVisible = false;
var settleDuration = isCancel ? CancelSettleDuration : CommitSettleDuration;
_root.Transitions = new Transitions
if (DesktopEditAnimationRuntime.CanUseTransitions())
{
CreateOpacityTransition(settleDuration)
};
_ghostView.SetOpacityTransitionDuration(settleDuration);
_ghostView.SetScaleTransitionDuration(settleDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
_root.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
_ghostView.SetOpacityTransitionDuration(settleDuration);
_ghostView.SetScaleTransitionDuration(settleDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
}
var targetScale = _ghostView.HasPreviewImage
? 1.00
: isCancel ? 0.96 : 1.04;
@@ -292,7 +296,7 @@ internal sealed class DesktopEditOverlayPresenter
var rect = _previewRect.Value;
_ghostView.Width = Math.Max(1, rect.Width);
_ghostView.Height = Math.Max(1, rect.Height);
SetOverlayOffset(_ghostView, new Point(rect.X, rect.Y), ref _ghostUsesCompositionOffset);
SetOverlayOffset(_ghostView, new Point(rect.X, rect.Y));
_ghostView.UpdatePreviewMetrics(rect.Width, rect.Height);
}
@@ -309,7 +313,7 @@ internal sealed class DesktopEditOverlayPresenter
_candidateOutline.IsVisible = true;
_candidateOutline.Width = Math.Max(1, rect.Width);
_candidateOutline.Height = Math.Max(1, rect.Height);
SetOverlayOffset(_candidateOutline, new Point(rect.X, rect.Y), ref _candidateUsesCompositionOffset);
SetOverlayOffset(_candidateOutline, new Point(rect.X, rect.Y));
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
@@ -339,24 +343,11 @@ internal sealed class DesktopEditOverlayPresenter
return new Rect(rect.X, rect.Y, width, height);
}
private void SetOverlayOffset(Control target, Point position, ref bool usesCompositionOffset)
private void SetOverlayOffset(Control target, Point position)
{
if (_visualAnimationService.TrySetOffset(target, position))
{
Canvas.SetLeft(target, 0);
Canvas.SetTop(target, 0);
usesCompositionOffset = true;
return;
}
if (usesCompositionOffset)
{
_visualAnimationService.TryResetOffset(target);
usesCompositionOffset = false;
}
Canvas.SetLeft(target, position.X);
Canvas.SetTop(target, position.Y);
_visualAnimationService.TryResetOffset(target);
}
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>

View File

@@ -0,0 +1,97 @@
using System;
using Avalonia;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.DesktopEditing;
internal static class FusedDesktopPlacementMath
{
public static FusedDesktopComponentPlacementSnapshot CreateCenteredPlacement(
string placementId,
string componentId,
DesktopGridGeometry grid,
int widthCells,
int heightCells)
{
ArgumentException.ThrowIfNullOrWhiteSpace(placementId);
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
var safeWidthCells = Math.Max(1, widthCells);
var safeHeightCells = Math.Max(1, heightCells);
var column = Math.Clamp(
(grid.ColumnCount - safeWidthCells) / 2,
0,
Math.Max(0, grid.ColumnCount - safeWidthCells));
var row = Math.Clamp(
(grid.RowCount - safeHeightCells) / 2,
0,
Math.Max(0, grid.RowCount - safeHeightCells));
var rect = DesktopPlacementMath.GetCellRect(grid, column, row, safeWidthCells, safeHeightCells);
return new FusedDesktopComponentPlacementSnapshot
{
PlacementId = placementId,
ComponentId = componentId,
X = rect.X,
Y = rect.Y,
Width = rect.Width,
Height = rect.Height,
GridColumn = column,
GridRow = row,
GridWidthCells = safeWidthCells,
GridHeightCells = safeHeightCells
};
}
public static FusedDesktopComponentPlacementSnapshot SnapToNearestCell(
FusedDesktopComponentPlacementSnapshot placement,
DesktopGridGeometry grid,
Point requestedOrigin)
{
ArgumentNullException.ThrowIfNull(placement);
var widthCells = Math.Max(1, placement.GridWidthCells ?? EstimateCellSpan(placement.Width, grid));
var heightCells = Math.Max(1, placement.GridHeightCells ?? EstimateCellSpan(placement.Height, grid));
var maxColumn = Math.Max(0, grid.ColumnCount - widthCells);
var maxRow = Math.Max(0, grid.RowCount - heightCells);
var pitch = grid.Pitch;
if (!grid.IsValid || pitch <= 0)
{
return placement.Clone();
}
var column = Math.Clamp(
(int)Math.Round((requestedOrigin.X - grid.Origin.X) / pitch, MidpointRounding.AwayFromZero),
0,
maxColumn);
var row = Math.Clamp(
(int)Math.Round((requestedOrigin.Y - grid.Origin.Y) / pitch, MidpointRounding.AwayFromZero),
0,
maxRow);
var rect = DesktopPlacementMath.GetCellRect(grid, column, row, widthCells, heightCells);
var snapped = placement.Clone();
snapped.X = rect.X;
snapped.Y = rect.Y;
snapped.Width = rect.Width;
snapped.Height = rect.Height;
snapped.GridColumn = column;
snapped.GridRow = row;
snapped.GridWidthCells = widthCells;
snapped.GridHeightCells = heightCells;
return snapped;
}
private static int EstimateCellSpan(double pixelSize, DesktopGridGeometry grid)
{
if (!grid.IsValid || grid.CellSize <= 0)
{
return 1;
}
return Math.Max(1, (int)Math.Round(
(Math.Max(1, pixelSize) + grid.CellGap) / grid.Pitch,
MidpointRounding.AwayFromZero));
}
}

View File

@@ -537,11 +537,11 @@
"settings.update.status_channel_changed": "Update channel changed. Please check again.",
"settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.",
"settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.",
"settings.update.status_checking": "Checking GitHub releases...",
"settings.update.status_checking": "Checking update sources...",
"settings.update.status_check_failed_format": "Update check failed: {0}",
"settings.update.status_up_to_date": "You are already on the latest version.",
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.",
"settings.update.status_available_format": "New version {0} is available. Download it when you are ready.",
"settings.update.status_downloading": "Downloading installer...",
"settings.update.status_downloading_delta": "Downloading incremental update...",
"settings.update.status_delta_applying": "Applying incremental update. The app will close for update.",
@@ -550,7 +550,7 @@
"settings.update.type_delta": "Incremental Update",
"settings.update.type_full": "Full Installer",
"settings.update.status_download_failed_format": "Download failed: {0}",
"settings.update.status_launching_installer": "Download complete. Launching installer...",
"settings.update.status_launching_installer": "Download complete. You can install the update now.",
"settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
@@ -655,17 +655,17 @@
"settings.update.status_channel_changed": "Update channel changed. Please check again.",
"settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.",
"settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.",
"settings.update.status_checking": "Checking GitHub releases...",
"settings.update.status_checking": "Checking update sources...",
"settings.update.status_check_failed_format": "Update check failed: {0}",
"settings.update.status_up_to_date": "You are already on the latest version.",
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.",
"settings.update.status_available_format": "New version {0} is available. Download it when you are ready.",
"settings.update.status_downloading": "Downloading installer...",
"settings.update.status_downloading_delta": "Downloading incremental update...",
"settings.update.status_delta_applying": "Applying incremental update. The app will close for update.",
"settings.update.status_delta_launch_failed": "Failed to launch updater for incremental update.",
"settings.update.status_download_failed_format": "Download failed: {0}",
"settings.update.status_launching_installer": "Download complete. Launching installer...",
"settings.update.status_launching_installer": "Download complete. You can install the update now.",
"settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",

View File

@@ -430,14 +430,14 @@
"settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。",
"settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。",
"settings.update.status_windows_only": "自動インストーラーアップデートは現在Windowsでのみ利用可能です。",
"settings.update.status_checking": "GitHubリリースを確認中...",
"settings.update.status_checking": "更新元を確認中...",
"settings.update.status_check_failed_format": "アップデートの確認に失敗しました: {0}",
"settings.update.status_up_to_date": "最新バージョンを使用しています。",
"settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。",
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。ダウンロードしてインストールをクリックしてください。",
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。準備ができたらダウンロードできます。",
"settings.update.status_downloading": "インストーラーをダウンロード中...",
"settings.update.status_download_failed_format": "ダウンロードに失敗しました: {0}",
"settings.update.status_launching_installer": "ダウンロード完了。インストーラーを起動中...",
"settings.update.status_launching_installer": "ダウンロード完了。更新をインストールできます。",
"settings.update.status_installer_missing": "ダウンロード後にインストーラーファイルが見つかりませんでした。",
"settings.update.status_installer_started": "インストーラーが開始されました。アプリはアップデートのために終了します。",
"settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。",

View File

@@ -478,14 +478,14 @@
"settings.update.status_channel_changed": "업데이트 채널이 변경되었습니다. 다시 업데이트를 확인하세요.",
"settings.update.status_channel_changed_format": "업데이트 채널이 {0}(으)로 전환되었습니다. 다시 업데이트를 확인하세요.",
"settings.update.status_windows_only": "자동 설치 패키지 업데이트는 현재 Windows만 지원합니다.",
"settings.update.status_checking": "GitHub Release 확인 중...",
"settings.update.status_checking": "업데이트 소스 확인 중...",
"settings.update.status_check_failed_format": "업데이트 확인 실패: {0}",
"settings.update.status_up_to_date": "현재 최신 버전입니다.",
"settings.update.status_asset_missing": "새 버전이 발견되었지만 호환되는 설치 패키지를 찾을 수 없습니다.",
"settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. \"다운로드 및 설치\"를 클릭하여 계속하세요.",
"settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. 준비되면 업데이트를 다운로드하세요.",
"settings.update.status_downloading": "설치 패키지 다운로드 중...",
"settings.update.status_download_failed_format": "다운로드 실패: {0}",
"settings.update.status_launching_installer": "다운로드 완료, 설치 프로그램 시작 중...",
"settings.update.status_launching_installer": "다운로드 완료. 이제 업데이트를 설치할 수 있습니다.",
"settings.update.status_installer_missing": "다운로드 후 설치 패키지 파일을 찾을 수 없습니다.",
"settings.update.status_installer_started": "설치 프로그램이 시작되었습니다. 앱이 업데이트를 위해 종료됩니다.",
"settings.update.status_elevation_cancelled": "관리자 권한이 부여되지 않아 업데이트가 취소되었습니다.",

View File

@@ -537,11 +537,11 @@
"settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。",
"settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。",
"settings.update.status_windows_only": "自动安装包更新当前仅支持 Windows。",
"settings.update.status_checking": "正在检查 GitHub Release...",
"settings.update.status_checking": "正在检查更新源...",
"settings.update.status_check_failed_format": "检查更新失败:{0}",
"settings.update.status_up_to_date": "当前已是最新版本。",
"settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。",
"settings.update.status_available_format": "发现新版本 {0}点击“下载并安装”继续。",
"settings.update.status_available_format": "发现新版本 {0}准备好后可下载更新。",
"settings.update.status_downloading": "正在下载安装包...",
"settings.update.status_downloading_delta": "正在下载增量更新包...",
"settings.update.status_delta_applying": "正在应用增量更新,应用将关闭进行更新。",
@@ -550,7 +550,7 @@
"settings.update.type_delta": "增量更新",
"settings.update.type_full": "完整安装包",
"settings.update.status_download_failed_format": "下载失败:{0}",
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_launching_installer": "下载完成,现在可以安装更新。",
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",

View File

@@ -3,7 +3,9 @@ using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
@@ -17,7 +19,7 @@ public interface IFusedDesktopManagerService
void Initialize();
void ReloadWidgets();
void Shutdown();
void AddComponent(string componentId);
void AddComponent(string componentId, Window? referenceWindow = null);
void RemoveComponent(string placementId);
void EnterEditMode();
void ExitEditMode();
@@ -40,8 +42,6 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
private bool _isEditMode;
private const double DefaultCellSize = 100;
private const double DefaultComponentWidth = 200;
private const double DefaultComponentHeight = 200;
public bool IsEditMode => _isEditMode;
@@ -109,7 +109,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
AppLogger.Info("FusedDesktop", "Exited edit mode.");
}
public void AddComponent(string componentId)
public void AddComponent(string componentId, Window? referenceWindow = null)
{
EnsureRegistries();
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
@@ -118,23 +118,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
return;
}
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = Guid.NewGuid().ToString("N"),
ComponentId = componentId,
Width = DefaultComponentWidth,
Height = DefaultComponentHeight
};
var screen = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
?.MainWindow?.Screens.Primary;
if (screen is not null)
{
var scaling = screen.Scaling;
var workArea = screen.WorkingArea;
placement.X = (workArea.Width / scaling - placement.Width) / 2;
placement.Y = (workArea.Height / scaling - placement.Height) / 2;
}
var widthCells = Math.Max(1, descriptor.Definition.MinWidthCells);
var heightCells = Math.Max(1, descriptor.Definition.MinHeightCells);
var placement = CreateCenteredPlacement(
Guid.NewGuid().ToString("N"),
componentId,
widthCells,
heightCells,
referenceWindow);
_layoutService.AddComponentPlacement(placement);
@@ -160,7 +151,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
_layoutService.RemoveComponentPlacement(placement.PlacementId);
}
AppLogger.Info("FusedDesktopMgr", $"Added component '{componentId}' with placement '{placement.PlacementId}'.");
AppLogger.Info(
"FusedDesktopMgr",
$"Added component '{componentId}' with placement '{placement.PlacementId}' at grid {placement.GridColumn},{placement.GridRow}.");
}
public void RemoveComponent(string placementId)
@@ -249,8 +242,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
return null;
}
var cellSize = ResolveCellSize(placement);
var control = descriptor.CreateControl(
DefaultCellSize,
cellSize,
_timeZoneService,
_weatherDataService,
_recommendationInfoService,
@@ -264,6 +258,140 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
var window = new DesktopWidgetWindow(control, placement.PlacementId);
return window;
}
private FusedDesktopComponentPlacementSnapshot CreateCenteredPlacement(
string placementId,
string componentId,
int widthCells,
int heightCells,
Window? referenceWindow)
{
var screen = ResolveTargetScreen(referenceWindow);
if (screen is null)
{
var fallbackWidth = widthCells * DefaultCellSize;
var fallbackHeight = heightCells * DefaultCellSize;
return new FusedDesktopComponentPlacementSnapshot
{
PlacementId = placementId,
ComponentId = componentId,
X = 0,
Y = 0,
Width = fallbackWidth,
Height = fallbackHeight,
GridColumn = 0,
GridRow = 0,
GridWidthCells = widthCells,
GridHeightCells = heightCells
};
}
var workArea = screen.WorkingArea;
var scaling = Math.Max(0.1, screen.Scaling);
var viewportSize = GetScreenViewportSize(screen);
var adapter = new FusedDesktopEditGridAdapter(_settingsFacade);
if (!adapter.TryCreate(viewportSize, out var context))
{
var fallbackWidth = widthCells * DefaultCellSize;
var fallbackHeight = heightCells * DefaultCellSize;
return new FusedDesktopComponentPlacementSnapshot
{
PlacementId = placementId,
ComponentId = componentId,
X = workArea.X + Math.Max(0, (workArea.Width - fallbackWidth * scaling) / 2),
Y = workArea.Y + Math.Max(0, (workArea.Height - fallbackHeight * scaling) / 2),
Width = fallbackWidth,
Height = fallbackHeight,
GridColumn = 0,
GridRow = 0,
GridWidthCells = widthCells,
GridHeightCells = heightCells
};
}
var localPlacement = FusedDesktopPlacementMath.CreateCenteredPlacement(
placementId,
componentId,
context.Geometry,
widthCells,
heightCells);
localPlacement.X = workArea.X + localPlacement.X * scaling;
localPlacement.Y = workArea.Y + localPlacement.Y * scaling;
return localPlacement;
}
private Screen? ResolveTargetScreen(Window? referenceWindow)
{
if (referenceWindow is not null)
{
var referenceScreen = referenceWindow.Screens.ScreenFromWindow(referenceWindow);
if (referenceScreen is not null)
{
return referenceScreen;
}
}
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
return mainWindow?.Screens.Primary;
}
private static Size GetScreenViewportSize(Screen screen)
{
var scaling = Math.Max(0.1, screen.Scaling);
var workArea = screen.WorkingArea;
return new Size(workArea.Width / scaling, workArea.Height / scaling);
}
private double ResolveCellSize(FusedDesktopComponentPlacementSnapshot placement)
{
if (TryResolveScreenForPlacement(placement, out var screen))
{
var adapter = new FusedDesktopEditGridAdapter(_settingsFacade);
if (adapter.TryCreate(GetScreenViewportSize(screen), out var context))
{
return Math.Max(1, context.Metrics.CellSize);
}
}
if (placement.GridWidthCells is > 0 && placement.Width > 0)
{
return Math.Max(1, placement.Width / placement.GridWidthCells.Value);
}
if (placement.GridHeightCells is > 0 && placement.Height > 0)
{
return Math.Max(1, placement.Height / placement.GridHeightCells.Value);
}
return DefaultCellSize;
}
private bool TryResolveScreenForPlacement(
FusedDesktopComponentPlacementSnapshot placement,
out Screen screen)
{
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
{
foreach (var candidate in mainWindow.Screens.All)
{
if (candidate.WorkingArea.Contains(new PixelPoint((int)placement.X, (int)placement.Y)))
{
screen = candidate;
return true;
}
}
if (mainWindow.Screens.Primary is not null)
{
screen = mainWindow.Screens.Primary;
return true;
}
}
screen = null!;
return false;
}
}
public static class FusedDesktopManagerServiceFactory

View File

@@ -4,7 +4,7 @@ internal static class PlondsClientServiceFactory
{
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json";
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
public static IPlondsService CreateDefault(HttpClient? httpClient = null)

View File

@@ -1091,7 +1091,18 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
}
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null;
if (!latest.Success)
{
_pendingPlondsLatest = null;
_pendingPlondsCleanInstallCandidate = null;
_pendingPlondsInstallerManifest = null;
_pendingPlondsPackage = null;
TransitionPlonds(UpdatePhase.Idle);
SaveLastChecked();
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
}
_pendingPlondsLatest = latest.IsUpdateAvailable ? latest : null;
_pendingPlondsCleanInstallCandidate = _pendingPlondsLatest?.Candidates
.FirstOrDefault(candidate => candidate.Manifest.RequiresCleanInstall);
_pendingPlondsInstallerManifest = null;
@@ -1099,11 +1110,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
TransitionPlonds(UpdatePhase.Checked);
SaveLastChecked();
if (!latest.Success)
{
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
}
var payloadKind = latest.IsUpdateAvailable
? _pendingPlondsCleanInstallCandidate is not null
? UpdatePayloadKind.FullInstaller
@@ -1368,7 +1374,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
_plondsPhase = phase;
_phaseChanged?.Invoke(phase);
_progressChanged?.Invoke(new UpdateProgressReport(phase, $"Phase changed to {phase}", 0, null, null));
_progressChanged?.Invoke(new UpdateProgressReport(phase, string.Empty, 0, null, null));
}
private UpdateOrchestrator GetOrchestrator()

View File

@@ -222,7 +222,7 @@ internal sealed class UpdateInstallGateway
try
{
AppLogger.Info("UpdateInstallGateway", "Launching full installer with elevation.");
var workingDir = Path.GetDirectoryName(installerPath) ?? Path.GetDirectoryName(installerPath)!;
var workingDir = Path.GetDirectoryName(installerPath) ?? AppContext.BaseDirectory;
var startInfo = new ProcessStartInfo
{

View File

@@ -39,7 +39,7 @@ internal sealed class UpdateStateStore
PhaseChanged?.Invoke(newPhase);
ProgressChanged?.Invoke(new UpdateProgressReport(
newPhase,
$"Phase changed to {newPhase}",
string.Empty,
0,
null,
null));

View File

@@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Services;
@@ -114,18 +115,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
public bool IsBusy => CurrentPhase.IsBusy();
public bool IsPaused => CurrentPhase.IsPaused();
public bool CanCheck => CurrentPhase.CanCheck();
public bool CanDownload => CurrentPhase.CanDownload();
public bool CanDownload => IsUpdateAvailable && CurrentPhase.CanDownload();
public bool CanInstall => CurrentPhase.CanInstall();
public bool CanRollback => CurrentPhase.CanRollback();
public bool CanPause => CurrentPhase.CanPause();
public bool CanResume => CurrentPhase.CanResume();
public bool CanCancel => CurrentPhase.CanCancel();
public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack or UpdatePhase.Recovering;
public bool IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused;
public bool IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused || HasVisibleAction;
public string PhaseText => GetPhaseText(CurrentPhase);
public string LatestVersionDisplayText => string.IsNullOrEmpty(LatestVersionText)
? L("settings.update.latest_version_none", "Up to date")
: LatestVersionText;
private bool HasVisibleAction => CanDownload || CanInstall || CanRollback || CanPause || CanResume || CanCancel;
partial void OnCurrentPhaseChanged(UpdatePhase value)
{
@@ -150,6 +152,13 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
CancelCommand.NotifyCanExecuteChanged();
}
partial void OnIsUpdateAvailableChanged(bool value)
{
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(IsProgressSectionVisible));
DownloadCommand.NotifyCanExecuteChanged();
}
partial void OnSelectedUpdateChannelValueChanged(string value)
{
SavePreferenceState();
@@ -221,9 +230,21 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds;
StatusMessage = report.LatestVersion is null
var availableMessage = report.LatestVersion is null
? GetUpdateAvailableStatusText(string.Empty)
: string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), report.LatestVersion);
: string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Download it when you are ready."), report.LatestVersion);
StatusMessage = string.IsNullOrWhiteSpace(report.ErrorMessage)
? availableMessage
: $"{availableMessage} {report.ErrorMessage}";
}
else if (!string.IsNullOrWhiteSpace(report.ErrorMessage))
{
IsUpdateAvailable = false;
LatestVersionText = string.Empty;
PublishedAtText = string.Empty;
UpdateTypeText = string.Empty;
IsDeltaUpdate = false;
StatusMessage = report.ErrorMessage;
}
else
{
@@ -315,10 +336,15 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
private void OnUpdatePhaseChanged(UpdatePhase phase)
{
CurrentPhase = phase;
RunOnUiThread(() => CurrentPhase = phase);
}
private void OnUpdateProgressChanged(UpdateProgressReport report)
{
RunOnUiThread(() => ApplyUpdateProgress(report));
}
private void ApplyUpdateProgress(UpdateProgressReport report)
{
ProgressFraction = report.ProgressFraction;
@@ -338,13 +364,37 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
}
else
{
StatusMessage = string.IsNullOrWhiteSpace(report.Message)
? GetPhaseStatusText(CurrentPhase)
: report.Message;
if (!string.IsNullOrWhiteSpace(report.Message))
{
StatusMessage = report.Message;
}
ProgressDetail = string.Empty;
}
}
private void RunOnUiThread(Action action)
{
if (_disposed)
{
return;
}
if (Dispatcher.UIThread.CheckAccess())
{
action();
return;
}
Dispatcher.UIThread.Post(() =>
{
if (!_disposed)
{
action();
}
}, DispatcherPriority.Normal);
}
private void LoadPreferenceState()
{
@@ -565,19 +615,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
=> L("settings.update.status_ready", "Ready to check for updates.");
private string GetCheckingStatusText()
=> L("settings.update.status_checking", "Checking GitHub releases...");
=> L("settings.update.status_checking", "Checking update sources...");
private string GetUpToDateStatusText()
=> L("settings.update.status_up_to_date", "You are already on the latest version.");
private string GetUpdateAvailableStatusText(string version)
=> string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), version);
=> string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Download it when you are ready."), version);
private string GetDownloadingStatusText()
=> L("settings.update.status_downloading", "Downloading installer...");
private string GetDownloadCompleteStatusText()
=> L("settings.update.status_launching_installer", "Download complete. Launching installer...");
=> L("settings.update.status_launching_installer", "Download complete. You can install the update now.");
private string GetDownloadFailedStatusText()
=> L("settings.update.status_download_failed", "Download failed.");

View File

@@ -4,7 +4,10 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Threading;
using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views;
@@ -12,6 +15,7 @@ public partial class DesktopWidgetWindow : Window
{
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
private bool _isEditMode;
private bool _isDragging;
@@ -174,8 +178,7 @@ public partial class DesktopWidgetWindow : Window
p => string.Equals(p.PlacementId, PlacementId, StringComparison.OrdinalIgnoreCase));
if (placement is not null)
{
placement.X = Position.X;
placement.Y = Position.Y;
ApplySnappedDragPlacement(placement);
layoutService.Save(layout);
}
}
@@ -183,6 +186,70 @@ public partial class DesktopWidgetWindow : Window
RefreshDesktopLayer();
}
private void ApplySnappedDragPlacement(FusedDesktopComponentPlacementSnapshot placement)
{
if (!TrySnapToCurrentScreenGrid(placement, Position, out var snappedPosition) ||
!snappedPosition.HasValue)
{
placement.X = Position.X;
placement.Y = Position.Y;
return;
}
placement.X = snappedPosition.Value.X;
placement.Y = snappedPosition.Value.Y;
Position = snappedPosition.Value;
}
private bool TrySnapToCurrentScreenGrid(
FusedDesktopComponentPlacementSnapshot placement,
PixelPoint requestedPosition,
out PixelPoint? snappedPosition)
{
snappedPosition = null;
var screen = Screens.ScreenFromWindow(this) ?? Screens.Primary;
if (screen is null)
{
return false;
}
var scaling = Math.Max(0.1, screen.Scaling);
var workArea = screen.WorkingArea;
var viewportSize = new Size(workArea.Width / scaling, workArea.Height / scaling);
var adapter = new FusedDesktopEditGridAdapter(_settingsFacade);
if (!adapter.TryCreate(viewportSize, out var context))
{
return false;
}
var requestedLocalOrigin = new Point(
(requestedPosition.X - workArea.X) / scaling,
(requestedPosition.Y - workArea.Y) / scaling);
var localPlacement = placement.Clone();
localPlacement.X = requestedLocalOrigin.X;
localPlacement.Y = requestedLocalOrigin.Y;
var snappedLocalPlacement = FusedDesktopPlacementMath.SnapToNearestCell(
localPlacement,
context.Geometry,
requestedLocalOrigin);
placement.Width = snappedLocalPlacement.Width;
placement.Height = snappedLocalPlacement.Height;
placement.GridColumn = snappedLocalPlacement.GridColumn;
placement.GridRow = snappedLocalPlacement.GridRow;
placement.GridWidthCells = snappedLocalPlacement.GridWidthCells;
placement.GridHeightCells = snappedLocalPlacement.GridHeightCells;
snappedPosition = new PixelPoint(
workArea.X + (int)Math.Round(snappedLocalPlacement.X * scaling),
workArea.Y + (int)Math.Round(snappedLocalPlacement.Y * scaling));
placement.X = snappedPosition.Value.X;
placement.Y = snappedPosition.Value.Y;
UpdateComponentLayout(placement.Width, placement.Height);
return true;
}
private void ShowContextMenu(PointerPressedEventArgs e)
{
var removeItem = new MenuItem

View File

@@ -146,15 +146,22 @@
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"/>
<Border Grid.Row="2"
<Border x:Name="PreviewInteractionHost"
Grid.Row="2"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1"
Width="390"
Height="230"
Focusable="True"
HorizontalAlignment="Center"
VerticalAlignment="Center">
VerticalAlignment="Center"
PointerPressed="OnPreviewPointerPressed"
PointerReleased="OnPreviewPointerReleased"
PointerCaptureLost="OnPreviewPointerCaptureLost"
PointerWheelChanged="OnPreviewPointerWheelChanged"
KeyDown="OnPreviewKeyDown">
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using FluentIcons.Common;
@@ -18,6 +19,8 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
{
public event EventHandler<string>? AddComponentRequested;
private const double PreviewSwipeThreshold = 48d;
private static readonly LocalizationService LocalizationService = new();
private readonly ComponentLibraryWindowViewModel _viewModel = new();
@@ -28,9 +31,13 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private List<DesktopComponentDefinition> _allDefinitions = new();
private IReadOnlyList<DesktopComponentDefinition> _selectedCategoryDefinitions = [];
private int _selectedComponentIndex;
private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private Control? _selectedPreviewControl;
private bool _isPreviewSwipeActive;
private Point _previewSwipeStartPoint;
public FusedDesktopComponentLibraryControl()
{
@@ -157,17 +164,114 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
.Where(definition => string.Equals(definition.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase);
var firstComponent = filtered.FirstOrDefault();
if (firstComponent is null)
_selectedCategoryDefinitions = filtered.ToList();
_selectedComponentIndex = 0;
ApplySelectedComponentIndex();
}
private void ApplySelectedComponentIndex()
{
if (_selectedCategoryDefinitions.Count == 0)
{
_viewModel.SelectedComponent = null;
SetSelectedPreviewControl(null);
return;
}
_viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id)
?? CreateComponentItem(firstComponent, _settingsFacade.Region.Get().LanguageCode);
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent));
_selectedComponentIndex = NormalizeComponentIndex(_selectedComponentIndex);
var selectedDefinition = _selectedCategoryDefinitions[_selectedComponentIndex];
_viewModel.SelectedComponent = CreateComponentItem(selectedDefinition, _settingsFacade.Region.Get().LanguageCode);
SetSelectedPreviewControl(CreateStaticPreviewControl(selectedDefinition));
}
private int NormalizeComponentIndex(int index)
{
if (_selectedCategoryDefinitions.Count == 0)
{
return 0;
}
var count = _selectedCategoryDefinitions.Count;
return ((index % count) + count) % count;
}
private void MoveSelectedComponent(int direction)
{
if (_selectedCategoryDefinitions.Count <= 1 || direction == 0)
{
return;
}
_selectedComponentIndex = NormalizeComponentIndex(_selectedComponentIndex + direction);
ApplySelectedComponentIndex();
}
private void OnPreviewPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
_isPreviewSwipeActive = true;
_previewSwipeStartPoint = e.GetPosition(this);
PreviewInteractionHost.Focus();
e.Pointer.Capture(PreviewInteractionHost);
}
}
private void OnPreviewPointerReleased(object? sender, PointerReleasedEventArgs e)
{
_ = sender;
if (!_isPreviewSwipeActive)
{
return;
}
_isPreviewSwipeActive = false;
e.Pointer.Capture(null);
var endPoint = e.GetPosition(this);
var delta = endPoint - _previewSwipeStartPoint;
if (Math.Abs(delta.Y) < PreviewSwipeThreshold || Math.Abs(delta.Y) <= Math.Abs(delta.X))
{
return;
}
MoveSelectedComponent(delta.Y < 0 ? 1 : -1);
e.Handled = true;
}
private void OnPreviewPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
{
_ = sender;
_ = e;
_isPreviewSwipeActive = false;
}
private void OnPreviewPointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
_ = sender;
if (Math.Abs(e.Delta.Y) <= 0)
{
return;
}
MoveSelectedComponent(e.Delta.Y < 0 ? 1 : -1);
e.Handled = true;
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
_ = sender;
if (e.Key == Key.Down)
{
MoveSelectedComponent(1);
e.Handled = true;
}
else if (e.Key == Key.Up)
{
MoveSelectedComponent(-1);
e.Handled = true;
}
}
private Control? CreateStaticPreviewControl(DesktopComponentDefinition definition)

View File

@@ -65,9 +65,8 @@ public partial class FusedDesktopComponentLibraryWindow : Window
private void OnAddComponentRequested(object? sender, string componentId)
{
FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId);
FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId, this);
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' directly to fused desktop.");
Close();
}
private void OnCloseClick(object? sender, RoutedEventArgs e)

View File

@@ -6,10 +6,12 @@ param(
[string]$Version = "",
[string]$PublishDir = "",
[string]$InstallerOutputDir = "",
[string]$OnlineInstallerOutputDir = "",
[string]$ArchiveOutputDir = "",
[string]$InnoScript = "",
[string]$InnoCompiler = "",
[switch]$SkipInstaller,
[switch]$SkipOnlineInstaller,
[switch]$SkipArchive,
[switch]$KeepSymbols
)
@@ -428,6 +430,35 @@ if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) {
}
[System.IO.Directory]::CreateDirectory($InstallerOutputDir) | Out-Null
if (-not $SkipOnlineInstaller) {
if (-not $OnlineInstallerOutputDir) {
$OnlineInstallerOutputDir = Join-Path $repoRoot "artifacts/installer-online/$RuntimeIdentifier"
}
if (-not [System.IO.Path]::IsPathRooted($OnlineInstallerOutputDir)) {
$OnlineInstallerOutputDir = Join-Path $repoRoot $OnlineInstallerOutputDir
}
Clear-DirectoryContents -TargetDirectory $OnlineInstallerOutputDir
$onlineInstallerProject = Join-Path $repoRoot "LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj"
Write-Host "Publishing PLONDS online installer..."
$onlineInstallerArgs = @(
"publish",
$onlineInstallerProject,
"-c", $Configuration,
"-r", $RuntimeIdentifier,
"-p:Version=$Version",
"-p:PublishAot=true",
"-o", $OnlineInstallerOutputDir
)
& dotnet @onlineInstallerArgs
if ($LASTEXITCODE -ne 0) {
throw "Online installer publish failed with exit code $LASTEXITCODE."
}
Write-Host "Online installer published: $OnlineInstallerOutputDir"
}
if ($SkipInstaller) {
Write-Host "Publish completed. Installer step skipped."
Write-Host "Published files: $PublishDir"