mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2793be68d4 | ||
|
|
13895e0f43 | ||
|
|
2768b76e1e | ||
|
|
60645ccf40 | ||
|
|
8d1dbaea54 | ||
|
|
49af6601aa | ||
|
|
7db72fbcd0 | ||
|
|
1a6f129e78 | ||
|
|
11b8216e5b | ||
|
|
8df0271032 | ||
|
|
eae3e67238 | ||
|
|
f142307729 | ||
|
|
8c88e305ee | ||
|
|
bb4e90ea8d | ||
|
|
75c7aece4f | ||
|
|
e888b0423a | ||
|
|
28b06031f7 |
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.
|
||||
|
||||
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
|
||||
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
|
||||
**状态**: ✅ 完成
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,10 +16,13 @@ Make the Settings > Update page the single user-facing control surface for the h
|
||||
- The page displays whether the current payload is an incremental update or reinstall/full installer.
|
||||
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
|
||||
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
|
||||
- The page follows ClassIsland's durable-status vs working-status split: a transient check/download error must not be treated as an available update, and available/downloaded actions must stay visible while the worker is idle.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel.
|
||||
- `UpdateSettingsState` persists forced reinstall alongside other update preferences.
|
||||
- Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply.
|
||||
- After a successful check with an available update, the download action is visible even though no transfer is running.
|
||||
- After a failed check, no download action is shown unless a valid update is still pending.
|
||||
- Build succeeds for `LanMountainDesktop.slnx`.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
**测试完成时间**: ___________
|
||||
**签名**: ___________
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
31
LanMountainDesktop.AirAppSdk/IAirApp.cs
Normal file
31
LanMountainDesktop.AirAppSdk/IAirApp.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Core interface for AirApp entry point.
|
||||
/// </summary>
|
||||
public interface IAirApp
|
||||
{
|
||||
/// <summary>
|
||||
/// Initialize the AirApp and register services.
|
||||
/// Called during host startup before the application is fully running.
|
||||
/// </summary>
|
||||
/// <param name="context">Host builder context</param>
|
||||
/// <param name="services">Service collection for dependency injection</param>
|
||||
void Initialize(HostBuilderContext context, IServiceCollection services);
|
||||
|
||||
/// <summary>
|
||||
/// Called after the host application has started.
|
||||
/// Use this for initialization that requires runtime services.
|
||||
/// </summary>
|
||||
/// <param name="context">AirApp runtime context</param>
|
||||
Task OnStartedAsync(IAirAppRuntimeContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Called when the host application is stopping.
|
||||
/// Use this for cleanup and resource disposal.
|
||||
/// </summary>
|
||||
Task OnStoppingAsync();
|
||||
}
|
||||
19
LanMountainDesktop.AirAppSdk/IAirAppAppearanceContext.cs
Normal file
19
LanMountainDesktop.AirAppSdk/IAirAppAppearanceContext.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Provides appearance and theme context.
|
||||
/// </summary>
|
||||
public interface IAirAppAppearanceContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current appearance snapshot.
|
||||
/// </summary>
|
||||
AirAppAppearanceSnapshot CurrentSnapshot { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to appearance changes.
|
||||
/// </summary>
|
||||
/// <param name="handler">Change handler</param>
|
||||
/// <returns>Subscription token</returns>
|
||||
IDisposable SubscribeToChanges(Action<AirAppAppearanceSnapshot> handler);
|
||||
}
|
||||
58
LanMountainDesktop.AirAppSdk/IAirAppComponentContext.cs
Normal file
58
LanMountainDesktop.AirAppSdk/IAirAppComponentContext.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Context provided to an AirApp desktop component instance.
|
||||
/// </summary>
|
||||
public interface IAirAppComponentContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the component identifier.
|
||||
/// </summary>
|
||||
string ComponentId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique placement identifier for this component instance.
|
||||
/// </summary>
|
||||
string PlacementId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current width in grid cells.
|
||||
/// </summary>
|
||||
int Width { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current height in grid cells.
|
||||
/// </summary>
|
||||
int Height { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service provider for this component.
|
||||
/// </summary>
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the appearance context.
|
||||
/// </summary>
|
||||
IAirAppAppearanceContext Appearance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Request a window to be opened.
|
||||
/// </summary>
|
||||
/// <param name="windowId">Window identifier</param>
|
||||
Task OpenWindowAsync(string windowId);
|
||||
|
||||
/// <summary>
|
||||
/// Send a message to other components or AirApps.
|
||||
/// </summary>
|
||||
/// <param name="topic">Message topic</param>
|
||||
/// <param name="payload">Message payload</param>
|
||||
void SendMessage(string topic, object? payload = null);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to messages.
|
||||
/// </summary>
|
||||
/// <param name="topic">Message topic</param>
|
||||
/// <param name="handler">Message handler</param>
|
||||
/// <returns>Subscription token for unsubscribing</returns>
|
||||
IDisposable Subscribe(string topic, Action<object?> handler);
|
||||
}
|
||||
37
LanMountainDesktop.AirAppSdk/IAirAppLogger.cs
Normal file
37
LanMountainDesktop.AirAppSdk/IAirAppLogger.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Logger interface for AirApps.
|
||||
/// </summary>
|
||||
public interface IAirAppLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Log a debug message.
|
||||
/// </summary>
|
||||
void Debug(string message);
|
||||
|
||||
/// <summary>
|
||||
/// Log an informational message.
|
||||
/// </summary>
|
||||
void Info(string message);
|
||||
|
||||
/// <summary>
|
||||
/// Log a warning message.
|
||||
/// </summary>
|
||||
void Warn(string message);
|
||||
|
||||
/// <summary>
|
||||
/// Log a warning with exception.
|
||||
/// </summary>
|
||||
void Warn(string message, Exception exception);
|
||||
|
||||
/// <summary>
|
||||
/// Log an error message.
|
||||
/// </summary>
|
||||
void Error(string message);
|
||||
|
||||
/// <summary>
|
||||
/// Log an error with exception.
|
||||
/// </summary>
|
||||
void Error(string message, Exception exception);
|
||||
}
|
||||
31
LanMountainDesktop.AirAppSdk/IAirAppMessageBus.cs
Normal file
31
LanMountainDesktop.AirAppSdk/IAirAppMessageBus.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Message bus for inter-AirApp communication.
|
||||
/// </summary>
|
||||
public interface IAirAppMessageBus
|
||||
{
|
||||
/// <summary>
|
||||
/// Publish a message to a topic.
|
||||
/// </summary>
|
||||
/// <param name="topic">Message topic</param>
|
||||
/// <param name="payload">Message payload</param>
|
||||
void Publish(string topic, object? payload = null);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to a topic.
|
||||
/// </summary>
|
||||
/// <param name="topic">Message topic</param>
|
||||
/// <param name="handler">Message handler</param>
|
||||
/// <returns>Subscription token</returns>
|
||||
IDisposable Subscribe(string topic, Action<object?> handler);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to a topic with typed payload.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Payload type</typeparam>
|
||||
/// <param name="topic">Message topic</param>
|
||||
/// <param name="handler">Typed message handler</param>
|
||||
/// <returns>Subscription token</returns>
|
||||
IDisposable Subscribe<T>(string topic, Action<T?> handler);
|
||||
}
|
||||
91
LanMountainDesktop.AirAppSdk/IAirAppRuntimeContext.cs
Normal file
91
LanMountainDesktop.AirAppSdk/IAirAppRuntimeContext.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Provides runtime context and services for an AirApp.
|
||||
/// </summary>
|
||||
public interface IAirAppRuntimeContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier of this AirApp.
|
||||
/// </summary>
|
||||
string AirAppId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name of this AirApp.
|
||||
/// </summary>
|
||||
string AirAppName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the AirApp version.
|
||||
/// </summary>
|
||||
string AirAppVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the data directory for this AirApp.
|
||||
/// Use this directory to store persistent user data.
|
||||
/// </summary>
|
||||
string DataDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache directory for this AirApp.
|
||||
/// Use this directory to store temporary cached data.
|
||||
/// </summary>
|
||||
string CacheDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service provider for dependency injection.
|
||||
/// </summary>
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the host application lifetime manager.
|
||||
/// </summary>
|
||||
IHostApplicationLifetime Lifetime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message bus for inter-AirApp communication.
|
||||
/// </summary>
|
||||
IAirAppMessageBus MessageBus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the appearance context for theme and styling.
|
||||
/// </summary>
|
||||
IAirAppAppearanceContext Appearance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logger for this AirApp.
|
||||
/// </summary>
|
||||
IAirAppLogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Opens a window defined by this AirApp.
|
||||
/// </summary>
|
||||
/// <param name="windowId">Window identifier</param>
|
||||
/// <returns>The opened window instance</returns>
|
||||
Task<IAirAppWindow> OpenWindowAsync(string windowId);
|
||||
|
||||
/// <summary>
|
||||
/// Closes a window by its identifier.
|
||||
/// </summary>
|
||||
/// <param name="windowId">Window identifier</param>
|
||||
void CloseWindow(string windowId);
|
||||
|
||||
/// <summary>
|
||||
/// Register a desktop component (internal use by AirAppBase).
|
||||
/// </summary>
|
||||
void RegisterComponent(AirAppComponentOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Register a window (internal use by AirAppBase).
|
||||
/// </summary>
|
||||
void RegisterWindow(string id, string name, Type windowType);
|
||||
|
||||
/// <summary>
|
||||
/// Register a service (internal use by AirAppBase).
|
||||
/// </summary>
|
||||
void RegisterService<TService, TImplementation>()
|
||||
where TService : class
|
||||
where TImplementation : class, TService;
|
||||
}
|
||||
29
LanMountainDesktop.AirAppSdk/IAirAppWidget.cs
Normal file
29
LanMountainDesktop.AirAppSdk/IAirAppWidget.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for AirApp desktop component widgets.
|
||||
/// </summary>
|
||||
public interface IAirAppWidget
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the component context.
|
||||
/// Set by the host when the widget is created.
|
||||
/// </summary>
|
||||
IAirAppComponentContext Context { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Called when the widget is attached to the desktop.
|
||||
/// </summary>
|
||||
void OnAttached();
|
||||
|
||||
/// <summary>
|
||||
/// Called when the widget is detached from the desktop.
|
||||
/// </summary>
|
||||
void OnDetached();
|
||||
|
||||
/// <summary>
|
||||
/// Called when the appearance (theme) has changed.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">New appearance snapshot</param>
|
||||
void OnAppearanceChanged(AirAppAppearanceSnapshot snapshot);
|
||||
}
|
||||
36
LanMountainDesktop.AirAppSdk/IAirAppWindow.cs
Normal file
36
LanMountainDesktop.AirAppSdk/IAirAppWindow.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.AirAppSdk;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for AirApp windows.
|
||||
/// </summary>
|
||||
public interface IAirAppWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the window descriptor (configuration).
|
||||
/// </summary>
|
||||
AirAppWindowDescriptor Descriptor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Called before the window is opened.
|
||||
/// Use this for async initialization.
|
||||
/// </summary>
|
||||
Task OnWindowOpeningAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Called after the window has been opened.
|
||||
/// </summary>
|
||||
void OnWindowOpened();
|
||||
|
||||
/// <summary>
|
||||
/// Called when the window is closing.
|
||||
/// Set e.Cancel = true to prevent closing.
|
||||
/// </summary>
|
||||
void OnWindowClosing(WindowClosingEventArgs e);
|
||||
|
||||
/// <summary>
|
||||
/// Called after the window has been closed.
|
||||
/// </summary>
|
||||
void OnWindowClosed();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<!-- Package metadata -->
|
||||
<PackageId>LanMountainDesktop.AirAppSdk</PackageId>
|
||||
<Version>6.0.0</Version>
|
||||
<Authors>LanMountainDesktop Team</Authors>
|
||||
<Description>Official SDK for developing AirApps (Lightweight Applications) for LanMountainDesktop</Description>
|
||||
<PackageTags>lanmountaindesktop;airapp;sdk;plugin;avalonia</PackageTags>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<RepositoryUrl>https://github.com/LanMountain/LanMountainDesktop</RepositoryUrl>
|
||||
|
||||
<!-- Build transitive: include packaging targets in consuming projects -->
|
||||
<BuildTransitive>true</BuildTransitive>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="11.2.2" />
|
||||
<PackageReference Include="Avalonia.Controls" Version="11.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Build targets for .laapp packaging -->
|
||||
<ItemGroup>
|
||||
<None Include="build\**" Pack="true" PackagePath="build\" />
|
||||
<None Include="buildTransitive\**" Pack="true" PackagePath="buildTransitive\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
363
LanMountainDesktop.AirAppSdk/README.md
Normal file
363
LanMountainDesktop.AirAppSdk/README.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# LanMountainDesktop.AirAppSdk
|
||||
|
||||
Official SDK for developing AirApps (Lightweight Applications) for LanMountainDesktop.
|
||||
|
||||
## What is an AirApp?
|
||||
|
||||
AirApp is the next-generation application framework for LanMountainDesktop. It provides a unified development experience for creating:
|
||||
|
||||
- **Desktop Components** - Widgets that live on the desktop
|
||||
- **Window Applications** - Standalone windowed apps
|
||||
- **Background Services** - Services that run in the background
|
||||
- **Hybrid Apps** - Apps that combine multiple modes
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install the SDK package
|
||||
dotnet add package LanMountainDesktop.AirAppSdk
|
||||
```
|
||||
|
||||
### Create Your First AirApp
|
||||
|
||||
1. **Create a new project**
|
||||
|
||||
```bash
|
||||
dotnet new classlib -n MyFirstAirApp
|
||||
cd MyFirstAirApp
|
||||
dotnet add package LanMountainDesktop.AirAppSdk
|
||||
```
|
||||
|
||||
2. **Create the entry point**
|
||||
|
||||
```csharp
|
||||
using LanMountainDesktop.AirAppSdk;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace MyFirstAirApp;
|
||||
|
||||
[AirAppEntrance]
|
||||
public class MyAirApp : AirAppBase
|
||||
{
|
||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
// Register a desktop component
|
||||
services.AddAirAppComponent<MyWidget>("my-widget", "My Widget");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create a widget**
|
||||
|
||||
```csharp
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.AirAppSdk;
|
||||
|
||||
namespace MyFirstAirApp;
|
||||
|
||||
public class MyWidget : AirAppWidgetBase
|
||||
{
|
||||
public MyWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
// Simple widget with a TextBlock
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = "Hello from AirApp!",
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
protected override void OnAttachedCore()
|
||||
{
|
||||
// Called when widget is added to desktop
|
||||
Context.Logger.Info("My widget attached!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create manifest file** (`airapp.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "com.example.myfirstairapp",
|
||||
"name": "My First AirApp",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "6.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "My first AirApp for LanMountainDesktop",
|
||||
"entranceAssembly": "MyFirstAirApp.dll",
|
||||
"runtime": {
|
||||
"mode": "in-process"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Build the project**
|
||||
|
||||
```bash
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
This will produce a `.laapp` package in `bin/Release/net10.0/MyFirstAirApp.laapp`.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### AirAppBase
|
||||
|
||||
The entry point for your AirApp. Override `Initialize()` to register components and services:
|
||||
|
||||
```csharp
|
||||
[AirAppEntrance]
|
||||
public class MyAirApp : AirAppBase
|
||||
{
|
||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
// Register components
|
||||
services.AddAirAppComponent<MyWidget>("widget-id", "Widget Name");
|
||||
|
||||
// Register windows
|
||||
services.AddAirAppWindow<MyWindow>("window-id", "Window Name");
|
||||
|
||||
// Register your services
|
||||
services.AddSingleton<IMyService, MyService>();
|
||||
}
|
||||
|
||||
public override async Task OnStartedAsync(IAirAppRuntimeContext context)
|
||||
{
|
||||
// Runtime initialization
|
||||
context.Logger.Info("AirApp started!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Desktop Components
|
||||
|
||||
Create widgets that appear on the desktop:
|
||||
|
||||
```csharp
|
||||
public class ClockWidget : AirAppWidgetBase
|
||||
{
|
||||
private TextBlock _timeText;
|
||||
|
||||
public ClockWidget()
|
||||
{
|
||||
_timeText = new TextBlock();
|
||||
Content = _timeText;
|
||||
|
||||
// Update every second
|
||||
DispatcherTimer.Run(() =>
|
||||
{
|
||||
_timeText.Text = DateTime.Now.ToString("HH:mm:ss");
|
||||
return true;
|
||||
}, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
protected override void OnAppearanceChangedCore(AirAppAppearanceSnapshot snapshot)
|
||||
{
|
||||
// Respond to theme changes
|
||||
_timeText.Foreground = new SolidColorBrush(snapshot.ForegroundColor);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Create standalone windows:
|
||||
|
||||
```csharp
|
||||
public class MyWindow : AirAppWindowBase
|
||||
{
|
||||
public override AirAppWindowDescriptor Descriptor => new()
|
||||
{
|
||||
Title = "My Window",
|
||||
Width = 800,
|
||||
Height = 600,
|
||||
ChromeMode = AirAppWindowChromeMode.Standard,
|
||||
CanResize = true
|
||||
};
|
||||
|
||||
public MyWindow()
|
||||
{
|
||||
Content = new TextBlock { Text = "Hello from window!" };
|
||||
}
|
||||
|
||||
public override async Task OnWindowOpeningAsync()
|
||||
{
|
||||
// Async initialization before window opens
|
||||
await LoadDataAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Runtime Context
|
||||
|
||||
Access runtime services:
|
||||
|
||||
```csharp
|
||||
protected override async Task OnStartedAsync(IAirAppRuntimeContext context)
|
||||
{
|
||||
// Get data directories
|
||||
var dataDir = context.DataDirectory;
|
||||
var cacheDir = context.CacheDirectory;
|
||||
|
||||
// Use services
|
||||
var myService = context.Services.GetService<IMyService>();
|
||||
|
||||
// Log messages
|
||||
context.Logger.Info("AirApp started!");
|
||||
|
||||
// Open a window
|
||||
await context.OpenWindowAsync("my-window");
|
||||
|
||||
// Subscribe to messages
|
||||
context.MessageBus.Subscribe("theme-changed", payload =>
|
||||
{
|
||||
context.Logger.Info("Theme changed!");
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Interfaces
|
||||
|
||||
- `IAirApp` - AirApp entry point
|
||||
- `IAirAppWidget` - Desktop component widget
|
||||
- `IAirAppWindow` - Window application
|
||||
- `IAirAppRuntimeContext` - Runtime services and context
|
||||
- `IAirAppComponentContext` - Component instance context
|
||||
|
||||
### Base Classes
|
||||
|
||||
- `AirAppBase` - Base implementation of IAirApp
|
||||
- `AirAppWidgetBase` - Base class for widgets
|
||||
- `AirAppWindowBase` - Base class for windows
|
||||
|
||||
### Configuration
|
||||
|
||||
- `AirAppManifest` - Manifest file structure
|
||||
- `AirAppComponentOptions` - Component registration options
|
||||
- `AirAppWindowDescriptor` - Window configuration
|
||||
- `AirAppRuntimeMode` - Runtime isolation modes
|
||||
|
||||
### Services
|
||||
|
||||
- `IAirAppLogger` - Logging service
|
||||
- `IAirAppMessageBus` - Inter-app messaging
|
||||
- `IAirAppAppearanceContext` - Theme and appearance
|
||||
|
||||
## Runtime Modes
|
||||
|
||||
### In-Process (Default)
|
||||
|
||||
Best performance, runs in the host process:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": {
|
||||
"mode": "in-process"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Isolated Background
|
||||
|
||||
Runs in a separate background process:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": {
|
||||
"mode": "isolated-background"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Isolated Window
|
||||
|
||||
Runs in a completely isolated window process:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": {
|
||||
"mode": "isolated-window"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Packaging
|
||||
|
||||
Your AirApp is automatically packaged as a `.laapp` file when you build:
|
||||
|
||||
```bash
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
The package includes:
|
||||
- All assemblies
|
||||
- The `airapp.json` manifest
|
||||
- Any additional resources
|
||||
|
||||
## Migration from Plugin SDK v5
|
||||
|
||||
If you're migrating from the older Plugin SDK:
|
||||
|
||||
1. Update package reference:
|
||||
```xml
|
||||
<!-- Old -->
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="5.0.0" />
|
||||
|
||||
<!-- New -->
|
||||
<PackageReference Include="LanMountainDesktop.AirAppSdk" Version="6.0.0" />
|
||||
```
|
||||
|
||||
2. Update manifest file: `plugin.json` → `airapp.json`
|
||||
|
||||
3. Update namespaces:
|
||||
```csharp
|
||||
// Old
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
[PluginEntrance]
|
||||
public class Plugin : PluginBase { }
|
||||
|
||||
// New
|
||||
using LanMountainDesktop.AirAppSdk;
|
||||
[AirAppEntrance]
|
||||
public class MyAirApp : AirAppBase { }
|
||||
```
|
||||
|
||||
4. Update API calls (mostly compatible, minor naming changes)
|
||||
|
||||
## Examples
|
||||
|
||||
See the `samples/` directory for complete examples:
|
||||
|
||||
- **SimpleWidget** - Basic desktop component
|
||||
- **ClockWidget** - Time display with auto-update
|
||||
- **WindowApp** - Standalone window application
|
||||
- **HybridApp** - Component + window combination
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Full API Documentation](https://docs.lanmountain.com/airapp-sdk)
|
||||
- [Development Guide](https://docs.lanmountain.com/airapp-dev-guide)
|
||||
- [Best Practices](https://docs.lanmountain.com/airapp-best-practices)
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Issues: https://github.com/LanMountain/LanMountainDesktop/issues
|
||||
- Discord: https://discord.gg/lanmountain
|
||||
- Documentation: https://docs.lanmountain.com
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See LICENSE file for details
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<PackageType>Template</PackageType>
|
||||
<PackageVersion>6.0.0</PackageVersion>
|
||||
<PackageId>LanMountainDesktop.AirAppTemplate</PackageId>
|
||||
<Title>LanMountainDesktop AirApp Templates</Title>
|
||||
<Authors>LanMountainDesktop Team</Authors>
|
||||
<Description>Project templates for creating AirApps for LanMountainDesktop</Description>
|
||||
<PackageTags>templates;lanmountaindesktop;airapp;dotnet-new</PackageTags>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<IncludeContentInPack>true</IncludeContentInPack>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<ContentTargetFolders>content</ContentTargetFolders>
|
||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||
<NoDefaultExcludes>true</NoDefaultExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="templates\**\*" Exclude="templates\**\bin\**;templates\**\obj\**" />
|
||||
<Compile Remove="**\*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/template",
|
||||
"author": "LanMountainDesktop Team",
|
||||
"classifications": ["LanMountainDesktop", "AirApp", "Component"],
|
||||
"identity": "LanMountainDesktop.AirApp.Component",
|
||||
"name": "LanMountainDesktop AirApp - Desktop Component",
|
||||
"shortName": "lmd-airapp-component",
|
||||
"tags": {
|
||||
"language": "C#",
|
||||
"type": "project"
|
||||
},
|
||||
"sourceName": "LanMountainDesktop.AirApp.ComponentTemplate",
|
||||
"preferNameDirectory": true,
|
||||
"symbols": {
|
||||
"ComponentId": {
|
||||
"type": "parameter",
|
||||
"datatype": "string",
|
||||
"defaultValue": "my-widget",
|
||||
"replaces": "my-widget",
|
||||
"description": "The unique identifier for the component"
|
||||
},
|
||||
"ComponentName": {
|
||||
"type": "parameter",
|
||||
"datatype": "string",
|
||||
"defaultValue": "My Widget",
|
||||
"replaces": "My Widget",
|
||||
"description": "The display name for the component"
|
||||
},
|
||||
"AuthorName": {
|
||||
"type": "parameter",
|
||||
"datatype": "string",
|
||||
"defaultValue": "Your Name",
|
||||
"replaces": "Your Name",
|
||||
"description": "The author name"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.AirAppSdk" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Include airapp.json in output -->
|
||||
<ItemGroup>
|
||||
<None Update="airapp.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
using LanMountainDesktop.AirAppSdk;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.AirApp.ComponentTemplate;
|
||||
|
||||
/// <summary>
|
||||
/// AirApp entry point.
|
||||
/// </summary>
|
||||
[AirAppEntrance]
|
||||
public sealed class MyAirApp : AirAppBase
|
||||
{
|
||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
// Register the desktop component
|
||||
services.AddAirAppComponent<MyWidget>(
|
||||
"my-widget",
|
||||
"My Widget",
|
||||
options =>
|
||||
{
|
||||
options.Description = "A sample desktop component";
|
||||
options.DefaultWidth = 2;
|
||||
options.DefaultHeight = 2;
|
||||
options.ResizeMode = AirAppComponentResizeMode.Both;
|
||||
options.Category = "Custom";
|
||||
options.IconKey = "AppGeneric";
|
||||
});
|
||||
}
|
||||
|
||||
public override Task OnStartedAsync(IAirAppRuntimeContext context)
|
||||
{
|
||||
context.Logger.Info("My AirApp started successfully!");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task OnStoppingAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.AirAppSdk;
|
||||
|
||||
namespace LanMountainDesktop.AirApp.ComponentTemplate;
|
||||
|
||||
/// <summary>
|
||||
/// Desktop component widget implementation.
|
||||
/// </summary>
|
||||
public sealed class MyWidget : AirAppWidgetBase
|
||||
{
|
||||
private readonly TextBlock _titleText;
|
||||
private readonly TextBlock _timeText;
|
||||
private readonly DispatcherTimer _timer;
|
||||
|
||||
public MyWidget()
|
||||
{
|
||||
// Create UI
|
||||
_titleText = new TextBlock
|
||||
{
|
||||
Text = "My Widget",
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center
|
||||
};
|
||||
|
||||
_timeText = new TextBlock
|
||||
{
|
||||
FontSize = 24,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
var panel = new StackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
panel.Children.Add(_titleText);
|
||||
panel.Children.Add(_timeText);
|
||||
|
||||
Content = panel;
|
||||
|
||||
// Setup timer to update time
|
||||
_timer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
};
|
||||
_timer.Tick += (s, e) => UpdateTime();
|
||||
}
|
||||
|
||||
protected override void OnAttachedCore()
|
||||
{
|
||||
Context.Logger.Info($"Widget attached: {Context.ComponentId} at {Context.PlacementId}");
|
||||
UpdateTime();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
protected override void OnDetachedCore()
|
||||
{
|
||||
Context.Logger.Info($"Widget detached: {Context.ComponentId}");
|
||||
_timer.Stop();
|
||||
}
|
||||
|
||||
protected override void OnAppearanceChangedCore(AirAppAppearanceSnapshot snapshot)
|
||||
{
|
||||
// Respond to theme changes
|
||||
_titleText.Foreground = new SolidColorBrush(snapshot.ForegroundColor);
|
||||
_timeText.Foreground = new SolidColorBrush(snapshot.AccentColor);
|
||||
|
||||
Context.Logger.Info($"Appearance changed: DarkMode={snapshot.IsDarkMode}");
|
||||
}
|
||||
|
||||
private void UpdateTime()
|
||||
{
|
||||
_timeText.Text = DateTime.Now.ToString("HH:mm:ss");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# LanMountainDesktop.AirApp.ComponentTemplate
|
||||
|
||||
A desktop component AirApp for LanMountainDesktop.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
This will produce a `.laapp` package in `bin/Release/net10.0/`.
|
||||
|
||||
## Install
|
||||
|
||||
Copy the `.laapp` file to LanMountainDesktop's plugins directory or install via the AirApp Market.
|
||||
|
||||
## Development
|
||||
|
||||
To test your component during development:
|
||||
|
||||
1. Build the project
|
||||
2. Run LanMountainDesktop with debug mode:
|
||||
```bash
|
||||
dotnet run --project path/to/LanMountainDesktop.csproj -- --debug-airapp path/to/your/bin/Debug/net10.0
|
||||
```
|
||||
|
||||
## Customize
|
||||
|
||||
- Edit `MyWidget.cs` to modify the component UI and behavior
|
||||
- Edit `airapp.json` to change metadata
|
||||
- Add more components by creating additional widget classes and registering them in `MyAirApp.cs`
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "com.example.LanMountainDesktop.AirApp.ComponentTemplate",
|
||||
"name": "LanMountainDesktop.AirApp.ComponentTemplate",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "6.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "A desktop component AirApp for LanMountainDesktop",
|
||||
"entranceAssembly": "LanMountainDesktop.AirApp.ComponentTemplate.dll",
|
||||
"runtime": {
|
||||
"mode": "in-process",
|
||||
"capabilities": ["desktop-component"]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"id": "my-widget",
|
||||
"name": "My Widget",
|
||||
"defaultWidth": 2,
|
||||
"defaultHeight": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -87,31 +87,68 @@ internal sealed class DeploymentLocator
|
||||
var explicitAppRoot = context.ExplicitAppRoot;
|
||||
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
|
||||
|
||||
Logger.Info($"=== HOST RESOLUTION START ===");
|
||||
Logger.Info($" AppRoot: {_appRoot}");
|
||||
Logger.Info($" Executable: {executable}");
|
||||
Logger.Info($" IsDebugMode: {context.IsDebugMode}");
|
||||
Logger.Info($" ExplicitAppRoot: {explicitAppRoot ?? "<none>"}");
|
||||
Logger.Info($" LauncherBaseDirectory: {AppContext.BaseDirectory}");
|
||||
|
||||
string? resolvedPath;
|
||||
string? source;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
|
||||
{
|
||||
Logger.Info($"Trying explicit app root: {explicitAppRoot}");
|
||||
var explicitRoot = Path.GetFullPath(explicitAppRoot);
|
||||
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info("Trying published or portable host...");
|
||||
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
|
||||
}
|
||||
|
||||
if (resolvedPath is null && context.IsDebugMode)
|
||||
{
|
||||
Logger.Info("Debug mode: trying debug host paths...");
|
||||
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
|
||||
}
|
||||
|
||||
if (resolvedPath is null)
|
||||
{
|
||||
Logger.Warn("Standard resolution failed, trying legacy fallback...");
|
||||
resolvedPath = ResolveHostExecutablePathLegacy();
|
||||
if (!string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
searchedPaths.Add(Path.GetFullPath(resolvedPath));
|
||||
source = "legacy_fallback";
|
||||
Logger.Info($"Legacy fallback found: {resolvedPath}");
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Info($"=== HOST RESOLUTION RESULT ===");
|
||||
Logger.Info($" Success: {!string.IsNullOrWhiteSpace(resolvedPath)}");
|
||||
Logger.Info($" ResolvedPath: {resolvedPath ?? "<NOT FOUND>"}");
|
||||
Logger.Info($" Source: {source ?? "<none>"}");
|
||||
Logger.Info($" SearchedPaths ({searchedPaths.Count}):");
|
||||
foreach (var path in searchedPaths.Take(10))
|
||||
{
|
||||
Logger.Info($" - {path}");
|
||||
}
|
||||
if (searchedPaths.Count > 10)
|
||||
{
|
||||
Logger.Info($" ... and {searchedPaths.Count - 10} more");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
Logger.Error("CRITICAL: Could not resolve host executable path!");
|
||||
Console.Error.WriteLine("[CRITICAL] Could not find main application executable!");
|
||||
Console.Error.WriteLine($"[CRITICAL] Searched {searchedPaths.Count} locations:");
|
||||
foreach (var path in searchedPaths.Take(5))
|
||||
{
|
||||
Console.Error.WriteLine($"[CRITICAL] - {path}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,27 @@ internal sealed class DataLocationResolver
|
||||
return ResolveDataRoot(config);
|
||||
}
|
||||
|
||||
public string ResolveDataRoot(DataLocationMode mode, string? customPath = null)
|
||||
{
|
||||
return ResolveDataRoot(BuildConfig(mode, customPath));
|
||||
}
|
||||
|
||||
public DataLocationConfig BuildConfig(DataLocationMode mode, string? customPath = null)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable
|
||||
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||
? customPath
|
||||
: DefaultPortableDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
return new DataLocationConfig
|
||||
{
|
||||
DataLocationMode = mode.ToString(),
|
||||
SystemDataPath = _defaultSystemDataPath,
|
||||
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
|
||||
};
|
||||
}
|
||||
|
||||
private string ResolveDataRoot(DataLocationConfig? config)
|
||||
{
|
||||
if (config is null)
|
||||
@@ -193,18 +214,8 @@ internal sealed class DataLocationResolver
|
||||
|
||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable
|
||||
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||
? customPath
|
||||
: DefaultPortableDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
var config = new DataLocationConfig
|
||||
{
|
||||
DataLocationMode = mode.ToString(),
|
||||
SystemDataPath = _defaultSystemDataPath,
|
||||
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
|
||||
};
|
||||
var config = BuildConfig(mode, customPath);
|
||||
var targetDataRoot = ResolveDataRoot(config);
|
||||
|
||||
// 先创建目录结构
|
||||
try
|
||||
|
||||
@@ -57,6 +57,23 @@ internal sealed class OobeCompletionResult
|
||||
public string ErrorMessage { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class OobeSessionDraft
|
||||
{
|
||||
public DataLocationMode DataLocationMode { get; init; } = DataLocationMode.System;
|
||||
|
||||
public bool MigrateExistingData { get; init; }
|
||||
|
||||
public HostAppSettingsStartupChoices StartupChoices { get; init; }
|
||||
|
||||
public PrivacyConfig PrivacyConfig { get; init; } = new();
|
||||
|
||||
public bool PrivacyAgreementAccepted { get; init; }
|
||||
|
||||
public string PrivacyUserId { get; init; } = string.Empty;
|
||||
|
||||
public string PrivacyDeviceId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record LauncherExecutionSnapshot(
|
||||
bool IsElevated,
|
||||
string UserName,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Views;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
internal sealed class DataLocationOobeStep : IOobeStep
|
||||
{
|
||||
private readonly DataLocationResolver _resolver;
|
||||
|
||||
public DataLocationOobeStep(DataLocationResolver resolver)
|
||||
{
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var existingConfig = _resolver.LoadConfig();
|
||||
if (existingConfig is not null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step skipped: config already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
DataLocationPromptWindow? window = null;
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
window = new DataLocationPromptWindow(_resolver);
|
||||
window.Show();
|
||||
});
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
Logger.Warn("DataLocation OOBE step failed: window could not be created.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await window.WaitForChoiceAsync().ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location.");
|
||||
_resolver.ApplyLocationChoice(DataLocationMode.System, null, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData);
|
||||
Logger.Info(
|
||||
$"DataLocation OOBE step: user selected '{result.SelectedMode}'. " +
|
||||
$"Migrate={result.MigrateExistingData}; Success={success}.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (window.IsVisible)
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
internal sealed record OobeStepResult(bool ContinueLaunch, LauncherResult? Result = null)
|
||||
{
|
||||
public static OobeStepResult Continue { get; } = new(true);
|
||||
|
||||
public static OobeStepResult Complete(LauncherResult result) => new(false, result);
|
||||
}
|
||||
|
||||
internal interface IOobeStep
|
||||
{
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
Task<OobeStepResult> RunAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
92
LanMountainDesktop.Launcher/Oobe/OobeSessionCommitService.cs
Normal file
92
LanMountainDesktop.Launcher/Oobe/OobeSessionCommitService.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Oobe;
|
||||
|
||||
internal sealed class OobeSessionCommitService
|
||||
{
|
||||
private readonly DataLocationResolver _dataLocationResolver;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly CommandContext _context;
|
||||
private readonly Func<bool, bool>? _setWindowsStartup;
|
||||
|
||||
public OobeSessionCommitService(
|
||||
DataLocationResolver dataLocationResolver,
|
||||
OobeStateService oobeStateService,
|
||||
CommandContext context,
|
||||
Func<bool, bool>? setWindowsStartup = null)
|
||||
{
|
||||
_dataLocationResolver = dataLocationResolver;
|
||||
_oobeStateService = oobeStateService;
|
||||
_context = context;
|
||||
_setWindowsStartup = setWindowsStartup;
|
||||
}
|
||||
|
||||
public OobeCompletionResult Commit(OobeSessionDraft draft)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(draft);
|
||||
|
||||
if (!_dataLocationResolver.ApplyLocationChoice(
|
||||
draft.DataLocationMode,
|
||||
customPath: null,
|
||||
draft.MigrateExistingData))
|
||||
{
|
||||
return Failure("data_location_save_failed", "Failed to save the selected data location.");
|
||||
}
|
||||
|
||||
var dataRoot = _dataLocationResolver.ResolveDataRoot();
|
||||
|
||||
try
|
||||
{
|
||||
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataRoot);
|
||||
HostAppSettingsOobeMerger.MergeStartupPresentation(settingsPath, draft.StartupChoices);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failure("startup_settings_save_failed", ex.Message);
|
||||
}
|
||||
|
||||
var setWindowsStartup = _setWindowsStartup ?? new LauncherWindowsStartupService().SetEnabled;
|
||||
if (OperatingSystem.IsWindows() &&
|
||||
!setWindowsStartup(draft.StartupChoices.AutoStartWithWindows))
|
||||
{
|
||||
return Failure("windows_startup_save_failed", "Failed to save Windows startup preference.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var launcherDataPath = _dataLocationResolver.ResolveLauncherDataPath();
|
||||
Directory.CreateDirectory(launcherDataPath);
|
||||
|
||||
var privacyConfigPath = Path.Combine(launcherDataPath, "privacy-config.json");
|
||||
var privacyJson = JsonSerializer.Serialize(draft.PrivacyConfig, AppJsonContext.Default.PrivacyConfig);
|
||||
File.WriteAllText(privacyConfigPath, privacyJson);
|
||||
|
||||
var agreementService = new PrivacyAgreementService(launcherDataPath);
|
||||
if (!agreementService.SaveAgreement(
|
||||
draft.PrivacyAgreementAccepted,
|
||||
draft.PrivacyUserId,
|
||||
draft.PrivacyDeviceId))
|
||||
{
|
||||
return Failure("privacy_agreement_save_failed", "Failed to save privacy agreement state.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Failure("privacy_settings_save_failed", ex.Message);
|
||||
}
|
||||
|
||||
var completion = _oobeStateService.MarkCompleted(_context, dataRoot);
|
||||
return completion.Success
|
||||
? completion
|
||||
: Failure(completion.ResultCode, completion.ErrorMessage);
|
||||
}
|
||||
|
||||
private static OobeCompletionResult Failure(string code, string message) =>
|
||||
new()
|
||||
{
|
||||
Success = false,
|
||||
ResultCode = code,
|
||||
ErrorMessage = message
|
||||
};
|
||||
}
|
||||
@@ -7,10 +7,12 @@ internal sealed class OobeStateService
|
||||
{
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
private readonly string _appRoot;
|
||||
private readonly string? _stateRootOverride;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _statePath;
|
||||
private readonly string _legacyStatePath;
|
||||
private readonly string _legacyMarkerPath;
|
||||
private readonly IReadOnlyList<string> _legacyStatePaths;
|
||||
private readonly IReadOnlyList<string> _legacyMarkerPaths;
|
||||
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||
|
||||
public OobeStateService(
|
||||
@@ -18,21 +20,17 @@ internal sealed class OobeStateService
|
||||
string? stateRootOverride = null,
|
||||
LauncherExecutionSnapshot? executionSnapshot = null)
|
||||
{
|
||||
_ = Path.GetFullPath(appRoot);
|
||||
_appRoot = Path.GetFullPath(appRoot);
|
||||
_stateRootOverride = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? null
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||
|
||||
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? ResolveStateRoot(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
var stateRoot = ResolveCurrentStateRoot();
|
||||
(_stateDirectory, _statePath) = BuildStatePaths(stateRoot);
|
||||
|
||||
var legacyRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
var legacyStateDirectory = Path.Combine(legacyRoot, ".launcher", "state");
|
||||
_legacyStatePath = Path.Combine(legacyStateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(legacyStateDirectory, "first_run_completed");
|
||||
_legacyStatePaths = BuildLegacyPaths("oobe-state.json");
|
||||
_legacyMarkerPaths = BuildLegacyPaths("first_run_completed");
|
||||
}
|
||||
|
||||
public OobeLaunchDecision Evaluate(CommandContext context)
|
||||
@@ -47,10 +45,17 @@ internal sealed class OobeStateService
|
||||
}
|
||||
|
||||
public OobeCompletionResult MarkCompleted(CommandContext context)
|
||||
{
|
||||
return MarkCompleted(context, null);
|
||||
}
|
||||
|
||||
public OobeCompletionResult MarkCompleted(CommandContext context, string? stateRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var (stateDirectory, statePath) = BuildStatePaths(
|
||||
string.IsNullOrWhiteSpace(stateRoot) ? ResolveCurrentStateRoot() : Path.GetFullPath(stateRoot));
|
||||
Directory.CreateDirectory(stateDirectory);
|
||||
var payload = new OobeStateFile
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
@@ -60,14 +65,14 @@ internal sealed class OobeStateService
|
||||
LaunchSource = context.LaunchSource
|
||||
};
|
||||
|
||||
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
|
||||
var tempPath = Path.Combine(stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
|
||||
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _statePath, overwrite: true);
|
||||
File.Move(tempPath, statePath, overwrite: true);
|
||||
TryDeleteLegacyMarker();
|
||||
|
||||
Logger.Info(
|
||||
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{statePath}'; " +
|
||||
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
|
||||
|
||||
return new OobeCompletionResult
|
||||
@@ -110,20 +115,27 @@ internal sealed class OobeStateService
|
||||
return EvaluateStateFile(context, _statePath, migratedLegacyState: false);
|
||||
}
|
||||
|
||||
if (File.Exists(_legacyStatePath))
|
||||
foreach (var legacyStatePath in _legacyStatePaths)
|
||||
{
|
||||
return EvaluateStateFile(context, _legacyStatePath, migratedLegacyState: false);
|
||||
if (File.Exists(legacyStatePath))
|
||||
{
|
||||
var decision = EvaluateStateFile(context, legacyStatePath, migratedLegacyState: true);
|
||||
if (decision.Status == OobeStateStatus.Completed)
|
||||
{
|
||||
_ = MarkCompleted(context);
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
foreach (var legacyMarkerPath in _legacyMarkerPaths)
|
||||
{
|
||||
migratedLegacyMarker = TryMigrateLegacyMarker(context);
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
|
||||
}
|
||||
|
||||
if (_executionSnapshot.IsElevated)
|
||||
{
|
||||
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
|
||||
if (File.Exists(legacyMarkerPath))
|
||||
{
|
||||
migratedLegacyMarker = TryMigrateLegacyMarker(context);
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -159,15 +171,18 @@ internal sealed class OobeStateService
|
||||
|
||||
private void TryDeleteLegacyMarker()
|
||||
{
|
||||
try
|
||||
foreach (var legacyMarkerPath in _legacyMarkerPaths)
|
||||
{
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
try
|
||||
{
|
||||
if (File.Exists(legacyMarkerPath))
|
||||
{
|
||||
File.Delete(legacyMarkerPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
File.Delete(_legacyMarkerPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +240,44 @@ internal sealed class OobeStateService
|
||||
};
|
||||
}
|
||||
|
||||
private string ResolveCurrentStateRoot()
|
||||
{
|
||||
return _stateRootOverride ?? ResolveStateRoot(_appRoot);
|
||||
}
|
||||
|
||||
private static (string StateDirectory, string StatePath) BuildStatePaths(string stateRoot)
|
||||
{
|
||||
var stateDirectory = Path.Combine(Path.GetFullPath(stateRoot), "Launcher", "state");
|
||||
return (stateDirectory, Path.Combine(stateDirectory, "oobe-state.json"));
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> BuildLegacyPaths(string fileName)
|
||||
{
|
||||
var roots = new List<string>();
|
||||
if (_stateRootOverride is not null)
|
||||
{
|
||||
roots.Add(_stateRootOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
roots.Add(ResolveDefaultSystemStateRoot());
|
||||
roots.Add(_appRoot);
|
||||
try
|
||||
{
|
||||
roots.Add(ResolveCurrentStateRoot());
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
.Where(root => !string.IsNullOrWhiteSpace(root))
|
||||
.Select(root => Path.Combine(Path.GetFullPath(root), ".launcher", "state", fileName))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveStateRoot(string appRoot)
|
||||
{
|
||||
try
|
||||
@@ -243,4 +296,15 @@ internal sealed class OobeStateService
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveDefaultSystemStateRoot()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(appData))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,19 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly CommandContext _context;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly DataLocationResolver _dataLocationResolver;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
|
||||
public WelcomeOobeStep(
|
||||
OobeStateService oobeStateService,
|
||||
CommandContext context,
|
||||
DataLocationResolver dataLocationResolver)
|
||||
{
|
||||
_oobeStateService = oobeStateService;
|
||||
_context = context;
|
||||
_dataLocationResolver = dataLocationResolver;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
public async Task<OobeStepResult> RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -27,16 +32,32 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
return BuildCancelledResult("OOBE window could not be created.");
|
||||
}
|
||||
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
var completion = _oobeStateService.MarkCompleted(_context);
|
||||
var draft = await window.WaitForCompletionAsync().ConfigureAwait(false);
|
||||
if (draft is null)
|
||||
{
|
||||
Logger.Info("OOBE was cancelled before completion; Host launch will be skipped.");
|
||||
return BuildCancelledResult("OOBE was cancelled before completion.");
|
||||
}
|
||||
|
||||
var completion = new OobeSessionCommitService(
|
||||
_dataLocationResolver,
|
||||
_oobeStateService,
|
||||
_context)
|
||||
.Commit(draft);
|
||||
if (!completion.Success)
|
||||
{
|
||||
Logger.Warn(
|
||||
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " +
|
||||
$"OOBE session was not persisted. ResultCode='{completion.ResultCode}'; " +
|
||||
$"Error='{completion.ErrorMessage}'.");
|
||||
return OobeStepResult.Complete(LaunchResultBuilder.Build(
|
||||
false,
|
||||
"oobe",
|
||||
completion.ResultCode,
|
||||
"OOBE settings could not be saved.",
|
||||
errorMessage: completion.ErrorMessage));
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
@@ -46,5 +67,16 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
|
||||
return OobeStepResult.Continue;
|
||||
}
|
||||
|
||||
private static OobeStepResult BuildCancelledResult(string message)
|
||||
{
|
||||
return OobeStepResult.Complete(LaunchResultBuilder.Build(
|
||||
false,
|
||||
"oobe",
|
||||
"oobe_cancelled",
|
||||
message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,17 @@ public static class Strings
|
||||
public static string DebugDebug_ButtonCancel => ResourceManager.GetString(nameof(DebugDebug_ButtonCancel), Culture)!;
|
||||
public static string DebugDebug_ButtonOk => ResourceManager.GetString(nameof(DebugDebug_ButtonOk), Culture)!;
|
||||
public static string DebugDebug_SelectExeDialog => ResourceManager.GetString(nameof(DebugDebug_SelectExeDialog), Culture)!;
|
||||
public static string DebugDebug_BackgroundImage => ResourceManager.GetString(nameof(DebugDebug_BackgroundImage), Culture)!;
|
||||
public static string DebugDebug_BackgroundImageDesc => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageDesc), Culture)!;
|
||||
public static string DebugDebug_BackgroundImageNotSet => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageNotSet), Culture)!;
|
||||
public static string DebugDebug_BackgroundImageSaved => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageSaved), Culture)!;
|
||||
public static string DebugDebug_BackgroundImageCleared => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageCleared), Culture)!;
|
||||
public static string DebugDebug_BackgroundImageSaveFailedFormat => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageSaveFailedFormat), Culture)!;
|
||||
public static string DebugDebug_BackgroundImageReadyFormat => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageReadyFormat), Culture)!;
|
||||
public static string DebugDebug_BackgroundImageInvalidFormat => ResourceManager.GetString(nameof(DebugDebug_BackgroundImageInvalidFormat), Culture)!;
|
||||
public static string DebugDebug_Clear => ResourceManager.GetString(nameof(DebugDebug_Clear), Culture)!;
|
||||
public static string DebugDebug_SelectImageDialog => ResourceManager.GetString(nameof(DebugDebug_SelectImageDialog), Culture)!;
|
||||
public static string DebugDebug_ImageFiles => ResourceManager.GetString(nameof(DebugDebug_ImageFiles), Culture)!;
|
||||
public static string Oobe_Title => ResourceManager.GetString(nameof(Oobe_Title), Culture)!;
|
||||
public static string Oobe_WelcomeTitle => ResourceManager.GetString(nameof(Oobe_WelcomeTitle), Culture)!;
|
||||
public static string Oobe_WelcomeSubtitle => ResourceManager.GetString(nameof(Oobe_WelcomeSubtitle), Culture)!;
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>Cancel</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>OK</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>Select LanMountainDesktop host executable</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>Splash image</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>Choose an image to show on the splash screen. It will be copied into the Launcher data directory.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>No splash image selected</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>Splash image saved. The current splash screen will refresh immediately.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>Splash image cleared.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>Image setting failed: {0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>Current splash image is ready ({0} x {1}).</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>Current splash image is unavailable: {0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>Clear</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>Select splash image</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>Image files</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>Welcome to LanMountain Desktop</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>Welcome to LanMountain Desktop</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>Your desktop, more than one side</value></data>
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>キャンセル</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>OK</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>蘭山デスクトップホスト実行可能ファイルを選択</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>スプラッシュ画像</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>起動画面に表示する画像を選択します。画像は Launcher のデータディレクトリにコピーされます。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>スプラッシュ画像は未設定です</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>スプラッシュ画像を保存しました。現在の起動画面はすぐに更新されます。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>スプラッシュ画像をクリアしました。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>画像設定に失敗しました: {0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>現在のスプラッシュ画像は使用できます({0} x {1})。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>現在のスプラッシュ画像は使用できません: {0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>クリア</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>スプラッシュ画像を選択</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>画像ファイル</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>蘭山デスクトップへようこそ</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>蘭山デスクトップへようこそ</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>あなたのデスクトップ、一面だけじゃない</value></data>
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>취소</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>확인</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>란산 데스크톱 호스트 실행 파일 선택</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>스플래시 이미지</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>시작 화면에 표시할 이미지를 선택합니다. 이미지는 Launcher 데이터 디렉터리에 복사됩니다.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>스플래시 이미지가 설정되지 않았습니다</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>스플래시 이미지가 저장되었습니다. 현재 시작 화면이 즉시 새로 고쳐집니다.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>스플래시 이미지가 지워졌습니다.</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>이미지 설정 실패: {0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>현재 스플래시 이미지를 사용할 수 있습니다({0} x {1}).</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>현재 스플래시 이미지를 사용할 수 없습니다: {0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>지우기</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>스플래시 이미지 선택</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>이미지 파일</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>란산 데스크톱에 오신 것을 환영합니다</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>란산 데스크톱에 오신 것을 환영합니다</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>당신의 데스크톱, 한 면이 아닙니다</value></data>
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
<data name="DebugDebug_ButtonCancel" xml:space="preserve"><value>取消</value></data>
|
||||
<data name="DebugDebug_ButtonOk" xml:space="preserve"><value>确定</value></data>
|
||||
<data name="DebugDebug_SelectExeDialog" xml:space="preserve"><value>选择阑山桌面主程序可执行文件</value></data>
|
||||
<data name="DebugDebug_BackgroundImage" xml:space="preserve"><value>启动图</value></data>
|
||||
<data name="DebugDebug_BackgroundImageDesc" xml:space="preserve"><value>选择一张图片显示在启动画面中。图片会复制保存到 Launcher 数据目录。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageNotSet" xml:space="preserve"><value>未设置启动图</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaved" xml:space="preserve"><value>启动图已保存,当前启动画面会立即刷新。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageCleared" xml:space="preserve"><value>启动图已清除。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageSaveFailedFormat" xml:space="preserve"><value>图片设置失败:{0}</value></data>
|
||||
<data name="DebugDebug_BackgroundImageReadyFormat" xml:space="preserve"><value>当前启动图可用({0} × {1})。</value></data>
|
||||
<data name="DebugDebug_BackgroundImageInvalidFormat" xml:space="preserve"><value>当前启动图不可用:{0}</value></data>
|
||||
<data name="DebugDebug_Clear" xml:space="preserve"><value>清除</value></data>
|
||||
<data name="DebugDebug_SelectImageDialog" xml:space="preserve"><value>选择启动图</value></data>
|
||||
<data name="DebugDebug_ImageFiles" xml:space="preserve"><value>图片文件</value></data>
|
||||
<data name="Oobe_Title" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
|
||||
<data name="Oobe_WelcomeTitle" xml:space="preserve"><value>欢迎使用阑山桌面</value></data>
|
||||
<data name="Oobe_WelcomeSubtitle" xml:space="preserve"><value>你的桌面,不止一面</value></data>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
@@ -18,31 +19,48 @@ internal sealed class AirAppRuntimeBridge
|
||||
|
||||
public async Task EnsureStartedAsync()
|
||||
{
|
||||
Logger.Info($"AIRAPP: Checking if AirApp Runtime is available. AppRoot='{_appRoot}'");
|
||||
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
{
|
||||
Logger.Info("AirApp Runtime is already available.");
|
||||
Logger.Info("AIRAPP: AirApp Runtime is already available.");
|
||||
return;
|
||||
}
|
||||
|
||||
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||
_appRoot,
|
||||
Environment.ProcessId,
|
||||
0,
|
||||
_dataRoot));
|
||||
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||
Logger.Info("AIRAPP: Starting AirApp Runtime...");
|
||||
Process? process;
|
||||
try
|
||||
{
|
||||
process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||
_appRoot,
|
||||
Environment.ProcessId,
|
||||
0,
|
||||
_dataRoot));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"AIRAPP: AirApp Runtime start request failed. AppRoot='{_appRoot}'; Error='{ex.Message}'");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Info($"AIRAPP: AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
|
||||
|
||||
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
|
||||
{
|
||||
Logger.Info($"AIRAPP: Attempt {attempt}/{ConnectAttempts} - Checking IPC connection...");
|
||||
|
||||
if (await TryGetStatusAsync().ConfigureAwait(false) is not null)
|
||||
{
|
||||
Logger.Info("AirApp Runtime IPC is ready.");
|
||||
Logger.Info("AIRAPP: AirApp Runtime IPC is ready.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt)).ConfigureAwait(false);
|
||||
var delayMs = 250 * attempt;
|
||||
Logger.Info($"AIRAPP: IPC not ready, waiting {delayMs}ms before retry...");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(delayMs)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Logger.Warn("AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||
Logger.Warn("AIRAPP: AirApp Runtime did not become ready after pre-start; Host fallback remains available.");
|
||||
}
|
||||
|
||||
public async Task AttachHostAsync(int hostProcessId)
|
||||
@@ -54,10 +72,15 @@ internal sealed class AirAppRuntimeBridge
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
|
||||
var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName);
|
||||
await connectTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false);
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
var result = await proxy.AttachHostAsync(hostProcessId).ConfigureAwait(false);
|
||||
var attachTask = proxy.AttachHostAsync(hostProcessId);
|
||||
var result = await attachTask.WaitAsync(TimeSpan.FromSeconds(3), cts.Token).ConfigureAwait(false);
|
||||
Logger.Info($"AirApp Runtime host attach completed. Accepted={result.Accepted}; Code='{result.Code}'; HostPid={hostProcessId}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -70,13 +93,29 @@ internal sealed class AirAppRuntimeBridge
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
using var client = new LanMountainDesktopIpcClient();
|
||||
await client.ConnectAsync(IpcConstants.AirAppRuntimePipeName).ConfigureAwait(false);
|
||||
|
||||
var connectTask = client.ConnectAsync(IpcConstants.AirAppRuntimePipeName);
|
||||
await connectTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false);
|
||||
|
||||
var proxy = client.CreateProxy<IAirAppRuntimeControlService>();
|
||||
return await proxy.GetStatusAsync().ConfigureAwait(false);
|
||||
var statusTask = proxy.GetStatusAsync();
|
||||
return await statusTask.WaitAsync(TimeSpan.FromSeconds(2), cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Logger.Info("AIRAPP: TryGetStatusAsync timed out (2s).");
|
||||
return null;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.Info("AIRAPP: TryGetStatusAsync cancelled.");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Info($"AIRAPP: TryGetStatusAsync failed: {ex.GetType().Name} - {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ internal static class PreviewEntryHandler
|
||||
{
|
||||
try
|
||||
{
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
await window.WaitForCompletionAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,22 +2,28 @@ using Avalonia.Media.Imaging;
|
||||
|
||||
namespace LanMountainDesktop.Launcher.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 启动器背景图片服务
|
||||
/// </summary>
|
||||
internal static class LauncherBackgroundService
|
||||
{
|
||||
private const string PictureFileName = "Launcher Picture";
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private const double WindowAspectRatio = 7.0 / 5.0; // 700:500
|
||||
private const double AspectRatioTolerance = 0.15; // 15% 误差
|
||||
private const long MaxFileSize = 10 * 1024 * 1024;
|
||||
|
||||
private static readonly string[] SupportedExtensions =
|
||||
[
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".bmp",
|
||||
".gif",
|
||||
".webp"
|
||||
];
|
||||
|
||||
private static Bitmap? _cachedBitmap;
|
||||
private static string? _cachedPath;
|
||||
private static long _cachedLength;
|
||||
private static DateTime _cachedLastWriteTimeUtc;
|
||||
|
||||
internal static string? LauncherDataDirectoryOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 背景图片信息
|
||||
/// </summary>
|
||||
public record BackgroundImageInfo
|
||||
{
|
||||
public required bool Exists { get; init; }
|
||||
@@ -30,29 +36,29 @@ internal static class LauncherBackgroundService
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载背景图片
|
||||
/// </summary>
|
||||
public record BackgroundImageMutationResult
|
||||
{
|
||||
public required bool IsSuccess { get; init; }
|
||||
public string? FilePath { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public static BackgroundImageInfo LoadBackgroundImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolver = new DataLocationResolver(AppContext.BaseDirectory);
|
||||
var launcherPath = resolver.ResolveLauncherDataPath();
|
||||
|
||||
// 查找图片文件
|
||||
var launcherPath = ResolveLauncherDataPath();
|
||||
var imagePath = FindImageFile(launcherPath);
|
||||
if (imagePath == null)
|
||||
if (imagePath is null)
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = "未找到背景图片文件"
|
||||
ErrorMessage = "No launcher background image was found."
|
||||
};
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
var fileInfo = new FileInfo(imagePath);
|
||||
if (fileInfo.Length > MaxFileSize)
|
||||
{
|
||||
@@ -61,12 +67,11 @@ internal static class LauncherBackgroundService
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)"
|
||||
ErrorMessage = $"Image file is too large ({fileInfo.Length / 1024 / 1024}MB > 10MB)."
|
||||
};
|
||||
}
|
||||
|
||||
// 使用缓存
|
||||
if (_cachedBitmap != null && _cachedPath == imagePath)
|
||||
if (IsCacheCurrent(imagePath, fileInfo))
|
||||
{
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
@@ -74,40 +79,40 @@ internal static class LauncherBackgroundService
|
||||
IsValid = true,
|
||||
FilePath = imagePath,
|
||||
Bitmap = _cachedBitmap,
|
||||
Width = _cachedBitmap.PixelSize.Width,
|
||||
Width = _cachedBitmap!.PixelSize.Width,
|
||||
Height = _cachedBitmap.PixelSize.Height,
|
||||
AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height
|
||||
};
|
||||
}
|
||||
|
||||
// 加载图片
|
||||
var bitmap = new Bitmap(imagePath);
|
||||
var width = bitmap.PixelSize.Width;
|
||||
var height = bitmap.PixelSize.Height;
|
||||
var aspectRatio = (double)width / height;
|
||||
DisposeCache();
|
||||
|
||||
// 校验比例
|
||||
var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio;
|
||||
if (ratioDiff > AspectRatioTolerance)
|
||||
Bitmap bitmap;
|
||||
try
|
||||
{
|
||||
bitmap = new Bitmap(imagePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
bitmap.Dispose();
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = true,
|
||||
IsValid = false,
|
||||
FilePath = imagePath,
|
||||
Width = width,
|
||||
Height = height,
|
||||
AspectRatio = aspectRatio,
|
||||
ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})"
|
||||
ErrorMessage = $"Image could not be decoded: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
// 缓存图片
|
||||
var width = bitmap.PixelSize.Width;
|
||||
var height = bitmap.PixelSize.Height;
|
||||
var aspectRatio = height == 0 ? 0d : (double)width / height;
|
||||
|
||||
_cachedBitmap = bitmap;
|
||||
_cachedPath = imagePath;
|
||||
_cachedLength = fileInfo.Length;
|
||||
_cachedLastWriteTimeUtc = fileInfo.LastWriteTimeUtc;
|
||||
|
||||
Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})");
|
||||
Logger.Info($"[LauncherBackground] Background image loaded: {imagePath} ({width}x{height}).");
|
||||
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
@@ -122,38 +127,159 @@ internal static class LauncherBackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}");
|
||||
Logger.Warn($"[LauncherBackground] Failed to load background image: {ex.Message}");
|
||||
return new BackgroundImageInfo
|
||||
{
|
||||
Exists = false,
|
||||
IsValid = false,
|
||||
ErrorMessage = $"加载失败: {ex.Message}"
|
||||
ErrorMessage = $"Load failed: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查找图片文件
|
||||
/// </summary>
|
||||
public static BackgroundImageMutationResult SaveBackgroundImage(string sourcePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourcePath))
|
||||
{
|
||||
return FailMutation("No image file was selected.");
|
||||
}
|
||||
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
return FailMutation("The selected image file does not exist.");
|
||||
}
|
||||
|
||||
var extension = NormalizeExtension(Path.GetExtension(fullSourcePath));
|
||||
if (!IsSupportedExtension(extension))
|
||||
{
|
||||
return FailMutation("The selected image format is not supported.");
|
||||
}
|
||||
|
||||
var sourceInfo = new FileInfo(fullSourcePath);
|
||||
if (sourceInfo.Length > MaxFileSize)
|
||||
{
|
||||
return FailMutation($"Image file is too large ({sourceInfo.Length / 1024 / 1024}MB > 10MB).");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var bitmap = new Bitmap(fullSourcePath);
|
||||
_ = bitmap.PixelSize;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return FailMutation($"The selected image could not be decoded: {ex.Message}");
|
||||
}
|
||||
|
||||
var launcherPath = ResolveLauncherDataPath();
|
||||
Directory.CreateDirectory(launcherPath);
|
||||
|
||||
var destinationPath = Path.Combine(launcherPath, PictureFileName + extension);
|
||||
var tempPath = Path.Combine(launcherPath, $".{PictureFileName}.{Guid.NewGuid():N}.tmp");
|
||||
|
||||
try
|
||||
{
|
||||
File.Copy(fullSourcePath, tempPath, overwrite: true);
|
||||
ClearCache();
|
||||
File.Move(tempPath, destinationPath, overwrite: true);
|
||||
DeleteManagedImageFiles(launcherPath, destinationPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(tempPath);
|
||||
}
|
||||
|
||||
ClearCache();
|
||||
|
||||
Logger.Info($"[LauncherBackground] Background image saved: {destinationPath}.");
|
||||
return new BackgroundImageMutationResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
FilePath = destinationPath
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LauncherBackground] Failed to save background image: {ex.Message}");
|
||||
return FailMutation($"Save failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static BackgroundImageMutationResult ClearBackgroundImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var launcherPath = ResolveLauncherDataPath();
|
||||
ClearCache();
|
||||
DeleteManagedImageFiles(launcherPath, exceptPath: null);
|
||||
|
||||
Logger.Info("[LauncherBackground] Background image cleared.");
|
||||
return new BackgroundImageMutationResult
|
||||
{
|
||||
IsSuccess = true
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LauncherBackground] Failed to clear background image: {ex.Message}");
|
||||
return FailMutation($"Clear failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearCache()
|
||||
{
|
||||
DisposeCache();
|
||||
_cachedPath = null;
|
||||
_cachedLength = 0;
|
||||
_cachedLastWriteTimeUtc = DateTime.MinValue;
|
||||
}
|
||||
|
||||
internal static string? FindManagedImageFile()
|
||||
{
|
||||
return FindImageFile(ResolveLauncherDataPath());
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> GetSupportedExtensions() => SupportedExtensions;
|
||||
|
||||
private static BackgroundImageMutationResult FailMutation(string message)
|
||||
{
|
||||
return new BackgroundImageMutationResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
ErrorMessage = message
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsCacheCurrent(string imagePath, FileInfo fileInfo)
|
||||
{
|
||||
return _cachedBitmap is not null &&
|
||||
string.Equals(_cachedPath, imagePath, StringComparison.OrdinalIgnoreCase) &&
|
||||
_cachedLength == fileInfo.Length &&
|
||||
_cachedLastWriteTimeUtc == fileInfo.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
private static string? FindImageFile(string directory)
|
||||
{
|
||||
var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
|
||||
|
||||
foreach (var ext in extensions)
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
var path = Path.Combine(directory, PictureFileName + ext);
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var extension in SupportedExtensions)
|
||||
{
|
||||
var path = Path.Combine(directory, PictureFileName + extension);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 也尝试不带扩展名的匹配(如果文件本身就有扩展名)
|
||||
var files = Directory.GetFiles(directory, PictureFileName + ".*");
|
||||
foreach (var file in files)
|
||||
foreach (var file in Directory.GetFiles(directory, PictureFileName + ".*"))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (extensions.Contains(ext))
|
||||
if (IsSupportedExtension(Path.GetExtension(file)))
|
||||
{
|
||||
return file;
|
||||
}
|
||||
@@ -162,13 +288,72 @@ internal static class LauncherBackgroundService
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清除缓存
|
||||
/// </summary>
|
||||
public static void ClearCache()
|
||||
private static void DeleteManagedImageFiles(string directory, string? exceptPath)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.GetFiles(directory, PictureFileName + ".*"))
|
||||
{
|
||||
if (!IsSupportedExtension(Path.GetExtension(file)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(exceptPath) &&
|
||||
string.Equals(Path.GetFullPath(file), Path.GetFullPath(exceptPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryDeleteFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"[LauncherBackground] Failed to delete '{path}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string? extension)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(extension)
|
||||
? string.Empty
|
||||
: extension.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsSupportedExtension(string? extension)
|
||||
{
|
||||
var normalized = NormalizeExtension(extension);
|
||||
return SupportedExtensions.Contains(normalized, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ResolveLauncherDataPath()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(LauncherDataDirectoryOverride))
|
||||
{
|
||||
return Path.GetFullPath(LauncherDataDirectoryOverride);
|
||||
}
|
||||
|
||||
var resolver = new DataLocationResolver(AppContext.BaseDirectory);
|
||||
return resolver.ResolveLauncherDataPath();
|
||||
}
|
||||
|
||||
private static void DisposeCache()
|
||||
{
|
||||
_cachedBitmap?.Dispose();
|
||||
_cachedBitmap = null;
|
||||
_cachedPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ internal static class LauncherGuiCoordinator
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
|
||||
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
@@ -123,6 +121,7 @@ internal static class LauncherGuiCoordinator
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ internal sealed class LauncherOrchestrator
|
||||
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
|
||||
_oobeSteps =
|
||||
[
|
||||
new WelcomeOobeStep(_oobeStateService, _context),
|
||||
new DataLocationOobeStep(_dataLocationResolver)
|
||||
new WelcomeOobeStep(_oobeStateService, _context, _dataLocationResolver)
|
||||
];
|
||||
_pipeline = pipeline ?? new LaunchPipeline(
|
||||
[
|
||||
@@ -145,8 +144,18 @@ internal sealed class LauncherOrchestrator
|
||||
return;
|
||||
}
|
||||
|
||||
if (!softTimeoutShown)
|
||||
{
|
||||
// 用户在软超时前关闭窗口,提示确认
|
||||
Logger.Info("Splash window was closed manually before soft timeout. Cancelling startup attempt.");
|
||||
_startupAttemptRegistry.MarkOwnedFailed(lastStage, "User cancelled startup before soft timeout.");
|
||||
// 取消后续监控
|
||||
successTcs.TrySetCanceled();
|
||||
return;
|
||||
}
|
||||
|
||||
_startupAttemptRegistry.MarkOwnedDetachedWaiting();
|
||||
Logger.Warn("Splash window was closed manually. Launcher will continue monitoring the current startup attempt.");
|
||||
Logger.Warn("Splash window was closed manually after soft timeout. Launcher will continue monitoring the current startup attempt in detached mode.");
|
||||
};
|
||||
splashWindow.Closed += splashClosedHandler;
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ internal sealed class HostLaunchService
|
||||
return HostLaunchOutcome.FromResult(prerequisiteFailure);
|
||||
}
|
||||
|
||||
await EnsureAirAppRuntimeStartedAsync(context.DeploymentLocator.GetAppRoot(), dataRoot).ConfigureAwait(false);
|
||||
|
||||
var hostPath = plan.HostPath;
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
@@ -204,6 +206,20 @@ internal sealed class HostLaunchService
|
||||
details));
|
||||
}
|
||||
|
||||
private static async Task EnsureAirAppRuntimeStartedAsync(string appRoot, string? dataRoot)
|
||||
{
|
||||
Logger.Info("HOST LAUNCH: Attempting to pre-start AirApp Runtime...");
|
||||
try
|
||||
{
|
||||
await new AirAppRuntimeBridge(appRoot, dataRoot).EnsureStartedAsync().ConfigureAwait(false);
|
||||
Logger.Info("HOST LAUNCH: AirApp Runtime pre-start completed.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"HOST LAUNCH: AirApp Runtime pre-start failed; Host fallback remains available. Error='{ex.Message}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HostStartAttempt> StartHostProcessAsync(
|
||||
HostLaunchPlan plan,
|
||||
HostStartMode startMode,
|
||||
@@ -235,6 +251,11 @@ internal sealed class HostLaunchService
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Info($"ATTEMPTING HOST START: Path='{plan.HostPath}'; WorkingDir='{plan.WorkingDirectory}'; Mode='{startMode}'");
|
||||
Logger.Info($" Arguments: {HostLaunchPlanBuilder.FormatArgumentsForLog(plan.Arguments)}");
|
||||
Logger.Info($" File exists: {File.Exists(plan.HostPath)}");
|
||||
Logger.Info($" Working dir exists: {Directory.Exists(plan.WorkingDirectory)}");
|
||||
|
||||
var process = Process.Start(startInfo);
|
||||
Logger.Info(
|
||||
$"Host launch requested. Mode='{startMode}'; RetryTag='{retryTag ?? "<none>"}'; Path='{plan.HostPath}'; " +
|
||||
@@ -243,15 +264,30 @@ internal sealed class HostLaunchService
|
||||
|
||||
if (process is null)
|
||||
{
|
||||
Logger.Error($"CRITICAL: Process.Start returned null! Path='{plan.HostPath}'; Mode='{startMode}'");
|
||||
Console.Error.WriteLine($"[CRITICAL] Process.Start returned null for path: {plan.HostPath}");
|
||||
return HostStartAttempt.StartFailed(startMode, "process_start_returned_null", plan);
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
// 等待一小段时间,检查进程是否立即退出
|
||||
await Task.Delay(500).ConfigureAwait(false);
|
||||
|
||||
if (process.HasExited)
|
||||
{
|
||||
Logger.Error($"CRITICAL: Host process exited immediately! ExitCode={process.ExitCode}; Path='{plan.HostPath}'");
|
||||
Console.Error.WriteLine($"[CRITICAL] Host process exited immediately with code {process.ExitCode}");
|
||||
return HostStartAttempt.StartFailed(startMode, $"process_exited_immediately_code_{process.ExitCode}", plan);
|
||||
}
|
||||
|
||||
Logger.Info($"Host process started successfully and is running. PID={process.Id}");
|
||||
return HostStartAttempt.Started(startMode, process, plan);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"Host start failed. Mode='{startMode}'.", ex);
|
||||
Logger.Error($"CRITICAL: Host start exception! Path='{plan.HostPath}'; Mode='{startMode}'; Exception={ex.GetType().Name}; Message='{ex.Message}'", ex);
|
||||
Console.Error.WriteLine($"[CRITICAL] Host start failed: {ex.Message}");
|
||||
Console.Error.WriteLine($"[CRITICAL] Path: {plan.HostPath}");
|
||||
Console.Error.WriteLine($"[CRITICAL] Exception: {ex}");
|
||||
return HostStartAttempt.StartFailed(startMode, ex.GetType().Name, plan);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user