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