Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c075cd7bc | ||
|
|
94d48cf82a | ||
|
|
b1dfc68a0f | ||
|
|
aebbc4b889 | ||
|
|
ff67c554f7 | ||
|
|
96b7951ee1 | ||
|
|
16ab2549d7 | ||
|
|
683645be8e | ||
|
|
cb8f587e30 | ||
|
|
61b33ae0e3 | ||
|
|
36ccf46df5 | ||
|
|
4410e53afa | ||
|
|
46380513bc | ||
|
|
1bb8f20c88 | ||
|
|
b276fada4b | ||
|
|
af63f242da | ||
|
|
4b2c005222 | ||
|
|
6264523c76 | ||
|
|
cb0200ff29 | ||
|
|
17336de1b7 |
@@ -1,12 +0,0 @@
|
|||||||
.gradle/
|
|
||||||
.idea/
|
|
||||||
.kotlin/
|
|
||||||
app/.cxx/
|
|
||||||
app/release/
|
|
||||||
app/src/main/assets/
|
|
||||||
app/src/main/jniLibs/
|
|
||||||
build/
|
|
||||||
gradle/
|
|
||||||
gradlew
|
|
||||||
gradlew.bat
|
|
||||||
local.properties
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# Android 控制端
|
|
||||||
|
|
||||||
这个目录提供一个 Kotlin/Compose Android App,用来把 `honoka-chan` 作为本地动态库加载,并通过 JNI 控制启停。
|
|
||||||
|
|
||||||
## 当前功能
|
|
||||||
|
|
||||||
- 启动 / 停止本地 `honoka-chan` 服务
|
|
||||||
- 直接通过 JNI 查询运行状态、健康信息,并在修改配置后即时重载
|
|
||||||
- 将指定目录以符号链接方式挂到运行时 `/static/Android`
|
|
||||||
- 支持导出 / 导入用户数据备份(`data.db`)
|
|
||||||
- 使用前台服务 + `PARTIAL_WAKE_LOCK` 做后台保活
|
|
||||||
- 首次启动时解压运行时资源包到应用私有目录
|
|
||||||
- 打包运行时资源时自动生成默认 `config.json`
|
|
||||||
- 检测到未加入电池优化白名单时,仅弹出说明提示,不强制跳转系统设置
|
|
||||||
|
|
||||||
## 目录约定
|
|
||||||
|
|
||||||
- Go 动态库放到:`android/app/src/main/jniLibs/<abi>/libhonokachan.so`
|
|
||||||
- 运行时资源包放到:`android/app/src/main/assets/honoka_runtime.zip`
|
|
||||||
|
|
||||||
目前工程里已经预建了:
|
|
||||||
|
|
||||||
- `android/app/src/main/jniLibs/arm64-v8a/`
|
|
||||||
- `android/app/src/main/jniLibs/armeabi-v7a/`
|
|
||||||
- `android/app/src/main/jniLibs/x86_64/`
|
|
||||||
- `android/app/src/main/assets/`
|
|
||||||
|
|
||||||
## 1. 开发环境要求
|
|
||||||
|
|
||||||
Linux 下建议直接使用 `android/scripts/*.sh` 先准备运行时资源与 JNI 动态库,最终 APK 由 Android Studio 构建。
|
|
||||||
|
|
||||||
需要准备:
|
|
||||||
|
|
||||||
- `Go`
|
|
||||||
- `zip`
|
|
||||||
- Android SDK
|
|
||||||
- Android NDK
|
|
||||||
- Android Studio
|
|
||||||
|
|
||||||
常用环境变量:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANDROID_SDK_ROOT=/path/to/android-sdk
|
|
||||||
export ANDROID_NDK_HOME=/path/to/android-ndk
|
|
||||||
```
|
|
||||||
|
|
||||||
默认 `MIN_SDK=26`,和当前 Android App 的 `minSdk` 保持一致。如果你确实需要改动原生层最低版本,可以在执行脚本前自行覆盖:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export MIN_SDK=26
|
|
||||||
```
|
|
||||||
|
|
||||||
建议先跑环境检查:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./android/scripts/check_env.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 不需要预先在仓库根目录准备 `config.json`,`prepare_runtime_zip.sh` 会自动生成默认配置并打包进去。
|
|
||||||
|
|
||||||
## 2. 一键准备运行时资源
|
|
||||||
|
|
||||||
如果环境已经准备好,直接执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./android/scripts/build_all.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
它会依次完成:
|
|
||||||
|
|
||||||
1. 打包 `honoka_runtime.zip`
|
|
||||||
2. 编译 `arm64-v8a` 的 `libhonokachan.so`
|
|
||||||
3. 编译 `armeabi-v7a` 的 `libhonokachan.so`
|
|
||||||
4. 编译 `x86_64` 的 `libhonokachan.so`
|
|
||||||
|
|
||||||
完成后再用 Android Studio 打开 `android/` 并构建 APK。
|
|
||||||
|
|
||||||
## 3. 单步脚本
|
|
||||||
|
|
||||||
### 3.1 打包运行时资源
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./android/scripts/prepare_runtime_zip.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 编译 Go 动态库
|
|
||||||
|
|
||||||
需要使用 Android NDK 的 clang 作为 `CC`,并启用 `c-shared`。
|
|
||||||
|
|
||||||
脚本方式:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./android/scripts/build_go_android.sh arm64-v8a
|
|
||||||
./android/scripts/build_go_android.sh armeabi-v7a
|
|
||||||
./android/scripts/build_go_android.sh x86_64
|
|
||||||
```
|
|
||||||
|
|
||||||
手工方式,`arm64-v8a` 示例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANDROID_NDK_HOME=/path/to/android-ndk
|
|
||||||
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang"
|
|
||||||
CGO_ENABLED=1 GOOS=android GOARCH=arm64 \
|
|
||||||
go build -buildmode=c-shared \
|
|
||||||
-o android/app/src/main/jniLibs/arm64-v8a/libhonokachan.so \
|
|
||||||
./cmd/honoka-android
|
|
||||||
```
|
|
||||||
|
|
||||||
手工方式,`armeabi-v7a` 示例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANDROID_NDK_HOME=/path/to/android-ndk
|
|
||||||
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi26-clang"
|
|
||||||
CGO_ENABLED=1 GOOS=android GOARCH=arm GOARM=7 \
|
|
||||||
go build -buildmode=c-shared \
|
|
||||||
-o android/app/src/main/jniLibs/armeabi-v7a/libhonokachan.so \
|
|
||||||
./cmd/honoka-android
|
|
||||||
```
|
|
||||||
|
|
||||||
手工方式,`x86_64` 示例:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ANDROID_NDK_HOME=/path/to/android-ndk
|
|
||||||
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android26-clang"
|
|
||||||
CGO_ENABLED=1 GOOS=android GOARCH=amd64 \
|
|
||||||
go build -buildmode=c-shared \
|
|
||||||
-o android/app/src/main/jniLibs/x86_64/libhonokachan.so \
|
|
||||||
./cmd/honoka-android
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 这里直接使用 `./cmd/honoka-android`,不需要额外 JNI 头文件。
|
|
||||||
- Android App 自带的 `honoka_android.cpp` 会在运行时通过 `dlopen("libhonokachan.so")` 找到导出的 `ServerStart` / `ServerStop` / `ServerStatusJSON` 等符号。
|
|
||||||
- `go build -buildmode=c-shared` 会顺带生成 `libhonokachan.h`,脚本会自动删除这个头文件,因为当前 Android 工程并不会使用它。
|
|
||||||
|
|
||||||
## 4. 打包运行时资源
|
|
||||||
|
|
||||||
`honoka-chan` 在 Android 上仍然按仓库根目录结构读取文件,所以 zip 包内必须直接包含这些顶层条目:
|
|
||||||
|
|
||||||
- `assets/`
|
|
||||||
- `static/`
|
|
||||||
- `config.json`
|
|
||||||
|
|
||||||
最小建议内容:
|
|
||||||
|
|
||||||
- `assets/main.db`
|
|
||||||
- `assets/serverdata/`
|
|
||||||
- `assets/certs/`
|
|
||||||
- `static/templates/`
|
|
||||||
- `static/css/`
|
|
||||||
- `static/js/`
|
|
||||||
- `static/images/`
|
|
||||||
- `static/font/`
|
|
||||||
- `config.json`
|
|
||||||
|
|
||||||
如果你希望把已有用户数据也一起带进去,可以额外放入:
|
|
||||||
|
|
||||||
- `assets/data.db`
|
|
||||||
|
|
||||||
`prepare_runtime_zip.sh` 会自动生成一份默认配置,内容等同于服务端 `config.DefaultConfigs()`:
|
|
||||||
|
|
||||||
- `app_name`: `honoka-chan`
|
|
||||||
- `settings.listen_port`: `8080`
|
|
||||||
- `settings.cdn_server`: `http://127.0.0.1:8080/static`
|
|
||||||
- `settings.reload_token`: `""`
|
|
||||||
- `settings.unlock_all_special_rotation`: `false`
|
|
||||||
|
|
||||||
手工打包命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
zip -r android/app/src/main/assets/honoka_runtime.zip assets static config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
- 压缩包根目录下必须直接看到 `assets`、`static`、`config.json`,不要多包一层目录。
|
|
||||||
- App 会记录 `honoka_runtime.zip` 的内容哈希;如果资源包发生变化,下次启动服务或刷新挂载时会自动重新解压。
|
|
||||||
- 自动重新解压时会保留运行时目录里现有的 `config.json`,避免覆盖用户已经修改过的服务设置。
|
|
||||||
- 自动重新解压时也会保留运行时目录里现有的 `assets/data.db` 及其 `-wal/-shm`,避免覆盖用户数据。
|
|
||||||
|
|
||||||
## 5. 构建 APK
|
|
||||||
|
|
||||||
当前不再提供命令行脚本构建 APK,直接使用 Android Studio 打开 `android/` 构建即可。
|
|
||||||
|
|
||||||
如果你主要用 Android Studio 模拟器测试,建议保留 `x86_64` ABI;很多模拟器镜像不会加载仅包含 ARM so 的 APK。
|
|
||||||
|
|
||||||
## 6. 数据目录
|
|
||||||
|
|
||||||
App 会把你选择的目录挂到运行时:
|
|
||||||
|
|
||||||
- `/static/Android`
|
|
||||||
|
|
||||||
实现方式不是 Linux bind mount,而是:
|
|
||||||
|
|
||||||
- 在应用私有运行时目录中创建 `static/Android -> 你的目录` 的符号链接
|
|
||||||
|
|
||||||
默认数据目录:
|
|
||||||
|
|
||||||
- `/sdcard/Download/data`
|
|
||||||
|
|
||||||
如果 Android 版本较高,点击“选择目录”时会先跳转系统设置申请“所有文件访问权限”,授权后自动继续打开目录选择器。
|
|
||||||
|
|
||||||
## 7. 备份导入导出
|
|
||||||
|
|
||||||
- 备份导出和导入的对象都是运行时目录里的 `assets/data.db`
|
|
||||||
- 执行前需要先停止服务,避免数据库仍在使用中
|
|
||||||
- 导出前 App 会先执行一次 `wal_checkpoint(TRUNCATE)`,这样单独导出的 `data.db` 就是完整的
|
|
||||||
- 导入备份时会提示“将覆盖当前用户数据,是否继续”
|
|
||||||
|
|
||||||
## 8. 运行流程
|
|
||||||
|
|
||||||
1. 准备好 `libhonokachan.so`
|
|
||||||
2. 准备好 `honoka_runtime.zip`
|
|
||||||
3. 用 Android Studio 构建并安装 APK
|
|
||||||
4. 打开 App
|
|
||||||
5. 首次进入时按系统提示授予通知权限
|
|
||||||
6. 根据需要处理“所有文件访问权限”与数据目录选择
|
|
||||||
7. 如有需要,打开“解锁全部日替”开关
|
|
||||||
8. 点击“启动服务”
|
|
||||||
9. 用“刷新状态”确认当前进程健康状态正常
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 如果系统尚未将本应用加入电池优化白名单,App 只会弹出说明提示,不会自动跳转设置页。
|
|
||||||
- 即使没有加入白名单,服务仍然可以启动。
|
|
||||||
- 在部分国产系统上,如果退到后台后服务仍会断开,除了忽略电池优化、自启动和后台运行,还可能需要在最近任务中手动锁定本应用。
|
|
||||||
|
|
||||||
## 9. 其他说明
|
|
||||||
|
|
||||||
- 这个 Android 子目录默认只提交源码与脚本;`gradlew`、`gradle/`、构建产物、`jniLibs/`、`assets/` 等本地生成内容当前都被 `android/.gitignore` 排除了。
|
|
||||||
- 如果你希望把 Gradle wrapper 一起纳入仓库,可以自行调整 `android/.gitignore`。
|
|
||||||
- 前台服务会持有 `PARTIAL_WAKE_LOCK`,停止服务时会释放。
|
|
||||||
- App 内部的状态、健康检查、配置重载都直接通过 JNI 调用 Go 导出符号。
|
|
||||||
- `/system/health` 和 `/system/reload` 仍然保留,方便外部调试、脚本探活和浏览器访问。
|
|
||||||
- Android Studio 升级到 AGP `8.11.2` 后,如果项目放在 Samba 映射盘上,可能会出现 `Cannot create directory .../merged.dir/values` 之类的 AAPT 目录创建失败。这个更像文件系统兼容问题,建议改回本地磁盘构建。
|
|
||||||
|
|
||||||
## 10. Reload 与资源更新说明
|
|
||||||
|
|
||||||
- App 内部修改服务设置时会直接在进程内执行 reload
|
|
||||||
- `/system/reload` HTTP 接口只用于外部调用,仍然保留 token 校验
|
|
||||||
- 无论通过 JNI 还是 HTTP,reload 都只会重新加载运行时目录中的 `config.json`
|
|
||||||
- 它不会重新解压 `honoka_runtime.zip`
|
|
||||||
- 如果你更新了数据库、证书、serverdata、模板等资源,需要重新生成 `honoka_runtime.zip` 并重新安装或覆盖资源包
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("org.jetbrains.kotlin.android")
|
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "me.killkiss.honokactrl"
|
|
||||||
compileSdk = 36
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "me.killkiss.honokactrl"
|
|
||||||
minSdk = 26
|
|
||||||
targetSdk = 36
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "0.1.0"
|
|
||||||
|
|
||||||
ndk {
|
|
||||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
|
||||||
}
|
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
vectorDrawables {
|
|
||||||
useSupportLibrary = true
|
|
||||||
}
|
|
||||||
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
cppFlags += "-std=c++20"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
|
||||||
jniLibs {
|
|
||||||
useLegacyPackaging = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
path = file("src/main/cpp/CMakeLists.txt")
|
|
||||||
version = "3.22.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
|
|
||||||
|
|
||||||
implementation("androidx.core:core-ktx:1.18.0")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
|
||||||
implementation("androidx.activity:activity-compose:1.13.0")
|
|
||||||
implementation("com.google.android.material:material:1.14.0")
|
|
||||||
implementation(composeBom)
|
|
||||||
implementation("androidx.compose.ui:ui")
|
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
|
||||||
implementation("androidx.compose.material3:material3")
|
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
|
||||||
|
|
||||||
debugImplementation(composeBom)
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# No custom ProGuard rules yet.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/Theme.HonokaControl"
|
|
||||||
android:usesCleartextTraffic="true">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".service.HonokaServerService"
|
|
||||||
android:enabled="true"
|
|
||||||
android:exported="false"
|
|
||||||
android:stopWithTask="false"
|
|
||||||
android:foregroundServiceType="dataSync" />
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.22.1)
|
|
||||||
project(honoka_android)
|
|
||||||
|
|
||||||
add_library(
|
|
||||||
honoka_android
|
|
||||||
SHARED
|
|
||||||
honoka_android.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
find_library(log-lib log)
|
|
||||||
|
|
||||||
find_library(dl-lib dl)
|
|
||||||
|
|
||||||
target_link_libraries(
|
|
||||||
honoka_android
|
|
||||||
${log-lib}
|
|
||||||
${dl-lib}
|
|
||||||
)
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
#include <jni.h>
|
|
||||||
#include <dlfcn.h>
|
|
||||||
#include <mutex>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
using server_start_fn = char* (*)(const char*);
|
|
||||||
using server_stop_fn = char* (*)();
|
|
||||||
using server_status_fn = char* (*)();
|
|
||||||
using server_health_fn = char* (*)();
|
|
||||||
using server_reload_fn = char* (*)();
|
|
||||||
using server_free_string_fn = void (*)(char*);
|
|
||||||
|
|
||||||
std::once_flag g_init_once;
|
|
||||||
void* g_handle = nullptr;
|
|
||||||
server_start_fn g_start = nullptr;
|
|
||||||
server_stop_fn g_stop = nullptr;
|
|
||||||
server_status_fn g_status = nullptr;
|
|
||||||
server_health_fn g_health = nullptr;
|
|
||||||
server_reload_fn g_reload = nullptr;
|
|
||||||
server_free_string_fn g_free_string = nullptr;
|
|
||||||
std::string g_error;
|
|
||||||
|
|
||||||
void InitServerSymbols() {
|
|
||||||
g_handle = dlopen("libhonokachan.so", RTLD_NOW | RTLD_GLOBAL);
|
|
||||||
if (g_handle == nullptr) {
|
|
||||||
g_error = dlerror();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_start = reinterpret_cast<server_start_fn>(dlsym(g_handle, "ServerStart"));
|
|
||||||
g_stop = reinterpret_cast<server_stop_fn>(dlsym(g_handle, "ServerStop"));
|
|
||||||
g_status = reinterpret_cast<server_status_fn>(dlsym(g_handle, "ServerStatusJSON"));
|
|
||||||
g_health = reinterpret_cast<server_health_fn>(dlsym(g_handle, "ServerHealthJSON"));
|
|
||||||
g_reload = reinterpret_cast<server_reload_fn>(dlsym(g_handle, "ServerReload"));
|
|
||||||
g_free_string = reinterpret_cast<server_free_string_fn>(dlsym(g_handle, "ServerFreeString"));
|
|
||||||
|
|
||||||
if (g_start == nullptr || g_stop == nullptr || g_status == nullptr || g_health == nullptr ||
|
|
||||||
g_reload == nullptr || g_free_string == nullptr) {
|
|
||||||
g_error = "missing exported symbols from libhonokachan.so";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const char* EnsureServerReady() {
|
|
||||||
std::call_once(g_init_once, InitServerSymbols);
|
|
||||||
if (!g_error.empty()) {
|
|
||||||
return g_error.c_str();
|
|
||||||
}
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
jstring CallStringResult(JNIEnv* env, char* result) {
|
|
||||||
if (result == nullptr) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
|
|
||||||
jstring java_string = env->NewStringUTF(result);
|
|
||||||
g_free_string(result);
|
|
||||||
return java_string;
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jstring JNICALL
|
|
||||||
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStart(JNIEnv* env, jobject, jstring work_dir) {
|
|
||||||
const char* error = EnsureServerReady();
|
|
||||||
if (error != nullptr) {
|
|
||||||
return env->NewStringUTF(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const char* work_dir_chars = env->GetStringUTFChars(work_dir, nullptr);
|
|
||||||
char* result = g_start(work_dir_chars);
|
|
||||||
env->ReleaseStringUTFChars(work_dir, work_dir_chars);
|
|
||||||
return CallStringResult(env, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jstring JNICALL
|
|
||||||
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStop(JNIEnv* env, jobject) {
|
|
||||||
const char* error = EnsureServerReady();
|
|
||||||
if (error != nullptr) {
|
|
||||||
return env->NewStringUTF(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return CallStringResult(env, g_stop());
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jstring JNICALL
|
|
||||||
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStatusJson(JNIEnv* env, jobject) {
|
|
||||||
const char* error = EnsureServerReady();
|
|
||||||
if (error != nullptr) {
|
|
||||||
std::string json = std::string("{\"running\":false,\"last_error\":\"") + error + "\"}";
|
|
||||||
return env->NewStringUTF(json.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
char* result = g_status();
|
|
||||||
if (result == nullptr) {
|
|
||||||
return env->NewStringUTF("{\"running\":false,\"last_error\":\"status unavailable\"}");
|
|
||||||
}
|
|
||||||
jstring java_string = env->NewStringUTF(result);
|
|
||||||
g_free_string(result);
|
|
||||||
return java_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jstring JNICALL
|
|
||||||
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeHealthJson(JNIEnv* env, jobject) {
|
|
||||||
const char* error = EnsureServerReady();
|
|
||||||
if (error != nullptr) {
|
|
||||||
std::string json = std::string("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"") + error + "\\\"}";
|
|
||||||
return env->NewStringUTF(json.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
char* result = g_health();
|
|
||||||
if (result == nullptr) {
|
|
||||||
return env->NewStringUTF("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"health unavailable\\\"}");
|
|
||||||
}
|
|
||||||
jstring java_string = env->NewStringUTF(result);
|
|
||||||
g_free_string(result);
|
|
||||||
return java_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jstring JNICALL
|
|
||||||
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeReload(JNIEnv* env, jobject) {
|
|
||||||
const char* error = EnsureServerReady();
|
|
||||||
if (error != nullptr) {
|
|
||||||
return env->NewStringUTF(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return CallStringResult(env, g_reload());
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
package me.killkiss.honokactrl
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.DocumentsContract
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.File
|
|
||||||
import me.killkiss.honokactrl.ui.MainScreen
|
|
||||||
import me.killkiss.honokactrl.ui.MainViewModel
|
|
||||||
import me.killkiss.honokactrl.theme.HonokaControlTheme
|
|
||||||
import me.killkiss.honokactrl.storage.RuntimePreparer
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
private val viewModel by viewModels<MainViewModel>()
|
|
||||||
private var pendingDirectoryPicker = false
|
|
||||||
private var pendingStartServer = false
|
|
||||||
private var batteryOptimizationDialogShown = false
|
|
||||||
private val runtimePreparer by lazy { RuntimePreparer(this) }
|
|
||||||
|
|
||||||
private val notificationPermissionLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
|
||||||
|
|
||||||
private val openDocumentTreeLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
|
||||||
handleSelectedDirectory(uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val exportBackupLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
exportBackupToUri(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val importBackupLauncher =
|
|
||||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
confirmImportBackup(uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
setContent {
|
|
||||||
HonokaControlTheme {
|
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
|
||||||
MainScreen(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onToggleServer = { handleToggleServer() },
|
|
||||||
onOpenLocalUrl = { openLocalUrl() },
|
|
||||||
onPickDataDirectory = { openDataDirectoryPicker() },
|
|
||||||
onExportBackup = { openExportBackup() },
|
|
||||||
onImportBackup = { openImportBackup() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestNotificationPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
viewModel.refreshAllFilesAccess(hasAllFilesAccess())
|
|
||||||
viewModel.refreshStatus()
|
|
||||||
maybeShowBatteryOptimizationDialog()
|
|
||||||
if (pendingStartServer && hasAllFilesAccess()) {
|
|
||||||
pendingStartServer = false
|
|
||||||
viewModel.toggleServer()
|
|
||||||
}
|
|
||||||
if (pendingDirectoryPicker && hasAllFilesAccess()) {
|
|
||||||
pendingDirectoryPicker = false
|
|
||||||
openDocumentTreeLauncher.launch(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestNotificationPermission() {
|
|
||||||
if (
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
||||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeShowBatteryOptimizationDialog() {
|
|
||||||
if (batteryOptimizationDialogShown || isIgnoringBatteryOptimizations()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
batteryOptimizationDialogShown = true
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle("允许后台运行")
|
|
||||||
.setMessage("当前还未为本应用开启后台保活相关设置。建议手动在系统设置中开启忽略电池优化、自启动和后台运行。服务现在仍可正常启动;如果在国产系统上退到后台后仍会断开,还需要在最近任务中将本应用锁定。")
|
|
||||||
.setPositiveButton("知道了", null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openDataDirectoryPicker() {
|
|
||||||
if (!hasAllFilesAccess()) {
|
|
||||||
pendingDirectoryPicker = true
|
|
||||||
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续选择目录")
|
|
||||||
openAllFilesAccessSettings()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
openDocumentTreeLauncher.launch(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleToggleServer() {
|
|
||||||
if (viewModel.uiState.value.status.running) {
|
|
||||||
viewModel.toggleServer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!hasAllFilesAccess()) {
|
|
||||||
pendingStartServer = true
|
|
||||||
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续启动服务")
|
|
||||||
openAllFilesAccessSettings()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModel.toggleServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isIgnoringBatteryOptimizations(): Boolean {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
val powerManager = getSystemService(PowerManager::class.java)
|
|
||||||
return powerManager?.isIgnoringBatteryOptimizations(packageName) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openLocalUrl() {
|
|
||||||
val localUrl = viewModel.uiState.value.status.localUrl
|
|
||||||
if (localUrl.isBlank()) {
|
|
||||||
viewModel.showMessage("当前没有可打开的本地地址")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(localUrl))
|
|
||||||
try {
|
|
||||||
startActivity(intent)
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
viewModel.showMessage("没有可用于打开本地地址的浏览器")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openExportBackup() {
|
|
||||||
if (!ensureBackupOperationAllowed()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
exportBackupLauncher.launch("honoka-data.db")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openImportBackup() {
|
|
||||||
if (!ensureBackupOperationAllowed()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
importBackupLauncher.launch(arrayOf("*/*"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openAllFilesAccessSettings() {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = Uri.parse("package:$packageName")
|
|
||||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri)
|
|
||||||
try {
|
|
||||||
startActivity(intent)
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasAllFilesAccess(): Boolean {
|
|
||||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureBackupOperationAllowed(): Boolean {
|
|
||||||
if (viewModel.uiState.value.status.running) {
|
|
||||||
viewModel.showMessage("请先停止服务后再导入或导出备份")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSelectedDirectory(uri: Uri?) {
|
|
||||||
if (uri == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
contentResolver.takePersistableUriPermission(
|
|
||||||
uri,
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val path = treeUriToPath(uri)
|
|
||||||
if (path != null) {
|
|
||||||
viewModel.setDataDirPath(path)
|
|
||||||
} else {
|
|
||||||
viewModel.showMessage("无法解析所选目录,请改用外部存储目录")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun treeUriToPath(uri: Uri): String? {
|
|
||||||
val documentId = DocumentsContract.getTreeDocumentId(uri)
|
|
||||||
val parts = documentId.split(":", limit = 2)
|
|
||||||
val volume = parts.getOrNull(0) ?: return null
|
|
||||||
val relativePath = parts.getOrNull(1).orEmpty()
|
|
||||||
|
|
||||||
val basePath = when {
|
|
||||||
volume.equals("primary", ignoreCase = true) -> Environment.getExternalStorageDirectory().absolutePath
|
|
||||||
else -> "/storage/$volume"
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (relativePath.isBlank()) {
|
|
||||||
basePath
|
|
||||||
} else {
|
|
||||||
File(basePath, relativePath).absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun exportBackupToUri(uri: Uri) {
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = true)
|
|
||||||
checkpointUserDatabase(dataDbFile)
|
|
||||||
contentResolver.openOutputStream(uri)?.use { output ->
|
|
||||||
dataDbFile.inputStream().use { input ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: error("无法打开导出目标文件")
|
|
||||||
}.onSuccess {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
viewModel.showMessage("备份已导出")
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
viewModel.showMessage(it.message ?: "导出备份失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun confirmImportBackup(uri: Uri) {
|
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle("导入备份")
|
|
||||||
.setMessage("导入备份会覆盖当前用户数据,是否继续?")
|
|
||||||
.setNegativeButton("取消", null)
|
|
||||||
.setPositiveButton("继续") { _, _ ->
|
|
||||||
importBackupFromUri(uri)
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun importBackupFromUri(uri: Uri) {
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = false)
|
|
||||||
val tempFile = File(dataDbFile.parentFile, "${dataDbFile.name}.importing")
|
|
||||||
contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
tempFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: error("无法打开备份文件")
|
|
||||||
|
|
||||||
deleteUserDatabaseSidecars(dataDbFile)
|
|
||||||
if (dataDbFile.exists()) {
|
|
||||||
dataDbFile.delete()
|
|
||||||
}
|
|
||||||
if (!tempFile.renameTo(dataDbFile)) {
|
|
||||||
tempFile.inputStream().use { input ->
|
|
||||||
dataDbFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tempFile.delete()
|
|
||||||
}
|
|
||||||
deleteUserDatabaseSidecars(dataDbFile)
|
|
||||||
}.onSuccess {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
viewModel.showMessage("备份已导入,下次启动服务时生效")
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
viewModel.showMessage(it.message ?: "导入备份失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureRuntimeDataDbFile(requireExisting: Boolean): File {
|
|
||||||
val runtimeInfo = runtimePreparer.prepare(viewModel.uiState.value.dataDirPath, forceRedeploy = false)
|
|
||||||
.getOrElse { throw it }
|
|
||||||
val dataDbFile = File(runtimeInfo.runtimeRoot, "assets/data.db")
|
|
||||||
dataDbFile.parentFile?.mkdirs()
|
|
||||||
if (requireExisting && !dataDbFile.exists()) {
|
|
||||||
throw IllegalStateException("当前没有可用的用户数据文件")
|
|
||||||
}
|
|
||||||
return dataDbFile
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkpointUserDatabase(dataDbFile: File) {
|
|
||||||
val db = SQLiteDatabase.openDatabase(dataDbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
|
|
||||||
db.use {
|
|
||||||
it.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { cursor ->
|
|
||||||
while (cursor.moveToNext()) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteUserDatabaseSidecars(dataDbFile: File) {
|
|
||||||
File(dataDbFile.parentFile, "${dataDbFile.name}-wal").delete()
|
|
||||||
File(dataDbFile.parentFile, "${dataDbFile.name}-shm").delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package me.killkiss.honokactrl.jni
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
data class NativeServerStatus(
|
|
||||||
val running: Boolean = false,
|
|
||||||
val workDir: String = "",
|
|
||||||
val listenPort: String = "",
|
|
||||||
val localUrl: String = "",
|
|
||||||
val startedAt: String = "",
|
|
||||||
val lastError: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NativeReloadResult(
|
|
||||||
val success: Boolean,
|
|
||||||
val message: String,
|
|
||||||
val reloadedAt: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NativeHealthInfo(
|
|
||||||
val status: String = "",
|
|
||||||
val appName: String = "",
|
|
||||||
val version: String = "",
|
|
||||||
val startedAt: String = "",
|
|
||||||
val uptimeSeconds: Long = 0,
|
|
||||||
val lastReloadAt: String = "",
|
|
||||||
val listenPort: String = "",
|
|
||||||
val cdnServer: String = "",
|
|
||||||
val reloadTokenConfigured: Boolean = false,
|
|
||||||
val mainDb: String = "",
|
|
||||||
val userDb: String = "",
|
|
||||||
val message: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
object NativeBridge {
|
|
||||||
private val loadError: String? by lazy {
|
|
||||||
runCatching { System.loadLibrary("honoka_android") }
|
|
||||||
.exceptionOrNull()
|
|
||||||
?.message
|
|
||||||
}
|
|
||||||
|
|
||||||
private external fun nativeStart(workDir: String): String?
|
|
||||||
private external fun nativeStop(): String?
|
|
||||||
private external fun nativeStatusJson(): String
|
|
||||||
private external fun nativeHealthJson(): String
|
|
||||||
private external fun nativeReload(): String?
|
|
||||||
|
|
||||||
fun start(workDir: String): Result<Unit> {
|
|
||||||
val error = ensureLoaded() ?: nativeStart(workDir)
|
|
||||||
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop(): Result<Unit> {
|
|
||||||
val error = ensureLoaded() ?: nativeStop()
|
|
||||||
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun status(): NativeServerStatus {
|
|
||||||
val error = ensureLoaded()
|
|
||||||
if (error != null) {
|
|
||||||
return NativeServerStatus(lastError = error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
val json = JSONObject(nativeStatusJson())
|
|
||||||
NativeServerStatus(
|
|
||||||
running = json.optBoolean("running"),
|
|
||||||
workDir = json.optString("work_dir"),
|
|
||||||
listenPort = json.optString("listen_port"),
|
|
||||||
localUrl = json.optString("local_url"),
|
|
||||||
startedAt = json.optString("started_at"),
|
|
||||||
lastError = json.optString("last_error"),
|
|
||||||
)
|
|
||||||
}.getOrElse {
|
|
||||||
NativeServerStatus(lastError = it.message ?: "failed to parse native status")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun healthJson(): String {
|
|
||||||
val error = ensureLoaded()
|
|
||||||
if (error != null) {
|
|
||||||
return """{"status":"error","message":${JSONObject.quote(error)}}"""
|
|
||||||
}
|
|
||||||
return nativeHealthJson()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun health(): NativeHealthInfo {
|
|
||||||
val raw = healthJson()
|
|
||||||
return runCatching {
|
|
||||||
val json = JSONObject(raw)
|
|
||||||
NativeHealthInfo(
|
|
||||||
status = json.optString("status"),
|
|
||||||
appName = json.optString("app_name"),
|
|
||||||
version = json.optString("version"),
|
|
||||||
startedAt = json.optString("started_at"),
|
|
||||||
uptimeSeconds = json.optLong("uptime_seconds"),
|
|
||||||
lastReloadAt = json.optString("last_reload_at"),
|
|
||||||
listenPort = json.optString("listen_port"),
|
|
||||||
cdnServer = json.optString("cdn_server"),
|
|
||||||
reloadTokenConfigured = json.optBoolean("reload_token_configured"),
|
|
||||||
mainDb = json.optString("main_db"),
|
|
||||||
userDb = json.optString("user_db"),
|
|
||||||
message = json.optString("message"),
|
|
||||||
)
|
|
||||||
}.getOrElse {
|
|
||||||
NativeHealthInfo(
|
|
||||||
status = "error",
|
|
||||||
message = it.message ?: "failed to parse health response",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reload(): NativeReloadResult {
|
|
||||||
val error = ensureLoaded()
|
|
||||||
if (error != null) {
|
|
||||||
return NativeReloadResult(success = false, message = error)
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = nativeReload()
|
|
||||||
if (result == null) {
|
|
||||||
return NativeReloadResult(success = true, message = "configuration reloaded")
|
|
||||||
}
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
val json = JSONObject(result)
|
|
||||||
NativeReloadResult(
|
|
||||||
success = json.optString("status") == "ok",
|
|
||||||
message = json.optString("message").ifBlank { "reload failed" },
|
|
||||||
reloadedAt = json.optString("reloaded_at"),
|
|
||||||
)
|
|
||||||
}.getOrElse {
|
|
||||||
NativeReloadResult(success = false, message = it.message ?: "reload failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureLoaded(): String? = loadError
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package me.killkiss.honokactrl.service
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.killkiss.honokactrl.MainActivity
|
|
||||||
import me.killkiss.honokactrl.R
|
|
||||||
import me.killkiss.honokactrl.jni.NativeBridge
|
|
||||||
|
|
||||||
class HonokaServerService : Service() {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "HonokaServer"
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
|
||||||
private const val CHANNEL_ID = "honoka_server"
|
|
||||||
private const val NOTIFICATION_ID = 1001
|
|
||||||
|
|
||||||
const val ACTION_START = "me.killkiss.honokactrl.action.START"
|
|
||||||
const val ACTION_STOP = "me.killkiss.honokactrl.action.STOP"
|
|
||||||
const val EXTRA_RUNTIME_ROOT = "runtime_root"
|
|
||||||
|
|
||||||
fun startIntent(context: Context, runtimeRoot: String): Intent {
|
|
||||||
return Intent(context, HonokaServerService::class.java).apply {
|
|
||||||
action = ACTION_START
|
|
||||||
putExtra(EXTRA_RUNTIME_ROOT, runtimeRoot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopIntent(context: Context): Intent {
|
|
||||||
return Intent(context, HonokaServerService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
private var runtimeRoot: String? = null
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
Log.i(TAG, "service created")
|
|
||||||
ensureChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
Log.i(TAG, "onStartCommand: action=${intent?.action}, startId=$startId")
|
|
||||||
when (intent?.action) {
|
|
||||||
ACTION_START -> {
|
|
||||||
startForeground(NOTIFICATION_ID, buildNotification("正在启动服务"))
|
|
||||||
acquireWakeLock()
|
|
||||||
runtimeRoot = intent.getStringExtra(EXTRA_RUNTIME_ROOT)
|
|
||||||
if (runtimeRoot.isNullOrBlank()) {
|
|
||||||
Log.e(TAG, "missing runtime root")
|
|
||||||
updateNotification("缺少运行目录")
|
|
||||||
stopSelf()
|
|
||||||
return START_REDELIVER_INTENT
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
Log.i(TAG, "native start begin, runtimeRoot=$runtimeRoot")
|
|
||||||
val result = NativeBridge.start(runtimeRoot!!)
|
|
||||||
if (result.isSuccess) {
|
|
||||||
Log.i(TAG, "native start success")
|
|
||||||
updateNotification("服务运行中")
|
|
||||||
} else {
|
|
||||||
Log.e(TAG, "native start failed", result.exceptionOrNull())
|
|
||||||
updateNotification(result.exceptionOrNull()?.message ?: "启动失败")
|
|
||||||
releaseWakeLock()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ACTION_STOP -> {
|
|
||||||
scope.launch {
|
|
||||||
Log.i(TAG, "native stop begin")
|
|
||||||
NativeBridge.stop()
|
|
||||||
Log.i(TAG, "native stop finished")
|
|
||||||
updateNotification("服务已停止")
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
releaseWakeLock()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return START_REDELIVER_INTENT
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
Log.i(TAG, "service destroyed")
|
|
||||||
scope.cancel()
|
|
||||||
releaseWakeLock()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
private fun buildNotification(content: String): Notification {
|
|
||||||
val openIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
Intent(this, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setSmallIcon(R.drawable.ic_notification_server)
|
|
||||||
.setContentTitle(getString(R.string.app_name))
|
|
||||||
.setContentText(content)
|
|
||||||
.setContentIntent(openIntent)
|
|
||||||
.setOngoing(true)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateNotification(content: String) {
|
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
manager.notify(NOTIFICATION_ID, buildNotification(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
"Honoka Server",
|
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
|
||||||
).apply {
|
|
||||||
description = "honoka-chan background service"
|
|
||||||
}
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun acquireWakeLock() {
|
|
||||||
if (wakeLock?.isHeld == true) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "honoka:server").apply {
|
|
||||||
setReferenceCounted(false)
|
|
||||||
acquire()
|
|
||||||
}
|
|
||||||
Log.i(TAG, "wakelock acquired")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
|
||||||
wakeLock?.takeIf { it.isHeld }?.release()
|
|
||||||
wakeLock = null
|
|
||||||
Log.i(TAG, "wakelock released")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
package me.killkiss.honokactrl.storage
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.system.Os
|
|
||||||
import android.util.Log
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
|
|
||||||
class RuntimePreparer(private val context: Context) {
|
|
||||||
fun prepare(dataDirPath: String, forceRedeploy: Boolean = false): Result<RuntimeInfo> {
|
|
||||||
return runCatching {
|
|
||||||
val runtimeRoot = File(context.filesDir, RUNTIME_DIR_NAME)
|
|
||||||
val bundleHash = calculateBundleHash()
|
|
||||||
Log.i(TAG, "prepare runtime root=${runtimeRoot.absolutePath}, dataDir=$dataDirPath, forceRedeploy=$forceRedeploy")
|
|
||||||
if (forceRedeploy || shouldRedeployBundle(runtimeRoot, bundleHash)) {
|
|
||||||
redeployBundle(runtimeRoot, bundleHash)
|
|
||||||
}
|
|
||||||
mountAndroidDir(runtimeRoot, File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }))
|
|
||||||
RuntimeInfo(
|
|
||||||
runtimeRoot = runtimeRoot,
|
|
||||||
mountedDataDir = File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }),
|
|
||||||
configFile = File(runtimeRoot, "config.json"),
|
|
||||||
deployedBundleHash = bundleHash,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun redeployBundle(runtimeRoot: File, bundleHash: String) {
|
|
||||||
Log.i(TAG, "redeploy bundle into ${runtimeRoot.absolutePath}")
|
|
||||||
val preservedFiles = PRESERVED_RUNTIME_FILES.mapNotNull { relativePath ->
|
|
||||||
File(runtimeRoot, relativePath).takeIf { it.exists() }?.let { relativePath to it.readBytes() }
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
if (runtimeRoot.exists()) {
|
|
||||||
runtimeRoot.deleteRecursively()
|
|
||||||
}
|
|
||||||
runtimeRoot.mkdirs()
|
|
||||||
|
|
||||||
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
|
|
||||||
ZipInputStream(input).use { zip ->
|
|
||||||
var entry = zip.nextEntry
|
|
||||||
while (entry != null) {
|
|
||||||
val outFile = File(runtimeRoot, entry.name)
|
|
||||||
if (preservedFiles.containsKey(entry.name)) {
|
|
||||||
zip.closeEntry()
|
|
||||||
entry = zip.nextEntry
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (entry.isDirectory) {
|
|
||||||
outFile.mkdirs()
|
|
||||||
} else {
|
|
||||||
outFile.parentFile?.mkdirs()
|
|
||||||
FileOutputStream(outFile).use { output ->
|
|
||||||
zip.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zip.closeEntry()
|
|
||||||
entry = zip.nextEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
preservedFiles.forEach { (relativePath, data) ->
|
|
||||||
File(runtimeRoot, relativePath).apply {
|
|
||||||
parentFile?.mkdirs()
|
|
||||||
writeBytes(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File(runtimeRoot, BUNDLE_MARKER_FILE).writeText(bundleHash)
|
|
||||||
Log.i(TAG, "bundle deployed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shouldRedeployBundle(runtimeRoot: File, bundleHash: String): Boolean {
|
|
||||||
val markerFile = File(runtimeRoot, BUNDLE_MARKER_FILE)
|
|
||||||
if (!markerFile.exists()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val deployedHash = runCatching { markerFile.readText().trim() }.getOrDefault("")
|
|
||||||
val changed = deployedHash != bundleHash
|
|
||||||
if (changed) {
|
|
||||||
Log.i(TAG, "runtime bundle hash changed, redeploy required")
|
|
||||||
}
|
|
||||||
return changed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calculateBundleHash(): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
||||||
while (true) {
|
|
||||||
val read = input.read(buffer)
|
|
||||||
if (read <= 0) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
digest.update(buffer, 0, read)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mountAndroidDir(runtimeRoot: File, targetDir: File) {
|
|
||||||
if (!targetDir.exists()) {
|
|
||||||
targetDir.mkdirs()
|
|
||||||
}
|
|
||||||
Log.i(TAG, "mount Android dir target=${targetDir.absolutePath}")
|
|
||||||
|
|
||||||
val androidDir = File(runtimeRoot, "static/Android")
|
|
||||||
androidDir.parentFile?.mkdirs()
|
|
||||||
|
|
||||||
if (isSymlink(androidDir)) {
|
|
||||||
androidDir.delete()
|
|
||||||
} else if (androidDir.exists()) {
|
|
||||||
androidDir.deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
Os.symlink(targetDir.absolutePath, androidDir.absolutePath)
|
|
||||||
Log.i(TAG, "symlink created: ${androidDir.absolutePath} -> ${targetDir.absolutePath}")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isSymlink(file: File): Boolean {
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
Files.isSymbolicLink(file.toPath())
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
file.canonicalFile != file.absoluteFile
|
|
||||||
} catch (_: IOException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "HonokaServer"
|
|
||||||
const val BUNDLE_ASSET_NAME = "honoka_runtime.zip"
|
|
||||||
const val RUNTIME_DIR_NAME = "honoka-runtime"
|
|
||||||
private const val BUNDLE_MARKER_FILE = ".bundle_ready"
|
|
||||||
private val PRESERVED_RUNTIME_FILES = listOf(
|
|
||||||
"config.json",
|
|
||||||
"assets/data.db",
|
|
||||||
"assets/data.db-wal",
|
|
||||||
"assets/data.db-shm",
|
|
||||||
)
|
|
||||||
|
|
||||||
fun readDeployedBundleHash(runtimeRoot: File): String {
|
|
||||||
return runCatching {
|
|
||||||
File(runtimeRoot, BUNDLE_MARKER_FILE).takeIf { it.exists() }?.readText()?.trim().orEmpty()
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RuntimeInfo(
|
|
||||||
val runtimeRoot: File,
|
|
||||||
val mountedDataDir: File,
|
|
||||||
val configFile: File,
|
|
||||||
val deployedBundleHash: String,
|
|
||||||
)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package me.killkiss.honokactrl.storage
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
class SettingsStore(context: Context) {
|
|
||||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
fun loadDataDirPath(): String = prefs.getString(KEY_DATA_DIR_PATH, DEFAULT_DATA_DIR_PATH) ?: DEFAULT_DATA_DIR_PATH
|
|
||||||
|
|
||||||
fun saveDataDirPath(path: String) {
|
|
||||||
prefs.edit().putString(KEY_DATA_DIR_PATH, path).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PREFS_NAME = "honoka_control"
|
|
||||||
private const val KEY_DATA_DIR_PATH = "data_dir_path"
|
|
||||||
const val DEFAULT_DATA_DIR_PATH = "/sdcard/Download/data"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package me.killkiss.honokactrl.theme
|
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
private val HonokaLightScheme = lightColorScheme(
|
|
||||||
primary = Color(0xFFB73E3E),
|
|
||||||
onPrimary = Color(0xFFFFFFFF),
|
|
||||||
primaryContainer = Color(0xFFFFDAD6),
|
|
||||||
onPrimaryContainer = Color(0xFF410006),
|
|
||||||
secondary = Color(0xFF775651),
|
|
||||||
onSecondary = Color(0xFFFFFFFF),
|
|
||||||
secondaryContainer = Color(0xFFFFDAD6),
|
|
||||||
onSecondaryContainer = Color(0xFF2C1512),
|
|
||||||
background = Color(0xFFFFF8F6),
|
|
||||||
onBackground = Color(0xFF241917),
|
|
||||||
surface = Color(0xFFFFF8F6),
|
|
||||||
onSurface = Color(0xFF241917),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val HonokaDarkScheme = darkColorScheme(
|
|
||||||
primary = Color(0xFFFFB4AB),
|
|
||||||
onPrimary = Color(0xFF69000F),
|
|
||||||
primaryContainer = Color(0xFF93001D),
|
|
||||||
onPrimaryContainer = Color(0xFFFFDAD6),
|
|
||||||
secondary = Color(0xFFE7BDB7),
|
|
||||||
onSecondary = Color(0xFF442925),
|
|
||||||
secondaryContainer = Color(0xFF5D3F3A),
|
|
||||||
onSecondaryContainer = Color(0xFFFFDAD6),
|
|
||||||
background = Color(0xFF1A1110),
|
|
||||||
onBackground = Color(0xFFF1DFDC),
|
|
||||||
surface = Color(0xFF1A1110),
|
|
||||||
onSurface = Color(0xFFF1DFDC),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HonokaControlTheme(content: @Composable () -> Unit) {
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = if (isSystemInDarkTheme()) HonokaDarkScheme else HonokaLightScheme,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
package me.killkiss.honokactrl.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.FolderOpen
|
|
||||||
import androidx.compose.material.icons.outlined.Language
|
|
||||||
import androidx.compose.material.icons.outlined.PlayArrow
|
|
||||||
import androidx.compose.material.icons.outlined.Refresh
|
|
||||||
import androidx.compose.material.icons.outlined.Storage
|
|
||||||
import androidx.compose.material.icons.outlined.Stop
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import me.killkiss.honokactrl.R
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
|
||||||
fun MainScreen(
|
|
||||||
viewModel: MainViewModel,
|
|
||||||
onToggleServer: () -> Unit,
|
|
||||||
onOpenLocalUrl: () -> Unit,
|
|
||||||
onPickDataDirectory: () -> Unit,
|
|
||||||
onExportBackup: () -> Unit,
|
|
||||||
onImportBackup: () -> Unit,
|
|
||||||
) {
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
CenterAlignedTopAppBar(title = { Text(stringResource(R.string.app_name)) })
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text("服务控制", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
if (state.status.running) "当前状态:运行中" else "当前状态:已停止",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
if (state.status.localUrl.isNotBlank()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "本地地址:${state.status.localUrl}",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
IconButton(onClick = onOpenLocalUrl, modifier = Modifier.size(36.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Language,
|
|
||||||
contentDescription = "打开本地地址",
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (state.status.startedAt.isNotBlank()) {
|
|
||||||
Text("启动时间:${state.status.startedAt}")
|
|
||||||
}
|
|
||||||
if (state.status.lastError.isNotBlank()) {
|
|
||||||
Text("最近错误:${state.status.lastError}", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onToggleServer,
|
|
||||||
enabled = !state.busy,
|
|
||||||
modifier = Modifier.widthIn(min = 132.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (state.status.running) Icons.Outlined.Stop else Icons.Outlined.PlayArrow,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
Text(if (state.status.running) "停止服务" else "启动服务")
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { viewModel.refreshStatus() },
|
|
||||||
enabled = !state.busy,
|
|
||||||
modifier = Modifier.widthIn(min = 132.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Outlined.Refresh, contentDescription = null)
|
|
||||||
Text("刷新状态")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.message.isNotBlank()) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = state.message,
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text("服务设置", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text("解锁全部日替", style = MaterialTheme.typography.bodyLarge)
|
|
||||||
Text(
|
|
||||||
"切换后会直接写入配置文件,并在服务运行中自动重载。",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = state.unlockAllSpecialRotation,
|
|
||||||
onCheckedChange = { viewModel.updateUnlockAllSpecialRotation(it) },
|
|
||||||
enabled = !state.busy,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text("数据目录", style = MaterialTheme.typography.titleMedium)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.dataDirPath,
|
|
||||||
onValueChange = {},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { Text("Android 数据目录") },
|
|
||||||
leadingIcon = { Icon(Icons.Outlined.Storage, contentDescription = null) },
|
|
||||||
readOnly = true,
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = onPickDataDirectory) {
|
|
||||||
Icon(Icons.Outlined.FolderOpen, contentDescription = null)
|
|
||||||
Text("选择目录")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text("备份管理", style = MaterialTheme.typography.titleMedium)
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedButton(onClick = onExportBackup) {
|
|
||||||
Text("导出备份")
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = onImportBackup) {
|
|
||||||
Text("导入备份")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Text("运行时信息", style = MaterialTheme.typography.titleMedium)
|
|
||||||
LabelValue(label = "运行目录", value = state.runtimeRoot)
|
|
||||||
LabelValue(label = "配置文件", value = state.configPath)
|
|
||||||
LabelValue(label = "挂载目录", value = state.dataDirPath)
|
|
||||||
LabelValue(label = "资源包哈希", value = state.deployedBundleHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Text("Health 状态", style = MaterialTheme.typography.titleMedium)
|
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = state.health.status == "ok",
|
|
||||||
onClick = {},
|
|
||||||
label = { Text(if (state.health.status.isBlank()) "未知" else state.health.status) },
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = state.health.mainDb == "ok",
|
|
||||||
onClick = {},
|
|
||||||
label = { Text("Main DB: ${if (state.health.mainDb == "ok") "connected" else "disconnected"}") },
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = state.health.userDb == "ok",
|
|
||||||
onClick = {},
|
|
||||||
label = { Text("User DB: ${if (state.health.userDb == "ok") "connected" else "disconnected"}") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LabelValue(label = "应用名", value = state.health.appName)
|
|
||||||
LabelValue(label = "版本", value = state.health.version)
|
|
||||||
LabelValue(label = "监听端口", value = state.health.listenPort)
|
|
||||||
LabelValue(label = "启动时间", value = state.health.startedAt)
|
|
||||||
LabelValue(label = "运行时长", value = formatUptime(state.health.uptimeSeconds))
|
|
||||||
LabelValue(label = "最近重载", value = state.health.lastReloadAt)
|
|
||||||
LabelValue(label = "CDN", value = state.health.cdnServer)
|
|
||||||
LabelValue(
|
|
||||||
label = "Reload Token",
|
|
||||||
value = if (state.health.reloadTokenConfigured) "已配置" else "未配置",
|
|
||||||
)
|
|
||||||
if (state.health.message.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = state.health.message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LabelValue(label: String, value: String) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
|
||||||
Text(value.ifBlank { "-" }, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatUptime(seconds: Long): String {
|
|
||||||
if (seconds <= 0) {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
val hours = seconds / 3600
|
|
||||||
val minutes = (seconds % 3600) / 60
|
|
||||||
val remainSeconds = seconds % 60
|
|
||||||
return "%02d:%02d:%02d".format(hours, minutes, remainSeconds)
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
package me.killkiss.honokactrl.ui
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import me.killkiss.honokactrl.jni.NativeBridge
|
|
||||||
import me.killkiss.honokactrl.jni.NativeHealthInfo
|
|
||||||
import me.killkiss.honokactrl.jni.NativeReloadResult
|
|
||||||
import me.killkiss.honokactrl.jni.NativeServerStatus
|
|
||||||
import me.killkiss.honokactrl.service.HonokaServerService
|
|
||||||
import me.killkiss.honokactrl.storage.RuntimePreparer
|
|
||||||
import me.killkiss.honokactrl.storage.SettingsStore
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
data class MainUiState(
|
|
||||||
val dataDirPath: String = SettingsStore.DEFAULT_DATA_DIR_PATH,
|
|
||||||
val runtimeRoot: String = "",
|
|
||||||
val configPath: String = "",
|
|
||||||
val deployedBundleHash: String = "",
|
|
||||||
val allFilesAccessGranted: Boolean = false,
|
|
||||||
val status: NativeServerStatus = NativeServerStatus(),
|
|
||||||
val health: NativeHealthInfo = NativeHealthInfo(),
|
|
||||||
val unlockAllSpecialRotation: Boolean = false,
|
|
||||||
val message: String = "",
|
|
||||||
val busy: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "HonokaServer"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val settingsStore = SettingsStore(application)
|
|
||||||
private val runtimePreparer = RuntimePreparer(application)
|
|
||||||
private var messageClearJob: Job? = null
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(
|
|
||||||
MainUiState(
|
|
||||||
dataDirPath = settingsStore.loadDataDirPath(),
|
|
||||||
allFilesAccessGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
refreshStatus()
|
|
||||||
refreshRuntimePaths()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDataDirPath(path: String) {
|
|
||||||
val normalizedPath = path.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }
|
|
||||||
settingsStore.saveDataDirPath(normalizedPath)
|
|
||||||
_uiState.value = _uiState.value.copy(dataDirPath = normalizedPath)
|
|
||||||
applyDataDirMount()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showMessage(message: String) {
|
|
||||||
pushMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleServer() {
|
|
||||||
if (uiState.value.status.running) {
|
|
||||||
stopServer()
|
|
||||||
} else {
|
|
||||||
startServer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startServer() {
|
|
||||||
if (!uiState.value.allFilesAccessGranted) {
|
|
||||||
Log.w(TAG, "start aborted: all files access is not granted")
|
|
||||||
pushMessage("请先授予全部文件访问权限")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.i(TAG, "start requested, dataDir=${uiState.value.dataDirPath}")
|
|
||||||
prepareRuntime { runtimeRoot ->
|
|
||||||
val context = getApplication<Application>()
|
|
||||||
val intent = HonokaServerService.startIntent(context, runtimeRoot)
|
|
||||||
try {
|
|
||||||
Log.i(TAG, "starting foreground service, runtimeRoot=$runtimeRoot")
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
_uiState.value = _uiState.value.copy(busy = true)
|
|
||||||
pushMessage("正在启动服务", autoClearMillis = null)
|
|
||||||
refreshServerState(
|
|
||||||
initialDelayMillis = 500,
|
|
||||||
retryCount = 8,
|
|
||||||
retryDelayMillis = 400,
|
|
||||||
expectedRunning = true,
|
|
||||||
updateMessage = true,
|
|
||||||
)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(TAG, "failed to start foreground service", e)
|
|
||||||
_uiState.value = _uiState.value.copy(busy = false)
|
|
||||||
pushMessage(e.message ?: "启动前台服务失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopServer() {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
Log.i(TAG, "stop requested")
|
|
||||||
_uiState.value = _uiState.value.copy(busy = true)
|
|
||||||
val context = getApplication<Application>()
|
|
||||||
context.startService(HonokaServerService.stopIntent(context))
|
|
||||||
refreshServerState(
|
|
||||||
initialDelayMillis = 200,
|
|
||||||
retryCount = 6,
|
|
||||||
retryDelayMillis = 300,
|
|
||||||
expectedRunning = false,
|
|
||||||
updateMessage = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshStatus(delayed: Boolean = false) {
|
|
||||||
refreshServerState(initialDelayMillis = if (delayed) 800 else 0, updateMessage = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshAllFilesAccess(granted: Boolean) {
|
|
||||||
_uiState.value = _uiState.value.copy(allFilesAccessGranted = granted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateUnlockAllSpecialRotation(enabled: Boolean) {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
busy = true,
|
|
||||||
unlockAllSpecialRotation = enabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
val configFile = File(_uiState.value.configPath.ifBlank {
|
|
||||||
File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
|
|
||||||
.resolve("config.json")
|
|
||||||
.absolutePath
|
|
||||||
})
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
writeUnlockAllSpecialRotation(configFile, enabled)
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "failed to update config", it)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
busy = false,
|
|
||||||
unlockAllSpecialRotation = !enabled,
|
|
||||||
)
|
|
||||||
pushMessage(it.message ?: "更新配置失败")
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uiState.value.status.running) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_uiState.value = _uiState.value.copy(busy = false)
|
|
||||||
pushMessage("设置已保存,下次启动服务时生效")
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val result: NativeReloadResult = NativeBridge.reload()
|
|
||||||
Log.i(TAG, "reload requested by config switch: success=${result.success}, message=${result.message}")
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (result.success) {
|
|
||||||
pushMessage("设置已保存并重载")
|
|
||||||
refreshServerState(initialDelayMillis = 200, updateMessage = false)
|
|
||||||
} else {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
busy = false,
|
|
||||||
)
|
|
||||||
pushMessage(result.message.ifBlank { "设置已保存,但重载配置失败" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyDataDirMount() {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
Log.i(TAG, "apply data dir mount: ${_uiState.value.dataDirPath}")
|
|
||||||
_uiState.value = _uiState.value.copy(busy = true)
|
|
||||||
|
|
||||||
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_uiState.value = _uiState.value.copy(busy = false)
|
|
||||||
result.onSuccess {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
runtimeRoot = it.runtimeRoot.absolutePath,
|
|
||||||
configPath = it.configFile.absolutePath,
|
|
||||||
deployedBundleHash = it.deployedBundleHash,
|
|
||||||
)
|
|
||||||
syncConfigState(it.configFile)
|
|
||||||
pushMessage("数据目录已保存并挂载")
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "apply data dir mount failed", it)
|
|
||||||
pushMessage(it.message ?: "目录挂载失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun prepareRuntime(onSuccess: ((String) -> Unit)? = null) {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
Log.i(TAG, "prepare runtime")
|
|
||||||
_uiState.value = _uiState.value.copy(busy = true)
|
|
||||||
settingsStore.saveDataDirPath(_uiState.value.dataDirPath)
|
|
||||||
|
|
||||||
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
_uiState.value = _uiState.value.copy(busy = false)
|
|
||||||
result.onSuccess {
|
|
||||||
Log.i(TAG, "runtime ready: root=${it.runtimeRoot}, mounted=${it.mountedDataDir}")
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
runtimeRoot = it.runtimeRoot.absolutePath,
|
|
||||||
configPath = it.configFile.absolutePath,
|
|
||||||
deployedBundleHash = it.deployedBundleHash,
|
|
||||||
)
|
|
||||||
syncConfigState(it.configFile)
|
|
||||||
pushMessage("运行时已准备完成")
|
|
||||||
onSuccess?.invoke(it.runtimeRoot.absolutePath)
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "runtime prepare failed", it)
|
|
||||||
pushMessage(it.message ?: "运行时准备失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshRuntimePaths() {
|
|
||||||
val runtimeRoot = File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
runtimeRoot = runtimeRoot.absolutePath,
|
|
||||||
configPath = File(runtimeRoot, "config.json").absolutePath,
|
|
||||||
deployedBundleHash = RuntimePreparer.readDeployedBundleHash(runtimeRoot),
|
|
||||||
)
|
|
||||||
syncConfigState(File(runtimeRoot, "config.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshServerState(
|
|
||||||
initialDelayMillis: Long = 0,
|
|
||||||
retryCount: Int = 1,
|
|
||||||
retryDelayMillis: Long = 0,
|
|
||||||
expectedRunning: Boolean? = null,
|
|
||||||
updateMessage: Boolean = false,
|
|
||||||
) {
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
if (initialDelayMillis > 0) {
|
|
||||||
delay(initialDelayMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
var status = NativeBridge.status()
|
|
||||||
var health = NativeBridge.health()
|
|
||||||
for (attempt in 1 until retryCount.coerceAtLeast(1)) {
|
|
||||||
if (isExpectedStateReached(status, health, expectedRunning)) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (retryDelayMillis > 0) {
|
|
||||||
delay(retryDelayMillis)
|
|
||||||
}
|
|
||||||
status = NativeBridge.status()
|
|
||||||
health = NativeBridge.health()
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"server state refreshed: running=${status.running}, health=${health.status}, mainDb=${health.mainDb}, userDb=${health.userDb}, error=${status.lastError}",
|
|
||||||
)
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val message = if (updateMessage) {
|
|
||||||
buildStatusMessage(status, health, expectedRunning)
|
|
||||||
} else {
|
|
||||||
_uiState.value.message
|
|
||||||
}
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
status = status,
|
|
||||||
health = health,
|
|
||||||
busy = false,
|
|
||||||
message = if (updateMessage) message else _uiState.value.message,
|
|
||||||
)
|
|
||||||
syncConfigState(File(_uiState.value.configPath))
|
|
||||||
if (updateMessage && message.isNotBlank()) {
|
|
||||||
scheduleMessageClear(5000)
|
|
||||||
}
|
|
||||||
refreshRuntimePaths()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isExpectedStateReached(
|
|
||||||
status: NativeServerStatus,
|
|
||||||
health: NativeHealthInfo,
|
|
||||||
expectedRunning: Boolean?,
|
|
||||||
): Boolean {
|
|
||||||
return when (expectedRunning) {
|
|
||||||
true -> status.running && health.status != "stopped" && health.mainDb != "not initialized" && health.userDb != "not initialized"
|
|
||||||
false -> !status.running
|
|
||||||
null -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildStatusMessage(
|
|
||||||
status: NativeServerStatus,
|
|
||||||
health: NativeHealthInfo,
|
|
||||||
expectedRunning: Boolean?,
|
|
||||||
): String {
|
|
||||||
if (status.lastError.isNotBlank()) {
|
|
||||||
return status.lastError
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (expectedRunning) {
|
|
||||||
true -> {
|
|
||||||
if (status.running && health.status != "stopped") {
|
|
||||||
"服务运行中"
|
|
||||||
} else {
|
|
||||||
"服务启动中,请稍后刷新"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false -> "服务已停止"
|
|
||||||
null -> {
|
|
||||||
if (status.running) {
|
|
||||||
"服务运行中"
|
|
||||||
} else {
|
|
||||||
_uiState.value.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pushMessage(message: String, autoClearMillis: Long? = 5000) {
|
|
||||||
messageClearJob?.cancel()
|
|
||||||
_uiState.value = _uiState.value.copy(message = message)
|
|
||||||
if (autoClearMillis != null) {
|
|
||||||
scheduleMessageClear(autoClearMillis)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncConfigState(configFile: File) {
|
|
||||||
if (!configFile.exists()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
val json = JSONObject(configFile.readText())
|
|
||||||
json.optJSONObject("settings")?.optBoolean("unlock_all_special_rotation", false) ?: false
|
|
||||||
}.onSuccess { enabled ->
|
|
||||||
_uiState.value = _uiState.value.copy(unlockAllSpecialRotation = enabled)
|
|
||||||
}.onFailure {
|
|
||||||
Log.w(TAG, "failed to parse config state", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeUnlockAllSpecialRotation(configFile: File, enabled: Boolean) {
|
|
||||||
configFile.parentFile?.mkdirs()
|
|
||||||
val json = if (configFile.exists()) {
|
|
||||||
runCatching { JSONObject(configFile.readText()) }.getOrElse { JSONObject() }
|
|
||||||
} else {
|
|
||||||
JSONObject()
|
|
||||||
}
|
|
||||||
val settings = json.optJSONObject("settings") ?: JSONObject()
|
|
||||||
if (!json.has("app_name")) {
|
|
||||||
json.put("app_name", "honoka-chan")
|
|
||||||
}
|
|
||||||
settings.put("listen_port", settings.optString("listen_port", "8080"))
|
|
||||||
settings.put("cdn_server", settings.optString("cdn_server", "http://127.0.0.1:8080/static"))
|
|
||||||
settings.put("reload_token", settings.optString("reload_token", ""))
|
|
||||||
settings.put("unlock_all_special_rotation", enabled)
|
|
||||||
json.put("settings", settings)
|
|
||||||
configFile.writeText(json.toString(4) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleMessageClear(delayMillis: Long) {
|
|
||||||
messageClearJob?.cancel()
|
|
||||||
messageClearJob = viewModelScope.launch {
|
|
||||||
delay(delayMillis)
|
|
||||||
_uiState.value = _uiState.value.copy(message = "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#1A73E8"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +0,0 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="app_name">honoka-chan</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
|
||||||
<style name="Theme.HonokaControl" parent="Theme.Material3.DayNight.NoActionBar">
|
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
<item name="android:navigationBarColor">@android:color/black</item>
|
|
||||||
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<network-security-config>
|
|
||||||
<base-config cleartextTrafficPermitted="true" />
|
|
||||||
</network-security-config>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application") version "8.11.2" apply false
|
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.0" apply false
|
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
android.useAndroidX=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
|
||||||
|
|
||||||
log "step 1/4: prepare runtime zip"
|
|
||||||
"$SCRIPT_DIR/prepare_runtime_zip.sh"
|
|
||||||
|
|
||||||
log "step 2/4: build Go library arm64-v8a"
|
|
||||||
"$SCRIPT_DIR/build_go_android.sh" arm64-v8a
|
|
||||||
|
|
||||||
log "step 3/4: build Go library armeabi-v7a"
|
|
||||||
"$SCRIPT_DIR/build_go_android.sh" armeabi-v7a
|
|
||||||
|
|
||||||
log "step 4/4: build Go library x86_64"
|
|
||||||
"$SCRIPT_DIR/build_go_android.sh" x86_64
|
|
||||||
|
|
||||||
log "android runtime assets and JNI libraries are ready"
|
|
||||||
log "open android/ in Android Studio to build the APK"
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
|
||||||
|
|
||||||
require_cmd go
|
|
||||||
|
|
||||||
ABI="${1:-arm64-v8a}"
|
|
||||||
MIN_SDK="${MIN_SDK:-26}"
|
|
||||||
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
|
||||||
|
|
||||||
[[ -n "$NDK_ROOT" ]] || die "ANDROID_NDK_HOME is not set"
|
|
||||||
[[ -d "$NDK_ROOT" ]] || die "ANDROID_NDK_HOME does not exist: $NDK_ROOT"
|
|
||||||
|
|
||||||
HOST_TAG="${ANDROID_NDK_HOST_TAG:-linux-x86_64}"
|
|
||||||
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG/bin"
|
|
||||||
[[ -d "$TOOLCHAIN" ]] || die "NDK toolchain not found: $TOOLCHAIN"
|
|
||||||
|
|
||||||
case "$ABI" in
|
|
||||||
arm64-v8a)
|
|
||||||
export GOOS=android
|
|
||||||
export GOARCH=arm64
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
export CC="$TOOLCHAIN/aarch64-linux-android${MIN_SDK}-clang"
|
|
||||||
OUTPUT_DIR="$ANDROID_DIR/app/src/main/jniLibs/arm64-v8a"
|
|
||||||
;;
|
|
||||||
armeabi-v7a)
|
|
||||||
export GOOS=android
|
|
||||||
export GOARCH=arm
|
|
||||||
export GOARM=7
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
export CC="$TOOLCHAIN/armv7a-linux-androideabi${MIN_SDK}-clang"
|
|
||||||
OUTPUT_DIR="$ANDROID_DIR/app/src/main/jniLibs/armeabi-v7a"
|
|
||||||
;;
|
|
||||||
x86_64)
|
|
||||||
export GOOS=android
|
|
||||||
export GOARCH=amd64
|
|
||||||
export CGO_ENABLED=1
|
|
||||||
export CC="$TOOLCHAIN/x86_64-linux-android${MIN_SDK}-clang"
|
|
||||||
OUTPUT_DIR="$ANDROID_DIR/app/src/main/jniLibs/x86_64"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "unsupported abi: $ABI"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[[ -x "$CC" ]] || die "clang not found: $CC"
|
|
||||||
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
log "building Go shared library for $ABI"
|
|
||||||
go build -trimpath -buildmode=c-shared \
|
|
||||||
-ldflags="-s -w" \
|
|
||||||
-o "$OUTPUT_DIR/libhonokachan.so" \
|
|
||||||
./cmd/honoka-android
|
|
||||||
|
|
||||||
rm -f "$OUTPUT_DIR/libhonokachan.h"
|
|
||||||
|
|
||||||
log "built $OUTPUT_DIR/libhonokachan.so"
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
|
||||||
|
|
||||||
ok() {
|
|
||||||
printf '[android] ok: %s\n' "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
warn() {
|
|
||||||
printf '[android] warn: %s\n' "$*" >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
status=0
|
|
||||||
|
|
||||||
check_cmd() {
|
|
||||||
local cmd="$1"
|
|
||||||
if command -v "$cmd" >/dev/null 2>&1; then
|
|
||||||
ok "command found: $cmd -> $(command -v "$cmd")"
|
|
||||||
else
|
|
||||||
warn "command missing: $cmd"
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_file() {
|
|
||||||
local file="$1"
|
|
||||||
if [[ -f "$file" ]]; then
|
|
||||||
ok "file found: $file"
|
|
||||||
else
|
|
||||||
warn "file missing: $file"
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_dir() {
|
|
||||||
local dir="$1"
|
|
||||||
if [[ -d "$dir" ]]; then
|
|
||||||
ok "directory found: $dir"
|
|
||||||
else
|
|
||||||
warn "directory missing: $dir"
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
log "checking common commands"
|
|
||||||
check_cmd go
|
|
||||||
check_cmd zip
|
|
||||||
|
|
||||||
SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
|
|
||||||
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
|
|
||||||
HOST_TAG="${ANDROID_NDK_HOST_TAG:-linux-x86_64}"
|
|
||||||
MIN_SDK="${MIN_SDK:-26}"
|
|
||||||
|
|
||||||
if [[ -n "$SDK_ROOT" ]]; then
|
|
||||||
check_dir "$SDK_ROOT"
|
|
||||||
else
|
|
||||||
warn "ANDROID_SDK_ROOT is not set"
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$NDK_ROOT" ]]; then
|
|
||||||
check_dir "$NDK_ROOT"
|
|
||||||
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG/bin"
|
|
||||||
check_dir "$TOOLCHAIN"
|
|
||||||
check_file "$TOOLCHAIN/aarch64-linux-android${MIN_SDK}-clang"
|
|
||||||
check_file "$TOOLCHAIN/armv7a-linux-androideabi${MIN_SDK}-clang"
|
|
||||||
check_file "$TOOLCHAIN/x86_64-linux-android${MIN_SDK}-clang"
|
|
||||||
else
|
|
||||||
warn "ANDROID_NDK_HOME is not set"
|
|
||||||
status=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "checking project files"
|
|
||||||
check_file "$PROJECT_ROOT/assets/main.db"
|
|
||||||
check_dir "$PROJECT_ROOT/assets/serverdata"
|
|
||||||
check_dir "$PROJECT_ROOT/assets/certs"
|
|
||||||
check_dir "$PROJECT_ROOT/static/templates"
|
|
||||||
|
|
||||||
if [[ $status -ne 0 ]]; then
|
|
||||||
warn "environment check failed"
|
|
||||||
exit $status
|
|
||||||
fi
|
|
||||||
|
|
||||||
ok "environment check passed"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd -- "$ANDROID_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
readonly SCRIPT_DIR
|
|
||||||
readonly ANDROID_DIR
|
|
||||||
readonly PROJECT_ROOT
|
|
||||||
|
|
||||||
log() {
|
|
||||||
printf '[android] %s\n' "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
die() {
|
|
||||||
printf '[android] error: %s\n' "$*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
require_cmd() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_file() {
|
|
||||||
[[ -f "$1" ]] || die "missing file: $1"
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
|
||||||
|
|
||||||
require_cmd zip
|
|
||||||
|
|
||||||
ZIP_PATH="$ANDROID_DIR/app/src/main/assets/honoka_runtime.zip"
|
|
||||||
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
ensure_file "$PROJECT_ROOT/assets/main.db"
|
|
||||||
|
|
||||||
TMP_DIR="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
|
||||||
|
|
||||||
mkdir -p "$TMP_DIR/assets" "$TMP_DIR/static"
|
|
||||||
|
|
||||||
log "collecting runtime files"
|
|
||||||
|
|
||||||
cat > "$TMP_DIR/config.json" <<'EOF'
|
|
||||||
{
|
|
||||||
"app_name": "honoka-chan",
|
|
||||||
"settings": {
|
|
||||||
"listen_port": "8080",
|
|
||||||
"cdn_server": "http://127.0.0.1:8080/static",
|
|
||||||
"reload_token": "",
|
|
||||||
"unlock_all_special_rotation": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cp -r "$PROJECT_ROOT/assets/main.db" "$TMP_DIR/assets/main.db"
|
|
||||||
|
|
||||||
for path in \
|
|
||||||
"assets/serverdata" \
|
|
||||||
"assets/certs" \
|
|
||||||
"static/templates" \
|
|
||||||
"static/css" \
|
|
||||||
"static/js" \
|
|
||||||
"static/images" \
|
|
||||||
"static/font"
|
|
||||||
do
|
|
||||||
if [[ -e "$PROJECT_ROOT/$path" ]]; then
|
|
||||||
mkdir -p "$TMP_DIR/$(dirname "$path")"
|
|
||||||
cp -r "$PROJECT_ROOT/$path" "$TMP_DIR/$path"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
rm -f "$ZIP_PATH"
|
|
||||||
|
|
||||||
log "packing $ZIP_PATH"
|
|
||||||
(
|
|
||||||
cd "$TMP_DIR"
|
|
||||||
zip -qr "$ZIP_PATH" assets static config.json
|
|
||||||
)
|
|
||||||
|
|
||||||
log "runtime zip ready: $ZIP_PATH"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "honoka-android"
|
|
||||||
include(":app")
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
/*
|
|
||||||
#include <stdlib.h>
|
|
||||||
*/
|
|
||||||
import "C"
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"honoka-chan/internal/app"
|
|
||||||
systemhandler "honoka-chan/internal/handler/system"
|
|
||||||
"time"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
//export ServerStart
|
|
||||||
func ServerStart(workDir *C.char) *C.char {
|
|
||||||
if err := app.Start(C.GoString(workDir)); err != nil {
|
|
||||||
return C.CString(err.Error())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//export ServerStop
|
|
||||||
func ServerStop() *C.char {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := app.Stop(ctx); err != nil {
|
|
||||||
return C.CString(err.Error())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
//export ServerStatusJSON
|
|
||||||
func ServerStatusJSON() *C.char {
|
|
||||||
return C.CString(app.GetStatusJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
//export ServerHealthJSON
|
|
||||||
func ServerHealthJSON() *C.char {
|
|
||||||
return C.CString(systemhandler.HealthJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
//export ServerReload
|
|
||||||
func ServerReload() *C.char {
|
|
||||||
resp, _, err := systemhandler.Reload("", false)
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data, marshalErr := json.Marshal(resp)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return C.CString(err.Error())
|
|
||||||
}
|
|
||||||
return C.CString(string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
//export ServerFreeString
|
|
||||||
func ServerFreeString(str *C.char) {
|
|
||||||
if str == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
C.free(unsafe.Pointer(str))
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {}
|
|
||||||
@@ -2,7 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"honoka-chan/pkg/utils"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,7 +12,6 @@ var (
|
|||||||
Conf = &AppConfigs{}
|
Conf = &AppConfigs{}
|
||||||
|
|
||||||
PackageVersion = "97.4.6"
|
PackageVersion = "97.4.6"
|
||||||
ConfigPath = "./config.json"
|
|
||||||
|
|
||||||
PrivateKeyPath = "assets/certs/privatekey.pem"
|
PrivateKeyPath = "assets/certs/privatekey.pem"
|
||||||
PublicKeyPath = "assets/certs/publickey.pem"
|
PublicKeyPath = "assets/certs/publickey.pem"
|
||||||
@@ -26,17 +25,16 @@ type AppConfigs struct {
|
|||||||
type Settings struct {
|
type Settings struct {
|
||||||
ListenPort string `json:"listen_port"`
|
ListenPort string `json:"listen_port"`
|
||||||
CdnServer string `json:"cdn_server"`
|
CdnServer string `json:"cdn_server"`
|
||||||
ReloadToken string `json:"reload_token"`
|
|
||||||
UnlockAllSpecialRotation bool `json:"unlock_all_special_rotation"`
|
UnlockAllSpecialRotation bool `json:"unlock_all_special_rotation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitConfig() {
|
func InitConfig() error {
|
||||||
ReloadConfig()
|
conf, err := Load("./config.json")
|
||||||
}
|
if err != nil {
|
||||||
|
return err
|
||||||
func ReloadConfig() *AppConfigs {
|
}
|
||||||
Conf = Load(ConfigPath)
|
Conf = conf
|
||||||
return Conf
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfigs() *AppConfigs {
|
func DefaultConfigs() *AppConfigs {
|
||||||
@@ -45,25 +43,38 @@ func DefaultConfigs() *AppConfigs {
|
|||||||
Settings: Settings{
|
Settings: Settings{
|
||||||
ListenPort: "8080",
|
ListenPort: "8080",
|
||||||
CdnServer: "http://127.0.0.1:8080/static",
|
CdnServer: "http://127.0.0.1:8080/static",
|
||||||
ReloadToken: "",
|
|
||||||
UnlockAllSpecialRotation: false,
|
UnlockAllSpecialRotation: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(p string) *AppConfigs {
|
func Load(p string) (*AppConfigs, error) {
|
||||||
if !utils.PathExists(p) {
|
data, err := os.ReadFile(p)
|
||||||
_ = DefaultConfigs().Save(p)
|
if os.IsNotExist(err) {
|
||||||
|
conf := DefaultConfigs()
|
||||||
|
if err := conf.Save(p); err != nil {
|
||||||
|
return nil, fmt.Errorf("create default config: %w", err)
|
||||||
|
}
|
||||||
|
return conf, nil
|
||||||
}
|
}
|
||||||
c := AppConfigs{}
|
|
||||||
err := json.Unmarshal([]byte(utils.ReadAllText(p)), &c)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Rename(p, p+".backup"+strconv.FormatInt(time.Now().Unix(), 10))
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
_ = DefaultConfigs().Save(p)
|
|
||||||
}
|
}
|
||||||
c = AppConfigs{}
|
|
||||||
_ = json.Unmarshal([]byte(utils.ReadAllText(p)), &c)
|
c := AppConfigs{}
|
||||||
return &c
|
if err := json.Unmarshal(data, &c); err == nil {
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
backup := p + ".backup" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
if err := os.Rename(p, backup); err != nil {
|
||||||
|
return nil, fmt.Errorf("backup invalid config: %w", err)
|
||||||
|
}
|
||||||
|
conf := DefaultConfigs()
|
||||||
|
if err := conf.Save(p); err != nil {
|
||||||
|
return nil, fmt.Errorf("write default config: %w", err)
|
||||||
|
}
|
||||||
|
return conf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AppConfigs) Save(p string) error {
|
func (c *AppConfigs) Save(p string) error {
|
||||||
@@ -71,6 +82,5 @@ func (c *AppConfigs) Save(p string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
utils.WriteAllText(p, string(data)+"\n")
|
return os.WriteFile(p, append(data, '\n'), 0644)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadCreatesDefaultConfig(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
|
||||||
|
conf, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if conf.Settings.ListenPort != "8080" {
|
||||||
|
t.Fatalf("ListenPort = %q, want 8080", conf.Settings.ListenPort)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("default config was not written: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadBacksUpInvalidConfig(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(path, []byte("{"), 0644); err != nil {
|
||||||
|
t.Fatalf("write invalid config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conf, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if conf.Settings.ListenPort != "8080" {
|
||||||
|
t.Fatalf("ListenPort = %q, want 8080", conf.Settings.ListenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), "config.json.backup") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("invalid config backup was not created")
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
第一次启动 `honoka-chan` 后会生成 `config.json`。需要确认:
|
第一次启动 `honoka-chan` 后会生成 `config.json`。需要确认:
|
||||||
|
|
||||||
- `settings.cdn_server` 指向你的数据下载地址
|
- `settings.cdn_server` 指向你的数据下载地址
|
||||||
- `settings.reload_token` 可选;如果需要通过接口热重载 `config.json`,需要先设置一个 token
|
|
||||||
- `settings.unlock_all_special_rotation` 可选;设为 `true` 后会忽略日替时间限制,直接解锁全部日替特殊歌曲,不包含周替 `MASTER`
|
- `settings.unlock_all_special_rotation` 可选;设为 `true` 后会忽略日替时间限制,直接解锁全部日替特殊歌曲,不包含周替 `MASTER`
|
||||||
- 如果数据直接放在本项目的 `static` 目录下,通常可以配置成类似 `http://192.168.1.123/static`
|
- 如果数据直接放在本项目的 `static` 目录下,通常可以配置成类似 `http://192.168.1.123/static`
|
||||||
|
|
||||||
@@ -26,9 +25,6 @@
|
|||||||
|
|
||||||
## 补充说明
|
## 补充说明
|
||||||
|
|
||||||
- `GET /system/health` 可用于检查应用和数据库是否正常运行
|
|
||||||
- `POST /system/reload?token=你的token` 可在修改 `config.json` 后重新加载配置
|
|
||||||
|
|
||||||
SIF 的“全量数据下载”实际上并不是真正的全量。部分剧情资源仍然需要通过 `/download/getUrl` 接口按路径拉取。
|
SIF 的“全量数据下载”实际上并不是真正的全量。部分剧情资源仍然需要通过 `/download/getUrl` 接口按路径拉取。
|
||||||
|
|
||||||
所以除了 `archives` 目录里的压缩包,还需要另外维护一份:
|
所以除了 `archives` 目录里的压缩包,还需要另外维护一份:
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ require github.com/gin-gonic/gin v1.12.0
|
|||||||
require (
|
require (
|
||||||
github.com/gin-contrib/sessions v1.1.0
|
github.com/gin-contrib/sessions v1.1.0
|
||||||
github.com/go-think/openssl v1.20.0
|
github.com/go-think/openssl v1.20.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.45
|
|
||||||
github.com/tidwall/gjson v1.19.0
|
github.com/tidwall/gjson v1.19.0
|
||||||
modernc.org/sqlite v1.51.0
|
modernc.org/sqlite v1.53.0
|
||||||
xorm.io/builder v0.3.13
|
xorm.io/builder v0.3.13
|
||||||
xorm.io/xorm v1.3.11
|
xorm.io/xorm v1.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -30,15 +29,14 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tidwall/match v1.2.0 // indirect
|
github.com/tidwall/match v1.2.0 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.7.0 // indirect
|
||||||
golang.org/x/arch v0.27.0 // indirect
|
golang.org/x/arch v0.28.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
modernc.org/libc v1.73.5 // indirect
|
||||||
modernc.org/libc v1.72.5 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -54,13 +52,13 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
github.com/pelletier/go-toml/v2 v2.4.2 // indirect
|
||||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
|
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -85,14 +85,16 @@ github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
|
|||||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
||||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -118,30 +120,30 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
|
||||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -155,20 +157,20 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.2 h1:mxsy2FdrB6+qG3NfXefz1AmWv0ehOSDO4jxgxd7h9yo=
|
modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
|
||||||
modernc.org/ccgo/v4 v4.34.2/go.mod h1:1L7us56+kAKu04p25EATpmBBvhbcqqZ85ibqWVwVgog=
|
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.72.5 h1:m2OGx9Ser1VvTS4Z9ZJlWs+CBMxutLaTiAWkNz+NB9U=
|
modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
|
||||||
modernc.org/libc v1.72.5/go.mod h1:np0N7KDJ7eUtMZmOqVZNldrZyG+DHLl2B5pg8Hbar3U=
|
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -177,13 +179,13 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
|||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||||
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
|
xorm.io/xorm v1.4.0 h1:MOZfMAH09FlAnpXcGHp6jS+Np1u9DL/qvRjoW+4YITY=
|
||||||
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
xorm.io/xorm v1.4.0/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"honoka-chan/config"
|
|
||||||
_ "honoka-chan/internal/handler"
|
|
||||||
systemhandler "honoka-chan/internal/handler/system"
|
|
||||||
"honoka-chan/internal/router"
|
|
||||||
"honoka-chan/internal/startup"
|
|
||||||
"honoka-chan/pkg/db"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dateTimeFormat = "2006-01-02 15:04:05"
|
|
||||||
|
|
||||||
type Status struct {
|
|
||||||
Running bool `json:"running"`
|
|
||||||
WorkDir string `json:"work_dir"`
|
|
||||||
ListenPort string `json:"listen_port"`
|
|
||||||
LocalURL string `json:"local_url"`
|
|
||||||
StartedAt string `json:"started_at,omitempty"`
|
|
||||||
LastError string `json:"last_error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
httpServer *http.Server
|
|
||||||
listener net.Listener
|
|
||||||
state Status
|
|
||||||
)
|
|
||||||
|
|
||||||
func Start(workDir string) error {
|
|
||||||
if workDir == "" {
|
|
||||||
workDir = "."
|
|
||||||
}
|
|
||||||
|
|
||||||
absWorkDir, err := filepath.Abs(workDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
if state.Running {
|
|
||||||
mu.Unlock()
|
|
||||||
return errors.New("server is already running")
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
if err := os.Chdir(absWorkDir); err != nil {
|
|
||||||
setLastError(err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
config.ReloadConfig()
|
|
||||||
if err := db.Init("assets/main.db", "assets/data.db"); err != nil {
|
|
||||||
setLastError(err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := startup.StartUp(); err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
setLastError(err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
engine := gin.New()
|
|
||||||
engine.Use(gin.LoggerWithConfig(gin.LoggerConfig{
|
|
||||||
SkipPaths: []string{
|
|
||||||
"/agreement/all",
|
|
||||||
"/integration/appReport/initialize",
|
|
||||||
"/report/ge/app",
|
|
||||||
"/v1/account/reportRole",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
router.SifRouter(engine)
|
|
||||||
|
|
||||||
ln, err := net.Listen("tcp", ":"+config.Conf.Settings.ListenPort)
|
|
||||||
if err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
setLastError(err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Handler: engine,
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
systemhandler.MarkStarted(now)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
httpServer = srv
|
|
||||||
listener = ln
|
|
||||||
state = Status{
|
|
||||||
Running: true,
|
|
||||||
WorkDir: absWorkDir,
|
|
||||||
ListenPort: config.Conf.Settings.ListenPort,
|
|
||||||
LocalURL: fmt.Sprintf("http://127.0.0.1:%s", config.Conf.Settings.ListenPort),
|
|
||||||
StartedAt: now.Format(dateTimeFormat),
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
go serveLoop(srv, ln)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveLoop(srv *http.Server, ln net.Listener) {
|
|
||||||
err := srv.Serve(ln)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
state.LastError = err.Error()
|
|
||||||
}
|
|
||||||
state.Running = false
|
|
||||||
httpServer = nil
|
|
||||||
listener = nil
|
|
||||||
_ = db.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func Stop(ctx context.Context) error {
|
|
||||||
mu.Lock()
|
|
||||||
srv := httpServer
|
|
||||||
running := state.Running
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
if !running || srv == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
return srv.Shutdown(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetStatus() Status {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetStatusJSON() string {
|
|
||||||
status := GetStatus()
|
|
||||||
data, err := json.Marshal(status)
|
|
||||||
if err != nil {
|
|
||||||
return `{"running":false,"last_error":"failed to encode status"}`
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLastError(err error) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
state.LastError = err.Error()
|
|
||||||
state.Running = false
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,9 @@ type ErrorCode int
|
|||||||
|
|
||||||
// https://github.com/DarkEnergyProcessor/NPPS4/blob/master/npps4/idol/error.py
|
// https://github.com/DarkEnergyProcessor/NPPS4/blob/master/npps4/idol/error.py
|
||||||
const (
|
const (
|
||||||
ErrorCodeUnknown ErrorCode = 1
|
|
||||||
ErrorCodeNoUnitIsSellable ErrorCode = 1205
|
ErrorCodeNoUnitIsSellable ErrorCode = 1205
|
||||||
ErrorCodeLivePreciseListNotFound ErrorCode = 3421
|
ErrorCodeLivePreciseListNotFound ErrorCode = 3421
|
||||||
ErrorCodeEventNoEventData ErrorCode = 10004
|
ErrorCodeEventNoEventData ErrorCode = 10004
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const ErrorCodeAcceptableError = 600
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package achievement
|
||||||
|
|
||||||
|
import (
|
||||||
|
apiachievement "honoka-chan/internal/handler/api/achievement"
|
||||||
|
"honoka-chan/internal/middleware"
|
||||||
|
"honoka-chan/internal/router"
|
||||||
|
achievementschema "honoka-chan/internal/schema/achievement"
|
||||||
|
"honoka-chan/internal/session"
|
||||||
|
honokautils "honoka-chan/internal/utils"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pagingAccomplishedPageSize = 10
|
||||||
|
|
||||||
|
func pagingAccomplishedList(ctx *gin.Context) {
|
||||||
|
ss := session.Get(ctx)
|
||||||
|
defer ss.FinalizeOrRollback()
|
||||||
|
|
||||||
|
req := achievementschema.PagingAccomplishedListReq{}
|
||||||
|
err := honokautils.ParseRequestData(ctx, &req)
|
||||||
|
if ss.CheckErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped, err := apiachievement.ListVisibleAccomplishedAchievements(ss)
|
||||||
|
if ss.CheckErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := grouped[req.FilterCategoryID]
|
||||||
|
start := min(max(req.FromCount, 0), len(items))
|
||||||
|
|
||||||
|
end := min(start+pagingAccomplishedPageSize, len(items))
|
||||||
|
|
||||||
|
ss.Respond(achievementschema.PagingAccomplishedListResp{
|
||||||
|
ResponseData: items[start:end],
|
||||||
|
ReleaseInfo: []any{},
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
router.AddHandler("main.php", "POST", "/achievement/pagingAccomplishedList", middleware.Common, pagingAccomplishedList)
|
||||||
|
}
|
||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
"honoka-chan/internal/router"
|
"honoka-chan/internal/router"
|
||||||
albumschema "honoka-chan/internal/schema/album"
|
albumschema "honoka-chan/internal/schema/album"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func seriesAll(ctx *gin.Context) {
|
func seriesAll(ctx *gin.Context) {
|
||||||
ss := session.Get(ctx)
|
ss := session.Get(ctx)
|
||||||
defer ss.Finalize()
|
defer ss.FinalizeOrRollback()
|
||||||
|
|
||||||
var albumID []int
|
var albumID []int
|
||||||
err := ss.MainEng.Table("album_series_m").Select("album_series_id").Find(&albumID)
|
err := ss.MainEng.Table("album_series_m").Select("album_series_id").Find(&albumID)
|
||||||
@@ -81,7 +82,7 @@ func seriesAll(ctx *gin.Context) {
|
|||||||
ss.Respond(albumschema.SeriesAllResp{
|
ss.Respond(albumschema.SeriesAllResp{
|
||||||
ResponseData: albumSeriesAllRes,
|
ResponseData: albumSeriesAllRes,
|
||||||
ReleaseInfo: []any{},
|
ReleaseInfo: []any{},
|
||||||
StatusCode: 200,
|
StatusCode: http.StatusOK,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"honoka-chan/internal/router"
|
"honoka-chan/internal/router"
|
||||||
announceschema "honoka-chan/internal/schema/announce"
|
announceschema "honoka-chan/internal/schema/announce"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func checkState(ctx *gin.Context) {
|
func checkState(ctx *gin.Context) {
|
||||||
ss := session.Get(ctx)
|
ss := session.Get(ctx)
|
||||||
defer ss.Finalize()
|
defer ss.FinalizeOrRollback()
|
||||||
|
|
||||||
ss.Respond(announceschema.CheckStateDataResp{
|
ss.Respond(announceschema.CheckStateDataResp{
|
||||||
ResponseData: announceschema.CheckStateData{
|
ResponseData: announceschema.CheckStateData{
|
||||||
@@ -20,7 +21,7 @@ func checkState(ctx *gin.Context) {
|
|||||||
ServerTimestamp: time.Now().Unix(),
|
ServerTimestamp: time.Now().Unix(),
|
||||||
},
|
},
|
||||||
ReleaseInfo: []any{},
|
ReleaseInfo: []any{},
|
||||||
StatusCode: 200,
|
StatusCode: http.StatusOK,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package achievement
|
||||||
|
|
||||||
|
import (
|
||||||
|
honokautils "honoka-chan/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var achievementFilterCategoryList = []int{
|
||||||
|
1, // 重要
|
||||||
|
3, // 挑战
|
||||||
|
4, // 每日
|
||||||
|
5, // 期间限定
|
||||||
|
6, // 8周年限定/全世界5000万人突破纪念
|
||||||
|
7, // 回忆画廊
|
||||||
|
8, // 9周年限定
|
||||||
|
9, // 连携课题
|
||||||
|
}
|
||||||
|
|
||||||
|
func AchievementApi(ctx *gin.Context, action string) (res any, err error) {
|
||||||
|
switch action {
|
||||||
|
case "unaccomplishList":
|
||||||
|
res, err = unaccomplishList()
|
||||||
|
case "initialAccomplishedList":
|
||||||
|
res, err = initialAccomplishedList(ctx)
|
||||||
|
default:
|
||||||
|
err = honokautils.NewUnimplementedActionError("achievement", action)
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package achievement
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
achievementapischema "honoka-chan/internal/schema/api/achievement"
|
||||||
|
"honoka-chan/internal/session"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type achievementRow struct {
|
||||||
|
AchievementID int `xorm:"achievement_id"`
|
||||||
|
AchievementFilterCategoryID int `xorm:"achievement_filter_category_id"`
|
||||||
|
DisplayFlag int `xorm:"display_flag"`
|
||||||
|
StartDate string
|
||||||
|
EndDate *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListVisibleAccomplishedAchievements(ss *session.Session) (map[int][]achievementapischema.AchievementListItem, error) {
|
||||||
|
var achievementList []achievementRow
|
||||||
|
err := ss.MainEng.Table("achievement_m").
|
||||||
|
Cols("achievement_id,achievement_filter_category_id,display_flag,start_date,end_date").
|
||||||
|
Where("display_flag = ?", 1).
|
||||||
|
Find(&achievementList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
grouped := make(map[int][]achievementapischema.AchievementListItem, len(achievementFilterCategoryList))
|
||||||
|
for _, achievement := range achievementList {
|
||||||
|
if !isAchievementVisible(now, achievement.StartDate, achievement.EndDate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[achievement.AchievementFilterCategoryID] = append(
|
||||||
|
grouped[achievement.AchievementFilterCategoryID],
|
||||||
|
achievementapischema.AchievementListItem{
|
||||||
|
AchievementID: achievement.AchievementID,
|
||||||
|
Count: 1,
|
||||||
|
IsAccomplished: true,
|
||||||
|
InsertDate: normalizeAchievementDate(achievement.StartDate),
|
||||||
|
EndDate: normalizeAchievementDatePtr(achievement.EndDate),
|
||||||
|
RemainingTime: "",
|
||||||
|
IsNew: false,
|
||||||
|
ForDisplay: achievement.DisplayFlag != 0,
|
||||||
|
RewardList: []achievementapischema.AchievementRewardItem{
|
||||||
|
{
|
||||||
|
AddType: 3001,
|
||||||
|
ItemID: 4,
|
||||||
|
Amount: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for filterCategoryID, items := range grouped {
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].InsertDate > items[j].InsertDate
|
||||||
|
})
|
||||||
|
grouped[filterCategoryID] = items
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAchievementDate(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "1970-01-01 00:00:00"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ReplaceAll(value, "/", "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAchievementDatePtr(value *string) *string {
|
||||||
|
if value == nil || *value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := normalizeAchievementDate(*value)
|
||||||
|
return &normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAchievementVisible(now time.Time, startDate string, endDate *string) bool {
|
||||||
|
start, err := parseAchievementTime(startDate)
|
||||||
|
if err == nil && now.Before(start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDate == nil || *endDate == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
end, err := parseAchievementTime(*endDate)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return !now.After(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAchievementTime(value string) (time.Time, error) {
|
||||||
|
layouts := []string{
|
||||||
|
"2006/01/02 15:04:05",
|
||||||
|
"2006/1/2 15:04:05",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, layout := range layouts {
|
||||||
|
parsed, err := time.ParseInLocation(layout, value, time.Local)
|
||||||
|
if err == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}, fmt.Errorf("invalid achievement time: %s", value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package achievement
|
||||||
|
|
||||||
|
import (
|
||||||
|
achievementapischema "honoka-chan/internal/schema/api/achievement"
|
||||||
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initialAccomplishedList(ctx *gin.Context) (res any, err error) {
|
||||||
|
ss := session.Get(ctx)
|
||||||
|
|
||||||
|
grouped, err := ListVisibleAccomplishedAchievements(ss)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
listData := make([]achievementapischema.UnaccomplishListData, 0, len(achievementFilterCategoryList))
|
||||||
|
for _, filterCategoryID := range achievementFilterCategoryList {
|
||||||
|
achievementItems := grouped[filterCategoryID]
|
||||||
|
if achievementItems == nil {
|
||||||
|
achievementItems = []achievementapischema.AchievementListItem{}
|
||||||
|
} else {
|
||||||
|
if len(achievementItems) > 5 {
|
||||||
|
achievementItems = achievementItems[:5]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listData = append(listData, achievementapischema.UnaccomplishListData{
|
||||||
|
FilterCategoryID: filterCategoryID,
|
||||||
|
AchievementList: achievementItems,
|
||||||
|
Count: len(grouped[filterCategoryID]),
|
||||||
|
IsLast: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return achievementapischema.UnaccomplishListResp{
|
||||||
|
Result: listData,
|
||||||
|
Status: http.StatusOK,
|
||||||
|
CommandNum: false,
|
||||||
|
TimeStamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package achievement
|
||||||
|
|
||||||
|
import (
|
||||||
|
achievementapischema "honoka-chan/internal/schema/api/achievement"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func unaccomplishList() (res any, err error) {
|
||||||
|
listData := make([]achievementapischema.UnaccomplishListData, 0, len(achievementFilterCategoryList))
|
||||||
|
for i := range achievementFilterCategoryList {
|
||||||
|
listData = append(listData, achievementapischema.UnaccomplishListData{
|
||||||
|
FilterCategoryID: achievementFilterCategoryList[i],
|
||||||
|
AchievementList: []achievementapischema.AchievementListItem{},
|
||||||
|
Count: 0,
|
||||||
|
IsLast: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res = achievementapischema.UnaccomplishListResp{
|
||||||
|
Result: listData,
|
||||||
|
Status: http.StatusOK,
|
||||||
|
CommandNum: false,
|
||||||
|
TimeStamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package album
|
|||||||
import (
|
import (
|
||||||
albumapischema "honoka-chan/internal/schema/api/album"
|
albumapischema "honoka-chan/internal/schema/api/album"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -61,7 +62,7 @@ func albumAll(ctx *gin.Context) (res any, err error) {
|
|||||||
|
|
||||||
res = albumapischema.AllResp{
|
res = albumapischema.AllResp{
|
||||||
Result: albumLists,
|
Result: albumLists,
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"honoka-chan/internal/constant"
|
"honoka-chan/internal/handler/api/achievement"
|
||||||
"honoka-chan/internal/handler/api/album"
|
"honoka-chan/internal/handler/api/album"
|
||||||
"honoka-chan/internal/handler/api/award"
|
"honoka-chan/internal/handler/api/award"
|
||||||
"honoka-chan/internal/handler/api/background"
|
"honoka-chan/internal/handler/api/background"
|
||||||
@@ -32,17 +32,16 @@ import (
|
|||||||
"honoka-chan/internal/middleware"
|
"honoka-chan/internal/middleware"
|
||||||
"honoka-chan/internal/router"
|
"honoka-chan/internal/router"
|
||||||
apischema "honoka-chan/internal/schema/api"
|
apischema "honoka-chan/internal/schema/api"
|
||||||
commonschema "honoka-chan/internal/schema/common"
|
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
honokautils "honoka-chan/internal/utils"
|
honokautils "honoka-chan/internal/utils"
|
||||||
"time"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func api(ctx *gin.Context) {
|
func api(ctx *gin.Context) {
|
||||||
ss := session.Get(ctx)
|
ss := session.Get(ctx)
|
||||||
defer ss.Finalize()
|
defer ss.FinalizeOrRollback()
|
||||||
|
|
||||||
apiReq := []apischema.ApiReq{}
|
apiReq := []apischema.ApiReq{}
|
||||||
err := honokautils.ParseRequestData(ctx, &apiReq)
|
err := honokautils.ParseRequestData(ctx, &apiReq)
|
||||||
@@ -57,6 +56,8 @@ func api(ctx *gin.Context) {
|
|||||||
switch v.Module {
|
switch v.Module {
|
||||||
case "album":
|
case "album":
|
||||||
result, err = album.AlbumApi(ctx, v.Action)
|
result, err = album.AlbumApi(ctx, v.Action)
|
||||||
|
case "achievement":
|
||||||
|
result, err = achievement.AchievementApi(ctx, v.Action)
|
||||||
case "award":
|
case "award":
|
||||||
result, err = award.AwardApi(ctx, v.Action)
|
result, err = award.AwardApi(ctx, v.Action)
|
||||||
case "background":
|
case "background":
|
||||||
@@ -114,15 +115,8 @@ func api(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if honokautils.IsUnimplementedError(err) {
|
if honokautils.IsUnimplementedError(err) {
|
||||||
results = append(results, commonschema.ApiErrorResp{
|
ss.AbortWithStatus(http.StatusNotFound, honokautils.NewDetailContent(err.Error()))
|
||||||
Result: commonschema.ErrorData{
|
return
|
||||||
ErrorCode: constant.ErrorCodeUnknown,
|
|
||||||
},
|
|
||||||
Status: 600,
|
|
||||||
CommandNum: false,
|
|
||||||
TimeStamp: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if ss.CheckErr(err) {
|
if ss.CheckErr(err) {
|
||||||
return
|
return
|
||||||
@@ -133,7 +127,7 @@ func api(ctx *gin.Context) {
|
|||||||
apiResp := apischema.ApiResp{
|
apiResp := apischema.ApiResp{
|
||||||
ResponseData: results,
|
ResponseData: results,
|
||||||
ReleaseInfo: []any{},
|
ReleaseInfo: []any{},
|
||||||
StatusCode: 200,
|
StatusCode: http.StatusOK,
|
||||||
}
|
}
|
||||||
|
|
||||||
ss.Respond(apiResp)
|
ss.Respond(apiResp)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package award
|
|||||||
import (
|
import (
|
||||||
awardapischema "honoka-chan/internal/schema/api/award"
|
awardapischema "honoka-chan/internal/schema/api/award"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -40,7 +41,7 @@ func awardInfo(ctx *gin.Context) (res any, err error) {
|
|||||||
Result: awardapischema.InfoData{
|
Result: awardapischema.InfoData{
|
||||||
AwardInfo: awardsList,
|
AwardInfo: awardsList,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package background
|
|||||||
import (
|
import (
|
||||||
backgroundapischema "honoka-chan/internal/schema/api/background"
|
backgroundapischema "honoka-chan/internal/schema/api/background"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -40,7 +41,7 @@ func backgroundInfo(ctx *gin.Context) (res any, err error) {
|
|||||||
Result: backgroundapischema.InfoData{
|
Result: backgroundapischema.InfoData{
|
||||||
BackgroundInfo: backgroundsList,
|
BackgroundInfo: backgroundsList,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package banner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
bannerapischema "honoka-chan/internal/schema/api/banner"
|
bannerapischema "honoka-chan/internal/schema/api/banner"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ func bannerList() (res any, err error) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package challenge
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
challengeapischema "honoka-chan/internal/schema/api/challenge"
|
challengeapischema "honoka-chan/internal/schema/api/challenge"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func challengeInfo() (res any, err error) {
|
func challengeInfo() (res any, err error) {
|
||||||
res = challengeapischema.InfoResp{
|
res = challengeapischema.InfoResp{
|
||||||
Result: []any{},
|
Result: []any{},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package costume
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
costumeapischema "honoka-chan/internal/schema/api/costume"
|
costumeapischema "honoka-chan/internal/schema/api/costume"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ func costumeList() (res any, err error) {
|
|||||||
Result: costumeapischema.ListData{
|
Result: costumeapischema.ListData{
|
||||||
CostumeList: []costumeapischema.CostumeList{},
|
CostumeList: []costumeapischema.CostumeList{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
eventscenarioapischema "honoka-chan/internal/schema/api/eventscenario"
|
eventscenarioapischema "honoka-chan/internal/schema/api/eventscenario"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ func eventScenarioStatus(ctx *gin.Context) (res any, err error) {
|
|||||||
Result: eventscenarioapischema.StatusData{
|
Result: eventscenarioapischema.StatusData{
|
||||||
EventScenarioList: eventsList,
|
EventScenarioList: eventsList,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package exchange
|
|||||||
import (
|
import (
|
||||||
exchangeapischema "honoka-chan/internal/schema/api/exchange"
|
exchangeapischema "honoka-chan/internal/schema/api/exchange"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -28,7 +29,7 @@ func owningPoint(ctx *gin.Context) (res any, err error) {
|
|||||||
Result: exchangeapischema.OwningPointData{
|
Result: exchangeapischema.OwningPointData{
|
||||||
ExchangePointList: exPointsList,
|
ExchangePointList: exPointsList,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package item
|
|||||||
import (
|
import (
|
||||||
itemapischema "honoka-chan/internal/schema/api/item"
|
itemapischema "honoka-chan/internal/schema/api/item"
|
||||||
honokautils "honoka-chan/internal/utils"
|
honokautils "honoka-chan/internal/utils"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ func itemList() (res any, err error) {
|
|||||||
|
|
||||||
res = itemapischema.ListResp{
|
res = itemapischema.ListResp{
|
||||||
Result: itemData,
|
Result: itemData,
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package live
|
|||||||
import (
|
import (
|
||||||
liveapischema "honoka-chan/internal/schema/api/live"
|
liveapischema "honoka-chan/internal/schema/api/live"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -36,7 +37,7 @@ func liveSchedule(ctx *gin.Context) (res any, err error) {
|
|||||||
FreeLiveList: []any{},
|
FreeLiveList: []any{},
|
||||||
TrainingLiveList: []liveapischema.TrainingLiveList{},
|
TrainingLiveList: []liveapischema.TrainingLiveList{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package live
|
|||||||
import (
|
import (
|
||||||
liveapischema "honoka-chan/internal/schema/api/live"
|
liveapischema "honoka-chan/internal/schema/api/live"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -46,7 +47,7 @@ func liveStatus(ctx *gin.Context) (res any, err error) {
|
|||||||
FreeLiveStatusList: []any{},
|
FreeLiveStatusList: []any{},
|
||||||
CanResumeLive: true,
|
CanResumeLive: true,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package liveicon
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
liveiconapischema "honoka-chan/internal/schema/api/liveicon"
|
liveiconapischema "honoka-chan/internal/schema/api/liveicon"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ func liveIconInfo() (res any, err error) {
|
|||||||
Result: liveiconapischema.InfoData{
|
Result: liveiconapischema.InfoData{
|
||||||
LiveNotesIconList: []int{1, 2, 3},
|
LiveNotesIconList: []int{1, 2, 3},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package livese
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
liveseapischema "honoka-chan/internal/schema/api/livese"
|
liveseapischema "honoka-chan/internal/schema/api/livese"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ func LiveSeInfo() (res any, err error) {
|
|||||||
Result: liveseapischema.InfoData{
|
Result: liveseapischema.InfoData{
|
||||||
LiveSeList: []int{1, 2, 3},
|
LiveSeList: []int{1, 2, 3},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
usermodel "honoka-chan/internal/model/user"
|
usermodel "honoka-chan/internal/model/user"
|
||||||
loginapischema "honoka-chan/internal/schema/api/login"
|
loginapischema "honoka-chan/internal/schema/api/login"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -68,7 +69,7 @@ func loginTopInfo(ctx *gin.Context) (res any, err error) {
|
|||||||
AdFlag: false,
|
AdFlag: false,
|
||||||
HasAdReward: false,
|
HasAdReward: false,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: now.Unix(),
|
TimeStamp: now.Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package login
|
|||||||
import (
|
import (
|
||||||
loginapischema "honoka-chan/internal/schema/api/login"
|
loginapischema "honoka-chan/internal/schema/api/login"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -36,7 +37,7 @@ func loginTopInfoOnce(ctx *gin.Context) (res any, err error) {
|
|||||||
ArenaSiSkillUniqueCheck: true,
|
ArenaSiSkillUniqueCheck: true,
|
||||||
OpenV98: true,
|
OpenV98: true,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package marathon
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
marathonapischema "honoka-chan/internal/schema/api/marathon"
|
marathonapischema "honoka-chan/internal/schema/api/marathon"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func marathonInfo() (res any, err error) {
|
func marathonInfo() (res any, err error) {
|
||||||
res = marathonapischema.InfoResp{
|
res = marathonapischema.InfoResp{
|
||||||
Result: []any{},
|
Result: []any{},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package multiunit
|
|||||||
import (
|
import (
|
||||||
multiunitapischema "honoka-chan/internal/schema/api/multiunit"
|
multiunitapischema "honoka-chan/internal/schema/api/multiunit"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ func MultiUnitScenarioStatus(ctx *gin.Context) (res any, err error) {
|
|||||||
MultiUnitScenarioStatusList: multiUnitsList,
|
MultiUnitScenarioStatusList: multiUnitsList,
|
||||||
UnlockedMultiUnitScenarioIds: []any{},
|
UnlockedMultiUnitScenarioIds: []any{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package museum
|
|||||||
import (
|
import (
|
||||||
museumapischema "honoka-chan/internal/schema/api/museum"
|
museumapischema "honoka-chan/internal/schema/api/museum"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -42,7 +43,7 @@ func museumInfo(ctx *gin.Context) (res any, err error) {
|
|||||||
ContentsIDList: museumID,
|
ContentsIDList: museumID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package navigation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
navigationapischema "honoka-chan/internal/schema/api/navigation"
|
navigationapischema "honoka-chan/internal/schema/api/navigation"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ func SpecialCutin() (res any, err error) {
|
|||||||
Result: navigationapischema.SpecialCutinData{
|
Result: navigationapischema.SpecialCutinData{
|
||||||
SpecialCutinList: []any{},
|
SpecialCutinList: []any{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package notice
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
noticeapischema "honoka-chan/internal/schema/api/notice"
|
noticeapischema "honoka-chan/internal/schema/api/notice"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ func noticeMarquee() (res any, err error) {
|
|||||||
ItemCount: 0,
|
ItemCount: 0,
|
||||||
MarqueeList: []any{},
|
MarqueeList: []any{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package payment
|
|||||||
import (
|
import (
|
||||||
paymentapischema "honoka-chan/internal/schema/api/payment"
|
paymentapischema "honoka-chan/internal/schema/api/payment"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,7 +28,7 @@ func productList(ctx *gin.Context) (res any, err error) {
|
|||||||
SubscriptionList: []paymentapischema.Subscription{},
|
SubscriptionList: []paymentapischema.Subscription{},
|
||||||
ShowPointShop: false,
|
ShowPointShop: false,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package profile
|
|||||||
import (
|
import (
|
||||||
profileapischema "honoka-chan/internal/schema/api/profile"
|
profileapischema "honoka-chan/internal/schema/api/profile"
|
||||||
honokautils "honoka-chan/internal/utils"
|
honokautils "honoka-chan/internal/utils"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ func cardRanking() (res any, err error) {
|
|||||||
|
|
||||||
res = profileapischema.CardRankingResp{
|
res = profileapischema.CardRankingResp{
|
||||||
Result: cardRankingData,
|
Result: cardRankingData,
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
usermodel "honoka-chan/internal/model/user"
|
usermodel "honoka-chan/internal/model/user"
|
||||||
profileapischema "honoka-chan/internal/schema/api/profile"
|
profileapischema "honoka-chan/internal/schema/api/profile"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ func profileInfo(ctx *gin.Context, targetUserID int) (res any, err error) {
|
|||||||
SettingAwardID: targetPref.AwardID,
|
SettingAwardID: targetPref.AwardID,
|
||||||
SettingBackgroundID: targetPref.BackgroundID,
|
SettingBackgroundID: targetPref.BackgroundID,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
@@ -325,17 +326,7 @@ func getAccessoryInfo(ss *session.Session, targetUserID, unitOwningUserID int) (
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
accessoryData := struct {
|
has, accessoryData := ss.GetAccessoryByAccessoryOwningUserIDForUser(targetUserID, accessoryOwningID)
|
||||||
AccessoryID int `xorm:"accessory_id"`
|
|
||||||
Exp int `xorm:"exp"`
|
|
||||||
}{}
|
|
||||||
has, err = ss.MainEng.Table("common_accessory_m").
|
|
||||||
Where("accessory_owning_user_id = ?", accessoryOwningID).
|
|
||||||
Cols("accessory_id,exp").
|
|
||||||
Get(&accessoryData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !has {
|
if !has {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -345,9 +336,9 @@ func getAccessoryInfo(ss *session.Session, targetUserID, unitOwningUserID int) (
|
|||||||
AccessoryID: accessoryData.AccessoryID,
|
AccessoryID: accessoryData.AccessoryID,
|
||||||
Exp: accessoryData.Exp,
|
Exp: accessoryData.Exp,
|
||||||
NextExp: 0,
|
NextExp: 0,
|
||||||
Level: 8,
|
Level: accessoryData.MaxLevel,
|
||||||
MaxLevel: 8,
|
MaxLevel: accessoryData.MaxLevel,
|
||||||
RankUpCount: 4,
|
RankUpCount: accessoryData.MaxLevel - accessoryData.DefaultMaxLevel,
|
||||||
FavoriteFlag: true,
|
FavoriteFlag: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
usermodel "honoka-chan/internal/model/user"
|
usermodel "honoka-chan/internal/model/user"
|
||||||
profileapischema "honoka-chan/internal/schema/api/profile"
|
profileapischema "honoka-chan/internal/schema/api/profile"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ func liveCnt(ctx *gin.Context, targetUserID int) (res any, err error) {
|
|||||||
|
|
||||||
res = profileapischema.LiveCntResp{
|
res = profileapischema.LiveCntResp{
|
||||||
Result: result,
|
Result: result,
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package reward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
rewardapischema "honoka-chan/internal/schema/api/reward"
|
rewardapischema "honoka-chan/internal/schema/api/reward"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ func rewardHistory() (res any, err error) {
|
|||||||
Limit: 20,
|
Limit: 20,
|
||||||
History: []any{},
|
History: []any{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package reward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
rewardapischema "honoka-chan/internal/schema/api/reward"
|
rewardapischema "honoka-chan/internal/schema/api/reward"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ func rewardList() (res any, err error) {
|
|||||||
Order: 0,
|
Order: 0,
|
||||||
Items: []any{},
|
Items: []any{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package scenario
|
|||||||
import (
|
import (
|
||||||
scenarioapischema "honoka-chan/internal/schema/api/scenario"
|
scenarioapischema "honoka-chan/internal/schema/api/scenario"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -28,7 +29,7 @@ func scenarioStatus(ctx *gin.Context) (res any, err error) {
|
|||||||
Result: scenarioapischema.StatusData{
|
Result: scenarioapischema.StatusData{
|
||||||
ScenarioStatusList: scenarioLists,
|
ScenarioStatusList: scenarioLists,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package secretbox
|
|||||||
import (
|
import (
|
||||||
secretboxapischema "honoka-chan/internal/schema/api/secretbox"
|
secretboxapischema "honoka-chan/internal/schema/api/secretbox"
|
||||||
honokautils "honoka-chan/internal/utils"
|
honokautils "honoka-chan/internal/utils"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ func all() (res any, err error) {
|
|||||||
|
|
||||||
res = secretboxapischema.AllResp{
|
res = secretboxapischema.AllResp{
|
||||||
Result: secretBoxData,
|
Result: secretBoxData,
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package stamp
|
|||||||
import (
|
import (
|
||||||
stampapischema "honoka-chan/internal/schema/api/stamp"
|
stampapischema "honoka-chan/internal/schema/api/stamp"
|
||||||
honokautils "honoka-chan/internal/utils"
|
honokautils "honoka-chan/internal/utils"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ func stampInfo() (res any, err error) {
|
|||||||
|
|
||||||
res = stampapischema.InfoResp{
|
res = stampapischema.InfoResp{
|
||||||
Result: stampData,
|
Result: stampData,
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package subscenario
|
|||||||
import (
|
import (
|
||||||
subscenarioapischema "honoka-chan/internal/schema/api/subscenario"
|
subscenarioapischema "honoka-chan/internal/schema/api/subscenario"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -29,7 +30,7 @@ func subscenarioStatus(ctx *gin.Context) (res any, err error) {
|
|||||||
SubscenarioStatusList: subScenarioLists,
|
SubscenarioStatusList: subScenarioLists,
|
||||||
UnlockedSubscenarioIds: []any{},
|
UnlockedSubscenarioIds: []any{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package unit
|
|||||||
import (
|
import (
|
||||||
unitapischema "honoka-chan/internal/schema/api/unit"
|
unitapischema "honoka-chan/internal/schema/api/unit"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -12,7 +13,7 @@ func unitAccessoryAll(ctx *gin.Context) (res any, err error) {
|
|||||||
ss := session.Get(ctx)
|
ss := session.Get(ctx)
|
||||||
|
|
||||||
accessoryList := []unitapischema.AccessoryList{}
|
accessoryList := []unitapischema.AccessoryList{}
|
||||||
err = ss.MainEng.Table("common_accessory_m").Find(&accessoryList)
|
err = ss.UserEng.Table("user_accessory").Where("user_id = ?", ss.UserID).Find(&accessoryList)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,7 @@ func unitAccessoryAll(ctx *gin.Context) (res any, err error) {
|
|||||||
WearingInfo: wearingInfo,
|
WearingInfo: wearingInfo,
|
||||||
EspecialCreateFlag: false,
|
EspecialCreateFlag: false,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package unit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
unitapischema "honoka-chan/internal/schema/api/unit"
|
unitapischema "honoka-chan/internal/schema/api/unit"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func unitAccessoryMaterialAll() (res any, err error) {
|
func unitAccessoryMaterialAll() (res any, err error) {
|
||||||
res = unitapischema.AccessoryMaterialAllResp{
|
res = unitapischema.AccessoryMaterialAllResp{
|
||||||
Result: unitapischema.AccessoryMaterialAllData{},
|
Result: unitapischema.AccessoryMaterialAllData{},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package unit
|
|||||||
import (
|
import (
|
||||||
unitapischema "honoka-chan/internal/schema/api/unit"
|
unitapischema "honoka-chan/internal/schema/api/unit"
|
||||||
honokautils "honoka-chan/internal/utils"
|
honokautils "honoka-chan/internal/utils"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ func unitAccessoryTab() (res any, err error) {
|
|||||||
Result: unitapischema.AccessoryTabData{
|
Result: unitapischema.AccessoryTabData{
|
||||||
TabList: tabList,
|
TabList: tabList,
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
unitmodel "honoka-chan/internal/model/unit"
|
unitmodel "honoka-chan/internal/model/unit"
|
||||||
unitapischema "honoka-chan/internal/schema/api/unit"
|
unitapischema "honoka-chan/internal/schema/api/unit"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -55,7 +56,7 @@ func unitAll(ctx *gin.Context) (res any, err error) {
|
|||||||
Active: unitsData,
|
Active: unitsData,
|
||||||
Waiting: []unitapischema.Waiting{},
|
Waiting: []unitapischema.Waiting{},
|
||||||
},
|
},
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package unit
|
|||||||
import (
|
import (
|
||||||
unitapischema "honoka-chan/internal/schema/api/unit"
|
unitapischema "honoka-chan/internal/schema/api/unit"
|
||||||
"honoka-chan/internal/session"
|
"honoka-chan/internal/session"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -46,7 +47,7 @@ func unitDeckInfo(ctx *gin.Context) (res any, err error) {
|
|||||||
}
|
}
|
||||||
res = unitapischema.DeckInfoResp{
|
res = unitapischema.DeckInfoResp{
|
||||||
Result: unitDeckInfo,
|
Result: unitDeckInfo,
|
||||||
Status: 200,
|
Status: http.StatusOK,
|
||||||
CommandNum: false,
|
CommandNum: false,
|
||||||
TimeStamp: time.Now().Unix(),
|
TimeStamp: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|||||||