mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2793be68d4 | ||
|
|
13895e0f43 | ||
|
|
2768b76e1e | ||
|
|
60645ccf40 | ||
|
|
8d1dbaea54 | ||
|
|
49af6601aa | ||
|
|
7db72fbcd0 | ||
|
|
1a6f129e78 | ||
|
|
11b8216e5b | ||
|
|
8df0271032 | ||
|
|
eae3e67238 | ||
|
|
f142307729 | ||
|
|
8c88e305ee | ||
|
|
bb4e90ea8d | ||
|
|
75c7aece4f | ||
|
|
e888b0423a | ||
|
|
28b06031f7 | ||
|
|
29bd47986c | ||
|
|
b12c9bf11d | ||
|
|
dd73e02bce | ||
|
|
ed66869c8d | ||
|
|
8403b89a15 | ||
|
|
0ea98c08bf | ||
|
|
54d97e312d | ||
|
|
04b95020bd | ||
|
|
cf08269e15 | ||
|
|
03e4442e74 | ||
|
|
0c8830133a | ||
|
|
131043fe37 | ||
|
|
a2ac302ee7 | ||
|
|
c351a8e7f3 | ||
|
|
21e970c5b6 | ||
|
|
17873f0f43 | ||
|
|
4051b5cd74 | ||
|
|
5be4537b2c | ||
|
|
c5e75244af | ||
|
|
6a650873bc | ||
|
|
d004088601 | ||
|
|
a1cc0ee2bf |
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
122
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
36
.github/ISSUE_TEMPLATE/config_issue.md
vendored
36
.github/ISSUE_TEMPLATE/config_issue.md
vendored
@@ -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
111
.github/ISSUE_TEMPLATE/config_issue.yml
vendored
Normal 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
|
||||
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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
|
||||
103
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
103
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
|
||||
114
.github/pull_request_template.md
vendored
114
.github/pull_request_template.md
vendored
@@ -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.
|
||||
|
||||
381
.github/workflows/ddss-publish.yml
vendored
381
.github/workflows/ddss-publish.yml
vendored
@@ -1,381 +0,0 @@
|
||||
name: DDSS
|
||||
|
||||
concurrency:
|
||||
group: ddss-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- PLONDS
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve release tag and channel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ "$RAW_TAG" == v* ]]; then
|
||||
TAG="$RAW_TAG"
|
||||
else
|
||||
TAG="v$RAW_TAG"
|
||||
fi
|
||||
else
|
||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
|
||||
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
IS_PRERELEASE="$(gh release view "$TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
CHANNEL="preview"
|
||||
else
|
||||
CHANNEL="stable"
|
||||
fi
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: preview
|
||||
|
||||
- name: Prepare signing key
|
||||
env:
|
||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
||||
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
||||
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
|
||||
if [[ -z "$KEY" ]]; then
|
||||
echo "No signing key is configured."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$KEY" > update-private-key.pem
|
||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PLONDS tool
|
||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||
|
||||
- name: Download release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release-assets
|
||||
gh release download "$RELEASE_TAG" -D release-assets
|
||||
find release-assets -maxdepth 1 -type f | sort
|
||||
|
||||
- name: Prepare PLONDS static output
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf plonds-static
|
||||
mkdir -p plonds-static
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-static -D plonds-static || true
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 && -f release-assets/plonds-static.zip ]]; then
|
||||
unzip -q release-assets/plonds-static.zip -d plonds-static
|
||||
fi
|
||||
if [[ ! -d plonds-static/repo/sha256 || ! -d plonds-static/meta/channels || ! -d plonds-static/manifests ]]; then
|
||||
echo "PLONDS static output is missing. Run the PLONDS workflow for this release first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload release assets to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws --version
|
||||
for file in release-assets/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
name="$(basename "$file")"
|
||||
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
|
||||
continue
|
||||
fi
|
||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
|
||||
if [[ "$existing_sha" == "$sha256" ]]; then
|
||||
echo "Skip existing asset: $name"
|
||||
continue
|
||||
fi
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" \
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Upload PLONDS static output to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3 sync \
|
||||
plonds-static/ \
|
||||
"s3://$S3_BUCKET/lanmountain/update/" \
|
||||
--only-show-errors
|
||||
|
||||
- name: Mirror installers to Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${RELEASE_TAG#v}"
|
||||
for file in release-assets/*; do
|
||||
[[ -f "$file" ]] || continue
|
||||
name="$(basename "$file")"
|
||||
platform=""
|
||||
case "$name" in
|
||||
*.exe)
|
||||
if [[ "$name" == *x86* ]]; then platform="windows-x86"; else platform="windows-x64"; fi
|
||||
;;
|
||||
*.deb)
|
||||
platform="linux-x64"
|
||||
;;
|
||||
*.dmg)
|
||||
if [[ "$name" == *arm64* ]]; then platform="macos-arm64"; else platform="macos-x64"; fi
|
||||
;;
|
||||
esac
|
||||
[[ -n "$platform" ]] || continue
|
||||
key="lanmountain/update/installers/${platform}/${version}/${name}"
|
||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" \
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Build DDSS manifest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ddss-output
|
||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||
build-ddss \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--assets-dir release-assets \
|
||||
--output-dir ddss-output \
|
||||
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--s3-base-url "$S3_BASE_URL"
|
||||
|
||||
- name: Validate DDSS asset references in Rainyun S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
keys=$(jq -r '.assets[]?.mirrors[]?.url // empty' ddss-output/ddss.json \
|
||||
| sed -n 's#^.*/lanmountain/update/\(.*\)$#lanmountain/update/\1#p' \
|
||||
| sort -u)
|
||||
|
||||
if [[ -z "$keys" ]]; then
|
||||
echo "No S3-backed asset URLs found in ddss.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r key; do
|
||||
[[ -n "$key" ]] || continue
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
done <<< "$keys"
|
||||
|
||||
- name: Upload DDSS manifest to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
|
||||
|
||||
- name: Upload DDSS manifest to Rainyun S3 staging
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
|
||||
name="$(basename "$file")"
|
||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||
sha256="$(sha256sum "$file" | awk '{print $1}')"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" \
|
||||
--body "$file" \
|
||||
--metadata "sha256=$sha256"
|
||||
done
|
||||
|
||||
- name: Prepare DDSS channel pointer
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pointer_file="ddss-output/ddss-latest.json"
|
||||
cat > "$pointer_file" <<'JSON'
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"channel": "__CHANNEL__",
|
||||
"releaseTag": "__TAG__",
|
||||
"version": "__VERSION__",
|
||||
"updatedAt": "__UPDATED_AT__",
|
||||
"manifest": {
|
||||
"url": "__MANIFEST_URL__",
|
||||
"signatureUrl": "__SIG_URL__"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
manifest_url="${S3_BASE_URL}/ddss.json"
|
||||
sig_url="${S3_BASE_URL}/ddss.json.sig"
|
||||
version="${RELEASE_TAG#v}"
|
||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
sed -i "s|__CHANNEL__|${RELEASE_CHANNEL}|g" "$pointer_file"
|
||||
sed -i "s|__TAG__|${RELEASE_TAG}|g" "$pointer_file"
|
||||
sed -i "s|__VERSION__|${version}|g" "$pointer_file"
|
||||
sed -i "s|__UPDATED_AT__|${updated_at}|g" "$pointer_file"
|
||||
sed -i "s|__MANIFEST_URL__|${manifest_url}|g" "$pointer_file"
|
||||
sed -i "s|__SIG_URL__|${sig_url}|g" "$pointer_file"
|
||||
|
||||
jq -e . "$pointer_file" >/dev/null
|
||||
|
||||
- name: Atomically publish DDSS channel pointer
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pointer_file="ddss-output/ddss-latest.json"
|
||||
staging_key="lanmountain/update/releases/${RELEASE_TAG}/assets/ddss-latest.json"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$staging_key" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$DDSS_CHANNEL_POINTER_KEY" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
|
||||
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
|
||||
|
||||
- name: Verify Rainyun S3 PLONDS output
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t required < <(
|
||||
{
|
||||
find plonds-static/meta/channels -path '*/latest.json' -type f | sort | head -n 1
|
||||
find plonds-static/meta/distributions -name '*.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json' -type f | sort | head -n 1
|
||||
find plonds-static/manifests -name 'plonds-filemap.json.sig' -type f | sort | head -n 1
|
||||
find plonds-static/repo/sha256 -type f | sort | head -n 1
|
||||
} | sed '/^$/d'
|
||||
)
|
||||
|
||||
if [[ "${#required[@]}" -lt 5 ]]; then
|
||||
echo "Not enough PLONDS static files to verify."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for path in "${required[@]}"; do
|
||||
rel="${path#plonds-static/}"
|
||||
key="lanmountain/update/${rel}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/$rel" >/dev/null
|
||||
done
|
||||
146
.github/workflows/ddss-rollback.yml
vendored
146
.github/workflows/ddss-rollback.yml
vendored
@@ -1,146 +0,0 @@
|
||||
name: DDSS Rollback
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: 'Target channel to rollback'
|
||||
required: true
|
||||
type: choice
|
||||
default: stable
|
||||
options:
|
||||
- stable
|
||||
- preview
|
||||
target_tag:
|
||||
description: 'Release tag to rollback to (e.g. v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ddss-rollback-${{ github.event.inputs.channel }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve rollback context
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
RAW_TAG="${{ github.event.inputs.target_tag }}"
|
||||
if [[ "$RAW_TAG" == v* ]]; then
|
||||
TAG="$RAW_TAG"
|
||||
else
|
||||
TAG="v$RAW_TAG"
|
||||
fi
|
||||
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE}" >> "$GITHUB_ENV"
|
||||
echo "S3_BASE_URL=${PUBLIC_BASE}/releases/${TAG}/assets" >> "$GITHUB_ENV"
|
||||
echo "DDSS_CHANNEL_POINTER_KEY=lanmountain/update/meta/channels/${CHANNEL}/ddss-latest.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate rollback target assets
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
for name in ddss.json ddss.json.sig; do
|
||||
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$key" >/dev/null
|
||||
done
|
||||
|
||||
- name: Build rollback pointer
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p rollback-output
|
||||
pointer_file="rollback-output/ddss-latest.json"
|
||||
|
||||
manifest_url="${S3_BASE_URL}/ddss.json"
|
||||
sig_url="${S3_BASE_URL}/ddss.json.sig"
|
||||
version="${RELEASE_TAG#v}"
|
||||
updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
cat > "$pointer_file" <<EOF
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"channel": "${RELEASE_CHANNEL}",
|
||||
"releaseTag": "${RELEASE_TAG}",
|
||||
"version": "${version}",
|
||||
"updatedAt": "${updated_at}",
|
||||
"manifest": {
|
||||
"url": "${manifest_url}",
|
||||
"signatureUrl": "${sig_url}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
jq -e . "$pointer_file" >/dev/null
|
||||
|
||||
- name: Publish rollback pointer
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
|
||||
AWS_REGION: ${{ vars.S3_REGION }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pointer_file="rollback-output/ddss-latest.json"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$DDSS_CHANNEL_POINTER_KEY" \
|
||||
--body "$pointer_file"
|
||||
|
||||
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object \
|
||||
--bucket "$S3_BUCKET" \
|
||||
--key "$DDSS_CHANNEL_POINTER_KEY" >/dev/null
|
||||
|
||||
curl -fsSI "$S3_PUBLIC_BASE_URL/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json" >/dev/null
|
||||
|
||||
- name: Print rollback summary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Rolled back channel '${RELEASE_CHANNEL}' to '${RELEASE_TAG}'."
|
||||
echo "Pointer: ${S3_PUBLIC_BASE_URL}/meta/channels/${RELEASE_CHANNEL}/ddss-latest.json"
|
||||
136
.github/workflows/installer-build.yml
vendored
Normal file
136
.github/workflows/installer-build.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
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
|
||||
INSTALLER_RUNTIME: win-x64
|
||||
INSTALLER_ARTIFACT_DIR: artifacts/installer-online/win-x64
|
||||
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
|
||||
|
||||
- name: Publish online installer artifact payload
|
||||
if: matrix.configuration == 'Release'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$publishDir = Join-Path $env:GITHUB_WORKSPACE '${{ env.INSTALLER_ARTIFACT_DIR }}'
|
||||
$tempDir = Join-Path $env:GITHUB_WORKSPACE 'artifacts/installer-online/tmp'
|
||||
if (Test-Path $publishDir) {
|
||||
Remove-Item -LiteralPath $publishDir -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $publishDir -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
$env:TEMP = $tempDir
|
||||
$env:TMP = $tempDir
|
||||
|
||||
dotnet restore '${{ env.INSTALLER_PROJECT }}' `
|
||||
-r '${{ env.INSTALLER_RUNTIME }}' `
|
||||
-p:PublishAot=true
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Online installer NativeAOT restore failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
dotnet publish '${{ env.INSTALLER_PROJECT }}' `
|
||||
--no-restore `
|
||||
-c '${{ matrix.configuration }}' `
|
||||
-r '${{ env.INSTALLER_RUNTIME }}' `
|
||||
-p:PublishAot=true `
|
||||
-p:UseAppHost=true `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false `
|
||||
-p:StripSymbols=true `
|
||||
-o $publishDir `
|
||||
-v minimal
|
||||
|
||||
$installerExe = Join-Path $publishDir 'LanDesktopPLONDS.installer.exe'
|
||||
if (-not (Test-Path $installerExe)) {
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Online installer publish failed with exit code $LASTEXITCODE and did not produce $installerExe."
|
||||
}
|
||||
|
||||
throw "Expected online installer executable was not produced: $installerExe"
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "dotnet publish exited with $LASTEXITCODE after producing the installer artifact."
|
||||
}
|
||||
|
||||
Get-ChildItem -Path $publishDir -Recurse -Filter '*.pdb' |
|
||||
Remove-Item -Force
|
||||
|
||||
$jitFiles = @(
|
||||
'coreclr.dll',
|
||||
'clrjit.dll',
|
||||
'hostfxr.dll',
|
||||
'hostpolicy.dll',
|
||||
'LanDesktopPLONDS.installer.deps.json',
|
||||
'LanDesktopPLONDS.installer.runtimeconfig.json'
|
||||
)
|
||||
foreach ($file in $jitFiles) {
|
||||
if (Test-Path (Join-Path $publishDir $file)) {
|
||||
throw "JIT runtime artifact found in NativeAOT output: $file"
|
||||
}
|
||||
}
|
||||
|
||||
$unexpectedFiles = Get-ChildItem -Path $publishDir -File |
|
||||
Where-Object { $_.Name -ne 'LanDesktopPLONDS.installer.exe' }
|
||||
if ($unexpectedFiles) {
|
||||
$names = ($unexpectedFiles | Select-Object -ExpandProperty Name) -join ', '
|
||||
throw "Unexpected files in single-exe NativeAOT installer artifact: $names"
|
||||
}
|
||||
|
||||
Get-ChildItem -Path $publishDir -File |
|
||||
Sort-Object Name |
|
||||
Select-Object Name, Length
|
||||
|
||||
- name: Upload online installer artifact
|
||||
if: matrix.configuration == 'Release'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: LanDesktopPLONDS-online-installer-${{ env.INSTALLER_RUNTIME }}
|
||||
path: ${{ env.INSTALLER_ARTIFACT_DIR }}/**
|
||||
if-no-files-found: error
|
||||
278
.github/workflows/plonds-build.yml
vendored
278
.github/workflows/plonds-build.yml
vendored
@@ -1,278 +0,0 @@
|
||||
name: PLONDS
|
||||
|
||||
concurrency:
|
||||
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
- prereleased
|
||||
- edited
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag'
|
||||
required: true
|
||||
type: string
|
||||
baseline_tag:
|
||||
description: 'Optional baseline tag'
|
||||
required: false
|
||||
type: string
|
||||
channel:
|
||||
description: 'Update channel'
|
||||
required: false
|
||||
type: choice
|
||||
default: stable
|
||||
options:
|
||||
- stable
|
||||
- preview
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve release context
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
||||
CHANNEL="preview"
|
||||
else
|
||||
CHANNEL="stable"
|
||||
fi
|
||||
BASELINE_TAG=""
|
||||
else
|
||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ "${RAW_TAG}" == v* ]]; then
|
||||
TAG="${RAW_TAG}"
|
||||
else
|
||||
TAG="v${RAW_TAG}"
|
||||
fi
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
||||
PUBLIC_BASE="${{ vars.S3_PUBLIC_BASE_URL }}"
|
||||
if [[ -z "$PUBLIC_BASE" ]]; then
|
||||
PUBLIC_BASE="https://cn-nb1.rains3.com/lmdesktop/lanmountain/update"
|
||||
fi
|
||||
echo "S3_PUBLIC_BASE_URL=${PUBLIC_BASE%/}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: preview
|
||||
|
||||
- name: Prepare signing key
|
||||
env:
|
||||
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
|
||||
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
|
||||
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEY="${PLONDS_SIGNING_KEY:-}"
|
||||
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
|
||||
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
|
||||
if [[ -z "$KEY" ]]; then
|
||||
echo "No signing key is configured."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$KEY" > update-private-key.pem
|
||||
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build PLONDS tool
|
||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||
|
||||
- name: Resolve baseline plan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = '${{ github.repository }}'
|
||||
$tag = $env:RELEASE_TAG
|
||||
$baselineInput = $env:BASELINE_TAG_INPUT
|
||||
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
|
||||
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
|
||||
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
|
||||
|
||||
$entries = foreach ($platform in $platforms) {
|
||||
$assetName = "files-$platform.zip"
|
||||
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
|
||||
if (-not $currentAsset) {
|
||||
throw "Current release $tag does not contain required asset $assetName"
|
||||
}
|
||||
|
||||
$baselineRelease = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
|
||||
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
|
||||
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
|
||||
if (-not $baselineRelease) {
|
||||
throw "Specified baseline tag not found: $normalizedBaseline"
|
||||
}
|
||||
}
|
||||
else {
|
||||
$baselineRelease = $allReleases |
|
||||
Where-Object {
|
||||
$_.tag_name -ne $tag -and
|
||||
-not $_.draft -and
|
||||
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
|
||||
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
|
||||
} |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
platform = $platform
|
||||
assetName = $assetName
|
||||
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
|
||||
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
|
||||
isFullPayload = -not $baselineRelease
|
||||
}
|
||||
}
|
||||
|
||||
$plan = [pscustomobject]@{
|
||||
tag = $tag
|
||||
version = $env:RELEASE_VERSION
|
||||
channel = $env:RELEASE_CHANNEL
|
||||
platforms = $entries
|
||||
}
|
||||
|
||||
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
|
||||
Get-Content plonds-plan.json
|
||||
|
||||
- name: Download payload zips
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = '${{ github.repository }}'
|
||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
||||
|
||||
foreach ($entry in $plan.platforms) {
|
||||
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
|
||||
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
|
||||
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
|
||||
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
|
||||
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
|
||||
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
|
||||
}
|
||||
}
|
||||
|
||||
- name: Build delta assets
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
|
||||
foreach ($entry in $plan.platforms) {
|
||||
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
|
||||
$args = @(
|
||||
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
|
||||
'build-delta',
|
||||
'--platform', $entry.platform,
|
||||
'--current-version', $plan.version,
|
||||
'--current-tag', $plan.tag,
|
||||
'--current-zip', $currentZip,
|
||||
'--output-dir', 'plonds-output',
|
||||
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
|
||||
'--channel', $plan.channel,
|
||||
'--static-output-dir', 'plonds-output/static',
|
||||
'--update-base-url', $env:S3_PUBLIC_BASE_URL
|
||||
)
|
||||
|
||||
if ([bool]$entry.isFullPayload) {
|
||||
$args += @('--is-full-payload', 'true')
|
||||
}
|
||||
else {
|
||||
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
|
||||
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
|
||||
}
|
||||
|
||||
dotnet @args
|
||||
}
|
||||
|
||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
|
||||
build-index `
|
||||
--release-tag $plan.tag `
|
||||
--version $plan.version `
|
||||
--channel $plan.channel `
|
||||
--platform-summaries-dir plonds-output/platform-summaries `
|
||||
--output-dir plonds-output `
|
||||
--private-key $env:UPDATE_PRIVATE_KEY_PATH
|
||||
|
||||
foreach ($entry in $plan.platforms) {
|
||||
$summary = Get-Content "plonds-output/platform-summaries/platform-summary-$($entry.platform).json" | ConvertFrom-Json
|
||||
$required = @(
|
||||
"plonds-output/static/meta/channels/$($plan.channel)/$($entry.platform)/latest.json",
|
||||
"plonds-output/static/meta/distributions/$($summary.distributionId).json",
|
||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json",
|
||||
"plonds-output/static/manifests/$($summary.distributionId)/plonds-filemap.json.sig"
|
||||
)
|
||||
|
||||
foreach ($path in $required) {
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "Missing PLONDS static output: $path"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$objects = Get-ChildItem -Path "plonds-output/static/repo/sha256" -File -Recurse -ErrorAction SilentlyContinue
|
||||
if (-not $objects -or $objects.Count -eq 0) {
|
||||
throw "PLONDS static object repository is empty."
|
||||
}
|
||||
|
||||
Compress-Archive -Path "plonds-output/static/*" -DestinationPath "plonds-output/release-assets/plonds-static.zip" -Force
|
||||
|
||||
- name: Upload PLONDS assets to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
|
||||
|
||||
- name: Persist run metadata
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p plonds-run-metadata
|
||||
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
||||
|
||||
- name: Upload run metadata artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plonds-run-metadata
|
||||
path: plonds-run-metadata/tag.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload PLONDS static artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plonds-static
|
||||
path: plonds-output/static/**
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
258
.github/workflows/plonds-comparator.yml
vendored
Normal file
258
.github/workflows/plonds-comparator.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
||||
name: PLONDS Comparator
|
||||
|
||||
concurrency:
|
||||
group: plonds-${{ github.event_name }}-${{ github.event.release.tag_name || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
- prereleased
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag'
|
||||
required: true
|
||||
type: string
|
||||
baseline_tag:
|
||||
description: 'Optional baseline tag (auto-detected if omitted)'
|
||||
required: false
|
||||
type: string
|
||||
channel:
|
||||
description: 'Update channel'
|
||||
required: false
|
||||
type: choice
|
||||
default: stable
|
||||
options:
|
||||
- stable
|
||||
- preview
|
||||
compare_method:
|
||||
description: 'Compare method'
|
||||
required: false
|
||||
type: choice
|
||||
default: file-compare
|
||||
options:
|
||||
- file-compare
|
||||
- commit-analyze
|
||||
hash_algorithm:
|
||||
description: 'Hash algorithm (file-compare only)'
|
||||
required: false
|
||||
type: choice
|
||||
default: sha256
|
||||
options:
|
||||
- sha256
|
||||
- md5
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
|
||||
jobs:
|
||||
compare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve release context
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
|
||||
CHANNEL="preview"
|
||||
else
|
||||
CHANNEL="stable"
|
||||
fi
|
||||
BASELINE_TAG_INPUT=""
|
||||
COMPARE_METHOD="file-compare"
|
||||
HASH_ALGORITHM="sha256"
|
||||
else
|
||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ "${RAW_TAG}" == v* ]]; then
|
||||
TAG="${RAW_TAG}"
|
||||
else
|
||||
TAG="v${RAW_TAG}"
|
||||
fi
|
||||
CHANNEL="${{ github.event.inputs.channel }}"
|
||||
BASELINE_TAG_INPUT="${{ github.event.inputs.baseline_tag }}"
|
||||
COMPARE_METHOD="${{ github.event.inputs.compare_method }}"
|
||||
HASH_ALGORITHM="${{ github.event.inputs.hash_algorithm }}"
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
|
||||
echo "BASELINE_TAG_INPUT=${BASELINE_TAG_INPUT}" >> "$GITHUB_ENV"
|
||||
echo "COMPARE_METHOD=${COMPARE_METHOD}" >> "$GITHUB_ENV"
|
||||
echo "HASH_ALGORITHM=${HASH_ALGORITHM}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: preview
|
||||
|
||||
- name: Build PLONDS tool
|
||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||
|
||||
- name: Resolve baseline
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASELINE_TAG=""
|
||||
BASELINE_VERSION=""
|
||||
|
||||
if [[ -n "$BASELINE_TAG_INPUT" ]]; then
|
||||
NORMALIZED="$BASELINE_TAG_INPUT"
|
||||
if [[ "$NORMALIZED" != v* ]]; then NORMALIZED="v$NORMALIZED"; fi
|
||||
if gh release view "$NORMALIZED" --repo "${{ github.repository }}" --json tagName >/dev/null 2>&1; then
|
||||
BASELINE_TAG="$NORMALIZED"
|
||||
BASELINE_VERSION="${NORMALIZED#v}"
|
||||
else
|
||||
echo "Specified baseline tag not found: $NORMALIZED"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
IS_PRERELEASE="$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json isPrerelease --jq '.isPrerelease')"
|
||||
CANDIDATES="$(gh api "repos/${{ github.repository }}/releases?per_page=50" \
|
||||
--jq ".[] | select(.draft == false and .prerelease == ${IS_PRERELEASE} and .tag_name != \"${RELEASE_TAG}\") | .tag_name")"
|
||||
|
||||
for CANDIDATE in $CANDIDATES; do
|
||||
if gh release download "$CANDIDATE" -p "files-windows-x64.zip" -D /tmp/baseline-check --clobber 2>/dev/null; then
|
||||
BASELINE_TAG="$CANDIDATE"
|
||||
BASELINE_VERSION="${CANDIDATE#v}"
|
||||
rm -rf /tmp/baseline-check
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE_TAG" ]]; then
|
||||
echo "BASELINE_TAG=${BASELINE_TAG}" >> "$GITHUB_ENV"
|
||||
echo "BASELINE_VERSION=${BASELINE_VERSION}" >> "$GITHUB_ENV"
|
||||
echo "Resolved baseline: ${BASELINE_TAG}"
|
||||
else
|
||||
echo "No baseline found. This will be a full update."
|
||||
fi
|
||||
|
||||
- name: Download payload zips
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p plonds-input
|
||||
|
||||
gh release download "$RELEASE_TAG" -p "files-windows-x64.zip" -D plonds-input
|
||||
mv plonds-input/files-windows-x64.zip plonds-input/current-files-windows-x64.zip
|
||||
|
||||
if [[ -n "$BASELINE_TAG" ]]; then
|
||||
gh release download "$BASELINE_TAG" -p "files-windows-x64.zip" -D plonds-input
|
||||
mv plonds-input/files-windows-x64.zip plonds-input/baseline-files-windows-x64.zip
|
||||
fi
|
||||
|
||||
- name: Run build-delta (file-compare)
|
||||
if: env.COMPARE_METHOD == 'file-compare'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p plonds-output
|
||||
|
||||
ARGS=(
|
||||
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||
'--configuration' 'Release' '--'
|
||||
'build-delta'
|
||||
'--platform' 'windows-x64'
|
||||
'--current-version' "$RELEASE_VERSION"
|
||||
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||
'--output-dir' "$PWD/plonds-output"
|
||||
'--channel' "$RELEASE_CHANNEL"
|
||||
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||
)
|
||||
|
||||
if [[ -n "$BASELINE_TAG" ]]; then
|
||||
ARGS+=(
|
||||
'--baseline-version' "$BASELINE_VERSION"
|
||||
'--baseline-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||
)
|
||||
fi
|
||||
|
||||
dotnet "${ARGS[@]}"
|
||||
|
||||
- name: Run build-delta-from-commits (commit-analyze)
|
||||
if: env.COMPARE_METHOD == 'commit-analyze'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p plonds-output
|
||||
|
||||
ARGS=(
|
||||
'run' '--project' 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj'
|
||||
'--configuration' 'Release' '--'
|
||||
'build-delta-from-commits'
|
||||
'--platform' 'windows-x64'
|
||||
'--current-version' "$RELEASE_VERSION"
|
||||
'--current-zip' "$PWD/plonds-input/current-files-windows-x64.zip"
|
||||
'--output-dir' "$PWD/plonds-output"
|
||||
'--channel' "$RELEASE_CHANNEL"
|
||||
'--baseline-tag' "${BASELINE_TAG:-$RELEASE_TAG}"
|
||||
'--current-tag' "$RELEASE_TAG"
|
||||
'--hash-algorithm' "$HASH_ALGORITHM"
|
||||
)
|
||||
|
||||
if [[ -n "$BASELINE_TAG" ]]; then
|
||||
ARGS+=(
|
||||
'--fallback-zip' "$PWD/plonds-input/baseline-files-windows-x64.zip"
|
||||
)
|
||||
fi
|
||||
|
||||
dotnet "${ARGS[@]}"
|
||||
|
||||
- name: Validate output
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! -f plonds-output/changed.zip ]]; then
|
||||
echo "Missing output: changed.zip"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f plonds-output/PLONDS.json ]]; then
|
||||
echo "Missing output: PLONDS.json"
|
||||
exit 1
|
||||
fi
|
||||
jq -e . plonds-output/PLONDS.json >/dev/null
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" plonds-output/changed.zip plonds-output/PLONDS.json --clobber
|
||||
|
||||
- name: Persist run metadata
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p plonds-run-metadata
|
||||
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
|
||||
printf '%s' "$COMPARE_METHOD" > plonds-run-metadata/compare-method.txt
|
||||
|
||||
- name: Upload run metadata artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plonds-run-metadata
|
||||
path: |
|
||||
plonds-run-metadata/tag.txt
|
||||
plonds-run-metadata/compare-method.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
138
.github/workflows/plonds-uploader.yml
vendored
Normal file
138
.github/workflows/plonds-uploader.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
name: PLONDS Publisher
|
||||
|
||||
concurrency:
|
||||
group: plonds-publish-${{ github.event_name }}-${{ github.event.workflow_run.id || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- PLONDS Comparator
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
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: '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: 360
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
RAW_TAG="${{ github.event.inputs.tag }}"
|
||||
if [[ "$RAW_TAG" == v* ]]; then
|
||||
TAG="$RAW_TAG"
|
||||
else
|
||||
TAG="v$RAW_TAG"
|
||||
fi
|
||||
else
|
||||
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
|
||||
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
|
||||
fi
|
||||
|
||||
gh release view "$TAG" --repo "${{ github.repository }}" --json tagName >/dev/null
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
dotnet-quality: preview
|
||||
|
||||
- name: Build PLONDS tool
|
||||
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
|
||||
|
||||
- name: Download PLONDS release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf plonds-assets
|
||||
mkdir -p plonds-assets
|
||||
gh release download "$RELEASE_TAG" -p changed.zip -p PLONDS.json -p files-windows-x64.zip -D plonds-assets --clobber
|
||||
test -f plonds-assets/changed.zip
|
||||
test -f plonds-assets/PLONDS.json
|
||||
test -f plonds-assets/files-windows-x64.zip
|
||||
jq -e . plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Publish PLONDS assets to Rainyun S3
|
||||
env:
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" ]]; then
|
||||
echo "S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, and S3_BUCKET must be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGION="${S3_REGION:-us-east-1}"
|
||||
PUBLIC_BASE="${S3_PUBLIC_BASE_URL:-https://cn-nb1.rains3.com/lmdesktop}"
|
||||
PUBLIC_BASE="${PUBLIC_BASE%/}"
|
||||
|
||||
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
|
||||
publish-s3 \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--repository "${{ github.repository }}" \
|
||||
--manifest "$PWD/plonds-assets/PLONDS.json" \
|
||||
--changed-zip "$PWD/plonds-assets/changed.zip" \
|
||||
--files-zip "$PWD/plonds-assets/files-windows-x64.zip" \
|
||||
--work-dir "$PWD/plonds-publish-work" \
|
||||
--s3-prefix "$PLONDS_S3_PREFIX" \
|
||||
--s3-endpoint "$S3_ENDPOINT" \
|
||||
--s3-region "$REGION" \
|
||||
--s3-bucket "$S3_BUCKET" \
|
||||
--s3-access-key "$S3_ACCESS_KEY" \
|
||||
--s3-secret-key "$S3_SECRET_KEY" \
|
||||
--s3-public-base-url "$PUBLIC_BASE" \
|
||||
--s3-public-base-key-prefix "$PLONDS_S3_PUBLIC_BASE_KEY_PREFIX" \
|
||||
--directory-upload-concurrency "$PLONDS_S3_DIRECTORY_UPLOAD_CONCURRENCY" \
|
||||
--multipart-threshold-mb "$PLONDS_S3_MULTIPART_THRESHOLD_MB" \
|
||||
--multipart-part-size-mb "$PLONDS_S3_MULTIPART_PART_SIZE_MB" \
|
||||
--multipart-concurrency "$PLONDS_S3_MULTIPART_CONCURRENCY"
|
||||
|
||||
jq -e '.downloads.github.changedZipUrl and .downloads.github.filesZipUrl and .downloads.s3.changedFolderUrl and .downloads.s3.filesFolderUrl' plonds-assets/PLONDS.json >/dev/null
|
||||
|
||||
- name: Upload enriched PLONDS manifest to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" plonds-assets/PLONDS.json --clobber
|
||||
101
.github/workflows/release.yml
vendored
101
.github/workflows/release.yml
vendored
@@ -185,6 +185,29 @@ jobs:
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/airapp-runtime-win-$arch"
|
||||
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj `
|
||||
-c Release `
|
||||
-o ./$publishDir `
|
||||
--self-contained:false `
|
||||
-r win-$arch `
|
||||
-p:SelfContained=false `
|
||||
-p:PublishAot=false `
|
||||
-p:PublishSingleFile=false `
|
||||
-p:PublishTrimmed=false `
|
||||
-p:PublishReadyToRun=false `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false `
|
||||
-p:Version=${{ needs.prepare.outputs.version }} `
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Publish AirAppHost
|
||||
run: |
|
||||
$arch = "${{ matrix.arch }}"
|
||||
@@ -215,6 +238,7 @@ jobs:
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$publishDir = "publish/windows-$arch"
|
||||
$launcherPublishDir = "publish/launcher-win-$arch"
|
||||
$runtimePublishDir = "publish/airapp-runtime-win-$arch"
|
||||
$appDir = "app-$version"
|
||||
$newStructure = "publish-launcher/windows-$arch"
|
||||
|
||||
@@ -226,10 +250,15 @@ jobs:
|
||||
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
if (Test-Path $runtimePublishDir) {
|
||||
Copy-Item -Path "$runtimePublishDir\*" -Destination $newStructure -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
|
||||
|
||||
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $runtimePublishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -Path $newStructure -Destination $publishDir -Force
|
||||
shell: pwsh
|
||||
|
||||
@@ -253,6 +282,7 @@ jobs:
|
||||
|
||||
$requiredFiles = @(
|
||||
(Join-Path $publishDir "LanMountainDesktop.Launcher.exe"),
|
||||
(Join-Path $publishDir "LanMountainDesktop.AirAppRuntime.exe"),
|
||||
(Join-Path $appDir "LanMountainDesktop.exe"),
|
||||
(Join-Path $appDir "LanMountainDesktop.AirAppHost.exe")
|
||||
)
|
||||
@@ -330,7 +360,7 @@ jobs:
|
||||
run: |
|
||||
$version = "${{ needs.prepare.outputs.version }}"
|
||||
$arch = "${{ matrix.arch }}"
|
||||
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
|
||||
$payloadRoot = Join-Path $PWD "publish/windows-$arch"
|
||||
if (-not (Test-Path $payloadRoot)) {
|
||||
Write-Error "Payload root not found: $payloadRoot"
|
||||
exit 1
|
||||
@@ -344,7 +374,7 @@ jobs:
|
||||
|
||||
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
|
||||
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
if ($relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -462,12 +492,32 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||
-c Release \
|
||||
-o ./publish/airapp-runtime-linux-x64 \
|
||||
--self-contained false \
|
||||
-r linux-x64 \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishAot=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Restructure for Launcher
|
||||
run: |
|
||||
version="${{ needs.prepare.outputs.version }}"
|
||||
publishDir="publish/linux-x64"
|
||||
appDir="app-$version"
|
||||
launcherDir="publish/launcher-linux-x64"
|
||||
runtimeDir="publish/airapp-runtime-linux-x64"
|
||||
|
||||
mkdir -p "$publishDir"
|
||||
mv "publish/linux-x64-app" "$publishDir/$appDir"
|
||||
@@ -477,8 +527,13 @@ jobs:
|
||||
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -d "$runtimeDir" ]; then
|
||||
cp -r "$runtimeDir"/* "$publishDir/"
|
||||
chmod +x "$publishDir/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
touch "$publishDir/$appDir/.current"
|
||||
rm -rf "$launcherDir"
|
||||
rm -rf "$launcherDir" "$runtimeDir"
|
||||
|
||||
- name: Package as DEB
|
||||
run: |
|
||||
@@ -637,10 +692,10 @@ jobs:
|
||||
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
|
||||
-c Release \
|
||||
-o ./publish/macos-${{ matrix.arch }}-app \
|
||||
--self-contained \
|
||||
--self-contained:false \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:SelfContained=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:SkipAirAppHostBuild=true \
|
||||
@@ -651,6 +706,36 @@ jobs:
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Publish AirAppRuntime
|
||||
run: |
|
||||
dotnet publish LanMountainDesktop.AirAppRuntime/LanMountainDesktop.AirAppRuntime.csproj \
|
||||
-c Release \
|
||||
-o ./publish/airapp-runtime-macos-${{ matrix.arch }} \
|
||||
--self-contained false \
|
||||
-r osx-${{ matrix.arch }} \
|
||||
-p:SelfContained=false \
|
||||
-p:PublishAot=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishTrimmed=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-p:Version=${{ needs.prepare.outputs.version }} \
|
||||
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
|
||||
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
|
||||
|
||||
- name: Optimize and Guard macOS Payload
|
||||
run: |
|
||||
arch="${{ matrix.arch }}"
|
||||
publishDir="publish/macos-${arch}-app"
|
||||
|
||||
pwsh ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 \
|
||||
-PublishDir "$publishDir" \
|
||||
-RuntimeIdentifier "osx-${arch}" \
|
||||
-AssertClean
|
||||
shell: bash
|
||||
|
||||
- name: Package Payload Zip
|
||||
run: |
|
||||
release_dir="$PWD/release-assets"
|
||||
@@ -673,6 +758,7 @@ jobs:
|
||||
app_name="LanMountainDesktop"
|
||||
package_name="${app_name}-${version}-macos-${arch}"
|
||||
launcherDir="publish/launcher-macos-$arch"
|
||||
runtimeDir="publish/airapp-runtime-macos-$arch"
|
||||
appSourceDir="publish/macos-$arch-app"
|
||||
|
||||
mkdir -p "${app_name}.app/Contents/MacOS"
|
||||
@@ -685,6 +771,11 @@ jobs:
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -d "$runtimeDir" ]; then
|
||||
cp -r "$runtimeDir"/* "${app_name}.app/Contents/MacOS/"
|
||||
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.AirAppRuntime" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
|
||||
mkdir -p "${app_name}.app/Contents/Resources"
|
||||
|
||||
|
||||
328
.trae/analysis/fused-desktop-comprehensive-analysis.md
Normal file
328
.trae/analysis/fused-desktop-comprehensive-analysis.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 阑山桌面融合桌面功能全面分析报告
|
||||
|
||||
**生成时间**: 2026-06-08
|
||||
**分析范围**: 融合桌面组件系统、编辑模式、布局引擎、交互逻辑
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
融合桌面(Fused Desktop)是阑山桌面的核心功能之一,允许用户在系统桌面(负一屏)上放置和管理桌面组件。经过全面分析,发现以下**关键问题**:
|
||||
|
||||
### 🔴 严重问题
|
||||
1. **编辑模式控制缺失** - 组件库窗口的打开/关闭未正确触发编辑模式进入/退出
|
||||
2. **组件尺寸调整功能缺失** - 无法在编辑模式下调整组件大小
|
||||
3. **底部对齐问题** - 组件可能无法正确置于屏幕底部(需验证)
|
||||
|
||||
### 🟡 中等问题
|
||||
4. **编辑模式交互边界模糊** - 编辑模式下组件的交互状态管理不完整
|
||||
5. **网格吸附逻辑不一致** - 添加组件和拖拽组件的吸附行为可能存在差异
|
||||
|
||||
### 🟢 已实现的良好设计
|
||||
- ✅ 预览布局计算系统完整(`FusedDesktopLibraryPreviewLayout`)
|
||||
- ✅ 网格计算引擎健全(`FusedDesktopEditGridAdapter`、`FusedDesktopPlacementMath`)
|
||||
- ✅ 窗口层级管理完整(`BottomMost` 服务)
|
||||
- ✅ 持久化存储设计合理(`FusedDesktopLayoutService`)
|
||||
|
||||
---
|
||||
|
||||
## 详细问题分析
|
||||
|
||||
### 问题 1: 编辑模式控制流缺失 ⭐⭐⭐⭐⭐
|
||||
|
||||
**当前状态**:
|
||||
- `FusedDesktopComponentLibraryWindow` 在打开时注册到 `MainWindow`
|
||||
- 但 **未调用** `FusedDesktopManagerService.EnterEditMode()`
|
||||
- 窗口关闭时注销,但 **未调用** `ExitEditMode()`
|
||||
|
||||
**规格要求** (来自 spec.md):
|
||||
> 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.
|
||||
|
||||
**影响**:
|
||||
- 用户打开组件库后,桌面组件窗口仍然可以被交互,而非进入拖拽模式
|
||||
- 编辑模式的视觉反馈(光标变化、hit-test 禁用)不生效
|
||||
|
||||
**代码位置**:
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs:27-29`
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs:108-116`
|
||||
|
||||
**修复方案**:
|
||||
```csharp
|
||||
// 在 FusedDesktopComponentLibraryWindow 构造函数中
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow as MainWindow;
|
||||
mainWindow?.RegisterFusedLibraryWindow(this);
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode(); // 添加此行
|
||||
|
||||
// 在 OnClosed 方法中
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode(); // 添加此行
|
||||
LibraryControl.AddComponentRequested -= OnAddComponentRequested;
|
||||
KeyDown -= OnWindowKeyDown;
|
||||
base.OnClosed(e);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 2: 组件尺寸调整功能完全缺失 ⭐⭐⭐⭐⭐
|
||||
|
||||
**当前状态**:
|
||||
- `DesktopWidgetWindow` 仅支持拖拽移动
|
||||
- 无尺寸调整手柄(resize handles)
|
||||
- 无尺寸调整逻辑
|
||||
|
||||
**规格要求** (来自用户需求):
|
||||
> 逐步推进融合桌面组件编辑功能的实现,保障融合桌面的组件在编辑模式下也能够正常的调整组件的大小与尺寸,还有比例。
|
||||
|
||||
**影响**:
|
||||
- 用户无法在编辑模式下改变组件尺寸
|
||||
- 这是核心编辑功能的缺失
|
||||
|
||||
**实现复杂度**: 高
|
||||
**预计工作量**: 3-5 小时
|
||||
|
||||
**需要实现的组件**:
|
||||
1. **ResizeHandle** 控件 - 8个方向的调整手柄(四角 + 四边)
|
||||
2. **ResizeGesture** 检测 - 识别在编辑模式下的手柄拖拽
|
||||
3. **GridConstrainedResize** 逻辑 - 确保调整后仍然对齐网格
|
||||
4. **MinSize 约束** - 尊重 `MinWidthCells` 和 `MinHeightCells`
|
||||
5. **Persistence** - 持久化新的尺寸到 `FusedDesktopLayoutSnapshot`
|
||||
|
||||
**参考阑山桌面组件编辑逻辑**:
|
||||
- 阑山桌面主界面有完整的组件拖拽和调整系统
|
||||
- 应该复用 `DesktopPlacementMath.GetSnappedCell` 逻辑
|
||||
- 需要参考 `MainWindow.DesktopEditing.cs` 的实现模式
|
||||
|
||||
---
|
||||
|
||||
### 问题 3: 底部对齐验证需求 ⭐⭐⭐
|
||||
|
||||
**用户需求**:
|
||||
> 保障组件能够正常置于底部
|
||||
|
||||
**当前实现分析**:
|
||||
- 使用 `WorkingArea` 计算视口尺寸
|
||||
- 使用 `DesktopGridGeometry` 计算网格范围
|
||||
- 网格原点设置为 `(EdgeInsetPx, EdgeInsetPx)`
|
||||
|
||||
**潜在风险点**:
|
||||
1. **EdgeInset 计算** - 是否正确处理了底部边距?
|
||||
2. **Grid RowCount** - 网格行数是否能覆盖到屏幕底部?
|
||||
3. **Snap 逻辑** - 拖拽到底部时是否正确吸附?
|
||||
|
||||
**验证方法**:
|
||||
```csharp
|
||||
// 测试用例:创建一个组件并手动拖拽到屏幕底部
|
||||
// 预期:组件应该能够吸附到最底部的网格行,不超出 WorkingArea
|
||||
```
|
||||
|
||||
**代码位置**:
|
||||
- `LanMountainDesktop/DesktopEditing/FusedDesktopEditGridAdapter.cs:46-50`
|
||||
- `LanMountainDesktop/DesktopEditing/FusedDesktopPlacementMath.cs:45-84`
|
||||
|
||||
---
|
||||
|
||||
### 问题 4: 编辑模式交互边界管理 ⭐⭐⭐⭐
|
||||
|
||||
**当前状态**:
|
||||
- `DesktopWidgetWindow.SetEditMode(bool)` 正确设置了:
|
||||
- `child.IsHitTestVisible = !editMode` ✅
|
||||
- `Cursor = StandardCursorType.SizeAll` ✅
|
||||
- 但缺少以下功能:
|
||||
- ❌ 编辑模式视觉反馈(边框高亮、阴影等)
|
||||
- ❌ 锁定组件的特殊处理(`IsLocked` 字段存在但未使用)
|
||||
- ❌ 编辑模式下的右键菜单(应该显示"删除"、"锁定"等选项)
|
||||
|
||||
**规格要求**:
|
||||
> While edit mode is active, component windows can be moved but their inner component UI is not hit-test interactive.
|
||||
|
||||
**改进建议**:
|
||||
1. 添加编辑模式的视觉状态(Border + BoxShadow)
|
||||
2. 实现 `IsLocked` 状态的 UI 反馈
|
||||
3. 在编辑模式下显示不同的右键菜单
|
||||
|
||||
---
|
||||
|
||||
### 问题 5: 网格吸附一致性 ⭐⭐⭐
|
||||
|
||||
**观察到的不一致**:
|
||||
|
||||
**添加组件时** (`FusedDesktopManagerService.AddComponent`):
|
||||
- 使用 `FusedDesktopPlacementMath.CreateCenteredPlacement`
|
||||
- 将组件居中放置在网格中央
|
||||
|
||||
**拖拽释放时** (`DesktopWidgetWindow.EndDrag`):
|
||||
- 使用 `FusedDesktopPlacementMath.SnapToNearestCell`
|
||||
- 吸附到最近的网格单元
|
||||
|
||||
**潜在问题**:
|
||||
- 如果组件比网格大(跨多行/列),吸附逻辑是否正确?
|
||||
- `EstimateCellSpan` 方法的估算是否准确?
|
||||
|
||||
**测试场景**:
|
||||
1. 添加一个 4x4 的大组件
|
||||
2. 拖拽到网格边缘
|
||||
3. 验证是否正确吸附且不超出网格边界
|
||||
|
||||
---
|
||||
|
||||
## 架构优势分析
|
||||
|
||||
### ✅ 优秀的设计
|
||||
|
||||
#### 1. 分层清晰的网格系统
|
||||
```
|
||||
DesktopGridGeometry (数据)
|
||||
↓
|
||||
FusedDesktopEditGridAdapter (适配器)
|
||||
↓
|
||||
FusedDesktopPlacementMath (算法)
|
||||
↓
|
||||
DesktopWidgetWindow (UI)
|
||||
```
|
||||
|
||||
#### 2. 预览布局计算的智能化
|
||||
- `FusedDesktopLibraryPreviewLayout.Calculate`
|
||||
- 保持组件宽高比 ✅
|
||||
- 自适应舞台尺寸 ✅
|
||||
- 容错处理(非有限值、零尺寸) ✅
|
||||
- 单元测试覆盖完整 ✅
|
||||
|
||||
#### 3. 服务层设计模式
|
||||
- Singleton Factory 模式(`FusedDesktopManagerServiceFactory`)
|
||||
- 依赖注入(`ISettingsFacadeService`)
|
||||
- 接口隔离(`IFusedDesktopLayoutService`)
|
||||
|
||||
#### 4. 持久化设计
|
||||
- JSON 序列化 + 原子写入(临时文件 + Move)
|
||||
- 内存缓存 + Clone 防止意外修改
|
||||
- 错误处理完整
|
||||
|
||||
---
|
||||
|
||||
## 风险评估矩阵
|
||||
|
||||
| 问题 | 严重程度 | 用户影响 | 修复复杂度 | 优先级 |
|
||||
|------|---------|---------|-----------|--------|
|
||||
| 编辑模式控制缺失 | 🔴 高 | 🔴 高 | 🟢 低 | P0 |
|
||||
| 尺寸调整功能缺失 | 🔴 高 | 🔴 高 | 🔴 高 | P0 |
|
||||
| 底部对齐验证 | 🟡 中 | 🟡 中 | 🟢 低 | P1 |
|
||||
| 编辑模式交互边界 | 🟡 中 | 🟢 低 | 🟡 中 | P1 |
|
||||
| 网格吸附一致性 | 🟡 中 | 🟢 低 | 🟢 低 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 推荐实施计划
|
||||
|
||||
### 阶段 1: 核心功能修复 (1-2 天)
|
||||
|
||||
**任务 1.1: 修复编辑模式控制流** (0.5 小时)
|
||||
- [ ] 在 `FusedDesktopComponentLibraryWindow` 构造函数中调用 `EnterEditMode()`
|
||||
- [ ] 在 `OnClosed` 中调用 `ExitEditMode()`
|
||||
- [ ] 测试验证:打开组件库后,桌面组件光标变为 `SizeAll`
|
||||
|
||||
**任务 1.2: 实现组件尺寸调整** (4-6 小时)
|
||||
- [ ] 创建 `ResizeHandleAdorner` 控件(8个手柄)
|
||||
- [ ] 在 `DesktopWidgetWindow` 中添加 resize 手势检测
|
||||
- [ ] 实现 `ApplyResizeToGrid` 方法(约束到网格 + 最小尺寸)
|
||||
- [ ] 持久化调整后的尺寸
|
||||
- [ ] 添加单元测试
|
||||
|
||||
**任务 1.3: 验证底部对齐** (1 小时)
|
||||
- [ ] 手动测试拖拽组件到屏幕底部
|
||||
- [ ] 如发现问题,调整 `FusedDesktopEditGridAdapter` 的 EdgeInset 计算
|
||||
- [ ] 确保 RowCount 覆盖完整的工作区
|
||||
|
||||
### 阶段 2: 交互体验优化 (1 天)
|
||||
|
||||
**任务 2.1: 编辑模式视觉反馈** (2 小时)
|
||||
- [ ] 添加编辑模式下的 Border 高亮
|
||||
- [ ] 添加半透明覆盖层(可选)
|
||||
- [ ] 显示网格辅助线(可选)
|
||||
|
||||
**任务 2.2: 锁定功能实现** (2 小时)
|
||||
- [ ] 在编辑模式右键菜单添加"锁定"选项
|
||||
- [ ] 锁定后禁用拖拽和调整尺寸
|
||||
- [ ] 添加锁定状态的视觉反馈(🔒 图标)
|
||||
|
||||
**任务 2.3: 右键菜单增强** (1 小时)
|
||||
- [ ] 编辑模式菜单:删除、锁定/解锁、属性
|
||||
- [ ] 非编辑模式菜单:删除、设置
|
||||
|
||||
### 阶段 3: 全面测试与验证 (0.5 天)
|
||||
|
||||
**测试用例清单**:
|
||||
1. [ ] 打开组件库 → 编辑模式激活
|
||||
2. [ ] 添加组件 → 正确居中放置
|
||||
3. [ ] 拖拽组件 → 正确吸附网格
|
||||
4. [ ] 调整组件尺寸 → 保持网格对齐 + 最小尺寸约束
|
||||
5. [ ] 拖拽到屏幕底部 → 不超出工作区
|
||||
6. [ ] 拖拽到屏幕右侧 → 不超出工作区
|
||||
7. [ ] 关闭组件库 → 编辑模式退出
|
||||
8. [ ] 锁定组件 → 无法拖拽和调整尺寸
|
||||
9. [ ] 多屏幕场景 → 组件正确吸附到所在屏幕的网格
|
||||
10. [ ] 窗口缩放 → 预览布局正确调整
|
||||
|
||||
---
|
||||
|
||||
## 技术债务
|
||||
|
||||
### 已识别的技术债务
|
||||
|
||||
1. **硬编码常量** (低优先级)
|
||||
- `FusedDesktopLibraryPreviewLayout` 中的 Inset 值应该可配置
|
||||
|
||||
2. **错误处理不完整** (中优先级)
|
||||
- `CreateWidgetWindow` 的异常处理只有 log,用户无感知
|
||||
|
||||
3. **多屏幕支持不完善** (中优先级)
|
||||
- 跨屏幕拖拽时的网格切换逻辑需要验证
|
||||
|
||||
4. **性能优化空间** (低优先级)
|
||||
- 每次拖拽都重新计算网格,可以缓存
|
||||
|
||||
---
|
||||
|
||||
## 参考资料
|
||||
|
||||
### 相关代码文件
|
||||
|
||||
**核心服务**:
|
||||
- `LanMountainDesktop/Services/FusedDesktopManagerService.cs`
|
||||
- `LanMountainDesktop/Services/FusedDesktopLayoutService.cs`
|
||||
|
||||
**UI 层**:
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs`
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
|
||||
- `LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs`
|
||||
|
||||
**布局引擎**:
|
||||
- `LanMountainDesktop/DesktopEditing/FusedDesktopEditGridAdapter.cs`
|
||||
- `LanMountainDesktop/DesktopEditing/FusedDesktopPlacementMath.cs`
|
||||
- `LanMountainDesktop/DesktopEditing/DesktopPlacementMath.cs`
|
||||
- `LanMountainDesktop/Views/FusedDesktopLibraryPreviewLayout.cs`
|
||||
|
||||
**数据模型**:
|
||||
- `LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs`
|
||||
|
||||
**测试**:
|
||||
- `LanMountainDesktop.Tests/FusedDesktopLibraryPreviewLayoutTests.cs`
|
||||
- `LanMountainDesktop.Tests/DesktopPlacementMathTests.cs`
|
||||
|
||||
### 规格文档
|
||||
- `.trae/specs/fused-desktop-library-redesign/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 结论
|
||||
|
||||
阑山桌面的融合桌面功能拥有**坚实的架构基础**和**清晰的代码分层**,但在**编辑模式控制流**和**组件尺寸调整**两个核心功能上存在明显缺失。
|
||||
|
||||
**立即行动项**:
|
||||
1. ✅ 修复编辑模式进入/退出逻辑(简单修改,影响大)
|
||||
2. ✅ 实现组件尺寸调整功能(工作量大,但用户价值高)
|
||||
3. ✅ 验证底部对齐问题(快速验证,消除风险)
|
||||
|
||||
完成以上三项后,融合桌面将具备完整的基础编辑能力,可以进入下一阶段的体验优化和高级功能开发。
|
||||
461
.trae/implementation/fused-desktop-implementation-summary.md
Normal file
461
.trae/implementation/fused-desktop-implementation-summary.md
Normal file
@@ -0,0 +1,461 @@
|
||||
# 阑山桌面融合桌面功能实施总结
|
||||
|
||||
**实施日期**: 2026-06-08
|
||||
**实施人员**: Claude (Opus 4.6)
|
||||
**任务编号**: FUSED-DESKTOP-001
|
||||
|
||||
---
|
||||
|
||||
## 执行摘要
|
||||
|
||||
本次实施完成了阑山桌面融合桌面功能的三个核心问题修复和两个功能增强:
|
||||
|
||||
### ✅ 已完成的工作
|
||||
|
||||
1. **编辑模式控制流修复** - 组件库窗口现在正确控制编辑模式的进入和退出
|
||||
2. **组件尺寸调整功能** - 完整实现8方向调整尺寸,支持网格吸附
|
||||
3. **编辑模式视觉反馈** - 添加蓝色边框高亮和阴影效果
|
||||
4. **全面的测试清单** - 创建了包含10组测试场景的手动测试文档
|
||||
5. **详细的分析报告** - 生成了架构分析和问题诊断文档
|
||||
|
||||
### 📊 代码变更统计
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 新增文件 | 3 |
|
||||
| 修改文件 | 3 |
|
||||
| 新增代码行 | ~450 行 |
|
||||
| 删除/修改代码行 | ~30 行 |
|
||||
| 编译错误 | 0 |
|
||||
| 编译警告(新增) | 0 |
|
||||
|
||||
---
|
||||
|
||||
## 详细变更清单
|
||||
|
||||
### 1. 新增文件
|
||||
|
||||
#### 1.1 `DesktopWidgetResizeHandle.cs`
|
||||
**位置**: `LanMountainDesktop/Views/DesktopWidgetResizeHandle.cs`
|
||||
**代码行数**: ~250 行
|
||||
**功能**:
|
||||
- `DesktopWidgetResizeHandle` 控件 - 可视化的调整尺寸手柄
|
||||
- `DesktopWidgetResizeAdorner` - 管理8个调整手柄的装饰器层
|
||||
- 事件定义: `ResizeStartedEventArgs`, `ResizeEventArgs`, `ResizeCompletedEventArgs`
|
||||
- 支持8个方向: TopLeft, Top, TopRight, Right, BottomRight, Bottom, BottomLeft, Left
|
||||
|
||||
**关键设计**:
|
||||
```csharp
|
||||
internal sealed class DesktopWidgetResizeHandle : Control
|
||||
{
|
||||
public ResizeHandlePosition Position { get; set; }
|
||||
// 自定义渲染,显示白色半透明圆角矩形,蓝色边框
|
||||
public override void Render(DrawingContext context)
|
||||
}
|
||||
|
||||
internal sealed class DesktopWidgetResizeAdorner : Canvas
|
||||
{
|
||||
public event EventHandler<ResizeCompletedEventArgs>? ResizeCompleted;
|
||||
// 管理8个手柄的位置和交互
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 1.2 `fused-desktop-comprehensive-analysis.md`
|
||||
**位置**: `.trae/analysis/fused-desktop-comprehensive-analysis.md`
|
||||
**内容**: 11页的详细分析报告
|
||||
- 5个严重/中等问题的诊断
|
||||
- 架构优势分析
|
||||
- 风险评估矩阵
|
||||
- 推荐实施计划
|
||||
|
||||
---
|
||||
|
||||
#### 1.3 `fused-desktop-manual-test-checklist.md`
|
||||
**位置**: `.trae/testing/fused-desktop-manual-test-checklist.md`
|
||||
**内容**: 全面的手动测试清单
|
||||
- 10个测试组
|
||||
- 30+ 个测试用例
|
||||
- 预期结果描述
|
||||
- 日志验证提示
|
||||
|
||||
---
|
||||
|
||||
### 2. 修改文件
|
||||
|
||||
#### 2.1 `FusedDesktopComponentLibraryWindow.axaml.cs`
|
||||
**变更**:
|
||||
```diff
|
||||
public FusedDesktopComponentLibraryWindow()
|
||||
{
|
||||
// ... 初始化代码 ...
|
||||
+ FusedDesktopManagerServiceFactory.GetOrCreate().EnterEditMode();
|
||||
+ AppLogger.Info("FusedDesktopLibrary", "Entered edit mode via library window open.");
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
+ FusedDesktopManagerServiceFactory.GetOrCreate().ExitEditMode();
|
||||
+ AppLogger.Info("FusedDesktopLibrary", "Exited edit mode via library window close.");
|
||||
// ... 清理代码 ...
|
||||
}
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- ✅ 打开组件库自动进入编辑模式
|
||||
- ✅ 关闭组件库自动退出编辑模式
|
||||
- ✅ 符合规格要求: "Opening the library window enters edit mode"
|
||||
|
||||
---
|
||||
|
||||
#### 2.2 `DesktopWidgetWindow.axaml`
|
||||
**变更**:
|
||||
```xml
|
||||
<Grid x:Name="RootGrid">
|
||||
<Border x:Name="ComponentContainer" ... />
|
||||
|
||||
+ <!-- 编辑模式边框覆盖层 -->
|
||||
+ <Border x:Name="EditModeBorder"
|
||||
+ BorderThickness="2"
|
||||
+ BorderBrush="#0078D4"
|
||||
+ IsVisible="False"
|
||||
+ IsHitTestVisible="False">
|
||||
+ <Border.Effect>
|
||||
+ <DropShadowEffect Color="#0078D4" BlurRadius="8" />
|
||||
+ </Border.Effect>
|
||||
+ </Border>
|
||||
</Grid>
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- ✅ 编辑模式下显示蓝色高亮边框
|
||||
- ✅ 添加发光阴影效果,提升视觉反馈
|
||||
- ✅ 不影响鼠标交互(IsHitTestVisible="False")
|
||||
|
||||
---
|
||||
|
||||
#### 2.3 `DesktopWidgetWindow.axaml.cs`
|
||||
**主要变更**:
|
||||
|
||||
**新增字段**:
|
||||
```csharp
|
||||
private DesktopWidgetResizeAdorner? _resizeAdorner;
|
||||
private bool _isResizing;
|
||||
private Size _resizeStartSize;
|
||||
private PixelPoint _resizeStartPosition;
|
||||
private int _resizeStartWidthCells;
|
||||
private int _resizeStartHeightCells;
|
||||
```
|
||||
|
||||
**新增方法**:
|
||||
1. `SetupResizeAdorner()` - 初始化调整尺寸装饰器
|
||||
2. `OnResizeStarted()` - 处理调整尺寸开始事件
|
||||
3. `OnResizing()` - 处理调整尺寸进行中事件
|
||||
4. `OnResizeCompleted()` - 处理调整尺寸完成事件
|
||||
5. `CalculateResizedBounds()` - 计算调整后的边界
|
||||
6. `ApplySnappedResizePlacement()` - 应用网格吸附的调整结果
|
||||
7. `EstimateCellSpan()` - 估算像素尺寸对应的网格单元数
|
||||
|
||||
**修改方法**:
|
||||
- `SetEditMode()` - 添加 EditModeBorder 的显示/隐藏逻辑
|
||||
- `UpdateComponentLayout()` - 同步更新 ResizeAdorner 尺寸
|
||||
- `OnPointerPressed()` - 防止调整尺寸时触发拖拽
|
||||
- `OnClosing()` - 清理 ResizeAdorner 事件监听
|
||||
|
||||
**代码亮点**:
|
||||
```csharp
|
||||
// 智能网格吸附 - 调整尺寸后自动对齐网格
|
||||
var widthCells = Math.Max(1, EstimateCellSpan(requestedLocalWidth, context.Geometry));
|
||||
var heightCells = Math.Max(1, EstimateCellSpan(requestedLocalHeight, context.Geometry));
|
||||
|
||||
// 尊重最小尺寸约束
|
||||
widthCells = Math.Max(_resizeStartWidthCells, widthCells);
|
||||
heightCells = Math.Max(_resizeStartHeightCells, heightCells);
|
||||
|
||||
var snappedLocalPlacement = FusedDesktopPlacementMath.SnapToNearestCell(
|
||||
localPlacement, context.Geometry, requestedLocalOrigin);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术实现细节
|
||||
|
||||
### 调整尺寸手柄定位算法
|
||||
|
||||
8个手柄的位置计算(相对于组件边界):
|
||||
|
||||
| 手柄位置 | X 坐标 | Y 坐标 |
|
||||
|---------|--------|--------|
|
||||
| TopLeft | -6 | -6 |
|
||||
| Top | width/2 - 6 | -6 |
|
||||
| TopRight | width - 10 | -6 |
|
||||
| Right | width - 10 | height/2 - 6 |
|
||||
| BottomRight | width - 10 | height - 10 |
|
||||
| Bottom | width/2 - 6 | height - 10 |
|
||||
| BottomLeft | -6 | height - 10 |
|
||||
| Left | -6 | height/2 - 6 |
|
||||
|
||||
**设计理由**:
|
||||
- 手柄部分超出组件边界(-6px偏移),便于抓取
|
||||
- 角手柄尺寸 16x16px,边缘手柄尺寸 12x4px 或 4x12px
|
||||
- 使用 Canvas.Left 和 Canvas.Top 附加属性精确定位
|
||||
|
||||
---
|
||||
|
||||
### 网格吸附逻辑
|
||||
|
||||
调整尺寸完成后的吸附流程:
|
||||
|
||||
```
|
||||
1. 获取当前屏幕和工作区
|
||||
2. 计算屏幕的视口尺寸(物理像素 / DPI缩放)
|
||||
3. 通过 FusedDesktopEditGridAdapter 生成网格几何
|
||||
4. 将窗口位置从屏幕坐标转换为网格坐标
|
||||
5. 估算新尺寸对应的网格单元数
|
||||
widthCells = Round((width + gap) / pitch)
|
||||
6. 调用 FusedDesktopPlacementMath.SnapToNearestCell
|
||||
7. 将网格坐标转换回屏幕坐标
|
||||
8. 更新窗口位置和尺寸
|
||||
9. 持久化到 FusedDesktopLayoutSnapshot
|
||||
```
|
||||
|
||||
**关键约束**:
|
||||
- 最小尺寸: 50px 或 MinWidthCells/MinHeightCells
|
||||
- 边界约束: 不超出 WorkingArea
|
||||
- 单元对齐: 尺寸和位置都对齐网格
|
||||
|
||||
---
|
||||
|
||||
## 架构设计亮点
|
||||
|
||||
### 1. 事件驱动架构
|
||||
- ResizeAdorner 通过事件通知父窗口
|
||||
- 父窗口负责协调视图和数据层
|
||||
- 解耦良好,易于测试
|
||||
|
||||
### 2. 分离关注点
|
||||
- **UI层**: DesktopWidgetResizeHandle, DesktopWidgetResizeAdorner
|
||||
- **逻辑层**: DesktopWidgetWindow (事件处理)
|
||||
- **数据层**: FusedDesktopLayoutService (持久化)
|
||||
- **算法层**: FusedDesktopPlacementMath (网格计算)
|
||||
|
||||
### 3. 复用现有基础设施
|
||||
- 复用 `FusedDesktopEditGridAdapter` 计算网格
|
||||
- 复用 `FusedDesktopPlacementMath.SnapToNearestCell` 吸附逻辑
|
||||
- 复用 `FusedDesktopLayoutService` 持久化机制
|
||||
|
||||
### 4. 防御性编程
|
||||
```csharp
|
||||
// 空值检查
|
||||
if (_resizeAdorner is null) return;
|
||||
if (PlacementId is null) return;
|
||||
|
||||
// 边界检查
|
||||
var widthCells = Math.Max(1, estimatedCells);
|
||||
var newWidth = Math.Max(50, calculatedWidth);
|
||||
|
||||
// 状态保护
|
||||
if (_isResizing) return; // 防止重入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 遗留问题与未来改进
|
||||
|
||||
### 已识别但未修复的问题
|
||||
|
||||
#### 1. 锁定功能未实现 (优先级: P2)
|
||||
- `FusedDesktopComponentPlacementSnapshot.IsLocked` 字段存在但未使用
|
||||
- 需要添加右键菜单"锁定"选项
|
||||
- 锁定后应禁用拖拽和调整尺寸
|
||||
|
||||
#### 2. 多屏幕跨屏拖拽验证 (优先级: P2)
|
||||
- 跨屏幕拖拽的网格切换逻辑未充分测试
|
||||
- 需要在多显示器环境验证
|
||||
|
||||
#### 3. 性能优化空间 (优先级: P3)
|
||||
- 每次拖拽都重新计算网格,可以缓存
|
||||
- 大量组件时的渲染性能需要测试
|
||||
|
||||
#### 4. 网格辅助线 (优先级: P3)
|
||||
- 编辑模式下可选显示网格辅助线
|
||||
- 有助于用户对齐组件
|
||||
|
||||
---
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 单元测试(建议添加)
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void CalculateResizedBounds_BottomRight_IncreasesSize()
|
||||
{
|
||||
var (width, height, x, y) = CalculateResizedBounds(
|
||||
ResizeHandlePosition.BottomRight,
|
||||
new Point(100, 100),
|
||||
new Size(200, 200),
|
||||
new PixelPoint(0, 0));
|
||||
|
||||
Assert.Equal(300, width);
|
||||
Assert.Equal(300, height);
|
||||
Assert.Equal(0, x);
|
||||
Assert.Equal(0, y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EstimateCellSpan_ReturnsCorrectCells()
|
||||
{
|
||||
var grid = new DesktopGridGeometry(
|
||||
Origin: new Point(0, 0),
|
||||
CellSize: 100,
|
||||
CellGap: 10,
|
||||
ColumnCount: 10,
|
||||
RowCount: 10);
|
||||
|
||||
var cells = EstimateCellSpan(330, grid); // 330px = 3 cells (100 + 10 + 100 + 10 + 100)
|
||||
Assert.Equal(3, cells);
|
||||
}
|
||||
```
|
||||
|
||||
### 集成测试(建议添加)
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ResizeAndDrag_PreservesGridAlignment()
|
||||
{
|
||||
// 1. 添加组件
|
||||
// 2. 调整尺寸
|
||||
// 3. 拖拽移动
|
||||
// 4. 验证网格坐标连续性
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文档与知识传递
|
||||
|
||||
### 新增文档
|
||||
|
||||
1. **分析报告**: `.trae/analysis/fused-desktop-comprehensive-analysis.md`
|
||||
- 问题诊断
|
||||
- 架构分析
|
||||
- 实施计划
|
||||
|
||||
2. **测试清单**: `.trae/testing/fused-desktop-manual-test-checklist.md`
|
||||
- 10个测试组
|
||||
- 30+ 测试用例
|
||||
- 预期结果
|
||||
|
||||
3. **实施总结**: 本文档
|
||||
- 变更详情
|
||||
- 技术细节
|
||||
- 遗留问题
|
||||
|
||||
### 相关规格文档
|
||||
|
||||
- `.trae/specs/fused-desktop-library-redesign/spec.md` - 组件库重设计规格
|
||||
|
||||
---
|
||||
|
||||
## 风险评估
|
||||
|
||||
| 风险类型 | 风险级别 | 缓解措施 |
|
||||
|---------|---------|---------|
|
||||
| 拖拽性能下降 | 低 | 已优化算法,需实测验证 |
|
||||
| 多屏幕兼容性 | 中 | 需要在多显示器环境测试 |
|
||||
| 网格计算精度 | 低 | 复用现有成熟算法 |
|
||||
| 用户学习曲线 | 低 | 视觉反馈清晰,符合直觉 |
|
||||
|
||||
---
|
||||
|
||||
## 构建与部署
|
||||
|
||||
### 构建结果
|
||||
```
|
||||
✅ Build succeeded
|
||||
0 errors
|
||||
201 warnings (全部来自第三方库)
|
||||
```
|
||||
|
||||
### 部署检查清单
|
||||
- [ ] 备份现有配置文件
|
||||
- [ ] 清除旧的组件布局缓存(如果格式不兼容)
|
||||
- [ ] 验证 `EnableFusedDesktop` 配置项
|
||||
- [ ] 重启应用以加载新代码
|
||||
|
||||
---
|
||||
|
||||
## 贡献者
|
||||
|
||||
- **开发**: Claude Opus 4.6
|
||||
- **需求分析**: 基于用户反馈和规格文档
|
||||
- **代码审查**: 自动化审查(编译器、静态分析)
|
||||
- **测试**: 待用户执行手动测试
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 相关文件清单
|
||||
|
||||
**新增文件**:
|
||||
- `LanMountainDesktop/Views/DesktopWidgetResizeHandle.cs`
|
||||
- `.trae/analysis/fused-desktop-comprehensive-analysis.md`
|
||||
- `.trae/testing/fused-desktop-manual-test-checklist.md`
|
||||
|
||||
**修改文件**:
|
||||
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs`
|
||||
- `LanMountainDesktop/Views/DesktopWidgetWindow.axaml`
|
||||
- `LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs`
|
||||
|
||||
**未修改但相关文件**:
|
||||
- `LanMountainDesktop/Services/FusedDesktopManagerService.cs`
|
||||
- `LanMountainDesktop/DesktopEditing/FusedDesktopPlacementMath.cs`
|
||||
- `LanMountainDesktop/Models/FusedDesktopLayoutSnapshot.cs`
|
||||
|
||||
---
|
||||
|
||||
### B. 代码统计
|
||||
|
||||
| 文件 | 添加行数 | 删除行数 | 净变化 |
|
||||
|------|---------|---------|--------|
|
||||
| DesktopWidgetResizeHandle.cs | +280 | 0 | +280 |
|
||||
| FusedDesktopComponentLibraryWindow.axaml.cs | +4 | -0 | +4 |
|
||||
| DesktopWidgetWindow.axaml | +15 | -2 | +13 |
|
||||
| DesktopWidgetWindow.axaml.cs | +170 | -20 | +150 |
|
||||
| **总计** | **+469** | **-22** | **+447** |
|
||||
|
||||
---
|
||||
|
||||
### C. Git 提交建议
|
||||
|
||||
```bash
|
||||
git add LanMountainDesktop/Views/DesktopWidgetResizeHandle.cs
|
||||
git add LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs
|
||||
git add LanMountainDesktop/Views/DesktopWidgetWindow.axaml
|
||||
git add LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
|
||||
git add .trae/analysis/fused-desktop-comprehensive-analysis.md
|
||||
git add .trae/testing/fused-desktop-manual-test-checklist.md
|
||||
|
||||
git commit -m "feat: 实现融合桌面编辑模式和组件尺寸调整功能
|
||||
|
||||
- 修复编辑模式控制流:组件库窗口打开/关闭正确进入/退出编辑模式
|
||||
- 实现8方向调整尺寸手柄:支持角和边的尺寸调整
|
||||
- 添加网格吸附逻辑:调整尺寸后自动对齐网格
|
||||
- 添加编辑模式视觉反馈:蓝色边框高亮和阴影效果
|
||||
- 新增 DesktopWidgetResizeHandle 和 DesktopWidgetResizeAdorner 控件
|
||||
- 完善 DesktopWidgetWindow 的交互状态管理
|
||||
- 创建全面的分析报告和测试清单
|
||||
|
||||
Closes: FUSED-DESKTOP-001
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 1.0
|
||||
**最后更新**: 2026-06-08
|
||||
**状态**: ✅ 完成
|
||||
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
9
.trae/specs/air-app-runtime-container/checklist.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `LanMountainDesktop.AirAppRuntime` is included in `LanMountainDesktop.slnx`.
|
||||
- [x] Launcher no longer hosts `IAirAppLifecycleService`.
|
||||
- [x] Host fallback starts `LanMountainDesktop.AirAppRuntime`, not `LanMountainDesktop.Launcher air-app-broker`.
|
||||
- [x] AirApp Runtime is explicitly non-AOT and framework-dependent.
|
||||
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` passes.
|
||||
- [x] Related AirApp Runtime tests pass.
|
||||
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` passes.
|
||||
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
21
.trae/specs/air-app-runtime-container/spec.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# AirApp Runtime Container
|
||||
|
||||
## Goal
|
||||
|
||||
Move built-in Air APP lifecycle management out of Launcher into a dedicated framework-dependent JIT process named `LanMountainDesktop.AirAppRuntime`.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Launcher remains the user-facing entry point and pre-starts AirApp Runtime during normal `launch`.
|
||||
- AirApp Runtime exposes `IAirAppLifecycleService` and `IAirAppRuntimeControlService` on `LanMountainDesktop.AirAppRuntime.v1`.
|
||||
- Desktop host requests Air APP operations through AirApp Runtime IPC.
|
||||
- If the runtime pipe is unavailable, the desktop host starts `LanMountainDesktop.AirAppRuntime` directly and retries.
|
||||
- AirApp Runtime keeps one AirAppHost process per `{appId}:{sourceComponentId}:{sourcePlacementId}` key, with `world-clock` sharing `world-clock:clock-suite:global`.
|
||||
- AirApp Runtime remains alive while Launcher, Host, requester, or any AirAppHost process is alive.
|
||||
- AirApp Runtime exits after Launcher/Host/requester are gone and no Air APP windows remain.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Moving Air APP windows into the runtime process.
|
||||
- Third-party plugin-declared Air APP metadata.
|
||||
- Persisting the Air APP instance table across OS reboot.
|
||||
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
11
.trae/specs/air-app-runtime-container/tasks.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Add shared AirApp Runtime IPC/control contracts.
|
||||
- [x] Add shared AirApp Runtime path resolver and process starter.
|
||||
- [x] Add `LanMountainDesktop.AirAppRuntime` as a framework-dependent JIT process.
|
||||
- [x] Move Air APP lifecycle service out of Launcher.
|
||||
- [x] Make Launcher pre-start AirApp Runtime and attach Host PID after launch.
|
||||
- [x] Make Host fallback start AirApp Runtime instead of Launcher broker.
|
||||
- [x] Remove Launcher `air-app-broker` command handling.
|
||||
- [x] Update packaging scripts and release workflow to include AirApp Runtime.
|
||||
- [x] Update unit tests and architecture/package assertions.
|
||||
@@ -164,3 +164,29 @@
|
||||
|
||||
* ~~搜索功能~~:根据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`.
|
||||
|
||||
### Requirement: Preview area preserves widget proportions
|
||||
|
||||
The fused desktop component library preview area must size the selected widget from its component cell span instead of compressing every widget into a fixed preview box. The preview stage should stretch with the resizable library window, calculate the largest usable widget preview that fits the available stage, preserve the `MinWidthCells` / `MinHeightCells` ratio, and assign explicit preview control width and height before displaying the widget.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Checklist
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||
|
||||
- [x] `LanMountainDesktop.Shared.IPC` builds in Debug.
|
||||
- [x] `LanMountainDesktop.Launcher` builds in Debug.
|
||||
- [x] `LanMountainDesktop` builds in Debug.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Launcher Managed Air APP Lifecycle
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`. Launcher no longer hosts the Air APP lifecycle broker; it pre-starts `LanMountainDesktop.AirAppRuntime`, which owns the lifecycle IPC and AirAppHost process table.
|
||||
|
||||
## Goal
|
||||
|
||||
Make Launcher the authoritative lifecycle manager for built-in Air APP processes. The desktop host requests Air APP operations through IPC, while Launcher creates, activates, tracks, and cleans up Air APP host processes.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Tasks
|
||||
|
||||
> Superseded by `.trae/specs/air-app-runtime-container/`; the checked items below describe the former Launcher-managed implementation.
|
||||
|
||||
- [x] Add shared Air APP lifecycle IPC contracts.
|
||||
- [x] Add Launcher Air APP lifecycle service and dedicated IPC host.
|
||||
- [x] Make Launcher remain alive while desktop or Air APP processes exist.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- [ ] New install shows OOBE once.
|
||||
- [ ] Same-user reinstall does not show OOBE again.
|
||||
- [ ] `postinstall` launch path is handled without misclassifying the user state.
|
||||
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
|
||||
- [ ] `plugin-install` does not auto-enter OOBE.
|
||||
- [ ] Default plugin install does not request UAC.
|
||||
- [ ] Logs include OOBE status, suppression reason, and launch source.
|
||||
- [ ] Startup presentation step inside `OobeWindow` (after data location) writes host `settings.json` and syncs Windows Run when autostart is chosen (Launcher executable).
|
||||
|
||||
@@ -23,12 +23,11 @@ Stabilize the launcher startup path so that:
|
||||
- `launchSource` values are treated as:
|
||||
- `normal`
|
||||
- `postinstall`
|
||||
- `apply-update`
|
||||
- `plugin-install`
|
||||
- `debug-preview`
|
||||
- Automatic OOBE is allowed only for normal user-mode startup.
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
|
||||
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
|
||||
- `plugin-install` and `debug-preview` must not auto-enter OOBE.
|
||||
- Allowed elevation paths are limited to:
|
||||
- the installer itself
|
||||
- full installer update application
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`.
|
||||
- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`.
|
||||
|
||||
## Launcher custom splash image
|
||||
|
||||
- The hidden Launcher debug menu owns the splash image picker.
|
||||
- Saving an image copies it into `.Launcher` as `Launcher Picture.<ext>` and clears the in-memory image cache.
|
||||
- Invalid, unsupported, or oversized images must not overwrite the existing managed image.
|
||||
- Splash image rendering uses `Uniform` fitting so the full image remains visible.
|
||||
- The self-drawn Splash shell uses fixed Fluent corner tokens: `8px` outer radius and `4px` control radius.
|
||||
|
||||
## UX safeguards
|
||||
|
||||
- If the host process is still alive at failure time, the failure dialog must prefer:
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Checklist
|
||||
|
||||
- [x] `release.yml` does not invoke Velopack.
|
||||
- [x] `plonds-build.yml` uploads app payload artifacts and generates PloNDS delta/static outputs.
|
||||
- [x] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
|
||||
- [x] CI workflow expects `repo/`, `meta/`, `manifests/`, and `installers/` outputs after a release run.
|
||||
- [x] Host update source keeps compatibility (`pdc`/`stcn` normalize to active PloNDS source).
|
||||
- [x] Host can persist PloNDS payload into launcher incoming directory.
|
||||
- [x] Launcher can apply PloNDS FileMap payload with signature/hash verification.
|
||||
- [x] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
|
||||
- [x] Launcher keeps rollback-capable deployments after successful update.
|
||||
- [x] Manual rollback returns a structured failure when the snapshot source directory is missing.
|
||||
- [ ] CI run attached proving all release matrix jobs pass.
|
||||
- [x] N-1 -> N incremental update verified locally on Windows x64.
|
||||
- [ ] N-1 -> N incremental update verified on Windows x86 and Linux x64.
|
||||
- [x] Rollback regression tests attached in `LanMountainDesktop.Tests`.
|
||||
@@ -1,48 +0,0 @@
|
||||
# PDC Incremental Update Migration
|
||||
|
||||
## Goal
|
||||
|
||||
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
|
||||
|
||||
## Stage 1 (Completed)
|
||||
|
||||
- Release workflow removed VeloPack-based release packaging.
|
||||
- Signed FileMap path was restored as an interim release mechanism.
|
||||
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
|
||||
|
||||
## Stage 2 (Current Implementation Target)
|
||||
|
||||
- Use GitHub Actions PloNDS static publishing as the active incremental path.
|
||||
- Keep `phainon.yml` for future PDCC parity, but do not rely on PDCC for the current release flow.
|
||||
- Promote PloNDS-distributed FileMap/object-repo as the primary incremental path.
|
||||
- Keep GitHub Release installers and metadata as parallel distribution.
|
||||
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
|
||||
- Check updates in order: NS3/PloNDS static source, GitHub Release PloNDS assets, then GitHub full installer.
|
||||
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
|
||||
- Public object URLs come from `S3_PUBLIC_BASE_URL`; do not infer them from `S3_ENDPOINT` and `S3_BUCKET`.
|
||||
|
||||
Expected S3 layout:
|
||||
- `lanmountain/update/repo/sha256/<hash-prefix>/<hash-object>`
|
||||
- `lanmountain/update/meta/channels/<channel>/<platform>/latest.json`
|
||||
- `lanmountain/update/meta/distributions/<distributionId>.json`
|
||||
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json`
|
||||
- `lanmountain/update/manifests/<distributionId>/plonds-filemap.json.sig`
|
||||
- `lanmountain/update/installers/<platform>/<version>/*`
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `release.yml` contains no Velopack steps; PloNDS static publishing is handled by `plonds-build.yml` and `ddss-publish.yml`.
|
||||
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
|
||||
- PloNDS metadata + FileMap + object repo are published under `lanmountain/update/`.
|
||||
- Host can consume the NS3/PloNDS static payload and fallback to GitHub when unavailable.
|
||||
- Launcher can apply both:
|
||||
- legacy signed `files.json + update.zip`
|
||||
- PloNDS FileMap object-repo payload.
|
||||
- Rollback semantics keep both automatic failure rollback and manual rollback after a successful update.
|
||||
|
||||
## Deprecated Notes
|
||||
|
||||
- The following interim outputs are compatibility-only (not the long-term primary path):
|
||||
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
|
||||
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
|
||||
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`
|
||||
@@ -1,21 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Remove VeloPack packaging from release workflow.
|
||||
- [x] Keep signed FileMap path as interim compatibility fallback.
|
||||
- [x] Remove launcher/runtime Velopack branching.
|
||||
- [x] Add `phainon.yml` for PDCC publish configuration.
|
||||
- [ ] Add PDCC installation + publish steps in `release.yml` (deferred; active path is GitHub Actions PloNDS static publish).
|
||||
- [x] Upload app payload artifacts for PloNDS delta generation in release build jobs.
|
||||
- [x] Publish PloNDS metadata + sha256 object repo to S3 path root `lanmountain/update/`.
|
||||
- [x] Mirror installers to `lanmountain/update/installers/<platform>/<version>/`.
|
||||
- [x] Keep update source compatibility (`pdc`/`stcn` normalize to active PloNDS source).
|
||||
- [x] Add PloNDS static payload model into host update check result.
|
||||
- [x] Add host download path for PloNDS payload (`plonds-filemap.json` + signature + object repo).
|
||||
- [x] Add launcher PloNDS FileMap apply path with rollback-compatible semantics.
|
||||
- [x] Keep old `files.json + update.zip` path behind compatibility fallback.
|
||||
- [x] Keep rollback deployment directories after successful apply and prune by bounded retention.
|
||||
- [x] Return structured failure when manual rollback snapshot source is missing.
|
||||
- [x] Verify static S3 layout, filemap/signature, distribution, latest pointer, and at least one object in CI workflows.
|
||||
- [x] Add regression tests for PloNDS success rollback, hash-failure auto rollback, missing rollback source, static NS3 manifest, and manifest field mapping.
|
||||
- [ ] Attach live CI run proving the full release matrix passes.
|
||||
- [ ] Verify N-1 -> N incremental update on Windows x86 and Linux x64 in release artifacts.
|
||||
174
.trae/specs/plonds-client-service/spec.md
Normal file
174
.trae/specs/plonds-client-service/spec.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# PLONDS Client Service 独立化设计
|
||||
|
||||
> 日期:2026-06-01
|
||||
> 状态:设计中
|
||||
|
||||
## 1. 目标
|
||||
|
||||
PLONDS 在应用内必须作为独立服务存在,负责分发发现、下载、校验和本地包准备。它不是现有 Update 模块的 provider,也不应把 S3/GitHub/source 选择逻辑混入 `LanMountainDesktop/Services/Update/`。
|
||||
|
||||
最终边界:
|
||||
|
||||
- PLONDS 服务:寻找最新版本、选择下载源、下载 manifest 和包、校验文件、准备本地 staging。
|
||||
- 安装程序/安装网关:只消费 PLONDS 已准备好的本地安装输入,执行增量安装或完整安装。
|
||||
- UI:只展示 PLONDS 服务和安装程序返回的状态;完整包也失败后才处理错误。
|
||||
|
||||
## 2. 当前耦合点
|
||||
|
||||
当前需要拆离的耦合点:
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||
- 直接持有 `PlondsStaticUpdateService` 与 `PlondsReleaseUpdateService`
|
||||
- 在 `CheckForUpdatesCoreAsync` 中把 PLONDS 和 GitHub Update fallback 逻辑混在一起
|
||||
- `LanMountainDesktop/Services/Update/UpdateInstallGateway.cs`
|
||||
- 直接判断 `UpdatePayloadKind.DeltaPlonds`
|
||||
- 直接实例化 `PlondsUpdateApplier`
|
||||
- `LanMountainDesktop/Services/Update/Plonds*.cs`
|
||||
- PLONDS apply/parser/payload resolver 仍位于 Update 命名空间
|
||||
|
||||
## 3. Source 发现规则
|
||||
|
||||
PLONDS 客户端内置两个初始地址:
|
||||
|
||||
1. S3 上的 PLONDS manifest 地址
|
||||
2. GitHub Release 上的 PLONDS manifest 地址
|
||||
|
||||
两个地址读取的是同一种 JSON 文件,当前文件名为 `PLONDS.json`。客户端每次检查增量更新时,会并行或顺序请求所有已知 source 的 `PLONDS.json`。
|
||||
|
||||
### 3.1 Source 扩展
|
||||
|
||||
`PLONDS.json` 可以声明额外 source。客户端读取到额外 source 后,应把它们加入下一轮寻找列表。
|
||||
|
||||
建议 manifest 扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"sources": [
|
||||
{
|
||||
"id": "rainyun-s3",
|
||||
"kind": "s3",
|
||||
"manifestUrl": "https://example.com/plonds/1.2.3/PLONDS.json",
|
||||
"priority": 100
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"kind": "github",
|
||||
"manifestUrl": "https://github.com/owner/repo/releases/download/v1.2.3/PLONDS.json",
|
||||
"priority": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `sources` 为空或缺失时,只使用内置 S3 + GitHub。
|
||||
- 新 source 不覆盖内置 source,除非 `id` 相同。
|
||||
- source 列表需要去重,按 `id` 和 `manifestUrl` 双重去重。
|
||||
- source 持久化到 PLONDS 自己的配置/缓存,不写入 Update 设置。
|
||||
|
||||
## 4. 版本选择规则
|
||||
|
||||
如果多个 source 返回的版本不一致,客户端选择 `currentVersion` 最高的 manifest。
|
||||
|
||||
规则:
|
||||
|
||||
- 版本解析使用 `Version` 语义,忽略前导 `v`。
|
||||
- 版本相同时,优先选择下载可用性更高的 source。
|
||||
- 如果最高版本 manifest 下载包失败,可以尝试同版本的其他 source。
|
||||
- 不因为低版本 source 成功而降级,除非用户显式允许。
|
||||
|
||||
## 5. 下载与回退规则
|
||||
|
||||
PLONDS 服务优先走增量包:
|
||||
|
||||
1. 下载所选 manifest。
|
||||
2. 下载 `changed.zip`。
|
||||
3. 校验 `changed.zip` 与 manifest 中的 hash/checksum。
|
||||
4. 解压或准备增量 staging。
|
||||
5. 交给安装程序执行增量安装。
|
||||
|
||||
如果增量流程失败,PLONDS 服务自动改用完整包:
|
||||
|
||||
1. 下载 `Files.zip`。
|
||||
2. 校验 `Files.zip`。
|
||||
3. 解压或准备完整包 staging。
|
||||
4. 交给安装程序执行完整包安装。
|
||||
|
||||
如果完整包也失败,PLONDS 服务返回失败结果,由 UI 展示错误和重试入口。
|
||||
|
||||
## 6. 发布产物布局
|
||||
|
||||
Publisher 上传到 S3 的版本目录:
|
||||
|
||||
```text
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `Files.zip` 是上传到 S3 时的完整包标准名。
|
||||
- `<version>-Files/` 是 S3 上解压后的完整包目录。
|
||||
- `<prefix>/PLONDS.json` 是 S3 的固定 latest manifest 地址,和 GitHub Release latest manifest 一起作为客户端内置初始 source。
|
||||
- GitHub Release 仍可保留平台原始文件名,例如 `files-windows-x64.zip`。
|
||||
- `PLONDS.json` 的 downloads 字段同时包含 GitHub 与 S3 的增量包、完整包位置。
|
||||
- Publisher 必须先完成版本目录内的 `changed.zip`、`Files.zip`、解压目录和版本 `PLONDS.json` 上传,再更新 `<prefix>/PLONDS.json` latest 指针。
|
||||
- Publisher 的 S3 目录上传必须支持重跑续传;同 key 且大小一致的对象可以跳过,避免失败后从头上传完整包目录。
|
||||
- Publisher 上传大对象时应使用 S3 multipart upload,以避免 `changed.zip` / `Files.zip` 在低吞吐链路上被单次 PUT 长时间阻塞。
|
||||
|
||||
## 7. 建议代码结构
|
||||
|
||||
```text
|
||||
LanMountainDesktop/Services/Plonds/
|
||||
IPlondsService.cs
|
||||
PlondsService.cs
|
||||
Sources/
|
||||
IPlondsSource.cs
|
||||
PlondsHttpManifestSource.cs
|
||||
PlondsSourceRegistry.cs
|
||||
Download/
|
||||
PlondsDownloader.cs
|
||||
PlondsDownloadPlanner.cs
|
||||
Verification/
|
||||
PlondsVerifier.cs
|
||||
Staging/
|
||||
PlondsPackageStore.cs
|
||||
PlondsPreparedPackage.cs
|
||||
Models/
|
||||
PlondsClientManifest.cs
|
||||
PlondsSourceDescriptor.cs
|
||||
PlondsCheckResult.cs
|
||||
```
|
||||
|
||||
后续如果要移植,优先把这棵目录或等价项目抽成独立库。
|
||||
|
||||
## 8. 与安装程序的交接契约
|
||||
|
||||
PLONDS 服务输出本地 prepared package:
|
||||
|
||||
```csharp
|
||||
public sealed record PlondsPreparedPackage(
|
||||
Version Version,
|
||||
PlondsPackageMode Mode,
|
||||
string ManifestPath,
|
||||
string? ChangedZipPath,
|
||||
string? ChangedDirectory,
|
||||
string? FilesZipPath,
|
||||
string? FilesDirectory);
|
||||
```
|
||||
|
||||
安装程序只接受这个结果,不参与 source 发现、下载和校验。
|
||||
|
||||
## 9. 实施顺序
|
||||
|
||||
1. Publisher 补齐完整包 S3 上传与 manifest downloads 字段。
|
||||
2. 新增 `Services/Plonds/` 客户端服务骨架和模型。
|
||||
3. 把 `PlondsStaticUpdateService` / `PlondsReleaseUpdateService` 合并迁移到独立 PLONDS source 体系。
|
||||
4. 把 `LanMountainDesktop/Services/Update/Plonds*.cs` 迁出 Update 命名空间。
|
||||
5. `UpdateSettingsService` 改为调用 `IPlondsService`,不再直接组合 S3/GitHub PLONDS fallback。
|
||||
6. 安装入口只接收 `PlondsPreparedPackage`。
|
||||
7. 添加单元测试覆盖 source 扩展、最高版本选择、增量失败转完整包、完整包失败交 UI。
|
||||
549
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
549
.trae/specs/plonds-comparator-redesign/spec.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# PLONDS Comparator 改造设计
|
||||
|
||||
> 日期:2026-05-30
|
||||
> 状态:待审批
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
PLONDS(Penguin Logistics Online Network Distribution System)是 LanMountainDesktop 的文件驱动式分布式更新系统。当前 Comparator 工作流存在以下问题:
|
||||
|
||||
1. **产出物过于复杂**:生成 `update-{platform}.zip`、`plonds-filemap-{platform}.json`、`plonds-filemap-{platform}.json.sig`、`platform-summary-{platform}.json`、`plonds-static.zip` 等多个文件,客户端消费困难
|
||||
2. **模型定义重复**:`Plonds.Shared`、`Plonds.Core`、宿主侧、Launcher 侧各自定义独立的 DTO,字段名不一致
|
||||
3. **签名机制过重**:RSA 签名增加了 CI 复杂度(需要管理密钥),且对文件驱动式更新系统而言 SHA256 哈希校验已足够
|
||||
4. **平台覆盖不当**:Linux 平台不需要 PLONDS 支持,macOS 尚未接入,但代码中硬编码了三个平台
|
||||
5. **工作流间 artifact 传递脆弱**:Comparator → Publisher 通过 artifact 传递 `plonds-static.zip`,容易断裂
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
- 产出物精简为两个文件:`changed.zip` + `PLONDS.json`
|
||||
- 去掉 RSA 签名,只用 SHA256/MD5 校验
|
||||
- 只关注 Windows 平台
|
||||
- 统一模型定义,消除 DTO 重复
|
||||
- 保持 Comparator 和 Publisher 两个工作流的职责分离
|
||||
|
||||
## 3. 新产出物定义
|
||||
|
||||
### 3.1 changed.zip
|
||||
|
||||
只包含与上一版本有差异的文件(action 为 `add` 或 `replace` 的文件),目录结构与部署目录一致。
|
||||
|
||||
### 3.2 PLONDS.json
|
||||
|
||||
```json
|
||||
{
|
||||
"formatVersion": "2.0",
|
||||
"currentVersion": "1.2.0",
|
||||
"previousVersion": "1.1.0",
|
||||
"isFullUpdate": false,
|
||||
"requiresCleanInstall": false,
|
||||
"channel": "stable",
|
||||
"platform": "windows-x64",
|
||||
"updatedAt": "2026-05-30T12:00:00Z",
|
||||
|
||||
"filesMap": {
|
||||
"LanMountainDesktop.exe": {
|
||||
"action": "replace",
|
||||
"sha256": "abc123...",
|
||||
"size": 1024000
|
||||
},
|
||||
"LanMountainDesktop.dll": {
|
||||
"action": "reuse",
|
||||
"sha256": "def456...",
|
||||
"size": 512000
|
||||
},
|
||||
"OldModule.dll": {
|
||||
"action": "delete",
|
||||
"sha256": "",
|
||||
"size": 0
|
||||
}
|
||||
},
|
||||
|
||||
"changedFilesMap": {
|
||||
"LanMountainDesktop.exe": {
|
||||
"archivePath": "LanMountainDesktop.exe",
|
||||
"sha256": "abc123...",
|
||||
"size": 1024000
|
||||
}
|
||||
},
|
||||
|
||||
"checksums": {
|
||||
"changed.zip": "md5:9a8b7c6d..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 字段语义
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `formatVersion` | string | 协议版本,固定 `"2.0"` |
|
||||
| `currentVersion` | string | 当前发布版本 |
|
||||
| `previousVersion` | string | 基线版本(全量更新时为 `"0.0.0"`) |
|
||||
| `isFullUpdate` | bool | 是否为全量更新(找不到基线版本时为 true) |
|
||||
| `requiresCleanInstall` | bool | 启动器是否也更新了——如果是,客户端不走增量流程,让用户重新运行安装器 |
|
||||
| `channel` | string | 更新通道:`stable` 或 `preview` |
|
||||
| `platform` | string | 平台标识:`windows-x64` |
|
||||
| `updatedAt` | string | ISO 8601 时间戳 |
|
||||
| `filesMap` | object | 全量文件图:每个文件的 action + sha256 + size |
|
||||
| `changedFilesMap` | object | 变更文件图:只包含需要从 changed.zip 解压的文件 |
|
||||
| `checksums` | object | 产出物的 MD5 值 |
|
||||
|
||||
### 3.4 filesMap 中 action 的值
|
||||
|
||||
| Action | 含义 | changed.zip 中是否包含 |
|
||||
|--------|------|----------------------|
|
||||
| `add` | 新增文件 | ✅ |
|
||||
| `replace` | 替换文件 | ✅ |
|
||||
| `reuse` | 复用上一版本文件 | ❌ |
|
||||
| `delete` | 删除文件 | ❌ |
|
||||
|
||||
### 3.5 requiresCleanInstall 判断逻辑
|
||||
|
||||
比较 `LanMountainDesktop.Launcher.exe` 在当前版本和基线版本中的 SHA256:
|
||||
- 如果 SHA256 不同 → `requiresCleanInstall = true`
|
||||
- 如果 SHA256 相同或没有基线版本 → `requiresCleanInstall = false`
|
||||
|
||||
## 4. Plonds.Tool build-delta 命令改造
|
||||
|
||||
### 4.1 新命令签名
|
||||
|
||||
```
|
||||
build-delta --platform <platform>
|
||||
--current-version <version>
|
||||
--current-zip <file>
|
||||
--output-dir <dir>
|
||||
--channel <channel>
|
||||
[--baseline-version <version>]
|
||||
[--baseline-zip <file>]
|
||||
[--launcher-path <relative-path>]
|
||||
```
|
||||
|
||||
### 4.2 参数说明
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--platform` | 是 | 平台标识,如 `windows-x64` |
|
||||
| `--current-version` | 是 | 当前发布版本号 |
|
||||
| `--current-zip` | 是 | 当前版本的 payload zip 路径 |
|
||||
| `--output-dir` | 是 | 输出目录 |
|
||||
| `--channel` | 是 | 更新通道 |
|
||||
| `--baseline-version` | 否 | 基线版本号(省略则视为全量更新) |
|
||||
| `--baseline-zip` | 否 | 基线版本的 payload zip 路径(省略则视为全量更新) |
|
||||
| `--launcher-path` | 否 | Launcher 可执行文件的相对路径,默认 `LanMountainDesktop.Launcher.exe` |
|
||||
|
||||
### 4.3 删除的参数
|
||||
|
||||
| 参数 | 原因 |
|
||||
|------|------|
|
||||
| `--current-tag` | 不再需要,version 就够了 |
|
||||
| `--private-key` | 去掉签名 |
|
||||
| `--is-full-payload` | 自动判断:没有 baseline-zip 就是全量 |
|
||||
| `--static-output-dir` | 不再生成 S3 静态布局 |
|
||||
| `--update-base-url` | 不再生成 S3 URL |
|
||||
| `--baseline-tag` | 不再需要 |
|
||||
|
||||
### 4.4 内部逻辑
|
||||
|
||||
```
|
||||
1. 解压 current-zip → currentDir
|
||||
2. 如果有 baseline-zip → 解压 → baselineDir
|
||||
否则 → baselineDir = 空(全量更新)
|
||||
|
||||
3. 扫描 currentDir → 计算 SHA256
|
||||
4. 扫描 baselineDir → 计算 SHA256(如果有)
|
||||
|
||||
5. 对比生成 filesMap:
|
||||
- 两个版本都有且 SHA256 相同 → reuse
|
||||
- 两个版本都有但 SHA256 不同 → replace
|
||||
- 只在新版本中存在 → add
|
||||
- 只在旧版本中存在 → delete
|
||||
|
||||
6. 从 filesMap 提取 changedFilesMap:
|
||||
- 只包含 action=add/replace 的条目
|
||||
- 添加 archivePath(在 changed.zip 中的路径)
|
||||
|
||||
7. 打包 changed.zip:
|
||||
- 只包含 add/replace 的文件
|
||||
- 保持原始目录结构
|
||||
|
||||
8. 判断 requiresCleanInstall:
|
||||
- 比较 Launcher 可执行文件在两个版本中的 SHA256
|
||||
- 如果不同 → requiresCleanInstall=true
|
||||
|
||||
9. 计算 changed.zip 的 MD5
|
||||
|
||||
10. 生成 PLONDS.json
|
||||
|
||||
11. 输出到 output-dir:
|
||||
- changed.zip
|
||||
- PLONDS.json
|
||||
```
|
||||
|
||||
### 4.5 不再生成的产物
|
||||
|
||||
| 旧产物 | 处置 |
|
||||
|--------|------|
|
||||
| `update-{platform}.zip` | 被 `changed.zip` 替代 |
|
||||
| `plonds-filemap-{platform}.json` | 被 `PLONDS.json` 替代 |
|
||||
| `plonds-filemap-{platform}.json.sig` | 去掉签名 |
|
||||
| `platform-summary-{platform}.json` | 不再需要 |
|
||||
| `plonds-static.zip` | 不再生成 S3 静态布局 |
|
||||
| `meta/channels/...` | 不再由 Tool 生成,由 Publisher 负责 |
|
||||
|
||||
## 5. Plonds.Shared 模型改造
|
||||
|
||||
### 5.1 删除的模型
|
||||
|
||||
| 模型 | 原因 |
|
||||
|------|------|
|
||||
| `PlondsFileMap` | 被新的 `PlondsManifest` 替代 |
|
||||
| `PlondsFileEntry` | 被新的 `PlondsFileEntry` 替代 |
|
||||
| `PlondsComponent` | 不再有组件概念 |
|
||||
| `PlondsDistributionInfo` | 不再生成分发文档 |
|
||||
| `PlondsChannelPointer` | 由 Publisher 用脚本生成 |
|
||||
| `PlondsReleaseManifest` | 不再需要 |
|
||||
| `PlondsReleasePlatformEntry` | 不再需要 |
|
||||
| `PlondsSignatureDescriptor` | 去掉签名 |
|
||||
| `PlondsMirrorAsset` | 由 Publisher 处理 |
|
||||
| `PlondsMirrorEntry` | 由 Publisher 处理 |
|
||||
| `PlondsMetadataCatalog` | 不再需要 |
|
||||
| `PlondsAssetEntry` | 不再需要 |
|
||||
|
||||
### 5.2 新模型定义
|
||||
|
||||
```csharp
|
||||
// PlondsManifest — 对应 PLONDS.json
|
||||
public sealed record PlondsManifest(
|
||||
string FormatVersion,
|
||||
string CurrentVersion,
|
||||
string PreviousVersion,
|
||||
bool IsFullUpdate,
|
||||
bool RequiresCleanInstall,
|
||||
string Channel,
|
||||
string Platform,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyDictionary<string, PlondsFileEntry> FilesMap,
|
||||
IReadOnlyDictionary<string, PlondsChangedFileEntry> ChangedFilesMap,
|
||||
IReadOnlyDictionary<string, string> Checksums);
|
||||
|
||||
// PlondsFileEntry — filesMap 中的条目
|
||||
public sealed record PlondsFileEntry(
|
||||
string Action, // add | replace | reuse | delete
|
||||
string Sha256,
|
||||
long Size);
|
||||
|
||||
// PlondsChangedFileEntry — changedFilesMap 中的条目
|
||||
public sealed record PlondsChangedFileEntry(
|
||||
string ArchivePath, // 在 changed.zip 中的路径
|
||||
string Sha256,
|
||||
long Size);
|
||||
```
|
||||
|
||||
### 5.3 设计决策
|
||||
|
||||
- `FilesMap` 和 `ChangedFilesMap` 用 `IReadOnlyDictionary<string, T>` 而非 `IReadOnlyList<T>`,key 就是文件相对路径,查找 O(1)
|
||||
- 去掉 `Component` 概念——当前只有一个 `app` 组件,分层没有实际意义
|
||||
- `FormatVersion` 固定为 `"2.0"`,与旧格式区分
|
||||
|
||||
## 6. Comparator 工作流改造
|
||||
|
||||
### 6.1 保留两个工作流
|
||||
|
||||
- **Comparator**(`plonds-comparator.yml`):比较文件生成器,只负责生成 `changed.zip` + `PLONDS.json`
|
||||
- **Publisher**(`plonds-uploader.yml`):发布器,负责用仓库内 C# S3 客户端上传 `changed.zip`、`PLONDS.json` 和解压后的 `<version>-changed/` 目录,并把 GitHub/S3 下载信息写回 `PLONDS.json`
|
||||
- **Rollback**:独立 rollback 工作流已废弃,不再维护
|
||||
|
||||
### 6.2 Comparator 改造后步骤
|
||||
|
||||
```yaml
|
||||
# plonds-comparator.yml
|
||||
触发: release.published / release.prereleased / workflow_dispatch
|
||||
|
||||
jobs:
|
||||
compare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- Checkout
|
||||
|
||||
- 解析发布上下文
|
||||
→ RELEASE_TAG, RELEASE_VERSION, RELEASE_CHANNEL
|
||||
|
||||
- Setup .NET
|
||||
|
||||
- 构建 PLONDS Tool
|
||||
|
||||
- 解析基线版本
|
||||
→ 查找上一个同频道 Release
|
||||
→ 如果有 → 记录 baseline_tag, baseline_version
|
||||
→ 如果没有 → is_full_update=true
|
||||
|
||||
- 下载 payload zips
|
||||
→ 下载当前版本 files-windows-x64.zip
|
||||
→ 下载基线版本 files-windows-x64.zip (如果有)
|
||||
|
||||
- 运行 build-delta
|
||||
→ dotnet run Plonds.Tool -- build-delta \
|
||||
--platform windows-x64 \
|
||||
--current-version $VERSION \
|
||||
--current-zip files-windows-x64.zip \
|
||||
--output-dir plonds-output \
|
||||
--channel $CHANNEL \
|
||||
[--baseline-version $BASELINE_VERSION] \
|
||||
[--baseline-zip baseline-files-windows-x64.zip]
|
||||
|
||||
- 上传到 GitHub Release
|
||||
→ gh release upload changed.zip PLONDS.json
|
||||
|
||||
- 传递元数据给 Publisher
|
||||
→ 上传 artifact: plonds-run-metadata (tag.txt)
|
||||
```
|
||||
|
||||
### 6.3 Publisher 改造后步骤
|
||||
|
||||
```yaml
|
||||
# plonds-uploader.yml
|
||||
触发: PLONDS Comparator completed / workflow_dispatch
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- Checkout
|
||||
- 解析 release tag
|
||||
- Setup .NET
|
||||
- 构建 PLONDS Tool
|
||||
- 从 GitHub Release 下载 changed.zip + PLONDS.json
|
||||
- 调用 dotnet run Plonds.Tool -- publish-s3
|
||||
→ 使用仓库内 C# S3 客户端上传,不依赖 aws CLI
|
||||
→ S3 目录布局:
|
||||
<prefix>/<version>/PLONDS.json
|
||||
<prefix>/<version>/changed.zip
|
||||
<prefix>/<version>/<version>-changed/**
|
||||
<prefix>/<version>/Files.zip
|
||||
<prefix>/<version>/<version>-Files/**
|
||||
→ 回写 PLONDS.json downloads 字段:
|
||||
downloads.github.releaseUrl
|
||||
downloads.github.manifestUrl
|
||||
downloads.github.changedZipUrl
|
||||
downloads.github.filesZipUrl
|
||||
downloads.s3.manifestUrl
|
||||
downloads.s3.changedZipUrl
|
||||
downloads.s3.changedFolderUrl
|
||||
downloads.s3.filesZipUrl
|
||||
downloads.s3.filesFolderUrl
|
||||
- 将回写后的 PLONDS.json 重新上传到 GitHub Release
|
||||
```
|
||||
|
||||
### 6.4 与当前步骤的差异
|
||||
|
||||
| 当前步骤 | 改造后 |
|
||||
|---------|--------|
|
||||
| 准备签名密钥 | ❌ 删除 |
|
||||
| 解析基线计划 (pwsh,三平台) | ✅ 简化:只找 Windows,逻辑简化 |
|
||||
| 下载 payload zips (pwsh,三平台) | ✅ 简化:只下载 Windows |
|
||||
| 构建增量资产 (pwsh,含 build-index + 静态布局验证 + plonds-static.zip 打包) | ✅ 简化:只调用 build-delta |
|
||||
| 上传 PLONDS assets 到 release | ✅ 简化:只上传 changed.zip + PLONDS.json |
|
||||
| 传递元数据 | ✅ 保留,但 artifact 内容简化 |
|
||||
| Publisher 中使用 aws CLI / plonds-static / build-plonds / plonds.json.sig | ❌ 删除,改为 C# `publish-s3` |
|
||||
| 独立 rollback workflow | ❌ 删除 |
|
||||
|
||||
## 7. 双模式差分生成
|
||||
|
||||
### 7.1 概述
|
||||
|
||||
Comparator 支持两种差分生成方法,通过 `workflow_dispatch` 的 `compare_method` 输入项选择:
|
||||
|
||||
| 方法 | 标识 | 核心思路 |
|
||||
|------|------|---------|
|
||||
| 方法一 | `file-compare` | 下载两个版本的 files zip,全量文件哈希对比 |
|
||||
| 方法二 | `commit-analyze` | 分析两个版本之间的 git commit,映射源码变更到产物文件 |
|
||||
|
||||
### 7.2 GitHub Actions 触发器新增输入项
|
||||
|
||||
```yaml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag: ...
|
||||
baseline_tag: ...
|
||||
channel: ...
|
||||
compare_method: # 新增
|
||||
description: '比较方法'
|
||||
type: choice
|
||||
default: file-compare
|
||||
options:
|
||||
- file-compare
|
||||
- commit-analyze
|
||||
hash_algorithm: # 新增(仅方法一)
|
||||
description: '哈希算法'
|
||||
type: choice
|
||||
default: sha256
|
||||
options:
|
||||
- sha256
|
||||
- md5
|
||||
```
|
||||
|
||||
当由 `release` 事件触发时,默认使用 `file-compare` + `sha256`。
|
||||
|
||||
### 7.3 方法一:文件对比模式(file-compare)
|
||||
|
||||
**流程:**
|
||||
|
||||
```
|
||||
1. 下载当前版本 files-windows-x64.zip
|
||||
2. 下载基线版本 files-windows-x64.zip(如果有)
|
||||
3. 解压两个 zip 到临时目录
|
||||
4. 用指定哈希算法(sha256/md5)扫描两个目录的所有文件
|
||||
5. 对比哈希值,生成 filesMap(add/replace/reuse/delete)
|
||||
6. 从当前版本目录中提取 add/replace 的文件 → changed.zip
|
||||
7. 生成 PLONDS.json
|
||||
```
|
||||
|
||||
**PlondsDeltaBuildOptions 新增参数:**
|
||||
|
||||
```csharp
|
||||
string HashAlgorithm = "sha256" // "sha256" | "md5"
|
||||
```
|
||||
|
||||
**哈希算法对 PLONDS.json 的影响:**
|
||||
|
||||
- `sha256`:`filesMap` 和 `changedFilesMap` 中使用 `sha256` 字段
|
||||
- `md5`:`filesMap` 和 `changedFilesMap` 中使用 `md5` 字段
|
||||
|
||||
### 7.4 方法二:Commit 分析模式(commit-analyze)
|
||||
|
||||
**流程:**
|
||||
|
||||
```
|
||||
1. 下载当前版本 files-windows-x64.zip
|
||||
2. 解压到临时目录
|
||||
3. git log --name-only baseline_tag..current_tag
|
||||
→ 得到两个版本之间的 commit 列表和涉及的源码文件
|
||||
4. 过滤:只保留源码目录下的文件
|
||||
5. 用简单规则映射源码文件到产物文件
|
||||
6. 从当前版本的解压目录中提取映射到的产物文件 → changed.zip
|
||||
7. 生成 PLONDS.json
|
||||
8. 如果没有源码变更 → 自动回退到方法一
|
||||
```
|
||||
|
||||
**源码目录过滤规则:**
|
||||
|
||||
只分析以下目录下的文件变更:
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `LanMountainDesktop/` | 主宿主应用 |
|
||||
| `LanMountainDesktop.Launcher/` | 启动器 |
|
||||
| `LanMountainDesktop.Shared.Contracts/` | 共享契约 |
|
||||
| `LanMountainDesktop.PluginSdk/` | 插件 SDK |
|
||||
| `LanMountainDesktop.Appearance/` | 外观系统 |
|
||||
| `LanMountainDesktop.Settings.Core/` | 设置核心 |
|
||||
| `LanMountainDesktop.ComponentSystem/` | 组件系统 |
|
||||
|
||||
忽略的目录:`docs/`、`scripts/`、`.github/`、`.trae/`、`PenguinLogisticsOnlineNetworkDistributionSystem/`
|
||||
|
||||
**源码到产物的映射规则:**
|
||||
|
||||
| 源码路径模式 | 映射到产物文件 |
|
||||
|-------------|--------------|
|
||||
| `LanMountainDesktop/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.dll`, `LanMountainDesktop.exe` |
|
||||
| `LanMountainDesktop.Launcher/**/*.{cs,axaml,xaml}` | `LanMountainDesktop.Launcher.exe` |
|
||||
| `LanMountainDesktop.Shared.Contracts/**/*.cs` | `LanMountainDesktop.Shared.Contracts.dll` |
|
||||
| `LanMountainDesktop.PluginSdk/**/*.cs` | `LanMountainDesktop.PluginSdk.dll` |
|
||||
| `LanMountainDesktop.Appearance/**/*.cs` | `LanMountainDesktop.Appearance.dll` |
|
||||
| `LanMountainDesktop.Settings.Core/**/*.cs` | `LanMountainDesktop.Settings.Core.dll` |
|
||||
| `LanMountainDesktop.ComponentSystem/**/*.cs` | `LanMountainDesktop.ComponentSystem.dll` |
|
||||
| `**/*.json`(配置文件) | 同路径的 .json |
|
||||
| 其他无法映射的变更 | 保守标记 → 所有核心 .dll/.exe |
|
||||
|
||||
**方法二在 Plonds.Tool 中的新命令:**
|
||||
|
||||
```
|
||||
build-delta-from-commits --platform <platform>
|
||||
--current-version <version>
|
||||
--current-zip <file>
|
||||
--output-dir <dir>
|
||||
--channel <channel>
|
||||
--baseline-tag <tag>
|
||||
--current-tag <tag>
|
||||
[--source-dirs <dir1,dir2,...>]
|
||||
[--fallback-zip <file>]
|
||||
```
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--platform` | 是 | 平台标识 |
|
||||
| `--current-version` | 是 | 当前发布版本号 |
|
||||
| `--current-zip` | 是 | 当前版本的 payload zip |
|
||||
| `--output-dir` | 是 | 输出目录 |
|
||||
| `--channel` | 是 | 更新通道 |
|
||||
| `--baseline-tag` | 是 | 基线版本的 git tag |
|
||||
| `--current-tag` | 是 | 当前版本的 git tag |
|
||||
| `--source-dirs` | 否 | 要分析的源码目录列表(逗号分隔) |
|
||||
| `--fallback-zip` | 否 | 回退到方法一时使用的基线 zip |
|
||||
|
||||
**回退逻辑:**
|
||||
|
||||
如果 `git log` 分析后发现没有源码目录下的文件变更(比如只有 docs/ 变更),则自动回退到方法一:
|
||||
1. 如果提供了 `--fallback-zip` → 用方法一对比两个 zip
|
||||
2. 如果没有提供 → 生成全量更新(`isFullUpdate=true`)
|
||||
|
||||
### 7.5 方法二的 PLONDS.json 特殊处理
|
||||
|
||||
方法二无法像方法一那样生成完整的 `filesMap`(因为不知道哪些文件是 reuse 的),因此:
|
||||
|
||||
- `filesMap` 只包含映射到的变更文件(标记为 `add` 或 `replace`)
|
||||
- 不包含 `reuse` 和 `delete` 条目
|
||||
- `isFullUpdate` 始终为 `false`(除非回退到方法一且无基线)
|
||||
- `requiresCleanInstall` 根据 Launcher.exe 是否在映射到的变更文件列表中判断
|
||||
|
||||
### 7.6 工作流中的条件分支
|
||||
|
||||
```yaml
|
||||
- name: Run build-delta
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$COMPARE_METHOD" == "commit-analyze" ]]; then
|
||||
# 方法二
|
||||
dotnet run --project ... -- build-delta-from-commits \
|
||||
--platform windows-x64 \
|
||||
--current-version $RELEASE_VERSION \
|
||||
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||
--output-dir $PWD/plonds-output \
|
||||
--channel $RELEASE_CHANNEL \
|
||||
--baseline-tag $BASELINE_TAG \
|
||||
--current-tag $RELEASE_TAG \
|
||||
--fallback-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||
else
|
||||
# 方法一
|
||||
dotnet run --project ... -- build-delta \
|
||||
--platform windows-x64 \
|
||||
--current-version $RELEASE_VERSION \
|
||||
--current-zip $PWD/plonds-input/current-files-windows-x64.zip \
|
||||
--output-dir $PWD/plonds-output \
|
||||
--channel $RELEASE_CHANNEL \
|
||||
--hash-algorithm $HASH_ALGORITHM \
|
||||
--baseline-version $BASELINE_VERSION \
|
||||
--baseline-zip $PWD/plonds-input/baseline-files-windows-x64.zip
|
||||
fi
|
||||
```
|
||||
|
||||
方法二时,基线 zip 仍然需要下载(用于回退),但不需要解压(除非回退)。
|
||||
|
||||
### 7.7 两种方法的步骤差异
|
||||
|
||||
| 步骤 | 方法一 (file-compare) | 方法二 (commit-analyze) |
|
||||
|------|----------------------|------------------------|
|
||||
| 下载基线 zip | ✅ 需要 | ✅ 需要(用于回退) |
|
||||
| 下载当前 zip | ✅ | ✅ |
|
||||
| 解压两个 zip | ✅ | ✅ 只解压当前(回退时解压基线) |
|
||||
| git diff/log | ❌ | ✅ 需要 fetch-depth:0 |
|
||||
| 哈希对比 | ✅ 两个目录全量扫描 | ❌ 不做(除非回退) |
|
||||
| 源码→产物映射 | ❌ | ✅ |
|
||||
| 回退逻辑 | ❌ | ✅ 无源码变更时回退方法一 |
|
||||
|
||||
## 8. 不在本次改造范围内的事项
|
||||
|
||||
- 宿主侧客户端代码改造(PlondsUpdateApplier 等,后续单独设计)
|
||||
- Launcher 侧客户端代码改造(后续单独设计)
|
||||
- Plonds.Api 项目处置(后续决定是否保留)
|
||||
- `build-index`、`generate`、`publish`、`sign` 等旧 Tool 命令的清理(后续处理)
|
||||
@@ -8,7 +8,10 @@ Rebuild the settings window as an independent Fluent shell with a custom titleba
|
||||
|
||||
- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry.
|
||||
- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer.
|
||||
- Keep the titlebar and content area on one shared full-window background layer; the custom titlebar must remain transparent and must not paint a contrasting strip.
|
||||
- Avoid a visible titlebar bottom divider that makes the titlebar read as a separate color band.
|
||||
- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP.
|
||||
- Keep `FANavigationView` pane and content template backgrounds transparent in the settings shell so the navigation control does not reintroduce a second surface color.
|
||||
- Move the compact/minimal pane toggle from the navigation footer into the titlebar.
|
||||
- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights.
|
||||
- Add `auto` system material mode and make it the default.
|
||||
|
||||
@@ -15,11 +15,14 @@ Make the Settings > Update page the single user-facing control surface for the h
|
||||
- Users can opt into forced reinstall. When enabled, the update check targets the current version manifest where available and the UI labels the next payload as reinstall.
|
||||
- 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 and Launcher rollback ownership remain unchanged.
|
||||
- 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`.
|
||||
|
||||
@@ -8,7 +8,7 @@ This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration
|
||||
|
||||
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
|
||||
- The project has switched back to signed FileMap incremental assets as the primary update path.
|
||||
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
|
||||
- Host owns update install and rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows. Launcher only selects and starts the current app version.
|
||||
|
||||
## Migration Note
|
||||
|
||||
|
||||
364
.trae/testing/fused-desktop-manual-test-checklist.md
Normal file
364
.trae/testing/fused-desktop-manual-test-checklist.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 融合桌面功能手动测试清单
|
||||
|
||||
**测试日期**: 2026-06-08
|
||||
**测试人员**: ___________
|
||||
**构建版本**: ___________
|
||||
|
||||
---
|
||||
|
||||
## 测试环境准备
|
||||
|
||||
- [ ] 启用融合桌面功能(设置 -> 应用设置 -> EnableFusedDesktop = true)
|
||||
- [ ] 重启应用以加载融合桌面组件
|
||||
- [ ] 确认任务栏托盘图标可见
|
||||
|
||||
---
|
||||
|
||||
## 测试组 1: 编辑模式控制 ⭐⭐⭐
|
||||
|
||||
### 测试 1.1: 打开组件库进入编辑模式
|
||||
**步骤**:
|
||||
1. 右键点击托盘图标
|
||||
2. 选择"添加小组件"(或对应的菜单项)
|
||||
3. 观察融合桌面组件库窗口是否打开
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件库窗口成功打开
|
||||
- [ ] 已存在的桌面组件窗口的光标变为"移动"光标(十字箭头)
|
||||
- [ ] 桌面组件显示蓝色边框高亮
|
||||
- [ ] 桌面组件显示8个调整尺寸手柄(四角+四边)
|
||||
- [ ] 桌面组件内部UI变为不可交互(IsHitTestVisible = false)
|
||||
|
||||
**日志验证**:
|
||||
- 搜索日志: "Entered edit mode via library window open"
|
||||
|
||||
---
|
||||
|
||||
### 测试 1.2: 关闭组件库退出编辑模式
|
||||
**步骤**:
|
||||
1. 点击组件库窗口的关闭按钮(X)或按 ESC 键
|
||||
2. 观察桌面组件状态
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件库窗口关闭
|
||||
- [ ] 桌面组件的光标恢复正常
|
||||
- [ ] 蓝色边框高亮消失
|
||||
- [ ] 调整尺寸手柄消失
|
||||
- [ ] 桌面组件内部UI恢复可交互
|
||||
|
||||
**日志验证**:
|
||||
- 搜索日志: "Exited edit mode via library window close"
|
||||
|
||||
---
|
||||
|
||||
## 测试组 2: 组件添加与居中放置 ⭐⭐⭐
|
||||
|
||||
### 测试 2.1: 从组件库添加组件
|
||||
**步骤**:
|
||||
1. 打开组件库
|
||||
2. 选择一个分类(如"时钟")
|
||||
3. 观察预览区显示的组件
|
||||
4. 点击"添加小组件"按钮
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件成功添加到桌面
|
||||
- [ ] 组件居中显示在当前屏幕的工作区
|
||||
- [ ] 组件吸附到网格
|
||||
- [ ] 组件库窗口保持打开(根据规格要求)
|
||||
- [ ] 新组件立即显示蓝色边框和调整手柄(因为仍在编辑模式)
|
||||
|
||||
**日志验证**:
|
||||
- 搜索日志: "Added component '...' with placement '...' at grid"
|
||||
|
||||
---
|
||||
|
||||
### 测试 2.2: 连续添加多个组件
|
||||
**步骤**:
|
||||
1. 在组件库保持打开的状态下
|
||||
2. 连续添加3-5个不同的组件
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 每个组件都成功添加
|
||||
- [ ] 后添加的组件不会覆盖先前的组件位置
|
||||
- [ ] 所有组件都显示编辑模式视觉反馈
|
||||
|
||||
---
|
||||
|
||||
## 测试组 3: 组件拖拽移动 ⭐⭐⭐
|
||||
|
||||
### 测试 3.1: 在编辑模式下拖拽组件
|
||||
**步骤**:
|
||||
1. 打开组件库(进入编辑模式)
|
||||
2. 左键按住桌面组件
|
||||
3. 拖拽到不同位置
|
||||
4. 释放鼠标
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件跟随鼠标移动
|
||||
- [ ] 释放后组件吸附到最近的网格单元
|
||||
- [ ] 组件不会超出屏幕工作区边界
|
||||
- [ ] GridColumn 和 GridRow 正确更新
|
||||
|
||||
**日志验证**:
|
||||
- 搜索日志: "Edit mode set to true"
|
||||
|
||||
---
|
||||
|
||||
### 测试 3.2: 拖拽到屏幕底部
|
||||
**步骤**:
|
||||
1. 拖拽组件到屏幕最底部
|
||||
2. 释放鼠标
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件成功吸附到底部网格行
|
||||
- [ ] 组件不会被任务栏遮挡
|
||||
- [ ] 组件完全可见(不超出工作区)
|
||||
|
||||
---
|
||||
|
||||
### 测试 3.3: 拖拽到屏幕右侧
|
||||
**步骤**:
|
||||
1. 拖拽组件到屏幕最右侧
|
||||
2. 释放鼠标
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件成功吸附到最右侧网格列
|
||||
- [ ] 组件完全可见(不超出工作区)
|
||||
|
||||
---
|
||||
|
||||
## 测试组 4: 组件尺寸调整 ⭐⭐⭐⭐⭐
|
||||
|
||||
### 测试 4.1: 使用右下角手柄调整尺寸
|
||||
**步骤**:
|
||||
1. 进入编辑模式
|
||||
2. 左键按住组件右下角的调整手柄
|
||||
3. 向外拖拽增大尺寸
|
||||
4. 释放鼠标
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件尺寸实时变化
|
||||
- [ ] 释放后吸附到网格(宽度和高度都是 CellSize 的整数倍)
|
||||
- [ ] GridWidthCells 和 GridHeightCells 正确更新
|
||||
- [ ] 组件内容正确渲染新尺寸
|
||||
|
||||
**日志验证**:
|
||||
- 搜索日志: "Resize started. Handle=BottomRight"
|
||||
- 搜索日志: "Resize completed"
|
||||
|
||||
---
|
||||
|
||||
### 测试 4.2: 使用左上角手柄调整尺寸
|
||||
**步骤**:
|
||||
1. 拖拽左上角手柄
|
||||
2. 向内缩小组件
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件从左上角调整尺寸
|
||||
- [ ] 组件位置同步移动(保持右下角固定)
|
||||
- [ ] 释放后正确吸附到网格
|
||||
- [ ] 不会小于组件的 MinWidthCells 和 MinHeightCells
|
||||
|
||||
---
|
||||
|
||||
### 测试 4.3: 使用边缘手柄调整单一维度
|
||||
**步骤**:
|
||||
1. 拖拽右侧中间手柄(只调整宽度)
|
||||
2. 拖拽底部中间手柄(只调整高度)
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 只有对应维度的尺寸变化
|
||||
- [ ] 另一维度保持不变
|
||||
- [ ] 吸附逻辑正确
|
||||
|
||||
---
|
||||
|
||||
### 测试 4.4: 最小尺寸约束
|
||||
**步骤**:
|
||||
1. 尝试将组件缩小到极小尺寸
|
||||
2. 持续向内拖拽
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件停止在最小尺寸(50px 或 MinWidthCells/MinHeightCells)
|
||||
- [ ] 无法继续缩小
|
||||
|
||||
---
|
||||
|
||||
## 测试组 5: 网格吸附一致性 ⭐⭐⭐
|
||||
|
||||
### 测试 5.1: 添加大尺寸组件
|
||||
**步骤**:
|
||||
1. 添加一个 4x4 或更大的组件
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件正确居中
|
||||
- [ ] 跨越多个网格单元
|
||||
- [ ] 边界对齐网格线
|
||||
|
||||
---
|
||||
|
||||
### 测试 5.2: 拖拽大组件到边缘
|
||||
**步骤**:
|
||||
1. 拖拽大组件到屏幕边缘
|
||||
2. 释放
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件吸附时不会超出屏幕
|
||||
- [ ] 如果无法完全显示,自动调整到边界内最近的合法位置
|
||||
|
||||
---
|
||||
|
||||
## 测试组 6: 多屏幕场景 ⭐⭐
|
||||
|
||||
### 测试 6.1: 跨屏幕拖拽(如果有多显示器)
|
||||
**步骤**:
|
||||
1. 将组件拖拽到第二个显示器
|
||||
2. 释放
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件吸附到第二个显示器的网格
|
||||
- [ ] 使用第二个显示器的工作区计算网格
|
||||
|
||||
---
|
||||
|
||||
## 测试组 7: 组件删除 ⭐⭐
|
||||
|
||||
### 测试 7.1: 非编辑模式下右键删除
|
||||
**步骤**:
|
||||
1. 关闭组件库(退出编辑模式)
|
||||
2. 右键点击桌面组件
|
||||
3. 选择"移除组件"
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 右键菜单显示
|
||||
- [ ] 点击"移除组件"后窗口关闭
|
||||
- [ ] 组件从布局配置中移除
|
||||
|
||||
---
|
||||
|
||||
## 测试组 8: 持久化与重载 ⭐⭐⭐
|
||||
|
||||
### 测试 8.1: 重启后保持布局
|
||||
**步骤**:
|
||||
1. 添加多个组件,调整位置和尺寸
|
||||
2. 关闭应用
|
||||
3. 重新启动应用
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 所有组件在相同位置重新加载
|
||||
- [ ] 尺寸保持不变
|
||||
- [ ] 网格坐标保持一致
|
||||
|
||||
---
|
||||
|
||||
## 测试组 9: 预览布局计算 ⭐⭐
|
||||
|
||||
### 测试 9.1: 组件库预览保持比例
|
||||
**步骤**:
|
||||
1. 打开组件库
|
||||
2. 切换不同分类,观察不同尺寸的组件预览
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 横向组件(4x2)显示为宽大于高
|
||||
- [ ] 纵向组件(2x4)显示为高大于宽
|
||||
- [ ] 正方形组件(3x3)宽高相等
|
||||
- [ ] 预览尺寸适应窗口大小
|
||||
|
||||
---
|
||||
|
||||
### 测试 9.2: 调整组件库窗口尺寸
|
||||
**步骤**:
|
||||
1. 拖拽组件库窗口边框调整尺寸
|
||||
2. 观察预览区组件
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 预览组件尺寸自动调整
|
||||
- [ ] 保持组件原始宽高比
|
||||
- [ ] 不超出预览区边界
|
||||
|
||||
---
|
||||
|
||||
## 测试组 10: 边界情况 ⭐⭐
|
||||
|
||||
### 测试 10.1: 空布局启动
|
||||
**步骤**:
|
||||
1. 清空布局配置文件
|
||||
2. 启动应用
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 应用正常启动
|
||||
- [ ] 桌面无组件显示
|
||||
- [ ] 可正常打开组件库添加组件
|
||||
|
||||
---
|
||||
|
||||
### 测试 10.2: 编辑模式中拖拽组件库窗口
|
||||
**步骤**:
|
||||
1. 打开组件库
|
||||
2. 拖拽组件库窗口到不同位置
|
||||
3. 尝试拖拽桌面组件
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件库窗口可正常拖拽
|
||||
- [ ] 桌面组件仍可拖拽
|
||||
- [ ] 两者互不干扰
|
||||
|
||||
---
|
||||
|
||||
## 回归测试 ⭐
|
||||
|
||||
### 回归 1: 组件内部交互(非编辑模式)
|
||||
**步骤**:
|
||||
1. 退出编辑模式
|
||||
2. 与桌面组件交互(点击按钮、输入文字等)
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 组件内部UI完全可交互
|
||||
- [ ] 所有功能正常工作
|
||||
|
||||
---
|
||||
|
||||
### 回归 2: 底部窗口层级
|
||||
**步骤**:
|
||||
1. 打开其他应用窗口
|
||||
2. 最小化/移动窗口
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 桌面组件始终保持在最底层(BottomMost)
|
||||
- [ ] 其他窗口不会被组件遮挡
|
||||
|
||||
---
|
||||
|
||||
## 性能测试 ⭐
|
||||
|
||||
### 性能 1: 大量组件
|
||||
**步骤**:
|
||||
1. 添加 10-20 个组件到桌面
|
||||
|
||||
**预期结果**:
|
||||
- [ ] 拖拽仍然流畅
|
||||
- [ ] 编辑模式切换无延迟
|
||||
- [ ] CPU 和内存占用在合理范围
|
||||
|
||||
---
|
||||
|
||||
## 测试总结
|
||||
|
||||
**通过的测试**: _____ / 总计
|
||||
**失败的测试**: _____
|
||||
**阻塞问题**: _____
|
||||
|
||||
**关键问题列表**:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**改进建议**:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
---
|
||||
|
||||
**测试完成时间**: ___________
|
||||
**签名**: ___________
|
||||
1125
CODE_WIKI.md
1125
CODE_WIKI.md
File diff suppressed because it is too large
Load Diff
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
14
CheckIpcAot/CheckIpcAot.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dotnetCampus.Ipc" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
10
CheckIpcAot/Program.cs
Normal file
10
CheckIpcAot/Program.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using dotnetCampus.Ipc.CompilerServices.Attributes;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
[IpcPublic]
|
||||
public interface IMyService {
|
||||
Task<MyResult> DoWork(MyRequest req);
|
||||
}
|
||||
|
||||
public class MyResult { public string Msg {get;set;} }
|
||||
public class MyRequest { public string Data {get;set;} }
|
||||
@@ -4,6 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="12.0.3" />
|
||||
<PackageVersion Include="Avalonia.Angle.Windows.Natives" Version="2.1.25547.20250602" />
|
||||
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.3" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.3" />
|
||||
@@ -16,6 +17,7 @@
|
||||
<PackageVersion Include="Downloader" Version="5.4.0" />
|
||||
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview4" />
|
||||
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Win32" Version="8.3.1.3" />
|
||||
<PackageVersion Include="Lib.Harmony.Thin" Version="2.4.2" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
|
||||
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
|
||||
@@ -32,6 +34,7 @@
|
||||
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageVersion Include="PostHog" Version="2.7.1" />
|
||||
<PackageVersion Include="Sentry" Version="6.5.0" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Win32" Version="3.119.4-preview.1.1" />
|
||||
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
|
||||
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
|
||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
|
||||
@@ -40,4 +43,4 @@
|
||||
<PackageVersion Include="YamlDotNet" Version="17.1.0" />
|
||||
<PackageVersion Include="log4net" Version="3.3.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
204
LanDesktopPLONDS.installer/App.axaml
Normal file
204
LanDesktopPLONDS.installer/App.axaml
Normal file
@@ -0,0 +1,204 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:theme="using:Avalonia.Themes.Fluent"
|
||||
x:Class="LanDesktopPLONDS.Installer.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<FontFamily x:Key="AppFontFamily">Segoe UI, Microsoft YaHei UI</FontFamily>
|
||||
<FontFamily x:Key="InstallerIconFontFamily">Segoe MDL2 Assets</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>
|
||||
<theme:FluentTheme />
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
</Style>
|
||||
<Style Selector="UserControl">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.installer-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
<Setter Property="FontFamily" Value="{DynamicResource InstallerIconFontFamily}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="TextAlignment" 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 TextBlock.installer-icon">
|
||||
<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 TextBlock.installer-icon">
|
||||
<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 TextBlock.installer-icon">
|
||||
<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 TextBlock.installer-icon">
|
||||
<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>
|
||||
35
LanDesktopPLONDS.installer/App.axaml.cs
Normal file
35
LanDesktopPLONDS.installer/App.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
BIN
LanDesktopPLONDS.installer/Assets/logo.ico
Normal file
BIN
LanDesktopPLONDS.installer/Assets/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
45
LanDesktopPLONDS.installer/Compress-NativeLibrary.ps1
Normal file
45
LanDesktopPLONDS.installer/Compress-NativeLibrary.ps1
Normal file
@@ -0,0 +1,45 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $SourcePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $DestinationPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$source = Get-Item -LiteralPath $SourcePath
|
||||
$destinationDirectory = Split-Path -Parent $DestinationPath
|
||||
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
|
||||
|
||||
$existing = Get-Item -LiteralPath $DestinationPath -ErrorAction SilentlyContinue
|
||||
if ($existing -and $existing.LastWriteTimeUtc -ge $source.LastWriteTimeUtc -and $existing.Length -gt 0) {
|
||||
return
|
||||
}
|
||||
|
||||
$temporaryPath = "$DestinationPath.$PID.tmp"
|
||||
if (Test-Path -LiteralPath $temporaryPath) {
|
||||
Remove-Item -LiteralPath $temporaryPath -Force
|
||||
}
|
||||
|
||||
$inputStream = [System.IO.File]::OpenRead($source.FullName)
|
||||
try {
|
||||
$outputStream = [System.IO.File]::Create($temporaryPath)
|
||||
try {
|
||||
$gzipStream = New-Object System.IO.Compression.GZipStream($outputStream, [System.IO.Compression.CompressionMode]::Compress)
|
||||
try {
|
||||
$inputStream.CopyTo($gzipStream)
|
||||
}
|
||||
finally {
|
||||
$gzipStream.Dispose()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$outputStream.Dispose()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$inputStream.Dispose()
|
||||
}
|
||||
|
||||
Move-Item -LiteralPath $temporaryPath -Destination $DestinationPath -Force
|
||||
92
LanDesktopPLONDS.installer/InstallerStartupDiagnostics.cs
Normal file
92
LanDesktopPLONDS.installer/InstallerStartupDiagnostics.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer;
|
||||
|
||||
internal static class InstallerStartupDiagnostics
|
||||
{
|
||||
private const uint MessageBoxIconError = 0x00000010;
|
||||
private const uint MessageBoxOk = 0x00000000;
|
||||
|
||||
private static int _initialized;
|
||||
private static int _fatalMessageShown;
|
||||
|
||||
public static string LogPath => Path.Combine(GetLogDirectory(), "startup.log");
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _initialized, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
{
|
||||
var exception = args.ExceptionObject as Exception;
|
||||
ReportFatal("The installer encountered an unhandled startup error.", exception);
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
ReportFatal("The installer encountered an unobserved background error.", args.Exception);
|
||||
args.SetObserved();
|
||||
};
|
||||
|
||||
Log("Startup diagnostics initialized.");
|
||||
}
|
||||
|
||||
public static void Log(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(GetLogDirectory());
|
||||
File.AppendAllText(
|
||||
LogPath,
|
||||
$"[{DateTimeOffset.Now:O}] {message}{Environment.NewLine}",
|
||||
Encoding.UTF8);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Diagnostics must never become the reason the installer cannot start.
|
||||
}
|
||||
}
|
||||
|
||||
public static void ReportFatal(string message, Exception? exception)
|
||||
{
|
||||
Log(exception is null ? message : $"{message}{Environment.NewLine}{exception}");
|
||||
|
||||
if (!OperatingSystem.IsWindows() || Interlocked.Exchange(ref _fatalMessageShown, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var details = exception is null
|
||||
? message
|
||||
: $"{message}{Environment.NewLine}{Environment.NewLine}{exception.GetType().Name}: {exception.Message}";
|
||||
_ = MessageBox(
|
||||
IntPtr.Zero,
|
||||
$"{details}{Environment.NewLine}{Environment.NewLine}Log: {LogPath}",
|
||||
"LanDesktopPLONDS Installer",
|
||||
MessageBoxOk | MessageBoxIconError);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLogDirectory()
|
||||
{
|
||||
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
root = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(root, "LanMountainDesktop", "Installer", "logs");
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "MessageBoxW", CharSet = CharSet.Unicode)]
|
||||
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<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>
|
||||
<IlcOptimizationPreference>Size</IlcOptimizationPreference>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
<DebuggerSupport>false</DebuggerSupport>
|
||||
<EventSourceSupport>false</EventSourceSupport>
|
||||
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<MetadataUpdaterSupport>false</MetadataUpdaterSupport>
|
||||
<UseSystemResourceKeys>true</UseSystemResourceKeys>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target
|
||||
Name="PrepareInstallerEmbeddedNativeLibraries"
|
||||
BeforeTargets="AssignTargetPaths"
|
||||
Condition="'$(PublishAot)' == 'true' and '$(RuntimeIdentifier)' == 'win-x64'">
|
||||
<ItemGroup>
|
||||
<InstallerNativeLibrary
|
||||
Include="$(PkgHarfBuzzSharp_NativeAssets_Win32)\runtimes\win-x64\native\libHarfBuzzSharp.dll"
|
||||
CompressedName="libHarfBuzzSharp.dll.gz"
|
||||
Condition="Exists('$(PkgHarfBuzzSharp_NativeAssets_Win32)\runtimes\win-x64\native\libHarfBuzzSharp.dll')" />
|
||||
<InstallerNativeLibrary
|
||||
Include="$(PkgSkiaSharp_NativeAssets_Win32)\runtimes\win-x64\native\libSkiaSharp.dll"
|
||||
CompressedName="libSkiaSharp.dll.gz"
|
||||
Condition="Exists('$(PkgSkiaSharp_NativeAssets_Win32)\runtimes\win-x64\native\libSkiaSharp.dll')" />
|
||||
</ItemGroup>
|
||||
|
||||
<Error
|
||||
Condition="'@(InstallerNativeLibrary)' == ''"
|
||||
Text="NativeAOT installer native libraries were not found. Restore the installer with -p:PublishAot=true -r win-x64 before publishing." />
|
||||
|
||||
<MakeDir Directories="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\" />
|
||||
<Exec
|
||||
Command="powershell -NoProfile -ExecutionPolicy Bypass -File "$(MSBuildThisFileDirectory)Compress-NativeLibrary.ps1" -SourcePath "%(InstallerNativeLibrary.FullPath)" -DestinationPath "$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\%(InstallerNativeLibrary.CompressedName)"" />
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource
|
||||
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\libHarfBuzzSharp.dll.gz"
|
||||
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.libHarfBuzzSharp.dll.gz" />
|
||||
<EmbeddedResource
|
||||
Include="$(IntermediateOutputPath)embedded-native\$(RuntimeIdentifier)\libSkiaSharp.dll.gz"
|
||||
LogicalName="LanDesktopPLONDS.Installer.NativeLibraries.libSkiaSharp.dll.gz" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
35
LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
Normal file
35
LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<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.Angle.Windows.Natives" ExcludeAssets="all" PrivateAssets="all" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||
<PackageReference Include="HarfBuzzSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Win32" GeneratePathProperty="true" PrivateAssets="all" />
|
||||
<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>
|
||||
10
LanDesktopPLONDS.installer/Models/InstallerDeployProgress.cs
Normal file
10
LanDesktopPLONDS.installer/Models/InstallerDeployProgress.cs
Normal 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);
|
||||
10
LanDesktopPLONDS.installer/Models/InstallerStepId.cs
Normal file
10
LanDesktopPLONDS.installer/Models/InstallerStepId.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
public enum InstallerStepId
|
||||
{
|
||||
Welcome = 0,
|
||||
InstallLocation = 1,
|
||||
PrivacyConfirm = 2,
|
||||
Deploy = 3,
|
||||
Complete = 4
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
public sealed record InstallerWorkflowState(
|
||||
InstallerStepId CurrentStep,
|
||||
InstallerStepId MaxUnlockedStep,
|
||||
string InstallPath,
|
||||
bool PrivacyConfirmed,
|
||||
string? TargetVersion,
|
||||
string? ErrorMessage);
|
||||
179
LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs
Normal file
179
LanDesktopPLONDS.installer/NativeDependencyBootstrapper.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer;
|
||||
|
||||
internal static class NativeDependencyBootstrapper
|
||||
{
|
||||
private const string CacheRootEnvironmentVariable = "LANDESKTOPPLONDS_INSTALLER_NATIVE_CACHE";
|
||||
private const string ResourcePrefix = "LanDesktopPLONDS.Installer.NativeLibraries.";
|
||||
|
||||
private static readonly string[] NativeLibraryNames =
|
||||
[
|
||||
"libHarfBuzzSharp.dll",
|
||||
"libSkiaSharp.dll"
|
||||
];
|
||||
|
||||
public static bool TryPrepare()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var nativeDirectory = GetNativeDirectory();
|
||||
Directory.CreateDirectory(nativeDirectory);
|
||||
|
||||
var extractedLibraries = new List<string>(NativeLibraryNames.Length);
|
||||
foreach (var libraryName in NativeLibraryNames)
|
||||
{
|
||||
extractedLibraries.Add(ExtractLibrary(nativeDirectory, libraryName));
|
||||
}
|
||||
|
||||
AddToProcessDllSearchPath(nativeDirectory);
|
||||
|
||||
foreach (var libraryPath in extractedLibraries)
|
||||
{
|
||||
NativeLibrary.Load(libraryPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
InstallerStartupDiagnostics.Log($"Native dependency preparation failed: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetNativeDirectory()
|
||||
{
|
||||
var configuredCacheRoot = Environment.GetEnvironmentVariable(CacheRootEnvironmentVariable);
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var cacheRoot = !string.IsNullOrWhiteSpace(configuredCacheRoot)
|
||||
? configuredCacheRoot
|
||||
: string.IsNullOrWhiteSpace(localAppData)
|
||||
? Path.GetTempPath()
|
||||
: localAppData;
|
||||
|
||||
string? versionStamp = null;
|
||||
if (!string.IsNullOrWhiteSpace(Environment.ProcessPath))
|
||||
{
|
||||
versionStamp = FileVersionInfo.GetVersionInfo(Environment.ProcessPath).ProductVersion;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(versionStamp))
|
||||
{
|
||||
versionStamp = "dev";
|
||||
}
|
||||
|
||||
return Path.Combine(
|
||||
cacheRoot,
|
||||
"LanDesktopPLONDS",
|
||||
"Installer",
|
||||
"native",
|
||||
RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(),
|
||||
SanitizePathSegment(versionStamp));
|
||||
}
|
||||
|
||||
private static string ExtractLibrary(string nativeDirectory, string libraryName)
|
||||
{
|
||||
var resourceName = ResourcePrefix + libraryName + ".gz";
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var resource = assembly.GetManifestResourceStream(resourceName);
|
||||
if (resource is null)
|
||||
{
|
||||
var availableResources = string.Join(", ", assembly.GetManifestResourceNames());
|
||||
throw new FileNotFoundException(
|
||||
$"Missing embedded native installer library resource '{resourceName}'. Available resources: {availableResources}");
|
||||
}
|
||||
|
||||
var destinationPath = Path.Combine(nativeDirectory, libraryName);
|
||||
var temporaryPath = destinationPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
using (var gzip = new GZipStream(resource, CompressionMode.Decompress))
|
||||
using (var output = File.Create(temporaryPath))
|
||||
{
|
||||
gzip.CopyTo(output);
|
||||
}
|
||||
|
||||
if (File.Exists(destinationPath) && FilesEqual(destinationPath, temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
File.Move(temporaryPath, destinationPath, overwrite: true);
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
private static void AddToProcessDllSearchPath(string nativeDirectory)
|
||||
{
|
||||
var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
if (!currentPath.Contains(nativeDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PATH", nativeDirectory + Path.PathSeparator + currentPath);
|
||||
}
|
||||
|
||||
if (!SetDllDirectory(nativeDirectory))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastPInvokeError(), "Failed to update the process native DLL search path.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizePathSegment(string value)
|
||||
{
|
||||
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
value = value.Replace(invalidChar, '_');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static bool FilesEqual(string leftPath, string rightPath)
|
||||
{
|
||||
var left = new FileInfo(leftPath);
|
||||
var right = new FileInfo(rightPath);
|
||||
if (left.Length != right.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var leftStream = File.OpenRead(leftPath);
|
||||
using var rightStream = File.OpenRead(rightPath);
|
||||
var leftBuffer = new byte[81920];
|
||||
var rightBuffer = new byte[81920];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var leftRead = leftStream.Read(leftBuffer, 0, leftBuffer.Length);
|
||||
var rightRead = rightStream.Read(rightBuffer, 0, rightBuffer.Length);
|
||||
if (leftRead != rightRead)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leftRead == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
for (var i = 0; i < leftRead; i++)
|
||||
{
|
||||
if (leftBuffer[i] != rightBuffer[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32", EntryPoint = "SetDllDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetDllDirectory(string pathName);
|
||||
}
|
||||
39
LanDesktopPLONDS.installer/Program.cs
Normal file
39
LanDesktopPLONDS.installer/Program.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Win32;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
InstallerStartupDiagnostics.Initialize();
|
||||
try
|
||||
{
|
||||
InstallerStartupDiagnostics.Log("Preparing native dependencies.");
|
||||
if (!NativeDependencyBootstrapper.TryPrepare())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to prepare native dependencies.");
|
||||
}
|
||||
|
||||
InstallerStartupDiagnostics.Log("Starting Avalonia desktop lifetime.");
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
InstallerStartupDiagnostics.ReportFatal("The installer failed to start.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
{
|
||||
return AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.With(new Win32PlatformOptions
|
||||
{
|
||||
RenderingMode = [Win32RenderingMode.Software],
|
||||
CompositionMode = [Win32CompositionMode.RedirectionSurface]
|
||||
});
|
||||
}
|
||||
}
|
||||
3
LanDesktopPLONDS.installer/Properties/AssemblyInfo.cs
Normal file
3
LanDesktopPLONDS.installer/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
353
LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
Normal file
353
LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
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);
|
||||
|
||||
InstallerElevation.EnsureCanInstall(launcherRoot);
|
||||
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 = InstallerElevation.IsRunningElevated()
|
||||
? Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu)
|
||||
: Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
29
LanDesktopPLONDS.installer/Services/IOnlineInstallService.cs
Normal file
29
LanDesktopPLONDS.installer/Services/IOnlineInstallService.cs
Normal 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);
|
||||
}
|
||||
52
LanDesktopPLONDS.installer/Services/InstallerElevation.cs
Normal file
52
LanDesktopPLONDS.installer/Services/InstallerElevation.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
internal static class InstallerElevation
|
||||
{
|
||||
public static bool IsRunningElevated()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var principal = new WindowsPrincipal(identity);
|
||||
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
public static bool RequiresElevation(string installPath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(installPath);
|
||||
return IsUnderSpecialFolder(fullPath, Environment.SpecialFolder.ProgramFiles)
|
||||
|| IsUnderSpecialFolder(fullPath, Environment.SpecialFolder.ProgramFilesX86)
|
||||
|| IsUnderWindowsDirectory(fullPath);
|
||||
}
|
||||
|
||||
public static void EnsureCanInstall(string installPath)
|
||||
{
|
||||
if (RequiresElevation(installPath) && !IsRunningElevated())
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
"The selected installation path requires administrator permission. Restart the installer as administrator or choose a user-writable folder.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUnderSpecialFolder(string fullPath, Environment.SpecialFolder folder)
|
||||
{
|
||||
var root = Environment.GetFolderPath(folder);
|
||||
return !string.IsNullOrWhiteSpace(root) && InstallerPathGuard.IsSameOrChildPath(root, fullPath);
|
||||
}
|
||||
|
||||
private static bool IsUnderWindowsDirectory(string fullPath)
|
||||
{
|
||||
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
|
||||
return !string.IsNullOrWhiteSpace(windows) && InstallerPathGuard.IsSameOrChildPath(windows, fullPath);
|
||||
}
|
||||
}
|
||||
11
LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs
Normal file
11
LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs
Normal 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;
|
||||
151
LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
Normal file
151
LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
public static class InstallerPathGuard
|
||||
{
|
||||
public const string ApplicationDirectoryName = "LanMountainDesktop";
|
||||
|
||||
public static string GetDefaultInstallPath()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
localAppData = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return Path.Combine(localAppData, "Programs", 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;
|
||||
}
|
||||
}
|
||||
391
LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
Normal file
391
LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
83
LanDesktopPLONDS.installer/Services/OnlineInstallService.cs
Normal file
83
LanDesktopPLONDS.installer/Services/OnlineInstallService.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
83
LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
Normal file
83
LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
Normal 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);
|
||||
@@ -0,0 +1,22 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.ViewModels;
|
||||
|
||||
public sealed partial class InstallerStepViewModel(
|
||||
InstallerStepId stepId,
|
||||
string title,
|
||||
string iconGlyph) : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool _isUnlocked;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isSelected;
|
||||
|
||||
public InstallerStepId StepId { get; } = stepId;
|
||||
|
||||
public string Title { get; } = title;
|
||||
|
||||
public string IconGlyph { get; } = iconGlyph;
|
||||
}
|
||||
371
LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
Normal file
371
LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
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, "开始安装", "\uE768"),
|
||||
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", "\uE838"),
|
||||
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", "\uE946"),
|
||||
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", "\uE896"),
|
||||
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", "\uE73E")
|
||||
];
|
||||
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]}";
|
||||
}
|
||||
}
|
||||
535
LanDesktopPLONDS.installer/Views/MainWindow.axaml
Normal file
535
LanDesktopPLONDS.installer/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,535 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
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 TextBlock.installer-icon">
|
||||
<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}">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
FontSize="14" />
|
||||
</Button>
|
||||
<Button Classes="titlebar-icon-button"
|
||||
ToolTip.Tip="关闭"
|
||||
Click="OnCloseClick">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="{Binding IconGlyph}"
|
||||
Foreground="{DynamicResource InstallerTextSecondaryBrush}"
|
||||
FontSize="17"
|
||||
IsVisible="{Binding !IsSelected}" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="{Binding IconGlyph}"
|
||||
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}">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<TextBlock Text="开始安装" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="secondary-command"
|
||||
Command="{Binding CancelInstallCommand}"
|
||||
IsEnabled="{Binding IsInstalling}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<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}">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text=""
|
||||
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">
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
<TextBlock Text="上一步" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Classes="primary-command"
|
||||
Command="{Binding NextCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<TextBlock Text="下一步" />
|
||||
<TextBlock Classes="installer-icon"
|
||||
Text="" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
81
LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs
Normal file
81
LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
18
LanDesktopPLONDS.installer/app.Debug.manifest
Normal file
18
LanDesktopPLONDS.installer/app.Debug.manifest
Normal 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>
|
||||
18
LanDesktopPLONDS.installer/app.manifest
Normal file
18
LanDesktopPLONDS.installer/app.manifest
Normal 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>
|
||||
230
LanMountainDesktop.AirAppDevServer/AirAppDevServer.cs
Normal file
230
LanMountainDesktop.AirAppDevServer/AirAppDevServer.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Build.Locator;
|
||||
using Microsoft.Build.Execution;
|
||||
|
||||
namespace LanMountainDesktop.AirAppDevServer;
|
||||
|
||||
/// <summary>
|
||||
/// AirApp 开发服务器
|
||||
/// 提供文件监视、自动编译、热重载功能
|
||||
/// </summary>
|
||||
public sealed class AirAppDevServer
|
||||
{
|
||||
private readonly string _projectPath;
|
||||
private readonly int _port;
|
||||
private readonly bool _verbose;
|
||||
private FileSystemWatcher? _watcher;
|
||||
private DateTime _lastBuildTime = DateTime.MinValue;
|
||||
private readonly object _buildLock = new();
|
||||
private bool _isBuilding;
|
||||
|
||||
public AirAppDevServer(string projectPath, int port, bool verbose)
|
||||
{
|
||||
_projectPath = Path.GetFullPath(projectPath);
|
||||
_port = port;
|
||||
_verbose = verbose;
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
// 初始构建
|
||||
Console.WriteLine("🔨 初始构建中...");
|
||||
if (!BuildProject())
|
||||
{
|
||||
Console.WriteLine("❌ 初始构建失败");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
Console.WriteLine("✅ 初始构建成功");
|
||||
Console.WriteLine();
|
||||
|
||||
// 启动文件监视
|
||||
StartFileWatcher();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync()
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void StartFileWatcher()
|
||||
{
|
||||
_watcher = new FileSystemWatcher(_projectPath)
|
||||
{
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
|
||||
Filter = "*.*",
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
|
||||
_watcher.Changed += OnFileChanged;
|
||||
_watcher.Created += OnFileChanged;
|
||||
_watcher.Deleted += OnFileChanged;
|
||||
_watcher.Renamed += OnFileRenamed;
|
||||
|
||||
Console.WriteLine("👁️ 文件监视已启动,等待更改...");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private void OnFileChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
// 忽略 bin、obj、.vs 等目录
|
||||
if (e.FullPath.Contains("\\bin\\") ||
|
||||
e.FullPath.Contains("\\obj\\") ||
|
||||
e.FullPath.Contains("\\.vs\\") ||
|
||||
e.FullPath.Contains("\\.git\\"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 只处理源代码文件
|
||||
var ext = Path.GetExtension(e.FullPath).ToLowerInvariant();
|
||||
if (ext != ".cs" && ext != ".axaml" && ext != ".json" && ext != ".csproj")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复触发(文件保存时可能触发多次)
|
||||
var now = DateTime.Now;
|
||||
if ((now - _lastBuildTime).TotalMilliseconds < 500)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LogVerbose($"📝 检测到文件更改: {Path.GetFileName(e.FullPath)}");
|
||||
TriggerRebuild();
|
||||
}
|
||||
|
||||
private void OnFileRenamed(object sender, RenamedEventArgs e)
|
||||
{
|
||||
LogVerbose($"📝 检测到文件重命名: {Path.GetFileName(e.OldFullPath)} -> {Path.GetFileName(e.FullPath)}");
|
||||
TriggerRebuild();
|
||||
}
|
||||
|
||||
private void TriggerRebuild()
|
||||
{
|
||||
lock (_buildLock)
|
||||
{
|
||||
if (_isBuilding)
|
||||
{
|
||||
LogVerbose("⏳ 构建进行中,跳过此次触发");
|
||||
return;
|
||||
}
|
||||
|
||||
_isBuilding = true;
|
||||
}
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 短暂延迟,让文件写入完成
|
||||
Thread.Sleep(300);
|
||||
|
||||
Console.WriteLine("🔄 重新构建中...");
|
||||
var success = BuildProject();
|
||||
|
||||
_lastBuildTime = DateTime.Now;
|
||||
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine($"✅ 重新构建成功 [{DateTime.Now:HH:mm:ss}]");
|
||||
Console.WriteLine("♻️ 热重载已生效");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"❌ 重新构建失败 [{DateTime.Now:HH:mm:ss}]");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_buildLock)
|
||||
{
|
||||
_isBuilding = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private bool BuildProject()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 查找项目文件
|
||||
var projectFile = FindProjectFile();
|
||||
if (projectFile == null)
|
||||
{
|
||||
Console.WriteLine("❌ 未找到项目文件 (.csproj)");
|
||||
return false;
|
||||
}
|
||||
|
||||
LogVerbose($"📄 项目文件: {Path.GetFileName(projectFile)}");
|
||||
|
||||
// 使用 dotnet build
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"build \"{projectFile}\" -c Debug --nologo",
|
||||
WorkingDirectory = Path.GetDirectoryName(projectFile),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null)
|
||||
{
|
||||
Console.WriteLine("❌ 无法启动 dotnet build");
|
||||
return false;
|
||||
}
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
if (_verbose)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
Console.WriteLine(output);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
Console.WriteLine("❌ 构建错误:");
|
||||
Console.WriteLine(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"❌ 构建异常: {ex.Message}");
|
||||
if (_verbose)
|
||||
{
|
||||
Console.WriteLine(ex.StackTrace);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string? FindProjectFile()
|
||||
{
|
||||
var files = Directory.GetFiles(_projectPath, "*.csproj", SearchOption.TopDirectoryOnly);
|
||||
return files.Length > 0 ? files[0] : null;
|
||||
}
|
||||
|
||||
private void LogVerbose(string message)
|
||||
{
|
||||
if (_verbose)
|
||||
{
|
||||
Console.WriteLine($"[VERBOSE] {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
119
LanMountainDesktop.AirAppDevServer/AirAppPackager.cs
Normal file
119
LanMountainDesktop.AirAppDevServer/AirAppPackager.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace LanMountainDesktop.AirAppDevServer;
|
||||
|
||||
/// <summary>
|
||||
/// AirApp 打包工具
|
||||
/// 将 AirApp 项目打包为 .laapp 文件
|
||||
/// </summary>
|
||||
public sealed class AirAppPackager
|
||||
{
|
||||
private readonly string _projectPath;
|
||||
|
||||
public AirAppPackager(string projectPath)
|
||||
{
|
||||
_projectPath = Path.GetFullPath(projectPath);
|
||||
}
|
||||
|
||||
public async Task<string> PackageAsync(string? outputPath)
|
||||
{
|
||||
Console.WriteLine("🔨 构建项目...");
|
||||
if (!await BuildProjectAsync())
|
||||
{
|
||||
throw new InvalidOperationException("构建失败");
|
||||
}
|
||||
|
||||
var binPath = Path.Combine(_projectPath, "bin", "Release", "net10.0");
|
||||
if (!Directory.Exists(binPath))
|
||||
{
|
||||
binPath = Path.Combine(_projectPath, "bin", "Debug", "net10.0");
|
||||
if (!Directory.Exists(binPath))
|
||||
{
|
||||
throw new InvalidOperationException("未找到构建输出");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"📁 输出目录: {binPath}");
|
||||
|
||||
// 确定输出文件名
|
||||
var projectName = Path.GetFileNameWithoutExtension(
|
||||
Directory.GetFiles(_projectPath, "*.csproj").FirstOrDefault() ?? "AirApp");
|
||||
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
outputPath = Path.Combine(binPath, $"{projectName}.laapp");
|
||||
}
|
||||
else
|
||||
{
|
||||
outputPath = Path.GetFullPath(outputPath);
|
||||
if (Directory.Exists(outputPath))
|
||||
{
|
||||
outputPath = Path.Combine(outputPath, $"{projectName}.laapp");
|
||||
}
|
||||
}
|
||||
|
||||
// 删除旧的包
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
File.Delete(outputPath);
|
||||
}
|
||||
|
||||
Console.WriteLine($"📦 打包到: {outputPath}");
|
||||
|
||||
// 创建 ZIP 包
|
||||
using (var archive = ZipFile.Open(outputPath, ZipArchiveMode.Create))
|
||||
{
|
||||
var filesToPackage = Directory.GetFiles(binPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => !f.Contains(".pdb") && !f.EndsWith(".laapp"))
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"📄 打包 {filesToPackage.Count} 个文件...");
|
||||
|
||||
foreach (var file in filesToPackage)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(binPath, file);
|
||||
archive.CreateEntryFromFile(file, relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"✅ 包大小: {new FileInfo(outputPath).Length / 1024} KB");
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
private async Task<bool> BuildProjectAsync()
|
||||
{
|
||||
var projectFile = Directory.GetFiles(_projectPath, "*.csproj").FirstOrDefault();
|
||||
if (projectFile == null)
|
||||
{
|
||||
Console.WriteLine("❌ 未找到项目文件");
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"build \"{projectFile}\" -c Release --nologo",
|
||||
WorkingDirectory = _projectPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return false;
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = await process.StandardError.ReadToEndAsync();
|
||||
Console.WriteLine($"❌ 构建错误:\n{error}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
129
LanMountainDesktop.AirAppDevServer/AirAppPreviewer.cs
Normal file
129
LanMountainDesktop.AirAppDevServer/AirAppPreviewer.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.AirAppDevServer;
|
||||
|
||||
/// <summary>
|
||||
/// AirApp 预览工具
|
||||
/// 在独立窗口中预览组件或窗口,无需安装到宿主
|
||||
/// </summary>
|
||||
public sealed class AirAppPreviewer
|
||||
{
|
||||
private readonly string _projectPath;
|
||||
|
||||
public AirAppPreviewer(string projectPath)
|
||||
{
|
||||
_projectPath = Path.GetFullPath(projectPath);
|
||||
}
|
||||
|
||||
public async Task PreviewComponentAsync(string componentId)
|
||||
{
|
||||
Console.WriteLine($"🎨 预览组件: {componentId}");
|
||||
await LaunchPreviewAsync("component", componentId);
|
||||
}
|
||||
|
||||
public async Task PreviewWindowAsync(string windowId)
|
||||
{
|
||||
Console.WriteLine($"🪟 预览窗口: {windowId}");
|
||||
await LaunchPreviewAsync("window", windowId);
|
||||
}
|
||||
|
||||
public async Task PreviewAllAsync()
|
||||
{
|
||||
Console.WriteLine("📋 加载 AirApp 清单...");
|
||||
|
||||
var manifest = await LoadManifestAsync();
|
||||
if (manifest == null)
|
||||
{
|
||||
Console.WriteLine("❌ 未找到 airapp.json");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"✅ AirApp: {manifest.Name}");
|
||||
Console.WriteLine();
|
||||
|
||||
// 显示可用的组件和窗口
|
||||
if (manifest.Components?.Count > 0)
|
||||
{
|
||||
Console.WriteLine("📦 可用组件:");
|
||||
foreach (var comp in manifest.Components)
|
||||
{
|
||||
Console.WriteLine($" - {comp.Id}: {comp.Name}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
if (manifest.Windows?.Count > 0)
|
||||
{
|
||||
Console.WriteLine("🪟 可用窗口:");
|
||||
foreach (var win in manifest.Windows)
|
||||
{
|
||||
Console.WriteLine($" - {win.Id}: {win.Name}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("使用以下命令预览:");
|
||||
Console.WriteLine(" airapp-dev preview --component <component-id>");
|
||||
Console.WriteLine(" airapp-dev preview --window <window-id>");
|
||||
}
|
||||
|
||||
private async Task LaunchPreviewAsync(string type, string id)
|
||||
{
|
||||
// 确保项目已构建
|
||||
var binPath = Path.Combine(_projectPath, "bin", "Debug", "net10.0");
|
||||
if (!Directory.Exists(binPath))
|
||||
{
|
||||
Console.WriteLine("❌ 未找到构建输出,请先运行: dotnet build");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"📁 输出路径: {binPath}");
|
||||
Console.WriteLine("🚀 启动预览窗口...");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("💡 提示: 关闭预览窗口以退出");
|
||||
Console.WriteLine();
|
||||
|
||||
// TODO: 这里需要启动一个预览宿主应用
|
||||
// 预览宿主会加载 AirApp 并显示指定的组件或窗口
|
||||
Console.WriteLine("⚠️ 预览功能需要配合 LanMountainDesktop 宿主运行");
|
||||
Console.WriteLine(" 暂时请使用: dotnet run --project LanMountainDesktop.csproj -- --debug-airapp <path>");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ManifestModel?> LoadManifestAsync()
|
||||
{
|
||||
var manifestPath = Path.Combine(_projectPath, "airapp.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(manifestPath);
|
||||
return JsonSerializer.Deserialize<ManifestModel>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class ManifestModel
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public List<ComponentModel>? Components { get; set; }
|
||||
public List<WindowModel>? Windows { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ComponentModel
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
|
||||
private sealed class WindowModel
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
<PackageReference Include="Microsoft.Build.Locator" Version="1.7.8" />
|
||||
<PackageReference Include="Microsoft.Build" Version="17.11.4" />
|
||||
<PackageReference Include="Microsoft.Build.Framework" Version="17.11.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.AirAppSdk\LanMountainDesktop.AirAppSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
149
LanMountainDesktop.AirAppDevServer/Program.cs
Normal file
149
LanMountainDesktop.AirAppDevServer/Program.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System.CommandLine;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.AirAppDevServer;
|
||||
|
||||
/// <summary>
|
||||
/// AirApp 开发服务器主程序
|
||||
/// 提供热重载、实时预览等开发功能
|
||||
/// </summary>
|
||||
class Program
|
||||
{
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
var rootCommand = new RootCommand("LanMountainDesktop AirApp 开发服务器");
|
||||
|
||||
// 开发模式命令
|
||||
var devCommand = new Command("dev", "启动开发服务器(支持热重载)");
|
||||
var projectPathOption = new Option<string>(
|
||||
aliases: new[] { "--project", "-p" },
|
||||
description: "AirApp 项目路径",
|
||||
getDefaultValue: () => Directory.GetCurrentDirectory());
|
||||
var portOption = new Option<int>(
|
||||
aliases: new[] { "--port" },
|
||||
description: "开发服务器端口",
|
||||
getDefaultValue: () => 5000);
|
||||
var verboseOption = new Option<bool>(
|
||||
aliases: new[] { "--verbose", "-v" },
|
||||
description: "显示详细日志");
|
||||
|
||||
devCommand.AddOption(projectPathOption);
|
||||
devCommand.AddOption(portOption);
|
||||
devCommand.AddOption(verboseOption);
|
||||
|
||||
devCommand.SetHandler(async (projectPath, port, verbose) =>
|
||||
{
|
||||
await RunDevServerAsync(projectPath, port, verbose);
|
||||
}, projectPathOption, portOption, verboseOption);
|
||||
|
||||
// 预览命令
|
||||
var previewCommand = new Command("preview", "预览 AirApp(无需安装到宿主)");
|
||||
var componentOption = new Option<string?>(
|
||||
aliases: new[] { "--component", "-c" },
|
||||
description: "要预览的组件 ID");
|
||||
var windowOption = new Option<string?>(
|
||||
aliases: new[] { "--window", "-w" },
|
||||
description: "要预览的窗口 ID");
|
||||
|
||||
previewCommand.AddOption(projectPathOption);
|
||||
previewCommand.AddOption(componentOption);
|
||||
previewCommand.AddOption(windowOption);
|
||||
|
||||
previewCommand.SetHandler(async (projectPath, component, window) =>
|
||||
{
|
||||
await RunPreviewAsync(projectPath, component, window);
|
||||
}, projectPathOption, componentOption, windowOption);
|
||||
|
||||
// 打包命令
|
||||
var packageCommand = new Command("package", "打包 AirApp 为 .laapp 文件");
|
||||
var outputOption = new Option<string?>(
|
||||
aliases: new[] { "--output", "-o" },
|
||||
description: "输出路径");
|
||||
|
||||
packageCommand.AddOption(projectPathOption);
|
||||
packageCommand.AddOption(outputOption);
|
||||
|
||||
packageCommand.SetHandler(async (projectPath, output) =>
|
||||
{
|
||||
await PackageAirAppAsync(projectPath, output);
|
||||
}, projectPathOption, outputOption);
|
||||
|
||||
rootCommand.AddCommand(devCommand);
|
||||
rootCommand.AddCommand(previewCommand);
|
||||
rootCommand.AddCommand(packageCommand);
|
||||
|
||||
return await rootCommand.InvokeAsync(args);
|
||||
}
|
||||
|
||||
static async Task RunDevServerAsync(string projectPath, int port, bool verbose)
|
||||
{
|
||||
Console.WriteLine("🚀 启动 AirApp 开发服务器...");
|
||||
Console.WriteLine($"📁 项目路径: {projectPath}");
|
||||
Console.WriteLine($"🔌 端口: {port}");
|
||||
Console.WriteLine();
|
||||
|
||||
var server = new AirAppDevServer(projectPath, port, verbose);
|
||||
await server.StartAsync();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("✅ 开发服务器已启动");
|
||||
Console.WriteLine($"🌐 预览地址: http://localhost:{port}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("按 Ctrl+C 停止服务器...");
|
||||
Console.WriteLine();
|
||||
|
||||
// 等待取消信号
|
||||
var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (sender, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("🛑 正在停止服务器...");
|
||||
}
|
||||
|
||||
await server.StopAsync();
|
||||
Console.WriteLine("✅ 服务器已停止");
|
||||
}
|
||||
|
||||
static async Task RunPreviewAsync(string projectPath, string? component, string? window)
|
||||
{
|
||||
Console.WriteLine("👁️ 启动 AirApp 预览...");
|
||||
Console.WriteLine($"📁 项目路径: {projectPath}");
|
||||
|
||||
var previewer = new AirAppPreviewer(projectPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(component))
|
||||
{
|
||||
await previewer.PreviewComponentAsync(component);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(window))
|
||||
{
|
||||
await previewer.PreviewWindowAsync(window);
|
||||
}
|
||||
else
|
||||
{
|
||||
await previewer.PreviewAllAsync();
|
||||
}
|
||||
}
|
||||
|
||||
static async Task PackageAirAppAsync(string projectPath, string? output)
|
||||
{
|
||||
Console.WriteLine("📦 打包 AirApp...");
|
||||
Console.WriteLine($"📁 项目路径: {projectPath}");
|
||||
|
||||
var packager = new AirAppPackager(projectPath);
|
||||
var outputPath = await packager.PackageAsync(output);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"✅ 打包完成: {outputPath}");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppHostLocator
|
||||
{
|
||||
private const string WindowsExecutableName = "LanMountainDesktop.AirAppHost.exe";
|
||||
private const string UnixExecutableName = "LanMountainDesktop.AirAppHost";
|
||||
private const string DllName = "LanMountainDesktop.AirAppHost.dll";
|
||||
|
||||
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||
? WindowsExecutableName
|
||||
: UnixExecutableName;
|
||||
|
||||
public string Resolve(string? packageRoot, string? hostPath = null)
|
||||
{
|
||||
foreach (var candidate in EnumerateCandidates(packageRoot, hostPath))
|
||||
@@ -22,18 +27,18 @@ internal sealed class AirAppHostLocator
|
||||
{
|
||||
foreach (var root in EnumerateRoots(packageRoot, hostPath))
|
||||
{
|
||||
yield return Path.Combine(root, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(root, "AirAppHost", ExecutableName);
|
||||
yield return Path.Combine(root, "AirAppHost", DllName);
|
||||
yield return Path.Combine(root, WindowsExecutableName);
|
||||
yield return Path.Combine(root, ExecutableName);
|
||||
yield return Path.Combine(root, DllName);
|
||||
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
foreach (var deploymentDirectory in Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", WindowsExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", ExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, "AirAppHost", DllName);
|
||||
yield return Path.Combine(deploymentDirectory, WindowsExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, ExecutableName);
|
||||
yield return Path.Combine(deploymentDirectory, DllName);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +57,7 @@ internal sealed class AirAppHostLocator
|
||||
"Release",
|
||||
#endif
|
||||
"net10.0",
|
||||
WindowsExecutableName);
|
||||
ExecutableName);
|
||||
|
||||
yield return Path.Combine(
|
||||
current.FullName,
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal static class AirAppInstanceKey
|
||||
{
|
||||
@@ -17,8 +17,6 @@ internal static class AirAppInstanceKey
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@ using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
internal sealed class AirAppLifecycleService : IAirAppLifecycleService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IAirAppProcessStarter _processStarter;
|
||||
private readonly Dictionary<string, ManagedAirAppInstance> _instances = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public LauncherAirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||
public AirAppLifecycleService(IAirAppProcessStarter processStarter)
|
||||
{
|
||||
_processStarter = processStarter;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var appId = Normalize(request.AppId, "unknown");
|
||||
var instanceKey = AirAppInstanceKey.Build(appId, request.SourceComponentId, request.SourcePlacementId);
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Air APP open requested. AppId='{appId}'; InstanceKey='{instanceKey}'; RequesterProcessId={request.RequesterProcessId}.");
|
||||
|
||||
lock (_gate)
|
||||
@@ -57,12 +57,12 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||
AirAppRuntimeLogger.Info($"Started Air APP. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}.");
|
||||
return Task.FromResult(BuildResult(true, "started", "Started Air APP instance.", instance));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||
AirAppRuntimeLogger.Warn($"Failed to start Air APP '{appId}': {ex.Message}");
|
||||
return Task.FromResult(BuildResult(false, "start_failed", ex.Message, null));
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
request.SourceComponentId,
|
||||
request.SourcePlacementId);
|
||||
_instances[instanceKey] = instance;
|
||||
Logger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||
AirAppRuntimeLogger.Info($"Registered Air APP. AppId='{instance.AppId}'; InstanceKey='{instanceKey}'; ProcessId={instance.ProcessId}.");
|
||||
return Task.FromResult(BuildResult(true, "registered", "Air APP instance registered.", instance));
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
(processId <= 0 || instance.ProcessId == processId))
|
||||
{
|
||||
_instances.Remove(instanceKey);
|
||||
Logger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||
AirAppRuntimeLogger.Info($"Unregistered Air APP. InstanceKey='{instanceKey}'; ProcessId={processId}.");
|
||||
return Task.FromResult(BuildResult(true, "unregistered", "Air APP instance unregistered.", instance));
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
foreach (var key in exitedKeys)
|
||||
{
|
||||
_instances.Remove(key);
|
||||
Logger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||
AirAppRuntimeLogger.Info($"Pruned exited Air APP instance. InstanceKey='{key}'.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsProcessAlive(int processId)
|
||||
internal static bool IsProcessAlive(int processId)
|
||||
{
|
||||
if (processId <= 0)
|
||||
{
|
||||
@@ -257,9 +257,7 @@ internal sealed class LauncherAirAppLifecycleService : IAirAppLifecycleService
|
||||
|
||||
private static string Normalize(string? value, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? fallback
|
||||
: value.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
|
||||
private const int SW_SHOWNORMAL = 1;
|
||||
@@ -0,0 +1,29 @@
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppRuntimeControlService : IAirAppRuntimeControlService
|
||||
{
|
||||
private readonly AirAppRuntimeLifetime _lifetime;
|
||||
|
||||
public AirAppRuntimeControlService(AirAppRuntimeLifetime lifetime)
|
||||
{
|
||||
_lifetime = lifetime;
|
||||
}
|
||||
|
||||
public Task<AirAppRuntimeControlResult> AttachHostAsync(int hostProcessId)
|
||||
{
|
||||
_lifetime.AttachHost(hostProcessId);
|
||||
var status = _lifetime.GetStatus();
|
||||
return Task.FromResult(new AirAppRuntimeControlResult(
|
||||
hostProcessId > 0,
|
||||
hostProcessId > 0 ? "host_attached" : "invalid_host_pid",
|
||||
hostProcessId > 0 ? "AirApp runtime host process attached." : "Host process id must be positive.",
|
||||
status));
|
||||
}
|
||||
|
||||
public Task<AirAppRuntimeStatus> GetStatusAsync()
|
||||
{
|
||||
return Task.FromResult(_lifetime.GetStatus());
|
||||
}
|
||||
}
|
||||
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
29
LanMountainDesktop.AirAppRuntime/AirAppRuntimeIpcHost.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppRuntimeIpcHost : IDisposable
|
||||
{
|
||||
private readonly PublicIpcHostService _host;
|
||||
|
||||
public AirAppRuntimeIpcHost(
|
||||
AirAppLifecycleService lifecycleService,
|
||||
AirAppRuntimeControlService controlService)
|
||||
{
|
||||
_host = new PublicIpcHostService(IpcConstants.AirAppRuntimePipeName);
|
||||
_host.RegisterPublicService<IAirAppLifecycleService>(lifecycleService);
|
||||
_host.RegisterPublicService<IAirAppRuntimeControlService>(controlService);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_host.Start();
|
||||
AirAppRuntimeLogger.Info($"Air APP runtime IPC started. Pipe='{IpcConstants.AirAppRuntimePipeName}'.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
77
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLifetime.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed class AirAppRuntimeLifetime
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly DateTimeOffset _startedAtUtc = DateTimeOffset.UtcNow;
|
||||
private readonly AirAppLifecycleService _lifecycleService;
|
||||
private readonly int _launcherProcessId;
|
||||
private readonly int _requesterProcessId;
|
||||
private int _hostProcessId;
|
||||
private DateTimeOffset _updatedAtUtc;
|
||||
|
||||
public AirAppRuntimeLifetime(AirAppRuntimeOptions options, AirAppLifecycleService lifecycleService)
|
||||
{
|
||||
_lifecycleService = lifecycleService;
|
||||
_launcherProcessId = options.LauncherProcessId;
|
||||
_requesterProcessId = options.RequesterProcessId;
|
||||
_hostProcessId = options.RequesterProcessId;
|
||||
_updatedAtUtc = _startedAtUtc;
|
||||
}
|
||||
|
||||
public void AttachHost(int hostProcessId)
|
||||
{
|
||||
if (hostProcessId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_hostProcessId = hostProcessId;
|
||||
_updatedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
AirAppRuntimeLogger.Info($"Attached host process. HostPid={hostProcessId}.");
|
||||
}
|
||||
|
||||
public bool ShouldKeepAlive()
|
||||
{
|
||||
var status = GetStatus();
|
||||
return status.LauncherProcessAlive ||
|
||||
status.HostProcessAlive ||
|
||||
IsProcessAlive(_requesterProcessId) ||
|
||||
status.HasLiveAirApps;
|
||||
}
|
||||
|
||||
public AirAppRuntimeStatus GetStatus()
|
||||
{
|
||||
int hostPid;
|
||||
DateTimeOffset updatedAt;
|
||||
lock (_gate)
|
||||
{
|
||||
hostPid = _hostProcessId;
|
||||
updatedAt = _updatedAtUtc;
|
||||
}
|
||||
|
||||
var launcherAlive = IsProcessAlive(_launcherProcessId);
|
||||
var hostAlive = IsProcessAlive(hostPid);
|
||||
var hasLiveAirApps = _lifecycleService.HasLiveAirApps();
|
||||
return new AirAppRuntimeStatus(
|
||||
Environment.ProcessId,
|
||||
_launcherProcessId,
|
||||
hostPid,
|
||||
launcherAlive,
|
||||
hostAlive,
|
||||
hasLiveAirApps,
|
||||
_startedAtUtc,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
internal static bool IsProcessAlive(int processId)
|
||||
{
|
||||
return AirAppLifecycleService.IsProcessAlive(processId);
|
||||
}
|
||||
}
|
||||
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
16
LanMountainDesktop.AirAppRuntime/AirAppRuntimeLogger.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal static class AirAppRuntimeLogger
|
||||
{
|
||||
public static void Info(string message) => Trace.WriteLine($"[AirAppRuntime] INFO {message}");
|
||||
|
||||
public static void Warn(string message) => Trace.WriteLine($"[AirAppRuntime] WARN {message}");
|
||||
|
||||
public static void Warn(string message, Exception ex) =>
|
||||
Trace.WriteLine($"[AirAppRuntime] WARN {message} {ex}");
|
||||
|
||||
public static void Error(string message, Exception ex) =>
|
||||
Trace.WriteLine($"[AirAppRuntime] ERROR {message} {ex}");
|
||||
}
|
||||
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
66
LanMountainDesktop.AirAppRuntime/AirAppRuntimeOptions.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal sealed record AirAppRuntimeOptions(
|
||||
string? AppRoot,
|
||||
string? DataRoot,
|
||||
int LauncherProcessId,
|
||||
int RequesterProcessId)
|
||||
{
|
||||
public static AirAppRuntimeOptions Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < args.Count; index++)
|
||||
{
|
||||
var current = args[index];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var equalsIndex = key.IndexOf('=');
|
||||
if (equalsIndex >= 0)
|
||||
{
|
||||
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 < args.Count && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
values[key] = args[++index];
|
||||
}
|
||||
else
|
||||
{
|
||||
values[key] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
return new AirAppRuntimeOptions(
|
||||
GetOptionalPath(values, "app-root"),
|
||||
GetOptionalPath(values, "data-root"),
|
||||
GetInt(values, "launcher-pid"),
|
||||
GetInt(values, "requester-pid"));
|
||||
}
|
||||
|
||||
private static string? GetOptionalPath(IReadOnlyDictionary<string, string> values, string key)
|
||||
{
|
||||
return values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? Path.GetFullPath(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int GetInt(IReadOnlyDictionary<string, string> values, string key)
|
||||
{
|
||||
return values.TryGetValue(key, out var value) &&
|
||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
namespace LanMountainDesktop.Launcher.AirApp;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal interface IAirAppProcessStarter
|
||||
{
|
||||
@@ -12,20 +14,17 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
private readonly Func<string?> _packageRootProvider;
|
||||
private readonly Func<string?> _hostPathProvider;
|
||||
private readonly Func<string?> _dataRootProvider;
|
||||
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
|
||||
|
||||
public AirAppProcessStarter(
|
||||
AirAppHostLocator locator,
|
||||
Func<string?> packageRootProvider,
|
||||
Func<string?> hostPathProvider,
|
||||
Func<string?> dataRootProvider,
|
||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||
Func<string?> dataRootProvider)
|
||||
{
|
||||
_locator = locator;
|
||||
_packageRootProvider = packageRootProvider;
|
||||
_hostPathProvider = hostPathProvider;
|
||||
_dataRootProvider = dataRootProvider;
|
||||
_runtimeProbeOptions = runtimeProbeOptions;
|
||||
}
|
||||
|
||||
public Process? Start(
|
||||
@@ -36,12 +35,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
string? sourcePlacementId)
|
||||
{
|
||||
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
|
||||
var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
|
||||
var startInfo = CreateStartInfo(hostPath);
|
||||
|
||||
AddArgument(startInfo, "--app-id", appId);
|
||||
AddArgument(startInfo, "--session-id", sessionId);
|
||||
AddArgument(startInfo, "--instance-key", instanceKey);
|
||||
AddArgument(startInfo, "--launcher-pipe", LanMountainDesktop.Shared.IPC.IpcConstants.AirAppLifecyclePipeName);
|
||||
AddArgument(startInfo, "--launcher-pipe", IpcConstants.AirAppRuntimePipeName);
|
||||
var dataRoot = _dataRootProvider();
|
||||
if (!string.IsNullOrWhiteSpace(dataRoot))
|
||||
{
|
||||
@@ -58,7 +57,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
AddArgument(startInfo, "--source-placement-id", sourcePlacementId.Trim());
|
||||
}
|
||||
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Starting AirAppHost. AppId='{appId}'; InstanceKey='{instanceKey}'; HostPath='{hostPath}'; DataRoot='{dataRoot ?? string.Empty}'.");
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is not null)
|
||||
@@ -68,12 +67,12 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.Info(
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"AirAppHost exited. AppId='{appId}'; InstanceKey='{instanceKey}'; ProcessId={process.Id}; ExitCode={process.ExitCode}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||
AirAppRuntimeLogger.Warn($"Failed to log AirAppHost exit: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -81,54 +80,11 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
|
||||
return process;
|
||||
}
|
||||
|
||||
internal static ProcessStartInfo CreateStartInfo(
|
||||
string hostPath,
|
||||
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
|
||||
internal static ProcessStartInfo CreateStartInfo(string hostPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
|
||||
{
|
||||
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||
if (!executableRuntime.IsAvailable)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||
executableRuntime.Message);
|
||||
}
|
||||
}
|
||||
|
||||
startInfo.FileName = hostPath;
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
|
||||
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
|
||||
runtime.Message);
|
||||
}
|
||||
|
||||
startInfo.FileName = runtime.DotNetHostPath;
|
||||
startInfo.ArgumentList.Add(hostPath);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
startInfo.FileName = "dotnet";
|
||||
startInfo.ArgumentList.Add(hostPath);
|
||||
return startInfo;
|
||||
return AirAppRuntimeProcessStarter.CreateStartInfo(hostPath);
|
||||
}
|
||||
|
||||
|
||||
private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
|
||||
{
|
||||
startInfo.ArgumentList.Add(name);
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RollForward>LatestMajor</RollForward>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishAot>false</PublishAot>
|
||||
<SelfContained>false</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<PublishReadyToRun>false</PublishReadyToRun>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<ApplicationIcon>..\LanMountainDesktop\Assets\logo_nightly.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
40
LanMountainDesktop.AirAppRuntime/Program.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace LanMountainDesktop.AirAppRuntime;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
var options = AirAppRuntimeOptions.Parse(args);
|
||||
AirAppRuntimeLogger.Info(
|
||||
$"Starting. AppRoot='{options.AppRoot ?? string.Empty}'; DataRoot='{options.DataRoot ?? string.Empty}'; " +
|
||||
$"LauncherPid={options.LauncherProcessId}; RequesterPid={options.RequesterProcessId}.");
|
||||
|
||||
try
|
||||
{
|
||||
var lifecycleService = new AirAppLifecycleService(
|
||||
new AirAppProcessStarter(
|
||||
new AirAppHostLocator(),
|
||||
() => options.AppRoot,
|
||||
() => null,
|
||||
() => options.DataRoot));
|
||||
var lifetime = new AirAppRuntimeLifetime(options, lifecycleService);
|
||||
var controlService = new AirAppRuntimeControlService(lifetime);
|
||||
|
||||
using var ipcHost = new AirAppRuntimeIpcHost(lifecycleService, controlService);
|
||||
ipcHost.Start();
|
||||
|
||||
while (lifetime.ShouldKeepAlive())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
AirAppRuntimeLogger.Info("Exiting because launcher, host, requester, and AirApp windows are gone.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AirAppRuntimeLogger.Error("Unhandled runtime failure.", ex);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
49
LanMountainDesktop.AirAppSdk/AirAppAppearanceSnapshot.cs
Normal file
49
LanMountainDesktop.AirAppSdk/AirAppAppearanceSnapshot.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the current appearance settings.
|
||||
/// </summary>
|
||||
public sealed class AirAppAppearanceSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether dark mode is enabled.
|
||||
/// </summary>
|
||||
public bool IsDarkMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary accent color.
|
||||
/// </summary>
|
||||
public Color AccentColor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the glass effect opacity (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public double GlassOpacity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the corner radius preset.
|
||||
/// </summary>
|
||||
public AirAppCornerRadiusPreset CornerRadiusPreset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the background color.
|
||||
/// </summary>
|
||||
public Color BackgroundColor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the foreground (text) color.
|
||||
/// </summary>
|
||||
public Color ForegroundColor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the border color.
|
||||
/// </summary>
|
||||
public Color BorderColor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional custom properties.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? CustomProperties { get; init; }
|
||||
}
|
||||
119
LanMountainDesktop.AirAppSdk/AirAppBase.cs
Normal file
119
LanMountainDesktop.AirAppSdk/AirAppBase.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for AirApp implementations.
|
||||
/// Inherit from this class and apply the [AirAppEntrance] attribute.
|
||||
/// </summary>
|
||||
public abstract class AirAppBase : IAirApp
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the runtime context after the AirApp has started.
|
||||
/// Available after OnStartedAsync is called.
|
||||
/// </summary>
|
||||
protected IAirAppRuntimeContext? RuntimeContext { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the AirApp and register services.
|
||||
/// Override this method to register your components, windows, and services.
|
||||
/// </summary>
|
||||
/// <param name="context">Host builder context</param>
|
||||
/// <param name="services">Service collection</param>
|
||||
public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
// Default implementation: do nothing
|
||||
// Derived classes can override to register services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after the host application has started.
|
||||
/// Override this for runtime initialization.
|
||||
/// </summary>
|
||||
/// <param name="context">AirApp runtime context</param>
|
||||
public virtual Task OnStartedAsync(IAirAppRuntimeContext context)
|
||||
{
|
||||
RuntimeContext = context;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the host application is stopping.
|
||||
/// Override this for cleanup logic.
|
||||
/// </summary>
|
||||
public virtual Task OnStoppingAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a desktop component widget.
|
||||
/// </summary>
|
||||
/// <typeparam name="TWidget">Widget implementation type</typeparam>
|
||||
/// <param name="id">Unique component identifier</param>
|
||||
/// <param name="name">Display name</param>
|
||||
/// <param name="configure">Optional configuration</param>
|
||||
protected void RegisterComponent<TWidget>(
|
||||
string id,
|
||||
string name,
|
||||
Action<AirAppComponentOptions>? configure = null)
|
||||
where TWidget : class, IAirAppWidget
|
||||
{
|
||||
if (RuntimeContext == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"RegisterComponent can only be called after OnStartedAsync. " +
|
||||
"Use IServiceCollection extension methods in Initialize() instead.");
|
||||
}
|
||||
|
||||
var options = new AirAppComponentOptions
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
WidgetType = typeof(TWidget)
|
||||
};
|
||||
|
||||
configure?.Invoke(options);
|
||||
|
||||
// Delegate to runtime context
|
||||
RuntimeContext.RegisterComponent(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a window.
|
||||
/// </summary>
|
||||
/// <typeparam name="TWindow">Window implementation type</typeparam>
|
||||
/// <param name="id">Unique window identifier</param>
|
||||
/// <param name="name">Display name</param>
|
||||
protected void RegisterWindow<TWindow>(string id, string name)
|
||||
where TWindow : class, IAirAppWindow
|
||||
{
|
||||
if (RuntimeContext == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"RegisterWindow can only be called after OnStartedAsync.");
|
||||
}
|
||||
|
||||
RuntimeContext.RegisterWindow(id, name, typeof(TWindow));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a service in the DI container.
|
||||
/// </summary>
|
||||
/// <typeparam name="TService">Service interface</typeparam>
|
||||
/// <typeparam name="TImplementation">Implementation type</typeparam>
|
||||
protected void RegisterService<TService, TImplementation>()
|
||||
where TService : class
|
||||
where TImplementation : class, TService
|
||||
{
|
||||
if (RuntimeContext == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"RegisterService can only be called after OnStartedAsync. " +
|
||||
"Use IServiceCollection in Initialize() instead.");
|
||||
}
|
||||
|
||||
RuntimeContext.RegisterService<TService, TImplementation>();
|
||||
}
|
||||
}
|
||||
61
LanMountainDesktop.AirAppSdk/AirAppComponentOptions.cs
Normal file
61
LanMountainDesktop.AirAppSdk/AirAppComponentOptions.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Options for registering an AirApp desktop component.
|
||||
/// </summary>
|
||||
public sealed class AirAppComponentOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique component identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the display name.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the widget implementation type.
|
||||
/// Must implement IAirAppWidget.
|
||||
/// </summary>
|
||||
public required Type WidgetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional description.
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default width in grid cells.
|
||||
/// Default is 2.
|
||||
/// </summary>
|
||||
public int DefaultWidth { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default height in grid cells.
|
||||
/// Default is 2.
|
||||
/// </summary>
|
||||
public int DefaultHeight { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the resize mode.
|
||||
/// </summary>
|
||||
public AirAppComponentResizeMode ResizeMode { get; set; } = AirAppComponentResizeMode.Both;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this component can be added multiple times.
|
||||
/// Default is true.
|
||||
/// </summary>
|
||||
public bool AllowMultipleInstances { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the category for grouping in the component library.
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the icon identifier.
|
||||
/// </summary>
|
||||
public string? IconKey { get; set; }
|
||||
}
|
||||
27
LanMountainDesktop.AirAppSdk/AirAppComponentResizeMode.cs
Normal file
27
LanMountainDesktop.AirAppSdk/AirAppComponentResizeMode.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Resize mode for AirApp desktop components.
|
||||
/// </summary>
|
||||
public enum AirAppComponentResizeMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Cannot be resized.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Can be resized horizontally only.
|
||||
/// </summary>
|
||||
Horizontal = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Can be resized vertically only.
|
||||
/// </summary>
|
||||
Vertical = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Can be resized in both directions.
|
||||
/// </summary>
|
||||
Both = 3
|
||||
}
|
||||
32
LanMountainDesktop.AirAppSdk/AirAppCornerRadiusPreset.cs
Normal file
32
LanMountainDesktop.AirAppSdk/AirAppCornerRadiusPreset.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Corner radius presets.
|
||||
/// </summary>
|
||||
public enum AirAppCornerRadiusPreset
|
||||
{
|
||||
/// <summary>
|
||||
/// No rounded corners.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Small corner radius (4px).
|
||||
/// </summary>
|
||||
Small = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Medium corner radius (8px).
|
||||
/// </summary>
|
||||
Medium = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Large corner radius (12px).
|
||||
/// </summary>
|
||||
Large = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Extra large corner radius (16px).
|
||||
/// </summary>
|
||||
ExtraLarge = 4
|
||||
}
|
||||
10
LanMountainDesktop.AirAppSdk/AirAppEntranceAttribute.cs
Normal file
10
LanMountainDesktop.AirAppSdk/AirAppEntranceAttribute.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a class as the entry point for an AirApp.
|
||||
/// The marked class must inherit from AirAppBase or implement IAirApp.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class AirAppEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
188
LanMountainDesktop.AirAppSdk/AirAppManifest.cs
Normal file
188
LanMountainDesktop.AirAppSdk/AirAppManifest.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// AirApp manifest (airapp.json).
|
||||
/// </summary>
|
||||
public sealed record AirAppManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
string EntranceAssembly,
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null,
|
||||
AirAppRuntimeConfiguration? Runtime = null,
|
||||
IReadOnlyList<AirAppComponentManifest>? Components = null,
|
||||
IReadOnlyList<AirAppWindowManifest>? Windows = null,
|
||||
IReadOnlyList<string>? Permissions = null,
|
||||
IReadOnlyList<AirAppSharedContractReference>? SharedContracts = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Load manifest from file.
|
||||
/// </summary>
|
||||
public static AirAppManifest Load(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
return Load(stream, manifestPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load manifest from stream.
|
||||
/// </summary>
|
||||
public static AirAppManifest Load(Stream stream, string sourceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var manifest = JsonSerializer.Deserialize<AirAppManifest>(stream, SerializerOptions);
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize AirApp manifest '{sourceName}'.");
|
||||
}
|
||||
|
||||
return manifest.NormalizeAndValidate(sourceName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve entrance assembly path.
|
||||
/// </summary>
|
||||
public string ResolveEntranceAssemblyPath(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
if (Path.IsPathRooted(EntranceAssembly))
|
||||
{
|
||||
return Path.GetFullPath(EntranceAssembly);
|
||||
}
|
||||
|
||||
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
||||
?? throw new InvalidOperationException($"Failed to determine directory of '{manifestPath}'.");
|
||||
|
||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get runtime mode.
|
||||
/// </summary>
|
||||
public AirAppRuntimeMode RuntimeMode =>
|
||||
AirAppRuntimeModes.TryParse(Runtime?.Mode, out var mode) ? mode : AirAppRuntimeMode.InProcess;
|
||||
|
||||
private AirAppManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalizedRuntime = (Runtime ?? new AirAppRuntimeConfiguration()).NormalizeAndValidate(manifestPath);
|
||||
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Name = RequireValue(Name, nameof(Name), manifestPath),
|
||||
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? AirAppSdkInfo.ApiVersion,
|
||||
Runtime = normalizedRuntime,
|
||||
Components = Components ?? Array.Empty<AirAppComponentManifest>(),
|
||||
Windows = Windows ?? Array.Empty<AirAppWindowManifest>(),
|
||||
Permissions = Permissions ?? Array.Empty<string>(),
|
||||
SharedContracts = SharedContracts ?? Array.Empty<AirAppSharedContractReference>()
|
||||
};
|
||||
|
||||
// Validate API version
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AirApp manifest '{manifestPath}' declares invalid API version '{normalized.ApiVersion}'.");
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(AirAppSdkInfo.ApiVersion, out var currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException($"AirApp SDK API version '{AirAppSdkInfo.ApiVersion}' is invalid.");
|
||||
}
|
||||
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AirApp '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
|
||||
$"but the host provides '{AirAppSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
|
||||
$"This host only supports v{currentVersion.Major}.x AirApps and rejects v{requestedVersion.Major}.x packages. " +
|
||||
$"Migrate the AirApp manifest and code to API {AirAppSdkInfo.ApiVersion}, then rebuild and republish.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AirApp manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Component declaration in manifest.
|
||||
/// </summary>
|
||||
public sealed record AirAppComponentManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
int DefaultWidth = 2,
|
||||
int DefaultHeight = 2,
|
||||
string? Description = null,
|
||||
string? Category = null,
|
||||
string? IconKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Window declaration in manifest.
|
||||
/// </summary>
|
||||
public sealed record AirAppWindowManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
double DefaultWidth = 800,
|
||||
double DefaultHeight = 600,
|
||||
string? Description = null);
|
||||
|
||||
/// <summary>
|
||||
/// Shared contract reference.
|
||||
/// </summary>
|
||||
public sealed record AirAppSharedContractReference(
|
||||
string Id,
|
||||
string Version);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime configuration.
|
||||
/// </summary>
|
||||
public sealed record AirAppRuntimeConfiguration
|
||||
{
|
||||
public string? Mode { get; init; }
|
||||
public IReadOnlyList<string>? Capabilities { get; init; }
|
||||
|
||||
internal AirAppRuntimeConfiguration NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
return this with
|
||||
{
|
||||
Mode = string.IsNullOrWhiteSpace(Mode) ? "in-process" : Mode.Trim().ToLowerInvariant(),
|
||||
Capabilities = Capabilities ?? Array.Empty<string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
53
LanMountainDesktop.AirAppSdk/AirAppRuntimeMode.cs
Normal file
53
LanMountainDesktop.AirAppSdk/AirAppRuntimeMode.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime mode for AirApps.
|
||||
/// </summary>
|
||||
public enum AirAppRuntimeMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Run in the host process (best performance, shared memory).
|
||||
/// </summary>
|
||||
InProcess = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Run in an isolated background process (safer, separate memory).
|
||||
/// </summary>
|
||||
IsolatedBackground = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Run in an isolated window process (full isolation).
|
||||
/// </summary>
|
||||
IsolatedWindow = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper for parsing runtime modes.
|
||||
/// </summary>
|
||||
public static class AirAppRuntimeModes
|
||||
{
|
||||
public static bool TryParse(string? mode, out AirAppRuntimeMode result)
|
||||
{
|
||||
result = AirAppRuntimeMode.InProcess;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = mode.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"in-process" => SetResult(AirAppRuntimeMode.InProcess, out result),
|
||||
"isolated-background" => SetResult(AirAppRuntimeMode.IsolatedBackground, out result),
|
||||
"isolated-window" => SetResult(AirAppRuntimeMode.IsolatedWindow, out result),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool SetResult(AirAppRuntimeMode mode, out AirAppRuntimeMode result)
|
||||
{
|
||||
result = mode;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
33
LanMountainDesktop.AirAppSdk/AirAppSdkInfo.cs
Normal file
33
LanMountainDesktop.AirAppSdk/AirAppSdkInfo.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// AirApp SDK information.
|
||||
/// </summary>
|
||||
public static class AirAppSdkInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Current SDK version.
|
||||
/// </summary>
|
||||
public const string SdkVersion = "6.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Current API version.
|
||||
/// AirApps must target this major version to be compatible.
|
||||
/// </summary>
|
||||
public const string ApiVersion = "6.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SDK display name.
|
||||
/// </summary>
|
||||
public static string DisplayName => "LanMountainDesktop AirApp SDK";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default manifest file name.
|
||||
/// </summary>
|
||||
public const string ManifestFileName = "airapp.json";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the package file extension.
|
||||
/// </summary>
|
||||
public const string PackageExtension = ".laapp";
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering AirApp services.
|
||||
/// </summary>
|
||||
public static class AirAppServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Register a desktop component.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAirAppComponent<TWidget>(
|
||||
this IServiceCollection services,
|
||||
string id,
|
||||
string name,
|
||||
Action<AirAppComponentOptions>? configure = null)
|
||||
where TWidget : class, IAirAppWidget
|
||||
{
|
||||
var options = new AirAppComponentOptions
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
WidgetType = typeof(TWidget)
|
||||
};
|
||||
|
||||
configure?.Invoke(options);
|
||||
|
||||
// Register the widget as transient (new instance per placement)
|
||||
services.AddTransient<TWidget>();
|
||||
|
||||
// Register the component options (will be picked up by the host)
|
||||
services.AddSingleton(options);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a window.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAirAppWindow<TWindow>(
|
||||
this IServiceCollection services,
|
||||
string id,
|
||||
string name)
|
||||
where TWindow : class, IAirAppWindow
|
||||
{
|
||||
// Register the window as transient (new instance per open)
|
||||
services.AddTransient<TWindow>();
|
||||
|
||||
// TODO: Register window metadata
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a settings section (declarative).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAirAppSettings(
|
||||
this IServiceCollection services,
|
||||
string id,
|
||||
string name,
|
||||
Action<AirAppSettingsSectionBuilder>? configure = null)
|
||||
{
|
||||
var builder = new AirAppSettingsSectionBuilder(id, name);
|
||||
configure?.Invoke(builder);
|
||||
|
||||
// Register the settings section
|
||||
services.AddSingleton(builder.Build());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for settings sections.
|
||||
/// </summary>
|
||||
public sealed class AirAppSettingsSectionBuilder
|
||||
{
|
||||
private readonly string _id;
|
||||
private readonly string _name;
|
||||
private readonly List<AirAppSettingOption> _options = new();
|
||||
|
||||
internal AirAppSettingsSectionBuilder(string id, string name)
|
||||
{
|
||||
_id = id;
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public AirAppSettingsSectionBuilder AddToggle(string key, string label, bool defaultValue = false)
|
||||
{
|
||||
_options.Add(new AirAppSettingOption
|
||||
{
|
||||
Key = key,
|
||||
Label = label,
|
||||
Type = "toggle",
|
||||
DefaultValue = defaultValue
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public AirAppSettingsSectionBuilder AddText(string key, string label, string? defaultValue = null)
|
||||
{
|
||||
_options.Add(new AirAppSettingOption
|
||||
{
|
||||
Key = key,
|
||||
Label = label,
|
||||
Type = "text",
|
||||
DefaultValue = defaultValue
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public AirAppSettingsSectionBuilder AddNumber(string key, string label, double defaultValue = 0, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
_options.Add(new AirAppSettingOption
|
||||
{
|
||||
Key = key,
|
||||
Label = label,
|
||||
Type = "number",
|
||||
DefaultValue = defaultValue,
|
||||
Minimum = minimum,
|
||||
Maximum = maximum
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
internal AirAppSettingsSection Build()
|
||||
{
|
||||
return new AirAppSettingsSection
|
||||
{
|
||||
Id = _id,
|
||||
Name = _name,
|
||||
Options = _options
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settings section metadata.
|
||||
/// </summary>
|
||||
public sealed class AirAppSettingsSection
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required List<AirAppSettingOption> Options { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual setting option.
|
||||
/// </summary>
|
||||
public sealed class AirAppSettingOption
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public object? DefaultValue { get; init; }
|
||||
public double? Minimum { get; init; }
|
||||
public double? Maximum { get; init; }
|
||||
}
|
||||
80
LanMountainDesktop.AirAppSdk/AirAppWidgetBase.cs
Normal file
80
LanMountainDesktop.AirAppSdk/AirAppWidgetBase.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for AirApp desktop component widgets.
|
||||
/// Inherit from this to create custom desktop components.
|
||||
/// </summary>
|
||||
public abstract class AirAppWidgetBase : UserControl, IAirAppWidget
|
||||
{
|
||||
private IAirAppComponentContext? _context;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the component context.
|
||||
/// </summary>
|
||||
public IAirAppComponentContext Context
|
||||
{
|
||||
get => _context ?? throw new InvalidOperationException("Context has not been set yet.");
|
||||
set
|
||||
{
|
||||
_context = value;
|
||||
OnContextSet();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the context is first set.
|
||||
/// Override this to initialize based on context.
|
||||
/// </summary>
|
||||
protected virtual void OnContextSet()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the widget is attached to the desktop.
|
||||
/// </summary>
|
||||
public void OnAttached()
|
||||
{
|
||||
OnAttachedCore();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the widget is detached from the desktop.
|
||||
/// </summary>
|
||||
public void OnDetached()
|
||||
{
|
||||
OnDetachedCore();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the appearance has changed.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">New appearance snapshot</param>
|
||||
public void OnAppearanceChanged(AirAppAppearanceSnapshot snapshot)
|
||||
{
|
||||
OnAppearanceChangedCore(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override this to handle widget attachment.
|
||||
/// </summary>
|
||||
protected virtual void OnAttachedCore()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override this to handle widget detachment.
|
||||
/// </summary>
|
||||
protected virtual void OnDetachedCore()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override this to handle appearance changes.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">New appearance snapshot</param>
|
||||
protected virtual void OnAppearanceChangedCore(AirAppAppearanceSnapshot snapshot)
|
||||
{
|
||||
}
|
||||
}
|
||||
96
LanMountainDesktop.AirAppSdk/AirAppWindowBase.cs
Normal file
96
LanMountainDesktop.AirAppSdk/AirAppWindowBase.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for AirApp windows.
|
||||
/// </summary>
|
||||
public abstract class AirAppWindowBase : Window, IAirAppWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the window descriptor.
|
||||
/// Override this to customize window configuration.
|
||||
/// </summary>
|
||||
public virtual AirAppWindowDescriptor Descriptor => new()
|
||||
{
|
||||
Width = 800,
|
||||
Height = 600,
|
||||
MinWidth = 400,
|
||||
MinHeight = 300,
|
||||
ChromeMode = AirAppWindowChromeMode.Standard,
|
||||
CanResize = true,
|
||||
ShowInTaskbar = true,
|
||||
ShowAsDialog = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of AirAppWindowBase.
|
||||
/// </summary>
|
||||
protected AirAppWindowBase()
|
||||
{
|
||||
ApplyDescriptor(Descriptor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called before the window is opened.
|
||||
/// </summary>
|
||||
public virtual Task OnWindowOpeningAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after the window has been opened.
|
||||
/// </summary>
|
||||
public virtual void OnWindowOpened()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the window is closing.
|
||||
/// </summary>
|
||||
public virtual void OnWindowClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after the window has been closed.
|
||||
/// </summary>
|
||||
public virtual void OnWindowClosed()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the window descriptor configuration.
|
||||
/// </summary>
|
||||
private void ApplyDescriptor(AirAppWindowDescriptor descriptor)
|
||||
{
|
||||
Width = descriptor.Width;
|
||||
Height = descriptor.Height;
|
||||
MinWidth = descriptor.MinWidth;
|
||||
MinHeight = descriptor.MinHeight;
|
||||
CanResize = descriptor.CanResize;
|
||||
ShowInTaskbar = descriptor.ShowInTaskbar;
|
||||
ShowAsDialog = descriptor.ShowAsDialog;
|
||||
|
||||
// Apply chrome mode
|
||||
switch (descriptor.ChromeMode)
|
||||
{
|
||||
case AirAppWindowChromeMode.Standard:
|
||||
SystemDecorations = SystemDecorations.Full;
|
||||
break;
|
||||
case AirAppWindowChromeMode.Borderless:
|
||||
SystemDecorations = SystemDecorations.BorderOnly;
|
||||
break;
|
||||
case AirAppWindowChromeMode.FullScreen:
|
||||
SystemDecorations = SystemDecorations.None;
|
||||
WindowState = WindowState.FullScreen;
|
||||
break;
|
||||
case AirAppWindowChromeMode.Tool:
|
||||
SystemDecorations = SystemDecorations.Full;
|
||||
ShowInTaskbar = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
LanMountainDesktop.AirAppSdk/AirAppWindowChromeMode.cs
Normal file
32
LanMountainDesktop.AirAppSdk/AirAppWindowChromeMode.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Window chrome mode for AirApp windows.
|
||||
/// </summary>
|
||||
public enum AirAppWindowChromeMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard window with title bar and borders.
|
||||
/// </summary>
|
||||
Standard = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Borderless window with custom chrome.
|
||||
/// </summary>
|
||||
Borderless = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Full-screen window with no decorations.
|
||||
/// </summary>
|
||||
FullScreen = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Tool window (no taskbar icon, small title bar).
|
||||
/// </summary>
|
||||
Tool = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Background-only (no UI, reserved for future use).
|
||||
/// </summary>
|
||||
BackgroundOnly = 4
|
||||
}
|
||||
52
LanMountainDesktop.AirAppSdk/AirAppWindowDescriptor.cs
Normal file
52
LanMountainDesktop.AirAppSdk/AirAppWindowDescriptor.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Window configuration descriptor.
|
||||
/// </summary>
|
||||
public sealed class AirAppWindowDescriptor
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the window title.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = "AirApp Window";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial width.
|
||||
/// </summary>
|
||||
public double Width { get; set; } = 800;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial height.
|
||||
/// </summary>
|
||||
public double Height { get; set; } = 600;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum width.
|
||||
/// </summary>
|
||||
public double MinWidth { get; set; } = 400;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum height.
|
||||
/// </summary>
|
||||
public double MinHeight { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the chrome mode.
|
||||
/// </summary>
|
||||
public AirAppWindowChromeMode ChromeMode { get; set; } = AirAppWindowChromeMode.Standard;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the window can be resized.
|
||||
/// </summary>
|
||||
public bool CanResize { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the window shows in the taskbar.
|
||||
/// </summary>
|
||||
public bool ShowInTaskbar { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the window is modal.
|
||||
/// </summary>
|
||||
public bool ShowAsDialog { get; set; } = false;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user