From 6124d6060cde3c3b5c65fe9b7f82188ad1dfb665 Mon Sep 17 00:00:00 2001 From: jianglao Date: Sat, 27 Jun 2026 18:03:44 +0800 Subject: [PATCH] Initial commit: R0Installer source, patch generator, API backend and build scripts Co-authored-by: Cursor --- .gitignore | 28 + API_SPECIFICATION.md | 918 +++++++++++ R0Installer/ConfigManager.cs | 577 +++++++ R0Installer/MainForm.cs | 1161 ++++++++++++++ R0Installer/Program.cs | 171 ++ R0Installer/Properties/AssemblyInfo.cs | 16 + R0Installer/Properties/Resources.Designer.cs | 39 + R0Installer/Properties/Resources.resx | 43 + R0Installer/R0Installer.csproj | 58 + R0Installer/R0Installer.sln | 23 + R0Installer/SilentUpdater.cs | 232 +++ R0Installer/UpdateForm.cs | 492 ++++++ R0Installer/UpdateManager.cs | 944 +++++++++++ R0Installer/app.manifest | 25 + R0PatchGenerator/PatchGenerator.cs | 368 +++++ R0PatchGenerator/Program.cs | 109 ++ R0PatchGenerator/Properties/AssemblyInfo.cs | 19 + R0PatchGenerator/R0PatchGenerator.csproj | 42 + api/.htaccess | 7 + api/Medoo.php | 21 + api/admin/index.php | 743 +++++++++ api/composer.json | 8 + api/config.php.example | 16 + api/database.sql | 158 ++ api/db.php | 21 + api/helpers.php | 25 + api/index.php | 286 ++++ build.bat | 93 ++ build.ps1 | 102 ++ generate_patch.bat | 161 ++ icon.ico | Bin 0 -> 16958 bytes start.html | 1466 ++++++++++++++++++ 更新打包流程.txt | 17 + 33 files changed, 8389 insertions(+) create mode 100644 .gitignore create mode 100644 API_SPECIFICATION.md create mode 100644 R0Installer/ConfigManager.cs create mode 100644 R0Installer/MainForm.cs create mode 100644 R0Installer/Program.cs create mode 100644 R0Installer/Properties/AssemblyInfo.cs create mode 100644 R0Installer/Properties/Resources.Designer.cs create mode 100644 R0Installer/Properties/Resources.resx create mode 100644 R0Installer/R0Installer.csproj create mode 100644 R0Installer/R0Installer.sln create mode 100644 R0Installer/SilentUpdater.cs create mode 100644 R0Installer/UpdateForm.cs create mode 100644 R0Installer/UpdateManager.cs create mode 100644 R0Installer/app.manifest create mode 100644 R0PatchGenerator/PatchGenerator.cs create mode 100644 R0PatchGenerator/Program.cs create mode 100644 R0PatchGenerator/Properties/AssemblyInfo.cs create mode 100644 R0PatchGenerator/R0PatchGenerator.csproj create mode 100644 api/.htaccess create mode 100644 api/Medoo.php create mode 100644 api/admin/index.php create mode 100644 api/composer.json create mode 100644 api/config.php.example create mode 100644 api/database.sql create mode 100644 api/db.php create mode 100644 api/helpers.php create mode 100644 api/index.php create mode 100644 build.bat create mode 100644 build.ps1 create mode 100644 generate_patch.bat create mode 100644 icon.ico create mode 100644 start.html create mode 100644 更新打包流程.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..959f1fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# ---- Secrets / local config ---- +api/config.php + +# ---- Large binary app builds & patches (kept out of git) ---- +versions/ +ac_versions/ +atlas/ +maizhama/ +buy/ +bak/ +vmp/ + +# ---- Compiled binaries / archives ---- +*.exe +*.zip +*.dll +*.pdb + +# ---- .NET build output ---- +**/bin/ +**/obj/ + +# ---- Editor / OS ---- +.vs/ +.idea/ +*.user +Thumbs.db +desktop.ini diff --git a/API_SPECIFICATION.md b/API_SPECIFICATION.md new file mode 100644 index 0000000..3c966fa --- /dev/null +++ b/API_SPECIFICATION.md @@ -0,0 +1,918 @@ +# R0Installer 云端配置 API 规范文档 + +## 概述 + +为了支持越来越多的应用接入,R0Installer 将从硬编码配置迁移到云端 API 驱动。改造后,安装器/更新器的所有项目参数、命令映射、文件名检测规则都从云端获取,无需更新程序即可新增/修改需要管理的应用。 + +--- + +## 一、总体架构 + +``` +┌──────────────┐ ┌───────────────────┐ ┌──────────────────┐ +│ R0Installer │──────>│ 云端 Config API │<──────│ 管理后台 │ +│ (客户端) │ │ (你实现的后端) │ │ (配置管理) │ +└──────────────┘ └───────────────────┘ └──────────────────┘ +``` + +客户端启动后首先调用 **项目配置 API** 获取当前项目的所有动态参数,然后再执行安装/更新流程。 + +--- + +## 二、API 清单 + + +| # | API | 方法 | 用途 | +| --- | --------------------------------------------------- | --- | ---------------------- | +| 1 | `/v3/config/projects` | GET | 获取所有可用项目列表 | +| 2 | `/v3/config/project/{project_id}` | GET | 获取单个项目的完整配置 | +| 3 | `/v3/config/installer` | GET | 获取安装器自身的全局配置(文件名检测规则等) | +| 4 | `/v3/version/index?project={project_id}` | GET | 获取指定项目的最新版本信息(安装用) | +| 5 | `/v3/update/check?project={project_id}&v={version}` | GET | 检查指定项目是否有更新(更新用) | + + +> **Base URL 建议**: `https://a-p-i.r0csgo.com` +> **所有请求携带 Header**: `User-Agent: r0_installer` + +--- + +## 三、API 详细规范 + +### API 1: 获取所有可用项目列表 + +**用途**: 安装器启动时获取所有可管理的项目列表,用于 UI 展示或自动匹配。 + +``` +GET /v3/config/projects +``` + +**请求参数**: 无 + +**响应示例**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "projects": [ + { + "project_id": "r0_arena", + "display_name": "R0对战平台", + "description": "R0对战平台客户端", + "icon_url": "https://cdn.r0csgo.com/icons/r0_arena.ico", + "enabled": true + }, + { + "project_id": "r0_guard", + "display_name": "R0反作弊", + "description": "R0反作弊系统", + "icon_url": "https://cdn.r0csgo.com/icons/r0_guard.ico", + "enabled": true + }, + { + "project_id": "r0_launcher", + "display_name": "R0启动器", + "description": "R0游戏启动器", + "icon_url": "https://cdn.r0csgo.com/icons/r0_launcher.ico", + "enabled": true + } + ] + } +} +``` + +**字段说明**: + + +| 字段 | 类型 | 必填 | 说明 | +| ------------------------- | ------ | --- | --------------------- | +| `projects[].project_id` | string | 是 | 项目唯一标识符,用于后续所有 API 调用 | +| `projects[].display_name` | string | 是 | 项目显示名称(用于 UI) | +| `projects[].description` | string | 否 | 项目描述 | +| `projects[].icon_url` | string | 否 | 项目图标 URL | +| `projects[].enabled` | bool | 是 | 是否启用(false 时客户端跳过该项目) | + + +--- + +### API 2: 获取单个项目的完整配置 + +**用途**: 获取某个项目的所有安装/更新参数配置,这是 **核心 API**,包含原来所有硬编码的参数。 + +``` +GET /v3/config/project/{project_id} +``` + +**请求参数**: + + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| ------------ | ---- | ------ | --- | ----------------- | +| `project_id` | path | string | 是 | 项目ID,如 `r0_arena` | + + +**响应示例(主客户端项目 `r0_arena`)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "project_id": "r0_arena", + "display_name": "R0对战平台", + + "install": { + "window_title": "R0对战平台安装程序", + "steps": [ + { + "step_id": "runtime", + "display_name": "环境检查", + "type": "runtime_check", + "enabled": true + }, + { + "step_id": "anticheat", + "display_name": "反作弊安装", + "type": "download_and_run", + "enabled": true, + "download_url_field": "download_url_ac_cdn", + "temp_filename": "r0_guard.exe", + "installer_args": "", + "wait_for_exit": true + }, + { + "step_id": "main", + "display_name": "主程序安装", + "type": "download_and_run", + "enabled": true, + "download_url_field": "download_url_cdn", + "temp_filename_template": "r0_arena_v{version}.exe", + "installer_args": "", + "wait_for_exit": false + } + ] + }, + + "update": { + "window_title": "R0对战平台更新程序", + "command": "update", + "check_api": "/v3/update/check?project=r0_arena&v={0}", + "processes_to_close": ["r0_arena", "R0对战平台"], + "launch_after_update": "R0对战平台.exe", + "temp_filename_template": "r0_arena_v{version}.exe", + "hold_file": null, + "supports_incremental": true + }, + + "update_ac": { + "window_title": "R0反作弊更新程序", + "command": "update_ac", + "check_api": "/v3/update/check?project=r0_guard&v={0}", + "processes_to_close": ["R0Guard"], + "launch_after_update": "R0Guard.exe", + "temp_filename_template": "r0_guard_v{version}.exe", + "hold_file": "ac.hold_install", + "supports_incremental": true, + "silent": true, + "silent_install_args": "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + }, + + "runtime_requirements": [ + { + "runtime_id": "vc_redist", + "display_name": "VC++运行库", + "enabled": true, + "always_install": true, + "detection": { + "type": "registry", + "keys": [ + "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x86", + "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x64", + "SOFTWARE\\Microsoft\\VisualStudio\\15.0\\VC\\Runtimes\\x86", + "SOFTWARE\\Microsoft\\VisualStudio\\15.0\\VC\\Runtimes\\x64", + "SOFTWARE\\Microsoft\\VisualStudio\\16.0\\VC\\Runtimes\\x86", + "SOFTWARE\\Microsoft\\VisualStudio\\16.0\\VC\\Runtimes\\x64", + "SOFTWARE\\Microsoft\\VisualStudio\\17.0\\VC\\Runtimes\\x86", + "SOFTWARE\\Microsoft\\VisualStudio\\17.0\\VC\\Runtimes\\x64" + ], + "value_name": "Installed", + "expected_value": 1 + }, + "download_url_x86": "https://cdn.r0csgo.com/runtime/VC_redist.x86.exe", + "download_url_x64": "https://cdn.r0csgo.com/runtime/VC_redist.x64.exe", + "temp_filename_template": "VC_redist.{arch}.exe", + "install_args": "/install /passive /norestart" + }, + { + "runtime_id": "dotnet_desktop", + "display_name": ".NET运行库", + "enabled": false, + "always_install": false, + "detection": { + "type": "dotnet_runtime", + "runtime_name": "Microsoft.WindowsDesktop.App", + "version": "8.0.21" + }, + "download_url_x86": "https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-8.0.21-win-x86.exe", + "download_url_x64": "https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-8.0.21-win-x64.exe", + "temp_filename_template": "windowsdesktop-runtime-{version}-win-{arch}.exe", + "install_args": "/install /passive /norestart" + }, + { + "runtime_id": "aspnet_core", + "display_name": "ASP.NET Core运行库", + "enabled": false, + "always_install": false, + "detection": { + "type": "dotnet_runtime", + "runtime_name": "Microsoft.AspNetCore.App", + "version": "8.0.21" + }, + "download_url_x86": "https://cdn.r0csgo.com/runtime/aspnetcore-runtime-8.0.21-win-x86.exe", + "download_url_x64": "https://cdn.r0csgo.com/runtime/aspnetcore-runtime-8.0.21-win-x64.exe", + "temp_filename_template": "aspnetcore-runtime-{version}-win-{arch}.exe", + "install_args": "/install /passive /norestart" + } + ], + + "download_config": { + "chunk_size": 10485760, + "max_threads": 8, + "retry_count": 3, + "timeout_ms": 30000, + "user_agent": "r0_installer" + } + } +} +``` + +**响应示例(新增项目,例如 `r0_launcher`)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "project_id": "r0_launcher", + "display_name": "R0启动器", + + "install": { + "window_title": "R0启动器安装程序", + "steps": [ + { + "step_id": "runtime", + "display_name": "环境检查", + "type": "runtime_check", + "enabled": true + }, + { + "step_id": "main", + "display_name": "启动器安装", + "type": "download_and_run", + "enabled": true, + "download_url_field": "download_url_cdn", + "temp_filename_template": "r0_launcher_v{version}.exe", + "installer_args": "", + "wait_for_exit": false + } + ] + }, + + "update": { + "window_title": "R0启动器更新程序", + "command": "update", + "check_api": "/v3/update/check?project=r0_launcher&v={0}", + "processes_to_close": ["R0Launcher", "r0_launcher"], + "launch_after_update": "R0Launcher.exe", + "temp_filename_template": "r0_launcher_v{version}.exe", + "hold_file": null, + "supports_incremental": true + }, + + "runtime_requirements": [ + { + "runtime_id": "dotnet_desktop", + "display_name": ".NET 9.0运行库", + "enabled": true, + "always_install": false, + "detection": { + "type": "dotnet_runtime", + "runtime_name": "Microsoft.WindowsDesktop.App", + "version": "9.0.3" + }, + "download_url_x86": "https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-9.0.3-win-x86.exe", + "download_url_x64": "https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-9.0.3-win-x64.exe", + "temp_filename_template": "windowsdesktop-runtime-9.0.3-win-{arch}.exe", + "install_args": "/install /passive /norestart" + } + ], + + "download_config": { + "chunk_size": 10485760, + "max_threads": 8, + "retry_count": 3, + "timeout_ms": 30000, + "user_agent": "r0_installer" + } + } +} +``` + +**install.steps 字段说明**: + + +| 字段 | 类型 | 必填 | 说明 | +| ------------------------ | ------ | --- | ---------------------------- | +| `step_id` | string | 是 | 步骤唯一标识 | +| `display_name` | string | 是 | 步骤显示名称(用于 UI 步骤指示器) | +| `type` | string | 是 | 步骤类型,见下方类型说明 | +| `enabled` | bool | 是 | 是否启用此步骤 | +| `download_url_field` | string | 条件 | 对应版本信息 API 中的下载地址字段名 | +| `temp_filename` | string | 条件 | 固定的临时文件名(无版本号时用) | +| `temp_filename_template` | string | 条件 | 临时文件名模板,`{version}` 会被替换为版本号 | +| `installer_args` | string | 否 | 安装器启动参数 | +| `wait_for_exit` | bool | 否 | 是否等待安装器退出(默认 true) | + + +**步骤类型(`type`)**: + + +| 类型 | 说明 | +| ---------------------- | ---------------- | +| `runtime_check` | 运行环境检查与安装 | +| `download_and_run` | 下载文件并运行安装器 | +| `download_and_extract` | 下载文件并解压到指定目录(预留) | + + +**runtime_requirements 字段说明**: + + +| 字段 | 类型 | 必填 | 说明 | +| ------------------------ | ------ | --- | ---------------------------------------------- | +| `runtime_id` | string | 是 | 运行库唯一标识 | +| `display_name` | string | 是 | 显示名称 | +| `enabled` | bool | 是 | 是否启用检测和安装 | +| `always_install` | bool | 是 | true=无论是否已安装都重装;false=检测后按需安装 | +| `detection` | object | 是 | 检测方式配置 | +| `download_url_x86` | string | 是 | x86 下载地址 | +| `download_url_x64` | string | 是 | x64 下载地址 | +| `temp_filename_template` | string | 是 | 临时文件名模板(`{arch}` → x86/x64, `{version}` → 版本号) | +| `install_args` | string | 是 | 安装器命令行参数 | + + +**detection 类型说明**: + + +| detection.type | 额外字段 | 说明 | +| ---------------- | -------------------------------------- | ------------------------------ | +| `registry` | `keys`, `value_name`, `expected_value` | 检查注册表键值 | +| `dotnet_runtime` | `runtime_name`, `version` | 通过 `dotnet --list-runtimes` 检查 | +| `file_exists` | `path` | 检查文件是否存在(预留) | +| `command_output` | `command`, `args`, `contains` | 检查命令输出是否包含字符串(预留) | + + +--- + +### API 3: 获取安装器全局配置(文件名检测规则) + +**用途**: 替代硬编码的 `DetectInstallMode()` 逻辑。安装器启动时根据自身 exe 文件名匹配规则,确定要安装哪个项目以及安装模式。 + +``` +GET /v3/config/installer +``` + +**请求参数**: 无 + +**响应示例**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "installer_version": "2.0", + "min_client_version": "1.0.0.0", + + "filename_rules": [ + { + "pattern": "R0ClientInstaller", + "match_type": "starts_with", + "case_sensitive": false, + "project_id": "r0_arena", + "install_mode": "client_only", + "description": "仅安装R0对战平台客户端" + }, + { + "pattern": "R0AcInstaller", + "match_type": "starts_with", + "case_sensitive": false, + "project_id": "r0_arena", + "install_mode": "ac_only", + "description": "仅安装R0对战平台环境和反作弊" + }, + { + "pattern": "R0Installer", + "match_type": "starts_with", + "case_sensitive": false, + "project_id": "r0_arena", + "install_mode": "full", + "description": "完整安装R0对战平台" + }, + { + "pattern": "R0LauncherInstaller", + "match_type": "starts_with", + "case_sensitive": false, + "project_id": "r0_launcher", + "install_mode": "full", + "description": "完整安装R0启动器" + } + ], + + "command_rules": [ + { + "command": "update", + "project_id": "r0_arena", + "update_target": "update", + "silent": false, + "description": "R0对战平台主程序更新" + }, + { + "command": "update_ac", + "project_id": "r0_arena", + "update_target": "update_ac", + "silent": true, + "description": "R0对战平台反作弊更新(静默,无界面)" + }, + { + "command": "update_launcher", + "project_id": "r0_launcher", + "update_target": "update", + "silent": false, + "description": "R0启动器更新" + } + ], + + "default_project_id": "r0_arena", + "default_install_mode": "full" + } +} +``` + +**filename_rules 字段说明**: + + +| 字段 | 类型 | 必填 | 说明 | +| ---------------- | ------ | --- | --------------------------------------------- | +| `pattern` | string | 是 | 匹配模式字符串 | +| `match_type` | string | 是 | 匹配方式:`starts_with`、`contains`、`exact`、`regex` | +| `case_sensitive` | bool | 是 | 是否区分大小写 | +| `project_id` | string | 是 | 匹配成功后使用的项目ID | +| `install_mode` | string | 是 | 安装模式:`full`、`client_only`、`ac_only` | +| `description` | string | 否 | 规则描述 | + + +> **匹配优先级**:按数组顺序从前到后匹配,第一个命中的规则生效。因此应把更具体的规则放在前面(如 `R0ClientInstaller` 在 `R0Installer` 之前)。 + +**command_rules 字段说明**: + + +| 字段 | 类型 | 必填 | 说明 | +| --------------- | ------ | --- | -------------------------------------- | +| `command` | string | 是 | 命令行参数(第一个参数),如 `update`、`update_ac` | +| `project_id` | string | 是 | 对应的项目ID | +| `update_target` | string | 是 | 项目配置中的更新目标键名(`update` 或 `update_ac` 等) | +| `silent` | bool | 否 | 是否强制静默更新(不显示界面,后台自动完成)。默认 `false`。也可在 `project_config.{update_target}` 中配置同名字段 | +| `description` | string | 否 | 规则描述 | + + +> **静默更新**:当 `command_rules[].silent` 或对应 `update_target` 配置中的 `silent` 为 `true` 时,更新器不会弹出任何窗口,直接在后台下载并应用更新;更新完成后按 `launch_after_update` 自动拉起主程序。 +> 除服务端配置外,调用方也可在命令行追加 `--silent`(或 `-s` / `/silent` / `/s`)强制静默,例如:`R0Installer.exe update --silent`。 +> 静默模式下,更新结果仍会通过标准输出返回 `OK`(成功/无更新)或 `ERROR`(失败),方便调用方判断。 +> +> **全量包静默安装参数 `silent_install_args`**:配置在 `update_target` 配置块中(如上 `update_ac` 示例)。当静默执行 `update_type=full` 时,更新器会把该参数传给下载到的安装包并等待其安装完成。 +> 安装包由 Inno Setup 打包,默认参数为 `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`(`/VERYSILENT` 完全无界面、`/SUPPRESSMSGBOXES` 抑制弹窗、`/NORESTART` 不自动重启)。如需自定义可覆盖该字段,例如追加 `/CLOSEAPPLICATIONS /RESTARTAPPLICATIONS`。 +> 增量更新(`incremental` / `multi_incremental`)在更新器进程内直接应用补丁,本身就无界面,不涉及该参数。 + + +--- + +### API 4: 获取项目最新版本信息(安装用) + +**用途**: 替代原有 `/v2/version/index`,但支持多项目。 + +``` +GET /v3/version/index?project={project_id} +``` + +**请求参数**: + + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --------- | ----- | ------ | --- | ---- | +| `project` | query | string | 是 | 项目ID | + + +**响应示例(`r0_arena`)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "project_id": "r0_arena", + "version": "3.0.5", + "download_url_cdn": "https://cdn.r0csgo.com/releases/r0_arena_v3.0.5.exe", + "download_url_ac_cdn": "https://cdn.r0csgo.com/releases/r0_guard_v2.1.0.exe", + "threads": 8, + "changelog": "修复了若干问题", + "runtime_download_url": { + "VC_REDISTRIB_X86_URL": "https://cdn.r0csgo.com/runtime/VC_redist.x86.exe", + "VC_REDISTRIB_X64_URL": "https://cdn.r0csgo.com/runtime/VC_redist.x64.exe", + "DOTNET_SDK_X86_URL": "https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-8.0.21-win-x86.exe", + "DOTNET_SDK_X64_URL": "https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-8.0.21-win-x64.exe", + "ASPNET_CORE_X86_URL": "https://cdn.r0csgo.com/runtime/aspnetcore-runtime-8.0.21-win-x86.exe", + "ASPNET_CORE_X64_URL": "https://cdn.r0csgo.com/runtime/aspnetcore-runtime-8.0.21-win-x64.exe" + } + } +} +``` + +> **注意**: 此 API 的 `runtime_download_url` 字段可以覆盖项目配置中的运行库下载地址,用于紧急切换 CDN 等场景。客户端优先使用此处的地址,如果此处未提供则 fallback 到项目配置中的地址。 + +**响应示例(`r0_launcher`)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "project_id": "r0_launcher", + "version": "1.2.0", + "download_url_cdn": "https://cdn.r0csgo.com/releases/r0_launcher_v1.2.0.exe", + "threads": 8, + "changelog": "新增游戏管理功能" + } +} +``` + +--- + +### API 5: 检查更新 + +**用途**: 替代原有的 `/v2/update/check` 和 `/v2/update/check_ac`,统一为一个接口。 + +``` +GET /v3/update/check?project={project_id}&v={current_version} +``` + +**请求参数**: + + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --------- | ----- | ------ | --- | ------------------------------------------ | +| `project` | query | string | 是 | 项目ID,如 `r0_arena`、`r0_guard`、`r0_launcher` | +| `v` | query | string | 是 | 当前版本号 | + + +**响应示例(无更新)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "has_update": false, + "latest_version": "3.0.5", + "current_version": "3.0.5" + } +} +``` + +**响应示例(全量更新)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "has_update": true, + "latest_version": "3.1.0", + "current_version": "3.0.3", + "update_type": "full", + "download_url": "https://cdn.r0csgo.com/releases/r0_arena_v3.1.0.exe", + "file_size": "156000000", + "total_size": "156000000", + "changelog": "大版本更新,需要全量安装", + "threads": 8, + "silent": true + } +} +``` + +> **按版本指定静默更新(`silent` 字段)**:在本接口的响应中返回 `"silent": true`,即可让“更新到该目标版本”这一次走静默(无界面)流程;返回 `false` 则强制弹界面。 +> 这是**云端按版本控制**的开关:你在版本表里给某个版本打上静默标记,服务端检查更新命中该版本时下发 `silent`,客户端即据此决定。 +> 优先级:命令行 `--silent` > 本接口的 `silent` 字段 > `command_rules[].silent` / `update_target.silent`。若本接口未返回 `silent` 字段,则回退到命令/更新目标级配置。 +> `silent` 字段对 `full` / `incremental` / `multi_incremental` 三种更新类型都生效。全量包静默安装所用参数见前文 `silent_install_args`。 + +**响应示例(单个增量更新)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "has_update": true, + "latest_version": "3.0.6", + "current_version": "3.0.5", + "update_type": "incremental", + "download_url": "https://cdn.r0csgo.com/patches/r0_arena_3.0.5_to_3.0.6.zip", + "file_size": "5200000", + "total_size": "5200000", + "changelog": "修复了登录问题", + "threads": 8, + "silent": false + } +} +``` + +> 上例 `"silent": false` 表示该版本明确要求**弹界面**更新;省略该字段则由命令/更新目标级配置决定。 + +**响应示例(多步增量更新)**: + +```json +{ + "code": 0, + "message": "success", + "data": { + "has_update": true, + "latest_version": "3.0.8", + "current_version": "3.0.5", + "update_type": "multi_incremental", + "total_size": "15600000", + "changelog": "多版本增量更新", + "threads": 8, + "incremental_updates": [ + { + "from_version": "3.0.5", + "to_version": "3.0.6", + "download_url": "https://cdn.r0csgo.com/patches/r0_arena_3.0.5_to_3.0.6.zip", + "file_size": "5200000", + "changelog": "修复了登录问题" + }, + { + "from_version": "3.0.6", + "to_version": "3.0.7", + "download_url": "https://cdn.r0csgo.com/patches/r0_arena_3.0.6_to_3.0.7.zip", + "file_size": "4800000", + "changelog": "性能优化" + }, + { + "from_version": "3.0.7", + "to_version": "3.0.8", + "download_url": "https://cdn.r0csgo.com/patches/r0_arena_3.0.7_to_3.0.8.zip", + "file_size": "5600000", + "changelog": "新增功能" + } + ] + } +} +``` + +--- + +## 四、现有硬编码 → API 字段对照表 + +以下是当前代码中所有硬编码参数到 API 的映射关系,方便你实现后端时理解每个字段的来源: + +### 4.1 Program.cs 中的硬编码 + + +| 原硬编码 | 原始值 | 新来源 | +| ------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `UPDATE_CHECK_API` | `https://a-p-i.r0csgo.com/v2/update/check?v={0}` | `installer_config.command_rules` → 匹配到 project → `project_config.update.check_api` | +| `UPDATE_CHECK_AC_API` | `https://a-p-i.r0csgo.com/v2/update/check_ac?v={0}` | `installer_config.command_rules` → 匹配到 project → `project_config.update_ac.check_api` | +| `DetectInstallMode()` 中的文件名判断 `R0ClientInstaller` | 硬编码前缀 | `installer_config.filename_rules[].pattern` | +| `DetectInstallMode()` 中的文件名判断 `R0AcInstaller` | 硬编码前缀 | `installer_config.filename_rules[].pattern` | +| `command == "update"` | 硬编码命令 | `installer_config.command_rules[].command` | +| `command == "update_ac"` | 硬编码命令 | `installer_config.command_rules[].command` | + + +### 4.2 MainForm.cs 中的硬编码 + + +| 原硬编码 | 原始值 | 新来源 | +| ---------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `API_URL` | `https://a-p-i.r0csgo.com/v2/version/index` | `/v3/version/index?project={project_id}` | +| `DEFAULT_VC_REDISTRIB_X86_URL` | `https://cdn.r0csgo.com/runtime/VC_redist.x86.exe` | `project_config.runtime_requirements[].download_url_x86` | +| `DEFAULT_VC_REDISTRIB_X64_URL` | `https://cdn.r0csgo.com/runtime/VC_redist.x64.exe` | `project_config.runtime_requirements[].download_url_x64` | +| `DEFAULT_DOTNET_SDK_X64_URL` | `https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-8.0.21-win-x64.exe` | `project_config.runtime_requirements[].download_url_x64` | +| `DEFAULT_DOTNET_SDK_X86_URL` | `https://cdn.r0csgo.com/runtime/windowsdesktop-runtime-8.0.21-win-x86.exe` | `project_config.runtime_requirements[].download_url_x86` | +| `DEFAULT_ASPNET_CORE_X64_URL` | `https://cdn.r0csgo.com/runtime/aspnetcore-runtime-8.0.21-win-x64.exe` | `project_config.runtime_requirements[].download_url_x64` | +| `DEFAULT_ASPNET_CORE_X86_URL` | `https://cdn.r0csgo.com/runtime/aspnetcore-runtime-8.0.21-win-x86.exe` | `project_config.runtime_requirements[].download_url_x86` | +| `CHUNK_SIZE` = 10MB | 10485760 | `project_config.download_config.chunk_size` | +| `DEFAULT_MAX_THREADS` = 8 | 8 | `project_config.download_config.max_threads` | +| `INSTALL_VC_RUNTIME` = true | true | `project_config.runtime_requirements[runtime_id=vc_redist].enabled` | +| `INSTALL_DOTNET_RUNTIME` = false | false | `project_config.runtime_requirements[runtime_id=dotnet_desktop].enabled` | +| `INSTALL_ASPNET_RUNTIME` = false | false | `project_config.runtime_requirements[runtime_id=aspnet_core].enabled` | +| `USER_AGENT` | `r0_installer` | `project_config.download_config.user_agent` | +| 窗口标题 `R0对战平台安装程序` | 硬编码 | `project_config.install.window_title` | +| 步骤名称 `1. 环境检查` / `2. 反作弊安装` / `3. 主程序安装` | 硬编码 | `project_config.install.steps[].display_name` | +| 临时文件名 `r0_guard.exe` | 硬编码 | `project_config.install.steps[step_id=anticheat].temp_filename` | +| 临时文件名模板 `r0_arena_v{0}.exe` | 硬编码 | `project_config.install.steps[step_id=main].temp_filename_template` | +| 关闭进程 `r0_arena`, `R0对战平台` | 硬编码 | `project_config.update.processes_to_close` | +| 注册表检测路径 | 硬编码列表 | `project_config.runtime_requirements[].detection.keys` | +| dotnet 版本检测 `8.0.21` | 硬编码 | `project_config.runtime_requirements[].detection.version` | +| VC++ 安装参数 `/install /passive /norestart` | 硬编码 | `project_config.runtime_requirements[].install_args` | + + +### 4.3 UpdateManager.cs 中的硬编码 + + +| 原硬编码 | 原始值 | 新来源 | +| --------------------------- | ------------------------------------------------- | ---------------------------------------------- | +| `UPDATE_CHECK_API` | `https://a-p-i.r0csgo.com/v2/update/check?v={0}` | `project_config.update.check_api` | +| `UPDATE_CHECK_AC_API` | `https://a-p-i.r0csgo.com/v2/update/check_ac?v={0}` | `project_config.update_ac.check_api` | +| 临时文件名 `r0_arena_v{0}.exe` | 硬编码 | `project_config.update.temp_filename_template` | +| 临时文件名 `r0_patch_{guid}.zip` | 硬编码前缀 | 保持不变(通用增量包命名,与项目无关) | + + +### 4.4 UpdateForm.cs 中的硬编码 + + +| 原硬编码 | 原始值 | 新来源 | +| --------------------------------- | --- | -------------------------------------------------------------------------------------------- | +| 窗口标题 `R0反作弊更新程序` / `R0对战平台更新程序` | 硬编码 | `project_config.update.window_title` / `project_config.update_ac.window_title` | +| 关闭进程 `R0Guard` | 硬编码 | `project_config.update_ac.processes_to_close` | +| 关闭进程 `r0_arena`, `R0对战平台` | 硬编码 | `project_config.update.processes_to_close` | +| hold 文件名 `ac.hold_install` | 硬编码 | `project_config.update_ac.hold_file` | +| 启动程序 `R0Guard.exe` / `R0对战平台.exe` | 硬编码 | `project_config.update.launch_after_update` / `project_config.update_ac.launch_after_update` | + + +--- + +## 五、客户端改造后的启动流程 + +``` +程序启动 + │ + ├─> 1. 调用 GET /v3/config/installer 获取全局配置(缓存到本地) + │ + ├─> 2. 判断启动模式: + │ ├─ 有命令行参数(如 update / update_ac / update_launcher) + │ │ └─> 从 command_rules 匹配 → 得到 project_id 和 update_target + │ │ └─> 调用 GET /v3/config/project/{project_id} 获取项目配置 + │ │ └─> 使用 project_config.{update_target}.check_api 检查更新 + │ │ └─> 判断是否静默(命令行 --silent / command_rules.silent / update_target.silent) + │ │ ├─ 静默:后台执行更新,无界面(SilentUpdater) + │ │ └─ 非静默:弹出更新窗口执行更新(UpdateForm) + │ │ + │ └─ 无命令行参数(安装模式) + │ └─> 从 filename_rules 匹配 exe 文件名 → 得到 project_id 和 install_mode + │ └─> 调用 GET /v3/config/project/{project_id} 获取项目配置 + │ └─> 调用 GET /v3/version/index?project={project_id} 获取版本信息 + │ └─> 按 install.steps 和 install_mode 执行安装步骤 + │ + └─> 3. 完成 +``` + +--- + +## 六、错误响应格式 + +所有 API 统一使用以下错误格式: + +```json +{ + "code": 1001, + "message": "项目不存在", + "data": null +} +``` + +**错误码定义**: + + +| 错误码 | 说明 | +| ---- | ------- | +| 0 | 成功 | +| 1001 | 项目不存在 | +| 1002 | 版本参数无效 | +| 1003 | 配置不存在 | +| 5000 | 服务器内部错误 | + + +--- + +## 七、缓存策略建议 + + +| API | 缓存时间 | 说明 | +| ------------------------- | ---- | ---------- | +| `/v3/config/installer` | 1小时 | 文件名规则变化不频繁 | +| `/v3/config/project/{id}` | 30分钟 | 项目配置变化不频繁 | +| `/v3/version/index` | 不缓存 | 需要实时获取最新版本 | +| `/v3/update/check` | 不缓存 | 需要实时检查更新 | + + +客户端应在本地缓存 `installer` 和 `project` 配置,在网络不可用时使用缓存版本作为 fallback。 + +--- + +## 八、向后兼容方案 + +为了平滑过渡,建议: + +1. **v2 API 继续保留**,旧版安装器仍然可以工作 +2. **v3 API 新增**,新版安装器使用 v3 +3. 客户端先尝试调用 v3,如果 404 则 fallback 到 v2 行为(使用内置硬编码默认值) +4. `project_config` 缓存到 `%TEMP%\r0_installer_config_{project_id}.json`,API 不可用时读取缓存 + +--- + +## 九、新增应用接入流程(后续操作指南) + +假设要新增一个叫 "R0社区" 的应用: + +1. **后端操作**: + - 在数据库中新增项目 `r0_community` + - 配置该项目的安装步骤、运行库需求、更新参数 + - 在 `installer` 配置中添加 `filename_rules` 和 `command_rules` +2. **发布安装器**: + - 编译安装器,命名为 `R0CommunityInstaller.exe` + - 安装器无需改代码,会自动从 API 获取 `r0_community` 项目的配置 +3. **发布更新器**: + - 应用内调用 `R0Installer.exe update_community ` + - 安装器自动匹配 `command_rules` 中的 `update_community` 规则 + - 如需无界面静默更新:在 `command_rules` 中将该规则 `silent` 设为 `true`,或调用时追加 `--silent` 参数(`R0Installer.exe update_community --silent`) + +--- + +## 十、数据库表结构建议 + +### projects 表 + +```sql +CREATE TABLE projects ( + id INT PRIMARY KEY AUTO_INCREMENT, + project_id VARCHAR(50) UNIQUE NOT NULL, + display_name VARCHAR(100) NOT NULL, + description TEXT, + icon_url VARCHAR(500), + enabled TINYINT(1) DEFAULT 1, + config_json JSON NOT NULL, -- 存储完整的项目配置 JSON + created_at DATETIME DEFAULT NOW(), + updated_at DATETIME DEFAULT NOW() ON UPDATE NOW() +); +``` + +### installer_config 表 + +```sql +CREATE TABLE installer_config ( + id INT PRIMARY KEY AUTO_INCREMENT, + config_key VARCHAR(50) UNIQUE NOT NULL, -- 如 'global' + filename_rules JSON NOT NULL, + command_rules JSON NOT NULL, + default_project VARCHAR(50), + default_mode VARCHAR(20), + created_at DATETIME DEFAULT NOW(), + updated_at DATETIME DEFAULT NOW() ON UPDATE NOW() +); +``` + +### project_versions 表 + +```sql +CREATE TABLE project_versions ( + id INT PRIMARY KEY AUTO_INCREMENT, + project_id VARCHAR(50) NOT NULL, + version VARCHAR(50) NOT NULL, + download_url VARCHAR(500) NOT NULL, + file_size BIGINT DEFAULT 0, + changelog TEXT, + threads INT DEFAULT 8, + extra_downloads JSON, -- 额外下载项(如反作弊包) + runtime_urls JSON, -- 运行库下载地址覆盖 + is_latest TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT NOW(), + UNIQUE KEY (project_id, version) +); +``` + +### project_patches 表(增量更新包) + +```sql +CREATE TABLE project_patches ( + id INT PRIMARY KEY AUTO_INCREMENT, + project_id VARCHAR(50) NOT NULL, + from_version VARCHAR(50) NOT NULL, + to_version VARCHAR(50) NOT NULL, + download_url VARCHAR(500) NOT NULL, + file_size BIGINT DEFAULT 0, + changelog TEXT, + created_at DATETIME DEFAULT NOW(), + UNIQUE KEY (project_id, from_version, to_version) +); +``` + diff --git a/R0Installer/ConfigManager.cs b/R0Installer/ConfigManager.cs new file mode 100644 index 0000000..f77c650 --- /dev/null +++ b/R0Installer/ConfigManager.cs @@ -0,0 +1,577 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace R0Installer +{ + public static class ConfigManager + { + private const string BASE_URL = "https://a-p-i.r0csgo.com"; + private const string INSTALLER_CONFIG_API = "/v3/config/installer"; + private const string PROJECT_CONFIG_API = "/v3/config/project/{0}"; + private const string VERSION_INDEX_API = "/v3/version/index?project={0}"; + private const string USER_AGENT = "r0_installer"; + + private static string installerConfigCache = null; + private static string projectConfigCache = null; + private static string projectConfigCacheId = null; + + #region HTTP + + private static string HttpGet(string url) + { + using (WebClient client = new WebClient()) + { + client.Encoding = Encoding.UTF8; + client.Headers.Add("User-Agent", USER_AGENT); + return client.DownloadString(url); + } + } + + private static async Task HttpGetAsync(string url) + { + using (WebClient client = new WebClient()) + { + client.Encoding = Encoding.UTF8; + client.Headers.Add("User-Agent", USER_AGENT); + return await client.DownloadStringTaskAsync(url); + } + } + + #endregion + + #region JSON helpers + + public static string ExtractJsonValue(string json, string key) + { + string searchKey = String.Format("\"{0}\":", key); + int startIndex = json.IndexOf(searchKey); + if (startIndex < 0) return ""; + + startIndex += searchKey.Length; + + while (startIndex < json.Length && (json[startIndex] == ' ' || json[startIndex] == '\n' || json[startIndex] == '\r' || json[startIndex] == '\t')) + startIndex++; + + if (startIndex >= json.Length) return ""; + + if (json[startIndex] == '"') + { + startIndex++; + int endIndex = json.IndexOf('"', startIndex); + if (endIndex < 0) return ""; + return json.Substring(startIndex, endIndex - startIndex); + } + else + { + int endIndex = startIndex; + while (endIndex < json.Length && json[endIndex] != ',' && json[endIndex] != '}' && json[endIndex] != ']') + endIndex++; + return json.Substring(startIndex, endIndex - startIndex).Trim(); + } + } + + public static bool ExtractJsonBool(string json, string key) + { + string value = ExtractJsonValue(json, key); + return value.ToLower() == "true"; + } + + public static int ExtractJsonInt(string json, string key, int defaultValue) + { + string value = ExtractJsonValue(json, key); + int result; + if (int.TryParse(value, out result)) return result; + return defaultValue; + } + + public static string ExtractNestedJsonValue(string json, string parentKey, string childKey) + { + string searchParentKey = String.Format("\"{0}\":", parentKey); + int parentStartIndex = json.IndexOf(searchParentKey); + if (parentStartIndex < 0) return ""; + + parentStartIndex += searchParentKey.Length; + + while (parentStartIndex < json.Length && (json[parentStartIndex] == ' ' || json[parentStartIndex] == '\n' || json[parentStartIndex] == '\r')) + parentStartIndex++; + + if (parentStartIndex >= json.Length || json[parentStartIndex] != '{') return ""; + + int braceCount = 1; + int parentEndIndex = parentStartIndex + 1; + while (parentEndIndex < json.Length && braceCount > 0) + { + if (json[parentEndIndex] == '{') braceCount++; + else if (json[parentEndIndex] == '}') braceCount--; + parentEndIndex++; + } + + string parentJson = json.Substring(parentStartIndex, parentEndIndex - parentStartIndex); + return ExtractJsonValue(parentJson, childKey); + } + + public static string ExtractJsonObject(string json, string key) + { + string searchKey = String.Format("\"{0}\":", key); + int startIndex = json.IndexOf(searchKey); + if (startIndex < 0) return ""; + + startIndex += searchKey.Length; + + while (startIndex < json.Length && (json[startIndex] == ' ' || json[startIndex] == '\n' || json[startIndex] == '\r')) + startIndex++; + + if (startIndex >= json.Length) return ""; + + if (json[startIndex] == '{') + { + int braceCount = 1; + int endIndex = startIndex + 1; + while (endIndex < json.Length && braceCount > 0) + { + if (json[endIndex] == '{') braceCount++; + else if (json[endIndex] == '}') braceCount--; + endIndex++; + } + return json.Substring(startIndex, endIndex - startIndex); + } + return ""; + } + + public static string ExtractJsonArray(string json, string key) + { + string searchKey = String.Format("\"{0}\":", key); + int startIndex = json.IndexOf(searchKey); + if (startIndex < 0) return ""; + + startIndex += searchKey.Length; + + while (startIndex < json.Length && (json[startIndex] == ' ' || json[startIndex] == '\n' || json[startIndex] == '\r')) + startIndex++; + + if (startIndex >= json.Length || json[startIndex] != '[') return ""; + + int bracketCount = 1; + int endIndex = startIndex + 1; + while (endIndex < json.Length && bracketCount > 0) + { + if (json[endIndex] == '[') bracketCount++; + else if (json[endIndex] == ']') bracketCount--; + endIndex++; + } + return json.Substring(startIndex, endIndex - startIndex); + } + + public static List ParseJsonObjectArray(string arrayJson) + { + List objects = new List(); + if (string.IsNullOrEmpty(arrayJson) || arrayJson[0] != '[') return objects; + + string inner = arrayJson.Substring(1, arrayJson.Length - 2); + int pos = 0; + while (pos < inner.Length) + { + int objStart = inner.IndexOf('{', pos); + if (objStart < 0) break; + + int braceCount = 1; + int objEnd = objStart + 1; + while (objEnd < inner.Length && braceCount > 0) + { + if (inner[objEnd] == '{') braceCount++; + else if (inner[objEnd] == '}') braceCount--; + objEnd++; + } + + objects.Add(inner.Substring(objStart, objEnd - objStart)); + pos = objEnd; + } + return objects; + } + + public static List ParseJsonStringArray(string arrayJson) + { + List strings = new List(); + if (string.IsNullOrEmpty(arrayJson) || arrayJson[0] != '[') return strings; + + string inner = arrayJson.Substring(1, arrayJson.Length - 2); + int pos = 0; + while (pos < inner.Length) + { + int quoteStart = inner.IndexOf('"', pos); + if (quoteStart < 0) break; + + int quoteEnd = inner.IndexOf('"', quoteStart + 1); + if (quoteEnd < 0) break; + + strings.Add(inner.Substring(quoteStart + 1, quoteEnd - quoteStart - 1)); + pos = quoteEnd + 1; + } + return strings; + } + + #endregion + + #region Config loading + + private static string GetCachePath(string name) + { + return Path.Combine(Path.GetTempPath(), "r0_installer_" + name + ".json"); + } + + private static void SaveCache(string name, string json) + { + try + { + File.WriteAllText(GetCachePath(name), json, Encoding.UTF8); + } + catch (Exception ex) + { + Debug.WriteLine("缓存写入失败: " + ex.Message); + } + } + + private static string LoadCache(string name) + { + try + { + string path = GetCachePath(name); + if (File.Exists(path)) + return File.ReadAllText(path, Encoding.UTF8); + } + catch (Exception ex) + { + Debug.WriteLine("缓存读取失败: " + ex.Message); + } + return null; + } + + // 下载的安装包/运行库统一放到固定的厂商目录下,而不是 %TEMP%。 + // 从 %TEMP% 写入并立即执行 EXE 是杀软启发式判定下载器木马的主要特征, + // 改用 ProgramData 下的固定目录可显著降低误报,且符合正规安装器的行为。 + public static string GetWorkDirectory() + { + string baseDir = null; + try { baseDir = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); } + catch { } + if (string.IsNullOrEmpty(baseDir)) baseDir = Path.GetTempPath(); + + string dir = Path.Combine(baseDir, "R0Arena", "cache"); + try + { + Directory.CreateDirectory(dir); + return dir; + } + catch + { + return Path.GetTempPath(); + } + } + + // 目标 exe 已被占用无法覆盖时,改用同目录下的新 exe 文件名(必须保持 .exe 后缀以便运行)。 + public static string ResolveWritableDownloadPath(string preferredPath) + { + if (string.IsNullOrEmpty(preferredPath)) + preferredPath = Path.Combine(GetWorkDirectory(), "download_" + Guid.NewGuid().ToString("N") + ".exe"); + + if (!File.Exists(preferredPath)) + return preferredPath; + + try + { + File.Delete(preferredPath); + if (!File.Exists(preferredPath)) + return preferredPath; + } + catch { } + + string dir = Path.GetDirectoryName(preferredPath); + if (string.IsNullOrEmpty(dir)) + dir = GetWorkDirectory(); + + string nameWithoutExt = Path.GetFileNameWithoutExtension(preferredPath); + string ext = Path.GetExtension(preferredPath); + if (string.IsNullOrEmpty(ext)) + ext = ".exe"; + + return Path.Combine(dir, nameWithoutExt + "_" + Guid.NewGuid().ToString("N").Substring(0, 8) + ext); + } + + public static void PrepareDownloadFile(string path) + { + if (!File.Exists(path)) return; + try { File.Delete(path); } catch { } + } + + // 执行前确认下载结果确实是有效文件:避免把服务器返回的错误页 / 半截下载 + // 当作程序去运行(既是健壮性,也是“正规更新器”才有的行为)。 + public static bool IsDownloadedFileValid(string path) + { + try + { + FileInfo fi = new FileInfo(path); + if (!fi.Exists || fi.Length < 1) return false; + + if (path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + using (FileStream fs = File.OpenRead(path)) + { + return fs.ReadByte() == 0x4D && fs.ReadByte() == 0x5A; // 'MZ' + } + } + + return true; + } + catch + { + return false; + } + } + + public static string GetInstallerConfig() + { + if (installerConfigCache != null) return installerConfigCache; + + try + { + string json = HttpGet(BASE_URL + INSTALLER_CONFIG_API); + string data = ExtractJsonObject(json, "data"); + if (!string.IsNullOrEmpty(data)) + { + installerConfigCache = data; + SaveCache("installer_config", data); + return data; + } + } + catch (Exception ex) + { + Debug.WriteLine("获取安装器配置失败: " + ex.Message); + } + + string cached = LoadCache("installer_config"); + if (cached != null) + { + installerConfigCache = cached; + return cached; + } + + return null; + } + + public static async Task GetProjectConfig(string projectId) + { + if (projectConfigCache != null && projectConfigCacheId == projectId) + return projectConfigCache; + + try + { + string url = BASE_URL + String.Format(PROJECT_CONFIG_API, Uri.EscapeDataString(projectId)); + string json = await HttpGetAsync(url); + string data = ExtractJsonObject(json, "data"); + if (!string.IsNullOrEmpty(data)) + { + projectConfigCache = data; + projectConfigCacheId = projectId; + SaveCache("project_" + projectId, data); + return data; + } + } + catch (Exception ex) + { + Debug.WriteLine("获取项目配置失败: " + ex.Message); + } + + string cached = LoadCache("project_" + projectId); + if (cached != null) + { + projectConfigCache = cached; + projectConfigCacheId = projectId; + return cached; + } + + return null; + } + + public static string GetProjectConfigSync(string projectId) + { + if (projectConfigCache != null && projectConfigCacheId == projectId) + return projectConfigCache; + + try + { + string url = BASE_URL + String.Format(PROJECT_CONFIG_API, Uri.EscapeDataString(projectId)); + string json = HttpGet(url); + string data = ExtractJsonObject(json, "data"); + if (!string.IsNullOrEmpty(data)) + { + projectConfigCache = data; + projectConfigCacheId = projectId; + SaveCache("project_" + projectId, data); + return data; + } + } + catch (Exception ex) + { + Debug.WriteLine("获取项目配置失败: " + ex.Message); + } + + string cached = LoadCache("project_" + projectId); + if (cached != null) + { + projectConfigCache = cached; + projectConfigCacheId = projectId; + return cached; + } + + return null; + } + + public static async Task GetVersionIndex(string projectId) + { + try + { + string url = BASE_URL + String.Format(VERSION_INDEX_API, Uri.EscapeDataString(projectId)); + string json = await HttpGetAsync(url); + string data = ExtractJsonObject(json, "data"); + return data; + } + catch (Exception ex) + { + Debug.WriteLine("获取版本信息失败: " + ex.Message); + return null; + } + } + + public struct UpdateSummary + { + public bool HasUpdate; + public bool HasSilentField; // 服务端是否在本次响应中显式返回了 silent 字段 + public bool Silent; // 该版本是否应静默更新(仅当 HasSilentField 为 true 时有效) + } + + // 轻量预检:在决定“弹界面 / 静默”之前,先同步拿到 has_update 以及(可选的)按版本 silent 标志。 + // silent 由服务端在 /v3/update/check 响应中按目标版本下发,从而实现“云端指定哪个版本静默更新”。 + public static UpdateSummary GetUpdateSummarySync(string projectId, string checkApiTemplate, string currentVersion) + { + UpdateSummary summary = new UpdateSummary(); + try + { + string apiUrl = BASE_URL + String.Format(checkApiTemplate, Uri.EscapeDataString(currentVersion)); + string json = HttpGet(apiUrl); + + string data = ExtractJsonObject(json, "data"); + string body = string.IsNullOrEmpty(data) ? json : data; + + summary.HasUpdate = ExtractJsonBool(body, "has_update"); + + string silentRaw = ExtractJsonValue(body, "silent"); + summary.HasSilentField = !string.IsNullOrEmpty(silentRaw); + summary.Silent = silentRaw.Trim().ToLower() == "true"; + } + catch (Exception) + { + // 网络异常时保守认为有更新(与旧逻辑一致);是否静默回退到本地命令/更新目标配置。 + summary.HasUpdate = true; + summary.HasSilentField = false; + } + return summary; + } + + #endregion + + #region Installer config matching + + public struct MatchResult + { + public string ProjectId; + public string InstallMode; + public string UpdateTarget; + public bool Silent; + } + + public static MatchResult MatchFilename(string installerConfig, string exeName) + { + MatchResult result = new MatchResult(); + result.ProjectId = ExtractJsonValue(installerConfig, "default_project_id"); + result.InstallMode = ExtractJsonValue(installerConfig, "default_install_mode"); + if (string.IsNullOrEmpty(result.InstallMode)) result.InstallMode = "full"; + + string rulesArray = ExtractJsonArray(installerConfig, "filename_rules"); + if (string.IsNullOrEmpty(rulesArray)) return result; + + List rules = ParseJsonObjectArray(rulesArray); + foreach (string rule in rules) + { + string pattern = ExtractJsonValue(rule, "pattern"); + string matchType = ExtractJsonValue(rule, "match_type"); + bool caseSensitive = ExtractJsonBool(rule, "case_sensitive"); + + if (string.IsNullOrEmpty(pattern)) continue; + + bool matched = false; + string nameToCheck = caseSensitive ? exeName : exeName.ToLower(); + string patternToCheck = caseSensitive ? pattern : pattern.ToLower(); + + switch (matchType) + { + case "starts_with": + matched = nameToCheck.StartsWith(patternToCheck); + break; + case "contains": + matched = nameToCheck.Contains(patternToCheck); + break; + case "exact": + matched = nameToCheck == patternToCheck; + break; + default: + matched = nameToCheck.StartsWith(patternToCheck); + break; + } + + if (matched) + { + result.ProjectId = ExtractJsonValue(rule, "project_id"); + result.InstallMode = ExtractJsonValue(rule, "install_mode"); + return result; + } + } + + return result; + } + + public static MatchResult MatchCommand(string installerConfig, string command) + { + MatchResult result = new MatchResult(); + + string rulesArray = ExtractJsonArray(installerConfig, "command_rules"); + if (string.IsNullOrEmpty(rulesArray)) return result; + + List rules = ParseJsonObjectArray(rulesArray); + foreach (string rule in rules) + { + string cmd = ExtractJsonValue(rule, "command"); + if (cmd.ToLower() == command.ToLower()) + { + result.ProjectId = ExtractJsonValue(rule, "project_id"); + result.UpdateTarget = ExtractJsonValue(rule, "update_target"); + result.Silent = ExtractJsonBool(rule, "silent"); + return result; + } + } + + return result; + } + + public static string GetUpdateConfig(string projectConfig, string updateTarget) + { + return ExtractJsonObject(projectConfig, updateTarget); + } + + #endregion + } +} diff --git a/R0Installer/MainForm.cs b/R0Installer/MainForm.cs new file mode 100644 index 0000000..69fd068 --- /dev/null +++ b/R0Installer/MainForm.cs @@ -0,0 +1,1161 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.Win32; + +namespace R0Installer +{ + public class MainForm : Form + { + private Panel totalProgressBar; + private Panel totalProgressFill; + private Panel stepProgressBar; + private Panel stepProgressFill; + private Label titleLabel; + private Label totalStatusLabel; + private Label stepStatusLabel; + private Label totalPercentLabel; + private Label stepPercentLabel; + private Label versionLabel; + + private Label[] stepLabels; + private Panel[] stepIndicators; + + private string projectId; + private string projectConfig; + private string installConfigJson; + private string versionData; + + private int maxThreads = 8; + private string userAgent = "r0_installer"; + + private string mainVersion = ""; + private InstallMode installMode = InstallMode.Full; + + private List enabledSteps = new List(); + private List runtimeConfigs = new List(); + + private readonly Color primaryGreen = Color.FromArgb(76, 175, 80); + private readonly Color brightGreen = Color.FromArgb(111, 255, 99); + private readonly Color darkBg = Color.FromArgb(18, 18, 18); + private readonly Color panelBg = Color.FromArgb(28, 28, 28); + private readonly Color textGray = Color.FromArgb(180, 180, 180); + private readonly Color textDark = Color.FromArgb(100, 100, 100); + + private bool isDragging = false; + private Point dragStart; + + private int currentStep = 0; + private int totalStepCount = 3; + + private string windowTitle = "安装程序"; + + // total progress tracking: each step occupies a fraction of [0,100] + private int currentStepIndex = 0; + private double currentStepSubProgress = 0; // 0..1 within current step + + private class StepInfo + { + public string ConfigJson; + public string DisplayName; + public string StepType; + public bool ShouldExecute; + } + + public MainForm(InstallMode mode, string projectId) + { + this.installMode = mode; + this.projectId = projectId; + InitializeComponent(); + SetupUI(); + this.Load += MainForm_Load; + } + + private async void MainForm_Load(object sender, EventArgs e) + { + await StartInstallProcess(); + } + + private void InitializeComponent() + { + this.SuspendLayout(); + this.Text = windowTitle; + this.Size = new Size(500, 380); + this.StartPosition = FormStartPosition.CenterScreen; + this.FormBorderStyle = FormBorderStyle.None; + this.BackColor = darkBg; + this.DoubleBuffered = true; + try { this.Icon = Icon.ExtractAssociatedIcon(System.Reflection.Assembly.GetExecutingAssembly().Location); } catch { } + this.Region = CreateRoundedRegion(this.Width, this.Height, 12); + this.MouseDown += Form_MouseDown; + this.MouseMove += Form_MouseMove; + this.MouseUp += Form_MouseUp; + this.ResumeLayout(false); + } + + private Region CreateRoundedRegion(int width, int height, int radius) + { + GraphicsPath path = new GraphicsPath(); + path.AddArc(0, 0, radius * 2, radius * 2, 180, 90); + path.AddArc(width - radius * 2, 0, radius * 2, radius * 2, 270, 90); + path.AddArc(width - radius * 2, height - radius * 2, radius * 2, radius * 2, 0, 90); + path.AddArc(0, height - radius * 2, radius * 2, radius * 2, 90, 90); + path.CloseAllFigures(); + return new Region(path); + } + + private void SetupUI() + { + Label closeBtn = new Label(); + closeBtn.Text = "×"; + closeBtn.Font = new Font("Segoe UI", 14, FontStyle.Regular); + closeBtn.ForeColor = textGray; + closeBtn.Size = new Size(36, 36); + closeBtn.Location = new Point(this.Width - 40, 4); + closeBtn.TextAlign = ContentAlignment.MiddleCenter; + closeBtn.Cursor = Cursors.Hand; + closeBtn.Click += (s, ev) => Application.Exit(); + closeBtn.MouseEnter += (s, ev) => ((Label)s).ForeColor = Color.White; + closeBtn.MouseLeave += (s, ev) => ((Label)s).ForeColor = textGray; + this.Controls.Add(closeBtn); + + titleLabel = new Label(); + titleLabel.Text = windowTitle; + titleLabel.Font = new Font("Microsoft YaHei", 20, FontStyle.Bold); + titleLabel.ForeColor = primaryGreen; + titleLabel.AutoSize = true; + titleLabel.BackColor = Color.Transparent; + this.Controls.Add(titleLabel); + titleLabel.Location = new Point((this.Width - 240) / 2, 40); + + this.Paint += MainForm_Paint; + + int progressY = 180; + + totalStatusLabel = new Label(); + totalStatusLabel.Text = "总进度"; + totalStatusLabel.Font = new Font("Microsoft YaHei", 10, FontStyle.Regular); + totalStatusLabel.ForeColor = textGray; + totalStatusLabel.AutoSize = true; + totalStatusLabel.Location = new Point(40, progressY); + this.Controls.Add(totalStatusLabel); + + totalPercentLabel = new Label(); + totalPercentLabel.Text = "0%"; + totalPercentLabel.Font = new Font("Microsoft YaHei", 10, FontStyle.Bold); + totalPercentLabel.ForeColor = primaryGreen; + totalPercentLabel.AutoSize = true; + totalPercentLabel.Location = new Point(this.Width - 80, progressY); + this.Controls.Add(totalPercentLabel); + + totalProgressBar = new Panel(); + totalProgressBar.Size = new Size(this.Width - 80, 8); + totalProgressBar.Location = new Point(40, progressY + 25); + totalProgressBar.BackColor = panelBg; + this.Controls.Add(totalProgressBar); + + totalProgressFill = new Panel(); + totalProgressFill.Size = new Size(0, 8); + totalProgressFill.Location = new Point(0, 0); + totalProgressFill.BackColor = primaryGreen; + totalProgressBar.Controls.Add(totalProgressFill); + + int stepProgressY = progressY + 60; + + stepStatusLabel = new Label(); + stepStatusLabel.Text = "正在初始化..."; + stepStatusLabel.Font = new Font("Microsoft YaHei", 9, FontStyle.Regular); + stepStatusLabel.ForeColor = textGray; + stepStatusLabel.AutoSize = false; + stepStatusLabel.Size = new Size(300, 20); + stepStatusLabel.Location = new Point(40, stepProgressY); + this.Controls.Add(stepStatusLabel); + + stepPercentLabel = new Label(); + stepPercentLabel.Text = ""; + stepPercentLabel.Font = new Font("Microsoft YaHei", 9, FontStyle.Regular); + stepPercentLabel.ForeColor = brightGreen; + stepPercentLabel.AutoSize = false; + stepPercentLabel.Size = new Size(160, 20); + stepPercentLabel.TextAlign = ContentAlignment.MiddleRight; + stepPercentLabel.Location = new Point(this.Width - 200, stepProgressY); + this.Controls.Add(stepPercentLabel); + + stepProgressBar = new Panel(); + stepProgressBar.Size = new Size(this.Width - 80, 4); + stepProgressBar.Location = new Point(40, stepProgressY + 22); + stepProgressBar.BackColor = panelBg; + this.Controls.Add(stepProgressBar); + + stepProgressFill = new Panel(); + stepProgressFill.Size = new Size(0, 4); + stepProgressFill.Location = new Point(0, 0); + stepProgressFill.BackColor = brightGreen; + stepProgressBar.Controls.Add(stepProgressFill); + + versionLabel = new Label(); + versionLabel.Text = "v1.0"; + versionLabel.Font = new Font("Segoe UI", 8, FontStyle.Regular); + versionLabel.ForeColor = textDark; + versionLabel.AutoSize = true; + versionLabel.Location = new Point(this.Width - 50, this.Height - 25); + versionLabel.BackColor = Color.Transparent; + this.Controls.Add(versionLabel); + } + + private void BuildStepIndicators() + { + if (this.InvokeRequired) { this.Invoke(new Action(BuildStepIndicators)); return; } + + if (stepLabels != null) + { + foreach (Label l in stepLabels) this.Controls.Remove(l); + foreach (Panel p in stepIndicators) this.Controls.Remove(p); + } + + totalStepCount = enabledSteps.Count; + if (totalStepCount == 0) totalStepCount = 1; + + stepLabels = new Label[totalStepCount]; + stepIndicators = new Panel[totalStepCount]; + + int stepY = 100; + int maxStepWidth = (this.Width - 40) / totalStepCount; + int stepWidth = Math.Min(140, maxStepWidth); + int startX = (this.Width - stepWidth * totalStepCount) / 2; + + for (int i = 0; i < totalStepCount; i++) + { + stepIndicators[i] = new Panel(); + stepIndicators[i].Size = new Size(24, 24); + stepIndicators[i].Location = new Point(startX + i * stepWidth + (stepWidth - 24) / 2, stepY); + stepIndicators[i].BackColor = Color.Transparent; + stepIndicators[i].Tag = i; + stepIndicators[i].Paint += StepIndicator_Paint; + this.Controls.Add(stepIndicators[i]); + + string name = i < enabledSteps.Count ? enabledSteps[i].DisplayName : ""; + stepLabels[i] = new Label(); + stepLabels[i].Text = String.Format("{0}. {1}", i + 1, name); + stepLabels[i].Font = new Font("Microsoft YaHei", 9, FontStyle.Regular); + stepLabels[i].ForeColor = textDark; + stepLabels[i].AutoSize = true; + stepLabels[i].BackColor = Color.Transparent; + this.Controls.Add(stepLabels[i]); + stepLabels[i].Location = new Point(startX + i * stepWidth + (stepWidth - stepLabels[i].Width) / 2, stepY + 30); + } + + titleLabel.Text = windowTitle; + this.Text = windowTitle; + this.Invalidate(); + } + + private void StepIndicator_Paint(object sender, PaintEventArgs e) + { + Panel panel = (Panel)sender; + int index = (int)panel.Tag; + Graphics g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + Rectangle rect = new Rectangle(2, 2, 20, 20); + + if (index < currentStep) + { + using (SolidBrush brush = new SolidBrush(primaryGreen)) { g.FillEllipse(brush, rect); } + using (Pen pen = new Pen(Color.White, 2)) { g.DrawLine(pen, 7, 12, 10, 15); g.DrawLine(pen, 10, 15, 16, 8); } + } + else if (index == currentStep) + { + using (Pen pen = new Pen(primaryGreen, 2)) { g.DrawEllipse(pen, rect); } + using (SolidBrush brush = new SolidBrush(primaryGreen)) { g.FillEllipse(brush, 8, 8, 8, 8); } + } + else + { + using (Pen pen = new Pen(textDark, 2)) { g.DrawEllipse(pen, rect); } + } + } + + private void MainForm_Paint(object sender, PaintEventArgs e) + { + Graphics g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + + if (stepIndicators != null && totalStepCount > 1) + { + int stepY = 112; + int maxStepWidth = (this.Width - 40) / totalStepCount; + int stepWidth = Math.Min(140, maxStepWidth); + int startX = (this.Width - stepWidth * totalStepCount) / 2; + + for (int i = 0; i < totalStepCount - 1; i++) + { + int x1 = startX + i * stepWidth + (stepWidth + 24) / 2; + int x2 = startX + (i + 1) * stepWidth + (stepWidth - 24) / 2; + Color lineColor = i < currentStep ? primaryGreen : textDark; + using (Pen pen = new Pen(lineColor, 2)) { g.DrawLine(pen, x1, stepY, x2, stepY); } + } + } + + using (Pen pen = new Pen(Color.FromArgb(50, 255, 255, 255), 1)) + { + Rectangle rect = new Rectangle(0, 0, this.Width - 1, this.Height - 1); + using (GraphicsPath path = CreateRoundedPath(rect, 12)) { g.DrawPath(pen, path); } + } + } + + private GraphicsPath CreateRoundedPath(Rectangle rect, int radius) + { + GraphicsPath path = new GraphicsPath(); + path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90); + path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90); + path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90); + path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90); + path.CloseAllFigures(); + return path; + } + + #region Window drag + private void Form_MouseDown(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { isDragging = true; dragStart = e.Location; } } + private void Form_MouseMove(object sender, MouseEventArgs e) { if (isDragging) { this.Location = new Point(this.Location.X + e.X - dragStart.X, this.Location.Y + e.Y - dragStart.Y); } } + private void Form_MouseUp(object sender, MouseEventArgs e) { isDragging = false; } + #endregion + + #region UI updates + + private void SetCurrentStep(int step) + { + if (this.InvokeRequired) { this.Invoke(new Action(SetCurrentStep), step); return; } + currentStep = step; + currentStepIndex = step; + currentStepSubProgress = 0; + if (stepIndicators != null) + { + for (int i = 0; i < totalStepCount; i++) + { + stepIndicators[i].Invalidate(); + stepLabels[i].ForeColor = i <= currentStep ? primaryGreen : textDark; + } + } + this.Invalidate(); + } + + private void RecalcTotalProgress() + { + if (totalStepCount <= 0) return; + double total = (currentStepIndex + currentStepSubProgress) / totalStepCount * 100.0; + int pct = Math.Min(100, Math.Max(0, (int)total)); + UpdateTotalProgress(pct); + } + + private void UpdateTotalProgress(int percent) + { + if (this.InvokeRequired) { this.Invoke(new Action(UpdateTotalProgress), percent); return; } + int targetWidth = (int)(totalProgressBar.Width * percent / 100.0); + totalProgressFill.Width = targetWidth; + totalPercentLabel.Text = String.Format("{0}%", percent); + } + + private void UpdateStepProgress(int percent, string message) + { + UpdateStepProgress(percent, message, null); + } + + private void UpdateStepProgress(int percent, string message, string rightText) + { + if (this.InvokeRequired) { this.Invoke(new Action(UpdateStepProgress), percent, message, rightText); return; } + int targetWidth = (int)(stepProgressBar.Width * percent / 100.0); + stepProgressFill.Width = targetWidth; + if (message != null) stepStatusLabel.Text = message; + if (rightText != null) + stepPercentLabel.Text = rightText; + else + stepPercentLabel.Text = percent > 0 ? String.Format("{0}%", percent) : ""; + + currentStepSubProgress = percent / 100.0; + RecalcTotalProgress(); + } + + private void UpdateStepStatus(string message) + { + if (this.InvokeRequired) { this.Invoke(new Action(UpdateStepStatus), message); return; } + stepStatusLabel.Text = message; + } + + #endregion + + #region Install process + + private void CloseProjectProcesses() + { + CloseProcessesFromUpdateTarget("update"); + CloseProcessesFromUpdateTarget("update_ac"); + } + + private void CloseProcessesFromUpdateTarget(string updateTargetKey) + { + if (projectConfig == null) return; + string updateJson = ConfigManager.ExtractJsonObject(projectConfig, updateTargetKey); + if (string.IsNullOrEmpty(updateJson)) return; + string procArray = ConfigManager.ExtractJsonArray(updateJson, "processes_to_close"); + if (string.IsNullOrEmpty(procArray)) return; + List processNames = ConfigManager.ParseJsonStringArray(procArray); + foreach (string name in processNames) + { + try + { + Process[] processes = Process.GetProcessesByName(name); + foreach (Process proc in processes) + { + try { proc.Kill(); proc.WaitForExit(5000); } catch { } finally { proc.Dispose(); } + } + } + catch { } + } + } + + private async Task StartInstallProcess() + { + Exception installError = null; + try + { + UpdateStepStatus("正在获取项目配置..."); + projectConfig = await ConfigManager.GetProjectConfig(projectId); + if (projectConfig == null) + { + MessageBox.Show("无法获取项目配置,请检查网络连接", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + Application.Exit(); + return; + } + + installConfigJson = ConfigManager.ExtractJsonObject(projectConfig, "install"); + if (!string.IsNullOrEmpty(installConfigJson)) + { + windowTitle = ConfigManager.ExtractJsonValue(installConfigJson, "window_title"); + if (string.IsNullOrEmpty(windowTitle)) windowTitle = "安装程序"; + } + + string downloadConfig = ConfigManager.ExtractJsonObject(projectConfig, "download_config"); + if (!string.IsNullOrEmpty(downloadConfig)) + { + maxThreads = ConfigManager.ExtractJsonInt(downloadConfig, "max_threads", 8); + userAgent = ConfigManager.ExtractJsonValue(downloadConfig, "user_agent"); + if (string.IsNullOrEmpty(userAgent)) userAgent = "r0_installer"; + } + + string stepsArray = ConfigManager.ExtractJsonArray(installConfigJson, "steps"); + List stepConfigs = new List(); + if (!string.IsNullOrEmpty(stepsArray)) + stepConfigs = ConfigManager.ParseJsonObjectArray(stepsArray); + + enabledSteps.Clear(); + foreach (string step in stepConfigs) + { + bool enabled = ConfigManager.ExtractJsonBool(step, "enabled"); + if (!enabled) continue; + + string stepType = ConfigManager.ExtractJsonValue(step, "type"); + string displayName = ConfigManager.ExtractJsonValue(step, "display_name"); + string stepId = ConfigManager.ExtractJsonValue(step, "step_id"); + + bool shouldExecute = true; + if (installMode == InstallMode.ClientOnly && stepType == "runtime_check") + shouldExecute = false; + if (installMode == InstallMode.ClientOnly && stepId != "main" && stepType == "download_and_run") + shouldExecute = false; + if (installMode == InstallMode.AcOnly && stepId == "main" && stepType == "download_and_run") + shouldExecute = false; + + if (!shouldExecute) continue; + + enabledSteps.Add(new StepInfo + { + ConfigJson = step, + DisplayName = displayName, + StepType = stepType, + ShouldExecute = true, + }); + } + + string runtimeArray = ConfigManager.ExtractJsonArray(projectConfig, "runtime_requirements"); + if (!string.IsNullOrEmpty(runtimeArray)) + runtimeConfigs = ConfigManager.ParseJsonObjectArray(runtimeArray); + + BuildStepIndicators(); + + UpdateStepStatus("正在关闭相关程序..."); + CloseProjectProcesses(); + await Task.Delay(500); + + UpdateStepStatus("正在获取版本信息..."); + versionData = await ConfigManager.GetVersionIndex(projectId); + if (versionData == null) + { + MessageBox.Show("无法获取版本信息,请检查网络连接", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + Application.Exit(); + return; + } + + mainVersion = ConfigManager.ExtractJsonValue(versionData, "version"); + string threadsStr = ConfigManager.ExtractJsonValue(versionData, "threads"); + if (!string.IsNullOrEmpty(threadsStr)) { int t; if (int.TryParse(threadsStr, out t) && t > 0) maxThreads = t; } + + this.Invoke(new Action(() => { versionLabel.Text = String.Format("v{0}", mainVersion); })); + + await ExecuteSteps(); + + UpdateTotalProgress(100); + await Task.Delay(2000); + Application.Exit(); + } + catch (Exception ex) { installError = ex; } + + if (installError != null) + { + MessageBox.Show(String.Format("安装过程中出错: {0}", installError.Message), "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); + Application.Exit(); + } + } + + private async Task ExecuteSteps() + { + for (int i = 0; i < enabledSteps.Count; i++) + { + StepInfo si = enabledSteps[i]; + SetCurrentStep(i); + + if (!si.ShouldExecute) + { + UpdateStepProgress(100, String.Format("跳过{0}...", si.DisplayName)); + await Task.Delay(300); + } + else + { + switch (si.StepType) + { + case "runtime_check": + await CheckAndInstallRuntimeEnvironments(); + break; + case "download_and_run": + await ExecuteDownloadAndRunStep(si.ConfigJson); + break; + default: + UpdateStepProgress(100, String.Format("未知步骤类型: {0}", si.StepType)); + await Task.Delay(1000); + break; + } + } + + currentStepSubProgress = 1.0; + RecalcTotalProgress(); + } + } + + private async Task ExecuteDownloadAndRunStep(string stepJson) + { + string displayName = ConfigManager.ExtractJsonValue(stepJson, "display_name"); + string downloadUrlField = ConfigManager.ExtractJsonValue(stepJson, "download_url_field"); + string tempFilename = ConfigManager.ExtractJsonValue(stepJson, "temp_filename"); + string tempFilenameTemplate = ConfigManager.ExtractJsonValue(stepJson, "temp_filename_template"); + string installerArgs = ConfigManager.ExtractJsonValue(stepJson, "installer_args"); + bool waitForExit = ConfigManager.ExtractJsonBool(stepJson, "wait_for_exit"); + + string downloadUrl = ""; + if (!string.IsNullOrEmpty(downloadUrlField) && versionData != null) + { + string raw = ConfigManager.ExtractJsonValue(versionData, downloadUrlField); + if (!string.IsNullOrEmpty(raw)) + downloadUrl = raw.Replace("\\/", "/"); + } + + if (string.IsNullOrEmpty(downloadUrl)) + { + UpdateStepProgress(100, String.Format("{0}: 下载地址为空,跳过", displayName)); + await Task.Delay(1000); + return; + } + + string saveName; + if (!string.IsNullOrEmpty(tempFilenameTemplate)) + saveName = tempFilenameTemplate.Replace("{version}", mainVersion); + else if (!string.IsNullOrEmpty(tempFilename)) + { + if (!string.IsNullOrEmpty(mainVersion) && tempFilename.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + string baseName = Path.GetFileNameWithoutExtension(tempFilename); + saveName = baseName + "_v" + mainVersion + ".exe"; + } + else + saveName = tempFilename; + } + else + saveName = "download_" + Guid.NewGuid().ToString("N") + ".exe"; + + string savePath = ConfigManager.ResolveWritableDownloadPath( + Path.Combine(ConfigManager.GetWorkDirectory(), saveName)); + + CloseProjectProcesses(); + await Task.Delay(300); + + UpdateStepProgress(0, String.Format("正在下载{0}...", displayName)); + string actualPath = await DownloadWithRetry(downloadUrl, savePath, displayName); + + if (!ConfigManager.IsDownloadedFileValid(actualPath)) + { + UpdateStepProgress(100, String.Format("{0}: 下载文件无效,跳过", displayName)); + await Task.Delay(1500); + return; + } + + UpdateStepProgress(100, String.Format("正在安装{0}...", displayName)); + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = actualPath; + if (!string.IsNullOrEmpty(installerArgs)) psi.Arguments = installerArgs; + psi.UseShellExecute = true; + psi.WorkingDirectory = Path.GetDirectoryName(actualPath); + using (Process process = Process.Start(psi)) + { + if (process != null && waitForExit) + await Task.Run(new Action(process.WaitForExit)); + } + UpdateStepProgress(100, String.Format("{0}完成", displayName)); + } + + #endregion + + #region Runtime environment + + private async Task CheckAndInstallRuntimeEnvironments() + { + try + { + UpdateStepProgress(5, "正在检测运行环境..."); + bool installationNeeded = false; + int progressPerRuntime = runtimeConfigs.Count > 0 ? 90 / runtimeConfigs.Count : 90; + int currentProgress = 5; + + foreach (string rtConfig in runtimeConfigs) + { + bool enabled = ConfigManager.ExtractJsonBool(rtConfig, "enabled"); + if (!enabled) { currentProgress += progressPerRuntime; continue; } + + string displayName = ConfigManager.ExtractJsonValue(rtConfig, "display_name"); + bool alwaysInstall = ConfigManager.ExtractJsonBool(rtConfig, "always_install"); + bool isInstalled = alwaysInstall ? false : CheckRuntimeInstalled(rtConfig); + + if (alwaysInstall || !isInstalled) + { + UpdateStepProgress(currentProgress, String.Format("正在安装{0}...", displayName)); + bool success = await InstallRuntimeFromConfig(rtConfig); + if (!success) + { + UpdateStepProgress(currentProgress + progressPerRuntime / 2, String.Format("{0}安装失败,但程序将继续运行...", displayName)); + await Task.Delay(2000); + } + installationNeeded = true; + } + + currentProgress += progressPerRuntime; + UpdateStepProgress(currentProgress, String.Format("{0}完成", displayName)); + } + + UpdateStepProgress(100, installationNeeded ? "运行环境安装完成" : "运行环境检测完成"); + await Task.Delay(installationNeeded ? 1000 : 500); + } + catch (Exception ex) + { + Debug.WriteLine("运行环境检测失败: " + ex.Message); + UpdateStepProgress(100, "运行环境检测失败,程序将继续运行..."); + Thread.Sleep(2000); + } + } + + private bool CheckRuntimeInstalled(string rtConfig) + { + string detectionJson = ConfigManager.ExtractJsonObject(rtConfig, "detection"); + if (string.IsNullOrEmpty(detectionJson)) return false; + string detectionType = ConfigManager.ExtractJsonValue(detectionJson, "type"); + switch (detectionType) + { + case "registry": return CheckRegistryDetection(detectionJson); + case "dotnet_runtime": return CheckDotNetRuntimeDetection(detectionJson); + default: return false; + } + } + + private bool CheckRegistryDetection(string detectionJson) + { + string keysArray = ConfigManager.ExtractJsonArray(detectionJson, "keys"); + string valueName = ConfigManager.ExtractJsonValue(detectionJson, "value_name"); + int expectedValue = ConfigManager.ExtractJsonInt(detectionJson, "expected_value", 1); + if (string.IsNullOrEmpty(keysArray)) return false; + List keys = ConfigManager.ParseJsonStringArray(keysArray); + foreach (string keyPath in keys) + { + try + { + using (RegistryKey key = Registry.LocalMachine.OpenSubKey(keyPath)) + { + if (key != null) + { + object installed = key.GetValue(valueName); + if (installed != null && (int)installed == expectedValue) return true; + } + } + } + catch { } + } + return false; + } + + private bool CheckDotNetRuntimeDetection(string detectionJson) + { + string runtimeName = ConfigManager.ExtractJsonValue(detectionJson, "runtime_name"); + string version = ConfigManager.ExtractJsonValue(detectionJson, "version"); + try + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = "dotnet"; psi.Arguments = "--list-runtimes"; + psi.RedirectStandardOutput = true; psi.RedirectStandardError = true; + psi.UseShellExecute = false; psi.CreateNoWindow = true; + using (Process process = Process.Start(psi)) + { + if (process == null) return false; + string output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + string[] lines = output.Split('\n'); + foreach (string line in lines) + if (line.Contains(runtimeName) && line.Contains(version)) return true; + } + } + catch { } + return false; + } + + private async Task InstallRuntimeFromConfig(string rtConfig) + { + string displayName = ConfigManager.ExtractJsonValue(rtConfig, "display_name"); + bool is64 = Environment.Is64BitOperatingSystem; + string url = is64 ? ConfigManager.ExtractJsonValue(rtConfig, "download_url_x64") : ConfigManager.ExtractJsonValue(rtConfig, "download_url_x86"); + string arch = is64 ? "x64" : "x86"; + string filenameTemplate = ConfigManager.ExtractJsonValue(rtConfig, "temp_filename_template"); + string installArgs = ConfigManager.ExtractJsonValue(rtConfig, "install_args"); + string fileName = filenameTemplate.Replace("{arch}", arch).Replace("{version}", ConfigManager.ExtractJsonValue(ConfigManager.ExtractJsonObject(rtConfig, "detection"), "version")); + string filePath = ConfigManager.ResolveWritableDownloadPath( + Path.Combine(ConfigManager.GetWorkDirectory(), fileName)); + + try + { + string actualPath = await DownloadWithRetry(url, filePath, displayName); + + if (!ConfigManager.IsDownloadedFileValid(actualPath)) + throw new Exception(String.Format("{0}下载文件无效", displayName)); + + UpdateStepStatus(String.Format("正在安装{0}...", displayName)); + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = actualPath; + if (!string.IsNullOrEmpty(installArgs)) psi.Arguments = installArgs; + // 本程序的 manifest 已要求管理员权限,子进程会继承提权; + // 这里不再设置 Verb="runas",避免冗余的二次提权(杀软会将其当作可疑提权行为)。 + psi.UseShellExecute = true; + psi.WorkingDirectory = Path.GetDirectoryName(actualPath); + using (Process process = Process.Start(psi)) + { + if (process != null) + { + await Task.Run(new Action(process.WaitForExit)); + int exitCode = process.ExitCode; + if (exitCode != 0 && exitCode != 3010) + throw new Exception(String.Format("{0}安装失败,退出代码: {1}", displayName, exitCode)); + } + } + if (File.Exists(actualPath)) { try { await Task.Delay(500); File.Delete(actualPath); } catch { } } + return true; + } + catch (Exception ex) { Debug.WriteLine(String.Format("{0}安装失败: {1}", displayName, ex.Message)); return false; } + } + + #endregion + + #region Download with retry and resume + + private const int DOWNLOAD_MAX_RETRIES = 10; + private const int RETRY_DELAY_BASE_MS = 2000; + + private long speedCalcLastBytes = 0; + private DateTime speedCalcLastTime = DateTime.Now; + private double currentSpeed = 0; + + private class ChunkInfo + { + public int Index; + public long StartPos; + public long EndPos; + public long Downloaded; + } + + private string FormatFileSize(long bytes) + { + if (bytes >= 1073741824) return String.Format("{0:F2} GB", bytes / 1073741824.0); + if (bytes >= 1048576) return String.Format("{0:F2} MB", bytes / 1048576.0); + if (bytes >= 1024) return String.Format("{0:F2} KB", bytes / 1024.0); + return String.Format("{0} B", bytes); + } + + private string FormatSpeed(double bytesPerSecond) + { + if (bytesPerSecond <= 0) return ""; + if (bytesPerSecond >= 1073741824) return String.Format("{0:F2} GB/s", bytesPerSecond / 1073741824.0); + if (bytesPerSecond >= 1048576) return String.Format("{0:F2} MB/s", bytesPerSecond / 1048576.0); + if (bytesPerSecond >= 1024) return String.Format("{0:F2} KB/s", bytesPerSecond / 1024.0); + return String.Format("{0:F0} B/s", bytesPerSecond); + } + + /// + /// Top-level download with full retry logic. Never throws - retries until success. + /// + private async Task DownloadWithRetry(string url, string savePath, string displayName) + { + string downloadPath = ConfigManager.ResolveWritableDownloadPath(savePath); + + for (int attempt = 1; ; attempt++) + { + Exception caught = null; + try + { + ConfigManager.PrepareDownloadFile(downloadPath); + await DownloadFile(url, downloadPath, displayName); + return downloadPath; + } + catch (Exception ex) + { + caught = ex; + if (IsFileAccessError(ex) && attempt < DOWNLOAD_MAX_RETRIES) + downloadPath = ConfigManager.ResolveWritableDownloadPath(savePath); + } + + Debug.WriteLine(String.Format("下载失败 (第{0}次): {1}\nURL: {2}", attempt, caught.Message, url)); + if (attempt >= DOWNLOAD_MAX_RETRIES) + throw new Exception(String.Format("下载{0}失败,已重试{1}次: {2}", displayName, DOWNLOAD_MAX_RETRIES, caught.Message)); + + int delay = Math.Min(RETRY_DELAY_BASE_MS * attempt, 15000); + UpdateStepProgress(0, String.Format("下载中断({0}),{1}秒后第{2}次重试...", caught.Message, delay / 1000, attempt)); + await Task.Delay(delay); + } + } + + private static bool IsFileAccessError(Exception ex) + { + while (ex != null) + { + if (ex is UnauthorizedAccessException || ex is IOException) + return true; + ex = ex.InnerException; + } + return false; + } + + private class ProbeResult + { + public long FileSize = -1; + public bool SupportsRange = false; + } + + private async Task DownloadFile(string url, string savePath, string displayName) + { + speedCalcLastBytes = 0; + speedCalcLastTime = DateTime.Now; + currentSpeed = 0; + + if (maxThreads <= 1) + { + await DownloadSingleThreadNoRange(url, savePath, displayName); + return; + } + + ProbeResult probe = await ProbeUrl(url); + + if (probe.FileSize <= 0 || !probe.SupportsRange) + { + await DownloadSingleThreadNoRange(url, savePath, displayName); + return; + } + + long fileSize = probe.FileSize; + int chunkCount = maxThreads; + long chunkBytes = fileSize / chunkCount; + ChunkInfo[] allChunks = new ChunkInfo[chunkCount]; + for (int i = 0; i < chunkCount; i++) + { + allChunks[i] = new ChunkInfo + { + Index = i, + StartPos = (long)i * chunkBytes, + EndPos = (i == chunkCount - 1) ? fileSize - 1 : (long)i * chunkBytes + chunkBytes - 1, + Downloaded = 0, + }; + } + + if (File.Exists(savePath)) + { + try + { + if (new FileInfo(savePath).Length != fileSize) + { + File.Delete(savePath); + using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write)) + fs.SetLength(fileSize); + } + } + catch + { + if (File.Exists(savePath)) File.Delete(savePath); + using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write)) + fs.SetLength(fileSize); + } + } + else + { + using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write)) + fs.SetLength(fileSize); + } + + object progressLock = new object(); + Task[] workers = new Task[chunkCount]; + for (int i = 0; i < chunkCount; i++) + { + ChunkInfo chunk = allChunks[i]; + workers[i] = Task.Run(async () => + { + await DownloadChunkWithRetry(url, savePath, chunk, allChunks, progressLock, fileSize, displayName); + }); + } + await Task.WhenAll(workers); + } + + private async Task ProbeUrl(string url) + { + ProbeResult result = new ProbeResult(); + try + { + HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url); + req.Method = "HEAD"; + req.Timeout = 15000; + req.UserAgent = userAgent; + using (HttpWebResponse resp = (HttpWebResponse)await Task.Factory.FromAsync(req.BeginGetResponse, req.EndGetResponse, null)) + { + result.FileSize = resp.ContentLength; + string acceptRanges = resp.Headers["Accept-Ranges"]; + result.SupportsRange = !string.IsNullOrEmpty(acceptRanges) + && acceptRanges.IndexOf("none", StringComparison.OrdinalIgnoreCase) < 0; + } + } + catch { } + return result; + } + + private async Task DownloadChunkWithRetry(string url, string savePath, ChunkInfo chunk, ChunkInfo[] allChunks, object progressLock, long totalSize, string displayName) + { + int maxRetries = DOWNLOAD_MAX_RETRIES; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + HttpWebResponse response = null; + Stream responseStream = null; + FileStream fs = null; + + try + { + long resumeFrom = chunk.StartPos + chunk.Downloaded; + if (resumeFrom > chunk.EndPos) return; + + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); + request.AddRange(resumeFrom, chunk.EndPos); + request.Timeout = 60000; + request.ReadWriteTimeout = 60000; + request.KeepAlive = true; + request.UserAgent = userAgent; + + response = (HttpWebResponse)await Task.Factory.FromAsync(request.BeginGetResponse, request.EndGetResponse, null); + responseStream = response.GetResponseStream(); + + byte[] buffer = new byte[65536]; + fs = new FileStream(savePath, FileMode.Open, FileAccess.Write, FileShare.Write); + fs.Seek(resumeFrom, SeekOrigin.Begin); + + int bytesRead; + while ((bytesRead = await ReadStreamAsync(responseStream, buffer, 0, buffer.Length)) > 0) + { + await WriteStreamAsync(fs, buffer, 0, bytesRead); + chunk.Downloaded += bytesRead; + + lock (progressLock) + { + long totalDownloaded = 0; + foreach (ChunkInfo c in allChunks) totalDownloaded += c.Downloaded; + + DateTime now = DateTime.Now; + double elapsed = (now - speedCalcLastTime).TotalSeconds; + if (elapsed >= 0.3) + { + long delta = totalDownloaded - speedCalcLastBytes; + if (delta > 0) + { + double instantSpeed = delta / elapsed; + currentSpeed = currentSpeed <= 0 ? instantSpeed : currentSpeed * 0.3 + instantSpeed * 0.7; + } + speedCalcLastBytes = totalDownloaded; + speedCalcLastTime = now; + } + + int percent = (int)(totalDownloaded * 100 / totalSize); + string sizeInfo = String.Format("{0}/{1}", FormatFileSize(totalDownloaded), FormatFileSize(totalSize)); + string speedInfo = FormatSpeed(currentSpeed); + string rightLabel = string.IsNullOrEmpty(speedInfo) ? String.Format("{0}%", percent) : String.Format("{0}% {1}", percent, speedInfo); + UpdateStepProgress(percent, String.Format("正在下载{0} {1}", displayName, sizeInfo), rightLabel); + } + } + return; + } + catch (Exception ex) + { + Debug.WriteLine(String.Format("分片{0}下载失败(第{1}次): {2}", chunk.Index, attempt + 1, ex.Message)); + + WebException webEx = ex as WebException; + if (webEx == null && ex.InnerException != null) + webEx = ex.InnerException as WebException; + if (webEx != null && webEx.Response is HttpWebResponse) + { + HttpStatusCode code = ((HttpWebResponse)webEx.Response).StatusCode; + if (code == HttpStatusCode.NotFound || code == HttpStatusCode.Forbidden || code == HttpStatusCode.Unauthorized) + throw new Exception(String.Format("服务器返回 {0},URL: {1}", (int)code, url)); + } + } + finally + { + if (fs != null) { try { fs.Close(); fs.Dispose(); } catch { } } + if (responseStream != null) { try { responseStream.Close(); responseStream.Dispose(); } catch { } } + if (response != null) { try { response.Close(); } catch { } } + } + + if (attempt + 1 < maxRetries) + await Task.Delay(Math.Min(RETRY_DELAY_BASE_MS * (attempt + 1), 10000)); + } + + throw new Exception(String.Format("分片{0}下载失败,已达最大重试次数", chunk.Index)); + } + + private async Task DownloadSingleThreadNoRange(string url, string savePath, string displayName) + { + speedCalcLastBytes = 0; + speedCalcLastTime = DateTime.Now; + currentSpeed = 0; + + if (File.Exists(savePath)) { try { File.Delete(savePath); } catch { } } + + for (int attempt = 0; ; attempt++) + { + HttpWebResponse response = null; + Stream responseStream = null; + FileStream fs = null; + Exception caught = null; + + try + { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); + request.Timeout = 60000; + request.ReadWriteTimeout = 60000; + request.UserAgent = userAgent; + + response = (HttpWebResponse)await Task.Factory.FromAsync(request.BeginGetResponse, request.EndGetResponse, null); + + long totalSize = response.ContentLength > 0 ? response.ContentLength : 0; + fs = new FileStream(savePath, FileMode.Create, FileAccess.Write); + + responseStream = response.GetResponseStream(); + byte[] buffer = new byte[65536]; + long downloaded = 0; + + int bytesRead; + while ((bytesRead = await ReadStreamAsync(responseStream, buffer, 0, buffer.Length)) > 0) + { + await WriteStreamAsync(fs, buffer, 0, bytesRead); + downloaded += bytesRead; + + DateTime now = DateTime.Now; + double elapsed = (now - speedCalcLastTime).TotalSeconds; + if (elapsed >= 0.3) + { + long delta = downloaded - speedCalcLastBytes; + if (delta > 0) + { + double instantSpeed = delta / elapsed; + currentSpeed = currentSpeed <= 0 ? instantSpeed : currentSpeed * 0.3 + instantSpeed * 0.7; + } + speedCalcLastBytes = downloaded; + speedCalcLastTime = now; + } + + int percent = totalSize > 0 ? (int)(downloaded * 100 / totalSize) : 0; + string sizeInfo = totalSize > 0 ? String.Format("{0}/{1}", FormatFileSize(downloaded), FormatFileSize(totalSize)) : FormatFileSize(downloaded); + string speedInfo = FormatSpeed(currentSpeed); + string rightLabel = string.IsNullOrEmpty(speedInfo) ? String.Format("{0}%", percent) : String.Format("{0}% {1}", percent, speedInfo); + UpdateStepProgress(percent, String.Format("正在下载{0} {1}", displayName, sizeInfo), rightLabel); + } + + return; + } + catch (Exception ex) + { + caught = ex; + } + finally + { + if (fs != null) { try { fs.Close(); fs.Dispose(); } catch { } } + if (responseStream != null) { try { responseStream.Close(); responseStream.Dispose(); } catch { } } + if (response != null) { try { response.Close(); } catch { } } + } + + WebException singleWebEx = caught as WebException; + if (singleWebEx == null && caught.InnerException != null) + singleWebEx = caught.InnerException as WebException; + if (singleWebEx != null && singleWebEx.Response is HttpWebResponse) + { + HttpStatusCode code = ((HttpWebResponse)singleWebEx.Response).StatusCode; + if (code == HttpStatusCode.NotFound || code == HttpStatusCode.Forbidden || code == HttpStatusCode.Unauthorized) + throw new Exception(String.Format("服务器返回 {0},URL: {1}", (int)code, url)); + } + + Debug.WriteLine(String.Format("单线程下载失败(第{0}次): {1}", attempt + 1, caught.Message)); + if (attempt >= DOWNLOAD_MAX_RETRIES - 1) + throw caught; + + int delay = Math.Min(RETRY_DELAY_BASE_MS * (attempt + 1), 15000); + UpdateStepProgress(0, String.Format("下载中断({0}),{1}秒后重试...", caught.Message, delay / 1000)); + await Task.Delay(delay); + } + } + + private Task ReadStreamAsync(Stream stream, byte[] buffer, int offset, int count) + { + return Task.Factory.FromAsync( + (callback, state) => stream.BeginRead(buffer, offset, count, callback, state), + stream.EndRead, null); + } + + private Task WriteStreamAsync(Stream stream, byte[] buffer, int offset, int count) + { + return Task.Factory.FromAsync( + (callback, state) => stream.BeginWrite(buffer, offset, count, callback, state), + stream.EndWrite, null); + } + + #endregion + } +} diff --git a/R0Installer/Program.cs b/R0Installer/Program.cs new file mode 100644 index 0000000..9dc4626 --- /dev/null +++ b/R0Installer/Program.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using System.Net; +using System.Windows.Forms; + +namespace R0Installer +{ + public enum UpdateMode + { + MainProgram, + AntiCheat + } + + public enum InstallMode + { + Full, + ClientOnly, + AcOnly + } + + static class Program + { + [STAThread] + static void Main(string[] args) + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; + ServicePointManager.DefaultConnectionLimit = 64; + + string installerConfig = ConfigManager.GetInstallerConfig(); + + if (args.Length >= 2) + { + string command = args[0].ToLower(); + string currentVersion = args[1]; + + ConfigManager.MatchResult cmdMatch = new ConfigManager.MatchResult(); + if (installerConfig != null) + { + cmdMatch = ConfigManager.MatchCommand(installerConfig, command); + } + + if (!string.IsNullOrEmpty(cmdMatch.ProjectId)) + { + string projectConfig = ConfigManager.GetProjectConfigSync(cmdMatch.ProjectId); + if (projectConfig == null) + { + Console.WriteLine("ERROR: cannot load project config"); + return; + } + + string updateTarget = cmdMatch.UpdateTarget; + if (string.IsNullOrEmpty(updateTarget)) updateTarget = "update"; + + string updateConfig = ConfigManager.GetUpdateConfig(projectConfig, updateTarget); + if (string.IsNullOrEmpty(updateConfig)) + { + Console.WriteLine("ERROR: update target not found"); + return; + } + + string checkApi = ConfigManager.ExtractJsonValue(updateConfig, "check_api"); + if (string.IsNullOrEmpty(checkApi)) + { + Console.WriteLine("ERROR: check_api not configured"); + return; + } + + ConfigManager.UpdateSummary summary = ConfigManager.GetUpdateSummarySync(cmdMatch.ProjectId, checkApi, currentVersion); + + if (!summary.HasUpdate) + { + Console.WriteLine("OK"); + return; + } + + // 是否静默更新,优先级从高到低: + // 1) 命令行 --silent:调用方强制静默 + // 2) 云端按版本下发的 silent(/v3/update/check 响应里的 silent 字段,可开可关) + // 3) 命令规则 / 更新目标配置中的 silent(对该命令的所有版本生效) + bool silent; + if (HasSilentFlag(args)) + silent = true; + else if (summary.HasSilentField) + silent = summary.Silent; + else + silent = cmdMatch.Silent || ConfigManager.ExtractJsonBool(updateConfig, "silent"); + + if (silent) + { + new SilentUpdater(currentVersion, true, cmdMatch.ProjectId, updateTarget).Run(); + } + else + { + Application.Run(new UpdateForm(currentVersion, true, cmdMatch.ProjectId, updateTarget)); + } + } + else + { + RunInstallMode(installerConfig); + } + } + else + { + RunInstallMode(installerConfig); + } + } + + // 检测命令行中是否带有静默更新标志。支持 --silent / -s / /silent / /s 几种写法, + // 可出现在任意位置(通常作为第三个参数:R0Installer.exe update --silent)。 + private static bool HasSilentFlag(string[] args) + { + if (args == null) return false; + foreach (string arg in args) + { + if (string.IsNullOrEmpty(arg)) continue; + switch (arg.Trim().ToLowerInvariant()) + { + case "--silent": + case "-s": + case "/silent": + case "/s": + return true; + } + } + return false; + } + + private static void RunInstallMode(string installerConfig) + { + string projectId = "r0_arena"; + InstallMode mode = InstallMode.Full; + + if (installerConfig != null) + { + string exePath = System.Reflection.Assembly.GetExecutingAssembly().Location; + string exeName = Path.GetFileNameWithoutExtension(exePath); + + ConfigManager.MatchResult fileMatch = ConfigManager.MatchFilename(installerConfig, exeName); + + if (!string.IsNullOrEmpty(fileMatch.ProjectId)) + projectId = fileMatch.ProjectId; + + switch (fileMatch.InstallMode) + { + case "client_only": mode = InstallMode.ClientOnly; break; + case "ac_only": mode = InstallMode.AcOnly; break; + default: mode = InstallMode.Full; break; + } + } + else + { + try + { + string exePath = System.Reflection.Assembly.GetExecutingAssembly().Location; + string exeName = Path.GetFileNameWithoutExtension(exePath); + + if (exeName.StartsWith("R0ClientInstaller", StringComparison.OrdinalIgnoreCase)) + mode = InstallMode.ClientOnly; + else if (exeName.StartsWith("R0AcInstaller", StringComparison.OrdinalIgnoreCase)) + mode = InstallMode.AcOnly; + } + catch { } + } + + Application.Run(new MainForm(mode, projectId)); + } + } +} diff --git a/R0Installer/Properties/AssemblyInfo.cs b/R0Installer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d9ac4f1 --- /dev/null +++ b/R0Installer/Properties/AssemblyInfo.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("R0对战平台安装程序")] +[assembly: AssemblyDescription("R0对战平台环境检查与安装程序")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("R0 Arena")] +[assembly: AssemblyProduct("R0Installer")] +[assembly: AssemblyCopyright("Copyright © 2024")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: ComVisible(false)] +[assembly: Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] + diff --git a/R0Installer/Properties/Resources.Designer.cs b/R0Installer/Properties/Resources.Designer.cs new file mode 100644 index 0000000..af98b3d --- /dev/null +++ b/R0Installer/Properties/Resources.Designer.cs @@ -0,0 +1,39 @@ +namespace R0Installer.Properties { + using System; + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("R0Installer.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} + diff --git a/R0Installer/Properties/Resources.resx b/R0Installer/Properties/Resources.resx new file mode 100644 index 0000000..ac77426 --- /dev/null +++ b/R0Installer/Properties/Resources.resx @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + diff --git a/R0Installer/R0Installer.csproj b/R0Installer/R0Installer.csproj new file mode 100644 index 0000000..041bf90 --- /dev/null +++ b/R0Installer/R0Installer.csproj @@ -0,0 +1,58 @@ + + + + + Release + AnyCPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + WinExe + R0Installer + R0Installer + v4.8 + 512 + true + true + ..\icon.ico + app.manifest + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/R0Installer/R0Installer.sln b/R0Installer/R0Installer.sln new file mode 100644 index 0000000..925d3da --- /dev/null +++ b/R0Installer/R0Installer.sln @@ -0,0 +1,23 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "R0Installer", "R0Installer.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal + diff --git a/R0Installer/SilentUpdater.cs b/R0Installer/SilentUpdater.cs new file mode 100644 index 0000000..dbe4de1 --- /dev/null +++ b/R0Installer/SilentUpdater.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace R0Installer +{ + /// + /// 静默更新执行器:不创建任何窗口,直接在后台完成检查/下载/应用更新。 + /// 复用与 UpdateForm 完全相同的配置加载与 UpdateManager 逻辑, + /// 仅去除界面展示与纯用于观感的等待,因此更新行为与有界面模式一致。 + /// + public class SilentUpdater + { + private readonly string currentVersion; + private readonly bool updateConfirmed; + private readonly string projectId; + private readonly string updateTarget; + private readonly string installPath; + + private UpdateManager updateManager; + private string projectConfig; + private string updateConfig; + + private string[] processesToClose = new string[0]; + private string launchAfterUpdate = ""; + private string holdFile = ""; + + public SilentUpdater(string version, bool updateConfirmed, string projectId, string updateTarget) + { + this.currentVersion = version; + this.updateConfirmed = updateConfirmed; + this.projectId = projectId; + this.updateTarget = updateTarget; + this.installPath = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + + LoadConfig(); + } + + /// 同步入口:阻塞当前线程直到静默更新完成,返回是否成功。 + public bool Run() + { + return RunAsync().GetAwaiter().GetResult(); + } + + private void LoadConfig() + { + projectConfig = ConfigManager.GetProjectConfigSync(projectId); + + if (projectConfig != null) + updateConfig = ConfigManager.GetUpdateConfig(projectConfig, updateTarget); + + if (!string.IsNullOrEmpty(updateConfig)) + { + launchAfterUpdate = ConfigManager.ExtractJsonValue(updateConfig, "launch_after_update"); + holdFile = ConfigManager.ExtractJsonValue(updateConfig, "hold_file"); + + string procArray = ConfigManager.ExtractJsonArray(updateConfig, "processes_to_close"); + if (!string.IsNullOrEmpty(procArray)) + processesToClose = ConfigManager.ParseJsonStringArray(procArray).ToArray(); + + updateManager = new UpdateManager(updateConfig, projectConfig); + } + else + { + string fallbackUpdateJson = "{\"check_api\":\"/v3/update/check?project=" + projectId + "&v={0}\",\"temp_filename_template\":\"update_v{version}.exe\"}"; + updateManager = new UpdateManager(fallbackUpdateJson, "{}"); + } + + // 静默模式下没有界面,进度/状态仅写入调试输出,便于排查问题。 + updateManager.OnProgress = (percent, message, speed) => + Debug.WriteLine(String.Format("[静默更新] {0}% {1} {2}", percent, message, speed)); + updateManager.OnStatus = (message) => Debug.WriteLine("[静默更新] " + message); + } + + public async Task RunAsync() + { + bool hasHoldFile = !string.IsNullOrEmpty(holdFile) && holdFile != "null"; + try + { + UpdateCheckResult result = await updateManager.CheckForUpdate(currentVersion); + + if (result == null) + { + Console.WriteLine("ERROR"); + return false; + } + + if (!updateConfirmed && !result.has_update) + { + Console.WriteLine("OK"); + return true; + } + + if (hasHoldFile) CreateHoldFile(); + + CloseProcesses(); + // 给系统一点时间释放文件句柄,确保增量补丁能成功覆盖文件。 + await Task.Delay(500); + + bool success = false; + + switch (result.update_type) + { + case "full": + // 全量包(Inno Setup)静默安装。 + success = await updateManager.PerformFullUpdate(result, true); + break; + + case "incremental": + success = await updateManager.PerformIncrementalUpdate(result.download_url, installPath); + break; + + case "multi_incremental": + success = await updateManager.PerformMultiIncrementalUpdate(result.incremental_updates, installPath); + break; + + default: + Debug.WriteLine("未知的更新类型: " + result.update_type); + if (hasHoldFile) DeleteHoldFile(); + Console.WriteLine("ERROR"); + return false; + } + + // 无论全量/增量,静默更新由本程序负责全流程,完成后统一清理 hold 文件并拉起主程序。 + if (hasHoldFile) DeleteHoldFile(); + + if (success) + LaunchMainProgram(); + + Console.WriteLine(success ? "OK" : "ERROR"); + return success; + } + catch (Exception ex) + { + Debug.WriteLine("静默更新出错: " + ex); + if (hasHoldFile) DeleteHoldFile(); + Console.WriteLine("ERROR: " + ex.Message); + return false; + } + } + + private void CloseProcesses() + { + foreach (string name in processesToClose) + { + try + { + Process[] processes = Process.GetProcessesByName(name); + foreach (Process proc in processes) + { + try + { + proc.Kill(); + proc.WaitForExit(5000); + } + catch (Exception ex) + { + Debug.WriteLine("关闭进程失败: " + ex.Message); + } + finally + { + proc.Dispose(); + } + } + } + catch (Exception ex) + { + Debug.WriteLine("获取进程失败: " + ex.Message); + } + } + } + + private string GetHoldFilePath() + { + return Path.Combine(Path.GetTempPath(), holdFile); + } + + private void CreateHoldFile() + { + try + { + File.WriteAllText(GetHoldFilePath(), DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + } + catch (Exception ex) + { + Debug.WriteLine("创建 hold 文件失败: " + ex.Message); + } + } + + private void DeleteHoldFile() + { + try + { + string holdPath = GetHoldFilePath(); + if (File.Exists(holdPath)) + File.Delete(holdPath); + } + catch (Exception ex) + { + Debug.WriteLine("删除 hold 文件失败: " + ex.Message); + } + } + + private void LaunchMainProgram() + { + if (string.IsNullOrEmpty(launchAfterUpdate)) return; + + try + { + string mainExe = Path.Combine(installPath, launchAfterUpdate); + if (File.Exists(mainExe)) + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = mainExe; + psi.UseShellExecute = true; + psi.WorkingDirectory = installPath; + Process.Start(psi); + } + else + { + Debug.WriteLine("程序未找到: " + mainExe); + } + } + catch (Exception ex) + { + Debug.WriteLine("启动程序失败: " + ex.Message); + } + } + } +} diff --git a/R0Installer/UpdateForm.cs b/R0Installer/UpdateForm.cs new file mode 100644 index 0000000..5b62231 --- /dev/null +++ b/R0Installer/UpdateForm.cs @@ -0,0 +1,492 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.IO; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace R0Installer +{ + public class UpdateForm : Form + { + private Panel progressBar; + private Panel progressFill; + private Label titleLabel; + private Label statusLabel; + private Label percentLabel; + private Label versionLabel; + + private string currentVersion; + private UpdateManager updateManager; + + private readonly Color primaryGreen = Color.FromArgb(76, 175, 80); + private readonly Color darkBg = Color.FromArgb(18, 18, 18); + private readonly Color panelBg = Color.FromArgb(28, 28, 28); + private readonly Color textGray = Color.FromArgb(180, 180, 180); + private readonly Color textDark = Color.FromArgb(100, 100, 100); + + private bool isDragging = false; + private Point dragStart; + + private string installPath; + private bool updateConfirmed; + + private string projectId; + private string updateTarget; + private string projectConfig; + private string updateConfig; + + private string windowTitle; + private string[] processesToClose; + private string launchAfterUpdate; + private string holdFile; + + public UpdateForm(string version, bool updateConfirmed, string projectId, string updateTarget) + { + this.currentVersion = version; + this.updateConfirmed = updateConfirmed; + this.projectId = projectId; + this.updateTarget = updateTarget; + + this.installPath = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + + LoadConfig(); + + InitializeComponent(); + SetupUI(); + + this.Load += UpdateForm_Load; + } + + private void LoadConfig() + { + projectConfig = ConfigManager.GetProjectConfigSync(projectId); + + if (projectConfig != null) + { + updateConfig = ConfigManager.GetUpdateConfig(projectConfig, updateTarget); + } + + if (updateConfig != null) + { + windowTitle = ConfigManager.ExtractJsonValue(updateConfig, "window_title"); + launchAfterUpdate = ConfigManager.ExtractJsonValue(updateConfig, "launch_after_update"); + holdFile = ConfigManager.ExtractJsonValue(updateConfig, "hold_file"); + + string procArray = ConfigManager.ExtractJsonArray(updateConfig, "processes_to_close"); + if (!string.IsNullOrEmpty(procArray)) + { + List procs = ConfigManager.ParseJsonStringArray(procArray); + processesToClose = procs.ToArray(); + } + + this.updateManager = new UpdateManager(updateConfig, projectConfig); + } + else + { + windowTitle = "更新程序"; + processesToClose = new string[0]; + launchAfterUpdate = ""; + holdFile = ""; + + string fallbackUpdateJson = "{\"check_api\":\"/v3/update/check?project=" + projectId + "&v={0}\",\"temp_filename_template\":\"update_v{version}.exe\"}"; + string fallbackProjectJson = "{}"; + this.updateManager = new UpdateManager(fallbackUpdateJson, fallbackProjectJson); + } + + if (string.IsNullOrEmpty(windowTitle)) windowTitle = "更新程序"; + if (processesToClose == null) processesToClose = new string[0]; + + this.updateManager.OnProgress = OnProgressUpdate; + this.updateManager.OnStatus = OnStatusUpdate; + } + + private async void UpdateForm_Load(object sender, EventArgs e) + { + await StartUpdateProcess(); + } + + private void InitializeComponent() + { + this.SuspendLayout(); + + this.Text = windowTitle; + this.Size = new Size(500, 200); + this.StartPosition = FormStartPosition.CenterScreen; + this.FormBorderStyle = FormBorderStyle.None; + this.BackColor = darkBg; + this.DoubleBuffered = true; + + try + { + this.Icon = Icon.ExtractAssociatedIcon(System.Reflection.Assembly.GetExecutingAssembly().Location); + } + catch { } + + this.Region = CreateRoundedRegion(this.Width, this.Height, 12); + + this.MouseDown += Form_MouseDown; + this.MouseMove += Form_MouseMove; + this.MouseUp += Form_MouseUp; + + this.ResumeLayout(false); + } + + private Region CreateRoundedRegion(int width, int height, int radius) + { + GraphicsPath path = new GraphicsPath(); + path.AddArc(0, 0, radius * 2, radius * 2, 180, 90); + path.AddArc(width - radius * 2, 0, radius * 2, radius * 2, 270, 90); + path.AddArc(width - radius * 2, height - radius * 2, radius * 2, radius * 2, 0, 90); + path.AddArc(0, height - radius * 2, radius * 2, radius * 2, 90, 90); + path.CloseAllFigures(); + return new Region(path); + } + + private void SetupUI() + { + Label closeBtn = new Label(); + closeBtn.Text = "×"; + closeBtn.Font = new Font("Segoe UI", 14, FontStyle.Regular); + closeBtn.ForeColor = textGray; + closeBtn.Size = new Size(36, 36); + closeBtn.Location = new Point(this.Width - 40, 4); + closeBtn.TextAlign = ContentAlignment.MiddleCenter; + closeBtn.Cursor = Cursors.Hand; + closeBtn.Click += CloseBtn_Click; + closeBtn.MouseEnter += (s, e) => ((Label)s).ForeColor = Color.White; + closeBtn.MouseLeave += (s, e) => ((Label)s).ForeColor = textGray; + this.Controls.Add(closeBtn); + + titleLabel = new Label(); + titleLabel.Text = windowTitle.Replace("程序", ""); + titleLabel.Font = new Font("Microsoft YaHei", 16, FontStyle.Bold); + titleLabel.ForeColor = primaryGreen; + titleLabel.AutoSize = true; + titleLabel.BackColor = Color.Transparent; + titleLabel.Location = new Point((this.Width - 180) / 2, 30); + this.Controls.Add(titleLabel); + + versionLabel = new Label(); + versionLabel.Text = "当前版本: " + currentVersion; + versionLabel.Font = new Font("Microsoft YaHei", 9, FontStyle.Regular); + versionLabel.ForeColor = textGray; + versionLabel.AutoSize = true; + versionLabel.Location = new Point(40, 75); + this.Controls.Add(versionLabel); + + statusLabel = new Label(); + statusLabel.Text = "正在检查更新..."; + statusLabel.Font = new Font("Microsoft YaHei", 9, FontStyle.Regular); + statusLabel.ForeColor = textGray; + statusLabel.AutoSize = false; + statusLabel.Size = new Size(300, 25); + statusLabel.Location = new Point(40, 110); + this.Controls.Add(statusLabel); + + percentLabel = new Label(); + percentLabel.Text = ""; + percentLabel.Font = new Font("Microsoft YaHei", 9, FontStyle.Bold); + percentLabel.ForeColor = primaryGreen; + percentLabel.AutoSize = false; + percentLabel.Size = new Size(160, 25); + percentLabel.TextAlign = ContentAlignment.MiddleRight; + percentLabel.Location = new Point(this.Width - 200, 110); + this.Controls.Add(percentLabel); + + progressBar = new Panel(); + progressBar.Size = new Size(this.Width - 80, 8); + progressBar.Location = new Point(40, 140); + progressBar.BackColor = panelBg; + this.Controls.Add(progressBar); + + progressFill = new Panel(); + progressFill.Size = new Size(0, 8); + progressFill.Location = new Point(0, 0); + progressFill.BackColor = primaryGreen; + progressBar.Controls.Add(progressFill); + + this.Paint += UpdateForm_Paint; + } + + private void UpdateForm_Paint(object sender, PaintEventArgs e) + { + Graphics g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + + using (Pen pen = new Pen(Color.FromArgb(50, 255, 255, 255), 1)) + { + Rectangle rect = new Rectangle(0, 0, this.Width - 1, this.Height - 1); + using (GraphicsPath path = CreateRoundedPath(rect, 12)) + { + g.DrawPath(pen, path); + } + } + } + + private GraphicsPath CreateRoundedPath(Rectangle rect, int radius) + { + GraphicsPath path = new GraphicsPath(); + path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90); + path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90); + path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90); + path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90); + path.CloseAllFigures(); + return path; + } + + #region Window events + + private void CloseBtn_Click(object sender, EventArgs e) { Application.Exit(); } + + private void Form_MouseDown(object sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) { isDragging = true; dragStart = e.Location; } + } + + private void Form_MouseMove(object sender, MouseEventArgs e) + { + if (isDragging) + { + Point diff = new Point(e.X - dragStart.X, e.Y - dragStart.Y); + this.Location = new Point(this.Location.X + diff.X, this.Location.Y + diff.Y); + } + } + + private void Form_MouseUp(object sender, MouseEventArgs e) { isDragging = false; } + + #endregion + + #region Progress + + private void OnProgressUpdate(int percent, string message, string speedText) + { + if (this.InvokeRequired) { this.Invoke(new Action(OnProgressUpdate), percent, message, speedText); return; } + + int targetWidth = (int)(progressBar.Width * percent / 100.0); + progressFill.Width = targetWidth; + + if (message != null) + statusLabel.Text = message; + + if (!string.IsNullOrEmpty(speedText)) + percentLabel.Text = String.Format("{0}% {1}", percent, speedText); + else + percentLabel.Text = String.Format("{0}%", percent); + } + + private void OnStatusUpdate(string message) + { + if (this.InvokeRequired) { this.Invoke(new Action(OnStatusUpdate), message); return; } + statusLabel.Text = message; + } + + private void UpdateVersionLabel(string newVersion) + { + if (this.InvokeRequired) { this.Invoke(new Action(UpdateVersionLabel), newVersion); return; } + versionLabel.Text = String.Format("更新: {0} → {1}", currentVersion, newVersion); + } + + #endregion + + #region Update process + + private async Task StartUpdateProcess() + { + try + { + OnStatusUpdate("正在获取更新信息..."); + UpdateCheckResult result = await updateManager.CheckForUpdate(currentVersion); + + if (result == null) + { + OnStatusUpdate("获取更新信息失败,请检查网络连接"); + Console.WriteLine("ERROR"); + await Task.Delay(3000); + Application.Exit(); + return; + } + + if (!updateConfirmed && !result.has_update) + { + OnStatusUpdate("当前已是最新版本"); + Console.WriteLine("OK"); + Application.Exit(); + return; + } + + bool hasHoldFile = !string.IsNullOrEmpty(holdFile) && holdFile != "null"; + if (hasHoldFile) + { + CreateHoldFile(); + } + + OnStatusUpdate("正在关闭相关程序..."); + CloseProcesses(); + await Task.Delay(500); + + UpdateVersionLabel(result.latest_version); + + bool success = false; + bool isIncrementalUpdate = false; + + switch (result.update_type) + { + case "full": + OnStatusUpdate("正在准备全量更新..."); + success = await updateManager.PerformFullUpdate(result); + if (success) + { + OnProgressUpdate(100, "更新完成,正在启动安装程序...", ""); + await Task.Delay(1000); + } + break; + + case "incremental": + isIncrementalUpdate = true; + OnStatusUpdate("正在准备增量更新..."); + success = await updateManager.PerformIncrementalUpdate(result.download_url, installPath); + if (success) + { + OnProgressUpdate(100, "更新完成!", ""); + await Task.Delay(1000); + if (hasHoldFile) DeleteHoldFile(); + LaunchMainProgram(); + } + break; + + case "multi_incremental": + isIncrementalUpdate = true; + OnStatusUpdate("正在准备增量更新..."); + success = await updateManager.PerformMultiIncrementalUpdate(result.incremental_updates, installPath); + if (success) + { + OnProgressUpdate(100, "更新完成!", ""); + await Task.Delay(1000); + if (hasHoldFile) DeleteHoldFile(); + LaunchMainProgram(); + } + break; + + default: + OnStatusUpdate("未知的更新类型: " + result.update_type); + await Task.Delay(3000); + break; + } + + if (!success) + { + OnStatusUpdate("更新失败,请稍后重试"); + if (hasHoldFile && isIncrementalUpdate) DeleteHoldFile(); + await Task.Delay(3000); + } + + Application.Exit(); + } + catch (Exception ex) + { + OnStatusUpdate("更新出错: " + ex.Message); + Debug.WriteLine("更新错误: " + ex.ToString()); + Console.WriteLine("ERROR: " + ex.Message); + System.Threading.Thread.Sleep(5000); + Application.Exit(); + } + } + + private void CloseProcesses() + { + foreach (string name in processesToClose) + { + try + { + Process[] processes = Process.GetProcessesByName(name); + foreach (Process proc in processes) + { + try + { + proc.Kill(); + proc.WaitForExit(5000); + } + catch (Exception ex) + { + Debug.WriteLine("关闭进程失败: " + ex.Message); + } + finally + { + proc.Dispose(); + } + } + } + catch (Exception ex) + { + Debug.WriteLine("获取进程失败: " + ex.Message); + } + } + } + + private string GetHoldFilePath() + { + return Path.Combine(Path.GetTempPath(), holdFile); + } + + private void CreateHoldFile() + { + try + { + string holdPath = GetHoldFilePath(); + File.WriteAllText(holdPath, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + } + catch (Exception ex) + { + Debug.WriteLine("创建 hold 文件失败: " + ex.Message); + } + } + + private void DeleteHoldFile() + { + try + { + string holdPath = GetHoldFilePath(); + if (File.Exists(holdPath)) + { + File.Delete(holdPath); + } + } + catch (Exception ex) + { + Debug.WriteLine("删除 hold 文件失败: " + ex.Message); + } + } + + private void LaunchMainProgram() + { + if (string.IsNullOrEmpty(launchAfterUpdate)) return; + + try + { + string mainExe = Path.Combine(installPath, launchAfterUpdate); + + if (File.Exists(mainExe)) + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = mainExe; + psi.UseShellExecute = true; + psi.WorkingDirectory = installPath; + Process.Start(psi); + } + else + { + Debug.WriteLine("程序未找到: " + mainExe); + } + } + catch (Exception ex) + { + Debug.WriteLine("启动程序失败: " + ex.Message); + } + } + + #endregion + } +} diff --git a/R0Installer/UpdateManager.cs b/R0Installer/UpdateManager.cs new file mode 100644 index 0000000..3e9d2bf --- /dev/null +++ b/R0Installer/UpdateManager.cs @@ -0,0 +1,944 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace R0Installer +{ + public class UpdateManager + { + private string checkApiTemplate; + private string userAgent; + private int maxThreads; + private string tempFilenameTemplate; + private string fullSilentArgs; + + public Action OnProgress; + public Action OnStatus; + + private long speedCalcLastBytes = 0; + private DateTime speedCalcLastTime = DateTime.Now; + private double currentSpeed = 0; + + private string hpatchzPath; + + public UpdateManager(string updateConfigJson, string projectConfigJson) + { + checkApiTemplate = ConfigManager.ExtractJsonValue(updateConfigJson, "check_api"); + tempFilenameTemplate = ConfigManager.ExtractJsonValue(updateConfigJson, "temp_filename_template"); + + string downloadConfig = ConfigManager.ExtractJsonObject(projectConfigJson, "download_config"); + if (!string.IsNullOrEmpty(downloadConfig)) + { + maxThreads = ConfigManager.ExtractJsonInt(downloadConfig, "max_threads", 8); + userAgent = ConfigManager.ExtractJsonValue(downloadConfig, "user_agent"); + } + else + { + maxThreads = 8; + } + + if (string.IsNullOrEmpty(userAgent)) userAgent = "r0_installer"; + if (string.IsNullOrEmpty(tempFilenameTemplate)) tempFilenameTemplate = "update_v{version}.exe"; + + // 全量安装包(Inno Setup 打包)静默安装参数,可由更新配置覆盖。 + // /VERYSILENT 完全无界面;/SUPPRESSMSGBOXES 抑制弹窗;/NORESTART 安装后不自动重启。 + fullSilentArgs = ConfigManager.ExtractJsonValue(updateConfigJson, "silent_install_args"); + if (string.IsNullOrEmpty(fullSilentArgs)) fullSilentArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART"; + + string exeDir = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + hpatchzPath = Path.Combine(exeDir, "hpatchz.exe"); + } + + #region Version check + + public async Task CheckForUpdate(string currentVersion) + { + try + { + string apiUrl = "https://a-p-i.r0csgo.com" + String.Format(checkApiTemplate, Uri.EscapeDataString(currentVersion)); + + using (WebClient client = new WebClient()) + { + client.Encoding = Encoding.UTF8; + client.Headers.Add("User-Agent", userAgent); + string json = await client.DownloadStringTaskAsync(apiUrl); + + string data = ConfigManager.ExtractJsonObject(json, "data"); + if (!string.IsNullOrEmpty(data)) + return ParseUpdateCheckResult(data); + + return ParseUpdateCheckResult(json); + } + } + catch (Exception ex) + { + Debug.WriteLine("检查更新失败: " + ex.Message); + return null; + } + } + + private UpdateCheckResult ParseUpdateCheckResult(string json) + { + UpdateCheckResult result = new UpdateCheckResult(); + + result.has_update = ConfigManager.ExtractJsonBool(json, "has_update"); + result.silent = ConfigManager.ExtractJsonBool(json, "silent"); + result.latest_version = ConfigManager.ExtractJsonValue(json, "latest_version"); + result.current_version = ConfigManager.ExtractJsonValue(json, "current_version"); + result.update_type = ConfigManager.ExtractJsonValue(json, "update_type"); + string rawUrl = ConfigManager.ExtractJsonValue(json, "download_url"); + result.download_url = string.IsNullOrEmpty(rawUrl) ? "" : rawUrl.Replace("\\/", "/"); + result.file_size = ConfigManager.ExtractJsonValue(json, "file_size"); + result.changelog = ConfigManager.ExtractJsonValue(json, "changelog"); + result.total_size = ConfigManager.ExtractJsonValue(json, "total_size"); + + string threadsStr = ConfigManager.ExtractJsonValue(json, "threads"); + if (!string.IsNullOrEmpty(threadsStr)) + { + int parsedThreads; + if (int.TryParse(threadsStr, out parsedThreads) && parsedThreads > 0) + { + maxThreads = parsedThreads; + } + } + + if (result.update_type == "multi_incremental") + { + result.incremental_updates = ParseIncrementalUpdates(json); + } + + return result; + } + + private List ParseIncrementalUpdates(string json) + { + List updates = new List(); + + string arrayJson = ConfigManager.ExtractJsonArray(json, "incremental_updates"); + if (string.IsNullOrEmpty(arrayJson)) return updates; + + List objects = ConfigManager.ParseJsonObjectArray(arrayJson); + foreach (string objJson in objects) + { + IncrementalUpdate update = new IncrementalUpdate(); + update.from_version = ConfigManager.ExtractJsonValue(objJson, "from_version"); + update.to_version = ConfigManager.ExtractJsonValue(objJson, "to_version"); + string rawUpdateUrl = ConfigManager.ExtractJsonValue(objJson, "download_url"); + update.download_url = string.IsNullOrEmpty(rawUpdateUrl) ? "" : rawUpdateUrl.Replace("\\/", "/"); + update.file_size = ConfigManager.ExtractJsonValue(objJson, "file_size"); + update.changelog = ConfigManager.ExtractJsonValue(objJson, "changelog"); + updates.Add(update); + } + + return updates; + } + + #endregion + + #region Full update + + public async Task PerformFullUpdate(UpdateCheckResult updateInfo) + { + return await PerformFullUpdate(updateInfo, false); + } + + public async Task PerformFullUpdate(UpdateCheckResult updateInfo, bool silent) + { + try + { + ReportStatus("正在下载完整安装包..."); + + string filename = tempFilenameTemplate.Replace("{version}", updateInfo.latest_version); + string tempPath = ConfigManager.ResolveWritableDownloadPath( + Path.Combine(ConfigManager.GetWorkDirectory(), filename)); + + string actualPath = await DownloadFileMultiThread(updateInfo.download_url, tempPath, "安装包"); + + if (!ConfigManager.IsDownloadedFileValid(actualPath)) + throw new Exception("下载的安装包无效"); + + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = actualPath; + psi.UseShellExecute = true; + psi.WorkingDirectory = Path.GetDirectoryName(actualPath); + + if (silent) + { + // Inno Setup 安装包静默安装:传入 /VERYSILENT 等参数,等待安装结束。 + // 本程序 manifest 已要求管理员权限,子进程继承提权,Inno 不会再二次提权重启, + // 因此 WaitForExit 能准确反映安装完成。 + ReportStatus("正在静默安装更新..."); + psi.Arguments = fullSilentArgs; + + using (Process process = Process.Start(psi)) + { + if (process != null) + { + await Task.Run(new Action(process.WaitForExit)); + int exitCode = process.ExitCode; + // Inno Setup 退出码:0=成功,3010=成功但需重启。 + if (exitCode != 0 && exitCode != 3010) + throw new Exception(String.Format("静默安装失败,退出代码: {0}", exitCode)); + } + } + + ReportProgress(100, "安装完成"); + } + else + { + ReportStatus("正在启动安装程序..."); + Process.Start(psi); + } + + return true; + } + catch (Exception ex) + { + Debug.WriteLine("全量更新失败: " + ex.Message); + throw; + } + } + + #endregion + + #region Incremental update + + public async Task PerformIncrementalUpdate(string downloadUrl, string installPath) + { + try + { + ReportStatus("正在下载增量更新包..."); + string workDir = ConfigManager.GetWorkDirectory(); + string tempZipPath = Path.Combine(workDir, "r0_patch_" + Guid.NewGuid().ToString("N") + ".zip"); + + string actualZipPath = await DownloadFileMultiThread(downloadUrl, tempZipPath, "增量包"); + + ReportStatus("正在解压增量包..."); + string extractPath = Path.Combine(workDir, "r0_patch_" + Guid.NewGuid().ToString("N")); + ZipFile.ExtractToDirectory(actualZipPath, extractPath); + + ReportStatus("正在应用更新..."); + bool success = await ApplyPatch(extractPath, installPath); + + try + { + if (File.Exists(actualZipPath)) + File.Delete(actualZipPath); + if (Directory.Exists(extractPath)) + Directory.Delete(extractPath, true); + } + catch { } + + return success; + } + catch (Exception ex) + { + Debug.WriteLine("增量更新失败: " + ex.Message); + throw; + } + } + + public async Task PerformMultiIncrementalUpdate(List updates, string installPath) + { + for (int i = 0; i < updates.Count; i++) + { + IncrementalUpdate update = updates[i]; + ReportStatus(String.Format("正在应用更新 {0}/{1}: {2} -> {3}", i + 1, updates.Count, update.from_version, update.to_version)); + + bool success = await PerformIncrementalUpdate(update.download_url, installPath); + if (!success) + { + return false; + } + } + return true; + } + + private async Task ApplyPatch(string patchPath, string installPath) + { + try + { + string manifestPath = Path.Combine(patchPath, "patch.json"); + if (!File.Exists(manifestPath)) + { + throw new Exception("增量包格式错误:找不到 patch.json"); + } + + string manifestJson = File.ReadAllText(manifestPath, Encoding.UTF8); + PatchManifest manifest = ParsePatchManifest(manifestJson); + + int totalOperations = manifest.modified.Count + manifest.new_files.Count + manifest.deleted.Count; + int currentOperation = 0; + + foreach (PatchDiffInfo diff in manifest.modified) + { + currentOperation++; + int percent = (int)(currentOperation * 100.0 / totalOperations); + ReportProgress(percent, "正在更新: " + diff.path); + + string targetFile = Path.Combine(installPath, diff.path); + string patchFile = Path.Combine(patchPath, diff.patch_file); + + if (!File.Exists(targetFile)) + { + Debug.WriteLine("警告: 目标文件不存在,跳过: " + diff.path); + continue; + } + + if (!File.Exists(patchFile)) + { + Debug.WriteLine("警告: 补丁文件不存在,跳过: " + diff.path); + continue; + } + + string currentHash = CalculateMD5(targetFile); + if (currentHash != diff.old_hash) + { + Debug.WriteLine("警告: 原文件hash不匹配,跳过: " + diff.path); + continue; + } + + string tempOutputFile = targetFile + ".new"; + bool patchSuccess = await ApplyHDiff(targetFile, patchFile, tempOutputFile); + + if (patchSuccess && File.Exists(tempOutputFile)) + { + string newHash = CalculateMD5(tempOutputFile); + if (newHash == diff.new_hash) + { + File.Delete(targetFile); + File.Move(tempOutputFile, targetFile); + } + else + { + Debug.WriteLine("警告: 新文件hash不匹配: " + diff.path); + File.Delete(tempOutputFile); + } + } + else + { + Debug.WriteLine("警告: 应用补丁失败: " + diff.path); + if (File.Exists(tempOutputFile)) + File.Delete(tempOutputFile); + } + } + + string newFilesDir = Path.Combine(patchPath, "new"); + foreach (PatchFileInfo newFile in manifest.new_files) + { + currentOperation++; + int percent = (int)(currentOperation * 100.0 / totalOperations); + ReportProgress(percent, "正在添加: " + newFile.path); + + string sourceFile = Path.Combine(newFilesDir, newFile.path); + string targetFile = Path.Combine(installPath, newFile.path); + + if (File.Exists(sourceFile)) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + + if (File.Exists(targetFile)) + File.Delete(targetFile); + + File.Copy(sourceFile, targetFile, true); + } + else + { + Debug.WriteLine("警告: 新文件不存在于增量包: " + newFile.path); + } + } + + foreach (string deletePath in manifest.deleted) + { + currentOperation++; + int percent = (int)(currentOperation * 100.0 / totalOperations); + ReportProgress(percent, "正在删除: " + deletePath); + + string targetFile = Path.Combine(installPath, deletePath); + if (File.Exists(targetFile)) + { + try + { + File.Delete(targetFile); + } + catch (Exception ex) + { + Debug.WriteLine("警告: 删除文件失败: " + deletePath + " - " + ex.Message); + } + } + } + + ReportProgress(100, "更新完成"); + return true; + } + catch (Exception ex) + { + Debug.WriteLine("应用增量补丁失败: " + ex.Message); + throw; + } + } + + private async Task ApplyHDiff(string oldFile, string patchFile, string outputFile) + { + if (!File.Exists(hpatchzPath)) + { + string altPath = Path.Combine(Path.GetDirectoryName(oldFile), "hpatchz.exe"); + if (File.Exists(altPath)) + { + hpatchzPath = altPath; + } + else + { + Debug.WriteLine("hpatchz.exe 未找到"); + return false; + } + } + + try + { + return await Task.Run(() => + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = hpatchzPath; + psi.Arguments = String.Format("\"{0}\" \"{1}\" \"{2}\"", oldFile, patchFile, outputFile); + psi.UseShellExecute = false; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.CreateNoWindow = true; + + using (Process process = Process.Start(psi)) + { + process.WaitForExit(60000); + return process.ExitCode == 0; + } + }); + } + catch (Exception ex) + { + Debug.WriteLine("hpatchz执行失败: " + ex.Message); + return false; + } + } + + private PatchManifest ParsePatchManifest(string json) + { + PatchManifest manifest = new PatchManifest(); + + manifest.from_version = ConfigManager.ExtractJsonValue(json, "from_version"); + manifest.to_version = ConfigManager.ExtractJsonValue(json, "to_version"); + + manifest.modified = ParseModifiedArray(json); + manifest.new_files = ParseNewFilesArray(json); + manifest.deleted = ParseDeletedArray(json); + + return manifest; + } + + private List ParseModifiedArray(string json) + { + List list = new List(); + + string arrayJson = ConfigManager.ExtractJsonArray(json, "modified"); + if (string.IsNullOrEmpty(arrayJson)) return list; + + List objects = ConfigManager.ParseJsonObjectArray(arrayJson); + foreach (string objJson in objects) + { + PatchDiffInfo info = new PatchDiffInfo(); + info.path = ConfigManager.ExtractJsonValue(objJson, "path"); + info.patch_file = ConfigManager.ExtractJsonValue(objJson, "patch_file"); + info.old_hash = ConfigManager.ExtractJsonValue(objJson, "old_hash"); + info.new_hash = ConfigManager.ExtractJsonValue(objJson, "new_hash"); + long.TryParse(ConfigManager.ExtractJsonValue(objJson, "new_size"), out info.new_size); + list.Add(info); + } + + return list; + } + + private List ParseNewFilesArray(string json) + { + List list = new List(); + + string arrayJson = ConfigManager.ExtractJsonArray(json, "new_files"); + if (string.IsNullOrEmpty(arrayJson)) return list; + + List objects = ConfigManager.ParseJsonObjectArray(arrayJson); + foreach (string objJson in objects) + { + PatchFileInfo info = new PatchFileInfo(); + info.path = ConfigManager.ExtractJsonValue(objJson, "path"); + info.hash = ConfigManager.ExtractJsonValue(objJson, "hash"); + long.TryParse(ConfigManager.ExtractJsonValue(objJson, "size"), out info.size); + list.Add(info); + } + + return list; + } + + private List ParseDeletedArray(string json) + { + string arrayJson = ConfigManager.ExtractJsonArray(json, "deleted"); + if (string.IsNullOrEmpty(arrayJson)) return new List(); + return ConfigManager.ParseJsonStringArray(arrayJson); + } + + private string CalculateMD5(string filePath) + { + using (var md5 = MD5.Create()) + using (var stream = File.OpenRead(filePath)) + { + byte[] hash = md5.ComputeHash(stream); + StringBuilder sb = new StringBuilder(); + foreach (byte b in hash) + sb.Append(b.ToString("x2")); + return sb.ToString(); + } + } + + #endregion + + #region Download with retry and resume + + private const int DOWNLOAD_MAX_RETRIES = 10; + private const int RETRY_DELAY_BASE_MS = 2000; + + private class ChunkInfo + { + public int Index; + public long StartPos; + public long EndPos; + public long Downloaded; + } + + private async Task DownloadFileMultiThread(string url, string savePath, string displayName) + { + string downloadPath = ConfigManager.ResolveWritableDownloadPath(savePath); + + for (int attempt = 1; ; attempt++) + { + Exception caught = null; + try + { + ConfigManager.PrepareDownloadFile(downloadPath); + await DownloadFileInternal(url, downloadPath, displayName); + return downloadPath; + } + catch (Exception ex) + { + caught = ex; + if (IsFileAccessError(ex) && attempt < DOWNLOAD_MAX_RETRIES) + downloadPath = ConfigManager.ResolveWritableDownloadPath(savePath); + } + + Debug.WriteLine(String.Format("下载失败(第{0}次): {1}\nURL: {2}", attempt, caught.Message, url)); + if (attempt >= DOWNLOAD_MAX_RETRIES) + throw new Exception(String.Format("下载{0}失败,已重试{1}次: {2}", displayName, DOWNLOAD_MAX_RETRIES, caught.Message)); + + int delay = Math.Min(RETRY_DELAY_BASE_MS * attempt, 15000); + ReportStatus(String.Format("下载中断({0}),{1}秒后第{2}次重试...", caught.Message, delay / 1000, attempt)); + await Task.Delay(delay); + } + } + + private static bool IsFileAccessError(Exception ex) + { + while (ex != null) + { + if (ex is UnauthorizedAccessException || ex is IOException) + return true; + ex = ex.InnerException; + } + return false; + } + + private class ProbeResult + { + public long FileSize = -1; + public bool SupportsRange = false; + } + + private async Task DownloadFileInternal(string url, string savePath, string displayName) + { + speedCalcLastBytes = 0; + speedCalcLastTime = DateTime.Now; + currentSpeed = 0; + + if (maxThreads <= 1) + { + await DownloadSingleThreadNoRange(url, savePath, displayName); + return; + } + + ProbeResult probe = await ProbeUrl(url); + if (probe.FileSize <= 0 || !probe.SupportsRange) + { + await DownloadSingleThreadNoRange(url, savePath, displayName); + return; + } + + long fileSize = probe.FileSize; + int chunkCount = maxThreads; + long chunkBytes = fileSize / chunkCount; + ChunkInfo[] allChunks = new ChunkInfo[chunkCount]; + for (int i = 0; i < chunkCount; i++) + { + allChunks[i] = new ChunkInfo + { + Index = i, + StartPos = (long)i * chunkBytes, + EndPos = (i == chunkCount - 1) ? fileSize - 1 : (long)i * chunkBytes + chunkBytes - 1, + Downloaded = 0, + }; + } + + if (File.Exists(savePath)) + { + try + { + if (new FileInfo(savePath).Length != fileSize) + { + File.Delete(savePath); + using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write)) + fs.SetLength(fileSize); + } + } + catch + { + if (File.Exists(savePath)) File.Delete(savePath); + using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write)) + fs.SetLength(fileSize); + } + } + else + { + using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.Write)) + fs.SetLength(fileSize); + } + + object progressLock = new object(); + Task[] workers = new Task[chunkCount]; + for (int i = 0; i < chunkCount; i++) + { + ChunkInfo chunk = allChunks[i]; + workers[i] = Task.Run(async () => + { + await DownloadChunkWithRetry(url, savePath, chunk, allChunks, progressLock, fileSize, displayName); + }); + } + await Task.WhenAll(workers); + } + + private async Task ProbeUrl(string url) + { + ProbeResult result = new ProbeResult(); + try + { + HttpWebRequest req = (HttpWebRequest)WebRequest.Create(url); + req.Method = "HEAD"; req.Timeout = 15000; req.UserAgent = userAgent; + using (HttpWebResponse resp = (HttpWebResponse)await Task.Factory.FromAsync(req.BeginGetResponse, req.EndGetResponse, null)) + { + result.FileSize = resp.ContentLength; + string acceptRanges = resp.Headers["Accept-Ranges"]; + result.SupportsRange = !string.IsNullOrEmpty(acceptRanges) + && acceptRanges.IndexOf("none", StringComparison.OrdinalIgnoreCase) < 0; + } + } + catch { } + return result; + } + + private async Task DownloadChunkWithRetry(string url, string savePath, ChunkInfo chunk, ChunkInfo[] allChunks, object progressLock, long totalSize, string displayName) + { + for (int attempt = 0; attempt < DOWNLOAD_MAX_RETRIES; attempt++) + { + HttpWebResponse response = null; + Stream responseStream = null; + FileStream fs = null; + try + { + long resumeFrom = chunk.StartPos + chunk.Downloaded; + if (resumeFrom > chunk.EndPos) return; + + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); + request.AddRange(resumeFrom, chunk.EndPos); + request.Timeout = 60000; + request.ReadWriteTimeout = 60000; + request.KeepAlive = true; + request.UserAgent = userAgent; + + response = (HttpWebResponse)await Task.Factory.FromAsync(request.BeginGetResponse, request.EndGetResponse, null); + responseStream = response.GetResponseStream(); + + byte[] buffer = new byte[65536]; + fs = new FileStream(savePath, FileMode.Open, FileAccess.Write, FileShare.Write); + fs.Seek(resumeFrom, SeekOrigin.Begin); + + int bytesRead; + while ((bytesRead = await ReadStreamAsync(responseStream, buffer, 0, buffer.Length)) > 0) + { + await WriteStreamAsync(fs, buffer, 0, bytesRead); + chunk.Downloaded += bytesRead; + + lock (progressLock) + { + long totalDownloaded = 0; + foreach (ChunkInfo c in allChunks) totalDownloaded += c.Downloaded; + + DateTime now = DateTime.Now; + double elapsed = (now - speedCalcLastTime).TotalSeconds; + if (elapsed >= 0.3) + { + long delta = totalDownloaded - speedCalcLastBytes; + if (delta > 0) + { + double instantSpeed = delta / elapsed; + currentSpeed = currentSpeed <= 0 ? instantSpeed : currentSpeed * 0.3 + instantSpeed * 0.7; + } + speedCalcLastBytes = totalDownloaded; + speedCalcLastTime = now; + } + + int percent = (int)(totalDownloaded * 100 / totalSize); + string sizeInfo = String.Format("{0}/{1}", FormatFileSize(totalDownloaded), FormatFileSize(totalSize)); + string speedInfo = FormatSpeed(currentSpeed); + ReportProgress(percent, String.Format("正在下载{0} {1}", displayName, sizeInfo), speedInfo); + } + } + return; + } + catch (Exception ex) + { + Debug.WriteLine(String.Format("分片{0}下载失败(第{1}次): {2}", chunk.Index, attempt + 1, ex.Message)); + + WebException webEx = ex as WebException; + if (webEx == null && ex.InnerException != null) + webEx = ex.InnerException as WebException; + if (webEx != null && webEx.Response is HttpWebResponse) + { + HttpStatusCode code = ((HttpWebResponse)webEx.Response).StatusCode; + if (code == HttpStatusCode.NotFound || code == HttpStatusCode.Forbidden || code == HttpStatusCode.Unauthorized) + throw new Exception(String.Format("服务器返回 {0},URL: {1}", (int)code, url)); + } + } + finally + { + if (fs != null) { try { fs.Close(); fs.Dispose(); } catch { } } + if (responseStream != null) { try { responseStream.Close(); responseStream.Dispose(); } catch { } } + if (response != null) { try { response.Close(); } catch { } } + } + + if (attempt + 1 < DOWNLOAD_MAX_RETRIES) + await Task.Delay(Math.Min(RETRY_DELAY_BASE_MS * (attempt + 1), 10000)); + } + throw new Exception(String.Format("分片{0}下载失败,已达最大重试次数", chunk.Index)); + } + + private async Task DownloadSingleThreadNoRange(string url, string savePath, string displayName) + { + speedCalcLastBytes = 0; + speedCalcLastTime = DateTime.Now; + currentSpeed = 0; + + if (File.Exists(savePath)) { try { File.Delete(savePath); } catch { } } + + for (int attempt = 0; ; attempt++) + { + HttpWebResponse response = null; + Stream responseStream = null; + FileStream fs = null; + Exception caught = null; + try + { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url); + request.Timeout = 60000; request.ReadWriteTimeout = 60000; request.UserAgent = userAgent; + + response = (HttpWebResponse)await Task.Factory.FromAsync(request.BeginGetResponse, request.EndGetResponse, null); + + long totalSize = response.ContentLength > 0 ? response.ContentLength : 0; + fs = new FileStream(savePath, FileMode.Create, FileAccess.Write); + + responseStream = response.GetResponseStream(); + byte[] buffer = new byte[65536]; + long downloaded = 0; + + int bytesRead; + while ((bytesRead = await ReadStreamAsync(responseStream, buffer, 0, buffer.Length)) > 0) + { + await WriteStreamAsync(fs, buffer, 0, bytesRead); + downloaded += bytesRead; + + DateTime now = DateTime.Now; + double elapsed = (now - speedCalcLastTime).TotalSeconds; + if (elapsed >= 0.3) + { + long delta = downloaded - speedCalcLastBytes; + if (delta > 0) + { + double instantSpeed = delta / elapsed; + currentSpeed = currentSpeed <= 0 ? instantSpeed : currentSpeed * 0.3 + instantSpeed * 0.7; + } + speedCalcLastBytes = downloaded; + speedCalcLastTime = now; + } + + int percent = totalSize > 0 ? (int)(downloaded * 100 / totalSize) : 0; + string sizeInfo = totalSize > 0 ? String.Format("{0}/{1}", FormatFileSize(downloaded), FormatFileSize(totalSize)) : FormatFileSize(downloaded); + string speedInfo = FormatSpeed(currentSpeed); + ReportProgress(percent, String.Format("正在下载{0} {1}", displayName, sizeInfo), speedInfo); + } + return; + } + catch (Exception ex) + { + caught = ex; + } + finally + { + if (fs != null) { try { fs.Close(); fs.Dispose(); } catch { } } + if (responseStream != null) { try { responseStream.Close(); responseStream.Dispose(); } catch { } } + if (response != null) { try { response.Close(); } catch { } } + } + + WebException singleWebEx = caught as WebException; + if (singleWebEx == null && caught.InnerException != null) + singleWebEx = caught.InnerException as WebException; + if (singleWebEx != null && singleWebEx.Response is HttpWebResponse) + { + HttpStatusCode code = ((HttpWebResponse)singleWebEx.Response).StatusCode; + if (code == HttpStatusCode.NotFound || code == HttpStatusCode.Forbidden || code == HttpStatusCode.Unauthorized) + throw new Exception(String.Format("服务器返回 {0},URL: {1}", (int)code, url)); + } + + Debug.WriteLine(String.Format("单线程下载失败(第{0}次): {1}", attempt + 1, caught.Message)); + if (attempt >= DOWNLOAD_MAX_RETRIES - 1) throw caught; + + int delay = Math.Min(RETRY_DELAY_BASE_MS * (attempt + 1), 15000); + ReportStatus(String.Format("下载中断({0}),{1}秒后重试...", caught.Message, delay / 1000)); + await Task.Delay(delay); + } + } + + private Task ReadStreamAsync(Stream stream, byte[] buffer, int offset, int count) + { + return Task.Factory.FromAsync( + (callback, state) => stream.BeginRead(buffer, offset, count, callback, state), + stream.EndRead, null); + } + + private Task WriteStreamAsync(Stream stream, byte[] buffer, int offset, int count) + { + return Task.Factory.FromAsync( + (callback, state) => stream.BeginWrite(buffer, offset, count, callback, state), + stream.EndWrite, null); + } + + #endregion + + #region Helpers + + private void ReportProgress(int percent, string message) + { + ReportProgress(percent, message, ""); + } + + private void ReportProgress(int percent, string message, string speedText) + { + if (OnProgress != null) + { + OnProgress(percent, message, speedText); + } + } + + private void ReportStatus(string message) + { + if (OnStatus != null) + { + OnStatus(message); + } + } + + private string FormatFileSize(long bytes) + { + if (bytes >= 1073741824) + return String.Format("{0:F2} GB", bytes / 1073741824.0); + if (bytes >= 1048576) + return String.Format("{0:F2} MB", bytes / 1048576.0); + if (bytes >= 1024) + return String.Format("{0:F2} KB", bytes / 1024.0); + return String.Format("{0} B", bytes); + } + + private string FormatSpeed(double bytesPerSecond) + { + if (bytesPerSecond <= 0) return ""; + if (bytesPerSecond >= 1073741824) + return String.Format("{0:F2} GB/s", bytesPerSecond / 1073741824.0); + if (bytesPerSecond >= 1048576) + return String.Format("{0:F2} MB/s", bytesPerSecond / 1048576.0); + if (bytesPerSecond >= 1024) + return String.Format("{0:F2} KB/s", bytesPerSecond / 1024.0); + return String.Format("{0:F0} B/s", bytesPerSecond); + } + + #endregion + } + + #region Data structures + + public class UpdateCheckResult + { + public bool has_update; + public bool silent; + public string latest_version; + public string current_version; + public string update_type; + public string download_url; + public string file_size; + public string changelog; + public string total_size; + public List incremental_updates; + } + + public class IncrementalUpdate + { + public string from_version; + public string to_version; + public string download_url; + public string file_size; + public string changelog; + } + + public class PatchManifest + { + public string from_version; + public string to_version; + public List modified = new List(); + public List new_files = new List(); + public List deleted = new List(); + } + + public class PatchDiffInfo + { + public string path; + public string patch_file; + public string old_hash; + public string new_hash; + public long new_size; + } + + public class PatchFileInfo + { + public string path; + public string hash; + public long size; + } + + #endregion +} diff --git a/R0Installer/app.manifest b/R0Installer/app.manifest new file mode 100644 index 0000000..1fc5e19 --- /dev/null +++ b/R0Installer/app.manifest @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/R0PatchGenerator/PatchGenerator.cs b/R0PatchGenerator/PatchGenerator.cs new file mode 100644 index 0000000..3881c04 --- /dev/null +++ b/R0PatchGenerator/PatchGenerator.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; + +namespace R0PatchGenerator +{ + public class PatchGenerator + { + private string hdiffzPath; + + public PatchGenerator() + { + // 查找 hdiffz.exe + string exeDir = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + hdiffzPath = Path.Combine(exeDir, "hdiffz.exe"); + + if (!File.Exists(hdiffzPath)) + { + // 尝试在当前目录查找 + hdiffzPath = "hdiffz.exe"; + } + } + + public bool GeneratePatch(string oldPath, string newPath, string outputZip, string fromVersion, string toVersion) + { + // 创建临时目录 + string tempDir = Path.Combine(Path.GetTempPath(), "r0patch_" + Guid.NewGuid().ToString("N")); + string patchesDir = Path.Combine(tempDir, "patches"); + string newFilesDir = Path.Combine(tempDir, "new"); + + Directory.CreateDirectory(tempDir); + Directory.CreateDirectory(patchesDir); + Directory.CreateDirectory(newFilesDir); + + try + { + // 获取所有文件 + Dictionary oldFiles = GetAllFiles(oldPath); + Dictionary newFiles = GetAllFiles(newPath); + + Console.WriteLine("旧版本文件数: " + oldFiles.Count); + Console.WriteLine("新版本文件数: " + newFiles.Count); + Console.WriteLine(); + + // 创建清单 + PatchManifest manifest = new PatchManifest(); + manifest.from_version = fromVersion; + manifest.to_version = toVersion; + manifest.created_at = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); + + int processedCount = 0; + int totalFiles = newFiles.Count; + + foreach (var kvp in newFiles) + { + string relativePath = kvp.Key; + FileInfo newFile = kvp.Value; + processedCount++; + + Console.Write("[" + processedCount + "/" + totalFiles + "] " + relativePath + " ... "); + + if (oldFiles.ContainsKey(relativePath)) + { + // 文件存在于两个版本 + FileInfo oldFile = oldFiles[relativePath]; + string oldHash = CalculateMD5(oldFile.FullName); + string newHash = CalculateMD5(newFile.FullName); + + if (oldHash == newHash) + { + // 文件未变化 + Console.WriteLine("未变化"); + manifest.unchanged.Add(relativePath); + } + else + { + // 文件已修改,生成差异 + string patchFileName = relativePath.Replace("\\", "_").Replace("/", "_") + ".hdiff"; + string patchFilePath = Path.Combine(patchesDir, patchFileName); + + // 确保目录存在 + Directory.CreateDirectory(Path.GetDirectoryName(patchFilePath)); + + bool patchSuccess = CreateHDiff(oldFile.FullName, newFile.FullName, patchFilePath); + + if (patchSuccess && File.Exists(patchFilePath)) + { + FileInfo patchInfo = new FileInfo(patchFilePath); + FileInfo originalInfo = new FileInfo(newFile.FullName); + + // 如果差异文件比原文件还大,直接用新文件 + if (patchInfo.Length >= originalInfo.Length * 0.9) + { + Console.WriteLine("替换 (差异过大)"); + File.Delete(patchFilePath); + + // 作为新文件处理 + string newFilePath = Path.Combine(newFilesDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(newFilePath)); + File.Copy(newFile.FullName, newFilePath, true); + + PatchFileInfo pfi = new PatchFileInfo(); + pfi.path = relativePath; + pfi.hash = newHash; + pfi.size = newFile.Length; + manifest.new_files.Add(pfi); + } + else + { + Console.WriteLine("差异 (" + FormatFileSize(patchInfo.Length) + ")"); + + PatchDiffInfo pdi = new PatchDiffInfo(); + pdi.path = relativePath; + pdi.patch_file = "patches/" + patchFileName; + pdi.old_hash = oldHash; + pdi.new_hash = newHash; + pdi.new_size = newFile.Length; + manifest.modified.Add(pdi); + } + } + else + { + // 差异生成失败,使用完整文件 + Console.WriteLine("替换 (差异失败)"); + + string newFilePath = Path.Combine(newFilesDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(newFilePath)); + File.Copy(newFile.FullName, newFilePath, true); + + PatchFileInfo pfi = new PatchFileInfo(); + pfi.path = relativePath; + pfi.hash = newHash; + pfi.size = newFile.Length; + manifest.new_files.Add(pfi); + } + } + + // 从旧文件列表移除 + oldFiles.Remove(relativePath); + } + else + { + // 新增文件 + Console.WriteLine("新增"); + + string newFilePath = Path.Combine(newFilesDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(newFilePath)); + File.Copy(newFile.FullName, newFilePath, true); + + PatchFileInfo pfi = new PatchFileInfo(); + pfi.path = relativePath; + pfi.hash = CalculateMD5(newFile.FullName); + pfi.size = newFile.Length; + manifest.new_files.Add(pfi); + } + } + + // 剩余的旧文件需要删除 + foreach (var kvp in oldFiles) + { + Console.WriteLine("删除: " + kvp.Key); + manifest.deleted.Add(kvp.Key); + } + + Console.WriteLine(); + Console.WriteLine("统计:"); + Console.WriteLine(" - 未变化: " + manifest.unchanged.Count); + Console.WriteLine(" - 已修改: " + manifest.modified.Count); + Console.WriteLine(" - 新增: " + manifest.new_files.Count); + Console.WriteLine(" - 删除: " + manifest.deleted.Count); + + // 生成清单JSON + string manifestJson = GenerateManifestJson(manifest); + File.WriteAllText(Path.Combine(tempDir, "patch.json"), manifestJson, Encoding.UTF8); + + // 打包成ZIP + if (File.Exists(outputZip)) + File.Delete(outputZip); + + ZipFile.CreateFromDirectory(tempDir, outputZip, CompressionLevel.Optimal, false); + + return true; + } + finally + { + // 清理临时目录 + try + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + catch { } + } + } + + private Dictionary GetAllFiles(string rootPath) + { + Dictionary files = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string filePath in Directory.GetFiles(rootPath, "*", SearchOption.AllDirectories)) + { + string relativePath = filePath.Substring(rootPath.Length).TrimStart('\\', '/'); + files[relativePath] = new FileInfo(filePath); + } + + return files; + } + + private string CalculateMD5(string filePath) + { + using (var md5 = MD5.Create()) + using (var stream = File.OpenRead(filePath)) + { + byte[] hash = md5.ComputeHash(stream); + StringBuilder sb = new StringBuilder(); + foreach (byte b in hash) + sb.Append(b.ToString("x2")); + return sb.ToString(); + } + } + + private bool CreateHDiff(string oldFile, string newFile, string patchFile) + { + if (!File.Exists(hdiffzPath)) + { + Console.WriteLine("警告: hdiffz.exe 未找到,将使用完整文件替换"); + return false; + } + + try + { + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = hdiffzPath; + // -m: 使用内存匹配(更快),-c-zlib: 使用zlib压缩 + psi.Arguments = String.Format("-m -c-zlib \"{0}\" \"{1}\" \"{2}\"", oldFile, newFile, patchFile); + psi.UseShellExecute = false; + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.CreateNoWindow = true; + + using (Process process = Process.Start(psi)) + { + process.WaitForExit(60000); // 最多等待60秒 + return process.ExitCode == 0; + } + } + catch (Exception ex) + { + Console.WriteLine("HDiff错误: " + ex.Message); + return false; + } + } + + private string GenerateManifestJson(PatchManifest manifest) + { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("{"); + sb.AppendLine(" \"from_version\": \"" + EscapeJson(manifest.from_version) + "\","); + sb.AppendLine(" \"to_version\": \"" + EscapeJson(manifest.to_version) + "\","); + sb.AppendLine(" \"created_at\": \"" + EscapeJson(manifest.created_at) + "\","); + + // unchanged + sb.AppendLine(" \"unchanged\": ["); + for (int i = 0; i < manifest.unchanged.Count; i++) + { + sb.Append(" \"" + EscapeJson(manifest.unchanged[i]) + "\""); + if (i < manifest.unchanged.Count - 1) sb.Append(","); + sb.AppendLine(); + } + sb.AppendLine(" ],"); + + // modified + sb.AppendLine(" \"modified\": ["); + for (int i = 0; i < manifest.modified.Count; i++) + { + PatchDiffInfo pdi = manifest.modified[i]; + sb.AppendLine(" {"); + sb.AppendLine(" \"path\": \"" + EscapeJson(pdi.path) + "\","); + sb.AppendLine(" \"patch_file\": \"" + EscapeJson(pdi.patch_file) + "\","); + sb.AppendLine(" \"old_hash\": \"" + pdi.old_hash + "\","); + sb.AppendLine(" \"new_hash\": \"" + pdi.new_hash + "\","); + sb.AppendLine(" \"new_size\": " + pdi.new_size); + sb.Append(" }"); + if (i < manifest.modified.Count - 1) sb.Append(","); + sb.AppendLine(); + } + sb.AppendLine(" ],"); + + // new_files + sb.AppendLine(" \"new_files\": ["); + for (int i = 0; i < manifest.new_files.Count; i++) + { + PatchFileInfo pfi = manifest.new_files[i]; + sb.AppendLine(" {"); + sb.AppendLine(" \"path\": \"" + EscapeJson(pfi.path) + "\","); + sb.AppendLine(" \"hash\": \"" + pfi.hash + "\","); + sb.AppendLine(" \"size\": " + pfi.size); + sb.Append(" }"); + if (i < manifest.new_files.Count - 1) sb.Append(","); + sb.AppendLine(); + } + sb.AppendLine(" ],"); + + // deleted + sb.AppendLine(" \"deleted\": ["); + for (int i = 0; i < manifest.deleted.Count; i++) + { + sb.Append(" \"" + EscapeJson(manifest.deleted[i]) + "\""); + if (i < manifest.deleted.Count - 1) sb.Append(","); + sb.AppendLine(); + } + sb.AppendLine(" ]"); + + sb.AppendLine("}"); + return sb.ToString(); + } + + private string EscapeJson(string s) + { + if (s == null) return ""; + return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); + } + + private string FormatFileSize(long bytes) + { + if (bytes >= 1073741824) + return String.Format("{0:F2} GB", bytes / 1073741824.0); + if (bytes >= 1048576) + return String.Format("{0:F2} MB", bytes / 1048576.0); + if (bytes >= 1024) + return String.Format("{0:F2} KB", bytes / 1024.0); + return String.Format("{0} B", bytes); + } + } + + // 清单数据结构 + public class PatchManifest + { + public string from_version = ""; + public string to_version = ""; + public string created_at = ""; + public List unchanged = new List(); + public List modified = new List(); + public List new_files = new List(); + public List deleted = new List(); + } + + public class PatchDiffInfo + { + public string path = ""; + public string patch_file = ""; + public string old_hash = ""; + public string new_hash = ""; + public long new_size = 0; + } + + public class PatchFileInfo + { + public string path = ""; + public string hash = ""; + public long size = 0; + } +} + diff --git a/R0PatchGenerator/Program.cs b/R0PatchGenerator/Program.cs new file mode 100644 index 0000000..3e7cf81 --- /dev/null +++ b/R0PatchGenerator/Program.cs @@ -0,0 +1,109 @@ +using System; +using System.IO; + +namespace R0PatchGenerator +{ + class Program + { + static void Main(string[] args) + { + Console.WriteLine("========================================"); + Console.WriteLine(" R0对战平台 增量包生成器"); + Console.WriteLine("========================================"); + Console.WriteLine(); + + if (args.Length < 3) + { + PrintUsage(); + return; + } + + string oldVersionPath = args[0]; + string newVersionPath = args[1]; + string outputPath = args[2]; + + // 可选参数:版本号 + string fromVersion = args.Length > 3 ? args[3] : "unknown"; + string toVersion = args.Length > 4 ? args[4] : "unknown"; + + Console.WriteLine("源版本目录: " + oldVersionPath); + Console.WriteLine("目标版本目录: " + newVersionPath); + Console.WriteLine("输出路径: " + outputPath); + Console.WriteLine("从版本: " + fromVersion); + Console.WriteLine("到版本: " + toVersion); + Console.WriteLine(); + + if (!Directory.Exists(oldVersionPath)) + { + Console.WriteLine("错误: 源版本目录不存在!"); + return; + } + + if (!Directory.Exists(newVersionPath)) + { + Console.WriteLine("错误: 目标版本目录不存在!"); + return; + } + + try + { + PatchGenerator generator = new PatchGenerator(); + bool success = generator.GeneratePatch(oldVersionPath, newVersionPath, outputPath, fromVersion, toVersion); + + if (success) + { + Console.WriteLine(); + Console.WriteLine("增量包生成成功!"); + Console.WriteLine("输出文件: " + outputPath); + + FileInfo fi = new FileInfo(outputPath); + Console.WriteLine("文件大小: " + FormatFileSize(fi.Length)); + } + else + { + Console.WriteLine(); + Console.WriteLine("增量包生成失败!"); + } + } + catch (Exception ex) + { + Console.WriteLine("错误: " + ex.Message); + Console.WriteLine(ex.StackTrace); + } + } + + static void PrintUsage() + { + Console.WriteLine("用法:"); + Console.WriteLine(" R0PatchGenerator.exe <旧版本目录> <新版本目录> <输出文件.zip> [从版本] [到版本]"); + Console.WriteLine(); + Console.WriteLine("示例:"); + Console.WriteLine(" R0PatchGenerator.exe \"C:\\versions\\1.0.0\" \"C:\\versions\\1.0.1\" \"patch_1.0.0_to_1.0.1.zip\" 1.0.0 1.0.1"); + Console.WriteLine(); + Console.WriteLine("说明:"); + Console.WriteLine(" - 旧版本目录: 包含旧版本完整文件的目录"); + Console.WriteLine(" - 新版本目录: 包含新版本完整文件的目录"); + Console.WriteLine(" - 输出文件: 生成的增量包文件路径(.zip格式)"); + Console.WriteLine(" - 从版本/到版本: 可选,用于生成版本信息"); + Console.WriteLine(); + Console.WriteLine("增量包内容:"); + Console.WriteLine(" - patch.json: 更新清单文件"); + Console.WriteLine(" - patches/: 差异文件目录(使用HDiffPatch格式)"); + Console.WriteLine(" - new/: 新增文件目录"); + Console.WriteLine(); + Console.WriteLine("注意: 需要将 hdiffz.exe 放在同一目录下"); + } + + static string FormatFileSize(long bytes) + { + if (bytes >= 1073741824) + return String.Format("{0:F2} GB", bytes / 1073741824.0); + if (bytes >= 1048576) + return String.Format("{0:F2} MB", bytes / 1048576.0); + if (bytes >= 1024) + return String.Format("{0:F2} KB", bytes / 1024.0); + return String.Format("{0} B", bytes); + } + } +} + diff --git a/R0PatchGenerator/Properties/AssemblyInfo.cs b/R0PatchGenerator/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..16f4bd2 --- /dev/null +++ b/R0PatchGenerator/Properties/AssemblyInfo.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("R0PatchGenerator")] +[assembly: AssemblyDescription("R0对战平台增量包生成器")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("R0")] +[assembly: AssemblyProduct("R0PatchGenerator")] +[assembly: AssemblyCopyright("Copyright © R0 2024")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] + +[assembly: Guid("b2c3d4e5-f6a7-8901-bcde-f23456789012")] + +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] + diff --git a/R0PatchGenerator/R0PatchGenerator.csproj b/R0PatchGenerator/R0PatchGenerator.csproj new file mode 100644 index 0000000..749c010 --- /dev/null +++ b/R0PatchGenerator/R0PatchGenerator.csproj @@ -0,0 +1,42 @@ + + + + + Release + AnyCPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012} + Exe + R0PatchGenerator + R0PatchGenerator + v4.8 + 512 + true + true + ..\icon.ico + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + diff --git a/api/.htaccess b/api/.htaccess new file mode 100644 index 0000000..00d35ee --- /dev/null +++ b/api/.htaccess @@ -0,0 +1,7 @@ +RewriteEngine On +RewriteBase / + +# API 路由: /v3/* -> api/index.php +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^v3/(.*)$ index.php [QSA,L] diff --git a/api/Medoo.php b/api/Medoo.php new file mode 100644 index 0000000..0044658 --- /dev/null +++ b/api/Medoo.php @@ -0,0 +1,21 @@ + false, 'message' => 'config_json 不是有效的 JSON']); + exit; + } + + if ($editId) { + $db->update('projects', [ + 'display_name' => $displayName, + 'description' => $description, + 'icon_url' => $iconUrl, + 'enabled' => $enabled, + 'config_json' => $configJson, + ], ['id' => $editId]); + } else { + $db->insert('projects', [ + 'project_id' => $projectId, + 'display_name' => $displayName, + 'description' => $description, + 'icon_url' => $iconUrl, + 'enabled' => $enabled, + 'config_json' => $configJson, + ]); + } + + echo json_encode(['success' => true]); + exit; + + case 'delete_project': + $id = $_POST['id'] ?? 0; + $db->delete('projects', ['id' => $id]); + echo json_encode(['success' => true]); + exit; + + case 'save_installer_config': + $filenameRules = $_POST['filename_rules'] ?? '[]'; + $commandRules = $_POST['command_rules'] ?? '[]'; + $defaultProject = $_POST['default_project'] ?? 'r0_arena'; + $defaultMode = $_POST['default_mode'] ?? 'full'; + + $db->update('installer_config', [ + 'filename_rules' => $filenameRules, + 'command_rules' => $commandRules, + 'default_project' => $defaultProject, + 'default_mode' => $defaultMode, + ], ['config_key' => 'global']); + + echo json_encode(['success' => true]); + exit; + + case 'save_version': + $projectId = $_POST['project_id'] ?? ''; + $version = $_POST['version'] ?? ''; + $downloadUrl = $_POST['download_url'] ?? ''; + $fileSize = intval($_POST['file_size'] ?? 0); + $changelog = $_POST['changelog'] ?? ''; + $threads = intval($_POST['threads'] ?? 8); + $extraDownloads = $_POST['extra_downloads'] ?? '{}'; + $runtimeUrls = $_POST['runtime_urls'] ?? '{}'; + $isLatest = isset($_POST['is_latest']) ? 1 : 0; + $silentUpdate = isset($_POST['silent_update']) ? 1 : 0; + $editId = $_POST['edit_id'] ?? ''; + + if ($isLatest) { + $db->update('project_versions', ['is_latest' => 0], ['project_id' => $projectId]); + } + + if ($editId) { + $db->update('project_versions', [ + 'version' => $version, + 'download_url' => $downloadUrl, + 'file_size' => $fileSize, + 'changelog' => $changelog, + 'threads' => $threads, + 'extra_downloads' => $extraDownloads, + 'runtime_urls' => $runtimeUrls, + 'is_latest' => $isLatest, + 'silent_update' => $silentUpdate, + ], ['id' => $editId]); + } else { + $db->insert('project_versions', [ + 'project_id' => $projectId, + 'version' => $version, + 'download_url' => $downloadUrl, + 'file_size' => $fileSize, + 'changelog' => $changelog, + 'threads' => $threads, + 'extra_downloads' => $extraDownloads, + 'runtime_urls' => $runtimeUrls, + 'is_latest' => $isLatest, + 'silent_update' => $silentUpdate, + ]); + } + + echo json_encode(['success' => true]); + exit; + + case 'delete_version': + $id = $_POST['id'] ?? 0; + $db->delete('project_versions', ['id' => $id]); + echo json_encode(['success' => true]); + exit; + + case 'save_patch': + $projectId = $_POST['project_id'] ?? ''; + $fromVersion = $_POST['from_version'] ?? ''; + $toVersion = $_POST['to_version'] ?? ''; + $downloadUrl = $_POST['download_url'] ?? ''; + $fileSize = intval($_POST['file_size'] ?? 0); + $changelog = $_POST['changelog'] ?? ''; + $editId = $_POST['edit_id'] ?? ''; + + if ($editId) { + $db->update('project_patches', [ + 'from_version' => $fromVersion, + 'to_version' => $toVersion, + 'download_url' => $downloadUrl, + 'file_size' => $fileSize, + 'changelog' => $changelog, + ], ['id' => $editId]); + } else { + $db->insert('project_patches', [ + 'project_id' => $projectId, + 'from_version' => $fromVersion, + 'to_version' => $toVersion, + 'download_url' => $downloadUrl, + 'file_size' => $fileSize, + 'changelog' => $changelog, + ]); + } + + echo json_encode(['success' => true]); + exit; + + case 'delete_patch': + $id = $_POST['id'] ?? 0; + $db->delete('project_patches', ['id' => $id]); + echo json_encode(['success' => true]); + exit; + } + + echo json_encode(['success' => false, 'message' => '未知操作']); + exit; +} + +// ========== 页面渲染 ========== + +function showLoginPage($error = '') { +?> + + + + + +R0Installer 管理后台 - 登录 + + + + + + +select('projects', '*'); +?> + + + + + +R0Installer 管理后台 + + + + +
+ + + +count('project_versions'); + $patchCount = $db->count('project_patches'); +?> +

