Compare commits

20 Commits
Author SHA1 Message Date
YumeMichi 2c075cd7bc Document legacy RSA encryption compatibility
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:23 +08:00
YumeMichi 94d48cf82a Report configuration and HTTP startup failures
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:20 +08:00
YumeMichi b1dfc68a0f Handle malformed encrypted requests without panics
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:18 +08:00
YumeMichi aebbc4b889 Abort requests when session transactions cannot start
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:15 +08:00
YumeMichi ff67c554f7 Rollback live sessions on panic
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:12 +08:00
YumeMichi 96b7951ee1 Scope unit and accessory data to owning users
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:10 +08:00
YumeMichi 16ab2549d7 Validate main PHP sessions against user IDs
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:07 +08:00
YumeMichi 683645be8e Fix session cleanup on error paths
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-13 08:03:01 +08:00
YumeMichi cb8f587e30 Unify error handling and HTTP statuses
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-07-11 16:18:17 +08:00
YumeMichi 61b33ae0e3 Improve session abort error context
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-28 18:13:59 +08:00
YumeMichi 36ccf46df5 Fix accessory exp lookup from user data
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-28 18:13:57 +08:00
YumeMichi 4410e53afa Bump dependencies
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-28 01:35:07 +08:00
YumeMichi 46380513bc Backfill migrated unit exp data
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-28 01:35:07 +08:00
YumeMichi 1bb8f20c88 Fix legacy unit schema migration
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-28 01:20:05 +08:00
YumeMichi b276fada4b webui: Fix subnav active state
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-13 18:12:19 +08:00
YumeMichi af63f242da Force relogin after server restart
- Mark all users with force_relogin during startup
- Ensure existing clients are returned to the login screen after a server update or restart

Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-13 11:33:23 +08:00
YumeMichi 4b2c005222 Force relogin after webui data changes
- Add a force_relogin flag to user_pref
- Return 404 for main.php requests when the user must reauthenticate
- Clear the relogin flag after successful client login
- Mark users for relogin after webui card import, accessory changes, and profile updates

Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-13 11:29:57 +08:00
YumeMichi 6264523c76 webui: Add accessory management
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-13 11:04:18 +08:00
YumeMichi cb0200ff29 Migrate accessories to user data
- Add user_accessory as the per-user accessory inventory table.
- Migrate old accessory wear records to user-owned accessory ids, including legacy common_accessory_m lookups for old installs.
- Allocate default accessories for newly created users and update accessory reads to use user_accessory at runtime.
- Validate accessory ownership when wearing accessories and keep existing accessory-based responses working.

Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-13 07:36:21 +08:00
YumeMichi 17336de1b7 Implement achievement list
Signed-off-by: Sean Du <do4suki@gmail.com>
2026-06-13 06:54:32 +08:00
693 changed files with 4274 additions and 3762 deletions
-12
View File
@@ -1,12 +0,0 @@
.gradle/
.idea/
.kotlin/
app/.cxx/
app/release/
app/src/main/assets/
app/src/main/jniLibs/
build/
gradle/
gradlew
gradlew.bat
local.properties
-246
View File
@@ -1,246 +0,0 @@
# Android 控制端
这个目录提供一个 Kotlin/Compose Android App,用来把 `honoka-chan` 作为本地动态库加载,并通过 JNI 控制启停。
## 当前功能
- 启动 / 停止本地 `honoka-chan` 服务
- 直接通过 JNI 查询运行状态、健康信息,并在修改配置后即时重载
- 将指定目录以符号链接方式挂到运行时 `/static/Android`
- 支持导出 / 导入用户数据备份(`data.db`
- 使用前台服务 + `PARTIAL_WAKE_LOCK` 做后台保活
- 首次启动时解压运行时资源包到应用私有目录
- 打包运行时资源时自动生成默认 `config.json`
- 检测到未加入电池优化白名单时,仅弹出说明提示,不强制跳转系统设置
## 目录约定
- Go 动态库放到:`android/app/src/main/jniLibs/<abi>/libhonokachan.so`
- 运行时资源包放到:`android/app/src/main/assets/honoka_runtime.zip`
目前工程里已经预建了:
- `android/app/src/main/jniLibs/arm64-v8a/`
- `android/app/src/main/jniLibs/armeabi-v7a/`
- `android/app/src/main/jniLibs/x86_64/`
- `android/app/src/main/assets/`
## 1. 开发环境要求
Linux 下建议直接使用 `android/scripts/*.sh` 先准备运行时资源与 JNI 动态库,最终 APK 由 Android Studio 构建。
需要准备:
- `Go`
- `zip`
- Android SDK
- Android NDK
- Android Studio
常用环境变量:
```bash
export ANDROID_SDK_ROOT=/path/to/android-sdk
export ANDROID_NDK_HOME=/path/to/android-ndk
```
默认 `MIN_SDK=26`,和当前 Android App 的 `minSdk` 保持一致。如果你确实需要改动原生层最低版本,可以在执行脚本前自行覆盖:
```bash
export MIN_SDK=26
```
建议先跑环境检查:
```bash
./android/scripts/check_env.sh
```
说明:
- 不需要预先在仓库根目录准备 `config.json``prepare_runtime_zip.sh` 会自动生成默认配置并打包进去。
## 2. 一键准备运行时资源
如果环境已经准备好,直接执行:
```bash
./android/scripts/build_all.sh
```
它会依次完成:
1. 打包 `honoka_runtime.zip`
2. 编译 `arm64-v8a``libhonokachan.so`
3. 编译 `armeabi-v7a``libhonokachan.so`
4. 编译 `x86_64``libhonokachan.so`
完成后再用 Android Studio 打开 `android/` 并构建 APK。
## 3. 单步脚本
### 3.1 打包运行时资源
```bash
./android/scripts/prepare_runtime_zip.sh
```
### 3.2 编译 Go 动态库
需要使用 Android NDK 的 clang 作为 `CC`,并启用 `c-shared`
脚本方式:
```bash
./android/scripts/build_go_android.sh arm64-v8a
./android/scripts/build_go_android.sh armeabi-v7a
./android/scripts/build_go_android.sh x86_64
```
手工方式,`arm64-v8a` 示例:
```bash
export ANDROID_NDK_HOME=/path/to/android-ndk
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang"
CGO_ENABLED=1 GOOS=android GOARCH=arm64 \
go build -buildmode=c-shared \
-o android/app/src/main/jniLibs/arm64-v8a/libhonokachan.so \
./cmd/honoka-android
```
手工方式,`armeabi-v7a` 示例:
```bash
export ANDROID_NDK_HOME=/path/to/android-ndk
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi26-clang"
CGO_ENABLED=1 GOOS=android GOARCH=arm GOARM=7 \
go build -buildmode=c-shared \
-o android/app/src/main/jniLibs/armeabi-v7a/libhonokachan.so \
./cmd/honoka-android
```
手工方式,`x86_64` 示例:
```bash
export ANDROID_NDK_HOME=/path/to/android-ndk
export CC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android26-clang"
CGO_ENABLED=1 GOOS=android GOARCH=amd64 \
go build -buildmode=c-shared \
-o android/app/src/main/jniLibs/x86_64/libhonokachan.so \
./cmd/honoka-android
```
说明:
- 这里直接使用 `./cmd/honoka-android`,不需要额外 JNI 头文件。
- Android App 自带的 `honoka_android.cpp` 会在运行时通过 `dlopen("libhonokachan.so")` 找到导出的 `ServerStart` / `ServerStop` / `ServerStatusJSON` 等符号。
- `go build -buildmode=c-shared` 会顺带生成 `libhonokachan.h`,脚本会自动删除这个头文件,因为当前 Android 工程并不会使用它。
## 4. 打包运行时资源
`honoka-chan` 在 Android 上仍然按仓库根目录结构读取文件,所以 zip 包内必须直接包含这些顶层条目:
- `assets/`
- `static/`
- `config.json`
最小建议内容:
- `assets/main.db`
- `assets/serverdata/`
- `assets/certs/`
- `static/templates/`
- `static/css/`
- `static/js/`
- `static/images/`
- `static/font/`
- `config.json`
如果你希望把已有用户数据也一起带进去,可以额外放入:
- `assets/data.db`
`prepare_runtime_zip.sh` 会自动生成一份默认配置,内容等同于服务端 `config.DefaultConfigs()`
- `app_name`: `honoka-chan`
- `settings.listen_port`: `8080`
- `settings.cdn_server`: `http://127.0.0.1:8080/static`
- `settings.reload_token`: `""`
- `settings.unlock_all_special_rotation`: `false`
手工打包命令:
```bash
zip -r android/app/src/main/assets/honoka_runtime.zip assets static config.json
```
注意:
- 压缩包根目录下必须直接看到 `assets``static``config.json`,不要多包一层目录。
- App 会记录 `honoka_runtime.zip` 的内容哈希;如果资源包发生变化,下次启动服务或刷新挂载时会自动重新解压。
- 自动重新解压时会保留运行时目录里现有的 `config.json`,避免覆盖用户已经修改过的服务设置。
- 自动重新解压时也会保留运行时目录里现有的 `assets/data.db` 及其 `-wal/-shm`,避免覆盖用户数据。
## 5. 构建 APK
当前不再提供命令行脚本构建 APK,直接使用 Android Studio 打开 `android/` 构建即可。
如果你主要用 Android Studio 模拟器测试,建议保留 `x86_64` ABI;很多模拟器镜像不会加载仅包含 ARM so 的 APK。
## 6. 数据目录
App 会把你选择的目录挂到运行时:
- `/static/Android`
实现方式不是 Linux bind mount,而是:
- 在应用私有运行时目录中创建 `static/Android -> 你的目录` 的符号链接
默认数据目录:
- `/sdcard/Download/data`
如果 Android 版本较高,点击“选择目录”时会先跳转系统设置申请“所有文件访问权限”,授权后自动继续打开目录选择器。
## 7. 备份导入导出
- 备份导出和导入的对象都是运行时目录里的 `assets/data.db`
- 执行前需要先停止服务,避免数据库仍在使用中
- 导出前 App 会先执行一次 `wal_checkpoint(TRUNCATE)`,这样单独导出的 `data.db` 就是完整的
- 导入备份时会提示“将覆盖当前用户数据,是否继续”
## 8. 运行流程
1. 准备好 `libhonokachan.so`
2. 准备好 `honoka_runtime.zip`
3. 用 Android Studio 构建并安装 APK
4. 打开 App
5. 首次进入时按系统提示授予通知权限
6. 根据需要处理“所有文件访问权限”与数据目录选择
7. 如有需要,打开“解锁全部日替”开关
8. 点击“启动服务”
9. 用“刷新状态”确认当前进程健康状态正常
说明:
- 如果系统尚未将本应用加入电池优化白名单,App 只会弹出说明提示,不会自动跳转设置页。
- 即使没有加入白名单,服务仍然可以启动。
- 在部分国产系统上,如果退到后台后服务仍会断开,除了忽略电池优化、自启动和后台运行,还可能需要在最近任务中手动锁定本应用。
## 9. 其他说明
- 这个 Android 子目录默认只提交源码与脚本;`gradlew``gradle/`、构建产物、`jniLibs/``assets/` 等本地生成内容当前都被 `android/.gitignore` 排除了。
- 如果你希望把 Gradle wrapper 一起纳入仓库,可以自行调整 `android/.gitignore`
- 前台服务会持有 `PARTIAL_WAKE_LOCK`,停止服务时会释放。
- App 内部的状态、健康检查、配置重载都直接通过 JNI 调用 Go 导出符号。
- `/system/health``/system/reload` 仍然保留,方便外部调试、脚本探活和浏览器访问。
- Android Studio 升级到 AGP `8.11.2` 后,如果项目放在 Samba 映射盘上,可能会出现 `Cannot create directory .../merged.dir/values` 之类的 AAPT 目录创建失败。这个更像文件系统兼容问题,建议改回本地磁盘构建。
## 10. Reload 与资源更新说明
- App 内部修改服务设置时会直接在进程内执行 reload
- `/system/reload` HTTP 接口只用于外部调用,仍然保留 token 校验
- 无论通过 JNI 还是 HTTP,reload 都只会重新加载运行时目录中的 `config.json`
- 它不会重新解压 `honoka_runtime.zip`
- 如果你更新了数据库、证书、serverdata、模板等资源,需要重新生成 `honoka_runtime.zip` 并重新安装或覆盖资源包
-93
View File
@@ -1,93 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "me.killkiss.honokactrl"
compileSdk = 36
defaultConfig {
applicationId = "me.killkiss.honokactrl"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
externalNativeBuild {
cmake {
cppFlags += "-std=c++20"
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
implementation("androidx.core:core-ktx:1.18.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("com.google.android.material:material:1.14.0")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation(composeBom)
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
-1
View File
@@ -1 +0,0 @@
# No custom ProGuard rules yet.
-41
View File
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.HonokaControl"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.HonokaServerService"
android:enabled="true"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
-18
View File
@@ -1,18 +0,0 @@
cmake_minimum_required(VERSION 3.22.1)
project(honoka_android)
add_library(
honoka_android
SHARED
honoka_android.cpp
)
find_library(log-lib log)
find_library(dl-lib dl)
target_link_libraries(
honoka_android
${log-lib}
${dl-lib}
)
-133
View File
@@ -1,133 +0,0 @@
#include <jni.h>
#include <dlfcn.h>
#include <mutex>
#include <string>
namespace {
using server_start_fn = char* (*)(const char*);
using server_stop_fn = char* (*)();
using server_status_fn = char* (*)();
using server_health_fn = char* (*)();
using server_reload_fn = char* (*)();
using server_free_string_fn = void (*)(char*);
std::once_flag g_init_once;
void* g_handle = nullptr;
server_start_fn g_start = nullptr;
server_stop_fn g_stop = nullptr;
server_status_fn g_status = nullptr;
server_health_fn g_health = nullptr;
server_reload_fn g_reload = nullptr;
server_free_string_fn g_free_string = nullptr;
std::string g_error;
void InitServerSymbols() {
g_handle = dlopen("libhonokachan.so", RTLD_NOW | RTLD_GLOBAL);
if (g_handle == nullptr) {
g_error = dlerror();
return;
}
g_start = reinterpret_cast<server_start_fn>(dlsym(g_handle, "ServerStart"));
g_stop = reinterpret_cast<server_stop_fn>(dlsym(g_handle, "ServerStop"));
g_status = reinterpret_cast<server_status_fn>(dlsym(g_handle, "ServerStatusJSON"));
g_health = reinterpret_cast<server_health_fn>(dlsym(g_handle, "ServerHealthJSON"));
g_reload = reinterpret_cast<server_reload_fn>(dlsym(g_handle, "ServerReload"));
g_free_string = reinterpret_cast<server_free_string_fn>(dlsym(g_handle, "ServerFreeString"));
if (g_start == nullptr || g_stop == nullptr || g_status == nullptr || g_health == nullptr ||
g_reload == nullptr || g_free_string == nullptr) {
g_error = "missing exported symbols from libhonokachan.so";
}
}
const char* EnsureServerReady() {
std::call_once(g_init_once, InitServerSymbols);
if (!g_error.empty()) {
return g_error.c_str();
}
return nullptr;
}
jstring CallStringResult(JNIEnv* env, char* result) {
if (result == nullptr) {
return nullptr;
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
} // namespace
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStart(JNIEnv* env, jobject, jstring work_dir) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
const char* work_dir_chars = env->GetStringUTFChars(work_dir, nullptr);
char* result = g_start(work_dir_chars);
env->ReleaseStringUTFChars(work_dir, work_dir_chars);
return CallStringResult(env, result);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStop(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
return CallStringResult(env, g_stop());
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeStatusJson(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
std::string json = std::string("{\"running\":false,\"last_error\":\"") + error + "\"}";
return env->NewStringUTF(json.c_str());
}
char* result = g_status();
if (result == nullptr) {
return env->NewStringUTF("{\"running\":false,\"last_error\":\"status unavailable\"}");
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeHealthJson(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
std::string json = std::string("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"") + error + "\\\"}";
return env->NewStringUTF(json.c_str());
}
char* result = g_health();
if (result == nullptr) {
return env->NewStringUTF("{\\\"status\\\":\\\"error\\\",\\\"message\\\":\\\"health unavailable\\\"}");
}
jstring java_string = env->NewStringUTF(result);
g_free_string(result);
return java_string;
}
extern "C"
JNIEXPORT jstring JNICALL
Java_me_killkiss_honokactrl_jni_NativeBridge_nativeReload(JNIEnv* env, jobject) {
const char* error = EnsureServerReady();
if (error != nullptr) {
return env->NewStringUTF(error);
}
return CallStringResult(env, g_reload());
}
@@ -1,340 +0,0 @@
package me.killkiss.honokactrl
import android.Manifest
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.PowerManager
import android.provider.DocumentsContract
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import me.killkiss.honokactrl.ui.MainScreen
import me.killkiss.honokactrl.ui.MainViewModel
import me.killkiss.honokactrl.theme.HonokaControlTheme
import me.killkiss.honokactrl.storage.RuntimePreparer
class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MainViewModel>()
private var pendingDirectoryPicker = false
private var pendingStartServer = false
private var batteryOptimizationDialogShown = false
private val runtimePreparer by lazy { RuntimePreparer(this) }
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val openDocumentTreeLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
handleSelectedDirectory(uri)
}
private val exportBackupLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
if (uri != null) {
exportBackupToUri(uri)
}
}
private val importBackupLauncher =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
confirmImportBackup(uri)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
HonokaControlTheme {
Surface(modifier = Modifier.fillMaxSize()) {
MainScreen(
viewModel = viewModel,
onToggleServer = { handleToggleServer() },
onOpenLocalUrl = { openLocalUrl() },
onPickDataDirectory = { openDataDirectoryPicker() },
onExportBackup = { openExportBackup() },
onImportBackup = { openImportBackup() },
)
}
}
}
requestNotificationPermission()
}
override fun onResume() {
super.onResume()
viewModel.refreshAllFilesAccess(hasAllFilesAccess())
viewModel.refreshStatus()
maybeShowBatteryOptimizationDialog()
if (pendingStartServer && hasAllFilesAccess()) {
pendingStartServer = false
viewModel.toggleServer()
}
if (pendingDirectoryPicker && hasAllFilesAccess()) {
pendingDirectoryPicker = false
openDocumentTreeLauncher.launch(null)
}
}
private fun requestNotificationPermission() {
if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
private fun maybeShowBatteryOptimizationDialog() {
if (batteryOptimizationDialogShown || isIgnoringBatteryOptimizations()) {
return
}
batteryOptimizationDialogShown = true
AlertDialog.Builder(this)
.setTitle("允许后台运行")
.setMessage("当前还未为本应用开启后台保活相关设置。建议手动在系统设置中开启忽略电池优化、自启动和后台运行。服务现在仍可正常启动;如果在国产系统上退到后台后仍会断开,还需要在最近任务中将本应用锁定。")
.setPositiveButton("知道了", null)
.show()
}
private fun openDataDirectoryPicker() {
if (!hasAllFilesAccess()) {
pendingDirectoryPicker = true
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续选择目录")
openAllFilesAccessSettings()
return
}
openDocumentTreeLauncher.launch(null)
}
private fun handleToggleServer() {
if (viewModel.uiState.value.status.running) {
viewModel.toggleServer()
return
}
if (!hasAllFilesAccess()) {
pendingStartServer = true
viewModel.showMessage("请先授予全部文件访问权限,授权后会自动继续启动服务")
openAllFilesAccessSettings()
return
}
viewModel.toggleServer()
}
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true
}
val powerManager = getSystemService(PowerManager::class.java)
return powerManager?.isIgnoringBatteryOptimizations(packageName) == true
}
private fun openLocalUrl() {
val localUrl = viewModel.uiState.value.status.localUrl
if (localUrl.isBlank()) {
viewModel.showMessage("当前没有可打开的本地地址")
return
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(localUrl))
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
viewModel.showMessage("没有可用于打开本地地址的浏览器")
}
}
private fun openExportBackup() {
if (!ensureBackupOperationAllowed()) {
return
}
exportBackupLauncher.launch("honoka-data.db")
}
private fun openImportBackup() {
if (!ensureBackupOperationAllowed()) {
return
}
importBackupLauncher.launch(arrayOf("*/*"))
}
private fun openAllFilesAccessSettings() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return
}
val uri = Uri.parse("package:$packageName")
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri)
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION))
}
}
private fun hasAllFilesAccess(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()
}
private fun ensureBackupOperationAllowed(): Boolean {
if (viewModel.uiState.value.status.running) {
viewModel.showMessage("请先停止服务后再导入或导出备份")
return false
}
return true
}
private fun handleSelectedDirectory(uri: Uri?) {
if (uri == null) {
return
}
runCatching {
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
val path = treeUriToPath(uri)
if (path != null) {
viewModel.setDataDirPath(path)
} else {
viewModel.showMessage("无法解析所选目录,请改用外部存储目录")
}
}
private fun treeUriToPath(uri: Uri): String? {
val documentId = DocumentsContract.getTreeDocumentId(uri)
val parts = documentId.split(":", limit = 2)
val volume = parts.getOrNull(0) ?: return null
val relativePath = parts.getOrNull(1).orEmpty()
val basePath = when {
volume.equals("primary", ignoreCase = true) -> Environment.getExternalStorageDirectory().absolutePath
else -> "/storage/$volume"
}
return if (relativePath.isBlank()) {
basePath
} else {
File(basePath, relativePath).absolutePath
}
}
private fun exportBackupToUri(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = true)
checkpointUserDatabase(dataDbFile)
contentResolver.openOutputStream(uri)?.use { output ->
dataDbFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: error("无法打开导出目标文件")
}.onSuccess {
withContext(Dispatchers.Main) {
viewModel.showMessage("备份已导出")
}
}.onFailure {
withContext(Dispatchers.Main) {
viewModel.showMessage(it.message ?: "导出备份失败")
}
}
}
}
private fun confirmImportBackup(uri: Uri) {
AlertDialog.Builder(this)
.setTitle("导入备份")
.setMessage("导入备份会覆盖当前用户数据,是否继续?")
.setNegativeButton("取消", null)
.setPositiveButton("继续") { _, _ ->
importBackupFromUri(uri)
}
.show()
}
private fun importBackupFromUri(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val dataDbFile = ensureRuntimeDataDbFile(requireExisting = false)
val tempFile = File(dataDbFile.parentFile, "${dataDbFile.name}.importing")
contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
} ?: error("无法打开备份文件")
deleteUserDatabaseSidecars(dataDbFile)
if (dataDbFile.exists()) {
dataDbFile.delete()
}
if (!tempFile.renameTo(dataDbFile)) {
tempFile.inputStream().use { input ->
dataDbFile.outputStream().use { output ->
input.copyTo(output)
}
}
tempFile.delete()
}
deleteUserDatabaseSidecars(dataDbFile)
}.onSuccess {
withContext(Dispatchers.Main) {
viewModel.showMessage("备份已导入,下次启动服务时生效")
}
}.onFailure {
withContext(Dispatchers.Main) {
viewModel.showMessage(it.message ?: "导入备份失败")
}
}
}
}
private fun ensureRuntimeDataDbFile(requireExisting: Boolean): File {
val runtimeInfo = runtimePreparer.prepare(viewModel.uiState.value.dataDirPath, forceRedeploy = false)
.getOrElse { throw it }
val dataDbFile = File(runtimeInfo.runtimeRoot, "assets/data.db")
dataDbFile.parentFile?.mkdirs()
if (requireExisting && !dataDbFile.exists()) {
throw IllegalStateException("当前没有可用的用户数据文件")
}
return dataDbFile
}
private fun checkpointUserDatabase(dataDbFile: File) {
val db = SQLiteDatabase.openDatabase(dataDbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
db.use {
it.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { cursor ->
while (cursor.moveToNext()) {
}
}
}
}
private fun deleteUserDatabaseSidecars(dataDbFile: File) {
File(dataDbFile.parentFile, "${dataDbFile.name}-wal").delete()
File(dataDbFile.parentFile, "${dataDbFile.name}-shm").delete()
}
}
@@ -1,137 +0,0 @@
package me.killkiss.honokactrl.jni
import org.json.JSONObject
data class NativeServerStatus(
val running: Boolean = false,
val workDir: String = "",
val listenPort: String = "",
val localUrl: String = "",
val startedAt: String = "",
val lastError: String = "",
)
data class NativeReloadResult(
val success: Boolean,
val message: String,
val reloadedAt: String = "",
)
data class NativeHealthInfo(
val status: String = "",
val appName: String = "",
val version: String = "",
val startedAt: String = "",
val uptimeSeconds: Long = 0,
val lastReloadAt: String = "",
val listenPort: String = "",
val cdnServer: String = "",
val reloadTokenConfigured: Boolean = false,
val mainDb: String = "",
val userDb: String = "",
val message: String = "",
)
object NativeBridge {
private val loadError: String? by lazy {
runCatching { System.loadLibrary("honoka_android") }
.exceptionOrNull()
?.message
}
private external fun nativeStart(workDir: String): String?
private external fun nativeStop(): String?
private external fun nativeStatusJson(): String
private external fun nativeHealthJson(): String
private external fun nativeReload(): String?
fun start(workDir: String): Result<Unit> {
val error = ensureLoaded() ?: nativeStart(workDir)
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
}
fun stop(): Result<Unit> {
val error = ensureLoaded() ?: nativeStop()
return if (error == null) Result.success(Unit) else Result.failure(IllegalStateException(error))
}
fun status(): NativeServerStatus {
val error = ensureLoaded()
if (error != null) {
return NativeServerStatus(lastError = error)
}
return runCatching {
val json = JSONObject(nativeStatusJson())
NativeServerStatus(
running = json.optBoolean("running"),
workDir = json.optString("work_dir"),
listenPort = json.optString("listen_port"),
localUrl = json.optString("local_url"),
startedAt = json.optString("started_at"),
lastError = json.optString("last_error"),
)
}.getOrElse {
NativeServerStatus(lastError = it.message ?: "failed to parse native status")
}
}
private fun healthJson(): String {
val error = ensureLoaded()
if (error != null) {
return """{"status":"error","message":${JSONObject.quote(error)}}"""
}
return nativeHealthJson()
}
fun health(): NativeHealthInfo {
val raw = healthJson()
return runCatching {
val json = JSONObject(raw)
NativeHealthInfo(
status = json.optString("status"),
appName = json.optString("app_name"),
version = json.optString("version"),
startedAt = json.optString("started_at"),
uptimeSeconds = json.optLong("uptime_seconds"),
lastReloadAt = json.optString("last_reload_at"),
listenPort = json.optString("listen_port"),
cdnServer = json.optString("cdn_server"),
reloadTokenConfigured = json.optBoolean("reload_token_configured"),
mainDb = json.optString("main_db"),
userDb = json.optString("user_db"),
message = json.optString("message"),
)
}.getOrElse {
NativeHealthInfo(
status = "error",
message = it.message ?: "failed to parse health response",
)
}
}
fun reload(): NativeReloadResult {
val error = ensureLoaded()
if (error != null) {
return NativeReloadResult(success = false, message = error)
}
val result = nativeReload()
if (result == null) {
return NativeReloadResult(success = true, message = "configuration reloaded")
}
return runCatching {
val json = JSONObject(result)
NativeReloadResult(
success = json.optString("status") == "ok",
message = json.optString("message").ifBlank { "reload failed" },
reloadedAt = json.optString("reloaded_at"),
)
}.getOrElse {
NativeReloadResult(success = false, message = it.message ?: "reload failed")
}
}
private fun ensureLoaded(): String? = loadError
}
@@ -1,168 +0,0 @@
package me.killkiss.honokactrl.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import me.killkiss.honokactrl.MainActivity
import me.killkiss.honokactrl.R
import me.killkiss.honokactrl.jni.NativeBridge
class HonokaServerService : Service() {
companion object {
private const val TAG = "HonokaServer"
private var wakeLock: PowerManager.WakeLock? = null
private const val CHANNEL_ID = "honoka_server"
private const val NOTIFICATION_ID = 1001
const val ACTION_START = "me.killkiss.honokactrl.action.START"
const val ACTION_STOP = "me.killkiss.honokactrl.action.STOP"
const val EXTRA_RUNTIME_ROOT = "runtime_root"
fun startIntent(context: Context, runtimeRoot: String): Intent {
return Intent(context, HonokaServerService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_RUNTIME_ROOT, runtimeRoot)
}
}
fun stopIntent(context: Context): Intent {
return Intent(context, HonokaServerService::class.java).apply {
action = ACTION_STOP
}
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var runtimeRoot: String? = null
override fun onCreate() {
super.onCreate()
Log.i(TAG, "service created")
ensureChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand: action=${intent?.action}, startId=$startId")
when (intent?.action) {
ACTION_START -> {
startForeground(NOTIFICATION_ID, buildNotification("正在启动服务"))
acquireWakeLock()
runtimeRoot = intent.getStringExtra(EXTRA_RUNTIME_ROOT)
if (runtimeRoot.isNullOrBlank()) {
Log.e(TAG, "missing runtime root")
updateNotification("缺少运行目录")
stopSelf()
return START_REDELIVER_INTENT
}
scope.launch {
Log.i(TAG, "native start begin, runtimeRoot=$runtimeRoot")
val result = NativeBridge.start(runtimeRoot!!)
if (result.isSuccess) {
Log.i(TAG, "native start success")
updateNotification("服务运行中")
} else {
Log.e(TAG, "native start failed", result.exceptionOrNull())
updateNotification(result.exceptionOrNull()?.message ?: "启动失败")
releaseWakeLock()
stopSelf()
}
}
}
ACTION_STOP -> {
scope.launch {
Log.i(TAG, "native stop begin")
NativeBridge.stop()
Log.i(TAG, "native stop finished")
updateNotification("服务已停止")
stopForeground(STOP_FOREGROUND_REMOVE)
releaseWakeLock()
stopSelf()
}
return START_NOT_STICKY
}
}
return START_REDELIVER_INTENT
}
override fun onDestroy() {
Log.i(TAG, "service destroyed")
scope.cancel()
releaseWakeLock()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(content: String): Notification {
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_server)
.setContentTitle(getString(R.string.app_name))
.setContentText(content)
.setContentIntent(openIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(content: String) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(content))
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID,
"Honoka Server",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "honoka-chan background service"
}
manager.createNotificationChannel(channel)
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) {
return
}
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "honoka:server").apply {
setReferenceCounted(false)
acquire()
}
Log.i(TAG, "wakelock acquired")
}
private fun releaseWakeLock() {
wakeLock?.takeIf { it.isHeld }?.release()
wakeLock = null
Log.i(TAG, "wakelock released")
}
}
@@ -1,164 +0,0 @@
package me.killkiss.honokactrl.storage
import android.content.Context
import android.os.Build
import android.system.Os
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.Files
import java.security.MessageDigest
import java.util.zip.ZipInputStream
class RuntimePreparer(private val context: Context) {
fun prepare(dataDirPath: String, forceRedeploy: Boolean = false): Result<RuntimeInfo> {
return runCatching {
val runtimeRoot = File(context.filesDir, RUNTIME_DIR_NAME)
val bundleHash = calculateBundleHash()
Log.i(TAG, "prepare runtime root=${runtimeRoot.absolutePath}, dataDir=$dataDirPath, forceRedeploy=$forceRedeploy")
if (forceRedeploy || shouldRedeployBundle(runtimeRoot, bundleHash)) {
redeployBundle(runtimeRoot, bundleHash)
}
mountAndroidDir(runtimeRoot, File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }))
RuntimeInfo(
runtimeRoot = runtimeRoot,
mountedDataDir = File(dataDirPath.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }),
configFile = File(runtimeRoot, "config.json"),
deployedBundleHash = bundleHash,
)
}
}
private fun redeployBundle(runtimeRoot: File, bundleHash: String) {
Log.i(TAG, "redeploy bundle into ${runtimeRoot.absolutePath}")
val preservedFiles = PRESERVED_RUNTIME_FILES.mapNotNull { relativePath ->
File(runtimeRoot, relativePath).takeIf { it.exists() }?.let { relativePath to it.readBytes() }
}.toMap()
if (runtimeRoot.exists()) {
runtimeRoot.deleteRecursively()
}
runtimeRoot.mkdirs()
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
ZipInputStream(input).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val outFile = File(runtimeRoot, entry.name)
if (preservedFiles.containsKey(entry.name)) {
zip.closeEntry()
entry = zip.nextEntry
continue
}
if (entry.isDirectory) {
outFile.mkdirs()
} else {
outFile.parentFile?.mkdirs()
FileOutputStream(outFile).use { output ->
zip.copyTo(output)
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
preservedFiles.forEach { (relativePath, data) ->
File(runtimeRoot, relativePath).apply {
parentFile?.mkdirs()
writeBytes(data)
}
}
File(runtimeRoot, BUNDLE_MARKER_FILE).writeText(bundleHash)
Log.i(TAG, "bundle deployed successfully")
}
private fun shouldRedeployBundle(runtimeRoot: File, bundleHash: String): Boolean {
val markerFile = File(runtimeRoot, BUNDLE_MARKER_FILE)
if (!markerFile.exists()) {
return true
}
val deployedHash = runCatching { markerFile.readText().trim() }.getOrDefault("")
val changed = deployedHash != bundleHash
if (changed) {
Log.i(TAG, "runtime bundle hash changed, redeploy required")
}
return changed
}
private fun calculateBundleHash(): String {
val digest = MessageDigest.getInstance("SHA-256")
context.assets.open(BUNDLE_ASSET_NAME).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read <= 0) {
break
}
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun mountAndroidDir(runtimeRoot: File, targetDir: File) {
if (!targetDir.exists()) {
targetDir.mkdirs()
}
Log.i(TAG, "mount Android dir target=${targetDir.absolutePath}")
val androidDir = File(runtimeRoot, "static/Android")
androidDir.parentFile?.mkdirs()
if (isSymlink(androidDir)) {
androidDir.delete()
} else if (androidDir.exists()) {
androidDir.deleteRecursively()
}
Os.symlink(targetDir.absolutePath, androidDir.absolutePath)
Log.i(TAG, "symlink created: ${androidDir.absolutePath} -> ${targetDir.absolutePath}")
}
private fun isSymlink(file: File): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.isSymbolicLink(file.toPath())
} else {
try {
file.canonicalFile != file.absoluteFile
} catch (_: IOException) {
false
}
}
}
companion object {
private const val TAG = "HonokaServer"
const val BUNDLE_ASSET_NAME = "honoka_runtime.zip"
const val RUNTIME_DIR_NAME = "honoka-runtime"
private const val BUNDLE_MARKER_FILE = ".bundle_ready"
private val PRESERVED_RUNTIME_FILES = listOf(
"config.json",
"assets/data.db",
"assets/data.db-wal",
"assets/data.db-shm",
)
fun readDeployedBundleHash(runtimeRoot: File): String {
return runCatching {
File(runtimeRoot, BUNDLE_MARKER_FILE).takeIf { it.exists() }?.readText()?.trim().orEmpty()
}.getOrDefault("")
}
}
}
data class RuntimeInfo(
val runtimeRoot: File,
val mountedDataDir: File,
val configFile: File,
val deployedBundleHash: String,
)
@@ -1,19 +0,0 @@
package me.killkiss.honokactrl.storage
import android.content.Context
class SettingsStore(context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun loadDataDirPath(): String = prefs.getString(KEY_DATA_DIR_PATH, DEFAULT_DATA_DIR_PATH) ?: DEFAULT_DATA_DIR_PATH
fun saveDataDirPath(path: String) {
prefs.edit().putString(KEY_DATA_DIR_PATH, path).apply()
}
companion object {
private const val PREFS_NAME = "honoka_control"
private const val KEY_DATA_DIR_PATH = "data_dir_path"
const val DEFAULT_DATA_DIR_PATH = "/sdcard/Download/data"
}
}
@@ -1,46 +0,0 @@
package me.killkiss.honokactrl.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val HonokaLightScheme = lightColorScheme(
primary = Color(0xFFB73E3E),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFDAD6),
onPrimaryContainer = Color(0xFF410006),
secondary = Color(0xFF775651),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFFFDAD6),
onSecondaryContainer = Color(0xFF2C1512),
background = Color(0xFFFFF8F6),
onBackground = Color(0xFF241917),
surface = Color(0xFFFFF8F6),
onSurface = Color(0xFF241917),
)
private val HonokaDarkScheme = darkColorScheme(
primary = Color(0xFFFFB4AB),
onPrimary = Color(0xFF69000F),
primaryContainer = Color(0xFF93001D),
onPrimaryContainer = Color(0xFFFFDAD6),
secondary = Color(0xFFE7BDB7),
onSecondary = Color(0xFF442925),
secondaryContainer = Color(0xFF5D3F3A),
onSecondaryContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1110),
onBackground = Color(0xFFF1DFDC),
surface = Color(0xFF1A1110),
onSurface = Color(0xFFF1DFDC),
)
@Composable
fun HonokaControlTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) HonokaDarkScheme else HonokaLightScheme,
content = content,
)
}
@@ -1,275 +0,0 @@
package me.killkiss.honokactrl.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import me.killkiss.honokactrl.R
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
onToggleServer: () -> Unit,
onOpenLocalUrl: () -> Unit,
onPickDataDirectory: () -> Unit,
onExportBackup: () -> Unit,
onImportBackup: () -> Unit,
) {
val state by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
CenterAlignedTopAppBar(title = { Text(stringResource(R.string.app_name)) })
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("服务控制", style = MaterialTheme.typography.titleMedium)
Text(
if (state.status.running) "当前状态:运行中" else "当前状态:已停止",
style = MaterialTheme.typography.bodyLarge,
)
if (state.status.localUrl.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "本地地址:${state.status.localUrl}",
modifier = Modifier.weight(1f),
)
IconButton(onClick = onOpenLocalUrl, modifier = Modifier.size(36.dp)) {
Icon(
Icons.Outlined.Language,
contentDescription = "打开本地地址",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
)
}
}
}
if (state.status.startedAt.isNotBlank()) {
Text("启动时间:${state.status.startedAt}")
}
if (state.status.lastError.isNotBlank()) {
Text("最近错误:${state.status.lastError}", color = MaterialTheme.colorScheme.error)
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onToggleServer,
enabled = !state.busy,
modifier = Modifier.widthIn(min = 132.dp),
) {
Icon(
if (state.status.running) Icons.Outlined.Stop else Icons.Outlined.PlayArrow,
contentDescription = null,
)
Text(if (state.status.running) "停止服务" else "启动服务")
}
OutlinedButton(
onClick = { viewModel.refreshStatus() },
enabled = !state.busy,
modifier = Modifier.widthIn(min = 132.dp),
) {
Icon(Icons.Outlined.Refresh, contentDescription = null)
Text("刷新状态")
}
}
}
}
if (state.message.isNotBlank()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Text(
text = state.message,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("服务设置", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("解锁全部日替", style = MaterialTheme.typography.bodyLarge)
Text(
"切换后会直接写入配置文件,并在服务运行中自动重载。",
style = MaterialTheme.typography.bodySmall,
)
}
Switch(
checked = state.unlockAllSpecialRotation,
onCheckedChange = { viewModel.updateUnlockAllSpecialRotation(it) },
enabled = !state.busy,
)
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("数据目录", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = state.dataDirPath,
onValueChange = {},
modifier = Modifier.fillMaxWidth(),
label = { Text("Android 数据目录") },
leadingIcon = { Icon(Icons.Outlined.Storage, contentDescription = null) },
readOnly = true,
singleLine = true,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onPickDataDirectory) {
Icon(Icons.Outlined.FolderOpen, contentDescription = null)
Text("选择目录")
}
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("备份管理", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onExportBackup) {
Text("导出备份")
}
OutlinedButton(onClick = onImportBackup) {
Text("导入备份")
}
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("运行时信息", style = MaterialTheme.typography.titleMedium)
LabelValue(label = "运行目录", value = state.runtimeRoot)
LabelValue(label = "配置文件", value = state.configPath)
LabelValue(label = "挂载目录", value = state.dataDirPath)
LabelValue(label = "资源包哈希", value = state.deployedBundleHash)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Health 状态", style = MaterialTheme.typography.titleMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = state.health.status == "ok",
onClick = {},
label = { Text(if (state.health.status.isBlank()) "未知" else state.health.status) },
)
FilterChip(
selected = state.health.mainDb == "ok",
onClick = {},
label = { Text("Main DB: ${if (state.health.mainDb == "ok") "connected" else "disconnected"}") },
)
FilterChip(
selected = state.health.userDb == "ok",
onClick = {},
label = { Text("User DB: ${if (state.health.userDb == "ok") "connected" else "disconnected"}") },
)
}
LabelValue(label = "应用名", value = state.health.appName)
LabelValue(label = "版本", value = state.health.version)
LabelValue(label = "监听端口", value = state.health.listenPort)
LabelValue(label = "启动时间", value = state.health.startedAt)
LabelValue(label = "运行时长", value = formatUptime(state.health.uptimeSeconds))
LabelValue(label = "最近重载", value = state.health.lastReloadAt)
LabelValue(label = "CDN", value = state.health.cdnServer)
LabelValue(
label = "Reload Token",
value = if (state.health.reloadTokenConfigured) "已配置" else "未配置",
)
if (state.health.message.isNotBlank()) {
Text(
text = state.health.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
@Composable
private fun LabelValue(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(value.ifBlank { "-" }, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatUptime(seconds: Long): String {
if (seconds <= 0) {
return "-"
}
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val remainSeconds = seconds % 60
return "%02d:%02d:%02d".format(hours, minutes, remainSeconds)
}
@@ -1,390 +0,0 @@
package me.killkiss.honokactrl.ui
import android.app.Application
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import me.killkiss.honokactrl.jni.NativeBridge
import me.killkiss.honokactrl.jni.NativeHealthInfo
import me.killkiss.honokactrl.jni.NativeReloadResult
import me.killkiss.honokactrl.jni.NativeServerStatus
import me.killkiss.honokactrl.service.HonokaServerService
import me.killkiss.honokactrl.storage.RuntimePreparer
import me.killkiss.honokactrl.storage.SettingsStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
data class MainUiState(
val dataDirPath: String = SettingsStore.DEFAULT_DATA_DIR_PATH,
val runtimeRoot: String = "",
val configPath: String = "",
val deployedBundleHash: String = "",
val allFilesAccessGranted: Boolean = false,
val status: NativeServerStatus = NativeServerStatus(),
val health: NativeHealthInfo = NativeHealthInfo(),
val unlockAllSpecialRotation: Boolean = false,
val message: String = "",
val busy: Boolean = false,
)
class MainViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "HonokaServer"
}
private val settingsStore = SettingsStore(application)
private val runtimePreparer = RuntimePreparer(application)
private var messageClearJob: Job? = null
private val _uiState = MutableStateFlow(
MainUiState(
dataDirPath = settingsStore.loadDataDirPath(),
allFilesAccessGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager(),
)
)
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
init {
refreshStatus()
refreshRuntimePaths()
}
fun setDataDirPath(path: String) {
val normalizedPath = path.ifBlank { SettingsStore.DEFAULT_DATA_DIR_PATH }
settingsStore.saveDataDirPath(normalizedPath)
_uiState.value = _uiState.value.copy(dataDirPath = normalizedPath)
applyDataDirMount()
}
fun showMessage(message: String) {
pushMessage(message)
}
fun toggleServer() {
if (uiState.value.status.running) {
stopServer()
} else {
startServer()
}
}
fun startServer() {
if (!uiState.value.allFilesAccessGranted) {
Log.w(TAG, "start aborted: all files access is not granted")
pushMessage("请先授予全部文件访问权限")
return
}
Log.i(TAG, "start requested, dataDir=${uiState.value.dataDirPath}")
prepareRuntime { runtimeRoot ->
val context = getApplication<Application>()
val intent = HonokaServerService.startIntent(context, runtimeRoot)
try {
Log.i(TAG, "starting foreground service, runtimeRoot=$runtimeRoot")
ContextCompat.startForegroundService(context, intent)
_uiState.value = _uiState.value.copy(busy = true)
pushMessage("正在启动服务", autoClearMillis = null)
refreshServerState(
initialDelayMillis = 500,
retryCount = 8,
retryDelayMillis = 400,
expectedRunning = true,
updateMessage = true,
)
} catch (e: Throwable) {
Log.e(TAG, "failed to start foreground service", e)
_uiState.value = _uiState.value.copy(busy = false)
pushMessage(e.message ?: "启动前台服务失败")
}
}
}
fun stopServer() {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "stop requested")
_uiState.value = _uiState.value.copy(busy = true)
val context = getApplication<Application>()
context.startService(HonokaServerService.stopIntent(context))
refreshServerState(
initialDelayMillis = 200,
retryCount = 6,
retryDelayMillis = 300,
expectedRunning = false,
updateMessage = true,
)
}
}
fun refreshStatus(delayed: Boolean = false) {
refreshServerState(initialDelayMillis = if (delayed) 800 else 0, updateMessage = false)
}
fun refreshAllFilesAccess(granted: Boolean) {
_uiState.value = _uiState.value.copy(allFilesAccessGranted = granted)
}
fun updateUnlockAllSpecialRotation(enabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = _uiState.value.copy(
busy = true,
unlockAllSpecialRotation = enabled,
)
val configFile = File(_uiState.value.configPath.ifBlank {
File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
.resolve("config.json")
.absolutePath
})
runCatching {
writeUnlockAllSpecialRotation(configFile, enabled)
}.onFailure {
Log.e(TAG, "failed to update config", it)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(
busy = false,
unlockAllSpecialRotation = !enabled,
)
pushMessage(it.message ?: "更新配置失败")
}
return@launch
}
if (!uiState.value.status.running) {
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
pushMessage("设置已保存,下次启动服务时生效")
}
return@launch
}
val result: NativeReloadResult = NativeBridge.reload()
Log.i(TAG, "reload requested by config switch: success=${result.success}, message=${result.message}")
withContext(Dispatchers.Main) {
if (result.success) {
pushMessage("设置已保存并重载")
refreshServerState(initialDelayMillis = 200, updateMessage = false)
} else {
_uiState.value = _uiState.value.copy(
busy = false,
)
pushMessage(result.message.ifBlank { "设置已保存,但重载配置失败" })
}
}
}
}
private fun applyDataDirMount() {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "apply data dir mount: ${_uiState.value.dataDirPath}")
_uiState.value = _uiState.value.copy(busy = true)
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
result.onSuccess {
_uiState.value = _uiState.value.copy(
runtimeRoot = it.runtimeRoot.absolutePath,
configPath = it.configFile.absolutePath,
deployedBundleHash = it.deployedBundleHash,
)
syncConfigState(it.configFile)
pushMessage("数据目录已保存并挂载")
}.onFailure {
Log.e(TAG, "apply data dir mount failed", it)
pushMessage(it.message ?: "目录挂载失败")
}
}
}
}
private fun prepareRuntime(onSuccess: ((String) -> Unit)? = null) {
viewModelScope.launch(Dispatchers.IO) {
Log.i(TAG, "prepare runtime")
_uiState.value = _uiState.value.copy(busy = true)
settingsStore.saveDataDirPath(_uiState.value.dataDirPath)
val result = runtimePreparer.prepare(_uiState.value.dataDirPath, forceRedeploy = false)
withContext(Dispatchers.Main) {
_uiState.value = _uiState.value.copy(busy = false)
result.onSuccess {
Log.i(TAG, "runtime ready: root=${it.runtimeRoot}, mounted=${it.mountedDataDir}")
_uiState.value = _uiState.value.copy(
runtimeRoot = it.runtimeRoot.absolutePath,
configPath = it.configFile.absolutePath,
deployedBundleHash = it.deployedBundleHash,
)
syncConfigState(it.configFile)
pushMessage("运行时已准备完成")
onSuccess?.invoke(it.runtimeRoot.absolutePath)
}.onFailure {
Log.e(TAG, "runtime prepare failed", it)
pushMessage(it.message ?: "运行时准备失败")
}
}
}
}
private fun refreshRuntimePaths() {
val runtimeRoot = File(getApplication<Application>().filesDir, RuntimePreparer.RUNTIME_DIR_NAME)
_uiState.value = _uiState.value.copy(
runtimeRoot = runtimeRoot.absolutePath,
configPath = File(runtimeRoot, "config.json").absolutePath,
deployedBundleHash = RuntimePreparer.readDeployedBundleHash(runtimeRoot),
)
syncConfigState(File(runtimeRoot, "config.json"))
}
private fun refreshServerState(
initialDelayMillis: Long = 0,
retryCount: Int = 1,
retryDelayMillis: Long = 0,
expectedRunning: Boolean? = null,
updateMessage: Boolean = false,
) {
viewModelScope.launch(Dispatchers.IO) {
if (initialDelayMillis > 0) {
delay(initialDelayMillis)
}
var status = NativeBridge.status()
var health = NativeBridge.health()
for (attempt in 1 until retryCount.coerceAtLeast(1)) {
if (isExpectedStateReached(status, health, expectedRunning)) {
break
}
if (retryDelayMillis > 0) {
delay(retryDelayMillis)
}
status = NativeBridge.status()
health = NativeBridge.health()
}
Log.d(
TAG,
"server state refreshed: running=${status.running}, health=${health.status}, mainDb=${health.mainDb}, userDb=${health.userDb}, error=${status.lastError}",
)
withContext(Dispatchers.Main) {
val message = if (updateMessage) {
buildStatusMessage(status, health, expectedRunning)
} else {
_uiState.value.message
}
_uiState.value = _uiState.value.copy(
status = status,
health = health,
busy = false,
message = if (updateMessage) message else _uiState.value.message,
)
syncConfigState(File(_uiState.value.configPath))
if (updateMessage && message.isNotBlank()) {
scheduleMessageClear(5000)
}
refreshRuntimePaths()
}
}
}
private fun isExpectedStateReached(
status: NativeServerStatus,
health: NativeHealthInfo,
expectedRunning: Boolean?,
): Boolean {
return when (expectedRunning) {
true -> status.running && health.status != "stopped" && health.mainDb != "not initialized" && health.userDb != "not initialized"
false -> !status.running
null -> true
}
}
private fun buildStatusMessage(
status: NativeServerStatus,
health: NativeHealthInfo,
expectedRunning: Boolean?,
): String {
if (status.lastError.isNotBlank()) {
return status.lastError
}
return when (expectedRunning) {
true -> {
if (status.running && health.status != "stopped") {
"服务运行中"
} else {
"服务启动中,请稍后刷新"
}
}
false -> "服务已停止"
null -> {
if (status.running) {
"服务运行中"
} else {
_uiState.value.message
}
}
}
}
private fun pushMessage(message: String, autoClearMillis: Long? = 5000) {
messageClearJob?.cancel()
_uiState.value = _uiState.value.copy(message = message)
if (autoClearMillis != null) {
scheduleMessageClear(autoClearMillis)
}
}
private fun syncConfigState(configFile: File) {
if (!configFile.exists()) {
return
}
runCatching {
val json = JSONObject(configFile.readText())
json.optJSONObject("settings")?.optBoolean("unlock_all_special_rotation", false) ?: false
}.onSuccess { enabled ->
_uiState.value = _uiState.value.copy(unlockAllSpecialRotation = enabled)
}.onFailure {
Log.w(TAG, "failed to parse config state", it)
}
}
private fun writeUnlockAllSpecialRotation(configFile: File, enabled: Boolean) {
configFile.parentFile?.mkdirs()
val json = if (configFile.exists()) {
runCatching { JSONObject(configFile.readText()) }.getOrElse { JSONObject() }
} else {
JSONObject()
}
val settings = json.optJSONObject("settings") ?: JSONObject()
if (!json.has("app_name")) {
json.put("app_name", "honoka-chan")
}
settings.put("listen_port", settings.optString("listen_port", "8080"))
settings.put("cdn_server", settings.optString("cdn_server", "http://127.0.0.1:8080/static"))
settings.put("reload_token", settings.optString("reload_token", ""))
settings.put("unlock_all_special_rotation", enabled)
json.put("settings", settings)
configFile.writeText(json.toString(4) + "\n")
}
private fun scheduleMessageClear(delayMillis: Long) {
messageClearJob?.cancel()
messageClearJob = viewModelScope.launch {
delay(delayMillis)
_uiState.value = _uiState.value.copy(message = "")
}
}
}
@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1A73E8"
android:pathData="M0,0h108v108h-108z" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

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

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

