Compare commits

1 Commits
Author SHA1 Message Date
YumeMichi 91bbc7f607 Add Android control app and embedded server
Inspired by HNKServer/honoka-chan-apk-server, but implemented as a JNI-based embedded runtime instead of the original approach.

- Add an Android Compose controller app that starts and stops honoka-chan through JNI, shows runtime and health status, manages data directory mounting, and supports backup import/export for data.db
- Add an embeddable Go server entrypoint plus JNI-exported status, health, and reload hooks so desktop and Android builds share the same startup and shutdown path
- Add Android build scripts, runtime packaging, and project documentation, including generated default config.json content for honoka_runtime.zip
- Add runtime bundle hash tracking so updated honoka_runtime.zip assets are automatically redeployed while preserving config.json and user data files
- Add in-app service settings for unlock_all_special_rotation with immediate config persistence and automatic reload when the service is running
- Split SQLite driver selection by platform, using go-sqlite3 on Android and modernc.org/sqlite elsewhere to avoid Android x86_64 seccomp crashes
- Update startup, database initialization, and system health/reload handlers to support the embedded runtime and Android control flow

Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-12 19:17:21 +08:00
64 changed files with 3230 additions and 286 deletions
+12
View File
@@ -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
+246
View File
@@ -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/<abi>/libhonokachan.so`
- 运行时资源包放到:`android/app/src/main/assets/honoka_runtime.zip`
目前工程里已经预建了:
- `android/app/src/main/jniLibs/arm64-v8a/`
- `android/app/src/main/jniLibs/armeabi-v7a/`
- `android/app/src/main/jniLibs/x86_64/`
- `android/app/src/main/assets/`
## 1. 开发环境要求
Linux 下建议直接使用 `android/scripts/*.sh` 先准备运行时资源与 JNI 动态库,最终 APK 由 Android Studio 构建。
需要准备:
- `Go`
- `zip`
- Android SDK
- Android NDK
- Android Studio
常用环境变量:
```bash
export ANDROID_SDK_ROOT=/path/to/android-sdk
export ANDROID_NDK_HOME=/path/to/android-ndk
```
默认 `MIN_SDK=26`,和当前 Android App 的 `minSdk` 保持一致。如果你确实需要改动原生层最低版本,可以在执行脚本前自行覆盖:
```bash
export MIN_SDK=26
```
建议先跑环境检查:
```bash
./android/scripts/check_env.sh
```
说明:
- 不需要预先在仓库根目录准备 `config.json``prepare_runtime_zip.sh` 会自动生成默认配置并打包进去。
## 2. 一键准备运行时资源
如果环境已经准备好,直接执行:
```bash
./android/scripts/build_all.sh
```
它会依次完成:
1. 打包 `honoka_runtime.zip`
2. 编译 `arm64-v8a``libhonokachan.so`
3. 编译 `armeabi-v7a``libhonokachan.so`
4. 编译 `x86_64``libhonokachan.so`
完成后再用 Android Studio 打开 `android/` 并构建 APK。
## 3. 单步脚本
### 3.1 打包运行时资源
```bash
./android/scripts/prepare_runtime_zip.sh
```
### 3.2 编译 Go 动态库
需要使用 Android NDK 的 clang 作为 `CC`,并启用 `c-shared`
脚本方式:
```bash
./android/scripts/build_go_android.sh arm64-v8a
./android/scripts/build_go_android.sh armeabi-v7a
./android/scripts/build_go_android.sh x86_64
```
手工方式,`arm64-v8a` 示例:
```bash
export ANDROID_NDK_HOME=/path/to/android-ndk
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang"
CGO_ENABLED=1 GOOS=android GOARCH=arm64 \
go build -buildmode=c-shared \
-o android/app/src/main/jniLibs/arm64-v8a/libhonokachan.so \
./cmd/honoka-android
```
手工方式,`armeabi-v7a` 示例:
```bash
export ANDROID_NDK_HOME=/path/to/android-ndk
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi26-clang"
CGO_ENABLED=1 GOOS=android GOARCH=arm GOARM=7 \
go build -buildmode=c-shared \
-o android/app/src/main/jniLibs/armeabi-v7a/libhonokachan.so \
./cmd/honoka-android
```
手工方式,`x86_64` 示例:
```bash
export ANDROID_NDK_HOME=/path/to/android-ndk
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android26-clang"
CGO_ENABLED=1 GOOS=android GOARCH=amd64 \
go build -buildmode=c-shared \
-o android/app/src/main/jniLibs/x86_64/libhonokachan.so \
./cmd/honoka-android
```
说明:
- 这里直接使用 `./cmd/honoka-android`,不需要额外 JNI 头文件。
- Android App 自带的 `honoka_android.cpp` 会在运行时通过 `dlopen("libhonokachan.so")` 找到导出的 `ServerStart` / `ServerStop` / `ServerStatusJSON` 等符号。
- `go build -buildmode=c-shared` 会顺带生成 `libhonokachan.h`,脚本会自动删除这个头文件,因为当前 Android 工程并不会使用它。
## 4. 打包运行时资源
`honoka-chan` 在 Android 上仍然按仓库根目录结构读取文件,所以 zip 包内必须直接包含这些顶层条目:
- `assets/`
- `static/`
- `config.json`
最小建议内容:
- `assets/main.db`
- `assets/serverdata/`
- `assets/certs/`
- `static/templates/`
- `static/css/`
- `static/js/`
- `static/images/`
- `static/font/`
- `config.json`
如果你希望把已有用户数据也一起带进去,可以额外放入:
- `assets/data.db`
`prepare_runtime_zip.sh` 会自动生成一份默认配置,内容等同于服务端 `config.DefaultConfigs()`
- `app_name`: `honoka-chan`
- `settings.listen_port`: `8080`
- `settings.cdn_server`: `http://127.0.0.1:8080/static`
- `settings.reload_token`: `""`
- `settings.unlock_all_special_rotation`: `false`
手工打包命令:
```bash
zip -r android/app/src/main/assets/honoka_runtime.zip assets static config.json
```
注意:
- 压缩包根目录下必须直接看到 `assets``static``config.json`,不要多包一层目录。
- App 会记录 `honoka_runtime.zip` 的内容哈希;如果资源包发生变化,下次启动服务或刷新挂载时会自动重新解压。
- 自动重新解压时会保留运行时目录里现有的 `config.json`,避免覆盖用户已经修改过的服务设置。
- 自动重新解压时也会保留运行时目录里现有的 `assets/data.db` 及其 `-wal/-shm`,避免覆盖用户数据。
## 5. 构建 APK
当前不再提供命令行脚本构建 APK,直接使用 Android Studio 打开 `android/` 构建即可。
如果你主要用 Android Studio 模拟器测试,建议保留 `x86_64` ABI;很多模拟器镜像不会加载仅包含 ARM so 的 APK。
## 6. 数据目录
App 会把你选择的目录挂到运行时:
- `/static/Android`
实现方式不是 Linux bind mount,而是:
- 在应用私有运行时目录中创建 `static/Android -> 你的目录` 的符号链接
默认数据目录:
- `/sdcard/Download/data`
如果 Android 版本较高,点击“选择目录”时会先跳转系统设置申请“所有文件访问权限”,授权后自动继续打开目录选择器。
## 7. 备份导入导出
- 备份导出和导入的对象都是运行时目录里的 `assets/data.db`
- 执行前需要先停止服务,避免数据库仍在使用中
- 导出前 App 会先执行一次 `wal_checkpoint(TRUNCATE)`,这样单独导出的 `data.db` 就是完整的
- 导入备份时会提示“将覆盖当前用户数据,是否继续”
## 8. 运行流程
1. 准备好 `libhonokachan.so`
2. 准备好 `honoka_runtime.zip`
3. 用 Android Studio 构建并安装 APK
4. 打开 App
5. 首次进入时按系统提示授予通知权限
6. 根据需要处理“所有文件访问权限”与数据目录选择
7. 如有需要,打开“解锁全部日替”开关
8. 点击“启动服务”
9. 用“刷新状态”确认当前进程健康状态正常
说明:
- 如果系统尚未将本应用加入电池优化白名单,App 只会弹出说明提示,不会自动跳转设置页。
- 即使没有加入白名单,服务仍然可以启动。
- 在部分国产系统上,如果退到后台后服务仍会断开,除了忽略电池优化、自启动和后台运行,还可能需要在最近任务中手动锁定本应用。
## 9. 其他说明
- 这个 Android 子目录默认只提交源码与脚本;`gradlew``gradle/`、构建产物、`jniLibs/``assets/` 等本地生成内容当前都被 `android/.gitignore` 排除了。
- 如果你希望把 Gradle wrapper 一起纳入仓库,可以自行调整 `android/.gitignore`
- 前台服务会持有 `PARTIAL_WAKE_LOCK`,停止服务时会释放。
- App 内部的状态、健康检查、配置重载都直接通过 JNI 调用 Go 导出符号。
- `/system/health``/system/reload` 仍然保留,方便外部调试、脚本探活和浏览器访问。
- Android Studio 升级到 AGP `8.11.2` 后,如果项目放在 Samba 映射盘上,可能会出现 `Cannot create directory .../merged.dir/values` 之类的 AAPT 目录创建失败。这个更像文件系统兼容问题,建议改回本地磁盘构建。
## 10. Reload 与资源更新说明
- App 内部修改服务设置时会直接在进程内执行 reload
- `/system/reload` HTTP 接口只用于外部调用,仍然保留 token 校验
- 无论通过 JNI 还是 HTTP,reload 都只会重新加载运行时目录中的 `config.json`
- 它不会重新解压 `honoka_runtime.zip`
- 如果你更新了数据库、证书、serverdata、模板等资源,需要重新生成 `honoka_runtime.zip` 并重新安装或覆盖资源包
+93
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# No custom ProGuard rules yet.
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HonokaControl"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.HonokaServerService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
+18
View File
@@ -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}
)
+133
View File
@@ -0,0 +1,133 @@
#include <jni.h>
#include <dlfcn.h>
#include <mutex>
#include <string>
namespace {
using server_start_fn = char* (*)(const char*);
using server_stop_fn = char* (*)();
using server_status_fn = char* (*)();
using server_health_fn = char* (*)();
using server_reload_fn = char* (*)();
using server_free_string_fn = void (*)(char*);
std::once_flag g_init_once;
void* g_handle = nullptr;
server_start_fn g_start = nullptr;
server_stop_fn g_stop = nullptr;
server_status_fn g_status = nullptr;
server_health_fn g_health = nullptr;
server_reload_fn g_reload = nullptr;
server_free_string_fn g_free_string = nullptr;
std::string g_error;
void InitServerSymbols() {
g_handle = dlopen("libhonokachan.so", RTLD_NOW | RTLD_GLOBAL);
if (g_handle == nullptr) {
g_error = dlerror();
return;
}
g_start = reinterpret_cast<server_start_fn>(dlsym(g_handle, "ServerStart"));
g_stop = reinterpret_cast<server_stop_fn>(dlsym(g_handle, "ServerStop"));
g_status = reinterpret_cast<server_status_fn>(dlsym(g_handle, "ServerStatusJSON"));
g_health = reinterpret_cast<server_health_fn>(dlsym(g_handle, "ServerHealthJSON"));
g_reload = reinterpret_cast<server_reload_fn>(dlsym(g_handle, "ServerReload"));
g_free_string = reinterpret_cast<server_free_string_fn>(dlsym(g_handle, "ServerFreeString"));
if (g_start == nullptr || g_stop == nullptr || g_status == nullptr || g_health == nullptr ||
g_reload == nullptr || g_free_string == nullptr) {
g_error = "missing exported symbols from libhonokachan.so";
}
}
const char* EnsureServerReady() {
std::call_once(g_init_once, InitServerSymbols);
if (!g_error.empty()) {
return g_error.c_str();
}
return nullptr;
}
jstring CallStringResult(JNIEnv* env, char* result) {
if (result == nullptr) {
return nullptr;
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
} // namespace
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStart(JNIEnv* env, jobject, jstring work_dir) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
const char* work_dir_chars = env->GetStringUTFChars(work_dir, nullptr);
char* result = g_start(work_dir_chars);
env->ReleaseStringUTFChars(work_dir, work_dir_chars);
return CallStringResult(env, result);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStop(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
return CallStringResult(env, g_stop());
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStatusJson(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
std::string json = std::string("{\"running\":false,\"last_error\":\"") + error + "\"}";
return env->NewStringUTF(json.c_str());
}
char* result = g_status();
if (result == nullptr) {
return env->NewStringUTF("{\"running\":false,\"last_error\":\"status unavailable\"}");
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeHealthJson(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
std::string json = std::string("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"") + error + "\\\"}";
return env->NewStringUTF(json.c_str());
}
char* result = g_health();
if (result == nullptr) {
return env->NewStringUTF("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"health unavailable\\\"}");
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeReload(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
return CallStringResult(env, g_reload());
}
@@ -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<MainViewModel>()
private var pendingDirectoryPicker = false
private var pendingStartServer = false
private var batteryOptimizationDialogShown = false
private val runtimePreparer by lazy { RuntimePreparer(this) }
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val openDocumentTreeLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
handleSelectedDirectory(uri)
}
private val exportBackupLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
if (uri != null) {
exportBackupToUri(uri)
}
}
private val importBackupLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
confirmImportBackup(uri)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
HonokaControlTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MainScreen(
viewModel = viewModel,
onToggleServer = { handleToggleServer() },
onOpenLocalUrl = { openLocalUrl() },
onPickDataDirectory = { openDataDirectoryPicker() },
onExportBackup = { openExportBackup() },
onImportBackup = { openImportBackup() },
)
}
}
}
requestNotificationPermission()
}
override fun onResume() {
super.onResume()
viewModel.refreshAllFilesAccess(hasAllFilesAccess())
viewModel.refreshStatus()
maybeShowBatteryOptimizationDialog()
if (pendingStartServer && hasAllFilesAccess()) {
pendingStartServer = false
viewModel.toggleServer()
}
if (pendingDirectoryPicker && hasAllFilesAccess()) {
pendingDirectoryPicker = false
openDocumentTreeLauncher.launch(null)
}
}
private fun requestNotificationPermission() {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
private fun maybeShowBatteryOptimizationDialog() {
if (batteryOptimizationDialogShown || isIgnoringBatteryOptimizations()) {
return
}
batteryOptimizationDialogShown = true
AlertDialog.Builder(this)
.setTitle("允许后台运行")
.setMessage("当前还未为本应用开启后台保活相关设置。建议手动在系统设置中开启忽略电池优化、自启动和后台运行。服务现在仍可正常启动;如果在国产系统上退到后台后仍会断开,还需要在最近任务中将本应用锁定。")
.setPositiveButton("知道了", null)
.show()
}
private fun openDataDirectoryPicker() {
if (!hasAllFilesAccess()) {
pendingDirectoryPicker = true
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续选择目录")
openAllFilesAccessSettings()
return
}
openDocumentTreeLauncher.launch(null)
}
private fun handleToggleServer() {
if (viewModel.uiState.value.status.running) {
viewModel.toggleServer()
return
}
if (!hasAllFilesAccess()) {
pendingStartServer = true
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续启动服务")
openAllFilesAccessSettings()
return
}
viewModel.toggleServer()
}
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true
}
val powerManager = getSystemService(PowerManager::class.java)
return powerManager?.isIgnoringBatteryOptimizations(packageName) == true
}
private fun openLocalUrl() {
val localUrl = viewModel.uiState.value.status.localUrl
if (localUrl.isBlank()) {
viewModel.showMessage("当前没有可打开的本地地址")
return
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(localUrl))
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
viewModel.showMessage("没有可用于打开本地地址的浏览器")
}
}
private fun openExportBackup() {
if (!ensureBackupOperationAllowed()) {
return
}
exportBackupLauncher.launch("honoka-data.db")
}
private fun openImportBackup() {
if (!ensureBackupOperationAllowed()) {
return
}
importBackupLauncher.launch(arrayOf("*/*"))
}
private fun openAllFilesAccessSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
val uri = Uri.parse("package:$packageName")
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri)
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
}
}
private fun hasAllFilesAccess(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
}
private fun ensureBackupOperationAllowed(): Boolean {
if (viewModel.uiState.value.status.running) {
viewModel.showMessage("请先停止服务后再导入或导出备份")
return false
}
return true
}
private fun handleSelectedDirectory(uri: Uri?) {
if (uri == null) {
return
}
runCatching {
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
val path = treeUriToPath(uri)
if (path != null) {
viewModel.setDataDirPath(path)
} else {
viewModel.showMessage("无法解析所选目录,请改用外部存储目录")
}
}
private fun treeUriToPath(uri: Uri): String? {
val documentId = DocumentsContract.getTreeDocumentId(uri)
val parts = documentId.split(":", limit = 2)
val volume = parts.getOrNull(0) ?: return null
val relativePath = parts.getOrNull(1).orEmpty()
val basePath = when {
volume.equals("primary", ignoreCase = true) -> Environment.getExternalStorageDirectory().absolutePath
else -> "/storage/$volume"
}
return if (relativePath.isBlank()) {
basePath
} else {
File(basePath, relativePath).absolutePath
}
}
private fun exportBackupToUri(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = true)
checkpointUserDatabase(dataDbFile)
contentResolver.openOutputStream(uri)?.use { output ->
dataDbFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: error("无法打开导出目标文件")
}.onSuccess {
withContext(Dispatchers.Main) {
viewModel.showMessage("备份已导出")
}
}.onFailure {
withContext(Dispatchers.Main) {
viewModel.showMessage(it.message ?: "导出备份失败")
}
}
}
}
private fun confirmImportBackup(uri: Uri) {
AlertDialog.Builder(this)
.setTitle("导入备份")
.setMessage("导入备份会覆盖当前用户数据,是否继续?")
.setNegativeButton("取消", null)
.setPositiveButton("继续") { _, _ ->
importBackupFromUri(uri)
}
.show()
}
private fun importBackupFromUri(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = false)
val tempFile = File(dataDbFile.parentFile, "${dataDbFile.name}.importing")
contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
} ?: error("无法打开备份文件")
deleteUserDatabaseSidecars(dataDbFile)
if (dataDbFile.exists()) {
dataDbFile.delete()
}
if (!tempFile.renameTo(dataDbFile)) {
tempFile.inputStream().use { input ->
dataDbFile.outputStream().use { output ->
input.copyTo(output)
}
}
tempFile.delete()
}
deleteUserDatabaseSidecars(dataDbFile)
}.onSuccess {
withContext(Dispatchers.Main) {
viewModel.showMessage("备份已导入,下次启动服务时生效")
}
}.onFailure {
withContext(Dispatchers.Main) {
viewModel.showMessage(it.message ?: "导入备份失败")
}
}
}
}
private fun ensureRuntimeDataDbFile(requireExisting: Boolean): File {
val runtimeInfo = runtimePreparer.prepare(viewModel.uiState.value.dataDirPath, forceRedeploy = false)
.getOrElse { throw it }
val dataDbFile = File(runtimeInfo.runtimeRoot, "assets/data.db")
dataDbFile.parentFile?.mkdirs()
if (requireExisting && !dataDbFile.exists()) {
throw IllegalStateException("当前没有可用的用户数据文件")
}
return dataDbFile
}
private fun checkpointUserDatabase(dataDbFile: File) {
val db = SQLiteDatabase.openDatabase(dataDbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
db.use {
it.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { cursor ->
while (cursor.moveToNext()) {
}
}
}
}
private fun deleteUserDatabaseSidecars(dataDbFile: File) {
File(dataDbFile.parentFile, "${dataDbFile.name}-wal").delete()
File(dataDbFile.parentFile, "${dataDbFile.name}-shm").delete()
}
}
@@ -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<Unit> {
val error = ensureLoaded() ?: nativeStart(workDir)
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
}
fun stop(): Result<Unit> {
val error = ensureLoaded() ?: nativeStop()
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
}
fun status(): NativeServerStatus {
val error = ensureLoaded()
if (error != null) {
return NativeServerStatus(lastError = error)
}
return runCatching {
val json = JSONObject(nativeStatusJson())
NativeServerStatus(
running = json.optBoolean("running"),
workDir = json.optString("work_dir"),
listenPort = json.optString("listen_port"),
localUrl = json.optString("local_url"),
startedAt = json.optString("started_at"),
lastError = json.optString("last_error"),
)
}.getOrElse {
NativeServerStatus(lastError = it.message ?: "failed to parse native status")
}
}
private fun healthJson(): String {
val error = ensureLoaded()
if (error != null) {
return """{"status":"error","message":${JSONObject.quote(error)}}"""
}
return nativeHealthJson()
}
fun health(): NativeHealthInfo {
val raw = healthJson()
return runCatching {
val json = JSONObject(raw)
NativeHealthInfo(
status = json.optString("status"),
appName = json.optString("app_name"),
version = json.optString("version"),
startedAt = json.optString("started_at"),
uptimeSeconds = json.optLong("uptime_seconds"),
lastReloadAt = json.optString("last_reload_at"),
listenPort = json.optString("listen_port"),
cdnServer = json.optString("cdn_server"),
reloadTokenConfigured = json.optBoolean("reload_token_configured"),
mainDb = json.optString("main_db"),
userDb = json.optString("user_db"),
message = json.optString("message"),
)
}.getOrElse {
NativeHealthInfo(
status = "error",
message = it.message ?: "failed to parse health response",
)
}
}
fun reload(): NativeReloadResult {
val error = ensureLoaded()
if (error != null) {
return NativeReloadResult(success = false, message = error)
}
val result = nativeReload()
if (result == null) {
return NativeReloadResult(success = true, message = "configuration reloaded")
}
return runCatching {
val json = JSONObject(result)
NativeReloadResult(
success = json.optString("status") == "ok",
message = json.optString("message").ifBlank { "reload failed" },
reloadedAt = json.optString("reloaded_at"),
)
}.getOrElse {
NativeReloadResult(success = false, message = it.message ?: "reload failed")
}
}
private fun ensureLoaded(): String? = loadError
}
@@ -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")
}
}
@@ -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<RuntimeInfo> {
return runCatching {
val runtimeRoot = File(context.filesDir, RUNTIME_DIR_NAME)
val bundleHash = calculateBundleHash()
Log.i(TAG, "prepare runtime root=${runtimeRoot.absolutePath}, dataDir=$dataDirPath, forceRedeploy=$forceRedeploy")
if (forceRedeploy || shouldRedeployBundle(runtimeRoot, bundleHash)) {
redeployBundle(runtimeRoot, bundleHash)
}
mountAndroidDir(runtimeRoot, File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }))
RuntimeInfo(
runtimeRoot = runtimeRoot,
mountedDataDir = File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }),
configFile = File(runtimeRoot, "config.json"),
deployedBundleHash = bundleHash,
)
}
}
private fun redeployBundle(runtimeRoot: File, bundleHash: String) {
Log.i(TAG, "redeploy bundle into ${runtimeRoot.absolutePath}")
val preservedFiles = PRESERVED_RUNTIME_FILES.mapNotNull { relativePath ->
File(runtimeRoot, relativePath).takeIf { it.exists() }?.let { relativePath to it.readBytes() }
}.toMap()
if (runtimeRoot.exists()) {
runtimeRoot.deleteRecursively()
}
runtimeRoot.mkdirs()
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
ZipInputStream(input).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val outFile = File(runtimeRoot, entry.name)
if (preservedFiles.containsKey(entry.name)) {
zip.closeEntry()
entry = zip.nextEntry
continue
}
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { output ->
zip.copyTo(output)
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
preservedFiles.forEach { (relativePath, data) ->
File(runtimeRoot, relativePath).apply {
parentFile?.mkdirs()
writeBytes(data)
}
}
File(runtimeRoot, BUNDLE_MARKER_FILE).writeText(bundleHash)
Log.i(TAG, "bundle deployed successfully")
}
private fun shouldRedeployBundle(runtimeRoot: File, bundleHash: String): Boolean {
val markerFile = File(runtimeRoot, BUNDLE_MARKER_FILE)
if (!markerFile.exists()) {
return true
}
val deployedHash = runCatching { markerFile.readText().trim() }.getOrDefault("")
val changed = deployedHash != bundleHash
if (changed) {
Log.i(TAG, "runtime bundle hash changed, redeploy required")
}
return changed
}
private fun calculateBundleHash(): String {
val digest = MessageDigest.getInstance("SHA-256")
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read <= 0) {
break
}
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun mountAndroidDir(runtimeRoot: File, targetDir: File) {
if (!targetDir.exists()) {
targetDir.mkdirs()
}
Log.i(TAG, "mount Android dir target=${targetDir.absolutePath}")
val androidDir = File(runtimeRoot, "static/Android")
androidDir.parentFile?.mkdirs()
if (isSymlink(androidDir)) {
androidDir.delete()
} else if (androidDir.exists()) {
androidDir.deleteRecursively()
}
Os.symlink(targetDir.absolutePath, androidDir.absolutePath)
Log.i(TAG, "symlink created: ${androidDir.absolutePath} -> ${targetDir.absolutePath}")
}
private fun isSymlink(file: File): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.isSymbolicLink(file.toPath())
} else {
try {
file.canonicalFile != file.absoluteFile
} catch (_: IOException) {
false
}
}
}
companion object {
private const val TAG = "HonokaServer"
const val BUNDLE_ASSET_NAME = "honoka_runtime.zip"
const val RUNTIME_DIR_NAME = "honoka-runtime"
private const val BUNDLE_MARKER_FILE = ".bundle_ready"
private val PRESERVED_RUNTIME_FILES = listOf(
"config.json",
"assets/data.db",
"assets/data.db-wal",
"assets/data.db-shm",
)
fun readDeployedBundleHash(runtimeRoot: File): String {
return runCatching {
File(runtimeRoot, BUNDLE_MARKER_FILE).takeIf { it.exists() }?.readText()?.trim().orEmpty()
}.getOrDefault("")
}
}
}
data class RuntimeInfo(
val runtimeRoot: File,
val mountedDataDir: File,
val configFile: File,
val deployedBundleHash: String,
)
@@ -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"
}
}
@@ -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,
)
}
@@ -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)
}
@@ -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<MainUiState> = _uiState.asStateFlow()
init {
refreshStatus()
refreshRuntimePaths()
}
fun setDataDirPath(path: String) {
val normalizedPath = path.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }
settingsStore.saveDataDirPath(normalizedPath)
_uiState.value = _uiState.value.copy(dataDirPath = normalizedPath)
applyDataDirMount()
}
fun showMessage(message: String) {
pushMessage(message)
}
fun toggleServer() {
if (uiState.value.status.running) {
stopServer()
} else {
startServer()
}
}
fun startServer() {
if (!uiState.value.allFilesAccessGranted) {
Log.w(TAG, "start aborted: all files access is not granted")
pushMessage("请先授予全部文件访问权限")
return
}
Log.i(TAG, "start requested, dataDir=${uiState.value.dataDirPath}")
prepareRuntime { runtimeRoot ->
val context = getApplication<Application>()
val intent = HonokaServerService.startIntent(context, runtimeRoot)
try {
Log.i(TAG, "starting foreground service, runtimeRoot=$runtimeRoot")
ContextCompat.startForegroundService(context, intent)
_uiState.value = _uiState.value.copy(busy = true)
pushMessage("正在启动服务", autoClearMillis = null)
refreshServerState(
initialDelayMillis = 500,
retryCount = 8,
retryDelayMillis = 400,
expectedRunning = true,
updateMessage = true,
)
} catch (e: Throwable) {
Log.e(TAG, "failed to start foreground service", e)
_uiState.value = _uiState.value.copy(busy = false)
pushMessage(e.message ?: "启动前台服务失败")
}
}
}
fun stopServer() {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "stop requested")
_uiState.value = _uiState.value.copy(busy = true)
val context = getApplication<Application>()
context.startService(HonokaServerService.stopIntent(context))
refreshServerState(
initialDelayMillis = 200,
retryCount = 6,
retryDelayMillis = 300,
expectedRunning = false,
updateMessage = true,
)
}
}
fun refreshStatus(delayed: Boolean = false) {
refreshServerState(initialDelayMillis = if (delayed) 800 else 0, updateMessage = false)
}
fun refreshAllFilesAccess(granted: Boolean) {
_uiState.value = _uiState.value.copy(allFilesAccessGranted = granted)
}
fun updateUnlockAllSpecialRotation(enabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = _uiState.value.copy(
busy = true,
unlockAllSpecialRotation = enabled,
)
val configFile = File(_uiState.value.configPath.ifBlank {
File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
.resolve("config.json")
.absolutePath
})
runCatching {
writeUnlockAllSpecialRotation(configFile, enabled)
}.onFailure {
Log.e(TAG, "failed to update config", it)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(
busy = false,
unlockAllSpecialRotation = !enabled,
)
pushMessage(it.message ?: "更新配置失败")
}
return@launch
}
if (!uiState.value.status.running) {
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
pushMessage("设置已保存,下次启动服务时生效")
}
return@launch
}
val result: NativeReloadResult = NativeBridge.reload()
Log.i(TAG, "reload requested by config switch: success=${result.success}, message=${result.message}")
withContext(Dispatchers.Main) {
if (result.success) {
pushMessage("设置已保存并重载")
refreshServerState(initialDelayMillis = 200, updateMessage = false)
} else {
_uiState.value = _uiState.value.copy(
busy = false,
)
pushMessage(result.message.ifBlank { "设置已保存,但重载配置失败" })
}
}
}
}
private fun applyDataDirMount() {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "apply data dir mount: ${_uiState.value.dataDirPath}")
_uiState.value = _uiState.value.copy(busy = true)
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
result.onSuccess {
_uiState.value = _uiState.value.copy(
runtimeRoot = it.runtimeRoot.absolutePath,
configPath = it.configFile.absolutePath,
deployedBundleHash = it.deployedBundleHash,
)
syncConfigState(it.configFile)
pushMessage("数据目录已保存并挂载")
}.onFailure {
Log.e(TAG, "apply data dir mount failed", it)
pushMessage(it.message ?: "目录挂载失败")
}
}
}
}
private fun prepareRuntime(onSuccess: ((String) -> Unit)? = null) {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "prepare runtime")
_uiState.value = _uiState.value.copy(busy = true)
settingsStore.saveDataDirPath(_uiState.value.dataDirPath)
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
result.onSuccess {
Log.i(TAG, "runtime ready: root=${it.runtimeRoot}, mounted=${it.mountedDataDir}")
_uiState.value = _uiState.value.copy(
runtimeRoot = it.runtimeRoot.absolutePath,
configPath = it.configFile.absolutePath,
deployedBundleHash = it.deployedBundleHash,
)
syncConfigState(it.configFile)
pushMessage("运行时已准备完成")
onSuccess?.invoke(it.runtimeRoot.absolutePath)
}.onFailure {
Log.e(TAG, "runtime prepare failed", it)
pushMessage(it.message ?: "运行时准备失败")
}
}
}
}
private fun refreshRuntimePaths() {
val runtimeRoot = File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
_uiState.value = _uiState.value.copy(
runtimeRoot = runtimeRoot.absolutePath,
configPath = File(runtimeRoot, "config.json").absolutePath,
deployedBundleHash = RuntimePreparer.readDeployedBundleHash(runtimeRoot),
)
syncConfigState(File(runtimeRoot, "config.json"))
}
private fun refreshServerState(
initialDelayMillis: Long = 0,
retryCount: Int = 1,
retryDelayMillis: Long = 0,
expectedRunning: Boolean? = null,
updateMessage: Boolean = false,
) {
viewModelScope.launch(Dispatchers.IO) {
if (initialDelayMillis > 0) {
delay(initialDelayMillis)
}
var status = NativeBridge.status()
var health = NativeBridge.health()
for (attempt in 1 until retryCount.coerceAtLeast(1)) {
if (isExpectedStateReached(status, health, expectedRunning)) {
break
}
if (retryDelayMillis > 0) {
delay(retryDelayMillis)
}
status = NativeBridge.status()
health = NativeBridge.health()
}
Log.d(
TAG,
"server state refreshed: running=${status.running}, health=${health.status}, mainDb=${health.mainDb}, userDb=${health.userDb}, error=${status.lastError}",
)
withContext(Dispatchers.Main) {
val message = if (updateMessage) {
buildStatusMessage(status, health, expectedRunning)
} else {
_uiState.value.message
}
_uiState.value = _uiState.value.copy(
status = status,
health = health,
busy = false,
message = if (updateMessage) message else _uiState.value.message,
)
syncConfigState(File(_uiState.value.configPath))
if (updateMessage && message.isNotBlank()) {
scheduleMessageClear(5000)
}
refreshRuntimePaths()
}
}
}
private fun isExpectedStateReached(
status: NativeServerStatus,
health: NativeHealthInfo,
expectedRunning: Boolean?,
): Boolean {
return when (expectedRunning) {
true -> status.running && health.status != "stopped" && health.mainDb != "not initialized" && health.userDb != "not initialized"
false -> !status.running
null -> true
}
}
private fun buildStatusMessage(
status: NativeServerStatus,
health: NativeHealthInfo,
expectedRunning: Boolean?,
): String {
if (status.lastError.isNotBlank()) {
return status.lastError
}
return when (expectedRunning) {
true -> {
if (status.running && health.status != "stopped") {
"服务运行中"
} else {
"服务启动中,请稍后刷新"
}
}
false -> "服务已停止"
null -> {
if (status.running) {
"服务运行中"
} else {
_uiState.value.message
}
}
}
}
private fun pushMessage(message: String, autoClearMillis: Long? = 5000) {
messageClearJob?.cancel()
_uiState.value = _uiState.value.copy(message = message)
if (autoClearMillis != null) {
scheduleMessageClear(autoClearMillis)
}
}
private fun syncConfigState(configFile: File) {
if (!configFile.exists()) {
return
}
runCatching {
val json = JSONObject(configFile.readText())
json.optJSONObject("settings")?.optBoolean("unlock_all_special_rotation", false) ?: false
}.onSuccess { enabled ->
_uiState.value = _uiState.value.copy(unlockAllSpecialRotation = enabled)
}.onFailure {
Log.w(TAG, "failed to parse config state", it)
}
}
private fun writeUnlockAllSpecialRotation(configFile: File, enabled: Boolean) {
configFile.parentFile?.mkdirs()
val json = if (configFile.exists()) {
runCatching { JSONObject(configFile.readText()) }.getOrElse { JSONObject() }
} else {
JSONObject()
}
val settings = json.optJSONObject("settings") ?: JSONObject()
if (!json.has("app_name")) {
json.put("app_name", "honoka-chan")
}
settings.put("listen_port", settings.optString("listen_port", "8080"))
settings.put("cdn_server", settings.optString("cdn_server", "http://127.0.0.1:8080/static"))
settings.put("reload_token", settings.optString("reload_token", ""))
settings.put("unlock_all_special_rotation", enabled)
json.put("settings", settings)
configFile.writeText(json.toString(4) + "\n")
}
private fun scheduleMessageClear(delayMillis: Long) {
messageClearJob?.cancel()
messageClearJob = viewModelScope.launch {
delay(delayMillis)
_uiState.value = _uiState.value.copy(message = "")
}
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1A73E8"
android:pathData="M0,0h108v108h-108z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">honoka-chan</string>
</resources>
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.HonokaControl" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
+5
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+19
View File
@@ -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"
+61
View File
@@ -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"
+85
View File
@@ -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"
+27
View File
@@ -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"
}
+58
View File
@@ -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"
+18
View File
@@ -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")
+68
View File
@@ -0,0 +1,68 @@
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"context"
"encoding/json"
"honoka-chan/internal/app"
systemhandler "honoka-chan/internal/handler/system"
"time"
"unsafe"
)
//export ServerStart
func ServerStart(workDir *C.char) *C.char {
if err := app.Start(C.GoString(workDir)); err != nil {
return C.CString(err.Error())
}
return nil
}
//export ServerStop
func ServerStop() *C.char {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.Stop(ctx); err != nil {
return C.CString(err.Error())
}
return nil
}
//export ServerStatusJSON
func ServerStatusJSON() *C.char {
return C.CString(app.GetStatusJSON())
}
//export ServerHealthJSON
func ServerHealthJSON() *C.char {
return C.CString(systemhandler.HealthJSON())
}
//export ServerReload
func ServerReload() *C.char {
resp, _, err := systemhandler.Reload("", false)
if err == nil {
return nil
}
data, marshalErr := json.Marshal(resp)
if marshalErr != nil {
return C.CString(err.Error())
}
return C.CString(string(data))
}
//export ServerFreeString
func ServerFreeString(str *C.char) {
if str == nil {
return
}
C.free(unsafe.Pointer(str))
}
func main() {}
+9 -1
View File
@@ -12,6 +12,7 @@ var (
Conf = &AppConfigs{} Conf = &AppConfigs{}
PackageVersion = "97.4.6" PackageVersion = "97.4.6"
ConfigPath = "./config.json"
PrivateKeyPath = "assets/certs/privatekey.pem" PrivateKeyPath = "assets/certs/privatekey.pem"
PublicKeyPath = "assets/certs/publickey.pem" PublicKeyPath = "assets/certs/publickey.pem"
@@ -25,11 +26,17 @@ type AppConfigs struct {
type Settings struct { type Settings struct {
ListenPort string `json:"listen_port"` ListenPort string `json:"listen_port"`
CdnServer string `json:"cdn_server"` CdnServer string `json:"cdn_server"`
ReloadToken string `json:"reload_token"`
UnlockAllSpecialRotation bool `json:"unlock_all_special_rotation"` UnlockAllSpecialRotation bool `json:"unlock_all_special_rotation"`
} }
func InitConfig() { func InitConfig() {
Conf = Load("./config.json") ReloadConfig()
}
func ReloadConfig() *AppConfigs {
Conf = Load(ConfigPath)
return Conf
} }
func DefaultConfigs() *AppConfigs { func DefaultConfigs() *AppConfigs {
@@ -38,6 +45,7 @@ func DefaultConfigs() *AppConfigs {
Settings: Settings{ Settings: Settings{
ListenPort: "8080", ListenPort: "8080",
CdnServer: "http://127.0.0.1:8080/static", CdnServer: "http://127.0.0.1:8080/static",
ReloadToken: "",
UnlockAllSpecialRotation: false, UnlockAllSpecialRotation: false,
}, },
} }
+4
View File
@@ -7,6 +7,7 @@
第一次启动 `honoka-chan` 后会生成 `config.json`。需要确认: 第一次启动 `honoka-chan` 后会生成 `config.json`。需要确认:
- `settings.cdn_server` 指向你的数据下载地址 - `settings.cdn_server` 指向你的数据下载地址
- `settings.reload_token` 可选;如果需要通过接口热重载 `config.json`,需要先设置一个 token
- `settings.unlock_all_special_rotation` 可选;设为 `true` 后会忽略日替时间限制,直接解锁全部日替特殊歌曲,不包含周替 `MASTER` - `settings.unlock_all_special_rotation` 可选;设为 `true` 后会忽略日替时间限制,直接解锁全部日替特殊歌曲,不包含周替 `MASTER`
- 如果数据直接放在本项目的 `static` 目录下,通常可以配置成类似 `http://192.168.1.123/static` - 如果数据直接放在本项目的 `static` 目录下,通常可以配置成类似 `http://192.168.1.123/static`
@@ -25,6 +26,9 @@
## 补充说明 ## 补充说明
- `GET /system/health` 可用于检查应用和数据库是否正常运行
- `POST /system/reload?token=你的token` 可在修改 `config.json` 后重新加载配置
SIF 的“全量数据下载”实际上并不是真正的全量。部分剧情资源仍然需要通过 `/download/getUrl` 接口按路径拉取。 SIF 的“全量数据下载”实际上并不是真正的全量。部分剧情资源仍然需要通过 `/download/getUrl` 接口按路径拉取。
所以除了 `archives` 目录里的压缩包,还需要另外维护一份: 所以除了 `archives` 目录里的压缩包,还需要另外维护一份:
+1
View File
@@ -7,6 +7,7 @@ require github.com/gin-gonic/gin v1.12.0
require ( require (
github.com/gin-contrib/sessions v1.1.0 github.com/gin-contrib/sessions v1.1.0
github.com/go-think/openssl v1.20.0 github.com/go-think/openssl v1.20.0
github.com/mattn/go-sqlite3 v1.14.45
github.com/tidwall/gjson v1.19.0 github.com/tidwall/gjson v1.19.0
modernc.org/sqlite v1.51.0 modernc.org/sqlite v1.51.0
xorm.io/builder v0.3.13 xorm.io/builder v0.3.13
+2 -2
View File
@@ -71,8 +71,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+166
View File
@@ -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
}
+1
View File
@@ -30,6 +30,7 @@ import (
_ "honoka-chan/internal/handler/scenario" _ "honoka-chan/internal/handler/scenario"
_ "honoka-chan/internal/handler/secretbox" _ "honoka-chan/internal/handler/secretbox"
_ "honoka-chan/internal/handler/subscenario" _ "honoka-chan/internal/handler/subscenario"
_ "honoka-chan/internal/handler/system"
_ "honoka-chan/internal/handler/tos" _ "honoka-chan/internal/handler/tos"
_ "honoka-chan/internal/handler/unit" _ "honoka-chan/internal/handler/unit"
_ "honoka-chan/internal/handler/user" _ "honoka-chan/internal/handler/user"
+139
View File
@@ -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)
}
+21
View File
@@ -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"`
}
+239 -201
View File
@@ -1,6 +1,7 @@
package startup package startup
import ( import (
"fmt"
"honoka-chan/internal/constant" "honoka-chan/internal/constant"
ghomemodel "honoka-chan/internal/model/ghome" ghomemodel "honoka-chan/internal/model/ghome"
loginmodel "honoka-chan/internal/model/login" loginmodel "honoka-chan/internal/model/login"
@@ -18,31 +19,43 @@ var (
userEng *xorm.Session userEng *xorm.Session
) )
func CreateTables() { func CreateTables() error {
// db.UserEng.ShowSQL(true) models := []any{
db.UserEng.Sync2(new(ghomemodel.DeviceKey)) new(ghomemodel.DeviceKey),
db.UserEng.Sync2(new(loginmodel.AuthKey)) new(loginmodel.AuthKey),
db.UserEng.Sync2(new(usermodel.UserAccessoryWear)) new(usermodel.UserAccessoryWear),
db.UserEng.Sync2(new(usermodel.UserDeck)) new(usermodel.UserDeck),
db.UserEng.Sync2(new(usermodel.UserDeckUnit)) new(usermodel.UserDeckUnit),
db.UserEng.Sync2(new(usermodel.UserKey)) new(usermodel.UserKey),
db.UserEng.Sync2(new(usermodel.UserLiveGoal)) new(usermodel.UserLiveGoal),
db.UserEng.Sync2(new(usermodel.UserLiveStatus)) new(usermodel.UserLiveStatus),
db.UserEng.Sync2(new(usermodel.UserLiveInProgress)) new(usermodel.UserLiveInProgress),
db.UserEng.Sync2(new(usermodel.UserLiveRandom)) new(usermodel.UserLiveRandom),
db.UserEng.Sync2(new(usermodel.UserLiveRecord)) new(usermodel.UserLiveRecord),
db.UserEng.Sync2(new(usermodel.UserFriend)) new(usermodel.UserFriend),
db.UserEng.Sync2(new(usermodel.UserGreet)) new(usermodel.UserGreet),
db.UserEng.Sync2(new(usermodel.UserPref)) new(usermodel.UserPref),
db.UserEng.Sync2(new(usermodel.Users)) new(usermodel.Users),
db.UserEng.Sync2(new(usermodel.UserUnit)) new(usermodel.UserUnit),
db.UserEng.Sync2(new(usermodel.UserUnitSkillEquip)) new(usermodel.UserUnitSkillEquip),
}
MigrateUserPref() for _, model := range models {
MigrateUserLiveData() 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() session := db.UserEng.NewSession()
defer session.Close() defer session.Close()
@@ -52,7 +65,7 @@ func MigrateUserPref() {
Or("profile_version IS NULL"). Or("profile_version IS NULL").
Find(&prefList) Find(&prefList)
if err != nil { if err != nil {
log.Fatalln("迁移 user_pref 失败:", err.Error()) return fmt.Errorf("迁移 user_pref 失败: %w", err)
} }
for _, pref := range prefList { for _, pref := range prefList {
@@ -62,9 +75,10 @@ func MigrateUserPref() {
Cols(usermodel.UserPrefProfileColumns()...). Cols(usermodel.UserPrefProfileColumns()...).
Update(&pref) Update(&pref)
if err != nil { if err != nil {
log.Fatalln("迁移 user_pref 失败:", err.Error()) return fmt.Errorf("迁移 user_pref 失败: %w", err)
} }
} }
return nil
} }
type liveGoalRewardMigrationRow struct { 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() session := db.UserEng.NewSession()
defer session.Close() defer session.Close()
recordRows := []usermodel.UserLiveRecord{} recordRows := []usermodel.UserLiveRecord{}
if err := session.Table(new(usermodel.UserLiveRecord)).Find(&recordRows); err != nil { 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{} existingStatusRows := []usermodel.UserLiveStatus{}
if err := session.Table(new(usermodel.UserLiveStatus)).Find(&existingStatusRows); err != nil { 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 { type liveStatusKey struct {
@@ -144,7 +158,7 @@ func MigrateUserLiveData() {
UpdateDate: now, UpdateDate: now,
} }
if _, err := session.Insert(&status); err != nil { 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 statusMap[key] = status
continue continue
@@ -169,14 +183,14 @@ func MigrateUserLiveData() {
Where("user_id = ? AND live_difficulty_id = ?", status.UserID, status.LiveDifficultyID). Where("user_id = ? AND live_difficulty_id = ?", status.UserID, status.LiveDifficultyID).
Cols("hi_score", "hi_combo_count", "clear_cnt", "update_date"). Cols("hi_score", "hi_combo_count", "clear_cnt", "update_date").
Update(&status); err != nil { Update(&status); err != nil {
log.Fatalln("迁移 user_live_status 失败:", err.Error()) return fmt.Errorf("迁移 user_live_status 失败: %w", err)
} }
statusMap[key] = status statusMap[key] = status
} }
} }
liveGoalInfoMap := map[int]liveGoalInfoMigrationRow{} liveGoalInfoMap := map[int]liveGoalInfoMigrationRow{}
loadLiveGoalInfoRows := func(table string) { loadLiveGoalInfoRows := func(table string) error {
rows := []liveGoalInfoMigrationRow{} rows := []liveGoalInfoMigrationRow{}
err := db.MainEng.Table(table).Alias("live"). err := db.MainEng.Table(table).Alias("live").
Join("LEFT", "live_setting_m setting", "live.live_setting_id = setting.live_setting_id"). Join("LEFT", "live_setting_m setting", "live.live_setting_id = setting.live_setting_id").
@@ -197,20 +211,25 @@ func MigrateUserLiveData() {
`). `).
Find(&rows) Find(&rows)
if err != nil { if err != nil {
log.Fatalln("迁移 user_live_goal 失败:", err.Error()) return fmt.Errorf("迁移 user_live_goal 失败: %w", err)
} }
for _, row := range rows { for _, row := range rows {
liveGoalInfoMap[row.LiveDifficultyID] = row 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{} goalRewardRows := []liveGoalRewardMigrationRow{}
if err := db.MainEng.Table("live_goal_reward_m"). 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"). OrderBy("live_difficulty_id ASC, live_goal_type ASC, rank ASC, live_goal_reward_id ASC").
Find(&goalRewardRows); err != nil { Find(&goalRewardRows); err != nil {
log.Fatalln("迁移 user_live_goal 失败:", err.Error()) return fmt.Errorf("迁移 user_live_goal 失败: %w", err)
} }
goalRewardMap := map[int][]liveGoalRewardMigrationRow{} goalRewardMap := map[int][]liveGoalRewardMigrationRow{}
@@ -222,7 +241,7 @@ func MigrateUserLiveData() {
if err := session.Table(new(usermodel.UserLiveGoal)). if err := session.Table(new(usermodel.UserLiveGoal)).
Where("live_goal_reward_id > 0"). Where("live_goal_reward_id > 0").
Find(&existingGoalRows); err != nil { 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{}) existingGoalMap := make(map[liveStatusKey]map[int]struct{})
@@ -284,7 +303,7 @@ func MigrateUserLiveData() {
Rank: reward.Rank, Rank: reward.Rank,
CompletedAt: now, CompletedAt: now,
}); err != nil { }); err != nil {
log.Fatalln("迁移 user_live_goal 失败:", err.Error()) return fmt.Errorf("迁移 user_live_goal 失败: %w", err)
} }
if existingGoalMap[key] == nil { if existingGoalMap[key] == nil {
existingGoalMap[key] = map[int]struct{}{} existingGoalMap[key] = map[int]struct{}{}
@@ -292,13 +311,29 @@ func MigrateUserLiveData() {
existingGoalMap[key][reward.LiveGoalRewardID] = struct{}{} existingGoalMap[key][reward.LiveGoalRewardID] = struct{}{}
} }
} }
return nil
} }
func LoadUnitData() { func LoadUnitData() (err error) {
userEng = db.UserEng.NewSession() 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) CheckErr(err)
commonUnitExist, err := userEng.IsTableExist(new(unitmodel.CommonUnitData)) commonUnitExist, err := userEng.IsTableExist(new(unitmodel.CommonUnitData))
@@ -306,183 +341,186 @@ func LoadUnitData() {
userUnitExist, err := userEng.IsTableExist(new(usermodel.UserUnitData)) userUnitExist, err := userEng.IsTableExist(new(usermodel.UserUnitData))
CheckErr(err) CheckErr(err)
if !commonUnitExist || !userUnitExist { if commonUnitExist && userUnitExist {
log.Println("卡片数据不存在,正在同步...") _ = userEng.Rollback()
return nil
}
userEng.DropTable(new(unitmodel.CommonUnitData)) log.Println("卡片数据不存在,正在同步...")
userEng.CreateTable(new(unitmodel.CommonUnitData))
userEng.DropTable(new(usermodel.UserUnitData))
userEng.CreateTable(new(usermodel.UserUnitData))
var unitData []unitmodel.UnitM CheckErr(userEng.DropTable(new(unitmodel.CommonUnitData)))
err = db.MainEng.Table(new(unitmodel.UnitM)).OrderBy("unit_id ASC").Find(&unitData) 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) CheckErr(err)
checked := false // 计算突破前的经验总和
for _, u := range unitData { _, err = db.MainEng.Table("unit_level_up_pattern_m").
// 判断卡片最大等级 Where("unit_level_up_pattern_id = ?", u.UnitLevelUpPatternId).
var unitMaxLevel, nextExp, sumExp int Where("unit_level = ?", unitMaxLevel-1).Cols("next_exp").Get(&sumExp)
_, err = db.MainEng.Table("unit_level_up_pattern_m"). CheckErr(err)
Where("unit_level_up_pattern_id = ?", u.UnitLevelUpPatternId).
Select("MAX(unit_level),next_exp").Get(&unitMaxLevel, &nextExp) // 计算突破前的属性
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) CheckErr(err)
// 计算突破前的经验总和 // 突破后最大等级
_, err = db.MainEng.Table("unit_level_up_pattern_m"). unitMaxLevel = 350
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 += 6000
smileMax = u.SmileMax pureMax += 6000
pureMax = u.PureMax coolMax += 6000
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)
}
} }
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) 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) { func CheckErr(err error) {
if err != nil { if err != nil {
userEng.Rollback() panic(err)
log.Fatalln("同步失败:", err.Error())
} }
} }
+6 -4
View File
@@ -1,6 +1,7 @@
package startup package startup
import ( import (
"fmt"
"honoka-chan/internal/handler/ghome/account" "honoka-chan/internal/handler/ghome/account"
usermodel "honoka-chan/internal/model/user" usermodel "honoka-chan/internal/model/user"
"log" "log"
@@ -10,16 +11,17 @@ const (
defaultPassword = "klsbgames" defaultPassword = "klsbgames"
) )
func CreateDefaultUser() { func CreateDefaultUser() error {
_, code, msg, created, err := account.AddUser(usermodel.DefaultSystemPhone, defaultPassword, true) _, code, msg, created, err := account.AddUser(usermodel.DefaultSystemPhone, defaultPassword, true)
if err != nil { if err != nil {
log.Fatalln("默认用户创建失败:", err.Error()) return fmt.Errorf("默认用户创建失败: %w", err)
} }
if code != 0 { if code != 0 {
log.Fatalf("默认用户创建失败: code=%d msg=%s", code, msg) return fmt.Errorf("默认用户创建失败: code=%d msg=%s", code, msg)
} }
if created { if created {
log.Printf("默认用户创建成功, 账号: %s, 密码: %s\n", usermodel.DefaultSystemPhone, defaultPassword) log.Printf("默认用户创建成功, 账号: %s, 密码: %s\n", usermodel.DefaultSystemPhone, defaultPassword)
return return nil
} }
return nil
} }
+7 -6
View File
@@ -1,17 +1,17 @@
package startup package startup
import ( import (
"fmt"
usermodel "honoka-chan/internal/model/user" usermodel "honoka-chan/internal/model/user"
"honoka-chan/pkg/db" "honoka-chan/pkg/db"
"log"
) )
func EnsureDefaultFriends() { func EnsureDefaultFriends() error {
session := db.UserEng.NewSession() session := db.UserEng.NewSession()
defer session.Close() defer session.Close()
if err := session.Begin(); err != nil { if err := session.Begin(); err != nil {
log.Fatalln("初始化默认好友失败:", err.Error()) return fmt.Errorf("初始化默认好友失败: %w", err)
} }
userIDs := []int{} userIDs := []int{}
@@ -20,19 +20,20 @@ func EnsureDefaultFriends() {
Find(&userIDs) Find(&userIDs)
if err != nil { if err != nil {
session.Rollback() session.Rollback()
log.Fatalln("初始化默认好友失败:", err.Error()) return fmt.Errorf("初始化默认好友失败: %w", err)
} }
for _, userID := range userIDs { for _, userID := range userIDs {
err = usermodel.EnsureDefaultFriendship(session, userID) err = usermodel.EnsureDefaultFriendship(session, userID)
if err != nil { if err != nil {
session.Rollback() session.Rollback()
log.Fatalln("初始化默认好友失败:", err.Error()) return fmt.Errorf("初始化默认好友失败: %w", err)
} }
} }
if err := session.Commit(); err != nil { if err := session.Commit(); err != nil {
session.Rollback() session.Rollback()
log.Fatalln("初始化默认好友失败:", err.Error()) return fmt.Errorf("初始化默认好友失败: %w", err)
} }
return nil
} }
+14 -5
View File
@@ -1,8 +1,17 @@
package startup package startup
func StartUp() { func StartUp() error {
CreateTables() if err := CreateTables(); err != nil {
LoadUnitData() return err
CreateDefaultUser() }
EnsureDefaultFriends() if err := LoadUnitData(); err != nil {
return err
}
if err := CreateDefaultUser(); err != nil {
return err
}
if err := EnsureDefaultFriends(); err != nil {
return err
}
return nil
} }
+11 -40
View File
@@ -1,56 +1,27 @@
package main package main
import ( import (
"honoka-chan/config" "context"
_ "honoka-chan/internal/handler" "honoka-chan/internal/app"
"honoka-chan/internal/router"
"honoka-chan/internal/startup"
"honoka-chan/pkg/db"
"log" "log"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"github.com/gin-gonic/gin"
) )
func main() { func main() {
// 初始化配置 if err := app.Start("."); err != nil {
config.InitConfig() log.Fatalln(err)
}
// 初始化数据库表和用户数据
startup.StartUp()
// 处理系统信号,确保程序退出时关闭数据库 // 处理系统信号,确保程序退出时关闭数据库
signalChan := make(chan os.Signal, 1) signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
go func() { <-signalChan
<-signalChan log.Println("正在退出...")
log.Println("正在退出...") if err := app.Stop(context.Background()); err != nil {
db.MainEng.Close() log.Println(err.Error())
db.UserEng.Close() }
os.Exit(0) 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")
} }
+81 -27
View File
@@ -1,7 +1,7 @@
package db package db
import ( import (
_ "modernc.org/sqlite" "sync"
"xorm.io/xorm" "xorm.io/xorm"
) )
@@ -10,37 +10,91 @@ var (
UserDb = "assets/data.db" UserDb = "assets/data.db"
MainEng *xorm.Engine MainEng *xorm.Engine
UserEng *xorm.Engine UserEng *xorm.Engine
mu sync.Mutex
) )
func userSQLiteDSN(dbPath string) string { func Init(mainDbPath, userDbPath string) error {
return dbPath + mu.Lock()
"?_pragma=busy_timeout(5000)" + defer mu.Unlock()
"&_pragma=journal_mode(WAL)" +
"&_pragma=synchronous(NORMAL)" 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() { func Close() error {
eng, err := xorm.NewEngine("sqlite", MainDb) mu.Lock()
if err != nil { defer mu.Unlock()
panic(err) 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 UserEng != nil {
if err != nil { if err := UserEng.Close(); err != nil && firstErr == nil {
panic(err) firstErr = err
}
UserEng = nil
} }
eng.SetMaxOpenConns(10)
eng.SetMaxIdleConns(5)
MainEng = eng
eng, err = xorm.NewEngine("sqlite", userSQLiteDSN(UserDb)) return firstErr
if err != nil {
panic(err)
}
err = eng.Ping()
if err != nil {
panic(err)
}
eng.SetMaxOpenConns(10)
eng.SetMaxIdleConns(5)
UserEng = eng
} }
+39
View File
@@ -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
}
+29
View File
@@ -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
}