mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
feat.在线安装器,更好的Issue与pull request模板。
This commit is contained in:
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
|
请不要在 PR、截图、日志或测试数据中提交 token、密钥、Cookie、真实账号、学生/班级个人信息或其他敏感内容。
|
||||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
Do not include tokens, secrets, cookies, real accounts, student/class personal data, or other sensitive information in this PR, screenshots, logs, or test data.
|
||||||
- [ ] New feature (non-breaking change which adds functionality)
|
-->
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
|
||||||
- [ ] Documentation update
|
|
||||||
|
|
||||||
## Related Issues
|
## 这个 PR 做了什么? / What does this PR do?
|
||||||
Fixes #(issue number)
|
|
||||||
|
|
||||||
## Testing
|
<!--
|
||||||
Please describe the testing you've done to verify the changes:
|
用 2-5 句话说明改动内容和原因。请说明用户、开发者或维护者能得到什么。
|
||||||
- [ ] Built successfully
|
Describe the change and the reason in 2-5 sentences. Mention what users, developers, or maintainers get from it.
|
||||||
- [ ] Tested on Windows
|
-->
|
||||||
- [ ] No new warnings or errors introduced
|
|
||||||
- [ ] Backward compatible
|
|
||||||
|
|
||||||
## Screenshots/Videos (if applicable)
|
## 相关 Issue / Related issues
|
||||||
If your changes include UI modifications, please attach screenshots or videos.
|
|
||||||
|
|
||||||
## Checklist
|
<!--
|
||||||
- [ ] My code follows the project's style guidelines
|
如果可以关闭 Issue,请使用:
|
||||||
- [ ] I have performed a self-review of my own code
|
Fixes #123
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
## Additional context
|
If this closes an issue, use:
|
||||||
Add any other context about the PR here.
|
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.
|
||||||
|
|||||||
59
LanDesktopPLONDS.installer/App.axaml
Normal file
59
LanDesktopPLONDS.installer/App.axaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:sty="using:FluentAvalonia.Styling"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
x:Class="LanDesktopPLONDS.Installer.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<Application.Resources>
|
||||||
|
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusSm">6</CornerRadius>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusLg">10</CornerRadius>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
|
||||||
|
<CornerRadius x:Key="DesignCornerRadiusComponent">12</CornerRadius>
|
||||||
|
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F7F9FC" />
|
||||||
|
<SolidColorBrush x:Key="InstallerTintBrush" Color="#DDF8FAFF" />
|
||||||
|
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#F9FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#22000000" />
|
||||||
|
<SolidColorBrush x:Key="InstallerSecondaryTextBrush" Color="#A0000000" />
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<sty:FluentAvaloniaTheme />
|
||||||
|
<Style Selector="Window">
|
||||||
|
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="UserControl">
|
||||||
|
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="fi|FluentIcon">
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
</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="StackPanel.installer-page-container">
|
||||||
|
<Setter Property="Spacing" Value="18" />
|
||||||
|
<Setter Property="Margin" Value="0,20,0,24" />
|
||||||
|
<Setter Property="MaxWidth" Value="860" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.page-title-text">
|
||||||
|
<Setter Property="FontSize" Value="28" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.page-description-text">
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource InstallerSecondaryTextBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
33
LanDesktopPLONDS.installer/App.axaml.cs
Normal file
33
LanDesktopPLONDS.installer/App.axaml.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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();
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 |
@@ -0,0 +1,34 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<TrimMode>partial</TrimMode>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||||
|
<OptimizationPreference>Size</OptimizationPreference>
|
||||||
|
<PublishReadyToRun>false</PublishReadyToRun>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
|
<TrimmerRootAssembly Include="Avalonia" />
|
||||||
|
<TrimmerRootAssembly Include="Avalonia.Desktop" />
|
||||||
|
<TrimmerRootAssembly Include="FluentAvalonia" />
|
||||||
|
<TrimmerRootAssembly Include="FluentIcons.Avalonia" />
|
||||||
|
<TrimmerRootAssembly Include="LanDesktopPLONDS.installer" />
|
||||||
|
<TrimmerRootAssembly Include="System.Text.Json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
|
||||||
|
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||||
|
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
33
LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
Normal file
33
LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<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>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
|
<PackageReference Include="FluentAvaloniaUI" />
|
||||||
|
<PackageReference Include="FluentIcons.Avalonia" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets\logo.ico" />
|
||||||
|
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
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);
|
||||||
20
LanDesktopPLONDS.installer/Program.cs
Normal file
20
LanDesktopPLONDS.installer/Program.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace LanDesktopPLONDS.Installer;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
{
|
||||||
|
return AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
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")]
|
||||||
344
LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
Normal file
344
LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using LanDesktopPLONDS.Installer.Models;
|
||||||
|
|
||||||
|
namespace LanDesktopPLONDS.Installer.Services;
|
||||||
|
|
||||||
|
internal sealed class FilesPackageInstaller
|
||||||
|
{
|
||||||
|
public async Task InstallAsync(
|
||||||
|
PreparedFilesPackage package,
|
||||||
|
string installPath,
|
||||||
|
IProgress<InstallerDeployProgress>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await InstallAsync(package, installPath, OnlineInstallOptions.Default, progress, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InstallAsync(
|
||||||
|
PreparedFilesPackage package,
|
||||||
|
string installPath,
|
||||||
|
OnlineInstallOptions options,
|
||||||
|
IProgress<InstallerDeployProgress>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(package);
|
||||||
|
|
||||||
|
var launcherRoot = InstallerPathGuard.NormalizeInstallPath(installPath);
|
||||||
|
var sourceAppDirectory = ResolveFullPackageAppDirectory(package.ExtractDirectory, package.Version);
|
||||||
|
var targetDeployment = BuildDeploymentDirectory(launcherRoot, package.Version);
|
||||||
|
|
||||||
|
InstallerPathGuard.EnsureUsableInstallPath(launcherRoot, EstimateRequiredBytes(sourceAppDirectory));
|
||||||
|
Directory.CreateDirectory(launcherRoot);
|
||||||
|
await CopyLauncherRootPayloadAsync(package.ExtractDirectory, sourceAppDirectory, launcherRoot, package.Version, progress, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
progress?.Report(new InstallerDeployProgress(
|
||||||
|
"Creating deployment",
|
||||||
|
package.Version,
|
||||||
|
1,
|
||||||
|
0.15,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
null));
|
||||||
|
|
||||||
|
PrepareTargetDirectory(targetDeployment);
|
||||||
|
await CopyDirectoryAsync(sourceAppDirectory, targetDeployment, package.Version, progress, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
progress?.Report(new InstallerDeployProgress(
|
||||||
|
"Activating deployment",
|
||||||
|
package.Version,
|
||||||
|
1,
|
||||||
|
0.92,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
null));
|
||||||
|
|
||||||
|
ActivateInitialDeployment(launcherRoot, targetDeployment);
|
||||||
|
CreateWindowsShortcutsIfAvailable(launcherRoot, options.CreateDesktopShortcut);
|
||||||
|
|
||||||
|
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, bool createDesktopShortcut)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var launcherPath = Path.Combine(launcherRoot, "LanMountainDesktop.Launcher.exe");
|
||||||
|
if (!File.Exists(launcherPath))
|
||||||
|
{
|
||||||
|
var deployedLauncher = Directory
|
||||||
|
.EnumerateFiles(launcherRoot, "LanMountainDesktop.Launcher.exe", SearchOption.AllDirectories)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(deployedLauncher))
|
||||||
|
{
|
||||||
|
File.Copy(deployedLauncher, launcherPath, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(launcherPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startMenu = Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu);
|
||||||
|
if (string.IsNullOrWhiteSpace(startMenu))
|
||||||
|
{
|
||||||
|
startMenu = Environment.GetFolderPath(Environment.SpecialFolder.StartMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(startMenu))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var programs = Path.Combine(startMenu, "Programs");
|
||||||
|
Directory.CreateDirectory(programs);
|
||||||
|
var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url");
|
||||||
|
WriteUrlShortcut(shortcutPath, launcherPath);
|
||||||
|
|
||||||
|
if (!createDesktopShortcut)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
||||||
|
if (string.IsNullOrWhiteSpace(desktop))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(desktop);
|
||||||
|
WriteUrlShortcut(Path.Combine(desktop, "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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LanDesktopPLONDS.Installer.Services;
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(InstallerPlondsManifest))]
|
||||||
|
internal sealed partial class InstallerJsonContext : JsonSerializerContext;
|
||||||
129
LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
Normal file
129
LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
namespace LanDesktopPLONDS.Installer.Services;
|
||||||
|
|
||||||
|
public static class InstallerPathGuard
|
||||||
|
{
|
||||||
|
public static string GetDefaultInstallPath()
|
||||||
|
{
|
||||||
|
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||||
|
if (string.IsNullOrWhiteSpace(programFiles))
|
||||||
|
{
|
||||||
|
programFiles = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Programs");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(programFiles, "LanMountainDesktop");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
357
LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
Normal file
357
LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
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/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");
|
||||||
|
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);
|
||||||
|
|
||||||
|
await DownloadToFileAsync(candidate, zipPath, progress, cancellationToken).ConfigureAwait(false);
|
||||||
|
await VerifyPackageAsync(zipPath, candidate.Manifest, candidate.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
81
LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
Normal file
81
LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
public static OnlineInstallOptions Default { get; } = new(CreateDesktopShortcut: 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 iconKey) : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isUnlocked;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isSelected;
|
||||||
|
|
||||||
|
public InstallerStepId StepId { get; } = stepId;
|
||||||
|
|
||||||
|
public string Title { get; } = title;
|
||||||
|
|
||||||
|
public string IconKey { get; } = iconKey;
|
||||||
|
}
|
||||||
359
LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
Normal file
359
LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
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;
|
||||||
|
private bool _isNavigatingInternally;
|
||||||
|
|
||||||
|
[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))]
|
||||||
|
private bool _isInstalling;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _createDesktopShortcut;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private InstallerStepViewModel? _selectedStep;
|
||||||
|
|
||||||
|
public MainWindowViewModel(
|
||||||
|
IOnlineInstallService installService,
|
||||||
|
IPrivacyDeviceIdentityProvider privacyIdentity,
|
||||||
|
InstallerPrivacyConsentStore? privacyConsentStore = null)
|
||||||
|
{
|
||||||
|
_installService = installService;
|
||||||
|
_privacyIdentity = privacyIdentity;
|
||||||
|
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
|
||||||
|
Steps =
|
||||||
|
[
|
||||||
|
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", "Play"),
|
||||||
|
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", "Folder"),
|
||||||
|
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", "Info"),
|
||||||
|
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", "Apps"),
|
||||||
|
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", "Circle")
|
||||||
|
];
|
||||||
|
SyncSteps();
|
||||||
|
SelectedStep = Steps[0];
|
||||||
|
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 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 OnMaxUnlockedStepChanged(InstallerStepId value)
|
||||||
|
{
|
||||||
|
_ = value;
|
||||||
|
SyncSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedStepChanged(InstallerStepViewModel? value)
|
||||||
|
{
|
||||||
|
if (_isNavigatingInternally || value is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.StepId <= MaxUnlockedStep)
|
||||||
|
{
|
||||||
|
CurrentStep = value.StepId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 (CurrentStep > InstallerStepId.Welcome)
|
||||||
|
{
|
||||||
|
CurrentStep -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task BrowseAsync()
|
||||||
|
{
|
||||||
|
if (BrowseRequested is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected = await BrowseRequested(InstallPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(selected))
|
||||||
|
{
|
||||||
|
InstallPath = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
_isNavigatingInternally = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var step in Steps)
|
||||||
|
{
|
||||||
|
step.IsUnlocked = step.StepId <= MaxUnlockedStep;
|
||||||
|
step.IsSelected = step.StepId == CurrentStep;
|
||||||
|
if (step.StepId == CurrentStep && !ReferenceEquals(SelectedStep, step))
|
||||||
|
{
|
||||||
|
SelectedStep = step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isNavigatingInternally = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
321
LanDesktopPLONDS.installer/Views/MainWindow.axaml
Normal file
321
LanDesktopPLONDS.installer/Views/MainWindow.axaml
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
|
||||||
|
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Width="1080"
|
||||||
|
Height="720"
|
||||||
|
MinWidth="860"
|
||||||
|
MinHeight="620"
|
||||||
|
CanResize="True"
|
||||||
|
Title="{Binding WindowTitle}"
|
||||||
|
Background="Transparent"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaTitleBarHeightHint="48"
|
||||||
|
WindowDecorations="None">
|
||||||
|
<Window.Styles>
|
||||||
|
<Style Selector="Grid.step-page">
|
||||||
|
<Setter Property="IsVisible" Value="False" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Grid.step-page.visible">
|
||||||
|
<Setter Property="IsVisible" Value="True" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.muted">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource InstallerSecondaryTextBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.inline-panel">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
|
||||||
|
<Setter Property="Padding" Value="18" />
|
||||||
|
</Style>
|
||||||
|
</Window.Styles>
|
||||||
|
|
||||||
|
<Grid x:Name="RootGrid"
|
||||||
|
Background="{DynamicResource InstallerWindowBackgroundBrush}"
|
||||||
|
RowDefinitions="48,*">
|
||||||
|
<Border Grid.RowSpan="2"
|
||||||
|
Background="{DynamicResource InstallerTintBrush}"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
|
<Border Grid.Row="0"
|
||||||
|
Background="Transparent"
|
||||||
|
PointerPressed="OnTitleBarPointerPressed">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Margin="12,0,0,0"
|
||||||
|
Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<fi:FluentIcon Icon="ArrowDownload"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="18" />
|
||||||
|
<TextBlock Text="{Binding WindowTitle}"
|
||||||
|
FontSize="12"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="4"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Button Classes="titlebar-icon-button"
|
||||||
|
ToolTip.Tip="最小化"
|
||||||
|
Click="OnMinimizeClick">
|
||||||
|
<fi:FluentIcon Icon="Subtract"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="titlebar-icon-button"
|
||||||
|
ToolTip.Tip="关闭"
|
||||||
|
Click="OnCloseClick">
|
||||||
|
<fi:FluentIcon Icon="Dismiss"
|
||||||
|
IconVariant="Regular"
|
||||||
|
FontSize="14" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ui:FANavigationView x:Name="StepNavigation"
|
||||||
|
Grid.Row="1"
|
||||||
|
PaneDisplayMode="Left"
|
||||||
|
OpenPaneLength="272"
|
||||||
|
IsPaneOpen="True"
|
||||||
|
IsSettingsVisible="False"
|
||||||
|
IsBackButtonVisible="False"
|
||||||
|
IsPaneToggleButtonVisible="False"
|
||||||
|
IsPaneVisible="True"
|
||||||
|
MenuItemsSource="{Binding Steps}"
|
||||||
|
SelectedItem="{Binding SelectedStep, Mode=TwoWay}"
|
||||||
|
Background="Transparent"
|
||||||
|
Margin="0,0,0,0">
|
||||||
|
<ui:FANavigationView.Resources>
|
||||||
|
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="NavigationViewDefaultPaneBackground" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="NavigationViewExpandedPaneBackground" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="NavigationViewPaneBackground" Color="Transparent" />
|
||||||
|
</ui:FANavigationView.Resources>
|
||||||
|
<ui:FANavigationView.MenuItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:InstallerStepViewModel">
|
||||||
|
<ui:FANavigationViewItem Content="{Binding Title}"
|
||||||
|
Tag="{Binding StepId}"
|
||||||
|
IsEnabled="{Binding IsUnlocked}">
|
||||||
|
<ui:FANavigationViewItem.IconSource>
|
||||||
|
<ui:FAFontIconSource Glyph="" />
|
||||||
|
</ui:FANavigationViewItem.IconSource>
|
||||||
|
</ui:FANavigationViewItem>
|
||||||
|
</DataTemplate>
|
||||||
|
</ui:FANavigationView.MenuItemTemplate>
|
||||||
|
|
||||||
|
<Grid Margin="28,4,36,28"
|
||||||
|
RowDefinitions="*,Auto">
|
||||||
|
<Grid Grid.Row="0">
|
||||||
|
<Grid Classes="step-page"
|
||||||
|
IsVisible="{Binding IsWelcomeStep}">
|
||||||
|
<StackPanel Classes="installer-page-container">
|
||||||
|
<TextBlock Classes="page-title-text"
|
||||||
|
Text="安装阑山桌面" />
|
||||||
|
<TextBlock Classes="page-description-text"
|
||||||
|
Text="在线安装程序会从 PLONDS 获取最新完整包,并部署到本机的版本目录结构中。" />
|
||||||
|
<ui:FASettingsExpander Header="准备开始"
|
||||||
|
Description="安装器将检查最新版本、下载 Files 完整包、校验并部署。">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="首版支持 Windows 首次安装。修复和增量更新入口将在后续版本开放。"
|
||||||
|
Classes="muted" />
|
||||||
|
<TextBlock Text="安装完成后将使用 LanMountainDesktop.Launcher 作为统一入口。"
|
||||||
|
Classes="muted" />
|
||||||
|
</StackPanel>
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Classes="step-page"
|
||||||
|
IsVisible="{Binding IsLocationStep}">
|
||||||
|
<StackPanel Classes="installer-page-container">
|
||||||
|
<TextBlock Classes="page-title-text"
|
||||||
|
Text="选择安装位置" />
|
||||||
|
<TextBlock Classes="page-description-text"
|
||||||
|
Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装器保持一致。" />
|
||||||
|
<ui:FASettingsExpander Header="安装目录"
|
||||||
|
Description="安装根目录下会创建 .Launcher 和 app-{version}-0。">
|
||||||
|
<Grid ColumnDefinitions="*,Auto"
|
||||||
|
ColumnSpacing="10">
|
||||||
|
<TextBox Text="{Binding InstallPath, Mode=TwoWay}"
|
||||||
|
PlaceholderText="安装路径" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Command="{Binding BrowseCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="6">
|
||||||
|
<fi:FluentIcon Icon="FolderOpen"
|
||||||
|
IconVariant="Regular" />
|
||||||
|
<TextBlock Text="浏览" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
<ui:FASettingsExpander Header="安装后选项"
|
||||||
|
Description="开始菜单快捷方式会自动创建,桌面快捷方式可选。">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<CheckBox IsChecked="{Binding CreateDesktopShortcut}"
|
||||||
|
Content="创建桌面快捷方式" />
|
||||||
|
</StackPanel>
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Classes="step-page"
|
||||||
|
IsVisible="{Binding IsPrivacyStep}">
|
||||||
|
<StackPanel Classes="installer-page-container">
|
||||||
|
<TextBlock Classes="page-title-text"
|
||||||
|
Text="确认上传数据" />
|
||||||
|
<TextBlock Classes="page-description-text"
|
||||||
|
Text="请确认安装阶段需要使用的匿名数据类别。" />
|
||||||
|
<ui:FASettingsExpander Header="匿名设备码"
|
||||||
|
Description="与后续隐私计算使用同一设备码口径。">
|
||||||
|
<TextBlock Text="{Binding DeviceIdPreview}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
<ui:FASettingsExpander Header="网络与统计"
|
||||||
|
Description="服务端会接收 IP 地址,用于防 DDoS 与统计用户量。">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="muted"
|
||||||
|
Text="安装器会发送匿名设备码、系统与架构信息、目标版本和请求 IP。不会上传用户名、机器名或安装目录。" />
|
||||||
|
<CheckBox IsChecked="{Binding PrivacyConfirmed}"
|
||||||
|
Content="我确认上述匿名数据可用于安装、风控和用户量统计。" />
|
||||||
|
</StackPanel>
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Classes="step-page"
|
||||||
|
IsVisible="{Binding IsDeployStep}">
|
||||||
|
<StackPanel Classes="installer-page-container">
|
||||||
|
<TextBlock Classes="page-title-text"
|
||||||
|
Text="开始部署" />
|
||||||
|
<TextBlock Classes="page-description-text"
|
||||||
|
Text="安装时会下载 Files 完整包并写入当前版本目录。" />
|
||||||
|
<Border Classes="inline-panel">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
RowDefinitions="Auto,Auto,Auto"
|
||||||
|
ColumnSpacing="12"
|
||||||
|
RowSpacing="8">
|
||||||
|
<TextBlock Text="版本" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding TargetVersion}" />
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="来源" />
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="{Binding SourceId}" />
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
Text="状态" />
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
Grid.Column="1"
|
||||||
|
Text="{Binding StatusText}" />
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="下载进度" />
|
||||||
|
<ProgressBar Minimum="0"
|
||||||
|
Maximum="1"
|
||||||
|
Value="{Binding DownloadProgress}" />
|
||||||
|
<TextBlock Classes="muted"
|
||||||
|
Text="{Binding DownloadBytesText}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="安装进度" />
|
||||||
|
<ProgressBar Minimum="0"
|
||||||
|
Maximum="1"
|
||||||
|
Value="{Binding InstallProgress}" />
|
||||||
|
<TextBlock Classes="muted"
|
||||||
|
Text="{Binding CurrentFile}" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="8">
|
||||||
|
<Button Command="{Binding StartInstallCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="6">
|
||||||
|
<fi:FluentIcon Icon="ArrowDownload"
|
||||||
|
IconVariant="Regular" />
|
||||||
|
<TextBlock Text="开始安装" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding CancelInstallCommand}"
|
||||||
|
IsEnabled="{Binding IsInstalling}">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="6">
|
||||||
|
<fi:FluentIcon Icon="Dismiss"
|
||||||
|
IconVariant="Regular" />
|
||||||
|
<TextBlock Text="取消" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Classes="step-page"
|
||||||
|
IsVisible="{Binding IsCompleteStep}">
|
||||||
|
<StackPanel Classes="installer-page-container">
|
||||||
|
<TextBlock Classes="page-title-text"
|
||||||
|
Text="完成安装" />
|
||||||
|
<TextBlock Classes="page-description-text"
|
||||||
|
Text="阑山桌面已经部署完成。" />
|
||||||
|
<ui:FASettingsExpander Header="启动应用"
|
||||||
|
Description="使用 Launcher 进入首次启动流程。">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="如果需要,可以从这里重新启动 LanMountainDesktop.Launcher。"
|
||||||
|
Classes="muted" />
|
||||||
|
<Button Command="{Binding LaunchCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="6">
|
||||||
|
<fi:FluentIcon Icon="Play"
|
||||||
|
IconVariant="Regular" />
|
||||||
|
<TextBlock Text="启动阑山桌面" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</ui:FASettingsExpander>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1"
|
||||||
|
ColumnDefinitions="*,Auto,Auto"
|
||||||
|
ColumnSpacing="8"
|
||||||
|
Margin="0,16,0,0">
|
||||||
|
<TextBlock Text="{Binding ErrorMessage}"
|
||||||
|
Foreground="#C42B1C"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Command="{Binding BackCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="6">
|
||||||
|
<fi:FluentIcon Icon="ArrowLeft"
|
||||||
|
IconVariant="Regular" />
|
||||||
|
<TextBlock Text="上一步" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Command="{Binding NextCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="6">
|
||||||
|
<TextBlock Text="下一步" />
|
||||||
|
<fi:FluentIcon Icon="ArrowRight"
|
||||||
|
IconVariant="Regular" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ui:FANavigationView>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
62
LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs
Normal file
62
LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var startFolder = Directory.Exists(currentPath)
|
||||||
|
? await StorageProvider.TryGetFolderFromPathAsync(currentPath)
|
||||||
|
: null;
|
||||||
|
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "选择安装位置",
|
||||||
|
AllowMultiple = false,
|
||||||
|
SuggestedStartLocation = startFolder
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.Count == 0 ? null : result[0].Path.LocalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
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.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="requireAdministrator" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.Shared.Contracts.Privacy;
|
||||||
|
|
||||||
|
public interface IPrivacyDeviceIdentityProvider
|
||||||
|
{
|
||||||
|
string GetOrCreateDeviceId();
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Shared.Contracts.Privacy;
|
||||||
|
|
||||||
|
public sealed partial class PrivacyDeviceIdentityProvider : IPrivacyDeviceIdentityProvider
|
||||||
|
{
|
||||||
|
public const string DefaultIdentityFileName = "privacy-device.identity.json";
|
||||||
|
|
||||||
|
private readonly string _identityPath;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
public PrivacyDeviceIdentityProvider(string? identityPath = null)
|
||||||
|
{
|
||||||
|
_identityPath = string.IsNullOrWhiteSpace(identityPath)
|
||||||
|
? GetDefaultIdentityPath()
|
||||||
|
: Path.GetFullPath(identityPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetOrCreateDeviceId()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var existing = TryLoad();
|
||||||
|
if (!string.IsNullOrWhiteSpace(existing?.DeviceId))
|
||||||
|
{
|
||||||
|
return existing.DeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new PrivacyDeviceIdentityDocument(
|
||||||
|
SchemaVersion: 1,
|
||||||
|
DeviceId: GenerateDeviceId(),
|
||||||
|
CreatedAtUtc: DateTimeOffset.UtcNow);
|
||||||
|
Save(created);
|
||||||
|
return created.DeviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDefaultIdentityPath()
|
||||||
|
{
|
||||||
|
var root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
if (string.IsNullOrWhiteSpace(root))
|
||||||
|
{
|
||||||
|
root = AppContext.BaseDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(root, "LanMountainDesktop", DefaultIdentityFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PrivacyDeviceIdentityDocument? TryLoad()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_identityPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(_identityPath);
|
||||||
|
return JsonSerializer.Deserialize(
|
||||||
|
json,
|
||||||
|
PrivacyDeviceIdentityJsonContext.Default.PrivacyDeviceIdentityDocument);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save(PrivacyDeviceIdentityDocument document)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(_identityPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempPath = $"{_identityPath}.{Guid.NewGuid():N}.tmp";
|
||||||
|
var json = JsonSerializer.Serialize(
|
||||||
|
document,
|
||||||
|
PrivacyDeviceIdentityJsonContext.Default.PrivacyDeviceIdentityDocument);
|
||||||
|
File.WriteAllText(tempPath, json);
|
||||||
|
File.Move(tempPath, _identityPath, overwrite: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateDeviceId()
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[32];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PrivacyDeviceIdentityDocument(
|
||||||
|
int SchemaVersion,
|
||||||
|
string DeviceId,
|
||||||
|
DateTimeOffset CreatedAtUtc);
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
[JsonSerializable(typeof(PrivacyDeviceIdentityDocument))]
|
||||||
|
private sealed partial class PrivacyDeviceIdentityJsonContext : JsonSerializerContext;
|
||||||
|
}
|
||||||
@@ -20,5 +20,6 @@
|
|||||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
|
||||||
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
|
||||||
|
<ProjectReference Include="..\LanDesktopPLONDS.installer\LanDesktopPLONDS.installer.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
208
LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
Normal file
208
LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using LanDesktopPLONDS.Installer.Models;
|
||||||
|
using LanDesktopPLONDS.Installer.Services;
|
||||||
|
using LanDesktopPLONDS.Installer.ViewModels;
|
||||||
|
using LanMountainDesktop.Shared.Contracts.Privacy;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class OnlineInstallerCoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempRoot = Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"TestArtifacts",
|
||||||
|
"LanMountainDesktop.Tests",
|
||||||
|
nameof(OnlineInstallerCoreTests),
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_tempRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(_tempRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrivacyDeviceIdentityProvider_ReturnsStableAnonymousId()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_tempRoot, "identity.json");
|
||||||
|
var first = new PrivacyDeviceIdentityProvider(path).GetOrCreateDeviceId();
|
||||||
|
var second = new PrivacyDeviceIdentityProvider(path).GetOrCreateDeviceId();
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(first));
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
Assert.DoesNotContain(Environment.MachineName, first, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain(Environment.UserName, first, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstallerPrivacyConsentStore_PersistsConfirmationForDeviceId()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_tempRoot, "privacy-consent.json");
|
||||||
|
var store = new InstallerPrivacyConsentStore(path);
|
||||||
|
|
||||||
|
Assert.False(store.HasConfirmed("device-a"));
|
||||||
|
|
||||||
|
store.SaveConfirmed("device-a");
|
||||||
|
|
||||||
|
Assert.True(new InstallerPrivacyConsentStore(path).HasConfirmed("device-a"));
|
||||||
|
Assert.False(new InstallerPrivacyConsentStore(path).HasConfirmed("device-b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InstallerWorkflowNavigation_AllowsOnlyUnlockedSteps()
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel(new FakeInstallService(), new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")));
|
||||||
|
|
||||||
|
vm.SelectedStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Deploy);
|
||||||
|
|
||||||
|
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
|
||||||
|
|
||||||
|
await vm.NextCommand.ExecuteAsync(null);
|
||||||
|
vm.SelectedStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome);
|
||||||
|
|
||||||
|
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FilesZipUrlResolver_PrefersSourceSpecificThenDerivedThenFallbacks()
|
||||||
|
{
|
||||||
|
var manifest = CreateManifest(
|
||||||
|
downloads: new InstallerPlondsDownloads(
|
||||||
|
new InstallerPlondsGitHubDownloads(null, null, null, "https://github.test/Files.zip"),
|
||||||
|
new InstallerPlondsS3Downloads(null, null, null, null, null, null, null, null, null, "https://s3.test/Files.zip", null, null)));
|
||||||
|
|
||||||
|
var urls = InstallerPlondsUrlResolver.ResolveFilesZipUrls(
|
||||||
|
manifest,
|
||||||
|
new InstallerPlondsSource("s3", "s3", "https://origin.test/releases/PLONDS.json", 100));
|
||||||
|
|
||||||
|
Assert.Equal("https://s3.test/Files.zip", urls[0].AbsoluteUri);
|
||||||
|
Assert.Contains(urls, uri => uri.AbsoluteUri == "https://origin.test/releases/Files.zip");
|
||||||
|
Assert.Contains(urls, uri => uri.AbsoluteUri == "https://github.test/Files.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("C:\\")]
|
||||||
|
[InlineData("C:\\Windows")]
|
||||||
|
public void InstallerPathGuard_RejectsDangerousPaths(string path)
|
||||||
|
{
|
||||||
|
Assert.ThrowsAny<Exception>(() => InstallerPathGuard.NormalizeInstallPath(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FilesPackageInstaller_DeploysFullPackageWithCurrentMarker()
|
||||||
|
{
|
||||||
|
var packageRoot = Path.Combine(_tempRoot, "Files");
|
||||||
|
var appRoot = Path.Combine(packageRoot, "app-1.2.3");
|
||||||
|
Directory.CreateDirectory(appRoot);
|
||||||
|
File.WriteAllText(Path.Combine(packageRoot, "LanMountainDesktop.Launcher.exe"), "launcher");
|
||||||
|
File.WriteAllText(Path.Combine(appRoot, "LanMountainDesktop.exe"), "host");
|
||||||
|
File.WriteAllText(Path.Combine(appRoot, ".partial"), "old marker");
|
||||||
|
var package = new PreparedFilesPackage(
|
||||||
|
"1.2.3",
|
||||||
|
"s3",
|
||||||
|
Path.Combine(_tempRoot, "Files.zip"),
|
||||||
|
packageRoot,
|
||||||
|
CreateManifest());
|
||||||
|
var target = Path.Combine(_tempRoot, "install", "LanMountainDesktop");
|
||||||
|
|
||||||
|
await new FilesPackageInstaller().InstallAsync(package, target, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var deployment = Directory.GetDirectories(target, "app-1.2.3-0").Single();
|
||||||
|
Assert.True(File.Exists(Path.Combine(target, "LanMountainDesktop.Launcher.exe")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(deployment, "LanMountainDesktop.exe")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(deployment, ".current")));
|
||||||
|
Assert.False(File.Exists(Path.Combine(deployment, ".partial")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ZipExtraction_RejectsEscapingEntry()
|
||||||
|
{
|
||||||
|
var zipPath = Path.Combine(_tempRoot, "bad.zip");
|
||||||
|
Directory.CreateDirectory(_tempRoot);
|
||||||
|
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
|
||||||
|
{
|
||||||
|
var entry = archive.CreateEntry("../escape.txt");
|
||||||
|
using var writer = new StreamWriter(entry.Open());
|
||||||
|
writer.Write("bad");
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = CreateManifest(checksums: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Files.zip"] = "sha256:" + Sha256(zipPath)
|
||||||
|
});
|
||||||
|
var client = new InstallerPlondsClient(new HttpClient(new FileHandler(zipPath)), Path.Combine(_tempRoot, "staging"));
|
||||||
|
var candidate = new InstallerPlondsCandidate(
|
||||||
|
new InstallerPlondsSource("s3", "s3", "https://s3.test/PLONDS.json", 100),
|
||||||
|
manifest,
|
||||||
|
new Uri("https://s3.test/Files.zip"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidDataException>(() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InstallerPlondsManifest CreateManifest(
|
||||||
|
InstallerPlondsDownloads? downloads = null,
|
||||||
|
IReadOnlyDictionary<string, string>? checksums = null)
|
||||||
|
{
|
||||||
|
return new InstallerPlondsManifest(
|
||||||
|
"1",
|
||||||
|
"1.2.3",
|
||||||
|
"1.2.2",
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
"stable",
|
||||||
|
"windows-x64",
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
new Dictionary<string, InstallerPlondsFileEntry>(),
|
||||||
|
new Dictionary<string, InstallerPlondsChangedFileEntry>(),
|
||||||
|
checksums ?? new Dictionary<string, string>(),
|
||||||
|
downloads,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sha256(string filePath)
|
||||||
|
{
|
||||||
|
using var sha = SHA256.Create();
|
||||||
|
using var stream = File.OpenRead(filePath);
|
||||||
|
return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeInstallService : IOnlineInstallService
|
||||||
|
{
|
||||||
|
public Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(new OnlineInstallPackageInfo("1.2.3", "test", new Uri("https://test/Files.zip"), 1));
|
||||||
|
|
||||||
|
public Task InstallFreshAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task InstallFreshAsync(
|
||||||
|
string installPath,
|
||||||
|
OnlineInstallOptions options,
|
||||||
|
IProgress<InstallerDeployProgress>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task RepairAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task UpdateIncrementalAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FileHandler(string zipPath) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(File.ReadAllBytes(zipPath))
|
||||||
|
};
|
||||||
|
response.Content.Headers.ContentLength = new FileInfo(zipPath).Length;
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
|
||||||
|
<Project Path="LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj" />
|
||||||
<Project Path="ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj" />
|
<Project Path="ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj" />
|
||||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ param(
|
|||||||
[string]$Version = "",
|
[string]$Version = "",
|
||||||
[string]$PublishDir = "",
|
[string]$PublishDir = "",
|
||||||
[string]$InstallerOutputDir = "",
|
[string]$InstallerOutputDir = "",
|
||||||
|
[string]$OnlineInstallerOutputDir = "",
|
||||||
[string]$ArchiveOutputDir = "",
|
[string]$ArchiveOutputDir = "",
|
||||||
[string]$InnoScript = "",
|
[string]$InnoScript = "",
|
||||||
[string]$InnoCompiler = "",
|
[string]$InnoCompiler = "",
|
||||||
[switch]$SkipInstaller,
|
[switch]$SkipInstaller,
|
||||||
|
[switch]$SkipOnlineInstaller,
|
||||||
[switch]$SkipArchive,
|
[switch]$SkipArchive,
|
||||||
[switch]$KeepSymbols
|
[switch]$KeepSymbols
|
||||||
)
|
)
|
||||||
@@ -428,6 +430,35 @@ if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) {
|
|||||||
}
|
}
|
||||||
[System.IO.Directory]::CreateDirectory($InstallerOutputDir) | Out-Null
|
[System.IO.Directory]::CreateDirectory($InstallerOutputDir) | Out-Null
|
||||||
|
|
||||||
|
if (-not $SkipOnlineInstaller) {
|
||||||
|
if (-not $OnlineInstallerOutputDir) {
|
||||||
|
$OnlineInstallerOutputDir = Join-Path $repoRoot "artifacts/installer-online/$RuntimeIdentifier"
|
||||||
|
}
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($OnlineInstallerOutputDir)) {
|
||||||
|
$OnlineInstallerOutputDir = Join-Path $repoRoot $OnlineInstallerOutputDir
|
||||||
|
}
|
||||||
|
Clear-DirectoryContents -TargetDirectory $OnlineInstallerOutputDir
|
||||||
|
|
||||||
|
$onlineInstallerProject = Join-Path $repoRoot "LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj"
|
||||||
|
Write-Host "Publishing PLONDS online installer..."
|
||||||
|
$onlineInstallerArgs = @(
|
||||||
|
"publish",
|
||||||
|
$onlineInstallerProject,
|
||||||
|
"-c", $Configuration,
|
||||||
|
"-r", $RuntimeIdentifier,
|
||||||
|
"-p:Version=$Version",
|
||||||
|
"-p:PublishAot=true",
|
||||||
|
"-o", $OnlineInstallerOutputDir
|
||||||
|
)
|
||||||
|
|
||||||
|
& dotnet @onlineInstallerArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Online installer publish failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Online installer published: $OnlineInstallerOutputDir"
|
||||||
|
}
|
||||||
|
|
||||||
if ($SkipInstaller) {
|
if ($SkipInstaller) {
|
||||||
Write-Host "Publish completed. Installer step skipped."
|
Write-Host "Publish completed. Installer step skipped."
|
||||||
Write-Host "Published files: $PublishDir"
|
Write-Host "Published files: $PublishDir"
|
||||||
|
|||||||
Reference in New Issue
Block a user