@@ -1,3 +0,0 @@
<resources>
<string name="app_name">honoka-chan</string>
</resources>
@@ -1,7 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.HonokaControl" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>
</style>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
-5
View File
@@ -1,5 +0,0 @@
plugins {
id("com.android.application") version "8.11.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false
}
-4
View File
@@ -1,4 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
log "step 1/4: prepare runtime zip"
"$SCRIPT_DIR/prepare_runtime_zip.sh"
log "step 2/4: build Go library arm64-v8a"
"$SCRIPT_DIR/build_go_android.sh" arm64-v8a
log "step 3/4: build Go library armeabi-v7a"
"$SCRIPT_DIR/build_go_android.sh" armeabi-v7a
log "step 4/4: build Go library x86_64"
"$SCRIPT_DIR/build_go_android.sh" x86_64
log "android runtime assets and JNI libraries are ready"
log "open android/ in Android Studio to build the APK"
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
require_cmd go
ABI="${1:-arm64-v8a}"
MIN_SDK="${MIN_SDK:-26}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
[[ -n "$NDK_ROOT" ]] || die "ANDROID_NDK_HOME is not set"
[[ -d "$NDK_ROOT" ]] || die "ANDROID_NDK_HOME does not exist: $NDK_ROOT"
HOST_TAG="${ANDROID_NDK_HOST_TAG:-linux-x86_64}"
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG/bin"
[[ -d "$TOOLCHAIN" ]] || die "NDK toolchain not found: $TOOLCHAIN"
case "$ABI" in
arm64-v8a)
export GOOS=android
export GOARCH=arm64
export CGO_ENABLED=1
export CC="$TOOLCHAIN/aarch64-linux-android${MIN_SDK}-clang"
OUTPUT_DIR="$ANDROID_DIR/app/src/main/jniLibs/arm64-v8a"
;;
armeabi-v7a)
export GOOS=android
export GOARCH=arm
export GOARM=7
export CGO_ENABLED=1
export CC="$TOOLCHAIN/armv7a-linux-androideabi${MIN_SDK}-clang"
OUTPUT_DIR="$ANDROID_DIR/app/src/main/jniLibs/armeabi-v7a"
;;
x86_64)
export GOOS=android
export GOARCH=amd64
export CGO_ENABLED=1
export CC="$TOOLCHAIN/x86_64-linux-android${MIN_SDK}-clang"
OUTPUT_DIR="$ANDROID_DIR/app/src/main/jniLibs/x86_64"
;;
*)
die "unsupported abi: $ABI"
;;
esac
[[ -x "$CC" ]] || die "clang not found: $CC"
mkdir -p "$OUTPUT_DIR"
cd "$PROJECT_ROOT"
log "building Go shared library for $ABI"
go build -trimpath -buildmode=c-shared \
-ldflags="-s -w" \
-o "$OUTPUT_DIR/libhonokachan.so" \
./cmd/honoka-android
rm -f "$OUTPUT_DIR/libhonokachan.h"
log "built $OUTPUT_DIR/libhonokachan.so"
-85
View File
@@ -1,85 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
ok() {
printf '[android] ok: %s\n' "$*"
}
warn() {
printf '[android] warn: %s\n' "$*" >&2
}
status=0
check_cmd() {
local cmd="$1"
if command -v "$cmd" >/dev/null 2>&1; then
ok "command found: $cmd -> $(command -v "$cmd")"
else
warn "command missing: $cmd"
status=1
fi
}
check_file() {
local file="$1"
if [[ -f "$file" ]]; then
ok "file found: $file"
else
warn "file missing: $file"
status=1
fi
}
check_dir() {
local dir="$1"
if [[ -d "$dir" ]]; then
ok "directory found: $dir"
else
warn "directory missing: $dir"
status=1
fi
}
log "checking common commands"
check_cmd go
check_cmd zip
SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}"
NDK_ROOT="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
HOST_TAG="${ANDROID_NDK_HOST_TAG:-linux-x86_64}"
MIN_SDK="${MIN_SDK:-26}"
if [[ -n "$SDK_ROOT" ]]; then
check_dir "$SDK_ROOT"
else
warn "ANDROID_SDK_ROOT is not set"
status=1
fi
if [[ -n "$NDK_ROOT" ]]; then
check_dir "$NDK_ROOT"
TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG/bin"
check_dir "$TOOLCHAIN"
check_file "$TOOLCHAIN/aarch64-linux-android${MIN_SDK}-clang"
check_file "$TOOLCHAIN/armv7a-linux-androideabi${MIN_SDK}-clang"
check_file "$TOOLCHAIN/x86_64-linux-android${MIN_SDK}-clang"
else
warn "ANDROID_NDK_HOME is not set"
status=1
fi
log "checking project files"
check_file "$PROJECT_ROOT/assets/main.db"
check_dir "$PROJECT_ROOT/assets/serverdata"
check_dir "$PROJECT_ROOT/assets/certs"
check_dir "$PROJECT_ROOT/static/templates"
if [[ $status -ne 0 ]]; then
warn "environment check failed"
exit $status
fi
ok "environment check passed"
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
PROJECT_ROOT="$(cd -- "$ANDROID_DIR/.." && pwd)"
readonly SCRIPT_DIR
readonly ANDROID_DIR
readonly PROJECT_ROOT
log() {
printf '[android] %s\n' "$*"
}
die() {
printf '[android] error: %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
ensure_file() {
[[ -f "$1" ]] || die "missing file: $1"
}
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/common.sh"
require_cmd zip
ZIP_PATH="$ANDROID_DIR/app/src/main/assets/honoka_runtime.zip"
cd "$PROJECT_ROOT"
ensure_file "$PROJECT_ROOT/assets/main.db"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
mkdir -p "$TMP_DIR/assets" "$TMP_DIR/static"
log "collecting runtime files"
cat > "$TMP_DIR/config.json" <<'EOF'
{
"app_name": "honoka-chan",
"settings": {
"listen_port": "8080",
"cdn_server": "http://127.0.0.1:8080/static",
"reload_token": "",
"unlock_all_special_rotation": false
}
}
EOF
cp -r "$PROJECT_ROOT/assets/main.db" "$TMP_DIR/assets/main.db"
for path in \
"assets/serverdata" \
"assets/certs" \
"static/templates" \
"static/css" \
"static/js" \
"static/images" \
"static/font"
do
if [[ -e "$PROJECT_ROOT/$path" ]]; then
mkdir -p "$TMP_DIR/$(dirname "$path")"
cp -r "$PROJECT_ROOT/$path" "$TMP_DIR/$path"
fi
done
rm -f "$ZIP_PATH"
log "packing $ZIP_PATH"
(
cd "$TMP_DIR"
zip -qr "$ZIP_PATH" assets static config.json
)
log "runtime zip ready: $ZIP_PATH"
-18
View File
@@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "honoka-android"
include(":app")
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
-68
View File
@@ -1,68 +0,0 @@
package main
/*
#include <stdlib.h>
*/
import "C"
import (
"context"
"encoding/json"
"honoka-chan/internal/app"
systemhandler "honoka-chan/internal/handler/system"
"time"
"unsafe"
)
//export ServerStart
func ServerStart(workDir *C.char) *C.char {
if err := app.Start(C.GoString(workDir)); err != nil {
return C.CString(err.Error())
}
return nil
}
//export ServerStop
func ServerStop() *C.char {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.Stop(ctx); err != nil {
return C.CString(err.Error())
}
return nil
}
//export ServerStatusJSON
func ServerStatusJSON() *C.char {
return C.CString(app.GetStatusJSON())
}
//export ServerHealthJSON
func ServerHealthJSON() *C.char {
return C.CString(systemhandler.HealthJSON())
}
//export ServerReload
func ServerReload() *C.char {
resp, _, err := systemhandler.Reload("", false)
if err == nil {
return nil
}
data, marshalErr := json.Marshal(resp)
if marshalErr != nil {
return C.CString(err.Error())
}
return C.CString(string(data))
}
//export ServerFreeString
func ServerFreeString(str *C.char) {
if str == nil {
return
}
C.free(unsafe.Pointer(str))
}
func main() {}
+34 -24
View File
@@ -2,7 +2,7 @@ package config
import (
"encoding/json"
"honoka-chan/pkg/utils"
"fmt"
"os"
"strconv"
"time"
@@ -12,7 +12,6 @@ var (
Conf = &AppConfigs{}
PackageVersion = "97.4.6"
ConfigPath = "./config.json"
PrivateKeyPath = "assets/certs/privatekey.pem"
PublicKeyPath = "assets/certs/publickey.pem"
@@ -26,17 +25,16 @@ type AppConfigs struct {
type Settings struct {
ListenPort string `json:"listen_port"`
CdnServer string `json:"cdn_server"`
ReloadToken string `json:"reload_token"`
UnlockAllSpecialRotation bool `json:"unlock_all_special_rotation"`
}
func InitConfig() {
ReloadConfig()
}
func ReloadConfig() *AppConfigs {
Conf = Load(ConfigPath)
return Conf
func InitConfig() error {
conf, err := Load("./config.json")
if err != nil {
return err
}
Conf = conf
return nil
}
func DefaultConfigs() *AppConfigs {
@@ -45,32 +43,44 @@ func DefaultConfigs() *AppConfigs {
Settings: Settings{
ListenPort: "8080",
CdnServer: "http://127.0.0.1:8080/static",
ReloadToken: "",
UnlockAllSpecialRotation: false,
},
}
}
func Load(p string) *AppConfigs {
if !utils.PathExists(p) {
_ = DefaultConfigs().Save(p)
func Load(p string) (*AppConfigs, error) {
data, err := os.ReadFile(p)
if os.IsNotExist(err) {
conf := DefaultConfigs()
if err := conf.Save(p); err != nil {
return nil, fmt.Errorf("create default config: %w", err)
}
return conf, nil
}
c := AppConfigs{}
err := json.Unmarshal([]byte(utils.ReadAllText(p)), &c)
if err != nil {
_ = os.Rename(p, p+".backup"+strconv.FormatInt(time.Now().Unix(), 10))
_ = DefaultConfigs().Save(p)
return nil, fmt.Errorf("read config: %w", err)
}
c = AppConfigs{}
_ = json.Unmarshal([]byte(utils.ReadAllText(p)), &c)
return &c
c := AppConfigs{}
if err := json.Unmarshal(data, &c); err == nil {
return &c, nil
}
backup := p + ".backup" + strconv.FormatInt(time.Now().Unix(), 10)
if err := os.Rename(p, backup); err != nil {
return nil, fmt.Errorf("backup invalid config: %w", err)
}
conf := DefaultConfigs()
if err := conf.Save(p); err != nil {
return nil, fmt.Errorf("write default config: %w", err)
}
return conf, nil
}
func (c *AppConfigs) Save(p string) error {
data, err := json.MarshalIndent(c, "", " ")
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
utils.WriteAllText(p, string(data)+"\n")
return nil
return os.WriteFile(p, append(data, '\n'), 0644)
}
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadCreatesDefaultConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
conf, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if conf.Settings.ListenPort != "8080" {
t.Fatalf("ListenPort = %q, want 8080", conf.Settings.ListenPort)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("default config was not written: %v", err)
}
}
func TestLoadBacksUpInvalidConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte("{"), 0644); err != nil {
t.Fatalf("write invalid config: %v", err)
}
conf, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if conf.Settings.ListenPort != "8080" {
t.Fatalf("ListenPort = %q, want 8080", conf.Settings.ListenPort)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "config.json.backup") {
return
}
}
t.Fatal("invalid config backup was not created")
}
-4
View File
@@ -7,7 +7,6 @@
第一次启动 `honoka-chan` 后会生成 `config.json`。需要确认:
- `settings.cdn_server` 指向你的数据下载地址
- `settings.reload_token` 可选;如果需要通过接口热重载 `config.json`,需要先设置一个 token
- `settings.unlock_all_special_rotation` 可选;设为 `true` 后会忽略日替时间限制,直接解锁全部日替特殊歌曲,不包含周替 `MASTER`
- 如果数据直接放在本项目的 `static` 目录下,通常可以配置成类似 `http://192.168.1.123/static`
@@ -26,9 +25,6 @@
## 补充说明
- `GET /system/health` 可用于检查应用和数据库是否正常运行
- `POST /system/reload?token=你的token` 可在修改 `config.json` 后重新加载配置
SIF 的“全量数据下载”实际上并不是真正的全量。部分剧情资源仍然需要通过 `/download/getUrl` 接口按路径拉取。
所以除了 `archives` 目录里的压缩包,还需要另外维护一份:
+11 -13
View File
@@ -7,11 +7,10 @@ require github.com/gin-gonic/gin v1.12.0
require (
github.com/gin-contrib/sessions v1.1.0
github.com/go-think/openssl v1.20.0
github.com/mattn/go-sqlite3 v1.14.45
github.com/tidwall/gjson v1.19.0
modernc.org/sqlite v1.51.0
modernc.org/sqlite v1.53.0
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.11
xorm.io/xorm v1.4.0
)
require (
@@ -30,15 +29,14 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/tools v0.45.0 // indirect
modernc.org/libc v1.72.5 // indirect
go.mongodb.org/mongo-driver/v2 v2.7.0 // indirect
golang.org/x/arch v0.28.0 // indirect
modernc.org/libc v1.73.5 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
@@ -54,13 +52,13 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.2 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
+38 -36
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/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -85,14 +85,16 @@ github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -118,30 +120,30 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -155,20 +157,20 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.2 h1:mxsy2FdrB6+qG3NfXefz1AmWv0ehOSDO4jxgxd7h9yo=
modernc.org/ccgo/v4 v4.34.2/go.mod h1:1L7us56+kAKu04p25EATpmBBvhbcqqZ85ibqWVwVgog=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.5 h1:m2OGx9Ser1VvTS4Z9ZJlWs+CBMxutLaTiAWkNz+NB9U=
modernc.org/libc v1.72.5/go.mod h1:np0N7KDJ7eUtMZmOqVZNldrZyG+DHLl2B5pg8Hbar3U=
modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -177,13 +179,13 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI=
xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
xorm.io/xorm v1.4.0 h1:MOZfMAH09FlAnpXcGHp6jS+Np1u9DL/qvRjoW+4YITY=
xorm.io/xorm v1.4.0/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
-166
View File
@@ -1,166 +0,0 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"honoka-chan/config"
_ "honoka-chan/internal/handler"
systemhandler "honoka-chan/internal/handler/system"
"honoka-chan/internal/router"
"honoka-chan/internal/startup"
"honoka-chan/pkg/db"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const dateTimeFormat = "2006-01-02 15:04:05"
type Status struct {
Running bool `json:"running"`
WorkDir string `json:"work_dir"`
ListenPort string `json:"listen_port"`
LocalURL string `json:"local_url"`
StartedAt string `json:"started_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
var (
mu sync.Mutex
httpServer *http.Server
listener net.Listener
state Status
)
func Start(workDir string) error {
if workDir == "" {
workDir = "."
}
absWorkDir, err := filepath.Abs(workDir)
if err != nil {
return err
}
mu.Lock()
if state.Running {
mu.Unlock()
return errors.New("server is already running")
}
mu.Unlock()
if err := os.Chdir(absWorkDir); err != nil {
setLastError(err)
return err
}
config.ReloadConfig()
if err := db.Init("assets/main.db", "assets/data.db"); err != nil {
setLastError(err)
return err
}
if err := startup.StartUp(); err != nil {
_ = db.Close()
setLastError(err)
return err
}
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{
"/agreement/all",
"/integration/appReport/initialize",
"/report/ge/app",
"/v1/account/reportRole",
},
}))
router.SifRouter(engine)
ln, err := net.Listen("tcp", ":"+config.Conf.Settings.ListenPort)
if err != nil {
_ = db.Close()
setLastError(err)
return err
}
srv := &http.Server{
Handler: engine,
}
now := time.Now()
systemhandler.MarkStarted(now)
mu.Lock()
httpServer = srv
listener = ln
state = Status{
Running: true,
WorkDir: absWorkDir,
ListenPort: config.Conf.Settings.ListenPort,
LocalURL: fmt.Sprintf("http://127.0.0.1:%s", config.Conf.Settings.ListenPort),
StartedAt: now.Format(dateTimeFormat),
}
mu.Unlock()
go serveLoop(srv, ln)
return nil
}
func serveLoop(srv *http.Server, ln net.Listener) {
err := srv.Serve(ln)
mu.Lock()
defer mu.Unlock()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
state.LastError = err.Error()
}
state.Running = false
httpServer = nil
listener = nil
_ = db.Close()
}
func Stop(ctx context.Context) error {
mu.Lock()
srv := httpServer
running := state.Running
mu.Unlock()
if !running || srv == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
return srv.Shutdown(ctx)
}
func GetStatus() Status {
mu.Lock()
defer mu.Unlock()
return state
}
func GetStatusJSON() string {
status := GetStatus()
data, err := json.Marshal(status)
if err != nil {
return `{"running":false,"last_error":"failed to encode status"}`
}
return string(data)
}
func setLastError(err error) {
mu.Lock()
defer mu.Unlock()
state.LastError = err.Error()
state.Running = false
}
+2 -1
View File
@@ -4,8 +4,9 @@ type ErrorCode int
// https://github.com/DarkEnergyProcessor/NPPS4/blob/master/npps4/idol/error.py
const (
ErrorCodeUnknown ErrorCode = 1
ErrorCodeNoUnitIsSellable ErrorCode = 1205
ErrorCodeLivePreciseListNotFound ErrorCode = 3421
ErrorCodeEventNoEventData ErrorCode = 10004
)
const ErrorCodeAcceptableError = 600
@@ -0,0 +1,46 @@
package achievement
import (
apiachievement "honoka-chan/internal/handler/api/achievement"
"honoka-chan/internal/middleware"
"honoka-chan/internal/router"
achievementschema "honoka-chan/internal/schema/achievement"
"honoka-chan/internal/session"
honokautils "honoka-chan/internal/utils"
"net/http"
"github.com/gin-gonic/gin"
)
const pagingAccomplishedPageSize = 10
func pagingAccomplishedList(ctx *gin.Context) {
ss := session.Get(ctx)
defer ss.FinalizeOrRollback()
req := achievementschema.PagingAccomplishedListReq{}
err := honokautils.ParseRequestData(ctx, &req)
if ss.CheckErr(err) {
return
}
grouped, err := apiachievement.ListVisibleAccomplishedAchievements(ss)
if ss.CheckErr(err) {
return
}
items := grouped[req.FilterCategoryID]
start := min(max(req.FromCount, 0), len(items))
end := min(start+pagingAccomplishedPageSize, len(items))
ss.Respond(achievementschema.PagingAccomplishedListResp{
ResponseData: items[start:end],
ReleaseInfo: []any{},
StatusCode: http.StatusOK,
})
}
func init() {
router.AddHandler("main.php", "POST", "/achievement/pagingAccomplishedList", middleware.Common, pagingAccomplishedList)
}
+3 -2
View File
@@ -5,13 +5,14 @@ import (
"honoka-chan/internal/router"
albumschema "honoka-chan/internal/schema/album"
"honoka-chan/internal/session"
"net/http"
"github.com/gin-gonic/gin"
)
func seriesAll(ctx *gin.Context) {
ss := session.Get(ctx)
defer ss.Finalize()
defer ss.FinalizeOrRollback()
var albumID []int
err := ss.MainEng.Table("album_series_m").Select("album_series_id").Find(&albumID)
@@ -81,7 +82,7 @@ func seriesAll(ctx *gin.Context) {
ss.Respond(albumschema.SeriesAllResp{
ResponseData: albumSeriesAllRes,
ReleaseInfo: []any{},
StatusCode: 200,
StatusCode: http.StatusOK,
})
}
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"honoka-chan/internal/router"
announceschema "honoka-chan/internal/schema/announce"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -12,7 +13,7 @@ import (
func checkState(ctx *gin.Context) {
ss := session.Get(ctx)
defer ss.Finalize()
defer ss.FinalizeOrRollback()
ss.Respond(announceschema.CheckStateDataResp{
ResponseData: announceschema.CheckStateData{
@@ -20,7 +21,7 @@ func checkState(ctx *gin.Context) {
ServerTimestamp: time.Now().Unix(),
},
ReleaseInfo: []any{},
StatusCode: 200,
StatusCode: http.StatusOK,
})
}
@@ -0,0 +1,30 @@
package achievement
import (
honokautils "honoka-chan/internal/utils"
"github.com/gin-gonic/gin"
)
var achievementFilterCategoryList = []int{
1, // 重要
3, // 挑战
4, // 每日
5, // 期间限定
6, // 8周年限定/全世界5000万人突破纪念
7, // 回忆画廊
8, // 9周年限定
9, // 连携课题
}
func AchievementApi(ctx *gin.Context, action string) (res any, err error) {
switch action {
case "unaccomplishList":
res, err = unaccomplishList()
case "initialAccomplishedList":
res, err = initialAccomplishedList(ctx)
default:
err = honokautils.NewUnimplementedActionError("achievement", action)
}
return res, err
}
+118
View File
@@ -0,0 +1,118 @@
package achievement
import (
"fmt"
achievementapischema "honoka-chan/internal/schema/api/achievement"
"honoka-chan/internal/session"
"sort"
"strings"
"time"
)
type achievementRow struct {
AchievementID int `xorm:"achievement_id"`
AchievementFilterCategoryID int `xorm:"achievement_filter_category_id"`
DisplayFlag int `xorm:"display_flag"`
StartDate string
EndDate *string
}
func ListVisibleAccomplishedAchievements(ss *session.Session) (map[int][]achievementapischema.AchievementListItem, error) {
var achievementList []achievementRow
err := ss.MainEng.Table("achievement_m").
Cols("achievement_id,achievement_filter_category_id,display_flag,start_date,end_date").
Where("display_flag = ?", 1).
Find(&achievementList)
if err != nil {
return nil, err
}
now := time.Now()
grouped := make(map[int][]achievementapischema.AchievementListItem, len(achievementFilterCategoryList))
for _, achievement := range achievementList {
if !isAchievementVisible(now, achievement.StartDate, achievement.EndDate) {
continue
}
grouped[achievement.AchievementFilterCategoryID] = append(
grouped[achievement.AchievementFilterCategoryID],
achievementapischema.AchievementListItem{
AchievementID: achievement.AchievementID,
Count: 1,
IsAccomplished: true,
InsertDate: normalizeAchievementDate(achievement.StartDate),
EndDate: normalizeAchievementDatePtr(achievement.EndDate),
RemainingTime: "",
IsNew: false,
ForDisplay: achievement.DisplayFlag != 0,
RewardList: []achievementapischema.AchievementRewardItem{
{
AddType: 3001,
ItemID: 4,
Amount: 1,
},
},
},
)
}
for filterCategoryID, items := range grouped {
sort.Slice(items, func(i, j int) bool {
return items[i].InsertDate > items[j].InsertDate
})
grouped[filterCategoryID] = items
}
return grouped, nil
}
func normalizeAchievementDate(value string) string {
if value == "" {
return "1970-01-01 00:00:00"
}
return strings.ReplaceAll(value, "/", "-")
}
func normalizeAchievementDatePtr(value *string) *string {
if value == nil || *value == "" {
return nil
}
normalized := normalizeAchievementDate(*value)
return &normalized
}
func isAchievementVisible(now time.Time, startDate string, endDate *string) bool {
start, err := parseAchievementTime(startDate)
if err == nil && now.Before(start) {
return false
}
if endDate == nil || *endDate == "" {
return true
}
end, err := parseAchievementTime(*endDate)
if err != nil {
return true
}
return !now.After(end)
}
func parseAchievementTime(value string) (time.Time, error) {
layouts := []string{
"2006/01/02 15:04:05",
"2006/1/2 15:04:05",
}
for _, layout := range layouts {
parsed, err := time.ParseInLocation(layout, value, time.Local)
if err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("invalid achievement time: %s", value)
}
@@ -0,0 +1,45 @@
package achievement
import (
achievementapischema "honoka-chan/internal/schema/api/achievement"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
func initialAccomplishedList(ctx *gin.Context) (res any, err error) {
ss := session.Get(ctx)
grouped, err := ListVisibleAccomplishedAchievements(ss)
if err != nil {
return nil, err
}
listData := make([]achievementapischema.UnaccomplishListData, 0, len(achievementFilterCategoryList))
for _, filterCategoryID := range achievementFilterCategoryList {
achievementItems := grouped[filterCategoryID]
if achievementItems == nil {
achievementItems = []achievementapischema.AchievementListItem{}
} else {
if len(achievementItems) > 5 {
achievementItems = achievementItems[:5]
}
}
listData = append(listData, achievementapischema.UnaccomplishListData{
FilterCategoryID: filterCategoryID,
AchievementList: achievementItems,
Count: len(grouped[filterCategoryID]),
IsLast: true,
})
}
return achievementapischema.UnaccomplishListResp{
Result: listData,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}, nil
}
@@ -0,0 +1,28 @@
package achievement
import (
achievementapischema "honoka-chan/internal/schema/api/achievement"
"net/http"
"time"
)
func unaccomplishList() (res any, err error) {
listData := make([]achievementapischema.UnaccomplishListData, 0, len(achievementFilterCategoryList))
for i := range achievementFilterCategoryList {
listData = append(listData, achievementapischema.UnaccomplishListData{
FilterCategoryID: achievementFilterCategoryList[i],
AchievementList: []achievementapischema.AchievementListItem{},
Count: 0,
IsLast: true,
})
}
res = achievementapischema.UnaccomplishListResp{
Result: listData,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
return res, nil
}
+2 -1
View File
@@ -3,6 +3,7 @@ package album
import (
albumapischema "honoka-chan/internal/schema/api/album"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -61,7 +62,7 @@ func albumAll(ctx *gin.Context) (res any, err error) {
res = albumapischema.AllResp{
Result: albumLists,
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+8 -14
View File
@@ -1,7 +1,7 @@
package api
import (
"honoka-chan/internal/constant"
"honoka-chan/internal/handler/api/achievement"
"honoka-chan/internal/handler/api/album"
"honoka-chan/internal/handler/api/award"
"honoka-chan/internal/handler/api/background"
@@ -32,17 +32,16 @@ import (
"honoka-chan/internal/middleware"
"honoka-chan/internal/router"
apischema "honoka-chan/internal/schema/api"
commonschema "honoka-chan/internal/schema/common"
"honoka-chan/internal/session"
honokautils "honoka-chan/internal/utils"
"time"
"net/http"
"github.com/gin-gonic/gin"
)
func api(ctx *gin.Context) {
ss := session.Get(ctx)
defer ss.Finalize()
defer ss.FinalizeOrRollback()
apiReq := []apischema.ApiReq{}
err := honokautils.ParseRequestData(ctx, &apiReq)
@@ -57,6 +56,8 @@ func api(ctx *gin.Context) {
switch v.Module {
case "album":
result, err = album.AlbumApi(ctx, v.Action)
case "achievement":
result, err = achievement.AchievementApi(ctx, v.Action)
case "award":
result, err = award.AwardApi(ctx, v.Action)
case "background":
@@ -114,15 +115,8 @@ func api(ctx *gin.Context) {
}
if honokautils.IsUnimplementedError(err) {
results = append(results, commonschema.ApiErrorResp{
Result: commonschema.ErrorData{
ErrorCode: constant.ErrorCodeUnknown,
},
Status: 600,
CommandNum: false,
TimeStamp: time.Now().Unix(),
})
continue
ss.AbortWithStatus(http.StatusNotFound, honokautils.NewDetailContent(err.Error()))
return
}
if ss.CheckErr(err) {
return
@@ -133,7 +127,7 @@ func api(ctx *gin.Context) {
apiResp := apischema.ApiResp{
ResponseData: results,
ReleaseInfo: []any{},
StatusCode: 200,
StatusCode: http.StatusOK,
}
ss.Respond(apiResp)
+2 -1
View File
@@ -3,6 +3,7 @@ package award
import (
awardapischema "honoka-chan/internal/schema/api/award"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -40,7 +41,7 @@ func awardInfo(ctx *gin.Context) (res any, err error) {
Result: awardapischema.InfoData{
AwardInfo: awardsList,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package background
import (
backgroundapischema "honoka-chan/internal/schema/api/background"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -40,7 +41,7 @@ func backgroundInfo(ctx *gin.Context) (res any, err error) {
Result: backgroundapischema.InfoData{
BackgroundInfo: backgroundsList,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,6 +2,7 @@ package banner
import (
bannerapischema "honoka-chan/internal/schema/api/banner"
"net/http"
"time"
)
@@ -63,7 +64,7 @@ func bannerList() (res any, err error) {
},
},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,13 +2,14 @@ package challenge
import (
challengeapischema "honoka-chan/internal/schema/api/challenge"
"net/http"
"time"
)
func challengeInfo() (res any, err error) {
res = challengeapischema.InfoResp{
Result: []any{},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,6 +2,7 @@ package costume
import (
costumeapischema "honoka-chan/internal/schema/api/costume"
"net/http"
"time"
)
@@ -10,7 +11,7 @@ func costumeList() (res any, err error) {
Result: costumeapischema.ListData{
CostumeList: []costumeapischema.CostumeList{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt"
eventscenarioapischema "honoka-chan/internal/schema/api/eventscenario"
"honoka-chan/internal/session"
"net/http"
"strings"
"time"
@@ -71,7 +72,7 @@ func eventScenarioStatus(ctx *gin.Context) (res any, err error) {
Result: eventscenarioapischema.StatusData{
EventScenarioList: eventsList,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package exchange
import (
exchangeapischema "honoka-chan/internal/schema/api/exchange"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -28,7 +29,7 @@ func owningPoint(ctx *gin.Context) (res any, err error) {
Result: exchangeapischema.OwningPointData{
ExchangePointList: exPointsList,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package item
import (
itemapischema "honoka-chan/internal/schema/api/item"
honokautils "honoka-chan/internal/utils"
"net/http"
"time"
)
@@ -14,7 +15,7 @@ func itemList() (res any, err error) {
res = itemapischema.ListResp{
Result: itemData,
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package live
import (
liveapischema "honoka-chan/internal/schema/api/live"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -36,7 +37,7 @@ func liveSchedule(ctx *gin.Context) (res any, err error) {
FreeLiveList: []any{},
TrainingLiveList: []liveapischema.TrainingLiveList{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package live
import (
liveapischema "honoka-chan/internal/schema/api/live"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -46,7 +47,7 @@ func liveStatus(ctx *gin.Context) (res any, err error) {
FreeLiveStatusList: []any{},
CanResumeLive: true,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,6 +2,7 @@ package liveicon
import (
liveiconapischema "honoka-chan/internal/schema/api/liveicon"
"net/http"
"time"
)
@@ -10,7 +11,7 @@ func liveIconInfo() (res any, err error) {
Result: liveiconapischema.InfoData{
LiveNotesIconList: []int{1, 2, 3},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,6 +2,7 @@ package livese
import (
liveseapischema "honoka-chan/internal/schema/api/livese"
"net/http"
"time"
)
@@ -10,7 +11,7 @@ func LiveSeInfo() (res any, err error) {
Result: liveseapischema.InfoData{
LiveSeList: []int{1, 2, 3},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -4,6 +4,7 @@ import (
usermodel "honoka-chan/internal/model/user"
loginapischema "honoka-chan/internal/schema/api/login"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -68,7 +69,7 @@ func loginTopInfo(ctx *gin.Context) (res any, err error) {
AdFlag: false,
HasAdReward: false,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: now.Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package login
import (
loginapischema "honoka-chan/internal/schema/api/login"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -36,7 +37,7 @@ func loginTopInfoOnce(ctx *gin.Context) (res any, err error) {
ArenaSiSkillUniqueCheck: true,
OpenV98: true,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,13 +2,14 @@ package marathon
import (
marathonapischema "honoka-chan/internal/schema/api/marathon"
"net/http"
"time"
)
func marathonInfo() (res any, err error) {
res = marathonapischema.InfoResp{
Result: []any{},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
@@ -3,6 +3,7 @@ package multiunit
import (
multiunitapischema "honoka-chan/internal/schema/api/multiunit"
"honoka-chan/internal/session"
"net/http"
"strings"
"time"
@@ -53,7 +54,7 @@ func MultiUnitScenarioStatus(ctx *gin.Context) (res any, err error) {
MultiUnitScenarioStatusList: multiUnitsList,
UnlockedMultiUnitScenarioIds: []any{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package museum
import (
museumapischema "honoka-chan/internal/schema/api/museum"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -42,7 +43,7 @@ func museumInfo(ctx *gin.Context) (res any, err error) {
ContentsIDList: museumID,
},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
@@ -2,6 +2,7 @@ package navigation
import (
navigationapischema "honoka-chan/internal/schema/api/navigation"
"net/http"
"time"
)
@@ -10,7 +11,7 @@ func SpecialCutin() (res any, err error) {
Result: navigationapischema.SpecialCutinData{
SpecialCutinList: []any{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,6 +2,7 @@ package notice
import (
noticeapischema "honoka-chan/internal/schema/api/notice"
"net/http"
"time"
)
@@ -11,7 +12,7 @@ func noticeMarquee() (res any, err error) {
ItemCount: 0,
MarqueeList: []any{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package payment
import (
paymentapischema "honoka-chan/internal/schema/api/payment"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -27,7 +28,7 @@ func productList(ctx *gin.Context) (res any, err error) {
SubscriptionList: []paymentapischema.Subscription{},
ShowPointShop: false,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package profile
import (
profileapischema "honoka-chan/internal/schema/api/profile"
honokautils "honoka-chan/internal/utils"
"net/http"
"time"
)
@@ -14,7 +15,7 @@ func cardRanking() (res any, err error) {
res = profileapischema.CardRankingResp{
Result: cardRankingData,
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+6 -15
View File
@@ -6,6 +6,7 @@ import (
usermodel "honoka-chan/internal/model/user"
profileapischema "honoka-chan/internal/schema/api/profile"
"honoka-chan/internal/session"
"net/http"
"strconv"
"time"
@@ -89,7 +90,7 @@ func profileInfo(ctx *gin.Context, targetUserID int) (res any, err error) {
SettingAwardID: targetPref.AwardID,
SettingBackgroundID: targetPref.BackgroundID,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
@@ -325,17 +326,7 @@ func getAccessoryInfo(ss *session.Session, targetUserID, unitOwningUserID int) (
return nil, nil
}
accessoryData := struct {
AccessoryID int `xorm:"accessory_id"`
Exp int `xorm:"exp"`
}{}
has, err = ss.MainEng.Table("common_accessory_m").
Where("accessory_owning_user_id = ?", accessoryOwningID).
Cols("accessory_id,exp").
Get(&accessoryData)
if err != nil {
return nil, err
}
has, accessoryData := ss.GetAccessoryByAccessoryOwningUserIDForUser(targetUserID, accessoryOwningID)
if !has {
return nil, nil
}
@@ -345,9 +336,9 @@ func getAccessoryInfo(ss *session.Session, targetUserID, unitOwningUserID int) (
AccessoryID: accessoryData.AccessoryID,
Exp: accessoryData.Exp,
NextExp: 0,
Level: 8,
MaxLevel: 8,
RankUpCount: 4,
Level: accessoryData.MaxLevel,
MaxLevel: accessoryData.MaxLevel,
RankUpCount: accessoryData.MaxLevel - accessoryData.DefaultMaxLevel,
FavoriteFlag: true,
}, nil
}
+2 -1
View File
@@ -4,6 +4,7 @@ import (
usermodel "honoka-chan/internal/model/user"
profileapischema "honoka-chan/internal/schema/api/profile"
"honoka-chan/internal/session"
"net/http"
"sort"
"time"
@@ -82,7 +83,7 @@ func liveCnt(ctx *gin.Context, targetUserID int) (res any, err error) {
res = profileapischema.LiveCntResp{
Result: result,
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,6 +2,7 @@ package reward
import (
rewardapischema "honoka-chan/internal/schema/api/reward"
"net/http"
"time"
)
@@ -12,7 +13,7 @@ func rewardHistory() (res any, err error) {
Limit: 20,
History: []any{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -2,6 +2,7 @@ package reward
import (
rewardapischema "honoka-chan/internal/schema/api/reward"
"net/http"
"time"
)
@@ -13,7 +14,7 @@ func rewardList() (res any, err error) {
Order: 0,
Items: []any{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package scenario
import (
scenarioapischema "honoka-chan/internal/schema/api/scenario"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -28,7 +29,7 @@ func scenarioStatus(ctx *gin.Context) (res any, err error) {
Result: scenarioapischema.StatusData{
ScenarioStatusList: scenarioLists,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package secretbox
import (
secretboxapischema "honoka-chan/internal/schema/api/secretbox"
honokautils "honoka-chan/internal/utils"
"net/http"
"time"
)
@@ -35,7 +36,7 @@ func all() (res any, err error) {
res = secretboxapischema.AllResp{
Result: secretBoxData,
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package stamp
import (
stampapischema "honoka-chan/internal/schema/api/stamp"
honokautils "honoka-chan/internal/utils"
"net/http"
"time"
)
@@ -14,7 +15,7 @@ func stampInfo() (res any, err error) {
res = stampapischema.InfoResp{
Result: stampData,
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package subscenario
import (
subscenarioapischema "honoka-chan/internal/schema/api/subscenario"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -29,7 +30,7 @@ func subscenarioStatus(ctx *gin.Context) (res any, err error) {
SubscenarioStatusList: subScenarioLists,
UnlockedSubscenarioIds: []any{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+3 -2
View File
@@ -3,6 +3,7 @@ package unit
import (
unitapischema "honoka-chan/internal/schema/api/unit"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -12,7 +13,7 @@ func unitAccessoryAll(ctx *gin.Context) (res any, err error) {
ss := session.Get(ctx)
accessoryList := []unitapischema.AccessoryList{}
err = ss.MainEng.Table("common_accessory_m").Find(&accessoryList)
err = ss.UserEng.Table("user_accessory").Where("user_id = ?", ss.UserID).Find(&accessoryList)
if err != nil {
return nil, err
}
@@ -34,7 +35,7 @@ func unitAccessoryAll(ctx *gin.Context) (res any, err error) {
WearingInfo: wearingInfo,
EspecialCreateFlag: false,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
@@ -2,13 +2,14 @@ package unit
import (
unitapischema "honoka-chan/internal/schema/api/unit"
"net/http"
"time"
)
func unitAccessoryMaterialAll() (res any, err error) {
res = unitapischema.AccessoryMaterialAllResp{
Result: unitapischema.AccessoryMaterialAllData{},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package unit
import (
unitapischema "honoka-chan/internal/schema/api/unit"
honokautils "honoka-chan/internal/utils"
"net/http"
"time"
)
@@ -16,7 +17,7 @@ func unitAccessoryTab() (res any, err error) {
Result: unitapischema.AccessoryTabData{
TabList: tabList,
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -4,6 +4,7 @@ import (
unitmodel "honoka-chan/internal/model/unit"
unitapischema "honoka-chan/internal/schema/api/unit"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -55,7 +56,7 @@ func unitAll(ctx *gin.Context) (res any, err error) {
Active: unitsData,
Waiting: []unitapischema.Waiting{},
},
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
+2 -1
View File
@@ -3,6 +3,7 @@ package unit
import (
unitapischema "honoka-chan/internal/schema/api/unit"
"honoka-chan/internal/session"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -46,7 +47,7 @@ func unitDeckInfo(ctx *gin.Context) (res any, err error) {
}
res = unitapischema.DeckInfoResp{
Result: unitDeckInfo,
Status: 200,
Status: http.StatusOK,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}

Some files were not shown because too many files have changed in this diff Show More