From 91bbc7f6071c78f415562c45015bdd87279665a3 Mon Sep 17 00:00:00 2001 From: Sean Du Date: Fri, 12 Jun 2026 01:45:44 +0800 Subject: [PATCH] Add Android control app and embedded server Inspired by HNKServer/honoka-chan-apk-server, but implemented as a JNI-based embedded runtime instead of the original approach. - Add an Android Compose controller app that starts and stops honoka-chan through JNI, shows runtime and health status, manages data directory mounting, and supports backup import/export for data.db - Add an embeddable Go server entrypoint plus JNI-exported status, health, and reload hooks so desktop and Android builds share the same startup and shutdown path - Add Android build scripts, runtime packaging, and project documentation, including generated default config.json content for honoka_runtime.zip - Add runtime bundle hash tracking so updated honoka_runtime.zip assets are automatically redeployed while preserving config.json and user data files - Add in-app service settings for unlock_all_special_rotation with immediate config persistence and automatic reload when the service is running - Split SQLite driver selection by platform, using go-sqlite3 on Android and modernc.org/sqlite elsewhere to avoid Android x86_64 seccomp crashes - Update startup, database initialization, and system health/reload handlers to support the embedded runtime and Android control flow Signed-off-by: Sean Du --- android/.gitignore | 12 + android/README.md | 246 ++++++++++ android/app/build.gradle.kts | 93 ++++ android/app/proguard-rules.pro | 1 + android/app/src/main/AndroidManifest.xml | 41 ++ android/app/src/main/assets/.gitkeep | 0 android/app/src/main/cpp/CMakeLists.txt | 18 + android/app/src/main/cpp/honoka_android.cpp | 133 ++++++ .../me/killkiss/honokactrl/MainActivity.kt | 340 ++++++++++++++ .../killkiss/honokactrl/jni/NativeBridge.kt | 137 ++++++ .../honokactrl/service/HonokaServerService.kt | 168 +++++++ .../honokactrl/storage/RuntimePreparer.kt | 164 +++++++ .../honokactrl/storage/SettingsStore.kt | 19 + .../me/killkiss/honokactrl/theme/Theme.kt | 46 ++ .../me/killkiss/honokactrl/ui/MainScreen.kt | 275 +++++++++++ .../killkiss/honokactrl/ui/MainViewModel.kt | 390 ++++++++++++++++ .../res/drawable/ic_launcher_background.xml | 9 + .../res/drawable/ic_launcher_monochrome.png | Bin 0 -> 4988 bytes .../res/drawable/ic_notification_server.png | Bin 0 -> 1822 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2835 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 1915 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2835 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2000 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1376 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2000 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3682 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 2499 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 3682 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 5157 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 3746 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 5157 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 6864 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 4988 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 6864 bytes android/app/src/main/res/values/strings.xml | 3 + android/app/src/main/res/values/themes.xml | 7 + .../main/res/xml/network_security_config.xml | 4 + android/build.gradle.kts | 5 + android/gradle.properties | 4 + android/scripts/build_all.sh | 19 + android/scripts/build_go_android.sh | 61 +++ android/scripts/check_env.sh | 85 ++++ android/scripts/common.sh | 27 ++ android/scripts/prepare_runtime_zip.sh | 58 +++ android/settings.gradle.kts | 18 + cmd/honoka-android/main.go | 68 +++ config/config.go | 10 +- docs/04-testing.md | 4 + go.mod | 1 + go.sum | 4 +- internal/app/server.go | 166 +++++++ internal/handler/handler.go | 1 + internal/handler/system/system.go | 139 ++++++ internal/schema/system/system.go | 21 + internal/startup/database.go | 440 ++++++++++-------- internal/startup/defaultuser.go | 10 +- internal/startup/friend.go | 13 +- internal/startup/startup.go | 19 +- main.go | 51 +- pkg/db/db.go | 108 +++-- pkg/db/sqlite_driver_android.go | 39 ++ pkg/db/sqlite_driver_default.go | 29 ++ 64 files changed, 3230 insertions(+), 286 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/assets/.gitkeep create mode 100644 android/app/src/main/cpp/CMakeLists.txt create mode 100644 android/app/src/main/cpp/honoka_android.cpp create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/MainActivity.kt create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/jni/NativeBridge.kt create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/service/HonokaServerService.kt create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/storage/RuntimePreparer.kt create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/storage/SettingsStore.kt create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/theme/Theme.kt create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/ui/MainScreen.kt create mode 100644 android/app/src/main/java/me/killkiss/honokactrl/ui/MainViewModel.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_monochrome.png create mode 100644 android/app/src/main/res/drawable/ic_notification_server.png create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100755 android/scripts/build_all.sh create mode 100755 android/scripts/build_go_android.sh create mode 100755 android/scripts/check_env.sh create mode 100755 android/scripts/common.sh create mode 100755 android/scripts/prepare_runtime_zip.sh create mode 100644 android/settings.gradle.kts create mode 100644 cmd/honoka-android/main.go create mode 100644 internal/app/server.go create mode 100644 internal/handler/system/system.go create mode 100644 internal/schema/system/system.go create mode 100644 pkg/db/sqlite_driver_android.go create mode 100644 pkg/db/sqlite_driver_default.go diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..07f4531 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,12 @@ +.gradle/ +.idea/ +.kotlin/ +app/.cxx/ +app/release/ +app/src/main/assets/ +app/src/main/jniLibs/ +build/ +gradle/ +gradlew +gradlew.bat +local.properties diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..41f63be --- /dev/null +++ b/android/README.md @@ -0,0 +1,246 @@ +# 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//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` 并重新安装或覆盖资源包 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..f5d739c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,93 @@ +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") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f10fd8a --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# No custom ProGuard rules yet. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4ae8a16 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/.gitkeep b/android/app/src/main/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..80f7721 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,18 @@ +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} +) diff --git a/android/app/src/main/cpp/honoka_android.cpp b/android/app/src/main/cpp/honoka_android.cpp new file mode 100644 index 0000000..9a7265d --- /dev/null +++ b/android/app/src/main/cpp/honoka_android.cpp @@ -0,0 +1,133 @@ +#include +#include +#include +#include + +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(dlsym(g_handle, "ServerStart")); + g_stop = reinterpret_cast(dlsym(g_handle, "ServerStop")); + g_status = reinterpret_cast(dlsym(g_handle, "ServerStatusJSON")); + g_health = reinterpret_cast(dlsym(g_handle, "ServerHealthJSON")); + g_reload = reinterpret_cast(dlsym(g_handle, "ServerReload")); + g_free_string = reinterpret_cast(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()); +} diff --git a/android/app/src/main/java/me/killkiss/honokactrl/MainActivity.kt b/android/app/src/main/java/me/killkiss/honokactrl/MainActivity.kt new file mode 100644 index 0000000..1614eef --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/MainActivity.kt @@ -0,0 +1,340 @@ +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() + 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() + } +} diff --git a/android/app/src/main/java/me/killkiss/honokactrl/jni/NativeBridge.kt b/android/app/src/main/java/me/killkiss/honokactrl/jni/NativeBridge.kt new file mode 100644 index 0000000..42560a7 --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/jni/NativeBridge.kt @@ -0,0 +1,137 @@ +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 { + val error = ensureLoaded() ?: nativeStart(workDir) + return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error)) + } + + fun stop(): Result { + 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 +} diff --git a/android/app/src/main/java/me/killkiss/honokactrl/service/HonokaServerService.kt b/android/app/src/main/java/me/killkiss/honokactrl/service/HonokaServerService.kt new file mode 100644 index 0000000..ccf3c1a --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/service/HonokaServerService.kt @@ -0,0 +1,168 @@ +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") + } +} diff --git a/android/app/src/main/java/me/killkiss/honokactrl/storage/RuntimePreparer.kt b/android/app/src/main/java/me/killkiss/honokactrl/storage/RuntimePreparer.kt new file mode 100644 index 0000000..47227b8 --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/storage/RuntimePreparer.kt @@ -0,0 +1,164 @@ +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 { + 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, +) diff --git a/android/app/src/main/java/me/killkiss/honokactrl/storage/SettingsStore.kt b/android/app/src/main/java/me/killkiss/honokactrl/storage/SettingsStore.kt new file mode 100644 index 0000000..2a03973 --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/storage/SettingsStore.kt @@ -0,0 +1,19 @@ +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" + } +} diff --git a/android/app/src/main/java/me/killkiss/honokactrl/theme/Theme.kt b/android/app/src/main/java/me/killkiss/honokactrl/theme/Theme.kt new file mode 100644 index 0000000..1292e19 --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/theme/Theme.kt @@ -0,0 +1,46 @@ +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, + ) +} diff --git a/android/app/src/main/java/me/killkiss/honokactrl/ui/MainScreen.kt b/android/app/src/main/java/me/killkiss/honokactrl/ui/MainScreen.kt new file mode 100644 index 0000000..5233611 --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/ui/MainScreen.kt @@ -0,0 +1,275 @@ +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) +} diff --git a/android/app/src/main/java/me/killkiss/honokactrl/ui/MainViewModel.kt b/android/app/src/main/java/me/killkiss/honokactrl/ui/MainViewModel.kt new file mode 100644 index 0000000..d9476dc --- /dev/null +++ b/android/app/src/main/java/me/killkiss/honokactrl/ui/MainViewModel.kt @@ -0,0 +1,390 @@ +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 = _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() + 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() + 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().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().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 = "") + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..5570ebe --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.png b/android/app/src/main/res/drawable/ic_launcher_monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..8e803d547822a686bc0a90c013717a8f11980b8f GIT binary patch literal 4988 zcmd6rcTiK?x5q<~&;v*jkRl?20tS>s`jsjrRDpn$2m%5jh=dZl(gYL~q+dirFM&`3 zlF*cP6_FZBXx9RSu9$%I7k~5S&AdO}|L@G4z0X=_eb@f1Q)bWJ$yOG|Je*>j004l; z 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ebddc0e --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..ebddc0e --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..185a8f1da17f9f7ca3c3c35b3a01adf9b279d464 GIT binary patch literal 2835 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 HV;}P$=)pTa literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..5f17a388f1518999a5c027e1c3447567b4a87e70 GIT binary patch literal 1915 zcmaKtcQo4z8^?dmYu%zr(4uCF&{~PciXE{_Xze|tNYq|!sU6#G6<3W4EpLn3HKN>C z%&M)`C~YZf#JW}~-t?dMocE7=zvn#9_ZjDNocH zV&vP$bJ+|N^)S3?3II`X0CG1tMc45u#@s;^`LRh4u*YVi=%+P*9RZD9IufED@^c z>&j@9vNQsLMj)KGi=X`;K)mDWgOB`sK)wpj&j6(UcOk@w=oK8|Mhy6SCZd(Km%(uQ zSlWgV+}yngj0OM}V%tHy!*T$?qHl!Nv5cV6Ke-TYSqZXkZLYWql?jCq7Dld_Ne-a6 zM4{t-ytC24kgPWLpQLBL&kOUKPMr9T`7q@W zIvo1-Q94y(M#3lW(nlEV!^klB7Ra_&#?*l?kUCbAbmCHoMT+cI8fZ9M>zzok|wFO36=wR%L)W(Y!b2>J^e`s+4;r<|5ur$roxMuGnA`PZ|v+wJQ>FUou^d z4{(s<^46q%>%FYCof9aQq|8S4-tden6C`7T5P)yO1)0ifnC=x1~1JtP3)A%VzVfWr9U2R|3R%E~ysWRN&_4B-}Cs zt}~mVJv-U~Tj}NZ*KLTvYQk&@8z%k=fqPLdsW9#EfyAq7^&?(oC*CJT`xpl z`KJ5w*HVqzQhE{r=`p&2XRQ;Qc{Qw)XbTUVe0q7RQipR1mT6D5$OP971SQsrxK}st zkyppV^PD-p2p)~*wbAuNo&wH7OmI%yEoX=G^q`M=*R$ty!7!Ev10?Xey*I$Vf4z|wESfWu*rE+8bls+ z8_N`2YoHWWT%k!_^Eai4TTs}5{M725lmv};w?y2j6YBSt#IXe9_I@9YedEW;k*fzO z7U1BmqBNW3AVPlxfuip)SjS^x5zluGDRk08ik6uOtF9HYl#|0aW~uULJ+XSPlQl#| zSM|)tm3&qdBu9HA_eYnW3?*}QJ4C!+FiD(%R6pD-hrISokF@1t&KN|AmJASU)|$BI zGeLuAvL09u=()_xxp2F$ZTK_YpY;#MKbx(cQ8=>1s8H5Loe!bM0`nEjJomj{9MBa| z8*B!Iyw-)tIqp7wT^g7Nroka+=tuh;qvRkgo9;zUGOg1)h>ngo?{Sm z^HaOkD(k#?H1%b&GS07py87f*_T~@YT8_^CYcKbhb({wq#+>->f0i*u zH)_21eD_y0?#W&)h5HkpC`t9+$vGsxSaziEzC!)wNRK?57SF}ure_PeBcI2Vv?&{? zIfV~)3Oj4Wl~7mpZu;x~goWZ_kV`jA9gxWr?2_9vm_KrZuOBN{FVE71Wfhh>I@G^K zdR!V%d0LWCu4dpfGk)|=dH07SzI@MK%&E7v>2gJ3&Pv$J#{0i}gOCf28a@YVh^pD9 zs_sYQ2}+K=Aer%KTlBm_uB-I>+Z7$pPa7PcIn_(|736OxDt@$?OhT{n0fuEu84J$G zRNw` z`{TPc+*@MQ_H?h#71-82S~-fS`8Y`QW3;`yb1|JsVq!71bgQXgPA!cia7fKFMSO&f zD79{977;XQe%Mj)W(=fd-oG1*H*@=8f5AyWYnes6ncvu@imsujxx@FhkIWI=HV{fb zO>Fto{3q7}>0(f27og_Vw00EtTiu_Ku6jX_9Ci52B4!wfd29n8e5()X{1$m-jlDf@ zEZ%BWyEt&M1)NsL#z`s#S#Zn?9tB)V9*c`#B{dAyGePI|QQ|RI#ecZ;Z)!oH|DZ9& Z%@_;)yp^i#o{V45#YoQ#TZ3_p`!B*2VI}|o literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..185a8f1da17f9f7ca3c3c35b3a01adf9b279d464 GIT binary patch literal 2835 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 HV;}P$=)pTa literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a3e3b11bb3d5cd027ac63200148d6f40e5d0dce3 GIT binary patch literal 2000 zcmZ{ldo$!#-gEMXZoqN$b3WP}*Gl-nrR`AQMFMo4a1#bWNa z>RaZL%f3-lX{LM|VTSbmxg@{+&iS72Ip1@B-`6?s*W;Ym>-{{h_xq3c`+0-tXsZB% zf&c(eAmDMQ#YFyZK_taFb&-`L2Fx3O+5rG!-~f=23;^rmQo;-XM2I)edIA8Z008!d z7PmW@i5oH(>}_#kiet^HBVG*IaDs!i?5vEuoEBm^_?IAYNh{piCEUj=+!y10#a9df zg+v|IM;_HTM3IoD7$Y>s7_Ey$VvtCWrF&KX5CnzzP%lRRy8uxGR~8Gj|DF&|4e|{O z_X-OAcMYvgwnu#OIsy($@}CeEUL`4>Rq2g4>d+ftF~R@TRX;aE#Y2&w;rx^W1J5`Msf_y-z06K)~4{cl^px17IUOY0~p%+RC ze?cC(GOy7LmHgVNX+=o{Nk#fJ8~0|(-S>e2{BfaYg9!()W=K z-*XohvU#`@^eq|oQ)hyT&-0P{Lc7Et!!^`#)}2##Ol@|>MHx&!%uNfv4ymnLwzvH* zcPbI)S5^UR?0MW{_MC^ZD0MkDIOC8}%`KC}Wa)*srdG`lIcR7793PS`Y|$48iv&Ul z;35q~0h4;qj|=M*J?CDGI-b|H|1{#>rT?SuxJ>lDHZvB$FDcD14&L9Oi-9GI`Z9)^f_B zcB@vf*ZNwVW(PadjCu^K)B(Pj)|ZO3z1)sj*Q&4ZRm1okd7m9`e-&MlzCb9k98Hjj zq~smZcm3Qsh`QrfQySg7^(r`Te5zgD^(p=1%gVIqK>gk8Xy+0tA75p04_*%*pf_GW zX}7p6k)TGBm_!iByVuM>Se@K}XuWLPn8SYs+se7$KjolGbz*I%@3A5Y=*U2k_GS)SJvV(*y11 zlzikB|WF5zpafP*l1(=H7{kVrbsuGp!DXRf!fxB zT+d#e3%h0aK}q~(@4wl51#s!4FIKF^>5Y`R!n&$cY>=gBRTf7`;7^3`eM-H8Umyo6 zR_Y#Up47cfwoQ4Oq;n}^1n#uZ!f6aSsJ{4Scl@E1Udc~}FYb!3w`ft4cJbnLfnm9v zfpfRHQqx1u_?d&({qyqOyv-Ht6wk#mGU5pHtL&7*jFCj$F7H?C#Vt$$?c;>{{+}k& z^;~w{_-!dBw{7h3v}bYUe>k-@JYPlPD5K@I-1`V;O5FF11Xl}uG;5Cev{NZniUI)J z>A{r8GdCDtV>OhOb@Fv(l{&MIvA%k8KB!iPAa4*CUvf;9YgK}ZZJzapIHZkp(WFS; z+dIy@Z;$0g(`|`cgt#7+QxBt@esp>bdw5hA)1&_wg)y@G=y=5>63@cHQHFBdFIC-? zZR>~b56L0#;j@?hvoG=QKq=371DWUn&6{EHrd>OpeQp)f1LvU`=tGFhu1Ejuo5UxU zJA+Rg!2ddkDCOGDvB;Z!IgczBmiynw$@1V!qQGlYGDFw^(I@8YyZ(bj9UB;*t zWh>=r4)5Pu3Tu$=X`=h3=curl-v*M3w317{$Hoj+J#c&Gy1_KRt0Q`wG|M8VnB#v( zJo#Ac3AGgIMsla`<_dfj!1>Hv&ECwLpQjbVmSf5;_o0)Ha3i?gcWz?rc76CW-T%}G z8ltfsMcNe&Y1*6jYan~RnGH{3IuLsy_qfqQ9$bIPpo^dTK-vCWrHx|Ho0%AowGGo( z8dD6g=2kk&QQwH$J)1W1E-R|7_JH5U4U(|)9-`7rM~_pvr*}5_!5Og+@>SZbK*o{h zpn`HZz}SYsn6GA(6YtP!pBkpD>ilL>?|u;cbW$~-`u>;{reejJsL-{G_^~DC(E_n- zrW)LSaNv5(+u?Qh3KnQ~dcXSjKyyhwM8WGPU^Ud?#OaoOQ9>;Bu6(SiPUZE`{moO< nohw`Pjj7xJ{QCc|C;U*LW1gq4t7y^0eg_b&9dXPPo=N`&Qt^~H literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..67d3017be6b1d35a5cfbc147c896eabc66d793f0 GIT binary patch literal 1376 zcmeAS@N?(olHy`uVBq!ia0vp^IUvlz0wh)Q=eq$Zg=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(G&@^*J&U|7|wYy{-7mw5WRvcF>E;Z)WCl*g9~ z6jCj5jVMV;EJ?LWE=mPb3`Pb9 znO2EggZ>&dNuUM|xD6$lxv9k^iMa*1^;GO+KL*spn;8;Ol9QN}3i3Py!{f%?OycMC z7#LWzJzX3_DsH`<>6;;wDsj9%|BP9t@NV5+x68ghR~G5Iu3b9G%W*@;rRGPjXST}i z(ex7CTXCuT{)O2cdpQ;<=yJvNWV8xo>*oH+P`s#hBd^zUR=@A8YfFBApKvzq`PsDc z$7ifo+w50VIsg02?`J>Xp4;>J9FIg>B04f$wB*)RiE`1?EBS2?CGTOLm8Po5?Cp-`AU-h0Z#^c!lnr(lhZl=YK!@xqfZzwyyO?56^8ZO1kOAzRGmt zwEwFkp0+=#kMdcvx2ETQ#Ez576&igz_y3z1F-=o#MoLrSr8(&ebsMvpFRqwhw%2Qd zf}?HcfijDN#QKwag<8F)BpuYO;VBVM=gv31YxDWcue&Ca0@31o!)_Go{8&)JWZ^O2 z>eRR0F^?XvXgYNyF@Nd*i^OD|!1a_f;IdF+?nByM22iZ>-V(i zQ$4p&1#T7WDo7IV+M;XM#o4zfJM-A(w>A-FPm=Xw%jRo!iT-{b|Ez94_m;fJ%5N`N zExqFWe$&@;g8K8GuPlgCyf{s1d0o2R_SKI(>sU$@J_Niw8?n{ldd4;FO%JTEB(x^U zGiR)KEB?6QQM~P|CE-uhS$EFkIx2Va?f%f`DxSX|Z)5thFZ@Y%Qg&{_iVC7r#ld)?Jg{r`cxdDc^p#Wa}1%FS_&O6b`x`+j*kmHXTE62#Nh&t;ucLK6VL!*8|# literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..a3e3b11bb3d5cd027ac63200148d6f40e5d0dce3 GIT binary patch literal 2000 zcmZ{ldo$!#-gEMXZoqN$b3WP}*Gl-nrR`AQMFMo4a1#bWNa z>RaZL%f3-lX{LM|VTSbmxg@{+&iS72Ip1@B-`6?s*W;Ym>-{{h_xq3c`+0-tXsZB% zf&c(eAmDMQ#YFyZK_taFb&-`L2Fx3O+5rG!-~f=23;^rmQo;-XM2I)edIA8Z008!d z7PmW@i5oH(>}_#kiet^HBVG*IaDs!i?5vEuoEBm^_?IAYNh{piCEUj=+!y10#a9df zg+v|IM;_HTM3IoD7$Y>s7_Ey$VvtCWrF&KX5CnzzP%lRRy8uxGR~8Gj|DF&|4e|{O z_X-OAcMYvgwnu#OIsy($@}CeEUL`4>Rq2g4>d+ftF~R@TRX;aE#Y2&w;rx^W1J5`Msf_y-z06K)~4{cl^px17IUOY0~p%+RC ze?cC(GOy7LmHgVNX+=o{Nk#fJ8~0|(-S>e2{BfaYg9!()W=K z-*XohvU#`@^eq|oQ)hyT&-0P{Lc7Et!!^`#)}2##Ol@|>MHx&!%uNfv4ymnLwzvH* zcPbI)S5^UR?0MW{_MC^ZD0MkDIOC8}%`KC}Wa)*srdG`lIcR7793PS`Y|$48iv&Ul z;35q~0h4;qj|=M*J?CDGI-b|H|1{#>rT?SuxJ>lDHZvB$FDcD14&L9Oi-9GI`Z9)^f_B zcB@vf*ZNwVW(PadjCu^K)B(Pj)|ZO3z1)sj*Q&4ZRm1okd7m9`e-&MlzCb9k98Hjj zq~smZcm3Qsh`QrfQySg7^(r`Te5zgD^(p=1%gVIqK>gk8Xy+0tA75p04_*%*pf_GW zX}7p6k)TGBm_!iByVuM>Se@K}XuWLPn8SYs+se7$KjolGbz*I%@3A5Y=*U2k_GS)SJvV(*y11 zlzikB|WF5zpafP*l1(=H7{kVrbsuGp!DXRf!fxB zT+d#e3%h0aK}q~(@4wl51#s!4FIKF^>5Y`R!n&$cY>=gBRTf7`;7^3`eM-H8Umyo6 zR_Y#Up47cfwoQ4Oq;n}^1n#uZ!f6aSsJ{4Scl@E1Udc~}FYb!3w`ft4cJbnLfnm9v zfpfRHQqx1u_?d&({qyqOyv-Ht6wk#mGU5pHtL&7*jFCj$F7H?C#Vt$$?c;>{{+}k& z^;~w{_-!dBw{7h3v}bYUe>k-@JYPlPD5K@I-1`V;O5FF11Xl}uG;5Cev{NZniUI)J z>A{r8GdCDtV>OhOb@Fv(l{&MIvA%k8KB!iPAa4*CUvf;9YgK}ZZJzapIHZkp(WFS; z+dIy@Z;$0g(`|`cgt#7+QxBt@esp>bdw5hA)1&_wg)y@G=y=5>63@cHQHFBdFIC-? zZR>~b56L0#;j@?hvoG=QKq=371DWUn&6{EHrd>OpeQp)f1LvU`=tGFhu1Ejuo5UxU zJA+Rg!2ddkDCOGDvB;Z!IgczBmiynw$@1V!qQGlYGDFw^(I@8YyZ(bj9UB;*t zWh>=r4)5Pu3Tu$=X`=h3=curl-v*M3w317{$Hoj+J#c&Gy1_KRt0Q`wG|M8VnB#v( zJo#Ac3AGgIMsla`<_dfj!1>Hv&ECwLpQjbVmSf5;_o0)Ha3i?gcWz?rc76CW-T%}G z8ltfsMcNe&Y1*6jYan~RnGH{3IuLsy_qfqQ9$bIPpo^dTK-vCWrHx|Ho0%AowGGo( z8dD6g=2kk&QQwH$J)1W1E-R|7_JH5U4U(|)9-`7rM~_pvr*}5_!5Og+@>SZbK*o{h zpn`HZz}SYsn6GA(6YtP!pBkpD>ilL>?|u;cbW$~-`u>;{reejJsL-{G_^~DC(E_n- zrW)LSaNv5(+u?Qh3KnQ~dcXSjKyyhwM8WGPU^Ud?#OaoOQ9>;Bu6(SiPUZE`{moO< nohw`Pjj7xJ{QCc|C;U*LW1gq4t7y^0eg_b&9dXPPo=N`&Qt^~H literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..33475910af5118eda9ae566b55ff87050007fb5f GIT binary patch literal 3682 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 Il2!PB04=VxH2?qr literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..8d2a7d2bb861496fb0ed85333dea68e8e0bfdd00 GIT binary patch literal 3746 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 Mxt&>!iD&#j0YH-cJ^%m! literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..91e42751f41c0a7903bcd00b4ddf28244d201e6e GIT binary patch literal 5157 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 Il2!PB04=VxH2?qr literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a4f4635a43db60884c28652bdc748659ff087015 GIT binary patch literal 6864 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..8e803d547822a686bc0a90c013717a8f11980b8f GIT binary patch literal 4988 zcmd6rcTiK?x5q<~&;v*jkRl?20tS>s`jsjrRDpn$2m%5jh=dZl(gYL~q+dirFM&`3 zlF*cP6_FZBXx9RSu9$%I7k~5S&AdO}|L@G4z0X=_eb@f1Q)bWJ$yOG|Je*>j004l; z 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d31fc48 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + honoka-chan + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..8c78147 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..2439f15 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..2155cf7 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +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 +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android/scripts/build_all.sh b/android/scripts/build_all.sh new file mode 100755 index 0000000..d0d97c8 --- /dev/null +++ b/android/scripts/build_all.sh @@ -0,0 +1,19 @@ +#!/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" diff --git a/android/scripts/build_go_android.sh b/android/scripts/build_go_android.sh new file mode 100755 index 0000000..84e9e00 --- /dev/null +++ b/android/scripts/build_go_android.sh @@ -0,0 +1,61 @@ +#!/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" diff --git a/android/scripts/check_env.sh b/android/scripts/check_env.sh new file mode 100755 index 0000000..aa3a7eb --- /dev/null +++ b/android/scripts/check_env.sh @@ -0,0 +1,85 @@ +#!/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" diff --git a/android/scripts/common.sh b/android/scripts/common.sh new file mode 100755 index 0000000..8cb3596 --- /dev/null +++ b/android/scripts/common.sh @@ -0,0 +1,27 @@ +#!/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" +} diff --git a/android/scripts/prepare_runtime_zip.sh b/android/scripts/prepare_runtime_zip.sh new file mode 100755 index 0000000..9d9aacf --- /dev/null +++ b/android/scripts/prepare_runtime_zip.sh @@ -0,0 +1,58 @@ +#!/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" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..189a938 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "honoka-android" +include(":app") diff --git a/cmd/honoka-android/main.go b/cmd/honoka-android/main.go new file mode 100644 index 0000000..74f4817 --- /dev/null +++ b/cmd/honoka-android/main.go @@ -0,0 +1,68 @@ +package main + +/* +#include +*/ +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() {} diff --git a/config/config.go b/config/config.go index 4985b59..1741129 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ var ( Conf = &AppConfigs{} PackageVersion = "97.4.6" + ConfigPath = "./config.json" PrivateKeyPath = "assets/certs/privatekey.pem" PublicKeyPath = "assets/certs/publickey.pem" @@ -25,11 +26,17 @@ type AppConfigs struct { type Settings struct { ListenPort string `json:"listen_port"` CdnServer string `json:"cdn_server"` + ReloadToken string `json:"reload_token"` UnlockAllSpecialRotation bool `json:"unlock_all_special_rotation"` } func InitConfig() { - Conf = Load("./config.json") + ReloadConfig() +} + +func ReloadConfig() *AppConfigs { + Conf = Load(ConfigPath) + return Conf } func DefaultConfigs() *AppConfigs { @@ -38,6 +45,7 @@ func DefaultConfigs() *AppConfigs { Settings: Settings{ ListenPort: "8080", CdnServer: "http://127.0.0.1:8080/static", + ReloadToken: "", UnlockAllSpecialRotation: false, }, } diff --git a/docs/04-testing.md b/docs/04-testing.md index 03a8180..3d4e4a8 100644 --- a/docs/04-testing.md +++ b/docs/04-testing.md @@ -7,6 +7,7 @@ 第一次启动 `honoka-chan` 后会生成 `config.json`。需要确认: - `settings.cdn_server` 指向你的数据下载地址 +- `settings.reload_token` 可选;如果需要通过接口热重载 `config.json`,需要先设置一个 token - `settings.unlock_all_special_rotation` 可选;设为 `true` 后会忽略日替时间限制,直接解锁全部日替特殊歌曲,不包含周替 `MASTER` - 如果数据直接放在本项目的 `static` 目录下,通常可以配置成类似 `http://192.168.1.123/static` @@ -25,6 +26,9 @@ ## 补充说明 +- `GET /system/health` 可用于检查应用和数据库是否正常运行 +- `POST /system/reload?token=你的token` 可在修改 `config.json` 后重新加载配置 + SIF 的“全量数据下载”实际上并不是真正的全量。部分剧情资源仍然需要通过 `/download/getUrl` 接口按路径拉取。 所以除了 `archives` 目录里的压缩包,还需要另外维护一份: diff --git a/go.mod b/go.mod index e2012aa..8557bb1 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require github.com/gin-gonic/gin v1.12.0 require ( github.com/gin-contrib/sessions v1.1.0 github.com/go-think/openssl v1.20.0 + github.com/mattn/go-sqlite3 v1.14.45 github.com/tidwall/gjson v1.19.0 modernc.org/sqlite v1.51.0 xorm.io/builder v0.3.13 diff --git a/go.sum b/go.sum index 50a7a63..830c123 100644 --- a/go.sum +++ b/go.sum @@ -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/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-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..8b07b49 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,166 @@ +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 +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index f703198..8b8c2b4 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -30,6 +30,7 @@ import ( _ "honoka-chan/internal/handler/scenario" _ "honoka-chan/internal/handler/secretbox" _ "honoka-chan/internal/handler/subscenario" + _ "honoka-chan/internal/handler/system" _ "honoka-chan/internal/handler/tos" _ "honoka-chan/internal/handler/unit" _ "honoka-chan/internal/handler/user" diff --git a/internal/handler/system/system.go b/internal/handler/system/system.go new file mode 100644 index 0000000..b886391 --- /dev/null +++ b/internal/handler/system/system.go @@ -0,0 +1,139 @@ +package system + +import ( + "encoding/json" + "errors" + "honoka-chan/config" + "honoka-chan/internal/router" + systemschema "honoka-chan/internal/schema/system" + "honoka-chan/pkg/db" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +var ( + startedAt = time.Now() + lastReloadAt atomic.Int64 +) + +const dateTimeFormat = "2006-01-02 15:04:05" + +func MarkStarted(t time.Time) { + startedAt = t +} + +func Health() (systemschema.HealthResp, int) { + appName := config.Conf.AppName + if appName == "" { + appName = "honoka-chan" + } + + mainDBStatus := "not initialized" + if db.MainEng != nil { + mainDBStatus = "ok" + if err := db.MainEng.Ping(); err != nil { + mainDBStatus = err.Error() + } + } + + userDBStatus := "not initialized" + if db.UserEng != nil { + userDBStatus = "ok" + if err := db.UserEng.Ping(); err != nil { + userDBStatus = err.Error() + } + } + + status := "ok" + statusCode := http.StatusOK + if db.MainEng == nil || db.UserEng == nil { + status = "stopped" + } + + resp := systemschema.HealthResp{ + Status: status, + AppName: appName, + Version: config.PackageVersion, + StartedAt: startedAt.Format(dateTimeFormat), + UptimeSeconds: int64(time.Since(startedAt).Seconds()), + ListenPort: config.Conf.Settings.ListenPort, + CdnServer: config.Conf.Settings.CdnServer, + ReloadTokenConfigured: strings.TrimSpace(config.Conf.Settings.ReloadToken) != "", + MainDB: mainDBStatus, + UserDB: userDBStatus, + } + + if ts := lastReloadAt.Load(); ts > 0 { + resp.LastReloadAt = time.Unix(ts, 0).Format(dateTimeFormat) + } + + if status == "ok" && (mainDBStatus != "ok" || userDBStatus != "ok") { + resp.Status = "degraded" + statusCode = http.StatusServiceUnavailable + } + + return resp, statusCode +} + +func HealthJSON() string { + resp, _ := Health() + data, err := json.Marshal(resp) + if err != nil { + return `{"status":"error","app_name":"honoka-chan"}` + } + return string(data) +} + +func Reload(token string, requireToken bool) (systemschema.ReloadResp, int, error) { + expectedToken := strings.TrimSpace(config.Conf.Settings.ReloadToken) + if requireToken && expectedToken == "" { + return systemschema.ReloadResp{ + Status: "error", + Message: "reload_token is not configured", + }, http.StatusServiceUnavailable, errors.New("reload_token is not configured") + } + + if requireToken && strings.TrimSpace(token) != expectedToken { + return systemschema.ReloadResp{ + Status: "error", + Message: "invalid reload token", + }, http.StatusUnauthorized, errors.New("invalid reload token") + } + + config.ReloadConfig() + now := time.Now() + lastReloadAt.Store(now.Unix()) + + return systemschema.ReloadResp{ + Status: "ok", + Message: "configuration reloaded", + ReloadedAt: now.Format(dateTimeFormat), + }, http.StatusOK, nil +} + +func health(ctx *gin.Context) { + resp, statusCode := Health() + ctx.JSON(statusCode, resp) +} + +func reload(ctx *gin.Context) { + token := strings.TrimSpace(ctx.GetHeader("X-Reload-Token")) + if token == "" { + token = strings.TrimSpace(ctx.Query("token")) + } + if token == "" { + token = strings.TrimSpace(ctx.PostForm("token")) + } + + resp, statusCode, _ := Reload(token, true) + ctx.JSON(statusCode, resp) +} + +func init() { + router.AddHandler("/", "GET", "/system/health", health) + router.AddHandler("/", "POST", "/system/reload", reload) +} diff --git a/internal/schema/system/system.go b/internal/schema/system/system.go new file mode 100644 index 0000000..308070b --- /dev/null +++ b/internal/schema/system/system.go @@ -0,0 +1,21 @@ +package systemschema + +type HealthResp struct { + Status string `json:"status"` + AppName string `json:"app_name"` + Version string `json:"version"` + StartedAt string `json:"started_at"` + UptimeSeconds int64 `json:"uptime_seconds"` + LastReloadAt string `json:"last_reload_at,omitempty"` + ListenPort string `json:"listen_port"` + CdnServer string `json:"cdn_server"` + ReloadTokenConfigured bool `json:"reload_token_configured"` + MainDB string `json:"main_db"` + UserDB string `json:"user_db"` +} + +type ReloadResp struct { + Status string `json:"status"` + Message string `json:"message"` + ReloadedAt string `json:"reloaded_at,omitempty"` +} diff --git a/internal/startup/database.go b/internal/startup/database.go index 06f84d5..e820c7f 100644 --- a/internal/startup/database.go +++ b/internal/startup/database.go @@ -1,6 +1,7 @@ package startup import ( + "fmt" "honoka-chan/internal/constant" ghomemodel "honoka-chan/internal/model/ghome" loginmodel "honoka-chan/internal/model/login" @@ -18,31 +19,43 @@ var ( userEng *xorm.Session ) -func CreateTables() { - // db.UserEng.ShowSQL(true) - db.UserEng.Sync2(new(ghomemodel.DeviceKey)) - db.UserEng.Sync2(new(loginmodel.AuthKey)) - db.UserEng.Sync2(new(usermodel.UserAccessoryWear)) - db.UserEng.Sync2(new(usermodel.UserDeck)) - db.UserEng.Sync2(new(usermodel.UserDeckUnit)) - db.UserEng.Sync2(new(usermodel.UserKey)) - db.UserEng.Sync2(new(usermodel.UserLiveGoal)) - db.UserEng.Sync2(new(usermodel.UserLiveStatus)) - db.UserEng.Sync2(new(usermodel.UserLiveInProgress)) - db.UserEng.Sync2(new(usermodel.UserLiveRandom)) - db.UserEng.Sync2(new(usermodel.UserLiveRecord)) - db.UserEng.Sync2(new(usermodel.UserFriend)) - db.UserEng.Sync2(new(usermodel.UserGreet)) - db.UserEng.Sync2(new(usermodel.UserPref)) - db.UserEng.Sync2(new(usermodel.Users)) - db.UserEng.Sync2(new(usermodel.UserUnit)) - db.UserEng.Sync2(new(usermodel.UserUnitSkillEquip)) +func CreateTables() error { + models := []any{ + new(ghomemodel.DeviceKey), + new(loginmodel.AuthKey), + new(usermodel.UserAccessoryWear), + new(usermodel.UserDeck), + new(usermodel.UserDeckUnit), + new(usermodel.UserKey), + new(usermodel.UserLiveGoal), + new(usermodel.UserLiveStatus), + new(usermodel.UserLiveInProgress), + new(usermodel.UserLiveRandom), + new(usermodel.UserLiveRecord), + new(usermodel.UserFriend), + new(usermodel.UserGreet), + new(usermodel.UserPref), + new(usermodel.Users), + new(usermodel.UserUnit), + new(usermodel.UserUnitSkillEquip), + } - MigrateUserPref() - MigrateUserLiveData() + for _, model := range models { + if err := db.UserEng.Sync2(model); err != nil { + return fmt.Errorf("同步表失败: %w", err) + } + } + + if err := MigrateUserPref(); err != nil { + return err + } + if err := MigrateUserLiveData(); err != nil { + return err + } + return nil } -func MigrateUserPref() { +func MigrateUserPref() error { session := db.UserEng.NewSession() defer session.Close() @@ -52,7 +65,7 @@ func MigrateUserPref() { Or("profile_version IS NULL"). Find(&prefList) if err != nil { - log.Fatalln("迁移 user_pref 失败:", err.Error()) + return fmt.Errorf("迁移 user_pref 失败: %w", err) } for _, pref := range prefList { @@ -62,9 +75,10 @@ func MigrateUserPref() { Cols(usermodel.UserPrefProfileColumns()...). Update(&pref) if err != nil { - log.Fatalln("迁移 user_pref 失败:", err.Error()) + return fmt.Errorf("迁移 user_pref 失败: %w", err) } } + return nil } type liveGoalRewardMigrationRow struct { @@ -105,18 +119,18 @@ func liveRankForMigration(value int, cRank int, bRank int, aRank int, sRank int) } } -func MigrateUserLiveData() { +func MigrateUserLiveData() error { session := db.UserEng.NewSession() defer session.Close() recordRows := []usermodel.UserLiveRecord{} if err := session.Table(new(usermodel.UserLiveRecord)).Find(&recordRows); err != nil { - log.Fatalln("迁移 user_live_status 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_status 失败: %w", err) } existingStatusRows := []usermodel.UserLiveStatus{} if err := session.Table(new(usermodel.UserLiveStatus)).Find(&existingStatusRows); err != nil { - log.Fatalln("迁移 user_live_status 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_status 失败: %w", err) } type liveStatusKey struct { @@ -144,7 +158,7 @@ func MigrateUserLiveData() { UpdateDate: now, } if _, err := session.Insert(&status); err != nil { - log.Fatalln("迁移 user_live_status 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_status 失败: %w", err) } statusMap[key] = status continue @@ -169,14 +183,14 @@ func MigrateUserLiveData() { Where("user_id = ? AND live_difficulty_id = ?", status.UserID, status.LiveDifficultyID). Cols("hi_score", "hi_combo_count", "clear_cnt", "update_date"). Update(&status); err != nil { - log.Fatalln("迁移 user_live_status 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_status 失败: %w", err) } statusMap[key] = status } } liveGoalInfoMap := map[int]liveGoalInfoMigrationRow{} - loadLiveGoalInfoRows := func(table string) { + loadLiveGoalInfoRows := func(table string) error { rows := []liveGoalInfoMigrationRow{} err := db.MainEng.Table(table).Alias("live"). Join("LEFT", "live_setting_m setting", "live.live_setting_id = setting.live_setting_id"). @@ -197,20 +211,25 @@ func MigrateUserLiveData() { `). Find(&rows) if err != nil { - log.Fatalln("迁移 user_live_goal 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_goal 失败: %w", err) } for _, row := range rows { liveGoalInfoMap[row.LiveDifficultyID] = row } + return nil + } + if err := loadLiveGoalInfoRows("normal_live_m"); err != nil { + return err + } + if err := loadLiveGoalInfoRows("special_live_m"); err != nil { + return err } - loadLiveGoalInfoRows("normal_live_m") - loadLiveGoalInfoRows("special_live_m") goalRewardRows := []liveGoalRewardMigrationRow{} if err := db.MainEng.Table("live_goal_reward_m"). OrderBy("live_difficulty_id ASC, live_goal_type ASC, rank ASC, live_goal_reward_id ASC"). Find(&goalRewardRows); err != nil { - log.Fatalln("迁移 user_live_goal 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_goal 失败: %w", err) } goalRewardMap := map[int][]liveGoalRewardMigrationRow{} @@ -222,7 +241,7 @@ func MigrateUserLiveData() { if err := session.Table(new(usermodel.UserLiveGoal)). Where("live_goal_reward_id > 0"). Find(&existingGoalRows); err != nil { - log.Fatalln("迁移 user_live_goal 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_goal 失败: %w", err) } existingGoalMap := make(map[liveStatusKey]map[int]struct{}) @@ -284,7 +303,7 @@ func MigrateUserLiveData() { Rank: reward.Rank, CompletedAt: now, }); err != nil { - log.Fatalln("迁移 user_live_goal 失败:", err.Error()) + return fmt.Errorf("迁移 user_live_goal 失败: %w", err) } if existingGoalMap[key] == nil { existingGoalMap[key] = map[int]struct{}{} @@ -292,13 +311,29 @@ func MigrateUserLiveData() { existingGoalMap[key][reward.LiveGoalRewardID] = struct{}{} } } + return nil } -func LoadUnitData() { +func LoadUnitData() (err error) { userEng = db.UserEng.NewSession() - defer userEng.Close() + defer func() { + if recovered := recover(); recovered != nil { + if userEng != nil { + _ = userEng.Rollback() + } + switch v := recovered.(type) { + case error: + err = fmt.Errorf("同步失败: %w", v) + default: + err = fmt.Errorf("同步失败: %v", v) + } + } + if userEng != nil { + userEng.Close() + } + }() - err := userEng.Begin() + err = userEng.Begin() CheckErr(err) commonUnitExist, err := userEng.IsTableExist(new(unitmodel.CommonUnitData)) @@ -306,183 +341,186 @@ func LoadUnitData() { userUnitExist, err := userEng.IsTableExist(new(usermodel.UserUnitData)) CheckErr(err) - if !commonUnitExist || !userUnitExist { - log.Println("卡片数据不存在,正在同步...") + if commonUnitExist && userUnitExist { + _ = userEng.Rollback() + return nil + } - userEng.DropTable(new(unitmodel.CommonUnitData)) - userEng.CreateTable(new(unitmodel.CommonUnitData)) - userEng.DropTable(new(usermodel.UserUnitData)) - userEng.CreateTable(new(usermodel.UserUnitData)) + log.Println("卡片数据不存在,正在同步...") - var unitData []unitmodel.UnitM - err = db.MainEng.Table(new(unitmodel.UnitM)).OrderBy("unit_id ASC").Find(&unitData) + CheckErr(userEng.DropTable(new(unitmodel.CommonUnitData))) + CheckErr(userEng.CreateTable(new(unitmodel.CommonUnitData))) + CheckErr(userEng.DropTable(new(usermodel.UserUnitData))) + CheckErr(userEng.CreateTable(new(usermodel.UserUnitData))) + + var unitData []unitmodel.UnitM + err = db.MainEng.Table(new(unitmodel.UnitM)).OrderBy("unit_id ASC").Find(&unitData) + CheckErr(err) + + checked := false + for _, u := range unitData { + // 判断卡片最大等级 + var unitMaxLevel, nextExp, sumExp int + _, err = db.MainEng.Table("unit_level_up_pattern_m"). + Where("unit_level_up_pattern_id = ?", u.UnitLevelUpPatternId). + Select("MAX(unit_level),next_exp").Get(&unitMaxLevel, &nextExp) CheckErr(err) - checked := false - for _, u := range unitData { - // 判断卡片最大等级 - var unitMaxLevel, nextExp, sumExp int - _, err = db.MainEng.Table("unit_level_up_pattern_m"). - Where("unit_level_up_pattern_id = ?", u.UnitLevelUpPatternId). - Select("MAX(unit_level),next_exp").Get(&unitMaxLevel, &nextExp) + // 计算突破前的经验总和 + _, err = db.MainEng.Table("unit_level_up_pattern_m"). + Where("unit_level_up_pattern_id = ?", u.UnitLevelUpPatternId). + Where("unit_level = ?", unitMaxLevel-1).Cols("next_exp").Get(&sumExp) + CheckErr(err) + + // 计算突破前的属性 + var smileMax, pureMax, coolMax int + smileMax = u.SmileMax + pureMax = u.PureMax + coolMax = u.CoolMax + + // 如果 nexpExp 不为零,则说明卡片等级没有达到上限 + if nextExp != 0 { + // 计算突破后的经验总和 + _, err = db.MainEng.Table("unit_level_limit_pattern_m"). + Where("unit_level_limit_id = 1 AND unit_level = 349"). + Cols("next_exp").Get(&sumExp) CheckErr(err) - // 计算突破前的经验总和 - _, err = db.MainEng.Table("unit_level_up_pattern_m"). - Where("unit_level_up_pattern_id = ?", u.UnitLevelUpPatternId). - Where("unit_level = ?", unitMaxLevel-1).Cols("next_exp").Get(&sumExp) - CheckErr(err) + // 突破后最大等级 + unitMaxLevel = 350 - // 计算突破前的属性 - var smileMax, pureMax, coolMax int - smileMax = u.SmileMax - pureMax = u.PureMax - coolMax = u.CoolMax - - // 如果 nexpExp 不为零,则说明卡片等级没有达到上限 - if nextExp != 0 { - // 计算突破后的经验总和 - _, err = db.MainEng.Table("unit_level_limit_pattern_m"). - Where("unit_level_limit_id = 1 AND unit_level = 349"). - Cols("next_exp").Get(&sumExp) - CheckErr(err) - - // 突破后最大等级 - unitMaxLevel = 350 - - // 计算突破后的属性 - smileMax += 6000 - pureMax += 6000 - coolMax += 6000 - } - - // 计算绊值、技能等级、技能经验 - var maxLove, skillLevel, skillExp, removableSkillCapacity, levelLimitID int - switch u.Rarity { - case 1: - maxLove = 50 - skillExp = 0 - skillLevel = 0 - removableSkillCapacity = 0 - levelLimitID = 0 - case 2: - maxLove = 200 - skillExp = 490 - skillLevel = 8 - removableSkillCapacity = 1 - levelLimitID = 0 - case 3: - maxLove = 500 - skillExp = 4900 - skillLevel = 8 - removableSkillCapacity = 2 - levelLimitID = 0 - case 4: - maxLove = 1000 - skillExp = 29900 - skillLevel = 8 - removableSkillCapacity = 8 - levelLimitID = 1 - case 5: - maxLove = 750 - skillExp = 12700 - skillLevel = 8 - removableSkillCapacity = 3 - levelLimitID = 0 - } - - // 针对技能卡等应援卡片 - if smileMax == 1 { - maxLove = 0 - skillExp = 0 - skillLevel = 0 - removableSkillCapacity = 0 - } - - // 检查是否签名卡 - var isSigned bool - exist, err := db.MainEng.Table("unit_sign_asset_m").Where("unit_id = ?", u.UnitId).Exist() - CheckErr(err) - if exist { - isSigned = true - } - - // 生成公共卡片 - unitCommon := unitmodel.CommonUnitData{ - UnitNumber: u.UnitNumber, - UnitID: u.UnitId, - UnitTypeID: u.UnitTypeId, - Name: *u.NameEn, - Eponym: u.EponymEn, - Rarity: u.Rarity, - Attribute: u.AttributeId, - Smile: smileMax, - Cute: pureMax, - Cool: coolMax, - Exp: sumExp, - Level: unitMaxLevel, - MaxLevel: unitMaxLevel, - LevelLimitID: levelLimitID, - Rank: u.RankMin, - MaxRank: u.RankMax, - Love: maxLove, - MaxLove: maxLove, - UnitSkillExp: skillExp, - UnitSkillLevel: skillLevel, - MaxHp: u.HpMax, - UnitRemovableSkillCapacity: removableSkillCapacity, - IsRankMax: true, - IsLoveMax: true, - IsLevelMax: true, - IsSigned: isSigned, - IsSkillLevelMax: true, - IsRemovableSkillCapacityMax: true, - InsertDate: time.Now().Unix(), - } - - _, err = userEng.Insert(&unitCommon) - CheckErr(err) - - var userID []int - err = db.UserEng.Table(new(usermodel.Users)).Cols("user_id").Find(&userID) - CheckErr(err) - - for _, id := range userID { - userUnit := usermodel.UserUnitData{ - UnitID: u.UnitId, - FavoriteFlag: false, - DisplayRank: u.RankMax, - UserID: id, - InsertDate: time.Now().Unix(), - } - - // 检查表里是否已经有数据 - if !checked { - ct, err := userEng.Table(new(usermodel.UserUnitData)).Count() - CheckErr(err) - - if ct == 0 { - userUnit.UnitOwningUserID = 38383 - } - - checked = true - } - - _, err = userEng.Insert(&userUnit) - CheckErr(err) - } + // 计算突破后的属性 + smileMax += 6000 + pureMax += 6000 + coolMax += 6000 } - err = userEng.Commit() + // 计算绊值、技能等级、技能经验 + var maxLove, skillLevel, skillExp, removableSkillCapacity, levelLimitID int + switch u.Rarity { + case 1: + maxLove = 50 + skillExp = 0 + skillLevel = 0 + removableSkillCapacity = 0 + levelLimitID = 0 + case 2: + maxLove = 200 + skillExp = 490 + skillLevel = 8 + removableSkillCapacity = 1 + levelLimitID = 0 + case 3: + maxLove = 500 + skillExp = 4900 + skillLevel = 8 + removableSkillCapacity = 2 + levelLimitID = 0 + case 4: + maxLove = 1000 + skillExp = 29900 + skillLevel = 8 + removableSkillCapacity = 8 + levelLimitID = 1 + case 5: + maxLove = 750 + skillExp = 12700 + skillLevel = 8 + removableSkillCapacity = 3 + levelLimitID = 0 + } + + // 针对技能卡等应援卡片 + if smileMax == 1 { + maxLove = 0 + skillExp = 0 + skillLevel = 0 + removableSkillCapacity = 0 + } + + // 检查是否签名卡 + var isSigned bool + exist, err := db.MainEng.Table("unit_sign_asset_m").Where("unit_id = ?", u.UnitId).Exist() + CheckErr(err) + if exist { + isSigned = true + } + + // 生成公共卡片 + unitCommon := unitmodel.CommonUnitData{ + UnitNumber: u.UnitNumber, + UnitID: u.UnitId, + UnitTypeID: u.UnitTypeId, + Name: *u.NameEn, + Eponym: u.EponymEn, + Rarity: u.Rarity, + Attribute: u.AttributeId, + Smile: smileMax, + Cute: pureMax, + Cool: coolMax, + Exp: sumExp, + Level: unitMaxLevel, + MaxLevel: unitMaxLevel, + LevelLimitID: levelLimitID, + Rank: u.RankMin, + MaxRank: u.RankMax, + Love: maxLove, + MaxLove: maxLove, + UnitSkillExp: skillExp, + UnitSkillLevel: skillLevel, + MaxHp: u.HpMax, + UnitRemovableSkillCapacity: removableSkillCapacity, + IsRankMax: true, + IsLoveMax: true, + IsLevelMax: true, + IsSigned: isSigned, + IsSkillLevelMax: true, + IsRemovableSkillCapacityMax: true, + InsertDate: time.Now().Unix(), + } + + _, err = userEng.Insert(&unitCommon) CheckErr(err) - log.Println("同步完成!") + var userID []int + err = db.UserEng.Table(new(usermodel.Users)).Cols("user_id").Find(&userID) + CheckErr(err) + + for _, id := range userID { + userUnit := usermodel.UserUnitData{ + UnitID: u.UnitId, + FavoriteFlag: false, + DisplayRank: u.RankMax, + UserID: id, + InsertDate: time.Now().Unix(), + } + + // 检查表里是否已经有数据 + if !checked { + ct, err := userEng.Table(new(usermodel.UserUnitData)).Count() + CheckErr(err) + + if ct == 0 { + userUnit.UnitOwningUserID = 38383 + } + + checked = true + } + + _, err = userEng.Insert(&userUnit) + CheckErr(err) + } } + + err = userEng.Commit() + CheckErr(err) + + log.Println("同步完成!") + return nil } func CheckErr(err error) { if err != nil { - userEng.Rollback() - log.Fatalln("同步失败:", err.Error()) + panic(err) } } diff --git a/internal/startup/defaultuser.go b/internal/startup/defaultuser.go index 2bf0b01..3fa3eae 100644 --- a/internal/startup/defaultuser.go +++ b/internal/startup/defaultuser.go @@ -1,6 +1,7 @@ package startup import ( + "fmt" "honoka-chan/internal/handler/ghome/account" usermodel "honoka-chan/internal/model/user" "log" @@ -10,16 +11,17 @@ const ( defaultPassword = "klsbgames" ) -func CreateDefaultUser() { +func CreateDefaultUser() error { _, code, msg, created, err := account.AddUser(usermodel.DefaultSystemPhone, defaultPassword, true) if err != nil { - log.Fatalln("默认用户创建失败:", err.Error()) + return fmt.Errorf("默认用户创建失败: %w", err) } if code != 0 { - log.Fatalf("默认用户创建失败: code=%d msg=%s", code, msg) + return fmt.Errorf("默认用户创建失败: code=%d msg=%s", code, msg) } if created { log.Printf("默认用户创建成功, 账号: %s, 密码: %s\n", usermodel.DefaultSystemPhone, defaultPassword) - return + return nil } + return nil } diff --git a/internal/startup/friend.go b/internal/startup/friend.go index 5267523..c039c73 100644 --- a/internal/startup/friend.go +++ b/internal/startup/friend.go @@ -1,17 +1,17 @@ package startup import ( + "fmt" usermodel "honoka-chan/internal/model/user" "honoka-chan/pkg/db" - "log" ) -func EnsureDefaultFriends() { +func EnsureDefaultFriends() error { session := db.UserEng.NewSession() defer session.Close() if err := session.Begin(); err != nil { - log.Fatalln("初始化默认好友失败:", err.Error()) + return fmt.Errorf("初始化默认好友失败: %w", err) } userIDs := []int{} @@ -20,19 +20,20 @@ func EnsureDefaultFriends() { Find(&userIDs) if err != nil { session.Rollback() - log.Fatalln("初始化默认好友失败:", err.Error()) + return fmt.Errorf("初始化默认好友失败: %w", err) } for _, userID := range userIDs { err = usermodel.EnsureDefaultFriendship(session, userID) if err != nil { session.Rollback() - log.Fatalln("初始化默认好友失败:", err.Error()) + return fmt.Errorf("初始化默认好友失败: %w", err) } } if err := session.Commit(); err != nil { session.Rollback() - log.Fatalln("初始化默认好友失败:", err.Error()) + return fmt.Errorf("初始化默认好友失败: %w", err) } + return nil } diff --git a/internal/startup/startup.go b/internal/startup/startup.go index a247f0c..fa612b9 100644 --- a/internal/startup/startup.go +++ b/internal/startup/startup.go @@ -1,8 +1,17 @@ package startup -func StartUp() { - CreateTables() - LoadUnitData() - CreateDefaultUser() - EnsureDefaultFriends() +func StartUp() error { + if err := CreateTables(); err != nil { + return err + } + if err := LoadUnitData(); err != nil { + return err + } + if err := CreateDefaultUser(); err != nil { + return err + } + if err := EnsureDefaultFriends(); err != nil { + return err + } + return nil } diff --git a/main.go b/main.go index 0b5c879..830b83a 100644 --- a/main.go +++ b/main.go @@ -1,56 +1,27 @@ package main import ( - "honoka-chan/config" - _ "honoka-chan/internal/handler" - "honoka-chan/internal/router" - "honoka-chan/internal/startup" - "honoka-chan/pkg/db" + "context" + "honoka-chan/internal/app" "log" "os" "os/signal" "syscall" - - "github.com/gin-gonic/gin" ) func main() { - // 初始化配置 - config.InitConfig() - - // 初始化数据库表和用户数据 - startup.StartUp() + if err := app.Start("."); err != nil { + log.Fatalln(err) + } // 处理系统信号,确保程序退出时关闭数据库 signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-signalChan - log.Println("正在退出...") - db.MainEng.Close() - db.UserEng.Close() - os.Exit(0) - }() - - // Gin - gin.SetMode(gin.ReleaseMode) - - // Router - r := gin.New() - - // Logger - r.Use(gin.LoggerWithConfig(gin.LoggerConfig{ - SkipPaths: []string{ - "/agreement/all", - "/integration/appReport/initialize", - "/report/ge/app", - "/v1/account/reportRole", - }, - })) - - // SIF - router.SifRouter(r) - - r.Run(":" + config.Conf.Settings.ListenPort) // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080") + <-signalChan + log.Println("正在退出...") + if err := app.Stop(context.Background()); err != nil { + log.Println(err.Error()) + } + os.Exit(0) } diff --git a/pkg/db/db.go b/pkg/db/db.go index 2b8a5ee..8102bd2 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -1,7 +1,7 @@ package db import ( - _ "modernc.org/sqlite" + "sync" "xorm.io/xorm" ) @@ -10,37 +10,91 @@ var ( UserDb = "assets/data.db" MainEng *xorm.Engine UserEng *xorm.Engine + + mu sync.Mutex ) -func userSQLiteDSN(dbPath string) string { - return dbPath + - "?_pragma=busy_timeout(5000)" + - "&_pragma=journal_mode(WAL)" + - "&_pragma=synchronous(NORMAL)" +func Init(mainDbPath, userDbPath string) error { + mu.Lock() + defer mu.Unlock() + + if mainDbPath == "" { + mainDbPath = MainDb + } + if userDbPath == "" { + userDbPath = UserDb + } + + if MainEng != nil || UserEng != nil { + if MainDb == mainDbPath && UserDb == userDbPath { + return nil + } + if err := closeLocked(); err != nil { + return err + } + } + + mainEng, err := xorm.NewEngine(sqliteDriverName, mainSQLiteDSN(mainDbPath)) + if err != nil { + return err + } + if err := prepareMainSQLiteEngine(mainEng); err != nil { + mainEng.Close() + return err + } + if err := mainEng.Ping(); err != nil { + mainEng.Close() + return err + } + mainEng.SetMaxOpenConns(10) + mainEng.SetMaxIdleConns(5) + + userEng, err := xorm.NewEngine(sqliteDriverName, userSQLiteDSN(userDbPath)) + if err != nil { + mainEng.Close() + return err + } + if err := prepareUserSQLiteEngine(userEng); err != nil { + mainEng.Close() + userEng.Close() + return err + } + if err := userEng.Ping(); err != nil { + mainEng.Close() + userEng.Close() + return err + } + userEng.SetMaxOpenConns(10) + userEng.SetMaxIdleConns(5) + + MainDb = mainDbPath + UserDb = userDbPath + MainEng = mainEng + UserEng = userEng + return nil } -func init() { - eng, err := xorm.NewEngine("sqlite", MainDb) - if err != nil { - panic(err) +func Close() error { + mu.Lock() + defer mu.Unlock() + return closeLocked() +} + +func closeLocked() error { + var firstErr error + + if MainEng != nil { + if err := MainEng.Close(); err != nil && firstErr == nil { + firstErr = err + } + MainEng = nil } - err = eng.Ping() - if err != nil { - panic(err) + if UserEng != nil { + if err := UserEng.Close(); err != nil && firstErr == nil { + firstErr = err + } + UserEng = nil } - eng.SetMaxOpenConns(10) - eng.SetMaxIdleConns(5) - MainEng = eng - eng, err = xorm.NewEngine("sqlite", userSQLiteDSN(UserDb)) - if err != nil { - panic(err) - } - err = eng.Ping() - if err != nil { - panic(err) - } - eng.SetMaxOpenConns(10) - eng.SetMaxIdleConns(5) - UserEng = eng + return firstErr } diff --git a/pkg/db/sqlite_driver_android.go b/pkg/db/sqlite_driver_android.go new file mode 100644 index 0000000..3a02f66 --- /dev/null +++ b/pkg/db/sqlite_driver_android.go @@ -0,0 +1,39 @@ +//go:build android + +package db + +import ( + _ "github.com/mattn/go-sqlite3" + "xorm.io/xorm" +) + +const sqliteDriverName = "sqlite3" + +func mainSQLiteDSN(dbPath string) string { + return dbPath +} + +func userSQLiteDSN(dbPath string) string { + return dbPath +} + +func prepareMainSQLiteEngine(engine *xorm.Engine) error { + return applySQLitePragmas(engine, nil) +} + +func prepareUserSQLiteEngine(engine *xorm.Engine) error { + return applySQLitePragmas(engine, []string{ + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + }) +} + +func applySQLitePragmas(engine *xorm.Engine, pragmas []string) error { + for _, pragma := range pragmas { + if _, err := engine.Exec(pragma); err != nil { + return err + } + } + return nil +} diff --git a/pkg/db/sqlite_driver_default.go b/pkg/db/sqlite_driver_default.go new file mode 100644 index 0000000..d9bd1d8 --- /dev/null +++ b/pkg/db/sqlite_driver_default.go @@ -0,0 +1,29 @@ +//go:build !android + +package db + +import ( + _ "modernc.org/sqlite" + "xorm.io/xorm" +) + +const sqliteDriverName = "sqlite" + +func mainSQLiteDSN(dbPath string) string { + return dbPath +} + +func userSQLiteDSN(dbPath string) string { + return dbPath + + "?_pragma=busy_timeout(5000)" + + "&_pragma=journal_mode(WAL)" + + "&_pragma=synchronous(NORMAL)" +} + +func prepareMainSQLiteEngine(*xorm.Engine) error { + return nil +} + +func prepareUserSQLiteEngine(*xorm.Engine) error { + return nil +}