仪表盘

+
+
+
+
项目总数
+
+
+
+
版本总数
+
+
+
+
增量包总数
+
+
+

项目列表

+ + + + + + + + + +
项目ID名称状态
启用' : '禁用' ?>
+ +

项目管理

+ + + + + + + + + + + + +
ID项目ID名称状态更新时间操作
启用' : '禁用' ?> + + +
+ + + + $filterProject] : []; + $where['ORDER'] = ['id' => 'DESC']; + $versions = $db->select('project_versions', '*', $where); +?> +

版本管理

+
+ 筛选项目: + 全部 + + + +
+ + + + + + + + + + + + + + +
ID项目版本最新静默更新线程数创建时间操作
是' : '' ?>静默' : '界面' ?> + + +
+ + + + $filterProject] : []; + $where['ORDER'] = ['id' => 'DESC']; + $patches = $db->select('project_patches', '*', $where); +?> +

增量包管理

+
+ 筛选项目: + 全部 + + + +
+ + + + + + + + + + + + + +
ID项目从版本到版本文件大小创建时间操作
+ + +
+ + + +get('installer_config', '*', ['config_key' => 'global']); + $filenameRules = $config ? $config['filename_rules'] : '[]'; + $commandRules = $config ? $config['command_rules'] : '[]'; + + // 格式化 JSON 显示 + $fr = json_decode($filenameRules, true); + $cr = json_decode($commandRules, true); + $frFormatted = $fr ? json_encode($fr, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '[]'; + $crFormatted = $cr ? json_encode($cr, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '[]'; +?> +

