diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 84b0f16..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -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. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..92aaa02 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8452d2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config_issue.md b/.github/ISSUE_TEMPLATE/config_issue.md deleted file mode 100644 index d7b2cd8..0000000 --- a/.github/ISSUE_TEMPLATE/config_issue.md +++ /dev/null @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config_issue.yml b/.github/ISSUE_TEMPLATE/config_issue.yml new file mode 100644 index 0000000..cd6eb67 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config_issue.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 54dd89b..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..3a2e93e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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//` + - 是否影响已有插件或用户配置 + - 是否仅适用于 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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6d97a18..f61ac59 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,34 +1,92 @@ -## Description -Please include a summary of the changes and related context. Describe the "why" behind your changes. + -## Related Issues -Fixes #(issue number) +## 这个 PR 做了什么? / What does this PR do? -## Testing -Please describe the testing you've done to verify the changes: -- [ ] Built successfully -- [ ] Tested on Windows -- [ ] No new warnings or errors introduced -- [ ] Backward compatible + -## Screenshots/Videos (if applicable) -If your changes include UI modifications, please attach screenshots or videos. +## 相关 Issue / Related issues -## Checklist -- [ ] My code follows the project's style guidelines -- [ ] I have performed a self-review of my own code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] I have made corresponding changes to the documentation -- [ ] My changes generate no new warnings -- [ ] I have tested my changes thoroughly -- [ ] New and existing unit tests pass locally with my changes -- [ ] I have added tests that prove my fix is effective or that my feature works + + +## 影响范围 / Affected areas + + + +- [ ] 桌面宿主 / 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 + + + +## 验证 / Verification + + + +- [ ] `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 + + + +- [ ] 本 PR 不需要更新文档或 `.trae/specs/` / No documentation or `.trae/specs/` update is needed +- [ ] 已更新权威文档 / Updated source-of-truth documentation +- [ ] 已新增或更新 `.trae/specs//` / Added or updated `.trae/specs//` +- [ ] 已更新 SDK 迁移说明或共享契约说明 / Updated SDK migration or shared contract notes + +## UI 截图或录屏 / UI screenshots or videos + + + +## 最终检查 / 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. diff --git a/LanDesktopPLONDS.installer/App.axaml b/LanDesktopPLONDS.installer/App.axaml new file mode 100644 index 0000000..0154504 --- /dev/null +++ b/LanDesktopPLONDS.installer/App.axaml @@ -0,0 +1,59 @@ + + + Inter, Segoe UI, Microsoft YaHei UI + 2 + 4 + 6 + 8 + 10 + 12 + 12 + + + + + + + + + + + + + + + + + + diff --git a/LanDesktopPLONDS.installer/App.axaml.cs b/LanDesktopPLONDS.installer/App.axaml.cs new file mode 100644 index 0000000..f0ce791 --- /dev/null +++ b/LanDesktopPLONDS.installer/App.axaml.cs @@ -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(); + } +} diff --git a/LanDesktopPLONDS.installer/Assets/logo.ico b/LanDesktopPLONDS.installer/Assets/logo.ico new file mode 100644 index 0000000..aa1524d Binary files /dev/null and b/LanDesktopPLONDS.installer/Assets/logo.ico differ diff --git a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.AOT.props b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.AOT.props new file mode 100644 index 0000000..6fc6a74 --- /dev/null +++ b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.AOT.props @@ -0,0 +1,34 @@ + + + true + true + partial + true + true + true + true + Size + false + + + + true + true + + + + + + + + + + + + + false + false + true + true + + diff --git a/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj new file mode 100644 index 0000000..2c756bb --- /dev/null +++ b/LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj @@ -0,0 +1,33 @@ + + + + + WinExe + net10.0 + enable + enable + 0.0.0-dev + $(Version) + true + Assets\logo.ico + app.manifest + + + + + + + + + + + + + + + + + + + + diff --git a/LanDesktopPLONDS.installer/Models/InstallerDeployProgress.cs b/LanDesktopPLONDS.installer/Models/InstallerDeployProgress.cs new file mode 100644 index 0000000..3576b6f --- /dev/null +++ b/LanDesktopPLONDS.installer/Models/InstallerDeployProgress.cs @@ -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); diff --git a/LanDesktopPLONDS.installer/Models/InstallerStepId.cs b/LanDesktopPLONDS.installer/Models/InstallerStepId.cs new file mode 100644 index 0000000..a9072a2 --- /dev/null +++ b/LanDesktopPLONDS.installer/Models/InstallerStepId.cs @@ -0,0 +1,10 @@ +namespace LanDesktopPLONDS.Installer.Models; + +public enum InstallerStepId +{ + Welcome = 0, + InstallLocation = 1, + PrivacyConfirm = 2, + Deploy = 3, + Complete = 4 +} diff --git a/LanDesktopPLONDS.installer/Models/InstallerWorkflowState.cs b/LanDesktopPLONDS.installer/Models/InstallerWorkflowState.cs new file mode 100644 index 0000000..a5a89ca --- /dev/null +++ b/LanDesktopPLONDS.installer/Models/InstallerWorkflowState.cs @@ -0,0 +1,9 @@ +namespace LanDesktopPLONDS.Installer.Models; + +public sealed record InstallerWorkflowState( + InstallerStepId CurrentStep, + InstallerStepId MaxUnlockedStep, + string InstallPath, + bool PrivacyConfirmed, + string? TargetVersion, + string? ErrorMessage); diff --git a/LanDesktopPLONDS.installer/Program.cs b/LanDesktopPLONDS.installer/Program.cs new file mode 100644 index 0000000..a976aa7 --- /dev/null +++ b/LanDesktopPLONDS.installer/Program.cs @@ -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() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + } +} diff --git a/LanDesktopPLONDS.installer/Properties/AssemblyInfo.cs b/LanDesktopPLONDS.installer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..944b2df --- /dev/null +++ b/LanDesktopPLONDS.installer/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")] diff --git a/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs b/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs new file mode 100644 index 0000000..3dec9c6 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs @@ -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? progress, + CancellationToken cancellationToken) + { + await InstallAsync(package, installPath, OnlineInstallOptions.Default, progress, cancellationToken) + .ConfigureAwait(false); + } + + public async Task InstallAsync( + PreparedFilesPackage package, + string installPath, + OnlineInstallOptions options, + IProgress? 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? 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? 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}"); + } +} diff --git a/LanDesktopPLONDS.installer/Services/IOnlineInstallService.cs b/LanDesktopPLONDS.installer/Services/IOnlineInstallService.cs new file mode 100644 index 0000000..288d6b0 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/IOnlineInstallService.cs @@ -0,0 +1,29 @@ +using LanDesktopPLONDS.Installer.Models; + +namespace LanDesktopPLONDS.Installer.Services; + +public interface IOnlineInstallService +{ + Task CheckLatestAsync(CancellationToken cancellationToken); + + Task InstallFreshAsync( + string installPath, + IProgress? progress, + CancellationToken cancellationToken); + + Task InstallFreshAsync( + string installPath, + OnlineInstallOptions options, + IProgress? progress, + CancellationToken cancellationToken); + + Task RepairAsync( + string installPath, + IProgress? progress, + CancellationToken cancellationToken); + + Task UpdateIncrementalAsync( + string installPath, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs b/LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs new file mode 100644 index 0000000..8beaef4 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstallerJsonContext.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Serialization; + +namespace LanDesktopPLONDS.Installer.Services; + +[JsonSerializable(typeof(InstallerPlondsManifest))] +internal sealed partial class InstallerJsonContext : JsonSerializerContext; diff --git a/LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs b/LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs new file mode 100644 index 0000000..5763e48 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs @@ -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(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; + } +} diff --git a/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs b/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs new file mode 100644 index 0000000..79175e8 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstallerPlondsClient.cs @@ -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 CreateBuiltInSources() + { + return + [ + new("s3", "s3", ResolveManifestUrl(S3ManifestUrlEnvironmentVariable, DefaultS3ManifestUrl), 100), + new("github", "github", ResolveManifestUrl(GitHubManifestUrlEnvironmentVariable, DefaultGitHubManifestUrl), 50) + ]; + } + + public async Task FindLatestAsync(CancellationToken cancellationToken) + { + var sources = CreateBuiltInSources().ToList(); + var candidates = new List(); + + 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 DownloadAndPrepareFullPackageAsync( + InstallerPlondsCandidate candidate, + IProgress? 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 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? 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 sources, IEnumerable? 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 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? checksums, IEnumerable 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 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? 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; + } +} diff --git a/LanDesktopPLONDS.installer/Services/InstallerPlondsUrlResolver.cs b/LanDesktopPLONDS.installer/Services/InstallerPlondsUrlResolver.cs new file mode 100644 index 0000000..dd1fcf5 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstallerPlondsUrlResolver.cs @@ -0,0 +1,51 @@ +namespace LanDesktopPLONDS.Installer.Services; + +internal static class InstallerPlondsUrlResolver +{ + public static IReadOnlyList ResolveFilesZipUrls( + InstallerPlondsManifest manifest, + InstallerPlondsSource source) + { + var urls = new List(); + 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() + .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; + } +} diff --git a/LanDesktopPLONDS.installer/Services/InstallerPrivacyConsentStore.cs b/LanDesktopPLONDS.installer/Services/InstallerPrivacyConsentStore.cs new file mode 100644 index 0000000..2cb7bad --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/InstallerPrivacyConsentStore.cs @@ -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 Categories); + + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(InstallerPrivacyConsentDocument))] + private sealed partial class InstallerPrivacyConsentJsonContext : JsonSerializerContext; +} diff --git a/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs b/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs new file mode 100644 index 0000000..accec4b --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/OnlineInstallService.cs @@ -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 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? progress, + CancellationToken cancellationToken) + { + await InstallFreshAsync(installPath, OnlineInstallOptions.Default, progress, cancellationToken) + .ConfigureAwait(false); + } + + public async Task InstallFreshAsync( + string installPath, + OnlineInstallOptions options, + IProgress? 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? progress, + CancellationToken cancellationToken) + { + _ = installPath; + _ = progress; + _ = cancellationToken; + throw new NotSupportedException("Repair is reserved for a later installer version."); + } + + public Task UpdateIncrementalAsync( + string installPath, + IProgress? progress, + CancellationToken cancellationToken) + { + _ = installPath; + _ = progress; + _ = cancellationToken; + throw new NotSupportedException("Incremental update is reserved for a later installer version."); + } +} diff --git a/LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs b/LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs new file mode 100644 index 0000000..afe5d75 --- /dev/null +++ b/LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs @@ -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 FilesMap, + IReadOnlyDictionary ChangedFilesMap, + IReadOnlyDictionary Checksums, + InstallerPlondsDownloads? Downloads, + IReadOnlyList? 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); diff --git a/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs b/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs new file mode 100644 index 0000000..c4f479f --- /dev/null +++ b/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs @@ -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; +} diff --git a/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs b/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..1b66f3d --- /dev/null +++ b/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs @@ -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 Steps { get; } + + public Func>? 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(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]}"; + } +} diff --git a/LanDesktopPLONDS.installer/Views/MainWindow.axaml b/LanDesktopPLONDS.installer/Views/MainWindow.axaml new file mode 100644 index 0000000..ee90e8e --- /dev/null +++ b/LanDesktopPLONDS.installer/Views/MainWindow.axaml @@ -0,0 +1,321 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs b/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..542ae96 --- /dev/null +++ b/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs @@ -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 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(); + } +} diff --git a/LanDesktopPLONDS.installer/app.manifest b/LanDesktopPLONDS.installer/app.manifest new file mode 100644 index 0000000..46efea9 --- /dev/null +++ b/LanDesktopPLONDS.installer/app.manifest @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.Shared.Contracts/Privacy/IPrivacyDeviceIdentityProvider.cs b/LanMountainDesktop.Shared.Contracts/Privacy/IPrivacyDeviceIdentityProvider.cs new file mode 100644 index 0000000..861ab6f --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Privacy/IPrivacyDeviceIdentityProvider.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.Shared.Contracts.Privacy; + +public interface IPrivacyDeviceIdentityProvider +{ + string GetOrCreateDeviceId(); +} diff --git a/LanMountainDesktop.Shared.Contracts/Privacy/PrivacyDeviceIdentityProvider.cs b/LanMountainDesktop.Shared.Contracts/Privacy/PrivacyDeviceIdentityProvider.cs new file mode 100644 index 0000000..29d445c --- /dev/null +++ b/LanMountainDesktop.Shared.Contracts/Privacy/PrivacyDeviceIdentityProvider.cs @@ -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 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; +} diff --git a/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj index 635f2bb..67a5135 100644 --- a/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj +++ b/LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj @@ -20,5 +20,6 @@ + diff --git a/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs new file mode 100644 index 0000000..3504637 --- /dev/null +++ b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs @@ -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(() => 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 + { + ["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(() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None)); + } + + private static InstallerPlondsManifest CreateManifest( + InstallerPlondsDownloads? downloads = null, + IReadOnlyDictionary? checksums = null) + { + return new InstallerPlondsManifest( + "1", + "1.2.3", + "1.2.2", + true, + false, + "stable", + "windows-x64", + DateTimeOffset.UtcNow, + new Dictionary(), + new Dictionary(), + checksums ?? new Dictionary(), + 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 CheckLatestAsync(CancellationToken cancellationToken) + => Task.FromResult(new OnlineInstallPackageInfo("1.2.3", "test", new Uri("https://test/Files.zip"), 1)); + + public Task InstallFreshAsync(string installPath, IProgress? progress, CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task InstallFreshAsync( + string installPath, + OnlineInstallOptions options, + IProgress? progress, + CancellationToken cancellationToken) + => Task.CompletedTask; + + public Task RepairAsync(string installPath, IProgress? progress, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task UpdateIncrementalAsync(string installPath, IProgress? progress, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } + + private sealed class FileHandler(string zipPath) : HttpMessageHandler + { + protected override Task 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); + } + } +} diff --git a/LanMountainDesktop.slnx b/LanMountainDesktop.slnx index 127aea5..f143eac 100644 --- a/LanMountainDesktop.slnx +++ b/LanMountainDesktop.slnx @@ -14,6 +14,7 @@ + diff --git a/LanMountainDesktop/scripts/package.ps1 b/LanMountainDesktop/scripts/package.ps1 index 8905f2d..c0adbcc 100644 --- a/LanMountainDesktop/scripts/package.ps1 +++ b/LanMountainDesktop/scripts/package.ps1 @@ -6,10 +6,12 @@ param( [string]$Version = "", [string]$PublishDir = "", [string]$InstallerOutputDir = "", + [string]$OnlineInstallerOutputDir = "", [string]$ArchiveOutputDir = "", [string]$InnoScript = "", [string]$InnoCompiler = "", [switch]$SkipInstaller, + [switch]$SkipOnlineInstaller, [switch]$SkipArchive, [switch]$KeepSymbols ) @@ -428,6 +430,35 @@ if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) { } [System.IO.Directory]::CreateDirectory($InstallerOutputDir) | Out-Null + if (-not $SkipOnlineInstaller) { + if (-not $OnlineInstallerOutputDir) { + $OnlineInstallerOutputDir = Join-Path $repoRoot "artifacts/installer-online/$RuntimeIdentifier" + } + if (-not [System.IO.Path]::IsPathRooted($OnlineInstallerOutputDir)) { + $OnlineInstallerOutputDir = Join-Path $repoRoot $OnlineInstallerOutputDir + } + Clear-DirectoryContents -TargetDirectory $OnlineInstallerOutputDir + + $onlineInstallerProject = Join-Path $repoRoot "LanDesktopPLONDS.installer/LanDesktopPLONDS.installer.csproj" + Write-Host "Publishing PLONDS online installer..." + $onlineInstallerArgs = @( + "publish", + $onlineInstallerProject, + "-c", $Configuration, + "-r", $RuntimeIdentifier, + "-p:Version=$Version", + "-p:PublishAot=true", + "-o", $OnlineInstallerOutputDir + ) + + & dotnet @onlineInstallerArgs + if ($LASTEXITCODE -ne 0) { + throw "Online installer publish failed with exit code $LASTEXITCODE." + } + + Write-Host "Online installer published: $OnlineInstallerOutputDir" + } + if ($SkipInstaller) { Write-Host "Publish completed. Installer step skipped." Write-Host "Published files: $PublishDir"