Compare commits

...
79 Commits
Author SHA1 Message Date
AskaEth db672cbe8f Merge branch 'feat/m26-sdk-image-stack-into-main-repo' 2026-11-06 21:35:50 +08:00
AskaEth 281f4153ee fix(sdk): M26 r1 P1 — ILP32 尺寸算术溢出(畸形 BMP 经公开入口 SIGSEGV)+ 加 S17 回归
评审在 ARM32(ILP32,与 mipsel 同数据模型)实测:54 字节 BMP 头声明 w=2^27、bpp=32
时 w*bpp 回绕成 0 → rowsize=0 → malloc(0) → 采样索引 row+sx*4 越界读 → SIGSEGV,
经 meow_img_load_file_dim 端到端可触发;同一文件在 LP64 宿主返回 NULL(不溢出),
合并前走 stb 也是 NULL。即这是相对旧路径的鲁棒性回归。

修复(只加闸,正常路径数值不变):

- meow_png_stream.c(P1 本体)
  · 新增 ILP32 守卫 MZP_MAX_DIM=1<<20 与 mzp_bytes_ok()(unsigned long long 验算,
    超过 SIZE_MAX 即拒)
  · BMP:声明尺寸先升 int/long long 校验(w/h 单边 ≤1<<20;h=INT_MIN 用
    -(long long)hi 取绝对值,不再有 -h 溢出);rowsize 用 64 位算并验;
    行偏移 off / 采样下标 sx 改 long long,sx 夹在 [0,w-1];按行 fseek 前加
    off > LONG_MAX 拒绝;目标 RGBA 字节数 64 位验后再 malloc
  · PNG:IHDR 尺寸原已 ≤65535,补上 tw*th*4 的 64 位校验(32768²×4 = 2^32 会回绕),
    emit_row 的 sy/sx 与输出行偏移改 64 位并夹紧
- meow_jpeg.c(顺手审计同类 32 位算术,评审要求「不要只修点名那处」)
  · mj_layout 返回 unsigned long long(单平面/三平面之和都可能越 2^32),
    越界返回 MJ_LAYOUT_BAD → mj_sof 各处重新布局后都检查
  · target_bytes 与分量 plane malloc 改 64 位验算;目标图字节超 MJ_PEAK_CAP 直接拒
  · mj_output:输出缓冲 64 位验;y*vh / x*vw 采样改 long long 并夹紧
  · meow_img_target_dims / mj_pick_n 的 (long)h*maxdim、8*maxdim 改 long long

- test_meow_img_stream.c:新增 S17(10 组畸形 BMP 声明尺寸 + 2 个合法正例 +
  PNG 回绕点),断言「后端拒绝 + 公开入口不崩(越界尺寸另断言 NULL 且不分配)」;
  文件头补 ILP32 交叉编译命令(宿主 LP64 看不到 32 位回绕,测试注释里明写这一点)

验证:同一测试在 zig arm-linux-musleabi(sizeof(long)=4)下 S1-S17 全绿(206 checks,
exit 0);把后端换回 r1 提交的旧实现,同一 ARM32 二进制 exit 139(说明 S17 真能抓);
宿主 LP64 S1-S17 全绿、B1-B9 全绿;静态库 --verify 仍 78 成员 / 全 MIPS32r2。

同步提醒:这些行与公开仓 ~/Proj/MeowOS-SDK/sdk/{meow_png_stream.c,meow_jpeg.c} 逐字节
同源,同一补丁必须由控制塔同步过去,否则立刻产生双边漂移。
2026-11-06 21:27:03 +08:00
AskaEth ee5796747d feat(sdk): M26 公开仓真缩解图像栈并入主仓 — JPEG DCT/PNG 逐行/BMP 跳行后端 + RGB565 页图
主仓 SDK 的图片层原本是 220 行 stb 直通(整幅解码后重采样)。公开仓 MeowOS-SDK
的同一子系统已被真实 App(tuku 阅读器)驱动并做过峰值实测与像素比对,故以公开仓
为权威方向并入主仓 meow_ui.a,两个方向的新功能都保留(逐文件 diff 确认公开仓是
严格超集:无一处「双方互有增减」)。

- 新增 meow_jpeg.c / meow_jpeg.h:DCT 域缩解 1/8、1/4、1/2(N=1/2/4 点 IDCT),
  渐进式只解到首个精细化扫描;不支持项(CMYK/YCCK、算术编码、过订阅 DHT、16bit
  量化表)返回 0 由调用方回退 stb
- 新增 meow_png_stream.c:libz 逐行 inflate + 反滤波,按步长丢行丢列直写目标尺寸;
  BMP 按目标行 seek + 行内跳列(8/16/24/32bpp)
- meow_ui_img.c + include/meow_ui_image.h:新增 meow_img_pick_scale /
  load_file_step / load_file_step_ex / resample 四个公开入口;_dim 与 _mem_dim
  先进后端,失败/不支持才回退 stb(对外语义不变)
- include/meow_ui_draw.h + meow_ui_gfx.c:M32 RGB565 页图 5 函数
  (meow_gfx_rgb565 / rgba_to_rgb565 / blit_rgb565 / _scaled)。gfx 保留主仓的
  `#include "../meow_launcherd/meow_font.h"` 与 <time.h>(公开仓自 vendored
  meow_font.h 且无 <time.h>,机械覆盖会丢这两行)
- meow_jpeg.h 保持内部:不进 meow_ui.h 伞头,include/ 下无任何头暴露 meow_jpeg_*
- build_sdk.sh:OBJS 插入 meow_jpeg / meow_png_stream(32→34);成员数
  76→78(+meow_font.o +43 freetype),--verify 全为 ELF32 MIPS32 rel2/o32
- 测试:新增 test_meow_img_stream.c(S1-S16),test_meow_img_budget.c 同步公开仓
  版(B9 断言收紧为 p_new <= p_old);22 个既有宿主测试的编译注释行补
  meow_jpeg.c / meow_png_stream.c / -lz(meow_ui_img.c 现引用后端符号,不加即
  undefined reference)

宿主实测:S1-S16 全绿(S7 与 stb 网格像素一致:JPEG mad 4.35-13.69、PNG/BMP
mad 0.00;S9 回退矩阵 CMYK/1bpp BMP/Adam7 均拒绝且 stb 仍成功),B1-B9 全绿
(B9 PNG 峰值 25.65→0.61 MiB、BMP 14.65→0.59 MiB)。回归 test_meow_ui_widgets /
scroll / ime_fix / test_umbrella 全过。zlib 在应用链接期解析:meow_ui.a 只留
inflate* 未定义符号,四个 App 构建脚本原本就带 $SYSROOT/lib/libz.a,静态
linkcheck(meow_ui.a + libz.a -lpthread)通过。

注:主仓 patch_v2/reader_app 仍自带 vendored stb、不用 meow_img_*,本单不改变
阅读器的内存占用;RGB565 页图接入 reader_app 是另一个任务。
2026-11-06 20:44:22 +08:00
AskaEth 479d93a3bf Merge branch 'feat/m24-devkit-static-mipsel-debug-tools' 2026-11-06 20:03:08 +08:00
AskaEth adaed09f94 fix(devkit): M24 r2 评审 — 交付完整性闸门 + 补 sysctl + time64 文档更正
P2-1(与 P1 同类):licenses/ 与 README 缺失仍会假成功。
- 新增 meta_complete()/verify_meta():MANIFEST + README + 五份许可证副本
  (procps COPYING/COPYING.LIB、ncurses、lsof、musl-COPYRIGHT)缺一即不完整
- 纳入 skip 条件与 --verify-only;产物未变但 meta 缺失时就地 write_meta 补全(不重编),
  构建树不在则退回全量重建
- 回归测试新增 T6(删 licenses 后 --with-sources 必须恢复)、T7(--verify-only 必须拒绝)、
  T8(删 README 后恢复);共 20 项全过

P2-2:README.txt/MANIFEST.txt 的卡上路径全错(写成 ../src、scripts/build/…)。
- 全部改为卡内相对路径:src/、licenses/、MANIFEST.txt、src/DEVKIT.md、src/build_devkit.sh
- README 增加「目录内容」段并注明 src/ 是生成目录、勿手放文件

P2-3:valgrind 的排除理由(“无 MIPS32 后端”)是错的 —— 官方支持 MIPS32/Linux。
- 改为「未尝试」:交叉构建需 autotools + 目标 vDSO 细节、产物 10MB+,56MB 单核不实用

P2-4:busybox 1.31 实测有 pgrep/kill/killall,没有 pkill、没有 sysctl。
- 表格改准确;pgrep 保留(增量有限不交付);pkill 写明「busybox 无此 applet,
  killall 只能部分替代,本轮未做」
- 新增交付 sysctl(procps 4.0.4,148184 B,静态):MANIFEST/verify-only/真机
  (sysctl -a 428 键、kernel.hostname/panic 读取、-n)均已验证

P2-5:free -s <小数> 也受 time64 影响(usleep 亚秒被抹平),文档说反了。
- DEVKIT §9 增加一行(free -s 0.5 实测 0s、free -s 1 实测 1s);README 同步;
  “收益只有 vmstat -t 一列”改为包含 free -s

P3:
1. §9 机制改准:内核只写 8 字节 → tv_sec 高 32 位是内核写入的 nsec、offset 8 的
   tv_nsec 才是未初始化;nanosleep 是把 tv_sec 高半字当 nsec 读(0)
2. 删掉脚本里 top 的旧错误归因(时长溢出/59.4 GiB),与 DEVKIT 对齐
3. 测试耗时改为「热缓存约 15s,冷启动 1–2 分钟」(实测 28s 含多次构建/修复路径)
4. --verify-only 依赖清单写全(file/readelf/sha256sum/awk/sed/grep/wc/cut/basename)
5. licenses/ 增加 musl-COPYRIGHT(zig env → libc/musl/COPYRIGHT,取不到即失败),§6 补一行
6. htop 说明改准:亚秒刷新走 ncurses halfdelay()(select + 32 位 timeval),大概率不受
   time64 影响;受影响的是时钟显示与部分采样(仍注明未做运行时验证)
7. README/脚本注明 src/ 是生成目录
8. LGPL 摘要改为「源码(§6a) 或 共享库(§6b) 或 书面要约(§6c)」
9. ltrace/sysstat 从「不可行」改为「未做(成本高)」,与 perf 的硬阻塞区分

验证:test_build_devkit.sh 20/20;--verify-only 通过(7 个二进制 + MANIFEST + 5 份许可证);
独立工作区离线重建 64s/0 下载,七个 sha256 与前一次逐字节一致;设备实测 sysctl 与其余六项正常。
2026-11-06 19:53:45 +08:00
AskaEth 0531603779 fix(devkit): M24 r1 评审 — P1 --with-sources 静默漏源码 + time64 归因更正
P1(阻断):stamp 命中时 main 提前 return,--with-sources 在 return 之后,
于是「先构建再 --with-sources」会打印成功、退出码 0、却不产出 src/。

- 把源码整理抽成 stage_sources(),stamp 命中路径先兑现 WITH_SOURCES 再返回;
  整理后按钉住的 sha256 复核(sha 漂移/缺文件都重铺),任何失败即非零退出
- 新增 test_build_devkit.sh(12 项,秒级):stamp 命中下的 --with-sources、
  src 缺文件/被改坏的自愈、--verify-only 的 MANIFEST 篡改检测
- skip 条件额外要求 MANIFEST 与 bin 逐条 sha 一致;--clean 同时清 out/src;
  全新构建未带 --with-sources 时删除上次残留 src/(避免源码与二进制错配)

P2-a:top 排除理由改写。根因是 zig 0.17 musl 的 time64 ABI 错配(mips o32 是
ILP32 + 内核 4.4 无 time64 syscall,16B timespec 直传 32 位 syscall),不是
procps 的 bug;top 的 Mem 一栏是「百分比/总容量」不是 used GiB。新增 DEVKIT.md
§9 记录证据(vmstat -t 空列、usleep(0.5s)×10 实测 0s、sleep(1) 正常、ps lstart
正常),并说明 M16 的 patch_v2/sdk/meow_ui_time.c 可复用(本轮实测该 .o 独立
编译、仅 5 个 libc 未定义符号)但本轮不接入的取舍——需要单独 mission 做链接期
接管 + 全量回归,不是 -D 能修的。

P2-b:--with-sources 现在同时放 build_devkit.sh + DEVKIT.md(GPLv2 §3 的
"scripts used to control compilation");补 LGPL-2.1 §6 静态链接 libproc2 的
义务说明;MANIFEST/README.txt 写明 src/ 与 licenses/。

P2-c:lsof 内嵌 Termux musl 的 resolv.conf 路径记为工具链副作用(实测
lsof -i 约 3s 超时、-nP 瞬时;端口名/本机名不受影响),不换工具链。

P3:软链路径写实(/bin/ps、/usr/bin/{free,lsof,top});MANIFEST 用 zig version
而非 clang 版本;chmod 措辞改成检查挂载参数;--verify-only 增加 MANIFEST 逐条
sha256/体积比对且不再依赖 date;stamp 纳入 zig 完整版本;许可证复制失败即报错。

验证:test_build_devkit.sh 12/12 通过;--verify-only 通过(含 MANIFEST 对账);
独立工作区离线重建 85s/0 下载,六个二进制 sha256 与上一版逐字节一致;设备侧
二进制未变(sha 与 r1 真机验证过的一致)。
2026-11-06 19:29:27 +08:00
AskaEth b52e6a08bc feat(devkit): M24 静态 MIPS32r2 调试工具集 — vmstat/slabtop/free/ps/pmap/lsof
TF 卡 MeowOS/opt/devkit 用的交叉编译流水线:zig 0.17 musl 静态链接,
产物 fw_apps/devkit/{bin,MANIFEST.txt,licenses,README.txt}(gitignore 内)。

- build_devkit.sh:源码固定 版本+sha256+URL(ncurses 6.5 / procps-ng 4.0.4 /
  lsof 4.99.4),缓存命中零联网;冷启动 3 次下载约 80s,重跑 0 次联网约 65s,
  stamp 命中 1s 跳过;--force/--clean/--offline/--with-sources/--verify-only
- 自编静态 ncursesw 只为 slabtop(procps 的 slabtop 硬依赖 ncurses);
  terminfo 目录钉到设备 /usr/share/terminfo,交互式 slabtop 可用
- 逐项自检 file/readelf(static、ELF32、mips32r2、0 NEEDED),任一不过即失败
- 不交付 top(真机输出不可信)、htop(卡上已有 3.5.3 且我们的更大)
- DEVKIT.md:许可证留痕(procps=GPL-2+ 需提供源码、ncurses=MIT、lsof=Purdue)、
  可行性实测、上卡 PATH 与 busybox 同名关系、真机验证输出
2026-11-06 19:05:15 +08:00
AskaEth 2a2e6988ae Merge branch 'feat/m23-network-time-sync' 2026-11-06 13:43:24 +08:00
AskaEth 1f692ae2a7 fix(ntp): M22 r1 评审 P2×3 — 孤儿 ntpd 收尸 / 日志失败不吞 hwclock / 锁自证
r1 评审 verdict=p2-3items,三条均已按评审给的修法修掉,且都补了可证伪的回归断言。

P2-1 SIGTERM 后遗留孤儿 ntpd,之后每次静默 skip 且 rc=0
- attempt() 把子进程记进全局 NTPD_CHILD 并落盘 <锁目录>/ntpd.pid;
  新增 cleanup()(EXIT trap)先收尸子进程再释放锁;新增 on_signal()
  (INT/TERM/HUP)清理后 exit 143——旧代码的 TERM trap 只清锁、不 exit,
  被杀后还会继续跑完看门狗。
- 取代原来的 "pidof ntpd 就 skip":新增 reap_or_skip_ntpd(),用
  /proc/<pid>/cmdline 是否含本锁目录来区分「我们自己遗留的孤儿」(收尸)
  与「别人的常驻 ntpd」(让路)。崩溃后留下的 ntpd.pid 也据此校验,
  pid 被复用时不会误判成外部 daemon(不会永久 skip)。
- S45 stop 不再按陈旧 pid 文件盲杀:只从 <锁目录>/<pid> 取持有者,
  再核对 /proc/<pid>/cmdline 含 meow_ntpsync 才 kill。

P2-2 日志重定向失败导致 hwclock 根本不执行
- persist_rtc 改 ">/dev/null 2>>$LOG_FILE":hwclock 的 rc 与日志写入解耦。
- 新增 prepare_log():启动时在子 shell 里试写日志,不可写就整体退化 /dev/null。
  必须用子 shell——`:` 是 POSIX 特殊内建,它的重定向失败会让 dash 直接
  退出整个脚本(本单自测先踩到了,rc=2)。

P2-3 锁 mkdir→写 pid 有窗口,并发抑制非原子
- 锁的持有者改由 <锁目录>/<pid> 子目录自证(mkdir 原子),不再写 pid 文件;
  读到空锁时给 3s 宽限而不是立刻判死。
- 清失效锁改用 mv 到 <锁目录>.stale.$$(rename 原子),并发清锁时只有
  一个能成功,避免「抢锁失败方删掉胜方的锁」。
- 退出只删自己的锁:没有自己的 <pid> 子目录就不动锁目录。

验证
- 新增回归断言 11-16(run_tests.sh):SIGTERM 收尸/锁释放、遗留孤儿收尸、
  日志不可写仍写 RTC、锁被占用不抢、死锁可接管、pid 复用不阻断。
- 可证伪性:新套件跑旧代码(c9f0cba 的 meow_ntpsync)→ 6 条 FAIL
  (孤儿 alive、日志不可写 rc=1 且 hwclock 未执行、抢锁并发调 ntpd×3)。
  再跑「仅回退 STALE_NTPD 硬化」的变体 → pid 复用那条 FAIL
  (rc=0 但 RTC 未写,正是评审指出的静默失效)。
- dash 与 busybox ash 各 41/41 PASS,rc=0;dash -n / busybox sh -n 均 0 错。
- 时间断言放宽为负载容忍(宿主是多 agent 共享机,实测 sleep 5 能跑成 85s),
  假 ntpd 改为挂 3600s,保证上界仍能证明是看门狗掐断而非自然退出。

fw_content.conf: meow_ntpsync md5 ad324e3f→f3c3abe4,
S45meow_ntpsync md5 25454a3f→115e7592(50meow_ntpsync 未改,md5 不变)。
2026-11-06 13:34:45 +08:00
AskaEth c9f0cbaab6 feat(ntp): M22 网络校时 — 复用固件自带 ntpd + hwclock 回写 RTC
真机实测(0.8.47,2026-11-06)定位到的根因不是「没有 NTP 客户端」:
固件自带 ntp-4.2.8p14(/usr/sbin/ntpd)和 /etc/ntp.conf(4 个 pool.ntp.org),
但没有任何 init 脚本/进程启动它 —— 有客户端、没启动。

实测证据:
- RTC 存在且可读(/dev/rtc0,10003000.rtc),内核开机 hctosys=1,
  冷启动不回到 1970 → 掉电保持;RTC 存 UTC(since_epoch == date +%s)。
- 但 RTC 会走慢且无人纠正:实测系统时间比 NTP 慢 49 s;把系统时间人为
  拨错 4 h 后观察 65 s 无人纠正,RTC 也不跟随系统时间。
- UDP/123 出网正常(ntpd -q 一次成功 time set +48.877s);DNS/HTTP 正常。
- udhcpc 的 default.script 不处理 option 42,实测热点也不下发。
- ntpd -q 不会回写 RTC(步进后 sys-rtc 仍差 49 s)→ 必须 hwclock -w -u。
- ntpd 没有超时参数,无服务器时永久存活 → 必须自带看门狗。
- -p 形式的 server 首个 poll 要 ~64 s;配置里加 iburst 后 8 s 内返回。

实现(新增,不改任何基线文件):
- /usr/bin/meow_ntpsync:一次性校时器。复用 ntpd -q -g + 运行时生成的
  iburst 配置;内置 IP 兜底(阿里/腾讯/Cloudflare)→ DNS 名兜底;
  单次 15 s 看门狗、默认路由轮询、1 h 短路、进程锁、失败静默。
  成功严格校时后 hwclock -w -u 回写 RTC(RTC 存 UTC,必须 -u)。
- /etc/init.d/S45meow_ntpsync:开机只 setsid 后台拉起并立即返回
  (关键路径 +0 秒;后台最长约 150 s 自行退出),失败不阻断 init。
- /usr/share/udhcpc/default.script.d/50meow_ntpsync:只在 bound/renew
  触发(设备上唯一可靠的「网络就绪」事件),后台拉起后立即 exit 0;
  有 $ntpsrv(option 42)就优先用。基线 default.script 本就有 HOOK_DIR
  循环,目录原本为空。
- tests/run_tests.sh:宿主沙箱单测(假 ntpd/hwclock/网络探测)。

验证:dash 与 busybox sh 各 sh -n 通过;单测两套 shell 各 23/23 通过;
真机(/tmp 下运行,未装系统目录)首跑 8 s rc=0、ntpd slew 成功、
RTC 由 1793940395 写成 1793940452(49 s 偏差被纠正)、hwclock -u -r 吻合;
二次运行 0 s 短路;-b -t 3 无网络 5 s 退出 rc=1;
DHCP 灌入不可达服务器 192.0.2.x 仍靠 IP 兜底在 ~10 s 内成功。
2026-11-06 12:49:13 +08:00
AskaEth 2cfac55b4e Merge branch 'feat/m22-boot-logo-and-brightness-init-script' 2026-11-05 22:04:56 +08:00
AskaEth f9c2ea42a4 fix(boot): M21 r1 评审 P1 — bl_cap 把 cur=0 当「无有效值」照写 20(+P2 两处)
P1 根因(评审给的真机驱动证据链,已按其最小修法改):
  /module_driver/pwm_backlight.sh 是 `insmod pwm_backlight.ko ... default_brightness=0
  max_brightness=101`,模块 init 把 props.brightness 置 0 再 update_status(PWM 0 分支)→ 上电即灭屏;
  该模块由 S11module_driver_default 加载,rcS 自然序排在 S11jpeg_display_shell 之后。于是:
    - 函数入口那次 bl_cap:背光节点还不存在,写必然失败(no-op);
    - fb0 出现后画图前那次 bl_cap:读到 cur=0,旧条件 `cur <= cap` 命中「只降不升」跳过分支,
      20 从未写入 → 画图时刻亮度 0。
  物理后果两头都是缺陷:面板仍亮则 logo 依旧亮(原问题没修);面板灭则 S11~S21 无可见 logo,
  settings.conf 缺失时直到 launcherd 才亮屏(相对 stock「0.1s 后写 60」是回归)。

修改:
  - bl_cap 判定改为「仅 1 <= cur <= cap 才视为已有有效值跳过;0 / 空 / 非数字一律照写」。
    S22 抢写 1 的竞态保护不变(1 落在 1..20 → 仍跳过,不会被顶回 20)。
  - 删掉函数入口那次 bl_cap(节点未建,必然 no-op,且是重定向报错的来源);只保留 fb0 就绪后、
    画 logo 前那一次 —— 同一驱动脚本里 pwm_backlight.ko 先于 soc_fb.ko 加载,fb0 出现即节点就绪。
  - 写入成功时打一行 `backlight: -> 20 (was N)` 便于刷机后复核;`2>/dev/null` 移到 `>` 之前,
    节点未就绪时连重定向报错一起吞掉(P2-1)。
  - S22:失败路径日志由「保持 S11 低亮度」改为「不改背光(当前 brightness=N)」(P2-2);写入失败
    日志同样附当前读数;写入处的 2>/dev/null 次序一并改正。
  - 头部注释按时序真相重写(驱动默认 0 = 灭屏,不是「上电很亮」)。

实测(沙箱重写 sysfs 路径 + stub cmd_jpeg_display/nanddump/sleep,dash 与 busybox 各一遍):
  cur=0 → 画图时刻 20、结束 20(修复前 0/0);cur=1 → 1/1;cur=60、cur=101 → 20/20;
  竞态(fb0 就绪前外部写 1)→ 画图时刻 1、结束 1(未被顶回);节点不可写时 stderr 0 行、仍画图、rc=0。
  S22 19 用例全 rc=0(仅 usage rc=1,rcS 不可达)、sleep 调用 0、行为与改文案前一致。

S11 与 S22 内容均变 → fw_content.conf 两条 md5 同步更新为
  3ad33301fb9f0884102a47e17a1985ed / 1a0aa33d6f806f3f1648168cc1d8b953
(未改动 meow_watchdog 那条:main tip dfde95b 仍是 553840cd,与本单无关,已在报告中提示。)
2026-11-05 22:04:16 +08:00
AskaEth 0ab23161b6 fix(boot): M21 开机 logo 亮度 — S11 画前压到 <=20 + 新增 S22brightness 按用户设置恢复
冷启动 logo 很亮:stock S11 是画完 logo 之后才写死 60,画图那一刻用的是面板上电默认高亮;
而 S11 早于 S21mount_ubifs,那时 /usr/data 未挂载、读不到 settings.conf,只能写死一个值。

- S11jpeg_display_shell:以基线(249220f2)为底,把背光压制挪到画 logo 之前(jpeg_display
  函数体内、先于 cmd_jpeg_display),值 20(只降不升:S22 已写更暗则不覆盖);删除画完后
  写死 60 的那行;/dev/fb0 等待、mtd5 主题判定、后台 jpeg_display & 语义不变;
  主题判定 `==` 改 `=`(POSIX test,语义不变,便于宿主验证)。
- S22brightness(新):S21mount_ubifs 之后运行,读 /usr/data/meow/settings.conf 的
  display.brightness,按 display.brightness_min 与 sysfs max_brightness clamp 后写入;
  读不到/解析失败不改背光(保持 S11 低值)、无 sleep/重试、任何运行期失败 exit 0;幂等。
- scripts/build/fw_content.conf:登记两条(S11 覆盖基线同名文件、S22 新增),md5 实算,
  说明列写清 S11 < S21mount_ubifs < S22 的顺序依赖。
- scripts/build/README.md:补「覆盖基线已有文件」语义(无条件 cp -f,不校验基线 md5、
  也不跳过已存在文件;唯一基线校验仍是 hiby_player)。

固件需整包刷机才生效,未刷机、未写设备。
2026-11-05 22:04:16 +08:00
AskaEth 316d9d42c7 build(fw): 同步 watchdog 的 md5 到 M19 重编后的二进制
M19 把 meow_watchdog 重编为 ad8c1f2b(首次把 M6.23 的亮屏亮度修复真正编入),但
fw_content.conf 第 32 条仍 pin 旧值 553840cd,build_fw_apps.sh 的强校验会在该条中止。
本提交只改这一行 md5 与该条说明。

注:scripts/build/ 被 .gitignore:68 忽略,而这些文件本就已跟踪,故需 git add -f。
2026-11-05 21:54:23 +08:00
AskaEth dfde95b020 Merge branch 'feat/m20-watchdog-brightness-boot-fallback' 2026-11-05 21:41:25 +08:00
AskaEth f2551a28a2 fix(watchdog): M20 亮屏亮度不再回退固定 60(settings 读失败重试 + 未知快照语义拆分)— 重编出货二进制
真机 0.8.46(2026-11-05 21:09 日志)复现「开机/亮屏背光跳到固定值」:
  76646 [watchdog] power: panel ON (blank 0 + brightness 60)
而 /usr/data/meow/settings.conf 是 display.brightness = 1。根因链:
- 出货 /usr/bin/meow_watchdog(md5 553840cd…,与仓库内二进制逐字节相同)是
  2026-09-01 6a4d330 编出的旧版:不含 M6.23 的 settings 读取(strings 无
  display.brightness_min,日志仍是旧格式 "brightness %d")—— M6.23 只改了源码、
  没重编。旧版 wd_panel_on 无条件 write_brightness(wd_saved_bri>0?wd_saved_bri:60),
  wd_saved_bri 初值 60 且该 session 未熄屏过 → 亮屏写 60,覆盖 launcher 刚设的 1。
- 源码侧同样有 60 兜底:wd_settings_brightness() fopen 失败即 -1 → 回退快照 60。

改动(patch_v2/meow_watchdog/):
- wd_settings_brightness():fopen 失败重试 3×10ms。覆盖 settings.conf「写 tmp+rename」
  原子替换、开机早期 /usr/data 未就绪的瞬时窗口;只在失败时发生(成功路径 0 开销),
  最坏 20ms,远小于亮屏轮询粒度 REFRESH_MS=100ms。
- 新增 wd_bri_decide() 纯函数:settings(clamp 后)→ 本次运行期真实快照 → 不写
  (面板已亮,untouched)→ 安全低亮 10(面板黑且无任何值来源)。任何路径都不再写 60。
- wd_saved_bri 初值 60 → WD_BRI_UNKNOWN(0);wd_panel_off 只在读到正亮度时更新快照,
  读失败/被 launcher 抢先写 0 时保留上一次真实值(不再是 (br>0)?br:60)。
- panel ON/OFF 日志带 src=settings|snapshot|untouched|safe-low 与 user/saved/cur,
  真机一眼看出用的是哪一支。
- 背光路径加 MEOW_BACKLIGHT env 测试钩子(与 MEOW_SETTINGS_FILE 同款,真机无 env)。
- test_wd_fw.c 新增 21 条用例(决策 7 + settings 解析/重试 6 + panel OFF/ON 端到端 8);
  端到端用例真的读写背光文件,复现「settings=1 → 亮屏写 1」而不是 60。
- 重编 meow_watchdog:mipsel 静态 MIPS32r2 ELF,md5 ad8c1f2b…,0 warning。

验证:./build_watchdog.sh host → 38 pass / 0 fail;交叉编译(-Os -Wall -Wextra -static)
0 warning,产物静态 MIPS32r2。
生效条件:meow_watchdog 在 squashfs /usr/bin,必须刷固件(或等价的重打包流程)才生效。
后续(不在本单 scope):scripts/build/fw_content.conf 第 32 行仍 pin 旧 md5 553840cd…,
不更新会让 build_fw_apps.sh 的强校验失败。
2026-11-05 21:32:27 +08:00
AskaEth bcb11e012f fix(reader): stb_image 实现改由 meow_ui.a 提供,修 M6.45 收编后的重复定义
SDK 在 M6.45 收编 stb_image.c.o 进 meow_ui.a 后,reader 的 engine/rdimg.c 仍带
-DSTB_IMAGE_IMPLEMENTATION,链接期 stbi_* 与归档内 stb_image.o 重复定义,reader
无法重链。改为只编封装、实现取自归档;rdimg.c 用到的 stbi_info_from_memory /
stbi_load_from_memory 在归档中均已导出。
2026-11-05 21:12:42 +08:00
AskaEth 62b3603ade merge(sdk): 回补 M19 滚动池槽泄漏修复(wrap 上下文归还 + 44 断言回归) 2026-11-05 21:00:22 +08:00
AskaEth 72c6e9889d fix(sdk): 从公开仓回补 M19 滚动池修复(free 归还 wrap 槽)+ 宿主回归
meow_scroll_wrap 从 MEOW_SCROLL_POOL(=8) 个 wrap 上下文槽占一个,meow_scroll_free
只归还会话槽、从不归还 wrap 槽也不清 ctx.w → 进程内最多成功 wrap 8 次,之后恒 NULL。
主仓此前仍是有缺陷版本;本提交把公开 SDK 仓 c85877b 的修复按内容移植到主仓(唯一权威
源),meow_ui_scroll.c 结果与公开仓 sdk/meow_ui_scroll.c 逐字节一致,diff 仅 wrap 相关
hunk(之前两文件差异也只有这些 hunk,故无其它改动被覆盖)。

- meow_scroll 结构记 wrap_slot(create 置 -1),wrap 时写槽下标;free 按该下标清
  g_wrap_ctx[].w/lv 并归还 g_wrap_used,随后置 -1(二次 free 不触碰任何槽)。
  直接 create 的实例 wrap_slot=-1,free 不误清别人的槽。池满语义不变:8 个同时活跃时
  第 9 个 wrap 仍返回 NULL;free(NULL) 安全。
- test_meow_ui_scroll 断言 17 → 44:64 次 wrap/free(修复前 8/64、修复后 64/64)、
  free 后再 wrap、8 满 + 第 9 个 NULL + 释放其一即复用、交错 free 不串槽、直接 create
  不误清槽、list→listview→list 槽复用不残留 kind、free(NULL)/二次 free。
  头注释补 meow_ui_nav.c:原配方缺该 TU,链接期 meow_ui_nav_request_exit 未定义。

验证:LAUNCHER_DIR=主检出/patch_v2/meow_launcherd build_sdk_host.sh --verify
exit=0、0 warning/error、成员清单 76 项一致且全 MIPS32r2;宿主测试 44 pass / 0 fail;
同测试对修复前 meow_ui_scroll.c 为 13 pass / 19 fail(首条即 ok=8 first_fail=8)。
meow_ui.a 中 meow_ui_scroll.o 已变化,链 meow_ui.a 的 App 需重链。
2026-11-05 20:59:36 +08:00
AskaEth 8d2c098deb Merge branch 'feat/files-hello-row-height-followup' 2026-11-05 20:57:43 +08:00
AskaEth 9600f10352 fix(hello): demo 左滑列表行高按字体行高派生 — 首行标题不再丢失(M17)
根因(承接 M12/M15 finding):SDK meow_listview 双行 renderer 把
block = 2*meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2) 在行内垂直居中
(ty = 行顶 + (item - block)/2),标题只在 ty >= 视口顶时才画。demo 的 swipe 列表
显式 set_item_renderer(2,1,0)(双行档位)却 set_item_h(48):真机 source_han_serif
20px 行高 30 → block 62 → ty = 行顶 - 7,首行「notif 0」标题被裁剪守卫整条跳过,
只剩 swipe left 副标题。

修复:
- hello_internal.h:新增 HELLO_ROW_H_MIN(56) / HELLO_ROW_SUB_GAP(2,与 SDK
  MEOW_LISTV_SUB_GAP 同口径) + hello_row_h_for_line_h(line_h)
  = max(56, 2*line_h + 2) + hello_row_h()(取当前主题 font_body 的
  meow_ui_text_height)。本地派生,不依赖 M15 新增导出 meow_listview_item_h;
  显式传值在 M15 之前的 SDK(默认 48/56 档位)与 M15 之后(默认已派生 62)都成立且一致。
- ui/demo/sdk2/sdk2.c:set_item_h(48) 改传 hello_row_h()(行高 30 → 62)。
  列表视口 LV_H / 三节布局推进量不变(行高只影响行内几何与滚动量),其它 demo 区块
  (LazyColumn 列表、宫格、选择器)不受影响。

验证(宿主,未碰真机):
- zig-mipsel-cc -Os -static -Wall -Wextra(另用 -Werror 复验)交叉编译 hello:0 warning;
- 新宿主 test/test_row_text_fit.c(32 断言,纯头文件派生、不链 SDK):
  字库行高矩阵 14/21/26/27/30 下 block <= item、首行 ty >= 0;行高 30 → 62;
  旧 48 → ty=-7 的 bug 形态哨兵;全 PASS。

影响:meow_ui.a 未改 → 无需重编 SDK;本 App 需重链重部署(热更 hello 二进制)。
2026-11-05 20:57:13 +08:00
AskaEth e69b8efeeb fix(files): 目录列表行高按字体行高派生 — 20px 字库下首行标题不再空白(M17)
根因(承接 M12/M15 finding):SDK meow_listview 双行 renderer 把
block = 2*meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2) 在行内垂直居中
(ty = 行顶 + (item - block)/2),标题只在 ty >= 视口顶时才画。目录行是双行档位,
真机 source_han_serif 20px(ascent 24 + descent 6)行高 30 → block 62,而
browser_page_create 显式 set_item_h(56) → ty = 行顶 - 3:首行(行顶 == 视口顶)
标题被裁剪守卫整条跳过,只剩大小/时间副标题。

修复:
- files_internal.h:APP_ROW_H 降级为行高下限(56);新增 FILES_ROW_SUB_GAP(2,与
  SDK MEOW_LISTV_SUB_GAP 同口径) + files_row_h_for_line_h(line_h)
  = max(56, 2*line_h + 2) + files_row_h()(取当前主题 meow_theme_get()->font_body
  的 meow_ui_text_height)。本地派生,不依赖 M15 新增导出 meow_listview_item_h;
  显式传值在 M15 之前的 SDK(默认 56)与 M15 之后(默认已派生 62)都成立且一致。
- ui/browser.c:browser_page_create 的 set_item_h 改传 files_row_h()
  (行高 30 → 62)。SDK 的 draw/hit/max_scroll 全部按这一个值换算,滚动/命中天然
  同步;页脚几何(r_list 单独扣 APP_FOOTER_H)与行高无关。其它几何/交互不变。

验证(宿主,未碰真机):
- zig-mipsel-cc -Os -static -Wall -Wextra(另用 -Werror 复验)交叉编译 files:0 warning;
- 新宿主 test/test_row_text_fit.c(32 断言,纯头文件派生、不链 SDK):
  字库行高矩阵 14/21/26/27/30 下 block <= item、首行 ty >= 0;行高 30 → 62;
  旧 56 → ty=-3 的 bug 形态哨兵;全 PASS。

影响:meow_ui.a 未改 → 无需重编 SDK;本 App 需重链重部署(热更 files 二进制)。
2026-11-05 20:57:13 +08:00
AskaEth c2635daf3f Merge branch 'feat/sdk-time64-syscall-gaps' 2026-11-05 20:46:36 +08:00
AskaEth a7bd6c44d9 fix(sdk): 评审 p2 修正 — 负 nsec 判 EINVAL / 音频条件等待计数改 5 处
- meow_ui_time.c `__timedwait_cp`:`at->tv_nsec` 是有符号 long,原写法
  `>= 1000000000L` 会让负 nsec 通过(musl 原文用 `>= 1000000000UL`,负值按无符号
  比较同样判 EINVAL)。改为 `< 0 || >= 1000000000L`,与本文件 M11 的
  `__clock_nanosleep` 写法一致;同时避开 `-Wsign-compare`(UL 写法在 -Wextra 下
  会告警)。反汇编确认 llvm 合并成一条 `sltu`,与 musl 的 unsigned 比较等价。
- CLOCK_ABI.md:meow_ui_audio.c 的 pthread_cond_timedwait 调用点由「3 处」改为
  5 处(:312/:351/:417/:431/:499),§5/§6 两处同步;核对文中其它计数(四 App、
  23 项单测)无误。

验证:宿主 gcc -Wall -Wextra 0 warning,test_meow_time64 23 项 PASS、
test_meow_time 回归 PASS;交叉 zig -Wall -Wextra 0 warning;
build_sdk_host.sh --verify 76 成员全 MIPS32r2、0 warning;归档内 meow_ui_time.o
与新鲜交叉构建逐字节一致。
2026-11-05 20:46:26 +08:00
AskaEth 40ac1e40f0 docs(sdk): CLOCK_ABI 补 M16 全量清点/接管机制/验证
§6 把「同源残余」换成逐条清点:zig musl 源里 20 处
`#if defined(SYS_*_time64) && !defined(__ILP32__)` 守卫,分「受损(回退分支直传
time64 结构)」与「不受影响(回退分支自带 long[]/宽度判断)」两类,各给源文件:行号
与反汇编证据;纠正 M11 对 pselect/ppoll 的保守判断(它们本来就正确)。

§7 记录接管机制:_REDIR_TIME64 让应用引用 __x_time64(不是 API 名);同一 .o 的
libc 符号必须一起定义(只定义 __timedwait_cp 会与 libc 的 __timedwait.o 撞
duplicate symbol,lld 实测);hidden 不阻止静态链接接管但要保证本 TU 被拉入;
结构宽度前提(整链 zig 头)。附未修 7 项的 5 步配方与「不建议顺手全改」的理由。

§8 记录本单验证:宿主 23 项换算单测 + M11 回归、build_sdk_host.sh --verify
(76 成员/全 MIPS32r2/0 warning)、四个入口的 syscall 号与缓冲宽度反汇编、
真实 meow_ui.a 链接探针的符号绑定。真机确认仍待设备。
2026-11-05 20:46:26 +08:00
AskaEth 0412994953 test(sdk): time64→o32 time32 换算宿主单测(23 项)
test_meow_time64.c 覆盖 meow_ui_time.c 接管入口共用的纯换算层:

- 相对超时(futex):普通值、亚秒、INT32_MAX 上界、上/下溢饱和、负值透传;
- 绝对时刻(clock_settime/timerfd):int32 上下界接受、越界拒绝(对应 ENOTSUP);
- itimerspec 四字段不串位、任一越界整体失败且不写坏输出、反向展开对称;
- 结构宽度 before/after:同一块 16B time64 内存按内核 o32 只读 8B 时,
  0.4s 被读成 {0,0}(立即超时)、2.3s 被读成 2s 整;经本层转换后内核读到
  {2, 300000000}。

宿主编译/运行:
  gcc -Os -Wall -Wextra -I. -Iinclude -c meow_ui_time.c -o /tmp/m.o
  gcc -Os -Wall -Wextra -I. -Iinclude test_meow_time64.c /tmp/m.o -o test_meow_time64
  ./test_meow_time64        # == PASS ==
M11 的 test_meow_time.c 仍 PASS(回归)。
2026-11-05 20:46:26 +08:00
AskaEth 3b5ffece57 feat(sdk): 接管 mipsel time64 同源缺口 — futex 相对超时/timerfd/clock_settime
zig clang 给 mipsel 预定义 __ILP32__,打穿 musl 的 time64 回退守卫,以下入口把
16/32 字节 time64 结构直接交给 o32 time32 系统调用:

- thread/__timedwait.c:14:futex 相对超时(pthread_cond_timedwait / sem_timedwait /
  pthread_join 超时 / aio_suspend / 非 PI timedlock / rwlock timed)。内核只读前 8B,
  tv_nsec 落在 tv_sec 高半字(恒 0)→ 亚秒等待立即超时、整秒等待丢纳秒。SDK 的
  meow_ui_audio 三处条件等待直接受害。按 musl 原文接管 __timedwait_cp/__timedwait,
  只在发 futex 前把相对超时转成 time32;取消点仍走 libc 的 __syscall_cp。
- linux/timerfd.c:14/42:32B itimerspec 直传 → 内核按 16B 读、字段错位;接管
  create/settime/gettime(同一 .o 的三个符号必须一起定义,否则重复定义)。
- time/clock_settime.c:9:16B 直传 → nsec 恒 0;接管 __clock_settime64。

换算抽成平台无关的 meow_time32_{rel,abs}_from_parts() / itimerspec 转换
(meow_ui_internal.h),宿主可测。pselect/ppoll/select、getrusage、wait4、
clock_adjtime 逐条反汇编确认回退分支自带宽度转换,不受影响(纠正 M11 §6 的保守
估计)。未修项(sigtimedwait/semtimedop/timer_settime/gettime/utimensat/recvmmsg/
mq_timedsend/PI timedlock)的清单与配方见 CLOCK_ABI.md §6/§7。

验证:宿主 gcc -Wall -Wextra 0 warning;交叉 zig -Wall -Wextra 0 warning;
build_sdk_host.sh --verify 76 成员全 MIPS32r2;真实 meow_ui.a 链接探针确认
__pthread_cond_timedwait_time64 的 jal 指向本实现、futex=4238 且超时为 8B、
timerfd 4323/4322、clock_settime 4262。
2026-11-05 20:46:26 +08:00
AskaEth cfcb94b8d2 Merge branch 'feat/sdk-listview-row-height' 2026-11-05 20:38:09 +08:00
AskaEth 31c12892d6 test(sdk): 宿主回归 — --wrap 行高 30 下首行标题必须画出(listview 行高派生)
新 test_meow_ui_listview_rowh.c(35 断言)用链接期
-Wl,--wrap=meow_ui_text_height 把宿主 5x7 回退行高 14 换成真机 source_han_serif
20px 实测行高 30,驱动真实 SDK 渲染(同 M12 app/test/test_first_row_title_render.c
手法),断言全部读 back_buf 像素:

- T0 wrap 生效(meow_ui_text_height(font_body) == 30);
- T1 默认(不设 item_h):item_h == 62 = max(56, 2*30+2) ≥ 2*行高+间距;
  max_scroll/hit 按 62 换算;scroll=0 时首行标题带与副标题带都有文字像素;
  视口上方无标题泄漏;
- T2 裸 create(不调 renderer)同样派生;set_item_h(0) 忽略并保持派生;
- T3 显式 set_item_h(70)(ncm 语义):item_h/hit/max_scroll 全按 70,首行标题可见;
- T4 档位:tier(2) 恢复派生 62、tier(1) 48,单行档位不画副标题且标题可见;
- T5 pool 模式(set_vsource + 默认 renderer):同样派生 62,首行标题可见;
- T6 旧行为哨兵:显式 56 < block 62 时首行标题带 0 像素、副标题仍有 → 证明
  像素断言能区分「画出/被跳过」,即 A/B 复现 bug 形态。

回归(宿主 gcc -O1 -Wall -Wextra,全部 0 warning):
- 新 test:35 pass / 0 fail;
- listvirt 44 / widgets ALL PASS / md3 ALL / scroll 17 / select 6 / swipe 35 /
  lazygrid 32 全 PASS;
- 交叉 build_sdk_host.sh --verify:76 成员全 MIPS32r2 rel2,meow_ui_widget.c
  -Wall -Wextra 0 warning(meow_ui.a 变更 → 下游 App 重链)。
2026-11-05 20:38:02 +08:00
AskaEth 4d9ba46a7b fix(sdk): listview 默认行高按字体行高派生 — 双行列表首行标题不再被裁剪
根因(M12 finding / SDK 侧):meow_listview 双行 renderer 把「标题+副标题」
文本块 block = 2*meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2) 在行内
垂直居中(ty = 行顶 + (item-block)/2),且文本只在 `ty >= 视口顶` 时才画。
旧默认行高固定 56 假定行高 ≤ 27;真机 source_han_serif 20px(ascent 24 +
descent 6)行高 30 → block 62 > 56 → ty = 行顶 - 3,首行(行顶 == 视口顶)
标题被裁剪守卫整条跳过、副标题仍在 → 所有用默认行高的 App 两行列表首行
标题消失。

修复(patch_v2/sdk/meow_ui_widget.c):
- item_h=0 改为「派生」哨兵:listv_item_h() 在未显式定行高时按当前主题正文
  字体行高算 —— lines=2: max(56, 2*line_h+2)(行高 30 → 62)、lines=1:
  max(48, line_h)。draw/hit/max_scroll 共用同一函数,行几何恒一致;
- meow_listview_create 不再写死 56;set_item_renderer 不再写死 48/56;
  set_item_tier 恢复派生默认(仍是 1→≥48 / 2→≥56 的口径,字体大时自动长高);
- 显式 meow_listview_set_item_h(>0) 语义不变、优先且固定(ncm 传 70、files
  传 56 等调用方不受影响);set_item_h(<=0) 仍忽略,返回值改为「当前生效行高」;
- 新增 meow_listview_item_h() 读当前生效行高(App 自绘行装饰对齐用,避免再
  硬编码 56);
- src2 分支的双行判断补 `w->lines == 2`,与 pool 分支同口径:tier/renderer 设
  单行档位时明确不画副标题(此前单行档位 48px 里仍塞 62px 文本块 → 同样丢标题)。

同类路径评估(不改):
- LazyColumn/LazyRow(W_LIST):单行 label,item_h 由 create 显式给(默认 44),
  且 item_h 兼作横向列宽,按行高派生意味不明;默认 44 > 真机行高 30,当前不触发;
- lazygrid:格子几何由 App 显式给、文本底部对齐并整体 clip_push 裁剪,不受影响。

影响面:所有用默认行高的两行列表(hello/reader/files 及其它 App)在 20px 字库
下默认行高 56 → 62;显式 set_item_h 的调用方(ncm 70/launcher/files 等)零变化。
meow_ui.a 变更 → 下游 App 需重链(追加导出符号 meow_listview_item_h,ABI 兼容,
老 App 不重编也能跑)。

验证:见下一条 test 提交(宿主 --wrap 行高 30 + 像素断言 35 断言)。
2026-11-05 20:38:02 +08:00
AskaEth 805cfe11cc Merge branch 'feat/firmware-build-m646-killfix' 2026-11-05 20:16:38 +08:00
AskaEth c811913c06 build(fw): M6.46 固件 — 编入 M7 杀后台修复 + M11 SDK 时钟修复(0.8.46)
- launcher bb2d5f12 (863160B) 重编,链 meow_ui.a 4c4a1554:
  M7(bgmgr_can_kill 门控 framework→protected_app / 后台页 sub 指针持久化 /
  am kill 走 meow_bg_terminate 与页面按钮同通道) + M11(meow_ui_time 按 o32 time32 取时、
  链接期接管 __clock_gettime*)
- meow_amsd b698f176 (139252B) 重编:M7 am kill 同通道 + cmd_kill/cmd_start 失败原因细分
  (am CLI 9e323faf 保持不动:am.c 自 ca2290e 未变,与仓库发布物一致)
- fw_content.conf 8 条源 md5 对齐:bootmenu 4441822d / watchdog 553840cd / audiod b122c9b8 /
  meow_audio 891ab264 / netmgrd×2 20243ea1 / launcher bb2d5f12 / amsd b698f176;头注 M6.46/0.8.46
- build_fw_apps.sh:repack.sh 改 bash 显式执行(#!/usr/bin/env bash 在 su 下 bad interpreter)
- 打包 r3proii_m6_46.upt md5 66501aaf(42,930,176B):rootfs 38,756,352<=45,088,768,
  unpack 0 diff,87 files/1 folder 与 m6_45 一致
- 卡上写 r3proii.upt(66501aaf),原 0.8.45(3f4a8ad3) 备份 r3proii.upt.bak.0.8.45;
  写前写后 du -sk Music 均 199164544;未刷机(运行中仍 43ac2312 / 0.8.45)
2026-11-05 20:16:29 +08:00
AskaEth 278e7eaa93 merge(sdk): 收编公开 SDK 仓的图片预算解码,主仓成为 SDK 唯一权威源 2026-11-05 20:07:26 +08:00
AskaEth d9950d75eb Merge branch 'feat/ncm-list-first-row-title' 2026-11-05 20:04:49 +08:00
AskaEth e23bdca26a fix(sdk): 收编公开 SDK 仓独有改进(图片预算解码 + JPEG 解码期缩解),主仓成为唯一权威源
公开仓 ~/Proj/MeowOS-SDK 的 sdk/ 与主仓 patch_v2/sdk 无共同 git 历史(subtree split
重写了提交哈希,不做跨仓 merge/subtree pull),按内容逐文件裁决。公开仓侧的真增量只有
commit 21bffa1(图片预算解码)及其依赖;其余差异是公开仓的自包含打包(扁平 include/、
vendored 头与 meow_font.c)或主仓早已超越的旧快照。

一、取公开侧(纯追加,主仓该文件无独有改动)
- stb_image.h:新增 stbi_load_scaled / stbi_load_scaled_from_memory——JPEG 输出级按
  步长抽取行/列,整幅原尺寸输出缓冲从不物化;进升级 JPEG 各分量系数 IDCT 进像素平面后
  立即释放。scale<=1 与旧 API 等价,非 JPEG 忽略。主仓版本的 diff 全部是公开侧新增
  (无主仓独有 hunk),故整体替换。
- meow_ui_img.c:追加 img_pick_scale / img_finish_dim / meow_img_probe_file /
  meow_img_load_file_dim / meow_img_load_mem_dim。既有加载入口一字未改。
- include/meow_ui_image.h:追加上述三个公共声明。
- 新增 test_meow_img_budget.c + test_meow_img_budget_gen.py(--wrap malloc/free 打点
  统计瞬时峰值,B1-B9 覆盖矩阵)。

二、取主仓(公开侧为旧快照或自包含适配,并入会回退功能)
- meow_ui_ime.c / test_ime_fix.c:主仓 bug3 修复(IME 打开瞬间 200ms TAP 冷却窗 +
  drain/40ms/再 drain)及其回归用例为公开侧所无;公开侧同名文件不含该修复。
- meow_ui_input.c / meow_ui_internal.h:主仓 meow_input_touch_reset(drain 与叠加抑制
  进入沿共用)、FreeType 渲染/字形声明、meow_time_ms_from_parts。公开侧此三处无独有声明
  可并,取主仓即并集。
- meow_ui_gfx.c / meow_ui_sys.c / meow_cachegov.c:主仓时钟 ABI 根因修复(o32 time32,
  meow_ui_time.c 链接期接管 __clock_gettime*)已把这些文件的本地 now_ms 统一为
  meow_ui_time_ms();公开侧仍是各自 clock_gettime,回落即回退根因修复。
- meow_ui_dialog.c / meow_select.c:主仓 MD3 后续(对话框按钮胶囊 MEOW_SHAPE_FULL、
  bottom sheet 顶角 28)晚于公开侧快照,公开侧等于旧版。
- include/meow_ui_font.h:主仓多出 FreeType 段与 stdint 自洽 include,公开侧缺。
- build_sdk.sh / build_sdk_host.sh:主仓含 FreeType vendor 裁剪构建、meow_ui_time TU、
  launcher meow_font.a 打包;公开侧为自包含重写,不回流。
- 其余 test_*.c:差异仅为编译配方注释(公开仓用 -Iinclude、无 meow_ui_time.c、标注
  「需主仓组件故公开仓跳过」等),对主仓目录布局不成立,不改。

三、不引入(vendored 副本 / 打包产物)
- meow_font.c、include/meow_font.h、include/meow_ipc.h、include/meow_notif.h:公开仓
  为自包含 vendored 副本(meow_font.c 与主仓 font/meow_font.c 逐字节相同),主仓已有
  权威源,复制会制造双源。

裁决说明(两边都改过的文件):
- test_meow_ui_select.c:公开仓把 T1 探针从 x=10 改到 x=12(commit 432875d)。主仓 x=10
  在本树实测 6/6 PASS(容器卡左缘 8、文本左缘 24,x=10 已在卡内且避开圆角),无失败
  可修,故不跟进该改动,避免无谓 churn。
- 公开侧「meow_media_playctl」一类 API 名:仅出现在公开仓 vendored 头/文档,主仓
  sdk 无对应实现,未硬造。

四、验证(worktree / 分支 feat/sdk-absorb-public-deltas)
- build_sdk_host.sh --verify:0 warning / 0 error;成员清单 76 个(OBJS 32 +
  meow_font.o + freetype 43)一致,全部 ELF32 MIPS32 rel2 (o32)。
- nm 确认 meow_ui.a 导出 meow_img_probe_file / meow_img_load_file_dim /
  meow_img_load_mem_dim / stbi_load_scaled / stbi_load_scaled_from_memory。
- 宿主测试:对「未改动的 main SDK」跑同一套矩阵做基线对比,40 项同名结果逐项完全一致
  (22 PASS / 11 FAIL / 7 建链失败,失败项与失败计数一字不差);本提交新增
  test_meow_img_budget 为 PASS。补充按各测试正确配方单编:test_meow_img_color、
  test_meow_lyric、test_meow_lyric_view、test_meow_ui_ft(显式传入 MiSans 字体)、
  test_umbrella 均通过/0 warning。
- 既有失败全部是 Termux 环境问题,与本次合并无关(基线同样失败):/tmp 不可写导致
  mkdtemp/fopen/bind EACCES(config/vol/notif_open/notify_ipc/notify_integration/
  sb_collapse/overlay_suppress/input_keys),audiod 守护不在场(ui_audio_host),
  test_sdk_fixes 阻塞循环超时(基线同样 124)。
- hello_app 用新 meow_ui.a 重链成功(0 warning,静态 MIPS32r2),可执行文件
  471380→473636 字节(+2256,新解码路径)。

五、影响
meow_ui.a 内容变化(新增 4 个 MEOW_API + stb 解码路径改变)→ 需重编重链 hello / files /
reader / ncm 四 App 与 meow_launcherd。
2026-11-05 20:03:20 +08:00
AskaEth 22f9dab2ce fix(ncm): 列表首行标题不显示 — 行高按字体行高派生(M12)
根因(真机 seated,非数据/颜色/遮挡):SDK meow_listview 双行渲染把
「2*行高 + 2」的文本块在行内垂直居中,且标题只在 ty >= 视口顶时才画
(meow_ui_widget.c:2061 的越界守卫)。真机 source_han_serif 20px 面
ascent 24 + descent 6 → 行高 30 → 文本块 62px,而 ncm 行高 APP_ROW_H=56
→ ty = 行顶 - 3;首行行顶 == 视口顶 → ty < 视口顶 → 标题被整条跳过,
副标题(ty+32)仍在视口内照画。两类列表同用该路径 + 同一行高,故症状一致。

修复(不动 SDK):ncm_app_internal.h 新增 ncm_row_h()/ncm_row_h_for_line_h(),
行高 = max(56, 2*字体行高 + 2 + 8)(偶数;本机 30 → 70,卡高 62 == 文本块);
home/playlist/queue/cache/search 在 page create 取一次 g_item_h,统一用于
set_item_h 与卡片几何/可见行/页脚/补载视口换算。

回归:
- app/test/test_meow_row_text_fit.c(新):真机字库行高矩阵 14/21/26/27/30
  48 断言(文本块 ≤ 行高、首行 ty ≥ 0、卡高 ≥ 文本块、旧 56 哨兵)。
- app/test/test_first_row_title_render.c(新):--wrap 行高=30 驱动真实 SDK
  渲染 + 读回 PPM 像素计数:旧 56 → 首行标题 0 像素/副标题 186;新 70 →
  标题 84/副标题 100。
- test_meow_row_gap、plpage_sim 全 PASS;ncm_host 7 页出图与改动前逐字节相同
  (宿主行高 14 → 行高仍 56,证重构无视觉回归);5 页自检 PASS;0 warning。
- plpage_sim 临时目录改 $TMPDIR(Termux /tmp 不可写,原写死无法运行)。
- REPORT §43:根因/排除方向/修复/验证/真机验收要点。
2026-11-05 19:51:01 +08:00
AskaEth 98b9169b86 build(ncm): 宿主构建补 meow_ui_time.c(M11 漏编,main 上 ncm_host 链接失败)
M11 新增 patch_v2/sdk/meow_ui_time.c 并把 SDK 各处取时收敛到 meow_ui_time_ms,
但 build_ncm_host.sh 的 SDK_SRC 未同步 → main 上跑本脚本链接报
`undefined symbol: meow_ui_time_ms`(且失败链接会删掉已入库的 ncm_host 产物)。
补一个源文件即可,宿主分支只编 meow_ui_time_ms 的非 mips 实现。
2026-11-05 19:50:51 +08:00
AskaEth 4e60f440f6 Merge branch 'feat/sdk-clock-abi-fix' 2026-11-05 13:42:04 +08:00
AskaEth 38be5e78bc fix(sdk): 时钟 ABI 根因修复 — meow_ui_time 按 o32 time32 取时并链接期接管
真机 meow_ui_time_ms() 返回「低 32=秒×1000、高 32 随机」的根因不是 SDK 写法,
而是 zig 0.17 自带 musl 在 mipsel 上的 time64 ABI 自相矛盾:
- zig 头(mips-linux-musl/bits/alltypes.h)无条件 64 位 time_t、struct timespec 16 字节;
- zig 的 clang 给 32 位 mips 预定义 __ILP32__,musl 的 time64 回退分支
  `#if defined(SYS_x_time64) && !defined(__ILP32__)`(本意只排除 x32)被编译掉;
- 于是 libc 的 __clock_gettime64 实为 o32 time32 实现(SYS_clock_gettime 4263),
  只写 8 字节 {int32 sec; int32 nsec},与头的 16 字节布局错配。

修复(patch_v2/sdk/meow_ui_time.c):
- meow_ui_time_ms() 直接按内核 o32 ABI 取 8 字节 time32,再升 64 位毫秒;
- 同 TU 强定义 __clock_gettime/__clock_gettime64(归档先于 -lc,链接期接管,
  time()/gettimeofday()/pthread 定时等待的内部取时随之修正);
- 同源缺陷一并修:__nanosleep_time64/__clock_nanosleep(_time64) 转换 time32 后重发
  (修复前 usleep(16000) 等亚秒睡眠立即返回、变忙等空转);
- gfx/sys/input/cachegov 四处重复 now_ms 收敛到 meow_ui_time_ms;
- build_sdk.sh OBJS 增加 meow_ui_time;新增 test_meow_time 宿主单测、
  tools/clock_abi_probe.c 探针、CLOCK_ABI.md 根因/验证/影响面文档;
- 26 个宿主测试的编译注释补 meow_ui_time.c(cachegov 测试补时钟源)。

验证:宿主单测 PASS;anim(32)/view_event 宿主用例全过;build_sdk_host.sh --verify
76 成员全 MIPS32r2、0 warning;探针链接后无 libc cgt_init/__vdsosym,
__time64/__timedwait_cp 的取时指向本 TU,睡眠走 4166/4265 且用本地 8 字节结构。
2026-11-05 13:41:52 +08:00
AskaEth 8a115f6583 Merge branch 'feat/ncm-offline-retry-storm' 2026-11-05 13:16:54 +08:00
AskaEth 294ed6ffc7 test(ncm): M10 宿主回归三套(44 断言)+ 构建脚本 + REPORT §42
- bench/retry_storm_sim.c(25 断言):真实 retry_ctl.c/meta_gate.c,100ms tick 断网
  120s→4 次/间隔 ≥30s;状态每 tick 抖动→2 次;时钟回退→不立即重试且间隔仍 ≥30s;
  worker 45s>间隔→不并发;META 空闲 3 帧/60s(旧 600)、播放 13 帧(≤1/5s)、
  状态抖动 ≤4 帧/秒、元数据变化/seek 立即报、超长标题截断不重报、回退不连发。
- bench/home_retry_sim.c(13 断言):真实 app/ui/home.c 的 home_tick 编进宿主
  (--gc-sections + 桩),断网 120s→4 次重试、单飞、全帧重绘 0 次;
  把真机形态的垃圾 64 位 now 喂给 home_tick 仍为 4 次/≥30s;回在线再断网不清基准。
- bench/meta_ipc_sim.c(6 断言):真实 ipc_media 线程 + 假 launcher AF_UNIX socket,
  空闲 2s 只收到 1 帧 META(旧 ~10 帧),状态翻转恰好多 1 帧,帧头合法。
- build_ncm.sh / build_ncm_host.sh:源列表加 retry_ctl.c、meta_gate.c(host 补
  mem_watch.c,否则 player_manager 链接失败);build_ncm_sims.sh 加三个 sim。
- REPORT §42:真机日志证据(时间戳低/高 32 位拆解、21016 帧 META)、根因三链、
  实现、验证(session_gate 88 / http_truncate 13 / core 139 全过)、真机验证建议。
2026-11-05 13:00:32 +08:00
AskaEth 7f4e18b0ed fix(ncm): META 改「变化+低频心跳」,不再每 100ms 刷 launcher(M10)
根因:ipc_media 主循环 `if (now - last_meta >= 1000)` 同样依赖 meow_ui_time_ms()
的坏 64 位值(REPORT §42)→ 条件恒真 → 每个 IPC 轮次都发 MSG_META。
launcher 日志 meow_20261105_122541.log:单次启动 21016 条
`ipc: META app=dev.player.ncm state=0 pos=0/0 -> ACK`,间隔 ~100ms,且全是
无变化的静止帧;launcher 每帧写 2 行日志+归并会话,SD 写放大到整机卡死。

修复:
- 新增 app/play/meta_gate.{h,c}(纯逻辑):状态/时长/标题/歌手/专辑/封面/歌词
  标志变化 → 立即报;播放中位置偏移 ≥5s 重锚(launcher 自己按 last_active 外推);
  心跳播放中 5s / 非播放 30s;硬下限 250ms(任何路径 ≤4 帧/秒);时钟回退不下溢;
  字符串按发送侧截断长度比较(超长标题不得恒判「变了」)。
- ipc_media.send_meta 改为「组快照 → 闸判定 → 才发」,删除 1s 定时器;
  时间改 ncm_monotonic_ms();首帧仍必发(注册媒体源);发送走 DEBUG 日志。
2026-11-05 13:00:28 +08:00
AskaEth 35a636ef42 fix(ncm): 离线重试改单调钟节奏闸,断网不再每个 tick 重试(M10)
根因(真机日志 19000100_000000_7534.log):meow_ui_time_ms() 在设备上返回坏值——
低 32 位是「开机秒数×1000」(无毫秒),高 32 位每次调用随机(REPORT §42)。
旧守卫 `!g_home_thr_on && (!g_offline_retry_ms || now - g_offline_retry_ms >= 30000)`
因此恒真:worker 一收工,下一个 tick 立刻再重试;真机 74s 内 10 次
(间隔 5-7s 只是失败请求本身的连接超时)。第二条链是 else 分支清零重试时刻,
online/offline 一抖动就再试。

修复:
- 新增 app/logic/retry_ctl.{h,c}:节奏闸只做差值,时钟由调用方给可信单调钟
  (home.c 改传 core 的 ncm_monotonic_ms());回退不下溢(重定基准、不重试);
  busy 单飞;在线/离线抖动不重置基准;首次进离线立即探测一次,之后严格 ≥30s。
- home_tick 不再使用入参 now(真机坏值无效化);新增 home_retry_reset()。
- 静默重试不再走完整 home_reload:新增 home_reload_ex(silent) 不预读盘
  (旧实现每轮在 UI 线程同步读 playlists.bin)、home_apply_ex(keep_scroll)
  保留滚动位置、worker 记 g_home_changed 决定是否需要重绘——旧实现每轮
  home_apply() 复位滚动+整帧重绘,是「歌单列表滑动特别卡」的直接来源。
- worker 线程 pthread_detach(旧代码每轮泄漏线程栈)。
2026-11-05 13:00:24 +08:00
AskaEth aa225a1973 tower: snapshot of uncommitted base checkout changes (worktree wt-10) 2026-11-05 12:47:10 +08:00
AskaEth 61ef0f6de9 Merge branch 'feat/ncm-offline-session-keep' 2026-11-05 12:01:33 +08:00
AskaEth 31879f3302 fix(ncm): 缓存命中改单请求出参,移除全局命中计数(评审 P2-2/P2-1)
问题(P2-2):离线判定用 `ncm_apicache_hits()` 前后差值判「本次是否缓存命中」——
①其它 worker 的命中会污染差值 → 误判「已回在线」;②盘缓存分支的 g_hits++ 在 g_mu 锁外,
跨线程自增/64 位读存在竞争与撕裂可能。

修法(出参方案):
- `api_post_cached(..., int *from_cache)`:命中置 1(未走网络),miss/失败为 0;
  四个调用点显式传参。
- 新增 `ncm_api_user_playlist_ex(..., int *out_from_cache)`;out_n 与原接口一致**必填**
  (NULL→NCM_ERR_ARG,修入口容忍 NULL、成功路径却无条件解引用的契约不一致,评审 P2-1);
  out_from_cache 可 NULL。旧 `ncm_api_user_playlist` 为薄包装(调用方零改动)。
- `appstate_load_playlists_ex` 透传该标志;home_worker 用本次请求的 from_cache 判定是否
  回在线;歌单页只在失败时同步离线态(成功可能是缓存命中,不据此清离线)。
- 删除 `ncm_apicache_hits()`/g_hits(无使用者,同时消除数据竞争);盘缓存命中分支两行
  合并为一行(行为不变,重构)。
- 测试:session_gate_sim 新增 D2 五例(miss→0 且真网络 +1 次 / 命中→1 且 0 请求 /
  失败→0 / 兼容入口不崩 / out_n 必填契约);playlist_race_sim、plpage_sim 补 `_ex` 桩。

验证:session_gate_sim 88 PASS/0 FAIL;test_ncm_core_host 139 PASS/0 FAIL;
http_truncate_sim 13 PASS/0 FAIL;核心 13 TU(-Wall -Wextra)0 warning。
2026-11-05 11:59:33 +08:00
AskaEth b1d607d43f fix(ncm): http_ret_norm 保留 NCM_ERR_JSON(评审 P2-1:畸形分块不再误报断网)
问题:http_run 在 body_feed 失败时返回 NCM_ERR_JSON(畸形 chunk 尺寸/帧体),
但 http_ret_norm 的白名单漏了它,落到 default 被改判 NCM_ERR_NET → 上层按网络类
进离线模式,明明是数据/协议错误却误报「网络不可用」,也会误导离线恢复重试。

修法:白名单补 NCM_ERR_JSON(已属 NCM 错误码的一律原样返回),并把「不得改判」
写进函数注释。测试:http_truncate_sim 新增畸形分块用例(chunk 尺寸行 "ZZ"),
期望 NCM_ERR_JSON;修前该用例会得到 -4。

验证:http_truncate_sim 13 PASS(原 8 截断 + 4 传输归一 + 1 本用例)。
2026-11-05 11:54:23 +08:00
AskaEth 2ca386973b test(ncm): M9 分类/登录门宿主单测(83 断言)+ 传输层归一 4 例 + REPORT §41
- bench/session_gate_sim.c(新):真实 core + ncm_http_request 桩,覆盖
  A 错误分类矩阵(-4/-5/-6/-17/裸 mbedtls vs -13/其它)、B HTTP 状态矩阵
  (401/403 vs 5xx/429 vs 3xx/4xx)、C 登录门 + 启动决策(无网→离线、无会话→扫码)、
  D 真实 ncm_api 状态/业务码映射(含 -460→SVC、301/302→AUTH)、
  E 真实 ncm_login_status_check/refresh 映射、F 会话密钥稳定性(换 MAC 仍可解、
  旧 MAC 密文可解且自动迁移)。83 PASS / 0 FAIL,0 真实请求。
- bench/http_truncate_sim.c:追加 4 例传输层归一(握手 fatal alert→NET、握手超时→TIMEOUT、
  读 fatal alert→NET、CA 失败→TLS),原 8 例截断守卫零回归(12 PASS)。
- build_ncm_sims.sh:window/search/plpage/apicache/cookie_refresh 链入 ncm_session.o
  (appstate/api/login 新依赖),新增 session_gate_sim 构建与运行。
- test_ncm_core.c:采用 M8 分支的 $TMPDIR 适配(Termux /tmp 不可写;合并时以 M8 为准)。
- 相关 sim 头注释同步链接命令(ncm_apicache.o/ncm_session.o)。
- REPORT §41:判定点调用链与行号、根因证据、实现、验证矩阵与诚实出入
  (mipsel/真机未跑;app.c 宿主不可编译的既有原因;403/-460 取舍)。

验证:test_ncm_core_host 139 PASS/0 FAIL;session_gate_sim 83 PASS;
http_truncate_sim 12 PASS;window/search/apicache/cookie_refresh sim 通过;
核心 12 TU + 应用 13 TU(-Wall -Wextra)0 warning。
2026-11-05 11:45:44 +08:00
AskaEth e0bd503261 fix(ncm): 会话密钥改用设备私有 .mkey,网卡/MAC 变化不再丢登录态(M9 启动无网根因)
根因(代码级):会话 cookie 加密密钥 = sha256(首选网卡 MAC + 盐),MAC 取自
/sys/class/net 首个非 lo 网卡。设备断网时 Wi-Fi 网卡可能从 /sys/class/net 消失,
或 readdir 顺序变化选到另一块网卡 → 派生密钥改变 → session.enc 解不开 →
ncm_ctx_load_cookie 失败 → cookie 为空 → g_st.logged_in=0 → 启动直接进扫码页。
这与「把网络不可达当登录过期」是同一症状的另一条路径,且发生在登录门之前。

改动:
- 新密钥优先 <data_dir>/.mkey(32B 随机,首次生成后稳定,与网络状态无关);
  .mkey 不可写(只读 FS/无权限)才回退旧 MAC 派生,保持极端环境可启动。
- 旧版 MAC 派生密文仍可解(cookie_decrypt_sel 二次尝试);load_session 命中旧密钥时
  自动重写为 .mkey 密钥(透明迁移,不改调用方)。
- ncm_store.h 同步说明:machine_key 仅作旧密文兼容,新写入不再使用。
2026-11-05 11:45:20 +08:00
AskaEth 707bbd8a02 feat(ncm): 断网保持登录态 + 离线模式(可见提示/缓存可播/联网自动回在线)(M9)
用户报告:「一断网就退出到扫码登录界面,不会保持账号态切离线,缓存列表形同虚设」。
上一条已把失败分成 NETWORK/AUTH;本条把结论落到 App 状态与 UI。

- 会话状态机(appstate.c):只有 NCM_ERR_AUTH(401/403/301/302)清 cookie;网络类失败
  (不可达/超时/TLS/5xx/-460)只置 g_st.offline,账号态与 session.enc 一律保留;
  appstate_ensure_uid 同语义(原先任何 AUTH 都清 cookie,网络故障不得进入该分支)。
- 启动(app.c):根页改由 ncm_session_boot_decide 决定——有本地会话就进 home(无网也是),
  只有无本地会话才进扫码;打印 boot 决策日志。
- 离线 UX:首页个人卡「离线模式」红标 + 页脚提示 + 空态「离线模式/可播放已缓存」;
  歌单页/搜索页/播放页/缓存页分别给出离线文案(缓存页点播照旧走 NCM_QUEUE_CACHE,本地可播)。
- 恢复:home 可见时每 30s 静默重试一次(强制绕过接口缓存,成功即回在线并重绘);
  任何真实网络成功(home/playlist worker)也会清离线态。
- 防误判:接口缓存命中(120s 旧响应)不算「回在线」——home_worker 用
  ncm_apicache_hits() 差分识别,避免离线提示被缓存命中闪掉。
2026-11-05 11:45:20 +08:00
AskaEth 8557da6fca feat(ncm): 失败分类 + 登录门决策核心(M9:断网不再被当会话过期)
根因:传输层/服务端暂不可用与鉴权失败共用「负错误码」一个通道,登录门只能按
NCM_ERR_AUTH 判断,任何上游映射偏差都会变成「退出登录」:
- ncm_login.c 把任何业务码 !=200(含 -460 风控)都当 AUTH;ncm_api.c 把 -460 也映射 AUTH;
- api/login 层完全不看 HTTP 状态:401/403 与 5xx/网关 HTML 都落进 JSON/缺字段分支,
  反过来 5xx 与网络故障无法区分;
- http 层握手/写入失败把 mbedtls 裸负码(如 -0x7280)直接返回,上层按 NCM_ERR_* 比不中。

改动:
- core/ncm_session.{h,c}(新):ncm_err_classify / ncm_err_is_network /
  ncm_http_status_class|err / ncm_gate_decide / ncm_session_boot_decide。
  语义:NETWORK={NET,TLS,TIMEOUT,SVC,历史裸码};AUTH 仅 NCM_ERR_AUTH;
  401/403→AUTH,408/425/429/5xx→SVC,其它非 2xx→HTTP;启动时有本地会话就先信它
  (无网→离线进 home,绝不进扫码)。
- NCM_ERR_SVC=-17(服务端 5xx/429、业务码 -460:暂不可用、可重试,不是登录失效)。
- ncm_http.c:http_ret_norm 在 done: 统一归一(超时/证书/其它→NET),裸 mbedtls 码不再穿透。
- ncm_api.c:api_status_gate 先按 HTTP 状态定性再解析 body;业务码加 302→AUTH、-460→SVC。
- ncm_login.c:status_check/refresh 先过状态门;301/302→AUTH、-460→SVC、其它非 200→HTTP
  (不再因一次未知响应把用户踢回扫码)。
- ncm_apicache:新增命中计数,供 App 区分「真网络成功」与「离线读到旧缓存」。
- build_core.sh / build_ncm.sh:核心源列表加 ncm_session.c。
2026-11-05 11:45:20 +08:00
AskaEth 03868d048a Merge branch 'feat/ncm-crossbuild-hotupdate' 2026-11-05 11:36:56 +08:00
AskaEth 7d4906828d build(ncm): 宿主交叉适配层 + 全量测试补跑(M8)
- 新增 build_ncm_cross_host.sh:同构 HOME 复用容器脚本,串起 sdk/vendor/host-mbedtls/core/tests/ncm
- libs_common.sh 工具链路径改为 MEOW_R3PROII_ROOT/$HOME 可覆盖,容器默认不变
- build_mbedtls.sh 递归清理 library/ 与 3rdparty/ 旧对象,修宿主/mipsel 混架构归档
- test_ncm_core.c 改用 $TMPDIR,Termux /tmp 不可写不再导致 store/cache/tag 用例失败
- REPORT §40:构建/测试/热更结果(ncm md5 1bbd2a55…,Music du 前后恒定)
2026-11-05 11:34:45 +08:00
AskaEth 2c69070cc4 Merge branch 'feat/ncm-cache-filename-actual-level' 2026-11-04 21:11:04 +08:00
AskaEth e60642217a test(ncm): M6 缓存命名宿主单测(63 PASS)+ core 全量用例 + REPORT §39
- 新增 test/test_ncm_cache.c + build_ncm_cache_host.sh(纯 libc:真实 ncm_cache.c +
  ncm_tag.c,仅桩 strlcpy/mkdir_p/档位映射,不依赖 vendor/mbedtls)
- 覆盖:实际档映射、降级后写入名、hires 命中已存 exhigh/lossless、低档不命中高档、
  旧命名命中/扫描/删除/双点名迁移、列表去重、LRU/清空/删除兼容、level_from_path 边界
- test_ncm_core.c 新增 test_cache_actual_naming(接入 core 全量测试集,同语义断言)
- REPORT §39:命名/命中/兼容方案、质量取舍与未执行项(vendor/交叉/真机)
2026-11-04 21:11:00 +08:00
AskaEth 68833cf642 feat(ncm): 下载落盘名用响应实际档 + 命中按文件名标实际档(M6 播放链路)
- song_download_attempt:URL/类型确定后算实际档,.part/open/commit 统一用实际档键
  (hires 请求被降级为 320k mp3 → song_<id>_exhigh.mp3),eff_level=实际档
- song_local_file:命中路径由 ncm_cache_level_from_path 取文件名档位并按容器钳顶
  (mp3 永不高于极高),旧 hires.mp3 正确显示「极高」
- 移除旧「stale-degraded 改名+强制重下」与「无档位键迁移」:老卡文件读可用、不迁移
- player_flow_sim 适配:补实际档/路径档位桩与 M5 链接桩;P7/P12 期望键改实际档,
  P13 改为旧命名命中即播、不重下不迁移
2026-11-04 21:11:00 +08:00
AskaEth 30ffe469c8 feat(ncm): 缓存实际档判定/文件名解析 + 查找档位下探(M6 core)
- ncm_cache_actual_level:请求档 + 容器/码率/位深 + 响应生效档 → 实际档(只降不升),
  作为写盘名/播放显示/已缓存列表的唯一事实源
- ncm_cache_level_from_path:文件名档位段解析(song_<id>_<档位>,含 sky_c51)
- ncm_cache_lookup:请求档精确 miss 后按实际档逐档下探(hires 命中已存 exhigh/lossless),
  只降不升,低档请求不命中高档实体
- ncm_cache_scan:actual_level 改用统一判定;同曲同实际档的旧名+新名去重展示
2026-11-04 21:11:00 +08:00
AskaEth 132622cbc1 Merge branch 'feat/launcher-bg-kill-button-dead' 2026-11-04 21:00:45 +08:00
AskaEth 6b52740ff1 fix: 后台页杀按钮点不动 — app.json framework=1 被误当「不可杀」门控 + am kill 无前台会话不可执行
根因(2026-11-04 真机 dev.player.ncm pid=7520):
- bg_page.c bgm_app_meta 把 app.json 的 framework 字段映射进 bgmgr_row.framework,而
  bgmgr_can_kill 把它当「系统/框架 App 禁止 kill」。但 framework=1 在 MeowOS 里意为「用
  meow_ui.a 的 SDK 框架 App」(docs/APP_DEV_GUIDE.md §9,推荐值,真机 4 个 App 全写 1)
  → 后台页每行「杀」按钮被置灰,bgm_row_tap 命中后直接 return 1、不调 meow_bg_terminate;
  「一键清除后台」同理 0 行可杀。整条链路无日志,故只能靠翻 app.json 才定位(全部设备日志里
  "后台管理页终止后台App" 出现 0 次 = 4 次进页从未真正杀过)。
- 面板 killfg 正常是因为走 am kill → amsd → MSG_AMS_KILL,无 framework 门控;但它其实靠
  amsd 1200ms×2 超时兜底 kill(pid,SIGTERM) 才杀成:util.c 原本在无前台 App 会话时只回
  ACK -2((uint32_t)MEOW_ERR_NOENT),ACK 及时到达时 amsd 判「launcher refused」完全不杀
  (ams_20260915_031339.log 实证),ACK 迟到才侥幸成功(2026-11-04 真机)。

修复:
- meow_bgmgr: 门控位 framework → protected_app(系统级受保护标记;当前无 App 置位,launcher
  自身组件本就不在 App 表/后台表内)。bg_page 不再把 a->framework 送进门控。
- bg_page: 行副文本改存 ctx 持久缓冲(原 rbuf[].sub 指向 bgm_rows 栈帧 → 悬垂,副标题乱码);
  门控拒绝/未命中后台表各留一行 logmsg,「点了没反应」从此可查。
- util.c MSG_AMS_KILL: 先查后台保活表 → meow_bg_terminate 就地走真退序列(与页面「杀」按钮
  同一通道,故 am kill 与页面按钮行为对称),无前台会话也能杀;否则仍走 pending / ACK -2。
- amsd cmd_kill/cmd_start: ipc 失败原因细分(connect failed / timeout, no reply / ...),日志
  不再笼统写 "launcher offline"(响应串保留该字样,MeowPanel api_ams_ctl 依此映射 503)。
- meow_bgplay: meow_bg_terminate 注释/日志写明「后台管理页 / am kill」双入口同通道。

测试(宿主 gcc,产物在 $TMPDIR):
- test_bgpage 40 checks:新增 framework=1 仍判可杀且按钮真 terminate(本 bug 回归)、
  受保护行按钮不杀但留诊断日志、sub 持久化 + 栈抖动不变(悬垂回归)。
- test_bgmgr 30 checks:protected_app 门控矩阵 + framework=1 可杀回归。
- test_bgplay 92 PASS:新增 4b meow_bg_terminate 全链路(受理→term_at_ms→tick SIGTERM→
  on_bg_dead→清槽;空/不在表分别 INVAL/NOENT);TEST_SVC_PATH 改取 $TMPDIR
  (Termux 无 /tmp,原 fputs(NULL) 直接 abort)。
- amsd 宿主集成(fake launcher 桩,4 例):ACK 0→terminate 且不盲杀;ACK -2→refused 不盲杀;
  无 launcher→direct SIGTERM (connect failed);挂起不回→direct SIGTERM (timeout, no reply)。
0warning:util.c 与 main 同告警逐行一致(仅 SDK 头既有 typedef 重定义),其余文件干净。
2026-11-04 21:00:40 +08:00
AskaEth a73bcb9841 Merge branch 'feat/ncm-cached-list-and-tag-embed' 2026-11-04 20:49:36 +08:00
AskaEth a9c94c7221 fix(ncm): tag 嵌入按实际容器拒绝 MP4/M4A(ID3v2 前缀会破坏 MP4 容器)
download 扩展名在 type 非 flac 时一律落 mp3;若服务端降级返回 MP4/M4A("....ftyp"),
给文件加 ID3v2 头会破坏容器。ncm_tag_embed_file 现读 12 字节 magic:fLaC → VORBIS_COMMENT,
"ftyp" → NCM_ERR_ARG 拒绝(调用方 commit 裸流),其余按 MP3 处理。
test_ncm_tag 增 2 条断言(拒绝 + 原文件不变)→ 53 PASS / 0 FAIL,0 warning。
2026-11-04 20:49:25 +08:00
AskaEth 39465ea1ef feat(ncm): 新写入的缓存文件嵌入歌曲元数据(FLAC/MP3,commit 前流式重写,失败降级裸流)
用户需求:保留元数据分离的同时把歌曲元数据嵌进缓存文件(否则外部播放器读不到任何信息)。
- 接入点 player_manager:下载完整(dl_rc==OK)→ stream_src_finish(读端先自然收尾)→
  ncm_tag_embed_file(.tmp, 实际扩展名, {name/art_name/album_name}) → dispose join 之后才
  commit。tag 重写是同目录 .tag.tmp + rename 换 inode,解码端全程持旧 fd,播放/seek/
  .part 续传字节语义零改动。
- 容器按实际 magic 分支(ncm_tag.c);峰值内存 64KB;失败只记 WARN、commit 裸流,
  播放主链路不受影响(用户红线)。
- 元数据分离体系不动:<cache_dir>/meta/<id>.bin 旁路照旧写读;LRU 只 stat 文件,
  无 size/etag 索引需回填(重写后 stat 自然一致)。
- test_ncm_core 新增 test_cache_tag:writer 写裸流 → 嵌入 ID3v2.3 / VORBIS_COMMENT →
  读回 → ncm_cache_scan(hires 名 + 320k mp3 体 → 实际档 EXHIGH,名称来自文件内 tag)
  → ncm_cache_remove → ncm_cache_clear。
- REPORT §38:设计/内存估算/验证/诚实遗留(本机缺 vendor 与交叉工具链,未跑整链与真机)。
2026-11-04 20:49:25 +08:00
AskaEth f392ee5c48 feat(ncm): 首页顶栏「已缓存」入口 + 已缓存列表页(歌名/歌手/实际档位/大小,点播/单曲删除/清空)
入口:首页 topbar 新增动作 3(MEOW_ICON_DOWNLOAD 位图,原「回播放页」顺延动作 4)→
push ACT_CACHE;左缘返回弹回首页(app.c 路由补齐 tap/swipe/touch/tick)。

列表页 app/ui/cache.c(对齐 queue.c 惯例:圆角卡/红 mini-vinyl/按压红条/当前播放行高亮):
- 行 = 歌名 / 歌手 · 实际档位 · 大小;空态/页脚(N 首 · 合计 X MB)。
- 点行 = 点播(NCM_QUEUE_CACHE 队列,上/下一首在缓存列表内循环);行右「删除」=单曲删除;
  顶栏「清空」=确认框 + ncm_cache_clear(与设置页「清理缓存」同语义)。
- 数据全在 worker:cache_reload → appstate_cache_list_load → ncm_cache_scan(读目录 +
  文件内 tag + 音频头探测)→ 快照;UI 线程零磁盘 IO。快照上限 512 条(最近访问优先,
  ≈116KB bss),更多条目按 mtime 保留最近的。

core:
- ncm_cache_scan / ncm_cache_remove(songs/ 池,song_<id>_<请求档>.<实际格式> 解析,
  跳过 .tmp/.part 与非数字 id;按 mtime 降序;历史双点名删除兜底)。
- ncm_api_level_from_key(缓存键后缀 ↔ 档位枚举,与 ncm_api_level_key 互逆)。
- **档位显示实际档而非请求档**:真机存在 song_171650_hires.mp3(无损请求被降级链落到
  exhigh mp3),故 scan 用 ncm_audio_probe_file 读实际容器参数后只降不升地推断
  (FLAC 16bit→无损 / 24bit→Hi-Res+,MP3 ≤128k→标准 / >128k→极高)。

播放:player_manager 对 NCM_QUEUE_CACHE 用条目真实键(id+请求档+实际扩展名)直读缓存
文件,不按用户当前档键重下;song_local_file/play_is_same_song/queue 页均支持第三种队列。
宿主 mock(NCM_PAGE=cache)造 3 条缓存 + 嵌入 tag + 同步扫描。
2026-11-04 20:49:25 +08:00
AskaEth 184ebd0ac9 feat(ncm): 缓存文件内嵌元数据模块 ncm_tag(FLAC VORBIS_COMMENT / MP3 ID3v2.3 写读 + 音频头探测)
core/ncm_tag.c/.h(纯 libc,宿主可单测,不依赖 mbedtls/jsmn):
- FLAC:在元数据末块之后插入 VORBIS_COMMENT(TITLE/ARTIST/ALBUM),旧 VORBIS_COMMENT
  替换、PADDING/PICTURE 原样保留;MP3:ID3v2.3 TIT2/TPE1/TALB(UTF-16LE+BOM,中英文
  与 emoji 均可),源已带 ID3v2 时先剥旧标签(不留双标签)。
- **按实际容器分支**(读 fLaC 魔数):缓存名档位段是请求档,真机存在
  song_<id>_hires.mp3(无损请求被降级链落到 exhigh mp3),不能按名字判容器。
- 写入 = 同目录 <path>.tag.tmp + 64KB 分块搬运 + fsync + rename 原子替换;任一步失败
  即 unlink 临时件、原文件保持不变(调用方 commit 裸流降级),峰值内存 64KB。
- ncm_audio_probe_file:只读文件头探测实际容器/位深/码率(FLAC STREAMINFO / MP3 首帧),
  「已缓存列表」据此显示实际档位而非请求档。
- test/test_ncm_tag.c + build_ncm_tag_host.sh:FLAC/MP3 构建+读回、中文 roundtrip、
  旧标签替换、容器判定、3MB 分块、失败降级、探测 —— 51 断言全过,0 warning。
2026-11-04 20:49:25 +08:00
AskaEth e56c837f4f Merge branch 'feat/sdk-cross-build-bring-up' 2026-11-04 20:26:22 +08:00
AskaEth 8be4634a61 feat(sdk): 本机 zig 交叉链跑通 build_sdk.sh — 新增 build_sdk_host.sh 环境适配+成员/架构自检
主仓 patch_v2/sdk/build_sdk.sh 此前只在 SDK 仓验证过:硬编码 ROOT="$HOME/Project/R3ProII"
取 .tools/zig-mipsel-cc 与 patch_v2/meow_launcherd/meow_font.a,默认 AR=/usr/bin/ar、
mktemp 落 /tmp。Termux 上三者都不成立(工具链在 ~/.tools、meow_font.a 在仓库内且 *.a 被
gitignore、ar 在 $PREFIX/bin、/tmp 不可写),脚本无法直接跑通。

- build_sdk_host.sh: 不改 build_sdk.sh,临时布局同构 $HOME(.tools 与 patch_v2/
  meow_launcherd 符号链接到真实位置),导出 AR/TMPDIR 后原样执行脚本;--verify 追加
  「成员清单 = 本次 *.o(OBJS + meow_font.o)+ libfreetype.a 对象」比对与全量架构校验
  (不复制 OBJS 列表,避免与 build_sdk.sh 漂移)
- FreeType 2.14.3 源: SDK_FREETYPE_SRC 指向本地源优先;未设且无 vendor 时仍走 savannah 下载
- 验证: 冷构建(zig cache 空) 55s,重编 11-14s;meow_ui.a 5,701,866B / 75 成员
  (OBJS 31 + meow_font.o + freetype 43),全 ELF32 MIPS32 rel2 o32;hello_app 按文档链路
  meow_ui.a + libz.a + -lpthread 链接成功(474,612B 静态 MIPS32r2),不调 meow_ft_* 时
  freetype 成员未被拉入(零增量成立)
- 产物不入库: vendor/、*.a、*.o 均被 .gitignore 覆盖,git status 仅本脚本
2026-11-04 20:25:54 +08:00
AskaEth 47c638d65b Merge branch 'feat/m6445-web-license-residual-sync' 2026-11-04 20:14:16 +08:00
AskaEth 20f4727714 feat: M6.44+M6.45 现场补全 — 补齐按扩展名过滤遗漏的 web 前端与 license 合规件
真机 0.8.45 现场组成补全(初次提现场按扩展名过滤漏掉 7 个文本文件),
内容逐字节取自容器现场 /mnt/mb/home/Aska/Project/R3ProII 并 md5 对拍一致:

- patch_v2/meowpanel/web/meowpanel.css          (+17)  趋势/App 详情卡样式
- patch_v2/meowpanel/web/mp_pages.js            (+289) 性能趋势/功耗曲线/App 详情/蓝牙详情
- patch_v2/meowpanel/web/mp_shared.js           (+49)  趋势 sparkline 通用逻辑
- patch_v2/reader_app/.gitignore                (+2)   seg/chapter smoke host
- patch_v2/reader_app/NOTICE                    (+23)  FreeType 2.14.3 + zlib 条目
- patch_v2/reader_app/resources/licenses/NOTICE (+23)  同 reader_app/NOTICE(md5 一致)
- patch_v2/reader_app/resources/licenses/FTL.txt (新增 6743B) FreeType 许可证全文

面板前端对应已收编的后端 mp_files.c/mp_net.c(文件搜索+蓝牙详情),
NOTICE/FTL.txt 为 LGPL/FreeType 合规产物。仅投运现场内容,未改内容。
2026-11-04 20:10:42 +08:00
AskaEth 2ffb92c038 Merge branch 'feat/m6445-dirty-snapshot-consolidation' 2026-11-04 20:09:20 +08:00
AskaEth 7c7d891570 feat: M6.44+M6.45 收编 — 真机 0.8.45 现场保全(reader章节/key_router按键路由/WiFi连接修复/ncm bench+IME修复)
脏工作区现场收编:devdocs/local/m6_44_45_dirty_tracked.patch(46 个已跟踪文本文件,
+3090/-585, 已对 M6.43 基线干净适配) + m6_44_45_untracked_src.tar.gz 中 patch_v2/ 下
13 个新增源文件, 合成单个提交, 内容与容器工作树(R3ProII/patch_v2)逐字节一致。

- reader 章节: 新增 core/rdchapter.{h,c} — TXT 章节规范解析器, 扫描 512KB 码位对齐段文件
  逐段流式识别标题行(行首缩进≤4/显示宽≤40/行内标点≤1 三重约束防误判), 输出标题+全书字符
  偏移+段号/段内偏移+阿拉伯章号(中文数字归一), 首章前内容归"卷首"; book/reader/rdenc/
  rdlayout/settings 接入章节跳转与进度锚点, 附 chapter_smoke/turn_repro/build_turn_repro.sh
- key_router 按键路由: 新增 meow_launcherd/key_router.{h,c} — launcher-UI 页物理键统一路由
  (P1 仅音量键), 把读取边沿+防抖+长按 held+ramp 推进从 5 个会话收敛为进程内单开;
  各页每圈 key_router_pump() 取防抖后边沿, 动作(调音量/光标导航)仍由调用页决定,
  保留 run_menu 的 meow_mode 分支; App 前台 volume_page 武装时边沿抑制;
  main_menu/now_playing/notif_center/settings_page/subpage 接入, 附 test_key_router_vol/test_np_keys
- WiFi 连接修复: meow_netmgrd connect 有界等待 wpa_state=COMPLETED(普通 10s/隐藏网络 15s,
  env 可调)修设置页连接无响应; list_networks/scan_results 的 \xNN 转义还原始字节, 修中文 SSID
  永不命中致新建同名 open 记录顶影; launcher netmgr_ipc 配读超时; 附 test_wifi_connect(桩
  wpa_cli 驱动 static wifi_connect_ex), test_netmgrd.sh 补用例; wifi_page 同步
- ncm bench: 新增 bench/lyric_sim.c、bench/plpage_sim.c, vol_sync_sim 与 ncm core/app 同步
- SDK IME 修复: shift 大小写、K_ENTER 只提交不关、阻塞 IME 循环喂 on_tick 不被遮挡、
  IME 打开 ~200ms TAP 冷却窗吞 stray-TAP; 附 sdk/test_ime_fix.c
- 同期: meow_audiod 音量异步 ACK 线程(代际防 fd/槽复用竞态)、watchdog 音量收编(首选 audiod
  权威, settings.conf 覆盖路径)、panel 文件搜索(F1-④)与蓝牙只读详情(F1-③)、各类 build 脚本同步

范围红线: third_party/legado-E-main 原开发者未跟踪, 本次不入库(保留 devdocs/local tar 参考);
二进制不入库(后续重编); devdocs/ 不入库。59 文件全部在 patch_v2/ 下, 无 third_party/devdocs。
验证: git apply --check 干净; 59 文件 md5 与容器 patch_v2 逐一比对 59/59 一致(含
key_router.c/rdchapter.c/meow_ui_ime.c/test_wifi_connect.c 抽查); 可执行位保守
(check_lgpl_release.sh 100755, build_turn_repro.sh 100644 与容器一致)。
2026-11-04 20:02:28 +08:00
AskaEth e87ccdf061 Merge branch 'feat/m643-reader-streaming-panel-sync' 2026-11-04 20:00:59 +08:00
AskaEth f40abbd494 feat: M6.43 — reader分段流式读取(Phase1,只txt) + 面板前台管理/返回回桌面按钮
- reader分段(只txt): core/seg.{h,c} 512KB码位对齐切段(尾字完整)+manifest+按需读单段; book.c rd_book_open只布局每页所在段(治CPU100%/返回无效) load_seg只读当前段(非txt原路径); reader.c 进度实时保存(翻页/刮擦/字号/编码dirty+on_tick节流落盘+退出即时写,锚点存段号,崩溃不丢进度); rdtxt.c fread替代fgetc(去开书扫描卡顿); Phase2(双段拼接+4%释放)留待
- 面板: /api/ams/bg加fg/fg_count(后台保持向后兼容)+POST /api/ams/:pkg/killfg(前台救命:优先am kill,launcher拒则pid直信号SIGTERM->SIGKILL绕过门控)+前端/#/bg前台组'终止'绿徽标+确认弹窗(一键清仍只后台不误杀前台); POST /api/input/gesture(返回=左缘右滑/回桌面=底部上滑,注入滑动轨迹复用SDK手势)+前端屏幕控制页加返回/回桌面MD3按钮

0warning;宿主test_reader_core 72-0/seg_smoke 19-0/loaders_smoke/book_smoke/formats_smoke PASS;面板selftest/node check PASS
2026-11-04 19:47:10 +08:00
AskaEth a64ae06eb6 fix(reader): txt编码误判(UTF-8前窗截断判GB18030致乱码) + build_index无持久化(整本重排慢/UI阻塞卡) — ①rd_utf8_sample_ok结尾不完整序列按合法(中间非法仍拒,GBK不误判,新增t13回归) ②索引落盘MeowData/os.meow.reader/index/*.idx(失效键size/mtime/font/fs/ls/wh/enc,命中秒开,原子写+强自检弃坏缓存,编码/字号/行距重排重写) 重开<0.3s/首开建cache; test_reader_core 72-0, 0warning 2026-11-04 19:47:10 +08:00
AskaEth 68710d7ed8 fix(reader): 书索引build_index全量逐字FreeType排版优化 — rdft_advance加(px,cp)->advance缓存(开放寻址4096槽,与glyph缓存分离,load_font/set_px同清), 测量从O(全书字符)降到O(唯一码点~2600); 大书(10MB)打开 rd_book_open 23.9s->140ms(~170x), npages一致布局确定保留; 点条目不再用户态busy循环冻死(非驱动/锁屏, 是UI线程同步全量建索引); 0warning, 宿主test_reader_core 68-0/loaders_smoke/book_smoke/formats_smoke PASS 2026-09-04 21:01:56 +08:00
AskaEth 5e67100231 fix(reader): 书架scan_books改为递归扫子目录(Books/<subdir>/,深度≤2), 用户按目录组织书也能扫到 — 修复'书架为空'(书在Books/kh/子目录); reader热更 eacd0e6c 已基于此; 0warning 2026-09-04 20:48:55 +08:00
AskaEth 06f872faf2 fix(fw): M6.42 收编audiod开机路由武装瘦身(防楔死) — 推卡
- audiod 重编 d833abce(0warning, glibc-dynamic≤2.17): 去无条件apply_route(5)→target双写,当前端口正确则跳过强烧,仅未武装/端口不对/读失败才写目标;保留M2.91防挂死武装(numid=9语义/popen有界超时/D态抛弃/io_thread未动)
- SDK未变(meow_ui.a 207630cb沿用); launcher 9013f843未变; netmgrd 9c345b2e/meow_audio 6864ec38/amsd 57e19cde 未变
- 固件 r3proii_m6_42.upt 6d7c1168(0.8.42, rootfs 38719488<=45088768, unpack 0 diff); 已上卡+备份r3proii_prev_m6_42=2a966f2b+rm recovery_all+升级重启; 设备已回网0.8.42
- reader eacd0e6c/files 01ad3f01 卡上已热更未动(不在固件)
- 注: audiod瘦身是驱动楔死缓解,需用户后续多boot复测(不在本收编范围)
2026-09-04 20:48:02 +08:00
AskaEth 68f91f7de2 fix(audio): M6.42 — audiod开机路由武装瘦身(去无条件apply_route(5)→target双写, 当前端口正确则跳过强烧, 仅未武装/端口不对/读失败才写目标; 保留M2.91核心防挂死武装, numid=9语义/popen有界超时/D态抛弃/io_thread未动) — 降低stock hbc3000驱动楔死整机硬冻结触发面(reader后台+Files切换撞楔死驱动口); 0warning, 宿主test_audiod_host 100-0; 需真机多boot复测 2026-09-04 20:43:22 +08:00
AskaEth 4ce7a268a8 fix(fw): M6.41 收编启动失败原因透传+后台管理按钮点不动修复+主菜单微调 — 推卡(固件已上卡,设备待回网补reader热更+校准)
- launcher 9013f843(启动失败原因透传+后台管理按钮点不动修复+主菜单字体/样式微调) / reader 5a0c8776(左缘返回+view栈+字体瘦身+页位图释放)
- SDK未变(meow_ui.a 207630cb沿用),不重编meow_ui.a/不重链ncm/hello/files(files已热更01ad3f01不动)
- 固件 r3proii_m6_41.upt 2a966f2b(0.8.41, rootfs 38719488<=45088768, unpack 0 diff); 已上卡+备份r3proii_prev_m6_41=28191ae7+rm recovery_all+触发升级重启
- netmgrd 9c345b2e/audiod 9f47c8fd/meow_audio 6864ec38 未变; amsd 57e19cde 保持
- 注: 设备升级后未回网(WiFi已知问题,超~5分钟),reader热更+升级后验收+红线校准待设备回网后补
2026-09-04 19:50:55 +08:00
AskaEth ddc8d35eb9 fix: M6.41批 — 启动失败原因透传 + 阅读器左缘返回/阅读页接入view栈 + 后台管理按钮点不动 + 阅读器瘦身(fonts common+页位图释放) + files txt换行
- launcher(453): app_launcher.c g_launch_fail_reason+launch_fail()统一出口(5失败点 fork OOM/exec/no such file/missing dep/execpipe)+main_menu.c launch_fail_toast(缺依赖/CANNOT START: name(reason))+toast256
- launcher(457): bg_page.c bgm_enumerate跳过term_at_ms(杀后当下消失)+不可杀行按钮置灰+row_tap/top_action返回1; subpage.c 消warning
- reader(454): reader_on_swipe LEFT/RIGHT→返回(先关overlay否则page_pop回书架)+app.c书架根LEFT→exit
- reader(458瘦身A+B): app.json fonts full→common(省13MB atlas)+reader.c RD_PAGE_CACHE 3→2+g_anim_old懒分配/anim_done释放+on_enter/on_leave页位图离开释放重建
- reader(459导航): reader_open返回int+shelf点书push ACT_READER(阅读页接入view栈,on_enter/on_leave驱动) — 使454左缘返回+458瘦身B真正生效
- files(455): viewer.c viewer_cp_len+按码位折行(txt中文不冲出右缘)

0warning;宿主测试全过;files二进制已热更
2026-09-04 19:43:28 +08:00
222 changed files with 22358 additions and 1220 deletions

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
#!/bin/sh
#
# S11jpeg_display_shell — 开机 logo 显示(stock 基线 + MeowOS M21 改动)
#
# stock 基线(md5 249220f2489f63a4c946c55d8d981ca9)的问题:
# `cmd_jpeg_display` 画完 logo **之后**才写死 60。画图那一刻用的是驱动默认亮度,而真机
# /module_driver/pwm_backlight.sh 是 `insmod pwm_backlight.ko ... default_brightness=0
# max_brightness=101`,pwm_backlight.ko 的 init 会把 props.brightness 置 0 再 update_status
# (走 PWM 0 分支)—— 即上电是**全黑**:logo 先画在灭屏上,0.1s 后才被写死的 60 照亮,
# 一出现就是 60(≈60% 亮度,用户报「冷启动开机 logo 挺亮」)。
# 本脚本又跑在 S21mount_ubifs 之前:那时 /usr/data 还没挂载,读不到
# /usr/data/meow/settings.conf,所以这里**只能**写一个和用户设置无关的安全低值。
#
# M21 改动:
# 1) 在画 logo **之前**写 BOOT_BL_MAX=20(≈max_brightness 101 的 1/5):logo 以「暗但可见」出现;
# 2) 删除画完后写死 60 的那行 —— 用户亮度改由 S22brightness(/usr/data 挂载后)就位;
# 3) 写入「只降不升」:当前值落在 **1..20**(有效且已更暗,例如 S22 已按用户设置写成 1)才不动;
# **0 / 空 / 非数字一律照写 20** —— 驱动默认就是 0(等于灭屏),把 0 当「已有值」跳过会让本
# 脚本一个值都写不进去(评审 P1:画图时刻亮度 0,且删掉 60 后整段 init 没有可见 logo)。
# 4) /dev/fb0 等待、mtd5 主题判定、后台 `jpeg_display &` 语义与 stock 一致。
#
# 时序(三层写入者 + 运行期):
# 驱动默认 0(灭)→ S11 写 20(画 logo 前,logo 暗而可见)→ S21mount_ubifs
# → S22brightness(用户值,如 1)→ S70/S90 launcher/watchdog 继续按 display.brightness 维持。
#
# 注:rcS 用 `$i start` fork 执行本脚本,`jpeg_display &` 后父进程立即返回。所以写入必须在
# jpeg_display() **函数体内**(子进程自己的时序),不能挪到 case 分支顶层 —— 那是父进程,
# 会和子进程抢时序。
# ⚠️ 也不能放在函数入口:背光模块由排在后面的 S11module_driver_default(rcS 自然序
# S11jpeg_display_shell < S11module_driver_default)才 insmod,入口那一刻
# /sys/class/backlight/backlight_pwm0/ 还不存在,写入必然 no-op(重定向失败还会漏一行错)。
# 能写的时机是 /dev/fb0 出现之后 —— 同一份驱动脚本里 pwm_backlight.ko 先于 soc_fb.ko 加载,
# fb0 一出现背光节点必定已就绪。
BL_BRIGHTNESS="/sys/class/backlight/backlight_pwm0/brightness"
# 画 logo 前的亮度上限(绝对档位 0..max_brightness,不是百分比)。
# 这是「暗但可见」的安全低值,不是用户值;用户值(display.brightness,可能更暗如 1)由 S22brightness 写入。
BOOT_BL_MAX=20
# 只降不升地把背光压到 <= $1:
# 当前读数在 1..$1 = 已有有效且更暗的值(如 S22 已写用户值 1)→ 不动;
# 0 / 空 / 非数字(驱动默认 0 = 灭屏、节点未就绪)→ 视为「没有有效值」,照写。
bl_cap()
{
cap="$1"
cur=$(cat "$BL_BRIGHTNESS" 2>/dev/null)
case "$cur" in
''|*[!0-9]*) ;; # 读不到 / 非数字 → 照写
*) [ "$cur" -ge 1 ] && [ "$cur" -le "$cap" ] && return 0 ;; # 已有 1..cap 的有效值 → 不动
esac
# 2>/dev/null 放在 > 之前:节点尚未就绪时连「重定向失败」的报错一起吞掉(否则每个冷启动漏一行)。
if echo "$cap" 2>/dev/null > "$BL_BRIGHTNESS"; then
echo "backlight: -> $cap (was ${cur:-unreadable})"
fi
return 0
}
jpeg_display()
{
echo "Starting display... "
while true
do
if [ -e "/dev/fb0" ]; then
break
fi
sleep 0.02
done
echo "Wait display... "
# M21:画 logo 前写一次安全低亮度。此时背光节点必定已就绪(同一驱动脚本里
# pwm_backlight.ko 先于 soc_fb.ko 加载,fb0 出现即背光已注册)。只降不升:
# cur 落在 1..20 的有效值就不动;cur=0(驱动默认 = 灭屏)照写 20;
# S22brightness 若已写更暗的用户值也不会被顶回 20。
bl_cap "$BOOT_BL_MAX"
theme=$(nanddump -q -s 0x20000 -l 7 /dev/mtd5 -a)
# stock 写的是 `==`(BusyBox test 接受,dash/POSIX test 不接受);语义不变,改为 `=` 以便移植与宿主验证。
if [ "$theme" = "theme:1" ]; then
cmd_jpeg_display /etc/logo1.jpeg
elif [ "$theme" = "theme:2" ]; then
cmd_jpeg_display /etc/logo2.jpeg
else
cmd_jpeg_display /etc/logo.jpeg
fi
# stock: "R1 lg 35583 need 100ms delay" —— 原先是给「画完后写 60」留的面板 settle 时间。
# 亮度写入已移到画图之前,但该硬件 settle 保留(在后台子进程里,不阻塞 init 链)。
sleep 0.1
echo "Finish display... "
}
case "$1" in
start)
jpeg_display &
;;
*)
exit 1
esac
exit 0
+124
View File
@@ -0,0 +1,124 @@
#!/bin/sh
#
# S22brightness — 开机按用户设置恢复背光(MeowOS M21)
#
# 位置/顺序(/etc/init.d 按文件名自然序执行,见 /etc/init.d/rcS):
# S11jpeg_display_shell 画开机 logo,画前把背光写成 20(驱动默认 0 = 灭屏 → logo 暗而可见)
# → S21mount_ubifs 挂载 /usr/data
# → S22brightness 本脚本:此时才读得到 /usr/data/meow/settings.conf
# → S30dbus … → S70meow_sysui / S90meow_bootmenu launcher/watchdog 接管后继续维持用户亮度
#
# 为什么不能只改 S11:S11 早于 S21,/usr/data 未挂载 → settings.conf 不存在,只能写死一个安全低值
# (且真机 S11 跑在背光模块加载之前,写入时机受 fb0 约束)。
#
# 行为:
# - 读 display.brightness,以 display.brightness_min(下限,缺省 1)和
# /sys/class/backlight/backlight_pwm0/max_brightness(上限,本机实测 101)clamp 后写入。
# - 单位是 0..max_brightness 的**绝对档位**,不是百分比;display.brightness_min 同单位。
# - 读不到文件 / 解析不出 display.brightness / max_brightness 读不到 → **不改背光**
# (S11 未必写过值,真机驱动默认 0 = 灭屏,所以这里只报当前读数、不做假设),
# 打一行日志后 exit 0。
# - 幂等(当前值已是目标值则不写)、无 sleep / 无重试,任何运行期失败都 exit 0(不阻断 init 链)。
BL_BRIGHTNESS="/sys/class/backlight/backlight_pwm0/brightness"
BL_MAX="/sys/class/backlight/backlight_pwm0/max_brightness"
MEOW_SETTINGS="/usr/data/meow/settings.conf"
log()
{
echo "S22brightness: $*"
}
# 当前背光读数(仅用于日志;读不到返回 unreadable)。
cur_bl()
{
v=$(cat "$BL_BRIGHTNESS" 2>/dev/null)
case "$v" in
''|*[!0-9]*) printf 'unreadable' ;;
*) printf '%s' "$v" ;;
esac
}
start()
{
# /usr/data 刚挂载时 settings.conf 可能尚不存在(新机/首次启动)或被正在写:
# 只读一次,读不到就不改背光,不 sleep 重试(不阻塞 init 链)。
if [ ! -r "$MEOW_SETTINGS" ]; then
log "settings.conf 不可读($MEOW_SETTINGS),不改背光(当前 brightness=$(cur_bl))"
return 0
fi
max=$(cat "$BL_MAX" 2>/dev/null)
case "$max" in
''|*[!0-9]*)
log "max_brightness 读不到,不改背光(当前 brightness=$(cur_bl))"
return 0
;;
esac
if [ "$max" -le 0 ]; then
log "max_brightness=$max 非法,不改背光(当前 brightness=$(cur_bl))"
return 0
fi
# 解析 settings.conf:<key> = <value>,容忍两侧空白/CR,忽略注释与非数字值。
b=""
mn=""
while IFS='=' read -r k v; do
k=$(printf '%s' "$k" | tr -d '[:space:]')
v=$(printf '%s' "$v" | tr -d '[:space:]')
case "$k" in
display.brightness)
case "$v" in
''|*[!0-9]*) ;;
*) b="$v" ;;
esac
;;
display.brightness_min)
case "$v" in
''|*[!0-9]*) ;;
*) mn="$v" ;;
esac
;;
esac
done < "$MEOW_SETTINGS"
if [ -z "$b" ]; then
log "settings.conf 无有效 display.brightness,不改背光(当前 brightness=$(cur_bl))"
return 0
fi
[ -n "$mn" ] || mn=1
[ "$mn" -lt 1 ] && mn=1
[ "$mn" -gt "$max" ] && mn="$max"
[ "$b" -lt "$mn" ] && b="$mn"
[ "$b" -gt "$max" ] && b="$max"
cur=$(cat "$BL_BRIGHTNESS" 2>/dev/null)
if [ "$cur" = "$b" ]; then
log "背光已是 $b,跳过写入"
return 0
fi
# 2>/dev/null 放在 > 之前,避免节点不可写时重定向报错漏到控制台。
if echo "$b" 2>/dev/null > "$BL_BRIGHTNESS"; then
log "开机背光 -> $b(min=$mn max=$max)"
else
log "写背光 $b 失败,保持当前值(brightness=$(cur_bl))"
fi
return 0
}
case "$1" in
start)
start
;;
stop|restart|reload)
;;
*)
echo "Usage: $0 {start|stop}"
exit 1
;;
esac
exit 0
+15 -2
View File
@@ -22,7 +22,8 @@ patch_v2/files_app/
│ ├── fsx.h # 纯业务接口(不依赖 meow_ui)
│ └── fsx.c # 路径安全/格式化/枚举排序/虚拟化区间/写操作(可宿主测)
└── test/
└── test_fsx.c # 宿主单元自测(gcc 直接编,指向临时卡根)
├── test_fsx.c # 宿主单元自测(gcc 直接编,指向临时卡根)
└── test_row_text_fit.c # 宿主几何断言:列表行高 ≥ 双行文本块(M17)
```
## 构建
@@ -55,12 +56,20 @@ v1 无需持久化,暂未使用(启动恒进卡根 `/usr/data/mnt/sd_0/`)
cd patch_v2/files_app
gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \
test/test_fsx.c logic/fsx.c -o /tmp/test_fsx && /tmp/test_fsx
# 列表行高几何(无需 meow_ui.a;Termux 下产物落 $TMPDIR)
gcc -O2 -Wall -Wextra -I. -I../sdk test/test_row_text_fit.c \
-o "$TMPDIR/test_row_text_fit" && "$TMPDIR/test_row_text_fit"
```
覆盖:路径安全(`within_sd`/`join`/`sanitize`/`dirname`/`basename`)、展示格式化(`fmt_size`/`fmt_time`)、
目录枚举排序(`scan_dir`/`sort`)、虚拟化区间(`visible_range`/`max_scroll`)、写操作(`mkdir`/`rename`/
`delete`,含卡外/越界拒绝)、非文本写拒绝。宿主全 PASS。
`test_row_text_fit` 是 M17 行高回归:目录列表行恒为双行档位,行高必须 ≥ 文本块
`2*meow_ui_text_height(font_body) + 2`(真机 20px 字库行高 30 → 62),否则 SDK 居中后首行标题
`ty < 视口顶` 被裁剪守卫整条跳过。断言矩阵覆盖真机五种字库行高 + 旧固定 56 的 bug 形态。
## 交互
- 触摸:行 body=进目录/开文本预览;右侧 `⋯` 或长按→BottomSheet(删除[确认弹窗]/重命名[IME]/新建目录[IME]/属性[大小·mtime]);
@@ -78,6 +87,10 @@ gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \
位移 ≤ 容忍」→ 经 `meow_ui_set_long_press_cb` 注册的回调带坐标交给本页;`browser_on_long_press`
用 `meow_listview_hit` 行命中打开 BottomSheet。App 层长按计时(`browser_tick`/`g_press_ms`)已删除,
其抬手 TAP 由 SDK 抑制(长按不再触发点击)。
- **行高按字体行高派生(M17)**:`files_row_h()` = `max(APP_ROW_H, 2*行高 + 2)`(当前主题正文
`font_body` 的 `meow_ui_text_height`)。目录行是双行档位,行高小于双行文本块时 SDK 居中后首行
标题落在视口顶之上被裁剪(20px 字库行高 30 → 块 62,旧的显式 56 首行名称空白)。滚动/命中/
最大滚动量由 SDK 按同一行高换算,页脚几何独立(`r_list` 单独扣 `APP_FOOTER_H`)。
2. **中文字形**:SDK 默认 `common` 字库(≈4k)不含 CJK 全量;exFAT 中文文件名/中文文本预览在 common 档会显示缺字豆腐。
预览页不声明 `fonts`(app.json 未加 `"fonts":"full"`/`"gbk"`),如需中文预览可在 app.json 声明大字号档。
3. **预览仅文本**:按扩展名白名单 + 首 512 字节 NUL 嗅探判文本;二进制/图片无预览(提示「not a text file」)。
@@ -88,7 +101,7 @@ gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \
## 验证状态
- 编译:`-Wall -Wextra` 0 warning/0 error,产物 `files`(MIPS32r2 静态 ELF)。
- 宿主:`test/test_fsx.c` 全 PASS。
- 宿主:`test/test_fsx.c` 全 PASS;`test/test_row_text_fit.c` 32 断言全 PASS(M17 行高几何)。
- qemu-mipsel(无 /dev/fb0 → SDK 离屏 480×720):正常运行到事件循环,导出离屏帧,
确认 init/建页/topbar(返回+NEW)/空态(NO STORAGE CARD)渲染正确、SIGTERM 干净退出。
- 实机目录列表渲染(有真实 TF 卡目录)需刷机/设备挂载验证——宿主模拟无法造出 `/usr/data/mnt/sd_0`。
+26 -1
View File
@@ -37,13 +37,38 @@ void picker_cancel(void); /* 取消:发 code=MEOW_ERR
#define APP_W 480
#define APP_H 720
#define APP_TOPBAR_H 56
#define APP_ROW_H 56 /* 列表行高 */
#define APP_ROW_H 56 /* 列表行高下限(旧固定值;实际按字体行高派生,见 files_row_h) */
#define APP_PAD 16
#define APP_ICON_W 26 /* 图标占宽 */
#define APP_OP_W 52 /* 右侧操作钮占宽(触达区) */
#define APP_FOOTER_H 26 /* 底部信息行高(列表/文本内容在其之上滚动) */
#define APP_READ_MAX (256 * 1024) /* 文本预览读取上限 */
/* ---- 列表行高:按字体行高派生(M17,首行标题不显示根因修复)----
* SDK meow_listview 的双行渲染把「标题+副标题」文本块
* block = 2 * meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2)
* 在行内垂直居中(ty = 行顶 + (item - block) / 2),标题只在 ty >= 视口顶时才画。
* item < block 时 ty = 行顶 - Δ:第一行(行顶 == 视口顶)标题被整条跳过、只剩副标题。
* 真机 source_han_serif 20px(ascent 24 + descent 6)行高 30 → block 62,旧的显式 56 → Δ=3。
* 故行高按当前主题正文行高派生:max(APP_ROW_H, 2*line_h + 2)(行高 30 → 62)。
* 本页行恒为双行档位(browser_src2 填 title + subtitle)→ 用 lines=2 公式。
* 行几何(draw / hit / max_scroll)全由 SDK 按传入的这一个值换算,故滚动与命中天然同步;
* 页脚几何与行高无关(r_list 单独扣 APP_FOOTER_H)。 */
#define FILES_ROW_SUB_GAP 2 /* 与 SDK MEOW_LISTV_SUB_GAP 同口径(标题/副标题行距) */
static inline int files_row_h_for_line_h(int line_h)
{
if (line_h < 1) line_h = 1; /* 字体未就绪 → 退回下限 */
int h = 2 * line_h + FILES_ROW_SUB_GAP;
return (h > APP_ROW_H) ? h : APP_ROW_H;
}
/* 当前主题正文行高下的两行列表行高(页面 create 时取一次;字体档位切换走页面重建)。 */
static inline int files_row_h(void)
{
const meow_theme *th = meow_theme_get();
return files_row_h_for_line_h(th ? meow_ui_text_height(th->font_body) : 0);
}
/* ---- App 级复用矩形/几何 ---- */
typedef struct { int x, y, w, h; } app_rect_t;
#define AR(x,y,w,h) ((app_rect_t){ (x),(y),(w),(h) })
@@ -0,0 +1,86 @@
/* test/test_row_text_fit.c — 宿主回归(M17):目录列表行高必须容纳双行文本块。
*
* 背景(真机根因,承接 M12/M15):SDK meow_listview 的双行渲染把
* block = 2 * meow_ui_text_height(font_body) + 2 (标题行 + 2px 行距 + 副标题行)
* 在行内垂直居中:ty = 行顶 + (item - block) / 2;且标题只在 `ty >= 视口顶` 时才画(越界钳制)。
* 行高 item < block 时 ty < 行顶 → **第一行(行顶 == 视口顶)标题被整条跳过**、副标题仍在。
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 → 行高 30)block = 62,旧的
* 显式 set_item_h(56) → ty = 行顶 - 3(本 App 目录列表首行名称空白)。
*
* 本测试只验 app 侧行高派生(files_row_h_for_line_h,纯整数、不链 SDK):
* 行高 = max(APP_ROW_H, 2*行高 + FILES_ROW_SUB_GAP) → block ≤ item,首行标题不被钳掉。
*
* 编译/运行(宿主 gcc,只需 SDK 头,不需要 meow_ui.a):
* cd patch_v2/files_app
* gcc -O2 -Wall -Wextra -I. -I../sdk test/test_row_text_fit.c -o "$TMPDIR/test_row_text_fit" \
* && "$TMPDIR/test_row_text_fit"
*
* 断言矩阵 = 真机已装字库 20px 面实测行高:source_han_serif 30(当前生效)、
* misans/default 27、google_sans/meslo 26、source_han_sans 21;14 = 宿主 5x7 回退。
* ① block ≤ item(否则居中偏移为负)
* ② 首行 ty ≥ 0 → 绝对 ty_abs ≥ 视口顶(标题不被裁剪守卫跳过)
* ③ 副标题底边 ty + block ≤ item(两行都不越行)
* ④ item ≥ APP_ROW_H 下限(小字库/字体未就绪不缩回固定小值)
* ⑤ 旧固定 56 在行高 30 下 ty < 0(bug 形态复现,防止再写死 56)
*/
#include "../files_internal.h"
#include <stdio.h>
static int g_checks, g_fail;
static void chk(int cond, int line_h, const char *what)
{
g_checks++;
if (!cond) {
g_fail++;
printf(" FAIL lh=%d: %s\n", line_h, what);
}
}
int main(void)
{
static const int lh_all[] = { 14, 21, 26, 27, 30 };
const int n = (int)(sizeof lh_all / sizeof lh_all[0]);
const int list_y = 120; /* 任意列表顶(只验相对几何) */
for (int k = 0; k < n; k++) {
int lh = lh_all[k];
int item = files_row_h_for_line_h(lh);
int block = 2 * lh + FILES_ROW_SUB_GAP;
int ty = (item - block) / 2; /* SDK 双行文本块居中偏移 */
printf("lh=%2d item=%3d block=%3d ty=%d\n", lh, item, block, ty);
chk(block <= item, lh, "文本块必须 <= 行高(否则首行标题被视口顶裁剪)");
chk(ty >= 0, lh, "首行标题 y 不得高于行顶/视口顶");
chk(ty + block <= item, lh, "副标题底边仍在行内");
chk(item >= APP_ROW_H, lh, "行高不得小于 APP_ROW_H 下限");
/* 首行(scroll=0 → 行顶 == 视口顶)的绝对坐标 */
chk(list_y + ty >= list_y, lh, "首行标题绝对 y 不得被视口顶裁掉");
}
/* 行高随字体行高单调不减 */
chk(files_row_h_for_line_h(30) >= files_row_h_for_line_h(26), 30, "行高随字体行高单调不减");
/* 真机当前生效字库(source_han_serif 20px,行高 30):block 62 → item 62 */
chk(files_row_h_for_line_h(30) == 62, 30, "行高 30 → 派生行高 62");
chk(2 * 30 + FILES_ROW_SUB_GAP <= files_row_h_for_line_h(30), 30, "派生行高容纳 62px 文本块");
/* 下限:小字库 / 字体未就绪(line_h=0)仍不缩回固定小值 */
chk(files_row_h_for_line_h(14) == APP_ROW_H, 14, "小字库退回 APP_ROW_H 下限");
chk(files_row_h_for_line_h(0) == APP_ROW_H, 0, "字体未就绪(行高 0)退回下限");
/* 回归哨兵:旧固定行高 56 + 当前默认字体(行高 30)就是 bug 形态 */
{
const int lh = 30;
int block = 2 * lh + FILES_ROW_SUB_GAP; /* 62 */
int ty_legacy = (APP_ROW_H - block) / 2; /* -3 */
chk(ty_legacy < 0, lh, "旧行高 56 + 行高 30 → ty<0(首行标题被跳过,bug 复现)");
chk(files_row_h_for_line_h(lh) > APP_ROW_H, lh, "默认字体下行高必须大于旧值 56");
}
printf("%s: %d checks, %d fail (font line-height matrix: %d)\n",
g_fail ? "FAIL" : "PASS", g_checks, g_fail, n);
return g_fail ? 1 : 0;
}
+1 -1
View File
@@ -421,7 +421,7 @@ static void browser_page_create(meow_view *v, void *ctx)
/* 结构化列表:行数据源 browser_src2;单选模式(行高亮用 selected,§5.8.3) */
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
browser_src2, NULL, MEOW_LIST_SEL_SINGLE);
meow_listview_set_item_h(g_list, APP_ROW_H);
meow_listview_set_item_h(g_list, files_row_h()); /* M17:按字体行高派生,容纳双行文本块 */
/* 空态/页脚注册为非控件绘制物(列表视口不含页脚;invalidate/paint_full 自动重画) */
g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h,
browser_draw_empty, NULL);
+29 -7
View File
@@ -39,7 +39,27 @@ static int viewer_vp_h(void) { return r_content.h - APP_FOOTER_H; }
/* 屏幕行高(px)。 */
static int viewer_rowh(const meow_theme *th) { return meow_ui_text_height(th->font_body) + 6; }
/* 折行:返回逻辑行 li 折成的屏幕行数;若 intra>=0,把该屏幕行文本写入 out(NUL 结尾)。 */
/* UTF-8 码位字节长(1..4):s[bi] 起、串内有效字节数 lim。
* 首字节非法/续字节非法/序列不完整 → 保守按 1(单字节);保证折行绝不断在多字节码位中间,
* 也保证宽测量(meow_ui_text_width 按码位测)对中文/混合文本正确。 */
static int viewer_cp_len(const unsigned char *s, int bi, int lim)
{
unsigned char c = s[bi];
int n;
if (c < 0x80) return 1;
else if ((c & 0xE0) == 0xC0) n = 2;
else if ((c & 0xF0) == 0xE0) n = 3;
else if ((c & 0xF8) == 0xF0) n = 4;
else return 1;
if (lim - bi < n) return 1;
for (int i = 1; i < n; i++)
if ((s[bi + i] & 0xC0) != 0x80) return 1;
return n;
}
/* 折行:返回逻辑行 li 折成的屏幕行数;若 intra>=0,把该屏幕行文本写入 out(NUL 结尾)。
* 按「UTF-8 码位」逐字累加显示宽度(meow_ui_text_width 按码位测>0),到内容宽 colw 即断行;
* 多字节(CJK)整字不断开。 */
static int viewer_wrap_line(int li, int intra, char *out, int outsz)
{
if (!g_buf || li < 0 || li >= g_nline) return 0;
@@ -49,13 +69,15 @@ static int viewer_wrap_line(int li, int intra, char *out, int outsz)
while (len > 0 && (g_buf[start + len - 1] == '\n' || g_buf[start + len - 1] == '\r')) len--;
int colw = viewer_colw();
int col = 0, row = 0, outlen = 0;
char ch[2]; ch[1] = '\0';
for (int i = 0; i < len; i++) {
ch[0] = g_buf[start + i];
if (ch[0] == '\r') continue;
int w = meow_ui_text_width(ch, 20);
const unsigned char *s = (const unsigned char *)g_buf + start;
for (int bi = 0; bi < len; ) {
int clen = viewer_cp_len(s, bi, len);
if (clen == 1 && s[bi] == '\r') { bi++; continue; }
char cp[5]; cp[clen] = '\0';
memcpy(cp, s + bi, (size_t)clen); bi += clen;
int w = meow_ui_text_width(cp, 20);
if (col + w > colw && col > 0) { row++; col = 0; }
if (row == intra && out && outlen < outsz - 1) out[outlen++] = ch[0];
if (row == intra && out && outlen + clen < outsz) { memcpy(out + outlen, cp, (size_t)clen); outlen += clen; }
col += w;
}
if (out) out[outlen] = '\0';
+25
View File
@@ -33,6 +33,31 @@ extern meow_sheet *g_sheet; /* BottomSheet(阻塞模态;main 创建
typedef struct { int x, y, w, h; } rect_t;
#define RX(x,y,w,h) ((rect_t){ (x), (y), (w), (h) })
/* ---- 两行列表行高:按字体行高派生(M17,首行标题不显示根因修复)----
* SDK meow_listview 的双行渲染把「标题+副标题」文本块
* block = 2 * meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2)
* 在行内垂直居中(ty = 行顶 + (item - block) / 2),标题只在 ty >= 视口顶时才画。
* item < block 时 ty = 行顶 - Δ:第一行(行顶 == 视口顶)标题被整条跳过、只剩副标题。
* 真机 source_han_serif 20px(ascent 24 + descent 6)行高 30 → block 62;demo 的 swipe 列表
* 是双行档位(set_item_renderer(2,...)),旧的显式 48 比 block 小 14 → 首行标题必丢。
* 故行高按当前主题正文行高派生:max(HELLO_ROW_H_MIN, 2*line_h + 2)(行高 30 → 62)。
* 行几何(draw / hit / max_scroll)全由 SDK 按传入的这一个值换算,滚动与命中天然同步。 */
#define HELLO_ROW_H_MIN 56
#define HELLO_ROW_SUB_GAP 2 /* 与 SDK MEOW_LISTV_SUB_GAP 同口径(标题/副标题行距) */
static inline int hello_row_h_for_line_h(int line_h)
{
if (line_h < 1) line_h = 1; /* 字体未就绪 → 退回下限 */
int h = 2 * line_h + HELLO_ROW_SUB_GAP;
return (h > HELLO_ROW_H_MIN) ? h : HELLO_ROW_H_MIN;
}
/* 当前主题正文行高下的两行列表行高(建列表时取一次;字体档位切换走内容重建)。 */
static inline int hello_row_h(void)
{
const meow_theme *th = meow_theme_get();
return hello_row_h_for_line_h(th ? meow_ui_text_height(th->font_body) : 0);
}
/* ---- 视图控制器(ctx = biz-UI 绑定点)----
* 本批控制器为薄壳:持 meow_view 回指针;页面细粒度视图态(g_ds/g_drag_* /scroll 等)留在各页
* 模块作用域全局(设计定案 B:demo 视图态不迁 logic)。控制器未来可吸收这些状态。 */
@@ -0,0 +1,87 @@
/* test/test_row_text_fit.c — 宿主回归(M17):demo 的 swipe 列表行高必须容纳双行文本块。
*
* 背景(真机根因,承接 M12/M15):SDK meow_listview 的双行渲染把
* block = 2 * meow_ui_text_height(font_body) + 2 (标题行 + 2px 行距 + 副标题行)
* 在行内垂直居中:ty = 行顶 + (item - block) / 2;且标题只在 `ty >= 视口顶` 时才画(越界钳制)。
* 行高 item < block 时 ty < 行顶 → **第一行(行顶 == 视口顶)标题被整条跳过**、副标题仍在。
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 → 行高 30)block = 62;
* demo 的 swipe 列表是双行档位(set_item_renderer(2,...))却显式 set_item_h(48) → ty = 行顶 - 7,
* 「notif 0」首行标题在真机上必然空白。
*
* 本测试只验 app 侧行高派生(hello_row_h_for_line_h,纯整数、不链 SDK):
* 行高 = max(HELLO_ROW_H_MIN, 2*行高 + HELLO_ROW_SUB_GAP) → block ≤ item,首行标题不被钳掉。
*
* 编译/运行(宿主 gcc,只需 SDK 头,不需要 meow_ui.a):
* cd patch_v2/hello_app
* gcc -O2 -Wall -Wextra -I. -I../sdk test/test_row_text_fit.c -o "$TMPDIR/test_row_text_fit" \
* && "$TMPDIR/test_row_text_fit"
*
* 断言矩阵 = 真机已装字库 20px 面实测行高:source_han_serif 30(当前生效)、
* misans/default 27、google_sans/meslo 26、source_han_sans 21;14 = 宿主 5x7 回退。
* ① block ≤ item(否则居中偏移为负)
* ② 首行 ty ≥ 0 → 绝对 ty_abs ≥ 视口顶(标题不被裁剪守卫跳过)
* ③ 副标题底边 ty + block ≤ item(两行都不越行)
* ④ item ≥ HELLO_ROW_H_MIN 下限(小字库/字体未就绪不缩回固定小值)
* ⑤ 旧固定 48 在行高 30 下 ty < 0(bug 形态复现,防止再写死 48)
*/
#include "../hello_internal.h"
#include <stdio.h>
static int g_checks, g_fail;
static void chk(int cond, int line_h, const char *what)
{
g_checks++;
if (!cond) {
g_fail++;
printf(" FAIL lh=%d: %s\n", line_h, what);
}
}
int main(void)
{
static const int lh_all[] = { 14, 21, 26, 27, 30 };
const int n = (int)(sizeof lh_all / sizeof lh_all[0]);
const int list_y = 400; /* 任意列表顶(只验相对几何) */
for (int k = 0; k < n; k++) {
int lh = lh_all[k];
int item = hello_row_h_for_line_h(lh);
int block = 2 * lh + HELLO_ROW_SUB_GAP;
int ty = (item - block) / 2; /* SDK 双行文本块居中偏移 */
printf("lh=%2d item=%3d block=%3d ty=%d\n", lh, item, block, ty);
chk(block <= item, lh, "文本块必须 <= 行高(否则首行标题被视口顶裁剪)");
chk(ty >= 0, lh, "首行标题 y 不得高于行顶/视口顶");
chk(ty + block <= item, lh, "副标题底边仍在行内");
chk(item >= HELLO_ROW_H_MIN, lh, "行高不得小于 HELLO_ROW_H_MIN 下限");
/* 首行(scroll=0 → 行顶 == 视口顶)的绝对坐标 */
chk(list_y + ty >= list_y, lh, "首行标题绝对 y 不得被视口顶裁掉");
}
/* 行高随字体行高单调不减 */
chk(hello_row_h_for_line_h(30) >= hello_row_h_for_line_h(26), 30, "行高随字体行高单调不减");
/* 真机当前生效字库(source_han_serif 20px,行高 30):block 62 → item 62 */
chk(hello_row_h_for_line_h(30) == 62, 30, "行高 30 → 派生行高 62");
chk(2 * 30 + HELLO_ROW_SUB_GAP <= hello_row_h_for_line_h(30), 30, "派生行高容纳 62px 文本块");
/* 下限:小字库 / 字体未就绪(line_h=0)仍不缩回固定小值 */
chk(hello_row_h_for_line_h(14) == HELLO_ROW_H_MIN, 14, "小字库退回 HELLO_ROW_H_MIN 下限");
chk(hello_row_h_for_line_h(0) == HELLO_ROW_H_MIN, 0, "字体未就绪(行高 0)退回下限");
/* 回归哨兵:旧固定行高 48 + 当前默认字体(行高 30)就是 bug 形态 */
{
const int lh = 30;
int block = 2 * lh + HELLO_ROW_SUB_GAP; /* 62 */
int ty_legacy = (48 - block) / 2; /* -7 */
chk(ty_legacy < 0, lh, "旧行高 48 + 行高 30 → ty<0(首行标题被跳过,bug 复现)");
chk(hello_row_h_for_line_h(lh) > 48, lh, "默认字体下行高必须大于旧值 48");
}
printf("%s: %d checks, %d fail (font line-height matrix: %d)\n",
g_fail ? "FAIL" : "PASS", g_checks, g_fail, n);
return g_fail ? 1 : 0;
}
+1 -1
View File
@@ -266,7 +266,7 @@ int sdk2_build_at(int yb_base, int ct, int scroll)
g_lv = meow_listview_create(r_lv.x, r_lv.y, r_lv.w, r_lv.h, 0,
NULL, NULL, MEOW_LIST_SEL_NONE);
if (g_lv) {
meow_listview_set_item_h(g_lv, 48);
meow_listview_set_item_h(g_lv, hello_row_h()); /* M17:双行文本块按字体行高派生 */
meow_listview_set_vsource(g_lv, lv_count, lv_bind, NULL);
meow_listview_set_item_renderer(g_lv, 2, 1, 0); /* 双行、左图标、无右尾件 */
meow_listview_set_empty_text(g_lv, "All rows deleted");
Binary file not shown.
+56 -15
View File
@@ -784,11 +784,35 @@ static void out_append(const char *pkg, pid_t pid, long epoch, long up, const ch
}
/* ---- launcher IPC 转发(xchg 一帧,读回一帧) ---- */
/* 最近一次 ipc_xchg 的失败原因:区分「连不上」与「连上但没及时回」。此前两者都记成
* "launcher offline",2026-11-04 真机即误诊——launcher 活着且回了 ACK,只是晚于 1200ms 超时。 */
enum { IPC_ERR_NONE = 0, IPC_ERR_CONN, IPC_ERR_IO, IPC_ERR_TIMEOUT, IPC_ERR_PROTO };
static int g_ipc_err = IPC_ERR_NONE;
static const char *ipc_err_str(void)
{
switch (g_ipc_err) {
case IPC_ERR_CONN: return "connect failed";
case IPC_ERR_IO: return "io error";
case IPC_ERR_TIMEOUT: return "timeout, no reply";
case IPC_ERR_PROTO: return "unexpected reply";
default: return "ok";
}
}
static int ipc_fail(int fd, int err)
{
if (fd >= 0) close(fd);
g_ipc_err = err;
return -1;
}
static int ipc_xchg(uint16_t type, const void *payload, uint32_t len,
uint8_t *rbuf, uint32_t rcap)
{
g_ipc_err = IPC_ERR_NONE;
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) return -1;
if (fd < 0) { g_ipc_err = IPC_ERR_IO; return -1; }
/* 收发加超时:launcher 刚刚重启/主循环未就绪(接受但不回包)时,amsd 的阻塞
* read 会永远挂起,冻结整个单线程守护。设 SO_SNDTIMEO/SO_RCVTIMEO 让单次转发
* 有界,超时即返回失败 → ams_request_ctl 退避重连,绝不无限阻塞。
@@ -802,7 +826,7 @@ static int ipc_xchg(uint16_t type, const void *payload, uint32_t len,
memset(&a, 0, sizeof a);
a.sun_family = AF_UNIX;
strncpy(a.sun_path, g_ipc_sock, sizeof a.sun_path - 1);
if (connect(fd, (struct sockaddr *)&a, sizeof a) != 0) { close(fd); return -1; }
if (connect(fd, (struct sockaddr *)&a, sizeof a) != 0) return ipc_fail(fd, IPC_ERR_CONN);
uint8_t hdr[MEOW_IPC_HDR_LEN];
uint32_t magic = MEOW_IPC_MAGIC, rmagic, rlen;
uint32_t reqid = 0; /* amsd→launcher 控制请求无需回显 */
@@ -813,31 +837,35 @@ static int ipc_xchg(uint16_t type, const void *payload, uint32_t len,
size_t off = 0;
while (off < sizeof hdr) {
ssize_t w = write(fd, hdr + off, sizeof hdr - off);
if (w <= 0) { close(fd); return -1; }
if (w <= 0) return ipc_fail(fd, (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
off += (size_t)w;
}
if (len && payload) {
size_t p = 0;
while (p < len) {
ssize_t w = write(fd, (const uint8_t *)payload + p, len - p);
if (w <= 0) { close(fd); return -1; }
if (w <= 0) return ipc_fail(fd, (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
p += (size_t)w;
}
}
off = 0;
while (off < sizeof hdr) {
ssize_t r = read(fd, hdr + off, sizeof hdr - off);
if (r <= 0) { close(fd); return -1; }
if (r <= 0) return ipc_fail(fd, (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
off += (size_t)r;
}
memcpy(&rmagic, hdr, 4); memcpy(&rver, hdr + 4, 2);
memcpy(&rtype, hdr + 6, 2); memcpy(&rlen, hdr + 12, 4);
if (rmagic != MEOW_IPC_MAGIC || rver != MEOW_IPC_VERSION) { close(fd); return -1; }
if (rmagic != MEOW_IPC_MAGIC || rver != MEOW_IPC_VERSION) return ipc_fail(fd, IPC_ERR_PROTO);
if (rlen > rcap) rlen = rcap;
off = 0;
while (off < rlen) {
ssize_t r = read(fd, rbuf + off, rlen - off);
if (r <= 0) { close(fd); return -1; }
if (r <= 0) return ipc_fail(fd, (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
off += (size_t)r;
}
close(fd);
@@ -858,11 +886,12 @@ static int ams_request_ctl(uint16_t type, const void *payload, uint32_t len, uin
if (code) *code = a.code;
return 0;
}
if (rt == MSG_ERR) return MEOW_ERR_INTERNAL;
if (rt == MSG_ERR) { g_ipc_err = IPC_ERR_PROTO; return MEOW_ERR_INTERNAL; }
/* 一次连接失败/超时(launcher 未就绪)→ 短退避再试;仍失败则返回 -1。 */
if (attempt + 1 < AMS_IPC_RETRY)
usleep((useconds_t)(AMS_IPC_BACKOFF_MS * (attempt + 1)) * 1000);
}
if (g_ipc_err == IPC_ERR_NONE) g_ipc_err = IPC_ERR_PROTO; /* 收到非 ACK/ERR 帧:不是「无回执」 */
return -1;
}
@@ -1228,9 +1257,14 @@ static int cmd_start(const char *spec, char *resp, int rn)
/* launcher ACK ack.code 携带正幅度(busy=8,见 util.c `rc<0?-rc:MEOW_OK`),非二补。 */
snprintf(detail, sizeof detail, "busy (app running)");
emit_log("AMS start %s -> BUSY (app running)", pkg);
} else if (rt == 0) {
snprintf(detail, sizeof detail, "launcher refused code=%d", (int)code);
emit_log("AMS start %s -> launcher refused code=%u(%d)", pkg, code, (int)code);
} else {
snprintf(detail, sizeof detail, "launcher offline");
emit_log("AMS start %s -> launcher offline", pkg);
/* rt!=0 只代表「这次没拿到回执」,不等于 launcher 死了(同 cmd_kill 注释)。
* 响应串保留 "launcher offline"(MeowPanel 据此映射 503),日志补真实原因。 */
snprintf(detail, sizeof detail, "launcher offline (%s)", ipc_err_str());
emit_log("AMS start %s -> launcher offline (%s)", pkg, ipc_err_str());
}
snprintf(resp, rn, "%s %s/%s\n", strstr(detail, "ok forwarded") ? "OK" : "ERR",
pkg, act[0] ? act : "(default)");
@@ -1251,14 +1285,21 @@ static int cmd_kill(const char *pkg, char *resp, int rn)
uint32_t code = 0;
int rt = ams_request_ctl(MSG_AMS_KILL, &k, sizeof k, &code);
if (rt != 0 && a->pid > 0) {
/* 兜底:launcher 不在,直接 SIGTERM(与 launcher 的 SIGTERM 语义一致) */
/* 兜底:launcher 没给回执 → 直接 SIGTERM(与 launcher 的 SIGTERM 语义一致)。
* 注意 rt!=0 **不等于 launcher 死了**:主循环被 IPC 洪泛拖慢时 ACK 会晚于
* AMS_IPC_TIMEOUT_MS,故日志写实际原因(connect failed / timeout, no reply / ...),
* 而不是笼统的 "launcher offline"(2026-11-04 真机即此误判)。 */
kill(a->pid, SIGTERM);
emit_log("AMS kill %s -> direct SIGTERM (launcher offline)", pkg);
emit_log("AMS kill %s -> direct SIGTERM (launcher %s)", pkg, ipc_err_str());
} else if (rt != 0) {
return snprintf(resp, rn, "ERR launcher offline: %s\n", pkg);
/* 响应串保留 "launcher offline" 字样(MeowPanel api_ams_ctl 依此映射 503 AMS_UNAVAILABLE),
* 只在括号里补真实原因,便于区分「连不上」与「连上但超时」。 */
return snprintf(resp, rn, "ERR launcher offline (%s): %s\n", ipc_err_str(), pkg);
} else if (code != MEOW_OK) {
emit_log("AMS kill %s -> launcher refused code=%u", pkg, code);
return snprintf(resp, rn, "ERR launcher refused: %s (code=%u)\n", pkg, code);
/* launcher 明确拒绝:不改判为直杀(pid 可能已被回收,盲杀会打错进程),如实回错。
* code 是负错误码的 uint32 呈现(-2 → 4294967294),括号里给有符号值便于对照。 */
emit_log("AMS kill %s -> launcher refused code=%u(%d)", pkg, code, (int)code);
return snprintf(resp, rn, "ERR launcher refused: %s (code=%d)\n", pkg, (int)code);
}
hist_push(a, "ams_kill", "terminate (via launcher)", (long)time(NULL), read_uptime_sec());
int m = snprintf(resp, rn, "OK terminate %s\n", pkg);
+102 -18
View File
@@ -8,9 +8,10 @@
* 核心设计(用户定案,勿推翻):
* - 独占 + 抢占(后开流赢,旧流收 MSG_AUDIO_PREEMPTED 并被置 draining,PCM 丢弃);
* 软混音已否决(MIPS32 软 SRC 伤音质),留混音器接口位(v1 不实现)。
* - 单进程双线程(+路由线程):IO 线程(accept/读帧/解析/ring 写)、播放线程
* - 单进程四线程:IO 线程(accept/读帧/解析/ring 写)、播放线程
* (独占 ALSA 直开,从 ring 取数写 DAC,xrun PREPARE 恢复)、路由线程
* (轮询 headset/balance 插孔 switch 状态 → amixer cset numid=9)。
* (轮询 headset/balance 插孔 switch 状态 → amixer cset numid=9)、
* 音量执行线程(MSG_AUDIO_VOLUME 异步落 amixer + 持久化,末值去抖,见事项三)。
* - 格式归一 = 协商:grant_rate/ch/fmt 回客户端应产出的格式(SDK 负责 mono→stereo
* 复制 / S16↔S32 定值升降位);audiod 只做「硬件最优」格式的字节管道。
* - DAC 原生率跟随(无 SRC);切率 mute-重配-恢复。
@@ -130,6 +131,7 @@ static void session_clean_locked(void);
static void _audio_mark_reconfig_needed(void);
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_cond;
static pthread_cond_t g_vol_cond; /* 音量异步执行线程等待(CLOCK_MONOTONIC,main 中初始化) */
struct client {
int used;
@@ -137,10 +139,23 @@ struct client {
char app_id[MEOW_APP_ID_MAX];
uint32_t sid;
int state; /* CLS_* */
uint32_t gen; /* 分配代际(音量异步 ACK 防 fd/槽复用竞态) */
int vol_ack; /* 音量请求已执行,待 IO 线程回 ACK(vol 线程写/IO 线程清) */
uint32_t vol_ack_req; /* 对应 req_id */
uint32_t vol_ack_status; /* ACK status(应用后 g_vol_pct) */
uint8_t rbuf[READ_BUF_MAX];
size_t rlen;
};
static struct client g_cli[CLI_MAX];
static uint32_t g_cli_gen = 0; /* 客户端分配代际计数(cli_alloc 递增) */
/* ---- 音量异步化(事项三):IO 线程只入队末值,专用 vol 线程落 amixer+持久化后
* 经 IO 线程回 ACK。驱动 wedge 时 3×popen≈4.5s 不再卡持锁的 dispatch/IO 循环。 ---- */
static int g_vol_pending = 0; /* 有待执行音量请求(g_mu 保护) */
static int g_vol_pending_pct = 0; /* 末值(0-100;连续请求只保留最新=去抖) */
static struct client *g_vol_pending_cli = NULL; /* 末次请求方(ACK 用;可能已回收,靠 gen 校验) */
static uint32_t g_vol_pending_req = 0; /* 末次请求 req_id */
static uint32_t g_vol_pending_gen = 0; /* 末次请求方 gen(防槽复用误 ACK) */
/* 当前授权流(喂养 DAC 的唯一流) */
struct stream_info {
@@ -308,15 +323,16 @@ static int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payloa
}
/* cond 有界等待(g_cond 为 CLOCK_MONOTONIC;调用方已持 g_mu)。 */
static void cond_wait_tmo(int ms)
static void cond_wait_tmo_cv(pthread_cond_t *cv, int ms)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
ts.tv_sec += ms / 1000;
ts.tv_nsec += (long)(ms % 1000) * 1000000L;
if (ts.tv_nsec >= 1000000000L) { ts.tv_sec++; ts.tv_nsec -= 1000000000L; }
pthread_cond_timedwait(&g_cond, &g_mu, &ts);
pthread_cond_timedwait(cv, &g_mu, &ts);
}
static void cond_wait_tmo(int ms) { cond_wait_tmo_cv(&g_cond, ms); }
/* ---- frame 大小/位深 ---- */
static int fmt_bps(int fmt)
@@ -1135,12 +1151,14 @@ static void bt_vol_full(void)
}
/* 音量应用(含 BT 隔离):BT → 只通知 AVRCP(best-effort),绝不动 DAC numid=1/2、不持久化
* audio.volume(那是 DAC 音量键);DAC → 原感知锥度反转 + 持久化。 */
static void vol_apply(int pct)
* audio.volume(那是 DAC 音量键);DAC → 原感知锥度反转 + 持久化。
* target 为调用方在持锁下快照的 g_target;本函数不持 g_mu 执行(g_vol_pct 为 plain int
* 松散写,同 route_thread 的 g_route_cur 语义,MIPS32 int 写天然原子)。 */
static void vol_apply(int pct, int target)
{
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
if (g_target == AUDIO_OUT_BT) {
if (target == AUDIO_OUT_BT) {
logmsg("vol -> %d%% (BT/AVRCP, DAC numid=1/2 NOT written)", pct);
return;
}
@@ -1551,6 +1569,7 @@ static struct client *cli_alloc(int fd)
freec->fd = fd;
freec->state = CLS_OPEN;
freec->sid = 0;
freec->gen = ++g_cli_gen; /* 代际递增:音量异步 ACK 校验用,防槽复用误发 */
return freec;
}
static void cli_free(struct client *c)
@@ -1771,13 +1790,20 @@ static void dispatch(struct client *c, uint16_t type, uint32_t req_id, const uin
int32_t pct = -1;
if (len >= sizeof(int32_t)) memcpy(&pct, pl, sizeof pct);
if (pct >= 0) {
/* 音量应用含 BT 隔离:target=BT 时绝不下发 DAC 反转(vol_apply 内判断) */
vol_apply((int)pct);
/* 异步化(事项三):只入队末值,**不在持 g_mu 下 popen amixer**——驱动 wedge 时
* 一次 set = 3×popen ≈ 4.5s,会把 OPEN/QUERY/喂流一起卡死。连续请求合并为末值
* (去抖),由 vol 线程应用+持久化后经 IO 线程回 ACK。 */
g_vol_pending = 1;
g_vol_pending_pct = (pct > 100) ? 100 : (int)pct;
g_vol_pending_cli = c;
g_vol_pending_req = req_id;
g_vol_pending_gen = c->gen;
pthread_cond_signal(&g_vol_cond);
} else {
g_vol_pct = settings_get_volume();
g_vol_pct = settings_get_volume(); /* 读当前:文件读非 popen,可同步 */
m_ack_t a; a.code = MEOW_OK; a.status = (uint32_t)g_vol_pct;
send_frame(c->fd, MSG_ACK, req_id, &a, sizeof a);
}
m_ack_t a; a.code = MEOW_OK; a.status = (uint32_t)g_vol_pct;
send_frame(c->fd, MSG_ACK, req_id, &a, sizeof a);
break;
}
case MSG_AUDIO_OUTPUT: {
@@ -1911,6 +1937,14 @@ static void *io_thread(void *arg)
if (snaps[i].revents & (POLLHUP | POLLERR | POLLNVAL)) { cli_free(c); continue; }
if (snaps[i].revents & POLLIN) cx_read_frames(c);
}
/* 音量 ACK 回写(事项三):vol 线程执行完只置标记,由 IO 线程统一发送——客户端 fd
* 生命周期归 IO 线程独占,跨线程直接 send_frame 有 fd 复用竞态。 */
for (int i = 0; i < CLI_MAX; i++) {
if (!g_cli[i].used || !g_cli[i].vol_ack) continue;
m_ack_t va; va.code = MEOW_OK; va.status = g_cli[i].vol_ack_status;
send_frame(g_cli[i].fd, MSG_ACK, g_cli[i].vol_ack_req, &va, sizeof va);
g_cli[i].vol_ack = 0;
}
pthread_mutex_unlock(&g_mu);
/* 路由线程与播放线程独立;此处不再做 */
}
@@ -1921,6 +1955,40 @@ static void *io_thread(void *arg)
return NULL;
}
/* ---- 音量执行线程(事项三):异步落 amixer + 持久化,不在 IO 线程持锁 popen ----
* 等待 g_vol_cond 取末值(连续请求合并=去抖),放锁后执行 dac_vol_set(3×popen,有界)
* + settings_set_volume(文件 IO),完成后只置 ACK 标记,由 IO 线程统一发送(防 fd 复用竞态)。
* BT 目标只记日志不动 DAC/不持久化(AVRCP 隔离,g_vol_pct 保持不变)。 */
static void *vol_thread(void *arg)
{
(void)arg;
for (;;) {
pthread_mutex_lock(&g_mu);
while (!g_vol_pending && !g_stop)
cond_wait_tmo_cv(&g_vol_cond, COND_WAIT_MS);
if (g_stop) { pthread_mutex_unlock(&g_mu); break; }
int pct = g_vol_pending_pct;
int target = g_target; /* 持锁快照:vol_apply 不读全局 */
struct client *c = g_vol_pending_cli;
uint32_t req = g_vol_pending_req;
uint32_t cgen = g_vol_pending_gen;
g_vol_pending = 0;
g_vol_pending_cli = NULL;
pthread_mutex_unlock(&g_mu);
vol_apply(pct, target);
pthread_mutex_lock(&g_mu);
if (c && c->used && c->gen == cgen) { /* 槽复用校验:只 ACK 原请求方 */
c->vol_ack = 1;
c->vol_ack_req = req;
c->vol_ack_status = (uint32_t)g_vol_pct;
}
pthread_mutex_unlock(&g_mu);
}
return NULL;
}
/* ---- 路由线程主体 ---- */
static void *route_thread(void *arg)
{
@@ -1930,8 +1998,10 @@ static void *route_thread(void *arg)
/* M2.91 挂死根治(AUDIO_HANG_INVESTIGATION.md):hbc3000 FPGA 未烧录+enable 时
* 启动 PCM 会在第二次 hbc3000_start 死锁整机。写 Output Port Switch 是烧录触发器
* (驱动 put:set_spdif_mode→program_ram→enabled)。开机端口恒 0=未烧,故启动时
* 无条件写一次:4.4 在→3,3.5 在→2,都没→4(官方播放器拔插孔后的选择,保 FPGA 已烧)。
* 端口已是目标值(audiod 重启场景,驱动同值短路不写)→ 先写 5(disable) 强制重烧。
* 武装一次:4.4 在→3,3.5 在→2,都没→4(官方播放器拔插孔后的选择,保 FPGA 已烧)。
* 多余重烧收敛(AUDIO_HANG_INVESTIGATION):端口已是目标值(audiod 重启场景)时
* **不再**先写 5(disable) 强制重烧——每次开机做 5→目标 双写等于把 FPGA 烧录暴露翻倍,
* 正是驱动级非确定楔死触发面;改为只需读回一致即跳过,仅当未武装/端口不对时才写目标。
* ⚠️ P2 蓝牙:numnid=9/FPGA 武装只作用于 **DAC 物理路径**(plan §五解耦);target=BT 时
* 不开 hw:0,0,无 hbc3000 start 死锁风险,故 BT 期间跳过 numid=9/FPGA(切回 DAC 再武装)。 */
int need_arm = 1; /* 启动(DAC 起点)武装一次;BT 期间置 1(回 DAC 再武装) */
@@ -1951,8 +2021,19 @@ static void *route_thread(void *arg)
int bal = read_switch("/sys/class/switch/balance/state");
int target = (bal == 1) ? 3 : (hb == 1) ? 2 : 4;
int cur = amixer_get(9, 1);
if (cur == target) { logmsg("route: port already %d, force re-arm via 5", target); apply_route(5); }
apply_route(target);
if (cur == target) {
/* 端口已正确:跳过 5→target 双重强烧,只确认读回一致(cur==target 已由
* 上一拍 amixer_get 证实)。原逻辑「cur==target 时先写 5(disable) 再回
* target」是给 audiod 重启/同值短路场景强制重烧用的,但每次开机无条件做
* 等于把 FPGA 烧录暴露翻倍——正是驱动级非确定楔死的触发面,故收敛掉。 */
g_route_cur = target; /* 保持 QUERY/monitor 最近端口与读回一致 */
logmsg("route: port already %d, skip re-arm (no 5->%d re-burn)", target, target);
} else {
/* 未武装(cur=0/读失败)或端口不对:写目标端口。这是 M2.91「未武装则武装」
* 防挂死核心(FPGA 未烧+enable 时二次 hbc3000_start 死锁整机),绝不可裸奔;
* 直接写 target 即触发烧录,无需再绕写 5(同值短路场景已被上一分支跳过)。 */
apply_route(target);
}
last_hb = hb; last_bal = bal; last_target = (hb == 0 && bal == 0) ? -1 : target;
logmsg("route: boot/DAC-return arm done (port %d %s)", target, port_name(target));
need_arm = 0;
@@ -2136,19 +2217,21 @@ int main(int argc, char **argv)
logmsg("volume init = %d%% target=%s codec=%s volctl=%s",
g_vol_pct, target_name(), g_bt_codec, volctl_name());
/* g_cond 用 CLOCK_MONOTONIC(所有 timedwait 的基准;防系统时间跳变影响护栏超时) */
/* g_cond/g_vol_cond 用 CLOCK_MONOTONIC(所有 timedwait 的基准;防系统时间跳变影响护栏超时) */
{
pthread_condattr_t ca;
pthread_condattr_init(&ca);
pthread_condattr_setclock(&ca, CLOCK_MONOTONIC);
pthread_cond_init(&g_cond, &ca);
pthread_cond_init(&g_vol_cond, &ca);
pthread_condattr_destroy(&ca);
}
pthread_t ti, tp, tr;
pthread_t ti, tp, tr, tv;
pthread_create(&ti, NULL, io_thread, NULL);
pthread_create(&tp, NULL, playback_thread, NULL);
pthread_create(&tr, NULL, route_thread, NULL);
pthread_create(&tv, NULL, vol_thread, NULL);
/* 主线程 = watchdog:心跳静默 >WD_SILENT_SEC 秒即告警(只告警不强杀——
* 对卡在内核的线程任何外部干预都不安全;护栏保证其它线程/连接继续服务)。 */
@@ -2178,6 +2261,7 @@ int main(int argc, char **argv)
pthread_join(ti, NULL);
pthread_join(tp, NULL);
pthread_join(tr, NULL);
pthread_join(tv, NULL);
unlink(g_sock);
if (g_logfd >= 0) close(g_logfd);
return 0;
+74
View File
@@ -187,6 +187,24 @@ static int start_audiod_trace(const char *sink, const char *settings_path, const
return pid;
}
/* 带假 amixer 的启动(事项三异步化验证:假 amixer 每次 cset 睡 1s,模拟慢 popen)。
* 用于断言「MSG_AUDIO_VOLUME 在 IO 线程只入队、不放锁 popen」——慢 amixer 期间 QUERY 仍即时。 */
static int start_audiod_fake_amixer(const char *sink, const char *settings_path, const char *amixer_script)
{
pid_t pid = fork();
if (pid == 0) {
setenv("MEOW_AUDIO_SINK", sink, 1);
setenv("MEOW_AUDIO_OUT", TWAV, 1);
setenv("MEOW_AUDIO_SOCK", TSOCK, 1);
setenv("MEOW_AUDIO_FORCE", "1", 1);
if (settings_path) setenv("MEOW_AUDIO_SETTINGS", settings_path, 1);
if (amixer_script) setenv("MEOW_AUDIO_AMIXER", amixer_script, 1);
execl("./meow_audiod_host", "meow_audiod_host", (char *)0);
_exit(127);
}
return pid;
}
/* 直接客户端 open(带 flags:AUDIO_FLAG_DSD 用于 DoP) */
static int do_open_f(struct cli *c, int rate, int ch, int fmt, uint32_t flags, m_audio_open_ack_t *ack)
{
@@ -1080,6 +1098,62 @@ int main(void)
}
stop_audiod();
/* ---- T17:MSG_AUDIO_VOLUME 异步化(事项三)。假 amixer 每次 cset 睡 1s(一次 set=3×cset≈3s)。
* 断言:慢 amixer 期间 QUERY 即时返回(dispatch 不放锁 popen);应用后回 ACK + 双组写 + 持久化。 */
printf("[T17 volume async (dispatch not blocked by slow amixer)]\n");
unlink(TSOCK); unlink(TWAV);
const char *tconf17 = "/tmp/meow_test_settings17.conf";
FILE *tf17 = fopen(tconf17, "w");
if (tf17) { fprintf(tf17, "# meow-settings 1\naudio.volume = 60\n"); fclose(tf17); }
const char *fake17 = "/tmp/meow_fake_amixer17.sh";
const char *trace17 = "/tmp/meow_fake_amixer17_trace.txt";
unlink(trace17);
{
FILE *fs = fopen(fake17, "w");
if (fs) {
fprintf(fs, "#!/bin/sh\necho \"cset $4 $5\" >> %s\nsleep 1\nexit 0\n", trace17);
fclose(fs);
chmod(fake17, 0755);
}
}
g_audiod_pid = start_audiod_fake_amixer("file", tconf17, fake17);
usleep(400000);
{
struct cli v17;
check(cli_connect(&v17) == 0, "connect V17");
int32_t pct = 40;
check(sf(v17.fd, MSG_AUDIO_VOLUME, 17, &pct, sizeof pct) == 0, "send async volume 40");
/* 慢 amixer 执行期间 QUERY 必须即时(<600ms),证明 IO 线程没被 popen 卡住 */
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
char q17[1024];
int qok = do_query(&v17, q17, sizeof q17);
clock_gettime(CLOCK_MONOTONIC, &t1);
long qms = (t1.tv_sec - t0.tv_sec) * 1000L + (t1.tv_nsec - t0.tv_nsec) / 1000000L;
check(qok == 0 && qms < 600, "QUERY immediate during slow amixer (async dispatch)");
if (qok == 0 && qms >= 600) printf(" (query took %ldms)\n", qms);
/* 应用后回 ACK(末值 40) */
uint8_t ab[64]; uint16_t at;
int rlen = rf_tmo(v17.fd, 4000, &at, ab, sizeof ab);
check(rlen >= 0 && at == MSG_ACK, "async volume ACK returned after apply");
usleep(100000);
{
FILE *chk = fopen(tconf17, "r"); int has40 = 0; char l[128];
if (chk) { while (fgets(l, sizeof l, chk)) if (strstr(l, "audio.volume = 40")) has40 = 1; fclose(chk); }
check(has40, "async volume persisted = 40");
}
{
char trb[2048]; int trl = 0;
int fd = open(trace17, O_RDONLY);
if (fd >= 0) { trl = (int)read(fd, trb, sizeof trb - 1); close(fd); if (trl > 0) trb[trl] = 0; }
check(trl > 0 && strstr(trb, "numid=1 ") && strstr(trb, "numid=2 ") && strstr(trb, "numid=11 "),
"async dual-group write (numid=1/2/11) via fake amixer");
}
close(v17.fd);
}
stop_audiod();
unlink(fake17); unlink(trace17);
printf("=== SUMMARY: pass=%d fail=%d ===\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}
+28 -5
View File
@@ -23,6 +23,28 @@ static void app_name_copy(char *dst, int cap, const char *src)
}
/* M6.40 启动失败原因透传:launch_app/launch_activity(→launch_app_chain)返回 -1 前按实际
* 失败点填具体原因(fork OOM / exec 失败 / 缺依赖 / 路径不存在等),供 main_menu.c 的
* launch_fail_toast 显示「CANNOT START: %s(%s)」;success 路径恒为 ""。 */
static char g_launch_fail_reason[160] = "";
const char *launch_fail_reason(void)
{
return g_launch_fail_reason;
}
/* 内部失败出口:设原因 + 记统一失败日志 + 返回 -1(各失败分支复用)。 */
static int launch_fail(struct app *a, const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vsnprintf(g_launch_fail_reason, sizeof g_launch_fail_reason, fmt, ap);
va_end(ap);
logmsg("launch failed: %s reason=%s", a->name, g_launch_fail_reason);
return -1;
}
/* ---- amsd 上报 / 控制挂点(AMSD_DESIGN §4):best-effort,amsd 不在不影响 launcher ---- */
#define MEOW_AMS_SOCK_DEFAULT "/usr/data/meow_ams.sock"
@@ -1585,8 +1607,9 @@ static int launch_app_chain(struct app *a, const char *chain)
uint32_t pick_req = 0;
meow_ams_take_picker(pick_action, sizeof pick_action,
pick_caller, sizeof pick_caller, &pick_req);
if (!a->ok) { logmsg("launch blocked: exec not ok"); return -1; }
meow_dep_clear_reason(); /* 每次 launch 入口清 stale 依赖阻断原因(防上一 app 残留) */
g_launch_fail_reason[0] = 0; /* M6.40:同上,清 stale 启动失败原因 */
if (!a->ok) { logmsg("launch blocked: exec not ok"); return launch_fail(a, "no such file: %s", a->exec); }
/* M4 后台保活恢复:该 App 驻留后台(播放中经 nav-exit 转过)→ 原进程回前台,
* 不 fork/exec(进程仍存无法注入新 env:chain/picker 上下文忽略,记日志)。
@@ -1613,7 +1636,7 @@ static int launch_app_chain(struct app *a, const char *chain)
char dep_missing[32] = "";
if (meow_check_app_depends(a, dep_missing, sizeof dep_missing) != 0) {
logmsg("launch blocked: dep '%s' missing (CANNOT START: %s)", dep_missing, a->name);
return -1;
return launch_fail(a, "missing dep: %s", dep_missing);
}
}
/* 冷启动:标记内容置为前台归属(a->id),让本 App 首帧即不被自抑(后台常驻 App 继续自抑)。
@@ -1641,7 +1664,7 @@ static int launch_app_chain(struct app *a, const char *chain)
* watchdog and waitpid — so an exec failure never leaves an orphan watchdog
* and never masquerades as "app ran and exited". */
int execpipe[2];
if (pipe(execpipe) < 0) { logmsg("exec pipe failed: %s", strerror(errno)); return -1; }
if (pipe(execpipe) < 0) { logmsg("exec pipe failed: %s", strerror(errno)); return launch_fail(a, "exec pipe failed: %s", strerror(errno)); }
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
/* AMSD_DESIGN §5:stdout/stderr 管道捕获(logcat 思路)。fork 前建管道;子进程 dup2 到
@@ -1661,7 +1684,7 @@ static int launch_app_chain(struct app *a, const char *chain)
close(execpipe[0]); close(execpipe[1]);
if (out_pipe[0] >= 0) close(out_pipe[0]);
if (out_pipe[1] >= 0) close(out_pipe[1]);
logmsg("fork failed: %s", strerror(errno)); return -1;
logmsg("fork failed: %s", strerror(errno)); return launch_fail(a, "fork failed: %s", strerror(errno));
}
if (pid == 0) {
/* child: exec the app. M2.12 注入 MEOW_LOG_DIR + MEOW_DATA_DIR/MEOW_CACHE_DIR
@@ -1850,7 +1873,7 @@ static int launch_app_chain(struct app *a, const char *chain)
int st = 0;
reap_wait(pid, &st); /* reap the failed child(有界) */
if (out_fd >= 0) close(out_fd);
return -1;
return launch_fail(a, "exec failed: %s (errno %d)", strerror(exe_err), exe_err);
}
} /* end if (!resumed):冷启动分支(prepare/fork/exec/确认) */
+50 -32
View File
@@ -2,7 +2,7 @@
*
* 容器:sub_page_t 脚手架(subpage.c,与 wifi_page/bt_page 同款)。顶栏=meow_topappbar:
* 返回箭头 + 标题「后台管理」+ **顶栏标题栏右侧「一键清除后台」按钮**(sub_page_t.top_action,
* act=bgm_top_action → bgmgr_kill_all,**不杀框架/当前前台/非后台**,经 bgmgr_can_kill 门控)。
* act=bgm_top_action → bgmgr_kill_all,**不杀受保护/当前前台/非后台**,经 bgmgr_can_kill 门控)。
* 列表(live=1 约 2s 重扫,实时增删):
* - 每后台 App 一行 SUB_ROW:label=名字,sub=「CPU x% · yMB · 状态」,**行最右一个「杀」按钮**
* (sub_page_t.row_draw 行自绘 + row_tap 自定义命中:命中按钮区=kill 该 App;行体不响应)。
@@ -38,8 +38,9 @@ typedef struct {
int napps;
bgmgr_row_t rows[MEOW_BGMR_ROW_MAX];
int n;
char toast[64];
long toast_ms;
/* 行副文本存储:sub_row_t.sub 被 SDK 行池/自绘在整页会话内持续读取,
* 不能指向 bgm_rows 的栈帧(曾用函数内 char sub[80] → 悬垂,页面副标题为乱码)。 */
char subs[MEOW_BGMR_ROW_MAX][80];
} bgm_ctx_t;
/* 找 struct app(launcher 应用表)by id */
@@ -54,36 +55,44 @@ static struct app *bgm_app_find(struct app *apps, int napps, const char *app_id)
/* ---- meow_bgmgr ops 注入(UI 侧真实实现)---- */
static int bgm_app_meta(void *ctx, const char *app_id, pid_t pid,
char *name, int nl, char *icon, int il,
char *framework, int *is_fg)
char *protected_app, int *is_fg)
{
(void)pid;
bgm_ctx_t *b = ctx;
struct app *a = bgm_app_find(b->apps, b->napps, app_id);
*framework = 0;
*is_fg = 0;
*protected_app = 0;
*is_fg = 0;
if (!a) return -1; /* 未注册 → name 回退 app_id */
if (a->name[0]) snprintf(name, nl, "%s", a->name);
if (icon && il > 0) snprintf(icon, il, "%s", a->icon[0] ? a->icon : "");
*framework = (a->framework == '1') ? '1' : 0;
/* 刻意**不**用 a->framework:app.json 的 framework=1 意为「用 meow_ui.a 的 SDK 框架 App」
* (docs/APP_DEV_GUIDE.md §9,推荐且现有 App 全写 1),不是「系统 App」。曾把它当「禁止 kill」
* 门控 → 后台页所有行的「杀」按钮被置灰、点了不杀也不留日志(2026-11-04 真机 dev.player.ncm)。
* launcher 自身组件(状态栏/设置页/子页…)不在 App 表也不在后台表,无需此位保护。 */
return 0;
}
static int bgm_enumerate(void *ctx, bgmgr_row_t *rows, int max)
{
(void)ctx;
int n = meow_bg_count();
if (n > max) n = max;
for (int i = 0; i < n; i++) {
/* 逐槽扫描并**压缩**(g_bg 可能有空洞),跳过已发出终止的条目:
* meow_bg_terminate 只置 term_at_ms 发 SIGTERM,真正的 reap/摘表在 meow_bg_tick(launcher
* 主循环),它在本页 meow_subpage_run 阻塞期间**不跑**——若不在此过滤,kill/清后旧行会一直
* 残留(pid 已死/0 CPU),页面上看就是「点不动」。过滤后 kill/一键清当下即从列表消失(可见反馈)。 */
int k = 0;
for (int i = 0; i < MEOW_BG_MAX && k < max; i++) {
const meow_bg_entry_t *e = meow_bg_at(i);
if (!e || !e->used || !e->app_id[0]) continue;
memset(&rows[i], 0, sizeof rows[i]);
snprintf(rows[i].app_id, sizeof rows[i].app_id, "%s", e->app_id);
rows[i].pid = e->pid;
rows[i].keepalive = (e->keepalive ? 1 : 0);
rows[i].on_card = (e->on_card ? 1 : 0);
rows[i].bg_since_ms = e->bg_since_ms;
if (e->term_at_ms) continue; /* 已进入终止序列(kill/宽限回收/卡拔)→ 行不再显示 */
memset(&rows[k], 0, sizeof rows[k]);
snprintf(rows[k].app_id, sizeof rows[k].app_id, "%s", e->app_id);
rows[k].pid = e->pid;
rows[k].keepalive = (e->keepalive ? 1 : 0);
rows[k].on_card = (e->on_card ? 1 : 0);
rows[k].bg_since_ms = e->bg_since_ms;
k++;
}
return n;
return k;
}
/* proc_sample:/proc/<pid>/stat(utime+stime 差分) + /proc/<pid>/status(VmRSS)。
@@ -171,12 +180,13 @@ static int bgm_rows(void *ctx, sub_row_t *rbuf, int max)
return r;
}
for (int i = 0; i < b->n && r < max; i++) {
char sub[80];
snprintf(sub, sizeof sub, "CPU %.1f%% · %s · %s", b->rows[i].pcpu,
/* 副文本写进 ctx 的持久缓冲(b->subs):sub_row_t.sub 会被 SDK 行池在整页会话内反复读取,
* 指向本函数栈帧的 buffer 会在返回后变成乱码/越界(真机实证:副标题全是乱码字节)。 */
snprintf(b->subs[i], sizeof b->subs[i], "CPU %.1f%% · %s · %s", b->rows[i].pcpu,
bgmgr_mem_str(b->rows[i].rss_kb), bgmgr_status_str(&b->rows[i]));
rbuf[r].kind = SUB_ROW;
rbuf[r].label = b->rows[i].name[0] ? b->rows[i].name : b->rows[i].app_id;
rbuf[r].sub = sub;
rbuf[r].sub = b->subs[i];
rbuf[r].act = i; /* row_tap/row_draw 用 i 定位行 */
r++;
}
@@ -226,12 +236,16 @@ static void bgm_row_draw(void *ctx, int idx, sub_row_t *r,
int ry = cy + (ch - (th + tsub + 4)) / 2; /* 内容在卡内垂直居中(行中心=卡中心) */
if (r->label) meow_ui_text(rx, ry, r->label, 20, mt->onSurface);
if (r->sub) meow_ui_text(rx, ry + th + 4, r->sub, 16, mt->onSurfaceVariant);
/* 行内杀按钮(全形=胶囊(bh/2);「杀」;error=红=杀)——P1 按钮胶囊化 */
/* 行内杀按钮(全形=胶囊(bh/2);「杀」;error=红=杀)——P1 按钮胶囊化。
* 不可杀行(受保护/前台/无进程,bgmgr_can_kill=0)→ 置灰禁点外观,避免误导(按钮可点但不可杀)。 */
int bx, by, bw, bh; bgm_btn_rect(x, y, w, h, &bx, &by, &bw, &bh);
meow_ui_draw_rect_round(bx, by, bw, bh, bh / 2, mt->error);
uint32_t btn_col = mt->error; /* 可杀:error 红 */
if (i >= 0 && i < b->n && !b->rows[i].killable)
btn_col = mt->onSurfaceVariant; /* 禁点:置灰(onSurfaceVariant) */
meow_ui_draw_rect_round(bx, by, bw, bh, bh / 2, btn_col);
int bth = meow_ui_text_height(16);
if (bth > bh) bth = bh;
meow_ui_text_center(bx + bw / 2, by + (bh - bth) / 2, "杀", 16, mt->error);
meow_ui_text_center(bx + bw / 2, by + (bh - bth) / 2, "杀", 16, btn_col);
}
/* ---- 行内自定义命中(sub_page_t.row_tap;仅按钮区 kill,行体不响应/不误杀)---- */
@@ -245,13 +259,16 @@ static int bgm_row_tap(void *ctx, int idx, sub_row_t *r, int x, int y,
int bx, by, bw, bh; bgm_btn_rect(rx, ry, rw, rh, &bx, &by, &bw, &bh);
if (x < bx || x >= bx + bw || y < by || y >= by + bh) return 0; /* 行体/按钮外 → 不杀 */
if (!bgmgr_can_kill(&b->rows[i])) {
snprintf(b->toast, sizeof b->toast, "该 App 不可杀(框架/前台)");
b->toast_ms = (long)now_ms();
/* 禁点:受保护/前台/无进程 → 按钮已置灰,不 kill(返回 1=重绘)。
* 留一行日志:「按了没反应」当年因为没有这行日志,只能靠翻 app.json 才查出是门控。 */
logmsg("bgpage: kill 被门控 app=%s pid=%d fg=%d protected=%d",
b->rows[i].app_id, (int)b->rows[i].pid, b->rows[i].is_fg,
b->rows[i].protected_app ? 1 : 0);
return 1;
}
meow_bg_terminate(b->rows[i].app_id);
snprintf(b->toast, sizeof b->toast, "已请求终止 %s", b->rows[i].app_id);
b->toast_ms = (long)now_ms();
int rc = meow_bg_terminate(b->rows[i].app_id); /* 置 term_at_ms → bgm_enumerate 下一跳行消失(可见反馈) */
if (rc != 0)
logmsg("bgpage: kill app=%s 未命中后台表 rc=%d(列表/后台表不同步)", b->rows[i].app_id, rc);
return 1;
}
@@ -270,11 +287,12 @@ static int bgm_top_action(void *ctx)
memset(&ops, 0, sizeof ops);
ops.ctx = b;
ops.enumerate = bgm_enumerate; /* 行已由 bgm_rows 建 */
/* kill 只置 term_at_ms(真退/摘表在 launcher 主循环 bgm_tick),此处由 bgm_enumerate 过滤
* term_at_ms 让清后的行当下消失(可见反馈;不可杀行本就在上/一键不杀)。 */
int k = bgmgr_kill_all(&ops, b->rows, b->n, bgm_kill_fn);
if (k > 0) snprintf(b->toast, sizeof b->toast, "已清 %d 个后台 App", k);
else snprintf(b->toast, sizeof b->toast, "无后台 App 可清");
b->toast_ms = (long)now_ms();
return 1;
if (k < b->n)
logmsg("bgpage: 一键清除 杀 %d/%d(其余为前台/无进程/受保护)", k, b->n);
return 1; /* 重绘:重扫行(已清行消失) */
}
/* ---- 入口:运行后台管理页(阻塞到返回)---- */
+1 -1
View File
@@ -26,7 +26,7 @@ SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf"
main_menu.c statusbar.c fb_compositor.c settings_page.c power_menu.c \
notif_center.c app_launcher.c audio.c backlight.c net.c about.c \
subpage.c wifi_page.c bt_page.c netmgr_ipc.c now_playing.c bg_page.c \
font_theme.c keys.c util.c meow_dep.c \
font_theme.c keys.c util.c meow_dep.c key_router.c \
meow_touch.c meow_settings.c meow_notif.c meow_media.c meow_bgplay.c \
meow_bgmgr.c \
meow_ams_start.c lock_screen.c lock_screen_ui.c lock_settings.c \
+142
View File
@@ -0,0 +1,142 @@
/* key_router.c — launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY,P1:音量键)。
*
* 本模块持有一个进程内唯一的音量键防抖/基线:
* kp_keys / kp_power(事件设备 poll 态,key_router_init 单开)
* kd_vold / kd_volu(40ms 防抖态,KEY_DEBOUNCE_MS)
* m_prev_vold / m_prev_volu(音量边沿基线)
* 各 launcher-UI 页每圈调 key_router_pump() —— 它只产「音量边沿 + 原始 held + ramp 状态」,
* 产出的边沿由「谁在跑循环」天然归属当前页;页据此决定 settings_volume_adj(±step) /
* 光标导航 / ramp 推进。这样 run_menu 的 meow_mode 分支(HibyOS overlay 音量=导航)不被破坏,
* 其余页(设置/通知中心/Now Playing/子页)用默认「音量=调音量」。
*
* 边界(§3 P1 / §4):
* - App 前台 volume_page 武装时(meow_volpage_armed())音量边沿被抑制(防御闸;launcher-UI
* 下 sink 恒空,此闸是「不改变行为」的保险)。
* - 播放键/电源键 P1 不路由(各页现状不动;播放键严禁 cmd=6)。
* - 锁屏(EVIOCGRAB)/App 会话层不调用本模块 —— 边界由「调用栈里当前循环是不是 launcher-UI 页」
* 自动强加。
*/
#include "meow_launcher_internal.h"
static int g_inited = 0;
/* 进程唯一事件设备 poll 态(key_router_init 单开;各页另自开 kp_* 仅供播放/电源键用)。 */
static struct keypoll kp_power, kp_keys;
/* 进程唯一音量键防抖态 + 边沿基线。 */
static struct kd_state kd_vold, kd_volu;
static int m_prev_vold = 0, m_prev_volu = 0;
/* 当前活动页哨兵(key_router_page_enter/exit 登记;与 sdk meow_ui_activity 交叉校验用)。 */
static int g_page_sentinel = 0;
void key_router_init(void)
{
if (g_inited) return;
g_inited = 1;
memset(&kp_power, 0, sizeof kp_power);
memset(&kp_keys, 0, sizeof kp_keys);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
m_prev_vold = 0; m_prev_volu = 0;
kp_open(&kp_power, DEV_POWER);
kp_open(&kp_keys, DEV_KEYS);
/* 基线对齐当前 raw:进入时已按住的音量键不产假边沿。 */
key_router_rebase();
}
void key_router_rebase(void)
{
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
if (!g_inited) return;
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
}
key_evt_t key_router_pump(void)
{
key_evt_t ev;
memset(&ev, 0, sizeof ev);
if (!g_inited) key_router_init();
/* 设备晚就绪兜底:init 时 open 失败则此处每圈重试(open 失败快速返回,无阻塞)。 */
if (!kp_keys.ok) kp_open(&kp_keys, DEV_KEYS);
if (!kp_power.ok) kp_open(&kp_power, DEV_POWER);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
int vold = 0, volu = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
}
/* 原始(未防抖)值:长按 ramp held 用(松开立即停止;同 run_menu/now_playing 现状)。 */
ev.vol_held_dn = vold;
ev.vol_held_up = volu;
(void)kd_tick(&kd_vold, vold);
(void)kd_tick(&kd_volu, volu);
vold = kd_vold.stable; volu = kd_volu.stable;
ev.vol_st_dn = vold; ev.vol_st_up = volu;
if (meow_volpage_armed()) {
/* 防御闸:App 前台 volume_page 武装时(launcher-UI 下恒空)不放行音量边沿。
* 音量键在那态下由 app_launcher 的 meow_volpage_key 消费(翻页),不落系统调音。 */
ev.vol_dn_edge = 0;
ev.vol_up_edge = 0;
} else {
ev.vol_dn_edge = (vold && !m_prev_vold);
ev.vol_up_edge = (volu && !m_prev_volu);
}
ev.ramping = (vol_ramp_dir != 0);
m_prev_vold = vold;
m_prev_volu = volu;
return ev;
}
void key_router_default_volume(const key_evt_t *ev, unsigned long long *last_input)
{
if (ev->vol_dn_edge) {
settings_volume_adj(-settings_volume_step);
if (last_input) *last_input = now_ms();
logmsg("key_router: volume DOWN <- step=%d", settings_volume_step);
}
if (ev->vol_up_edge) {
settings_volume_adj(+settings_volume_step);
if (last_input) *last_input = now_ms();
logmsg("key_router: volume UP <- step=%d", settings_volume_step);
}
/* 在 settings_volume_adj 之后重查 vol_ramp_dir:同圈「起 ramp」即立刻迈第一步(同现状
* 各页「settings_volume_adj 后 if(vol_ramp_dir) vol_ramp_tick」),且长按续爬随每圈推进。 */
if (vol_ramp_dir != 0) {
int held = (vol_ramp_dir > 0) ? ev->vol_held_up : ev->vol_held_dn;
(void)vol_ramp_tick(held);
}
}
void key_router_page_enter(int sentinel)
{
g_page_sentinel = sentinel;
/* 与 sdk 活动栈交叉校验:哨兵非 0 且栈顶不一致 → 记录(不中止,防宿主/真机栈深度差异)。 */
if (sentinel) {
int act = meow_ui_activity_active();
if (act && act != sentinel)
logmsg("key_router: page_enter sentinel=%#x but active=%#x", sentinel, act);
}
}
void key_router_page_exit(int sentinel)
{
if (g_page_sentinel == sentinel) g_page_sentinel = 0;
/* 页退出即重播基线(§6 嵌套返回风险项:返回瞬间不产假边沿)。 */
key_router_rebase();
}
+65
View File
@@ -0,0 +1,65 @@
/* key_router.h — launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY,P1:音量键)。
*
* 设计(devdocs/plan/KEY_ROUTER_UNIFY.md §2.5 方案 C / §3 P1):
* - 进程内**唯一**的 kd_*(防抖态)+ m_prev_*(边沿基线)+ kp_*(事件设备 poll 态),
* 把「读边沿 + 防抖 + 长按 held + ramp 推进」这层从 5 个会话(run_menu /
* run_settings_page / run_notif_center / run_now_playing / meow_subpage_run)
* 收敛到一处。
* - 每个 launcher-UI 页在其 for(;;) 每圈调一次 key_router_pump(),得到「防抖后的
* 音量边沿 + 原始 held + ramp 状态」;**真正的动作**(音量边沿 →
* settings_volume_adj(±step),还是光标导航)由调用页决定 —— 这样 run_menu 的
* meow_mode 分支(HibyOS overlay:音量=导航;MeowOS:音量=调音量)得以保留。
* - 非导航页可经 key_router_default_volume()(内含 settings_volume_adj + vol_ramp_tick +
* last_input 复位)直接用;run_menu 走自己的 meow_mode 分支。
*
* 范围与边界(§3 P1,勿越界):
* - 本模块只收敛**音量键**(KEY_VOLUMEDOWN/KEY_VOLUMEUP)。播放键/电源键 P1 不路由,
* 维持各页现状(播放键严禁改成 cmd=6;电源键单击/菜单/8s 状态机不动)。
* - App 前台 volume_page 武装时(meow_volpage_armed())音量边沿被抑制(防御闸)。
* - 锁屏层(EVIOCGRAB)/App 会话层从不调用 key_router_pump,天然退出本路由。
*/
#ifndef MEOW_KEY_ROUTER_H
#define MEOW_KEY_ROUTER_H
/* 一框音量边沿/held/ramp 输出。P1 只填音量;播放/电源字段后续期(P2/P3)再扩展。
* 字段语义:
* vol_dn_edge / vol_up_edge —— 防抖后**稳定按下边沿**(= stable ∧ ¬上一 stable;
* 1=本圈新按下)。这是「音量边沿」的唯一权威判定。
* vol_held_dn / vol_held_up —— **原始(未防抖)**按住态,ramp 长按 held 用
* (松开立即停止,同 run_menu/now_playing 现状)。
* vol_st_dn / vol_st_up —— **防抖稳定**按住态(wake_suppress「全键抬起」判定用)。
* ramping —— vol_ramp_dir != 0(线性渐变是否在跑;repaint 用)。
*/
typedef struct {
int vol_dn_edge;
int vol_up_edge;
int vol_held_dn;
int vol_held_up;
int vol_st_dn;
int vol_st_up;
int ramping;
} key_evt_t;
/* 进程级单开 kp_*(事件设备)+ 初始化防抖态/基线(幂等,可安全重复调用)。 */
void key_router_init(void);
/* 每页 for(;;) 每圈调用一次:poll 两设备 → 防抖音量键 → 产边沿/held/ramp。
* 不做 settings_volume_adj、不驱动 vol_ramp_tick、不复位 last_input —— 皆由调用页决定
* (见 key_router_default_volume 与各页自己的分支)。 */
key_evt_t key_router_pump(void);
/* 模态/菜单/嵌套会话返回/唤醒后重播基线:kd_* 清零 + m_prev_* 对齐当前 raw
* (对齐现状各页「memset(kd_*) + kp_poll + m_prev_*=当前 raw」的语义,防假边沿)。
* 各页在原本做该重播处调用。 */
void key_router_rebase(void);
/* 页进入/退出登记(记录当前哨兵;与 sdk meow_ui_activity_active 交叉校验)。 */
void key_router_page_enter(int sentinel);
void key_router_page_exit(int sentinel);
/* 默认「音量键=调音量」页回调(非导航页直接用):音量边沿 → settings_volume_adj(±step);
* 若有 ramp 在爬 → vol_ramp_tick(held);边沿 → 复位 *last_input。
* run_menu 不走本函数(自行 meow_mode 分支)。 */
void key_router_default_volume(const key_evt_t *ev, unsigned long long *last_input);
#endif /* MEOW_KEY_ROUTER_H */
+41 -59
View File
@@ -16,11 +16,14 @@ int menu_opened_blank = 0;
/* launch_app/launch_activity 返回 -1 时的 toast:缺二进制依赖(app.json depends)→ 「缺依赖: <name>」;
* 其余启动失败沿用「CANNOT START: <name>」。原因由 meow_dep.c 的 meow_dep_block_reason() 提供。 */
* 其余启动失败优先显示具体失败原因(app_launcher.c 的 launch_fail_reason():fork OOM / exec 失败 /
* 路径不存在等)→ 「CANNOT START: <name>(<reason>)」;无具体原因则回退「CANNOT START: <name>」。 */
static void launch_fail_toast(char *toast, size_t n, const char *name)
{
const char *reason = launch_fail_reason(); /* M6.40:具体失败原因(成功/未失败时为 "") */
const char *bd = meow_dep_block_reason();
if (bd && bd[0]) snprintf(toast, n, meow_str_zh("menu.missing_dep", "缺依赖: %s"), bd);
else if (reason && reason[0]) snprintf(toast, n, "CANNOT START: %s(%s)", name, reason);
else snprintf(toast, n, "CANNOT START: %s", name);
}
@@ -1121,7 +1124,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
int cursor = 0;
unsigned long long t0 = now_ms();
int toast_ms = 0;
char toast[64] = "";
char toast[256] = ""; /* M6.40:失败 toast 带具体原因(CANNOT START: <name>(<reason>))需更大缓冲 */
int want_exit = 0;
/* M2.17 超时自动息屏:任何输入重置 last_input;空闲 > screen_off_timeout → meow_panel_off()。
* meow_auto_slept 记录「自动息屏」态(手动电源息屏不置位),任意输入唤醒 + meow_repaint_surface。 */
@@ -1211,10 +1214,11 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER);
kp_open(&kp_keys, DEV_KEYS);
/* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动 */
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
/* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动。
* 音量键防抖/基线已上收 key_router(进程唯一);本页只保留播放/电源键自家防抖。 */
struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
key_router_rebase(); /* 进入主菜单:音量基线对齐当前 raw(防残留边沿) */
logmsg("menu open: napps=%d state=%d cursor=%d",
napps, state, cursor);
@@ -1223,10 +1227,8 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* when the menu opens (e.g. the hotkey release tail) do not fire
* edges; kp.prev cannot be used for this because kp_poll() syncs it
* to cur before we compare */
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
int m_prev_play = 0, m_prev_pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
}
@@ -1430,26 +1432,20 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* does not affect it) — the launcher must NOT touch brightness/panel
* or exit the menu on it. Exit is only via the BACK TO PLAYER touch
* row (below) or sim-exit. */
/* 音量键走 key_router(进程唯一防抖/基线,产边沿/held/ramp);播放/电源键仍本页自理。 */
key_evt_t kev = key_router_pump();
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
int vold = 0, volu = 0, play = 0, pwr = 0;
int play = 0, pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
}
if (sim_power_hold_ms > 0 && el >= sim_power_hold_ms) pwr = 1; /* sim:按住电源 */
/* 原始(未防抖)键值:长按连续(vol_ramp held)用,避免防抖延迟让单击仍连爬多档。 */
int rvold = vold, rvolu = volu, rplay = play, rpwr = pwr; /* 原始值:长按连续用 rvolu/rvold */
(void)rplay; (void)rpwr; /* play/pwr 只用稳定边沿,不需原始 held */
/* M2.16 防抖:每键值须稳定 KEY_DEBOUNCE_MS(40ms) 才认可,抗 ADC 抖动("点一下跳5-6下")。
* 稳定态 vold/volu/play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */
(void)kd_tick(&kd_vold, vold);
(void)kd_tick(&kd_volu, volu);
* 稳定态 play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */
(void)kd_tick(&kd_play, play);
(void)kd_tick(&kd_pwr, pwr);
vold = kd_vold.stable; volu = kd_volu.stable;
play = kd_play.stable; pwr = kd_pwr.stable;
/* M2.6 电源语义(MeowOS 自持面板,无播放器拔河):
* 单击(短按松开 <2s)= 息屏/亮屏;2s = 电源菜单;8s = 强制关机。 */
@@ -1494,15 +1490,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* 难唤醒:launcher 以为屏开着,电源唤醒逻辑不触发)。电源键确认取消时 raw
* 仍=按住=1,无此错配,所以只有触摸路径黑屏。此处重播 kd_* 并以当前 raw
* 对齐 m_prev_*,杜绝该假边沿(真实新按键仍正常防抖/单击)。 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
continue;
}
@@ -1527,15 +1521,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
if (lck_wake_should_lock()) {
int lr = run_lock_screen();
/* 重播按键防抖基线(阻塞期间按键可能已变化,防误判新边沿) */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
if (lr == 1)
meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */
@@ -1564,11 +1556,11 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* M2.11 E:MeowOS 下音量键 = 调系统软件音量(settings audio.volume,步进5 clamp),
* 状态栏即时显示、持久化。有播放器的覆盖态才保留音量键=菜单光标导航。 */
if (meow_mode) {
if (vold && !m_prev_vold) {
if (kev.vol_dn_edge) {
any_input = 1;
settings_volume_adj(-settings_volume_step); /* 起线性渐变(1% 爬) */
logmsg("volume DOWN <- ramp step=%d", settings_volume_step);
} else if (volu && !m_prev_volu) {
} else if (kev.vol_up_edge) {
any_input = 1;
settings_volume_adj(+settings_volume_step);
logmsg("volume UP <- ramp step=%d", settings_volume_step);
@@ -1576,22 +1568,23 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
} else {
/* M2.36 B2b:光标范围扩展为 [0..napps](napps=「系统设置」行,位于 Exit 行上方)。
* 音量下移越过最后应用 → 设置行;设置行再下移 → 回 0。应用行仍 0..napps-1。 */
if (vold && !m_prev_vold && napps > 0 && state == 0) {
if (kev.vol_dn_edge && napps > 0 && state == 0) {
any_input = 1;
cursor++;
if (cursor > napps) cursor = 0; /* napps=设置行 */
logmsg("menu cursor -> %d", cursor);
}
if (volu && !m_prev_volu && napps > 0 && state == 0) {
if (kev.vol_up_edge && napps > 0 && state == 0) {
any_input = 1;
cursor--;
if (cursor < 0) cursor = napps; /* 回绕到设置行 */
logmsg("menu cursor -> %d", cursor);
}
}
/* M2.14 线性渐变:每 tick 推进 1% 并刷新状态栏(显示爬升中的值或最终值) */
/* M2.14 线性渐变:每 tick 推进 1% 并刷新状态栏(显示爬升中的值或最终值)。
* 长按 raw held 取自 key_router(kev.vol_held_*,同现状 rvolu/rvold),松开立即停止。 */
if (meow_mode && vol_ramp_dir != 0) {
int held = (vol_ramp_dir > 0) ? rvolu : rvold; /* 长按=该方向键仍按住(用原始值:松开立即停止) */
int held = (vol_ramp_dir > 0) ? kev.vol_held_up : kev.vol_held_dn;
int going = vol_ramp_tick(held);
g_sys.vol = settings_volume_get();
paint_sb_right(); /* M2.16:只重画右侧信息格(VOL 变化),不动列表/时钟 */
@@ -1615,7 +1608,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
logmsg("play key: media-reserved (no media source)");
}
}
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play;
m_prev_play = play;
/* touch (event reads) */
if (touch_fd >= 0 && meow_screen_on) { /* 息屏时不处理触摸(goodix 已被 FBIOBLANK(4) 挂起) */
@@ -1680,15 +1673,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
click.status = NOTIF_CLICK_STATUS_NONE;
run_notif_center(apps, napps, &click, 1, t_down_x, loy);
/* 重播按键防抖基线(同原 top-swipe 路径),防面板内电源/音量边沿被 run_menu 误判 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms();
menu_arm_touch(800);
@@ -1780,15 +1771,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
click.status = NOTIF_CLICK_STATUS_NONE;
run_notif_center(apps, napps, &click, 0, 0, 0); /* ⑨ live_open=0 → 触发式自动展开(防御) */
/* 重播按键防抖基线,防面板内电源/音量边沿被 run_menu 误判(M2.25 同款)。 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms();
menu_arm_touch(800);
@@ -1832,15 +1821,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
menu_arm_touch(TAP_ARM_DELAY_MS);
run_now_playing(apps, napps);
/* 重播按键防抖基线,防播放页内电源/音量边沿被 run_menu 误判 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms();
menu_arm_touch(800);
@@ -1855,15 +1842,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
logmsg("menu: touch topbar bg-manager button");
menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护 */
run_bg_page(apps, napps);
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms();
menu_arm_touch(800);
@@ -1895,15 +1880,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
logmsg("menu: touch settings row -> settings page");
menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护:吃掉打开此行的那次触摸 */
run_settings_page();
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms(); /* 回主界面即视为输入 */
menu_arm_touch(800); /* 设置页返回后再忽略近期触摸 */
@@ -1972,15 +1955,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
/* M2.x 锁屏:自动息屏唤醒后若需锁屏 → 弹阻塞式锁屏页;解锁才重绘,息屏留 dirty */
if (lck_wake_should_lock()) {
int lr = run_lock_screen();
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
if (lr == 1)
meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */
@@ -2216,6 +2197,7 @@ int main(int argc, char **argv)
load_config(); /* hotkey_seq / hotkey_seq_gap / bg_color (also run per
* menu open so edits apply without a daemon restart) */
settings_load(); /* M2.11:读系统设置(audio.volume 等),供音量/状态栏使用 */
key_router_init(); /* M6.x:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1)单开 kp_*/
/* M2.6:启动扫描一次 TF 卡 MeowOS/fonts/<family>/<weight>/,填充「字体文件」下拉 preset。
* 无卡/无字库目录 → 仅默认字体(等价现状)。进设置页时 run_settings_page 会再扫一次(verify=0)。 */
+3 -3
View File
@@ -13,7 +13,7 @@ int bgmgr_collect(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int max)
if (n > max) n = max;
for (int i = 0; i < n; i++) {
bgmgr_row_t *r = &rows[i];
r->framework = 0;
r->protected_app = 0;
r->is_fg = 0;
r->name[0] = 0;
r->icon[0] = 0;
@@ -21,7 +21,7 @@ int bgmgr_collect(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int max)
r->rss_kb = 0;
ops->app_meta(ops->ctx, r->app_id, r->pid,
r->name, (int)sizeof r->name, r->icon, (int)sizeof r->icon,
&r->framework, &r->is_fg);
&r->protected_app, &r->is_fg);
if (!r->name[0]) snprintf(r->name, sizeof r->name, "%s", r->app_id);
if (ops->proc_sample && ops->proc_sample(ops->ctx, r->pid, &r->pcpu, &r->rss_kb) < 0) {
r->pcpu = 0.0; r->rss_kb = 0;
@@ -34,7 +34,7 @@ int bgmgr_collect(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int max)
int bgmgr_can_kill(const bgmgr_row_t *r)
{
if (!r) return 0;
if (r->framework) return 0; /* 框架/系统 App 不 kill(铁律) */
if (r->protected_app) return 0; /* 系统级受保护 App 不 kill(铁律;framework 是 SDK 标志,不在此列) */
if (r->is_fg) return 0; /* 当前前台 App 不 kill */
if (r->pid <= 0) return 0; /* 非后台/无进程 */
return 1;
+11 -6
View File
@@ -4,7 +4,10 @@
* 便于宿主单测(test_bgmgr.c)与 launcher 页面(bg_page.c)复用。同 meow_bgplay 的 ops 注入手法:
* UI 提供真实 enumerate/app_meta/proc_sample;单测用假件。**单一写者=launcher 主循环**,无锁。
*
* 铁律遵循:kill/一键清 带 !framework 门控(防误杀框架/系统 App,对齐 app_launcher.c:2088 教训);
* 铁律遵循:kill/一键清 带 !protected_app 门控(防误杀系统/受保护 App,对齐 app_launcher.c:2088 教训)。
* **收口:门控位不是 app.json 的 `framework`**——那是「用 meow_ui.a 的 SDK 框架 App」标志
* (docs/APP_DEV_GUIDE.md §9,推荐且现有 App 全写 1),拿它当「禁止 kill」会让所有后台 App 变成
* 不可杀(2026-11-04 真机:dev.player.ncm 声明 framework:1 → 后台页杀按钮全被置灰点不动)。
* 不新建 daemon;不引 GDBus;不碰 /data/recovery_all。
*/
#ifndef MEOW_BGMGR_H
@@ -23,7 +26,9 @@ typedef struct bgmgr_row {
char app_id[MEOW_APP_ID_MAX];
char name[49]; /* display name(回退 app_id) */
char icon[129]; /* 图标 path(空=无,页面用首字/占位) */
char framework; /* '1'=framework=true(看门狗/系统 App,禁止 kill) */
char protected_app; /* '1'=系统级受保护 App(禁止 kill)。**非** app.json framework;
* 目前 launcher 自身组件(状态栏/设置页…)不在 App 表/后台表里,
* 故实际恒为 0,保留为将来受保护组件的显式标记位。 */
pid_t pid; /* >0 后台进程 */
int keepalive; /* 1=声明常驻(background_keepalive,无限期不清) */
int is_fg; /* 1=当前前台 App(不 kill,标注「前台」) */
@@ -39,10 +44,10 @@ typedef struct bgmgr_ops {
/* 枚举后台常驻集:每调一次填 rows 的 app_id/pid/keepalive/on_card/bg_since_ms,
* 返回行数(0..max;<0 视 0)。UI=读 launcher meow_bg 表;单测=假表。 */
int (*enumerate)(void *ctx, bgmgr_row_t *rows, int max);
/* 按 app_id 补 App 元数据:name/icon/framework/is_fg。返回 0=找到;<0=未注册(name 回退 app_id)。 */
/* 按 app_id 补 App 元数据:name/icon/protected_app/is_fg。返回 0=找到;<0=未注册(name 回退 app_id)。 */
int (*app_meta)(void *ctx, const char *app_id, pid_t pid,
char *name, int nl, char *icon, int il,
char *framework, int *is_fg);
char *protected_app, int *is_fg);
/* 每进程 CPU/RSS 取样:返回 0=ok(写 pcpu 与 rss_kb);<0=无数据(pcpu=0/rss=0)。 */
int (*proc_sample)(void *ctx, pid_t pid, double *pcpu, long *rss_kb);
void (*log)(const char *fmt, ...); /* 可空=静默 */
@@ -53,11 +58,11 @@ typedef struct bgmgr_ops {
* 行序=enumerate 返回序(UI 端期望 bg_since_ms 升序=最久在前)。 */
int bgmgr_collect(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int max);
/* !framework 门控 + 非前台 + pid>0:返回 1=可 kill;0=禁止(框架/非后台/当前前台/无进程)。 */
/* !protected_app 门控 + 非前台 + pid>0:返回 1=可 kill;0=禁止(受保护/当前前台/无进程)。 */
int bgmgr_can_kill(const bgmgr_row_t *r);
/* 一键清:遍历 rows,bgmgr_can_kill() 的条目逐一调 kill_fn(app_id)。返回发出 kill 数。
* **不杀框架 App/当前前台/非后台**;调用方负责对返回 0 弹「无后台 App 可清」之类提示。 */
* **不杀受保护 App/当前前台/非后台**;调用方负责对返回 0 弹「无后台 App 可清」之类提示。 */
int bgmgr_kill_all(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int n,
void (*kill_fn)(const char *app_id));
+6 -3
View File
@@ -270,9 +270,11 @@ void meow_bg_tick(void)
}
}
/* 批③后台管理页:请求终止某个后台保活 App(走既有 SIGTERM→SIGKILL 真退序列)。
/* 批③后台管理页 / `am kill`:请求终止某个后台保活 App(走既有 SIGTERM→SIGKILL 真退序列)。
* 只置 term_at_ms(tick 续做 SIGTERM→TERM_GRACE_MS→SIGKILL→reap→on_bg_dead→摘表),
* 与宽限超时/卡拔回收同一条终止通道,**不在此处直接 reap**(保持单一写者 tick 收口)。 */
* 与宽限超时/卡拔回收同一条终止通道,**不在此处直接 reap**(保持单一写者 tick 收口)。
* 调用者:bg_page.c 行内「杀」/「一键清除」+ util.c MSG_AMS_KILL(`am kill`,无前台会话时)
* ——两个入口同一通道,故页面按钮与面板/CLI kill 行为对称。 */
int meow_bg_terminate(const char *app_id)
{
if (!app_id || !app_id[0]) return MEOW_ERR_INVAL;
@@ -281,7 +283,8 @@ int meow_bg_terminate(const char *app_id)
meow_bg_entry_t *e = &g_bg[i];
if (e->used && e->pid > 0 && !strcmp(e->app_id, app_id)) {
if (e->term_at_ms == 0) {
bg_log("[bgplay] 后台管理页终止后台App: %s pid=%d", app_id, (int)e->pid);
bg_log("[bgplay] 用户终止后台App: %s pid=%d(后台管理页/am kill 同通道)",
app_id, (int)e->pid);
kill(e->pid, SIGTERM);
e->term_at_ms = bg_now_ms();
}
+2 -1
View File
@@ -122,7 +122,8 @@ int meow_bg_add(const char *app_id, pid_t pid, int out_fd, int on_card, int kee
int meow_bg_take(const char *app_id, int *out_fd, pid_t *pid);
/* 该 App 是否在后台保活。 */
int meow_bg_is_backgrounded(const char *app_id);
/* 批③后台管理页:请求终止某个后台保活 App(走既有 SIGTERM→SIGKILL 真退序列)。
/* 用户终止后台保活 App(走既有 SIGTERM→SIGKILL 真退序列)。调用者:后台管理页行内「杀」/
* 「一键清除」+ launcher IPC MSG_AMS_KILL(`am kill`)——两入口同通道,页面与面板/CLI 行为对称。
* 置 e->term_at_ms=now 由 meow_bg_tick 续做 SIGTERM→TERM_GRACE_MS→SIGKILL→reap→on_bg_dead→摘表。
* 返回 0=已受理(在后台表且 pid>0);MEOW_ERR_NOENT=不在后台/无进程;MEOW_ERR_INVAL=app_id 空。 */
int meow_bg_terminate(const char *app_id);
+5 -2
View File
@@ -93,7 +93,8 @@ enum {
/* ---- amsd 控制(AMSD_DESIGN §4.2):amsd→launcher,申请拉起/终止某 App ----
* 语义:amsd 为「启动/终止」决策者 → launcher(执行器)执行。payload 见下。 */
MSG_AMS_START = 0x00A0, /* → 请求 launcher 拉起某 App(pkg + 可选 activity) */
MSG_AMS_KILL = 0x00A1, /* → 请求 launcher 终止某 App(SIGTERM 语义) */
MSG_AMS_KILL = 0x00A1, /* → 请求 launcher 终止某 App(SIGTERM 语义):后台保活表命中→就地真退序列;
* 否则有前台会话→置 pending 交会话消费;都没有→ACK -2(MEOW_ERR_NOENT) */
/* ---- audiod 独占音频服务(devdocs/plan/AUDIOD_DESIGN.md §2):0x00C0-0x00CA ----
* 语义:App 只经 SDK 客户端 API(meow_ui_audio.c)喂 PCM,audiod 独占 hw:0,0。
* 独占+抢占(后开赢);软混音已否决(MIPS32 软 SRC 伤音质,留混音器接口位)。 */
@@ -463,7 +464,9 @@ typedef struct m_ams_start {
char picker_caller[MEOW_APP_ID_MAX]; /* 32:发起方 app_id(""=系统/CLI) */
} m_ams_start_t; /* 160 B */
/* MSG_AMS_KILL →:请求 launcher 终止某 App(SIGTERM 语义,执行器=launcher)。 */
/* MSG_AMS_KILL →:请求 launcher 终止某 App(SIGTERM 语义,执行器=launcher)。
* 后台保活 App:launcher 就地走 meow_bg_terminate 真退序列(与后台管理页「杀」同一通道);
* 前台 App:置 pending 交前台会话消费;两者皆非 → ACK code=(uint32_t)MEOW_ERR_NOENT(-2)。 */
typedef struct m_ams_kill {
char app_id[MEOW_APP_ID_MAX]; /* 32 */
} m_ams_kill_t; /* 32 B */
@@ -28,6 +28,7 @@ extern "C" {
#include <sys/stat.h>
#include <sys/syscall.h> /* Bug 2:直连 reboot(2) syscall 用 */
#include <pthread.h> /* M0:IPC 线程 */
#include "key_router.h" /* M6.x:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1) */
#include <sys/eventfd.h> /* M0:IPC→主循环唤醒(EFD_NONBLOCK) */
#include <sys/socket.h> /* M0:AF_UNIX IPC */
#include <sys/un.h> /* M0:sockaddr_un */
@@ -675,6 +676,10 @@ int kp_open(struct keypoll *kp, const char *dev);
int kp_poll(struct keypoll *kp, int (*cb)(int code, int down, void *ctx),
void *ctx);
int launch_app(struct app *a);
/* M6.40:最近一次启动失败的具体原因(fork OOM / exec 失败 / 缺依赖 / 路径不存在等;
* launch 成功或未失败则 "")。launch_app/launch_activity 返回 -1 前由 app_launcher.c 填好,
* main_menu.c 的 launch_fail_toast 据以显示「CANNOT START: %s(%s)」。 */
const char *launch_fail_reason(void);
/* ---- app.json 二进制依赖(depends)解析与启动前检查(meow_dep.c)---- */
int meow_parse_depends(const char *val, struct meow_dep *deps, int max);
int meow_check_app_depends(const struct app *a, char *missing, int mlen);
@@ -843,6 +848,9 @@ int nm_wifi_saved(meow_net_t *out, int max);
int nm_wifi_scan(meow_net_t *out, int max);
void nm_wifi_scan_trigger(void);
int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen); /* MEOW_OK=成功 */
/* WiFi 设置页 bug2(2026-09-15):hidden=1 走 netmgrd connecth(scan_ssid=1 + 更长 COMPLETED 等待),
* 回退直连同步对齐;err 回填 netmgrd/直连的诚实结果文本(成功含 ip / 失败含原因)。 */
int nm_wifi_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen);
int nm_wifi_disconnect(char *err, int elen);
int nm_wifi_toggle(int on);
Binary file not shown.
+134 -38
View File
@@ -15,6 +15,14 @@
#define NETMGR_SOCK_DEFAULT "/usr/data/meow_net.sock"
/* NETMGR_UNREACH 定义在 meow_launcher_internal.h(测试/调用方共用) */
/* IPC 读超时(ms)。netmgrd 单线程 poll,connect/connecth 会阻塞 ~10s/~15s 有界等待
* wpa_state=COMPLETED(bug1),scan 内含 1.5s 触发等待(P3)——客户端 per-command 超时需
* 覆盖这些同步等待,其余读级子命令维持 1.5s 不被卡死。 */
#define NM_TMO_DFL 1500
#define NM_TMO_SCAN 5000
#define NM_TMO_CONNECT 12000
#define NM_TMO_CONNECTH 17000
/* 可达性缓存:netmgrd 不可达时每次 connect 都秒回 ECONNREFUSED,无成本;缓存约 1s 防热循环。 */
static int g_nm_ok = -1; /* -1 未知 / 0 down / 1 up */
static time_t g_nm_ok_ts = 0;
@@ -50,7 +58,7 @@ static int nm_r_all(int fd, uint8_t *b, size_t n)
return 0;
}
static int nm_xchg(int fd, uint16_t type, uint32_t req, const void *pl, uint32_t len,
uint8_t *rbuf, uint32_t rcap, uint16_t *rtype)
uint8_t *rbuf, uint32_t rcap, uint16_t *rtype, int tmo_ms)
{
uint8_t hdr[MEOW_IPC_HDR_LEN];
uint32_t magic = MEOW_IPC_MAGIC, rlen = len;
@@ -60,7 +68,7 @@ static int nm_xchg(int fd, uint16_t type, uint32_t req, const void *pl, uint32_t
if (nm_w_all(fd, hdr, sizeof hdr) < 0) return -1;
if (len && pl && nm_w_all(fd, pl, len) < 0) return -1;
struct pollfd pf = { fd, POLLIN, 0 };
int pr = poll(&pf, 1, 1500);
int pr = poll(&pf, 1, tmo_ms);
if (pr <= 0) return -1;
uint8_t rhdr[MEOW_IPC_HDR_LEN];
if (nm_r_all(fd, rhdr, sizeof rhdr) < 0) return -1;
@@ -81,15 +89,16 @@ static int nm_hello(int fd)
snprintf(h.app_id, sizeof h.app_id, "%s", "os.meow.launcher");
h.version = 1;
uint8_t rbuf[256]; uint16_t rtype = 0;
if (nm_xchg(fd, MSG_HELLO, 0x100, &h, sizeof h, rbuf, sizeof rbuf, &rtype) < 0 || rtype != MSG_ACK)
if (nm_xchg(fd, MSG_HELLO, 0x100, &h, sizeof h, rbuf, sizeof rbuf, &rtype, NM_TMO_DFL) < 0 || rtype != MSG_ACK)
return -1;
m_ack_t ak; memcpy(&ak, rbuf, sizeof ak);
return (ak.code == MEOW_OK) ? 0 : -1;
}
/* 发一条 WIFI_CTL/BT_CTL 子命令,收 RESULT。返回 netmgrd 业务 code;NETMGR_UNREACH=不可达。 */
/* 发一条 WIFI_CTL/BT_CTL 子命令,收 RESULT。返回 netmgrd 业务 code;NETMGR_UNREACH=不可达。
* tmo_ms:读超时(connect/connecth/scan 需覆盖 netmgrd 同步等待,见 NM_TMO_*)。 */
static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const char *arg,
char *text, int tlen)
char *text, int tlen, int tmo_ms)
{
m_net_cmd_t q;
memset(&q, 0, sizeof q);
@@ -100,7 +109,7 @@ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const c
if (nm_hello(fd) < 0) { close(fd); nm_cache(0); return NETMGR_UNREACH; }
uint8_t rbuf[MEOW_IPC_MAX]; uint16_t rtype = 0;
memset(rbuf, 0, sizeof rbuf);
if (nm_xchg(fd, ctl_type, 0x101, &q, sizeof q, rbuf, sizeof rbuf, &rtype) < 0 || rtype != res_type) {
if (nm_xchg(fd, ctl_type, 0x101, &q, sizeof q, rbuf, sizeof rbuf, &rtype, tmo_ms) < 0 || rtype != res_type) {
close(fd); nm_cache(0); return NETMGR_UNREACH;
}
m_net_cmd_result_t res; memcpy(&res, rbuf, sizeof res);
@@ -112,11 +121,14 @@ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const c
return (int)res.code;
}
int netmgr_wifi_tmo(const char *cmd, const char *arg, char *text, int tlen, int tmo_ms)
{ return nm_cmd(MSG_NET_WIFI_CTL, MSG_NET_WIFI_RESULT, cmd, arg, text, tlen, tmo_ms); }
int netmgr_wifi(const char *cmd, const char *arg, char *text, int tlen)
{ return nm_cmd(MSG_NET_WIFI_CTL, MSG_NET_WIFI_RESULT, cmd, arg, text, tlen); }
{ return netmgr_wifi_tmo(cmd, arg, text, tlen, NM_TMO_DFL); }
int netmgr_bt(const char *cmd, const char *arg, char *text, int tlen)
{ return nm_cmd(MSG_NET_BT_CTL, MSG_NET_BT_RESULT, cmd, arg, text, tlen); }
{ return nm_cmd(MSG_NET_BT_CTL, MSG_NET_BT_RESULT, cmd, arg, text, tlen, NM_TMO_DFL); }
int netmgr_ok(void)
{
@@ -361,7 +373,7 @@ int nm_wifi_saved(meow_net_t *out, int max)
int nm_wifi_scan(meow_net_t *out, int max)
{
char text[MEOW_IPC_MAX - 32];
int rc = netmgr_wifi("scan", NULL, text, sizeof text); /* netmgrd 内含触发+有界等待+读结果 */
int rc = netmgr_wifi_tmo("scan", NULL, text, sizeof text, NM_TMO_SCAN); /* netmgrd 内含触发+有界等待+读结果 */
if (rc == NETMGR_UNREACH) return net_wifi_scan(out, max);
if (rc != MEOW_OK) return 0;
return nm_parse_scan(text, out, max);
@@ -374,49 +386,133 @@ void nm_wifi_scan_trigger(void)
if (!netmgr_ok()) net_wifi_scan_trigger();
}
/* —— 直连回退:wpa_cli add/set/enable/select 新网络(复刻 netmgrd wifi_connect 逻辑)—— */
static int nm_direct_connect(const char *ssid, const char *psk, char *err, int elen)
/* —— 直连回退:与 netmgrd wifi_connect/connecth 对齐(bug1/bug2/bug4)。
* 匹配既有(\xNN 还原同表示)→ 新建或(命中且带新 psk)更新凭据 → hidden 补 scan_ssid=1 →
* enable+select → 有界等待 wpa_state=COMPLETED → 成功 enable_network all + save_config(校验重试);
* 失败时本次新建的网络回收,报文明示原因。仅在 netmgrd 不可达时走此路径。 */
static const char NM_WPA_PREFIX[] = "PATH=/bin:/sbin:/usr/bin:/usr/sbin wpa_cli -i wlan0";
static int nm_direct_wpa(char *out, int cap, const char *args, int tmo)
{
const char *wpa = "PATH=/bin:/sbin:/usr/bin:/usr/sbin wpa_cli -i wlan0";
char cmd[256];
snprintf(cmd, sizeof cmd, "%s add_network 2>/dev/null", wpa);
char *o = popen_read_tmo(1200, cmd);
int id = o ? atoi(o) : -1;
if (o) free(o);
if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; }
{
char *r;
snprintf(cmd, sizeof cmd, "%s set_network %d ssid '\"%s\"' 2>/dev/null", wpa, id, ssid);
if ((r = popen_read_tmo(1200, cmd))) free(r);
if (psk && psk[0]) {
snprintf(cmd, sizeof cmd, "%s set_network %d psk '\"%s\"' 2>/dev/null", wpa, id, psk);
if ((r = popen_read_tmo(1200, cmd))) free(r);
} else {
snprintf(cmd, sizeof cmd, "%s set_network %d key_mgmt NONE 2>/dev/null", wpa, id);
if ((r = popen_read_tmo(1200, cmd))) free(r);
char cmd[384];
snprintf(cmd, sizeof cmd, "%s %s 2>/dev/null", NM_WPA_PREFIX, args);
char *o = popen_read_tmo(tmo, cmd);
if (!o) { out[0] = 0; return -1; }
int l = (int)strlen(o);
if (l >= cap) l = cap - 1;
memcpy(out, o, (size_t)l); out[l] = 0;
free(o);
return l;
}
static int nm_direct_save_config(void)
{
char out[128];
if (nm_direct_wpa(out, sizeof out, "save_config", 1500) > 0 && strstr(out, "OK")) return 1;
nm_direct_wpa(out, sizeof out, "set update_config 1", 1500);
if (nm_direct_wpa(out, sizeof out, "save_config", 1500) > 0 && strstr(out, "OK")) return 1;
return 0;
}
static int nm_direct_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen)
{
char out[1024]; char args[256];
int id = -1;
if (nm_direct_wpa(out, sizeof out, "list_networks", 1500) > 0) {
char *ln = strtok(out, "\n");
while (ln) {
char *tab0 = strchr(ln, '\t');
if (tab0) {
int this_id = atoi(ln);
char *ss = tab0 + 1;
char *tab1 = strchr(ss, '\t');
if (tab1) *tab1 = 0;
char clean[128], unesc[160], norm[128];
size_t cl = strlen(ss);
if (cl >= 2 && ss[0] == '"' && ss[cl - 1] == '"') {
memcpy(clean, ss + 1, cl - 2); clean[cl - 2] = 0;
} else {
snprintf(clean, sizeof clean, "%s", ss);
}
nm_decode_ssid(clean, unesc, sizeof unesc);
char *p = unesc; while (*p == ' ' || *p == '\t') p++;
snprintf(norm, sizeof norm, "%s", p);
size_t nl = strlen(norm);
while (nl > 0 && (norm[nl - 1] == ' ' || norm[nl - 1] == '\t')) norm[--nl] = 0;
if (strcmp(norm, ssid) == 0) { id = this_id; break; }
}
ln = strtok(NULL, "\n");
}
snprintf(cmd, sizeof cmd, "%s enable_network %d 2>/dev/null", wpa, id);
if ((r = popen_read_tmo(1200, cmd))) free(r);
snprintf(cmd, sizeof cmd, "%s select_network %d 2>/dev/null", wpa, id);
if ((r = popen_read_tmo(1200, cmd))) free(r);
}
if (err) snprintf(err, elen, "connect: ssid=%s network_id=%d", ssid, id);
logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s id=%d", ssid, id);
int created = 0;
if (id < 0) {
if (nm_direct_wpa(out, sizeof out, "add_network", 1500) <= 0) {
if (err) snprintf(err, elen, "add_network 无 id");
return MEOW_ERR_INVAL;
}
id = atoi(out);
if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; }
created = 1;
snprintf(args, sizeof args, "set_network %d ssid '\"%s\"'", id, ssid);
nm_direct_wpa(out, sizeof out, args, 1500);
if (psk && psk[0]) { snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk); nm_direct_wpa(out, sizeof out, args, 1500); }
else { snprintf(args, sizeof args, "set_network %d key_mgmt NONE", id); nm_direct_wpa(out, sizeof out, args, 1500); }
} else if (psk && psk[0]) {
snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk);
nm_direct_wpa(out, sizeof out, args, 1500);
}
if (hidden) { snprintf(args, sizeof args, "set_network %d scan_ssid 1", id); nm_direct_wpa(out, sizeof out, args, 1500); }
snprintf(args, sizeof args, "enable_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500);
snprintf(args, sizeof args, "select_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500);
int wait_ms = hidden ? 15000 : 10000;
int waited = 0, ok = 0; char ip[40] = "";
for (;;) {
char st[512];
if (nm_direct_wpa(st, sizeof st, "status", 1500) > 0) {
char *ws = strstr(st, "wpa_state=");
if (ws && !strncmp(ws + 10, "COMPLETED", 9)) {
ok = 1;
char *ib = strstr(st, "ip_address=");
if (ib) { ib += 11; char *ie = ib; while (*ie && *ie != '\n' && *ie != '\r') ie++;
int il = (int)(ie - ib); if (il >= (int)sizeof ip) il = (int)sizeof ip - 1;
memcpy(ip, ib, (size_t)il); ip[il] = 0; }
break;
}
}
if (waited >= wait_ms) break;
usleep(200000);
waited += 200;
}
if (!ok) {
if (created) { snprintf(args, sizeof args, "remove_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500); nm_direct_save_config(); }
if (err) snprintf(err, elen, "连接失败:密码错误或信号不佳(超时未关联)");
logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s hidden=%d id=%d timeout", ssid, hidden, id);
return MEOW_ERR_NODEV;
}
nm_direct_wpa(out, sizeof out, "enable_network all", 1500);
int persisted = nm_direct_save_config();
if (err) snprintf(err, elen, "已连接:%s ip=%s%s", ssid, ip[0] ? ip : "-", persisted ? "" : "(记录未持久化)");
logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s hidden=%d id=%d ip=%s", ssid, hidden, id, ip[0] ? ip : "-");
return MEOW_OK;
}
int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen)
int nm_wifi_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen)
{
if (!ssid || !ssid[0]) { if (err) snprintf(err, elen, "ssid 缺失"); return MEOW_ERR_INVAL; }
char arg[132];
snprintf(arg, sizeof arg, "%s,%s", ssid, psk ? psk : ""); /* connect 带空 psk → netmgrd 复用既有网络 */
snprintf(arg, sizeof arg, "%s,%s", ssid, psk ? psk : ""); /* 带空 psk → netmgrd 复用既有网络 */
char text[192];
int rc = netmgr_wifi("connect", arg, text, sizeof text);
if (rc == NETMGR_UNREACH) return nm_direct_connect(ssid, psk, err, elen);
int rc = netmgr_wifi_tmo(hidden ? "connecth" : "connect", arg, text, sizeof text,
hidden ? NM_TMO_CONNECTH : NM_TMO_CONNECT);
if (rc == NETMGR_UNREACH) return nm_direct_connect_ex(ssid, psk, hidden, err, elen);
if (err && elen > 0) snprintf(err, elen, "%s", text);
return rc;
}
int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen)
{ return nm_wifi_connect_ex(ssid, psk, 0, err, elen); }
int nm_wifi_disconnect(char *err, int elen)
{
char text[128];
+19 -27
View File
@@ -1168,16 +1168,15 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
/* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
int m_prev_play = 0, m_prev_pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
}
key_router_rebase(); /* 进入通知中心:音量基线对齐当前 raw(防残留边沿) */
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
unsigned long long touch_cooldown_until = now_ms() + 300;
@@ -1304,12 +1303,11 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
}
if (wak) {
meow_panel_on(); meow_auto_slept = 0; wake_suppress = 1; last_act = now_ms();
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
/* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; drag_slider = 0; tlx = tly = -1;
@@ -1331,23 +1329,20 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
}
/* 按键:系统语义(APP §3.4.1 红线——不映射返回/确认) */
key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
int vold = 0, volu = 0, play = 0, pwr = 0;
int play = 0, pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
}
(void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu);
(void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr);
vold = kd_vold.stable; volu = kd_volu.stable;
play = kd_play.stable; pwr = kd_pwr.stable;
if (wake_suppress) {
if (!pwr && !vold && !volu && !play) wake_suppress = 0;
if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0;
} else {
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
if (play && !m_prev_play) {
logmsg("notif-center: play key caught (media-reserved, no exit)");
last_act = now;
@@ -1366,12 +1361,11 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
show_power_menu();
last_act = now_ms();
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_suppress_click = 1;
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; need_paint = 1;
goto ncp_keys_seeded;
@@ -1390,14 +1384,14 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
}
}
if (vol_ramp_dir != 0) {
int held = (vol_ramp_dir > 0) ? volu : vold;
int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn;
int going = vol_ramp_tick(held);
g_sys.vol = settings_volume_get();
paint_sb_right();
need_paint = 1;
if (!going) logmsg("notif-center vol ramp end -> %d", g_sys.vol);
}
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr;
m_prev_play = play; m_prev_pwr = pwr;
ncp_keys_seeded:
;
@@ -1562,15 +1556,13 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
after_modal = 1; /* 电源菜单已弹 → 整帧重绘还原 */
/* 电源菜单返回:菜单期间用户可能按电源确认/取消,键边沿可能错配,
* 同电源键长按路径(line 7480-7488)重 poll + 重采 m_prev_*,防误判「单击」→ 误切 panel。 */
/* 菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
/* 菜单期间用户的触摸经 evdev 广播也会进本 panel 的 tfd → 立即排空,
* 防返回后把「菜单里的点击」误判为 panel 内 tap/滑动。 */
+128 -17
View File
@@ -198,6 +198,54 @@ static void np_center_text(int y, const char *s, int size, uint32_t color, int m
ui_draw((W - ui_width(buf, size)) / 2, y, buf, color, size);
}
/* 左对齐 + 超宽截断(UTF-8 安全,近似按字节比例切;歌词页顶部元信息用)。 */
static void np_left_text(int x, int y, const char *s, int size, uint32_t color, int maxw)
{
if (!s) s = "";
if (ui_width(s, size) <= maxw) {
ui_draw(x, y, s, color, size);
return;
}
char buf[160];
int keep = (int)(strlen(s) * maxw / (ui_width(s, size) + 1));
if (keep < 0) keep = 0;
if (keep > (int)sizeof buf - 4) keep = (int)sizeof buf - 4;
memcpy(buf, s, (size_t)keep); buf[keep] = 0;
strcat(buf, "...");
ui_draw(x, y, buf, color, size);
}
/* 歌词页顶部元信息(两行居左,复刻 ncm 歌词页):第一行歌名(大字白)、第二行歌手(小字灰)。
* 多歌手已由源侧拼串(ncm art_name 即「A / B」),此处原样显示 + 超宽截断。
* 左边距与歌词正文一致(LYR_PAD=24);离状态栏留边距(SB_BAR_H + 20);右缘同距截断。 */
static void np_draw_lyric_title(const meow_media_session_t *s)
{
if (!s) return;
if (!s->title[0] && !s->artist[0]) return;
int pad = 24;
int y = SB_BAR_H + 20;
int maxw = fb_xres - 2 * pad;
if (s->title[0])
np_left_text(pad, y, s->title, 28, 0xFFFFFF, maxw);
if (s->artist[0])
np_left_text(pad, y + meow_font_line_height(28) + 6, s->artist, 20, 0xBBBBBB, maxw);
}
/* 歌词页强制 ncm 配色:活动行恒纯白 + 上下逐级灰阶(对齐 ncm lyr_gray_tbl
* {FFFFFF,BBBBBB,929292,808080,6A6A6A,555555}——gray[0]=0 按 cur=0xFFFFFF 自动递减即为该表)。
* 背景恒为 np_draw_lyric_page 铺的黑色半透明叠加层,故关掉 lyric_view 自带 scrim
* (scrim_alpha=0)避免双重压暗;当前行/灰阶不随主题/封面取色(保黑底对比度,夜间可读)。 */
static void np_set_lyric_ncm_colors(void)
{
if (!g_np_lyric) return;
meow_lyric_view_colors c;
memset(&c, 0, sizeof c);
c.scrim = 0x000000;
c.cur = 0xFFFFFF;
c.scrim_alpha = 0; /* 不画 scrim:由 launcher 黑底铺(单一叠加层) */
meow_lyric_view_set_colors(g_np_lyric, &c);
}
/* ==================================================================
* M7.0b Now Playing 控件化(launcher 无 meow_ui_run,手动驱动 SDK 控件):
* - 进度条 → meow_progress(线性,SDK 主题 track/fill);
@@ -346,28 +394,49 @@ static void np_draw_home_bar(void)
NP_HOME_BAR_H / 2, th->on_surface_variant);
}
/* 歌词页整页绘制:背景统一黑色半透明叠加层(用户 2026-09-04,弃封面取色/封面图半透明底,
* 保证高亮封面下歌词对比度)→ lyric_view(自绘 scrim + 歌词/空态)。 */
/* 网易云式歌词页封面背景(用户 2026-09-05):有封面 → 等比居中放大铺满内容区(cover-fit,
* 超出部分由画布裁剪,镜像 ncm plr_draw_lyrics 的 meow_ui_img_scaled 算法);无封面 → 纯黑底
* (现状回退)。本函数只铺底;压暗由调用方随后叠黑色半透明叠加层(alpha176)完成。 */
static void np_draw_lyric_cover_bg(int W, int H)
{
int x = 0, y = SB_BAR_H, cw = W, ch = H - SB_BAR_H; /* 只盖内容区(状态栏下),与播放页一致 */
if (!g_np_cover || meow_img_w(g_np_cover) <= 0 || meow_img_h(g_np_cover) <= 0) {
fill_rect(x, y, cw, ch, 0x000000); /* 无封面 → 纯黑底(回退现状) */
return;
}
int iw = meow_img_w(g_np_cover), ih = meow_img_h(g_np_cover);
long dw = cw, dh = (long)ih * cw / iw; /* cover-fit:铺满宽度,高按等比 */
if (dh < ch) { dh = ch; dw = (long)iw * ch / ih; } /* 宽度不足再铺满高度(超出裁剪) */
meow_ui_img_scaled(x + (cw - (int)dw) / 2, y + (ch - (int)dh) / 2, (int)dw, (int)dh, g_np_cover);
}
/* 歌词页整页绘制:网易云式封面背景(封面图当底 + 黑色半透明叠加层压暗,保证歌词对比度)→
* lyric_view(自绘 scrim 关闭,仅歌词/空态)→ 顶部元信息(歌名/歌手)。 */
static void np_draw_lyric_page(int redraw_sb)
{
const meow_ui_theme_t *th = s_ui_theme();
int W = fb_xres, H = fb_yres;
if (!g_np_init) np_widgets_create();
if (redraw_sb) fill_rect(0, 0, W, SB_BAR_H, th->surface);
/* 批5:封面取色背景(主→次渐变)或主题面;并注入歌词页 set_colors。
* 批6:有封面时以封面作整页背景(衬在 lyric_view 半透明 scrim 之下),取色只做附加;
* 无封面回退渐变/主题面。
/* 批5:np_cover_theme_update 取色(仅为播放页文字/按钮主题;歌词页背景不走取色,恒 ncm 白/灰)。
* 批6:有封面时以封面作整页背景(网易云式 cover-fit 铺满,见 np_draw_lyric_cover_bg),
* 无封面回退纯黑底;压暗由下方黑色半透明叠加层(alpha176)统一完成,lyric_view 关内置 scrim。
* M6.33:先 np_cover_load(artwork 空→复原/换源时重载封面)再取色——顺序不能反,
* 取色读 g_np_cover,须在封面载入后(否则歌词页在 artwork 复原后仍无封面)。 */
np_cover_load(meow_media_current());
np_cover_theme_update();
np_apply_lyric_colors();
/* 用户 2026-09-04:歌词页背景统一黑色半透明叠加层(弃封面取色/封面图半透明底)——
* 部分高亮度封面对歌词显示不友好,黑底保证对比度。歌词行颜色/布局/滚动不变。 */
/* 网易云式封面背景(用户 2026-09-05):先铺封面底(cover-fit 原图铺满),无封面回退纯黑底;
* 随后统一叠黑色半透明叠加层(alpha176)压暗,保证歌词对比度。歌词配色仍恒 ncm 白/灰
* (不随封面取色——取色仅服务播放页文字/按钮)。歌词行布局/滚动逻辑不变,只换背景。 */
np_draw_lyric_cover_bg(W, H);
np_set_lyric_ncm_colors();
/* 用户 2026-09-04:黑色半透明叠加层(alpha176)——压暗封面底,保证歌词可读;无封面时叠于
* 纯黑底之上(仍纯黑,即现状回退)。 */
meow_ui_fill_rect_alpha(0, SB_BAR_H, W, H - SB_BAR_H, 0x000000, 176);
np_lyric_refresh(meow_media_current());
if (g_np_lyric)
meow_widget_draw(meow_lyric_view_widget(g_np_lyric));
np_draw_lyric_title(meow_media_current()); /* 顶部元信息(歌名/歌手,居左,避状态栏) */
np_draw_home_bar(); /* 批②:歌词页也示 home 指示(底部上滑可回桌面) */
if (redraw_sb) draw_statusbar();
}
@@ -486,6 +555,31 @@ static int64_t np_progress_seek(int tx, int ty)
return 0;
}
/* 播放页按键动作应用(音量 + 播放切换)。语义与主界面/子页一致:
* - 音量键 = 系统音量(settings_volume_adj 内含插孔消抖 + 线性渐变);
* - 播放键 = 媒体播放/暂停切换(路由最近活跃媒体会话,与主界面同判);
* 边沿由调用方按防抖后稳定值计算(vold_edge = 稳定按下 ∧ 上一稳定为抬起)。
* 抽成独立小函数供宿主单测(np_apply_key_actions 仅依赖 settings_volume_adj/
* meow_media_playctl/now_ms/logmsg,可 -ffunction-sections 链接隔离)。 */
static void np_apply_key_actions(int vold_edge, int volu_edge, int play_edge,
const meow_media_session_t *s,
unsigned long long *last_input)
{
if (vold_edge) {
settings_volume_adj(-settings_volume_step);
if (last_input) *last_input = now_ms();
logmsg("np: volume DOWN <- ramp step=%d", settings_volume_step);
} else if (volu_edge) {
settings_volume_adj(+settings_volume_step);
if (last_input) *last_input = now_ms();
logmsg("np: volume UP <- ramp step=%d", settings_volume_step);
}
if (play_edge) {
if (s) meow_media_playctl((s->state == MEOW_MEDIA_PLAYING) ? 2u : 1u, 0);
if (last_input) *last_input = now_ms();
}
}
void run_now_playing(struct app *apps, int napps)
{
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
@@ -495,15 +589,17 @@ void run_now_playing(struct app *apps, int napps)
kp_open(&kp_keys, DEV_KEYS);
struct keypoll kp_power; /* M2.92:NP 页接管电源键(修「页内电源键无反应」) */
kp_open(&kp_power, DEV_POWER);
struct kd_state kd_play;
struct kd_state kd_play; /* 音量键防抖/基线上收 key_router(进程唯一) */
memset(&kd_play, 0, sizeof kd_play);
/* 读播放键按住态(用于边沿) */
int m_prev_play = 0;
for (int w = 0; w < KEYBUF_WORDS; w++)
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
}
int np_prev_power = 0; /* 电源键边沿跟踪 */
for (int w = 0; w < KEYBUF_WORDS; w++)
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) np_prev_power = 1;
key_router_rebase(); /* 进入 Now Playing:音量基线对齐当前 raw(防残留边沿) */
unsigned long long np_last_input = now_ms(); /* 自动息屏计时(M2.92:NP 页也遵守 display.screen_off_timeout) */
int td = 0, td_ok = 0, tdx = -1, tdy = -1, tlx = -1, tly = -1;
@@ -521,6 +617,7 @@ void run_now_playing(struct app *apps, int napps)
* 控件 owner 一直钉哨兵,命中要走 meow_widget_hit 的
* owner==active 闸门——漏 push 则 active()!=0x51,
* 三钮 meow_widget_hit 恒 false(「播放页三键无效」根因)。 */
key_router_page_enter(NP_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
g_np_page = NP_PAGE_PLAY; /* 批4:每次进入 Now Playing 从播放页起(上次退出页不残留) */
g_np_lyric_last_valid = -1; /* 批4:重置歌词「是否有词」过渡态,防首帧多清一次 */
g_np_lyric_seq = 0xFFFFFFFFu; /* 批4:强制首次进歌词页重解析(防跨进入残留缓存) */
@@ -631,17 +728,30 @@ void run_now_playing(struct app *apps, int napps)
}
}
/* 播放键边沿 → 播放/暂停切换 */
/* 播放键/音量键边沿 → 媒体播放/暂停切换 + 系统音量(与主界面/子页一致)。
* 修复:run_now_playing 原先只接播放/电源键,未接音量键 → 播放页/歌词页(同一循环,
* 与页无关)音量键无反应。 */
key_evt_t kev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
kp_poll(&kp_keys, NULL, NULL);
int play = 0;
for (int w = 0; w < KEYBUF_WORDS; w++)
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (play && !m_prev_play) {
const meow_media_session_t *s = meow_media_current();
if (s) meow_media_playctl((s->state == MEOW_MEDIA_PLAYING) ? 2u : 1u, 0);
np_last_input = now_ms();
}
(void)kd_tick(&kd_play, play);
play = kd_play.stable;
/* 音量键/播放键边沿 → 动作(与主界面/子页同语义;见 np_apply_key_actions) */
np_apply_key_actions(kev.vol_dn_edge, kev.vol_up_edge,
play && !m_prev_play, meow_media_current(), &np_last_input);
m_prev_play = play;
/* 音量线性渐变(按住长爬;与主界面/子页同语义,状态栏即时刷新)。
* Now Playing 用 raw held(kev.vol_held_*,同现状 rvold/rvolu)。 */
if (vol_ramp_dir != 0) {
int held = (vol_ramp_dir > 0) ? kev.vol_held_up : kev.vol_held_dn;
int going = vol_ramp_tick(held);
g_sys.vol = settings_volume_get();
paint_sb_right();
if (!going) logmsg("np: volume ramp end -> %d", g_sys.vol);
}
/* M2.92:NP 页电源键接管——单击边沿 = 息屏/亮屏(与主界面语义一致;
* 修「NP 页电源键无反应」。3s 电源菜单仍由 watchdog→/tmp 标记→主循环消费,
@@ -714,6 +824,7 @@ void run_now_playing(struct app *apps, int napps)
usleep(10000);
}
meow_ui_activity_pop(); /* M7.0b:退出 Now Playing,回主 activity(控件 owner 仍钉哨兵,复用) */
key_router_page_exit(NP_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
if (tfd >= 0) close(tfd);
kp_close(&kp_keys);
kp_close(&kp_power);
+24 -38
View File
@@ -1315,16 +1315,15 @@ void run_settings_page(void)
struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
/* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
int m_prev_play = 0, m_prev_pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
}
key_router_rebase(); /* 进入设置页:音量基线对齐当前 raw(防残留边沿) */
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
unsigned long long touch_cooldown_until = now_ms() + 300;
@@ -1358,6 +1357,7 @@ void run_settings_page(void)
logmsg("settings entry: net toggle-sync+kick %llums", now_ms() - t_sets);
if (!g_set_init) set_widgets_create(); /* M7.2:lazy 建 SDK 控件集(一次,跨次进入复用) */
meow_ui_activity_push(SET_SENTINEL_ACT); /* 控件 owner 闸门打开(hit/按压态生效) */
key_router_page_enter(SET_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
set_paint_page(sel);
meow_dirty_full(); meow_ui_flush();
logmsg("settings entry: page shown in %llums", now_ms() - t_sets);
@@ -1396,13 +1396,12 @@ void run_settings_page(void)
meow_auto_slept = 0;
wake_suppress = 1;
last_act = now_ms();
/* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸 */
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
/* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸。
* 音量防抖/基线上收 key_router:唤醒时重播音量基线;播放/电源仍本页自理。 */
key_router_rebase(); /* 音量:kd_vold/volu 清零 + m_prev_vold/volu=当前 raw */
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; drag = -1; scroll_drag = 0; tlx = tly = -1;
@@ -1423,28 +1422,25 @@ void run_settings_page(void)
}
/* ---- 按键 ---- */
key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
int vold = 0, volu = 0, play = 0, pwr = 0;
int play = 0, pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
}
(void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu);
(void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr);
vold = kd_vold.stable; volu = kd_volu.stable;
play = kd_play.stable; pwr = kd_pwr.stable;
if (wake_suppress) {
/* 刚被唤醒:吸收唤醒键的边沿,等所有按键完全抬起后再接管动作,
* 避免「电源唤醒→抬起→被当成电源单击返回」或音量唤醒→误导航。 */
if (!pwr && !vold && !volu && !play) wake_suppress = 0;
if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0;
} else {
/* M2.39:设置页音量键 = 调系统音量(写 DAC),不做光标移动(用户约束:
* 设置页仅触摸导航;音量键在电菜单/应用列表的系统级导航保持不动)。
* 按下边沿即起线性渐变(vol_ramp_begin),下方 vol_ramp_tick 逐 1% 爬 + 刷状态栏。 */
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
/* M2.x(KEY_CONTROL_DESIGN §2/§3)播放键在设置页同样被系统「抓住」:
* 捕获+日志+消费,不再映射为「返回/确认」(原 play 边沿=break 已删,播放键不再退出设置页)。
* 仍视为输入(重置自动息屏计时,与主界面 play 的媒体预留/any_input 一致)。 */
@@ -1480,14 +1476,11 @@ void run_settings_page(void)
pwr_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击息屏 */
/* M2.25 同款:阻塞式 show_power_menu() 期间 kd_* 冻结,m_prev_* 与稳定值可能错配,
* 重播 kd_* 并以当前 raw 对齐 m_prev_*,杜绝「菜单内触摸点取消->返回后被误判单击息屏」。 */
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase(); /* 音量:kd_vold/volu 清零 + m_prev_vold/volu=当前 raw */
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; /* 全屏 dim 覆盖过状态栏,需整帧重绘还原 */
need_paint = 1;
@@ -1514,14 +1507,14 @@ void run_settings_page(void)
}
/* M2.39:驱动音量线性渐变(长按续爬/松开即止)+ 刷新状态栏 VOL;「音量」行随 need_paint 更新 */
if (vol_ramp_dir != 0) {
int held = (vol_ramp_dir > 0) ? volu : vold;
int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn;
int going = vol_ramp_tick(held);
g_sys.vol = settings_volume_get();
paint_sb_right();
need_paint = 1;
if (!going) logmsg("settings vol ramp end -> %d", g_sys.vol);
}
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr;
m_prev_play = play; m_prev_pwr = pwr;
settings_keys_seeded:
;
@@ -1658,14 +1651,10 @@ void run_settings_page(void)
scroll_y0 = 0;
tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300;
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
key_router_rebase(); /* 音量基线重播 */
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
need_paint = 1;
} else if (r->ctrl == SC_ENUM || r->ctrl == SC_PRESET) {
@@ -1703,14 +1692,10 @@ void run_settings_page(void)
tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300;
/* 重置设置页按键防抖基线,防残边 */
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
key_router_rebase(); /* 音量基线重播 */
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
need_paint = 1;
} else if (r->ctrl == SC_SLIDER && tlx >= 0) {
@@ -1768,6 +1753,7 @@ void run_settings_page(void)
done:
meow_input_touch_set(0, 0, 0, 0); /* M7.2 防残留按压态 */
meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */
key_router_page_exit(SET_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
if (tfd >= 0) close(tfd);
kp_close(&kp_power); kp_close(&kp_keys);
logmsg("settings page closed");
+31 -40
View File
@@ -247,12 +247,11 @@ int meow_subpage_run(sub_page_t *p)
meow_auto_slept = 0;
wake_suppress = 1;
last_act = now;
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
/* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; scroll_drag = 0; tlx = tly = -1;
@@ -302,14 +301,12 @@ int meow_subpage_run(sub_page_t *p)
last_act = now_ms();
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0;
pwr_suppress_click = 1;
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1;
need_paint = 1;
@@ -456,14 +453,12 @@ int meow_subpage_run(sub_page_t *p)
scroll_base = 0; scroll_y0 = 0;
tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300;
/* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1;
need_paint = 1;
@@ -601,7 +596,10 @@ static void sp_lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx
/* ---- 顶栏标题栏右侧动作按钮矩形(批③:sub_page_t.top_action)---- */
static void sp_top_action_rect(int *x, int *y, int *w, int *h)
{
if (x) *x = 0; if (y) *y = 0; if (w) *w = 0; if (h) *h = 0;
if (x) *x = 0;
if (y) *y = 0;
if (w) *w = 0;
if (h) *h = 0;
if (!g_sp || !g_sp->top_action) return;
int tw = ui_width(g_sp->top_action, 20);
if (tw <= 0) return;
@@ -743,16 +741,15 @@ int meow_subpage_run(sub_page_t *p)
{
struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
/* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
int m_prev_play = 0, m_prev_pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
}
key_router_rebase(); /* 进入子页:音量基线对齐当前 raw(防残留边沿) */
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
unsigned long long touch_cooldown_until = now_ms() + 300;
@@ -780,6 +777,7 @@ int meow_subpage_run(sub_page_t *p)
sp_configure_page(p, top_sw_en, top_on);
meow_ui_activity_push(SUB_SENTINEL_ACT); /* 控件 owner 闸门打开(hit 生效) */
key_router_page_enter(SUB_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
sp_paint_page();
meow_dirty_full(); meow_ui_flush();
@@ -814,12 +812,11 @@ int meow_subpage_run(sub_page_t *p)
meow_auto_slept = 0;
wake_suppress = 1;
last_act = now;
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
/* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; scroll_drag = 0; drag_last_y = 0; tlx = tly = -1;
@@ -832,23 +829,20 @@ int meow_subpage_run(sub_page_t *p)
}
/* ---- 按键(与旧版逐字一致:音量=系统音量/播放=媒体预留/电源=系统级语义)---- */
key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
int vold = 0, volu = 0, play = 0, pwr = 0;
int play = 0, pwr = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
}
(void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu);
(void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr);
vold = kd_vold.stable; volu = kd_volu.stable;
play = kd_play.stable; pwr = kd_pwr.stable;
if (wake_suppress) {
if (!pwr && !vold && !volu && !play) wake_suppress = 0;
if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0;
} else {
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
if (play && !m_prev_play) { logmsg("subpage: play key caught (media-reserved)"); last_act = now; }
if (pwr && !m_prev_pwr) {
pwr_hold_at = now; pwr_menu_state = 0;
@@ -867,14 +861,12 @@ int meow_subpage_run(sub_page_t *p)
last_act = now_ms();
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0;
pwr_suppress_click = 1;
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1;
need_paint = 1;
@@ -899,13 +891,13 @@ int meow_subpage_run(sub_page_t *p)
}
}
if (vol_ramp_dir != 0) {
int held = (vol_ramp_dir > 0) ? volu : vold;
int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn;
(void)vol_ramp_tick(held);
g_sys.vol = settings_volume_get();
paint_sb_right();
need_paint = 1;
}
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr;
m_prev_play = play; m_prev_pwr = pwr;
sub_keys_seeded:
;
@@ -1021,14 +1013,12 @@ int meow_subpage_run(sub_page_t *p)
drag_last_y = 0;
tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300;
/* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr);
memset(&kd_vold, 0, sizeof kd_vold);
memset(&kd_volu, 0, sizeof kd_volu);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1;
need_paint = 1;
@@ -1087,6 +1077,7 @@ int meow_subpage_run(sub_page_t *p)
subdone:
meow_input_touch_set(0, 0, 0, 0); /* 防残留按压态 */
meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */
key_router_page_exit(SUB_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
if (tfd >= 0) close(tfd);
kp_close(&kp_power); kp_close(&kp_keys);
logmsg("subpage '%s' closed", p->title ? p->title : "");
+27 -14
View File
@@ -1,11 +1,12 @@
/* test_bgmgr.c — 批③ 后台管理页 逻辑层宿主单测(meow_bgmgr)。
*
* 验证:
* 1) bgmgr_collect:enumerate 后台表 → app_meta 补 name/icon/framework/is_fg → proc_sample 补
* 1) bgmgr_collect:enumerate 后台表 → app_meta 补 name/icon/protected_app/is_fg → proc_sample 补
* pcpu/rss_kb;行 killable 预判正确;name 回退 app_id。
* 2) bgmgr_can_kill 门控矩阵:framework=1 → 0(**框架 App 不 kill**);is_fg=1 → 0(当前前台不 kill);
* pid<=0 → 0(非后台/无进程);普通后台(非框架/非前台/pid>0)→ 1。
* 3) bgmgr_kill_all 一键清:框架 App 不杀、当前前台不杀、非后台不杀;其余逐一 kill;返回 kill 数。
* 2) bgmgr_can_kill 门控矩阵:protected_app=1 → 0(**受保护 App 不 kill**);is_fg=1 → 0(当前前台不 kill);
* pid<=0 → 0(非后台/无进程);普通后台(非受保护/非前台/pid>0)→ 1。
* 3) bgmgr_kill_all 一键清:受保护 App 不杀、当前前台不杀、非后台不杀;其余逐一 kill;返回 kill 数。
* 3b) 回归:app.json framework=1(SDK 框架 App 标志,现有 App 全写 1)**不影响**可杀性。
* 4) bgmgr_status_str / bgmgr_mem_str 文案。
* 只依赖 meow_bgmgr.c + meow_ipc.h(纯 C);宿主 gcc 直编:
* gcc -Wall -Wextra -std=gnu99 test_bgmgr.c meow_bgmgr.c -o test_bgmgr
@@ -17,7 +18,9 @@
#include "meow_bgmgr.h"
static int g_fails = 0;
static int g_checks = 0;
#define CHECK(cond, msg) do { \
g_checks++; \
if (cond) printf("PASS: %s\n", msg); \
else { printf("FAIL: %s\n", msg); g_fails++; } } while (0)
@@ -33,7 +36,7 @@ static int fake_enumerate(void *ctx, bgmgr_row_t *rows, int max)
{
(void)ctx;
if (max < 4) return 0;
/* 4 个后台行:hello=普通、reader=常驻、sysguard=framework、fgapp=当前前台 */
/* 4 个后台行:hello=普通、reader=常驻、sysguard=受保护、fgapp=当前前台 */
memset(&rows[0], 0, sizeof rows[0]);
snprintf(rows[0].app_id, sizeof rows[0].app_id, "%s", g_meta_app[0]);
rows[0].pid = 1001; rows[0].keepalive = 0; rows[0].on_card = 0; rows[0].bg_since_ms = 1000;
@@ -52,10 +55,10 @@ static int fake_enumerate(void *ctx, bgmgr_row_t *rows, int max)
return 4;
}
/* meta:hello/reader/sysguard/fgapp 各给名字;sysguard 标 framework;pid==1004 标前台。 */
/* meta:hello/reader/sysguard/fgapp 各给名字;sysguard 标受保护;pid==1004 标前台。 */
static int fake_app_meta(void *ctx, const char *app_id, pid_t pid,
char *name, int nl, char *icon, int il,
char *framework, int *is_fg)
char *protected_app, int *is_fg)
{
(void)ctx;
if (!strcmp(app_id, "os.meow.hello")) snprintf(name, nl, "播放");
@@ -64,8 +67,8 @@ static int fake_app_meta(void *ctx, const char *app_id, pid_t pid,
else if (!strcmp(app_id, "os.meow.fgapp")) snprintf(name, nl, "前台应用");
else return -1; /* 未注册 → name 回退 app_id */
snprintf(icon, il, "/tmp/icon_%s.png", app_id);
*framework = (!strcmp(app_id, "os.meow.sysguard")) ? '1' : 0;
*is_fg = (pid == 1004) ? 1 : 0; /* fgapp 标前台 */
*protected_app = (!strcmp(app_id, "os.meow.sysguard")) ? '1' : 0;
*is_fg = (pid == 1004) ? 1 : 0; /* fgapp 标前台 */
return 0;
}
@@ -112,9 +115,9 @@ int main(void)
printf("=== 1) collect ===\n");
CHECK(n == 4, "collect returns 4 rows");
CHECK(!strcmp(rows[0].name, "播放") && rows[0].pid == 1001, "row0 hello name/pid");
CHECK(rows[0].framework == 0, "row0 hello not framework");
CHECK(rows[0].protected_app == 0, "row0 hello not protected");
CHECK(!strcmp(rows[1].name, "阅读") && rows[1].keepalive == 1, "row1 reader keepalive");
CHECK(rows[2].framework == '1', "row2 sysguard framework");
CHECK(rows[2].protected_app == '1', "row2 sysguard protected");
CHECK(rows[3].is_fg == 1, "row3 fgapp is_fg");
CHECK(rows[0].pcpu > 0 && rows[0].rss_kb > 0, "row0 pcpu/rss populated");
CHECK(rows[0].killable == 1 && rows[2].killable == 0, "killable prefilled by can_kill");
@@ -132,7 +135,7 @@ int main(void)
}
printf("=== 2) can_kill gating ===\n");
CHECK(bgmgr_can_kill(&rows[2]) == 0, "framework app NOT killable");
CHECK(bgmgr_can_kill(&rows[2]) == 0, "protected app NOT killable");
CHECK(bgmgr_can_kill(&rows[3]) == 0, "fg app NOT killable (is_fg)");
CHECK(bgmgr_can_kill(&rows[0]) == 1, "normal bg app killable");
CHECK(bgmgr_can_kill(&rows[1]) == 1, "keepalive bg app killable");
@@ -155,11 +158,21 @@ int main(void)
if (!strcmp(g_killed_app[i], "os.meow.fgapp")) killed_fg = 1;
if (!strcmp(g_killed_app[i], "os.meow.hello")) killed_bg = 1;
}
CHECK(killed_fw == 0, "kill_all did NOT kill framework app");
CHECK(killed_fw == 0, "kill_all did NOT kill protected app");
CHECK(killed_fg == 0, "kill_all did NOT kill fg app");
CHECK(killed_bg == 1, "kill_all killed a real bg app");
CHECK(bgmgr_kill_all(&ops, rows, n, NULL) == 0, "kill_all NULL kill_fn -> 0");
/* 回归(2026-11-04 真机):app.json framework=1 是「SDK 框架 App」标志(现有 App 全写 1),
* **不是**受保护位。旧实现把它当门控 → dev.player.ncm 等所有后台 App 都不可杀、按钮置灰。 */
printf("=== 3b) framework(SDK) ≠ 受保护 ===\n");
{
bgmgr_row_t sdk; memset(&sdk, 0, sizeof sdk);
snprintf(sdk.app_id, sizeof sdk.app_id, "dev.player.ncm");
sdk.pid = 7520; sdk.protected_app = 0;
CHECK(bgmgr_can_kill(&sdk) == 1, "SDK 框架 App(framework=1)后台仍可杀");
}
printf("=== 4) status/mem str ===\n");
CHECK(!strcmp(bgmgr_status_str(&rows[1]), "常驻"), "status_str keepalive=常驻");
CHECK(!strcmp(bgmgr_status_str(&rows[0]), "后台"), "status_str normal=后台");
@@ -167,7 +180,7 @@ int main(void)
CHECK(!strcmp(bgmgr_mem_str(512), "512KB"), "mem_str(<1MB)=KB");
CHECK(!strcmp(bgmgr_mem_str(2048), "2.0MB"), "mem_str(>=1MB)=MB");
if (g_fails == 0) printf("\nALL PASS (%d checks)\n", 20);
if (g_fails == 0) printf("\nALL PASS (%d checks)\n", g_checks);
else printf("\n%d FAILED\n", g_fails);
return g_fails ? 1 : 0;
}
+62 -21
View File
@@ -1,14 +1,16 @@
/* test_bgpage.c — 批③ 后台管理页 UI 按钮化 宿主子测(bg_page.c 按钮命中/顶栏清除命中)。
*
* 通过 #include bg_page.c 把其 FILE-STATIC 的 bgm_rows/bgm_btn_rect/bgm_row_tap/bgm_top_action
* 平移进本 TU;桩掉 launcher 后台表(meow_bg_count/at/terminate)+ 时钟 + meow_str,
* 平移进本 TU;桩掉 launcher 后台表(meow_bg_count/at/terminate)+ 时钟 + meow_str/logmsg,
* 直接验证:
* P1 bgm_rows 行骨架:后台 App 行(act=i)+ 空态行(act=-1);name/framework 经 app_meta 正确;
* P1 bgm_rows 行骨架:后台 App 行(act=i)+ 空态行(act=-1);name 经 app_meta 正确;
* **行副文本指向 ctx 持久缓冲且扛得住栈抖动**(曾指向 bgm_rows 栈帧 → 真机副标题乱码);
* P2 bgm_btn_rect 按钮几何:行右 46×34 圆角钮(rx+rw-12-46 定位);
* P3 bgm_row_tap 行内命中:**仅按钮区 kill(bgm_terminate 一次),行体/按钮外不误杀**;框架 App
* 按钮点击 → 不 terminate(!framework 门控);空态行不响应;
* P4 bgm_top_action 顶栏清除:kill_all **不杀框架 App**(sysguard 不 terminate),杀普通后台;
* P5 门控矩阵:bgmgr_can_kill(framework/fg/pid<=0/普通)。
* P3 bgm_row_tap 行内命中:**仅按钮区 kill(bgm_terminate 一次),行体/按钮外不误杀**;
* **app.json framework=1(SDK 框架 App)照样可杀**(2026-11-04 真机回归);
* 受保护/前台行按钮点击 → 不 terminate(门控,留诊断日志);空态行不响应;
* P4 bgm_top_action 顶栏清除:kill_all 不杀受保护/前台、杀普通后台(含 framework=1 的);
* P5 门控矩阵:bgmgr_can_kill(protected_app/is_fg/pid<=0/普通)。
* 不调用行自绘 bgm_row_draw(纯逻辑),故无需桩 SDK 绘制;行自绘段经 -ffunction-sections/--gc-sections 剔除。
*
* 编译(在 patch_v2/meow_launcherd 下):
@@ -44,6 +46,16 @@ int meow_bg_terminate(const char *app_id)
}
unsigned long long now_ms(void) { return 0; }
const char *meow_str(const char *id) { (void)id; return NULL; } /* 未知 id → meow_str_zh 回退 zh */
static int g_log_n;
void logmsg(const char *fmt, ...) { (void)fmt; g_log_n++; } /* 只计数:门控/未命中应留诊断日志 */
/* 栈抖动:bgm_rows 返回后调用,用来暴露「sub 指向栈帧」的悬垂指针。 */
static void stack_clobber(void)
{
volatile char buf[256];
memset((void *)buf, 'X', sizeof buf);
buf[0] = buf[0]; /* 防优化掉 */
}
/* ---------- 测试态:launcher 应用表(app_meta 用) ---------- */
static struct app g_apps[4];
@@ -71,7 +83,9 @@ static void bg_reset(int n, const char *id0, pid_t pid0, const char *id1, pid_t
}
static int g_fails = 0;
static int g_checks = 0;
#define CHECK(cond, msg) do { \
g_checks++; \
if (cond) printf("PASS: %s\n", msg); \
else { printf("FAIL: %s\n", msg); g_fails++; } } while (0)
@@ -86,21 +100,31 @@ int main(void)
{
static bgm_ctx_t b;
memset(&b, 0, sizeof b);
b.apps = g_apps; b.napps = 2;
b.apps = g_apps; b.napps = 3;
set_app(0, "os.meow.hello", "播放", 0);
set_app(1, "os.meow.sysguard", "系统守护", '1');
set_app(1, "dev.player.ncm", "流媒体客户端", '1'); /* SDK 框架 App(真机 app.json framework:1) */
set_app(2, "os.meow.sysguard", "系统守护", 0);
static sub_row_t rows[SUB_ROWS_MAX];
printf("=== P1) bgm_rows 行骨架 ===\n");
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002);
printf("=== P1) bgm_rows 行骨架 + 副文本持久化 ===\n");
bg_reset(2, "os.meow.hello", 1001, "dev.player.ncm", 7520);
int rn = bgm_rows(&b, rows, SUB_ROWS_MAX);
CHECK(rn == 2, "2 后台 App → 2 行(无空态/无清除动作行)");
CHECK(rows[0].kind == SUB_ROW && rows[0].act == 0, "row0 act=0");
CHECK(!strcmp(rows[0].label, "播放"), "row0 label=名字(播放)");
CHECK(rows[1].kind == SUB_ROW && rows[1].act == 1, "row1 act=1");
CHECK(!strcmp(rows[1].label, "系统守护"), "row1 label=系统守护");
CHECK(!strcmp(rows[1].sub, "") || rows[1].sub[0] != '\0', "row1 sub 非空(CPU/内存/状态)");
CHECK(!strcmp(rows[1].label, "流媒体客户端"), "row1 label=流媒体客户端");
CHECK(rows[1].sub != NULL && !strncmp(rows[1].sub, "CPU ", 4), "row1 sub=CPU/内存/状态");
CHECK(b.n == 2, "bgm_ctx.n=2");
/* 悬垂指针回归:sub 必须落在 ctx 缓冲里,且栈被改写后内容不变 */
uintptr_t subp = (uintptr_t)rows[1].sub;
uintptr_t lob = (uintptr_t)(char *)&b;
uintptr_t hib = lob + sizeof b;
CHECK(subp >= lob && subp < hib, "sub 指向 ctx 持久缓冲(不是 bgm_rows 栈帧)");
char sub_before[80];
snprintf(sub_before, sizeof sub_before, "%s", rows[1].sub);
stack_clobber();
CHECK(!strcmp(rows[1].sub, sub_before), "栈抖动后 sub 内容不变(无悬垂)");
printf("=== P2) bgm_btn_rect 按钮几何(行右 46×34) ===\n");
{
@@ -111,7 +135,7 @@ int main(void)
}
printf("=== P3) bgm_row_tap 行内杀按钮命中(仅按钮区 kill,行区不误杀) ===\n");
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002);
bg_reset(2, "os.meow.hello", 1001, "dev.player.ncm", 7520);
bgm_rows(&b, rows, SUB_ROWS_MAX);
/* 按钮区命中:行(0,0,480,56),按钮 x 422-468, y 11-45 */
CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 28, 0, 0, 480, 56) == 1, "按钮点击→返回1(重绘)");
@@ -122,9 +146,19 @@ int main(void)
CHECK(bgm_row_tap(&b, 0, &rows[0], 410, 28, 0, 0, 480, 56) == 0, "按钮左侧缘(410)→不杀");
CHECK(bgm_row_tap(&b, 0, &rows[0], 475, 28, 0, 0, 480, 56) == 0, "按钮右侧(475)→不杀");
CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 60, 0, 0, 480, 56) == 0, "按钮下方(60)→不杀");
/* 框架 App:按钮命中但不 terminate(!framework 门控) */
CHECK(bgm_row_tap(&b, 1, &rows[1], 445, 28, 0, 0, 480, 56) == 1, "框架App按钮→返回1(不可杀提示)");
CHECK(term_count_app("os.meow.sysguard") == 0, "框架App按钮点击→不 terminate");
/* 回归(2026-11-04 真机「杀后台按钮按下去没反应」):dev.player.ncm 的 app.json 写 framework:1
* (= 用 SDK 框架,现有 App 全写 1),**必须照杀**——旧实现把它当受保护位,按钮置灰点不动。 */
CHECK(b.rows[1].killable == 1, "framework=1(SDK 框架)App 仍判可杀");
CHECK(bgm_row_tap(&b, 1, &rows[1], 445, 28, 0, 0, 480, 56) == 1, "framework=1 App 按钮→返回1");
CHECK(term_count_app("dev.player.ncm") == 1, "framework=1 App 按钮→真 terminate(无反应 bug 回归)");
/* 真·受保护行(未来系统组件/前台):按钮命中但不 terminate,且留诊断日志 */
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002);
bgm_rows(&b, rows, SUB_ROWS_MAX);
b.rows[1].protected_app = '1';
g_log_n = 0;
CHECK(bgm_row_tap(&b, 1, &rows[1], 445, 28, 0, 0, 480, 56) == 1, "受保护行按钮→返回1(重绘)");
CHECK(term_count_app("os.meow.sysguard") == 0, "受保护行按钮→不 terminate");
CHECK(g_log_n >= 1, "受保护行按钮→留一行诊断日志(可查「点了没反应」)");
printf("=== P3b) 空态行不响应 ===\n");
bg_reset(0, NULL, 0, NULL, 0);
@@ -133,12 +167,19 @@ int main(void)
CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 28, 0, 0, 480, 56) == 0, "空态行点击→返回0(不杀)");
g_term_calls = 0;
printf("=== P4) 顶栏清除(一键清除后台)不杀框架/前台 ===\n");
printf("=== P4) 顶栏清除(一键清除后台)不杀受保护/前台 ===\n");
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002);
bgm_rows(&b, rows, SUB_ROWS_MAX);
b.rows[1].protected_app = '1';
CHECK(bgm_top_action(&b) == 1, "顶栏清除按钮:返回 1(重绘约定)");
CHECK(term_count_app("os.meow.hello") == 1, "清除→terminate 普通后台 hello");
CHECK(term_count_app("os.meow.sysguard") == 0, "清除→不杀框架 sysguard");
CHECK(term_count_app("os.meow.sysguard") == 0, "清除→不杀受保护 sysguard");
/* 回归:framework=1 的 SDK 框架 App 也在「一键清」范围内 */
bg_reset(2, "os.meow.hello", 1001, "dev.player.ncm", 7520);
bgm_rows(&b, rows, SUB_ROWS_MAX);
g_term_calls = 0;
CHECK(bgm_top_action(&b) == 1, "framework=1 App:一键清返回 1");
CHECK(term_count_app("dev.player.ncm") == 1, "一键清→terminate framework=1 App(回归)");
/* 前台 App(is_fg)不杀:给 hello 标前台再清 */
bg_reset(1, "os.meow.hello", 1001, NULL, 0);
bgm_rows(&b, rows, SUB_ROWS_MAX);
@@ -153,12 +194,12 @@ int main(void)
{
bgmgr_row_t r; memset(&r, 0, sizeof r);
r.pid = 5; CHECK(bgmgr_can_kill(&r) == 1, "普通后台可杀");
r.framework = '1'; CHECK(bgmgr_can_kill(&r) == 0, "framework 不可杀");
r.framework = 0; r.is_fg = 1; CHECK(bgmgr_can_kill(&r) == 0, "前台不可杀");
r.protected_app = '1'; CHECK(bgmgr_can_kill(&r) == 0, "protected_app 不可杀");
r.protected_app = 0; r.is_fg = 1; CHECK(bgmgr_can_kill(&r) == 0, "前台不可杀");
r.is_fg = 0; r.pid = 0; CHECK(bgmgr_can_kill(&r) == 0, "pid<=0 不可杀");
}
if (g_fails == 0) printf("\nALL PASS (%d checks)\n", 32);
if (g_fails == 0) printf("\nALL PASS (%d checks)\n", g_checks);
else printf("\n%d FAILED\n", g_fails);
return g_fails ? 1 : 0;
}
+35 -2
View File
@@ -7,6 +7,8 @@
* should_keep=0(停播后再退出=真退);
* 3) 后台 App 崩溃照收:子进程被杀 → bg_tick WNOHANG 回收 → on_bg_dead + 清槽;
* 4) 卡拔例外:on_card=1 + card_present 假 0 → tick SIGTERM 回收(防 SIGBUS);
* 4b) meow_bg_terminate(后台管理页「杀」按钮 / `am kill` 共用通道):受理置 term_at_ms →
* tick SIGTERM 回收 + on_bg_dead;不在表/空 id 分别 NOENT/INVAL;
* 4b) 批①+宽限:未声明(keepalive=0)媒体不活跃超 MEOW_BG_GRACE_MS 回收(含恰等边界不回收);
* 声明常驻(keepalive=1)或媒体活跃(META PLAYING)超 grace 不清;
* 5) bg 表满(MEOW_BG_MAX)→ LRU 回收最久未激活的未声明槽新增成功;全为声明常驻 → MEOW_ERR_NOMEM
@@ -138,11 +140,17 @@ static int cond_svc_gone(void *ctx)
return meow_svc_find("dev.test.svc", "daemon") == NULL;
}
/* 服务测试用长命可执行:shell 脚本 exec sleep 300(svc_spawn 内部 execve 走 shebang)。 */
#define TEST_SVC_PATH "/tmp/meow_bgplay_test_svc.sh"
/* 服务测试用长命可执行:shell 脚本 exec sleep 300(svc_spawn 内部 execve 走 shebang)。
* 路径取 $TMPDIR(Termux/Android 主机没有 /tmp),缺省回退 /tmp。 */
static char g_svc_path[256];
#define TEST_SVC_PATH g_svc_path
static void write_test_svc(void)
{
const char *tmp = getenv("TMPDIR");
if (!tmp || !tmp[0]) tmp = "/tmp";
snprintf(g_svc_path, sizeof g_svc_path, "%s/meow_bgplay_test_svc.sh", tmp);
FILE *f = fopen(TEST_SVC_PATH, "w");
if (!f) { printf("FAIL: 无法创建测试脚本 %s\n", TEST_SVC_PATH); exit(1); }
fputs("#!/bin/sh\nexec sleep 300\n", f);
fclose(f);
chmod(TEST_SVC_PATH, 0755);
@@ -271,6 +279,31 @@ int main(void)
"卡拔 → 卡上后台 App SIGTERM 回收(防 SIGBUS)");
g_card = 1;
/* ---- 4b) meow_bg_terminate:后台管理页「杀」按钮 / `am kill` 共用的终止通道 ---- */
{
g_bg_dead = 0;
pid_t tk = spawn_sleeper();
CHECK(meow_bg_add("dev.test.term", tk, -1, 0, 0) == 0, "bg_add(term)");
CHECK(meow_bg_terminate("") == MEOW_ERR_INVAL, "terminate 空 app_id → INVAL");
CHECK(meow_bg_terminate("dev.test.nothere") == MEOW_ERR_NOENT, "terminate 不在后台表 → NOENT");
CHECK(meow_bg_terminate("dev.test.term") == 0, "terminate 受理(在后台表且 pid>0)");
{
int found = 0;
for (int i = 0; i < MEOW_BG_MAX; i++) {
const meow_bg_entry_t *be = meow_bg_at(i);
if (be && !strcmp(be->app_id, "dev.test.term")) {
CHECK(be->term_at_ms > 0, "terminate 置 term_at_ms(bgm_enumerate 据此隐藏行)");
found = 1;
}
}
CHECK(found == 1, "terminate 后槽仍在(等 tick 回收)");
}
pump_bg_until(cond_bg_dead, NULL);
CHECK(g_bg_dead == 1 && strcmp(g_bg_dead_app, "dev.test.term") == 0,
"terminate → tick SIGTERM 回收 + on_bg_dead 上报 amsd");
CHECK(meow_bg_count() == 0, "terminate 回收后清槽");
}
/* ---- 5) 表满:LRU 回收最久未激活的未声明槽 / 全常驻满→NOMEM / 重复 / 参数 ---- */
{
pid_t k[MEOW_BG_MAX];
@@ -0,0 +1,229 @@
/* test_key_router_vol.c — 宿主单测:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1)。
*
* 测「key_router 自身的音量编排」——它只产「音量边沿 + 原始 held + ramp 状态」,
* 由调用页决定 settings_volume_adj(±step) / 光标导航 / ramp 推进。本测用内存桩:
* - kp_poll/kp_open/kp_is_down → 注入 raw 键位矩阵到 key_router 的静态 kp_keys.cur[];
* - kd_tick → 用 keys.c 的真实现(40ms 防抖,V4 依赖);keys.c 内 kp_open/kp_poll/
* kp_is_down/meow_volpage_armed 用宏重命名避开真实 ioctl 与桩冲突;
* - settings_volume_adj/settings_volume_step/vol_ramp_dir/vol_ramp_tick → 内存桩;
* - now_ms → 假时钟(手动推进,40ms 防抖可瞬时测);
* - meow_ui_activity_* → 内存桩(V5 嵌套:真语义=纯静态数组,桩复刻)。
*
* 断言:
* V1 音量减边沿 → settings_volume_adj(-step) 恰一次 + last_input 复位;
* V2 同会话重复(同 raw 仍按住)→ 不再触发 settings_volume_adj;
* V3 长按 held → vol_ramp_tick 被驱动(vol_ramp_dir!=0,held=正确方向 raw);松开 → dir=0;
* V4 40ms 防抖:raw 在 40ms 内抖动 → 不产边沿;稳定按下 ≥40ms → 才产边沿;
* V5 嵌套返回:page_enter(0x53)→page_exit(0x53)→page_enter(0x55),基线重播后 held 键不产假边沿;
* V6 meow_volpage_armed() → 音量边沿被抑制(不下发,不调 settings_volume_adj)。
*
* 编译(在 patch_v2/meow_launcherd 下):
* gcc -Wall -Wextra -I../sdk -I. -ffunction-sections -fdata-sections \
* -Wl,--gc-sections test_key_router_vol.c -o test_key_router_vol
* ./test_key_router_vol
*/
#include <stdio.h>
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
/* ---- 假时钟 ---- */
static unsigned long long g_now = 100000;
unsigned long long now_ms(void) { return g_now; }
static void advance_ms(unsigned long long d) { g_now += d; }
/* ---- 音量机外部符号桩(key_router 依赖)---- */
int settings_volume_step = 5;
static int g_adj_count = 0, g_adj_delta = 0;
int vol_ramp_dir = 0; /* 桩全局:模拟 audio.c 的 ramp 状态 */
static int g_tick_count = 0, g_tick_held = -1;
void settings_volume_adj(int delta)
{
g_adj_count++; g_adj_delta = delta;
if (delta > 0) vol_ramp_dir = 1; else if (delta < 0) vol_ramp_dir = -1;
}
int vol_ramp_tick(int key_held)
{
g_tick_count++; g_tick_held = key_held;
if (!key_held) vol_ramp_dir = 0;
return vol_ramp_dir != 0;
}
static int g_volpage = 0; /* 桩:meow_volpage_armed */
/* ---- keys.c 内 kp_open/kp_poll/kp_is_down/meow_volpage_armed 用宏重命名,避开真实 ioctl ----
* (宏在 #include keys.c 前生效,仅重命名 keys.c 体内的调用/定义) ---- */
#define kp_open kp_open_rt_
#define kp_poll kp_poll_rt_
#define kp_is_down kp_is_down_rt_
#define meow_volpage_armed meow_volpage_armed_rt_
#include "keys.c" /* 真 kd_tick(40ms 防抖素材) */
#undef kp_open
#undef kp_poll
#undef kp_is_down
#undef meow_volpage_armed
/* host 桩声明(key_router.c 需要;恢复到正常名,与 keys.c 被更名后的定义不冲突) */
struct keypoll;
int kp_open(struct keypoll *kp, const char *dev);
int kp_poll(struct keypoll *kp, int (*cb)(int code, int down, void *ctx), void *ctx);
int kp_is_down(const struct keypoll *k, int code);
int meow_volpage_armed(void);
#include "key_router.c" /* 同 TU #include:拿到静态 kp_keys/kp_power/kd_vold/kd_volu + 全部函数 */
#include "key_router.h"
/* ---- meow_ui_activity_* 桩(复刻 sdk/meow_ui_activity.c:纯静态数组,真语义)---- */
static int g_act[32]; static int g_act_n = 0;
void meow_ui_activity_push(int id) { if (g_act_n < 32) g_act[g_act_n++] = id; }
void meow_ui_activity_pop(void) { if (g_act_n > 0) g_act_n--; }
int meow_ui_activity_active(void) { return g_act_n > 0 ? g_act[g_act_n - 1] : 0; }
/* ---- 注入 raw 到 key_router 静态 kp_keys ---- */
static void set_vol(int vold, int volu)
{
for (int w = 0; w < KEYBUF_WORDS; w++) kp_keys.cur[w] = 0;
if (vold) kp_keys.cur[KEY_VOLUMEDOWN / 32] |= (1UL << (KEY_VOLUMEDOWN % 32));
if (volu) kp_keys.cur[KEY_VOLUMEUP / 32] |= (1UL << (KEY_VOLUMEUP % 32));
}
/* ---- host 桩:kp_*(prev=cur 模拟真实 poll 的键位更新)+ meow_volpage_armed ---- */
int kp_open(struct keypoll *kp, const char *dev)
{ (void)dev; memset(kp, 0, sizeof *kp); kp->ok = 1; return 0; }
int kp_poll(struct keypoll *kp, int (*cb)(int, int, void *), void *ctx)
{
(void)cb; (void)ctx;
for (int w = 0; w < KEYBUF_WORDS; w++) kp->prev[w] = kp->cur[w];
return 0;
}
int kp_is_down(const struct keypoll *k, int code)
{ return (k->cur[code / 32] >> (code % 32)) & 1; }
int meow_volpage_armed(void) { return g_volpage; }
/* 其它全局桩(keys.c 内被更名函数引用;本测 GC 后不保留,仅满足声明一致性) */
uint32_t menu_bg = 0;
int screen_off_timeout = 0, screen_off_mode = 0, power_longpress_menu_ms = 0;
int power_forceoff_ms = 0, refresh_rate_cfg = 60, refresh_always_max = 0;
void logmsg(const char *fmt, ...) { (void)fmt; }
static int g_fails = 0;
#define CHECK(cond, name) do { \
if (cond) printf(" PASS %s\n", name); \
else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fails++; } } while (0)
static void reset_router(void)
{
set_vol(0, 0);
key_router_rebase();
g_adj_count = 0; g_adj_delta = 0; g_tick_count = 0; g_tick_held = -1;
vol_ramp_dir = 0; g_volpage = 0;
g_act_n = 0;
}
/* 按下并稳定:register(pump) → hold ≥40ms → pump(stable=1, 边沿)。 */
static key_evt_t press_settle(int vold, int volu)
{
set_vol(vold, volu);
key_router_pump(); /* 登记 raw 跳变(防抖窗口起点) */
advance_ms(50); /* ≥KEY_DEBOUNCE_MS(40) */
return key_router_pump(); /* stable→1,边沿 */
}
/* 抬起并稳定:register(pump) → → pump(stable=0)。 */
static key_evt_t release_settle(void)
{
set_vol(0, 0);
key_router_pump(); /* 登记 release */
advance_ms(50);
return key_router_pump(); /* stable→0 */
}
int main(void)
{
unsigned long long last_input = 0;
/* ---- V1 音量减边沿 → settings_volume_adj(-step) 恰一次 + last_input 复位 ---- */
reset_router();
set_vol(0, 0); advance_ms(10); key_router_pump(); /* 全抬起基线 */
last_input = 0;
key_evt_t ev = press_settle(1, 0);
CHECK(ev.vol_dn_edge == 1, "V1 vol-down edge fires");
key_router_default_volume(&ev, &last_input);
CHECK(g_adj_count == 1 && g_adj_delta == -settings_volume_step,
"V1 vol-down -> settings_volume_adj(-step) exactly once");
CHECK(last_input == g_now, "V1 last_input reset on edge");
/* ---- V2 同会话重复(同 raw 仍按住)→ 不再触发 ---- */
reset_router();
set_vol(0, 0); advance_ms(10); key_router_pump();
ev = press_settle(1, 0);
CHECK(ev.vol_dn_edge == 1, "V2a press edge fires");
ev = key_router_pump(); /* 同 raw 仍按住,无新边沿 */
CHECK(ev.vol_dn_edge == 0, "V2 held: no re-trigger edge");
key_router_default_volume(&ev, &last_input);
CHECK(g_adj_count == 0, "V2 held: no double settings_volume_adj");
/* ---- V3 长按 held → vol_ramp_tick 被驱动(held=正确方向 raw);松开 → dir=0 ---- */
reset_router();
set_vol(0, 0); advance_ms(10); key_router_pump(); last_input = 0;
ev = press_settle(1, 0); /* 按下(vol down)并转稳定 */
key_router_default_volume(&ev, &last_input);
CHECK(g_tick_count == 1 && g_tick_held == ev.vol_held_dn && vol_ramp_dir != 0,
"V3 hold vol-down -> ramp driven with held=raw down");
advance_ms(50);
ev = key_router_pump(); /* 继续长按(raw=1 仍按住) */
key_router_default_volume(&ev, &last_input);
CHECK(g_tick_count == 2 && g_tick_held == 1, "V3 long-press continues ramp");
ev = release_settle(); /* 松开:raw held 立即为 0 */
key_router_default_volume(&ev, &last_input);
CHECK(vol_ramp_dir == 0, "V3 release -> ramp dir 0 (stops)");
/* ---- V4 40ms 防抖:raw 在 40ms 内抖动不产边沿;稳定 ≥40ms 才产 ---- */
reset_router();
set_vol(0, 0); advance_ms(10); key_router_pump();
set_vol(1, 0); advance_ms(20); key_router_pump(); /* 按下未满 40ms */
set_vol(0, 0); advance_ms(20); key_router_pump(); /* 40ms 内又抬起 */
ev = key_router_pump();
CHECK(ev.vol_dn_edge == 0, "V4 <40ms jitter -> no edge");
ev = press_settle(1, 0); /* 稳定按下 ≥40ms */
CHECK(ev.vol_dn_edge == 1, "V4 stable >=40ms press -> edge");
ev = release_settle(); /* 抬起稳定 */
CHECK(ev.vol_dn_edge == 0 && ev.vol_up_edge == 0, "V4 released -> no residual edge");
ev = press_settle(0, 1); /* 稳定按音量加 */
CHECK(ev.vol_up_edge == 1, "V4 vol-up stable press -> up edge");
/* ---- V5 嵌套返回:page_enter/exit 重播基线,held 键不产假边沿 ---- */
reset_router();
meow_ui_activity_push(0x55); key_router_page_enter(0x55); /* 主菜单 */
set_vol(0, 0); advance_ms(10); key_router_pump();
ev = press_settle(1, 0);
CHECK(ev.vol_dn_edge == 1, "V5a press edge in menu");
/* 嵌套进设置页:压栈 + 登记 */
meow_ui_activity_push(0x53); key_router_page_enter(0x53);
CHECK(meow_ui_activity_active() == 0x53, "V5b settings active sentinel");
ev = release_settle(); /* 设置页内把键抬起 */
CHECK(ev.vol_dn_edge == 0, "V5c settings-up: no fresh edge (consumer)");
/* 设置页返回(模拟 run_settings_page 结束):弹栈 + 重播基线 → m_prev=当前 raw(0) */
key_router_page_exit(0x53); meow_ui_activity_pop();
key_router_page_enter(0x55);
CHECK(meow_ui_activity_active() == 0x55, "V5d menu active again");
/* 主菜单基线已重播;再次按下应为新边沿,而非残留/双发 */
ev = press_settle(1, 0);
CHECK(ev.vol_dn_edge == 1, "V5e fresh press after nest return -> single edge");
/* ---- V6 meow_volpage_armed() → 音量边沿被抑制(不下发)---- */
reset_router();
set_vol(0, 0); advance_ms(10); key_router_pump();
g_volpage = 1;
ev = press_settle(1, 0);
CHECK(ev.vol_dn_edge == 0, "V6 volpage armed -> edge suppressed");
key_router_default_volume(&ev, &last_input);
CHECK(g_adj_count == 0, "V6 volpage armed -> settings_volume_adj NOT called");
g_volpage = 0;
printf("\nRESULT: %s (%d fails)\n", g_fails ? "FAIL" : "ALL PASS", g_fails);
return g_fails ? 1 : 0;
}
+114
View File
@@ -0,0 +1,114 @@
/* test_np_keys.c — 宿主单测:Now Playing 播放页/歌词页物理键分发(Task C)。
*
* 背景(真机现象):桌面播放页与歌词页上,音量键 + 播放键「完全无反应」。
* 根因(now_playing.c):run_now_playing() 原先只接播放键(164)与电源键,未接音量键
* (114/115),导致播放页/歌词页(同一循环,与页无关)调不了音量。
*
* 本测复现 run_now_playing() 的按键动作应用(np_apply_key_actions):音量键走
* settings_volume_adj(±step)、播放键走 meow_media_playctl(按会话当前态 1=播放 2=暂停)。
* 断言:
* K1 音量减边沿 → settings_volume_adj(-step);
* K2 音量加边沿 → settings_volume_adj(+step);
* K3 播放键边沿 + 会话正在播放 → meow_media_playctl(2,0)(暂停);
* K4 播放键边沿 + 会话已暂停 → meow_media_playctl(1,0)(播放);
* K5 播放键边沿 + 无会话 → 不下发(空态不误报);
* K6 无任何边沿 → 两个回调都不触发;
* K7 音量/播放边沿都会更新 last_input(自动息屏计时复位)。
*
* 编译(在 patch_v2/meow_launcherd 下):
* gcc -Wall -Wextra -I../sdk -I. -ffunction-sections -fdata-sections \
* -Wl,--gc-sections -DMEOW_NP_KEYS_TEST \
* test_np_keys.c -o test_np_keys
* ./test_np_keys
* 说明:now_playing.c 用 #include 同 TU 平移(惯例同 test_vol_ramp_jack);-ffunction-sections
* + --gc-sections 只保留 np_apply_key_actions 及其依赖,其余(渲染/控件/绘制)被回收,
* 故仅需桩 settings_volume_adj/settings_volume_step/meow_media_playctl/now_ms/logmsg。
*/
#include "now_playing.c" /* 同 TU #include:拿到 static np_apply_key_actions */
#include <stdio.h>
#include <string.h>
static int g_fails = 0;
#define CHECK(cond, name) do { \
if (cond) printf(" PASS %s\n", name); \
else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fails++; } } while (0)
/* ---- 桩:np_apply_key_actions 依赖的 launcher 外部符号 ---- */
int settings_volume_step = 5;
static int g_adj_count = 0, g_adj_delta = 0;
void settings_volume_adj(int delta) { g_adj_count++; g_adj_delta = delta; }
static int g_playctl_count = 0;
static uint32_t g_playctl_cmd = 0;
static int64_t g_playctl_seek = 0;
int meow_media_playctl(uint32_t cmd, int64_t seek_ms)
{ g_playctl_count++; g_playctl_cmd = cmd; g_playctl_seek = seek_ms; return 0; }
static unsigned long long g_now = 100000;
unsigned long long now_ms(void) { return g_now; }
void logmsg(const char *fmt, ...) { (void)fmt; }
/* 会话(meow_media_session_t 来自 meow_media.h,含 state 字段) */
static meow_media_session_t g_s;
static void reset_callbacks(void)
{
g_adj_count = 0; g_adj_delta = 0;
g_playctl_count = 0; g_playctl_cmd = 0; g_playctl_seek = 0;
}
int main(void)
{
memset(&g_s, 0, sizeof g_s);
unsigned long long last_input = 0;
/* K1 音量减边沿 → settings_volume_adj(-step) */
reset_callbacks(); last_input = 0; g_now = 100000;
np_apply_key_actions(1, 0, 0, &g_s, &last_input);
CHECK(g_adj_count == 1 && g_adj_delta == -settings_volume_step,
"K1 vol-down edge -> settings_volume_adj(-step)");
CHECK(last_input == g_now, "K7a vol-down updates last_input");
/* K2 音量加边沿 → settings_volume_adj(+step) */
reset_callbacks(); last_input = 0; g_now = 100050;
np_apply_key_actions(0, 1, 0, &g_s, &last_input);
CHECK(g_adj_count == 1 && g_adj_delta == +settings_volume_step,
"K2 vol-up edge -> settings_volume_adj(+step)");
CHECK(last_input == g_now, "K7b vol-up updates last_input");
/* K3 播放键边沿 + 正在播放 → MSG_PLAYCTL(2,0) 暂停 */
g_s.state = MEOW_MEDIA_PLAYING;
reset_callbacks();
np_apply_key_actions(0, 0, 1, &g_s, &last_input);
CHECK(g_playctl_count == 1 && g_playctl_cmd == 2 && g_playctl_seek == 0,
"K3 play edge (playing) -> playctl pause(2)");
/* K4 播放键边沿 + 已暂停 → MSG_PLAYCTL(1,0) 播放 */
g_s.state = MEOW_MEDIA_PAUSED;
reset_callbacks();
np_apply_key_actions(0, 0, 1, &g_s, &last_input);
CHECK(g_playctl_count == 1 && g_playctl_cmd == 1 && g_playctl_seek == 0,
"K4 play edge (paused) -> playctl play(1)");
/* K5 播放键边沿 + 无会话 → 不下发(并仍复位 last_input) */
reset_callbacks(); last_input = 0; g_now = 100100;
np_apply_key_actions(0, 0, 1, NULL, &last_input);
CHECK(g_playctl_count == 0, "K5 play edge (no session) -> no playctl");
CHECK(last_input == g_now, "K7c play edge updates last_input (no session)");
/* K6 无任何边沿 → 两个回调都不触发 */
reset_callbacks(); last_input = 0;
np_apply_key_actions(0, 0, 0, &g_s, &last_input);
CHECK(g_adj_count == 0 && g_playctl_count == 0, "K6 no edge -> no callbacks");
/* K7 音量递减时播放键不同时触发(音量边沿优先,播放单独判) */
g_s.state = MEOW_MEDIA_PLAYING;
reset_callbacks();
np_apply_key_actions(1, 0, 1, &g_s, &last_input);
CHECK(g_adj_count == 1 && g_playctl_count == 1,
"K8 vol-down + play_edge both fire (independent)");
printf("\nRESULT: %s (%d fails)\n", g_fails ? "FAIL" : "ALL PASS", g_fails);
return g_fails ? 1 : 0;
}
+30 -24
View File
@@ -186,23 +186,29 @@ static void lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx)
uint32_t textcol = sel ? mt->onPrimaryContainer : mt->onSurface;
uint32_t subcol = sel ? mt->onPrimaryContainer : mt->onSurfaceVariant;
/* MD3 容器化(P0):行=surface 上 surfaceContainer 圆角卡;段头=surface 上的 primary 段标签(无卡
* 无分隔线);与 settings_page.c set_lv_draw 同构造。 */
int cx = x + SET_CARD_X;
int cw = w - 2 * SET_CARD_X;
int cy = y + 8 / 2;
int ch = SET_ITEM_H - 8;
if (r->ctrl == SC_HEADER) {
meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->surfaceContainerHigh);
int ty = y + (SET_ITEM_H - thh) / 2;
if (ty >= 0) meow_ui_text(x + 16, ty, r->label, mt->font_body, mt->primary);
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant);
if (ty >= 0) meow_ui_text(cx + 16, ty, r->label, mt->font_body, mt->primary);
return;
}
if (sel) meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->primaryContainer);
else if (press) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->primary, 26);
meow_gfx_fill_round(cx, cy, cw, ch, 12, mt->surfaceContainer);
if (sel) meow_gfx_fill_round(cx, cy, cw, ch, 12, mt->primaryContainer);
else if (press) meow_gfx_fill_round_alpha(cx, cy, cw, ch, 12, mt->primary, 26);
int ly = y + (SET_ITEM_H - thh) / 2;
int rgt = cx + cw - 16;
switch (r->ctrl) {
case SC_SWITCH: {
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
meow_widget *sw = set_sw_take();
if (sw) {
int on = 0; meow_settings_get_switch(r->key, &on);
meow_widget_set_rect(sw, x + w - 16 - 56, y + (SET_ITEM_H - 32) / 2, 56, 32);
meow_widget_set_rect(sw, rgt - 56, y + (SET_ITEM_H - 32) / 2, 56, 32);
meow_switch_set(sw, on);
meow_widget_draw(sw);
}
@@ -211,26 +217,25 @@ static void lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx)
case SC_SLIDER: {
/* 宿主测试不引入 launcher s_draw_slider/s_ui_theme:只验证行文字渲染 + 命中,track 视觉由
* 真机 s_draw_slider 呈现(settings_page.c set_lv_draw 已复用)。 */
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
char vb[32]; s_fmt_value(r, vb, sizeof vb);
meow_ui_text(x + w - 16 - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol);
meow_ui_text(rgt - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol);
break;
}
case SC_LIST: {
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
meow_ui_text(x + w - 16 - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol);
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
meow_ui_text(rgt - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol);
break;
}
default: { /* SC_ENUM / SC_PRESET */
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
char vb[40]; s_display_value(r, vb, sizeof vb);
int vx = x + w - 16 - meow_ui_text_width(vb, mt->font_body);
int vx = rgt - meow_ui_text_width(vb, mt->font_body);
meow_ui_text(vx, ly, vb, mt->font_body, subcol);
break;
}
}
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant);
if (state & MEOW_LISTVIEW_ST_DISABLED) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->onSurface, 90);
if (state & MEOW_LISTVIEW_ST_DISABLED) meow_gfx_fill_round_alpha(cx, cy, cw, ch, 12, mt->onSurface, 90);
}
static void mk_widgets(void)
@@ -247,7 +252,7 @@ static void draw_all(void)
{
const meow_theme *th = meow_theme_get();
meow_ui_fill_rect(0, 0, XRES, YRES, th->surface);
meow_ui_fill_rect(0, SET_LIST_Y, XRES, LV_H, th->surfaceVariant);
meow_ui_fill_rect(0, SET_LIST_Y, XRES, LV_H, th->surface); /* P0:内容底 surface(卡在其上抬升) */
if (lv) { g_set_sw_use = 0; meow_widget_draw(lv); }
}
@@ -305,16 +310,17 @@ int main(void)
meow_ui_activity_push(SET_SENTINEL);
const meow_theme *th = meow_theme_get();
draw_all();
/* 段头行整体填 surfaceContainerHigh(覆盖列表底 surfaceVariant)→ 对列表底背景即非背景;
* 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「段头底 vs 列表底」判渲染而非文字像素。 */
int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant);
int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant);
CHECK(hdrcnt > 0, "section header band renders (hdr=%d)", hdrcnt);
CHECK(rowcnt > 40, "row band renders (title/value/switch) cnt=%d", rowcnt);
/* P1b:开关行(ROWS[2]=最高刷新率 right switch)中心右缘区非背景 */
/* MD3 容器化(P0):段头=surface 上的 primary 段标签(无满幅 band/卡,宿主文本为 no-op →
* 段头区应回 surface);普通行=surfaceContainer 圆角卡(region_nonbg 应见大量非 surface 像素)。
* 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「卡/段头 vs 列表底」判渲染而非文字像素。 */
int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surface);
int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surface);
CHECK(hdrcnt == 0, "section header no-band(label-on-surface) nb=%d", hdrcnt);
CHECK(rowcnt > 40, "row card renders (title/value/switch) cnt=%d", rowcnt);
/* P1b:开关行(ROWS[2]=最高刷新率 right switch)中心右缘区非背景(卡内 switch) */
{
int swy = SET_LIST_Y + 2 * SET_ITEM_H + SET_ITEM_H / 2;
int swc = region_nonbg(XRES - 16 - 56, swy - 4, 56, 8, th->surfaceVariant);
int swc = region_nonbg(XRES - 16 - 56, swy - 4, 56, 8, th->surface);
CHECK(swc > 0, "switch row right tail (meow_switch) renders swc=%d", swc);
}
@@ -125,6 +125,7 @@ static void mk_widgets(void)
meow_listview_set_vsource(lv, sp_lv_count, sp_lv_bind, NULL);
meow_listview_set_item_renderer(lv, 2, 0, 1);
meow_listview_set_item_h(lv, SUB_ITEM_H);
meow_listview_set_container(lv, 1); /* MD3 容器模式(M7.1a P0):圆角卡+空隙+去分隔线 */
sw = meow_switch_create(XRES - 16 - 56, SUB_TB_Y + (SUB_TB_H - 32) / 2, 1);
meow_ui_activity_pop();
}
@@ -153,12 +154,14 @@ int main(void)
meow_ui_activity_push(SUB_SENTINEL);
const meow_theme *th = meow_theme_get();
draw_all();
/* MD3 容器模式:段头=surface 上的 primary 段标签(无满幅 band/卡,宿主文本为 no-op →
* 段头区应回 surface);普通行=surfaceContainer 圆角卡(region_nonbg 应见大量非 surface 像素)。 */
int hdrcnt = region_nonbg(2, SUB_CONT_TOP + 4, XRES - 4, SUB_ITEM_H - 8, th->surface);
int rowcnt = region_nonbg(2, SUB_CONT_TOP + SUB_ITEM_H + 4, XRES - 4, SUB_ITEM_H - 8, th->surface);
char nm[80];
snprintf(nm, sizeof nm, "section header renders (band=%d)", hdrcnt);
CHECK(hdrcnt > 0, "%s", nm);
snprintf(nm, sizeof nm, "row renders (title hdr/switch/arrow) cnt=%d", rowcnt);
snprintf(nm, sizeof nm, "section header no-band(label-on-surface) nb=%d", hdrcnt);
CHECK(hdrcnt == 0, "%s", nm);
snprintf(nm, sizeof nm, "row renders (card) cnt=%d", rowcnt);
CHECK(rowcnt > 40, "%s", nm);
/* ============ P2 listview 命中(行号 + right 操作区) ============ */
@@ -190,6 +193,7 @@ int main(void)
meow_listview_set_vsource(scroll_lv, big_count, big_bind, NULL);
meow_listview_set_item_renderer(scroll_lv, 2, 0, 1);
meow_listview_set_item_h(scroll_lv, SUB_ITEM_H);
meow_listview_set_container(scroll_lv, 1); /* 与子页一致:MD3 容器模式 */
meow_scroll *s = meow_scroll_wrap(scroll_lv, 1); /* 1=W_LISTVIEW */
CHECK(s != NULL, "meow_scroll_wrap(listview)");
int maxs = meow_listview_max_scroll(scroll_lv);
+12 -4
View File
@@ -1466,15 +1466,23 @@ static void ipc_dispatch(struct meow_ipc_msg *m)
memcpy(&k, m->payload, m->plen < (uint16_t)sizeof k ? m->plen : (uint16_t)sizeof k);
k.app_id[MEOW_APP_ID_MAX - 1] = 0;
m_ack_t a;
if (g_app_session_active) {
a.status = 0;
/* 先打后台保活表:后台 App 的终止由主循环单写者就地执行(meow_bg_terminate 置
* term_at_ms + SIGTERM,meow_bg_tick 续 SIGKILL→reap→on_bg_dead 上报 amsd)——
* 与后台管理页「杀」按钮**同一条通道**,故 `am kill` 与页面按钮现行为对称。
* 此前只置 pending 交前台会话消费:用户不在 App 里时(桌面/后台页/面板)没有前台会话
* → 只能回 -2(MEOW_ERR_NOENT),amsd 判「拒绝」不杀(ACK 及时)或超时后盲发
* SIGTERM(ACK 迟到)——2026-11-04 真机 ACK 0xFFFFFFFE 即此,行为随主循环负载漂移。 */
if (meow_bg_terminate(k.app_id) == 0) {
a.code = MEOW_OK;
} else if (g_app_session_active) {
meow_ams_pending_kill_set(k.app_id);
a.code = MEOW_OK;
} else {
a.code = (uint32_t)MEOW_ERR_NOENT; /* 无前台 App 可终止 */
a.code = (uint32_t)MEOW_ERR_NOENT; /* 既非后台保活 App,也无前台 App 会话 */
}
a.status = 0;
ipc_send(m->fd, MSG_ACK, m->req_id, &a, sizeof a);
logmsg("ipc: AMS_KILL app=%s -> ACK code=%u", k.app_id, a.code);
logmsg("ipc: AMS_KILL app=%s -> ACK code=%u(%d)", k.app_id, a.code, (int)a.code);
break;
}
/* AMSD_DESIGN §6.2 二期:picker 跨 App 调用(App A → launcher → amsd 路由)。
+64 -15
View File
@@ -19,6 +19,7 @@
#define WF_ACT_CURR (-1) /* 当前连接(SUB_ROW,detail) */
#define WF_ACT_SAVED 0 /* 已保存网络 idx(SUB_ACT,connect) */
#define WF_ACT_SCAN_BASE 1000 /* 附近网络 1000+idx(SUB_ROW,连接) */
#define WF_ACT_MANUAL 2000 /* 「手动添加隐藏网络」动作行(SUB_ACT,connecth) */
typedef struct {
meow_net_t saved[S_NET_MAX];
@@ -122,13 +123,22 @@ static int wifi_rows(void *ctx, sub_row_t *rbuf, int max)
rbuf[n].on = 0; rbuf[n].act = WF_ACT_SCAN_BASE + i;
n++;
}
/* bug2:附近网络末尾的「手动添加隐藏网络」入口(SUB_ACT → wifi_act)。 */
if (n < max) {
rbuf[n].kind = SUB_ACT;
rbuf[n].label = "手动添加隐藏网络";
rbuf[n].sub = "";
rbuf[n].on = 0;
rbuf[n].act = WF_ACT_MANUAL;
n++;
}
return n;
}
/* SDK IME 输密码(PW 模式=ASCII 软键盘输入,现有输入法复用;回车提交/ESC或点背景取消)。
/* SDK IME 通用文本输入(MEOW_IME_ASCII:QWERTY,顶条「中」可切拼音输中文 SSID)。
* 返回 1=已输入;0=取消。 */
static int wifi_ask_psk(char *psk, int maxlen)
static int wifi_ask_text(char *out, int maxlen)
{
meow_ime_ctx ic;
memset(&ic, 0, sizeof ic);
@@ -136,36 +146,72 @@ static int wifi_ask_psk(char *psk, int maxlen)
ic.maxlen = maxlen;
ic.buf[0] = 0;
int res = meow_ui_text_input(&ic); /* 阻塞:提交后 ctx.buf 回填最终文本 */
if (res && ic.buf[0]) { snprintf(psk, maxlen, "%s", ic.buf); return 1; }
if (res && ic.buf[0]) { snprintf(out, maxlen, "%s", ic.buf); return 1; }
return 0;
}
/* bug1:connect 阻塞前画非模态「正在连接…」横幅。子页单线程,阻塞期间画面停在此横幅。 */
static void wifi_connecting_banner(const char *ssid)
{
const meow_ui_theme_t *th = s_ui_theme();
int W = meow_ui_width();
int y = 108, h = 44;
char line[160];
snprintf(line, sizeof line, "正在连接 %s ...", ssid && ssid[0] ? ssid : "网络");
meow_ui_fill_rect(0, y, W, h, th->surface_container);
meow_ui_text_center(W / 2, y + h / 2 - meow_ui_text_height(20) / 2, line, 20, th->on_surface);
meow_dirty_full();
meow_ui_flush();
}
/* bug2:手动添加隐藏网络(附近网络占位行点击 / 「手动添加隐藏网络」动作行共用)。 */
static void wifi_manual_add(wifi_ctx_t *c)
{
char ssid[96] = "";
if (!wifi_ask_text(ssid, sizeof ssid)) return;
const char *items[2];
items[0] = "加密网络(WPA/WPA2/WPA3)";
items[1] = "开放网络";
int pick = s_dd_modal("网络加密方式", items, 2, -1);
if (pick < 0) return;
int open = (pick == 1);
char psk[96] = "";
if (!open && !wifi_ask_text(psk, sizeof psk)) return;
char err[160]; err[0] = 0;
wifi_connecting_banner(ssid);
int rc = nm_wifi_connect_ex(ssid, open ? NULL : psk, 1, err, sizeof err);
char line[192]; const char *txt[1]; txt[0] = line;
snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败"));
logmsg("wifi_page: manual connect ssid=%s hidden=1 open=%d rc=%d err=%s", ssid, open, rc, err);
s_dd_modal(ssid, txt, 1, -1);
nm_wifi_scan_trigger();
c->last_scan_ms = now_ms();
}
/* 附近网络点选 →【WiFi 新网络录入】连接流程。返回 1=重绘。 */
static int wifi_connect_scan(wifi_ctx_t *c, int scan_idx)
{
if (scan_idx < 0 || scan_idx >= c->scan_n) return 0;
meow_net_t *s = &c->scan[scan_idx];
if (!s->name[0] || !strcmp(s->name, "(隐藏网络)")) {
const char *txt[1]; txt[0] = "隐藏网络无法直接连接";
s_dd_modal("附近网络", txt, 1, -1);
wifi_manual_add(c); /* bug2:占位隐藏网络 → 手动录入(SSID 待输) */
return 1;
}
int open = (s->sec[0] == 0 || !strcmp(s->sec, "Open"));
char psk[96] = "";
if (!open) {
if (!wifi_ask_psk(psk, sizeof psk)) return 1; /* 用户取消输密码 */
if (!wifi_ask_text(psk, sizeof psk)) return 1; /* 用户取消输密码 */
}
char err[160]; err[0] = 0;
wifi_connecting_banner(s->name); /* bug1:阻塞前横幅,避免「点完无反馈」 */
int rc = nm_wifi_connect(s->name, open ? NULL : psk, err, sizeof err);
char line[192];
const char *txt[1]; txt[0] = line;
if (rc == MEOW_OK) {
snprintf(line, sizeof line, "连接成功:%s%s", s->name,
err[0] ? "" : "(已启用)");
} else {
snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误");
}
/* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */
snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败"));
logmsg("wifi_page: scan connect ssid=%s open=%d rc=%d err=%s", s->name, open, rc, err);
/* 连接结果反馈(成功后进已保存网络列表由 rows() 重扫刷新) */
s_dd_modal(s->name, txt, 1, -1);
@@ -208,20 +254,23 @@ static int wifi_detail(void *ctx, int idx, int act)
}
/* 动作行(SUB_ACT)点击:已保存网络 → netmgr connect(ssid;复用既有网络) */
/* 动作行(SUB_ACT)点击:已保存网络 → netmgr connect(ssid;复用既有网络)。
* 另承接「手动添加隐藏网络」入口(bug2)。 */
static int wifi_act(void *ctx, int idx, int act)
{
(void)idx;
wifi_ctx_t *c = ctx;
if (act == WF_ACT_MANUAL) { wifi_manual_add(c); return 1; }
if (act >= WF_ACT_SCAN_BASE) return 0; /* 附近是 SUB_ROW(detail),不走这里 */
int sidx = act - WF_ACT_SAVED;
if (sidx < 0 || sidx >= c->saved_n) return 0;
if (c->saved[sidx].id < 0) return 0;
char err[160]; err[0] = 0;
wifi_connecting_banner(c->saved[sidx].name); /* bug1:阻塞前横幅 */
int rc = nm_wifi_connect(c->saved[sidx].name, NULL, err, sizeof err);
char line[192]; const char *txt[1]; txt[0] = line;
if (rc == MEOW_OK) snprintf(line, sizeof line, "已连接:%s", c->saved[sidx].name);
else snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误");
/* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */
snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败"));
logmsg("wifi_page: saved connect ssid=%s rc=%d err=%s", c->saved[sidx].name, rc, err);
s_dd_modal(c->saved[sidx].name, txt, 1, -1);
nm_wifi_scan_trigger();
+156 -52
View File
@@ -85,6 +85,10 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET
#define NET_POLL_SEC_DEF 2 /* 接口轮询/状态节拍(秒;MEOW_NET_POLL_SEC 覆盖,测试用) */
#define RUNCMD_TMO_MS 1500 /* 后端命令(wpa_cli/hciconfig/etc)有界收割限时(ms) */
/* WiFi 设置页 bug1(2026-09-15):connect 有界等待 wpa_state=COMPLETED 的上限(ms)。
* 普通 ~10s;隐藏网络(connecth,主动探测+关联更慢)~15s。env 可调(宿主桩测试缩短)。 */
#define WIFI_CONNECT_WAIT_MS 10000 /* MEOW_WIFI_CONNECT_WAIT_MS */
#define WIFI_CONNECTH_WAIT_MS 15000 /* MEOW_WIFI_CONNECTH_WAIT_MS */
#define IFACE_MAX 16
#define RFKILL_MAX 8
#define DNS_MAX 8
@@ -112,6 +116,8 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET
#define DNS_SELF_IP "127.0.0.1"
static int g_poll_sec = NET_POLL_SEC_DEF;
static int g_connect_wait_ms = WIFI_CONNECT_WAIT_MS; /* MEOW_WIFI_CONNECT_WAIT_MS */
static int g_connecth_wait_ms = WIFI_CONNECTH_WAIT_MS; /* MEOW_WIFI_CONNECTH_WAIT_MS */
static int g_dns_port = DNS_PORT_DEF; /* MEOW_DNS_PORT */
static int g_dns_disabled = 0; /* MEOW_DNS_DISABLE=1:禁用 DNS 代理(宿主测试无 root 绑 53) */
@@ -2650,13 +2656,82 @@ static const char *str_capsafe(const char *s, char *tmp, size_t cap)
return tmp;
}
/* wpa_cli 单命令封装:拼 PATH 前缀 + 有界收割(RUNCMD_TMO_MS)。返回 run_cmd 读到的字节数。 */
static int wpa_cmd(char *out, int cap, const char *args)
{
char cmd[512];
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s %s 2>/dev/null", g_wpa_cli, args);
return run_cmd(out, cap, RUNCMD_TMO_MS, cmd);
}
/* WiFi 设置页 bug4-②(2026-09-15):list_networks/scan_results 对非 ASCII 字节输出 \xNN 转义
* (中文 SSID)——还原为原始字节串再与输入 SSID 做同表示比较;不还原则中文 SSID 永不命中
* 既有网络 → 新建同名 open 记录顶影原记录。net.c wpa_decode_ssid / netmgr_ipc.c nm_decode_ssid
* 已有两份同功能实现(后续可收拢,本次先在 netmgrd 补齐)。 */
static int wpa_hexval(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static void wpa_unescape_ssid(const char *src, char *dst, size_t dcap)
{
size_t o = 0;
while (*src && o + 1 < dcap) {
if (src[0] == '\\' && src[1] == 'x' && wpa_hexval(src[2]) >= 0 && wpa_hexval(src[3]) >= 0) {
dst[o++] = (char)((wpa_hexval(src[2]) << 4) | wpa_hexval(src[3]));
src += 4;
} else {
dst[o++] = *src++;
}
}
dst[o] = 0;
}
/* WiFi 设置页 bug4-③(2026-09-15):save_config 结果校验。wpa_supplicant 仅在 conf 含
* update_config=1 时允许写盘;固件模板 /etc/wpa_supplicant.conf 无此项 → 首次 FAIL 时先
* `set update_config 1`(运行期开关,wpa_supplicant 标准用法;成功落盘后文件自带
* update_config=1)再重试一次。返回 1=已持久化;0=仍失败(调用方须在报文/日志明示
* 「记录未持久化」)。仅 MeowOS 模式调用(写盘红线不变,NETMGR_DESIGN §14)。 */
static int wifi_save_config(void)
{
char out[160];
if (wpa_cmd(out, sizeof out, "save_config") > 0 && strstr(out, "OK")) return 1;
wpa_cmd(out, sizeof out, "set update_config 1");
if (wpa_cmd(out, sizeof out, "save_config") > 0 && strstr(out, "OK")) {
net_log("info", "write", "save_config 初次 FAIL → set update_config 1 重试成功(此后 conf 自带该位)");
return 1;
}
net_log("warn", "write", "save_config 失败(含 set update_config 1 重试):记录未持久化");
return 0;
}
/* WiFi 设置页 bug1(2026-09-15):连接状态机——有界等待 wpa_state=COMPLETED(每 200ms 查
* status,上限 timeout_ms,防卡死)。返回 1=已关联(out 带回 ssid/ip 供诚实报文);0=超时。 */
static int wifi_wait_completed(int timeout_ms, wifi_t *out)
{
int waited = 0;
for (;;) {
wifi_t w;
wifi_status(&w);
if (w.connected) { if (out) *out = w; return 1; }
if (waited >= timeout_ms) return 0;
struct timespec ts = { 0, 200 * 1000000L };
nanosleep(&ts, NULL);
waited += 200;
}
}
static void wifi_scan(ctl_result_t *r)
{
char cmd[160];
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s scan 2>/dev/null", g_wpa_cli);
char buf[128];
run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd);
struct timespec ts = { 0, 300 * 1000000L }; /* 触发后稍候再读结果(有界)。 */
/* P3(WiFi 设置页附带,2026-09-15):触发后 300ms→1500ms 再读结果——真机扫描常 1-2s,
* 300ms 读到的多是上一轮陈旧结果。IPC 客户端 per-cmd 超时相应放宽(netmgr_ipc scan=3.5s)。 */
struct timespec ts = { 1, 500 * 1000000L };
nanosleep(&ts, NULL);
char bufout[MEOW_IPC_MAX - 16];
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s scan_results 2>/dev/null", g_wpa_cli);
@@ -2675,7 +2750,13 @@ static void wifi_networks(ctl_result_t *r)
ctl_sets(r, MEOW_OK, buf);
}
static void wifi_connect(const char *arg, ctl_result_t *r)
/* WiFi 连接(bug1/bug2/bug4 修复,2026-09-15)。hidden=1 走隐藏网络流程(connecth):
* 对所用 id(新建/命中皆然)追加 scan_ssid=1,并把 COMPLETED 等待上限放宽(主动探测+关联更慢)。
* 流程:匹配既有(\xNN 还原同表示比较)→ 新建或(命中且带新凭据时)更新 psk →
* enable+select → 有界等待 wpa_state=COMPLETED(不再无条件报成功)→
* 成功:enable_network all(消除 select 的 disabled 副作用)+ save_config(校验+重试);
* 失败:本次新建的网络 remove 回收(防坏记录堆积),报文明示失败原因。 */
static void wifi_connect_ex(const char *arg, ctl_result_t *r, int hidden)
{
char ssid[96] = "", psk[96] = "";
if (arg && arg[0]) {
@@ -2694,11 +2775,10 @@ static void wifi_connect(const char *arg, ctl_result_t *r)
str_capsafe(psk, tmp_psk, sizeof tmp_psk);
if (!tmp_ssid[0]) { ctl_sets(r, MEOW_ERR_INVAL, "connect: ssid 缺失"); return; }
/* 找既有网络:list_networks → 匹配 ssid(剥引号比较);命中则复用其 id。 */
char cmd[200];
/* 找既有网络:list_networks → 匹配 ssid(剥引号 + \xNN 还原后同表示比较);命中则复用其 id。 */
char cmd[256];
char list[1024];
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s list_networks 2>/dev/null", g_wpa_cli);
int n = run_cmd(list, sizeof list, RUNCMD_TMO_MS, cmd);
int n = wpa_cmd(list, sizeof list, "list_networks");
int id = -1;
if (n > 0) {
char *ln = strtok(list, "\n");
@@ -2709,61 +2789,81 @@ static void wifi_connect(const char *arg, ctl_result_t *r)
char *ss = tab0 + 1;
char *tab1 = strchr(ss, '\t');
if (tab1) *tab1 = 0;
char clean[128], norm_clean[128];
char clean[128], unesc[160], norm_clean[128];
size_t cl = strlen(ss);
if (cl >= 2 && ss[0] == '"' && ss[cl-1] == '"') {
memcpy(clean, ss + 1, cl - 2); clean[cl - 2] = 0;
} else {
snprintf(clean, sizeof clean, "%s", ss);
}
str_capsafe(clean, norm_clean, sizeof norm_clean);
wpa_unescape_ssid(clean, unesc, sizeof unesc); /* bug4-②:中文 SSID 转义还原 */
str_capsafe(unesc, norm_clean, sizeof norm_clean);
if (strcmp(norm_clean, tmp_ssid) == 0) id = this_id;
}
ln = strtok(NULL, "\n");
}
}
char out[128];
int created = 0;
if (id < 0) {
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s add_network 2>/dev/null", g_wpa_cli);
if (run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd) <= 0) {
if (wpa_cmd(out, sizeof out, "add_network") <= 0) {
ctl_sets(r, MEOW_ERR_NODEV, "connect: add_network 失败 (wpa_cli 不可用?)");
return;
}
id = atoi(out);
if (id < 0) { ctl_sets(r, MEOW_ERR_INVAL, "connect: add_network 无 id"); return; }
snprintf(cmd, sizeof cmd,
"PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d ssid '\"%s\"' 2>/dev/null",
g_wpa_cli, id, tmp_ssid);
run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd);
if (tmp_psk[0]) {
snprintf(cmd, sizeof cmd,
"PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d psk '\"%s\"' 2>/dev/null",
g_wpa_cli, id, tmp_psk);
} else {
snprintf(cmd, sizeof cmd,
"PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d key_mgmt NONE 2>/dev/null",
g_wpa_cli, id);
}
run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd);
created = 1;
snprintf(cmd, sizeof cmd, "set_network %d ssid '\"%s\"'", id, tmp_ssid);
wpa_cmd(out, sizeof out, cmd);
if (tmp_psk[0]) snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk);
else snprintf(cmd, sizeof cmd, "set_network %d key_mgmt NONE", id);
wpa_cmd(out, sizeof out, cmd);
} else if (tmp_psk[0]) {
/* bug4-①/bug1-④:命中既有网络且调用方带了新 psk → 先更新凭据再连
* (旧记录密码错误可经扫描路径重输修复;psk 为空则不动既有凭据)。 */
snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk);
wpa_cmd(out, sizeof out, cmd);
}
if (hidden) {
/* bug2:隐藏 AP 必须 scan_ssid=1,supplicant 才会主动发 Probe Request(命中既有也要补)。 */
snprintf(cmd, sizeof cmd, "set_network %d scan_ssid 1", id);
wpa_cmd(out, sizeof out, cmd);
}
{ /* enable + select 该网络(复用/新建皆用)。 */
char en[256], sel[256];
snprintf(en, sizeof en, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s enable_network %d 2>/dev/null",
g_wpa_cli, id);
run_cmd(out, sizeof out, RUNCMD_TMO_MS, en);
snprintf(sel, sizeof sel, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s select_network %d 2>/dev/null",
g_wpa_cli, id);
run_cmd(out, sizeof out, RUNCMD_TMO_MS, sel);
/* 期3:MeowOS 模式 connect 后持久化 wpa_supplicant.conf(HibyOS 只读由服务端门控,这里仅 MeowOS 生效)。 */
if (g_mode_meow) {
char sc[160];
snprintf(sc, sizeof sc, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s save_config 2>/dev/null", g_wpa_cli);
run_cmd(out, sizeof out, RUNCMD_TMO_MS, sc);
}
snprintf(cmd, sizeof cmd, "enable_network %d", id);
wpa_cmd(out, sizeof out, cmd);
snprintf(cmd, sizeof cmd, "select_network %d", id);
wpa_cmd(out, sizeof out, cmd);
}
char msg[140];
snprintf(msg, sizeof msg, "connect: ssid=%s network_id=%d (已 enable+select%s)", tmp_ssid, id,
g_mode_meow ? "+save_config" : "");
/* bug1:有界等待 wpa_state=COMPLETED——密码错误/AP 不在范围/握手失败不再误报成功。 */
wifi_t w;
int wait_ms = hidden ? g_connecth_wait_ms : g_connect_wait_ms;
if (!wifi_wait_completed(wait_ms, &w)) {
if (created) { /* 本次新建且失败 → 回收防坏记录堆积(bug4 联动) */
snprintf(cmd, sizeof cmd, "remove_network %d", id);
wpa_cmd(out, sizeof out, cmd);
if (g_mode_meow) wifi_save_config();
}
net_log("info", "write", "wifi connect ssid=%s id=%d 超时未关联(%dms)", tmp_ssid, id, wait_ms);
char msg[128];
snprintf(msg, sizeof msg, "连接失败:密码错误或信号不佳(超时未关联)");
ctl_sets(r, MEOW_ERR_NODEV, msg);
return;
}
/* bug4-④:验证成功后 enable_network all 复位其它网络 disabled 态(select_network 副作用),
* 再 save_config 落盘(bug4-③:结果校验,FAIL 时 set update_config 1 重试)。
* 写盘仍受 MeowOS 模式门控(红线不变,NETMGR_DESIGN §14)。 */
int persisted = 0;
if (g_mode_meow) {
wpa_cmd(out, sizeof out, "enable_network all");
persisted = wifi_save_config();
}
net_log("info", "write", "wifi connect ssid=%s id=%d 已连接 ip=%s persisted=%d",
tmp_ssid, id, w.ip[0] ? w.ip : "-", persisted);
char msg[160];
snprintf(msg, sizeof msg, "已连接:%s ip=%s%s",
w.ssid[0] ? w.ssid : tmp_ssid, w.ip[0] ? w.ip : "-",
(g_mode_meow && !persisted) ? "(记录未持久化)" : "");
ctl_sets(r, MEOW_OK, msg);
}
@@ -2840,7 +2940,9 @@ static void wifi_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r)
} else if (!strcmp(cmd, "networks")) {
wifi_networks(r);
} else if (!strcmp(cmd, "connect")) {
wifi_connect(arg, r);
wifi_connect_ex(arg, r, 0);
} else if (!strcmp(cmd, "connecth")) {
wifi_connect_ex(arg, r, 1); /* bug2:隐藏网络(scan_ssid=1 + 更长 COMPLETED 等待) */
} else if (!strcmp(cmd, "disconnect")) {
char cmd2[160];
snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s disconnect 2>/dev/null", g_wpa_cli);
@@ -2857,13 +2959,13 @@ static void wifi_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r)
g_wpa_cli, nid);
char buf[128];
int n = run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2);
/* 期4:移除后持久化(MeowOS 模式;HibyOS 只读由服务端门控)。 */
if (g_mode_meow) {
snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s save_config 2>/dev/null", g_wpa_cli);
run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2);
}
char msg[64];
snprintf(msg, sizeof msg, "wifi forget: network_id=%d 已移除%s", nid, g_mode_meow ? "+save_config" : "");
/* 期4:移除后持久化(MeowOS 模式;HibyOS 只读由服务端门控)。
* bug4-③:save_config 结果校验(FAIL 时 set update_config 1 重试),报文不再谎称已落盘。 */
int persisted = 0;
if (g_mode_meow) persisted = wifi_save_config();
char msg[96];
snprintf(msg, sizeof msg, "wifi forget: network_id=%d 已移除%s", nid,
g_mode_meow ? (persisted ? "+save_config" : "(记录未持久化)") : "");
ctl_sets(r, n >= 0 ? MEOW_OK : MEOW_ERR_NODEV, msg);
} else if (!strcmp(cmd, "rfkill")) {
rfkill_ctl(arg, r);
@@ -3704,7 +3806,7 @@ static int cli_policy(int op, const char *entity, const char *arg)
static const char *usage(void)
{
return "status | iface [name] | wifi scan|networks|connect <ssid>[,psk]|disconnect|on|off"
return "status | iface [name] | wifi scan|networks|connect <ssid>[,psk]|connecth <ssid>[,psk]|disconnect|on|off"
" | bt status|on|off|paired|connected|a2dp|devices|scan on|off|connect|disconnect|pair|unpair <mac>"
" | rfkill [<type> <on|off>] | udhcpc renew|release [if]"
" | apps | conns [entity] | top | dns status|stats|flush"
@@ -3757,9 +3859,9 @@ static int cli_run(int n, char **v)
if (!strcmp(cmd, "wifi")) {
if (!arg) { fprintf(stderr, "wifi 子命令缺失(%s)\n", usage()); return 1; }
if (!strcmp(arg, "on") || !strcmp(arg, "off")) return cli_cmd_result(MSG_NET_WIFI_CTL, arg, NULL);
if (!strcmp(arg, "connect")) {
if (!arg2) { fprintf(stderr, "connect 需 <ssid>[,psk]\n"); return 1; }
return cli_cmd_result(MSG_NET_WIFI_CTL, "connect", arg2);
if (!strcmp(arg, "connect") || !strcmp(arg, "connecth")) {
if (!arg2) { fprintf(stderr, "%s 需 <ssid>[,psk]\n", arg); return 1; }
return cli_cmd_result(MSG_NET_WIFI_CTL, arg, arg2);
}
if (!strcmp(arg, "forget")) {
if (!arg2) { fprintf(stderr, "forget 需 <network_id>\n"); return 1; }
@@ -3821,6 +3923,8 @@ int main(int argc, char **argv)
if ((e = getenv("MEOW_WIFI_IF")) && e[0]) g_wifi_if = e;
if ((e = getenv("MEOW_NET_SOCK")) && e[0]) g_sock = e;
if ((e = getenv("MEOW_NET_POLL_SEC")) && atoi(e) > 0) g_poll_sec = atoi(e);
if ((e = getenv("MEOW_WIFI_CONNECT_WAIT_MS")) && atoi(e) >= 200) g_connect_wait_ms = atoi(e);
if ((e = getenv("MEOW_WIFI_CONNECTH_WAIT_MS")) && atoi(e) >= 200) g_connecth_wait_ms = atoi(e);
if ((e = getenv("MEOW_TCP")) && e[0]) snprintf(g_proc_tcp, sizeof g_proc_tcp, "%s", e);
if ((e = getenv("MEOW_UDP")) && e[0]) snprintf(g_proc_udp, sizeof g_proc_udp, "%s", e);
if ((e = getenv("MEOW_AMS_SOCK")) && e[0]) snprintf(g_ams_sock, sizeof g_ams_sock, "%s", e);
+19
View File
@@ -423,6 +423,25 @@ kill $D2PID 2>/dev/null; wait $D2PID 2>/dev/null
# 恢复守护 A 的 MEOW_RESOLV(其未被 DNS 触碰,此处确认)
grep -q "nameserver 223.5.5.5" "$T/resolv" && { PASS=$((PASS+1)); echo "PASS T14f daemonA resolv untouched"; } || { FAIL=$((FAIL+1)); echo "FAIL T14f daemonA resolv touched"; }
# ---- T19: WiFi connect 状态机/持久化/隐藏网络/中文 SSID(宿主桩直驱 wifi_connect_ex)----
# daemon IPC 写级需系统身份(SO_PEERCRED /proc/exe),宿主 CLI=CALL_OTHER 必拒;故用 #include
# meow_netmgrd.c 的独立宿主二进制直驱 static wifi_connect_ex,桩 wpa_cli 由二进制自建。
if [ -z "$Q" ]; then
WCON="$T/test_wifi_connect"
if cc -Os -Wall -Wextra -Wno-format-truncation -I../meow_launcherd \
test_wifi_connect.c -o "$WCON" >/dev/null 2>&1; then
if "$WCON" >"$T/wcon.out" 2>/dev/null && grep -q "FAIL=0" "$T/wcon.out"; then
PASS=$((PASS+1)); echo "PASS T19 wifi-connect host (12 asserts, FAIL=0)"
else
FAIL=$((FAIL+1)); echo "FAIL T19 wifi-connect host"; cat "$T/wcon.out"
fi
else
FAIL=$((FAIL+1)); echo "FAIL T19 wifi-connect host build"
fi
else
echo "SKIP T19 wifi-connect(qemu 下 fork/exec 宿主 /bin/sh 不便,宿主已覆盖)"
fi
echo "=============================="
echo "PASS=$PASS FAIL=$FAIL"
[ $FAIL -eq 0 ]
+168
View File
@@ -0,0 +1,168 @@
/* test_wifi_connect.c — WiFi 设置页 bug1/bug2/bug4 宿主桩测试(2026-09-15)。
*
* 通过 #include meow_netmgrd.c 直接驱动 static 的 wifi_connect_ex(),绕过 daemon 的
* SO_PEERCRED 系统身份鉴权(宿主 CLI=CALL_OTHER 写级必拒,无法经 IPC 测连接路径)。
* 桩 wpa_cli 由本程序写入临时目录;控制文件(status_mode/list/save_fail_once)决定
* status 序列 / list_networks 原文 / save_config 是否首败,调用日志写入 <dir>/log 供断言。
*
* 编译:cc -Wall -Wextra -I../meow_launcherd test_wifi_connect.c -o test_wifi_connect
*/
#define _GNU_SOURCE
#define main meow_netmgrd_main
#include "meow_netmgrd.c"
#undef main
#include <sys/stat.h>
static char g_dir[128];
static int g_pass = 0, g_fail = 0;
static void ck(const char *name, int cond, const char *detail)
{
if (cond) { g_pass++; printf("PASS %s\n", name); }
else { g_fail++; printf("FAIL %s: %s\n", name, detail ? detail : ""); }
fflush(stdout);
}
static void putf(const char *name, const char *fmt, ...)
{
char path[192], body[512];
va_list ap;
va_start(ap, fmt);
vsnprintf(body, sizeof body, fmt, ap);
va_end(ap);
snprintf(path, sizeof path, "%s/%s", g_dir, name);
FILE *f = fopen(path, "w");
if (f) { fputs(body, f); fclose(f); }
}
static int log_has(const char *needle)
{
char path[192], buf[8192];
snprintf(path, sizeof path, "%s/log", g_dir);
FILE *f = fopen(path, "r");
if (!f) return 0;
size_t n = fread(buf, 1, sizeof buf - 1, f);
fclose(f);
buf[n] = 0;
return strstr(buf, needle) != NULL;
}
static void reset_log(void)
{
char path[192];
snprintf(path, sizeof path, "%s/log", g_dir);
FILE *f = fopen(path, "w");
if (f) fclose(f);
}
static void write_stub(void)
{
char path[192];
snprintf(path, sizeof path, "%s/wpa_cli", g_dir);
FILE *f = fopen(path, "w");
if (!f) { fprintf(stderr, "stub open fail\n"); exit(2); }
fputs(
"#!/bin/sh\n"
"D=\"${MN_WIFI_DIR:-/tmp}\"\n"
"echo \"$@\" >> \"$D/log\"\n"
"case \"$1\" in\n"
" status)\n"
" m=$(cat \"$D/status_mode\" 2>/dev/null || echo completed)\n"
" if [ \"$m\" = \"associating\" ]; then\n"
" echo \"wpa_state=ASSOCIATING\"; echo \"ssid=MyWiFi\"\n"
" else\n"
" echo \"wpa_state=COMPLETED\"; echo \"ssid=MyWiFi\"\n"
" echo \"ip_address=10.78.193.50\"; echo \"signal=-45\"\n"
" fi\n"
" ;;\n"
" list_networks)\n"
" if [ -f \"$D/list\" ]; then cat \"$D/list\"\n"
" else printf 'network id / ssid / bssid / flags\\n0\\tMyWiFi\\tany\\t[CURRENT]\\n'\n"
" fi\n"
" ;;\n"
" add_network) echo 1 ;;\n"
" set_network) echo OK ;;\n"
" enable_network) echo OK ;;\n"
" select_network) echo OK ;;\n"
" disconnect) echo OK ;;\n"
" save_config)\n"
" if [ -f \"$D/save_fail_once\" ]; then rm -f \"$D/save_fail_once\"; echo FAIL; else echo OK; fi\n"
" ;;\n"
" set) echo OK ;;\n"
" remove_network) echo OK ;;\n"
" *) echo OK ;;\n"
"esac\n"
"exit 0\n",
f);
fclose(f);
chmod(path, 0755);
}
int main(void)
{
snprintf(g_dir, sizeof g_dir, "/tmp/mnwifi.XXXXXX");
if (!mkdtemp(g_dir)) { fprintf(stderr, "mkdtemp fail\n"); return 2; }
write_stub();
setenv("MN_WIFI_DIR", g_dir, 1);
g_mode_meow = 1;
g_connect_wait_ms = 400;
g_connecth_wait_ms = 400;
{
char p[192];
snprintf(p, sizeof p, "%s/wpa_cli", g_dir);
g_wpa_cli = p;
}
ctl_result_t r;
/* ① 成功:命中既有 id=0 + 带新 psk → 更新凭据,等待 COMPLETED,成功报 ip。 */
putf("status_mode", "completed");
ctl_reset(&r, MEOW_OK);
reset_log();
wifi_connect_ex("MyWiFi,secret", &r, 0);
ck("connect-success code", r.code == MEOW_OK, r.text);
ck("connect-success text ip", strstr(r.text, "已连接") && strstr(r.text, "ip=10.78.193.50"), r.text);
ck("connect-success psk update", log_has("set_network 0 psk"), "");
ck("connect-success enable all", log_has("enable_network all"), "");
/* ② 错误密码/不在范围:status 恒 ASSOCIATING → 超时失败,新建 id 被回收。 */
putf("status_mode", "associating");
reset_log();
ctl_reset(&r, MEOW_OK);
wifi_connect_ex("OtherWiFi", &r, 0);
ck("connect-timeout code", r.code == MEOW_ERR_NODEV, r.text);
ck("connect-timeout text", strstr(r.text, "连接失败") != NULL, r.text);
ck("connect-timeout remove new", log_has("remove_network 1"), "");
/* ③ 中文 SSID \xNN 还原匹配:list_networks 原文转义,输入 UTF-8 名 → 命中 id=0 不新建。 */
putf("status_mode", "completed");
putf("list", "network id / ssid / bssid / flags\n0\t\\xe5\\xa4\\xa9\\xe5\\xa4\\xa9\tany\t[CURRENT]\n");
reset_log();
ctl_reset(&r, MEOW_OK);
wifi_connect_ex("天天", &r, 0);
ck("connect-escape match", r.code == MEOW_OK, r.text);
ck("connect-escape no add", !log_has("add_network"), "");
/* ④ connecth:隐藏网络命中既有 id → 追加 scan_ssid 1。 */
putf("status_mode", "completed");
putf("list", "network id / ssid / bssid / flags\n0\tMyWiFi\tany\t[CURRENT]\n");
reset_log();
ctl_reset(&r, MEOW_OK);
wifi_connect_ex("MyWiFi", &r, 1);
ck("connecth scan_ssid", r.code == MEOW_OK && log_has("set_network 0 scan_ssid 1"), r.text);
/* ⑤ save_config 首败 → set update_config 1 重试 → 持久化成功。 */
putf("status_mode", "completed");
putf("list", "network id / ssid / bssid / flags\n0\tMyWiFi\tany\t[CURRENT]\n");
putf("save_fail_once", "1");
reset_log();
ctl_reset(&r, MEOW_OK);
wifi_connect_ex("MyWiFi", &r, 0);
ck("save-config retry", r.code == MEOW_OK && !strstr(r.text, "记录未持久化"), r.text);
ck("save-config set update_config", log_has("set update_config 1"), "");
printf("==============================\nPASS=%d FAIL=%d\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}
+4 -3
View File
@@ -8,16 +8,17 @@ set -e
cd "$(dirname "$0")"
ROOT=../..
CC="$ROOT/.tools/zig-mipsel-cc"
INC="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(音量收编 v2:MSG_AUDIO_VOLUME/MSG_ACK)
if [ "$1" = "host" ]; then
gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
test_wd_fw.c -o test_wd_fw -lm
gcc -Wall -Wextra -Os -I"$INC" -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
-Wno-unused-variable test_wd_fw.c -o test_wd_fw -lm
./test_wd_fw
exit $?
fi
echo "== meow_watchdog (mipsel static) =="
"$CC" -Os -Wall -Wextra -static meow_watchdog.c -o meow_watchdog
"$CC" -Os -Wall -Wextra -static -I"$INC" meow_watchdog.c -o meow_watchdog
ls -la meow_watchdog
md5sum meow_watchdog
file meow_watchdog
Binary file not shown.
+199 -25
View File
@@ -19,7 +19,10 @@
* (launcher 在 waitpid 轮询中消费;菜单期该进程靠 /tmp/meow_power_menu_active
* 让位,绝不与 launcher 抢屏/抢键);launcher 画菜单,本进程不自绘。
* 电源长按 ≥8s -> 强制关机兜底(仅当菜单未生效/launcher 未接管)。
* 音量±(115/114) -> 写真实 DAC(CS43198 amixer numid=1/2)+ 持久化 settings.conf(同 launcher 同源)。
* 音量±(115/114) -> 音量收编 v2(FUTURE_AUDIO 事项三):首选发 audiod 权威
* MSG_AUDIO_VOLUME(audiod 写 DAC numid=1/2 + DSD_VOLUME + 持久化
* settings.conf,同源同键单写者);audiod 未起(HibyOS/开机早期)
* 才回退本地 amixer 直写兜底 + 本地持久化。
* 插孔消抖:/sys/class/switch/{headset,balance} 变化后 800ms 内忽略音量键边沿
* (插拔电气瞬态会在 ADC 键上产生幽灵按下,真机实证 2026-09-01)。
* 播放/上下曲(164/165/163/19) -> 捕获 + 日志 + 消费,不路由(媒体会话落地后转活跃会话)。
@@ -28,6 +31,9 @@
* (true|1|yes)。判定为 SDK 框架 App -> 音量/媒体键让位 SDK 输入层(meow_ui_input.c 已消化,
* 防同一次按键双步进);电源仍由本进程兜底(SDK 对电源是 no-op)。裸自绘 App(framework=0/
* 无 framework)或 app.json 不可读 -> 音量/媒体键仍由本进程兜底(不造成功能真空)。
* - M20 亮屏亮度:settings.conf 的 display.brightness(clamp 到 brightness_min..100)为唯一
* 权威;settings 瞬时不可读用**真实观测快照**;两者都未知时**不写背光**(不再回退固定 60,
* 那会覆盖 launcher 刚设好的用户值 → 开机/亮屏跳 60)。面板已黑且无任何值来源才写安全低亮。
* Exits when the app process disappears (kill(pid,0) -> ESRCH) or on
* SIGTERM from the parent.
*
@@ -50,8 +56,12 @@
#include <sys/mman.h>
#include <poll.h>
#include <sys/syscall.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/time.h>
#include <stdint.h>
#include <time.h>
#include "meow_ipc.h" /* MSG_AUDIO_VOLUME/MSG_ACK/帧格式(音量收编 v2:首选 audiod 权威) */
/* device-verified: VSCREENINFO=0x4600, FSCREENINFO=0x4602 (do not swap) */
#define FBIOGET_VSCREENINFO 0x4600
@@ -185,16 +195,44 @@ static int g_fw_app = 0;
/* ---- M2.22(A)+ M2.24 电源键接管:息屏/亮屏 + 长按菜单/强关 ---- */
static int wd_fbfd = -1; /* fb0 fd(main 里打开,供 FBIOBLANK/FBIOPAN 用) */
static int wd_saved_bri = 60; /* 熄屏前亮度快照 */
/* M20(远古 bug 回归:开机/亮屏背光跳到固定 60):0 是「未知」哨兵,不是亮度值
* (0 = 面板全黑/息屏,任何情况下都不是有效用户亮度)。旧代码让 60 同时表达
* 「未知快照」和「兜底值」,于是一旦没有可信来源就把 60 写进背光,覆盖 launcher
* 刚按 display.brightness 设好的用户值(真机 0.8.46:用户亮度 1,亮屏仍写 60)。 */
#define WD_BRI_UNKNOWN 0
/* 唯一保留的固定亮度:settings 读不到 + 快照未知 + **当前亮度确实是 0**(面板黑)时才用。
* 这条退路上不存在任何可保留的有效用户值,不写就是永久黑屏;取低亮(≈10% of
* max_brightness 101)而不是 60 —— 宁可暗到用户手动调亮,也不要夜里突然刺眼。
* 注意 60 是固件 S11jpeg_display_shell 画完开机 logo 后写死的值(/etc/init.d,不在本仓库),
* 本进程任何分支都不再写 60。 */
#define WD_BRI_SAFE_LOW 10
/* M20 短期修复 A:settings 读取重试。settings.conf 由 launcher/audiod 以
* 「写 .tmp + rename」原子替换,开机早期 /usr/data(UBIFS) 也可能尚未就绪,
* fopen 存在瞬时失败窗口。3 次 × 10ms 只覆盖该窗口:最坏总阻塞 (3-1)*10 = 20ms,
* 且只在失败时发生(成功路径 0 开销)。亮屏轮询粒度本身是 REFRESH_MS=100ms,
* 20ms 在「按键→亮屏」路径上不可感知(见 M20 报文)。 */
#define WD_SETTINGS_TRIES 3
#define WD_SETTINGS_GAP_MS 10
static int wd_saved_bri = WD_BRI_UNKNOWN; /* 熄屏前亮度快照;未知=WD_BRI_UNKNOWN(不再用 60) */
static int pwr_down = 0; /* 电源当前按住 */
static unsigned long long pwr_down_ms = 0;
static int pwr_woke = 0; /* 按下时屏已黑 → 本次只作唤醒,松开不再 toggle */
static int pwr_menu_notified = 0; /* 已写 /tmp/meow_power_menu 请 launcher 弹菜单 */
static int pwr_force_done = 0; /* 8s 强关已发(防重复) */
/* 背光 sysfs 路径(缺省 BACKLIGHT;env MEOW_BACKLIGHT 覆盖,供宿主测试隔离——与
* MEOW_SETTINGS_FILE 同款测试钩子,真机无 env 走默认,行为不变)。 */
static const char *wd_backlight_path(void)
{
const char *e = getenv("MEOW_BACKLIGHT");
return (e && e[0]) ? e : BACKLIGHT;
}
static int read_brightness(void)
{
int fd = open(BACKLIGHT, O_RDONLY);
int fd = open(wd_backlight_path(), O_RDONLY);
if (fd < 0) return -1;
char buf[16];
int n = read(fd, buf, sizeof buf - 1);
@@ -208,24 +246,40 @@ static void write_brightness(int val)
{
char buf[8];
int l = snprintf(buf, sizeof buf, "%d\n", val);
int fd = open(BACKLIGHT, O_WRONLY);
int fd = open(wd_backlight_path(), O_WRONLY);
if (fd < 0) return;
if (write(fd, buf, l) < 0) wd_log("backlight write %d: %s", val, strerror(errno));
close(fd);
}
/* settings.conf 路径(缺省 /usr/data/meow/settings.conf;env MEOW_SETTINGS_FILE 覆盖,
* 供宿主测试隔离——与 audiod MEOW_AUDIO_SETTINGS 同款测试钩子,真机无 env 走默认)。 */
static const char *wd_settings_path(void)
{
const char *e = getenv("MEOW_SETTINGS_FILE");
return (e && e[0]) ? e : MEOW_SETTINGS_FILE;
}
/* M6.23:亮屏恢复亮度以**用户设置**为准(/usr/data/meow/settings.conf 的 display.brightness),
* 而非 wd_saved_bri 快照——该快照可能被污染:panel OFF 前 read_brightness() 可能读到 launcher
* 先写好的 0(launcher meow_panel_off 已 write_brightness(0)),`wd_saved_bri=(br>0)?br:60` 便落到
* 先写好的 0(launcher meow_panel_off 已 write_brightness(0)),旧代码 `(br>0)?br:60` 便落到
* 默认 60;panel ON 恢复 60 与 launcher 按用户设置恢复 7 竞态 → 亮屏亮度跳变(真机「跳高到 60 /
* 又降回 7」,2026-09-03)。
* 又降回 7」,2026-09-03)。旧公式里的 `:60` 已在 M20 移除。
* 与 launcher meow_settings_brightness_clamped() 同源(同文件、同 clamp:下限 display.brightness_min
* 默认 1=极暗但可见、上限 100=防闪屏);本进程自身 wd_vol_load 早已读该文件,不引入 launcher 全量
* 依赖,保持 watchdog 独立性。返回 clamped 值;settings 文件不可读(S70 早启动 /usr/data 未就绪)
* → 返回 -1,调用方回退 wd_saved_bri(保持旧行为,防开机竞态)。 */
* M20 起:fopen 失败做 WD_SETTINGS_TRIES 次、间隔 WD_SETTINGS_GAP_MS 的短重试,
* 覆盖 settings.conf 原子替换 / 开机早期 /usr/data 未就绪的瞬时窗口;仍失败才返回 -1。
* 只重试 open:rename 是原子的,已打开的 fd 指向旧 inode、内容完整,读内容无需重试。
* 返回 -1 时调用方**不再**回退「默认 60」,取值语义统一由 wd_bri_decide() 决定。 */
static int wd_settings_brightness(void)
{
FILE *f = fopen(MEOW_SETTINGS_FILE, "r");
FILE *f = NULL;
for (int i = 0; i < WD_SETTINGS_TRIES; i++) {
f = fopen(wd_settings_path(), "r");
if (f) break;
if (i + 1 < WD_SETTINGS_TRIES) usleep(WD_SETTINGS_GAP_MS * 1000);
}
if (!f) return -1;
int b = 101; /* display.brightness 默认(与 launcher 设置表同值,键缺失时 clamp→100) */
int mn = 1; /* display.brightness_min 默认(极暗但可见;键缺失时保底) */
@@ -261,26 +315,53 @@ static void wd_pan_buf0_force(void)
ioctl(wd_fbfd, FBIOPAN_DISPLAY, v);
}
/* 亮屏亮度决策(纯函数:宿主单测直接断言,见 test_wd_fw.c)。
* 返回 >0 = 要写入的背光值;返回 -1 = **不写背光**(保持当前值)。
* *src 输出取值来源,原样写进日志供真机排查:
* settings settings.conf 的 display.brightness(已 clamp)—— 唯一权威
* snapshot 本次运行期熄屏前读到的真实亮度(>0 才算数)—— settings 瞬时不可读时的过渡值
* untouched settings 与快照都未知、但面板已亮 → 不覆盖现场(M20 核心修复)
* safe-low settings/快照都未知且面板当前为 0(黑)→ 点亮,用低亮而不是 60
* 优先级:settings → 快照(真实近似值)→ 保持现状 → 安全低亮。
* 任何分支都不再写「固定 60」:60 只在固件 S11 画完开机 logo 后写一次(固件侧问题,非本进程)。 */
static int wd_bri_decide(int user, int saved, int cur, const char **src)
{
if (user > 0) { *src = "settings"; return user; } /* 用户设置优先 */
if (saved > 0) { *src = "snapshot"; return saved; } /* 真实观测到的近似值 */
if (cur > 0) { *src = "untouched"; return -1; } /* 已有可见亮度:绝不覆盖 */
*src = "safe-low"; return WD_BRI_SAFE_LOW; /* 面板黑 + 无任何值来源 */
}
static void wd_panel_off(void)
{
int br = read_brightness();
wd_saved_bri = (br > 0) ? br : 60;
/* M20:快照只记「真实观测到的正亮度」。br<=0(读失败,或 launcher meow_panel_off 抢先
* 写成 0)时保留上一次已知真实值,绝不写死 60 —— 否则 60 会在下次亮屏被当成用户值回放
* (远古 bug 的燃料)。真的一次都没观测到正亮度时保持 WD_BRI_UNKNOWN。 */
if (br > 0) wd_saved_bri = br;
write_brightness(0);
if (wd_fbfd >= 0) ioctl(wd_fbfd, FBIOBLANK, 4); /* 面板下电 + 挂起 goodix 触摸 */
wd_log("power: panel OFF (was %d -> 0, blank 4)", br);
wd_log("power: panel OFF (was %d -> 0, blank 4, saved=%d)", br, wd_saved_bri);
}
static void wd_panel_on(void)
{
if (wd_fbfd >= 0) ioctl(wd_fbfd, FBIOBLANK, 0);
/* M6.23:恢复亮度以**用户设置**为准(wd_settings_brightness),settings 不可用时才回退
* wd_saved_bri 快照(再退默认 60)——与 launcher meow_panel_on 同语义,杜绝「亮屏恢复 60
* 与 launcher 恢复 7 竞态 → 跳变」。 */
int b = wd_settings_brightness();
int restore = (b > 0) ? b : (wd_saved_bri > 0 ? wd_saved_bri : 60);
write_brightness(restore);
/* M6.23 + M20:用户设置为唯一权威;settings 瞬时不可读用真实快照;两者都未知时
* **不写背光**(旧代码在这里写死 60,直接覆盖 launcher 刚按 display.brightness 设好的
* 用户值 → 开机/亮屏跳 60,真机 0.8.46 实证)。取值分支见 wd_bri_decide()。 */
int user = wd_settings_brightness(); /* >0=用户设置;-1=不可读 */
int cur = read_brightness(); /* 当前系统背光:判定「不写」是否安全 + 日志 */
const char *src = "?";
int restore = wd_bri_decide(user, wd_saved_bri, cur, &src);
if (restore > 0) write_brightness(restore);
wd_pan_buf0_force(); /* 唤醒后强制回 buffer0 */
wd_log("power: panel ON (blank 0 + brightness %d, user=%d saved=%d)", restore, b, wd_saved_bri);
if (restore > 0)
wd_log("power: panel ON (blank 0 + brightness %d src=%s user=%d saved=%d cur=%d)",
restore, src, user, wd_saved_bri, cur);
else
wd_log("power: panel ON (blank 0 + brightness UNTOUCHED cur=%d src=%s user=%d saved=%d)",
cur, src, user, wd_saved_bri);
}
/* launcher 正在画电源菜单(/tmp/meow_power_menu_active 存在)→ watchdog 完全让位。 */
@@ -372,7 +453,7 @@ static int wd_vol_step = 1; /* 跳步 */
static void wd_vol_load(void)
{
wd_vol = 60; wd_vol_max = 100; wd_vol_step = 1;
FILE *f = fopen(MEOW_SETTINGS_FILE, "r");
FILE *f = fopen(wd_settings_path(), "r");
if (!f) { wd_log("vol: no settings, default %d", wd_vol); return; }
char buf[128];
while (fgets(buf, sizeof buf, f)) {
@@ -391,13 +472,17 @@ static void wd_vol_load(void)
wd_log("vol: loaded volume=%d max=%d step=%d", wd_vol, wd_vol_max, wd_vol_step);
}
/* 写真实 DAC(CS43198, numid 1/2, 0-255),与 launcher meow_dac_vol_write 同路径。 */
/* 写真实 DAC(CS43198, numid 1/2, 0-255),与 launcher meow_dac_vol_write 同路径。
* ⚠️ 音量收编 v2 兜底函数:仅 audiod 未起(HibyOS/开机早期)时由 wd_vol_adj 回退调用;
* MeowOS 常态一律走 wd_audiod_set_volume(audiod 唯一权威,防多写者漂移)。
* 锥度为定案平方锥度(与 audiod dac_vol_set 同款,勿改语义:CS43198=衰减量,0 最响/255 静音)。 */
static void wd_vol_write(int pct)
{
int t = 100 - pct, dac;
if (pct <= 0) dac = 255; /* 真静音(DS1039:255=-127.5dB) */
else dac = t * t * 192 / 10000; /* 感知平方锥度:dB 衰减=(1-pct)^2×96dB */
if (dac > 255) dac = 255; if (dac < 0) dac = 0; /* CS43198=衰减量(0最响255静音)+平方锥度(听感线性) */
if (dac > 255) dac = 255;
if (dac < 0) dac = 0; /* CS43198=衰减量(0最响255静音)+平方锥度(听感线性) */
char cmd[160];
snprintf(cmd, sizeof cmd,
"amixer -c 0 cset numid=1 %d >/dev/null 2>&1; amixer -c 0 cset numid=2 %d >/dev/null 2>&1",
@@ -406,14 +491,97 @@ static void wd_vol_write(int pct)
wd_log("DAC vol -> L/R=%d (pct %d)", dac, pct);
}
/* ---- 音量收编 v2(FUTURE_AUDIO 事项三):首选 audiod 权威 MSG_AUDIO_VOLUME ----
* watchdog 为 per-App 小进程,audiod sock 0666 无鉴权可直接连。一次性短连接(按键 rare,
* 无常驻连接/陈旧态负担):非阻塞 connect + poll 300ms,SO_SNDTIMEO/RCVTIMEO 1s,发 i32 pct
* 等 MSG_ACK。audiod 侧已异步化(末值去抖,ACK=已应用+已持久化),正常 <<1s 返回。
* 返回 0=audiod 已受理(DAC 写+持久化均由 audiod 完成,本进程不再写 settings 防双写);
* -1=audiod 缺席/失败(调用方回退 wd_vol_write 本地直写 + wd_vol_save 本地持久化)。 */
#define WD_AUD_SOCK_DEFAULT "/usr/data/meow_audio.sock" /* 同 audiod AUD_SOCK_DEFAULT */
#define WD_AUD_CONN_TMO_MS 300
#define WD_AUD_IO_TMO_MS 1000
static const char *wd_aud_sock(void)
{
const char *e = getenv("MEOW_AUDIO_SOCK"); /* 与 SDK sock_path() 同款 env(宿主测覆盖) */
return (e && e[0]) ? e : WD_AUD_SOCK_DEFAULT;
}
static int wd_wr_all(int fd, const void *p, size_t n)
{
const uint8_t *b = p; size_t o = 0;
while (o < n) { ssize_t r = write(fd, b + o, n - o); if (r < 0) { if (errno == EINTR) continue; return -1; } o += (size_t)r; }
return 0;
}
static int wd_rd_all(int fd, void *p, size_t n)
{
uint8_t *b = p; size_t o = 0;
while (o < n) { ssize_t r = read(fd, b + o, n - o); if (r < 0) { if (errno == EINTR) continue; return -1; } if (r == 0) return -1; o += (size_t)r; }
return 0;
}
static int wd_audiod_set_volume(int pct)
{
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) return -1;
int fl = fcntl(fd, F_GETFL, 0);
if (fl >= 0) fcntl(fd, F_SETFL, fl | O_NONBLOCK);
struct sockaddr_un a;
memset(&a, 0, sizeof a);
a.sun_family = AF_UNIX;
snprintf(a.sun_path, sizeof a.sun_path, "%s", wd_aud_sock());
int r = connect(fd, (struct sockaddr *)&a, sizeof a);
if (r < 0 && errno == EINPROGRESS) {
struct pollfd p;
p.fd = fd; p.events = POLLOUT; p.revents = 0;
if (poll(&p, 1, WD_AUD_CONN_TMO_MS) > 0) {
int err = 0;
socklen_t el = sizeof err;
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &el) == 0 && err != 0) { errno = err; r = -1; }
} else {
r = -1; /* 超时/错误 */
}
}
if (r < 0) { close(fd); return -1; }
if (fl >= 0) fcntl(fd, F_SETFL, fl); /* 回阻塞模式;读写超时由 SO_*TIMEO 兜底 */
struct timeval tv;
tv.tv_sec = WD_AUD_IO_TMO_MS / 1000; tv.tv_usec = (WD_AUD_IO_TMO_MS % 1000) * 1000;
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
/* 发 MSG_AUDIO_VOLUME(i32 pct);req_id 任意(一次性连接单请求) */
uint8_t frame[MEOW_IPC_HDR_LEN + sizeof(int32_t)];
uint32_t magic = MEOW_IPC_MAGIC, flen = sizeof(int32_t), freq = 1;
uint16_t ver = MEOW_IPC_VERSION, type = MSG_AUDIO_VOLUME;
memcpy(frame + 0, &magic, 4);
memcpy(frame + 4, &ver, 2);
memcpy(frame + 6, &type, 2);
memcpy(frame + 8, &freq, 4);
memcpy(frame + 12, &flen, 4);
int32_t p32 = pct;
memcpy(frame + MEOW_IPC_HDR_LEN, &p32, sizeof p32);
if (wd_wr_all(fd, frame, sizeof frame) != 0) { close(fd); return -1; }
/* 等 MSG_ACK(有界:RCVTIMEO;audiod 异步执行点应用后回 ACK) */
uint8_t hdr[MEOW_IPC_HDR_LEN];
if (wd_rd_all(fd, hdr, sizeof hdr) != 0) { close(fd); return -1; }
uint32_t rmagic, rlen;
uint16_t rver, rtype;
memcpy(&rmagic, hdr, 4); memcpy(&rver, hdr + 4, 2); memcpy(&rtype, hdr + 6, 2); memcpy(&rlen, hdr + 12, 4);
if (rmagic != MEOW_IPC_MAGIC || rver != MEOW_IPC_VERSION || rtype != MSG_ACK || rlen > 64) { close(fd); return -1; }
uint8_t pl[64];
if (rlen && wd_rd_all(fd, pl, rlen) != 0) { close(fd); return -1; }
close(fd);
return 0;
}
/* M2.92+ 修复(真机实证 settings.conf 被毁):旧版 O_TRUNC 整文件重写只剩 4 行 ——
* 抹掉全部其它设置;与其它写者交错还叠出重复 audio.volume 行、丢 volume_max/step。
* 改为读改写单键(流式拷贝、命中行全部改写去重、缺失末尾追加、rename 原子替换),
* 与 SDK meow_ui_vol_set 同款语义。 */
static void wd_vol_save(void)
{
system("mkdir -p /usr/data/meow 2>/dev/null");
const char *path = MEOW_SETTINGS_FILE;
const char *path = wd_settings_path();
if (strcmp(path, MEOW_SETTINGS_FILE) == 0) /* 仅默认路径补目录(env 覆盖=宿主测,目录自备) */
system("mkdir -p /usr/data/meow 2>/dev/null");
char tmp[300];
snprintf(tmp, sizeof tmp, "%s.tmp", path);
FILE *in = fopen(path, "r");
@@ -490,7 +658,9 @@ static int wd_jack_debounce(void)
(now_ms() - wd_jack_changed_at < (unsigned long long)WD_JACK_DEBOUNCE_MS);
}
/* 音量步进:+1/-1(每按下边沿一次;clamp 到极大/最小)。 */
/* 音量步进:+1/-1(每按下边沿一次;clamp 到极大/最小)。
* 音量收编 v2:首选 audiod 权威(IPC;DAC 写+持久化 audiod 完成);audiod 缺席才本地
* amixer 直写 + 本地持久化兜底(wd_vol 本地缓存仅作步进基数,两路都维护)。 */
static void wd_vol_adj(int dir)
{
int target = wd_vol + dir * wd_vol_step;
@@ -498,9 +668,13 @@ static void wd_vol_adj(int dir)
if (target > wd_vol_max) target = wd_vol_max;
if (target == wd_vol) { wd_log("volume %s at limit (%d)", dir > 0 ? "UP" : "DOWN", wd_vol); return; }
wd_vol = target;
wd_vol_write(wd_vol);
if (wd_audiod_set_volume(wd_vol) == 0) {
wd_log("volume %s -> %d (via audiod)", dir > 0 ? "UP" : "DOWN", wd_vol);
return;
}
wd_vol_write(wd_vol); /* 兜底:仅 audiod 未起(HibyOS/开机早期) */
wd_vol_save();
wd_log("volume %s -> %d", dir > 0 ? "UP" : "DOWN", wd_vol);
wd_log("volume %s -> %d (local amixer fallback, audiod absent)", dir > 0 ? "UP" : "DOWN", wd_vol);
}
static void handle_touch(int immersive, int app_pid,
Binary file not shown.
+164 -3
View File
@@ -1,11 +1,14 @@
/* test_wd_fw.c — 宿主单测:meow_watchdog 的「SDK 框架 App 判定」逻辑(M2.86, issue 226)。
/* test_wd_fw.c — 宿主单测:meow_watchdog 的纯逻辑(M2.86 框架 App 判定 + M20 亮屏亮度)。
*
* 编译:#include meow_watchdog.c(去 main 拿 wd_app_is_framework/wd_json_str/wd_framework_from_pid)。
* 编译:#include meow_watchdog.c(去 main 拿 wd_app_is_framework/json_str/from_pid、
* wd_bri_decide、wd_settings_brightness、wd_panel_off/on)。
* gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
* test_wd_fw.c -o test_wd_fw
* (meow_watchdog.c 含仅 main 使用的静态函数,宿主去 main 后须 -Wno-unused-function 静音;
* mips 目标构建不定义 WD_NO_MAIN,不受影响。)
* 运行:./test_wd_fw;全 PASS -> exit 0。
* M20 用例用 env MEOW_BACKLIGHT / MEOW_SETTINGS_FILE 把背光与 settings 指到临时文件,
* 真的走 wd_panel_off/wd_panel_on 的读写路径(真机无这两个 env,走 /sys 与 /usr/data 默认)。
*/
#define WD_NO_MAIN
#include "meow_watchdog.c"
@@ -24,6 +27,14 @@ static void check(const char *name, int got, int want)
else { g_fail++; printf("FAIL %s: got %d want %d\n", name, got, want); }
}
/* 临时目录根:优先 $TMPDIR(Termux/Android 上 /tmp 属于 shell 用户、本进程不可写),
* 否则 /tmp(Linux 构建容器)。让 `build_watchdog.sh host` 在两种环境都能直接跑。 */
static const char *tmp_root(void)
{
const char *t = getenv("TMPDIR");
return (t && t[0]) ? t : "/tmp";
}
/* ---- M2.87:SIGUSR1 手势让位(handle_touch 左缘滑动分支)----
* 判定(meow_watchdog.c):`!g_fw_app && immersive` 才发 SIGUSR1(只给裸沉浸 App);
* SDK 框架 App(g_fw_app=1)不发(返回由 SDK on_swipe 处理),防 SIGUSR1 杀死 App。 */
@@ -68,10 +79,155 @@ static int expect_fw(const char *base, const char *name, const char *appjson, in
return 1;
}
/* ---- M20:亮屏亮度决策(wd_bri_decide 纯函数)----
* 真机 bug(0.8.46,2026-11-05):settings display.brightness=1,亮屏时 watchdog 写入固定 60
* 覆盖它。新语义:settings 优先 → 真实快照 → 面板已亮则不写 → 面板黑才写安全低亮。 */
static void test_bri_decide(void)
{
const char *src = NULL;
int v;
v = wd_bri_decide(1, 60, 60, &src); /* 用户值 1 对上被污染的 60/当前 60:必须写 1 */
check("bri: settings wins over saved/cur", v == 1 && !strcmp(src, "settings"), 1);
v = wd_bri_decide(7, 0, 7, &src);
check("bri: settings 7", v == 7 && !strcmp(src, "settings"), 1);
v = wd_bri_decide(-1, 42, 3, &src); /* settings 不可读 → 用真实快照 */
check("bri: snapshot fallback", v == 42 && !strcmp(src, "snapshot"), 1);
v = wd_bri_decide(-1, 0, 3, &src); /* 都未知但面板已亮 → 不写(核心修复) */
check("bri: untouched keeps cur", v == -1 && !strcmp(src, "untouched"), 1);
v = wd_bri_decide(-1, 0, 0, &src); /* 都未知且面板黑 → 安全低亮,绝不 60 */
check("bri: safe-low not 60", v == WD_BRI_SAFE_LOW && v != 60 && v > 0 &&
!strcmp(src, "safe-low"), 1);
v = wd_bri_decide(-1, 0, -1, &src); /* 背光读失败按「黑」处理,仍要点亮 */
check("bri: read-fail -> safe-low", v == WD_BRI_SAFE_LOW && !strcmp(src, "safe-low"), 1);
v = wd_bri_decide(0, -1, 5, &src); /* 非正值一律按「未知」,不当作亮度 */
check("bri: non-positive treated unknown", v == -1 && !strcmp(src, "untouched"), 1);
}
/* ---- M20:settings 解析 + 重试 ----
* 用真机 /usr/data/meow/settings.conf 的关键行断言「路径/键名/解析/clamp 都是对的」,
* 证明真机写 60 不是本函数返回 -1(原因见报文:出货二进制根本没编进这段代码)。 */
static void test_settings_brightness(void)
{
char path[600];
snprintf(path, sizeof path, "%s/wdsetXXXXXX", tmp_root());
int fd = mkstemp(path);
if (fd < 0) { g_fail++; printf("FAIL settings: mkstemp\n"); return; }
FILE *f = fdopen(fd, "w");
fputs("# meow-settings 1\n"
"display.brightness = 1\n"
"display.brightness_min = 1\n"
"audio.volume = 10\n", f);
fclose(f);
setenv("MEOW_SETTINGS_FILE", path, 1);
check("settings: device file -> 1", wd_settings_brightness(), 1);
f = fopen(path, "w"); /* clamp 抬到 brightness_min */
fputs("display.brightness = 0\ndisplay.brightness_min = 3\n", f);
fclose(f);
check("settings: clamp up to min", wd_settings_brightness(), 3);
f = fopen(path, "w"); /* clamp 压到 100 */
fputs("display.brightness = 200\ndisplay.brightness_min = 1\n", f);
fclose(f);
check("settings: clamp down to 100", wd_settings_brightness(), 100);
f = fopen(path, "w"); /* 键缺失:与 launcher 设置表同默认 101 -> clamp 100 */
fputs("audio.volume = 10\n", f);
fclose(f);
check("settings: missing key -> 100", wd_settings_brightness(), 100);
/* 不可读(原子替换/未挂载窗口):open 重试 3 次仍失败才 -1,总耗时 ≈ 20ms */
unlink(path);
unsigned long long t0 = now_ms();
int r = wd_settings_brightness();
unsigned long long dt = now_ms() - t0;
check("settings: missing -> -1", r, -1);
check("settings: open retried (>=15ms)", dt >= 15, 1);
unsetenv("MEOW_SETTINGS_FILE");
}
static void put_file(const char *path, const char *body)
{
FILE *f = fopen(path, "w");
if (!f) return;
fputs(body, f);
fclose(f);
}
static int get_file_int(const char *path)
{
FILE *f = fopen(path, "r");
if (!f) return -1;
int v = -1;
if (fscanf(f, "%d", &v) != 1) v = -1;
fclose(f);
return v;
}
/* ---- M20:panel OFF/ON 端到端(真的读写一个「背光文件」,不 mock 函数)----
* 复现真机场景:settings display.brightness=1 → 熄屏 → 亮屏。旧代码亮屏写 60,
* 新代码必须写 1;且 settings 不可读、快照未知时保持当前值而不是回退 60。 */
static void test_panel_brightness_e2e(void)
{
char bl[600], st[600];
snprintf(bl, sizeof bl, "%s/wdblXXXXXX", tmp_root());
snprintf(st, sizeof st, "%s/wdstXXXXXX", tmp_root());
int fd = mkstemp(bl);
if (fd < 0) { g_fail++; printf("FAIL e2e: mkstemp bl\n"); return; }
close(fd);
fd = mkstemp(st);
if (fd < 0) { g_fail++; printf("FAIL e2e: mkstemp st\n"); unlink(bl); return; }
close(fd);
setenv("MEOW_BACKLIGHT", bl, 1);
setenv("MEOW_SETTINGS_FILE", st, 1);
/* 1) 真机 bug 场景:settings=1、屏亮 1 → OFF 快照 1;ON 必须写 1,绝不 60 */
put_file(bl, "1\n");
put_file(st, "display.brightness = 1\ndisplay.brightness_min = 1\n");
wd_saved_bri = WD_BRI_UNKNOWN;
wd_panel_off();
check("e2e: panel OFF writes 0", get_file_int(bl), 0);
check("e2e: panel OFF snapshot = 1", wd_saved_bri, 1);
wd_panel_on();
check("e2e: panel ON restores settings 1 (not 60)", get_file_int(bl), 1);
/* 2) 用户改了亮度:settings 仍是唯一权威(旧快照 1 不得覆盖 5) */
put_file(st, "display.brightness = 5\ndisplay.brightness_min = 1\n");
put_file(bl, "0\n");
wd_panel_on();
check("e2e: panel ON follows new settings 5", get_file_int(bl), 5);
/* 3) settings 不可读 + 快照未知 + 屏亮 3 → 不写(保持 3,不再回退 60) */
unlink(st);
put_file(bl, "3\n");
wd_saved_bri = WD_BRI_UNKNOWN;
wd_panel_on();
check("e2e: panel ON leaves 3 untouched", get_file_int(bl), 3);
/* 4) settings 不可读 + 快照未知 + 屏黑 0 → 写安全低亮(>0 且 != 60) */
put_file(bl, "0\n");
wd_panel_on();
check("e2e: panel ON dark -> safe-low", get_file_int(bl), WD_BRI_SAFE_LOW);
/* 5) settings 不可读但本次运行期真观测到 7 → 快照优先于「不动」 */
put_file(bl, "7\n");
wd_saved_bri = WD_BRI_UNKNOWN;
wd_panel_off(); /* 观测到 7,写 0 */
check("e2e: panel OFF snapshot = 7", wd_saved_bri, 7);
wd_panel_on();
check("e2e: panel ON uses real snapshot 7", get_file_int(bl), 7);
unlink(bl);
unsetenv("MEOW_BACKLIGHT");
unsetenv("MEOW_SETTINGS_FILE");
}
int main(void)
{
signal(SIGUSR1, on_sigusr1); /* 捕获 handle_touch 可能发出的 SIGUSR1(测命中) */
char dir[] = "/tmp/wdfwXXXXXX";
char dir[600];
snprintf(dir, sizeof dir, "%s/wdfwXXXXXX", tmp_root());
if (!mkdtemp(dir)) { perror("mkdtemp"); return 2; }
/* 框架 App:各值形态 */
@@ -109,6 +265,11 @@ int main(void)
check("sigusr1 bare-immersive -> 1", sim_swipe_sigusr1(0, 1), 1); /* 裸沉浸:发(命中) */
check("sigusr1 bare-nonimmersive -> 0", sim_swipe_sigusr1(0, 0), 0); /* 裸非沉浸:不发 */
/* ---- M20:亮屏亮度(决策 + settings 解析/重试 + panel OFF/ON 端到端)---- */
test_bri_decide();
test_settings_brightness();
test_panel_brightness_e2e();
/* 清理 */
{
char path[560];
+7
View File
@@ -289,6 +289,8 @@ 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);
int net_locked_entity(const char *entity); /* 防自锁:系统关键网络/服务组件(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init) */
int net_bt_aggregate(const char *st, const char *paired, const char *connected, const char *a2dp, const char *devices, sbuf_t *s); /* F1-③ 0xE6 五读文本聚合(selftest 可测) */
int api_net_bt(int sock); /* F1-③ GET /api/net/bt 蓝牙只读详情 */
int panel_policy_write_enabled(void); /* policy 默认关 gate:settings.conf `panel.policy_enabled`,缺省 0=关(只读透传) */
void panel_strip_ansi(char *s);
int txt_tokens(char *buf, char **toks, int maxtoks);
@@ -304,6 +306,7 @@ 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);
int api_ams_kill_fg(int sock, const char *pkg); /* 前台强制终止(救命):am kill 优先,launcher 拒→直接 pid 信号 */
void svcd_fill(sbuf_t *s);
int api_svcd(int sock);
int api_overview(int sock);
@@ -414,6 +417,8 @@ 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 files_search_json(const char *abs_start, const char *name, const char *typef, int max, sbuf_t *out); /* F1-④ 搜索核心(selftest 可测) */
int api_files_search(int sock, const req_t *r); /* F1-④ GET /api/files/search(只读) */
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);
@@ -446,7 +451,9 @@ 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 inject_swipe(const char *dev, int x0, int y0, int x1, int y1);
int api_input_tap(int sock, const req_t *r);
int api_input_gesture(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);
+147 -19
View File
@@ -253,7 +253,32 @@ static void bg_proc_sample(int pid, double *pcpu, long *rss_kb)
}
}
/* 解析已 strip 的 `am list` 全文 → bg 列表 JSON(state=bg 的 APP 行)。 */
/* 发射单条 APP 行 JSON(复用于 bg/fg 列表;带 state + is_fg + cpu_pct/rss_kb)。
* toks 为已切分的 `APP ...` 行(n>=9);*first 控制数组元素逗号(调用方初始化为 1)。
* cpu_pct/rss_kb 诚实原则:pid 读不到/不作为可用进程 → null(前端显「—」),绝不编造。 */
static void ams_entry_emit(sbuf_t *out, char **toks, int n, int *first)
{
const char *st = toks[n - 6]; /* 自右向左:state=tok[n-6], pid=n-5(与 ams_fill 同布局) */
char nm[128];
join_tokens(toks, 2, n - 6, nm, sizeof nm);
int pid = atoi(toks[n - 5]);
double pcpu = -1.0; long rss = -1;
if (pid > 0) bg_proc_sample(pid, &pcpu, &rss);
if (!*first) sA(out, ",");
*first = 0;
sA(out, "{\"pkg\":"); sA_str(out, toks[1]);
sA(out, ",\"name\":"); sA_str(out, nm);
sA(out, ",\"pid\":%d", pid);
sA(out, ",\"state\":"); sA_str(out, st);
sA(out, ",\"is_fg\":%s", strcmp(st, "fg") == 0 ? "true" : "false");
sA(out, ",\"cpu_pct\":");
if (pcpu < 0) sA(out, "null"); else sA(out, "%.1f", pcpu);
sA(out, ",\"rss_kb\":");
if (rss < 0) sA(out, "null"); else sA(out, "%ld", rss);
sA(out, "}");
}
/* 解析已 strip 的 `am list` 全文 → bg 列表 JSON(state=bg 的 APP 行;向后兼容专用,selftest 覆盖)。 */
void ams_bg_parse(char *list_text, sbuf_t *s)
{
char *toks[256];
@@ -266,25 +291,10 @@ void ams_bg_parse(char *list_text, sbuf_t *s)
if (strncmp(ln, "APP ", 4) == 0) {
int n = txt_tokens(ln, toks, 256);
if (n >= 9) {
/* 自右向左固定(与 ams_fill 同布局):state=tok[n-6], pid=n-5;pkg=tok[1],name=join(tok[2..n-6)) */
const char *st = toks[n - 6];
if (strcmp(st, "bg") == 0) {
char nm[128];
join_tokens(toks, 2, n - 6, nm, sizeof nm);
int pid = atoi(toks[n - 5]);
double pcpu; long rss;
bg_proc_sample(pid, &pcpu, &rss);
if (!first) sA(&apps, ",");
first = 0; count++;
sA(&apps, "{\"pkg\":"); sA_str(&apps, toks[1]);
sA(&apps, ",\"name\":"); sA_str(&apps, nm);
sA(&apps, ",\"pid\":%d", pid);
sA(&apps, ",\"state\":"); sA_str(&apps, st);
sA(&apps, ",\"cpu_pct\":");
if (pcpu < 0) sA(&apps, "null"); else sA(&apps, "%.1f", pcpu);
sA(&apps, ",\"rss_kb\":");
if (rss < 0) sA(&apps, "null"); else sA(&apps, "%ld", rss);
sA(&apps, "}");
count++;
ams_entry_emit(&apps, toks, n, &first);
}
}
}
@@ -295,6 +305,8 @@ void ams_bg_parse(char *list_text, sbuf_t *s)
sbuf_free(&apps);
}
/* am list 全文 → bg+fg 双列表 JSON。`apps`(bg)保持向后兼容(count=bg 数量);
* 追加 `fg` + `fg_count`(前台 App,前端救命分组)。state=stop 无进程,不在本页(proc 页可见)。 */
void ams_bg_fill(sbuf_t *s)
{
char out[16384];
@@ -303,7 +315,28 @@ void ams_bg_fill(sbuf_t *s)
int code = run_cmd(cmd, out, sizeof out);
if (code != 0 || !strstr(out, "APP")) { sA(s, "{\"available\":false}"); return; }
panel_strip_ansi(out);
ams_bg_parse(out, s);
char *toks[256];
sbuf_t bg = {0}, fg = {0};
int bg_first = 1, fg_first = 1, bg_count = 0, fg_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) {
const char *st = toks[n - 6];
if (strcmp(st, "bg") == 0) { bg_count++; ams_entry_emit(&bg, toks, n, &bg_first); }
else if (strcmp(st, "fg") == 0) { fg_count++; ams_entry_emit(&fg, toks, n, &fg_first); }
}
}
if (nl) { *nl = '\n'; ln = nl + 1; } else break;
}
sA(s, "{\"available\":true,\"count\":%d,\"ts\":%ld,\"apps\":[%s]",
bg_count, (long)time(NULL), bg.b ? bg.b : "");
sA(s, ",\"fg_count\":%d,\"fg\":[%s]}", fg_count, fg.b ? fg.b : "");
sbuf_free(&bg);
sbuf_free(&fg);
}
int api_ams_bg(int sock)
@@ -314,3 +347,98 @@ int api_ams_bg(int sock)
sbuf_free(&s);
return rc;
}
/* 从 am list 解析某 pkg 的当前 pid(未注册/无进程 → 0)。 */
static int ams_pkg_pid(const char *pkg)
{
char out[16384];
char cmd[300];
snprintf(cmd, sizeof cmd, "%s list 2>/dev/null", ams_bin());
if (run_cmd(cmd, out, sizeof out) != 0 || !strstr(out, "APP")) return 0;
panel_strip_ansi(out);
char *toks[256];
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 && strcmp(toks[1], pkg) == 0) {
int pid = atoi(toks[n - 5]);
if (nl) *nl = '\n';
return pid;
}
}
if (nl) { *nl = '\n'; ln = nl + 1; } else break;
}
return 0;
}
/* /proc/<pid>/stat 进程是否已消失/僵尸:-1=已消失,1=僵尸,0=存活。 */
static int proc_zombie(int pid)
{
char path[64], line[256];
snprintf(path, sizeof path, "/proc/%d/stat", pid);
if (read_small_file(path, line, sizeof line) < 0) return -1;
char *rp = strrchr(line, ')');
if (!rp) return -1;
const char *p = rp + 2;
while (*p == ' ' || *p == '\t') p++;
return (*p == 'Z') ? 1 : 0;
}
/* 前台强制终止(救命):优先走 `am kill`(launcher 干净 SIGTERM 序列,accepted);若 launcher 拒
* (framework / 无前台会话 → "launcher refused"),回退:解析 am list 拿当前 pid → 直接 SIGTERM,
* 宽限后仍存活走 SIGKILL(与 launcher 杀序列同语义)。这是用户明示「前台卡死救命」的强通道——
* 前端已二次确认 + 标注「前台」。require_auth 在路由层已护。 */
int api_ams_kill_fg(int sock, const char *pkg)
{
if (!ams_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
/* ① 干净通道:am kill(launcher 执行 SIGTERM 序列)。 */
{
char out[256];
char cmd[320];
snprintf(cmd, sizeof cmd, "%s kill %s 2>/dev/null", ams_bin(), pkg);
int code = run_cmd(cmd, out, sizeof out);
if (code == 0 && strstr(out, "OK ")) {
sbuf_t s = {0};
sA(&s, "{\"accepted\":true,\"op\":\"kill-fg\",\"pkg\":");
sA_str(&s, pkg);
sA(&s, ",\"force\":false,\"message\":"); sA_str(&s, out);
sA(&s, "}");
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
panel_log("ams kill-fg %s via am refused: %s -> direct signal fallback", pkg,
out[0] ? out : "(empty)");
}
/* ② 回退:直接按 pid 信号(绕过 launcher 门控)。 */
int pid = ams_pkg_pid(pkg);
if (pid <= 0) return send_err(sock, 409, "CONFLICT", "该 App 当前无进程可终止");
kill(pid, SIGTERM);
int killed_hard = 0;
for (int i = 0; i < 5; i++) { /* SIGTERM 宽限 ~0.6s(与 launcher 杀序列同语义) */
struct timespec ts = {0, 120 * 1000 * 1000};
nanosleep(&ts, NULL);
int z = proc_zombie(pid);
if (z < 0 || z == 1) break; /* 消失/僵尸 → 已终止 */
}
if (proc_zombie(pid) == 0) { kill(pid, SIGKILL); killed_hard = 1; }
char msg[192];
snprintf(msg, sizeof msg, "force terminated pid %d via direct signal%s", pid,
killed_hard ? " (SIGKILL 强杀)" : " (SIGTERM)");
sbuf_t s = {0};
sA(&s, "{\"accepted\":true,\"op\":\"kill-fg\",\"pkg\":");
sA_str(&s, pkg);
sA(&s, ",\"force\":%s,\"pid\":%d,\"message\":", killed_hard ? "true" : "false", pid);
sA_str(&s, msg);
sA(&s, "}");
int rc = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return rc;
}
+136
View File
@@ -571,6 +571,142 @@ int api_files_delete(int sock, const req_t *r)
return send_json(sock, 200, "{\"removed\":true}");
}
/* ---- F1-④ 文件搜索(FUTURE_MEOWPANEL §2.3,只读):白名单内 readdir 递归(限深 4 / 限量 200 /
* 限遍历 8000 条),不 run_cmd("find")(防注入、可控)。name=名子串(ASCII 大小写不敏感);
* type=file|dir 可选过滤。不跟符号链接(防循环/逃逸出白名单根)。 ---- */
#define SEARCH_MAX_DEPTH 4
#define SEARCH_MAX_ITEMS 200
#define SEARCH_MAX_VISIT 8000
typedef struct {
const char *needle; /* 已小写化的匹配子串 */
int want_type; /* 0=不限 1=仅文件 2=仅目录 */
int max;
int count;
long visited;
sbuf_t *s;
} search_ctx_t;
static void name_lower(const char *in, char *out, size_t cap)
{
size_t i = 0;
for (; in[i] && i + 1 < cap; i++) {
unsigned char c = (unsigned char)in[i];
out[i] = (c >= 'A' && c <= 'Z') ? (char)(c + 32) : (char)c;
}
out[i] = 0;
}
/* 返回 1=到达上限应全程停止(truncated)。 */
static int search_walk(search_ctx_t *c, const char *dir, int depth)
{
if (c->count >= c->max || c->visited >= SEARCH_MAX_VISIT) return 1;
DIR *d = opendir(dir);
if (!d) return 0;
int stop = 0;
struct dirent *e;
while ((e = readdir(d))) {
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue;
if (++c->visited > SEARCH_MAX_VISIT) { stop = 1; break; }
char full[1400];
snprintf(full, sizeof full, "%s/%s", dir, e->d_name);
struct stat st;
if (lstat(full, &st) < 0) continue;
if (S_ISLNK(st.st_mode)) continue; /* 不跟符号链接 */
int isdir = S_ISDIR(st.st_mode);
int type_ok = !c->want_type || (c->want_type == 1 && !isdir) || (c->want_type == 2 && isdir);
if (type_ok) {
char low[300];
name_lower(e->d_name, low, sizeof low);
if (strstr(low, c->needle)) {
if (c->count >= c->max) { stop = 1; break; }
char rel[1200];
make_rel(full, rel, sizeof rel);
if (c->count) sA(c->s, ",");
sA(c->s, "{\"name\":");
sA_str(c->s, e->d_name);
sA(c->s, ",\"type\":\"%s\",\"size\":%lld,\"mtime\":%lld,\"rel\":",
isdir ? "dir" : "file", isdir ? 0LL : (long long)st.st_size, (long long)st.st_mtime);
sA_str(c->s, rel);
sA(c->s, "}");
c->count++;
}
}
if (isdir && depth < SEARCH_MAX_DEPTH && search_walk(c, full, depth + 1)) { stop = 1; break; }
}
closedir(d);
return stop;
}
/* 0=OK(JSON 入 out);-1 白名单外;-2 路径不存在/非目录;-3 name 空。 */
int files_search_json(const char *abs_start, const char *name, const char *typef, int max, sbuf_t *out)
{
if (!path_in_whitelist(abs_start)) return -1;
struct stat st;
if (stat(abs_start, &st) < 0 || !S_ISDIR(st.st_mode)) return -2;
char needle[300];
name_lower(name ? name : "", needle, sizeof needle);
if (!needle[0]) return -3;
int want_type = 0;
if (typef && !strcmp(typef, "file")) want_type = 1;
else if (typef && !strcmp(typef, "dir")) want_type = 2;
else if (typef && typef[0]) return -4; /* 非法 type 值(api 层映射 422) */
if (max <= 0 || max > SEARCH_MAX_ITEMS) max = SEARCH_MAX_ITEMS;
search_ctx_t c;
memset(&c, 0, sizeof c);
c.needle = needle; c.want_type = want_type; c.max = max; c.s = out;
char rel[1200];
make_rel(abs_start, rel, sizeof rel);
sA(out, "{\"path\":");
sA_str(out, abs_start);
sA(out, ",\"rel\":");
sA_str(out, rel);
sA(out, ",\"name\":");
sA_str(out, name);
sA(out, ",\"items\":[");
int stopped = search_walk(&c, abs_start, 1);
sA(out, "],\"count\":%d,\"truncated\":%s}", c.count,
(stopped || c.visited >= SEARCH_MAX_VISIT) ? "true" : "false");
return 0;
}
int api_files_search(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);
char name[512] = "";
if (!qparam(r->query, "name", name, sizeof name) || !name[0])
return send_err(sock, 400, "BAD_REQUEST", "缺 name(按名子串搜索)");
char typef[16] = "";
qparam(r->query, "type", typef, sizeof typef);
char maxs[16] = "";
int max = SEARCH_MAX_ITEMS;
if (qparam(r->query, "max", maxs, sizeof maxs)) { int m = atoi(maxs); if (m > 0) max = m; }
char abs[1024];
if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs))
return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸");
sbuf_t s = {0};
int rc = files_search_json(abs, name, typef, max, &s);
if (rc == -1) { sbuf_free(&s); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); }
if (rc == -2) { sbuf_free(&s); return send_err(sock, 404, "NOT_FOUND", "路径不存在或非目录"); }
if (rc == -4) { sbuf_free(&s); return send_err(sock, 422, "UNSUPPORTED", "type 须为 file|dir"); }
if (rc != 0) { sbuf_free(&s); return send_err(sock, 400, "BAD_REQUEST", "name 为空"); }
/* 放行前自校验(与 overview 同口径:括号平衡/内嵌 NUL/坏字节,浏览器 JSON.parse 不容忍) */
jv_t *chk = jv_parse(s.b ? s.b : "{}", (int)(s.b ? s.n : 2));
int bad = !s.b || !chk || (strlen(s.b) != s.n) ||
!utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n);
if (chk) jv_free(chk);
if (bad) {
panel_log("files/search: SELF-CHECK fail (n=%zu)", s.n);
sbuf_free(&s);
return send_err(sock, 500, "INTERNAL", "搜索结果输出未通过 JSON 自校验");
}
int out = send_json(sock, 200, s.b);
sbuf_free(&s);
return out;
}
int multipart_extract_file(const req_t *r, const char *outpath)
{
const char *b = strstr(r->content_type, "boundary=");
+154
View File
@@ -175,6 +175,160 @@ int api_net_dns_flush(int sock)
return rc;
}
/* ---- F1-③ 蓝牙只读详情(FUTURE_MEOWPANEL §2.6):netmgrd MSG_NET_BT_CTL(0xE6) 读级五命令
* (status/paired/connected/a2dp/devices)聚合为一份 JSON。读级任意调用者(bt_cmd_is_write=读),
* 无阻塞(net_ipc 2s 超时兜底);子命令失败/坏字节 → 该子块空数组降级,绝不拖垮整卡、绝不发非法 JSON。 ---- */
/* 单发 0xE6 读命令;成功返回文本长度(>0),IPC/服务端失败 -1,坏 UTF-8 -2(该子块降级)。 */
static int bt_ctl_read(const char *cmd, char *out, size_t cap)
{
m_net_cmd_t q; memset(&q, 0, sizeof q);
snprintf(q.cmd, sizeof q.cmd, "%s", cmd);
uint16_t t; uint32_t code;
int n = net_ipc(MSG_NET_BT_CTL, &q, sizeof q, &t, &code, NULL, out, cap);
if (n <= 0 || t != MSG_NET_BT_RESULT || code != MEOW_OK) return -1;
if (!utf8_valid_n(out, (size_t)n)) return -2;
return n;
}
static int bt_mac_ok(const char *m)
{
for (int i = 0; i < 17; i++) {
if (i % 3 == 2) { if (m[i] != ':') return 0; }
else if (!is_hex(m[i])) return 0;
}
return 1;
}
/* "Device <MAC> <Name>" 行集 → JSON 数组项(不合法行跳过;名经 sA_str_n 消毒)。返回条数。 */
static int bt_devlines_json(sbuf_t *s, const char *txt, int max)
{
int n = 0;
const char *ln = txt;
while (ln && *ln && n < max) {
const char *nl = strchr(ln, '\n');
size_t ll = nl ? (size_t)(nl - ln) : strlen(ln);
const char *p = ln;
while ((size_t)(p - ln) < ll && (*p == ' ' || *p == '\t')) p++;
if ((size_t)(ln + ll - p) >= 7 + 17 && !strncmp(p, "Device ", 7) && bt_mac_ok(p + 7)) {
const char *mac = p + 7;
const char *nm = mac + 17;
while ((size_t)(nm - ln) < ll && (*nm == ' ' || *nm == '\t')) nm++;
size_t nlen = ll - (size_t)(nm - ln);
while (nlen && (nm[nlen - 1] == ' ' || nm[nlen - 1] == '\t' || nm[nlen - 1] == '\r')) nlen--;
if (n) sA(s, ",");
sA(s, "{\"mac\":");
sA_str_n(s, mac, 17);
sA(s, ",\"name\":");
sA_str_n(s, nm, nlen);
sA(s, "}");
n++;
}
ln = nl ? nl + 1 : NULL;
}
return n;
}
/* 行内 " key=value"(空白/行尾收尾)取值。 */
static void bt_line_kv(const char *line, const char *key, char *out, size_t cap)
{
out[0] = 0;
const char *v = strstr(line, key);
if (!v) return;
v += strlen(key);
size_t i = 0;
while (v[i] && v[i] != ' ' && v[i] != '\t' && v[i] != '\n' && v[i] != '\r' && i + 1 < cap) { out[i] = v[i]; i++; }
out[i] = 0;
}
/* "A2DP <mac> codec=.. rate=.. channels=.. bits=.. acquired=<0|1> role=<..>" 行集 → JSON 数组项。 */
static int bt_a2dp_json(sbuf_t *s, const char *txt, int max)
{
int n = 0;
const char *ln = txt;
while (ln && *ln && n < max) {
const char *nl = strchr(ln, '\n');
size_t ll = nl ? (size_t)(nl - ln) : strlen(ln);
char line[320];
if (ll >= sizeof line) ll = sizeof line - 1;
memcpy(line, ln, ll); line[ll] = 0;
if (!strncmp(line, "A2DP ", 5) && strncmp(line, "A2DP none", 9) && bt_mac_ok(line + 5)) {
char codec[24], rate[16], chn[8], bits[8], acq[8], role[8];
bt_line_kv(line, " codec=", codec, sizeof codec);
bt_line_kv(line, " rate=", rate, sizeof rate);
bt_line_kv(line, " channels=", chn, sizeof chn);
bt_line_kv(line, " bits=", bits, sizeof bits);
bt_line_kv(line, " acquired=", acq, sizeof acq);
bt_line_kv(line, " role=", role, sizeof role);
if (n) sA(s, ",");
sA(s, "{\"mac\":");
sA_str_n(s, line + 5, 17);
sA(s, ",\"codec\":"); sA_str(s, codec);
sA(s, ",\"rate\":"); sA_str(s, rate);
sA(s, ",\"channels\":"); sA_str(s, chn);
sA(s, ",\"bits\":"); sA_str(s, bits);
sA(s, ",\"acquired\":%s", !strcmp(acq, "1") ? "true" : "false");
sA(s, ",\"role\":"); sA_str(s, role);
sA(s, "}");
n++;
}
ln = nl ? nl + 1 : NULL;
}
return n;
}
/* 五读文本聚合(任一 NULL=该子块空数组;st=NULL → {"available":false})。 */
int net_bt_aggregate(const char *st, const char *paired, const char *connected,
const char *a2dp, const char *devices, sbuf_t *s)
{
if (!st) { sA(s, "{\"available\":false}"); return -1; }
int avail = 0, powered = 0; long long paired_n = -1, connected_n = -1;
char addr[24] = "";
(void)sscanf(st, "bt available=%d powered=%d address=%23s paired=%lld connected=%lld",
&avail, &powered, addr, &paired_n, &connected_n);
if (!strcmp(addr, "-")) addr[0] = 0;
sA(s, "{\"available\":%s", avail ? "true" : "false");
sA(s, ",\"powered\":%s", powered ? "true" : "false");
sA(s, ",\"address\":"); sA_str(s, addr);
if (paired_n >= 0) sA(s, ",\"paired_n\":%lld", paired_n); else sA(s, ",\"paired_n\":null");
if (connected_n >= 0) sA(s, ",\"connected_n\":%lld", connected_n); else sA(s, ",\"connected_n\":null");
sA(s, ",\"paired\":["); if (paired) bt_devlines_json(s, paired, 32); sA(s, "]");
sA(s, ",\"connected\":["); if (connected) bt_devlines_json(s, connected, 16); sA(s, "]");
sA(s, ",\"a2dp\":["); if (a2dp) bt_a2dp_json(s, a2dp, 8); sA(s, "]");
sA(s, ",\"nearby\":["); if (devices) bt_devlines_json(s, devices, 16); sA(s, "]");
sA(s, "}");
return 0;
}
int api_net_bt(int sock)
{
char st[MEOW_IPC_MAX], pa[MEOW_IPC_MAX], co[MEOW_IPC_MAX], a2[MEOW_IPC_MAX], dv[MEOW_IPC_MAX];
int ns = bt_ctl_read("status", st, sizeof st);
if (ns <= 0) /* netmgrd 不可达/status 坏字节 → 整体降级(net 域惯例:200 available:false) */
return send_json(sock, 200, "{\"available\":false}");
int np = bt_ctl_read("paired", pa, sizeof pa);
int nc = bt_ctl_read("connected", co, sizeof co);
int na = bt_ctl_read("a2dp", a2, sizeof a2);
int nd = bt_ctl_read("devices", dv, sizeof dv);
sbuf_t s = {0};
net_bt_aggregate(st, np > 0 ? pa : NULL, nc > 0 ? co : NULL, na > 0 ? a2 : NULL, nd > 0 ? dv : NULL, &s);
/* 放行前自校验(与 overview 同口径:浏览器 JSON.parse 不容忍尾随垃圾/括号错位/内嵌 NUL/坏字节) */
jv_t *chk = jv_parse(s.b ? s.b : "{}", (int)(s.b ? s.n : 2));
int bad = !s.b || !chk || (strlen(s.b) != s.n) ||
!utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n);
if (chk) jv_free(chk);
if (bad) {
FILE *df = fopen("/tmp/net_bt_bad.json", "w");
if (df && s.b) { fwrite(s.b, 1, s.n, df); fclose(df); }
panel_log("net/bt: SELF-CHECK fail, raw dumped /tmp/net_bt_bad.json");
sbuf_free(&s);
return send_err(sock, 500, "INTERNAL", "蓝牙聚合输出未通过 JSON 自校验(原始数据已留 /tmp/net_bt_bad.json)");
}
int rc = send_json(sock, 200, s.b);
sbuf_free(&s);
return rc;
}
int api_net_log(int sock)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
+122 -1
View File
@@ -1,4 +1,5 @@
/* mp_screen.c — 实时画面域:/api/screen.jpeg(fb0→JPEG,增量帧)+ 触摸注入 /api/input/tap。 */
/* mp_screen.c — 实时画面域:/api/screen.jpeg(fb0→JPEG,增量帧)+ 触摸注入 /api/input/tap
* 与系统手势注入 /api/input/gesture(back=左缘右滑 / home=底部上滑回桌面)。 */
#include "mp.h"
static uint8_t *g_screen_prev = NULL; /* 上一帧紧凑 RGB565 */
@@ -6,6 +7,17 @@ static int g_screen_pw = 0, g_screen_ph = 0;
static int64_t g_last_self_inject_ms = 0;
#define TOUCH_ACTIVE_MS 5000
/* 手势注入阈值(镜像 meow_ui_input.c / np_gesture.h 系统已识别手势,勿新造语义):
* 返回=左缘(起)右移 ≥SWIPE_THRESH 且横向主导;回桌面=底部热区(起)上移 ≥HOME_SWIPE
* 且竖向主导 + down_x≥EDGE_W(避左缘返回列)。此处位移留容差(≥65)。 */
#define GESTURE_EDGE_W 24 /* 左缘宽度:返回手势起点 x 须 < 此值(meow_ui_input.c EDGE_W) */
#define GESTURE_SWIPE_DX 78 /* 返回手势右移位移(≥ SDK SWIPE_THRESH 65,留容差) */
#define GESTURE_HOME_EDGE_H 28 /* 底部热区高:回桌面起点 y 须 >= fb_h-此值(HOME_EDGE_H) */
#define GESTURE_HOME_DY 80 /* 回桌面手势上移位移(≥ SDK HOME_SWIPE 65,留容差) */
#define GESTURE_STEPS 10 /* 线性轨迹插值点数(营造真实滑动轨迹) */
#define GESTURE_STEP_MS 12 /* 每步间隔毫秒 */
#define GESTURE_HOLD_MS 60 /* 末端按住毫秒再抬起(防 fling 误判) */
int screen_read_frame(const char *dev, uint8_t **out, int *w, int *h)
{
@@ -230,6 +242,47 @@ int inject_tap(const char *dev, int x, int y)
return 0;
}
/* 线性滑动注入:按下(原点) → 插值移动 → 末端按住 → 抬起。事件序同 inject_tap
* (BTN_TOUCH 边沿先于 ABS,goodix 时序免疫;首帧锁原点),设备手势机据此判
* 「左缘右滑=返回」或「底部上滑=回桌面」(复用系统已识别信号,不新造)。 */
int inject_swipe(const char *dev, int x0, int y0, int x1, int y1)
{
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)
/* 按下边沿 + 首帧 ABS 锁原点(与 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, x0);
__WE(EV_ABS, ABS_MT_POSITION_Y, y0);
__WE(EV_ABS, ABS_MT_TOUCH_MAJOR, 6);
__WE(EV_ABS, ABS_X, x0);
__WE(EV_ABS, ABS_Y, y0);
__WE(EV_SYN, SYN_REPORT, 0);
/* 插值逐点移动:每步坐标更新 + 同步,营造连续轨迹 */
for (int i = 1; i <= GESTURE_STEPS; i++) {
int xi = x0 + (x1 - x0) * i / GESTURE_STEPS;
int yi = y0 + (y1 - y0) * i / GESTURE_STEPS;
__WE(EV_ABS, ABS_MT_POSITION_X, xi);
__WE(EV_ABS, ABS_MT_POSITION_Y, yi);
__WE(EV_ABS, ABS_X, xi);
__WE(EV_ABS, ABS_Y, yi);
__WE(EV_SYN, SYN_REPORT, 0);
usleep((useconds_t)GESTURE_STEP_MS * 1000);
}
/* 末端短暂按住再抬起(与 tap 同:抬起到按下边沿被手势机视为一次抬手) */
usleep((useconds_t)GESTURE_HOLD_MS * 1000);
__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");
@@ -275,3 +328,71 @@ int api_input_tap(int sock, const req_t *r)
sbuf_free(&s);
return ret;
}
/* /api/input/gesture:注入设备已识别的系统手势(back=左缘右滑 / home=底部上滑回桌面)。
* 复用现有触摸注入通道(/dev/input/event1 滑动轨迹),不触碰被物理键红线禁注入的
* /api/key 158/172;由设备自身手势机判成 MEOW_SWIPE_LEFT / MEOW_EV_HOME。 */
int api_input_gesture(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 *jg = jv_obj_get(root, "gesture");
if (!jg || jg->type != J_STR) {
jv_free(root);
return send_err(sock, 400, "BAD_REQUEST", "缺 gesture(back|home)");
}
const char *gesture = jg->str;
int is_back = (strcmp(gesture, "back") == 0);
int is_home = (strcmp(gesture, "home") == 0);
jv_free(root);
if (!is_back && !is_home)
return send_err(sock, 400, "BAD_REQUEST", "gesture 非法(仅 back|home)");
const char *dev = getenv("MEOW_TOUCH_DEV"); if (!dev || !dev[0]) dev = TOUCH_DEV;
const char *fbdev = getenv("MEOW_FB_DEV"); if (!fbdev || !fbdev[0]) fbdev = FB_DEV;
int sw = 480, sh = 720;
{ int fx = 0, fy = 0; if (fb_dims(fbdev, &fx, &fy) == 0) { sw = fx; sh = fy; } }
if (sw < 1) sw = 480;
if (sh < 1) sh = 720;
/* 几何(镜像 SDK 判据 + 留容差,见上 GESTURE_* 宏) */
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
if (is_back) {
x0 = 4; /* 左缘内(< EDGE_W=24) */
if (x0 >= sw) x0 = sw - 1;
if (x0 >= GESTURE_EDGE_W) x0 = GESTURE_EDGE_W - 1;
y0 = y1 = sh / 2; /* 屏中部,避开顶部下拉/底部上滑热区 */
x1 = x0 + GESTURE_SWIPE_DX;
if (x1 >= sw) x1 = sw - 1; /* 横向主导 + 右移≥阈值 */
} else { /* home */
x0 = x1 = sw / 2; /* 屏底中部,避开左缘返回列 */
if (x0 < GESTURE_EDGE_W) x0 = x1 = GESTURE_EDGE_W;
y0 = sh - 2; /* 底部热区(>= fb_h-HOME_EDGE_H) */
if (y0 >= sh) y0 = sh - 1;
y1 = y0 - GESTURE_HOME_DY;
if (y1 < 0) y1 = 0; /* 竖向主导 + 上移≥阈值 */
}
/* 仲裁:本机最近触摸后 5s 内忽略注入(与 tap 同) */
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_swipe(dev, x0, y0, x1, y1) < 0)
return send_err(sock, 500, "INTERNAL", "手势注入失败(无法打开触摸设备)");
sbuf_t s = {0};
sA(&s, "{\"dispatched\":true,\"gesture\":\"%s\",\"from\":[%d,%d],\"to\":[%d,%d]}",
gesture, x0, y0, x1, y1);
int ret = send_json(sock, 200, s.b ? s.b : "");
sbuf_free(&s);
return ret;
}
+119 -2
View File
@@ -301,6 +301,7 @@ int handle(int sock, req_t *r)
/* ---- 网络管理(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/bt")) return api_net_bt(sock); /* F1-③ 蓝牙只读详情(0xE6 读级聚合) */
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);
@@ -339,16 +340,22 @@ int handle(int sock, req_t *r)
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);
if (!strcmp(p, "/api/input/gesture")) return api_input_gesture(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/<pkg>/start|kill(管启/停;require_auth 已护,kill 前台前端二次确认) */
/* POST /api/ams/<pkg>/start|kill|killfg(管启/停;require_auth 已护,kill 前台前端二次确认;
* killfg=前台救命强制终止:am kill 优先,launcher 拒→直接 pid 信号,前端二次确认+标注前台) */
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 > 7 && !strcmp(p + pl - 7, "/killfg")) {
char pkg[256];
if (path_segment("/api/ams/", p, pkg, sizeof pkg))
return api_ams_kill_fg(sock, pkg);
} else if (pl > 4 && !strcmp(p + pl - 5, "/kill")) {
char pkg[256];
if (path_segment("/api/ams/", p, pkg, sizeof pkg))
@@ -375,6 +382,7 @@ int handle(int sock, req_t *r)
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, "GET") && !strcmp(p, "/api/files/search")) return api_files_search(sock, r); /* F1-④ 搜索(只读) */
if (!strcmp(r->method, "DELETE") && !strcmp(p, "/api/files")) return api_files_delete(sock, r);
/* ---- 应用 ---- */
@@ -628,7 +636,116 @@ int panel_selftest(void)
if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; }
}
printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 新增端点池 → 合法 JSON + 无非法字节;防自锁实体 + policy 门禁 默认关/开启 校验通过\n");
/* 10) net_bt_aggregate(F1-③):0xE6 五读文本聚合 → 合法 JSON + 字段断言;
* 设备名含截断坏字节 → sA_str_n 消毒为 '?' 且产物仍合法;子文本 NULL → 空数组;status NULL → available:false。 */
{
sbuf_t b = {0};
const char *st = "bt available=1 powered=1 address=4C:BC:98:B0:98:DB paired=2 connected=1";
const char *pa = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice 17:0A:15:AA:BB:CC 流媒体客户\xE7\n";
const char *co = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\n";
const char *a2 = "A2DP 33:DE:2E:21:AD:30 codec=sbc rate=44100 channels=2 bits=16 acquired=1 role=src\n";
const char *dv = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice 17:0A:15:AA:BB:CC 17OP15\n";
if (net_bt_aggregate(st, pa, co, a2, dv, &b) != 0) { printf("SELFTEST FAIL: net_bt_aggregate 正常文本返回非 0\n"); sbuf_free(&b); return 1; }
if (!b.b || !utf8_valid_n(b.b, b.n) || !json_strict_balanced(b.b, b.n)) { printf("SELFTEST FAIL: net_bt_aggregate 产物非法字节/括号错位\n"); sbuf_free(&b); return 1; }
jv_t *br = jv_parse(b.b, (int)b.n);
if (!br) { printf("SELFTEST FAIL: net_bt_aggregate 产物 JSON 无法解析\n"); sbuf_free(&b); return 1; }
jv_t *pw = jv_obj_get(br, "powered");
if (!pw || pw->type != J_BOOL || !pw->bval) { printf("SELFTEST FAIL: net_bt_aggregate powered 非 true\n"); jv_free(br); sbuf_free(&b); return 1; }
jv_t *pr = jv_obj_get(br, "paired");
if (!pr || pr->type != J_ARR || !pr->child || !pr->child->next || pr->child->next->next) { printf("SELFTEST FAIL: net_bt_aggregate paired 应 2 条\n"); jv_free(br); sbuf_free(&b); return 1; }
jv_t *cn = jv_obj_get(br, "connected");
if (!cn || cn->type != J_ARR || !cn->child || cn->child->next) { printf("SELFTEST FAIL: net_bt_aggregate connected 应 1 条\n"); jv_free(br); sbuf_free(&b); return 1; }
jv_t *a2p = jv_obj_get(br, "a2dp");
if (!a2p || a2p->type != J_ARR || !a2p->child) { printf("SELFTEST FAIL: net_bt_aggregate 缺 a2dp 条目\n"); jv_free(br); sbuf_free(&b); return 1; }
jv_t *cd = jv_obj_get(a2p->child, "codec");
if (!cd || cd->type != J_STR || !cd->str || strcmp(cd->str, "sbc") != 0) { printf("SELFTEST FAIL: net_bt_aggregate a2dp codec 非 sbc\n"); jv_free(br); sbuf_free(&b); return 1; }
jv_t *aq = jv_obj_get(a2p->child, "acquired");
if (!aq || aq->type != J_BOOL || !aq->bval) { printf("SELFTEST FAIL: net_bt_aggregate a2dp acquired 非 true\n"); jv_free(br); sbuf_free(&b); return 1; }
jv_free(br);
sbuf_free(&b);
/* status NULL → available:false;子文本 NULL → 空数组 */
sbuf_t b2 = {0};
net_bt_aggregate(NULL, NULL, NULL, NULL, NULL, &b2);
if (!b2.b || strstr(b2.b, "\"available\":false") == NULL) { printf("SELFTEST FAIL: net_bt_aggregate status NULL 未降级 available:false\n"); sbuf_free(&b2); return 1; }
sbuf_free(&b2);
sbuf_t b3 = {0};
net_bt_aggregate(st, NULL, NULL, NULL, NULL, &b3);
jv_t *b3r = b3.b ? jv_parse(b3.b, (int)b3.n) : NULL;
jv_t *b3p = b3r ? jv_obj_get(b3r, "paired") : NULL;
if (!b3p || b3p->type != J_ARR || b3p->child) { printf("SELFTEST FAIL: net_bt_aggregate 子文本 NULL 应为空数组\n"); if (b3r) jv_free(b3r); sbuf_free(&b3); return 1; }
jv_free(b3r);
sbuf_free(&b3);
}
/* 11) files_search_json(F1-④):沙盒卡根递归搜索(限深/限量/类型过滤/rel 回显)→ 合法 JSON + 计数断言;
* 白名单外起点 → -1;name 空 → -3;非法 type → -4。 */
{
char tmpl[] = "/tmp/mp_search_XXXXXX";
char *root = mkdtemp(tmpl);
if (!root) { printf("SELFTEST FAIL: mkdtemp 失败\n"); return 1; }
char saved_root[1024];
snprintf(saved_root, sizeof saved_root, "%s", g_tf_root_buf);
snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", root);
char d1[1200], d2[1200], f1[1200], f2[1200], f3[1200];
snprintf(d1, sizeof d1, "%s/MeowOS", root);
snprintf(d2, sizeof d2, "%s/MeowOS/sub", root);
snprintf(f1, sizeof f1, "%s/MeowOS/Test Song.mp3", root);
snprintf(f2, sizeof f2, "%s/MeowOS/another.txt", root);
snprintf(f3, sizeof f3, "%s/MeowOS/sub/x Song2.log", root);
int setup_ok = (mkdir(d1, 0755) == 0 && mkdir(d2, 0755) == 0);
const char *mk[3] = { f1, f2, f3 };
for (int i = 0; i < 3 && setup_ok; i++) {
FILE *f = fopen(mk[i], "w");
if (!f) { setup_ok = 0; break; }
fputs("x", f); fclose(f);
}
if (!setup_ok) { printf("SELFTEST FAIL: 搜索沙盒构建失败\n"); snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", saved_root); rm_recursive(root); rmdir(root); return 1; }
int fails = 0;
sbuf_t s = {0};
if (files_search_json(d1, "song", NULL, 0, &s) != 0) { printf("SELFTEST FAIL: files_search_json 正常搜索返回非 0\n"); fails = 1; }
jv_t *sr = (!fails && s.b) ? jv_parse(s.b, (int)s.n) : NULL;
if (!fails && (!sr || !utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n))) { printf("SELFTEST FAIL: files_search_json 产物非法\n"); fails = 1; }
jv_t *cnt = (!fails && sr) ? jv_obj_get(sr, "count") : NULL;
if (!fails && (!cnt || cnt->type != J_NUM || cnt->inum != 2)) { printf("SELFTEST FAIL: files_search_json song 应中 2 条(递归命中子目录)\n"); fails = 1; }
jv_t *its = (!fails && sr) ? jv_obj_get(sr, "items") : NULL;
jv_t *rel0 = (its && its->child) ? jv_obj_get(its->child, "rel") : NULL;
if (!fails && (!rel0 || rel0->type != J_STR || !rel0->str || strncmp(rel0->str, "/MeowOS/", 8) != 0)) { printf("SELFTEST FAIL: files_search_json rel 未按卡根相对回显\n"); fails = 1; }
if (sr) jv_free(sr);
sbuf_free(&s);
/* type=dir 过滤:建 Songs 目录应仅中目录 */
char d3[1200];
snprintf(d3, sizeof d3, "%s/MeowOS/Songs", root);
if (!fails && mkdir(d3, 0755) != 0) { printf("SELFTEST FAIL: 沙盒 Songs 目录创建失败\n"); fails = 1; }
sbuf_t s2 = {0};
if (!fails && files_search_json(d1, "song", "dir", 0, &s2) == 0) {
jv_t *sr2 = jv_parse(s2.b, (int)s2.n);
jv_t *c2 = sr2 ? jv_obj_get(sr2, "count") : NULL;
jv_t *i2 = sr2 ? jv_obj_get(sr2, "items") : NULL;
jv_t *t0 = (i2 && i2->child) ? jv_obj_get(i2->child, "type") : NULL;
if (!c2 || c2->type != J_NUM || c2->inum != 1 || !t0 || t0->type != J_STR || strcmp(t0->str, "dir") != 0) {
printf("SELFTEST FAIL: files_search_json type=dir 应仅中 1 个目录\n"); fails = 1;
}
if (sr2) jv_free(sr2);
} else if (!fails) { printf("SELFTEST FAIL: files_search_json type=dir 搜索失败\n"); fails = 1; }
sbuf_free(&s2);
/* 无匹配 → count 0;白名单外 → -1;空 name → -3;非法 type → -4 */
sbuf_t s3 = {0};
if (!fails && files_search_json(d1, "zzzz", NULL, 0, &s3) == 0) {
if (!strstr(s3.b, "\"count\":0")) { printf("SELFTEST FAIL: files_search_json 无匹配应 count 0\n"); fails = 1; }
} else if (!fails) { printf("SELFTEST FAIL: files_search_json 无匹配搜索失败\n"); fails = 1; }
sbuf_free(&s3);
sbuf_t s4 = {0};
if (!fails && files_search_json("/etc", "x", NULL, 0, &s4) != -1) { printf("SELFTEST FAIL: files_search_json 白名单外未拒\n"); fails = 1; }
if (!fails && files_search_json(d1, "", NULL, 0, &s4) != -3) { printf("SELFTEST FAIL: files_search_json 空 name 未拒\n"); fails = 1; }
if (!fails && files_search_json(d1, "x", "bogus", 0, &s4) != -4) { printf("SELFTEST FAIL: files_search_json 非法 type 未拒\n"); fails = 1; }
sbuf_free(&s4);
snprintf(g_tf_root_buf, sizeof g_tf_root_buf, "%s", saved_root);
rm_recursive(root);
rmdir(root);
if (fails) return 1;
}
printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 端点池 + 蓝牙 0xE6 聚合 + 文件搜索 → 合法 JSON + 无非法字节;防自锁实体 + policy 门禁 默认关/开启 校验通过\n");
return 0;
}
+1 -1
View File
@@ -69,7 +69,7 @@
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="9" cy="6" r="2.2" fill="var(--bg)"/><circle cx="15" cy="12" r="2.2" fill="var(--bg)"/><circle cx="7" cy="18" r="2.2" fill="var(--bg)"/></svg></span>
<span class="nav-label">进程·服务</span>
</button>
<button class="nav-item" data-route="bg" title="后台活动(App 后台常驻:CPU/内存占用 + 一键清后台)">
<button class="nav-item" data-route="bg" title="App 活动(前台可终止救命 · 后台常驻:CPU/内存占用 + 一键清后台)">
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M12 3 3 8l9 5 9-5-9-5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M3 12l9 5 9-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M3 16l9 5 9-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>
<span class="nav-label">后台</span>
</button>
+20
View File
@@ -270,6 +270,9 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
.app-card .app-pkg{ font-size:12px; color:var(--on-surface-variant); font-family:var(--mono); }
.app-card .app-meta{ font-size:12px; color:var(--on-surface-variant); }
.app-card .app-actions{ display:flex; gap:6px; margin-top:auto; flex-wrap:wrap; }
.app-detail-card{ grid-column:1/-1; }
.app-detail-card .app-detail-head{ display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:6px; }
.app-detail-card .app-detail-head strong{ font-family:var(--mono); }
/* 后台活动列表(批④,与启动器后台管理页观感呼应:MD3 圆角卡 + 行右杀按钮) */
.bg-list{ display:flex; flex-direction:column; gap:8px; }
@@ -283,6 +286,9 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
.bg-row .bg-name{ font-weight:600; }
.bg-row .bg-sub{ font-size:12px; color:var(--on-surface-variant); margin-top:2px; display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.bg-row .btn{ flex:0 0 auto; }
.bg-group{ margin:14px 0 8px; font-weight:600; font-size:13px; color:var(--on-surface-variant); }
.bg-group:first-of-type{ margin-top:0; }
.bg-group + .bg-list{ margin-bottom:4px; }
/* 实时屏幕(屏幕页) */
.screen-frame{ display:flex; justify-content:center; max-width:480px; margin:0 auto; }
@@ -454,6 +460,20 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
.lvl-spark .grid{ stroke:var(--outline-variant); stroke-width:1; }
.lvl-note{ color:var(--on-surface-variant); font-size:12px; margin-top:6px; }
/* 性能趋势 sparkline(F1-① 多序列:CPU/内存/负载/温度)+ 功耗历史曲线(F1-②) */
.trend-spark polyline{ vector-effect:non-scaling-stroke; }
.trend-cpu{ stroke:#4a8bf5; }
.trend-mem{ stroke:#00bfa5; }
.trend-load{ stroke:#ffb300; }
.trend-temp{ stroke:#ff7043; }
.trend-legend{ display:flex; flex-wrap:wrap; gap:8px 16px; margin-top:12px; font-size:12px; }
.trend-legend .sw{ display:inline-block; width:12px; height:12px; border-radius:3px; margin-right:5px; vertical-align:middle; }
.trend-legend .seg{ display:inline-flex; align-items:center; }
.pwr-chart{ display:block; width:100%; height:60px; margin-top:8px; }
.pwr-chart .grid{ stroke:var(--outline-variant); stroke-width:1; }
.pwr-mw{ stroke:#ff7043; }
.pwr-pct{ stroke:#4a8bf5; }
/* 置信度徽标(measured/derived/estimated) */
.conf-badge{ display:inline-flex; align-items:center; gap:5px; padding:3px 12px; border-radius:999px; font-size:12px; font-weight:600; }
.conf-badge.measured{ background:rgba(76,175,80,.18); color:var(--success); }
+1 -1
View File
@@ -21,7 +21,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
notif: { title: '通知中心', render: renderNotif, bind: bindNotif },
power: { title: '电源 / 功耗', render: renderPower, bind: bindPower },
proc: { title: '进程 / 服务', render: renderProc, bind: bindProc },
bg: { title: '后台管理', render: renderBg, bind: bindBg }
bg: { title: 'App 活动(前台可终止 · 后台管理)', render: renderBg, bind: bindBg }
};
let started = false;
+333 -45
View File
@@ -3,7 +3,7 @@
(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;
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, drawTrendSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, polylinePoints, pushSpark, pushTrend, refreshAuthGate, resetSpark, resetTrend, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, trendCpu, trendLoad, trendMem, trendSvgHtml, trendTemp, updateLevelMeter, volTimer } = MP;
let audioTimer = null;
let audioVolDrag = false; // 音量滑条拖拽中(暂停 1s 重渲染,防打断交互)
@@ -14,6 +14,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
let powerData = null, powerEvents = null, powerHistory = null;
let procTimer = null;
let expandedProc = null; // { pkg, file, lines, truncated }:已展开的 App stdout 尾部(重渲染后重开,防轮询清空)
let appDetailPkg = null; // F1-⑤ 当前展开「App 详情四源聚合」的包名
let netTimer = null;
let netEntityFilter = '';
let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义)
@@ -39,7 +40,9 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
power: '<svg viewBox="0 0 24 24"><path d="M12 3v8" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M6.6 6.6a7.5 7.5 0 1 0 10.8 0" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
play: '<svg viewBox="0 0 24 24"><path d="M7 5.5v13l11-6.5z" fill="currentColor"/></svg>',
next: '<svg viewBox="0 0 24 24"><path d="M5 5.5l9 6.5-9 6.5z" fill="currentColor"/><rect x="16.2" y="5" width="2.4" height="14" fill="currentColor"/></svg>',
prev: '<svg viewBox="0 0 24 24"><path d="M19 5.5l-9 6.5 9 6.5z" fill="currentColor"/><rect x="5.4" y="5" width="2.4" height="14" fill="currentColor"/></svg>'
prev: '<svg viewBox="0 0 24 24"><path d="M19 5.5l-9 6.5 9 6.5z" fill="currentColor"/><rect x="5.4" y="5" width="2.4" height="14" fill="currentColor"/></svg>',
back: '<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" fill="currentColor"/></svg>',
home: '<svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor"/></svg>'
};
let ctlBright = { min: 1, max: 100, value: 100 }; // 控制板亮度(读取自 /api/settings/display.brightness)
let logsGroup = 'all', logsPrefix = '';
@@ -90,7 +93,28 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
h += stat('温度', '<span class="stat-value">' + (tmpCpu == null ? '—' : tmpCpu) + '<span class="stat-unit">℃</span></span>',
tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : '');
h += '</div></div>';
/* F1-① 趋势(2s 节拍,最近 30 点≈1 分钟):CPU%/内存%/负载(1m/核)/温度(20-90℃ 归一) */
const loadN = (cpu.load && cpu.load[0] != null && cpu.cores) ? (cpu.load[0] / cpu.cores) * 100 : null;
pushTrend(trendCpu, cpu.usage);
pushTrend(trendMem, mem.total ? memP : null);
pushTrend(trendLoad, loadN);
pushTrend(trendTemp, tmpCpu != null ? (tmpCpu - 20) / 70 * 100 : null);
const perfTrend = [
{ cls: 'trend-cpu', arr: trendCpu },
{ cls: 'trend-mem', arr: trendMem },
{ cls: 'trend-load', arr: trendLoad },
{ cls: 'trend-temp', arr: trendTemp }
];
h += '<div class="card"><h3 class="card-title">趋势(2s 采样 · 最近 ' + trendCpu.length + ' 点)</h3>' +
'<div class="trend-legend">' +
'<span class="seg"><span class="sw" style="background:#4a8bf5"></span>CPU ' + (cpu.usage == null ? '—' : (+cpu.usage).toFixed(1) + '%') + '</span>' +
'<span class="seg"><span class="sw" style="background:#00bfa5"></span>内存 ' + (mem.total ? memP.toFixed(1) + '%' : '—') + '</span>' +
'<span class="seg"><span class="sw" style="background:#ffb300"></span>负载1m/核 ' + (cpu.load && cpu.load[0] != null ? (+cpu.load[0]).toFixed(2) : '—') + '</span>' +
'<span class="seg"><span class="sw" style="background:#ff7043"></span>温度 ' + (tmpCpu == null ? '—' : tmpCpu + '℃') + '</span>' +
'</div>' + trendSvgHtml(perfTrend) +
'<p class="muted">温度曲线按 20–90℃ 归一;负载按 load1/核(100%=单核满载)。进页起累积,切页清零。</p></div>';
page.innerHTML = h;
drawTrendSpark(page, perfTrend);
}
async function fetchPerfOnce() {
@@ -107,6 +131,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
function renderPerf() {
$('#content').innerHTML = '<div class="page" id="perf-page"><p class="placeholder">加载中…</p></div>';
if (state.perfTimer) stopPerf();
resetTrend(); // 进页重置趋势累积(F1-①)
fetchPerfOnce();
state.perfTimer = setInterval(fetchPerfOnce, 2000);
}
@@ -441,11 +466,21 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
const cpu = perf.cpu || {}, mem = perf.memory || {}, tmp = perf.temp || {};
const cpuU = cpu.usage;
const memP = mem.total ? (mem.used / mem.total) * 100 : 0;
/* F1-① 首页迷你趋势(5s overview 节拍,CPU/内存/温度) */
pushTrend(trendCpu, cpuU);
pushTrend(trendMem, mem.total ? memP : null);
pushTrend(trendTemp, tmp.cpu != null ? ((+tmp.cpu) - 20) / 70 * 100 : null);
const homeTrend = [
{ cls: 'trend-cpu', arr: trendCpu },
{ cls: 'trend-mem', arr: trendMem },
{ cls: 'trend-temp', arr: trendTemp }
];
return homeCard('性能小条', '<div class="stat-grid" style="grid-template-columns:repeat(auto-fill,minmax(120px,1fr))">' +
'<div class="stat"><div class="stat-label">CPU</div><div class="stat-value">' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '<span class="stat-unit">%</span></div>' + barHtml(cpuU || 0) + '</div>' +
'<div class="stat"><div class="stat-label">内存</div><div class="stat-value">' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '</div>' + barHtml(mem.used && mem.total ? memP : 0) + '</div>' +
'<div class="stat"><div class="stat-label">温度</div><div class="stat-value">' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '<span class="stat-unit">℃</span>' : '—') + '</div></div>' +
'</div>');
'</div>' + trendSvgHtml(homeTrend) +
'<p class="muted">趋势 5s 采样 · <span style="color:#4a8bf5">CPU</span> / <span style="color:#00bfa5">内存</span> / <span style="color:#ff7043">温度(20–90℃)</span></p>');
}
function powerSummaryCard() {
@@ -481,6 +516,11 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
deviceCard() + batteryCard() + audioCard() + netCard() + btCard() + healthCard() + storageCard() + perfCard() +
'</div>';
body.innerHTML = h;
drawTrendSpark(body, [
{ cls: 'trend-cpu', arr: trendCpu },
{ cls: 'trend-mem', arr: trendMem },
{ cls: 'trend-temp', arr: trendTemp }
]); // F1-① 首页迷你趋势(perfCard 内 .trend-spark)
const ad = homeData.audio;
if (isAvail(ad)) updateLevelMeter($('#home-audio-level'), ad);
}
@@ -520,6 +560,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
function renderHome() {
stopHome();
resetSpark(); // 进入首页重置 sparkline 累积
resetTrend(); // 进入首页重置性能趋势累积(F1-①)
homeData = null; homeSys = null;
$('#content').innerHTML =
'<div class="page" id="home-page">' +
@@ -643,6 +684,27 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
'<table class="table"><thead><tr><th>类型</th><th>电量</th><th>说明</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function powerHistoryChart(h) {
const samples = (h && h.samples) || []; // [mono, pct, est_mw, chg, scr, bri](30s 环形 ≈4h)
if (!samples.length) return '';
let maxMw = 0, hasMw = false;
samples.forEach(function (s2) { if (s2[2] != null && s2[2] >= 0) { hasMw = true; if (s2[2] > maxMw) maxMw = s2[2]; } });
if (!hasMw) return '<p class="muted">本窗口无 est_mw 估算采样(只读模式/未知),不画曲线。</p>';
const W = 100, H = 30;
const mwVals = samples.map(function (s2) { return (s2[2] != null && s2[2] >= 0 && maxMw > 0) ? s2[2] / maxMw * 100 : null; });
const pctVals = samples.map(function (s2) { return (s2[1] != null && s2[1] >= 0) ? s2[1] : null; });
return '<div class="trend-legend">' +
'<span class="seg"><span class="sw" style="background:#ff7043"></span>est_mw(窗内峰值 ' + maxMw + ' mW)</span>' +
'<span class="seg"><span class="sw" style="background:#4a8bf5"></span>电量 %</span>' +
'</div>' +
'<svg class="pwr-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true">' +
'<line class="grid" x1="0" y1="29" x2="100" y2="29"/>' +
'<polyline class="pwr-mw" points="' + polylinePoints(mwVals, W, H) + '" fill="none" stroke-width="1.4" vector-effect="non-scaling-stroke"/>' +
'<polyline class="pwr-pct" points="' + polylinePoints(pctVals, W, H) + '" fill="none" stroke-width="1.1" vector-effect="non-scaling-stroke"/>' +
'</svg>' +
'<p class="muted">30s 采样 · 内存环形约 4h · est_mw 为经验估算(confidence 见「功耗估算」卡),非实测功率;X 轴为采样序(等间隔)。</p>';
}
function powerHistoryCard(h) {
if (!h) return '';
if (h.available === false) return '<div class="card"><h3 class="card-title">功耗历史</h3>' + homeUnavailable() + '</div>';
@@ -652,6 +714,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
row('起止电量', (h.start_pct != null ? h.start_pct : '—') + '% → ' + (h.end_pct != null ? h.end_pct : '—') + '%') +
row('平均功耗', (h.avg_mw != null ? h.avg_mw + ' mW' : '—')) +
'</tbody></table>';
body += powerHistoryChart(h); // F1-② est_mw 4h 曲线(纯前端画既有 samples)
return '<div class="card"><h3 class="card-title">功耗历史</h3>' + body + '</div>';
}
@@ -889,9 +952,10 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
});
}
/* ---- 批④ 后台活动页(#/bg):列表 + 行内「杀死」 + 顶栏「一键清后台」(复用 /api/ams/bg 只读 + /api/ams/:pkg/kill) ---- */
/* ---- 批④ App 活动页(#/bg):前台「终止」救命 + 后台列表/行内「杀死」 + 顶栏「一键清后台」
*(复用 /api/ams/bg 只读:apps=bg 向后兼容 + fg=前台;前台终止走 /api/ams/:pkg/killfg,后台杀死走 /api/ams/:pkg/kill) ---- */
let bgTimer = null;
let bgData = null; // {available,count,apps:[{pkg,name,pid,state,cpu_pct,rss_kb}]}
let bgData = null; // {available,count,apps:[{pkg,name,pid,state,is_fg,cpu_pct,rss_kb}],fg_count,fg:[...]}
function stopBg() { if (bgTimer) { clearInterval(bgTimer); bgTimer = null; } }
@@ -900,30 +964,43 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
return '<span class="bg-ico">' + ch + '</span>';
}
/* 状态徽标:前台=ok/绿,后台=warn/黄(复用已定义的 home-chip 组件样式)。 */
function bgStateBadge(a) {
const isFg = a.is_fg || a.state === 'fg';
return '<span class="home-chip ' + (isFg ? 'ok' : 'warn') + '">' + (isFg ? '前台' : '后台') + '</span>';
}
/* 单行 App 卡(kind='fg'=前台可终止/救命;'bg'=后台可杀死)。 */
function bgRowHtml(a, kind) {
const cpu = (a.cpu_pct == null) ? '—' : a.cpu_pct.toFixed(1) + '%';
const mem = (a.rss_kb == null) ? '—' : fmtBytes(Number(a.rss_kb) * 1024);
const sub = 'CPU ' + cpu + ' · ' + mem +
(a.pid > 0 ? ' · pid ' + esc(a.pid) : '') + ' · ' + bgStateBadge(a);
const btn = (kind === 'fg')
? '<button class="btn btn-danger" data-bgkillfg="' + esc(a.pkg) + '">终止</button>'
: '<button class="btn btn-danger" data-bgkill="' + esc(a.pkg) + '">杀死</button>';
return '<div class="bg-row">' + bgIconHtml(a) +
'<div class="bg-info"><div class="bg-name">' + esc(a.name || a.pkg || '未命名') + '</div>' +
'<div class="bg-sub">' + sub + '<span class="mono">' + esc(a.pkg) + '</span></div></div>' +
btn + '</div>';
}
function renderBgBody(d) {
let h = '<div class="page" id="bg-page">';
h += '<div class="card"><h3 class="card-title">后台活动 App(meow_amsd bg · 只读)</h3>';
h += '<div class="card"><h3 class="card-title">App 活动(前台可终止 · 后台管理)</h3>';
if (!d || d.available === false) {
h += homeUnavailable();
} else {
const fg = d.fg || [];
const apps = d.apps || [];
if (!apps.length) {
h += '<div class="empty">无后台 App</div>';
} else {
h += '<div class="bg-list">';
apps.forEach(a => {
const cpu = (a.cpu_pct == null) ? '—' : a.cpu_pct.toFixed(1) + '%';
const mem = (a.rss_kb == null) ? '—' : fmtBytes(Number(a.rss_kb) * 1024);
const sub = 'CPU ' + cpu + ' · ' + mem + ' · 后台' +
(a.pid > 0 ? ' · pid ' + esc(a.pid) : '');
h += '<div class="bg-row">' + bgIconHtml(a) +
'<div class="bg-info"><div class="bg-name">' + esc(a.name || a.pkg || '未命名') + '</div>' +
'<div class="bg-sub">' + sub + '<span class="mono">' + esc(a.pkg) + '</span></div></div>' +
'<button class="btn btn-danger" data-bgkill="' + esc(a.pkg) + '">杀死</button></div>';
});
h += '</div>';
}
h += '<p class="muted">占用数据读自 /proc/&lt;pid&gt;(与启动器后台管理页同源、同口径);「杀死」经 amsd(require_auth)。CPU 首采样为「—」,轮询后显示真实占用;数据不可得时如实显示「—」,绝不编造。</p>';
h += '<p class="muted">前台 App 可「终止」(救命:前台卡死/无响应时强制结束,需二次确认);后台 App 可「杀死」或一键清。</p>';
h += '<div class="bg-group">' + (fg.length ? '前台 App(' + fg.length + ')' : '前台 App') + '</div>';
if (!fg.length) h += '<div class="empty">当前无前台 App</div>';
else { h += '<div class="bg-list">'; fg.forEach(a => { h += bgRowHtml(a, 'fg'); }); h += '</div>'; }
h += '<div class="bg-group">' + (apps.length ? '后台 App(' + apps.length + ')' : '后台 App') + '</div>';
if (!apps.length) h += '<div class="empty">无后台 App</div>';
else { h += '<div class="bg-list">'; apps.forEach(a => { h += bgRowHtml(a, 'bg'); }); h += '</div>'; }
h += '<p class="muted">占用数据读自 /proc/&lt;pid&gt;(与启动器后台管理页同源、同口径);「终止/杀死」经 amsd(require_auth),前台终止为救命操作需二次确认。CPU 首采样为「—」,轮询后显示真实占用;数据不可得时如实显示「—」,绝不编造。</p>';
}
h += '</div></div>';
return h;
@@ -965,6 +1042,15 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
fetchBgOnce();
}
/* 前台强制终止(救命):明确标注「前台」,二次确认;后端 am kill 优先、launcher 拒则直接按进程信号。 */
async function bgKillFg(pkg) {
const ok = await confirmDlg('终止前台 App', '『' + pkg + '』当前位于前台。若它已卡死/无响应,可强制终止(救命):先请求干净结束,启动器拒绝时直接按进程信号(SIGTERM/SIGKILL)。确认终止?', true);
if (!ok) return;
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/killfg'); toast('已请求终止前台 ' + pkg); }
catch (e) { toast('终止失败:' + errText(e)); }
fetchBgOnce();
}
async function bgClearAll() {
const list = (bgData && bgData.apps) ? bgData.apps : [];
if (!list.length) { toast('无后台 App 可清'); return; }
@@ -981,6 +1067,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
function bindBg() {
$('#content').addEventListener('click', async function (e) {
const kfg = e.target.closest('[data-bgkillfg]');
if (kfg) { await bgKillFg(kfg.dataset.bgkillfg); return; }
const kl = e.target.closest('[data-bgkill]');
if (kl) { await bgKill(kl.dataset.bgkill); return; }
});
@@ -1107,18 +1195,44 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
h += row('状态', '<span class="net-state warn">未连接</span>');
}
h += '</tbody></table></div>';
// 蓝牙摘要
const bt = (d && d.bt) || {};
h += '<div class="card"><h3 class="card-title">蓝牙</h3><table class="table"><tbody>';
if (bt.available) {
h += row('状态', '<span class="net-state ' + (bt.powered ? 'ok' : 'warn') + '">' + (bt.powered ? '已开启' : '已关闭') + '</span>');
h += row('地址', esc(bt.address || '—'));
h += row('已配对', esc(bt.paired != null ? bt.paired : '—'));
h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—'));
} else {
h += row('状态', '<span class="net-state warn">无蓝牙</span>');
return h;
}
/* F1-③ 蓝牙详情(GET /api/net/bt 只读聚合:status/paired/connected/a2dp/nearby) */
function btDevTable(title, list) {
let h = '<div class="home-sub" style="margin-top:10px">' + esc(title) + '</div>';
if (!list || !list.length) return h + '<div class="empty">无</div>';
h += '<table class="table"><thead><tr><th>MAC</th><th>名称</th></tr></thead><tbody>';
list.forEach(function (d) { h += '<tr><td class="mono">' + esc(d.mac) + '</td><td>' + esc(d.name || '—') + '</td></tr>'; });
return h + '</tbody></table>';
}
function netBtCard(bt) {
if (!bt || bt.available === false) return '<div class="card"><h3 class="card-title">蓝牙</h3>' + homeUnavailable() + '</div>';
let h = '<div class="card"><h3 class="card-title">蓝牙</h3>';
h += '<table class="table"><tbody>';
h += row('状态', '<span class="net-state ' + (bt.powered ? 'ok' : 'warn') + '">' + (bt.powered ? '已开启' : '已关闭') + '</span>');
h += row('地址', esc(bt.address || '—'));
h += row('已配对 / 已连接', (bt.paired_n != null ? esc(bt.paired_n) : '—') + ' / ' + (bt.connected_n != null ? esc(bt.connected_n) : '—'));
h += '</tbody></table>';
h += btDevTable('已配对设备', bt.paired);
h += btDevTable('已连接设备(内核 ACL)', bt.connected);
const a2 = bt.a2dp || [];
h += '<div class="home-sub" style="margin-top:10px">A2DP 传输</div>';
if (!a2.length) h += '<div class="empty">无 A2DP 端点</div>';
else {
h += '<table class="table"><thead><tr><th>设备</th><th>编解码</th><th>采样率</th><th>声道/位深</th><th>传输</th><th>角色</th></tr></thead><tbody>';
a2.forEach(function (a) {
h += '<tr><td class="mono">' + esc(a.mac) + '</td><td>' + esc(a.codec || '—') + '</td><td>' + esc(a.rate || '—') + '</td>' +
'<td class="muted">' + esc(a.channels || '—') + ' / ' + esc(a.bits || '—') + '</td>' +
'<td>' + (a.acquired ? '<span class="home-chip ok">RUNNING</span>' : '<span class="home-chip mut">空闲</span>') + '</td>' +
'<td class="muted">' + esc(a.role || '—') + '</td></tr>';
});
h += '</tbody></table>';
}
h += '</tbody></table></div>';
h += btDevTable('附近设备(扫描缓存)', bt.nearby);
h += '<p class="muted">只读聚合(netmgrd 0xE6:status/paired/connected/a2dp/devices);配对/连接/开关等写操作走实体机,面板未开放。</p>';
h += '</div>';
return h;
}
@@ -1212,8 +1326,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
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;
ifEl = $('#net-ifaces'), poEl = $('#net-policy'), btEl = $('#net-bt');
let st, cn, dn, ifd, pol, bt;
try { st = await api('GET', '/api/net/status'); }
catch (e) { netBannerErr(errText(e)); return; }
netLoaded = true;
@@ -1224,9 +1338,11 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
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; }
try { bt = await api('GET', '/api/net/bt'); } catch (e) { bt = null; } /* F1-③ 蓝牙只读详情 */
if (state.route !== 'net') return;
if (ifEl) ifEl.innerHTML = netIfaceCards(ifd);
if (stEl) stEl.innerHTML = netStatusCards(st);
if (btEl) btEl.innerHTML = netBtCard(bt);
if (poEl) poEl.innerHTML = netPolicyCard(pol);
if (cbEl) cbEl.innerHTML = netConnsBody(cn);
if (dbEl) dbEl.innerHTML = netDnsBody(dn);
@@ -1266,6 +1382,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
'<div id="net-banner" class="page-banner" hidden></div>' +
'<div id="net-ifaces"><p class="placeholder">加载网络接口…</p></div>' +
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
'<div id="net-bt"><p class="placeholder">加载蓝牙详情…</p></div>' +
'<div id="net-policy"><p class="placeholder">加载网络策略…</p></div>' +
'<div class="card"><h3 class="card-title">每实体连接表</h3>' +
'<div class="field-row" style="margin-bottom:12px">' +
@@ -1398,6 +1515,10 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
'<button class="btn" id="file-upload">上传文件</button>' +
'<button class="btn" id="file-refresh">刷新</button>' +
'<input type="file" id="file-input" multiple hidden></div>';
/* F1-④ 搜索(只读,当前目录起递归限深 4 层) */
h += '<div class="field-row wrap" style="margin-bottom:12px">' +
'<input type="text" id="file-search" placeholder="按名称搜索本目录(含子目录,限 4 层)" style="max-width:300px">' +
'<button class="btn" id="file-search-go">搜索</button></div>';
h += '<div class="card"><table class="table"><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!d.items.length) h += '<tr><td colspan="4" class="empty">空目录</td></tr>';
d.items.forEach(function (it) {
@@ -1423,6 +1544,49 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
function renderFiles() { $('#content').innerHTML = '<div class="page" id="files-page"></div>'; navFiles(state.filesRel || '/'); }
/* F1-④ 搜索结果视图(GET /api/files/search,只读;下载/进入仍走既有端点) */
function searchParentRel(it) {
const rel = it.rel || '/';
if (it.type === 'dir') return rel;
const i = rel.lastIndexOf('/');
return i > 0 ? rel.slice(0, i) : '/';
}
async function searchFiles() {
const q = ($('#file-search') ? $('#file-search').value : '').trim();
if (!q) return toast('请输入搜索关键字');
const page = $('#files-page'); if (!page) return;
const abs = state.filesAbs || '/';
page.innerHTML = '<p class="placeholder">在 ' + esc(state.filesRel || '/') + ' 内搜索「' + esc(q) + '」…</p>';
let d;
try { d = await api('GET', '/api/files/search?path=' + encodeURIComponent(abs) + '&name=' + encodeURIComponent(q)); }
catch (e) { page.innerHTML = emptyState('搜索失败:' + errText(e), e.code); return; }
let h = crumbHtml(segments(d.rel), d.rel);
h += '<div class="field-row wrap" style="margin-bottom:12px">' +
'<span class="muted">「' + esc(q) + '」在 ' + esc(d.rel) + ' 内命中 ' + (d.count || 0) + ' 条' +
(d.truncated ? '(已达上限,结果截断)' : '') + '</span>' +
'<button class="btn" id="file-search-back">返回目录</button></div>';
h += '<div class="card"><table class="table"><thead><tr><th>名称</th><th>位置</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
if (!d.items.length) h += '<tr><td colspan="5" class="empty">无匹配</td></tr>';
d.items.forEach(function (it) {
const isDir = it.type === 'dir';
const icon = isDir ? '📁' : '📄';
const parent = searchParentRel(it);
h += '<tr><td>' + icon + ' ' + esc(it.name) + '</td>' +
'<td class="muted mono">' + esc(parent) + '</td>' +
'<td class="muted">' + (isDir ? '—' : fmtBytes(it.size)) + '</td>' +
'<td class="muted">' + fmtTime(it.mtime) + '</td>' +
'<td><div class="row-actions">' +
(isDir
? '<button class="btn btn-ghost" data-sopen="' + esc(it.rel) + '">打开</button>'
: '<button class="btn btn-ghost" data-sopen="' + esc(parent) + '">所在目录</button>' +
'<a class="btn btn-ghost" data-sdl="' + esc(it.rel) + '" data-name="' + esc(it.name) + '" href="#">下载</a>') +
'</div></td></tr>';
});
h += '</tbody></table></div>';
page.innerHTML = h;
}
function absFor(name) { return state.filesAbs ? (state.filesAbs.endsWith('/') ? state.filesAbs + name : state.filesAbs + '/' + name) : null; }
function bindFiles() {
@@ -1433,6 +1597,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
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 sopen = t.closest('[data-sopen]'); const sdl = t.closest('[data-sdl]');
const btn = t.closest('button[id]');
if (crumb) { navFiles(crumb.dataset.rel); return; }
if (nav) { navFiles(nav.dataset.nav); return; }
@@ -1442,11 +1607,22 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
doDownload('/api/files/download?path=' + encodeURIComponent(abs), dl.dataset.dl);
return;
}
if (sopen) { navFiles(sopen.dataset.sopen); return; } /* F1-④ 搜索结果:打开目录/所在目录 */
if (sdl) { /* F1-④ 搜索结果:下载(rel 已是卡根相对) */
e.preventDefault();
doDownload('/api/files/download?path=' + encodeURIComponent(sdl.dataset.sdl), sdl.dataset.name || sdl.dataset.sdl);
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; }
if (btn && btn.id === 'file-search-go') { searchFiles(); return; } /* F1-④ */
if (btn && btn.id === 'file-search-back') { navFiles(state.filesRel); return; } /* F1-④ */
});
c.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && e.target && e.target.id === 'file-search') { e.preventDefault(); searchFiles(); }
});
c.addEventListener('change', function (e) {
if (e.target.id === 'file-input') uploadFiles(e.target.files);
@@ -1548,9 +1724,11 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
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');
const aclose = t.closest('[data-aclose]');
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 (aclose) { appDetailRemove(); return; }
if (exp) {
e.preventDefault();
if (exp.dataset.busy) return; /* 打包期间防连点 */
@@ -1597,14 +1775,110 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
catch (e) { toast('卸载失败:' + errText(e)); }
}
async function appInfo(pkg) {
/* ---- F1-⑤ 单 App 详情四源聚合(FUTURE_MEOWPANEL §2.4,纯前端):apps 页行内展开,
* 并发聚合既有端点:①安装域 /api/apps/:pkg ②运行崩溃史 /api/ams/:pkg ③实时连接
* /api/net/conns?entity=app:<pkg> ④stdout 尾部(newestStdout + /api/logs/read)。
* 单源失败 → 该子块「—/不可用」,绝不拖垮整卡(overview 同款降级)。 ---- */
function appDetailRemove() {
const el = document.querySelector('.app-detail-card');
if (el) el.remove();
appDetailPkg = null;
}
async function appStdoutBlock(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)); }
const file = await newestStdout(pkg);
if (!file) return '<div class="empty">无 stdout 日志</div>';
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(pkg + '/' + file) + '&lines=200');
const lines = d.lines || [];
return '<pre class="log-view">' + esc(lines.join('\n') || '(空)') +
(d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
'<div class="muted">' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行</div>';
} catch (e) { return '<div class="empty">stdout 读取失败:' + esc(errText(e)) + '</div>'; }
}
async function appDetailBody(pkg) {
const r = await Promise.allSettled([
api('GET', '/api/apps/' + pkg),
api('GET', '/api/ams/' + pkg),
api('GET', '/api/net/conns?entity=' + encodeURIComponent('app:' + pkg)),
appStdoutBlock(pkg)
]);
let h = '';
/* ① 安装域(app.json) */
const ap = r[0].status === 'fulfilled' ? r[0].value : null;
if (ap) {
h += '<table class="table"><tbody>' +
row('名称', esc(ap.name || '—')) +
row('版本', esc(ap.version || '—')) +
row('执行', '<span class="mono">' + esc(ap.exec || '—') + '</span>') +
row('体积', fmtBytes(ap.size)) +
row('路径', '<span class="mono muted">' + esc(ap.path || '—') + '</span>') +
'</tbody></table>';
} else {
h += '<div class="empty">安装详情不可用' + (r[0].status === 'rejected' ? ':' + esc(errText(r[0].reason)) : '') + '</div>';
}
/* ② 运行域崩溃/事件史(am history) */
h += '<div class="home-sub" style="margin-top:12px">崩溃 / 事件历史(amsd)</div>';
const am = r[1].status === 'fulfilled' ? r[1].value : null;
const evs = (am && am.available && am.events) || [];
if (evs.length) {
h += '<table class="table"><thead><tr><th>时间</th><th>类型</th><th>详情</th></tr></thead><tbody>';
evs.slice().reverse().slice(0, 10).forEach(function (ev) {
h += '<tr><td class="muted">' + fmtTime(ev.ts) + '</td><td>' + esc(ev.kind || '—') + '</td>' +
'<td class="muted">' + esc(ev.detail || '—') + '</td></tr>';
});
h += '</tbody></table>';
} else {
h += '<div class="empty">无事件记录' + (r[1].status === 'rejected' ? '(' + esc(errText(r[1].reason)) + ')' : '') + '</div>';
}
/* ③ 实时连接(app:<pkg>) */
h += '<div class="home-sub" style="margin-top:12px">实时连接(app:' + esc(pkg) + ')</div>';
const cn = r[2].status === 'fulfilled' ? r[2].value : null;
const conns = (cn && cn.conns) || [];
if (conns.length) {
h += '<table class="table"><thead><tr><th>远端</th><th>本端</th><th>状态</th><th>寿命</th></tr></thead><tbody>';
conns.forEach(function (c) {
h += '<tr><td class="mono">' + esc(c.domain || c.remote || '—') + '</td>' +
'<td class="mono muted">' + esc(c.local || '—') + '</td>' +
'<td>' + esc(c.state || '—') + '</td><td class="muted">' + fmtDur(c.age) + '</td></tr>';
});
h += '</tbody></table>';
} else {
h += '<div class="empty">无连接' + (r[2].status === 'rejected' ? '(' + esc(errText(r[2].reason)) + ')' : '') + '</div>';
}
/* ④ stdout 尾部 */
h += '<div class="home-sub" style="margin-top:12px">stdout 尾部</div>';
h += (r[3].status === 'fulfilled' && r[3].value) ? r[3].value : '<div class="empty">stdout 读取失败</div>';
return h;
}
async function appInfo(pkg) {
if (appDetailPkg === pkg) { appDetailRemove(); return; } // 已展开 → 收起
appDetailRemove(); // 收起上一个
const card = document.querySelector('.app-card[data-pkg="' + escSafeAttr(pkg) + '"]');
if (!card) return;
const box = document.createElement('div');
box.className = 'card app-detail-card';
box.innerHTML = '<div class="app-detail-head"><strong>' + esc(pkg) + '</strong>' +
'<button class="btn btn-ghost" data-aclose="' + esc(pkg) + '">收起</button></div>' +
'<p class="placeholder">并发聚合四源详情…</p>';
card.after(box);
appDetailPkg = pkg;
try {
box.innerHTML = '<div class="app-detail-head"><strong>' + esc(pkg) + '</strong>' +
'<button class="btn btn-ghost" data-aclose="' + esc(pkg) + '">收起</button></div>' + (await appDetailBody(pkg));
} catch (e) {
box.innerHTML = '<div class="app-detail-head"><strong>' + esc(pkg) + '</strong>' +
'<button class="btn btn-ghost" data-aclose="' + esc(pkg) + '">收起</button></div>' +
'<div class="empty">详情加载失败:' + esc(errText(e)) + '</div>';
appDetailPkg = null;
}
}
function renderFontsPage() {
@@ -1967,7 +2241,17 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
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, '返回');
else if (dir === 'back') sendGesture('back'); // 左缘右滑 → 注入设备已识别的返回手势
}
async function sendGesture(name) {
const label = name === 'back' ? '返回' : '回桌面';
try {
const d = await api('POST', '/api/input/gesture', { gesture: name });
if (d && d.dispatched) toast(label + ' 已触发');
else if (d && d.reason === 'local_touch_active') toast('本机触摸中,注入已暂停');
else toast(label + ' 触发未派发');
} catch (e) { toast(label + ' 触发失败:' + errText(e)); }
}
function remotePointerDown(e) {
@@ -2010,6 +2294,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
h += remoteBtn('next', 'next', '下一曲', '下一曲(code=163)');
h += remoteBtn('prev', 'prev', '上一曲', '上一曲(code=165)');
h += remoteBtn('power', 'power', '电源', '电源键(单击;code=116)');
h += remoteBtn('back', 'back', '返回', '左缘右滑返回(注入设备已识别的返回手势)');
h += remoteBtn('home', 'home', '回桌面', '底部上滑回桌面(注入设备已识别的回桌面手势)');
h += '</div>';
h += '<div class="remote-bright">' +
'<span class="r-lbl">指定亮度</span>' +
@@ -2018,7 +2304,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
'</div>';
h += '<button class="btn" id="ctl-powerlong">' + (MP_ICONS.power || '') + ' <span>长按电源(电源菜单)</span></button>';
h += '<p class="muted" style="margin-top:8px;line-height:1.8">滑动手势(在遥控板上):上滑=音量+ · 下滑=音量- · 左滑=下一曲 · 右滑=上一曲 · 左缘右滑=返回。<br>' +
'<b>返回/主页</b>属物理键红线(<code>/api/key</code> 后端禁注入 158/172),故未提供注入按钮。</p>';
'<b>返回/回桌面</b>走手势注入(<code>/api/input/gesture</code>,模拟设备已识别的左缘右滑/底部上滑),<b>不走</b>被物理键红线禁注入的 <code>/api/key</code> 158/172。</p>';
h += '</div>';
return h;
}
@@ -2484,6 +2770,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
else if (a === 'next') sendKey(163, 'tap');
else if (a === 'prev') sendKey(165, 'tap');
else if (a === 'power') sendKey(116, 'tap');
else if (a === 'back') sendGesture('back');
else if (a === 'home') sendGesture('home');
return;
}
if (e.target.closest('#ctl-powerlong')) { sendKey(116, 'long', 3000); return; }
+48 -1
View File
@@ -99,6 +99,53 @@
if (arr.length > SPARK_MAX) arr.shift();
}
/* ---- F1-① 性能趋势(FUTURE_MEOWPANEL §2.1):pushSpark 模式泛化到任意 0..100 归一值 ---- */
const trendCpu = [], trendMem = [], trendLoad = [], trendTemp = [];
function pushTrend(arr, v) { // 0..100 归一值入队(<=SPARK_MAX 点);null/NaN 不入队(诚实空缺)
if (v == null || isNaN(v)) return;
arr.push(Math.max(0, Math.min(100, Number(v))));
if (arr.length > SPARK_MAX) arr.shift();
}
function resetTrend() { trendCpu.length = 0; trendMem.length = 0; trendLoad.length = 0; trendTemp.length = 0; }
/* series: [{cls,arr}],cls 决定颜色(CSS .trend-cpu/.trend-mem/.trend-load/.trend-temp),
* 位置稳定(首页 3 序列、perf 页 4 序列共用同一语义色,不靠序号猜色)。 */
function trendSvgHtml(series) {
let polys = '';
series.forEach(function (s) {
polys += '<polyline class="' + s.cls + '" points="" fill="none" stroke-width="1.6" vector-effect="non-scaling-stroke"/>';
});
return '<svg class="lvl-spark trend-spark" viewBox="0 0 100 46" preserveAspectRatio="none" aria-hidden="true">' +
'<line class="grid" x1="0" y1="46" x2="100" y2="46"/>' +
'<line class="grid" x1="0" y1="23" x2="100" y2="23"/>' + polys + '</svg>';
}
function drawTrendSpark(rootEl, series) {
const svg = rootEl && rootEl.querySelector('.trend-spark');
if (!svg) return;
series.forEach(function (s) {
const p = svg.querySelector('.' + s.cls);
if (p) p.setAttribute('points', sparklinePoints(s.arr));
});
}
/* 通用折线点串(任意长度数组,值 0..100 归一;null 跳过):x 铺满 w,y 反向映射 h 内(F1-② 功耗 4h 曲线用) */
function polylinePoints(vals, w, h) {
const n = vals.length;
if (!n) return '';
const out = [];
for (let i = 0; i < n; i++) {
const v = vals[i];
if (v == null || isNaN(v)) continue;
const x = n > 1 ? (i / (n - 1)) * w : w / 2;
const y = h - 2 - Math.max(0, Math.min(100, v)) / 100 * (h - 4);
out.push(x.toFixed(1) + ',' + y.toFixed(1));
}
return out.join(' ');
}
function drawSpark(rootEl) {
const svg = rootEl && rootEl.querySelector('.lvl-spark');
if (!svg) return;
@@ -463,5 +510,5 @@
});
}
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 });
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, drawTrendSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, polylinePoints, pushSpark, pushTrend, refreshAuthGate, resetSpark, resetTrend, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, trendCpu, trendLoad, trendMem, trendSvgHtml, trendTemp, updateLevelMeter, volTimer });
})();
+530
View File
@@ -3293,3 +3293,533 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进
- **`account.json` 旧账号首次升级**:旧文件无 `last_refresh_ts` 字段 → `cookie_last_refresh_ts=0` → 视为临期,开机续期一次;成功即落盘,避免反复判临期。
- **eapi 会员播放(song_url 等)因 MUSIC_U 头注入而改变行为**:此前 eapi 请求不带 MUSIC_U(§36.5 遗留),登录态 eapi 播放按游客鉴权;本批起带真实 MUSIC_U,会员档/登录态更准确——需在 eapi 可达网络下观察歌单/播放回归(`music.163.com` 域不受影响)。
- **未部署**:`ncm` 产物未上卡;真机仅做了只读探针(不动 App 二进制),`app.json`/`ncm` 未替换。
## 38. M5:已缓存列表页 + 缓存文件内元数据嵌入(2026-11-04)
用户原话:「优化一下网易云的部分逻辑。在首页顶栏新加一个已缓存列表入口。另外,缓存文件的配置改一下:
在保留元数据分离的同时,将歌曲元数据嵌入缓存文件中(不然在其他播放器中读不到任何信息)。」
### 38.1 功能①:首页顶栏「已缓存」入口 + 缓存列表页
- 首页 topbar 新增动作 3(`MEOW_ICON_DOWNLOAD` 位图,`app/ui/home.c`),原「回播放页」顺延为动作 4;
点击 `cache_open()` → push `ACT_CACHE`(新 activity id 7)。
- 新页 `app/ui/cache.c`(对齐 queue.c 惯例:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮):
- 行:歌名 / `歌手 · 实际档位 · 大小`;空态「暂无缓存」;页脚「N 首 · 合计 X MB」。
- 点行 = 点播(切 `NCM_QUEUE_CACHE` 队列,上/下一首在缓存列表内循环);行右「删除」= 单曲删除;
顶栏「清空」= 确认框 + `ncm_cache_clear`(与设置页「清理缓存」同语义:音频 + 封面条目)。
- **数据全在 worker**:`cache_reload` 起线程 → `appstate_cache_list_load()` →
`ncm_cache_scan()`(读目录 + 文件头 tag + 音频参数探测)→ 快照;UI 线程只读快照(零磁盘 IO)。
- 播放链路:`NCM_QUEUE_CACHE`(`appstate_queue_count/song`、`player_play_cache_song`);
`player_manager.c` 的 `song_local_file` 对缓存队列用**条目真实键**(id + 文件名请求档 + 实际扩展名)
直接命中缓存文件(不按用户当前档键重下),`eff_level` 回填实际档。
### 38.2 功能②:缓存文件内元数据嵌入(保留元数据分离)
- 新模块 `core/ncm_tag.c/.h`(纯 libc,宿主可单测):
- **FLAC → VORBIS_COMMENT**(type 4,TITLE/ARTIST/ALBUM;插在既有元数据块之后、音频帧之前,
旧 VORBIS_COMMENT 被替换、PADDING/PICTURE 等原样保留);
- **MP3 → ID3v2.3**(TIT2/TPE1/TALB,UTF-16LE + BOM,中英文/emoji 均可;源已带 ID3v2 时先剥旧标签)。
- **按实际容器分支**(读 `fLaC` 魔数)——缓存名里的档位段是**请求档**,真机存在
`song_171650_hires.mp3`(无损请求被降级链落到 exhigh mp3),不能按名字判容器。
- 写入 = 同目录 `<path>.tag.tmp` + 64KB 分块搬运 + fsync + rename 原子替换;失败即 unlink 临时件、
原文件不变 → 调用方 commit 裸流(**降级不影响播放主链路**)。
- 接入点 `app/play/player_manager.c`:下载完整(`dl_rc==OK`)→ `stream_src_finish`(读端先自然收尾)→
`ncm_tag_embed_file(.tmp, ext, {name, art_name, album_name})` → 播放线程 `dispose` join 后才 commit。
解码端全程持有原 inode 的 fd(tag 重写是 rename 换 inode),播放/seek/续传字节语义零改动。
- **元数据分离体系不动**:`<cache_dir>/meta/<id>.bin` 旁路照旧写读;LRU 只 stat 文件(无 size 索引,
故无「回填 size/etag」需求——重写后 stat 自然得到新 size)。
### 38.3 实际档位(用户/塔台取证要求)
`ncm_audio_probe_file`(ncm_tag.c)只读文件头:FLAC STREAMINFO(采样率/位深)、MP3 首帧(码率);
`ncm_cache_scan` 用「请求档 ∩ 实际格式能力」得实际档(只降不升):
FLAC 16bit→无损 SQ;24bit/48k→Hi-Res;24bit/96k→高清臻音;24bit/192k→超清母带;
MP3 ≤128k→标准,>128k→极高 HQ。列表显示**实际档**(如 hires 名 + mp3 体 → 极高 HQ)。
### 38.4 内存(设备 56MB,硬约束)
| 项 | 峰值 |
|---|---|
| tag 嵌入(每次 commit 前) | malloc **64KB** 分块缓冲 + ~2KB 栈(ID3 构建)+ 结构体 ≈ **68KB 瞬时**,完毕即释放 |
| 缓存页扫描(worker) | 快照临时数组 512×232B ≈ **116KB** + 单文件 tag/探测 64KB 顺序复用 ≈ **180KB 瞬时** |
| 常驻 bss | `g_st.cache_items[512]` ≈ **116KB**(+8.5% 于原 1364516B bss;RSS 占比 <0.3%) |
大文件不整读:FLAC 元数据块/PICTURE、MP3 标签区、音频体全部 ≤64KB 分块;tag 读取只读文件头(≤64KB)。
### 38.5 验证
| 项 | 结果 |
|---|---|
| 新 `test/test_ncm_tag.c` + `build_ncm_tag_host.sh`(FLAC/MP3 构建+读回、中文 roundtrip、旧标签替换、容器判定、3MB 分块、失败降级、音频头探测) | host **51 PASS / 0 FAIL** |
| `test_ncm_core.c` 新增 `test_cache_tag`(writer→embed→read→scan→remove→clear,含 hires 名/mp3 体实际档断言) | 已加入 core 测试集(本机缺 vendor mbedtls/jsmn 无法整链,见 38.6) |
| 临时单测(真实 ncm_cache.c + ncm_api.c + ncm_tag.c,仅桩 `ncm_strlcpy`/`mkdir_p`) | 16 PASS / 0 FAIL(scan 两条、actual=EXHIGH/HIRES、name/artist 来自文件内 tag、remove/clear) |
| 全部改动文件 `gcc -Wall -Wextra`(core + app + tests,-Wpedantic 对 ncm_tag) | **0 warning** |
| `build_ncm.sh` / `build_core.sh` / `build_ncm_host.sh` 源列表 | 已加入 `core/ncm_tag.c`、`app/ui/cache.c`(交叉/宿主编译本机缺工具链与 vendor,未执行) |
### 38.6 诚实出入 / 遗留
- **交叉与整链测试未在本机执行**:worktree 里 `vendor/mbedtls|jsmn|minimp3|qrcodegen` 均被 .gitignore 排除且
本机无交叉工具链(`libs_common.sh` 指向 /home/Aska/Project/R3ProII/.tools)。故 `build_core.sh`/
`build_ncm.sh`/`build_ncm_host.sh` 与本仓库 core 测试**未运行**;已用真实源码单独编译(0 warning)并跑
ncm_tag 全量与临时 cache-scan 单测覆盖。**需在构建环境补跑 `build_core.sh` 的 host+mipsel 两套测试**。
- **未上真机**:不碰真机 Music/MeowData(红线)。ta 嵌入后「外部播放器可读」「minimp3/dr_flac 读带 tag 的
缓存文件仍正常」需真机抽验;minimp3_ex 对 ID3v2 有跳过逻辑(VENDOR_NOTES:「minimp3_ex 带 ID3/CRC 处理」),
dr_flac 原生解析 VORBIS_COMMENT 元数据块,理论兼容,但未实测。
- **tag 重写是一次额外整文件读+写**(TF 顺序 IO):在下载线程内、播放收尾前完成;短曲/快速拖动到底时
dispose 的 join 可能多等 ~0.3–1s。磁盘瞬时要 2× 单曲空间(失败自动降级裸流)。
- **缓存队列点播的时长/封面**取自旁路 `meta/<id>.bin`(单次 ≤600B 读);无旁路的旧条目只有歌名/歌手/大小。
- **列表上限 512 条**(最近访问优先);池内更多时页脚仍显示展示条数,超出部分需 LRU 或搜索触达。
## 39. M6:缓存文件名改为「实际档」(2026-11-04)
用户原话:「有降级显示,但是那个缓存文件名误导人」。背景:现缓存命名 `song_<id>_<请求档>.<实际格式>`,
当服务端对无损档只给 mp3 时降级链落到 exhigh,文件名却仍写 hires(真机 `song_171650_hires.mp3`),名不副实。
### 39.1 设计(先写方案再动代码)
- **命名**:新写入一律 `song_<id>_<实际档>.<实际格式>`,实际档由 `ncm_cache_actual_level(req, 音频参数,
响应生效档)` 判定(**单一事实源**:写盘名 / 播放页显示 / 已缓存列表三处共用)。
规则:`min(请求档, 容器可承载档, 降级链生效档)`,只降不升——
mp3 码率 >128k→exhigh、≤128k→standard(mp3 永不高于极高,码率未知时也钳到极高);
flac 16bit/44.1k→lossless、24/48→hires、24/96→jyeffect、24/192→jymaster。
典型:hires 请求 + 320k mp3 响应 → `song_171650_exhigh.mp3`。
- **命中语义(同歌同档不重复下载)**:`ncm_cache_lookup` 在请求档精确 miss 后**只降不升**逐档下探
(`surround→sky→jymaster→jyeffect→hires→lossless→exhigh→higher→standard`,DOLBY 不在链内)。
于是 hires 请求命中已存的 `song_<id>_exhigh.mp3` / `song_<id>_lossless.flac`,不重下;低档请求
不会向上命中高档实体(不偷跑流量/存储)。`.part` 续传键也用实际档,同名才续传/commit。
- **旧文件兼容(不强制迁移)**:旧命名 `song_<id>_hires.mp3`(及无档位段 `song_<id>.mp3`、历史双点名)
原样保留:按原键可命中、可播放、可单曲删除、可 LRU 淘汰;列表按文件头探测显示实际档(M5 既有链路)。
播放/列表的生效档由 `ncm_cache_level_from_path`(文件名档位)+ 容器钳顶得出:mp3 实体不可能高于
极高,故旧 `hires.mp3` 标注「极高」,与实际一致。**不再**像 §NEW 任务2 那样把旧 mp3 改名 + 强制重下。
- **去重展示**:同曲同实际档、两种命名并存时(旧请求档名 + 新实际档名),`ncm_cache_scan` 只保留
最近访问的一条;不同实际档(如 lossless.flac + exhigh.mp3)照常各列一行。
### 39.2 改动
| 文件 | 改动 |
|---|---|
| `core/ncm_cache.c/.h` | 新增 `ncm_cache_actual_level`(容器/码率/位深/响应档→实际档,只降不升)、`ncm_cache_level_from_path`(文件名档位解析,含 `sky_c51`);`ncm_cache_lookup` 拆出精确查找 + 档位下探链;`ncm_cache_scan` 复用实际档判定并按 (id, actual_level, ext) 去重 |
| `app/play/player_manager.c` | 写盘键改用响应实际档(`ext` 判定后计算,`.part`/`open`/日志同键);`eff_level` = 实际档;命中路径按文件名档位 + mp3 钳顶标注实际档;删除旧「stale-degraded 改名+强制重下」与「无档位键迁移」块(老卡文件免迁移) |
| `test/test_ncm_cache.c` + `build_ncm_cache_host.sh` | 新增纯 libc 宿主单测:命名生成/解析、降级后写入名、hires 命中已存 exhigh/lossless、低档不命中高档、旧命名命中/扫描/删除、双点名迁移、列表去重、LRU/清空/删除对两种命名兼容、`level_from_path` 边界 |
| `test/test_ncm_core.c` | 新增 `test_cache_actual_naming`(同一组语义接入 core 全量测试集;本机缺 vendor 未整链,仅 `-fsyntax-only` 0 warning) |
| `bench/player_flow_sim.c` | 适配:补 `ncm_cache_actual_level`/`ncm_cache_level_from_path` 桩与 M5 后缺失的链接桩(`appstate_cache_*`、`ncm_tag_embed_file`);P7/P12 期望键改实际档;P13 改为「旧命名命中即播 + 实际档显示,不重下不迁移」 |
### 39.3 验证
| 项 | 结果 |
|---|---|
| `./build_ncm_cache_host.sh`(真实 `core/ncm_cache.c` + `core/ncm_tag.c`,仅桩 `ncm_strlcpy`/`mkdir_p`/档位映射) | **63 PASS / 0 FAIL**,`-Wall -Wextra` 0 warning |
| `player_manager.c` / `player_flow_sim.c` 语法+告警检查(`gcc -O2 -Wall -Wextra -fsyntax-only`,临时 jsmn 头垫片) | 0 warning |
| `bench/player_flow_sim.c` 链接(真实 player_manager + stream_src + mem_watch + meow_cachegov) | 链接通过(运行需可写 `/tmp`,本机 Termux 不可写,见 39.4) |
| `test_ncm_tag_host.sh` 既有链路 | 未改 `ncm_tag.c`,未重跑(非本批改动面) |
### 39.4 诚实出入 / 遗留
- **本机缺 vendor(mbedtls/jsmn/minimp3/qrcodegen)与交叉工具链**,`build_core.sh`/`build_ncm.sh`/
`build_ncm_host.sh` 未执行;`test_ncm_core_host` 未重编(M5 已记录同一限制)。新语义用
`test/test_ncm_cache_host`(真实 core 源)全量覆盖,构建环境需补跑 core 全量回归。
- **`player_flow_sim` 未在本机运行**:其路径硬编码 `/tmp`(Termux 不可写),M5 后还缺若干链接桩
(本批已补齐);需在常规构建机跑 P1–P15 全量。
- **质量取舍(任务口径)**:命中下探后,hires 请求会直接播放已存的 exhigh 实体,不再像 §NEW 任务2
那样强制为「更高档」重下。这是「实际档命名 + 同名去重」的必然结果(用户已接受降级显示);
如需「换高档时重取」,应作为显式「重新下载」交互另开需求。
- **flac 头参数在响应时点未知**:落盘名以降级链生效档为上限,下载完成后由列表探测显示实际档;
服务端「声称 hires 实为 16bit」的病态情况,文件名会高于列表实测档(既有取舍,未引入回归)。
- **旧命名文件的跨档复用**:旧 `song_<id>_hires.mp3`(实际 exhigh)在 hires 请求下直接命中;若用户
切到「极高」档再播同名曲,会按实际档新落一份 `song_<id>_exhigh.mp3`(一次性重下,旧文件不删,
列表按 (id,实际档,ext) 去重只显示一行,旧文件由 LRU/清空自然回收)。未对旧名做向上探测扫描
(避免每次 miss 扫目录 + 读多份文件头);如需「旧名跨档零重下」,可作为后续优化加一次目录级兼容探测。
## 40. M8:交叉编译 + 真机热更(2026-11-04)
用户原话:「跑」(把 M5/M6 已合并代码交叉编译并热更上卡真机验证)。
### 40.1 宿主适配层(新增入库)
| 文件 | 改动 |
|---|---|
| `build_ncm_cross_host.sh`(新) | 不改原脚本的容器路径约定:临时布局同构 `$HOME/Project/R3ProII`(`.tools/zig-mipsel-cc`、`mipsel-linux-muslsf-cross/mipsel-linux-muslsf` → 本机 `~/.tools/mipsel-linux-musleabi-sysroot`、`zig-env/bin/zig` → 系统 `ar` 包装)+ launcher 镜像(仓库头文件 + 取回的 `meow_font.a`),导出 `CC_MIPS`/`AR_MIPS`/`TMPDIR` 后按 `sdk → vendor → host-mbedtls → core → tests → ncm` 原样执行;每步日志落 `~/.cache/meow-ncm-build/logs/`。缺省 `all`,也可单步跑。 |
| `libs_common.sh` | `CC_MIPS`/`AR_MIPS` 由硬编码 `/home/Aska/...` 改为 `MEOW_R3PROII_ROOT`(缺省 `$HOME/Project/R3ProII`)+ 环境变量可覆盖;容器行为不变。 |
| `build_mbedtls.sh` | 清旧对象从 `library/*.o` 改为递归清 `library/` + `3rdparty/`(everest/p256-m 的对象在 3rdparty),否则混架构对象会被归档进库。 |
| `test/test_ncm_core.c` | 三处硬编码 `/tmp`(store/cache/tag)改为 `$TMPDIR` 优先,默认 data_dir 同改;Termux `/tmp` 不可写,否则 deviceId 持久化/机器密钥直接 FAIL。 |
> 任务建议的 `build_ncm_host.sh` 名称已被「宿主 UI 截图/内存 harness」占用,适配层改用 `build_ncm_cross_host.sh`。
### 40.2 构建结果
| 项 | 结果 |
|---|---|
| `build_sdk_host.sh --verify` | 75 成员(31 OBJS + meow_font.o + 43 freetype),全 ELF32 MIPS32 rel2(o32);`meow_ui.a` 5,701,866 B;本机约 16s |
| mbedtls(裁剪 config,102 enabled) | mipsel:crypto text 267,933 / x509 39,074 / tls 112,485(合计 419,492 ≈ 410 KB) |
| `build_ncm.sh` | `ncm` **1,193,464 B**;text 1,084,494 / data 1,948 / bss 1,493,360;ELF32 MIPS32 rel2、静态链接、已 strip;**0 warning**(自有代码 -Wall -Wextra) |
| md5 | `1bbd2a5527dc992de60cdf4b0ba8656d` |
| 耗时 | 首次整链约 13s;清空 `out/app-mipsel` 后重编 39 个对象约 5s(zig cc 进程级缓存命中) |
### 40.3 宿主全量测试(补齐 M5/M6 缺口)
| 测试 | 结果 |
|---|---|
| `test/test_ncm_core_host`(全量,含 M5 `test_cache_tag` / M6 `test_cache_actual_naming`) | **139 PASS / 0 FAIL / 2 SKIP**(SKIP 均为 live 网络项:Termux 环境 unikey rc=-5;计数不含 `== RESULT: PASS ==` 横幅) |
| `test/test_ncm_tag_host`(FLAC/MP3 tag 嵌入/读回/分块/降级) | **52 PASS / 0 FAIL** |
| `test/test_ncm_cache_host`(实际档命名/命中下探/旧名兼容/LRU) | **63 PASS / 0 FAIL** |
| 告警 | core/tag/cache/ncm 各构建日志 `warning:` 计数均为 0 |
| mipsel 测试二进制 | `out/test_ncm_core_mipsel` text 545,098 链接成功;本机无 `qemu-mipsel`,未执行 |
### 40.4 真机热更(白名单仅 MeowAPPs/dev.player.ncm/)
- 设备 `root@10.76.208.50`,`app.json.exec=/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm`。
- 旧件 md5 `276b6caaf3212389f7f9dab1c289ac27`(1,160,092 B)→ 备份 `ncm.bak.276b6caaf3212389f7f9dab1c289ac27`(同目录;原有 `ncm.bak` 保留)。
- 新件经 `ssh 'cat > ncm.new'` 流式写入 → 卡上 md5 核对一致 → `chmod 755` + `mv` → `sync`。
- 卡上 md5 `1bbd2a5527dc992de60cdf4b0ba8656d` == 本地;大小 1,193,464 B。
- **红线校验**:`du -s /usr/data/mnt/sd_0/Music` 写前/写后均为 **199,164,288 B**,恒定;未触碰 Music/MeowData/固件,未重启设备。
- 生效方式:launcher 直接执行 `MeowAPPs/<pkg>/` 二进制,替换文件即生效,app 列表无需 rescan。热更时 ncm 未在运行,用户重新打开 App 即加载新件(物理验收由用户做)。
### 40.5 遗留
- **mipsel 侧未运行**:本机无 `qemu-mipsel`,`out/test_ncm_core_mipsel` 只完成编译+链接;真机功能验证依赖用户打开 App(顶栏入口/列表)。
- **live 网络测试跳过**:Termux 环境 HTTPS 到 music.163.com 返回 rc=-5(网络/证书被拒),2 项 SKIP;加密/缓存/API 解析等离线项全部 PASS。
- **产物不入库**:`ncm` 二进制、`meow_ui.a`、vendor、`out/` 均只在本机产出;仓库中原本被跟踪的旧 `ncm`/`test_ncm_core_host` 已恢复为 HEAD 版本,避免把本机构建产物带进提交。
- 卡上保留两份备份(`ncm.bak` 与 `ncm.bak.<旧md5>`),需要回滚可 `cp` 任一覆盖。
## 41. M9:断网保持登录态 + 离线模式(账号态不被网络故障否决)(2026-11-05)
> 用户原话:「网易云有个问题,只要设备一断网 它就会自动退出到扫码登录界面,而不会保持当前账号状态
> 切换到离线模式。这样导致昨天刚做的那个缓存列表形同虚设。估计是没有判断网络状态 把网络访问不可达
> 当成了登录状态过期」。
>
> 目标:网络不可达/超时/DNS/连接失败/服务端 5xx → **保持账号态**、进入离线模式(界面可见提示、
> 缓存列表可打开可播放、不弹扫码),网络恢复后回在线;只有服务端**明确**未登录/鉴权失败
> (HTTP 401、业务码 301/302)才沿用原退出登录逻辑。排除:不做离线下载/搜索等在线功能。
> 红线:0warning;宿主可验证错误分类单测必过;不碰真机;不 push。
>
> 编号说明:§40 为 M8 交叉编译/热更(本分支已 rebase 到含 §40 的 main),本节顺延 §41。
### 41.1 判定点盘点:谁会把用户踢回扫码
| # | 位置 | 触发 | 后果 |
|---|------|------|------|
| ① | `app/app.c:176-180` `app_on_tick` | `account_cookie_need_relogin()`(仅由 `account_cookie_maybe_refresh` 置位) | `appstate_logout()`(删 `session.enc`、清 uid/vip)+ `meow_view_page_replace(ACT_LOGIN)` |
| ② | `app/logic/appstate.c:231-256` `appstate_ensure_uid` | `ncm_login_status_check` 返回 `NCM_ERR_AUTH` | `ncm_ctx_clear_cookie` + `logged_in=0`(首页转「未登录/会话过期」) |
| ③ | `app/app.c:245-252` 启动根页 + `app/logic/appstate.c:100` | `g_st.logged_in = (cookie[0] != 0)`;cookie 只在 `ncm_ctx_load_cookie` 解密成功时非空 | cookie 解密失败 → 直接进 `ACT_LOGIN`(扫码页) |
| ④ | `app/ui/settings.c` | 用户点「退出登录」 | 主动登出(保留原逻辑) |
调用链(①):`home_worker`(`app/ui/home.c`) → `account_cookie_maybe_refresh`(`app/logic/account.c:181`)
→ `ncm_login_refresh`(`core/ncm_login.c:190`) → 返回码 → `s_cookie_need_relogin=1`(`account.c:210-215`)
→ `app_on_tick`(`app.c:176`) → `appstate_logout()` + 扫码页。
调用链(②):`appstate_load_playlists`(`appstate.c:227`) → `appstate_ensure_uid`(`appstate.c:231`)
→ `ncm_login_status_check`(`core/ncm_login.c:142`) → 返回码 → 清 cookie(`appstate.c:246-250`)。
### 41.2 根因:哪些失败被误判成「登录失效」
代码级证据(均为本批修改前的行为):
1. **任何业务码非 200 都当会话失效**:`core/ncm_login.c:227`(改前)`if (code != 200) return NCM_ERR_AUTH;`
—— `-460`(风控/请求被拒,换网/恢复网络时常见)也被判 AUTH。`core/ncm_api.c:104`(改前)
把 `-460/2000/201` 一律映射 `NCM_ERR_AUTH`。→ 网络环境变化触发的风控被当成「会话过期」。
2. **HTTP 状态被完全忽略**:`api_post`(`core/ncm_api.c:123`)与 `ncm_login_status_check`
(`core/ncm_login.c:162`)只看 body 里的业务码。HTTP 401/403 与 5xx/网关 HTML 都落进
「JSON 解析失败 / 缺字段」;反过来 5xx 与网络故障无法区分,离线判定拿不到可靠输入。
3. **mbedtls 裸负码穿透**:`http_run` 的握手/写入失败分支 `goto done` 直接把 mbedtls 的负码
(如 `-0x7280`)返回给上层(`core/ncm_http.c` 改前 `done:` 段);上层按 `NCM_ERR_*` 比较,
落进「未知错误」,既判不了离线也判不了鉴权。
4. **启动无网的持久化缺陷(与错误分类无关,但症状一致)**:会话 cookie 的加密密钥是
`sha256(首选网卡 MAC + 盐)`(`core/ncm_store.c:102-135 read_mac` 遍历 `/sys/class/net` 取首个
非 lo 网卡 + `151-171` 派生)。网卡集合/readdir 顺序随网络状态变化(断网时 Wi-Fi 网卡可能
从 `/sys/class/net` 消失,或选到另一块网卡)→ 派生密钥变化 → `session.enc` 解密失败
(`ncm_store.c:260-268` → `ncm_ctx_load_cookie` → `appstate.c:100`)→ cookie 为空 →
`logged_in=0` → **启动即进扫码页**。这正是「一断网就退到扫码」在启动侧的机制。
结论:设计上缺少「失败分类」这一层——传输层/服务端暂不可用、鉴权失败、数据解析失败共用
「负错误码」这一个通道,登录门(`app.c` / `appstate_ensure_uid`)只能按 AUTH 一个值判断,
于是任何上游映射偏差都会变成「退出登录」。
### 41.3 实现
| 文件 | 改动 |
|---|---|
| `core/ncm_session.h/.c`(新) | 纯逻辑分类层:`ncm_err_classify`(NETWORK/AUTH/OTHER;`-4/-5/-6/-17` 与历史 mbedtls 裸负码→NETWORK,仅 `-13`→AUTH)、`ncm_http_status_class/err`(401/403→AUTH;408/425/429/5xx→SVC;其它非 2xx→HTTP)、`ncm_gate_decide`(一次请求→在线/离线/退登录/保持)、`ncm_session_boot_decide`(有本地会话就先信它,无网→离线,绝不因连不上进扫码)。 |
| `core/ncm_common.h` | 新增 `NCM_ERR_SVC = -17`(服务端 5xx/429/-460:暂不可用,可重试,**不是**登录失效)。 |
| `core/ncm_http.c` | 新增 `http_ret_norm`:超时→`NCM_ERR_TIMEOUT`、证书→`NCM_ERR_TLS`、其它 mbedtls 负码→`NCM_ERR_NET`,在 `done:` 统一归一(532 行)。**已是 NCM 错误码的一律原样返回**(含 `NCM_ERR_JSON`:畸形分块/帧体是数据错误,不得改判成网络不可达——评审 P2-1)。 |
| `core/ncm_api.c` | `api_status_gate`:非 2xx 先按状态定性(401/403→AUTH、5xx/429→SVC、其它→HTTP),不再把 5xx HTML 丢给 JSON 解析;业务码映射加 302→AUTH、`-460`→SVC(113-141 行)。`api_post_cached` 新增 `from_cache` 出参;新增 `ncm_api_user_playlist_ex`(回填「本次是否接口缓存命中」;`out_n` 与原接口一致必填、`out_from_cache` 可 NULL),旧接口为薄包装(评审 P2-2)。 |
| `core/ncm_login.c` | `ncm_login_status_check` / `ncm_login_refresh` 先过 HTTP 状态门,再按业务码:301/302→AUTH、`-460`→SVC、其它→HTTP(163-249 行)。`-460` 不再触发退扫码。 |
| `core/ncm_store.c/.h` | 会话密钥改为设备私有 `<data_dir>/.mkey` 派生(`key_from_mkey`/`session_key`,175-193 行);旧 MAC 派生密文仍可解(`cookie_decrypt_sel`,270 行)且 `load_session` 命中旧密钥时自动重写迁移(306-317 行)。 |
| `core/ncm_apicache.c/.h` | 盘缓存命中分支的两行合并为一行(行为不变,重构)。**不再提供全局命中计数**(评审 P2-2:跨 worker 差值会误判、自增在锁外有竞争;改为单请求出参,见下)。 |
| `app/ncm_app_internal.h` | `ncm_app_state_t` 增 `offline`;新增 `appstate_session_gate/is_offline/offline_set`。 |
| `app/logic/appstate.c` | 新增离线状态机(177-197 行);`appstate_ensure_uid` 仅 AUTH 清 cookie,网络类只切离线(231-256 行);init/logout 清零离线态;新增 `appstate_load_playlists_ex(from_cache)` 透传单请求缓存命中标志。 |
| `app/app.c` | 启动根页改由 `ncm_session_boot_decide` 决定并打日志(245-252 行);`need_relogin` 路径保持(仅 AUTH 会置位)。 |
| `app/ui/home.c` | worker 结果经 `appstate_session_gate`;「本次是否接口缓存命中」用 `appstate_load_playlists_ex(&from_cache)` 的单请求出参判定(缓存命中→不判回在线),个人卡右侧「离线模式」红标 + 页脚提示 + 空态「离线模式/可播放已缓存」;离线时每 30s 静默重试一次(强制绕过接口缓存)。 |
| `app/ui/playlist.c` | 首屏**失败**时同步离线态(成功不判回在线:歌单详情可能命中接口缓存;锁外应用);空态「离线模式」。 |
| `app/ui/search.c` / `app/ui/player.c` / `app/ui/cache.c` | 离线提示文案:搜索需网络、播放页「离线模式:网络不可用,可播放已缓存」、缓存页页脚「· 离线模式(可播放)」。 |
| `build_core.sh` / `build_ncm.sh` | 核心源列表加 `core/ncm_session.c`。 |
| `build_ncm_sims.sh` | window/search/plpage/apicache/cookie_refresh 链入 `ncm_session.o`;新增 `session_gate_sim` 构建/运行。 |
| `bench/session_gate_sim.c`(新) | 分类矩阵 + HTTP 状态矩阵 + 登录门/启动门矩阵 + 真实 `ncm_api`/`ncm_login` 状态/业务码映射 + 会话密钥稳定性/旧密文迁移(83 断言)。 |
| `bench/http_truncate_sim.c` | 追加 4 个传输层归一用例(握手 fatal alert→NET、握手超时→TIMEOUT、读 fatal alert→NET、CA 失败→TLS)。 |
行为不变量(本次确立):**只有 `NCM_ERR_AUTH`(HTTP 401/403、业务码 301/302/profile 为空)会清
`session.enc` 并回扫码;其余一切失败最多进入离线模式,绝不动账号态。**
### 41.4 验证(宿主,0 真实 NCM 请求)
| 项 | 结果 |
|---|---|
| `bench/session_gate_sim`(新,真实 core + `ncm_http_request` 桩) | **88 PASS / 0 FAIL**:A 分类矩阵 11 例(-5/-4/-6/-17/裸 mbedtls/…)、B HTTP 状态 14 例、C 登录门 10 + 启动门 6 例、D `ncm_api` 15 例(401/403、5xx/429、业务 301/302、-460、非 JSON、传输码)、D2 `from_cache` 出参 5 例(miss→0/命中→1 且 0 请求/失败→0/兼容入口/out_n 必填契约)、E `login_status` 8 + `login_refresh` 8 例、F 会话密钥 9 例(换 MAC 仍可解、旧密文可解并自动迁移) |
| `bench/http_truncate_sim` | **13 PASS**(原 8 截断用例零回归 + 4 个传输层归一用例 + 1 个畸形分块(NCM_ERR_JSON 不被改判 NET)用例) |
| `test/test_ncm_core_host`(全量核心库) | **139 PASS / 0 FAIL**(`TMPDIR` 指向可写目录;含 crypto/store/cache/tag/api/降级链全量) |
| `bench/window_sim` / `bench/search_sim` | PASS(window 需可写 TMPDIR:本机 `/tmp` 不可写时盘缓存用例环境性失败,换 `$TMPDIR` 后 6000/6000 落盘通过) |
| `bench/apicache_sim` / `bench/cookie_refresh_sim` | PASS(apicache 同样仅盘缓存文件路径受 `/tmp` 影响,换可写目录后通过) |
| 宿主告警 | 核心 13 个 TU(含新 `ncm_session.c`)+ 应用 13 个 TU(login/home/playlist/player/search/cache/appstate/account/settings/queue/minibar/chrome/colors)`-Wall -Wextra` **0 warning** |
| mipsel / 真机 | **未跑**(本机无 zig 交叉链,M8 适配层未并入;设备当前离线,真机验证后置) |
### 41.5 诚实出入 / 遗留
- **5xx 归类为「离线/可重试」**(`NCM_ERR_SVC`),UI 统一显示「离线模式」——网络其实可能是通的,
只是服务端暂不可用;文案取「可播放已缓存」这一用户可执行动作,未再细分。
- **HTTP 403 仍按 AUTH**(标准语义:服务端理解请求但拒绝)。若真机遇到 CDN/WAF 的 403,可能造成
一次误登出;NCM 的风控通常以业务码 `-460` 表达(已改为 SVC)。如真机复现可再收窄为「仅 401」。
- **离线时接口缓存命中不算「回在线」**:用 `ncm_api_user_playlist_ex` 的 `from_cache` 出参判定本次
结果是否真走网络(评审 P2-2:不用全局计数差值);离线恢复探测(home 每 30s)强制
`ncm_api_cache_refresh_path` 绕过缓存,确保成功即真在线。歌单页的缓存命中成功同样不判回在线
(该路径只在失败时更新离线态,在线恢复由 home 探测统一裁决)。
- **畸形分块/帧体归 `NCM_ERR_JSON`(数据错误)**,`http_ret_norm` 不再把它改判为网络不可达
(评审 P2-1:否则会误报离线)。
- **离线播放未缓存曲目**仍走原 `song_url` 重试(15s 超时 ×3),未改播放重试策略(不属本任务);
已缓存曲目离线可播(`song_local_file` 先查缓存)。
- **`app/app.c` 未能宿主编译**:其 SIGSEGV 定位处理器用 MIPS `ucontext.gregs[]`,aarch64 宿主
无法编译(既有问题,`build_ncm_host.sh` 本身也排除 app.c)。本批对该文件的改动仅十余行
(`ncm_gate_t` + 日志 + 三元切页),交叉构建时需复核 0 warning。
- **会话密钥迁移**:旧 `session.enc`(MAC 派生)在当前 MAC 未变时可解并自动重写为新密钥;
若升级时 MAC 已变化(例如换网卡后首次启动),旧会话仍会解不开 —— 这是旧方案的固有缺陷,
无法事后补救;迁移后再不会因网络状态变化丢会话。
- **未部署/未 push**:设备离线,真机联调后置;本节所有验证均在宿主完成。
## 42. M10:断网重试风暴 / META 洪泛(真机卡死两分钟 + 歌单滑动卡)(2026-11-05)
> 用户原话:「我打开网易云 现在设备卡死了 你帮我抓日志调试一下」→「刚才卡了两分钟 现在恢复了 但是
> 网易云的歌单列表滑动特别卡」。
>
> 现场:ncm CPU 48%、load 3.65,整机交互卡死两分钟(塔台 kill pid 7534 恢复)。
> 红线:不依赖真机即可完成(宿主覆盖计时逻辑);0warning;不 push。
### 42.1 真机证据(只读抓取,未动设备)
**App 日志** `/usr/data/mnt/sd_0/MeowOS/logs/dev.player.ncm/19000100_000000_7534.log`(44 行,
跨度 74s)。稳定循环三个一组:
```
... home: playlists cache hit count=120 ts=1793794687
... home: playlists rc=-4 count=120 uid=408996274
... session: offline
... home: offline retry ← 每轮一次
... home: playlists cache hit count=120 ts=1793794687
... home: playlists rc=-6 count=120 uid=408996274
... home: offline retry ← 又一轮
```
74s 内 **10 次** `home: offline retry`(≈7s 一次;旧代码本意是 30s 一次)。
**launcher 日志** `/usr/data/mnt/sd_0/MeowOS/logs/meow_20261105_122541.log`:单次启动
**21016 条** `ipc: META app=dev.player.ncm state=0 pos=0/0 rc=0 -> ACK`,尾部连续帧时间戳
`1203566 / 1203667 / 1203767 / 1203868 …`——**每 ~100ms 一帧**,且 `state=0 pos=0/0` 完全静止
(没有任何元数据变化)。旧代码本意是 1s 一帧。
**关键新证据:`meow_ui_time_ms()` 在真机上是坏的。** 把上面两个日志里每行的首列(该函数返回值)
按 64 位拆开看:
| 日志行 | 返回的 64 位值 | 低 32 位 | 高 32 位 |
|---|---|---|---|
| `home: offline retry` | 6607363222717682072 | 1135000 | 1538396632 |
| `cache hit` | 8754846870717682072 | 1135000 | 2038396632 |
| `rc=-6` | 18138485921460348192 | 1140000 | 4223195352 |
| `offline retry` | 395341259877737760 | 1140000 | 92047560 |
- 低 32 位 = **开机秒数 × 1000**(1130000 → 1204000 共 74s,与 launcher 的 `up=1129` 吻合),
**整秒粒度、毫秒位恒为 0**:`now_ms()` 里的 `tv_nsec/1e6` 项要么被读成 0 要么被读错;
- 高 32 位**每次调用都变、无任何单调性**(同一秒内 1538396632 → 2038396632 → …)。
对照:同一进程里 core 的 `ncm_now_ms()` 落盘时间戳正确(`ts=1793794687` ≈ 2026-11-05),
HTTP 超时按秒级生效(重试周期 5-7s 正是连接超时量级)——**app/core 侧(`clock_gettime` 走 app
的 TU)的时钟是好的,只有 SDK 侧 `meow_ui_time_ms()` 返回坏值**。反汇编
`patch_v2/sdk/meow_ui.a(meow_ui_gfx.o)` 的 `meow_ui_time_ms`(`meow_ui_gfx.c:175-182 now_ms`)
可见它按 **64 位 `tv_sec`** 的 `struct timespec` 布局取数(`lw 0x10/0x14`),与真机 libc 的
32 位 `time_t` 布局不一致:低字拿到正确的 `tv_sec`,高字拿到 `tv_nsec`(×1000 后表现为随机),
`tv_nsec` 项则从缓冲外读。这与观测到的「低 32 位=整秒×1000、高 32 位随机」完全吻合。
(`patch_v2/sdk/**` 不在本 mission 范围,已作为 finding 上报给塔台。)
### 42.2 根因:100ms/7s 循环与 META 洪泛是同一个根因
**① 离线重试风暴**(`app/ui/home.c` 改前 300-314 行,`app/app.c:172`):
```c
uint64_t now = meow_ui_time_ms(); // app.c:172 —— 高 32 位是垃圾
if (!g_home_thr_on && (!g_offline_retry_ms || now - g_offline_retry_ms >= 30000)) { ... }
```
`now` 与 `g_offline_retry_ms` 各自带随机高 32 位,`now - last` 的 64 位差值几乎总是巨大正数
(≈2^63),**守卫恒真** → 只要 `!g_home_thr_on`(worker 收工后的下一个 tick)就再发一次重试。
真机上「每轮间隔 5-7s」不是定时器的节奏,而是**一次失败请求本身的耗时**(连接超时)——
定时器实际上从未生效。第二条触发链是 `else { g_offline_retry_ms = 0; }`:离线→在线→离线
只要在一个 tick 内抖动,基准就被清零,立刻再试。
**② META 洪泛**(`app/play/ipc_media.c` 改前 200-204 行,`ipc_thread` 主循环):
```c
uint64_t now = meow_ui_time_ms();
if (now - last_meta >= 1000) { send_meta(fd, ...); last_meta = now; }
```
同一个坏时钟 → 条件恒真 → **每个 IPC 轮次(poll 200ms)都发一帧 META**;每次上报在 launcher 侧
要写 2 行日志(`util.c:1674` + `meow_media.c:98`)并做会话归并,SD 写把 launcher 拖到 ~100ms/帧。
而且 `state=0 pos=0/0` 说明这些帧**没有任何新信息**。
**③ 卡顿的直接来源(UI 线程)**:重试路径走的是 `home_reload()`(进页用的完整刷新):
`appstate_playlists_cache_load()` 在 **UI 线程同步读盘**(`playlists.bin`,120 条歌单),随后
`home_apply()` **把列表滚动位置复位为 0 并 `meow_ui_paint_full()` 整帧重绘**。风暴期间每次重试
都把用户正在滑的列表拽回顶部 + 全屏重绘 + UI 线程写 SD 日志 → 「歌单列表滑动特别卡」。
另外 `home_reload()` 每轮 `ncm_thread_create` 后从不 join/detach,线程栈也不回收。
结论:**一个坏时钟打穿了所有「`now - last >= N`」型守卫**(重试节奏 + META 节奏),UI 线程上的
同步读盘/滚动复位/整帧重绘又把风暴放大成整机卡死。
### 42.3 实现
| 文件 | 改动 |
|---|---|
| `app/logic/retry_ctl.h/.c`(新) | 离线重试节奏闸(纯逻辑,零依赖):①时钟由调用方给**可信单调钟**;②回退(`now < last`)不下溢成「巨大已超时」,只重定基准、不重试;③`busy` 期间单飞;④在线/离线抖动**不重置**基准(抖动不得加速)。首次观察到离线立即探测一次,之后严格 ≥ interval。 |
| `app/ui/home.c` | `home_tick`:节奏改用 `ncm_retry_ctl_poll(&g_retry, offline, g_home_thr_on, ncm_monotonic_ms(), 30000)`;入参 `now` 不再参与判定(真机坏时钟无效化)。新增 `home_reload_ex(silent)`:静默重试**不预读盘、不复位滚动、不重绘**;`home_apply_ex(keep_scroll)`:后台刷新保留滚动位置;worker 记 `g_home_changed`(离线态翻转/歌单数量变化/空态才需要重绘);worker 线程 `pthread_detach`(旧代码每轮泄漏栈)。新增 `home_retry_reset()`(页创建/登出/宿主测试)。 |
| `app/play/meta_gate.h/.c`(新) | META 上报闸(纯逻辑):状态/时长/标题/歌手/专辑/封面/歌词标志**变化立即报**;播放中位置偏移 ≥5s 重锚(launcher `meow_media_display_pos` 自己外推);心跳播放中 5s / 非播放 30s;**硬下限 250ms**(任何路径 ≤4 帧/秒);时间同样用单调钟,回退不下溢。字符串比较按发送侧截断长度进行(防超长标题恒判「变了」)。 |
| `app/play/ipc_media.c` | `send_meta()` 改为「组快照 → meta_gate 判定 → 才发」,删除 1s 定时器;主循环时间改 `ncm_monotonic_ms()`;首帧仍必发(注册媒体源);发送时打 DEBUG 日志(默认 INFO 级不落盘)。 |
| `app/ncm_app_internal.h` | 新增 `home_retry_reset()` 声明。 |
| `bench/retry_storm_sim.c`(新) | 节奏闸 + META 闸的纯逻辑回归(真实 `retry_ctl.c`/`meta_gate.c`):100ms tick 断网 120s、状态抖动、时钟回退、单飞;META 空闲/播放/抖动/元数据变化/seek/超长标题/回退。 |
| `bench/home_retry_sim.c`(新) | **真实 `app/ui/home.c`** 编进宿主(`-ffunction-sections --gc-sections` 只保留 `home_tick` 链),用桩替换网络/状态/UI,驱动 `home_tick` 断言重试次数/间隔/重绘次数;其中一个用例把**真机形态的垃圾 64 位 now** 喂进 `home_tick`。 |
| `bench/meta_ipc_sim.c`(新) | **真实 `app/play/ipc_media.c`** 起线程连到临时目录里的「假 launcher」AF_UNIX socket,统计 App 真发出的 `MSG_META` 帧数。 |
| `build_ncm.sh` / `build_ncm_host.sh` | 源列表加 `app/logic/retry_ctl.c`、`app/play/meta_gate.c`(host 构建同时补上漏掉的 `app/play/mem_watch.c`,否则 `player_manager` 链接失败)。 |
| `build_ncm_sims.sh` | 新增三个 sim 的构建/运行(产物落 `out/chk/`,不入库)。 |
### 42.4 验证(宿主,0 真机、0 真实 NCM 请求)
| 项 | 结果 |
|---|---|
| `bench/retry_storm_sim`(新) | **25 PASS / 0 FAIL**:A 断网 120s→4 次(0/30/60/90s,间隔全 ≥30000ms);B 每 tick 抖动→2 次(旧实现随抖动加速);C 时钟回退 30s→回退瞬间不重试、真实间隔仍 ≥30s、闸不失锁;D worker 跑 45s>间隔→不并发;E1 空闲 META 60s→3 帧(旧 600);E2 播放中→13 帧(≤1/5s);E3 状态每 tick 抖动→≤4 帧/秒且最小间隔 ≥250ms;E4 元数据/seek 立即报、超长标题截断后不重报;E5 时钟回退不连发。 |
| `bench/home_retry_sim`(新,真实 home.c) | **13 PASS / 0 FAIL**:断网 120s→4 次重试、每次只起 1 个 worker、**全帧重绘 0 次**(旧实现每轮 1 次);**入参 now 用真机垃圾 64 位值→仍是 4 次/≥30s**;抖动→2 次;回在线再断网→间隔仍 30s(在线不得清零基准)。 |
| `bench/meta_ipc_sim`(新,真实 ipc_media.c + 假 launcher) | **6 PASS / 0 FAIL**:空闲 2s 只收到 **1 帧** META(旧实现 ~10 帧);播放态翻转→恰好多 1 帧,停止→再多 1 帧,其余时间 0 帧;帧头 magic/version/长度合法。 |
| `bench/session_gate_sim`(M9) | **88 PASS / 0 FAIL**(`TMPDIR` 可写;http 桩 34 次) |
| `bench/http_truncate_sim`(M9) | **13 PASS / 0 FAIL** |
| `test/test_ncm_core_host`(全量核心库) | **139 PASS / 0 FAIL** |
| 宿主告警 | 改动/新增的 6 个 TU(home.c、ipc_media.c、retry_ctl.c、meta_gate.c + 2 个 sim)`-Wall -Wextra` **0 warning**;全量宿主 app 链接(`ncm_host`,`NCM_PAGE=home` 出图)通过 |
| mipsel / 真机 | **未跑**(本机无 zig 交叉链;交叉编译 + 热更由塔台做) |
### 42.5 诚实出入 / 遗留
- **根因不止在 app**:`meow_ui_time_ms()` 在真机返回坏值是 SDK/构建侧问题(`patch_v2/sdk/meow_ui_gfx.c`
的 `now_ms` 与真机 libc 的 `struct timespec` 宽度不一致)。本 mission 范围内只能**在 app 侧绕开**:
重试与 META 的节奏全部改用 core 的 `ncm_monotonic_ms()`(同进程实测正确)。**SDK 侧仍需修**,
否则所有依赖「经过时间」的 SDK/App 逻辑(动画、IME 冷却、snack 计时、其他 App 的定时器)在真机
上都会表现异常——已作为 finding 上报塔台。
- **低 32 位只有整秒精度**:即使将来某些代码继续用 `meow_ui_time_ms()`,也拿不到毫秒;本批新代码
已全部改用 `ncm_monotonic_ms()`。
- **META 硬下限 250ms**:极端情况下(播放状态每 100ms 抖动一次,例如缓冲/播放反复切换)仍可能
4 帧/秒;这是刻意的安全上限,正常状态稳定时不会触发。
- **首次进离线立即探测一次**:进离线先探一次(用户体验:恢复即回在线),之后严格 30s;若首次
进离线的同时页刚加载过,会多一次请求。**不合并**是因为「网络刚恢复」是最常见的时刻。
- **静默重试不重绘**:歌单数量/离线态都没变时界面完全不重绘(滑动不受打扰);若服务端返回了
不同的歌单数量,会在下一次 tick 保留滚动位置地重绘一次。
- **真机验证建议**(塔台做热更时):断网启动 → 看 App 日志 `home: offline retry` 应约每 30s 一条
(旧:5-7s);launcher 日志 `ipc: META app=dev.player.ncm` 空闲时应约每 30s 一条(旧:~10/s);
播放中约每 5s 一条、暂停/切歌各多一条;同时观察 ncm CPU 与首屏/列表滑动是否恢复。
- **未部署/未 push**:设备只读访问,未改设备;本节所有验证均在宿主完成。
## 43. M12:列表首行主标题不显示(两行列表行高 < 文本块,SDK 视口顶裁剪把首行标题整条跳过)(2026-11-05)
### 43.1 现象(用户原话)
> 「网易云有一个问题 歌单列表的第1个歌单名字不显示 副标题(歌曲数目)有显示 。点开歌单 加载歌曲列表
> 第1个歌曲名字不显示 副标题(歌手名)有显示。」
特征:两类列表(首页歌单列表 / 歌单详情曲目列表)都只有**首行主标题完全空白**、副标题正常,
其余行标题/副标题都正常。设备二进制 431ca4dd(含 M5/M6/M10/M11)。
### 43.2 根因(代码路径 + 触发机制,已坐实)
SDK `meow_listview` 的双行渲染(`patch_v2/sdk/meow_ui_widget.c:2061`,src2 分支):
```c
ty = ry + (item - (2 * thh + MEOW_LISTV_SUB_GAP)) / 2;
if (it.title && ty >= vy0 && ty + thh <= vy1)
listv_text_trunc(rtx, ty, it.title, ...); /* 标题:视口顶裁剪守卫 */
int sy = ty + thh + MEOW_LISTV_SUB_GAP;
if (sy >= vy0 && sy + thh <= vy1)
listv_text_trunc(rtx, sy, it.subtitle, ...); /* 副标题 */
```
- `thh = meow_ui_text_height(theme->font_body)`。真机 ncm 进程(pid 6693)环境:
`MEOW_MULTI_FONT=1`、主库 `MEOW_FONT_APP=source_han_serif`;从设备
`/usr/data/mnt/sd_0/MeowOS/fonts/source_han_serif/regular/font.idx` 的 MFNI 面头直接读出
20px 面 `ascent=24 / descent=6` → `meow_font_line_height(20) = 30`
(多字库取最大:google_sans 类库 20px 面 = 26,不改变结果)→ **文本块 = 2*30 + 2 = 62**。
- ncm 行高 `APP_ROW_H = 56`(与 SDK `item_h` 默认 56 相同)→ `ty = ry + (56-62)/2 = ry - 3`。
- 首行(scroll=0)`ry == vy0`(行顶 == 控件视口顶)→ `ty = vy0 - 3 < vy0` → **标题被整条跳过**
(不是画了被裁,是守卫直接不画);`sy = vy0 + 29` 在视口内 → 副标题照画。
- 其余行 `ty = ry - 3 > vy0` → 标题仍画(比本行顶高 3px,文字整体仍在视口内,肉眼难察觉)。
- 两类列表同用 `meow_listview_create(..., src2, ...)` + 双行 + `APP_ROW_H`,故症状完全一致。
排除的其它方向:
| 怀疑方向 | 结论 |
|---|---|
| ① title 指针悬垂/空串(绑定生命周期) | **排除**:`home_src2`/`pl_src2` 每帧刷新静态缓冲,副标题取自同一缓冲且可见 |
| ② 首行被顶部栏/裁剪区遮住(布局偏移) | **排除**:组头红条在 `r_list` 上方 8px;行卡片 z=5、文本 z=10、叠层 z=60 均不覆盖首行标题位 |
| ③ 首行字体色 = 背景色(选中/主题色) | **排除**:同一次绘制里 title=`onSurface`、subtitle=`onSurfaceVariant`;且用户确认「完全空白」非同色 |
| ④ 首项特殊路径(我喜欢的音乐/当前播放) | **排除**:歌单详情首行是任意曲目,无特殊渲染分支 |
### 43.3 修复(app 侧,不动 SDK / 不动 meow_ui.a)
行高改为**按当前主题字体实测行高派生**,保证文本块装得下(卡高 == 文本块):
| 文件 | 改动 |
|---|---|
| `app/ncm_app_internal.h` | 新增 `NCM_ROW_SUB_GAP`(与 SDK 同口径 2)、`ncm_row_h_for_line_h(line_h)`(= `max(APP_ROW_H, 2*行高 + 2 + NCM_ROW_GAP)`,偶数)、`ncm_row_h()`(取 `meow_ui_text_height(meow_theme_get()->font_body)`)。`APP_ROW_H` 降级为**行高下限**(注释同步)。 |
| `app/ui/home.c` / `playlist.c` / `queue.c` / `cache.c` / `search.c` | 各页 `g_item_h = ncm_row_h()` 在 page create 取一次(字体档位切换走重建页面,避免同一页内行高漂移),`meow_listview_set_item_h` 与全部卡片几何/可见行推算/页脚「x-y / n 首」/补载视口换算统一用 `g_item_h`。 |
| `app/test/test_meow_row_text_fit.c`(新) | 纯头内联几何回归(不链 SDK):真机已装字库 20px 面实测行高矩阵 14/21/26/27/30 → 断言文本块 ≤ 行高、首行 ty ≥ 0、副标题不越行、卡高 ≥ 文本块、行高偶数且 ≥ 下限;并用旧 56 + 行高 30 断言 `ty<0`(bug 形态哨兵,防再写死)。 |
| `app/test/test_first_row_title_render.c`(新) | **渲染级回归**:宿主 5x7 回退行高是 14,照原样渲染复现不了。用 `-Wl,--wrap=meow_ui_text_height` 把行高替换成真机实测 30,驱动**真实 SDK listview 双行渲染**,读回 `MEOW_UI_DUMP` 的 PPM 数像素:旧行高 56 → 首行标题带 0 文字像素、副标题带 186;新行高 70 → 标题带 84、副标题带 100。 |
| `bench/plpage_sim.c` | 滚动换行高改用 `ncm_row_h()`;临时目录/出图改走 `$TMPDIR`(Termux 下 `/tmp` 不可写,原写死 `/tmp` 无法运行)。 |
| `build_ncm_host.sh` | **补 `meow_ui_time`**:M11 新增 `sdk/meow_ui_time.c` 后本脚本未同步,main 上宿主链接报 `undefined symbol: meow_ui_time_ms`(与 M12 无关的既有断裂,不补则本 mission 无法出图验证)。 |
行高取值(本机默认字体 source_han_serif,行高 30):`2*30 + 2 + 8 = 70` → 卡高 62 == 文本块 62,
`ty = +4`(首行标题正常落卡内)。其它字库:行高 27 → 64;26 → 62;21 → 52 但受下限钳到 56(与旧视觉一致)。
### 43.4 验证(宿主,0 真机写、0 真实 NCM 请求)
| 项 | 结果 |
|---|---|
| `app/test/test_meow_row_text_fit`(新) | **PASS:48 checks / 0 fail**(行高矩阵 5 档;含「旧 56 + 行高 30 → ty=-3」哨兵) |
| `app/test/test_first_row_title_render`(新,真实 SDK 渲染 + PPM 像素计数) | **PASS**:legacy `item=56 ty=-3 → title_ink=0 sub_ink=186`(复现用户症状);fixed `item=70 ty=4 → title_ink=84 sub_ink=100`(首行标题正常) |
| `app/test/test_meow_row_gap`(既有卡片几何) | **PASS**(row_gap=8 card_h=48 top2top=56 symmetric=1) |
| `bench/plpage_sim`(真实 playlist.c 全链路:懒加载分页/触底/回滚补物化) | **RESULT: PASS**(全用例 PASS) |
| `ncm_host` 出图(home/playlist/cache/search/searchpl/plsplay/queuetest) | 全部 rc=0;与**改动前基线逐字节相同**(宿主行高 14 → `ncm_row_h()` 仍为 56,几何零变化 → 证明重构本身无视觉回归) |
| `ncm_host` 页面自检(queuetest/modetest/barhit/searchtap/cachetest) | 全 PASS(covswitch 因测试自身写死 `/tmp/*.bmp`、Termux 下不可写而 `init FAIL`——与 M12 无关的既有环境限制) |
| 宿主告警 | 改动/新增 TU(5 个 ui + ncm_app_internal.h + 2 个 test + 1 个 sim)`-Wall -Wextra` **0 warning**;`build_ncm_host.sh` 全量链接通过 |
| 真机只读取证 | 读 `/proc/6693/environ`(字体档/多字体开关)+ 设备 `font.idx` 面头(ascent/descent);**未改设备** |
| mipsel / 真机 | **未跑**(本机无 zig 交叉链;交叉编译 + 热更由塔台做) |
### 43.5 诚实出入 / 遗留
- **修复形态是「行高变高」**:本机默认字库下行高 56 → 70(每屏可见行数略减)。这是必然代价——旧行高
本来就装不下 62px 的两行文本(文字一直溢出卡外 3~7px),只是首行标题被守卫整条吞掉才暴露。
若用户切到行高 21 的字库(source_han_sans 等),行高回到 56,视觉与旧版一致。
- **滚动时顶部半露行仍会隐藏标题**:SDK 的语义是「标题不完全在视口内就不画」(`ty >= vy0`),
这是滚动裁剪的正常表现,不属于本 bug;scroll=0 的首行标题现已正常。
- **根因在 SDK 通用渲染**:2 行档默认 `item_h=56` 假定字体行高 ≤27;任何 App 的两行列表在 20px
字库(行高 30)下都会丢首行标题。本 mission scope 只到 `patch_v2/ncm_app/**`,故 app 侧派生行高
规避;**SDK 侧仍应修**(2 行分支把 `ty` 钳到 `vy0` 或按文本块反推行高),已作为 finding 上报塔台。
- **`build_ncm_host.sh` 的 M11 漏编**:本脚本补了 `meow_ui_time`(否则宿主链接失败)。同类还写死
`/tmp` 的两处(`bench/plpage_sim.c` 已改 `$TMPDIR`;`app/test/test_ncm_app.c` covswitch 仍写死,
未改——与本 bug 无关)。
- **未部署/未 push**:设备只读访问,未改设备;所有验证在宿主完成。
### 43.6 真机验证要点(塔台热更后按此验收)
1. 首页「我的音乐」歌单列表:**第 1 个歌单名字可见**,副标题「N 首」仍在;第 2 行起视觉正常。
2. 点开任一歌单(含「我喜欢的音乐」):**第 1 首歌名可见**,副标题「歌手 / 专辑」仍在。
3. 同类页面顺带确认:播放队列(ACT_QUEUE)、已缓存列表(ACT_CACHE)、搜索结果单曲页签,首行标题都在。
4. 预期可见变化:每行变高(本机字库下 56 → 70px)、每屏可见行数略减;卡片内两行文字完整、不再溢出卡片。
5. 滚动列表:顶部半露行标题不可见属正常裁剪;回到顶部(scroll=0)后首行标题必须重新出现。
6. 若系统字体切到 source_han_sans 等小行高字库:行高回落 56,视觉与旧版一致且首行标题仍正常。
+18 -3
View File
@@ -24,7 +24,7 @@ volatile int g_net_ready = 0;
int g_host_mock = 0;
static meow_view *g_login_view, *g_home_view, *g_pl_view, *g_plr_view, *g_set_view,
*g_search_view, *g_queue_view;
*g_search_view, *g_queue_view, *g_cache_view;
static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); }
@@ -93,6 +93,7 @@ static void app_on_tap(int x, int y)
if (settings_is_active()) { settings_on_tap(x, y); return; }
if (playlist_is_active()) { playlist_on_tap(x, y); return; }
if (queue_is_active()) { queue_on_tap(x, y); return; }
if (cache_is_active()) { cache_on_tap(x, y); return; }
if (search_is_active()) { search_on_tap(x, y); return; }
home_on_tap(x, y);
}
@@ -107,6 +108,7 @@ static void app_on_swipe(int dir, int dx, int dy)
if (settings_is_active()) { meow_view_page_pop(); return; }
if (playlist_is_active()) { meow_view_page_pop(); return; }
if (queue_is_active()) { meow_view_page_pop(); return; }
if (cache_is_active()) { meow_view_page_pop(); return; }
if (search_is_active()) { meow_view_page_pop(); return; }
if (home_is_active()) {
/* 任务2:home 根页左滑不再退出进程 → 请求 launcher 退到桌面/后台(进程+媒体会话保留)。
@@ -124,6 +126,7 @@ static void app_on_swipe(int dir, int dx, int dy)
if (home_is_active()) { home_swipe(dir, dy); return; }
if (playlist_is_active()) { playlist_swipe(dir, dy); return; }
if (queue_is_active()) { queue_swipe(dir, dy); return; }
if (cache_is_active()) { cache_swipe(dir, dy); return; }
if (search_is_active()) { search_swipe(dir, dy); return; }
if (player_is_active()) { player_on_swipe(dir, dy); return; }
}
@@ -134,6 +137,7 @@ static void app_on_touch_down(int x, int y)
else if (home_is_active()) home_touch_down(x, y);
else if (playlist_is_active()) playlist_touch_down(x, y);
else if (queue_is_active()) queue_touch_down(x, y);
else if (cache_is_active()) cache_touch_down(x, y);
else if (settings_is_active()) settings_touch_down(x, y);
else if (search_is_active()) search_touch_down(x, y);
}
@@ -144,6 +148,7 @@ static void app_on_touch_move(int x, int y)
else if (home_is_active()) home_touch_move(x, y);
else if (playlist_is_active()) playlist_touch_move(x, y);
else if (queue_is_active()) queue_touch_move(x, y);
else if (cache_is_active()) cache_touch_move(x, y);
else if (settings_is_active()) settings_touch_move(x, y);
else if (search_is_active()) search_touch_move(x, y);
}
@@ -194,6 +199,7 @@ static void app_on_tick(void)
if (player_is_active()) { player_tick(now); return; }
if (search_is_active()) { search_tick(now); return; }
if (queue_is_active()) { queue_tick(now); return; }
if (cache_is_active()) { cache_tick(now); return; }
if (home_is_active()) { home_tick(now); }
if (playlist_is_active()) { playlist_tick(now); }
chrome_tick();
@@ -233,10 +239,18 @@ int main(void)
g_set_view = settings_view_create();
g_search_view = search_view_create();
g_queue_view = queue_view_create(); /* §NEW 当前播放队列页 */
g_cache_view = cache_view_create(); /* M5 已缓存列表页 */
mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */
/* 进入根页:已登录→home,否则→login */
meow_view_page_push(g_st.logged_in ? ACT_HOME : ACT_LOGIN);
/* 进入根页(M9 启动决策):本地有会话就先信它——无网也进 home(缓存可看可播),
* 只有「无本地会话」才进扫码页。网络类失败在首个请求后由 appstate_session_gate
* 切离线模式,不再把「连不上」当登录过期。 */
{
ncm_gate_t boot = ncm_session_boot_decide(g_st.logged_in, NCM_OK);
meow_log(MEOW_LOG_INFO, "boot: session gate=%s (cookie=%d uid=%s)",
ncm_gate_name(boot), g_st.logged_in, g_st.uid[0] ? g_st.uid : "-");
meow_view_page_push(boot == NCM_GATE_RELOGIN ? ACT_LOGIN : ACT_HOME);
}
meow_ui_events ev = {
app_on_key, app_on_tap, app_on_swipe, app_on_tick, app_on_swipe_progress,
@@ -256,6 +270,7 @@ int main(void)
meow_view_free(g_login_view);
meow_view_free(g_search_view);
meow_view_free(g_queue_view);
meow_view_free(g_cache_view);
meow_ui_shutdown();
meow_log(MEOW_LOG_INFO, "exit");
return 0;
+150 -7
View File
@@ -98,6 +98,7 @@ int appstate_init(void)
appstate_load_settings();
g_st.logged_in = (g_st.ctx.cookie[0] != 0);
g_st.offline = 0; /* M9:启动先按在线渲染;首个网络动作失败再进离线(本地会话不被否决) */
g_st.active_playlist = -1;
g_st.playing_song = -1;
g_st.qr_size = 0;
@@ -169,6 +170,33 @@ void appstate_set_logged_in(const char *uid, const char *nickname)
g_st.logged_in = 1;
}
/* ---- 会话保持 / 离线模式(M9)----
* 判定本身在 core(ncm_gate_decide,宿主可单测);这里只把结果落到 App 状态并打日志。
* 关键不变量:**只有 NCM_ERR_AUTH 会走退出登录**;传输层/服务端暂不可用只切离线,
* 绝不清 cookie(清 cookie 才是「退到扫码登录」的实质)。 */
void appstate_offline_set(int on)
{
int v = on ? 1 : 0;
if (g_st.offline == v) return;
g_st.offline = v;
meow_log(v ? MEOW_LOG_WARN : MEOW_LOG_INFO, "session: %s", v ? "offline" : "online");
}
int appstate_is_offline(void) { return g_st.offline; }
int appstate_session_gate(int rc)
{
ncm_gate_t g = ncm_gate_decide(g_st.logged_in, rc);
switch (g) {
case NCM_GATE_ONLINE: appstate_offline_set(0); break;
case NCM_GATE_OFFLINE: appstate_offline_set(1); break;
/* RELOGIN:登录态处置由调用方(app.c tick / settings 登出)执行;
* HOLD:解析/参数类错误,保持现状。 */
default: break;
}
return (int)g;
}
void appstate_logout(void)
{
ncm_ctx_clear_cookie(&g_st.ctx);
@@ -176,6 +204,7 @@ void appstate_logout(void)
* 被下一账号复用;盘缓存目录一并清)。 */
ncm_apicache_clear();
g_st.logged_in = 0;
g_st.offline = 0;
g_st.uid[0] = 0;
g_st.nickname[0] = 0;
g_st.vip = NCM_VIP_NONE; /* §20:登出清会员态(下一账号不继承) */
@@ -199,7 +228,9 @@ void appstate_logout(void)
cover_cache_reset();
}
/* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。 */
/* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。
* M9:只有服务端**明确**否定会话(NCM_ERR_AUTH:HTTP 401/403、业务码 301/302)才清 cookie;
* 网络不可达/超时/服务端 5xx 一律保留 cookie 与登录态,只切离线模式。 */
int appstate_ensure_uid(void)
{
if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_ERR_AUTH;
@@ -209,12 +240,16 @@ int appstate_ensure_uid(void)
nick, sizeof nick);
if (rc == NCM_OK) {
appstate_set_logged_in(uid, nick);
appstate_offline_set(0);
return NCM_OK;
}
/* 会话失效:清 cookie → 未登录 */
if (rc == NCM_ERR_AUTH) {
/* 会话确失效:清 cookie → 未登录(原逻辑) */
ncm_ctx_clear_cookie(&g_st.ctx);
g_st.logged_in = 0;
g_st.offline = 0;
} else if (ncm_err_is_network(rc)) {
appstate_offline_set(1); /* 只是连不上:保留账号态,离线可看缓存 */
}
return rc;
}
@@ -224,8 +259,11 @@ int appstate_ensure_uid(void)
* 之间的发布点。持久化(playlists.bin)同单写者(home worker)在发布后读,无相互竞态。 */
static pthread_mutex_t g_pls_mu = PTHREAD_MUTEX_INITIALIZER;
int appstate_load_playlists(void)
/* from_cache(可 NULL):透传 ncm_api_user_playlist_ex 的「本次是否接口缓存命中」。
* 仅真正走网络成功时才应把 App 判为在线(M9 评审 P2-2:缓存命中不代表网络恢复)。 */
int appstate_load_playlists_ex(int *from_cache)
{
if (from_cache) *from_cache = 0;
if (!g_st.logged_in) return NCM_ERR_AUTH;
int rc = appstate_ensure_uid();
if (rc != NCM_OK) return rc;
@@ -238,8 +276,9 @@ int appstate_load_playlists(void)
* 单飞),免 96KB 栈。 */
static ncm_playlist_t s_pls[NCM_PLAYLIST_MAX];
int n = 0;
int rc2 = ncm_api_user_playlist(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid,
NCM_PLAYLIST_MAX, 0, s_pls, NCM_PLAYLIST_MAX, &n);
int rc2 = ncm_api_user_playlist_ex(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid,
NCM_PLAYLIST_MAX, 0, s_pls, NCM_PLAYLIST_MAX, &n,
from_cache);
if (rc2 == NCM_OK) {
int cnt = (n > NCM_PLAYLIST_MAX) ? NCM_PLAYLIST_MAX : n;
pthread_mutex_lock(&g_pls_mu);
@@ -248,11 +287,14 @@ int appstate_load_playlists(void)
pthread_mutex_unlock(&g_pls_mu);
appstate_playlists_cache_save(); /* §26:网络成功 → 覆写盘缓存(stale-while-revalidate) */
}
meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s", rc2,
(rc2 == NCM_OK) ? n : g_st.playlist_count, g_st.uid);
meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s%s", rc2,
(rc2 == NCM_OK) ? n : g_st.playlist_count, g_st.uid,
(from_cache && *from_cache) ? " (cache)" : "");
return rc2;
}
int appstate_load_playlists(void) { return appstate_load_playlists_ex(NULL); }
/* ---- 歌单列表磁盘缓存(§26,NCM_CACHE_DESIGN §2.2)----
* 快照 <data_dir>/playlists.bin:magic(4) + ver(2) + 时间戳(8) + count(4) + 数组(ncm_playlist_t)。
* 进首页先渲染缓存秒开(断网可离线浏览),后台 worker 拉网络成功后覆写;结构体改版靠 ver 判 miss。
@@ -783,6 +825,7 @@ void appstate_apply_play_mode(int m)
int appstate_queue_count(void)
{
int c;
if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_count();
pthread_mutex_lock(&g_search_mu);
c = (g_st.queue_src == NCM_QUEUE_SEARCH) ? g_st.search_song_count : g_st.track_ids_count;
pthread_mutex_unlock(&g_search_mu);
@@ -792,6 +835,7 @@ int appstate_queue_count(void)
int appstate_queue_song(int idx, ncm_song_t *out)
{
if (!out) return NCM_ERR_ARG;
if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_song(idx, out);
if (g_st.queue_src == NCM_QUEUE_SEARCH) {
pthread_mutex_lock(&g_search_mu);
int ok = (idx >= 0 && idx < g_st.search_song_count);
@@ -803,6 +847,105 @@ int appstate_queue_song(int idx, ncm_song_t *out)
return appstate_song_fetch(idx, out);
}
/* ============================================================
* 已缓存列表(ACT_CACHE / NCM_QUEUE_CACHE;数据源 = songs/ 池 + 文件内 tag)
* 数组读写经 g_cache_mu;扫描走 worker(磁盘 IO 不进 UI 线程)。
* ============================================================ */
static pthread_mutex_t g_cache_mu = PTHREAD_MUTEX_INITIALIZER;
int appstate_cache_list_load(void)
{
/* 堆上临时数组(≈116KB):扫描期间不持锁,UI/播放线程读旧快照不被 IO 阻塞。 */
ncm_cache_item_t *tmp = (ncm_cache_item_t *)malloc(sizeof(ncm_cache_item_t) * NCM_CACHE_LIST_MAX);
if (!tmp) return NCM_ERR_OOM;
int n = ncm_cache_scan(&g_st.ctx.cache, tmp, NCM_CACHE_LIST_MAX);
if (n < 0) { free(tmp); return n; }
/* 旧无 tag 缓存:回退旁路元数据(songmeta)补名称/歌手,保持列表可读。 */
for (int i = 0; i < n; i++) {
ncm_song_t s;
if (tmp[i].name[0] && tmp[i].artist[0]) continue;
if (songmeta_load(g_st.cache_dir, tmp[i].id, &s) != NCM_OK) continue;
if (!tmp[i].name[0]) ncm_strlcpy(tmp[i].name, s.name, sizeof tmp[i].name);
if (!tmp[i].artist[0]) ncm_strlcpy(tmp[i].artist, s.art_name, sizeof tmp[i].artist);
}
pthread_mutex_lock(&g_cache_mu);
memcpy(g_st.cache_items, tmp, sizeof(ncm_cache_item_t) * (size_t)n);
g_st.cache_item_count = n;
pthread_mutex_unlock(&g_cache_mu);
free(tmp);
return n;
}
int appstate_cache_count(void)
{
int n;
pthread_mutex_lock(&g_cache_mu);
n = g_st.cache_item_count;
pthread_mutex_unlock(&g_cache_mu);
return n < 0 ? 0 : n;
}
int appstate_cache_item(int i, ncm_cache_item_t *out)
{
if (!out) return NCM_ERR_ARG;
pthread_mutex_lock(&g_cache_mu);
int ok = (i >= 0 && i < g_st.cache_item_count);
if (ok) *out = g_st.cache_items[i];
pthread_mutex_unlock(&g_cache_mu);
return ok ? NCM_OK : NCM_ERR_ARG;
}
int appstate_cache_song(int i, ncm_song_t *out)
{
ncm_cache_item_t it;
ncm_song_t m;
if (!out) return NCM_ERR_ARG;
if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG;
memset(out, 0, sizeof *out);
ncm_strlcpy(out->id, it.id, sizeof out->id);
ncm_strlcpy(out->name, it.name[0] ? it.name : it.id, sizeof out->name);
ncm_strlcpy(out->art_name, it.artist, sizeof out->art_name);
/* 旁路元数据(≤600B 单次读)补时长/封面/fee;播放页与上/下一首逻辑照旧可用。 */
if (songmeta_load(g_st.cache_dir, it.id, &m) == NCM_OK) {
if (!it.name[0] && m.name[0]) ncm_strlcpy(out->name, m.name, sizeof out->name);
if (!it.artist[0] && m.art_name[0]) ncm_strlcpy(out->art_name, m.art_name, sizeof out->art_name);
out->duration_ms = m.duration_ms;
out->fee = m.fee;
ncm_strlcpy(out->al_pic, m.al_pic, sizeof out->al_pic);
}
return NCM_OK;
}
int appstate_cache_remove(int i)
{
ncm_cache_item_t it;
int rc;
if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG;
/* 条目可能已被 LRU/清空删掉:NOENT 也算「已不在」,照样摘除列表项。 */
rc = ncm_cache_remove(&g_st.ctx.cache, it.id, it.level, it.ext);
if (rc != NCM_OK && rc != NCM_ERR_NOENT) return rc;
pthread_mutex_lock(&g_cache_mu);
for (int k = 0; k < g_st.cache_item_count; k++) {
if (strcmp(g_st.cache_items[k].id, it.id) != 0 ||
strcmp(g_st.cache_items[k].level, it.level) != 0 ||
strcmp(g_st.cache_items[k].ext, it.ext) != 0) continue;
memmove(&g_st.cache_items[k], &g_st.cache_items[k + 1],
sizeof(ncm_cache_item_t) * (size_t)(g_st.cache_item_count - k - 1));
g_st.cache_item_count--;
break;
}
pthread_mutex_unlock(&g_cache_mu);
return NCM_OK;
}
void appstate_cache_clear(void)
{
ncm_cache_clear(&g_st.ctx.cache); /* songs/ + covers/ 的 song_*(设置页同语义) */
pthread_mutex_lock(&g_cache_mu);
g_st.cache_item_count = 0;
pthread_mutex_unlock(&g_cache_mu);
}
int appstate_search_count(int tab)
{
int c;
+30
View File
@@ -0,0 +1,30 @@
/* logic/retry_ctl.c — 见 retry_ctl.h 头注释(M10 离线重试节奏闸)。 */
#include "retry_ctl.h"
void ncm_retry_ctl_init(ncm_retry_ctl *c)
{
if (!c) return;
c->last_ms = 0;
c->have_last = 0;
}
int ncm_retry_ctl_poll(ncm_retry_ctl *c, int offline, int busy, int64_t now_ms,
int64_t interval_ms)
{
if (!c || interval_ms <= 0) return 0;
if (!offline) return 0; /* 在线:不动基准 —— 抖动不得加速重试 */
if (busy) return 0; /* 单飞:上一个 worker 还在跑 */
if (!c->have_last) { /* 首次进离线:立即探测一次 */
c->have_last = 1;
c->last_ms = now_ms;
return 1;
}
if (now_ms < c->last_ms) { /* 时钟回退/异常:绝不让差值下溢成「巨大已超时」 */
c->last_ms = now_ms; /* 重定基准 → 仍需重新等满 interval */
return 0;
}
if (now_ms - c->last_ms < interval_ms) return 0;
c->last_ms = now_ms;
return 1;
}
+42
View File
@@ -0,0 +1,42 @@
/* logic/retry_ctl.h — 离线自动重试节奏闸(M10:断网重试风暴的修复点)。
*
* 背景(真机 19000100_000000_7534.log + launcher meow_20261105_122541.log):
* M9 的 30s 守卫写成 `if (!thr_on && (!last || now - last >= 30000))`,节奏完全依赖
* meow_ui_time_ms() 的 64 位差值。真机实测该值「低 32 位 = 整秒×1000(无毫秒)、高 32 位是垃圾」
* (见 REPORT §42),于是 now-last 几乎恒为巨大正数 → 每个 UI tick 都判「该重试」,
* 断网时把「30s 一次」退化成「每个 tick 一次」的重试风暴。
*
* 本模块把节奏判定收敛成三条硬约束(纯逻辑、零依赖,宿主编译可直接驱动):
* 1) 时钟由调用方供给**可信单调钟**(home.c 传 core 的 ncm_monotonic_ms(),不再用 SDK 的
* meow_ui_time_ms());本模块只做差值,不取时间。
* 2) 时钟回退(now < last)绝不下溢成「巨大已超时」:按「刚试过」处理并重定基准,
* 下一次尝试仍需重新等满 interval。
* 3) 单飞:busy(上一个 worker 未收工)期间任何时钟跳变都不发新尝试。
* 在线/离线抖动不重置基准 —— 抖动无法加速重试(旧实现 else 分支清零 g_offline_retry_ms,
* 状态一抖就立刻再试,是风暴的第二条触发链)。
*/
#ifndef NCM_RETRY_CTL_H
#define NCM_RETRY_CTL_H
#include <stdint.h>
typedef struct {
int64_t last_ms; /* 上次发起尝试的单调毫秒时刻(have_last=0 时无意义) */
int have_last; /* 1=至少尝试过一次 */
} ncm_retry_ctl;
void ncm_retry_ctl_init(ncm_retry_ctl *c);
/* 每个 UI tick 调一次。返回 1=现在应当发起一次重试(内部已记账:调用方随即起 worker)。
* offline=当前是否离线;busy=是否已有 worker 在跑(单飞);now_ms=单调毫秒;
* interval_ms=两次尝试的最小间隔(M10 定 30000)。
*
* 语义:
* - offline=0:返回 0,且**不动**基准(在线期间与状态抖动都不重置节奏)。
* - busy=1:返回 0。
* - 首次观察到离线:返回 1(进离线先立即探测一次,恢复后由会话门切回在线)。
* - 之后:仅当 now-last >= interval 才返回 1;now<last(时钟回退)→ 重置基准、返回 0。 */
int ncm_retry_ctl_poll(ncm_retry_ctl *c, int offline, int busy, int64_t now_ms,
int64_t interval_ms);
#endif /* NCM_RETRY_CTL_H */
+86 -5
View File
@@ -31,6 +31,7 @@
#define ACT_SETTINGS 4 /* 设置页 */
#define ACT_SEARCH 5 /* 搜索页(顶栏搜索按钮进入) */
#define ACT_QUEUE 6 /* 当前播放队列页(播放页「队列」钮进入,展示实时播放列表) */
#define ACT_CACHE 7 /* 已缓存列表页(首页顶栏「已缓存」入口,NCM_CACHE_DESIGN §2.5) */
/* ---- 播放模式三态(REPORT §NEW:随机 / 列表循环 / 单曲循环)----
* 默认=列表循环(延续既有「顺序下一首」语义;列表循环下自动顺延会在队尾回绕)。
@@ -61,14 +62,14 @@ enum {
#define APP_W 480
#define APP_H 720
#define APP_TOPBAR_H 56
#define APP_ROW_H 56 /* 列表行高 */
#define APP_ROW_H 56 /* 列表行高下限(旧固定值;实际行高按字体行高派生,见 ncm_row_h) */
#define APP_PAD 16
#define APP_OP_W 48 /* 行右侧操作区触达宽 */
#define APP_FOOTER_H 26
/* 圆角卡列表项外边距(item margin/gap):相邻卡片间可见空隙 px。SDK listview 行距(=APP_ROW_H)
* 不变——卡片在其行高内上下对称内缩 NCM_ROW_GAP/2,故文本(SDK 按 APP_ROW_H 垂直居中)仍落卡中心;
* 相邻卡顶间距恒=APP_ROW_H=卡高+NCM_ROW_GAP。要求偶数,保证上下对称。 */
/* 圆角卡列表项外边距(item margin/gap):相邻卡片间可见空隙 px。SDK listview 行距(=ncm_row_h())
* 不变——卡片在其行高内上下对称内缩 NCM_ROW_GAP/2,故文本(SDK 按行高垂直居中)仍落卡中心;
* 相邻卡顶间距恒=行高=卡高+NCM_ROW_GAP。要求偶数,保证上下对称。 */
#define NCM_ROW_GAP 8
/* 第 idx 行(行高 item,滚动 off,列表顶 list_y)的圆角卡:顶 y 与高 h。 */
static inline int ncm_row_card_y(int item, int idx, int off, int list_y)
@@ -77,6 +78,32 @@ static inline int ncm_row_card_y(int item, int idx, int off, int list_y)
}
static inline int ncm_row_card_h(int item) { return item - NCM_ROW_GAP; }
/* ---- 列表行高(M12:首行标题不显示根因修复)----
* SDK meow_listview 的双行渲染(sdk/meow_ui_widget.c 的 2 行分支)把「标题+副标题」文本块
* block = 2 * meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2)
* 在行内垂直居中(ty = 行顶 + (item - block) / 2),且标题只在 `ty >= 视口顶` 时才画
* (越界钳制,防泄漏)。行高 item 小于 block 时 ty = 行顶 - Δ,于是:
* - 第一行(行顶 == 视口顶)标题被钳掉、副标题(ty + thh + 2)仍在视口内 → 真机
* 「歌单列表/歌曲列表第 1 行只有副标题」,其余行标题画在自己行顶上方 Δ(不易察觉);
* - 文本块也比圆角卡高,两行文字溢出卡外。
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 = 30)block = 62,旧固定行高
* 56 → Δ = 3。故行高必须由字体实测行高派生:block + NCM_ROW_GAP → 卡高 == 文本块,
* 文本完整落在卡内并仍居中。行高由 NCM_ROW_GAP 为偶数、2*行高 为偶数保证偶数(卡对称内缩)。 */
#define NCM_ROW_SUB_GAP 2 /* 与 SDK MEOW_LISTV_SUB_GAP 同口径(标题/副标题行间距) */
static inline int ncm_row_h_for_line_h(int line_h)
{
if (line_h < 1) line_h = 1; /* 字体未就绪的防御(0 高 → 退回下限) */
int h = 2 * line_h + NCM_ROW_SUB_GAP + NCM_ROW_GAP;
if (h < APP_ROW_H) h = APP_ROW_H;
if (h & 1) h++;
return h;
}
/* 当前主题字体下的两行列表行高(页面 create 时取值并缓存;字体档位切换走重建页面)。 */
static inline int ncm_row_h(void)
{
return ncm_row_h_for_line_h(meow_ui_text_height(meow_theme_get()->font_body));
}
/* ---- 播放页(§22 重做:封面页/歌词页双页,NCM_CACHE_DESIGN §6)----
* 沉浸底恒 NCM_PLAYER_BG(不随系统明暗反转——「不动沉浸底」);日夜间主题配色只映射到
* 半透明叠加层(歌词页 scrim)与 accent(红)。 */
@@ -115,6 +142,11 @@ static inline int ncm_row_card_h(int item) { return item - NCM_ROW_GAP; }
/* 播放队列来源(§23:搜索单曲起播建立「搜索队列」上下文,不覆写歌单窗口) */
#define NCM_QUEUE_PLAYLIST 0 /* 默认:歌单滑动窗口 */
#define NCM_QUEUE_SEARCH 1 /* 搜索单曲结果队列 */
#define NCM_QUEUE_CACHE 2 /* 已缓存列表队列(点播缓存页条目;上/下一首在缓存列表内循环) */
/* 已缓存列表条目上限(NCM_CACHE_LIST_MAX 条 × sizeof(ncm_cache_item_t)≈232B ≈ 116KB bss)。
* 缓存池条目更多时只展示最近访问的 max 条(scan 按 mtime 降序保留最近的)。 */
#define NCM_CACHE_LIST_MAX 512
/* active_playlist 哨兵:搜索歌单详情(复用 playlist_open 链路,读 g_st.search_pl_open) */
#define NCM_PL_SEARCH (-2)
@@ -199,6 +231,10 @@ typedef struct ncm_app_state {
int qr_size;
int login_state; /* NCM_LOGIN_* */
int logged_in;
/* 离线模式(M9):网络类失败(不可达/超时/DNS/连接失败/服务端 5xx)时为 1——账号态与
* cookie 一律保留,UI 显示可见提示、已缓存列表照常可开可播;任何一次请求成功即回 0。
* 鉴权失败(NCM_ERR_AUTH)不走这里:沿用原退出登录→扫码逻辑。 */
int offline;
/* 账号 */
char uid[32];
@@ -269,6 +305,10 @@ typedef struct ncm_app_state {
/* 播放队列来源(§23):默认 NCM_QUEUE_PLAYLIST;搜索单曲起播切 NCM_QUEUE_SEARCH */
int queue_src;
/* 已缓存列表(ACT_CACHE 页 + NCM_QUEUE_CACHE 队列;worker 扫描磁盘填充,UI 只读) */
ncm_cache_item_t cache_items[NCM_CACHE_LIST_MAX];
int cache_item_count;
} ncm_app_state_t;
extern ncm_app_state_t g_st; /* 全局状态(app.c 定义) */
@@ -331,6 +371,7 @@ void home_touch_down(int x, int y);
void home_touch_move(int x, int y);
void home_reload(void); /* 刷新我的歌单(后台 worker,非阻塞) */
void home_tick(uint64_t now); /* UI tick:检查歌单 worker 是否完成并刷新 */
void home_retry_reset(void); /* M10:清离线自动重试节奏(页创建/登出/宿主测试) */
int home_go_up(void); /* 左缘返回:到底则退出 */
/* playlist */
@@ -353,6 +394,7 @@ void player_tick(uint64_t now); /* 播放进度/歌词滚动 */
void player_play_song(int idx); /* 从歌单窗口第 idx 首开始播放(queue=PLAYLIST) */
void player_play_search_song(int i); /* 从搜索单曲第 i 首开始播放(queue=SEARCH,§23) */
void player_play_queue_song(int idx);/* 从「当前实时播放队列」第 idx 首播放(保持当前 queue_src,§NEW) */
void player_play_cache_song(int i); /* 从已缓存列表第 i 首点播(切 NCM_QUEUE_CACHE 队列) */
void player_pause_toggle(int on);
void player_next(int dir); /* dir=+1 下一首 / -1 上一首 */
void player_seek(int64_t ms);
@@ -390,6 +432,19 @@ void queue_touch_move(int x, int y);
void queue_open(void); /* 进当前播放队列页 */
void queue_tick(uint64_t now); /* UI tick:检查队列 worker 完成并刷新 */
/* cache(已缓存列表页,ACT_CACHE:首页顶栏「已缓存」钮进入,NCM_CACHE_DESIGN §2.5)
* 列表 = songs/ 池扫描(worker 读盘,UI 零 IO);行显示 歌名/歌手/实际档位/大小;
* 点行=点播(NCM_QUEUE_CACHE),行右「删除」=单曲删除,顶栏「清空」=清空缓存池。 */
meow_view *cache_view_create(void);
int cache_is_active(void);
int cache_on_tap(int x, int y);
void cache_swipe(int dir, int dist);
void cache_touch_down(int x, int y);
void cache_touch_move(int x, int y);
void cache_open(void); /* 进已缓存列表页(push ACT_CACHE) */
void cache_reload(void); /* 起 worker 重扫缓存池(非阻塞) */
void cache_tick(uint64_t now); /* UI tick:检查扫描 worker 完成并刷新 */
/* search */
meow_view *search_view_create(void);
int search_is_active(void);
@@ -409,7 +464,20 @@ void appstate_save_settings(void);
void appstate_set_logged_in(const char *uid, const char *nickname);
void appstate_logout(void);
int appstate_ensure_uid(void); /* 若 uid 缺失则 login_status_check 取回;0=成功 */
/* ---- 会话保持 / 离线模式(M9)----
* 一次网络动作的返回码 → 会话状态机(core ncm_session.h 的纯判定在上,这里落到 App 状态):
* 成功 → 在线(解除离线);网络类失败 → 离线(保留登录态/cookie);
* 鉴权失败 → 由调用方沿用原退出登录逻辑(本函数不改登录态);其它 → 保持现状。
* 返回 ncm_gate_t。可在 worker 线程调用(只写 g_st.offline,值域 0/1 幂等)。 */
int appstate_session_gate(int rc);
int appstate_is_offline(void); /* 1=离线模式(UI 画「离线模式」提示) */
void appstate_offline_set(int on); /* 直接置离线态(登出/初始化清零) */
int appstate_load_playlists(void); /* 拉我的歌单 */
int appstate_load_playlists_ex(int *from_cache);
/* 同上,额外回填「本次是否接口缓存命中」:
* 1=缓存(未走网络,不能判为已回在线);0=走网络/失败。
* from_cache 可为 NULL(等价 appstate_load_playlists)。 */
int appstate_load_playlist_songs(int idx); /* 兼容:载索引 + 首个窗口(内部走 ensure) */
int appstate_load_playlist_songs_page(int idx, int offset, int limit);
/* 兼容入口(滑动窗口语义,REPORT §17):
@@ -470,6 +538,16 @@ int appstate_queue_count(void); /* 当前播放队列长度(
int appstate_queue_song(int idx, ncm_song_t *out); /* 队列第 idx 首;0=ok(按 queue_src) */
void appstate_apply_play_mode(int m); /* 设 g_st.play_mode + 落盘(持久化「记住模式」,§NEW) */
/* ---- 已缓存列表(NCM_CACHE_LIST_MAX;磁盘扫描/删除只在 worker 或 UI 单次小 IO)----
* queue_src==NCM_QUEUE_CACHE 时,appstate_queue_count/song 走本数组(播放页上/下一首同源)。
* 名称/歌手优先取文件内 tag(与外部播放器同源),空则回退旁路元数据 <cache_dir>/meta/<id>.bin。 */
int appstate_cache_list_load(void); /* worker:ncm_cache_scan + 旁路补名;返回条数或负错误码 */
int appstate_cache_count(void);
int appstate_cache_item(int i, ncm_cache_item_t *out); /* 0=ok */
int appstate_cache_song(int i, ncm_song_t *out); /* 合成 ncm_song_t(时长/封面/fee 取旁路) */
int appstate_cache_remove(int i); /* 删文件 + 摘除条目;0=成功 */
void appstate_cache_clear(void); /* 清空缓存池(含封面池,同设置页语义)+ 清列表 */
/* ---- 搜索结果独立数组(§23;g_search_mu 保护,UI/worker 经此访问)---- */
int appstate_search_count(int tab); /* tab: 0=单曲 1=歌单 */
int appstate_search_offset(int tab); /* 下次 offset(=已加载条数) */
@@ -537,7 +615,10 @@ int audio_out_pause(int paused); /* 1=暂停(停 PCM 时钟不丢流) 0=恢
void audio_out_close(void);
int audio_out_sync_volume(void); /* 任务1:读 settings.conf 当前系统音量并下发到 audiod
(MSG_AUDIO_VOLUME→写 DAC+持久化)。0=下发成功/-1=失败。
audio_out_open SINK_AUDIOD 成功后已自动调;独立导出供宿主验证与复调。 */
每音频会话(新 audiod 流 sid)首同步一次 + 值变才重发——切歌/新建
会话时 DAC 音量不残留默认响度(修「切歌音量骤增」);同会话内不
重复建连(不做 fd 风暴)。audio_out_open SINK_AUDIOD 成功后已自动调;
独立导出供宿主验证与复调。 */
void audio_out_set_target(int ms); /* 预留:缓冲目标 */
int audio_out_buffered_ms(void);
+17 -9
View File
@@ -233,20 +233,27 @@ static void audiod_sync_volume(int pct)
close(fd);
}
/* 每进程同步一次 + 值变才重发:杜绝每次 audio_out_open 新建短连接(fd=N accept 风暴根因)。
* g_vol_synced=1 即已同步过;g_vol_last=上次下发的 pct,二者相同则零连接直接返回。 */
static int g_vol_synced = 0;
static int g_vol_last = -1;
/* 会话身份 = audiod 流 sid(g_sid):切歌/新建音频会话 = 每次 audio_out_open 都向 audiod 开一条
* 新流、audiod 分配新 sid(单调不重用)。此前用进程级 g_vol_synced「首次同步一次」——切歌(新会话)
* 时音量不再被重发到 audiod,DAC 落在会话建立/重配后的默认/上次残留值(衰减量 0=最响),造成
* 「切歌音量骤增,按一下音量键才回落到设定值」(用户真机 2026-09-05)。audiod 只在 reconfigure
* 且参数变化时才 re-apply 音量(_audio_mark_reconfig_needed 同参数跳过),本会话重发是权威兜底。
* 修法:**每会话首同步一次**——新 sid(新会话)必重发一次当前音量;同会话内仅值变才重发。
* 每会话恰 1 条短连接,不回到「每次 open 都建连」的 fd 风暴(§2026-09-02 根因,vol_sync_sim 60 次
* 同值→1 连接)。音量值取自 settings.conf(audiod 权威同源同键),反转/真静音(255) 由 audiod
* dac_vol_set 负责,本层只传 pct(0-100)。 */
static int g_vol_synced_sid = -1; /* 上次音量同步针对的 audiod 流 sid;-1=从未(必发) */
static int g_vol_last = -1;
int audio_out_sync_volume(void)
{
int pct = settings_read_volume();
if (g_vol_synced && pct == g_vol_last) return 0; /* 已同步且未变:零新建连接 */
g_vol_synced = 1;
g_vol_last = pct;
if (g_vol_synced_sid == g_sid && pct == g_vol_last) return 0; /* 本会话已同步且值未变:零新建连接 */
g_vol_synced_sid = g_sid;
g_vol_last = pct;
audiod_sync_volume(pct);
meow_log(MEOW_LOG_INFO, "audio: sync volume -> audiod pct=%d (settings %s)",
pct, settings_conf_path());
meow_log(MEOW_LOG_INFO, "audio: sync volume -> audiod pct=%d (settings %s, sid=%d)",
pct, settings_conf_path(), g_sid);
return 0;
}
@@ -379,6 +386,7 @@ int audio_out_pause(int paused)
}
g_sid = sid;
meow_log(MEOW_LOG_INFO, "audio: audiod reopen ok sid=%d", sid);
audio_out_sync_volume(); /* 新会话(新 sid):音量权威兜底重发一次(DAC 复位后不残留默认响度) */
return 0;
}
+36 -13
View File
@@ -1,12 +1,14 @@
/* play/ipc_media.c — 媒体会话 IPC 最小客户端(SDK 尚无媒体封装,App 内用 meow_ipc.h 帧格式直连)。
*
* 连接:AF_UNIX SOCK_STREAM 到 MEOW_IPC_SOCK(缺省 /usr/data/meow_ipc.sock)。HELLO(app_id,
* MF_PLAYBACK) 注册为媒体源;之后**同一连接**周期发 MSG_META(~1s,进度随播放推进)并阻塞等
* launcher 下发 MSG_PLAYCTL(播放/暂停/上下曲/seek/toggle)。launcher 经该连接 fd 回发(meow_media.c
* send_playctl 用最近活跃会话的 fd)。无 socket/launcher 不跑 → 优雅跳过(打日志不崩)。
* MF_PLAYBACK) 注册为媒体源;之后**同一连接**上报 MSG_META 并阻塞等 launcher 下发 MSG_PLAYCTL
* (播放/暂停/上下曲/seek/toggle)。launcher 经该连接 fd 回发(meow_media.c send_playctl 用最近
* 活跃会话的 fd)。META 节奏见 meta_gate.h(变化 + 低频心跳,不再是 1s 定时;M10 修洪泛)。
* 无 socket/launcher 不跑 → 优雅跳过(打日志不崩)。
* 线程模型:独立线程(main 起了 meow_ui_run 阻塞;播放线程归 player_manager,本线程只做 IPC 收发)。
*/
#include "../ncm_app_internal.h"
#include "meta_gate.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -23,6 +25,12 @@ static int g_thr_on;
static int g_quit;
static volatile int g_req_focus_player = 0; /* 任务3:launcher 请求「回到播放页」(UI 线程消费) */
/* M10:META 上报闸(变化 + 低频心跳;时间用 core 单调钟)。
* 旧实现 `if (now - last_meta >= 1000)` 依赖 meow_ui_time_ms(),真机该值高 32 位是垃圾
* (REPORT §42)→ 条件恒真 → 每个 IPC 轮次都发 META(真机 launcher 日志 ~100ms 一帧、
* 单次启动 21016 帧),launcher 每帧写日志+归并会话,SD 写放大到整机卡死。 */
static ncm_meta_gate g_meta_gate;
static const char *sock_path(void)
{
const char *e = getenv("MEOW_IPC_SOCK");
@@ -92,8 +100,10 @@ static int read_frame(int fd, uint16_t *type, uint8_t *rbuf, uint32_t rcap)
return (int)len;
}
/* 组并发送一条 META。 */
static void send_meta(int fd, uint32_t req_id)
/* 组并发送一条 META。返回 1=已发 / 0=闸判定不必发 / -1=写失败。
* 是否发由 meta_gate 决定:状态/元数据变化立即发;播放中位置偏移 ≥5s 重锚;心跳 5s/30s;
* 硬下限 250ms(任何路径都不快于 4 帧/秒)。now_ms 必须是单调毫秒(ncm_monotonic_ms)。 */
static int send_meta(int fd, uint32_t req_id, int64_t now_ms)
{
m_meta_t mm;
memset(&mm, 0, sizeof mm);
@@ -135,7 +145,21 @@ static void send_meta(int fd, uint32_t req_id)
mm.flags |= MF_ART | MF_COVER;
}
if (lyric_count() > 0) mm.flags |= MF_LYRIC;
send_frame(fd, MSG_META, req_id, &mm, sizeof mm);
ncm_meta_snap s;
s.state = (int)mm.state;
s.pos_ms = (int)mm.pos_ms;
s.dur_ms = (int)mm.dur_ms;
s.flags = (int)mm.flags;
s.title = mm.title;
s.artist = mm.artist;
s.album = mm.album;
s.artwork = mm.artwork;
if (!ncm_meta_gate_should_send(&g_meta_gate, &s, now_ms)) return 0;
if (send_frame(fd, MSG_META, req_id, &mm, sizeof mm) != 0) return -1;
meow_log(MEOW_LOG_DEBUG, "ipc: meta sent state=%u pos=%u/%u", mm.state, mm.pos_ms, mm.dur_ms);
return 1;
}
static void apply_playctl(const m_playctl_t *c)
@@ -177,9 +201,8 @@ static void *ipc_thread(void *arg)
uint16_t htype;
if (read_frame(fd, &htype, hb, sizeof hb) < 0) { close(fd); return NULL; }
send_meta(fd, 2); /* 首条注册媒体源(state=0 未播也登记,start 播后自动成源) */
send_meta(fd, 2, ncm_monotonic_ms()); /* 首条注册媒体源(state=0 未播也登记,start 播后自动成源) */
uint64_t last_meta = 0;
while (!g_quit && !g_app_quit) {
struct pollfd p;
p.fd = fd; p.events = POLLIN;
@@ -197,11 +220,9 @@ static void *ipc_thread(void *arg)
} else if (pr < 0 && errno != EINTR) {
break;
}
uint64_t now = meow_ui_time_ms();
if (now - last_meta >= 1000) {
send_meta(fd, (uint32_t)(now & 0x7FFFFFFF));
last_meta = now;
}
/* M10:不再定时无脑发;send_meta 内部按 meta_gate 判「变化 / 位置偏移 / 低频心跳」。 */
int64_t now_ms = ncm_monotonic_ms();
send_meta(fd, (uint32_t)(now_ms & 0x7FFFFFFF), now_ms);
}
close(fd);
return NULL;
@@ -242,6 +263,7 @@ static const char *ncm_act_name(int act)
case ACT_PLAYER: return "player";
case ACT_SETTINGS: return "settings";
case ACT_SEARCH: return "search";
case ACT_CACHE: return "cache"; /* M5 已缓存列表(app.json 未声明常亮 → 照常息屏) */
default: return NULL;
}
}
@@ -283,6 +305,7 @@ int ipc_media_start(void)
{
if (g_thr_on) return 0;
g_quit = 0;
ncm_meta_gate_init(&g_meta_gate); /* M10:首帧必发(注册媒体源),之后按变化/心跳 */
if (ncm_thread_create(&g_thr, ipc_thread, NULL) != 0) return -1;
pthread_detach(g_thr);
g_thr_on = 1;
+74
View File
@@ -0,0 +1,74 @@
/* play/meta_gate.c — 见 meta_gate.h 头注释(M10 媒体会话 META 上报闸)。 */
#include "meta_gate.h"
#include <string.h>
static void gate_cp(char *dst, size_t cap, const char *src)
{
size_t i = 0;
if (!dst || cap == 0) return;
if (src) {
for (; src[i] && i + 1 < cap; i++) dst[i] = src[i];
}
dst[i] = 0;
}
void ncm_meta_gate_init(ncm_meta_gate *g)
{
if (g) memset(g, 0, sizeof *g);
}
static void gate_record(ncm_meta_gate *g, const ncm_meta_snap *s, int64_t now_ms)
{
g->inited = 1;
g->state = s->state;
g->dur_ms = s->dur_ms;
g->flags = s->flags;
g->last_pos_ms = s->pos_ms;
g->last_send_ms = now_ms;
gate_cp(g->title, sizeof g->title, s->title);
gate_cp(g->artist, sizeof g->artist, s->artist);
gate_cp(g->album, sizeof g->album, s->album);
gate_cp(g->artwork, sizeof g->artwork, s->artwork);
}
/* 只比较「会被发送的那部分」:发送侧同样按 cap 截断,超长串不得因截断而恒判「变了」。 */
static int str_diff(const char *stored, size_t cap, const char *now)
{
size_t i = 0;
if (!now) now = "";
for (; i + 1 < cap && stored[i] && stored[i] == now[i]; i++) { }
if (i + 1 >= cap) return 0; /* 前 cap-1 字符一致且已到截断长度 → 相同 */
return stored[i] != now[i];
}
int ncm_meta_gate_should_send(ncm_meta_gate *g, const ncm_meta_snap *s, int64_t now_ms)
{
if (!g || !s) return 0;
if (!g->inited) { gate_record(g, s, now_ms); return 1; } /* 首帧:注册媒体源 */
int64_t gap = now_ms - g->last_send_ms;
if (gap < 0) { /* 时钟回退/异常:重定基准,差值按 0 处理(防下溢成「恒该发」) */
g->last_send_ms = now_ms;
gap = 0;
}
int changed = (s->state != g->state) ||
(s->dur_ms != g->dur_ms) ||
(s->flags != g->flags) ||
str_diff(g->title, sizeof g->title, s->title) ||
str_diff(g->artist, sizeof g->artist, s->artist) ||
str_diff(g->album, sizeof g->album, s->album) ||
str_diff(g->artwork, sizeof g->artwork, s->artwork);
int64_t dpos = (int64_t)s->pos_ms - (int64_t)g->last_pos_ms;
if (dpos < 0) dpos = -dpos; /* seek 前后跳都算偏移 */
int playing = (s->state == NCM_META_ST_PLAYING || s->state == NCM_META_ST_BUFFER);
int64_t hb = playing ? NCM_META_HB_PLAY_MS : NCM_META_HB_IDLE_MS;
if (!changed && dpos < NCM_META_POS_RESEND_MS && gap < hb) return 0;
if (gap < NCM_META_MIN_GAP_MS) return 0; /* 硬下限:变化挂起,下一轮再发(不快于 4 帧/秒) */
gate_record(g, s, now_ms);
return 1;
}
+66
View File
@@ -0,0 +1,66 @@
/* play/meta_gate.h — 媒体会话 META 上报闸(M10:META 洪泛修复点;纯逻辑,宿主可驱动)。
*
* 背景(真机证据):launcher meow_20261105_122541.log 里 21016 条 `ipc: META app=dev.player.ncm
* state=0 pos=0/0 rc=0 -> ACK`,间隔 ~100ms(每次 launcher 收帧都要写日志+归并会话)。
* 旧 ipc_media 的节奏是 `if (now - last_meta >= 1000)`,同样依赖 meow_ui_time_ms() 的 64 位差值;
* 真机该值高 32 位是垃圾(REPORT §42)→ 条件几乎恒真 → 每个 IPC 轮次都发 META,
* 1s 心跳退化成 10Hz 洪泛,把 launcher 的 SD 写/会话归并拖满,整机卡死。
*
* 修复后的上报策略(不再是定时器,而是「变化 + 低频心跳」):
* - 状态/时长/标题/歌手/专辑/封面/歌词标志变化 → 立即上报(用户操作要即时可见);
* - 播放中位置相对上次上报偏移 ≥ 5s(含 seek 前后跳)→ 上报,给 launcher 重锚进度
* (launcher meow_media_display_pos 会按 last_active 自行外推,5s 足够);
* - 心跳:播放中 5s、非播放 30s(保活会话/防 launcher 会话超时清理);
* - 硬下限 250ms:任何路径(含状态抖动)都不快于 4 帧/秒。
* 时间基准由调用方给单调钟(ipc_media 传 core 的 ncm_monotonic_ms())。
*/
#ifndef NCM_META_GATE_H
#define NCM_META_GATE_H
#include <stdint.h>
/* 与 meow_ipc.h 的 m_meta_t.state 同口径(MF_PLAYBACK 会话状态)。 */
enum {
NCM_META_ST_STOPPED = 0,
NCM_META_ST_PLAYING = 1,
NCM_META_ST_PAUSED = 2,
NCM_META_ST_BUFFER = 3,
};
/* 一次上报快照(字符串由调用方持有,本模块只拷贝比较,不接管所有权)。 */
typedef struct {
int state; /* NCM_META_ST_* */
int pos_ms;
int dur_ms;
int flags; /* MF_ART/MF_COVER/MF_LYRIC 位 */
const char *title;
const char *artist;
const char *album;
const char *artwork;
} ncm_meta_snap;
typedef struct {
int inited;
int state;
int dur_ms;
int flags;
int last_pos_ms; /* 上次上报时的播放位置(判进度偏移/seek) */
int64_t last_send_ms; /* 上次上报的单调毫秒 */
char title[128];
char artist[128];
char album[128];
char artwork[192];
} ncm_meta_gate;
#define NCM_META_POS_RESEND_MS 5000 /* 播放中位置偏移(ms)触发重报 */
#define NCM_META_HB_PLAY_MS 5000 /* 播放中心跳 */
#define NCM_META_HB_IDLE_MS 30000 /* 非播放心跳 */
#define NCM_META_MIN_GAP_MS 250 /* 硬下限:任何情况下 ≤ 4 帧/秒 */
void ncm_meta_gate_init(ncm_meta_gate *g);
/* 返回 1=现在应当发送(内部已把本次快照记为「已上报」);0=不必发。
* now_ms=单调毫秒(建议 ncm_monotonic_ms())。未初始化时首调必发(注册媒体源)。 */
int ncm_meta_gate_should_send(ncm_meta_gate *g, const ncm_meta_snap *s, int64_t now_ms);
#endif /* NCM_META_GATE_H */
+140 -96
View File
@@ -167,9 +167,10 @@ void player_manager_set_quality(int level, int cache_gb)
static int play_is_same_song(int song_idx)
{
ncm_song_t cand;
int have = (appstate_get_queue() == NCM_QUEUE_SEARCH)
? (appstate_search_song(song_idx, &cand) == NCM_OK)
: (appstate_song_copy(song_idx, &cand) == NCM_OK);
int src = appstate_get_queue();
int have = (src == NCM_QUEUE_SEARCH) ? (appstate_search_song(song_idx, &cand) == NCM_OK)
: (src == NCM_QUEUE_CACHE) ? (appstate_cache_song(song_idx, &cand) == NCM_OK)
: (appstate_song_copy(song_idx, &cand) == NCM_OK);
if (!have || !cand.id[0]) return 0; /* 未物化:不能确证同曲 → 交下方正常起播 */
pthread_mutex_lock(&g_play.mu);
int same = g_play.play_song_valid && g_play.play_song.id[0] &&
@@ -348,6 +349,11 @@ typedef struct {
char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */
size_t maxb; /* 下载限幅 */
int64_t total; /* 标称大小(song_url size;0=未知;mp3 seek 分母) */
/* 缓存文件元数据嵌入(NCM_CACHE_DESIGN §2.5 + M5):下载完整后由下载线程把
* id3/vorbis tag 写进 .tmp(按实际容器分支),再交播放线程 commit。失败只记日志,
* commit 裸流——播放主链路不受影响。 */
ncm_tag_meta_t tag;
char ext[8]; /* 实际容器(song_url type / url 扩展名) */
/* 阶段3 .part 续传(§5.2) */
uint64_t resume_base; /* >0=断点续传起点(.part 大小);Http 带 Range: bytes=<n>- */
int first_body; /* 首个 body 回调未处理(按响应状态切换写姿态) */
@@ -440,8 +446,16 @@ static void *dl_thread(void *arg)
if (!ok_st) rc = NCM_ERR_HTTP;
}
ss->dl_rc = rc;
/* 结束通知:成功 final=全长;失败/中止 final=截断点+failed(读端播到截断点自然结束,F5) */
/* 缓存文件元数据嵌入(M5):下载完整 → 把 id3/vorbis tag 流式写进 .tmp(64KB 分块,
* 同 inode 替换:解码端持有旧 fd 继续播,不受影响)。先 finish 让读端自然收尾,再重写;
* dispose 的 join 保证「重写完成 → commit」顺序。任何失败都只降级为裸流。 */
stream_src_finish(ss->src, rc == NCM_OK ? 0 : rc);
if (rc == NCM_OK && ss->w && !g_app_quit) {
int trc = ncm_tag_embed_file(ncm_cache_writer_tmp_path(ss->w), ss->ext, &ss->tag);
if (trc != NCM_OK)
meow_log(MEOW_LOG_WARN, "play: tag embed skipped rc=%d ext=%s (raw stream kept)",
trc, ss->ext);
}
return NULL;
}
@@ -485,7 +499,9 @@ static void song_gov_cleanup(song_src_t *ss)
meow_cachegov_drop_finished(ss->gov_final_path);
}
/* 缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 音质档位 → "song_<id>_<level>.<ext>"。
/* 请求缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 用户音质档位 → "song_<id>_<level>.<ext>"。
* 只用于**查找**(命中下探由 ncm_cache_lookup 处理);**写入**的正式名用响应实际档
* (M6:hires 请求被降级链落到 exhigh mp3 → 落盘为 song_<id>_exhigh.mp3,名实相符)。
* 同歌同档命中不重复下载;换档=新条目,旧档留给 LRU 自然淘汰。
* §20:键后缀用 ncm_api_level_key(sky 与 sky_c51 可区分)。 */
static void audio_cache_key(char *out, size_t sz, const char *id, int level)
@@ -493,19 +509,17 @@ static void audio_cache_key(char *out, size_t sz, const char *id, int level)
snprintf(out, sz, "%s_%s", id, ncm_api_level_key(level));
}
/* §档位保真(REPORT §NEW 任务2②):显示档以响应实际格式/码率为准——
* - type=flac:采信降级链 eff(无损档链已保证 flac,br/采样由解码后定)。
* - type=mp3:绝不显示 Hi-Res 及以上(Hi-Res 需 >16bit/高采样,mp3 达不到)。按响应 br
* 参与判定:≤128k→标准;否则→极高 HQ(≤320k,仅降级链中间档「较高 192k」不进 UI,不上屏)。
* - br 未知:钳 chain_eff 到 ≤EXHIGH(保守不夸大)。
* 返回 UI 显示用档位(g_play.eff_level)。 */
static int eff_level_for_resp(const ncm_play_url_t *u, int chain_eff, int want)
/* 响应 → 实际档(写盘文件名 / 播放页显示 / 已缓存列表三处同源,core ncm_cache_actual_level):
* 容器 type 权威;mp3 按响应 br(≤128k→标准,否则极高,永不高于极高);flac 取降级链
* 生效档 eff(头参数下载后才能测,落盘名不夸大)。只降不升(min(请求档, 实际档))。
* 返回实际档(g_play.eff_level 用)。 */
static int actual_level_for_resp(const ncm_play_url_t *u, const char *ext, int chain_eff, int want)
{
if (u && ncm_streq(u->type, "flac")) return (chain_eff >= 0) ? chain_eff : want;
int br = u ? u->br : 0;
if (br > 0) return (br <= 128000) ? NCM_LEVEL_STANDARD : NCM_LEVEL_EXHIGH; /* 按实际码率 */
int e = (chain_eff >= 0) ? chain_eff : want;
return (e < NCM_LEVEL_EXHIGH) ? e : NCM_LEVEL_EXHIGH; /* 未知:钳到 ≤EXHIGH */
ncm_audio_info_t ai;
memset(&ai, 0, sizeof ai);
ncm_strlcpy(ai.container, ext ? ext : "", sizeof ai.container);
if (u && u->br > 0 && ncm_streq(ai.container, "mp3")) ai.bitrate_kbps = u->br / 1000;
return ncm_cache_actual_level(want, &ai, chain_eff);
}
/* 同曲重播护栏(§25 切音质后重播同一曲,替代 §18 的 still_current):仅 cur/state 不够——
@@ -526,7 +540,7 @@ static int still_intent(int idx, int my_seq)
* 返回:NCM_OK=src 就绪(下载在后台继续);DL_RETRYABLE=预缓冲前网络失败(调用方整首重试);
* DL_ABORTED=切歌/退出(静默放弃,不重试);其它负数=硬错误(不重试)。
* *real_err 回填预缓冲失败的真实错误码。失败时 ss->w/src/dl_thr 已清理干净。 */
static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const char *key, int want,
static int song_download_attempt(const ncm_song_t *s, int idx, int seq, int want,
song_src_t *ss, char *path, size_t psz, int64_t *bytes,
int *real_err)
{
@@ -539,14 +553,26 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
account_vip(), s->fee, want, &u, &eff);
if (rc != NCM_OK) return rc;
if (!u.url[0]) return NCM_ERR_NOTFOUND;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff_level_for_resp(&u, eff, want); /* §2②:显示档按实际格式/码率 */
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 落盘扩展名:type 权威;type 缺失时看 url 扩展名,默认 mp3 */
const char *ext = "mp3";
if (ncm_streq(u.type, "flac")) ext = "flac";
else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac";
ncm_strlcpy(ss->ext, ext, sizeof ss->ext);
/* M6:写盘名用**响应实际档**(不是请求档)。hires 请求被降级链落到 exhigh mp3
* → song_<id>_exhigh.mp3,文件名与内容相符;同档再播按实际档键直接命中。
* .part 续传也用同一实际档键(同名才可续传/commit)。 */
int actual = actual_level_for_resp(&u, ext, eff, want);
char akey[64];
audio_cache_key(akey, sizeof akey, id, actual);
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = actual; /* 显示档 = 实际档(与文件名/列表同源) */
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
pthread_mutex_unlock(&g_play.mu);
/* 文件内 tag 字段(下载完整后由 dl_thread 写入;空字段不写)。 */
memset(&ss->tag, 0, sizeof ss->tag);
ncm_strlcpy(ss->tag.title, s->name, sizeof ss->tag.title);
ncm_strlcpy(ss->tag.artist, s->art_name, sizeof ss->tag.artist);
ncm_strlcpy(ss->tag.album, s->album_name, sizeof ss->tag.album);
/* 写前检查:池大小超限先按 LRU 淘汰(半成品 .tmp/.part 已被 evict 跳过,无在途误删风险) */
ncm_cache_evict(&g_st.ctx.cache);
@@ -554,17 +580,17 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
/* 断点续传起点(§5.2):.part 残体存在且本次 URL 的 size > 残体大小 → 续传:
* 追加打开 .part(O_APPEND,保留已写字节),written 从 part_size 起算(读端秒开播)。 */
uint64_t part_size = 0;
int have_part = (ncm_cache_part_size(&g_st.ctx.cache, key, ext, &part_size) == NCM_OK) &&
int have_part = (ncm_cache_part_size(&g_st.ctx.cache, akey, ext, &part_size) == NCM_OK) &&
part_size > 0;
int resuming = 0;
if (have_part && (long)part_size < u.size) {
rc = ncm_cache_open_resume(&g_st.ctx.cache, key, ext, &ss->w);
rc = ncm_cache_open_resume(&g_st.ctx.cache, akey, ext, &ss->w);
if (rc == NCM_OK) { ss->resume_base = part_size; resuming = 1; }
else have_part = 0; /* 残体打开失败:转整首 */
}
if (!resuming) {
if (have_part) ncm_cache_part_remove(&g_st.ctx.cache, key, ext); /* 残体过期(size 不符):删 */
rc = ncm_cache_open(&g_st.ctx.cache, key, ext, &ss->w);
if (have_part) ncm_cache_part_remove(&g_st.ctx.cache, akey, ext); /* 残体过期(size 不符):删 */
rc = ncm_cache_open(&g_st.ctx.cache, akey, ext, &ss->w);
if (rc != NCM_OK) return rc;
}
if (stream_src_open_stream_at(&ss->src, ncm_cache_writer_tmp_path(ss->w),
@@ -576,8 +602,8 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
ss->total = (u.size > 0) ? u.size : 0;
/* §NEW 任务1(SIGSEGV 定位):下载链路诊断日志——起播即留档,断流/崩溃后从 log 回溯。 */
meow_log(MEOW_LOG_INFO,
"play: dl start id=%s lv=%s eff=%s size=%lld resume=%s url=%.56s",
id, ncm_api_level_key(want), ncm_api_level_key(eff >= 0 ? eff : want),
"play: dl start id=%s req=%s actual=%s size=%lld resume=%s url=%.56s",
id, ncm_api_level_key(want), ncm_api_level_key(actual),
(long long)u.size, resuming ? "yes" : "no", u.url);
/* 上限:Hi-Res flac 单曲可达数百 MB,flac 上限 1GB;mp3 维持 64MB。
* size 可信时按 size 限幅(防超额),未知按格式默认上限(续传 body ≤ size 亦安全)。 */
@@ -645,74 +671,60 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
char key[64];
audio_cache_key(key, sizeof key, id, g_level);
/* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */
/* 已缓存列表点播(NCM_QUEUE_CACHE):用条目**真实键**(id+文件名档位)与实际扩展名直接
* 命中,不走用户当前档键——否则别的档位/降级产物条目会 miss 而被重下(与「点缓存就该放缓存」
* 相悖,也会白耗流量)。条目已被删/被 LRU 淘汰 → NOENT(不重下,UI 报错)。 */
if (appstate_get_queue() == NCM_QUEUE_CACHE) {
ncm_cache_item_t it;
if (appstate_cache_item(idx, &it) == NCM_OK) {
char ck[48];
if (it.level[0]) snprintf(ck, sizeof ck, "%s_%s", it.id, it.level);
else ncm_strlcpy(ck, it.id, sizeof ck);
if (ncm_cache_lookup(&g_st.ctx.cache, ck, it.ext, path, psz) == NCM_OK) {
struct stat st;
if (stat(path, &st) == 0 &&
stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) == 0) {
ss->total = (int64_t)st.st_size;
*bytes = ss->total;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = it.actual_level; /* 实际档(非请求档),UI 标注用 */
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
}
}
return NCM_ERR_NOENT;
}
/* 缓存命中 → 静态 stream_src。查找顺序(档位下探在 ncm_cache_lookup 内):
* ① 请求档精确(flac 优先于 mp3)
* ② 请求档以下逐档下探——缓存名已按**实际档**命名,hires 请求命中已存的
* song_<id>_exhigh.mp3(降级产物不重下)
* ③ 历史无档位键 song_<id>.mp3 / 双点名(老卡存量:读可用,**不强制迁移**) */
int hit = 0;
if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK ||
ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, psz) == NCM_OK) {
ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, psz) == NCM_OK ||
ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
hit = 1;
} else if (ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
/* §19 前存量:无档位后缀的 song_<id>[.]mp3(含历史双点名 song_<id>..mp3)。
* §NEW 任务5:命中即**迁移重命名**到当前档位键 song_<id>_<level>.mp3(下方 hit==2
* 分支做 rename,不重下),之后不再回退旧键。 */
hit = 2;
}
if (hit) {
struct stat st;
if (stat(path, &st) != 0) return NCM_ERR_NOENT;
if (hit == 2) {
/* §NEW 任务5:存量旧键(无档位后缀 song_<id>.mp3 / 历史双点 song_<id>..mp3)
* 迁移重命名到当前档位键 song_<id>_<g_level>.<ext>(**不重下**,仅改名)。
* 目标已存在(该档已有正式条目)则删旧;迁移失败回退旧路径照常播。 */
char nk[64], np[512];
audio_cache_key(nk, sizeof nk, id, g_level);
const char *dot = strrchr(path, '.');
const char *ext = dot ? dot + 1 : "mp3";
if (ncm_cache_path(&g_st.ctx.cache, nk, ext, np, sizeof np) == NCM_OK) {
struct stat nst;
if (stat(np, &nst) == 0) unlink(path); /* 目标已在(或并发建成):删旧双点/旧名 */
else rename(path, np);
ncm_strlcpy(path, np, psz); /* 迁移后即用新路径(无论 rename 成败,
* 覆盖到同一目标;失败则下次会再试) */
if (stat(np, &st) != 0) return NCM_ERR_NOENT;
}
}
/* §NEW 任务2:静默降级时代产物修复(用户实锤:hires 档键下存着 mp3,播放命中旧档)。
* 用户档 > EXHIGH(无损/更高)且缓存实体是 .mp3 = flac 门禁前把 exhigh 的 mp3 写进了
* 无损域键(当年降级产物归用户档键)。迁移重命名到 exhigh 键 song_<id>_exhigh.mp3
* (选「极高」档时仍可用;目标已存在则删旧),然后本档视为未命中走下载——
* 311 的 flac 门禁会拿到真无损或正确降级。放在 hit==2 迁移块之后、静态流打开之前。 */
const char *mdot = strrchr(path, '.');
if (g_level > NCM_LEVEL_EXHIGH && mdot && ncm_streq(mdot, ".mp3")) {
char kh[64], ph[512];
audio_cache_key(kh, sizeof kh, id, NCM_LEVEL_EXHIGH);
if (ncm_cache_path(&g_st.ctx.cache, kh, "mp3", ph, sizeof ph) == NCM_OK) {
struct stat hst;
if (stat(ph, &hst) == 0) unlink(path); /* exhigh 目标已在:删旧(降级产物) */
else rename(path, ph);
meow_log(MEOW_LOG_INFO,
"play: stale-degraded id=%s lv=%s entity=mp3 -> exhigh key; redownload",
id, ncm_api_level_key(g_level));
hit = 0; /* 本档不符 → 走下载(flac 门禁取真档) */
}
}
if (hit) {
if (stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) != 0)
return NCM_ERR_IO;
ss->total = (int64_t)st.st_size;
*bytes = ss->total;
/* 生效档推断(缓存键=用户档,降级产物归用户档键,无法精确还原):
* mp3 实体 + 用户档 > 极高 → 必为降级产物,标 EXHIGH;其余按用户档显示。
* (flac 实体视为与用户档一致;hires→lossless 档内降级会高估显示,已知取舍。)
* 注:mp3 + 用户档 > 极高 的组合已被上方迁移块拦截走下载,此处仅存防御。 */
const char *dot = strrchr(path, '.');
int eff = g_level;
if (dot && ncm_streq(dot, ".mp3") && g_level > NCM_LEVEL_EXHIGH)
eff = NCM_LEVEL_EXHIGH;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff;
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
/* 生效/显示档:新命名文件名即实际档;旧命名(请求档)向下钳——mp3 实体不可能高于
* 极高,故旧 song_<id>_hires.mp3 标注 EXHIGH;旧无档位键条目按用户档同规则钳。
* 只改显示不改文件名(老卡文件继续可播/可删/可淘汰,免迁移)。 */
const char *dot = strrchr(path, '.');
int eff = ncm_cache_level_from_path(path);
if (eff < 0) eff = g_level;
if (dot && ncm_streq(dot, ".mp3") && eff > NCM_LEVEL_EXHIGH) eff = NCM_LEVEL_EXHIGH;
if (stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) != 0)
return NCM_ERR_IO;
ss->total = (int64_t)st.st_size;
*bytes = ss->total;
pthread_mutex_lock(&g_play.mu);
g_play.eff_level = eff;
pthread_mutex_unlock(&g_play.mu);
return NCM_OK;
}
/* fee 门禁:VIP 才能听(1=VIP 歌曲 / 4=付费数字专辑)且非会员 → 不请求 url。
@@ -736,7 +748,7 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
if (g_app_quit || !still_intent(idx, seq)) return NCM_ERR_IO;
}
int real_err = NCM_ERR_NET;
int rc = song_download_attempt(s, idx, seq, key, want, ss, path, psz, bytes, &real_err);
int rc = song_download_attempt(s, idx, seq, want, ss, path, psz, bytes, &real_err);
if (rc == NCM_OK) return NCM_OK;
if (rc == DL_RETRYABLE) { last_rc = real_err; continue; } /* 重试整首 */
return rc; /* DL_ABORTED / 硬错误:不重试 */
@@ -1032,10 +1044,18 @@ static int g_lyr_thr_on;
static volatile int g_lyr_state; /* 0=空闲/未拉 1=加载中 2=已拉完 */
static char g_lyr_song_id[24]; /* 歌词归属歌曲 id */
/* 歌词拉取失败后的重试退避(ms,指数翻倍封顶 OS):掉线瞬间失败的请求**不**写死「无歌词」,
* 交回退避重试;网络恢复后下一轮到点即重拉成功(真机症状:掉线一会儿再重连后恒「暂无歌词」)。 */
#define LYRIC_RETRY_BASE_MS 1000
#define LYRIC_RETRY_MAX_MS (30 * 1000)
static void *lyric_worker(void *arg)
{
(void)arg;
char last_id[24] = "";
int last_ok = 1; /* last_id 对应结果是否成功(成功不重拉;失败记退避待重试) */
int retry_delay = 0; /* 失败后逐次翻倍的退避基值(ms) */
int64_t retry_at = 0; /* 单调时刻:下次重试;0=无待重试 */
while (!g_app_quit) {
usleep(300 * 1000);
char id[24];
@@ -1051,14 +1071,28 @@ static void *lyric_worker(void *arg)
g_lyr_state = 0;
pthread_mutex_unlock(&g_lyrmu);
last_id[0] = 0;
last_ok = 1;
retry_delay = 0;
retry_at = 0;
continue;
}
if (!id[0]) continue;
if (strcmp(id, last_id) == 0) continue; /* 同一曲已拉/在拉 */
if (meow_cachegov_prefetch_paused()) continue; /* page-cache 治理(§2.3/§3):暂停歌词预取
* (A 回退硬暂停 OR svc_perf C Cached 降载),
* 不置 last_id → 降载恢复后下轮再拉。 */
ncm_strlcpy(last_id, id, sizeof last_id);
int is_new = (strcmp(id, last_id) != 0);
if (is_new) {
if (meow_cachegov_prefetch_paused()) continue; /* page-cache 治理(§2.3/§3):暂停歌词预取
* (A 回退硬暂停 OR svc_perf C Cached 降载),
* 不置 last_id → 降载恢复后下轮再拉。 */
ncm_strlcpy(last_id, id, sizeof last_id);
last_ok = 0;
retry_delay = 0;
retry_at = 0;
} else {
if (last_ok) continue; /* 同一曲已成功拉完(含 NCM_OK 空词=真无词),不重拉 */
if (meow_cachegov_prefetch_paused()) continue;
if (retry_at > ncm_monotonic_ms()) continue; /* 失败退避未到 */
/* 到点重试:保留 retry_delay 以便失败时继续翻倍 */
}
lyric_clear();
pthread_mutex_lock(&g_lyrmu);
g_lyr_state = 1; /* 加载中 */
@@ -1068,22 +1102,32 @@ static void *lyric_worker(void *arg)
char *tl = (char *)malloc(32 * 1024);
if (!lrc || !tl) {
free(lrc); free(tl);
retry_delay = retry_delay ? (retry_delay * 2) : LYRIC_RETRY_BASE_MS;
if (retry_delay > LYRIC_RETRY_MAX_MS) retry_delay = LYRIC_RETRY_MAX_MS;
retry_at = ncm_monotonic_ms() + retry_delay;
pthread_mutex_lock(&g_lyrmu); g_lyr_state = 0; pthread_mutex_unlock(&g_lyrmu);
continue;
}
lrc[0] = tl[0] = 0; /* 默认空串(fetch 失败/空词时上报清词,防会话留旧曲歌词) */
int rc = ncm_api_lyric(g_st.ctx.device_id, g_st.ctx.cookie, id, lrc, 64 * 1024, tl, 32 * 1024);
if (rc == NCM_OK) {
last_ok = 1; /* 真正结果:空词(NCM_OK 无词也算「已拉完」) */
retry_delay = 0;
retry_at = 0;
lyric_parse(lrc, tl);
ipc_media_lyric_report(lrc, tl); /* 批3:上报 live 歌词到媒体会话 */
} else {
/* 拉取失败(网络/HTTP/会话):不写死「无词」,交回退避重试;UI 呈「加载中」而非「暂无歌词」 */
retry_delay = retry_delay ? (retry_delay * 2) : LYRIC_RETRY_BASE_MS;
if (retry_delay > LYRIC_RETRY_MAX_MS) retry_delay = LYRIC_RETRY_MAX_MS;
retry_at = ncm_monotonic_ms() + retry_delay;
ipc_media_lyric_report("", ""); /* 无词/拉取失败:清 media session 该曲歌词 */
}
free(lrc); free(tl);
pthread_mutex_lock(&g_lyrmu);
ncm_strlcpy(g_lyr_song_id, id, sizeof g_lyr_song_id);
g_lyr_state = 2; /* 拉完(可能 0 行=无词) */
g_lyr_state = last_ok ? 2 : 0; /* 2=已拉完(可 0 行=无词);0=失败待重试(UI 呈加载中) */
pthread_mutex_unlock(&g_lyrmu);
}
return NULL;
@@ -0,0 +1,174 @@
/* app/test/test_first_row_title_render.c — 宿主渲染回归(M12):列表首行标题必须真正画出来。
*
* 目的:在宿主上用**真实 SDK listview 双行渲染**复现/验证真机根因,而不只是验几何公式。
* 真机字体(source_han_serif 20px,font.idx: ascent 24 + descent 6)行高 = 30,
* 双行文本块 = 2*30 + 2 = 62。旧固定行高 56 < 62 → SDK 的 2 行分支
* ty = 行顶 + (item - block)/2 = 行顶 - 3
* 而标题只在 `ty >= 视口顶` 时才画 → 第一行(行顶 == 视口顶)标题被整条跳过、副标题仍在。
*
* 宿主 5x7 字体的行高是 14(gfx 回退),照原样渲染永远复现不了。故用链接期
* `-Wl,--wrap=meow_ui_text_height` 把行高替换成真机实测的 30,驱动同一份 SDK 代码:
* A 旧行高 56 → 首行标题带无文字像素(bug 形态)
* B ncm_row_h_for_line_h(30) = 70 → 首行标题带/副标题带都有文字像素(修复形态)
* 断言直接读回 MEOW_UI_DUMP 写出的 PPM(按像素计数)。
*
* 编译/运行(先跑 build_ncm_host.sh 产出 out/host/sdk_*.o):
* gcc -O2 -Wall -Wextra -DMEOW_UI_HOST_TEST -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -Ivendor/jsmn -Ivendor/mbedtls/include -Ivendor/minimp3 -Ivendor/dr_flac \
* app/test/test_first_row_title_render.c out/host/sdk_*.o \
* -Wl,--wrap=meow_ui_text_height -lz -lpthread -lm -o test_first_row_title_render
* ./test_first_row_title_render # 产物落 $TMPDIR/first_row_*.ppm
*
* 断言:
* ① 行高替换生效(meow_ui_text_height(20) == 30);
* ② A:首行标题带零文字像素、副标题带有 → 复现「只有副标题」;
* ③ B:首行标题带与副标题带都有像素 → 修复后首行标题正常;
* ④ A/B 副标题带像素数都不为 0(副标题路径未受影响)。
*/
#include "../ncm_app_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* --wrap 的替换实现:真机 source_han_serif 20px 实测行高(font.idx ascent+descent)。 */
int __wrap_meow_ui_text_height(int size)
{
(void)size;
return 30;
}
#define LV_X 16
#define LV_Y 40
#define LV_W 448
#define LV_H 300
static int src2(int idx, meow_list_item *out, void *ctx)
{
(void)ctx;
static char title[32], sub[32];
if (idx < 0 || idx >= 5) return 0;
snprintf(title, sizeof title, "T%d", idx + 1);
snprintf(sub, sizeof sub, "S%d", idx + 1);
memset(out, 0, sizeof *out);
out->icon_kind = MEOW_LIST_ICON_NONE;
out->title = title;
out->subtitle = sub;
out->right = MEOW_LIST_RIGHT_NONE;
out->enabled = 1;
return 1;
}
/* ---- 极简 PPM(P6) 读取 + 区域文字像素计数 ---- */
static unsigned char *ppm_load(const char *path, int *w, int *h)
{
FILE *f = fopen(path, "rb");
if (!f) { fprintf(stderr, "open %s failed\n", path); return NULL; }
char magic[3] = {0};
int maxv = 0;
if (fscanf(f, "%2s %d %d %d", magic, w, h, &maxv) != 4 ||
strcmp(magic, "P6") != 0 || *w <= 0 || *h <= 0) {
fclose(f); fprintf(stderr, "bad ppm %s\n", path); return NULL;
}
if (fgetc(f) < 0) { fclose(f); fprintf(stderr, "truncated ppm %s\n", path); return NULL; } /* 单个空白 */
size_t n = (size_t)*w * (size_t)*h * 3u;
unsigned char *buf = (unsigned char *)malloc(n);
if (!buf || fread(buf, 1, n, f) != n) { free(buf); fclose(f); return NULL; }
fclose(f);
return buf;
}
/* 区域内「与页底色不同」的像素数。页底色取 (470,700)(远离列表控件)。 */
static int ink_count(const unsigned char *img, int w, int x0, int y0, int x1, int y1)
{
const unsigned char *bg = img + ((size_t)700 * (size_t)w + 470u) * 3u;
int n = 0;
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++) {
const unsigned char *p = img + ((size_t)y * (size_t)w + (size_t)x) * 3u;
int d = abs((int)p[0] - (int)bg[0]) + abs((int)p[1] - (int)bg[1]) +
abs((int)p[2] - (int)bg[2]);
if (d > 24) n++;
}
return n;
}
/* 造一个双行列表(容器=关、无图标/右尾件)并整帧绘制(paint_full 内部 flush → dump PPM)。 */
static void render_once(int item)
{
meow_widget *lv = meow_listview_create(LV_X, LV_Y, LV_W, LV_H, 5,
src2, NULL, MEOW_LIST_SEL_NONE);
meow_listview_set_container(lv, 0);
meow_listview_set_item_renderer(lv, 2, 0, 0);
meow_listview_set_item_h(lv, item);
meow_listview_set_count(lv, 5);
meow_ui_set_page_bg(meow_theme_get()->bg);
meow_ui_paint_full(); /* 画 + meow_ui_flush → MEOW_UI_DUMP 落 PPM */
meow_widget_free(lv);
}
static char g_dump[256];
int main(void)
{
const char *td = getenv("TMPDIR");
if (!td || !td[0]) td = "/tmp";
snprintf(g_dump, sizeof g_dump, "%s/first_row.ppm", td);
setenv("MEOW_UI_DUMP", g_dump, 1);
meow_ui_opts o = { APP_W, APP_H };
if (meow_ui_init(&o) != 0) { fprintf(stderr, "init failed\n"); return 1; }
meow_theme_set_mode(MEOW_THEME_DARK);
int fail = 0;
if (meow_ui_text_height(20) != 30) {
printf(" FAIL 行高替换未生效(expected 30,got %d)\n", meow_ui_text_height(20));
fail = 1;
}
const int lh = 30;
const int block = 2 * lh + NCM_ROW_SUB_GAP; /* 62 */
const int fixed = ncm_row_h_for_line_h(lh); /* 70 */
const int ty_legacy = (APP_ROW_H - block) / 2; /* -3 */
const int ty_fixed = (fixed - block) / 2; /* +4 */
int n_title_a = 0, n_sub_a = 0, n_title_b = 0, n_sub_b = 0;
/* A:旧固定行高 56(bug 形态)。标题带取「文本行顶 ∩ 控件视口」(ty<0 时从 0 起),
* 这样「被裁剪守卫整条跳过」与「只画出上半截」也能区分开。 */
render_once(APP_ROW_H);
{
int pw = 0, ph = 0;
unsigned char *img = ppm_load(g_dump, &pw, &ph);
if (!img) return 1;
int ty0 = (ty_legacy > 0) ? ty_legacy : 0;
n_title_a = ink_count(img, pw, LV_X + 16, LV_Y + ty0,
LV_X + 16 + 40, LV_Y + ty_legacy + lh);
n_sub_a = ink_count(img, pw, LV_X + 16, LV_Y + ty_legacy + lh + NCM_ROW_SUB_GAP,
LV_X + 16 + 40, LV_Y + ty_legacy + 2 * lh + NCM_ROW_SUB_GAP);
free(img);
}
/* B:修复后的行高(ncm_row_h_for_line_h) */
render_once(fixed);
{
int pw = 0, ph = 0;
unsigned char *img = ppm_load(g_dump, &pw, &ph);
if (!img) return 1;
n_title_b = ink_count(img, pw, LV_X + 16, LV_Y + ty_fixed,
LV_X + 16 + 40, LV_Y + ty_fixed + lh);
n_sub_b = ink_count(img, pw, LV_X + 16, LV_Y + ty_fixed + lh + NCM_ROW_SUB_GAP,
LV_X + 16 + 40, LV_Y + ty_fixed + 2 * lh + NCM_ROW_SUB_GAP);
free(img);
}
printf("legacy item=%d ty=%d: title_ink=%d sub_ink=%d\n", APP_ROW_H, ty_legacy, n_title_a, n_sub_a);
printf("fixed item=%d ty=%d: title_ink=%d sub_ink=%d\n", fixed, ty_fixed, n_title_b, n_sub_b);
if (!(n_title_a == 0 && n_sub_a > 0)) fail = 1; /* ② 旧行高首行标题被跳过(bug 复现) */
if (!(n_title_b > 0 && n_sub_b > 0)) fail = 1; /* ③ 修复后首行标题/副标题都在 */
printf("%s: first-row title render (legacy skipped=%d, fixed drawn=%d)\n",
fail ? "FAIL" : "PASS", n_title_a == 0, n_title_b > 0);
return fail ? 1 : 0;
}
@@ -9,10 +9,12 @@
* test_meow_row_gap.c -o test_meow_row_gap && ./test_meow_row_gap
*
* 断言集(对应「相邻 item 顶之间 = item 高 + gap」的可见形式):
* ① 相邻卡顶间距 = APP_ROW_H(= 卡高 + gap;SDK 行距不变);
* ① 相邻卡顶间距 = item 高(= 卡高 + gap;SDK 行距不变);
* ② 卡片间可见外距 = NCM_ROW_GAP;
* ③ 卡在其行高内上下对称内缩 → SDK 文本(按 APP_ROW_H 垂直居中)落卡中心;
* ③ 卡在其行高内上下对称内缩 → SDK 文本(按行高垂直居中)落卡中心;
* ④ 滚动偏移只整体平移,不改变上述间距/对称。
* 行高在这里固定用 APP_ROW_H(几何对任意 item 成立;M12 起真机行高由 ncm_row_h() 按字体
* 行高派生,文本块必须装得下,见 test_meow_row_text_fit.c)。
*/
#include "../ncm_app_internal.h" /* APP_ROW_H / NCM_ROW_GAP / ncm_row_card_y / ncm_row_card_h */
#include <stdio.h>
@@ -20,7 +22,7 @@
int main(void)
{
const int item = APP_ROW_H; /* 行距(SDK listview item_h,不变) */
const int item = APP_ROW_H; /* 几何取固定行高(对任意 item 成立) */
const int gap = NCM_ROW_GAP;
const int list_y = 100;
@@ -0,0 +1,93 @@
/* app/test/test_meow_row_text_fit.c — 宿主回归(M12):两行列表行高必须容纳行文本块。
*
* 背景(真机根因):SDK meow_listview 的双行渲染(sdk/meow_ui_widget.c 的 2 行分支)把
* block = 2 * meow_ui_text_height(font_body) + 2 (标题行 + 2px 行距 + 副标题行)
* 在行内垂直居中:ty = 行顶 + (item - block) / 2;且标题只在 `ty >= 视口顶` 时才画(越界钳制)。
* 行高 item < block 时 ty < 行顶 → **第一行(行顶 == 视口顶)标题被整条跳过**、副标题仍在,
* 即用户报的「歌单列表/歌曲列表第 1 行只有副标题,名字空白」。
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 → 行高 30)block = 62,旧固定
* 行高 56 → ty = 行顶 - 3。
*
* 本测试只验 app 侧行高派生(ncm_row_h_for_line_h,纯整数、不链 SDK):
* 行高 = max(APP_ROW_H, block + NCM_ROW_GAP)(偶数)→ 卡高 == 文本块,文本完整落在卡内。
*
* 编译/运行(同 test_meow_row_gap.c,需仓库根 ROOT 下的 SDK 头):
* gcc -O2 -Wall -Wextra -I. -I../sdk -I../meow_launcherd -Icore \
* -Ivendor/mbedtls/include -Ivendor/jsmn -Ivendor/qrcodegen/c \
* -Ivendor/minimp3 -Ivendor/dr_flac \
* test_meow_row_text_fit.c -o test_meow_row_text_fit && ./test_meow_row_text_fit
*
* 断言矩阵 = 真机已装字库 20px 面实测行高(font.idx 的 ascent+descent):
* source_han_serif 30(当前生效)、misans/default 27、google_sans/meslo 26、
* source_han_sans 21;14 = 宿主 5x7 回退。逐项断言:
* ① block ≤ item(否则居中偏移为负)
* ② 首行 ty ≥ 行顶(绝对:ty_abs ≥ 视口顶)→ 标题不被裁剪守卫跳过
* ③ 副标题底边 sy + 行高 ≤ item(副标题不越行)
* ④ 卡高(item - NCM_ROW_GAP)≥ block(两行文本全落在圆角卡内)
* ⑤ item ≥ APP_ROW_H 下限且为偶数(卡上下对称内缩 gap/2)
* ⑥ 旧固定行高 56 在行高 30 下 ty < 0(bug 形态复现,防止再写死 56)
*/
#include "../ncm_app_internal.h"
#include <stdio.h>
static int g_checks, g_fail;
static void chk(int cond, int line_h, const char *what)
{
g_checks++;
if (!cond) {
g_fail++;
printf(" FAIL lh=%d: %s\n", line_h, what);
}
}
int main(void)
{
static const int lh_all[] = { 14, 21, 26, 27, 30 };
const int n = (int)(sizeof lh_all / sizeof lh_all[0]);
const int list_y = 120; /* 任意列表顶(只验相对几何) */
for (int k = 0; k < n; k++) {
int lh = lh_all[k];
int item = ncm_row_h_for_line_h(lh);
int block = 2 * lh + NCM_ROW_SUB_GAP;
int ty = (item - block) / 2; /* SDK 双行文本块居中偏移 */
int sy = ty + lh + NCM_ROW_SUB_GAP; /* 副标题行顶 */
int card_h = ncm_row_card_h(item);
printf("lh=%2d item=%3d block=%3d ty=%d sy=%d card_h=%3d\n",
lh, item, block, ty, sy, card_h);
chk(block <= item, lh, "文本块必须 <= 行高(否则首行标题被视口顶裁剪)");
chk(ty >= 0, lh, "首行标题 y 不得高于行顶/视口顶");
chk(sy + lh <= item, lh, "副标题底边仍在行内");
chk(card_h >= block, lh, "两行文本块必须完整落在圆角卡内");
chk(item >= APP_ROW_H, lh, "行高不得小于 APP_ROW_H 下限");
chk((item & 1) == 0, lh, "行高必须偶数(卡上下对称内缩 gap/2)");
/* 首行(scroll=0 → 行顶 == 视口顶)的绝对坐标 */
int ty_abs = list_y + ty;
chk(ty_abs >= list_y, lh, "首行标题绝对 y 不得被视口顶裁掉");
chk(ty_abs + lh <= list_y + item, lh, "首行标题底边在行内");
/* 卡在行内对称 → 卡中心 == 行中心 */
int cy = ncm_row_card_y(item, 0, 0, list_y);
chk(cy + card_h / 2 == list_y + item / 2, lh, "卡中心 == 行中心");
}
/* 行高随字体行高单调不减 */
chk(ncm_row_h_for_line_h(30) >= ncm_row_h_for_line_h(26), 30, "行高随字体行高单调不减");
/* ⑥ 回归哨兵:旧固定行高 56 + 当前默认字体(行高 30)就是 bug 形态 */
{
const int lh = 30;
int block = 2 * lh + NCM_ROW_SUB_GAP; /* 62 */
int ty_legacy = (APP_ROW_H - block) / 2; /* -3 */
chk(ty_legacy < 0, lh, "旧行高 56 + 行高 30 → ty<0(首行标题被跳过,bug 复现)");
chk(ncm_row_h_for_line_h(lh) > APP_ROW_H, lh, "默认字体下行高必须大于旧值 56");
}
printf("%s: %d checks, %d fail (font line-height matrix: %d)\n",
g_fail ? "FAIL" : "PASS", g_checks, g_fail, n);
return g_fail ? 1 : 0;
}
+36 -2
View File
@@ -72,6 +72,37 @@ static void mock_lyric(void)
lyric_parse(lrc, tl);
}
/* M5:已缓存列表页 mock —— 造 3 条 songs/ 缓存(mp3 帧 + 嵌入 ID3v2.3),再同步扫描进快照。 */
static void mock_cache(void)
{
static const char *names[] = { "Song Cached One", "Song Cached Two", "Song Cached Three" };
static const char *arts[] = { "Artist X", "Artist Y", "Artist Z" };
static const char *lvks[] = { "exhigh", "hires", "lossless" };
ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir);
for (int i = 0; i < 3; i++) {
char id[24], key[48];
unsigned char fr[4096];
ncm_cache_writer_t *w;
snprintf(id, sizeof id, "%ld", 5000L + i);
snprintf(key, sizeof key, "%s_%s", id, lvks[i]);
for (size_t k = 0; k < sizeof fr; k++) fr[k] = (unsigned char)(0x20 + (k * 7));
fr[0] = 0xFF; fr[1] = 0xFB; fr[2] = 0xE0; fr[3] = 0x00; /* 320kbps / 44.1kHz */
if (ncm_cache_open(&g_st.ctx.cache, key, "mp3", &w) != NCM_OK) continue;
ncm_cache_write(w, fr, sizeof fr);
if (ncm_cache_commit(w) != NCM_OK) continue;
char path[NCM_CACHE_DIR_MAX + 64];
if (ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, sizeof path) == NCM_OK) {
ncm_tag_meta_t t;
memset(&t, 0, sizeof t);
snprintf(t.title, sizeof t.title, "%s", names[i]);
snprintf(t.artist, sizeof t.artist, "%s", arts[i]);
snprintf(t.album, sizeof t.album, "Album %d", i + 1);
ncm_tag_embed_file(path, "mp3", &t);
}
}
appstate_cache_list_load();
}
static void mock_qr(void)
{
g_st.qr_size = 29;
@@ -137,6 +168,7 @@ static int page_id(const char *name)
if (strcmp(name, "searchtap") == 0) return ACT_SEARCH; /* §23 误触抑制自检 */
if (strcmp(name, "modetest") == 0) return ACT_PLAYER; /* §NEW 播放模式三态逻辑自检 */
if (strcmp(name, "queuetest") == 0) return ACT_QUEUE; /* §NEW 播放队列页/点播语义自检 */
if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) return ACT_CACHE; /* M5 */
return -1;
}
@@ -268,8 +300,7 @@ int main(int argc, char **argv)
appstate_search_history_add("Jay \u5553\u8868");
appstate_search_history_add("lofi beat");
appstate_search_history_add("Jay \u5553\u8868"); /* 去重 → 仍 3 条 */
} else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) {
/* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */
} else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) { /* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */
mock_playlists(); mock_songs();
pthread_mutex_lock(&g_play.mu);
g_play.state = NCM_PC_PLAYING;
@@ -290,6 +321,8 @@ int main(int argc, char **argv)
g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */
g_play.play_song_valid = 1;
pthread_mutex_unlock(&g_play.mu);
} else if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) {
mock_cache(); /* M5:假缓存池 + 文件内 tag + 快照扫描 */
} else {
mock_playlists();
}
@@ -301,6 +334,7 @@ int main(int argc, char **argv)
strcmp(name, "barhit") == 0 || strcmp(name, "covswitch") == 0) v = playlist_view_create();
else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr/modetest */
else if (page_id(name) == ACT_QUEUE) v = queue_view_create(); /* §NEW 播放队列页 */
else if (page_id(name) == ACT_CACHE) v = cache_view_create(); /* M5 已缓存列表页 */
else if (strcmp(name, "settings") == 0) v = settings_view_create();
else if (strcmp(name, "search") == 0 || strcmp(name, "searchhist") == 0 ||
strcmp(name, "searchpl") == 0 || strcmp(name, "searchtap") == 0) v = search_view_create();
+415
View File
@@ -0,0 +1,415 @@
/* ui/cache.c — 已缓存列表页(ACT_CACHE,首页顶栏「已缓存」钮进入)。
*
* 数据源:songs/ 音频池(键=歌曲ID+请求档位,NCM_CACHE_DESIGN §2.5)。扫描在 worker:
* ncm_cache_scan(读目录 + 文件头 tag + 音频参数探测)→ appstate 快照;
* UI 线程只读快照(零磁盘 IO),列表由 meow_listview 虚拟化。
* 行:歌名 / 歌手 · **实际档位** · 大小。**档位显示实际档而非请求档**:缓存名里的档位段只是
* 请求档(真机存在 song_<id>_hires.mp3 = 无损请求被降级链落到 exhigh mp3),实际档由
* 文件头(FLAC bps/采样率、MP3 首帧码率)推断。
* 交互:点行=点播(NCM_QUEUE_CACHE,上/下一首在缓存列表内循环);行右「删除」=单曲删除;
* 顶栏「清空」=清空缓存池(确认框,与设置页「清理缓存」同语义)。
* 主题/控件惯例同 queue.c:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮。
* 左缘返回=弹回首页(app.c)。
*/
#include "../ncm_app_internal.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
typedef struct { meow_view *view; int active; } cache_ctrl;
static cache_ctrl g_cc;
static meow_widget *g_tb, *g_list;
static ncm_rect_t r_cont, r_list;
static int g_bg_id, g_draw_id, g_empty_id, g_footer_id;
static int g_press_row, g_press_delta, g_dragging, g_drag_last_y;
static int g_last_count;
static int g_item_h = APP_ROW_H; /* 两行列表行高(M12:按字体行高派生,见 ncm_row_h) */
/* 扫描 worker(磁盘 IO 不进 UI 线程;gen 计数做完成信号) */
static pthread_mutex_t g_cmu = PTHREAD_MUTEX_INITIALIZER;
static pthread_t g_cthr;
static int g_cthr_on;
static volatile int g_cgen, g_cacked;
static volatile int g_cloading;
static volatile int g_cerr;
static void c_drag_reset(void)
{
g_press_row = -1; g_press_delta = 0; g_dragging = 0; g_drag_last_y = 0;
}
/* 字节数 → "12.3 MB"(整数运算,不引浮点 printf;MIPS 软浮点省体积)。 */
static void fmt_size(uint64_t n, char *out, size_t sz)
{
if (n >= (1024ULL * 1024ULL * 1024ULL)) {
uint64_t t = (n * 10ULL + 512ULL * 1024ULL * 1024ULL) / (1024ULL * 1024ULL * 1024ULL);
snprintf(out, sz, "%llu.%llu GB", (unsigned long long)(t / 10), (unsigned long long)(t % 10));
} else if (n >= 1024ULL * 1024ULL) {
uint64_t t = (n * 10ULL + 512ULL * 1024ULL) / (1024ULL * 1024ULL);
snprintf(out, sz, "%llu.%llu MB", (unsigned long long)(t / 10), (unsigned long long)(t % 10));
} else {
snprintf(out, sz, "%llu KB", (unsigned long long)(n / 1024ULL));
}
}
static const char *level_label(int lv)
{
return (lv >= 0) ? ncm_level_name(lv) : "未知档";
}
/* 列表数据源(逐行快照读取,零 IO)。 */
static int c_src2(int idx, meow_list_item *out, void *ctx)
{
(void)ctx;
static ncm_cache_item_t it;
static char sub[192];
static char title[100];
char sz[24];
if (appstate_cache_item(idx, &it) != NCM_OK) return 0;
fmt_size(it.size, sz, sizeof sz);
snprintf(sub, sizeof sub, "%s · %s · %s",
it.artist[0] ? it.artist : "-", level_label(it.actual_level), sz);
snprintf(title, sizeof title, "%s", it.name[0] ? it.name : "(未知歌曲)");
out->icon_kind = MEOW_LIST_ICON_NONE; /* 图标槽由红 overlays 画迷你黑胶 */
out->title = title;
out->subtitle = sub;
out->right = MEOW_LIST_RIGHT_NONE; /* 右侧「删除」由 z=60 叠层自绘 */
out->on = 0; out->enabled = 1;
return 1;
}
/* 行背景(z=5,画在 listview 文本之下):圆角卡 + 当前播放行红色高亮。 */
static void c_row_bg(void *ctx)
{
(void)ctx;
if (meow_ui_activity_active() != ACT_CACHE) return;
int cnt = appstate_cache_count();
if (cnt <= 0) return;
const meow_theme *th = meow_theme_get();
const ncm_pal_t *pa = ncm_palette(g_dark);
int item = g_item_h;
int off = meow_listview_scroll(g_list);
int first = off / item, last = (off + r_list.h) / item + 1;
if (last > cnt) last = cnt;
int cur = (appstate_get_queue() == NCM_QUEUE_CACHE) ? player_manager_playing_song() : -1;
for (int i = first; i < last; i++) {
int ry = ncm_row_card_y(item, i, off, r_list.y);
int th2 = ncm_row_card_h(item);
if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue; /* 只画完整落在视口内的行 */
if (i == cur) {
ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, pa->red_ctr);
meow_ui_fill_rect(r_list.x + 2, ry + 4, 4, th2 - 8, pa->red);
} else {
ncm_fill_round(r_list.x, ry, r_list.w, th2, 12, th->surfaceContainer);
}
}
}
/* 主绘制(z=60):迷你黑胶缩略 + 行右「删除」+ 按压红条;文本由 listview z=10 画。 */
static void c_draw(void *ctx)
{
(void)ctx;
if (meow_ui_activity_active() != ACT_CACHE) return;
const meow_theme *th = meow_theme_get();
const ncm_pal_t *pa = ncm_palette(g_dark);
int cnt = appstate_cache_count();
if (cnt <= 0) return;
int item = g_item_h, isz = 24;
int off = meow_listview_scroll(g_list);
int first = off / item, last = (off + r_list.h) / item + 1;
if (last > cnt) last = cnt;
for (int i = first; i < last; i++) {
int ry = ncm_row_card_y(item, i, off, r_list.y);
int th2 = ncm_row_card_h(item);
if (ry < r_list.y || ry + th2 > r_list.y + r_list.h) continue;
int icy = ry + (th2 - isz) / 2, bx = r_list.x + 16;
ncm_fill_round(bx, icy, isz, isz, 6, pa->red);
meow_ui_fill_rect(bx + isz / 2 - 2, icy + isz / 2 - 2, 4, 4, pa->on_red); /* 盘芯 */
/* 行右「删除」(触达区 = 右 48px,见 cache_on_tap) */
meow_ui_text_right(r_list.x + r_list.w - APP_PAD,
ry + (th2 - 18) / 2, "删除", 18, pa->red);
if (g_press_row == i) {
meow_ui_fill_rect(r_list.x, ry, 4, th2, pa->red);
meow_ui_fill_rect_alpha(r_list.x, ry, r_list.w, th2, pa->red, 26);
}
/* 擦除 z=10 的 SDK 行分隔线(现落进卡片间空隙):以页底色填空隙带;不越出列表视口底。 */
int gy = ry + th2, gh = NCM_ROW_GAP;
if (gy + gh > r_list.y + r_list.h) gh = r_list.y + r_list.h - gy;
if (gh > 0) meow_ui_fill_rect(r_list.x, gy, r_list.w, gh, th->bg);
}
}
static void c_draw_empty(void *ctx)
{
(void)ctx;
if (meow_ui_activity_active() != ACT_CACHE) return;
if (appstate_cache_count() != 0) return;
if (g_cloading) { ncm_draw_empty(r_list, "LOADING", "正在扫描缓存..."); return; }
if (g_cerr) { ncm_draw_empty(r_list, "ERROR", "缓存目录读取失败"); return; }
ncm_draw_empty(r_list, "暂无缓存", "播放过的歌曲会自动缓存");
}
static void c_draw_footer(void *ctx)
{
(void)ctx;
if (meow_ui_activity_active() != ACT_CACHE) return;
int n = appstate_cache_count();
if (n <= 0) return;
uint64_t total = 0;
ncm_cache_item_t it;
for (int i = 0; i < n; i++)
if (appstate_cache_item(i, &it) == NCM_OK) total += it.size;
char sz[24], foot[128];
fmt_size(total, sz, sizeof sz);
snprintf(foot, sizeof foot, "%d 首 · 合计 %s%s", n, sz,
appstate_is_offline() ? " · 离线模式(可播放)" : "");
ncm_draw_footer(r_cont, foot);
}
static void c_apply(void)
{
if (!g_list) return;
int n = appstate_cache_count();
meow_listview_set_count(g_list, n);
if (n != g_last_count) {
g_last_count = n;
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
const meow_theme *th = meow_theme_get();
meow_ui_set_page_bg(th->bg);
}
static void *cache_worker(void *arg)
{
(void)arg;
int rc = appstate_cache_list_load(); /* 扫描 + 文件内 tag + 旁路补名:全部磁盘 IO */
pthread_mutex_lock(&g_cmu);
g_cerr = (rc < 0) ? rc : 0;
g_cloading = 0;
g_cgen++;
pthread_mutex_unlock(&g_cmu);
return NULL;
}
void cache_reload(void)
{
if (g_host_mock) { /* 宿主渲染:同步扫描(无线程/无延时,截图确定) */
int rc = appstate_cache_list_load();
g_cerr = (rc < 0) ? rc : 0;
g_cloading = 0;
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
return;
}
if (g_cthr_on) return;
g_cloading = 1;
g_cthr_on = 1;
if (ncm_thread_create(&g_cthr, cache_worker, NULL) != 0) {
g_cthr_on = 0; g_cloading = 0;
g_cerr = NCM_ERR_IO;
}
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
/* 单曲删除(行右「删除」):删文件 + 摘除快照项 + 提示。 */
static void cache_delete_row(int row)
{
ncm_cache_item_t it;
if (appstate_cache_item(row, &it) != NCM_OK) return;
char name[96], msg[160];
ncm_strlcpy(name, it.name[0] ? it.name : it.id, sizeof name);
if (appstate_cache_remove(row) == 0) {
snprintf(msg, sizeof msg, "已删除:%s", name);
ncm_snack(msg);
c_apply();
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
} else {
ncm_snack("删除失败");
}
}
/* 清空(顶栏「清空」):确认框(设置页「清理缓存」同语义:删除全部音频 + 封面缓存条目)。 */
static void cache_confirm_clear(void)
{
int n = appstate_cache_count();
if (n <= 0) { ncm_snack("缓存已为空"); return; }
uint64_t total = 0;
ncm_cache_item_t it;
for (int i = 0; i < n; i++)
if (appstate_cache_item(i, &it) == NCM_OK) total += it.size;
char sz[24], msg[192];
fmt_size(total, sz, sizeof sz);
snprintf(msg, sizeof msg, "将删除全部 %d 首已缓存歌曲 (%s) 及封面缓存。\n已播放的歌曲需重新下载。",
n, sz);
meow_dialog *d = meow_dialog_create(MEOW_DLG_CONFIRM, "清空缓存?");
const char *msgs[1];
msgs[0] = msg;
meow_dialog_set_items(d, msgs, 1, -1);
int yes = meow_dialog_confirm(d);
meow_dialog_free(d);
if (!yes) return;
appstate_cache_clear();
ncm_snack("缓存已清理");
c_apply();
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
void cache_open(void)
{
meow_view_page_push(ACT_CACHE);
cache_reload();
c_apply();
meow_ui_paint_full();
}
int cache_is_active(void) { return g_cc.active; }
int cache_on_tap(int x, int y)
{
if (g_tb) {
int ah = meow_topappbar_hit(g_tb, x, y);
if (ah == MEOW_TOPBAR_BACK) { c_drag_reset(); meow_view_page_pop(); return 1; }
if (ah == 0) { c_drag_reset(); cache_confirm_clear(); return 1; } /* 文本动作「清空」 */
}
if (meow_widget_hit(g_list, x, y)) {
int was_drag = (g_press_delta > 10);
int row = (g_press_row >= 0) ? g_press_row : meow_listview_hit(g_list, x, y);
int del = (x >= r_list.x + r_list.w - APP_OP_W);
c_drag_reset();
if (row < 0 || was_drag) return 1;
if (del) { cache_delete_row(row); return 1; }
player_play_cache_song(row);
return 1;
}
return 0;
}
void cache_touch_down(int x, int y)
{
c_drag_reset();
if (appstate_cache_count() <= 0) return;
if (meow_widget_hit(g_list, x, y)) {
int row = meow_listview_hit(g_list, x, y);
if (row >= 0) g_press_row = row;
g_dragging = 1; g_drag_last_y = y;
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
}
void cache_touch_move(int x, int y)
{
(void)x;
if (!g_dragging) return;
if (g_press_row >= 0) {
int d = y - g_drag_last_y; if (d < 0) d = -d;
g_press_delta += d;
if (g_press_delta > 10) g_press_row = -1;
}
int dy = y - g_drag_last_y; g_drag_last_y = y;
if (dy == 0) return;
int before = meow_listview_scroll(g_list);
meow_listview_scroll_by(g_list, -dy);
if (meow_listview_scroll(g_list) != before)
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
void cache_swipe(int dir, int dist)
{
if (dir != MEOW_SWIPE_UP && dir != MEOW_SWIPE_DOWN) return;
if (appstate_cache_count() <= 0) return;
int del = (dir == MEOW_SWIPE_UP) ? dist : -dist;
int before = meow_listview_scroll(g_list);
meow_listview_scroll_by(g_list, del);
if (meow_listview_scroll(g_list) != before)
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
void cache_tick(uint64_t now)
{
(void)now;
chrome_tick();
if (!g_cc.active) return;
if (!g_cthr_on) return;
int gen, acked;
pthread_mutex_lock(&g_cmu);
gen = g_cgen; acked = g_cacked;
pthread_mutex_unlock(&g_cmu);
if (gen == acked) return;
pthread_mutex_lock(&g_cmu);
g_cacked = gen;
pthread_mutex_unlock(&g_cmu);
g_cthr_on = 0;
if (g_cerr) ncm_snack("缓存读取失败");
c_apply();
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
/* 生命周期 */
static void c_page_create(meow_view *v, void *ctx)
{
(void)v;
((cache_ctrl *)ctx)->view = v;
r_cont = ncm_content_rect();
g_item_h = ncm_row_h(); /* M12:行高按当前字体两行文本块派生(首行标题不被视口顶钳掉) */
r_list = NR(16, r_cont.y + 8, r_cont.w - 32,
r_cont.y + r_cont.h - APP_FOOTER_H - (r_cont.y + 8));
if (r_list.h < 0) r_list.h = 0;
meow_ui_activity_push(ACT_CACHE);
g_tb = ncm_topbar_create("已缓存", 1, "清空"); /* 文本动作(动作0)=清空缓存 */
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
c_src2, NULL, MEOW_LIST_SEL_NONE);
meow_listview_set_item_h(g_list, g_item_h);
meow_ui_activity_pop();
meow_widget_set_z(g_list, 10);
g_bg_id = meow_draw_custom_register(5, r_list.x, r_list.y, r_list.w, r_list.h,
c_row_bg, NULL);
g_draw_id = meow_draw_custom_register(60, r_cont.x, r_cont.y, r_cont.w, r_cont.h,
c_draw, NULL);
g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h,
c_draw_empty, NULL);
g_footer_id = meow_draw_custom_register(1, r_cont.x, r_cont.y + r_cont.h - APP_FOOTER_H,
r_cont.w, APP_FOOTER_H, c_draw_footer, NULL);
c_drag_reset();
}
static void c_page_enter(meow_view *v, void *ctx)
{
(void)v;
((cache_ctrl *)ctx)->active = 1;
const meow_theme *th = meow_theme_get();
meow_ui_set_page_bg(th->bg);
g_last_count = -1;
cache_reload(); /* 每次进入重扫(播放/删除后 LRU 变化) */
c_apply();
meow_ui_paint_full();
}
static void c_page_leave(meow_view *v, void *ctx)
{
(void)v;
((cache_ctrl *)ctx)->active = 0;
}
static void c_page_destroy(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
if (g_bg_id) { meow_draw_unregister(g_bg_id); g_bg_id = 0; }
if (g_draw_id) { meow_draw_unregister(g_draw_id); g_draw_id = 0; }
if (g_empty_id) { meow_draw_unregister(g_empty_id); g_empty_id = 0; }
if (g_footer_id) { meow_draw_unregister(g_footer_id); g_footer_id = 0; }
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
if (g_tb) { meow_widget_free(g_tb); g_tb = NULL; }
}
meow_view *cache_view_create(void)
{
static const meow_view_hooks h = {
.on_create = c_page_create,
.on_enter = c_page_enter,
.on_leave = c_page_leave,
.on_destroy = c_page_destroy,
};
return meow_view_create(ACT_CACHE, MEOW_VIEW_ACTIVITY, &h, &g_cc);
}
+119 -27
View File
@@ -1,9 +1,10 @@
/* ui/home.c — 我的音乐页(ACT_HOME,根页,已登录)。
*
* 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+搜索/刷新/设置/回播放页动作)+
* 网易云风格(NCM_UI_DESIGN §4.2):红系 topbar(标题「我的音乐」+刷新/设置/搜索/已缓存/回播放页动作)+
* 个人区卡片(头像/昵称/ID)+ 双页签「我创建的/我收藏的」(meow_tabbar + 各自 meow_listview)+
* 组头红条 + 歌单行(rounded card + 红 mini-vinyl 缩略 + 红色按压竖条)。
* 批6:顶栏「回播放页」按钮(sb_icons 位图,动作3 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。
* 批6:顶栏「回播放页」按钮(sb_icons 位图,动作4 最右)——无活动音频也生效,按下 push(ACT_PLAYER)。
* M5:顶栏「已缓存」按钮(动作3,MEOW_ICON_DOWNLOAD 位图)→ 已缓存列表页 ACT_CACHE。
*
* 品牌红走 ncm_palette(g_dark)(App 无权改 SDK MD3 蓝 primary),代码零硬编码散落:
* 中性色读 meow_theme_get(),红读 ncm_palette()。SDK listview 的 MD3 蓝选中不可改,
@@ -11,8 +12,14 @@
*
* 左缘返回 = 退出 App(app.c)。歌单加载后台 worker(home_worker,保留 REPORT §10.11
* 的异步结构,不回退成同步——P1 启动卡顿已修,勿回退)。
*
* M10:离线自动重试不再用「裸 u64 差值 + 状态抖动清零」的 30s 守卫(真机 meow_ui_time_ms()
* 高 32 位是垃圾 → 守卫恒真 → 断网重试风暴 / META 洪泛 / 列表被反复拽回顶部)。改由
* logic/retry_ctl.c 统一管节奏(core 单调钟、单飞、时钟回退安全、抖动不重置),
* 静默重试不预读盘/不复位滚动/无可见变化不重绘。详见 REPORT §42。
*/
#include "../ncm_app_internal.h"
#include "../logic/retry_ctl.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
@@ -26,6 +33,7 @@ static ncm_rect_t r_cont, r_list, r_tabs, r_group;
static int g_bg_id, g_draw_id, g_empty_id, g_footer_id;
static volatile int g_load_err;
static int g_tab; /* 0=我创建的 1=我收藏的 */
static int g_item_h = APP_ROW_H; /* 两行列表行高(M12:按字体行高派生,见 ncm_row_h) */
static int g_press_row, g_press_delta, g_dragging, g_drag_last_y;
/* 歌单加载后台 worker(P1:从 UI 线程挪出,gen 代数做完成信号)。 */
@@ -35,6 +43,13 @@ static int g_home_thr_on;
static volatile int g_home_gen;
static int g_home_acked;
static volatile int g_home_loading;
static int g_home_silent; /* 1=本次加载是后台静默刷新(M10 离线重试) */
static int g_home_changed; /* 1=本次加载改了可见数据/离线态(决定是否重绘) */
/* M10:离线自动重试节奏闸。时钟用 core 单调钟(ncm_monotonic_ms),不用 SDK meow_ui_time_ms
* ——真机后者高 32 位是垃圾,任何 `now - last >= N` 都会变成「恒真」(REPORT §42)。 */
#define HOME_OFFLINE_RETRY_MS 30000
static ncm_retry_ctl g_retry;
static const char *tab_label(int tab) { return tab ? "我收藏的" : "我创建的"; }
@@ -86,7 +101,7 @@ static void home_row_bg(void *ctx)
if (meow_ui_activity_active() != ACT_HOME) return;
if (g_load_err != 0 || g_st.playlist_count == 0) return;
const meow_theme *th = meow_theme_get();
int item = APP_ROW_H;
int item = g_item_h;
int off = meow_listview_scroll(g_list);
int first = off / item, last = (off + r_list.h) / item + 1;
int n = tab_count(g_tab);
@@ -145,6 +160,21 @@ static void home_draw(void *ctx)
}
meow_ui_text_right(pc.x + pc.w - 16, pc.y + (pc.h - 28) / 2, ">", 28, th->onSurfaceVariant);
/* M9 离线模式:个人卡右侧可见提示(红点 + 红字,避开「>」)。断网不再退扫码,
* 用户能从这一处看出「当前是离线,缓存照常可播」。 */
if (appstate_is_offline()) {
const char *ol = "离线模式";
int osz = th->font_body - 2;
if (osz < 16) osz = 16;
int ow = meow_ui_text_width(ol, osz);
int ox = pc.x + pc.w - 28 - ow;
int oy = pc.y + (pc.h - osz) / 2;
if (ox > pc.x + asz + 28) {
meow_ui_fill_rect(ox - 14, oy + osz / 2 - 3, 6, 6, pa->red);
meow_ui_text(ox, oy, ol, osz, pa->red);
}
}
/* 双页签(g_tabs=meow_tabbar widget 建)——红下划线/红选中字叠层。 */
if (g_tabs) {
int iw = r_tabs.w / 2;
@@ -165,7 +195,7 @@ static void home_draw(void *ctx)
/* 歌单行:红 mini-vinyl 缩略 + 按压红条(文本由 listview 画在 z=10)。 */
if (g_load_err == 0 && g_st.playlist_count > 0) {
int item = APP_ROW_H;
int item = g_item_h;
int off = meow_listview_scroll(g_list);
int isz = 24;
int first = off / item, last = (off + r_list.h) / item + 1;
@@ -200,6 +230,8 @@ static void home_draw_empty(void *ctx)
if (g_home_loading) { ncm_draw_empty(r_list, "LOADING", "正在加载歌单..."); return; }
if (g_st.logged_in == 0) ncm_draw_empty(r_list, "NOT SIGNED IN", "请先登录");
else if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "SESSION EXPIRED", "请重新登录");
/* M9 离线模式:网络类失败不再是「登录过期」,提示可离线听已缓存。 */
else if (appstate_is_offline()) ncm_draw_empty(r_list, "离线模式", "网络不可用 · 可播放已缓存(顶栏「已缓存」)");
else if (g_load_err) ncm_draw_empty(r_list, "NETWORK ERROR", "上滑重试");
else ncm_draw_empty(r_list, "NO PLAYLISTS", "还没有歌单");
}
@@ -210,17 +242,23 @@ static void home_draw_footer(void *ctx)
if (meow_ui_activity_active() != ACT_HOME) return;
if (g_load_err != 0) return;
char foot[192];
snprintf(foot, sizeof foot, "共 %d 个歌单 %s", g_st.playlist_count,
g_st.nickname[0] ? g_st.nickname : "");
snprintf(foot, sizeof foot, "共 %d 个歌单 %s%s", g_st.playlist_count,
g_st.nickname[0] ? g_st.nickname : "",
appstate_is_offline() ? " · 离线模式" : "");
ncm_draw_footer(r_cont, foot);
}
/* 把当前 tab 子集刷进列表 + 重绘(UI 线程)。 */
static void home_apply(void)
/* 把当前 tab 子集刷进列表 + 重绘(UI 线程)。
* keep_scroll=1:后台静默刷新用——保留用户当前滚动位置(数量变化由 SDK 钳位);
* 0=复位到顶部(进页/切 tab/手动刷新,与原行为一致)。
* M10:离线重试不再每轮把列表拽回顶部(旧代码每轮调本函数复位滚动 + 整帧重绘,
* 重试风暴时表现为「歌单列表滑动特别卡」)。 */
static void home_apply_ex(int keep_scroll)
{
if (g_list) {
int keep = keep_scroll ? meow_listview_scroll(g_list) : 0;
meow_listview_set_count(g_list, tab_count(g_tab));
meow_listview_set_scroll(g_list, 0);
meow_listview_set_scroll(g_list, keep);
meow_listview_set_sel(g_list, -1);
}
if (g_tabs) meow_tabbar_set_selected(g_tabs, g_tab);
@@ -229,6 +267,8 @@ static void home_apply(void)
meow_ui_paint_full();
}
static void home_apply(void) { home_apply_ex(0); }
/* 后台 worker:唯一走网络的地方(ensure_uid + user_playlist)。
* §26 stale-while-revalidate:网络成功 → appstate_load_playlists 覆写盘缓存 + 更新内存;
* 失败但有缓存在展示 → 保持缓存列表(g_load_err=0),后台静默不打断(断网可离线浏览)。 */
@@ -236,52 +276,98 @@ static void *home_worker(void *arg)
{
(void)arg;
int rc;
int from_cache = 0;
int off_before = appstate_is_offline();
int cnt_before = g_st.playlist_count;
if (!g_st.logged_in) rc = NCM_ERR_AUTH;
else {
/* §NEW MUSIC_U 临期续期:先于歌单网络请求(后续 eapi/weapi 用新 cookie)。
* AUTH=会话确失效(置回登录页标志,UI 线程消费);瞬断=保持旧 cookie 走缓存/旧会话。 */
int crc = account_cookie_maybe_refresh();
if (crc == NCM_ERR_AUTH) rc = NCM_ERR_AUTH;
else rc = appstate_load_playlists();
else rc = appstate_load_playlists_ex(&from_cache);
}
if (rc == NCM_OK)
account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */
/* M9:网络类失败→离线(保留登录态);成功→回在线。但「接口缓存命中」不算回在线——
* 离线时读到 120s 内的旧缓存同样返回 NCM_OK,不能据此清掉离线提示/重试节奏。
* 判定用本次请求的出参(评审 P2-2:不再用全局命中计数做前后差值)。 */
if (!(rc == NCM_OK && from_cache)) appstate_session_gate(rc);
pthread_mutex_lock(&g_home_mu);
g_load_err = (g_st.playlist_count > 0) ? 0 : rc; /* 有数据(缓存/已载)即不置错误态 */
g_home_loading = 0;
/* M10:本次加载是否产生「看得见的变化」——离线态翻转 / 歌单数量变化 / 仍处空态
* (空态文案随 load_err、离线提示变化)。无变化则静默重试不重绘,不打断滑动。 */
g_home_changed = (appstate_is_offline() != off_before) ||
(g_st.playlist_count != cnt_before) ||
(g_st.playlist_count == 0);
g_home_gen++;
pthread_mutex_unlock(&g_home_mu);
return NULL;
}
void home_reload(void)
/* silent=0:进页/手动刷新(读盘缓存秒开 + 复位滚动);1:后台静默刷新(不动界面)。 */
static void home_reload_ex(int silent)
{
if (g_host_mock) { g_load_err = 0; home_apply(); return; }
if (g_home_thr_on) return;
/* §26:进首页先用磁盘缓存秒渲染(若有),后台 worker 静默刷新;无缓存才显示 LOADING。 */
int64_t ts = 0;
int had_cache = (appstate_playlists_cache_load(&ts) == NCM_OK && g_st.playlist_count > 0);
g_load_err = 0;
if (!had_cache) {
g_st.playlist_count = 0; /* 无缓存 → LOADING 空态 */
g_home_loading = 1;
if (g_host_mock) { if (!silent) { g_load_err = 0; home_apply(); } return; }
if (g_home_thr_on) return; /* 单飞:同一时刻只一个 worker */
if (!silent) {
/* §26:进首页先用磁盘缓存秒渲染(若有),后台 worker 静默刷新;无缓存才显示 LOADING。 */
int64_t ts = 0;
int had_cache = (appstate_playlists_cache_load(&ts) == NCM_OK && g_st.playlist_count > 0);
g_load_err = 0;
if (!had_cache) {
g_st.playlist_count = 0; /* 无缓存 → LOADING 空态 */
g_home_loading = 1;
}
}
/* M10 静默刷新:界面已有数据 → 不预读盘(旧实现每轮都在 UI 线程同步读 playlists.bin)、
* 不复位滚动、不重绘;等 worker 完成后由 home_tick 按 g_home_changed 决定是否重绘。 */
g_home_silent = silent;
g_home_thr_on = 1;
if (ncm_thread_create(&g_home_thr, home_worker, NULL) != 0) {
g_home_thr_on = 0; g_home_loading = 0; g_load_err = NCM_ERR_IO;
return;
}
home_apply();
pthread_detach(g_home_thr); /* 只靠 gen 代次收工、不 join:线程栈即刻回收(旧代码每轮泄漏) */
if (!silent) home_apply();
}
void home_reload(void) { home_reload_ex(0); }
/* M10:清离线重试节奏(页创建/登出/宿主测试用)。 */
void home_retry_reset(void) { ncm_retry_ctl_init(&g_retry); }
void home_tick(uint64_t now)
{
/* M10 离线恢复:断网下严格 ≥30s 静默重试一次(home worker 单飞;成功后 appstate_session_gate
* 自动回在线并重绘)。仅在 home 可见时轮询,不轰炸服务端(风控纪律)。
*
* 节奏判定不使用入参 now:真机 meow_ui_time_ms() 的高 32 位是垃圾(REPORT §42),
* `now - last >= 30000` 会恒真 → 旧实现每个 tick 都重试(真机 74s 内 10 次,CPU 48%、
* load 3.65、整机卡死)。这里统一用 core 单调钟 + retry_ctl 闸:
* - 单飞(worker 未收工不重试)、
* - 时钟回退不下溢(回退时重定基准,不立即重试)、
* - 在线/离线抖动不重置基准(旧 else 分支清零 g_offline_retry_ms,一抖就重试)。 */
(void)now;
if (appstate_is_offline() && !g_host_mock &&
ncm_retry_ctl_poll(&g_retry, 1, g_home_thr_on, ncm_monotonic_ms(), HOME_OFFLINE_RETRY_MS)) {
meow_log(MEOW_LOG_INFO, "home: offline retry");
ncm_api_cache_refresh_path("/api/user/playlist"); /* 离线恢复探测必须真打网络 */
home_reload_ex(1);
}
if (!g_home_thr_on) return;
if (g_home_gen == g_home_acked) return;
g_home_acked = g_home_gen;
g_home_thr_on = 0;
g_home_loading = 0;
home_apply();
int changed, silent;
pthread_mutex_lock(&g_home_mu);
changed = g_home_changed;
silent = g_home_silent;
pthread_mutex_unlock(&g_home_mu);
if (!silent || changed) home_apply_ex(silent ? 1 : 0); /* 静默刷新保留滚动位置 */
}
int home_is_active(void) { return g_hc.active; }
@@ -299,7 +385,8 @@ int home_on_tap(int x, int y)
}
if (ah == 1) { home_drag_reset(); settings_open(); return 1; } /* 设置 */
if (ah == 2) { home_drag_reset(); search_open(); return 1; } /* 搜索 */
if (ah == 3) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */
if (ah == 3) { home_drag_reset(); cache_open(); return 1; } /* 已缓存列表 */
if (ah == 4) { home_drag_reset(); meow_view_page_push(ACT_PLAYER); return 1; } /* 回播放页 */
}
if (g_tabs && meow_widget_hit(g_tabs, x, y)) {
int t = meow_tabbar_hit(g_tabs, x, y);
@@ -365,7 +452,9 @@ static void home_page_create(meow_view *v, void *ctx)
{
(void)v;
((home_ctrl *)ctx)->view = v;
home_retry_reset(); /* M10:离线重试节奏闸(首次进离线立即探测一次) */
r_cont = ncm_content_rect();
g_item_h = ncm_row_h(); /* M12:行高按当前字体两行文本块派生(首行标题不被视口顶钳掉) */
ncm_rect_t pc = NR(16, r_cont.y + 8, r_cont.w - 32, 72);
r_tabs = NR(16, pc.y + pc.h + 8, r_cont.w - 32, 48);
r_group = NR(16, r_tabs.y + r_tabs.h + 4, r_cont.w - 32, 36);
@@ -376,22 +465,25 @@ static void home_page_create(meow_view *v, void *ctx)
g_tb = ncm_topbar_create("我的音乐", 0, "\xe2\x86\xbb"); /* 刷新(动作0) */
meow_topappbar_add_action(g_tb, "\xe2\x9a\x99"); /* 设置(动作1) */
meow_topappbar_add_action(g_tb, "\xe2\x8c\x95"); /* 搜索(动作2) */
/* 批6:回播放页(动作3, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate
/* M5:已缓存列表入口(动作3)——songs/ 池全部已缓存歌曲(点播/删除/清空)。 */
meow_topappbar_add_action(g_tb, "\xe2\x87\xa9"); /* 已缓存(动作3) */
/* 批6:回播放页(动作4, 最右)。无活动音频也生效——按下即 push(ACT_PLAYER),播放页按 appstate
* 现有语义展示空态(暂无播放)/上次会话状态。 */
meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作3, 最右) */
meow_topappbar_add_action(g_tb, "\xe2\x99\xaa"); /* 回播放页(动作4, 最右) */
/* §307 topbar 图标迁移:三位码点(↻ ⚙ ⌕)在 4k 字库缺字形渲染成豆腐(WORK_STATE 310 前置
* 症状①),改为 sb_icons 位图(Material Symbols Rounded,字符标签仅作占位,绘制走位图)。 */
meow_topappbar_set_item_icon(g_tb, 0, MEOW_ICON_REFRESH);
meow_topappbar_set_item_icon(g_tb, 1, MEOW_ICON_SETTINGS);
meow_topappbar_set_item_icon(g_tb, 2, MEOW_ICON_SEARCH);
meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */
meow_topappbar_set_item_icon(g_tb, 3, MEOW_ICON_DOWNLOAD); /* 已缓存列表 */
meow_topappbar_set_item_icon(g_tb, 4, MEOW_ICON_QUEUE_MUSIC); /* 回播放页 */
g_tabs = meow_tabbar_create(r_tabs.x, r_tabs.y, r_tabs.w, r_tabs.h);
meow_tabbar_add_tab(g_tabs, tab_label(0));
meow_tabbar_add_tab(g_tabs, tab_label(1));
meow_tabbar_set_selected(g_tabs, 0);
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
home_src2, NULL, MEOW_LIST_SEL_NONE);
meow_listview_set_item_h(g_list, APP_ROW_H);
meow_listview_set_item_h(g_list, g_item_h);
meow_ui_activity_pop();
/* 显式 z:行卡片(5) < listview(10) < tabbar(20) < 红态叠层(60) < topbar(999) */
meow_widget_set_z(g_list, 10);
+15 -1
View File
@@ -422,10 +422,13 @@ static void plr_draw_cover_dyn(void *ctx)
/* 缓冲/错误态(沿用 §18 三态;错误时用文案替代时间行) */
if (perr != 0) {
/* M9:网络类失败(不可达/超时/5xx)提示离线可播缓存,而不是「登录过期」。 */
int pnet = ncm_err_is_network(perr);
const char *em = (perr == NCM_ERR_VIP) ? "VIP 专享,开通会员后可播放"
: (perr == NCM_ERR_NOTFOUND) ? "无法播放:无音源(VIP/版权)"
: (perr == NCM_ERR_AUTH) ? "登录已过期,请重新登录"
: "加载失败,请检查网络";
: pnet ? "离线模式:网络不可用,可播放已缓存"
: "加载失败,请重试";
meow_ui_text_center(W / 2, g_times_y - 8, em, 20, pa->red_lit);
meow_ui_text_center(W / 2, g_times_y + 18, "点播放键重试 / 切下一首", 18, PLR_TEXT_DIM);
} else {
@@ -783,6 +786,8 @@ void player_play_queue_song(int idx)
int have = 0;
if (appstate_get_queue() == NCM_QUEUE_SEARCH) {
if (appstate_search_song(idx, &s) == 0) have = 1;
} else if (appstate_get_queue() == NCM_QUEUE_CACHE) {
if (appstate_cache_song(idx, &s) == 0) have = 1;
} else if (appstate_song_copy(idx, &s) == 0) {
have = 1;
}
@@ -794,6 +799,15 @@ void player_play_queue_song(int idx)
meow_view_page_push(ACT_PLAYER);
}
/* 已缓存列表点播(ACT_CACHE 页点行):切 NCM_QUEUE_CACHE 队列后走同一套「队列点播」路径——
* 上/下一首在缓存列表内循环;播放线程按条目真实键直读缓存文件(不重下)。 */
void player_play_cache_song(int i)
{
if (i < 0 || i >= appstate_cache_count()) return;
appstate_set_queue(NCM_QUEUE_CACHE);
player_play_queue_song(i);
}
void player_pause_toggle(int on)
{
if (player_manager_state() == NCM_PC_PAUSED) player_manager_pause(0);
+82 -40
View File
@@ -24,10 +24,15 @@ static int g_bg_id, g_draw_id, g_empty_id, g_footer_id;
static char g_title[256];
static volatile int g_load_err;
static int g_press_row, g_press_delta, g_dragging, g_drag_last_y;
static int g_item_h = APP_ROW_H; /* 两行列表行高(M12:按字体行高派生,见 ncm_row_h) */
/* 歌单详情加载后台 worker(大歌单可达数十秒,不能占 UI 线程)。
* §17 滑动窗口:worker 两种任务——①首屏(g_ens_first=1):重建 trackIds 索引 + 物化首段;
* ②滑窗补载:物化视口区间 [g_ens_lo,g_ens_hi)(ensure_range,盘缓存优先)。 */
* §17 滑动窗口:worker 三种任务——①首屏(g_ens_first=1):重建 trackIds 索引 + 物化第 0 页
* (≤NCM_SONG_BATCH 首,一次一页——不再首屏连拉多批补满窗口,大歌单阻塞久且触发风控);
* ②顺序下一页(g_ens_page=1):仅当用户滑到已分页底部(余量 ≤NCM_PL_PAGE_EDGE 首)时拉一页,
* 成功推进 g_paged_hi,拉完该歌单全部曲目即止;③回滚补物化:已分页区间内被窗口挤出的
* 失效槽按视口 ±24 补(盘缓存命中为主,不占网络分页额度)。 */
#define NCM_PL_PAGE_EDGE 20 /* 懒加载触发余量(首):视口底距已分页底部 ≤20 首才拉下一页 */
static pthread_mutex_t g_plmu = PTHREAD_MUTEX_INITIALIZER;
static pthread_t g_plthr;
static int g_plthr_on;
@@ -37,9 +42,11 @@ static volatile int g_plloading;
static volatile int g_plloaded_idx;
static volatile int g_plloaded_gen; /* worker 启动时代次(§26:过期结果丢弃判据) */
static volatile int g_pl_stale; /* 1=本次结果被判过期(未写全局,需重拉当前目标) */
static volatile int g_ens_lo, g_ens_hi; /* 滑窗补载目标区间(全局下标) */
static volatile int g_ens_lo, g_ens_hi; /* 补载/下一页目标区间(全局下标) */
static volatile int g_ens_first; /* 1=首屏重头(offset=0) */
static volatile int g_ens_page; /* 1=顺序下一页任务(成功推进 g_paged_hi) */
static volatile int g_ens_err; /* 最近一次滑窗补载错误(0=ok;首屏错误走 g_load_err) */
static volatile int g_paged_hi; /* 已分页高水位:从顶部连续物化到的全局下标(下一页起点) */
/* 打开快照(§26 开A→未载完→开B 防串):refresh 在启动 worker 前把「目标歌单」拷进 g_tgt,
* worker 启动时再拷到本地——detail 加载不再依赖 g_st.playlists[](会被回家时 home_worker 的
@@ -119,7 +126,7 @@ static void pl_row_bg(void *ctx)
if (meow_ui_activity_active() != ACT_PLAYLIST) return;
if (g_load_err != 0 || g_st.track_ids_count == 0) return;
const meow_theme *th = meow_theme_get();
int item = APP_ROW_H;
int item = g_item_h;
int off = meow_listview_scroll(g_list);
int first = off / item, last = (off + r_list.h) / item + 1;
if (last > g_st.track_ids_count) last = g_st.track_ids_count;
@@ -189,7 +196,7 @@ static void pl_draw(void *ctx)
/* 曲目行:红 mini-vinyl 缩略 + 按压红条(文本由 listview z=10 画) */
if (g_load_err == 0 && g_st.track_ids_count > 0) {
int item = APP_ROW_H, isz = 24;
int item = g_item_h, isz = 24;
int off = meow_listview_scroll(g_list);
int first = off / item, last = (off + r_list.h) / item + 1;
if (last > g_st.track_ids_count) last = g_st.track_ids_count;
@@ -219,6 +226,7 @@ static void pl_draw_empty(void *ctx)
if (g_load_err == 0 && g_st.track_ids_count != 0) return;
if (g_plloading) { ncm_draw_empty(r_list, "加载中", "正在加载曲目..."); return; }
if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "登录已过期", "请重新登录");
else if (appstate_is_offline()) ncm_draw_empty(r_list, "离线模式", "网络不可用 · 可播放已缓存(返回首页→「已缓存」)");
else if (g_load_err) ncm_draw_empty(r_list, "加载失败", "点按重试");
else ncm_draw_empty(r_list, "暂无歌曲", "这个歌单是空的");
}
@@ -231,20 +239,23 @@ static void pl_draw_footer(void *ctx)
int total = g_st.track_ids_count;
if (total <= 0) return;
/* 视口位置 / 总数(滑窗模型下「已物化数」无用户意义,视口区间更直观) */
int first = meow_listview_scroll(g_list) / APP_ROW_H + 1;
int last = first + r_list.h / APP_ROW_H;
int first = meow_listview_scroll(g_list) / g_item_h + 1;
int last = first + r_list.h / g_item_h;
if (last > total) last = total;
char foot[96];
snprintf(foot, sizeof foot, "%d-%d / %d 首", first, last, total);
ncm_draw_footer(r_cont, foot);
}
/* apply:reset_scroll=1 时列表回顶并清选中(首屏/刷新);0=保持当前滚动(滑窗补载,防跳顶)。 */
/* apply:reset_scroll=1 时列表回顶并清选中(首屏/刷新);0=保持当前滚动(滑窗补载,防跳顶)。
* 拉到数据后必须让列表项重新绑定/重绘:src2 每帧 live 取行(占位行 ↔ 已物化歌名/歌手),
* 失效列表带 + 整帧重画把刚物化的歌名/歌手填进各项(修「拉完曲目行仍 ... 加载中」)。 */
static void pl_apply(int reset_scroll)
{
if (g_list) {
meow_listview_set_count(g_list, g_st.track_ids_count); /* 全量行数(占位行兜底未物化区) */
if (reset_scroll) { meow_listview_set_scroll(g_list, 0); meow_listview_set_sel(g_list, -1); }
meow_ui_invalidate(r_list.x, r_list.y, r_list.w, r_list.h);
}
const ncm_playlist_t *pcur = pl_cur();
if (g_tb && pcur) {
@@ -261,30 +272,25 @@ static void *playlist_worker(void *arg)
(void)arg;
/* 拷贝打开快照 + 任务参数:单飞(g_plthr_on)保证 worker 运行期间 g_tgt 不被改写。 */
ncm_playlist_t tpl;
int tsearch, tidx, tgen, first, lo, hi;
int tsearch, tidx, tgen, first, lo, hi, page;
pthread_mutex_lock(&g_plmu);
tpl = g_tgt.pl; tsearch = g_tgt.is_search;
tidx = g_tgt.idx; tgen = g_tgt.gen;
first = g_ens_first; lo = g_ens_lo; hi = g_ens_hi;
first = g_ens_first; lo = g_ens_lo; hi = g_ens_hi; page = g_ens_page;
pthread_mutex_unlock(&g_plmu);
int cur_active = g_st.active_playlist; /* ensure 路径结束校验用(当前目标) */
int rc;
int gate_rc = NCM_ERR_STALE; /* M9:首屏结果→会话/离线态(锁外应用) */
if (g_host_mock) rc = 0;
else if (first) {
/* 首屏:快照式加载(detail 全量 trackIds + 物化首段)。ctx 校验代次/目标,过期自动丢弃。 */
/* 首屏:快照式加载(detail 全量 trackIds + 物化第 0 页)。ctx 校验代次/目标,过期自动丢弃。
* §懒加载:首屏只拉一页(≤NCM_SONG_BATCH 首,一次仅一批)——不再 ensure_range 补满整个
* 滑动窗口(旧版首屏连拉 ≤3 批:5k+ 大歌单「我喜欢的音乐」长时间卡住不出列表,连续
* 请求还触发风控);后续页由 pl_maybe_ensure 在用户滑到已分页底部时逐页懒加载。 */
if (tidx < 0) rc = NCM_ERR_ARG;
else rc = appstate_load_playlist_songs_ctx(&tpl, tsearch, tgen, tidx, 0, NCM_SONG_BATCH);
/* §22 整张歌单逐渐物化:首屏成功后把「能装进滑动窗口」的歌单一次性补填入窗
* (≤NCM_SONG_WINDOW 的歌单整张物化,末叶行不再恒「.../加载中」——真机反馈
* 「曲目稍多时行一直加载中,点开播放这首才回填」);超窗口的歌单仍按视口滑动补载
* (内存纪律:窗口 ≤256 首)。窗口基址=0 覆盖 [0,min(track,WINDOW)),不滑窗不挤占视口。 */
if (rc == NCM_OK) {
int full = (g_st.track_ids_count < NCM_SONG_WINDOW) ? g_st.track_ids_count : NCM_SONG_WINDOW;
if (full > NCM_SONG_BATCH) {
int r2 = appstate_song_ensure_range(0, full);
if (r2 != NCM_OK && rc == NCM_OK) rc = r2; /* 补填失败非致命:已物化槽保留 */
}
}
if (rc == NCM_OK)
g_paged_hi = (g_st.track_ids_count < NCM_SONG_BATCH) ? g_st.track_ids_count : NCM_SONG_BATCH;
/* REPORT §30(真机四连症状①):首屏结果打日志——tidx/track_ids_count/物化数/rc,
* 供「162 首歌单所有条目占位 加载中」定位(首屏 song_detail 失败/部分失败=非致命
* g_ens_err=rc,窗口空 → 行占位;g_st.song_count 为诊断竞态读,仅日志用途)。 */
@@ -292,8 +298,10 @@ static void *playlist_worker(void *arg)
"playlist: first-screen idx=%d plid=%s trackIds=%d materialized=%d rc=%d",
tidx, tpl.id, g_st.track_ids_count, g_st.song_count, rc);
} else {
/* 滑窗补载:物化视口区间(盘缓存优先,批网格补载)。 */
/* 补载:物化目标区间(顺序下一页 / 回滚补物化;盘缓存优先,批网格补载)。
* 顺序下一页成功 → 已分页高水位推进到 hi;部分失败不推进(占位行保留,滑到底部重试本页)。 */
rc = (lo < hi) ? appstate_song_ensure_range(lo, hi) : NCM_OK;
if (rc == NCM_OK && page && hi > g_paged_hi) g_paged_hi = hi;
}
pthread_mutex_lock(&g_plmu);
if (first) {
@@ -307,6 +315,9 @@ static void *playlist_worker(void *arg)
} else {
g_load_err = rc; /* 索引失败/鉴权失败 → 整页空态(点按重试) */
}
/* M9:本次首屏失败(非 STALE)同步离线态;成功不在此判「回在线」——歌单详情可能命中
* 接口缓存,缓存命中不代表网络恢复(回在线由 home 的离线探测/真实成功判定)。 */
gate_rc = rc;
g_plloaded_idx = tidx;
g_plloaded_gen = tgen;
/* §NEW bug B:首屏发布后标记窗口归属=目标歌单(失败/部分也归属,支持同歌单重试不清窗)。
@@ -321,26 +332,16 @@ static void *playlist_worker(void *arg)
g_plloading = 0;
g_plgen++;
pthread_mutex_unlock(&g_plmu);
if (gate_rc != NCM_ERR_STALE && gate_rc != NCM_OK) appstate_session_gate(gate_rc);
return NULL;
}
/* 视口驱动滑窗补载:视口 ±24 行有未物化槽位 → 发 ensure worker(已在载/失败态/宿主 mock 不发)。 */
static void pl_maybe_ensure(void)
/* 发补载 worker(单飞;调用方先判 g_plthr_on)。page=1 为顺序下一页任务。 */
static void pl_ensure_post(int lo, int hi, int page)
{
if (g_plthr_on || g_host_mock) return;
if (g_load_err != 0) return; /* 首屏失败态:走点按重试 */
int total = g_st.track_ids_count;
if (total <= 0) return;
int scroll = meow_listview_scroll(g_list);
int first = scroll / APP_ROW_H;
int last = (scroll + r_list.h) / APP_ROW_H + 1;
int lo = first - 24, hi = last + 24;
if (lo < 0) lo = 0;
if (hi > total) hi = total;
if (lo >= hi) return;
if (appstate_range_loaded(lo, hi)) return; /* 已物化:无操作(窗口原地不动) */
g_ens_lo = lo; g_ens_hi = hi;
g_ens_first = 0;
g_ens_page = page;
g_plloading = 1;
g_plthr_on = 1;
if (ncm_thread_create(&g_plthr, playlist_worker, NULL) != 0) {
@@ -348,9 +349,47 @@ static void pl_maybe_ensure(void)
}
}
/* 懒加载分页触发(Bug 修①:旧版视口 ±24 触发 + 首屏连拉多批 → 大歌单连续拉多页、阻塞 + 风控):
* ① 顺序下一页:仅在视口底滑到「已分页底部」(余量 ≤NCM_PL_PAGE_EDGE 首)时拉下一页,
* 一次只拉一页(≤NCM_SONG_BATCH);g_paged_hi 达 track_ids_count = 全部拉完,停止。
* 远离已分页底部(快速猛滑到深处占位区)不拉——防连续拉多页触发风控。
* ② 回滚补物化:视口 ±24 落在「已分页区间内」的失效槽(窗口挤出)按旧逻辑补——
* 盘缓存(songmeta)命中为主,不消耗网络分页额度;区间外(未分页)一律不预拉。 */
static void pl_maybe_ensure(void)
{
if (g_plthr_on || g_host_mock) return;
if (g_load_err != 0) return; /* 首屏失败态:走点按重试 */
int total = g_st.track_ids_count;
if (total <= 0) return;
int scroll = meow_listview_scroll(g_list);
int first = scroll / g_item_h;
int last = (scroll + r_list.h) / g_item_h + 1;
if (last > total) last = total;
int paged = g_paged_hi;
if (paged > total) paged = total; /* 防御:索引重建后高水位越界 */
/* ① 顺序下一页(懒加载) */
if (paged < total) {
int margin = paged - last; /* 视口底距已分页底部余量(首;负=已越过) */
if (margin <= NCM_PL_PAGE_EDGE && margin >= -NCM_PL_PAGE_EDGE) {
int hi = paged + NCM_SONG_BATCH;
if (hi > total) hi = total;
pl_ensure_post(paged, hi, 1);
return;
}
}
/* ② 回滚补物化(仅已分页区间内) */
int lo = first - 24, hi = last + 24;
if (lo < 0) lo = 0;
if (hi > paged) hi = paged;
if (lo >= hi) return;
if (appstate_range_loaded(lo, hi)) return; /* 已物化:无操作(窗口原地不动) */
pl_ensure_post(lo, hi, 0);
}
void playlist_refresh(void)
{
if (g_host_mock) { g_load_err = 0; g_ens_err = 0; pl_apply(1); return; }
if (g_host_mock) { g_load_err = 0; g_ens_err = 0; g_paged_hi = g_st.track_ids_count; pl_apply(1); return; }
if (g_plthr_on) return;
g_load_err = 0;
g_ens_err = 0;
@@ -373,7 +412,9 @@ void playlist_refresh(void)
if (!appstate_playlist_window_is(g_tgt.pl.id))
appstate_playlist_reset();
g_ens_first = 1; /* 首屏:重建索引 + 物化首段 */
g_ens_first = 1; /* 首屏:重建索引 + 物化第 0 页 */
g_ens_page = 0;
g_paged_hi = 0; /* 分页高水位归零:首屏成功后由 worker 重标 */
g_pl_stale = 0;
g_plloading = 1;
g_plthr_on = 1;
@@ -541,6 +582,7 @@ static void pl_page_create(meow_view *v, void *ctx)
(void)v;
((playlist_ctrl *)ctx)->view = v;
r_cont = ncm_content_rect();
g_item_h = ncm_row_h(); /* M12:行高按当前字体两行文本块派生(首行标题不被视口顶钳掉) */
r_card = NR(16, r_cont.y + 8, r_cont.w - 32, 148);
r_cov = NR(r_card.x + 16, r_card.y + 20, 80, 80);
int by = r_card.y + r_card.h + 12;
@@ -554,7 +596,7 @@ static void pl_page_create(meow_view *v, void *ctx)
g_tb = ncm_topbar_create("歌单", 1, NULL);
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
pl_src2, NULL, MEOW_LIST_SEL_NONE);
meow_listview_set_item_h(g_list, APP_ROW_H);
meow_listview_set_item_h(g_list, g_item_h);
meow_ui_activity_pop();
meow_widget_set_z(g_list, 10);
g_bg_id = meow_draw_custom_register(5, r_list.x, r_list.y, r_list.w, r_list.h,
+16 -11
View File
@@ -1,7 +1,7 @@
/* ui/queue.c — 当前实时播放队列页(ACT_QUEUE,§NEW:播放页「队列」钮进入)。
*
* 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 或 搜索单曲结果集),
* 含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17):
* 展示**当前播放队列**(player_manager 的 queue_src —— 歌单滑动窗口 / 搜索单曲结果集 /
* 已缓存列表),含当前曲高亮、可点某行切到那首播放。数据源与分页复用歌单滑动窗口(REPORT §17):
* - count = appstate_queue_count()(全量:track_ids_count 或 search_song_count,可 5194 首);
* - 行内容 = src2 逐行按 queue_src 取(PLAYLIST→滑窗拷贝,SEARCH→搜索数组),未物化槽显示
* ".../加载中" 占位,视口 ±24 行未物化 → 后台 worker ensure_range 补载(盘缓存优先,批≤NCM_SONG_BATCH);
@@ -28,6 +28,7 @@ static ncm_rect_t r_cont, r_list;
static int g_bg_id, g_draw_id, g_empty_id, g_footer_id;
static int g_press_row, g_press_delta, g_dragging, g_drag_last_y;
static int g_last_count, g_last_cur;
static int g_item_h = APP_ROW_H; /* 两行列表行高(M12:按字体行高派生,见 ncm_row_h) */
/* 队列补载(PLAYLIST)后台 worker 参数:目标区间 [lo,hi)(全局下标)。SEARCH 无需补载。 */
static pthread_mutex_t g_qmu = PTHREAD_MUTEX_INITIALIZER;
@@ -47,8 +48,11 @@ static void q_drag_reset(void)
* PLAYLIST:滑窗拷贝(UI 线程不触发网络);SEARCH:搜索数组(内存)。 */
static int q_song_get(int idx, ncm_song_t *out)
{
if (appstate_get_queue() == NCM_QUEUE_SEARCH)
int src = appstate_get_queue();
if (src == NCM_QUEUE_SEARCH)
return (appstate_search_song(idx, out) == 0) ? 0 : -1;
if (src == NCM_QUEUE_CACHE)
return (appstate_cache_song(idx, out) == 0) ? 0 : -1;
return (appstate_song_copy(idx, out) == 0) ? 0 : -1;
}
@@ -89,7 +93,7 @@ static void q_row_bg(void *ctx)
if (cnt <= 0) return;
const meow_theme *th = meow_theme_get();
const ncm_pal_t *pa = ncm_palette(g_dark);
int item = APP_ROW_H;
int item = g_item_h;
int off = meow_listview_scroll(g_list);
int first = off / item, last = (off + r_list.h) / item + 1;
if (last > cnt) last = cnt;
@@ -117,7 +121,7 @@ static void q_draw(void *ctx)
const ncm_pal_t *pa = ncm_palette(g_dark);
int cnt = appstate_queue_count();
if (cnt <= 0) return;
int item = APP_ROW_H, isz = 24;
int item = g_item_h, isz = 24;
int off = meow_listview_scroll(g_list);
int first = off / item, last = (off + r_list.h) / item + 1;
if (last > cnt) last = cnt;
@@ -153,8 +157,8 @@ static void q_draw_footer(void *ctx)
if (meow_ui_activity_active() != ACT_QUEUE) return;
int total = appstate_queue_count();
if (total <= 0) return;
int first = meow_listview_scroll(g_list) / APP_ROW_H + 1;
int last = first + r_list.h / APP_ROW_H;
int first = meow_listview_scroll(g_list) / g_item_h + 1;
int last = first + r_list.h / g_item_h;
if (last > total) last = total;
char foot[96];
snprintf(foot, sizeof foot, "%d-%d / %d 首", first, last, total);
@@ -195,13 +199,13 @@ static void *queue_worker(void *arg)
* SEARCH 队列全在内存,无需补载。 */
static void q_maybe_ensure(void)
{
if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return;
if (appstate_get_queue() != NCM_QUEUE_PLAYLIST) return; /* SEARCH/CACHE 队列全在内存 */
if (g_qthr_on || g_host_mock) return;
int total = appstate_queue_count();
if (total <= 0) return;
int scroll = meow_listview_scroll(g_list);
int first = scroll / APP_ROW_H;
int last = (scroll + r_list.h) / APP_ROW_H + 1;
int first = scroll / g_item_h;
int last = (scroll + r_list.h) / g_item_h + 1;
int lo = first - 24, hi = last + 24;
if (lo < 0) lo = 0;
if (hi > total) hi = total;
@@ -322,6 +326,7 @@ static void q_page_create(meow_view *v, void *ctx)
(void)v;
((queue_ctrl *)ctx)->view = v;
r_cont = ncm_content_rect();
g_item_h = ncm_row_h(); /* M12:行高按当前字体两行文本块派生(首行标题不被视口顶钳掉) */
r_list = NR(16, r_cont.y + 8, r_cont.w - 32,
r_cont.y + r_cont.h - APP_FOOTER_H - (r_cont.y + 8));
if (r_list.h < 0) r_list.h = 0;
@@ -329,7 +334,7 @@ static void q_page_create(meow_view *v, void *ctx)
g_tb = ncm_topbar_create("播放列表", 1, NULL);
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
q_src2, NULL, MEOW_LIST_SEL_NONE);
meow_listview_set_item_h(g_list, APP_ROW_H);
meow_listview_set_item_h(g_list, g_item_h);
meow_ui_activity_pop();
meow_widget_set_z(g_list, 10);
g_bg_id = meow_draw_custom_register(5, r_list.x, r_list.y, r_list.w, r_list.h,
+5 -2
View File
@@ -33,6 +33,7 @@ static ncm_rect_t r_cont, r_list, r_btn, r_tabs[2];
static int g_empty_id, g_footer_id, g_btn_id, g_tabbar_id;
static int g_press_row, g_press_delta, g_dragging, g_drag_last_y;
static int g_scroll_song, g_scroll_pl; /* 两页签各自滚动位置(§23)*/
static int g_item_h = APP_ROW_H; /* 两行列表行高(M12:按字体行高派生,见 ncm_row_h) */
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_t g_thr;
@@ -129,6 +130,7 @@ static void sr_draw_empty(void *ctx)
if (sr_view() != SR_EMPTY) return; /* 历史/结果态有行,空态不画 */
if (g_loading) { ncm_draw_empty(r_list, "搜索中", "正在搜索..."); return; }
if (g_st.search_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "登录已过期", "请重新登录");
else if (appstate_is_offline() && g_st.search_err) ncm_draw_empty(r_list, "离线模式", "搜索需要网络 · 可播放已缓存");
else if (g_st.search_err) ncm_draw_empty(r_list, "搜索失败", "点「搜索」重试");
else if (!g_st.search_query[0]) ncm_draw_empty(r_list, "输入关键词", "输入后点「搜索」");
else if (!g_searched) ncm_draw_empty(r_list, "输入后点「搜索」", "或点历史记录");
@@ -205,7 +207,7 @@ static void sr_maybe_load_more(void)
int n = appstate_search_count(g_tab);
int cap = (g_tab == 0) ? NCM_SEARCH_SONG_MAX : NCM_SEARCH_PL_MAX;
if (n <= 0 || n >= cap || !appstate_search_has_more(g_tab)) return;
int item = APP_ROW_H;
int item = g_item_h;
int scroll = meow_listview_scroll(g_list);
if (scroll + r_list.h < n * item - item * 2) return; /* 未近底 */
g_req_tab = g_tab; g_req_offset = n; g_req_replace = 0;
@@ -444,6 +446,7 @@ static void sr_page_create(meow_view *v, void *ctx)
(void)v;
((search_ctrl *)ctx)->view = v;
r_cont = ncm_content_rect();
g_item_h = ncm_row_h(); /* M12:行高按当前字体两行文本块派生(首行标题不被视口顶钳掉) */
ncm_rect_t field = NR(16, r_cont.y + 12, r_cont.w - 32, 48);
r_btn = NR(field.x, field.y + field.h + 12, field.w, 44);
int by = r_btn.y + r_btn.h + 8;
@@ -461,7 +464,7 @@ static void sr_page_create(meow_view *v, void *ctx)
sizeof g_st.search_query, MEOW_IME_PINYIN);
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
sr_src2, NULL, MEOW_LIST_SEL_NONE);
meow_listview_set_item_h(g_list, APP_ROW_H);
meow_listview_set_item_h(g_list, g_item_h);
meow_ui_activity_pop();
meow_widget_set_z(g_list, 10);
g_btn_id = meow_draw_custom_register(60, r_btn.x, r_btn.y, r_btn.w, r_btn.h,
+230
View File
@@ -0,0 +1,230 @@
/* bench/home_retry_sim.c — M10:驱动**真实 app/ui/home.c 的 home_tick** 的离线重试回归。
*
* 与 bench/retry_storm_sim.c 的分工:那边直接驱动 logic/retry_ctl.c(纯节奏闸);
* 这边把真实 home.c 编进宿主(-ffunction-sections + --gc-sections 只保留 home_tick 调用链),
* 用桩替换网络/状态/UI,验证三件事在**页面代码**层面成立:
* 1) 断网 120s(100ms tick):home_tick 只发起 4 次重试(0/30/60/90s),间隔 ≥30000ms;
* 2) 传给 home_tick 的 now 是**真机形态的垃圾 64 位值**(低 32 位=整秒×1000、高 32 位随机,
* 见 REPORT §42)时,节奏完全不受影响(旧实现此时会每 tick 重试);
* 3) 静默重试若无可见变化(歌单数量/离线态都不变)→ 一次全帧重绘都不发(旧实现每轮
* home_apply() 复位滚动 + 整帧重绘,这是「歌单列表滑动特别卡」的直接来源)。
*
* 编译(ncm_app 目录下;桩全部在本文件内,不链 SDK/核心库):
* gcc -O2 -Wall -Wextra -ffunction-sections -fdata-sections -DMEOW_UI_HOST_TEST -DJSMN_HEADER \
* -I. -I../sdk -I../sdk/include -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c app/ui/home.c -o out/chk/home_m10.o
* gcc -O2 -Wall -Wextra -ffunction-sections -fdata-sections -DMEOW_UI_HOST_TEST -DJSMN_HEADER \
* -I. -I../sdk -I../sdk/include -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c bench/home_retry_sim.c -o out/chk/home_retry_sim.o
* gcc -Wl,--gc-sections -o home_retry_sim out/chk/home_m10.o out/chk/home_retry_sim.o -lpthread
* ./home_retry_sim
*/
#include <stdio.h>
#include <stdint.h>
#include <stdarg.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "../app/ncm_app_internal.h"
/* ---- app.c 的全局(本测试自持) ---- */
ncm_app_state_t g_st;
ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER,
.state = NCM_PC_STOPPED, .cur_song = -1 };
volatile int g_app_quit = 0;
volatile int g_net_ready = 0;
int g_host_mock = 0; /* 关键:走真实离线重试路径(非 host mock 短路) */
/* ---- 可控世界状态 ---- */
static volatile int g_offline;
static volatile int g_load_calls;
static volatile int g_retry_probes; /* ncm_api_cache_refresh_path 调用 = 重试次数 */
static int64_t g_probe_at[64];
static volatile int g_paint_full; /* 全帧重绘次数 */
static int64_t g_mono; /* 假单调钟(UI 线程推进) */
static int64_t g_vt; /* 虚拟时间轴(跨 run_ticks 连续) */
#define TICK_MS 100
static int g_pass, g_fail;
#define CHECK(cond, ...) do { \
int _ok = (cond) ? 1 : 0; \
printf(" %s ", _ok ? "PASS" : "FAIL"); printf(__VA_ARGS__); printf("\n"); \
if (_ok) g_pass++; else g_fail++; \
} while (0)
/* ============================================================
* 桩:核心/逻辑层
* ============================================================ */
int64_t ncm_monotonic_ms(void) { return g_mono; }
int appstate_is_offline(void) { return g_offline; }
int appstate_session_gate(int rc)
{
/* 与真实实现同语义:网络类错误 → 离线;成功 → 在线 */
if (rc == NCM_OK) g_offline = 0;
else if (rc == -4 || rc == -6 || rc == -17) g_offline = 1;
return 0;
}
int appstate_load_playlists_ex(int *from_cache)
{
g_load_calls++;
if (from_cache) *from_cache = 0;
return -4; /* 断网:真机 rc=-4/-6 */
}
int appstate_playlists_cache_load(int64_t *ts_out)
{
if (ts_out) *ts_out = 0;
return NCM_ERR_NOENT; /* 本场景已有内存数据,不需要盘缓存 */
}
int account_cookie_maybe_refresh(void) { return NCM_OK; }
void account_vip_maybe_refresh(int force) { (void)force; }
void ncm_api_cache_refresh_path(const char *path)
{
(void)path;
int i = g_retry_probes++;
if (i < 64) g_probe_at[i] = g_mono;
}
/* ============================================================
* 桩:SDK(home_tick 可达的绘制/列表调用)
* ============================================================ */
static meow_theme s_theme;
const meow_theme *meow_theme_get(void) { return &s_theme; }
void meow_ui_set_page_bg(uint32_t c) { (void)c; }
void meow_ui_paint_full(void) { g_paint_full++; }
void meow_listview_set_count(meow_widget *w, int n) { (void)w; (void)n; }
void meow_listview_set_scroll(meow_widget *w, int o) { (void)w; (void)o; }
void meow_listview_set_sel(meow_widget *w, int i) { (void)w; (void)i; }
int meow_listview_scroll(meow_widget *w) { (void)w; return 0; }
void meow_tabbar_set_selected(meow_widget *w, int i) { (void)w; (void)i; }
void meow_log(meow_log_level lvl, const char *fmt, ...)
{
(void)lvl;
char body[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(body, sizeof body, fmt, ap);
va_end(ap);
printf(" [log] %s\n", body);
}
/* ============================================================
* 场景
* ============================================================ */
static void reset_world(void)
{
home_retry_reset(); /* 清真实 home.c 的节奏闸(等价于重新进页) */
g_offline = 1;
g_load_calls = 0;
g_retry_probes = 0;
g_paint_full = 0;
g_mono = 0;
g_vt = 0;
g_st.logged_in = 1;
g_st.playlist_count = 120; /* 已有缓存列表(真机 120 个歌单) */
g_st.nickname[0] = 0;
g_st.uid[0] = 0;
}
/* 真机 meow_ui_time_ms() 形态:低 32 位=整秒×1000,高 32 位随机(REPORT §42 实测)。 */
static uint64_t device_like_now(int tick, uint64_t *seed)
{
*seed = *seed * 6364136223846793005ULL + 1442695040888963407ULL;
uint32_t hi = (uint32_t)(*seed >> 33);
uint64_t lo = ((uint64_t)(tick / 10)) * 1000u; /* 整秒粒度 */
return ((uint64_t)hi << 32) | lo;
}
/* 跑 seconds 秒的 100ms tick(虚拟时间连续推进,跨调用不重置);garbage_clock=1 时传给
* home_tick 的 now 用真机垃圾形态。 */
static int64_t g_vt;
static void run_ticks(int seconds, int flap, int garbage_clock)
{
uint64_t seed = 88172645463325252ULL;
int64_t end = g_vt + (int64_t)seconds * 1000;
for (; g_vt < end; g_vt += TICK_MS) {
g_mono = g_vt; /* 可信单调钟(core clock_gettime) */
if (flap) g_offline = ((g_vt / TICK_MS) % 2 == 0);
uint64_t now = garbage_clock ? device_like_now((int)g_vt, &seed) : (uint64_t)g_vt;
home_tick(now);
usleep(1000); /* 让 worker 跑完(真机是一次网络失败 5-7s) */
}
}
static void case_steady(void)
{
printf("A. 真实 home_tick:断网 120s(100ms tick)\n");
reset_world();
run_ticks(120, 0, 0);
CHECK(g_retry_probes == 4, "重试次数 = %d(期望 4:0/30/60/90s;旧实现每 tick 一次)",
g_retry_probes);
int gaps_ok = 1;
for (int i = 1; i < g_retry_probes && i < 64; i++)
if (g_probe_at[i] - g_probe_at[i - 1] < 30000) gaps_ok = 0;
CHECK(gaps_ok, "相邻重试间隔全部 ≥30000ms");
CHECK(g_load_calls == g_retry_probes + 0, "每次重试只起一个 worker(单飞,%d/%d)",
g_load_calls, g_retry_probes);
CHECK(g_paint_full == 0,
"静默重试无可见变化 → 全帧重绘 %d 次(期望 0;旧实现每轮都重绘+复位滚动)",
g_paint_full);
}
static void case_garbage_clock(void)
{
printf("B. 真实 home_tick:入参 now 用真机垃圾 64 位值(低 32 位整秒×1000 + 高 32 位随机)\n");
reset_world();
run_ticks(120, 0, 1);
CHECK(g_retry_probes == 4, "垃圾 now 下重试次数 = %d(期望 4;旧实现此时每 tick 都重试)",
g_retry_probes);
int gaps_ok = 1;
for (int i = 1; i < g_retry_probes && i < 64; i++)
if (g_probe_at[i] - g_probe_at[i - 1] < 30000) gaps_ok = 0;
CHECK(gaps_ok, "垃圾 now 下间隔仍 ≥30000ms(节奏只认 core 单调钟)");
}
static void case_flap(void)
{
printf("C. 真实 home_tick:online/offline 每 tick 抖动 60s\n");
reset_world();
run_ticks(60, 1, 0);
CHECK(g_retry_probes == 2, "抖动下重试次数 = %d(期望 2:0/30s;旧实现随抖动加速)",
g_retry_probes);
if (g_retry_probes >= 2)
CHECK(g_probe_at[1] - g_probe_at[0] >= 30000, "抖动下间隔 = %lldms ≥30000",
(long long)(g_probe_at[1] - g_probe_at[0]));
}
static void case_recover(void)
{
printf("D. 真实 home_tick:回在线再断网(在线期间不得重置节奏)\n");
reset_world();
run_ticks(35, 0, 0); /* 0..35s 断网:0s / 30s 各一次 */
CHECK(g_retry_probes == 2, "35s 断网重试 %d 次(期望 2)", g_retry_probes);
g_offline = 0; /* 等价于 session_gate 把它切回在线 */
run_ticks(5, 0, 0); /* 35..40s 在线:不该有重试 */
CHECK(g_retry_probes == 2, "回在线期间不重试(%d)", g_retry_probes);
g_offline = 1; /* 40s 再次断网 */
run_ticks(30, 0, 0); /* 40..70s:上次尝试在 30s,故下一次落在 60s */
CHECK(g_retry_probes == 3, "再次断网重试 %d 次(期望 3)", g_retry_probes);
if (g_retry_probes >= 3)
CHECK(g_probe_at[2] - g_probe_at[1] >= 30000,
"跨越在线/离线切换后间隔 = %lldms ≥30000(在线不得清零基准)",
(long long)(g_probe_at[2] - g_probe_at[1]));
CHECK(g_paint_full >= 0, "离线态翻转路径可重绘(%d 次)", g_paint_full);
}
int main(void)
{
printf("home_retry_sim — M10 真实 home_tick 离线重试回归(REPORT §42)\n\n");
case_steady();
case_garbage_clock();
case_flap();
case_recover();
printf("\nhome_retry_sim: %d-%d\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}
+64 -4
View File
@@ -15,6 +15,12 @@
* chunked 完整(0 分块末端) → BS_DONE → NCM_OK
* chunked 中途关闭 → 截断 → 非 NCM_OK(NCM_ERR_IO)
*
* M9 追加(传输层错误归一):mbedtls 裸负码不得原样穿透给上层——
* 握手失败(fatal alert) → NCM_ERR_NET
* 握手超时 → NCM_ERR_TIMEOUT
* 读阶段 fatal alert(对端 reset/拒绝) → NCM_ERR_NET
* CA 加载/校验失败(verify_ca=1) → NCM_ERR_TLS
*
* 编译(在 ncm_app 下):
* gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -Ivendor/mbedtls/include \
* -c bench/http_truncate_sim.c -o /tmp/t.o
@@ -48,6 +54,8 @@ static const tr_chunk_t *g_script;
static int g_script_n;
static int g_script_i;
static int g_close_rc; /* 脚本耗尽后的 ssl_read 返回值 */
static int g_handshake_rc; /* M9:0=正常;否则握手直接失败(模拟网络/TLS 异常) */
static int g_ca_fail; /* M9:1=CA 加载失败(verify_ca=1 时应判 NCM_ERR_TLS) */
static void tr_script(const tr_chunk_t *chunks, int n, int close_rc) {
g_script = chunks; g_script_n = n; g_script_i = 0; g_close_rc = close_rc;
@@ -110,13 +118,13 @@ void mbedtls_ssl_set_bio(mbedtls_ssl_context *ssl, void *p_bio,
mbedtls_ssl_recv_timeout_t *f_recv_timeout) {
(void)ssl; (void)p_bio; (void)f_send; (void)f_recv; (void)f_recv_timeout;
}
int mbedtls_ssl_handshake(mbedtls_ssl_context *ssl) { (void)ssl; return 0; }
int mbedtls_ssl_handshake(mbedtls_ssl_context *ssl) { (void)ssl; return g_handshake_rc; }
void mbedtls_ssl_free(mbedtls_ssl_context *ssl) { (void)ssl; }
void mbedtls_ssl_config_free(mbedtls_ssl_config *conf) { (void)conf; }
void mbedtls_x509_crt_init(mbedtls_x509_crt *crt) { (void)crt; }
int mbedtls_x509_crt_parse_file(mbedtls_x509_crt *crt, const char *path) {
(void)crt; (void)path; return 0; /* CA 加载成功(validate=0 时不走此径,桩兜底) */
(void)crt; (void)path; return g_ca_fail ? -1 : 0; /* CA 加载成功/失败(M9 用例可控) */
}
void mbedtls_x509_crt_free(mbedtls_x509_crt *crt) { (void)crt; }
@@ -136,12 +144,13 @@ static int on_body(void *ctx, const unsigned char *data, size_t len) {
/* 运行一个用例:装填脚本 → 发起 GET 下载 → 记录 rc / body_len / content_length */
typedef struct { int rc; size_t body_len; size_t content_length; } case_result_t;
static case_result_t run_case(const tr_chunk_t *script, int script_n, int close_rc) {
static case_result_t run_case_ex(const tr_chunk_t *script, int script_n, int close_rc,
int verify_ca) {
ncm_http_req_t req;
memset(&req, 0, sizeof req);
req.url = "https://ex.test/song.mp3";
req.method = "GET";
req.verify_ca = 0; /* 跳过 CA 校验,聚焦 body 终态判定 */
req.verify_ca = verify_ca; /* 0=跳过 CA 校验,聚焦 body 终态判定 */
req.timeout_ms = 3000;
sink_cb_t sink; memset(&sink, 0, sizeof sink);
@@ -157,6 +166,10 @@ static case_result_t run_case(const tr_chunk_t *script, int script_n, int close_
return r;
}
static case_result_t run_case(const tr_chunk_t *script, int script_n, int close_rc) {
return run_case_ex(script, script_n, close_rc, 0);
}
static int g_fail = 0;
static void check(const char *name, int ok, const char *detail) {
if (!ok) g_fail++;
@@ -286,6 +299,53 @@ int main(void) {
(r.rc != NCM_OK) && (r.rc == NCM_ERR_IO), dbg);
}
/* ---- 场景 9(M9):握手 fatal alert(对端 reset/拒绝)→ 归一为 NCM_ERR_NET ---- */
{
g_handshake_rc = MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE;
case_result_t r = run_case(NULL, 0, 0);
g_handshake_rc = 0;
snprintf(dbg, sizeof dbg, "rc=%d", r.rc);
check("handshake_fatal_alert -> NCM_ERR_NET", r.rc == NCM_ERR_NET, dbg);
}
/* ---- 场景 10(M9):握手超时 → NCM_ERR_TIMEOUT ---- */
{
g_handshake_rc = MBEDTLS_ERR_SSL_TIMEOUT;
case_result_t r = run_case(NULL, 0, 0);
g_handshake_rc = 0;
snprintf(dbg, sizeof dbg, "rc=%d", r.rc);
check("handshake_timeout -> NCM_ERR_TIMEOUT", r.rc == NCM_ERR_TIMEOUT, dbg);
}
/* ---- 场景 11(M9):读阶段 fatal alert(连接被 reset)→ NCM_ERR_NET ---- */
{
case_result_t r = run_case(NULL, 0, MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE);
snprintf(dbg, sizeof dbg, "rc=%d", r.rc);
check("read_fatal_alert -> NCM_ERR_NET", r.rc == NCM_ERR_NET, dbg);
}
/* ---- 场景 12(M9):CA 不可用(verify_ca=1)→ NCM_ERR_TLS(不静默放行) ---- */
{
g_ca_fail = 1;
case_result_t r = run_case_ex(NULL, 0, 0, 1);
g_ca_fail = 0;
snprintf(dbg, sizeof dbg, "rc=%d", r.rc);
check("ca_load_fail -> NCM_ERR_TLS", r.rc == NCM_ERR_TLS, dbg);
}
/* ---- 场景 13(M9):chunked 分块尺寸畸形("ZZ" 非十六进制)→ 数据错误 NCM_ERR_JSON,
* 不得在 http_ret_norm 里被改判成 NCM_ERR_NET(否则误报离线)。 ---- */
{
static const char chunk[] =
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
"ZZ\r\ndata"; /* 分块尺寸行非法 */
static const tr_chunk_t script[] = { { (const unsigned char *)chunk, sizeof chunk - 1 } };
case_result_t r = run_case(script, 1, 0);
snprintf(dbg, sizeof dbg, "rc=%d", r.rc);
check("chunked_bad_size -> NCM_ERR_JSON (数据错误,不改判 NET)",
r.rc == NCM_ERR_JSON, dbg);
}
printf("\n== HTTP TRUNCATE SIM: %s ==\n", g_fail ? "FAIL" : "ALL PASS");
return g_fail ? 1 : 0;
}
+145
View File
@@ -0,0 +1,145 @@
/* bench/lyric_sim.c — 歌词「掉线失败 → 重连 → 重试成功」宿主离线回归(0 真实请求)。
*
* 驱动真实 app/play/player_manager.c 的 lyric_worker(后台歌词线程),网络换成可注入的
* ncm_api_lyric 桩:第 1 次调用返回 NCM_ERR_NET(模拟掉线瞬间歌词请求失败),第 2 次返回
* NCM_OK + 一段 LRC(模拟网络恢复后重试成功)。
*
* 复现的真机症状(REPORT/任务):播放中歌词正常;网络掉线一会儿再重连后某曲恒「暂无歌词」。
* 根因:旧 lyric_worker 在**发起网络请求前**就把 last_id 记为已拉,且失败也置 lyric_state=2
* (=已拉完),故网络失败被写死成「无歌词」,重连后永不重试。本机回归:
* ① 第 1 次失败后 lyric_state() 必须**不是** 2(可重试),且无词(lyric_count()==0);
* ② 推进单调时钟越过退避点(模拟重连)后必须**自动重试**并成功(第 2 次调用)+ 词展示
* (lyric_count()>0、lyric_state()==2)。
* 旧代码在 ② 上 FAIL(g_lyr_state 恒 2、第 2 次调用永不发生)。
*
* 编译(宿主,ncm_app 目录下;gc-sections 只保留歌词路径,避免链整个播放链的符号):
* CC=gcc F="-ffunction-sections -Wall -Wextra -DJSMN_HEADER -I. -Icore -I../sdk -I../meow_launcherd -Ivendor/jsmn"
* $CC $F -c bench/lyric_sim.c -o /tmp/ly.o
* $CC $F -c app/play/player_manager.c -o /tmp/ly_pm.o
* $CC $F -c app/logic/lyric.c -o /tmp/ly_lrc.o
* $CC -Wl,--gc-sections -o lyric_sim /tmp/ly.o /tmp/ly_pm.o /tmp/ly_lrc.o -lpthread -lm
* 运行:./lyric_sim (PASS/FAIL 退出码)
*/
#include "../app/ncm_app_internal.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h> /* usleep */
/* ---- 全局状态(app.c 定义;本 sim 提供桩) ---- */
ncm_app_state_t g_st;
ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 };
volatile int g_app_quit = 0;
volatile int g_net_ready = 0;
int g_host_mock = 1;
int g_dark = 0;
/* ---- 可控单调时钟(lyric_worker 用 ncm_monotonic_ms 做失败退避) ---- */
static int64_t g_now_ms = 1000;
int64_t ncm_monotonic_ms(void) { return g_now_ms; }
size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
{
size_t n = 0;
if (sz) { while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; } dst[n] = 0; }
while (src[n]) n++;
return n;
}
void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; }
/* appstate 桩:单曲队列(idx 0 恒有效,song id=1000) */
int appstate_queue_count(void) { return 1; }
/* page-cache 治理桩:恒非暂停(不挡歌词预取) */
int meow_cachegov_prefetch_paused(void) { return 0; }
/* 媒体会话歌词上报桩:失败上报空、成功上报 LRC;计数供断言 */
static int g_report_empty;
static int g_report_lyric;
static char g_report_last_lrc[256];
void ipc_media_lyric_report(const char *lrc, const char *tl)
{
(void)tl;
if (lrc && lrc[0]) { g_report_lyric++; ncm_strlcpy(g_report_last_lrc, lrc, sizeof g_report_last_lrc); }
else { g_report_empty++; }
}
/* 歌词 API 桩:第 1 次=网络失败,其后=成功返回 LRC。 */
static int g_fetch_calls;
int ncm_api_lyric(const char *device_id, const char *cookie, const char *id,
char *lrc, size_t lrc_sz, char *tlrc, size_t tlrc_sz)
{
(void)device_id; (void)cookie; (void)id; (void)tlrc; (void)tlrc_sz;
g_fetch_calls++;
if (g_fetch_calls == 1) {
if (lrc && lrc_sz) lrc[0] = 0; /* 掉线失败:不写词 */
return NCM_ERR_NET; /* 网络错误(掉线) */
}
static const char body[] =
"[00:00.00]Line one\n[00:05.00]Line two\n[00:10.00]Line three\n";
if (lrc && lrc_sz) snprintf(lrc, lrc_sz, "%s", body);
return NCM_OK;
}
static void wait_ms(int ms)
{
for (int i = 0; i < ms; i += 20) {
usleep(20000);
if (g_app_quit) break;
}
}
static int wait_until(const volatile int *val, int target, int timeout_ms)
{
for (int i = 0; i < timeout_ms; i += 10) {
if (*val >= target) return 1;
usleep(10000);
}
return (*val >= target);
}
int main(void)
{
memset(&g_st, 0, sizeof g_st);
pthread_mutex_init(&g_play.mu, NULL);
g_play.state = NCM_PC_PLAYING;
g_play.cur_song = 0;
g_play.play_song_valid = 1;
ncm_strlcpy(g_play.play_song.id, "1000", sizeof g_play.play_song.id);
if (player_manager_load_lyric() != NCM_OK) { printf("FAIL start worker\n"); return 1; }
int ok = 1;
/* ① 第 1 次拉取失败(网络错误):须可重试(state!=2)、无词 */
if (!wait_until(&g_fetch_calls, 1, 3000)) { printf("FAIL first fetch never happened\n"); return 1; }
wait_ms(400); /* 让 worker 完成失败收尾(state=0, retry_at 已算) */
int st1 = lyric_state();
int n1 = lyric_count();
int ok1 = (g_fetch_calls == 1 && st1 != 2 && n1 == 0);
printf("step1 failure: calls=%d state=%d (expect !=2) count=%d rep_empty=%d\n",
g_fetch_calls, st1, n1, g_report_empty);
printf(" -> %s\n", ok1 ? "PASS" : "FAIL");
ok &= ok1;
/* 模拟网络重连:推进单调时钟越过失败退避点 */
g_now_ms = 10000;
/* ② 重试自动发生并成功:第 2 次调用、state=2、词已展示 */
if (!wait_until(&g_fetch_calls, 2, 4000)) {
printf("FAIL retry never fired (calls=%d) — old code keeps state=2, no retry\n", g_fetch_calls);
return 1;
}
int st2 = lyric_state();
int n2 = lyric_count();
int ok2 = (g_fetch_calls == 2 && st2 == 2 && n2 > 0 && g_report_lyric >= 1);
printf("step2 retry: calls=%d state=%d count=%d rep_lyric=%d lrc=%s\n",
g_fetch_calls, st2, n2, g_report_lyric, g_report_last_lrc);
printf(" -> %s\n", ok2 ? "PASS" : "FAIL");
ok &= ok2;
g_app_quit = 1;
printf("lyric_sim overall -> %s\n", ok ? "PASS" : "FAIL");
fflush(stdout);
return ok ? 0 : 1;
}
+235
View File
@@ -0,0 +1,235 @@
/* bench/meta_ipc_sim.c — M10:真实 app/play/ipc_media.c 的 META 上报频率回归(假 launcher socket)。
*
* 真机证据:launcher meow_20261105_122541.log 单次启动 21016 条
* `ipc: META app=dev.player.ncm state=0 pos=0/0 rc=0 -> ACK`,间隔 ~100ms。
* 旧 ipc_media 用 `now(meow_ui_time_ms) - last_meta >= 1000` 做 1s 心跳;真机该值高 32 位是垃圾
* (REPORT §42)→ 条件恒真 → 每个 IPC 轮次(200ms poll)都发一帧。
*
* 本测试把真实 ipc_media.c 编进宿主(--gc-sections 只保留 ipc_media_start 调用链),
* 在临时目录起一个 AF_UNIX “假 launcher”,统计 App 真正发出的 MSG_META 帧数:
* A. 空闲(无播放、无元数据)2s → 只应有 1 帧(注册媒体源),旧实现为 ~10 帧;
* B. 播放态翻转一次 → 只多 1 帧(状态变化立即上报),之后持续播放 2s 不再刷屏;
* C. 帧格式合法(magic/version/app_id),且没有 MSG_META 以外的上行帧。
*
* 编译(ncm_app 目录下):
* gcc -O2 -Wall -Wextra -ffunction-sections -fdata-sections -DMEOW_UI_HOST_TEST -DJSMN_HEADER \
* -I. -I../sdk -I../sdk/include -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c app/play/ipc_media.c -o out/chk/ipc_m10.o
* gcc -O2 -Wall -Wextra -ffunction-sections -fdata-sections -DMEOW_UI_HOST_TEST -DJSMN_HEADER \
* -I. -I../sdk -I../sdk/include -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c app/play/meta_gate.c -o out/chk/meta_gate_m10.o
* gcc -O2 -Wall -Wextra -ffunction-sections -fdata-sections -DMEOW_UI_HOST_TEST -DJSMN_HEADER \
* -I. -I../sdk -I../sdk/include -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c bench/meta_ipc_sim.c -o out/chk/meta_ipc_sim.o
* gcc -Wl,--gc-sections -o meta_ipc_sim out/chk/ipc_m10.o out/chk/meta_gate_m10.o \
* out/chk/meta_ipc_sim.o -lpthread
* ./meta_ipc_sim
*/
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <poll.h>
#include <time.h>
#include <pthread.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/stat.h>
#include "../app/ncm_app_internal.h"
#include "meow_ipc.h"
/* ---- app.c 全局 ---- */
ncm_app_state_t g_st;
ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER,
.state = NCM_PC_STOPPED, .cur_song = -1 };
volatile int g_app_quit = 0;
volatile int g_net_ready = 0;
int g_host_mock = 0;
static int g_pass, g_fail;
#define CHECK(cond, ...) do { \
int _ok = (cond) ? 1 : 0; \
printf(" %s ", _ok ? "PASS" : "FAIL"); printf(__VA_ARGS__); printf("\n"); \
if (_ok) g_pass++; else g_fail++; \
} while (0)
/* ---- 播放器桩(本测试只关心「有没有播放/元数据变化」) ---- */
static volatile int g_pm_state = NCM_PC_STOPPED;
int player_manager_state(void) { return g_pm_state; }
int player_manager_playing_song(void) { return -1; }
int player_manager_current_song(ncm_song_t *out) { (void)out; return -1; }
void player_manager_pause(int on) { g_pm_state = on ? NCM_PC_PAUSED : NCM_PC_PLAYING; }
void player_manager_next(int dir) { (void)dir; }
void player_manager_seek(int64_t ms) { (void)ms; }
const char *cover_current_path(void) { return ""; }
int lyric_count(void) { return 0; }
/* ---- 日志/时间桩 ---- */
static volatile int g_meta_logs;
void meow_log(meow_log_level lvl, const char *fmt, ...)
{
(void)fmt;
if (lvl == MEOW_LOG_DEBUG) g_meta_logs++;
}
int64_t ncm_monotonic_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
size_t ncm_strlcpy(char *dst, const char *src, size_t cap)
{
size_t n = 0;
if (!dst || cap == 0) return src ? strlen(src) : 0;
if (src) { for (; src[n] && n + 1 < cap; n++) dst[n] = src[n]; }
dst[n] = 0;
return n;
}
/* ============================================================
* 假 launcher:AF_UNIX 服务端 + 帧计数
* ============================================================ */
static int g_srv = -1, g_cli = -1;
static char g_sock_path[256];
static int g_bad_frames;
static char g_last_app_id[64];
static void set_play_state(int st)
{
pthread_mutex_lock(&g_play.mu);
g_play.state = st; /* META 的 state 取自 g_play(与真实播放线程同源) */
g_play.err = 0;
pthread_mutex_unlock(&g_play.mu);
}
static void put_hdr(uint8_t *h, uint16_t type, uint32_t req, uint32_t len)
{
uint32_t magic = MEOW_IPC_MAGIC;
uint16_t ver = MEOW_IPC_VERSION;
memcpy(h + 0, &magic, 4); memcpy(h + 4, &ver, 2);
memcpy(h + 6, &type, 2); memcpy(h + 8, &req, 4);
memcpy(h + 12, &len, 4);
}
static int srv_open(void)
{
snprintf(g_sock_path, sizeof g_sock_path, "%s/m10_ipc.sock",
getenv("TMPDIR") && getenv("TMPDIR")[0] ? getenv("TMPDIR") : "/tmp");
unlink(g_sock_path);
g_srv = socket(AF_UNIX, SOCK_STREAM, 0);
if (g_srv < 0) return -1;
struct sockaddr_un a;
memset(&a, 0, sizeof a);
a.sun_family = AF_UNIX;
snprintf(a.sun_path, sizeof a.sun_path, "%s", g_sock_path);
if (bind(g_srv, (struct sockaddr *)&a, sizeof a) != 0) return -1;
if (listen(g_srv, 1) != 0) return -1;
setenv("MEOW_IPC_SOCK", g_sock_path, 1);
return 0;
}
/* 收一帧(带超时)。返回帧长(负载),*type 出帧类型;-1=超时/断开。 */
static int srv_read(int timeout_ms, uint16_t *type, uint8_t *buf, int cap)
{
struct pollfd p = { .fd = g_cli, .events = POLLIN };
if (poll(&p, 1, timeout_ms) <= 0) return -1;
uint8_t hdr[MEOW_IPC_HDR_LEN];
size_t off = 0;
while (off < sizeof hdr) {
ssize_t r = read(g_cli, hdr + off, sizeof hdr - off);
if (r <= 0) return -1;
off += (size_t)r;
}
uint32_t magic, len; uint16_t ver;
memcpy(&magic, hdr, 4); memcpy(&ver, hdr + 4, 2);
memcpy(type, hdr + 6, 2); memcpy(&len, hdr + 12, 4);
if (magic != MEOW_IPC_MAGIC || ver != MEOW_IPC_VERSION || len > (uint32_t)cap) {
g_bad_frames++;
return -1;
}
off = 0;
while (off < len) {
ssize_t r = read(g_cli, buf + off, len - off);
if (r <= 0) return -1;
off += (size_t)r;
}
return (int)len;
}
/* 收 HELLO 并回一帧 ACK(ipc_thread 等这一帧才进入主循环)。 */
static int srv_handshake(void)
{
struct pollfd p = { .fd = g_srv, .events = POLLIN };
if (poll(&p, 1, 2000) <= 0) return -1;
g_cli = accept(g_srv, NULL, NULL);
if (g_cli < 0) return -1;
uint8_t buf[MEOW_IPC_MAX]; uint16_t type = 0;
if (srv_read(2000, &type, buf, sizeof buf) < 0) return -1;
if (type != MSG_HELLO) return -1;
m_hello_t hh;
memset(&hh, 0, sizeof hh);
memcpy(&hh, buf, sizeof hh < sizeof buf ? sizeof hh : sizeof buf);
snprintf(g_last_app_id, sizeof g_last_app_id, "%s", hh.app_id);
uint8_t out[MEOW_IPC_HDR_LEN + sizeof(m_ack_t)];
m_ack_t ack; memset(&ack, 0, sizeof ack); ack.code = MEOW_OK;
put_hdr(out, MSG_ACK, 1, (uint32_t)sizeof ack);
memcpy(out + MEOW_IPC_HDR_LEN, &ack, sizeof ack);
return (write(g_cli, out, sizeof out) == (ssize_t)sizeof out) ? 0 : -1;
}
/* 收 run_ms 内的全部上行帧,按类型计数。 */
static void srv_collect(int run_ms, int *meta, int *other)
{
int64_t end = ncm_monotonic_ms() + run_ms;
uint8_t buf[MEOW_IPC_MAX];
*meta = *other = 0;
while (ncm_monotonic_ms() < end) {
uint16_t type = 0;
int n = srv_read(50, &type, buf, sizeof buf);
if (n < 0) continue;
if (type == MSG_META) (*meta)++;
else (*other)++;
}
}
int main(void)
{
printf("meta_ipc_sim — M10 真实 ipc_media 线程 META 帧频回归(REPORT §42)\n\n");
if (srv_open() != 0) { printf(" FAIL 假 launcher socket 建立失败 (%s)\n", g_sock_path); return 1; }
if (ipc_media_start() != 0) { printf(" FAIL ipc_media_start\n"); return 1; }
if (srv_handshake() != 0) { printf(" FAIL HELLO 握手失败\n"); return 1; }
printf("A. 空闲(state=0,无播放/无元数据)2s\n");
int meta = 0, other = 0;
srv_collect(2000, &meta, &other);
CHECK(meta == 1, "上行 META 帧 = %d(期望 1:注册媒体源;旧实现 ~10 帧/2s)", meta);
CHECK(other == 0, "无其它上行帧(%d)", other);
CHECK(strcmp(g_last_app_id, "dev.player.ncm") == 0, "HELLO app_id = %s", g_last_app_id);
printf("B. 播放态变化(0 → PLAYING)后继续空闲 2s\n");
set_play_state(NCM_PC_PLAYING);
int meta2 = 0, other2 = 0;
srv_collect(2000, &meta2, &other2);
CHECK(meta2 == 1, "状态变化只上报 1 帧(%d)", meta2);
/* 停止播放:又是一次状态变化 → 再 1 帧 */
set_play_state(NCM_PC_STOPPED);
int meta3 = 0, other3 = 0;
srv_collect(2000, &meta3, &other3);
CHECK(meta3 == 1, "停止播放只上报 1 帧(%d)", meta3);
CHECK(g_bad_frames == 0, "帧头 magic/version/长度全部合法(坏帧 %d)", g_bad_frames);
ipc_media_stop();
g_app_quit = 1;
usleep(400 * 1000); /* 让 ipc 线程在 poll 超时内退出 */
if (g_cli >= 0) close(g_cli);
if (g_srv >= 0) close(g_srv);
unlink(g_sock_path);
printf("\nmeta_ipc_sim: %d-%d\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}
+2 -1
View File
@@ -17,7 +17,8 @@
* -Icore -I../vendor/jsmn -c app/logic/appstate.c -o /tmp/ps_app.o
* $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -I../vendor/jsmn -c app/logic/songmeta.c -o /tmp/ps_meta.o
* $CC_HOST -O2 -Wl,--gc-sections -o pagination_sim /tmp/ps.o /tmp/ps_app.o /tmp/ps_meta.o -lpthread
* $CC_HOST -O2 -Wl,--gc-sections -o pagination_sim /tmp/ps.o /tmp/ps_app.o /tmp/ps_meta.o \
* out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread
* 运行:./pagination_sim (0 网络;PASS/FAIL 退出码)
*/
#include "../app/ncm_app_internal.h"
+98 -22
View File
@@ -14,8 +14,8 @@
* P12 切音质后重播同一曲(§25):同 idx 在播时再 play —— 修前被解码主循环忽略
* (旧曲继续出声、state 恒 BUFFER/「缓冲中」,收尾被外层当播完顺延下一首);
* 修后 seq 变化即重启换档,状态回 PLAYING、新档位下载、eff_level 回填。
* P13 陈旧 hires.mp3(flac 门禁前静默降级产物,hires 键下存 mp3):song_local_file
* 迁移重命名到 exhigh 键(song_<id>_exhigh.mp3)+ 本档视为 miss 走下载(§NEW 任务2)。
* P13 旧命名 hires.mp3(卡上存量:hires 键下存 mp3):M6 起按文件名档位+容器钳顶显示
* EXHIGH 直接播放,不重下、不迁移(老卡文件继续可播/可删/可淘汰)。
* P14 歌单里点「正在播的同曲」→ 只聚焦不重播(任务3 re-entry,同曲 id 判据)。
* P15 跨歌单切换后同一队列下标点歌 → 不被同曲护栏吞,起播(Bug2 回归:真机日志
* `play: re-enter current idx=0 state=2 -> no restart`,新歌永不播)。
@@ -87,12 +87,22 @@ int appstate_queue_song(int idx, ncm_song_t *out) { return appstate_song_copy(id
* sim 恒歌单窗口队列、搜索数组为空。appstate.c 未链入,两符号此处提供桩。 */
int appstate_get_queue(void) { return NCM_QUEUE_PLAYLIST; }
int appstate_search_song(int i, ncm_song_t *out) { (void)i; (void)out; return NCM_ERR_ARG; }
/* M5 已缓存列表 / 文件内 tag:本 sim 不链 appstate.c、ncm_tag.c(缓存列表语义由 M5/M6 的
* core 测试覆盖),仅提供链接桩避免 gc-sections 之外的空洞。 */
int appstate_cache_list_load(void) { return 0; }
int appstate_cache_count(void) { return 0; }
int appstate_cache_item(int i, ncm_cache_item_t *out) { (void)i; (void)out; return NCM_ERR_ARG; }
int appstate_cache_song(int i, ncm_song_t *out) { (void)i; (void)out; return NCM_ERR_ARG; }
int appstate_cache_remove(int i) { (void)i; return NCM_ERR_ARG; }
void appstate_cache_clear(void) { }
int ncm_tag_embed_file(const char *path, const char *ext, const ncm_tag_meta_t *m)
{ (void)path; (void)ext; (void)m; return NCM_ERR_ARG; } /* 降级为裸流(生产路径容忍) */
/* ---- 缓存/网络 stub ----
* g_cache_ok=1 → lookup 命中(给真实存在的 /tmp 文件,免下载);
* 否则走下载:song_url(g_url_rc 可注入失败) → http_download(g_dl_delay_ms 模拟慢) → commit。 */
static int g_cache_ok = 1;
static char g_cache_real[600]; /* g_cache_ok==2:lookup 命中返回此真实路径(P13 陈旧 mp3 迁移用) */
static char g_cache_real[600]; /* g_cache_ok==2:lookup 命中返回此真实路径(P13 旧命名命中用) */
static int g_url_rc = NCM_OK;
static int g_dl_delay_ms = 0;
static int g_downloads; /* http_download 调用数(song_url 请求放大检测) */
@@ -138,7 +148,7 @@ static void pf_ensure_sim_mp3(void)
int ncm_cache_lookup(const ncm_cache_t *c, const char *id, const char *ext, char *out, size_t out_sz)
{
(void)c; (void)id;
if (g_cache_ok == 2) { /* P13 真实命中模式:仅 mp3 命中(stale hires 实体是 mp3) */
if (g_cache_ok == 2) { /* P13 真实命中模式:仅 mp3 命中(旧命名 hires.mp3 实体是 mp3) */
if (ext && strcmp(ext, "mp3") == 0 && g_cache_real[0]) {
snprintf(out, out_sz, "%s", g_cache_real);
return NCM_OK;
@@ -195,6 +205,59 @@ int ncm_cache_path(const ncm_cache_t *c, const char *id, const char *ext, char *
(c && c->dir[0]) ? c->dir : "/tmp", id ? id : "", e ? e : "bin");
return NCM_OK;
}
/* M6:player_manager 用 core 的实际档判定/路径档位解析(本 sim 不链 core/ncm_cache.c,
* 此处按 core 同规则提供桩——命名规则本身由 test/test_ncm_cache_host 全量断言)。 */
int ncm_cache_actual_level(int req, const ncm_audio_info_t *ai, int resp_level)
{
int cap = -1, v;
if (resp_level < 0) resp_level = req;
if (ai && ai->container[0]) {
if (strcmp(ai->container, "flac") == 0) {
if (ai->bits_per_sample <= 0 || ai->sample_rate <= 0) cap = -1;
else if (ai->bits_per_sample <= 16) cap = NCM_LEVEL_LOSSLESS;
else if (ai->sample_rate > 96000) cap = NCM_LEVEL_JYMASTER;
else if (ai->sample_rate > 48000) cap = NCM_LEVEL_JYEFFECT;
else cap = NCM_LEVEL_HIRES;
} else if (strcmp(ai->container, "mp3") == 0) {
if (ai->bitrate_kbps > 128) cap = NCM_LEVEL_EXHIGH;
else if (ai->bitrate_kbps > 0) cap = NCM_LEVEL_STANDARD;
}
}
if (ai && strcmp(ai->container, "mp3") == 0) {
if (cap < 0) cap = NCM_LEVEL_EXHIGH;
if (resp_level >= 0 && resp_level < cap) cap = resp_level;
} else if (cap < 0) {
cap = resp_level;
} else if (resp_level >= 0 && resp_level < cap) {
cap = resp_level;
}
if (req < 0) v = (cap < 0) ? -1 : cap;
else if (cap < 0) v = req;
else v = (req < cap) ? req : cap;
return v;
}
int ncm_cache_level_from_path(const char *path)
{
const char *base, *dot, *us = NULL, *us2 = NULL;
char key[20];
size_t n;
int lv;
if (!path || !path[0]) return -1;
base = strrchr(path, '/'); base = base ? base + 1 : path;
dot = strrchr(base, '.'); if (!dot) dot = base + strlen(base);
for (const char *p = base; p < dot; p++) if (*p == '_') { us2 = us; us = p; }
if (!us || us + 1 >= dot) return -1;
n = (size_t)(dot - us - 1);
if (n >= sizeof key) return -1;
memcpy(key, us + 1, n); key[n] = 0;
lv = ncm_api_level_from_key(key);
if (lv >= 0 || !us2 || us2 + 1 >= us - 1) return lv;
n = (size_t)(dot - us2 - 1);
if (n >= sizeof key) return -1;
memcpy(key, us2 + 1, n); key[n] = 0;
return ncm_api_level_from_key(key);
}
int ncm_api_song_url_auto(const char *dev, const char *ck, const char *id, int level,
ncm_play_url_t *out, int *out_level)
@@ -310,6 +373,20 @@ const char *ncm_api_level_key(int level)
default: return "standard";
}
}
int ncm_api_level_from_key(const char *key)
{
if (!key || !key[0]) return -1;
if (!strcmp(key, "sky_c51")) return NCM_LEVEL_SURROUND;
if (!strcmp(key, "sky")) return NCM_LEVEL_SKY;
if (!strcmp(key, "jymaster")) return NCM_LEVEL_JYMASTER;
if (!strcmp(key, "jyeffect")) return NCM_LEVEL_JYEFFECT;
if (!strcmp(key, "hires")) return NCM_LEVEL_HIRES;
if (!strcmp(key, "lossless")) return NCM_LEVEL_LOSSLESS;
if (!strcmp(key, "exhigh")) return NCM_LEVEL_EXHIGH;
if (!strcmp(key, "higher")) return NCM_LEVEL_HIGHER;
if (!strcmp(key, "standard")) return NCM_LEVEL_STANDARD;
return -1;
}
/* ---- 音频输出 stub:g_write_fail 注入写失败 ---- */
static int g_write_fail;
@@ -467,8 +544,8 @@ int main(void)
g_url_req_level, NCM_LEVEL_EXHIGH);
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
"eff_level=%d (expect EXHIGH,UI 标「已降级至 极高 HQ」)", player_manager_eff_level());
CHECK(strcmp(g_last_key, "1000_sky_c51") == 0,
"cache key=%s (expect 1000_sky_c51 = id+用户档,降级产物归用户档键)", g_last_key);
CHECK(strcmp(g_last_key, "1000_exhigh") == 0,
"cache key=%s (expect 1000_exhigh = 实际档键,非用户档 sky_c51)", g_last_key);
/* SVIP:同一用户档不钳顶,直达 SURROUND。
* §21 时序:commit 移到 song0 曲尾 dispose(边下边播)——先 stop 阻断外层自动顺延
* 到 song1 的竞态,再等 commit 落地后才切缓存开关,否则 commit stub 会把
@@ -500,8 +577,7 @@ int main(void)
player_manager_error());
CHECK(g_url_calls == 0, "song_url 零请求(省一次请求,防风控;calls=%d)", g_url_calls);
/* 已缓存的 VIP 曲:门禁不拦(缓存优先,NCM_CACHE_DESIGN §2.4)。
* 注:把档位降回 EXHIGH——缓存 stub 的「命中」恒是 mp3;用户档若仍 SURROUND(>EXHIGH),
* 会被 §NEW 任务2 当作静默降级产物迁移/重下,与「绕过门禁」的本意不符(P13 单独测迁移)。 */
* 档位设 EXHIGH:stub 缓存恒返回 mp3(mp3 实际档不高于极高),与条目标注一致。 */
player_manager_set_quality(NCM_LEVEL_EXHIGH, 10);
g_cache_ok = 1;
player_manager_play(2);
@@ -595,8 +671,8 @@ int main(void)
CHECK(wait_state(NCM_PC_PLAYING, 4000),
"re-tap same idx -> back to PLAYING (expect 换档开播,非卡 缓冲中)");
CHECK(g_downloads >= 1, "re-tap started new-quality download (downloads=%d)", g_downloads);
CHECK(strcmp(g_last_key, "1000_lossless") == 0,
"re-tap cache key=%s (expect 1000_lossless = 新档位)", g_last_key);
CHECK(strcmp(g_last_key, "1000_exhigh") == 0,
"re-tap cache key=%s (expect 1000_exhigh = 实际档,非会员钳顶后 320k mp3)", g_last_key);
/* sim 账号非会员 → lossless 被 §20 钳顶到 EXHIGH;关键是 eff_level 已按新意图回填
* (UI 据此显示实际生效档,不再停 BUFFER/「缓冲中」)。 */
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
@@ -604,30 +680,30 @@ int main(void)
g_eof = 1;
wait_state(NCM_PC_STOPPED, 3000);
printf("== P13: 陈旧 hires.mp3(静默降级产物)-> exhigh 迁移 + 本档 miss 重下(§NEW 任务2)==\n");
printf("== P13: 旧命名 hires.mp3(实际 exhigh)-> 命中播放 + 实际档显示,不重下不迁移(M6)==\n");
usleep(300 * 1000);
g_eof = 0; g_reads_left = -1; g_url_rc = NCM_OK; g_fee = 0;
g_part_size = 0; g_http_status = 200; g_416_first = 0; g_dl_delay_ms = 0; g_dl_fail = 0;
g_vip = NCM_VIP_NONE; g_writes = 0; g_downloads = 0; g_url_calls = 0;
g_last_key[0] = 0; g_last_ext[0] = 0; g_url_type = "mp3";
/* 用户档 = Hi-Res(>EXHIGH),缓存实体是一首 mp3 = flac 门禁前把 exhigh 的 mp3 写进 hires
* 键的「静默降级」产物。须被迁移到 exhigh 键(song_1000_exhigh.mp3),本档视为 miss 走下载。 */
unlink("/tmp/song_1000_exhigh.mp3"); /* 保证 exhigh 目标不存在(走 rename 而非删旧) */
/* 用户档 = Hi-Res(>EXHIGH),卡上存量是旧命名 song_1000_hires.mp3,实体是 320k mp3。
* M6:文件名语义改为「实际档」后,此条目按文件名档位+容器钳顶显示 EXHIGH,直接播放;
* 不迁移、不重下(旧文件继续可播/可删/可淘汰)。 */
FILE *hf = fopen("/tmp/pf_hires.mp3", "wb");
if (hf) { fputs("DEGRADED", hf); fclose(hf); }
snprintf(g_cache_real, sizeof g_cache_real, "/tmp/pf_hires.mp3");
g_cache_ok = 2; /* lookup 命中返回 g_cache_real(仅 mp3 键) */
player_manager_set_quality(NCM_LEVEL_HIRES, 10);
player_manager_play(0);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "stale hires.mp3 redownloaded -> plays");
CHECK(g_downloads >= 1, "stale mp3 triggered redownload (downloads=%d)", g_downloads);
CHECK(strcmp(g_last_key, "1000_hires") == 0,
"redownload cache key=%s (expect 1000_hires=用户档键)", g_last_key);
CHECK(strcmp(g_last_ext, "mp3") == 0, "redownload ext=%s (请求档为 mp3,维持原身份)", g_last_ext);
CHECK(wait_state(NCM_PC_PLAYING, 3000), "legacy hires.mp3 hit -> plays directly");
CHECK(g_downloads == 0, "legacy entry hit -> no redownload (downloads=%d)", g_downloads);
CHECK(player_manager_eff_level() == NCM_LEVEL_EXHIGH,
"eff_level=%d (expect EXHIGH:mp3 实体不可能高于极高)", player_manager_eff_level());
struct stat mm;
CHECK(stat("/tmp/song_1000_exhigh.mp3", &mm) == 0,
"exhigh migrated file exists (选「极高」档仍可用)");
CHECK(stat("/tmp/pf_hires.mp3", &mm) != 0, "stale hires.mp3 source gone (已迁走)");
CHECK(stat("/tmp/pf_hires.mp3", &mm) == 0 && mm.st_size == 8,
"legacy file untouched (no migration)");
CHECK(stat("/tmp/song_1000_exhigh.mp3", &mm) != 0,
"no renamed copy created (old card files need no migration)");
g_cache_ok = 0; g_cache_real[0] = 0;
g_eof = 1;
wait_state(NCM_PC_STOPPED, 3000);
+11 -1
View File
@@ -21,7 +21,8 @@
* -c bench/playlist_race_sim.c -o /tmp/pr.o
* GCC -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icore -Ivendor/jsmn \
* -c app/logic/appstate.c -o /tmp/pr_app.o
* GCC -O2 -Wl,--gc-sections -o playlist_race_sim /tmp/pr.o /tmp/pr_app.o -lpthread
* GCC -O2 -Wl,--gc-sections -o playlist_race_sim /tmp/pr.o /tmp/pr_app.o \
* out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread
* 运行:./playlist_race_sim (0 网络;PASS/FAIL 退出码)
*/
#include "../app/ncm_app_internal.h"
@@ -136,6 +137,15 @@ int ncm_api_user_playlist(const char *device_id, const char *cookie, const char
return NCM_OK;
}
/* M9 出参变体(appstate_load_playlists_ex 引用;本 sim 恒「走网络」) */
int ncm_api_user_playlist_ex(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n,
int *out_from_cache)
{
if (out_from_cache) *out_from_cache = 0;
return ncm_api_user_playlist(device_id, cookie, uid, limit, offset, out, max, out_n);
}
static int g_fail = 0;
#define CHECK(cond, fmt, ...) do { \
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
+287
View File
@@ -0,0 +1,287 @@
/* bench/plpage_sim.c — 歌单页「懒加载分页 + 加载后列表刷新」离线回归(0 真实 NCM 请求)。
*
* 驱动真实 app/ui/playlist.c(页面打开 / 滚动触发 / worker / tick 消费全链路)+ 真实
* appstate.c(滑动窗口)+ 真实 songmeta.c(/tmp 盘缓存)+ 宿主 SDK(meow_listview/draw 全真,
* 与 ncm_host 同一批 out/host/sdk_*.o)。网络端点(ncm_api_playlist_detail /
* ncm_api_song_detail)为本地合成 stub(计调用次数、可注入单批失败)。
*
* Bug1(大歌单懒加载分页)验证点:
* ① 打开 400 首歌单:首屏 = 1 detail + 恰 1 批 song_detail(物化 [0,100),第 100 首未物化)
* ——不再首屏连拉多批补满窗口(旧行为 ≤3 批连发,5k+ 歌单阻塞 + 风控);
* ② 顶部小幅滚动(视口底距已分页底部 >20 首):不发任何新请求;
* ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败时 g_paged_hi 不推进(占位保留),
* 再滑到底部重试同一页成功(一次一页,不连拉);
* ④ 逐页到底(400 = 4 页);全部拉完后滑到尾部:不再发请求(停止);
* ⑤ 回滚到顶部:失效槽(窗口挤出)由盘缓存补物化,song_detail 调用数 0 增长(不占网络额度);
* Bug2(加载完列表刷新)验证点:
* ⑥ 首屏/每页 worker 完成经 playlist_tick 消费后,整帧重画计数(--wrap=meow_ui_paint_full)
* 递增,且新物化行经 appstate_song_copy 取到真实歌名/歌手(= pl_src2 将绑定真实数据,
* 不再是「... 加载中」占位)。
* 风控纪律:0 次真实 NCM 请求(离线 stub + /tmp 盘缓存)。
*
* 编译/运行见 build_ncm_sims.sh「plpage_sim」段(宿主,ncm_app 目录下)。
*/
#include "../app/ncm_app_internal.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
ncm_app_state_t g_st;
ncm_play_run_t g_play = { .mu = PTHREAD_MUTEX_INITIALIZER, .state = NCM_PC_STOPPED, .cur_song = -1 };
volatile int g_app_quit = 0;
volatile int g_net_ready = 0;
int g_host_mock = 0; /* 真实分页链路(不走 mock 跳网分支) */
/* ---- core 符号最小实现(同 playlist_race_sim 口径) ---- */
size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
{
size_t n = 0;
if (sz) {
while (src[n] && n < sz - 1) { dst[n] = src[n]; n++; }
dst[n] = 0;
}
while (src[n]) n++;
return n;
}
int64_t ncm_now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
/* ---- cover / 播放 stub(本 sim 不测封面与播放;playlist.c 引用,链接需要) ---- */
void cover_request(const char *url, const char *song_id) { (void)url; (void)song_id; }
meow_img *cover_get(void) { return NULL; }
void cover_put(void) {}
int cover_changed(void) { return 0; }
void cover_ack(void) {}
void cover_cache_reset(void) {}
void player_play_song(int idx) { (void)idx; }
/* ---- core 符号(appstate.c 引用;运行时不到达,仅链接需要) ---- */
int ncm_login_status_check(const char *device_id, const char *cookie, char *uid, size_t uid_sz,
char *nick, size_t nick_sz)
{ (void)device_id;(void)cookie;(void)uid;(void)uid_sz;(void)nick;(void)nick_sz; return NCM_ERR_AUTH; }
int ncm_ctx_clear_cookie(ncm_ctx_t *ctx) { (void)ctx; return NCM_OK; }
int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n)
{ (void)device_id;(void)cookie;(void)uid;(void)limit;(void)offset;(void)out;(void)max;(void)out_n; return NCM_ERR_NET; }
/* M9 出参变体(appstate_load_playlists_ex 引用;本 sim 不到达) */
int ncm_api_user_playlist_ex(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n,
int *out_from_cache)
{ if (out_from_cache) *out_from_cache = 0;
return ncm_api_user_playlist(device_id, cookie, uid, limit, offset, out, max, out_n); }
/* ---- 网络端点 stub(离线,合成数据;计调用次数 + 可注入单批失败) ---- */
#define SIM_TOTAL 400 /* 4 页:100 × 4(> NCM_SONG_WINDOW 256,回滚必滑窗) */
static int g_detail_calls;
static int g_song_calls; /* song_detail 成功批次数 */
static int g_song_attempts; /* song_detail 发起次数(含失败批) */
static int g_song_fail_n; /* 接下来 N 批 song_detail 网络失败模拟 */
int ncm_api_playlist_detail(const char *device_id, const char *cookie, const char *playlist_id,
char *name, size_t name_sz, char *cover, size_t cover_sz,
int64_t *ids, int id_cap, int *out_id_n)
{
(void)device_id; (void)cookie; (void)playlist_id; (void)cover_sz;
g_detail_calls++;
if (name) snprintf(name, name_sz, "Sim Playlist");
if (cover) cover[0] = 0;
int n = (SIM_TOTAL < id_cap) ? SIM_TOTAL : id_cap;
for (int i = 0; i < n; i++) ids[i] = 1000000LL + i;
*out_id_n = n;
return NCM_OK;
}
int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t *ids, int n,
ncm_song_t *out, int max, int *out_n)
{
(void)device_id; (void)cookie;
g_song_attempts++;
if (g_song_fail_n > 0) { /* 单批网络失败模拟(失败不推进分页高水位 → 同页重试) */
g_song_fail_n--;
if (out_n) *out_n = 0;
return NCM_ERR_NET;
}
g_song_calls++;
int m = (n < max) ? n : max;
for (int i = 0; i < m; i++) {
snprintf(out[i].id, sizeof out[i].id, "%lld", (long long)ids[i]);
snprintf(out[i].name, sizeof out[i].name, "Song%lld", (long long)ids[i]);
snprintf(out[i].art_name, sizeof out[i].art_name, "Artist%lld", (long long)ids[i]);
snprintf(out[i].album_name, sizeof out[i].album_name, "Album%lld", (long long)ids[i]);
out[i].duration_ms = 200000;
out[i].fee = 0;
}
if (out_n) *out_n = m;
return NCM_OK;
}
/* ---- Bug2 证据:整帧重画计数(链接时 --wrap=meow_ui_paint_full 重定向到此) ---- */
extern void __real_meow_ui_paint_full(void);
static int g_paint_full_calls;
void __wrap_meow_ui_paint_full(void)
{
g_paint_full_calls++;
__real_meow_ui_paint_full();
}
/* ---- 驱动辅助:模拟 app_on_tick 周期性消费 worker 完成 ---- */
static int pump_until(int (*cond)(void), int timeout_ms)
{
for (int t = 0; t < timeout_ms; t += 5) {
playlist_tick(0);
if (cond()) {
/* 再喂若干 tick:确保完成态被消费(g_plthr_on 清零),后续滚动可再触发 */
for (int k = 0; k < 30; k++) { playlist_tick(0); usleep(2000); }
return 1;
}
usleep(5000);
}
return 0;
}
static int g_fail = 0;
#define CHECK(cond, fmt, ...) do { \
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
else { printf(" FAIL " fmt "\n", ##__VA_ARGS__); g_fail = 1; } \
} while (0)
/* 条件(C 无闭包,用全局目标值) */
static int g_want_calls;
static int cond_calls(void) { return g_song_calls >= g_want_calls; }
static int g_want_attempts;
static int cond_attempts(void) { return g_song_attempts >= g_want_attempts; }
static int cond_first_screen(void)
{
return g_st.track_ids_count == SIM_TOTAL && g_song_calls >= 1;
}
/* 滚动到指定首行(行高 ncm_row_h() px;swipe 距离按当前行差换算) */
static void scroll_to_row(int row)
{
static int cur;
int d = row - cur;
cur = row;
playlist_swipe(d >= 0 ? MEOW_SWIPE_UP : MEOW_SWIPE_DOWN,
(d >= 0 ? d : -d) * ncm_row_h());
}
int main(void)
{
printf("== plpage_sim: 懒加载分页 + 加载后列表刷新(%d 首,窗口 %d,批 %d,触底余量 20)==\n",
SIM_TOTAL, NCM_SONG_WINDOW, NCM_SONG_BATCH);
/* 宿主 SDK 离屏初始化(与 ncm_host 同口径)。临时目录优先 $TMPDIR(Termux/容器里 /tmp 常不可写) */
const char *td = getenv("TMPDIR");
if (!td || !td[0]) td = "/tmp";
char dmp[256], cdt[256];
snprintf(dmp, sizeof dmp, "%s/plpage_sim.ppm", td);
setenv("MEOW_UI_DUMP", dmp, 0);
meow_ui_opts o = { APP_W, APP_H };
if (meow_ui_init(&o) != 0) { fprintf(stderr, "init failed\n"); return 1; }
meow_theme_set_mode(MEOW_THEME_DARK);
ncm_on_theme(MEOW_THEME_DARK);
/* 歌单 mock:1 张 SIM_TOTAL 首歌单;songmeta 盘缓存用临时目录 */
snprintf(cdt, sizeof cdt, "%s/plpage_cache_XXXXXX", td);
if (!mkdtemp(cdt)) { fprintf(stderr, "mkdtemp failed\n"); return 1; }
snprintf(g_st.cache_dir, sizeof g_st.cache_dir, "%s", cdt);
g_st.playlist_count = 1;
snprintf(g_st.playlists[0].id, sizeof g_st.playlists[0].id, "pl0");
snprintf(g_st.playlists[0].name, sizeof g_st.playlists[0].name, "SimPlaylist");
g_st.playlists[0].track_count = SIM_TOTAL;
ncm_song_t sq;
int paint0;
/* ---- ① Bug1:打开歌单 → 首屏恰 1 detail + 1 批 song_detail(物化 [0,100)) ---- */
printf("== case 1: 首屏只拉一页 ==\n");
paint0 = g_paint_full_calls;
playlist_view_create();
playlist_open(0);
CHECK(pump_until(cond_first_screen, 8000), "first-screen loaded (track_ids=%d calls=%d)",
g_st.track_ids_count, g_song_calls);
CHECK(g_detail_calls == 1, "detail calls=%d (expect 1)", g_detail_calls);
CHECK(g_song_calls == 1, "song_detail calls=%d (expect 1, 不再首屏连拉多批)", g_song_calls);
CHECK(g_st.track_ids_count == SIM_TOTAL, "track_ids_count=%d (expect %d)",
g_st.track_ids_count, SIM_TOTAL);
CHECK(appstate_song_copy(99, &sq) == 0 && strcmp(sq.name, "Song1000099") == 0,
"row 99 materialized name='%s'", sq.name);
CHECK(appstate_song_copy(100, &sq) != 0, "row 100 NOT materialized yet (placeholder)");
/* Bug2:首屏完成 → 重画计数递增 + 行绑定真实歌名/歌手(非占位) */
CHECK(g_paint_full_calls > paint0, "paint_full after first-screen %d -> %d (list repainted)",
paint0, g_paint_full_calls);
CHECK(appstate_song_copy(5, &sq) == 0 && strcmp(sq.art_name, "Artist1000005") == 0,
"row 5 binds real artist='%s' (src2 填真实数据,非「... 加载中」)", sq.art_name);
/* ---- ② 顶部小幅滚动:不触发任何新请求(距已分页底部 >20 首) ---- */
printf("== case 2: 顶部滚动不拉页 ==\n");
scroll_to_row(10);
int a0 = g_song_attempts;
for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); }
CHECK(g_song_attempts == a0, "song_detail attempts=%d (unchanged, 顶部滚动不拉页)", g_song_attempts);
/* ---- ③ 滑到已分页底部(余量 ≤20 首):恰拉 1 页;单批失败不推进,再滑重试同页成功 ---- */
printf("== case 3: 触底拉下一页(一次一页;失败重试同页) ==\n");
g_song_fail_n = 1; /* 注入:下一次 song_detail 网络失败 */
int att_before = g_song_attempts;
scroll_to_row(90); /* 视口底 ≈97 行,余量 ≤20 → 触发第 2 页(失败) */
g_want_attempts = att_before + 1;
CHECK(pump_until(cond_attempts, 8000), "page-2 first attempt fired (attempts=%d)", g_song_attempts);
CHECK(g_song_calls == 1, "page-2 failed -> calls=%d (g_paged_hi 不推进,占位保留)", g_song_calls);
CHECK(appstate_song_copy(199, &sq) != 0, "row 199 still placeholder after failed page");
scroll_to_row(91); /* 再滑(滚动事件)→ 重试同一页 */
g_want_calls = 2;
CHECK(pump_until(cond_calls, 8000), "page 2 retried & loaded (calls=%d attempts=%d)",
g_song_calls, g_song_attempts);
CHECK(g_song_attempts == att_before + 2,
"attempts +%d (1 失败 + 1 重试同页 = 一次一页,未连拉)", g_song_attempts - att_before);
CHECK(appstate_song_copy(199, &sq) == 0 && strcmp(sq.name, "Song1000199") == 0,
"row 199 materialized name='%s'", sq.name);
CHECK(g_paint_full_calls > paint0, "paint_full after page-2 consumed (=%d)", g_paint_full_calls);
/* ---- ④ 逐页到底;全部拉完后滑到尾部:不再发请求 ---- */
printf("== case 4: 逐页到底 + 拉完即止 ==\n");
att_before = g_song_attempts;
scroll_to_row(190); /* 余量 ≤20 → 触发第 3 页 */
g_want_calls = 3;
CHECK(pump_until(cond_calls, 8000), "page 3 loaded (calls=%d)", g_song_calls);
scroll_to_row(290); /* 余量 ≤20 → 触发第 4 页(末页) */
g_want_calls = 4;
CHECK(pump_until(cond_calls, 8000), "page 4 (last) loaded (calls=%d)", g_song_calls);
CHECK(g_song_attempts == att_before + 2, "attempts +%d (第 3、4 页恰各 1 批)",
g_song_attempts - att_before);
CHECK(appstate_song_copy(399, &sq) == 0 && strcmp(sq.name, "Song1000399") == 0,
"tail row 399 materialized name='%s'", sq.name);
att_before = g_song_attempts;
scroll_to_row(390); /* 已在尾部:全部拉完 → 不再发请求 */
for (int k = 0; k < 40; k++) { playlist_tick(0); usleep(3000); }
CHECK(g_song_attempts == att_before, "all loaded -> scroll at tail: attempts=%d (unchanged)",
g_song_attempts);
/* ---- ⑤ 回滚到顶部:失效槽(窗口挤出)盘缓存补物化,song_detail 0 增长 ---- */
printf("== case 5: 回滚补物化走盘缓存(0 网络) ==\n");
att_before = g_song_attempts;
scroll_to_row(0); /* 回顶部:窗口已滑走,行 0-35 失效 → 盘缓存补 */
for (int k = 0; k < 400 && !appstate_range_loaded(0, 30); k++) { playlist_tick(0); usleep(5000); }
CHECK(appstate_range_loaded(0, 30), "rows 0-29 re-materialized after scroll-back");
CHECK(g_song_attempts == att_before, "scroll-back refill: song_detail attempts=%d "
"(unchanged, 盘缓存命中不占网络)", g_song_attempts);
CHECK(appstate_song_copy(0, &sq) == 0 && strcmp(sq.name, "Song1000000") == 0,
"row 0 re-materialized name='%s'", sq.name);
printf("%s\n", g_fail ? "RESULT: FAIL" : "RESULT: PASS");
/* 清理临时盘缓存(≤400 个小文件) */
{
char cmd[600];
snprintf(cmd, sizeof cmd, "rm -rf %s", cdt);
if (system(cmd) != 0) fprintf(stderr, "cleanup failed: %s\n", cdt);
}
meow_ui_shutdown();
return g_fail ? 1 : 0;
}
+305
View File
@@ -0,0 +1,305 @@
/* bench/retry_storm_sim.c — M10 断网重试风暴 / META 洪泛 宿主回归(0 网络、0 真机)。
*
* 覆盖真实实现(直接编译 app/logic/retry_ctl.c 与 app/play/meta_gate.c,非复刻逻辑):
* A. 100ms tick + 断网常驻 + worker 每次耗时 5s(真机 rc=-4/-6 的连接超时量级):
* 重试间隔严格 ≥30000ms,74s 量级的窗口内次数与真机日志对得上(真机为 10 次 / 74s)。
* B. online/offline 每 tick 抖动(旧实现 else 分支清零重试时刻 → 一抖就重试):
* 抖动不得产生任何额外重试(间隔仍 ≥30s)。
* C. 时钟回退(喂入倒退 30s 的 now):不得下溢成「巨大已超时」立即重试;
* 以真实 tick 计的重试间隔仍 ≥30s。
* D. 单飞:worker 未收工时任何 tick 都不发第二次。
* E. META 闸:空闲(state=0, 元数据不变)60s 内 ≤3 帧(首帧 + 30s 心跳);
* 播放中位置推进 → ≤1 帧/5s;状态抖动 → 不快于 4 帧/秒(硬下限 250ms);
* 元数据变化/seek → 立即报;超长标题截断后不得因截断恒判「变了」而反复重报;
* 时钟回退不产生连发。
*
* 编译(ncm_app 目录下):
* gcc -O2 -Wall -Wextra -o retry_storm_sim bench/retry_storm_sim.c \
* app/logic/retry_ctl.c app/play/meta_gate.c
* 运行:./retry_storm_sim (退出码 0=全过)
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "../app/logic/retry_ctl.h"
#include "../app/play/meta_gate.h"
static int g_pass, g_fail;
#define CHECK(cond, ...) do { \
int _ok = (cond) ? 1 : 0; \
printf(" %s ", _ok ? "PASS" : "FAIL"); printf(__VA_ARGS__); printf("\n"); \
if (_ok) g_pass++; else g_fail++; \
} while (0)
#define TICK_MS 100
#define RETRY_MS 30000
#define WORKER_COST 5000 /* 断网时一次重试要跑多久(连接超时量级,真机 ~5-7s) */
/* 模拟 UI 每 tick 调 home_tick:offline 由场景给,busy 由「worker 是否还在跑」给。 */
typedef struct {
ncm_retry_ctl ctl;
int fires;
int64_t fire_tick[64];
int64_t fire_now[64];
int64_t busy_until;
int64_t worker_cost; /* 一次重试的耗时(默认 WORKER_COST) */
} retry_sim;
static void retry_sim_init(retry_sim *s)
{
memset(s, 0, sizeof *s);
ncm_retry_ctl_init(&s->ctl);
s->busy_until = 0;
s->worker_cost = WORKER_COST;
}
static int retry_sim_tick(retry_sim *s, int offline, int64_t now, int64_t tick)
{
int busy = (tick < s->busy_until);
int fire = ncm_retry_ctl_poll(&s->ctl, offline, busy, now, RETRY_MS);
if (fire) {
if (s->fires < 64) { s->fire_tick[s->fires] = tick; s->fire_now[s->fires] = now; }
s->fires++;
s->busy_until = tick + s->worker_cost;
}
return fire;
}
/* ---- A:断网常驻,120s,100ms tick,worker 5s ---- */
static void case_steady(void)
{
printf("A. 断网常驻 120s(100ms tick,worker 5s)\n");
retry_sim s; retry_sim_init(&s);
for (int64_t t = 0; t < 120000; t += TICK_MS) retry_sim_tick(&s, 1, t, t);
CHECK(s.fires == 4, "重试次数 = %d(期望 4:t=0/30/60/90s)", s.fires);
int gaps_ok = 1;
for (int i = 1; i < s.fires && i < 64; i++)
if (s.fire_tick[i] - s.fire_tick[i - 1] < RETRY_MS) gaps_ok = 0;
CHECK(gaps_ok, "相邻重试真实间隔全部 ≥30000ms");
/* 旧实现量级对照:同样 120s 若每 tick 都重试 = 1200 次(真机 74s/10 次,且每次都要跑满网络超时) */
CHECK(s.fires <= 8, "120s 内重试次数 = %d(≤8,即 ≤4 次/分钟的理论上限)", s.fires);
}
/* ---- B:状态抖动(每 tick 翻转 online/offline) ---- */
static void case_flap(void)
{
printf("B. online/offline 每 tick 抖动 60s\n");
retry_sim s; retry_sim_init(&s);
int k = 0;
for (int64_t t = 0; t < 60000; t += TICK_MS, k++)
retry_sim_tick(&s, (k % 2 == 0), t, t); /* 每 tick 翻转 */
CHECK(s.fires == 2, "抖动下重试次数 = %d(期望 2:0s / 30s,抖动不得加速)", s.fires);
if (s.fires >= 2)
CHECK(s.fire_tick[1] - s.fire_tick[0] >= RETRY_MS, "抖动下间隔 = %lldms ≥30000",
(long long)(s.fire_tick[1] - s.fire_tick[0]));
}
/* ---- C:时钟回退 ---- */
static void case_clock_rollback(void)
{
printf("C. 时钟回退 30s(真机 meow_ui_time_ms 异常形态的另一面)\n");
retry_sim s; retry_sim_init(&s);
int64_t now = 0;
int fired_at_rollback = 0;
for (int64_t t = 0; t < 120000; t += TICK_MS) {
if (t == 40000) { /* 40s 处时钟倒退 30s → now=10000(< 上次尝试时刻 30000) */
now -= 30000;
if (retry_sim_tick(&s, 1, now, t)) fired_at_rollback = 1;
} else {
retry_sim_tick(&s, 1, now, t);
}
now += TICK_MS;
}
CHECK(!fired_at_rollback, "回退瞬间不触发重试(不下溢成「巨大已超时」)");
/* 回退把基准重锚到回退后的 now(10000)→ 下一次要等 now 回到 40000(真实 t=70000)。
* 真实间隔:0 / 30s / 70s / 100s,全部 ≥30s。 */
CHECK(s.fires == 4, "回退场景重试次数 = %d(期望 4;回退只推迟、不加速)", s.fires);
int gaps_ok = 1;
for (int i = 1; i < s.fires && i < 64; i++)
if (s.fire_tick[i] - s.fire_tick[i - 1] < RETRY_MS) gaps_ok = 0;
CHECK(gaps_ok, "回退前后按真实 tick 计的间隔仍 ≥30000ms");
CHECK(s.fires >= 1 && s.ctl.have_last, "回退后闸仍处于已记账状态(不会失锁成每 tick 重试)");
}
/* ---- D:单飞 ---- */
static void case_single_flight(void)
{
printf("D. 单飞(每次重试的 worker 跑 45s > 30s 间隔)\n");
retry_sim s; retry_sim_init(&s);
s.worker_cost = 45000;
for (int64_t t = 0; t < 200000; t += TICK_MS) retry_sim_tick(&s, 1, t, t);
/* worker 跑 45s(比间隔还长):期间任何 tick 都不许再起第二个 worker;
* 收工后才按 30s 间隔排下一次 → 0/45/90/135/180s 共 5 次。 */
CHECK(s.fires == 5, "长 worker 期间不并发重试,次数 = %d(期望 5)", s.fires);
int gaps_ok = 1;
for (int i = 1; i < s.fires && i < 64; i++)
if (s.fire_tick[i] - s.fire_tick[i - 1] < RETRY_MS) gaps_ok = 0;
CHECK(gaps_ok, "单飞下间隔仍 ≥30000ms");
}
/* ============================================================
* E. META 闸
* ============================================================ */
static void snap_set(ncm_meta_snap *s, int state, int pos, int dur, const char *title)
{
s->state = state;
s->pos_ms = pos;
s->dur_ms = dur;
s->flags = 0;
s->title = title;
s->artist = "artist";
s->album = "album";
s->artwork = "";
}
/* 返回发送次数;记最小间隔。 */
static int run_meta(ncm_meta_gate *g, int64_t from, int64_t to, int64_t *min_gap, int64_t *last_send)
{
int n = 0;
*min_gap = -1;
*last_send = -1;
ncm_meta_snap s;
for (int64_t t = from; t <= to; t += TICK_MS) {
snap_set(&s, NCM_META_ST_STOPPED, 0, 0, "");
if (ncm_meta_gate_should_send(g, &s, t)) {
if (*last_send >= 0) {
int64_t gap = t - *last_send;
if (*min_gap < 0 || gap < *min_gap) *min_gap = gap;
}
*last_send = t;
n++;
}
}
return n;
}
static void case_meta_idle(void)
{
printf("E1. 空闲(state=0,元数据不变)60s\n");
ncm_meta_gate g; ncm_meta_gate_init(&g);
int64_t min_gap, last;
int n = run_meta(&g, 0, 60000, &min_gap, &last);
CHECK(n == 3, "上报次数 = %d(期望 3:首帧 + 30s + 60s 心跳;旧实现为 600)", n);
CHECK(min_gap < 0 || min_gap >= 29000, "心跳间隔 = %lldms(≥30s 量级)", (long long)min_gap);
}
static void case_meta_playing(void)
{
printf("E2. 播放中(位置每 tick 推进)60s\n");
ncm_meta_gate g; ncm_meta_gate_init(&g);
ncm_meta_snap s;
int n = 0, min_gap = -1, last = -1;
for (int64_t t = 0; t <= 60000; t += TICK_MS) {
snap_set(&s, NCM_META_ST_PLAYING, (int)t, 200000, "song");
if (ncm_meta_gate_should_send(&g, &s, t)) {
if (last >= 0 && (min_gap < 0 || (int)(t - last) < min_gap)) min_gap = (int)(t - last);
last = (int)t;
n++;
}
}
CHECK(n >= 12 && n <= 14, "上报次数 = %d(期望 ~13:≤1 帧/5s)", n);
CHECK(min_gap < 0 || min_gap >= 4000, "最小间隔 = %dms(给 launcher 重锚进度用,≥5s 量级)", min_gap);
}
static void case_meta_flap(void)
{
printf("E3. 播放状态每 tick 抖动 10s(硬下限)\n");
ncm_meta_gate g; ncm_meta_gate_init(&g);
ncm_meta_snap s;
int n = 0, min_gap = -1, last = -1;
int k = 0;
for (int64_t t = 0; t <= 10000; t += TICK_MS, k++) {
snap_set(&s, (k % 2) ? NCM_META_ST_PLAYING : NCM_META_ST_PAUSED, 0, 0, "");
if (ncm_meta_gate_should_send(&g, &s, t)) {
if (last >= 0 && (min_gap < 0 || (int)(t - last) < min_gap)) min_gap = (int)(t - last);
last = (int)t;
n++;
}
}
CHECK(n <= 42, "抖动 10s 上报次数 = %d(≤4 帧/秒的硬上限 40+2)", n);
CHECK(min_gap < 0 || min_gap >= NCM_META_MIN_GAP_MS, "最小间隔 = %dms(≥250ms 硬下限)", min_gap);
}
static void case_meta_change(void)
{
printf("E4. 元数据变化 / seek / 超长标题截断\n");
ncm_meta_gate g; ncm_meta_gate_init(&g);
ncm_meta_snap s;
int64_t t = 0;
snap_set(&s, NCM_META_ST_STOPPED, 0, 0, "");
CHECK(ncm_meta_gate_should_send(&g, &s, t), "首帧必发(注册媒体源)");
t += TICK_MS; /* 同一快照:不发 */
CHECK(!ncm_meta_gate_should_send(&g, &s, t), "无变化不重发");
t += 5000;
snap_set(&s, NCM_META_ST_STOPPED, 0, 0, "新歌名");
CHECK(ncm_meta_gate_should_send(&g, &s, t), "标题变化 → 立即报");
t += 5000;
snap_set(&s, NCM_META_ST_PLAYING, 120000, 200000, "新歌名");
CHECK(ncm_meta_gate_should_send(&g, &s, t), "state 变化(开始播放)→ 立即报");
t += 5000;
snap_set(&s, NCM_META_ST_PLAYING, 30000, 200000, "新歌名"); /* 用户 seek 回 30s */
CHECK(ncm_meta_gate_should_send(&g, &s, t), "seek 前后跳 → 立即报(给 launcher 重锚)");
/* 超长标题:发送侧按 127 字节截断;截断后不得恒判「变了」而每 tick 重报 */
static char longtitle[400];
memset(longtitle, 'A', sizeof longtitle - 1);
longtitle[sizeof longtitle - 1] = 0;
ncm_meta_gate g2; ncm_meta_gate_init(&g2);
snap_set(&s, NCM_META_ST_STOPPED, 0, 0, longtitle);
int n = 0, t2 = 0;
for (; t2 <= 10000; t2 += TICK_MS) {
if (ncm_meta_gate_should_send(&g2, &s, t2)) n++;
}
CHECK(n == 1, "超长标题 10s 内上报 %d 次(期望 1:截断不得造成恒变更)", n);
}
static void case_meta_rollback(void)
{
printf("E5. META 时钟回退\n");
ncm_meta_gate g; ncm_meta_gate_init(&g);
ncm_meta_snap s;
int n = 0;
int64_t now = 0;
snap_set(&s, NCM_META_ST_STOPPED, 0, 0, "");
for (int i = 0; i < 400; i++) { /* 40s:首帧 + 30s 心跳 */
if (ncm_meta_gate_should_send(&g, &s, now)) n++;
now += TICK_MS;
}
CHECK(n == 2, "回退前 40s 上报 %d 次(期望 2)", n);
now -= 20000; /* 时钟倒退 20s */
int burst = 0;
for (int i = 0; i < 50; i++) { /* 5s */
if (ncm_meta_gate_should_send(&g, &s, now)) burst++;
now += TICK_MS;
}
CHECK(burst <= 1, "回退后 5s 内补发 %d 次(≤1:不得连发成洪泛)", burst);
}
int main(void)
{
printf("retry_storm_sim — M10 断网重试风暴 / META 洪泛 宿主回归(REPORT §42)\n\n");
case_steady();
case_flap();
case_clock_rollback();
case_single_flight();
case_meta_idle();
case_meta_playing();
case_meta_flap();
case_meta_change();
case_meta_rollback();
printf("\nretry_storm_sim: %d-%d\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}
+2 -1
View File
@@ -10,7 +10,8 @@
* -Icore -c bench/search_sim.c -o /tmp/ss.o
* $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -c app/logic/appstate.c -o /tmp/ss_app.o
* $CC_HOST -O2 -Wl,--gc-sections -o search_sim /tmp/ss.o /tmp/ss_app.o -lpthread
* $CC_HOST -O2 -Wl,--gc-sections -o search_sim /tmp/ss.o /tmp/ss_app.o \
* out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread
* 运行:./search_sim (0 网络;PASS/FAIL 退出码)
*/
#include "../app/ncm_app_internal.h"
+471
View File
@@ -0,0 +1,471 @@
/* bench/session_gate_sim.c — M9 离线会话保持 / 登录门决策 宿主单测(0 真实网络)
*
* 覆盖(真实 core 代码 + 网络桩,0 请求):
* A. 错误分类矩阵 ncm_err_classify:-4/-5/-6/-17 网络类;-13 鉴权;JSON/IO/其它 = other;
* 历史泄漏的 mbedtls 裸负码按网络类兜底。
* B. HTTP 状态分类 ncm_http_status_class / ncm_http_status_err:
* 200/204 → ok;401/403 → auth;408/425/429/5xx → transient(SVC);3xx/其它 4xx → error(HTTP)。
* C. 登录门 ncm_gate_decide / ncm_session_boot_decide:在线/离线/鉴权失败/启动无网/无会话。
* D. 真实 ncm_api(ncm_http_request 桩)HTTP 状态与业务码映射:
* 401/403 与业务 301/302 → AUTH(退登录);5xx/429、业务 -460 → SVC(保登录态/离线);
* HTML 体 → JSON(other);其它业务码 → HTTP(other)。
* D2. `ncm_api_user_playlist_ex` 的 from_cache 出参:miss→0(真网络 +1 次)、命中→1(0 请求)、
* 失败→0;兼容入口不崩;out_n 必填契约(NULL→NCM_ERR_ARG)(离线判定不得用全局计数,
* 评审 P2-2/P2-1)。
* E. 真实 ncm_login_status_check / ncm_login_refresh 同上(AUTH 只在服务端明确否定时)。
* F. 会话加密密钥稳定性(M9 根因回归):session.enc 保存后改变网卡 MAC(NCM_MAC 桩)仍能解出
* cookie;旧版 MAC 派生密文可解且自动迁移到 .mkey 密钥。
*
* 编译(在 ncm_app 下;先 bash build_core.sh 生成宿主核心对象):
* gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -Ivendor/jsmn -Ivendor/mbedtls/include \
* -c bench/session_gate_sim.c -o out/chk/sg.o
* gcc -O2 -o session_gate_sim out/chk/sg.o \
* out/core-host/ncm_api.o out/core-host/ncm_login.o out/core-host/ncm_req.o \
* out/core-host/ncm_crypto.o out/core-host/ncm_json.o out/core-host/ncm_common.o \
* out/core-host/ncm_store.o out/core-host/ncm_apicache.o out/core-host/ncm_session.o \
* out/core-host/jsmn_impl.o out/core-host/v_qrcodegen.o \
* -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm
* TMPDIR 可用(Termux /tmp 不可写时用 $TMPDIR 或 HOME/.cache)。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <sys/stat.h>
#include "ncm.h"
#include "ncm_http.h"
#include "mbedtls/aes.h"
static int g_fail = 0;
#define CHECK(cond, ...) do { \
int _ok = (cond) ? 1 : 0; \
printf(" %s ", _ok ? "PASS" : "FAIL"); printf(__VA_ARGS__); printf("\n"); \
if (!_ok) g_fail++; \
} while (0)
/* ============================================================
* ncm_http_request 桩:按 g_stub_* 脚本返回(不触碰网络/文件)
* ============================================================ */
static int g_stub_rc; /* 非 0:直接返回该传输层错误码 */
static int g_stub_status; /* HTTP 状态 */
static const char *g_stub_body; /* 响应体(缺省 "") */
static const char *g_stub_cookie; /* Set-Cookie 头(可 NULL) */
static int g_http_calls;
int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_http_resp_t *resp)
{
(void)req;
g_http_calls++;
memset(resp, 0, sizeof *resp);
if (g_stub_rc) return g_stub_rc;
const char *body = g_stub_body ? g_stub_body : "";
size_t n = strlen(body);
if (n >= buf_sz) n = buf_sz - 1;
memcpy(buf, body, n);
resp->body = buf;
resp->body_len = n;
resp->status = g_stub_status;
if (g_stub_cookie) {
/* 真实响应每个 cookie 一条 Set-Cookie 头;测试串用 "; " 分隔多 cookie。 */
size_t o = 0;
const char *p = g_stub_cookie;
while (*p && o < sizeof resp->headers) {
const char *sep = strstr(p, "; ");
size_t n = sep ? (size_t)(sep - p) : strlen(p);
int hn = snprintf(resp->headers + o, sizeof resp->headers - o,
"Set-Cookie: %.*s\r\n", (int)n, p);
if (hn <= 0) break;
o += (size_t)hn;
if (!sep) break;
p = sep + 2;
}
resp->headers_len = o;
}
return NCM_OK;
}
static void stub(int status, const char *body, const char *cookie_set)
{
g_stub_rc = NCM_OK; g_stub_status = status; g_stub_body = body; g_stub_cookie = cookie_set;
}
static void stub_err(int rc)
{
g_stub_rc = rc; g_stub_status = 0; g_stub_body = NULL; g_stub_cookie = NULL;
}
/* ncm_http_parse_set_cookie:本 sim 不链 ncm_http.o(ncm_http_request 已桩化),
* 这里放一份与 core/ncm_http.c 相同的实现,供 ncm_login_refresh 提取 Set-Cookie。 */
int ncm_http_parse_set_cookie(const char *hdrs, size_t len, char *out, size_t out_sz)
{
if (!hdrs || !out || out_sz == 0) return -1;
out[0] = 0;
size_t o = 0;
int count = 0;
const char *p = hdrs;
const char *end = hdrs + len;
while ((p = strstr(p, "Set-Cookie:")) != NULL && p < end) {
const char *val = p + strlen("Set-Cookie:");
while (*val == ' ' || *val == '\t') val++;
const char *semi = strchr(val, ';');
size_t nlen = semi ? (size_t)(semi - val) : strlen(val);
while (nlen && (val[nlen - 1] == ' ' || val[nlen - 1] == '\t')) nlen--;
if (nlen == 0) { p = val; continue; }
if (o > 0) { if (o + 2 >= out_sz) break; out[o++] = ';'; out[o++] = ' '; }
if (o + nlen >= out_sz) break;
memcpy(out + o, val, nlen);
o += nlen;
count++;
p = val;
}
out[o] = 0;
return count;
}
/* ============================================================
* A. 错误分类
* ============================================================ */
static void test_err_class(void)
{
printf("== A. 错误分类(-5/-4/-6/超时/DNS/连接拒/5xx vs 401/业务 301/302)==\n");
struct { int rc; ncm_fail_class_t want; const char *why; } m[] = {
{ NCM_OK, NCM_FAIL_NONE, "NCM_OK" },
{ NCM_ERR_NET, NCM_FAIL_NETWORK, "-4 网络错误(DNS/连接拒绝/连接中断)" },
{ NCM_ERR_TLS, NCM_FAIL_NETWORK, "-5 TLS 错误(历史真机 eapi 域 reset 即此码)" },
{ NCM_ERR_TIMEOUT, NCM_FAIL_NETWORK, "-6 超时" },
{ NCM_ERR_SVC, NCM_FAIL_NETWORK, "-17 服务端 5xx/429/-460 暂不可用" },
{ NCM_ERR_AUTH, NCM_FAIL_AUTH, "-13 服务端明确未登录/鉴权失败" },
{ NCM_ERR_JSON, NCM_FAIL_OTHER, "-9 JSON(数据问题,不登出)" },
{ NCM_ERR_HTTP, NCM_FAIL_OTHER, "-7 HTTP 非 2xx(其它)" },
{ NCM_ERR_IO, NCM_FAIL_OTHER, "-3 文件/截断 IO" },
{ NCM_ERR_NOTFOUND, NCM_FAIL_OTHER, "-10 缺字段" },
{ -0x7280, NCM_FAIL_NETWORK, "mbedtls 裸负码(历史泄漏)→ 网络类兜底" },
};
for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) {
ncm_fail_class_t got = ncm_err_classify(m[i].rc);
CHECK(got == m[i].want, "classify(%d)=%s 期望 %s [%s]", m[i].rc,
ncm_fail_class_name(got), ncm_fail_class_name(m[i].want), m[i].why);
}
CHECK(ncm_err_is_network(NCM_ERR_TLS) == 1 && ncm_err_is_network(NCM_ERR_AUTH) == 0,
"ncm_err_is_network: TLS=1 / AUTH=0");
}
/* ============================================================
* B. HTTP 状态 → 分类/错误码
* ============================================================ */
static void test_http_class(void)
{
printf("== B. HTTP 状态分类(401/403 vs 5xx vs 3xx/4xx)==\n");
struct { int st; ncm_http_cls_t cls; int err; } m[] = {
{ 200, NCM_HTTP_CLS_OK, NCM_OK },
{ 204, NCM_HTTP_CLS_OK, NCM_OK },
{ 401, NCM_HTTP_CLS_AUTH, NCM_ERR_AUTH },
{ 403, NCM_HTTP_CLS_AUTH, NCM_ERR_AUTH },
{ 408, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC },
{ 425, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC },
{ 429, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC },
{ 500, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC },
{ 502, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC },
{ 503, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC },
{ 301, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP },
{ 302, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP },
{ 400, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP },
{ 404, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP },
};
for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) {
ncm_http_cls_t c = ncm_http_status_class(m[i].st);
int e = ncm_http_status_err(m[i].st);
CHECK(c == m[i].cls && e == m[i].err, "status %d -> cls=%d err=%d(期望 %d/%d)",
m[i].st, (int)c, e, (int)m[i].cls, m[i].err);
}
}
/* ============================================================
* C. 登录门决策
* ============================================================ */
static void test_gate(void)
{
printf("== C. 登录门决策(离线/在线/鉴权失败/启动无网)==\n");
struct { int logged_in; int rc; ncm_gate_t want; const char *why; } m[] = {
{ 1, NCM_OK, NCM_GATE_ONLINE, "请求成功 → 在线" },
{ 1, NCM_ERR_NET, NCM_GATE_OFFLINE, "断网/不可达 → 离线(保持账号态)" },
{ 1, NCM_ERR_TLS, NCM_GATE_OFFLINE, "TLS 失败 → 离线" },
{ 1, NCM_ERR_TIMEOUT, NCM_GATE_OFFLINE, "超时 → 离线" },
{ 1, NCM_ERR_SVC, NCM_GATE_OFFLINE, "服务端 5xx/-460 → 离线(可重试,不登出)" },
{ 1, NCM_ERR_AUTH, NCM_GATE_RELOGIN, "业务 301/HTTP 401 → 退登录" },
{ 1, NCM_ERR_JSON, NCM_GATE_HOLD, "解析失败 → 保持现状(不登出)" },
{ 1, NCM_ERR_NOTFOUND, NCM_GATE_HOLD,"缺字段 → 保持现状" },
{ 0, NCM_OK, NCM_GATE_RELOGIN, "本地无会话 → 扫码" },
{ 0, NCM_ERR_NET, NCM_GATE_RELOGIN, "本地无会话(无网也一样扫码)" },
};
for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) {
ncm_gate_t got = ncm_gate_decide(m[i].logged_in, m[i].rc);
CHECK(got == m[i].want, "gate(logged_in=%d, rc=%d)=%s 期望 %s [%s]",
m[i].logged_in, m[i].rc, ncm_gate_name(got), ncm_gate_name(m[i].want), m[i].why);
}
printf("-- 启动决策(有本地会话就先信它;无网也进 home/缓存)--\n");
struct { int has_cookie; int probe_rc; ncm_gate_t want; const char *why; } b[] = {
{ 1, NCM_OK, NCM_GATE_ONLINE, "有会话 + 探测成功 → 在线" },
{ 1, NCM_ERR_NET, NCM_GATE_OFFLINE, "启动时就没网 → 离线(绝不进扫码页)" },
{ 1, NCM_ERR_TLS, NCM_GATE_OFFLINE, "启动时 TLS 失败 → 离线" },
{ 1, NCM_ERR_AUTH, NCM_GATE_RELOGIN, "服务端明确否定 → 扫码" },
{ 1, NCM_ERR_JSON, NCM_GATE_ONLINE, "不确定的失败 → 不赶人,先进 App" },
{ 0, NCM_OK, NCM_GATE_RELOGIN, "无本地会话 → 扫码" },
};
for (size_t i = 0; i < sizeof b / sizeof b[0]; i++) {
ncm_gate_t got = ncm_session_boot_decide(b[i].has_cookie, b[i].probe_rc);
CHECK(got == b[i].want, "boot(cookie=%d, probe=%d)=%s 期望 %s [%s]",
b[i].has_cookie, b[i].probe_rc, ncm_gate_name(got), ncm_gate_name(b[i].want),
b[i].why);
}
}
/* ============================================================
* D. 真实 ncm_api:HTTP 状态 / 业务码 → 错误码
* ============================================================ */
static void test_api_map(void)
{
printf("== D. ncm_api(user_playlist)状态/业务码映射 ==\n");
ncm_apicache_init(NULL);
const char *json_ok = "{\"code\":200,\"playlist\":[{\"id\":1,\"name\":\"a\",\"trackCount\":2}]}";
struct { int status; const char *body; int stub_err; int want; const char *why; } m[] = {
{ 200, json_ok, 0, NCM_OK, "200+code200 → 成功" },
{ 401, "<html>401</html>", 0, NCM_ERR_AUTH, "HTTP 401 → AUTH(退登录)" },
{ 403, "{}", 0, NCM_ERR_AUTH, "HTTP 403 → AUTH" },
{ 500, "<html>oops</html>", 0, NCM_ERR_SVC, "HTTP 500(HTML)→ SVC(保留登录态)" },
{ 503, "{\"code\":200}", 0, NCM_ERR_SVC, "HTTP 503 → SVC" },
{ 429, "", 0, NCM_ERR_SVC, "HTTP 429 → SVC" },
{ 404, "{}", 0, NCM_ERR_HTTP, "HTTP 404 → HTTP(不登出)" },
{ 200, "{\"code\":301}", 0, NCM_ERR_AUTH, "业务 301 → AUTH" },
{ 200, "{\"code\":302}", 0, NCM_ERR_AUTH, "业务 302 → AUTH" },
{ 200, "{\"code\":-460}", 0, NCM_ERR_SVC, "业务 -460 风控 → SVC(M9:旧版误判 AUTH)" },
{ 200, "<html>not json</html>", 0, NCM_ERR_JSON, "200+非 JSON → JSON/NOTFOUND(other,不登出)" },
{ 200, "{\"code\":-447}", 0, NCM_ERR_HTTP, "其它业务码 → HTTP(other)" },
{ 0, NULL, NCM_ERR_NET, NCM_ERR_NET, "-4 传输层不可达 → NET(离线)" },
{ 0, NULL, NCM_ERR_TLS, NCM_ERR_TLS, "-5 TLS → TLS(离线)" },
{ 0, NULL, NCM_ERR_TIMEOUT, NCM_ERR_TIMEOUT, "-6 超时 → TIMEOUT(离线)" },
};
for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) {
ncm_apicache_clear();
if (m[i].stub_err) stub_err(m[i].stub_err);
else stub(m[i].status, m[i].body, NULL);
ncm_playlist_t pls[4];
int n = -1;
int rc = ncm_api_user_playlist("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n);
/* 非 JSON 体:「解析失败」具体码可能是 JSON(-9) 或 NOTFOUND(-10)(jsmn 把裸词当原语),
* 关键语义是**不属于鉴权/网络类**(不登出、不改离线态)。 */
int ok = (rc == m[i].want) ||
(m[i].want == NCM_ERR_JSON && ncm_err_classify(rc) == NCM_FAIL_OTHER);
CHECK(ok, "[%s] rc=%d(期望 %d)", m[i].why, rc, m[i].want);
}
}
/* ============================================================
* D2. user_playlist_ex:本次结果是否来自接口缓存(评审 P2-2)
* ============================================================ */
static void test_api_from_cache(void)
{
printf("== D2. ncm_api_user_playlist_ex 缓存命中出参(离线判定用)==\n");
ncm_apicache_init(NULL);
ncm_apicache_clear();
const char *body = "{\"code\":200,\"playlist\":[{\"id\":1,\"name\":\"a\",\"trackCount\":1}]}";
ncm_playlist_t pls[4];
int n = 0, fc = -1, rc;
stub(200, body, NULL);
int calls0 = g_http_calls;
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n, &fc);
CHECK(rc == NCM_OK && fc == 0 && g_http_calls == calls0 + 1 && n == 1,
"首次(缓存 miss):rc=0 n=%d from_cache=%d,真网络 +1 次(calls %d→%d)",
n, fc, calls0, g_http_calls);
fc = -1;
calls0 = g_http_calls;
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n, &fc);
CHECK(rc == NCM_OK && fc == 1 && g_http_calls == calls0 && n == 1,
"二次同参(缓存命中):rc=0 from_cache=%d,未发网络请求(calls=%d)",
fc, g_http_calls);
/* 失败不得把 from_cache 留成 1(App 会据此判离线,方向必须正确) */
ncm_apicache_clear();
stub_err(NCM_ERR_NET);
fc = -1;
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n, &fc);
CHECK(rc == NCM_ERR_NET && fc == 0, "网络失败:rc=%d from_cache=%d(期望 -4/0)", rc, fc);
/* 兼容入口仍可用(out_from_cache=NULL 不崩) */
ncm_apicache_clear();
stub(200, body, NULL);
rc = ncm_api_user_playlist("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n);
CHECK(rc == NCM_OK && n == 1, "兼容入口 ncm_api_user_playlist 正常(rc=%d n=%d)", rc, n);
/* 契约:out_n 必填(与原接口一致);out_from_cache 可 NULL(评审 P2-1) */
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, NULL, &fc);
CHECK(rc == NCM_ERR_ARG, "out_n=NULL → NCM_ERR_ARG(契约与原接口一致)rc=%d", rc);
ncm_apicache_clear();
}
/* ============================================================
* E. 真实 ncm_login:cookie 校验 / 续期
* ============================================================ */
static void test_login_map(void)
{
printf("== E. ncm_login_status_check(启动/uid 校验)==\n");
char uid[32], nick[128];
struct { int status; const char *body; int stub_err; int want; const char *why; } m[] = {
{ 200, "{\"code\":200,\"profile\":{\"userId\":\"42\",\"nickname\":\"n\"},"
"\"account\":{\"id\":42}}", 0, NCM_OK, "已登录 → OK" },
{ 200, "{\"code\":200,\"profile\":null,\"account\":null}", 0, NCM_ERR_AUTH,
"游客(profile=null)→ AUTH" },
{ 401, "{}", 0, NCM_ERR_AUTH, "HTTP 401 → AUTH" },
{ 500, "<html>bad gateway</html>", 0, NCM_ERR_SVC, "HTTP 500 → SVC(断网/服务异常不清会话)" },
{ 200, "{\"code\":-460}", 0, NCM_ERR_SVC, "业务 -460 风控 → SVC" },
{ 200, "{}", 0, NCM_ERR_JSON, "缺 code → JSON(other)" },
{ 0, NULL, NCM_ERR_NET, NCM_ERR_NET, "网络不可达 → NET" },
{ 0, NULL, NCM_ERR_TLS, NCM_ERR_TLS, "TLS 失败 → TLS" },
};
for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) {
if (m[i].stub_err) stub_err(m[i].stub_err);
else stub(m[i].status, m[i].body, NULL);
int rc = ncm_login_status_check("dev", "MUSIC_U=x", uid, sizeof uid, nick, sizeof nick);
CHECK(rc == m[i].want, "[%s] rc=%d(期望 %d)", m[i].why, rc, m[i].want);
}
printf("== E2. ncm_login_refresh(MUSIC_U 临期续期)==\n");
char out[NCM_COOKIE_MAX];
struct { int status; const char *body; const char *setc; int stub_err; int want; const char *why; } r[] = {
{ 200, "{\"code\":200}", "MUSIC_U=NEW; __csrf=c2", 0, NCM_OK,
"200+code200 → OK(换新 cookie)" },
{ 200, "{\"code\":301}", NULL, 0, NCM_ERR_AUTH, "业务 301 → AUTH(会话确失效)" },
{ 200, "{\"code\":302}", NULL, 0, NCM_ERR_AUTH, "业务 302 → AUTH" },
{ 200, "{\"code\":-460}", NULL, 0, NCM_ERR_SVC, "业务 -460 风控 → SVC(退避,不清登录态)" },
{ 200, "{\"code\":400}", NULL, 0, NCM_ERR_HTTP, "其它业务码 → HTTP(退避,不登出)" },
{ 502, "<html>bad gateway</html>", NULL, 0, NCM_ERR_SVC, "HTTP 502 → SVC(退避)" },
{ 0, NULL, NULL, NCM_ERR_TLS, NCM_ERR_TLS, "TLS 失败 → TLS(退避)" },
{ 0, NULL, NULL, NCM_ERR_NET, NCM_ERR_NET, "网络不可达 → NET(退避)" },
};
for (size_t i = 0; i < sizeof r / sizeof r[0]; i++) {
if (r[i].stub_err) stub_err(r[i].stub_err);
else stub(r[i].status, r[i].body, r[i].setc);
int rc = ncm_login_refresh("dev", "MUSIC_U=OLD; __csrf=c1", out, sizeof out);
CHECK(rc == r[i].want, "[%s] rc=%d(期望 %d)", r[i].why, rc, r[i].want);
if (r[i].want == NCM_OK)
CHECK(strstr(out, "MUSIC_U=NEW") != NULL && strstr(out, "__csrf=c2") != NULL,
"续期成功:新 MUSIC_U/__csrf 已合并");
}
}
/* ============================================================
* F. 会话加密密钥稳定性(旧版依赖网卡 MAC = 断网/换网后被踢扫码的根因)
* ============================================================ */
static void tmpdir_of(const char *name, char *out, size_t sz)
{
const char *base = getenv("TMPDIR");
if (!base || !base[0]) base = getenv("HOME");
if (!base || !base[0]) base = ".";
snprintf(out, sz, "%s/ncm_m9_%s_%d", base, name, (int)getpid());
char cmd[512];
snprintf(cmd, sizeof cmd, "rm -rf '%s'; mkdir -p '%s'", out, out);
(void)system(cmd);
}
/* 旧版(MAC 派生密钥)密文构造:magic "NCM1" + iv + AES-128-CBC(PKCS7)。
* 仅单测用,复刻 ncm_store 的旧格式以便验证「升级后可解 + 自动迁移」。 */
static int legacy_encrypt(const char *plain, unsigned char *out, size_t *out_len)
{
unsigned char key[16];
if (ncm_store_machine_key("os.meow.ncm.session.v1", key) != 0) return -1;
size_t n = strlen(plain);
int pad = (int)(16 - (n % 16));
size_t total = n + (size_t)pad;
unsigned char iv[16], iv0[16];
for (int i = 0; i < 16; i++) iv[i] = (unsigned char)(0x10 + i);
memcpy(iv0, iv, 16);
unsigned char *pt = (unsigned char *)malloc(total);
unsigned char *ct = (unsigned char *)malloc(total);
if (!pt || !ct) { free(pt); free(ct); return -1; }
memcpy(pt, plain, n);
memset(pt + n, pad, (size_t)pad);
mbedtls_aes_context aes;
mbedtls_aes_init(&aes);
mbedtls_aes_setkey_enc(&aes, key, 128);
mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_ENCRYPT, total, iv0, pt, ct);
mbedtls_aes_free(&aes);
memcpy(out, "NCM1", 4);
memcpy(out + 4, iv, 16);
memcpy(out + 20, ct, total);
*out_len = 20 + total;
free(pt); free(ct);
return 0;
}
static void test_session_key_stability(void)
{
printf("== F. 会话加密密钥稳定性(断网/网卡变化不再丢登录态)==\n");
const char *cookie = "MUSIC_U=ABC123; __csrf=xyz";
char dir[256];
tmpdir_of("store", dir, sizeof dir);
setenv("NCM_MAC", "aa:bb:cc:dd:ee:01", 1);
ncm_store_t st;
CHECK(ncm_store_init(&st, dir) == 0, "store init");
CHECK(ncm_store_save_session(&st, cookie) == NCM_OK, "session 落盘(.mkey 派生密钥)");
/* 网卡变化(断网/换网卡/uplink 顺序变化):MAC 从 01 变 02 */
setenv("NCM_MAC", "aa:bb:cc:dd:ee:02", 1);
char back[NCM_COOKIE_MAX];
int rc = ncm_store_load_session(&st, back, sizeof back);
CHECK(rc == NCM_OK && strcmp(back, cookie) == 0,
"MAC 变化后 session 仍可解(旧版此处失败→cookie 丢失→退扫码)");
/* 旧版 MAC 派生密文:仍可解,且 load_session 自动迁移为新密钥 */
char dir2[256];
tmpdir_of("legacy", dir2, sizeof dir2);
ncm_store_t st2;
ncm_store_init(&st2, dir2);
unsigned char enc[8192];
size_t elen = 0;
setenv("NCM_MAC", "aa:bb:cc:dd:ee:03", 1);
CHECK(legacy_encrypt(cookie, enc, &elen) == 0, "构造旧版(MAC 派生)密文");
char path[600];
snprintf(path, sizeof path, "%s/session.enc", dir2);
CHECK(ncm_store_write_file(path, enc, elen) == NCM_OK, "写入旧版 session.enc");
setenv("NCM_MAC", "aa:bb:cc:dd:ee:04", 1); /* 故意换 MAC:旧解密本应失败 */
char back2[NCM_COOKIE_MAX];
rc = ncm_store_load_session(&st2, back2, sizeof back2);
/* 旧格式是用 03 的 MAC 加的密钥;此刻 NCM_MAC=04 → 旧密钥也解不出,这是预期:
* 迁移只在「当前 MAC 与旧文件一致」时发生(真机升级时 MAC 未变)。验证迁移路径: */
CHECK(rc != NCM_OK, "旧密文 + 已换 MAC → 解不出(预期,证明旧方案确实依赖网卡)");
snprintf(path, sizeof path, "%s/session.enc", dir2);
setenv("NCM_MAC", "aa:bb:cc:dd:ee:03", 1); /* 回到写入时的 MAC(=真机升级现场) */
CHECK(ncm_store_write_file(path, enc, elen) == NCM_OK, "重写旧版 session.enc");
rc = ncm_store_load_session(&st2, back2, sizeof back2);
CHECK(rc == NCM_OK && strcmp(back2, cookie) == 0, "旧密文在升级现场可解(兼容)");
setenv("NCM_MAC", "aa:bb:cc:dd:ee:05", 1); /* 迁移后:换 MAC 仍可解 */
char back3[NCM_COOKIE_MAX];
rc = ncm_store_load_session(&st2, back3, sizeof back3);
CHECK(rc == NCM_OK && strcmp(back3, cookie) == 0, "已自动迁移为 .mkey 密钥(换 MAC 仍可解)");
char cmd[512];
snprintf(cmd, sizeof cmd, "rm -rf '%s' '%s'", dir, dir2);
(void)system(cmd);
unsetenv("NCM_MAC");
}
int main(void)
{
printf("===== M9 离线会话保持 / 登录门 宿主单测 =====\n");
test_err_class();
test_http_class();
test_gate();
test_api_map();
test_api_from_cache();
test_login_map();
test_session_key_stability();
printf("\n== SESSION GATE SIM: %s (http stubs called %d times) ==\n",
g_fail ? "FAIL" : "ALL PASS", g_http_calls);
return g_fail ? 1 : 0;
}
+45 -2
View File
@@ -40,8 +40,11 @@ size_t ncm_strlcpy(char *dst, const char *src, size_t sz)
}
void meow_log(meow_log_level lvl, const char *fmt, ...) { (void)lvl; (void)fmt; }
/* audio_out.c 引用但本 sim 不经 SDK 调用的符号:只链不跑就满足链接 */
int meow_audio_open(int rate, int ch, int fmt) { (void)rate;(void)ch;(void)fmt; return -1; }
/* audio_out.c 引用但本 sim 不经 SDK 调用的符号:只链不跑就满足链接。
* 会话建立→音量同步用例(§切歌音量骤增回归)需要 audio_out_open 真正「开新流/分配新 sid」,
* 故 meow_audio_open 桩改为返回递增 sid(模拟 audiod 单调分配),供 §切歌 会话切换复现。 */
static int g_sim_sid = 0;
int meow_audio_open(int rate, int ch, int fmt) { (void)rate;(void)ch;(void)fmt; return ++g_sim_sid; }
int meow_audio_write(const void *pcm, int frames) { (void)pcm; return frames; }
int meow_audio_pause(int sid, int paused) { (void)sid;(void)paused; return 0; }
int meow_audio_close(int sid) { (void)sid; return 0; }
@@ -161,6 +164,46 @@ int main(void)
printf("PASS changed-volume re-sync: settings=50 -> 2nd connection pct=50\n");
}
/* §切歌音量骤增回归:会话建立→音量同步。切歌 = 新 audio_out_open = 新 audiod 流(新 sid),
* 必须**每会话重发一次**当前音量——此前 g_vol_synced 进程级「首同步一次」,新会话被跳过,
* DAC 停在会话建立后的默认响度(切歌骤增,按音量键才回落)。用真实 audio_out_open/close
* (meow_audio_open 桩返回递增 sid)断言:新会话必 +1 连接(重发音量),同会话内重复 sync 不新增。 */
{
int base = g_nconn; /* 上面值变重发测试后基线 = 2 条连接 */
/* 会话 1(首开):应重发 1 条 VOLUME,pct==当前 settings(50) */
if (audio_out_open(44100, 2, 16) != 0) { printf("FAIL session1 audio_out_open\n"); fail = 1; }
usleep(50000);
if (g_nconn != base + 1) {
printf("FAIL session1 establish: conns=%d (expect %d) — 新会话未重发音量\n", (int)g_nconn, base + 1);
fail = 1;
} else if (g_got_pct != 50) {
printf("FAIL session1 establish: pct=%d (expect 50)\n", (int)g_got_pct); fail = 1;
} else {
printf("PASS session1 establish -> 1 volume sync (pct=50)\n");
}
/* 同会话内反复 sync(同值):不得新建连接(防 fd 风暴) */
for (int i = 0; i < 20; i++) audio_out_sync_volume();
usleep(50000);
if (g_nconn != base + 1) {
printf("FAIL same-session repeat sync: conns=%d (expect %d)\n", (int)g_nconn, base + 1);
fail = 1;
} else {
printf("PASS same-session repeat sync -> no new connection (storm-free)\n");
}
audio_out_close();
/* 会话 2(切歌):再开 → 必须再重发 1 条(新 sid),pct==50 */
if (audio_out_open(44100, 2, 16) != 0) { printf("FAIL session2 audio_out_open\n"); fail = 1; }
usleep(50000);
if (g_nconn != base + 2) {
printf("FAIL session2 (song switch): conns=%d (expect %d) — 切歌未重发音量\n", (int)g_nconn, base + 2);
fail = 1;
} else if (g_got_pct != 50) {
printf("FAIL session2 switch: pct=%d (expect 50)\n", (int)g_got_pct); fail = 1;
} else {
printf("PASS session2 (song switch) -> new volume sync (pct=50)\n");
}
}
g_stop = 1;
pthread_join(th, NULL);
unlink(g_sock);
+2 -1
View File
@@ -20,7 +20,8 @@
* -Icore -I../vendor/jsmn -c app/logic/appstate.c -o /tmp/ws_app.o
* $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -I../vendor/jsmn -c app/logic/songmeta.c -o /tmp/ws_meta.o
* $CC_HOST -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o -lpthread
* $CC_HOST -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o \
* out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread
* 运行:./window_sim (0 网络;PASS/FAIL 退出码)
*/
#include "../app/ncm_app_internal.h"
+1 -1
View File
@@ -7,7 +7,7 @@ INC_CORE="-Icore"
INC_VENDOR="-I$VENDOR/mbedtls/include -I$VENDOR/jsmn -I$VENDOR/qrcodegen/c"
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c"
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c core/ncm_session.c"
VENDOR_C="vendor/qrcodegen/c/qrcodegen.c" # qrcodegen 单独编译
JSMN_IMPL="core/jsmn_impl.c" # jsmn 唯一实现 TU
+3 -1
View File
@@ -51,7 +51,9 @@ touch "$MB/library/error.c" "$MB/library/version_features.c" \
"$MB/library/psa_crypto_driver_wrappers.h" \
"$MB/library/psa_crypto_driver_wrappers_no_static.c"
# 关键:config 变更不会自动触发 .c 重编,务必清掉旧对象/库再重编,否则 PEM 等新启用模块不会进库。
rm -f "$MB/library"/*.o "$MB/library"/*.a
# everest/ p256-m 的对象在 3rdparty/ 下,也要清,否则上一轮(如宿主 gcc)对象会被归档进 mipsel 库。
find "$MB/library" "$MB/3rdparty" -name '*.o' -delete
find "$MB/library" "$MB/3rdparty" -name '*.a' -delete
BM="$OUT/mbedtls/mipsel"; rm -rf "$BM"; mkdir -p "$BM"
# 在 vendor 源码内建,输出到本 app 的 out 目录
+7 -5
View File
@@ -16,13 +16,15 @@ STRICT="-Wall -Wextra -Os -DJSMN_HEADER"
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c"
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c core/ncm_session.c"
APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/ncm_colors.c app/ui/chrome.c \
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \
app/ui/ncm_colors.c app/ui/chrome.c \
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c \
app/play/mem_watch.c"
app/logic/cover.c app/logic/account.c app/logic/retry_ctl.c \
app/play/player_manager.c app/play/audio_out.c \
app/play/ipc_media.c app/play/meta_gate.c app/play/decoder.c app/play/stream_src.c \
app/play/minimp3_impl.c app/play/mem_watch.c"
echo "==> app+core (strict)"
for f in $APP $CORE; do
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# build_ncm_cache_host.sh — 宿主单测:core/ncm_cache.c(M6 实际档命名 / 命中下探 / 旧名兼容 / LRU)。
# ncm_cache.c 依赖 ncm_tag.c(纯 libc)+ ncm_common/store/api 的少量符号,后者由测试文件内联桩提供,
# 故不依赖 mbedtls/jsmn/vendor(vendor 缺失时也能跑,$TMPDIR 纪律见 test 文件头)。
cd "$(dirname "$0")"
CC_HOST=${CC_HOST:-gcc}
OUT="test/test_ncm_cache_host"
$CC_HOST -O2 -Wall -Wextra -Icore \
core/ncm_cache.c core/ncm_tag.c test/test_ncm_cache.c -o "$OUT" \
|| { echo "build test_ncm_cache FAILED"; exit 1; }
echo "build OK: $OUT"
echo "run: ./$OUT"
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# build_ncm_cross_host.sh — 在 Termux/本机(非容器)跑通 ncm 的容器交叉链路。
#
# 原构建脚本假定容器布局:build_ncm.sh 用 $HOME/Project/R3ProII/.tools 与
# .tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf(libz.a);libs_common.sh 指向
# /home/Aska/...;build_sdk.sh 从 meow_launcherd 取 meow_font.a。本脚本不改这些脚本,
# 而是临时布局一个同构 $HOME(符号链接到本机真实工具链/仓库),再原样执行:
# 1) .tools/zig-mipsel-cc -> ~/.tools/zig-mipsel-cc
# 2) .tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf
# -> ~/.tools/mipsel-linux-musleabi-sysroot(含 lib/libz.a)
# 3) .tools/zig-env/bin/zig -> 包装器:`zig ar` 转系统 GNU ar(架构无关)
# 4) launcher 镜像目录:仓库 meow_launcherd 头文件 + 取回的 meow_font.a(*.a 不入库)
#
# 依赖(均不入库):
# ~/.tools/zig-mipsel-cc
# ~/.tools/mipsel-linux-musleabi-sysroot
# patch_v2/sdk/vendor/freetype-src FreeType 2.14.3 源
# patch_v2/ncm_app/vendor/{mbedtls,jsmn,minimp3,qrcodegen,dr_flac}
# meow_font.a:默认找 patch_v2/meow_launcherd/meow_font.a,否则 <WORK>/launcher/meow_font.a
#
# 用法:
# bash patch_v2/ncm_app/build_ncm_cross_host.sh [all|prep|sdk|vendor|host-mbedtls|core|tests|ncm]
# 缺省 all;各步日志写 <WORK>/logs/<step>.log(WORK 默认 ~/.cache/meow-ncm-build)。
set -e
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
WORK="${NCM_CROSS_WORK:-$HOME/.cache/meow-ncm-build}"
LOGS="$WORK/logs"
HOMELAYOUT="$WORK/home"
PROJ="$HOMELAYOUT/Project/R3ProII"
TOOLS="${MEOW_TOOLS_DIR:-$HOME/.tools}"
SYSROOT="${MEOW_SYSROOT:-$TOOLS/mipsel-linux-musleabi-sysroot}"
FONT_A="${MEOW_FONT_A:-$REPO_ROOT/patch_v2/meow_launcherd/meow_font.a}"
usage() { sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; }
prep() {
mkdir -p "$PROJ/.tools/mipsel-linux-muslsf-cross" "$PROJ/.tools/zig-env/bin" "$LOGS" "$WORK/tmp"
ln -sfn "$TOOLS/zig-mipsel-cc" "$PROJ/.tools/zig-mipsel-cc"
ln -sfn "$SYSROOT" "$PROJ/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf"
cat > "$PROJ/.tools/zig-env/bin/zig" <<'EOF'
#!/bin/sh
[ "$1" = ar ] && shift
exec ar "$@"
EOF
chmod +x "$PROJ/.tools/zig-env/bin/zig"
mkdir -p "$WORK/launcher-mirror"
for f in "$REPO_ROOT"/patch_v2/meow_launcherd/*; do
b="$(basename "$f")"
[ "$b" = meow_font.a ] && continue
ln -sfn "$f" "$WORK/launcher-mirror/$b"
done
if [ -f "$FONT_A" ]; then
ln -sfn "$FONT_A" "$WORK/launcher-mirror/meow_font.a"
elif [ -f "$WORK/launcher/meow_font.a" ]; then
ln -sfn "$WORK/launcher/meow_font.a" "$WORK/launcher-mirror/meow_font.a"
else
echo "缺少 meow_font.a(放 patch_v2/meow_launcherd/ 或 $WORK/launcher/,或设 MEOW_FONT_A)" >&2
exit 1
fi
[ -x "$PROJ/.tools/zig-mipsel-cc" ] || { echo "缺少交叉编译器: $TOOLS/zig-mipsel-cc" >&2; exit 1; }
[ -f "$PROJ/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf/lib/libz.a" ] || {
echo "缺少 sysroot libz.a: $SYSROOT/lib/libz.a" >&2; exit 1; }
echo "== prep OK:$HOMELAYOUT =="
}
# 各步共用:同构 HOME + 显式 CC/AR(libs_common.sh 支持覆盖)+ Termux 可写 TMPDIR
export HOME="$HOMELAYOUT"
export CC_MIPS="$PROJ/.tools/zig-mipsel-cc"
export AR_MIPS="$(command -v ar)"
export TMPDIR="$WORK/tmp"
run() {
local name="$1"; shift
echo "== [$name] $*"
set +e
( "$@" ) 2>&1 | tee "$LOGS/$name.log"
local rc=${PIPESTATUS[0]}
set -e
[ "$rc" -eq 0 ] || { echo "[FAIL] $name rc=$rc(日志 $LOGS/$name.log)" >&2; exit "$rc"; }
}
step_sdk() {
run sdk env LAUNCHER_DIR="$WORK/launcher-mirror" bash "$REPO_ROOT/patch_v2/sdk/build_sdk_host.sh" --verify
}
step_vendor() {
cd "$SELF_DIR"
run vendor bash ./build_mbedtls.sh
}
# 宿主 mbedtls:复用 vendor 步骤已裁剪并应用的 config,gcc 重编同一套库给宿主测试。
step_host_mbedtls() {
local MB="$SELF_DIR/vendor/mbedtls" BM="$SELF_DIR/out/mbedtls/host"
[ -f "$MB/include/mbedtls/mbedtls_config.h" ] || { echo "先跑 vendor 步骤(config 未就绪)" >&2; exit 1; }
# everest/ p256-m 的对象在 3rdparty/ 下(library/ 里只有 *.c 引子);只删 library/*.o 会留下
# 上一轮的 mipsel 对象,归档后与宿主对象混架构(ld: incompatible with aarch64linux)。
find "$MB/library" "$MB/3rdparty" -name '*.o' -delete
find "$MB/library" "$MB/3rdparty" -name '*.a' -delete
rm -rf "$BM"; mkdir -p "$BM"
run host-mbedtls make -C "$MB" lib CC=gcc AR=ar \
CFLAGS="-O2 -Wno-unused-parameter -fno-stack-protector"
for a in libmbedcrypto.a libmbedx509.a libmbedtls.a; do
[ -s "$MB/library/$a" ] && cp "$MB/library/$a" "$BM/$a"
done
ls -la "$BM"
}
step_core() {
cd "$SELF_DIR"
run core bash ./build_core.sh
local DATA="$WORK/ncm_test"
rm -rf "$DATA"; mkdir -p "$DATA"
run core-test ./test/test_ncm_core_host "$DATA"
}
step_tests() {
cd "$SELF_DIR"
run tag-build bash ./build_ncm_tag_host.sh
run tag-test ./test/test_ncm_tag_host
run cache-build bash ./build_ncm_cache_host.sh
run cache-test ./test/test_ncm_cache_host
}
step_ncm() {
cd "$SELF_DIR"
run ncm bash ./build_ncm.sh
# 只匹配编译器告警本身;build_ncm.sh 结尾的 "0 warning enforced" 总结不是告警。
if grep -i "warning:" "$LOGS/ncm.log"; then
echo "[FAIL] ncm 构建出现告警(要求 0 warning)" >&2
exit 1
fi
}
case "${1:-all}" in
prep) prep ;;
sdk) prep; step_sdk ;;
vendor) prep; step_vendor ;;
host-mbedtls) prep; step_host_mbedtls ;;
core) prep; step_core ;;
tests) prep; step_tests ;;
ncm) prep; step_ncm ;;
all) prep; step_sdk; step_vendor; step_host_mbedtls; step_core; step_tests; step_ncm ;;
-h|--help) usage ;;
*) echo "未知步骤: $1(all|prep|sdk|vendor|host-mbedtls|core|tests|ncm)" >&2; exit 2 ;;
esac
+7 -4
View File
@@ -17,7 +17,7 @@ INC="-I. -I../sdk -I../meow_launcherd -Icore \
-Ivendor/mbedtls/include -Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/minimp3 -Ivendor/dr_flac"
echo "==> host SDK objects"
SDK_SRC="meow_ui_gfx meow_ui_sys meow_ui_theme meow_ui_vol meow_ui_log meow_ui_widget \
SDK_SRC="meow_ui_time meow_ui_gfx meow_ui_sys meow_ui_theme meow_ui_vol meow_ui_log meow_ui_widget \
meow_ui_input meow_ui_activity meow_ui_dialog meow_ui_ime meow_ui_draw meow_ui_view \
meow_ui_img meow_ui_config meow_ui_nav meow_ui_media meow_notify_ipc meow_pinyin \
meow_ui_audio meow_ui_anim meow_ui_box meow_ui_scroll meow_ui_str meow_cachegov"
@@ -30,10 +30,13 @@ $CC_HOST -O2 -c -I"$SDK_DIR" -I"$ROOT/patch_v2/meow_launcherd" "$SDK_DIR/test_ho
echo "==> host app sources (exclude app.c: has main + g_st; test provides them)"
APP_SRC="app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/ncm_colors.c app/ui/chrome.c \
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \
app/ui/ncm_colors.c app/ui/chrome.c \
app/logic/appstate.c app/logic/lyric.c app/logic/songmeta.c \
app/logic/cover.c app/logic/account.c app/play/player_manager.c app/play/audio_out.c \
app/play/ipc_media.c app/play/decoder.c app/play/stream_src.c app/play/minimp3_impl.c"
app/logic/cover.c app/logic/account.c app/logic/retry_ctl.c \
app/play/player_manager.c app/play/audio_out.c app/play/mem_watch.c \
app/play/ipc_media.c app/play/meta_gate.c app/play/decoder.c app/play/stream_src.c \
app/play/minimp3_impl.c"
for f in $APP_SRC; do
b=$(basename "$f" .c)
$CC_HOST -O2 -c -DMEOW_UI_HOST_TEST -DJSMN_HEADER $INC "$f" -o "$OUT/host/$b.o" || { echo "app $f FAILED"; exit 1; }
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff. Show more