安装器全局配置

+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+=7.4", + "catfan/medoo": "^2.1" + } +} diff --git a/api/config.php.example b/api/config.php.example new file mode 100644 index 0000000..d78ab4a --- /dev/null +++ b/api/config.php.example @@ -0,0 +1,16 @@ + DB_TYPE, + 'host' => DB_HOST, + 'port' => DB_PORT, + 'database' => DB_NAME, + 'username' => DB_USER, + 'password' => DB_PASS, + 'charset' => DB_CHARSET, + ]); + } + return $db; +} diff --git a/api/helpers.php b/api/helpers.php new file mode 100644 index 0000000..00cd2e9 --- /dev/null +++ b/api/helpers.php @@ -0,0 +1,25 @@ + $code, + 'message' => $message, + 'data' => $data + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function json_error($message, $code = 5000) { + json_response(null, $code, $message); +} + +function get_request_path() { + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + $pos = strpos($uri, '?'); + if ($pos !== false) { + $uri = substr($uri, 0, $pos); + } + return rtrim($uri, '/'); +} diff --git a/api/index.php b/api/index.php new file mode 100644 index 0000000..d72cdcf --- /dev/null +++ b/api/index.php @@ -0,0 +1,286 @@ +select('projects', [ + 'project_id', 'display_name', 'description', 'icon_url', 'enabled' + ]); + + $projects = []; + foreach ($rows as $row) { + $row['enabled'] = (bool)$row['enabled']; + $projects[] = $row; + } + + json_response(['projects' => $projects]); +} + +function handleGetProjectConfig($db, $projectId) { + $row = $db->get('projects', [ + 'project_id', 'display_name', 'config_json' + ], ['project_id' => $projectId]); + + if (!$row) { + json_error('项目不存在', 1001); + } + + $config = json_decode($row['config_json'], true); + if (!$config) { + json_error('项目配置解析失败', 1003); + } + + $config['project_id'] = $row['project_id']; + $config['display_name'] = $row['display_name']; + + json_response($config); +} + +function handleGetInstallerConfig($db) { + $row = $db->get('installer_config', '*', ['config_key' => 'global']); + + if (!$row) { + json_error('配置不存在', 1003); + } + + $filenameRules = json_decode($row['filename_rules'], true) ?: []; + $commandRules = json_decode($row['command_rules'], true) ?: []; + + json_response([ + 'installer_version' => $row['installer_version'], + 'min_client_version' => $row['min_client_version'], + 'filename_rules' => $filenameRules, + 'command_rules' => $commandRules, + 'default_project_id' => $row['default_project'], + 'default_install_mode' => $row['default_mode'], + ]); +} + +function handleGetVersionIndex($db, $projectId) { + if (empty($projectId)) { + json_error('缺少 project 参数', 1002); + } + + $row = $db->get('project_versions', '*', [ + 'project_id' => $projectId, + 'is_latest' => 1, + ]); + + if (!$row) { + $row = $db->get('project_versions', '*', [ + 'project_id' => $projectId, + 'ORDER' => ['id' => 'DESC'], + 'LIMIT' => 1, + ]); + } + + if (!$row) { + json_error('未找到版本信息', 1001); + } + + $data = [ + 'project_id' => $row['project_id'], + 'version' => $row['version'], + 'download_url_cdn' => $row['download_url'], + 'threads' => (int)$row['threads'], + 'changelog' => $row['changelog'] ?? '', + ]; + + $extraDownloads = json_decode($row['extra_downloads'] ?? '{}', true); + if ($extraDownloads) { + foreach ($extraDownloads as $key => $val) { + $data[$key] = $val; + } + } + + $runtimeUrls = json_decode($row['runtime_urls'] ?? '{}', true); + if ($runtimeUrls) { + $data['runtime_download_url'] = $runtimeUrls; + } + + json_response($data); +} + +function handleUpdateCheck($db, $projectId, $currentVersion) { + if (empty($projectId)) { + json_error('缺少 project 参数', 1002); + } + if (empty($currentVersion)) { + json_error('缺少 v 参数', 1002); + } + + $latestRow = $db->get('project_versions', '*', [ + 'project_id' => $projectId, + 'is_latest' => 1, + ]); + + if (!$latestRow) { + $latestRow = $db->get('project_versions', '*', [ + 'project_id' => $projectId, + 'ORDER' => ['id' => 'DESC'], + 'LIMIT' => 1, + ]); + } + + if (!$latestRow) { + json_response([ + 'has_update' => false, + 'latest_version' => $currentVersion, + 'current_version' => $currentVersion, + ]); + return; + } + + $latestVersion = $latestRow['version']; + + if (version_compare($currentVersion, $latestVersion, '>=')) { + json_response([ + 'has_update' => false, + 'latest_version' => $latestVersion, + 'current_version' => $currentVersion, + ]); + return; + } + + $patches = $db->select('project_patches', '*', [ + 'project_id' => $projectId, + 'ORDER' => ['id' => 'ASC'], + ]); + + $chain = buildPatchChain($patches, $currentVersion, $latestVersion); + + // 是否静默更新由“目标版本”(即最新版本)决定,云端按版本控制。 + $silent = (bool)($latestRow['silent_update'] ?? 0); + + if ($chain && count($chain) === 1) { + $p = $chain[0]; + json_response([ + 'has_update' => true, + 'latest_version' => $latestVersion, + 'current_version' => $currentVersion, + 'update_type' => 'incremental', + 'download_url' => $p['download_url'], + 'file_size' => (string)$p['file_size'], + 'total_size' => (string)$p['file_size'], + 'changelog' => $p['changelog'] ?? '', + 'threads' => (int)$latestRow['threads'], + 'silent' => $silent, + ]); + return; + } + + if ($chain && count($chain) > 1) { + $totalSize = 0; + $updates = []; + foreach ($chain as $p) { + $totalSize += (int)$p['file_size']; + $updates[] = [ + 'from_version' => $p['from_version'], + 'to_version' => $p['to_version'], + 'download_url' => $p['download_url'], + 'file_size' => (string)$p['file_size'], + 'changelog' => $p['changelog'] ?? '', + ]; + } + + json_response([ + 'has_update' => true, + 'latest_version' => $latestVersion, + 'current_version' => $currentVersion, + 'update_type' => 'multi_incremental', + 'total_size' => (string)$totalSize, + 'changelog' => '多版本增量更新', + 'threads' => (int)$latestRow['threads'], + 'incremental_updates' => $updates, + 'silent' => $silent, + ]); + return; + } + + json_response([ + 'has_update' => true, + 'latest_version' => $latestVersion, + 'current_version' => $currentVersion, + 'update_type' => 'full', + 'download_url' => $latestRow['download_url'], + 'file_size' => (string)$latestRow['file_size'], + 'total_size' => (string)$latestRow['file_size'], + 'changelog' => $latestRow['changelog'] ?? '', + 'threads' => (int)$latestRow['threads'], + 'silent' => $silent, + ]); +} + +function buildPatchChain($patches, $from, $to) { + $graph = []; + foreach ($patches as $p) { + $graph[$p['from_version']][] = $p; + } + + $visited = []; + $queue = [[$from, []]]; + + while (!empty($queue)) { + list($current, $path) = array_shift($queue); + + if ($current === $to) { + return $path; + } + + if (isset($visited[$current])) continue; + $visited[$current] = true; + + if (isset($graph[$current])) { + foreach ($graph[$current] as $edge) { + if (!isset($visited[$edge['to_version']])) { + $newPath = $path; + $newPath[] = $edge; + $queue[] = [$edge['to_version'], $newPath]; + } + } + } + } + + return null; +} diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..0d02ba6 --- /dev/null +++ b/build.bat @@ -0,0 +1,93 @@ +@echo off +chcp 65001 >nul +echo ======================================== +echo R0对战平台安装/更新程序 - 构建脚本 +echo ======================================== +echo. + +:: 设置路径 +set SCRIPT_DIR=%~dp0 +set INSTALLER_DIR=%SCRIPT_DIR%R0Installer +set PATCHGEN_DIR=%SCRIPT_DIR%R0PatchGenerator +set OUTPUT_DIR=%SCRIPT_DIR% +set MSBUILD="C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe" + +:: 检查MSBuild是否存在 +if not exist %MSBUILD% ( + echo [错误] 未找到 MSBuild.exe + echo 请确保已安装 .NET Framework 4.8 + pause + exit /b 1 +) + +echo [1/5] 清理旧文件... +if exist "%INSTALLER_DIR%\bin\Release\R0Installer.exe" del /f "%INSTALLER_DIR%\bin\Release\R0Installer.exe" +if exist "%OUTPUT_DIR%R0Installer.exe" del /f "%OUTPUT_DIR%R0Installer.exe" +if exist "%PATCHGEN_DIR%\bin\Release\R0PatchGenerator.exe" del /f "%PATCHGEN_DIR%\bin\Release\R0PatchGenerator.exe" +if exist "%OUTPUT_DIR%R0PatchGenerator.exe" del /f "%OUTPUT_DIR%R0PatchGenerator.exe" + +:: 编译 R0Installer +echo [2/5] 正在编译 R0Installer... +cd /d "%INSTALLER_DIR%" +%MSBUILD% R0Installer.csproj /p:Configuration=Release /verbosity:minimal /nologo + +if %ERRORLEVEL% NEQ 0 ( + echo. + echo [错误] R0Installer 编译失败! + pause + exit /b 1 +) + +echo [3/5] 复制 R0Installer... +copy /y "bin\Release\R0Installer.exe" "%OUTPUT_DIR%R0Installer.exe" >nul + +:: 编译 R0PatchGenerator +if exist "%PATCHGEN_DIR%" ( + echo [4/5] 正在编译 R0PatchGenerator... + cd /d "%PATCHGEN_DIR%" + %MSBUILD% R0PatchGenerator.csproj /p:Configuration=Release /verbosity:minimal /nologo + + if %ERRORLEVEL% NEQ 0 ( + echo [警告] R0PatchGenerator 编译失败! + ) else ( + echo [5/5] 复制 R0PatchGenerator... + copy /y "bin\Release\R0PatchGenerator.exe" "%OUTPUT_DIR%R0PatchGenerator.exe" >nul + ) +) else ( + echo [4/5] 跳过 R0PatchGenerator (目录不存在) + echo [5/5] 跳过 +) + +cd /d "%SCRIPT_DIR%" + +echo. +echo ======================================== +echo 构建完成! +echo ======================================== +echo. + +:: 显示 R0Installer 文件信息 +if exist "%OUTPUT_DIR%R0Installer.exe" ( + for %%A in ("%OUTPUT_DIR%R0Installer.exe") do ( + set /a SIZE_KB=%%~zA / 1024 + ) + call echo R0Installer.exe: %%SIZE_KB%% KB +) + +:: 显示 R0PatchGenerator 文件信息 +if exist "%OUTPUT_DIR%R0PatchGenerator.exe" ( + for %%A in ("%OUTPUT_DIR%R0PatchGenerator.exe") do ( + set /a SIZE_KB2=%%~zA / 1024 + ) + call echo R0PatchGenerator.exe: %%SIZE_KB2%% KB +) + +echo. +echo 使用说明: +echo 安装模式: R0Installer.exe +echo 更新模式: R0Installer.exe update ^<版本号^> +echo 生成增量包: R0PatchGenerator.exe ^<旧版本目录^> ^<新版本目录^> ^<输出.zip^> [从版本] [到版本] +echo. +echo 注意: 增量包生成需要 hdiffz.exe,更新需要 hpatchz.exe +echo. +pause diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..69bd7db --- /dev/null +++ b/build.ps1 @@ -0,0 +1,102 @@ +# R0对战平台安装程序 - PowerShell构建脚本 +# 使用方法: 右键 -> 使用PowerShell运行 + +$ErrorActionPreference = "Stop" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " R0对战平台安装/更新程序 - 构建脚本" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +# 设置路径 +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$InstallerDir = Join-Path $ScriptDir "R0Installer" +$PatchGenDir = Join-Path $ScriptDir "R0PatchGenerator" +$OutputDir = $ScriptDir +$MSBuild = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe" + +# 检查MSBuild是否存在 +if (-not (Test-Path $MSBuild)) { + Write-Host "[错误] 未找到 MSBuild.exe" -ForegroundColor Red + Write-Host "请确保已安装 .NET Framework 4.8" -ForegroundColor Red + Read-Host "按回车键退出" + exit 1 +} + +# 清理旧文件 +Write-Host "[1/5] 清理旧文件..." -ForegroundColor Yellow +$InstallerExe = Join-Path $InstallerDir "bin\Release\R0Installer.exe" +$PatchGenExe = Join-Path $PatchGenDir "bin\Release\R0PatchGenerator.exe" +$OutputInstallerExe = Join-Path $OutputDir "R0Installer.exe" +$OutputPatchGenExe = Join-Path $OutputDir "R0PatchGenerator.exe" +if (Test-Path $InstallerExe) { Remove-Item $InstallerExe -Force } +if (Test-Path $OutputInstallerExe) { Remove-Item $OutputInstallerExe -Force } +if (Test-Path $PatchGenExe) { Remove-Item $PatchGenExe -Force } +if (Test-Path $OutputPatchGenExe) { Remove-Item $OutputPatchGenExe -Force } + +# 编译 R0Installer 项目 +Write-Host "[2/5] 正在编译 R0Installer..." -ForegroundColor Yellow +Set-Location $InstallerDir +$process = Start-Process -FilePath $MSBuild -ArgumentList "R0Installer.csproj", "/p:Configuration=Release", "/verbosity:minimal", "/nologo" -Wait -PassThru -NoNewWindow + +if ($process.ExitCode -ne 0) { + Write-Host "" + Write-Host "[错误] R0Installer 编译失败!" -ForegroundColor Red + Read-Host "按回车键退出" + exit 1 +} + +# 复制 R0Installer 输出文件 +Write-Host "[3/5] 复制 R0Installer..." -ForegroundColor Yellow +Copy-Item $InstallerExe $OutputInstallerExe -Force + +# 编译 R0PatchGenerator 项目 +if (Test-Path $PatchGenDir) { + Write-Host "[4/5] 正在编译 R0PatchGenerator..." -ForegroundColor Yellow + Set-Location $PatchGenDir + $process = Start-Process -FilePath $MSBuild -ArgumentList "R0PatchGenerator.csproj", "/p:Configuration=Release", "/verbosity:minimal", "/nologo" -Wait -PassThru -NoNewWindow + + if ($process.ExitCode -ne 0) { + Write-Host "" + Write-Host "[警告] R0PatchGenerator 编译失败!" -ForegroundColor Yellow + } else { + # 复制 R0PatchGenerator 输出文件 + Write-Host "[5/5] 复制 R0PatchGenerator..." -ForegroundColor Yellow + Copy-Item $PatchGenExe $OutputPatchGenExe -Force + } +} else { + Write-Host "[4/5] 跳过 R0PatchGenerator (目录不存在)" -ForegroundColor Gray + Write-Host "[5/5] 跳过" -ForegroundColor Gray +} + +Set-Location $ScriptDir + +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host " 构建完成!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "" + +# 显示 R0Installer 文件信息 +if (Test-Path $OutputInstallerExe) { + $FileInfo = Get-Item $OutputInstallerExe + $SizeKB = [math]::Round($FileInfo.Length / 1KB, 2) + Write-Host "R0Installer.exe: $SizeKB KB" -ForegroundColor White +} + +# 显示 R0PatchGenerator 文件信息 +if (Test-Path $OutputPatchGenExe) { + $FileInfo = Get-Item $OutputPatchGenExe + $SizeKB = [math]::Round($FileInfo.Length / 1KB, 2) + Write-Host "R0PatchGenerator.exe: $SizeKB KB" -ForegroundColor White +} + +Write-Host "" +Write-Host "使用说明:" -ForegroundColor Cyan +Write-Host " 安装模式: R0Installer.exe" -ForegroundColor White +Write-Host " 更新模式: R0Installer.exe update <版本号>" -ForegroundColor White +Write-Host " 生成增量包: R0PatchGenerator.exe <旧版本目录> <新版本目录> <输出.zip> [从版本] [到版本]" -ForegroundColor White +Write-Host "" +Write-Host "注意: 增量包生成需要 hdiffz.exe,更新需要 hpatchz.exe" -ForegroundColor Yellow +Write-Host "" +Read-Host "按回车键退出" diff --git a/generate_patch.bat b/generate_patch.bat new file mode 100644 index 0000000..1233662 --- /dev/null +++ b/generate_patch.bat @@ -0,0 +1,161 @@ +@echo off +chcp 65001 >nul +setlocal EnableDelayedExpansion + +echo. +echo ======================================== +echo R0对战平台 增量包生成工具 +echo ======================================== +echo. + +:: 检查 R0PatchGenerator.exe 是否存在 +if not exist "%~dp0R0PatchGenerator.exe" ( + echo [错误] 未找到 R0PatchGenerator.exe + echo 请先运行 build.bat 编译项目 + echo. + pause + exit /b 1 +) + +:: 检查 hdiffz.exe 是否存在 +if not exist "%~dp0hdiffz.exe" ( + echo [错误] 未找到 hdiffz.exe + echo 请将 hdiffz.exe 放在同一目录下 + echo. + pause + exit /b 1 +) + +:: 询问旧版本目录 +echo [1/5] 请输入旧版本目录路径: +echo (包含旧版本完整文件的目录) +set /p OLD_PATH=^> + +if "!OLD_PATH!"=="" ( + echo [错误] 旧版本目录不能为空 + pause + exit /b 1 +) + +:: 去除路径两端的引号 +set OLD_PATH=!OLD_PATH:"=! + +if not exist "!OLD_PATH!" ( + echo [错误] 旧版本目录不存在: !OLD_PATH! + pause + exit /b 1 +) + +echo. + +:: 询问新版本目录 +echo [2/5] 请输入新版本目录路径: +echo (包含新版本完整文件的目录) +set /p NEW_PATH=^> + +if "!NEW_PATH!"=="" ( + echo [错误] 新版本目录不能为空 + pause + exit /b 1 +) + +:: 去除路径两端的引号 +set NEW_PATH=!NEW_PATH:"=! + +if not exist "!NEW_PATH!" ( + echo [错误] 新版本目录不存在: !NEW_PATH! + pause + exit /b 1 +) + +echo. + +:: 询问从版本号 +echo [3/5] 请输入旧版本号 (例如: 1.0.0): +set /p FROM_VER=^> + +if "!FROM_VER!"=="" ( + echo [警告] 未输入版本号,将使用 "unknown" + set FROM_VER=unknown +) + +echo. + +:: 询问到版本号 +echo [4/5] 请输入新版本号 (例如: 1.0.1): +set /p TO_VER=^> + +if "!TO_VER!"=="" ( + echo [警告] 未输入版本号,将使用 "unknown" + set TO_VER=unknown +) + +echo. + +:: 询问输出文件路径 +echo [5/5] 请输入输出文件路径: +echo (默认: %~dp0patch_!FROM_VER!_to_!TO_VER!.zip) +set /p OUTPUT_PATH=^> + +if "!OUTPUT_PATH!"=="" ( + set OUTPUT_PATH=%~dp0patch_!FROM_VER!_to_!TO_VER!.zip + echo 使用默认路径: !OUTPUT_PATH! +) + +:: 去除路径两端的引号 +set OUTPUT_PATH=!OUTPUT_PATH:"=! + +echo. +echo ======================================== +echo 生成参数确认 +echo ======================================== +echo. +echo 旧版本目录: !OLD_PATH! +echo 新版本目录: !NEW_PATH! +echo 旧版本号: !FROM_VER! +echo 新版本号: !TO_VER! +echo 输出文件: !OUTPUT_PATH! +echo. +echo ======================================== +echo. + +set /p CONFIRM=确认开始生成? (Y/N): + +if /i not "!CONFIRM!"=="Y" ( + echo. + echo 已取消操作 + pause + exit /b 0 +) + +echo. +echo 正在生成增量包,请稍候... +echo. + +:: 执行生成 +"%~dp0R0PatchGenerator.exe" "!OLD_PATH!" "!NEW_PATH!" "!OUTPUT_PATH!" "!FROM_VER!" "!TO_VER!" + +if %ERRORLEVEL% EQU 0 ( + echo. + echo ======================================== + echo 增量包生成完成! + echo ======================================== + echo. + if exist "!OUTPUT_PATH!" ( + for %%A in ("!OUTPUT_PATH!") do ( + set /a SIZE_KB=%%~zA / 1024 + set /a SIZE_MB=%%~zA / 1048576 + ) + echo 输出文件: !OUTPUT_PATH! + call echo 文件大小: %%SIZE_KB%% KB ^(%%SIZE_MB%% MB^) + ) +) else ( + echo. + echo ======================================== + echo 增量包生成失败! + echo ======================================== +) + +echo. +pause + diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aa40284a24c6ba2ef87e2cb75cbe6d2ee2d2560e GIT binary patch literal 16958 zcmeHPc~F(t6~8QkV8E!n$0h=TvI>tKWP3b7a9`q$WyO=4=) zru{?HOp{64bS6!rO;a-&JK8ogw$q7ms+}%lt<}_(diwj__dfVU9!3Qv zwoj~q0m*5W0V>@R()wu+zLNJ2D0@_7&7SJ=3qKiGEvvIL-UVE1|K7b-kYBGJj9jVt z!;)2E|9<7)FE2U0G%H=~(NXe)5e}J`nzR$Z0|?&N+q=i&YO>5qNs_-UtQA>V zFB^$JIZ-Yyt&kxUF4*oU zK0$0%Snt1^Jxybm6^mQ&|M$@a`q`-kvt@eHP$|dyfUR7gm6WgBeYp z92(>K2Vk=({b<84RoJC)V5G49-||I5+(tF*U(dhs96ES={3ywZ2$RLOVX~{z{tufa zWb~HO;Wu{F4ev}g>NhhvVe@NMj_dUGkL6{_4H#Wm+r^L1nxUs{j$*NeT4h5)t~_5_ zcs#66U`BCdxSE+Xv>PIC9TIJ|I%ZHkuwR^#I7G6;!{jsAw5);|8*_XM{9FfL&YvX? z=q>|$!i#0avOYiiL-Z0E@jxFH;*2F)JMAV{*t=DR)iU}O*eq?Av0Z*-gdCgI>hsJx za5w+Nf2wY_jEIfaGUz)PFVv@}?giAKUWkw3Te=BqXG~uR+oX-YU@tn(xMmn)+f-EI zKYAzlu@?}33UtUiDlS&`RhKtU85-XR;5vcs4m*M+AVAS3Q3h@($oX(9Y&9!9RAcWf z{M03Nz*up2mE*iUDxy|C_$m9muZ28DFQ**_)!S0{(MF44tEm`kXU;!m89G>zZIhkI z9TWOL=%_<3dB2Lu_I#&;$3XtpOdI7q8RPBD`8Q;yV^4UhC|@=qhwSU`mz0j2(q_Fs zyonroN0miI*233cpl#CM8RKM2{LE|abp2Pl*8dGMEfyUcFke`kFXqamMxMkrG?tp%u7p=-6P7*f;XA=#k`p`gDxp19wU%8 zhkZy&q6X@Gjd`2K>ISEQw{3e%{O;sI7v~!0$*W zK&bclxY)Ju^;gHm57v3wk(r2XIXCXc+QIWX#!s2(^TfQF!aR{Y;&U$E1hHY<5agcB zK}~zqUF+k|b7v@WbL8mA)zR+XLY~$kcEe`f=-`(4jqkhDFP{GfK7ziG`;3`4VGi0E zXG`*n1K=@YRIH1~AV)`iZv%(PJgwb%$GFZco-1!p7^QW@>-OI32>(~$M93GsIc}uf zYJD95f5?5IA|{$V^7Yxt3D0A~{W||wYE^o$n34GS$g?W0Py4(1lF-1d0nw+hN4&B(n)nyB1i{0rDrzQ@i4lY&241zPh zbKbT;d8G7iqai}=ULf4JY1L%IT-W<9rDLof7) zj6;$_21q^h1>WSJ8P<@o;NEU)-rLxWe@#llX4(`vIo98Hy9TyFqRn{G5Mc;#tWd#6LH@Yxb@9 z-ly<~WjIq~O!h)q5qXmjFsG4`nm5tMYqzwACfH1-)iRbgL>`0vGuG+I-~D}2t<1%~ z@OAPZ%6WZMrCv|;CtK}BI^Ju+)ee3`2RtkL)0{`#>j3(AV{EnFpBQ_;ikx(bZP-45 z4q~p_5pS{}EkzX%j3AGZ3ETPOdT?*FWBU9HOUO~T@9${8>%e(%zBx{#wm1Kb-S&;F z(BqppG+tI_XJ|dpx9W8O?9_wV{tbDC4i;ig^q#_Z&GYZNz6L)*ow9%0Bl>He>f}xI zk1g7Z3oL;FiZ+5m9^)hO7_^~T$m{-ESJS?}(LXueS1QXiSLqJF&VO{=x=^3>axF7o z{Nc!oD~Mgs5PJc5BIXd>_ZUZ?MxVy|H{&<1nFC+OKGR||W<6kB+@DrKxAcd^fb)b4 z$W`aaXTQA3{xy%`JaZ0$t?VU_LC%_S1=kzTX1GS%*7L|~-kCUBkKN!rjPbFhJ{XrU zXZaUmfSnbkdfvIF!0mrEd1&0a(7-^)Ge!AFnh;O0Kkl16ANsuJVSP^MPHqkivB$Ya?0jvoYN#iZ#;{7C?Q_^sL8tJwNZ!Y z&p2;MAx@5tYu{YiFLl6kpD~D+88dP%y;@mzrZPHe5ys$K8WqXhJ$YSz_WQeFM?4c| zrQOmuP=;Biam_w?MlcC$h(5G+T*mm~WeT2?J59#B9LE#T?K8#s$NKg5Pe~cj-{9?( zY}%-@L#=b(99w;9KYR!Gjz+x8_>4ZFqsn(0hv&Ji8}S+CQ3lVw!CU+#KO(HtmgkT&{(AZ(`os6opT%`f!@j-o+l1Q1 z>8U%HXQnqH-sysBYih8izKDM8@}rm8r}A-~={4Z@PmDEDLa-**tjx0Q==?F*ns6S~ zv?4Qo$C$w}n-Sl|nmHp`vs7^|y1G8%CJq_gHyFSD@l*2H@-82P$W?9oh!3fAPw{BL zqXCZwJR0z5z@q_=20R+@XuzWZj|My%_!ejY`EF;G=4hv+%YB{gIahnq-+6z~$JKf{ z+eRk3C`xT)bKTW8D^<$-%SuJNS}o`tPPBM`($yXWfx!Dl{09fFRtw~$ZPenlAc)f~ z#A$&LqZ9<{76MQ59)b?Cg`h@P%SI`Vie;m#E!E;6NQ;9YEe7pL{hIPW=xlvltyiP- S{%iepz3X)lZxhkIDE|ip*TV$> literal 0 HcmV?d00001 diff --git a/start.html b/start.html new file mode 100644 index 0000000..ae685c2 --- /dev/null +++ b/start.html @@ -0,0 +1,1466 @@ + + + + + + R0对战平台 + + + + +
+ +
+ +
+ + +
+ + +
+ + +
+ + + + + + + + + + +
+ + +
+
+ + +
+ + +
+
+ +
+ + + +
+
+
+
+
正在检测升级
+ +
+
+ + +
+ + + +
+ + +
+ ⚠️ 当前网速较慢,建议下载最新安装包 + 点击下载 +
+ + +
v2.0
+
+ + + + + diff --git a/更新打包流程.txt b/更新打包流程.txt new file mode 100644 index 0000000..3f6f778 --- /dev/null +++ b/更新打包流程.txt @@ -0,0 +1,17 @@ +打包流程 +1、打包整体客户端文件 +2、签名 R0对战平台.exe +3、使用inno setup打包 r0_arena_v{version}.exe,安装包文件 +4、安装包签名 +5、将整体客户端文件丢入versions文件夹内对应版本号文件夹,运行generate_patch.bat,生成上一版本到此版本的增量文件,例如patch_3.0.1_to_3.0.2.zip +6、将安装包文件exe、patch zip文件丢入pcdn桶client文件夹,后续自动化 + +检查更新流程 +1、首次安装 +官网下载的是安装器程序,即R0Installer.exe,直接运行将直接进行环境检查、安装、主程序安装,下载完成后会运行安装包程序 +2、后续更新 +打包包体内含R0Installer.exe、hpatchz.exe,同官网安装器,每次启动程序时运行 +R0Installer.exe update +即传入当前版本号,无需进行任何其他操作 +如果无更新,更新器会输出OK并自我结束 +如果有更新,更新器会自动杀死r0_arena.exe/R0对战平台.exe,然后进行全量/增量更新,完成后运行r0_arena.exe/R0对战平台.exe \ No newline at end of file