From 2dc4cad45420e82303a01686a6fd27d86dae02c7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 4 Sep 2026 00:56:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(panel):=20=E5=89=8D=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=8C=89=E6=A8=A1=E5=9D=97=E6=8B=86=E5=88=86+=E5=B1=8F?= =?UTF-8?q?=E5=B9=95/=E6=8E=A7=E5=88=B6=E5=90=88=E5=B9=B6=E9=A1=B5=20?= =?UTF-8?q?=E2=80=94=20=E5=90=8E=E7=AB=AFmeowpanel.c=206372=E8=A1=8C?= =?UTF-8?q?=E6=8B=86src/*.c+mp.h(235=E5=87=BD=E6=95=B0=E5=8E=9F=E5=9E=8B,2?= =?UTF-8?q?0=E7=AB=AF=E7=82=B9output=20byte-identical,0warning)+=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=8B=86mp=5Fshared/mp=5Fpages/meowpanel=E4=B8=89?= =?UTF-8?q?=E5=B1=82;=E5=B1=8F=E5=B9=95=C2=B7=E9=81=A5=E6=8E=A7=E5=90=88?= =?UTF-8?q?=E5=B9=B6(=E5=B7=A6=E5=B1=8F=E5=AE=9E=E6=97=B6=E7=94=BB?= =?UTF-8?q?=E9=9D=A2+=E5=8F=B3MD3=20SVG=E7=9F=A2=E9=87=8F=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=97=A0emoji);=E9=87=8D=E7=BC=96=E5=90=8E=E7=83=AD?= =?UTF-8?q?=E6=9B=B4TF=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- patch_v2/meowpanel/build_meowpanel.sh | 7 +- patch_v2/meowpanel/src/mp.h | 453 ++++ patch_v2/meowpanel/src/mp_ams.c | 196 ++ patch_v2/meowpanel/src/mp_apps.c | 242 +++ patch_v2/meowpanel/src/mp_audio.c | 232 +++ patch_v2/meowpanel/src/mp_auth.c | 426 ++++ patch_v2/meowpanel/src/mp_files.c | 619 ++++++ patch_v2/meowpanel/src/mp_fonts.c | 374 ++++ patch_v2/meowpanel/src/mp_ipc.c | 211 ++ patch_v2/meowpanel/src/mp_json.c | 232 +++ patch_v2/meowpanel/src/mp_key.c | 126 ++ patch_v2/meowpanel/src/mp_logs.c | 241 +++ patch_v2/meowpanel/src/mp_net.c | 440 ++++ patch_v2/meowpanel/src/mp_notif.c | 298 +++ patch_v2/meowpanel/src/mp_perf.c | 192 ++ patch_v2/meowpanel/src/mp_power.c | 140 ++ patch_v2/meowpanel/src/mp_screen.c | 277 +++ patch_v2/meowpanel/src/mp_server.c | 618 ++++++ patch_v2/meowpanel/src/mp_store.c | 596 ++++++ patch_v2/meowpanel/src/mp_svcd.c | 130 ++ patch_v2/meowpanel/src/mp_util.c | 368 ++++ patch_v2/meowpanel/web/index.html | 13 +- patch_v2/meowpanel/web/meowpanel.css | 22 + patch_v2/meowpanel/web/meowpanel.js | 2772 +------------------------ patch_v2/meowpanel/web/mp_pages.js | 2380 +++++++++++++++++++++ patch_v2/meowpanel/web/mp_shared.js | 467 +++++ 26 files changed, 9317 insertions(+), 2755 deletions(-) create mode 100644 patch_v2/meowpanel/src/mp.h create mode 100644 patch_v2/meowpanel/src/mp_ams.c create mode 100644 patch_v2/meowpanel/src/mp_apps.c create mode 100644 patch_v2/meowpanel/src/mp_audio.c create mode 100644 patch_v2/meowpanel/src/mp_auth.c create mode 100644 patch_v2/meowpanel/src/mp_files.c create mode 100644 patch_v2/meowpanel/src/mp_fonts.c create mode 100644 patch_v2/meowpanel/src/mp_ipc.c create mode 100644 patch_v2/meowpanel/src/mp_json.c create mode 100644 patch_v2/meowpanel/src/mp_key.c create mode 100644 patch_v2/meowpanel/src/mp_logs.c create mode 100644 patch_v2/meowpanel/src/mp_net.c create mode 100644 patch_v2/meowpanel/src/mp_notif.c create mode 100644 patch_v2/meowpanel/src/mp_perf.c create mode 100644 patch_v2/meowpanel/src/mp_power.c create mode 100644 patch_v2/meowpanel/src/mp_screen.c create mode 100644 patch_v2/meowpanel/src/mp_server.c create mode 100644 patch_v2/meowpanel/src/mp_store.c create mode 100644 patch_v2/meowpanel/src/mp_svcd.c create mode 100644 patch_v2/meowpanel/src/mp_util.c create mode 100644 patch_v2/meowpanel/web/mp_pages.js create mode 100644 patch_v2/meowpanel/web/mp_shared.js diff --git a/patch_v2/meowpanel/build_meowpanel.sh b/patch_v2/meowpanel/build_meowpanel.sh index ac10a90..b96e955 100755 --- a/patch_v2/meowpanel/build_meowpanel.sh +++ b/patch_v2/meowpanel/build_meowpanel.sh @@ -7,7 +7,8 @@ # 用法: # ./build_meowpanel.sh # 交叉编译 → patch_v2/meowpanel/meowpanel(mipsel 静态) # ./build_meowpanel.sh host # 宿主编译 → patch_v2/meowpanel/meowpanel_host -# 源码:meowpanel.c + mp_jpeg.c(最小 baseline JPEG 编码器,阶段 C 实时画面)。 +# 源码:src/*.c(模块化拆分,见 mp.h 共享内部头)+ mp_jpeg.c(最小 baseline JPEG 编码器,阶段 C 实时画面)。 +# 说明:meowpanel.c 为拆分前的单体源码(保留作参考,**不在构建源列表**;构建以 src/ 模块为准)。 # 字体库导入/导出走 busybox `gzip -dc | tar` 管道(与 nyai .mpk 同构),零额外源文件。 set -e cd "$(dirname "$0")" @@ -17,14 +18,14 @@ I_HDR="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(帧/m_perf_query_t/m_perf if [ "$1" = "host" ]; then HOST_CC="${CC_HOST:-cc}" - "$HOST_CC" -Os -Wall -Wextra -I"$I_HDR" meowpanel.c mp_jpeg.c -o meowpanel_host + "$HOST_CC" -Os -Wall -Wextra -I. -I"$I_HDR" src/*.c mp_jpeg.c -o meowpanel_host echo "== host build ==" ls -la meowpanel_host file meowpanel_host exit 0 fi -"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meowpanel.c mp_jpeg.c -o meowpanel +"$CC" -Os -Wall -Wextra -static -I. -I"$I_HDR" src/*.c mp_jpeg.c -o meowpanel echo "== meowpanel (mipsel static) ==" ls -la meowpanel md5sum meowpanel diff --git a/patch_v2/meowpanel/src/mp.h b/patch_v2/meowpanel/src/mp.h new file mode 100644 index 0000000..063bcbb --- /dev/null +++ b/patch_v2/meowpanel/src/mp.h @@ -0,0 +1,453 @@ +/* mp.h — MeowPanel 后端共享内部头(模块化拆分后各 .c 共同包含)。 + * 定位(MEOWPANEL_DESIGN §1/§2):外置于 TF 卡 `MeowOS/opt/MeowPanel/` 的系统组件 + * (HTTP/JSON 后端 + 前端静态托管),由 meow_svcd 读 TF 卡 services.d 启动。**不进固件**。 + * 这是拆分后的单一共享内部头:共享常量/类型/外部全局 + 所有跨模块函数原型。 + * 各 api_* 域 / store / ipc / util / json / server 均以 `#include "mp.h"` 起步。 + * 编译(mipsel 静态):build_meowpanel.sh;宿主编译 build_meowpanel.sh host。 */ +#ifndef MP_H +#define MP_H +#define _GNU_SOURCE /* gethostname/strcasestr/strnlen 等 GNU/musl 扩展 */ + +/* meowpanel.c — MeowOS Web 面板后端(MeowPanel)。 + * + * 定位(MEOWPANEL_DESIGN §1/§2):外置于 TF 卡 `MeowOS/opt/MeowPanel/` 的系统组件 + * (HTTP/JSON 后端 + 前端静态托管),由 meow_svcd 读 TF 卡 `MeowOS/services.d/meowpanel.conf` + * 启动、meow_sysui 监管。**不进固件**;无卡时系统核心绝不对它有任何依赖。 + * + * 架构(MEOWPANEL_PROTOCOL §1/§2 + SYSTEM_FRAMEWORK_SERVICES §4): + * - 单进程单线程 + poll 串行处理(面板访问量极小);内嵌 HTTP server。 + * - 读侧(/api/status,/api/perf,/api/system)经 meow_svc_perf IPC(MSG_PERF_QUERY→RESULT) + * 聚合,**绝不各自爬 /proc**(单一权威源)。 + * - 动作侧(/api/system/{reboot,shutdown,ota})经 meow_svc_power IPC(MSG_POWER_ACTION→STATUS), + * 不再直连 poweroff/写 mtd5。 + * - 设置镜像(/api/settings*,/api/theme*)直接读写板载 /usr/data/meow/settings.conf—— + * **与 meow_ui_vol_read / meow_svc_power settings_get_int_trim 同款解析**('=' 切分 + + * key 去首尾空白精确匹配;写用 "key = value\n")。后端是独立进程,无法链接 launcher 内的 + * meow_settings_*(那是 launcher 进程内存表),故直接镜像文件,保持同一存储契约。 + * - 文件/应用/日志/通知:白名单根 /usr/data/mnt/sd_0 下 MeowOS/Music/MeowData/MeowAPPs/book。 + * + * 编译(mipsel 静态):build_meowpanel.sh;宿主编译 build_meowpanel.sh host。 + * 依赖:仅 POSIX + libc(含 linux/input.h 内核头),零第三方库(mp_jpeg.c 自研最小 JPEG; + * 字体库导入/导出走 busybox `gzip -dc | tar` 管道——与 nyai .mpk 同构,设备必带 tar/gzip)。 + * 设备无 python3/lua。 + */ +#include +#include +#include +#include /* strcasecmp/strncasecmp */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include /* KEY_* / EV_KEY / EV_SYN / struct input_event(按键注入) */ + +#include "meow_ipc.h" /* 帧格式/消息号 + m_perf_query_t/m_perf_result_t + POWER_* 枚举 */ +#include "mp_jpeg.h" /* 实时画面:RGB565 → 最小 baseline JPEG(自包含,零依赖) */ + +/* 实时画面 / 触摸注入(阶段 C)。fb ioctl 常量按 launcher fb_compositor 同款手工定义 + * (避免 libc/kernel struct ABI 差异,读入裸字节缓冲再按偏移取字段)。 */ +#ifndef FBIOGET_VSCREENINFO +#define FBIOGET_VSCREENINFO 0x4600 +#define FBIOGET_FSCREENINFO 0x4602 +#endif +#ifndef FB_DEV +#define FB_DEV "/dev/fb0" /* 16bpp RGB565,480x720,双层虚拟缓冲 */ +#endif +#ifndef TOUCH_DEV +#define TOUCH_DEV "/dev/input/event1" /* goodix-ts:接收 write() 注入(本机实测可投递) */ +#endif + +/* ---------------- 常量 ---------------- */ +#define PANEL_VERSION "0.1" +#define DEFAULT_PORT 8080 +#define SETTINGS_FILE "/usr/data/meow/settings.conf" +#define SETTINGS_HDR "# meow-settings 1" +#define TF_ROOT g_tf_root_buf +#define TF_CARD_DIR g_tf_card +#define MEOWAPPS_DIR g_apps_dir +#define LOGS_DIR g_logs_dir +#define PERF_SOCK "/usr/data/meow_perf.sock" +#define POWER_SOCK "/usr/data/meow_power.sock" +#define LAUNCHER_SOCK "/usr/data/meow_ipc.sock" /* launcher 主 IPC(通知活跃集查询/撤清) */ +#define AUDIO_SOCK "/usr/data/meow_audio.sock" /* meow_audiod 状态快照(MSG_AUDIO_QUERY→STATE) */ +#define NET_SOCK "/usr/data/meow_net.sock" /* meow_netmgrd 网络管理(MSG_NET_*,期2 观测层/DNS 代理) */ +#define PM_SOCK "/usr/data/meow_pm.sock" /* meow_powermgr 功耗管理(MSG_PM_QUERY→RESULT) */ +#define NYAI_PATH "/usr/bin/nyai" /* 固件内置(权威) */ +#define NYAI_PATH_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai" /* 旧卡上路径(回退) */ +#define AMS_BIN "/usr/bin/am" /* 固件内置 amsd 用户侧 CLI(权威) */ +#define AMS_BIN_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/am" /* 旧卡上路径(回退) */ +#define SVCD_BIN "/usr/bin/meow_svcd" /* 固件内置 svcd 服务表 CLI(权威) */ +#define SVCD_BIN_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/meow_svcd" /* 旧卡上路径(回退) */ +#define BOOT_DEFAULT_FILE "/data/meow_boot_default" +#define VERSION_FILE "/etc/meowos_version" +#define HOSTNAME_FILE "" +#define IPC_TIMEOUT_MS 2000 +#define MAX_HTTP_HEADER (64 * 1024) +#define MAX_HTTP_BODY (16 * 1024 * 1024) /* 通用 body 上限(JSON/小件) */ +#define MAX_UPLOAD_BODY (8 * 1024 * 1024) /* multipart 上传上限(14MB 内存约束:不整读超 8MB) */ +#define MAX_FONT_UPLOAD (32 * 1024 * 1024) /* 字体库 tar.gz 上传上限(gbk 字库 png+idx 可能 10-20MB) */ +#define MAX_JSON_IN 65536 /* JSON body 解析上限 */ +#define MAX_STATIC_FREEBUF (2 * 1024 * 1024) +#define LOG_MAX_LINES 1000 +#define NOTIF_PREVIEW_MAX 200 /* 通知列表返回上限(每条约 0.2KB,总 <50KB) */ +#define TEXT_PREVIEW_MAX (1024 * 1024) /* 文本预览上限(超出截断,truncated=true;14MB 约束下 1MB 缓冲安全) */ +#define PANEL_VOL_MAX_DEFAULT 100 /* 音频音量上限默认(对齐 meow_launcher MEOW_VOL_MAX_DEFAULT;设备可经 settings.conf audio.volume_max 覆盖) */ +#define ALLOWED_COUNT ((int)(sizeof g_allowed_dirs / sizeof g_allowed_dirs[0])) +#define SENSITIVE_COUNT ((int)(sizeof g_sensitive_names / sizeof g_sensitive_names[0])) +#define SCREEN_BLOCK 8 /* 比对块边长(像素) */ +#ifndef MSG_PM_POLICY_GET +#define MSG_PM_POLICY_GET 0x00D2 /* ↑ 策略读(空负载 → MSG_PM_RESULT policy JSON;读级任意调用者) */ +#define MSG_PM_POLICY_SET 0x00D3 /* ↑ 策略写(req: m_pm_policy_set_t;动作级=root+系统身份) */ +#define MSG_PM_HISTORY 0x00D4 /* ↑ 历史采样查询(req: u32 count,0=默认) */ +#define MSG_PM_HISTORY_RESULT 0x00D5 /* ↓ 历史 JSON */ +#define MSG_PM_EVENTS 0x00D6 /* ↑ 事件流查询(req: u32 count,0=默认) */ +#define MSG_PM_EVENTS_RESULT 0x00D7 /* ↓ 事件 JSON */ +#define MSG_PM_CALIB_START 0x00D8 /* ↑ 标定启动(动作级;本期面板不暴露,预留) */ +#define MSG_PM_CALIB_STOP 0x00D9 /* ↑ 标定停止(动作级;预留) */ +#endif +#define AUTH_TOKEN_BYTES 16 /* 128bit → 32 hex */ +#define AUTH_TOKEN_TTL (3 * 86400) /* 3 天(固定,不滑动续期) */ +#define AUTH_MAX_SESS 8 /* 内存最多保存的并发会话数 */ +#define AUTH_HASH_LEN 64 /* sha256 hex 长度 */ + +/* ---------------- 有界动态字符串(sbuf) ---------------- */ +typedef struct { char *b; size_t n; size_t cap; } sbuf_t; + +/* ---------------- 最简 JSON 解析器(无第三方库) ---------------- + * 只覆盖 MeowPanel 实际用到的:对象 / 数组 / 字符串(含转义) / 数字 / bool / null。 + * value 树:J_OBJ 的 child 为键值对链(str=key,child=value,next=下个 pair); + * J_ARR 的 child 为元素链(next=下个元素)。 + */ +enum { J_NULL, J_BOOL, J_NUM, J_STR, J_ARR, J_OBJ }; + +typedef struct jv { + int type; + int bval; /* J_BOOL */ + long long inum; /* J_NUM 整数值 */ + double dnum; /* J_NUM 浮点值 */ + char *str; /* J_STR: 值; J_OBJ: key */ + struct jv *child; /* J_ARR: 头; J_OBJ: 头 pair */ + struct jv *next; /* 链 */ +} jv_t; + +typedef struct { const char *p; const char *end; int err; } jvp_t; + +enum { ST_INT, ST_STR, ST_ENUM, ST_BOOL }; + +typedef struct setdef { + const char *key; + int type; + const char *def; + int min, max; /* ST_INT 取值域;max<=0 表示无上界 */ + const char *enum_names; /* ST_ENUM 逗号名 */ + const char *enum_labels; /* 对应中文 */ + const char *group; /* 分组标签(协议 groups 用) */ +} setdef_t; + +/* 期 3 · 策略写负载(item="profile"/"low-pct"/"crit-pct"/"bri-cap"/"scr-cap")。 */ +typedef struct m_pm_policy_set { char item[16]; char val[32]; } m_pm_policy_set_t; /* 48 B */ + + +struct fent { char name[256]; int isdir; long long size, mtime; }; + +/* ---------------- HTTP 请求读取 ---------------- */ +typedef struct { + char method[8]; + char path[512]; /* 解码后的路径(不含 query) */ + char query[1024]; /* 原始 query(key=value&…,未解码;取值时用 qparam) */ + char *body; + size_t body_len; + char content_type[128]; /* 请求 Content-Type(若非空) */ + char authorization[256]; /* Authorization 头(Bearer ) */ + char x_meow_token[256]; /* X-MeowToken 头 */ + char cookie[512]; /* Cookie 头(meow_token= 等) */ +} req_t; + +typedef struct { char path[1400]; char filename[320]; } upfile_t; + +typedef struct { + long long ms; /* created_ms(epoch ms) */ + unsigned id; + char app_id[40]; + char app_icon[64]; + char category[40]; + int urgency; + int progress; /* -1=无 */ + char summary[96]; + char body[128]; + char target[64]; + int ongoing, media, dismissed; +} notif_rec_t; + +typedef struct { uint32_t state[8]; uint64_t count; uint8_t buf[64]; size_t buflen; } mp_sha256_ctx; + +/* ---- 会话(**纯内存**:重启自然失效,无卡上持久化) ---- */ +typedef struct { char token[65]; time_t expires; } auth_sess_t; + +/* ---------------- 跨模块全局(extern;定义见各拥有模块 .c) ---------------- */ +extern const char *g_settings_file; +extern char g_tf_root_buf[1024]; +extern char g_tf_card_buf[2048], g_apps_dir_buf[2048], g_logs_dir_buf[2048], g_notif_log_buf[2048]; +extern const char *g_tf_card, *g_apps_dir, *g_logs_dir, *g_notif_log; +extern const char *g_allowed_dirs[]; +extern const char *g_sensitive_names[]; +extern const char *g_sock_perf, *g_sock_power, *g_sock_launcher, *g_sock_audio, *g_sock_net, *g_sock_pm; +extern time_t g_auth_ttl; + +/* ---------------- 函数原型(全部跨模块;无 static,避免 -Wunused-function) ---------------- */ +void init_tf_paths(void); +int sb_reserve(sbuf_t *s, size_t extra); +void sA(sbuf_t *s, const char *fmt, ...); +int utf8_seq_len(unsigned char lead); +int json_strict_balanced(const char *b, size_t n); +int utf8_seq_ok(const char *p, int len); +int utf8_valid_n(const char *s, size_t n); +int utf8_sanitize(char *dst, const char *src, size_t n); +void sA_str_n(sbuf_t *s, const char *v, size_t n); +void sA_str(sbuf_t *s, const char *v); +void sA_append(sbuf_t *s, const char *b, size_t n); +void sbuf_free(sbuf_t *s); +jv_t *jv_alloc(int type); +void jv_free(jv_t *v); +void jvp_skip_ws(jvp_t *t); +char *jv_parse_string(jvp_t *t); +jv_t *jv_parse_object(jvp_t *t); +jv_t *jv_parse_array(jvp_t *t); +jv_t *jv_parse_value(jvp_t *t); +jv_t *jv_parse(const char *text, int len); +jv_t *jv_obj_get(const jv_t *obj, const char *key); +void jv_to_json(sbuf_t *s, const jv_t *v); +const setdef_t *setdef_find(const char *key); +void settings_mkdir_p(void); +char *settings_get_value(const char *key); +int settings_get_int(const char *key, int defv); +int brightness_clamp(int v); +int settings_write_one(const char *key, const char *val); +int settings_coerce(const setdef_t *d, const char *input, char *out, size_t outcap); +int settings_reset_all(void); +int x_read_full(int fd, uint8_t *buf, size_t n); +int x_write_full(int fd, const uint8_t *buf, size_t n); +int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payload, uint32_t len); +int recv_frame(int fd, uint16_t *type, uint32_t *req_id, uint8_t *buf, uint32_t cap); +int ipc_connect_tmo(const char *sock, int tmo_ms); +int ipc_hello(int fd); +const char *perf_code_str(int code); +int perf_subset_for_path(const char *path); +int ipc_perf_query(int subset, char *out, int cap); +const char *power_code_str(int code); +int ipc_power_action(uint32_t cmd, uint32_t flag, const char *arg, m_power_status_t *out_st); +void svc_msg_kv(const char *msg, const char *key, char *out, size_t cap); +const char *audio_kv(const char *txt, const char *key, char *out, size_t cap); +long long audio_kv_ll(const char *txt, const char *key, long long defv); +int audio_vol_max(void); +void audio_fill(sbuf_t *s); +int api_audio(int sock); +int audio_write_cmd(uint16_t type, const void *pl, uint32_t len, m_ack_t *out); +int api_audio_route(int sock, const char *body, size_t body_len); +int api_audio_volume(int sock, const char *body, size_t body_len); +int api_audio_monitor(int sock, const char *query); +int ipc_pm_query(uint32_t subset, char *out, int cap); +int api_power(int sock); +int ipc_pm_send(uint16_t req_type, const void *pl, uint32_t plen, uint16_t *rtype, char *out, int cap); +int api_power_policy_get(int sock); +int api_power_policy_set(int sock, const char *body, size_t body_len); +int api_power_window(int sock, uint16_t req_type, uint16_t want_type, const char *unavail); +int api_power_history(int sock); +int api_power_events(int sock); +const char *ams_bin(void); +const char *svcd_bin(void); +int net_ipc(uint16_t req_type, const void *req, uint32_t req_len, uint16_t *ret_type, uint32_t *code, uint16_t *meta, char *out, size_t cap); +int api_net_status(int sock); +int net_conns_parse_body(sbuf_t *s, const char *txt); +int api_net_conns(int sock, const char *query); +int net_dns_obj_fill(sbuf_t *s); +int api_net_dns(int sock); +int net_block_fill(sbuf_t *s); +int api_net_dns_flush(int sock); +int api_net_log(int sock); +int api_net_ifaces(int sock); +void net_policy_parse(const char *txt, sbuf_t *s); +int api_net_policy_get(int sock); +int api_net_policy_set(int sock, const char *body, size_t body_len); +void panel_strip_ansi(char *s); +int txt_tokens(char *buf, char **toks, int maxtoks); +void join_tokens(char **toks, int from, int to, char *out, size_t cap); +void ams_fill(sbuf_t *s); +void ams_fg_fill(sbuf_t *s); +int api_ams(int sock); +int ams_pkg_ok(const char *pkg); +void ams_history_parse(char *out, sbuf_t *s, const char *pkg); +void ams_history_fill(sbuf_t *s, const char *pkg); +int api_ams_history(int sock, const char *pkg); +int api_ams_ctl(int sock, const char *pkg, int kill); +void svcd_fill(sbuf_t *s); +int api_svcd(int sock); +int api_overview(int sock); +int tf_present(void); +int path_in_whitelist(const char *abs); +int is_sensitive_name(const char *base); +int normalize_path(const char *in, char *buf, size_t buflen); +void make_rel(const char *abs, char *buf, size_t buflen); +int open_safe_for_read(const char *abs, int follow_rel); +void fmt_json_item(sbuf_t *s, const char *name, int isdir, long long size, long long mtime); +int nat_strcmp(const char *a, const char *b); +int fent_cmp(const void *a, const void *b); +int files_list(int sock, const char *relpath); +char *read_text_file_limited(const char *abs, long long *size, int *truncated); +int keycode_to_device(int code, const char **dev); +int inject_key_event(const char *dev, int code, int down); +int inject_key_release(int code, int press_ms); +int read_small_file(const char *path, char *buf, int cap); +int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail); +void req_free(req_t *r); +int is_hex(char c); +int hexval(char c); +void url_decode(const char *in, char *buf, int buflen); +int qparam(const char *query, const char *key, char *out, int outcap); +int path_segment(const char *prefix, const char *full, char *out, int outcap); +void split_path_query(const char *src, char *path, int plc, char *query, int qlc); +ptrdiff_t find_header_end(const char *buf, size_t n); +int read_request(int sock, req_t *r); +void mp_sha256_init(mp_sha256_ctx *c); +void mp_sha256_block(mp_sha256_ctx *c, const uint8_t *p); +void mp_sha256_update(mp_sha256_ctx *c, const uint8_t *data, size_t len); +void mp_sha256_final(mp_sha256_ctx *c, uint8_t out[32]); +int auth_read_random(unsigned char *buf, size_t n); +void auth_hex(const unsigned char *raw, size_t n, char *out); +int auth_token_gen(char *out, int outcap); +int auth_make_salt(char *out, int outcap); +int auth_pass_hash(const char *salt, const char *password, char *out, int outcap); +int auth_make_hash(const char *password, char *out, int outcap); +int hash_matches(const char *stored, const char *password); +int pin_set(void); +int auth_pass_set(void); +int auth_verify(const char *password); +int pin_verify(const char *pin); +int pin_validate(const char *pin); +long long pin_lock_remaining(void); +void pin_lock_reset(void); +void pin_record_fail(void); +int sess_find(const char *token); +int sess_valid(const char *token); +void sess_add(const char *token, time_t expires); +void sess_remove(const char *token); +void auth_cp(char *dst, int dcap, const char *src); +int extract_token(const req_t *r, char *out, int outcap); +int require_auth(int sock, const req_t *r); +int api_auth_status(int sock, const req_t *r); +int api_auth_login(int sock, const req_t *r); +int api_auth_setup(int sock, const req_t *r); +int api_auth_logout(int sock, const req_t *r); +int write_full(int sock, const void *b, size_t n); +int send_head(int sock, int status, const char *ctype, long long clen, const char *extra); +int send_raw(int sock, int status, const char *ctype, const void *body, size_t bodylen); +int send_ok(int sock, const char *data); +int send_err(int sock, int status, const char *code, const char *msg); +int send_json(int sock, int status, const char *data); +int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename); +const char *mem_find(const void *hay, size_t hlen, const char *needle); +int safe_filename(const char *name, char *out, size_t cap); +int parse_multipart(const req_t *r, upfile_t *files, int maxfiles); +void panel_log(const char *fmt, ...); +int str_truthy(const char *v); +int int_meta_max(const setdef_t *d); +const char *csv_at(const char *s, int idx, char *buf, int buflen); +int csv_count(const char *s); +void enum_options_json(sbuf_t *s, const char *names, const char *labels); +void settings_item_json(sbuf_t *s, const setdef_t *d, const char *val); +char *settings_current_value(const setdef_t *d); +int api_settings_get_any(int sock, const char *key); +int api_settings_get_all(int sock); +int api_settings_put(int sock, const char *key, const req_t *r); +int api_settings_batch(int sock, const req_t *r); +int api_settings_restore(int sock); +void theme_json(sbuf_t *s); +int api_theme_get(int sock); +int api_theme_put(int sock, const req_t *r); +int api_perf(int sock, const char *path); +int perf_extract_network(const char *json, char *out, int cap); +int api_system(int sock); +int api_pin_unlock_once(int sock); +int api_system_action(int sock, const char *which, const req_t *r); +int api_key_single(int sock, int code, const req_t *r); +int api_key_batch(int sock, const req_t *r); +const char *log_group(const char *name); +int api_logs_list(int sock); +int api_logs_read(int sock, const req_t *r); +int api_logs_crash(int sock); +int notif_csv_field(const char **pp, char *out, int outcap); +int notif_parse_line(const char *line, notif_rec_t *r); +int notif_load_list(notif_rec_t *recs, int maxrec); +const char *notif_app_name(const char *app_id); +void notif_emit_item(sbuf_t *out, const notif_rec_t *r); +int notif_ipc_query(notif_rec_t *recs, int maxrec); +int notif_ipc_dismiss(unsigned id); +int notif_ipc_dismiss_all(int *out_count); +int api_notifications(int sock); +int notif_rewrite(int mode, unsigned del_id); +int api_notification_delete(int sock, unsigned id); +int api_notification_dismiss_all(int sock); +int api_files_list(int sock, const req_t *r); +int api_files_read(int sock, const req_t *r); +int api_files_download(int sock, const req_t *r); +int api_files_upload(int sock, const req_t *r); +int rm_recursive(const char *path); +int api_files_mkdir(int sock, const req_t *r); +int api_files_rename(int sock, const req_t *r); +int api_files_delete(int sock, const req_t *r); +void fonts_root(char *buf, size_t cap); +int safe_dir_segment(const char *s, char *out, size_t cap); +int unsafe_path(const char *s); +int has_dotdot_comp(const char *p); +int sanitize_member(const char *in, char *out, int n); +int mkdir_p(const char *path); +const jv_t *font_json_str(const jv_t *root, const char *key); +int api_fonts_list(int sock); +int sha256_file_hex(const char *path, char out[65]); +int api_fonts_import(int sock, const req_t *r); +int api_fonts_export(int sock, const req_t *r); +int run_cmd(const char *cmd, char *out, int outcap); +int api_apps_list(int sock); +int api_apps_info(int sock, const char *pkg); +int multipart_extract_file(const req_t *r, const char *outpath); +const char *nyai_bin(void); +int api_apps_install(int sock, const req_t *r); +int api_apps_export(int sock, const char *pkg); +int api_apps_remove(int sock, const char *pkg, const req_t *r); +int screen_read_frame(const char *dev, uint8_t **out, int *w, int *h); +int fb_dims(const char *dev, int *w, int *h); +void screen_prev_store(const uint8_t *frame, int w, int h); +int screen_diff_blocks(const uint8_t *cur, const uint8_t *prev, int w, int h, int *bx, int *by, int *bw, int *bh, int *total_blocks); +int api_screen_jpeg(int sock, const req_t *r); +int64_t now_mono_ms(void); +int64_t touch_last_local_ms(const char *dev); +int inject_tap(const char *dev, int x, int y); +int api_input_tap(int sock, const req_t *r); +const char *web_root(void); +const char *mime_for(const char *path); +int serve_static(int sock, const char *path); +int handle(int sock, req_t *r); +int read_port_config(void); +void handle_conn(int sock); +int panel_selftest(void); +int main(int argc, char **argv); + +#endif /* MP_H */ \ No newline at end of file diff --git a/patch_v2/meowpanel/src/mp_ams.c b/patch_v2/meowpanel/src/mp_ams.c new file mode 100644 index 0000000..8bc0ee2 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_ams.c @@ -0,0 +1,196 @@ +/* mp_ams.c — App 运行表域:am list 解析 + amd 历史 + start/kill 控制。 */ +#include "mp.h" + + +const char *ams_bin(void) +{ + const char *e = getenv("MEOW_AMS_BIN"); + if (e && e[0]) return e; + if (!access(AMS_BIN, X_OK)) return AMS_BIN; + return AMS_BIN_CARD; +} + +void ams_fill(sbuf_t *s) +{ + char out[16384]; + char cmd[300]; + snprintf(cmd, sizeof cmd, "%s list 2>/dev/null", ams_bin()); + int code = run_cmd(cmd, out, sizeof out); + if (code != 0 || !strstr(out, "APP")) { sA(s, "{\"available\":false}"); return; } + panel_strip_ansi(out); + char *toks[256]; + sbuf_t apps = {0}; + int first = 1, count = 0; + char *ln = out; + while (*ln) { + char *nl = strchr(ln, '\n'); + if (nl) *nl = 0; + if (strncmp(ln, "APP ", 4) == 0) { + int n = txt_tokens(ln, toks, 256); + if (n >= 9) { + /* 布局:APP + * tok[0]="APP" 前缀;自右向左固定:state=tok[n-6], pid=n-5, launches=n-4, crashes=n-3, + * last_kind=n-2, last_code=n-1;pkg=tok[1],name=join(tok[2..n-6))。 */ + char nm[128]; + join_tokens(toks, 2, n - 6, nm, sizeof nm); + if (!first) sA(&apps, ","); + first = 0; count++; + sA(&apps, "{\"pkg\":"); sA_str(&apps, toks[1]); + sA(&apps, ",\"name\":"); sA_str(&apps, nm); + sA(&apps, ",\"state\":"); sA_str(&apps, toks[n-6]); + sA(&apps, ",\"pid\":%d", atoi(toks[n-5])); + sA(&apps, ",\"launches\":%d", atoi(toks[n-4])); + sA(&apps, ",\"crashes\":%d", atoi(toks[n-3])); + sA(&apps, ",\"last_kind\":"); sA_str(&apps, toks[n-2]); + sA(&apps, ",\"last_code\":%d}", atoi(toks[n-1])); + } + } + if (nl) { *nl = '\n'; ln = nl + 1; } else break; + } + sA(s, "{\"available\":true,\"count\":%d,\"apps\":[%s]", count, apps.b ? apps.b : ""); + sbuf_free(&apps); + ams_fg_fill(s); /* 追加 `,"fg":{..},"running":[..]`(首页前台 App 高亮;additive) */ + sA(s, "}"); +} + +void ams_fg_fill(sbuf_t *s) +{ + char out[16384]; + char cmd[300]; + snprintf(cmd, sizeof cmd, "%s status 2>/dev/null", ams_bin()); + int code = run_cmd(cmd, out, sizeof out); + if (code != 0 || !strstr(out, "RUN")) { sA(s, ",\"fg\":null,\"running\":[]"); return; } + panel_strip_ansi(out); + char *toks[256]; + char fg_pkg[128] = "", fg_act[160] = ""; int fg_pid = 0; + sbuf_t run = {0}; int first = 1; + char *ln = out; + while (*ln) { + char *nl = strchr(ln, '\n'); + if (nl) *nl = 0; + if (strncmp(ln, "RUN ", 4) == 0) { + int n = txt_tokens(ln, toks, 256); + if (n >= 6) { /* RUN pkg state pid dur activity... */ + char act[160]; + join_tokens(toks, 5, n, act, sizeof act); + const char *st = toks[2]; + if (!first) sA(&run, ","); + first = 0; + sA(&run, "{\"pkg\":"); sA_str(&run, toks[1]); + sA(&run, ",\"state\":"); sA_str(&run, st); + sA(&run, ",\"pid\":%d", atoi(toks[3])); + sA(&run, ",\"activity\":"); sA_str(&run, act); + sA(&run, "}"); + if (strcmp(st, "fg") == 0 && !fg_pkg[0]) { + snprintf(fg_pkg, sizeof fg_pkg, "%s", toks[1]); + snprintf(fg_act, sizeof fg_act, "%s", act); + fg_pid = atoi(toks[3]); + } + } + } + if (nl) { *nl = '\n'; ln = nl + 1; } else break; + } + sA(s, ",\"fg\":"); + if (fg_pkg[0]) { sA(s, "{\"pkg\":"); sA_str(s, fg_pkg); sA(s, ",\"activity\":"); sA_str(s, fg_act); sA(s, ",\"pid\":%d}", fg_pid); } + else sA(s, "null"); + sA(s, ",\"running\":[%s]", run.b ? run.b : ""); + sbuf_free(&run); +} + +int api_ams(int sock) +{ + sbuf_t s = {0}; + ams_fill(&s); + int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}"); + sbuf_free(&s); + return rc; +} + +int ams_pkg_ok(const char *pkg) +{ + if (!pkg || !pkg[0]) return 0; + if (strlen(pkg) >= MEOW_APP_ID_MAX) return 0; + for (const char *p = pkg; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '/' || c == ':' || c == ' ' || c == '\t' || c == '\n' || c == '\r') return 0; + if (c < 0x21 || c > 0x7e) return 0; /* 包名仅 ASCII 可见字符 */ + } + return 1; +} + +void ams_history_parse(char *out, sbuf_t *s, const char *pkg) /* out 被 panel_strip_ansi 就地改 */ +{ + if (!utf8_valid_n(out, strlen(out))) { sA(s, "{\"available\":false}"); return; } /* 崩溃 detail 坏字节 → 降级 */ + panel_strip_ansi(out); + char *toks[256]; + sbuf_t ev = {0}; + int first = 1, count = 0; + char *ln = out; + while (*ln) { + char *nl = strchr(ln, '\n'); + if (nl) *nl = 0; + if (strncmp(ln, "EVT ", 4) == 0) { + int n = txt_tokens(ln, toks, 256); + if (n >= 5) { /* EVT epoch up kind pkg detail... */ + char det[256]; + join_tokens(toks, 5, n, det, sizeof det); + if (!first) sA(&ev, ","); + first = 0; count++; + sA(&ev, "{\"ts\":%ld,\"uptime_sec\":%ld,\"kind\":", atol(toks[1]), atol(toks[2])); + sA_str(&ev, toks[3]); + sA(&ev, ",\"detail\":"); sA_str(&ev, det); + sA(&ev, "}"); + } + } + if (nl) { *nl = '\n'; ln = nl + 1; } else break; + } + sA(s, "{\"available\":true,\"pkg\":"); sA_str(s, pkg); + sA(s, ",\"count\":%d,\"events\":[%s]}", count, ev.b ? ev.b : ""); + sbuf_free(&ev); +} + +void ams_history_fill(sbuf_t *s, const char *pkg) +{ + char out[16384]; + char cmd[320]; + snprintf(cmd, sizeof cmd, "%s history %s 2>/dev/null", ams_bin(), pkg); + int code = run_cmd(cmd, out, sizeof out); + if (code != 0 || !strstr(out, "EVT")) { sA(s, "{\"available\":false}"); return; } + ams_history_parse(out, s, pkg); +} + +int api_ams_history(int sock, const char *pkg) +{ + if (!ams_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + sbuf_t s = {0}; + ams_history_fill(&s, pkg); + int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}"); + sbuf_free(&s); + return rc; +} + +int api_ams_ctl(int sock, const char *pkg, int kill) +{ + if (!ams_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + char out[256]; + char cmd[320]; + snprintf(cmd, sizeof cmd, "%s %s %s 2>/dev/null", ams_bin(), kill ? "kill" : "start", pkg); + int code = run_cmd(cmd, out, sizeof out); + if (code == 0 && strstr(out, "OK ")) { + sbuf_t s = {0}; + sA(&s, "{\"accepted\":true,\"op\":\"%s\",\"pkg\":", kill ? "kill" : "start"); + sA_str(&s, pkg); + sA(&s, ",\"message\":"); sA_str(&s, out); + sA(&s, "}"); + int rc2 = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc2; + } + /* am CLI 以文本报告失败(ERR ...);按内容映射 HTTP 码(如实,绝不掩盖)。 */ + const char *detail = out[0] ? out : (kill ? "am kill 失败" : "am start 失败"); + if (strstr(detail, "not registered")) return send_err(sock, 404, "NOT_FOUND", detail); + if (strstr(detail, "busy")) return send_err(sock, 409, "CONFLICT", detail); + if (strstr(detail, "not running")) return send_err(sock, 409, "CONFLICT", detail); + if (strstr(detail, "launcher offline")) return send_err(sock, 503, "AMS_UNAVAILABLE", "amsd/launcher 不可达"); + return send_err(sock, 500, "INTERNAL", detail); +} diff --git a/patch_v2/meowpanel/src/mp_apps.c b/patch_v2/meowpanel/src/mp_apps.c new file mode 100644 index 0000000..4a52a13 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_apps.c @@ -0,0 +1,242 @@ +/* mp_apps.c — 应用域:MeowAPPs 扫描 + nyai 安装/导出/移除。 */ +#include "mp.h" + + +int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail) +{ + char pj[1024]; + snprintf(pj, sizeof pj, "%s/%s/app.json", appdir, name); + char buf[8192]; + if (read_small_file(pj, buf, sizeof buf) <= 0) { + /* 解析失败项:invalid */ + if (!want_detail) { + sA(out, "{\"package\":"); + sA_str(out, name); + sA(out, ",\"name\":null,\"version\":null,\"size\":0,\"installed\":true,\"invalid\":true}"); + } + return -1; + } + jv_t *root = jv_parse(buf, (int)strlen(buf)); + if (!root) { + if (!want_detail) { + sA(out, "{\"package\":"); + sA_str(out, name); + sA(out, ",\"name\":null,\"version\":null,\"size\":0,\"installed\":true,\"invalid\":true}"); + } + return -1; + } + jv_t *jpkg = jv_obj_get(root, "package"); + jv_t *jname = jv_obj_get(root, "name"); + jv_t *jver = jv_obj_get(root, "version"); + jv_t *jexec = jv_obj_get(root, "exec"); + jv_t *jfw = jv_obj_get(root, "framework"); + jv_t *jim = jv_obj_get(root, "immersive"); + jv_t *jfull = jv_obj_get(root, "fullscreen"); + const char *pkg = (jpkg && jpkg->type == J_STR) ? jpkg->str : name; + const char *nm = (jname && jname->type == J_STR) ? jname->str : ""; + const char *ver = (jver && jver->type == J_STR) ? jver->str : ""; + const char *exec = (jexec && jexec->type == J_STR) ? jexec->str : ""; + /* 目录大小(递归统计,限 8 层防爆) -- 简化:stat 直接子项合计 */ + long long dsize = 0; + snprintf(pj, sizeof pj, "%s/%s", appdir, name); + DIR *d = opendir(pj); + if (d) { + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s/%s", appdir, name, e->d_name); + struct stat st; + if (stat(full, &st) == 0) dsize += (long long)st.st_size; + } + closedir(d); + } + if (!want_detail) { + sA(out, "{\"package\":"); + sA_str(out, pkg); + sA(out, ",\"name\":"); + sA_str(out, nm[0] ? nm : NULL); + sA(out, ",\"version\":"); + sA_str(out, ver[0] ? ver : NULL); + sA(out, ",\"exec\":"); + sA_str(out, exec); + sA(out, ",\"framework\":%lld,\"immersive\":%lld,\"size\":%lld,\"installed\":true,\"mtime\":%lld}", + jfw ? jfw->inum : 0, jim ? jim->inum : 0, dsize, (long long)time(NULL)); + } else { + sA(out, "{\"package\":"); + sA_str(out, pkg); + sA(out, ",\"name\":"); + sA_str(out, nm[0] ? nm : NULL); + sA(out, ",\"version\":"); + sA_str(out, ver[0] ? ver : NULL); + sA(out, ",\"exec\":"); + sA_str(out, exec); + sA(out, ",\"framework\":%lld,\"immersive\":%lld,\"fullscreen\":%lld,\"size\":%lld,\"path\":", + jfw ? jfw->inum : 0, jim ? jim->inum : 0, jfull ? jfull->inum : 0, dsize); + sA_str(out, pj); + sA(out, ",\"routes\":{},\"services\":[],\"depends\":[],\"description\":\"\"}"); + } + jv_free(root); + return 0; +} + +int api_apps_list(int sock) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + DIR *d = opendir(MEOWAPPS_DIR); + if (!d) return send_err(sock, 404, "NOT_FOUND", "MeowAPPs 目录不存在"); + sbuf_t s = {0}; + sA(&s, "{\"count\":0,\"apps\":["); + int first = 1, count = 0; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + if (!strcmp(e->d_name, "fonts") || !strcmp(e->d_name, "logs") || !strcmp(e->d_name, "backgrounds")) continue; + if (strstr(e->d_name, ".bak")) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", MEOWAPPS_DIR, e->d_name); + struct stat st; + if (stat(full, &st) < 0 || !S_ISDIR(st.st_mode)) continue; + if (!first) sA(&s, ","); + first = 0; + apps_scan_dir(MEOWAPPS_DIR, e->d_name, &s, 0); + count++; + } + closedir(d); + sA(&s, "]}"); + /* count 前端不强制,但协议要求;用两次扫描不可取,故以 "count" 占位并回填:改为在 s 中替换 */ + /* 简化:直接输出 apps 数组,前端只读 d.apps(count 忽略)。 */ + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + (void)count; + return rc; +} + +int api_apps_info(int sock, const char *pkg) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + char appdir[1024]; + snprintf(appdir, sizeof appdir, "%s/%s", MEOWAPPS_DIR, pkg); + struct stat st; + if (stat(appdir, &st) < 0 || !S_ISDIR(st.st_mode)) return send_err(sock, 404, "NOT_FOUND", "应用未安装"); + sbuf_t s = {0}; + if (apps_scan_dir(MEOWAPPS_DIR, pkg, &s, 1) < 0) + return send_err(sock, 404, "NOT_FOUND", "app.json 解析失败"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +const char *nyai_bin(void) +{ + /* nyai 已进固件(/usr/bin,权威);旧版固件回退卡上 MeowOS/opt/bin */ + if (!access(NYAI_PATH, X_OK)) return NYAI_PATH; + return NYAI_PATH_CARD; +} + +int api_apps_install(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (access(nyai_bin(), X_OK) < 0) return send_err(sock, 500, "INTERNAL", "nyai 不可用(固件与卡上均无)"); + char mpkpath[1024]; + int ispath = 0; + if (strncasecmp(r->content_type, "multipart/form-data", 19) != 0) { + /* JSON {path} */ + if (r->body && r->body[0]) { + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *pj = jv_obj_get(root, "path"); + if (pj && pj->type == J_STR) { + snprintf(mpkpath, sizeof mpkpath, "%s", pj->str); + ispath = 1; + } + jv_free(root); + } + if (!ispath) return send_err(sock, 400, "BAD_REQUEST", "缺 path 或 multipart file"); + } else { + /* 保存上传 file 到临时文件 */ + snprintf(mpkpath, sizeof mpkpath, "%s/tmp", TF_CARD_DIR); + mkdir(mpkpath, 0777); + char tmp[1024]; + snprintf(tmp, sizeof tmp, "%s/tmp/upload.mpk", TF_CARD_DIR); + if (multipart_extract_file(r, tmp) < 0) return send_err(sock, 400, "BAD_REQUEST", "上传解析失败"); + snprintf(mpkpath, sizeof mpkpath, "%s", tmp); + } + if (access(mpkpath, R_OK) < 0) return send_err(sock, 404, "NOT_FOUND", ".mpk 文件不存在"); + char cmd[1400]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' install '%s' 2>&1", nyai_bin(), mpkpath); + char out[4096]; + int code = run_cmd(cmd, out, sizeof out); + if (code != 0) return send_err(sock, 422, "UNSUPPORTED", out[0] ? out : "nyai install 失败"); + /* 包名 = 文件名茎;版本从 app.json 读 */ + char stem[256]; + const char *base = strrchr(mpkpath, '/'); + base = base ? base + 1 : mpkpath; + snprintf(stem, sizeof stem, "%.*s", (int)(sizeof stem - 1), base); + char *dot = strrchr(stem, '.'); + if (dot) *dot = 0; + char appdir[1024], pj[1100]; + snprintf(appdir, sizeof appdir, "%s/%s", MEOWAPPS_DIR, stem); + jv_t *approot = NULL; + char version[16] = ""; + int upgraded = 0; + snprintf(pj, sizeof pj, "%s/app.json", appdir); + char jbuf[8192]; + if (access(pj, R_OK) == 0 && read_small_file(pj, jbuf, sizeof jbuf) > 0) + approot = jv_parse(jbuf, (int)strlen(jbuf)); + if (approot) { + jv_t *v = jv_obj_get(approot, "version"); + if (v && v->type == J_STR) snprintf(version, sizeof version, "%s", v->str); + jv_free(approot); + } + if (access(mpkpath, R_OK) == 0 && ispath && strstr(out, "upgrad")) upgraded = 1; + sbuf_t s = {0}; + sA(&s, "{\"package\":"); + sA_str(&s, stem); + sA(&s, ",\"version\":"); + sA_str(&s, version[0] ? version : NULL); + sA(&s, ",\"installed\":true,\"upgraded\":%s,\"message\":\"已安装\"}", upgraded ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_apps_export(int sock, const char *pkg) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + char tmp[1100]; + char card_tmp[1200]; + snprintf(card_tmp, sizeof card_tmp, "%s/tmp", TF_CARD_DIR); + mkdir(card_tmp, 0777); + snprintf(tmp, sizeof tmp, "%s/tmp/%s.mpk", TF_CARD_DIR, pkg); + unlink(tmp); + char cmd[1400]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' export '%s' '%s' 2>&1", nyai_bin(), pkg, tmp); + char out[1024]; + int code = run_cmd(cmd, out, sizeof out); + if (code != 0 || access(tmp, R_OK) < 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai export 失败"); + char dn[256]; + snprintf(dn, sizeof dn, "%s.mpk", pkg); + return send_file(sock, 200, "application/octet-stream", tmp, dn); +} + +int api_apps_remove(int sock, const char *pkg, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + char purgebuf[8] = "0"; + if (r->query[0]) qparam(r->query, "purge", purgebuf, sizeof purgebuf); + int use_purge = atoi(purgebuf); + char cmd[1400]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' remove '%s'%s 2>&1", nyai_bin(), pkg, use_purge ? " --purge" : ""); + char out[1024]; + int code = run_cmd(cmd, out, sizeof out); + if (code != 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai remove 失败"); + sbuf_t s = {0}; + sA(&s, "{\"removed\":true,\"purged\":%s}", use_purge ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} diff --git a/patch_v2/meowpanel/src/mp_audio.c b/patch_v2/meowpanel/src/mp_audio.c new file mode 100644 index 0000000..8b9a791 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_audio.c @@ -0,0 +1,232 @@ +/* mp_audio.c — 音频域:meow_audiod IPC(状态快照 + 写命令 route/volume)。 */ +#include "mp.h" + + +const char *audio_kv(const char *txt, const char *key, char *out, size_t cap) +{ + size_t kl = strlen(key); + const char *p = txt; + while (p && *p) { + if (!strncmp(p, key, kl) && p[kl] == '=') { + p += kl + 1; + size_t n = strcspn(p, " \t\n"); + if (n >= cap) n = cap - 1; + memcpy(out, p, n); + out[n] = 0; + return out; + } + p = strchr(p, ' '); + if (p) p++; + } + out[0] = 0; + return NULL; +} + +long long audio_kv_ll(const char *txt, const char *key, long long defv) +{ + char b[64]; + if (!audio_kv(txt, key, b, sizeof b) || !b[0]) return defv; + return strtoll(b, NULL, 10); +} + +int audio_vol_max(void) +{ + int v = settings_get_int("audio.volume_max", PANEL_VOL_MAX_DEFAULT); + if (v < 0) v = 0; + if (v > 100) v = 100; + return v; +} + +void audio_fill(sbuf_t *s) +{ + int fd = ipc_connect_tmo(g_sock_audio, IPC_TIMEOUT_MS); + if (fd < 0) { sA(s, "{\"available\":false}"); return; } + uint8_t buf[MEOW_IPC_MAX]; + uint16_t type = 0; uint32_t rid = 0; + int n = -1; + if (send_frame(fd, MSG_AUDIO_QUERY, 5, NULL, 0) == 0) + n = recv_frame(fd, &type, &rid, buf, sizeof buf - 1); + close(fd); + if (n <= 0 || type != MSG_AUDIO_STATE) { sA(s, "{\"available\":false}"); return; } + buf[n] = 0; + const char *txt = (const char *)buf; + char app[MEOW_APP_ID_MAX + 8], fmt[32], rmode[16], target[16], volctl[16], codec[32]; + audio_kv(txt, "app", app, sizeof app); + audio_kv(txt, "fmt", fmt, sizeof fmt); + audio_kv(txt, "route_mode", rmode, sizeof rmode); + /* 输出目标/音量控制/蓝牙编解码器(M6.32 面板透传:audiod 已发 target/volctl/codec, + * 前端蓝牙卡据此点亮「输出目标 DAC|蓝牙 / 音量控制 avrcp|dac / 编解码器」)。缺失时前端优雅降级「—」。 */ + audio_kv(txt, "target", target, sizeof target); + audio_kv(txt, "volctl", volctl, sizeof volctl); + audio_kv(txt, "codec", codec, sizeof codec); + sA(s, "{\"available\":true,\"active\":%lld,\"app\":", audio_kv_ll(txt, "active", 0)); + sA_str(s, app); + sA(s, ",\"sid\":%lld,\"rate\":%lld,\"ch\":%lld,\"fmt\":", + audio_kv_ll(txt, "sid", 0), audio_kv_ll(txt, "rate", 0), audio_kv_ll(txt, "ch", 0)); + sA_str(s, fmt); + sA(s, ",\"written_frames\":%lld,\"played_frames\":%lld,\"underrun\":%lld,\"paused\":%lld,\"route_mode\":", + audio_kv_ll(txt, "written", 0), audio_kv_ll(txt, "played", 0), + audio_kv_ll(txt, "underrun", 0), audio_kv_ll(txt, "paused", 0)); + sA_str(s, rmode); + sA(s, ",\"port\":%lld,\"vol\":%lld", + audio_kv_ll(txt, "port", -1), audio_kv_ll(txt, "vol", -1)); + sA(s, ",\"target\":"); + sA_str(s, target); + sA(s, ",\"volctl\":"); + sA_str(s, volctl); + sA(s, ",\"codec\":"); + sA_str(s, codec); + /* 电平监控(261):rms/peak deci-dB(-990=-inf),lvlseq=快照代际;透传时别漏(ABI 追加键)。 */ + sA(s, ",\"rms\":%lld,\"peak\":%lld,\"lvlseq\":%lld,\"vol_max\":%d}", + audio_kv_ll(txt, "rms", -990), audio_kv_ll(txt, "peak", -990), audio_kv_ll(txt, "lvlseq", 0), audio_vol_max()); +} + +int api_audio(int sock) +{ + sbuf_t s = {0}; + audio_fill(&s); + int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}"); + sbuf_free(&s); + return rc; +} + +int audio_write_cmd(uint16_t type, const void *pl, uint32_t len, m_ack_t *out) +{ + int fd = ipc_connect_tmo(g_sock_audio, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + uint8_t buf[64]; + uint16_t rtype = 0; uint32_t rid = 0; + int n = -1; + if (send_frame(fd, type, 0x22, pl, len) == 0) + n = recv_frame(fd, &rtype, &rid, buf, sizeof buf); + close(fd); + if (n < (int)sizeof(m_ack_t) || rtype != MSG_ACK) return -1; + if (out) memcpy(out, buf, sizeof *out); + return 0; +} + +int api_audio_route(int sock, const char *body, size_t body_len) +{ + if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(body, (int)body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *jp = jv_obj_get(root, "port"); + if (!jp || jp->type != J_NUM) { jv_free(root); return send_err(sock, 422, "UNSUPPORTED", "缺 port 数字"); } + long port = (long)jp->inum; + jv_free(root); + if (port != 0 && port != 2 && port != 3) + return send_err(sock, 422, "UNSUPPORTED", "非法路由口(仅允许 0=auto/2=3.5/3=4.4;4=SPDIF、5=audiod 内部值禁止)"); + m_audio_route_t a; a.port = (uint32_t)port; + m_ack_t st; + if (audio_write_cmd(MSG_AUDIO_ROUTE, &a, sizeof a, &st) < 0) + return send_err(sock, 503, "AUDIO_UNAVAILABLE", "音频服务不可用(audiod 未运行/IPC 不可达)"); + sbuf_t s = {0}; + sA(&s, "{\"accepted\":true,\"port\":%ld}", port); + int out = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return out; +} + +int api_audio_volume(int sock, const char *body, size_t body_len) +{ + if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(body, (int)body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *jv = jv_obj_get(root, "value"); + if (!jv || jv->type != J_NUM) { jv_free(root); return send_err(sock, 422, "UNSUPPORTED", "缺 value 数字"); } + long v = (long)jv->inum; + jv_free(root); + int vmax = audio_vol_max(); + if (v < 0) v = 0; + if (v > vmax) v = vmax; /* 关键 clamp 红线 */ + m_audio_volume_t a; a.pct = (int32_t)v; + m_ack_t st; + if (audio_write_cmd(MSG_AUDIO_VOLUME, &a, sizeof a, &st) < 0) + return send_err(sock, 503, "AUDIO_UNAVAILABLE", "音频服务不可用(audiod 未运行/IPC 不可达)"); + sbuf_t s = {0}; + sA(&s, "{\"accepted\":true,\"value\":%ld,\"applied\":%u,\"vol_max\":%d}", v, st.status, vmax); + int out = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return out; +} + +/* ---- /api/audio/monitor(A3 面板音频页增强):读 audiod「全面监控日志」最新 + * audio_monitor_.log 尾部 N 行(默认 200,?lines=N 覆盖)。监控日志由 audiod 主线程 1s + * 节拍写(流活跃才写):时间戳+app+参数+rms/peak dBFS+帧计数+underrun/stall/dropped/txdrop+ + * 路由口+FPGA+音量%+hwrate+dsd 标记(见 meow_audiod.c mon_tick)。面板只读展示,写口一律不开放 + * (A3 定案);audiod 不写/无卡/无文件 → {"available":false}(HTTP 200,不报错)。 + * 复用 mp_logs 的「找最新 <前缀> 文件 + 尾读」模式,分页=尾部 N 行(前端滚动翻页)。 ---- */ +#define AUD_MON_TAIL_MAX (256 * 1024) /* 监控日志单次读取上限(内存约束,不整读大文件) */ +int api_audio_monitor(int sock, const char *query) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + int maxlines = 200; + char ql[16]; + if (query && query[0] && qparam(query, "lines", ql, sizeof ql)) { + int n = atoi(ql); + if (n > 0) maxlines = n; + if (maxlines > LOG_MAX_LINES) maxlines = LOG_MAX_LINES; + } + DIR *d = opendir(LOGS_DIR); + if (!d) return send_json(sock, 200, "{\"available\":false}"); + char best[512] = ""; long best_mt = -1; + struct dirent *e; + while ((e = readdir(d))) { + if (strncmp(e->d_name, "audio_monitor_", 14) != 0) continue; + if (e->d_name[0] == '.') continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, e->d_name); + struct stat st; + if (stat(full, &st) < 0 || !S_ISREG(st.st_mode)) continue; + if (st.st_mtime > best_mt) { best_mt = st.st_mtime; snprintf(best, sizeof best, "%s", e->d_name); } + } + closedir(d); + if (!best[0]) return send_json(sock, 200, "{\"available\":false,\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, best); + int fd = open(full, O_RDONLY); + struct stat st; + if (fd < 0 || fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { + if (fd >= 0) close(fd); + return send_json(sock, 200, "{\"available\":false,\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); + } + off_t off = st.st_size > AUD_MON_TAIL_MAX ? st.st_size - AUD_MON_TAIL_MAX : 0; + size_t buflen = (size_t)(st.st_size - off); + if (buflen == 0) { close(fd); return send_json(sock, 200, "{\"available\":false,\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); } + if (off) (void)lseek(fd, off, SEEK_SET); + char *buf = malloc(buflen + 1); + if (!buf) { close(fd); return send_err(sock, 500, "INTERNAL", "内存不足"); } + ssize_t n = read(fd, buf, buflen); + close(fd); + if (n <= 0) { free(buf); return send_json(sock, 200, "{\"available\":false,\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); } + sbuf_t s = {0}; + sA(&s, "{\"available\":true,\"file\":"); + sA_str(&s, best); + sA(&s, ",\"lines\":["); + const char *p = buf, *end = buf + n; + int linecnt = 0; + for (const char *c = buf; c < end; c++) if (*c == '\n') linecnt++; + if (linecnt > 0 && end[-1] != '\n') linecnt++; + int startrow = linecnt - maxlines; + if (startrow < 0) startrow = 0; + int truncated = (off > 0 || startrow > 0) ? 1 : 0; + int rowidx = 0, gotlines = 0; + p = buf; + while (p < end) { + const char *nl = memchr(p, '\n', (size_t)(end - p)); + const char *line_end = nl ? nl : end; + if (rowidx >= startrow) { + if (gotlines) sA(&s, ","); + sA_str_n(&s, p, (size_t)(line_end - p)); /* 转义 + UTF-8 消毒 */ + gotlines++; + } + rowidx++; + if (!nl) break; + p = nl + 1; + } + free(buf); + sA(&s, "],\"total\":%d,\"truncated\":%s}", linecnt, truncated ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} diff --git a/patch_v2/meowpanel/src/mp_auth.c b/patch_v2/meowpanel/src/mp_auth.c new file mode 100644 index 0000000..551691d --- /dev/null +++ b/patch_v2/meowpanel/src/mp_auth.c @@ -0,0 +1,426 @@ +/* mp_auth.c — 访问级 PIN 鉴权(panel.pin)+ 内联 SHA-256 + 内存会话。 */ +#include "mp.h" + +time_t g_auth_ttl = AUTH_TOKEN_TTL; +#define PIN_MAX_FAILS 5 /* 连续错 PIN 锁定阈值 */ +#define PIN_LOCK_SEC 300 /* 锁定 5 分钟 */ +static const uint32_t mp_sha256_k[64] = { + 0x428a2f98u,0x71374491u,0xb5c0fbcfu,0xe9b5dba5u,0x3956c25bu,0x59f111f1u,0x923f82a4u,0xab1c5ed5u, + 0xd807aa98u,0x12835b01u,0x243185beu,0x550c7dc3u,0x72be5d74u,0x80deb1feu,0x9bdc06a7u,0xc19bf174u, + 0xe49b69c1u,0xefbe4786u,0x0fc19dc6u,0x240ca1ccu,0x2de92c6fu,0x4a7484aau,0x5cb0a9dcu,0x76f988dau, + 0x983e5152u,0xa831c66du,0xb00327c8u,0xbf597fc7u,0xc6e00bf3u,0xd5a79147u,0x06ca6351u,0x14292967u, + 0x27b70a85u,0x2e1b2138u,0x4d2c6dfcu,0x53380d13u,0x650a7354u,0x766a0abbu,0x81c2c92eu,0x92722c85u, + 0xa2bfe8a1u,0xa81a664bu,0xc24b8b70u,0xc76c51a3u,0xd192e819u,0xd6990624u,0xf40e3585u,0x106aa070u, + 0x19a4c116u,0x1e376c08u,0x2748774cu,0x34b0bcb5u,0x391c0cb3u,0x4ed8aa4au,0x5b9cca4fu,0x682e6ff3u, + 0x748f82eeu,0x78a5636fu,0x84c87814u,0x8cc70208u,0x90befffau,0xa4506cebu,0xbef9a3f7u,0xc67178f2u +}; +static int g_pin_fails = 0; +static time_t g_pin_locked_until = 0; +static auth_sess_t g_sess[AUTH_MAX_SESS]; +static int g_sess_n = 0; +#define MP_ROR(x,n) (((x) >> (n)) | ((x) << (32 - (n)))) +#define MP_CH(x,y,z) (((x) & (y)) ^ (~(x) & (z))) +#define MP_MAJ(x,y,z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z))) +#define MP_BSIG0(x) (MP_ROR(x,2) ^ MP_ROR(x,13) ^ MP_ROR(x,22)) +#define MP_BSIG1(x) (MP_ROR(x,6) ^ MP_ROR(x,11) ^ MP_ROR(x,25)) +#define MP_SSIG0(x) (MP_ROR(x,7) ^ MP_ROR(x,18) ^ ((x) >> 3)) +#define MP_SSIG1(x) (MP_ROR(x,17) ^ MP_ROR(x,19) ^ ((x) >> 10)) + + +void mp_sha256_init(mp_sha256_ctx *c) +{ + c->state[0] = 0x6a09e667u; c->state[1] = 0xbb67ae85u; + c->state[2] = 0x3c6ef372u; c->state[3] = 0xa54ff53au; + c->state[4] = 0x510e527fu; c->state[5] = 0x9b05688cu; + c->state[6] = 0x1f83d9abu; c->state[7] = 0x5be0cd19u; + c->count = 0; c->buflen = 0; +} + +void mp_sha256_block(mp_sha256_ctx *c, const uint8_t *p) +{ + uint32_t w[64]; + int i; + for (i = 0; i < 16; i++) + w[i] = ((uint32_t)p[i*4] << 24) | ((uint32_t)p[i*4+1] << 16) | + ((uint32_t)p[i*4+2] << 8) | (uint32_t)p[i*4+3]; + for (i = 16; i < 64; i++) + w[i] = MP_SSIG1(w[i-2]) + w[i-7] + MP_SSIG0(w[i-15]) + w[i-16]; + uint32_t a = c->state[0], b = c->state[1], cc = c->state[2], d = c->state[3]; + uint32_t e = c->state[4], f = c->state[5], g = c->state[6], h = c->state[7]; + for (i = 0; i < 64; i++) { + uint32_t t1 = h + MP_BSIG1(e) + MP_CH(e,f,g) + mp_sha256_k[i] + w[i]; + uint32_t t2 = MP_BSIG0(a) + MP_MAJ(a,b,cc); + h = g; g = f; f = e; e = d + t1; + d = cc; cc = b; b = a; a = t1 + t2; + } + c->state[0] += a; c->state[1] += b; c->state[2] += cc; c->state[3] += d; + c->state[4] += e; c->state[5] += f; c->state[6] += g; c->state[7] += h; +} + +void mp_sha256_update(mp_sha256_ctx *c, const uint8_t *data, size_t len) +{ + c->count += (uint64_t)len; + if (c->buflen) { + size_t need = 64 - c->buflen; + size_t take = len < need ? len : need; + memcpy(c->buf + c->buflen, data, take); + c->buflen += take; data += take; len -= take; + if (c->buflen == 64) { mp_sha256_block(c, c->buf); c->buflen = 0; } + } + while (len >= 64) { mp_sha256_block(c, data); data += 64; len -= 64; } + if (len) { memcpy(c->buf, data, len); c->buflen = len; } +} + +void mp_sha256_final(mp_sha256_ctx *c, uint8_t out[32]) +{ + uint64_t bitlen = c->count * 8; + uint8_t *b = c->buf; + size_t bl = c->buflen; + int i; + b[bl++] = 0x80; + if (bl > 56) { + while (bl < 64) b[bl++] = 0; + mp_sha256_block(c, b); + bl = 0; + } + while (bl < 56) b[bl++] = 0; + for (i = 0; i < 8; i++) + b[bl++] = (uint8_t)(bitlen >> (56 - i * 8)); + mp_sha256_block(c, b); + for (i = 0; i < 8; i++) { + out[i*4] = (uint8_t)(c->state[i] >> 24); + out[i*4+1] = (uint8_t)(c->state[i] >> 16); + out[i*4+2] = (uint8_t)(c->state[i] >> 8); + out[i*4+3] = (uint8_t)(c->state[i]); + } +} + +int auth_read_random(unsigned char *buf, size_t n) +{ + int fd = open("/dev/urandom", O_RDONLY); + if (fd < 0) return -1; + size_t got = 0; + while (got < n) { + ssize_t rd = read(fd, buf + got, n - got); + if (rd <= 0) { close(fd); return -1; } + got += (size_t)rd; + } + close(fd); + return 0; +} + +void auth_hex(const unsigned char *raw, size_t n, char *out) +{ + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < n; i++) { + out[i*2] = hex[raw[i] >> 4]; + out[i*2+1] = hex[raw[i] & 0xf]; + } + out[n*2] = 0; +} + +int auth_token_gen(char *out, int outcap) /* 16B → 32 hex(128bit) */ +{ + unsigned char raw[AUTH_TOKEN_BYTES]; + if (outcap < AUTH_TOKEN_BYTES * 2 + 1) return -1; + if (auth_read_random(raw, sizeof raw) < 0) return -1; + auth_hex(raw, sizeof raw, out); + return 0; +} + +int auth_make_salt(char *out, int outcap) /* 16B → 32 hex 盐 */ +{ + unsigned char raw[16]; + if (outcap < 33) return -1; + if (auth_read_random(raw, sizeof raw) < 0) return -1; + auth_hex(raw, sizeof raw, out); + return 0; +} + +int auth_pass_hash(const char *salt, const char *password, char *out, int outcap) +{ + mp_sha256_ctx c; + uint8_t digest[32]; + if (outcap < AUTH_HASH_LEN + 1) return -1; + mp_sha256_init(&c); + mp_sha256_update(&c, (const uint8_t *)salt, strlen(salt)); + mp_sha256_update(&c, (const uint8_t *)password, strlen(password)); + mp_sha256_final(&c, digest); + auth_hex(digest, sizeof digest, out); + return 0; +} + +int auth_make_hash(const char *password, char *out, int outcap) /* salt:hex */ +{ + char salt[33], h[AUTH_HASH_LEN + 1]; + if (auth_make_salt(salt, sizeof salt) < 0) return -1; + if (auth_pass_hash(salt, password, h, sizeof h) < 0) return -1; + snprintf(out, outcap, "%s:%s", salt, h); + return 0; +} + +int hash_matches(const char *stored, const char *password) +{ + if (!stored || !stored[0] || !password) return 0; + char tmp[160]; + snprintf(tmp, sizeof tmp, "%s", stored); + char *colon = strchr(tmp, ':'); + if (!colon) return 0; + *colon = 0; + char want[AUTH_HASH_LEN + 1]; + if (auth_pass_hash(tmp, password, want, sizeof want) != 0) return 0; + if (strlen(colon + 1) != AUTH_HASH_LEN) return 0; + return memcmp(want, colon + 1, AUTH_HASH_LEN) == 0; +} + +int pin_set(void) /* panel.pin 是否已设(访问屏障开启) */ +{ + char *v = settings_get_value("panel.pin"); + int s = (v && v[0]) ? 1 : 0; + free(v); + return s; +} + +int auth_pass_set(void) /* 旧密码是否已设(兼容迁移用) */ +{ + char *v = settings_get_value("panel.auth_pass"); + int s = (v && v[0]) ? 1 : 0; + free(v); + return s; +} + +int auth_verify(const char *password) /* 旧密码校验(迁移期;见 login) */ +{ + char *stored = settings_get_value("panel.auth_pass"); + int ok = hash_matches(stored, password); + free(stored); + return ok; +} + +int pin_verify(const char *pin) /* PIN 校验 */ +{ + char *stored = settings_get_value("panel.pin"); + int ok = hash_matches(stored, pin); + free(stored); + return ok; +} + +int pin_validate(const char *pin) +{ + if (!pin) return 0; + size_t l = strlen(pin); + if (l != 4) return 0; + for (size_t i = 0; i < l; i++) + if (pin[i] < '0' || pin[i] > '9') return 0; + return 1; +} + +long long pin_lock_remaining(void) /* 剩余锁定秒;0=未锁 */ +{ + time_t now = time(NULL); + if (g_pin_locked_until <= now) return 0; + return (long long)(g_pin_locked_until - now); +} + +void pin_lock_reset(void) +{ + g_pin_fails = 0; + g_pin_locked_until = 0; +} + +void pin_record_fail(void) +{ + if (++g_pin_fails >= PIN_MAX_FAILS) { + g_pin_locked_until = time(NULL) + PIN_LOCK_SEC; + g_pin_fails = 0; + } +} + +int sess_find(const char *token) +{ + for (int i = 0; i < g_sess_n; i++) + if (!strcmp(g_sess[i].token, token)) return i; + return -1; +} + +int sess_valid(const char *token) +{ + int i = sess_find(token); + if (i < 0) return 0; + if (g_sess[i].expires > 0 && time(NULL) >= g_sess[i].expires) return 0; + return 1; +} + +void sess_add(const char *token, time_t expires) +{ + int i = sess_find(token); + if (i >= 0) { g_sess[i].expires = expires; return; } + if (g_sess_n >= AUTH_MAX_SESS) { /* 满:淘汰最早过期的一条 */ + int e = 0; + for (int k = 1; k < g_sess_n; k++) + if (g_sess[k].expires < g_sess[e].expires) e = k; + if (e < g_sess_n - 1) + memmove(&g_sess[e], &g_sess[e + 1], (size_t)(g_sess_n - 1 - e) * sizeof(auth_sess_t)); + g_sess_n--; + } + snprintf(g_sess[g_sess_n].token, sizeof g_sess[g_sess_n].token, "%s", token); + g_sess[g_sess_n].expires = expires; + g_sess_n++; +} + +void sess_remove(const char *token) +{ + int i = sess_find(token); + if (i < 0) return; + if (i < g_sess_n - 1) + memmove(&g_sess[i], &g_sess[i + 1], (size_t)(g_sess_n - 1 - i) * sizeof(auth_sess_t)); + g_sess_n--; +} + +void auth_cp(char *dst, int dcap, const char *src) /* 有界拷贝(防 -Wformat-truncation) */ +{ + size_t l = strlen(src); + if (l >= (size_t)dcap) l = (size_t)dcap - 1; + memcpy(dst, src, l); + dst[l] = 0; +} + +int extract_token(const req_t *r, char *out, int outcap) +{ + out[0] = 0; + if (r->x_meow_token[0]) { auth_cp(out, outcap, r->x_meow_token); return out[0] ? 1 : 0; } + if (!strncasecmp(r->authorization, "Bearer ", 7)) { + auth_cp(out, outcap, r->authorization + 7); + return out[0] ? 1 : 0; + } + if (r->cookie[0]) { + const char *p = strstr(r->cookie, "meow_token="); + if (p) { + p += 11; + const char *e = p; + while (*e && *e != ';' && *e != ' ' && *e != '\t') e++; + size_t l = (size_t)(e - p); + if (l >= (size_t)outcap) l = (size_t)outcap - 1; + memcpy(out, p, l); + out[l] = 0; + return out[0] ? 1 : 0; + } + } + return 0; +} + +int require_auth(int sock, const req_t *r) +{ + const char *p = r->path; + if (!strcmp(p, "/api/auth/login") || !strcmp(p, "/api/auth/logout") || + !strcmp(p, "/api/auth/status") || !strcmp(p, "/api/auth/setup")) return 1; + char t[65]; + if (extract_token(r, t, sizeof t) && t[0] && sess_valid(t)) return 1; + return send_err(sock, 401, "UNAUTHORIZED", "未认证或会话已失效"); +} + +int api_auth_status(int sock, const req_t *r) +{ + int ps = pin_set(); + int lg = auth_pass_set(); + int sess = 0; + char t[65]; + if (extract_token(r, t, sizeof t) && t[0]) sess = sess_valid(t); + long long rem = pin_lock_remaining(); + sbuf_t s = {0}; + sA(&s, "{\"auth_enabled\":true,\"session\":%s,\"pin_set\":%s,\"legacy\":%s,\"locked\":%s", + sess ? "true" : "false", ps ? "true" : "false", lg ? "true" : "false", rem > 0 ? "true" : "false"); + if (rem > 0) sA(&s, ",\"lock_remaining_ms\":%lld", rem * 1000LL); + sA(&s, "}"); + int rc = send_json(sock, 200, s.b ? s.b : "{}"); + sbuf_free(&s); + return rc; +} + +int api_auth_login(int sock, const req_t *r) +{ + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 PIN"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *jpin = jv_obj_get(root, "pin"); + jv_t *jpw = jv_obj_get(root, "password"); + char pin[8], pw[256]; + if (jpin && jpin->type == J_STR && jpin->str) snprintf(pin, sizeof pin, "%s", jpin->str); else pin[0] = 0; + if (jpw && jpw->type == J_STR && jpw->str) snprintf(pw, sizeof pw, "%s", jpw->str); else pw[0] = 0; + jv_free(root); + + if (pin_lock_remaining() > 0) { /* 已在锁定期 */ + sbuf_t e = {0}; + sA(&e, "{\"ok\":false,\"error\":{\"code\":\"LOCKED\",\"message\":\"已锁定,请稍后重试\"},\"lock_remaining_ms\":%lld}", + pin_lock_remaining() * 1000LL); + int rc = send_json(sock, 429, e.b ? e.b : ""); + sbuf_free(&e); + return rc; + } + + int use_legacy = 0; + const char *cred = NULL; + if (pin_set()) { cred = pin; if (!cred || !cred[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 pin 字段"); } + else if (auth_pass_set()) { use_legacy = 1; cred = pw; if (!cred || !cred[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 password 字段"); } + else return send_err(sock, 400, "BAD_REQUEST", "请先设置面板 PIN(/api/auth/setup)"); + + int ok = use_legacy ? auth_verify(cred) : pin_verify(cred); + if (!ok) { + pin_record_fail(); + if (pin_lock_remaining() > 0) { /* 本错已触发锁定 */ + sbuf_t e = {0}; + sA(&e, "{\"ok\":false,\"error\":{\"code\":\"LOCKED\",\"message\":\"连续错误次数过多,已锁定 5 分钟\"},\"lock_remaining_ms\":%lld}", + pin_lock_remaining() * 1000LL); + int rc = send_json(sock, 429, e.b ? e.b : ""); + sbuf_free(&e); + return rc; + } + return send_err(sock, 401, "UNAUTHORIZED", "PIN 错误"); + } + pin_lock_reset(); + char token[65]; + if (auth_token_gen(token, sizeof token) < 0) return send_err(sock, 500, "INTERNAL", "生成会话失败"); + time_t exp = time(NULL) + g_auth_ttl; /* 固定 3 天(MEOW_AUTH_TTL 可覆盖测试用),不滑动续期 */ + sess_add(token, exp); + sbuf_t s = {0}; + sA(&s, "{\"token\":"); + sA_str(&s, token); + sA(&s, ",\"expires_at\":%lld,\"auth_enabled\":true}", (long long)exp); + int rc = send_json(sock, 200, s.b ? s.b : "{}"); + sbuf_free(&s); + return rc; +} + +int api_auth_setup(int sock, const req_t *r) +{ + if (pin_set()) return send_err(sock, 409, "CONFLICT", "面板 PIN 已设置,请直接登录"); + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 PIN"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *jpin = jv_obj_get(root, "pin"); + char pin[8]; + if (jpin && jpin->type == J_STR && jpin->str) snprintf(pin, sizeof pin, "%s", jpin->str); + else pin[0] = 0; + jv_free(root); + if (!pin_validate(pin)) return send_err(sock, 422, "UNSUPPORTED", "PIN 必须为 4 位数字"); + char h[200]; + if (auth_make_hash(pin, h, sizeof h) < 0) return send_err(sock, 500, "INTERNAL", "生成 PIN 哈希失败"); + if (settings_write_one("panel.pin", h) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败"); + pin_lock_reset(); + return send_json(sock, 200, "{\"pin_set\":true}"); +} + +int api_auth_logout(int sock, const req_t *r) +{ + char t[65]; + if (extract_token(r, t, sizeof t) && t[0]) sess_remove(t); + return send_json(sock, 200, "{\"logged_out\":true}"); +} + +#undef MP_ROR +#undef MP_CH +#undef MP_MAJ +#undef MP_BSIG0 +#undef MP_BSIG1 +#undef MP_SSIG0 +#undef MP_SSIG1 diff --git a/patch_v2/meowpanel/src/mp_files.c b/patch_v2/meowpanel/src/mp_files.c new file mode 100644 index 0000000..26cce49 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_files.c @@ -0,0 +1,619 @@ +/* mp_files.c — 文件域:路径安全/白名单 + /api/files* + multipart 上传解析 + 卡根派生路径。 */ +#include "mp.h" + +char g_tf_root_buf[1024] = "/usr/data/mnt/sd_0"; +char g_tf_card_buf[2048], g_apps_dir_buf[2048], g_logs_dir_buf[2048], g_notif_log_buf[2048]; +const char *g_tf_card = NULL, *g_apps_dir = NULL, *g_logs_dir = NULL, *g_notif_log = NULL; +const char *g_allowed_dirs[] = { "MeowOS", "Music", "MeowData", "MeowAPPs", "book" }; +#define ALLOWED_COUNT ((int)(sizeof g_allowed_dirs / sizeof g_allowed_dirs[0])) +const char *g_sensitive_names[] = { + "settings.conf", "setting.bak", ".env", "passwd", "shadow", + "meow.conf", "user.ini", "config.json", +}; +#define SENSITIVE_COUNT ((int)(sizeof g_sensitive_names / sizeof g_sensitive_names[0])) + + +void init_tf_paths(void) +{ + const char *env = getenv("MEOW_TF_ROOT"); + if (env && env[0]) { + snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", env); + /* 去掉末尾 `/`(规范化处统一按无尾斜杠处理) */ + size_t l = strlen(g_tf_root_buf); + while (l > 1 && g_tf_root_buf[l - 1] == '/') g_tf_root_buf[--l] = 0; + } + snprintf(g_tf_card_buf, sizeof g_tf_card_buf, "%s/MeowOS", TF_ROOT); + snprintf(g_apps_dir_buf, sizeof g_apps_dir_buf, "%s/MeowAPPs", TF_ROOT); + snprintf(g_logs_dir_buf, sizeof g_logs_dir_buf, "%s/MeowOS/logs", TF_ROOT); + snprintf(g_notif_log_buf, sizeof g_notif_log_buf, "%s/MeowOS/notifications/notifications.log", TF_ROOT); + env = getenv("MEOW_NOTIF_LOG"); + if (env && env[0]) snprintf(g_notif_log_buf, sizeof g_notif_log_buf, "%s", env); + g_tf_card = g_tf_card_buf; + g_apps_dir = g_apps_dir_buf; + g_logs_dir = g_logs_dir_buf; + g_notif_log = g_notif_log_buf; +} + +int tf_present(void) +{ + if (getenv("MEOW_TF_ROOT")) return 1; /* 宿主测试显式指定卡根(env 覆盖),信任其存在 */ + FILE *f = fopen("/proc/mounts", "r"); + if (!f) return 0; + char line[512]; + int present = 0; + while (fgets(line, sizeof line, f)) { + if (strstr(line, TF_ROOT)) { present = 1; break; } + } + fclose(f); + return present; +} + +int path_in_whitelist(const char *abs) +{ + size_t rl = strlen(TF_ROOT); + if (strncmp(abs, TF_ROOT, rl) != 0) return 0; + if (abs[rl] == 0) return 1; /* 根自身 */ + if (abs[rl] != '/') return 0; + const char *rest = abs + rl + 1; + for (int i = 0; i < ALLOWED_COUNT; i++) { + size_t dl = strlen(g_allowed_dirs[i]); + if (strncmp(rest, g_allowed_dirs[i], dl) == 0) { + if (rest[dl] == 0 || rest[dl] == '/') return 1; + } + } + return 0; +} + +int is_sensitive_name(const char *base) +{ + for (int i = 0; i < SENSITIVE_COUNT; i++) + if (!strcmp(base, g_sensitive_names[i])) return 1; + return 0; +} + +int normalize_path(const char *in, char *buf, size_t buflen) +{ + size_t rl = strlen(TF_ROOT); + if (strlen(in) > 900) return -1; /* 拒绝超长路径(防御;真实路径远小于此) */ + char joined[2048]; + if (in[0] == '/') { + if (strncmp(in, TF_ROOT, rl) == 0 && (in[rl] == 0 || in[rl] == '/')) { + snprintf(joined, sizeof joined, "%s", in); + } else { + /* 相对 root 的展示路径,如 /MeowOS/x → 拼到 root */ + if (in[0] == '/' && in[1] == '/') snprintf(joined, sizeof joined, "%s%s", TF_ROOT, in + 1); + else if (in[0] == '/') snprintf(joined, sizeof joined, "%s%s", TF_ROOT, in); + else snprintf(joined, sizeof joined, "%s/%s", TF_ROOT, in); + } + } else { + snprintf(joined, sizeof joined, "%s/%s", TF_ROOT, in); + } + /* 逐分量规范化成**绝对**路径:/a/b/../c → /a/c;拒绝逃逸出 root。 + * 用绝对 out(以 '/' 开头)构建:首段即带前导 '/', 保证 path_in_whitelist 的绝对前缀比对成立。 */ + char out[2048]; + size_t n = 0; + out[0] = '/'; + out[1] = 0; + n = 1; + char *p = joined; + while (*p) { + while (*p == '/') p++; + if (!*p) break; + char seg[256]; + int k = 0; + while (*p && *p != '/') { if (k < 255) seg[k++] = *p; p++; } + seg[k] = 0; + if (!strcmp(seg, ".") || !strcmp(seg, "")) continue; + if (!strcmp(seg, "..")) { + /* 弹出一级;已在根(n==1)再 .. → 逃逸出 root,拒绝 */ + if (n <= 1) return -1; + while (n > 1 && out[n - 1] != '/') n--; + if (n > 1) n--; /* 去掉结尾 /;out[n] 归属下一段或结束 */ + out[n] = 0; + continue; + } + if (k > 0) { + size_t need = n + ((n > 1) ? 1 : 0) + (size_t)k + 1; + if (need >= sizeof out) return -1; + if (n > 1) { out[n++] = '/'; out[n] = 0; } + memcpy(out + n, seg, (size_t)k); + n += (size_t)k; + out[n] = 0; + } + } + /* 空/根(n==1,仅 "/")→ 卡根 */ + if (n <= 1) snprintf(out, sizeof out, "%s", TF_ROOT); + size_t ol = strlen(out); + if (ol >= buflen) return -1; + memcpy(buf, out, ol + 1); /* 已证明 ol= '0' && ca <= '9' && cb >= '0' && cb <= '9') { + const unsigned char *na = pa, *nb = pb; + while (*na == '0') na++; + while (*nb == '0') nb++; + const unsigned char *da = na, *db = nb; + while (*da >= '0' && *da <= '9') da++; + while (*db >= '0' && *db <= '9') db++; + int la = (int)(da - na), lb = (int)(db - nb); /* 有效数字位数(去前导0) */ + if (la != lb) return la - lb; /* 位数小者数小 */ + int c = memcmp(na, nb, (size_t)la); + if (c) return c; + int ta = (int)(da - pa), tb = (int)(db - pb); /* 原始数字串长(含前导0) */ + if (ta != tb) return ta - tb; + pa = da; pb = db; + continue; /* 数字段完全相同,继续 */ + } + unsigned char la = (ca >= 'A' && ca <= 'Z') ? (unsigned char)(ca + 32) : ca; + unsigned char lbc = (cb >= 'A' && cb <= 'Z') ? (unsigned char)(cb + 32) : cb; + if (la != lbc) return (int)la - (int)lbc; + if (ca != cb) return (int)ca - (int)cb; /* 大小写 tie-break:大写在前 */ + pa++; pb++; + } + return (int)*pa - (int)*pb; +} + +int fent_cmp(const void *a, const void *b) +{ + const struct fent *fa = a, *fb = b; + if (fa->isdir != fb->isdir) return fb->isdir - fa->isdir; + return nat_strcmp(fa->name, fb->name); +} + +int files_list(int sock, const char *relpath) +{ + char abs[1024], rel[1024]; + if (normalize_path(relpath, abs, sizeof abs) < 0) + return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + if (!path_in_whitelist(abs)) + return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + struct stat st; + if (stat(abs, &st) < 0) + return send_err(sock, 404, "NOT_FOUND", "路径不存在"); + if (!S_ISDIR(st.st_mode)) + return send_err(sock, 404, "NOT_FOUND", "非目录"); + DIR *d = opendir(abs); + if (!d) + return send_err(sock, 500, "INTERNAL", "读目录失败"); + make_rel(abs, rel, sizeof rel); + sbuf_t s = {0}; + sA(&s, "{\"root\":"); + sA_str(&s, TF_ROOT); + sA(&s, ",\"path\":"); + sA_str(&s, abs); + sA(&s, ",\"rel\":"); + sA_str(&s, rel); + sA(&s, ",\"allowed\":["); + for (int i = 0; i < ALLOWED_COUNT; i++) { if (i) sA(&s, ","); sA_str(&s, g_allowed_dirs[i]); } + sA(&s, "],\"items\":["); + /* M2.78:排序后输出(原 readdir 原始顺序混杂难用)——目录优先,同组按名称 + * 不区分大小写排序;条目上限 1024(与 files App 一致,防巨目录爆内存)。 */ + int cap = 1024, n = 0; + struct fent *ents = malloc((size_t)cap * sizeof *ents); + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + char full[1400]; + snprintf(full, sizeof full, "%s/%s", abs, e->d_name); + struct stat fs; + if (stat(full, &fs) < 0) continue; + if (n >= cap) break; + struct fent *en = &ents[n++]; + snprintf(en->name, sizeof en->name, "%s", e->d_name); + en->isdir = S_ISDIR(fs.st_mode); + en->size = en->isdir ? 0 : (long long)fs.st_size; + en->mtime = (long long)fs.st_mtime; + } + closedir(d); + qsort(ents, (size_t)n, sizeof *ents, fent_cmp); + for (int i = 0; i < n; i++) { + if (i) sA(&s, ","); + fmt_json_item(&s, ents[i].name, ents[i].isdir, ents[i].size, ents[i].mtime); + } + free(ents); + sA(&s, "]}"); + return send_json(sock, 200, s.b ? s.b : ""); +} + +char *read_text_file_limited(const char *abs, long long *size, int *truncated) +{ + int fd = open_safe_for_read(abs, 0); + if (fd < 0) return NULL; + struct stat st; + if (fstat(fd, &st) < 0 || st.st_size < 0) { close(fd); return NULL; } + long long total = (long long)st.st_size; + size_t cap = total > TEXT_PREVIEW_MAX ? (size_t)TEXT_PREVIEW_MAX : (size_t)total; + char *buf = malloc(cap + 1); + if (!buf) { close(fd); return NULL; } + ssize_t n = read(fd, buf, cap); + close(fd); + if (n < 0) { free(buf); return NULL; } + buf[n] = 0; + /* 二进制检测:含 NUL 或大量控制字符 */ + int ctrl = 0; + for (ssize_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)buf[i]; + if (c == 0) { free(buf); return (char *)-2; } /* 二进制 */ + if (c < 0x09) ctrl++; + } + if (ctrl > n / 10) { free(buf); return (char *)-2; } + if (size) *size = total; + if (truncated) *truncated = (total > (long long)cap) ? 1 : 0; + return buf; +} + +int safe_filename(const char *name, char *out, size_t cap) +{ + if (!name || !name[0]) return -1; + const char *b = name; + /* 取最后一个路径分隔符之后(同时兼容 '/' 与 '\\') */ + const char *slash = strrchr(b, '/'); + const char *bsl = strrchr(b, '\\'); + if (bsl && (!slash || bsl > slash)) slash = bsl; + if (slash) b = slash + 1; + if (!b[0]) return -1; + if (!strcmp(b, ".") || !strcmp(b, "..")) return -1; + for (const char *p = b; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '/' || c == '\\' || c < 0x20 || c == 0x7f) return -1; /* 禁止分隔符/控制字符 */ + } + snprintf(out, cap, "%s", b); + return 0; +} + +int parse_multipart(const req_t *r, upfile_t *files, int maxfiles) +{ + /* 取 boundary */ + const char *ct = r->content_type; + const char *b = strstr(ct, "boundary="); + if (!b) return -1; + b += 9; + if (*b == '"') { b++; } + char boundary[128]; + int bi = 0; + while (b[bi] && b[bi] != ';' && b[bi] != '"' && b[bi] != '\r' && bi < 120) { boundary[bi] = b[bi]; bi++; } + boundary[bi] = 0; + char dmark[160]; + snprintf(dmark, sizeof dmark, "--%s", boundary); + char emark[160]; + snprintf(emark, sizeof emark, "--%s--", boundary); + /* 块间以 \r\n--boundary 分隔;用 strstr 扫描 */ + const char *p = r->body; + const char *end = r->body + r->body_len; + int nfiles = 0; + char curpath[1024] = ""; + while (p < end) { + const char *fstart = mem_find(p, (size_t)(end - p), dmark); + if (!fstart) break; + fstart += strlen(dmark); + if (*fstart == '-') break; /* 结束标记 --boundary-- */ + if (*fstart == '\r' || *fstart == '\n') fstart++; + /* 解析该 part 的头部(直至空行) */ + const char *headers_end = strstr(fstart, "\r\n\r\n"); + if (!headers_end) break; + char hdr[512]; + size_t hl = (size_t)(headers_end - fstart); + if (hl >= sizeof hdr) hl = sizeof hdr - 1; + memcpy(hdr, fstart, hl); + hdr[hl] = 0; + const char *data_start = headers_end + 4; + const char *name = NULL, *fname = NULL; + char *save = NULL; + for (char *ln = strtok_r(hdr, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { + char *l = ln; + while (*l == '\r' || *l == ' ' || *l == '\t') l++; + if (strncasecmp(l, "Content-Disposition:", 20) == 0) { + /* name 与 filename 常同行(name="file"; filename="up.txt")。 + * 先取 filename 再取 name——若先截断 name 收尾引号会把同行的 filename 一并截断。 */ + char *fn = strstr(l, "filename=\""); + if (fn) { fn += 10; char *q = strchr(fn, '"'); if (q) { *q = 0; fname = fn; } } + char *nm = strstr(l, "name=\""); + if (nm) { nm += 6; char *q = strchr(nm, '"'); if (q) { *q = 0; name = nm; } } + } + } + /* 数据结束:下一个 "--boundary" 之前(二进制安全) */ + const char *data_end = mem_find(data_start, (size_t)(end - data_start), dmark); + if (!data_end) data_end = end; + /* 去掉数据尾部 \r\n */ + while (data_end > data_start && (data_end[-1] == '\r' || data_end[-1] == '\n')) data_end--; + if (name && !strcmp(name, "path")) { + size_t dl = (size_t)(data_end - data_start); + if (dl >= sizeof curpath) dl = sizeof curpath - 1; + memcpy(curpath, data_start, dl); + curpath[dl] = 0; + } else if (name && !strcmp(name, "file") && fname && nfiles < maxfiles) { + size_t dl = (size_t)(data_end - data_start); + char safe[260]; + if (safe_filename(fname, safe, sizeof safe) < 0) { p = data_end; continue; } /* 文件名非法:跳过该 part */ + /* 目标目录规范化 + 白名单校验 */ + char absdir[1024]; + if (normalize_path(curpath, absdir, sizeof absdir) < 0 || !path_in_whitelist(absdir)) { p = data_end; continue; } + /* 拼绝对路径后再规范化 + 白名单复校(防拼接后残留逃逸) */ + char joined[1400]; + snprintf(joined, sizeof joined, "%s/%s", absdir, safe); + char abs[1400]; + if (normalize_path(joined, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { p = data_end; continue; } + snprintf(files[nfiles].filename, sizeof files[nfiles].filename, "%s", safe); + snprintf(files[nfiles].path, sizeof files[nfiles].path, "%s", abs); + int fd = open(abs, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) { + write_full(fd, data_start, dl); + close(fd); + nfiles++; + } + /* fd<0(不可写)→ 不计数,跳过该文件 */ + } + p = data_end; + } + return nfiles; /* >=0:已保存文件数;-1 边界缺失/解析失败 */ +} + +int api_files_list(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024] = "/"; + if (r->query[0]) qparam(r->query, "path", path, sizeof path); + return files_list(sock, path); +} + +int api_files_read(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024]; + if (!qparam(r->query, "path", path, sizeof path)) return send_err(sock, 400, "BAD_REQUEST", "缺 path"); + char abs[1024]; + if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + long long size; int trunc; + char *txt = read_text_file_limited(abs, &size, &trunc); + if (txt == (char *)-2) return send_err(sock, 415, "UNSUPPORTED_MEDIA", "二进制文件不支持文本读取"); + if (!txt) return send_err(sock, 404, "NOT_FOUND", "文件不存在或过大"); + sbuf_t s = {0}; + sA(&s, "{\"path\":"); + sA_str(&s, abs); + sA(&s, ",\"content\":"); + sA_str(&s, txt); + sA(&s, ",\"size\":%lld,\"truncated\":%s}", size, trunc ? "true" : "false"); + free(txt); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_files_download(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024]; + if (!qparam(r->query, "path", path, sizeof path)) return send_err(sock, 400, "BAD_REQUEST", "缺 path"); + char abs[1024]; + if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + const char *base = strrchr(abs, '/'); + base = base ? base + 1 : abs; + if (is_sensitive_name(base)) return send_err(sock, 403, "FORBIDDEN", "敏感文件"); + return send_file(sock, 200, "application/octet-stream", abs, base); +} + +int api_files_upload(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strncasecmp(r->content_type, "multipart/form-data", 19) != 0) + return send_err(sock, 415, "UNSUPPORTED_MEDIA", "需 multipart/form-data"); + upfile_t files[16]; + memset(files, 0, sizeof files); + int n = parse_multipart(r, files, 16); + if (n < 0) return send_err(sock, 400, "BAD_REQUEST", "multipart 解析失败"); + sbuf_t s = {0}; + sA(&s, "{\"item\":["); + int first = 1; + for (int i = 0; i < n && i < 16; i++) { + if (!files[i].filename[0]) continue; + if (!strstr(files[i].path, "/")) continue; /* 无目标目录的跳过 */ + struct stat st; + if (stat(files[i].path, &st) < 0) continue; + const char *base = strrchr(files[i].path, '/'); + base = base ? base + 1 : files[i].path; + if (!first) sA(&s, ","); + first = 0; + sA(&s, "{\"name\":"); + sA_str(&s, base); + sA(&s, ",\"type\":\"file\",\"size\":%lld,\"mtime\":%lld}", + (long long)st.st_size, (long long)st.st_mtime); + } + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int rm_recursive(const char *path) +{ + struct stat st; + if (lstat(path, &st) < 0) return -1; + if (S_ISDIR(st.st_mode)) { + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", path, e->d_name); + rm_recursive(full); + } + closedir(d); + return rmdir(path); + } + return unlink(path); +} + +int api_files_mkdir(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺 body"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *pj = jv_obj_get(root, "path"); + if (!pj || pj->type != J_STR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺 path"); } + char abs[1024]; + if (normalize_path(pj->str, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { jv_free(root); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); } + struct stat st; + if (stat(abs, &st) == 0) { jv_free(root); return send_err(sock, 409, "CONFLICT", "目录已存在"); } + if (mkdir(abs, 0755) < 0) { jv_free(root); return send_err(sock, 500, "INTERNAL", "创建目录失败"); } + const char *base = strrchr(abs, '/'); + const char *b = base ? base + 1 : abs; + sbuf_t s = {0}; + sA(&s, "{\"item\":{\"name\":"); + sA_str(&s, b); + sA(&s, ",\"type\":\"dir\",\"size\":0,\"mtime\":%lld}}", (long long)time(NULL)); + jv_free(root); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_files_rename(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺 body"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *pj = jv_obj_get(root, "path"); + jv_t *pnn = jv_obj_get(root, "new_name"); + if (!pj || pj->type != J_STR || !pnn || pnn->type != J_STR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺 path/new_name"); } + if (strchr(pnn->str, '/')) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "new_name 仅文件名"); } + char abs[1024], newabs[1400]; + if (normalize_path(pj->str, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { jv_free(root); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); } + /* 新路径 = 原路径的**父目录** + new_name(new_name 仅文件名,不含分隔符) */ + char parent[1024]; + snprintf(parent, sizeof parent, "%s", abs); + char *sl = strrchr(parent, '/'); + if (!sl || sl == parent) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "无法重命名根路径"); } + *sl = 0; + if (!parent[0]) snprintf(parent, sizeof parent, "%s", TF_ROOT); /* abs 顶层(如 /tmp/mptf/x)→ 父=根 */ + snprintf(newabs, sizeof newabs, "%s/%s", parent, pnn->str); + /* newabs 复校白名单(new_name 无 /,父目录已校验;防御) */ + if (!path_in_whitelist(newabs)) { jv_free(root); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); } + if (rename(abs, newabs) < 0) { jv_free(root); return send_err(sock, 500, "INTERNAL", "重命名失败"); } + sbuf_t s = {0}; + sA(&s, "{\"item\":{\"name\":"); + sA_str(&s, pnn->str); + sA(&s, ",\"type\":\""); + struct stat st; + if (stat(newabs, &st) == 0 && S_ISDIR(st.st_mode)) sA(&s, "dir"); else sA(&s, "file"); + sA(&s, "\",\"size\":%lld,\"mtime\":%lld}}", (long long)st.st_size, (long long)st.st_mtime); + jv_free(root); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_files_delete(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024]; + if (!qparam(r->query, "path", path, sizeof path)) return send_err(sock, 400, "BAD_REQUEST", "缺 path"); + char recursive[8] = "0"; + qparam(r->query, "recursive", recursive, sizeof recursive); + char abs[1024]; + if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + if (!strcmp(abs, TF_ROOT)) return send_err(sock, 403, "FORBIDDEN", "禁止删除卡根"); + struct stat st; + if (stat(abs, &st) < 0) return send_err(sock, 404, "NOT_FOUND", "路径不存在"); + if (S_ISDIR(st.st_mode) && atoi(recursive) != 1) { + DIR *d = opendir(abs); + int nonempty = 0; + if (d) { + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + nonempty = 1; break; + } + closedir(d); + } + if (nonempty) return send_err(sock, 409, "CONFLICT", "目录非空(需 recursive=1)"); + } + if (rm_recursive(abs) < 0) return send_err(sock, 500, "INTERNAL", "删除失败"); + return send_json(sock, 200, "{\"removed\":true}"); +} + +int multipart_extract_file(const req_t *r, const char *outpath) +{ + const char *b = strstr(r->content_type, "boundary="); + if (!b) return -1; + b += 9; + if (*b == '"') b++; + char boundary[128]; int bi = 0; + while (b[bi] && b[bi] != ';' && b[bi] != '"' && b[bi] != '\r' && bi < 120) { boundary[bi] = b[bi]; bi++; } + boundary[bi] = 0; + char dmark[160]; + snprintf(dmark, sizeof dmark, "--%s", boundary); + const char *p = r->body; + while (p < r->body + r->body_len) { + const char *fs = strstr(p, dmark); + if (!fs) break; + fs += strlen(dmark); + if (*fs == '-') break; + if (*fs == '\r' || *fs == '\n') fs++; + const char *he = strstr(fs, "\r\n\r\n"); + if (!he) break; + char hdr[512]; + size_t hl = (size_t)(he - fs); if (hl >= sizeof hdr) hl = sizeof hdr - 1; + memcpy(hdr, fs, hl); hdr[hl] = 0; + int isfile = 0; + char *save = NULL; + for (char *ln = strtok_r(hdr, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { + char *l = ln; while (*l == '\r' || *l == ' ' || *l == '\t') l++; + if (strncasecmp(l, "Content-Disposition:", 20) == 0) { + if (strstr(l, "name=\"file\"")) isfile = 1; + } + } + const char *ds = he + 4; + const char *de = mem_find(ds, (size_t)((r->body + r->body_len) - ds), dmark); + if (!de) de = r->body + r->body_len; + while (de > ds && (de[-1] == '\r' || de[-1] == '\n')) de--; + if (isfile) { + int fd = open(outpath, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) return -1; + write_full(fd, ds, (size_t)(de - ds)); + close(fd); + return 0; + } + p = de; + } + return -1; +} diff --git a/patch_v2/meowpanel/src/mp_fonts.c b/patch_v2/meowpanel/src/mp_fonts.c new file mode 100644 index 0000000..2d16ed5 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_fonts.c @@ -0,0 +1,374 @@ +/* mp_fonts.c — 字体库域:tar.gz 导入/导出 + 列表(与 .mpk 同构,走 busybox gzip|tar 管道)。 */ +#include "mp.h" + +#define FONT_EXTRACT_MAX (32u * 1024u * 1024u) /* 三件套解压后总大小上限(防 gzip 炸弹) */ + + +void fonts_root(char *buf, size_t cap) { snprintf(buf, cap, "%s/MeowOS/fonts", TF_ROOT); } + +int safe_dir_segment(const char *s, char *out, size_t cap) +{ + if (!s || !s[0]) return -1; + if (!strcmp(s, ".") || !strcmp(s, "..")) return -1; + for (const char *p = s; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '/' || c == '\\' || c < 0x20 || c == 0x7f) return -1; + } + if (strlen(s) + 1 > cap) return -1; + snprintf(out, cap, "%s", s); + return 0; +} + +int unsafe_path(const char *s) +{ + return s && strpbrk(s, " ;`$'\"&|<>") != NULL; +} + +int has_dotdot_comp(const char *p) +{ + const char *s = p; + while (*s) { + if (s[0] == '.' && s[1] == '.' && + (s == p || s[-1] == '/') && (s[2] == 0 || s[2] == '/')) + return 1; + s++; + } + return 0; +} + +int sanitize_member(const char *in, char *out, int n) +{ + if (!in || !*in) return -1; + const char *p = in; + while (*p == '/' || (p[0] == '.' && p[1] == '/')) p++; + if (*p == '/' || !*p) return -1; + if (has_dotdot_comp(p)) return -1; + if (unsafe_path(p)) return -1; + size_t l = strlen(p); + if (l + 1 > (size_t)n) return -1; + memcpy(out, p, l + 1); /* memcpy 而非 snprintf:p 可能来自长行缓冲,防 -Wformat-truncation 误报 */ + return 0; +} + +int mkdir_p(const char *path) +{ + char buf[1600]; + snprintf(buf, sizeof buf, "%s", path); + for (char *p = buf + 1; *p; p++) { + if (*p == '/') { *p = 0; mkdir(buf, 0755); *p = '/'; } + } + return mkdir(buf, 0755) == 0 || errno == EEXIST ? 0 : -1; +} + +const jv_t *font_json_str(const jv_t *root, const char *key) +{ + const jv_t *v = jv_obj_get(root, key); + if (v && v->type == J_STR && v->str) return v; + return NULL; +} + +int api_fonts_list(int sock) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char root[1100]; + fonts_root(root, sizeof root); + DIR *fd = opendir(root); + if (!fd) return send_json(sock, 200, "{\"fonts\":[]}"); + sbuf_t s = {0}; + sA(&s, "{\"fonts\":["); + int first = 1; + struct dirent *fe; + while ((fe = readdir(fd))) { + if (fe->d_name[0] == '.') continue; + char famdir[1700]; + snprintf(famdir, sizeof famdir, "%s/%s", root, fe->d_name); + struct stat fst; + if (stat(famdir, &fst) < 0 || !S_ISDIR(fst.st_mode)) continue; + DIR *wd = opendir(famdir); + if (!wd) continue; + struct dirent *we; + while ((we = readdir(wd))) { + if (we->d_name[0] == '.') continue; + char wdir[2200]; + snprintf(wdir, sizeof wdir, "%s/%s", famdir, we->d_name); + struct stat wst; + if (stat(wdir, &wst) < 0 || !S_ISDIR(wst.st_mode)) continue; + char pp[2560], pi[2560], pj[2560]; + snprintf(pp, sizeof pp, "%s/font.png", wdir); + snprintf(pi, sizeof pi, "%s/font.idx", wdir); + snprintf(pj, sizeof pj, "%s/font.json", wdir); + if (access(pp, R_OK) || access(pi, R_OK) || access(pj, R_OK)) continue; + /* 解析 font.json(可选增强字段) */ + char name[256] = "", tier[64] = ""; + long long gc = 0; + char jbuf[4096]; + if (read_small_file(pj, jbuf, sizeof jbuf) >= 0) { + jv_t *jr = jv_parse(jbuf, (int)strlen(jbuf)); + if (jr) { + const jv_t *v = font_json_str(jr, "name"); + if (v) snprintf(name, sizeof name, "%s", v->str); + v = font_json_str(jr, "tier"); + if (v) snprintf(tier, sizeof tier, "%s", v->str); + else { v = font_json_str(jr, "charset"); if (v) snprintf(tier, sizeof tier, "%s", v->str); } + v = jv_obj_get(jr, "glyph_count"); + if (v && v->type == J_NUM) gc = v->inum; + jv_free(jr); + } + } + if (!name[0]) snprintf(name, sizeof name, "%s", fe->d_name); + if (!first) sA(&s, ","); + sA(&s, "{\"family\":"); + sA_str(&s, fe->d_name); + sA(&s, ",\"weight\":"); + sA_str(&s, we->d_name); + sA(&s, ",\"name\":"); + sA_str(&s, name); + sA(&s, ",\"tier\":"); + sA_str(&s, tier); + sA(&s, ",\"glyph_count\":%lld,\"mtime\":%lld}", gc, (long long)wst.st_mtime); + first = 0; + } + closedir(wd); + } + closedir(fd); + sA(&s, "]}"); + return send_json(sock, 200, s.b ? s.b : "{\"fonts\":[]}"); +} + +int sha256_file_hex(const char *path, char out[65]) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + mp_sha256_ctx c; + mp_sha256_init(&c); + uint8_t buf[8192]; + ssize_t n; + while ((n = read(fd, buf, sizeof buf)) > 0) mp_sha256_update(&c, buf, (size_t)n); + close(fd); + if (n < 0) return -1; + uint8_t h[32]; + mp_sha256_final(&c, h); + auth_hex(h, sizeof h, out); + out[64] = 0; + return 0; +} + +int api_fonts_import(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char force[8] = "0"; + qparam(r->query, "force", force, sizeof force); + long uni = (long)time(NULL); + char tmpdir[1500], tmpgz[1500], stg[1500]; + snprintf(tmpdir, sizeof tmpdir, "%s/tmp", TF_CARD_DIR); + if (mkdir_p(tmpdir) != 0) return send_err(sock, 500, "INTERNAL", "建临时目录失败"); + snprintf(tmpgz, sizeof tmpgz, "%s/tmp/mp_font_%d_%ld.tar.gz", TF_CARD_DIR, (int)getpid(), uni); + snprintf(stg, sizeof stg, "%s/tmp/mp_font_stg_%d_%ld", TF_CARD_DIR, (int)getpid(), uni); + + /* ① 写 multipart file 到临时 tar.gz */ + if (multipart_extract_file(r, tmpgz) < 0) + return send_err(sock, 400, "BAD_REQUEST", "缺字体库 tar.gz(multipart 字段 file)"); + + /* ② 成员名预校验:gzip -dc | tar -t → 每名 sanitize,且恰为三件套平铺(拒绝对/.. / 多出条目) */ + { + char cmd[3200]; + snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -t 2>/dev/null", tmpgz); + FILE *pf = popen(cmd, "r"); + if (!pf) { unlink(tmpgz); return send_err(sock, 500, "INTERNAL", "无法列 tar 成员"); } + int bad = 0, have_png = 0, have_idx = 0, have_json = 0, nent = 0; + char line[512]; + while (fgets(line, sizeof line, pf)) { + char *e = strchr(line, '\n'); if (e) *e = 0; + if (!line[0]) continue; + char san[128]; + if (sanitize_member(line, san, sizeof san) < 0) { bad = 1; break; } + if (!strcmp(san, "font.png")) have_png = 1; + else if (!strcmp(san, "font.idx")) have_idx = 1; + else if (!strcmp(san, "font.json")) have_json = 1; + else { bad = 1; break; } + nent++; + } + pclose(pf); + if (bad || nent != 3 || !have_png || !have_idx || !have_json) { + unlink(tmpgz); + return send_err(sock, 400, "BAD_REQUEST", "tar 缺三件套或含非法成员(拒绝绝对路径/../多出条目)"); + } + } + + /* ③ 解压到临时目录 */ + rm_recursive(stg); + if (mkdir_p(stg) != 0) { unlink(tmpgz); return send_err(sock, 500, "INTERNAL", "建临时目录失败"); } + { + char cmd[3200]; + snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -x -C '%s' 2>/dev/null", tmpgz, stg); + if (system(cmd) != 0) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 400, "BAD_REQUEST", "tar 解压失败(非 gzip/tar?)"); } + } + + /* ④ 目录锁:恰为 3 个普通文件(lstat 拒软链/目录/设备),总大小 ≤ FONT_EXTRACT_MAX(防炸弹) */ + { + static const char *names[3] = { "font.png", "font.idx", "font.json" }; + int seen[3] = {0,0,0}, cnt = 0, ok = 1; + DIR *d = opendir(stg); + if (!d) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 500, "INTERNAL", "读临时目录失败"); } + struct dirent *de; + while ((de = readdir(d))) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue; + int idx = -1; + for (int i = 0; i < 3; i++) if (!strcmp(de->d_name, names[i])) { idx = i; break; } + if (idx < 0) { ok = 0; break; } + char fp[1800]; snprintf(fp, sizeof fp, "%s/%s", stg, de->d_name); + struct stat st; + if (lstat(fp, &st) != 0 || !S_ISREG(st.st_mode)) { ok = 0; break; } /* 拒软链/目录/设备 */ + seen[idx] = 1; cnt++; + } + closedir(d); + long long tot = 0; + for (int i = 0; i < 3; i++) { + if (!seen[i]) { ok = 0; break; } + char fp[1600]; snprintf(fp, sizeof fp, "%s/%s", stg, names[i]); + struct stat st; + if (stat(fp, &st) != 0) { ok = 0; break; } + tot += (long long)st.st_size; + } + if (!ok || cnt != 3 || tot > (long long)FONT_EXTRACT_MAX) { + rm_recursive(stg); unlink(tmpgz); + return send_err(sock, 400, "BAD_REQUEST", "tar 内容非法(缺件/软链/多出条目/解压后超大)"); + } + } + + /* ⑤ 读 font.json → family/weight(可解析硬失败) */ + char jpath[1600]; snprintf(jpath, sizeof jpath, "%s/font.json", stg); + char jbuf[8192]; + if (read_small_file(jpath, jbuf, sizeof jbuf) < 0) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 400, "BAD_REQUEST", "font.json 缺失"); } + jv_t *jr = jv_parse(jbuf, (int)strlen(jbuf)); + if (!jr) { rm_recursive(stg); unlink(tmpgz); return send_err(sock, 400, "BAD_REQUEST", "font.json 解析失败"); } + char family[128] = "font", weight[64] = "regular"; + const jv_t *v = font_json_str(jr, "family"); + if (v) snprintf(family, sizeof family, "%s", v->str); + else { v = font_json_str(jr, "name"); if (v) snprintf(family, sizeof family, "%s", v->str); } + v = font_json_str(jr, "weight"); + if (v) snprintf(weight, sizeof weight, "%s", v->str); + char fam_s[128], wt_s[64]; + if (safe_dir_segment(family, fam_s, sizeof fam_s) < 0) { jv_free(jr); rm_recursive(stg); unlink(tmpgz); return send_err(sock, 422, "UNSUPPORTED", "font.json family 非法"); } + if (safe_dir_segment(weight, wt_s, sizeof wt_s) < 0) { jv_free(jr); rm_recursive(stg); unlink(tmpgz); return send_err(sock, 422, "UNSUPPORTED", "font.json weight 非法"); } + snprintf(family, sizeof family, "%s", fam_s); + snprintf(weight, sizeof weight, "%s", wt_s); + + /* ⑥ 目标目录 + 覆盖冲突(409,除非 force=1) */ + char root[1100], target[1500]; + fonts_root(root, sizeof root); + snprintf(target, sizeof target, "%s/%s/%s", root, family, weight); + if (strcmp(force, "1") != 0) { + static const char *names3[] = { "font.png", "font.idx", "font.json" }; + char tp[1600]; int conflicted = 0; + for (int i = 0; i < 3; i++) { snprintf(tp, sizeof tp, "%s/%s", target, names3[i]); if (access(tp, F_OK) == 0) { conflicted = 1; break; } } + if (conflicted) { jv_free(jr); rm_recursive(stg); unlink(tmpgz); return send_err(sock, 409, "CONFLICT", "目标字库已存在,请确认覆盖"); } + } + + /* ⑦ sha256 仅 WARN(对齐 font.json 约定,不硬失败) */ + char warn[256] = ""; + { + const jv_t *wp = font_json_str(jr, "sha256_png"); + const jv_t *wi = font_json_str(jr, "sha256_idx"); + char need[65], got[65]; + if (wp && wp->str[0]) { + snprintf(need, sizeof need, "%s", wp->str); + char fp[1600]; snprintf(fp, sizeof fp, "%s/font.png", stg); + if (sha256_file_hex(fp, got) == 0 && strcasecmp(need, got) != 0) + snprintf(warn, sizeof warn, "sha256_png 不匹配(源库校验失败,但已导入)"); + } + if (wi && wi->str[0] && !warn[0]) { + snprintf(need, sizeof need, "%s", wi->str); + char fp[1600]; snprintf(fp, sizeof fp, "%s/font.idx", stg); + if (sha256_file_hex(fp, got) == 0 && strcasecmp(need, got) != 0) + snprintf(warn, sizeof warn, "sha256_idx 不匹配(源库校验失败,但已导入)"); + } + } + + /* ⑧ 建目标目录 + rename 三件套(原子替换) */ + { + char p1[1400]; + snprintf(p1, sizeof p1, "%s/%s", root, family); + mkdir(p1, 0755); + mkdir(target, 0755); + } + { + static const char *names3[] = { "font.png", "font.idx", "font.json" }; + int ok = 1; + for (int i = 0; i < 3; i++) { + char sp[1600], tp[1600]; + snprintf(sp, sizeof sp, "%s/%s", stg, names3[i]); + snprintf(tp, sizeof tp, "%s/%s", target, names3[i]); + if (rename(sp, tp) < 0) ok = 0; + } + if (!ok) { rm_recursive(stg); unlink(tmpgz); jv_free(jr); return send_err(sock, 500, "INTERNAL", "写入字体文件失败(磁盘满?)"); } + } + rm_recursive(stg); + unlink(tmpgz); + + sbuf_t s = {0}; + sA(&s, "{\"family\":"); + sA_str(&s, family); + sA(&s, ",\"weight\":"); + sA_str(&s, weight); + sA(&s, ",\"imported\":true,\"hint\":\"字体已导入。请进设备「设置 → 字体」页,字体列表在进入时自动重扫生效。\",\"warning\":"); + sA_str(&s, warn[0] ? warn : ""); + sA(&s, "}"); + jv_free(jr); + return send_json(sock, 200, s.b ? s.b : ""); +} + +int api_fonts_export(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char family[128], weight[64]; + if (!qparam(r->query, "family", family, sizeof family)) family[0] = 0; + if (!qparam(r->query, "weight", weight, sizeof weight)) weight[0] = 0; + char f[128], w[64]; + if (safe_dir_segment(family, f, sizeof f) < 0) return send_err(sock, 400, "BAD_REQUEST", "family 非法"); + if (!weight[0]) snprintf(w, sizeof w, "%s", "regular"); + else if (safe_dir_segment(weight, w, sizeof w) < 0) return send_err(sock, 400, "BAD_REQUEST", "weight 非法"); + char root[1100], dir[1500]; + fonts_root(root, sizeof root); + snprintf(dir, sizeof dir, "%s/%s/%s", root, f, w); + static const char *names3[] = { "font.png", "font.idx", "font.json" }; + for (int i = 0; i < 3; i++) { + char ap[1600]; + snprintf(ap, sizeof ap, "%s/%s", dir, names3[i]); + struct stat st; + if (stat(ap, &st) < 0) { + sbuf_t s = {0}; + sA(&s, "font."); sA_str(&s, names3[i]); + sA(&s, " 缺失(字库 <"); + sA_str(&s, f); sA(&s, "/"); sA_str(&s, w); sA(&s, "> 不完整)"); + int rc = send_err(sock, 404, "NOT_FOUND", s.b ? s.b : "字体三件套缺失"); + sbuf_free(&s); + return rc; + } + } + /* 打包:tar -cf - -C 三件套 | gzip -c > ,落临时文件求 Content-Length 再流式下发 */ + long uni = (long)time(NULL); + char tmpdir[1500], tmp[1500]; + snprintf(tmpdir, sizeof tmpdir, "%s/tmp", TF_CARD_DIR); + if (mkdir_p(tmpdir) != 0) return send_err(sock, 500, "INTERNAL", "建临时目录失败"); + snprintf(tmp, sizeof tmp, "%s/tmp/mp_font_exp_%d_%ld.tar.gz", TF_CARD_DIR, (int)getpid(), uni); + char cmd[3600]; + snprintf(cmd, sizeof cmd, "tar -cf - -C '%s' font.png font.idx font.json 2>/dev/null | gzip -c > '%s' 2>/dev/null", dir, tmp); + if (system(cmd) != 0) { unlink(tmp); return send_err(sock, 500, "INTERNAL", "打包失败"); } + struct stat st; + if (stat(tmp, &st) < 0 || !S_ISREG(st.st_mode)) { unlink(tmp); return send_err(sock, 500, "INTERNAL", "打包文件不可读"); } + char fname[220]; + snprintf(fname, sizeof fname, "%s-%s.font.tar.gz", f, w); + char extra[300]; + snprintf(extra, sizeof extra, "Content-Disposition: attachment; filename=\"%s\"", fname); + if (send_head(sock, 200, "application/gzip", (long long)st.st_size, extra) < 0) { unlink(tmp); return -1; } + int fd = open(tmp, O_RDONLY); + if (fd < 0) { unlink(tmp); return -1; } + char buf[8192]; + ssize_t n; + while ((n = read(fd, buf, sizeof buf)) > 0) + if (write_full(sock, buf, (size_t)n) < 0) { close(fd); unlink(tmp); return -1; } + close(fd); + unlink(tmp); + return 0; +} diff --git a/patch_v2/meowpanel/src/mp_ipc.c b/patch_v2/meowpanel/src/mp_ipc.c new file mode 100644 index 0000000..5ed5041 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_ipc.c @@ -0,0 +1,211 @@ +/* mp_ipc.c — IPC 帧读写 + meow_svc_perf/power/netmgr 客户端(单点 IPC 收发)。 */ +#include "mp.h" + +const char *g_sock_perf = PERF_SOCK; +const char *g_sock_power = POWER_SOCK; +const char *g_sock_launcher = LAUNCHER_SOCK; +const char *g_sock_audio = AUDIO_SOCK; +const char *g_sock_net = NET_SOCK; +const char *g_sock_pm = PM_SOCK; + + +int x_read_full(int fd, uint8_t *buf, size_t n) +{ + size_t off = 0; + while (off < n) { + ssize_t r = read(fd, buf + off, n - off); + if (r <= 0) return -1; + off += (size_t)r; + } + return 0; +} + +int x_write_full(int fd, const uint8_t *buf, size_t n) +{ + size_t off = 0; + while (off < n) { + ssize_t w = write(fd, buf + off, n - off); + if (w <= 0) return -1; + off += (size_t)w; + } + return 0; +} + +int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payload, uint32_t len) +{ + uint8_t hdr[MEOW_IPC_HDR_LEN]; + uint32_t magic = MEOW_IPC_MAGIC, rl = len; + uint16_t ver = MEOW_IPC_VERSION, t = type; + memcpy(hdr + 0, &magic, 4); + memcpy(hdr + 4, &ver, 2); + memcpy(hdr + 6, &t, 2); + memcpy(hdr + 8, &req_id, 4); + memcpy(hdr + 12, &rl, 4); + if (x_write_full(fd, hdr, MEOW_IPC_HDR_LEN) < 0) return -1; + if (len && payload && x_write_full(fd, payload, len) < 0) return -1; + return 0; +} + +int recv_frame(int fd, uint16_t *type, uint32_t *req_id, uint8_t *buf, uint32_t cap) +{ + uint8_t hdr[MEOW_IPC_HDR_LEN]; + if (x_read_full(fd, hdr, sizeof hdr) < 0) return -1; + uint32_t magic, rlen, rid; + uint16_t ver, t; + memcpy(&magic, hdr, 4); + memcpy(&ver, hdr + 4, 2); + memcpy(&t, hdr + 6, 2); + memcpy(&rid, hdr + 8, 4); + memcpy(&rlen, hdr + 12, 4); + if (magic != MEOW_IPC_MAGIC || ver != MEOW_IPC_VERSION) return -1; + if (rlen > MEOW_IPC_MAX) return -1; + if (type) *type = t; + if (req_id) *req_id = rid; + uint32_t n = (rlen < cap) ? rlen : cap; + if (n && x_read_full(fd, buf, n) < 0) return -1; + /* 若实际更长,丢弃剩余 */ + for (uint32_t i = n; i < rlen; i++) { uint8_t c; if (x_read_full(fd, &c, 1) < 0) return -1; } + return (int)n; +} + +int ipc_connect_tmo(const char *sock, int tmo_ms) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + struct sockaddr_un a; + memset(&a, 0, sizeof a); + a.sun_family = AF_UNIX; + strncpy(a.sun_path, sock, sizeof a.sun_path - 1); + int fl = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, fl | O_NONBLOCK); + int rc = connect(fd, (struct sockaddr *)&a, sizeof a); + if (rc < 0 && errno == EINPROGRESS) { + struct pollfd pf = { fd, POLLOUT, 0 }; + int pr = poll(&pf, 1, tmo_ms); + if (pr <= 0) { close(fd); return -1; } + int soerr = 0; + socklen_t sl = sizeof soerr; + getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &sl); + if (soerr != 0) { close(fd); return -1; } + } else if (rc < 0) { close(fd); return -1; } + fcntl(fd, F_SETFL, fl); + /* 读/写超时兜底(正常服务应即时响应) */ + struct timeval tv = { tmo_ms / 1000, (tmo_ms % 1000) * 1000 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + return fd; +} + +int ipc_hello(int fd) +{ + m_hello_t h; + memset(&h, 0, sizeof h); + strncpy(h.app_id, "os.meow.panel", sizeof h.app_id - 1); + h.version = 1; + if (send_frame(fd, MSG_HELLO, 1, &h, sizeof h) < 0) return -1; + uint8_t buf[64]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_ack_t)) return -1; + if (type != MSG_ACK) return -1; + m_ack_t a; + memcpy(&a, buf, sizeof a); + return (a.code == MEOW_OK) ? 0 : -1; +} + +const char *perf_code_str(int code) +{ + switch (code) { + case MEOW_OK: return ""; /* 不应使用 */ + case MEOW_ERR_INVAL: return "参数非法"; + case MEOW_ERR_IO: return "读取失败"; + case MEOW_ERR_NODEV: return "设备不存在"; + default: return "服务端错误"; + } +} + +int perf_subset_for_path(const char *path) +{ + if (!strcmp(path, "/api/perf")) return PERF_SUBSET_PERF; + if (!strcmp(path, "/api/system")) return 0; /* system 需 uptime/storage,取全量 */ + return 0; /* /api/status */ +} + +int ipc_perf_query(int subset, char *out, int cap) +{ + int fd = ipc_connect_tmo(g_sock_perf, IPC_TIMEOUT_MS); + if (fd < 0) return MEOW_ERR_NODEV; + int rc = MEOW_ERR_INTERNAL; + char msg[128] = ""; + if (ipc_hello(fd) < 0) { rc = MEOW_ERR_INTERNAL; snprintf(msg, sizeof msg, "握手中断:性能服务未就绪"); goto done; } + m_perf_query_t q; + memset(&q, 0, sizeof q); + q.subset = (uint32_t)subset; + if (send_frame(fd, MSG_PERF_QUERY, 2, &q, sizeof q) < 0) { rc = MEOW_ERR_IO; snprintf(msg, sizeof msg, "发送查询失败"); goto done; } + uint8_t buf[MEOW_IPC_MAX + MEOW_IPC_HDR_LEN]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_perf_result_t)) { rc = MEOW_ERR_IO; snprintf(msg, sizeof msg, "应答帧过短"); goto done; } + if (type != MSG_PERF_RESULT) { rc = MEOW_ERR_INVAL; snprintf(msg, sizeof msg, "非预期应答类型 0x%04x", type); goto done; } + m_perf_result_t r; + memcpy(&r, buf, sizeof r); + if (r.code != MEOW_OK) { rc = (int)r.code; snprintf(msg, sizeof msg, "性能服务错误: %s", perf_code_str((int)r.code)); goto done; } + uint16_t jl = r.json_len; + if (jl > sizeof r.json - 1) jl = (uint16_t)(sizeof r.json - 1); + if ((size_t)jl >= (size_t)cap) jl = (uint16_t)(cap - 1); + memcpy(out, r.json, jl); + out[jl] = 0; + rc = MEOW_OK; +done: + close(fd); + /* 服务不可达/非 OK 由调用方据此返回错误;绝不静默失败、绝不降级直连 /proc。 */ + return rc; +} + +const char *power_code_str(int code) +{ + switch (code) { + case MEOW_OK: return ""; + case MEOW_ERR_PERM: return "无权限(需 root)"; + case MEOW_ERR_INVAL: return "未知命令"; + case MEOW_ERR_NODEV: return "设备/能力不支持"; + case MEOW_ERR_NOENT: return "缺少升级文件"; + case MEOW_ERR_IO: return "读写失败"; + default: return "电源服务错误"; + } +} + +int ipc_power_action(uint32_t cmd, uint32_t flag, const char *arg, m_power_status_t *out_st) +{ + int fd = ipc_connect_tmo(g_sock_power, IPC_TIMEOUT_MS); + if (fd < 0) return MEOW_ERR_NODEV; + int rc = MEOW_ERR_INTERNAL; + if (ipc_hello(fd) < 0) { rc = MEOW_ERR_INTERNAL; goto done; } + m_power_action_t a; + memset(&a, 0, sizeof a); + a.cmd = cmd; + a.flag = flag; + if (arg) snprintf(a.arg, sizeof a.arg, "%s", arg); + if (send_frame(fd, MSG_POWER_ACTION, 3, &a, sizeof a) < 0) { rc = MEOW_ERR_IO; goto done; } + uint8_t buf[256]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_power_status_t)) { rc = MEOW_ERR_IO; goto done; } + if (type != MSG_POWER_STATUS) { rc = MEOW_ERR_INVAL; goto done; } + m_power_status_t st; + memcpy(&st, buf, sizeof st); + if (out_st) *out_st = st; + rc = (int)st.code; +done: + close(fd); + return rc; +} + +void svc_msg_kv(const char *msg, const char *key, char *out, size_t cap) +{ + out[0] = 0; + if (!msg || !key) return; + size_t kl = strlen(key); + if (strncmp(msg, key, kl) == 0 && msg[kl] == '=' && msg[kl + 1]) + snprintf(out, cap, "%s", msg + kl + 1); +} diff --git a/patch_v2/meowpanel/src/mp_json.c b/patch_v2/meowpanel/src/mp_json.c new file mode 100644 index 0000000..900bb0e --- /dev/null +++ b/patch_v2/meowpanel/src/mp_json.c @@ -0,0 +1,232 @@ +/* mp_json.c — 最简 JSON 解析器/序列化器(jv_t 树;零第三方库)。 + * 模块化拆分自 meowpanel.c(接口/行为不变)。 */ +#include "mp.h" + + +int json_strict_balanced(const char *b, size_t n) +{ + int depth = 0, instr = 0, esc = 0; + for (size_t i = 0; i < n; i++) { + char c = b[i]; + if (instr) { + if (esc) esc = 0; + else if (c == '\\') esc = 1; + else if (c == '"') instr = 0; + continue; + } + if (c == '"') { instr = 1; continue; } + if (c == '{' || c == '[') depth++; + else if (c == '}' || c == ']') { if (--depth < 0) return 0; } + } + return depth == 0 && !instr; +} + +jv_t *jv_alloc(int type) +{ + jv_t *v = calloc(1, sizeof *v); + if (v) v->type = type; + return v; +} + +void jv_free(jv_t *v) +{ + if (!v) return; + jv_t *c = v->child; + while (c) { + jv_t *nx = c->next; + if (v->type == J_ARR) { jv_free(c); } /* 数组元素是完整值(可为对象/嵌套数组),须整体递归释放 */ + else { jv_free(c->child); free(c->str); free(c); } /* 对象:pair 链 → 释放 value + key + pair 节点 */ + c = nx; + } + free(v->str); + free(v); +} + +void jvp_skip_ws(jvp_t *t) +{ + while (t->p < t->end && (*t->p == ' ' || *t->p == '\t' || *t->p == '\n' || *t->p == '\r')) t->p++; +} + +char *jv_parse_string(jvp_t *t) +{ + if (t->p >= t->end || *t->p != '"') { t->err = 1; return NULL; } + t->p++; + sbuf_t s = {0}; + while (t->p < t->end && *t->p != '"') { + unsigned char c = (unsigned char)*t->p++; + if (c == '\\') { + if (t->p >= t->end) { t->err = 1; break; } + char e = *t->p++; + switch (e) { + case 'n': sA(&s, "\n"); break; + case 't': sA(&s, "\t"); break; + case 'r': sA(&s, "\r"); break; + case 'b': sA(&s, "\b"); break; + case 'f': sA(&s, "\f"); break; + case 'u': { + if (t->end - t->p < 4) { t->err = 1; sbuf_free(&s); return NULL; } + unsigned int cp = 0; + for (int i = 0; i < 4; i++) { char h = *t->p++; cp <<= 4; if (h>='0'&&h<='9') cp|=h-'0'; else if (h>='a'&&h<='f') cp|=h-'a'+10; else if (h>='A'&&h<='F') cp|=h-'A'+10; else { t->err=1; sbuf_free(&s); return NULL; } } + /* 只支持 BMP 常见字符;代理对按 UTF-8 重算(简化:cp<0x80 单字节,否则保守转发) */ + if (cp < 0x80) sA(&s, "%c", (int)cp); + else if (cp < 0x800) { sA(&s, "%c", 0xc0|(cp>>6)); sA(&s, "%c", 0x80|(cp&0x3f)); } + else { sA(&s, "%c", 0xe0|(cp>>12)); sA(&s, "%c", 0x80|((cp>>6)&0x3f)); sA(&s, "%c", 0x80|(cp&0x3f)); } + break; } + default: sA(&s, "%c", (int)e); break; + } + } else if (c < 0x20) { t->err = 1; break; } + else sA(&s, "%c", (int)c); + } + if (t->p >= t->end || *t->p != '"') { t->err = 1; sbuf_free(&s); return NULL; } + t->p++; + if (!s.b) { s.b = calloc(1, 1); } + return s.b; +} + +jv_t *jv_parse_object(jvp_t *t) +{ + t->p++; + jv_t *obj = jv_alloc(J_OBJ); + if (!obj) { t->err = 1; return NULL; } + jv_t **tail = &obj->child; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == '}') { t->p++; return obj; } + for (;;) { + jvp_skip_ws(t); + if (t->p >= t->end) { t->err = 1; jv_free(obj); return NULL; } + char *key = jv_parse_string(t); + if (!key) { jv_free(obj); return NULL; } + jvp_skip_ws(t); + if (t->p >= t->end || *t->p != ':') { t->err = 1; free(key); jv_free(obj); return NULL; } + t->p++; + jv_t *val = jv_parse_value(t); + if (!val) { t->err = 1; free(key); jv_free(obj); return NULL; } + jv_t *pair = calloc(1, sizeof *pair); + if (!pair) { t->err = 1; free(key); jv_free(val); jv_free(obj); return NULL; } + pair->type = J_OBJ; + pair->str = key; + pair->child = val; + *tail = pair; + tail = &pair->next; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == ',') { t->p++; continue; } + if (t->p < t->end && *t->p == '}') { t->p++; return obj; } + t->err = 1; + jv_free(obj); + return NULL; + } +} + +jv_t *jv_parse_array(jvp_t *t) +{ + t->p++; + jv_t *arr = jv_alloc(J_ARR); + if (!arr) { t->err = 1; return NULL; } + jv_t **tail = &arr->child; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == ']') { t->p++; return arr; } + for (;;) { + jv_t *v = jv_parse_value(t); + if (!v) { jv_free(arr); return NULL; } + *tail = v; + tail = &v->next; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == ',') { t->p++; continue; } + if (t->p < t->end && *t->p == ']') { t->p++; return arr; } + t->err = 1; + jv_free(arr); + return NULL; + } +} + +jv_t *jv_parse_value(jvp_t *t) +{ + jvp_skip_ws(t); + if (t->p >= t->end) { t->err = 1; return NULL; } + char c = *t->p; + if (c == '{') return jv_parse_object(t); + if (c == '[') return jv_parse_array(t); + if (c == '"') { + char *s = jv_parse_string(t); + if (!s) return NULL; + jv_t *v = jv_alloc(J_STR); + if (!v) { free(s); t->err = 1; return NULL; } + v->str = s; + return v; + } + if (!strncmp(t->p, "true", 4)) { t->p += 4; jv_t *v = jv_alloc(J_BOOL); if (v) v->bval = 1; return v; } + if (!strncmp(t->p, "false", 5)) { t->p += 5; return jv_alloc(J_BOOL); } + if (!strncmp(t->p, "null", 4)) { t->p += 4; return jv_alloc(J_NULL); } + /* number */ + const char *q = t->p; + int isint = 1; + while (q < t->end && (*q == '-' || *q == '+' || *q == '.' || *q == 'e' || *q == 'E' || isdigit((unsigned char)*q))) { + if (*q == '.' || *q == 'e' || *q == 'E') isint = 0; + q++; + } + if (q == t->p) { t->err = 1; return NULL; } + char tmp[64]; + size_t dlen = (size_t)(q - t->p); + if (dlen >= sizeof tmp) dlen = sizeof tmp - 1; + memcpy(tmp, t->p, dlen); + tmp[dlen] = 0; + t->p = q; + jv_t *v = jv_alloc(J_NUM); + if (!v) { t->err = 1; return NULL; } + v->dnum = strtod(tmp, NULL); + v->inum = (long long)strtoll(tmp, NULL, 10); + (void)isint; + return v; +} + +jv_t *jv_parse(const char *text, int len) +{ + jvp_t t; + t.p = text; + t.end = text + len; + t.err = 0; + jv_t *v = jv_parse_value(&t); + if (!v || t.err) { if (v) jv_free(v); return NULL; } + return v; +} + +jv_t *jv_obj_get(const jv_t *obj, const char *key) +{ + if (!obj || obj->type != J_OBJ) return NULL; + for (const jv_t *p = obj->child; p; p = p->next) + if (p->str && !strcmp(p->str, key)) return p->child; + return NULL; +} + +void jv_to_json(sbuf_t *s, const jv_t *v) +{ + if (!v) { sA(s, "null"); return; } + switch (v->type) { + case J_NULL: sA(s, "null"); break; + case J_BOOL: sA(s, v->bval ? "true" : "false"); break; + case J_NUM: + if (v->dnum == (double)v->inum) sA(s, "%lld", v->inum); + else sA(s, "%g", v->dnum); + break; + case J_STR: sA_str(s, v->str ? v->str : ""); break; + case J_ARR: + sA(s, "["); + { int f = 1; for (const jv_t *e = v->child; e; e = e->next) { if (!f) sA(s, ","); f = 0; jv_to_json(s, e); } } + sA(s, "]"); + break; + case J_OBJ: + sA(s, "{"); + { int f2 = 1; + for (const jv_t *p = v->child; p; p = p->next) { + if (!f2) { sA(s, ","); } + f2 = 0; + sA_str(s, p->str); sA(s, ":"); jv_to_json(s, p->child); + } } + sA(s, "}"); + break; + } +} + +int is_hex(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); } + +int hexval(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; return c - 'A' + 10; } diff --git a/patch_v2/meowpanel/src/mp_key.c b/patch_v2/meowpanel/src/mp_key.c new file mode 100644 index 0000000..cedea86 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_key.c @@ -0,0 +1,126 @@ +/* mp_key.c — 远程控制域:按键注入(写 /dev/input/event* EV_KEY,物理键红线内)。 */ +#include "mp.h" + +#define PWR_KEYDEV "/dev/input/event0" +#define ADC_KEYDEV "/dev/input/event2" + + +int keycode_to_device(int code, const char **dev) +{ + switch (code) { + case KEY_POWER: *dev = PWR_KEYDEV; return KEY_POWER; + case KEY_PREVIOUSSONG: *dev = PWR_KEYDEV; return KEY_PREVIOUSSONG; + case KEY_VOLUMEUP: *dev = ADC_KEYDEV; return KEY_VOLUMEUP; + case KEY_VOLUMEDOWN: *dev = ADC_KEYDEV; return KEY_VOLUMEDOWN; + case KEY_NEXTSONG: *dev = ADC_KEYDEV; return KEY_NEXTSONG; + case KEY_PLAYPAUSE: *dev = ADC_KEYDEV; return KEY_PLAYPAUSE; + default: return -1; + } +} + +int inject_key_event(const char *dev, int code, int down) +{ + int fd = open(dev, O_WRONLY | O_NONBLOCK); + if (fd < 0) return -1; + struct input_event ev; + memset(&ev, 0, sizeof ev); + ev.type = EV_KEY; + ev.code = code; + ev.value = down ? 1 : 0; + int ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev); + if (ok) { + memset(&ev, 0, sizeof ev); + ev.type = EV_SYN; + ev.code = SYN_REPORT; + ev.value = 0; + ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev); + } + close(fd); + return ok ? 0 : -1; +} + +int inject_key_release(int code, int press_ms) +{ + const char *dev = NULL; + int kc = keycode_to_device(code, &dev); + if (kc < 0) return -1; + int release_ms = press_ms > 0 ? press_ms : 0; + if (inject_key_event(dev, code, 1) < 0) return -1; + if (release_ms > 0) usleep((useconds_t)release_ms * 1000); + else usleep(80 * 1000); + if (inject_key_event(dev, code, 0) < 0) return -1; + return 0; +} + +int api_key_single(int sock, int code, const req_t *r) +{ + int press_ms = 0; + char pressbuf[16] = "tap"; + const char *press = pressbuf; + if (r->body && r->body[0]) { + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (root) { + jv_t *p = jv_obj_get(root, "press"); + if (p && p->type == J_STR) snprintf(pressbuf, sizeof pressbuf, "%s", p->str); + jv_t *m = jv_obj_get(root, "ms"); + if (m && m->type == J_NUM) press_ms = (int)m->inum; + jv_free(root); + } + } + if (!strcmp(press, "hold")) + return send_err(sock, 501, "NOT_IMPLEMENTED", "hold 持续注入需常驻通道(阶段 D),后端未实现"); + if (strcmp(press, "tap") && strcmp(press, "long")) + return send_err(sock, 400, "BAD_REQUEST", "press 非法"); + if (press_ms <= 0) { + if (!strcmp(press, "long")) press_ms = 3000; + } + /* 物理键红线:禁止映射 返回(158)/主页(172)/确认/exit(APPS.md)。 */ + if (code == 158 || code == 172 || code == 32 || code == 28 || code == 96) + return send_err(sock, 403, "FORBIDDEN", "该键码(返回/主页/确认)按物理键红线禁止映射"); + const char *tmpdev = NULL; + int dispatchable = (keycode_to_device(code, &tmpdev) >= 0); + if (!dispatchable) + return send_err(sock, 422, "UNSUPPORTED", "不支持的键码(仅音量/电源/播放/上下曲)"); + if (inject_key_release(code, press_ms) < 0) + return send_err(sock, 500, "INTERNAL", "按键注入失败(无法打开输入设备)"); + sbuf_t s = {0}; + sA(&s, "{\"code\":%d,\"press\":\"%s\",\"dispatched\":true}", code, press); + int ret = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return ret; +} + +int api_key_batch(int sock, const req_t *r) +{ + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *keys = jv_obj_get(root, "keys"); + if (!keys || keys->type != J_ARR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺 keys 数组"); } + sbuf_t s = {0}; + int first = 1; + for (const jv_t *k = keys->child; k; k = k->next) { + if (k->type != J_OBJ) continue; + jv_t *c = jv_obj_get(k, "code"); + jv_t *p = jv_obj_get(k, "press"); + jv_t *m = jv_obj_get(k, "ms"); + if (!c || c->type != J_NUM) continue; + int code = (int)c->inum; + if (code == 158 || code == 172) continue; /* 红线跳过 */ + int press_ms = (m && m->type == J_NUM) ? (int)m->inum : 0; + if (p && p->type == J_STR && !strcmp(p->str, "long") && press_ms <= 0) press_ms = 3000; + const char *tmpdev = NULL; + if (keycode_to_device(code, &tmpdev) < 0) continue; + if (inject_key_release(code, press_ms) < 0) continue; + if (!first) sA(&s, ","); + first = 0; + sA(&s, "%d", code); + } + jv_free(root); + sbuf_t o = {0}; + if (first) sA(&o, "{\"dispatched\":[]}"); + else sA(&o, "{\"dispatched\":[%s]}", s.b); + int ret = send_json(sock, 200, o.b ? o.b : ""); + sbuf_free(&s); sbuf_free(&o); + return ret; +} diff --git a/patch_v2/meowpanel/src/mp_logs.c b/patch_v2/meowpanel/src/mp_logs.c new file mode 100644 index 0000000..0745b06 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_logs.c @@ -0,0 +1,241 @@ +/* mp_logs.c — 日志域:/api/logs 列表 + 读 + 崩溃汇总(meow_crash 落盘格式)。 */ +#include "mp.h" + +#define CRASH_TAIL_MAX (256u * 1024u) /* 崩溃日志单次读取上限 */ +#define CRASH_ENT_MAX 64 /* 实体汇总上限 */ +#define CRASH_DMESG_TAIL 5 /* dmesg 保留最近行数 */ + + +const char *log_group(const char *name) +{ + if (!strncmp(name, "crash_", 6)) return "crash"; + if (!strncmp(name, "ams_", 4)) return "ams"; + if (!strncmp(name, "audio_monitor_", 14)) return "audio_monitor"; + if (!strncmp(name, "net_", 4)) return "net"; + if (!strncmp(name, "power_", 6)) return "power"; + if (!strncmp(name, "meow_", 5)) return "system"; + return "app"; +} + +int api_logs_list(int sock) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + DIR *d = opendir(LOGS_DIR); + if (!d) return send_err(sock, 404, "NOT_FOUND", "日志目录不存在"); + sbuf_t s = {0}; + sA(&s, "{\"dir\":"); + sA_str(&s, LOGS_DIR); + sA(&s, ",\"files\":["); + int first = 1; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + if (is_sensitive_name(e->d_name)) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, e->d_name); + struct stat st; + if (stat(full, &st) < 0 || !S_ISREG(st.st_mode)) continue; + if (!first) sA(&s, ","); + first = 0; + sA(&s, "{\"name\":"); + sA_str(&s, e->d_name); + sA(&s, ",\"size\":%lld,\"mtime\":%lld,\"group\":", (long long)st.st_size, (long long)st.st_mtime); + sA_str(&s, log_group(e->d_name)); + sA(&s, "}"); + } + closedir(d); + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_logs_read(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char file[512], lines[16]; + if (!qparam(r->query, "file", file, sizeof file)) + return send_err(sock, 400, "BAD_REQUEST", "缺 file 参数"); + int maxlines = 200; + if (qparam(r->query, "lines", lines, sizeof lines)) { + int n = atoi(lines); + if (n > 0) maxlines = n; + if (maxlines > LOG_MAX_LINES) maxlines = LOG_MAX_LINES; + } + /* 防逃逸:file 相对 LOGS_DIR,禁止 .. 与绝对路径 */ + if (file[0] == '/' || strstr(file, "..")) return send_err(sock, 403, "FORBIDDEN", "文件路径逃逸"); + if (is_sensitive_name(strrchr(file, '/') ? strrchr(file, '/') + 1 : file)) + return send_err(sock, 403, "FORBIDDEN", "敏感文件"); + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, file); + int fd = open(full, O_RDONLY); + if (fd < 0) return send_err(sock, 404, "NOT_FOUND", "日志文件不存在"); + struct stat st; + if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { close(fd); return send_err(sock, 404, "NOT_FOUND", "非普通文件"); } + /* 逐行读,保留末尾 maxlines 行 */ + const char *buf = malloc((size_t)st.st_size + 1); + if (!buf) { close(fd); return send_err(sock, 500, "INTERNAL", "内存不足"); } + ssize_t n = read(fd, (char *)buf, (size_t)st.st_size); + close(fd); + if (n < 0) { free((void *)buf); return send_err(sock, 500, "INTERNAL", "读日志失败"); } + sbuf_t s = {0}; + sA(&s, "{\"file\":"); + sA_str(&s, file); + sA(&s, ",\"lines\":["); + /* 直接扫一遍,保留末尾 maxlines 行文本 */ + const char *p = buf; + const char *end = buf + n; + int total = 0, truncated = 0; + int gotlines = 0; + int linecnt = 0; + for (const char *c = buf; c < end; c++) if (*c == '\n') linecnt++; + if (linecnt > 0 && end[-1] != '\n') linecnt++; + int startrow = linecnt - maxlines; + if (startrow < 0) startrow = 0; + if (startrow > 0) truncated = 1; + int rowidx = 0; + p = buf; + while (p < end) { + const char *nl = memchr(p, '\n', (size_t)(end - p)); + const char *line_end = nl ? nl : end; + if (rowidx >= startrow) { + if (gotlines) sA(&s, ","); + /* sA_str_n:转义 + UTF-8 消毒(任意长度安全;勿 sA("%s")——单次超 511B 会截断坏 JSON) */ + sA_str_n(&s, p, (size_t)(line_end - p)); + gotlines++; + } + rowidx++; + if (!nl) break; + p = nl + 1; + } + total = linecnt; + free((void *)buf); + sA(&s, "],\"total\":%d,\"truncated\":%s}", total, truncated ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_logs_crash(int sock) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + /* 找最新 crash_*.log(按 mtime);无 → 空汇总(不 404,不谎报)。 */ + DIR *d = opendir(LOGS_DIR); + if (!d) return send_json(sock, 200, "{\"file\":\"\",\"count\":0,\"dmesg_count\":0,\"dmesg_tail\":[],\"entities\":[]}"); + char best[512] = ""; long best_mt = -1; + struct dirent *e; + while ((e = readdir(d))) { + if (strncmp(e->d_name, "crash_", 6) != 0 || e->d_name[0] == '.') continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, e->d_name); + struct stat st; + if (stat(full, &st) < 0 || !S_ISREG(st.st_mode)) continue; + if (st.st_mtime > best_mt) { best_mt = st.st_mtime; snprintf(best, sizeof best, "%s", e->d_name); } + } + closedir(d); + if (!best[0]) + return send_json(sock, 200, "{\"file\":\"\",\"count\":0,\"dmesg_count\":0,\"dmesg_tail\":[],\"entities\":[]}"); + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, best); + int fd = open(full, O_RDONLY); + struct stat st; + if (fd < 0 || fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { if (fd >= 0) close(fd); return send_err(sock, 500, "INTERNAL", "崩溃日志不可读"); } + off_t off = st.st_size > (off_t)CRASH_TAIL_MAX ? st.st_size - (off_t)CRASH_TAIL_MAX : 0; + size_t buflen = (size_t)(st.st_size - off); + if (buflen == 0) { close(fd); return send_json(sock, 200, "{\"file\":\"\",\"count\":0,\"dmesg_count\":0,\"dmesg_tail\":[],\"entities\":[]}"); } + if (off) (void)lseek(fd, off, SEEK_SET); + char *buf = malloc(buflen + 1); + if (!buf) { close(fd); return send_err(sock, 500, "INTERNAL", "内存不足"); } + ssize_t n = read(fd, buf, buflen); + close(fd); + if (n < 0) { free(buf); return send_err(sock, 500, "INTERNAL", "读崩溃日志失败"); } + buf[n] = 0; + + /* 每实体聚合(svc 行);dmesg 单独计数 + 留最近几行。 + * 有界缓冲(det ≤ 127 字符)保证 snprintf 到 name/detail 不越界且 -Wformat-truncation 零告警。 */ + struct { char name[128]; int count; long long last_epoch; char kind[8]; int code; char detail[160]; } ents[CRASH_ENT_MAX]; + int entn = 0, svc_count = 0, dmesg_count = 0; + char dmesg_tail[CRASH_DMESG_TAIL][128]; int dmesg_tail_n = 0; + char *ln = buf; + while (*ln && (*ln == '\n')) ln++; + while (*ln) { + char *nl = strchr(ln, '\n'); + if (nl) *nl = 0; + long long epoch = 0, up = 0; char src[16] = "", det[128] = ""; + int f = sscanf(ln, "%lld %lld %15s %127[^\n]", &epoch, &up, src, det); + if (f >= 3) { + if (!strcmp(src, "svc")) { + svc_count++; + /* det = ` exit=` 或 ` signal=`;name 为第一 token(就地切分) */ + char name[128] = "", kind[8] = ""; int code = 0, k2 = 0; + char *sp = strchr(det, ' '); + if (sp) { + *sp = 0; + snprintf(name, sizeof name, "%s", det); /* det ≤127 → name[128] 不截断 */ + char *kp = sp + 1; + if (!strncmp(kp, "exit=", 5)) { snprintf(kind, sizeof kind, "exit"); code = atoi(kp + 5); k2 = 1; } + else if (!strncmp(kp, "signal=", 7)) { snprintf(kind, sizeof kind, "sig"); code = atoi(kp + 7); k2 = 1; } + else { snprintf(kind, sizeof kind, "crash"); k2 = 0; } + } else { + snprintf(name, sizeof name, "%s", det); + snprintf(kind, sizeof kind, "crash"); + } + /* 聚合到 ents(按 name 匹配;超限则丢弃剩余陌生实体,仅计入总数) */ + int found = -1; + for (int i = 0; i < entn; i++) if (!strcmp(ents[i].name, name)) { found = i; break; } + if (found < 0 && entn < CRASH_ENT_MAX) { + found = entn++; + snprintf(ents[found].name, sizeof ents[found].name, "%s", name); + ents[found].count = 0; ents[found].last_epoch = 0; + ents[found].kind[0] = 0; ents[found].code = 0; ents[found].detail[0] = 0; + } + if (found >= 0) { + ents[found].count++; + if (epoch >= ents[found].last_epoch) { + ents[found].last_epoch = epoch; + snprintf(ents[found].kind, sizeof ents[found].kind, "%s", k2 ? kind : "crash"); + ents[found].code = code; + snprintf(ents[found].detail, sizeof ents[found].detail, "%s", det); + } + } + } else if (!strcmp(src, "dmesg")) { + dmesg_count++; + if (dmesg_tail_n < CRASH_DMESG_TAIL) snprintf(dmesg_tail[dmesg_tail_n++], sizeof dmesg_tail[0], "%s", det); + else { /* 前移一行,末尾放最新 */ for (int i = 0; i < dmesg_tail_n - 1; i++) memcpy(dmesg_tail[i], dmesg_tail[i + 1], sizeof dmesg_tail[0]); snprintf(dmesg_tail[dmesg_tail_n - 1], sizeof dmesg_tail[0], "%s", det); } + } + } + if (nl) { *nl = '\n'; ln = nl + 1; } else break; + while (*ln == '\n') ln++; + } + free(buf); + + sbuf_t s = {0}; + sA(&s, "{\"file\":"); + sA_str(&s, best); + sA(&s, ",\"count\":%d,\"dmesg_count\":%d,\"dmesg_tail\":[", svc_count, dmesg_count); + for (int i = 0; i < dmesg_tail_n; i++) { if (i) sA(&s, ","); sA_str(&s, dmesg_tail[i]); } + sA(&s, "],\"entities\":["); + int first = 1; + for (int i = 0; i < entn; i++) { + if (!ents[i].count) continue; + if (!first) sA(&s, ","); + first = 0; + sA(&s, "{\"name\":"); + sA_str(&s, ents[i].name); + sA(&s, ",\"source\":\"svc\",\"count\":%d,\"last_epoch\":%lld,\"last_kind\":", ents[i].count, ents[i].last_epoch); + sA_str(&s, ents[i].kind[0] ? ents[i].kind : "crash"); + sA(&s, ",\"last_code\":%d,\"last_detail\":", ents[i].code); + sA_str(&s, ents[i].detail); + /* 最近崩溃时间(epoch → 可读);strftime 本地时区,失败回退 epoch */ + char tbuf[32] = ""; time_t t = (time_t)ents[i].last_epoch; + struct tm tmv; + if (ents[i].last_epoch > 0 && localtime_r(&t, &tmv)) strftime(tbuf, sizeof tbuf, "%Y-%m-%d %H:%M:%S", &tmv); + sA(&s, ",\"last_time\":"); + sA_str(&s, tbuf); + sA(&s, "}"); + } + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} diff --git a/patch_v2/meowpanel/src/mp_net.c b/patch_v2/meowpanel/src/mp_net.c new file mode 100644 index 0000000..f2f4cc9 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_net.c @@ -0,0 +1,440 @@ +/* mp_net.c — 网络管理域:meow_netmgrd IPC(状态/连接/DNS/ifaces/policy,期2/期3)。 */ +#include "mp.h" + +#define NET_LOG_TAIL_MAX (256 * 1024) /* net 日志 tail 读取上限 */ + + +int net_ipc(uint16_t req_type, const void *req, uint32_t req_len, + uint16_t *ret_type, uint32_t *code, uint16_t *meta, + char *out, size_t cap) +{ + int fd = ipc_connect_tmo(g_sock_net, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + uint8_t buf[MEOW_IPC_MAX]; + uint16_t type = 0; uint32_t rid = 0; + int rlen = -1; + if (send_frame(fd, req_type, 0x1020, req, req_len) == 0) + rlen = recv_frame(fd, &type, &rid, buf, sizeof buf); + close(fd); + if (rlen <= 0) return -1; + if (ret_type) *ret_type = type; + /* m_net_*_result 统一布局:code(u32)|len(u16)|meta/pad(u16)|content[len],content 偏移 8 */ + uint32_t c = 0; uint16_t ln = 0, mv = 0; + memcpy(&c, buf, 4); memcpy(&ln, buf + 4, 2); memcpy(&mv, buf + 6, 2); + if (code) *code = c; + if (meta) *meta = mv; + if ((size_t)ln >= cap) ln = (uint16_t)(cap - 1); + if ((size_t)(8 + ln) < sizeof buf) buf[8 + ln] = 0; + memcpy(out, buf + 8, ln); + out[ln] = 0; + return (int)ln; +} + +int api_net_status(int sock) +{ + uint16_t t; uint32_t code; char json[MEOW_IPC_MAX]; + int n = net_ipc(MSG_NET_STATUS_REQ, NULL, 0, &t, &code, NULL, json, sizeof json); + if (n <= 0 || t != MSG_NET_STATUS_RESULT || code != MEOW_OK || !json[0]) + return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用"); + if (!utf8_valid_n(json, strlen(json))) /* 上游坏字节(含 ssid)→ 不返回,降级 */ + return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务应答包含非法字节"); + return send_json(sock, 200, json); +} + +int net_conns_parse_body(sbuf_t *s, const char *txt) +{ + const char *p = txt; + int first = 1, n = 0; + while (p && *p) { + const char *nl = strchr(p, '\n'); + size_t ll = nl ? (size_t)(nl - p) : strlen(p); + if (ll == 0) { p = nl ? nl + 1 : p + 1; continue; } + char line[384]; + if (ll >= sizeof line) ll = sizeof line - 1; + memcpy(line, p, ll); line[ll] = 0; + char *f[6]; int fn = 0; char *cur = line; + while (fn < 6) { + f[fn++] = cur; + char *bar = strchr(cur, '|'); + if (!bar) break; + *bar = 0; cur = bar + 1; + } + if (fn >= 6) { + if (!first) sA(s, ","); + first = 0; + sA(s, "{\"entity\":"); sA_str(s, f[0]); + sA(s, ",\"local\":"); sA_str(s, f[1]); + sA(s, ",\"remote\":"); sA_str(s, f[2]); + sA(s, ",\"state\":"); sA_str(s, f[3]); + sA(s, ",\"age\":%lld,\"domain\":", atoll(f[4])); + sA_str(s, strcmp(f[5], "-") == 0 ? "" : f[5]); + sA(s, "}"); + n++; + } + p = nl ? nl + 1 : p + ll; + } + return n; +} + +int api_net_conns(int sock, const char *query) +{ + m_net_conns_req_t q; memset(&q, 0, sizeof q); + char ent[128]; + if (qparam(query, "entity", ent, sizeof ent)) + strncpy(q.entity, ent, sizeof q.entity - 1); + uint16_t t; uint32_t code; uint16_t meta; char txt[MEOW_IPC_MAX]; + int n = net_ipc(MSG_NET_CONNS_REQ, &q, sizeof q, &t, &code, &meta, txt, sizeof txt); + if (n <= 0 || t != MSG_NET_CONNS_RESULT || code != MEOW_OK) + return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用"); + sbuf_t a = {0}; + int count = net_conns_parse_body(&a, txt); + sbuf_t s = {0}; + sA(&s, "{\"truncated\":%s,\"count\":%d,\"conns\":[", meta ? "true" : "false", count); + if (a.n) sA_append(&s, a.b, a.n); + sA(&s, "]}"); + sbuf_free(&a); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int net_dns_obj_fill(sbuf_t *s) +{ + m_net_dns_ctl_t q; memset(&q, 0, sizeof q); + strcpy(q.cmd, "status"); + uint16_t t; uint32_t code; uint16_t meta; char txt[MEOW_IPC_MAX]; + int n = net_ipc(MSG_NET_DNS_CTL, &q, sizeof q, &t, &code, &meta, txt, sizeof txt); + if (n <= 0 || t != MSG_NET_DNS_RESULT) return -1; + if (code != MEOW_OK) return -2; + /* txt: `dns mode=%s enabled=%d upstream=%s cache=%d/%d q=%llu deny=%llu first=%llu resolv_modified=%d` */ + char mode[32] = "", upstream[64] = ""; + int enabled = 0, ch = 0, ct = 0, rmod = 0; + unsigned long long qq = 0, deny = 0, first = 0; + (void)sscanf(txt, "dns mode=%31s enabled=%d upstream=%63s cache=%d/%d q=%llu deny=%llu first=%llu resolv_modified=%d", + mode, &enabled, upstream, &ch, &ct, &qq, &deny, &first, &rmod); + sA(s, "{\"mode\":"); + sA_str(s, mode); + sA(s, ",\"enabled\":%s,\"upstream\":", enabled ? "true" : "false"); + sA_str(s, upstream); + sA(s, ",\"cache_hits\":%d,\"cache_total\":%d,\"queries\":%llu,\"denies\":%llu,\"first_seen\":%llu,\"resolv_modified\":%s}", + ch, ct, qq, deny, first, rmod ? "true" : "false"); + return 0; +} + +int api_net_dns(int sock) +{ + sbuf_t s = {0}; + int rc = net_dns_obj_fill(&s); + if (rc == -1) { sbuf_free(&s); return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用"); } + if (rc == -2) { sbuf_free(&s); return send_err(sock, 503, "NET_NODEV", "DNS 代理不可用"); } + int out = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return out; +} + +int net_block_fill(sbuf_t *s) +{ + uint16_t nt; uint32_t ncode; char njson[MEOW_IPC_MAX]; + int nn = net_ipc(MSG_NET_STATUS_REQ, NULL, 0, &nt, &ncode, NULL, njson, sizeof njson); + if (nn <= 0 || nt != MSG_NET_STATUS_RESULT || ncode != MEOW_OK || !njson[0]) return -1; + /* 状态 JSON 为对象:剥外层大括号,拼入 DNS 子键(DNS 不可用则省略该键,仍回 net 状态,不谎报)。 */ + size_t l = strlen(njson); + while (l > 0 && (njson[l - 1] == ' ' || njson[l - 1] == '\t' || njson[l - 1] == '\n' || njson[l - 1] == '\r')) njson[--l] = 0; + if (l < 2 || njson[0] != '{' || njson[l - 1] != '}') return -1; + if (!utf8_valid_n(njson, l)) return -1; /* 任一上游(netmgrd 含 ssid)产出非法 UTF-8 → 整块降级,不拖垮整页 */ + njson[l - 1] = 0; /* 去尾部 } */ + sbuf_t dns = {0}; + int drc = net_dns_obj_fill(&dns); + sA(s, ",\"net\":{"); + sA(s, "%s", njson + 1); /* 去首 { 的状态内层 */ + if (drc == 0 && dns.b) sA(s, ",\"dns\":%s", dns.b); + sA(s, "}"); /* net 块只开了一个 {,一个 } 收尾(批2 曾写 }} 多一括号,浏览器 Extra data 破页 2026-09-02) */ + sbuf_free(&dns); + return 0; +} + +int api_net_dns_flush(int sock) +{ + m_net_dns_ctl_t q; memset(&q, 0, sizeof q); + strcpy(q.cmd, "flush"); + uint16_t t; uint32_t code; uint16_t meta; char txt[MEOW_IPC_MAX]; + int n = net_ipc(MSG_NET_DNS_CTL, &q, sizeof q, &t, &code, &meta, txt, sizeof txt); + if (n <= 0 || t != MSG_NET_DNS_RESULT) + return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用"); + if (code == (uint32_t)MEOW_ERR_PERM) + return send_err(sock, 403, "FORBIDDEN", txt[0] ? txt : "DNS flush 需系统身份"); + if (code != MEOW_OK) + return send_err(sock, 503, "NET_NODEV", txt[0] ? txt : "DNS flush 失败"); + /* txt: `dns cache flushed (N entries)` */ + int entries = 0; + (void)sscanf(txt, "dns cache flushed (%d entries)", &entries); + sbuf_t s = {0}; + sA(&s, "{\"flushed\":true,\"entries\":%d}", entries); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_net_log(int sock) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + DIR *d = opendir(LOGS_DIR); + if (!d) return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); + char best[512] = ""; long best_mt = -1; + struct dirent *e; + while ((e = readdir(d))) { + if (strncmp(e->d_name, "net_", 4) != 0) continue; + if (e->d_name[0] == '.') continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, e->d_name); + struct stat st; + if (stat(full, &st) < 0 || !S_ISREG(st.st_mode)) continue; + if (st.st_mtime > best_mt) { best_mt = st.st_mtime; snprintf(best, sizeof best, "%s", e->d_name); } + } + closedir(d); + if (!best[0]) + return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, best); + int fd = open(full, O_RDONLY); + struct stat st; + if (fd < 0 || fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { + if (fd >= 0) close(fd); + return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); + } + off_t off = st.st_size > NET_LOG_TAIL_MAX ? st.st_size - NET_LOG_TAIL_MAX : 0; + size_t buflen = (size_t)(st.st_size - off); + if (buflen == 0) { close(fd); return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); } + if (off) (void)lseek(fd, off, SEEK_SET); + char *buf = malloc(buflen + 1); + if (!buf) { close(fd); return send_err(sock, 500, "INTERNAL", "内存不足"); } + ssize_t n = read(fd, buf, buflen); + close(fd); + if (n <= 0) { free(buf); return send_json(sock, 200, "{\"file\":\"\",\"lines\":[],\"total\":0,\"truncated\":false}"); } + sbuf_t s = {0}; + sA(&s, "{\"file\":"); + sA_str(&s, best); + sA(&s, ",\"lines\":["); + const char *p = buf, *end = buf + n; + int linecnt = 0; + for (const char *c = buf; c < end; c++) if (*c == '\n') linecnt++; + if (linecnt > 0 && end[-1] != '\n') linecnt++; + int maxlines = LOG_MAX_LINES; + int startrow = linecnt - maxlines; + if (startrow < 0) startrow = 0; + int truncated = (off > 0 || startrow > 0) ? 1 : 0; + int rowidx = 0, gotlines = 0; + p = buf; + while (p < end) { + const char *nl = memchr(p, '\n', (size_t)(end - p)); + const char *line_end = nl ? nl : end; + if (rowidx >= startrow) { + if (gotlines) sA(&s, ","); + /* sA_str_n:转义 + UTF-8 消毒(任意长度安全;勿 sA("%s")——单次超 511B 会截断坏 JSON) */ + sA_str_n(&s, p, (size_t)(line_end - p)); + gotlines++; + } + rowidx++; + if (!nl) break; + p = nl + 1; + } + free(buf); + sA(&s, "],\"total\":%d,\"truncated\":%s}", linecnt, truncated ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_net_ifaces(int sock) +{ + m_net_iface_req_t q; memset(&q, 0, sizeof q); + uint16_t t; uint32_t code; char arr[MEOW_IPC_MAX]; + int n = net_ipc(MSG_NET_IFACE_REQ, &q, sizeof q, &t, &code, NULL, arr, sizeof arr); + if (n <= 0 || t != MSG_NET_IFACE_RESULT || code != MEOW_OK || !arr[0]) + return send_json(sock, 200, "{\"available\":false}"); + /* 上游接口数组由 netmgrd iface_json_obj 直出(name/kind/ip/gw/rx/tx,ASCII,无 ssid),仍校验坏字节。 */ + if (!utf8_valid_n(arr, strlen(arr))) + return send_json(sock, 200, "{\"available\":false}"); + + /* 取 rssi + rfkill + dns:用 0xE0 状态 JSON(单一权威,含 wifi.rssi/rfkill[]/bt.dns[]),jv 提取。 */ + uint16_t st; uint32_t scode; char sj[MEOW_IPC_MAX]; + int sn2 = net_ipc(MSG_NET_STATUS_REQ, NULL, 0, &st, &scode, NULL, sj, sizeof sj); + long long rssi = -9999; int got_rssi = 0; + sbuf_t rfkill = {0}, dns = {0}; + if (sn2 > 0 && st == MSG_NET_STATUS_RESULT && scode == MEOW_OK && utf8_valid_n(sj, strlen(sj))) { + jv_t *jr = jv_parse(sj, (int)strlen(sj)); + if (jr) { + jv_t *w = jv_obj_get(jr, "wifi"); + jv_t *r = w ? jv_obj_get(w, "rssi") : NULL; + if (r && r->type == J_NUM) { rssi = r->inum; got_rssi = 1; } + jv_t *rf = jv_obj_get(jr, "rfkill"); + if (rf && rf->type == J_ARR) jv_to_json(&rfkill, rf); + jv_t *bt = jv_obj_get(jr, "bt"); + jv_t *dn = bt ? jv_obj_get(bt, "dns") : NULL; + if (dn && dn->type == J_ARR) jv_to_json(&dns, dn); + jv_free(jr); + } + } + sbuf_t s = {0}; + sA(&s, "{\"available\":true,\"rssi\":"); + if (got_rssi) sA(&s, "%lld", rssi); else sA(&s, "null"); + sA(&s, ",\"rfkill\":%s,\"dns\":%s,\"ifaces\":%s}", rfkill.b ? rfkill.b : "[]", dns.b ? dns.b : "[]", arr); + sbuf_free(&rfkill); sbuf_free(&dns); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +void net_policy_parse(const char *txt, sbuf_t *s) +{ + char lvl[24] = "", enf[24] = "dns_only"; + const char *p = txt; + sA(s, "{\"available\":true,\"enforce\":"); + /* 首行找 enforce=... */ + /* enforce 后面紧跟全角「(」多字节说明,须在非 ASCII 字节处停(否则会吞「(SOCK_DESTROY...)」)。 */ + const char *en = strstr(p, "enforce="); + if (en) { + en += 8; + int i = 0; while (en[i] && en[i] != ' ' && en[i] != '\n' && (unsigned char)en[i] < 0x80 && i < (int)sizeof enf - 1) { enf[i] = en[i]; i++; } + enf[i] = 0; + } + const char *lg = strstr(p, "log_level="); + if (lg) { + lg += 10; + int i = 0; while (lg[i] && lg[i] != ' ' && lg[i] != '\n' && (unsigned char)lg[i] < 0x80 && i < (int)sizeof lvl - 1) { lvl[i] = lg[i]; i++; } + lvl[i] = 0; + } + sA_str(s, enf); + sA(s, ",\"log_level\":"); + sA_str(s, lvl); + sA(s, ",\"rules\":["); + int first = 1; + const char *ln = txt; + while (ln && *ln) { + const char *nl = strchr(ln, '\n'); + size_t ll = nl ? (size_t)(nl - ln) : strlen(ln); + if (ll == 0) { ln = nl ? nl + 1 : ln + ll; continue; } + /* 跳注释行(以 # 开头)与空;数据行为 ` net=... domain=...`(entity 含冒号无空格) */ + const char *c = ln; + while (c < ln + ll && (*c == ' ' || *c == '\t')) c++; + int is_comment = (c < ln + ll && *c == '#'); + int is_none = (strncmp(c, "(none)", 6) == 0 || strncmp(c, "(allow)", 7) == 0); + if (!is_comment && !is_none && c < ln + ll) { + /* entity = 首个空白符之前 */ + char entity[64] = ""; size_t ei = 0; + const char *e = c; + while (e < ln + ll && *e != ' ' && *e != '\t' && ei < sizeof entity - 1) { entity[ei++] = *e++; } + entity[ei] = 0; + /* net=block|allow 与 domain=+h1,-h2 从 entity 之后扫 */ + char net[8] = "allow"; int has_net = 0; + char allow_h[512] = "", deny_h[512] = ""; + const char *k = e; + if (k < ln + ll) { + const char *seg = k; + while (seg < ln + ll && (*seg == ' ' || *seg == '\t')) seg++; + const char *nh = strstr(seg, "net="); + if (nh) { nh += 4; int i2 = 0; while (nh[i2] && nh[i2] != ' ' && nh[i2] != '\n' && i2 < 7) { net[i2] = nh[i2]; i2++; } net[i2] = 0; has_net = 1; } + const char *dh = strstr(seg, "domain="); + if (dh) { + dh += 7; + /* 逗号分隔;+ 前缀=allow,- 前缀=deny。主机列表内部以 ',' 拼接(供拆分回数组)。 */ + size_t ao = 0, do2 = 0; + const char *h = dh; + while (*h && *h != ' ' && *h != '\n' && *h != '\r') { + if (*h == '+') { + h++; + if (ao && ao < sizeof allow_h - 1) allow_h[ao++] = ','; + while (*h && *h != ',' && *h != ' ' && *h != '\n' && ao < sizeof allow_h - 1) allow_h[ao++] = *h++; + } else if (*h == '-') { + h++; + if (do2 && do2 < sizeof deny_h - 1) deny_h[do2++] = ','; + while (*h && *h != ',' && *h != ' ' && *h != '\n' && do2 < sizeof deny_h - 1) deny_h[do2++] = *h++; + } else h++; + if (*h == ',') h++; + } + allow_h[ao] = 0; deny_h[do2] = 0; + } + if (!first) sA(s, ","); + first = 0; + sA(s, "{\"entity\":"); sA_str(s, entity); + sA(s, ",\"net\":"); sA_str(s, has_net ? net : ""); + sA(s, ",\"has_net\":%s", has_net ? "true" : "false"); + sA(s, ",\"allow_hosts\":["); + { const char *hp = allow_h; int hf = 1; + while (hp && *hp) { const char *comma = strchr(hp, ','); size_t hl = comma ? (size_t)(comma - hp) : strlen(hp); + if (!hf) { sA(s, ","); } + hf = 0; sA_str_n(s, hp, hl); + hp = comma ? comma + 1 : hp + hl; } } + sA(s, "],\"deny_hosts\":["); + { const char *hp = deny_h; int hf = 1; + while (hp && *hp) { const char *comma = strchr(hp, ','); size_t hl = comma ? (size_t)(comma - hp) : strlen(hp); + if (!hf) { sA(s, ","); } + hf = 0; sA_str_n(s, hp, hl); + hp = comma ? comma + 1 : hp + hl; } } + sA(s, "]}"); + } + } + ln = nl ? nl + 1 : ln + ll; + } + sA(s, "]}"); +} + +int api_net_policy_get(int sock) +{ + m_net_policy_t q; memset(&q, 0, sizeof q); + q.op = NET_POLICY_SHOW; + uint16_t t; uint32_t code; char txt[MEOW_IPC_MAX]; + int n = net_ipc(MSG_NET_POLICY, &q, sizeof q, &t, &code, NULL, txt, sizeof txt); + if (n <= 0 || t != MSG_NET_POLICY_RESULT || code != MEOW_OK) + return send_json(sock, 200, "{\"available\":false}"); + if (!utf8_valid_n(txt, strlen(txt))) /* 实体名/域名坏字节 → 降级 */ + return send_json(sock, 200, "{\"available\":false}"); + sbuf_t s = {0}; + net_policy_parse(txt, &s); + int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}"); + sbuf_free(&s); + return rc; +} + +int api_net_policy_set(int sock, const char *body, size_t body_len) +{ + if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(body, (int)body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *jo = jv_obj_get(root, "op"), *je = jv_obj_get(root, "entity"), *ja = jv_obj_get(root, "arg"); + char op[16] = "set_net", entity[64] = "", arg[64] = ""; + if (jo && jo->type == J_STR && jo->str) snprintf(op, sizeof op, "%s", jo->str); + if (je && je->type == J_STR && je->str) snprintf(entity, sizeof entity, "%s", je->str); + if (ja && ja->type == J_STR && ja->str) snprintf(arg, sizeof arg, "%s", ja->str); + jv_free(root); + if (!entity[0]) return send_err(sock, 422, "UNSUPPORTED", "缺 entity(app:/svc:/proc:)"); + if (!entity[0] || strchr(entity, '/')) return send_err(sock, 422, "UNSUPPORTED", "entity 非法"); + int nop = 0; + if (!strcmp(op, "set_net")) nop = NET_POLICY_SET_NET; + else if (!strcmp(op, "set_domain")) nop = NET_POLICY_SET_DOMAIN; + else if (!strcmp(op, "del")) nop = NET_POLICY_DEL; + else return send_err(sock, 422, "UNSUPPORTED", "op 须为 set_net|set_domain|del"); + if (nop != NET_POLICY_DEL && !arg[0]) return send_err(sock, 422, "UNSUPPORTED", "缺 arg"); + + m_net_policy_t q; memset(&q, 0, sizeof q); + q.op = (uint32_t)nop; + snprintf(q.entity, sizeof q.entity, "%.47s", entity); /* entity[48] 留一个 NUL;防截断告警 */ + snprintf(q.arg, sizeof q.arg, "%.63s", arg); /* arg[64] 同理 */ + uint16_t t; uint32_t code; char txt[MEOW_IPC_MAX]; + int n = net_ipc(MSG_NET_POLICY, &q, sizeof q, &t, &code, NULL, txt, sizeof txt); + if (n <= 0 || t != MSG_NET_POLICY_RESULT) + return send_err(sock, 503, "NET_UNAVAILABLE", "网络管理服务不可用"); + if (code == (uint32_t)MEOW_ERR_PERM) + return send_err(sock, 403, "FORBIDDEN", txt[0] ? txt : "策略写入需系统身份,面板未被白名单?"); + if (code == (uint32_t)MEOW_ERR_INVAL) + return send_err(sock, 422, "UNSUPPORTED", txt[0] ? txt : "策略参数非法"); + if (code != MEOW_OK) + return send_err(sock, 503, "NET_NODEV", txt[0] ? txt : "策略写入失败"); + sbuf_t s = {0}; + sA(&s, "{\"accepted\":true,\"message\":"); + sA_str(&s, txt); + sA(&s, "}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} diff --git a/patch_v2/meowpanel/src/mp_notif.c b/patch_v2/meowpanel/src/mp_notif.c new file mode 100644 index 0000000..85c1143 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_notif.c @@ -0,0 +1,298 @@ +/* mp_notif.c — 通知域:优先 launcher IPC 活跃集,回退 TF 归档(查询/撤/清)。 */ +#include "mp.h" + + +int notif_csv_field(const char **pp, char *out, int outcap) +{ + const char *p = *pp; + int o = 0; + if (*p == '"') { + p++; + while (*p && *p != '"') { + if (*p == '"' && p[1] == '"') { + if (o < outcap - 1) out[o++] = '"'; + p += 2; + } else { + if (o < outcap - 1) out[o++] = *p; + p++; + } + } + if (*p == '"') p++; + } else { + while (*p && *p != ',' && *p != '\n' && *p != '\r') { + if (o < outcap - 1) out[o++] = *p; + p++; + } + } + if (o < outcap) { out[o] = 0; } + else if (outcap > 0) { out[outcap - 1] = 0; } + if (*p == ',') p++; + *pp = p; + return 0; +} + +int notif_parse_line(const char *line, notif_rec_t *r) +{ + const char *p = line; + char f1[32], f2[16], f6[8], f7[16], f11[8], f12[8], f13[8]; + char app[40], icon[64], cat[40], sum[96], body[128], tgt[64]; + notif_csv_field(&p, f1, sizeof f1); + notif_csv_field(&p, f2, sizeof f2); + notif_csv_field(&p, app, sizeof app); + notif_csv_field(&p, icon, sizeof icon); + notif_csv_field(&p, cat, sizeof cat); + notif_csv_field(&p, f6, sizeof f6); + notif_csv_field(&p, f7, sizeof f7); + notif_csv_field(&p, sum, sizeof sum); + notif_csv_field(&p, body, sizeof body); + notif_csv_field(&p, tgt, sizeof tgt); + notif_csv_field(&p, f11, sizeof f11); + notif_csv_field(&p, f12, sizeof f12); + notif_csv_field(&p, f13, sizeof f13); + r->ms = strtoll(f1, NULL, 10); + r->id = (unsigned)strtoul(f2, NULL, 10); + r->urgency = atoi(f6); + r->progress = atoi(f7); + r->ongoing = atoi(f11); + r->media = atoi(f12); + r->dismissed = atoi(f13); + snprintf(r->app_id, sizeof r->app_id, "%s", app); + snprintf(r->app_icon, sizeof r->app_icon, "%s", icon); + snprintf(r->category, sizeof r->category, "%s", cat); + snprintf(r->summary, sizeof r->summary, "%s", sum); + snprintf(r->body, sizeof r->body, "%s", body); + snprintf(r->target, sizeof r->target, "%s", tgt); + return 0; +} + +int notif_load_list(notif_rec_t *recs, int maxrec) +{ + FILE *f = fopen(g_notif_log, "r"); + if (!f) return -1; + int total = 0, head = 0; + char line[1400]; + notif_rec_t r; + while (fgets(line, sizeof line, f)) { + if (notif_parse_line(line, &r) != 0) continue; + recs[head] = r; + head = (head + 1) % maxrec; + if (total < maxrec) total++; + } + fclose(f); + if (total < maxrec) return total; /* 未满窗:recs[0..total-1] 即 oldest..newest */ + /* 满窗:oldest 在 recs[head],重排为 oldest..newest 连续 */ + notif_rec_t tmp[NOTIF_PREVIEW_MAX]; + for (int i = 0; i < maxrec; i++) tmp[i] = recs[(head + i) % maxrec]; + memcpy(recs, tmp, (size_t)maxrec * sizeof recs[0]); + return total; +} + +const char *notif_app_name(const char *app_id) +{ + if (!strcmp(app_id, "system")) return "系统"; + return app_id; +} + +void notif_emit_item(sbuf_t *out, const notif_rec_t *r) +{ + sA(out, "{\"id\":%u,\"app_id\":", r->id); + sA_str(out, r->app_id); + sA(out, ",\"app_name\":"); + sA_str(out, notif_app_name(r->app_id)); + sA(out, ",\"app_icon\":"); + sA_str(out, r->app_icon); + sA(out, ",\"category\":"); + sA_str(out, r->category); + sA(out, ",\"summary\":"); + sA_str(out, r->summary); + sA(out, ",\"body\":"); + sA_str(out, r->body); + sA(out, ",\"urgency\":%d,\"ts\":%lld,\"progress\":", r->urgency, r->ms > 0 ? r->ms / 1000 : 0); + if (r->progress >= 0) sA(out, "%d", r->progress); else sA(out, "null"); + sA(out, ",\"ongoing\":%s,\"media\":%s,\"target_activity\":", + r->ongoing ? "true" : "false", r->media ? "true" : "false"); + sA_str(out, r->target); + sA(out, ",\"dismissed\":%s}", r->dismissed ? "true" : "false"); +} + +int notif_ipc_query(notif_rec_t *recs, int maxrec) +{ + int fd = ipc_connect_tmo(g_sock_launcher, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + int cnt = -1; + if (ipc_hello(fd) < 0) goto done; + if (send_frame(fd, MSG_NOTIFY_QUERY, 8, NULL, 0) < 0) goto done; + uint8_t buf[MEOW_IPC_MAX + MEOW_IPC_HDR_LEN]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_notify_query_result_t)) goto done; + if (type != MSG_NOTIFY_QUERY_RESULT) goto done; + m_notify_query_result_t qr; + memcpy(&qr, buf, sizeof qr); + if (qr.code != MEOW_OK) goto done; + uint16_t tl = qr.text_len; + if (tl >= sizeof qr.text) tl = (uint16_t)(sizeof qr.text - 1); + if (tl == 0) { cnt = 0; goto done; } /* 无活跃通知 */ + jv_t *arr = jv_parse(qr.text, tl); + if (!arr) goto done; + if (arr->type != J_ARR) { jv_free(arr); goto done; } + int c = 0; + for (jv_t *e = arr->child; e && c < maxrec; e = e->next) { + if (e->type != J_OBJ) continue; + notif_rec_t r; memset(&r, 0, sizeof r); + r.progress = -1; + r.urgency = 0; + jv_t *v; + if ((v = jv_obj_get(e, "id")) && v->type == J_NUM) r.id = (unsigned)v->inum; + if ((v = jv_obj_get(e, "app_id")) && v->type == J_STR) snprintf(r.app_id, sizeof r.app_id, "%s", v->str); + if ((v = jv_obj_get(e, "app_icon")) && v->type == J_STR) snprintf(r.app_icon, sizeof r.app_icon, "%s", v->str); + if ((v = jv_obj_get(e, "category")) && v->type == J_STR) snprintf(r.category, sizeof r.category, "%s", v->str); + if ((v = jv_obj_get(e, "summary")) && v->type == J_STR) snprintf(r.summary, sizeof r.summary, "%s", v->str); + if ((v = jv_obj_get(e, "body")) && v->type == J_STR) snprintf(r.body, sizeof r.body, "%s", v->str); + if ((v = jv_obj_get(e, "target_activity")) && v->type == J_STR) snprintf(r.target, sizeof r.target, "%s", v->str); + if ((v = jv_obj_get(e, "urgency")) && v->type == J_NUM) r.urgency = (int)v->inum; + /* ts 为 epoch 毫秒 → 存入 r.ms(emit 时 /1000 出秒,与 TF 同口径) */ + if ((v = jv_obj_get(e, "ts")) && v->type == J_NUM) r.ms = (long long)v->inum; + if ((v = jv_obj_get(e, "progress")) && v->type == J_NUM) r.progress = (int)v->inum; /* <0=无;json 无该键则 -1 */ + if ((v = jv_obj_get(e, "ongoing")) && v->type == J_NUM) r.ongoing = (int)v->inum; + if ((v = jv_obj_get(e, "media")) && v->type == J_NUM) r.media = (int)v->inum; + recs[c++] = r; + } + jv_free(arr); + /* 按 ms 升序(oldest..newest),与 TF 路径一致(emit 时倒序遍历= newest first)。 */ + for (int i = 1; i < c; i++) { notif_rec_t key = recs[i]; int j = i - 1; + while (j >= 0 && recs[j].ms > key.ms) { recs[j + 1] = recs[j]; j--; } recs[j + 1] = key; } + cnt = c; +done: + close(fd); + return cnt; +} + +int notif_ipc_dismiss(unsigned id) +{ + int fd = ipc_connect_tmo(g_sock_launcher, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + int rc = -1; + if (ipc_hello(fd) < 0) goto done; + uint32_t nid = (uint32_t)id; + if (send_frame(fd, MSG_NOTIFY_DISMISS, 9, &nid, sizeof nid) < 0) goto done; + uint8_t buf[64]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_ack_t)) goto done; + if (type != MSG_ACK) goto done; + m_ack_t a; memcpy(&a, buf, sizeof a); + rc = (a.code == MEOW_OK) ? 0 : -1; +done: + close(fd); + return rc; +} + +int notif_ipc_dismiss_all(int *out_count) +{ + int fd = ipc_connect_tmo(g_sock_launcher, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + int rc = -1; + if (ipc_hello(fd) < 0) goto done; + if (send_frame(fd, MSG_NOTIFY_DISMISS_ALL, 10, NULL, 0) < 0) goto done; + uint8_t buf[64]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_ack_t)) goto done; + if (type != MSG_ACK) goto done; + m_ack_t a; memcpy(&a, buf, sizeof a); + if (a.code == MEOW_OK) { if (out_count) *out_count = (int)a.status; rc = 0; } +done: + close(fd); + return rc; +} + +int api_notifications(int sock) +{ + notif_rec_t recs[NOTIF_PREVIEW_MAX]; + const char *source = "ipc"; + int n = notif_ipc_query(recs, NOTIF_PREVIEW_MAX); + if (n < 0) { /* IPC 不可达 → 回退 TF 归档 */ + n = notif_load_list(recs, NOTIF_PREVIEW_MAX); + source = (n < 0) ? "none" : "tf"; + if (n < 0) n = 0; + } + sbuf_t arr = {0}; + int first = 1; + for (int i = n - 1; i >= 0; i--) { /* newest first */ + if (!first) sA(&arr, ","); + first = 0; + notif_emit_item(&arr, &recs[i]); + } + sbuf_t s = {0}; + sA(&s, "{\"count\":%d,\"source\":\"%s\",\"items\":[", n, source); + if (arr.n) sA_append(&s, arr.b, arr.n); + sA(&s, "],\"notifications\":["); + if (arr.n) sA_append(&s, arr.b, arr.n); + sA(&s, "]}"); + sbuf_free(&arr); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int notif_rewrite(int mode, unsigned del_id) +{ + if (!g_notif_log || !g_notif_log[0]) return -1; + char tmppath[1400]; + snprintf(tmppath, sizeof tmppath, "%s.panel.tmp", g_notif_log); + FILE *in = fopen(g_notif_log, "r"); + if (!in) return 0; + FILE *out = fopen(tmppath, "w"); + if (!out) { fclose(in); return -1; } + int removed = 0; + char line[1400]; + while (fgets(line, sizeof line, in)) { + notif_rec_t r; + int parsed = (notif_parse_line(line, &r) == 0); + int drop = 0; + if (mode == 1) { + if (!parsed) drop = 1; + else drop = !(r.ongoing || r.media || r.progress >= 0 || !strcmp(r.category, "media")); + } else { + if (parsed && r.id == del_id) drop = 1; + } + if (drop) removed++; + else fputs(line, out); + } + fclose(in); + if (fclose(out) != 0) return -1; + if (rename(tmppath, g_notif_log) < 0) { unlink(tmppath); return -1; } + return removed; +} + +int api_notification_delete(int sock, unsigned id) +{ + /* 优先 IPC 联动 launcher 内存(launcher 撤下时自动归档留档);IPC 不可达/未受理回退 TF 重写。 */ + if (notif_ipc_dismiss(id) == 0) { + char d[64]; + snprintf(d, sizeof d, "{\"id\":%u,\"dismissed\":true}", id); + return send_json(sock, 200, d); + } + int removed = notif_rewrite(0, id); + if (removed < 0) return send_err(sock, 500, "INTERNAL", "写通知存储失败"); + if (removed == 0) return send_err(sock, 404, "NOT_FOUND", "通知不存在"); + char d[64]; + snprintf(d, sizeof d, "{\"id\":%u,\"dismissed\":true}", id); + return send_json(sock, 200, d); +} + +int api_notification_dismiss_all(int sock) +{ + int cleared = 0; + if (notif_ipc_dismiss_all(&cleared) == 0) { /* 联动 launcher 内存,返回清除数 */ + char d[64]; + snprintf(d, sizeof d, "{\"dismissed\":%d}", cleared); + return send_json(sock, 200, d); + } + int removed = notif_rewrite(1, 0); /* IPC 不可达 → 回退重写 TF 日志 */ + if (removed < 0) return send_err(sock, 500, "INTERNAL", "写通知存储失败"); + char d[64]; + snprintf(d, sizeof d, "{\"dismissed\":%d}", removed); + return send_json(sock, 200, d); +} diff --git a/patch_v2/meowpanel/src/mp_perf.c b/patch_v2/meowpanel/src/mp_perf.c new file mode 100644 index 0000000..8a58579 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_perf.c @@ -0,0 +1,192 @@ +/* mp_perf.c — 性能/系统域:/api/perf,/api/system,action,pin-unlock(经 meow_svc_perf IPC 聚合)。 */ +#include "mp.h" + + +int api_perf(int sock, const char *path) +{ + char json[4096]; + int rc = ipc_perf_query(perf_subset_for_path(path), json, sizeof json); + if (rc != MEOW_OK || !utf8_valid_n(json, strlen(json))) /* 上游坏字节 → 不返回,降级 */ + return send_err(sock, 500, "INTERNAL", "性能服务不可用(IPC 连接/应答失败)"); + return send_json(sock, 200, json); +} + +int perf_extract_network(const char *json, char *out, int cap) +{ + const char *p = json; + for (;;) { + p = strstr(p, "\"network\""); + if (!p) return -1; + const char *q = p + 9; /* strlen("\"network\"") */ + while (*q == ' ' || *q == '\t') q++; + if (*q == ':') { p = q + 1; break; } /* 顶层 network 键 */ + p += 9; /* 嵌套同名键跳过 */ + } + while (*p == ' ' || *p == '\t') p++; + if (*p != '{') return -1; + int depth = 0, in_str = 0, esc = 0; + const char *q; + for (q = p; *q; q++) { + char ch = *q; + if (in_str) { + if (esc) esc = 0; + else if (ch == '\\') esc = 1; + else if (ch == '"') in_str = 0; + continue; + } + if (ch == '"') { in_str = 1; continue; } + if (ch == '{') depth++; + else if (ch == '}') { depth--; if (depth == 0) { q++; break; } } + } + if (depth != 0) return -1; + size_t len = (size_t)(q - p); + if (len == 0) return -1; + if (len >= (size_t)cap) len = (size_t)cap - 1; + memcpy(out, p, len); out[len] = 0; + return 0; +} + +int api_system(int sock) +{ + char json[4096]; + int rc = ipc_perf_query(0, json, sizeof json); + if (rc != MEOW_OK) return send_err(sock, 500, "INTERNAL", "性能服务不可用(IPC 连接/应答失败)"); + jv_t *root = jv_parse(json, (int)strlen(json)); + if (!root) return send_err(sock, 500, "INTERNAL", "性能服务 JSON 解析失败"); + jv_t *jsys = jv_obj_get(root, "system"); + jv_t *jupt = jv_obj_get(root, "uptime_sec"); + jv_t *jst = jv_obj_get(root, "storage"); + jv_t *jtf = jv_obj_get(root, "tfcard"); + const char *ver = "-", *host = "-", *ker = "-", *arch = "-"; + if (jsys && jsys->type == J_OBJ) { + jv_t *v = jv_obj_get(jsys, "version"); if (v && v->type == J_STR) ver = v->str; + v = jv_obj_get(jsys, "hostname"); if (v && v->type == J_STR) host = v->str; + v = jv_obj_get(jsys, "kernel"); if (v && v->type == J_STR) ker = v->str; + v = jv_obj_get(jsys, "arch"); if (v && v->type == J_STR) arch = v->str; + } + long long upt = jupt ? (long long)jupt->dnum : 0; + /* boot default:读权威 /data/meow_boot_default(0=meow 默认, 1=hiby),失败回退 meow */ + int bdf = -1; + { + char b[16]; + FILE *f = fopen(BOOT_DEFAULT_FILE, "r"); + if (f) { if (fgets(b, sizeof b, f)) bdf = atoi(b); fclose(f); } + } + const char *bootname = bdf < 0 ? "meow" : (bdf ? "hiby" : "meow"); + /* 当前运行模式:读 /tmp/meow_boot_mode(meow/hiby),缺失回退 default(PANEL_ENHANCE_PLAN 批1 前端读 boot.mode) */ + char modebuf[16] = {0}; + { + FILE *f = fopen("/tmp/meow_boot_mode", "r"); + if (f) { + if (fgets(modebuf, sizeof modebuf, f)) { + char *p = modebuf; + while (*p) { if (*p == '\n' || *p == '\r' || *p == ' ') { *p = 0; break; } p++; } + } + fclose(f); + } + } + const char *bootmode = (modebuf[0] == 'm' || modebuf[0] == 'h') ? modebuf : bootname; + char host_local[80]; + snprintf(host_local, sizeof host_local, "%s.local", host); + sbuf_t s = {0}; + sA(&s, "{\"version\":"); + sA_str(&s, ver); + sA(&s, ",\"hostname\":"); + sA_str(&s, host); + sA(&s, ",\"kernel\":"); + sA_str(&s, ker); + sA(&s, ",\"arch\":"); + sA_str(&s, arch); + sA(&s, ",\"uptime_sec\":%lld,\"boot\":{\"default\":", upt); + sA_str(&s, bootname); + sA(&s, ",\"mode\":"); + sA_str(&s, bootmode); + sA(&s, "},\"network\":"); + char netbuf[1024]; + /* 透传 perf 权威 network 段(wifi/bluetooth/hostname_local);含非法 UTF-8(如坏 ssid)→ 回退,不嵌坏字节 */ + if (perf_extract_network(json, netbuf, sizeof netbuf) == 0 && utf8_valid_n(netbuf, strlen(netbuf))) { + sA(&s, "%s", netbuf); + } else { + /* 回退:perf 未含 network 段 → 仅 hostname_local(保持原语义,不崩) */ + sA(&s, "{\"hostname_local\":"); + sA_str(&s, host_local); + sA(&s, "}"); + } + sA(&s, ",\"storage\":{"); + if (jst && jst->type == J_OBJ) { + jv_t *t = jv_obj_get(jst, "total"); long long tot = t ? (long long)t->dnum : 0; + jv_t *u = jv_obj_get(jst, "used"); long long usd = u ? (long long)u->dnum : 0; + jv_t *a = jv_obj_get(jst, "avail"); long long ava = a ? (long long)a->dnum : 0; + sA(&s, "\"root\":{\"total\":%lld,\"used\":%lld,\"avail\":%lld}", tot, usd, ava); + } else { + sA(&s, "\"root\":{\"total\":0,\"used\":0,\"avail\":0}"); + } + sA(&s, ",\"tfcard\":{"); + if (jtf && jtf->type == J_OBJ) { + jv_t *pr = jv_obj_get(jtf, "present"); int present = pr && pr->bval; + long long tot = 0, usd = 0, ava = 0; + jv_t *t = jv_obj_get(jtf, "total"); if (t) tot = (long long)t->dnum; + jv_t *u = jv_obj_get(jtf, "used"); if (u) usd = (long long)u->dnum; + jv_t *a = jv_obj_get(jtf, "avail"); if (a) ava = (long long)a->dnum; + sA(&s, "\"present\":%s,\"total\":%lld,\"used\":%lld,\"avail\":%lld}", present ? "true" : "false", tot, usd, ava); + } else { + sA(&s, "\"present\":false,\"total\":0,\"used\":0,\"avail\":0}"); + } + sA(&s, "},\"ota\":{\"current\":"); + sA_str(&s, ver); + sA(&s, ",\"latest\":"); + sA_str(&s, ver); + sA(&s, ",\"available\":false,\"url\":null}"); + /* 重启原因 / 升级状态:svc_power 读级查询(POWER_REBOOT_REASON(8)/POWER_UPGRADE_STATUS(9),只读、 + * 任意调用者)。st.msg 分别为 "coldboot=reboot|shutdown|unknown" 与 "upgrade=yes|no"。 + * 前端批1-B 已留位(读 d.reboot_reason / d.upgrade_status),此处点亮。查询失败保持空串(不谎报)。 */ + char rreason[48] = "", ustatus[32] = ""; + { + m_power_status_t st; + memset(&st, 0, sizeof st); + if (ipc_power_action(POWER_REBOOT_REASON, 0, NULL, &st) == MEOW_OK) + svc_msg_kv(st.msg, "coldboot", rreason, sizeof rreason); + memset(&st, 0, sizeof st); + if (ipc_power_action(POWER_UPGRADE_STATUS, 0, NULL, &st) == MEOW_OK) + svc_msg_kv(st.msg, "upgrade", ustatus, sizeof ustatus); + } + sA(&s, ",\"reboot_reason\":"); + sA_str(&s, rreason); + sA(&s, ",\"upgrade_status\":"); + sA_str(&s, ustatus); + sA(&s, "}"); + jv_free(root); + int ret = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return ret; +} + +int api_pin_unlock_once(int sock) +{ + const char *path = "/tmp/meow_pin_unlock_once"; + FILE *f = fopen(path, "w"); + if (!f) return send_err(sock, 500, "INTERNAL", "无法写入一次性解锁标志"); + fputs("1\n", f); + fclose(f); + return send_json(sock, 200, "{\"unlock_once\":true}"); +} + +int api_system_action(int sock, const char *which, const req_t *r) +{ + (void)r; + uint32_t cmd; + if (!strcmp(which, "reboot")) cmd = POWER_REBOOT; + else if (!strcmp(which, "shutdown")) cmd = POWER_SHUTDOWN; + else if (!strcmp(which, "ota")) cmd = POWER_UPGRADE; + else return send_err(sock, 404, "NOT_FOUND", "未知系统动作"); + m_power_status_t st; + memset(&st, 0, sizeof st); + int rc = ipc_power_action(cmd, 0, NULL, &st); + if (rc == MEOW_OK) return send_json(sock, 200, "{\"accepted\":true}"); + /* 系统动作类服务端通常「先应答受理再执行」;此处若返回非 OK 则说明服务未受理 */ + const char *why = st.msg[0] ? st.msg : power_code_str(rc); + if (rc == MEOW_ERR_PERM) return send_err(sock, 403, "FORBIDDEN", "电源服务拒绝(非 root 调用)"); + if (rc == MEOW_ERR_NODEV) return send_err(sock, 503, "NO_CARD", "升级文件缺失或服务不可达"); + if (rc == MEOW_ERR_NOENT || rc == MEOW_ERR_IO) return send_err(sock, 503, "NO_CARD", why); + return send_err(sock, 500, "INTERNAL", why); +} diff --git a/patch_v2/meowpanel/src/mp_power.c b/patch_v2/meowpanel/src/mp_power.c new file mode 100644 index 0000000..c4b3c24 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_power.c @@ -0,0 +1,140 @@ +/* mp_power.c — 功耗管理域:meow_powermgr IPC(/api/power*,policy,window,events)。 */ +#include "mp.h" + + +int ipc_pm_query(uint32_t subset, char *out, int cap) +{ + int fd = ipc_connect_tmo(g_sock_pm, IPC_TIMEOUT_MS); + if (fd < 0) return MEOW_ERR_NODEV; + int rc = MEOW_ERR_INTERNAL; + if (ipc_hello(fd) < 0) { rc = MEOW_ERR_INTERNAL; goto done; } + m_pm_query_t q; + memset(&q, 0, sizeof q); + q.subset = subset; + if (send_frame(fd, MSG_PM_QUERY, 0x31, &q, sizeof q) < 0) { rc = MEOW_ERR_IO; goto done; } + uint8_t buf[MEOW_IPC_MAX]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < 12) { rc = MEOW_ERR_IO; goto done; } /* code(4)+subset(4)+json_len(2)+pad(2) */ + if (type != MSG_PM_RESULT) { rc = MEOW_ERR_INVAL; goto done; } + uint32_t c; memcpy(&c, buf, 4); + if (c != MEOW_OK) { rc = (int)c; goto done; } + /* powermgr 发**紧凑**负载:json 从偏移 12 起(json_len 在偏移 8);同 m_pm_result_t 布局,兼容全量/紧凑。 */ + uint16_t jl; memcpy(&jl, buf + 8, 2); + if (jl > MEOW_IPC_MAX - 12) jl = (uint16_t)(MEOW_IPC_MAX - 12); + if ((size_t)jl >= (size_t)cap) jl = (uint16_t)(cap - 1); + memcpy(out, buf + 12, jl); + out[jl] = 0; + rc = MEOW_OK; +done: + close(fd); + return rc; +} + +int api_power(int sock) +{ + char js[MEOW_IPC_MAX]; + int rc = ipc_pm_query(0, js, sizeof js); + if (rc != MEOW_OK || !utf8_valid_n(js, strlen(js))) /* 上游坏字节 → 不返回,降级 */ + return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务不可用"); + return send_json(sock, 200, js); +} + +int ipc_pm_send(uint16_t req_type, const void *pl, uint32_t plen, + uint16_t *rtype, char *out, int cap) +{ + int fd = ipc_connect_tmo(g_sock_pm, IPC_TIMEOUT_MS); + if (fd < 0) return MEOW_ERR_NODEV; + int rc = MEOW_ERR_INTERNAL; + if (rtype) *rtype = 0; + if (ipc_hello(fd) < 0) { rc = MEOW_ERR_INTERNAL; goto done; } + if (send_frame(fd, req_type, 0x41, pl, plen) < 0) { rc = MEOW_ERR_IO; goto done; } + uint8_t buf[MEOW_IPC_MAX]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n <= 0) { rc = MEOW_ERR_IO; goto done; } + if (rtype) *rtype = type; + if (type == MSG_ERR) { + m_err_t e; memcpy(&e, buf, sizeof e); + rc = (int)e.code; goto done; + } + if (n < 12) { rc = MEOW_ERR_IO; goto done; } /* code(4)+subset(4)+json_len(2)+pad(2) */ + uint32_t c; memcpy(&c, buf, 4); + if (c != MEOW_OK) { rc = (int)c; goto done; } + uint16_t jl; memcpy(&jl, buf + 8, 2); + if (jl > MEOW_IPC_MAX - 12) jl = (uint16_t)(MEOW_IPC_MAX - 12); + if ((size_t)jl >= (size_t)cap) jl = (uint16_t)(cap - 1); + memcpy(out, buf + 12, jl); out[jl] = 0; + rc = (int)jl; +done: + close(fd); + return rc; +} + +int api_power_policy_get(int sock) +{ + char js[MEOW_IPC_MAX]; + uint16_t rt = 0; + int rc = ipc_pm_send(MSG_PM_POLICY_GET, NULL, 0, &rt, js, sizeof js); + if (rc <= 0) + return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务不可用(powermgr 未运行/IPC 不可达)"); + if (rt != MSG_PM_RESULT || !utf8_valid_n(js, strlen(js))) + return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务应答包含非法字节"); + return send_json(sock, 200, js); +} + +int api_power_policy_set(int sock, const char *body, size_t body_len) +{ + if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(body, (int)body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *ji = jv_obj_get(root, "item"), *jv = jv_obj_get(root, "value"); + char item[20] = "", val[40] = ""; + if (ji && ji->type == J_STR && ji->str) snprintf(item, sizeof item, "%s", ji->str); + if (jv && jv->type == J_STR && jv->str) snprintf(val, sizeof val, "%s", jv->str); + else if (jv && jv->type == J_NUM) snprintf(val, sizeof val, "%lld", jv->inum); + jv_free(root); + if (!item[0]) return send_err(sock, 422, "UNSUPPORTED", "缺 item(profile/low-pct/crit-pct/bri-cap/scr-cap)"); + if (!val[0]) return send_err(sock, 422, "UNSUPPORTED", "缺 value"); + + m_pm_policy_set_t s; memset(&s, 0, sizeof s); + snprintf(s.item, sizeof s.item, "%.15s", item); /* item[16] 留一个 NUL;防越界截断告警 */ + snprintf(s.val, sizeof s.val, "%.31s", val); /* val[32] 同理 */ + char js[MEOW_IPC_MAX]; + uint16_t rt = 0; + int rc = ipc_pm_send(MSG_PM_POLICY_SET, &s, sizeof s, &rt, js, sizeof js); + if (rc <= 0) { + if (rt == MSG_ERR) /* 权限不足:powermgr 拒绝(面板 root 仅当被白名单才放行;如实回传) */ + return send_err(sock, 403, "FORBIDDEN", rc == MEOW_ERR_PERM ? "设备身份不允许写电源策略" : "电源策略写入被拒绝"); + return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务不可用(powermgr 未运行/IPC 不可达)"); + } + if (rt != MSG_PM_RESULT || !utf8_valid_n(js, strlen(js))) + return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务应答包含非法字节"); + /* 服务端对非法值回 ok:false(HTTP 层 200,code 仍 OK);按其 why 映射 422。 */ + if (strstr(js, "\"ok\":false")) { + char why[96] = ""; + jv_t *jr = jv_parse(js, (int)strlen(js)); + if (jr) { jv_t *w = jv_obj_get(jr, "why"); if (w && w->type == J_STR && w->str) snprintf(why, sizeof why, "%s", w->str); jv_free(jr); } + return send_err(sock, 422, "UNSUPPORTED", why[0] ? why : "非法电源策略值"); + } + return send_json(sock, 200, js); +} + +int api_power_window(int sock, uint16_t req_type, uint16_t want_type, const char *unavail) +{ + uint32_t count = 0; + char js[MEOW_IPC_MAX]; + uint16_t rt = 0; + int rc = ipc_pm_send(req_type, &count, sizeof count, &rt, js, sizeof js); + if (rc <= 0) + return send_err(sock, 503, "POWER_UNAVAILABLE", unavail); + if (rt != want_type || !utf8_valid_n(js, strlen(js))) + return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务应答包含非法字节"); + return send_json(sock, 200, js); +} + +int api_power_history(int sock) +{ return api_power_window(sock, MSG_PM_HISTORY, MSG_PM_HISTORY_RESULT, "电源历史采样不可用(powermgr 未运行)"); } + +int api_power_events(int sock) +{ return api_power_window(sock, MSG_PM_EVENTS, MSG_PM_EVENTS_RESULT, "电源事件不可用(powermgr 未运行)"); } diff --git a/patch_v2/meowpanel/src/mp_screen.c b/patch_v2/meowpanel/src/mp_screen.c new file mode 100644 index 0000000..305dcfb --- /dev/null +++ b/patch_v2/meowpanel/src/mp_screen.c @@ -0,0 +1,277 @@ +/* mp_screen.c — 实时画面域:/api/screen.jpeg(fb0→JPEG,增量帧)+ 触摸注入 /api/input/tap。 */ +#include "mp.h" + +static uint8_t *g_screen_prev = NULL; /* 上一帧紧凑 RGB565 */ +static int g_screen_pw = 0, g_screen_ph = 0; +static int64_t g_last_self_inject_ms = 0; +#define TOUCH_ACTIVE_MS 5000 + + +int screen_read_frame(const char *dev, uint8_t **out, int *w, int *h) +{ + int fd = open(dev, O_RDONLY); + if (fd < 0) return -1; + unsigned char vinfo[160], finfo[68]; + memset(vinfo, 0, sizeof vinfo); + memset(finfo, 0, sizeof finfo); + if (ioctl(fd, FBIOGET_VSCREENINFO, vinfo) < 0 || + ioctl(fd, FBIOGET_FSCREENINFO, finfo) < 0) { close(fd); return -1; } + uint32_t xres = *(uint32_t *)(vinfo + 0); + uint32_t yres = *(uint32_t *)(vinfo + 4); + uint32_t yoff = *(uint32_t *)(vinfo + 20); + uint32_t bpp = *(uint32_t *)(vinfo + 24); + uint32_t line = *(uint32_t *)(finfo + 44); + if (!line) line = xres * (bpp / 8); + if (bpp != 16 || xres == 0 || yres == 0 || line < xres * 2) { close(fd); return -1; } + size_t framelen = (size_t)line * (size_t)yres; + uint8_t *frame = (uint8_t *)malloc(framelen); + if (!frame) { close(fd); return -1; } + ssize_t got = pread(fd, frame, framelen, (off_t)line * (off_t)yoff); + close(fd); + if (got != (ssize_t)framelen) { free(frame); return -1; } + /* 若 line != xres*2(行尾填充),压缩为紧凑 RGB565(xres*yres*2) */ + if (line != xres * 2) { + size_t cplen = (size_t)xres * 2; + for (uint32_t y = 0; y < yres; y++) + memmove(frame + (size_t)y * cplen, frame + (size_t)y * line, cplen); + } + *out = frame; *w = (int)xres; *h = (int)yres; + return 0; +} + +int fb_dims(const char *dev, int *w, int *h) +{ + int fd = open(dev, O_RDONLY); + if (fd < 0) return -1; + unsigned char vinfo[160]; + memset(vinfo, 0, sizeof vinfo); + if (ioctl(fd, FBIOGET_VSCREENINFO, vinfo) < 0) { close(fd); return -1; } + uint32_t xres = *(uint32_t *)(vinfo + 0); + uint32_t yres = *(uint32_t *)(vinfo + 4); + close(fd); + if (!xres || !yres) return -1; + *w = (int)xres; *h = (int)yres; + return 0; +} + +void screen_prev_store(const uint8_t *frame, int w, int h) +{ + size_t need = (size_t)w * (size_t)h * 2; + if (w != g_screen_pw || h != g_screen_ph) { + free(g_screen_prev); + g_screen_prev = NULL; + g_screen_pw = w; g_screen_ph = h; + g_screen_prev = (uint8_t *)malloc(need); + } + if (g_screen_prev) memcpy(g_screen_prev, frame, need); +} + +int screen_diff_blocks(const uint8_t *cur, const uint8_t *prev, int w, int h, + int *bx, int *by, int *bw, int *bh, int *total_blocks) +{ + const int B = SCREEN_BLOCK; + int nbx = (w + B - 1) / B, nby = (h + B - 1) / B; + if (total_blocks) *total_blocks = nbx * nby; + int changed = 0; + int minx = nbx, miny = nby, maxx = -1, maxy = -1; + for (int byi = 0; byi < nby; byi++) { + for (int bxi = 0; bxi < nbx; bxi++) { + int x0 = bxi * B, y0 = byi * B; + int wdt = B, hgt = B; + if (x0 + wdt > w) wdt = w - x0; + if (y0 + hgt > h) hgt = h - y0; + int diff = 0; + for (int y = 0; y < hgt && !diff; y++) { + const uint8_t *a = cur + ((size_t)(y0 + y) * (size_t)w + (size_t)x0) * 2; + const uint8_t *b = prev + ((size_t)(y0 + y) * (size_t)w + (size_t)x0) * 2; + if (memcmp(a, b, (size_t)wdt * 2) != 0) diff = 1; + } + if (diff) { + changed++; + if (bxi < minx) minx = bxi; + if (byi < miny) miny = byi; + if (bxi > maxx) maxx = bxi; + if (byi > maxy) maxy = byi; + } + } + } + if (changed) { *bx = minx; *by = miny; *bw = maxx - minx + 1; *bh = maxy - miny + 1; } + return changed; +} + +int api_screen_jpeg(int sock, const req_t *r) +{ + int q = 70; /* 默认质量(体积/清晰折中) */ + char qv[8]; + if (qparam(r->query, "q", qv, sizeof qv)) { + int t = atoi(qv); + if (t >= 1 && t <= 100) q = t; + } + char dv[8]; + int delta = (qparam(r->query, "delta", dv, sizeof dv) && atoi(dv) == 1); + + const char *dev = getenv("MEOW_FB_DEV"); if (!dev || !dev[0]) dev = FB_DEV; + uint8_t *frame = NULL; int w = 0, h = 0; + if (screen_read_frame(dev, &frame, &w, &h) != 0) + return send_err(sock, 500, "INTERNAL", "读取帧缓冲失败(/dev/fb0)"); + + if (!delta) { /* 默认整帧(不破坏原行为) */ + uint8_t *jpeg = NULL; size_t jlen = 0; + int rc = mp_jpeg_encode_rgb565(frame, w, h, q, &jpeg, &jlen); + if (rc != 0) { free(frame); return send_err(sock, 500, "INTERNAL", "JPEG 编码失败"); } + screen_prev_store(frame, w, h); /* 维护 delta 基线 */ + int ret = send_raw(sock, 200, "image/jpeg", jpeg, jlen); + free(jpeg); + free(frame); + return ret; + } + + /* ---- delta 模式 ---- */ + const int B = SCREEN_BLOCK; + int changed = 0, bx = 0, by = 0, bw = 0, bh = 0, total = 0; + int force_full = (!g_screen_prev || g_screen_pw != w || g_screen_ph != h); + if (!force_full) { + changed = screen_diff_blocks(frame, g_screen_prev, w, h, &bx, &by, &bw, &bh, &total); + if (bw * B >= w && bh * B >= h) force_full = 1; /* 全屏变化 → 直接发整帧 */ + } else { + total = ((w + B - 1) / B) * ((h + B - 1) / B); + } + screen_prev_store(frame, w, h); /* 基线更新为当前帧 */ + + char hdr[224]; + int ret = -1; + if (changed == 0 && !force_full) { /* 无变化:空 body,前端跳过叠加 */ + snprintf(hdr, sizeof hdr, "X-Meow-Delta: 1\r\nX-Meow-Full: 0\r\nX-Meow-W: %d\r\nX-Meow-H: %d\r\n" + "X-Meow-Changed: 0\r\nX-Meow-Blocks: %d", w, h, total); + ret = send_head(sock, 200, "image/jpeg", 0, hdr); + free(frame); + return ret; + } + if (force_full) { /* 冷藏起/几何变化/全屏变化 → 整帧 */ + uint8_t *jpeg = NULL; size_t jlen = 0; + int rc = mp_jpeg_encode_rgb565(frame, w, h, q, &jpeg, &jlen); + if (rc != 0) { free(frame); return send_err(sock, 500, "INTERNAL", "JPEG 编码失败"); } + snprintf(hdr, sizeof hdr, "X-Meow-Delta: 1\r\nX-Meow-Full: 1\r\nX-Meow-W: %d\r\nX-Meow-H: %d\r\n" + "X-Meow-Changed: %d\r\nX-Meow-Blocks: %d", w, h, changed ? changed : total, total); + ret = send_head(sock, 200, "image/jpeg", (long long)jlen, hdr); + if (ret == 0 && jlen && write_full(sock, jpeg, jlen) < 0) ret = -1; + free(jpeg); + free(frame); + return ret; + } + /* 子图 = 变化块 bbox 外接矩形(8 对齐,钳到屏内),重编码为独立 JPEG 并以响应头回传偏移。 */ + int x0 = bx * B, y0 = by * B; + int sw = bw * B, sh = bh * B; + if (x0 + sw > w) sw = w - x0; + if (y0 + sh > h) sh = h - y0; + uint8_t *sub = (uint8_t *)malloc((size_t)sw * (size_t)sh * 2); + if (!sub) { free(frame); return send_err(sock, 500, "INTERNAL", "内存不足"); } + for (int y = 0; y < sh; y++) + memcpy(sub + (size_t)y * (size_t)sw * 2, + frame + ((size_t)(y0 + y) * (size_t)w + (size_t)x0) * 2, + (size_t)sw * 2); + uint8_t *jpeg = NULL; size_t jlen = 0; + int rc = mp_jpeg_encode_rgb565(sub, sw, sh, q, &jpeg, &jlen); + free(sub); + if (rc != 0) { free(frame); return send_err(sock, 500, "INTERNAL", "JPEG 编码失败"); } + snprintf(hdr, sizeof hdr, "X-Meow-Delta: 1\r\nX-Meow-Full: 0\r\nX-Meow-W: %d\r\nX-Meow-H: %d\r\n" + "X-Meow-Sub-X: %d\r\nX-Meow-Sub-Y: %d\r\nX-Meow-Sub-W: %d\r\nX-Meow-Sub-H: %d\r\n" + "X-Meow-Changed: %d\r\nX-Meow-Blocks: %d", w, h, x0, y0, sw, sh, changed, total); + ret = send_head(sock, 200, "image/jpeg", (long long)jlen, hdr); + if (ret == 0 && jlen && write_full(sock, jpeg, jlen) < 0) ret = -1; + free(jpeg); + free(frame); + return ret; +} + +int64_t touch_last_local_ms(const char *dev) +{ + int fd = open(dev, O_RDONLY | O_NONBLOCK); + if (fd < 0) return 0; + int64_t last = 0; + unsigned char ev[16]; + int64_t inject_deadline = g_last_self_inject_ms + 200; /* 注入事件时间 ≈ 注入时刻 */ + while (read(fd, ev, sizeof ev) == (ssize_t)sizeof ev) { + int64_t sec = (int64_t)(*(int32_t *)(ev + 0)); + int64_t usec = (int64_t)(*(int32_t *)(ev + 4)); + int64_t t = sec * 1000 + usec / 1000; + if (t <= inject_deadline) continue; /* 自注入,跳过 */ + if (t > last) last = t; + } + close(fd); + return last; +} + +int inject_tap(const char *dev, int x, int y) +{ + int fd = open(dev, O_WRONLY); + if (fd < 0) return -1; + struct input_event ev; +#define __WE(t_, c_, v_) do { memset(&ev, 0, sizeof ev); ev.type = (t_); ev.code = (c_); \ + ev.value = (v_); if (write(fd, &ev, sizeof ev) != (ssize_t)sizeof ev) { close(fd); return -1; } } while (0) + /* 事件顺序:先 BTN_TOUCH=1(按下边沿),后 ABS 坐标 —— launcher/meow_touch 在按下边沿 + * 会重置坐标 (-1),只在 down 之后到来的首个有效 ABS 才锁原点(goodix 时序免疫)。若先发 + * ABS 再发 BTN_TOUCH,原点永不锁定 → 抬手表被判为无效 tap(只高亮不启动)。 */ + __WE(EV_KEY, BTN_TOUCH, 1); + __WE(EV_ABS, ABS_MT_SLOT, 0); + __WE(EV_ABS, ABS_MT_TRACKING_ID, 7); + __WE(EV_ABS, ABS_MT_POSITION_X, x); + __WE(EV_ABS, ABS_MT_POSITION_Y, y); + __WE(EV_ABS, ABS_MT_TOUCH_MAJOR, 6); + __WE(EV_ABS, ABS_X, x); + __WE(EV_ABS, ABS_Y, y); + __WE(EV_SYN, SYN_REPORT, 0); + usleep(100000); /* 按住 100ms 再抬起,抬起到按下的边沿被 launcher 视为一次 tap */ + __WE(EV_KEY, BTN_TOUCH, 0); + __WE(EV_ABS, ABS_MT_TRACKING_ID, -1); + __WE(EV_SYN, SYN_REPORT, 0); +#undef __WE + close(fd); + return 0; +} + +int api_input_tap(int sock, const req_t *r) +{ + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *jx = jv_obj_get(root, "x"); + jv_t *jy = jv_obj_get(root, "y"); + if (!jx || jx->type != J_NUM || !jy || jy->type != J_NUM) { + jv_free(root); + return send_err(sock, 400, "BAD_REQUEST", "缺 x/y(数值)"); + } + int x = (int)jx->inum, y = (int)jy->inum; + jv_free(root); + + const char *dev = getenv("MEOW_TOUCH_DEV"); if (!dev || !dev[0]) dev = TOUCH_DEV; + /* 坐标 clamp 到屏幕范围(前端已按 480x720 映射,此处再兜底) */ + int sw = 480, sh = 720; + { int fx = 0, fy = 0; const char *fbdev = getenv("MEOW_FB_DEV"); if (!fbdev || !fbdev[0]) fbdev = FB_DEV; + if (fb_dims(fbdev, &fx, &fy) == 0) { sw = fx; sh = fy; } } + if (x < 0) x = 0; + if (x >= sw) x = sw - 1; + if (y < 0) y = 0; + if (y >= sh) y = sh - 1; + + /* 仲裁:本机最近触摸后 5s 内忽略注入 */ + int64_t now = now_mono_ms(); + int64_t last = touch_last_local_ms(dev); + if (last > 0 && (now - last) < TOUCH_ACTIVE_MS) { + sbuf_t s = {0}; + sA(&s, "{\"dispatched\":false,\"reason\":\"local_touch_active\",\"cooldown_ms\":%lld}", + (long long)(TOUCH_ACTIVE_MS - (now - last))); + int ret = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return ret; + } + + g_last_self_inject_ms = now_mono_ms(); + if (inject_tap(dev, x, y) < 0) + return send_err(sock, 500, "INTERNAL", "触摸注入失败(无法打开触摸设备)"); + sbuf_t s = {0}; + sA(&s, "{\"dispatched\":true,\"x\":%d,\"y\":%d}", x, y); + int ret = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return ret; +} diff --git a/patch_v2/meowpanel/src/mp_server.c b/patch_v2/meowpanel/src/mp_server.c new file mode 100644 index 0000000..322fe30 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_server.c @@ -0,0 +1,618 @@ +/* mp_server.c — HTTP 服务器/路由 + 静态托管 + main(单线程 + poll 串行)。 */ +#include "mp.h" + +static char g_webroot[512]; +static int g_port = DEFAULT_PORT; + + +void req_free(req_t *r) { free(r->body); r->body = NULL; r->body_len = 0; } + +int read_request(int sock, req_t *r) +{ + char *buf = malloc(MAX_HTTP_HEADER); + if (!buf) return -1; + size_t hn = 0; + int deadline_ms = 5000; + ptrdiff_t hend = -1; + for (;;) { + struct pollfd pf = { sock, POLLIN, 0 }; + int pr = poll(&pf, 1, deadline_ms); + if (pr <= 0) { free(buf); return -1; } + ssize_t rd = read(sock, buf + hn, MAX_HTTP_HEADER - 1 - hn); + if (rd <= 0) { free(buf); return -1; } + hn += (size_t)rd; + buf[hn] = 0; + hend = find_header_end(buf, hn); + if (hend >= 0) break; + if (hn >= MAX_HTTP_HEADER - 2) { free(buf); return -1; } + } + /* 头结束在 hend,body 起点在 hend+4 */ + char *hdr = buf; + char *line_end = strstr(hdr, "\r\n"); + if (!line_end) { free(buf); return -1; } + *line_end = 0; + char method[8], pathraw[512], proto[16]; + if (sscanf(hdr, "%7s %511s %15s", method, pathraw, proto) < 2) { free(buf); return -1; } + snprintf(r->method, sizeof r->method, "%s", method); + split_path_query(pathraw, r->path, sizeof r->path, r->query, sizeof r->query); + size_t clen = 0; + char *hp = line_end + 2; + while (hp < buf + hend && strncmp(hp, "\r\n", 2)) { + char *he = strstr(hp, "\r\n"); + if (!he || he > buf + hend) break; + *he = 0; + if (!strncasecmp(hp, "Content-Length:", 15)) clen = (size_t)strtoul(hp + 15, NULL, 10); + else if (!strncasecmp(hp, "Content-Type:", 13)) { + char *v = hp + 13; + while (*v == ' ') v++; + snprintf(r->content_type, sizeof r->content_type, "%s", v); + } + else if (!strncasecmp(hp, "Authorization:", 14)) { + char *v = hp + 14; + while (*v == ' ') v++; + snprintf(r->authorization, sizeof r->authorization, "%s", v); + } + else if (!strncasecmp(hp, "X-MeowToken:", 12)) { + char *v = hp + 12; + while (*v == ' ') v++; + snprintf(r->x_meow_token, sizeof r->x_meow_token, "%s", v); + } + else if (!strncasecmp(hp, "Cookie:", 7)) { + char *v = hp + 7; + while (*v == ' ') v++; + snprintf(r->cookie, sizeof r->cookie, "%s", v); + } + hp = he + 2; + } + char *body_start = buf + hend + 4; /* body 实际起点 */ + size_t buffered = (hn > (size_t)(body_start - buf)) ? (hn - (size_t)(body_start - buf)) : 0; + /* 上传(multipart)走更严上限:14MB 内存约束下不整读超 MAX_UPLOAD_BODY。超限 → -2(413)。 + * 字体库 tar.gz 端点例外:gbk/full 字库 png+idx 可能 10-20MB,放宽到 MAX_FONT_UPLOAD(32MB)。 */ + size_t cap = MAX_HTTP_BODY; + if (strncasecmp(r->content_type, "multipart/form-data", 19) == 0) + cap = (!strcmp(r->path, "/api/fonts/import")) ? MAX_FONT_UPLOAD : MAX_UPLOAD_BODY; + if (clen > cap) { free(buf); return -2; } + if (clen == 0) { free(buf); r->body = NULL; r->body_len = 0; return 0; } + char *body = malloc(clen + 1); + if (!body) { free(buf); return -1; } + size_t got = 0; + if (buffered > 0) { + size_t take = buffered < clen ? buffered : clen; + memcpy(body, body_start, take); + got = take; + } + free(buf); + while (got < clen) { + struct pollfd pf = { sock, POLLIN, 0 }; + int pr = poll(&pf, 1, 5000); + if (pr <= 0) { free(body); return -1; } + ssize_t rd = read(sock, body + got, clen - got); + if (rd <= 0) { free(body); return -1; } + got += (size_t)rd; + } + body[clen] = 0; + r->body = body; + r->body_len = clen; + return 0; +} + +int write_full(int sock, const void *b, size_t n) +{ + const char *p = (const char *)b; + size_t off = 0; + while (off < n) { + ssize_t w = write(sock, p + off, n - off); + if (w <= 0) return -1; + off += (size_t)w; + } + return 0; +} + +int send_head(int sock, int status, const char *ctype, long long clen, const char *extra) +{ + char h[512]; + const char *sts = "OK"; + switch (status) { + case 200: sts = "OK"; break; + case 201: sts = "Created"; break; + case 202: sts = "Accepted"; break; + case 400: sts = "Bad Request"; break; + case 401: sts = "Unauthorized"; break; + case 403: sts = "Forbidden"; break; + case 404: sts = "Not Found"; break; + case 405: sts = "Method Not Allowed"; break; + case 409: sts = "Conflict"; break; + case 413: sts = "Payload Too Large"; break; + case 415: sts = "Unsupported Media Type"; break; + case 422: sts = "Unprocessable Entity"; break; + case 500: sts = "Internal Server Error"; break; + case 501: sts = "Not Implemented"; break; + case 503: sts = "Service Unavailable"; break; + default: sts = "OK"; break; + } + snprintf(h, sizeof h, "HTTP/1.1 %d %s\r\nServer: MeowPanel/%s\r\nContent-Type: %s\r\nContent-Length: %lld\r\nConnection: close\r\n%s%s\r\n", + status, sts, PANEL_VERSION, ctype, clen, extra ? extra : "", extra ? "\r\n" : ""); + return write_full(sock, h, strlen(h)); +} + +int send_raw(int sock, int status, const char *ctype, const void *body, size_t bodylen) +{ + if (send_head(sock, status, ctype, (long long)bodylen, NULL) < 0) return -1; + if (bodylen && write_full(sock, body, bodylen) < 0) return -1; + return 0; +} + +int send_ok(int sock, const char *data) +{ + static const char prefix[] = "{\"ok\":true,\"data\":"; + size_t pl = sizeof prefix - 1; /* 18 */ + size_t dl = strlen(data); + size_t tot = pl + dl + 1; + char *b = malloc(tot + 1); + if (!b) { + const char *e = "{\"ok\":false,\"error\":{\"code\":\"INTERNAL\",\"message\":\"内存不足\"}}"; + return send_raw(sock, 500, "application/json; charset=utf-8", e, strlen(e)); + } + memcpy(b, prefix, pl); + memcpy(b + pl, data, dl); + b[pl + dl] = '}'; + b[pl + dl + 1] = 0; + int rc = send_raw(sock, 200, "application/json; charset=utf-8", b, tot); + free(b); + return rc; +} + +int send_err(int sock, int status, const char *code, const char *msg) +{ + sbuf_t s = {0}; + sA(&s, "{\"ok\":false,\"error\":{\"code\":"); + sA_str(&s, code ? code : "INTERNAL"); + sA(&s, ",\"message\":"); + sA_str(&s, msg ? msg : ""); + sA(&s, "}}"); + int rc = send_raw(sock, status, "application/json; charset=utf-8", s.b, s.n); + sbuf_free(&s); + return rc; +} + +int send_json(int sock, int status, const char *data) +{ + if (status == 200) return send_ok(sock, data); + return send_raw(sock, status, "application/json; charset=utf-8", data, strlen(data)); +} + +int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename) +{ + int fd = open(abspath, O_RDONLY); + if (fd < 0) return send_err(sock, 404, "NOT_FOUND", "文件不存在"); + struct stat st; + if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { close(fd); return send_err(sock, 404, "NOT_FOUND", "非普通文件"); } + char extra[256]; + if (filename) snprintf(extra, sizeof extra, "Content-Disposition: attachment; filename=\"%s\"", filename); + else extra[0] = 0; + if (send_head(sock, status, ctype, (long long)st.st_size, extra[0] ? extra : NULL) < 0) { close(fd); return -1; } + char buf[8192]; + ssize_t n; + while ((n = read(fd, buf, sizeof buf)) > 0) { + if (write_full(sock, buf, (size_t)n) < 0) { close(fd); return -1; } + } + close(fd); + return 0; +} + +const char *web_root(void) +{ + if (g_webroot[0]) return g_webroot; + char exe[512]; + ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1); + if (n <= 0) { snprintf(g_webroot, sizeof g_webroot, "."); return g_webroot; } + exe[n] = 0; + char *slash = strrchr(exe, '/'); + if (slash) *slash = 0; + snprintf(g_webroot, sizeof g_webroot, "%s", exe[0] ? exe : "."); + return g_webroot; +} + +const char *mime_for(const char *path) +{ + const char *ext = strrchr(path, '.'); + if (!ext) return "application/octet-stream"; + if (!strcasecmp(ext, ".html")) return "text/html; charset=utf-8"; + if (!strcasecmp(ext, ".css")) return "text/css; charset=utf-8"; + if (!strcasecmp(ext, ".js")) return "application/javascript; charset=utf-8"; + if (!strcasecmp(ext, ".json")) return "application/json; charset=utf-8"; + if (!strcasecmp(ext, ".png")) return "image/png"; + if (!strcasecmp(ext, ".ico")) return "image/x-icon"; + if (!strcasecmp(ext, ".svg")) return "image/svg+xml"; + return "application/octet-stream"; +} + +int serve_static(int sock, const char *path) +{ + const char *root = web_root(); + char rel[512]; + /* / → index.html;/assets/ → ;/ → (meowpanel.css/js 等) */ + if (!strcmp(path, "/")) snprintf(rel, sizeof rel, "index.html"); + else if (!strncmp(path, "/assets/", 8)) snprintf(rel, sizeof rel, "%s", path + 8); + else snprintf(rel, sizeof rel, "%s", path + 1); + if (strstr(rel, "..") || rel[0] == '/') return send_err(sock, 403, "FORBIDDEN", "路径逃逸"); + /* 只允许安全文件(防读到二进制/任意文件) */ + char full[1024]; + snprintf(full, sizeof full, "%s/%s", root, rel); + /* 敏感名拒绝 */ + const char *base = strrchr(full, '/'); + base = base ? base + 1 : full; + if (!strcmp(base, "meowpanel") || !strcmp(base, "meowpanel_host") + || !strcmp(base, "meowpanel.session") ) return send_err(sock, 403, "FORBIDDEN", "禁止访问可执行/会话文件"); + FILE *f = fopen(full, "rb"); + if (!f) return send_err(sock, 404, "NOT_FOUND", "资源不存在"); + fclose(f); + return send_file(sock, 200, mime_for(full), full, NULL); +} + +int handle(int sock, req_t *r) +{ + const char *p = r->path; + if (strncmp(p, "/api/", 5) != 0) { + if (strcmp(r->method, "GET") != 0 && strcmp(r->method, "HEAD") != 0) + return send_err(sock, 405, "METHOD_NOT_ALLOWED", "静态资源仅支持 GET"); + return serve_static(sock, p); + } + + /* ---- 可选鉴权门禁(启用后除 /api/auth 系列外全 /api/ 端点需会话) ---- */ + if (require_auth(sock, r) != 1) return 0; + + /* ---- 鉴权端点(登录/设置/登出/状态:公开——访问面板即需登录,须先经此建立会话) ---- */ + if (!strcmp(r->method, "POST") && !strcmp(p, "/api/auth/login")) return api_auth_login(sock, r); + if (!strcmp(r->method, "POST") && !strcmp(p, "/api/auth/setup")) return api_auth_setup(sock, r); + if (!strcmp(r->method, "POST") && !strcmp(p, "/api/auth/logout")) return api_auth_logout(sock, r); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/auth/status")) return api_auth_status(sock, r); + + /* ---- 性能/状态 ---- */ + if (!strcmp(r->method, "GET") && (!strcmp(p, "/api/status") || !strcmp(p, "/api/perf"))) + return api_perf(sock, p); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/system")) + return api_system(sock); + + /* ---- 字体库(导入/导出/列表) ---- */ + if (!strcmp(r->method, "POST") && !strcmp(p, "/api/fonts/import")) return api_fonts_import(sock, r); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts")) return api_fonts_list(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/fonts/export")) return api_fonts_export(sock, r); + + /* ---- 音频状态(meow_audiod 快照,只读;含 rms/peak/lvlseq 电平键;monitor=实时监控日志尾读) ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/audio")) return api_audio(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/audio/monitor")) return api_audio_monitor(sock, r->query); + + /* ---- 批1 只读增强:功耗/进程服务/首页聚合(均走 PIN 鉴权读级) ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/power")) return api_power(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/power/policy")) return api_power_policy_get(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/power/history")) return api_power_history(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/power/events")) return api_power_events(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/ams")) return api_ams(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/svcd")) return api_svcd(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/overview")) return api_overview(sock); + if (!strcmp(r->method, "GET")) { + char seg[256]; + if (path_segment("/api/ams/", p, seg, sizeof seg) && seg[0]) + return api_ams_history(sock, seg); /* GET /api/ams/:pkg → am history(崩溃根因) */ + } + + /* ---- 网络管理(meow_netmgrd 期2/期3 透传:读级任意调用者;flush/policy 写级走 require_auth) ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/status")) return api_net_status(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/ifaces")) return api_net_ifaces(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/policy")) return api_net_policy_get(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/conns")) return api_net_conns(sock, r->query); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/dns")) return api_net_dns(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/log")) return api_net_log(sock); + + /* ---- 实时画面(阶段 C) ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/screen.jpeg")) + return api_screen_jpeg(sock, r); + + /* ---- 系统动作 ---- */ + if (!strcmp(r->method, "POST")) { + if (!strcmp(p, "/api/system/reboot")) return api_system_action(sock, "reboot", r); + if (!strcmp(p, "/api/system/shutdown")) return api_system_action(sock, "shutdown", r); + if (!strcmp(p, "/api/system/ota")) return api_system_action(sock, "ota", r); + if (!strcmp(p, "/api/system/pin-unlock-once")) return api_pin_unlock_once(sock); + if (!strcmp(p, "/api/debug/report_bad_json")) { + /* 前端 NOT_JSON 自动回传浏览器收到的原始字节(抓间歇性破包) */ + FILE *df = fopen("/tmp/overview_client_bad.txt", "w"); + if (df && r->body && r->body_len) fwrite(r->body, 1, r->body_len, df); + if (df) fclose(df); + panel_log("debug: client bad json captured %zu bytes -> /tmp/overview_client_bad.txt", (size_t)r->body_len); + return send_json(sock, 200, "{\"captured\":true}"); + } + if (!strcmp(p, "/api/settings")) return api_settings_batch(sock, r); + if (!strcmp(p, "/api/settings/restore")) return api_settings_restore(sock); + if (!strcmp(p, "/api/theme")) return api_theme_put(sock, r); + if (!strcmp(p, "/api/files/upload")) return api_files_upload(sock, r); + if (!strcmp(p, "/api/files/mkdir")) return api_files_mkdir(sock, r); + if (!strcmp(p, "/api/files/rename")) return api_files_rename(sock, r); + if (!strcmp(p, "/api/apps/install")) return api_apps_install(sock, r); + if (!strcmp(p, "/api/notifications/dismiss_all") || !strcmp(p, "/api/notifications/clear")) return api_notification_dismiss_all(sock); + if (!strcmp(p, "/api/net/dns/flush")) return api_net_dns_flush(sock); + if (!strcmp(p, "/api/net/policy")) return api_net_policy_set(sock, r->body, r->body_len); + if (!strcmp(p, "/api/power/policy")) return api_power_policy_set(sock, r->body, r->body_len); + if (!strcmp(p, "/api/audio/route")) return api_audio_route(sock, r->body, r->body_len); + if (!strcmp(p, "/api/audio/volume")) return api_audio_volume(sock, r->body, r->body_len); + if (!strcmp(p, "/api/key")) return api_key_batch(sock, r); + if (!strcmp(p, "/api/input/tap")) return api_input_tap(sock, r); + char seg[256]; + if (path_segment("/api/key/", p, seg, sizeof seg)) + return api_key_single(sock, atoi(seg), r); + /* POST /api/ams//start|kill(管启/停;require_auth 已护,kill 前台前端二次确认) */ + if (!strncmp(p, "/api/ams/", 9)) { + size_t pl = strlen(p); + if (pl > 5 && !strcmp(p + pl - 6, "/start")) { + char pkg[256]; + if (path_segment("/api/ams/", p, pkg, sizeof pkg)) + return api_ams_ctl(sock, pkg, 0); + } else if (pl > 4 && !strcmp(p + pl - 5, "/kill")) { + char pkg[256]; + if (path_segment("/api/ams/", p, pkg, sizeof pkg)) + return api_ams_ctl(sock, pkg, 1); + } + } + } + + /* ---- 设置 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/settings")) return api_settings_get_all(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/theme")) return api_theme_get(sock); + if (!strcmp(r->method, "PUT")) { + char seg[256]; + if (path_segment("/api/settings/", p, seg, sizeof seg)) + return api_settings_put(sock, seg, r); + } + if (!strcmp(r->method, "GET")) { + char seg[256]; + if (path_segment("/api/settings/", p, seg, sizeof seg)) + return api_settings_get_any(sock, seg); + } + + /* ---- 文件 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files")) return api_files_list(sock, r); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/read")) return api_files_read(sock, r); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/download")) return api_files_download(sock, r); + if (!strcmp(r->method, "DELETE") && !strcmp(p, "/api/files")) return api_files_delete(sock, r); + + /* ---- 应用 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/apps")) return api_apps_list(sock); + if (!strcmp(r->method, "GET")) { + char seg[256]; + if (path_segment("/api/apps/", p, seg, sizeof seg)) { + /* :pkg/export 结尾(path_segment 已把 seg 截为 pkg,这里检查原 path 尾缀) */ + size_t pl = strlen(p); + int is_export = (pl >= 7 && !strcmp(p + pl - 7, "/export")); + if (is_export) return api_apps_export(sock, seg); + return api_apps_info(sock, seg); + } + } + if (!strcmp(r->method, "DELETE")) { + char seg[256]; + if (path_segment("/api/apps/", p, seg, sizeof seg)) + return api_apps_remove(sock, seg, r); + } + + /* ---- 日志 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs")) return api_logs_list(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs/crash")) return api_logs_crash(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs/read")) return api_logs_read(sock, r); + + /* ---- 通知 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/notifications")) return api_notifications(sock); + if (!strcmp(r->method, "DELETE")) { + char seg[256]; + if (path_segment("/api/notifications/", p, seg, sizeof seg)) { + unsigned nid = (unsigned)strtoul(seg, NULL, 10); + return api_notification_delete(sock, nid); + } + } + + return send_err(sock, 404, "NOT_FOUND", "端点不存在"); +} + +int read_port_config(void) +{ + /* 配置文件 meowpanel.conf(web 根目录),格式 key=value,取 port */ + char path[1024]; + snprintf(path, sizeof path, "%s/meowpanel.conf", web_root()); + FILE *f = fopen(path, "r"); + if (f) { + char line[160]; + while (fgets(line, sizeof line, f)) { + char *eq = strchr(line, '='); + if (!eq) continue; + char *k = line; while (*k == ' ' || *k == '\t') k++; + if (!strncmp(k, "port", 4) && k[4] == '=') g_port = atoi(eq + 1); + } + fclose(f); + } + const char *env = getenv("MEOWPORT"); + if (env && env[0]) g_port = atoi(env); + if (g_port <= 0 || g_port > 65535) g_port = DEFAULT_PORT; + return g_port; +} + +void handle_conn(int sock) +{ + req_t r; + memset(&r, 0, sizeof r); + int rr = read_request(sock, &r); + if (rr != 0) { + if (rr == -2) send_err(sock, 413, "PAYLOAD_TOO_LARGE", "请求体过大(超过上传上限)"); + close(sock); + return; + } + handle(sock, &r); + req_free(&r); + close(sock); +} + +int panel_selftest(void) +{ + const char *trunc = "流媒体客户\xE7"; /* 事故里被腰斩的显示名(16B,末字节为「端」首字节) */ + const char *oknat = "流媒体客户端"; /* 完整合法名(18B,回归确认不误伤) */ + size_t n = strlen(trunc); + + /* 1a) utf8_sanitize:截断串必被改写,长度保持,产物合法 UTF-8 */ + char san[64]; + if (!utf8_sanitize(san, trunc, n)) { printf("SELFTEST FAIL: utf8_sanitize 未改写截断串\n"); return 1; } + if (strlen(san) != n) { printf("SELFTEST FAIL: utf8_sanitize 长度变化 %zu->%zu\n", n, strlen(san)); return 1; } + if (!utf8_valid_n(san, strlen(san))) { printf("SELFTEST FAIL: utf8_sanitize 产物含非法字节\n"); return 1; } + + /* 1b) 完整合法名不得被改写(误伤检测) */ + char san2[64]; + if (utf8_sanitize(san2, oknat, strlen(oknat)) != 0 || strcmp(san2, oknat) != 0) { + printf("SELFTEST FAIL: utf8_sanitize 误改合法 UTF-8\n"); return 1; + } + + /* 2) sA_str 嵌入:产物合法 UTF-8 且 jv_parse 完整解析 */ + sbuf_t s = {0}; + sA(&s, "{\"pkg\":"); + sA_str(&s, "com.example.trunc"); + sA(&s, ",\"name\":"); + sA_str(&s, trunc); + sA(&s, ",\"state\":"); + sA_str(&s, "running"); + sA(&s, "}"); + if (!s.b) { printf("SELFTEST FAIL: sA_str 输出空\n"); return 1; } + if (!utf8_valid_n(s.b, s.n)) { printf("SELFTEST FAIL: sA_str 产物含非法字节\n"); sbuf_free(&s); return 1; } + jv_t *r = jv_parse(s.b, (int)s.n); + if (!r) { printf("SELFTEST FAIL: sA_str 产物 JSON 无法解析\n"); sbuf_free(&s); return 1; } + jv_t *nm = jv_obj_get(r, "name"); + if (!nm || nm->type != J_STR) { printf("SELFTEST FAIL: sA_str 产物缺 name\n"); jv_free(r); sbuf_free(&s); return 1; } + jv_free(r); + sbuf_free(&s); + + /* 3) ams_fill 桩:模拟 APP 行 parse 出的截断名(join_tokens 后)→ apps 数组可解析 */ + sbuf_t a = {0}; + sA(&a, "{\"available\":true,\"count\":1,\"apps\":[{\"pkg\":"); + sA_str(&a, "com.example.trunc"); + sA(&a, ",\"name\":"); + sA_str(&a, trunc); + sA(&a, ",\"state\":"); + sA_str(&a, "running"); + sA(&a, ",\"pid\":123}]}"); + if (!a.b || !utf8_valid_n(a.b, a.n)) { printf("SELFTEST FAIL: ams_fill 桩产物含非法字节/为空\n"); sbuf_free(&a); return 1; } + jv_t *ar = jv_parse(a.b, (int)a.n); + if (!ar) { printf("SELFTEST FAIL: ams_fill 桩产物 JSON 无法解析\n"); sbuf_free(&a); return 1; } + jv_free(ar); + sbuf_free(&a); + + /* 4) net_policy_parse:喂含坏字节域名(截断「段」字)+ allow/deny 混合 → 产物合法 JSON、无非法字节。 + * 域名/实体名都走 sA_str_n 消毒(坏字节→'?'),故解析器产物必为合法 JSON。 */ + { + static const char ptext[] = + "# 策略(NETMGR_DESIGN §6)log_level=debug enforce=dns_only(SOCK_DESTROY 在本内核为 no-op)\n" + "app:com.example.trunc net=block domain=+good.example,-bad\xE7\n" + "svc:meowpanel net=allow domain=+svc.local\n"; + sbuf_t p = {0}; + net_policy_parse(ptext, &p); + if (!p.b || !utf8_valid_n(p.b, p.n)) { printf("SELFTEST FAIL: net_policy_parse 产物含非法字节/为空\n"); sbuf_free(&p); return 1; } + jv_t *pr = jv_parse(p.b, (int)p.n); + if (!pr) { printf("SELFTEST FAIL: net_policy_parse 产物 JSON 无法解析\n"); sbuf_free(&p); return 1; } + jv_t *rules = jv_obj_get(pr, "rules"); + if (!rules || rules->type != J_ARR || !rules->child) { printf("SELFTEST FAIL: net_policy_parse 缺 rules 数组\n"); jv_free(pr); sbuf_free(&p); return 1; } + jv_t *enf = jv_obj_get(pr, "enforce"); + if (!enf || enf->type != J_STR || !enf->str || strcmp(enf->str, "dns_only") != 0) { printf("SELFTEST FAIL: net_policy_parse enforce 未如实透传 dns_only\n"); jv_free(pr); sbuf_free(&p); return 1; } + jv_free(pr); + sbuf_free(&p); + } + + /* 5) ams_history_parse:喂 EVT 行 + 截断崩溃 detail(含坏字节)→ available:false(降级,绝不发非法 JSON)。 + * detail 合法时 → 合法 JSON 可解析。 */ + { + char bad[] = "# amd history com.example.trunc\nEVT 1786523400 123 crash com.example.trunc 崩溃在函数 foo\xE7\n"; + sbuf_t h = {0}; + ams_history_parse(bad, &h, "com.example.trunc"); + if (!h.b || strstr(h.b, "available\":false") == NULL) { printf("SELFTEST FAIL: ams_history_parse 截断 detail 未降级 available:false\n"); sbuf_free(&h); return 1; } + sbuf_free(&h); + + char good[] = "# amd history com.example.trunc\nEVT 1786523400 123 crash com.example.trunc SIGSEGV at addr 0x10\nEVT 1786523500 124 ams_start com.example.trunc ok forwarded\n"; + sbuf_t h2 = {0}; + ams_history_parse(good, &h2, "com.example.trunc"); + if (!h2.b || !utf8_valid_n(h2.b, h2.n) || !jv_parse(h2.b, (int)h2.n)) { printf("SELFTEST FAIL: ams_history_parse 合法 detail 无法解析/含非法字节\n"); sbuf_free(&h2); return 1; } + sbuf_free(&h2); + } + + printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 新增端点池 → 合法 JSON + 无非法字节\n"); + return 0; +} + +int main(int argc, char **argv) +{ + /* 参数:-p PORT(可选)。默认端口 8080。 */ + int port = DEFAULT_PORT; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--selftest")) return panel_selftest(); + if (!strcmp(argv[i], "-p") && i + 1 < argc) port = atoi(argv[++i]); + else if (!strcmp(argv[i], "--sock-perf") && i + 1 < argc) g_sock_perf = argv[++i]; + else if (!strcmp(argv[i], "--sock-power") && i + 1 < argc) g_sock_power = argv[++i]; + else if (!strcmp(argv[i], "--sock-launcher") && i + 1 < argc) g_sock_launcher = argv[++i]; + else if (!strcmp(argv[i], "--sock-audio") && i + 1 < argc) g_sock_audio = argv[++i]; + else if (!strcmp(argv[i], "--sock-net") && i + 1 < argc) g_sock_net = argv[++i]; + else if (!strcmp(argv[i], "--sock-pm") && i + 1 < argc) g_sock_pm = argv[++i]; + else if (!strcmp(argv[i], "--sock") && i + 1 < argc) { g_sock_perf = argv[++i]; g_sock_power = g_sock_perf; } + else if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) { + printf("MeowPanel backend %s\n", PANEL_VERSION); + printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P] [--sock-audio P] [--sock-net P] [--sock-pm P]\n"); + return 0; + } + } + init_tf_paths(); /* 先定卡根/派生路径(env 可覆盖,宿主测试用) */ + const char *env = getenv("MEOW_IPC_PERF_SOCK"); + if (env && env[0]) g_sock_perf = env; + env = getenv("MEOW_IPC_POWER_SOCK"); + if (env && env[0]) g_sock_power = env; + env = getenv("MEOW_IPC_LAUNCHER_SOCK"); + if (env && env[0]) g_sock_launcher = env; + env = getenv("MEOW_AUDIO_SOCK"); + if (env && env[0]) g_sock_audio = env; /* 与 audiod CLI 同 env(宿主/qemu 测试用) */ + env = getenv("MEOW_NET_SOCK"); + if (env && env[0]) g_sock_net = env; /* 与 netmgrd/meow_net 同 env(宿主/qemu 测试用) */ + env = getenv("MEOW_PM_SOCK"); + if (env && env[0]) g_sock_pm = env; /* 与 powermgr/meow_power 同 env(宿主/qemu 测试用) */ + env = getenv("MEOW_AUTH_TTL"); + if (env && env[0]) g_auth_ttl = (time_t)atol(env); /* 会话 TTL 覆盖(宿主测试用) */ + env = getenv("MEOW_SETTINGS_FILE"); + if (env && env[0]) g_settings_file = env; /* 设置文件覆盖(宿主测试用;设备默认 /usr/data/meow/settings.conf) */ + + (void)read_port_config(); /* 应用 meowpanel.conf 的 port(env/-p 优先) */ + /* 优先级:-p 已设则保留;否则用 conf/env(read_port_config 会写 g_port) */ + if (port != DEFAULT_PORT) g_port = port; + else g_port = read_port_config(); + /* 再按 env MEOWPORT 覆盖 conf(-p 最高) */ + if (port == DEFAULT_PORT) { + const char *ep = getenv("MEOWPORT"); + if (ep && ep[0]) g_port = atoi(ep); + } + if (g_port <= 0 || g_port > 65535) g_port = DEFAULT_PORT; + + signal(SIGPIPE, SIG_IGN); + web_root(); + panel_log("MeowPanel %s listening on port %d (web root %s)", PANEL_VERSION, g_port, web_root()); + + int lfd = socket(AF_INET, SOCK_STREAM, 0); + if (lfd < 0) { panel_log("socket fail: %s", strerror(errno)); return 1; } + int on = 1; + setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on); + struct sockaddr_in addr; + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons((uint16_t)g_port); + if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) < 0) { + panel_log("bind :%d fail: %s", g_port, strerror(errno)); + close(lfd); + return 1; + } + if (listen(lfd, 16) < 0) { panel_log("listen fail: %s", strerror(errno)); close(lfd); return 1; } + + for (;;) { + int c = accept(lfd, NULL, NULL); + if (c < 0) { if (errno == EINTR) continue; continue; } + handle_conn(c); + } + return 0; +} diff --git a/patch_v2/meowpanel/src/mp_store.c b/patch_v2/meowpanel/src/mp_store.c new file mode 100644 index 0000000..a69368c --- /dev/null +++ b/patch_v2/meowpanel/src/mp_store.c @@ -0,0 +1,596 @@ +/* mp_store.c — 设置镜像(settings.conf)+ 主题 + /api/settings*,/api/theme*。权威来源:meow_settings.c g_defs(逐键一致)。 */ +#include "mp.h" + +const char *g_settings_file = SETTINGS_FILE; +static const setdef_t g_sets[] = { + /* 显示 */ + { "display.brightness", ST_INT, "101", 1, 100, NULL, NULL, "显示" }, + { "display.brightness_min", ST_INT, "1", 0, 255, NULL, NULL, "显示" }, + { "display.screen_off_mode", ST_ENUM, "panel",0,0, "panel,black", "面板下电,背光全黑", "显示" }, + { "display.screen_off_timeout", ST_INT, "30", 0, 86400,NULL, NULL, "显示" }, + { "display.refresh_rate", ST_ENUM, "60", 0,0, "60,30,15", "60Hz,30Hz,15Hz", "显示" }, + { "display.refresh_always_max", ST_BOOL, "0", 0,0, NULL, NULL, "显示" }, + { "display.theme", ST_ENUM, "dark", 0,0, "dark,light", "深色,浅色", "显示" }, + { "display.theme_mode", ST_ENUM, "manual",0,0, "manual,auto", "手动,时段自动", "显示" }, + { "display.theme_dark_start", ST_INT, "0", 0, 1439, NULL, NULL, "显示" }, + { "display.theme_dark_end", ST_INT, "0", 0, 1439, NULL, NULL, "显示" }, + { "display.background", ST_STR, "black",0,0, NULL, NULL, "显示" }, + { "display.multi_font", ST_INT, "0", 0, 1, NULL, NULL, "显示" }, + /* 电源 */ + { "power.longpress_menu_ms", ST_INT, "3000", 0,0, NULL, NULL, "电源" }, + { "power.forceoff_ms", ST_INT, "8000", 0,0, NULL, NULL, "电源" }, + { "power.defeat_hotkey", ST_STR, "power_voldown",0,0, NULL, NULL, "电源" }, + /* 开机 */ + { "boot.default", ST_ENUM, "meow", 0,0, "meow,hiby", "MeowOS,HiByOS", "开机" }, + { "boot.menu_enabled", ST_BOOL, "1", 0,0, NULL, NULL, "开机" }, + { "boot.timeout_ms", ST_INT, "5000", 0,0, NULL, NULL, "开机" }, + /* 输入 */ + { "input.hotkey_seq", ST_STR, "volup,volup,volup",0,0, NULL, NULL, "输入" }, + { "input.hotkey_window_ms", ST_INT, "2500", 0,0, NULL, NULL, "输入" }, + { "input.touch_arm_delay_ms", ST_INT, "700", 0,0, NULL, NULL, "输入" }, + { "input.swipe_invert", ST_BOOL, "0", 0,0, NULL, NULL, "输入" }, + /* 字体/主题 */ + { "font.file", ST_STR, "MeowOS/fonts/default/regular/font.png",0,0, NULL, NULL, "字体主题" }, + { "font.class_cjk", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_cjk_sym", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_num", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_latin", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_latin_sym", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_other", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.load_full", ST_INT, "0", 0,1, NULL, NULL, "字体主题" }, + { "font.size.title", ST_INT, "28", 0,0, NULL, NULL, "字体主题" }, + { "font.size.body", ST_INT, "20", 0,0, NULL, NULL, "字体主题" }, + { "ui.style", ST_ENUM, "md3", 0,0, "md3,miuix", "Material You,MIUIx", "字体主题" }, + /* 通知 */ + { "notify.center_enabled", ST_BOOL, "1", 0,0, NULL, NULL, "通知" }, + { "notify.app_quota", ST_INT, "16", 0,0, NULL, NULL, "通知" }, + { "notify.categories", ST_INT, "4", 1,4, NULL, NULL, "通知" }, + { "notify.category_quota", ST_INT, "4", 1,16, NULL, NULL, "通知" }, + { "notify.media_quota", ST_INT, "1", 1,1, NULL, NULL, "通知" }, + { "notify.dnd", ST_BOOL, "0", 0,0, NULL, NULL, "通知" }, + /* 日志 */ + { "log.level", ST_ENUM, "info", 0,0, "error,warn,info,debug", "错误,警告,信息,调试", "日志" }, + { "log.to_card", ST_BOOL, "1", 0,0, NULL, NULL, "日志" }, + /* 音频 */ + { "audio.volume", ST_INT, "60", 0,100, NULL, NULL, "音频" }, + { "audio.volume_max", ST_INT, "100", 0,100, NULL, NULL, "音频" }, + { "audio.volume_step", ST_INT, "1", 1,5, NULL, NULL, "音频" }, + { "audio.output", ST_STR, "hw:0,0",0,0, NULL, NULL, "音频" }, + /* 网络 */ + { "network.wifi_enabled", ST_BOOL, "1", 0,0, NULL, NULL, "网络" }, + { "network.bluetooth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "网络" }, + /* 系统 */ + { "system.hostname", ST_STR, "meowos",0,0, NULL, NULL, "系统" }, + /* 面板(MeowPanel 自身,访问级 PIN 鉴权) + * panel.pin = 4 位数字 PIN 的 sha256+salt 哈希(`:`,绝不存明文)。 + * 与未来 MeowOS 锁屏 PIN 同源:锁屏键 `system.pin` 预留——本后端只读面板键 panel.pin;将来锁屏启用时 + * 以 system.pin 为准并做同源迁移(panel.pin 视为其缓存/别名),此处仅留兼容注记,不实现。 + * panel.auth_enabled/panel.auth_pass 为旧密码机制,保留兼容迁移(见鉴权段注释),新代码以 PIN 为准。 */ + { "panel.pin", ST_STR, "", 0,0, NULL, NULL, "面板" }, + { "panel.auth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" }, + { "panel.auth_pass", ST_STR, "", 0,0, NULL, NULL, "面板" }, +}; +#define SETS_COUNT ((int)(sizeof g_sets / sizeof g_sets[0])) + + +const setdef_t *setdef_find(const char *key) { + for (int i = 0; i < SETS_COUNT; i++) + if (!strcmp(g_sets[i].key, key)) return &g_sets[i]; + return NULL; +} + +void settings_mkdir_p(void) +{ + /* 逐级创建 settings.conf 所在目录(mkdir -p 语义,容忍已存在)。 */ + char tmp[1024]; + snprintf(tmp, sizeof tmp, "%s", g_settings_file); + char *slash = strrchr(tmp, '/'); + if (!slash || slash == tmp) return; /* 无目录或根部 */ + *slash = 0; + if (!tmp[0]) return; + char buf[1024]; buf[0] = 0; + size_t i = 0; + if (tmp[0] == '/') buf[i++] = '/'; + const char *s = tmp + (tmp[0] == '/' ? 1 : 0); + while (*s) { + const char *tom = strchr(s, '/'); + size_t l = tom ? (size_t)(tom - s) : strlen(s); + if (l) { + if (i && buf[i - 1] != '/') buf[i++] = '/'; + if (i + l >= sizeof buf) break; + memcpy(buf + i, s, l); + i += l; + buf[i] = 0; + mkdir(buf, 0777); + } + if (!tom) break; + s = tom + 1; + } +} + +char *settings_get_value(const char *key) +{ + FILE *f = fopen(g_settings_file, "r"); + if (!f) return NULL; + size_t kl = strlen(key); + char line[256]; + char *found = NULL; + while (fgets(line, sizeof line, f)) { + char *eq = strchr(line, '='); + if (!eq) continue; + char *k = line; + while (*k == ' ' || *k == '\t') k++; + long n = (long)(eq - k); + while (n > 0 && (k[n - 1] == ' ' || k[n - 1] == '\t')) n--; + if ((size_t)n != kl || strncmp(k, key, kl) != 0) continue; + found = eq + 1; + break; + } + fclose(f); + if (!found) return NULL; + while (*found == ' ' || *found == '\t') found++; + char *e = found + strlen(found); + while (e > found && (e[-1] == '\n' || e[-1] == '\r' || e[-1] == ' ' || e[-1] == '\t')) *--e = 0; + return strdup(found); +} + +int settings_get_int(const char *key, int defv) +{ + char *v = settings_get_value(key); + if (!v) return defv; + int r = atoi(v); + free(v); + return r; +} + +int brightness_clamp(int v) +{ + int mn = settings_get_int("display.brightness_min", 1); + if (mn < 1) mn = 1; + if (mn > 100) mn = 100; + if (v < mn) v = mn; + if (v > 100) v = 100; + return v; +} + +int settings_write_one(const char *key, const char *val) +{ + settings_mkdir_p(); /* 目录缺失自动建(与 meow_settings_save 同;失败由 fopen 兜底) */ + FILE *f = fopen(g_settings_file, "r"); + char tmp[2048]; + size_t n = 0; + int c; + if (f) { + while (n < sizeof tmp - 1 && (c = fgetc(f)) != EOF) tmp[n++] = (char)c; + fclose(f); + } + tmp[n] = 0; + FILE *nf = fopen(g_settings_file, "w"); + if (!nf) return -1; + size_t klen = strlen(key); + int found = 0; + if (n == 0) fprintf(nf, "%s\n", SETTINGS_HDR); + /* 逐行重写:头照抄;命中 key 行替换;其余照抄。匹配容忍 `key = value` 两侧空格(与 settings_get_value 同口径)。 */ + char *save = NULL; + for (char *ln = strtok_r(tmp, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { + char *p = ln; + while (*p == ' ' || *p == '\t') p++; + char *eq = strchr(p, '='); + if (eq) { + char *k = p; + size_t km = (size_t)(eq - k); + while (km > 0 && (k[km - 1] == ' ' || k[km - 1] == '\t')) km--; + if (km == klen && strncmp(k, key, klen) == 0) { + fprintf(nf, "%s = %s\n", key, val); + found = 1; + continue; + } + } + fprintf(nf, "%s\n", ln); + } + if (!found) fprintf(nf, "%s = %s\n", key, val); + fclose(nf); + return 0; +} + +int settings_coerce(const setdef_t *d, const char *input, char *out, size_t outcap) +{ + switch (d->type) { + case ST_INT: { + int v = atoi(input); + if (!strcmp(d->key, "display.brightness")) v = brightness_clamp(v); + else { + if (d->max > 0) { if (v < d->min) v = d->min; if (v > d->max) v = d->max; } + else if (v < d->min) v = d->min; + } + snprintf(out, outcap, "%d", v); + return 0; + } + case ST_BOOL: { + char *q = (char *)input; + while (*q == ' ' || *q == '\t') q++; + int b = 0; + if (!strcasecmp(q, "true") || !strcasecmp(q, "yes") || !strcasecmp(q, "on")) b = 1; + else { long v = strtol(q, NULL, 10); if (q[0] && v != 0) b = 1; } + snprintf(out, outcap, "%d", b ? 1 : 0); + return 0; + } + case ST_ENUM: { + const char *names = d->enum_names; + if (!names || !names[0]) { snprintf(out, outcap, "%s", input); return 0; } + int cnt = csv_count(names); + char tok[128]; + /* 接受名字或索引(向后兼容) */ + int idx = -1; + for (int i = 0; i < cnt; i++) { + csv_at(names, i, tok, sizeof tok); + if (!strcmp(tok, input)) { idx = i; break; } + } + if (idx < 0) { + int n = atoi(input); + if (n >= 0 && n < cnt) idx = n; + } + if (idx < 0) return -1; /* 非法枚举 */ + csv_at(names, idx, tok, sizeof tok); + snprintf(out, outcap, "%s", tok); + return 0; + } + case ST_STR: + strncpy(out, input, outcap - 1); + out[outcap - 1] = 0; + return 0; + } + return -1; +} + +int settings_reset_all(void) +{ + settings_mkdir_p(); + FILE *nf = fopen(g_settings_file, "w"); + if (!nf) return -1; + fprintf(nf, "%s\n", SETTINGS_HDR); + for (int i = 0; i < SETS_COUNT; i++) + fprintf(nf, "%s = %s\n", g_sets[i].key, g_sets[i].def); + fclose(nf); + return 0; +} + +void enum_options_json(sbuf_t *s, const char *names, const char *labels) +{ + sA(s, "["); + int cnt = csv_count(names); + for (int i = 0; i < cnt; i++) { + char nb[128], lb[128]; + csv_at(names, i, nb, sizeof nb); + csv_at(labels, i, lb, sizeof lb); + if (i) sA(s, ","); + sA(s, "{\"name\":"); + sA_str(s, nb); + sA(s, ",\"label\":"); + sA_str(s, lb[0] ? lb : nb); + sA(s, "}"); + } + sA(s, "]"); +} + +void settings_item_json(sbuf_t *s, const setdef_t *d, const char *val) +{ + const char *tn = "str"; + if (d->type == ST_INT) tn = "int"; + else if (d->type == ST_BOOL) tn = "bool"; + else if (d->type == ST_ENUM) tn = "enum"; + sA(s, "{\"key\":"); + sA_str(s, d->key); + sA(s, ",\"type\":\"%s\",\"value\":", tn); + if (d->type == ST_INT) { + int v = atoi(val); + if (!strcmp(d->key, "display.brightness")) v = brightness_clamp(v); + else if (d->max > 0) { if (v < d->min) v = d->min; if (v > d->max) v = d->max; } + else if (v < d->min) v = d->min; + sA(s, "%d", v); + sA(s, ",\"min\":%d,\"max\":%d", d->min, int_meta_max(d)); + } else if (d->type == ST_BOOL) { + sA(s, "%s", str_truthy(val) ? "true" : "false"); + } else { + if (!strcmp(d->key, "panel.auth_pass") || !strcmp(d->key, "panel.pin")) { + /* 密码/PIN 哈希绝不出现在响应里:value 恒空串,附 set 标志(是否已设) */ + sA(s, "\"\",\"set\":%s", val[0] ? "true" : "false"); + } else { + sA_str(s, val); + } + } + sA(s, ",\"def\":"); + sA_str(s, d->def); + if (d->type == ST_ENUM) { + sA(s, ",\"options\":"); + enum_options_json(s, d->enum_names, d->enum_labels); + sA(s, ",\"default_name\":"); + sA_str(s, d->def); + } + sA(s, "}"); +} + +char *settings_current_value(const setdef_t *d) +{ + char *v = settings_get_value(d->key); + if (v) return v; + return strdup(d->def); +} + +int api_settings_get_any(int sock, const char *key) +{ + const setdef_t *d = setdef_find(key); + if (!d) return send_err(sock, 404, "NOT_FOUND", "未知设置项"); + char *val = settings_current_value(d); + sbuf_t s = {0}; + settings_item_json(&s, d, val); + int rc = send_json(sock, 200, s.b ? s.b : ""); + free(val); + sbuf_free(&s); + return rc; +} + +int api_settings_get_all(int sock) +{ + sbuf_t s = {0}; + sA(&s, "{\"items\":["); + char groups[16][32]; + int ngroups = 0; + for (int i = 0; i < SETS_COUNT; i++) { + const setdef_t *d = &g_sets[i]; + char *val = settings_current_value(d); + if (i) sA(&s, ","); + settings_item_json(&s, d, val); + free(val); + if (d->group && d->group[0]) { + int found = 0; + for (int g = 0; g < ngroups; g++) if (!strcmp(groups[g], d->group)) { found = 1; break; } + if (!found && ngroups < 16) snprintf(groups[ngroups++], 32, "%s", d->group); + } + } + sA(&s, "],\"count\":%d,\"groups\":[", SETS_COUNT); + for (int g = 0; g < ngroups; g++) { + if (g) sA(&s, ","); + sA_str(&s, groups[g]); + } + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_settings_put(int sock, const char *key, const req_t *r) +{ + const setdef_t *d = setdef_find(key); + if (!d) return send_err(sock, 404, "NOT_FOUND", "未知设置项"); + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *val = jv_obj_get(root, "value"); + if (!val) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺少 value 字段"); } + char raw[256]; + switch (val->type) { + case J_NUM: snprintf(raw, sizeof raw, "%lld", val->inum); break; + case J_STR: snprintf(raw, sizeof raw, "%s", val->str); break; + case J_BOOL: snprintf(raw, sizeof raw, "%d", val->bval ? 1 : 0); break; + default: jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "value 类型不支持"); + } + jv_free(root); + char hashbuf[200]; + /* panel.pin:4 位数字 PIN,只落带盐哈希;空串=清除(回未设状态);非法(非 4 位数字)→ 422 */ + if (!strcmp(key, "panel.pin")) { + if (raw[0]) { + if (!pin_validate(raw)) return send_err(sock, 422, "UNSUPPORTED", "PIN 必须为 4 位数字"); + if (auth_make_hash(raw, hashbuf, sizeof hashbuf) < 0) + return send_err(sock, 500, "INTERNAL", "生成 PIN 哈希失败"); + } else { + hashbuf[0] = 0; /* 清空 → 回未设状态 */ + } + if (settings_write_one(key, hashbuf) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败"); + pin_lock_reset(); + sbuf_t s = {0}; + settings_item_json(&s, d, hashbuf); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; + } + /* panel.auth_pass:只落带盐哈希,绝不存明文;空串=清除密码 */ + if (!strcmp(key, "panel.auth_pass")) { + if (raw[0]) { + if (auth_make_hash(raw, hashbuf, sizeof hashbuf) < 0) + return send_err(sock, 500, "INTERNAL", "生成密码哈希失败"); + } else { + hashbuf[0] = 0; /* 清空 → 禁用密码 */ + } + if (settings_write_one(key, hashbuf) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败"); + sbuf_t s = {0}; + settings_item_json(&s, d, hashbuf); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; + } + /* panel.auth_enabled(旧机制,兼容迁移):置 1 前须已有访问屏障(PIN 或旧密码) */ + if (!strcmp(key, "panel.auth_enabled")) { + char coerced[256]; + if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) + return send_err(sock, 422, "UNSUPPORTED", "值域/枚举非法"); + if (!strcmp(coerced, "1") && !pin_set() && !auth_pass_set()) + return send_err(sock, 422, "UNSUPPORTED", "请先设置面板 PIN 或密码"); + if (settings_write_one(key, coerced) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败"); + sbuf_t s = {0}; + settings_item_json(&s, d, coerced); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; + } + char coerced[256]; + if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { + return send_err(sock, 422, "UNSUPPORTED", "值域/枚举非法"); + } + if (settings_write_one(key, coerced) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败"); + /* boot.default 同步权威文件(同 meow_settings_save) */ + if (!strcmp(key, "boot.default")) { + int v = (!strcmp(coerced, "hiby")) ? 1 : 0; + FILE *bf = fopen(BOOT_DEFAULT_FILE, "w"); + if (bf) { fprintf(bf, "%d\n", v); fclose(bf); } + } + sbuf_t s = {0}; + settings_item_json(&s, d, coerced); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_settings_batch(int sock, const req_t *r) +{ + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root || root->type != J_OBJ) { if (root) jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON 对象"); } + sbuf_t ok = {0}, fail = {0}; + int nok = 0, nfail = 0; + for (const jv_t *p = root->child; p; p = p->next) { + const setdef_t *d = setdef_find(p->str); + if (!d) { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"NOT_FOUND\",\"message\":\"未知设置项\"}"); nfail++; continue; } + jv_t *val = p->child; + char raw[256]; + switch (val->type) { + case J_NUM: snprintf(raw, sizeof raw, "%lld", val->inum); break; + case J_STR: snprintf(raw, sizeof raw, "%s", val->str); break; + case J_BOOL: snprintf(raw, sizeof raw, "%d", val->bval ? 1 : 0); break; + default: if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"BAD_REQUEST\",\"message\":\"非法值类型\"}"); nfail++; continue; + } + /* panel.pin:只落带盐哈希(空=清除);非法(非 4 位数字)→ 422 */ + if (!strcmp(p->str, "panel.pin")) { + char hashbuf[200]; + if (raw[0]) { + if (!pin_validate(raw)) { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"PIN 必须为 4 位数字\"}"); nfail++; continue; } + if (auth_make_hash(raw, hashbuf, sizeof hashbuf) < 0) { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"生成 PIN 哈希失败\"}"); nfail++; continue; } + } else hashbuf[0] = 0; + if (settings_write_one(p->str, hashbuf) == 0) { if (nok) sA(&ok, ","); sA_str(&ok, p->str); nok++; } + else { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"写失败\"}"); nfail++; } + continue; + } + /* panel.auth_pass:只落带盐哈希(空=清除);panel.auth_enabled:启用前须已有密码 */ + if (!strcmp(p->str, "panel.auth_pass")) { + char hashbuf[200]; + if (raw[0]) { if (auth_make_hash(raw, hashbuf, sizeof hashbuf) < 0) { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"生成密码哈希失败\"}"); nfail++; continue; } } + else hashbuf[0] = 0; + if (settings_write_one(p->str, hashbuf) == 0) { if (nok) sA(&ok, ","); sA_str(&ok, p->str); nok++; } + else { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"写失败\"}"); nfail++; } + continue; + } + char coerced[256]; + if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { + if (nfail) sA(&fail, ","); + sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"值域/枚举非法\"}"); + nfail++; + continue; + } + /* panel.auth_enabled(迁移):启用前须已有访问屏障(PIN 或旧密码) */ + if (!strcmp(p->str, "panel.auth_enabled") && !strcmp(coerced, "1") && !pin_set() && !auth_pass_set()) { + if (nfail) sA(&fail, ","); + sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"请先设置面板 PIN 或密码\"}"); + nfail++; + continue; + } + if (settings_write_one(p->str, coerced) == 0) { + if (nok) sA(&ok, ","); + sA_str(&ok, p->str); + nok++; + } else { + if (nfail) sA(&fail, ","); + sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"写失败\"}"); + nfail++; + } + } + jv_free(root); + sbuf_t s = {0}; + sA(&s, "{\"updated\":["); + if (ok.b) sA(&s, "%s", ok.b); + sA(&s, "],\"failed\":["); + if (fail.b) sA(&s, "%s", fail.b); + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); sbuf_free(&ok); sbuf_free(&fail); + return rc; +} + +int api_settings_restore(int sock) +{ + if (settings_reset_all() < 0) return send_err(sock, 500, "INTERNAL", "恢复失败"); + return send_json(sock, 200, "{\"reset\":true}"); +} + +void theme_json(sbuf_t *s) +{ + const setdef_t *dst = setdef_find("ui.style"); + const setdef_t *dth = setdef_find("display.theme"); + const setdef_t *dbg = setdef_find("display.background"); + char *style = settings_current_value(dst); + char *theme = settings_current_value(dth); + char *bg = settings_current_value(dbg); + /* 当前值 → 中文标签(csv 里同下标查 label) */ + char slabel[128] = "", tlabel[128] = ""; + for (int i = 0; i < csv_count(dst->enum_names); i++) { + char nb[128], lb[128]; + csv_at(dst->enum_names, i, nb, sizeof nb); + csv_at(dst->enum_labels, i, lb, sizeof lb); + if (!strcmp(nb, style)) { snprintf(slabel, sizeof slabel, "%s", lb[0] ? lb : nb); break; } + } + for (int i = 0; i < csv_count(dth->enum_names); i++) { + char nb[128], lb[128]; + csv_at(dth->enum_names, i, nb, sizeof nb); + csv_at(dth->enum_labels, i, lb, sizeof lb); + if (!strcmp(nb, theme)) { snprintf(tlabel, sizeof tlabel, "%s", lb[0] ? lb : nb); break; } + } + sA(s, "{\"style\":"); + sA_str(s, style); + sA(s, ",\"theme\":"); + sA_str(s, theme); + sA(s, ",\"background\":"); + sA_str(s, bg); + sA(s, ",\"style_label\":"); + sA_str(s, slabel); + sA(s, ",\"theme_label\":"); + sA_str(s, tlabel); + sA(s, ",\"style_options\":"); + enum_options_json(s, dst->enum_names, dst->enum_labels); + sA(s, ",\"theme_options\":"); + enum_options_json(s, dth->enum_names, dth->enum_labels); + sA(s, ",\"background_options\":[{\"name\":\"black\",\"label\":\"黑\"},{\"name\":\"blue\",\"label\":\"深蓝\"},{\"name\":\"green\",\"label\":\"墨绿\"}]}"); + free(style); free(theme); free(bg); +} + +int api_theme_get(int sock) +{ + sbuf_t s = {0}; + theme_json(&s); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +int api_theme_put(int sock, const req_t *r) +{ + if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + /* 允许 style/theme/background 任意子集 */ + const char *keys[3] = { "style", "theme", "background" }; + const char *setkeys[3] = { "ui.style", "display.theme", "display.background" }; + for (int i = 0; i < 3; i++) { + jv_t *v = jv_obj_get(root, keys[i]); + if (!v) continue; + const setdef_t *d = setdef_find(setkeys[i]); + if (!d) continue; + char raw[256]; + if (v->type == J_STR) snprintf(raw, sizeof raw, "%s", v->str); + else if (v->type == J_NUM) snprintf(raw, sizeof raw, "%lld", v->inum); + else continue; + char coerced[256]; + if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { jv_free(root); return send_err(sock, 422, "UNSUPPORTED", "非法值"); } + settings_write_one(setkeys[i], coerced); + } + jv_free(root); + return api_theme_get(sock); +} diff --git a/patch_v2/meowpanel/src/mp_svcd.c b/patch_v2/meowpanel/src/mp_svcd.c new file mode 100644 index 0000000..68c3ada --- /dev/null +++ b/patch_v2/meowpanel/src/mp_svcd.c @@ -0,0 +1,130 @@ +/* mp_svcd.c — 服务表域:svcd 状态解析 + /api/svcd + 首页聚合 /api/overview。 */ +#include "mp.h" + + +const char *svcd_bin(void) +{ + const char *e = getenv("MEOW_SVCD_BIN"); + if (e && e[0]) return e; + if (!access(SVCD_BIN, X_OK)) return SVCD_BIN; + return SVCD_BIN_CARD; +} + +void svcd_fill(sbuf_t *s) +{ + char out[16384]; + char cmd[300]; + snprintf(cmd, sizeof cmd, "%s status all 2>/dev/null", svcd_bin()); + int code = run_cmd(cmd, out, sizeof out); + if (code != 0) { sA(s, "{\"available\":false}"); return; } + panel_strip_ansi(out); + char *toks[256]; + sbuf_t svcs = {0}; + int first = 1, count = 0; + char *ln = out; + while (*ln) { + char *nl = strchr(ln, '\n'); + if (nl) *nl = 0; + int n = txt_tokens(ln, toks, 256); + /* 数据行:NAME STATE PID RESTART BACKOFF TRIGGER SRC EXIT EXIT_TIME EXEC... + * state=tok[1] 须为合法状态;exec=join(tok[9..n))。 */ + if (n >= 10) { + const char *st = toks[1]; + int is_known = (!strcmp(st,"running") || !strcmp(st,"stopped") || + !strcmp(st,"stopping") || !strcmp(st,"disabled") || !strcmp(st,"failed")); + if (is_known) { + char exec[512]; + join_tokens(toks, 9, n, exec, sizeof exec); + int restarts = 0; + { const char *slash = strchr(toks[3], '/'); (void)slash; restarts = atoi(toks[3]); } + if (!first) sA(&svcs, ","); + first = 0; count++; + sA(&svcs, "{\"name\":"); sA_str(&svcs, toks[0]); + sA(&svcs, ",\"state\":"); sA_str(&svcs, toks[1]); + sA(&svcs, ",\"pid\":%d", atoi(toks[2])); + sA(&svcs, ",\"restart\":%d", restarts); + sA(&svcs, ",\"restart_str\":"); + sA_str(&svcs, toks[3]); + sA(&svcs, ",\"backoff_ms\":%d", atoi(toks[4])); + sA(&svcs, ",\"trigger\":"); + sA_str(&svcs, toks[5]); + sA(&svcs, ",\"src\":"); + sA_str(&svcs, toks[6]); + sA(&svcs, ",\"exit\":"); + sA_str(&svcs, toks[7]); + sA(&svcs, ",\"exit_time\":"); + sA_str(&svcs, toks[8]); + sA(&svcs, ",\"exec\":"); + sA_str(&svcs, exec); + sA(&svcs, "}"); + } + } + if (nl) { *nl = '\n'; ln = nl + 1; } else break; + } + sA(s, "{\"available\":true,\"count\":%d,\"services\":[%s]}", count, svcs.b ? svcs.b : ""); + sbuf_free(&svcs); +} + +int api_svcd(int sock) +{ + sbuf_t s = {0}; + svcd_fill(&s); + int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}"); + sbuf_free(&s); + return rc; +} + +int api_overview(int sock) +{ + sbuf_t s = {0}; + sA(&s, "{\"ts\":%lld,", (long long)time(NULL)); + char pb[4096]; + if (ipc_perf_query(0, pb, sizeof pb) == MEOW_OK && utf8_valid_n(pb, strlen(pb))) + sA(&s, "\"perf\":%s,", pb); + else sA(&s, "\"perf\":{\"available\":false},"); + char pw[MEOW_IPC_MAX]; + if (ipc_pm_query(0, pw, sizeof pw) == MEOW_OK && utf8_valid_n(pw, strlen(pw))) + sA(&s, "\"power\":%s,", pw); + else sA(&s, "\"power\":{\"available\":false},"); + /* 最近异常耗电/充放电/亮灭屏/背光跳变事件(摘要 top2 进 overview,完整列表走 /api/power/events)。 */ + { + uint32_t ecnt = 2; + char ej[MEOW_IPC_MAX]; + uint16_t ert = 0; + int ert_rc = ipc_pm_send(MSG_PM_EVENTS, &ecnt, sizeof ecnt, &ert, ej, sizeof ej); + if (ert_rc > 0 && ert == MSG_PM_EVENTS_RESULT && utf8_valid_n(ej, strlen(ej))) + sA(&s, "\"events\":%s,", ej); + else + sA(&s, "\"events\":{\"available\":false},"); + } + sA(&s, "\"audio\":"); + audio_fill(&s); + if (net_block_fill(&s) == 0) + ; /* net 块已追加(状态 + 合并的 dns 子键) */ + else + sA(&s, ",\"net\":{\"available\":false}"); + sA(&s, ",\"ams\":"); + ams_fill(&s); + sA(&s, ",\"svcd\":"); + svcd_fill(&s); + sA(&s, "}"); + /* 服务端自校验:发出前 jv_parse 自查,坏了留证+回 500(防「非 JSON 响应」破页,2026-09-02 间歇性事故) + * 加严:内嵌 NUL(strlen 截断浏览器收残体)/非法 UTF-8(浏览器 JSON.parse 拒收,宽松解析器放行)*/ + jv_t *chk = jv_parse(s.b ? s.b : "{}", (int)(s.b ? s.n : 2)); + size_t slen = s.b ? strlen(s.b) : 0; + int nul_inside = s.b && (slen != s.n); /* 内嵌 NUL:strlen < s.n */ + int utf8_bad = s.b && !utf8_valid_n(s.b, s.n); + int unbalanced = s.b && !json_strict_balanced(s.b, s.n); /* 尾随垃圾/多括号(jv_parse 容忍,浏览器不容忍) */ + if (s.b && (nul_inside || utf8_bad || unbalanced)) { chk = NULL; } + if (!chk && s.b) { + FILE *df = fopen("/tmp/overview_bad.json", "w"); + if (df) { fwrite(s.b, 1, s.n, df); fclose(df); } + panel_log("overview: SELF-CHECK fail (nul=%d utf8_bad=%d unbalanced=%d n=%zu strlen=%zu), raw dumped /tmp/overview_bad.json", nul_inside, utf8_bad, unbalanced, s.n, slen); + sbuf_free(&s); + return send_err(sock, 500, "INTERNAL", "overview 聚合输出未通过 JSON 自校验(原始数据已留 /tmp/overview_bad.json)"); + } + if (chk) jv_free(chk); + int rc = send_json(sock, 200, s.b ? s.b : "{}"); + sbuf_free(&s); + return rc; +} diff --git a/patch_v2/meowpanel/src/mp_util.c b/patch_v2/meowpanel/src/mp_util.c new file mode 100644 index 0000000..7533307 --- /dev/null +++ b/patch_v2/meowpanel/src/mp_util.c @@ -0,0 +1,368 @@ +/* mp_util.c — 通用工具:有界字符串(sbuf)/JSON 字符串消毒(UTF-8)/内存搜索/命令管道/路径与 token 工具。 */ +#include "mp.h" + + +int sb_reserve(sbuf_t *s, size_t extra) +{ + if (s->n + extra + 1 <= s->cap) return 0; + size_t ncap = s->cap ? s->cap : 256; + while (ncap < s->n + extra + 1) ncap *= 2; + char *nb = realloc(s->b, ncap); + if (!nb) return -1; + s->b = nb; + s->cap = ncap; + return 0; +} + +void sA(sbuf_t *s, const char *fmt, ...) +{ + char tmp[512]; + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + int w = vsnprintf(tmp, sizeof tmp, fmt, ap); + va_end(ap); + if (w < 0) { va_end(ap2); return; } + if ((size_t)w < sizeof tmp) { + if (sb_reserve(s, (size_t)w) < 0) { va_end(ap2); return; } + memcpy(s->b + s->n, tmp, (size_t)w); + s->n += (size_t)w; + } else { + /* 超长 %s 等:格式化结果超出固定缓冲,动态分配后再追加(防截断坏 JSON) */ + char *big = malloc((size_t)w + 1); + if (!big) { va_end(ap2); return; } + int w2 = vsnprintf(big, (size_t)w + 1, fmt, ap2); + va_end(ap2); + if (w2 < 0) { free(big); return; } + if (sb_reserve(s, (size_t)w2) < 0) { free(big); return; } + memcpy(s->b + s->n, big, (size_t)w2); + s->n += (size_t)w2; + free(big); + } + s->b[s->n] = 0; +} + +int utf8_seq_len(unsigned char lead) +{ + if (lead >= 0xC2 && lead <= 0xDF) return 2; /* C2+:规避 overlong C0/C1 */ + if (lead >= 0xE0 && lead <= 0xEF) return 3; /* 3 字节(含代理区,由 seq_ok 剔除) */ + if (lead >= 0xF0 && lead <= 0xF4) return 4; /* F5+ 超 U+10FFFF,剔除 */ + return 0; +} + +int utf8_seq_ok(const char *p, int len) +{ + unsigned char c0 = (unsigned char)p[0]; + for (int k = 1; k < len; k++) { + unsigned char ck = (unsigned char)p[k]; + if (ck == 0 || (ck & 0xC0) != 0x80) return 0; /* 截断/非续字节 */ + } + unsigned long cp; + if (len == 2) cp = ((unsigned long)(c0 & 0x1F) << 6) | ((unsigned char)p[1] & 0x3F); + else if (len == 3) cp = ((unsigned long)(c0 & 0x0F) << 12) | (((unsigned char)p[1] & 0x3F) << 6) | ((unsigned char)p[2] & 0x3F); + else cp = ((unsigned long)(c0 & 0x07) << 18) | (((unsigned char)p[1] & 0x3F) << 12) | (((unsigned char)p[2] & 0x3F) << 6) | ((unsigned char)p[3] & 0x3F); + if (len == 2 && cp < 0x80) return 0; /* overlong(C0/C1 已由 seq_len 拒,双保险) */ + if (len == 3 && cp < 0x800) return 0; /* overlong E0 80.. */ + if (len == 4 && cp < 0x10000) return 0; /* overlong F0 80.. */ + if (cp >= 0xD800 && cp <= 0xDFFF) return 0; /* 代理对(JS 字符串不允许孤立) */ + if (cp > 0x10FFFF) return 0; + return 1; +} + +int utf8_valid_n(const char *s, size_t n) +{ + size_t i = 0; + while (i < n) { + unsigned char c = (unsigned char)s[i]; + if (c < 0x80) { i++; continue; } + int len = utf8_seq_len(c); + if (len == 0 || i + (size_t)len > n || !utf8_seq_ok(s + i, len)) return 0; + i += (size_t)len; + } + return 1; +} + +int utf8_sanitize(char *dst, const char *src, size_t n) +{ + size_t i = 0, j = 0; + int changed = 0; + while (i < n) { + unsigned char c = (unsigned char)src[i]; + if (c < 0x80) { dst[j++] = (char)c; i++; continue; } + int len = utf8_seq_len(c); + if (len == 0 || i + (size_t)len > n || !utf8_seq_ok(src + i, len)) { + dst[j++] = '?'; changed = 1; i++; continue; /* 逐字节替换,前进 1 重对齐 */ + } + memcpy(dst + j, src + i, (size_t)len); + j += (size_t)len; i += (size_t)len; + } + dst[j] = 0; + return changed; +} + +void sA_str_n(sbuf_t *s, const char *v, size_t n) +{ + sA(s, "\""); + size_t i = 0; + while (i < n) { + unsigned char c = (unsigned char)v[i]; + if (c == '"' || c == '\\') { sA(s, "\\%c", (int)c); i++; } + else if (c < 0x20) { sA(s, "\\u%04x", c); i++; } + else if (c < 0x80) { sA(s, "%c", (int)c); i++; } + else { + int len = utf8_seq_len(c); + if (len == 0 || i + (size_t)len > n || !utf8_seq_ok(v + i, len)) { + sA(s, "?"); i++; /* 非法/截断序列 → 逐字节替换为 '?',前进 1 重对齐 */ + } else { + for (int k = 0; k < len; k++) sA(s, "%c", (int)(unsigned char)v[i + k]); + i += (size_t)len; + } + } + } + sA(s, "\""); +} + +void sA_str(sbuf_t *s, const char *v) +{ + if (!v) v = ""; + sA_str_n(s, v, strlen(v)); +} + +void sA_append(sbuf_t *s, const char *b, size_t n) +{ + if (!b || n == 0) return; + if (sb_reserve(s, n) < 0) return; + memcpy(s->b + s->n, b, n); + s->n += n; + s->b[s->n] = 0; +} + +void sbuf_free(sbuf_t *s) { free(s->b); s->b = NULL; s->n = s->cap = 0; } + +void panel_strip_ansi(char *s) +{ + char *r = s, *w = s; + while (*r) { + if (*r == '\033' && r[1] == '[') { + r += 2; + while (*r && !(*r >= '@' && *r <= '~')) r++; + if (*r) r++; + } else { *w++ = *r++; } + } + *w = 0; +} + +int txt_tokens(char *buf, char **toks, int maxtoks) +{ + int n = 0; + char *p = buf; + while (*p && n < maxtoks) { + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; + if (!*p) break; + toks[n++] = p; + while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++; + if (*p) *p++ = 0; + } + return n; +} + +void join_tokens(char **toks, int from, int to, char *out, size_t cap) +{ + size_t o = 0; + for (int i = from; i < to; i++) { + size_t need = strlen(toks[i]) + (o ? 1 : 0); + if (o + need >= cap) { out[cap - 1] = 0; return; } + if (o) out[o++] = ' '; + memcpy(out + o, toks[i], strlen(toks[i])); + o += strlen(toks[i]); + } + out[o] = 0; +} + +int read_small_file(const char *path, char *buf, int cap) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + int n = 0; + while (n < cap - 1) { + ssize_t r = read(fd, buf + n, (size_t)(cap - 1 - n)); + if (r <= 0) break; + n += (int)r; + } + close(fd); + buf[n] = 0; + return n; +} + +void url_decode(const char *in, char *buf, int buflen) +{ + int o = 0; + for (int i = 0; in[i] && o < buflen - 1; i++) { + if (in[i] == '%' && is_hex(in[i + 1]) && is_hex(in[i + 2])) { + buf[o++] = (char)((hexval(in[i + 1]) << 4) | hexval(in[i + 2])); + i += 2; + } else if (in[i] == '+') buf[o++] = ' '; + else buf[o++] = in[i]; + } + buf[o] = 0; +} + +int qparam(const char *query, const char *key, char *out, int outcap) +{ + size_t kl = strlen(key); + const char *p = query; + while (p && *p) { + const char *amp = strchr(p, '&'); + size_t seg = amp ? (size_t)(amp - p) : strlen(p); + if (seg >= kl + 1 && strncmp(p, key, kl) == 0 && p[kl] == '=') { + char tmp[1024]; + size_t vlen = seg - kl - 1; + if (vlen >= sizeof tmp) vlen = sizeof tmp - 1; + memcpy(tmp, p + kl + 1, vlen); + tmp[vlen] = 0; + url_decode(tmp, out, outcap); + return 1; + } + if (!amp) break; + p = amp + 1; + } + return 0; +} + +int path_segment(const char *prefix, const char *full, char *out, int outcap) +{ + size_t pl = strlen(prefix); + if (strncmp(full, prefix, pl) != 0) return 0; + const char *rest = full + pl; + if (*rest == '/') rest++; + if (!*rest) return 0; + size_t l = strlen(rest); + if (l >= (size_t)outcap) l = (size_t)outcap - 1; + memcpy(out, rest, l); + out[l] = 0; + /* 去掉后续 / 段(取第一段) */ + char *slash = strchr(out, '/'); + if (slash) *slash = 0; + return 1; +} + +void split_path_query(const char *src, char *path, int plc, char *query, int qlc) +{ + const char *q = strchr(src, '?'); + if (q) { + size_t pl = (size_t)(q - src); + if (pl >= (size_t)plc) pl = (size_t)plc - 1; + memcpy(path, src, pl); + path[pl] = 0; + snprintf(query, qlc, "%s", q + 1); + } else { + snprintf(path, plc, "%s", src); + query[0] = 0; + } +} + +ptrdiff_t find_header_end(const char *buf, size_t n) +{ + if (n < 4) return -1; + for (size_t i = 0; i + 3 < n; i++) { + if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n') + return (ptrdiff_t)i; + } + return -1; +} + +const char *mem_find(const void *hay, size_t hlen, const char *needle) +{ + size_t nl = strlen(needle); + if (!nl || hlen < nl) return NULL; + const char *h = (const char *)hay; + for (size_t i = 0; i + nl <= hlen; i++) { + if (h[i] == needle[0] && memcmp(h + i, needle, nl) == 0) + return h + i; + } + return NULL; +} + +void panel_log(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + fprintf(stderr, "[MeowPanel] "); + vfprintf(stderr, fmt, ap); + fprintf(stderr, "\n"); + va_end(ap); +} + +int str_truthy(const char *v) +{ + char q[32]; + snprintf(q, sizeof q, "%s", v); + char *p = q; + while (*p == ' ' || *p == '\t') p++; + if (!strcasecmp(p, "true") || !strcasecmp(p, "yes") || !strcasecmp(p, "on")) return 1; + long n = strtol(p, NULL, 10); + return (p[0] && n != 0); +} + +int int_meta_max(const setdef_t *d) +{ + if (d->max > 0) return d->max; + int dn = atoi(d->def); + if (dn > 0) return dn * 2 + 100; + return 100; +} + +const char *csv_at(const char *s, int idx, char *buf, int buflen) +{ + buf[0] = 0; + if (!s || !s[0]) return buf; + const char *p = s; + int i = 0; + for (;;) { + const char *start = p; + while (*p && *p != ',') p++; + if (i == idx) { + size_t k = (size_t)(p - start); + if (k >= (size_t)buflen) k = (size_t)buflen - 1; + memcpy(buf, start, k); + buf[k] = 0; + break; + } + if (!*p) break; + p++; + i++; + } + return buf; +} + +int csv_count(const char *s) +{ + if (!s || !s[0]) return 0; + int n = 1; + for (const char *p = s; *p; p++) if (*p == ',') n++; + return n; +} + +int run_cmd(const char *cmd, char *out, int outcap) +{ + FILE *f = popen(cmd, "r"); + if (!f) return -1; + size_t off = 0; + while (off < (size_t)outcap - 1) { + size_t rd = fread(out + off, 1, (size_t)outcap - 1 - off, f); + if (rd == 0) break; + off += rd; + } + out[off] = 0; + int st = pclose(f); + int code = -1; + if (WIFEXITED(st)) code = WEXITSTATUS(st); + return code; +} + +int64_t now_mono_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} diff --git a/patch_v2/meowpanel/web/index.html b/patch_v2/meowpanel/web/index.html index 5479279..77817ab 100644 --- a/patch_v2/meowpanel/web/index.html +++ b/patch_v2/meowpanel/web/index.html @@ -61,13 +61,9 @@ 设置 - - '; - }); - s += ''; - s += '
' + vol + '' + - '' + - '' + vmax + '
'; - s += '

切换/调节前会确认。选错输出口会无声;音量走平方锥度(越接近上限越响,非等比),系统上限 ' + esc(vmax) + '。

'; - s += ''; - return s; - } - function audioFlowHtml(d) { - let h = ''; - if (!d || !d.available) { - h += '

音频服务(meow_audiod)

' + - '

audiod 不可用(服务未运行或 IPC 不可达)。MeowOS 模式下 audiod 恒在;HibyOS 模式下按门控不启动。

'; - } else { - h += '

当前流

'; - if (d.active) { - h += '' + - row('应用', esc(d.app || '—') + ' (sid ' + esc(d.sid) + ')') + - row('采样率', esc(d.rate) + ' Hz') + - row('声道', esc(d.ch) + ' ch') + - row('格式', esc(d.fmt || '—')) + - row('已写帧', fmtFrames(d.written_frames)) + - row('已播放帧', fmtFrames(d.played_frames)) + - row('underrun', '' + esc(d.underrun) + '') + - row('状态', d.paused ? '已暂停' : '播放中') + - '
'; - } else { - h += '

当前无活动音频流。

'; - } - h += '
'; - h += audioCtlHtml(d); - } - return h; - } - async function fetchAudioOnce() { - const flowEl = $('#audio-flow'); - if (!flowEl || state.route !== 'audio') return; - let d; - try { d = await api('GET', '/api/audio'); } - catch (e) { - if (flowEl && state.route === 'audio') flowEl.innerHTML = '
加载失败:' + esc(errText(e)) + '
'; - return; - } - if (state.route !== 'audio') return; - if (audioVolDrag) return; // 音量滑条拖拽中:暂停重渲染防打断(电平/控件保留当前交互态) - flowEl.innerHTML = audioFlowHtml(d); - updateLevelMeter($('#audio-level'), d); - const note = $('#audio-level-note'); - if (note) { - if (!d || !d.available) note.innerHTML = 'audiod 不可用 · 每 1 秒自动刷新(路由/音量写端点不可用)。'; - else if (!d.active) note.innerHTML = '当前无流 · 电平保持 −∞ · 每 1 秒自动刷新。路由/音量为写级,需登录会话。'; - else note.innerHTML = '每 1 秒自动刷新 · 流信息只读;路由与音量为写级(确认后生效,不放测试音)。'; - } - } - function renderAudio() { - resetSpark(); // 进入页面重置 sparkline 累积 - stopAudio(); - $('#content').innerHTML = - '
' + - '

加载音频状态…

' + - '

电平(dBFS)

' + - '
' + levelMeterHtml() + '
' + - '

每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI meow_audio tone,面板不放声)。

' + - '
' + - '
'; - fetchAudioOnce(); - audioTimer = setInterval(fetchAudioOnce, 1000); - } - /* 音频页交互(委托 #content,元素随 1s 轮询重建亦不失效):路由切换/音量滑条均写级 + confirm。 */ - function bindAudio() { - $('#content').addEventListener('click', async function (e) { - const rb = e.target.closest('[data-audio-route]'); - if (rb) { - const port = parseInt(rb.dataset.audioRoute, 10); - if (!(port === 0 || port === 2 || port === 3)) { toast('非法路由口(仅自动/3.5/4.4)'); return; } - const ok = await confirmDlg('切换输出路由', - '确认切换到「' + audioRouteLabel(port) + (port === 0 ? '(跟随插孔)' : '') + '」?\n选错输出口会无声,请确认当前插孔。', false); - if (!ok) return; - try { await api('POST', '/api/audio/route', { port: port }); toast('路由已切换:' + audioRouteLabel(port)); } - catch (err) { toast('切换失败:' + errText(err)); } - return; - } - }); - $('#content').addEventListener('pointerdown', function (e) { - const sl = e.target.closest('[data-audio-vol]'); - if (sl) { audioVolDrag = true; const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; } - }); - $('#content').addEventListener('pointerup', function (e) { - // 释放即复位;若值已提交 change 会再走确认,此处仅保证「点击未改动」时也不卡住重渲染 - if (e.target.closest('[data-audio-vol]')) setTimeout(function () { audioVolDrag = false; }, 250); - }); - $('#content').addEventListener('input', function (e) { - const sl = e.target.closest('[data-audio-vol]'); - if (!sl) return; - const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; - }); - $('#content').addEventListener('change', async function (e) { - const sl = e.target.closest('[data-audio-vol]'); - if (!sl) return; - audioVolDrag = false; - const v = parseInt(sl.value, 10); - const vmax = parseInt(sl.max, 10) || 100; - const ok = await confirmDlg('确认音量', - '确认将音量设为 ' + v + ' %?(系统上限 ' + vmax + ',平方锥度非线性——越接近上限越响且非等比)', false); - if (!ok) { fetchAudioOnce(); return; } // 取消 → 立即拉一次复位滑条 - try { - await api('POST', '/api/audio/volume', { value: v }); - toast('音量已设为 ' + v + ' %'); - fetchAudioOnce(); - } catch (err) { toast('设置失败:' + errText(err)); fetchAudioOnce(); } - }); - } - - /* ---------------- 2d. 首页仪表盘(#/home,默认路由;/api/overview 聚合,5s 节拍 + 电平 1s 子节拍) ---------------- - * 单请求 /api/overview 供全部卡片;另低频补充 /api/system(boot/默认启动,静态)+ /api/net/dns(DNS 上游/缓存)。 - * 任一源 available:false / 缺失 → 该卡显示「—」,绝不拖垮其它卡。 */ - let homeTimer = null, homeLevelTimer = null; - let homeData = null, homeSys = null; - function stopHome() { - if (homeTimer) { clearInterval(homeTimer); homeTimer = null; } - if (homeLevelTimer) { clearInterval(homeLevelTimer); homeLevelTimer = null; } - } - /* 设备信息卡:版本/hostname/内核/架构/运行模式/默认启动(boot 来自 /api/system 一次性拉取) */ - function deviceCard() { - const p = (homeData && homeData.perf && homeData.perf.system); - const s = homeSys; - const ver = (s && s.version) || (p && p.version) || '—'; - const host = (s && s.hostname) || (p && p.hostname) || '—'; - const ker = (s && s.kernel) || (p && p.kernel) || '—'; - const arch = (s && s.arch) || (p && p.arch) || '—'; - const bootD = (s && s.boot && s.boot.default) || '—'; - const cur = (s && s.boot) ? (s.boot.mode || s.boot.current || null) : null; - let rows = ''; - rows += homeKv('版本', esc(ver)); - rows += homeKv('主机名', esc(host)); - rows += homeKv('内核', esc(ker)); - rows += homeKv('架构', esc(arch)); - rows += homeKv('运行模式', modePill(cur)); - rows += homeKv('默认启动', modePill(bootD)); - return homeCard('设备信息', '
' + rows + '
'); - } - function batteryCard() { - const pw = homeData && homeData.power; - if (!isAvail(pw)) return homeCard('电量 / 充电', homeUnavailable()); - const b = pw.battery || {}; - const pct = (b.pct != null) ? b.pct : null; - const state = b.state || '—'; - const charging = (state === 'Charging' || state === 'Full'); - const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : '')); - let inner = '
' + - '' + (pct == null ? '—' : pct) + '%' + - '' + esc(state) + '
'; - inner += '
' + - homeKv('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') + - homeKv('健康', b.health ? esc(b.health) : '—') + - homeKv('温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') + - (b.est_minutes_left != null ? homeKv('续航估算', b.est_minutes_left + ' 分钟') : - (b.est_minutes_full != null ? homeKv('充满估算', b.est_minutes_full + ' 分钟') : '')) + - '
'; - return homeCard('电量 / 充电', inner); - } - function audioCard() { - const ad = homeData && homeData.audio; - if (!isAvail(ad)) return homeCard('音频', homeUnavailable()); - let body = ''; - if (ad.active) { - body += '
' + - homeKv('流', esc(ad.app || '—') + ' · ' + esc(ad.rate) + 'Hz ' + esc(ad.fmt || '')) + - homeKv('输出口', audioPortName(ad.port)) + - homeKv('音量', (ad.vol != null && ad.vol >= 0 ? esc(ad.vol) + ' %' : '—')) + - '
'; - } else { - body += '
当前无活跃音频流。
'; - } - body += '
' + levelMeterHtml() + '
' + - '

' + (ad.active ? '电平 · 1s 子节拍' : '无流') + '

'; - return homeCard('音频', body); - } - function netCard() { - const nd = homeData && homeData.net; - if (!isAvail(nd)) return homeCard('WiFi / DNS', homeUnavailable()); - const w = nd.wifi || {}; - const dn = (nd && nd.dns) || null; // 批2: DNS 统计已并入 /api/overview 的 net 块(首页严格单请求) - let rows = ''; - rows += homeKv('WiFi', w.connected ? ('已连接 ' + esc(w.ssid || '')) : '未连接'); - if (w.connected) { - rows += homeKv('IP', esc(w.ip || '—')); - rows += homeKv('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—')); - } - if (dn) { - rows += homeKv('DNS 上游', esc(dn.upstream || '—')); - rows += homeKv('DNS 缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0) + (dn.enabled ? '' : '(停用)')); - } else { - rows += homeKv('DNS', '—'); - } - const pb = (nd && nd.pub_bytes) || null; - if (pb && pb.total) { - rows += homeKv('公网流量', '↓ ' + fmtBytes(pb.total.rx || 0) + ' · ↑ ' + fmtBytes(pb.total.tx || 0) + '(TCP)'); - } else { - rows += homeKv('公网流量', '—'); - } - return homeCard('WiFi / DNS', '
' + rows + '
'); - } - /* 蓝牙状态卡(M6.30):数据来自 overview 的 net.bt(netmgrd status pass-through)。 - * 展示:蓝牙开关/已连接/已配对/本机地址/A2DP 编解码·采样率;输出目标(DAC|蓝牙)与音量控制(avrcp/dac) - * 依赖面板后端透传 audiod 的 target/volctl 键——当前 audio_fill 未透传,缺失时优雅降级为「—」(不谎报)。 */ - function btCard() { - const nd = homeData && homeData.net; - const ad = homeData && homeData.audio; - let bt = nd && nd.bt; - if (!bt || !isAvail(nd)) return homeCard('蓝牙', homeUnavailable()); - if (bt.available === false) return homeCard('蓝牙', '
无蓝牙
'); - const on = !!bt.powered; - const conn = (bt.connected != null) ? bt.connected : 0; - let rows = ''; - rows += homeKv('蓝牙', on ? '已开启' : '已关闭'); - rows += homeKv('连接', conn > 0 ? ('已连接 ' + esc(conn) + '') : '未连接'); - rows += homeKv('已配对', bt.paired != null ? esc(bt.paired) : '—'); - if (bt.address) rows += homeKv('本机地址', esc(bt.address)); - if (conn > 0) { - rows += homeKv('A2DP 编解码', bt.a2dp_codec ? esc(bt.a2dp_codec) : '—'); - rows += homeKv('采样率', bt.a2dp_rate ? esc(bt.a2dp_rate) : '—'); - } else { - rows += homeKv('A2DP 编解码', '—'); - } - const target = ad && ad.target != null ? ad.target : null; - const volctl = ad && ad.volctl != null ? ad.volctl : null; - const targetName = target === 'bt' ? '蓝牙' : (target === 'dac' ? 'DAC' : null); - rows += homeKv('输出目标', targetName ? ('' + esc(targetName) + '') : '—'); - rows += homeKv('音量控制', volctl ? esc(volctl) : '—'); - if (ad && ad.vol != null && ad.vol >= 0) { - rows += homeKv('音量', esc(ad.vol) + ' %' + (volctl === 'avrcp' ? ' (AVRCP)' : '')); - } - return homeCard('蓝牙', '
' + rows + '
'); - } - function healthCard() { - const ams = homeData && homeData.ams; - const svcd = homeData && homeData.svcd; - let appTxt = '—', svcBad = 0, svcHi = 0, svcTxt = '—', fgHtml = ''; - if (isAvail(ams)) { - const apps = ams.apps || []; - const run = apps.filter(a => a.state === 'fg' || a.state === 'bg').length; - appTxt = run + ' 在跑 / ' + (ams.count || apps.length) + ' 已装'; - const fg = ams.fg; - if (fg && fg.pkg) { - fgHtml = '前台 ' + esc(fg.pkg) + - (fg.activity ? ' · ' + esc(fg.activity) + '' : ''); - } - } - let svcHtml; - if (isAvail(svcd)) { - const svcs = svcd.services || []; - svcBad = svcs.filter(s => s.state === 'failed').length; - svcHi = svcs.filter(s => (s.restart || 0) >= 3).length; - svcTxt = svcs.filter(s => s.state === 'running').length + ' 运行中'; - svcHtml = ''; - if (svcBad) svcHtml += '' + svcBad + ' FAILED '; - if (svcHi) svcHtml += '' + svcHi + ' 高重启 '; - if (!svcBad && !svcHi) svcHtml += '正常 '; - } else { - svcHtml = '不可用 '; - svcTxt = '—'; - } - return homeCard('在跑 App 与服务', '
' + - homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) + - (fgHtml ? homeKv('前台应用', fgHtml) : '') + '
'); - } - function storageCard() { - const perf = homeData && homeData.perf; - if (!isAvail(perf)) return homeCard('存储', homeUnavailable()); - const st = perf.storage || {}, tf = perf.tfcard || {}; - let rows = homeKv('rootfs', (st.used != null ? fmtBytes(st.used * 1024) : '—') + ' / ' + (st.total != null ? fmtBytes(st.total * 1024) : '—')); - rows += (tf.present) ? - homeKv('TF 卡', fmtBytes((tf.used || 0) * 1024) + ' / ' + fmtBytes((tf.total || 0) * 1024)) : - homeKv('TF 卡', '未插卡'); - return homeCard('存储', '
' + rows + '
'); - } - function perfCard() { - const perf = homeData && homeData.perf; - if (!isAvail(perf)) return homeCard('性能小条', homeUnavailable()); - const cpu = perf.cpu || {}, mem = perf.memory || {}, tmp = perf.temp || {}; - const cpuU = cpu.usage; - const memP = mem.total ? (mem.used / mem.total) * 100 : 0; - return homeCard('性能小条', '
' + - '
CPU
' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '%
' + barHtml(cpuU || 0) + '
' + - '
内存
' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '
' + barHtml(mem.used && mem.total ? memP : 0) + '
' + - '
温度
' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '℃' : '—') + '
' + - '
'); - } - /* 首页功耗摘要条(est_mw + breakdown:复用 estimateCardBody/breakdownCardBody 保持一致口径,诚实标注 confidence)。 - * 上游 power 不可用 → 不渲染该条(绝不假数据);HibyOS 只读 degraded → 显示说明。 */ - function powerSummaryCard() { - const pw = homeData && homeData.power; - if (!isAvail(pw)) return ''; - const est = pw.estimate; - if (!est) return ''; - if (est.est_mw == null) return homeCard('功耗视角', '
' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '
'); - return homeCard('功耗视角', estimateCardBody(est) + (pw.breakdown ? breakdownCardBody(pw.breakdown) : '')); - } - /* 最近异常事件条(power.events 摘要 top3 进 overview;完整列表走 /api/power/events)。 */ - function eventsBar() { - const ev = homeData && homeData.events; - if (!ev || ev.available === false) return ''; - const list = (ev && ev.events) || []; - if (!list.length) return ''; - const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' }; - let items = list.slice(0, 3).map(e => { - const tn = typeLabel[e[1]] || e[1]; - const txt = String(e[3] == null ? '' : e[3]); - const cls = (e[1] === 'lowbat') ? 'warn' : (e[1] === 'charger' ? 'ok' : 'mut'); - return '' + esc(tn) + (txt ? ' · ' + esc(txt.length > 28 ? txt.slice(0, 28) + '…' : txt) : '') + ''; - }).join(' '); - return '

最近事件

' + items + '
'; - } - function renderHomeCards() { - const body = $('#home-body'); - if (!body || state.route !== 'home') return; - if (!homeData) { body.innerHTML = '

加载总览…

'; return; } - let h = powerSummaryCard() + eventsBar(); // 摘要条(功耗/事件)置顶 - h += '
' + - deviceCard() + batteryCard() + audioCard() + netCard() + btCard() + healthCard() + storageCard() + perfCard() + - '
'; - body.innerHTML = h; - const ad = homeData.audio; - if (isAvail(ad)) updateLevelMeter($('#home-audio-level'), ad); - } - async function fetchHomeSystem() { // 静态设备信息(boot/默认启动);进入时拉一次,失败不阻塞 - try { homeSys = await api('GET', '/api/system'); } catch (e) { homeSys = null; } - if (state.route === 'home') renderHomeCards(); - } - async function fetchHomeLevel() { // 电平 1s 子节拍(独立于 overview 5s) - if (state.route !== 'home') return; - const el = $('#home-audio-level'); if (!el) return; - let d; - try { d = await api('GET', '/api/audio'); } catch (e) { return; } - if (state.route !== 'home') return; - updateLevelMeter(el, d); - const note = $('#home-audio-note'); - if (note) note.textContent = (!d || !d.available) ? 'audiod 不可用' : (d.active ? '电平 · 1s 子节拍' : '无流'); - } - async function fetchOverviewOnce() { - if (state.route !== 'home') return; - const r = await Promise.allSettled([api('GET', '/api/overview')]); - if (r[0].status !== 'fulfilled') { - const b = $('#home-banner'); - if (b) { - b.innerHTML = '⚠️总览加载失败:' + esc(errText(r[0].reason)) + ''; - b.hidden = false; - } - return; - } - const b = $('#home-banner'); if (b) { b.hidden = true; b.innerHTML = ''; } - homeData = r[0].value; // 首页严格单请求:DNS 已并入 overview 的 net 块 - renderHomeCards(); - } - function renderHome() { - stopHome(); - resetSpark(); // 进入首页重置 sparkline 累积 - homeData = null; homeSys = null; - $('#content').innerHTML = - '
' + - '' + - '

加载总览…

' + - '
'; - fetchHomeSystem(); - fetchOverviewOnce(); - homeTimer = setInterval(fetchOverviewOnce, 5000); - homeLevelTimer = setInterval(fetchHomeLevel, 1000); - } - function bindHome() { - $('#content').addEventListener('click', function (e) { - if (e.target.closest('#home-retry')) { fetchOverviewOnce(); return; } - }); - } - - /* ---------------- 2e. 电源 / 功耗页(#/power;/api/power,只读) ---------------- - * 电池卡 + 功耗估算卡(est_mw + confidence 徽标,必须如实标注)+ 子系统分解堆叠条。 */ - let powerTimer = null; - function stopPower() { if (powerTimer) { clearInterval(powerTimer); powerTimer = null; } } - function batteryCardBody(b) { - if (!b) return homeUnavailable(); - const pct = (b.pct != null) ? b.pct : null; - const state = b.state || '—'; - const charging = (state === 'Charging' || state === 'Full'); - const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : '')); - let h = '
' + - '' + (pct == null ? '—' : pct) + '%' + - '' + esc(state) + '
'; - h += '' + - row('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') + - row('健康', b.health ? esc(b.health) : '—') + - row('技术', b.technology ? esc(b.technology) : '—') + - row('电池温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') + - row('充电器温度', b.charger_temp_c10 != null ? (b.charger_temp_c10 / 10).toFixed(1) + ' ℃' : '—') + - row('循环次数', b.cycle_count != null ? b.cycle_count : '—') + - row('放电速率', b.rate_pct_min_x100 != null ? ((b.rate_pct_min_x100 / 100).toFixed(2) + ' %/min') : '—') + - row('续航估算', b.est_minutes_left != null ? (b.est_minutes_left + ' 分钟(剩余)') : (b.est_minutes_full != null ? (b.est_minutes_full + ' 分钟(充满)') : '—')) + - '
'; - return h; - } - function estimateCardBody(est) { - if (!est) return homeUnavailable(); - if (est.est_mw == null) return '
' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '
'; - const conf = est.confidence || 'estimated'; - const cls = conf === 'measured' ? 'measured' : (conf === 'derived' ? 'derived' : (conf === 'none' ? 'none' : 'estimated')); - return '
' + est.est_mw + 'mW' + - '' + esc(conf) + (est.calibrated ? ' · 已标定' : ' · 经验值') + '
' + - '

Model 系数全部为经验初值(未真机标定);confidence 必须如实标注。

'; - } - function breakdownCardBody(bd) { - if (!bd) return homeUnavailable(); - if (bd.degraded) return '
系统分解不可用(HibyOS 只读模式)
'; - const comps = [ - { label: '屏幕(背光)', mw: bd.backlight_mw, color: '#4a8bf5' }, - { label: 'WiFi', mw: bd.wifi_mw, color: '#00bfa5' }, - { label: '蓝牙', mw: bd.bt_mw, color: '#ab47bc' }, - { label: '音频', mw: bd.audio_mw, color: '#ffb300' }, - { label: 'CPU', mw: bd.cpu_mw, color: '#ff7043' }, - { label: '基线', mw: bd.baseline_mw, color: '#8d6e63' } - ].filter(c => c.mw != null && c.mw >= 0); - const total = comps.reduce((a, c) => a + c.mw, 0); - let stack = '
'; - comps.forEach(c => { stack += ''; }); - stack += '
'; - let legend = '
'; - comps.forEach(c => { - legend += '' + esc(c.label) + ' ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)'; - }); - legend += '
'; - if (bd.cpu_util_pct != null || bd.brightness_pct != null || bd.wifi_state || bd.bt_state || bd.audio_state) { - legend += '

' + - (bd.cpu_util_pct != null ? 'CPU 利用率 ' + esc(bd.cpu_util_pct) + '% ' : '') + - (bd.brightness_pct != null ? '· 亮度 ' + esc(bd.brightness_pct) + '% ' : '') + - (bd.wifi_state ? '· WiFi ' + esc(bd.wifi_state) : '') + - (bd.bt_state ? ' · BT ' + esc(bd.bt_state) : '') + - (bd.audio_state ? ' · 音频 ' + esc(bd.audio_state) : '') + '

'; - } - return '
合计 ' + total + ' mW(分解为经验估算)
' + stack + legend; - } - let powerEditing = false; // 用户在编辑策略表单时暂停 5s 重渲染(防输入被轮询清空) - /* 电源策略卡:profile 四档 + 低电/临界阈值 + 亮度/息屏上限。前端确认后 POST /api/power/policy。 - * 默认全关(enacted=0)如实标注;note 透传 powermgr 口径(只写 power.* 键,不直接写系统)。 */ - function powerProfileLabel(p) { - return { performance: '性能', balanced: '均衡', powersave: '省电', ultra: '超省电' }[p] || p; - } - function powerPolicyCard(pol) { - if (!pol) return '

电源策略

' + homeUnavailable() + '
'; - const profiles = ['performance', 'balanced', 'powersave', 'ultra']; - const cur = pol.profile || 'balanced'; - let btns = profiles.map(p => '').join(' '); - return '

电源策略

' + - '
省电档位:' + btns + '
' + - '' + - row('低电阈值', ' %') + - row('临界阈值', ' %') + - row('亮度上限', '0=不生效') + - row('息屏上限', '秒 · 0=不生效') + - row('状态', (pol.enacted ? '已手动开启' : '默认全关')) + - '
' + - '
切档即时生效(需确认);阈值点「保存阈值」。
' + - '

' + esc(pol.note || '') + '

' + - '
'; - } - function powerEventsCard(ev) { - if (!ev) return ''; - if (ev.available === false) return '

电源事件

' + homeUnavailable() + '
'; - const list = (ev && ev.events) || []; - if (!list.length) return '

电源事件

暂无事件
'; - const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' }; - let rows = list.map(e => { - const tn = typeLabel[e[1]] || e[1]; - return '' + esc(tn) + '' + (e[2] != null ? esc(e[2]) + '%' : '—') + '' + esc(e[3] || '') + ''; - }).join(''); - return '

电源事件

' + - '' + rows + '
类型电量说明
'; - } - function powerHistoryCard(h) { - if (!h) return ''; - if (h.available === false) return '

功耗历史

' + homeUnavailable() + '
'; - let body = '' + - row('采样窗口', (h.n_samples || 0) + ' 点 · ' + fmtDur(h.window_sec)) + - row('电量范围', (h.min_pct != null ? h.min_pct : '—') + '% ~ ' + (h.max_pct != null ? h.max_pct : '—') + '%') + - row('起止电量', (h.start_pct != null ? h.start_pct : '—') + '% → ' + (h.end_pct != null ? h.end_pct : '—') + '%') + - row('平均功耗', (h.avg_mw != null ? h.avg_mw + ' mW' : '—')) + - '
'; - return '

功耗历史

' + body + '
'; - } - function renderPowerBody(d, ev, hi) { - let h = '
'; - if (d && d.mode === 'readonly') h += '
ℹ️当前为电源只读观测(HibyOS 模式),仅显示电池采样,无功耗估算/分解。
'; - h += '

电池

' + ((d && d.battery) ? batteryCardBody(d.battery) : homeUnavailable()) + '
'; - h += '

功耗估算

' + ((d && d.estimate) ? estimateCardBody(d.estimate) : homeUnavailable()) + '
'; - h += '

子系统分解

' + ((d && d.breakdown) ? breakdownCardBody(d.breakdown) : homeUnavailable()) + '
'; - h += ((d && d.policy) ? powerPolicyCard(d.policy) : '

电源策略

' + homeUnavailable() + '
'); - h += powerEventsCard(ev); - h += powerHistoryCard(hi); - h += '
'; - return h; - } - let powerData = null, powerEvents = null, powerHistory = null; - async function fetchPowerOnce() { - const el = $('#power-page'); - if (!el || state.route !== 'power') return; - let d, ev, hi; - const r = await Promise.allSettled([api('GET', '/api/power'), api('GET', '/api/power/events'), api('GET', '/api/power/history')]); - if (r[0].status !== 'fulfilled') { - if (el && state.route === 'power') el.innerHTML = '
' + emptyState('加载失败:' + errText(r[0].reason), (r[0].reason && r[0].reason.code) || '') + '
'; - return; - } - if (state.route !== 'power') return; - d = r[0].value; ev = (r[1].status === 'fulfilled') ? r[1].value : null; hi = (r[2].status === 'fulfilled') ? r[2].value : null; - if (!powerEditing) el.innerHTML = renderPowerBody(d, ev, hi); - } - function renderPower() { - stopPower(); - powerEditing = false; - powerData = null; powerEvents = null; powerHistory = null; - $('#content').innerHTML = '

加载电源数据…

'; - fetchPowerOnce(); - powerTimer = setInterval(fetchPowerOnce, 5000); - } - function bindPower() { - $('#content').addEventListener('click', async function (e) { - const pb = e.target.closest('.pol-btn'); - if (pb) { - const p = pb.dataset.pol; - const ok = await confirmDlg('切换省电档位', '确认将省电档位切换为「' + powerProfileLabel(p) + '」?(写 settings.conf power.profile,launcher/sysui 期4消费生效)', false); - if (!ok) { powerEditing = false; fetchPowerOnce(); return; } - powerEditing = false; - try { await api('POST', '/api/power/policy', { item: 'profile', value: p }); toast('已切档:' + powerProfileLabel(p)); } - catch (err) { toast('切档失败:' + errText(err)); } - fetchPowerOnce(); - return; - } - const apply = e.target.closest('#pol-apply'); - if (apply) { - const low = parseInt($('#pol-low') ? $('#pol-low').value : '', 10); - const crit = parseInt($('#pol-crit') ? $('#pol-crit').value : '', 10); - const bri = parseInt($('#pol-bri') ? $('#pol-bri').value : '', 10); - const scr = parseInt($('#pol-scr') ? $('#pol-scr').value : '', 10); - if (isNaN(low) || isNaN(crit)) { toast('请输入有效的低电/临界阈值'); return; } - const ok = await confirmDlg('保存电源阈值', '确认写入 低电=' + low + '% / 临界=' + crit + '% / 亮度上限=' + (isNaN(bri) ? 0 : bri) + ' / 息屏上限=' + (isNaN(scr) ? 0 : scr) + 's?', false); - if (!ok) { powerEditing = false; fetchPowerOnce(); return; } - powerEditing = false; - try { - await api('POST', '/api/power/policy', { item: 'low-pct', value: low }); - await api('POST', '/api/power/policy', { item: 'crit-pct', value: crit }); - await api('POST', '/api/power/policy', { item: 'bri-cap', value: bri || 0 }); - await api('POST', '/api/power/policy', { item: 'scr-cap', value: scr || 0 }); - toast('电源阈值已保存'); - } catch (err) { toast('保存失败:' + errText(err)); } - fetchPowerOnce(); - return; - } - }); - $('#content').addEventListener('focusin', function (e) { - if (e.target.closest('.pol-num')) powerEditing = true; // 编辑中暂停 5s 重渲染(防输入被清空) - }); - } - - /* ---------------- 2f. 进程 / 服务页(#/proc;/api/svcd + /api/ams,只读,无 restart/kill) ---------------- - * svcd 服务表(state/pid/restart 计数≥3 高亮/exec/来源 board|tf)+ ams App 表(state/pid/crashes/launches/last_kind); - * 行点开经 /api/files 列 MeowOS/logs// 找最新 stdout_*.log,再用 /api/logs/read 读尾部(崩溃尾巴)。 */ - let procTimer = null; - let expandedProc = null; // { pkg, file, lines, truncated }:已展开的 App stdout 尾部(重渲染后重开,防轮询清空) - function stopProc() { if (procTimer) { clearInterval(procTimer); procTimer = null; } } - function svcStatePill(s) { - const map = { running: ['ok', '运行'], stopped: ['mut', '已停'], stopping: ['warn', '停止中'], disabled: ['mut', '禁用'], failed: ['bad', '失败'] }; - const m = map[s] || ['mut', s || '—']; - return '' + esc(m[1]) + ''; - } - function appStatePill(s) { - const map = { fg: ['ok', '前台'], bg: ['warn', '后台'], stop: ['mut', '已停'] }; - const m = map[s] || ['mut', s || '—']; - return '' + esc(m[1]) + ''; - } - function srcPill(src) { - const cls = (src === 'board') ? 'board' : (src === 'tf' ? 'tf' : ''); - return src ? '' + esc(src) + '' : '—'; - } - function renderProcBody(ams, svcd) { - let h = '
'; - h += '

服务(meow_svcd)

'; - if (svcd && svcd.available) { - const svcs = svcd.services || []; - h += ''; - if (!svcs.length) h += ''; - svcs.forEach(s => { - h += '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - }); - h += '
服务状态PID重启退避触发来源退出执行
暂无服务
' + esc(s.name) + '' + svcStatePill(s.state) + '' + (s.pid > 0 ? esc(s.pid) : '—') + '' + (s.restart || 0) + ((s.restart || 0) >= 3 ? ' ⚠' : '') + '' + esc(s.backoff_ms || '—') + '' + esc(s.trigger || '—') + '' + srcPill(s.src) + '' + esc(s.exit || '—') + '' + esc(s.exec || '—') + '
'; - } else h += homeUnavailable(); - h += '
'; - h += '

App(meow_amsd,只读)

'; - if (ams && ams.available) { - const apps = ams.apps || []; - h += ''; - if (!apps.length) h += ''; - apps.forEach(a => { - const openHere = expandedProc && expandedProc.pkg === a.pkg; - h += '' + - '' + - '' + - '' + - '' + - ''; - if (openHere) { - const lines = (expandedProc.lines || []); - h += ''; - } - }); - h += '
包名名称状态PID启动崩溃上次退出操作
无可观测 App
' + esc(a.pkg) + '' + esc(a.name || '—') + '' + appStatePill(a.state) + '' + (a.pid > 0 ? esc(a.pid) : '—') + '' + esc(a.launches || 0) + '' + (a.crashes || 0) + ((a.crashes || 0) > 0 ? ' ⚠' : '') + '' + esc(a.last_kind || '—') + (a.last_code != null && a.last_code !== 0 ? ' (' + esc(a.last_code) + ')' : '') + '
' + - '' + - '' + - (a.pid > 0 ? '' : '') + - '
' + esc(lines.join('\n') || '(空)') +
-            (expandedProc.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '
' + - '
' + esc(a.pkg + '/' + expandedProc.file) + ' · 末尾 ' + lines.length + ' 行
'; - h += '

「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。「启动/结束」经 amsd(require_auth);结束前台 App 需二次确认。

'; - } else h += homeUnavailable(); - h += '
'; - return h; - } - async function newestStdout(pkg) { - try { - const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + pkg + '/')); - const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0); - if (!files.length) return null; - files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); - return files[0].name; - } catch (e) { return null; } - } - async function viewAppStdout(pkg, btn) { - const row = btn.closest('tr'); if (!row) return; - const next = row.nextElementSibling; - if (next && next.classList && next.classList.contains('proc-tail')) { next.remove(); expandedProc = null; btn.textContent = 'stdout 尾部'; return; } - if (expandedProc && expandedProc.pkg !== pkg) { // 先收起上一个 opened - const prevTail = document.querySelector('tr.proc-tail'); - if (prevTail) prevTail.remove(); - const prevBtn = document.querySelector('[data-applog="' + escSafeAttr(expandedProc.pkg) + '"]'); - if (prevBtn) prevBtn.textContent = 'stdout 尾部'; - expandedProc = null; - } - btn.textContent = '加载中…'; - const tailTr = document.createElement('tr'); - tailTr.className = 'proc-tail'; - tailTr.innerHTML = '
读取 ' + esc(pkg) + ' 最新 stdout…
'; - row.after(tailTr); - try { - const file = await newestStdout(pkg); - if (!file) { tailTr.innerHTML = '
未找到 ' + esc(pkg) + ' 的 stdout 日志
'; btn.textContent = 'stdout 尾部'; return; } - const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(pkg + '/' + file) + '&lines=200'); - const lines = (d.lines || []); - expandedProc = { pkg: pkg, file: file, lines: lines, truncated: !!d.truncated }; - tailTr.innerHTML = '
' + esc(lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '
' + - '
' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行
'; - btn.textContent = '收起'; - } catch (err) { - tailTr.innerHTML = '
读取失败:' + esc(errText(err)) + '
'; - btn.textContent = 'stdout 尾部'; - } - } - function escSafeAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); } - async function fetchProcOnce() { - const el = $('#proc-page'); - if (!el || state.route !== 'proc') return; - const r = await Promise.allSettled([api('GET', '/api/ams'), api('GET', '/api/svcd')]); - if (state.route !== 'proc') return; - if (r[0].status !== 'fulfilled' && r[1].status !== 'fulfilled') { - el.innerHTML = '
' + emptyState('加载失败:' + errText(r[0].reason || r[1].reason)) + '
'; - return; - } - el.innerHTML = renderProcBody(r[0].status === 'fulfilled' ? r[0].value : null, r[1].status === 'fulfilled' ? r[1].value : null); - } - function renderProc() { - stopProc(); - $('#content').innerHTML = '

加载服务与进程…

'; - fetchProcOnce(); - procTimer = setInterval(fetchProcOnce, 5000); - } - function bindProc() { - $('#content').addEventListener('click', async function (e) { - const b = e.target.closest('[data-applog]'); - if (b) { viewAppStdout(b.dataset.applog, b); return; } - const st = e.target.closest('[data-amsstart]'); - if (st) { - const pkg = st.dataset.amsstart; - try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/start'); toast('已请求启动 ' + pkg); } - catch (err) { toast('启动失败:' + errText(err)); } - fetchProcOnce(); - return; - } - const kl = e.target.closest('[data-amskill]'); - if (kl) { - const pkg = kl.dataset.amskill, fg = kl.dataset.fg === '1'; - const ok = await confirmDlg('结束 App', (fg ? '『' + pkg + '』正位于前台,' : '') + '确认结束 ' + pkg + '?(SIGTERM 语义)', true); - if (!ok) return; - try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/kill'); toast('已请求结束 ' + pkg); } - catch (err) { toast('结束失败:' + errText(err)); } - fetchProcOnce(); - return; - } - }); - } - - /* ---------------- 2c. 网络管理页(meow_netmgrd 期2 面板侧) ---------------- - * 数据源:GET /api/net/status(E0 接口+WiFi/BT 摘要)、/api/net/conns?entity=(E8 连接表)、 - * /api/net/dns(EC DNS 统计)、/api/net/dns/flush(EC flush 写级)、/api/net/log(net_*.log tail)。 - * netmgrd 不在 → 后端 503(NET_UNAVAILABLE)→ 本页显示「网络管理服务不可用」占位,不白屏。 - * status 成功即开始 5s 轮询(接口/WiFi/BT + 连接表 + DNS 统计);日志仅在进入页面/点「刷新」时拉取。 */ - let netTimer = null; - let netEntityFilter = ''; - function stopNet() { if (netTimer) { clearInterval(netTimer); netTimer = null; } } - function fmtDur(sec) { // 连接寿命(秒级精度) - if (sec == null || isNaN(sec)) return '—'; - sec = Math.floor(sec); - if (sec < 60) return sec + ' 秒'; - const m = Math.floor(sec / 60), s = sec % 60; - if (m < 60) return m + ' 分 ' + s + ' 秒'; - const h = Math.floor(m / 60), mm = m % 60; - return h + ' 时 ' + mm + ' 分'; - } - - function netBadge(e) { - const i = e.indexOf(':'); - const kind = i > 0 ? e.slice(0, i) : 'proc'; - return '' + esc(kind) + '' + - (i > 0 ? ' ' + esc(e.slice(i + 1)) + '' : ''); - } - function netStateCls(st) { - if (!st) return ''; - if (st === 'ESTABLISHED') return 'net-state ok'; - if (st === 'LISTEN') return 'net-state listen'; - if (st === 'UDP') return 'net-state listen'; - return 'net-state warn'; - } - - /* 网络接口卡(/api/net/ifaces:kind/ip/gw/rssi/rfkill/dns + RX/TX)。 - * enforce 执行语义如实:仅 DNS 层拒绝(SOCK_DESTROY 在本内核为 no-op),绝不谎称「已断连」。 */ - function netIfaceCards(ifd) { - if (!ifd || ifd.available === false) return '

网络接口

' + homeUnavailable() + '
'; - const ifs = (ifd && ifd.ifaces) || []; - let h = '

网络接口

'; - let meta = '
'; - meta += (ifd.rssi != null) ? '信号 ' + esc(ifd.rssi) + ' dBm' : '信号 —'; - if (ifd.dns && ifd.dns.length) meta += ' · DNS ' + esc(ifd.dns.join(' / ')); - meta += '
'; - h += '

RX / TX 为接口累计字节(含局域网/面板轮询/DNS 等内网往返),非手机热点公网消耗。

'; - h += meta; - h += ''; - if (!ifs.length) h += ''; - ifs.forEach(function (f) { - const up = !!f.up; - h += '' + - '' + - '' + - ''; - }); - h += '
接口类型状态IP 地址网关RX / TX
无接口数据
' + esc(f.name) + '' + esc(f.kind || '—') + '' + (up ? 'UP' : 'DOWN') + '' + esc(f.ip || '—') + '' + esc(f.gw || '—') + '' + fmtBytes(f.rx_bytes) + ' / ' + fmtBytes(f.tx_bytes) + '
'; - const rf = (ifd && ifd.rfkill) || []; - if (rf.length) { - h += '
'; - rf.forEach(r => h += '' + esc(r.type) + (r.soft ? ' 软关' : '') + (r.hard ? ' 硬关' : '') + ''); - h += '
'; - } - h += '
'; - return h; - } - /* 公网流量卡(netmgrd pub_bytes:netlink sock_diag 按 TCP 连接累积,远端公网/内网分流)。 - * 口径诚实:仅 TCP;UDP/DNS/mDNS/ARP 不计;内网本链路(面板轮询等)归 lan。 */ - function pubBytesCard(pb) { - if (!pb) return ''; - const t = pb.total || {}; - let h = '

公网流量

'; - h += '

按 TCP 连接计量(远端公网才计入);UDP/DNS/mDNS/ARP 不计。接口累计含内网往返,非公网消耗。

'; - h += '
公网累计:↓ ' + fmtBytes(t.rx || 0) + ' · ↑ ' + fmtBytes(t.tx || 0) + '
'; - const ents = pb.by_entity || []; - if (ents.length) { - h += ''; - ents.forEach(e => { - h += '' + - '' + - ''; - }); - h += '
实体公网 ↓ / ↑内网 ↓ / ↑
' + netBadge(e.entity) + '' + fmtBytes((e.pub && e.pub[0]) || 0) + ' / ' + fmtBytes((e.pub && e.pub[1]) || 0) + '' + fmtBytes((e.lan && e.lan[0]) || 0) + ' / ' + fmtBytes((e.lan && e.lan[1]) || 0) + '
'; - } - const flows = pb.by_flow || []; - if (flows.length) { - h += '
公网流 Top
'; - h += ''; - flows.forEach(f => { - h += '' + - '' + - ''; - }); - h += '
远端实体↓ / ↑
' + esc(f.ip || '') + ':' + esc('' + f.port) + - (f.domain ? ' ' + esc(f.domain) + '' : '') + '' + netBadge(f.entity) + '' + fmtBytes(f.rx || 0) + ' / ' + fmtBytes(f.tx || 0) + '
'; - } - h += '
'; - return h; - } - - /* WiFi / 蓝牙摘要(/api/net/status:E0 概览,含实时速率)。接口细分已迁 netIfaceCards。 */ - function netStatusCards(d) { - let h = ''; - if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null)) - h += '

实时速率(上行接口,不含回环):↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s

'; - if (d && d.pub_bytes) h += pubBytesCard(d.pub_bytes); - // WiFi 摘要 - const w = (d && d.wifi) || {}; - h += '

WiFi

'; - if (w.connected) { - h += row('SSID', '' + esc(w.ssid || '—') + ''); - h += row('状态', '' + esc(w.state || '已连接') + ''); - h += row('IP', esc(w.ip || '—')); - h += row('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—')); - } else { - h += row('状态', '未连接'); - } - h += '
'; - // 蓝牙摘要 - const bt = (d && d.bt) || {}; - h += '

蓝牙

'; - if (bt.available) { - h += row('状态', '' + (bt.powered ? '已开启' : '已关闭') + ''); - h += row('地址', esc(bt.address || '—')); - h += row('已配对', esc(bt.paired != null ? bt.paired : '—')); - h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—')); - } else { - h += row('状态', '无蓝牙'); - } - h += '
'; - return h; - } - - /* 网络策略卡(/api/net/policy;写走 POST /api/net/policy,require_auth + 二次确认)。 - * enforce 如实显示(dns_only):仅 DNS 层拒绝,不会真断连。 */ - function netPolicyCard(pol) { - if (!pol || pol.available === false) return '

网络策略

' + homeUnavailable() + '
'; - const enforce = pol.enforce || ''; - const dnsOnly = enforce === 'dns_only'; - let h = '

网络策略

'; - h += '
' + (dnsOnly ? 'ℹ️' : '⚠️') + '' + - (dnsOnly ? '执行模式:仅 DNS 层拒绝(enforce=dns_only;本内核 SOCK_DESTROY 为 no-op,不会真正断连)' : '执行模式:' + esc(enforce)) + '
'; - const rules = pol.rules || []; - if (!rules.length) h += '
暂无策略规则(默认放行)
'; - else { - h += ''; - rules.forEach(r => { - h += '' + - '' + - '' + - '' + - ''; - }); - h += '
实体网络白名单域名黑名单域名操作
' + netBadge(r.entity) + '' + (r.net === 'block' ? '阻断' : '放行') + '' + esc((r.allow_hosts || []).join(', ') || '—') + '' + esc((r.deny_hosts || []).join(', ') || '—') + '
' + - '' + - '
'; - } - h += '
' + - '' + - '
'; - h += '
' + - '
'; - h += '

防自锁实体(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)拒 block。

'; - h += '
'; - return h; - } - - function netConnsBody(cn) { - const list = (cn && cn.conns) || []; - let h = ''; - /* 连接 top 摘要(聚合计数,轻量——overview 与网速页的观察摘要,非完整归因)。 */ - const entCnt = {}; - (list || []).forEach(c => { entCnt[c.entity] = (entCnt[c.entity] || 0) + 1; }); - const top = Object.keys(entCnt).sort((a, b) => entCnt[b] - entCnt[a]).slice(0, 3); - if (top.length) { - h += '
连接 top:' + top.map(e => '' + netBadge(e) + ' ×' + entCnt[e] + '').join(' · ') + '(共 ' + list.length + ' 条)
'; - } - if (!list.length) h += '
暂无连接' + (netEntityFilter ? '(该实体)' : '') + '
'; - else { - h += ''; - list.forEach(function (c) { - h += '' + - '' + - '' + - '' + - ''; - }); - h += '
实体远端(域名)本端状态寿命
' + netBadge(c.entity) + '' + esc(c.domain || c.remote || '—') + '' + esc(c.local || '—') + '' + esc(c.state || '—') + '' + fmtDur(c.age) + '
'; - if (cn.truncated) h += '

连接表超出单帧上限,已截断(' + (cn.count || list.length) + ' 条)

'; - } - return h; - } - - function netDnsBody(dn) { - if (!dn) return '
DNS 代理不可用
'; - let h = '' + - row('状态', '' + (dn.enabled ? '已启用' : '已停用') + '') + - row('模式', esc(dn.mode || '—')) + - row('上游', esc(dn.upstream || '—')) + - row('缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0)) + - row('查询数', esc(dn.queries)) + - row('拦截 (deny)', esc(dn.denies)) + - row('首次域名', esc(dn.first_seen)) + - row('resolv.conf', (dn.resolv_modified ? '已被代理改写' : '未改写')) + - '
'; - h += '

写操作(清缓存)需系统身份,面板路径可能被 netmgrd 拒绝。

'; - return h; - } - - // 网络状态(/api/net/status)瞬态失败 → 仅顶部内联错误条,不整页踢「不可用」占位;保留上次成功内容,5s 自动重试。 - function netBannerErr(msg) { - const nb = $('#net-banner'); - if (!nb) return; - nb.innerHTML = '⚠️网络状态获取失败:' + esc(msg) + '(保留上次内容,5 秒后自动重试)' + - ''; - nb.hidden = false; - } - - let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义) - async function fetchNetOnce() { - if (state.route !== 'net') return; - const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body'), - ifEl = $('#net-ifaces'), poEl = $('#net-policy'); - let st, cn, dn, ifd, pol; - try { st = await api('GET', '/api/net/status'); } - catch (e) { netBannerErr(errText(e)); return; } - netLoaded = true; - const nb = $('#net-banner'); if (nb) { nb.hidden = true; nb.innerHTML = ''; } - try { cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); } - catch (e) { cn = null; } - try { dn = await api('GET', '/api/net/dns'); } - catch (e) { dn = null; } - try { ifd = await api('GET', '/api/net/ifaces'); } catch (e) { ifd = null; } - try { pol = await api('GET', '/api/net/policy'); } catch (e) { pol = null; } - if (state.route !== 'net') return; - if (ifEl) ifEl.innerHTML = netIfaceCards(ifd); - if (stEl) stEl.innerHTML = netStatusCards(st); - if (poEl) poEl.innerHTML = netPolicyCard(pol); - if (cbEl) cbEl.innerHTML = netConnsBody(cn); - if (dbEl) dbEl.innerHTML = netDnsBody(dn); - } - - async function fetchNetConns() { - if (state.route !== 'net') return; - const cbEl = $('#net-conns-body'); if (!cbEl) return; - let cn; - try { - cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); - } catch (e) { cn = null; } - if (state.route !== 'net') return; - cbEl.innerHTML = netConnsBody(cn); - } - - async function fetchNetLog() { - if (state.route !== 'net') return; - const el = $('#net-log-body'); - try { - const d = await api('GET', '/api/net/log'); - if (el) { - if (!d || !d.file) { el.innerHTML = '
暂无 net_ 日志' + (d && !d.file ? '(netmgrd 未写日志)' : '') + '
'; return; } - el.innerHTML = '
' + esc((d.lines || []).join('\n') || '(空)') +
-          (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '
'; - const name = $('#net-log-file'); if (name) name.textContent = d.file; - } - } catch (e) { if (el) el.innerHTML = '
日志加载失败:' + esc(errText(e)) + '
'; } - } - - async function renderNet() { - stopNet(); - netEntityFilter = ''; - netLoaded = false; - $('#content').innerHTML = - '
' + - '' + - '

加载网络接口…

' + - '

加载网络状态…

' + - '

加载网络策略…

' + - '

每实体连接表

' + - '
' + - '' + - '
' + - '

加载连接表…

' + - '

DNS 代理

' + - '
' + - '
' + - '

加载 DNS 统计…

' + - '

网络日志

' + - '
' + - '

加载日志…

' + - '
'; - netTimer = setInterval(fetchNetOnce, 5000); // status 仅瞬态横幅,不停止轮询 - fetchNetOnce(); // 填充 status + conns + dns - fetchNetLog(); - } - - function bindNet() { - $('#content').addEventListener('input', function (e) { - const f = e.target.closest('#net-entity-filter'); if (!f) return; - netEntityFilter = f.value.trim(); - const cb = $('#net-conns-body'); if (cb) cb.textContent = '筛选…'; - fetchNetConns(); - }); - $('#content').addEventListener('click', async function (e) { - if (e.target.closest('#net-conns-refresh')) { fetchNetConns(); return; } - if (e.target.closest('#net-log-refresh')) { fetchNetLog(); return; } - if (e.target.closest('#net-banner-retry') || e.target.closest('#net-retry')) { fetchNetOnce(); return; } - const flush = e.target.closest('#net-dns-flush'); - if (flush) { - flush.disabled = true; - try { - const d = await api('POST', '/api/net/dns/flush'); - toast('已清空 DNS 缓存(' + (d && d.entries != null ? d.entries : '') + ' 条)'); - } catch (err) { toast('清缓存失败:' + errText(err)); } - flush.disabled = false; - fetchNetOnce(); - } - /* ---- 网络策略写(require_auth + 二次确认;enforce=dns_only 如实显示,不谎称断连) ---- */ - const tgl = e.target.closest('[data-ptoggle]'); - if (tgl) { - const ent = tgl.dataset.ptoggle, net = tgl.dataset.net; - const ok = await confirmDlg('网络策略', '确认将 ' + ent + ' 网络策略设为「' + (net === 'block' ? '阻断(DNS 层拒绝,不会真断连)' : '放行') + '」?', net === 'block'); - if (!ok) { fetchNetOnce(); return; } - try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: net }); toast('已更新策略:' + ent); } - catch (err) { toast('策略更新失败:' + errText(err)); } - fetchNetOnce(); - return; - } - const del = e.target.closest('[data-pdel]'); - if (del) { - const ent = del.dataset.pdel; - const ok = await confirmDlg('删除策略', '确认删除 ' + ent + ' 的全部策略规则(恢复默认放行)?', true); - if (!ok) { fetchNetOnce(); return; } - try { await api('POST', '/api/net/policy', { op: 'del', entity: ent }); toast('已删除策略:' + ent); } - catch (err) { toast('删除失败:' + errText(err)); } - fetchNetOnce(); - return; - } - const blk = e.target.closest('#pol-block'); - if (blk) { - const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : ''); - if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; } - const ok = await confirmDlg('阻断实体', '确认将 ' + ent + ' 设为阻断(DNS 层拒绝,不会真断连)?', true); - if (!ok) { fetchNetOnce(); return; } - try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'block' }); toast('已阻断:' + ent); } - catch (err) { toast('阻断失败:' + errText(err)); } - fetchNetOnce(); - return; - } - const alw = e.target.closest('#pol-allow'); - if (alw) { - const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : ''); - if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; } - try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'allow' }); toast('已放行:' + ent); } - catch (err) { toast('放行失败:' + errText(err)); } - fetchNetOnce(); - return; - } - const dom = e.target.closest('#pol-dom'); - if (dom) { - const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : ''); - const hst = ($('#pol-domain') ? $('#pol-domain').value.trim() : ''); - if (!ent) { toast('请输入实体'); return; } - if (!hst || (hst[0] !== '+' && hst[0] !== '-')) { toast('域名须以 +(白)或 -(黑)开头'); return; } - const ok = await confirmDlg('域名规则', '确认对 ' + ent + ' ' + (hst[0] === '+' ? '加入白名单' : '加入黑名单') + ' ' + hst.slice(1) + '?', hst[0] === '-'); - if (!ok) { fetchNetOnce(); return; } - try { await api('POST', '/api/net/policy', { op: 'set_domain', entity: ent, arg: hst }); toast('域名规则已更新'); } - catch (err) { toast('域名规则失败:' + errText(err)); } - fetchNetOnce(); - return; - } - }); - } - - /* ---------------- 3. 文件管理页 ---------------- */ - // 返回带索引的 rel 显示面包屑 - function joinRel(a, b) { - if (!a || a === '/') return '/' + b; - return (a.endsWith('/') ? a.slice(0, -1) : a) + '/' + b; - } - function crumbHtml(items, cur) { // items: [{name,rel}] - let h = '
'; - h += '根目录'; - items.forEach(it => { h += '/' + esc(it.name) + ''; }); - return h + '
'; - } - async function navFiles(rel) { - const page = $('#files-page'); - if (!page) return; - let relPath = rel || '/'; - page.innerHTML = '

加载中…

'; - let d; - try { d = await api('GET', '/api/files?path=' + encodeURIComponent(relPath)); } - catch (e) { page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } - state.filesRel = d.rel || relPath; state.filesAbs = d.path; - let h = crumbHtml(segments(d.rel), d.rel); - // 白名单快捷入口(根目录下) - if (d.rel === '/' && d.allowed) { - h += '
' + - d.allowed.map(a => '').join(' ') + '
'; - } - h += '
' + - '' + - '' + - '' + - '
'; - h += '
'; - if (!d.items.length) h += ''; - d.items.forEach(function (it) { - const isDir = it.type === 'dir'; - const icon = isDir ? '📁' : (it.type === 'file' ? '📄' : '❔'); - h += '' + - ''; - }); - h += '
名称大小修改时间操作
空目录
' + (isDir ? '' : '') + icon + ' ' + esc(it.name) + (isDir ? '' : '') + - '' + (isDir ? '—' : fmtBytes(it.size)) + '' + fmtTime(it.mtime) + '
' + - (isDir ? '' : '下载') + - '' + - '' + - '
'; - page.innerHTML = h; - } - function segments(rel) { - const parts = (rel || '').split('/').filter(Boolean); const out = []; let cur = ''; - parts.forEach(p => { cur += '/' + p; out.push({ name: p, rel: cur }); }); - return out; - } - function renderFiles() { $('#content').innerHTML = '
'; navFiles(state.filesRel || '/'); } - function absFor(name) { return state.filesAbs ? (state.filesAbs.endsWith('/') ? state.filesAbs + name : state.filesAbs + '/' + name) : null; } - function bindFiles() { - const c = $('#content'); - c.addEventListener('click', async function (e) { - const t = e.target; - const enter = t.closest('[data-enter]'); const dl = t.closest('[data-dl]'); - const ren = t.closest('[data-ren]'); const del = t.closest('[data-del]'); - const dirlink = t.closest('[data-dir]'); const nav = t.closest('[data-nav]'); - const crumb = t.closest('[data-rel]'); - const btn = t.closest('button[id]'); - if (crumb) { navFiles(crumb.dataset.rel); return; } - if (nav) { navFiles(nav.dataset.nav); return; } - if (dirlink || enter) { const n = (dirlink || enter).dataset.directory || (dirlink || enter).dataset.dir || (dirlink || enter).dataset.enter; navFiles(joinRel(state.filesRel, n)); return; } - if (dl) { - const abs = absFor(dl.dataset.dl); if (!abs) return toast('无当前目录'); - doDownload('/api/files/download?path=' + encodeURIComponent(abs), dl.dataset.dl); - return; - } - if (ren) { renameFile(ren.dataset.ren); return; } - if (del) { removeFile(del.dataset.del); return; } - if (btn && btn.id === 'file-newdir') { mkdirFile(); return; } - if (btn && btn.id === 'file-upload') { const fi = $('#file-input'); if (fi) { fi.value = ''; fi.click(); } return; } - if (btn && btn.id === 'file-refresh') { navFiles(state.filesRel); return; } - }); - c.addEventListener('change', function (e) { - if (e.target.id === 'file-input') uploadFiles(e.target.files); - }); - } - function doDownload(url, name) { - return apiDownload(url).then(blobUrl => { - const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'file'; - document.body.appendChild(a); a.click(); a.remove(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); - }).catch(e => toast('下载失败:' + errText(e))); - } - async function removeFile(name) { - const abs = absFor(name); if (!abs) return; - const ok = await confirmDlg('删除 ' + name, '此操作不可撤销,确认删除?\n' + abs, true); - if (!ok) return; - try { - await api('DELETE', '/api/files?path=' + encodeURIComponent(abs)); - toast('已删除 ' + name); navFiles(state.filesRel); - } catch (e) { toast('删除失败:' + errText(e)); } - } - async function renameFile(name) { - const abs = absFor(name); if (!abs) return; - const nn = prompt('将「' + name + '」重命名为:', name); - if (!nn || nn === name) return; - try { - await api('POST', '/api/files/rename', { path: abs, new_name: nn }); - toast('已重命名'); navFiles(state.filesRel); - } catch (e) { toast('重命名失败:' + errText(e)); } - } - async function mkdirFile() { - const abs = state.filesAbs; if (!abs) return; - const nn = prompt('新建目录名称(将创建于 ' + state.filesRel + '):', 'newdir'); - if (!nn) return; - try { - await api('POST', '/api/files/mkdir', { path: joinRel(abs, nn) }); - toast('已创建 ' + nn); navFiles(state.filesRel); - } catch (e) { toast('创建失败:' + errText(e)); } - } - async function uploadFiles(files) { - if (!files || !files.length) return; - const abs = state.filesAbs; if (!abs) return toast('无当前目录'); - const fd = new FormData(); fd.append('path', abs); - Array.prototype.forEach.call(files, f => fd.append('file', f)); - const n = files.length; - toast('正在上传 ' + n + ' 个文件…'); - try { - await api('POST', '/api/files/upload', fd); - toast('上传完成'); navFiles(state.filesRel); - } catch (e) { toast('上传失败:' + errText(e)); } - } - - /* ---------------- 4. 应用管理页 ---------------- */ - function renderApps() { - $('#content').innerHTML = '

加载应用列表…

'; - (async function () { - let d; - try { d = await api('GET', '/api/apps'); } - catch (e) { if (state.route === 'apps') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } - if (state.route !== 'apps') return; - let h = '
'; - h += '
' + - '' + - '' + - '
'; - h += '
'; - if (!d.apps.length) h += '
尚未安装任何 MeowAPP
'; - d.apps.forEach(a => { - const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase()); - h += '
' + - '
' + ino + '
' + - '
' + esc(a.name || a.package || '未命名') + '
' + - '
' + esc(a.package || '') + '
' + - '
版本 ' + esc(a.version || '—') + (a.invalid ? ' · 无效' : '') + - (a.size ? ' · ' + fmtBytes(a.size) : '') + '
' + - '
' + - '' + - '导出 .mpk' + - '' + - '
'; - }); - h += '
'; - // 本地源 .mpk 安装(后端支持 path 形式) - h += '

从卡上路径安装

' + - '
' + - '
'; - $('#content').innerHTML = h; - })(); - } - function bindApps() { - const c = $('#content'); - c.addEventListener('click', async function (e) { - const t = e.target; - const install = t.closest('#app-install'); const refresh = t.closest('#app-refresh'); - const info = t.closest('[data-ainfo]'); const exp = t.closest('[data-aexport]'); - const uninst = t.closest('[data-auninst]'); const goPath = t.closest('#app-install-path-go'); - if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; } - if (refresh) { renderApps(); return; } - if (info) { appInfo(info.dataset.ainfo); return; } - if (exp) { - e.preventDefault(); - if (exp.dataset.busy) return; /* 打包期间防连点 */ - exp.dataset.busy = '1'; - const oldTxt = exp.textContent; - exp.textContent = '打包中…'; - toast('正在打包导出 ' + exp.dataset.aexport + ',请稍候…'); - doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk') - .finally(() => { delete exp.dataset.busy; exp.textContent = oldTxt; }); - return; - } - if (uninst) { appUninstall(uninst.dataset.auninst); return; } - if (goPath) { installFromPath($('#app-install-path').value); return; } - }); - c.addEventListener('change', function (e) { - if (e.target.id === 'app-file') installFromFile(e.target.files); - }); - } - async function installFromFile(files) { - if (!files || !files.length) return; - const fd = new FormData(); fd.append('file', files[0]); - toast('正在安装 ' + files[0].name + '…'); - try { - const d = await api('POST', '/api/apps/install', fd); - toast('已安装 ' + (d.package || '应用') + (d.version ? ' v' + d.version : '')); - renderApps(); - } catch (e) { toast('安装失败:' + errText(e)); } - } - async function installFromPath(p) { - if (!p) return toast('请输入 .mpk 路径'); - toast('正在安装 ' + p + '…'); - try { - const d = await api('POST', '/api/apps/install', { path: p }); - toast('已安装 ' + (d.package || '应用')); renderApps(); - } catch (e) { toast('安装失败:' + errText(e)); } - } - async function appUninstall(pkg) { - const ok = await confirmDlg('卸载 ' + pkg, '将移除应用与数据预留(数据默认保留)。确认卸载?', true); - if (!ok) return; - try { await api('DELETE', '/api/apps/' + pkg); toast('已卸载 ' + pkg); renderApps(); } - catch (e) { toast('卸载失败:' + errText(e)); } - } - async function appInfo(pkg) { - try { - const d = await api('GET', '/api/apps/' + pkg); - let txt = '包名: ' + d.package + '\n名称: ' + d.name + '\n版本: ' + d.version + '\nexec: ' + d.exec + - '\nsize: ' + fmtBytes(d.size) + '\n路径: ' + d.path; - if (d.description) txt += '\n描述: ' + d.description; - alert(txt); - } catch (e) { toast('获取详情失败:' + errText(e)); } - } - - /* ---------------- 4b. 字体管理页(/api/fonts* 专属页) ---------------- */ - function renderFontsPage() { - $('#content').innerHTML = '
' + - '

字体库

' + - '
' + - '' + - '' + - '
' + - '

加载字体库列表…

' + - '

上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' + - 'MeowOS/fonts/<family>/<weight>/;同 family/weight 冲突时会要求确认覆盖。' + - '导入后进设备「设置 → 字体」页,列表自动重扫生效。

'; - loadFontsPage(); - } - async function loadFontsPage() { - const box = $('#fpage-list'); if (!box) return; - let d; - try { d = await api('GET', '/api/fonts'); } - catch (e) { box.innerHTML = '

字体库列表加载失败:' + esc(errText(e)) + '

'; return; } - const fs = (d && d.fonts) || []; - if (!fs.length) { box.innerHTML = '
暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)
'; return; } - let h = ''; - fs.forEach(f => { - h += '' + - '' + - '' + - ''; - }); - box.innerHTML = h + '
familyweight名称tierglyph 数操作
' + esc(f.family || '') + '' + esc(f.weight || '') + '' + esc(f.name || '—') + '' + esc(f.tier || '—') + '' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + '
'; - } - async function importFontPage(files) { - if (!files || !files.length) return; - const f = files[0]; - const btn = $('#fpage-import'); - if (btn && btn.disabled) return; // 上传中防连点(进度态) - const fd = new FormData(); fd.append('file', f); - if (btn) { btn.disabled = true; btn.textContent = '导入中…'; } - toast('正在导入 ' + f.name + '…'); - let res = null; - try { res = await api('POST', '/api/fonts/import', fd); } - catch (err) { - if (err.code === 'CONFLICT') { - const ok = await confirmDlg('覆盖字库', '目标字库(同 family/weight)已存在,覆盖导入吗?', true); - if (ok) { - try { res = await api('POST', '/api/fonts/import?force=1', fd); } - catch (e2) { toast('导入失败:' + errText(e2)); } - } - } else toast('导入失败:' + errText(err)); - } - if (btn) { btn.disabled = false; btn.textContent = '上传字体库 .tar.gz'; } - if (res) { - toast('已导入 ' + (res.family || '') + '/' + (res.weight || '')); - if (res.warning) toast(res.warning); - else if (res.hint) toast(res.hint); - loadFontsPage(); - } - } - function bindFonts() { - const c = $('#content'); - c.addEventListener('click', function (e) { - if (e.target.closest('#fpage-import')) { const inp = $('#fpage-file'); if (inp) { inp.value = ''; inp.click(); } return; } - if (e.target.closest('#fpage-refresh')) { loadFontsPage(); return; } - const fe = e.target.closest('[data-fpexport]'); - if (fe) { - if (fe.dataset.busy) return; // 打包期间防连点 - fe.dataset.busy = '1'; - const old = fe.textContent; fe.textContent = '打包中…'; - const f = fe.dataset.fpexport, w = fe.dataset.fpweight; - doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), - (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz') - .finally(() => { delete fe.dataset.busy; fe.textContent = old; }); - return; - } - }); - c.addEventListener('change', function (e) { - if (e.target.id === 'fpage-file') importFontPage(e.target.files); - }); - } - - /* ---------------- 5. 设置镜像页 ---------------- */ - function settingControl(it) { - const key = it.key; - if (key === 'panel.auth_pass') { - const txt = it.set ? '已设置' : '未设置'; - return '
面板密码(' + txt + ')
' + - '
'; - } - if (it.type === 'bool') { - return '
' + esc(key) + '
' + - '
'; - } - if (it.type === 'int') { - const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max); - return '
' + - '
' + esc(it.value == null ? '' : it.value) + '' + - '
'; - } - if (it.type === 'enum') { - const opts = (it.options || []).map(o => '').join(''); - return '
'; - } - return '
'; - } - async function renderSettings() { - $('#content').innerHTML = '

加载设置…

'; - let d; - try { d = await api('GET', '/api/settings'); } - catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } - if (state.route !== 'settings') return; - let h = '
'; - h += '
' + - '' + - '
'; - const items = d.items || []; - // 面板外观(主题):本面板覆盖(localStorage),默认跟随设备 ui.style / display.theme - h += '

主题(面板外观)

' + - '
' + - '
' + - '' + - '
' + - '
' + - '

默认跟随设备 ui.style(当前:' + esc(devStyleLabel()) + ');在面板选择即为本面板覆盖,只影响网页观感(顶栏切换才会同步设备)。

' + - '
' + - '
' + - '' + - '' + - '' + - '
' + - '

跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。

'; - // 面板 PIN(访问级鉴权):显示状态 + 修改/清空控件(走 /api/auth/* 校验旧 PIN + PUT /api/settings/panel.pin) - h += '

面板 PIN(访问鉴权)

' + - '

查询鉴权状态…

'; - // 面板鉴权(可选防护)单独一卡 - const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass'); - if (authItems.length) { - h += '

面板鉴权(可选防护)

' + authItems.map(settingControl).join('') + - '

开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。

'; - } - const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass'); - // 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看 - const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')], - ['数值/滑条', rest.filter(x => x.type === 'int')], - ['枚举/选择', rest.filter(x => x.type === 'enum')], - ['字符串/路径', rest.filter(x => x.type === 'str')]]; - groups.forEach(function (g) { - if (!g[1].length) return; - h += '

' + g[0] + '

' + g[1].map(settingControl).join('') + '
'; - }); - // 字体库(.zip 导入 / 导出) - h += '

字体库(.tar.gz 导入 / 导出)

' + - '
' + - '
' + - '

加载字体库列表…

' + - '

导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/<family>/<weight>/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。

'; - h += '
'; - $('#content').innerHTML = h; - applyTheme(); // 同步「主题」卡 seg 选中态(applyTheme 按 id 查询,不存在时为空操作) - loadFonts(); - loadPinStatus(); - } - async function loadPinStatus() { - const box = $('#pin-status'); - if (!box) return; - let st = null; - try { st = await api('GET', '/api/auth/status'); } catch (e) { box.innerHTML = '

无法查询鉴权状态:' + esc(errText(e)) + '

'; return; } - let txt, cls; - if (st.locked) { txt = '锁定中(' + Math.ceil((st.lock_remaining_ms || 0) / 1000) + ' 秒后可重试)'; cls = 'locked'; } - else if (st.pin_set) { txt = '已设置'; cls = 'set'; } - else if (st.legacy) { txt = '未设(旧密码屏障生效,建议改设为 PIN)'; cls = 'unset'; } - else { txt = '未设置(当前访问面板无需 PIN)'; cls = 'unset'; } - const canEdit = st.pin_set && !st.locked; - let h = '
' + esc(txt) + '' + - (st.session ? ' · 当前会话有效' : ' · 未登录') + '
'; - h += '
' + - '' + - '' + - (st.locked ? '锁定期间不可修改/清空' : '修改/清空需先验证当前 PIN') + - '
'; - h += '
' + - '' + - '设备下次亮屏免 PIN 进入一次(不清除 PIN)
'; - h += '

4 位数字 PIN,纯加盐哈希存储(不落明文);未设 PIN 时写端点一律拒绝。

'; - box.innerHTML = h; - } - function pinEditDlg(mode) { // 返回 {old, n1}(n1=null 表示清空)或 null(取消) - return new Promise(function (resolve) { - const t = $('#pindlg-title'), b = $('#pindlg-body'); - const oldIn = $('#pindlg-old'), n1 = $('#pindlg-new'), n2 = $('#pindlg-new2'); - const err = $('#pindlg-err'); - if (mode === 'change') { - t.textContent = '修改 PIN'; - b.textContent = '请输入当前 PIN 与新 PIN(4 位数字)。'; - oldIn.hidden = false; n1.hidden = false; n2.hidden = false; - n1.placeholder = '新 4 位 PIN'; n2.placeholder = '再次输入新 PIN'; - } else { - t.textContent = '清空 PIN'; - b.textContent = '清空后访问面板将不再要求 PIN。请输入当前 PIN 确认。'; - oldIn.hidden = false; n1.hidden = true; n2.hidden = true; - } - err.hidden = true; oldIn.value = ''; n1.value = ''; n2.value = ''; - oldIn.focus(); - const scrim = $('#pindlg'); scrim.hidden = false; - const ok = $('#pindlg-ok'), cancel = $('#pindlg-cancel'); - const done = function (v) { scrim.hidden = true; ok.onclick = cancel.onclick = scrim.onclick = null; resolve(v); }; - ok.onclick = function () { - const old = oldIn.value.trim(); - if (!old) { err.textContent = '请输入当前 PIN'; err.hidden = false; return; } - if (mode === 'change') { - const np = n1.value.trim(); - if (!/^[0-9]{4}$/.test(np)) { err.textContent = '新 PIN 必须为 4 位数字'; err.hidden = false; return; } - if (np !== n2.value.trim()) { err.textContent = '两次输入的新 PIN 不一致'; err.hidden = false; return; } - done({ old: old, n1: np }); - } else done({ old: old, n1: null }); - }; - cancel.onclick = function () { done(null); }; - scrim.onclick = function (e) { if (e.target === scrim) done(null); }; - }); - } - async function pinEdit(mode) { - const v = await pinEditDlg(mode); - if (!v) return; - try { await api('POST', '/api/auth/login', { pin: v.old }); } // 先验证旧 PIN(客户端复核,后端改 PIN 本身不需旧值) - catch (e2) { - if (e2.code === 'LOCKED' || e2.status === 429) toast('错误次数过多,已锁定,请稍后重试(锁定 5 分钟)'); - else if (e2.status === 401) toast('当前 PIN 验证失败'); - else toast('验证失败:' + errText(e2)); - loadPinStatus(); return; - } - try { - await api('PUT', '/api/settings/panel.pin', { value: (v.n1 !== null ? v.n1 : '') }); - toast(mode === 'change' ? 'PIN 已修改' : 'PIN 已清空'); - loadPinStatus(); - } catch (e3) { toast('操作失败:' + errText(e3)); } - } - function bindPin() { - const c = $('#content'); - c.addEventListener('click', async function (e) { - if (e.target.closest('#pin-change')) { pinEdit('change'); return; } - if (e.target.closest('#pin-clear')) { pinEdit('clear'); return; } - if (e.target.closest('#pin-unlock-once')) { - try { - await api('POST', '/api/system/pin-unlock-once', {}); - toast('已下发:设备下次亮屏免 PIN 进入一次'); - } catch (err) { toast('下发失败:' + errText(err)); } - return; - } - }); - ['#pindlg-old', '#pindlg-new', '#pindlg-new2'].forEach(function (sel) { - const el = $(sel); - if (el) el.addEventListener('input', function () { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); }); - }); - } - async function loadFonts() { - const box = $('#font-list'); if (!box) return; - let d; - try { d = await api('GET', '/api/fonts'); } - catch (e) { box.innerHTML = '

字体库列表加载失败:' + esc(errText(e)) + '

'; return; } - const fs = (d && d.fonts) || []; - if (!fs.length) { box.innerHTML = '

暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)。

'; return; } - box.innerHTML = fs.map(f => { - const nm = esc(f.name || f.family || ''); - const meta = esc((f.family || '') + '/' + (f.weight || '') + (f.tier ? ' · ' + f.tier : '') + (f.glyph_count ? ' · ' + f.glyph_count + ' 字' : '')); - return '
' + - '
' + nm + '
' + meta + '
' + - '
'; - }).join(''); - } - async function importFont(files) { - if (!files || !files.length) return; - const f = files[0]; - const fd = new FormData(); fd.append('file', f); - toast('正在导入 ' + f.name + '…'); - let res; - try { res = await api('POST', '/api/fonts/import', fd); } - catch (err) { - if (err.code === 'CONFLICT') { - const ok = await confirmDlg('覆盖字库', '目标字库已存在,覆盖导入吗?', true); - if (!ok) return; - try { res = await api('POST', '/api/fonts/import?force=1', fd); } - catch (e2) { toast('导入失败:' + errText(e2)); return; } - } else { toast('导入失败:' + errText(err)); return; } - } - toast('已导入 ' + ((res && res.family) || '') + '/' + ((res && res.weight) || '')); - if (res && res.hint) toast(res.hint); - loadFonts(); - } - function bindSettings() { - const c = $('#content'); - c.addEventListener('input', function (e) { - const r = e.target.closest('[data-stype="int"]'); if (!r) return; - const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value; - updateSetting(r.dataset.skey, +r.value, 'int'); - }); - c.addEventListener('change', function (e) { - if (e.target.id === 'font-file') { importFont(e.target.files); return; } - const t = e.target; - const sk = t.dataset.skey; if (!sk) return; - if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool'); - else if (t.dataset.stype === 'enum') updateSetting(sk, t.value, 'enum'); - else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str'); - }); - c.addEventListener('click', async function (e) { - // 「主题(面板外观)」卡:本面板覆盖(localStorage),不写设备 - const ss = e.target.closest('#set-style-seg .seg-btn'); - if (ss) { storageSet('meowpanel.style', ss.dataset.style); applyTheme(); return; } - const sm = e.target.closest('#set-mode-seg .seg-btn'); - if (sm) { storageSet('meowpanel.mode', sm.dataset.mode); applyTheme(); return; } - if (e.target.closest('#set-style-reset')) { storageDel('meowpanel.style'); applyTheme(); return; } - if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; } - const fe = e.target.closest('[data-fexport]'); - if (fe) { - const f = fe.dataset.fexport, w = fe.dataset.fweight; - doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz'); - return; - } - if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; } - if (e.target.closest('#set-restore')) { - const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true); - if (!ok) return; - try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); } - catch (err) { toast('恢复失败:' + errText(err)); } - } else if (e.target.closest('#set-refresh')) { renderSettings(); } - }); - } - const updateSetting = debounce(async function (key, value, type) { - try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); } - catch (e) { toast('保存失败:' + errText(e)); } - }, 350); - - /* ---------------- 6b. 远程控制页滑动手势(设备端靠触摸滑动操作,控制页据此补滑动手势) ---------------- - * 说明:后端 /api/input/tap 仅支持「单点坐标注入」(屏幕页用),无滑动/手势注入端点; - * 故控制页滑动统一映射到 /api/key/:code 语义键(与本地按键行为一致),多步音量 = 逐键 tap 多次。 */ - const SWIPE = { - minDist: 40, // 判定为「滑动」的最小位移(px),小于此视为点按 - axisRatio: 1.5, // 主轴位移须至少为次轴的倍数,防斜滑误判 - edgePx: 56, // 距控制区左缘多少 px 内起手即视为「左缘滑」 - maxVolSteps: 5, // 单次滑动最多递增音量步数(防连刷冲上限) - volStepPx: 70, // 每多滑这么多位移,多算一步音量 - clickSuppressMs: 450 // 滑动后短暂屏蔽按钮点击,防误触 - }; - let swipeState = null; // 当前手势 {id,x0,y0,edge} - let lastSwipeAt = 0; // 上次触发手势的时刻,用于屏蔽随后可能的 click - async function sendKeyLabel(code, label) { - try { await api('POST', '/api/key/' + code, { press: 'tap' }); toast(label + ' 已发送'); } - catch (e) { toast('发送失败:' + errText(e)); } - } - async function sendKeyRepeat(code, n, label) { - let count = 0, err = null; - for (let i = 0; i < n; i++) { - try { await api('POST', '/api/key/' + code, { press: 'tap' }); count++; } - catch (e) { err = errText(e); break; } // 失败即停,避免连刷 - } - if (count > 0) toast(label + ' ×' + count); - if (err) toast('发送失败:' + err); - } - // 依据滑动主轴位移换算音量步数(>=1,封顶 maxVolSteps) - function volSteps(ay) { - return Math.max(1, Math.min(SWIPE.maxVolSteps, Math.round(ay / SWIPE.volStepPx))); - } - function handleRemoteGesture(dir, ax, ay) { - if (dir === 'volup') sendKeyRepeat(115, volSteps(ay), '音量+'); - else if (dir === 'voldown') sendKeyRepeat(114, volSteps(ay), '音量−'); - else if (dir === 'next') sendKeyLabel(163, '下一曲'); - else if (dir === 'prev') sendKeyLabel(165, '上一曲'); - else if (dir === 'back') sendKeyLabel(158, '返回'); - } - function remotePointerDown(e) { - if (state.route !== 'remote' || swipeState) return; // 已有一指在手 → 忽略多点触控 - if (!e.target.closest('#remote-page')) return; - const page = $('#remote-page'); if (!page) return; - const r = page.getBoundingClientRect(); - swipeState = { id: e.pointerId, x0: e.clientX, y0: e.clientY, edge: (e.clientX - r.left) <= SWIPE.edgePx }; - } - function remotePointerUp(e) { - const s = swipeState; - if (!s || e.pointerId !== s.id) return; - swipeState = null; - const dx = e.clientX - s.x0, dy = e.clientY - s.y0; - const ax = Math.abs(dx), ay = Math.abs(dy); - if (Math.max(ax, ay) < SWIPE.minDist) return; // 轻点/未动 → 交给按钮 click 处理 - let dir = null; - if (s.edge && dx > 0 && dx >= ay * SWIPE.axisRatio && dx >= SWIPE.minDist) dir = 'back'; // 左缘滑 → 返回 - else if (ax >= ay * SWIPE.axisRatio) dir = (dx < 0 ? 'next' : 'prev'); // 水平 → 上下曲 - else if (ay >= ax * SWIPE.axisRatio) dir = (dy < 0 ? 'volup' : 'voldown'); // 垂直 → 音量 - if (!dir) return; // 斜滑/未达占优 → 忽略 - lastSwipeAt = Date.now(); - handleRemoteGesture(dir, ax, ay); - } - - /* ---------------- 6. 远程控制页 ---------------- */ - function renderRemote() { - const keys = [ - { code: 115, label: '音量 +', ico: '🔊' }, - { code: 114, label: '音量 −', ico: '🔉' }, - { code: 116, label: '电源(单击)', ico: '⏻' }, - { code: 164, label: '播放/暂停', ico: '⏯' }, - { code: 163, label: '下一曲', ico: '⏭' }, - { code: 165, label: '上一曲', ico: '⏮' }, - { code: 158, label: '返回', ico: '↩' }, - { code: 172, label: '主页', ico: '⌂' } - ]; - let h = '
'; - h += '

模拟按键

' + - '

行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。

' + - '
' + keys.map(k => - '' - ).join('') + '
' + - '

滑动手势(与按钮并存,轻点仍是点击):' + - '上滑=音量+ · 下滑=音量-(滑得越远步数越多,单次至多 ' + SWIPE.maxVolSteps + ' 步) · ' + - '左滑=下一曲 · 右滑=上一曲 · 控制区左缘右滑=返回

' + - '
'; - h += '

电源长按(电源菜单)

' + - '
' + - '发送 code=116 · press=long · ms=3000
'; - h += '
'; - $('#content').innerHTML = h; - } - async function sendKey(code, press, ms) { - try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); } - catch (e) { toast('发送失败:' + errText(e)); } - } - function bindRemote() { - $('#content').addEventListener('click', function (e) { - if (Date.now() - lastSwipeAt < SWIPE.clickSuppressMs) return; // 刚滑完 → 屏蔽本次点击,防误触按钮 - const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; } - const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; } - }); - // 滑动手势:指针事件统一触摸/鼠标;#remote-page 已设 touch-action:none 防浏览器滚动劫持 - $('#content').addEventListener('pointerdown', remotePointerDown); - window.addEventListener('pointerup', remotePointerUp); - window.addEventListener('pointercancel', remotePointerUp); - } - - /* ---------------- 7. 系统信息 / OTA 页 ---------------- */ - async function renderSystem() { - $('#content').innerHTML = '

加载系统信息…

'; - let d, pw = null; - try { d = await api('GET', '/api/system'); } - catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } - try { pw = await api('GET', '/api/power'); } catch (e) { pw = null; } - if (state.route !== 'system') return; - const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {}; - const boot = d.boot || {}; - const curMode = boot.mode || boot.current || null; // 后端若暴露 /tmp/meow_boot_mode → boot.mode/current - const bootDef = boot.default || '—'; - const mism = curMode && boot.default && curMode !== boot.default; - const batt = (pw && pw.battery) || {}; - const charging = (batt.state === 'Charging' || batt.state === 'Full'); - let modeHtml = mism - ? '' + esc(curMode) + ' (与默认启动不一致)' - : (curMode ? esc(curMode) : '—'); - let sysRows = row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) + - row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec)); - if (pw && pw.battery) { - sysRows += row('电池', '' + esc(batt.state || '—') + ' ' + - (batt.pct != null ? batt.pct + ' %' : '—') + - (batt.mv != null ? ' · ' + (batt.mv / 1000).toFixed(2) + ' V' : '') + - (batt.health ? ' · ' + esc(batt.health) : '')); - } - sysRows += row('当前运行模式', modeHtml); - sysRows += row('默认启动', esc(bootDef)); - sysRows += row('重启原因', esc(boot.reboot_reason || d.reboot_reason || '—')); - sysRows += row('升级状态', esc(boot.upgrade_status || d.upgrade_status || '—')); - let h = '
'; - h += '

系统信息

' + sysRows + '
'; - h += '

网络

' + - row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') + - row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') + - row('mDNS', esc(net.hostname_local || '—')) + - '
'; - h += '

升级

' + - '

当前 ' + esc(ota.current || d.version || '—') + - (ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '

' + - '
' + - '
'; - h += '

危险操作

' + - '
' + - '
' + - '

⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。

'; - h += '
'; - $('#content').innerHTML = h; - } - function row(k, v) { return '' + k + '' + v + ''; } - function bindSystem() { // init 一次性委托绑定(不随渲染累积) - $('#content').addEventListener('click', async function (e) { - if (e.target.closest('#sys-reboot')) { - if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true)) - try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); } - return; - } - if (e.target.closest('#sys-shutdown')) { - if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true)) - try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); } - return; - } - if (e.target.closest('#sys-ota')) { - if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true)) - try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); } - return; - } - if (e.target.closest('#sys-ota-check')) { - // 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级) - try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); } - catch (err) { toast('检查失败:' + errText(err)); } - return; - } - }); - } - - /* ---------------- 8. 日志页(批2 崩溃/日志中心:崩溃汇总 + 分组 + 前缀过滤 + 崩溃尾巴前置) ---------------- */ - let logsGroup = 'all', logsPrefix = ''; - const LOG_GROUP_NAME = { all: '全部', system: '系统服务', app: 'App', audio_monitor: 'audio_monitor', power: 'power', net: 'net', crash: 'crash', ams: 'ams' }; - function groupName(g) { return LOG_GROUP_NAME[g] || (g || 'app'); } - function logsCrashCard(cr) { - const ents = (cr.entities || []).filter(e => e.name); - let h = '

崩溃汇总(' + esc(cr.file || '本 boot · crash log') + ')

'; - if (!ents.length && !(cr.dmesg_count || 0)) { h += '

本 boot 无崩溃记录。

'; return h; } - h += ''; - ents.forEach(e => { - h += '' + - '' + - '' + - '' + - ''; - }); - h += '
实体崩溃数最近时间信号/退出操作
' + esc(e.name) + '' + esc(e.count) + ((e.count || 0) > 0 ? ' ⚠' : '') + '' + esc(e.last_time || '—') + '' + esc(e.last_kind || '—') + '' + (e.last_code != null && e.last_code !== 0 ? ' (' + esc(e.last_code) + ')' : '') + '
'; - if (cr.dmesg_count) { - h += '

内核 oops / segfault 捕获:' + esc(cr.dmesg_count) + ' 条' + - ((cr.dmesg_tail || []).length ? '

' + esc((cr.dmesg_tail || []).map(x => '· ' + x).join('\n')) + '
' : '') + '

'; - } - h += '
'; - return h; - } - function renderLogsFiles(files) { - const el = $('#logs-files'); - if (!el) return; - const filtered = files.filter(f => { - if (logsGroup !== 'all' && (f.group || 'app') !== logsGroup) return false; - if (logsPrefix && !f.name.toLowerCase().startsWith(logsPrefix.toLowerCase())) return false; - return true; - }); - let h = ''; - if (!filtered.length) h += ''; - filtered.forEach(f => { - h += '' + - ''; - }); - h += '
文件分组大小修改时间操作
暂无日志(或过滤后无结果)
' + esc(f.name) + '' + groupName(f.group) + '' + fmtBytes(f.size) + '' + fmtTime(f.mtime) + '
'; - el.innerHTML = h; - } - async function renderLogs() { - $('#content').innerHTML = '

加载日志列表…

'; - const r = await Promise.allSettled([api('GET', '/api/logs'), api('GET', '/api/logs/crash')]); - if (state.route !== 'logs') return; - if (r[0].status !== 'fulfilled') { - $('#content').innerHTML = emptyState('加载失败:' + errText(r[0].reason), r[0].reason && r[0].reason.code); - return; - } - const d = r[0].value; - const cr = (r[1].status === 'fulfilled') ? r[1].value : null; - let h = '
'; - if (cr && ((cr.count || 0) > 0 || (cr.dmesg_count || 0) > 0 || (cr.entities || []).length)) h += logsCrashCard(cr); - h += '

日志文件(MeowOS/logs)

'; - h += '
' + esc(d.dir || '') + '
'; - h += '
'; - h += ''; - h += '
'; - h += '
'; - Object.keys(LOG_GROUP_NAME).forEach(g => { - h += ''; - }); - h += '
'; - h += '
加载…
'; - h += '
'; - $('#content').innerHTML = h; - logsFiles = d.files || []; - renderLogsFiles(logsFiles); - } - function bindLogs() { - $('#content').addEventListener('click', async function (e) { - const gbtn = e.target.closest('[data-log-group]'); - if (gbtn) { - logsGroup = gbtn.dataset.logGroup; - $$('[data-log-group]').forEach(b => b.classList.toggle('btn-primary', b === gbtn)); - renderLogsFiles(logsFiles); - return; - } - const tb = e.target.closest('[data-crash-tail]'); - if (tb) { await viewLogsCrashTail(tb.dataset.crashTail, tb); return; } - const t = e.target.closest('[data-log]'); if (!t) return; - const p = $('#log-view'); - try { - const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(t.dataset.log) + '&lines=400'); - p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : ''); - } catch (err) { toast('查看失败:' + errText(err)); } - }); - $('#content').addEventListener('input', function (e) { - const p = e.target.closest('[data-log-prefix]'); - if (!p) return; - logsPrefix = p.value; - renderLogsFiles(logsFiles); - }); - } - /* 崩溃实体 → stdout 尾部:复用批1-B 的读取路径(/api/files 列 logs// → /api/logs/read 读尾部)。 */ - let logsFiles = []; - async function viewLogsCrashTail(name, btn) { - btn.textContent = '加载中…'; - let file; - try { - const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + name + '/')); - const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0); - if (!files.length) { btn.textContent = 'stdout 尾部'; toast('未找到 ' + name + ' 的 stdout 日志'); return; } - files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); - file = files[0].name; - } catch (err) { btn.textContent = 'stdout 尾部'; toast('查找失败:' + errText(err)); return; } - try { - const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(name + '/' + file) + '&lines=200'); - const lines = (d.lines || []); - const p = $('#log-view'); - if (p) { p.hidden = false; p.textContent = name + '/' + file + '\n' + (lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : ''); } - } catch (err) { toast('读取失败:' + errText(err)); } - btn.textContent = 'stdout 尾部'; - } - - /* ---------------- 9. 通知页 ---------------- */ - async function renderNotif() { - $('#content').innerHTML = '

加载通知…

'; - let d; - try { d = await api('GET', '/api/notifications'); } - catch (e) { if (state.route === 'notif') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } - if (state.route !== 'notif') return; - let h = '
'; - h += '
' + - '
'; - const list = d.notifications || []; - if (!list.length) h += '
暂无通知
'; - list.forEach(n => { - const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低'); - h += '
' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '
' + - '
' + esc(n.summary || n.app_name || '通知') + '
' + - (n.body ? '
' + esc(n.body) + '
' : '') + - '
' + esc(n.app_name || '') + '' + fmtTime(n.ts) + '' + - '紧急度 ' + urg + '' + - (n.progress != null ? '进度 ' + n.progress + '%' : '') + - (n.ongoing ? '进行中' : '') + '
' + - '
'; - }); - h += '
'; - $('#content').innerHTML = h; - } - function bindNotif() { - $('#content').addEventListener('click', async function (e) { - if (e.target.closest('#notif-refresh')) { renderNotif(); return; } - if (e.target.closest('#notif-clear')) { - if (await confirmDlg('清空通知', '将清空全部通知。确认?', true)) - try { await api('POST', '/api/notifications/dismiss_all'); toast('已清空'); renderNotif(); } - catch (err) { toast('清空失败:' + errText(err)); } - return; - } - const del = e.target.closest('[data-ndel]'); if (del) { - try { await api('DELETE', '/api/notifications/' + del.dataset.ndel); toast('已清除'); renderNotif(); } - catch (err) { toast('清除失败:' + errText(err)); } - } - }); - } - - /* ---------------- 10. 实时屏幕 / 点击遥控 ---------------- - * 轮询改「上一帧到了再取下一帧」(链式 setTimeout,绝不堆积/重叠): - * 每帧下载完成后按目标周期自校正 delay(周期 = max(0, interval - 请求耗时)), - * 故名义 fps 被尊重,且请求变慢时自动退化为 1/请求耗时。 */ - let screenTimer = null, screenBusy = false, screenOn = false; - let screenObjUrl = null, screenFps = 1; - let screenLastMs = 0, screenMeasFps = 0; // 实测帧率(帧间间隔 EMA) - let screenDelta = false, screenCanvasRef = null, screenCanvasReady = false; // 增量模式(canvas 叠加) - function stopScreenPoll() { - screenOn = false; - if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; } - } - function restartScreenPoll() { - screenOn = true; - if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; } - // 请求在途时不重复启动(避免并发请求);在途帧结束后会按最新 screenFps 自续 - if (!screenBusy) screenTimer = setTimeout(fetchScreen, 0); - } - async function fetchScreen() { - if (screenBusy) return; - screenBusy = true; - const t0 = performance.now(); - let statusMsg = ''; - try { - const headers = {}; - const token = storageGet('meowpanel.token'); - if (token) headers['X-MeowToken'] = token; - const deltaQ = screenDelta ? '&delta=1' : ''; - const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70' + deltaQ, - Object.keys(headers).length ? { headers: headers } : undefined); - if (!res.ok) { $('#screen-status').textContent = '获取失败(HTTP ' + res.status + ')'; return; } - const hd = (k) => res.headers.get(k); - if (screenDelta) { - const isDelta = hd('X-Meow-Delta') === '1'; - const full = hd('X-Meow-Full') === '1'; - const changed = parseInt(hd('X-Meow-Changed') || '-1', 10); - const fw = parseInt(hd('X-Meow-W') || '480', 10); - const fh = parseInt(hd('X-Meow-H') || '720', 10); - const blob = await res.blob(); - if (!isDelta || full) { // 整帧(或旧后端无 delta 头)→ 播种/替换 canvas - const img = await createImgFromBlob(blob); - const cv = ensureScreenCanvas(fw, fh); - if (cv) { cv.getContext('2d').drawImage(img, 0, 0, fw, fh); screenCanvasReady = true; } - statusMsg = '已更新' + (changed >= 0 ? '(整帧)' : ''); - } else if (!screenCanvasReady) { // 尚未播种:取一整帧再继续 - await seedScreenCanvas(fw, fh); - if (screenCanvasReady) statusMsg = '已更新(播种)'; - } else if (changed === 0) { // 无变化:跳过叠加 - statusMsg = '无变化'; - } else { // 子图叠加 - const subX = parseInt(hd('X-Meow-Sub-X') || '0', 10); - const subY = parseInt(hd('X-Meow-Sub-Y') || '0', 10); - const subW = parseInt(hd('X-Meow-Sub-W') || fw, 10); - const subH = parseInt(hd('X-Meow-Sub-H') || fh, 10); - const img = await createImgFromBlob(blob); - const cv = ensureScreenCanvas(fw, fh); - if (cv) cv.getContext('2d').drawImage(img, subX, subY, subW, subH); - statusMsg = '已更新(增量 ' + changed + ' 块)'; - } - } else { - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const img = $('#screen-img'); - if (!img) { URL.revokeObjectURL(url); return; } - img.src = url; - if (screenObjUrl) URL.revokeObjectURL(screenObjUrl); - screenObjUrl = url; - statusMsg = '已更新'; - } - // 实测帧率:上一帧到本帧间隔的 EMA(首帧无间隔) - const now = performance.now(); - if (screenLastMs) { - const dt = (now - screenLastMs) / 1000; - screenMeasFps = screenMeasFps ? screenMeasFps * 0.7 + (1 / dt) * 0.3 : 1 / dt; - } - screenLastMs = now; - $('#screen-status').textContent = statusMsg + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString(); - } catch (e) { - $('#screen-status').textContent = '后端未连接'; - } - screenBusy = false; - // 自校正续链(本轮耗时计入周期,保证名义 fps) - if (screenOn) { - const intervalMs = Math.max(500, Math.round(1000 / screenFps)); - const wait = Math.max(0, intervalMs - (performance.now() - t0)); - screenTimer = setTimeout(fetchScreen, wait); - } - } - // 增量模式助手:blob → Image(一次性 objectURL,加载后即回收) - async function createImgFromBlob(blob) { - const url = URL.createObjectURL(blob); - try { - const img = new Image(); - await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); - return img; - } finally { URL.revokeObjectURL(url); } - } - function ensureScreenCanvas(fw, fh) { - let cv = screenCanvasRef; - if (!cv) cv = screenCanvasRef = $('#screen-canvas'); - if (!cv) return null; - if (cv.width !== fw || cv.height !== fh) { cv.width = fw; cv.height = fh; } - return cv; - } - async function seedScreenCanvas(fw, fh) { - const headers = {}; - const token = storageGet('meowpanel.token'); - if (token) headers['X-MeowToken'] = token; - const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70', - Object.keys(headers).length ? { headers: headers } : undefined); - if (!res.ok) return false; - const img = await createImgFromBlob(await res.blob()); - const cv = ensureScreenCanvas(fw, fh); - if (!cv) return false; - cv.getContext('2d').drawImage(img, 0, 0, fw, fh); - screenCanvasReady = true; - return true; - } - function applyScreenDeltaDom() { - const img = $('#screen-img'), cv = $('#screen-canvas'); - if (!img || !cv) return; - if (screenDelta) { img.style.display = 'none'; cv.style.display = 'block'; } - else { img.style.display = 'block'; cv.style.display = 'none'; } - } - async function screenTap(e) { - const img = $('#screen-img'), cv = $('#screen-canvas'); - const useCanvas = screenDelta && cv && cv.style.display !== 'none'; - const el = useCanvas ? cv : img; - if (!el) return; - const r = el.getBoundingClientRect(); - if (!r.width || !r.height) return; - // 前端按 480x720(设备原生分辨率)换算 - const nx = Math.round((e.clientX - r.left) / r.width * 480); - const ny = Math.round((e.clientY - r.top) / r.height * 720); - const nxCL = Math.max(0, Math.min(479, nx)), nyCL = Math.max(0, Math.min(719, ny)); - try { - const d = await api('POST', '/api/input/tap', { x: nxCL, y: nyCL }); - if (d && d.dispatched) toast('已注入 (' + nxCL + ',' + nyCL + ')'); - else if (d && d.reason === 'local_touch_active') toast('本机触摸中,注入已暂停'); - else toast('注入未派发'); - } catch (err) { toast('注入失败:' + errText(err)); } - } - function renderScreen() { - screenFps = parseFloat(storageGet('screen.fps') || '1'); - if (!(screenFps >= 0.2 && screenFps <= 2)) screenFps = 1; - let h = '
'; - h += '

实时画面

' + - '

点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。

' + - '
' + - '设备实时画面' + - '' + - '
' + - '
' + - '' + - '' + - '' + screenFps.toFixed(1) + ' fps' + - '
' + - '
' + - '' + - '
' + - '

载入中…

' + - '
'; - $('#content').innerHTML = h; - screenCanvasRef = null; screenCanvasReady = false; // 切换页面重置增量 canvas 状态 - const slider = $('#screen-fps'); - slider.addEventListener('input', function () { - const v = parseFloat(slider.value); - screenFps = v; storageSet('screen.fps', String(v)); - $('#screen-fps-lbl').textContent = v.toFixed(1) + ' fps'; - restartScreenPoll(); - }); - const deltaBox = $('#screen-delta'); - deltaBox.addEventListener('change', function () { - screenDelta = deltaBox.checked; - screenCanvasRef = null; screenCanvasReady = false; // 换增量模式:强制重新播种 - applyScreenDeltaDom(); - restartScreenPoll(); - }); - $('#screen-img').addEventListener('click', screenTap); - $('#screen-canvas').addEventListener('click', screenTap); - applyScreenDeltaDom(); - // restartScreenPoll 已排程首帧(上一帧到了再取下一帧),勿再显式 fetch 以免双发 - restartScreenPoll(); - } - function bindScreen() { - // 页面隐藏(切到其它标签页)时停刷;回到本页继续 - document.addEventListener('visibilitychange', function () { - if (document.hidden) stopScreenPoll(); - else if (state.route === 'screen') restartScreenPoll(); - }); - } - - /* ---------------- 路由 ---------------- */ const ROUTES = { home: { title: '首页仪表盘', render: renderHome, bind: bindHome }, perf: { title: '实时性能', render: renderPerf, bind: null }, @@ -2734,21 +15,38 @@ apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps }, fonts: { title: '字体管理', render: renderFontsPage, bind: bindFonts }, settings: { title: '设置镜像', render: renderSettings, bind: bindSettings }, - remote: { title: '远程控制', render: renderRemote, bind: bindRemote }, - screen: { title: '实时屏幕', render: renderScreen, bind: bindScreen }, + screen: { title: '屏幕 · 遥控', render: renderScreen, bind: bindScreen }, system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem }, logs: { title: '系统日志', render: renderLogs, bind: bindLogs }, notif: { title: '通知中心', render: renderNotif, bind: bindNotif }, power: { title: '电源 / 功耗', render: renderPower, bind: bindPower }, proc: { title: '进程 / 服务', render: renderProc, bind: bindProc } }; - // 一次性绑定(各菜单 click / 侧栏);页面内交互用 #content 事件委托(已各自绑定) + let started = false; + function bindNav() { $$('.nav-item').forEach(b => b.addEventListener('click', () => { location.hash = '#/' + b.dataset.route; })); $('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open')); } + + async function init() { + bindNav(); + bindThemeUi(); + bindSlider(); + bindFiles(); bindApps(); bindFonts(); bindSettings(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindPin(); bindAudio(); + bindLogin(); + bindPwDlg(); + // 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录 + let st = null; + try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; } + if (st && st.session) { startApp(); return; } + if (st && !st.pin_set && !st.legacy) { showLogin('setup'); return; } + if (st) { showLogin('login'); return; } + startApp(); // 后端不可达:仍启动(显示离线横幅) + } + function route() { - if (state.unauth) { showLogin(authMode); return; } // 未认证 → 登录页(保持当前设置/登录态) + if (state.unauth) { showLogin(MP.authMode); return; } // 未认证 → 登录页(保持当前设置/登录态) const id = (location.hash || '#/home').replace('#/', '').split('?')[0] || 'home'; const r = ROUTES[id] || ROUTES.home; state.route = id; @@ -2765,8 +63,6 @@ r.render(); } - /* ---------------- 启动 ---------------- */ - let started = false; function startApp() { if (started) { route(); return; } started = true; @@ -2774,20 +70,6 @@ window.addEventListener('hashchange', route); route(); } - async function init() { - bindNav(); - bindThemeUi(); - bindSlider(); - bindFiles(); bindApps(); bindFonts(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindPin(); bindAudio(); - bindLogin(); - bindPwDlg(); - // 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录 - let st = null; - try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; } - if (st && st.session) { startApp(); return; } - if (st && !st.pin_set && !st.legacy) { showLogin('setup'); return; } - if (st) { showLogin('login'); return; } - startApp(); // 后端不可达:仍启动(显示离线横幅) - } + document.addEventListener('DOMContentLoaded', init); -})(); +})(); \ No newline at end of file diff --git a/patch_v2/meowpanel/web/mp_pages.js b/patch_v2/meowpanel/web/mp_pages.js new file mode 100644 index 0000000..9e5d233 --- /dev/null +++ b/patch_v2/meowpanel/web/mp_pages.js @@ -0,0 +1,2380 @@ +/* mp_pages.js — MeowPanel 前端各页渲染/绑定(home/perf/power/audio/net/files/apps/fonts/settings/system/logs/notif/proc + 合并的屏幕·遥控)。 + * 由 meowpanel.js 拆分而来(接口/行为不变);函数体保持原样,仅顶部从 window.MP 解构共享框架。 */ +(function () { + 'use strict'; + var MP = window.MP = window.MP || {}; +const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, pushSpark, refreshAuthGate, resetSpark, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, updateLevelMeter, volTimer } = MP; + + let audioTimer = null; + let audioVolDrag = false; // 音量滑条拖拽中(暂停 1s 重渲染,防打断交互) + let homeTimer = null, homeLevelTimer = null; + let homeData = null, homeSys = null; + let powerTimer = null; + let powerEditing = false; // 用户在编辑策略表单时暂停 5s 重渲染(防输入被轮询清空) + let powerData = null, powerEvents = null, powerHistory = null; + let procTimer = null; + let expandedProc = null; // { pkg, file, lines, truncated }:已展开的 App stdout 尾部(重渲染后重开,防轮询清空) + let netTimer = null; + let netEntityFilter = ''; + let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义) + const updateSetting = debounce(async function (key, value, type) { + try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); } + catch (e) { toast('保存失败:' + errText(e)); } + }, 350); + const SWIPE = { + minDist: 40, // 判定为「滑动」的最小位移(px),小于此视为点按 + axisRatio: 1.5, // 主轴位移须至少为次轴的倍数,防斜滑误判 + edgePx: 56, // 距控制区左缘多少 px 内起手即视为「左缘滑」 + maxVolSteps: 5, // 单次滑动最多递增音量步数(防连刷冲上限) + volStepPx: 70, // 每多滑这么多位移,多算一步音量 + clickSuppressMs: 450 // 滑动后短暂屏蔽按钮点击,防误触 + }; + let swipeState = null; // 当前手势 {id,x0,y0,edge} + let lastSwipeAt = 0; // 上次触发手势的时刻,用于屏蔽随后可能的 click + const MP_ICONS = { + volup: '', + voldown: '', + brightup:'', + brightdn:'', + power: '', + play: '', + next: '', + prev: '' + }; + let ctlBright = { min: 1, max: 100, value: 100 }; // 控制板亮度(读取自 /api/settings/display.brightness) + let logsGroup = 'all', logsPrefix = ''; + let logsFiles = []; + let screenTimer = null, screenBusy = false, screenOn = false; + let screenObjUrl = null, screenFps = 1; + let screenLastMs = 0, screenMeasFps = 0; // 实测帧率(帧间间隔 EMA) + let screenDelta = false, screenCanvasRef = null, screenCanvasReady = false; // 增量模式(canvas 叠加) + + async function setPanelPassword() { + const np = await passwordDlg('设置面板密码', '请输入新密码(留空则清除密码)。'); + if (np === null) return; // 取消 + try { + await api('PUT', '/api/settings/panel.auth_pass', { value: np }); + toast(np ? '面板密码已设置' : '面板密码已清除'); + renderSettings(); + } catch (e) { toast('设置失败:' + errText(e)); } + } + + async function sendTheme(patch) { + try { await api('PUT', '/api/theme', patch); } + catch (e) { toast('主题已本地切换(后端未同步:' + e.message + ')'); } + } + + function renderPerfData(page, d) { + const cpu = d.cpu || {}; + const mem = d.memory || {}, st = d.storage || {}, tf = d.tfcard || {}, tmp = d.temp; + let h = '

实时性能

'; + h += '
运行时间:' + fmtUptime(d.uptime_sec) + '
'; + h += stat('CPU', '' + (cpu.usage == null ? '—' : +cpu.usage.toFixed(1)) + '%', + cpu.cores != null ? cpu.cores + ' 核 · 负载 ' + (cpu.load ? cpu.load.map(x => (+x).toFixed(2)).join(' / ') : '—') : '', + cpu.usage || 0); + const memP = mem.total ? (mem.used / mem.total) * 100 : 0; + h += stat('内存', '' + fmtBytes((mem.used || 0) * 1024) + '/ ' + fmtBytes((mem.total || 0) * 1024) + '', + '可用 ' + fmtBytes((mem.available || (mem.free || 0)) * 1024), memP); + const stP = st.total ? (st.used / st.total) * 100 : 0; + h += stat('存储 (rootfs)', '' + fmtBytes((st.used || 0) * 1024) + '/ ' + fmtBytes((st.total || 0) * 1024) + '', st.path || '', stP); + if (tf) { + if (tf.present) { + const tfP = tf.total ? (tf.used / tf.total) * 100 : 0; + h += stat('TF 卡', '' + fmtBytes((tf.used || 0) * 1024) + '/ ' + fmtBytes((tf.total || 0) * 1024) + '', + fmtBytes((tf.avail || 0) * 1024) + ' 可用 · ' + (tf.filesystem || ''), tfP); + } else { + h += stat('TF 卡', '未插卡', ''); + } + } + const tmpCpu = tmp && tmp.cpu != null ? +tmp.cpu.toFixed(1) : null; + h += stat('温度', '' + (tmpCpu == null ? '—' : tmpCpu) + '℃', + tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : ''); + h += '
'; + page.innerHTML = h; + } + + async function fetchPerfOnce() { + const page = $('#perf-page'); + if (!page || state.route !== 'perf') return; + let d; + try { d = await api('GET', '/api/perf'); } + catch (e) { if (page && state.route === 'perf') page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route === 'perf') renderPerfData(page, d); + } + + function stopPerf() { if (state.perfTimer) { clearInterval(state.perfTimer); state.perfTimer = null; } } + + function renderPerf() { + $('#content').innerHTML = '

加载中…

'; + if (state.perfTimer) stopPerf(); + fetchPerfOnce(); + state.perfTimer = setInterval(fetchPerfOnce, 2000); + } + + async function renderBright() { + $('#content').innerHTML = '

加载设置…

'; + let bright, vol; + try { + bright = await api('GET', '/api/settings/display.brightness'); + } catch (e) { $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + try { vol = await api('GET', '/api/settings/audio.volume'); } catch (e) { vol = null; } + if (state.route !== 'bright') return; + let h = '
'; + h += slur({ key: 'display.brightness', label: '亮度', min: bright.min || 1, max: bright.max || 100, val: bright.value }); + if (vol) h += slur({ key: 'audio.volume', label: '音量', min: vol.min || 0, max: vol.max || 100, val: vol.value }); + else h += '

音量设置项不可用(后端未实现 audio.volume)

'; + h += '

滑条改动自动保存到系统(走 meow_settings_*),与实体设置页同步。

'; + $('#content').innerHTML = h; + } + + function stopAudio() { if (audioTimer) { clearInterval(audioTimer); audioTimer = null; } } + + function audioPortName(p) { // audiod numid=9 路由口语义(AUDIOD_DESIGN §5) + const m = { 0: '空闲', 1: 'Line Out', 2: '3.5mm', 3: '4.4mm 平衡', 4: 'Type-C SPDIF' }; + if (p == null || p < 0) return '—'; + return m[p] || ('端口 ' + p); + } + + function fmtFrames(n) { return (n == null) ? '—' : Number(n).toLocaleString(); } + + function audioRouteLabel(p) { + const m = { 0: '自动', 2: '3.5mm', 3: '4.4mm' }; + return m[p] || ('端口 ' + p); + } + + function audioCtlHtml(d) { + const vmax = (d && d.vol_max != null) ? d.vol_max : 100; + const vol = (d && d.vol != null && d.vol >= 0) ? Math.min(d.vol, vmax) : 0; + const cur = d.port; + let s = '

路由与音量

' + + row('路由模式', esc(d.route_mode || '—') + (d.route_mode === 'manual' ? '(手动覆盖)' : '(跟随插孔)')) + + row('输出口', audioPortName(d.port)) + + row('音量(只读)', (d.vol != null && d.vol >= 0 ? '' + esc(d.vol) + ' %' : '—') + + ' (上限 ' + esc(vmax) + ' · 平方锥度非线性)') + + '
'; + s += '
输出路由'; + [0, 2, 3].forEach(p => { + s += ''; + }); + s += '
'; + s += '
' + vol + '' + + '' + + '' + vmax + '
'; + s += '

切换/调节前会确认。选错输出口会无声;音量走平方锥度(越接近上限越响,非等比),系统上限 ' + esc(vmax) + '。

'; + s += '
'; + return s; + } + + function audioFlowHtml(d) { + let h = ''; + if (!d || !d.available) { + h += '

音频服务(meow_audiod)

' + + '

audiod 不可用(服务未运行或 IPC 不可达)。MeowOS 模式下 audiod 恒在;HibyOS 模式下按门控不启动。

'; + } else { + h += '

当前流

'; + if (d.active) { + h += '' + + row('应用', esc(d.app || '—') + ' (sid ' + esc(d.sid) + ')') + + row('采样率', esc(d.rate) + ' Hz') + + row('声道', esc(d.ch) + ' ch') + + row('格式', esc(d.fmt || '—')) + + row('已写帧', fmtFrames(d.written_frames)) + + row('已播放帧', fmtFrames(d.played_frames)) + + row('underrun', '' + esc(d.underrun) + '') + + row('状态', d.paused ? '已暂停' : '播放中') + + '
'; + } else { + h += '

当前无活动音频流。

'; + } + h += '
'; + h += audioCtlHtml(d); + } + return h; + } + + async function fetchAudioOnce() { + const flowEl = $('#audio-flow'); + if (!flowEl || state.route !== 'audio') return; + let d; + try { d = await api('GET', '/api/audio'); } + catch (e) { + if (flowEl && state.route === 'audio') flowEl.innerHTML = '
加载失败:' + esc(errText(e)) + '
'; + return; + } + if (state.route !== 'audio') return; + if (audioVolDrag) return; // 音量滑条拖拽中:暂停重渲染防打断(电平/控件保留当前交互态) + flowEl.innerHTML = audioFlowHtml(d); + updateLevelMeter($('#audio-level'), d); + const note = $('#audio-level-note'); + if (note) { + if (!d || !d.available) note.innerHTML = 'audiod 不可用 · 每 1 秒自动刷新(路由/音量写端点不可用)。'; + else if (!d.active) note.innerHTML = '当前无流 · 电平保持 −∞ · 每 1 秒自动刷新。路由/音量为写级,需登录会话。'; + else note.innerHTML = '每 1 秒自动刷新 · 流信息只读;路由与音量为写级(确认后生效,不放测试音)。'; + } + } + + function renderAudio() { + resetSpark(); // 进入页面重置 sparkline 累积 + stopAudio(); + $('#content').innerHTML = + '
' + + '

加载音频状态…

' + + '

电平(dBFS)

' + + '
' + levelMeterHtml() + '
' + + '

每 1 秒自动刷新 · 只读展示(测试音请用设备 CLI meow_audio tone,面板不放声)。

' + + '
' + + '
'; + fetchAudioOnce(); + audioTimer = setInterval(fetchAudioOnce, 1000); + } + + function bindAudio() { + $('#content').addEventListener('click', async function (e) { + const rb = e.target.closest('[data-audio-route]'); + if (rb) { + const port = parseInt(rb.dataset.audioRoute, 10); + if (!(port === 0 || port === 2 || port === 3)) { toast('非法路由口(仅自动/3.5/4.4)'); return; } + const ok = await confirmDlg('切换输出路由', + '确认切换到「' + audioRouteLabel(port) + (port === 0 ? '(跟随插孔)' : '') + '」?\n选错输出口会无声,请确认当前插孔。', false); + if (!ok) return; + try { await api('POST', '/api/audio/route', { port: port }); toast('路由已切换:' + audioRouteLabel(port)); } + catch (err) { toast('切换失败:' + errText(err)); } + return; + } + }); + $('#content').addEventListener('pointerdown', function (e) { + const sl = e.target.closest('[data-audio-vol]'); + if (sl) { audioVolDrag = true; const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; } + }); + $('#content').addEventListener('pointerup', function (e) { + // 释放即复位;若值已提交 change 会再走确认,此处仅保证「点击未改动」时也不卡住重渲染 + if (e.target.closest('[data-audio-vol]')) setTimeout(function () { audioVolDrag = false; }, 250); + }); + $('#content').addEventListener('input', function (e) { + const sl = e.target.closest('[data-audio-vol]'); + if (!sl) return; + const row = sl.closest('.field-row'); if (row) row.querySelector('.muted').textContent = sl.value; + }); + $('#content').addEventListener('change', async function (e) { + const sl = e.target.closest('[data-audio-vol]'); + if (!sl) return; + audioVolDrag = false; + const v = parseInt(sl.value, 10); + const vmax = parseInt(sl.max, 10) || 100; + const ok = await confirmDlg('确认音量', + '确认将音量设为 ' + v + ' %?(系统上限 ' + vmax + ',平方锥度非线性——越接近上限越响且非等比)', false); + if (!ok) { fetchAudioOnce(); return; } // 取消 → 立即拉一次复位滑条 + try { + await api('POST', '/api/audio/volume', { value: v }); + toast('音量已设为 ' + v + ' %'); + fetchAudioOnce(); + } catch (err) { toast('设置失败:' + errText(err)); fetchAudioOnce(); } + }); + } + + function stopHome() { + if (homeTimer) { clearInterval(homeTimer); homeTimer = null; } + if (homeLevelTimer) { clearInterval(homeLevelTimer); homeLevelTimer = null; } + } + + function deviceCard() { + const p = (homeData && homeData.perf && homeData.perf.system); + const s = homeSys; + const ver = (s && s.version) || (p && p.version) || '—'; + const host = (s && s.hostname) || (p && p.hostname) || '—'; + const ker = (s && s.kernel) || (p && p.kernel) || '—'; + const arch = (s && s.arch) || (p && p.arch) || '—'; + const bootD = (s && s.boot && s.boot.default) || '—'; + const cur = (s && s.boot) ? (s.boot.mode || s.boot.current || null) : null; + let rows = ''; + rows += homeKv('版本', esc(ver)); + rows += homeKv('主机名', esc(host)); + rows += homeKv('内核', esc(ker)); + rows += homeKv('架构', esc(arch)); + rows += homeKv('运行模式', modePill(cur)); + rows += homeKv('默认启动', modePill(bootD)); + return homeCard('设备信息', '
' + rows + '
'); + } + + function batteryCard() { + const pw = homeData && homeData.power; + if (!isAvail(pw)) return homeCard('电量 / 充电', homeUnavailable()); + const b = pw.battery || {}; + const pct = (b.pct != null) ? b.pct : null; + const state = b.state || '—'; + const charging = (state === 'Charging' || state === 'Full'); + const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : '')); + let inner = '
' + + '' + (pct == null ? '—' : pct) + '%' + + '' + esc(state) + '
'; + inner += '
' + + homeKv('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') + + homeKv('健康', b.health ? esc(b.health) : '—') + + homeKv('温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') + + (b.est_minutes_left != null ? homeKv('续航估算', b.est_minutes_left + ' 分钟') : + (b.est_minutes_full != null ? homeKv('充满估算', b.est_minutes_full + ' 分钟') : '')) + + '
'; + return homeCard('电量 / 充电', inner); + } + + function audioCard() { + const ad = homeData && homeData.audio; + if (!isAvail(ad)) return homeCard('音频', homeUnavailable()); + let body = ''; + if (ad.active) { + body += '
' + + homeKv('流', esc(ad.app || '—') + ' · ' + esc(ad.rate) + 'Hz ' + esc(ad.fmt || '')) + + homeKv('输出口', audioPortName(ad.port)) + + homeKv('音量', (ad.vol != null && ad.vol >= 0 ? esc(ad.vol) + ' %' : '—')) + + '
'; + } else { + body += '
当前无活跃音频流。
'; + } + body += '
' + levelMeterHtml() + '
' + + '

' + (ad.active ? '电平 · 1s 子节拍' : '无流') + '

'; + return homeCard('音频', body); + } + + function netCard() { + const nd = homeData && homeData.net; + if (!isAvail(nd)) return homeCard('WiFi / DNS', homeUnavailable()); + const w = nd.wifi || {}; + const dn = (nd && nd.dns) || null; // 批2: DNS 统计已并入 /api/overview 的 net 块(首页严格单请求) + let rows = ''; + rows += homeKv('WiFi', w.connected ? ('已连接 ' + esc(w.ssid || '')) : '未连接'); + if (w.connected) { + rows += homeKv('IP', esc(w.ip || '—')); + rows += homeKv('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—')); + } + if (dn) { + rows += homeKv('DNS 上游', esc(dn.upstream || '—')); + rows += homeKv('DNS 缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0) + (dn.enabled ? '' : '(停用)')); + } else { + rows += homeKv('DNS', '—'); + } + const pb = (nd && nd.pub_bytes) || null; + if (pb && pb.total) { + rows += homeKv('公网流量', '↓ ' + fmtBytes(pb.total.rx || 0) + ' · ↑ ' + fmtBytes(pb.total.tx || 0) + '(TCP)'); + } else { + rows += homeKv('公网流量', '—'); + } + return homeCard('WiFi / DNS', '
' + rows + '
'); + } + + function btCard() { + const nd = homeData && homeData.net; + const ad = homeData && homeData.audio; + let bt = nd && nd.bt; + if (!bt || !isAvail(nd)) return homeCard('蓝牙', homeUnavailable()); + if (bt.available === false) return homeCard('蓝牙', '
无蓝牙
'); + const on = !!bt.powered; + const conn = (bt.connected != null) ? bt.connected : 0; + let rows = ''; + rows += homeKv('蓝牙', on ? '已开启' : '已关闭'); + rows += homeKv('连接', conn > 0 ? ('已连接 ' + esc(conn) + '') : '未连接'); + rows += homeKv('已配对', bt.paired != null ? esc(bt.paired) : '—'); + if (bt.address) rows += homeKv('本机地址', esc(bt.address)); + if (conn > 0) { + rows += homeKv('A2DP 编解码', bt.a2dp_codec ? esc(bt.a2dp_codec) : '—'); + rows += homeKv('采样率', bt.a2dp_rate ? esc(bt.a2dp_rate) : '—'); + } else { + rows += homeKv('A2DP 编解码', '—'); + } + const target = ad && ad.target != null ? ad.target : null; + const volctl = ad && ad.volctl != null ? ad.volctl : null; + const targetName = target === 'bt' ? '蓝牙' : (target === 'dac' ? 'DAC' : null); + rows += homeKv('输出目标', targetName ? ('' + esc(targetName) + '') : '—'); + rows += homeKv('音量控制', volctl ? esc(volctl) : '—'); + if (ad && ad.vol != null && ad.vol >= 0) { + rows += homeKv('音量', esc(ad.vol) + ' %' + (volctl === 'avrcp' ? ' (AVRCP)' : '')); + } + return homeCard('蓝牙', '
' + rows + '
'); + } + + function healthCard() { + const ams = homeData && homeData.ams; + const svcd = homeData && homeData.svcd; + let appTxt = '—', svcBad = 0, svcHi = 0, svcTxt = '—', fgHtml = ''; + if (isAvail(ams)) { + const apps = ams.apps || []; + const run = apps.filter(a => a.state === 'fg' || a.state === 'bg').length; + appTxt = run + ' 在跑 / ' + (ams.count || apps.length) + ' 已装'; + const fg = ams.fg; + if (fg && fg.pkg) { + fgHtml = '前台 ' + esc(fg.pkg) + + (fg.activity ? ' · ' + esc(fg.activity) + '' : ''); + } + } + let svcHtml; + if (isAvail(svcd)) { + const svcs = svcd.services || []; + svcBad = svcs.filter(s => s.state === 'failed').length; + svcHi = svcs.filter(s => (s.restart || 0) >= 3).length; + svcTxt = svcs.filter(s => s.state === 'running').length + ' 运行中'; + svcHtml = ''; + if (svcBad) svcHtml += '' + svcBad + ' FAILED '; + if (svcHi) svcHtml += '' + svcHi + ' 高重启 '; + if (!svcBad && !svcHi) svcHtml += '正常 '; + } else { + svcHtml = '不可用 '; + svcTxt = '—'; + } + return homeCard('在跑 App 与服务', '
' + + homeKv('在跑 App', esc(appTxt)) + homeKv('服务健康', svcHtml + esc(svcTxt)) + + (fgHtml ? homeKv('前台应用', fgHtml) : '') + '
'); + } + + function storageCard() { + const perf = homeData && homeData.perf; + if (!isAvail(perf)) return homeCard('存储', homeUnavailable()); + const st = perf.storage || {}, tf = perf.tfcard || {}; + let rows = homeKv('rootfs', (st.used != null ? fmtBytes(st.used * 1024) : '—') + ' / ' + (st.total != null ? fmtBytes(st.total * 1024) : '—')); + rows += (tf.present) ? + homeKv('TF 卡', fmtBytes((tf.used || 0) * 1024) + ' / ' + fmtBytes((tf.total || 0) * 1024)) : + homeKv('TF 卡', '未插卡'); + return homeCard('存储', '
' + rows + '
'); + } + + function perfCard() { + const perf = homeData && homeData.perf; + if (!isAvail(perf)) return homeCard('性能小条', homeUnavailable()); + const cpu = perf.cpu || {}, mem = perf.memory || {}, tmp = perf.temp || {}; + const cpuU = cpu.usage; + const memP = mem.total ? (mem.used / mem.total) * 100 : 0; + return homeCard('性能小条', '
' + + '
CPU
' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '%
' + barHtml(cpuU || 0) + '
' + + '
内存
' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '
' + barHtml(mem.used && mem.total ? memP : 0) + '
' + + '
温度
' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '℃' : '—') + '
' + + '
'); + } + + function powerSummaryCard() { + const pw = homeData && homeData.power; + if (!isAvail(pw)) return ''; + const est = pw.estimate; + if (!est) return ''; + if (est.est_mw == null) return homeCard('功耗视角', '
' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '
'); + return homeCard('功耗视角', estimateCardBody(est) + (pw.breakdown ? breakdownCardBody(pw.breakdown) : '')); + } + + function eventsBar() { + const ev = homeData && homeData.events; + if (!ev || ev.available === false) return ''; + const list = (ev && ev.events) || []; + if (!list.length) return ''; + const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' }; + let items = list.slice(0, 3).map(e => { + const tn = typeLabel[e[1]] || e[1]; + const txt = String(e[3] == null ? '' : e[3]); + const cls = (e[1] === 'lowbat') ? 'warn' : (e[1] === 'charger' ? 'ok' : 'mut'); + return '' + esc(tn) + (txt ? ' · ' + esc(txt.length > 28 ? txt.slice(0, 28) + '…' : txt) : '') + ''; + }).join(' '); + return '

最近事件

' + items + '
'; + } + + function renderHomeCards() { + const body = $('#home-body'); + if (!body || state.route !== 'home') return; + if (!homeData) { body.innerHTML = '

加载总览…

'; return; } + let h = powerSummaryCard() + eventsBar(); // 摘要条(功耗/事件)置顶 + h += '
' + + deviceCard() + batteryCard() + audioCard() + netCard() + btCard() + healthCard() + storageCard() + perfCard() + + '
'; + body.innerHTML = h; + const ad = homeData.audio; + if (isAvail(ad)) updateLevelMeter($('#home-audio-level'), ad); + } + + async function fetchHomeSystem() { // 静态设备信息(boot/默认启动);进入时拉一次,失败不阻塞 + try { homeSys = await api('GET', '/api/system'); } catch (e) { homeSys = null; } + if (state.route === 'home') renderHomeCards(); + } + + async function fetchHomeLevel() { // 电平 1s 子节拍(独立于 overview 5s) + if (state.route !== 'home') return; + const el = $('#home-audio-level'); if (!el) return; + let d; + try { d = await api('GET', '/api/audio'); } catch (e) { return; } + if (state.route !== 'home') return; + updateLevelMeter(el, d); + const note = $('#home-audio-note'); + if (note) note.textContent = (!d || !d.available) ? 'audiod 不可用' : (d.active ? '电平 · 1s 子节拍' : '无流'); + } + + async function fetchOverviewOnce() { + if (state.route !== 'home') return; + const r = await Promise.allSettled([api('GET', '/api/overview')]); + if (r[0].status !== 'fulfilled') { + const b = $('#home-banner'); + if (b) { + b.innerHTML = '⚠️总览加载失败:' + esc(errText(r[0].reason)) + ''; + b.hidden = false; + } + return; + } + const b = $('#home-banner'); if (b) { b.hidden = true; b.innerHTML = ''; } + homeData = r[0].value; // 首页严格单请求:DNS 已并入 overview 的 net 块 + renderHomeCards(); + } + + function renderHome() { + stopHome(); + resetSpark(); // 进入首页重置 sparkline 累积 + homeData = null; homeSys = null; + $('#content').innerHTML = + '
' + + '' + + '

加载总览…

' + + '
'; + fetchHomeSystem(); + fetchOverviewOnce(); + homeTimer = setInterval(fetchOverviewOnce, 5000); + homeLevelTimer = setInterval(fetchHomeLevel, 1000); + } + + function bindHome() { + $('#content').addEventListener('click', function (e) { + if (e.target.closest('#home-retry')) { fetchOverviewOnce(); return; } + }); + } + + function stopPower() { if (powerTimer) { clearInterval(powerTimer); powerTimer = null; } } + + function batteryCardBody(b) { + if (!b) return homeUnavailable(); + const pct = (b.pct != null) ? b.pct : null; + const state = b.state || '—'; + const charging = (state === 'Charging' || state === 'Full'); + const fillCls = (!pct) ? '' : (pct >= 85 ? 'charge' : (pct <= 20 ? 'bad' : '')); + let h = '
' + + '' + (pct == null ? '—' : pct) + '%' + + '' + esc(state) + '
'; + h += '' + + row('电压', b.mv != null ? (b.mv / 1000).toFixed(2) + ' V' : '—') + + row('健康', b.health ? esc(b.health) : '—') + + row('技术', b.technology ? esc(b.technology) : '—') + + row('电池温度', b.temp_c10 != null ? (b.temp_c10 / 10).toFixed(1) + ' ℃' : '—') + + row('充电器温度', b.charger_temp_c10 != null ? (b.charger_temp_c10 / 10).toFixed(1) + ' ℃' : '—') + + row('循环次数', b.cycle_count != null ? b.cycle_count : '—') + + row('放电速率', b.rate_pct_min_x100 != null ? ((b.rate_pct_min_x100 / 100).toFixed(2) + ' %/min') : '—') + + row('续航估算', b.est_minutes_left != null ? (b.est_minutes_left + ' 分钟(剩余)') : (b.est_minutes_full != null ? (b.est_minutes_full + ' 分钟(充满)') : '—')) + + '
'; + return h; + } + + function estimateCardBody(est) { + if (!est) return homeUnavailable(); + if (est.est_mw == null) return '
' + (est.degraded ? '功耗估算不可用(HibyOS 只读模式)' : '—') + '
'; + const conf = est.confidence || 'estimated'; + const cls = conf === 'measured' ? 'measured' : (conf === 'derived' ? 'derived' : (conf === 'none' ? 'none' : 'estimated')); + return '
' + est.est_mw + 'mW' + + '' + esc(conf) + (est.calibrated ? ' · 已标定' : ' · 经验值') + '
' + + '

Model 系数全部为经验初值(未真机标定);confidence 必须如实标注。

'; + } + + function breakdownCardBody(bd) { + if (!bd) return homeUnavailable(); + if (bd.degraded) return '
系统分解不可用(HibyOS 只读模式)
'; + const comps = [ + { label: '屏幕(背光)', mw: bd.backlight_mw, color: '#4a8bf5' }, + { label: 'WiFi', mw: bd.wifi_mw, color: '#00bfa5' }, + { label: '蓝牙', mw: bd.bt_mw, color: '#ab47bc' }, + { label: '音频', mw: bd.audio_mw, color: '#ffb300' }, + { label: 'CPU', mw: bd.cpu_mw, color: '#ff7043' }, + { label: '基线', mw: bd.baseline_mw, color: '#8d6e63' } + ].filter(c => c.mw != null && c.mw >= 0); + const total = comps.reduce((a, c) => a + c.mw, 0); + let stack = '
'; + comps.forEach(c => { stack += ''; }); + stack += '
'; + let legend = '
'; + comps.forEach(c => { + legend += '' + esc(c.label) + ' ' + c.mw + ' mW (' + (total > 0 ? (c.mw / total * 100).toFixed(1) : '0') + '%)'; + }); + legend += '
'; + if (bd.cpu_util_pct != null || bd.brightness_pct != null || bd.wifi_state || bd.bt_state || bd.audio_state) { + legend += '

' + + (bd.cpu_util_pct != null ? 'CPU 利用率 ' + esc(bd.cpu_util_pct) + '% ' : '') + + (bd.brightness_pct != null ? '· 亮度 ' + esc(bd.brightness_pct) + '% ' : '') + + (bd.wifi_state ? '· WiFi ' + esc(bd.wifi_state) : '') + + (bd.bt_state ? ' · BT ' + esc(bd.bt_state) : '') + + (bd.audio_state ? ' · 音频 ' + esc(bd.audio_state) : '') + '

'; + } + return '
合计 ' + total + ' mW(分解为经验估算)
' + stack + legend; + } + + function powerProfileLabel(p) { + return { performance: '性能', balanced: '均衡', powersave: '省电', ultra: '超省电' }[p] || p; + } + + function powerPolicyCard(pol) { + if (!pol) return '

电源策略

' + homeUnavailable() + '
'; + const profiles = ['performance', 'balanced', 'powersave', 'ultra']; + const cur = pol.profile || 'balanced'; + let btns = profiles.map(p => '').join(' '); + return '

电源策略

' + + '
省电档位:' + btns + '
' + + '' + + row('低电阈值', ' %') + + row('临界阈值', ' %') + + row('亮度上限', '0=不生效') + + row('息屏上限', '秒 · 0=不生效') + + row('状态', (pol.enacted ? '已手动开启' : '默认全关')) + + '
' + + '
切档即时生效(需确认);阈值点「保存阈值」。
' + + '

' + esc(pol.note || '') + '

' + + '
'; + } + + function powerEventsCard(ev) { + if (!ev) return ''; + if (ev.available === false) return '

电源事件

' + homeUnavailable() + '
'; + const list = (ev && ev.events) || []; + if (!list.length) return '

电源事件

暂无事件
'; + const typeLabel = { charger: '充电', lowbat: '低电', screen: '屏幕', brightness: '背光' }; + let rows = list.map(e => { + const tn = typeLabel[e[1]] || e[1]; + return '' + esc(tn) + '' + (e[2] != null ? esc(e[2]) + '%' : '—') + '' + esc(e[3] || '') + ''; + }).join(''); + return '

电源事件

' + + '' + rows + '
类型电量说明
'; + } + + function powerHistoryCard(h) { + if (!h) return ''; + if (h.available === false) return '

功耗历史

' + homeUnavailable() + '
'; + let body = '' + + row('采样窗口', (h.n_samples || 0) + ' 点 · ' + fmtDur(h.window_sec)) + + row('电量范围', (h.min_pct != null ? h.min_pct : '—') + '% ~ ' + (h.max_pct != null ? h.max_pct : '—') + '%') + + row('起止电量', (h.start_pct != null ? h.start_pct : '—') + '% → ' + (h.end_pct != null ? h.end_pct : '—') + '%') + + row('平均功耗', (h.avg_mw != null ? h.avg_mw + ' mW' : '—')) + + '
'; + return '

功耗历史

' + body + '
'; + } + + function renderPowerBody(d, ev, hi) { + let h = '
'; + if (d && d.mode === 'readonly') h += '
ℹ️当前为电源只读观测(HibyOS 模式),仅显示电池采样,无功耗估算/分解。
'; + h += '

电池

' + ((d && d.battery) ? batteryCardBody(d.battery) : homeUnavailable()) + '
'; + h += '

功耗估算

' + ((d && d.estimate) ? estimateCardBody(d.estimate) : homeUnavailable()) + '
'; + h += '

子系统分解

' + ((d && d.breakdown) ? breakdownCardBody(d.breakdown) : homeUnavailable()) + '
'; + h += ((d && d.policy) ? powerPolicyCard(d.policy) : '

电源策略

' + homeUnavailable() + '
'); + h += powerEventsCard(ev); + h += powerHistoryCard(hi); + h += '
'; + return h; + } + + async function fetchPowerOnce() { + const el = $('#power-page'); + if (!el || state.route !== 'power') return; + let d, ev, hi; + const r = await Promise.allSettled([api('GET', '/api/power'), api('GET', '/api/power/events'), api('GET', '/api/power/history')]); + if (r[0].status !== 'fulfilled') { + if (el && state.route === 'power') el.innerHTML = '
' + emptyState('加载失败:' + errText(r[0].reason), (r[0].reason && r[0].reason.code) || '') + '
'; + return; + } + if (state.route !== 'power') return; + d = r[0].value; ev = (r[1].status === 'fulfilled') ? r[1].value : null; hi = (r[2].status === 'fulfilled') ? r[2].value : null; + if (!powerEditing) el.innerHTML = renderPowerBody(d, ev, hi); + } + + function renderPower() { + stopPower(); + powerEditing = false; + powerData = null; powerEvents = null; powerHistory = null; + $('#content').innerHTML = '

加载电源数据…

'; + fetchPowerOnce(); + powerTimer = setInterval(fetchPowerOnce, 5000); + } + + function bindPower() { + $('#content').addEventListener('click', async function (e) { + const pb = e.target.closest('.pol-btn'); + if (pb) { + const p = pb.dataset.pol; + const ok = await confirmDlg('切换省电档位', '确认将省电档位切换为「' + powerProfileLabel(p) + '」?(写 settings.conf power.profile,launcher/sysui 期4消费生效)', false); + if (!ok) { powerEditing = false; fetchPowerOnce(); return; } + powerEditing = false; + try { await api('POST', '/api/power/policy', { item: 'profile', value: p }); toast('已切档:' + powerProfileLabel(p)); } + catch (err) { toast('切档失败:' + errText(err)); } + fetchPowerOnce(); + return; + } + const apply = e.target.closest('#pol-apply'); + if (apply) { + const low = parseInt($('#pol-low') ? $('#pol-low').value : '', 10); + const crit = parseInt($('#pol-crit') ? $('#pol-crit').value : '', 10); + const bri = parseInt($('#pol-bri') ? $('#pol-bri').value : '', 10); + const scr = parseInt($('#pol-scr') ? $('#pol-scr').value : '', 10); + if (isNaN(low) || isNaN(crit)) { toast('请输入有效的低电/临界阈值'); return; } + const ok = await confirmDlg('保存电源阈值', '确认写入 低电=' + low + '% / 临界=' + crit + '% / 亮度上限=' + (isNaN(bri) ? 0 : bri) + ' / 息屏上限=' + (isNaN(scr) ? 0 : scr) + 's?', false); + if (!ok) { powerEditing = false; fetchPowerOnce(); return; } + powerEditing = false; + try { + await api('POST', '/api/power/policy', { item: 'low-pct', value: low }); + await api('POST', '/api/power/policy', { item: 'crit-pct', value: crit }); + await api('POST', '/api/power/policy', { item: 'bri-cap', value: bri || 0 }); + await api('POST', '/api/power/policy', { item: 'scr-cap', value: scr || 0 }); + toast('电源阈值已保存'); + } catch (err) { toast('保存失败:' + errText(err)); } + fetchPowerOnce(); + return; + } + }); + $('#content').addEventListener('focusin', function (e) { + if (e.target.closest('.pol-num')) powerEditing = true; // 编辑中暂停 5s 重渲染(防输入被清空) + }); + } + + function stopProc() { if (procTimer) { clearInterval(procTimer); procTimer = null; } } + + function svcStatePill(s) { + const map = { running: ['ok', '运行'], stopped: ['mut', '已停'], stopping: ['warn', '停止中'], disabled: ['mut', '禁用'], failed: ['bad', '失败'] }; + const m = map[s] || ['mut', s || '—']; + return '' + esc(m[1]) + ''; + } + + function appStatePill(s) { + const map = { fg: ['ok', '前台'], bg: ['warn', '后台'], stop: ['mut', '已停'] }; + const m = map[s] || ['mut', s || '—']; + return '' + esc(m[1]) + ''; + } + + function srcPill(src) { + const cls = (src === 'board') ? 'board' : (src === 'tf' ? 'tf' : ''); + return src ? '' + esc(src) + '' : '—'; + } + + function renderProcBody(ams, svcd) { + let h = '
'; + h += '

服务(meow_svcd)

'; + if (svcd && svcd.available) { + const svcs = svcd.services || []; + h += ''; + if (!svcs.length) h += ''; + svcs.forEach(s => { + h += '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + h += '
服务状态PID重启退避触发来源退出执行
暂无服务
' + esc(s.name) + '' + svcStatePill(s.state) + '' + (s.pid > 0 ? esc(s.pid) : '—') + '' + (s.restart || 0) + ((s.restart || 0) >= 3 ? ' ⚠' : '') + '' + esc(s.backoff_ms || '—') + '' + esc(s.trigger || '—') + '' + srcPill(s.src) + '' + esc(s.exit || '—') + '' + esc(s.exec || '—') + '
'; + } else h += homeUnavailable(); + h += '
'; + h += '

App(meow_amsd,只读)

'; + if (ams && ams.available) { + const apps = ams.apps || []; + h += ''; + if (!apps.length) h += ''; + apps.forEach(a => { + const openHere = expandedProc && expandedProc.pkg === a.pkg; + h += '' + + '' + + '' + + '' + + '' + + ''; + if (openHere) { + const lines = (expandedProc.lines || []); + h += ''; + } + }); + h += '
包名名称状态PID启动崩溃上次退出操作
无可观测 App
' + esc(a.pkg) + '' + esc(a.name || '—') + '' + appStatePill(a.state) + '' + (a.pid > 0 ? esc(a.pid) : '—') + '' + esc(a.launches || 0) + '' + (a.crashes || 0) + ((a.crashes || 0) > 0 ? ' ⚠' : '') + '' + esc(a.last_kind || '—') + (a.last_code != null && a.last_code !== 0 ? ' (' + esc(a.last_code) + ')' : '') + '
' + + '' + + '' + + (a.pid > 0 ? '' : '') + + '
' + esc(lines.join('\n') || '(空)') +
+            (expandedProc.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '
' + + '
' + esc(a.pkg + '/' + expandedProc.file) + ' · 末尾 ' + lines.length + ' 行
'; + h += '

「stdout 尾部」查看该 App 最新 stdout_*.log 末尾(崩溃尾巴)。「启动/结束」经 amsd(require_auth);结束前台 App 需二次确认。

'; + } else h += homeUnavailable(); + h += '
'; + return h; + } + + async function newestStdout(pkg) { + try { + const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + pkg + '/')); + const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0); + if (!files.length) return null; + files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); + return files[0].name; + } catch (e) { return null; } + } + + async function viewAppStdout(pkg, btn) { + const row = btn.closest('tr'); if (!row) return; + const next = row.nextElementSibling; + if (next && next.classList && next.classList.contains('proc-tail')) { next.remove(); expandedProc = null; btn.textContent = 'stdout 尾部'; return; } + if (expandedProc && expandedProc.pkg !== pkg) { // 先收起上一个 opened + const prevTail = document.querySelector('tr.proc-tail'); + if (prevTail) prevTail.remove(); + const prevBtn = document.querySelector('[data-applog="' + escSafeAttr(expandedProc.pkg) + '"]'); + if (prevBtn) prevBtn.textContent = 'stdout 尾部'; + expandedProc = null; + } + btn.textContent = '加载中…'; + const tailTr = document.createElement('tr'); + tailTr.className = 'proc-tail'; + tailTr.innerHTML = '
读取 ' + esc(pkg) + ' 最新 stdout…
'; + row.after(tailTr); + try { + const file = await newestStdout(pkg); + if (!file) { tailTr.innerHTML = '
未找到 ' + esc(pkg) + ' 的 stdout 日志
'; btn.textContent = 'stdout 尾部'; return; } + const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(pkg + '/' + file) + '&lines=200'); + const lines = (d.lines || []); + expandedProc = { pkg: pkg, file: file, lines: lines, truncated: !!d.truncated }; + tailTr.innerHTML = '
' + esc(lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '
' + + '
' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行
'; + btn.textContent = '收起'; + } catch (err) { + tailTr.innerHTML = '
读取失败:' + esc(errText(err)) + '
'; + btn.textContent = 'stdout 尾部'; + } + } + + function escSafeAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); } + + async function fetchProcOnce() { + const el = $('#proc-page'); + if (!el || state.route !== 'proc') return; + const r = await Promise.allSettled([api('GET', '/api/ams'), api('GET', '/api/svcd')]); + if (state.route !== 'proc') return; + if (r[0].status !== 'fulfilled' && r[1].status !== 'fulfilled') { + el.innerHTML = '
' + emptyState('加载失败:' + errText(r[0].reason || r[1].reason)) + '
'; + return; + } + el.innerHTML = renderProcBody(r[0].status === 'fulfilled' ? r[0].value : null, r[1].status === 'fulfilled' ? r[1].value : null); + } + + function renderProc() { + stopProc(); + $('#content').innerHTML = '

加载服务与进程…

'; + fetchProcOnce(); + procTimer = setInterval(fetchProcOnce, 5000); + } + + function bindProc() { + $('#content').addEventListener('click', async function (e) { + const b = e.target.closest('[data-applog]'); + if (b) { viewAppStdout(b.dataset.applog, b); return; } + const st = e.target.closest('[data-amsstart]'); + if (st) { + const pkg = st.dataset.amsstart; + try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/start'); toast('已请求启动 ' + pkg); } + catch (err) { toast('启动失败:' + errText(err)); } + fetchProcOnce(); + return; + } + const kl = e.target.closest('[data-amskill]'); + if (kl) { + const pkg = kl.dataset.amskill, fg = kl.dataset.fg === '1'; + const ok = await confirmDlg('结束 App', (fg ? '『' + pkg + '』正位于前台,' : '') + '确认结束 ' + pkg + '?(SIGTERM 语义)', true); + if (!ok) return; + try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/kill'); toast('已请求结束 ' + pkg); } + catch (err) { toast('结束失败:' + errText(err)); } + fetchProcOnce(); + return; + } + }); + } + + function stopNet() { if (netTimer) { clearInterval(netTimer); netTimer = null; } } + + function fmtDur(sec) { // 连接寿命(秒级精度) + if (sec == null || isNaN(sec)) return '—'; + sec = Math.floor(sec); + if (sec < 60) return sec + ' 秒'; + const m = Math.floor(sec / 60), s = sec % 60; + if (m < 60) return m + ' 分 ' + s + ' 秒'; + const h = Math.floor(m / 60), mm = m % 60; + return h + ' 时 ' + mm + ' 分'; + } + + function netBadge(e) { + const i = e.indexOf(':'); + const kind = i > 0 ? e.slice(0, i) : 'proc'; + return '' + esc(kind) + '' + + (i > 0 ? ' ' + esc(e.slice(i + 1)) + '' : ''); + } + + function netStateCls(st) { + if (!st) return ''; + if (st === 'ESTABLISHED') return 'net-state ok'; + if (st === 'LISTEN') return 'net-state listen'; + if (st === 'UDP') return 'net-state listen'; + return 'net-state warn'; + } + + function netIfaceCards(ifd) { + if (!ifd || ifd.available === false) return '

网络接口

' + homeUnavailable() + '
'; + const ifs = (ifd && ifd.ifaces) || []; + let h = '

网络接口

'; + let meta = '
'; + meta += (ifd.rssi != null) ? '信号 ' + esc(ifd.rssi) + ' dBm' : '信号 —'; + if (ifd.dns && ifd.dns.length) meta += ' · DNS ' + esc(ifd.dns.join(' / ')); + meta += '
'; + h += '

RX / TX 为接口累计字节(含局域网/面板轮询/DNS 等内网往返),非手机热点公网消耗。

'; + h += meta; + h += ''; + if (!ifs.length) h += ''; + ifs.forEach(function (f) { + const up = !!f.up; + h += '' + + '' + + '' + + ''; + }); + h += '
接口类型状态IP 地址网关RX / TX
无接口数据
' + esc(f.name) + '' + esc(f.kind || '—') + '' + (up ? 'UP' : 'DOWN') + '' + esc(f.ip || '—') + '' + esc(f.gw || '—') + '' + fmtBytes(f.rx_bytes) + ' / ' + fmtBytes(f.tx_bytes) + '
'; + const rf = (ifd && ifd.rfkill) || []; + if (rf.length) { + h += '
'; + rf.forEach(r => h += '' + esc(r.type) + (r.soft ? ' 软关' : '') + (r.hard ? ' 硬关' : '') + ''); + h += '
'; + } + h += '
'; + return h; + } + + function pubBytesCard(pb) { + if (!pb) return ''; + const t = pb.total || {}; + let h = '

公网流量

'; + h += '

按 TCP 连接计量(远端公网才计入);UDP/DNS/mDNS/ARP 不计。接口累计含内网往返,非公网消耗。

'; + h += '
公网累计:↓ ' + fmtBytes(t.rx || 0) + ' · ↑ ' + fmtBytes(t.tx || 0) + '
'; + const ents = pb.by_entity || []; + if (ents.length) { + h += ''; + ents.forEach(e => { + h += '' + + '' + + ''; + }); + h += '
实体公网 ↓ / ↑内网 ↓ / ↑
' + netBadge(e.entity) + '' + fmtBytes((e.pub && e.pub[0]) || 0) + ' / ' + fmtBytes((e.pub && e.pub[1]) || 0) + '' + fmtBytes((e.lan && e.lan[0]) || 0) + ' / ' + fmtBytes((e.lan && e.lan[1]) || 0) + '
'; + } + const flows = pb.by_flow || []; + if (flows.length) { + h += '
公网流 Top
'; + h += ''; + flows.forEach(f => { + h += '' + + '' + + ''; + }); + h += '
远端实体↓ / ↑
' + esc(f.ip || '') + ':' + esc('' + f.port) + + (f.domain ? ' ' + esc(f.domain) + '' : '') + '' + netBadge(f.entity) + '' + fmtBytes(f.rx || 0) + ' / ' + fmtBytes(f.tx || 0) + '
'; + } + h += '
'; + return h; + } + + function netStatusCards(d) { + let h = ''; + if (d && d.rate && (d.rate.rx_bps != null || d.rate.tx_bps != null)) + h += '

实时速率(上行接口,不含回环):↓ ' + fmtBytes(d.rate.rx_bps) + '/s · ↑ ' + fmtBytes(d.rate.tx_bps) + '/s

'; + if (d && d.pub_bytes) h += pubBytesCard(d.pub_bytes); + // WiFi 摘要 + const w = (d && d.wifi) || {}; + h += '

WiFi

'; + if (w.connected) { + h += row('SSID', '' + esc(w.ssid || '—') + ''); + h += row('状态', '' + esc(w.state || '已连接') + ''); + h += row('IP', esc(w.ip || '—')); + h += row('信号', (w.rssi != null ? esc(w.rssi) + ' dBm' : '—')); + } else { + h += row('状态', '未连接'); + } + h += '
'; + // 蓝牙摘要 + const bt = (d && d.bt) || {}; + h += '

蓝牙

'; + if (bt.available) { + h += row('状态', '' + (bt.powered ? '已开启' : '已关闭') + ''); + h += row('地址', esc(bt.address || '—')); + h += row('已配对', esc(bt.paired != null ? bt.paired : '—')); + h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—')); + } else { + h += row('状态', '无蓝牙'); + } + h += '
'; + return h; + } + + function netPolicyCard(pol) { + if (!pol || pol.available === false) return '

网络策略

' + homeUnavailable() + '
'; + const enforce = pol.enforce || ''; + const dnsOnly = enforce === 'dns_only'; + let h = '

网络策略

'; + h += '
' + (dnsOnly ? 'ℹ️' : '⚠️') + '' + + (dnsOnly ? '执行模式:仅 DNS 层拒绝(enforce=dns_only;本内核 SOCK_DESTROY 为 no-op,不会真正断连)' : '执行模式:' + esc(enforce)) + '
'; + const rules = pol.rules || []; + if (!rules.length) h += '
暂无策略规则(默认放行)
'; + else { + h += ''; + rules.forEach(r => { + h += '' + + '' + + '' + + '' + + ''; + }); + h += '
实体网络白名单域名黑名单域名操作
' + netBadge(r.entity) + '' + (r.net === 'block' ? '阻断' : '放行') + '' + esc((r.allow_hosts || []).join(', ') || '—') + '' + esc((r.deny_hosts || []).join(', ') || '—') + '
' + + '' + + '
'; + } + h += '
' + + '' + + '
'; + h += '
' + + '
'; + h += '

防自锁实体(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)拒 block。

'; + h += '
'; + return h; + } + + function netConnsBody(cn) { + const list = (cn && cn.conns) || []; + let h = ''; + /* 连接 top 摘要(聚合计数,轻量——overview 与网速页的观察摘要,非完整归因)。 */ + const entCnt = {}; + (list || []).forEach(c => { entCnt[c.entity] = (entCnt[c.entity] || 0) + 1; }); + const top = Object.keys(entCnt).sort((a, b) => entCnt[b] - entCnt[a]).slice(0, 3); + if (top.length) { + h += '
连接 top:' + top.map(e => '' + netBadge(e) + ' ×' + entCnt[e] + '').join(' · ') + '(共 ' + list.length + ' 条)
'; + } + if (!list.length) h += '
暂无连接' + (netEntityFilter ? '(该实体)' : '') + '
'; + else { + h += ''; + list.forEach(function (c) { + h += '' + + '' + + '' + + '' + + ''; + }); + h += '
实体远端(域名)本端状态寿命
' + netBadge(c.entity) + '' + esc(c.domain || c.remote || '—') + '' + esc(c.local || '—') + '' + esc(c.state || '—') + '' + fmtDur(c.age) + '
'; + if (cn.truncated) h += '

连接表超出单帧上限,已截断(' + (cn.count || list.length) + ' 条)

'; + } + return h; + } + + function netDnsBody(dn) { + if (!dn) return '
DNS 代理不可用
'; + let h = '' + + row('状态', '' + (dn.enabled ? '已启用' : '已停用') + '') + + row('模式', esc(dn.mode || '—')) + + row('上游', esc(dn.upstream || '—')) + + row('缓存', (dn.cache_hits || 0) + ' / ' + (dn.cache_total || 0)) + + row('查询数', esc(dn.queries)) + + row('拦截 (deny)', esc(dn.denies)) + + row('首次域名', esc(dn.first_seen)) + + row('resolv.conf', (dn.resolv_modified ? '已被代理改写' : '未改写')) + + '
'; + h += '

写操作(清缓存)需系统身份,面板路径可能被 netmgrd 拒绝。

'; + return h; + } + + function netBannerErr(msg) { + const nb = $('#net-banner'); + if (!nb) return; + nb.innerHTML = '⚠️网络状态获取失败:' + esc(msg) + '(保留上次内容,5 秒后自动重试)' + + ''; + nb.hidden = false; + } + + async function fetchNetOnce() { + if (state.route !== 'net') return; + const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body'), + ifEl = $('#net-ifaces'), poEl = $('#net-policy'); + let st, cn, dn, ifd, pol; + try { st = await api('GET', '/api/net/status'); } + catch (e) { netBannerErr(errText(e)); return; } + netLoaded = true; + const nb = $('#net-banner'); if (nb) { nb.hidden = true; nb.innerHTML = ''; } + try { cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); } + catch (e) { cn = null; } + try { dn = await api('GET', '/api/net/dns'); } + catch (e) { dn = null; } + try { ifd = await api('GET', '/api/net/ifaces'); } catch (e) { ifd = null; } + try { pol = await api('GET', '/api/net/policy'); } catch (e) { pol = null; } + if (state.route !== 'net') return; + if (ifEl) ifEl.innerHTML = netIfaceCards(ifd); + if (stEl) stEl.innerHTML = netStatusCards(st); + if (poEl) poEl.innerHTML = netPolicyCard(pol); + if (cbEl) cbEl.innerHTML = netConnsBody(cn); + if (dbEl) dbEl.innerHTML = netDnsBody(dn); + } + + async function fetchNetConns() { + if (state.route !== 'net') return; + const cbEl = $('#net-conns-body'); if (!cbEl) return; + let cn; + try { + cn = await api('GET', '/api/net/conns' + (netEntityFilter ? '?entity=' + encodeURIComponent(netEntityFilter) : '')); + } catch (e) { cn = null; } + if (state.route !== 'net') return; + cbEl.innerHTML = netConnsBody(cn); + } + + async function fetchNetLog() { + if (state.route !== 'net') return; + const el = $('#net-log-body'); + try { + const d = await api('GET', '/api/net/log'); + if (el) { + if (!d || !d.file) { el.innerHTML = '
暂无 net_ 日志' + (d && !d.file ? '(netmgrd 未写日志)' : '') + '
'; return; } + el.innerHTML = '
' + esc((d.lines || []).join('\n') || '(空)') +
+          (d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '
'; + const name = $('#net-log-file'); if (name) name.textContent = d.file; + } + } catch (e) { if (el) el.innerHTML = '
日志加载失败:' + esc(errText(e)) + '
'; } + } + + async function renderNet() { + stopNet(); + netEntityFilter = ''; + netLoaded = false; + $('#content').innerHTML = + '
' + + '' + + '

加载网络接口…

' + + '

加载网络状态…

' + + '

加载网络策略…

' + + '

每实体连接表

' + + '
' + + '' + + '
' + + '

加载连接表…

' + + '

DNS 代理

' + + '
' + + '
' + + '

加载 DNS 统计…

' + + '

网络日志

' + + '
' + + '

加载日志…

' + + '
'; + netTimer = setInterval(fetchNetOnce, 5000); // status 仅瞬态横幅,不停止轮询 + fetchNetOnce(); // 填充 status + conns + dns + fetchNetLog(); + } + + function bindNet() { + $('#content').addEventListener('input', function (e) { + const f = e.target.closest('#net-entity-filter'); if (!f) return; + netEntityFilter = f.value.trim(); + const cb = $('#net-conns-body'); if (cb) cb.textContent = '筛选…'; + fetchNetConns(); + }); + $('#content').addEventListener('click', async function (e) { + if (e.target.closest('#net-conns-refresh')) { fetchNetConns(); return; } + if (e.target.closest('#net-log-refresh')) { fetchNetLog(); return; } + if (e.target.closest('#net-banner-retry') || e.target.closest('#net-retry')) { fetchNetOnce(); return; } + const flush = e.target.closest('#net-dns-flush'); + if (flush) { + flush.disabled = true; + try { + const d = await api('POST', '/api/net/dns/flush'); + toast('已清空 DNS 缓存(' + (d && d.entries != null ? d.entries : '') + ' 条)'); + } catch (err) { toast('清缓存失败:' + errText(err)); } + flush.disabled = false; + fetchNetOnce(); + } + /* ---- 网络策略写(require_auth + 二次确认;enforce=dns_only 如实显示,不谎称断连) ---- */ + const tgl = e.target.closest('[data-ptoggle]'); + if (tgl) { + const ent = tgl.dataset.ptoggle, net = tgl.dataset.net; + const ok = await confirmDlg('网络策略', '确认将 ' + ent + ' 网络策略设为「' + (net === 'block' ? '阻断(DNS 层拒绝,不会真断连)' : '放行') + '」?', net === 'block'); + if (!ok) { fetchNetOnce(); return; } + try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: net }); toast('已更新策略:' + ent); } + catch (err) { toast('策略更新失败:' + errText(err)); } + fetchNetOnce(); + return; + } + const del = e.target.closest('[data-pdel]'); + if (del) { + const ent = del.dataset.pdel; + const ok = await confirmDlg('删除策略', '确认删除 ' + ent + ' 的全部策略规则(恢复默认放行)?', true); + if (!ok) { fetchNetOnce(); return; } + try { await api('POST', '/api/net/policy', { op: 'del', entity: ent }); toast('已删除策略:' + ent); } + catch (err) { toast('删除失败:' + errText(err)); } + fetchNetOnce(); + return; + } + const blk = e.target.closest('#pol-block'); + if (blk) { + const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : ''); + if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; } + const ok = await confirmDlg('阻断实体', '确认将 ' + ent + ' 设为阻断(DNS 层拒绝,不会真断连)?', true); + if (!ok) { fetchNetOnce(); return; } + try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'block' }); toast('已阻断:' + ent); } + catch (err) { toast('阻断失败:' + errText(err)); } + fetchNetOnce(); + return; + } + const alw = e.target.closest('#pol-allow'); + if (alw) { + const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : ''); + if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; } + try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'allow' }); toast('已放行:' + ent); } + catch (err) { toast('放行失败:' + errText(err)); } + fetchNetOnce(); + return; + } + const dom = e.target.closest('#pol-dom'); + if (dom) { + const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : ''); + const hst = ($('#pol-domain') ? $('#pol-domain').value.trim() : ''); + if (!ent) { toast('请输入实体'); return; } + if (!hst || (hst[0] !== '+' && hst[0] !== '-')) { toast('域名须以 +(白)或 -(黑)开头'); return; } + const ok = await confirmDlg('域名规则', '确认对 ' + ent + ' ' + (hst[0] === '+' ? '加入白名单' : '加入黑名单') + ' ' + hst.slice(1) + '?', hst[0] === '-'); + if (!ok) { fetchNetOnce(); return; } + try { await api('POST', '/api/net/policy', { op: 'set_domain', entity: ent, arg: hst }); toast('域名规则已更新'); } + catch (err) { toast('域名规则失败:' + errText(err)); } + fetchNetOnce(); + return; + } + }); + } + + function joinRel(a, b) { + if (!a || a === '/') return '/' + b; + return (a.endsWith('/') ? a.slice(0, -1) : a) + '/' + b; + } + + function crumbHtml(items, cur) { // items: [{name,rel}] + let h = '
'; + h += '根目录'; + items.forEach(it => { h += '/' + esc(it.name) + ''; }); + return h + '
'; + } + + async function navFiles(rel) { + const page = $('#files-page'); + if (!page) return; + let relPath = rel || '/'; + page.innerHTML = '

加载中…

'; + let d; + try { d = await api('GET', '/api/files?path=' + encodeURIComponent(relPath)); } + catch (e) { page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + state.filesRel = d.rel || relPath; state.filesAbs = d.path; + let h = crumbHtml(segments(d.rel), d.rel); + // 白名单快捷入口(根目录下) + if (d.rel === '/' && d.allowed) { + h += '
' + + d.allowed.map(a => '').join(' ') + '
'; + } + h += '
' + + '' + + '' + + '' + + '
'; + h += '
'; + if (!d.items.length) h += ''; + d.items.forEach(function (it) { + const isDir = it.type === 'dir'; + const icon = isDir ? '📁' : (it.type === 'file' ? '📄' : '❔'); + h += '' + + ''; + }); + h += '
名称大小修改时间操作
空目录
' + (isDir ? '' : '') + icon + ' ' + esc(it.name) + (isDir ? '' : '') + + '' + (isDir ? '—' : fmtBytes(it.size)) + '' + fmtTime(it.mtime) + '
' + + (isDir ? '' : '下载') + + '' + + '' + + '
'; + page.innerHTML = h; + } + + function segments(rel) { + const parts = (rel || '').split('/').filter(Boolean); const out = []; let cur = ''; + parts.forEach(p => { cur += '/' + p; out.push({ name: p, rel: cur }); }); + return out; + } + + function renderFiles() { $('#content').innerHTML = '
'; navFiles(state.filesRel || '/'); } + + function absFor(name) { return state.filesAbs ? (state.filesAbs.endsWith('/') ? state.filesAbs + name : state.filesAbs + '/' + name) : null; } + + function bindFiles() { + const c = $('#content'); + c.addEventListener('click', async function (e) { + const t = e.target; + const enter = t.closest('[data-enter]'); const dl = t.closest('[data-dl]'); + const ren = t.closest('[data-ren]'); const del = t.closest('[data-del]'); + const dirlink = t.closest('[data-dir]'); const nav = t.closest('[data-nav]'); + const crumb = t.closest('[data-rel]'); + const btn = t.closest('button[id]'); + if (crumb) { navFiles(crumb.dataset.rel); return; } + if (nav) { navFiles(nav.dataset.nav); return; } + if (dirlink || enter) { const n = (dirlink || enter).dataset.directory || (dirlink || enter).dataset.dir || (dirlink || enter).dataset.enter; navFiles(joinRel(state.filesRel, n)); return; } + if (dl) { + const abs = absFor(dl.dataset.dl); if (!abs) return toast('无当前目录'); + doDownload('/api/files/download?path=' + encodeURIComponent(abs), dl.dataset.dl); + return; + } + if (ren) { renameFile(ren.dataset.ren); return; } + if (del) { removeFile(del.dataset.del); return; } + if (btn && btn.id === 'file-newdir') { mkdirFile(); return; } + if (btn && btn.id === 'file-upload') { const fi = $('#file-input'); if (fi) { fi.value = ''; fi.click(); } return; } + if (btn && btn.id === 'file-refresh') { navFiles(state.filesRel); return; } + }); + c.addEventListener('change', function (e) { + if (e.target.id === 'file-input') uploadFiles(e.target.files); + }); + } + + function doDownload(url, name) { + return apiDownload(url).then(blobUrl => { + const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'file'; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); + }).catch(e => toast('下载失败:' + errText(e))); + } + + async function removeFile(name) { + const abs = absFor(name); if (!abs) return; + const ok = await confirmDlg('删除 ' + name, '此操作不可撤销,确认删除?\n' + abs, true); + if (!ok) return; + try { + await api('DELETE', '/api/files?path=' + encodeURIComponent(abs)); + toast('已删除 ' + name); navFiles(state.filesRel); + } catch (e) { toast('删除失败:' + errText(e)); } + } + + async function renameFile(name) { + const abs = absFor(name); if (!abs) return; + const nn = prompt('将「' + name + '」重命名为:', name); + if (!nn || nn === name) return; + try { + await api('POST', '/api/files/rename', { path: abs, new_name: nn }); + toast('已重命名'); navFiles(state.filesRel); + } catch (e) { toast('重命名失败:' + errText(e)); } + } + + async function mkdirFile() { + const abs = state.filesAbs; if (!abs) return; + const nn = prompt('新建目录名称(将创建于 ' + state.filesRel + '):', 'newdir'); + if (!nn) return; + try { + await api('POST', '/api/files/mkdir', { path: joinRel(abs, nn) }); + toast('已创建 ' + nn); navFiles(state.filesRel); + } catch (e) { toast('创建失败:' + errText(e)); } + } + + async function uploadFiles(files) { + if (!files || !files.length) return; + const abs = state.filesAbs; if (!abs) return toast('无当前目录'); + const fd = new FormData(); fd.append('path', abs); + Array.prototype.forEach.call(files, f => fd.append('file', f)); + const n = files.length; + toast('正在上传 ' + n + ' 个文件…'); + try { + await api('POST', '/api/files/upload', fd); + toast('上传完成'); navFiles(state.filesRel); + } catch (e) { toast('上传失败:' + errText(e)); } + } + + function renderApps() { + $('#content').innerHTML = '

加载应用列表…

'; + (async function () { + let d; + try { d = await api('GET', '/api/apps'); } + catch (e) { if (state.route === 'apps') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'apps') return; + let h = '
'; + h += '
' + + '' + + '' + + '
'; + h += '
'; + if (!d.apps.length) h += '
尚未安装任何 MeowAPP
'; + d.apps.forEach(a => { + const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase()); + h += '
' + + '
' + ino + '
' + + '
' + esc(a.name || a.package || '未命名') + '
' + + '
' + esc(a.package || '') + '
' + + '
版本 ' + esc(a.version || '—') + (a.invalid ? ' · 无效' : '') + + (a.size ? ' · ' + fmtBytes(a.size) : '') + '
' + + '
' + + '' + + '导出 .mpk' + + '' + + '
'; + }); + h += '
'; + // 本地源 .mpk 安装(后端支持 path 形式) + h += '

从卡上路径安装

' + + '
' + + '
'; + $('#content').innerHTML = h; + })(); + } + + function bindApps() { + const c = $('#content'); + c.addEventListener('click', async function (e) { + const t = e.target; + const install = t.closest('#app-install'); const refresh = t.closest('#app-refresh'); + const info = t.closest('[data-ainfo]'); const exp = t.closest('[data-aexport]'); + const uninst = t.closest('[data-auninst]'); const goPath = t.closest('#app-install-path-go'); + if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; } + if (refresh) { renderApps(); return; } + if (info) { appInfo(info.dataset.ainfo); return; } + if (exp) { + e.preventDefault(); + if (exp.dataset.busy) return; /* 打包期间防连点 */ + exp.dataset.busy = '1'; + const oldTxt = exp.textContent; + exp.textContent = '打包中…'; + toast('正在打包导出 ' + exp.dataset.aexport + ',请稍候…'); + doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk') + .finally(() => { delete exp.dataset.busy; exp.textContent = oldTxt; }); + return; + } + if (uninst) { appUninstall(uninst.dataset.auninst); return; } + if (goPath) { installFromPath($('#app-install-path').value); return; } + }); + c.addEventListener('change', function (e) { + if (e.target.id === 'app-file') installFromFile(e.target.files); + }); + } + + async function installFromFile(files) { + if (!files || !files.length) return; + const fd = new FormData(); fd.append('file', files[0]); + toast('正在安装 ' + files[0].name + '…'); + try { + const d = await api('POST', '/api/apps/install', fd); + toast('已安装 ' + (d.package || '应用') + (d.version ? ' v' + d.version : '')); + renderApps(); + } catch (e) { toast('安装失败:' + errText(e)); } + } + + async function installFromPath(p) { + if (!p) return toast('请输入 .mpk 路径'); + toast('正在安装 ' + p + '…'); + try { + const d = await api('POST', '/api/apps/install', { path: p }); + toast('已安装 ' + (d.package || '应用')); renderApps(); + } catch (e) { toast('安装失败:' + errText(e)); } + } + + async function appUninstall(pkg) { + const ok = await confirmDlg('卸载 ' + pkg, '将移除应用与数据预留(数据默认保留)。确认卸载?', true); + if (!ok) return; + try { await api('DELETE', '/api/apps/' + pkg); toast('已卸载 ' + pkg); renderApps(); } + catch (e) { toast('卸载失败:' + errText(e)); } + } + + async function appInfo(pkg) { + try { + const d = await api('GET', '/api/apps/' + pkg); + let txt = '包名: ' + d.package + '\n名称: ' + d.name + '\n版本: ' + d.version + '\nexec: ' + d.exec + + '\nsize: ' + fmtBytes(d.size) + '\n路径: ' + d.path; + if (d.description) txt += '\n描述: ' + d.description; + alert(txt); + } catch (e) { toast('获取详情失败:' + errText(e)); } + } + + function renderFontsPage() { + $('#content').innerHTML = '
' + + '

字体库

' + + '
' + + '' + + '' + + '
' + + '

加载字体库列表…

' + + '

上传 tar.gz(内含 font.png + font.idx + font.json,平铺三件套)→ 校验后解压到 ' + + 'MeowOS/fonts/<family>/<weight>/;同 family/weight 冲突时会要求确认覆盖。' + + '导入后进设备「设置 → 字体」页,列表自动重扫生效。

'; + loadFontsPage(); + } + + async function loadFontsPage() { + const box = $('#fpage-list'); if (!box) return; + let d; + try { d = await api('GET', '/api/fonts'); } + catch (e) { box.innerHTML = '

字体库列表加载失败:' + esc(errText(e)) + '

'; return; } + const fs = (d && d.fonts) || []; + if (!fs.length) { box.innerHTML = '
暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)
'; return; } + let h = ''; + fs.forEach(f => { + h += '' + + '' + + '' + + ''; + }); + box.innerHTML = h + '
familyweight名称tierglyph 数操作
' + esc(f.family || '') + '' + esc(f.weight || '') + '' + esc(f.name || '—') + '' + esc(f.tier || '—') + '' + (f.glyph_count ? Number(f.glyph_count).toLocaleString() : '—') + '
'; + } + + async function importFontPage(files) { + if (!files || !files.length) return; + const f = files[0]; + const btn = $('#fpage-import'); + if (btn && btn.disabled) return; // 上传中防连点(进度态) + const fd = new FormData(); fd.append('file', f); + if (btn) { btn.disabled = true; btn.textContent = '导入中…'; } + toast('正在导入 ' + f.name + '…'); + let res = null; + try { res = await api('POST', '/api/fonts/import', fd); } + catch (err) { + if (err.code === 'CONFLICT') { + const ok = await confirmDlg('覆盖字库', '目标字库(同 family/weight)已存在,覆盖导入吗?', true); + if (ok) { + try { res = await api('POST', '/api/fonts/import?force=1', fd); } + catch (e2) { toast('导入失败:' + errText(e2)); } + } + } else toast('导入失败:' + errText(err)); + } + if (btn) { btn.disabled = false; btn.textContent = '上传字体库 .tar.gz'; } + if (res) { + toast('已导入 ' + (res.family || '') + '/' + (res.weight || '')); + if (res.warning) toast(res.warning); + else if (res.hint) toast(res.hint); + loadFontsPage(); + } + } + + function bindFonts() { + const c = $('#content'); + c.addEventListener('click', function (e) { + if (e.target.closest('#fpage-import')) { const inp = $('#fpage-file'); if (inp) { inp.value = ''; inp.click(); } return; } + if (e.target.closest('#fpage-refresh')) { loadFontsPage(); return; } + const fe = e.target.closest('[data-fpexport]'); + if (fe) { + if (fe.dataset.busy) return; // 打包期间防连点 + fe.dataset.busy = '1'; + const old = fe.textContent; fe.textContent = '打包中…'; + const f = fe.dataset.fpexport, w = fe.dataset.fpweight; + doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), + (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz') + .finally(() => { delete fe.dataset.busy; fe.textContent = old; }); + return; + } + }); + c.addEventListener('change', function (e) { + if (e.target.id === 'fpage-file') importFontPage(e.target.files); + }); + } + + function settingControl(it) { + const key = it.key; + if (key === 'panel.auth_pass') { + const txt = it.set ? '已设置' : '未设置'; + return '
面板密码(' + txt + ')
' + + '
'; + } + if (it.type === 'bool') { + return '
' + esc(key) + '
' + + '
'; + } + if (it.type === 'int') { + const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max); + return '
' + + '
' + esc(it.value == null ? '' : it.value) + '' + + '
'; + } + if (it.type === 'enum') { + const opts = (it.options || []).map(o => '').join(''); + return '
'; + } + return '
'; + } + + async function renderSettings() { + $('#content').innerHTML = '

加载设置…

'; + let d; + try { d = await api('GET', '/api/settings'); } + catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'settings') return; + let h = '
'; + h += '
' + + '' + + '
'; + const items = d.items || []; + // 面板外观(主题):本面板覆盖(localStorage),默认跟随设备 ui.style / display.theme + h += '

主题(面板外观)

' + + '
' + + '
' + + '' + + '
' + + '
' + + '

默认跟随设备 ui.style(当前:' + esc(devStyleLabel()) + ');在面板选择即为本面板覆盖,只影响网页观感(顶栏切换才会同步设备)。

' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '

跟随系统 = 设备 display.theme(当前:' + esc(devThemeLabel()) + ');跟随浏览器 = prefers-color-scheme,随浏览器日/夜自动切换。

'; + // 面板 PIN(访问级鉴权):显示状态 + 修改/清空控件(走 /api/auth/* 校验旧 PIN + PUT /api/settings/panel.pin) + h += '

面板 PIN(访问鉴权)

' + + '

查询鉴权状态…

'; + // 面板鉴权(可选防护)单独一卡 + const authItems = items.filter(x => x.key === 'panel.auth_enabled' || x.key === 'panel.auth_pass'); + if (authItems.length) { + h += '

面板鉴权(可选防护)

' + authItems.map(settingControl).join('') + + '

开启后访问面板需密码登录;密码以加盐哈希存储,不落明文。重启/关机/OTA 等危险操作亦需会话。

'; + } + const rest = items.filter(x => x.key !== 'panel.auth_enabled' && x.key !== 'panel.auth_pass'); + // 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看 + const groups = [['开关/布尔', rest.filter(x => x.type === 'bool')], + ['数值/滑条', rest.filter(x => x.type === 'int')], + ['枚举/选择', rest.filter(x => x.type === 'enum')], + ['字符串/路径', rest.filter(x => x.type === 'str')]]; + groups.forEach(function (g) { + if (!g[1].length) return; + h += '

' + g[0] + '

' + g[1].map(settingControl).join('') + '
'; + }); + // 字体库(.zip 导入 / 导出) + h += '

字体库(.tar.gz 导入 / 导出)

' + + '
' + + '
' + + '

加载字体库列表…

' + + '

导入 tar.gz(内含 font.png + font.idx + font.json)→ 校验后解压到 MeowOS/fonts/<family>/<weight>/;导出把该三件套打包为 tar.gz。导入后进设备「设置 → 字体」页列表自动重扫生效。

'; + h += '
'; + $('#content').innerHTML = h; + applyTheme(); // 同步「主题」卡 seg 选中态(applyTheme 按 id 查询,不存在时为空操作) + loadFonts(); + loadPinStatus(); + } + + async function loadPinStatus() { + const box = $('#pin-status'); + if (!box) return; + let st = null; + try { st = await api('GET', '/api/auth/status'); } catch (e) { box.innerHTML = '

无法查询鉴权状态:' + esc(errText(e)) + '

'; return; } + let txt, cls; + if (st.locked) { txt = '锁定中(' + Math.ceil((st.lock_remaining_ms || 0) / 1000) + ' 秒后可重试)'; cls = 'locked'; } + else if (st.pin_set) { txt = '已设置'; cls = 'set'; } + else if (st.legacy) { txt = '未设(旧密码屏障生效,建议改设为 PIN)'; cls = 'unset'; } + else { txt = '未设置(当前访问面板无需 PIN)'; cls = 'unset'; } + const canEdit = st.pin_set && !st.locked; + let h = '
' + esc(txt) + '' + + (st.session ? ' · 当前会话有效' : ' · 未登录') + '
'; + h += '
' + + '' + + '' + + (st.locked ? '锁定期间不可修改/清空' : '修改/清空需先验证当前 PIN') + + '
'; + h += '
' + + '' + + '设备下次亮屏免 PIN 进入一次(不清除 PIN)
'; + h += '

4 位数字 PIN,纯加盐哈希存储(不落明文);未设 PIN 时写端点一律拒绝。

'; + box.innerHTML = h; + } + + function pinEditDlg(mode) { // 返回 {old, n1}(n1=null 表示清空)或 null(取消) + return new Promise(function (resolve) { + const t = $('#pindlg-title'), b = $('#pindlg-body'); + const oldIn = $('#pindlg-old'), n1 = $('#pindlg-new'), n2 = $('#pindlg-new2'); + const err = $('#pindlg-err'); + if (mode === 'change') { + t.textContent = '修改 PIN'; + b.textContent = '请输入当前 PIN 与新 PIN(4 位数字)。'; + oldIn.hidden = false; n1.hidden = false; n2.hidden = false; + n1.placeholder = '新 4 位 PIN'; n2.placeholder = '再次输入新 PIN'; + } else { + t.textContent = '清空 PIN'; + b.textContent = '清空后访问面板将不再要求 PIN。请输入当前 PIN 确认。'; + oldIn.hidden = false; n1.hidden = true; n2.hidden = true; + } + err.hidden = true; oldIn.value = ''; n1.value = ''; n2.value = ''; + oldIn.focus(); + const scrim = $('#pindlg'); scrim.hidden = false; + const ok = $('#pindlg-ok'), cancel = $('#pindlg-cancel'); + const done = function (v) { scrim.hidden = true; ok.onclick = cancel.onclick = scrim.onclick = null; resolve(v); }; + ok.onclick = function () { + const old = oldIn.value.trim(); + if (!old) { err.textContent = '请输入当前 PIN'; err.hidden = false; return; } + if (mode === 'change') { + const np = n1.value.trim(); + if (!/^[0-9]{4}$/.test(np)) { err.textContent = '新 PIN 必须为 4 位数字'; err.hidden = false; return; } + if (np !== n2.value.trim()) { err.textContent = '两次输入的新 PIN 不一致'; err.hidden = false; return; } + done({ old: old, n1: np }); + } else done({ old: old, n1: null }); + }; + cancel.onclick = function () { done(null); }; + scrim.onclick = function (e) { if (e.target === scrim) done(null); }; + }); + } + + async function pinEdit(mode) { + const v = await pinEditDlg(mode); + if (!v) return; + try { await api('POST', '/api/auth/login', { pin: v.old }); } // 先验证旧 PIN(客户端复核,后端改 PIN 本身不需旧值) + catch (e2) { + if (e2.code === 'LOCKED' || e2.status === 429) toast('错误次数过多,已锁定,请稍后重试(锁定 5 分钟)'); + else if (e2.status === 401) toast('当前 PIN 验证失败'); + else toast('验证失败:' + errText(e2)); + loadPinStatus(); return; + } + try { + await api('PUT', '/api/settings/panel.pin', { value: (v.n1 !== null ? v.n1 : '') }); + toast(mode === 'change' ? 'PIN 已修改' : 'PIN 已清空'); + loadPinStatus(); + } catch (e3) { toast('操作失败:' + errText(e3)); } + } + + function bindPin() { + const c = $('#content'); + c.addEventListener('click', async function (e) { + if (e.target.closest('#pin-change')) { pinEdit('change'); return; } + if (e.target.closest('#pin-clear')) { pinEdit('clear'); return; } + if (e.target.closest('#pin-unlock-once')) { + try { + await api('POST', '/api/system/pin-unlock-once', {}); + toast('已下发:设备下次亮屏免 PIN 进入一次'); + } catch (err) { toast('下发失败:' + errText(err)); } + return; + } + }); + ['#pindlg-old', '#pindlg-new', '#pindlg-new2'].forEach(function (sel) { + const el = $(sel); + if (el) el.addEventListener('input', function () { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); }); + }); + } + + async function loadFonts() { + const box = $('#font-list'); if (!box) return; + let d; + try { d = await api('GET', '/api/fonts'); } + catch (e) { box.innerHTML = '

字体库列表加载失败:' + esc(errText(e)) + '

'; return; } + const fs = (d && d.fonts) || []; + if (!fs.length) { box.innerHTML = '

暂无字库(TF 卡 MeowOS/fonts 下无完整三件套)。

'; return; } + box.innerHTML = fs.map(f => { + const nm = esc(f.name || f.family || ''); + const meta = esc((f.family || '') + '/' + (f.weight || '') + (f.tier ? ' · ' + f.tier : '') + (f.glyph_count ? ' · ' + f.glyph_count + ' 字' : '')); + return '
' + + '
' + nm + '
' + meta + '
' + + '
'; + }).join(''); + } + + async function importFont(files) { + if (!files || !files.length) return; + const f = files[0]; + const fd = new FormData(); fd.append('file', f); + toast('正在导入 ' + f.name + '…'); + let res; + try { res = await api('POST', '/api/fonts/import', fd); } + catch (err) { + if (err.code === 'CONFLICT') { + const ok = await confirmDlg('覆盖字库', '目标字库已存在,覆盖导入吗?', true); + if (!ok) return; + try { res = await api('POST', '/api/fonts/import?force=1', fd); } + catch (e2) { toast('导入失败:' + errText(e2)); return; } + } else { toast('导入失败:' + errText(err)); return; } + } + toast('已导入 ' + ((res && res.family) || '') + '/' + ((res && res.weight) || '')); + if (res && res.hint) toast(res.hint); + loadFonts(); + } + + function bindSettings() { + const c = $('#content'); + c.addEventListener('input', function (e) { + const r = e.target.closest('[data-stype="int"]'); if (!r) return; + const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value; + updateSetting(r.dataset.skey, +r.value, 'int'); + }); + c.addEventListener('change', function (e) { + if (e.target.id === 'font-file') { importFont(e.target.files); return; } + const t = e.target; + const sk = t.dataset.skey; if (!sk) return; + if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool'); + else if (t.dataset.stype === 'enum') updateSetting(sk, t.value, 'enum'); + else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str'); + }); + c.addEventListener('click', async function (e) { + // 「主题(面板外观)」卡:本面板覆盖(localStorage),不写设备 + const ss = e.target.closest('#set-style-seg .seg-btn'); + if (ss) { storageSet('meowpanel.style', ss.dataset.style); applyTheme(); return; } + const sm = e.target.closest('#set-mode-seg .seg-btn'); + if (sm) { storageSet('meowpanel.mode', sm.dataset.mode); applyTheme(); return; } + if (e.target.closest('#set-style-reset')) { storageDel('meowpanel.style'); applyTheme(); return; } + if (e.target.closest('#font-import')) { const inp = $('#font-file'); if (inp) { inp.value = ''; inp.click(); } return; } + const fe = e.target.closest('[data-fexport]'); + if (fe) { + const f = fe.dataset.fexport, w = fe.dataset.fweight; + doDownload('/api/fonts/export?family=' + encodeURIComponent(f) + '&weight=' + encodeURIComponent(w), (f || 'font') + '-' + (w || 'regular') + '.font.tar.gz'); + return; + } + if (e.target.closest('[data-stype="pass"]')) { setPanelPassword(); return; } + if (e.target.closest('#set-restore')) { + const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true); + if (!ok) return; + try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); } + catch (err) { toast('恢复失败:' + errText(err)); } + } else if (e.target.closest('#set-refresh')) { renderSettings(); } + }); + } + + async function sendKeyLabel(code, label) { + try { await api('POST', '/api/key/' + code, { press: 'tap' }); toast(label + ' 已发送'); } + catch (e) { toast('发送失败:' + errText(e)); } + } + + async function sendKeyRepeat(code, n, label) { + let count = 0, err = null; + for (let i = 0; i < n; i++) { + try { await api('POST', '/api/key/' + code, { press: 'tap' }); count++; } + catch (e) { err = errText(e); break; } // 失败即停,避免连刷 + } + if (count > 0) toast(label + ' ×' + count); + if (err) toast('发送失败:' + err); + } + + function volSteps(ay) { + return Math.max(1, Math.min(SWIPE.maxVolSteps, Math.round(ay / SWIPE.volStepPx))); + } + + function handleRemoteGesture(dir, ax, ay) { + if (dir === 'volup') sendKeyRepeat(115, volSteps(ay), '音量+'); + else if (dir === 'voldown') sendKeyRepeat(114, volSteps(ay), '音量−'); + else if (dir === 'next') sendKeyLabel(163, '下一曲'); + else if (dir === 'prev') sendKeyLabel(165, '上一曲'); + else if (dir === 'back') sendKeyLabel(158, '返回'); + } + + function remotePointerDown(e) { + if (state.route !== 'screen' || swipeState) return; // 已有一指在手 → 忽略多点触控 + if (!e.target.closest('#remote-pad')) return; // 手势只作用于右侧遥控器板(屏幕区点击=注入) + const pad = $('#remote-pad'); if (!pad) return; + const r = pad.getBoundingClientRect(); + swipeState = { id: e.pointerId, x0: e.clientX, y0: e.clientY, edge: (e.clientX - r.left) <= SWIPE.edgePx }; + } + + function remotePointerUp(e) { + const s = swipeState; + if (!s || e.pointerId !== s.id) return; + swipeState = null; + const dx = e.clientX - s.x0, dy = e.clientY - s.y0; + const ax = Math.abs(dx), ay = Math.abs(dy); + if (Math.max(ax, ay) < SWIPE.minDist) return; // 轻点/未动 → 交给按钮 click 处理 + let dir = null; + if (s.edge && dx > 0 && dx >= ay * SWIPE.axisRatio && dx >= SWIPE.minDist) dir = 'back'; // 左缘滑 → 返回 + else if (ax >= ay * SWIPE.axisRatio) dir = (dx < 0 ? 'next' : 'prev'); // 水平 → 上下曲 + else if (ay >= ax * SWIPE.axisRatio) dir = (dy < 0 ? 'volup' : 'voldown'); // 垂直 → 音量 + if (!dir) return; // 斜滑/未达占优 → 忽略 + lastSwipeAt = Date.now(); + handleRemoteGesture(dir, ax, ay); + } + + function remoteBtn(act, icon, label, title) { + return ''; + } + + function remotePadHtml() { + let h = '
'; + h += '
'; + h += remoteBtn('volup', 'volup', '音量+', '音量加(物理键红线内)'); + h += remoteBtn('voldown', 'voldown', '音量−', '音量减(物理键红线内)'); + h += remoteBtn('brightup', 'brightup', '亮度+', '调高背光亮度'); + h += remoteBtn('brightdn', 'brightdn', '亮度−', '调低背光亮度'); + h += remoteBtn('play', 'play', '播放/暂停', '媒体播放/暂停切换(code=164)'); + h += remoteBtn('next', 'next', '下一曲', '下一曲(code=163)'); + h += remoteBtn('prev', 'prev', '上一曲', '上一曲(code=165)'); + h += remoteBtn('power', 'power', '电源', '电源键(单击;code=116)'); + h += '
'; + h += '
' + + '指定亮度' + + '' + + '' + ctlBright.value + '' + + '
'; + h += ''; + h += '

滑动手势(在遥控板上):上滑=音量+ · 下滑=音量- · 左滑=下一曲 · 右滑=上一曲 · 左缘右滑=返回。
' + + '返回/主页属物理键红线(/api/key 后端禁注入 158/172),故未提供注入按钮。

'; + h += '
'; + return h; + } + + async function setBrightCtl(v) { + v = Math.max(ctlBright.min, Math.min(ctlBright.max, Math.round(v))); + if (v === ctlBright.value) return; + ctlBright.value = v; + const sl = $('#ctl-bright'); if (sl) sl.value = v; + const lbl = $('#ctl-bright-val'); if (lbl) lbl.textContent = v; + try { await api('PUT', '/api/settings/display.brightness', { value: v }); toast('亮度 = ' + v); } + catch (e) { toast('亮度保存失败:' + errText(e)); } + } + + async function sendKey(code, press, ms) { + try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); } + catch (e) { toast('发送失败:' + errText(e)); } + } + + async function renderSystem() { + $('#content').innerHTML = '

加载系统信息…

'; + let d, pw = null; + try { d = await api('GET', '/api/system'); } + catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + try { pw = await api('GET', '/api/power'); } catch (e) { pw = null; } + if (state.route !== 'system') return; + const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {}; + const boot = d.boot || {}; + const curMode = boot.mode || boot.current || null; // 后端若暴露 /tmp/meow_boot_mode → boot.mode/current + const bootDef = boot.default || '—'; + const mism = curMode && boot.default && curMode !== boot.default; + const batt = (pw && pw.battery) || {}; + const charging = (batt.state === 'Charging' || batt.state === 'Full'); + let modeHtml = mism + ? '' + esc(curMode) + ' (与默认启动不一致)' + : (curMode ? esc(curMode) : '—'); + let sysRows = row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) + + row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec)); + if (pw && pw.battery) { + sysRows += row('电池', '' + esc(batt.state || '—') + ' ' + + (batt.pct != null ? batt.pct + ' %' : '—') + + (batt.mv != null ? ' · ' + (batt.mv / 1000).toFixed(2) + ' V' : '') + + (batt.health ? ' · ' + esc(batt.health) : '')); + } + sysRows += row('当前运行模式', modeHtml); + sysRows += row('默认启动', esc(bootDef)); + sysRows += row('重启原因', esc(boot.reboot_reason || d.reboot_reason || '—')); + sysRows += row('升级状态', esc(boot.upgrade_status || d.upgrade_status || '—')); + let h = '
'; + h += '

系统信息

' + sysRows + '
'; + h += '

网络

' + + row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') + + row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') + + row('mDNS', esc(net.hostname_local || '—')) + + '
'; + h += '

升级

' + + '

当前 ' + esc(ota.current || d.version || '—') + + (ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '

' + + '
' + + '
'; + h += '

危险操作

' + + '
' + + '
' + + '

⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。

'; + h += '
'; + $('#content').innerHTML = h; + } + + function row(k, v) { return '' + k + '' + v + ''; } + + function bindSystem() { // init 一次性委托绑定(不随渲染累积) + $('#content').addEventListener('click', async function (e) { + if (e.target.closest('#sys-reboot')) { + if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true)) + try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); } + return; + } + if (e.target.closest('#sys-shutdown')) { + if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true)) + try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); } + return; + } + if (e.target.closest('#sys-ota')) { + if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true)) + try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); } + return; + } + if (e.target.closest('#sys-ota-check')) { + // 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级) + try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); } + catch (err) { toast('检查失败:' + errText(err)); } + return; + } + }); + } + + function groupName(g) { return LOG_GROUP_NAME[g] || (g || 'app'); } + + function logsCrashCard(cr) { + const ents = (cr.entities || []).filter(e => e.name); + let h = '

崩溃汇总(' + esc(cr.file || '本 boot · crash log') + ')

'; + if (!ents.length && !(cr.dmesg_count || 0)) { h += '

本 boot 无崩溃记录。

'; return h; } + h += ''; + ents.forEach(e => { + h += '' + + '' + + '' + + '' + + ''; + }); + h += '
实体崩溃数最近时间信号/退出操作
' + esc(e.name) + '' + esc(e.count) + ((e.count || 0) > 0 ? ' ⚠' : '') + '' + esc(e.last_time || '—') + '' + esc(e.last_kind || '—') + '' + (e.last_code != null && e.last_code !== 0 ? ' (' + esc(e.last_code) + ')' : '') + '
'; + if (cr.dmesg_count) { + h += '

内核 oops / segfault 捕获:' + esc(cr.dmesg_count) + ' 条' + + ((cr.dmesg_tail || []).length ? '

' + esc((cr.dmesg_tail || []).map(x => '· ' + x).join('\n')) + '
' : '') + '

'; + } + h += '
'; + return h; + } + + function renderLogsFiles(files) { + const el = $('#logs-files'); + if (!el) return; + const filtered = files.filter(f => { + if (logsGroup !== 'all' && (f.group || 'app') !== logsGroup) return false; + if (logsPrefix && !f.name.toLowerCase().startsWith(logsPrefix.toLowerCase())) return false; + return true; + }); + let h = ''; + if (!filtered.length) h += ''; + filtered.forEach(f => { + h += '' + + ''; + }); + h += '
文件分组大小修改时间操作
暂无日志(或过滤后无结果)
' + esc(f.name) + '' + groupName(f.group) + '' + fmtBytes(f.size) + '' + fmtTime(f.mtime) + '
'; + el.innerHTML = h; + } + + async function renderLogs() { + $('#content').innerHTML = '

加载日志列表…

'; + const r = await Promise.allSettled([api('GET', '/api/logs'), api('GET', '/api/logs/crash')]); + if (state.route !== 'logs') return; + if (r[0].status !== 'fulfilled') { + $('#content').innerHTML = emptyState('加载失败:' + errText(r[0].reason), r[0].reason && r[0].reason.code); + return; + } + const d = r[0].value; + const cr = (r[1].status === 'fulfilled') ? r[1].value : null; + let h = '
'; + if (cr && ((cr.count || 0) > 0 || (cr.dmesg_count || 0) > 0 || (cr.entities || []).length)) h += logsCrashCard(cr); + h += '

日志文件(MeowOS/logs)

'; + h += '
' + esc(d.dir || '') + '
'; + h += '
'; + h += ''; + h += '
'; + h += '
'; + Object.keys(LOG_GROUP_NAME).forEach(g => { + h += ''; + }); + h += '
'; + h += '
加载…
'; + h += '
'; + $('#content').innerHTML = h; + logsFiles = d.files || []; + renderLogsFiles(logsFiles); + } + + function bindLogs() { + $('#content').addEventListener('click', async function (e) { + const gbtn = e.target.closest('[data-log-group]'); + if (gbtn) { + logsGroup = gbtn.dataset.logGroup; + $$('[data-log-group]').forEach(b => b.classList.toggle('btn-primary', b === gbtn)); + renderLogsFiles(logsFiles); + return; + } + const tb = e.target.closest('[data-crash-tail]'); + if (tb) { await viewLogsCrashTail(tb.dataset.crashTail, tb); return; } + const t = e.target.closest('[data-log]'); if (!t) return; + const p = $('#log-view'); + try { + const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(t.dataset.log) + '&lines=400'); + p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : ''); + } catch (err) { toast('查看失败:' + errText(err)); } + }); + $('#content').addEventListener('input', function (e) { + const p = e.target.closest('[data-log-prefix]'); + if (!p) return; + logsPrefix = p.value; + renderLogsFiles(logsFiles); + }); + } + + async function viewLogsCrashTail(name, btn) { + btn.textContent = '加载中…'; + let file; + try { + const d = await api('GET', '/api/files?path=' + encodeURIComponent('/MeowOS/logs/' + name + '/')); + const files = (d.items || []).filter(f => f.type === 'file' && f.name && f.name.indexOf('stdout_') === 0); + if (!files.length) { btn.textContent = 'stdout 尾部'; toast('未找到 ' + name + ' 的 stdout 日志'); return; } + files.sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); + file = files[0].name; + } catch (err) { btn.textContent = 'stdout 尾部'; toast('查找失败:' + errText(err)); return; } + try { + const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(name + '/' + file) + '&lines=200'); + const lines = (d.lines || []); + const p = $('#log-view'); + if (p) { p.hidden = false; p.textContent = name + '/' + file + '\n' + (lines.join('\n') || '(空)') + (d.truncated ? '\n…(超出尾部上限,部分被截断)' : ''); } + } catch (err) { toast('读取失败:' + errText(err)); } + btn.textContent = 'stdout 尾部'; + } + + async function renderNotif() { + $('#content').innerHTML = '

加载通知…

'; + let d; + try { d = await api('GET', '/api/notifications'); } + catch (e) { if (state.route === 'notif') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'notif') return; + let h = '
'; + h += '
' + + '
'; + const list = d.notifications || []; + if (!list.length) h += '
暂无通知
'; + list.forEach(n => { + const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低'); + h += '
' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '
' + + '
' + esc(n.summary || n.app_name || '通知') + '
' + + (n.body ? '
' + esc(n.body) + '
' : '') + + '
' + esc(n.app_name || '') + '' + fmtTime(n.ts) + '' + + '紧急度 ' + urg + '' + + (n.progress != null ? '进度 ' + n.progress + '%' : '') + + (n.ongoing ? '进行中' : '') + '
' + + '
'; + }); + h += '
'; + $('#content').innerHTML = h; + } + + function bindNotif() { + $('#content').addEventListener('click', async function (e) { + if (e.target.closest('#notif-refresh')) { renderNotif(); return; } + if (e.target.closest('#notif-clear')) { + if (await confirmDlg('清空通知', '将清空全部通知。确认?', true)) + try { await api('POST', '/api/notifications/dismiss_all'); toast('已清空'); renderNotif(); } + catch (err) { toast('清空失败:' + errText(err)); } + return; + } + const del = e.target.closest('[data-ndel]'); if (del) { + try { await api('DELETE', '/api/notifications/' + del.dataset.ndel); toast('已清除'); renderNotif(); } + catch (err) { toast('清除失败:' + errText(err)); } + } + }); + } + + function stopScreenPoll() { + screenOn = false; + if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; } + } + + function restartScreenPoll() { + screenOn = true; + if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; } + // 请求在途时不重复启动(避免并发请求);在途帧结束后会按最新 screenFps 自续 + if (!screenBusy) screenTimer = setTimeout(fetchScreen, 0); + } + + async function fetchScreen() { + if (screenBusy) return; + screenBusy = true; + const t0 = performance.now(); + let statusMsg = ''; + try { + const headers = {}; + const token = storageGet('meowpanel.token'); + if (token) headers['X-MeowToken'] = token; + const deltaQ = screenDelta ? '&delta=1' : ''; + const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70' + deltaQ, + Object.keys(headers).length ? { headers: headers } : undefined); + if (!res.ok) { $('#screen-status').textContent = '获取失败(HTTP ' + res.status + ')'; return; } + const hd = (k) => res.headers.get(k); + if (screenDelta) { + const isDelta = hd('X-Meow-Delta') === '1'; + const full = hd('X-Meow-Full') === '1'; + const changed = parseInt(hd('X-Meow-Changed') || '-1', 10); + const fw = parseInt(hd('X-Meow-W') || '480', 10); + const fh = parseInt(hd('X-Meow-H') || '720', 10); + const blob = await res.blob(); + if (!isDelta || full) { // 整帧(或旧后端无 delta 头)→ 播种/替换 canvas + const img = await createImgFromBlob(blob); + const cv = ensureScreenCanvas(fw, fh); + if (cv) { cv.getContext('2d').drawImage(img, 0, 0, fw, fh); screenCanvasReady = true; } + statusMsg = '已更新' + (changed >= 0 ? '(整帧)' : ''); + } else if (!screenCanvasReady) { // 尚未播种:取一整帧再继续 + await seedScreenCanvas(fw, fh); + if (screenCanvasReady) statusMsg = '已更新(播种)'; + } else if (changed === 0) { // 无变化:跳过叠加 + statusMsg = '无变化'; + } else { // 子图叠加 + const subX = parseInt(hd('X-Meow-Sub-X') || '0', 10); + const subY = parseInt(hd('X-Meow-Sub-Y') || '0', 10); + const subW = parseInt(hd('X-Meow-Sub-W') || fw, 10); + const subH = parseInt(hd('X-Meow-Sub-H') || fh, 10); + const img = await createImgFromBlob(blob); + const cv = ensureScreenCanvas(fw, fh); + if (cv) cv.getContext('2d').drawImage(img, subX, subY, subW, subH); + statusMsg = '已更新(增量 ' + changed + ' 块)'; + } + } else { + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const img = $('#screen-img'); + if (!img) { URL.revokeObjectURL(url); return; } + img.src = url; + if (screenObjUrl) URL.revokeObjectURL(screenObjUrl); + screenObjUrl = url; + statusMsg = '已更新'; + } + // 实测帧率:上一帧到本帧间隔的 EMA(首帧无间隔) + const now = performance.now(); + if (screenLastMs) { + const dt = (now - screenLastMs) / 1000; + screenMeasFps = screenMeasFps ? screenMeasFps * 0.7 + (1 / dt) * 0.3 : 1 / dt; + } + screenLastMs = now; + $('#screen-status').textContent = statusMsg + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString(); + } catch (e) { + $('#screen-status').textContent = '后端未连接'; + } + screenBusy = false; + // 自校正续链(本轮耗时计入周期,保证名义 fps) + if (screenOn) { + const intervalMs = Math.max(500, Math.round(1000 / screenFps)); + const wait = Math.max(0, intervalMs - (performance.now() - t0)); + screenTimer = setTimeout(fetchScreen, wait); + } + } + + async function createImgFromBlob(blob) { + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); + return img; + } finally { URL.revokeObjectURL(url); } + } + + function ensureScreenCanvas(fw, fh) { + let cv = screenCanvasRef; + if (!cv) cv = screenCanvasRef = $('#screen-canvas'); + if (!cv) return null; + if (cv.width !== fw || cv.height !== fh) { cv.width = fw; cv.height = fh; } + return cv; + } + + async function seedScreenCanvas(fw, fh) { + const headers = {}; + const token = storageGet('meowpanel.token'); + if (token) headers['X-MeowToken'] = token; + const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70', + Object.keys(headers).length ? { headers: headers } : undefined); + if (!res.ok) return false; + const img = await createImgFromBlob(await res.blob()); + const cv = ensureScreenCanvas(fw, fh); + if (!cv) return false; + cv.getContext('2d').drawImage(img, 0, 0, fw, fh); + screenCanvasReady = true; + return true; + } + + function applyScreenDeltaDom() { + const img = $('#screen-img'), cv = $('#screen-canvas'); + if (!img || !cv) return; + if (screenDelta) { img.style.display = 'none'; cv.style.display = 'block'; } + else { img.style.display = 'block'; cv.style.display = 'none'; } + } + + async function screenTap(e) { + const img = $('#screen-img'), cv = $('#screen-canvas'); + const useCanvas = screenDelta && cv && cv.style.display !== 'none'; + const el = useCanvas ? cv : img; + if (!el) return; + const r = el.getBoundingClientRect(); + if (!r.width || !r.height) return; + // 前端按 480x720(设备原生分辨率)换算 + const nx = Math.round((e.clientX - r.left) / r.width * 480); + const ny = Math.round((e.clientY - r.top) / r.height * 720); + const nxCL = Math.max(0, Math.min(479, nx)), nyCL = Math.max(0, Math.min(719, ny)); + try { + const d = await api('POST', '/api/input/tap', { x: nxCL, y: nyCL }); + if (d && d.dispatched) toast('已注入 (' + nxCL + ',' + nyCL + ')'); + else if (d && d.reason === 'local_touch_active') toast('本机触摸中,注入已暂停'); + else toast('注入未派发'); + } catch (err) { toast('注入失败:' + errText(err)); } + } + + async function renderScreen() { + screenFps = parseFloat(storageGet('screen.fps') || '1'); + if (!(screenFps >= 0.2 && screenFps <= 2)) screenFps = 1; + try { // 读当前亮度 → 遥控板滑差/增减步进基线 + const b = await api('GET', '/api/settings/display.brightness'); + if (b && b.value != null) ctlBright = { min: b.min || 1, max: b.max || 100, value: b.value }; + } catch (e) { /* 读不到用默认;不阻塞页面 */ } + if (state.route !== 'screen') return; + let h = '
'; + h += '
'; + /* 左:实时画面 */ + h += '
' + + '

实时画面

' + + '

点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。

' + + '
' + + '设备实时画面' + + '' + + '
' + + '
' + + '' + + '' + + '' + screenFps.toFixed(1) + ' fps' + + '
' + + '
' + + '' + + '
' + + '

载入中…

' + + '
' + + '
'; + /* 右:遥控板 */ + h += '

遥控器

' + remotePadHtml() + '
'; + h += '
'; + $('#content').innerHTML = h; + screenCanvasRef = null; screenCanvasReady = false; // 切换页面重置增量 canvas 状态 + applyScreenDeltaDom(); + // restartScreenPoll 已排程首帧(上一帧到了再取下一帧),勿再显式 fetch 以免双发 + restartScreenPoll(); + } + + function bindScreen() { + $('#content').addEventListener('input', function (e) { + if (e.target.id === 'screen-fps') { + const v = parseFloat(e.target.value); + screenFps = v; storageSet('screen.fps', String(v)); + const lbl = $('#screen-fps-lbl'); if (lbl) lbl.textContent = v.toFixed(1) + ' fps'; + restartScreenPoll(); + } else if (e.target.id === 'ctl-bright') { + setBrightCtl(parseFloat(e.target.value)); + } + }); + $('#content').addEventListener('change', function (e) { + if (e.target.id === 'screen-delta') { + screenDelta = e.target.checked; + screenCanvasRef = null; screenCanvasReady = false; // 换增量模式:强制重新播种 + applyScreenDeltaDom(); + restartScreenPoll(); + } + }); + $('#content').addEventListener('click', function (e) { + if (Date.now() - lastSwipeAt < SWIPE.clickSuppressMs) return; // 刚滑完 → 屏蔽本次点击,防误触按钮 + const act = e.target.closest('[data-act]'); + if (act) { + const a = act.dataset.act; + if (a === 'volup') sendKey(115, 'tap'); + else if (a === 'voldown') sendKey(114, 'tap'); + else if (a === 'brightup') setBrightCtl(ctlBright.value + 5); + else if (a === 'brightdn') setBrightCtl(ctlBright.value - 5); + else if (a === 'play') sendKey(164, 'tap'); + else if (a === 'next') sendKey(163, 'tap'); + else if (a === 'prev') sendKey(165, 'tap'); + else if (a === 'power') sendKey(116, 'tap'); + return; + } + if (e.target.closest('#ctl-powerlong')) { sendKey(116, 'long', 3000); return; } + if (e.target.closest('#screen-img') || e.target.closest('#screen-canvas')) { screenTap(e); return; } + }); + // 滑动手势(仅遥控板);屏幕区点击=注入,不参与手势 + $('#content').addEventListener('pointerdown', remotePointerDown); + window.addEventListener('pointerup', remotePointerUp); + window.addEventListener('pointercancel', remotePointerUp); + // 页面隐藏(切到其它标签页)时停刷;回到本页继续 + document.addEventListener('visibilitychange', function () { + if (document.hidden) stopScreenPoll(); + else if (state.route === 'screen') restartScreenPoll(); + }); + } + + Object.assign(MP, { absFor, appInfo, appStatePill, appUninstall, applyScreenDeltaDom, audioCard, audioCtlHtml, audioFlowHtml, audioPortName, audioRouteLabel, batteryCard, batteryCardBody, bindApps, bindAudio, bindFiles, bindFonts, bindHome, bindLogs, bindNet, bindNotif, bindPin, bindPower, bindProc, bindScreen, bindSettings, bindSystem, breakdownCardBody, btCard, createImgFromBlob, crumbHtml, deviceCard, doDownload, ensureScreenCanvas, escSafeAttr, estimateCardBody, eventsBar, fetchAudioOnce, fetchHomeLevel, fetchHomeSystem, fetchNetConns, fetchNetLog, fetchNetOnce, fetchOverviewOnce, fetchPerfOnce, fetchPowerOnce, fetchProcOnce, fetchScreen, fmtDur, fmtFrames, groupName, handleRemoteGesture, healthCard, importFont, importFontPage, installFromFile, installFromPath, joinRel, loadFonts, loadFontsPage, loadPinStatus, logsCrashCard, mkdirFile, navFiles, netBadge, netBannerErr, netCard, netConnsBody, netDnsBody, netIfaceCards, netPolicyCard, netStateCls, netStatusCards, newestStdout, perfCard, pinEdit, pinEditDlg, powerEventsCard, powerHistoryCard, powerPolicyCard, powerProfileLabel, powerSummaryCard, pubBytesCard, remoteBtn, remotePadHtml, remotePointerDown, remotePointerUp, removeFile, renameFile, renderApps, renderAudio, renderBright, renderFiles, renderFontsPage, renderHome, renderHomeCards, renderLogs, renderLogsFiles, renderNet, renderNotif, renderPerf, renderPerfData, renderPower, renderPowerBody, renderProc, renderProcBody, renderScreen, renderSettings, renderSystem, restartScreenPoll, row, screenTap, seedScreenCanvas, segments, sendKey, sendKeyLabel, sendKeyRepeat, sendTheme, setBrightCtl, setPanelPassword, settingControl, srcPill, stopAudio, stopHome, stopNet, stopPerf, stopPower, stopProc, stopScreenPoll, storageCard, svcStatePill, uploadFiles, viewAppStdout, viewLogsCrashTail, volSteps }); +})(); \ No newline at end of file diff --git a/patch_v2/meowpanel/web/mp_shared.js b/patch_v2/meowpanel/web/mp_shared.js new file mode 100644 index 0000000..f67b23b --- /dev/null +++ b/patch_v2/meowpanel/web/mp_shared.js @@ -0,0 +1,467 @@ +/* mp_shared.js — MeowPanel 前端共享框架:全局状态 + API 客户端 + 公共工具 + 主题/弹窗/登录/滑条。 + * 由 meowpanel.js 拆分而来(接口/行为不变)。作为经典脚本在 meowpanel.js 之前加载,暴露 window.MP。 */ +(function () { + 'use strict'; + var MP = window.MP = window.MP || {}; + const $ = (sel, root) => (root || document).querySelector(sel); + const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel)); + const sparkRms = [], sparkPeak = []; + const SPARK_MAX = 30; + const state = { + style: 'md3', mode: 'dark', // 已解析的风格/明暗(应用到 data-style/data-mode) + devStyle: null, devTheme: null, // 设备 settings.conf 的 ui.style / display.theme(默认来源) + conn: 'unknown', + filesRel: '/', filesAbs: null, + perfTimer: null, // 性能页轮询 + route: 'perf', + unauth: false, // 鉴权未通过(弹登录页) + downloadNonce: 0 + }; + let toastTimer = null; + let authMode = 'login'; // 'login' | 'setup' + let volTimer = null; + const saveSetting = debounce(async function (key, value, type) { + try { + await api('PUT', '/api/settings/' + key, { value: value }); + toast('已保存 ' + key + ' = ' + value); + } catch (e) { toast('保存失败:' + errText(e)); } + }, 400); + const LOG_GROUP_NAME = { all: '全部', system: '系统服务', app: 'App', audio_monitor: 'audio_monitor', power: 'power', net: 'net', crash: 'crash', ams: 'ams' }; + + function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } } + + function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } + + function storageDel(k) { try { localStorage.removeItem(k); } catch (e) {} } + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[m])); + } + + function fmtBytes(n) { + if (n == null || isNaN(n)) return '—'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = Number(n), i = 0; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i]; + } + + function fmtUptime(sec) { + if (sec == null || isNaN(sec)) return '—'; + sec = Math.floor(sec); + const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60); + return (d > 0 ? d + ' 天 ' : '') + h + ' 小时 ' + m + ' 分'; + } + + function fmtTime(ts) { return ts ? new Date(ts * 1000).toLocaleString() : '—'; } + + function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; } + + function pct(p) { return Math.max(0, Math.min(100, p)); } + + function deciToDb(v) { return (v == null || v <= -990) ? null : v / 10; } + + function fmtDb(v) { + const db = deciToDb(v); + if (db == null) return '−∞'; + return (db > 0 ? '+' : '') + db.toFixed(1); + } + + function dbPct(v) { // -60..0 dBFS 映射到 0..100% 条宽 + const db = deciToDb(v); + if (db == null) return 0; + return Math.max(0, Math.min(100, (db + 60) / 60 * 100)); + } + + function isAvail(b) { return !!(b && b.available !== false); } + + function sparklineHtml() { + return ''; + } + + function sparklinePoints(arr) { + const n = arr.length; + if (!n) return ''; + return arr.map((v, i) => { + const x = (i / (SPARK_MAX - 1)) * 100; + return x.toFixed(1) + ',' + (46 - Math.max(0, Math.min(100, v)) / 100 * 40).toFixed(1); + }).join(' '); + } + + function pushSpark(v, arr) { // deci-dB → 0..100 百分比入队(<=30 点) + arr.push(dbPct(v)); + if (arr.length > SPARK_MAX) arr.shift(); + } + + function drawSpark(rootEl) { + const svg = rootEl && rootEl.querySelector('.lvl-spark'); + if (!svg) return; + const r = svg.querySelector('.spk-rms'), p = svg.querySelector('.spk-peak'); + if (r) r.setAttribute('points', sparklinePoints(sparkRms)); + if (p) p.setAttribute('points', sparklinePoints(sparkPeak)); + } + + function resetSpark() { sparkRms.length = 0; sparkPeak.length = 0; } + + function levelMeterHtml() { + return '
' + + '
RMS' + + '
' + + '−∞
' + + '
PEAK' + + '
' + + '−∞
' + + '
-60-40-200 dBFS
' + + sparklineHtml() + '
'; + } + + function updateLevelMeter(rootEl, d) { + if (!rootEl) return; + const rms = (d && d.available) ? d.rms : null; + const peak = (d && d.available) ? d.peak : null; + const fr = rootEl.querySelector('.lvl-fill.rms'); + const fp = rootEl.querySelector('.lvl-fill.peak'); + const vr = rootEl.querySelector('.lvl-val[data-val="rms"]'); + const vp = rootEl.querySelector('.lvl-val[data-val="peak"]'); + if (fr) fr.style.width = dbPct(rms) + '%'; + if (fp) fp.style.width = dbPct(peak) + '%'; + if (vr) vr.textContent = fmtDb(rms); + if (vp) vp.textContent = fmtDb(peak); + pushSpark(rms, sparkRms); pushSpark(peak, sparkPeak); + drawSpark(rootEl); + } + + function homeKv(k, v) { + return '
' + esc(k) + '' + v + '
'; + } + + function homeUnavailable() { return '
— 数据不可用
'; } + + function homeCard(title, body) { return '

' + esc(title) + '

' + body + '
'; } + + function modePill(m) { + if (!m) return '—'; + const cls = (m === 'meow') ? 'ok' : ((m === 'hiby') ? 'warn' : 'mut'); + return '' + esc(m) + ''; + } + + function setConn(ok) { + state.conn = ok ? 'ok' : 'bad'; + const dot = $('#conn-dot'); + dot.dataset.state = state.conn; + $('#conn-text').textContent = ok ? '在线' : '离线'; + $('#offline-banner').hidden = !!ok; + } + + function dbg() { + const a = Array.from(arguments); + console.log.apply(console, ['[mp]'].concat(a)); + } + + function dbgErr() { + const a = Array.from(arguments); + console.warn.apply(console, ['[mp][ERR]'].concat(a)); + } + + async function api(method, path, body) { + let res; + const headers = {}; + const token = storageGet('meowpanel.token'); + if (token) headers['X-MeowToken'] = token; + const init = { method: method || 'GET' }; + if (body instanceof FormData) { + init.body = body; + } else if (body !== undefined && body !== null) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + if (Object.keys(headers).length) init.headers = headers; + const t0 = performance.now(); + try { + res = await fetch(path, init); + } catch (e) { + dbgErr(method || 'GET', path, '网络层失败:', e && e.message); + setConn(false); + throw { code: 'NETWORK', message: '后端未连接', status: 0 }; + } + const ms = Math.round(performance.now() - t0); + /* 先读原文再解析:非 JSON 时能拿到原始内容前 300 字节进控制台 */ + let text = ''; + try { text = await res.text(); } catch (e) { /* 读体失败走 NOT_JSON */ } + let json = null; + try { json = text ? JSON.parse(text) : null; } catch (e) { json = null; } + if (!json || typeof json.ok !== 'boolean') { + // 静态服务器返回 HTML / 404 等 + dbgErr(method || 'GET', path, 'HTTP', res.status, ms + 'ms', '非 JSON 响应, len=' + text.length, '头:', text.slice(0, 200), '尾:', text.slice(-200)); + try { fetch('/api/debug/report_bad_json', { method: 'POST', headers: { 'Content-Type': 'text/plain', 'X-MeowToken': storageGet('meowpanel.token') || '' }, body: text }); } catch (e2) {} + setConn(res.ok ? true : false); + throw { code: 'NOT_JSON', message: '后端返回了非 JSON 响应(HTTP ' + res.status + ')', status: res.status }; + } + if (!json.ok) { + setConn(true); + const code = (json.error && json.error.code) || 'ERROR'; + dbgErr(method || 'GET', path, 'HTTP', res.status, ms + 'ms', '后端错误:', code, (json.error && json.error.message) || ''); + if (code === 'UNAUTHORIZED') authRequired(); // 会话失效/未认证 → 弹登录页 + throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status }; + } + if (ms > 800) dbg(method || 'GET', path, '慢响应', ms + 'ms'); + setConn(true); + return json.data; + } + + async function apiDownload(path) { + let res; + const headers = {}; + const token = storageGet('meowpanel.token'); + if (token) headers['X-MeowToken'] = token; + try { res = await fetch(path, Object.keys(headers).length ? { headers: headers } : undefined); } + catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; } + if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; } + setConn(true); + return URL.createObjectURL(await res.blob()); + } + + function toast(msg, ms) { + const t = $('#toast'); + t.textContent = msg; t.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { t.hidden = true; }, ms || 2600); + } + + function confirmDlg(title, body, danger) { + return new Promise(res => { + $('#modal-title').textContent = title || '确认'; + $('#modal-body').textContent = body || ''; + const yes = $('#modal-yes'), no = $('#modal-no'); + yes.className = 'btn btn-primary' + (danger ? ' btn-danger' : ''); + const scrim = $('#modal'); scrim.hidden = false; + const done = v => { scrim.hidden = true; yes.onclick = no.onclick = scrim.onclick = null; res(v); }; + yes.onclick = () => done(true); + no.onclick = () => done(false); + scrim.onclick = e => { if (e.target === scrim) done(false); }; + }); + } + + function loginErr(msg) { const err = $('#login-err'); if (err) { err.textContent = msg; err.hidden = false; } } + + function authRequired() { // 任一路由收到 401 → 弹登录页(会话失效/未认证) + state.unauth = true; + refreshAuthGate(); + } + + async function refreshAuthGate() { // 查状态决定「首次设置」还是「登录」 + let st = null; + try { st = await api('GET', '/api/auth/status'); } catch (e) { st = null; } + if (st && st.session) { state.unauth = false; hideLogin(); return; } + if (st && !st.pin_set && !st.legacy) showLogin('setup'); + else showLogin('login'); + } + + function showLogin(mode) { + state.unauth = true; + authMode = mode || 'login'; + const lg = $('#login'); if (lg) lg.hidden = false; + const sub = $('#login-sub'); + const pin2 = $('#login-pin2'); + const btn = $('#login-btn'); + if (authMode === 'setup') { + if (sub) sub.textContent = '首次使用:请设置一个 4 位数字 PIN 作为本面板访问口令。'; + if (pin2) pin2.hidden = false; + if (btn) btn.textContent = '设 置'; + } else { + if (sub) sub.textContent = '此面板已开启访问鉴权,请输入 4 位 PIN。'; + if (pin2) pin2.hidden = true; + if (btn) btn.textContent = '登 录'; + } + const ip = $('#login-pin'); if (ip) { ip.value = ''; ip.focus(); } + const ip2 = $('#login-pin2'); if (ip2 && authMode !== 'setup') ip2.value = ''; + const err = $('#login-err'); if (err) err.hidden = true; + } + + function hideLogin() { const lg = $('#login'); if (lg) lg.hidden = true; } + + async function doLogin() { + const pin = ($('#login-pin').value || '').trim(); + const err = $('#login-err'); if (err) err.hidden = true; + if (!pin) { loginErr('请输入 PIN'); return; } + if (authMode === 'setup') { + const pin2 = ($('#login-pin2').value || '').trim(); + if (pin.length !== 4 || /[^0-9]/.test(pin)) { loginErr('PIN 必须为 4 位数字'); return; } + if (pin !== pin2) { loginErr('两次输入的 PIN 不一致'); return; } + const btn = $('#login-btn'); if (btn) btn.disabled = true; + try { + await api('POST', '/api/auth/setup', { pin: pin }); + toast('PIN 已设置,请登录'); + showLogin('login'); + } catch (e2) { loginErr((e2 && e2.message) ? e2.message : '设置失败'); } + finally { if (btn) btn.disabled = false; } + return; + } + const btn = $('#login-btn'); if (btn) btn.disabled = true; + try { + const d = await api('POST', '/api/auth/login', { pin: pin }); + storageSet('meowpanel.token', d.token); + state.unauth = false; + hideLogin(); + toast('已登录'); + if (started) route(); else startApp(); + } catch (e) { + if (e && (e.code === 'LOCKED' || e.status === 429)) loginErr(e.message || '错误次数过多,已锁定,请稍后重试'); + else loginErr((e && e.message) ? e.message : '登录失败'); + } finally { + const b = $('#login-btn'); if (b) b.disabled = false; + } + } + + function bindLogin() { + const ok = $('#login-btn'); if (ok) ok.addEventListener('click', doLogin); + const ip = $('#login-pin'); if (ip) ip.addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); + // PIN 输入:只允许数字,最长 4 位 + [ip, $('#login-pin2')].forEach(el => { if (el) el.addEventListener('input', () => { el.value = el.value.replace(/[^0-9]/g, '').slice(0, 4); }); }); + } + + function passwordDlg(title, body) { + return new Promise(res => { + $('#pwdlg-title').textContent = title || '设置面板密码'; + $('#pwdlg-body').textContent = body || '请输入新密码(留空则清除密码)。'; + const inp = $('#pwdlg-input'); inp.value = ''; inp.focus(); + const scrim = $('#pwdlg'); scrim.hidden = false; + const okb = $('#pwdlg-ok'), cancel = $('#pwdlg-cancel'); + const done = v => { scrim.hidden = true; okb.onclick = cancel.onclick = scrim.onclick = inp.onkeydown = null; res(v); }; + okb.onclick = () => done(inp.value); + cancel.onclick = () => done(null); + scrim.onclick = e => { if (e.target === scrim) done(null); }; + inp.onkeydown = e => { if (e.key === 'Enter') done(inp.value); if (e.key === 'Escape') done(null); }; + }); + } + + function bindPwDlg() { /* #content 点击委托在 bindSettings 里处理 pass 按钮;此处无需额外绑定 */ } + + function browserMode() { + try { return (window.matchMedia && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark'; } + catch (e) { return 'dark'; } + } + + function storedStyle() { const v = storageGet('meowpanel.style'); return (v === 'md3' || v === 'miuix') ? v : null; } + + function storedMode() { const v = storageGet('meowpanel.mode'); return (v === 'system' || v === 'browser' || v === 'light' || v === 'dark') ? v : null; } + + function resolveStyle() { return storedStyle() || state.devStyle || 'md3'; } + + function resolveMode() { + const m = storedMode() || 'system'; + if (m === 'light' || m === 'dark') return m; + if (m === 'system') return state.devTheme || browserMode(); // 设备未知时回退浏览器偏好 + return browserMode(); + } + + function devStyleLabel() { return state.devStyle ? (state.devStyle === 'miuix' ? 'MIUIx' : 'Material You') : '未知(后端离线)'; } + + function devThemeLabel() { return state.devTheme ? (state.devTheme === 'light' ? '浅色' : '深色') : '未知(后端离线)'; } + + function applyTheme() { + const style = resolveStyle(), mode = resolveMode(); + state.style = style; state.mode = mode; + const root = document.documentElement; + root.dataset.style = style; root.dataset.mode = mode; + $$('#style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style)); + $$('#theme-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === mode)); + // 设置页「主题」卡(仅在该页渲染时存在) + $$('#set-style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style)); + $$('#set-mode-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === (storedMode() || 'system'))); + const rst = $('#set-style-reset'); if (rst) rst.disabled = !storedStyle(); + } + + async function initTheme() { + // 旧版键迁移:meowpanel.theme(dark/light)→ meowpanel.mode + const old = storageGet('meowpanel.theme'); + if (old) { + if (!storedMode()) storageSet('meowpanel.mode', old === 'light' ? 'light' : 'dark'); + storageDel('meowpanel.theme'); + } + applyTheme(); + // 浏览器日/夜切换:模式为 browser(或 system 且设备主题未知)时实时跟随 + try { + if (window.matchMedia) { + const mq = matchMedia('(prefers-color-scheme: dark)'); + const onChange = function () { + const m = storedMode() || 'system'; + if (m === 'browser' || (m === 'system' && !state.devTheme)) applyTheme(); + }; + if (mq.addEventListener) mq.addEventListener('change', onChange); + else if (mq.addListener) mq.addListener(onChange); + } + } catch (e) {} + // 设备默认值(ui.style/display.theme)——未覆盖时的默认来源 + try { + const t = await api('GET', '/api/theme'); + state.devStyle = (t.style === 'md3' || t.style === 'miuix') ? t.style : null; + state.devTheme = (t.theme === 'light' || t.theme === 'dark') ? t.theme : null; + applyTheme(); + if (state.route === 'settings') renderSettings(); // 刷新「主题」卡的设备当前值提示 + } catch (e) { /* 后端离线,保持本地/浏览器偏好 */ } + } + + function bindThemeUi() { + $('#style-seg').addEventListener('click', function (e) { + const b = e.target.closest('.seg-btn'); if (!b) return; + storageSet('meowpanel.style', b.dataset.style); + applyTheme(); + sendTheme({ style: b.dataset.style }); + }); + $('#theme-seg').addEventListener('click', function (e) { + const b = e.target.closest('.seg-btn'); if (!b) return; + storageSet('meowpanel.mode', b.dataset.mode); // 顶栏=显式明/暗 + applyTheme(); + sendTheme({ theme: b.dataset.mode }); + }); + } + + function emptyState(msg, code) { + const t = code === 'NO_CARD' ? '未识别到 TF 卡' : (code === 'NOT_IMPLEMENTED' ? '该功能后端尚未实现' : msg); + return '
' + esc(t) + '
'; + } + + function errText(e) { + if (e && e.code === 'NO_CARD') return '未识别到 TF 卡'; + if (e && e.code === 'NOT_IMPLEMENTED') return '该功能后端尚未实现'; + return (e && e.message) ? e.message : '未知错误'; + } + + function barHtml(p) { + p = pct(p); + const c = p >= 90 ? 'danger' : (p >= 70 ? 'warn' : ''); + return '
'; + } + + function stat(label, valueHtml, sub, barPct) { + return '
' + esc(label) + '
' + valueHtml + + (sub ? '
' + sub + '
' : '') + + (barPct != null ? barHtml(barPct) : '') + '
'; + } + + function slur(step) { // step: {key,label,min,max,val} + return '

' + esc(step.label) + '

' + + '
' + step.val + '' + + '' + + '' + step.max + '
'; + } + + function bindSlider() { + $('#content').addEventListener('input', function (e) { + const r = e.target.closest('[data-slider]'); + if (!r) return; + const row = r.closest('.field-row'); + if (row) row.querySelector('.muted').textContent = r.value; + saveSetting(r.dataset.key, +r.value, 'int'); + }); + } + + Object.assign(MP, { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, pushSpark, refreshAuthGate, resetSpark, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, updateLevelMeter, volTimer }); +})(); \ No newline at end of file