Compare commits

...
134 Commits
Author SHA1 Message Date
AskaEth 5a06a8cc1a Merge branch 'feat/m50-audiod-bluetooth-output-end-to-end' 2026-11-11 20:14:50 +08:00
AskaEth d36a3a02a6 fix(audiod): build_audiod.sh 评审 P2 两条 — CC 单一命令词 + ZIG guard 下沉;顺手两处可见性
评审 r2(p2-2items/merge,reviewed 234b4d1)两条:

① P2-1 `CC="zig cc --target=mipsel-linux-musleabi"` 是死代码:多词整串存进变量,`"$CC"`
   按一个命令名展开 → `command not found`(评审实测 exit 127)。**M51 在 git worktree 里跑
   必踩**(`$ROOT/.tools/zig-mipsel-cc` 基本不存在 → 走 PATH 回退 → meow_audio 编不出来,
   而 meow_audio 正是 M43 带 time32 的那个 CLI)。
   - `CC` 只存**单个命令词**,多词 target flags 另放 `CC_EXTRA`(未加引号展开,故意分词);
   - PATH 回退改为 `CC="$(command -v zig)"` + `CC_EXTRA="cc --target=mipsel-linux-musleabi"`;
   - cli 分支加 `[ -n "$CC" ] || { 明确提示 CC=/ZIG= 或把 zig 放进 PATH; exit 1; }`——
     缺编译器时是**清晰错误**,不再是 command not found。

② P2-2 `[ -n "$ZIG" ] || exit 1` 放得太早:`$ZIG` 只有 audiod 目标用,却在 host/cli 分支之前,
   没有 zig 的机器上 `build_audiod.sh host`(只需宿主 cc)会被无谓打断。guard 下沉到 audiod
   段(MAIN 之后、LIBBT 之前),提示语也改成「audiod 目标需要它」。

顺带(评审附带观察):
③ host 目标不再用「先编一次 2>/dev/null、失败再编一次」兜底——那会把成功路径的 warning 吞掉。
   改成先跑一个 trivial 链接探针决定 `AS_LINK`(`-l:libasound.so.2` 或 `-lasound`),真正的编译
   全程保留 stderr。
④ `libasound.so.2.0.0 not found` 现在**列出试过的全部路径** + 覆盖语法(M51 在陌生环境里的
   唯一路标)。同时把 MAIN 的推导改成「git 不可用就留空」,避免产生 `/`、`$HOME` 这类伪候选。

自证(本 worktree 真跑):
- `ROOT=/nonexistent ./build_audiod.sh cli` → 正常出件 88888B/md5 3909c8d2…(评审的复现命令)
- PATH 里没有 zig → cli:`!! mipsel CC 未找到:设置 CC=…`(不再 command not found);
  audiod:`!! zig not found(audiod 目标需要它)…`
- PATH 里没有 zig → **host 目标照常 4 个产物 + test_bt_dev 71/71 + test_audiod_host 109/0**
- 正常环境两目标产物**一字未变**:meow_audiod 68604B/md5 9f58b219…、meow_audio 88888B/md5 3909c8d2…
- `test_time32_abi.sh --builds` ALL PASS,`build meow_audio src=yes macro=yes`
2026-11-11 20:05:49 +08:00
AskaEth 234b4d13b9 fix(audiod): build_audiod.sh 解 rebase 冲突后补主 checkout 回退(worktree 里也能找到固件树)
rebase 到 main(ca042aa,M43 已合入)时 build_audiod.sh 冲突,解决原则:M43 的 cli 目标
time32 接入与 M50 的路径环境变量化**两者都保留**,守护目标仍不链 time32:
  cli    : "$CC" $INC ... -static -DMEOW_TIME32_LIBC_OVERRIDES meow_audio.c ../sdk/meow_time32.c
  audiod : "$ZIG" cc --target=mipsel-linux-gnueabihf.2.22 ... meow_audiod.c bt_dev.c -lpthread -L"$LIBBT" -lasound
(M43 的守护例外注释按 main 的新措辞保留;LIBBT/ASOUND_SRC 环境变量覆盖与 host 链接兜底保留。)

解冲突时发现一个实际问题:`devdocs/fw-baseline/...` 与 `fw_apps/build/...` 这两条 ASOUND_SRC
候选**都没有入库**,在 git worktree 里运行时 REPO=worktree 根,两条都不存在 → 守护目标
直接 exit 1(本仓库正是按 worktree 并行开发的)。补一条回退:用
`git rev-parse --git-common-dir` 的父目录找到**主 checkout**(worktree 下即主仓根),
把主 checkout 里的 devdocs/ 与 fw_apps/ 也列为候选;普通 checkout 下 MAIN==REPO,行为不变。

自证(解完冲突、真实跑本脚本):meow_audiod 68604B/md5 9f58b219…(glibc-dynamic,
NEEDED=libasound.so.2/libc.so.6/libpthread.so.0,interpreter /lib/ld.so.1);
meow_audio 88888B/md5 3909c8d2…(static,含 S24/S32,4263=3/4403=0/16B-layout=OK);
test_time32_abi.sh --builds ALL PASS(build meow_audio src=yes macro=yes)。
2026-11-11 19:38:32 +08:00
AskaEth 6e67c74fe6 fix(audiod): M50 r3 评审 P2 三条 — fd 卫生 / 端点路径按 MAC 精确匹配 / 多 ACL 逐个试开
评审 r1(p2-3items/merge,reviewed 26c6077)三条:

① P2-1 dev_close() 在「BT 写路径判死 → 回落」这条路上会 close(0) 误关 stdin:
   播放线程处理 g_bt_fail_req 时**提前把 g_bt_dev 清 0**,下拍 dev_close() 就落到
   close(g_dev_fd) 分支,而 BT 打开成功时 dev_open() 返回 0(g_dev_fd==0)。
   - 去掉那行提前清零(让 dev_close 走 BT 分支自己清),并同时置 g_dev_fd = -1 做第二道保险;
   - 把关闭决策抽成纯函数 bt_dev_close_kind(bt_dev, fd)(0=NONE/1=BT/2=FD):**fd 0/1/2
     一律不当设备 fd 关**,宿主单测 D10 覆盖(含 bt_dev=0+fd=0 这条回归用例);
   - 补不变式「设备 fd 恒 >2」:dev_open 的 DAC/WAV open 过 fd_high()(0..2 时 F_DUPFD 升位,
     dup 失败则关掉并报打开失败——绝不留 stdio 区间里没人认领的设备 fd);
   - dev_close 注释改成诚实版:不再声称「已消除」,写明「BT 分支内自行清理;调用方不得
     提前清 g_bt_dev」。

② P2-2 g_bt_pcm 只写一次 → 换耳机 A→B 后 volume/info 打在 A 的端点上:
   - bt_refresh_endpoint() 每次先清空再探测,并用新 bt_dev_pick_endpoint() **按 g_bt_mac
     精确挑端点**(找不到就留空,宁可跳过 volume/info 也不串台);
   - bt_vol_full() 删除「拿 list-pcms 第一条端点凑数」的兜底(同样会串台);
   - 单测 D9:两条端点里按 MAC 分别取 A/B、未知 MAC 返回 -1。

③ P2-3 bt_dev_acl_mac() 只取第一条 ACL,多设备(手机/手柄并存,M49 正在测 BT 控制面)
   会挑错设备 → 耳机连着却回落 DAC:
   - 改名/重写为 bt_dev_acl_macs():收集**全部** ACL MAC(去重、上限由调用方给);
   - bt_find_mac() → bt_collect_candidates():①list-pcms 里已有端点的设备优先(真 A2DP)
     ②其后全部 ACL 设备;候选按可信度排序、去重;
   - bt_try_open() 改为对候选**轮流试开**,3 次尝试、300ms 间隔(1 候选=A,A,A 与 r2 语义
     相同;2 候选=A,B,A;3 候选=A,B,C),仍受退避门控;**删除裸 "bluealsa" 兜底**
     (那会落到「最近连接」的错设备,review §P2-3 明确指出);
   - 单测 D8c/D8d:两条 ACL 全取到且保序、三条含重复时去重并受上限裁剪。

顺带清死代码(评审「其余观察 2」):删除 bt_dev_count_src()、bt_dev_endpoint_path()
(生产路径已无调用者,前者早已只剩单测)。

宿主:test_bt_dev 71/71(新增 D8c/D8d/D9/D10 共 10 条),test_audiod_host 109/0,
交叉编译 0 warning(68604B,md5 9f58b2197ed91d24951918d41a78cf29,NEEDED 三条、
interpreter /lib/ld.so.1、GLIBC≤2.17),test_time32_abi.sh --builds 里
`build meow_audio src=yes macro=yes` 保持。真机项(耳机)未动:本轮不连蓝牙、不扫设备。
2026-11-11 19:37:03 +08:00
AskaEth 6e7c0d00c0 fix(audiod): M50 r2 蓝牙打开时序 — MAC 改从 hcitool con 取(list-pcms 易失)+ 短重试 + 行内匹配修正
真机(2026-11-11 18:2x/18:3x,MOONDROP Pudding 33:DE:2E:21:AD:30)暴露两个时序/解析问题:

① **list-pcms 易失,不能当 MAC 主来源**:bluealsa 的 a2dpsrc PCM 对象只在 A2DP profile
   配置好后的秒级窗口内存在(无人开 PCM 时 1~3s 就消失)。r1 用它取 MAC → 探针一错过窗口
   就 btdev=- / bt=fail 回落。改为 **主来源 `hcitool con` 的 ACL 行**(内核 ACL 是连接权威,
   开销一个 popen),拿到 MAC 直接 snd_pcm_open("bluealsa:DEV=<mac>,...") 让 bluealsa
   在 open 时自己创建 PCM;list-pcms 降级为兜底来源。
② **ACL 刚建立时 profile 可能还没配置好** → open 会瞬时 ENODEV。改 3 次 × 300ms 短重试
   (上限 600ms,不占播放线程太久);失败后才记为 fail#n 进退避(仍只在开流/重配边界)。
③ **端点路径要 D-Bus 全串**(`bluealsa-cli volume|info` 要它,不是 MAC):新增
   bt_dev_endpoint_path();PCM 撑住后再 list-pcms 取路径(此时稳定)→ bt_refresh_endpoint()。
④ **命令换行 bug**:旧 vol_full 的 token 扫描不认 '\n',真机把端点路径连换行一起塞进
   `bluealsa-cli volume <path>\n 127`,第二行成了独立命令 → 音量设置实际没下发。改为
   bt_dev_endpoint_path() 严格按空白/\r/\n 截断。
⑤ **行内匹配 bug**:is_src_line() 对整串 strstr 会借到下一行的 a2dpsrc —— hfphf/source
   行被误判成 A2DP 源端点(count_src 少算/endpoint_path 取错行)。改 line_is_src() 只在
   本行内判定。
⑥ STATE 增 `btacodec=`:读回 `bluealsa-cli info` 的 Selected codec。真机 MOONDROP Pudding
   只有 SBC/AAC,settings 请求 LDAC 时 bluealsa 接受参数但实际选 AAC —— 面板必须看到
   实际值(btcodec= 仍是请求值,btacodec= 是实际值)。
⑦ AVRCP 满量程 127(bluealsa-cli volume 取值范围 [0,127],旧值 100 只到 79%)。

宿主:test_bt_dev 66/66(新增 hcitool con 真机原文 / 端点路径 / info 原文解析用例),
test_audiod_host 109/0,交叉编译 0 warning(67944B,md5 0ec3b9a6…)。
真机:r1 二进制 18:26 那次已实测 BT 出声(bt=ok / played=55296 / bluealsa Running:true /
Delay 17.2ms / Volume 127),18:33 起耳机自动关机,r2 的 hcitool-con 主路径未能在真机复验
(耳机不可达),详见报告。
2026-11-11 19:37:03 +08:00
AskaEth 33bef36d16 fix(audiod): M50 蓝牙输出端到端 — 内联 DEV/CODEC 打开 + 失败必回落 DAC + MAC 解析修正
① ALSA 正确用法(核心):不再写 /usr/data/alsa.conf(HiBy 播放器数据文件、语法非法、
   libasound 从不加载,改它对解析零影响)。改为 snd_pcm_open("bluealsa:DEV=<mac>,
   PROFILE=a2dp,CODEC=<token>"),参数名严格取 /etc/alsa/conf.d/20-bluealsa.conf 的
   pcm.bluealsa @args[DEV PROFILE CODEC VOL SOFTVOL DELAY SRV](实测未知参数会被
   alsa-lib 拒绝);删掉 bt_apply_device_conf/bt_apply_codec_conf 两条改写分支。
② 硬 bug:bt_dev_detect_mac() 旧逻辑取端点 token「最后一个 '/' 之后」——真机
   bluealsa-cli list-pcms 输出是 D-Bus 路径 /org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/
   a2dpsrc/source,旧代码会把 "source" 当 MAC(旧单测喂的是人造输入所以一直是绿的)。
   改按 dev_XX_XX_XX_XX_XX_XX 段解析,兼容老式 a2dpsrc/<MAC>。
③ codec:settings bt.codec 经 bt_dev_codec_token() 归一成 bluealsa canonical 名
   (v4.1.1 a2dp-codecs.c 别名表,strcasecmp);auto/未知 → unchanged(不干预协商)。
④ 失败必回落(硬验收):新增 BT_ST_IDLE/OK/FAIL 状态机 —— 无端点/未连接/set_params
   失败/写路径致命错(ENODEV 等)都回落 DAC 继续出声,绝不整机静音;回落前同步武装
   numid=9(hbc3000 未烧+enable 开 PCM 会楔死整机);STATE 增 bt=/btdev=/btcodec=
   三键供面板显示「输出=蓝牙(失败)」,volctl 回落时报 dac。
⑤ 防重试风暴:只在开流/重配边界重试,指数退避 2s→60s(bt_dev_should_try),写循环内
   绝不重试;用户显式切目标才清退避。
⑥ 音量:BT 真走 A2DP 时仍不下发 DAC 反转(回归确认),回落 DAC 时按 DAC 音量控;
   AVRCP 一键置最大从 100 改 127(bluealsa-cli volume 满量程 [0,127],旧值只到 79%)。
⑦ build_audiod.sh:ROOT/REPO 自动推导,ZIG/CC/LIBBT/ASOUND_SRC 可环境变量覆盖(旧
   hiby_os_crack 路径在多数 checkout 里不存在),宿主链接 -l:libasound.so.2 失败退
   -lasound;保留 M43 的 meow_audio time32 cli 目标。

宿主:test_bt_dev 51/51(真机 D-Bus 路径格式/别名表/退避),test_audiod_host 109/0,
0 warning。真机:新二进制 glibc-dynamic 上机正常,DAC 出声回归通过,无端点回落 DAC
出声(played/rms 实证),/usr/data/alsa.conf 全流程零写入;耳机出声一项待耳机上电。

不提交二进制、不改 fw_content.conf 钉值(按塔交付形态政策,由 M51 统一重编)。
2026-11-11 19:37:03 +08:00
AskaEth ca042aa79c Merge branch 'feat/m49-netmgrd-bt-control-plane-rebuild-and' 2026-11-11 19:34:41 +08:00
worker-m49 239cebae01 fix(bt): M49 r1 评审 — 写操作预算压回 1500ms 窗口内 + A2DP 总预算/TTL 缓存 + 测试可重复 + rfkill 顺序;rebase 到 main 保住 M43 time32
rebase 到 main(00e7271,M43 time32 已合入) 时解开 build_netmgrd.sh 冲突:**两者都保留**——
M43 的 `../sdk/meow_time32.c` + `-DMEOW_TIME32_LIBC_OVERRIDES`,以及我的 ROOT/ZIGCC/OUT
环境变量化。编译改在 mktemp 目录里做(产物名仍是字面量 `-o meow_netmgrd`,供
sdk/test_time32_abi.sh 的按名定位),编完再落到 OUT —— 设了 OUT 也不会覆盖/删除仓库里
那份被跟踪的二进制(r1 期间 mv 曾把它删掉过)。门禁自证:`--builds` → build meow_netmgrd
src=yes macro=yes;`--no-builds` → 4263=3 4403=0 16B-layout=OK ALL PASS。

P1-1(写操作越过客户端 1500ms 窗口):预算模型改成**每一层都受限**——
`BT_ACT_TMO_MS 800 / BT_ACT_SHORT_MS 600 / BT_VERIFY_BUDGET_MS 220 / gap 90`,
`bt_poll_probe` 把**剩余预算**传给探测函数(`bt_acl_has_tri`/`bt_info_flag` 新增 tmo 形参),
探测自身那次 run_cmd 也受同一个 deadline 约束;`bt_onoff` 同理(up 失败只在剩余预算内重试
一次);成功文案不再补设备名(省掉 1-2 次 fork,UI 本来就按 MAC join)。
探测改为三态:命令失败/超时 = -1(**未知**),不再被当成「没有 ACL」——否则 disconnect 会在
查不到的情况下报「已断开」,又是一次假成功。
实测(宿主桩驱动 bt_ctl_execute,命令卡死 5s 的形态):connect 1042-1047ms、pair 1042-1045ms、
bt on 831ms,全部 < 1450 且有 400ms+ 余量(r1 实测 1528/2424ms)。设备侧实测 fork 成本
≈25-30ms/次(bt a2dp 50ms、bt status 100ms),余量足够。

P2-1(A2DP 1+N 子进程塞进 status JSON):`bt_a2dp_query(buf,cap,budget)` 加**总预算**,
list-pcms 单命令 ≤400ms、每个 info ≤300ms、剩下端点超预算就不再查;`bt a2dp` 命令用 900ms。
status JSON 不在无连接时查(0 次 fork),有连接时走 10s TTL 缓存 + 500ms 预算;
`a2dp_codec`/`a2dp_rate` 字段保留(MeowPanel btCard 依赖,见 docs/plan/FUTURE_MEOWPANEL.md:48)。
实测 4 端点全卡死:911ms(r1 6053ms);status JSON 首次 350-384ms、缓存命中 31-49ms。

P2-2(test_bt_ctl 不可重复):开跑先 `rm -rf` 状态目录、退出再清;连跑 3 次 ALL PASS(贴了输出)。

P2-3(bt_onoff 顺序 + 多节点):开=先 rfkill unblock(**全部**同类型节点)再 hciconfig up,
up 未生效时剩余预算内重试一次;关=先 down 再 block。新增 `rfkill_set_block_all()`(旧
`rfkill_set_block` 仍只写首个节点,WiFi 路径行为不变)。桩改成「blocked 时 up 必败」,并加了
反向用例证明桩真的会失败(否则断言空转)。

P3:①info 的 RUNCMD_EMPTY(exit 0 无输出)不再当失败跳过;②`bt_pcm_paths_parse` 的
`/a2dpsrc` 搜索限制在行内 [p,e)(原来会跨 \n 命中下一行);③`bt_devices_text` 注释写明
「本轮扫描见到过的设备」而非「此刻在场」([DEL] 不删除、devices 块含 BlueZ 历史缓存)。

附带(塔 11:06 建议,BT 作用域内先落地):连续 3 次 RUNCMD_TIMEOUT 后熔断 30s,冷却期内
直接回「熔断中」不再 fork(真机 55 个挂死 bluetoothctl 吃光进程槽的事故);任何非超时返回清零。
单测覆盖:第 4 次调用 0ms 返回。范围只限 BT 写操作,WiFi/DNS 调用点未动。

真机(本轮只读,设备已恢复在线):新件 md5 e62e7512 上卡只读验收 status/paired/connected/
devices/a2dp 全通;`ps w | grep -c bluetoothctl` 前后均为 2(≤3);Music du 199164544;
临时目录/独立实例清理干净,/usr/bin 未动。
2026-11-11 19:15:00 +08:00
worker-m49 370c290160 fix(bt): M49 蓝牙控制面重建 — paired-devices/a2dp list-pcms+info/run_cmd 四态+写后真态复查+扫描 ANSI 解析
真机 0.8.49 现场 /usr/bin/meow_netmgrd 是 P1(eb07415) 之前的旧件:`bt status` 无
connected= 字段、`bt connected|devices|a2dp` 连子命令都不存在(被当成写级拒绝)、
`bt paired` 落到 `bluetoothctl paired` → "Invalid command in menu main"。上游只有
NETMGR_UNREACH 才回退直连,INVAL/PERM 直接返回 → 三个列表恒空。

本单修掉「部署新件后依然存在」的三个真 bug + 一个解析缺陷:

1. `bluetoothctl paired` → `paired-devices`(bt_status 计数、bt paired 子命令两处);
   `devices` 只用于补名,不再当配对权威。
2. A2DP:v4.1.1 没有 `a2dp list`。改为 `list-pcms` 取端点 → 每端点 `info <path>`;
   MAC 只从 D-Bus 路径的 `dev_XX_..` 段解(真机路径带 `/a2dpsrc/source` 段,
   按「最后一段取 token」会拿到 "source")。info 查不到就跳过,不编造。
3. `run_cmd` 四态(成功有输出/成功无输出/非 0 退出/超时/起不来)+ stderr 有界抓取
   (≤256B):旧实现只回字节数、失败也返回 0 → on/off/connect/disconnect/pair 恒报成功。
   写操作改为下发后复查真态(hcitool con / bluetoothctl info Paired: / hciconfig
   power),超时或未收敛如实报 MEOW_ERR_NODEV。兼容外壳 run_cmd() 契约不变。
4. 扫描缓存:实时 `[NEW] Device` 行以 ANSI ESC 开头(真机 od -c 证据),旧解析器只认
   行首 → 实时行全丢。新增 bt_clean_ansi()/bt_scan_line_parse():剥 ESC/CSI/控制符、
   去 `[bluetooth]# ` 提示符、只认 [NEW] 与裸 Device、排除 [CHG]/[DEL];bt_scan_on 改用
   g_bluetoothctl(原来硬编码,桩对 scan 无效)。

解析器全部抽成纯函数并用真机原始字节做单测(test_bt_ctl.c 扩充:四态、ANSI 样本、
截断/空输出、list-pcms/info、hcitool con)。test_netmgrd.sh 桩改 list-pcms/info +
真机 ANSI 缓存样本;顺带修掉两处宿主可移植性问题(/tmp 不可写→$TMPDIR、桩脚本在
PATH=/bin:/usr/bin 下用了 cat/rm→改 shell 内建,test_wifi_connect 的 g_wpa_cli 悬垂
栈指针)。

按塔要求:本 commit 不含二进制、不改 fw_content.conf(netmgrd 产物由 M51 统一重编)。
build_netmgrd.sh 只做 ROOT/ZIGCC/OUT 环境变量化(旧硬编码 $HOME/Project/R3ProII),
M51 复用;time32 接入仍归 M43。
2026-11-11 18:56:06 +08:00
AskaEth 00e7271395 Merge branch 'feat/m43-time32-rollout-to-system-components' 2026-11-11 18:51:24 +08:00
AskaEth 1ad2346d34 fix(m43): r2 评审 P2 — 构建级主判据 + 数据依赖语义 + bootmenu 注释
- test_time32_abi.sh 增加构建级主判据:解析各组件的 build_*.sh,按 -o <产物>
  定位实际编译行(自动拼接续行),断言同时出现 meow_time32.c 与
  -DMEOW_TIME32_LIBC_OVERRIDES。缺任一即 FAIL(100% 抓「漏加」)。
- 二进制 16B 断言从「空间窗口」改为「数据依赖」:0/4/8 写回必须来自
  被调函数填的 ts32 缓冲(lw off() → sw 0x0,lw off+4() → sw 0x8,
  含 sra 符号扩展),修掉无关结构体写回造成的 false positive。
  已知健康件 launcherd(非内联转换)仍 PASS;复现的评审反例 bad6b 现 FAIL。
- bootmenu 注释把 initramfs 误述改为 squashfs /usr/bin(M23 未碰该文件)。
2026-11-11 18:51:16 +08:00
AskaEth 7a32b0690e fix(m43): r1 评审 P2 — 语义断言入仓 + audiod 表述对齐产物
- test_time32_abi.sh 增加 16 字节 time64 布局断言:4263 后必须写 tv_sec 0x0/0x4
  (含 sra 符号扩展)+ tv_nsec 0x8;支持「内联」与「调用后转换」两种编译形态。
  自证:amsd 出货件 FAIL、修复后 amsd PASS、不加修复重编 sysui FAIL。
- build_audiod.sh / fw_content.conf:41 的 audiod 例外说明改为:「出货件是旧工具链
  静态 musl,4403→ENOSYS→4263+宽度转换本身健康;glibc-2.22 目标同样无 ABI 错位」,
  不再单称 glibc-dynamic。
2026-11-11 18:51:16 +08:00
AskaEth 884d4c4891 fix(fw): M43 系统组件接入 o32 time32(9 组件 + meow_audio CLI)
- 构建脚本链 ../sdk/meow_time32.c + -DMEOW_TIME32_LIBC_OVERRIDES:
  sysui/svcd/bootmenu/amsd/netmgrd/crash/svc_perf/powermgr/meowpanel,
  以及 musl-static 的 meow_audio CLI(monitor 取时/轮询)
- meow_audiod 守护例外:glibc-2.22 time_t 32 位,无 musl time64 ABI 错位,不链
- 重编 10 个产物:反汇编 4263=3 / 4403=0,__clock_gettime 按 16 字节布局写回
- test_time32_abi.sh 收紧为 addiu 系统调用号匹配,避免 .text+0x1133x 偏移误报
- fw_content.conf 同步全部新 md5(清单逐行校验通过)
2026-11-11 18:51:16 +08:00
AskaEth 96c15a06ec Merge branch 'feat/m24-bootmenu-hibyos-brightness-floor' 2026-11-11 18:42:54 +08:00
AskaEth a9281c2d64 fix(bootmenu): M23 评审 r2 — 电源键锁存取代「3s 抑制」,缺键视同不可信
P2-2(r2):删除「电源键按下后 3s 内不补」的定时抑制 —— 它到期就会把用户刚熄的屏点亮
(窗内可重复),且该行为被单测固定。改为**锁存**:扫描窗内一看到电源键按下边沿就置
scan_pwr_seen=1,本次窗口内此后一律不补(why="power-key-latched"),窗口结束才清零。
理由:Bug 4 保护的是「无人值守时媒体扫描导致黑屏几分钟」;用户已按键介入,需要亮屏时
自己再按一次即可。锁存同时覆盖「播放器只写 brightness=0 不改 bl_power」与「HibyOS 自动
超时熄屏」两种残余。

bl_power 门保留但降级为纯保险,注释改成诚实表述:据 patch_v2/meow_launcherd/backlight.c:90-95
实测,本机播放器熄屏(brightness 0 + FBIOBLANK 4)后 bl_power **仍为 0**,该门在播放器熄屏
场景不生效;真正的门是电源键锁存。(上一轮注释里的「播放器灭屏会置 bl_power>0」是反向推断,
已删。)

顺手关掉评审指出的竞态:亮度保底判定块从主循环前半(keypoll 之前)移到电源键边沿更新之后,
电源按下与播放器写 0 落在同一 poll 间隙时不会再出现一轮「没看到电源键就补亮」。

非阻塞 1(采纳):settings 文件存在但缺 display.brightness 键 → 返回 -1(视同不可信),
不再走 launcher 设置表的 101→100;否则扫描窗待机补 100 比老 bug 的 60 更亮。

单测(52 → 56 条):
- 删「power key expired -> floors」(把 bug 固定成期望的那条),换「latch 在窗口内永不过期」
  「下一轮窗口(锁存清零)恢复正常补」「latched 时面板暗也不写、why=power-key-latched」;
- 「missing key -> 100」改「missing key -> -1 (untrusted)」,并补 e2e「缺键 → saved=10 不是 100」;
- 两条名字去 degraded:`floor: saved 80 (from settings) floors at 80`、
  `hotkey: blank -> saved 60 (settings value)`(降级路径已封顶 10,80/60 只可能来自 settings)。

只交源码:不含二进制、不改 fw_content.conf、不动 build_bootmenu.sh。
2026-11-11 18:42:41 +08:00
AskaEth 8cd6e21cad fix(bootmenu): M23 评审 r1 — 修 P2-1 降级路径 + P2-2 Bug4 防护退化
P2-1(降级路径把老 bug 请回来):settings 不可读时不再原样采信面板瞬时值。
  bm_bri_pick_saved 的 panel 分支改为 min(面板, BM_BRI_SAFE_LOW),src 改名 panel-capped;
  BM_BRI_SAFE_LOW 20 → 10,与 meow_watchdog 的 WD_BRI_SAFE_LOW 对齐(同一系统同一个安全下限)。
  这样 settings 不可读 + 面板 60(播放器启动画面值)时 saved 是 10,扫描窗/确认框不会再写 60。
  注释改掉「与 wd_bri_decide 同构」的断言:方向相同,但 bootmenu 没有运行期亮度快照,
  降级走安全下限而不是 M20 的「快照 → cur>0 不写」链。
  单测:把原先钉死 bug 的 "fallback keeps panel 60" 改成 "caps panel 60 -> SAFE_LOW 10",
  并补「比 SAFE_LOW 更暗的真实值保留」「SAFE_LOW 与 M20 对齐」两个断言。

P2-2(busy 门可能让 Bug 4 防护变成空操作):删除 /proc D 态门,触发条件回到 pre-M23 的
  「窗口内 + 面板暗(bri<=0)+ 面板未下电(bl_power<=0)」。
  避免「熄屏瞬间爆亮」改由两条可观测信号承担:bl_power>0(播放器 FBIOBLANK)与
  「电源键按下后 3s 内不补」(supervisor 已在 10ms 轮询 evdev,记录按下边沿)。
  单测补 Bug4 防退化回归用例(窗口剩余 89999ms、面板暗、未下电 → 必须补)。

评审非阻塞三条:
- 日志节流:同一原因 ≥2s 一条、原因跃迁也 ≥1s(防 visible↔scan-floor 每 400ms 交替刷屏,
  90s 窗口最坏 ~90 行);日志字段 busy= 换成 pwr_ago=。
- 单测复跑配方补 TMPDIR;tmp_root() 增加自愈(TMPDIR → 可写 /tmp → $HOME/tmp/m23test)。
- 验收配方(3 次亮/灭屏 + 进一次开机菜单 + 期望日志行)写进报文。

只交源码:不含二进制、不改 fw_content.conf、不动 build_bootmenu.sh。
2026-11-11 18:42:41 +08:00
AskaEth bc7bafa5dc fix(bootmenu): M23 亮度单一权威源(只交源码,二进制由 M43 统一重编)
真机 0.8.46 两个症状:
1) 开机菜单亮度 = 第一屏的 60(用户设置 1)——根因是 M6.25 的选单亮度写回只改了源码、
   二进制没重编(strings 无 display.brightness_min),本次只交源码,重编交给 M43 批次。
2) HibyOS 亮/灭屏瞬间爆亮——supervisor 把启动瞬间面板读数(播放器写的 60)当保底,
   且 >60 一律钉 60;扫描窗保底只要读到亮度<=0 就在 400ms 内写回该值,用户主动熄屏
   也会被强行点亮。

改动(全部失败也沿用现状行为):
- saved_brightness 权威源改为 settings.conf 的 display.brightness(与 watchdog/launcher
  同源同 clamp);面板读数只在 settings 不可读时兜底;两者都不可知才写 BM_BRI_SAFE_LOW。
- 旧的 `>60 → 60` 硬钉改为 clamp 进 [display.brightness_min, max_brightness](bm_bri_clamp),
  不再用常量顶替用户值;为什么不会重新引入 M2.9 开机闪屏写在 bm_bri_pick_saved 注释里。
- 扫描窗保底加「播放器忙(proc 态 D)+ 面板未下电(bl_power<=0)」两门,窗口结束彻底放手。
- 热键熄屏恢复:面板真实正值优先(HibyOS 内改过亮度),否则用 settings 值,夹进 [min,max]。
- 决策抽纯函数 bm_bri_pick_saved / bm_bri_clamp / bm_bri_floor_decide / bm_bri_hotkey_saved。
- 亮度判定/写回补 bm_log 落卡(stderr 原行全部保留),只在状态跃迁时打,不在高频路径刷日志。
- test_bm_fw.c:宿主单测(settings=1/saved=60/now=0/blank 矩阵 + clamp 矩阵 + 真文件 e2e)。
- -Wall -Wextra 清理:3 处 unsigned/int 比较、未用参数、未用变量、sys/poll.h→poll.h。

不含二进制、不改 fw_content.conf 钉值(保持 4441822d)。
2026-11-11 18:42:41 +08:00
AskaEth 63644985a6 Merge branch 'feat/m47-settings-bt-and-wifi-true-toggle-sta' 2026-11-11 18:20:27 +08:00
AskaEth f49585dd62 fix(launcher): M47 r1 评审 P2 四项 — 未知态不写盘/不顶替 + deadline 从点击起算 + 开关弹窗后置清理
评审 r1 status=p2-4items / merge=fix-then-merge(reviewed e4eda3a)。逐条:

P2-1 未知态被写成 settings=0,把失真推到别的入口
- bt_page.c / wifi_page.c:`meow_settings_set_bool(..., disp == 1)` 加 `disp >= 0` 门。
  disp == -1(daemon 与直连都读不到)时**保留用户原意图不动**。该键被 notif_center /
  settings_page 当显示态读、被 app_launcher 用来决定自动拉起 WiFi,写 0 会把「不知道」
  污染成「确定已关」。
- test_bt_page.c 5d 期望同步改:未知 → settings 保持原意图 1(不再期望「回滚为 0」)。

P2-2 收尾后「探测未知」仍显示成确定态,第三态丢失
- mtog_calc:显示态**只认 probe**,probe < 0 直接返回 -1;last_real 不再顶替确定态
  (`(void)last_real`,仅由调用方用于弹窗文案)。超时/下发失败且探测未知 → 顶栏保留
  「关+?」、行内保留「状态未知」,与文件头契约一致。
- 弹窗文案按结论区分:「无法确认当前状态(上次已知:开启/关闭/未知)」vs
  「切换未生效:当前仍为开启/关闭」——不再出现「当前仍为开启」自相矛盾(该文案只在
  probe 已知且 != 意图时出现)。日志加 elapsed。

P2-3 deadline 起点错,注释上限是假的
- bt_page/wifi_page:`deadline` 从**点击时刻**起算(含下发耗时)。
- 注释改成如实上限:快路径 1~2s / ≈16 次探测;慢路径(on-off 读超时 1.5s + hciconfig
  0.3~2s,之后一次探测 status 1.5s + 直连 1.8s ≈3.3s)总阻塞最坏 ≈7s、预算内只够 1 次
  探测,此时只能确认「探测超时」而非「设备无响应」——文案已按此区分。异步化留给后续单。

P2-4 开关路径弹窗后缺 after_modal / 按键基线复位
- subpage.c:顶开关命中分支补上与行命中分支相同的后置清理(排空触摸 + 40ms 二次排空、
  触摸基线/cooldown、key_router_rebase、kd_*/kp_poll 基线、m_prev_*、after_modal=1)。
  s_dd_modal 是全屏压暗层,否则关闭后状态栏(0..27)残留压暗到下一次时钟重绘,电源/播放键
  也可能带进弹窗里的陈旧边沿。

顺带(评审观察,未改全局超时策略)
- netmgr_ipc.c:拆出 `nm_cmd_t(..., int *read_timeout)`;`nm_bt_power_state()` 走它,
  **读超时 = 状态未知且不把可达性缓存打成 down**(netmgrd 单线程忙时 `bt status` 自己
  跑 hciconfig 可能超 1.5s;旧行为会标 down 1s,令紧随的 bt_rows 闪出「服务未运行(直连
  模式)」并把后续探测切到 1.8s 直连)。WiFi/DNS 等其它调用保持旧语义(传 NULL)。

验证
- `bash patch_v2/meow_launcherd/build_launcher.sh test` → exit 0,**65 PASS / 0 FAIL / 0 warning**
  (矩阵 13 组,新增「下发失败·探测未知 → 未知不顶替」)。
- 交叉编译 0 warning;产物(临时副本,不入库)901020 B / md5 e21450ff6621a67fd854165413c58be1。
- `test_time32_abi.sh` → 4263=3 / 4403=0 ALL PASS;`pkg_install` 与 7 条切换中/未知态文案
  均在 strings 中(旧的两条「无法确认蓝牙/WiFi 状态」已合并为「无法确认当前状态(上次已知:%s)」)。
- 真机 --sysdump(只读,临时文件已删):exit 0,bt_on=1/wifi_on=1 与设备现状一致;
  Music du 前后 199164544;未替换 /usr/bin/meow_launcherd、未重启 launcherd。
- 交付形态不变:本 commit 只动 5 个源码文件;二进制与 scripts/build/fw_content.conf 对 main 零 diff。
2026-11-11 17:59:13 +08:00
AskaEth e4eda3a06f Merge branch 'main' into feat/m47-settings-bt-and-wifi-true-toggle-sta 2026-11-11 17:23:55 +08:00
AskaEth 514cc0e5d0 chore(m47): 只交源码 — 撤回本单的 launcherd 二进制与 fw 钉值
M42(pkg_install.c)已并入 main,本分支 base 的编译列表不含它,本单产出的
二进制/钉值是旧 base 的(1c88661a…)。为避免「每个单各改一次二进制和钉值」继续
制造 M48 审计查出的分叉,tower 立了 M51 统一重编批次:所有产物的最终重编、钉值
回写、语义指纹自证与 .upt 打包都在那一个受控批次里做一次。

本 commit 把两个文件恢复到 base 原值(不做任何手写钉值):
- patch_v2/meow_launcherd/meow_launcherd → 605e13a9…(base 版)
- scripts/build/fw_content.conf 的 launcherd 行 → base 原值

源码改动(bt_page/wifi_page/netmgr_ipc/net/subpage/两个测试/build_launcher.sh 的
ZIGCC/MIPS_SYSROOT 环境变量化)全部保留。合并 main 后编译列表需同时含 pkg_install.c。
2026-11-11 17:23:42 +08:00
AskaEth b20e3c43e9 fix(launcher): M47 设置页蓝牙/WiFi 开关只认真实态 — 三态查询 + 待确认收敛 + 未知态呈现
用户原话:「开关都存在显示问题 我都不能判断它是不是真的开启还是关闭(包括 WiFi 页也是)」。
根因不是「没读到硬件态」,而是「读到/没读到都被拿去当用户意图的装饰」:
- bt_page.c:103 开关初值来自 settings 布尔;:112-114 下发后立刻 g_sys.bt_on = on(显示意图);
  :129 c->bt_on = nm_bt_powered() 赋值后全文无人引用(M45 也独立发现)。
- wifi_page.c:40 读不到 settings 时默认 1(显示已开)。
- netmgrd 的 bt/wifi 查询本身把「问不到」塌缩成 0(nm_bt_powered/nm_wifi_connected)。

本单只解「状态可信」,不碰扫描/配对/音频(另有 M45/M46 单)。

1) 真实态三态(-1 未知 / 0 关 / 1 开)
- net.c 新增 net_bt_power_probe()(hciconfig hci0 UP/DOWN;无输出=未知)与
  net_wifi_power_probe()(rfkill wlan soft/hard 汇总,回退 wlan0 IFF_UP;读不到=未知)。
- netmgr_ipc.c 新增 nm_bt_power_state()(daemon bt status 的 available/powered 权威,不可达
  回退直连探测)与 nm_wifi_power_state()(netmgrd 的 wifi status 只有 wpa_state、不报射频态,
  故取本地 rfkill——daemon 的 wifi on/off 改的就是同一份内核状态)。
- 两个子页的 top_sw/rows 只读这两个函数;settings 布尔降级为「意图持久化」,不再决定外观。
- 「读不到默认 1」删除:未知就是未知。

2) 点击 → 待确认 → 真实态定稿
- 纯函数 meow_toggle_{display,pending,rollback,error}(bt_page.c,无 I/O/UI):
  只有 probe 确认才算收敛;probe 未知继续等,绝不因 last_real 恰好等于意图就宣布成功。
- 页面:落盘意图 → 下发 → 250ms 轮询(BT 上限 4s、WiFi 上限 5s;hciconfig 0.3~2s、
  wifi_up.sh 起 supplicant/DHCP 更慢,故不用 20ms 级忙轮询)→ 真实态定稿;失败/超时把
  settings 回滚到已确认真实态 + s_dd_modal 可见报错,不静默、不乐观。
- subpage.c:切换前先画「正在开启/关闭…(等待设备确认)」条(待确认态与稳定态可区分);
  切换后回读 top_sw 定稿(取消原「点了就翻面」),live tick 也回读,外部改动驻留期间可见。

3) 第三态呈现(未知 ≠ 已关)
- SDK meow_switch 只有两态:未知时开关停「关」并在左侧画灰色「?」;行内另有
  「蓝牙状态未知 / WiFi 开关状态未知」文字说明。选「?」是因为它不占用开关控件本身、
  且「关+?」不会被读成「已关」。

4) 顺带修 net.c 三条开关失真(塔追加的 M45 盘点,同一病根)
- s_net_toggle_bt() 补 rfkill soft(与 netmgrd 的 bt on/off 同一语义;开:先 unblock 再 up;
  关:先 down 再 block)。此前只发 hciconfig → 「BT 页关过再从头开」会被 rfkill 拦下。
- s_net_sync_from_sys() WiFi 判据从 wlan_connected()(已关联)改为射频态;BT 用三态,
  读不到不写(旧写法把「网卡 up 未关联」写成 0,把未知写成已关)。
- rfkill 同类多节点(本机 2 wlan + 2 bluetooth)按「任一 block 即 block」读、全部写入。

测试(`bash patch_v2/meow_launcherd/build_launcher.sh test`,新增 test 入口)
- test_bt_page.c:P4 三态与未知占位、P5 收敛四例(达成/下发失败/超时/未知)、P6 收敛矩阵
  12 组(真实开/关 × 下发成功/失败/超时 × probe 未知),全 PASS(64 条断言)。
- test_netmgr_ipc.c:新增 nm_bt_power_state 解析(powered=1/0、available=0、缺字段→-1)、
  nm_wifi_power_state 透传、不可达回退=未知、wifi_rows 未知态占位;并修掉两处既有测试缺陷
  (行内 UI 桩缺失导致本文件此前根本链不过;直连连接桩统一返回 "0\n" 导致等满 10s 必失败)。
- 交叉编译 0 warning(-Wall -Wextra,逐文件单独编 + 整链);launcherd 899388 B
  md5 1c88661a9410ac1a4139304362281cdd,同 commit 同步 scripts/build/fw_content.conf 钉值。

真机(10.58.215.50,仅只读自检,未替换 /usr/bin/meow_launcherd、未刷机)
- 二进制放 /usr/data/mnt/sd_0/MeowOS/tmp/ 跑 --sysdump:exit 0,输出 wifi_on=1 / bt_seen=1
  bt_on=0,与设备现状一致(rfkill 全 0 + wlan0 up + hci0 DOWN + netmgrd powered=0)。
- 卡上 du -s /usr/data/mnt/sd_0/Music 前后恒 199164544;临时文件已删;settings 未改。
- launcherd 在 squashfs 里 → 本改动需刷机生效。

已知不在本单:输出目标只写 settings(audiod 仅启动读一次)→ M45 finding / M46;
netmgrd 侧四处失真(未知当关、up 与 unblock 顺序、rfkill 只写首节点、wifi status 无射频字段)
已报 finding;设置主页/通知中心的开关显示仍是 settings(另立单)。
2026-11-11 17:16:28 +08:00
AskaEth 3f22158fc2 Merge branch 'feat/m42-launcherd-pkg-install-to-nyai' 2026-11-11 17:08:54 +08:00
AskaEth 58cdb73bf8 fix(launcherd): M42 包安装入口改委托 nyai — 删内联解包/验签/部署,复用 M35 安全链
- app_launcher.c 删掉 meow_pkg_install 旧内联实现(文件名推包名、硬要 .mpk 后缀、
  rm -rf 目标目录直解、失败不回滚 .bak_、不拒 ./..、系统包大小写敏感比较);
  导出保留签名逻辑不动(验签仍只 shell-out meow_verify)。
- 新增 pkg_install.c:fork+exec /usr/bin/nyai install <路径>(argv 直传不经 shell;
  MEOW_NYAI_BIN 覆写,缺省不走 PATH)。包身份/暂存/清理/防降级/大小写全由 nyai 闭环。
- stdout/stderr 分两条管道收:nyai 进度是缓冲 stdout,错误是即时 stderr,合流会把
  真错误顶掉(真机首测即踩到);错误文案只取 stderr 尾行。
- start/poll 两段式(+busy 查询)供 UI 主循环按帧驱动不假死;同步封装 20ms 切片轮询,
  180s 超时 SIGTERM→SIGKILL,不用时钟 ABI(M40 time32 教训)。
- main_menu --pkg-install 失败打 stderr 可读文案、退出码统一 0/1。
- test_pkg_install.c:44 断言(路径预检含任意文件名可装/目录/不可读/控制字符/超长、
  错误文案矩阵、argv 直传、start 立即返回、并发拒装、超时、nyai 缺失)。
- 重链 meow_launcherd 894940B/cea0a735(链 meow_ui.a 86800633,0 warning);
  fw_content.conf 同步钉值。
2026-11-11 16:37:00 +08:00
AskaEth ebd64d07e1 Merge branch 'feat/m41-reader-scroll-broken-in-folder-mode' 2026-11-11 13:23:24 +08:00
AskaEth bd6fc8da3c fix(reader): M41 r1 评审 P2 — 拖动重绘节流 + 终帧补齐 + 三条边界测试
P2-1(用户可感知):shelf_on_touch_move 每收到一条 TOUCH_MOVE 就同步全量重绘。
输入线程「坐标一变就一条 MOVE」且不合并(队列 128 条),folder ≤200 行走圆角卡
≈40ms/帧 → 主循环追不上手指、抬手后还会继续滑、极端情况丢 release。修法:
- 滚动位置仍每条 move 立即生效(纯算术,便宜);上屏节流:首帧立即画,之后每
  max(16ms, 上一帧实测耗时) 一次(自适应窗口,圆角卡 40ms 帧自动降到 ~40ms 周期,
  不再把队列拖垮)。
- 手势收尾(shelf_on_tap / shelf_on_swipe)无条件补终帧再清手势态;下一次按下也会
  先把上一次遗留的未上屏帧补上(HOME/长按等没有 TAP/SWIPE 收尾的手势)。
- 位置没变(已到顶/底)连帧都不画。

P2-2(测试盲区):
- 新增三条边界:顶栏起手不拖、副题区起手不拖、横向手势判向取消后不再滚动。
- 吞 TAP 边界带:20/40/60px(>8px 且 <65px,SDK 报 TAP)抬在目录行上不得进目录;
  6px 抖动(≤8px)仍算点击能进目录。
- 断言从「像素变了」升级为「像素**恰好**位移 N px」(内容区快照逐行比对,避开视口
  边缘裁剪带):swipe 120 → 恰好 120;拖动 60 → 恰好 60;并新增 6 步×10px 多步拖动
  断言终帧恰好 60(专测节流不丢最后一段)。
- 修掉把 20px 写成「< SHELF_DRAG_TAP_PX」的错误注释。

顺手:
- shelf_draw_notice 用 th->bg 而不是只在 shelf_redraw 赋值的 g_bg。
- build_reader.sh 用 trap 保证测试失败时也清 $TMPDIR/m41_scroll_sd。

验证:shelf_scroll_smoke 38/38、shelf_group_smoke 99/99、shelf_index_smoke 170/170。
红绿对照:dist=dy(旧 swipe 语义)→ 9 条 FAIL;拿掉「抬手补终帧」→ 恰好 1 条 FAIL
(6 步拖动终帧那条),证明节流与终帧都有测试盯着。
reader 1719220B md5 890175ec…;mpk 818162B md5 ffbb7097…(0.1.8/code9,未签名)。
2026-11-11 13:15:54 +08:00
AskaEth 2de47154bf fix(reader): M41 书架补滚动输入通路 — 列表/宫格、format/folder 全都能滑
书架(shelf.c)从 M6.40 首版起就**没有**滚动输入通路,不是 M32/M33 的回归:
  1) app_on_touch_down/move 只路由给阅读页 → 书架没有跟手拖动;
  2) shelf_on_swipe 用 dy 去 scroll_by,而 SDK 固定按 on_swipe(dir, dist, 0)
     分发(总位移在第 2 参 dx,dy 恒 0)→ 恒 scroll_by(0),死代码。
两条都在 shelf.c 自己身上,list/grid × format/folder 共用同一段,所以四种组合
全都「滑不动」(files/ncm 能滑是因为它们各自实现了 touch_down/move → scroll_by)。

修复:
- app.c:touch down/move 也路由给书架(阅读页优先级不变)。
- shelf.c:
  * 新增 shelf_on_touch_down/move 跟手拖动:按下点在内容区才起手;纵向主导(≥4px)
    才锁滚动,横向主导直接取消(不抢左缘返回);逐帧 scroll_by(-dy) 只重画内容区。
  * shelf_on_swipe 改按 dist(=dx,兼容 dy 分支)+ 方向定符号;本次手势已跟手滚过
    就不再叠加(防一划滚两倍)。
  * 拖动后的抬手吞掉 TAP(<65px 的拖动 SDK 报 TAP)→ 滑动时不会误开书/误进目录。
  * 控件重建(进/退目录、布局/分组切换、刷新)清滚动手势态,避免旧标记吃掉下一次滑动。
  * 提示条抽成 shelf_draw_notice(),滚动局部重画时照常重贴。
- 新增宿主回归 test/shelf_scroll_smoke.c(真实 shelf.c + 真实 SDK 绘制 + back_buf
  像素断言,27 条):format/folder × list/grid 都能滑;3 本书的目录滑不动;
  拖动后不误进目录、干净点击仍能进目录;folder 进出目录仍正常。把 dist=dx 改回
  dist=dy 后同一测试 6 条 FAIL(红绿对照证明它抓的就是这个 bug)。
- build_reader.sh 接入该测试;READER_SD_ROOT 加 #ifndef 以便宿主把扫描根指到临时目录。
- app.json 版本 0.1.7/code8 → 0.1.8/code9(同一版本号不发两个构建)。

宿主验证:shelf_scroll_smoke 27/27、shelf_group_smoke 99/99、shelf_index_smoke
170/170、其余 core/seg/chapter/loaders 全绿;reader 1718468B md5 259cb6ed5a60…
(meow_ui.a 用本仓 SDK 源码重编:79 成员全 MIPS32r2 o32)。真机验证待刷机
0.8.49 完成后部署(设备验证需用户手滑,面板无任意滑动注入端点)。
2026-11-11 13:01:12 +08:00
AskaEth a364932145 Merge branch 'feat/m40-power-key-time64-and-lock-screen-fix' 2026-11-11 12:35:35 +08:00
AskaEth 340c41afb7 fix(m40): 评审 r1 — rebase 解冲突 + 宿主构建/可复验断言(无运行逻辑改动)
- rebase 到 main(b1acc90,含 M39 r1):fw_content.conf 冲突取 launcherd 605e13a9、
  watchdog 37c57e0f;nyai 保持 main 的 ef7940ec(M39 6c6da79 已修,本单不再改该行)
- build_ncm_host.sh:SDK_SRC 补 meow_time32(否则链接 U meow_time32_monotonic_ms);
  顺带补 meow_jpeg/meow_png_stream/meow_img_color,脚本端到端可构建
- build_sdk.sh / build_launcher.sh 加 -Wall -Wextra 并重编:0 warning,产物 md5 不变
  (meow_ui.a=86800633、meow_launcherd=605e13a9)
- 新增 sdk/test_time32_abi.sh:反汇编断言 4263≥1、4403=0(覆盖宿主 fallback 测不到的真机路径)
- 注释修正 sdk/meow_ui_repaint.c → .h
- P1-1(launcherd 只能刷固件;watchdog 可热更但需退出重进 App)为交付说明修正,无代码改动
2026-11-10 22:54:11 +08:00
AskaEth 63b278e13e fix(watchdog+launcher+sdk): M40 电源键 time64 ABI + 锁屏/叠加残留链
- sdk/meow_time32: 抽出可独立链接的 o32 time32 取时/睡眠设施(不依赖 meow_ui.a,
  watchdog 等独立静态组件可直接链;可选 -DMEOW_TIME32_LIBC_OVERRIDES 接管 libc 入口)
- watchdog: now_ms/usleep 改走 time32(修复 held 变天文数字→每次单击都弹菜单);
  阈值读 power.longpress_menu_ms/forceoff_ms(缺省 3000/8000),删除
  PWR_TOGGLE_MS==PWR_MENU_MS;新增 wd_power.h 纯判定 + 宿主行为矩阵测试
- launcher: App 前台电源菜单补齐 §10.5(notif 全抑制 + 帧恢复 + SIGCONT 排空 +
  标记翻回前台);show_power_menu 恢复进入前背景帧(关闭后不再停留菜单画面);
  重绘挪到首轮按键采样之后;activity 标记排空窗 + 会话入口清理 + manual_off 去闩锁
- lock_screen: 电源改按下边沿 + 进场 300ms 才熄屏;电源熄屏退出也恢复底层帧;
  launcher 在 SIGCONT 前写 /tmp/meow_app_repaint,SDK present 消费并 dirty_full
- power_menu: 菜单内强关接 power.forceoff_ms(按 longpress 抵扣),日志不再谎称 8s
- 重编 meow_ui.a 并重链 meow_launcherd/ncm/files/hello/reader(picbrowser 无源码)
- fw_content.conf: 同步 launcherd/watchdog 新 md5;顺带修正 M35 遗留的 nyai 失效钉值

Host: test_wd_fw 66/66、test_overlay_gate 40/40、test_meow_time64/time/repaint PASS。
真机未验证(设备关机);未刷机、未写设备。
2026-11-10 22:51:00 +08:00
AskaEth b1acc9096b Merge branch 'feat/m39-click-input-gate-removal' 2026-11-10 22:27:23 +08:00
AskaEth 6c6da79ad9 fix(launcher+fw): M39 r1 评审 P2 — 固件清单钉值同步 + APPS.md 守卫文档改到 150ms
- scripts/build/fw_content.conf:launcherd 钉值 bb2d5f12→e342d24f(本分支重链产物 891,164B),
  desc 补 M39 输入门与「链 meow_ui.a 8525aa6b」;同文件顺带修一处 pre-existing 打包阻塞:
  nyai 仍钉 M2.97 的 a7d24553,而仓库内二进制自 M35(cf5703b 评审 r1) 起已是 ef7940ec →
  从 main 打包固件会在该行 exit 1(与本单无关,但同一文件同一类错)。
- APPS.md §4.7:冷却 2500ms → MENU_TOUCH_GUARD_MS(150ms);写清「drain 才是防重放机制、
  时长只是面板重配尾巴守卫」;副作用段改为真实敞口(drain 之后硬件新产生的幽灵 1.2s 与
  150ms 都盖不住,只能靠真机日志收口),并指向 test_meow_ui_home_gesture T4。
- 校验:复刻 build_fw_apps.sh 第 4 步逐条 md5 强校验(真实 conf、临时 DST)→ 30 条全过、
  无 exit 1,装入的 launcherd = e342d24f、nyai = ef7940ec。
2026-11-10 22:16:59 +08:00
AskaEth 837cd47e7c fix(sdk+launcher): M39 点击输入门 — 固定时长吞窗改为「启动排空 + 短系统手势防穿透」
根因(M36 已查实):不是布局竞态,而是固定时长的输入封闭窗口且窗口起点在首帧之后。
- SDK meow_ui_run:M2.22(C) 500ms 内丢弃 TAP/SWIPE/HOME/NOTIF_PULL/TOUCH_DOWN/MOVE/LONG_PRESS,
  窗口在 App 画完首帧后才 arm → 用户看到界面后第一下必被吞。
  改为入口 meow_input_drain() 排空一次(清环形队列 + 复位触摸状态机),
  TAP/TOUCH_DOWN/TOUCH_MOVE/LONG_PRESS 立即生效;仅系统手势(左缘 SWIPE、底部上滑 HOME、
  顶部下拉 NOTIF_PULL、SWIPE_PROGRESS)保留 STARTUP_GESTURE_GUARD_MS=150 防穿透。
- launcher:menu_after_app_exit 1200ms→150ms;子页返回 800ms→150ms;菜单/子页打开
  300ms→150ms(统一 MENU_TOUCH_GUARD_MS)。menu_drain_touch() 同时复位 t_down/原点/坐标:
  只清队列会把「按下已被 drain、抬手稍后才到」误判成一次 tap(幽灵重放的另一半)。
  历史理由已核对:旧窗口是为 Bug2/4(App 运行期事件堆积在 launcher 自身 touch_fd 里被重放)
  加的,真正的机制就是排空本进程 fd —— evdev 每 fd 各收一份,App/子页运行期全量事件都在
  这个 fd 里,drain 后无历史事件可重放;窗口只兜面板重配尾巴。
- 单测:test_meow_ui_home_gesture 新增 T4(11 断言:立即点击生效/立即上滑被守卫挡+守卫后生效/
  立即左缘 SWIPE 同理/TOUCH_DOWN+MOVE 立即下发/LONG_PRESS 立即下发/run 入口 drain 丢弃预置残留;
  旧 SDK 下 8 条红、新 SDK 30 pass/0 fail);test_meow_ui_dispatch 新增 M5(drain 清队列+复位在途
  手势+新点击不受影响,ALL PASS);swipe 35/0、view_event ALL PASS(回归)。
- 重编重链(铁律):meow_ui.a 8525aa6b(meow_ui_input.o 8248a6c6,旧 e54903ca)→
  meow_launcherd e342d24f(891164) / files 17edac0f(383072) / hello 65c1c575(499380) /
  ncm 1c2f5ff7(1225676) / reader a463645f(1716212,gitignore 不入库)。
  picbrowser 仓库内 0 个 tracked 文件(源码不存在)→ 无法重链,见 finding。
- 真机:未验证(纪律禁止覆盖设备已装组件;uinput 注入是 M36 设备失联诱因)。
2026-11-10 21:58:13 +08:00
AskaEth 46278fd051 Merge branch 'feat/m35-nyai-manifest-authority-and-sd-stagi' 2026-11-10 21:42:40 +08:00
AskaEth cf5703b32b fix(nyai): M35 评审 r1 — 闭合 exFAT 大小写绕过(P1)+ 修 fmt_utc 1/2 月 + 成员名/炸弹纵深
P1(信任边界):is_system_pkg() 原用 strncmp,而落盘目录在 exFAT(名字比较
不区分大小写)。包内 app.json 写 "OS.MEOW.READER" 时 valid_pkg 放行、
is_system_pkg 返回 0 → 当第三方无签放行 → dst 解析到已装的
MeowAPPs/os.meow.reader → 备份后部署假包并删掉真系统 App(无需私钥)。
三层封堵:
  1) is_system_pkg 改 strncasecmp(前缀判定不再受大小写影响);
  2) valid_pkg 收紧为只收 [a-z0-9._-],并拒绝前导/尾随 '.'(现网包名全小写,
     无兼容代价;tail-dot 同时挡 exFAT/Windows 名字映射怪癖);
  3) 部署前 readdir 检查 MeowAPPs 里是否有与 pkg 仅大小写不同的已装目录
     (有则拒装并报两个名字);部署后再用 readdir 复核 on-disk 名字与清单包名
     逐字节相等,不等则回滚,杜绝「身份不明的 App 目录」。

P2-1:fmt_utc() 的 March-based 月份换算写成 +9(应为 -9),1/2 月会打成
20251901/20251931 且年份不 +1。修正为 int 运算;新增 test_fmt_utc.c
(直接 include nyai.c 测 static 函数)覆盖 epoch/负数、1/1、2/1、2/28、闰年
2/29(2024 与 2000)、3/1、12/31 23:59:59、2038-01-01、int32 上界、2100 非闰。

纵深防御(评审残留风险,一并做):
- tar 成员名净化不再依赖外部 tar:mpk_check_members 解析 tar -tv 的名字,
  拒绝绝对路径 / 含 '..' 组件 / 首字符 '-' / 原始控制字符;解包后 stg_tree_ok
  再用 readdir 的真实名字复核控制字符(tar 列名会把控制字符转义成可见文本,
  原检查会漏)。
- 解压炸弹:累加 tar -tv 的体积,超过 512MB 上限拒装,并加 statvfs
  「剩余空间 ≥ 体积 + 16MB」门禁(解包先于验签,恶意包可在验签前写满卡)。
- 顺手改掉 appmeta.package 注释里「== 文件名茎」的旧语义。

回归:test_nyai.sh 49 → 70 条断言全过(新增:fmt_utc 13 例、大写包名绕过顶替
已装系统包被拒且原 App 未变、尾点包名、与已装目录仅大小写不同、绝对路径/
../ / 首字符 '-' 成员名、控制字符成员名、700MB 解压炸弹),
`-Wall -Wextra` 零告警,二进制可重复构建。

交付:nyai 153,844 B / md5 ef7940ecf5ece993d718ac1d9019cc82(旧 4524ce6b…)。
真机 10.58.215.50 仍不可登录(22 端口 TCP 可连、15-50s 无 banner),本轮
**未做真机复核**,仅宿主验证。
2026-11-10 21:42:37 +08:00
AskaEth 829d4475e8 feat(nyai): M35 包身份以已签名 app.json 清单为准 + 暂存迁到 SD 并必清理
包名/版本/依赖不再从 .mpk 文件名推导(用户定案:不对安装包文件名强依赖,
一切信息从清单取),改为解包后读 app.json.package 作唯一权威:
  - 落地目录 MeowAPPs/<pkg>、meow_verify --package <pkg>、防降级基准全用清单包名;
  - 新增 footer.package == app.json.package 一致性检查,不一致拒装并同时报两个名字;
  - valid_pkg 拒绝 "." / ".."(此前 MeowAPPs/.. 可越出安装目录并被 rm_rf);
  - index 的 Package 字段同样取自清单(缺 package 的包跳过并告警);
  - scan/export 同步;--help 与错误文案去掉「需要 <pkg>.mpk」。

暂存:MeowAPPs/.stg_<pid>_<pkg>(/usr/data NAND,仅剩 4MB)
  → <MeowROOT>/MeowOS/tmp/pkgs/<pkg>-<UTC时间戳>-<pid>-<n>/(真机即 SD 卡):
  - mkdir 原子占位 + 失败重试 + pid/序号保证唯一,时间戳只给人看;
  - SD 未挂载/不可写直接拒装(不回退 /usr/data:目标本就在 SD,且跨 fs rename 会 EXDEV);
  - 解包前后各一道防护:tar -tv 只允许普通文件/目录(拒符号链接/硬链接/设备/FIFO),
    解包后复核暂存树(非普通文件、nlink>1 一律拒);
  - 中央化 cleanup + SIGINT/SIGTERM/SIGHUP handler(先收子进程组再清)+ atexit 兜底,
    成功/验签失败/清单非法/降级/解压失败/被中断全部删除暂存目录。

真机 time() 受 zig 0.17 mipsel time64 ABI 缺陷影响返回垃圾(实测 3381223399391169494、
localtime_r=NULL),故时间戳直取内核 o32 time32(syscall 4263)并自行换算 UTC,
不依赖 libc time()/localtime_r()。

upgrade 复用 install 同一实现(清单包名必须等于目标包名、目标必须已装)。

新增 host 回归 patch_v2/meowpkg/test_nyai.sh(49 断言:改名包/真实 0.1.7 反例/
footer≠清单/os.meow 无签名/第三方无签名/恶意包名/解压失败/SIGTERM/索引路径/upgrade)。

真机验证(固件 0.8.48,二进制只放 MeowOS/tmp,未覆盖 /usr/bin/nyai):
os.meow.reader-0.1.4.bak.mpk 现在「签名校验通过 (package=os.meow.reader)」后才被
降级门禁拒绝(旧版此处是包名不符);weird-name.bin 装成 dev.m35.probe;SIGTERM
中断后暂存目录为空;Music=199164544、/usr/bin/nyai md5 a7d24553… 均未变。
2026-11-10 21:42:37 +08:00
AskaEth 8f748b137d Merge branch 'feat/m34-font-u-00b7-middle-dot-glyph-missing' 2026-11-10 21:41:39 +08:00
AskaEth 365c8f756b fix(font): 字库生成器补 U+00B7 中点 — 修复 · 渲染豆腐块(M34)
GB2312 A1A4 经 Python gb2312 codec 解成 U+30FB(MiSans 无该字形 → 空条目),
U+00B7 从未进入字符集;meow_font.c 对「码位不存在」画空心方块,故 format 副题
「Books/ · 595 本」、书行「TXT · 10.3MB」与 M31「· 读」徽标在真机全是豆腐。

build_charset 显式 add(0x00B7),common/gbk 档生效(full 档本已含)。
重生成 default 字库(28/20px,common):glyph 4110→4194——+U+00B7 1 个,
另 +83 平假名来自 8/30 tier 重构后 common 字符集扩展(设备字体早于该重构)。
既有 4110 字形两档像素逐字节一致;渲染代码 meow_font.c 未改。
2026-11-10 20:34:54 +08:00
AskaEth e21cbc7865 Merge branch 'feat/m33-reader-folder-drilldown-navigation' 2026-11-10 19:52:07 +08:00
AskaEth 2881ce908a feat(reader): M33 目录模式逐级下钻 — 子目录/书分层显示(416 本不再平铺)
- folder 模式从「按第一级目录分段」改为当前目录视图:子目录在前(带子树书数),
  直接书在后;进入/返回只过滤已扫平的 g_books,不 readdir、不重扫(M32 索引不动)。
- 纯函数 shelf_build_rows_at(books,n,folder,cwd,...) + nav 工具(enter/up/breadcrumb/
  count)全部宿主可测;format 模式行序列/副题逐字不变,path 仍是完整路径(进度键不变)。
- 交互:点目录进入;顶栏返回箭头/左缘返回手势回上一级,根层再返回退出 App;顶栏标题=
  当前目录名(按像素宽截断),副题行=面包屑全路径+当前层书数;list/grid 均支持导航。
- 状态:folder↔format、list↔grid 切换保留 cwd;刷新重扫后 cwd 失效则收缩到有书的祖先。
- 边界:空目录不显示(导航只读 g_books,不额外 readdir);深层目录逐级进入;真实名为
  「未分类」的目录按普通目录处理,不与根层书合并。
- 测试:shelf_group_smoke 63→99 断言;M27 的按第一级分组断言改写为「逐级进入后可见该书」
  (同等强度),新增进入/返回/面包屑/计数/空目录/纯内存/rowcap/UTF-8 截断断言。
- 交付 0.1.7/code8(M32 已占用任务书里的 0.1.6/code7);真机 0.1.4 未被覆盖。
2026-11-10 19:26:44 +08:00
AskaEth 190041cd67 Merge branch 'feat/m32-reader-shelf-persistent-index' 2026-11-10 19:03:43 +08:00
AskaEth 58f2d9bff4 perf(reader): M32 r2 书架索引改 level-1 校验 — 真机冷缓存首开 3.47s→0.13–0.16s(r1 全目录校验被真机证伪)
r1 用宿主 1.1µs/stat + M28 的**热** stat 150µs 外推「434 次≈65ms」,真机 drop_caches 后
实测是 3.15s(7.26ms/次),与 readdir 的 3.0s 同量级:全目录校验的索引命中冷启动仍要
3.06s,只比全扫快 0.2s。「3.0s→0.15s」在冷启动不成立。按任务 1 自己定的判据
(stat 接近 1s 就退回 level-1)改为只校验 Books 根 + 每个一级目录。

真机冷缓存实测(exFAT /usr/data/mnt/sd_0,434 目录/646 文件/595 本;sync + drop_caches;
探针走 /proc/uptime,UI 走 meow_ui_time_ms):
- 索引命中首开:**127 / 138 / 157ms**(复测 147ms),热 dentry 107–120ms;
- 全量扫描首开:**3428 / 3477ms**(复测 3711ms),热 dentry 不变(readdir 不吃 page cache);
- 探针冷分项:索引载入 20–30ms;载入+rdprog×595 70–120ms;4 个目录 stat 0–30ms;
- 从阅读页返回书架 29–31ms(M29 语义未变)。
→ 冷启动 22–26 倍提升,方案值得做;索引实测 **63,579B**(v2;r1 全目录 97,490B)。

实现(格式 v2,magic MEOWIDX2 / version 2):
- writer 回调过滤 rel 含 '/' 的目录:只落 root + 一级目录的 mtime;书/大小/格式记录仍全量;
  level-2+ 不落盘、载入不 stat。载入仍然 0 次 opendir/readdir(strace:0 getdents64,
  真实库 4 次 stat;宿主模拟库 11 次)。
- 校验记录里出现 level-2+ rel 视为坏档;sum(books_here) 改为「≤ nbooks」合理性上界。
- fail-open/原子写/CRC/安全加固/ts 不缓存全部保留(r1 已过评审的部分未动)。

失效矩阵(报文逐条写清):
自动(根 mtime):Books 根下直接增/删/改名书;一级目录新增/删除/改名。
自动(一级目录 mtime):一级目录里直接增/删/改名书、新建子目录。
必须刷新:level-2 及更深处的任何变化(只改深层目录 mtime)。刷新=删索引+全扫。
真机 exFAT mtime 实测 **1 秒**(tm_nsec=0;同秒连续增删 mtime 不变)。我复测 10 轮
「目录刚改过就扫描、同秒再改」→ 9/10 仍命中(陈旧),与评审 29/30 一致;sleep 跨秒后
全部正常失效。头注释已把「10ms」改为「1 秒」,并写明「不在同一秒刻度的变更靠索引失效,
同秒二次变更需刷新」。

bench 修复(P3-3):MEOW_BENCH_IDX=only —— **不预先 load/scan**,直接进书架量生产首开;
miss —— 先删索引再进书架。两者配合 drop_caches 即冷启动口径。旧行为(先 bench_index/
bench_scan 焐热)保留为默认模式。新增 test/shelf_index_probe_mipsel(write/scan/load/
loadts/statpaths/mtime/size)用于真机分项与 mtime 粒度验证,不进发行包。

测试:shelf_index_smoke 170 pass(新增:level-3/level-2 内变更「索引仍命中且先看到旧
列表 → 刷新后可见」、新建 level-2 目录自动失效、一级目录直接删书自动失效、v2 ndirs=
根+一级数);ASan+UBSan 干净;build_reader.sh 0 warning,core 6846 / seg 19 / chapter 71 /
shelf_group 63 / index 170 / font 14 / loaders PASS。真机 6 场景逐项验证:一级直接增/
新建子目录/一级改名/根下删书/一级直接删书 = 自动失效;level-3 深处新增 = 仍命中(需刷新)。

交付:app.json 0.1.5/code6 → **0.1.6/code7**;reader md5 5c848c99… 1,711,988B;
mpk md5 10d676f5… 814,574B;未签名不 push。设备验证后已清理 /tmp 与 m32probe,
真实 MeowData 的 shelf.idx 已删(app 自己的章节 .idx 未动),Books 434/646 不变,
已装 0.1.4 md5 4036b889… 未变。
2026-11-10 18:49:48 +08:00
AskaEth e0dfab95c3 perf(reader): M32 书架索引落盘 — 冷启动只 stat 434 个目录 mtime,跳过 readdir(3.0s→~0.15s)
M29 只解决了「回书架不重扫」;首次进书架仍要全量扫 434 目录(真机 3.0–3.4s,
其中 opendir+readdir 占 96%)。本单把扫描结果 + 每个扫描目录的 mtime 存到
MeowData/os.meow.reader/index/shelf.idx,冷启动逐目录 stat 比对 mtime,命中即
完全跳过 readdir;任何异常一律 fail-open 全量重扫。

先量再选(任务 1):宿主 434 目录/595 本/51 rar 虚拟库(贴真机量级)
- 434 次目录 stat = 0.48ms/pass(1.1µs/次,不含 readdir);
- 读 72KB 索引 = 0.009ms;全量扫描 3.54ms;索引命中 1.29ms(ts_fn=NULL)。
真机按 M28 实测 1079 次 stat=160–170ms(≈150µs/次)外推:434 次 ≈ 65ms,
远低于「可能不可行」的 1s 阈值 → 选「校验全部扫描目录 mtime」(非只 level-1)。
strace 断言:indexonly 载入 0 次 getdents64、恰好 434 次 stat(真的没 readdir)。
生产路径 ≈ 65ms(stat) + <20ms(索引读) + 40–50ms(rdprog×595) + <20ms(build_rows)
≈ 0.13–0.15s(对比 3.0s)。

索引格式 v1(手工小端序列化,不依赖结构体布局):
header(magic/version/header_size/root_len) + root + dir 记录×(rel,mtime_s,
mtime_ns,books_here) + book 记录×(rel,size,fmt) + trailer(ndirs,nbooks,flags,
depth_skipped,crc32,magic)。truncated/depth_skipped 存在 flags/字段里,载入时
如实重建提示条;**不存阅读进度 ts**,载入后每本由 rdprog_load 现读再排序。

失效语义(存每个扫描过的目录,不只 level-1):
- level-2 新增 / level-3 深处新增 / 删书 → 该目录 mtime 变 → 重扫;
- 一级目录改名 → 父目录 mtime 变 + 旧目录 stat ENOENT → 重扫;
- 超深(>6 层)未扫描子树不在索引内,其内部变化不自动失效(本来就不显示),
  点「刷新」更新 depth_skipped 计数;mtime 精度受限(exFAT 10ms/FAT32 2s)的
  同刻度变更也可能漏判,点「刷新」必可纠正。
M29 语义不变:首次进书架=索引或全扫;回书架=不扫;刷新=删索引+全扫(之后原子
写回新索引)。

安全/健壮(宁可慢不可错):
- 写:shelf.idx.tmp 全量写完(尽力 fsync)后 rename;中途被杀只剩 tmp,正式索引
  要么旧要么完整新;rename 覆盖失败先删旧再改名;fflush/写错即放弃,绝不影响书架。
- 读:O_NOFOLLOW|O_NONBLOCK + S_ISREG(FIFO/符号链接直接拒绝不阻塞);CRC32 覆盖
  全部记录;rel 做绝对路径/“..”/内嵌 NUL/长度校验;版本/根路径/计数/逐目录 mtime
  任一不符即重扫;载入崩溃路径全部 goto 清理,不改书架可用性。
- writer_open 失败(卡满/写保护/无 MeowData)→ 扫描不采集目录、照常出书;
  commit 失败(含只读目录 rename 失败)不留正式索引。

测试:test/shelf_index_smoke 154 pass(含四种变更场景失效、截断/字节翻转/垃圾/
缺失/FIFO/符号链接/版本不符/根路径不符、ts 现读、truncated/depth_skipped 重建、
只读目录写失败、空 Books、root 缺失、.tmp 残渣),ASan+UBSan 干净;
test/shelf_index_bench 模拟 434 目录/595 本/51 rar:scan/load 均 595 本、
truncated=0/depth_skipped=0、索引 72,156B。build_reader.sh 全绿 0 warning
(core 6846 / seg 19 / chapter 71 / shelf_group 63 / index 154 / font 14 /
loaders PASS)。shelf_bench_ondev 增加 index_load/scan_save 计时与
MEOW_BENCH_IDX=miss(真机首开命中 vs 未命中对比,待有卡复核)。

交付:app.json 0.1.4/code5 → 0.1.5/code6;reader md5 df4cadcf
1,711,924B;mpk md5 fec7b1bb 814,535B;未签名不推卡。
2026-11-10 18:14:52 +08:00
AskaEth 3f09f7c137 Merge branch 'feat/m31-reader-font-memo-and-read-badge' 2026-11-10 13:48:49 +08:00
AskaEth dc9bfdbdaf perf(reader): M31 字体路径记忆化(开书字体查找 35 目录 walk → 1 次 stat)+ 「· 读」徽标即时刷新
字体:find_font_rec/resolve_font 从 reader.c 抽到 app/font_resolve.c 并加进程内记忆化。
只缓存成功路径;命中后仍做一次 access() 校验(字库被删即重扫);没找到不缓存,
用户之后拷入的字库下次开书即可找到。顶栏「刷新」额外调 font_cache_invalidate(),
给「新拷入字库、旧字库仍在」一条显式失效路径。宿主实测:35 目录树 walk 0.026~0.54ms
(=300+ 次系统调用)vs 命中 0.001ms(1 次 access);开书路径每次省一次完整 walk。

徽标:副题格式化抽成 shelf_fmt_sub()(扫描与「刚读完置顶」共用一份实现),
promote_just_read() 改 ts 后立即重算这本的 sub/sub_folder,「· 读」不再等下次全量刷新。

回归:test_reader_core 6846、shelf_group_smoke 63(+8)、font_resolve_smoke 14 全绿;
build_reader.sh 0 warning;扫描缓存/进度语义未改。app.json 0.1.3/code4 → 0.1.4/code5。
2026-11-10 13:39:32 +08:00
AskaEth 0b30cdbafb Merge branch 'feat/m30-reader-drop-unused-next-page-slot' 2026-11-10 13:30:44 +08:00
AskaEth a9638fa59c perf(reader): M30 删掉无人消费的下一页预取槽(refill 渲染减半、常驻 −0.69MB)+ 回书架置顶刚读完的书 2026-11-10 13:22:35 +08:00
AskaEth dc559aa1ac Merge branch 'feat/m29-reader-shelf-scan-cache-and-row-draw' 2026-11-10 13:11:58 +08:00
AskaEth bece9848e7 perf(reader): M29 书架扫描缓存 + 行数多时关圆角容器(回书架 3.4s→0.03s,滚动 52ms→19ms)
真机(0.8.48 / reader 0.1.1 基线,同机同字库 ztest 实测):
- 从阅读页返回书架:3435ms(3259–3531)→ 33ms(24–54)。根因是 shelf_on_enter 无条件
  scan_books():exFAT 全量扫描 434 目录 = 3.1s。改为进程内缓存(首次进入才扫,顶栏
  「刷新」强制重扫);truncated/depth_skipped 提示语义不变(首次扫描时算好并保留)。
  行为变化:新进 Books/ 的书、刚读完那本书的「最近阅读置顶」需点一次「刷新」才更新。
- 书架滑动每步(62px,含 widget_draw + flush):51.9–56.2ms → 19.3–19.9ms。
  行数 > 200 时 meow_listview_set_container(0)(扁平行),≤200 行仍用 MD3 圆角卡;
  圆角卡走 meow_gfx_fill_round 逐像素 rr_inside(464×54 一张 3.8–6.4ms ×11 行)。
- 首次进书架仍需扫一次(3497ms,含 3157ms 扫描);595 本全可见、format/folder 分组不变。

另加 test/shelf_bench_ondev.c(真机耗时基准,不进 mpk)与 build_reader.sh 的编译步骤;
app.json 0.1.1/2 → 0.1.2/3。未做(按塔砍范围):字体路径记忆化、首屏只渲染当前页、
自绘行、mtime 失效检测。
2026-11-10 12:54:17 +08:00
AskaEth 288c133efc Merge branch 'feat/m27-reader-shelf-folder-grouping' 2026-11-09 22:52:43 +08:00
AskaEth e0a82a3d47 fix(reader): M27 r3 评审整改 — cap 边界截断/默认副题不变/提示两行且 6s 消失/版本号
对应评审 P2-1、P3-7、P3-1、P3-2、P3-10、P3-9:
- P2-1:scan_dir 在目录边界遇 cap 时探测子树(count_books_under)再置 truncated;
  同目录内超限逻辑不变。新增两条断言:cap 卡边界 → truncated=1;恰好装满且后续为空目录 → 不误报
- P3-7:shelf_book 拆 sub(默认副题,与 base 逐字一致)/sub_folder(folder 模式前置子路径);
  list 按模式选,宫格始终用 base;断言 format 副题逐字等于 base
- P3-1:超量+超深合并提示拆两行绘制(610 起,不再被 480px 右缘切掉)
- P3-2:提示 6s 到期自动消失(app.c 书架前台时调 shelf_on_tick)
- P3-10:app.json version 0.1.1 / version_code 2
- P3-9:shelf_scan_bench.c 头部注明 /proc/uptime 10ms 分辨率、只适合真机
2026-11-09 22:45:01 +08:00
AskaEth 63ead3ec1e fix(reader): M27 r2 — 扫描深度提到 6 + 按 Books 第一级目录分组
真机真实布局暴露两处问题(评审 P1):
- Books/400+本高质量完本合集/<分类>/<作品>/<书名>.txt 在 depth 3-4,旧 depth≤2 让 413 本 3.8GB 不可见
  → SHELF_SCAN_MAX_DEPTH=2→6;report 新增 depth_skipped,超深书籍文件计数并在 g_notice 可见提示
  (有 N 个文件因目录层数超过 6 层未显示),不再静默
- 按完整相对路径分组会把 413 本书炸成 410 个每条 1 书的段头
  → 分组键改为 Books/ 第一级目录(shelf_book.top);完整相对路径仍在 group[],
    第一级之后的子路径(分类/作品)放进副题 sub(如 01、玄幻/01、斗破 · TXT · 2.1MB)
  → 真机书库只剩 3 组:400+本高质量完本合集 / kh / 轻小说
- 单测 50 断言:depth3/4 收录、depth7 计入 depth_skipped、四种真实路径的组名、
  a/b 与 a/c 同组 a、副题含子路径、format 回归、根组未分类、path/进度不变
2026-11-09 22:29:46 +08:00
AskaEth 409fd1edf7 feat(reader): M27 书架按目录分组(ui.shelf_group=folder|format,默认 format 不变)
- 新增 shelf_model.c/.h:扫描/行序列从 shelf.c 拆出(纯 C,宿主可测);shelf_book 加 group[96](相对 Books/ 的目录路径,根下空串),path 语义/内容完全不变(进度键仍为 rdprog_hash(path))
- folder 模式:目录段头(根下=未分类),未分类置顶、其余按目录名排序;组内沿用最近阅读优先/其次书名;空目录不产生段头
- format 模式逐行保持旧行为;新增 reader.conf ui.shelf_group,缺键默认 format
- 上限 256→2048(SHELF_MAX_ROWS=2N=4096,段头≤书数),超限置可读提示,不再静默截断
- 顶栏加「目录/格式」切换,设置页加「书架分组」;UTF-8 安全截断长中文目录名
- 单测 test/shelf_group_smoke.c(38 断言)+ 计时 test/shelf_scan_bench.c
- build_reader.sh:链 meow_ui.a 解析冒烟 stbi_*;无 qemu 时跳过执行而非失败
2026-11-09 21:55:33 +08:00
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
AskaEth 626a87a30d fix(fw): M6.40 收编系统级音量翻页映射(MSG_KEY_PAGEUP/PAGEDOWN)+P1 MD3(select面板28/对话框按钮胶囊)+阅读器UI改版 — 推卡(固件已上卡,设备待回网验收)
- meow_ui.a 重编 207630cb(0warning): select面板28/对话框按钮胶囊h/2
- launcher f1980f4f(音量翻页系统映射) / hello 92c7ba17 / files 66bdb60d / reader 9c2a25e0(阅读器UI改版) / ncm e796282e
- 固件 r3proii_m6_40.upt 28191ae7(0.8.40, rootfs 38719488<=45088768, unpack 0 diff); 已上卡+备份r3proii_prev_m6_40=a671b8ba+rm recovery_all+触发升级重启
- netmgrd 9c345b2e/audiod 9f47c8fd/meow_audio 6864ec38 未变; amsd 57e19cde 保持
- 注: 设备升级后未回网(超8分钟),四App+面板热更与升级后验收待设备回网后补
2026-09-04 18:24:16 +08:00
AskaEth 2826c1e905 feat: M6.40批 — 阅读器UI改版(书架list·grid+空态骨架+界面设置+App设置页+header·footer+背景黑默认) + 系统级音量翻页映射 + P1 MD3(select面板28/对话框按钮胶囊) + MeowPanel写端点护栏 + SDK公开仓·文档同步
- reader: app.c/shelf.c/reader.c 全套UI; 新增 reader_conf(reader.conf)/settings(App设置页)/reader_ipc(音量翻页IPC响应); app.json加volume_page
- launcher: keys.c meow_volpage_*门 + util.c app_id→fd映射(HELLO记fd/断开清) + app_launcher.c 前台三处接线(进入sink/按键路径volpage_key/离开clear) → 前台声明volume_page的App音量键→MSG_KEY_PAGEUP/PAGEDOWN翻页(非阅读器前台音量照旧)
- sdk: meow_select.c 底部面板28 + meow_ui_dialog.c 对话框按钮胶囊(h/2) + test_meow_ui_select.c修采样
- meowpanel: /api/net/policy·/api/power/policy 加防自锁+policy默认关两条护栏(panel.policy_enabled默认0)
- 文档: MEOW_APP_SDK.md §12.6 + meowpanel README端点表

0warning;宿主测试全过;二进制留批量批次未入库
2026-09-04 18:13:18 +08:00
298 changed files with 35274 additions and 2589 deletions

No files matched your search

+3 -2
View File
@@ -302,12 +302,13 @@ meow_launcherd --sim-exit N # 菜单打开 N 毫秒后模拟退出
**修复(`run_menu` + `menu_after_app_exit()`)**: **修复(`run_menu` + `menu_after_app_exit()`)**:
1. **drain**:应用退出后立即读完并丢弃 `touch_fd` 的全部残留事件。 1. **drain**:应用退出后立即读完并丢弃 `touch_fd` 的全部残留事件。
2. **冷却**:应用**真正跑过**(`launch_app` 返回 0)时置 `menu_touch_cooldown_until = now + 2500ms`,冷却窗内忽略一切触摸按下边沿(与 watchdog 的同值 COOLDOWN_MS 对齐,覆盖实测幽灵窗 1.2-2.25s);启动失败(返回 -1)不置冷却,避免无谓锁菜单。 2. **短守卫**:应用**真正跑过**(`launch_app` 返回 0)时置 `menu_touch_cooldown_until = now + MENU_TOUCH_GUARD_MS`(**150ms**,M39 定值;M2.6 曾 2500ms、M2.22 缩到 1200ms),守卫窗内忽略触摸按下边沿;启动失败(返回 -1)不置守卫,避免无谓锁菜单。
**真正的防重放机制是第 1 条的 drain**:evdev 每个 fd 各收一份事件副本,App 运行期(launcher 不轮询 `touch_fd`)的全部事件都堆在本进程的 fd 里,drain 一次读空后已无历史事件可重放;**150ms 只兜面板重配后驱动补发的幽灵尾巴**。M39 起 drain 同时复位 `t_down/原点/坐标`——只清队列不清 pressed 边沿,会把「按下已被 drain、抬手稍后到」误判成一次 tap。旧注释写的「覆盖实测幽灵窗 1.2-2.25s」与实现自相矛盾(1200/2500ms 与 1.2–2.25s 的关系在 M2.22 注释里自己都写「实测略短」),故 M39 按「drain 是机制、时长只是尾巴守卫」定稿。
3. **菜单重绘**:v6.8 曾把 `last_redraw` 从绝对时间戳 `t` 改为相对量 `el`(使 500ms 安全重绘生效)以覆盖应用退出后的残帧(如 BYE)。**v6.9 起摒弃周期整屏重绘**(它在 500ms 定时把整个菜单重写进正在显示的 buffer0,造成列表 ~3fps 频闪):改为**仅 dirty 时整屏重绘**,应用退出/进入路径显式置 `dirty=1` 主动覆盖应用帧;时钟每秒用轻量 `draw_clock()` 只刷数字。 3. **菜单重绘**:v6.8 曾把 `last_redraw` 从绝对时间戳 `t` 改为相对量 `el`(使 500ms 安全重绘生效)以覆盖应用退出后的残帧(如 BYE)。**v6.9 起摒弃周期整屏重绘**(它在 500ms 定时把整个菜单重写进正在显示的 buffer0,造成列表 ~3fps 频闪):改为**仅 dirty 时整屏重绘**,应用退出/进入路径显式置 `dirty=1` 主动覆盖应用帧;时钟每秒用轻量 `draw_clock()` 只刷数字。
**Bug 3 根因与修复**:旧代码在所有 `launch_app` 调用点**无条件**置「CANNOT START: <name>」toast,即使应用**正常跑完**。修复:仅当 `launch_app` 返回 **-1(真启动失败)** 才置 toast;应用干净退出(返回 0)不显示。`launch_app` 用 **CLOEXEC 管道**检测 execve 失败(子进程写 errno → 父进程读 >0 判失败并返回 -1),不再把子进程 `_exit(127)` 误当「应用跑完」。 **Bug 3 根因与修复**:旧代码在所有 `launch_app` 调用点**无条件**置「CANNOT START: <name>」toast,即使应用**正常跑完**。修复:仅当 `launch_app` 返回 **-1(真启动失败)** 才置 toast;应用干净退出(返回 0)不显示。`launch_app` 用 **CLOEXEC 管道**检测 execve 失败(子进程写 errno → 父进程读 >0 判失败并返回 -1),不再把子进程 `_exit(127)` 误当「应用跑完」。
**副作用(已知取舍)**:应用退出后 2.5s 内菜单触摸不响应(返回键误触/退出后紧急再操作需等待;与 watchdog 相同值,实测幽灵窗 1.2-2.25s)。物理触摸的幽灵无法注入复现(§18.2 注入不投递),以上防抖逻辑在注入/sim 链路已验证,物理最终确认需用户。 **副作用(已知取舍)**:应用退出后 **150ms** 内菜单触摸不响应(M39 定值;M2.6 时是 2.5s,用户可见症状是「点一下没反应、要点两次」,M2.22 缩到 1.2s 仍会吞)。真正的不可控敞口是「面板在 drain 之后、队列之外新产生的硬件幽灵触摸」——这类幽灵 1.2s 与 150ms 都盖不住,只能靠真机日志收口:App 退出后观察 3–5s 内是否**自己重进 App / 闪回播放器**(Bug 2/4 原症状),并 `grep -c "menu touch dropped by cooldown"` 应为 0。物理触摸的幽灵无法注入复现(§18.2 注入不投递),宿主侧已由 `test_meow_ui_home_gesture` T4 覆盖「启动后立即点击生效 / 系统手势守卫内不触发、守卫后正好一次」。
--- ---
+33
View File
@@ -1632,6 +1632,39 @@ void meow_lazygrid_draw(meow_lazygrid *g, const meow_theme *th);
- **机制层新增**:`meow_ui_input.c` 主循环新增 `meow_anim_tick()` 调用(无动画零开销);`meow_ui_view.c` 新增布局登记表(未设布局的 view 行为不变);`meow_ui_widget.c` 新增 `meow_widget_set_rect`(只设字段不标脏,既有 `meow_layout_arrange` 不受影响)。 - **机制层新增**:`meow_ui_input.c` 主循环新增 `meow_anim_tick()` 调用(无动画零开销);`meow_ui_view.c` 新增布局登记表(未设布局的 view 行为不变);`meow_ui_widget.c` 新增 `meow_widget_set_rect`(只设字段不标脏,既有 `meow_layout_arrange` 不受影响)。
- **同步重编注意**:`meow_ui.a` 内容变化(新增 box/anim/scroll 三个 `.o`,`meow_ui_widget.o`/`meow_ui_view.o`/`meow_ui_input.o` 重编)→ 链 `meow_ui.a` 的 App 需**重链**(`build_sdk.sh` 一键重编 + 各 App `build_*.sh` 重链)。既有 App **不改源码**即可零回归。 - **同步重编注意**:`meow_ui.a` 内容变化(新增 box/anim/scroll 三个 `.o`,`meow_ui_widget.o`/`meow_ui_view.o`/`meow_ui_input.o` 重编)→ 链 `meow_ui.a` 的 App 需**重链**(`build_sdk.sh` 一键重编 + 各 App `build_*.sh` 重链)。既有 App **不改源码**即可零回归。
### 12.6 SDK 默认列表/容器控件默认 MD3(`meow_list_set_container`)+ 全局 App 内底部上滑回桌面(M6.37–M6.39 批)
> **性质**:M6.37–M6.39 批,两条默认行为定案。
> ① **SDK 默认列表/容器控件默认 MD3**——`meow_list_create` / `meow_lazycolumn_create` / `meow_lazyrow_create`(皆 `W_LIST`)与
> `meow_listview` 容器控件**默认即容器模式**:每项套 `surfaceContainer` 圆角卡 + 项间空隙 + 去分隔线 + 卡内 tonal 选中/按压,
> 用默认 renderer 的 App **开箱即 MD3**(此前默认扁平)。需扁平/满幅分隔线的宿主可显式 `meow_list_set_container(w,0)` **回退**。
> ② **全局 App 内「底部上滑回桌面」为系统保留手势**——任意 App 内屏底边缘上滑即回桌面(后台保活、进程保留转后台),
> App **勿在 `on_swipe` 拦截上滑**,否则该手势不生效。
```c
/* MD3 容器模式(W_LIST 共用,§5.5/§5.8.6):0=扁平满宽项+分隔线(回退);1=每项外套 surfaceContainer
* 圆角卡+项间空隙(去分隔线),选中=primaryContainer/按压=primary α26(卡内 tonal)。
* 覆盖 meow_list_create / meow_lazycolumn_create / meow_lazyrow_create。返回 0=成功;无效=负值。 */
int meow_list_set_container(meow_widget *w, int on);
/* 底部热区上滑 → 回桌面(home)判定(镜像 launcher np_gesture.h 同口径):屏底热区(HOME_EDGE_H)起滑 +
* 上移 ≥HOME_SWIPE + 竖向主导(|dx|<|dy|)+ 避开左缘返回列(down_x≥EDGE_W)。1=命中(系统保留手势);0=否。
* 供宿主单测直调(判据与触摸状态机 touch_release 共用)。 */
int meow_ui_input_is_home_swipe(int down_x, int down_y, int dx, int dy, int fb_h);
```
- **MD3 默认**:两者 `container` 字段默认 1;`meow_list_set_container(w,0)` 回退至「满幅扁平行 + 分隔线」旧观感(宿主测试
`test_meow_ui_widgets`/`test_meow_ui_listvirt` 用 set_container(0) 锁扁平基线)。卡内未选中行以 `surfaceContainer` 呈现、
选中 `primaryContainer`、按压 `primary α26`;容器模式下分隔线去除,回退时以 `onSurfaceVariant` 分隔线呈现。
- **业务上滑不冲突**:`meow_ui_input_is_home_swipe` 只认「**屏底热区 + 竖向主导上滑**」;**中部上滑/滚动
(LazyColumn/LazyGrid/页面滚动)不受影响**,仍交 App 处理;底部热区的横向滑仍走左缘返回/横向逻辑。
- **App 勿拦截**:命中后 SDK 作为**系统保留手势**优先于 App `on_swipe`/视图树(否则已注册 `on_swipe` 的 App 会接管,
导致 App 内上滑不回桌面),路由 `meow_ui_nav_request_exit()`(MSG_NAV EXIT → launcher 批①后台保活,进程保留转后台、
present 自抑);**不**调 `meow_ui_request_exit()`(那会 `exit(0)` 断送保活)。IME 软键盘覆盖底部热区时手势让位 IME
(防输入时误退)。故 App 无需也不应在 `on_swipe` 处理「上滑回桌面」——交给 SDK 兜底。
- **系统边界**:与「顶部下拉通知中心」(§6)同理为系统保留手势;流媒体客户端/媒体 App 的列表、播放页照常用**中部**
手势(滚动/翻页),不外溢到屏底热区拦截回桌面。
--- ---
## 13. 字符串资源 / i18n(`meow_str_*`,2026-09-02 追加) ## 13. 字符串资源 / i18n(`meow_str_*`,2026-09-02 追加)
+4
View File
@@ -135,6 +135,10 @@ def build_charset(tier="common", extra=""):
add(ord(bytes([hi, lo]).decode("gb2312"))) add(ord(bytes([hi, lo]).decode("gb2312")))
except UnicodeDecodeError: except UnicodeDecodeError:
pass pass
# 显式补 U+00B7(MIDDLE DOT):Python 的 gb2312 codec 把 GB2312 A1A4 解成
# U+30FB(片假名中点),而 MiSans 无 U+30FB 字形 → 该槽位是空条目,U+00B7 从未进库,
# 真机渲染成 tofu。format 副题「Books/ · 595 本」与「· 读」徽标依赖此码位。
add(0x00B7)
if tier in ("gbk", "full"): if tier in ("gbk", "full"):
# CJK 统一表意(含常用简繁/生僻):U+4E00-9FFF ≈ 2万+ # CJK 统一表意(含常用简繁/生僻):U+4E00-9FFF ≈ 2万+
for cp in range(0x4E00, 0xA000): for cp in range(0x4E00, 0xA000):
+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
+5 -1
View File
@@ -6,6 +6,10 @@ set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
CC="$HOME/Project/R3ProII/.tools/zig-mipsel-cc" CC="$HOME/Project/R3ProII/.tools/zig-mipsel-cc"
MEOW_TOUCH_DIR="../meow_launcherd" MEOW_TOUCH_DIR="../meow_launcherd"
"$CC" -Os -static -I "$MEOW_TOUCH_DIR" meow_bootmenu.c "$MEOW_TOUCH_DIR/meow_touch.c" -o meow_bootmenu # M43: bootmenu 也不链 meow_ui.a,单独链 time32 修取时/睡眠 ABI。只改语义正确的取时来源,
# boot_timeout/按键长按/守候计时行为不变(修复后才是真实毫秒;squashfs /usr/bin,改坏影响
# 开机选单/自动启动,但不是 initramfs 级不可恢复)。
"$CC" -Os -static -I "$MEOW_TOUCH_DIR" -DMEOW_TIME32_LIBC_OVERRIDES \
meow_bootmenu.c "$MEOW_TOUCH_DIR/meow_touch.c" ../sdk/meow_time32.c -o meow_bootmenu
ls -la meow_bootmenu ls -la meow_bootmenu
file meow_bootmenu file meow_bootmenu
Binary file not shown.
+259 -46
View File
@@ -32,6 +32,26 @@
* 说明:/proc/mtd 只反映分区表不变读,无法观察内容变化,故用轻量读 /dev/mtd5 轮询。 * 说明:/proc/mtd 只反映分区表不变读,无法观察内容变化,故用轻量读 /dev/mtd5 轮询。
* 两根都写 bm_log(supervisor 接管 shutdown/upgrade)。 * 两根都写 bm_log(supervisor 接管 shutdown/upgrade)。
* *
* M23 亮度单一权威源(2026-11-11,MEOW_BOOTMENU_VER 0.1.0-m2.30-bri-src):
* 真机症状(0.8.46)「开机菜单亮度=第一屏的 60」「HibyOS 亮/灭屏瞬间爆亮」。根因两处:
* - 开机菜单:M6.25 的「显示前按 settings.conf 写用户亮度」(本文件 main 末尾)只改了源码,
* **二进制没重编**(fw_content.conf 钉的是 8/31 的旧二进制,strings 无 display.brightness_min)
* → 0.8.46/0.8.49 上选单仍继承面板值(0.8.46 = S11 写的 60)。本版重编即带上。
* - HibyOS 亮/灭屏:supervisor 起始把「启动瞬间面板读数」当保底亮度,且 >60 一律钉 60;
* 扫描窗保底只要读到亮度<=0 就 400ms 内写回该值 —— 用户主动熄屏(播放器写 0)会被
* 强行点亮成 60。现改为:settings.conf 的 display.brightness 为唯一权威(与 watchdog
* wd_bri_decide / launcher 同源同 clamp);旧的 `>60 → 60` 改为 clamp 进
* [display.brightness_min, max_brightness](为什么不会重新引入 M2.9 的开机闪屏见
* bm_bri_pick_saved 注释)。settings 不可读的降级路径**不采信面板瞬时值**,取
* min(面板, BM_BRI_SAFE_LOW=10)(与 watchdog 的 WD_BRI_SAFE_LOW 对齐)。
* 扫描窗保底恢复 pre-M23 的「面板暗」触发(不依赖 /proc 的 D 态,避免媒体扫描在子线程时
* Bug 4 防护失效);不抢用户熄屏靠**电源键锁存**(窗内按过电源键则本次窗口内不再补),
* bl_power>0 仅作纯保险(实测本机播放器熄屏后 bl_power 仍为 0);窗口结束彻底停手;
* 日志同原因 ≥2s 一条。
* 决策全部抽成纯函数(bm_bri_pick_saved / bm_bri_floor_decide / bm_bri_hotkey_saved),
* 宿主单测见 test_bm_fw.c。亮度判定改为 bm_log 落卡(stderr 原行保留)。
* 本单一律**只交源码**:二进制由 M43/M51 统一重编并回写 fw_content.conf 钉值。
*
* ============================================================================ * ============================================================================
* WHY (设计见 BOOT_SELECTOR_DESIGN.md §3) * WHY (设计见 BOOT_SELECTOR_DESIGN.md §3)
* ============================================================================ * ============================================================================
@@ -81,7 +101,7 @@
#include <time.h> #include <time.h>
#include <sys/ioctl.h> #include <sys/ioctl.h>
#include <sys/mman.h> #include <sys/mman.h>
#include <sys/poll.h> #include <poll.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/syscall.h> /* M2.29:supervisor 接管关机/升级,直连 reboot(2) syscall */ #include <sys/syscall.h> /* M2.29:supervisor 接管关机/升级,直连 reboot(2) syscall */
@@ -111,6 +131,7 @@
#define FBIOBLANK 0x4611 #define FBIOBLANK 0x4611
#endif #endif
#define BACKLIGHT "/sys/class/backlight/backlight_pwm0/brightness" #define BACKLIGHT "/sys/class/backlight/backlight_pwm0/brightness"
#define BACKLIGHT_MAX "/sys/class/backlight/backlight_pwm0/max_brightness"
#define BACKLIGHT_POWER "/sys/class/backlight/backlight_pwm0/bl_power" #define BACKLIGHT_POWER "/sys/class/backlight/backlight_pwm0/bl_power"
/* ---- 热键切换(HibyOS→MeowOS):按住电源 + 窗口内单击音量减 ---- */ /* ---- 热键切换(HibyOS→MeowOS):按住电源 + 窗口内单击音量减 ---- */
@@ -161,7 +182,7 @@
#define POLL_MS 10 #define POLL_MS 10
#define SIM_SELECT_DELAY_MS 2000 /* --sim-select 延迟后模拟选 */ #define SIM_SELECT_DELAY_MS 2000 /* --sim-select 延迟后模拟选 */
#define GHOST_COOLDOWN_MS 800 /* 入口 drain + 短暂冷却防幽灵触摸(M2.1:缩短,不吞首次有效输入) */ #define GHOST_COOLDOWN_MS 800 /* 入口 drain + 短暂冷却防幽灵触摸(M2.1:缩短,不吞首次有效输入) */
#define MEOW_BOOTMENU_VER "0.1.0-m2.29-takeover" /* M2.29:supervisor 接管关机(poweroff)/升级(reboot) */ #define MEOW_BOOTMENU_VER "0.1.0-m2.30-bri-src" /* M23:亮度单一权威源(settings.conf),二进制随源码重编 */
/* ---- 版式 ---- */ /* ---- 版式 ---- */
#define STATUS_H 40 #define STATUS_H 40
@@ -329,7 +350,7 @@ static int fb_open(void)
fb_bpp = *(uint32_t *)(vinfo + 24); fb_bpp = *(uint32_t *)(vinfo + 24);
fb_line = *(uint32_t *)(finfo + 44); fb_line = *(uint32_t *)(finfo + 44);
if (!fb_line) fb_line = fb_xres * (fb_bpp / 8); if (!fb_line) fb_line = fb_xres * (fb_bpp / 8);
if (fb_yres_virtual < fb_yres) fb_yres_virtual = fb_yres; /* 单缓冲兜底 */ if (fb_yres_virtual < (unsigned)fb_yres) fb_yres_virtual = fb_yres; /* 单缓冲兜底 */
fb = mmap(NULL, (size_t)fb_line * fb_yres_virtual, fb = mmap(NULL, (size_t)fb_line * fb_yres_virtual,
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (fb == MAP_FAILED) { if (fb == MAP_FAILED) {
@@ -345,7 +366,7 @@ static int fb_open(void)
/* 选单进入前快照「当前显示缓冲」一帧(播放器事件驱动不主动重绘,须恢复) */ /* 选单进入前快照「当前显示缓冲」一帧(播放器事件驱动不主动重绘,须恢复) */
static void fb_snapshot_player(void) static void fb_snapshot_player(void)
{ {
if (fb_yres_virtual <= fb_yres) { fb_orig_yoffset = -1; return; } if (fb_yres_virtual <= (unsigned)fb_yres) { fb_orig_yoffset = -1; return; }
if (fb_active_yoffset >= fb_yres_virtual) fb_active_yoffset = 0; if (fb_active_yoffset >= fb_yres_virtual) fb_active_yoffset = 0;
fb_orig_yoffset = (int)fb_active_yoffset; fb_orig_yoffset = (int)fb_active_yoffset;
fb_snapshot_len = (size_t)fb_line * fb_yres; fb_snapshot_len = (size_t)fb_line * fb_yres;
@@ -357,7 +378,7 @@ static void fb_snapshot_player(void)
/* 画前 FBIOPAN_DISPLAY pan 到 yoffset=0(buffer0,选单/app 共同绘制目标) */ /* 画前 FBIOPAN_DISPLAY pan 到 yoffset=0(buffer0,选单/app 共同绘制目标) */
static void fb_pan_buf0(void) static void fb_pan_buf0(void)
{ {
if (fb_fd < 0 || fb_yres_virtual <= fb_yres) { fb_active_yoffset = 0; return; } if (fb_fd < 0 || fb_yres_virtual <= (unsigned)fb_yres) { fb_active_yoffset = 0; return; }
if (fb_active_yoffset == 0) return; /* 已显示 buffer0 */ if (fb_active_yoffset == 0) return; /* 已显示 buffer0 */
unsigned char v[160]; unsigned char v[160];
memset(v, 0, sizeof v); memset(v, 0, sizeof v);
@@ -436,6 +457,7 @@ static int amixer_vol(int numid)
/* 28px 状态栏:行1=时钟(左) 电量% WIFI BT(右),行2=VOL nn%(左),y=28 分隔线(40→28,用户定) */ /* 28px 状态栏:行1=时钟(左) 电量% WIFI BT(右),行2=VOL nn%(左),y=28 分隔线(40→28,用户定) */
static void draw_statusbar(uint32_t bg) static void draw_statusbar(uint32_t bg)
{ {
(void)bg; /* 传参保留(调用点已传 cfg_bg),本版状态栏用固定配色 */
char timebuf[16], right[48]; char timebuf[16], right[48];
time_t now = time(NULL); time_t now = time(NULL);
struct tm tm; struct tm tm;
@@ -814,9 +836,23 @@ static void draw_menu(int cursor, long remaining_ms)
/* ================================================================== */ /* ================================================================== */
/* 面板/背光 helper(复用 meow_launcherd 语义,§18.21.5 / §红线) */ /* 面板/背光 helper(复用 meow_launcherd 语义,§18.21.5 / §红线) */
/* ================================================================== */ /* ================================================================== */
/* 路径可被 env 覆盖(宿主测试用,与 meow_watchdog 的 MEOW_BACKLIGHT /
* MEOW_SETTINGS_FILE 同款钩子);真机 init 环境无这些变量 → 走宏默认路径。 */
static const char *bm_backlight_path(void)
{
const char *e = getenv("MEOW_BACKLIGHT");
return (e && e[0]) ? e : BACKLIGHT;
}
static const char *bm_settings_path(void)
{
const char *e = getenv("MEOW_SETTINGS_FILE");
return (e && e[0]) ? e : MEOW_SETTINGS_FILE;
}
static int read_brightness(void) static int read_brightness(void)
{ {
int fd = open(BACKLIGHT, O_RDONLY); int fd = open(bm_backlight_path(), O_RDONLY);
if (fd < 0) return -1; if (fd < 0) return -1;
char buf[16]; char buf[16];
int n = read(fd, buf, sizeof buf - 1); int n = read(fd, buf, sizeof buf - 1);
@@ -839,24 +875,46 @@ static void write_brightness(int val)
{ {
char buf[8]; char buf[8];
int l = snprintf(buf, sizeof buf, "%d\n", val); int l = snprintf(buf, sizeof buf, "%d\n", val);
int fd = open(BACKLIGHT, O_WRONLY); int fd = open(bm_backlight_path(), O_WRONLY);
if (fd < 0) { fprintf(stderr, "bootmenu: backlight open fail: %s\n", strerror(errno)); return; } if (fd < 0) { fprintf(stderr, "bootmenu: backlight open fail: %s\n", strerror(errno)); return; }
if (write(fd, buf, l) < 0) fprintf(stderr, "bootmenu: backlight write %d: %s\n", val, strerror(errno)); if (write(fd, buf, l) < 0) fprintf(stderr, "bootmenu: backlight write %d: %s\n", val, strerror(errno));
close(fd); close(fd);
} }
/* 面板背光上限(本机 101)。读不到返回 0 = 未知,调用方按「无上限信息」降级处理。 */
static int read_max_brightness(void)
{
int v = read_file_int(BACKLIGHT_MAX);
return v > 0 ? v : 0;
}
/* M6.25:开机选单亮度以**用户设置**为准(/usr/data/meow/settings.conf 的 display.brightness)。 /* M6.25:开机选单亮度以**用户设置**为准(/usr/data/meow/settings.conf 的 display.brightness)。
* 根因:选单显示路径从不写背光,开机菜单/第二屏亮度纯继承引导早期面板高值;M2.83 起 launcher * 根因:选单显示路径从不写背光,开机菜单/第二屏亮度纯继承引导早期面板高值;M2.83 起 launcher
* 「开机选单期不抢屏」怠速,移除了 launcher 提前写用户亮度那一步 → 菜单亮在引导高值上。 * 「开机选单期不抢屏」怠速,移除了 launcher 提前写用户亮度那一步 → 菜单亮在引导高值上。
* 与 meow_watchdog wd_settings_brightness() 同源解析(同文件、同 clamp:下限 display.brightness_min * 与 meow_watchdog wd_settings_brightness() 同源解析(同文件、同 clamp:下限 display.brightness_min
* 默认 1=极暗但可见、上限 100=防闪屏),自包含零依赖,不链 launcher meow_settings。返回 clamped * 默认 1=极暗但可见、上限 100=防闪屏),自包含零依赖,不链 launcher meow_settings。返回 clamped
* 值;settings 文件不可读 → 返回 -1,调用方**不动**(退化为现状,保安全——选单阶段只写 brightness、 * 值;settings 文件不可读 → 返回 -1,调用方**不动**(退化为现状,保安全——选单阶段只写 brightness、
* 绝不碰 FBIOBLANK/blank,§18.24 纪律)。 */ * 绝不碰 FBIOBLANK/blank,§18.24 纪律)。
static int bootmenu_settings_brightness(void) * M23:fopen 失败做 BM_SETTINGS_TRIES 次短重试,覆盖 /usr/data 刚挂载 / 原子替换的瞬时窗口
* (与 M20 的 wd_settings_brightness 同策略);只重试 open(rename 原子,已打开的 fd 内容完整)。
* M23 评审 r2:文件存在但**缺 display.brightness 键**同样返回 -1(不可信),不再按 launcher
* 设置表的默认 101→clamp 100。理由:bootmenu 的用途是「永不写高于用户值」,把「文件里没有
* 这个键」当成 100 会在扫描窗待机时补到 100(比老 bug 的 60 更亮)。launcher/watchdog 的 100
* 默认是「设置表缺省值」语义,与本进程的「授权写亮度」语义不同,这里有意更严格。
* (注:键存在但值非数字 → atoi=0 → 夹到 brightness_min,与旧行为一致。) */
#define BM_SETTINGS_TRIES 3
#define BM_SETTINGS_GAP_MS 10
static int bootmenu_settings_brightness(int *min_out)
{ {
FILE *f = fopen(MEOW_SETTINGS_FILE, "r"); FILE *f = NULL;
for (int i = 0; i < BM_SETTINGS_TRIES; i++) {
f = fopen(bm_settings_path(), "r");
if (f) break;
if (i + 1 < BM_SETTINGS_TRIES) usleep(BM_SETTINGS_GAP_MS * 1000);
}
if (!f) return -1; if (!f) return -1;
int b = 101; /* display.brightness 默认(与 launcher 设置表同值,键缺失时 clamp→100) */ int b = -1; /* -1 = 文件里没看到 display.brightness(视为不可信 → 返回 -1) */
int mn = 1; /* display.brightness_min 默认(极暗但可见;键缺失时保底) */ int mn = 1; /* display.brightness_min 默认(极暗但可见;键缺失时保底) */
char buf[128]; char buf[128];
while (fgets(buf, sizeof buf, f)) { while (fgets(buf, sizeof buf, f)) {
@@ -871,11 +929,99 @@ static int bootmenu_settings_brightness(void)
else if (!strcmp(k, "display.brightness")) { b = iv; } else if (!strcmp(k, "display.brightness")) { b = iv; }
} }
fclose(f); fclose(f);
if (min_out) *min_out = mn;
if (b < 0) return -1;
if (b < mn) b = mn; if (b < mn) b = mn;
if (b > 100) b = 100; if (b > 100) b = 100;
return b; return b;
} }
/* ---- M23:亮度决策(纯函数,宿主单测见 test_bm_fw.c)----
* 方向同 meow_watchdog wd_bri_decide()(settings 是唯一权威,任何分支都不再写「固定 60」),
* 但 bootmenu **没有运行期亮度快照**(supervisor 起始只有一次读取),所以降级时转而走
* 安全下限,而不是 M20 的「运行期快照 → cur>0 不写」那条链。 */
#define BM_BRI_MIN_DEFAULT 1 /* display.brightness_min 缺省(与 launcher/watchdog 同值:极暗但可见) */
#define BM_BRI_SAFE_LOW 10 /* 与 meow_watchdog 的 WD_BRI_SAFE_LOW 对齐(同一系统同一个安全下限) */
/* 把任意亮度读数夹进面板合法区间 [mn, max_brightness](M23:取代旧 `>60 → 60` 的硬钉)。
* mn < 1 时按 1(防黑屏:亮着总比黑屏好,但也不伪造「比用户下限更亮」);
* maxb <= 0(sysfs 读不到)时只夹下限,不臆造上限。
* 为什么这样不会重新引入 M2.9 要压的开机闪屏:见 bm_bri_pick_saved 注释。 */
static int bm_bri_clamp(int v, int mn, int maxb)
{
if (mn < BM_BRI_MIN_DEFAULT) mn = BM_BRI_MIN_DEFAULT;
if (v < mn) v = mn;
if (maxb > 0 && v > maxb) v = maxb;
return v;
}
/* supervisor 起始「保底亮度」取值。
* settings(已 clamp 到 [brightness_min,100])→ min(面板读数, SAFE_LOW) → SAFE_LOW;
* 后两者再夹进 [brightness_min, max_brightness]。
* *src 回填来源,原样进日志(settings / panel-capped / safe-low)。
* 真机症状来源:旧实现把「启动瞬间面板读数」当权威、且 >60 一律钉 60,用户值 1 被 60 顶掉。
*
* 降级路径(settings 不可读)**不再原样采信面板读数**(P2-1 评审):启动瞬间面板上可能是
* 播放器启动画面的 60/101,若原值保存,后续扫描窗保底/确认框 unblank 就会把 60 写回去 ——
* 正是 M20 把「当前面板值」排除出权威源的原因。bootmenu 也没有运行期快照可退,所以取
* min(cur, SAFE_LOW=10):宁可暗一点,也不把瞬时值当用户亮度回放(这就是 M2.9 闪屏防护的正确形态)。
* HibyOS 运行期由用户改的亮度**不走这条路径**,而走热键 `panel-now`(明确用户动作,见
* bm_bri_hotkey_saved),所以这里不跟随面板不会丢用户意图。
*
* 为什么 clamp[min,max] 不会重新引入 M2.9 Bug 3 的「开机闪一下最高亮度」:
* M2.9 的前置条件是「supervisor 启动时面板上是播放器启动画面的瞬时值、还没人写过用户值」。
* 现在:① 有 settings 时根本不看面板值,写回的就是用户设置本身;② M21/S22 之后 /usr/data
* 一挂载就写用户值,主路径上不存在「瞬时值当权威」;③ 写回只发生在面板已暗时,方向是
* 「暗 → 回到用户值」,而退化路径已被 SAFE_LOW 封顶,不会跳 max。 */
static int bm_bri_pick_saved(int user, int cur, int mn, int maxb, const char **src)
{
if (user > 0) { *src = "settings"; return bm_bri_clamp(user, mn, maxb); }
if (cur > 0) {
*src = "panel-capped";
return bm_bri_clamp((cur < BM_BRI_SAFE_LOW) ? cur : BM_BRI_SAFE_LOW, mn, maxb);
}
*src = "safe-low";
return bm_bri_clamp(BM_BRI_SAFE_LOW, mn, maxb);
}
/* 扫描窗保底(Bug 4)决策:返回 >0 = 要写的亮度,0 = 不动;*why 回填原因供日志。
* 触发条件**回到 pre-M23 的形态**(评审 r1 P2-2):窗口内 + 面板暗(bri<=0)→ 补。
* **不依赖 /proc 的 D 态**:媒体扫描若在子线程,主线程是 S,D 态门会让 Bug 4 的防护变成
* 空操作(黑屏几分钟回归),那比亮度偏高严重得多。
*
* 不抢用户熄屏靠 **电源键锁存**(评审 r2 P2-2):扫描窗内一旦看到电源键按下边沿,
* 本次窗口内此后一律不补(*why="power-key-latched")。理由:Bug 4 保护的是「**无人值守**时
* 媒体扫描导致黑屏几分钟」;用户已经按键介入,需要亮屏时自己再按一次即可。锁存覆盖两种残余:
* 播放器只写 brightness=0 不改 bl_power,以及 HibyOS 自动超时熄屏。
*
* bl_power 门**保留但只是纯保险**:据 patch_v2/meow_launcherd/backlight.c:90-95 的实测注释,
* 本机播放器熄屏(brightness 0 + FBIOBLANK 4)后 **bl_power 仍为 0**,所以这道门在「播放器
* 熄屏」场景**不生效**;保留它只是为了其它可能置位的面板下电状态。真正的门是上面的电源键锁存。
*
* 窗口结束 → *why="window-over",调用方彻底停手(并把锁存清零,逻辑上等价于「下一轮窗口从零开始」)。 */
static int bm_bri_floor_decide(int saved, int cur, int bl_power,
long long win_left_ms, int pwr_latched,
const char **why)
{
if (win_left_ms <= 0) { *why = "window-over"; return 0; }
if (cur > 0) { *why = "visible"; return 0; }
if (bl_power > 0) { *why = "panel-off"; return 0; }
if (pwr_latched) { *why = "power-key-latched"; return 0; }
*why = "scan-floor";
return saved > 0 ? saved : BM_BRI_SAFE_LOW;
}
/* 热键冻结播放器前刷新「熄屏恢复值」:面板当前有正亮度 = 用户此刻看到的真实值,以它为准
* (HibyOS 里改过亮度不写 settings.conf,这条路径必须跟随面板);否则沿用 saved(现在=用户设置);
* 都没有才 BM_BRI_SAFE_LOW。一律夹进 [brightness_min, max_brightness],防越界值回放。 */
static int bm_bri_hotkey_saved(int saved, int now_bri, int mn, int maxb, const char **src)
{
if (now_bri > 0) { *src = "panel-now"; return bm_bri_clamp(now_bri, mn, maxb); }
if (saved > 0) { *src = "saved"; return saved; }
*src = "safe-low";
return bm_bri_clamp(BM_BRI_SAFE_LOW, mn, maxb);
}
/* 面板 unblank(FBIOBLANK 0)。仅用于杀播放器后把屏点亮,以便 MeowOS 接管; /* 面板 unblank(FBIOBLANK 0)。仅用于杀播放器后把屏点亮,以便 MeowOS 接管;
* 播放器已死,故不涉及「播放器 state machine」一致性(§18.11 关注点)。 * 播放器已死,故不涉及「播放器 state machine」一致性(§18.11 关注点)。
* bootmenu 选单/自检阶段绝不调用本函数(§18.24 纪律:不碰 panel blank)。 */ * bootmenu 选单/自检阶段绝不调用本函数(§18.24 纪律:不碰 panel blank)。 */
@@ -1192,14 +1338,19 @@ static int confirm_switch(void)
/* ---- HibyOS 分支:父进程监督者/热键守护(M2)---- */ /* ---- HibyOS 分支:父进程监督者/热键守护(M2)---- */
static void bootmenu_supervisor(int player_pid) static void bootmenu_supervisor(int player_pid)
{ {
/* 记录开机后的正常亮度,供「杀播放器后亮度==0 则恢复 saved/60」。 /* M23:保底亮度(saved_brightness)的权威源 = settings.conf 的 display.brightness
* 此刻播放器刚拉起,可能已写入其背光值;过早读到 0 则回退默认 60。 */ * (与 launcher / meow_watchdog 同源同 clamp),不再是「启动瞬间面板读数 + >60 钉 60」。
int saved_brightness = read_brightness(); * 旧写法把播放器启动瞬间写入的面板值(真机 60)当权威,用户设置 1 被顶掉,随后扫描窗
/* M2.9 Bug 3:开机初期播放器启动画面会把背光设为瞬时高值(本机 max=101), * 保底 / 熄屏恢复都把 60 写回 → 「亮/灭屏瞬间爆亮」。
* 若直接以其为「保底亮度」,会在扫描窗首次写回时跳一次最高亮度(开机瞬间闪一下)。 * 取值优先级见 bm_bri_pick_saved:settings → 面板读数 → BM_BRI_SAFE_LOW,后两者夹进
* 只信任稳定读数:读到 0 或异常高(>稳定上限 60,适中非最大)一律回退稳定值 60, * [brightness_min, max_brightness](取代旧的 >60→60 硬钉)。起始**不写**背光
* 确保扫描期写回平滑(只写目标一次、不跳 max)。 */ * (不抢播放器刚开始的写入)。 */
if (saved_brightness <= 0 || saved_brightness > 60) saved_brightness = 60; const char *bri_src = "?";
int bri_min = BM_BRI_MIN_DEFAULT;
int bri_user = bootmenu_settings_brightness(&bri_min); /* >0=用户设置(clamped);-1=不可读 */
int bri_panel = read_brightness();
int bri_max = read_max_brightness();
int saved_brightness = bm_bri_pick_saved(bri_user, bri_panel, bri_min, bri_max, &bri_src);
fprintf(stderr, "supervisor: player pid=%d, saved brightness=%d, monitoring...\n", fprintf(stderr, "supervisor: player pid=%d, saved brightness=%d, monitoring...\n",
player_pid, saved_brightness); player_pid, saved_brightness);
/* 信号记录 hook(M2 诊断):先装 handler、start 一行写入 TF 卡日志。 /* 信号记录 hook(M2 诊断):先装 handler、start 一行写入 TF 卡日志。
@@ -1207,12 +1358,19 @@ static void bootmenu_supervisor(int player_pid)
bm_sig_install(); bm_sig_install();
bm_log("supervisor: start pid=%d saved_brightness=%d mode=hiby ver=%s", bm_log("supervisor: start pid=%d saved_brightness=%d mode=hiby ver=%s",
player_pid, saved_brightness, MEOW_BOOTMENU_VER); player_pid, saved_brightness, MEOW_BOOTMENU_VER);
bm_log("supervisor: bri saved=%d src=%s user=%d panel=%d min=%d max=%d",
saved_brightness, bri_src, bri_user, bri_panel, bri_min, bri_max);
/* Bug 4:HibyOS 开机媒体扫描窗内做亮度保底。播放器忙(媒体扫描)时其背光 /* Bug 4:HibyOS 开机媒体扫描窗内做亮度保底。播放器忙(媒体扫描)时其背光
* 待机会把亮度降到 0、面板 blank → 黑屏几分钟。supervisor 在扫描窗内若读到 * 待机会把亮度降到 0、面板 blank → 黑屏几分钟。**只写 brightness、绝不碰 FBIOBLANK**
* 亮度 0,写回保底亮度(saved 或 60)。**只写 brightness、绝不碰 FBIOBLANK** * (不抢播放器的面板归属语义,参考 §18.30 面板归属)。扫描窗结束即放手。
* (不抢播放器的面板归属语义,参考 §18.30 面板归属)。扫描窗结束即放手。 */ * M23:补的值改为用户设置(不再 60);触发回到 pre-M23 的「面板暗」,不抢用户熄屏靠
* 「电源键锁存」(见 bm_bri_floor_decide)。 */
unsigned long long scan_keep_until = now_ms() + SCAN_KEEP_MS; unsigned long long scan_keep_until = now_ms() + SCAN_KEEP_MS;
unsigned long long last_bri_floor_at = 0; /* 亮度保底节流:≥400ms 才再写一次 */ unsigned long long last_bri_floor_at = 0; /* 亮度保底节流:≥400ms 才判定/写一次 */
const char *last_floor_why = "init"; /* 判定原因(日志只在跃迁或 ≥2s 时打) */
unsigned long long last_floor_log_at = 0; /* 上次 scan-window 日志时刻(同原因 ≥2s 一条) */
unsigned long long last_pwr_edge_at = 0; /* 最近一次电源键按下边沿(0=本次开机尚未按过) */
int scan_pwr_seen = 0; /* 扫描窗内见过电源键按下 → 本次窗口内不再补亮 */
struct keypoll kp_power, kp_keys; struct keypoll kp_power, kp_keys;
if (kp_open(&kp_power, DEV_POWER) != 0) fprintf(stderr, "supervisor: WARN no power dev\n"); if (kp_open(&kp_power, DEV_POWER) != 0) fprintf(stderr, "supervisor: WARN no power dev\n");
@@ -1268,19 +1426,10 @@ static void bootmenu_supervisor(int player_pid)
} }
} }
/* 2a) Bug 4 亮度保底:扫描窗内、播放器存活且亮度被降到 0 → 写回保底亮度。 /* 2a) Bug 4 亮度保底(M23):**已移到主循环后半(按键采样之后)**,见下面
* (播放器扫描忙时无法响应唤醒,我们只把背光拉回,画面仍为播放器最后帧, * 「2b) 亮度保底」—— 电源键边沿与播放器写 0 落在同一 poll 间隙时,先采键再判定
* 不再黑屏数分钟;不碰 blank。) */ * 才不会出现一轮「未看到电源键就补亮」的竞态(评审 r2 非阻塞建议)。
if (player_alive && now_ms() < scan_keep_until) { */
int bri = read_brightness();
if (bri <= 0 && now_ms() - last_bri_floor_at >= 400) {
int floor = saved_brightness > 0 ? saved_brightness : 60;
write_brightness(floor);
last_bri_floor_at = now_ms();
fprintf(stderr, "supervisor: scan-window brightness floor -> %d\n", floor);
}
}
/* 2) EVIOCGKEY 采样(event0 电源 / event2 音量) */ /* 2) EVIOCGKEY 采样(event0 电源 / event2 音量) */
int pwr = 0, vold = 0; int pwr = 0, vold = 0;
if (kp_power.ok) { kp_poll(&kp_power, NULL, NULL); pwr = kp_is_down(&kp_power, KEY_POWER); } if (kp_power.ok) { kp_poll(&kp_power, NULL, NULL); pwr = kp_is_down(&kp_power, KEY_POWER); }
@@ -1294,12 +1443,54 @@ static void bootmenu_supervisor(int player_pid)
if (se >= sim_hotkey_ms + 800 && se < sim_hotkey_ms + 950) vold = 1; if (se >= sim_hotkey_ms + 800 && se < sim_hotkey_ms + 950) vold = 1;
} }
/* 3) 热键:电源按下边沿 arming,窗口内音量减按下边沿触发 */ /* 3) 热键:电源按下边沿 arming,窗口内音量减按下边沿触发。
if (pwr && !prev_pwr) { pwr_hold_armed = 1; pwr_down_at = t; } * 同一边沿也记到 last_pwr_edge_at,并**锁存**到本次扫描窗结束:亮度保底据此
* 不再与用户抢屏(用户已按键介入;需要亮屏时自己再按一次)。 */
if (pwr && !prev_pwr) {
pwr_hold_armed = 1; pwr_down_at = t; last_pwr_edge_at = t;
if (scan_keep_until) scan_pwr_seen = 1;
}
if (!pwr && prev_pwr) pwr_hold_armed = 0; /* 松开:disarm */ if (!pwr && prev_pwr) pwr_hold_armed = 0; /* 松开:disarm */
if (pwr_hold_armed && pwr && (t - pwr_down_at) > HK_PWRWIN_MAX) if (pwr_hold_armed && pwr && (t - pwr_down_at) > HK_PWRWIN_MAX)
pwr_hold_armed = 0; /* 超窗:disarm */ pwr_hold_armed = 0; /* 超窗:disarm */
/* 2b) Bug 4 亮度保底(M23,放在按键采样之后以关掉「电源键与写 0 同间隙」竞态):
* 扫描窗内、面板暗(bri<=0)→ 写回用户设置亮度。触发不依赖 /proc 的 D 态;
* 不抢用户熄屏靠**电源键锁存**(why=power-key-latched);bl_power>0 只是纯保险
* (据 backlight.c:90-95 实测,本机播放器熄屏后 bl_power 仍为 0,这道门在该场景不生效)。
* 判定 400ms 节流;日志同一原因 ≥2s、跃迁 ≥1s(90s 窗口最坏 ~90 行)。 */
if (player_alive && scan_keep_until) {
unsigned long long now2 = now_ms();
if (now2 - last_bri_floor_at >= 400) {
long long win_left = (scan_keep_until > now2)
? (long long)(scan_keep_until - now2) : 0;
long long pwr_ago = (last_pwr_edge_at > 0)
? (long long)(now2 - last_pwr_edge_at) : -1;
int bri = read_brightness();
int blp = read_file_int(BACKLIGHT_POWER);
const char *why = "?";
int floor = bm_bri_floor_decide(saved_brightness, bri, blp, win_left,
scan_pwr_seen, &why);
last_bri_floor_at = now2;
if (floor > 0) {
write_brightness(floor);
/* 保留 M2 时代原有的 stderr 行(串口调试仍看它),另加一路 bm_log 落卡。 */
fprintf(stderr, "supervisor: scan-window brightness floor -> %d\n", floor);
}
unsigned long long gap = now2 - last_floor_log_at;
if ((strcmp(why, last_floor_why) != 0 && gap >= 1000) || gap >= 2000) {
last_floor_why = why;
last_floor_log_at = now2;
bm_log("supervisor: scan-window brightness %s -> %d (user=%d panel=%d bl_power=%d latched=%d pwr_ago=%lldms left=%lldms)",
(floor > 0) ? "floor" : "skip", floor, bri_user, bri, blp,
scan_pwr_seen, pwr_ago, win_left);
}
/* 窗口结束 → 彻底放手:此后不再读背光/bl_power;锁存清零(逻辑上等价于
* 「下一轮窗口从零开始」,当前实现每进程只有一轮窗口)。 */
if (win_left <= 0) { scan_keep_until = 0; scan_pwr_seen = 0; }
}
}
int hotkey_fired = 0; int hotkey_fired = 0;
if (pwr_hold_armed && pwr && vold && !prev_vold) { if (pwr_hold_armed && pwr && vold && !prev_vold) {
long since = (long)(t - pwr_down_at); long since = (long)(t - pwr_down_at);
@@ -1338,10 +1529,16 @@ static void bootmenu_supervisor(int player_pid)
prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN); prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
continue; continue;
} }
/* 刷新 saved 亮度:若播放器当前亮度仍为正(如「背光常亮」设置), /* 刷新 saved 亮度:若播放器当前亮度仍为正(如「背光常亮」设置 / 用户在 HibyOS 里
* 以它为准;若电源按下已触发灭屏(亮度 0),保留此前 saved(≥60)。 */ * 改过亮度但没写 settings.conf),以面板此刻的真实值为准(clamp 到
* [brightness_min, max_brightness]);若电源按下已触发灭屏(亮度 0),保留此前 saved
* —— M23 起 saved 来自 settings.conf 的用户亮度,不再回退 60。见 bm_bri_hotkey_saved。 */
int now_bri = read_brightness(); int now_bri = read_brightness();
if (now_bri > 0) saved_brightness = now_bri; const char *hk_src = "?";
saved_brightness = bm_bri_hotkey_saved(saved_brightness, now_bri,
bri_min, bri_max, &hk_src);
bm_log("supervisor: hotkey saved brightness=%d src=%s (now=%d user=%d)",
saved_brightness, hk_src, now_bri, bri_user);
/* 先冻结播放器(SIGSTOP→T),防止其在确认框上重绘覆盖; /* 先冻结播放器(SIGSTOP→T),防止其在确认框上重绘覆盖;
* 同时捕获面板是否已因电源按下而灭屏(was_blank),供较后恢复。 */ * 同时捕获面板是否已因电源按下而灭屏(was_blank),供较后恢复。 */
if (player_alive && !proc_is_stopped(player_pid)) { if (player_alive && !proc_is_stopped(player_pid)) {
@@ -1354,7 +1551,10 @@ static void bootmenu_supervisor(int player_pid)
/* 点亮面板以便看清确认框(播放器已冻结,不会 re-blank)。 */ /* 点亮面板以便看清确认框(播放器已冻结,不会 re-blank)。 */
unblank_panel(); unblank_panel();
if (was_blank) write_brightness(saved_brightness); if (was_blank) {
write_brightness(saved_brightness);
bm_log("supervisor: confirm-box unblank brightness=%d (panel was blank)", saved_brightness);
}
if (read_file_int(BACKLIGHT_POWER) > 0) write_file_str(BACKLIGHT_POWER, "0\n"); if (read_file_int(BACKLIGHT_POWER) > 0) write_file_str(BACKLIGHT_POWER, "0\n");
int ans = confirm_switch(); int ans = confirm_switch();
@@ -1409,6 +1609,7 @@ static void bootmenu_supervisor(int player_pid)
if (f >= 0) { ioctl(f, FBIOBLANK, 4); close(f); } if (f >= 0) { ioctl(f, FBIOBLANK, 4); close(f); }
} else if (read_brightness() <= 0) { } else if (read_brightness() <= 0) {
write_brightness(saved_brightness); write_brightness(saved_brightness);
bm_log("supervisor: cancel restore brightness=%d (panel read 0)", saved_brightness);
} }
fprintf(stderr, "supervisor: hotkey cancelled (stay HibyOS)\n"); fprintf(stderr, "supervisor: hotkey cancelled (stay HibyOS)\n");
if (player_alive && proc_is_stopped(player_pid)) { if (player_alive && proc_is_stopped(player_pid)) {
@@ -1628,7 +1829,6 @@ static int run_menu(int sim_sel, int do_fork)
/* 每秒刷时钟 */ /* 每秒刷时钟 */
if (el - last_clock_tick >= 1000) { last_clock_tick = el; draw_clock(cfg_bg); } if (el - last_clock_tick >= 1000) { last_clock_tick = el; draw_clock(cfg_bg); }
/* 每秒刷倒计时(秒值变化才刷) */ /* 每秒刷倒计时(秒值变化才刷) */
long cd = (remaining + 999) / 1000;
if (el - last_cd_tick >= 1000) { last_cd_tick = el; draw_countdown(cfg_bg, remaining); } if (el - last_cd_tick >= 1000) { last_cd_tick = el; draw_countdown(cfg_bg, remaining); }
usleep(POLL_MS * 1000); usleep(POLL_MS * 1000);
@@ -1676,6 +1876,9 @@ static void usage(void)
SIM_SELECT_DELAY_MS); SIM_SELECT_DELAY_MS);
} }
/* M23:宿主单测(test_bm_fw.c)先 #define BM_NO_MAIN 再把本文件 include 进来,去 main 拿
* 静态亮度决策函数(与 meow_watchdog 的 -DWD_NO_MAIN 同款);交叉编译不定义该宏,main 照常。 */
#ifndef BM_NO_MAIN
int main(int argc, char **argv) int main(int argc, char **argv)
{ {
long dumpkeys = 0, timeout_override = 0; long dumpkeys = 0, timeout_override = 0;
@@ -1737,10 +1940,17 @@ int main(int argc, char **argv)
} }
/* M6.25(方案A):选单显示前把背光按用户设置恢复(display.brightness,clamp [min||1,100])。 /* M6.25(方案A):选单显示前把背光按用户设置恢复(display.brightness,clamp [min||1,100])。
* settings 不可读/值非法 → 不动(退化为现状)。只在显示前写一次,不碰 FBIOBLANK/blank(§18.24)。 */ * settings 不可读/值非法 → 不动(退化为现状)。只在显示前写一次,不碰 FBIOBLANK/blank(§18.24)。
* M23:这句在 0.8.46/0.8.49 的**出货二进制里根本不存在**(源码改了但二进制没重编,见文件头),
* 所以选单一直亮在面板继承值上。现在补一行 bm_log:每次开机留下「选单亮度到底写了多少」。 */
{ {
int b = bootmenu_settings_brightness(); int b = bootmenu_settings_brightness(NULL);
if (b > 0) write_brightness(b); if (b > 0) {
write_brightness(b);
bm_log("menu: brightness -> %d (src=settings.conf)", b);
} else {
bm_log("menu: brightness untouched, settings unreadable (panel=%d)", read_brightness());
}
} }
if (fb_open() != 0) return 1; if (fb_open() != 0) return 1;
@@ -1750,3 +1960,6 @@ int main(int argc, char **argv)
run_menu(sim_sel, do_fork); run_menu(sim_sel, do_fork);
return 0; return 0;
} }
/* BM_NO_MAIN(宿主单测)到此为止 */
#endif
+334
View File
@@ -0,0 +1,334 @@
/* test_bm_fw.c — 宿主单测:meow_bootmenu 的亮度决策(M23 亮度单一权威源)。
*
* 覆盖真机 0.8.46 两个症状的判定矩阵:
* ① 开机菜单/选单亮度 = 面板继承值 60(用户设置 1)—— 选单路径 bootmenu_settings_brightness()
* ② HibyOS 亮/灭屏瞬间爆亮 —— supervisor 起始 saved、扫描窗保底、热键熄屏恢复三处决策
*
* 编译/运行(Termux 上 /tmp 不可写,**必须带 TMPDIR**,否则 mkstemp 两条用例会 FAIL):
* TMPDIR=$HOME/tmp/m23test mkdir -p "$HOME/tmp/m23test"
* gcc -Wall -Wextra -Os -I../meow_launcherd -Wno-unused-function \
* -Wno-unused-parameter -Wno-unused-variable \
* test_bm_fw.c ../meow_launcherd/meow_touch.c -o "$HOME/tmp/m23test/test_bm_fw"
* TMPDIR=$HOME/tmp/m23test "$HOME/tmp/m23test/test_bm_fw" # 全 PASS -> exit 0
* (TMPDIR 未设时本文件会自己退到可写的 /tmp 或 $HOME/tmp,但显式给最稳。)
* env MEOW_BACKLIGHT / MEOW_SETTINGS_FILE 把背光与 settings 指到临时文件(真机无这两个 env,
* 走 /sys 与 /usr/data 默认路径),因此这里的读写断言就是真机同一条代码路径。
*/
#define BM_NO_MAIN
#include "meow_bootmenu.c"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
static int g_pass = 0, g_fail = 0;
static void check(const char *name, int got, int want)
{
if (got == want) { g_pass++; printf("PASS %s\n", name); }
else { g_fail++; printf("FAIL %s: got %d want %d\n", name, got, want); }
}
/* 临时目录根(返回可写目录;结果缓存在静态缓冲里):
* ① $TMPDIR(Termux/Android 上 /tmp 属别的用户、本进程不可写)
* ② /tmp(Linux 构建容器)
* ③ $HOME/tmp/m23test(两者都不可写时的兜底,自动 mkdir)
* 评审 r1 反馈:只靠 $TMPDIR 会让没设环境变量的复跑出现 2 条 mkstemp FAIL,故这里自愈。 */
static const char *tmp_root(void)
{
static char buf[512];
if (buf[0]) return buf;
const char *t = getenv("TMPDIR");
if (t && t[0]) { snprintf(buf, sizeof buf, "%s", t); return buf; }
const char *home = getenv("HOME");
snprintf(buf, sizeof buf, "%s", "/tmp");
char probe[560];
snprintf(probe, sizeof probe, "%s/.bm_probeXXXXXX", buf);
int fd = mkstemp(probe);
if (fd >= 0) { close(fd); unlink(probe); return buf; }
if (home && home[0]) {
snprintf(buf, sizeof buf, "%s/tmp/m23test", home);
mkdir(buf, 0700);
return buf;
}
return buf; /* 实在不行也返回 /tmp,让失败信息可读 */
}
static void put_file(const char *path, const char *body)
{
FILE *f = fopen(path, "w");
if (!f) { g_fail++; printf("FAIL cannot write %s\n", path); return; }
fputs(body, f);
fclose(f);
}
static int get_file_int(const char *path)
{
FILE *f = fopen(path, "r");
int v = -1;
if (!f) return -2;
if (fscanf(f, "%d", &v) != 1) v = -1;
fclose(f);
return v;
}
/* ---- A) supervisor 起始 saved_brightness:settings 是唯一权威 ----
* 真机 bug:settings display.brightness=1,面板被播放器写成 60 → 旧代码 saved=60(>60→60 的
* 硬钉同宗),随后扫描窗保底/熄屏恢复把 60 写回 → 亮/灭屏爆亮。新语义必须取 1。 */
static void test_pick_saved(void)
{
const char *src = NULL;
int v;
v = bm_bri_pick_saved(1, 60, 1, 101, &src); /* ← 真机症状本体 */
check("saved: settings 1 wins over panel 60", v == 1 && !strcmp(src, "settings"), 1);
v = bm_bri_pick_saved(1, 0, 1, 101, &src); /* 播放器已把面板写成 0 */
check("saved: settings 1 when panel dark", v == 1 && !strcmp(src, "settings"), 1);
v = bm_bri_pick_saved(7, 7, 1, 101, &src);
check("saved: settings 7", v == 7 && !strcmp(src, "settings"), 1);
/* settings 不可读(降级路径):**不**原样采信面板瞬时值,封顶 SAFE_LOW=10(P2-1 修复)。
* 这是 M2.9 闪屏防护的正确形态:宁可用安全低亮,也不把「启动瞬间的面板值」当用户亮度回放。 */
v = bm_bri_pick_saved(-1, 60, 1, 101, &src);
check("saved: fallback caps panel 60 -> SAFE_LOW 10",
v == BM_BRI_SAFE_LOW && v == 10 && !strcmp(src, "panel-capped"), 1);
v = bm_bri_pick_saved(-1, 80, 1, 101, &src); /* 用户可能真设 80,但降级时不敢信面板 → 10 */
check("saved: fallback caps 80 -> SAFE_LOW (old bug shape)",
v == BM_BRI_SAFE_LOW && !strcmp(src, "panel-capped"), 1);
v = bm_bri_pick_saved(-1, 200, 1, 101, &src); /* 越界(播放器瞬时 max)同样封顶 */
check("saved: fallback caps 200 -> SAFE_LOW",
v == BM_BRI_SAFE_LOW && !strcmp(src, "panel-capped"), 1);
v = bm_bri_pick_saved(-1, 90, 1, 0, &src); /* max 读不到也不抬高 */
check("saved: fallback caps 90 -> SAFE_LOW (unknown max)",
v == BM_BRI_SAFE_LOW && !strcmp(src, "panel-capped"), 1);
/* 比 SAFE_LOW 更暗的真实值保留(用户刻意调很暗,不要替用户调亮) */
v = bm_bri_pick_saved(-1, 3, 1, 101, &src);
check("saved: fallback keeps darker 3", v == 3 && !strcmp(src, "panel-capped"), 1);
v = bm_bri_pick_saved(-1, 2, 6, 101, &src); /* 低于 brightness_min 时抬到 min(防全黑) */
check("saved: fallback clamps up to brightness_min 6", v == 6 && !strcmp(src, "panel-capped"), 1);
v = bm_bri_pick_saved(0, 3, 1, 101, &src); /* 非正值按住「未知」处理 */
check("saved: user<=0 treated unknown", v == 3 && !strcmp(src, "panel-capped"), 1);
/* settings 与面板都不可知且面板黑:安全低亮,绝不再写 60 */
v = bm_bri_pick_saved(-1, 0, 1, 101, &src);
check("saved: dark + no settings -> safe-low", v == BM_BRI_SAFE_LOW && v != 60 &&
v > 0 && !strcmp(src, "safe-low"), 1);
v = bm_bri_pick_saved(-1, -1, 1, 0, &src); /* 读失败 + max 读不到 */
check("saved: read-fail -> safe-low", v == BM_BRI_SAFE_LOW && !strcmp(src, "safe-low"), 1);
/* 与 M20 watchdog 同一安全下限(同一系统不该有两个安全值) */
check("saved: SAFE_LOW aligned with WD_BRI_SAFE_LOW (10)", BM_BRI_SAFE_LOW, 10);
}
/* ---- A2) clamp 语义单测:取代旧的 `>60 → 60` 硬钉 ---- */
static void test_clamp(void)
{
check("clamp: in-range untouched", bm_bri_clamp(37, 1, 101), 37);
check("clamp: above max -> max", bm_bri_clamp(200, 1, 101), 101);
check("clamp: 80 stays 80 (was pinned 60)", bm_bri_clamp(80, 1, 101), 80);
check("clamp: below min -> min", bm_bri_clamp(0, 3, 101), 3);
check("clamp: mn<1 -> 1", bm_bri_clamp(0, 0, 101), 1);
check("clamp: unknown max only floors", bm_bri_clamp(90, 1, 0), 90);
}
/* ---- B) 扫描窗保底:只在「播放器忙 + 面板暗 + 面板未下电」时补,补的是用户值 ---- */
static void test_floor_decide(void)
{
const char *why = NULL;
int v;
/* 触发条件回到 pre-M23:面板暗就补(不依赖 /proc 的 D 态 = 评审 r1 P2-2 硬要求) */
v = bm_bri_floor_decide(1, 0, 0, 5000, 0, &why); /* settings=1,播放器待机写 0 */
check("floor: writes user value 1 (not 60)", v == 1 && !strcmp(why, "scan-floor"), 1);
v = bm_bri_floor_decide(80, 0, 0, 5000, 0, &why); /* saved=80 只可能来自 settings */
check("floor: saved 80 (from settings) floors at 80", v == 80 && !strcmp(why, "scan-floor"), 1);
v = bm_bri_floor_decide(0, 0, 0, 5000, 0, &why); /* saved 未知 -> 安全低亮 */
check("floor: unknown saved -> safe-low", v == BM_BRI_SAFE_LOW && !strcmp(why, "scan-floor"), 1);
/* Bug 4 防退化回归用例(评审 r1 P2-2):面板暗就必须能补,不能有任何「播放器看起来不忙」
* 的前提 —— 旧 busy 门会让这条变成 no-write(黑屏几分钟)。 */
v = bm_bri_floor_decide(7, 0, 0, 89999, 0, &why);
check("floor: Bug4 regression — dark+unlatched always floors", v == 7 &&
!strcmp(why, "scan-floor"), 1);
v = bm_bri_floor_decide(1, 5, 0, 5000, 0, &why); /* 面板已亮:不动 */
check("floor: visible -> no write", v == 0 && !strcmp(why, "visible"), 1);
v = bm_bri_floor_decide(1, 0, 1, 5000, 0, &why); /* bl_power>0(纯保险):不动 */
check("floor: panel powered off -> no write", v == 0 && !strcmp(why, "panel-off"), 1);
/* 评审 r2 P2-2 核心:电源键**锁存**。窗内按过电源键 → 本次窗口内此后一律不补,
* 不再「3s 到期就把用户刚熄的屏点亮」。 */
v = bm_bri_floor_decide(1, 0, 0, 5000, 1, &why);
check("floor: power key latched -> no write", v == 0 && !strcmp(why, "power-key-latched"), 1);
/* 锁存不会过期:窗口剩 1ms 也不补(旧实现 3s 后就会补) */
v = bm_bri_floor_decide(1, 0, 0, 1, 1, &why);
check("floor: latch never expires inside window", v == 0 &&
!strcmp(why, "power-key-latched"), 1);
/* 下一轮窗口(锁存已清零)恢复正常补亮 */
v = bm_bri_floor_decide(1, 0, 0, 90000, 0, &why);
check("floor: new window (latch cleared) floors again", v == 1 &&
!strcmp(why, "scan-floor"), 1);
v = bm_bri_floor_decide(1, 0, 0, 0, 0, &why); /* 90s 窗口结束:放手 */
check("floor: window over -> no write", v == 0 && !strcmp(why, "window-over"), 1);
v = bm_bri_floor_decide(1, 0, 0, -1, 0, &why);
check("floor: negative window -> no write", v == 0 && !strcmp(why, "window-over"), 1);
}
/* ---- C) 热键冻结前的熄屏恢复值:面板真实值 > settings saved > 安全低亮 ---- */
static void test_hotkey_saved(void)
{
const char *src = NULL;
int v;
v = bm_bri_hotkey_saved(1, 0, 1, 101, &src); /* 电源已熄屏:恢复用户值 1,不再回 60 */
check("hotkey: blank -> saved (settings 1)", v == 1 && !strcmp(src, "saved"), 1);
v = bm_bri_hotkey_saved(60, 0, 1, 101, &src); /* saved=60 只可能来自 settings(降级已封顶 10) */
check("hotkey: blank -> saved 60 (settings value)", v == 60 && !strcmp(src, "saved"), 1);
v = bm_bri_hotkey_saved(1, 77, 1, 101, &src); /* 面板亮着:跟随面板(HibyOS 里改过亮度) */
check("hotkey: panel-now wins", v == 77 && !strcmp(src, "panel-now"), 1);
v = bm_bri_hotkey_saved(1, 200, 1, 101, &src); /* 越界值 clamp 到 max_brightness */
check("hotkey: panel-now clamped to max", v == 101 && !strcmp(src, "panel-now"), 1);
v = bm_bri_hotkey_saved(1, 1, 6, 101, &src); /* 低于 brightness_min 抬到 min */
check("hotkey: panel-now raised to min 6", v == 6 && !strcmp(src, "panel-now"), 1);
v = bm_bri_hotkey_saved(0, 0, 1, 101, &src);
check("hotkey: nothing known -> safe-low", v == BM_BRI_SAFE_LOW && !strcmp(src, "safe-low"), 1);
}
/* ---- D) settings.conf 解析(与 watchdog 同源同 clamp)+ 打开重试 ---- */
static void test_settings_parse(void)
{
char path[600];
snprintf(path, sizeof path, "%s/bmsetXXXXXX", tmp_root());
int fd = mkstemp(path);
if (fd < 0) { g_fail++; printf("FAIL settings: mkstemp\n"); return; }
close(fd);
setenv("MEOW_SETTINGS_FILE", path, 1);
put_file(path, "# meow-settings 1\ndisplay.brightness = 1\ndisplay.brightness_min = 1\n"
"audio.volume = 10\n");
check("settings: device file -> 1", bootmenu_settings_brightness(NULL), 1);
put_file(path, "display.brightness = 0\ndisplay.brightness_min = 3\n");
int mn = 0;
check("settings: clamp up to min", bootmenu_settings_brightness(&mn), 3);
check("settings: brightness_min parsed", mn, 3);
put_file(path, "display.brightness = 200\ndisplay.brightness_min = 1\n");
check("settings: clamp down to 100", bootmenu_settings_brightness(NULL), 100);
put_file(path, "audio.volume = 10\n");
/* 评审 r2 非阻塞 1(采纳):「文件里没有这个键」与「文件读不到」同属不可信 →
* 返回 -1,而不是 launcher 设置表的默认 101→100(那会让扫描窗待机补到 100)。 */
int mn2 = 0;
check("settings: missing key -> -1 (untrusted)", bootmenu_settings_brightness(&mn2), -1);
check("settings: missing key still reports min", mn2, 1);
unlink(path);
unsigned long long t0 = now_ms();
int r = bootmenu_settings_brightness(NULL);
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");
}
/* ---- E) 端到端:真的读写一个「背光文件」,复现两个真机场景 ----
* 1) settings=1、播放器把面板写成 60 → supervisor 起始 saved 必须是 1,扫描窗保底写 1(不是 60)
* 2) 用户按电源熄屏(播放器不忙)→ 保底决策返回 0,面板保持 0(不被抢亮 = 熄屏不爆亮) */
static void test_e2e_backlight(void)
{
char bl[600], st[600];
snprintf(bl, sizeof bl, "%s/bmblXXXXXX", tmp_root());
snprintf(st, sizeof st, "%s/bmstXXXXXX", 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);
put_file(st, "display.brightness = 1\ndisplay.brightness_min = 1\n");
put_file(bl, "60\n"); /* 播放器启动画面写的 60 */
int mn_e2e = 1;
int user = bootmenu_settings_brightness(&mn_e2e);
int panel = read_brightness();
const char *src = "?";
int saved = bm_bri_pick_saved(user, panel, mn_e2e, 101, &src);
check("e2e: panel file read 60", panel, 60);
check("e2e: saved from settings = 1", saved, 1);
/* 扫描窗:播放器待机把背光写 0 → 保底写用户值 1 */
put_file(bl, "0\n");
const char *why = NULL;
int floor = bm_bri_floor_decide(saved, read_brightness(), 0, 5000, 0, &why);
if (floor > 0) write_brightness(floor);
check("e2e: scan floor writes 1 (not 60)", get_file_int(bl), 1);
/* bl_power>0(纯保险分支)→ 保底不写 */
put_file(bl, "0\n");
floor = bm_bri_floor_decide(saved, read_brightness(), 1, 5000, 0, &why);
if (floor > 0) write_brightness(floor);
check("e2e: user screen-off (panel-off) stays dark", get_file_int(bl), 0);
/* 评审 r2 核心场景:用户按电源熄屏(bl_power 仍 0)→ 锁存 → 本次窗口内**永久**不补,
* 不会再出现「3s 后自己亮回来」 */
put_file(bl, "0\n");
floor = bm_bri_floor_decide(saved, read_brightness(), 0, 5000, 1, &why);
if (floor > 0) write_brightness(floor);
check("e2e: user screen-off (power key latched) stays dark", get_file_int(bl), 0);
check("e2e: latched why", !strcmp(why, "power-key-latched"), 1);
/* 热键确认框:面板黑着 → 按 saved(用户值 1)点亮,不是 60 */
int hk = bm_bri_hotkey_saved(saved, read_brightness(), mn_e2e, 101, &src);
write_brightness(hk);
check("e2e: confirm box lights at 1", get_file_int(bl), 1);
/* settings 被改:saved 不再跟旧值(每次开机重读 settings) */
put_file(st, "display.brightness = 5\ndisplay.brightness_min = 1\n");
src = "?";
saved = bm_bri_pick_saved(bootmenu_settings_brightness(NULL), read_brightness(), mn_e2e, 101, &src);
check("e2e: saved follows new settings 5", saved, 5);
/* 降级路径回归(评审 P2-1):settings 不可读 + 面板瞬时 60(播放器启动画面)
* → saved 只能是 SAFE_LOW=10,绝不能原样 60,否则扫描窗/确认框会把 60 写回去。 */
unlink(st);
put_file(bl, "60\n");
int mn_d = 1;
src = "?";
int dsaved = bm_bri_pick_saved(bootmenu_settings_brightness(&mn_d), read_brightness(),
mn_d, 101, &src);
check("e2e: settings unreadable -> saved SAFE_LOW (not 60)", dsaved, BM_BRI_SAFE_LOW);
check("e2e: degraded src=panel-capped", !strcmp(src, "panel-capped"), 1);
/* 评审 r2 非阻塞 1(采纳):文件存在但缺 display.brightness 键 → 视同不可信 →
* saved 同样封顶 SAFE_LOW,不会补到 launcher 缺省的 100(比老 bug 的 60 更亮)。 */
put_file(st, "display.brightness_min = 1\n");
put_file(bl, "60\n");
src = "?";
int msaved = bm_bri_pick_saved(bootmenu_settings_brightness(&mn_d), read_brightness(),
mn_d, 101, &src);
check("e2e: missing brightness key -> SAFE_LOW (not 100)", msaved, BM_BRI_SAFE_LOW);
check("e2e: missing key src=panel-capped", !strcmp(src, "panel-capped"), 1);
unlink(bl); unlink(st);
unsetenv("MEOW_BACKLIGHT");
unsetenv("MEOW_SETTINGS_FILE");
}
int main(void)
{
test_pick_saved();
test_clamp();
test_floor_decide();
test_hotkey_saved();
test_settings_parse();
test_e2e_backlight();
printf("\n%d passed, %d failed\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}
+15 -2
View File
@@ -22,7 +22,8 @@ patch_v2/files_app/
│ ├── fsx.h # 纯业务接口(不依赖 meow_ui) │ ├── fsx.h # 纯业务接口(不依赖 meow_ui)
│ └── fsx.c # 路径安全/格式化/枚举排序/虚拟化区间/写操作(可宿主测) │ └── fsx.c # 路径安全/格式化/枚举排序/虚拟化区间/写操作(可宿主测)
└── test/ └── 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 cd patch_v2/files_app
gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \ 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 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`)、 覆盖:路径安全(`within_sd`/`join`/`sanitize`/`dirname`/`basename`)、展示格式化(`fmt_size`/`fmt_time`)、
目录枚举排序(`scan_dir`/`sort`)、虚拟化区间(`visible_range`/`max_scroll`)、写操作(`mkdir`/`rename`/ 目录枚举排序(`scan_dir`/`sort`)、虚拟化区间(`visible_range`/`max_scroll`)、写操作(`mkdir`/`rename`/
`delete`,含卡外/越界拒绝)、非文本写拒绝。宿主全 PASS。 `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]); - 触摸:行 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_ui_set_long_press_cb` 注册的回调带坐标交给本页;`browser_on_long_press`
用 `meow_listview_hit` 行命中打开 BottomSheet。App 层长按计时(`browser_tick`/`g_press_ms`)已删除, 用 `meow_listview_hit` 行命中打开 BottomSheet。App 层长按计时(`browser_tick`/`g_press_ms`)已删除,
其抬手 TAP 由 SDK 抑制(长按不再触发点击)。 其抬手 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 档会显示缺字豆腐。 2. **中文字形**:SDK 默认 `common` 字库(≈4k)不含 CJK 全量;exFAT 中文文件名/中文文本预览在 common 档会显示缺字豆腐。
预览页不声明 `fonts`(app.json 未加 `"fonts":"full"`/`"gbk"`),如需中文预览可在 app.json 声明大字号档。 预览页不声明 `fonts`(app.json 未加 `"fonts":"full"`/`"gbk"`),如需中文预览可在 app.json 声明大字号档。
3. **预览仅文本**:按扩展名白名单 + 首 512 字节 NUL 嗅探判文本;二进制/图片无预览(提示「not a text file」)。 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)。 - 编译:`-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):正常运行到事件循环,导出离屏帧, - qemu-mipsel(无 /dev/fb0 → SDK 离屏 480×720):正常运行到事件循环,导出离屏帧,
确认 init/建页/topbar(返回+NEW)/空态(NO STORAGE CARD)渲染正确、SIGTERM 干净退出。 确认 init/建页/topbar(返回+NEW)/空态(NO STORAGE CARD)渲染正确、SIGTERM 干净退出。
- 实机目录列表渲染(有真实 TF 卡目录)需刷机/设备挂载验证——宿主模拟无法造出 `/usr/data/mnt/sd_0`。 - 实机目录列表渲染(有真实 TF 卡目录)需刷机/设备挂载验证——宿主模拟无法造出 `/usr/data/mnt/sd_0`。
Binary file not shown.
+26 -1
View File
@@ -37,13 +37,38 @@ void picker_cancel(void); /* 取消:发 code=MEOW_ERR
#define APP_W 480 #define APP_W 480
#define APP_H 720 #define APP_H 720
#define APP_TOPBAR_H 56 #define APP_TOPBAR_H 56
#define APP_ROW_H 56 /* 列表行高 */ #define APP_ROW_H 56 /* 列表行高下限(旧固定值;实际按字体行高派生,见 files_row_h) */
#define APP_PAD 16 #define APP_PAD 16
#define APP_ICON_W 26 /* 图标占宽 */ #define APP_ICON_W 26 /* 图标占宽 */
#define APP_OP_W 52 /* 右侧操作钮占宽(触达区) */ #define APP_OP_W 52 /* 右侧操作钮占宽(触达区) */
#define APP_FOOTER_H 26 /* 底部信息行高(列表/文本内容在其之上滚动) */ #define APP_FOOTER_H 26 /* 底部信息行高(列表/文本内容在其之上滚动) */
#define APP_READ_MAX (256 * 1024) /* 文本预览读取上限 */ #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 级复用矩形/几何 ---- */ /* ---- App 级复用矩形/几何 ---- */
typedef struct { int x, y, w, h; } app_rect_t; typedef struct { int x, y, w, h; } app_rect_t;
#define AR(x,y,w,h) ((app_rect_t){ (x),(y),(w),(h) }) #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) */ /* 结构化列表:行数据源 browser_src2;单选模式(行高亮用 selected,§5.8.3) */
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0, 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); 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 自动重画) */ /* 空态/页脚注册为非控件绘制物(列表视口不含页脚;invalidate/paint_full 自动重画) */
g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h, g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h,
browser_draw_empty, NULL); 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)。 */ /* 屏幕行高(px)。 */
static int viewer_rowh(const meow_theme *th) { return meow_ui_text_height(th->font_body) + 6; } 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) static int viewer_wrap_line(int li, int intra, char *out, int outsz)
{ {
if (!g_buf || li < 0 || li >= g_nline) return 0; 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--; while (len > 0 && (g_buf[start + len - 1] == '\n' || g_buf[start + len - 1] == '\r')) len--;
int colw = viewer_colw(); int colw = viewer_colw();
int col = 0, row = 0, outlen = 0; int col = 0, row = 0, outlen = 0;
char ch[2]; ch[1] = '\0'; const unsigned char *s = (const unsigned char *)g_buf + start;
for (int i = 0; i < len; i++) { for (int bi = 0; bi < len; ) {
ch[0] = g_buf[start + i]; int clen = viewer_cp_len(s, bi, len);
if (ch[0] == '\r') continue; if (clen == 1 && s[bi] == '\r') { bi++; continue; }
int w = meow_ui_text_width(ch, 20); 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 (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; col += w;
} }
if (out) out[outlen] = '\0'; if (out) out[outlen] = '\0';
Binary file not shown.
+25
View File
@@ -33,6 +33,31 @@ extern meow_sheet *g_sheet; /* BottomSheet(阻塞模态;main 创建
typedef struct { int x, y, w, h; } rect_t; typedef struct { int x, y, w, h; } rect_t;
#define RX(x,y,w,h) ((rect_t){ (x), (y), (w), (h) }) #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 绑定点)---- /* ---- 视图控制器(ctx = biz-UI 绑定点)----
* 本批控制器为薄壳:持 meow_view 回指针;页面细粒度视图态(g_ds/g_drag_* /scroll 等)留在各页 * 本批控制器为薄壳:持 meow_view 回指针;页面细粒度视图态(g_ds/g_drag_* /scroll 等)留在各页
* 模块作用域全局(设计定案 B:demo 视图态不迁 logic)。控制器未来可吸收这些状态。 */ * 模块作用域全局(设计定案 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, g_lv = meow_listview_create(r_lv.x, r_lv.y, r_lv.w, r_lv.h, 0,
NULL, NULL, MEOW_LIST_SEL_NONE); NULL, NULL, MEOW_LIST_SEL_NONE);
if (g_lv) { 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_vsource(g_lv, lv_count, lv_bind, NULL);
meow_listview_set_item_renderer(g_lv, 2, 1, 0); /* 双行、左图标、无右尾件 */ meow_listview_set_item_renderer(g_lv, 2, 1, 0); /* 双行、左图标、无右尾件 */
meow_listview_set_empty_text(g_lv, "All rows deleted"); meow_listview_set_empty_text(g_lv, "All rows deleted");
+4 -1
View File
@@ -33,7 +33,10 @@ if [ "$1" = "am" ]; then
exit 0 exit 0
fi fi
"$CC" -I"$ROOT/patch_v2/meow_launcherd" -Os -Wall -Wextra -static meow_amsd.c -o meow_amsd # meow_time32.c:独立 o32 time32 取时/睡眠(amsd 不链 meow_ui.a);同时修 now_ms 与
# usleep 退避。am CLI 无取时/睡眠调用,保持原样。
"$CC" -I"$ROOT/patch_v2/meow_launcherd" -Os -Wall -Wextra -static \
-DMEOW_TIME32_LIBC_OVERRIDES meow_amsd.c ../sdk/meow_time32.c -o meow_amsd
echo "== meow_amsd (mipsel static) ==" echo "== meow_amsd (mipsel static) =="
ls -la meow_amsd ls -la meow_amsd
md5sum meow_amsd md5sum meow_amsd
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 一帧,读回一帧) ---- */ /* ---- 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, static int ipc_xchg(uint16_t type, const void *payload, uint32_t len,
uint8_t *rbuf, uint32_t rcap) uint8_t *rbuf, uint32_t rcap)
{ {
g_ipc_err = IPC_ERR_NONE;
int fd = socket(AF_UNIX, SOCK_STREAM, 0); 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 的阻塞 /* 收发加超时:launcher 刚刚重启/主循环未就绪(接受但不回包)时,amsd 的阻塞
* read 会永远挂起,冻结整个单线程守护。设 SO_SNDTIMEO/SO_RCVTIMEO 让单次转发 * read 会永远挂起,冻结整个单线程守护。设 SO_SNDTIMEO/SO_RCVTIMEO 让单次转发
* 有界,超时即返回失败 → ams_request_ctl 退避重连,绝不无限阻塞。 * 有界,超时即返回失败 → 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); memset(&a, 0, sizeof a);
a.sun_family = AF_UNIX; a.sun_family = AF_UNIX;
strncpy(a.sun_path, g_ipc_sock, sizeof a.sun_path - 1); 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]; uint8_t hdr[MEOW_IPC_HDR_LEN];
uint32_t magic = MEOW_IPC_MAGIC, rmagic, rlen; uint32_t magic = MEOW_IPC_MAGIC, rmagic, rlen;
uint32_t reqid = 0; /* amsd→launcher 控制请求无需回显 */ 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; size_t off = 0;
while (off < sizeof hdr) { while (off < sizeof hdr) {
ssize_t w = write(fd, hdr + off, sizeof hdr - off); 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; off += (size_t)w;
} }
if (len && payload) { if (len && payload) {
size_t p = 0; size_t p = 0;
while (p < len) { while (p < len) {
ssize_t w = write(fd, (const uint8_t *)payload + p, len - p); 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; p += (size_t)w;
} }
} }
off = 0; off = 0;
while (off < sizeof hdr) { while (off < sizeof hdr) {
ssize_t r = read(fd, hdr + off, sizeof hdr - off); 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; off += (size_t)r;
} }
memcpy(&rmagic, hdr, 4); memcpy(&rver, hdr + 4, 2); memcpy(&rmagic, hdr, 4); memcpy(&rver, hdr + 4, 2);
memcpy(&rtype, hdr + 6, 2); memcpy(&rlen, hdr + 12, 4); 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; if (rlen > rcap) rlen = rcap;
off = 0; off = 0;
while (off < rlen) { while (off < rlen) {
ssize_t r = read(fd, rbuf + off, rlen - off); 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; off += (size_t)r;
} }
close(fd); 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; if (code) *code = a.code;
return 0; 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。 */ /* 一次连接失败/超时(launcher 未就绪)→ 短退避再试;仍失败则返回 -1。 */
if (attempt + 1 < AMS_IPC_RETRY) if (attempt + 1 < AMS_IPC_RETRY)
usleep((useconds_t)(AMS_IPC_BACKOFF_MS * (attempt + 1)) * 1000); 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; 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`),非二补。 */ /* launcher ACK ack.code 携带正幅度(busy=8,见 util.c `rc<0?-rc:MEOW_OK`),非二补。 */
snprintf(detail, sizeof detail, "busy (app running)"); snprintf(detail, sizeof detail, "busy (app running)");
emit_log("AMS start %s -> BUSY (app running)", pkg); 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 { } else {
snprintf(detail, sizeof detail, "launcher offline"); /* rt!=0 只代表「这次没拿到回执」,不等于 launcher 死了(同 cmd_kill 注释)。
emit_log("AMS start %s -> launcher offline", pkg); * 响应串保留 "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", snprintf(resp, rn, "%s %s/%s\n", strstr(detail, "ok forwarded") ? "OK" : "ERR",
pkg, act[0] ? act : "(default)"); pkg, act[0] ? act : "(default)");
@@ -1251,14 +1285,21 @@ static int cmd_kill(const char *pkg, char *resp, int rn)
uint32_t code = 0; uint32_t code = 0;
int rt = ams_request_ctl(MSG_AMS_KILL, &k, sizeof k, &code); int rt = ams_request_ctl(MSG_AMS_KILL, &k, sizeof k, &code);
if (rt != 0 && a->pid > 0) { 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); 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) { } 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) { } else if (code != MEOW_OK) {
emit_log("AMS kill %s -> launcher refused code=%u", pkg, code); /* launcher 明确拒绝:不改判为直杀(pid 可能已被回收,盲杀会打错进程),如实回错。
return snprintf(resp, rn, "ERR launcher refused: %s (code=%u)\n", pkg, code); * 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()); hist_push(a, "ams_kill", "terminate (via launcher)", (long)time(NULL), read_uptime_sec());
int m = snprintf(resp, rn, "OK terminate %s\n", pkg); int m = snprintf(resp, rn, "OK terminate %s\n", pkg);
+272 -92
View File
@@ -1,18 +1,147 @@
/* bt_dev.c — 蓝牙 A2DP 设备自识别(纯逻辑实现,见 bt_dev.h 契约)。 /* bt_dev.c — 蓝牙 A2DP 端点选择 + PCM 名构造 + 回落退避(纯逻辑实现,见 bt_dev.h 契约)。
* *
* 纯 libc(string / stdlib),无任何 meow_* 调用、无 popen、无文件 I/O —— meow_audiod 负责 * 纯 libc(string / stdlib),无任何 meow_* 调用、无 popen、无文件 I/O —— meow_audiod 负责
* 读取 /usr/data/alsa.conf、执行 `bluealsa-cli list-pcms` 及写回。宿主单测零链路重。 * 执行 `bluealsa-cli list-pcms`、调用 libasound 及维护运行时状态。
*/ */
#include "bt_dev.h" #include "bt_dev.h"
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
#include <stdio.h> #include <stdio.h>
#include <strings.h>
/* 是否 a2dpsrc / a2dp_source 端点行(bluealsa 4.1.1 list-pcms 的端点头)。
* ⚠️ 必须**行内**判定:直接对整串 strstr 会让「本行」借到「下一行」里的 a2dpsrc
* (真机 hfphf/source 行会被误判成 A2DP 源端点——M50 实测踩到)。 */
static int line_is_src(const char *ln)
{
const char *nl = strchr(ln, '\n');
size_t len = nl ? (size_t)(nl - ln) : strlen(ln);
char line[256];
if (len >= sizeof line) len = sizeof line - 1;
memcpy(line, ln, len);
line[len] = 0;
const char *p = line;
while (*p == ' ' || *p == '\t') p++;
return strstr(p, "a2dpsrc") != NULL || strstr(p, "a2dp_source") != NULL;
}
/* 从 `hcitool con` 输出收集**所有** ACL 连接的对端 BD_ADDR(按出现顺序去重)。
* 内核 ACL 是「设备是否真的连着」的权威(M45/M49 结论);bluealsa 的 list-pcms 只在
* profile 配置好后的**秒级窗口**里存在,不能当 MAC 的唯一来源(M50 真机实测)。
* ⚠️ 多条 ACL 是常态(手机/手柄/第二个耳机同时连着)——**不能只取第一条**:
* 取错设备会 snd_pcm_open 失败 → 有耳机却回落 DAC(M50 r1 评审 P2-3)。
* 行形如:`\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER AUTH ENCRYPT`
* 返回找到的条数(0=无 ACL 连接)。macs 是 [max][18] 的二维数组,元素写成 NUL 结尾
* 17 字符大写冒号 MAC。 */
int bt_dev_acl_macs(const char *hcitool_con, char (*macs)[18], int max)
{
if (!hcitool_con || !macs || max <= 0) return 0;
for (int i = 0; i < max; i++) macs[i][0] = 0;
int n = 0;
const char *ln = hcitool_con;
while (ln && *ln && n < max) {
const char *nl = strchr(ln, '\n');
const char *le = nl ? nl : (ln + strlen(ln));
size_t linelen = (size_t)(le - ln);
char line[256];
if (linelen >= sizeof line) linelen = sizeof line - 1;
memcpy(line, ln, linelen); line[linelen] = 0;
if (strstr(line, "ACL")) {
for (const char *p = line; *p && n < max; p++) {
int ok = 1;
for (int i = 0; i < 17 && ok; i++) {
if ((i % 3) == 2) { if (p[i] != ':') ok = 0; }
else {
char c = p[i];
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) ok = 0;
}
}
if (!ok ||
!(p == line || !(p[-1] == ':' || (p[-1] >= '0' && p[-1] <= '9') ||
(p[-1] >= 'A' && p[-1] <= 'F') || (p[-1] >= 'a' && p[-1] <= 'f'))) ||
(p[17] == ':' || (p[17] >= '0' && p[17] <= '9') ||
(p[17] >= 'A' && p[17] <= 'F') || (p[17] >= 'a' && p[17] <= 'f')))
continue;
char mac[18];
for (int i = 0; i < 17; i++) {
char c = p[i];
mac[i] = (c >= 'a' && c <= 'f') ? (char)(c - 'a' + 'A') : c;
}
mac[17] = 0;
int dup = 0; /* 同一设备可能在多行/多 profile 出现 */
for (int i = 0; i < n; i++) if (strcmp(macs[i], mac) == 0) { dup = 1; break; }
if (!dup) memcpy(macs[n++], mac, 18);
}
}
if (!nl) break;
ln = nl + 1;
}
return n;
}
/* 从 D-Bus 对象路径取 MAC。bluealsa 4.1.1 的 PCM 路径是
* /org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink
* (dev_%.2X_%.2X_%.2X_%.2X_%.2X_%.2X,见 src/ba-transport.c / src/shared/dbus-client.c)。
* 返回 0=解析出 17 字符 MAC(大写、冒号分隔)。 */
static int parse_dev_seg(const char *line, char *mac, size_t macsz)
{
if (!line || !mac || macsz < 18) return -1;
const char *d = strstr(line, "dev_");
if (!d) return -1;
const char *p = d + 4;
char out[18];
size_t o = 0;
for (int i = 0; i < 6; i++) {
unsigned v = 0;
int digits = 0;
for (; digits < 2; digits++) {
char c = *p;
int h = -1;
if (c >= '0' && c <= '9') h = c - '0';
else if (c >= 'A' && c <= 'F') h = c - 'A' + 10;
else if (c >= 'a' && c <= 'f') h = c - 'a' + 10;
if (h < 0) break;
v = v * 16u + (unsigned)h;
p++;
}
if (digits == 0) return -1;
o += (size_t)snprintf(out + o, sizeof out - o, (i == 0) ? "%02X" : ":%02X", v);
if (i < 5) {
if (*p != '_') return -1;
p++;
}
}
if (o != 17) return -1;
memcpy(mac, out, 18);
return 0;
}
/* 兜底:端点 token 的最后一段直接是 MAC(老式/简化输出 `a2dpsrc/AA:BB:...`)。 */
static int parse_tail_mac(const char *hit, char *mac, size_t macsz)
{
const char *te = hit;
while (*te && *te != ' ' && *te != '\t' && *te != '\n') te++;
const char *slash = NULL;
for (const char *q = hit; q < te; q++) if (*q == '/') slash = q;
if (!slash || (slash + 1) >= te) return -1;
const char *start = slash + 1;
size_t len = (size_t)(te - start);
if (len != 17 || len >= macsz) return -1;
int colons = 0;
for (size_t i = 0; i < len; i++) if (start[i] == ':') colons++;
if (colons != 5) return -1;
memcpy(mac, start, len);
mac[len] = 0;
return 0;
}
/* 解析 bluealsa-cli list-pcms 输出,取第一个 a2dpsrc/a2dp_source 端点的设备 MAC。 /* 解析 bluealsa-cli list-pcms 输出,取第一个 a2dpsrc/a2dp_source 端点的设备 MAC。
* 端点形如 `a2dpsrc/AA:BB:CC:DD:EE:FF`(MAC 在端点 token 内最后一个 '/' 之后到空白前)。 * 真实输出是 D-Bus 对象路径(每行一个):
* /org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink
* MAC 取自 `dev_XX_XX_XX_XX_XX_XX` 段(下划线 → 冒号),另兼容简化形式 `a2dpsrc/AA:BB:...`。
* 逐个**有界行**匹配(strstr 必须在行内,避免跨换行误把后一行当本行端点——如行 1 * 逐个**有界行**匹配(strstr 必须在行内,避免跨换行误把后一行当本行端点——如行 1
* "profile/aptx" 与行 3 "a2dp_source/..." 的经典误判)。 */ * "hfphf/source" 与行 2 "a2dpsrc/sink" 的经典误判)。 */
int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz) int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz)
{ {
if (!listpcms || !mac || macsz < 2) return -1; if (!listpcms || !mac || macsz < 2) return -1;
@@ -27,22 +156,42 @@ int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz)
memcpy(line, ln, linelen); line[linelen] = 0; memcpy(line, ln, linelen); line[linelen] = 0;
const char *p = line; const char *p = line;
while (*p == ' ' || *p == '\t') p++; while (*p == ' ' || *p == '\t') p++;
if (line_is_src(line)) {
if (parse_dev_seg(p, mac, macsz) == 0) return 0;
const char *hit = strstr(p, "a2dpsrc"); const char *hit = strstr(p, "a2dpsrc");
if (!hit) hit = strstr(p, "a2dp_source"); if (!hit) hit = strstr(p, "a2dp_source");
if (hit) { if (hit && parse_tail_mac(hit, mac, macsz) == 0) return 0;
const char *te = hit;
while (*te && *te != ' ' && *te != '\t' && *te != '\n') te++;
const char *slash = NULL;
for (const char *q = hit; q < te; q++) if (*q == '/') slash = q;
if (slash && (slash + 1) < te) {
const char *start = slash + 1;
size_t len = (size_t)(te - start);
if (len > 0 && len < macsz) {
memcpy(mac, start, len);
mac[len] = 0;
return 0;
} }
if (!nl) break;
ln = nl + 1;
} }
mac[0] = 0;
return -1;
}
/* 在 list-pcms 里按「设备 MAC」精确挑端点路径(A→B 换耳机后不能拿 A 的路径去发 volume/info)。
* 返回 0=找到(写到 out),-1=列表里没有该 MAC 的端点。 */
int bt_dev_pick_endpoint(const char *listpcms, const char *mac, char *out, size_t outsz)
{
if (!listpcms || !mac || !mac[0] || !out || outsz < 2) return -1;
out[0] = 0;
const char *ln = listpcms;
while (ln && *ln) {
const char *nl = strchr(ln, '\n');
const char *le = nl ? nl : (ln + strlen(ln));
size_t linelen = (size_t)(le - ln);
char line[256], lmac[18];
if (linelen >= sizeof line) linelen = sizeof line - 1;
memcpy(line, ln, linelen); line[linelen] = 0;
if (line_is_src(line) && parse_dev_seg(line, lmac, sizeof lmac) == 0 &&
strcasecmp(lmac, mac) == 0) {
const char *p = line;
while (*p == ' ' || *p == '\t') p++;
size_t n = 0;
while (p[n] && p[n] != ' ' && p[n] != '\t' && p[n] != '\n' && p[n] != '\r' &&
n + 1 < outsz) { out[n] = p[n]; n++; }
out[n] = 0;
return n ? 0 : -1;
} }
if (!nl) break; if (!nl) break;
ln = nl + 1; ln = nl + 1;
@@ -50,89 +199,120 @@ int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz)
return -1; return -1;
} }
/* 在单行内把 anchor("DEV=" / "device")后的 MAC 值替换为 mac,保留引号风格。 /* 关闭决策(纯函数,宿主可单测):当前「设备」该关谁。
* 行未含 anchor → 返回 1(out=原样);含 anchor → 写回新行并返回 0。 */ * bt_dev=1 → 关 BT PCM(句柄在 audiod 的 g_bt_hdl),**不碰 fd**;
static int line_replace_mac(char *out, size_t outsz, const char *line, * 否则 fd>2 才当设备 fd 关;**0/1/2 一律不动**——BT 打开成功时 dev_open() 返回 0,
const char *anchor, const char *mac) * 任何「提前清 bt_dev」的路径都会落到 close(g_dev_fd) 上误关 stdin(M50 r1 评审 P2-1)。
* 返回 0=NONE / 1=BT / 2=FD。 */
int bt_dev_close_kind(int bt_dev, int fd)
{ {
const char *at = strstr(line, anchor); if (bt_dev) return 1;
if (!at) { snprintf(out, outsz, "%s", line); return 1; } return fd > 2 ? 2 : 0;
const char *vp = at + strlen(anchor);
while (*vp == ' ' || *vp == '\t') vp++;
if (*vp == '"') vp++; /* 引号风格:值可带双引号 */
const char *ve = vp;
while (*ve && *ve != ' ' && *ve != '\t' && *ve != '\n' &&
*ve != '"' && *ve != '{' && *ve != '}') ve++;
size_t prefix = (size_t)(vp - line); /* 前段(含开引号,若带) */
size_t tail = (size_t)(ve - line); /* 值终点(不含闭引号,若带) */
/* 前段 + mac + 尾段(尾段保留闭引号与其后内容) */
snprintf(out, outsz, "%.*s%s%s", (int)prefix, line, mac, line + tail);
return 0;
} }
/* 幂等改写 alsa.conf:只把 `pcm.bluealsa` 块内 `DEV=` 与 `device` 后的 MAC 换成 mac。 /* 从 `bluealsa-cli info <path>` 输出取实际协商到的 codec("Selected codec: AAC" → "AAC")。
* 逐行处理;无 pcm.bluealsa / 无 DEV= / device token → -1;两端已一致 → 1;改了 → 0。 */ * 只认行首 "Selected codec:"("Available codecs: SBC AAC" 那行不能误匹配)。 */
int bt_dev_rewrite(const char *in, size_t in_len, const char *mac, int bt_dev_info_codec(const char *info_out, char *out, size_t outsz)
char **out, size_t *out_len)
{ {
if (!in || !mac || !mac[0] || !out || !out_len || in_len == 0) return -1; if (!info_out || !out || outsz < 2) return -1;
if (!strstr(in, "pcm.bluealsa") || out[0] = 0;
(!strstr(in, "DEV=") && !strstr(in, "device"))) return -1; const char *ln = info_out;
while (ln && *ln) {
size_t cap = in_len + 128; const char *nl = strchr(ln, '\n');
char *res = malloc(cap ? cap : 1); const char *p = ln;
if (!res) return -1; while (*p == ' ' || *p == '\t') p++;
size_t o = 0, changed = 0; if (strncmp(p, "Selected codec:", 15) == 0) {
const char *p = in, *end = in + in_len; p += 15;
while (*p == ' ' || *p == '\t') p++;
while (p < end) { size_t n = 0;
const char *nl = memchr(p, '\n', (size_t)(end - p)); while (p[n] && p[n] != '\n' && p[n] != '\r' && n + 1 < outsz) { out[n] = p[n]; n++; }
const char *le = nl ? nl : end; while (n > 0 && (out[n-1] == ' ' || out[n-1] == '\t')) n--;
size_t linelen = (size_t)(le - p); out[n] = 0;
char line[512], newline[512]; return n ? 0 : -1;
if (linelen >= sizeof line) linelen = sizeof line - 1;
memcpy(line, p, linelen); line[linelen] = 0;
const char *k = line;
while (*k == ' ' || *k == '\t') k++;
int anchored = 0;
if (strstr(k, "DEV=")) {
line_replace_mac(newline, sizeof newline, line, "DEV=", mac);
anchored = 1;
} else if (strncmp(k, "device", 6) == 0 &&
(k[6] == ' ' || k[6] == '\t' || k[6] == 0)) {
line_replace_mac(newline, sizeof newline, line, "device", mac);
anchored = 1;
}
if (anchored) {
if (strcmp(line, newline) != 0) changed = 1;
size_t n = strlen(newline);
if (o + n + 2 > cap) {
cap = o + n + 256;
char *t = realloc(res, cap);
if (!t) { free(res); return -1; }
res = t;
}
memcpy(res + o, newline, n); o += n;
if (nl) res[o++] = '\n';
} else {
size_t n = linelen;
if (o + n + 2 > cap) {
cap = o + n + 256;
char *t = realloc(res, cap);
if (!t) { free(res); return -1; }
res = t;
}
memcpy(res + o, p, n); o += n;
if (nl) res[o++] = '\n';
} }
if (!nl) break; if (!nl) break;
p = nl + 1; ln = nl + 1;
} }
return -1;
}
if (!changed) { free(res); return 1; } /* bluealsa 4.1.1 src/shared/a2dp-codecs.c 的 canonical 名与别名(strcasecmp 匹配)。
res[o] = 0; * 只列本机可达的音频 codec;auto/unchanged/未知 → unchanged(不干预协商)。 */
*out = res; static const struct {
*out_len = o; const char *canonical;
const char *aliases[4];
} codecs[] = {
{ "SBC", { "SBC", NULL } },
{ "MP3", { "MP3", "MPEG12", "MPEG", NULL } },
{ "AAC", { "AAC", "MPEG24", NULL } },
{ "ATRAC", { "ATRAC", NULL } },
{ "aptX", { "aptX", "apt-X", NULL } },
{ "aptX-AD", { "aptX-AD", "apt-X-AD", NULL } },
{ "aptX-HD", { "aptX-HD", "apt-X-HD", NULL } },
{ "aptX-LL", { "aptX-LL", "apt-X-LL", NULL } },
{ "aptX-TWS", { "aptX-TWS", "apt-X-TWS", NULL } },
{ "FastStream", { "FastStream", "FS", NULL } },
{ "LC3plus", { "LC3plus", NULL } },
{ "LDAC", { "LDAC", NULL } },
{ "LHDC", { "LHDC", NULL } },
};
const char *bt_dev_codec_token(const char *setting, char *out, size_t outsz)
{
const char *auto_tok = "unchanged";
if (!out || outsz < 2) return auto_tok;
if (!setting || !setting[0]) { snprintf(out, outsz, "%s", auto_tok); return out; }
if (!strcasecmp(setting, "auto") || !strcasecmp(setting, "unchanged")) {
snprintf(out, outsz, "%s", auto_tok);
return out;
}
for (size_t i = 0; i < sizeof codecs / sizeof codecs[0]; i++)
for (size_t n = 0; n < 4 && codecs[i].aliases[n]; n++)
if (!strcasecmp(codecs[i].aliases[n], setting)) {
snprintf(out, outsz, "%s", codecs[i].canonical);
return out;
}
snprintf(out, outsz, "%s", auto_tok); /* 未知值:不干预协商(不把垃圾塞进 PCM 名) */
return out;
}
/* MAC 合法性:6 组两位十六进制,'/' 或 ':' 分隔都不接受(只认冒号,bluealsa 约定)。 */
static int mac_valid(const char *mac)
{
if (!mac || strlen(mac) != 17) return 0;
for (int i = 0; i < 17; i++) {
if ((i % 3) == 2) { if (mac[i] != ':') return 0; }
else {
char c = mac[i];
int hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) return 0;
}
}
return 1;
}
int bt_dev_pcm_name(const char *mac, const char *codec_token, char *out, size_t outsz)
{
if (!out || outsz < 32 || !mac_valid(mac)) return -1;
const char *tok = (codec_token && codec_token[0]) ? codec_token : "unchanged";
int n = snprintf(out, outsz, "bluealsa:DEV=%s,PROFILE=a2dp,CODEC=%s", mac, tok);
if (n < 0 || (size_t)n >= outsz) { out[0] = 0; return -1; }
return 0; return 0;
} }
unsigned bt_dev_backoff_ms(unsigned fails)
{
if (fails == 0) return 0;
unsigned shift = fails - 1;
if (shift > 5) shift = 5;
unsigned long d = 2000UL << shift; /* 2s,4s,8s,16s,32s,64s */
if (d > 60000UL) d = 60000UL;
return (unsigned)d;
}
int bt_dev_should_try(unsigned fails, long long since_ms)
{
if (fails == 0) return 1;
if (since_ms < 0) return 1;
return since_ms >= (long long)bt_dev_backoff_ms(fails) ? 1 : 0;
}
+60 -16
View File
@@ -1,27 +1,71 @@
/* bt_dev.h — 蓝牙 A2DP 设备自识别(DEV 自动更新纯逻辑,宿主可单测)。 /* bt_dev.h — 蓝牙 A2DP 端点选择 + PCM 名构造 + 回落退避(纯逻辑,宿主可单测)。
* *
* 背景:/usr/data/alsa.conf 的 `pcm.bluealsa`(plug→bluealsa)需把 DEV/device 钉在当前 * 背景(M50 真机定案):`/usr/data/alsa.conf` **不是** alsa 配置(是 HiBy 播放器的数据文件,
* 连接的 A2DP sink 设备 MAC 上才能正确出声。本模块提供两段纯逻辑(无 meow_* / popen / 文件 * libasound 从不加载),改它对实际解析零影响。真正生效的是 alsa-lib 自带的
* I/O —— meow_audiod 负责读文件与执行系统命令): * `/etc/alsa/conf.d/20-bluealsa.conf`:
* - bt_dev_detect_mac:从 `bluealsa-cli list-pcms` 输出提取第一个 a2dpsrc 端点的设备 MAC; *
* - bt_dev_rewrite:幂等改写 alsa.conf 文本,把 `pcm.bluealsa` 块内 `DEV=<mac>` 与 * pcm.bluealsa {
* `device <mac>` 两处换成目标 MAC(保留引号风格 / codec / 其余 token)。 * @args [ DEV PROFILE CODEC VOL SOFTVOL DELAY SRV ] ← 只有这 7 个名字
* 纯 libc(string / stdlib),独立编译,宿主零链接重。 * ... type plug / slave.pcm { type bluealsa ... }
* }
*
* 其 `@args` 均可从 PCM 名内联传入(`snd_pcm_open("bluealsa:DEV=<mac>,PROFILE=a2dp,
* CODEC=<token>")`),未声明的参数会被 alsa-lib 拒绝(parse_args: Unknown parameter)——
* 所以本模块只生成这 7 个里的 3 个,且**不改任何文件**。
*
* 纯 libc(string/stdlib),无 meow_* / popen / 文件 I/O —— meow_audiod 负责探测与打开。
*/ */
#ifndef BT_DEV_H #ifndef BT_DEV_H
#define BT_DEV_H #define BT_DEV_H
#include <stddef.h> #include <stddef.h>
/* 解析 bluealsa-cli list-pcms 输出,取第一个 a2dpsrc/a2dp_source 端点的设备 MAC。 /* 从 `hcitool con` 输出收集**所有** ACL 连接的对端 BD_ADDR(按出现顺序去重;MAC 主来源:
* 返回 0=找到并写入 mac(NUL 结尾),-1=无 a2dpsrc 端点 / 参数非法。macsz 建议 ≥18。 */ * 内核 ACL 是连接权威,list-pcms 只在 profile 配置后的秒级窗口内存在)。
* 多设备是常态,调用方要**逐个试开**而不是只取第一条(M50 r1 评审 P2-3)。
* macs 为 [max][18];返回条数(0=无 ACL)。 */
int bt_dev_acl_macs(const char *hcitool_con, char (*macs)[18], int max);
/* 解析 bluealsa-cli list-pcms 输出,取第一个 a2dpsrc/a2dp_source 端点的设备 MAC
* (**兜底来源**:list-pcms 可见时用)。
* 真实行为 D-Bus 对象路径(bluealsa 4.1.1):
* /org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink
* 返回 0=找到并写入 mac(17 字符、大写冒号分隔,NUL 结尾),-1=无 a2dpsrc 端点 / 参数非法。
* macsz ≥ 18。 */
int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz); int bt_dev_detect_mac(const char *listpcms, char *mac, size_t macsz);
/* 幂等改写 alsa.conf 文本(in_len 不含 NUL):把 `pcm.bluealsa` 块内 `DEV=<mac>` 与
* `device <mac>` 两处 MAC 换成 mac(保留引号风格、codec/其它 token 原样)。 /* 在 list-pcms 里按设备 MAC 精确挑端点路径(换耳机 A→B 后不能拿 A 的路径发 volume/info)。
* 返回 0=已改动(*out 与 *out_len 给出新文本,调用方负责 free);1=无需改动(两端已一致); * 返回 0=找到,-1=该 MAC 无端点。 */
* -1=无 pcm.bluealsa / 无 DEV= 或 device token / 参数非法(不动)。 */ int bt_dev_pick_endpoint(const char *listpcms, const char *mac, char *out, size_t outsz);
int bt_dev_rewrite(const char *in, size_t in_len, const char *mac,
char **out, size_t *out_len); /* 关闭决策(纯函数):bt_dev=1 → 关 BT PCM;否则 fd>2 才关;0/1/2 一律不动(护 stdin)。
* 返回 0=NONE / 1=BT / 2=FD。 */
int bt_dev_close_kind(int bt_dev, int fd);
/* 从 `bluealsa-cli info` 输出取实际协商 codec:行首 "Selected codec: AAC" → "AAC"。
* 返回 0=找到,-1=无该行/参数非法。 */
int bt_dev_info_codec(const char *info_out, char *out, size_t outsz);
/* settings.conf `bt.codec` → bluealsa CODEC 参数 token。
* bluealsa 4.1.1 的 a2dp_codecs_codec_id_from_string() 用 strcasecmp 匹配 canonical 名与别名,
* 故大小写不敏感;本函数把它归一成 canonical 名(日志/STATE 用),未知值/auto/unchanged →
* "unchanged"(不改协商结果,等价 20-bluealsa.conf 的 defaults.bluealsa.codec)。
* 返回 out(调用方给的 buf)。outsz 建议 ≥16。 */
const char *bt_dev_codec_token(const char *setting, char *out, size_t outsz);
/* 组 snd_pcm_open 的 PCM 名:
* bluealsa:DEV=<mac>,PROFILE=a2dp,CODEC=<token>
* mac 非 6 组十六进制 → -1(不拼非法名字);token 为空 → 用 "unchanged"。
* 返回 0=写好(out NUL 结尾)。outsz 建议 ≥96。 */
int bt_dev_pcm_name(const char *mac, const char *codec_token, char *out, size_t outsz);
/* 回落退避:连续失败 fails 次后的重试间隔 ms(2s→4s→8s→16s→32s→60s 封顶;fails=0 → 0)。
* 防重试风暴(M10 教训):失败越多等越久,绝不每次 OPEN 都硬打一次 BT。 */
unsigned bt_dev_backoff_ms(unsigned fails);
/* 纯决策:此刻是否允许再尝试一次 BT。fails=0 → 允许;否则要求距上次失败 ≥ 退避间隔。
* since_ms<0(时钟异常)按「允许」处理,避免永久卡死。 */
int bt_dev_should_try(unsigned fails, long long since_ms);
#endif /* BT_DEV_H */ #endif /* BT_DEV_H */
+78 -12
View File
@@ -17,18 +17,50 @@
# ./build_audiod.sh # 交叉编译 → meow_audiod(mipsel glibc-dynamic,BT 直链 libasound) # ./build_audiod.sh # 交叉编译 → meow_audiod(mipsel glibc-dynamic,BT 直链 libasound)
# ./build_audiod.sh cli # 交叉编译 meow_audio CLI → meow_audio(mipsel musl-static) # ./build_audiod.sh cli # 交叉编译 meow_audio CLI → meow_audio(mipsel musl-static)
# ./build_audiod.sh host # 宿主编译 *_host 并跑 test_audiod_host 回归(FILE sink) # ./build_audiod.sh host # 宿主编译 *_host 并跑 test_audiod_host 回归(FILE sink)
#
# M50 蓝牙输出端到端:audiod 不再读写 /usr/data/alsa.conf(那是 HiBy 播放器的数据文件、语法非法、
# libasound 从不加载),改为 snd_pcm_open("bluealsa:DEV=<mac>,PROFILE=a2dp,CODEC=<token>")
# 内联参数(对应 /etc/alsa/conf.d/20-bluealsa.conf 的 pcm.bluealsa @args),失败必回落 DAC。
#
# 环境变量覆盖:ROOT / REPO 自动推导,ZIG / CC / LIBBT / ASOUND_SRC / CC_HOST 可显式指定。
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
ROOT="$HOME/Project/R3ProII" # ---- 路径解析(M50:全部可被环境变量覆盖,不再硬绑某台机器的目录布局)----
CC="$ROOT/.tools/zig-mipsel-cc" # ROOT 只用于可选的历史路径(旧 .tools/zig-env、hiby_os_crack 解包树);缺省布局下不依赖它。
ZIG="$ROOT/.tools/zig-env/bin/zig" ROOT="${ROOT:-$HOME/Project/R3ProII}"
INC="-I$ROOT/patch_v2/meow_launcherd" REPO="$(cd "$(dirname "$0")/../.." && pwd)" # 仓库根(worktree 亦可)
# mipsel 静态 CC:显式 CC → 历史 zig 包装脚本 → PATH 里的 zig(裸 zig 要另带 target flags)。
# ⚠️ CC **只存单个命令词**:`"$CC"` 是按一个命令名展开的,把 "zig cc --target=..." 整串存进去
# 会变成「找不到名叫 zig cc --target=... 的命令」(评审 P2-1,M51 在 worktree 里必踩)。
# 多词 flags 走 CC_EXTRA(未加引号展开,故意按空白分词),与下面 ZIG 的写法保持一致。
CC="${CC:-}"
CC_EXTRA=""
if [ -z "$CC" ]; then
if [ -x "$ROOT/.tools/zig-mipsel-cc" ]; then
CC="$ROOT/.tools/zig-mipsel-cc" # 历史包装脚本(内部已带 --target)
elif command -v zig >/dev/null 2>&1; then
CC="$(command -v zig)"
CC_EXTRA="cc --target=mipsel-linux-musleabi"
fi
fi
# 交叉编译用的 zig:显式 ZIG → 历史 zig-env → PATH。
ZIG="${ZIG:-}"
if [ -z "$ZIG" ]; then
if [ -x "$ROOT/.tools/zig-env/bin/zig" ]; then ZIG="$ROOT/.tools/zig-env/bin/zig"; else ZIG="$(command -v zig || true)"; fi
fi
INC="-I$REPO/patch_v2/meow_launcherd" # 共享协议头(相对仓库根,不再绑 ROOT)
# 注:$ZIG 只有 audiod 目标用;guard 放在 audiod 段之前(见下),host/cli 不因缺 zig 而中断。
if [ "$1" = "host" ]; then if [ "$1" = "host" ]; then
HOST_CC="${CC_HOST:-cc}" HOST_CC="${CC_HOST:-cc}"
# BT 目标直链宿主 libasound.so.2(无 asoundlib.h,原型内联于 meow_audiod.c;-l: 精确到版本, # BT 目标直链宿主 libasound(无 asoundlib.h,原型内联于 meow_audiod.c)。删 -ldl:BT 不再用 dlopen。
# 不依赖 unversioned dev 链接)。删 -ldl:BT 不再用 dlopen。 # 链接名先用「trivial 探针」决定:有 -l:libasound.so.2(精确版本)就用它,否则退 -lasound。
"$HOST_CC" $INC -Os -Wall -Wextra meow_audiod.c bt_dev.c -o meow_audiod_host -lpthread -l:libasound.so.2 # 用探针而**不是**「先编一次、失败再编一次 2>/dev/null」——后者会把成功路径的 warning 一起吞掉。
AS_LINK="-l:libasound.so.2"
if ! printf 'int main(void){return 0;}\n' | "$HOST_CC" -x c - -o /dev/null "$AS_LINK" 2>/dev/null; then
AS_LINK="-lasound"
fi
"$HOST_CC" $INC -Os -Wall -Wextra meow_audiod.c bt_dev.c -o meow_audiod_host -lpthread $AS_LINK
"$HOST_CC" $INC -Os -Wall -Wextra meow_audio.c -o meow_audio_host -lm "$HOST_CC" $INC -Os -Wall -Wextra meow_audio.c -o meow_audio_host -lm
"$HOST_CC" $INC -Os -Wall -Wextra test_audiod_host.c -o test_audiod_host -lm "$HOST_CC" $INC -Os -Wall -Wextra test_audiod_host.c -o test_audiod_host -lm
"$HOST_CC" -Os -Wall -Wextra test_bt_dev.c bt_dev.c -o test_bt_dev -lm "$HOST_CC" -Os -Wall -Wextra test_bt_dev.c bt_dev.c -o test_bt_dev -lm
@@ -43,7 +75,13 @@ if [ "$1" = "host" ]; then
fi fi
if [ "$1" = "cli" ]; then if [ "$1" = "cli" ]; then
"$CC" $INC -Os -Wall -Wextra -static meow_audio.c -o meow_audio -lm [ -n "$CC" ] || {
echo "!! mipsel CC 未找到:设置 CC=/path/to/zig 或 CC=/path/to/zig-mipsel-cc(或把 zig 放进 PATH)" >&2
exit 1; }
# M43: meow_audio 是 musl-static(当前 zig 链),与其它系统组件同源受损 → 链 time32 修
# clock_gettime/usleep(monitor 的 100ms 窗口/轮询)。
"$CC" $CC_EXTRA $INC -Os -Wall -Wextra -static -DMEOW_TIME32_LIBC_OVERRIDES \
meow_audio.c ../sdk/meow_time32.c -o meow_audio -lm
echo "== meow_audio (mipsel static CLI) ==" echo "== meow_audio (mipsel static CLI) =="
ls -la meow_audio ls -la meow_audio
md5sum meow_audio md5sum meow_audio
@@ -52,15 +90,43 @@ if [ "$1" = "cli" ]; then
fi fi
# meow_audiod:mipsel glibc-dynamic,BT 直链 libasound(-Llibbt -lasound)。-lpthread 供播放线程。 # meow_audiod:mipsel glibc-dynamic,BT 直链 libasound(-Llibbt -lasound)。-lpthread 供播放线程。
# 链接源 libbt/libasound.so.2.0.0(设备固件 libasound.so.2 副本);缺失时从固件镜像自拷贝(idempotent)。 # M43 例外:本目标走 glibc-2.22(time_t 32 位、timespec 8 字节)→ 无 musl time64 头/实现错位;
LIBBT="libbt" # 当前钉在 fw_content.conf 的出货件则是旧工具链静态 musl,其 4403→ENOSYS→4263+宽度转换本身健康。
ASOUND_SRC="$ROOT/hiby_os_crack/r3proii/squashfs-root-example/usr/lib/libasound.so.2.0.0" # 两种形态都不需要 meow_time32.c(其 __clock_* 接管针对 zig musl 内部符号)。musl-static 的
# meow_audio CLI 已链 time32。
# 链接源 libbt/libasound.so.2.0.0(设备固件 libasound.so.2 副本);缺失时从固件树自拷贝(idempotent)。
# M50:LIBBT / ASOUND_SRC 可被环境变量覆盖;ASOUND_SRC 候选按「显式 → 历史解包树 → 仓内固件基线」
# 顺序取第一个存在的(旧值 $ROOT/hiby_os_crack/... 在多数 checkout 里不存在,set -e 下会直接卡住构建)。
# MAIN = 主 checkout 根(在 git worktree 里运行时,devdocs/ 与 fw_apps/ 这类未入库的固件树
# 只在主 checkout 里;git-common-dir 指向 <main>/.git,其父目录即主 checkout)。普通 checkout 下 == REPO。
MAIN=""
_gcd="$(git rev-parse --git-common-dir 2>/dev/null)" && MAIN="$(cd "$_gcd/.." 2>/dev/null && pwd)" || MAIN=""
[ -n "$ZIG" ] || {
echo "!! zig not found(audiod 目标需要它):设置 ZIG=/path/to/zig 或把 zig 放进 PATH" >&2
exit 1; }
LIBBT="${LIBBT:-libbt}"
if [ -z "$ASOUND_SRC" ]; then
tried=""
for c in "$ROOT/hiby_os_crack/r3proii/squashfs-root-example/usr/lib/libasound.so.2.0.0" \
"$REPO/devdocs/fw-baseline/squashfs-root/usr/lib/libasound.so.2.0.0" \
"$REPO/fw_apps/build/squashfs-root/usr/lib/libasound.so.2.0.0" \
${MAIN:+"$MAIN/devdocs/fw-baseline/squashfs-root/usr/lib/libasound.so.2.0.0"} \
${MAIN:+"$MAIN/fw_apps/build/squashfs-root/usr/lib/libasound.so.2.0.0"}; do
[ -f "$c" ] && { ASOUND_SRC="$c"; break; }
tried="$tried
- $c"
done
fi
[ -n "$ASOUND_SRC" ] && [ -f "$ASOUND_SRC" ] || {
echo "!! libasound.so.2.0.0 not found. 试过这些路径:$tried" >&2
echo " 覆盖方式:ASOUND_SRC=/path/to/libasound.so.2.0.0 $0" >&2
exit 1; }
mkdir -p "$LIBBT" mkdir -p "$LIBBT"
if [ ! -f "$LIBBT/libasound.so.2.0.0" ]; then cp "$ASOUND_SRC" "$LIBBT/libasound.so.2.0.0"; fi if [ ! -f "$LIBBT/libasound.so.2.0.0" ]; then cp "$ASOUND_SRC" "$LIBBT/libasound.so.2.0.0"; fi
ln -sf libasound.so.2.0.0 "$LIBBT/libasound.so" ln -sf libasound.so.2.0.0 "$LIBBT/libasound.so"
ln -sf libasound.so.2.0.0 "$LIBBT/libasound.so.2" ln -sf libasound.so.2.0.0 "$LIBBT/libasound.so.2"
"$ZIG" cc --target=mipsel-linux-gnueabihf.2.22 $INC -Os -Wall -Wextra meow_audiod.c bt_dev.c -o meow_audiod \ "$ZIG" cc --target=mipsel-linux-gnueabihf.2.22 $INC -Os -Wall -Wextra meow_audiod.c bt_dev.c -o meow_audiod \
-lpthread -Llibbt -lasound -lpthread -L"$LIBBT" -lasound
echo "== meow_audiod (mipsel glibc-dynamic, BT links libasound.so.2) ==" echo "== meow_audiod (mipsel glibc-dynamic, BT links libasound.so.2) =="
ls -la meow_audiod ls -la meow_audiod
md5sum meow_audiod md5sum meow_audiod
Binary file not shown.
+413 -178
View File
@@ -8,9 +8,10 @@
* 核心设计(用户定案,勿推翻): * 核心设计(用户定案,勿推翻):
* - 独占 + 抢占(后开流赢,旧流收 MSG_AUDIO_PREEMPTED 并被置 draining,PCM 丢弃); * - 独占 + 抢占(后开流赢,旧流收 MSG_AUDIO_PREEMPTED 并被置 draining,PCM 丢弃);
* 软混音已否决(MIPS32 软 SRC 伤音质),留混音器接口位(v1 不实现)。 * 软混音已否决(MIPS32 软 SRC 伤音质),留混音器接口位(v1 不实现)。
* - 单进程双线程(+路由线程):IO 线程(accept/读帧/解析/ring 写)、播放线程 * - 单进程四线程:IO 线程(accept/读帧/解析/ring 写)、播放线程
* (独占 ALSA 直开,从 ring 取数写 DAC,xrun PREPARE 恢复)、路由线程 * (独占 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 * - 格式归一 = 协商:grant_rate/ch/fmt 回客户端应产出的格式(SDK 负责 mono→stereo
* 复制 / S16↔S32 定值升降位);audiod 只做「硬件最优」格式的字节管道。 * 复制 / S16↔S32 定值升降位);audiod 只做「硬件最优」格式的字节管道。
* - DAC 原生率跟随(无 SRC);切率 mute-重配-恢复。 * - DAC 原生率跟随(无 SRC);切率 mute-重配-恢复。
@@ -58,7 +59,7 @@
#include <sys/un.h> #include <sys/un.h>
#include <sys/wait.h> #include <sys/wait.h>
#include "meow_ipc.h" #include "meow_ipc.h"
#include "bt_dev.h" /* P2:DEV 自识别(a2dpsrc 端点 → MAC → alsa.conf 幂等改写) */ #include "bt_dev.h" /* M50:端点探测 / PCM 名构造 / 回落退避(纯逻辑,不改任何配置文件) */
#ifdef __linux__ #ifdef __linux__
#include <sound/asound.h> #include <sound/asound.h>
@@ -75,9 +76,12 @@
#define SETTINGS_CONF "/usr/data/meow/settings.conf" #define SETTINGS_CONF "/usr/data/meow/settings.conf"
#define DEFAULT_OUT_WAV "/tmp/meow_audiod.wav" #define DEFAULT_OUT_WAV "/tmp/meow_audiod.wav"
/* ---- 蓝牙 A2DP source(P2 输出目标 OUT_BT) ---- */ /* ---- 蓝牙 A2DP source(P2 输出目标 OUT_BT)----
#define BT_PCM_DEFAULT "pcm.bluealsa" /* alsa.conf `pcm.bluealsa`(plug→bluealsa);aplay -D bluealsa 等价 */ * M50 真机定案:**不写任何配置文件**。设备真正生效的是 alsa-lib 自带的
#define BT_PCM_FALLBACK "bluealsa" /* 部分配置只定义别名节点,防打开失败再试一次 */ * /etc/alsa/conf.d/20-bluealsa.conf 里的 `pcm.bluealsa`(type plug → slave type bluealsa,
* @args = DEV PROFILE CODEC VOL SOFTVOL DELAY SRV)。DEV/CODEC 直接从 PCM 名内联传入,
* 一次 snd_pcm_open 即完成「钉设备 + 钉 codec」,无需 DEV 自识别改写文件。
* ⚠️ /usr/data/alsa.conf 是 HiBy 播放器的数据文件、语法非法、libasound 从不加载——不读也不写。 */
#define BT_CODEC_AUTO "auto" #define BT_CODEC_AUTO "auto"
#define BT_VOL_FULL_PCM "/usr/bin/bluealsa-cli" /* 一键设 AVRCP 100%(best-effort,不存在则跳过) */ #define BT_VOL_FULL_PCM "/usr/bin/bluealsa-cli" /* 一键设 AVRCP 100%(best-effort,不存在则跳过) */
@@ -130,6 +134,7 @@ static void session_clean_locked(void);
static void _audio_mark_reconfig_needed(void); static void _audio_mark_reconfig_needed(void);
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_cond; static pthread_cond_t g_cond;
static pthread_cond_t g_vol_cond; /* 音量异步执行线程等待(CLOCK_MONOTONIC,main 中初始化) */
struct client { struct client {
int used; int used;
@@ -137,10 +142,23 @@ struct client {
char app_id[MEOW_APP_ID_MAX]; char app_id[MEOW_APP_ID_MAX];
uint32_t sid; uint32_t sid;
int state; /* CLS_* */ 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]; uint8_t rbuf[READ_BUF_MAX];
size_t rlen; size_t rlen;
}; };
static struct client g_cli[CLI_MAX]; 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 的唯一流) */ /* 当前授权流(喂养 DAC 的唯一流) */
struct stream_info { struct stream_info {
@@ -188,11 +206,27 @@ static int g_dev_dsd = 0; /* 当前打开设备为 DoP DSD(决
static int g_reconfig_pending = 0; static int g_reconfig_pending = 0;
/* ---- 蓝牙 A2DP source 运行时状态(g_mu 保护;仅 g_target==OUT_BT && g_sink==SINK_ALSA 时用) ---- /* ---- 蓝牙 A2DP source 运行时状态(g_mu 保护;仅 g_target==OUT_BT && g_sink==SINK_ALSA 时用) ----
* 直接调用 libasound 插件 PCM(pcm.bluealsa)。audiod 以动态 glibc 链接设备 libasound.so.2 * 直接调用 libasound 插件 PCM(见 bt_* 段注释),此处仅存 snd_pcm_t 不透明句柄。
* (见 bt_* 段注释),此处仅存 snd_pcm_t 不透明句柄。 */ *
* 失败状态机(M50 硬验收点:失败必回落 DAC,绝不整机静音):
* BT_ST_IDLE —— 尚未尝试(启动初值 / 刚切到 BT 未开流)
* BT_ST_OK —— snd_pcm_open 成功,音频走 A2DP
* BT_ST_FAIL —— 最近一次尝试失败(无端点/未连接/set_params 失败/写路径致命错),
* 音频已回落 DAC;重试按 bt_dev_backoff_ms() 指数退避(2s→60s),
* 且只在「开流/重配」边界触发,绝不在写循环里重试(防 M10 重试风暴)。 */
enum { BT_ST_IDLE = 0, BT_ST_OK = 1, BT_ST_FAIL = 2 };
static void *g_bt_hdl = NULL; /* snd_pcm_t *(libasound 不透明句柄) */ static void *g_bt_hdl = NULL; /* snd_pcm_t *(libasound 不透明句柄) */
static int g_bt_open = 0; /* BT PCM 已打开 */ static int g_bt_open = 0; /* BT PCM 已打开 */
static int g_bt_vol_full_done = 0; /* 已对 AVRCP 置 100%(幂等,一次即可) */ static int g_bt_vol_full_done = 0; /* 已对 AVRCP 置 100%(幂等,一次即可) */
static int g_bt_state = BT_ST_IDLE; /* BT_ST_*(见上) */
static unsigned g_bt_fail_n = 0; /* 连续失败次数(成功后归零) */
static long long g_bt_last_fail_ms = 0; /* 上次失败时刻(CLOCK_MONOTONIC ms) */
static char g_bt_mac[20] = ""; /* 已打开/最近探测到的 A2DP 端点 MAC */
static char g_bt_tok[16] = "unchanged"; /* 下发的 CODEC token(canonical) */
static char g_bt_used[16] = ""; /* bluealsa info 回报的实际协商 codec(AAC/SBC…) */
static char g_bt_pcm[128] = ""; /* 端点 D-Bus 路径(volume/info 用) */
static int g_bt_fail_req = 0; /* 写路径判死:请求播放线程下一拍重配(回落 DAC) */
static int g_bt_dev = 0; /* 当前「设备」是 BT PCM(dev_close 分流;g_mu 保护) */
/* ---- 数字域电平表(DAC 实时监控;CS43198 无模拟电平回读,播放线程看 PCM 样本) ---- /* ---- 数字域电平表(DAC 实时监控;CS43198 无模拟电平回读,播放线程看 PCM 样本) ----
* 播放线程对从 ring 取出的每块 PCM 做纯整数累加(归一到 16bit 域:S24/S32 >>16), * 播放线程对从 ring 取出的每块 PCM 做纯整数累加(归一到 16bit 域:S24/S32 >>16),
@@ -308,15 +342,16 @@ static int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payloa
} }
/* cond 有界等待(g_cond 为 CLOCK_MONOTONIC;调用方已持 g_mu)。 */ /* 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; struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts); clock_gettime(CLOCK_MONOTONIC, &ts);
ts.tv_sec += ms / 1000; ts.tv_sec += ms / 1000;
ts.tv_nsec += (long)(ms % 1000) * 1000000L; ts.tv_nsec += (long)(ms % 1000) * 1000000L;
if (ts.tv_nsec >= 1000000000L) { ts.tv_sec++; ts.tv_nsec -= 1000000000L; } 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 大小/位深 ---- */ /* ---- frame 大小/位深 ---- */
static int fmt_bps(int fmt) static int fmt_bps(int fmt)
@@ -362,10 +397,12 @@ static int target_grant_fmt(int native_fmt)
return AUDIO_FMT_S16; return AUDIO_FMT_S16;
} }
/* 音量控制归属(QUERY volctl=):dac=反转控制;avrcp=蓝牙 AVRCP 侧控。 */ /* 音量控制归属(QUERY volctl=):dac=反转控制;avrcp=蓝牙 AVRCP 侧控。
* BT 回落 DAC(bt=fail)时实际由 DAC 控,故报 dac——面板据此显示「蓝牙(失败)」。 */
static const char *volctl_name(void) static const char *volctl_name(void)
{ {
return (g_target == AUDIO_OUT_BT) ? "avrcp" : "dac"; if (g_target != AUDIO_OUT_BT) return "dac";
return (g_bt_state == BT_ST_FAIL) ? "dac" : "avrcp";
} }
/* 整数开平方(uint64→uint32,逐位法;替代软浮点 sqrt) */ /* 整数开平方(uint64→uint32,逐位法;替代软浮点 sqrt) */
@@ -844,7 +881,8 @@ static int settings_get_output_target(void)
} }
/* P2 蓝牙:读 settings.conf `bt.codec`(auto/SBC/AAC/aptX/LDAC…)→ g_bt_codec。缺省 auto。 /* P2 蓝牙:读 settings.conf `bt.codec`(auto/SBC/AAC/aptX/LDAC…)→ g_bt_codec。缺省 auto。
* codec 经 /usr/data/alsa.conf 的 `pcm.bluealsa` codec 参数生效(audiod BT 打开前 best-effort sync)。 */ * 生效路径(M50):bt_dev_codec_token() 归一成 bluealsa canonical 名后,作为 CODEC 参数内联进
* PCM 名(bluealsa:DEV=…,PROFILE=a2dp,CODEC=…);auto/未知 → unchanged(不干预协商)。 */
static const char *settings_get_bt_codec(void) static const char *settings_get_bt_codec(void)
{ {
char buf[24]; char buf[24];
@@ -909,135 +947,150 @@ const char *snd_strerror(int errnum);
static void bt_close(void); /* 前向:bt_write 判死路径要用 */ static void bt_close(void); /* 前向:bt_write 判死路径要用 */
static void bt_vol_full(void); /* 前向:bt_open 成功时促成 AVRCP 100% */ static void bt_vol_full(void); /* 前向:bt_open 成功时促成 AVRCP 100% */
static void bt_read_used_codec(void); /* 前向:bt_open 成功时读回实际协商 codec */
/* bt.codec 生效:bluealsa 的 codec 是 config-time 参数(/usr/data/alsa.conf `pcm.bluealsa` 块的 /* 单调时钟 ms(退避计时;CLOCK_MONOTONIC 不受系统时间跳变影响) */
* `codec "X"`)。audiod 在 BT 打开前做一次**幂等且外科式**的 token 替换:只把 bluealsa 块内的 static long long now_ms(void)
* `codec "..."` 改成 `codec "<g_bt_codec>"`(其余字符不动)。仅真机且文件可写时执行;
* 宿主/只读/找不到 codec token → 跳过(绝不破坏 alsa.conf——audiod 只调整既有 token,不新建结构)。 */
static void bt_apply_codec_conf(void)
{ {
if (!g_bt_codec[0] || !strcmp(g_bt_codec, BT_CODEC_AUTO) || !strcmp(g_bt_codec, "unchanged")) return; struct timespec ts;
const char *path = "/usr/data/alsa.conf"; clock_gettime(CLOCK_MONOTONIC, &ts);
/* 先探测是否已有 codec token:无则不动(避免误建/无权写) */ return (long long)ts.tv_sec * 1000 + (long long)(ts.tv_nsec / 1000000);
{
FILE *pf = fopen(path, "r");
if (!pf) return;
int hit = 0; char l[160];
while (fgets(l, sizeof l, pf)) {
char *k = l; while (*k==' '||*k=='\t') k++;
if (strncmp(k, "codec", 5) == 0) { hit = 1; break; }
}
fclose(pf);
if (!hit) return;
}
FILE *f = fopen(path, "r");
if (!f) return;
char *data = NULL; size_t len = 0;
fseek(f, 0, SEEK_END); long sz = ftell(f);
if (sz <= 0 || sz > 64 * 1024) { fclose(f); return; }
fseek(f, 0, SEEK_SET);
data = malloc((size_t)sz + 1);
if (!data) { fclose(f); return; }
len = fread(data, 1, (size_t)sz, f); data[len] = 0;
fclose(f);
char *out = malloc(len + 64);
if (!out) { free(data); return; }
out[0] = 0; size_t o = 0; int changed = 0;
char *save = NULL;
char *ln = strtok_r(data, "\n", &save);
while (ln) {
char *k = ln; while (*k==' '||*k=='\t') k++;
int is_codec = (strncmp(k, "codec", 5) == 0);
if (is_codec && !changed) {
int n = snprintf(out + o, len + 64 - o, "codec \"%s\"\n", g_bt_codec);
if (n > 0) o += (size_t)n;
changed = 1;
} else {
int n = snprintf(out + o, len + 64 - o, "%s\n", ln);
if (n > 0) o += (size_t)n;
}
ln = strtok_r(NULL, "\n", &save);
}
free(data);
if (changed) {
int fd = open(path, O_WRONLY | O_TRUNC, 0644);
if (fd >= 0) { if (write(fd, out, o) < 0) { } close(fd); logmsg("bt: codec conf %s -> %s", path, g_bt_codec); }
else logmsg("bt: codec conf %s not writable, skip", path);
}
free(out);
} }
/* P2 BT DEV 自识别:把 /usr/data/alsa.conf 的 `pcm.bluealsa` DEV/device 自动改为**当前连接的 /* 记录一次 BT 失败:状态置 FAIL(→ 回落 DAC)+ 失败计数递增(退避依据)+ 日志。
* A2DP sink 设备** MAC(bluealsa-cli list-pcms 的 a2dpsrc 端点)。幂等(设备未变不重写)、 * 只在「开流/重配」边界或写路径判死时调用;写循环内绝不重试(防 M10 重试风暴)。 */
* 无设备/失败跳过;只改 DEV/device 两处、保留 codec/其余 token。BT 打开前调用,实现 static void bt_note_fail(const char *why)
* 「连哪个设备就播哪个」。跟 bt_apply_codec_conf 同构(外科式 token 替换,绝不建结构)。 */
static void bt_apply_device_conf(void)
{ {
char mac[32] = ""; g_bt_state = BT_ST_FAIL;
g_bt_fail_n++;
g_bt_last_fail_ms = now_ms();
logmsg("bt: FAIL (%s) -> fallback DAC; fail#%u, next retry in %ums",
why ? why : "?", g_bt_fail_n, bt_dev_backoff_ms(g_bt_fail_n));
}
/* 成功:状态 OK、失败计数归零。 */
static void bt_note_ok(void)
{
g_bt_state = BT_ST_OK;
g_bt_fail_n = 0;
}
/* 收集「可以试着往它播」的设备 MAC 候选(按可信度排序,去重):
* ① list-pcms 里**已经存在端点**的设备 —— 那就是真的 A2DP 连接(最可信,窗口内命中时用它);
* ② `hcitool con` 的**全部** ACL 设备 —— 内核权威,覆盖「profile 还没配置好、端点还没出现」。
* ⚠️ 多条 ACL 是常态(手机/手柄/第二副耳机);只取第一条会挑错设备 → 耳机连着却回落 DAC
* (M50 r1 评审 P2-3)。候选交给 bt_try_open 逐个试开(上限 3 次尝试)。
* 返回候选数(0=当前没有可播的蓝牙设备)。 */
static int bt_collect_candidates(char (*cand)[18], int max)
{
if (!cand || max <= 0) return 0;
int n = 0;
char *o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null"); char *o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null");
if (!o) { logmsg("bt: dev conf skipped (no bluealsa-cli)"); return; } if (o && o[0]) {
if (bt_dev_detect_mac(o, mac, sizeof mac) != 0) { char m[18];
logmsg("bt: dev conf skipped (no a2dpsrc pcm)"); if (bt_dev_detect_mac(o, m, sizeof m) == 0) {
return; memcpy(cand[n++], m, 18);
logmsg("bt: candidate#%d %s (source=list-pcms endpoint)", n, m);
} }
logmsg("bt: current a2dp dev %s", mac);
const char *path = "/usr/data/alsa.conf";
FILE *f = fopen(path, "r");
if (!f) return;
fseek(f, 0, SEEK_END); long sz = ftell(f);
if (sz <= 0 || sz > 64 * 1024) { fclose(f); return; }
fseek(f, 0, SEEK_SET);
char *data = malloc((size_t)sz + 1);
if (!data) { fclose(f); return; }
size_t rl = fread(data, 1, (size_t)sz, f); data[rl] = 0;
fclose(f);
char *outdata = NULL; size_t outlen = 0;
int rc = bt_dev_rewrite(data, rl, mac, &outdata, &outlen);
free(data);
if (rc == 0) {
int fd = open(path, O_WRONLY | O_TRUNC, 0644);
if (fd >= 0) {
if (write(fd, outdata, outlen) < 0) { }
close(fd);
logmsg("bt: dev conf auto-updated %s -> %s", path, mac);
} else {
logmsg("bt: dev conf %s not writable, skip", path);
} }
} else if (rc == 1) { o = popen_read_tmo(1200, "hcitool con 2>/dev/null");
logmsg("bt: dev conf ok (already %s)", mac); if (o) {
} else { char macs[4][18];
logmsg("bt: dev conf skip (no pcm.bluealsa/no DEV token)"); int k = bt_dev_acl_macs(o, macs, 4);
for (int i = 0; i < k && n < max; i++) {
int dup = 0;
for (int j = 0; j < n; j++) if (strcmp(cand[j], macs[i]) == 0) { dup = 1; break; }
if (dup) continue;
memcpy(cand[n++], macs[i], 18);
logmsg("bt: candidate#%d %s (source=hcitool con ACL)", n, macs[i]);
} }
if (outdata) free(outdata); }
return n;
} }
/* BT 打开:libasound → snd_pcm_open(pcm.bluealsa/bluealsa, PLAYBACK, NONBLOCK) → /* 用当前 g_bt_mac 精确刷新端点路径(PCM 已打开后 bluealsa 端点对象才稳定)。
* set_params(S16_LE, RW, ch, rate, soft_resample, 100ms)。返回 0=ok。失败置 g_bt_open=0。 */ * **必须按 MAC 匹配**:换耳机 A→B 后若沿用 A 的路径,volume/info 会打到旧设备上
static int bt_open(void) * (M50 r1 评审 P2-2)。找不到本设备的端点就留空(宁可跳过 volume/info 也不串台)。 */
static void bt_refresh_endpoint(void)
{
g_bt_pcm[0] = 0;
char *o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null");
if (!o) return;
char path[128];
if (bt_dev_pick_endpoint(o, g_bt_mac, path, sizeof path) == 0)
snprintf(g_bt_pcm, sizeof g_bt_pcm, "%s", path);
else
logmsg("bt: endpoint for %s not listed yet (volume/info skipped)", g_bt_mac);
}
/* 按 PCM 名打开(libasound → NONBLOCK open + set_params S16/RW/ch/rate/soft_resample/100ms)。
* 返回 0=ok(g_bt_hdl/g_bt_open/g_dev_* 已置位),-1=失败(句柄已 close,未记账)。 */
static int bt_open_pcm(const char *name)
{ {
void *pcm = NULL; void *pcm = NULL;
const char *names[2] = { BT_PCM_DEFAULT, BT_PCM_FALLBACK }; int r = snd_pcm_open(&pcm, name, SND_PCM_STREAM_PLAYBACK, SND_PCM_NONBLOCK);
int r = -1; if (r != 0) {
for (int i = 0; i < 2; i++) { logmsg("bt: snd_pcm_open %s: %s", name, snd_strerror(r));
r = snd_pcm_open(&pcm, names[i], SND_PCM_STREAM_PLAYBACK, SND_PCM_NONBLOCK); return -1;
if (r == 0) { logmsg("bt: opened %s", names[i]); break; }
logmsg("bt: snd_pcm_open %s: %s", names[i], snd_strerror(r));
pcm = NULL;
} }
if (r != 0) return -1;
g_bt_hdl = pcm;
snd_pcm_nonblock(pcm, 1); snd_pcm_nonblock(pcm, 1);
unsigned rate = g_st.rate ? g_st.rate : 44100; unsigned rate = g_st.rate ? g_st.rate : 44100;
unsigned ch = g_st.ch ? g_st.ch : 2; unsigned ch = g_st.ch ? g_st.ch : 2;
r = snd_pcm_set_params(pcm, SND_PCM_FORMAT_S16_LE, SND_PCM_ACCESS_RW_INTERLEAVED, r = snd_pcm_set_params(pcm, SND_PCM_FORMAT_S16_LE, SND_PCM_ACCESS_RW_INTERLEAVED,
ch, rate, 1, 100000); ch, rate, 1, 100000);
if (r < 0) { if (r < 0) {
logmsg("bt: set_params %u/%u/S16: %s", rate, ch, snd_strerror(r)); logmsg("bt: set_params %u/%u/S16 (%s): %s", rate, ch, name, snd_strerror(r));
snd_pcm_close(pcm); g_bt_hdl = NULL; return -1; snd_pcm_close(pcm);
return -1;
} }
g_bt_hdl = pcm;
g_bt_open = 1; g_bt_open = 1;
logmsg("bt: PCM ready rate=%u ch=%u fmt=S16 codec=%s", rate, ch, g_bt_codec); g_dev_rate = rate; g_dev_ch = ch; g_dev_fmt = AUDIO_FMT_S16;
bt_vol_full(); /* best-effort AVRCP 100%(一次性;失败仅日志) */ logmsg("bt: PCM ready name=%s rate=%u ch=%u fmt=S16 codec=%s", name, rate, ch, g_bt_tok);
return 0;
}
/* 尝试把输出打开到 BT:退避门 → 端点探测(list-pcms)→ 内联 DEV/CODEC 打开 →
* 兜底裸 "bluealsa"(配置缺省 DEV 通配)→ 记账失败。
* 返回 0=BT 就绪(音频走 A2DP);-1=本次不可用(调用方负责回落 DAC)。
* ⚠️ 在 playback 线程持 g_mu 时执行(popen ≤1.5s + snd_pcm_open),所以只允许在开流/重配
* 边界调用;连续失败由退避门(2s→60s)挡住,不会形成重试风暴。 */
static int bt_try_open(void)
{
long long since = now_ms() - g_bt_last_fail_ms;
if (!bt_dev_should_try(g_bt_fail_n, since)) {
static unsigned s_skip = 0;
if ((s_skip++ % 16) == 0)
logmsg("bt: retry gated (fail#%u, need %ums, elapsed %lldms)",
g_bt_fail_n, bt_dev_backoff_ms(g_bt_fail_n), since);
return -1;
}
char cand[3][18];
int ncand = bt_collect_candidates(cand, 3);
if (ncand <= 0) { bt_note_fail("no BT device (no a2dp endpoint, no ACL)"); return -1; }
bt_dev_codec_token(g_bt_codec, g_bt_tok, sizeof g_bt_tok);
/* 3 次尝试、每次间隔 300ms(ACL 刚建立时 BlueZ 可能还没配置完 A2DP profile,等一拍就通)。
* 多候选时轮流试:1 个候选→A,A,A(=r2 语义);2 个→A,B,A;3 个→A,B,C。
* 候选都来自「真的连着」的集合,每次尝试都有意义;总耗时上限 ≈600ms + open 时延。
* **不再用裸 "bluealsa" 兜底**:那会落到「最近连接」的设备上,多设备时等于挑错人。 */
const char *opened = NULL;
for (int i = 0; i < 3 && !opened; i++) {
const char *mac = cand[i % ncand];
if (i) { struct timespec ts = { 0, 300 * 1000000L }; nanosleep(&ts, NULL); }
char name[96];
if (bt_dev_pcm_name(mac, g_bt_tok, name, sizeof name) != 0) continue;
logmsg("bt: try#%d open %s (codec setting=%s -> token=%s)", i + 1, name, g_bt_codec, g_bt_tok);
if (bt_open_pcm(name) == 0) {
opened = name;
snprintf(g_bt_mac, sizeof g_bt_mac, "%s", mac);
}
}
if (!opened) { bt_note_fail("snd_pcm_open/set_params failed (3 tries)"); return -1; }
bt_note_ok();
bt_refresh_endpoint(); /* 端点对象此时稳定;按 MAC 精确取路径(换设备不串台) */
bt_read_used_codec();
g_bt_vol_full_done = 0; /* 新设备/PCM:允许再促成一次 AVRCP 满量程 */
bt_vol_full();
return 0; return 0;
} }
@@ -1068,13 +1121,18 @@ static void bt_write(uint8_t *buf, size_t n)
if (pr == 0) { /* 无空间超时 → 停摆计数(镜像 DAC stall) */ if (pr == 0) { /* 无空间超时 → 停摆计数(镜像 DAC stall) */
g_stall++; stalls++; g_stall++; stalls++;
if (stalls >= ALSA_STALL_MAX) { if (stalls >= ALSA_STALL_MAX) {
logmsg("bt WEDGED: %d consecutive waits, close + drop until next OPEN", stalls); logmsg("bt WEDGED: %d consecutive waits, close + fallback DAC", stalls);
bt_close(); g_bt_open = 0; g_dev_open = 0; bt_close(); g_bt_open = 0; g_dev_open = 0;
g_bt_fail_req = 1; /* 播放线程下一拍重配 → 回落 DAC(不静音) */
g_played_frames += n / fs; return; g_played_frames += n / fs; return;
} }
continue; continue;
} }
if (pr < 0 && errno != EINTR) { logmsg("bt wait err: %s", strerror(errno)); break; } if (pr < 0 && errno != EINTR) {
logmsg("bt wait err: %s -> fallback DAC", strerror(errno));
bt_close(); g_bt_open = 0; g_dev_open = 0; g_bt_fail_req = 1;
break;
}
continue; continue;
} }
if (err == EPIPE || err == ESTRPIPE) { /* xrun:recover 后继续 */ if (err == EPIPE || err == ESTRPIPE) { /* xrun:recover 后继续 */
@@ -1083,7 +1141,10 @@ static void bt_write(uint8_t *buf, size_t n)
snd_pcm_recover(g_bt_hdl, err, 1); snd_pcm_recover(g_bt_hdl, err, 1);
continue; continue;
} }
logmsg("bt writei err: %s", snd_strerror(err)); /* 端点消失/设备断开(ENODEV/ENXIO/EIO/ESHUTDOWN…):关 PCM 并请求回落 DAC。
* **不在这里重试**(写循环内重试 = M10 重试风暴);退避重试在下次开流/重配边界。 */
logmsg("bt writei err: %s -> fallback DAC", snd_strerror(err));
bt_close(); g_bt_open = 0; g_dev_open = 0; g_bt_fail_req = 1;
break; break;
} }
if (w == 0) break; if (w == 0) break;
@@ -1103,44 +1164,78 @@ static void bt_close(void)
} }
/* 音量隔离(PLAN §五):路由=BT 时**不下发 DAC 音量反转**(音量由 AVRCP/耳机侧控)。 /* 音量隔离(PLAN §五):路由=BT 时**不下发 DAC 音量反转**(音量由 AVRCP/耳机侧控)。
* 仅记录 DAC 音量(g_vol_pct 语义=本地 DAC 音量);BT 侧 best-effort 促成 AVRCP 100% * BT 侧 best-effort 促成 AVRCP 最大音量(127 = bluealsa-cli volume 的满量程 [0,127];
* (避免"窃听音量误解"——耳机侧音量钉到最响,设备侧滑块即绝对控制)。 */ * 旧代码发 100 只到 79%,耳机偏轻的隐性原因之一),设备侧滑块即绝对控制。
* PCM-PATH 取 list-pcms 的整行(D-Bus 对象路径)——bluealsa-cli 要的就是它,不是 MAC。 */
static void bt_vol_full(void) static void bt_vol_full(void)
{ {
if (g_bt_vol_full_done) return; if (g_bt_vol_full_done) return;
g_bt_vol_full_done = 1; g_bt_vol_full_done = 1;
char *o = popen_read_tmo(1500, "bluealsa-cli list-pcms 2>/dev/null"); /* 只用 bt_refresh_endpoint() 按 MAC 精确取到的路径;为空就**跳过**——
if (!o) { logmsg("bt: avrcp set-100 skipped (no bluealsa-cli)"); return; } * 宁可这一次不顶音量,也不拿别的设备的端点发 volume(换耳机 A→B 串台,评审 P2-2)。 */
char pcm[128] = ""; char pcm[128];
const char *ln = o; snprintf(pcm, sizeof pcm, "%s", g_bt_pcm);
while (ln && *ln) { if (!pcm[0]) { logmsg("bt: avrcp set-max skipped (no endpoint path for %s)", g_bt_mac); return; }
const char *nl = strchr(ln, '\n');
const char *p = ln;
while (*p == ' ' || *p == '\t') p++;
if (strstr(p, "a2dpsrc") || strstr(p, "a2dp_source")) {
const char *tok = p;
while (*tok && *tok != ' ' && *tok != '\t') tok++;
size_t tlen = (size_t)(tok - p);
if (tlen && tlen < sizeof pcm) { memcpy(pcm, p, tlen); pcm[tlen] = 0; }
break;
}
if (!nl) break;
ln = nl + 1;
}
if (!pcm[0]) { logmsg("bt: avrcp set-100 skipped (no a2dpsrc pcm found)"); return; }
char cmd[256]; char cmd[256];
snprintf(cmd, sizeof cmd, "%s volume %s 100 >/dev/null 2>&1", BT_VOL_FULL_PCM, pcm); snprintf(cmd, sizeof cmd, "%s volume %s 127 >/dev/null 2>&1", BT_VOL_FULL_PCM, pcm);
char *r = popen_read_tmo(1500, cmd); char *r = popen_read_tmo(1500, cmd);
logmsg("bt: avrcp volume set 100%% (%s)%s", pcm, r ? " ok" : " (exec failed; AVRCP still controls)"); logmsg("bt: avrcp volume set max(127) (%s)%s", pcm, r ? " ok" : " (exec failed; AVRCP still controls)");
} }
/* 音量应用(含 BT 隔离):BT → 只通知 AVRCP(best-effort),绝不动 DAC numid=1/2、不持久化 /* 实际输出是否真的在蓝牙上(BT_ST_OK)。STATE/路由/音量都以「实际」而非「请求」为准判断,
* audio.volume(那是 DAC 音量键);DAC → 原感知锥度反转 + 持久化。 */ * 但 target= 仍报用户请求值(面板据此显示「输出=蓝牙(失败)」)。 */
static void vol_apply(int pct) static int bt_effective_bt(void)
{
return g_target == AUDIO_OUT_BT && g_sink == SINK_ALSA && g_bt_state == BT_ST_OK;
}
/* 请求了 BT 但当前回落 DAC(STATE 的 bt=fail;此时 DAC 音量/路由语义生效)。 */
static int bt_fallback_active(void)
{
return g_target == AUDIO_OUT_BT && g_bt_state == BT_ST_FAIL;
}
static const char *bt_state_name(void)
{
switch (g_bt_state) {
case BT_ST_OK: return "ok";
case BT_ST_FAIL: return "fail";
default: return "idle";
}
}
/* 读回 bluealsa 实际协商到的 codec(`bluealsa-cli info <端点路径>` → "Selected codec:")。
* 为什么需要:耳机不一定支持 settings 里点名的 codec——真机 MOONDROP Pudding 只有 SBC/AAC,
* 我们请求 LDAC 时 bluealsa 接受参数但实际选中 AAC。STATE 报实际值(btacodec=)才不撒谎。
* 失败仅日志(best-effort,不影响播放)。 */
static void bt_read_used_codec(void)
{
g_bt_used[0] = 0;
if (!g_bt_pcm[0]) return;
char cmd[256];
snprintf(cmd, sizeof cmd, "%s info %s 2>/dev/null", BT_VOL_FULL_PCM, g_bt_pcm);
char *o = popen_read_tmo(1500, cmd);
if (!o) return;
char codec[16];
if (bt_dev_info_codec(o, codec, sizeof codec) == 0) {
snprintf(g_bt_used, sizeof g_bt_used, "%s", codec);
if (strcasecmp(codec, g_bt_tok) != 0)
logmsg("bt: codec requested=%s but bluealsa selected=%s (device codec set)", g_bt_tok, codec);
else
logmsg("bt: codec %s confirmed by bluealsa", codec);
}
}
/* 音量应用(含 BT 隔离):BT(真实走 A2DP)→ 只通知 AVRCP(best-effort),绝不动 DAC numid=1/2、
* 不持久化 audio.volume(那是 DAC 音量键);DAC 及「BT 回落 DAC」→ 原感知锥度反转 + 持久化
* (回落时声音实际从 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 < 0) pct = 0;
if (pct > 100) pct = 100; if (pct > 100) pct = 100;
if (g_target == AUDIO_OUT_BT) { if (target == AUDIO_OUT_BT && g_bt_state != BT_ST_FAIL) {
logmsg("vol -> %d%% (BT/AVRCP, DAC numid=1/2 NOT written)", pct); logmsg("vol -> %d%% (BT/AVRCP, DAC numid=1/2 NOT written)", pct);
return; return;
} }
@@ -1158,6 +1253,10 @@ static void target_set(int target)
if (g_target == target) { logmsg("output: target already %d", target); return; } if (g_target == target) { logmsg("output: target already %d", target); return; }
logmsg("output: target %d -> %d (%s)", g_target, target, target == AUDIO_OUT_BT ? "BT" : "DAC"); logmsg("output: target %d -> %d (%s)", g_target, target, target == AUDIO_OUT_BT ? "BT" : "DAC");
g_target = target; g_target = target;
/* 用户显式切目标:清 BT 退避(切 BT 时立刻尝试一次;仍失败就按退避回落,不死缠蓝牙) */
g_bt_fail_n = 0;
g_bt_last_fail_ms = 0;
g_bt_state = BT_ST_IDLE;
if (g_st.active) g_reconfig_pending = 1; if (g_st.active) g_reconfig_pending = 1;
pthread_cond_signal(&g_cond); pthread_cond_signal(&g_cond);
} }
@@ -1284,25 +1383,62 @@ static void wav_header(int fd, int rate, int ch, int bits, uint32_t data_len)
/* ---- 播放线程设备管理(dev_open/close/write 皆在播放线程调用) ---- */ /* ---- 播放线程设备管理(dev_open/close/write 皆在播放线程调用) ---- */
static void dev_close(void); /* 前向:dev_write 的 wedge 判死路径要用 */ static void dev_close(void); /* 前向:dev_write 的 wedge 判死路径要用 */
static int dev_open(void) /* DAC 回落前的端口武装(复制 route_thread 的 boot-arm 语义,但同步执行)。
* 为什么必须同步:hbc3000 FPGA 未烧录 + enable 时开 PCM 会在第二次 hbc3000_start 死锁整机
* (M2.91 事故)。BT→DAC 回落是**流内**切换,不能等路由线程 1s 节拍,否则 PCM 可能先开。 */
static void dac_fallback_arm(void)
{ {
/* 输出目标=BT 且走真机 ALSA:打开 libasound 的 pcm.bluealsa(不开 hw:0,0)。 int hb = read_switch("/sys/class/switch/headset/state");
* 宿主即 FILE/NULL sink 时不走此支——因此宿主可测「目标选择/音量隔离/格式归一」逻辑, int bal = read_switch("/sys/class/switch/balance/state");
* 真实 bluealsa 传输留给真机(见 bt_* 注释)。失败 → 降级丢帧(自感知兜底 §五)。 */ int port = (bal == 1) ? 3 : (hb == 1) ? 2 : 4;
if (g_target == AUDIO_OUT_BT && g_sink == SINK_ALSA) { int cur = amixer_get(9, 1);
bt_apply_device_conf(); /* P2:DEV 幂等自更新到当前连接 A2DP 设备(连哪台播哪台) */ if (cur == port) {
bt_apply_codec_conf(); g_route_cur = port;
if (bt_open() == 0) return 0; /* g_bt_open=1;返回 0=逻辑已开(真实句柄在 g_bt_hdl) */ logmsg("bt: fallback arm skipped (numid=9 already %d)", port);
logmsg("bt: open failed (degrade: drop PCM until next OPEN)"); return;
}
logmsg("bt: fallback arm numid=9 -> %d (was %d)", port, cur);
apply_route(port);
}
/* 把设备 fd 升到 ≥3:BT 打开成功时 dev_open() 返回 0,而 0/1/2 是 stdio——把设备 fd
* 钉在 0..2 上等于给「谁误清了 g_bt_dev」埋地雷(dev_close 的 close_kind 守卫会跳过 0..2)。
* open() 只在 stdio 被关掉时才可能返回 <3;这里 dup 一次让「设备 fd 恒 >2」成为不变式。
* dup 也失败(极端 EMFILE)时把原 fd 关掉并报失败——绝不留一个「在 stdio 区间又没人认领」
* 的设备 fd(那会变成打开的设备泄漏,比关一次 stdin 更糟)。 */
static int fd_high(int fd)
{
if (fd < 0 || fd > 2) return fd;
int nfd = fcntl(fd, F_DUPFD, 3);
if (nfd < 0) {
logmsg("fd %d in stdio range and dup failed: %s -> treat as open failure", fd, strerror(errno));
close(fd);
return -1; return -1;
} }
close(fd);
return nfd;
}
static int dev_open(void)
{
/* 输出目标=BT 且走真机 ALSA:打开 libasound 的 bluealsa PCM(内联 DEV/CODEC,不开 hw:0,0)。
* 宿主即 FILE/NULL sink 时不走此支——因此宿主可测「目标选择/音量隔离/格式归一」逻辑。
* **失败必回落 DAC**(M50 硬验收:没有 A2DP 端点时整机不许静音;同时区间由 bt_note_fail
* 记账退避,STATE 报 bt=fail 供面板显示「输出=蓝牙(失败)」)。 */
if (g_target == AUDIO_OUT_BT && g_sink == SINK_ALSA) {
if (bt_try_open() == 0) { g_bt_dev = 1; return 0; } /* g_bt_open=1(真实句柄在 g_bt_hdl) */
logmsg("bt: open unavailable -> DAC fallback (audio continues)");
dac_fallback_arm();
/* 落到下面的 DAC 打开路径(grant fmt 已是 S16,DAC 支持) */
}
g_bt_dev = 0;
uint32_t rate = g_st.rate, ch = g_st.ch, fmt = g_st.fmt; uint32_t rate = g_st.rate, ch = g_st.ch, fmt = g_st.fmt;
int bits = fmt_bps((int)fmt) * 8; int bits = fmt_bps((int)fmt) * 8;
if (g_sink == SINK_ALSA) { if (g_sink == SINK_ALSA) {
#ifdef AUDIOD_HAVE_ALSA #ifdef AUDIOD_HAVE_ALSA
/* O_NONBLOCK:写路径走 poll(POLLOUT)+限时(dev_write),write 本身永不睡眠—— /* O_NONBLOCK:写路径走 poll(POLLOUT)+限时(dev_write),write 本身永不睡眠——
* 驱动 wedge 时绝不能把播放线程永久压进内核(真机长流卡死根因防线)。 */ * 驱动 wedge 时绝不能把播放线程永久压进内核(真机长流卡死根因防线)。 */
int fd = open(DEV_PCM, O_RDWR | O_NONBLOCK); int fd = fd_high(open(DEV_PCM, O_RDWR | O_NONBLOCK));
if (fd < 0) { logmsg("dev open %s: %s", DEV_PCM, strerror(errno)); return -1; } if (fd < 0) { logmsg("dev open %s: %s", DEV_PCM, strerror(errno)); return -1; }
if (alsa_hw_params(fd, rate, ch, alsa_fmt_to_snd((int)fmt)) != 0) { if (alsa_hw_params(fd, rate, ch, alsa_fmt_to_snd((int)fmt)) != 0) {
logmsg("dev hw_params %u/%u/%s failed", (unsigned)rate, (unsigned)ch, fmt_name((int)fmt)); logmsg("dev hw_params %u/%u/%s failed", (unsigned)rate, (unsigned)ch, fmt_name((int)fmt));
@@ -1316,7 +1452,7 @@ static int dev_open(void)
#endif #endif
} }
if (g_sink == SINK_FILE) { if (g_sink == SINK_FILE) {
int fd = open(g_out_wav, O_WRONLY | O_CREAT | O_TRUNC, 0644); int fd = fd_high(open(g_out_wav, O_WRONLY | O_CREAT | O_TRUNC, 0644));
if (fd < 0) { logmsg("dev open wav %s: %s", g_out_wav, strerror(errno)); return -1; } if (fd < 0) { logmsg("dev open wav %s: %s", g_out_wav, strerror(errno)); return -1; }
wav_header(fd, (int)rate, (int)ch, bits, 0); wav_header(fd, (int)rate, (int)ch, bits, 0);
return fd; return fd;
@@ -1344,7 +1480,7 @@ static void dev_write(uint8_t *buf, size_t n)
} }
g_dev_bytes += n; g_dev_bytes += n;
} }
if (g_target == AUDIO_OUT_BT && g_sink == SINK_ALSA) { bt_write(buf, n); return; } if (bt_effective_bt()) { bt_write(buf, n); return; }
if (g_sink == SINK_NULL) { g_played_frames += n / (size_t)frame_size(); return; } if (g_sink == SINK_NULL) { g_played_frames += n / (size_t)frame_size(); return; }
if (g_sink == SINK_ALSA) { if (g_sink == SINK_ALSA) {
#ifdef AUDIOD_HAVE_ALSA #ifdef AUDIOD_HAVE_ALSA
@@ -1410,7 +1546,15 @@ static void dev_write(uint8_t *buf, size_t n)
static void dev_close(void) static void dev_close(void)
{ {
if (g_bt_open) { bt_close(); g_dev_fd = -1; g_dev_open = 0; return; } /* 关设备的决策交给 bt_dev_close_kind()(纯函数,宿主可单测):
* - **BT 分支内自行清理**:认的是 g_bt_dev(不是 g_bt_open——写路径判死时 bt_write 已
* bt_close(),g_bt_open 已是 0),进来后把 g_bt_dev/g_dev_fd 一起复位;
* - ⚠️ **调用方不得提前清 g_bt_dev**:BT 打开成功时 dev_open() 返回 0,谁提前清 0,
* 这里就会落到下面 close(g_dev_fd) 分支上误关 stdin(M50 r1 评审 P2-1 就是这么复现的);
* - 非 BT 分支只关 fd>2 的真 fd(0/1/2 一律不动,第二道保险)。 */
int kind = bt_dev_close_kind(g_bt_dev, g_dev_fd);
if (kind == 1) { bt_close(); g_bt_dev = 0; g_dev_fd = -1; g_dev_open = 0; return; }
if (kind == 0) { g_dev_fd = -1; g_dev_open = 0; return; }
if (g_sink == SINK_FILE && g_dev_fd >= 0 && g_dev_fd != -2) { if (g_sink == SINK_FILE && g_dev_fd >= 0 && g_dev_fd != -2) {
off_t end = lseek(g_dev_fd, 0, SEEK_END); off_t end = lseek(g_dev_fd, 0, SEEK_END);
if (end > 44) { if (end > 44) {
@@ -1466,7 +1610,8 @@ static void reconfigure_locked(void)
g_dev_rate = rate; g_dev_ch = ch; g_dev_fmt = fmt; g_dev_rate = rate; g_dev_ch = ch; g_dev_fmt = fmt;
if (g_dev_fd == -2) g_dev_fd = -1; /* NULL sink:无真实 fd */ if (g_dev_fd == -2) g_dev_fd = -1; /* NULL sink:无真实 fd */
g_dev_dsd = (do_dsd_seq && new_dsd); g_dev_dsd = (do_dsd_seq && new_dsd);
if (g_target == AUDIO_OUT_DAC && g_sink == SINK_ALSA) dac_vol_set(saved_vol); /* 复原(含 DSD_VOLUME) */ if ((g_target == AUDIO_OUT_DAC || bt_fallback_active()) && g_sink == SINK_ALSA)
dac_vol_set(saved_vol); /* 复原(含 DSD_VOLUME);BT 回落 DAC 时同样要落 DAC 音量 */
logmsg("device open ok: %u/%u/%s sink=%d target=%s%s", (unsigned)rate, (unsigned)ch, logmsg("device open ok: %u/%u/%s sink=%d target=%s%s", (unsigned)rate, (unsigned)ch,
fmt_name((int)fmt), g_sink, tgt, g_dev_dsd ? " dsd" : ""); fmt_name((int)fmt), g_sink, tgt, g_dev_dsd ? " dsd" : "");
} else { } else {
@@ -1523,6 +1668,22 @@ static void *playback_thread(void *arg)
pthread_mutex_unlock(&g_mu); pthread_mutex_unlock(&g_mu);
if (n == 0) continue; if (n == 0) continue;
dev_write(buf, n); dev_write(buf, n);
/* BT 写路径判死(端点消失/耳机断电/wedge)→ 下一拍重配回落 DAC。
* 记账/状态在持锁下做;退避门保证这次重配不会立刻再打一次 BT(不形成重试风暴)。 */
if (g_bt_fail_req) {
pthread_mutex_lock(&g_mu);
if (g_bt_fail_req) {
g_bt_fail_req = 0;
/* **不要**在这里清 g_bt_dev:dev_close() 靠它认出「当前设备是 BT PCM」并走
* BT 分支清理;提前清 0 会让它落到 close(g_dev_fd),而 BT 打开时 g_dev_fd==0
* →误关 stdin(M50 r1 评审 P2-1)。g_dev_fd 同时置 -1 做第二道保险。 */
g_dev_fd = -1;
bt_note_fail("write path fatal (endpoint gone?)");
g_reconfig_pending = 1;
pthread_cond_signal(&g_cond);
}
pthread_mutex_unlock(&g_mu);
}
} }
pthread_mutex_lock(&g_mu); pthread_mutex_lock(&g_mu);
if (g_dev_open) dev_close(); if (g_dev_open) dev_close();
@@ -1551,6 +1712,7 @@ static struct client *cli_alloc(int fd)
freec->fd = fd; freec->fd = fd;
freec->state = CLS_OPEN; freec->state = CLS_OPEN;
freec->sid = 0; freec->sid = 0;
freec->gen = ++g_cli_gen; /* 代际递增:音量异步 ACK 校验用,防槽复用误发 */
return freec; return freec;
} }
static void cli_free(struct client *c) static void cli_free(struct client *c)
@@ -1687,9 +1849,9 @@ static void dispatch(struct client *c, uint16_t type, uint32_t req_id, const uin
a.grant_fmt = g_st.fmt; a.grant_fmt = g_st.fmt;
a.err = MEOW_OK; a.err = MEOW_OK;
send_frame(c->fd, MSG_AUDIO_OPEN_ACK, req_id, &a, sizeof a); send_frame(c->fd, MSG_AUDIO_OPEN_ACK, req_id, &a, sizeof a);
logmsg("OPEN grant: sid=%u app=%s rate=%u ch=%u fmt=%s target=%s codec=%s", logmsg("OPEN grant: sid=%u app=%s rate=%u ch=%u fmt=%s target=%s bt=%s codec=%s",
(unsigned)sid, g_st.app_id, (unsigned)o.rate, (unsigned)g_st.ch, (unsigned)sid, g_st.app_id, (unsigned)o.rate, (unsigned)g_st.ch,
fmt_name((int)g_st.fmt), target_name(), g_bt_codec); fmt_name((int)g_st.fmt), target_name(), bt_state_name(), g_bt_codec);
break; break;
} }
case MSG_AUDIO_WRITE: { case MSG_AUDIO_WRITE: {
@@ -1730,15 +1892,19 @@ static void dispatch(struct client *c, uint16_t type, uint32_t req_id, const uin
} }
case MSG_AUDIO_QUERY: { case MSG_AUDIO_QUERY: {
char txt[1024]; char txt[1024];
char bttok[16];
/* 路由口用缓存值 g_route_cur(路由线程维护),不在 IO 热路径 popen amixer—— /* 路由口用缓存值 g_route_cur(路由线程维护),不在 IO 热路径 popen amixer——
* 驱动 wedge 时 amixer 虽有界但仍会拖慢 IO 循环 1.5s/次。 */ * 驱动 wedge 时 amixer 虽有界但仍会拖慢 IO 循环 1.5s/次。 */
int rb = g_route_cur; int rb = g_route_cur;
int vol = g_vol_pct; int vol = g_vol_pct;
/* btcodec= 报「当前设置归一后的 token」(不是上次打开用的),便于面板/CLI 直接核对
* settings bt.codec 到底会被下发放什么;纯函数,无 I/O。 */
bt_dev_codec_token(g_bt_codec, bttok, sizeof bttok);
snprintf(txt, sizeof txt, snprintf(txt, sizeof txt,
"active=%d app=%s sid=%u rate=%u ch=%u fmt=%s dsd=%d " "active=%d app=%s sid=%u rate=%u ch=%u fmt=%s dsd=%d "
"written=%llu played=%llu underrun=%u dropped=%llu stall=%u txdrop=%u paused=%d " "written=%llu played=%llu underrun=%u dropped=%llu stall=%u txdrop=%u paused=%d "
"route_mode=%s port=%d vol=%d volctl=%s rms=%d peak=%d lvlseq=%u " "route_mode=%s port=%d vol=%d volctl=%s rms=%d peak=%d lvlseq=%u "
"target=%s codec=%s\n", "target=%s bt=%s btdev=%s btcodec=%s btacodec=%s codec=%s\n",
g_st.active ? 1 : 0, g_st.active ? 1 : 0,
g_st.active ? g_st.app_id : "-", g_st.active ? g_st.app_id : "-",
(unsigned)g_st.sid, (unsigned)g_st.rate, (unsigned)g_st.ch, (unsigned)g_st.sid, (unsigned)g_st.rate, (unsigned)g_st.ch,
@@ -1753,7 +1919,8 @@ static void dispatch(struct client *c, uint16_t type, uint32_t req_id, const uin
(g_route_manual != -1 ? g_route_manual : rb), (g_route_manual != -1 ? g_route_manual : rb),
vol, volctl_name(), vol, volctl_name(),
g_lvl_rms_db10, g_lvl_peak_db10, (unsigned)g_lvl_seq, g_lvl_rms_db10, g_lvl_peak_db10, (unsigned)g_lvl_seq,
target_name(), g_bt_codec); target_name(), bt_state_name(), g_bt_mac[0] ? g_bt_mac : "-",
bttok, g_bt_used[0] ? g_bt_used : "-", g_bt_codec);
send_frame(c->fd, MSG_AUDIO_STATE, req_id, txt, (uint32_t)strlen(txt) + 1); send_frame(c->fd, MSG_AUDIO_STATE, req_id, txt, (uint32_t)strlen(txt) + 1);
break; break;
} }
@@ -1771,13 +1938,20 @@ static void dispatch(struct client *c, uint16_t type, uint32_t req_id, const uin
int32_t pct = -1; int32_t pct = -1;
if (len >= sizeof(int32_t)) memcpy(&pct, pl, sizeof pct); if (len >= sizeof(int32_t)) memcpy(&pct, pl, sizeof pct);
if (pct >= 0) { if (pct >= 0) {
/* 音量应用含 BT 隔离:target=BT 时绝不下发 DAC 反转(vol_apply 内判断) */ /* 异步化(事项三):只入队末值,**不在持 g_mu 下 popen amixer**——驱动 wedge 时
vol_apply((int)pct); * 一次 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 { } 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; 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); send_frame(c->fd, MSG_ACK, req_id, &a, sizeof a);
}
break; break;
} }
case MSG_AUDIO_OUTPUT: { case MSG_AUDIO_OUTPUT: {
@@ -1911,6 +2085,14 @@ static void *io_thread(void *arg)
if (snaps[i].revents & (POLLHUP | POLLERR | POLLNVAL)) { cli_free(c); continue; } if (snaps[i].revents & (POLLHUP | POLLERR | POLLNVAL)) { cli_free(c); continue; }
if (snaps[i].revents & POLLIN) cx_read_frames(c); 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); pthread_mutex_unlock(&g_mu);
/* 路由线程与播放线程独立;此处不再做 */ /* 路由线程与播放线程独立;此处不再做 */
} }
@@ -1921,6 +2103,40 @@ static void *io_thread(void *arg)
return NULL; 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) static void *route_thread(void *arg)
{ {
@@ -1930,8 +2146,10 @@ static void *route_thread(void *arg)
/* M2.91 挂死根治(AUDIO_HANG_INVESTIGATION.md):hbc3000 FPGA 未烧录+enable 时 /* M2.91 挂死根治(AUDIO_HANG_INVESTIGATION.md):hbc3000 FPGA 未烧录+enable 时
* 启动 PCM 会在第二次 hbc3000_start 死锁整机。写 Output Port Switch 是烧录触发器 * 启动 PCM 会在第二次 hbc3000_start 死锁整机。写 Output Port Switch 是烧录触发器
* (驱动 put:set_spdif_mode→program_ram→enabled)。开机端口恒 0=未烧,故启动时 * (驱动 put:set_spdif_mode→program_ram→enabled)。开机端口恒 0=未烧,故启动时
* 无条件写一次:4.4 在→3,3.5 在→2,都没→4(官方播放器拔插孔后的选择,保 FPGA 已烧)。 * 武装一次:4.4 在→3,3.5 在→2,都没→4(官方播放器拔插孔后的选择,保 FPGA 已烧)。
* 端口已是目标值(audiod 重启场景,驱动同值短路不写)→ 先写 5(disable) 强制重烧。 * 多余重烧收敛(AUDIO_HANG_INVESTIGATION):端口已是目标值(audiod 重启场景)时
* **不再**先写 5(disable) 强制重烧——每次开机做 5→目标 双写等于把 FPGA 烧录暴露翻倍,
* 正是驱动级非确定楔死触发面;改为只需读回一致即跳过,仅当未武装/端口不对时才写目标。
* ⚠️ P2 蓝牙:numnid=9/FPGA 武装只作用于 **DAC 物理路径**(plan §五解耦);target=BT 时 * ⚠️ P2 蓝牙:numnid=9/FPGA 武装只作用于 **DAC 物理路径**(plan §五解耦);target=BT 时
* 不开 hw:0,0,无 hbc3000 start 死锁风险,故 BT 期间跳过 numid=9/FPGA(切回 DAC 再武装)。 */ * 不开 hw:0,0,无 hbc3000 start 死锁风险,故 BT 期间跳过 numid=9/FPGA(切回 DAC 再武装)。 */
int need_arm = 1; /* 启动(DAC 起点)武装一次;BT 期间置 1(回 DAC 再武装) */ int need_arm = 1; /* 启动(DAC 起点)武装一次;BT 期间置 1(回 DAC 再武装) */
@@ -1939,8 +2157,11 @@ static void *route_thread(void *arg)
pthread_mutex_lock(&g_mu); pthread_mutex_lock(&g_mu);
int manual = g_route_manual; /* 快照后放锁:amixer popen 不持 g_mu(防 wedge 级联) */ int manual = g_route_manual; /* 快照后放锁:amixer popen 不持 g_mu(防 wedge 级联) */
int tgt = g_target; /* 输出目标快照(决定是否武装 FPGA) */ int tgt = g_target; /* 输出目标快照(决定是否武装 FPGA) */
/* M50:BT 失败回落 DAC 时 **必须武装**(否则 FPGA 未烧 + enable 下开 PCM 会楔死整机)。
* 只有「请求 BT 且 BT 真的在用」才跳过 numid=9。 */
int bt_hold = (tgt == AUDIO_OUT_BT && g_bt_state != BT_ST_FAIL);
pthread_mutex_unlock(&g_mu); pthread_mutex_unlock(&g_mu);
if (tgt == AUDIO_OUT_BT) { if (bt_hold) {
static int s_logged = 0; static int s_logged = 0;
if (!s_logged) { s_logged = 1; logmsg("route: output=BT, numid=9/FPGA not armed (DAC dormant)"); } if (!s_logged) { s_logged = 1; logmsg("route: output=BT, numid=9/FPGA not armed (DAC dormant)"); }
need_arm = 1; /* 回 DAC 后补一次武装 */ need_arm = 1; /* 回 DAC 后补一次武装 */
@@ -1951,8 +2172,19 @@ static void *route_thread(void *arg)
int bal = read_switch("/sys/class/switch/balance/state"); int bal = read_switch("/sys/class/switch/balance/state");
int target = (bal == 1) ? 3 : (hb == 1) ? 2 : 4; int target = (bal == 1) ? 3 : (hb == 1) ? 2 : 4;
int cur = amixer_get(9, 1); int cur = amixer_get(9, 1);
if (cur == target) { logmsg("route: port already %d, force re-arm via 5", target); apply_route(5); } 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); apply_route(target);
}
last_hb = hb; last_bal = bal; last_target = (hb == 0 && bal == 0) ? -1 : 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)); logmsg("route: boot/DAC-return arm done (port %d %s)", target, port_name(target));
need_arm = 0; need_arm = 0;
@@ -2136,19 +2368,21 @@ int main(int argc, char **argv)
logmsg("volume init = %d%% target=%s codec=%s volctl=%s", logmsg("volume init = %d%% target=%s codec=%s volctl=%s",
g_vol_pct, target_name(), g_bt_codec, volctl_name()); 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_t ca;
pthread_condattr_init(&ca); pthread_condattr_init(&ca);
pthread_condattr_setclock(&ca, CLOCK_MONOTONIC); pthread_condattr_setclock(&ca, CLOCK_MONOTONIC);
pthread_cond_init(&g_cond, &ca); pthread_cond_init(&g_cond, &ca);
pthread_cond_init(&g_vol_cond, &ca);
pthread_condattr_destroy(&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(&ti, NULL, io_thread, NULL);
pthread_create(&tp, NULL, playback_thread, NULL); pthread_create(&tp, NULL, playback_thread, NULL);
pthread_create(&tr, NULL, route_thread, NULL); pthread_create(&tr, NULL, route_thread, NULL);
pthread_create(&tv, NULL, vol_thread, NULL);
/* 主线程 = watchdog:心跳静默 >WD_SILENT_SEC 秒即告警(只告警不强杀—— /* 主线程 = watchdog:心跳静默 >WD_SILENT_SEC 秒即告警(只告警不强杀——
* 对卡在内核的线程任何外部干预都不安全;护栏保证其它线程/连接继续服务)。 */ * 对卡在内核的线程任何外部干预都不安全;护栏保证其它线程/连接继续服务)。 */
@@ -2178,6 +2412,7 @@ int main(int argc, char **argv)
pthread_join(ti, NULL); pthread_join(ti, NULL);
pthread_join(tp, NULL); pthread_join(tp, NULL);
pthread_join(tr, NULL); pthread_join(tr, NULL);
pthread_join(tv, NULL);
unlink(g_sock); unlink(g_sock);
if (g_logfd >= 0) close(g_logfd); if (g_logfd >= 0) close(g_logfd);
return 0; return 0;
+79
View File
@@ -187,6 +187,24 @@ static int start_audiod_trace(const char *sink, const char *settings_path, const
return pid; 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) */ /* 直接客户端 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) static int do_open_f(struct cli *c, int rate, int ch, int fmt, uint32_t flags, m_audio_open_ack_t *ack)
{ {
@@ -792,6 +810,11 @@ int main(void)
check(strstr(q11, "codec=SBC") != NULL, "settings bt.codec=SBC -> codec=SBC"); check(strstr(q11, "codec=SBC") != NULL, "settings bt.codec=SBC -> codec=SBC");
check(strstr(q11, "volctl=avrcp") != NULL, "bt -> volctl=avrcp (AVRCP vol control)"); check(strstr(q11, "volctl=avrcp") != NULL, "bt -> volctl=avrcp (AVRCP vol control)");
check(strstr(q11, "fmt=S16") != NULL, "bt grant fmt=S16 (format normalize)"); check(strstr(q11, "fmt=S16") != NULL, "bt grant fmt=S16 (format normalize)");
/* M50:BT 运行时状态三键(面板据此显示「输出=蓝牙(失败)」)。FILE sink 从不尝试 BT 打开
* → bt=idle 且 btdev=-(无端点);token 由 settings bt.codec=SBC 归一(canonical 同名)。 */
check(strstr(q11, "bt=idle") != NULL, "bt=idle before any open attempt (FILE sink)");
check(strstr(q11, "btdev=-") != NULL, "btdev=- when no endpoint opened");
check(strstr(q11, "btcodec=SBC") != NULL, "btcodec=SBC (settings token normalized)");
/* BT/file sink 写一段流(应正常落盘到 WAV) */ /* BT/file sink 写一段流(应正常落盘到 WAV) */
do_write(&b11, pcm, (uint32_t)gen_sine(pcm, sizeof pcm, (int)(ack11.grant_fmt ? ack11.grant_fmt : AUDIO_FMT_S16), 600, 440, 44100)); do_write(&b11, pcm, (uint32_t)gen_sine(pcm, sizeof pcm, (int)(ack11.grant_fmt ? ack11.grant_fmt : AUDIO_FMT_S16), 600, 440, 44100));
usleep(150000); usleep(150000);
@@ -1080,6 +1103,62 @@ int main(void)
} }
stop_audiod(); 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); printf("=== SUMMARY: pass=%d fail=%d ===\n", g_pass, g_fail);
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }
+237 -65
View File
@@ -1,10 +1,23 @@
/* test_bt_dev.c — 蓝牙 DEV 自识别宿主验证(纯逻辑,bt_dev.c)。 /* test_bt_dev.c — 蓝牙端点选择 / PCM 名构造 / 回落退避 宿主验证(纯逻辑,bt_dev.c)。
* *
* 覆盖矩阵(对齐任务B 宿主测试要求): * 输入全部取自**真机实测原文**(bluealsa v4.1.1;设备 /usr/bin/bluealsa 的
* D1 bt_dev_detect_mac:a2dpsrc/<MAC> 端点 → 提取 MAC(含 a2dp_source、多空行、多设备取第一个、 * `dev_%.2X_..` + src/ba-transport.c 的 `<dev>/<profile>/<source|sink>`):
* 无 a2dpsrc / 空输出 / NULL → -1)。 * /org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink ← 实机(本机 a2dp-source)
* D2 bt_dev_rewrite:真实 alsa.conf 文本 → DEV=、device 两处 MAC 换成目标,保留 codec/其余 token; * (同一格式下 /a2dpsrc/source 只是 PCM 模式不同,解析只认 dev_XX_.. 段)
* 同值 → 1(无需改动);无 pcm.bluealsa / 无 DEV= → -1;带引号风格保留。 *
* 覆盖矩阵:
* D1 bt_dev_detect_mac:真实 D-Bus 路径 → MAC;多端点/多设备取第一个 a2dpsrc;
* hfphf/a2dpsnk 不算;简化 `a2dpsrc/<MAC>` 兼容;空/无源端点/NULL/坏 dev_ 段 → -1。
* D3 bt_dev_codec_token:settings bt.codec → bluealsa canonical(4.1.1 别名表 strcasecmp);
* auto/空/unchanged/垃圾 → unchanged。
* D4 bt_dev_pcm_name:内联 DEV/PROFILE/CODEC;坏 MAC/小缓冲 → -1。
* D5 bt_dev_backoff_ms / should_try:2s→60s 封顶 + 门控(防重试风暴)。
* D7 bt_dev_info_codec:真机 `bluealsa-cli info` 原文 → Selected codec(不能误吃 Available 行)。
* D8 bt_dev_acl_macs:真机 `hcitool con` 原文 → **全部** ACL 对端 MAC(多设备一等公民:
* 只取第一条会在手机/手柄同时连着时挑错设备,导致有耳机却回落 DAC)。
* D9 bt_dev_pick_endpoint:按 MAC 精确挑端点(换耳机 A→B 后不能拿 A 的路径发 volume/info)。
* D10 bt_dev_close_kind:关闭决策(BT 分支 / 真 fd / **0-2 一律不动**=护 stdin,P2-1 回归)。
* *
* 编译(在 patch_v2/meow_audiod 下): * 编译(在 patch_v2/meow_audiod 下):
* cc -Wall -Wextra -o test_bt_dev test_bt_dev.c bt_dev.c * cc -Wall -Wextra -o test_bt_dev test_bt_dev.c bt_dev.c
@@ -26,91 +39,250 @@ int main(void)
{ {
setbuf(stdout, NULL); setbuf(stdout, NULL);
/* ---- D1 设备列表 → 选中 a2dpsrc 连接设备 ---- */ /* ==== D1 设备列表 → 选中 a2dpsrc 连接设备(真实 D-Bus 路径格式) ==== */
{ {
char mac[32] = ""; char mac[32] = "";
int rc = bt_dev_detect_mac( int rc = bt_dev_detect_mac(
"a2dpsrc/E0:42:6D:79:C5:98\na2dpsnk/E0:42:6D:79:C5:98\n", mac, sizeof mac); "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/source\n", mac, sizeof mac);
check(rc == 0 && strcmp(mac, "E0:42:6D:79:C5:98") == 0, "D1a a2dpsrc/<MAC> -> extract MAC"); check(rc == 0 && strcmp(mac, "33:DE:2E:21:AD:30") == 0,
"D1a real D-Bus path -> MAC (dev_ underscores -> colons)");
} }
{ {
char mac[32] = ""; char mac[32] = "";
int rc = bt_dev_detect_mac( int rc = bt_dev_detect_mac(
"profile/aptx\n\na2dp_source/33:DE:2E:21:AD:30\n", mac, sizeof mac); "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/hfphf/source\n"
check(rc == 0 && strcmp(mac, "33:DE:2E:21:AD:30") == 0, "D1b a2dp_source/<MAC> -> extract MAC"); "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsnk/sink\n"
"/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/source\n", mac, sizeof mac);
check(rc == 0 && strcmp(mac, "33:DE:2E:21:AD:30") == 0,
"D1b hfphf/a2dpsnk skipped -> first a2dpsrc");
} }
{ {
char mac[32] = ""; char mac[32] = "";
int rc = bt_dev_detect_mac( int rc = bt_dev_detect_mac(
"a2dpsrc/AA:BB:CC:DD:EE:FF 0\na2dpsrc/11:22:33:44:55:66 1\n", mac, sizeof mac); "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_FF/a2dpsrc/source\n"
"/org/bluealsa/hci0/dev_11_22_33_44_55_66/a2dpsrc/source\n", mac, sizeof mac);
check(rc == 0 && strcmp(mac, "AA:BB:CC:DD:EE:FF") == 0, "D1c multi-device -> first a2dpsrc"); check(rc == 0 && strcmp(mac, "AA:BB:CC:DD:EE:FF") == 0, "D1c multi-device -> first a2dpsrc");
} }
{ {
char mac[32] = ""; char mac[32] = "";
int rc = bt_dev_detect_mac("\n", mac, sizeof mac); int rc = bt_dev_detect_mac("a2dpsrc/E0:42:6D:79:C5:98\n", mac, sizeof mac);
check(rc == -1, "D1d empty output -> -1"); check(rc == 0 && strcmp(mac, "E0:42:6D:79:C5:98") == 0, "D1d legacy a2dpsrc/<MAC> still parsed");
} }
{ {
char mac[32] = ""; char mac[32] = "";
int rc = bt_dev_detect_mac("a2dpsnk/11:22:33:44:55:66\n", mac, sizeof mac); int rc = bt_dev_detect_mac("", mac, sizeof mac);
check(rc == -1, "D1e only sink (no a2dpsrc) -> -1"); check(rc == -1, "D1e empty output -> -1");
}
{
char mac[32] = "";
int rc = bt_dev_detect_mac(
"/org/bluealsa/hci0/dev_11_22_33_44_55_66/a2dpsnk/sink\n", mac, sizeof mac);
check(rc == -1, "D1f only sink (no a2dpsrc) -> -1");
} }
{ {
char mac[32] = ""; char mac[32] = "";
int rc = bt_dev_detect_mac(NULL, mac, sizeof mac); int rc = bt_dev_detect_mac(NULL, mac, sizeof mac);
check(rc == -1, "D1f NULL input -> -1"); check(rc == -1, "D1g NULL input -> -1");
}
{
char mac[32] = "";
int rc = bt_dev_detect_mac(
"/org/bluealsa/hci0/dev_ZZ_42_6D_79_C5_98/a2dpsrc/source\n", mac, sizeof mac);
check(rc == -1 && mac[0] == 0, "D1h malformed dev_ seg -> -1 (no garbage MAC)");
}
{
char mac[32] = "";
int rc = bt_dev_detect_mac(
"/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/source 1\n", mac, sizeof mac);
check(rc == 0 && strcmp(mac, "33:DE:2E:21:AD:30") == 0, "D1i trailing token tolerated");
} }
/* ---- D2 DEV 串入 / 保留其它 token ---- */ /* ==== D3 settings bt.codec → bluealsa canonical token ==== */
{ {
const char *conf = struct { const char *in; const char *want; const char *name; } t[] = {
"pcm.bluealsa:DEV=E0:42:6D:79:C5:98 {\n" { "LDAC", "LDAC", "D3a LDAC passthrough" },
" type plug\n" { "ldac", "LDAC", "D3b lowercase ldac -> canonical" },
" slave {\n" { "SBC", "SBC", "D3c SBC" },
" pcm {\n" { "AAC", "AAC", "D3d AAC" },
" type bluealsa\n" { "MPEG24", "AAC", "D3e MPEG24 alias -> AAC" },
" device E0:42:6D:79:C5:98\n" { "aptX", "aptX", "D3f aptX" },
" profile \"a2dp\"\n" { "apt-X", "aptX", "D3g apt-X alias -> aptX" },
" codec \"LDAC\"\n" { "aptx-hd", "aptX-HD", "D3h aptx-hd -> aptX-HD" },
" ldac_eqmid \"LDAC_ABR\"\n" { "MPEG", "MP3", "D3i MPEG alias -> MP3" },
" }\n" { "MP3", "MP3", "D3j MP3" },
" }\n" { "FastStream", "FastStream", "D3k FastStream" },
"}\n"; { "FS", "FastStream", "D3l FS alias -> FastStream" },
char *out = NULL; size_t olen = 0; { "auto", "unchanged", "D3m auto -> unchanged (do not touch negotiation)" },
int rc = bt_dev_rewrite(conf, strlen(conf), "33:DE:2E:21:AD:30", &out, &olen); { "", "unchanged", "D3n empty -> unchanged" },
check(rc == 0 && out != NULL, "D2a rewrite returns changed"); { "unchanged", "unchanged", "D3o unchanged -> unchanged" },
check(out && strstr(out, ":DEV=33:DE:2E:21:AD:30 {"), "D2b DEV= updated"); { "BOGUS", "unchanged", "D3p unknown -> unchanged (never inject junk)" },
check(out && strstr(out, "device 33:DE:2E:21:AD:30\n"), "D2c device updated"); };
check(out && strstr(out, "codec \"LDAC\"") && strstr(out, "ldac_eqmid \"LDAC_ABR\""), for (size_t i = 0; i < sizeof t / sizeof t[0]; i++) {
"D2d codec/other tokens preserved"); char tok[16] = "";
/* 旧 MAC 不再出现 */ const char *r = bt_dev_codec_token(t[i].in, tok, sizeof tok);
check(out && !strstr(out, "E0:42:6D:79:C5:98"), "D2e old MAC gone"); check(r == tok && strcmp(tok, t[i].want) == 0, t[i].name);
free(out);
} }
{ char tok[16] = "";
const char *conf = check(bt_dev_codec_token(NULL, tok, sizeof tok) == tok && !strcmp(tok, "unchanged"),
"pcm.bluealsa:DEV=33:DE:2E:21:AD:30 {\n" "D3q NULL setting -> unchanged");
" type plug\n slave {\n pcm {\n"
" type bluealsa\n device 33:DE:2E:21:AD:30\n"
" profile \"a2dp\"\n codec \"LDAC\"\n }\n }\n}\n";
char *out = NULL; size_t olen = 0;
int rc = bt_dev_rewrite(conf, strlen(conf), "33:DE:2E:21:AD:30", &out, &olen);
check(rc == 1 && out == NULL, "D2f same MAC -> unchanged (idempotent)");
}
{
const char *conf = "pcm.bluealsa {\n type bluealsa\n device \"E0:42:6D:79:C5:98\"\n}\n";
char *out = NULL; size_t olen = 0;
int rc = bt_dev_rewrite(conf, strlen(conf), "33:DE:2E:21:AD:30", &out, &olen);
check(rc == 0 && out && strstr(out, "device \"33:DE:2E:21:AD:30\""),
"D2g quoted device preserved (no DEV= token)");
free(out);
}
{
char *out = NULL; size_t olen = 0;
int rc = bt_dev_rewrite("pcm.hw {\n type hw\n}\n", 21, "33:DE:2E:21:AD:30", &out, &olen);
check(rc == -1 && out == NULL, "D2h no pcm.bluealsa -> -1");
} }
printf(g_fail ? "\nRESULT: %d FAIL(S)\n" : "\nRESULT: ALL PASS\n", g_fail); /* ==== D4 PCM 名构造(20-bluealsa.conf @args 只认 DEV/PROFILE/CODEC 这套名字) ==== */
return g_fail; {
char n[96] = "";
int rc = bt_dev_pcm_name("33:DE:2E:21:AD:30", "LDAC", n, sizeof n);
check(rc == 0 && strcmp(n, "bluealsa:DEV=33:DE:2E:21:AD:30,PROFILE=a2dp,CODEC=LDAC") == 0,
"D4a name = bluealsa:DEV=<mac>,PROFILE=a2dp,CODEC=<token>");
}
{
char n[96] = "";
int rc = bt_dev_pcm_name("33:DE:2E:21:AD:30", "", n, sizeof n);
check(rc == 0 && strstr(n, "CODEC=unchanged") != NULL, "D4b empty token -> CODEC=unchanged");
}
{
char n[96] = "";
int rc = bt_dev_pcm_name("33:DE:2E:21:AD:30", NULL, n, sizeof n);
check(rc == 0 && strstr(n, "CODEC=unchanged") != NULL, "D4c NULL token -> CODEC=unchanged");
}
{
char n[96] = "";
check(bt_dev_pcm_name("33:DE:2E:21:AD", "LDAC", n, sizeof n) == -1, "D4d short MAC -> -1");
check(bt_dev_pcm_name("GG:DE:2E:21:AD:30", "LDAC", n, sizeof n) == -1, "D4e non-hex MAC -> -1");
check(bt_dev_pcm_name(NULL, "LDAC", n, sizeof n) == -1, "D4f NULL MAC -> -1");
check(bt_dev_pcm_name("33:DE:2E:21:AD:30", "LDAC", n, 16) == -1, "D4g tiny buffer -> -1");
}
/* ==== D7 实际协商 codec(真机 info 原文:MOONDROP Pudding 只有 SBC/AAC,请求 LDAC 实际选 AAC) ==== */
{
const char *info =
"Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30\n"
"Sequence: 0\n"
"Transport: A2DP-source\n"
"Mode: sink\n"
"Running: true\n"
"Format: S16_LE\n"
"Channels: 2\n"
"Sampling: 48000 Hz\n"
"Available codecs: SBC AAC\n"
"Selected codec: AAC\n"
"Delay: 17.2 ms\n"
"SoftVolume: false\n"
"Volume: L: 127 R: 127\n"
"Muted: L: false R: false\n";
char codec[16] = "";
check(bt_dev_info_codec(info, codec, sizeof codec) == 0 && strcmp(codec, "AAC") == 0,
"D7a real info -> Selected codec=AAC (not the Available line)");
}
{
const char *info = "Available codecs: SBC AAC\nSelected codec: SBC\r\n";
char codec[16] = "";
check(bt_dev_info_codec(info, codec, sizeof codec) == 0 && strcmp(codec, "SBC") == 0,
"D7b CRLF trimmed");
}
{
char codec[16] = "";
check(bt_dev_info_codec("Running: false\n", codec, sizeof codec) == -1,
"D7c no Selected line -> -1");
check(bt_dev_info_codec(NULL, codec, sizeof codec) == -1, "D7d NULL -> -1");
}
/* ==== D8 hcitool con → 全部 ACL MAC(真机原文;MAC 主来源,多设备是常态) ==== */
{
const char *con =
"Connections:\n"
"\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER AUTH ENCRYPT \n";
char m[4][18];
check(bt_dev_acl_macs(con, m, 4) == 1 && strcmp(m[0], "33:DE:2E:21:AD:30") == 0,
"D8a real hcitool con (state 1 AUTH ENCRYPT) -> 1 ACL MAC");
}
{
const char *con =
"Connections:\n"
"\t< ACL 33:DE:2E:21:AD:30 handle 0 state 5 lm MASTER \n";
char m[4][18];
check(bt_dev_acl_macs(con, m, 4) == 1 && strcmp(m[0], "33:DE:2E:21:AD:30") == 0,
"D8b half-open ACL (state 5) still gives MAC (kernel is authority)");
}
{
/* 多设备:第二副耳机 + 手机同时连着 —— 两条都要拿到,且顺序=输出顺序 */
const char *con =
"Connections:\n"
"\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER AUTH ENCRYPT \n"
"\t< ACL 5C:FB:7C:11:22:33 handle 5 state 1 lm SLAVE \n";
char m[4][18];
int n = bt_dev_acl_macs(con, m, 4);
check(n == 2 && strcmp(m[0], "33:DE:2E:21:AD:30") == 0 && strcmp(m[1], "5C:FB:7C:11:22:33") == 0,
"D8c two ACLs -> both MACs in order (not just the first one)");
}
{
/* 三设备 + 上限裁剪 + 去重(同一设备多 profile 行重复出现) */
const char *con =
"Connections:\n"
"\t< ACL AA:BB:CC:DD:EE:01 handle 1 state 1 lm MASTER \n"
"\t< ACL AA:BB:CC:DD:EE:01 handle 2 state 1 lm SLAVE \n"
"\t< ACL AA:BB:CC:DD:EE:02 handle 3 state 1 lm MASTER \n"
"\t< ACL AA:BB:CC:DD:EE:03 handle 4 state 1 lm MASTER \n";
char m[3][18];
int n = bt_dev_acl_macs(con, m, 3);
check(n == 3 && strcmp(m[0], "AA:BB:CC:DD:EE:01") == 0 &&
strcmp(m[1], "AA:BB:CC:DD:EE:02") == 0 && strcmp(m[2], "AA:BB:CC:DD:EE:03") == 0,
"D8d dedup + cap at max (3 distinct)");
}
{
char m[4][18];
check(bt_dev_acl_macs("Connections:\n", m, 4) == 0, "D8e no ACL -> 0");
check(bt_dev_acl_macs(NULL, m, 4) == 0, "D8f NULL -> 0");
check(bt_dev_acl_macs("\t< ACL aa:bb:cc:dd:ee:ff handle 3 state 1 lm SLAVE \n", m, 4) == 1 &&
strcmp(m[0], "AA:BB:CC:DD:EE:FF") == 0, "D8g lowercase -> uppercase canonical");
}
/* ==== D9 按 MAC 精确挑端点(换耳机 A→B 不串台) ==== */
{
const char *pcms =
"/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_01/a2dpsrc/sink\n"
"/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_02/a2dpsrc/sink\n";
char path[128] = "";
check(bt_dev_pick_endpoint(pcms, "AA:BB:CC:DD:EE:02", path, sizeof path) == 0 &&
strcmp(path, "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_02/a2dpsrc/sink") == 0,
"D9a picks B's path when MAC=B (not the first endpoint)");
check(bt_dev_pick_endpoint(pcms, "AA:BB:CC:DD:EE:01", path, sizeof path) == 0 &&
strcmp(path, "/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_01/a2dpsrc/sink") == 0,
"D9b picks A's path when MAC=A");
check(bt_dev_pick_endpoint(pcms, "AA:BB:CC:DD:EE:03", path, sizeof path) == -1 &&
path[0] == 0, "D9c unknown MAC -> -1 (never borrow another device's path)");
check(bt_dev_pick_endpoint("/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_01/hfphf/source\n",
"AA:BB:CC:DD:EE:01", path, sizeof path) == -1,
"D9d non-a2dpsrc endpoint -> -1");
check(bt_dev_pick_endpoint(NULL, "AA:BB:CC:DD:EE:01", path, sizeof path) == -1, "D9e NULL -> -1");
}
/* ==== D10 关闭决策(P2-1 回归:0/1/2 永不当设备 fd 关) ==== */
check(bt_dev_close_kind(1, 0) == 1, "D10a bt_dev=1 + fd=0 -> close BT only (never fd 0)");
check(bt_dev_close_kind(1, 5) == 1, "D10b bt_dev=1 + fd=5 -> close BT only");
check(bt_dev_close_kind(0, 0) == 0, "D10c bt_dev=0 + fd=0 -> NONE (stdin guard, P2-1)");
check(bt_dev_close_kind(0, 1) == 0, "D10d bt_dev=0 + fd=1 -> NONE (stdout guard)");
check(bt_dev_close_kind(0, 2) == 0, "D10e bt_dev=0 + fd=2 -> NONE (stderr guard)");
check(bt_dev_close_kind(0, 3) == 2, "D10f bt_dev=0 + fd=3 -> close FD");
check(bt_dev_close_kind(0, -1) == 0, "D10g bt_dev=0 + fd=-1 -> NONE");
check(bt_dev_close_kind(0, -2) == 0, "D10h bt_dev=0 + fd=-2 (NULL sink) -> NONE");
/* ==== D5 回落退避(防重试风暴:M10 教训) ==== */
check(bt_dev_backoff_ms(0) == 0, "D5a fails=0 -> 0ms (immediate)");
check(bt_dev_backoff_ms(1) == 2000, "D5b fails=1 -> 2s");
check(bt_dev_backoff_ms(2) == 4000, "D5c fails=2 -> 4s");
check(bt_dev_backoff_ms(3) == 8000, "D5d fails=3 -> 8s");
check(bt_dev_backoff_ms(4) == 16000, "D5e fails=4 -> 16s");
check(bt_dev_backoff_ms(5) == 32000, "D5f fails=5 -> 32s");
check(bt_dev_backoff_ms(6) == 60000, "D5g fails=6 -> 60s (capped)");
check(bt_dev_backoff_ms(99) == 60000, "D5h fails=99 -> 60s (capped)");
check(bt_dev_should_try(0, 0) == 1, "D5i never failed -> try now");
check(bt_dev_should_try(1, 1999) == 0, "D5j fail#1 @1999ms -> gated");
check(bt_dev_should_try(1, 2000) == 1, "D5k fail#1 @2000ms -> allowed");
check(bt_dev_should_try(3, 7999) == 0, "D5l fail#3 @7999ms -> gated");
check(bt_dev_should_try(3, 8000) == 1, "D5m fail#3 @8000ms -> allowed");
check(bt_dev_should_try(2, -1) == 1, "D5n clock anomaly (negative) -> allowed (no deadlock)");
printf(g_fail ? "\nRESULT: %d FAIL(S)\n" : "\nRESULT: ALL PASS (%d checks)\n",
g_fail ? g_fail : g_pass);
return g_fail ? 1 : 0;
} }
+3 -1
View File
@@ -21,7 +21,9 @@ if [ "$1" = "host" ]; then
exit 0 exit 0
fi fi
"$CC" -Os -Wall -Wextra -static meow_crash.c -o meow_crash # meow_time32.c:独立 o32 time32 取时/睡眠(crash 不链 meow_ui.a);扫描/清理节拍恢复真实毫秒。
"$CC" -Os -Wall -Wextra -static -DMEOW_TIME32_LIBC_OVERRIDES \
meow_crash.c ../sdk/meow_time32.c -o meow_crash
echo "== meow_crash (mipsel static) ==" echo "== meow_crash (mipsel static) =="
ls -la meow_crash ls -la meow_crash
md5sum meow_crash md5sum meow_crash
Binary file not shown.
+187 -127
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 ---- */ /* ---- amsd 上报 / 控制挂点(AMSD_DESIGN §4):best-effort,amsd 不在不影响 launcher ---- */
#define MEOW_AMS_SOCK_DEFAULT "/usr/data/meow_ams.sock" #define MEOW_AMS_SOCK_DEFAULT "/usr/data/meow_ams.sock"
@@ -1022,7 +1044,8 @@ void cont_player(void)
/* ================================================================== */ /* ================================================================== */
/* M2.12 MeowAPPs 包数据目录 + .mpk 接口 */ /* M2.12 MeowAPPs 包数据目录 + .mpk 导出接口 */
/* 安装入口 meow_pkg_install M42 起移到 pkg_install.c(委托 nyai)。 */
/* exFAT 无符号链接 → 数据目录用环境变量(MEOW_DATA_DIR/CACHE_DIR) */ /* exFAT 无符号链接 → 数据目录用环境变量(MEOW_DATA_DIR/CACHE_DIR) */
/* 传址给应用,不做 symlink(APPS.md 附录 v2 修订)。 */ /* 传址给应用,不做 symlink(APPS.md 附录 v2 修订)。 */
/* ================================================================== */ /* ================================================================== */
@@ -1105,16 +1128,6 @@ static int pkg_json_field_mpk(const char *mpk, const char *key, char *out, int n
return 0; return 0;
} }
/* 安装成功后,把原签 .mpk 另存 MeowData/<pkg>/data/<pkg>.mpk.src(导出保留签名)。 */
static void pkg_save_src(const char *mpk, const char *pkg)
{
char dst[320], cmd[720];
snprintf(dst, sizeof dst, "/usr/data/mnt/sd_0/MeowData/%s/data/%s.mpk.src", pkg, pkg);
snprintf(cmd, sizeof cmd, "mkdir -p '/usr/data/mnt/sd_0/MeowData/%s/data' 2>/dev/null && "
"cp '%s' '%s' 2>/dev/null", pkg, mpk, dst);
if (system(cmd) != 0) logmsg("pkg_install: save .mpk.src fail for %s", pkg);
}
/* 导出保留签名:缓存 .mpk.src 存在 + 验签通过 + 其 app.json 版本 == 已装 → 直接复制(不二次签名)。 /* 导出保留签名:缓存 .mpk.src 存在 + 验签通过 + 其 app.json 版本 == 已装 → 直接复制(不二次签名)。
* 成功 0;否则 -1(调用方现场重打包,无签名)。 */ * 成功 0;否则 -1(调用方现场重打包,无签名)。 */
static int pkg_export_preserve_sig(const char *pkg, const char *dst) static int pkg_export_preserve_sig(const char *pkg, const char *dst)
@@ -1135,97 +1148,6 @@ static int pkg_export_preserve_sig(const char *pkg, const char *dst)
return 0; return 0;
} }
/* .mpk 安装(本期接口/CLI + 文档;UI 后期):解压 .mpk → MeowAPPs/<pkg>/。
* 约定 .mpk = tar.gz,顶层即包目录(含 app.json);包名 = 文件名茎 <pkg>.mpk。
* 同名先备份;解压后校验 app.json;随后数据 init(MeowData/<pkg>/{data,cache})。
* 返回 0 ok / 负=错误。 */
int meow_pkg_install(const char *mpk_path)
{
if (!mpk_path || access(mpk_path, R_OK) != 0) { logmsg("pkg_install: bad mpk %s", mpk_path?mpk_path:"(null)"); return -1; }
if (!sd_device_present()) { logmsg("pkg_install: no card"); return -1; }
if (strpbrk(mpk_path, " ;`$'\"&|<>") != NULL) { logmsg("pkg_install: unsafe path"); return -2; }
/* 包名 = 文件名茎(去掉 .mpk) */
const char *bn = strrchr(mpk_path, '/');
bn = bn ? bn + 1 : mpk_path;
const char *dot = strrchr(bn, '.');
char pkg[64];
if (!dot || strcmp(dot, ".mpk") != 0) { logmsg("pkg_install: need <pkg>.mpk"); return -1; }
int len = (int)(dot - bn);
if (len <= 0 || len >= (int)sizeof pkg) { logmsg("pkg_install: bad pkg name"); return -1; }
memcpy(pkg, bn, len); pkg[len] = 0;
/* 目标 MeowAPPs/<pkg>/;同名先备份(.bak_<pid>),再解压,再校验 app.json */
char dst[256], bak[256], cmd[760];
snprintf(dst, sizeof dst, "/usr/data/mnt/sd_0/MeowAPPs/%s", pkg);
snprintf(bak, sizeof bak, "%s.bak_%d", dst, (int)getpid());
int replacing = (access(dst, F_OK) == 0);
/* II 期:签名分级先于解包(MPK_SIGNATURE_DESIGN §5.2:先验签再解包,失败不落盘)。
* 有签名必须验签(失败拒装);os.meow.* 必须有有效签名;无签名第三方放行(告警)。 */
int vr = pkg_verify_sig(mpk_path, pkg);
int sysp = (strncmp(pkg, "os.meow.", 8) == 0);
if (vr == 1) { logmsg("pkg_install: sig invalid, reject %s", pkg); return -3; }
else if (vr == 2) {
if (sysp) { logmsg("pkg_install: sys pkg %s must be signed, rejected", pkg); return -3; }
logmsg("pkg_install: %s unsigned (3rd party allowed)", pkg);
}
else if (vr != 0) {
if (sysp) { logmsg("pkg_install: meow_verify unavailable rc=%d, sys pkg reject", vr); return -3; }
logmsg("pkg_install: meow_verify unavailable rc=%d, %s unverified (3rd party)", vr, pkg);
}
/* 防降级门禁(与 nyai 同规范,APPS.md §3.8):仅按 version_code 比较。
* 新版 code < 已装 → 拒;相等 → 允许重装;任一侧缺 version_code → 拒。 */
if (replacing) {
char nc[32], oc[32];
long long ncode = -1, ocode = -1;
int nok = 0, ok2 = 0;
if (pkg_json_field_mpk(mpk_path, "version_code", nc, sizeof nc) == 0) {
char *ep; long long v = strtoll(nc, &ep, 10);
if (ep != nc && *ep == '\0') { ncode = v; nok = 1; }
}
char instj[280];
snprintf(instj, sizeof instj, "/usr/data/mnt/sd_0/MeowAPPs/%s/app.json", pkg);
if (pkg_json_field(instj, "version_code", oc, sizeof oc) == 0) {
char *ep; long long v = strtoll(oc, &ep, 10);
if (ep != oc && *ep == '\0') { ocode = v; ok2 = 1; }
}
if (!nok || !ok2) { logmsg("pkg_install: %s version_code missing, reject", pkg); return -4; }
if (ncode < ocode) { logmsg("pkg_install: downgrade %s new %lld < old %lld, reject", pkg, ncode, ocode); return -4; }
}
/* BusyBox tar 无 -z:用 gzip -dc | tar -x 处理 tar.gz。 */
snprintf(cmd, sizeof cmd,
"rm -rf '%s' 2>/dev/null; "
"[ -d '%s' ] && mv '%s' '%s' 2>/dev/null; "
"mkdir -p '%s' && gzip -dc '%s' | tar -x -C '%s' 2>/dev/null && "
"[ -f '%s/app.json' ]", bak, dst, dst, bak, dst, mpk_path, dst, dst);
if (system(cmd) != 0) { logmsg("pkg_install: extract/validate fail for %s", pkg); return -1; }
/* II 期:app.json.package 绑定(== 包名,禁改包名/目录改名;防第三方面具系统包)。 */
{
char appj[280], ph[64];
snprintf(appj, sizeof appj, "/usr/data/mnt/sd_0/MeowAPPs/%s/app.json", pkg);
if (pkg_json_field(appj, "package", ph, sizeof ph) == 0 && strcmp(ph, pkg) != 0) {
logmsg("pkg_install: app.json.package %s != %s, reject", ph, pkg);
char rb[760];
snprintf(rb, sizeof rb, "rm -rf '%s' 2>/dev/null; [ -d '%s' ] && mv '%s' '%s' 2>/dev/null",
dst, bak, bak, dst);
system(rb);
return -3;
}
}
/* 数据 init:MeowData/<pkg>/{data,cache} */
snprintf(cmd, sizeof cmd, "mkdir -p '/usr/data/mnt/sd_0/MeowData/%s/data' "
"'/usr/data/mnt/sd_0/MeowData/%s/cache' 2>/dev/null", pkg, pkg);
system(cmd);
/* 有效签 → 原签 .mpk 另存 MeowData/<pkg>/data/<pkg>.mpk.src(导出保留签名) */
if (vr == 0) pkg_save_src(mpk_path, pkg);
logmsg("pkg_install: %s installed (old backed up %s)", pkg, bak);
return 0;
}
/* .mpk 导出:打包 MeowAPPs/<pkg>/(数据目录本就由 env 指向 MeowData,MeowAPPs/<pkg> 下无 /* .mpk 导出:打包 MeowAPPs/<pkg>/(数据目录本就由 env 指向 MeowData,MeowAPPs/<pkg> 下无
* data/cache 实体,直接全打包)→ <pkg>.mpk。返回 0 ok。 */ * data/cache 实体,直接全打包)→ <pkg>.mpk。返回 0 ok。 */
int meow_pkg_export(const char *pkg, const char *out_path) int meow_pkg_export(const char *pkg, const char *out_path)
@@ -1410,6 +1332,15 @@ static void notif_active_set(const char *owner)
} }
} }
/* M40 F5/P5-P6:请求 App 在恢复前台后整帧重画一次(SDK present 层消费)。
* launcher 侧恢复的「底层帧」只能是快照;锁屏/菜单期间 App 若被冻结,它自己的 back_buf
* 仍是旧内容,只按脏区上屏会留下 PIN/菜单残影。写这个文件让 App dirty_full 一次。 */
static void meow_app_repaint_request(void)
{
int fd = open(MEOW_APP_REPAINT_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { (void)write(fd, "1", 1); close(fd); }
}
/* §10.5:App 前台顶部下滑 → 展开「由 launcher 渲染」的通知中心叠加会话。 /* §10.5:App 前台顶部下滑 → 展开「由 launcher 渲染」的通知中心叠加会话。
* 复用电源菜单对 App 的冻结模板(show_power_menu 的 SIGSTOP/读回/叠加/接管/恢复): * 复用电源菜单对 App 的冻结模板(show_power_menu 的 SIGSTOP/读回/叠加/接管/恢复):
* ① SIGSTOP App → ② 写 /tmp/meow_notif_active(+ /tmp/meow_power_menu_active 让 watchdog 让位) * ① SIGSTOP App → ② 写 /tmp/meow_notif_active(+ /tmp/meow_power_menu_active 让 watchdog 让位)
@@ -1548,6 +1479,9 @@ static int run_app_lock_overlay(struct app *a, pid_t pid)
pm_capture_app_frame(); pm_capture_app_frame();
meow_ui_flush(); meow_ui_flush();
/* M40 F5/P5-P6:先写整帧重画请求,再 SIGCONT/放行——App 恢复后的第一次 present 会
* 消费它并 dirty_full(),用自身完整帧盖掉 launcher 快照恢复可能留下的任何残影。 */
meow_app_repaint_request();
if (!keep_running) { if (!keep_running) {
/* §310 输入穿透定案(overlay_gate.h):App 被 SIGSTOP 冻结过 → 在 /tmp/meow_notif_active /* §310 输入穿透定案(overlay_gate.h):App 被 SIGSTOP 冻结过 → 在 /tmp/meow_notif_active
* 仍未更新时**先 SIGCONT**,让 SDK 输入线程排空冻结期缓冲的 evdev 事件副本 + 对齐 * 仍未更新时**先 SIGCONT**,让 SDK 输入线程排空冻结期缓冲的 evdev 事件副本 + 对齐
@@ -1555,10 +1489,10 @@ static int run_app_lock_overlay(struct app *a, pid_t pid)
* 防解锁瞬间把积累的触摸回放成 App 内点击(锁屏穿透→「启动奇怪界面」→ 状态栏消失的触发源)。 */ * 防解锁瞬间把积累的触摸回放成 App 内点击(锁屏穿透→「启动奇怪界面」→ 状态栏消失的触发源)。 */
kill(pid, SIGCONT); kill(pid, SIGCONT);
usleep(OVERLAY_DRAIN_WINDOW_MS * 1000); usleep(OVERLAY_DRAIN_WINDOW_MS * 1000);
logmsg("lock-overlay: closed (lr=%d) -> SIGCONT app, drain %dms, then mark fg", logmsg("lock-overlay: closed (lr=%d) -> SIGCONT app, drain %dms, repaint req, then mark fg",
lr, OVERLAY_DRAIN_WINDOW_MS); lr, OVERLAY_DRAIN_WINDOW_MS);
} else { } else {
logmsg("lock-overlay: closed (lr=%d) -> app never stopped (media keep-alive), no SIGCONT", lr); logmsg("lock-overlay: closed (lr=%d) -> app never stopped (media keep-alive), no SIGCONT, repaint req", lr);
} }
/* 标记翻回前台归属(a->id):本 App 解除自抑恢复前台;后台常驻 App(如 ncm)随之继续自抑 /* 标记翻回前台归属(a->id):本 App 解除自抑恢复前台;后台常驻 App(如 ncm)随之继续自抑
* (不叠帧 / 不穿输入——修复「锁屏解锁后 ncm 歌单帧叠加在 Hello 上 + 交互双响应」)。 */ * (不叠帧 / 不穿输入——修复「锁屏解锁后 ncm 歌单帧叠加在 Hello 上 + 交互双响应」)。 */
@@ -1573,6 +1507,72 @@ static int run_app_lock_overlay(struct app *a, pid_t pid)
return 0; return 0;
} }
/* M40 F2/P2:App 前台「电源菜单」会话——补齐 §10.5 协议。
* 旧实现(M2.24)只有 SIGSTOP → show_power_menu → SIGCONT:
* - 没有 notif_active 抑制标记(后台常驻 App 可能在这期间上屏/穿输入);
* - 没有帧快照/恢复(菜单把「压暗 App 帧 + 菜单卡」present 后,关闭时 launcher 又
* pm_capture_app_frame() 把**菜单帧**拍回 back_buf 再上屏 → 屏幕仍是菜单画面,
* 用户看到「按了没反应」,而 App 只按脏区重绘、永远不会重画整屏(P2 主因);
* - 没有 SIGCONT→300ms 排空→标记翻回前台 的输入隔离(冻结期触摸回放)。
* show_power_menu() 现在会在所有退出路径恢复进入前的背景帧(power_menu.c),所以这里
* 的 pm_capture_app_frame() 读到的是 App 帧而不是菜单帧。
* 返回:0=取消(继续 App);1=已执行动作(调用方退出循环);2=App 在菜单期死亡。 */
static int run_app_power_menu(struct app *a, pid_t pid)
{
int keep_running = meow_media_app_playing(a->id);
if (keep_running) {
logmsg("power-menu: open (app=%s pid=%d) PLAYING -> no SIGSTOP (SDK present suppressed)",
a->name, pid);
} else {
logmsg("power-menu: open (app=%s pid=%d) SIGSTOP", a->name, pid);
if (kill(pid, SIGSTOP) < 0) {
logmsg("power-menu: SIGSTOP %d fail: %s", pid, strerror(errno));
} else {
for (int i = 0; i < 100; i++) {
if (proc_is_stopped(pid)) break;
usleep(10000);
}
}
}
/* 叠加期全抑制:菜单盖在前台 App + 后台常驻 App 上;同时写 POWMENU_ACTIVE 让 watchdog
* 对电源长按/强关让位(与通知中心/锁屏同款双标记)。 */
notif_active_set("");
int pfd = open(POWMENU_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (pfd >= 0) { write(pfd, "1", 1); close(pfd); }
meow_sys_light();
meow_theme_reload();
pm_capture_app_frame(); /* 背景 = App 帧(fb0 buffer0) */
int pm_ret = show_power_menu();
if (proc_is_zombie(pid)) {
/* App 在菜单期死亡:不 SIGCONT、不把菜单帧当 App 帧恢复;交回 waitpid 统一回收。 */
notif_active_set("");
unlink(POWMENU_ACTIVE);
logmsg("power-menu: app pid %d died during menu -> unified exit path", pid);
return 2;
}
/* 恢复 App 帧(show_power_menu 已还原进入前背景;再拍一次兜底覆盖任何非标准退出路径),
* 然后按 §10.5:先请求整帧重画 → SIGCONT → 排空窗 → 标记翻回前台归属。 */
pm_capture_app_frame();
meow_ui_flush();
meow_app_repaint_request();
if (!keep_running) {
kill(pid, SIGCONT);
usleep(OVERLAY_DRAIN_WINDOW_MS * 1000);
logmsg("power-menu: closed (ret=%d) -> SIGCONT app, drain %dms, repaint req, then mark fg",
pm_ret, OVERLAY_DRAIN_WINDOW_MS);
} else {
logmsg("power-menu: closed (ret=%d) -> app never stopped (media keep-alive), no SIGCONT, repaint req",
pm_ret);
}
notif_active_set(a ? a->id : "");
unlink(POWMENU_ACTIVE);
return pm_ret;
}
/* launch_app_chain:launch_app / launch_activity 的共享核心。 /* launch_app_chain:launch_app / launch_activity 的共享核心。
* chain 非空 → 向子进程注入 MEOW_ACTIVITY/MEOW_CHAIN 并初始化 nav_stack(M3);空/NULL → 裸启动。 */ * chain 非空 → 向子进程注入 MEOW_ACTIVITY/MEOW_CHAIN 并初始化 nav_stack(M3);空/NULL → 裸启动。 */
static int launch_app_chain(struct app *a, const char *chain) static int launch_app_chain(struct app *a, const char *chain)
@@ -1585,8 +1585,9 @@ static int launch_app_chain(struct app *a, const char *chain)
uint32_t pick_req = 0; uint32_t pick_req = 0;
meow_ams_take_picker(pick_action, sizeof pick_action, meow_ams_take_picker(pick_action, sizeof pick_action,
pick_caller, sizeof pick_caller, &pick_req); 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 残留) */ 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 转过)→ 原进程回前台, /* M4 后台保活恢复:该 App 驻留后台(播放中经 nav-exit 转过)→ 原进程回前台,
* 不 fork/exec(进程仍存无法注入新 env:chain/picker 上下文忽略,记日志)。 * 不 fork/exec(进程仍存无法注入新 env:chain/picker 上下文忽略,记日志)。
@@ -1613,7 +1614,7 @@ static int launch_app_chain(struct app *a, const char *chain)
char dep_missing[32] = ""; char dep_missing[32] = "";
if (meow_check_app_depends(a, dep_missing, sizeof dep_missing) != 0) { 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); 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 继续自抑)。 /* 冷启动:标记内容置为前台归属(a->id),让本 App 首帧即不被自抑(后台常驻 App 继续自抑)。
@@ -1641,7 +1642,7 @@ static int launch_app_chain(struct app *a, const char *chain)
* watchdog and waitpid — so an exec failure never leaves an orphan watchdog * watchdog and waitpid — so an exec failure never leaves an orphan watchdog
* and never masquerades as "app ran and exited". */ * and never masquerades as "app ran and exited". */
int execpipe[2]; 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); fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
/* AMSD_DESIGN §5:stdout/stderr 管道捕获(logcat 思路)。fork 前建管道;子进程 dup2 到 /* AMSD_DESIGN §5:stdout/stderr 管道捕获(logcat 思路)。fork 前建管道;子进程 dup2 到
@@ -1661,7 +1662,7 @@ static int launch_app_chain(struct app *a, const char *chain)
close(execpipe[0]); close(execpipe[1]); close(execpipe[0]); close(execpipe[1]);
if (out_pipe[0] >= 0) close(out_pipe[0]); if (out_pipe[0] >= 0) close(out_pipe[0]);
if (out_pipe[1] >= 0) close(out_pipe[1]); 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) { if (pid == 0) {
/* child: exec the app. M2.12 注入 MEOW_LOG_DIR + MEOW_DATA_DIR/MEOW_CACHE_DIR /* child: exec the app. M2.12 注入 MEOW_LOG_DIR + MEOW_DATA_DIR/MEOW_CACHE_DIR
@@ -1850,7 +1851,7 @@ static int launch_app_chain(struct app *a, const char *chain)
int st = 0; int st = 0;
reap_wait(pid, &st); /* reap the failed child(有界) */ reap_wait(pid, &st); /* reap the failed child(有界) */
if (out_fd >= 0) close(out_fd); 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/确认) */ } /* end if (!resumed):冷启动分支(prepare/fork/exec/确认) */
@@ -1929,6 +1930,8 @@ static int launch_app_chain(struct app *a, const char *chain)
int nav_clean_exit = 0; /* M3:MSG_NAV{EXIT} 主动退出 → 不按崩溃上报 */ int nav_clean_exit = 0; /* M3:MSG_NAV{EXIT} 主动退出 → 不按崩溃上报 */
int to_background = 0; /* M4:nav-exit 转后台保活(进程保留,跳过退出尾) */ int to_background = 0; /* M4:nav-exit 转后台保活(进程保留,跳过退出尾) */
int app_prev_vold = 0; int app_prev_vold = 0;
int app_prev_volu = 0; /* E:VOL_UP 上一边沿(翻页门下边沿判定) */
int fg_volpage = 0; /* E:前台 App 是否声明 volume_page(会话期恒定) */
int app_seq_hits = 0; int app_seq_hits = 0;
unsigned long long app_seq_last = 0; unsigned long long app_seq_last = 0;
struct vol_snap app_vol0 = { -1, -1 }; struct vol_snap app_vol0 = { -1, -1 };
@@ -1938,6 +1941,11 @@ static int launch_app_chain(struct app *a, const char *chain)
* 修法:本循环以「背光==0 且非超时」补判手动息屏,唤醒时按 meow_auto_slept||manual 一并触发 * 修法:本循环以「背光==0 且非超时」补判手动息屏,唤醒时按 meow_auto_slept||manual 一并触发
* lck_wake_should_lock 锁屏,两条路径对齐。 */ * lck_wake_should_lock 锁屏,两条路径对齐。 */
int app_panel_manual_off = 0; int app_panel_manual_off = 0;
/* M40 F3/P3:叠加层/电源菜单返回后的排空窗。watchdog 在电源菜单期间让位,菜单关闭后
* 被 SIGCONT 的 App 会把冻结期缓存的触摸重放成 /tmp/meow_app_activity;若把它当用户
* 触摸,会立刻「亮屏 + 弹锁屏」(P3 的 ACTIVITY 唤醒路径)。窗口内的标记只消费不唤醒。 */
unsigned long long overlay_return_ms = now_ms();
int overlay_return_valid = 0;
/* M2.11 D:应用二进制/资源若在卡上,卡被拔可能 SIGBUS 崩。启动前记录,运行期监测卡移除。 /* M2.11 D:应用二进制/资源若在卡上,卡被拔可能 SIGBUS 崩。启动前记录,运行期监测卡移除。
* (拷贝到内存/板载 staging 的方案先不做,仅记录 APPS.md 规范。) */ * (拷贝到内存/板载 staging 的方案先不做,仅记录 APPS.md 规范。) */
int app_on_card = (strncmp(a->exec, MEOWAPPS, strlen(MEOWAPPS)) == 0); int app_on_card = (strncmp(a->exec, MEOWAPPS, strlen(MEOWAPPS)) == 0);
@@ -1961,13 +1969,23 @@ static int launch_app_chain(struct app *a, const char *chain)
g_sb_mode_last = -1; /* 强制首帧同步(进入新 App 会话清零,避免沿用上一 App 的缓存态) */ g_sb_mode_last = -1; /* 强制首帧同步(进入新 App 会话清零,避免沿用上一 App 的缓存态) */
unlink(MEOW_SBAR_MODE_FILE); /* 清可能残留的上一 App 顶条模式(崩溃/被杀未清),防新 App 误收合 */ unlink(MEOW_SBAR_MODE_FILE); /* 清可能残留的上一 App 顶条模式(崩溃/被杀未清),防新 App 误收合 */
if (!a->fullscreen) app_overlay_statusbar(); if (!a->fullscreen) app_overlay_statusbar();
/* M2.24:清上一次会话可能残留的电源菜单标记,只认本次新 watchdog 生成的请求。 */ /* M2.24:清上一次会话可能残留的电源菜单标记,只认本次新 watchdog 生成的请求。
* M40 F3:同时清 /tmp/meow_app_activity——上一会话(或启动手势)遗留的标记不能在本会话
* 首圈被当成「用户触摸」而触发息屏计时重置/唤醒+锁屏。 */
unlink(POWMENU_MARKER); unlink(POWMENU_MARKER);
unlink(POWMENU_ACTIVE); unlink(POWMENU_ACTIVE);
unlink(MEOW_APP_ACTIVITY_FILE);
/* §10.5:进入 App 前台会话。置位让 ipc_dispatch 的 MSG_NOTIF_OPEN 可入队(否则回 ERR); /* §10.5:进入 App 前台会话。置位让 ipc_dispatch 的 MSG_NOTIF_OPEN 可入队(否则回 ERR);
* 清残留 pending(防上一会话未消费的 OPEN 被本会话误展开)。 */ * 清残留 pending(防上一会话未消费的 OPEN 被本会话误展开)。 */
g_app_session_active = 1; g_app_session_active = 1;
meow_notif_open_clear(); meow_notif_open_clear();
/* E:系统级音量翻页——前台 App 声明 volume_page 时武装 sink(fd 就绪即生效;断线重连由
* 本循环每圈重试 meow_volpage_sink 幂等更新,keys.c 门只消费 VOL 按下且已武装)。 */
fg_volpage = meow_volpage_app_allows(a->id) ? 1 : 0;
if (fg_volpage) {
int vfd = meow_ipc_app_fd(a->id);
if (vfd >= 0) meow_volpage_sink(a->id, vfd);
}
for (;;) { for (;;) {
int r = waitpid(pid, &status, WNOHANG); int r = waitpid(pid, &status, WNOHANG);
if (r == pid) { ams_status_valid = 1; break; } if (r == pid) { ams_status_valid = 1; break; }
@@ -2025,6 +2043,8 @@ static int launch_app_chain(struct app *a, const char *chain)
m_notif_open_t nopen; m_notif_open_t nopen;
if (meow_notif_open_take(&nopen)) { if (meow_notif_open_take(&nopen)) {
int nr = run_app_notif_overlay(a, pid); int nr = run_app_notif_overlay(a, pid);
overlay_return_ms = now_ms(); /* M40 F3:排空窗起点(activity 标记忽略窗) */
overlay_return_valid = 1;
/* 输入基线重播(与电源菜单同款):面板期间按键可能已变化,重播防误判 key_edge */ /* 输入基线重播(与电源菜单同款):面板期间按键可能已变化,重播防误判 key_edge */
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
@@ -2057,14 +2077,43 @@ static int launch_app_chain(struct app *a, const char *chain)
for (int w = 0; w < KEYBUF_WORDS; w++) { 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_VOLUMEDOWN % 32))) vold = 1;
} }
/* E:系统级音量翻页——前台 App 声明 volume_page 时,VOL_UP/VOL_DOWN 按下边沿 →
* 系统翻页信号(keys.c 门,已武装才消费下发;未武装返回 0 放行,音量/物理键语义全保留)。
* 每圈幂等重试武装(reader 持久连接 HELLO 后 fd 就绪;断线重连换 fd 自动更新)。 */
if (fg_volpage) {
int vfd = meow_ipc_app_fd(a->id);
if (vfd >= 0) meow_volpage_sink(a->id, vfd);
}
{
int volu = 0;
for (int w = 0; w < KEYBUF_WORDS; w++)
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
if (volu && !app_prev_volu) (void)meow_volpage_key(KEY_VOLUMEUP, 1);
if (vold && !app_prev_vold) (void)meow_volpage_key(KEY_VOLUMEDOWN, 1);
app_prev_volu = volu;
}
/* M2.17 自动息屏(app 会话):任意物理键边沿重置计时;空闲>timeout 且非 keep_awake → 息屏; /* M2.17 自动息屏(app 会话):任意物理键边沿重置计时;空闲>timeout 且非 keep_awake → 息屏;
* 息屏后再按键 → 唤醒(fb_pan_buf0_force 兜底 LCDC 落 buffer0,内容随应用输入重绘)。 * 息屏后再按键 → 唤醒(fb_pan_buf0_force 兜底 LCDC 落 buffer0,内容随应用输入重绘)。
* M6.5 §310 问题1:watchdog 电源键手动息屏(背光 0、不置 meow_auto_slept)→ 唤醒时按 * M6.5 §310 问题1:watchdog 电源键手动息屏(背光 0、不置 meow_auto_slept)→ 唤醒时按
* app_panel_manual_off 一并锁屏,与超时路径(meow_auto_slept)对齐。 */ * app_panel_manual_off 一并锁屏,与超时路径(meow_auto_slept)对齐。 */
{ {
/* 背光==0 => 面板已关(超时路径已置 auto_slept;手动路径靠此补判)。 /* M40 F3:去掉单向闩锁。旧实现只在 watchdog 手动息屏时置 1、只有本循环唤醒分支清 1;
* read_brightness() 失败返回 -1(≠0),不误置位。 */ * watchdog 之后把屏点亮(pwr_woke)闩锁仍是 1 → 任何 activity 标记都会再次
if (read_brightness() == 0) app_panel_manual_off = 1; * 「亮屏+锁屏」(P3 放大器)。改为每圈按真实背光复位:
* - 背光==0 → 置 1(手动/超时熄屏都算);
* - 背光>0 且**没有任何键按住** → 复位 0(屏已被 watchdog 点亮,不再残留闩锁);
* - 背光>0 但电源键仍按住(本次唤醒按压)→ 保留一拍,让下面的 key-wake 分支仍能
* 按 lck_wake_should_lock 弹锁屏(不牺牲「手动息屏后电源唤醒要锁屏」语义)。
* read_brightness() 失败返回 -1(≠0),不误置/误清。 */
int bri_now = read_brightness();
if (bri_now == 0) {
app_panel_manual_off = 1;
} else if (bri_now > 0) {
int any_key_down = 0;
for (int w = 0; w < KEYBUF_WORDS; w++)
if (kp_power.cur[w] || kp_keys.cur[w]) { any_key_down = 1; break; }
if (!any_key_down) app_panel_manual_off = 0;
}
int key_edge = 0; int key_edge = 0;
for (int w = 0; w < KEYBUF_WORDS; w++) for (int w = 0; w < KEYBUF_WORDS; w++)
if (kp_power.cur[w] != save_pow[w] || kp_keys.cur[w] != save_keys[w]) { key_edge = 1; break; } if (kp_power.cur[w] != save_pow[w] || kp_keys.cur[w] != save_keys[w]) { key_edge = 1; break; }
@@ -2094,6 +2143,8 @@ static int launch_app_chain(struct app *a, const char *chain)
* (launcher 不崩、run_menu 重绘主屏),同 run_app_notif_overlay 的 died 分支。 */ * (launcher 不崩、run_menu 重绘主屏),同 run_app_notif_overlay 的 died 分支。 */
if (lck_wake_should_lock()) { if (lck_wake_should_lock()) {
int lr2 = run_app_lock_overlay(a, pid); int lr2 = run_app_lock_overlay(a, pid);
overlay_return_ms = now_ms(); /* M40 F3:排空窗起点 */
overlay_return_valid = 1;
if (lr2 != 0) { if (lr2 != 0) {
reap_wait(pid, &status); reap_wait(pid, &status);
ams_status_valid = 1; ams_status_valid = 1;
@@ -2114,11 +2165,15 @@ static int launch_app_chain(struct app *a, const char *chain)
} }
} }
/* M2.71-bug5:App 前台触摸/交互(launcher 看不到应用内触摸,SDK 写标记)→ 重置自动息屏 /* M2.71-bug5:App 前台触摸/交互(launcher 看不到应用内触摸,SDK 写标记)→ 重置自动息屏
* 计时;若已自动息屏,按 key-wake 同款路径唤醒(面板上电 + 落 buffer0)。 */ * 计时;若已自动息屏,按 key-wake 同款路径唤醒(面板上电 + 落 buffer0)。
* M40 F3:叠加层/电源菜单刚返回的排空窗内,该标记是 App 被 SIGCONT 后重放的冻结期
* 输入,只消费不唤醒(否则「亮屏 + 弹 PIN」,P3)。 */
if (access(MEOW_APP_ACTIVITY_FILE, F_OK) == 0) { if (access(MEOW_APP_ACTIVITY_FILE, F_OK) == 0) {
unlink(MEOW_APP_ACTIVITY_FILE); unlink(MEOW_APP_ACTIVITY_FILE);
app_last_input = now_ms(); app_last_input = now_ms();
if (meow_auto_slept || app_panel_manual_off) { if (overlay_activity_drain_active(overlay_return_valid, overlay_return_ms, now_ms())) {
logmsg("activity marker consumed in overlay drain window -> no wake");
} else if (meow_auto_slept || app_panel_manual_off) {
logmsg("screen-off (app): touch wake (auto=%d manual=%d)", logmsg("screen-off (app): touch wake (auto=%d manual=%d)",
meow_auto_slept, app_panel_manual_off); meow_auto_slept, app_panel_manual_off);
meow_panel_on(); meow_panel_on();
@@ -2129,6 +2184,8 @@ static int launch_app_chain(struct app *a, const char *chain)
* §310:叠加期 App 崩溃 → 回收僵尸走统一退出路径(launcher 不崩、run_menu 重绘主屏)。 */ * §310:叠加期 App 崩溃 → 回收僵尸走统一退出路径(launcher 不崩、run_menu 重绘主屏)。 */
if (lck_wake_should_lock()) { if (lck_wake_should_lock()) {
int lr2 = run_app_lock_overlay(a, pid); int lr2 = run_app_lock_overlay(a, pid);
overlay_return_ms = now_ms(); /* M40 F3:排空窗起点 */
overlay_return_valid = 1;
if (lr2 != 0) { if (lr2 != 0) {
reap_wait(pid, &status); reap_wait(pid, &status);
ams_status_valid = 1; ams_status_valid = 1;
@@ -2169,20 +2226,16 @@ static int launch_app_chain(struct app *a, const char *chain)
app_prev_vold = vold; app_prev_vold = vold;
if (app_seq_hits > 0 && now_ms() - app_seq_last > (unsigned)seq_gap_ms * 2) if (app_seq_hits > 0 && now_ms() - app_seq_last > (unsigned)seq_gap_ms * 2)
app_seq_hits = 0; app_seq_hits = 0;
/* M2.24 全面按键接管:watchdog 检测到电源长按≥3s 写 /tmp/meow_power_menu → 此处消费: /* M2.24/M40 全面按键接管:watchdog 检测到电源长按≥power.longpress_menu_ms 写
* SIGSTOP 应用冻结 → 写 powmenu_active 让 watchdog 让位 → 统一电源菜单(launcher 一处绘制, * /tmp/meow_power_menu → 此处消费。M40 F2 起走 run_app_power_menu() 的完整 §10.5
* 复用 show_power_menu,含触摸/音量导航/电源确认/8s 强关/pm_interacted 保护)→ 菜单关 → * 协议(SIGSTOP + 全抑制标记 + 帧恢复 + SIGCONT→排空→标记翻回前台 + 整帧重画请求),
* 清 active → SIGCONT 应用续跑。 */ * 不再只 SIGSTOP/show_power_menu/SIGCONT(旧实现关闭后屏幕仍是菜单帧 = P2 主因)。 */
if (access(POWMENU_MARKER, F_OK) == 0) { if (access(POWMENU_MARKER, F_OK) == 0) {
unlink(POWMENU_MARKER); unlink(POWMENU_MARKER);
logmsg("watchdog: power menu request (app %s) -> SIGSTOP + menu", a->name); logmsg("watchdog: power menu request (app %s) -> overlay session", a->name);
kill(pid, SIGSTOP); int pm_ret = run_app_power_menu(a, pid);
int mfd = open(POWMENU_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644); overlay_return_ms = now_ms(); /* M40 F3:排空窗起点(activity 标记忽略窗) */
if (mfd >= 0) { write(mfd, "1", 1); close(mfd); } overlay_return_valid = 1;
int pm_ret = show_power_menu();
unlink(POWMENU_ACTIVE);
kill(pid, SIGCONT);
logmsg("watchdog: power menu closed (ret=%d) -> SIGCONT app", pm_ret);
/* M2.25:菜单期间按键可能已变化,重播 save_pow/save_keys 防误判 key_edge /* M2.25:菜单期间按键可能已变化,重播 save_pow/save_keys 防误判 key_edge
* (避免菜单关闭瞬间误触发「键唤醒」或刷新自动息屏计时)。 */ * (避免菜单关闭瞬间误触发「键唤醒」或刷新自动息屏计时)。 */
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
@@ -2190,6 +2243,11 @@ static int launch_app_chain(struct app *a, const char *chain)
memcpy(save_pow, kp_power.cur, sizeof save_pow); memcpy(save_pow, kp_power.cur, sizeof save_pow);
memcpy(save_keys, kp_keys.cur, sizeof save_keys); memcpy(save_keys, kp_keys.cur, sizeof save_keys);
app_last_input = now_ms(); /* M2.17:菜单交互即输入,重置应用会话自动息屏计时 */ app_last_input = now_ms(); /* M2.17:菜单交互即输入,重置应用会话自动息屏计时 */
if (pm_ret == 2) { /* App 在菜单期死亡:回收僵尸走统一退出路径 */
reap_wait(pid, &status);
ams_status_valid = 1;
break;
}
if (pm_ret != 0) break; /* 关机/重启/回HibyOS:系统通常已 reboot,防御性退出 */ if (pm_ret != 0) break; /* 关机/重启/回HibyOS:系统通常已 reboot,防御性退出 */
} }
/* 「同一个状态栏」闭环:常驻顶条叠加(agent-146 全屏收合接入)。 /* 「同一个状态栏」闭环:常驻顶条叠加(agent-146 全屏收合接入)。
@@ -2223,6 +2281,7 @@ static int launch_app_chain(struct app *a, const char *chain)
if (to_background) { if (to_background) {
g_app_session_active = 0; /* 前台会话结束(ipc_dispatch 不再受理 NOTIF_OPEN) */ g_app_session_active = 0; /* 前台会话结束(ipc_dispatch 不再受理 NOTIF_OPEN) */
meow_notif_open_clear(); meow_notif_open_clear();
meow_volpage_sink_clear(); /* E:离开前台撤翻页 sink(音量键交还系统) */
if (wpid > 0) { if (wpid > 0) {
/* 看门狗随前台会话结束回收(后台无手势监听;有界等防挂死) */ /* 看门狗随前台会话结束回收(后台无手势监听;有界等防挂死) */
kill(wpid, SIGTERM); kill(wpid, SIGTERM);
@@ -2251,6 +2310,7 @@ static int launch_app_chain(struct app *a, const char *chain)
/* §10.5:App 前台会话结束,清会话标记(ipc_dispatch 不再受理 MSG_NOTIF_OPEN) */ /* §10.5:App 前台会话结束,清会话标记(ipc_dispatch 不再受理 MSG_NOTIF_OPEN) */
g_app_session_active = 0; g_app_session_active = 0;
meow_notif_open_clear(); meow_notif_open_clear();
meow_volpage_sink_clear(); /* E:离开前台撤翻页 sink(音量键交还系统) */
/* M2.17 兜底:App 会话结束(waitpid 回收/崩溃/被杀,回主界面)清 fg_act —— /* M2.17 兜底:App 会话结束(waitpid 回收/崩溃/被杀,回主界面)清 fg_act ——
* App 在 keep_awake activity(如 ncm 播放页)被 OOM/崩溃杀死且未弹回 home 时,g_fg_act 会 * App 在 keep_awake activity(如 ncm 播放页)被 OOM/崩溃杀死且未弹回 home 时,g_fg_act 会
* 滞留 "<act>",使主页 fg_keep_awake()==1 不再自动息屏。此处兜底清空(App 已退出/死亡, * 滞留 "<act>",使主页 fg_keep_awake()==1 不再自动息屏。此处兜底清空(App 已退出/死亡,
+49 -31
View File
@@ -2,7 +2,7 @@
* *
* 容器:sub_page_t 脚手架(subpage.c,与 wifi_page/bt_page 同款)。顶栏=meow_topappbar: * 容器:sub_page_t 脚手架(subpage.c,与 wifi_page/bt_page 同款)。顶栏=meow_topappbar:
* 返回箭头 + 标题「后台管理」+ **顶栏标题栏右侧「一键清除后台」按钮**(sub_page_t.top_action, * 返回箭头 + 标题「后台管理」+ **顶栏标题栏右侧「一键清除后台」按钮**(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 重扫,实时增删): * 列表(live=1 约 2s 重扫,实时增删):
* - 每后台 App 一行 SUB_ROW:label=名字,sub=「CPU x% · yMB · 状态」,**行最右一个「杀」按钮** * - 每后台 App 一行 SUB_ROW:label=名字,sub=「CPU x% · yMB · 状态」,**行最右一个「杀」按钮**
* (sub_page_t.row_draw 行自绘 + row_tap 自定义命中:命中按钮区=kill 该 App;行体不响应)。 * (sub_page_t.row_draw 行自绘 + row_tap 自定义命中:命中按钮区=kill 该 App;行体不响应)。
@@ -38,8 +38,9 @@ typedef struct {
int napps; int napps;
bgmgr_row_t rows[MEOW_BGMR_ROW_MAX]; bgmgr_row_t rows[MEOW_BGMR_ROW_MAX];
int n; int n;
char toast[64]; /* 行副文本存储:sub_row_t.sub 被 SDK 行池/自绘在整页会话内持续读取,
long toast_ms; * 不能指向 bgm_rows 的栈帧(曾用函数内 char sub[80] → 悬垂,页面副标题为乱码)。 */
char subs[MEOW_BGMR_ROW_MAX][80];
} bgm_ctx_t; } bgm_ctx_t;
/* 找 struct app(launcher 应用表)by id */ /* 找 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 侧真实实现)---- */ /* ---- meow_bgmgr ops 注入(UI 侧真实实现)---- */
static int bgm_app_meta(void *ctx, const char *app_id, pid_t pid, static int bgm_app_meta(void *ctx, const char *app_id, pid_t pid,
char *name, int nl, char *icon, int il, char *name, int nl, char *icon, int il,
char *framework, int *is_fg) char *protected_app, int *is_fg)
{ {
(void)pid; (void)pid;
bgm_ctx_t *b = ctx; bgm_ctx_t *b = ctx;
struct app *a = bgm_app_find(b->apps, b->napps, app_id); struct app *a = bgm_app_find(b->apps, b->napps, app_id);
*framework = 0; *protected_app = 0;
*is_fg = 0; *is_fg = 0;
if (!a) return -1; /* 未注册 → name 回退 app_id */ if (!a) return -1; /* 未注册 → name 回退 app_id */
if (a->name[0]) snprintf(name, nl, "%s", a->name); if (a->name[0]) snprintf(name, nl, "%s", a->name);
if (icon && il > 0) snprintf(icon, il, "%s", a->icon[0] ? a->icon : ""); 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; return 0;
} }
static int bgm_enumerate(void *ctx, bgmgr_row_t *rows, int max) static int bgm_enumerate(void *ctx, bgmgr_row_t *rows, int max)
{ {
(void)ctx; (void)ctx;
int n = meow_bg_count(); /* 逐槽扫描并**压缩**(g_bg 可能有空洞),跳过已发出终止的条目:
if (n > max) n = max; * meow_bg_terminate 只置 term_at_ms 发 SIGTERM,真正的 reap/摘表在 meow_bg_tick(launcher
for (int i = 0; i < n; i++) { * 主循环),它在本页 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); const meow_bg_entry_t *e = meow_bg_at(i);
if (!e || !e->used || !e->app_id[0]) continue; if (!e || !e->used || !e->app_id[0]) continue;
memset(&rows[i], 0, sizeof rows[i]); if (e->term_at_ms) continue; /* 已进入终止序列(kill/宽限回收/卡拔)→ 行不再显示 */
snprintf(rows[i].app_id, sizeof rows[i].app_id, "%s", e->app_id); memset(&rows[k], 0, sizeof rows[k]);
rows[i].pid = e->pid; snprintf(rows[k].app_id, sizeof rows[k].app_id, "%s", e->app_id);
rows[i].keepalive = (e->keepalive ? 1 : 0); rows[k].pid = e->pid;
rows[i].on_card = (e->on_card ? 1 : 0); rows[k].keepalive = (e->keepalive ? 1 : 0);
rows[i].bg_since_ms = e->bg_since_ms; 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)。 /* 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; return r;
} }
for (int i = 0; i < b->n && r < max; i++) { for (int i = 0; i < b->n && r < max; i++) {
char sub[80]; /* 副文本写进 ctx 的持久缓冲(b->subs):sub_row_t.sub 会被 SDK 行池在整页会话内反复读取,
snprintf(sub, sizeof sub, "CPU %.1f%% · %s · %s", b->rows[i].pcpu, * 指向本函数栈帧的 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])); bgmgr_mem_str(b->rows[i].rss_kb), bgmgr_status_str(&b->rows[i]));
rbuf[r].kind = SUB_ROW; rbuf[r].kind = SUB_ROW;
rbuf[r].label = b->rows[i].name[0] ? b->rows[i].name : b->rows[i].app_id; 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 定位行 */ rbuf[r].act = i; /* row_tap/row_draw 用 i 定位行 */
r++; 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; /* 内容在卡内垂直居中(行中心=卡中心) */ int ry = cy + (ch - (th + tsub + 4)) / 2; /* 内容在卡内垂直居中(行中心=卡中心) */
if (r->label) meow_ui_text(rx, ry, r->label, 20, mt->onSurface); 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); 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); 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); int bth = meow_ui_text_height(16);
if (bth > bh) bth = bh; 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,行体不响应/不误杀)---- */ /* ---- 行内自定义命中(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); 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 (x < bx || x >= bx + bw || y < by || y >= by + bh) return 0; /* 行体/按钮外 → 不杀 */
if (!bgmgr_can_kill(&b->rows[i])) { if (!bgmgr_can_kill(&b->rows[i])) {
snprintf(b->toast, sizeof b->toast, "该 App 不可杀(框架/前台)"); /* 禁点:受保护/前台/无进程 → 按钮已置灰,不 kill(返回 1=重绘)。
b->toast_ms = (long)now_ms(); * 留一行日志:「按了没反应」当年因为没有这行日志,只能靠翻 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; return 1;
} }
meow_bg_terminate(b->rows[i].app_id); int rc = meow_bg_terminate(b->rows[i].app_id); /* 置 term_at_ms → bgm_enumerate 下一跳行消失(可见反馈) */
snprintf(b->toast, sizeof b->toast, "已请求终止 %s", b->rows[i].app_id); if (rc != 0)
b->toast_ms = (long)now_ms(); logmsg("bgpage: kill app=%s 未命中后台表 rc=%d(列表/后台表不同步)", b->rows[i].app_id, rc);
return 1; return 1;
} }
@@ -270,11 +287,12 @@ static int bgm_top_action(void *ctx)
memset(&ops, 0, sizeof ops); memset(&ops, 0, sizeof ops);
ops.ctx = b; ops.ctx = b;
ops.enumerate = bgm_enumerate; /* 行已由 bgm_rows 建 */ 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); 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); if (k < b->n)
else snprintf(b->toast, sizeof b->toast, "无后台 App 可清"); logmsg("bgpage: 一键清除 杀 %d/%d(其余为前台/无进程/受保护)", k, b->n);
b->toast_ms = (long)now_ms(); return 1; /* 重绘:重扫行(已清行消失) */
return 1;
} }
/* ---- 入口:运行后台管理页(阻塞到返回)---- */ /* ---- 入口:运行后台管理页(阻塞到返回)---- */
+159 -20
View File
@@ -17,8 +17,14 @@
* *
* 【扫描节流(§七)】进页 nm_bt_scan_on、退页/超时 nm_bt_scan_off;7s 节流由 netmgrd 统一控。 * 【扫描节流(§七)】进页 nm_bt_scan_on、退页/超时 nm_bt_scan_off;7s 节流由 netmgrd 统一控。
* *
* 与网络线共用 net.c(net_bt_* 直连回退 + bt_role 角色判定)。开关写 network.bluetooth_enabled + * 【M47 开关可信化】顶栏开关的显示态只来自真实态查询(daemon `bt status` 的 powered,
* netmgrd on/off;连接/断开/配对经 netmgrd(netmgr_ipc.c nm_bt_* 封装)。 * 不可达时直连 hciconfig 探测),三态:-1 未知 / 0 关 / 1 开;探测失败=未知(画「?」,不当已关)。
* settings 的 network.bluetooth_enabled 降级为「意图持久化」,不再决定开关外观。点击后先出示
* 「正在开启/关闭…(等待设备确认)」,下发命令 → 有界轮询真实态收敛 → 用真实态定稿;失败/超时
* 回滚并弹窗告知(不许乐观显示成功)。
*
* 与网络线共用 net.c(net_bt_* 直连回退 + bt_role 角色判定)。连接/断开/配对经 netmgrd
* (netmgr_ipc.c nm_bt_* 封装)。
*/ */
#include "meow_launcher_internal.h" #include "meow_launcher_internal.h"
#include <stdio.h> #include <stdio.h>
@@ -42,18 +48,40 @@ typedef struct {
meow_net_t near[S_NET_MAX]; meow_net_t near[S_NET_MAX];
bt_ep_t a2dp[S_NET_MAX]; bt_ep_t a2dp[S_NET_MAX];
int conn_n, paired_n, near_n, a2dp_n; int conn_n, paired_n, near_n, a2dp_n;
int bt_on; int bt_on; /* 真实态三态(-1 未知 / 0 关 / 1 开)——只由 bt_state_now() 写 */
unsigned long long bt_on_ts; /* 上面这次查询的时刻(rows/top_sw 同一 tick 只查一次) */
int output_bt; /* 0=DAC/本地,1=蓝牙 */ int output_bt; /* 0=DAC/本地,1=蓝牙 */
int full; /* 1=本次 rows() 全量重扫(conn/paired/a2dp);0=仅附近(缓存)+角色(节流) */ int full; /* 1=本次 rows() 全量重扫(conn/paired/a2dp);0=仅附近(缓存)+角色(节流) */
char mode[48]; /* bt_role 中文描述(次行摘要) */ char mode[48]; /* bt_role 中文描述(次行摘要) */
/* 行副文本池:sub_page_t 行 label/sub 为 const char*,跨 rows() 返回后仍须有效(直到下次 /* 行副文本池:sub_page_t 行 label/sub 为 const char*,跨 rows() 返回后仍须有效(直到下次
* rows() 重填)。本地栈缓冲会悬垂(agent-396 根因同类),故格式化 sub 一律写进本池。 */ * rows() 重填)。本地栈缓冲会悬垂(agent-396 根因同类),故格式化 sub 一律写进本池。 */
char pool[512]; char pool[768];
int pool_off; int pool_off;
} bt_ctx_t; } bt_ctx_t;
/* M47:settings 里的网络开关布尔值降级为「用户意图的持久化」,不再参与显示判定;
* 显示态一律来自 nm_bt_power_state()。 */
extern int nm_bt_power_state(void); /* netmgr_ipc.c:daemon 权威 → 直连探测,三态 */
/* 格式化并拷入 ctx 行副文本池(返回稳定指针;溢出则绕回——512B 远超单页行数所需)。 */ /* 开关收敛参数(宿主测试可在 include 前改小,见 test_bt_page.c)。
* 上限从**点击时刻**起算(评审 r1 P2-3),故它约束的是「从点击到出结论」的总时长:
* - 快路径(daemon 正常,`bt status` 本地 IPC 几 ms):4s 内约 16 次探测,通常 1~2s 收敛;
* - 慢路径(netmgrd 卡住:on/off 读超时 1.5s → 直连 s_net_toggle_bt 的 hciconfig 0.3~2s;
* 之后一次探测 = status 1.5s 超时 + hciconfig 兜底 1.8s ≈ 3.3s):总阻塞最坏
* ≈3.5s(下发) + 3.3s(最后一次探测) ≈ **7s**,预算内只够 1 次探测。此时能确认的只是
* 「探测超时」,**不能**宣称「设备无响应」——弹窗文案按此区分(「无法确认当前状态」)。
* 250ms 只是快路径的轮询间隔(不 20ms 级忙轮询);慢路径由探测自身耗时决定。 */
#ifndef BT_TOGGLE_SETTLE_MS
#define BT_TOGGLE_SETTLE_MS 4000
#endif
#ifndef BT_TOGGLE_POLL_MS
#define BT_TOGGLE_POLL_MS 250
#endif
#define BT_STATE_TTL_MS 800 /* 真实态缓存:rows/top_sw 同一帧共用一次查询 */
/* 格式化并拷入 ctx 行副文本池(返回稳定指针;溢出则绕回——768B 远超单页行数所需)。 */
static char *bt_pool(bt_ctx_t *c, const char *fmt, ...) static char *bt_pool(bt_ctx_t *c, const char *fmt, ...)
{ {
va_list ap; char tmp[128]; va_list ap; char tmp[128];
@@ -95,27 +123,127 @@ static const char *bt_conn_name(const bt_ctx_t *c, const char *mac)
} }
/* 顶栏开关当前值 */ /* ===== M47:开关「意图 → 真实态」收敛判定(纯函数;无 I/O、无 UI、无全局态)=====
* 输入:intent(1=要开/0=要关)、last_real(下发前最后一次可信真实态,-1 未知)、
* dispatch_ok(下发命令是否成功)、probe(有界等待后的探测结果,-1 未知)、
* timed_out(是否已到等待上限)。
* 输出(四个访问器共用同一套判定,拆开只为调用处可读):
* meow_toggle_display() 应显示的态(-1 未知 / 0 / 1):**只认 probe**。probe 未知 → -1,
* 不许回退 last_real 冒充确定态(评审 r1 P2-2:从没被确认过的旧值
* 一旦显示成确定态,就与本单「探测失败=未知」的契约自相矛盾)。
* meow_toggle_pending() 1=还要继续等:下发成功、未超时、且 probe 未等于意图。
* 注意「probe 未知」也算没收敛——绝不因为 last_real 恰好等于意图就收工。
* meow_toggle_rollback() 1=意图未达成(显示态 != 意图)→ UI 必须回滚到真实态。
* meow_toggle_error() 1=收尾后仍未确认 → 必须给用户可见的错误提示(不许静默失败)。
* last_real 不参与状态判定:它只由调用方用于弹窗文案(「上次已知为开启,当前无法确认」),
* 以及后续异步化时判断「回滚到哪」;保留在接口里是因为它是本单约定的输入维度。
* 判定矩阵见 test_bt_page.c「收敛矩阵」;wifi_page.c 复用同一实现。 */
static void mtog_calc(int intent, int last_real, int dispatch_ok, int probe, int timed_out,
int *disp, int *pending, int *rollback, int *error)
{
int want = intent ? 1 : 0;
int d = (probe >= 0) ? (probe ? 1 : 0) : -1; /* 未确认 = 未知,绝不拿旧值顶替 */
int confirmed = (probe >= 0) && ((probe ? 1 : 0) == want);
(void)last_real; /* 仅弹窗文案用,见函数头注释 */
*disp = d;
*pending = (dispatch_ok && !timed_out && !confirmed) ? 1 : 0;
*rollback = (d == want) ? 0 : 1;
*error = (!confirmed && !*pending) ? 1 : 0;
}
int meow_toggle_display(int intent, int last_real, int dispatch_ok, int probe, int timed_out)
{
int d, p, r, e;
mtog_calc(intent, last_real, dispatch_ok, probe, timed_out, &d, &p, &r, &e);
return d;
}
int meow_toggle_pending(int intent, int last_real, int dispatch_ok, int probe, int timed_out)
{
int d, p, r, e;
mtog_calc(intent, last_real, dispatch_ok, probe, timed_out, &d, &p, &r, &e);
return p;
}
int meow_toggle_rollback(int intent, int last_real, int dispatch_ok, int probe, int timed_out)
{
int d, p, r, e;
mtog_calc(intent, last_real, dispatch_ok, probe, timed_out, &d, &p, &r, &e);
return r;
}
int meow_toggle_error(int intent, int last_real, int dispatch_ok, int probe, int timed_out)
{
int d, p, r, e;
mtog_calc(intent, last_real, dispatch_ok, probe, timed_out, &d, &p, &r, &e);
return e;
}
/* 真实态(带 800ms 缓存:top_sw 与 rows 在同一帧各查一次,别为同一问题问 daemon 两遍)。 */
static int bt_state_now(bt_ctx_t *c, int force)
{
unsigned long long t = now_ms();
if (!force && c->bt_on_ts && t - c->bt_on_ts < BT_STATE_TTL_MS) return c->bt_on;
c->bt_on = nm_bt_power_state();
c->bt_on_ts = t;
return c->bt_on;
}
/* 顶栏开关当前值:只报真实态(-1=未知,subpage 画「?」第三态) */
static int bt_top_sw(void *ctx, int *on) static int bt_top_sw(void *ctx, int *on)
{ {
(void)ctx; bt_ctx_t *c = ctx;
int v = 0; *on = bt_state_now(c, 0);
if (meow_settings_get_bool("network.bluetooth_enabled", &v) != 0) v = 0;
*on = v;
return 1; return 1;
} }
/* 顶栏开关切换:写设置 + on/off(netmgrd IPC,回退直连 hciconfig)+ 触发扫描 + 同步状态栏 */ /* 顶栏开关切换:落盘意图 → 下发 → 有界等待真实态收敛 → 用真实态定稿/回滚 + 可见错误提示。
* 绝不在下发后就直接把 UI 设成用户想要的样子(那是「显示意图」而不是「显示状态」)。 */
static void bt_top_sw_set(void *ctx, int on) static void bt_top_sw_set(void *ctx, int on)
{ {
(void)ctx; bt_ctx_t *c = ctx;
meow_settings_set_bool("network.bluetooth_enabled", on); unsigned long long t_click = now_ms(); /* 上限从点击起算(含下发耗时,见参数注释) */
nm_bt_onoff(on); int want = on ? 1 : 0;
g_sys.bt_on = on; int last_real = bt_state_now(c, 0);
bt_role_refresh(1); meow_settings_set_bool("network.bluetooth_enabled", want); /* 意图持久化 */
paint_sb_right();
meow_settings_save(); meow_settings_save();
if (on) nm_bt_scan_on(); int dispatch_ok = (nm_bt_onoff(want) == MEOW_OK);
unsigned long long deadline = t_click + BT_TOGGLE_SETTLE_MS;
int probe = bt_state_now(c, 1), timed_out = 0;
while (meow_toggle_pending(want, last_real, dispatch_ok, probe, timed_out)) {
if (now_ms() >= deadline) { timed_out = 1; break; }
usleep(BT_TOGGLE_POLL_MS * 1000);
probe = bt_state_now(c, 1);
}
int disp = meow_toggle_display(want, last_real, dispatch_ok, probe, timed_out);
int rb = meow_toggle_rollback(want, last_real, dispatch_ok, probe, timed_out);
int err = meow_toggle_error(want, last_real, dispatch_ok, probe, timed_out);
unsigned long long elapsed = now_ms() - t_click;
c->bt_on = disp; c->bt_on_ts = now_ms();
/* 回滚语义:只在拿到**真实态**时才改写 settings(确认态或确认的相反态)。
* disp == -1(探测未知)时保留用户原意图不动——本键被 notif_center / settings_page 当
* 显示态读、被 app_launcher 用来决定自动拉起 WiFi,写 0 会把「不知道」污染成「确定已关」
* (评审 r1 P2-1)。 */
if (disp >= 0) {
meow_settings_set_bool("network.bluetooth_enabled", disp == 1);
meow_settings_save();
}
bt_role_refresh(1); /* 先刷新角色(它会写 g_sys.bt_on),再用真实态覆盖 */
if (disp >= 0) g_sys.bt_on = disp;
paint_sb_right();
if (disp == 1) nm_bt_scan_on();
logmsg("bt_page: toggle want=%d ok=%d probe=%d to=%d disp=%d rollback=%d err=%d elapsed=%llums",
want, dispatch_ok, probe, timed_out, disp, rb, err, elapsed);
if (err) {
char line[128];
const char *txt[1]; txt[0] = line;
/* 文案必须与结论同义:probe 未知 = 「无法确认」(不冒充未生效/未响应);
* probe 已知且 != 意图 = 确认未生效。last_real 只在这里出现。 */
if (disp < 0)
snprintf(line, sizeof line, "无法确认当前状态(上次已知:%s)",
last_real < 0 ? "未知" : (last_real ? "开启" : "关闭"));
else
snprintf(line, sizeof line, "切换未生效:当前仍为%s", disp ? "开启" : "关闭");
s_dd_modal("蓝牙", txt, 1, -1);
}
} }
@@ -126,7 +254,7 @@ static int bt_rows(void *ctx, sub_row_t *rbuf, int max)
bt_ctx_t *c = ctx; bt_ctx_t *c = ctx;
int full = c->full; c->full = 0; /* 消费一次全量标记 */ int full = c->full; c->full = 0; /* 消费一次全量标记 */
c->pool_off = 0; /* 行副文本池绕回(上轮行字符串已被重填替换) */ c->pool_off = 0; /* 行副文本池绕回(上轮行字符串已被重填替换) */
c->bt_on = nm_bt_powered(); /* IPC status;回退直连 hci_powered() */ c->bt_on = bt_state_now(c, 0); /* 真实态三态(未知名 -1);见文件头 M47 说明 */
c->output_bt = 0; c->output_bt = 0;
if (meow_settings_get_bool("audio.output_bt", &c->output_bt) != 0) c->output_bt = 0; if (meow_settings_get_bool("audio.output_bt", &c->output_bt) != 0) c->output_bt = 0;
if (full || c->conn_n < 0) { /* 首次/动作后:全量重扫 conn/paired/a2dp(全权威) */ if (full || c->conn_n < 0) { /* 首次/动作后:全量重扫 conn/paired/a2dp(全权威) */
@@ -148,6 +276,13 @@ static int bt_rows(void *ctx, sub_row_t *rbuf, int max)
rbuf[n].kind = SUB_HDR; rbuf[n].label = "网络管理服务未运行(直连模式)"; rbuf[n].kind = SUB_HDR; rbuf[n].label = "网络管理服务未运行(直连模式)";
rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++;
} }
/* 第三态显式呈现:真实态未知 ≠ 已关。顶栏开关此时也画「?」(subpage.c),这里再给一行
* 文字说明,避免用户把「不知道」读成「关着」。 */
if (c->bt_on < 0 && n < max) {
rbuf[n].kind = SUB_ROW; rbuf[n].label = "蓝牙状态未知";
rbuf[n].sub = "无法读取开关状态(服务/控制器无响应)";
rbuf[n].on = 0; rbuf[n].act = -2; n++;
}
/* ② A2DP 状态摘要(设备名·codec·采样率·输出=蓝牙/DAC) */ /* ② A2DP 状态摘要(设备名·codec·采样率·输出=蓝牙/DAC) */
{ {
@@ -270,10 +405,14 @@ static int bt_act(void *ctx, int idx, int act)
char mac[24] = ""; char mac[24] = "";
/* 输出目标/编解码器:与本页设备行 act 段(CONN/PAIRED/NEAR base)不冲突,先处理 */ /* 输出目标/编解码器:与本页设备行 act 段(CONN/PAIRED/NEAR base)不冲突,先处理 */
if (act == BT_ACT_OUTPUT) { if (act == BT_ACT_OUTPUT) {
/* 注意返回值语义:return 1 = 「请重绘/重填行」,**不是**「切换已生效」。本分支只写
* settings(audiod 仅启动时读 audio.output_bt),在线切需要 MSG_AUDIO_OUTPUT,
* 属 M46 输出目标单的范围(M45 finding: 20261111-survey-m45-btctl-bug-bt-settings-audiod)。
* 这里不假装成功:不发 IPC、不写 g_sys,只落偏好。 */
c->output_bt = !c->output_bt; c->output_bt = !c->output_bt;
meow_settings_set_bool("audio.output_bt", c->output_bt); meow_settings_set_bool("audio.output_bt", c->output_bt);
meow_settings_save(); meow_settings_save();
logmsg("bt_page: output_target=%s", c->output_bt ? "bt" : "dac"); logmsg("bt_page: output_target=%s (preference only; audiod reads at start)", c->output_bt ? "bt" : "dac");
return 1; /* 重绘 + 重填行 */ return 1; /* 重绘 + 重填行 */
} }
if (act == BT_ACT_CODEC) { if (act == BT_ACT_CODEC) {
+48 -11
View File
@@ -11,22 +11,59 @@
# - launcher 与 SDK 共用同一 meow_font.o(已打进 meow_ui.a)——不再单独链 meow_font.a。 # - launcher 与 SDK 共用同一 meow_font.o(已打进 meow_ui.a)——不再单独链 meow_font.a。
# - MEOWOS_VERSION 0.7.21 → 0.7.23(M2.28)。 # - MEOWOS_VERSION 0.7.21 → 0.7.23(M2.28)。
# #
# Build(在 patch_v2/meow_launcherd 下): # 用法(在 patch_v2/meow_launcherd 下或任意目录):
# zig-mipsel-cc -I../sdk -Os -static main_menu.c statusbar.c fb_compositor.c settings_page.c \ # bash build_launcher.sh # 交叉编译 meow_launcherd(mipsel 静态)
# power_menu.c notif_center.c app_launcher.c audio.c backlight.c net.c about.c \ # bash build_launcher.sh test # 宿主 gcc 跑本目录 affected 单测(test_bt_page / test_netmgr_ipc)
# font_theme.c keys.c util.c meow_touch.c meow_settings.c meow_notif.c \ #
# ../sdk/meow_ui.a <sysroot>/lib/libz.a -lpthread -o meow_launcherd # 工具链:优先环境变量 ZIGCC / MIPS_SYSROOT(M47 起),否则退回历史固定路径 $HOME/Project/R3ProII。
# export ZIGCC=$HOME/.tools/zig-mipsel-cc
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
ROOT="$HOME/Project/R3ProII"
CC="$ROOT/.tools/zig-mipsel-cc" CC="${ZIGCC:-$HOME/.tools/zig-mipsel-cc}"
SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf" [ -x "$CC" ] || CC="$HOME/Project/R3ProII/.tools/zig-mipsel-cc"
SYSROOT="${MIPS_SYSROOT:-$HOME/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf}"
[ -f "$SYSROOT/lib/libz.a" ] || SYSROOT="$HOME/Project/R3ProII/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf"
# ---------------- 宿主单测(M47 起提供入口;只跑本目录受影响的测试)----------------
run_tests() {
local HOSTCC="${HOSTCC:-gcc}"
local TMPD rc=0
TMPD="$(mktemp -d "${TMPDIR:-/tmp}/meow_launcher_test.XXXXXX")"
trap 'rm -rf "$TMPD"' EXIT
echo "== host test: test_bt_page =="
if "$HOSTCC" -I"../sdk" -I. -Wall -Wextra -fsanitize=undefined \
test_bt_page.c -o "$TMPD/test_bt_page"; then
"$TMPD/test_bt_page" || rc=1
else
rc=1
fi
echo "== host test: test_netmgr_ipc =="
if "$HOSTCC" -I"../sdk" -I. -Wall -Wextra -c netmgr_ipc.c -o "$TMPD/netmgr_ipc.test.o" \
&& "$HOSTCC" -I"../sdk" -I. -Wall -Wextra \
test_netmgr_ipc.c "$TMPD/netmgr_ipc.test.o" -lpthread -o "$TMPD/test_netmgr_ipc"; then
"$TMPD/test_netmgr_ipc" || rc=1
else
rc=1
fi
if [ $rc -eq 0 ]; then echo "== ALL TESTS PASS =="; else echo "!! TESTS FAILED"; fi
return $rc
}
if [ "$1" = "test" ]; then
run_tests
exit $?
fi
[ -f "../sdk/meow_ui.a" ] || { echo "!! 缺 ../sdk/meow_ui.a,先跑 sdk/build_sdk.sh"; exit 1; } [ -f "../sdk/meow_ui.a" ] || { echo "!! 缺 ../sdk/meow_ui.a,先跑 sdk/build_sdk.sh"; exit 1; }
"$CC" -I"../sdk" -Os -static \ "$CC" -I"../sdk" -Os -static -Wall -Wextra \
main_menu.c statusbar.c fb_compositor.c settings_page.c power_menu.c \ 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 \ notif_center.c app_launcher.c pkg_install.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 \ 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_touch.c meow_settings.c meow_notif.c meow_media.c meow_bgplay.c \
meow_bgmgr.c \ meow_bgmgr.c \
meow_ams_start.c lock_screen.c lock_screen_ui.c lock_settings.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 */
+89
View File
@@ -212,3 +212,92 @@ void load_config(void)
menu_bg, seq_len, seq_codes[0], seq_gap_ms); menu_bg, seq_len, seq_codes[0], seq_gap_ms);
} }
/* =========================================================================
* 系统级音量键翻页门(READER_UI_DESIGN §E;用户 2026-09-04 拍板)。
*
* 语义:音量键是系统资源(默认调音量)。本门在「当前台 App 为其 app.json 声明
* volume_page 的应用(MVP:os.meow.reader)」时,把 VOL_UP/VOL_DOWN 转换成系统翻页
* 信号(MSG_KEY_PAGEUP/PAGEDOWN,meow_ipc.h)经 IPC 下发给该前台 App;其余情况一概
* 放行(return 0),音量基本行为/插孔消抖/音量反转等绝不破坏。
*
* 职责分离(关键,且最小侵入):
* - 本模块只提供「判定 + 门」:meow_volpage_app_allows() 读 app.json 声明;
* meow_volpage_key() 在「已武装 sink 且按下 VOL」时消费并发下发;
* - 武装(sink 注册)= 前台应用进入体积页声明 app 时调用 meow_volpage_sink()
* (应用存在连接后其 fd 生效);离开/退出时 meow_volpage_sink_clear()。
* 该接线落在「前台会话的按键路径」(app_launcher.c / meow_launcherd.c 主循环),
* 属主会话统一收编范围,此处不越界改动。
*
* 声明字段(app.json):`"volume_page": true`(或 1)。未来其它 App 可声明复用。
* ========================================================================= */
/* 前台翻页 sink(本模块私有;单写者=前台会话上下文,读=按键门)。 */
static char g_volpage_app[MEOW_APP_ID_MAX] = "";
static int g_volpage_fd = -1;
void meow_volpage_sink_clear(void); /* 前向声明(sink 内调用) */
/* 读 <MEOWAPPS>/<app_id>/app.json 是否声明 volume_page=true|1。1=声明;0=否/读不到。 */
int meow_volpage_app_allows(const char *app_id)
{
if (!app_id || !app_id[0]) return 0;
char path[320];
snprintf(path, sizeof path, "%s/%s/app.json", MEOWAPPS, app_id);
FILE *f = fopen(path, "r");
if (!f) return 0;
/* 轻量扫描:找 "volume_page" → 跳过空白 → true/1。app.json 为紧凑 JSON。 */
char buf[1024];
buf[0] = 0;
size_t rd = fread(buf, 1, sizeof buf - 1, f);
buf[rd] = 0;
fclose(f);
const char *p = buf;
while ((p = strstr(p, "volume_page")) != NULL) {
p += strlen("volume_page");
while (*p == ' ' || *p == '\t' || *p == ':' || *p == '"') p++;
if (strncmp(p, "true", 4) == 0) return 1;
if (*p == '1' && (p[1] < '0' || p[1] > '9')) return 1;
/* 未命中则继续找(防字段重复);跳过本段 */
while (*p && *p != ',' && *p != '}') p++;
}
return 0;
}
/* 武装/清除 sink。app_id 非空且 fd>=0 → 记录;否则清除。幂等(同 app_id+fd 不重复记日志,
* 供前台会话循环每圈安全重调,也兼容断线重连换 fd)。 */
void meow_volpage_sink(const char *app_id, int fd)
{
if (!app_id || !app_id[0] || fd < 0) { meow_volpage_sink_clear(); return; }
if (g_volpage_fd == fd && !strcmp(g_volpage_app, app_id)) return; /* 同 app_id+fd:幂等 */
snprintf(g_volpage_app, sizeof g_volpage_app, "%s", app_id);
g_volpage_fd = fd;
logmsg("volpage: sink armed app=%s fd=%d", g_volpage_app, g_volpage_fd);
}
void meow_volpage_sink_clear(void)
{
if (g_volpage_fd >= 0)
logmsg("volpage: sink cleared app=%s", g_volpage_app[0] ? g_volpage_app : "?");
g_volpage_app[0] = 0;
g_volpage_fd = -1;
}
int meow_volpage_armed(void) { return g_volpage_fd >= 0; }
/* 按键门:按下 VOL_UP/DOWN 且 sink 已武装 → 下发翻页信号并消费(返回 1)。
* 未武装 / 非 VOL / 抬起 → 返回 0(放行给音量路径)。 */
int meow_volpage_key(int code, int is_down)
{
if (!is_down) return 0; /* 只响应按下边沿 */
if (g_volpage_fd < 0) return 0; /* 未武装:音量照旧 */
if (code == KEY_VOLUMEUP) {
(void)meow_ipc_send_pub(g_volpage_fd, MSG_KEY_PAGEUP, 1, NULL, 0);
return 1;
}
if (code == KEY_VOLUMEDOWN) {
(void)meow_ipc_send_pub(g_volpage_fd, MSG_KEY_PAGEDOWN, 1, NULL, 0);
return 1;
}
return 0;
}
+23 -13
View File
@@ -315,11 +315,12 @@ int run_lock_screen(void)
return 1; return 1;
} }
/* M7.1 解锁恢复:锁屏是整帧不透明覆盖(lk_draw fill_rect 全屏),退出后底层界面不会自动重绘 /* M7.1/M40 F5 解锁/熄屏恢复:锁屏是整帧不透明覆盖(lk_draw fill_rect 全屏),退出后底层界面
* —— 只有 App 自绘的控件区域刷新,未刷新区残留锁屏 PIN 最后一帧(解锁后整屏脏区根因)。此处 * 不会自动重绘 —— 只有 App 自绘的控件区域刷新,未刷新区残留锁屏 PIN 最后一帧。
* 在锁屏首扑前把底层帧(SDK back_buf = 进入前的 App 帧/主界面帧)快照下来;解锁(unlocked=1) * 此处把锁屏首扑前的底层帧(SDK back_buf = 进入前的 App 帧/主界面帧)快照下来;
* 时整帧回写 back_buf + present,强制底层界面完整重绘(app_launcher.c 的 lock-overlay 关闭路径 * **任何退出路径**(解锁 unlocked=1 或电源键熄屏 unlocked=0)都整帧回写 back_buf + present,
* 随后 pm_capture_app_frame 读 fb0 即得到正确 App 帧,不再读到锁帧)。 */ * 保证 app_launcher.c 的 lock-overlay 关闭路径随后 pm_capture_app_frame 读 fb0 得到的是
* 正确 App 帧,而不是 PIN 帧(后者会被下一次锁屏当成底层帧 → P5 残影链)。 */
unsigned char *lk_bg_snap = NULL; unsigned char *lk_bg_snap = NULL;
size_t lk_bg_len = 0; size_t lk_bg_len = 0;
if (back_buf_ok && fb && fb_line > 0 && fb_yres > 0) { if (back_buf_ok && fb && fb_line > 0 && fb_yres > 0) {
@@ -400,7 +401,7 @@ int run_lock_screen(void)
} }
/* 电源键/音量键轮询 + 输入活动检测(重置自睡计时) + 自睡/唤醒切换。 /* 电源键/音量键轮询 + 输入活动检测(重置自睡计时) + 自睡/唤醒切换。
* 活跃态:电源键按下 → 息屏并退出锁屏(回 App/主屏,下次唤醒再锁)——沿用原语义; * 活跃态:电源键**按下边沿**(进场 ≥300ms)→ 息屏并退出锁屏(回 App/主屏,下次唤醒再锁);
* 空闲超 lk_off_ms → 自睡(asleep=1,锁态保持,仍在本循环)。 * 空闲超 lk_off_ms → 自睡(asleep=1,锁态保持,仍在本循环)。
* asleep:电源键按下边沿 / 触摸活动 → meow_panel_on + 重画锁屏页(本循环唤醒链回锁)。 * asleep:电源键按下边沿 / 触摸活动 → meow_panel_on + 重画锁屏页(本循环唤醒链回锁)。
* 音量键本页不动(仅视为输入活动,重置计时)。 */ * 音量键本页不动(仅视为输入活动,重置计时)。 */
@@ -429,9 +430,14 @@ int run_lock_screen(void)
continue; continue;
} }
} else { } else {
/* 电源键=息屏(允许再睡;下次唤醒按 lck_wake_should_lock 再锁) */ /* 电源键=息屏(允许再睡;下次唤醒按 lck_wake_should_lock 再锁)。
if (pwr_down) { * M40 F4/P4:必须用**按下边沿**且进场 ≥300ms 才生效。旧实现用电平
logmsg("lock: power key -> screen off (stay locked)"); * (pwr_down):打开电源菜单的那次长按在 PIN 页出现时仍按着,页面被同一次
* 按住立刻熄灭(用户症状「手动锁屏不熄屏而是进 PIN,此时点电源才熄屏」)。
* 边沿判定在 overlay_gate.h(宿主单测同源)。 */
if (overlay_lock_power_off_now(pwr_edge, now_ms() - entry_ms)) {
logmsg("lock: power key edge (%llums after entry) -> screen off (stay locked)",
now_ms() - entry_ms);
meow_panel_off(); meow_panel_off();
break; /* return 0 */ break; /* return 0 */
} }
@@ -569,13 +575,17 @@ int run_lock_screen(void)
done: done:
meow_ui_activity_pop(); /* M7.1b:锁屏控件 owner 闸门关闭(与进场 push 对称) */ meow_ui_activity_pop(); /* M7.1b:锁屏控件 owner 闸门关闭(与进场 push 对称) */
g_lk_down_key = LK_DOWN_NONE; g_lk_down_key = LK_DOWN_NONE;
/* M7.1 解锁恢复:把锁屏首扑前快照的底层帧整帧回写 back_buf + present,强制底层 App/主界面 /* M7.1/M40 F5:把锁屏首扑前快照的底层帧整帧回写 back_buf + present。
* 完整重绘(消除「解锁后整屏 = PIN 最后一帧,仅 App 控件自绘区刷新」的脏区残留)。 */ * M40 起**不再限定 unlocked**:电源键熄屏退出(lr=0)也必须恢复,否则 fb0 会停留在
if (unlocked && lk_bg_snap && back_buf_ok && fb && lk_bg_len > 0) { * PIN 最后一帧;launcher 的 lock-overlay 关闭路径紧接着 pm_capture_app_frame() 会把
* 这帧 PIN 抄进 back_buf 并上屏,下一次锁屏再把它当「底层帧」快照 —— P5 残影链的起点。
* 屏黑时 present 无害(面板已下电),解锁时则同时消除「整屏 = PIN」的脏区残留。
* 「解锁后 App 整帧重画」由 launcher 写 /tmp/meow_app_repaint(SDK present 消费)完成。 */
if (lk_bg_snap && back_buf_ok && fb && lk_bg_len > 0) {
memcpy(fb, lk_bg_snap, lk_bg_len); memcpy(fb, lk_bg_snap, lk_bg_len);
meow_dirty_full(); meow_dirty_full();
meow_ui_flush(); meow_ui_flush();
logmsg("lock: unlock -> restore underlying frame (full repaint)"); logmsg("lock: exit (unlocked=%d) -> restore underlying frame (full repaint)", unlocked);
} }
if (lk_bg_snap) { free(lk_bg_snap); lk_bg_snap = NULL; lk_bg_len = 0; } if (lk_bg_snap) { free(lk_bg_snap); lk_bg_snap = NULL; lk_bg_len = 0; }
if (tfd >= 0) { if (tfd >= 0) {
+84 -87
View File
@@ -16,11 +16,14 @@ int menu_opened_blank = 0;
/* launch_app/launch_activity 返回 -1 时的 toast:缺二进制依赖(app.json depends)→ 「缺依赖: <name>」; /* 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) 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(); const char *bd = meow_dep_block_reason();
if (bd && bd[0]) snprintf(toast, n, meow_str_zh("menu.missing_dep", "缺依赖: %s"), bd); 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); else snprintf(toast, n, "CANNOT START: %s", name);
} }
@@ -53,17 +56,28 @@ static int menu_live_open_pending = 0;
* fresh menu taps. A replayed tap on an app row relaunches the app (symptom: * fresh menu taps. A replayed tap on an app row relaunches the app (symptom:
* BYE then the app's main screen — Bug 2); a replayed tap on the "BACK TO * BYE then the app's main screen — Bug 2); a replayed tap on the "BACK TO
* PLAYER" row closes the menu and SIGCONTs the player (symptom: whole screen * PLAYER" row closes the menu and SIGCONTs the player (symptom: whole screen
* flashes back to the player — Bug 4). We drain the queue immediately after * flashes back to the player — Bug 4).
* the app exits and ignore touches for a short cooldown that covers the *
* measured 1.2-2.25s ghost window (matches the watchdog's COOLDOWN_MS). */ * M39:真正的机制是「排空本进程 touch_fd」——evdev 每个 fd 各收一份事件副本,App 运行期间
* (launcher 不读)的全部事件都堆在这个 fd 里,app 一退出就会作为「新菜单 tap」重放。
* menu_drain_touch() 把它们全读掉(含按下沿)+ 复位手势状态机,历史上用于兜底的
* 1.2–2.25s 冷却窗口因此可以收窄到 MENU_TOUCH_GUARD_MS:那个窗口是从「App 退出」开始计时
* 的,用户看到菜单后伸手的第一下必然落在里面被吞(用户实测「首页点图标要点两次」根因之一)。
* 现在只留极短的驱动尾巴守卫,不再按时间吞真实输入。 */
static unsigned long long menu_touch_cooldown_until = 0; static unsigned long long menu_touch_cooldown_until = 0;
/* 排空本进程触摸队列(非阻塞读到 EAGAIN)+ 复位手势状态机。
* 队列/down 边沿都要清:只清队列而保留 t_down,会让「按下已被 drain 掉、抬手稍后才到」的
* 这次手势被当成一次有效 tap(ghost touch 重放的另一半)。 */
static void menu_drain_touch(void) static void menu_drain_touch(void)
{ {
if (touch_fd >= 0) { if (touch_fd >= 0) {
unsigned char ev[16]; unsigned char ev[16];
while (read(touch_fd, ev, sizeof ev) == (ssize_t)sizeof ev) { /* discard */ } while (read(touch_fd, ev, sizeof ev) == (ssize_t)sizeof ev) { /* discard */ }
} }
t_down = 0; t_down_valid = 0;
t_down_x = t_down_y = -1;
t_last_x = t_last_y = -1;
} }
static void menu_arm_touch(long ms) static void menu_arm_touch(long ms)
@@ -73,18 +87,19 @@ static void menu_arm_touch(long ms)
} }
/* Drain the touch queue + arm the post-app cooldown (call right after the /* Drain the touch queue + arm the post-app guard (call right after the app
* app exits, before the menu resumes polling touch). The drain always runs so * exits, before the menu resumes polling touch). The drain always runs so
* touches queued by the launch-attempt tail are cleared; the cooldown only * touches queued by the launch-attempt tail are cleared; the guard only arms
* arms when an app actually ran (app_ran) so a failed launch doesn't lock the * when an app actually ran (app_ran) so a failed launch doesn't lock the menu
* menu out for 2.5s. */ * out. */
static void menu_after_app_exit(int app_ran) static void menu_after_app_exit(int app_ran)
{ {
menu_drain_touch(); menu_drain_touch();
/* M2.22(D):2500ms 冷却把正常的「app 退出后立即点下一个应用」也吞掉(点几下才进)。 /* M39:M2.6 的 2500ms → M2.22 的 1200ms → 现在的 MENU_TOUCH_GUARD_MS(150ms)。
* 用户要求点一下就能进 → 调窄到 1200ms,仍覆盖 goodix ghost 窗口(1.2–2.25s 实测略短, * 依据:重放的来源是本进程 fd 里 App 运行期的事件,menu_drain_touch() 已一次性清空
* 与 watchdog COOLDOWN_MS 同阶),防启动重放。只调窄,保留 cooldown 本身。 */ * (含 t_down/原点,见上),窗口只兜「面板重配后驱动补发的幽灵尾巴」。1200ms 会稳定吞掉
if (app_ran) menu_touch_cooldown_until = now_ms() + 1200; * 用户「返回桌面后立即点下一个图标」——正是要修的症状。 */
if (app_ran) menu_touch_cooldown_until = now_ms() + MENU_TOUCH_GUARD_MS;
} }
@@ -1121,7 +1136,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
int cursor = 0; int cursor = 0;
unsigned long long t0 = now_ms(); unsigned long long t0 = now_ms();
int toast_ms = 0; int toast_ms = 0;
char toast[64] = ""; char toast[256] = ""; /* M6.40:失败 toast 带具体原因(CANNOT START: <name>(<reason>))需更大缓冲 */
int want_exit = 0; int want_exit = 0;
/* M2.17 超时自动息屏:任何输入重置 last_input;空闲 > screen_off_timeout → meow_panel_off()。 /* M2.17 超时自动息屏:任何输入重置 last_input;空闲 > screen_off_timeout → meow_panel_off()。
* meow_auto_slept 记录「自动息屏」态(手动电源息屏不置位),任意输入唤醒 + meow_repaint_surface。 */ * meow_auto_slept 记录「自动息屏」态(手动电源息屏不置位),任意输入唤醒 + meow_repaint_surface。 */
@@ -1204,17 +1219,19 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
/* touch: event reads (event1 is not grabbed) */ /* touch: event reads (event1 is not grabbed) */
touch_fd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK); touch_fd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
if (touch_fd < 0) logmsg("touch open failed: %s", strerror(errno)); if (touch_fd < 0) logmsg("touch open failed: %s", strerror(errno));
/* M2.6 幽灵触摸防护:菜单激活(启动)即 drain + 冷却 */ /* M39:菜单激活(启动)即 drain + 极短守卫(M2.6 原为 300ms;界面此时已整帧画完,
menu_arm_touch(TAP_ARM_DELAY_MS); * 窗口只会吞用户看到首页后的第一下,故收窄到 MENU_TOUCH_GUARD_MS) */
menu_arm_touch(MENU_TOUCH_GUARD_MS);
/* key polling */ /* key polling */
struct keypoll kp_power, kp_keys; struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER); kp_open(&kp_power, DEV_POWER);
kp_open(&kp_keys, DEV_KEYS); kp_open(&kp_keys, DEV_KEYS);
/* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动 */ /* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动。
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; * 音量键防抖/基线已上收 key_router(进程唯一);本页只保留播放/电源键自家防抖。 */
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof 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", logmsg("menu open: napps=%d state=%d cursor=%d",
napps, state, cursor); napps, state, cursor);
@@ -1223,10 +1240,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 * 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 * edges; kp.prev cannot be used for this because kp_poll() syncs it
* to cur before we compare */ * 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1; if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
} }
@@ -1333,7 +1348,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
sim_exit_tap_done = 1; sim_exit_tap_done = 1;
if (meow_mode) { if (meow_mode) {
int rb = confirm_reboot_player(); int rb = confirm_reboot_player();
menu_arm_touch(TAP_ARM_DELAY_MS); menu_arm_touch(MENU_TOUCH_GUARD_MS);
dirty = 1; dirty = 1;
logmsg("meow mode: BACK TO PLAYER -> confirm reboot (rb=%d)", rb); logmsg("meow mode: BACK TO PLAYER -> confirm reboot (rb=%d)", rb);
} else { } else {
@@ -1430,26 +1445,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 * 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 * or exit the menu on it. Exit is only via the BACK TO PLAYER touch
* row (below) or sim-exit. */ * 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_power, NULL, NULL);
kp_poll(&kp_keys, 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 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:按住电源 */ 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下")。 /* M2.16 防抖:每键值须稳定 KEY_DEBOUNCE_MS(40ms) 才认可,抗 ADC 抖动("点一下跳5-6下")。
* 稳定态 vold/volu/play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */ * 稳定态 play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */
(void)kd_tick(&kd_vold, vold);
(void)kd_tick(&kd_volu, volu);
(void)kd_tick(&kd_play, play); (void)kd_tick(&kd_play, play);
(void)kd_tick(&kd_pwr, pwr); (void)kd_tick(&kd_pwr, pwr);
vold = kd_vold.stable; volu = kd_volu.stable;
play = kd_play.stable; pwr = kd_pwr.stable; play = kd_play.stable; pwr = kd_pwr.stable;
/* M2.6 电源语义(MeowOS 自持面板,无播放器拔河): /* M2.6 电源语义(MeowOS 自持面板,无播放器拔河):
* 单击(短按松开 <2s)= 息屏/亮屏;2s = 电源菜单;8s = 强制关机。 */ * 单击(短按松开 <2s)= 息屏/亮屏;2s = 电源菜单;8s = 强制关机。 */
@@ -1486,7 +1495,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0;
pwr_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击熄屏 */ pwr_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击熄屏 */
dirty = 1; /* 取消后重绘应用列表 */ dirty = 1; /* 取消后重绘应用列表 */
menu_arm_touch(800); /* M2.6 幽灵触摸防护:菜单关闭后忽略触摸 */ menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫(原 M2.6 800ms) */
/* M2.25 黑屏修复:阻塞式 show_power_menu() 期间 kd_* 防抖状态冻结。 /* M2.25 黑屏修复:阻塞式 show_power_menu() 期间 kd_* 防抖状态冻结。
* 用户常「松开电源 → 用触摸点取消」,返回后 kd_pwr.stable 仍是旧的「按住=1」, * 用户常「松开电源 → 用触摸点取消」,返回后 kd_pwr.stable 仍是旧的「按住=1」,
* 而 m_prev_pwr 已按 raw 重播为 0 → 二者错配误判「新按下」刷新 pwr_hold_at, * 而 m_prev_pwr 已按 raw 重播为 0 → 二者错配误判「新按下」刷新 pwr_hold_at,
@@ -1494,15 +1503,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* 难唤醒:launcher 以为屏开着,电源唤醒逻辑不触发)。电源键确认取消时 raw * 难唤醒:launcher 以为屏开着,电源唤醒逻辑不触发)。电源键确认取消时 raw
* 仍=按住=1,无此错配,所以只有触摸路径黑屏。此处重播 kd_* 并以当前 raw * 仍=按住=1,无此错配,所以只有触摸路径黑屏。此处重播 kd_* 并以当前 raw
* 对齐 m_prev_*,杜绝该假边沿(真实新按键仍正常防抖/单击)。 */ * 对齐 m_prev_*,杜绝该假边沿(真实新按键仍正常防抖/单击)。 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
continue; continue;
} }
@@ -1527,15 +1534,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
if (lck_wake_should_lock()) { if (lck_wake_should_lock()) {
int lr = run_lock_screen(); int lr = run_lock_screen();
/* 重播按键防抖基线(阻塞期间按键可能已变化,防误判新边沿) */ /* 重播按键防抖基线(阻塞期间按键可能已变化,防误判新边沿) */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
if (lr == 1) if (lr == 1)
meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */ meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */
@@ -1564,11 +1569,11 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* M2.11 E:MeowOS 下音量键 = 调系统软件音量(settings audio.volume,步进5 clamp), * M2.11 E:MeowOS 下音量键 = 调系统软件音量(settings audio.volume,步进5 clamp),
* 状态栏即时显示、持久化。有播放器的覆盖态才保留音量键=菜单光标导航。 */ * 状态栏即时显示、持久化。有播放器的覆盖态才保留音量键=菜单光标导航。 */
if (meow_mode) { if (meow_mode) {
if (vold && !m_prev_vold) { if (kev.vol_dn_edge) {
any_input = 1; any_input = 1;
settings_volume_adj(-settings_volume_step); /* 起线性渐变(1% 爬) */ settings_volume_adj(-settings_volume_step); /* 起线性渐变(1% 爬) */
logmsg("volume DOWN <- ramp step=%d", settings_volume_step); logmsg("volume DOWN <- ramp step=%d", settings_volume_step);
} else if (volu && !m_prev_volu) { } else if (kev.vol_up_edge) {
any_input = 1; any_input = 1;
settings_volume_adj(+settings_volume_step); settings_volume_adj(+settings_volume_step);
logmsg("volume UP <- ramp step=%d", settings_volume_step); logmsg("volume UP <- ramp step=%d", settings_volume_step);
@@ -1576,22 +1581,23 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
} else { } else {
/* M2.36 B2b:光标范围扩展为 [0..napps](napps=「系统设置」行,位于 Exit 行上方)。 /* M2.36 B2b:光标范围扩展为 [0..napps](napps=「系统设置」行,位于 Exit 行上方)。
* 音量下移越过最后应用 → 设置行;设置行再下移 → 回 0。应用行仍 0..napps-1。 */ * 音量下移越过最后应用 → 设置行;设置行再下移 → 回 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; any_input = 1;
cursor++; cursor++;
if (cursor > napps) cursor = 0; /* napps=设置行 */ if (cursor > napps) cursor = 0; /* napps=设置行 */
logmsg("menu cursor -> %d", cursor); 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; any_input = 1;
cursor--; cursor--;
if (cursor < 0) cursor = napps; /* 回绕到设置行 */ if (cursor < 0) cursor = napps; /* 回绕到设置行 */
logmsg("menu cursor -> %d", cursor); 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) { 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); int going = vol_ramp_tick(held);
g_sys.vol = settings_volume_get(); g_sys.vol = settings_volume_get();
paint_sb_right(); /* M2.16:只重画右侧信息格(VOL 变化),不动列表/时钟 */ paint_sb_right(); /* M2.16:只重画右侧信息格(VOL 变化),不动列表/时钟 */
@@ -1615,7 +1621,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
logmsg("play key: media-reserved (no media source)"); 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) */ /* touch (event reads) */
if (touch_fd >= 0 && meow_screen_on) { /* 息屏时不处理触摸(goodix 已被 FBIOBLANK(4) 挂起) */ if (touch_fd >= 0 && meow_screen_on) { /* 息屏时不处理触摸(goodix 已被 FBIOBLANK(4) 挂起) */
@@ -1673,25 +1679,23 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
logmsg("menu: top-edge live drag-down -> notification center (dy=%d)", logmsg("menu: top-edge live drag-down -> notification center (dy=%d)",
t_last_y - loy); t_last_y - loy);
t_down = 0; t_down_valid = 0; /* 消费本次手势:返回后不再判 tap/滑动/重开 */ t_down = 0; t_down_valid = 0; /* 消费本次手势:返回后不再判 tap/滑动/重开 */
menu_arm_touch(TAP_ARM_DELAY_MS); menu_arm_touch(MENU_TOUCH_GUARD_MS);
notif_click_t click; notif_click_t click;
memset(&click, 0, sizeof click); memset(&click, 0, sizeof click);
click.app_idx = -1; click.app_idx = -1;
click.status = NOTIF_CLICK_STATUS_NONE; click.status = NOTIF_CLICK_STATUS_NONE;
run_notif_center(apps, napps, &click, 1, t_down_x, loy); run_notif_center(apps, napps, &click, 1, t_down_x, loy);
/* 重播按键防抖基线(同原 top-swipe 路径),防面板内电源/音量边沿被 run_menu 误判 */ /* 重播按键防抖基线(同原 top-swipe 路径),防面板内电源/音量边沿被 run_menu 误判 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms(); last_input_ms = now_ms();
menu_arm_touch(800); menu_arm_touch(MENU_TOUCH_GUARD_MS);
dirty = 1; dirty = 1;
/* M5:通知点击按 route_status 产出 click(0 跳转 / 1 NO ROUTE / 2 SOURCE NOT FOUND) */ /* M5:通知点击按 route_status 产出 click(0 跳转 / 1 NO ROUTE / 2 SOURCE NOT FOUND) */
if (click.status == NOTIF_CLICK_STATUS_OK && if (click.status == NOTIF_CLICK_STATUS_OK &&
@@ -1773,25 +1777,23 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* 本函数只在顶部热区(down_y<SB_BAR_H)+向下主导时返回 1,与左缘互斥。 */ * 本函数只在顶部热区(down_y<SB_BAR_H)+向下主导时返回 1,与左缘互斥。 */
if (mnp_top_swipe_down(tx, ty, dx, dy, SB_BAR_H, MENU_SWIPE_THRESH)) { if (mnp_top_swipe_down(tx, ty, dx, dy, SB_BAR_H, MENU_SWIPE_THRESH)) {
logmsg("menu: top-edge swipe-down -> notification center (release edge, fallback)"); logmsg("menu: top-edge swipe-down -> notification center (release edge, fallback)");
menu_arm_touch(TAP_ARM_DELAY_MS); menu_arm_touch(MENU_TOUCH_GUARD_MS);
notif_click_t click; notif_click_t click;
memset(&click, 0, sizeof click); memset(&click, 0, sizeof click);
click.app_idx = -1; click.app_idx = -1;
click.status = NOTIF_CLICK_STATUS_NONE; click.status = NOTIF_CLICK_STATUS_NONE;
run_notif_center(apps, napps, &click, 0, 0, 0); /* ⑨ live_open=0 → 触发式自动展开(防御) */ run_notif_center(apps, napps, &click, 0, 0, 0); /* ⑨ live_open=0 → 触发式自动展开(防御) */
/* 重播按键防抖基线,防面板内电源/音量边沿被 run_menu 误判(M2.25 同款)。 */ /* 重播按键防抖基线,防面板内电源/音量边沿被 run_menu 误判(M2.25 同款)。 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms(); last_input_ms = now_ms();
menu_arm_touch(800); menu_arm_touch(MENU_TOUCH_GUARD_MS);
dirty = 1; dirty = 1;
/* M5:通知点击按 route_status 产出 click(0 跳转 / 1 NO ROUTE / 2 SOURCE NOT FOUND) */ /* M5:通知点击按 route_status 产出 click(0 跳转 / 1 NO ROUTE / 2 SOURCE NOT FOUND) */
if (click.status == NOTIF_CLICK_STATUS_OK && if (click.status == NOTIF_CLICK_STATUS_OK &&
@@ -1829,21 +1831,19 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
if (dx <= -MENU_SWIPE_THRESH && adx > ady && if (dx <= -MENU_SWIPE_THRESH && adx > ady &&
tx >= MEOW_EDGE_W && ty >= SB_BAR_H) { tx >= MEOW_EDGE_W && ty >= SB_BAR_H) {
logmsg("menu: left-swipe -> Now Playing (dx=%d,dy=%d)", dx, dy); logmsg("menu: left-swipe -> Now Playing (dx=%d,dy=%d)", dx, dy);
menu_arm_touch(TAP_ARM_DELAY_MS); menu_arm_touch(MENU_TOUCH_GUARD_MS);
run_now_playing(apps, napps); run_now_playing(apps, napps);
/* 重播按键防抖基线,防播放页内电源/音量边沿被 run_menu 误判 */ /* 重播按键防抖基线,防播放页内电源/音量边沿被 run_menu 误判 */
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms(); last_input_ms = now_ms();
menu_arm_touch(800); menu_arm_touch(MENU_TOUCH_GUARD_MS);
dirty = 1; dirty = 1;
continue; continue;
} }
@@ -1853,20 +1853,18 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
/* 批③:顶栏居右「后台管理」按钮(最高优先级;阻挡非阻塞,返回后重画) */ /* 批③:顶栏居右「后台管理」按钮(最高优先级;阻挡非阻塞,返回后重画) */
if (menu_hit_bgm_btn(tx, ty)) { if (menu_hit_bgm_btn(tx, ty)) {
logmsg("menu: touch topbar bg-manager button"); logmsg("menu: touch topbar bg-manager button");
menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护 */ menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫 */
run_bg_page(apps, napps); run_bg_page(apps, napps);
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms(); last_input_ms = now_ms();
menu_arm_touch(800); menu_arm_touch(MENU_TOUCH_GUARD_MS);
dirty = 1; dirty = 1;
continue; continue;
} }
@@ -1878,7 +1876,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* 绝不恢复到播放器死帧、绝不对死进程 SIGCONT。 */ * 绝不恢复到播放器死帧、绝不对死进程 SIGCONT。 */
logmsg("meow mode: BACK TO PLAYER -> confirm reboot"); logmsg("meow mode: BACK TO PLAYER -> confirm reboot");
int rb = confirm_reboot_player(); int rb = confirm_reboot_player();
menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护 */ menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫 */
dirty = 1; dirty = 1;
if (rb) { logmsg("reboot-to-player: yes, rebooting"); } if (rb) { logmsg("reboot-to-player: yes, rebooting"); }
/* rb==1 时 reboot 已发起,通常不回;rb==0 留在菜单 */ /* rb==1 时 reboot 已发起,通常不回;rb==0 留在菜单 */
@@ -1893,20 +1891,18 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
* 返回」的抬起边沿在 run_menu 被误判为面板单击而熄屏。 */ * 返回」的抬起边沿在 run_menu 被误判为面板单击而熄屏。 */
else if (ty >= EXIT_ROW - ROW_H - 4 && ty < EXIT_ROW - ROW_H + ROW_H - 8) { else if (ty >= EXIT_ROW - ROW_H - 4 && ty < EXIT_ROW - ROW_H + ROW_H - 8) {
logmsg("menu: touch settings row -> settings page"); logmsg("menu: touch settings row -> settings page");
menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护:吃掉打开此行的那次触摸 */ menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫 */
run_settings_page(); run_settings_page();
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
last_input_ms = now_ms(); /* 回主界面即视为输入 */ last_input_ms = now_ms(); /* 回主界面即视为输入 */
menu_arm_touch(800); /* 设置页返回后再忽略近期触摸 */ menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫(原设置页返回 800ms) */
dirty = 1; /* 全帧重画应用列表 */ dirty = 1; /* 全帧重画应用列表 */
} }
/* M2.18 排查乱跳:改为 else if —— 原为两个独立 if,退出行与 /* M2.18 排查乱跳:改为 else if —— 原为两个独立 if,退出行与
@@ -1972,15 +1968,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
/* M2.x 锁屏:自动息屏唤醒后若需锁屏 → 弹阻塞式锁屏页;解锁才重绘,息屏留 dirty */ /* M2.x 锁屏:自动息屏唤醒后若需锁屏 → 弹阻塞式锁屏页;解锁才重绘,息屏留 dirty */
if (lck_wake_should_lock()) { if (lck_wake_should_lock()) {
int lr = run_lock_screen(); int lr = run_lock_screen();
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
if (lr == 1) if (lr == 1)
meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */ meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */
@@ -2138,7 +2132,7 @@ static void usage(void)
" [--sim-exit-tap N] [--sim-power-hold N]\n" " [--sim-exit-tap N] [--sim-power-hold N]\n"
" [--sim-power-pick N] [--sim-no-exec]\n" " [--sim-power-pick N] [--sim-no-exec]\n"
" [--dumpkeys N] [--scan] [--sysdump]\n" " [--dumpkeys N] [--scan] [--sysdump]\n"
" [--pkg-install <pkg.mpk>] [--pkg-export <pkg> <out.mpk>]\n" " [--pkg-install <file>] [--pkg-export <pkg> <out.mpk>]\n"
" [--ipc-only N] [--ipc-sock <path>] [--appinfo <app.json>]\n" " [--ipc-only N] [--ipc-sock <path>] [--appinfo <app.json>]\n"
" [--fontscan <card_root>]\n"); " [--fontscan <card_root>]\n");
} }
@@ -2216,6 +2210,7 @@ int main(int argc, char **argv)
load_config(); /* hotkey_seq / hotkey_seq_gap / bg_color (also run per load_config(); /* hotkey_seq / hotkey_seq_gap / bg_color (also run per
* menu open so edits apply without a daemon restart) */ * menu open so edits apply without a daemon restart) */
settings_load(); /* M2.11:读系统设置(audio.volume 等),供音量/状态栏使用 */ 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。 /* M2.6:启动扫描一次 TF 卡 MeowOS/fonts/<family>/<weight>/,填充「字体文件」下拉 preset。
* 无卡/无字库目录 → 仅默认字体(等价现状)。进设置页时 run_settings_page 会再扫一次(verify=0)。 */ * 无卡/无字库目录 → 仅默认字体(等价现状)。进设置页时 run_settings_page 会再扫一次(verify=0)。 */
@@ -2258,10 +2253,12 @@ int main(int argc, char **argv)
} }
if (pkg_install_mpk) { if (pkg_install_mpk) {
/* M2.12 .mpk 安装接口(CLI 钩子;UI 后期)。安全、离开主循环。 */ /* M42:安装委托 nyai(唯一权威实现;包身份只看包内 app.json,文件名/后缀不参与)。
* 失败必须给可读原因(nyai stderr 尾行),不能静默;退出码统一 0/1。 */
int r = meow_pkg_install(pkg_install_mpk); int r = meow_pkg_install(pkg_install_mpk);
printf("pkg_install %s -> %d\n", pkg_install_mpk, r); printf("pkg_install %s -> %d\n", pkg_install_mpk, r);
return r; if (r != 0) fprintf(stderr, "pkg_install failed: %s\n", meow_pkg_install_error());
return r == 0 ? 0 : 1;
} }
if (pkg_export_name && pkg_export_out) { if (pkg_export_name && pkg_export_out) {
/* M2.12 .mpk 导出接口(CLI 钩子;UI 后期)。 */ /* M2.12 .mpk 导出接口(CLI 钩子;UI 后期)。 */
+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; if (n > max) n = max;
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
bgmgr_row_t *r = &rows[i]; bgmgr_row_t *r = &rows[i];
r->framework = 0; r->protected_app = 0;
r->is_fg = 0; r->is_fg = 0;
r->name[0] = 0; r->name[0] = 0;
r->icon[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; r->rss_kb = 0;
ops->app_meta(ops->ctx, r->app_id, r->pid, ops->app_meta(ops->ctx, r->app_id, r->pid,
r->name, (int)sizeof r->name, r->icon, (int)sizeof r->icon, 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 (!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) { 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; 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) int bgmgr_can_kill(const bgmgr_row_t *r)
{ {
if (!r) return 0; 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->is_fg) return 0; /* 当前前台 App 不 kill */
if (r->pid <= 0) return 0; /* 非后台/无进程 */ if (r->pid <= 0) return 0; /* 非后台/无进程 */
return 1; return 1;
+11 -6
View File
@@ -4,7 +4,10 @@
* 便于宿主单测(test_bgmgr.c)与 launcher 页面(bg_page.c)复用。同 meow_bgplay 的 ops 注入手法: * 便于宿主单测(test_bgmgr.c)与 launcher 页面(bg_page.c)复用。同 meow_bgplay 的 ops 注入手法:
* UI 提供真实 enumerate/app_meta/proc_sample;单测用假件。**单一写者=launcher 主循环**,无锁。 * 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。 * 不新建 daemon;不引 GDBus;不碰 /data/recovery_all。
*/ */
#ifndef MEOW_BGMGR_H #ifndef MEOW_BGMGR_H
@@ -23,7 +26,9 @@ typedef struct bgmgr_row {
char app_id[MEOW_APP_ID_MAX]; char app_id[MEOW_APP_ID_MAX];
char name[49]; /* display name(回退 app_id) */ char name[49]; /* display name(回退 app_id) */
char icon[129]; /* 图标 path(空=无,页面用首字/占位) */ 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 后台进程 */ pid_t pid; /* >0 后台进程 */
int keepalive; /* 1=声明常驻(background_keepalive,无限期不清) */ int keepalive; /* 1=声明常驻(background_keepalive,无限期不清) */
int is_fg; /* 1=当前前台 App(不 kill,标注「前台」) */ int is_fg; /* 1=当前前台 App(不 kill,标注「前台」) */
@@ -39,10 +44,10 @@ typedef struct bgmgr_ops {
/* 枚举后台常驻集:每调一次填 rows 的 app_id/pid/keepalive/on_card/bg_since_ms, /* 枚举后台常驻集:每调一次填 rows 的 app_id/pid/keepalive/on_card/bg_since_ms,
* 返回行数(0..max;<0 视 0)。UI=读 launcher meow_bg 表;单测=假表。 */ * 返回行数(0..max;<0 视 0)。UI=读 launcher meow_bg 表;单测=假表。 */
int (*enumerate)(void *ctx, bgmgr_row_t *rows, int max); 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, int (*app_meta)(void *ctx, const char *app_id, pid_t pid,
char *name, int nl, char *icon, int il, 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)。 */ /* 每进程 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); int (*proc_sample)(void *ctx, pid_t pid, double *pcpu, long *rss_kb);
void (*log)(const char *fmt, ...); /* 可空=静默 */ void (*log)(const char *fmt, ...); /* 可空=静默 */
@@ -53,11 +58,11 @@ typedef struct bgmgr_ops {
* 行序=enumerate 返回序(UI 端期望 bg_since_ms 升序=最久在前)。 */ * 行序=enumerate 返回序(UI 端期望 bg_since_ms 升序=最久在前)。 */
int bgmgr_collect(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int max); 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); int bgmgr_can_kill(const bgmgr_row_t *r);
/* 一键清:遍历 rows,bgmgr_can_kill() 的条目逐一调 kill_fn(app_id)。返回发出 kill 数。 /* 一键清:遍历 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, int bgmgr_kill_all(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int n,
void (*kill_fn)(const char *app_id)); 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→摘表), * 只置 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) int meow_bg_terminate(const char *app_id)
{ {
if (!app_id || !app_id[0]) return MEOW_ERR_INVAL; 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]; meow_bg_entry_t *e = &g_bg[i];
if (e->used && e->pid > 0 && !strcmp(e->app_id, app_id)) { if (e->used && e->pid > 0 && !strcmp(e->app_id, app_id)) {
if (e->term_at_ms == 0) { 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); kill(e->pid, SIGTERM);
e->term_at_ms = bg_now_ms(); 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); int meow_bg_take(const char *app_id, int *out_fd, pid_t *pid);
/* 该 App 是否在后台保活。 */ /* 该 App 是否在后台保活。 */
int meow_bg_is_backgrounded(const char *app_id); 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→摘表。 * 置 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 空。 */ * 返回 0=已受理(在后台表且 pid>0);MEOW_ERR_NOENT=不在后台/无进程;MEOW_ERR_INVAL=app_id 空。 */
int meow_bg_terminate(const char *app_id); int meow_bg_terminate(const char *app_id);
+13 -2
View File
@@ -82,10 +82,19 @@ enum {
MSG_META = 0x0072, /* ↑ 媒体会话上报元数据/进度/状态(阶段 D 附录 B) */ MSG_META = 0x0072, /* ↑ 媒体会话上报元数据/进度/状态(阶段 D 附录 B) */
MSG_PLAYCTL = 0x0073, /* ↓ 播放控制下发/↑ 控制器转发到最近活跃媒体会话 */ MSG_PLAYCTL = 0x0073, /* ↓ 播放控制下发/↑ 控制器转发到最近活跃媒体会话 */
MSG_META_LYRIC = 0x0074, /* ↑ 媒体会话上报当前曲歌词 lrc/tlrc(阶段 D · 批3,分片) */ MSG_META_LYRIC = 0x0074, /* ↑ 媒体会话上报当前曲歌词 lrc/tlrc(阶段 D · 批3,分片) */
/* ---- 系统翻页信号(READER_UI_DESIGN §E,用户 2026-09-04 拍板):0x0080-0x0081 ----
* 语义:launcher 按键层(meow_launcherd/keys.c)在「当前台 App 为其 app.json 声明
* volume_page 的前台应用」时,把物理 VOL_UP/VOL_DOWN 转换成系统翻页信号下发(否则照旧
* 调音量——音量基本行为/插孔消抖/音量反转绝不破坏)。App 侧(如 reader_app/app/reader_ipc.c)
* 持持久连接收本消息 → 上一页/下一页。方向由消息号本身决定(UP=上一页,DOWN=下一页);
* 无负载(len=0)。属系统级能力,未来其它 App 可声明复用。 */
MSG_KEY_PAGEUP = 0x0080, /* ↓ 系统级:上一页(VOL_UP 语义,仅前台声明 app 收) */
MSG_KEY_PAGEDOWN = 0x0081, /* ↓ 系统级:下一页(VOL_DOWN 语义,仅前台声明 app 收) */
/* ---- amsd 控制(AMSD_DESIGN §4.2):amsd→launcher,申请拉起/终止某 App ---- /* ---- amsd 控制(AMSD_DESIGN §4.2):amsd→launcher,申请拉起/终止某 App ----
* 语义:amsd 为「启动/终止」决策者 → launcher(执行器)执行。payload 见下。 */ * 语义:amsd 为「启动/终止」决策者 → launcher(执行器)执行。payload 见下。 */
MSG_AMS_START = 0x00A0, /* → 请求 launcher 拉起某 App(pkg + 可选 activity) */ 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 ---- /* ---- audiod 独占音频服务(devdocs/plan/AUDIOD_DESIGN.md §2):0x00C0-0x00CA ----
* 语义:App 只经 SDK 客户端 API(meow_ui_audio.c)喂 PCM,audiod 独占 hw:0,0。 * 语义:App 只经 SDK 客户端 API(meow_ui_audio.c)喂 PCM,audiod 独占 hw:0,0。
* 独占+抢占(后开赢);软混音已否决(MIPS32 软 SRC 伤音质,留混音器接口位)。 */ * 独占+抢占(后开赢);软混音已否决(MIPS32 软 SRC 伤音质,留混音器接口位)。 */
@@ -455,7 +464,9 @@ typedef struct m_ams_start {
char picker_caller[MEOW_APP_ID_MAX]; /* 32:发起方 app_id(""=系统/CLI) */ char picker_caller[MEOW_APP_ID_MAX]; /* 32:发起方 app_id(""=系统/CLI) */
} m_ams_start_t; /* 160 B */ } 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 { typedef struct m_ams_kill {
char app_id[MEOW_APP_ID_MAX]; /* 32 */ char app_id[MEOW_APP_ID_MAX]; /* 32 */
} m_ams_kill_t; /* 32 B */ } m_ams_kill_t; /* 32 B */
@@ -28,6 +28,7 @@ extern "C" {
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/syscall.h> /* Bug 2:直连 reboot(2) syscall 用 */ #include <sys/syscall.h> /* Bug 2:直连 reboot(2) syscall 用 */
#include <pthread.h> /* M0:IPC 线程 */ #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/eventfd.h> /* M0:IPC→主循环唤醒(EFD_NONBLOCK) */
#include <sys/socket.h> /* M0:AF_UNIX IPC */ #include <sys/socket.h> /* M0:AF_UNIX IPC */
#include <sys/un.h> /* M0:sockaddr_un */ #include <sys/un.h> /* M0:sockaddr_un */
@@ -177,7 +178,12 @@ static inline const char *meow_str_zh(const char *id, const char *zh)
#define MENU_SWIPE_THRESH 65 /* px:抬手位移 >= 阈值判为滑动,不启动行(同 SDK SWIPE_THRESH) */ #define MENU_SWIPE_THRESH 65 /* px:抬手位移 >= 阈值判为滑动,不启动行(同 SDK SWIPE_THRESH) */
#define NCP_DRAG_ENTER_SLOP 16 #define NCP_DRAG_ENTER_SLOP 16
#define NCP_DRAG_THR_PCT 40 #define NCP_DRAG_THR_PCT 40
#define TAP_ARM_DELAY_MS 300 /* M39:launcher 触摸守卫窗口(取代 M2.6「菜单关闭后忽略触摸」800ms / M2.22 的 App 退出后
* 1200ms / M2.6 打开子页 300ms)。语义只剩「drain 队列后仍防面板重配尾巴的幽灵按下」,
* 不再吞用户看到界面后的第一下:窗口起点固定在 drain 之后,且只覆盖极短的驱动尾巴。
* 依据:每次 arm 前都先 menu_drain_touch() 清本进程 touch_fd 队列(evdev 每 fd 各收一份,
* App/子页运行期间的全量事件都在本 fd 里,drain 后已无历史事件可重放)。 */
#define MENU_TOUCH_GUARD_MS 150
#define PM_NUM 6 #define PM_NUM 6
#define PM_R 50 /* 圆钮半径(直径 100) */ #define PM_R 50 /* 圆钮半径(直径 100) */
#define PM_GAPX 130 /* 两列圆心 x 间距(175->305,居中 240) */ #define PM_GAPX 130 /* 两列圆心 x 间距(175->305,居中 240) */
@@ -262,6 +268,9 @@ static inline const char *meow_str_zh(const char *id, const char *zh)
#define MEOW_SBAR_MODE_FILE "/tmp/meowapp_sbar" #define MEOW_SBAR_MODE_FILE "/tmp/meowapp_sbar"
/* M2.71-bug5:App 前台触摸/交互 → SDK 写该标记;launcher 前台 app 循环消费(unlink + 重置息屏计时) */ /* M2.71-bug5:App 前台触摸/交互 → SDK 写该标记;launcher 前台 app 循环消费(unlink + 重置息屏计时) */
#define MEOW_APP_ACTIVITY_FILE "/tmp/meow_app_activity" #define MEOW_APP_ACTIVITY_FILE "/tmp/meow_app_activity"
/* M40 F5/P5-P6:解锁/菜单关闭后强制 App 整帧重画的请求文件。launcher 在 SIGCONT 前写,
* SDK present 层(sdk/meow_ui_repaint.h,static inline)看到就 dirty_full() 一次并删除。 */
#define MEOW_APP_REPAINT_FILE "/tmp/meow_app_repaint"
/* ---- 共享结构体/枚举(来自 meow_launcherd.c,拆分后统一放置)---- */ /* ---- 共享结构体/枚举(来自 meow_launcherd.c,拆分后统一放置)---- */
struct kd_state { int raw, stable, down; int longpressed; unsigned long long since, down_at; }; struct kd_state { int raw, stable, down; int longpressed; unsigned long long since, down_at; };
@@ -675,6 +684,10 @@ int kp_open(struct keypoll *kp, const char *dev);
int kp_poll(struct keypoll *kp, int (*cb)(int code, int down, void *ctx), int kp_poll(struct keypoll *kp, int (*cb)(int code, int down, void *ctx),
void *ctx); void *ctx);
int launch_app(struct app *a); 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)---- */ /* ---- app.json 二进制依赖(depends)解析与启动前检查(meow_dep.c)---- */
int meow_parse_depends(const char *val, struct meow_dep *deps, int max); 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); int meow_check_app_depends(const struct app *a, char *missing, int mlen);
@@ -756,6 +769,15 @@ int meow_ipc_conn_alive(int fd);
/* M4:ipc_send 公开包装(meow_bgplay send_frame 回调用;同 meow_media 的注入口径)。 */ /* M4:ipc_send 公开包装(meow_bgplay send_frame 回调用;同 meow_media 的注入口径)。 */
size_t meow_ipc_send_pub(int fd, uint16_t type, uint32_t req_id, size_t meow_ipc_send_pub(int fd, uint16_t type, uint32_t req_id,
const void *payload, uint32_t len); const void *payload, uint32_t len);
/* ---- E:系统级音量翻页(READER_UI_DESIGN §E,用户 2026-09-04 拍板)----
* 前台 App 声明 app.json `volume_page` 时,launcher 把物理 VOL_UP/DOWN 转成系统翻页信号
* (MSG_KEY_PAGEUP/PAGEDOWN,meow_ipc.h)经 IPC 下发给该 App;否则照旧调音量。 */
int meow_volpage_app_allows(const char *app_id); /* keys.c:读 app.json 声明 */
void meow_volpage_sink(const char *app_id, int fd); /* keys.c:武装前台翻页 sink */
void meow_volpage_sink_clear(void); /* keys.c:撤武装 */
int meow_volpage_armed(void); /* keys.c:查询 */
int meow_volpage_key(int code, int is_down); /* keys.c:门(1=已消费/已下发) */
int meow_ipc_app_fd(const char *app_id); /* util.c:前台 App→持久 IPC fd(-1=无) */
/* §10.5:读并清除待处理的 MSG_NOTIF_OPEN(App 前台顶部下滑请求展开通知中心叠加会话)。 /* §10.5:读并清除待处理的 MSG_NOTIF_OPEN(App 前台顶部下滑请求展开通知中心叠加会话)。
* 调用方=launch_app waitpid 循环;返回 1 并填 *out(待处理),0=无。 */ * 调用方=launch_app waitpid 循环;返回 1 并填 *out(待处理),0=无。 */
int meow_notif_open_take(m_notif_open_t *out); int meow_notif_open_take(m_notif_open_t *out);
@@ -765,7 +787,23 @@ void meow_panel_off(void);
void meow_panel_on(void); void meow_panel_on(void);
void meow_parse_app_json(FILE *f, struct app *a); void meow_parse_app_json(FILE *f, struct app *a);
int meow_pkg_export(const char *pkg, const char *out_path); int meow_pkg_export(const char *pkg, const char *out_path);
int meow_pkg_install(const char *mpk_path); /* ---- M42:包安装(唯一实现 = /usr/bin/nyai,launcherd 只做入口预检 + 异步执行 + 错误文案)----
* 安装流的解包/清单/footer/验签(meow_verify)/防降级/部署/清理全在 nyai;本模块不解析包内容。
* nyai 路径 env MEOW_NYAI_BIN 覆写(缺省 /usr/bin/nyai,不依赖 PATH)。 */
#define MEOW_PKG_E_PATH (-1) /* 入口路径预检失败(文案见 meow_pkg_install_error) */
#define MEOW_PKG_E_NYAI (-2) /* nyai 不可执行 / pipe/fork 失败 */
#define MEOW_PKG_E_FAIL (-3) /* nyai 返回非 0 */
#define MEOW_PKG_E_TIMEOUT (-4) /* 超时并已终止子进程 */
#define MEOW_PKG_E_BUSY (-5) /* 已有一次安装在跑 */
#define MEOW_PKG_INSTALL_TIMEOUT_MS 180000
int meow_pkg_install(const char *path); /* 同步:0=成功;<0=MEOW_PKG_E_* */
int meow_pkg_install_start(const char *path); /* 异步启动:0=已启动;<0 同左 */
int meow_pkg_install_poll(int *rc); /* 1=运行中 0=结束(*rc) -1=无任务 */
int meow_pkg_install_busy(void); /* 1=有安装在跑(UI 据此禁双击) */
const char *meow_pkg_install_error(void); /* 最近一次失败的可读文案(永不 NULL) */
/* 纯逻辑(宿主单测 test_pkg_install.c):路径预检 / 退出码→可读文案 */
int meow_pkg_install_check_path(const char *path, char *err, int n);
void meow_pkg_install_error_text(int rc, const char *detail, char *out, int n);
void meow_sys_light(void); void meow_sys_light(void);
void meow_sys_refresh(void); void meow_sys_refresh(void);
void meow_toggle_panel(void); void meow_toggle_panel(void);
@@ -834,6 +872,9 @@ int nm_wifi_saved(meow_net_t *out, int max);
int nm_wifi_scan(meow_net_t *out, int max); int nm_wifi_scan(meow_net_t *out, int max);
void nm_wifi_scan_trigger(void); void nm_wifi_scan_trigger(void);
int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen); /* MEOW_OK=成功 */ 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_disconnect(char *err, int elen);
int nm_wifi_toggle(int on); int nm_wifi_toggle(int on);
Binary file not shown.
+144 -8
View File
@@ -128,6 +128,129 @@ char *popen_read_tmo(int tmo_ms, const char *cmd)
} }
/* ===== M47:WiFi/BT「真实开/关」三态探测 =========================================
* 背景:设置页两个开关此前把 settings 布尔(用户意图)当显示态,读不到时 WiFi 还默认 1,
* 于是「开关长什么样」与硬件真实态无关(用户无法判断到底开没开)。M47 起 UI 只认真实态,
* 且真实态一律三态:-1=未知 / 0=关 / 1=开。探测失败/解析不出 = 未知,绝不用 0 冒充「已关」,
* 也不许用 1 冒充「已开」。netmgrd 可达时 BT 以 daemon 的 bt status 为准(见 netmgr_ipc.c);
* 下列函数是「daemon 不可达」时的直连回退,以及 WiFi 的权威探测(netmgrd 的 wifi on/off
* 就是 rfkill wlan block/unblock,而 wifi status 只报 wpa_state、不报射频态)。 */
/* 读一份 sysfs 字符串(去尾部换行);失败返回 -1,buf 置空。 */
static int net_read_sys(const char *path, char *buf, int n)
{
buf[0] = 0;
int fd = open(path, O_RDONLY);
if (fd < 0) return -1;
ssize_t r = read(fd, buf, (size_t)n - 1);
close(fd);
if (r <= 0) { buf[0] = 0; return -1; }
buf[r] = 0;
while (r > 0 && (buf[r - 1] == '\n' || buf[r - 1] == '\r')) buf[--r] = 0;
return 0;
}
/* 按 rfkill type 读 soft/hard。返回 1=找到 / 0=无该类型 / -1=不可读。
* 本机实测有 2 个 wlan + 2 个 bluetooth 节点(rfkill0..3):同一 type 只要有一个被 block,
* 射频就不可用,故按「任一 block 即 block」汇总(保守方向:宁可报关,不假装开着)。 */
static int rfkill_state(const char *type, int *soft, int *hard)
{
DIR *d = opendir("/sys/class/rfkill");
if (!d) return -1;
int found = 0, s_any = 0, h_any = 0;
struct dirent *de;
while ((de = readdir(d))) {
if (strncmp(de->d_name, "rfkill", 6) != 0) continue;
char path[128], ty[32];
snprintf(path, sizeof path, "/sys/class/rfkill/%s/type", de->d_name);
if (net_read_sys(path, ty, sizeof ty) != 0) continue;
if (strcmp(ty, type) != 0) continue;
snprintf(path, sizeof path, "/sys/class/rfkill/%s/soft", de->d_name);
int s = read_file_int(path);
snprintf(path, sizeof path, "/sys/class/rfkill/%s/hard", de->d_name);
int h = read_file_int(path);
if (s < 0 || h < 0) continue; /* 读出失败:当作没找到这一类 */
found = 1;
if (s) s_any = 1;
if (h) h_any = 1;
}
closedir(d);
if (!found) return 0;
*soft = s_any; *hard = h_any;
return 1;
}
/* 写 rfkill soft(block=1 关 / 0 开)。返回 0=至少写成功一个;-1=无该类型设备或全写失败。
* 同类多个节点全写(只写第一个会漏掉真正绑定驱动的那个)。netmgrd 的 rfkill_set_block
* 只写首个匹配节点(见 meow_netmgrd.c:992-1016)——已作为 finding 报给塔,不在本单改。
* M47:netmgrd 的 bt on/off 就是「hciconfig + rfkill soft」,直连路径必须写同一份状态,
* 否则「从本页关过蓝牙」之后从别的入口 `hciconfig up` 会被内核 rfkill 拦下(开关失真)。 */
static int rfkill_set_soft(const char *type, int block)
{
DIR *d = opendir("/sys/class/rfkill");
if (!d) return -1;
int rc = -1;
struct dirent *de;
while ((de = readdir(d))) {
if (strncmp(de->d_name, "rfkill", 6) != 0) continue;
char path[128], ty[32];
snprintf(path, sizeof path, "/sys/class/rfkill/%s/type", de->d_name);
if (net_read_sys(path, ty, sizeof ty) != 0) continue;
if (strcmp(ty, type) != 0) continue;
snprintf(path, sizeof path, "/sys/class/rfkill/%s/soft", de->d_name);
int fd = open(path, O_WRONLY);
if (fd < 0) continue;
char v[2] = { block ? '1' : '0', 0 };
if (write(fd, v, 1) == 1) rc = 0; /* 任一成功即视为已下发(不因后一个失败翻回 -1) */
close(fd);
}
closedir(d);
return rc;
}
/* WiFi 射频真实态:-1 未知 / 0 关 / 1 开。
* 1) 有 wlan rfkill 设备(netmgrd 的 wifi on/off 就是写它):hard/soft 任一 block = 关;
* 未 block 时接口存在才算开(无 wlan0 = 驱动没载,无法判定 → 未知)。
* 2) 无 rfkill 设备:退 wlan0 管理态 IFF_UP(与状态栏 wlan_radio_up 同源)。
* 3) 都读不到 = 未知(不当「已关」)。 */
int net_wifi_power_probe(void)
{
int soft = 0, hard = 0;
int rk = rfkill_state("wlan", &soft, &hard);
if (rk == 1) {
if (hard || soft) return 0;
return access("/sys/class/net/wlan0", F_OK) == 0 ? 1 : -1;
}
if (rk < 0) return -1;
if (access("/sys/class/net/wlan0", F_OK) != 0) return -1;
char b[16];
if (net_read_sys("/sys/class/net/wlan0/flags", b, sizeof b) != 0) return -1;
return (int)(strtoul(b, NULL, 16) & 0x1UL) ? 1 : 0;
}
/* BT 控制器真实态:-1 未知 / 0 关 / 1 开。
* 权威 = hciconfig hci0 的 UP/DOWN 状态行(与 netmgrd bt_status 的回退解析同源)。
* 命令超时/无输出/既无 UP 也无 DOWN(如 hci0 不存在)= 未知。 */
int net_bt_power_probe(void)
{
char *o = popen_read_tmo(1800,
"PATH=/bin:/sbin:/usr/bin:/usr/sbin hciconfig -a hci0 2>/dev/null");
if (!o) return -1;
int r = -1;
char *line = o;
while (line && *line) {
char *eol = strchr(line, '\n'); if (eol) *eol = 0;
char *p = line;
while (*p == ' ' || *p == '\t') p++;
if (!strncmp(p, "DOWN", 4)) { r = 0; break; }
if (!strncmp(p, "UP", 2)) { r = 1; break; }
line = eol ? eol + 1 : NULL;
}
free(o);
return r;
}
/* 十六进制字符值('0'-'9'/'a'-'f'/'A'-'F');非法返回 -1。避免依赖 ctype.h(musl/内存受限)。 /* 十六进制字符值('0'-'9'/'a'-'f'/'A'-'F');非法返回 -1。避免依赖 ctype.h(musl/内存受限)。
* M2.40:wpa_cli 对非 ASCII SSID 输出 "\xNN" 转义文字(`\`+x+两位hex 的纯文字,非真字节), * M2.40:wpa_cli 对非 ASCII SSID 输出 "\xNN" 转义文字(`\`+x+两位hex 的纯文字,非真字节),
* 需先还原成真实 UTF-8 字节才能进界面文本。 */ * 需先还原成真实 UTF-8 字节才能进界面文本。 */
@@ -733,7 +856,11 @@ int s_net_get(const srow_t *r, char *buf, int len)
/* 网络动作开关:wifi/bluetooth 开/关(写 wlan0/hci0;立即生效,异步刷新缓存)。 /* 网络动作开关:wifi/bluetooth 开/关(写 wlan0/hci0;立即生效,异步刷新缓存)。
* 走 system(wifi_up.sh 自身后台化子进程、ip link/hciconfig 均为快 ioctl)。 */ * 走 system(wifi_up.sh 自身后台化子进程、ip link/hciconfig 均为快 ioctl)。
* M47:蓝牙补上 rfkill soft(与 netmgrd 的 bt on/off 同一语义)——此前本函数只发 hciconfig,
* 于是「在蓝牙页关过(rfkill soft=1)→ 再从通知中心/设置主页打开」时 `hciconfig up` 被内核
* rfkill 拦下、settings 却已写 1,开关显示 ON 实际 OFF(M45 盘点的开关失真)。开:先解 rfkill
* 再 up(顺序颠倒会先失败一次);关:先 down 再 block(与 netmgrd 一致)。 */
void s_net_toggle_wifi(int on) void s_net_toggle_wifi(int on)
{ {
if (on) system("PATH=/bin:/sbin:/usr/bin:/usr/sbin /usr/bin/wifi_up.sh"); if (on) system("PATH=/bin:/sbin:/usr/bin:/usr/sbin /usr/bin/wifi_up.sh");
@@ -742,24 +869,33 @@ void s_net_toggle_wifi(int on)
void s_net_toggle_bt(int on) void s_net_toggle_bt(int on)
{ {
if (on) system("hciconfig hci0 up 2>/dev/null"); if (on) {
else system("hciconfig hci0 down 2>/dev/null"); rfkill_set_soft("bluetooth", 0);
system("hciconfig hci0 up 2>/dev/null");
} else {
system("hciconfig hci0 down 2>/dev/null");
rfkill_set_soft("bluetooth", 1);
}
} }
/* 进入设置页时把 network.wifi_enabled/bluetooth_enabled 同步为真实硬件态, /* 进入设置页时把 network.wifi_enabled/bluetooth_enabled 同步为真实硬件态,
* 让开关行反映 reality(仅同步内存 + 刷新缓存;不落盘 —— s_commit 的 save 才真正写)。 * 让开关行反映 reality(仅同步内存 + 刷新缓存;不落盘 —— s_commit 的 save 才真正写)。
* settings 未 init/取值失败则不动(保默认)。 */ * settings 未 init/取值失败则不动(保默认)。
* M47(M45 盘点):WiFi 判据不再是 wlan_connected()(=operstate up,即「已关联」)——网卡 up
* 但未关联时 operstate=dormant,旧写法会把 key 改成 0、WiFi 页开关显示 OFF 而射频明明开着;
* 改用 net_wifi_power_probe()(rfkill/网卡 up)。BT 改用三态探测:读不到(-1)就不写,
* 不拿 0 猜「已关」。 */
void s_net_sync_from_sys(void) void s_net_sync_from_sys(void)
{ {
int cur = 0; int cur = 0;
if (meow_settings_get_bool("network.wifi_enabled", &cur) == 0) { if (meow_settings_get_bool("network.wifi_enabled", &cur) == 0) {
int real = wlan_connected() ? 1 : 0; int real = net_wifi_power_probe();
if (real != cur) meow_settings_set_bool("network.wifi_enabled", real); if (real >= 0 && real != cur) meow_settings_set_bool("network.wifi_enabled", real);
} }
if (meow_settings_get_bool("network.bluetooth_enabled", &cur) == 0) { if (meow_settings_get_bool("network.bluetooth_enabled", &cur) == 0) {
int real = hci_powered() ? 1 : 0; int real = net_bt_power_probe();
if (real != cur) meow_settings_set_bool("network.bluetooth_enabled", real); if (real >= 0 && real != cur) meow_settings_set_bool("network.bluetooth_enabled", real);
} }
/* 只同步开关(快)+ 异步 kick;状态/列表由 run_settings_page 主循环 pump,绝不阻塞入口。 */ /* 只同步开关(快)+ 异步 kick;状态/列表由 run_settings_page 主循环 pump,绝不阻塞入口。 */
s_net_refresh_kick(); s_net_refresh_kick();
+184 -39
View File
@@ -15,6 +15,19 @@
#define NETMGR_SOCK_DEFAULT "/usr/data/meow_net.sock" #define NETMGR_SOCK_DEFAULT "/usr/data/meow_net.sock"
/* NETMGR_UNREACH 定义在 meow_launcher_internal.h(测试/调用方共用) */ /* NETMGR_UNREACH 定义在 meow_launcher_internal.h(测试/调用方共用) */
/* M47:net.c 的直连三态探测(-1 未知 / 0 关 / 1 开)。声明在此而非头文件——头文件不在本单
* 改动范围;两处定义与用法同属 launcher 网络线。 */
int net_bt_power_probe(void);
int net_wifi_power_probe(void);
/* 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 防热循环。 */ /* 可达性缓存:netmgrd 不可达时每次 connect 都秒回 ECONNREFUSED,无成本;缓存约 1s 防热循环。 */
static int g_nm_ok = -1; /* -1 未知 / 0 down / 1 up */ static int g_nm_ok = -1; /* -1 未知 / 0 down / 1 up */
static time_t g_nm_ok_ts = 0; static time_t g_nm_ok_ts = 0;
@@ -50,7 +63,7 @@ static int nm_r_all(int fd, uint8_t *b, size_t n)
return 0; return 0;
} }
static int nm_xchg(int fd, uint16_t type, uint32_t req, const void *pl, uint32_t len, 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]; uint8_t hdr[MEOW_IPC_HDR_LEN];
uint32_t magic = MEOW_IPC_MAGIC, rlen = len; uint32_t magic = MEOW_IPC_MAGIC, rlen = len;
@@ -60,7 +73,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 (nm_w_all(fd, hdr, sizeof hdr) < 0) return -1;
if (len && pl && nm_w_all(fd, pl, len) < 0) return -1; if (len && pl && nm_w_all(fd, pl, len) < 0) return -1;
struct pollfd pf = { fd, POLLIN, 0 }; struct pollfd pf = { fd, POLLIN, 0 };
int pr = poll(&pf, 1, 1500); int pr = poll(&pf, 1, tmo_ms);
if (pr <= 0) return -1; if (pr <= 0) return -1;
uint8_t rhdr[MEOW_IPC_HDR_LEN]; uint8_t rhdr[MEOW_IPC_HDR_LEN];
if (nm_r_all(fd, rhdr, sizeof rhdr) < 0) return -1; if (nm_r_all(fd, rhdr, sizeof rhdr) < 0) return -1;
@@ -81,15 +94,21 @@ static int nm_hello(int fd)
snprintf(h.app_id, sizeof h.app_id, "%s", "os.meow.launcher"); snprintf(h.app_id, sizeof h.app_id, "%s", "os.meow.launcher");
h.version = 1; h.version = 1;
uint8_t rbuf[256]; uint16_t rtype = 0; 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; return -1;
m_ack_t ak; memcpy(&ak, rbuf, sizeof ak); m_ack_t ak; memcpy(&ak, rbuf, sizeof ak);
return (ak.code == MEOW_OK) ? 0 : -1; 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=不可达。
static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const char *arg, * tmo_ms:读超时(connect/connecth/scan 需覆盖 netmgrd 同步等待,见 NM_TMO_*)。
char *text, int tlen) * read_timeout(可空):非空时,「帧收发失败/类型不符」这一支只回写 *read_timeout=1 且**不**
* 把可达性缓存打成 down——调用方据此区分「读超时」与「真不可达」。M47 的 BT 状态查询用它:
* netmgrd 单线程忙时(bt status 自己会跑 hciconfig)状态查询可能超过 1.5s,若据此标 down,
* 紧随的 bt_rows 会闪出「服务未运行(直连模式)」并把后续探测切到 1.8s 的 hciconfig
* (评审 r1 观察;global 超时常量不动,WiFi/DNS 路径行为不变)。 */
static int nm_cmd_t(uint16_t ctl_type, uint16_t res_type, const char *cmd, const char *arg,
char *text, int tlen, int tmo_ms, int *read_timeout)
{ {
m_net_cmd_t q; m_net_cmd_t q;
memset(&q, 0, sizeof q); memset(&q, 0, sizeof q);
@@ -100,8 +119,11 @@ 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; } if (nm_hello(fd) < 0) { close(fd); nm_cache(0); return NETMGR_UNREACH; }
uint8_t rbuf[MEOW_IPC_MAX]; uint16_t rtype = 0; uint8_t rbuf[MEOW_IPC_MAX]; uint16_t rtype = 0;
memset(rbuf, 0, sizeof rbuf); 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; close(fd);
if (read_timeout) *read_timeout = 1;
else nm_cache(0);
return NETMGR_UNREACH;
} }
m_net_cmd_result_t res; memcpy(&res, rbuf, sizeof res); m_net_cmd_result_t res; memcpy(&res, rbuf, sizeof res);
close(fd); close(fd);
@@ -112,11 +134,20 @@ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const c
return (int)res.code; return (int)res.code;
} }
static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const char *arg,
char *text, int tlen, int tmo_ms)
{
return nm_cmd_t(ctl_type, res_type, cmd, arg, text, tlen, tmo_ms, NULL);
}
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) 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) 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) int netmgr_ok(void)
{ {
@@ -361,7 +392,7 @@ int nm_wifi_saved(meow_net_t *out, int max)
int nm_wifi_scan(meow_net_t *out, int max) int nm_wifi_scan(meow_net_t *out, int max)
{ {
char text[MEOW_IPC_MAX - 32]; 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 == NETMGR_UNREACH) return net_wifi_scan(out, max);
if (rc != MEOW_OK) return 0; if (rc != MEOW_OK) return 0;
return nm_parse_scan(text, out, max); return nm_parse_scan(text, out, max);
@@ -374,49 +405,133 @@ void nm_wifi_scan_trigger(void)
if (!netmgr_ok()) net_wifi_scan_trigger(); if (!netmgr_ok()) net_wifi_scan_trigger();
} }
/* —— 直连回退:wpa_cli add/set/enable/select 新网络(复刻 netmgrd wifi_connect 逻辑)—— */ /* —— 直连回退:与 netmgrd wifi_connect/connecth 对齐(bug1/bug2/bug4)。
static int nm_direct_connect(const char *ssid, const char *psk, char *err, int elen) * 匹配既有(\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[384];
char cmd[256]; snprintf(cmd, sizeof cmd, "%s %s 2>/dev/null", NM_WPA_PREFIX, args);
snprintf(cmd, sizeof cmd, "%s add_network 2>/dev/null", wpa); char *o = popen_read_tmo(tmo, cmd);
char *o = popen_read_tmo(1200, cmd); if (!o) { out[0] = 0; return -1; }
int id = o ? atoi(o) : -1; int l = (int)strlen(o);
if (o) free(o); if (l >= cap) l = cap - 1;
if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; } memcpy(out, o, (size_t)l); out[l] = 0;
{ free(o);
char *r; return l;
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]) { static int nm_direct_save_config(void)
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); 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 { } else {
snprintf(cmd, sizeof cmd, "%s set_network %d key_mgmt NONE 2>/dev/null", wpa, id); snprintf(clean, sizeof clean, "%s", ss);
if ((r = popen_read_tmo(1200, cmd))) free(r);
} }
snprintf(cmd, sizeof cmd, "%s enable_network %d 2>/dev/null", wpa, id); nm_decode_ssid(clean, unesc, sizeof unesc);
if ((r = popen_read_tmo(1200, cmd))) free(r); char *p = unesc; while (*p == ' ' || *p == '\t') p++;
snprintf(cmd, sizeof cmd, "%s select_network %d 2>/dev/null", wpa, id); snprintf(norm, sizeof norm, "%s", p);
if ((r = popen_read_tmo(1200, cmd))) free(r); 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; }
} }
if (err) snprintf(err, elen, "connect: ssid=%s network_id=%d", ssid, id); ln = strtok(NULL, "\n");
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; 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; } if (!ssid || !ssid[0]) { if (err) snprintf(err, elen, "ssid 缺失"); return MEOW_ERR_INVAL; }
char arg[132]; 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]; char text[192];
int rc = netmgr_wifi("connect", arg, text, sizeof text); int rc = netmgr_wifi_tmo(hidden ? "connecth" : "connect", arg, text, sizeof text,
if (rc == NETMGR_UNREACH) return nm_direct_connect(ssid, psk, err, elen); 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); if (err && elen > 0) snprintf(err, elen, "%s", text);
return rc; 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) int nm_wifi_disconnect(char *err, int elen)
{ {
char text[128]; char text[128];
@@ -456,6 +571,36 @@ int nm_bt_powered(void)
return atoi(v); return atoi(v);
} }
/* ===== M47:设置页开关的「真实态」三态查询(-1 未知 / 0 关 / 1 开)=====
* 与 nm_bt_powered()/nm_wifi_connected() 的区别:那两个把「问不到」塌缩成 0(看起来像
* 「已关」),正是开关显示不可信的根因之一。M47 起设置页开关只用下面两个函数。 */
int nm_bt_power_state(void)
{
char text[192];
int read_timeout = 0;
/* 状态查询走专用入口:读超时 = 「状态未知」,且不把 netmgrd 标成 down(见 nm_cmd_t 注释)。 */
int rc = nm_cmd_t(MSG_NET_BT_CTL, MSG_NET_BT_RESULT, "status", NULL,
text, sizeof text, NM_TMO_DFL, &read_timeout);
if (read_timeout) return -1;
if (rc == NETMGR_UNREACH) return net_bt_power_probe(); /* 直连回退;失败=未知 */
if (rc != MEOW_OK) return -1; /* daemon 报错:不知道就是不知道 */
char v[8];
nm_val(text, "available=", v, sizeof v, 0);
if (v[0] && atoi(v) == 0) return 0; /* 无控制器 → 必为关 */
nm_val(text, "powered=", v, sizeof v, 0);
if (!v[0]) return -1; /* 无 powered 字段 → 未知 */
return atoi(v) ? 1 : 0;
}
/* WiFi 射频真实态(三态)。netmgrd 的 `wifi status` 只报 wpa_state/连接态,不报射频开关
* (见 meow_netmgrd.c wifi_status,字段仅 state/connected/ssid/ip/rssi),无法据此判断
* 「wifi 开关」;而 daemon 的 wifi on/off 恰好就是 rfkill wlan block/unblock,所以射频态
* 取本地 rfkill(回退 wlan0 IFF_UP)——与 daemon 改的是同一份内核状态,且探测失败=未知。 */
int nm_wifi_power_state(void)
{
return net_wifi_power_probe();
}
int nm_bt_onoff(int on) int nm_bt_onoff(int on)
{ {
char text[128]; 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; struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS); kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; /* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 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); int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} } if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
unsigned long long touch_cooldown_until = now_ms() + 300; 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) { if (wak) {
meow_panel_on(); meow_auto_slept = 0; wake_suppress = 1; last_act = now_ms(); 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); /* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
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); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; drag_slider = 0; tlx = tly = -1; 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 红线——不映射返回/确认) */ /* 按键:系统语义(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); 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 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); (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; play = kd_play.stable; pwr = kd_pwr.stable;
if (wake_suppress) { 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 { } else {
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; } if (ev.vol_dn_edge) { 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_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
if (play && !m_prev_play) { if (play && !m_prev_play) {
logmsg("notif-center: play key caught (media-reserved, no exit)"); logmsg("notif-center: play key caught (media-reserved, no exit)");
last_act = now; last_act = now;
@@ -1366,12 +1361,11 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
show_power_menu(); show_power_menu();
last_act = now_ms(); last_act = now_ms();
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_suppress_click = 1; 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); /* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
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); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; need_paint = 1; after_modal = 1; need_paint = 1;
goto ncp_keys_seeded; 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) { 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); int going = vol_ramp_tick(held);
g_sys.vol = settings_volume_get(); g_sys.vol = settings_volume_get();
paint_sb_right(); paint_sb_right();
need_paint = 1; need_paint = 1;
if (!going) logmsg("notif-center vol ramp end -> %d", g_sys.vol); 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: ncp_keys_seeded:
; ;
@@ -1562,15 +1556,13 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
after_modal = 1; /* 电源菜单已弹 → 整帧重绘还原 */ after_modal = 1; /* 电源菜单已弹 → 整帧重绘还原 */
/* 电源菜单返回:菜单期间用户可能按电源确认/取消,键边沿可能错配, /* 电源菜单返回:菜单期间用户可能按电源确认/取消,键边沿可能错配,
* 同电源键长按路径(line 7480-7488)重 poll + 重采 m_prev_*,防误判「单击」→ 误切 panel。 */ * 同电源键长按路径(line 7480-7488)重 poll + 重采 m_prev_*,防误判「单击」→ 误切 panel。 */
/* 菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_power, NULL, NULL);
kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
/* 菜单期间用户的触摸经 evdev 广播也会进本 panel 的 tfd → 立即排空, /* 菜单期间用户的触摸经 evdev 广播也会进本 panel 的 tfd → 立即排空,
* 防返回后把「菜单里的点击」误判为 panel 内 tap/滑动。 */ * 防返回后把「菜单里的点击」误判为 panel 内 tap/滑动。 */
+127 -16
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); 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 控件): * M7.0b Now Playing 控件化(launcher 无 meow_ui_run,手动驱动 SDK 控件):
* - 进度条 → meow_progress(线性,SDK 主题 track/fill); * - 进度条 → 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); NP_HOME_BAR_H / 2, th->on_surface_variant);
} }
/* 歌词页整页绘制:背景统一黑色半透明叠加层(用户 2026-09-04,弃封面取色/封面图半透明底, /* 网易云式歌词页封面背景(用户 2026-09-05):有封面 → 等比居中放大铺满内容区(cover-fit,
* 保证高亮封面下歌词对比度)→ lyric_view(自绘 scrim + 歌词/空态)。 */ * 超出部分由画布裁剪,镜像 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) static void np_draw_lyric_page(int redraw_sb)
{ {
const meow_ui_theme_t *th = s_ui_theme(); const meow_ui_theme_t *th = s_ui_theme();
int W = fb_xres, H = fb_yres; int W = fb_xres, H = fb_yres;
if (!g_np_init) np_widgets_create(); if (!g_np_init) np_widgets_create();
if (redraw_sb) fill_rect(0, 0, W, SB_BAR_H, th->surface); if (redraw_sb) fill_rect(0, 0, W, SB_BAR_H, th->surface);
/* 批5:封面取色背景(主→次渐变)或主题面;并注入歌词页 set_colors。 /* 批5:np_cover_theme_update 取色(仅为播放页文字/按钮主题;歌词页背景不走取色,恒 ncm 白/灰)。
* 批6:有封面时以封面作整页背景(衬在 lyric_view 半透明 scrim 之下),取色只做附加; * 批6:有封面时以封面作整页背景(网易云式 cover-fit 铺满,见 np_draw_lyric_cover_bg),
* 无封面回退渐变/主题面。 * 无封面回退纯黑底;压暗由下方黑色半透明叠加层(alpha176)统一完成,lyric_view 关内置 scrim。
* M6.33:先 np_cover_load(artwork 空→复原/换源时重载封面)再取色——顺序不能反, * M6.33:先 np_cover_load(artwork 空→复原/换源时重载封面)再取色——顺序不能反,
* 取色读 g_np_cover,须在封面载入后(否则歌词页在 artwork 复原后仍无封面)。 */ * 取色读 g_np_cover,须在封面载入后(否则歌词页在 artwork 复原后仍无封面)。 */
np_cover_load(meow_media_current()); np_cover_load(meow_media_current());
np_cover_theme_update(); np_cover_theme_update();
np_apply_lyric_colors(); /* 网易云式封面背景(用户 2026-09-05):先铺封面底(cover-fit 原图铺满),无封面回退纯黑底;
/* 用户 2026-09-04:歌词页背景统一黑色半透明叠加层(弃封面取色/封面图半透明底)—— * 随后统一叠黑色半透明叠加层(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); meow_ui_fill_rect_alpha(0, SB_BAR_H, W, H - SB_BAR_H, 0x000000, 176);
np_lyric_refresh(meow_media_current()); np_lyric_refresh(meow_media_current());
if (g_np_lyric) if (g_np_lyric)
meow_widget_draw(meow_lyric_view_widget(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 指示(底部上滑可回桌面) */ np_draw_home_bar(); /* 批②:歌词页也示 home 指示(底部上滑可回桌面) */
if (redraw_sb) draw_statusbar(); if (redraw_sb) draw_statusbar();
} }
@@ -486,6 +555,31 @@ static int64_t np_progress_seek(int tx, int ty)
return 0; 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) void run_now_playing(struct app *apps, int napps)
{ {
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK); 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); kp_open(&kp_keys, DEV_KEYS);
struct keypoll kp_power; /* M2.92:NP 页接管电源键(修「页内电源键无反应」) */ struct keypoll kp_power; /* M2.92:NP 页接管电源键(修「页内电源键无反应」) */
kp_open(&kp_power, DEV_POWER); 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); memset(&kd_play, 0, sizeof kd_play);
/* 读播放键按住态(用于边沿) */ /* 读播放键按住态(用于边沿) */
int m_prev_play = 0; int m_prev_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))) m_prev_play = 1; if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
}
int np_prev_power = 0; /* 电源键边沿跟踪 */ int np_prev_power = 0; /* 电源键边沿跟踪 */
for (int w = 0; w < KEYBUF_WORDS; w++) for (int w = 0; w < KEYBUF_WORDS; w++)
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) np_prev_power = 1; 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) */ 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; 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 一直钉哨兵,命中要走 meow_widget_hit 的
* owner==active 闸门——漏 push 则 active()!=0x51, * owner==active 闸门——漏 push 则 active()!=0x51,
* 三钮 meow_widget_hit 恒 false(「播放页三键无效」根因)。 */ * 三钮 meow_widget_hit 恒 false(「播放页三键无效」根因)。 */
key_router_page_enter(NP_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
g_np_page = NP_PAGE_PLAY; /* 批4:每次进入 Now Playing 从播放页起(上次退出页不残留) */ g_np_page = NP_PAGE_PLAY; /* 批4:每次进入 Now Playing 从播放页起(上次退出页不残留) */
g_np_lyric_last_valid = -1; /* 批4:重置歌词「是否有词」过渡态,防首帧多清一次 */ g_np_lyric_last_valid = -1; /* 批4:重置歌词「是否有词」过渡态,防首帧多清一次 */
g_np_lyric_seq = 0xFFFFFFFFu; /* 批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); kp_poll(&kp_keys, NULL, NULL);
int play = 0; 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 (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; 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 页电源键接管——单击边沿 = 息屏/亮屏(与主界面语义一致; /* M2.92:NP 页电源键接管——单击边沿 = 息屏/亮屏(与主界面语义一致;
* 修「NP 页电源键无反应」。3s 电源菜单仍由 watchdog→/tmp 标记→主循环消费, * 修「NP 页电源键无反应」。3s 电源菜单仍由 watchdog→/tmp 标记→主循环消费,
@@ -714,6 +824,7 @@ void run_now_playing(struct app *apps, int napps)
usleep(10000); usleep(10000);
} }
meow_ui_activity_pop(); /* M7.0b:退出 Now Playing,回主 activity(控件 owner 仍钉哨兵,复用) */ meow_ui_activity_pop(); /* M7.0b:退出 Now Playing,回主 activity(控件 owner 仍钉哨兵,复用) */
key_router_page_exit(NP_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
if (tfd >= 0) close(tfd); if (tfd >= 0) close(tfd);
kp_close(&kp_keys); kp_close(&kp_keys);
kp_close(&kp_power); kp_close(&kp_power);
+22
View File
@@ -91,4 +91,26 @@ static inline int overlay_lock_wake_now(int pwr_edge, int touch_ready)
return (pwr_edge || touch_ready) ? 1 : 0; return (pwr_edge || touch_ready) ? 1 : 0;
} }
/* M40 F4/P4:锁屏页「电源按下=息屏退出」必须在**按下边沿**生效,且进场后 ≥300ms 才允许。
* 旧实现用电平(kp_is_down)且无防抖:打开电源菜单的那次按压在 PIN 页出现时仍按着,
* PIN 页会被同一次按住立刻熄灭(用户症状「手动锁屏不熄屏而是进 PIN,此时点电源才熄屏」)。
* 300ms 也避开 launcher 进入锁屏页时的按键基线窗口。 */
#define OVERLAY_LOCK_POWER_GRACE_MS 300
static inline int overlay_lock_power_off_now(int pwr_down_edge, unsigned long long since_entry_ms)
{
return (pwr_down_edge && since_entry_ms >= OVERLAY_LOCK_POWER_GRACE_MS) ? 1 : 0;
}
/* M40 F3/P3:叠加层/电源菜单返回后的排空窗内,`/tmp/meow_app_activity` 是 App 被 SIGCONT
* 后重放的冻结期输入(不是用户新触摸),必须消费掉但不得触发「亮屏+锁屏」。
* 返回 1=仍在排空窗(忽略 activity 标记)。 */
static inline int overlay_activity_drain_active(int have_return_mark,
unsigned long long since_return_ms,
unsigned long long now_ms)
{
if (!have_return_mark) return 0;
return (now_ms - since_return_ms < OVERLAY_DRAIN_WINDOW_MS) ? 1 : 0;
}
#endif /* MEOW_OVERLAY_GATE_H */ #endif /* MEOW_OVERLAY_GATE_H */
+329
View File
@@ -0,0 +1,329 @@
/* pkg_install.c — M42: launcherd 侧包安装入口 = fork+exec `nyai install <包路径>`。
*
* 背景:M35 把 nyai 改成「包身份以已签名 app.json 清单为准 + SD 暂存 + 必清理」后,
* launcherd 的 meow_pkg_install() 仍是旧内联实现(文件名推包名、硬要 .mpk 后缀、
* 解压前 rm -rf 目标目录、失败不回滚备份、不拒 . / .. 、系统包大小写敏感比较),
* 会在 launcherd 入口 100% 复现原事故。本模块删掉那套内联解包,改委托 nyai:
* 解包→读清单→footer 一致性→验签(唯一权威 meow_verify)→防降级→rename 部署→清理
* 全部只有 nyai 一份实现,launcherd 只负责:
* ① 入口路径预检(纯逻辑,宿主可测);
* ② 启动子进程并把 stdout/stderr 分别收进有界缓冲(失败给 UI 可读文案);
* ③ 两段式 start/poll,安装期间调用方(UI 主循环)可按帧驱动、不假死。
*
* stdout/stderr 必须分开收:nyai 的进度走 stdout(管道 → 全缓冲,进程退出时才刷),
* 错误走 stderr(无缓冲,立即出)。合成一路时「最后一行」会被刷出的旧进度覆盖,
* 真机首测即踩到(降到 code 8 的包报错被「签名校验通过」顶掉)。错误文案只看 stderr。
*
* nyai 二进制:env MEOW_NYAI_BIN 覆写(宿主测试/非常规部署),缺省 /usr/bin/nyai
* (固件 fw_content.conf 安装点,与 MEOW_VERIFY_BIN 同口径,不依赖 PATH)。
*/
#include "meow_launcher_internal.h"
#define NYAI_BIN_DEFAULT "/usr/bin/nyai"
#define OUT_KEEP 1024 /* 每条流保留尾部字节数(够放 nyai 的最后几条信息) */
static pid_t g_pid = -1; /* >0 = 安装子进程在跑 */
static int g_fd_out = -1; /* stdout 读端 */
static int g_fd_err = -1; /* stderr 读端 */
static char g_buf_out[OUT_KEEP + 1];
static int g_len_out;
static char g_buf_err[OUT_KEEP + 1];
static int g_len_err;
static char g_err[256]; /* 最近一次失败的可读文案 */
/* ---------------- 纯逻辑(宿主单测:test_pkg_install.c) ---------------- */
/* 取字符串里最后一条非空行(去尾部空白;超长取尾部)。0=有内容 -1=空。 */
static int last_line_of(const char *s, char *out, int n)
{
if (!s || !*s || !out || n <= 0) return -1;
size_t L = strlen(s);
while (L > 0 && (s[L-1] == '\n' || s[L-1] == '\r' || s[L-1] == ' ' || s[L-1] == '\t')) L--;
if (L == 0) { out[0] = 0; return -1; }
size_t start = L;
while (start > 0 && s[start-1] != '\n') start--;
size_t len = L - start;
if (len >= (size_t)n) { start += len - (size_t)(n - 1); len = (size_t)(n - 1); }
memcpy(out, s + start, len);
out[len] = 0;
return 0;
}
/* 入口路径预检:0=可以交给 nyai;<0=拒装(err 填可读文案)。纯逻辑,宿主单测。
* 只判「有没有这个文件、能不能读、有没有控制字符」——包身份一律由 nyai 从包内
* app.json 取,**不要求 .mpk 后缀、不看文件名**(M35 定案)。 */
int meow_pkg_install_check_path(const char *path, char *err, int n)
{
char dummy[8];
if (!err || n <= 0) { err = dummy; n = (int)sizeof dummy; }
err[0] = 0;
if (!path || !*path) { snprintf(err, n, "包路径为空"); return -1; }
if (strlen(path) >= 480) { snprintf(err, n, "包路径过长"); return -1; }
for (const char *p = path; *p; p++) {
unsigned char c = (unsigned char)*p;
if (c < 0x20 || c == 0x7f) { snprintf(err, n, "包路径含控制字符"); return -1; }
}
struct stat st;
if (stat(path, &st) != 0) { snprintf(err, n, "找不到包文件 %s", path); return -1; }
if (!S_ISREG(st.st_mode)) { snprintf(err, n, "不是普通文件 %s", path); return -1; }
if (access(path, R_OK) != 0) { snprintf(err, n, "包文件不可读 %s", path); return -1; }
return 0;
}
/* 退出码 + 输出尾行 → 一条可读文案。rc 可为 nyai 原始退出码(>0 / >=128)或本模块
* 的 MEOW_PKG_E_* 负码。纯逻辑,宿主单测(文案不许为空、不许越界)。 */
void meow_pkg_install_error_text(int rc, const char *detail, char *out, int n)
{
if (!out || n <= 0) return;
out[0] = 0;
if (rc == 0) return;
char line[192];
if (last_line_of(detail, line, sizeof line) != 0) line[0] = 0;
if (rc == MEOW_PKG_E_BUSY) {
snprintf(out, n, "已有安装在运行,请稍后再试");
} else if (rc == MEOW_PKG_E_PATH) {
snprintf(out, n, "%s", line[0] ? line : "安装参数无效");
} else if (rc == MEOW_PKG_E_NYAI) {
snprintf(out, n, "包管理器 nyai 不可用%s%s", line[0] ? ": " : "", line);
} else if (rc == MEOW_PKG_E_TIMEOUT) {
snprintf(out, n, "安装超时(nyai 未在限时内结束)%s%s", line[0] ? ": " : "", line);
} else if (rc >= 128) {
snprintf(out, n, "安装失败(nyai 被信号 %d 终止)", rc - 128);
} else if (line[0]) {
snprintf(out, n, "%s", line);
} else if (rc > 0) {
snprintf(out, n, "安装失败(nyai 退出码 %d)", rc);
} else {
snprintf(out, n, "安装失败(错误码 %d)", rc);
}
}
/* ---------------- 输出缓冲 ---------------- */
static void buf_put(char *buf, int *len, const char *s, int n)
{
if (n <= 0) return;
if (n >= OUT_KEEP) {
memcpy(buf, s + n - OUT_KEEP, OUT_KEEP - 1);
buf[OUT_KEEP - 1] = 0;
*len = OUT_KEEP - 1;
return;
}
if (*len + n > OUT_KEEP - 1) {
int drop = *len + n - (OUT_KEEP - 1);
memmove(buf, buf + drop, (size_t)(*len - drop));
*len -= drop;
}
memcpy(buf + *len, s, (size_t)n);
*len += n;
buf[*len] = 0;
}
/* 读干一条流当前可用字节;*eof=1 表示写端已关。非阻塞读,调用方按帧驱动。 */
static void drain_fd(int *fd, char *buf, int *len, int *eof)
{
if (*fd < 0) { if (eof) *eof = 1; return; }
for (;;) {
char b[256];
ssize_t r = read(*fd, b, sizeof b);
if (r > 0) { buf_put(buf, len, b, (int)r); continue; }
if (r == 0) { if (eof) *eof = 1; break; }
if (errno == EINTR) continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (eof) *eof = 1;
break;
}
}
/* 子进程退出后把残留数据读干(此时无写端,读空即 EOF),然后关 fd。 */
static void drain_finish(int *fd, char *buf, int *len)
{
if (*fd < 0) return;
for (;;) {
char b[256];
ssize_t r = read(*fd, b, sizeof b);
if (r > 0) { buf_put(buf, len, b, (int)r); continue; }
if (r < 0 && errno == EINTR) continue;
break;
}
close(*fd);
*fd = -1;
}
/* ---------------- 启动 / 轮询 / 同步封装 ---------------- */
const char *meow_pkg_install_error(void)
{
return g_err;
}
int meow_pkg_install_busy(void)
{
return g_pid > 0;
}
/* 启动一次安装(不等待)。0=已启动;<0=MEOW_PKG_E_*(g_err 已填)。 */
int meow_pkg_install_start(const char *path)
{
char perr[256];
if (g_pid > 0) {
meow_pkg_install_error_text(MEOW_PKG_E_BUSY, NULL, g_err, sizeof g_err);
return MEOW_PKG_E_BUSY;
}
if (meow_pkg_install_check_path(path, perr, sizeof perr) != 0) {
snprintf(g_err, sizeof g_err, "%s", perr);
logmsg("pkg_install: rejected path: %s", perr);
return MEOW_PKG_E_PATH;
}
const char *bin = getenv("MEOW_NYAI_BIN");
if (!bin || !*bin) bin = NYAI_BIN_DEFAULT;
if (access(bin, X_OK) != 0) {
snprintf(g_err, sizeof g_err, "包管理器 nyai 不可用: 无法执行 %s", bin);
logmsg("pkg_install: nyai unavailable at %s", bin);
return MEOW_PKG_E_NYAI;
}
int po[2], pe[2];
if (pipe(po) != 0) {
snprintf(g_err, sizeof g_err, "安装无法启动: pipe 失败 (%s)", strerror(errno));
return MEOW_PKG_E_NYAI;
}
if (pipe(pe) != 0) {
close(po[0]); close(po[1]);
snprintf(g_err, sizeof g_err, "安装无法启动: pipe 失败 (%s)", strerror(errno));
return MEOW_PKG_E_NYAI;
}
/* 读端不进子进程,且轮询按帧读不阻塞主循环。 */
fcntl(po[0], F_SETFD, FD_CLOEXEC);
fcntl(po[0], F_SETFL, O_NONBLOCK);
fcntl(pe[0], F_SETFD, FD_CLOEXEC);
fcntl(pe[0], F_SETFL, O_NONBLOCK);
pid_t pid = fork();
if (pid < 0) {
close(po[0]); close(po[1]); close(pe[0]); close(pe[1]);
snprintf(g_err, sizeof g_err, "安装无法启动: fork 失败 (%s)", strerror(errno));
logmsg("pkg_install: fork failed");
return MEOW_PKG_E_NYAI;
}
if (pid == 0) {
/* 子进程:stdout/stderr 分两条管道,再 exec nyai(argv 直传,不经 shell)。 */
dup2(po[1], 1);
dup2(pe[1], 2);
if (po[1] > 2) close(po[1]);
if (pe[1] > 2) close(pe[1]);
execl(bin, "nyai", "install", path, (char *)0);
{
char m[160];
int l = snprintf(m, sizeof m, "nyai: 无法执行 %s\n", bin);
if (l > 0) { ssize_t w = write(2, m, (size_t)l); (void)w; }
}
_exit(127);
}
close(po[1]);
close(pe[1]);
g_pid = pid;
g_fd_out = po[0];
g_fd_err = pe[0];
g_buf_out[0] = g_buf_err[0] = 0;
g_len_out = g_len_err = 0;
g_err[0] = 0;
logmsg("pkg_install: exec %s install '%s' (pid %d)", bin, path, (int)pid);
return 0;
}
/* 轮询:1=运行中;0=已结束(*rc = 退出码,信号为 128+sig);-1=没有安装任务。 */
int meow_pkg_install_poll(int *rc)
{
if (g_pid <= 0) return -1;
int eo = 0, ee = 0;
drain_fd(&g_fd_out, g_buf_out, &g_len_out, &eo);
drain_fd(&g_fd_err, g_buf_err, &g_len_err, &ee);
if (eo) { close(g_fd_out); g_fd_out = -1; }
if (ee) { close(g_fd_err); g_fd_err = -1; }
int st = 0;
pid_t w = waitpid(g_pid, &st, WNOHANG);
if (w < 0 && errno == EINTR) return 1;
if (w == 0) return 1;
drain_finish(&g_fd_out, g_buf_out, &g_len_out);
drain_finish(&g_fd_err, g_buf_err, &g_len_err);
int code;
if (w < 0) {
code = -1;
} else if (WIFEXITED(st)) {
code = WEXITSTATUS(st);
} else if (WIFSIGNALED(st)) {
code = 128 + WTERMSIG(st);
} else {
code = -1;
}
g_pid = -1;
if (rc) *rc = code;
if (code != 0) {
/* 错误文案只看 stderr(stdout 是缓冲进度,退出时才刷,会把真错误顶掉);
* stderr 空才退回 stdout 尾行。 */
meow_pkg_install_error_text(code, g_buf_err[0] ? g_buf_err : g_buf_out,
g_err, sizeof g_err);
logmsg("pkg_install: failed rc=%d (%s)", code, g_err);
} else {
char line[192];
if (last_line_of(g_buf_out, line, sizeof line) == 0)
logmsg("pkg_install: ok (%s)", line);
else
logmsg("pkg_install: ok");
}
return 0;
}
/* 同步安装(CLI 入口用):start + 轮询到结束。0=成功;<0=MEOW_PKG_E_*。
* 超时默认 180s(MEOW_NYAI_TIMEOUT_MS 覆写,仅测试/调试),超时先 SIGTERM 再 SIGKILL。
* 轮询切片用固定 20ms usleep 计数,不依赖时钟 ABI(M40:mipsel time32/time64 混用教训)。 */
int meow_pkg_install(const char *path)
{
int r = meow_pkg_install_start(path);
if (r != 0) return r;
int timeout_ms = MEOW_PKG_INSTALL_TIMEOUT_MS;
const char *tv = getenv("MEOW_NYAI_TIMEOUT_MS");
if (tv && *tv) { int v = atoi(tv); if (v > 0) timeout_ms = v; }
int ticks = timeout_ms / 20;
if (ticks < 1) ticks = 1;
for (int i = 0; ; i++) {
int rc = 0;
int p = meow_pkg_install_poll(&rc);
if (p == 0) return (rc == 0) ? 0 : MEOW_PKG_E_FAIL;
if (p < 0) {
if (!g_err[0]) snprintf(g_err, sizeof g_err, "安装失败(无安装任务)");
return MEOW_PKG_E_FAIL;
}
if (i >= ticks) break;
usleep(20000);
}
/* 超时:SIGTERM 给 nyai 走自身清理(stg_cleanup 的 signal 钩子),2s 后 SIGKILL 兜底。 */
int finished = 0;
if (g_pid > 0) kill(g_pid, SIGTERM);
for (int i = 0; i < 100; i++) {
int rc = 0;
if (meow_pkg_install_poll(&rc) == 0) { finished = 1; break; }
usleep(20000);
}
if (!finished && g_pid > 0) {
kill(g_pid, SIGKILL);
for (int i = 0; i < 100 && g_pid > 0; i++) {
int rc = 0;
meow_pkg_install_poll(&rc);
if (g_pid > 0) usleep(20000);
}
}
snprintf(g_err, sizeof g_err, "安装超时(超过 %d 秒未完成,已终止)", timeout_ms / 1000);
logmsg("pkg_install: timeout after %d ms, child killed", timeout_ms);
return MEOW_PKG_E_TIMEOUT;
}
+92 -44
View File
@@ -355,22 +355,59 @@ int meow_power_action(uint32_t cmd)
} }
/* M40 F2/P2:关闭菜单时恢复进入前的背景帧并 present。
* dim_screen() 就地压暗 back_buf;若不恢复,app_launcher 关闭会话时的 pm_capture_app_frame()
* 会把「压暗 App 帧 + 菜单卡」当成 App 帧拍回并上屏 → 菜单关闭后屏幕仍是菜单画面,
* 用户看到「再次单击没反应」(P2 主因)。所有退出路径(取消/锁定/关机/强关)统一恢复。 */
static void pm_restore_bg(unsigned char *snap, size_t len)
{
if (!snap || !len || !back_buf_ok || !fb) return;
memcpy(fb, snap, len);
meow_dirty_full();
meow_ui_flush();
}
/* 电源菜单(M2.6):2×2 网格,dirty 绘制消频闪;输入修复: /* 电源菜单(M2.6):2×2 网格,dirty 绘制消频闪;输入修复:
* - 触摸按下捕获坐标、抬起命中按钮即确认(同 run_menu MT 防抖); * - 触摸按下捕获坐标、抬起命中按钮即确认(同 run_menu MT 防抖);
* - 音量上/下导航;播放=确认;电源新按(松开长按后)=确认当前钮(非取消); * - 音量上/下导航;播放=确认;电源新按(松开长按后)=确认当前钮(非取消);
* - 取消=选「取消」钮;8s 持续按住=强制关机(绕过)。 * - 取消=选「取消」钮;按住 power.forceoff_ms(缺省 8s,从按下起算)=强制关机(绕过)。
* 返回 1=采取动作,0=取消。 */ * 返回 1=采取动作,0=取消。 */
int show_power_menu(void) int show_power_menu(void)
{ {
int fbfd = open(FB_DEV, O_RDWR); int fbfd = open(FB_DEV, O_RDWR);
if (fbfd >= 0) { ioctl(fbfd, FBIOBLANK, 0); close(fbfd); } /* 亮起面板 */ if (fbfd >= 0) { ioctl(fbfd, FBIOBLANK, 0); close(fbfd); } /* 亮起面板 */
pm_capture_app_frame(); /* A-0:菜单背景 = app 当前帧(fb0 buffer0),再压暗绘制其上 */ meow_screen_on = 1; /* 菜单打开即视为亮屏 */
/* M40 F2:先开输入设备并采样第一轮基线。旧顺序把这一步放在 pm_capture/dim/建卡之后
* (真机实测菜单请求→keypoll ready 相隔 75ms),一次完整的按下+抬起若落在这段阻塞绘制里
* 就永远看不到边沿(EVIOCGKEY 是电平采样,不回溯事件队列)。重活挪到采样之后。 */
meow_touch mt;
mt_reset(&mt);
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER);
kp_open(&kp_keys, DEV_KEYS);
int prev_vold = 0, prev_volu = 0, prev_pwr = 0;
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
prev_pwr = kp_is_down(&kp_power, KEY_POWER) ? 1 : 0;
prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN) ? 1 : 0;
prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP) ? 1 : 0;
/* 背景帧:先把 app 当前帧读回 back_buf,快照后再压暗绘制 */
pm_capture_app_frame(); /* A-0:菜单背景 = app 当前帧(fb0 buffer0) */
unsigned char *pm_bg_snap = NULL;
size_t pm_bg_len = 0;
if (back_buf_ok && fb && fb_line > 0 && fb_yres > 0) {
pm_bg_len = (size_t)fb_line * (size_t)fb_yres;
pm_bg_snap = malloc(pm_bg_len);
if (pm_bg_snap) memcpy(pm_bg_snap, fb, pm_bg_len);
else { pm_bg_len = 0; logmsg("pm: bg snapshot malloc fail (len=%zu)", pm_bg_len); }
}
dim_screen(); dim_screen();
/* M2.8 Bug 3-residual:若电源菜单是在「息屏」态打开(按住 3s),此处 FBIOBLANK(0) /* M2.8 Bug 3-residual:若电源菜单是在「息屏」态打开(按住 3s),此处 FBIOBLANK(0)
* 刚上电,LCDC 扫描地址可能非 buffer0;强制 re-pan 让压暗的应用列表 + 菜单落在被显示的 * 刚上电,LCDC 扫描地址可能非 buffer0;强制 re-pan 让压暗的应用列表 + 菜单落在被显示的
* 缓冲,否则菜单画在非显示缓冲上不可见。 */ * 缓冲,否则菜单画在非显示缓冲上不可见。 */
fb_pan_buf0_force(); fb_pan_buf0_force();
meow_screen_on = 1; /* 菜单打开即视为亮屏 */
/* M2.11 B:默认光标 = 「取消」(安全项,index 4)。用户打开菜单后未导航就按电源/播放, /* M2.11 B:默认光标 = 「取消」(安全项,index 4)。用户打开菜单后未导航就按电源/播放,
* 只会取消关菜单,绝不因「按电源键」本身关机/重启。要真关机必须先导航到「关机」再确认。 */ * 只会取消关菜单,绝不因「按电源键」本身关机/重启。要真关机必须先导航到「关机」再确认。 */
@@ -383,29 +420,25 @@ int show_power_menu(void)
pm_draw_card(); /* 卡片 + 标题 POWER MENU + 底部提示(SDK 主题色,i18n 文案) */ pm_draw_card(); /* 卡片 + 标题 POWER MENU + 底部提示(SDK 主题色,i18n 文案) */
meow_dirty_full(); /* M2.16:把压暗列表+卡片+标题+(下方圆钮)作为一整帧呈现 */ meow_dirty_full(); /* M2.16:把压暗列表+卡片+标题+(下方圆钮)作为一整帧呈现 */
/* 触摸状态(按下→抬起):统一 meow_touch 接口(goodix 时序免疫,原点由首个有效 ABS 锁) */
meow_touch mt;
mt_reset(&mt);
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER);
kp_open(&kp_keys, DEV_KEYS);
int prev_vold = 0, prev_volu = 0, prev_pwr = 0;
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
prev_pwr = kp_is_down(&kp_power, KEY_POWER) ? 1 : 0;
prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN) ? 1 : 0;
prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP) ? 1 : 0;
int act = -1; int act = -1;
int pm_rc = 0;
unsigned long long pm_t0 = now_ms(); unsigned long long pm_t0 = now_ms();
/* 强关阈值:power.forceoff_ms 是**从按下起算**的总时长;菜单在 longpress 阈值处打开,
* 因此菜单内剩余等待 = forceoff - longpress(缺省 8000-3000=5000ms,与原硬编码值一致,
* 但不再忽略 settings)。配置异常(剩余<1s)时退回完整 forceoff。 */
unsigned long long pm_forceoff_after_ms = 5000;
if (power_forceoff_ms > power_longpress_menu_ms &&
power_forceoff_ms - power_longpress_menu_ms >= 1000)
pm_forceoff_after_ms = (unsigned long long)(power_forceoff_ms - power_longpress_menu_ms);
else if (power_forceoff_ms > 0)
pm_forceoff_after_ms = (unsigned long long)power_forceoff_ms;
unsigned long long pm_last_interact = pm_t0; /* M2.33:菜单空闲自动关闭基准(任何互动刷新) */ unsigned long long pm_last_interact = pm_t0; /* M2.33:菜单空闲自动关闭基准(任何互动刷新) */
int pm_init = 0, last_sel = -1; int pm_init = 0, last_sel = -1;
/* M2.10 Bug:电源菜单 8s 强关(按住电源≥8s=绕过强关)会覆盖用户的菜单操作 —— 用户按住 /* M2.10 Bug:电源菜单强关(按住电源到 forceoff)会覆盖用户的菜单操作 —— 用户按住
* 电源打开菜单后(尤其仍按着电源)点选「回到HibyOS/重启」等按钮,8s 一到先触发强关 → 关机 * 电源打开菜单后(尤其仍按着电源)点选「回到HibyOS/重启」等按钮,强关一到先触发 → 关机
* 而非执行所选动作。修法:一旦用户与菜单互动(音量导航/触摸/播放/电源确认),置 pm_interacted=1 * 而非执行所选动作。修法:一旦用户与菜单互动(音量导航/触摸/播放/电源确认),置 pm_interacted=1
* 取消 8s 强关,让所选动作(如「回到HibyOS」→ reboot RESTART)生效。仅当全程未互动才保留「按住 * 取消强关,让所选动作(如「回到HibyOS」→ reboot RESTART)生效。仅当全程未互动才保留「按住
* 8s=强关」。*/ * 到 forceoff=强关」。*/
int pm_interacted = 0; int pm_interacted = 0;
int pm_press_idx = -1; /* M7.0b:当前按下钮(按压态走 SDK 状态机位) */ int pm_press_idx = -1; /* M7.0b:当前按下钮(按压态走 SDK 状态机位) */
for (;;) { for (;;) {
@@ -488,17 +521,25 @@ int show_power_menu(void)
} }
} }
/* 8s 强关(绕过):仅当电源持续按住、且用户全程未与菜单互动才触发 —— 放在触摸/按键处理 /* 强关(绕过):仅当电源持续按住、且用户全程未与菜单互动才触发 —— 放在触摸/按键处理
* 之后,避免「用户已点选按钮」的同一轮被强关抢先(M2.10 Bug)。 */ * 之后,避免「用户已点选按钮」的同一轮被强关抢先(M2.10 Bug)。
if (pwr && !pm_interacted && now_ms() - pm_t0 >= 5000) { * M40:阈值接 power.forceoff_ms(从按下起算的剩余时长),不再硬编码 5000/日志谎称 8s。 */
if (sim_no_exec) { logmsg("pm: [sim-no-exec] POWER HOLD 8s -> would force poweroff"); meow_ui_activity_pop(); return 1; } if (pwr && !pm_interacted && now_ms() - pm_t0 >= pm_forceoff_after_ms) {
logmsg("pm: POWER HOLD 8s -> force poweroff"); if (sim_no_exec) {
logmsg("pm: [sim-no-exec] POWER HOLD %dms (menu-open + %llums) -> would force poweroff",
power_forceoff_ms, pm_forceoff_after_ms);
pm_rc = 1;
goto out;
}
logmsg("pm: POWER HOLD %dms (menu-open + %llums) -> force poweroff",
power_forceoff_ms, pm_forceoff_after_ms);
if (meow_power_action(POWER_SHUTDOWN) != MEOW_OK) { if (meow_power_action(POWER_SHUTDOWN) != MEOW_OK) {
logmsg("pm: power service unavailable, abort force-off (menu closed, no action)"); logmsg("pm: power service unavailable, abort force-off (menu closed, no action)");
meow_ui_activity_pop(); return 0; pm_rc = 0;
goto out;
} }
meow_ui_activity_pop(); pm_rc = 1;
return 1; goto out;
} }
/* M2.33 菜单空闲超时自动关闭:菜单受 MeowOS screen_off_timeout 管理(系统界面不豁免)。 /* M2.33 菜单空闲超时自动关闭:菜单受 MeowOS screen_off_timeout 管理(系统界面不豁免)。
@@ -516,57 +557,64 @@ int show_power_menu(void)
if (act >= 0) break; if (act >= 0) break;
usleep(POLL_MS * 1000); usleep(POLL_MS * 1000);
} }
if (tfd >= 0) close(tfd);
meow_ui_activity_pop(); /* M7.0b:退出电源菜单,回主 activity(控件 owner 仍钉哨兵,复用) */
/* M2.11 B:电源键在菜单 = 按当前项确认。act 与电源动作逐项核对: /* M2.11 B:电源键在菜单 = 按当前项确认。act 与电源动作逐项核对:
* act==PM_NUM-1(取消) -> 只关菜单,绝不关机(return 0,永不到电源动作); * act==PM_NUM-1(取消) -> 只关菜单,绝不关机(rc=0,永不到电源动作);
* act==4(锁定) -> 手动锁(meow_lock_screen_engage,未配 PIN 则 no-op),关菜单; * act==4(锁定) -> 手动锁(meow_lock_screen_engage,未配 PIN 则 no-op),关菜单;
* act==0(关机)/act==1(重启)/act==2(回HibyOS)/act==3(升级模式) -> 才经服务 IPC 执行电源动作。 * act==0(关机)/act==1(重启)/act==2(回HibyOS)/act==3(升级模式) -> 才经服务 IPC 执行电源动作。
* 只有 act 为 0/1/2/3 才可能发电源动作;power 键本身绝不触发关机。 */ * 只有 act 为 0/1/2/3 才可能发电源动作;power 键本身绝不触发关机。 */
logmsg("pm: act=%d -> %s", act, logmsg("pm: act=%d -> %s", act,
act == 0 ? "关机" : act == 1 ? "重启" : act == 2 ? "回HibyOS" : act == 3 ? "升级模式" act == 0 ? "关机" : act == 1 ? "重启" : act == 2 ? "回HibyOS" : act == 3 ? "升级模式"
: act == 4 ? "锁定" : "取消"); : act == 4 ? "锁定" : "取消");
if (act == PM_NUM - 1) { logmsg("pm: cancel (close menu only)"); return 0; } if (act == PM_NUM - 1) { logmsg("pm: cancel (close menu only)"); pm_rc = 0; goto out; }
if (act == 4) { if (act == 4) {
logmsg("pm: LOCK SCREEN"); logmsg("pm: LOCK SCREEN");
meow_lock_screen_engage(); meow_lock_screen_engage();
return 0; /* 关菜单;run_menu 继续(锁屏页已阻塞到解锁或电源息屏) */ pm_rc = 0; /* 关菜单;run_menu 继续(锁屏页已阻塞到解锁或电源息屏) */
goto out;
} }
/* 动作统一经电源管理服务 IPC(meow_svc_power);launcher 不再直连 poweroff/reboot(2)/写 mtd5/ /* 动作统一经电源管理服务 IPC(meow_svc_power);launcher 不再直连 poweroff/reboot(2)/写 mtd5/
* 写 boot 默认——关机/重启/升级/回HiBy 的动作原语都在服务内实现(§3.2 单一动作链路)。 */ * 写 boot 默认——关机/重启/升级/回HiBy 的动作原语都在服务内实现(§3.2 单一动作链路)。 */
/* 公共服务:执行动作;服务不可达 → 按「取消」返回(launcher 存活,绝不误报已动作卡死)。 /* 公共服务:执行动作;服务不可达 → 按「取消」返回(launcher 存活,绝不误报已动作卡死)。
* meow_power_action 内部已先 meow_notif_before_poweroff()(转储未上卡通知)。 */ * meow_power_action 内部已先 meow_notif_before_poweroff()(转储未上卡通知)。 */
pm_rc = 1;
if (act == 0) { if (act == 0) {
logmsg("pm: POWER OFF"); logmsg("pm: POWER OFF");
if (sim_no_exec) { logmsg("pm: [sim-no-exec] would poweroff"); return 1; } if (sim_no_exec) { logmsg("pm: [sim-no-exec] would poweroff"); goto out; }
if (meow_power_action(POWER_SHUTDOWN) != MEOW_OK) { if (meow_power_action(POWER_SHUTDOWN) != MEOW_OK) {
logmsg("pm: power service unavailable, abort (menu closed, no action)"); logmsg("pm: power service unavailable, abort (menu closed, no action)");
return 0; pm_rc = 0;
} }
} else if (act == 1) { } else if (act == 1) {
logmsg("pm: REBOOT"); logmsg("pm: REBOOT");
if (sim_no_exec) { logmsg("pm: [sim-no-exec] would reboot"); return 1; } if (sim_no_exec) { logmsg("pm: [sim-no-exec] would reboot"); goto out; }
if (meow_power_action(POWER_REBOOT) != MEOW_OK) { if (meow_power_action(POWER_REBOOT) != MEOW_OK) {
logmsg("pm: power service unavailable, abort (menu closed, no action)"); logmsg("pm: power service unavailable, abort (menu closed, no action)");
return 0; pm_rc = 0;
} }
} else if (act == 2) { } else if (act == 2) {
logmsg("pm: RETURN TO HIBYOS"); logmsg("pm: RETURN TO HIBYOS");
if (sim_no_exec) { logmsg("pm: [sim-no-exec] would reboot to hiby"); return 1; } if (sim_no_exec) { logmsg("pm: [sim-no-exec] would reboot to hiby"); goto out; }
if (meow_power_action(POWER_BACK_HIBY) != MEOW_OK) { if (meow_power_action(POWER_BACK_HIBY) != MEOW_OK) {
logmsg("pm: power service unavailable, abort (menu closed, no action)"); logmsg("pm: power service unavailable, abort (menu closed, no action)");
return 0; pm_rc = 0;
} }
} else if (act == 3) { } else if (act == 3) {
logmsg("pm: ENTER UPGRADE"); logmsg("pm: ENTER UPGRADE");
if (sim_no_exec) { logmsg("pm: [sim-no-exec] would enter upgrade"); return 1; } if (sim_no_exec) { logmsg("pm: [sim-no-exec] would enter upgrade"); goto out; }
if (meow_power_action(POWER_UPGRADE) != MEOW_OK) { if (meow_power_action(POWER_UPGRADE) != MEOW_OK) {
logmsg("pm: power service unavailable, abort (menu closed, no action)"); logmsg("pm: power service unavailable, abort (menu closed, no action)");
return 0; pm_rc = 0;
} }
} }
return 1; out:
if (tfd >= 0) close(tfd);
meow_ui_activity_pop(); /* M7.0b:退出电源菜单,回主 activity(控件 owner 仍钉哨兵,复用) */
/* M40 F2:恢复进入前的背景帧(否则 app_launcher 会把菜单帧当 App 帧上屏 = P2)。
* 放在动作执行之后:锁屏动作已 meow_panel_off(),此时恢复只写 fb0(面板黑),
* 下次唤醒 / App 整帧重画时得到的是正确底层帧而不是 PIN/菜单帧。 */
pm_restore_bg(pm_bg_snap, pm_bg_len);
if (pm_bg_snap) free(pm_bg_snap);
return pm_rc;
} }
+24 -38
View File
@@ -1315,16 +1315,15 @@ void run_settings_page(void)
struct keypoll kp_power, kp_keys; struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS); kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; /* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 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); int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} } if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
unsigned long long touch_cooldown_until = now_ms() + 300; 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); logmsg("settings entry: net toggle-sync+kick %llums", now_ms() - t_sets);
if (!g_set_init) set_widgets_create(); /* M7.2:lazy 建 SDK 控件集(一次,跨次进入复用) */ if (!g_set_init) set_widgets_create(); /* M7.2:lazy 建 SDK 控件集(一次,跨次进入复用) */
meow_ui_activity_push(SET_SENTINEL_ACT); /* 控件 owner 闸门打开(hit/按压态生效) */ meow_ui_activity_push(SET_SENTINEL_ACT); /* 控件 owner 闸门打开(hit/按压态生效) */
key_router_page_enter(SET_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
set_paint_page(sel); set_paint_page(sel);
meow_dirty_full(); meow_ui_flush(); meow_dirty_full(); meow_ui_flush();
logmsg("settings entry: page shown in %llums", now_ms() - t_sets); logmsg("settings entry: page shown in %llums", now_ms() - t_sets);
@@ -1396,13 +1396,12 @@ void run_settings_page(void)
meow_auto_slept = 0; meow_auto_slept = 0;
wake_suppress = 1; wake_suppress = 1;
last_act = now_ms(); last_act = now_ms();
/* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸 */ /* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸。
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); * 音量防抖/基线上收 key_router:唤醒时重播音量基线;播放/电源仍本页自理。 */
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play); 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); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; drag = -1; scroll_drag = 0; tlx = tly = -1; 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); 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 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); (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; play = kd_play.stable; pwr = kd_pwr.stable;
if (wake_suppress) { 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 { } else {
/* M2.39:设置页音量键 = 调系统音量(写 DAC),不做光标移动(用户约束: /* M2.39:设置页音量键 = 调系统音量(写 DAC),不做光标移动(用户约束:
* 设置页仅触摸导航;音量键在电菜单/应用列表的系统级导航保持不动)。 * 设置页仅触摸导航;音量键在电菜单/应用列表的系统级导航保持不动)。
* 按下边沿即起线性渐变(vol_ramp_begin),下方 vol_ramp_tick 逐 1% 爬 + 刷状态栏。 */ * 按下边沿即起线性渐变(vol_ramp_begin),下方 vol_ramp_tick 逐 1% 爬 + 刷状态栏。 */
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; } if (ev.vol_dn_edge) { 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_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
/* M2.x(KEY_CONTROL_DESIGN §2/§3)播放键在设置页同样被系统「抓住」: /* M2.x(KEY_CONTROL_DESIGN §2/§3)播放键在设置页同样被系统「抓住」:
* 捕获+日志+消费,不再映射为「返回/确认」(原 play 边沿=break 已删,播放键不再退出设置页)。 * 捕获+日志+消费,不再映射为「返回/确认」(原 play 边沿=break 已删,播放键不再退出设置页)。
* 仍视为输入(重置自动息屏计时,与主界面 play 的媒体预留/any_input 一致)。 */ * 仍视为输入(重置自动息屏计时,与主界面 play 的媒体预留/any_input 一致)。 */
@@ -1480,14 +1476,11 @@ void run_settings_page(void)
pwr_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击息屏 */ pwr_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击息屏 */
/* M2.25 同款:阻塞式 show_power_menu() 期间 kd_* 冻结,m_prev_* 与稳定值可能错配, /* M2.25 同款:阻塞式 show_power_menu() 期间 kd_* 冻结,m_prev_* 与稳定值可能错配,
* 重播 kd_* 并以当前 raw 对齐 m_prev_*,杜绝「菜单内触摸点取消->返回后被误判单击息屏」。 */ * 重播 kd_* 并以当前 raw 对齐 m_prev_*,杜绝「菜单内触摸点取消->返回后被误判单击息屏」。 */
memset(&kd_pwr, 0, sizeof kd_pwr); /* 重播音量基线(key_router)+ 本页播放/电源基线 */
memset(&kd_vold, 0, sizeof kd_vold); key_router_rebase(); /* 音量:kd_vold/volu 清零 + m_prev_vold/volu=当前 raw */
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; /* 全屏 dim 覆盖过状态栏,需整帧重绘还原 */ after_modal = 1; /* 全屏 dim 覆盖过状态栏,需整帧重绘还原 */
need_paint = 1; need_paint = 1;
@@ -1514,14 +1507,14 @@ void run_settings_page(void)
} }
/* M2.39:驱动音量线性渐变(长按续爬/松开即止)+ 刷新状态栏 VOL;「音量」行随 need_paint 更新 */ /* M2.39:驱动音量线性渐变(长按续爬/松开即止)+ 刷新状态栏 VOL;「音量」行随 need_paint 更新 */
if (vol_ramp_dir != 0) { 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); int going = vol_ramp_tick(held);
g_sys.vol = settings_volume_get(); g_sys.vol = settings_volume_get();
paint_sb_right(); paint_sb_right();
need_paint = 1; need_paint = 1;
if (!going) logmsg("settings vol ramp end -> %d", g_sys.vol); 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: settings_keys_seeded:
; ;
@@ -1658,14 +1651,10 @@ void run_settings_page(void)
scroll_y0 = 0; scroll_y0 = 0;
tdx = tdy = tlx = tly = -1; tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
memset(&kd_pwr, 0, sizeof kd_pwr); key_router_rebase(); /* 音量基线重播 */
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
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); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
need_paint = 1; need_paint = 1;
} else if (r->ctrl == SC_ENUM || r->ctrl == SC_PRESET) { } else if (r->ctrl == SC_ENUM || r->ctrl == SC_PRESET) {
@@ -1703,14 +1692,10 @@ void run_settings_page(void)
tdx = tdy = tlx = tly = -1; tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
/* 重置设置页按键防抖基线,防残边 */ /* 重置设置页按键防抖基线,防残边 */
memset(&kd_pwr, 0, sizeof kd_pwr); key_router_rebase(); /* 音量基线重播 */
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
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); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
need_paint = 1; need_paint = 1;
} else if (r->ctrl == SC_SLIDER && tlx >= 0) { } else if (r->ctrl == SC_SLIDER && tlx >= 0) {
@@ -1768,6 +1753,7 @@ void run_settings_page(void)
done: done:
meow_input_touch_set(0, 0, 0, 0); /* M7.2 防残留按压态 */ meow_input_touch_set(0, 0, 0, 0); /* M7.2 防残留按压态 */
meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */ meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */
key_router_page_exit(SET_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
if (tfd >= 0) close(tfd); if (tfd >= 0) close(tfd);
kp_close(&kp_power); kp_close(&kp_keys); kp_close(&kp_power); kp_close(&kp_keys);
logmsg("settings page closed"); logmsg("settings page closed");
+118 -52
View File
@@ -247,12 +247,11 @@ int meow_subpage_run(sub_page_t *p)
meow_auto_slept = 0; meow_auto_slept = 0;
wake_suppress = 1; wake_suppress = 1;
last_act = now; last_act = now;
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); /* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
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); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; scroll_drag = 0; tlx = tly = -1; 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(); last_act = now_ms();
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0;
pwr_suppress_click = 1; pwr_suppress_click = 1;
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; after_modal = 1;
need_paint = 1; need_paint = 1;
@@ -456,14 +453,12 @@ int meow_subpage_run(sub_page_t *p)
scroll_base = 0; scroll_y0 = 0; scroll_base = 0; scroll_y0 = 0;
tdx = tdy = tlx = tly = -1; tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
/* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; after_modal = 1;
need_paint = 1; need_paint = 1;
@@ -546,7 +541,8 @@ static sub_page_t *g_sp; /* 当前运行子页(rows/act/detai
static sub_row_t g_rows[SUB_ROWS_MAX]; /* 行缓冲:rows() 一次性填满,listview bind 只读 */ static sub_row_t g_rows[SUB_ROWS_MAX]; /* 行缓冲:rows() 一次性填满,listview bind 只读 */
static int g_nrows = 0; /* 当前行数(listview vsource count 实时读) */ static int g_nrows = 0; /* 当前行数(listview vsource count 实时读) */
static int g_top_sw_en = 0; /* 本页是否显示顶开关 */ static int g_top_sw_en = 0; /* 本页是否显示顶开关 */
static int g_top_on = 0; /* 顶开关当前值 */ static int g_top_on = 0; /* 顶开关当前值(0/1;未知时停 0 并置 g_top_sw_unknown) */
static int g_top_sw_unknown = 0; /* M47 第三态:真实态未知(页回调 top_sw 回 -1) */
static meow_widget *g_tb = NULL; /* topappbar */ static meow_widget *g_tb = NULL; /* topappbar */
static meow_widget *g_sw = NULL; /* 顶开关(仅在有顶开关的页创建/使用) */ static meow_widget *g_sw = NULL; /* 顶开关(仅在有顶开关的页创建/使用) */
@@ -601,7 +597,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)---- */ /* ---- 顶栏标题栏右侧动作按钮矩形(批③:sub_page_t.top_action)---- */
static void sp_top_action_rect(int *x, int *y, int *w, int *h) 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; if (!g_sp || !g_sp->top_action) return;
int tw = ui_width(g_sp->top_action, 20); int tw = ui_width(g_sp->top_action, 20);
if (tw <= 0) return; if (tw <= 0) return;
@@ -670,22 +669,29 @@ static void sp_widgets_create(void)
g_sp_init = 1; g_sp_init = 1;
} }
/* 顶开关控件置态(M47:未知 -1 时 SDK 开关只有两态,停在「关」+ 灰色「?」角标区分)。 */
static void sp_top_sw_apply(void)
{
if (g_sw) meow_switch_set(g_sw, g_top_on == 1 ? 1 : 0);
}
/* 逐页配置:topappbar 标题 + 顶开关(按需创建/置态)+ listview 行数。 */ /* 逐页配置:topappbar 标题 + 顶开关(按需创建/置态)+ listview 行数。 */
static void sp_configure_page(const sub_page_t *p, int top_sw_en, int top_on) static void sp_configure_page(const sub_page_t *p, int top_sw_en, int top_on)
{ {
if (p->title && g_tb) meow_topappbar_set_title(g_tb, p->title); if (p->title && g_tb) meow_topappbar_set_title(g_tb, p->title);
g_top_sw_en = top_sw_en; g_top_sw_en = top_sw_en;
g_top_on = top_on; /* M47:top_sw 回调的 *on 是三态(-1 未知 / 0 / 1),见 bt_page/wifi_page。 */
g_top_sw_unknown = (top_on < 0) ? 1 : 0;
g_top_on = (top_on == 1) ? 1 : 0;
if (top_sw_en) { if (top_sw_en) {
if (!g_sw) { if (!g_sw) {
meow_ui_activity_push(SUB_SENTINEL_ACT); /* owner 钉哨兵(命中闸门) */ meow_ui_activity_push(SUB_SENTINEL_ACT); /* owner 钉哨兵(命中闸门) */
int swx = fb_xres - SUB_PAD_X - 56; /* meow_switch 固定 56×32 */ int swx = fb_xres - SUB_PAD_X - 56; /* meow_switch 固定 56×32 */
int swy = SUB_TB_Y + (SUB_TB_H - SUB_SW_H) / 2; int swy = SUB_TB_Y + (SUB_TB_H - SUB_SW_H) / 2;
g_sw = meow_switch_create(swx, swy, top_on); g_sw = meow_switch_create(swx, swy, g_top_on);
meow_ui_activity_pop(); meow_ui_activity_pop();
} else if (g_sw) {
meow_switch_set(g_sw, top_on);
} }
sp_top_sw_apply();
} }
/* 批③:行完全自绘 on/off —— 页提供 row_draw 用自绘;否则清 NULL 回默认 renderer。 */ /* 批③:行完全自绘 on/off —— 页提供 row_draw 用自绘;否则清 NULL 回默认 renderer。 */
if (g_lv) meow_listview_set_item_draw(g_lv, p->row_draw ? sp_lv_draw : NULL, NULL); if (g_lv) meow_listview_set_item_draw(g_lv, p->row_draw ? sp_lv_draw : NULL, NULL);
@@ -699,7 +705,17 @@ static void sp_paint_page(void)
meow_ui_fill_rect(0, 0, W, H, mt->surface); /* 全屏底 surface */ meow_ui_fill_rect(0, 0, W, H, mt->surface); /* 全屏底 surface */
draw_statusbar(); draw_statusbar();
if (g_tb) meow_widget_draw(g_tb); /* topappbar(含底部分隔线) */ if (g_tb) meow_widget_draw(g_tb); /* topappbar(含底部分隔线) */
if (g_sw && g_top_sw_en) meow_widget_draw(g_sw); /* 顶开关(叠顶栏右缘) */ if (g_sw && g_top_sw_en) {
meow_widget_draw(g_sw); /* 顶开关(叠顶栏右缘) */
if (g_top_sw_unknown) {
/* M47 第三态:真实态未知。SDK 开关只有开/关两态,故开关停「关」并在左侧画灰色
* 「?」——明确表达「不是关着,是不知道」,而不是拿「关」冒充。 */
int qh = meow_ui_text_height(20);
int qw = ui_width("?", 20);
ui_draw(fb_xres - SUB_PAD_X - 56 - qw - 6,
SUB_TB_Y + (SUB_TB_H - qh) / 2, "?", mt->outline, 20);
}
}
sp_paint_top_action(); /* 批③:顶栏右侧动作按钮 */ sp_paint_top_action(); /* 批③:顶栏右侧动作按钮 */
int lvx, lvy, lvw, lvh; int lvx, lvy, lvw, lvh;
sp_lv_rect(&lvx, &lvy, &lvw, &lvh); sp_lv_rect(&lvx, &lvy, &lvw, &lvh);
@@ -712,13 +728,40 @@ static void sp_paint_page(void)
s_ui_theme()->outline, 18); s_ui_theme()->outline, 18);
} }
/* 顶部开关切换:写设置 + 生效 + 触发重扫 + 重填行。 */ /* 顶开关回读真实态(M47:UI 只认权威回调,不做「点了就翻面」的乐观更新)。 */
static void sp_top_sw_resync(void)
{
if (!g_sp || !g_sp->top_sw) return;
int on = 0;
if (!g_sp->top_sw(g_sp->ctx, &on)) { g_top_sw_en = 0; return; }
g_top_sw_en = 1;
g_top_sw_unknown = (on < 0) ? 1 : 0;
g_top_on = (on == 1) ? 1 : 0;
}
/* 「待确认」态:开关保持上一次已确认外观,内容区顶出示同步条——与稳定态肉眼可区分。
* 有界等待期间主循环阻塞在 top_sw_set 内,所以必须先画一帧再下发。 */
static void sp_paint_switching(int want)
{
const meow_theme *mt = meow_theme_get();
char line[64];
snprintf(line, sizeof line, "正在%s…(等待设备确认)", want ? "开启" : "关闭");
int h = 32, y = SUB_CONT_TOP;
meow_ui_fill_rect(0, y, fb_xres, h, mt->surfaceContainerHigh);
meow_ui_text_center(fb_xres / 2, y + (h - meow_ui_text_height(18)) / 2, line, 18, mt->primary);
meow_dirty_full();
meow_ui_flush();
}
/* 顶部开关切换:待确认态 → 页内下发+有界等待+失败弹窗 → 回读真实态 → 重填行。 */
static void sp_top_sw_toggle(void) static void sp_top_sw_toggle(void)
{ {
if (!g_sp || !g_sp->top_sw_set) return; if (!g_sp || !g_sp->top_sw_set) return;
g_sp->top_sw_set(g_sp->ctx, !g_top_on); int want = (g_top_on == 1) ? 0 : 1; /* 未知(-1) 视同「关」:点一下表示「请打开」 */
g_top_on = !g_top_on; sp_paint_switching(want);
if (g_sw) meow_switch_set(g_sw, g_top_on); g_sp->top_sw_set(g_sp->ctx, want); /* 页内:下发 + 有界轮询真实态 + 失败回滚/弹窗 */
sp_top_sw_resync(); /* 定稿只认真实态 */
sp_top_sw_apply();
if (g_sp->rows) { if (g_sp->rows) {
g_nrows = g_sp->rows(g_sp->ctx, g_rows, SUB_ROWS_MAX); g_nrows = g_sp->rows(g_sp->ctx, g_rows, SUB_ROWS_MAX);
if (g_nrows > SUB_ROWS_MAX) g_nrows = SUB_ROWS_MAX; if (g_nrows > SUB_ROWS_MAX) g_nrows = SUB_ROWS_MAX;
@@ -743,16 +786,15 @@ int meow_subpage_run(sub_page_t *p)
{ {
struct keypoll kp_power, kp_keys; struct keypoll kp_power, kp_keys;
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS); kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr; /* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu); struct kd_state kd_play, kd_pwr;
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 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); int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} } if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
unsigned long long touch_cooldown_until = now_ms() + 300; unsigned long long touch_cooldown_until = now_ms() + 300;
@@ -780,6 +822,7 @@ int meow_subpage_run(sub_page_t *p)
sp_configure_page(p, top_sw_en, top_on); sp_configure_page(p, top_sw_en, top_on);
meow_ui_activity_push(SUB_SENTINEL_ACT); /* 控件 owner 闸门打开(hit 生效) */ meow_ui_activity_push(SUB_SENTINEL_ACT); /* 控件 owner 闸门打开(hit 生效) */
key_router_page_enter(SUB_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
sp_paint_page(); sp_paint_page();
meow_dirty_full(); meow_ui_flush(); meow_dirty_full(); meow_ui_flush();
@@ -814,12 +857,11 @@ int meow_subpage_run(sub_page_t *p)
meow_auto_slept = 0; meow_auto_slept = 0;
wake_suppress = 1; wake_suppress = 1;
last_act = now; last_act = now;
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold); /* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
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); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
td = 0; td_ok = 0; scroll_drag = 0; drag_last_y = 0; tlx = tly = -1; td = 0; td_ok = 0; scroll_drag = 0; drag_last_y = 0; tlx = tly = -1;
@@ -832,23 +874,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); 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++) { 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_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 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); (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; play = kd_play.stable; pwr = kd_pwr.stable;
if (wake_suppress) { 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 { } else {
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; } if (ev.vol_dn_edge) { 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_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 (play && !m_prev_play) { logmsg("subpage: play key caught (media-reserved)"); last_act = now; }
if (pwr && !m_prev_pwr) { if (pwr && !m_prev_pwr) {
pwr_hold_at = now; pwr_menu_state = 0; pwr_hold_at = now; pwr_menu_state = 0;
@@ -867,14 +906,12 @@ int meow_subpage_run(sub_page_t *p)
last_act = now_ms(); last_act = now_ms();
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0;
pwr_suppress_click = 1; pwr_suppress_click = 1;
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; after_modal = 1;
need_paint = 1; need_paint = 1;
@@ -899,13 +936,13 @@ int meow_subpage_run(sub_page_t *p)
} }
} }
if (vol_ramp_dir != 0) { 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); (void)vol_ramp_tick(held);
g_sys.vol = settings_volume_get(); g_sys.vol = settings_volume_get();
paint_sb_right(); paint_sb_right();
need_paint = 1; 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: sub_keys_seeded:
; ;
@@ -985,9 +1022,31 @@ int meow_subpage_run(sub_page_t *p)
} }
if (g_top_sw_en && g_sw && meow_widget_hit(g_sw, tx, ty)) { if (g_top_sw_en && g_sw && meow_widget_hit(g_sw, tx, ty)) {
sp_top_sw_toggle(); sp_top_sw_toggle();
last_act = now; last_act = now_ms(); /* 切换内可能阻塞数秒:取新鲜时刻,
* 否则空闲息屏会按陈旧时刻提前触发 */
/* 评审 r1 P2-4:M47 起顶开关路径也会开 s_dd_modal(切换失败/
* 无法确认)。s_dd_modal 是全屏压暗层,关闭后必须像行命中分支
* 一样排空触摸、复位触摸/按键基线并置 after_modal —— 否则
* 返回脏区不含状态栏(0..27),会残留压暗到下一次时钟重绘,
* 电源/播放键也可能带进弹窗里的陈旧边沿。 */
if (tfd >= 0) {
unsigned char e[16];
while (read(tfd, e, sizeof e) == 16) {}
usleep(40000);
while (read(tfd, e, sizeof e) == 16) {}
}
td = 0; td_ok = 0; scroll_drag = 0;
drag_last_y = 0;
tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300;
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_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1;
need_paint = 1; need_paint = 1;
if (tfd >= 0) { unsigned char e[16]; while (read(tfd, e, sizeof e) == 16) {} }
continue; continue;
} }
/* 列表行命中(SDK 命中:含滚动换算 + 越界/禁用行不响应) */ /* 列表行命中(SDK 命中:含滚动换算 + 越界/禁用行不响应) */
@@ -1021,14 +1080,12 @@ int meow_subpage_run(sub_page_t *p)
drag_last_y = 0; drag_last_y = 0;
tdx = tdy = tlx = tly = -1; tdx = tdy = tlx = tly = -1;
touch_cooldown_until = now_ms() + 300; touch_cooldown_until = now_ms() + 300;
/* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */
key_router_rebase();
memset(&kd_pwr, 0, sizeof kd_pwr); 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); memset(&kd_play, 0, sizeof kd_play);
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL); kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER); 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); m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
after_modal = 1; after_modal = 1;
need_paint = 1; need_paint = 1;
@@ -1045,6 +1102,14 @@ int meow_subpage_run(sub_page_t *p)
/* ---- 周期性重扫(live 页:蓝牙附近扫描等驻留更新)---- */ /* ---- 周期性重扫(live 页:蓝牙附近扫描等驻留更新)---- */
if (g_sp && g_sp->live && now - last_live >= 2000) { if (g_sp && g_sp->live && now - last_live >= 2000) {
last_live = now; last_live = now;
/* M47:顶开关也按 live 周期回读真实态——外部(其它页面/守护进程)改了开关,
* 本页驻留期间也要跟着变,而不是停在进入页面时那一刻。 */
int sw_before = g_top_on, unk_before = g_top_sw_unknown;
if (g_top_sw_en) sp_top_sw_resync();
if (g_top_on != sw_before || g_top_sw_unknown != unk_before) {
sp_top_sw_apply();
need_paint = 1;
}
int new_n = g_sp->rows ? g_sp->rows(g_sp->ctx, g_rows, SUB_ROWS_MAX) : 0; int new_n = g_sp->rows ? g_sp->rows(g_sp->ctx, g_rows, SUB_ROWS_MAX) : 0;
if (new_n > SUB_ROWS_MAX) new_n = SUB_ROWS_MAX; if (new_n > SUB_ROWS_MAX) new_n = SUB_ROWS_MAX;
if (new_n != g_nrows) { if (new_n != g_nrows) {
@@ -1087,6 +1152,7 @@ int meow_subpage_run(sub_page_t *p)
subdone: subdone:
meow_input_touch_set(0, 0, 0, 0); /* 防残留按压态 */ meow_input_touch_set(0, 0, 0, 0); /* 防残留按压态 */
meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */ meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */
key_router_page_exit(SUB_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
if (tfd >= 0) close(tfd); if (tfd >= 0) close(tfd);
kp_close(&kp_power); kp_close(&kp_keys); kp_close(&kp_power); kp_close(&kp_keys);
logmsg("subpage '%s' closed", p->title ? p->title : ""); logmsg("subpage '%s' closed", p->title ? p->title : "");
+26 -13
View File
@@ -1,11 +1,12 @@
/* test_bgmgr.c — 批③ 后台管理页 逻辑层宿主单测(meow_bgmgr)。 /* 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。 * pcpu/rss_kb;行 killable 预判正确;name 回退 app_id。
* 2) bgmgr_can_kill 门控矩阵:framework=1 → 0(**框架 App 不 kill**);is_fg=1 → 0(当前前台不 kill); * 2) bgmgr_can_kill 门控矩阵:protected_app=1 → 0(**受保护 App 不 kill**);is_fg=1 → 0(当前前台不 kill);
* pid<=0 → 0(非后台/无进程);普通后台(非框架/非前台/pid>0)→ 1。 * pid<=0 → 0(非后台/无进程);普通后台(非受保护/非前台/pid>0)→ 1。
* 3) bgmgr_kill_all 一键清:框架 App 不杀、当前前台不杀、非后台不杀;其余逐一 kill;返回 kill 数。 * 3) bgmgr_kill_all 一键清:受保护 App 不杀、当前前台不杀、非后台不杀;其余逐一 kill;返回 kill 数。
* 3b) 回归:app.json framework=1(SDK 框架 App 标志,现有 App 全写 1)**不影响**可杀性。
* 4) bgmgr_status_str / bgmgr_mem_str 文案。 * 4) bgmgr_status_str / bgmgr_mem_str 文案。
* 只依赖 meow_bgmgr.c + meow_ipc.h(纯 C);宿主 gcc 直编: * 只依赖 meow_bgmgr.c + meow_ipc.h(纯 C);宿主 gcc 直编:
* gcc -Wall -Wextra -std=gnu99 test_bgmgr.c meow_bgmgr.c -o test_bgmgr * gcc -Wall -Wextra -std=gnu99 test_bgmgr.c meow_bgmgr.c -o test_bgmgr
@@ -17,7 +18,9 @@
#include "meow_bgmgr.h" #include "meow_bgmgr.h"
static int g_fails = 0; static int g_fails = 0;
static int g_checks = 0;
#define CHECK(cond, msg) do { \ #define CHECK(cond, msg) do { \
g_checks++; \
if (cond) printf("PASS: %s\n", msg); \ if (cond) printf("PASS: %s\n", msg); \
else { printf("FAIL: %s\n", msg); g_fails++; } } while (0) 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; (void)ctx;
if (max < 4) return 0; if (max < 4) return 0;
/* 4 个后台行:hello=普通、reader=常驻、sysguard=framework、fgapp=当前前台 */ /* 4 个后台行:hello=普通、reader=常驻、sysguard=受保护、fgapp=当前前台 */
memset(&rows[0], 0, sizeof rows[0]); memset(&rows[0], 0, sizeof rows[0]);
snprintf(rows[0].app_id, sizeof rows[0].app_id, "%s", g_meta_app[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; 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; 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, static int fake_app_meta(void *ctx, const char *app_id, pid_t pid,
char *name, int nl, char *icon, int il, char *name, int nl, char *icon, int il,
char *framework, int *is_fg) char *protected_app, int *is_fg)
{ {
(void)ctx; (void)ctx;
if (!strcmp(app_id, "os.meow.hello")) snprintf(name, nl, "播放"); if (!strcmp(app_id, "os.meow.hello")) snprintf(name, nl, "播放");
@@ -64,7 +67,7 @@ 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 if (!strcmp(app_id, "os.meow.fgapp")) snprintf(name, nl, "前台应用");
else return -1; /* 未注册 → name 回退 app_id */ else return -1; /* 未注册 → name 回退 app_id */
snprintf(icon, il, "/tmp/icon_%s.png", app_id); snprintf(icon, il, "/tmp/icon_%s.png", app_id);
*framework = (!strcmp(app_id, "os.meow.sysguard")) ? '1' : 0; *protected_app = (!strcmp(app_id, "os.meow.sysguard")) ? '1' : 0;
*is_fg = (pid == 1004) ? 1 : 0; /* fgapp 标前台 */ *is_fg = (pid == 1004) ? 1 : 0; /* fgapp 标前台 */
return 0; return 0;
} }
@@ -112,9 +115,9 @@ int main(void)
printf("=== 1) collect ===\n"); printf("=== 1) collect ===\n");
CHECK(n == 4, "collect returns 4 rows"); CHECK(n == 4, "collect returns 4 rows");
CHECK(!strcmp(rows[0].name, "播放") && rows[0].pid == 1001, "row0 hello name/pid"); 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(!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[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].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"); 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"); 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[3]) == 0, "fg app NOT killable (is_fg)");
CHECK(bgmgr_can_kill(&rows[0]) == 1, "normal bg app killable"); CHECK(bgmgr_can_kill(&rows[0]) == 1, "normal bg app killable");
CHECK(bgmgr_can_kill(&rows[1]) == 1, "keepalive 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.fgapp")) killed_fg = 1;
if (!strcmp(g_killed_app[i], "os.meow.hello")) killed_bg = 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_fg == 0, "kill_all did NOT kill fg app");
CHECK(killed_bg == 1, "kill_all killed a real bg 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"); 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"); printf("=== 4) status/mem str ===\n");
CHECK(!strcmp(bgmgr_status_str(&rows[1]), "常驻"), "status_str keepalive=常驻"); CHECK(!strcmp(bgmgr_status_str(&rows[1]), "常驻"), "status_str keepalive=常驻");
CHECK(!strcmp(bgmgr_status_str(&rows[0]), "后台"), "status_str normal=后台"); 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(512), "512KB"), "mem_str(<1MB)=KB");
CHECK(!strcmp(bgmgr_mem_str(2048), "2.0MB"), "mem_str(>=1MB)=MB"); 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); else printf("\n%d FAILED\n", g_fails);
return g_fails ? 1 : 0; return g_fails ? 1 : 0;
} }
+62 -21
View File
@@ -1,14 +1,16 @@
/* test_bgpage.c — 批③ 后台管理页 UI 按钮化 宿主子测(bg_page.c 按钮命中/顶栏清除命中)。 /* test_bgpage.c — 批③ 后台管理页 UI 按钮化 宿主子测(bg_page.c 按钮命中/顶栏清除命中)。
* *
* 通过 #include bg_page.c 把其 FILE-STATIC 的 bgm_rows/bgm_btn_rect/bgm_row_tap/bgm_top_action * 通过 #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 定位); * P2 bgm_btn_rect 按钮几何:行右 46×34 圆角钮(rx+rw-12-46 定位);
* P3 bgm_row_tap 行内命中:**仅按钮区 kill(bgm_terminate 一次),行体/按钮外不误杀**;框架 App * P3 bgm_row_tap 行内命中:**仅按钮区 kill(bgm_terminate 一次),行体/按钮外不误杀**;
* 按钮点击 → 不 terminate(!framework 门控);空态行不响应; * **app.json framework=1(SDK 框架 App)照样可杀**(2026-11-04 真机回归);
* P4 bgm_top_action 顶栏清除:kill_all **不杀框架 App**(sysguard 不 terminate),杀普通后台; * 受保护/前台行按钮点击 → 不 terminate(门控,留诊断日志);空态行不响应;
* P5 门控矩阵:bgmgr_can_kill(framework/fg/pid<=0/普通)。 * 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 剔除。 * 不调用行自绘 bgm_row_draw(纯逻辑),故无需桩 SDK 绘制;行自绘段经 -ffunction-sections/--gc-sections 剔除。
* *
* 编译(在 patch_v2/meow_launcherd 下): * 编译(在 patch_v2/meow_launcherd 下):
@@ -44,6 +46,16 @@ int meow_bg_terminate(const char *app_id)
} }
unsigned long long now_ms(void) { return 0; } unsigned long long now_ms(void) { return 0; }
const char *meow_str(const char *id) { (void)id; return NULL; } /* 未知 id → meow_str_zh 回退 zh */ 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 用) ---------- */ /* ---------- 测试态:launcher 应用表(app_meta 用) ---------- */
static struct app g_apps[4]; 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_fails = 0;
static int g_checks = 0;
#define CHECK(cond, msg) do { \ #define CHECK(cond, msg) do { \
g_checks++; \
if (cond) printf("PASS: %s\n", msg); \ if (cond) printf("PASS: %s\n", msg); \
else { printf("FAIL: %s\n", msg); g_fails++; } } while (0) else { printf("FAIL: %s\n", msg); g_fails++; } } while (0)
@@ -86,21 +100,31 @@ int main(void)
{ {
static bgm_ctx_t b; static bgm_ctx_t b;
memset(&b, 0, sizeof 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(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]; static sub_row_t rows[SUB_ROWS_MAX];
printf("=== P1) bgm_rows 行骨架 ===\n"); printf("=== P1) bgm_rows 行骨架 + 副文本持久化 ===\n");
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002); bg_reset(2, "os.meow.hello", 1001, "dev.player.ncm", 7520);
int rn = bgm_rows(&b, rows, SUB_ROWS_MAX); int rn = bgm_rows(&b, rows, SUB_ROWS_MAX);
CHECK(rn == 2, "2 后台 App → 2 行(无空态/无清除动作行)"); CHECK(rn == 2, "2 后台 App → 2 行(无空态/无清除动作行)");
CHECK(rows[0].kind == SUB_ROW && rows[0].act == 0, "row0 act=0"); CHECK(rows[0].kind == SUB_ROW && rows[0].act == 0, "row0 act=0");
CHECK(!strcmp(rows[0].label, "播放"), "row0 label=名字(播放)"); CHECK(!strcmp(rows[0].label, "播放"), "row0 label=名字(播放)");
CHECK(rows[1].kind == SUB_ROW && rows[1].act == 1, "row1 act=1"); CHECK(rows[1].kind == SUB_ROW && rows[1].act == 1, "row1 act=1");
CHECK(!strcmp(rows[1].label, "系统守护"), "row1 label=系统守护"); CHECK(!strcmp(rows[1].label, "流媒体客户端"), "row1 label=流媒体客户端");
CHECK(!strcmp(rows[1].sub, "") || rows[1].sub[0] != '\0', "row1 sub 非空(CPU/内存/状态)"); CHECK(rows[1].sub != NULL && !strncmp(rows[1].sub, "CPU ", 4), "row1 sub=CPU/内存/状态");
CHECK(b.n == 2, "bgm_ctx.n=2"); 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"); printf("=== P2) bgm_btn_rect 按钮几何(行右 46×34) ===\n");
{ {
@@ -111,7 +135,7 @@ int main(void)
} }
printf("=== P3) bgm_row_tap 行内杀按钮命中(仅按钮区 kill,行区不误杀) ===\n"); 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); bgm_rows(&b, rows, SUB_ROWS_MAX);
/* 按钮区命中:行(0,0,480,56),按钮 x 422-468, y 11-45 */ /* 按钮区命中:行(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(重绘)"); 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], 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], 475, 28, 0, 0, 480, 56) == 0, "按钮右侧(475)→不杀");
CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 60, 0, 0, 480, 56) == 0, "按钮下方(60)→不杀"); CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 60, 0, 0, 480, 56) == 0, "按钮下方(60)→不杀");
/* 框架 App:按钮命中但不 terminate(!framework 门控) */ /* 回归(2026-11-04 真机「杀后台按钮按下去没反应」):dev.player.ncm 的 app.json 写 framework:1
CHECK(bgm_row_tap(&b, 1, &rows[1], 445, 28, 0, 0, 480, 56) == 1, "框架App按钮→返回1(不可杀提示)"); * (= 用 SDK 框架,现有 App 全写 1),**必须照杀**——旧实现把它当受保护位,按钮置灰点不动。 */
CHECK(term_count_app("os.meow.sysguard") == 0, "框架App按钮点击→不 terminate"); 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"); printf("=== P3b) 空态行不响应 ===\n");
bg_reset(0, NULL, 0, NULL, 0); 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(不杀)"); CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 28, 0, 0, 480, 56) == 0, "空态行点击→返回0(不杀)");
g_term_calls = 0; g_term_calls = 0;
printf("=== P4) 顶栏清除(一键清除后台)不杀框架/前台 ===\n"); printf("=== P4) 顶栏清除(一键清除后台)不杀受保护/前台 ===\n");
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002); bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002);
bgm_rows(&b, rows, SUB_ROWS_MAX); bgm_rows(&b, rows, SUB_ROWS_MAX);
b.rows[1].protected_app = '1';
CHECK(bgm_top_action(&b) == 1, "顶栏清除按钮:返回 1(重绘约定)"); CHECK(bgm_top_action(&b) == 1, "顶栏清除按钮:返回 1(重绘约定)");
CHECK(term_count_app("os.meow.hello") == 1, "清除→terminate 普通后台 hello"); 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 标前台再清 */ /* 前台 App(is_fg)不杀:给 hello 标前台再清 */
bg_reset(1, "os.meow.hello", 1001, NULL, 0); bg_reset(1, "os.meow.hello", 1001, NULL, 0);
bgm_rows(&b, rows, SUB_ROWS_MAX); bgm_rows(&b, rows, SUB_ROWS_MAX);
@@ -153,12 +194,12 @@ int main(void)
{ {
bgmgr_row_t r; memset(&r, 0, sizeof r); bgmgr_row_t r; memset(&r, 0, sizeof r);
r.pid = 5; CHECK(bgmgr_can_kill(&r) == 1, "普通后台可杀"); r.pid = 5; CHECK(bgmgr_can_kill(&r) == 1, "普通后台可杀");
r.framework = '1'; CHECK(bgmgr_can_kill(&r) == 0, "framework 不可杀"); r.protected_app = '1'; CHECK(bgmgr_can_kill(&r) == 0, "protected_app 不可杀");
r.framework = 0; r.is_fg = 1; CHECK(bgmgr_can_kill(&r) == 0, "前台不可杀"); 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 不可杀"); 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); else printf("\n%d FAILED\n", g_fails);
return g_fails ? 1 : 0; return g_fails ? 1 : 0;
} }
+35 -2
View File
@@ -7,6 +7,8 @@
* should_keep=0(停播后再退出=真退); * should_keep=0(停播后再退出=真退);
* 3) 后台 App 崩溃照收:子进程被杀 → bg_tick WNOHANG 回收 → on_bg_dead + 清槽; * 3) 后台 App 崩溃照收:子进程被杀 → bg_tick WNOHANG 回收 → on_bg_dead + 清槽;
* 4) 卡拔例外:on_card=1 + card_present 假 0 → tick SIGTERM 回收(防 SIGBUS); * 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 回收(含恰等边界不回收); * 4b) 批①+宽限:未声明(keepalive=0)媒体不活跃超 MEOW_BG_GRACE_MS 回收(含恰等边界不回收);
* 声明常驻(keepalive=1)或媒体活跃(META PLAYING)超 grace 不清; * 声明常驻(keepalive=1)或媒体活跃(META PLAYING)超 grace 不清;
* 5) bg 表满(MEOW_BG_MAX)→ LRU 回收最久未激活的未声明槽新增成功;全为声明常驻 → MEOW_ERR_NOMEM * 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; return meow_svc_find("dev.test.svc", "daemon") == NULL;
} }
/* 服务测试用长命可执行:shell 脚本 exec sleep 300(svc_spawn 内部 execve 走 shebang)。 */ /* 服务测试用长命可执行:shell 脚本 exec sleep 300(svc_spawn 内部 execve 走 shebang)。
#define TEST_SVC_PATH "/tmp/meow_bgplay_test_svc.sh" * 路径取 $TMPDIR(Termux/Android 主机没有 /tmp),缺省回退 /tmp。 */
static char g_svc_path[256];
#define TEST_SVC_PATH g_svc_path
static void write_test_svc(void) 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"); 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); fputs("#!/bin/sh\nexec sleep 300\n", f);
fclose(f); fclose(f);
chmod(TEST_SVC_PATH, 0755); chmod(TEST_SVC_PATH, 0755);
@@ -271,6 +279,31 @@ int main(void)
"卡拔 → 卡上后台 App SIGTERM 回收(防 SIGBUS)"); "卡拔 → 卡上后台 App SIGTERM 回收(防 SIGBUS)");
g_card = 1; 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 / 重复 / 参数 ---- */ /* ---- 5) 表满:LRU 回收最久未激活的未声明槽 / 全常驻满→NOMEM / 重复 / 参数 ---- */
{ {
pid_t k[MEOW_BG_MAX]; pid_t k[MEOW_BG_MAX];
+131 -6
View File
@@ -8,16 +8,25 @@
* P2 bt_act 命中 → 正确下发 netmgrd 命令(已连接→disconnect、已配对未连→connect、输出目标 * P2 bt_act 命中 → 正确下发 netmgrd 命令(已连接→disconnect、已配对未连→connect、输出目标
* 切换、编解码器循环); * 切换、编解码器循环);
* P3 附近设备仅列未配对(已配对者归「已配对设备」)。 * P3 附近设备仅列未配对(已配对者归「已配对设备」)。
* P4 M47 开关三态:真实态未知(-1) → top_sw 回 -1 + 行内「蓝牙状态未知」(不当已关);
* P5 M47 点击收敛:下发成功且真实态达成 → 定稿、不弹窗;下发失败/超时/未知 → 回滚 + 弹窗;
* P6 M47 收敛矩阵(纯函数 meow_toggle_*,真实态×下发结果×探测 全组合)。
* *
* 编译(在 patch_v2/meow_launcherd 下): * 编译(在 patch_v2/meow_launcherd 下):
* gcc -I../sdk -I. -Wall -Wextra -fsanitize=undefined test_bt_page.c -o test_bt_page * gcc -I../sdk -I. -Wall -Wextra -fsanitize=undefined test_bt_page.c -o test_bt_page
* 运行:./test_bt_page * 运行:./test_bt_page(或 `bash build_launcher.sh test`)
*/ */
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <stdarg.h> #include <stdarg.h>
#include <strings.h> #include <strings.h>
#include <unistd.h>
#include <time.h>
/* M47:把收敛等待改成宿主可接受的尺度(生产 4000ms/250ms,见 bt_page.c)。 */
#define BT_TOGGLE_SETTLE_MS 80
#define BT_TOGGLE_POLL_MS 10
#include "meow_launcher_internal.h" #include "meow_launcher_internal.h"
#include "bt_page.c" /* bt_rows/bt_act/bt_top_sw/bt_detail 平移到本 TU */ #include "bt_page.c" /* bt_rows/bt_act/bt_top_sw/bt_detail 平移到本 TU */
@@ -29,9 +38,12 @@ static bt_ep_t g_eps[8]; static int g_eps_n;
static char g_last_cmd[128]; /* 记录 act 下发命令,如 "disconnect:<mac>" */ static char g_last_cmd[128]; /* 记录 act 下发命令,如 "disconnect:<mac>" */
static int g_output_bt; /* audio.output_bt 桩值 */ static int g_output_bt; /* audio.output_bt 桩值 */
static char g_codec[16] = "auto"; /* bt.codec 桩值 */ static char g_codec[16] = "auto"; /* bt.codec 桩值 */
static int g_bt_state = 1; /* M47:真实态三态桩(-1 未知 / 0 / 1) */
static int g_onoff_rc = MEOW_OK; /* M47:nm_bt_onoff 下发结果桩 */
static int g_bt_en_setting = 1; /* M47:network.bluetooth_enabled 桩值(意图/回滚观察点) */
int nm_bt_powered(void) { return 1; } int nm_bt_power_state(void) { return g_bt_state; }
int nm_bt_onoff(int on) { (void)on; return 0; } int nm_bt_onoff(int on) { (void)on; return g_onoff_rc; }
int nm_bt_connected(meow_net_t *out, int max) { int nm_bt_connected(meow_net_t *out, int max) {
if (max > g_conn_n) max = g_conn_n; if (max > g_conn_n) max = g_conn_n;
memcpy(out, g_conn, (size_t)max * sizeof *out); return max; memcpy(out, g_conn, (size_t)max * sizeof *out); return max;
@@ -65,14 +77,14 @@ void net_bt_mode_str(char *buf, int len) { snprintf(buf, (size_t)len, "主模式
int meow_settings_get_bool(const char *k, int *v) int meow_settings_get_bool(const char *k, int *v)
{ {
if (v) *v = 0; if (v) *v = 0;
if (!strcmp(k, "network.bluetooth_enabled")) { if (v) *v = 1; return 0; } if (!strcmp(k, "network.bluetooth_enabled")) { if (v) *v = g_bt_en_setting; return 0; }
if (!strcmp(k, "audio.output_bt")) { if (v) *v = g_output_bt; return 0; } if (!strcmp(k, "audio.output_bt")) { if (v) *v = g_output_bt; return 0; }
return -1; return -1;
} }
int meow_settings_set_bool(const char *k, int v) int meow_settings_set_bool(const char *k, int v)
{ {
if (!strcmp(k, "audio.output_bt")) { g_output_bt = v; return 0; } if (!strcmp(k, "audio.output_bt")) { g_output_bt = v; return 0; }
if (!strcmp(k, "network.bluetooth_enabled")) return 0; if (!strcmp(k, "network.bluetooth_enabled")) { g_bt_en_setting = v; return 0; }
return -1; return -1;
} }
int meow_settings_get_str(const char *k, char *b, int n) int meow_settings_get_str(const char *k, char *b, int n)
@@ -91,9 +103,26 @@ void paint_sb_right(void) {}
void logmsg(const char *fmt, ...) { (void)fmt; } void logmsg(const char *fmt, ...) { (void)fmt; }
void bt_role_refresh(int force) { (void)force; } void bt_role_refresh(int force) { (void)force; }
meow_sys_state g_sys; meow_sys_state g_sys;
int s_dd_modal(const char *title, const char *item[S_NET_MAX], int n, int cur) { (void)title; (void)item; (void)n; (void)cur; return -1; } static int g_modal_n = 0; /* M47:可见错误提示(弹窗)计数 */
static char g_modal_line[128] = "";
int s_dd_modal(const char *title, const char *item[S_NET_MAX], int n, int cur)
{
(void)title; (void)cur;
g_modal_n++;
g_modal_line[0] = 0;
if (item && n > 0 && item[0]) snprintf(g_modal_line, sizeof g_modal_line, "%s", item[0]);
return -1;
}
int meow_subpage_run(sub_page_t *p) { (void)p; return 0; } int meow_subpage_run(sub_page_t *p) { (void)p; return 0; }
/* M47:真实时钟(收敛等待用),毫秒单调。 */
unsigned long long now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (unsigned long long)ts.tv_sec * 1000ULL + (unsigned long long)(ts.tv_nsec / 1000000);
}
static int g_fail = 0; static int g_fail = 0;
#define CHECK(cond, ...) do { if (cond) { printf(" PASS: " __VA_ARGS__); printf("\n"); } \ #define CHECK(cond, ...) do { if (cond) { printf(" PASS: " __VA_ARGS__); printf("\n"); } \
else { printf(" FAIL: " __VA_ARGS__); printf("\n"); g_fail++; } } while (0) else { printf(" FAIL: " __VA_ARGS__); printf("\n"); g_fail++; } } while (0)
@@ -111,6 +140,13 @@ static int find_act(const sub_row_t *r, int n, int from, const char *pref)
if (r[i].kind == SUB_ACT && r[i].label && !strncmp(r[i].label, pref, strlen(pref))) return i; if (r[i].kind == SUB_ACT && r[i].label && !strncmp(r[i].label, pref, strlen(pref))) return i;
return -1; return -1;
} }
/* 按 label 精确找任意行(M47 未知态占位行用) */
static int row_label(const sub_row_t *r, int n, const char *lbl)
{
for (int i = 0; i < n; i++)
if (r[i].label && !strcmp(r[i].label, lbl)) return i;
return -1;
}
static void fill_state(void) static void fill_state(void)
{ {
@@ -230,6 +266,95 @@ int main(void)
bt_act(&c, ci, rows[ci].act); bt_act(&c, ci, rows[ci].act);
CHECK(strcmp(g_codec, "SBC") == 0, "编解码器 auto → SBC (实际=%s)", g_codec); CHECK(strcmp(g_codec, "SBC") == 0, "编解码器 auto → SBC (实际=%s)", g_codec);
printf("== P4 M47 开关三态(未知 ≠ 已关)==\n");
int sw = 0;
g_bt_state = -1; /* 真实态未知 */
memset(&c, 0, sizeof c); c.full = 1; fill_state();
CHECK(bt_top_sw(&c, &sw) == 1 && sw == -1, "真实态未知 → top_sw *on=-1 (实际=%d)", sw);
n = bt_rows(&c, rows, SUB_ROWS_MAX);
CHECK(row_label(rows, n, "蓝牙状态未知") >= 0, "未知态 → 行内含「蓝牙状态未知」");
g_bt_state = 0; /* 真实态为关 */
memset(&c, 0, sizeof c); c.full = 1;
sw = -2;
bt_top_sw(&c, &sw);
CHECK(sw == 0, "真实态关 → top_sw *on=0 (实际=%d)", sw);
n = bt_rows(&c, rows, SUB_ROWS_MAX);
CHECK(row_label(rows, n, "蓝牙状态未知") < 0, "真实态已知 → 无未知占位行");
printf("== P5 M47 点击收敛:定稿 / 回滚 + 可见错误 ==\n");
/* 5a 下发成功且真实态已达成 → 立即定稿,无弹窗,settings/状态栏=真实态 */
g_bt_state = 1; g_onoff_rc = MEOW_OK; g_modal_n = 0; g_bt_en_setting = 0;
memset(&c, 0, sizeof c);
unsigned long long t0 = now_ms();
bt_top_sw_set(&c, 1);
unsigned long long dt = now_ms() - t0;
CHECK(g_modal_n == 0, "已达成 → 无错误弹窗 (弹窗数=%d)", g_modal_n);
CHECK(g_bt_en_setting == 1, "已达成 → settings 定稿为 1");
CHECK(g_sys.bt_on == 1, "已达成 → 状态栏 g_sys.bt_on=1");
CHECK(dt < BT_TOGGLE_SETTLE_MS, "已达成 → 不必等满超时 (%llums)", dt);
/* 5b 下发失败、真实态仍为开(要求关)→ 回滚到开 + 可见弹窗 */
g_bt_state = 1; g_onoff_rc = MEOW_ERR_NODEV; g_modal_n = 0; g_bt_en_setting = 1;
memset(&c, 0, sizeof c);
bt_top_sw_set(&c, 0);
CHECK(g_modal_n == 1, "下发失败 → 有可见错误弹窗");
CHECK(g_bt_en_setting == 1, "下发失败 → settings 回滚为真实态 1 (实际=%d)", g_bt_en_setting);
CHECK(g_sys.bt_on == 1, "下发失败 → 状态栏仍为真实态 1");
/* 5c 下发成功但真实态不收敛(要求开、真实一直关)→ 等满上限后回滚 + 弹窗 */
g_bt_state = 0; g_onoff_rc = MEOW_OK; g_modal_n = 0; g_bt_en_setting = 1;
memset(&c, 0, sizeof c);
t0 = now_ms();
bt_top_sw_set(&c, 1);
dt = now_ms() - t0;
CHECK(g_modal_n == 1, "超时未收敛 → 有可见错误弹窗(%s)", g_modal_line);
CHECK(g_bt_en_setting == 0, "超时未收敛 → settings 回滚为真实态 0");
CHECK(g_sys.bt_on == 0, "超时未收敛 → 状态栏=真实态 0");
CHECK(dt >= BT_TOGGLE_SETTLE_MS, "确实等满了上限 (%llums)", dt);
/* 5d 真实态未知 → 等满上限后仍是未知 + 弹窗(绝不把未知显示成成功);
* settings 保留原意图不动(评审 r1 P2-1:写 0 会把「不知道」污染成「确定已关」,
* notif_center/settings_page/app_launcher 都把这个键当显示态/自动拉起依据读)。 */
g_bt_state = -1; g_onoff_rc = MEOW_OK; g_modal_n = 0; g_bt_en_setting = 1;
memset(&c, 0, sizeof c);
bt_top_sw_set(&c, 1);
CHECK(g_modal_n == 1 && strstr(g_modal_line, "无法确认") != NULL,
"未知 → 弹窗明说无法确认 (%s)", g_modal_line);
CHECK(c.bt_on == -1, "未知 → 定稿态仍为 -1 (实际=%d)", c.bt_on);
CHECK(g_bt_en_setting == 1, "未知 → settings 保留原意图 1,不被改写 (实际=%d)", g_bt_en_setting);
printf("== P6 M47 收敛矩阵(真实态 × 下发结果 × 探测 × 超时)==\n");
{
static const struct {
int last, want, ok, probe, to; /* 输入 */
int disp, pend, rb, err; /* 期望输出 */
const char *what;
} m[] = {
{ 1, 1, 1, 1, 0, 1, 0, 0, 0, "真开·下发成功·探测开" },
{ 1, 0, 1, 0, 0, 0, 0, 0, 0, "真开·下发成功·探测关" },
{ 1, 0, 1, 1, 1, 1, 0, 1, 1, "真开·下发成功·未收敛(超时)" },
{ 1, 0, 0, 1, 0, 1, 0, 1, 1, "真开·下发失败·探测仍开" },
{ 1, 0, 0, -1, 0, -1, 0, 1, 1, "真开·下发失败·探测未知 → 未知(不顶替)" },
{ 1, 0, 0, 0, 0, 0, 0, 0, 0, "真开·下发失败但探测已关(不误报)" },
{ 0, 1, 1, 1, 0, 1, 0, 0, 0, "真关·下发成功·探测开" },
{ 0, 1, 1, 0, 1, 0, 0, 1, 1, "真关·下发成功·未收敛(超时)" },
{ 0, 1, 0, 0, 0, 0, 0, 1, 1, "真关·下发失败·仍关" },
{ 1, 0, 1, -1, 0, -1, 1, 1, 0, "真开·下发成功·探测未知(继续等)" },
{ 1, 0, 1, -1, 1, -1, 0, 1, 1, "真开·下发成功·探测未知(超时)→未知,不用 last_real 顶替" },
{-1, 1, 1, -1, 1, -1, 0, 1, 1, "未知·下发成功·探测仍未知(超时)" },
{-1, 1, 1, -1, 0, -1, 1, 1, 0, "未知·下发成功·探测仍未知(继续等)" },
};
for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) {
int d = meow_toggle_display(m[i].want, m[i].last, m[i].ok, m[i].probe, m[i].to);
int p = meow_toggle_pending(m[i].want, m[i].last, m[i].ok, m[i].probe, m[i].to);
int r = meow_toggle_rollback(m[i].want, m[i].last, m[i].ok, m[i].probe, m[i].to);
int e = meow_toggle_error(m[i].want, m[i].last, m[i].ok, m[i].probe, m[i].to);
CHECK(d == m[i].disp && p == m[i].pend && r == m[i].rb && e == m[i].err,
"%s → disp=%d pend=%d rb=%d err=%d (期望 %d/%d/%d/%d)",
m[i].what, d, p, r, e, m[i].disp, m[i].pend, m[i].rb, m[i].err);
}
}
printf(g_fail ? "\nRESULT: %d FAIL(S)\n" : "\nRESULT: ALL PASS\n", g_fail); printf(g_fail ? "\nRESULT: %d FAIL(S)\n" : "\nRESULT: ALL PASS\n", g_fail);
return g_fail; return g_fail;
} }
@@ -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;
}
+78 -3
View File
@@ -40,8 +40,47 @@ int net_bt_paired(meow_net_t *out, int max) { (void)out; (void)max; return 0; }
void s_net_toggle_wifi(int on) { (void)on; } void s_net_toggle_wifi(int on) { (void)on; }
void s_net_toggle_bt(int on) { (void)on; } void s_net_toggle_bt(int on) { (void)on; }
void net_wifi_scan_trigger(void) {} void net_wifi_scan_trigger(void) {}
int net_bt_connected(meow_net_t *out, int max) { (void)out; (void)max; return 0; }
int net_bt_scan(meow_net_t *out, int max) { (void)out; (void)max; return 0; }
void net_bt_scan_trigger(void) {}
/* UI 桩:wifi_connecting_banner 走 SDK 绘制函数;宿主无 fb,空实现即可(不验证像素)。 */
const meow_ui_theme_t *s_ui_theme(void) { static meow_ui_theme_t th; return &th; }
int meow_ui_width(void) { return 480; }
void meow_ui_fill_rect(int x, int y, int w, int h, uint32_t color)
{ (void)x; (void)y; (void)w; (void)h; (void)color; }
void meow_ui_text_center(int cx, int y, const char *utf8, int size, uint32_t color)
{ (void)cx; (void)y; (void)utf8; (void)size; (void)color; }
int meow_ui_text_height(int size) { return size; }
void meow_dirty_full(void) {}
void meow_ui_flush(void) {}
char *popen_read_tmo(int tmo_ms, const char *cmd) char *popen_read_tmo(int tmo_ms, const char *cmd)
{ (void)tmo_ms; (void)cmd; return strdup("0\n"); } /* 模拟 wpa_cli add_network 返回 id=0 */ {
(void)tmo_ms;
/* 直连回退(不可达分支)会跑 wpa_cli 剧本:list_networks/add_network/status/save_config。
* 桩按子命令给近似真机的应答——旧桩统一返回 "0\n",会让 nm_direct_connect_ex 等满 10s
* 再报 MEOW_ERR_NODEV(该断言因此长期失败;M47 顺手把桩修成可收敛)。 */
if (strstr(cmd, " status")) return strdup("wpa_state=COMPLETED\nip_address=192.168.1.5\nssid=Home2G\n");
if (strstr(cmd, "list_networks")) return strdup("network id / ssid / bssid / flags\n0\tHome2G\t00:11:22:33:44:01\t[WPA-PSK-CCMP]\n");
return strdup("OK\n");
}
/* ---------- M47:net.c 三态直连探测桩(daemon 不可达时 nm_*_power_state 落到这里) ---------- */
int nm_bt_power_state(void); /* netmgr_ipc.c(头文件不在本单改动范围) */
static int g_bt_probe = -1, g_wifi_probe = -1;
int net_bt_power_probe(void) { return g_bt_probe; }
int net_wifi_power_probe(void) { return g_wifi_probe; }
/* M47:开关收敛判定纯函数的实现放在 bt_page.c,矩阵测试在 test_bt_page.c;本测试只验
* nm_*_power_state 的解析/回退与 wifi_rows 的未知态渲染,故这里给可链接的最小桩。 */
int meow_toggle_display(int intent, int last, int ok, int probe, int to)
{ (void)intent; (void)ok; (void)to; return (probe >= 0) ? probe : last; }
int meow_toggle_pending(int intent, int last, int ok, int probe, int to)
{ (void)intent; (void)last; (void)ok; (void)probe; (void)to; return 0; }
int meow_toggle_rollback(int intent, int last, int ok, int probe, int to)
{ (void)intent; (void)last; (void)ok; (void)probe; (void)to; return 0; }
int meow_toggle_error(int intent, int last, int ok, int probe, int to)
{ (void)intent; (void)last; (void)ok; (void)probe; (void)to; return 0; }
/* ---------- launcher/UI/IME 桩(wifi_page.c 链接所需;测试不触碰这些路径) ---------- */ /* ---------- launcher/UI/IME 桩(wifi_page.c 链接所需;测试不触碰这些路径) ---------- */
static unsigned long long g_test_ms = 0; static unsigned long long g_test_ms = 0;
@@ -175,7 +214,9 @@ static int row_has_label(const sub_row_t *rows, int n, const char *needle)
int main(void) int main(void)
{ {
char sock[110]; char sock[110];
snprintf(sock, sizeof sock, "/tmp/meow_net_test_%d.sock", (int)getpid()); /* 宿主 /tmp 可能不可写(Android 上 /tmp 属 shell):优先 TMPDIR。 */
snprintf(sock, sizeof sock, "%s/meow_net_test_%d.sock",
getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp", (int)getpid());
fake_net_t f; fake_net_t f;
memset(&f, 0, sizeof f); memset(&f, 0, sizeof f);
@@ -236,7 +277,26 @@ int main(void)
assert(strstr(err, "add_network") && "connect 失败带回原因"); assert(strstr(err, "add_network") && "connect 失败带回原因");
printf("[D] connect 失败: rc=%d err=%s PASS\n", rc, err); printf("[D] connect 失败: rc=%d err=%s PASS\n", rc, err);
/* ---- ③ 降级占位:MEOW_NET_SOCK 指向不存在路径 ---- /* ---- ③ M47:开关真实态三态查询(daemon 权威解析) ---- */
{
f.bt_status_text = "bt available=1 powered=1 address=00:1A:7D:DA:71:13 paired=2 connected=0";
assert(nm_bt_power_state() == 1 && "bt status powered=1 → 1(开)");
f.bt_status_text = "bt available=1 powered=0 address=00:1A:7D:DA:71:13 paired=2 connected=0";
assert(nm_bt_power_state() == 0 && "bt status powered=0 → 0(关)");
f.bt_status_text = "bt available=0 powered=0 address=- paired=-1 connected=-1";
assert(nm_bt_power_state() == 0 && "无控制器(available=0) → 0");
f.bt_status_text = "bt available=1 address=00:1A:7D:DA:71:13 paired=-1 connected=-1";
assert(nm_bt_power_state() == -1 && "缺 powered 字段 → -1(未知,不猜 0)");
g_wifi_probe = -1;
assert(nm_wifi_power_state() == -1 && "wifi 探测失败 → -1(不再默认已开)");
g_wifi_probe = 1;
assert(nm_wifi_power_state() == 1 && "wifi 探测开 → 1");
g_wifi_probe = 0;
assert(nm_wifi_power_state() == 0 && "wifi 探测关 → 0");
printf("[F] nm_bt_power_state/nm_wifi_power_state 三态解析 PASS\n");
}
/* ---- ④ 降级占位:MEOW_NET_SOCK 指向不存在路径 ----
* 先发一次 netmgr_wifi(内部强制重连,失败 → 置可达缓存=0),再断言 netmgr_ok()。 */ * 先发一次 netmgr_wifi(内部强制重连,失败 → 置可达缓存=0),再断言 netmgr_ok()。 */
setenv("MEOW_NET_SOCK", "/tmp/meow_net_nonexistent_zz.sock", 1); setenv("MEOW_NET_SOCK", "/tmp/meow_net_nonexistent_zz.sock", 1);
assert(netmgr_wifi("status", NULL, err, sizeof err) == NETMGR_UNREACH && "netmgr_wifi=NETMGR_UNREACH"); assert(netmgr_wifi("status", NULL, err, sizeof err) == NETMGR_UNREACH && "netmgr_wifi=NETMGR_UNREACH");
@@ -254,6 +314,21 @@ int main(void)
printf("[E] 降级: netmgr_ok=0, UNREACH, 回退不崩, 占位渲染 PASS\n"); printf("[E] 降级: netmgr_ok=0, UNREACH, 回退不崩, 占位渲染 PASS\n");
} }
/* ---- ⑤ M47:daemon 不可达时真实态回退直连探测;探测失败=未知(不当已关/已开) ---- */
g_bt_probe = -1;
assert(nm_bt_power_state() == -1 && "BT daemon 不可达 + 直连探测失败 → -1");
g_bt_probe = 0;
assert(nm_bt_power_state() == 0 && "BT daemon 不可达 + 直连探测关 → 0");
g_wifi_probe = -1;
assert(nm_wifi_power_state() == -1 && "WiFi 探测失败 → -1");
{
wifi_ctx_t c; memset(&c, 0, sizeof c);
sub_row_t rows[SUB_ROWS_MAX];
int rn = wifi_rows(&c, rows, SUB_ROWS_MAX);
assert(row_has_label(rows, rn, "WiFi 开关状态未知") && "未知态 → 行内第三态占位");
printf("[G] 不可达回退三态: BT/WiFi 失败=未知, 行内第三态 PASS\n");
}
printf("ALL PASS\n"); printf("ALL PASS\n");
unlink(sock); unlink(sock);
return 0; return 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;
}
@@ -18,6 +18,9 @@
* S4 锁屏页自睡(294 遗留) → overlay_lock_idle_off_sec():阈值 = min(系统 screen_off_timeout, 30s) * S4 锁屏页自睡(294 遗留) → overlay_lock_idle_off_sec():阈值 = min(系统 screen_off_timeout, 30s)
* (0/超30→30,尊重更短);overlay_lock_should_sleep()/overlay_lock_wake_now() * (0/超30→30,尊重更短);overlay_lock_should_sleep()/overlay_lock_wake_now()
* 判定超时灭屏(锁态保持)与唤醒回锁屏。 * 判定超时灭屏(锁态保持)与唤醒回锁屏。
* M40 F3/F4 → overlay_activity_drain_active():叠加/菜单返回后的排空窗内忽略
* activity 标记(防冻结期输入重放成「亮屏+锁屏」);
* overlay_lock_power_off_now():锁屏电源必须按下边沿 + 进场≥300ms。
* *
* 编译运行(在 patch_v2/meow_launcherd 下,无其它依赖): * 编译运行(在 patch_v2/meow_launcherd 下,无其它依赖):
* gcc -Wall -Wextra -o test_overlay_gate test_overlay_gate.c && ./test_overlay_gate * gcc -Wall -Wextra -o test_overlay_gate test_overlay_gate.c && ./test_overlay_gate
@@ -80,6 +83,19 @@ int main(void)
CHECK(overlay_lock_wake_now(0, 1) == 1, "S4 touch -> wake to lock"); CHECK(overlay_lock_wake_now(0, 1) == 1, "S4 touch -> wake to lock");
CHECK(overlay_lock_wake_now(0, 0) == 0, "S4 no wake input -> stay asleep"); CHECK(overlay_lock_wake_now(0, 0) == 0, "S4 no wake input -> stay asleep");
/* ---- M40 F4/P4:锁屏页电源「按下边沿 + 进场 ≥300ms」才熄屏 ----
* 电平判定会让「打开菜单的那次长按」在 PIN 页出现时立刻把页面熄灭(用户症状)。 */
CHECK(OVERLAY_LOCK_POWER_GRACE_MS == 300, "F4 lock power grace = 300ms");
CHECK(overlay_lock_power_off_now(1, 299) == 0, "F4 press edge inside 300ms grace -> no off");
CHECK(overlay_lock_power_off_now(1, 300) == 1, "F4 press edge at 300ms -> off");
CHECK(overlay_lock_power_off_now(0, 5000) == 0, "F4 held level (no edge) -> never re-fires");
CHECK(overlay_lock_power_off_now(1, 5000) == 1, "F4 new press edge after grace -> off");
/* ---- M40 F3/P3:叠加层/菜单返回后的 activity 标记排空窗 ---- */
CHECK(overlay_activity_drain_active(0, 0, 999999) == 0, "F3 no overlay return mark -> activity honored");
CHECK(overlay_activity_drain_active(1, 1000, 1299) == 1, "F3 marker inside 300ms drain -> ignored");
CHECK(overlay_activity_drain_active(1, 1000, 1300) == 0, "F3 marker after drain -> honored");
/* ---- S3 App 死亡触发主屏重绘:崩溃必须走统一退出路径 ---- */ /* ---- S3 App 死亡触发主屏重绘:崩溃必须走统一退出路径 ---- */
CHECK(lock_overlay_app_died_exit(1) == 1, "S3 app died -> unified exit path (redraw home)"); CHECK(lock_overlay_app_died_exit(1) == 1, "S3 app died -> unified exit path (redraw home)");
CHECK(lock_overlay_app_died_exit(0) == 0, "S3 app alive -> normal restore"); CHECK(lock_overlay_app_died_exit(0) == 0, "S3 app alive -> normal restore");
+299
View File
@@ -0,0 +1,299 @@
/* test_pkg_install.c — M42 宿主单测:安装入口路径预检 + 错误文案 + exec 合同(假 nyai)。
*
* 编译运行(在 patch_v2/meow_launcherd 下,需 -I../sdk 因内部头含 SDK 声明):
* gcc -Wall -Wextra -Os -I../sdk test_pkg_install.c pkg_install.c -o test_pkg_install && ./test_pkg_install
* 全 PASS -> exit 0。
*
* 覆盖:
* A. 路径预检:任意文件名/后缀可装(M35 定案:身份只来自包内 app.json)、目录/缺失/不可读/
* 控制字符/超长一律拒。
* B. 错误文案:nyai 退出码 + 输出尾行 → 单条可读文案(不空、不越界)。
* C. exec 合同:argv 直传(不经 shell,带空格/引号的路径原样到达)、退出码映射、
* start/poll 非阻塞(start 立即返回)、并发拒装、超时kill。
* 假 nyai 用 MEOW_NYAI_BIN 注入(与 MEOW_VERIFY_BIN 同口径),不碰真机/真包。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/types.h>
#include "meow_launcher_internal.h"
/* pkg_install.c 依赖的 launcher 日志:宿主打桩(宿主无 util.c)。 */
void logmsg(const char *fmt, ...)
{
(void)fmt;
}
static int g_pass = 0, g_fail = 0;
static void check(const char *name, int ok)
{
if (ok) { g_pass++; printf("PASS %s\n", name); }
else { g_fail++; printf("FAIL %s\n", name); }
}
static long long now_ms_mono(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}
static char g_dir[512];
/* 在临时目录写一个文件,返回其路径(buf)。 */
static const char *mkfile(const char *name, const char *content, char *buf, int n)
{
snprintf(buf, n, "%s/%s", g_dir, name);
FILE *f = fopen(buf, "w");
if (!f) return NULL;
if (content) fputs(content, f);
fclose(f);
return buf;
}
/* 写一个可执行假 nyai。 */
static const char *mkscript(const char *name, const char *body, char *buf, int n)
{
if (!mkfile(name, body, buf, n)) return NULL;
chmod(buf, 0755);
return buf;
}
/* ---------------- A. 路径预检 ---------------- */
static void test_path_check(void)
{
char err[256], f[512];
check("A1 null -> reject", meow_pkg_install_check_path(NULL, err, sizeof err) != 0);
check("A1 null -> has text", err[0] != 0);
err[0] = 0;
check("A2 empty -> reject", meow_pkg_install_check_path("", err, sizeof err) != 0 && err[0]);
err[0] = 0;
check("A3 missing -> reject", meow_pkg_install_check_path("/nonexistent/x.mpk", err, sizeof err) != 0);
check("A3 missing -> 找不到", strstr(err, "找不到") != NULL);
err[0] = 0;
check("A4 directory -> reject", meow_pkg_install_check_path(g_dir, err, sizeof err) != 0);
check("A4 directory -> 不是普通文件", strstr(err, "不是普通文件") != NULL);
err[0] = 0;
check("A5 dot -> reject", meow_pkg_install_check_path(".", err, sizeof err) != 0);
check("A6 dotdot -> reject", meow_pkg_install_check_path("..", err, sizeof err) != 0);
err[0] = 0;
check("A7 control char -> reject",
meow_pkg_install_check_path("/tmp/a\x01" "b", err, sizeof err) != 0 &&
strstr(err, "控制字符") != NULL);
{
char longp[700];
memset(longp, 'a', sizeof longp - 1);
longp[0] = '/'; longp[sizeof longp - 1] = 0;
err[0] = 0;
check("A8 too long -> reject",
meow_pkg_install_check_path(longp, err, sizeof err) != 0 &&
strstr(err, "过长") != NULL);
}
/* 关键回归:任意文件名/无 .mpk 后缀都可装(m35 事故:改名后装不上)。 */
if (mkfile("weird name-v0.1.7.bin", "x", f, sizeof f))
check("A9 any name/no .mpk -> accept",
meow_pkg_install_check_path(f, err, sizeof err) == 0);
else check("A9 any name/no .mpk -> accept", 0);
if (mkfile("OS.MEOW.READER", "x", f, sizeof f))
check("A10 uppercase name -> accept (nyai 拒,入口不按名字判)",
meow_pkg_install_check_path(f, err, sizeof err) == 0);
else check("A10 uppercase name -> accept (nyai 拒,入口不按名字判)", 0);
if (mkfile("noread.bin", "x", f, sizeof f)) {
chmod(f, 0000);
err[0] = 0;
check("A11 unreadable -> reject",
meow_pkg_install_check_path(f, err, sizeof err) != 0 &&
strstr(err, "不可读") != NULL);
chmod(f, 0644);
} else check("A11 unreadable -> reject", 0);
}
/* ---------------- B. 错误文案 ---------------- */
static void test_error_text(void)
{
char out[256];
meow_pkg_install_error_text(0, "whatever", out, sizeof out);
check("B1 rc=0 -> empty", out[0] == 0);
meow_pkg_install_error_text(1,
"Reading package lists... Done\r\nnyai: install: 不允许降级: 新版本 1 < 已装 2\n\n",
out, sizeof out);
check("B2 last non-empty line", strcmp(out, "nyai: install: 不允许降级: 新版本 1 < 已装 2") == 0);
meow_pkg_install_error_text(7, "", out, sizeof out);
check("B3 no output -> exit code text", strstr(out, "退出码 7") != NULL);
meow_pkg_install_error_text(137, "x", out, sizeof out);
check("B4 signal -> 128+sig text", strstr(out, "信号 9") != NULL);
meow_pkg_install_error_text(MEOW_PKG_E_TIMEOUT, NULL, out, sizeof out);
check("B5 timeout text", strstr(out, "超时") != NULL);
meow_pkg_install_error_text(MEOW_PKG_E_NYAI, "无法执行 /usr/bin/nyai", out, sizeof out);
check("B6 nyai unavailable text", strstr(out, "不可用") && strstr(out, "/usr/bin/nyai"));
meow_pkg_install_error_text(MEOW_PKG_E_BUSY, NULL, out, sizeof out);
check("B7 busy text", strstr(out, "已有安装") != NULL);
meow_pkg_install_error_text(MEOW_PKG_E_PATH, "找不到包文件 /x", out, sizeof out);
check("B8 path text", strcmp(out, "找不到包文件 /x") == 0);
{
char tiny[24];
memset(tiny, 0x5a, sizeof tiny);
meow_pkg_install_error_text(1,
"nyai: 这是一个非常非常长的错误信息,用来验证截断不会越界写坏栈\n", tiny, 16);
int guard = 1;
for (int i = 16; i < (int)sizeof tiny; i++) if (tiny[i] != 0x5a) guard = 0;
check("B9 truncation bounded", strlen(tiny) < 16 && guard);
}
meow_pkg_install_error_text(1, " \n\t\n", out, sizeof out);
check("B10 whitespace-only detail -> generic", out[0] != 0 && strstr(out, "退出码 1"));
}
/* ---------------- C. exec 合同(假 nyai) ---------------- */
static void test_exec(void)
{
char script[512], pkg[512], log[512], err[256];
/* C1: 失败透传 —— stderr 尾行进错误文案,退出码映射 MEOW_PKG_E_FAIL */
if (mkscript("nyai_fail.sh",
"#!/bin/sh\n"
"echo \"Reading package lists... Done\"\n"
"echo \"nyai: 假失败:测试包损坏\" >&2\n"
"exit 7\n", script, sizeof script) &&
mkfile("pkg-fail.bin", "x", pkg, sizeof pkg)) {
setenv("MEOW_NYAI_BIN", script, 1);
int r = meow_pkg_install(pkg);
check("C1 exit!=0 -> MEOW_PKG_E_FAIL", r == MEOW_PKG_E_FAIL);
check("C1 stderr tail surfaced", strstr(meow_pkg_install_error(), "假失败") != NULL);
check("C1 not busy after finish", meow_pkg_install_busy() == 0);
} else check("C1 exit!=0 -> MEOW_PKG_E_FAIL", 0);
/* C1b: 真机回归 —— nyai 的 stdout 是管道全缓冲(退出时才刷),错误走 stderr 先到。
* 合成一路时「最后一行」会把真错误顶成旧进度;错误文案必须取 stderr。 */
if (mkscript("nyai_buforder.sh",
"#!/bin/sh\n"
"echo \"nyai: install: 不允许降级: 新版本 code 8 < 已装 code 9\" >&2\n"
"echo \"Reading package lists... Done\"\n"
"echo \"nyai: 签名校验通过 (package=os.meow.reader)\"\n"
"exit 1\n", script, sizeof script) &&
mkfile("pkg-buforder.bin", "x", pkg, sizeof pkg)) {
setenv("MEOW_NYAI_BIN", script, 1);
int r = meow_pkg_install(pkg);
check("C1b fail rc", r == MEOW_PKG_E_FAIL);
check("C1b stderr beats buffered stdout",
strstr(meow_pkg_install_error(), "不允许降级") != NULL &&
strstr(meow_pkg_install_error(), "签名校验通过") == NULL);
} else check("C1b stderr beats buffered stdout", 0);
/* C2: argv 直传(不经 shell):带空格/单引号的路径原样到 $2;$1 必须是 install */
snprintf(log, sizeof log, "%s/argv.log", g_dir);
if (mkscript("nyai_argv.sh",
"#!/bin/sh\n"
"printf '%s|%s\\n' \"$1\" \"$2\" > \"$M42_ARGV_LOG\"\n"
"exit 0\n", script, sizeof script) &&
mkfile("name with space 'q'.bin", "x", pkg, sizeof pkg)) {
setenv("MEOW_NYAI_BIN", script, 1);
setenv("M42_ARGV_LOG", log, 1);
int r = meow_pkg_install(pkg);
check("C2 success rc=0", r == 0);
FILE *f = fopen(log, "r");
char line[600] = "";
if (f) { if (!fgets(line, sizeof line, f)) line[0] = 0; fclose(f); }
char want[700];
snprintf(want, sizeof want, "install|%s\n", pkg);
check("C2 argv passed verbatim (no shell)", strcmp(line, want) == 0);
unsetenv("M42_ARGV_LOG");
} else check("C2 success rc=0", 0);
/* C3: start 立即返回 + 并发拒装(UI 不假死的关键合同) */
if (mkscript("nyai_slow.sh", "#!/bin/sh\nsleep 0.4\nexit 0\n", script, sizeof script) &&
mkfile("pkg-slow.bin", "x", pkg, sizeof pkg)) {
setenv("MEOW_NYAI_BIN", script, 1);
long long t0 = now_ms_mono();
int r = meow_pkg_install_start(pkg);
long long dt = now_ms_mono() - t0;
check("C3 start returns fast", r == 0 && dt < 150);
check("C3 busy while running", meow_pkg_install_busy() == 1);
int r2 = meow_pkg_install_start(pkg);
check("C3 second start -> BUSY", r2 == MEOW_PKG_E_BUSY);
check("C3 busy error text", strstr(meow_pkg_install_error(), "已有安装") != NULL);
int rc = -1, p = 1, spins = 0;
while ((p = meow_pkg_install_poll(&rc)) == 1 && spins++ < 500) usleep(10000);
check("C3 poll finish rc=0", p == 0 && rc == 0);
check("C3 no longer busy", meow_pkg_install_busy() == 0);
} else check("C3 start returns fast", 0);
/* C4: 超时 —— 子进程被终止,返回 TIMEOUT 且不拖住调用方 */
if (mkscript("nyai_hang.sh", "#!/bin/sh\nexec sleep 5\n", script, sizeof script) &&
mkfile("pkg-hang.bin", "x", pkg, sizeof pkg)) {
setenv("MEOW_NYAI_BIN", script, 1);
setenv("MEOW_NYAI_TIMEOUT_MS", "100", 1);
long long t0 = now_ms_mono();
int r = meow_pkg_install(pkg);
long long dt = now_ms_mono() - t0;
check("C4 timeout -> MEOW_PKG_E_TIMEOUT", r == MEOW_PKG_E_TIMEOUT);
check("C4 timeout bounded (<3s)", dt < 3000);
check("C4 timeout text", strstr(meow_pkg_install_error(), "超时") != NULL);
unsetenv("MEOW_NYAI_TIMEOUT_MS");
} else check("C4 timeout -> MEOW_PKG_E_TIMEOUT", 0);
/* C5: nyai 不存在 → 明确报错,不回落旧内联实现、不静默 */
setenv("MEOW_NYAI_BIN", "/nonexistent/nyai", 1);
if (mkfile("pkg-ok.bin", "x", pkg, sizeof pkg)) {
err[0] = 0;
int r = meow_pkg_install(pkg);
check("C5 missing nyai -> MEOW_PKG_E_NYAI", r == MEOW_PKG_E_NYAI);
check("C5 missing nyai -> readable", strstr(meow_pkg_install_error(), "不可用") != NULL);
} else check("C5 missing nyai -> MEOW_PKG_E_NYAI", 0);
/* C6: nyai 存在但不可执行 → 同 E_NYAI */
if (mkfile("nyai_noexec.sh", "#!/bin/sh\nexit 0\n", script, sizeof script) &&
mkfile("pkg-ok2.bin", "x", pkg, sizeof pkg)) {
chmod(script, 0644);
setenv("MEOW_NYAI_BIN", script, 1);
check("C6 non-executable nyai -> MEOW_PKG_E_NYAI",
meow_pkg_install(pkg) == MEOW_PKG_E_NYAI);
chmod(script, 0755);
} else check("C6 non-executable nyai -> MEOW_PKG_E_NYAI", 0);
/* C7: 入口路径预检先于 exec(坏路径不启动子进程) */
unsetenv("MEOW_NYAI_BIN");
check("C7 bad path -> MEOW_PKG_E_PATH",
meow_pkg_install("/nonexistent/x.mpk") == MEOW_PKG_E_PATH);
}
int main(void)
{
const char *tmp = getenv("TMPDIR");
if (!tmp || !*tmp) tmp = "/tmp";
snprintf(g_dir, sizeof g_dir, "%s/m42pkg-XXXXXX", tmp);
if (!mkdtemp(g_dir)) {
/* 退路:当前目录(宿主 CI 也能跑) */
snprintf(g_dir, sizeof g_dir, "./m42pkg-XXXXXX");
if (!mkdtemp(g_dir)) { printf("FAIL cannot create temp dir\n"); return 1; }
}
printf("tmp=%s\n", g_dir);
test_path_check();
test_error_text();
test_exec();
/* 清理(先恢复文件权限,避免删不掉) */
{
char cmd[700];
snprintf(cmd, sizeof cmd, "chmod -R u+rwx '%s' 2>/dev/null; rm -rf '%s'", g_dir, g_dir);
if (system(cmd) != 0) printf("warn: cleanup %s failed\n", g_dir);
}
printf("\n%d PASS, %d FAIL\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}
+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 textcol = sel ? mt->onPrimaryContainer : mt->onSurface;
uint32_t subcol = sel ? mt->onPrimaryContainer : mt->onSurfaceVariant; 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) { 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; int ty = y + (SET_ITEM_H - thh) / 2;
if (ty >= 0) meow_ui_text(x + 16, ty, r->label, mt->font_body, mt->primary); if (ty >= 0) meow_ui_text(cx + 16, ty, r->label, mt->font_body, mt->primary);
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant);
return; return;
} }
if (sel) meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->primaryContainer); meow_gfx_fill_round(cx, cy, cw, ch, 12, mt->surfaceContainer);
else if (press) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->primary, 26); 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 ly = y + (SET_ITEM_H - thh) / 2;
int rgt = cx + cw - 16;
switch (r->ctrl) { switch (r->ctrl) {
case SC_SWITCH: { 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(); meow_widget *sw = set_sw_take();
if (sw) { if (sw) {
int on = 0; meow_settings_get_switch(r->key, &on); 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_switch_set(sw, on);
meow_widget_draw(sw); 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: { case SC_SLIDER: {
/* 宿主测试不引入 launcher s_draw_slider/s_ui_theme:只验证行文字渲染 + 命中,track 视觉由 /* 宿主测试不引入 launcher s_draw_slider/s_ui_theme:只验证行文字渲染 + 命中,track 视觉由
* 真机 s_draw_slider 呈现(settings_page.c set_lv_draw 已复用)。 */ * 真机 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); 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; break;
} }
case SC_LIST: { case SC_LIST: {
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_ui_text(x + w - 16 - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol); meow_ui_text(rgt - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol);
break; break;
} }
default: { /* SC_ENUM / SC_PRESET */ 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); 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); meow_ui_text(vx, ly, vb, mt->font_body, subcol);
break; break;
} }
} }
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant); if (state & MEOW_LISTVIEW_ST_DISABLED) meow_gfx_fill_round_alpha(cx, cy, cw, ch, 12, mt->onSurface, 90);
if (state & MEOW_LISTVIEW_ST_DISABLED) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->onSurface, 90);
} }
static void mk_widgets(void) static void mk_widgets(void)
@@ -247,7 +252,7 @@ static void draw_all(void)
{ {
const meow_theme *th = meow_theme_get(); const meow_theme *th = meow_theme_get();
meow_ui_fill_rect(0, 0, XRES, YRES, th->surface); 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); } if (lv) { g_set_sw_use = 0; meow_widget_draw(lv); }
} }
@@ -305,16 +310,17 @@ int main(void)
meow_ui_activity_push(SET_SENTINEL); meow_ui_activity_push(SET_SENTINEL);
const meow_theme *th = meow_theme_get(); const meow_theme *th = meow_theme_get();
draw_all(); draw_all();
/* 段头行整体填 surfaceContainerHigh(覆盖列表底 surfaceVariant)→ 对列表底背景即非背景; /* MD3 容器化(P0):段头=surface 上的 primary 段标签(无满幅 band/卡,宿主文本为 no-op →
* 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「段头底 vs 列表底」判渲染而非文字像素。 */ * 段头区应回 surface);普通行=surfaceContainer 圆角卡(region_nonbg 应见大量非 surface 像素)。
int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant); * 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「卡/段头 vs 列表底」判渲染而非文字像素。 */
int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant); int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surface);
CHECK(hdrcnt > 0, "section header band renders (hdr=%d)", hdrcnt); int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surface);
CHECK(rowcnt > 40, "row band renders (title/value/switch) cnt=%d", rowcnt); CHECK(hdrcnt == 0, "section header no-band(label-on-surface) nb=%d", hdrcnt);
/* P1b:开关行(ROWS[2]=最高刷新率 right switch)中心右缘区非背景 */ 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 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); 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_vsource(lv, sp_lv_count, sp_lv_bind, NULL);
meow_listview_set_item_renderer(lv, 2, 0, 1); meow_listview_set_item_renderer(lv, 2, 0, 1);
meow_listview_set_item_h(lv, SUB_ITEM_H); 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); sw = meow_switch_create(XRES - 16 - 56, SUB_TB_Y + (SUB_TB_H - 32) / 2, 1);
meow_ui_activity_pop(); meow_ui_activity_pop();
} }
@@ -153,12 +154,14 @@ int main(void)
meow_ui_activity_push(SUB_SENTINEL); meow_ui_activity_push(SUB_SENTINEL);
const meow_theme *th = meow_theme_get(); const meow_theme *th = meow_theme_get();
draw_all(); 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 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); int rowcnt = region_nonbg(2, SUB_CONT_TOP + SUB_ITEM_H + 4, XRES - 4, SUB_ITEM_H - 8, th->surface);
char nm[80]; char nm[80];
snprintf(nm, sizeof nm, "section header renders (band=%d)", hdrcnt); snprintf(nm, sizeof nm, "section header no-band(label-on-surface) nb=%d", hdrcnt);
CHECK(hdrcnt > 0, "%s", nm); CHECK(hdrcnt == 0, "%s", nm);
snprintf(nm, sizeof nm, "row renders (title hdr/switch/arrow) cnt=%d", rowcnt); snprintf(nm, sizeof nm, "row renders (card) cnt=%d", rowcnt);
CHECK(rowcnt > 40, "%s", nm); CHECK(rowcnt > 40, "%s", nm);
/* ============ P2 listview 命中(行号 + right 操作区) ============ */ /* ============ P2 listview 命中(行号 + right 操作区) ============ */
@@ -190,6 +193,7 @@ int main(void)
meow_listview_set_vsource(scroll_lv, big_count, big_bind, NULL); 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_renderer(scroll_lv, 2, 0, 1);
meow_listview_set_item_h(scroll_lv, SUB_ITEM_H); 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 */ meow_scroll *s = meow_scroll_wrap(scroll_lv, 1); /* 1=W_LISTVIEW */
CHECK(s != NULL, "meow_scroll_wrap(listview)"); CHECK(s != NULL, "meow_scroll_wrap(listview)");
int maxs = meow_listview_max_scroll(scroll_lv); int maxs = meow_listview_max_scroll(scroll_lv);
+61 -4
View File
@@ -907,6 +907,52 @@ int meow_ipc_conn_alive(int fd)
return 0; return 0;
} }
/* ---- E:系统级音量翻页——app_id→持久 IPC fd 最近活跃映射(util.c 单写者 = ipc_dispatch)----
* App 的「持久连接」(如 reader_app/app/reader_ipc.c 后台线程)连上后发 MSG_HELLO,本映射在该
* HELLO 被 ipc_dispatch 处理时记录 app_id→m->fd;连接关闭(meow_ipc_process 剔除)时清对应项。
* 只记录「declares volume_page」的 App(meow_volpage_app_allows),保持映射极小且无歧义。
* meow_ipc_app_fd() 供 app_launcher.c 前台会话取持久 fd 武装翻页 sink。 */
#define VOLPAGE_FD_MAX 8
static struct { char app_id[MEOW_APP_ID_MAX]; int fd; } g_volpage_fd[VOLPAGE_FD_MAX];
static int g_volpage_fd_n;
static void volpage_fd_set(const char *app_id, int fd)
{
if (!app_id || !app_id[0] || fd < 0) return;
for (int i = 0; i < g_volpage_fd_n; i++) {
if (!strcmp(g_volpage_fd[i].app_id, app_id)) { g_volpage_fd[i].fd = fd; return; }
}
if (g_volpage_fd_n < VOLPAGE_FD_MAX) {
snprintf(g_volpage_fd[g_volpage_fd_n].app_id, sizeof g_volpage_fd[0].app_id, "%s", app_id);
g_volpage_fd[g_volpage_fd_n].fd = fd;
g_volpage_fd_n++;
}
}
static void volpage_fd_clear_fd(int fd)
{
for (int i = 0; i < g_volpage_fd_n; i++) {
if (g_volpage_fd[i].fd == fd) {
for (int j = i; j < g_volpage_fd_n - 1; j++) g_volpage_fd[j] = g_volpage_fd[j + 1];
g_volpage_fd_n--;
return;
}
}
}
int meow_ipc_app_fd(const char *app_id)
{
if (!app_id) return -1;
for (int i = 0; i < g_volpage_fd_n; i++) {
if (!strcmp(g_volpage_fd[i].app_id, app_id)) {
if (meow_ipc_conn_alive(g_volpage_fd[i].fd)) return g_volpage_fd[i].fd;
volpage_fd_clear_fd(g_volpage_fd[i].fd); /* fd 已死:清残留 */
return -1;
}
}
return -1;
}
/* §10.5:待处理的 App 前台「展开通知中心」请求(ipc_dispatch 单写者置位;launch_app waitpid /* §10.5:待处理的 App 前台「展开通知中心」请求(ipc_dispatch 单写者置位;launch_app waitpid
* 循环 meow_notif_open_take 读取并清除)。仅当 g_app_session_active(App 前台会话)期间才置位, * 循环 meow_notif_open_take 读取并清除)。仅当 g_app_session_active(App 前台会话)期间才置位,
* 否则回 ERR(无 App 前台,不展开)。 */ * 否则回 ERR(无 App 前台,不展开)。 */
@@ -1099,6 +1145,7 @@ static void *ipc_thread(void *arg)
if (ipc_conn_read(cc) < 0) { if (ipc_conn_read(cc) < 0) {
logmsg("ipc: conn %d closed", cc->fd); logmsg("ipc: conn %d closed", cc->fd);
close(cc->fd); close(cc->fd);
volpage_fd_clear_fd(cc->fd); /* E:清已断连接的翻页 fd 映射 */
for (int j = i; j < nfds - 1; j++) { for (int j = i; j < nfds - 1; j++) {
fds[j] = fds[j + 1]; fds[j] = fds[j + 1];
ipc_conns[j - 1] = ipc_conns[j]; ipc_conns[j - 1] = ipc_conns[j];
@@ -1209,6 +1256,8 @@ static void ipc_dispatch(struct meow_ipc_msg *m)
memcpy(&h, m->payload, m->plen < (uint16_t)sizeof h ? m->plen : (uint16_t)sizeof h); memcpy(&h, m->payload, m->plen < (uint16_t)sizeof h ? m->plen : (uint16_t)sizeof h);
h.app_id[MEOW_APP_ID_MAX - 1] = 0; h.app_id[MEOW_APP_ID_MAX - 1] = 0;
meow_acct_hello(h.app_id, h.version, h.flags); meow_acct_hello(h.app_id, h.version, h.flags);
/* E:记录声明 volume_page 的 App 的持久连接 fd(HELLO=App 侧持久 IPC 连接;仅读卡 app.json 一次)。 */
if (meow_volpage_app_allows(h.app_id)) volpage_fd_set(h.app_id, m->fd);
m_ack_t a; m_ack_t a;
a.code = MEOW_OK; a.code = MEOW_OK;
a.status = (uint32_t)g_naccts; a.status = (uint32_t)g_naccts;
@@ -1417,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); 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; k.app_id[MEOW_APP_ID_MAX - 1] = 0;
m_ack_t a; 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); meow_ams_pending_kill_set(k.app_id);
a.code = MEOW_OK; a.code = MEOW_OK;
} else { } 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); 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; break;
} }
/* AMSD_DESIGN §6.2 二期:picker 跨 App 调用(App A → launcher → amsd 路由)。 /* AMSD_DESIGN §6.2 二期:picker 跨 App 调用(App A → launcher → amsd 路由)。
+152 -27
View File
@@ -11,6 +11,11 @@
* 【M6.0 迁移】全部查询/操作走 netmgr IPC(netmgr_ipc.c 的 nm_* 封装),netmgrd 是 WiFi 权威; * 【M6.0 迁移】全部查询/操作走 netmgr IPC(netmgr_ipc.c 的 nm_* 封装),netmgrd 是 WiFi 权威;
* netmgrd 不可达(NETMGR_UNREACH)→ 自动回退 net.c 直连 wpa_cli(双轨保功能),页面显示降级占位 * netmgrd 不可达(NETMGR_UNREACH)→ 自动回退 net.c 直连 wpa_cli(双轨保功能),页面显示降级占位
* 「网络管理服务未运行(直连模式)」。 * 「网络管理服务未运行(直连模式)」。
*
* 【M47 开关可信化】顶栏开关显示态只来自真实态三态查询 nm_wifi_power_state()(rfkill wlan
* soft/hard,回退 wlan0 IFF_UP;探测失败=未知),**读不到不再默认「已开」**;settings 的
* network.wifi_enabled 只承载用户意图。点击先出示「正在开启/关闭…(等待确认)」,下发 → 有界
* 轮询真实态 → 真实态定稿;失败/超时回滚并弹窗(不许乐观显示成功)。
*/ */
#include "meow_launcher_internal.h" #include "meow_launcher_internal.h"
@@ -19,6 +24,7 @@
#define WF_ACT_CURR (-1) /* 当前连接(SUB_ROW,detail) */ #define WF_ACT_CURR (-1) /* 当前连接(SUB_ROW,detail) */
#define WF_ACT_SAVED 0 /* 已保存网络 idx(SUB_ACT,connect) */ #define WF_ACT_SAVED 0 /* 已保存网络 idx(SUB_ACT,connect) */
#define WF_ACT_SCAN_BASE 1000 /* 附近网络 1000+idx(SUB_ROW,连接) */ #define WF_ACT_SCAN_BASE 1000 /* 附近网络 1000+idx(SUB_ROW,连接) */
#define WF_ACT_MANUAL 2000 /* 「手动添加隐藏网络」动作行(SUB_ACT,connecth) */
typedef struct { typedef struct {
meow_net_t saved[S_NET_MAX]; meow_net_t saved[S_NET_MAX];
@@ -26,32 +32,96 @@ typedef struct {
int saved_n, scan_n; int saved_n, scan_n;
char ssid[64]; char ssid[64];
int connected; /* nm_wifi_connected()(IPC>直连回退 wlan_connected) */ int connected; /* nm_wifi_connected()(IPC>直连回退 wlan_connected) */
int wifi_on; /* network.wifi_enabled */ int sw_state; /* 真实射频态三态:-1 未知 / 0 关 / 1 开(M47) */
unsigned long long sw_ts; /* 上面这次查询的时刻(缓存去重) */
long last_scan_ms; /* 扫描触发节流 */ long last_scan_ms; /* 扫描触发节流 */
} wifi_ctx_t; } wifi_ctx_t;
/* M47:开关收敛参数(宿主测试可预先定义改小)。WiFi 走 wifi_up.sh(起 wpa_supplicant +
* 等关联/DHCP),比 BT 慢,所以上限 5s——且从**点击时刻**起算(含下发耗时,评审 r1 P2-3)。
* 快路径(rfkill 写入在 netmgrd 内是毫秒级)5s 内约 20 次探测;慢路径(netmgrd 卡住 →
* on/off 读超时 1.5s → 直连 wifi_up.sh 自身后台化)下探测只剩 1~2 次,此时只能确认
* 「探测超时」,不能宣称「射频无响应」——弹窗文案按此区分。
* 250ms 只是快路径的轮询间隔,不 20ms 级忙轮询。 */
#ifndef WF_TOGGLE_SETTLE_MS
#define WF_TOGGLE_SETTLE_MS 5000
#endif
#ifndef WF_TOGGLE_POLL_MS
#define WF_TOGGLE_POLL_MS 250
#endif
#define WF_STATE_TTL_MS 800
/* 顶栏开关当前值 */ /* 真实态查询(带缓存)。定义见 netmgr_ipc.c:netmgrd 不报射频态,故取 rfkill/wlan0。 */
extern int nm_wifi_power_state(void);
/* 收敛判定纯函数,实现见 bt_page.c(两个页面共用一套;矩阵测试在 test_bt_page.c) */
extern int meow_toggle_display(int intent, int last_real, int dispatch_ok, int probe, int timed_out);
extern int meow_toggle_pending(int intent, int last_real, int dispatch_ok, int probe, int timed_out);
extern int meow_toggle_rollback(int intent, int last_real, int dispatch_ok, int probe, int timed_out);
extern int meow_toggle_error(int intent, int last_real, int dispatch_ok, int probe, int timed_out);
static int wifi_state_now(wifi_ctx_t *c, int force)
{
unsigned long long t = now_ms();
if (!force && c->sw_ts && t - c->sw_ts < WF_STATE_TTL_MS) return c->sw_state;
c->sw_state = nm_wifi_power_state();
c->sw_ts = t;
return c->sw_state;
}
/* 顶栏开关当前值:只报真实态(-1=未知 → subpage 画「?」)。绝不再「读不到就默认 1」。 */
static int wifi_top_sw(void *ctx, int *on) static int wifi_top_sw(void *ctx, int *on)
{ {
(void)ctx; wifi_ctx_t *c = ctx;
int v = 1; *on = wifi_state_now(c, 0);
if (meow_settings_get_bool("network.wifi_enabled", &v) != 0) v = 1;
*on = v;
return 1; return 1;
} }
/* 顶栏开关切换:写设置 + 开/关网卡(netmgr on/off,回退直连)+ 触发重扫 + 同步状态栏 */ /* 顶栏开关切换:落盘意图 → 开/关网卡(netmgr on/off,回退直连)→ 有界等待真实态收敛 →
* 真实态定稿/回滚 + 可见错误提示。 */
static void wifi_top_sw_set(void *ctx, int on) static void wifi_top_sw_set(void *ctx, int on)
{ {
wifi_ctx_t *c = ctx; wifi_ctx_t *c = ctx;
meow_settings_set_bool("network.wifi_enabled", on); unsigned long long t_click = now_ms(); /* 上限从点击起算(含下发耗时,见参数注释) */
nm_wifi_toggle(on); int want = on ? 1 : 0;
g_sys.wifi_on = on; int last_real = wifi_state_now(c, 0);
paint_sb_right(); /* 状态栏 WiFi 图标随开关即时刷新 */ meow_settings_set_bool("network.wifi_enabled", want); /* 意图持久化 */
meow_settings_save(); meow_settings_save();
if (on) { nm_wifi_scan_trigger(); c->last_scan_ms = now_ms(); } int dispatch_ok = (nm_wifi_toggle(want) == MEOW_OK);
unsigned long long deadline = t_click + WF_TOGGLE_SETTLE_MS;
int probe = wifi_state_now(c, 1), timed_out = 0;
while (meow_toggle_pending(want, last_real, dispatch_ok, probe, timed_out)) {
if (now_ms() >= deadline) { timed_out = 1; break; }
usleep(WF_TOGGLE_POLL_MS * 1000);
probe = wifi_state_now(c, 1);
}
int disp = meow_toggle_display(want, last_real, dispatch_ok, probe, timed_out);
int rb = meow_toggle_rollback(want, last_real, dispatch_ok, probe, timed_out);
int err = meow_toggle_error(want, last_real, dispatch_ok, probe, timed_out);
unsigned long long elapsed = now_ms() - t_click;
c->sw_state = disp; c->sw_ts = now_ms();
/* 只在拿到真实态时改写 settings;disp == -1(探测未知)保留原意图不动——该键被
* notif_center/settings_page 当显示态读,写 0 会把「不知道」污染成「确定已关」(P2-1)。 */
if (disp >= 0) {
meow_settings_set_bool("network.wifi_enabled", disp == 1);
meow_settings_save();
}
if (disp >= 0) g_sys.wifi_on = disp;
paint_sb_right(); /* 状态栏 WiFi 图标随「已确认」状态刷新 */
if (disp == 1) { nm_wifi_scan_trigger(); c->last_scan_ms = now_ms(); }
logmsg("wifi_page: toggle want=%d ok=%d probe=%d to=%d disp=%d rollback=%d err=%d elapsed=%llums",
want, dispatch_ok, probe, timed_out, disp, rb, err, elapsed);
if (err) {
char line[128];
const char *txt[1]; txt[0] = line;
if (disp < 0)
snprintf(line, sizeof line, "无法确认当前状态(上次已知:%s)",
last_real < 0 ? "未知" : (last_real ? "开启" : "关闭"));
else
snprintf(line, sizeof line, "切换未生效:当前仍为%s", disp ? "开启" : "关闭");
s_dd_modal("WiFi", txt, 1, -1);
}
} }
@@ -71,6 +141,7 @@ static void wifi_scan(void *ctx)
static int wifi_rows(void *ctx, sub_row_t *rbuf, int max) static int wifi_rows(void *ctx, sub_row_t *rbuf, int max)
{ {
wifi_ctx_t *c = ctx; wifi_ctx_t *c = ctx;
c->sw_state = wifi_state_now(c, 0); /* 真实射频态(三态);未知时下面显式说明 */
c->connected = nm_wifi_connected(); c->connected = nm_wifi_connected();
if (c->connected) { if (c->connected) {
nm_wifi_ssid(c->ssid, sizeof c->ssid); nm_wifi_ssid(c->ssid, sizeof c->ssid);
@@ -89,6 +160,12 @@ static int wifi_rows(void *ctx, sub_row_t *rbuf, int max)
rbuf[n].kind = SUB_HDR; rbuf[n].label = "网络管理服务未运行(直连模式)"; rbuf[n].kind = SUB_HDR; rbuf[n].label = "网络管理服务未运行(直连模式)";
rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++;
} }
/* 第三态显式呈现(M47):射频态未知 ≠ 已关;顶栏开关同时画「?」,这里补文字说明。 */
if (c->sw_state < 0 && n < max) {
rbuf[n].kind = SUB_ROW; rbuf[n].label = "WiFi 开关状态未知";
rbuf[n].sub = "无法读取射频状态(rfkill/网卡无响应)";
rbuf[n].on = 0; rbuf[n].act = -2; n++;
}
/* 当前连接 */ /* 当前连接 */
if (n < max) { rbuf[n].kind = SUB_HDR; rbuf[n].label = "当前连接"; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++; } if (n < max) { rbuf[n].kind = SUB_HDR; rbuf[n].label = "当前连接"; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++; }
if (n < max) { if (n < max) {
@@ -122,13 +199,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; rbuf[n].on = 0; rbuf[n].act = WF_ACT_SCAN_BASE + i;
n++; 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; return n;
} }
/* SDK IME 输密码(PW 模式=ASCII 软键盘输入,现有输入法复用;回车提交/ESC或点背景取消)。 /* SDK IME 通用文本输入(MEOW_IME_ASCII:QWERTY,顶条「中」可切拼音输中文 SSID)。
* 返回 1=已输入;0=取消。 */ * 返回 1=已输入;0=取消。 */
static int wifi_ask_psk(char *psk, int maxlen) static int wifi_ask_text(char *out, int maxlen)
{ {
meow_ime_ctx ic; meow_ime_ctx ic;
memset(&ic, 0, sizeof ic); memset(&ic, 0, sizeof ic);
@@ -136,36 +222,72 @@ static int wifi_ask_psk(char *psk, int maxlen)
ic.maxlen = maxlen; ic.maxlen = maxlen;
ic.buf[0] = 0; ic.buf[0] = 0;
int res = meow_ui_text_input(&ic); /* 阻塞:提交后 ctx.buf 回填最终文本 */ 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; 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=重绘。 */ /* 附近网络点选 →【WiFi 新网络录入】连接流程。返回 1=重绘。 */
static int wifi_connect_scan(wifi_ctx_t *c, int scan_idx) static int wifi_connect_scan(wifi_ctx_t *c, int scan_idx)
{ {
if (scan_idx < 0 || scan_idx >= c->scan_n) return 0; if (scan_idx < 0 || scan_idx >= c->scan_n) return 0;
meow_net_t *s = &c->scan[scan_idx]; meow_net_t *s = &c->scan[scan_idx];
if (!s->name[0] || !strcmp(s->name, "(隐藏网络)")) { if (!s->name[0] || !strcmp(s->name, "(隐藏网络)")) {
const char *txt[1]; txt[0] = "隐藏网络无法直接连接"; wifi_manual_add(c); /* bug2:占位隐藏网络 → 手动录入(SSID 待输) */
s_dd_modal("附近网络", txt, 1, -1);
return 1; return 1;
} }
int open = (s->sec[0] == 0 || !strcmp(s->sec, "Open")); int open = (s->sec[0] == 0 || !strcmp(s->sec, "Open"));
char psk[96] = ""; char psk[96] = "";
if (!open) { 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; 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); int rc = nm_wifi_connect(s->name, open ? NULL : psk, err, sizeof err);
char line[192]; char line[192];
const char *txt[1]; txt[0] = line; const char *txt[1]; txt[0] = line;
if (rc == MEOW_OK) { /* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */
snprintf(line, sizeof line, "连接成功:%s%s", s->name, snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败"));
err[0] ? "" : "(已启用)");
} else {
snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误");
}
logmsg("wifi_page: scan connect ssid=%s open=%d rc=%d err=%s", s->name, open, rc, err); logmsg("wifi_page: scan connect ssid=%s open=%d rc=%d err=%s", s->name, open, rc, err);
/* 连接结果反馈(成功后进已保存网络列表由 rows() 重扫刷新) */ /* 连接结果反馈(成功后进已保存网络列表由 rows() 重扫刷新) */
s_dd_modal(s->name, txt, 1, -1); s_dd_modal(s->name, txt, 1, -1);
@@ -208,20 +330,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) static int wifi_act(void *ctx, int idx, int act)
{ {
(void)idx; (void)idx;
wifi_ctx_t *c = ctx; 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),不走这里 */ if (act >= WF_ACT_SCAN_BASE) return 0; /* 附近是 SUB_ROW(detail),不走这里 */
int sidx = act - WF_ACT_SAVED; int sidx = act - WF_ACT_SAVED;
if (sidx < 0 || sidx >= c->saved_n) return 0; if (sidx < 0 || sidx >= c->saved_n) return 0;
if (c->saved[sidx].id < 0) return 0; if (c->saved[sidx].id < 0) return 0;
char err[160]; err[0] = 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); int rc = nm_wifi_connect(c->saved[sidx].name, NULL, err, sizeof err);
char line[192]; const char *txt[1]; txt[0] = line; char line[192]; const char *txt[1]; txt[0] = line;
if (rc == MEOW_OK) snprintf(line, sizeof line, "已连接:%s", c->saved[sidx].name); /* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */
else snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误"); 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); 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); s_dd_modal(c->saved[sidx].name, txt, 1, -1);
nm_wifi_scan_trigger(); nm_wifi_scan_trigger();
+28 -9
View File
@@ -9,25 +9,44 @@
# 用法: # 用法:
# ./build_netmgrd.sh # 交叉编译 → patch_v2/meow_netmgrd/meow_netmgrd(mipsel 静态) # ./build_netmgrd.sh # 交叉编译 → patch_v2/meow_netmgrd/meow_netmgrd(mipsel 静态)
# ./build_netmgrd.sh host # 宿主编译 → patch_v2/meow_netmgrd/meow_netmgrd_host # ./build_netmgrd.sh host # 宿主编译 → patch_v2/meow_netmgrd/meow_netmgrd_host
# OUT=/path/to/out ./build_netmgrd.sh # 产物另存(M49 起:仓库里不提交二进制,验证产物放仓库外)
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
ROOT="$HOME/Project/R3ProII" # 仓库根按本脚本位置推导(<root>/patch_v2/meow_netmgrd/),MEOW_ROOT 可覆盖。
CC="$ROOT/.tools/zig-mipsel-cc" # 旧版硬编码 $HOME/Project/R3ProII —— 换 checkout 路径/用 worktree 时直接编不动。
ROOT="${MEOW_ROOT:-$(cd ../.. && pwd)}"
# 交叉编译器:ZIGCC env 优先,其次 <root>/.tools/,最后 $HOME/.tools/(.tools 常只在主 checkout 里)
CC="${ZIGCC:-$ROOT/.tools/zig-mipsel-cc}"
[ -x "$CC" ] || CC="$HOME/.tools/zig-mipsel-cc"
I_HDR="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(帧/MSG_NET_*/m_net_*) I_HDR="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(帧/MSG_NET_*/m_net_*)
OUT="${OUT:-.}" # 产物目录:默认脚本目录;设了则在交叉编译后把产物移过去
[ -d "$OUT" ] || mkdir -p "$OUT"
if [ "$1" = "host" ]; then if [ "$1" = "host" ]; then
HOST_CC="${CC_HOST:-cc}" HOST_CC="${CC_HOST:-cc}"
# -Wno-format-truncation:宿主 glibc 对固定缓冲 snprintf 的「安全截断」误报(musl 交叉不报)。 # -Wno-format-truncation:宿主 glibc 对固定缓冲 snprintf 的「安全截断」误报(musl 交叉不报)。
"$HOST_CC" -Os -Wall -Wextra -U_FORTIFY_SOURCE -Wno-format-truncation -I"$I_HDR" \ "$HOST_CC" -Os -Wall -Wextra -U_FORTIFY_SOURCE -Wno-format-truncation -I"$I_HDR" \
meow_netmgrd.c -o meow_netmgrd_host meow_netmgrd.c -o "$OUT/meow_netmgrd_host"
echo "== host build ==" echo "== host build =="
ls -la meow_netmgrd_host ls -la "$OUT/meow_netmgrd_host"
file meow_netmgrd_host file "$OUT/meow_netmgrd_host"
exit 0 exit 0
fi fi
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meow_netmgrd.c -o meow_netmgrd # meow_time32.c:独立 o32 time32 取时/睡眠(netmgrd 不链 meow_ui.a);修 DNS TTL 基准
# 与 nanosleep 退避。守护/CLI 同一二进制,一次构建两处安装。(M43 的 time32 接入,必须保留:
# 少了它 mono_now()/轮询退避会踩 mipsel o32 的 time64/time32 结构错位。)
# 注意:`-o meow_netmgrd` 保持字面量(不带目录前缀)——sdk/test_time32_abi.sh 的构建门禁正是
# 按 `-o <产物名>` 定位编译行来断言 time32 接入;写成 `-o "$OUT/..."` 会让门禁报「未找到」。
# 编译在临时目录里做(产物名仍是字面量),编完再落到 OUT:这样设了 OUT 也**不会**覆盖/删除
# 仓库里那份被跟踪的 patch_v2/meow_netmgrd/meow_netmgrd(M49 曾因 mv 把它删掉过一次)。
HERE="$(pwd)"
BUILD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netmgrd-build.XXXXXX")"
( cd "$BUILD_DIR" && "$CC" -Os -Wall -Wextra -static -I"$I_HDR" -DMEOW_TIME32_LIBC_OVERRIDES \
"$HERE/meow_netmgrd.c" "$HERE/../sdk/meow_time32.c" -o meow_netmgrd )
mv -f "$BUILD_DIR/meow_netmgrd" "$OUT/meow_netmgrd"
rmdir "$BUILD_DIR" 2>/dev/null || true
echo "== meow_netmgrd (mipsel static) ==" echo "== meow_netmgrd (mipsel static) =="
ls -la meow_netmgrd ls -la "$OUT/meow_netmgrd"
md5sum meow_netmgrd md5sum "$OUT/meow_netmgrd"
file meow_netmgrd file "$OUT/meow_netmgrd"
Binary file not shown.
File diff suppressed because it is too large. Load diff
+648 -60
View File
@@ -1,9 +1,13 @@
/* test_bt_ctl.c — meow_netmgrd 蓝牙 P1 权威收口 纯逻辑宿主自测(本程序非生产代码)。 /* test_bt_ctl.c — meow_netmgrd 蓝牙控制面 纯逻辑宿主自测(本程序非生产代码)。
* *
* 通过 #include 生产源文件(meow_netmgrd.c,用 #define main 改名避免入口冲突),同一 TU 直接访问 * 通过 #include 生产源文件(meow_netmgrd.c,用 #define main 改名避免入口冲突),同一 TU 直接访问
* FILE-STATIC 函数/全局:bt_cmd_is_write / bt_write_authorize / bt_ctl_execute(connect/disconnect/ * FILE-STATIC 函数/全局:三个纯解析器(bt_scan_line_parse / bt_pcm_paths_parse+bt_pcm_info_parse /
* pair/unpair/connected/a2dp/devices/status)。工具经桩脚本(g_bluetoothctl/g_hcitool/g_bluealsa_cli/ * bt_acl_parse)、run_cmd_ex 四态、bt_cmd_is_write / bt_write_authorize、bt_ctl_execute
* g_bt_scan_cache 指向 $T/桩)覆盖,不依赖真实蓝牙栈。 * (status/paired/connected/a2dp/devices/on/off/connect/disconnect/pair/unpair/scan)。
* 后端工具经有状态桩脚本(g_bluetoothctl/g_hcitool/g_bluealsa_cli/g_hciconfig/g_bt_scan_cache)
* 覆盖,不依赖真实蓝牙栈。
*
* 字节样本来源:2026-11-11 真机 `od -c /tmp/meow_bt_scan.txt`(0.8.49,BluetoothCtl 5.54 交互输出)。
* *
* 用法:cc -Wall -Wextra -I../meow_launcherd test_bt_ctl.c -o test_bt_ctl && ./test_bt_ctl * 用法:cc -Wall -Wextra -I../meow_launcherd test_bt_ctl.c -o test_bt_ctl && ./test_bt_ctl
*/ */
@@ -27,46 +31,396 @@ static void wf(const char *p, const char *s)
if (f) { fputs(s, f); fclose(f); } if (f) { fputs(s, f); fclose(f); }
} }
/* ---- 真机原始字节样本(含 ANSI ESC / \001\002 标记 / \r\033[K / 提示符) ---- */
static const char *S_NEW_PUDDING =
"\033[0;94m[bluetooth]\033[0m# \r\033[K"
"[\001\033[0;92m\002NEW\001\033[0m\002] Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n";
static const char *S_CHG_POWERED =
"\033[0;94m[bluetooth]\033[0m# \r\033[K"
"[\001\033[0;93m\002CHG\001\033[0m\002] Controller 4C:BC:98:B0:98:DB Powered: no\n";
static const char *S_DEL_LINE = "[DEL] Device 00:11:22:33:44:55\n";
static const char *S_PROMPT_DEVICES = "\033[0;94m[bluetooth]\033[0m# devices\n";
static const char *S_PLAIN_BLOCK = "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\n";
static const char *S_TRUNC_MAC =
"\033[0;94m[bluetooth]\033[0m# \r\033[K[NEW] Device 33:DE:2E:21:A";
int main(void) int main(void)
{ {
static const char *T = "/tmp/nmg_bt"; /* 宿主临时目录:Termux/Android 上 /tmp 不可写,优先 $TMPDIR(mktemp 同规则) */
char p[192]; const char *base = getenv("TMPDIR");
mkdir(T, 0755); if (!base || !base[0]) base = "/tmp";
static char Tbuf[256];
snprintf(Tbuf, sizeof Tbuf, "%s/nmg_bt", base);
const char *T = Tbuf;
char p[256];
char cmd[512];
/* 可重复运行(r1 评审 P2):上一轮遗留的 conn_ / paired_ 状态文件会把「无连接」断言推翻。
* 每次开跑先整棵删掉重来,退出时也清(见文件末尾)。 */
{ char rm[320]; snprintf(rm, sizeof rm, "rm -rf '%s'", Tbuf); (void)!system(rm); }
mkdir(Tbuf, 0755);
/* 假 /sys/class/bluetooth/hci0(available/powered/address) */ /* 假 /sys/class/bluetooth/hci0(available/powered/address) */
{ snprintf(p, sizeof p, "%s/bt", T); mkdir(p, 0755); { snprintf(p, sizeof p, "%s/bt", T); mkdir(p, 0755);
snprintf(p, sizeof p, "%s/bt/hci0", T); mkdir(p, 0755); snprintf(p, sizeof p, "%s/bt/hci0", T); mkdir(p, 0755);
snprintf(p, sizeof p, "%s/bt/hci0/address", T); wf(p, "00:1A:7D:DA:71:13\n"); snprintf(p, sizeof p, "%s/bt/hci0/address", T); wf(p, "4c:bc:98:b0:98:db\n");
snprintf(p, sizeof p, "%s/bt/hci0/power", T); wf(p, "on\n"); } snprintf(p, sizeof p, "%s/bt/hci0/power", T); wf(p, "off\n"); }
/* 假 rfkill:真机有两个 bluetooth 节点(rfkill0/rfkill3),任一仍 block 就等于没开 */
{ snprintf(p, sizeof p, "%s/rfkill", T); mkdir(p, 0755);
for (int i = 0; i < 2; i++) {
char d[300];
snprintf(d, sizeof d, "%s/rfkill/rfkill%d", T, i ? 3 : 0); mkdir(d, 0755);
snprintf(p, sizeof p, "%s/type", d); wf(p, "bluetooth\n");
snprintf(p, sizeof p, "%s/soft", d); wf(p, "1\n");
} }
/* 有状态桩的状态目录 */
snprintf(p, sizeof p, "%s/state", T); mkdir(p, 0755);
/* 桩:bluetoothctl(paired/devices/connect/remove)、hcitool con、bluealsa-cli a2dp、扫描缓存 */ /* 桩:bluetoothctl(paired-devices/devices/info/connect/disconnect/pair/remove,靠状态文件变迁)
snprintf(p, sizeof p, "%s/bluetoothctl", T); wf(p, * 注意:生产代码给后端命令前置 PATH=/bin:/sbin:/usr/bin:/usr/sbin,Termux 上 cat/basename/grep
"#!/bin/sh\ncase \"$1\" in\n" * 都不在这几个目录里 —— 桩脚本只能用 shell 内建(read/echo/[ /参数展开),否则测试会假失败。 */
" paired|paired-devices|devices) echo 'Device 33:DE:2E:21:AD:30 Pudding'; echo 'Device 11:22:33:44:55:66 Speaker';;\n" snprintf(p, sizeof p, "%s/bluetoothctl", T);
" connect|disconnect|pair|remove) echo 'Controller 00:1A:7D:DA:71:13'; echo 'Device 33:DE:2E:21:AD:30 Connected: yes';;\n" {
"esac\nexit 0\n"); chmod(p, 0755); char sh[3200];
snprintf(p, sizeof p, "%s/hcitool", T); wf(p, snprintf(sh, sizeof sh,
"#!/bin/sh\necho '\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER'\n" "#!/bin/sh\n"
"echo '\t< ACL 11:22:33:44:55:66 handle 13 state 1 lm SLAVE'\nexit 0\n"); chmod(p, 0755); "S='%s/state'\n"
snprintf(p, sizeof p, "%s/bluealsa-cli", T); wf(p, "catf() { [ -f \"$1\" ] || return 0; while IFS= read -r l || [ -n \"$l\" ]; do echo \"$l\"; done < \"$1\"; }\n"
"#!/bin/sh\nif [ \"${1:-}\" = \"a2dp\" ]; then\n" "case \"$1\" in\n"
"echo '/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc:'\n" " paired-devices) catf \"$S/paired_list.txt\"; exit 0;;\n"
"echo '\tCodec: SBC'\necho '\tSampling: 44100 Hz'\necho '\tChannels: 2'\n" " devices) catf \"$S/dev_list.txt\"; exit 0;;\n"
"echo '\tBits per Sample: 16'\necho '\tState: RUNNING'\necho '\tAcquired: yes'\nfi\nexit 0\n"); chmod(p, 0755); " info)\n"
snprintf(p, sizeof p, "%s/bt_scan.txt", T); wf(p, " mac=\"$2\"\n"
"[NEW] Device 33:DE:2E:21:AD:30 Pudding\n[NEW] Device AA:BB:CC:DD:EE:FF Earbuds\n"); " if [ ! -f \"$S/paired_$mac\" ]; then echo \"Device $mac not available\"; exit 1; fi\n"
" pv=no; IFS= read -r pv < \"$S/paired_$mac\"\n"
" if [ \"$pv\" = gone ]; then echo \"Device $mac not available\"; exit 1; fi\n"
" cv=no\n"
" if [ -f \"$S/conn_$mac\" ]; then IFS= read -r cv < \"$S/conn_$mac\"; fi\n"
" echo \"Device $mac\"\n"
" echo \" Name: Pudding\"\n"
" echo \" Paired: $pv\"\n"
" echo \" Connected: $cv\"\n"
" exit 0;;\n"
" connect)\n"
" case \"$2\" in CC:*) sleep 5;; esac\n"
" case \"$2\" in AA:*) echo 'Failed to connect: org.bluez.Error.Failed br-connection-refused'; exit 1;; esac\n"
" echo yes > \"$S/conn_$2\"; echo yes > \"$S/paired_$2\"\n"
" echo 'Attempting to connect'; echo 'Connection successful'; exit 0;;\n"
" disconnect)\n"
" case \"$2\" in AA:*) echo 'Failed to disconnect: not connected'; exit 1;; esac\n"
" case \"$2\" in BB:*) echo 'Successful disconnected'; exit 0;; esac\n"
" echo no > \"$S/conn_$2\"; echo 'Successful disconnected'; exit 0;;\n"
" pair)\n"
" case \"$2\" in CC:*) sleep 5;; esac\n"
" case \"$2\" in AA:*) echo 'Failed to pair: org.bluez.Error.AuthenticationFailed'; exit 1;; esac\n"
" echo yes > \"$S/paired_$2\"; echo 'Pairing successful'; exit 0;;\n"
" remove)\n"
" echo gone > \"$S/paired_$2\"; echo no > \"$S/conn_$2\"; echo 'Device has been removed'; exit 0;;\n"
"esac\n"
"exit 0\n", T);
wf(p, sh); chmod(p, 0755);
}
/* 桩:hcitool con —— 按 conn_<mac>=yes 标记生成 ACL 行 */
snprintf(p, sizeof p, "%s/hcitool", T);
{
char sh[900];
snprintf(sh, sizeof sh,
"#!/bin/sh\n"
"S='%s/state'\n"
"echo 'Connections:'\n"
"for f in \"$S\"/conn_*; do\n"
" [ -f \"$f\" ] || continue\n"
" v=no; IFS= read -r v < \"$f\"\n"
" [ \"$v\" = yes ] || continue\n"
" m=\"${f##*/}\"; m=\"${m#conn_}\"\n"
" echo \"\t< ACL $m handle 12 state 1 lm MASTER\"\n"
"done\n"
"exit 0\n", T);
wf(p, sh); chmod(p, 0755);
}
/* 桩:hciconfig —— `hci0 up/down` 改 sysfs power,`hci0` 打印状态 */
snprintf(p, sizeof p, "%s/hciconfig", T);
{
char sh[900];
snprintf(sh, sizeof sh,
"#!/bin/sh\n"
"S='%s'\n"
"if [ -f \"$S/state/hang_hciconfig\" ]; then sleep 5; fi\n"
"blocked() { for t in \"$S\"/rfkill/*; do\n"
" [ -f \"$t/type\" ] || continue\n"
" v=\"\"; IFS= read -r v < \"$t/type\"; [ \"$v\" = bluetooth ] || continue\n"
" b=0; IFS= read -r b < \"$t/soft\"; [ \"$b\" = 1 ] && return 0\n"
" done; return 1; }\n"
"case \"$2\" in\n"
" up) if blocked; then echo 'hci0: Operation not possible due to RF-kill' >&2; exit 1; fi\n"
" echo on > \"$S/bt/hci0/power\"; echo 'hci0: Type: Primary Bus: UART'; echo ' UP RUNNING';;\n"
" down) echo off > \"$S/bt/hci0/power\"; echo 'hci0: Type: Primary Bus: UART'; echo ' DOWN';;\n"
" *) echo 'hci0: Type: Primary Bus: UART'\n"
" v=off; if [ -f \"$S/bt/hci0/power\" ]; then IFS= read -r v < \"$S/bt/hci0/power\"; fi\n"
" if [ \"$v\" = on ]; then echo ' UP RUNNING'; else echo ' DOWN'; fi;;\n"
"esac\n"
"exit 0\n", T);
wf(p, sh); chmod(p, 0755);
}
/* 桩:bluealsa-cli —— list-pcms 端点由 $S/pcms.txt 控制,info 按路径返回属性 */
snprintf(p, sizeof p, "%s/bluealsa-cli", T);
{
char sh[3200];
snprintf(sh, sizeof sh,
"#!/bin/sh\n"
"S='%s/state'\n"
"catf() { [ -f \"$1\" ] || return 0; while IFS= read -r l || [ -n \"$l\" ]; do echo \"$l\"; done < \"$1\"; }\n"
"case \"$1\" in\n"
" list-pcms) catf \"$S/pcms.txt\"; exit 0;;\n"
" info)\n"
" if [ -f \"$S/hang_info\" ]; then sleep 5; fi\n"
" path=\"$2\"; found=0\n"
" if [ -f \"$S/pcms.txt\" ]; then\n"
" while IFS= read -r l || [ -n \"$l\" ]; do\n"
" if [ \"$l\" = \"$path\" ]; then found=1; fi\n"
" done < \"$S/pcms.txt\"\n"
" fi\n"
" case \"$path\" in *dev_33_DE_2E_21_AD_30*) ;; *) found=0;; esac\n"
" if [ \"$found\" != 1 ]; then\n"
" echo \"bluealsa-cli: E: CMD \\\"info\\\": Couldn't get BlueALSA PCM: Object path not found: '$path'\" >&2\n"
" exit 1\n"
" fi\n"
" case \"$path\" in *dev_33_DE_2E_21_AD_30*) ;; *) exit 1;; esac\n"
" if [ -f \"$S/running\" ]; then run=true; else run=false; fi\n"
" echo \"Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30\"\n" /* ← 真机原文(M50 2026-11-11 18:26) */
" echo 'Sequence: 0'\n"
" echo 'Transport: A2DP-source'\n"
" echo 'Mode: sink'\n"
" echo \"Running: $run\"\n"
" echo 'Format: S16_LE'\n"
" echo 'Channels: 2'\n"
" echo 'Sampling: 48000 Hz'\n"
" echo 'Available codecs: SBC AAC'\n"
" echo 'Selected codec: AAC'\n"
" echo 'Delay: 15.0 ms'\n"
" echo 'SoftVolume: false'\n"
" echo 'Volume: L: 127 R: 127'\n"
" echo 'Muted: L: false R: false'\n"
" exit 0;;\n"
"esac\n"
"exit 0\n", T);
wf(p, sh); chmod(p, 0755);
}
/* 初始状态:两台已配对(真机 MOONDROP / 17OP15),无 ACL */
snprintf(p, sizeof p, "%s/state/paired_list.txt", T);
wf(p, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice E0:42:6D:79:C5:98 17OP15\n");
snprintf(p, sizeof p, "%s/state/dev_list.txt", T);
wf(p, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice E0:42:6D:79:C5:98 17OP15\n"
"Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n");
snprintf(p, sizeof p, "%s/state/paired_33:DE:2E:21:AD:30", T); wf(p, "yes\n");
snprintf(p, sizeof p, "%s/state/paired_E0:42:6D:79:C5:98", T); wf(p, "yes\n");
g_bluetoothctl = ""; { char x[160]; snprintf(x, sizeof x, "%s/bluetoothctl", T); static char s[160]; /* 指向桩(g_* 是 const char*,用静态缓冲承载) */
snprintf(s, sizeof s, "%s", x); g_bluetoothctl = s; } static char sb_bt[256], sb_hci[256], sb_ba[256], sb_hc[256], sb_cache[256];
g_hcitool = ""; { char x[160]; snprintf(x, sizeof x, "%s/hcitool", T); static char s[160]; snprintf(sb_bt, sizeof sb_bt, "%s/bluetoothctl", T);
snprintf(s, sizeof s, "%s", x); g_hcitool = s; } snprintf(sb_hci, sizeof sb_hci, "%s/hcitool", T);
g_bluealsa_cli = ""; { char x[160]; snprintf(x, sizeof x, "%s/bluealsa-cli", T); static char s[160]; snprintf(sb_ba, sizeof sb_ba, "%s/bluealsa-cli", T);
snprintf(s, sizeof s, "%s", x); g_bluealsa_cli = s; } snprintf(sb_hc, sizeof sb_hc, "%s/hciconfig", T);
g_bt_scan_cache = ""; { char x[160]; snprintf(x, sizeof x, "%s/bt_scan.txt", T); static char s[160]; snprintf(sb_cache, sizeof sb_cache, "%s/bt_scan.txt", T);
snprintf(s, sizeof s, "%s", x); g_bt_scan_cache = s; } g_bluetoothctl = sb_bt;
g_hcitool = sb_hci;
g_bluealsa_cli = sb_ba;
g_hciconfig = sb_hc;
g_bt_scan_cache = sb_cache;
g_bt_dir[0] = 0; snprintf(g_bt_dir, sizeof g_bt_dir, "%s/bt", T); g_bt_dir[0] = 0; snprintf(g_bt_dir, sizeof g_bt_dir, "%s/bt", T);
g_rfkill_dir[0] = 0; snprintf(g_rfkill_dir, sizeof g_rfkill_dir, "%s/rfkill", T);
ctl_result_t r; ctl_result_t r;
/* ================= 1. run_cmd_ex 四态(M49 新增) ================= */
printf("== run_cmd_ex 四态:成功有输出/成功无输出/非 0 退出/超时/命令不存在/stderr 抓取 ==\n");
{
runcmd_res_t rr;
char b[128];
int n = run_cmd_ex(b, sizeof b, 1500, "echo hi", &rr);
CHECK(n == 3);
CHECK(rr.status == RUNCMD_OK);
CHECK(rr.exit_code == 0);
n = run_cmd_ex(b, sizeof b, 1500, "true", &rr);
CHECK(n == 0);
CHECK(rr.status == RUNCMD_EMPTY); /* 成功但无输出 ≠ 失败 */
n = run_cmd_ex(b, sizeof b, 1500, "exit 3", &rr);
CHECK(n == 0);
CHECK(rr.status == RUNCMD_EXIT);
CHECK(rr.exit_code == 3);
n = run_cmd_ex(b, sizeof b, 1500, "nosuchcmd_m49_xyz", &rr);
CHECK(rr.status == RUNCMD_EXIT);
CHECK(rr.exit_code == 127); /* 命令不存在(sh 报 127) */
n = run_cmd_ex(b, sizeof b, 150, "sleep 5", &rr);
CHECK(rr.status == RUNCMD_TIMEOUT); /* 限时内不退出 */
CHECK(n >= 0);
n = run_cmd_ex(b, sizeof b, 1500, "echo boom >&2; exit 4", &rr);
CHECK(rr.status == RUNCMD_EXIT);
CHECK(rr.exit_code == 4);
CHECK(strstr(rr.err, "boom") != NULL); /* stderr 不再丢 /dev/null */
int isfail = 0;
const char *why = run_cmd_why(&rr, &isfail);
CHECK(isfail == 1);
CHECK(strstr(why, "exit 4") != NULL);
CHECK(strstr(why, "boom") != NULL);
CHECK(run_cmd(b, sizeof b, 1500, "echo ok") == 3); /* 旧外壳契约不变 */
}
/* ================= 2. 扫描缓存解析(真机字节) ================= */
printf("== bt_clean_ansi / bt_scan_line_parse(真机 od 字节样本) ==\n");
{
char clean[512], mac[24], name[64];
bt_clean_ansi(S_NEW_PUDDING, clean, sizeof clean);
CHECK(strstr(clean, "\033") == NULL);
CHECK(strstr(clean, "\001") == NULL && strstr(clean, "\002") == NULL);
CHECK(strstr(clean, "\r") == NULL);
CHECK(strcmp(clean, "[bluetooth]# [NEW] Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63") == 0);
CHECK(bt_scan_line_parse(S_NEW_PUDDING, mac, sizeof mac, name, sizeof name) == 1);
CHECK(strcmp(mac, "79:85:D6:7B:73:63") == 0);
CHECK(strcmp(name, "79-85-D6-7B-73-63") == 0);
CHECK(bt_scan_line_parse(S_PLAIN_BLOCK, mac, sizeof mac, name, sizeof name) == 1);
CHECK(strcmp(mac, "33:DE:2E:21:AD:30") == 0);
CHECK(strcmp(name, "MOONDROP Pudding") == 0);
/* [CHG]/[DEL] 不是「附近有这台」 */
CHECK(bt_scan_line_parse(S_CHG_POWERED, mac, sizeof mac, name, sizeof name) == 0);
CHECK(bt_scan_line_parse(S_DEL_LINE, mac, sizeof mac, name, sizeof name) == 0);
/* 提示符回显行 / 空行 / 截断到半截 MAC / 无关行 */
CHECK(bt_scan_line_parse(S_PROMPT_DEVICES, mac, sizeof mac, name, sizeof name) == 0);
CHECK(bt_scan_line_parse("", mac, sizeof mac, name, sizeof name) == 0);
CHECK(bt_scan_line_parse(S_TRUNC_MAC, mac, sizeof mac, name, sizeof name) == 0);
CHECK(bt_scan_line_parse("Waiting to connect to bluetoothd...\r\n", mac, sizeof mac, name, sizeof name) == 0);
/* MAC 后无名字(截断输出)也要给出设备 */
CHECK(bt_scan_line_parse("[NEW] Device AA:BB:CC:DD:EE:FF\n", mac, sizeof mac, name, sizeof name) == 1);
CHECK(strcmp(mac, "AA:BB:CC:DD:EE:FF") == 0);
CHECK(name[0] == 0);
}
/* ================= 3. hcitool con 解析 ================= */
printf("== bt_acl_parse(hcitool con 原始输出/空/截断) ==\n");
{
char macs[8][24];
CHECK(bt_acl_parse("Connections:\n", macs, 8) == 0);
CHECK(bt_acl_parse("", macs, 8) == 0);
int n = bt_acl_parse("Connections:\n"
"\t< ACL 33:DE:2E:21:AD:30 handle 12 state 1 lm MASTER\n"
"\t< ACL 11:22:33:44:55:66 handle 13 state 1 lm SLAVE\n", macs, 8);
CHECK(n == 2);
CHECK(strcmp(macs[0], "33:DE:2E:21:AD:30") == 0);
CHECK(strcmp(macs[1], "11:22:33:44:55:66") == 0);
/* SCO 行不是 ACL;截断 MAC 丢掉 */
n = bt_acl_parse("\t< SCO 33:DE:2E:21:AD:30 handle 12 state 1\n"
"\t< ACL 33:DE:2E:21:A\n", macs, 8);
CHECK(n == 0);
}
/* ================= 4. list-pcms / info 解析 ================= */
printf("== bt_pcm_paths_parse / bt_path_mac / bt_pcm_info_parse ==\n");
{
char paths[4][80];
int n = bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc\n", paths, 4);
CHECK(n == 1);
CHECK(strcmp(paths[0], "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc") == 0);
n = bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc\n"
"/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/a2dpsnk\r\n"
"/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/sco\n", paths, 4);
CHECK(n == 2); /* SCO/HFP 端点不算 A2DP */
CHECK(strcmp(paths[1], "/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/a2dpsnk") == 0);
CHECK(bt_pcm_paths_parse("", paths, 4) == 0);
/* 真机 bluealsa v4.x 端点路径带末端段,且**结尾字面量不可依赖**:
* 塔 2026-11-11 首连实测是 `/a2dpsrc/sink`(不是 /source),M50 的旧 bt_dev_detect_mac()
* 按「最后一个 / 之后」取 token 就会拿到 "sink" 而不是 MAC。此处两种结尾都验。 */
n = bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n"
"/org/bluealsa/hci0/dev_E0_42_6D_79_C5_98/a2dpsnk/source\n", paths, 4);
CHECK(n == 2);
CHECK(strcmp(paths[0], "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink") == 0);
char mac2[24];
CHECK(bt_path_mac(paths[0], mac2, sizeof mac2) == 1);
CHECK(strcmp(mac2, "33:DE:2E:21:AD:30") == 0); /* 不是 "sink" */
CHECK(bt_path_mac(paths[1], mac2, sizeof mac2) == 1);
CHECK(strcmp(mac2, "E0:42:6D:79:C5:98") == 0);
char mac3[24]; /* 无末端段的老形态也兼容 */
CHECK(bt_path_mac("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc", mac3, sizeof mac3) == 1);
CHECK(strcmp(mac3, "33:DE:2E:21:AD:30") == 0);
/* 截断输出(半截路径)也不许崩、不许当成端点 */
bt_pcm_paths_parse("/org/bluealsa/hci0/dev_33_D", paths, 4);
char mac[24];
CHECK(bt_path_mac("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc", mac, sizeof mac) == 1);
CHECK(strcmp(mac, "33:DE:2E:21:AD:30") == 0);
CHECK(bt_path_mac("/org/bluealsa/hci0/dev_33_DE_2E_21_AD_3", mac, sizeof mac) == 0);
CHECK(bt_path_mac("/org/bluealsa/hci0/nothing", mac, sizeof mac) == 0);
/* 真机原文(M50 2026-11-11 18:26 实测,idle 与 running 各一份),逐字照抄 */
static const char *REAL_IDLE =
"Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30\n"
"Sequence: 0\n"
"Transport: A2DP-source\n"
"Mode: sink\n"
"Running: false\n"
"Format: S16_LE\n"
"Channels: 2\n"
"Sampling: 48000 Hz\n"
"Available codecs: SBC AAC\n"
"Selected codec: AAC\n"
"Delay: 15.0 ms\n"
"SoftVolume: false\n"
"Volume: L: 127 R: 127\n"
"Muted: L: false R: false\n";
static const char *REAL_RUNNING =
"Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30\n"
"Sequence: 3\n"
"Transport: A2DP-source\n"
"Mode: sink\n"
"Running: true\n"
"Format: S16_LE\n"
"Channels: 2\n"
"Sampling: 48000 Hz\n"
"Available codecs: SBC AAC\n"
"Selected codec: AAC\n"
"Delay: 17.2 ms\n"
"SoftVolume: false\n"
"Volume: L: 127 R: 127\n"
"Muted: L: false R: false\n";
bt_pcm_info_t info;
bt_pcm_info_parse(REAL_IDLE, &info);
CHECK(info.has == 1);
CHECK(strcmp(info.codec, "AAC") == 0); /* Selected codec,不是 Available 列表 */
CHECK(strcmp(info.rate, "48000 Hz") == 0);
CHECK(strcmp(info.channels, "2") == 0);
CHECK(strcmp(info.bits, "16") == 0); /* 从 Format: S16_LE 推出来 */
CHECK(info.running == 0); /* Running: false */
bt_pcm_info_parse(REAL_RUNNING, &info);
CHECK(info.running == 1); /* Running: true 才代表真在传 */
CHECK(strcmp(info.codec, "AAC") == 0);
CHECK(strcmp(info.bits, "16") == 0);
/* 兼容旧/其它形态(有人给的是 Codec:/Bits per Sample:/State: RUNNING) */
bt_pcm_info_parse(" Transport: A2DP-source\n"
" Codec: SBC\n"
" CodecConfiguration: 0x3f\n"
" Sampling: 44100 Hz\n"
" Channels: 2\n"
" Bits per Sample: 16\n"
" State: RUNNING\n", &info);
CHECK(strcmp(info.codec, "SBC") == 0); /* 不被 CodecConfiguration 抢走 */
CHECK(strcmp(info.rate, "44100 Hz") == 0);
CHECK(strcmp(info.bits, "16") == 0);
CHECK(info.running == 1);
/* 只有 Format 一行(属性打包)时的兜底 */
bt_pcm_info_parse(" Format: 48000 Hz, 2 channels, 24 bits\n", &info);
CHECK(info.has == 1);
CHECK(strcmp(info.rate, "48000") == 0);
CHECK(strcmp(info.channels, "2") == 0);
CHECK(strcmp(info.bits, "24") == 0);
CHECK(info.running == -1);
/* 空输出 / 错误输出(endpoint 不存在)→ has=0,不许编造 */
bt_pcm_info_parse("", &info);
CHECK(info.has == 0);
bt_pcm_info_parse("bluealsa-cli: E: CMD \"info\": Couldn't get BlueALSA PCM: "
"Object path not found: '/org/bluealsa/hci0/dev_00/a2dpsrc'\n", &info);
CHECK(info.has == 0);
}
/* ================= 5. 读写分类与授权 ================= */
printf("== bt_cmd_is_write 分类(读/写级) ==\n"); printf("== bt_cmd_is_write 分类(读/写级) ==\n");
CHECK(bt_cmd_is_write("status") == 0); CHECK(bt_cmd_is_write("status") == 0);
CHECK(bt_cmd_is_write("paired") == 0); CHECK(bt_cmd_is_write("paired") == 0);
@@ -81,46 +435,278 @@ int main(void)
CHECK(bt_cmd_is_write("unpair") == 1); CHECK(bt_cmd_is_write("unpair") == 1);
CHECK(bt_cmd_is_write("scan") == 1); CHECK(bt_cmd_is_write("scan") == 1);
printf("== bt_ctl_execute 写级(connect/disconnect/pair/unpair,经桩 bluetoothctl) ==\n"); /* ================= 6. 读级(status/paired/connected/a2dp/devices) ================= */
printf("== 读级:status/paired/connected/a2dp/devices ==\n");
bt_ctl_execute("status", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "paired=2") != NULL); /* paired-devices 计数 */
CHECK(strstr(r.text, "connected=0") != NULL); /* 内核 ACL 权威:当前无连接 */
bt_ctl_execute("paired", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding") != NULL);
CHECK(strstr(r.text, "Device E0:42:6D:79:C5:98 17OP15") != NULL);
/* 没有已配对设备 = 空列表(不是错误) */
snprintf(p, sizeof p, "%s/state/paired_list.txt", T); wf(p, "");
bt_ctl_execute("paired", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(r.text[0] == 0);
snprintf(p, sizeof p, "%s/state/paired_list.txt", T);
wf(p, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding\nDevice E0:42:6D:79:C5:98 17OP15\n");
/* a2dp:list-pcms → info,无端点时 "A2DP none" */
snprintf(p, sizeof p, "%s/state/pcms.txt", T);
wf(p, "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc\n");
bt_ctl_execute("a2dp", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "A2DP 33:DE:2E:21:AD:30") != NULL);
CHECK(strstr(r.text, "codec=AAC") != NULL); /* 真机 Selected codec */
CHECK(strstr(r.text, "rate=48000 Hz") != NULL);
CHECK(strstr(r.text, "channels=2") != NULL);
CHECK(strstr(r.text, "bits=16") != NULL);
CHECK(strstr(r.text, "acquired=0") != NULL); /* Running: false(空闲,端点还在) */
CHECK(strstr(r.text, "role=src") != NULL);
{ /* Running: true → acquired=1(权威布尔) */
snprintf(p, sizeof p, "%s/state/running", T); wf(p, "1\n");
bt_ctl_execute("a2dp", NULL, &r);
CHECK(strstr(r.text, "acquired=1") != NULL);
unlink(p);
}
/* 列表里有端点但 info 查不到(刚断开)→ 跳过,不编造 */
snprintf(p, sizeof p, "%s/state/pcms.txt", T);
wf(p, "/org/bluealsa/hci0/dev_00_00_00_00_00_00/a2dpsrc\n");
bt_ctl_execute("a2dp", NULL, &r);
CHECK(strcmp(r.text, "A2DP none\n") == 0);
wf(p, "");
bt_ctl_execute("a2dp", NULL, &r);
CHECK(strcmp(r.text, "A2DP none\n") == 0);
printf("== A2DP 预算:端点全卡死时不许把守护拖住(r1 评审 P2-1) ==\n");
{
char pcms[512];
snprintf(pcms, sizeof pcms,
"/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n"
"/org/bluealsa/hci0/dev_11_22_33_44_55_66/a2dpsrc/sink\n"
"/org/bluealsa/hci0/dev_AA_BB_CC_DD_EE_FF/a2dpsrc/sink\n"
"/org/bluealsa/hci0/dev_12_34_56_78_9A_BC/a2dpsnk/source\n");
snprintf(p, sizeof p, "%s/state/pcms.txt", T); wf(p, pcms);
snprintf(p, sizeof p, "%s/state/hang_info", T); wf(p, "1\n");
long t0 = mono_ms();
bt_ctl_execute("a2dp", NULL, &r);
long dt = mono_ms() - t0;
printf(" budget-a2dp(4 端点全卡死) = %ld ms\n", dt);
CHECK(dt < 1200); /* 旧实现实测 6053ms */
CHECK(r.code == MEOW_OK);
unlink(p);
snprintf(p, sizeof p, "%s/state/pcms.txt", T); wf(p, "");
/* status JSON 的 a2dp 摘要:无连接→0 次 fork;有连接→TTL 缓存(第二次几乎免费) */
snprintf(p, sizeof p, "%s/state/conn_33:DE:2E:21:AD:30", T); wf(p, "yes\n");
snprintf(p, sizeof p, "%s/state/pcms.txt", T);
wf(p, "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink\n");
snprintf(p, sizeof p, "%s/state/hang_info", T); wf(p, "1\n");
static char js[8192];
g_bt_a2dp_cache_ms = 0;
long t1 = mono_ms();
build_status_json(js, sizeof js);
long d1 = mono_ms() - t1;
long t2 = mono_ms();
build_status_json(js, sizeof js);
long d2 = mono_ms() - t2;
printf(" budget-status-json 1st = %ld ms, 2nd(cache) = %ld ms\n", d1, d2);
CHECK(d1 < 700); /* 单次刷新受 BT_A2DP_STATUS_BUDGET_MS 约束 */
CHECK(d2 < 60); /* 缓存命中:不再 fork */
CHECK(strstr(js, "\"a2dp_codec\"") != NULL); /* 字段保留(MeowPanel btCard 依赖) */
unlink(p);
unlink(snprintf(p, sizeof p, "%s/state/conn_33:DE:2E:21:AD:30", T) ? p : p);
snprintf(p, sizeof p, "%s/state/pcms.txt", T); wf(p, "");
}
printf("== 扫描缓存 → bt devices(真机 ANSI 字节 + devices 尾块 + 去重) ==\n");
{
char cache[4096];
/* 结构与真机 /tmp/meow_bt_scan.txt 一致:开头等待行 + ESC[P + 提示符,实时 NEW 行,
* 末尾 devices 块,最后一行被截断(没有换行)。 */
snprintf(cache, sizeof cache,
"Waiting to connect to bluetoothd...\r\033[P\033[P\033[P"
"\033[0;94m[bluetooth]\033[0m# \033[K\r\033[KAgent registered\n"
"\033[0;94m[bluetooth]\033[0m# scan on\n"
"%s"
"%s"
"\033[0;94m[bluetooth]\033[0m# scan off\n"
"\033[0;94m[bluetooth]\033[0m# devices\n"
"Device 33:DE:2E:21:AD:30 MOONDROP Pudding\n"
"Device E0:42:6D:79:C5:98 17OP15\n"
"Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n"
"\033[0;94m[bluetooth]\033[0m# quit\n"
"\033[0;94m[bluetooth]\033[0m# \r\033[K"
"[\001\033[0;92m\002NEW\001\033[0m\002] Device AA:BB:CC:DD:EE:FF Earbuds",
S_NEW_PUDDING, S_CHG_POWERED);
snprintf(p, sizeof p, "%s/bt_scan.txt", T); wf(p, cache);
bt_ctl_execute("devices", NULL, &r);
CHECK(r.code == MEOW_OK);
/* 实时 NEW 行(ANSI 开头)现在能解析出来 */
CHECK(strstr(r.text, "Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63") != NULL);
CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30 MOONDROP Pudding") != NULL);
CHECK(strstr(r.text, "Device E0:42:6D:79:C5:98 17OP15") != NULL);
/* 截断的最后一行(无换行)也要收;[CHG] 行不许混进来 */
CHECK(strstr(r.text, "Device AA:BB:CC:DD:EE:FF Earbuds") != NULL);
CHECK(strstr(r.text, "Controller 4C:BC:98") == NULL);
CHECK(strstr(r.text, "Powered") == NULL);
/* 去重:79:.. 实时行与 devices 块各一次,输出只一行 */
{ int cnt = 0; const char *q = r.text;
while ((q = strstr(q, "79:85:D6:7B:73:63")) != NULL) { cnt++; q += 17; }
CHECK(cnt == 1); }
/* 缓存不存在 → 空文本 */
unlink(p);
bt_ctl_execute("devices", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(r.text[0] == 0);
}
/* ================= 7. 写级:on/off + connect/disconnect/pair/unpair ================= */
printf("== bt on/off:按 power 真态定稿 + rfkill 顺序(先 unblock 再 up / 先 down 再 block) ==\n");
/* 初始两个 bluetooth rfkill 节点 soft=1(真机形态)。旧实现在这里必败:
* 先 hciconfig up 被 rfkill 拦下,再 unblock 也不会重试 → 报「未生效」(假失败)。 */
bt_ctl_execute("on", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "已复查") != NULL);
CHECK(bt_powered_now(1500) == 1);
{ /* 两个节点都必须被解 block(只写首个会让另一个仍 block) */
int all_unblocked = 1;
for (int i = 0; i < 2; i++) {
char f[300], v[8] = "1";
snprintf(f, sizeof f, "%s/rfkill/rfkill%d/soft", T, i ? 3 : 0);
FILE *fp = fopen(f, "r");
if (fp) { if (fscanf(fp, "%7s", v) != 1) v[0] = 0; fclose(fp); }
if (strcmp(v, "0") != 0) all_unblocked = 0;
}
CHECK(all_unblocked == 1);
}
bt_ctl_execute("off", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(bt_powered_now(1500) == 0);
{ /* 关也要写全部节点 */
int all_blocked = 1;
for (int i = 0; i < 2; i++) {
char f[300], v[8] = "";
snprintf(f, sizeof f, "%s/rfkill/rfkill%d/soft", T, i ? 3 : 0);
FILE *fp = fopen(f, "r");
if (fp) { if (fscanf(fp, "%7s", v) != 1) v[0] = 0; fclose(fp); }
if (strcmp(v, "1") != 0) all_blocked = 0;
}
CHECK(all_blocked == 1);
}
/* 反向验证:桩确实会在 blocked 时拒绝 up(否则上面的断言是空转) */
{ char f[300]; snprintf(f, sizeof f, "%s/rfkill/rfkill0/soft", T); wf(f, "1\n");
snprintf(f, sizeof f, "%s/rfkill/rfkill3/soft", T); wf(f, "1\n");
char out[128]; snprintf(out, sizeof out, "%s/hciconfig hci0 up 2>&1", sb_hc);
runcmd_res_t rr; run_cmd_ex(out, sizeof out, 800, out, &rr);
CHECK(rr.status != RUNCMD_OK); }
{ char f[300]; snprintf(f, sizeof f, "%s/bt/hci0/power", T); wf(f, "off\n");
for (int i = 0; i < 2; i++) { snprintf(f, sizeof f, "%s/rfkill/rfkill%d/soft", T, i ? 3 : 0); wf(f, "0\n"); } }
printf("== 预算上界:命令卡死时写操作必须落在客户端 1500ms IPC 窗口内(r1 评审 P1) ==\n");
{ /* connect:bluetoothctl 卡住 5s(耳机没上电的真实形态)→ 总耗时必须 < 1450ms */
long t0 = mono_ms();
bt_ctl_execute("connect", "CC:CC:CC:CC:CC:CC", &r);
long dt = mono_ms() - t0;
printf(" budget-connect = %ld ms (code=%d)\n", dt, r.code);
CHECK(dt < 1450);
CHECK(r.code == MEOW_ERR_NODEV); /* 未连上就必须报未生效 */
CHECK(strstr(r.text, "未生效") != NULL);
}
{ long t0 = mono_ms();
bt_ctl_execute("pair", "CC:CC:CC:CC:CC:CC", &r);
long dt = mono_ms() - t0;
printf(" budget-pair = %ld ms (code=%d)\n", dt, r.code);
CHECK(dt < 1450);
CHECK(r.code == MEOW_ERR_NODEV);
}
{ /* bt on:hciconfig 卡住 5s → 同样必须 < 1450ms(旧实现实测 2424ms) */
char f[300]; snprintf(f, sizeof f, "%s/state/hang_hciconfig", T); wf(f, "1\n");
long t0 = mono_ms();
bt_ctl_execute("on", NULL, &r);
long dt = mono_ms() - t0;
printf(" budget-bt-on = %ld ms (code=%d)\n", dt, r.code);
CHECK(dt < 1450);
CHECK(r.code == MEOW_ERR_NODEV); /* 卡住未生效:如实报 */
unlink(f);
}
{ /* 熔断:连续 3 次超时后,第 4 次必须**立刻**拒绝、不再 fork(防 55 个挂死 bluetoothctl
* 吃光进程槽的事故)。冷却 30s,测试里手工复位继续后续用例。 */
CHECK(g_bt_tmo_streak >= BT_TMO_STREAK_MAX);
long t0 = mono_ms();
bt_ctl_execute("connect", "CC:CC:CC:CC:CC:CC", &r);
long dt = mono_ms() - t0;
printf(" circuit-breaker 4th = %ld ms, code=%d\n", dt, r.code);
CHECK(dt < 100); /* 没再起子进程 */
CHECK(r.code == MEOW_ERR_NODEV);
CHECK(strstr(r.text, "熔断中") != NULL);
g_bt_tmo_streak = 0; /* 测试专用复位(生产靠 30s 冷却+成功清零) */
g_bt_tmo_until = 0;
}
{ /* 恢复:正常 on,供后面的用例使用;同时验证「非超时返回会清零熔断计数」 */
bt_ctl_execute("on", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(g_bt_tmo_streak == 0);
}
printf("== bt connect:成功路径以 hcitool con 复查 ==\n");
bt_ctl_execute("connect", "33:DE:2E:21:AD:30", &r); bt_ctl_execute("connect", "33:DE:2E:21:AD:30", &r);
CHECK(r.code == MEOW_OK); CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "Connected") != NULL); CHECK(strstr(r.text, "已连接") != NULL);
CHECK(strstr(r.text, "hcitool con") != NULL);
CHECK(bt_acl_has_tri("33:DE:2E:21:AD:30", 1500) == 1);
printf("== bt disconnect:成功路径以 hcitool con 复查 ==\n");
bt_ctl_execute("disconnect", "33:DE:2E:21:AD:30", &r); bt_ctl_execute("disconnect", "33:DE:2E:21:AD:30", &r);
CHECK(r.code == MEOW_OK); CHECK(r.code == MEOW_OK);
bt_ctl_execute("pair", "33:DE:2E:21:AD:30", &r); CHECK(strstr(r.text, "已断开") != NULL);
CHECK(bt_acl_has_tri("33:DE:2E:21:AD:30", 1500) == 0);
printf("== bt connect 失败:bluetoothctl 报错 + 无 ACL → ERR_NODEV(旧实现恒报成功) ==\n");
bt_ctl_execute("connect", "AA:AA:AA:AA:AA:AA", &r);
CHECK(r.code == MEOW_ERR_NODEV);
CHECK(strstr(r.text, "未生效") != NULL);
CHECK(strstr(r.text, "br-connection-refused") != NULL); /* 失败原因来自 stderr/stdout 片段 */
printf("== bt disconnect 假成功:命令 exit 0 但 ACL 仍在 → 仍报 ERR_NODEV ==\n");
/* BB:* 的 disconnect 桩打印成功但不删 ACL(模拟「命令说成功、链路其实还在」)。 */
snprintf(p, sizeof p, "%s/state/conn_BB:BB:BB:BB:BB:BB", T); wf(p, "yes\n");
snprintf(p, sizeof p, "%s/state/paired_BB:BB:BB:BB:BB:BB", T); wf(p, "yes\n");
bt_ctl_execute("disconnect", "BB:BB:BB:BB:BB:BB", &r);
CHECK(r.code == MEOW_ERR_NODEV);
CHECK(strstr(r.text, "仍有该 ACL") != NULL);
bt_ctl_execute("disconnect", "AA:AA:AA:AA:AA:AA", &r); /* 本来就没 ACL:真态=已断开 */
CHECK(r.code == MEOW_OK); CHECK(r.code == MEOW_OK);
bt_ctl_execute("unpair", "33:DE:2E:21:AD:30", &r); CHECK(strstr(r.text, "已断开") != NULL);
bt_ctl_execute("connect", "11:22:33:44:55:66", &r);
CHECK(r.code == MEOW_OK); CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "Connected") != NULL); /* remove 桩回 Connected: yes */
printf("== bt pair/unpair:以 bluetoothctl info Paired: 复查 ==\n");
bt_ctl_execute("pair", "11:22:33:44:55:66", &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "已配对") != NULL);
CHECK(bt_info_flag("11:22:33:44:55:66", "Paired", 1500) == 1);
bt_ctl_execute("unpair", "11:22:33:44:55:66", &r); /* remove → info 查不到也算解除 */
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "已解除配对") != NULL);
CHECK(bt_info_flag("11:22:33:44:55:66", "Paired", 1500) == -1);
bt_ctl_execute("pair", "AA:AA:AA:AA:AA:AA", &r);
CHECK(r.code == MEOW_ERR_NODEV);
CHECK(strstr(r.text, "未生效") != NULL);
bt_ctl_execute("connect", "", &r); bt_ctl_execute("connect", "", &r);
CHECK(r.code == MEOW_ERR_INVAL); /* 缺 mac → INVAL */ CHECK(r.code == MEOW_ERR_INVAL); /* 缺 mac → INVAL */
printf("== bt scan on/off(写级;桩 bluetoothctl 现在被 scan 真正调用) ==\n");
bt_ctl_execute("scan", NULL, &r); bt_ctl_execute("scan", NULL, &r);
CHECK(r.code == MEOW_OK); /* scan on(默认) */ CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "scan on") != NULL); CHECK(strstr(r.text, "scan on") != NULL);
bt_ctl_execute("scan", "off", &r); bt_ctl_execute("scan", "off", &r);
CHECK(r.code == MEOW_OK); CHECK(r.code == MEOW_OK);
printf("== bt_ctl_execute 读级(connected/a2dp/devices/status,权威) ==\n");
bt_ctl_execute("connected", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30") != NULL);
CHECK(strstr(r.text, "Pudding") != NULL);
bt_ctl_execute("a2dp", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "codec=SBC") != NULL);
CHECK(strstr(r.text, "rate=44100") != NULL);
CHECK(strstr(r.text, "acquired=1") != NULL);
CHECK(strstr(r.text, "role=src") != NULL);
snprintf(p, sizeof p, "%s/bt_scan.txt", T); wf(p, /* 重写(bt scan on 曾覆盖) */
"[NEW] Device 33:DE:2E:21:AD:30 Pudding\n[NEW] Device AA:BB:CC:DD:EE:FF Earbuds\n");
bt_ctl_execute("devices", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "Earbuds") != NULL);
CHECK(strstr(r.text, "Device 33:DE:2E:21:AD:30") != NULL);
bt_ctl_execute("status", NULL, &r);
CHECK(r.code == MEOW_OK);
CHECK(strstr(r.text, "connected=2") != NULL);
printf("== bt_write_authorize(系统身份 + MeowOS 才放行写级) ==\n"); printf("== bt_write_authorize(系统身份 + MeowOS 才放行写级) ==\n");
caller_t other; memset(&other, 0, sizeof other); other.kind = CALL_OTHER; caller_t other; memset(&other, 0, sizeof other); other.kind = CALL_OTHER;
caller_t sys; memset(&sys, 0, sizeof sys); sys.kind = CALL_SYS; caller_t sys; memset(&sys, 0, sizeof sys); sys.kind = CALL_SYS;
@@ -130,6 +716,8 @@ int main(void)
CHECK(bt_write_authorize(&sys, 0, "connect") == MEOW_ERR_PERM); /* 系统+hiby → 拒 */ CHECK(bt_write_authorize(&sys, 0, "connect") == MEOW_ERR_PERM); /* 系统+hiby → 拒 */
CHECK(bt_write_authorize(&other, 1, "status") == MEOW_OK); /* 读级任意 → 放 */ CHECK(bt_write_authorize(&other, 1, "status") == MEOW_OK); /* 读级任意 → 放 */
(void)cmd;
{ char rm[320]; snprintf(rm, sizeof rm, "rm -rf '%s'", T); (void)!system(rm); } /* 退出即清,保证可重复 */
if (g_fail) { printf("\nBT_CTL TEST: %d FAIL\n", g_fail); return 1; } if (g_fail) { printf("\nBT_CTL TEST: %d FAIL\n", g_fail); return 1; }
printf("\nBT_CTL TEST: ALL PASS\n"); printf("\nBT_CTL TEST: ALL PASS\n");
return 0; return 0;
+73 -25
View File
@@ -6,7 +6,7 @@ set -u
cd "$(dirname "$0")" cd "$(dirname "$0")"
BIN="${1:-./meow_netmgrd_host}" BIN="${1:-./meow_netmgrd_host}"
Q="${QEMU:-}" Q="${QEMU:-}"
T=$(mktemp -d /tmp/netmgrdtest.XXXXXX) T=$(mktemp -d "${TMPDIR:-/tmp}/netmgrdtest.XXXXXX")
trap 'kill $DPID 2>/dev/null; rm -rf "$T"' EXIT trap 'kill $DPID 2>/dev/null; rm -rf "$T"' EXIT
PASS=0; FAIL=0 PASS=0; FAIL=0
ck() { # ck <名> <期望子串> <实际> ck() { # ck <名> <期望子串> <实际>
@@ -97,16 +97,29 @@ exit 0
EOF EOF
cat > "$T/bluealsa-cli" <<'EOF' cat > "$T/bluealsa-cli" <<'EOF'
#!/bin/sh #!/bin/sh
if [ "${1:-}" = "a2dp" ]; then # v4.1.1 实际命令表:list-services/list-pcms/status/info/codec/volume/...(没有 a2dp 子命令)
echo "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc:" case "${1:-}" in
echo " Type: A2DP Source" list-pcms)
echo " Codec: SBC" # 真机路径带末端段,且结尾字面量不可依赖(首连实测是 /a2dpsrc/sink):
echo " Sampling: 44100 Hz" # /org/bluealsa/hci0/dev_XX/a2dpsrc/sink
echo " Channels: 2" echo "/org/bluealsa/hci0/dev_33_DE_2E_21_AD_30/a2dpsrc/sink";;
echo " Bits per Sample: 16" info)
echo " State: RUNNING" # 真机 v4.1.1 原文(M50 2026-11-11 18:26 抓,PCM 被 audiod 撑住时);此处取「正在传音频」态
echo " Acquired: yes" echo "Device: /org/bluez/hci0/dev_33_DE_2E_21_AD_30"
fi echo "Sequence: 3"
echo "Transport: A2DP-source"
echo "Mode: sink"
echo "Running: true"
echo "Format: S16_LE"
echo "Channels: 2"
echo "Sampling: 48000 Hz"
echo "Available codecs: SBC AAC"
echo "Selected codec: AAC"
echo "Delay: 17.2 ms"
echo "SoftVolume: false"
echo "Volume: L: 127 R: 127"
echo "Muted: L: false R: false";;
esac
exit 0 exit 0
EOF EOF
cat > "$T/ip" <<'EOF' cat > "$T/ip" <<'EOF'
@@ -179,22 +192,32 @@ OUT=$(env $ENVS $Q "$T/meow_net" bt connected 2>&1)
ck T5e "Device 33:DE:2E:21:AD:30 Pudding" "$OUT" ck T5e "Device 33:DE:2E:21:AD:30 Pudding" "$OUT"
ck T5e2 "Device 11:22:33:44:55:66 Speaker" "$OUT" ck T5e2 "Device 11:22:33:44:55:66 Speaker" "$OUT"
# ---- T5f: bt a2dp(P1:bluealsa-cli a2dp list → codec/rate/acquired/role)---- # ---- T5f: bt a2dp(M49:bluealsa-cli list-pcms → info <path> → codec/rate/acquired/role)----
# v4.1.1 没有 `a2dp list` 子命令,桩只认 list-pcms/info;acquired 由 `Running: true` 推出。
OUT=$(env $ENVS $Q "$T/meow_net" bt a2dp 2>&1) OUT=$(env $ENVS $Q "$T/meow_net" bt a2dp 2>&1)
ck T5f "codec=SBC" "$OUT" ck T5f "A2DP 33:DE:2E:21:AD:30" "$OUT"
ck T5f2 "rate=44100" "$OUT" ck T5f2 "codec=AAC" "$OUT" # 真机 Selected codec(不是 Available codecs 列表)
ck T5f3 "acquired=1" "$OUT" ck T5f3 "rate=48000 Hz" "$OUT"
ck T5f4 "role=src" "$OUT" ck T5f4 "channels=2" "$OUT"
ck T5f5 "bits=16" "$OUT" # 从 Format: S16_LE 推出
ck T5f6 "acquired=1" "$OUT"
ck T5f7 "role=src" "$OUT"
# ---- T5g: bt devices(P1:netmgrd 权威扫描缓存)---- # ---- T5g: bt devices(P1:netmgrd 权威扫描缓存;M49:实时 [NEW] 行带 ANSI ESC 也要能解析)----
cat > "$T/bt_scan.txt" <<'EOF' # 真机 /tmp/meow_bt_scan.txt 的实时行以 ESC[0;94m[bluetooth]ESC[0m# \rESC[K[001 ESC[0;92m 002NEW 001 ESC[0m 002] 开头
[NEW] Device 33:DE:2E:21:AD:30 Pudding { printf '%s\n' 'Waiting to connect to bluetoothd...'
[NEW] Device AA:BB:CC:DD:EE:FF Earbuds printf '\033[0;94m[bluetooth]\033[0m# \r\033[K[\001\033[0;92m\002NEW\001\033[0m\002] Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63\n'
[DEL] Device 00:11:22:33:44:55 printf '\033[0;94m[bluetooth]\033[0m# scan off\n'
EOF printf '\033[0;94m[bluetooth]\033[0m# devices\n'
printf 'Device AA:BB:CC:DD:EE:FF Earbuds\n'
printf '[DEL] Device 00:11:22:33:44:55\n'
} > "$T/bt_scan.txt"
OUT=$(env $ENVS $Q "$T/meow_net" bt devices 2>&1) OUT=$(env $ENVS $Q "$T/meow_net" bt devices 2>&1)
ck T5g "Device 33:DE:2E:21:AD:30 Pudding" "$OUT" ck T5g "Device 79:85:D6:7B:73:63 79-85-D6-7B-73-63" "$OUT" # 实时 ANSI 行(旧实现全丢)
ck T5g2 "Device AA:BB:CC:DD:EE:FF Earbuds" "$OUT" ck T5g2 "Device AA:BB:CC:DD:EE:FF Earbuds" "$OUT" # 尾部 devices 块
if echo "$OUT" | grep -q "00:11:22:33:44:55"; then
FAIL=$((FAIL+1)); echo "FAIL T5g3 [DEL] 行被误当成附近设备: $OUT"
else PASS=$((PASS+1)); echo "PASS T5g3 [DEL] 不算附近设备"; fi
# ---- T5h: bt 写级(connect/pair 等)在宿主 CLI=CALL_OTHER → 拒(仅系统身份+MeowOS 放行,见 T6)---- # ---- T5h: bt 写级(connect/pair 等)在宿主 CLI=CALL_OTHER → 拒(仅系统身份+MeowOS 放行,见 T6)----
OUT=$(env $ENVS $Q "$T/meow_net" bt connect 33:DE:2E:21:AD:30 2>&1); RC=$? OUT=$(env $ENVS $Q "$T/meow_net" bt connect 33:DE:2E:21:AD:30 2>&1); RC=$?
@@ -360,8 +383,13 @@ OUT=$(env $ENVS $Q "$T/meow_net" status 2>&1)
ck T18d "rssi=-45" "$OUT" ck T18d "rssi=-45" "$OUT"
# ---- T14: DNS 代理 wire 实测(守护 B:高端口 + 桩上游 + 全局 deny;测 deny/forward/cache)---- # ---- T14: DNS 代理 wire 实测(守护 B:高端口 + 桩上游 + 全局 deny;测 deny/forward/cache)----
# 需要「非回环的本机地址」当上游:daemon 明确把 127.0.0.1 排除在上游之外(resolv_backup_apply
# 防环路,meow_netmgrd.c:2588)。宿主拿不到这样的地址(例如 Android/Termux 的 hostname 不支持
# -I)时这套 wire 测试无法成立 —— 显式 SKIP,而不是留一堆假失败。
HOSTIP=$(hostname -I 2>/dev/null | awk '{print $1}') HOSTIP=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$HOSTIP" ] || HOSTIP="127.0.0.1" if [ -z "$HOSTIP" ] || [ "$HOSTIP" = "127.0.0.1" ]; then
echo "SKIP T14 DNS wire(本机无 hostname -I 或只有回环地址;daemon 拒绝把 127.0.0.1 当上游以防环路)"
else
cat > "$T/policy_dns" <<'EOF' cat > "$T/policy_dns" <<'EOF'
global domain=-ads.example.com dns.filter_aaaa=1 global domain=-ads.example.com dns.filter_aaaa=1
EOF EOF
@@ -422,6 +450,26 @@ ck T14e "deny=1" "$OUT"
kill $D2PID 2>/dev/null; wait $D2PID 2>/dev/null kill $D2PID 2>/dev/null; wait $D2PID 2>/dev/null
# 恢复守护 A 的 MEOW_RESOLV(其未被 DNS 触碰,此处确认) # 恢复守护 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"; } 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"; }
fi # T14 上游能力守卫
# ---- 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 "=============================="
echo "PASS=$PASS FAIL=$FAIL" echo "PASS=$PASS FAIL=$FAIL"
+175
View File
@@ -0,0 +1,175 @@
/* 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"
"catf() { [ -f \"$1\" ] || return 0; while IFS= read -r l || [ -n \"$l\" ]; do echo \"$l\"; done < \"$1\"; }\n"
"echo \"$@\" >> \"$D/log\"\n"
"case \"$1\" in\n"
" status)\n"
" m=completed\n"
" if [ -f \"$D/status_mode\" ]; then IFS= read -r m < \"$D/status_mode\"; fi\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 catf \"$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"
" v=\"\"\n"
" if [ -f \"$D/save_fail_once\" ]; then IFS= read -r v < \"$D/save_fail_once\"; fi\n"
" if [ \"$v\" = \"1\" ]; then echo \"\" > \"$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)
{
/* 宿主临时目录:Termux/Android 上 /tmp 不可写 → 跟随 $TMPDIR */
const char *tmp = getenv("TMPDIR");
if (!tmp || !tmp[0]) tmp = "/tmp";
snprintf(g_dir, sizeof g_dir, "%s/mnwifi.XXXXXX", tmp);
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;
{
static 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 -1
View File
@@ -24,7 +24,10 @@ if [ "$1" = "host" ]; then
exit 0 exit 0
fi fi
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meow_powermgr.c -o meow_powermgr # meow_time32.c:独立 o32 time32 取时/睡眠(powermgr 不链 meow_ui.a);采样时间戳与
# usleep 轮询恢复真实毫秒。
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" -DMEOW_TIME32_LIBC_OVERRIDES \
meow_powermgr.c ../sdk/meow_time32.c -o meow_powermgr
echo "== meow_powermgr (mipsel static) ==" echo "== meow_powermgr (mipsel static) =="
ls -la meow_powermgr ls -la meow_powermgr
md5sum meow_powermgr md5sum meow_powermgr
Binary file not shown.
+4 -1
View File
@@ -22,7 +22,10 @@ if [ "$1" = "host" ]; then
exit 0 exit 0
fi fi
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meow_svc_perf.c -o meow_svc_perf # meow_time32.c:独立 o32 time32 取时/睡眠(svc_perf 不链 meow_ui.a);采样间隔/卡死快照
# 冷却恢复真实毫秒。
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" -DMEOW_TIME32_LIBC_OVERRIDES \
meow_svc_perf.c ../sdk/meow_time32.c -o meow_svc_perf
echo "== meow_svc_perf (mipsel static) ==" echo "== meow_svc_perf (mipsel static) =="
ls -la meow_svc_perf ls -la meow_svc_perf
md5sum meow_svc_perf md5sum meow_svc_perf
Binary file not shown.
+4 -1
View File
@@ -21,7 +21,10 @@ if [ "$1" = "host" ]; then
exit 0 exit 0
fi fi
"$CC" -Os -Wall -Wextra -static meow_svcd.c -o meow_svcd # meow_time32.c:独立 o32 time32 取时/睡眠(svcd 不链 meow_ui.a);LIBC_OVERRIDES 在静态
# 链接期接管 clock_gettime/time/usleep/nanosleep,修 musl time64 头与 time32 实现的 ABI 错位。
"$CC" -Os -Wall -Wextra -static -DMEOW_TIME32_LIBC_OVERRIDES \
meow_svcd.c ../sdk/meow_time32.c -o meow_svcd
echo "== meow_svcd (mipsel static) ==" echo "== meow_svcd (mipsel static) =="
ls -la meow_svcd ls -la meow_svcd
md5sum meow_svcd md5sum meow_svcd
Binary file not shown.
+4 -1
View File
@@ -21,7 +21,10 @@ if [ "$1" = "host" ]; then
exit 0 exit 0
fi fi
"$CC" -Os -Wall -Wextra -static meow_sysui.c -o meow_sysui # meow_time32.c:独立 o32 time32 取时/睡眠(sysui 不链 meow_ui.a);LIBC_OVERRIDES 在静态
# 链接期接管 clock_gettime/time/usleep/nanosleep,修 musl time64 头与 time32 实现的 ABI 错位。
"$CC" -Os -Wall -Wextra -static -DMEOW_TIME32_LIBC_OVERRIDES \
meow_sysui.c ../sdk/meow_time32.c -o meow_sysui
echo "== meow_sysui (mipsel static) ==" echo "== meow_sysui (mipsel static) =="
ls -la meow_sysui ls -la meow_sysui
md5sum meow_sysui md5sum meow_sysui
Binary file not shown.
+7 -3
View File
@@ -8,16 +8,20 @@ set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
ROOT=../.. ROOT=../..
CC="$ROOT/.tools/zig-mipsel-cc" 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 if [ "$1" = "host" ]; then
gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \ gcc -Wall -Wextra -Os -I"$INC" -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
test_wd_fw.c -o test_wd_fw -lm -Wno-unused-variable test_wd_fw.c ../sdk/meow_time32.c -o test_wd_fw -lm
./test_wd_fw ./test_wd_fw
exit $? exit $?
fi fi
echo "== meow_watchdog (mipsel static) ==" echo "== meow_watchdog (mipsel static) =="
"$CC" -Os -Wall -Wextra -static meow_watchdog.c -o meow_watchdog # meow_time32.c:独立 time32 取时/睡眠(watchdog 不链 meow_ui.a)。LIBC_OVERRIDES 让
# 未逐个改写的 libc 取时/睡眠入口也一并修正(静态链接,本对象强定义覆盖 libc 归档成员)。
"$CC" -Os -Wall -Wextra -static -I"$INC" -DMEOW_TIME32_LIBC_OVERRIDES \
meow_watchdog.c ../sdk/meow_time32.c -o meow_watchdog
ls -la meow_watchdog ls -la meow_watchdog
md5sum meow_watchdog md5sum meow_watchdog
file meow_watchdog file meow_watchdog
Binary file not shown.
+288 -54
View File
@@ -12,14 +12,18 @@
* run — short press (<3s release) = toggle screen off/on (brightness 0 + * run — short press (<3s release) = toggle screen off/on (brightness 0 +
* FBIOBLANK(4) / FBIOBLANK(0)+brightness restore, with a force re-pan to * FBIOBLANK(4) / FBIOBLANK(0)+brightness restore, with a force re-pan to
* buffer0 on wake). * buffer0 on wake).
* - M2.24 全面按键接管(KEY_CONTROL_DESIGN):应用运行期所有物理键由本进程统一消费, * - M2.24/M40 全面按键接管(KEY_CONTROL_DESIGN):应用运行期所有物理键由本进程统一消费,
* 应用绝不碰物理键。 * 应用绝不碰物理键。
* 电源单击(<2s) -> 息屏/亮屏(沿用 M2.22); * 电源单击(松开时 held < power.longpress_menu_ms,缺省 3000)-> 息屏/亮屏(沿用 M2.22);
* 电源长按 ≥2s -> 写 /tmp/meow_power_menu,由 launcher SIGSTOP app 后弹统一电源菜单 * 电源长按 ≥power.longpress_menu_ms -> 写 /tmp/meow_power_menu,由 launcher SIGSTOP app
* (launcher 在 waitpid 轮询中消费;菜单期该进程靠 /tmp/meow_power_menu_active * 后弹统一电源菜单(launcher 在 waitpid 轮询中消费;菜单期该进程靠
* 让位,绝不与 launcher 抢屏/抢键);launcher 画菜单,本进程不自绘。 * /tmp/meow_power_menu_active 让位,绝不与 launcher 抢屏/抢键);
* 电源长按 ≥8s -> 强制关机兜底(仅当菜单未生效/launcher 未接管)。 * launcher 画菜单,本进程不自绘。
* 音量±(115/114) -> 写真实 DAC(CS43198 amixer numid=1/2)+ 持久化 settings.conf(同 launcher 同源)。 * 电源长按 ≥power.forceoff_ms(缺省 8000)-> 强制关机兜底(仅当菜单未被 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 内忽略音量键边沿 * 插孔消抖:/sys/class/switch/{headset,balance} 变化后 800ms 内忽略音量键边沿
* (插拔电气瞬态会在 ADC 键上产生幽灵按下,真机实证 2026-09-01)。 * (插拔电气瞬态会在 ADC 键上产生幽灵按下,真机实证 2026-09-01)。
* 播放/上下曲(164/165/163/19) -> 捕获 + 日志 + 消费,不路由(媒体会话落地后转活跃会话)。 * 播放/上下曲(164/165/163/19) -> 捕获 + 日志 + 消费,不路由(媒体会话落地后转活跃会话)。
@@ -28,6 +32,9 @@
* (true|1|yes)。判定为 SDK 框架 App -> 音量/媒体键让位 SDK 输入层(meow_ui_input.c 已消化, * (true|1|yes)。判定为 SDK 框架 App -> 音量/媒体键让位 SDK 输入层(meow_ui_input.c 已消化,
* 防同一次按键双步进);电源仍由本进程兜底(SDK 对电源是 no-op)。裸自绘 App(framework=0/ * 防同一次按键双步进);电源仍由本进程兜底(SDK 对电源是 no-op)。裸自绘 App(framework=0/
* 无 framework)或 app.json 不可读 -> 音量/媒体键仍由本进程兜底(不造成功能真空)。 * 无 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 * Exits when the app process disappears (kill(pid,0) -> ESRCH) or on
* SIGTERM from the parent. * SIGTERM from the parent.
* *
@@ -50,8 +57,14 @@
#include <sys/mman.h> #include <sys/mman.h>
#include <poll.h> #include <poll.h>
#include <sys/syscall.h> #include <sys/syscall.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/time.h>
#include <stdint.h> #include <stdint.h>
#include <time.h> #include <time.h>
#include "meow_ipc.h" /* MSG_AUDIO_VOLUME/MSG_ACK/帧格式(音量收编 v2:首选 audiod 权威) */
#include "../sdk/meow_time32.h" /* o32 time32 取时/睡眠(watchdog 不链 meow_ui.a) */
#include "wd_power.h" /* 电源阈值/边沿纯判定(宿主单测同源) */
/* device-verified: VSCREENINFO=0x4600, FSCREENINFO=0x4602 (do not swap) */ /* device-verified: VSCREENINFO=0x4600, FSCREENINFO=0x4602 (do not swap) */
#define FBIOGET_VSCREENINFO 0x4600 #define FBIOGET_VSCREENINFO 0x4600
@@ -85,11 +98,10 @@
#define KEY_NEXTSONG 163 #define KEY_NEXTSONG 163
#define KEY_PREVIOUS 19 #define KEY_PREVIOUS 19
#define BACKLIGHT "/sys/class/backlight/backlight_pwm0/brightness" #define BACKLIGHT "/sys/class/backlight/backlight_pwm0/brightness"
/* M2.24 全面按键接管:电源单击(<2s)=息屏/亮屏;长按≥2s=通知 launcher 弹统一电源菜单; /* M2.24 全面按键接管:电源单击(<power.longpress_menu_ms)=息屏/亮屏;长按≥该值=通知
* ≥8s=强制关机兜底(仅当 launcher 未接管菜单时)。音量± = 写真实 DAC;播放/上下曲 = 抓消费。 */ * launcher 弹统一电源菜单;≥power.forceoff_ms=强制关机兜底(launcher 未接管菜单时)。
#define PWR_TOGGLE_MS 2000 /* 短按上限:<2s 松开=单击(镜像 launcher 单单击语义) */ * 阈值不硬编码:启动时读 settings.conf(缺省 3000/8000),与 launcher 主屏同一语义。
#define PWR_MENU_MS 2000 /* 长按≥2s -> 写 /tmp/meow_power_menu 请 launcher 弹菜单 */ * 音量± = 写真实 DAC;播放/上下曲 = 抓消费。 */
#define PWR_FORCE_MS 8000 /* 长按≥8s -> 强制关机(launcher 未接管时的兜底) */
#define PWR_MENU_MARKER "/tmp/meow_power_menu" /* watchdog->launcher 菜单请求 */ #define PWR_MENU_MARKER "/tmp/meow_power_menu" /* watchdog->launcher 菜单请求 */
#define PWR_MENU_ACTIVE "/tmp/meow_power_menu_active" /* launcher 画菜单中,watchdog 让位 */ #define PWR_MENU_ACTIVE "/tmp/meow_power_menu_active" /* launcher 画菜单中,watchdog 让位 */
#define MEOW_SETTINGS_FILE "/usr/data/meow/settings.conf" /* 音量持久化(与 launcher settings.conf 同源) */ #define MEOW_SETTINGS_FILE "/usr/data/meow/settings.conf" /* 音量持久化(与 launcher settings.conf 同源) */
@@ -140,11 +152,14 @@ static int read_state_file(void)
* stderr when unset (e.g. no TF card at launch time). */ * stderr when unset (e.g. no TF card at launch time). */
static int wd_log_fd = -1; static int wd_log_fd = -1;
/* 单调毫秒:**必须**走 meow_time32(o32 内核 time32 取时)。
* zig musl 的 time64 ABI 在 mipsel 上自相矛盾:直接 clock_gettime() 时结构按 16 字节写、
* libc 只写 8 字节 time32,tv_sec 高 32 位会读成 nsec → held 变天文数字,每次单击都被
* 判成长按并立刻弹菜单(真机日志 `power hold 12392415168036864000ms`,反汇编证实只有
* 4263/无 4403)。meow_time32.c 不依赖 meow_ui.a,静态链入即可修正。 */
static unsigned long long now_ms(void) static unsigned long long now_ms(void)
{ {
struct timespec ts; return (unsigned long long)meow_time32_monotonic_ms();
clock_gettime(CLOCK_MONOTONIC, &ts);
return (unsigned long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
} }
static void wd_log_init(void) static void wd_log_init(void)
@@ -185,16 +200,47 @@ static int g_fw_app = 0;
/* ---- M2.22(A)+ M2.24 电源键接管:息屏/亮屏 + 长按菜单/强关 ---- */ /* ---- M2.22(A)+ M2.24 电源键接管:息屏/亮屏 + 长按菜单/强关 ---- */
static int wd_fbfd = -1; /* fb0 fd(main 里打开,供 FBIOBLANK/FBIOPAN 用) */ 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) */
/* 电源阈值(启动时读 settings.conf;缺省 3000/8000,与 launcher 主屏一致)。 */
static unsigned long long wd_pwr_longpress_ms = WD_PWR_LONGPRESS_DEFAULT_MS;
static unsigned long long wd_pwr_forceoff_ms = WD_PWR_FORCEOFF_DEFAULT_MS;
static int pwr_down = 0; /* 电源当前按住 */ static int pwr_down = 0; /* 电源当前按住 */
static unsigned long long pwr_down_ms = 0; static unsigned long long pwr_down_ms = 0;
static int pwr_woke = 0; /* 按下时屏已黑 → 本次只作唤醒,松开不再 toggle */ static int pwr_woke = 0; /* 按下时屏已黑 → 本次只作唤醒,松开不再 toggle */
static int pwr_menu_notified = 0; /* 已写 /tmp/meow_power_menu 请 launcher 弹菜单 */ static int pwr_menu_notified = 0; /* 已写 /tmp/meow_power_menu 请 launcher 弹菜单 */
static int pwr_force_done = 0; /* 8s 强关已发(防重复) */ 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) static int read_brightness(void)
{ {
int fd = open(BACKLIGHT, O_RDONLY); int fd = open(wd_backlight_path(), O_RDONLY);
if (fd < 0) return -1; if (fd < 0) return -1;
char buf[16]; char buf[16];
int n = read(fd, buf, sizeof buf - 1); int n = read(fd, buf, sizeof buf - 1);
@@ -208,24 +254,40 @@ static void write_brightness(int val)
{ {
char buf[8]; char buf[8];
int l = snprintf(buf, sizeof buf, "%d\n", val); 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 (fd < 0) return;
if (write(fd, buf, l) < 0) wd_log("backlight write %d: %s", val, strerror(errno)); if (write(fd, buf, l) < 0) wd_log("backlight write %d: %s", val, strerror(errno));
close(fd); 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), /* M6.23:亮屏恢复亮度以**用户设置**为准(/usr/data/meow/settings.conf 的 display.brightness),
* 而非 wd_saved_bri 快照——该快照可能被污染:panel OFF 前 read_brightness() 可能读到 launcher * 而非 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 / * 默认 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 * 与 launcher meow_settings_brightness_clamped() 同源(同文件、同 clamp:下限 display.brightness_min
* 默认 1=极暗但可见、上限 100=防闪屏);本进程自身 wd_vol_load 早已读该文件,不引入 launcher 全量 * 默认 1=极暗但可见、上限 100=防闪屏);本进程自身 wd_vol_load 早已读该文件,不引入 launcher 全量
* 依赖,保持 watchdog 独立性。返回 clamped 值;settings 文件不可读(S70 早启动 /usr/data 未就绪) * 依赖,保持 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) 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) meow_time32_sleep_us(WD_SETTINGS_GAP_MS * 1000);
}
if (!f) return -1; if (!f) return -1;
int b = 101; /* display.brightness 默认(与 launcher 设置表同值,键缺失时 clamp→100) */ int b = 101; /* display.brightness 默认(与 launcher 设置表同值,键缺失时 clamp→100) */
int mn = 1; /* display.brightness_min 默认(极暗但可见;键缺失时保底) */ int mn = 1; /* display.brightness_min 默认(极暗但可见;键缺失时保底) */
@@ -247,6 +309,42 @@ static int wd_settings_brightness(void)
return b; return b;
} }
/* 读电源阈值(power.longpress_menu_ms / power.forceoff_ms,缺省 3000/8000)。
* 与 wd_settings_brightness 同款重试窗口;文件不可读时**保留缺省**(绝不退回 2000——
* 旧硬编码会让 App 内 2s 出菜单、主屏 3s,且 PWR_TOGGLE==PWR_MENU 时「长按不出菜单」
* 的区间不存在)。解析后经 pure helper 清洗(非正数→缺省;forceoff≤longpress→抬 1s)。 */
static void wd_power_settings_load(void)
{
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) meow_time32_sleep_us(WD_SETTINGS_GAP_MS * 1000);
}
if (!f) {
wd_pwr_resolve_thresholds(0, 0, &wd_pwr_longpress_ms, &wd_pwr_forceoff_ms);
wd_log("power: settings unreadable -> defaults longpress=%llums forceoff=%llums",
wd_pwr_longpress_ms, wd_pwr_forceoff_ms);
return;
}
int lp = 0, fo = 0;
char buf[128];
while (fgets(buf, sizeof buf, f)) {
char *eq = strchr(buf, '=');
if (!eq) continue;
*eq = 0;
char *k = buf; int kl = (int)strlen(k);
while (kl > 0 && (k[kl-1] == ' ' || k[kl-1] == '\t')) k[--kl] = 0;
char *v = eq + 1; while (*v == ' ' || *v == '\t') v++;
if (!strcmp(k, "power.longpress_menu_ms")) lp = atoi(v);
else if (!strcmp(k, "power.forceoff_ms")) fo = atoi(v);
}
fclose(f);
wd_pwr_resolve_thresholds(lp, fo, &wd_pwr_longpress_ms, &wd_pwr_forceoff_ms);
wd_log("power: settings longpress=%llums forceoff=%llums (raw %d/%d)",
wd_pwr_longpress_ms, wd_pwr_forceoff_ms, lp, fo);
}
/* 强制 re-pan 到 buffer0:FBIOBLANK(4)->(0) 后 LCDC 扫描地址可能已复位到非 buffer0/失效 /* 强制 re-pan 到 buffer0:FBIOBLANK(4)->(0) 后 LCDC 扫描地址可能已复位到非 buffer0/失效
* 位置(launcher M2.8 Bug-3-residual 根因),而应用始终画 buffer0,必须无条件把显示翻回 * 位置(launcher M2.8 Bug-3-residual 根因),而应用始终画 buffer0,必须无条件把显示翻回
* buffer0,否则唤醒后黑屏/旧帧。 */ * buffer0,否则唤醒后黑屏/旧帧。 */
@@ -261,26 +359,53 @@ static void wd_pan_buf0_force(void)
ioctl(wd_fbfd, FBIOPAN_DISPLAY, v); 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) static void wd_panel_off(void)
{ {
int br = read_brightness(); 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); write_brightness(0);
if (wd_fbfd >= 0) ioctl(wd_fbfd, FBIOBLANK, 4); /* 面板下电 + 挂起 goodix 触摸 */ 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) static void wd_panel_on(void)
{ {
if (wd_fbfd >= 0) ioctl(wd_fbfd, FBIOBLANK, 0); if (wd_fbfd >= 0) ioctl(wd_fbfd, FBIOBLANK, 0);
/* M6.23:恢复亮度以**用户设置**为准(wd_settings_brightness),settings 不可用时才回退 /* M6.23 + M20:用户设置为唯一权威;settings 瞬时不可读用真实快照;两者都未知时
* wd_saved_bri 快照(再退默认 60)——与 launcher meow_panel_on 同语义,杜绝「亮屏恢复 60 * **不写背光**(旧代码在这里写死 60,直接覆盖 launcher 刚按 display.brightness 设好的
* 与 launcher 恢复 7 竞态 → 跳变」。 */ * 用户值 → 开机/亮屏跳 60,真机 0.8.46 实证)。取值分支见 wd_bri_decide()。 */
int b = wd_settings_brightness(); int user = wd_settings_brightness(); /* >0=用户设置;-1=不可读 */
int restore = (b > 0) ? b : (wd_saved_bri > 0 ? wd_saved_bri : 60); int cur = read_brightness(); /* 当前系统背光:判定「不写」是否安全 + 日志 */
write_brightness(restore); 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_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 完全让位。 */ /* launcher 正在画电源菜单(/tmp/meow_power_menu_active 存在)→ watchdog 完全让位。 */
@@ -297,15 +422,17 @@ static void wd_do_poweroff(void)
syscall(SYS_reboot, LINUX_REBOOT_MAGIC1, LINUX_REBOOT_MAGIC2, syscall(SYS_reboot, LINUX_REBOOT_MAGIC1, LINUX_REBOOT_MAGIC2,
LINUX_REBOOT_CMD_POWER_OFF, NULL); LINUX_REBOOT_CMD_POWER_OFF, NULL);
wd_log("sys_reboot returned: %s", strerror(errno)); /* 正常不会返回 */ wd_log("sys_reboot returned: %s", strerror(errno)); /* 正常不会返回 */
for (;;) usleep(1000000); for (;;) meow_time32_sleep_us(1000000);
} }
/* 电源键(KEY_POWER)边沿驱动:单击(短按松开 <3s)= 息屏/亮屏;按下时屏已黑 = 只唤醒。 /* 电源键(KEY_POWER)边沿驱动:单击(松开时 held < power.longpress_menu_ms,缺省 3s)= 息屏/亮屏;
* 长按(≥3s)由 wd_power_tick() 在轮询中检测 → 写 /tmp/meow_power_menu 请 launcher 弹菜单; * 按下时屏已黑 = 只唤醒。长按(≥longpress)由 wd_power_tick() 在轮询中检测 → 写
* 松开边沿不再 toggle(菜单/长按语义归 launcher 菜单,防「长按后仍 toggle」)。 */ * /tmp/meow_power_menu 请 launcher 弹菜单;松开边沿不再 toggle(菜单/长按语义归 launcher
* 菜单,防「长按后仍 toggle」)。 */
static void handle_power(int val) static void handle_power(int val)
{ {
if (wd_menu_active()) return; /* 电源菜单期:launcher 独占物理键语义 */ if (wd_menu_active()) return; /* 电源菜单期:launcher 独占物理键语义 */
if (val == 2) return; /* EV_KEY 重复值:本设备 event0 无 EV_REP,防御性忽略 */
if (val) { if (val) {
/* 按下边沿 */ /* 按下边沿 */
pwr_down = 1; pwr_down = 1;
@@ -315,28 +442,40 @@ static void handle_power(int val)
int br = read_brightness(); int br = read_brightness();
pwr_woke = (br <= 0) ? 1 : 0; /* 屏已黑:本次电源=唤醒,松开不 toggle */ pwr_woke = (br <= 0) ? 1 : 0; /* 屏已黑:本次电源=唤醒,松开不 toggle */
if (pwr_woke) wd_panel_on(); /* 立即亮屏(launcher 自动息屏唤醒路径同步醒,双保险幂等) */ if (pwr_woke) wd_panel_on(); /* 立即亮屏(launcher 自动息屏唤醒路径同步醒,双保险幂等) */
wd_log("power down (bri=%d woke=%d)", br, pwr_woke); wd_log("power down (bri=%d woke=%d longpress=%llums)", br, pwr_woke,
wd_pwr_longpress_ms);
return; return;
} }
/* 松开边沿 */ /* 松开边沿 */
if (!pwr_down) return; /* 未跟踪到按下(启动前已按住/残留):忽略,防误 toggle */ if (!pwr_down) return; /* 未跟踪到按下(启动前已按住/残留):忽略,防误 toggle */
pwr_down = 0; pwr_down = 0;
unsigned long long held = now_ms() - pwr_down_ms; unsigned long long held = now_ms() - pwr_down_ms;
if (pwr_woke) { pwr_woke = 0; wd_log("power release after wake (%llums) -> no toggle", held); return; } int br = read_brightness();
wd_pwr_release_act act = wd_pwr_release_action(pwr_woke, pwr_menu_notified, held,
wd_pwr_longpress_ms, br);
if (pwr_woke) {
pwr_woke = 0;
wd_log("power release after wake (%llums) -> no toggle", held);
return;
}
if (pwr_menu_notified) { if (pwr_menu_notified) {
/* 本次已请求电源菜单(≥3s):菜单是否画出由 launcher 负责,这里绝不 toggle。 */ /* 本次已请求电源菜单(≥longpress):菜单是否画出由 launcher 负责,这里绝不 toggle。 */
wd_log("power release after menu req (%llums) -> menu owns toggle", held); wd_log("power release after menu req (%llums) -> menu owns toggle", held);
pwr_menu_notified = 0; pwr_menu_notified = 0;
return; return;
} }
if (held >= PWR_TOGGLE_MS) { wd_log("power hold %llums -> no-op (menu/force-off in tick)", held); return; } if (act == WD_PWR_REL_NONE) {
int br = read_brightness(); /* held ≥ longpress 但 tick 还没发出菜单(轮询粒度/边界):仍按长按处理,不 toggle。 */
if (br > 0) wd_panel_off(); wd_log("power hold %llums -> no-op (menu/force-off in tick)", held);
return;
}
if (act == WD_PWR_REL_TOGGLE_OFF) wd_panel_off();
else wd_panel_on(); /* 屏已黑仍短按:亮屏 */ else wd_panel_on(); /* 屏已黑仍短按:亮屏 */
wd_log("power short-press %llums -> toggle panel (bri was %d)", held, br); wd_log("power short-press %llums -> toggle panel (bri was %d)", held, br);
} }
/* 电源长按检测(每轮询 tick 调):≥3s 写菜单请求;≥8s 强制关机兜底(仅当菜单未生效时)。 */ /* 电源长按检测(每轮询 tick 调):≥longpress 写菜单请求;≥forceoff 强制关机兜底
* (launcher 已接管菜单时顶部即让位,不会走到这里)。阈值来自 settings。 */
static void wd_power_tick(void) static void wd_power_tick(void)
{ {
/* launcher 已接管电源菜单:本进程完全让位,清空电源状态,下一手势从零开始。 */ /* launcher 已接管电源菜单:本进程完全让位,清空电源状态,下一手势从零开始。 */
@@ -347,14 +486,15 @@ static void wd_power_tick(void)
} }
if (!pwr_down) return; if (!pwr_down) return;
unsigned long long held = now_ms() - pwr_down_ms; unsigned long long held = now_ms() - pwr_down_ms;
if (held >= PWR_MENU_MS && !pwr_menu_notified) { wd_pwr_tick_act act = wd_pwr_tick_action(0, pwr_down, pwr_menu_notified, pwr_force_done,
held, wd_pwr_longpress_ms, wd_pwr_forceoff_ms);
if (act == WD_PWR_TICK_MENU) {
int fd = open(PWR_MENU_MARKER, O_WRONLY | O_CREAT | O_TRUNC, 0644); int fd = open(PWR_MENU_MARKER, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) { write(fd, "1", 1); close(fd); } if (fd >= 0) { write(fd, "1", 1); close(fd); }
pwr_menu_notified = 1; pwr_menu_notified = 1;
wd_log("power hold %llums -> %s (launcher draws menu)", held, PWR_MENU_MARKER); wd_log("power hold %llums -> %s (launcher draws menu)", held, PWR_MENU_MARKER);
} } else if (act == WD_PWR_TICK_FORCEOFF) {
if (held >= PWR_FORCE_MS && !pwr_force_done && !pwr_menu_notified) { /* 长按 ≥forceoff 仍无菜单接管(launcher 未响应/未消费)→ 强制关机兜底。 */
/* 长按 8s 仍无菜单接管(launcher 未响应/未消费)→ 强制关机兜底。 */
pwr_force_done = 1; pwr_force_done = 1;
wd_log("power hold %llums -> FORCE POWER OFF", held); wd_log("power hold %llums -> FORCE POWER OFF", held);
wd_do_poweroff(); wd_do_poweroff();
@@ -372,7 +512,7 @@ static int wd_vol_step = 1; /* 跳步 */
static void wd_vol_load(void) static void wd_vol_load(void)
{ {
wd_vol = 60; wd_vol_max = 100; wd_vol_step = 1; 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; } if (!f) { wd_log("vol: no settings, default %d", wd_vol); return; }
char buf[128]; char buf[128];
while (fgets(buf, sizeof buf, f)) { while (fgets(buf, sizeof buf, f)) {
@@ -391,13 +531,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); 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) static void wd_vol_write(int pct)
{ {
int t = 100 - pct, dac; int t = 100 - pct, dac;
if (pct <= 0) dac = 255; /* 真静音(DS1039:255=-127.5dB) */ if (pct <= 0) dac = 255; /* 真静音(DS1039:255=-127.5dB) */
else dac = t * t * 192 / 10000; /* 感知平方锥度:dB 衰减=(1-pct)^2×96dB */ 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]; char cmd[160];
snprintf(cmd, sizeof cmd, 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", "amixer -c 0 cset numid=1 %d >/dev/null 2>&1; amixer -c 0 cset numid=2 %d >/dev/null 2>&1",
@@ -406,14 +550,97 @@ static void wd_vol_write(int pct)
wd_log("DAC vol -> L/R=%d (pct %d)", dac, 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 行 —— /* M2.92+ 修复(真机实证 settings.conf 被毁):旧版 O_TRUNC 整文件重写只剩 4 行 ——
* 抹掉全部其它设置;与其它写者交错还叠出重复 audio.volume 行、丢 volume_max/step。 * 抹掉全部其它设置;与其它写者交错还叠出重复 audio.volume 行、丢 volume_max/step。
* 改为读改写单键(流式拷贝、命中行全部改写去重、缺失末尾追加、rename 原子替换), * 改为读改写单键(流式拷贝、命中行全部改写去重、缺失末尾追加、rename 原子替换),
* 与 SDK meow_ui_vol_set 同款语义。 */ * 与 SDK meow_ui_vol_set 同款语义。 */
static void wd_vol_save(void) static void wd_vol_save(void)
{ {
const char *path = wd_settings_path();
if (strcmp(path, MEOW_SETTINGS_FILE) == 0) /* 仅默认路径补目录(env 覆盖=宿主测,目录自备) */
system("mkdir -p /usr/data/meow 2>/dev/null"); system("mkdir -p /usr/data/meow 2>/dev/null");
const char *path = MEOW_SETTINGS_FILE;
char tmp[300]; char tmp[300];
snprintf(tmp, sizeof tmp, "%s.tmp", path); snprintf(tmp, sizeof tmp, "%s.tmp", path);
FILE *in = fopen(path, "r"); FILE *in = fopen(path, "r");
@@ -490,7 +717,9 @@ static int wd_jack_debounce(void)
(now_ms() - wd_jack_changed_at < (unsigned long long)WD_JACK_DEBOUNCE_MS); (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) static void wd_vol_adj(int dir)
{ {
int target = wd_vol + dir * wd_vol_step; int target = wd_vol + dir * wd_vol_step;
@@ -498,9 +727,13 @@ static void wd_vol_adj(int dir)
if (target > wd_vol_max) target = wd_vol_max; 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; } if (target == wd_vol) { wd_log("volume %s at limit (%d)", dir > 0 ? "UP" : "DOWN", wd_vol); return; }
wd_vol = target; 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_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, static void handle_touch(int immersive, int app_pid,
@@ -631,6 +864,7 @@ int main(int argc, char **argv)
setvbuf(stderr, NULL, _IONBF, 0); setvbuf(stderr, NULL, _IONBF, 0);
wd_log_init(); wd_log_init();
wd_vol_load(); /* M2.24:音量初始值读自与 launcher 同源 settings.conf */ wd_vol_load(); /* M2.24:音量初始值读自与 launcher 同源 settings.conf */
wd_power_settings_load(); /* M40:电源长按/强关阈值(缺省 3000/8000,与 launcher 主屏同源) */
/* M2.86(226):SDK 框架 App 判定(app.json framework=1)-> 音量/媒体键让位 SDK。 */ /* M2.86(226):SDK 框架 App 判定(app.json framework=1)-> 音量/媒体键让位 SDK。 */
g_fw_app = wd_framework_from_pid(app_pid); g_fw_app = wd_framework_from_pid(app_pid);
wd_log("app_pid=%d sim_tap=%d sim_swipe=%d framework=%d%s", wd_log("app_pid=%d sim_tap=%d sim_swipe=%d framework=%d%s",
@@ -696,7 +930,7 @@ int main(int argc, char **argv)
if (sim_tap && elapsed >= (long)sim_tap * 1000) { if (sim_tap && elapsed >= (long)sim_tap * 1000) {
wd_log("SIM TAP"); wd_log("SIM TAP");
kill(app_pid, SIGTERM); kill(app_pid, SIGTERM);
usleep(1000000); meow_time32_sleep_us(1000000);
kill(app_pid, SIGKILL); kill(app_pid, SIGKILL);
break; break;
} }
Binary file not shown.
+248 -4
View File
@@ -1,11 +1,15 @@
/* test_wd_fw.c — 宿主单测:meow_watchdog 的「SDK 框架 App 判定」逻辑(M2.86, issue 226)。 /* test_wd_fw.c — 宿主单测:meow_watchdog 的纯逻辑(M2.86 框架 App 判定 + M20 亮屏亮度 +
* M40 time32 取时/睡眠 + 电源阈值行为矩阵)。
* *
* 编译:#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;wd_power.h 的纯判定)。
* gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \ * gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
* test_wd_fw.c -o test_wd_fw * test_wd_fw.c ../sdk/meow_time32.c -o test_wd_fw
* (meow_watchdog.c 含仅 main 使用的静态函数,宿主去 main 后须 -Wno-unused-function 静音; * (meow_watchdog.c 含仅 main 使用的静态函数,宿主去 main 后须 -Wno-unused-function 静音;
* mips 目标构建不定义 WD_NO_MAIN,不受影响。) * mips 目标构建不定义 WD_NO_MAIN,不受影响。)
* 运行:./test_wd_fw;全 PASS -> exit 0。 * 运行:./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 #define WD_NO_MAIN
#include "meow_watchdog.c" #include "meow_watchdog.c"
@@ -24,6 +28,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); } 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 左缘滑动分支)---- /* ---- M2.87:SIGUSR1 手势让位(handle_touch 左缘滑动分支)----
* 判定(meow_watchdog.c):`!g_fw_app && immersive` 才发 SIGUSR1(只给裸沉浸 App); * 判定(meow_watchdog.c):`!g_fw_app && immersive` 才发 SIGUSR1(只给裸沉浸 App);
* SDK 框架 App(g_fw_app=1)不发(返回由 SDK on_swipe 处理),防 SIGUSR1 杀死 App。 */ * SDK 框架 App(g_fw_app=1)不发(返回由 SDK on_swipe 处理),防 SIGUSR1 杀死 App。 */
@@ -68,10 +80,233 @@ static int expect_fw(const char *base, const char *name, const char *appjson, in
return 1; 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");
}
/* ---- M40:time32 取时/睡眠(与 libc 正常取时一致;o32 syscall 路径由交叉二进制反汇编验证)---- */
static void test_time32(void)
{
struct meow_time32 t;
check("time32 gettime ok", meow_time32_clock_gettime(CLOCK_MONOTONIC, &t) == 0, 1);
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
check("time32 sec == libc sec", t.tv_sec == (int32_t)ts.tv_sec, 1);
long dn = (long)t.tv_nsec - (long)ts.tv_nsec;
if (dn < 0) dn = -dn;
check("time32 nsec == libc nsec (<1ms)", dn < 1000000, 1);
uint64_t a = meow_time32_monotonic_ms();
uint64_t b = meow_time32_monotonic_ms();
check("monotonic nonzero", a > 0, 1);
check("monotonic nondecreasing", b >= a, 1);
check("ms from parts 1135.008s", meow_time_ms_from_parts(1135, 8000000) == 1135008ULL, 1);
unsigned long long s0 = now_ms();
meow_time32_sleep_us(20000);
unsigned long long el = now_ms() - s0;
check("sleep 20ms actually sleeps (>=15ms)", el >= 15, 1);
}
/* ---- M40:电源阈值/边沿行为矩阵(settings 取不同值)---- */
static void test_power_matrix(void)
{
unsigned long long lp = 0, fo = 0;
wd_pwr_resolve_thresholds(0, 0, &lp, &fo);
check("pwr default longpress=3000", lp == 3000, 1);
check("pwr default forceoff=8000", fo == 8000, 1);
wd_pwr_resolve_thresholds(1500, 5000, &lp, &fo);
check("pwr settings longpress=1500", lp == 1500, 1);
check("pwr settings forceoff=5000", fo == 5000, 1);
wd_pwr_resolve_thresholds(-1, 0, &lp, &fo);
check("pwr invalid -> default longpress", lp == 3000, 1);
check("pwr invalid -> default forceoff", fo == 8000, 1);
wd_pwr_resolve_thresholds(3000, 2000, &lp, &fo);
check("pwr forceoff<=longpress lifted to lp+1000", fo == 4000, 1);
/* 松开边沿:单击 / 长按但未到阈值 / 到阈值 / 唤醒 / 菜单已请求 */
check("release 100ms screen-on -> toggle off",
wd_pwr_release_action(0, 0, 100, 3000, 50) == WD_PWR_REL_TOGGLE_OFF, 1);
check("release 100ms screen-off -> toggle on",
wd_pwr_release_action(0, 0, 100, 3000, 0) == WD_PWR_REL_TOGGLE_ON, 1);
check("release 2500ms (<3000) still click -> toggle off",
wd_pwr_release_action(0, 0, 2500, 3000, 50) == WD_PWR_REL_TOGGLE_OFF, 1);
check("release 3000ms (>=longpress) -> no toggle",
wd_pwr_release_action(0, 0, 3000, 3000, 50) == WD_PWR_REL_NONE, 1);
check("release after wake -> no toggle",
wd_pwr_release_action(1, 0, 100, 3000, 0) == WD_PWR_REL_NONE, 1);
check("release after menu req -> no toggle",
wd_pwr_release_action(0, 1, 3500, 3000, 50) == WD_PWR_REL_NONE, 1);
/* 按住 tick:单击窗 / 长按出菜单 / 强关兜底 / 菜单已接管 / 已强关 */
check("tick 2999ms -> none",
wd_pwr_tick_action(0, 1, 0, 0, 2999, 3000, 8000) == WD_PWR_TICK_NONE, 1);
check("tick 3000ms -> menu",
wd_pwr_tick_action(0, 1, 0, 0, 3000, 3000, 8000) == WD_PWR_TICK_MENU, 1);
check("tick 8000ms menu requested, no takeover -> forceoff",
wd_pwr_tick_action(0, 1, 1, 0, 8000, 3000, 8000) == WD_PWR_TICK_FORCEOFF, 1);
check("tick 8000ms launcher menu active -> none",
wd_pwr_tick_action(1, 1, 1, 0, 8000, 3000, 8000) == WD_PWR_TICK_NONE, 1);
check("tick forceoff already done -> none",
wd_pwr_tick_action(0, 1, 1, 1, 9000, 3000, 8000) == WD_PWR_TICK_NONE, 1);
check("tick not down -> none",
wd_pwr_tick_action(0, 0, 0, 0, 9000, 3000, 8000) == WD_PWR_TICK_NONE, 1);
/* settings 取短值:1000/2000 */
check("tick custom 1000ms -> menu",
wd_pwr_tick_action(0, 1, 0, 0, 1000, 1000, 2000) == WD_PWR_TICK_MENU, 1);
check("tick custom 2000ms no takeover -> forceoff",
wd_pwr_tick_action(0, 1, 1, 0, 2000, 1000, 2000) == WD_PWR_TICK_FORCEOFF, 1);
}
int main(void) int main(void)
{ {
signal(SIGUSR1, on_sigusr1); /* 捕获 handle_touch 可能发出的 SIGUSR1(测命中) */ 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; } if (!mkdtemp(dir)) { perror("mkdtemp"); return 2; }
/* 框架 App:各值形态 */ /* 框架 App:各值形态 */
@@ -109,6 +344,15 @@ int main(void)
check("sigusr1 bare-immersive -> 1", sim_swipe_sigusr1(0, 1), 1); /* 裸沉浸:发(命中) */ check("sigusr1 bare-immersive -> 1", sim_swipe_sigusr1(0, 1), 1); /* 裸沉浸:发(命中) */
check("sigusr1 bare-nonimmersive -> 0", sim_swipe_sigusr1(0, 0), 0); /* 裸非沉浸:不发 */ 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();
/* ---- M40:time32 取时/睡眠 + 电源阈值行为矩阵 ---- */
test_time32();
test_power_matrix();
/* 清理 */ /* 清理 */
{ {
char path[560]; char path[560];
+73
View File
@@ -0,0 +1,73 @@
/* wd_power.h — watchdog 电源键判定(纯函数,宿主可单测,无设备/无文件系统依赖)。
*
* 语义与 launcher 主屏(main_menu.c)统一:
* 单击(松开时 held < power.longpress_menu_ms) → 息屏/亮屏切换;
* 长按 held ≥ power.longpress_menu_ms → 写菜单请求(watchdog 让位 launcher);
* 长按 held ≥ power.forceoff_ms 且菜单未被接管 → 强制关机兜底。
* 旧实现把 PWR_TOGGLE_MS 与 PWR_MENU_MS 都硬编码成 2000,导致「长按但不出菜单」的区间
* 不存在,且 App 内 2s / launcher 主屏 3s 不一致;现在两者都读 settings(缺省 3000/8000)。
*
* 阈值清洗:
* - 非正数 → 默认 3000 / 8000(settings 允许 0,但 0ms 阈值会让每次按下立刻出菜单,无意义);
* - forceoff ≤ longpress → 抬到 longpress+1000,避免「强关先于菜单」的自相矛盾配置。
*/
#ifndef WD_POWER_H
#define WD_POWER_H
#define WD_PWR_LONGPRESS_DEFAULT_MS 3000
#define WD_PWR_FORCEOFF_DEFAULT_MS 8000
static inline void wd_pwr_resolve_thresholds(int cfg_longpress, int cfg_forceoff,
unsigned long long *longpress_ms,
unsigned long long *forceoff_ms)
{
unsigned long long lp = (cfg_longpress > 0)
? (unsigned long long)cfg_longpress
: WD_PWR_LONGPRESS_DEFAULT_MS;
unsigned long long fo = (cfg_forceoff > 0)
? (unsigned long long)cfg_forceoff
: WD_PWR_FORCEOFF_DEFAULT_MS;
if (fo <= lp) fo = lp + 1000;
*longpress_ms = lp;
*forceoff_ms = fo;
}
/* 松开边沿的动作。bright>0=屏亮 → 息屏;否则亮屏。
* woke(按下时屏已黑,本次只唤醒)/ menu_notified(已请求菜单)→ 不再 toggle。 */
typedef enum {
WD_PWR_REL_NONE = 0,
WD_PWR_REL_TOGGLE_OFF,
WD_PWR_REL_TOGGLE_ON
} wd_pwr_release_act;
static inline wd_pwr_release_act wd_pwr_release_action(int woke, int menu_notified,
unsigned long long held_ms,
unsigned long long longpress_ms,
int bright)
{
if (woke || menu_notified) return WD_PWR_REL_NONE;
if (held_ms >= longpress_ms) return WD_PWR_REL_NONE; /* 长按已由 tick 处理 */
return (bright > 0) ? WD_PWR_REL_TOGGLE_OFF : WD_PWR_REL_TOGGLE_ON;
}
/* 轮询 tick(按住中)的动作。menu_active=launcher 已接管 → 全让位。
* 强关不要求 menu_notified:launcher 未接管菜单时 8s 兜底仍须生效。 */
typedef enum {
WD_PWR_TICK_NONE = 0,
WD_PWR_TICK_MENU,
WD_PWR_TICK_FORCEOFF
} wd_pwr_tick_act;
static inline wd_pwr_tick_act wd_pwr_tick_action(int menu_active, int down,
int menu_notified, int force_done,
unsigned long long held_ms,
unsigned long long longpress_ms,
unsigned long long forceoff_ms)
{
if (menu_active || !down) return WD_PWR_TICK_NONE;
if (held_ms >= forceoff_ms && !force_done) return WD_PWR_TICK_FORCEOFF;
if (held_ms >= longpress_ms && !menu_notified) return WD_PWR_TICK_MENU;
return WD_PWR_TICK_NONE;
}
#endif /* WD_POWER_H */
+81 -10
View File
@@ -25,6 +25,10 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
./build_meowpanel.sh host # 宿主编译 → meowpanel_host(本机 ld,仅供调试) ./build_meowpanel.sh host # 宿主编译 → meowpanel_host(本机 ld,仅供调试)
``` ```
- **后端源码已按模块拆分**(`src/*.c` + 共享内部头 `src/mp.h`):`mp_server`(HTTP/路由/main) + `mp_util`/`mp_json` + `mp_store`(设置/主题) + `mp_ipc`(IPC 帧/单一收发) + 各 api_* 域 `mp_perf`/`mp_power`/`mp_audio`/`mp_net`/`mp_ams`/`mp_svcd`/`mp_files`/`mp_fonts`/`mp_apps`/`mp_logs`/`mp_notif`/`mp_auth`/`mp_key`/`mp_screen`。
`mp.h` 承载全部共享类型/常量/外部全局 + 跨模块函数原型;每个 `.c` 以 `#include "mp.h"` 起步。
拆分保持后端接口/行为**完全不变**(宿主 `-Wall -Wextra` 0 warning;重编产物与拆分前**逐字节一致**——同一 mock 环境全端点 diff 无差异)。
原单体 `meowpanel.c` 保留在根目录作参考,**不在构建源列表**(构建以 `src/*.c` 为准)。
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。 - 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。
- 产出(本次构建实测): - 产出(本次构建实测):
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`, - `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
@@ -120,8 +124,19 @@ http://<host>.local:8080/
| POST | `/api/audio/volume` | 写音量(**写级**,PIN 已护):`MSG_AUDIO_VOLUME`,**clamp 到系统 vol_max**(读 settings.conf `audio.volume_max`,缺省 100;设备当前 50);audiod 仅 clamp 0-100 无 vol_max 防护,面板是把守方(防炸耳红线) | ✅(clamp 后写;audiod 不在→503) | | POST | `/api/audio/volume` | 写音量(**写级**,PIN 已护):`MSG_AUDIO_VOLUME`,**clamp 到系统 vol_max**(读 settings.conf `audio.volume_max`,缺省 100;设备当前 50);audiod 仅 clamp 0-100 无 vol_max 防护,面板是把守方(防炸耳红线) | ✅(clamp 后写;audiod 不在→503) |
| GET | `/api/power` | 功耗管理:meow_powermgr IPC `MSG_PM_QUERY→RESULT`(battery/estimate/breakdown,confidence 原样透传) | ✅(powermgr 不在→503 `POWER_UNAVAILABLE`) | | GET | `/api/power` | 功耗管理:meow_powermgr IPC `MSG_PM_QUERY→RESULT`(battery/estimate/breakdown,confidence 原样透传) | ✅(powermgr 不在→503 `POWER_UNAVAILABLE`) |
| GET | `/api/ams` | App 运行表:`am list`(pkg/name/state/pid/launches/crashes/last_kind/last_code) | ✅(amsd 不在→`available:false`) | | GET | `/api/ams` | App 运行表:`am list`(pkg/name/state/pid/launches/crashes/last_kind/last_code) | ✅(amsd 不在→`available:false`) |
| GET | `/api/ams/bg` | **后台活动列表**(M6.38,批④):`am list` 仅取 `state=="bg"`,逐进程 `/proc` 采样 `VmRSS`/`cpu_pct`(首采样/不可得→`null` 前端显 `—`,绝不编造);同源于 amsd FG/BG 元数据,杀/清走既有 `/api/ams/:pkg/kill` | ✅(只读,需 PIN 会话;amsd 不在→`available:false`) |
| GET | `/api/svcd` | 服务表:`meow_svcd status all`(name/state/pid/restart/backoff_ms/trigger/src/exit/exec) | ✅(svcd 不在→`available:false`) | | GET | `/api/svcd` | 服务表:`meow_svcd status all`(name/state/pid/restart/backoff_ms/trigger/src/exit/exec) | ✅(svcd 不在→`available:false`) |
| GET | `/api/overview` | **首页聚合**(单线程后端只暴露聚合):后端顺序 fan-out perf/power/audio/net/ams/svcd 六源合成一份 JSON;任一源失败→该子块 `available:false`,绝不假数据 | ✅(**读级,需 PIN 会话**) | | GET | `/api/overview` | **首页聚合**(单线程后端只暴露聚合):后端顺序 fan-out perf/power+events/audio/net+union dns/ams+fg/svcd 六源合成一份 JSON;任一源失败→该子块 `available:false`,绝不假数据 | ✅(**读级,需 PIN 会话**) |
| GET | `/api/net/ifaces` | 接口列表详情(kind/ip/gw/rx/tx/rssi/rfkill/dns;netmgrd 0xE2+0xE0 合成) | ✅(只读,需 PIN 会话) |
| GET | `/api/net/policy` | 管控策略只读(0xEA SHOW;`enforce=dns_only` 如实)+ **`policy_write_enabled`**(guard③ 门禁状态)| ✅(只读,需 PIN 会话;上游坏字节→`available:false`) |
| POST | `/api/net/policy` | 写管控策略(`set_net`/`set_domain`/`del`):**guard① 防自锁**(`SELF_LOCK` 422)+ **guard③ 默认关**(`POLICY_DISABLED` 403)+ 二次确认 | ✅(**写级**,PIN 会话;自锁→422,门禁→403,IPC 权限→403 FORBIDDEN) |
| GET | `/api/power/policy` | 电源策略快照(profile 四档/阈值/`enacted`)+ **`policy_write_enabled`**(guard③;注入后 jv_parse 自校验) | ✅(只读,需 PIN 会话;坏字节→503) |
| POST | `/api/power/policy` | 写电源策略(**只写 settings.conf `power.*` 键**):**guard③ 默认关**(`POLICY_DISABLED` 403)+ 二次确认 | ✅(**写级**,PIN 会话;非法值→422,权限→403) |
| GET | `/api/power/events` | 电源异常事件(充放电/低电/亮灭屏/背光跳变;overview 摘要 top2 的来源) | ✅(只读,需 PIN 会话;powermgr 不在→503) |
| GET | `/api/power/history` | 功耗采样历史(30s 环形 4h,不落盘) | ✅(只读,需 PIN 会话;powermgr 不在→503) |
| GET | `/api/ams/:pkg` | 单 App incident 历史(`am history`,崩溃根因 detail;坏 detail→降级 `available:false`) | ✅(只读,需 PIN 会话) |
| POST | `/api/ams/:pkg/start` | 启动 App(`am start <pkg>`;**写级**,PIN 会话) | ✅(包名非法→400,launcher 离线→503) |
| POST | `/api/ams/:pkg/kill` | 结束 App(`am kill <pkg>`;**写级**,PIN 会话,前台前端二次确认) | ✅(包名非法→400,not registered→404,busy→409) |
| GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ | | GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ |
| GET | `/api/apps/:pkg` | 应用详情 | ✅ | | GET | `/api/apps/:pkg` | 应用详情 | ✅ |
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ | | POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
@@ -396,6 +411,33 @@ http://<host>.local:8080/
同款统计);DNS 不可用则省略 `dns` 键仅保留网络状态,**绝不假数据**。首页 netCard 改读 `net.dns`,去掉单独 `/api/net/dns` 同款统计);DNS 不可用则省略 `dns` 键仅保留网络状态,**绝不假数据**。首页 netCard 改读 `net.dns`,去掉单独 `/api/net/dns`
(首页严格单请求)。 (首页严格单请求)。
### 5.16 批 P1 写端点(power/net/ams 写)+ 三条护栏(MEOWPANEL_ENHANCE_PLAN P1)
> 三个写端点全部落在 `POST` 分支 + `require_auth` 之后(无一漏到无鉴权路径);响应**如实回错误码**,绝不掩盖。
- **三条护栏**:
1. **guard① 防自锁**(仅 `/api/net/policy`):对系统关键网络/服务组件禁设「阻断」(`set_net`+`arg=block`、
或任何 `set_domain` 域名限制)。命中即 `422 {"code":"SELF_LOCK"}`。自锁实体(取 `svc:|app:|proc:` 后裸名),
`net_locked_entity()` 精确比对 `dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init`(netmgrd 加载时也剔除,面板侧抢先硬拒)。
「放行 / 删除规则」不阻断(恢复连通)仍放行。
2. **guard② PIN 会话鉴权**:复用全局 `require_auth`(§5.9);无会话 → `401 UNAUTHORIZED`。
3. **guard③ 策略写默认关**:`panel.policy_enabled`(新设置项,`settings.conf`「面板」组,默认 `0`)。
`0`(缺省)时 `POST /api/net/policy`、`POST /api/power/policy` 在解析 body **前**即拒 `403 POLICY_DISABLED`
(只读 GET 不受此限);置 `1`(设置页 / 电源·网络策略卡的「开启策略写」)放行。与 powermgr `enacted` **独立**。
- **`POST /api/net/policy`**:`{op:"set_net|set_domain|del", entity:"app:…|svc:…|proc:…", arg:"…"}`(`del` 免 `arg`)。
成功 `{"accepted":true,"message":…}`;权限→`403 FORBIDDEN`、非法→`422 UNSUPPORTED`、自锁→`422 SELF_LOCK`、门禁→`403 POLICY_DISABLED`、
IPC 不可达→`503 NET_UNAVAILABLE`。`enforce=dns_only` 如实显示「仅 DNS 层拒绝」,不谎称断连。
- **`GET /api/net/policy`**:`net_policy_parse` 输出 `{"available":true,"policy_write_enabled":<bool>,"enforce","log_level","rules":[…]}`;
坏字节(实体名/域名)降级 `{"available":false}`。前端据此渲染「默认关」横幅 + 「开启策略写」(`enablePolicyWrite`)。
- **`POST /api/power/policy`**:`{item:"profile|low-pct|crit-pct|bri-cap|scr-cap", value:"…"}`,**只写 settings.conf `power.*` 键**;
成功后透传 powermgr 应答。非法值→`422`(服务端 `ok:false` 按 `why` 映射)、权限→`403`、门禁→`403 POLICY_DISABLED`、
IPC 不可达→`503 POWER_UNAVAILABLE`。
- **`GET /api/power/policy`**:透传 powermgr 策略快照(含 `enacted`)后经 `power_policy_append_flag()` **追加顶层 `policy_write_enabled`**
(剥尾拼键 + jv_parse 自校验,绝不发非法 JSON);非对象答原样透传。
- **`POST /api/ams/:pkg/{start,kill}`**:`am start/kill <pkg>`;包名经 `ams_pkg_ok`(仅 ASCII 可见、禁 `/ : 空白`)。成功
`{"accepted":true,"op","pkg","message"}`;`not registered`→404、`busy`/`not running`→409、`launcher offline`→503、其余→500。
**kill 前台 App 前端二次确认**。
--- ---
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md) ## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
@@ -486,17 +528,46 @@ http://<host>.local:8080/
``` ```
patch_v2/meowpanel/ patch_v2/meowpanel/
├── meowpanel.c # 后端源码(约 5590 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序/audiod 状态快照 + 批2 崩溃/日志中心 + 音频写 + overview DNS 聚合) ├── meowpanel.c # 拆分前单体源码(约 6372 行,保留作参考,**不在构建源列表**;构建以 src/*.c 为准)
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning) ├── src/ # 后端模块化源码(模块化拆分,接口/行为不变,-Wall -Wextra 0 warning)
│ ├── mp.h # 共享内部头:全部共享类型/常量/外部全局 + 跨模块函数原型(含鉴权/实时画面等)
│ ├── mp_server.c # HTTP 服务器/路由分发 + 静态托管 + main(单线程 + poll 串行)
│ ├── mp_util.c # sbuf/UTF-8 消毒/内存搜索/命令管道/路径与 token 工具
│ ├── mp_json.c # 最简 JSON 解析/序列化(jv_t 树)
│ ├── mp_store.c # 设置镜像(settings.conf)+ 主题 + /api/settings*,/api/theme* + g_sets 表
│ ├── mp_ipc.c # IPC 帧读写 + meow_svc_perf/power/netmgr 客户端(单点收发)+ socket 路径
│ ├── mp_perf.c # /api/perf,/api/system,action,pin-unlock(perf 聚合)
│ ├── mp_power.c # meow_powermgr IPC(/api/power*,policy,window,events)+ ipc_pm_send
│ ├── mp_audio.c # meow_audiod IPC(状态快照 + 写命令 route/volume)
│ ├── mp_net.c # meow_netmgrd IPC(状态/连接/DNS/ifaces/policy,期2/期3)
│ ├── mp_ams.c # App 运行表 + amd 历史 + start/kill 控制
│ ├── mp_svcd.c # svcd 服务表 + 首页聚合 /api/overview
│ ├── mp_files.c # 路径安全/白名单 + /api/files* + multipart 上传 + 卡根路径
│ ├── mp_fonts.c # 字体库 tar.gz 导入/导出 + 列表(busybox gzip|tar 管道)
│ ├── mp_apps.c # MeowAPPs 扫描 + nyai 安装/导出/移除
│ ├── mp_logs.c # /api/logs 列表 + 读 + 崩溃汇总
│ ├── mp_notif.c # 通知(优先 launcher IPC 活跃集,回退 TF 归档)
│ ├── mp_auth.c # 访问级 PIN 鉴权(panel.pin)+ 内联 SHA-256 + 内存会话
│ ├── mp_key.c # 按键注入(写 /dev/input/event* EV_KEY,物理键红线内)
│ └── mp_screen.c # 实时画面(fb0→JPEG,增量帧)+ 触摸注入 + delta 缓存
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm)
├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG) ├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG)
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;meowpanel.c + mp_jpeg.c) ├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;src/*.c + mp_jpeg.c + -I.)
├── meowpanel # 交叉产物(mipsel 静态,约 218 KB,md5 015f9cf1…) ├── meowpanel # 交叉产物(mipsel 静态,本次 md5 fead4852…)
├── meowpanel_host # 宿主编译(本机 ld,调试用) ├── meowpanel_host # 宿主编译(本机 ld,调试用)
├── services.d/ ├── services.d/
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf │ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
├── web/ # 前端(已存在;部署时拷到后端同目录) └── web/ # 前端(模块化拆分;部署时拷到后端同目录)
│ ├── index.html # 含登录页 overlay + 设置密码对话框 + 音频/字体导航 ├── index.html # 含登录页 overlay + 设置密码对话框 + 音频/字体导航;脚本按序加载
│ ├── meowpanel.css # 含登录页样式 ├── meowpanel.css # 含登录页样式 + 「屏幕·遥控」合并页样式(screen-remote/remote-btn)
│ └── meowpanel.js # API 客户端带 token/401 登录、设置页鉴权卡片、音频页(5s轮询)/字体页 ├── mp_shared.js # 共享框架:全局状态 + API 客户端(token/401) + 公共工具 + 主题/弹窗/登录/滑条(window.MP)
└── README.md # 本文件 ├── mp_pages.js # 各页渲染/绑定:home/perf/power/audio/net/files/apps/fonts/settings/system/logs/notif/proc + 合并的屏幕·遥控
└── meowpanel.js # 前端入口:ROUTES/路由/启动(读取 window.MP 装配)
``` ```
**前端拆分说明**:`meowpanel.js`(单体 IIFE)按「公共 util+api(client) / 各页渲染 / 应用入口」拆为
`mp_shared.js` + `mp_pages.js` + `meowpanel.js` 三文件(经典脚本按序加载,函数体保持原样,仅顶部从
`window.MP` 解构共享框架,**行为不变**)。
「屏幕」+「控制」两页**合并为一页**(route `screen`,导航「屏幕·遥控」):内容区左侧=屏幕实时画面
(复用 /api/screen.jpeg + 触摸注入 + fps 滑条 + 增量省流开关),右侧=MD3 矢量图标遥控板(音量± / 亮度± /
指定亮度滑条 / 播放·暂停 / 下一曲 / 上一曲 / 电源单击 / 电源长按),**全矢量内联 SVG,无 emoji**。
`返回`/`主页` 属物理键红线(后端 /api/key 禁注入 158/172),未提供注入按钮(遥控板附说明)。
+4 -1
View File
@@ -25,7 +25,10 @@ if [ "$1" = "host" ]; then
exit 0 exit 0
fi fi
"$CC" -Os -Wall -Wextra -static -I. -I"$I_HDR" src/*.c mp_jpeg.c -o meowpanel # meow_time32.c:独立 o32 time32 取时/睡眠(meowpanel 不链 meow_ui.a);修 time(NULL)
# 会话过期基准与 usleep 手势/按键节奏。
"$CC" -Os -Wall -Wextra -static -I. -I"$I_HDR" -DMEOW_TIME32_LIBC_OVERRIDES \
src/*.c mp_jpeg.c ../sdk/meow_time32.c -o meowpanel
echo "== meowpanel (mipsel static) ==" echo "== meowpanel (mipsel static) =="
ls -la meowpanel ls -la meowpanel
md5sum meowpanel md5sum meowpanel
Binary file not shown.
+10
View File
@@ -270,6 +270,7 @@ int ipc_pm_send(uint16_t req_type, const void *pl, uint32_t plen, uint16_t *rtyp
int api_power_policy_get(int sock); int api_power_policy_get(int sock);
int api_power_policy_set(int sock, const char *body, size_t body_len); int api_power_policy_set(int sock, const char *body, size_t body_len);
int api_power_window(int sock, uint16_t req_type, uint16_t want_type, const char *unavail); int api_power_window(int sock, uint16_t req_type, uint16_t want_type, const char *unavail);
int power_policy_append_flag(sbuf_t *out, const char *obj); /* 拼 guard③ policy_write_enabled 顶层键(自校验) */
int api_power_history(int sock); int api_power_history(int sock);
int api_power_events(int sock); int api_power_events(int sock);
const char *ams_bin(void); const char *ams_bin(void);
@@ -287,6 +288,10 @@ int api_net_ifaces(int sock);
void net_policy_parse(const char *txt, sbuf_t *s); void net_policy_parse(const char *txt, sbuf_t *s);
int api_net_policy_get(int sock); int api_net_policy_get(int sock);
int api_net_policy_set(int sock, const char *body, size_t body_len); 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); void panel_strip_ansi(char *s);
int txt_tokens(char *buf, char **toks, int maxtoks); int txt_tokens(char *buf, char **toks, int maxtoks);
void join_tokens(char **toks, int from, int to, char *out, size_t cap); void join_tokens(char **toks, int from, int to, char *out, size_t cap);
@@ -301,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); void ams_history_fill(sbuf_t *s, const char *pkg);
int api_ams_history(int sock, 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_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); void svcd_fill(sbuf_t *s);
int api_svcd(int sock); int api_svcd(int sock);
int api_overview(int sock); int api_overview(int sock);
@@ -411,6 +417,8 @@ int api_notification_dismiss_all(int sock);
int api_files_list(int sock, const req_t *r); int api_files_list(int sock, const req_t *r);
int api_files_read(int sock, const req_t *r); int api_files_read(int sock, const req_t *r);
int api_files_download(int sock, const req_t *r); int api_files_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 api_files_upload(int sock, const req_t *r);
int rm_recursive(const char *path); int rm_recursive(const char *path);
int api_files_mkdir(int sock, const req_t *r); int api_files_mkdir(int sock, const req_t *r);
@@ -443,7 +451,9 @@ int api_screen_jpeg(int sock, const req_t *r);
int64_t now_mono_ms(void); int64_t now_mono_ms(void);
int64_t touch_last_local_ms(const char *dev); int64_t touch_last_local_ms(const char *dev);
int inject_tap(const char *dev, int x, int y); 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_tap(int sock, const req_t *r);
int api_input_gesture(int sock, const req_t *r);
const char *web_root(void); const char *web_root(void);
const char *mime_for(const char *path); const char *mime_for(const char *path);
int serve_static(int sock, 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) void ams_bg_parse(char *list_text, sbuf_t *s)
{ {
char *toks[256]; char *toks[256];
@@ -266,25 +291,10 @@ void ams_bg_parse(char *list_text, sbuf_t *s)
if (strncmp(ln, "APP ", 4) == 0) { if (strncmp(ln, "APP ", 4) == 0) {
int n = txt_tokens(ln, toks, 256); int n = txt_tokens(ln, toks, 256);
if (n >= 9) { 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]; const char *st = toks[n - 6];
if (strcmp(st, "bg") == 0) { if (strcmp(st, "bg") == 0) {
char nm[128]; count++;
join_tokens(toks, 2, n - 6, nm, sizeof nm); ams_entry_emit(&apps, toks, n, &first);
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, "}");
} }
} }
} }
@@ -295,6 +305,8 @@ void ams_bg_parse(char *list_text, sbuf_t *s)
sbuf_free(&apps); 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) void ams_bg_fill(sbuf_t *s)
{ {
char out[16384]; char out[16384];
@@ -303,7 +315,28 @@ void ams_bg_fill(sbuf_t *s)
int code = run_cmd(cmd, out, sizeof out); int code = run_cmd(cmd, out, sizeof out);
if (code != 0 || !strstr(out, "APP")) { sA(s, "{\"available\":false}"); return; } if (code != 0 || !strstr(out, "APP")) { sA(s, "{\"available\":false}"); return; }
panel_strip_ansi(out); 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) int api_ams_bg(int sock)
@@ -314,3 +347,98 @@ int api_ams_bg(int sock)
sbuf_free(&s); sbuf_free(&s);
return rc; 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}"); 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) int multipart_extract_file(const req_t *r, const char *outpath)
{ {
const char *b = strstr(r->content_type, "boundary="); const char *b = strstr(r->content_type, "boundary=");
+185 -1
View File
@@ -175,6 +175,160 @@ int api_net_dns_flush(int sock)
return rc; 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) int api_net_log(int sock)
{ {
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
@@ -285,11 +439,30 @@ int api_net_ifaces(int sock)
return rc; return rc;
} }
/* 防自锁实体:对本机关键网络/服务组件禁设「阻断」(net=block / set_domain 域名限制),
* 否则会把能连上设备/面板/系统核心的网络能力一并锁死(SSH / WiFi / DHCP / DNS / 面板 / init)。
* netmgrd 加载时也会剔除自锁项,面板侧抢先硬拒(guard①),保证 0 warning + 如实回错误码。
* 匹配口径:取 `svc:|app:|proc:` 之后的裸名精确比对(`svc:netmgrd`/`proc:sysui`/裸 `udhcpc` 均命中)。 */
int net_locked_entity(const char *entity)
{
if (!entity || !entity[0]) return 0;
const char *base = entity;
const char *colon = strrchr(entity, ':');
if (colon) base = colon + 1;
static const char *locked[] = {
"dropbear", "wpa_supplicant", "udhcpc", "netmgrd", "svcd", "sysui", "init", NULL
};
for (int i = 0; locked[i]; i++)
if (!strcmp(base, locked[i])) return 1;
return 0;
}
void net_policy_parse(const char *txt, sbuf_t *s) void net_policy_parse(const char *txt, sbuf_t *s)
{ {
char lvl[24] = "", enf[24] = "dns_only"; char lvl[24] = "", enf[24] = "dns_only";
const char *p = txt; const char *p = txt;
sA(s, "{\"available\":true,\"enforce\":"); sA(s, "{\"available\":true,\"policy_write_enabled\":%s,\"enforce\":",
panel_policy_write_enabled() ? "true" : "false");
/* 首行找 enforce=... */ /* 首行找 enforce=... */
/* enforce 后面紧跟全角「(」多字节说明,须在非 ASCII 字节处停(否则会吞「(SOCK_DESTROY...)」)。 */ /* enforce 后面紧跟全角「(」多字节说明,须在非 ASCII 字节处停(否则会吞「(SOCK_DESTROY...)」)。 */
const char *en = strstr(p, "enforce="); const char *en = strstr(p, "enforce=");
@@ -399,6 +572,9 @@ int api_net_policy_get(int sock)
int api_net_policy_set(int sock, const char *body, size_t body_len) int api_net_policy_set(int sock, const char *body, size_t body_len)
{ {
if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
/* guard③ policy 默认关:写端点默认关闭,须在设置开启 panel.policy_enabled 才放行(只读 GET 不受此限)。 */
if (!panel_policy_write_enabled())
return send_err(sock, 403, "POLICY_DISABLED", "网络策略写默认关闭:先到面板设置开启 panel.policy_enabled(危险操作)");
jv_t *root = jv_parse(body, (int)body_len); jv_t *root = jv_parse(body, (int)body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *jo = jv_obj_get(root, "op"), *je = jv_obj_get(root, "entity"), *ja = jv_obj_get(root, "arg"); jv_t *jo = jv_obj_get(root, "op"), *je = jv_obj_get(root, "entity"), *ja = jv_obj_get(root, "arg");
@@ -415,6 +591,14 @@ int api_net_policy_set(int sock, const char *body, size_t body_len)
else if (!strcmp(op, "del")) nop = NET_POLICY_DEL; else if (!strcmp(op, "del")) nop = NET_POLICY_DEL;
else return send_err(sock, 422, "UNSUPPORTED", "op 须为 set_net|set_domain|del"); else return send_err(sock, 422, "UNSUPPORTED", "op 须为 set_net|set_domain|del");
if (nop != NET_POLICY_DEL && !arg[0]) return send_err(sock, 422, "UNSUPPORTED", "缺 arg"); if (nop != NET_POLICY_DEL && !arg[0]) return send_err(sock, 422, "UNSUPPORTED", "缺 arg");
/* guard① 防自锁:对系统关键服务(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)禁设阻断。
* 「放行 / 删除规则」不阻断(那是恢复连通),仍放行;仅 set_net=block 与 set_domain(域名限制)命中即拒。 */
{
int blocking = ((nop == NET_POLICY_SET_NET) && !strcmp(arg, "block")) ||
(nop == NET_POLICY_SET_DOMAIN);
if (blocking && net_locked_entity(entity))
return send_err(sock, 422, "SELF_LOCK", "防自锁:禁止阻断系统关键服务(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)的网络");
}
m_net_policy_t q; memset(&q, 0, sizeof q); m_net_policy_t q; memset(&q, 0, sizeof q);
q.op = (uint32_t)nop; q.op = (uint32_t)nop;
+28 -1
View File
@@ -71,6 +71,18 @@ done:
return rc; return rc;
} }
/* 在对象 JSON(已剥尾部 `}`、经 utf8 校验)上拼 guard③ `policy_write_enabled` 顶层键:
* 拼完 jv_parse 自校验,绝不发非法 JSON(防 overview 教训);失败返回 -1(调用方回 500,绝不带病下发)。 */
int power_policy_append_flag(sbuf_t *out, const char *obj)
{
sA(out, "%s", obj);
sA(out, ",\"policy_write_enabled\":%s}", panel_policy_write_enabled() ? "true" : "false");
jv_t *chk = jv_parse(out->b ? out->b : "{}", (int)(out->b ? out->n : 2));
if (!chk) return -1;
jv_free(chk);
return 0;
}
int api_power_policy_get(int sock) int api_power_policy_get(int sock)
{ {
char js[MEOW_IPC_MAX]; char js[MEOW_IPC_MAX];
@@ -80,12 +92,27 @@ int api_power_policy_get(int sock)
return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务不可用(powermgr 未运行/IPC 不可达)"); return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务不可用(powermgr 未运行/IPC 不可达)");
if (rt != MSG_PM_RESULT || !utf8_valid_n(js, strlen(js))) if (rt != MSG_PM_RESULT || !utf8_valid_n(js, strlen(js)))
return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务应答包含非法字节"); return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务应答包含非法字节");
return send_json(sock, 200, js); /* 追加顶层 `policy_write_enabled`(面板侧 gate 状态,独立于 powermgr 的 `enacted`):
* 稳妥剥外层括号再拼(同 net_block_fill 手法)。上游非对象(数组/文本)时原样透传、不带该键
* (前端缺省视为已开启,兼容旧 powermgr)。 */
size_t l = strlen(js);
while (l && (js[l - 1] == ' ' || js[l - 1] == '\t' || js[l - 1] == '\n' || js[l - 1] == '\r')) js[--l] = 0;
if (l < 2 || js[0] != '{' || js[l - 1] != '}') return send_json(sock, 200, js);
js[l - 1] = 0; /* 去尾部 } */
sbuf_t s = {0};
if (power_policy_append_flag(&s, js) != 0) { sbuf_free(&s); return send_err(sock, 500, "INTERNAL", "电源策略聚合输出未通过 JSON 自校验"); }
int out = send_json(sock, 200, s.b ? s.b : "{}");
sbuf_free(&s);
return out;
} }
int api_power_policy_set(int sock, const char *body, size_t body_len) int api_power_policy_set(int sock, const char *body, size_t body_len)
{ {
if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
/* guard③ policy 默认关:写端点默认关闭,须在设置开启 panel.policy_enabled 才放行(只读 GET 不受此限)。
* 注意:powermgr 侧另有 `enacted`(策略是否已应用),本 gate 是面板侧写入口门禁,二者独立。 */
if (!panel_policy_write_enabled())
return send_err(sock, 403, "POLICY_DISABLED", "电源策略写默认关闭:先到面板设置开启 panel.policy_enabled(危险操作)");
jv_t *root = jv_parse(body, (int)body_len); jv_t *root = jv_parse(body, (int)body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *ji = jv_obj_get(root, "item"), *jv = jv_obj_get(root, "value"); jv_t *ji = jv_obj_get(root, "item"), *jv = jv_obj_get(root, "value");
+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" #include "mp.h"
static uint8_t *g_screen_prev = NULL; /* 上一帧紧凑 RGB565 */ 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; static int64_t g_last_self_inject_ms = 0;
#define TOUCH_ACTIVE_MS 5000 #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) 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; 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) int api_input_tap(int sock, const req_t *r)
{ {
if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body"); 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); sbuf_free(&s);
return ret; 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;
}
+180 -2
View File
@@ -301,6 +301,7 @@ int handle(int sock, req_t *r)
/* ---- 网络管理(meow_netmgrd 期2/期3 透传:读级任意调用者;flush/policy 写级走 require_auth) ---- */ /* ---- 网络管理(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/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/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/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/conns")) return api_net_conns(sock, r->query);
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/dns")) return api_net_dns(sock); if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/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/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/key")) return api_key_batch(sock, r);
if (!strcmp(p, "/api/input/tap")) return api_input_tap(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]; char seg[256];
if (path_segment("/api/key/", p, seg, sizeof seg)) if (path_segment("/api/key/", p, seg, sizeof seg))
return api_key_single(sock, atoi(seg), r); 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)) { if (!strncmp(p, "/api/ams/", 9)) {
size_t pl = strlen(p); size_t pl = strlen(p);
if (pl > 5 && !strcmp(p + pl - 6, "/start")) { if (pl > 5 && !strcmp(p + pl - 6, "/start")) {
char pkg[256]; char pkg[256];
if (path_segment("/api/ams/", p, pkg, sizeof pkg)) if (path_segment("/api/ams/", p, pkg, sizeof pkg))
return api_ams_ctl(sock, pkg, 0); 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")) { } else if (pl > 4 && !strcmp(p + pl - 5, "/kill")) {
char pkg[256]; char pkg[256];
if (path_segment("/api/ams/", p, pkg, sizeof pkg)) 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")) 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/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/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); if (!strcmp(r->method, "DELETE") && !strcmp(p, "/api/files")) return api_files_delete(sock, r);
/* ---- 应用 ---- */ /* ---- 应用 ---- */
@@ -517,6 +525,8 @@ int panel_selftest(void)
if (!rules || rules->type != J_ARR || !rules->child) { printf("SELFTEST FAIL: net_policy_parse 缺 rules 数组\n"); jv_free(pr); sbuf_free(&p); return 1; } if (!rules || rules->type != J_ARR || !rules->child) { printf("SELFTEST FAIL: net_policy_parse 缺 rules 数组\n"); jv_free(pr); sbuf_free(&p); return 1; }
jv_t *enf = jv_obj_get(pr, "enforce"); jv_t *enf = jv_obj_get(pr, "enforce");
if (!enf || enf->type != J_STR || !enf->str || strcmp(enf->str, "dns_only") != 0) { printf("SELFTEST FAIL: net_policy_parse enforce 未如实透传 dns_only\n"); jv_free(pr); sbuf_free(&p); return 1; } if (!enf || enf->type != J_STR || !enf->str || strcmp(enf->str, "dns_only") != 0) { printf("SELFTEST FAIL: net_policy_parse enforce 未如实透传 dns_only\n"); jv_free(pr); sbuf_free(&p); return 1; }
jv_t *pwe = jv_obj_get(pr, "policy_write_enabled"); /* guard③ 状态键必须为布尔,且拼读无括号错位 */
if (!pwe || pwe->type != J_BOOL) { printf("SELFTEST FAIL: net_policy_parse 缺/损坏 policy_write_enabled\n"); jv_free(pr); sbuf_free(&p); return 1; }
jv_free(pr); jv_free(pr);
sbuf_free(&p); sbuf_free(&p);
} }
@@ -567,7 +577,175 @@ int panel_selftest(void)
sbuf_free(&b); sbuf_free(&b);
} }
printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 新增端点池 → 合法 JSON + 无非法字节\n"); /* 7) net_locked_entity:防自锁实体精确匹配(svc:/proc:/裸名);非关键实体放行(guard①)。 */
{
struct { const char *e; int want; } c[] = {
{ "svc:dropbear", 1 }, { "proc:netmgrd", 1 }, { "udhcpc", 1 },
{ "svc:sysui", 1 }, { "svc:wpa_supplicant", 1 }, { "svcd", 1 }, { "init", 1 },
{ "app:com.example", 0 }, { "svc:avahi", 0 }, { "proc:sh", 0 },
{ "app:com.netmgrd.helper", 0 }, /* 含子串但不等于裸名,不得误判 */
{ "", 0 }, { NULL, 0 }
};
for (int i = 0; c[i].e; i++) {
int got = net_locked_entity(c[i].e);
if (got != c[i].want) { printf("SELFTEST FAIL: net_locked_entity(%s)=%d 期望 %d\n", c[i].e, got, c[i].want); return 1; }
}
}
/* 8) policy 默认关 gate(guard③):无设置文件 → panel_policy_write_enabled()==0(默认关);
* settings_write_one("panel.policy_enabled","1") 后 ==1(写临时 settings 文件验证,测完清理)。 */
{
const char *saved = g_settings_file;
char tmpf[] = "/tmp/meowpanel_policy_gate_XXXXXX";
int tfd = mkstemp(tmpf);
if (tfd < 0) { printf("SELFTEST FAIL: mkstemp 失败\n"); return 1; }
close(tfd);
g_settings_file = tmpf;
int before = panel_policy_write_enabled();
if (settings_write_one("panel.policy_enabled", "1") != 0) {
printf("SELFTEST FAIL: 写 panel.policy_enabled 失败\n"); unlink(tmpf); g_settings_file = saved; return 1;
}
int after = panel_policy_write_enabled();
unlink(tmpf);
g_settings_file = saved;
if (before != 0 || after != 1) { printf("SELFTEST FAIL: policy gate 默认关/开启 %d->%d\n", before, after); return 1; }
}
/* 9) power_policy_append_flag:对象 JSON 剥尾拼入 policy_write_enabled 后仍可解析、键为布尔、原字段保留
* ——防括号错位重演(overview 教训)。测试在无 panel.policy_enabled 的临时 settings 下进行(期望 false=默认关)。 */
{
const char *saved = g_settings_file;
char tmpf[] = "/tmp/meowpanel_policy_append_XXXXXX";
int tfd = mkstemp(tmpf);
if (tfd >= 0) { close(tfd); g_settings_file = tmpf; }
sbuf_t p = {0};
if (power_policy_append_flag(&p, "{\"profile\":\"balanced\",\"enacted\":true") != 0) {
printf("SELFTEST FAIL: power_policy_append_flag 拼接失败\n"); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1;
}
if (!utf8_valid_n(p.b, p.n) || !strstr(p.b, "\"policy_write_enabled\":false")) {
printf("SELFTEST FAIL: power_policy_append_flag 产物异常\n"); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1;
}
jv_t *pr = jv_parse(p.b, (int)p.n);
if (!pr) { printf("SELFTEST FAIL: power_policy_append_flag 产物 JSON 无法解析\n"); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1; }
jv_t *pwe = jv_obj_get(pr, "policy_write_enabled");
if (!pwe || pwe->type != J_BOOL || pwe->bval != 0) { printf("SELFTEST FAIL: power_policy_append_flag policy_write_enabled 非 false(默认关)\n"); jv_free(pr); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1; }
jv_t *pfl = jv_obj_get(pr, "profile");
if (!pfl || pfl->type != J_STR || strcmp(pfl->str, "balanced") != 0) { printf("SELFTEST FAIL: power_policy_append_flag 丢失原字段\n"); jv_free(pr); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1; }
jv_free(pr);
sbuf_free(&p);
if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; }
}
/* 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; return 0;
} }
+9
View File
@@ -69,6 +69,10 @@ static const setdef_t g_sets[] = {
{ "panel.pin", ST_STR, "", 0,0, NULL, NULL, "面板" }, { "panel.pin", ST_STR, "", 0,0, NULL, NULL, "面板" },
{ "panel.auth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" }, { "panel.auth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" },
{ "panel.auth_pass", ST_STR, "", 0,0, NULL, NULL, "面板" }, { "panel.auth_pass", ST_STR, "", 0,0, NULL, NULL, "面板" },
/* panel.policy_enabled — 策略写「默认关」门禁(guard③):POST /api/net/policy 与 /api/power/policy
* 默认关闭(缺省 0),置 1 才放行。落 settings.conf(「面板」组,前端设置页可开启),仅作面板侧门禁记录,
* 不影响 powermgr 的 `enacted`(那是服务端「策略是否已应用」,二者独立)。 */
{ "panel.policy_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" },
}; };
#define SETS_COUNT ((int)(sizeof g_sets / sizeof g_sets[0])) #define SETS_COUNT ((int)(sizeof g_sets / sizeof g_sets[0]))
@@ -143,6 +147,11 @@ int settings_get_int(const char *key, int defv)
return r; return r;
} }
int panel_policy_write_enabled(void)
{
return settings_get_int("panel.policy_enabled", 0) ? 1 : 0;
}
int brightness_clamp(int v) int brightness_clamp(int v)
{ {
int mn = settings_get_int("display.brightness_min", 1); int mn = settings_get_int("display.brightness_min", 1);
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more