Initial commit: R0Installer source, patch generator, API backend and build scripts

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-27 18:03:44 +08:00
commit 6124d6060c
33 changed files with 8389 additions and 0 deletions
+28
View File
@@ -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
+918
View File
@@ -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 <version> --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 <version>`
- 安装器自动匹配 `command_rules` 中的 `update_community` 规则
- 如需无界面静默更新:在 `command_rules` 中将该规则 `silent` 设为 `true`,或调用时追加 `--silent` 参数(`R0Installer.exe update_community <version> --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)
);
```
+577
View File
@@ -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<string> 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<string> ParseJsonObjectArray(string arrayJson)
{
List<string> objects = new List<string>();
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<string> ParseJsonStringArray(string arrayJson)
{
List<string> strings = new List<string>();
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<string> 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<string> 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<string> 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<string> 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
}
}
File diff suppressed because it is too large Load Diff
+171
View File
@@ -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 <version> --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));
}
}
}
+16
View File
@@ -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")]
+39
View File
@@ -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;
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="0" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+58
View File
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>R0Installer</RootNamespace>
<AssemblyName>R0Installer</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<ApplicationIcon>..\icon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="ConfigManager.cs" />
<Compile Include="MainForm.cs" />
<Compile Include="UpdateForm.cs" />
<Compile Include="SilentUpdater.cs" />
<Compile Include="UpdateManager.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<Content Include="..\icon.ico" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
+23
View File
@@ -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
+232
View File
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
namespace R0Installer
{
/// <summary>
/// 静默更新执行器:不创建任何窗口,直接在后台完成检查/下载/应用更新。
/// 复用与 UpdateForm 完全相同的配置加载与 UpdateManager 逻辑,
/// 仅去除界面展示与纯用于观感的等待,因此更新行为与有界面模式一致。
/// </summary>
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();
}
/// <summary>同步入口:阻塞当前线程直到静默更新完成,返回是否成功。</summary>
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<bool> 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);
}
}
}
}
+492
View File
@@ -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<string> 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<int, string, string>(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<string>(OnStatusUpdate), message); return; }
statusLabel.Text = message;
}
private void UpdateVersionLabel(string newVersion)
{
if (this.InvokeRequired) { this.Invoke(new Action<string>(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
}
}
+944
View File
@@ -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<int, string, string> OnProgress;
public Action<string> 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<UpdateCheckResult> 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<IncrementalUpdate> ParseIncrementalUpdates(string json)
{
List<IncrementalUpdate> updates = new List<IncrementalUpdate>();
string arrayJson = ConfigManager.ExtractJsonArray(json, "incremental_updates");
if (string.IsNullOrEmpty(arrayJson)) return updates;
List<string> 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<bool> PerformFullUpdate(UpdateCheckResult updateInfo)
{
return await PerformFullUpdate(updateInfo, false);
}
public async Task<bool> 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<bool> 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<bool> PerformMultiIncrementalUpdate(List<IncrementalUpdate> 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<bool> 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<bool> 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<PatchDiffInfo> ParseModifiedArray(string json)
{
List<PatchDiffInfo> list = new List<PatchDiffInfo>();
string arrayJson = ConfigManager.ExtractJsonArray(json, "modified");
if (string.IsNullOrEmpty(arrayJson)) return list;
List<string> 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<PatchFileInfo> ParseNewFilesArray(string json)
{
List<PatchFileInfo> list = new List<PatchFileInfo>();
string arrayJson = ConfigManager.ExtractJsonArray(json, "new_files");
if (string.IsNullOrEmpty(arrayJson)) return list;
List<string> 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<string> ParseDeletedArray(string json)
{
string arrayJson = ConfigManager.ExtractJsonArray(json, "deleted");
if (string.IsNullOrEmpty(arrayJson)) return new List<string>();
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<string> 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<ProbeResult> 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<WebResponse>(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<WebResponse>(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<WebResponse>(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<int> ReadStreamAsync(Stream stream, byte[] buffer, int offset, int count)
{
return Task.Factory.FromAsync<int>(
(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<IncrementalUpdate> 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<PatchDiffInfo> modified = new List<PatchDiffInfo>();
public List<PatchFileInfo> new_files = new List<PatchFileInfo>();
public List<string> deleted = new List<string>();
}
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
}
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="R0Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- 请求管理员权限 -->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / Windows 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
</application>
</compatibility>
</assembly>
+368
View File
@@ -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<string, FileInfo> oldFiles = GetAllFiles(oldPath);
Dictionary<string, FileInfo> 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<string, FileInfo> GetAllFiles(string rootPath)
{
Dictionary<string, FileInfo> files = new Dictionary<string, FileInfo>(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<string> unchanged = new List<string>();
public List<PatchDiffInfo> modified = new List<PatchDiffInfo>();
public List<PatchFileInfo> new_files = new List<PatchFileInfo>();
public List<string> deleted = new List<string>();
}
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;
}
}
+109
View File
@@ -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);
}
}
}
@@ -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")]
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{B2C3D4E5-F6A7-8901-BCDE-F23456789012}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>R0PatchGenerator</RootNamespace>
<AssemblyName>R0PatchGenerator</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>true</Deterministic>
<ApplicationIcon>..\icon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.Compression.FileSystem" />
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="PatchGenerator.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<Content Include="..\icon.ico" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
+7
View File
@@ -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]
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* Medoo 数据库框架占位文件
*
* 请从官方下载完整版本替换此文件:
* composer require catfan/medoo
*
* 或者从 https://medoo.in/ 下载 Medoo.php 放到此目录
*
* 使用 composer 时请将 require 改为:
* require_once __DIR__ . '/vendor/autoload.php';
*/
// 如果已通过 composer 安装,取消下面的注释:
// require_once __DIR__ . '/vendor/autoload.php';
// 如果您直接下载了 Medoo.php,它应该在此位置
// 此文件仅为提示,请用实际的 Medoo 库替换
if (!class_exists('Medoo\Medoo')) {
die('请安装 Medoo 数据库框架: composer require catfan/medoo 或从 https://medoo.in/ 下载');
}
+743
View File
@@ -0,0 +1,743 @@
<?php
session_start();
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../db.php';
require_once __DIR__ . '/../helpers.php';
// 登录检查
function isLoggedIn() {
return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
}
// 处理登录
if (isset($_POST['action']) && $_POST['action'] === 'login') {
$user = $_POST['username'] ?? '';
$pass = $_POST['password'] ?? '';
if ($user === ADMIN_USER && $pass === ADMIN_PASS) {
$_SESSION['admin_logged_in'] = true;
header('Location: index.php');
exit;
}
$loginError = '用户名或密码错误';
}
// 处理登出
if (isset($_GET['action']) && $_GET['action'] === 'logout') {
session_destroy();
header('Location: index.php');
exit;
}
// 未登录显示登录页
if (!isLoggedIn()) {
showLoginPage($loginError ?? '');
exit;
}
$db = getDb();
$page = $_GET['page'] ?? 'dashboard';
// AJAX 操作处理
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['ajax_action'])) {
handleAjaxAction($db, $_POST['ajax_action']);
}
// 页面路由
showAdminPage($db, $page);
// ========== AJAX 处理 ==========
function handleAjaxAction($db, $action) {
header('Content-Type: application/json; charset=utf-8');
switch ($action) {
case 'save_project':
$projectId = $_POST['project_id'] ?? '';
$displayName = $_POST['display_name'] ?? '';
$description = $_POST['description'] ?? '';
$iconUrl = $_POST['icon_url'] ?? '';
$enabled = isset($_POST['enabled']) ? 1 : 0;
$configJson = $_POST['config_json'] ?? '{}';
$editId = $_POST['edit_id'] ?? '';
// 验证 JSON
$parsed = json_decode($configJson, true);
if ($parsed === null && $configJson !== 'null') {
echo json_encode(['success' => 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 = '') {
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>R0Installer 管理后台 - 登录</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f0f; color: #e0e0e0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.login-box { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 40px; width: 360px; }
.login-box h1 { color: #4CAF50; font-size: 22px; text-align: center; margin-bottom: 30px; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 6px; color: #999; font-size: 14px; }
.form-group input { width: 100%; padding: 10px 14px; background: #252525; border: 1px solid #444; border-radius: 6px; color: #e0e0e0; font-size: 15px; outline: none; transition: border-color 0.2s; }
.form-group input:focus { border-color: #4CAF50; }
.btn-login { width: 100%; padding: 12px; background: #4CAF50; color: #fff; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; transition: background 0.2s; }
.btn-login:hover { background: #45a049; }
.error { color: #f44336; text-align: center; margin-bottom: 16px; font-size: 14px; }
</style>
</head>
<body>
<div class="login-box">
<h1>R0Installer 管理后台</h1>
<?php if ($error): ?><div class="error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<form method="post">
<input type="hidden" name="action" value="login">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" required autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn-login">登录</button>
</form>
</div>
</body>
</html>
<?php
}
function showAdminPage($db, $page) {
$projects = $db->select('projects', '*');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>R0Installer 管理后台</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f0f; color: #e0e0e0; }
.layout { display: flex; min-height: 100vh; }
.sidebar { width: 220px; background: #1a1a1a; border-right: 1px solid #333; padding: 20px 0; flex-shrink: 0; }
.sidebar h2 { color: #4CAF50; font-size: 16px; padding: 0 20px 20px; border-bottom: 1px solid #333; }
.sidebar a { display: block; padding: 12px 20px; color: #999; text-decoration: none; font-size: 14px; transition: all 0.2s; }
.sidebar a:hover, .sidebar a.active { color: #4CAF50; background: #252525; }
.sidebar .logout { position: absolute; bottom: 20px; width: 220px; }
.main { flex: 1; padding: 30px; overflow-x: auto; }
.main h2 { color: #4CAF50; margin-bottom: 20px; font-size: 20px; }
table { width: 100%; border-collapse: collapse; background: #1a1a1a; border-radius: 8px; overflow: hidden; }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #2a2a2a; font-size: 14px; }
th { background: #252525; color: #4CAF50; font-weight: 600; }
tr:hover { background: #1f1f1f; }
.btn { padding: 6px 14px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; transition: opacity 0.2s; }
.btn-primary { background: #4CAF50; color: #fff; }
.btn-danger { background: #f44336; color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn:hover { opacity: 0.85; }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: flex-start; padding-top: 60px; }
.modal-overlay.show { display: flex; }
.modal { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; padding: 30px; width: 700px; max-height: 80vh; overflow-y: auto; }
.modal h3 { color: #4CAF50; margin-bottom: 20px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 4px; color: #999; font-size: 13px; }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 12px; background: #252525; border: 1px solid #444; border-radius: 4px; color: #e0e0e0; font-size: 14px; outline: none; }
.form-group textarea { min-height: 200px; font-family: monospace; font-size: 13px; }
.form-group input:focus, .form-group textarea:focus { border-color: #4CAF50; }
.form-row { display: flex; gap: 16px; }
.form-row .form-group { flex: 1; }
.form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
.badge-green { background: #1b5e20; color: #4CAF50; }
.badge-gray { background: #333; color: #999; }
.badge-blue { background: #0d47a1; color: #42a5f5; }
.toast { position: fixed; top: 20px; right: 20px; background: #4CAF50; color: #fff; padding: 12px 24px; border-radius: 6px; z-index: 2000; display: none; font-size: 14px; }
.tag { display: inline-block; padding: 2px 6px; background: #333; color: #aaa; border-radius: 3px; font-size: 11px; margin-right: 4px; }
.checkbox-label { display: flex; align-items: center; gap: 8px; color: #ccc; font-size: 14px; cursor: pointer; }
.checkbox-label input[type="checkbox"] { width: 18px; height: 18px; }
</style>
</head>
<body>
<div class="layout">
<div class="sidebar">
<h2>R0Installer</h2>
<a href="?page=dashboard" class="<?= $page === 'dashboard' ? 'active' : '' ?>">仪表盘</a>
<a href="?page=projects" class="<?= $page === 'projects' ? 'active' : '' ?>">项目管理</a>
<a href="?page=versions" class="<?= $page === 'versions' ? 'active' : '' ?>">版本管理</a>
<a href="?page=patches" class="<?= $page === 'patches' ? 'active' : '' ?>">增量包管理</a>
<a href="?page=installer" class="<?= $page === 'installer' ? 'active' : '' ?>">安装器配置</a>
<a href="?action=logout" style="color:#f44336; margin-top: 40px;">退出登录</a>
</div>
<div class="main">
<?php
switch ($page) {
case 'projects': renderProjectsPage($db, $projects); break;
case 'versions': renderVersionsPage($db, $projects); break;
case 'patches': renderPatchesPage($db, $projects); break;
case 'installer': renderInstallerPage($db); break;
default: renderDashboard($db, $projects); break;
}
?>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.display = 'block';
setTimeout(() => t.style.display = 'none', 2000);
}
function openModal(id) {
document.getElementById(id).classList.add('show');
}
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
function submitForm(formId, callback) {
const form = document.getElementById(formId);
const data = new FormData(form);
fetch('index.php', { method: 'POST', body: data })
.then(r => r.json())
.then(d => {
if (d.success) {
showToast('保存成功');
setTimeout(() => location.reload(), 500);
} else {
alert(d.message || '操作失败');
}
})
.catch(e => alert('请求失败: ' + e));
}
function deleteItem(action, id) {
if (!confirm('确定要删除吗?')) return;
const data = new FormData();
data.append('ajax_action', action);
data.append('id', id);
fetch('index.php', { method: 'POST', body: data })
.then(r => r.json())
.then(d => {
if (d.success) { showToast('已删除'); setTimeout(() => location.reload(), 500); }
else alert(d.message || '删除失败');
});
}
</script>
</body>
</html>
<?php
}
// ========== 各页面渲染 ==========
function renderDashboard($db, $projects) {
$versionCount = $db->count('project_versions');
$patchCount = $db->count('project_patches');
?>
<h2>仪表盘</h2>
<div style="display:flex;gap:20px;margin-bottom:30px;">
<div style="background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:24px;flex:1;text-align:center;">
<div style="font-size:36px;color:#4CAF50;font-weight:bold;"><?= count($projects) ?></div>
<div style="color:#999;margin-top:8px;">项目总数</div>
</div>
<div style="background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:24px;flex:1;text-align:center;">
<div style="font-size:36px;color:#42a5f5;font-weight:bold;"><?= $versionCount ?></div>
<div style="color:#999;margin-top:8px;">版本总数</div>
</div>
<div style="background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:24px;flex:1;text-align:center;">
<div style="font-size:36px;color:#ff9800;font-weight:bold;"><?= $patchCount ?></div>
<div style="color:#999;margin-top:8px;">增量包总数</div>
</div>
</div>
<h2 style="font-size:16px;margin-bottom:12px;">项目列表</h2>
<table>
<tr><th>项目ID</th><th>名称</th><th>状态</th></tr>
<?php foreach ($projects as $p): ?>
<tr>
<td><code><?= htmlspecialchars($p['project_id']) ?></code></td>
<td><?= htmlspecialchars($p['display_name']) ?></td>
<td><?= $p['enabled'] ? '<span class="badge badge-green">启用</span>' : '<span class="badge badge-gray">禁用</span>' ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php
}
function renderProjectsPage($db, $projects) {
?>
<h2>项目管理 <button class="btn btn-primary" onclick="openModal('projectModal');document.getElementById('projectForm').reset();document.getElementById('projectEditId').value='';">新增项目</button></h2>
<table>
<tr><th>ID</th><th>项目ID</th><th>名称</th><th>状态</th><th>更新时间</th><th>操作</th></tr>
<?php foreach ($projects as $p): ?>
<tr>
<td><?= $p['id'] ?></td>
<td><code><?= htmlspecialchars($p['project_id']) ?></code></td>
<td><?= htmlspecialchars($p['display_name']) ?></td>
<td><?= $p['enabled'] ? '<span class="badge badge-green">启用</span>' : '<span class="badge badge-gray">禁用</span>' ?></td>
<td><?= $p['updated_at'] ?></td>
<td>
<button class="btn btn-primary btn-sm" onclick='editProject(<?= json_encode($p, JSON_UNESCAPED_UNICODE) ?>)'>编辑</button>
<button class="btn btn-danger btn-sm" onclick="deleteItem('delete_project', <?= $p['id'] ?>)">删除</button>
</td>
</tr>
<?php endforeach; ?>
</table>
<div class="modal-overlay" id="projectModal">
<div class="modal">
<h3>项目配置</h3>
<form id="projectForm">
<input type="hidden" name="ajax_action" value="save_project">
<input type="hidden" name="edit_id" id="projectEditId">
<div class="form-row">
<div class="form-group">
<label>项目ID(唯一标识,创建后不可改)</label>
<input type="text" name="project_id" id="projectIdInput" required>
</div>
<div class="form-group">
<label>显示名称</label>
<input type="text" name="display_name" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>描述</label>
<input type="text" name="description">
</div>
<div class="form-group">
<label>图标URL</label>
<input type="text" name="icon_url">
</div>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" name="enabled" checked> 启用</label>
</div>
<div class="form-group">
<label>项目配置 JSONinstall / update / runtime_requirements / download_config</label>
<textarea name="config_json" style="min-height:300px;">{}</textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" onclick="closeModal('projectModal')">取消</button>
<button type="button" class="btn btn-primary" onclick="submitForm('projectForm')">保存</button>
</div>
</form>
</div>
</div>
<script>
function editProject(p) {
const f = document.getElementById('projectForm');
document.getElementById('projectEditId').value = p.id;
f.querySelector('[name=project_id]').value = p.project_id;
f.querySelector('[name=project_id]').readOnly = true;
f.querySelector('[name=display_name]').value = p.display_name;
f.querySelector('[name=description]').value = p.description || '';
f.querySelector('[name=icon_url]').value = p.icon_url || '';
f.querySelector('[name=enabled]').checked = !!p.enabled;
try {
const cfg = typeof p.config_json === 'string' ? JSON.parse(p.config_json) : p.config_json;
f.querySelector('[name=config_json]').value = JSON.stringify(cfg, null, 4);
} catch(e) {
f.querySelector('[name=config_json]').value = p.config_json || '{}';
}
openModal('projectModal');
}
</script>
<?php
}
function renderVersionsPage($db, $projects) {
$filterProject = $_GET['project'] ?? '';
$where = $filterProject ? ['project_id' => $filterProject] : [];
$where['ORDER'] = ['id' => 'DESC'];
$versions = $db->select('project_versions', '*', $where);
?>
<h2>版本管理 <button class="btn btn-primary" onclick="openModal('versionModal');document.getElementById('versionForm').reset();document.getElementById('versionEditId').value='';">新增版本</button></h2>
<div style="margin-bottom:16px;">
筛选项目:
<a href="?page=versions" class="tag <?= !$filterProject ? 'style=background:#4CAF50;color:#fff' : '' ?>">全部</a>
<?php foreach ($projects as $p): ?>
<a href="?page=versions&project=<?= urlencode($p['project_id']) ?>" class="tag <?= $filterProject === $p['project_id'] ? 'style=background:#4CAF50;color:#fff' : '' ?>"><?= htmlspecialchars($p['display_name']) ?></a>
<?php endforeach; ?>
</div>
<table>
<tr><th>ID</th><th>项目</th><th>版本</th><th>最新</th><th>静默更新</th><th>线程数</th><th>创建时间</th><th>操作</th></tr>
<?php foreach ($versions as $v): ?>
<tr>
<td><?= $v['id'] ?></td>
<td><code><?= htmlspecialchars($v['project_id']) ?></code></td>
<td><strong><?= htmlspecialchars($v['version']) ?></strong></td>
<td><?= $v['is_latest'] ? '<span class="badge badge-green">是</span>' : '<span class="badge badge-gray">否</span>' ?></td>
<td><?= !empty($v['silent_update']) ? '<span class="badge badge-blue">静默</span>' : '<span class="badge badge-gray">界面</span>' ?></td>
<td><?= $v['threads'] ?></td>
<td><?= $v['created_at'] ?></td>
<td>
<button class="btn btn-primary btn-sm" onclick='editVersion(<?= json_encode($v, JSON_UNESCAPED_UNICODE) ?>)'>编辑</button>
<button class="btn btn-danger btn-sm" onclick="deleteItem('delete_version', <?= $v['id'] ?>)">删除</button>
</td>
</tr>
<?php endforeach; ?>
</table>
<div class="modal-overlay" id="versionModal">
<div class="modal">
<h3>版本配置</h3>
<form id="versionForm">
<input type="hidden" name="ajax_action" value="save_version">
<input type="hidden" name="edit_id" id="versionEditId">
<div class="form-row">
<div class="form-group">
<label>项目ID</label>
<select name="project_id" required>
<?php foreach ($projects as $p): ?>
<option value="<?= htmlspecialchars($p['project_id']) ?>"><?= htmlspecialchars($p['display_name']) ?> (<?= htmlspecialchars($p['project_id']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>版本号</label>
<input type="text" name="version" required placeholder="如 3.0.5">
</div>
</div>
<div class="form-group">
<label>下载地址 (download_url)</label>
<input type="text" name="download_url" required placeholder="https://cdn.r0csgo.com/releases/...">
</div>
<div class="form-row">
<div class="form-group">
<label>文件大小 (bytes)</label>
<input type="number" name="file_size" value="0">
</div>
<div class="form-group">
<label>下载线程数</label>
<input type="number" name="threads" value="8">
</div>
</div>
<div class="form-group">
<label>更新日志</label>
<textarea name="changelog" style="min-height:60px;"></textarea>
</div>
<div class="form-group">
<label>额外下载项 JSON (extra_downloads),如 {"download_url_ac_cdn": "..."}</label>
<textarea name="extra_downloads" style="min-height:60px;">{}</textarea>
</div>
<div class="form-group">
<label>运行库下载地址覆盖 JSON (runtime_urls)</label>
<textarea name="runtime_urls" style="min-height:60px;">{}</textarea>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" name="is_latest"> 设为最新版本</label>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" name="silent_update"> 静默更新(更新到该版本时不弹界面,后台自动下载安装并拉起主程序)</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" onclick="closeModal('versionModal')">取消</button>
<button type="button" class="btn btn-primary" onclick="submitForm('versionForm')">保存</button>
</div>
</form>
</div>
</div>
<script>
function editVersion(v) {
const f = document.getElementById('versionForm');
document.getElementById('versionEditId').value = v.id;
f.querySelector('[name=project_id]').value = v.project_id;
f.querySelector('[name=version]').value = v.version;
f.querySelector('[name=download_url]').value = v.download_url;
f.querySelector('[name=file_size]').value = v.file_size;
f.querySelector('[name=threads]').value = v.threads;
f.querySelector('[name=changelog]').value = v.changelog || '';
f.querySelector('[name=extra_downloads]').value = v.extra_downloads || '{}';
f.querySelector('[name=runtime_urls]').value = v.runtime_urls || '{}';
f.querySelector('[name=is_latest]').checked = !!v.is_latest;
f.querySelector('[name=silent_update]').checked = !!Number(v.silent_update);
openModal('versionModal');
}
</script>
<?php
}
function renderPatchesPage($db, $projects) {
$filterProject = $_GET['project'] ?? '';
$where = $filterProject ? ['project_id' => $filterProject] : [];
$where['ORDER'] = ['id' => 'DESC'];
$patches = $db->select('project_patches', '*', $where);
?>
<h2>增量包管理 <button class="btn btn-primary" onclick="openModal('patchModal');document.getElementById('patchForm').reset();document.getElementById('patchEditId').value='';">新增增量包</button></h2>
<div style="margin-bottom:16px;">
筛选项目:
<a href="?page=patches" class="tag <?= !$filterProject ? 'style=background:#4CAF50;color:#fff' : '' ?>">全部</a>
<?php foreach ($projects as $p): ?>
<a href="?page=patches&project=<?= urlencode($p['project_id']) ?>" class="tag <?= $filterProject === $p['project_id'] ? 'style=background:#4CAF50;color:#fff' : '' ?>"><?= htmlspecialchars($p['display_name']) ?></a>
<?php endforeach; ?>
</div>
<table>
<tr><th>ID</th><th>项目</th><th>从版本</th><th>到版本</th><th>文件大小</th><th>创建时间</th><th>操作</th></tr>
<?php foreach ($patches as $p): ?>
<tr>
<td><?= $p['id'] ?></td>
<td><code><?= htmlspecialchars($p['project_id']) ?></code></td>
<td><?= htmlspecialchars($p['from_version']) ?></td>
<td><?= htmlspecialchars($p['to_version']) ?></td>
<td><?= number_format($p['file_size']) ?></td>
<td><?= $p['created_at'] ?></td>
<td>
<button class="btn btn-primary btn-sm" onclick='editPatch(<?= json_encode($p, JSON_UNESCAPED_UNICODE) ?>)'>编辑</button>
<button class="btn btn-danger btn-sm" onclick="deleteItem('delete_patch', <?= $p['id'] ?>)">删除</button>
</td>
</tr>
<?php endforeach; ?>
</table>
<div class="modal-overlay" id="patchModal">
<div class="modal">
<h3>增量包配置</h3>
<form id="patchForm">
<input type="hidden" name="ajax_action" value="save_patch">
<input type="hidden" name="edit_id" id="patchEditId">
<div class="form-group">
<label>项目ID</label>
<select name="project_id" required>
<?php foreach ($projects as $p): ?>
<option value="<?= htmlspecialchars($p['project_id']) ?>"><?= htmlspecialchars($p['display_name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>从版本</label>
<input type="text" name="from_version" required placeholder="如 3.0.5">
</div>
<div class="form-group">
<label>到版本</label>
<input type="text" name="to_version" required placeholder="如 3.0.6">
</div>
</div>
<div class="form-group">
<label>下载地址</label>
<input type="text" name="download_url" required placeholder="https://cdn.r0csgo.com/patches/...">
</div>
<div class="form-row">
<div class="form-group">
<label>文件大小 (bytes)</label>
<input type="number" name="file_size" value="0">
</div>
</div>
<div class="form-group">
<label>更新日志</label>
<textarea name="changelog" style="min-height:60px;"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-danger" onclick="closeModal('patchModal')">取消</button>
<button type="button" class="btn btn-primary" onclick="submitForm('patchForm')">保存</button>
</div>
</form>
</div>
</div>
<script>
function editPatch(p) {
const f = document.getElementById('patchForm');
document.getElementById('patchEditId').value = p.id;
f.querySelector('[name=project_id]').value = p.project_id;
f.querySelector('[name=from_version]').value = p.from_version;
f.querySelector('[name=to_version]').value = p.to_version;
f.querySelector('[name=download_url]').value = p.download_url;
f.querySelector('[name=file_size]').value = p.file_size;
f.querySelector('[name=changelog]').value = p.changelog || '';
openModal('patchModal');
}
</script>
<?php
}
function renderInstallerPage($db) {
$config = $db->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) : '[]';
?>
<h2>安装器全局配置</h2>
<form id="installerForm" style="background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:24px;max-width:800px;">
<input type="hidden" name="ajax_action" value="save_installer_config">
<div class="form-row">
<div class="form-group">
<label>默认项目ID</label>
<input type="text" name="default_project" value="<?= htmlspecialchars($config['default_project'] ?? 'r0_arena') ?>">
</div>
<div class="form-group">
<label>默认安装模式</label>
<select name="default_mode">
<option value="full" <?= ($config['default_mode'] ?? '') === 'full' ? 'selected' : '' ?>>full(完整安装)</option>
<option value="client_only" <?= ($config['default_mode'] ?? '') === 'client_only' ? 'selected' : '' ?>>client_only(仅客户端)</option>
<option value="ac_only" <?= ($config['default_mode'] ?? '') === 'ac_only' ? 'selected' : '' ?>>ac_only(仅反作弊)</option>
</select>
</div>
</div>
<div class="form-group">
<label>文件名匹配规则 (filename_rules) JSON 数组</label>
<textarea name="filename_rules" style="min-height:200px;"><?= htmlspecialchars($frFormatted) ?></textarea>
</div>
<div class="form-group">
<label>命令行匹配规则 (command_rules) JSON 数组</label>
<textarea name="command_rules" style="min-height:150px;"><?= htmlspecialchars($crFormatted) ?></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="submitForm('installerForm')">保存配置</button>
</div>
</form>
<?php
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "r0/installer-api",
"description": "R0Installer 云端配置 API",
"require": {
"php": ">=7.4",
"catfan/medoo": "^2.1"
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
// 数据库配置
define('DB_TYPE', 'mysql');
define('DB_HOST', '127.0.0.1');
define('DB_PORT', 3306);
define('DB_NAME', 'installer');
define('DB_USER', 'installer');
define('DB_PASS', 'your-database-password');
define('DB_CHARSET', 'utf8mb4');
// 管理后台账号密码(硬编码)
define('ADMIN_USER', 'admin');
define('ADMIN_PASS', 'your-admin-password');
// API Base URL
define('API_BASE', '/v3');
+158
View File
@@ -0,0 +1,158 @@
-- R0Installer 云端配置数据库
-- 使用 MySQL 5.7+ / MariaDB 10.2+
CREATE DATABASE IF NOT EXISTS `r0_installer` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `r0_installer`;
-- 项目表
CREATE TABLE IF NOT EXISTS `projects` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`project_id` VARCHAR(50) NOT NULL UNIQUE,
`display_name` VARCHAR(100) NOT NULL,
`description` TEXT,
`icon_url` VARCHAR(500) DEFAULT '',
`enabled` TINYINT(1) DEFAULT 1,
`config_json` JSON NOT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 安装器全局配置表
CREATE TABLE IF NOT EXISTS `installer_config` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`config_key` VARCHAR(50) NOT NULL UNIQUE DEFAULT 'global',
`installer_version` VARCHAR(20) DEFAULT '2.0',
`min_client_version` VARCHAR(20) DEFAULT '1.0.0.0',
`filename_rules` JSON NOT NULL,
`command_rules` JSON NOT NULL,
`default_project` VARCHAR(50) DEFAULT 'r0_arena',
`default_mode` VARCHAR(20) DEFAULT 'full',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 项目版本表
CREATE TABLE IF NOT EXISTS `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 DEFAULT NULL,
`runtime_urls` JSON DEFAULT NULL,
`is_latest` TINYINT(1) DEFAULT 0,
`silent_update` TINYINT(1) DEFAULT 0, -- 1=更新到该版本时静默更新(不弹界面),0=显示更新界面
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_project_version` (`project_id`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 已有数据库升级:为旧的 project_versions 表补充 silent_update 列(首次部署可忽略本行;
-- MySQL 8 不支持 ADD COLUMN IF NOT EXISTS,若列已存在会报错,可直接跳过执行)。
-- ALTER TABLE `project_versions` ADD COLUMN `silent_update` TINYINT(1) DEFAULT 0 AFTER `is_latest`;
-- 增量更新包表
CREATE TABLE IF NOT EXISTS `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 CURRENT_TIMESTAMP,
UNIQUE KEY `uk_patch` (`project_id`, `from_version`, `to_version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ========== 初始数据 ==========
-- 安装器全局配置
INSERT INTO `installer_config` (`config_key`, `installer_version`, `min_client_version`, `filename_rules`, `command_rules`, `default_project`, `default_mode`)
VALUES ('global', '2.0', '1.0.0.0',
'[{"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对战平台"}]',
'[{"command":"update","project_id":"r0_arena","update_target":"update","description":"R0对战平台主程序更新"},{"command":"update_ac","project_id":"r0_arena","update_target":"update_ac","description":"R0对战平台反作弊更新"}]',
'r0_arena', 'full');
-- R0对战平台项目
INSERT INTO `projects` (`project_id`, `display_name`, `description`, `enabled`, `config_json`)
VALUES ('r0_arena', 'R0对战平台', 'R0对战平台客户端', 1,
'{
"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_template": "r0_guard_v{version}.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
},
"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"
}
}');
-- 示例版本
INSERT INTO `project_versions` (`project_id`, `version`, `download_url`, `file_size`, `changelog`, `threads`, `extra_downloads`, `runtime_urls`, `is_latest`)
VALUES ('r0_arena', '3.0.5', 'https://cdn.r0csgo.com/releases/r0_arena_v3.0.5.exe', 0, '最新版本', 8,
'{"download_url_ac_cdn": "https://cdn.r0csgo.com/releases/r0_guard_v2.1.0.exe"}',
'{"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"}',
1);
+21
View File
@@ -0,0 +1,21 @@
<?php
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/Medoo.php';
use Medoo\Medoo;
function getDb() {
static $db = null;
if ($db === null) {
$db = new Medoo([
'type' => DB_TYPE,
'host' => DB_HOST,
'port' => DB_PORT,
'database' => DB_NAME,
'username' => DB_USER,
'password' => DB_PASS,
'charset' => DB_CHARSET,
]);
}
return $db;
}
+25
View File
@@ -0,0 +1,25 @@
<?php
function json_response($data, $code = 0, $message = 'success') {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
echo json_encode([
'code' => $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, '/');
}
+286
View File
@@ -0,0 +1,286 @@
<?php
/**
* R0Installer 云端配置 API 入口
*
* Nginx 配置示例:
* location /v3/ {
* try_files $uri $uri/ /api/index.php?$query_string;
* }
*
* Apache .htaccess:
* RewriteEngine On
* RewriteRule ^v3/(.*)$ api/index.php [QSA,L]
*/
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/helpers.php';
$path = get_request_path();
$db = getDb();
// 路由匹配
if ($path === '/v3/config/projects') {
handleGetProjects($db);
}
elseif (preg_match('#^/v3/config/project/([a-zA-Z0-9_]+)$#', $path, $m)) {
handleGetProjectConfig($db, $m[1]);
}
elseif ($path === '/v3/config/installer') {
handleGetInstallerConfig($db);
}
elseif ($path === '/v3/version/index') {
$projectId = $_GET['project'] ?? '';
handleGetVersionIndex($db, $projectId);
}
elseif ($path === '/v3/update/check') {
$projectId = $_GET['project'] ?? '';
$version = $_GET['v'] ?? '';
handleUpdateCheck($db, $projectId, $version);
}
else {
json_error('Not Found', 404);
}
// ========== API 处理函数 ==========
function handleGetProjects($db) {
$rows = $db->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;
}
+93
View File
@@ -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
+102
View File
@@ -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 "按回车键退出"
+161
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1466
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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 <version>
即传入当前版本号,无需进行任何其他操作
如果无更新,更新器会输出OK并自我结束
如果有更新,更新器会自动杀死r0_arena.exe/R0对战平台.exe,然后进行全量/增量更新,完成后运行r0_arena.exe/R0对战平台.exe