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 0000000..8e803d5 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable/ic_notification_server.png b/android/app/src/main/res/drawable/ic_notification_server.png new file mode 100644 index 0000000..a3461ed Binary files /dev/null and b/android/app/src/main/res/drawable/ic_notification_server.png differ 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 0000000..185a8f1 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ 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 0000000..5f17a38 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ 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 0000000..185a8f1 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ 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 0000000..a3e3b11 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ 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 0000000..67d3017 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ 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 0000000..a3e3b11 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ 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 0000000..3347591 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9fb437b Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..3347591 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..91e4275 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ 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 0000000..8d2a7d2 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ 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 0000000..91e4275 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ 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 0000000..a4f4635 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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 0000000..8e803d5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a4f4635 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ 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 +}