diff --git a/docs/MEOWOS_SPEC_REF.md b/docs/MEOWOS_SPEC_REF.md index 2a6d187..b721d25 100644 --- a/docs/MEOWOS_SPEC_REF.md +++ b/docs/MEOWOS_SPEC_REF.md @@ -178,7 +178,7 @@ - **默认字库目录**:默认字体统一放 **`<卡根>/MeowOS/fonts/default/regular/`**(`font.png`+`font.idx`,可选 `font.json`),不再用 `MeowAPPs/fonts/font.png`;设备旧 `MeowAPPs/fonts/` 及旧 `MeowOS/fonts//`(无 weight 级)**保留**(勿删,避免破坏老约定/已有文件),仅新引用一律指向 `MeowOS/fonts/default/regular`。 - **`font.file` 变更即 unload+reload 生效**(设置页回调);拔卡 → 回退固件内置。 - **preset 动态化(两级扫描)**:启动/进设置页扫描一次 `MeowOS/fonts///`,重建「字体文件」下拉(默认字体 + ≤16 套卡内 /;上限防爆)。扫描仅此二时机,勿放热路径。**存量卡片迁移**:旧版扫 `MeowOS/fonts//`(图片直接在该层);迁移到 `/regular/` 后,旧 `/` 下不再有三件套 → 新扫默认按两级找,故若未重做成 `//` 则扫不到(需先用 mv 迁成 `//` 再重扫)。 - - **MeowPanel 预留**:`meow_font_import_zip()`/`meow_font_export_zip()` 为 TODO 声明(不实现),供 MeowPanel 后续接入(`.zip` 导入校验→解压到 `MeowOS/fonts///`、导出打包)。 + - **MeowPanel 预留 → 已落地(2026-08-31 用户定案:**改为 `.tar.gz`、放弃 zip**,与 .mpk 同构走 busybox `gzip -dc | tar` 管道,设备必带 tar/gzip)**:面板以 `POST /api/fonts/import` + `GET /api/fonts/export` 接入(见 §5.7.6 与 `patch_v2/meowpanel/README.md` §5.11)。`meow_font_import_zip()`/`meow_font_export_zip()` 原 TODO 声明**不再按 zip 实现**,语义为 tar.gz(函数名可保留或改 `_tgz`)。导入校验→解压到 `MeowOS/fonts///`、导出打包。 - **多字体(M2.5x,`display.multi_font`)**:按字符类别(CJK/CJK_SYM/NUM/LATIN/LATIN_SYM/OTHER)逐字符路由,每类可各选一套字库(`font.class_*`,默认空=跟随全局 `font.file`);同 family 去重、同时装载不同字体 ≤4 套(超出 WARN「过多不同字体会增内存」并截断);开关关 = 单字体(现状)。详见 §5.7.7「多字体」。 - **字体档位 / 大字库(M2.5x,本次实现)**:抽象出**字库大小档位**,让系统/应用/设置页都知道选的是多大字库 `[源:用户 2026-08-30]`。**定案见 `devdocs/plan/FONT_SYSTEM.md`**(字体系统定案:三级目录 `///`、`font.json` 增 `tier`/`languages`/`label`、`charset` 改指编码、按语言选字体;本小节以下为旧口径)。 - 档位枚举:`MEOW_FONT_TIER_COMMON`(**默认 / ≈4k** 码位)/ `MEOW_FONT_TIER_GBK`(**大 / ≈20k+**)/ `MEOW_FONT_TIER_FULL`(**全 / 可选**,码位/体积最大)。 @@ -313,12 +313,19 @@ int lh = meow_ui_text_height_h(20, hBody); ``` - 说明:句柄状态为 app 进程内**全局**;同一屏需混排时,推荐每段绘制前显式 `meow_ui_use_font`,或全部走 `*_h` 显式传 handle,避免依赖上次绘制遗留的句柄状态。 -- **5.7.6 MeowPanel 上传 / 导出(.zip,预留)** `[源:meow_font.h]` - - 复用已声明的 `meow_font_import_zip(zip_path)` / `meow_font_export_zip(family,out_zip)`(§5.2 已列为 TODO,未实现)。 - - **系统字库导入**:`.zip` 内含 `font.png+font.idx+font.json` 三件套;校验三件套就绪 + `font.json` 可解析;sha256 沿用 `font.json` 约定(建议与运行时同口径 = **WARN**,仅缺件/不可解析硬失败);`family`/`weight` 取自 `font.json.family`/`font.json.weight`(缺省 `regular`)或 zip 名;解压到 `MeowOS/fonts///`;**覆盖冲突需先确认**;导入后应触发 launcher 重扫(刷新 §5.7.1 列表)。 - - **应用字库导入**:目标 `MeowAPPs//fonts//`,与 `app.json "fonts"` 声明对齐,校验同上。 - - **导出**:系统库把 `MeowOS/fonts///` 三件套打包;应用库把 `MeowAPPs//fonts//` 打包。 - - 约定:`.zip` 布局与校验沿用 `font.json` schema(§5.2);导入/导出后的一致性(列表刷新、应用声明同步)归 MeowPanel + launcher 协作,本小节仅列接口语义。 +- **5.7.6 MeowPanel 上传 / 导出(.tar.gz,2026-08-31 用户定案:放弃 zip,与 .mpk 同构)** `[源:meow_font.h][源:patch_v2/meowpanel/README.md §5.11]` + - **已由 MeowPanel 后端落地**:`POST /api/fonts/import`(multipart `file`=<.tar.gz>)+ `GET /api/fonts/export?family=&weight=` + + `GET /api/fonts`(列表)。**不用 zip**——设备 busybox `gzip`/`tar` 必带,后端走 `gzip -dc | tar` 管道(与 nyai .mpk 同款), + 零新增依赖、不引入 zlib/miniz/自研 zip。原 `meow_font_import_zip(zip_path)` / `meow_font_export_zip(family,out_zip)` TODO + 声明**不再按 zip 实现**,语义即 tar.gz(函名可保留或改 `_tgz`,注明即可)。 + - **系统字库导入**:`.tar.gz` 内含 `font.png+font.idx+font.json` 三件套;校验三件套就绪 + `font.json` 可解析; + sha256 沿用 `font.json` 约定(= **WARN**,仅缺件/不可解析硬失败);`family`/`weight` 取自 `font.json.family`/ + `font.json.weight`(缺省 `regular`);解压到 `MeowOS/fonts///`;**覆盖冲突需先确认**(409 → 前端 confirm 后 + `force=1` 重试);导入后进设备「设置→字体」页自动重扫(扫描时机=启动/进设置页,面板**不**主动触发重扫)。 + - **应用字库导入**:目标 `MeowAPPs//fonts//`,与 `app.json "fonts"` 声明对齐,校验同上(待面板后续扩展)。 + - **导出**:系统库把 `MeowOS/fonts///` 三件套打包为 `-.font.tar.gz`;应用库同理(待扩展)。 + - 约定:`.tar.gz` 布局与校验沿用 `font.json` schema(§5.2);导入/导出后的一致性(列表刷新、应用声明同步)归 MeowPanel + + launcher 协作。**安全**:tar 解出条目逐个校验(拒绝对路径/`..`/软链/多出条目/子目录包裹),沿用 nyai 的 sanitize 思路。 - **5.7.7 多字体(按字符类别逐字符路由,`display.multi_font`,M2.5x 新增)** `[源:meow_font.h][源:MEOWOS_SPEC_REF §5.2/§5.5]` - **背景**:一款字体难兼顾中文(大图集、字形全)与拉丁/数字(干净、省内存)。多字体让**不同字符类别各用一套字库**,按 codepoint 逐字符路由取字形。 - **设置键**(launcher 读,SDK 经 env 读同一映射): diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md index 19cf79d..adaa3e0 100644 --- a/patch_v2/meowpanel/README.md +++ b/patch_v2/meowpanel/README.md @@ -28,12 +28,14 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel - 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。 - 产出(本次构建实测): - `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`, - 体积约 **206 KB**(固件阈值内)。md5:`24f53b40fd556f758e5eba3417a52c3c`(本次含通知 IPC 实时化 + 增量帧)。 - - `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **146 KB**。 + 体积约 **218 KB**(固件阈值内)。md5:`be74e2ac0423718073a5a27fc7bbf96a`(本次含字体库 tar.gz 导入/导出 + 排序修复)。 + - `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **148 KB**。 - 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自 `meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。 阶段 C 实时画面用**自研最小 baseline JPEG 编码器** `mp_jpeg.c`(顺序 DCT + 4:2:0 + 标准 Huffman 表 Annex K; 无 libm、零依赖,仅 malloc/realloc),质量 70 下 480×720 帧约 41 KB。 + **字体库导入/导出(用户 2026-08-31 定案)**:与 `.mpk` 同构、**放弃 zip,改走 busybox `gzip -dc | tar` 管道** + (设备必带 tar/gzip,零新增依赖、不引入 zlib/miniz/自研 zip,见 §5.11)。 **性能**:设备为 mipsel muslsf **无 FPU**,初版逐块 `double` 矩阵 DCT 编码 480×720 一帧约 **3.96 s**(掉帧到 5-6 s/帧的根因)。 已改为**整数定点 DCT + 整数 YCbCr**(FSCALE=13,BT.601 256 系数,纯整型乘加), 实测编码降至约 **0.19 s(≈20×)**,单请求端到端(本机 localhost)约 0.20 s、LAN 约 0.22-0.31 s, @@ -107,6 +109,9 @@ http://.local:8080/ | POST | `/api/files/mkdir` | 新建目录(已存在→409) | ✅ | | POST | `/api/files/rename` | 重命名 | ✅ | | DELETE | `/api/files?path=` | 删除(可 recursive=1;非空未 recursive→409) | ✅ | +| GET | `/api/fonts` | 字体库列表(扫描 `MeowOS/fonts///` 三件套齐) | ✅(无卡→503) | +| POST | `/api/fonts/import` | 上传字体库 `.tar.gz`(multipart `file`,可带 `?force=1` 覆盖) | ✅(缺件/坏json/穿越→400,冲突→409,超限→413) | +| GET | `/api/fonts/export` | 导出 `//` 三件套为 `.tar.gz`(`?family=&weight=`) | ✅(缺件→404) | | GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ | | GET | `/api/apps/:pkg` | 应用详情 | ✅ | | POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ | @@ -274,6 +279,33 @@ http://.local:8080/ 防重叠 + 旧 URL 回收;后端不可达提示「后端未连接」);默认 **1fps**,滑条 **0.2–2fps**(存 `screen.fps`,页面隐藏/切走即停刷, `visibilitychange` 回前台续刷);点击画面按 480×720 比例换算坐标 → `POST /api/input/tap`,`dispatched:false` 时 toast「本机触摸中」。 +### 5.11 字体库 tar.gz 导入 / 导出(`/api/fonts*`,新增) +- **定位**(MEOWOS_SPEC_REF §5.7.6 / FONT_SYSTEM.md,**2026-08-31 用户定案:放弃 zip,与 .mpk 同构走 tar.gz**): + 系统字库按 `MeowOS/fonts///` 三件套存放(schema 见 SPEC_REF §5.2:family/weight/name/ + glyph_count/charset/tier/size_list/bit_depth/sha256_png/sha256_idx…)。**设备 busybox tar/gzip 必定存在**, + 后端全部走 `gzip -dc | tar` 管道(nyai .mpk 同款),**零新增依赖、不引入 zlib/miniz/自研 zip**。 +- **导入 `POST /api/fonts/import`**(multipart `file`=<.tar.gz>,上限 **`MAX_FONT_UPLOAD`=32MB**,可带 `?force=1`): + 1. multipart `file` 落临时 `/tmp/mp_font_...tar.gz`(`multipart_extract_file`,二进制安全 `mem_find`)。 + 2. **成员预校验**:`gzip -dc '' | tar -t`,每成员经 `sanitize_member`(去前导 `/`/`./`,拒 `..` 组件与 + shell 危险字符)且**恰为三件套平铺**(多出条目/子目录/绝对/`..` → 400)。 + 3. **解压到临时目录**:`gzip -dc | tar -x -C stg` → opendir/lstat 锁:**恰为 3 个普通文件** + (拒软链/目录/设备;总大小 ≤ `FONT_EXTRACT_MAX`=32MB 防 gzip 炸弹)→ 缺件/坏 json 400。 + 4. `font.json` 手写 JSON 解析 → `family`(缺省回退 `name`)/`weight`(缺省 `regular`),`safe_dir_segment` + 消毒(拒 `/`、`\`、控制符、`.`/`..`)→ 非法 422。 + 5. **覆盖冲突**:目标目录任一文件已存在且非 `force=1` → **409 `CONFLICT`**(前端 confirm 后带 `force=1` 重试)。 + 6. **sha256 仅 WARN**(对齐 font.json 约定):流式重算 `sha256_png`/`sha256_idx` 与声明比对,不符写 `warning` + 字段、仍导入成功(**不硬失败**);响应含 `family`/`weight`/`imported`/`hint`(提示进设备「设置→字体」页 + 列表自动重扫生效——**后端不触发 launcher 重扫**,扫描时机=启动/进设置页,勿加热路径)。 + 7. `mkdir`(系统调用,**不用 `system()`**,防注入)→ `rename` 三件套到目标目录(原子替换)。 +- **导出 `GET /api/fonts/export?family=&weight=`**:校验 family/weight 段 → 目标目录三件套须齐(缺件 404)→ + `tar -cf - -C font.png font.idx font.json | gzip -c > ` 落临时文件(求 `Content-Length`)→ + `Content-Disposition: attachment; filename="-.font.tar.gz"` + `application/gzip` → 流式下发。 +- **列表 `GET /api/fonts`**:扫描 `MeowOS/fonts///`,三件套齐才列出,读 `font.json` 取 + name/tier/glyph_count(容错)。无卡 → 503。 +- **往返验证**(宿主):Python tarfile 生成的平铺三件套 `.tar.gz` 导入→导出→逐字节比对源文件一致;本后端导出 + 再导入→逐字节一致;GNU tar/`tarfile` 均能读;目录穿越 `../`/绝对路径/软链成员/缺件/坏 font.json/多出条目/ + 子目录包裹/非 gzip——全部拒绝;打包文件名 `-.font.tar.gz`。见 §7 宿主测试备注。 + --- ## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md) @@ -304,12 +336,25 @@ http://.local:8080/ 11. **实时画面/触摸注入(阶段 C)**:协议 §6 待定案项称「实时画面(`/api/frame` 等,本文件暂未定义)」。本面板落地为 **`GET /api/screen.jpeg` + `POST /api/input/tap`**(命名自定:`.jpeg` 表达二进制帧 + `input/tap` 表达触摸注入), 见 §5.10。回改协议时可补这两端点(以及 `frames` 语义、`/api/input/tap` 的 `dispatched:false/reason` 软失败约定)。 +12. **multipart 二进制查找修正(strstr→memmem,本次修复,属 bug 修复非出入变更)**:`parse_multipart`/`multipart_extract_file` + 均用 `strstr(data_start, boundary)` 定位 part 数据边界——**strstr 遇到数据中的 NUL 字节即提前停止**(含 NUL 的二进制 + .tar.gz/.mpk/图会误把后续 boundary 与拖尾算进数据 → 解压出错)。已改**二进制安全**的 `mem_find`(memmem 语义,手写字节扫描, + 不依赖 glibc/musl 扩展)。这修复了字体库 tar.gz 导入,也顺带修复了二进制 `.mpk`/文件上传的潜在同病。**实现优先**,供回改协议/审计。 +13. **`/api/fonts*`(新增,§5.11)**:协议 §2 端点总览无此组;按 SPEC_REF §5.7.6「MeowPanel 预留 .zip 导入/导出」落地——**但用户 + 2026-08-31 定案:改 tar.gz、放弃 zip**(与 .mpk 同构)。落地为 `/api/fonts`(列表)/`/api/fonts/import`(导入,POST multipart, + 上限 32MB,`?force=1` 覆盖)/`/api/fonts/export`(导出,GET,`-.font.tar.gz`)。前两者由前端设置页「字体库」卡接入。 + 还修正了 `/api/files` 排序:**目录优先 + 名称自然排序**(数字感知 track2.local:8080/ ``` patch_v2/meowpanel/ -├── meowpanel.c # 后端源码(约 4100 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化) +├── meowpanel.c # 后端源码(约 4560 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序) ├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning) ├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG) -├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning) -├── meowpanel # 交叉产物(mipsel 静态,约 206 KB,md5 24f53b40…) +├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;meowpanel.c + mp_jpeg.c) +├── meowpanel # 交叉产物(mipsel 静态,约 218 KB,md5 be74e2ac…) ├── meowpanel_host # 宿主编译(本机 ld,调试用) ├── services.d/ │ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf diff --git a/patch_v2/meowpanel/build_meowpanel.sh b/patch_v2/meowpanel/build_meowpanel.sh index 920bdd5..ac10a90 100755 --- a/patch_v2/meowpanel/build_meowpanel.sh +++ b/patch_v2/meowpanel/build_meowpanel.sh @@ -2,12 +2,13 @@ # build_meowpanel.sh — MeowPanel 后端交叉编译(mipsel 静态)+ 宿主编译可选。 # # 目标:R3 Pro II / MeowOS(mipsel-linux-muslsf,静态)。后端仅 POSIX + Linux 基接口 -# (复用 meow_ipc.h 帧格式,无 launcher 依赖),单文件满足「卡上、静态、零第三方依赖」。 +# (复用 meow_ipc.h 帧格式,无 launcher 依赖),多文件满足「卡上、静态、零第三方依赖」。 # # 用法: # ./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 实时画面)。 +# 字体库导入/导出走 busybox `gzip -dc | tar` 管道(与 nyai .mpk 同构),零额外源文件。 set -e cd "$(dirname "$0")" ROOT="$HOME/Project/R3ProII" diff --git a/patch_v2/meowpanel/meowpanel b/patch_v2/meowpanel/meowpanel index f62fa1c..babe734 100755 Binary files a/patch_v2/meowpanel/meowpanel and b/patch_v2/meowpanel/meowpanel differ diff --git a/patch_v2/meowpanel/meowpanel.c b/patch_v2/meowpanel/meowpanel.c index b423b70..1a5cb38 100644 --- a/patch_v2/meowpanel/meowpanel.c +++ b/patch_v2/meowpanel/meowpanel.c @@ -19,7 +19,9 @@ * - 文件/应用/日志/通知:白名单根 /usr/data/mnt/sd_0 下 MeowOS/Music/MeowData/MeowAPPs/book。 * * 编译(mipsel 静态):build_meowpanel.sh;宿主编译 build_meowpanel.sh host。 - * 依赖:仅 POSIX + libc(含 linux/input.h 内核头),零第三方库。设备无 python3/lua。 + * 依赖:仅 POSIX + libc(含 linux/input.h 内核头),零第三方库(mp_jpeg.c 自研最小 JPEG; + * 字体库导入/导出走 busybox `gzip -dc | tar` 管道——与 nyai .mpk 同构,设备必带 tar/gzip)。 + * 设备无 python3/lua。 */ #include #include @@ -100,6 +102,7 @@ static const char *g_notif_log = NULL; /* 通知转储日志:/notificat #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 @@ -1039,13 +1042,48 @@ static void fmt_json_item(sbuf_t *s, const char *name, int isdir, long long size } struct fent { char name[256]; int isdir; long long size, mtime; }; -/* 目录优先 + 名称不区分大小写(再按大小写敏感定序保证稳定) */ + +/* 自然字符串比较:数字段按数值比较(track2 < track10),ASCII 字母大小写不敏感 + * (同字母时原始字节作 tie-break),其余按**无符号字节序**(UTF-8 中文等直接按字节序, + * 设备无 locale,避免 strcasecmp 对高位字节的 locale 依赖导致乱序)。 + * 实现:逐字节;遇到连续数字则各取整段,比「有效位数→数值→原始长度」;否则比字节。 */ +static int nat_strcmp(const char *a, const char *b) +{ + const unsigned char *pa = (const unsigned char *)a; + const unsigned char *pb = (const unsigned char *)b; + while (*pa && *pb) { + unsigned char ca = *pa, cb = *pb; + if (ca >= '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; +} + +/* 目录优先 + 名称自然排序(数字感知 + 无符号字节序) */ static 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; - int c = strcasecmp(fa->name, fb->name); - return c ? c : strcmp(fa->name, fb->name); + return nat_strcmp(fa->name, fb->name); } static int files_list(int sock, const char *relpath) @@ -1303,6 +1341,7 @@ typedef struct { } req_t; static void req_free(req_t *r) { free(r->body); r->body = NULL; r->body_len = 0; } +static int multipart_extract_file(const req_t *r, const char *outpath); /* 定义在文件后部;字体导入用(写 multipart file 到临时文件) */ static int is_hex(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); } static 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; } @@ -1451,9 +1490,11 @@ static int read_request(int sock, req_t *r) } 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)。 */ + /* 上传(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 = MAX_UPLOAD_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); @@ -1974,6 +2015,22 @@ static int send_file(int sock, int status, const char *ctype, const char *abspat return 0; } +/* 二进制安全子串查找:strstr 会因数据中的 NUL 提前停止,二进制(zip/mpk/图)必须用 memmem。 + * 返回 needle 在 hay[0..hlen) 中首次出现的指针;找不到 NULL。 + * 仅当 mot 出现,用 memmem(_GNU_SOURCE 已定义,glibc/musl 均提供)。 + * 若无 memmem(极端环境),退回手写字节扫描,保证可移植。 */ +static 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; +} + /* ---------------- multipart/form-data 解析(文件上传) ---------------- * 解析 body,提取 name= 字段与 name=file 字段(可多个)。写到 target dir(白名单校验)。 * 返回 0=成功(把写入的 item 用 items_json 回填),负错误码语义同协议。 @@ -2023,7 +2080,7 @@ static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles) int nfiles = 0; char curpath[1024] = ""; while (p < end) { - const char *fstart = strstr(p, dmark); + const char *fstart = mem_find(p, (size_t)(end - p), dmark); if (!fstart) break; fstart += strlen(dmark); if (*fstart == '-') break; /* 结束标记 --boundary-- */ @@ -2051,8 +2108,8 @@ static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles) if (nm) { nm += 6; char *q = strchr(nm, '"'); if (q) { *q = 0; name = nm; } } } } - /* 数据结束:下一个 "--boundary" 之前 */ - const char *data_end = strstr(data_start, dmark); + /* 数据结束:下一个 "--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--; @@ -3333,6 +3390,399 @@ static int api_files_delete(int sock, const req_t *r) return send_json(sock, 200, "{\"removed\":true}"); } +/* ---------------- 字体库 tar.gz 导入 / 导出(MeowOS/fonts///) ---------------- + * 定案(用户 2026-08-31):**与 .mpk 同构,放弃 zip**——设备 busybox tar/gzip 必定存在, + * 后端全部走 `gzip -dc | tar` 管道(nyai .mpk 同款),零新增依赖、不再自实现 zip 读写/miniz。 + * 导入(POST /api/fonts/import,multipart file=<.tar.gz> [&force=1]): + * - 上传 tar.gz(上限 MAX_FONT_UPLOAD=32MB,gbk/full 字库 png+idx 可能 10-20MB)→ + * `gzip -dc | tar -t` 预校验成员名(sanitize_member:拒绝对路径/`..`/shell 危险字符,含三个以上条目疑拒); + * 解压到临时目录 → opendir/lstat 逐个校验:恰为 font.png/font.idx/font.json 三个**普通文件** + * (拒软链/目录/设备,拒解压后超大)→ 缺件/坏 json 硬失败。 + * - family/weight 取 font.json 的 family(缺省回退 name)/weight(缺省 regular),safe_dir_segment 消毒。 + * - rename 到 MeowOS/fonts///;目标已存在则 409(前端确认后带 force=1 重试)。 + * - sha256_png/sha256_idx 仅 WARN(含在响应 warning 里);导入后**不触发 launcher 重扫** + * (扫描时机=启动/进设置页,勿加热路径),响应用 hint 提示。 + * 导出(GET /api/fonts/export?family=&weight=): + * - `tar -cf - -C font.png font.idx font.json | gzip -c > ` 打包为 + * `-.font.tar.gz` 下载流(先落临时文件求 Content-Length 再流式)。 + * 列表(GET /api/fonts):扫描 fonts///(三件套齐)供导出选择。 + */ +#define FONT_EXTRACT_MAX (32u * 1024u * 1024u) /* 三件套解压后总大小上限(防 gzip 炸弹;gbk/full 亦在界内) */ + +static void fonts_root(char *buf, size_t cap) { snprintf(buf, cap, "%s/MeowOS/fonts", TF_ROOT); } + +static 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; +} + +/* ---- tar 成员 sanitize(沿用 nyai 语义:拒绝对路径/`..` 组件/shell 危险字符) ---- */ +static int unsafe_path(const char *s) +{ + return s && strpbrk(s, " ;`$'\"&|<>") != NULL; +} +static 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; +} +/* 校验并相对化为平铺成员名:去前导 '/' 与重复 './',禁绝对/`..`/危险字符。返回 0=ok 写 out。 */ +static 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; +} +/* 递归建目录(mkdir 系统调用;已存在 EEXIST 忽略)。 */ +static 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; +} + +/* 逐一手写从 font.json 取字段(family/weight/name/tier/glyph_count/sha256_png/sha256_idx)。 + * 返回 0=ok;*pf 指向解析出的 J_STR(供复制)。 */ +static 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; +} + +/* 得到已装载字体库(family/weight 三级扫描 + 三件套齐)JSON 数组。 */ +static 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\":[]}"); +} + +/* 在 multipart body 中定位 name="file" 字段数据区间(r->body 内)。 */ +/* 对文件算 sha256 hex(64 字符,流式,不整读进内存)。返回 0=ok;-1=打开/读失败。 */ +static 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; +} + +static 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 : ""); +} + +static 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; +} + /* ---------------- 应用(MeowAPPs 扫描 + nyai) ---------------- */ /* 执行命令(sh -c),捕获 stdout(上限 cap-1),返回退出状态(-1=无法启动)。 */ static int run_cmd(const char *cmd, char *out, int outcap) @@ -3433,7 +3883,7 @@ static int multipart_extract_file(const req_t *r, const char *outpath) } } const char *ds = he + 4; - const char *de = strstr(ds, dmark); + 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) { @@ -3930,6 +4380,11 @@ static int handle(int sock, req_t *r) 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); + /* ---- 实时画面(阶段 C) ---- */ if (!strcmp(r->method, "GET") && !strcmp(p, "/api/screen.jpeg")) return api_screen_jpeg(sock, r); diff --git a/patch_v2/meowpanel/meowpanel_host b/patch_v2/meowpanel/meowpanel_host index a83d7f5..fadde84 100755 Binary files a/patch_v2/meowpanel/meowpanel_host and b/patch_v2/meowpanel/meowpanel_host differ diff --git a/patch_v2/meowpanel/web/meowpanel.js b/patch_v2/meowpanel/web/meowpanel.js index a55f019..4cee681 100644 --- a/patch_v2/meowpanel/web/meowpanel.js +++ b/patch_v2/meowpanel/web/meowpanel.js @@ -608,8 +608,49 @@ 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; + loadFonts(); + } + 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'); @@ -619,6 +660,7 @@ 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'); @@ -626,6 +668,13 @@ else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str'); }); c.addEventListener('click', async function (e) { + 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);