Initial commit: R0Installer source, patch generator, API backend and build scripts
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+28
@@ -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
|
||||
@@ -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)
|
||||
);
|
||||
```
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
@@ -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/ 下载');
|
||||
}
|
||||
@@ -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>项目配置 JSON(install / 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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "r0/installer-api",
|
||||
"description": "R0Installer 云端配置 API",
|
||||
"require": {
|
||||
"php": ">=7.4",
|
||||
"catfan/medoo": "^2.1"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 "按回车键退出"
|
||||
@@ -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
|
||||
|
||||
+1466
File diff suppressed because it is too large
Load Diff
+17
@@ -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
|
||||
Reference in New Issue
Block a user