Compare commits
121
Commits
2f22e26de7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1acc9096b | ||
|
|
6c6da79ad9 | ||
|
|
837cd47e7c | ||
|
|
46278fd051 | ||
|
|
cf5703b32b | ||
|
|
829d4475e8 | ||
|
|
8f748b137d | ||
|
|
365c8f756b | ||
|
|
e21cbc7865 | ||
|
|
2881ce908a | ||
|
|
190041cd67 | ||
|
|
58f2d9bff4 | ||
|
|
e0dfab95c3 | ||
|
|
3f09f7c137 | ||
|
|
dc9bfdbdaf | ||
|
|
0b30cdbafb | ||
|
|
a9638fa59c | ||
|
|
dc559aa1ac | ||
|
|
bece9848e7 | ||
|
|
288c133efc | ||
|
|
e0a82a3d47 | ||
|
|
63ead3ec1e | ||
|
|
409fd1edf7 | ||
|
|
db672cbe8f | ||
|
|
281f4153ee | ||
|
|
ee5796747d | ||
|
|
479d93a3bf | ||
|
|
adaed09f94 | ||
|
|
0531603779 | ||
|
|
b52e6a08bc | ||
|
|
2a2e6988ae | ||
|
|
1f692ae2a7 | ||
|
|
c9f0cbaab6 | ||
|
|
2cfac55b4e | ||
|
|
f9c2ea42a4 | ||
|
|
0ab23161b6 | ||
|
|
316d9d42c7 | ||
|
|
dfde95b020 | ||
|
|
f2551a28a2 | ||
|
|
bcb11e012f | ||
|
|
62b3603ade | ||
|
|
72c6e9889d | ||
|
|
8d2c098deb | ||
|
|
9600f10352 | ||
|
|
e69b8efeeb | ||
|
|
c2635daf3f | ||
|
|
a7bd6c44d9 | ||
|
|
40ac1e40f0 | ||
|
|
0412994953 | ||
|
|
3b5ffece57 | ||
|
|
cfcb94b8d2 | ||
|
|
31c12892d6 | ||
|
|
4d9ba46a7b | ||
|
|
805cfe11cc | ||
|
|
c811913c06 | ||
|
|
278e7eaa93 | ||
|
|
d9950d75eb | ||
|
|
e23bdca26a | ||
|
|
22f9dab2ce | ||
|
|
98b9169b86 | ||
|
|
4e60f440f6 | ||
|
|
38be5e78bc | ||
|
|
8a115f6583 | ||
|
|
294ed6ffc7 | ||
|
|
7f4e18b0ed | ||
|
|
35a636ef42 | ||
|
|
aa225a1973 | ||
|
|
61ef0f6de9 | ||
|
|
31879f3302 | ||
|
|
b1d607d43f | ||
|
|
2ca386973b | ||
|
|
e0bd503261 | ||
|
|
707bbd8a02 | ||
|
|
8557da6fca | ||
|
|
03868d048a | ||
|
|
7d4906828d | ||
|
|
2c69070cc4 | ||
|
|
e60642217a | ||
|
|
68833cf642 | ||
|
|
30ffe469c8 | ||
|
|
132622cbc1 | ||
|
|
6b52740ff1 | ||
|
|
a73bcb9841 | ||
|
|
a9c94c7221 | ||
|
|
39465ea1ef | ||
|
|
f392ee5c48 | ||
|
|
184ebd0ac9 | ||
|
|
e56c837f4f | ||
|
|
8be4634a61 | ||
|
|
47c638d65b | ||
|
|
20f4727714 | ||
|
|
2ffb92c038 | ||
|
|
7c7d891570 | ||
|
|
e87ccdf061 | ||
|
|
f40abbd494 | ||
|
|
a64ae06eb6 | ||
|
|
68710d7ed8 | ||
|
|
5e67100231 | ||
|
|
06f872faf2 | ||
|
|
68f91f7de2 | ||
|
|
4ce7a268a8 | ||
|
|
ddc8d35eb9 | ||
|
|
626a87a30d | ||
|
|
2826c1e905 | ||
|
|
b364b6371a | ||
|
|
501867b362 | ||
|
|
d0a5901ffb | ||
|
|
a2aec783a7 | ||
|
|
b3ce719742 | ||
|
|
8e68254ce4 | ||
|
|
4d0a8450e9 | ||
|
|
b712088daa | ||
|
|
3fea237306 | ||
|
|
2db87b2c5c | ||
|
|
7a276f609e | ||
|
|
6bab304660 | ||
|
|
491db5069c | ||
|
|
b23c6d53c4 | ||
|
|
5c174224f7 | ||
|
|
c69fb92284 | ||
|
|
211c5a556a |
No files matched your search
@@ -302,12 +302,13 @@ meow_launcherd --sim-exit N # 菜单打开 N 毫秒后模拟退出
|
||||
|
||||
**修复(`run_menu` + `menu_after_app_exit()`)**:
|
||||
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()` 只刷数字。
|
||||
|
||||
**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 覆盖「启动后立即点击生效 / 系统手势守卫内不触发、守卫后正好一次」。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.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 追加)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> 把 HiBy R3 Pro II 便携播放器(君正 X1600,MIPS32r2 / mipsel / musl 静态软浮点,480×720,无 GPU 软渲染)变成一个可扩展的「第二系统 + 应用框架」。
|
||||
> MeowOS 与官方 HibyOS 共存于同一台设备:开机由引导选单二选一;启动后 MeowOS 作为主控层,提供自己的桌面 UI、应用框架、服务管理与系统组件。
|
||||
>
|
||||
> 版本:**M6.32(0.8.32,已自刷验收)**。M6.19→M6.32 连续十三版推卡自刷验收——**M6.19**:ncm 后台化 UI 叠层/点击穿透+再进 ncm 桌面叠层+播放页新旧 UI 叠层抢刷脏区(软自抑帧移交不完整,恢复边沿强制整帧重画)+PIN 亮屏亮度跳+通知中心封面显歌单封面;**M6.20**:播放页右滑返回+顶部下滑通知中心+状态栏时钟 1s;**M6.21-22**:播放页返回修复(`np_exit` 退出外层主循环)+方向感知手势;**M6.23**:watchdog 亮屏亮度按用户设置恢复;**M6.24**:App 内按电源键锁屏时序;**M6.25**:bootmenu 开机菜单亮度方案 A;**M6.26**:播放页三键命中+媒体卡片体 FOCUS;**M6.27**:netmgrd 流量统计收编(pub_bytes/去 lo)+面板公网流量卡;**M6.28**:收编桌面 Now Playing 双页歌词页+歌词数据源+蓝牙 P1(netmgrd BT_CTL/bt_page);**M6.29**:播放页任意右滑返回桌面;**M6.30**:收编批5 取色+蓝牙 P2(audiod glibc-dynamic)+面板蓝牙卡;**M6.31**:桌面歌词页封面修复+alsa.conf DEV 自动识别;**M6.32**:重编 meow_ui.a+重链四 App(播放页进度条取色+ncm 列表 gap)+面板透传。M6.10→M6.18:M7 launcher 重写第 1-5 期(M6.10-14:虚拟化宫格/设置页卡顿修复/OOM 通用化/自动切歌根治)+(M6.15)meowpanel jv_free 泄漏修复+(M6.16)Hello demo 滚动卡顿修复+(M6.17)歌词 UI+(M6.18)媒体通知封面+封面分辨率。M6.1→M6.9 全链自刷验收——**M6.1**:netmgrd 期3/powermgr 期2/面板 PIN 鉴权+首页仪表盘/WiFi 录入 UI/服务名统一 meow_*;**M6.2**:锁屏 PIN 全量(`system.pin` 同源面板)+VOL_DOWN×3 框架门控+显示名 name[49](amsd/launcher UTF-8 整序列截断);**M6.3**:设置页 5s 修复(309 网络异步步骤机+跳过字体 sha 校验)+310 锁屏三连(输入穿透/解锁状态栏消失/App 死亡残影);**M6.4**:310 二期 **EVIOCGRAB 锁屏独占触控**(未冻结 App 锁屏输入隔离)+主界面应用名截断;**M6.5**:audiod 315 孤儿会话两段式回收(客户端崩溃/OOM 杀/正常退出后会话完整回收,STATE 回干净无流基线);**M6.6**:锁屏 310 三期——App 内手动息屏也触发锁屏 + 锁屏脏区全屏不透明底 + 300ms 重铺;**M6.7**:锁屏页自睡(空闲超时自动灭屏,294 遗留补;0.8.7 补刷吸收 fw_content 事故修复——拆分 watchdog+meow_audio 两行);**M6.8**:310 四轮 锁屏自睡 min(系统息屏,30s);**M6.9**:netmgrd 期4(SDK 自愿通道/forget/RSSI/AAAA 过滤/iptables 评估)+powermgr 期3(policy/标定工具)固化 + 327 M7.0b launcher 试点(电源菜单/Now Playing 控件化+主题薄壳+i18n 抽串);历史:M2.69→M2.96 全链路自刷验收:控件库/主界面 MD3 双布局/M3 导航栈/M5 通知跳转/媒体会话/Now Playing/SDK nav+media 封装/字库启动竞态修复**/M2.84-88**:installed 图标修复+残帧pan加固/物理键EVIOCGKEY双源/签名鉴权 2B/meow_route 音频路由/amsd 二期(picker 契约)/流媒体客户端二期(封面·歌词后台·大歌单分页)/MeowPanel 实时画面+遥控+通知实时化/中文拼音 IME**/M2.89**:开机选单抢屏回归修复+watchdog SIGUSR1 不误杀框架 App+amsd 失联修复+顶部下滑手势分流**/M2.90**:**meow_audiod 独占音频守护**(AUDIOD_DESIGN,meow_route 并入退役)+MIUIx 顶级主题+流媒体客户端文案中文化+面板字体库 tar.gz 导入导出**/M2.91-96**:audiod 开机武装 hbc3000 FPGA(根治 PCM 启动内核死锁)+svc_power 充电态权威链(mp2731)+amsd 注册自愈(`am rescan`)/Now Playing 死循环修复+状态栏充电闪电+插孔消抖(三路)/audiod 长流护栏+ALSA 几何收窄 period=1024×4(根治长流停摆)+watchdog 音量存盘毁设置修复/音量反转四路统一(CS43198 寄存器=衰减量,`dac=255-pct*255/100`)+炸耳安全修复(dac_vol_mute 写 255 真静音);**M6.0-6.1**:网络管理服务 **meow_netmgrd** 期1-3(接口统一层+连接普查三实体+DNS 代理+策略引擎防自锁+WiFi 录入 UI)+功耗管理服务 **meow_powermgr** 期0-2(查看/功耗分解 top/历史/事件/mp2731 实测充电功率)+MeowPanel 增强(4 位 PIN 鉴权+首页仪表盘/电源页/进程服务页)+服务名统一 meow_*(audiod/mdnsd→meow_audiod/meow_mdns))。版本号单点 = 板上 `/etc/meowos_version`,见「构建」。本仓库是一个**自带 SDK、包管理、媒体会话、独占音频服务、网页面板、流媒体客户端的完整第三方系统**。
|
||||
> 版本:**M6.35(0.8.35,已上卡)**。M6.19→M6.35 连续十七版推卡自刷验收——**M6.19**:ncm 后台化 UI 叠层/点击穿透+再进 ncm 桌面叠层+播放页新旧 UI 叠层抢刷脏区(软自抑帧移交不完整,恢复边沿强制整帧重画)+PIN 亮屏亮度跳+通知中心封面显歌单封面;**M6.20**:播放页右滑返回+顶部下滑通知中心+状态栏时钟 1s;**M6.21-22**:播放页返回修复(`np_exit` 退出外层主循环)+方向感知手势;**M6.23**:watchdog 亮屏亮度按用户设置恢复;**M6.24**:App 内按电源键锁屏时序;**M6.25**:bootmenu 开机菜单亮度方案 A;**M6.26**:播放页三键命中+媒体卡片体 FOCUS;**M6.27**:netmgrd 流量统计收编(pub_bytes/去 lo)+面板公网流量卡;**M6.28**:收编桌面 Now Playing 双页歌词页+歌词数据源+蓝牙 P1(netmgrd BT_CTL/bt_page);**M6.29**:播放页任意右滑返回桌面;**M6.30**:收编批5 取色+蓝牙 P2(audiod glibc-dynamic)+面板蓝牙卡;**M6.31**:桌面歌词页封面修复+alsa.conf DEV 自动识别;**M6.32**:重编 meow_ui.a+重链四 App(播放页进度条取色+ncm 列表 gap)+面板透传;**M6.33**:收编 launcher 播放页占位符移除(已有独立歌词页)+封面「空→复原」恢复修复(test_np_cover 14 项 PASS);**M6.34**:收编**批①后台常驻+宽限期**(全 App 入后台表+30min `MEOW_BG_GRACE_MS` 宽限超时回收+声明常驻/媒体活跃无限期保活+槽满 LRU+app.json `background_keepalive` 三权限位+amsd `EV BG/FG` 上报)+**audiod A3/DSD**(24/32bit 高清 byte-exact+独立 `AUDIO_FMT_DOP` DoP DSD 透传+音量收编 v2 双组写)+**meow_img_pixels 访问器上收**+**面板拆分+屏幕·遥控合并页**(前端三层+左屏右遥控 MD3 SVG 矢量按钮);**M6.35**:收编**批③后台管理页**(桌面顶栏「后台」入口+`bg_page` 后台列表+每行「杀」按钮+顶栏「一键清」,`!framework` 门控不杀框架)+**amsd FG/BG 收编**(`57e19cde`)+**i18n settings/lock 补抽**。M6.35 之后工作区已提交(暂未打包,并入 M6.36):批①叠加/穿透根治(`/tmp/meow_notif_active` 内容=前台归属 app_id+SDK present/input 共享判定+launcher 7 处转场)、主界面顶栏后台按钮图标美化、主界面列表圆角卡 8px 外距、后台管理页行 MD3 圆角卡;**MD3 对齐在跑**(agent-435,参考 `third_party/md-material-web` 最新 M3)。M6.10→M6.18:M7 launcher 重写第 1-5 期(M6.10-14:虚拟化宫格/设置页卡顿修复/OOM 通用化/自动切歌根治)+(M6.15)meowpanel jv_free 泄漏修复+(M6.16)Hello demo 滚动卡顿修复+(M6.17)歌词 UI+(M6.18)媒体通知封面+封面分辨率。M6.1→M6.9 全链自刷验收——**M6.1**:netmgrd 期3/powermgr 期2/面板 PIN 鉴权+首页仪表盘/WiFi 录入 UI/服务名统一 meow_*;**M6.2**:锁屏 PIN 全量(`system.pin` 同源面板)+VOL_DOWN×3 框架门控+显示名 name[49](amsd/launcher UTF-8 整序列截断);**M6.3**:设置页 5s 修复(309 网络异步步骤机+跳过字体 sha 校验)+310 锁屏三连(输入穿透/解锁状态栏消失/App 死亡残影);**M6.4**:310 二期 **EVIOCGRAB 锁屏独占触控**(未冻结 App 锁屏输入隔离)+主界面应用名截断;**M6.5**:audiod 315 孤儿会话两段式回收(客户端崩溃/OOM 杀/正常退出后会话完整回收,STATE 回干净无流基线);**M6.6**:锁屏 310 三期——App 内手动息屏也触发锁屏 + 锁屏脏区全屏不透明底 + 300ms 重铺;**M6.7**:锁屏页自睡(空闲超时自动灭屏,294 遗留补;0.8.7 补刷吸收 fw_content 事故修复——拆分 watchdog+meow_audio 两行);**M6.8**:310 四轮 锁屏自睡 min(系统息屏,30s);**M6.9**:netmgrd 期4(SDK 自愿通道/forget/RSSI/AAAA 过滤/iptables 评估)+powermgr 期3(policy/标定工具)固化 + 327 M7.0b launcher 试点(电源菜单/Now Playing 控件化+主题薄壳+i18n 抽串);历史:M2.69→M2.96 全链路自刷验收:控件库/主界面 MD3 双布局/M3 导航栈/M5 通知跳转/媒体会话/Now Playing/SDK nav+media 封装/字库启动竞态修复**/M2.84-88**:installed 图标修复+残帧pan加固/物理键EVIOCGKEY双源/签名鉴权 2B/meow_route 音频路由/amsd 二期(picker 契约)/流媒体客户端二期(封面·歌词后台·大歌单分页)/MeowPanel 实时画面+遥控+通知实时化/中文拼音 IME**/M2.89**:开机选单抢屏回归修复+watchdog SIGUSR1 不误杀框架 App+amsd 失联修复+顶部下滑手势分流**/M2.90**:**meow_audiod 独占音频守护**(AUDIOD_DESIGN,meow_route 并入退役)+MIUIx 顶级主题+流媒体客户端文案中文化+面板字体库 tar.gz 导入导出**/M2.91-96**:audiod 开机武装 hbc3000 FPGA(根治 PCM 启动内核死锁)+svc_power 充电态权威链(mp2731)+amsd 注册自愈(`am rescan`)/Now Line truncated
|
||||
>
|
||||
> 许可证:Apache-2.0(见 [`LICENSE`](LICENSE))。本仓库为非官方第三方项目。
|
||||
> 主仓**公开可克隆**:`https://git.yeij.top/AskaEth/MeowOS-For-R3P2.git`(维护者推送走
|
||||
@@ -26,6 +26,7 @@
|
||||
- **应用框架**:一套 C 应用 SDK(`meow_ui`),第三方 App 用统一控件 / 输入 / 主题 / 字体 / 通知 API 开发,无需自读帧缓冲、自读输入、自管返回——把每个 App 都要踩一遍的坑收口到框架里。
|
||||
- **系统组件**:状态栏、通知中心、电源菜单、全局左缘返回手势、主界面(应用列表,**MD3/MIUIx 顶级主题×明暗**,卡片宫格/列表双布局)、设置页、服务管理器、mDNS 广播;系统进程总监 `meow_sysui`;专项子服务 `meow_svc_perf`(性能/系统只读聚合,含真实 network)与 `meow_svc_power`(关机/重启/升级/回 HiBy,含升级写保护 + dac 真实音量 + 读级鉴权放宽);**独占音频守护 `meow_audiod`**(M2.90:App 经 SDK `meow_audio_*` 喂 PCM,守护独占 `hw:0,0`——DAC 原生率跟随无 SRC、格式归一、4.4/3.5 路由自动跟随插孔、抢占+EOF 回收、CLI status/tone/route/vol,规范 `devdocs/plan/AUDIOD_DESIGN.md`);系统级崩溃捕获 `meow_crash` + App 崩溃直报;.mpk **Ed25519 签名验签** `meow_verify`(见 §2.9);**网络管理服务 `meow_netmgrd`**(M6.0 期1 / M6.1 期3:接口统一层/连接普查三实体/DNS 代理/策略引擎防自锁,HibyOS 只读)与**功耗管理服务 `meow_powermgr`**(M6.0 期1 / M6.1 期2:功耗查看/分解/历史/事件,HibyOS 只读);MeowPanel 外部管理面板(HTTP/JSON 后端 + Web 前端,支持可选密码鉴权 + 4 位 PIN 访问级鉴权 + 首页仪表盘 + 字体库 tar.gz 导入导出,见 §2.10)。
|
||||
- **媒体会话 / Now Playing**:`MSG_META`/`MSG_PLAYCTL`(0x72/0x73)媒体会话(最近活跃源,30s 超时回收)+ 通知中心**媒体卡片** + 主界面左滑 **Now Playing 大页**(播放页⇄歌词页双页,方向感知手势:播放页右→左进歌词/左缘右滑回桌面、歌词页左→右回播放);媒体键路由到最近活跃媒体会话。
|
||||
- **App 后台常驻框架(M6.34 起,`devdocs/plan/APP_BACKGROUND_FRAMEWORK.md`)**:App 返回桌面**不立即杀**——`meow_media_app_resident` 按 App 自身会话判活跃 + `meow_bg_should_keep` 三参(keepalive/媒体活跃/声明)+ app.json **`background_keepalive`** 声明 + amsd `EV BG/FG` 上报;**宽限期**`MEOW_BG_GRACE_MS`(30min)全 App 入后台表,未声明超时回收(SIGTERM→SIGKILL 真退序列),声明常驻/媒体活跃**无限期保活**,槽满 LRU;**批③后台管理页**(M6.35,桌面顶栏「后台」入口):列出每个后台常驻 App(名字+CPU/内存+状态)+ 每行「杀」按钮 + 顶栏「一键清」(`!framework` 门控不杀框架 App),数据源=amsd FG/BG + svc_perf 同口径 /proc 采样。批②底部上滑回桌面+home 指示条待做、批④面板后台列表排队。
|
||||
- **应用生态**:**流媒体客户端 dev.player.ncm**(`core/`:mbedtls+jsmn+qrcodegen+minimp3 ≈438KB;复古 UI:登录二维码/我创建的·我收藏的双页签/搜索/歌单详情/黑胶播放页/设置,登录 803 已通;二期:专辑封面/歌词后台加载/大歌单分页;三期:歌单真分页/真实专辑封面/歌词锁修复/搜索接拼音 IME 中文搜索/设置页(音质 5 档+缓存上限+清理缓存)/缓存 LRU 淘汰接生产路径/文案全中文;三期续:歌曲对象瘦身 1324B→608B+256 首滑动窗口(任意长度歌单)/封面与缓冲 UX 修复(消除 worker 忙等 100% CPU)/FLAC 无损(dr_flac)+缓存键=歌曲 ID+音质档位/物理播放键 toggle 全链路;2026-09-02 批次:真机四连症状(豆腐/歌词一行/封面加载/音量键 SIGKILL)+返回首页竞态 staging+内存瘦身(封面 8.28MB→0.78MB)+流媒体专项六项(档位保真/封面 4xx 不重试/收藏显隐/缓存命名迁移/topbar 位图图标)+三死因闭环(OOM/误杀/歌词栈溢出)+封面链分诊+风控加固(接口 120s 缓存+游客令牌)+**M6.10 批次**(批B UI/批C 播放后台/338 VIP3 档+多歌手/336 根因——每次 audio_out_open 建短连接致 audiod fd 风暴已修;卡上热更 `bef4b0e1`,内存 40→49.2MB OOM 疑 page cache 待真机取证);**2026-09-03 桌面歌词/取色/蓝牙批**:桌面 Now Playing「播放页⇄歌词页」双页+歌词链路批1-5(SDK `meow_lyric` 通用歌词组件+`meow_lyric_view` 歌词页渲染控件+media 会话歌词数据源 `meow_media_lyric_*`/MSG_META_LYRIC+方向感知手势)+封面双色渐变取色(`meow_img_color`)+ncm 底部 mini 播放栏/播放模式三态+实时播放列表/列表项 8px 外距;**蓝牙 P0/P1/P2**(`aplay -D bluealsa` 真机出声打通/netmgrd BT_CTL 权威收口+launcher 蓝牙子页重做/audiod OUT_BT 输出目标+音量 AVRCP 隔离+alsa.conf DEV 自动识别))。**A2 起音频输出走 `meow_audiod`**(SINK_AUDIOD 默认后端,SDK `meow_audio_*` 客户端,被抢占回调→暂停;旧 ALSA 直开仅 `MEOW_AUDIO_DIRECT=1` 调试);外置**系统文件管理器 os.meow.files**;`meow_ui` SDK 配套结构化列表 `W_LISTVIEW`、通用长按、SDK nav/media 封装、视图框架(可嵌套 view + 事件路由 + 绘制期 clip);App 通知查询/清除 IPC(0x25-27)与**点击通知跳转路由**(route_status)。
|
||||
- **进程隔离**:系统 UI 渲染进程与 App 进程相互独立,App 卡死不会冻结整个 UI。
|
||||
- **包管理**:`nyai`(固件内置 `/usr/bin/nyai`)支持 .mpk 安装/升级/导出/列表/索引,走 **version_code 安卓式版本规则**(缺 code 拒装、防降级)+ 签名分级(有签必验、`os.meow.*` 无签拒装、第三方直装告警)+ 导出过滤与体检。
|
||||
@@ -79,14 +80,15 @@ init (rcS)
|
||||
|
||||
### 2.3 launcher —— 系统 UI 渲染宿主
|
||||
|
||||
`/usr/bin/meow_launcherd`是 MeowOS 的核心进程,负责渲染**系统级 UI**。**二进制名不变**(`/usr/bin/meow_launcherd`,bootmenu `LAUNCHER_BIN` 指向它),源码 M2.69 起由单文件 `patch_v2/meow_launcherd/meow_launcherd.c`(9778 行)**拆分为多模块**(`patch_v2/meow_launcherd/`,当前含 `main_menu/statusbar/settings_page/power_menu/notif_center/app_launcher/fb_compositor/audio/backlight/net/about/font_theme/keys/util` + 后期新增 `subpage/wifi_page/bt_page`(蓝牙/WiFi 独立子页)、`now_playing`(Now Playing 大页)、`meow_media`(媒体会话,最近活跃源 + 30s 超时回收)+ 共享头 `meow_launcher_internal.h`;`build_launcher.sh` 列模块)——逻辑更清晰、便于并行开发,行为不变。
|
||||
`/usr/bin/meow_launcherd`是 MeowOS 的核心进程,负责渲染**系统级 UI**。**二进制名不变**(`/usr/bin/meow_launcherd`,bootmenu `LAUNCHER_BIN` 指向它),源码 M2.69 起由单文件 `patch_v2/meow_launcherd/meow_launcherd.c`(9778 行)**拆分为多模块**(`patch_v2/meow_launcherd/`,当前含 `main_menu/statusbar/settings_page/power_menu/notif_center/app_launcher/fb_compositor/audio/backlight/net/about/font_theme/keys/util` + 后期新增 `subpage/wifi_page/bt_page`(蓝牙/WiFi 独立子页)、`now_playing`(Now Playing 大页)、`meow_media`(媒体会话,最近活跃源 + 30s 超时回收)、`bg_page`(批③后台管理页)+ `meow_bgplay`/`meow_bgmgr`(批①后台常驻保活/管理逻辑)+ 共享头 `meow_launcher_internal.h`;`build_launcher.sh` 列模块)——逻辑更清晰、便于并行开发,行为不变。
|
||||
|
||||
负责渲染:
|
||||
|
||||
- **状态栏(统一系统进程渲染)**:由 launcher 作为**唯一渲染主体**(主界面与 App 前台用**同一个**状态栏实例,App 不画、不做「样式一样的两套实现」);右侧 **MD3 图标 + 变量值**(时钟/电量/WiFi/蓝牙/音量,数据源唯一由 launcher 统一喂/取,两边必然一致);**全屏收合**:状态栏向上收出屏顶、只留提示线,**顶部保留 28px 热区**(仍可下滑通知中心)+ 左缘指示条;随主题 `ui.style` 换图标集。
|
||||
- 通知中心(顶部下滑展开,launcher 渲染,功能见 §2.6);
|
||||
- 电源菜单、全局左缘返回手势、全屏提示线;
|
||||
- 主界面(应用列表)、设置页;
|
||||
- 主界面(应用列表)、设置页、**后台管理页**(`bg_page`,M6.35:桌面顶栏「后台」入口,列出后台常驻 App + 每行杀 + 一键清);
|
||||
- **后台常驻框架**(M6.34):App 返回桌面保活 + 宽限期回收(`meow_bgplay`),amsd `EV BG/FG` 上报数据源;
|
||||
- **锁屏(M6.2 起)**:`lock_screen.c` 4 位 PIN(`system.pin` 同源面板 sha256+salt)+ 息屏唤醒锁 `system.pin_lock_on_wake` + 错 5 锁 5 分钟持久化 + 电源菜单「锁定」(作废一次性绕过标志)+ 设置「安全→锁屏 PIN」页(设/改需旧、清免旧)+ `/tmp/meow_pin_unlock_once` 一次性解锁恢复口;M6.4 起锁屏用 **EVIOCGRAB 独占触控**杜绝输入穿透下层 App。
|
||||
|
||||
**渲染与 App 进程隔离**:launcher 是系统进程,App 是独立进程。App 卡死/崩溃只影响该 App,系统 UI(状态栏/返回/通知中心)始终可用。
|
||||
@@ -195,6 +197,7 @@ init (rcS)
|
||||
- **面板批2 + 加固(2026-09-02)**:崩溃/日志中心 + 音频路由页 + 音量写经 clamp `vol_max` + 后端补 `reboot_reason`;**JSON 输出 UTF-8 加固**(`sA_str_n` 单遍校验、非法序列换 `?`、IPC 原始 JSON 门禁降级,防任一上游坏字节拖垮整页);**overview 自校验纪律**:`/api/overview` 服务端发出前先自查/严格平衡检查(坏则 dump `/tmp/overview_bad.json` + 500),前端非 JSON 自动回传原始字节到 `/api/debug/report_bad_json`(落 `/tmp/overview_client_bad.txt`);**教训**:后端 `jv_parse` **容忍尾随垃圾**,而浏览器 `JSON.parse` **不容忍**——net 块多写一个 `}` 即「Extra data」破页(batch2 DNS 并入事故),故自校验必须做与浏览器口径对齐的**严格括号/闭合平衡检查**。
|
||||
- **⚠️ 鉴权默认关**:内网信任模型(`reboot/shutdown/ota/apps/删除/上传` 等危险端点默认无鉴权,**勿把 8080 暴露到不可信网络**);`meow_svc_power` 服务端做 `SO_PEERCRED uid==0` 基础校验。
|
||||
- **前端**:`patch_v2/meowpanel/web/`(MD3/MIUIx 双主题 + 宝塔式 + 浏览日夜);`/usr/bin/nyai` 装 `.mpk`;launcher 的 `meow_pkg_export` / `nyai export` 导出 tar.gz(busybox tar + gzip,不需 zip)。详见 `patch_v2/meowpanel/README.md`。
|
||||
- **M6.34 前后端拆分 + 屏幕·遥控合并页(2dc4cad)**:后端 `meowpanel.c`(6372 行)拆为 `src/*.c`(20 个,按 API 域)+ `mp.h`(235 函数原型、20 端点 output byte-identical、0 warning);前端拆 `mp_shared.js`/`mp_pages.js`/`meowpanel.js` 三层;新增**屏幕·遥控合并页**(左=设备实时画面、右=MD3 SVG 矢量按钮无 emoji),替代旧的分离屏幕/遥控页;重编后热更 TF 卡。面板后端热更只写白名单 `MeowOS/opt/MeowPanel/`。
|
||||
|
||||
---
|
||||
|
||||
@@ -216,6 +219,7 @@ init (rcS)
|
||||
- **`docs/`(入库)**—— `MEOWOS_SPEC_REF.md` 权威速查总索引。
|
||||
- **`font/`(入库)**—— 字形系统:`gen/gen_font.py` 生成器、`meow_font.c/.h` 渲染库、`demo/`、`src/`(字体源文件)。
|
||||
- **`bootlogos/`(入库)**—— 启动 / 关机 logo。
|
||||
- **`third_party/`(入库)**—— 第三方参考/依赖归拢目录(与 MeowOS 源码 `patch_v2/` 分离、不混入构建):`CNMPlayer`(流媒体客户端功能对照)、`md-material-web`(**最新 Material 3 参考**,MD3 对齐依据,agent-435 按其对所有组件细化)。
|
||||
- **`LICENSE`** —— Apache-2.0。
|
||||
- **顶层文档(入库)**—— `APPS.md`、`MEOW_APP_SDK.md`、`FRAMEWORK_DESIGN.md`、`SYSTEM_SETTINGS.md`、`NOTIFICATION_SYSTEM.md`、`TF_CARD_LAYOUT.md`、`DEVELOPMENT_PLAN.md`、`GESTURE_BACK_DESIGN.md`、`KEY_CONTROL_DESIGN.md`、`PERF_SCHEDULER.md`、`MEOWOS_API.md`。
|
||||
- **本地构建资产(根目录 gitignore;但 `scripts/build/` 的 `README.md`/`build_fw_apps.sh`/`fw_content.conf` 已 `git add -f` 入库)**—— 其余(`fw_apps/` 固件组装产物、官方固件基线 rootfs、交叉工具链、本地开发文档、设备密钥等)不入库。详见「构建」与「部署」。
|
||||
@@ -309,7 +313,7 @@ patch_v2/hello_app/build_hello.sh # 链 meow_ui.a + libz.a + -lpthread → hel
|
||||
| `fullscreen` | — | app 级全屏:状态栏**收合至提示线** + 保留左缘返回 / 顶部提示线 / 下滑通知中心(activity 级用 `route.<id>.fullscreen`) |
|
||||
| `fonts` | — | 字库档位声明 `"full" | "gbk" | "common"`(默认 `common`≈4k) |
|
||||
|
||||
其他扩展(launcher 极简解析、不校验 JSON):`icon` / `framework`(导航栈承载)/ `immersive` / `route.<id>`(路由表,`framework=1` 时看门狗返回发 SIGUSR1)/ `awake.<id>`(keep_awake)/ `service.<svc>`。应用数上限 8,扫描深度 1 级。
|
||||
其他扩展(launcher 极简解析、不校验 JSON):`icon` / `framework`(导航栈承载)/ `immersive` / `route.<id>`(路由表,`framework=1` 时看门狗返回发 SIGUSR1)/ `awake.<id>`(keep_awake)/ `service.<svc>` / **`background_keepalive`**(后台常驻声明,M6.34:`1`=返回桌面**无限期保活**不清;缺省/`0` 走 30min 宽限期回收;播放类 App 若带 `background_audio` 且媒体活跃也无限期保活)。应用数上限 8,扫描深度 1 级。
|
||||
|
||||
### 6.2 链接与入口
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# MeowOS 固件内置(板载)二进制/组件清单
|
||||
|
||||
> **当前基线:M6.6(0.8.6)**。**本文件由 `scripts/build/fw_content.conf` 生成/手工同步——改固件内容改 conf,别改本文件。**
|
||||
> **当前基线:M6.35(0.8.35,commit `b23c6d5`,已上卡)**。**本文件由 `scripts/build/fw_content.conf` 生成/手工同步——改固件内容改 conf,别改本文件。**
|
||||
> 基线 = 官方 squashfs 解包 + 下表组件。打包入口 `scripts/build/build_fw_apps.sh`(见 scripts/build/README.md)。
|
||||
|
||||
| 固件内路径 | 仓库源 | 说明 |
|
||||
|---|---|---|
|
||||
| `/usr/bin/meow_bootmenu` | `patch_v2/bootmenu/meow_bootmenu` | 引导选单 M2.85(sysui让位 agent-216) |
|
||||
| `/usr/bin/meow_launcherd` | `patch_v2/meow_launcherd/meow_launcherd` | 主屏 launcher M6.6(M6.4 全量+310 三期:App 内手动息屏也触发锁屏+锁屏脏区全屏不透明底+300ms 重铺) |
|
||||
| `/usr/bin/meow_launcherd` | `patch_v2/meow_launcherd/meow_launcherd` | 主屏 launcher M6.35(批①后台常驻+宽限期 `f3ae06d/45c6338` + 批③后台管理页按钮化 `211c5a5/5c17422` + i18n settings/lock `c69fb92` + 310 三期锁屏/播放页双页歌词/取色;md5 `9515c9d8`) |
|
||||
| `/usr/bin/meow_watchdog` | `patch_v2/meow_watchdog/meow_watchdog` | 应用看门狗 M2.97(音量反转 + DS1039 平方锥度 + 插孔消抖) |
|
||||
| `/usr/bin/meow_svcd` | `patch_v2/meow_svcd/meow_svcd` | 服务管理器 M2.77(+崩溃上报 meow_crash agent-187) |
|
||||
| `/usr/bin/meow_mdns` | `mips-repo/bins/meow_mdns/bin/meow_mdns` | mDNS 应答器 M2.78(启动竞态+换IP重检 agent-194) |
|
||||
@@ -16,7 +16,7 @@
|
||||
| `/usr/bin/meow_verify` | `patch_v2/meow_verify/meow_verify` | II期验签 2B(多公钥+v2签名块 agent-221) |
|
||||
| `/usr/bin/meow_crash` | `patch_v2/meow_crash/meow_crash` | 系统级崩溃捕获 (agent-187) |
|
||||
| `/usr/bin/meow_svc_perf` | `patch_v2/meow_svc_perf/meow_svc_perf` | 性能专项服务 M2.75(net_json 补 network 闭合括号,修 /api/system JSON 解析失败) |
|
||||
| `/usr/bin/meow_audiod` | `patch_v2/meow_audiod/meow_audiod` | 独占音频守护 M6.5(M6.0 全量+315 孤儿会话两段式回收:客户端崩溃/OOM 杀后 STATE 回干净无流基线) |
|
||||
| `/usr/bin/meow_audiod` | `patch_v2/meow_audiod/meow_audiod` | 独占音频守护 M6.35(M6.0 全量+315 孤儿会话两段式回收+M6.34 A3/DSD:24/32bit 高清 byte-exact+DoP DSD 透传+音量收编 v2 双组写;改 glibc-dynamic 链 libasound;md5 `9f47c8fd`) |
|
||||
| `/usr/bin/meow_audio` | `patch_v2/meow_audiod/meow_audio` | audiod 用户侧 CLI M2.97(+monitor 实时电平表/文本电平条;status 补 rms/peak) |
|
||||
| `/usr/bin/meow_powermgr` | `patch_v2/meow_powermgr/meow_powermgr` | 功耗管理专项服务期2 M6.1(期1+history/top/events+mp2731 current_now 实测充电功率+WiFi 未关联档/BT rfkill 细化+系数路径 /data/meow/powermgr/;IPC 0xD0-D7) |
|
||||
| `/usr/bin/meow_power` | `patch_v2/meow_powermgr/meow_powermgr` | meow_powermgr 的 CLI 形态(同二进制双形态,argv[0]=meow_power 即 CLI:status/estimate/model/history/top/events,policy 期3桩) |
|
||||
@@ -24,7 +24,7 @@
|
||||
| `/usr/bin/meow_net` | `patch_v2/meow_netmgrd/meow_netmgrd` | meow_netmgrd 的 CLI 形态(同二进制双形态,argv[0]=meow_net 即 CLI:status/iface/wifi/bt/rfkill/apps/conns/top/dns/policy/help) |
|
||||
| `/etc/init.d/S70meow_sysui` | `patch_v2/meow_sysui/S70meow_sysui` | sysui 自启 M2.85(launcher.conf enabled=1+迁移) |
|
||||
| `/etc/init.d/S89meow_svcd` | `patch_v2/meow_svcd/S89meow_svcd` | svcd 自启 M6.2(+amsd.conf 热更 exec→/usr/bin 迁移) |
|
||||
| `/usr/bin/meow_amsd` | `patch_v2/meow_amsd/meow_amsd` | 活动管理器 M6.2(注册表自愈 3s 快重试+30s 合并重扫+am rescan + busy 识别修复 + 显示名 UTF-8 整序列 name[49] 修面板 overview 非法 UTF-8) |
|
||||
| `/usr/bin/meow_amsd` | `patch_v2/meow_amsd/meow_amsd` | 活动管理器 M6.35(注册表自愈 3s 快重试+30s 合并重扫+am rescan + busy 识别修复 + 显示名 UTF-8 整序列 name[49] + **批① FG/BG 上报 `EV BG/FG`/handle_bg/handle_fg**(md5 `57e19cde`,替代 M6.34 旧 `026cad52`;amsd FG/BG 供批③后台管理页/`am status` 数据源) |
|
||||
| `/usr/bin/am` | `patch_v2/meow_amsd/am` | amsd 用户侧CLI(+resolve/picker/rescan) |
|
||||
| `/etc/init.d/S90meow_bootmenu` | `patch_v2/bootmenu/S90meow_bootmenu` | bootmenu 自启 M2.85(注释同步) |
|
||||
| `/etc/init.d/S92_03_start_music_player` | `patch_v2/bootmenu/S92_03_start_music_player` | §4.3 兜底 patch(rcS 正常路径被 S90 阻塞不执行) |
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
- **功耗管理服务 `meow_powermgr`(期0 调研 + 期1 骨架 M6.0 已落地,期2 M6.1,规范 `devdocs/plan/POWER_MANAGER_DESIGN.md`)**:板载 `/usr/bin/meow_powermgr`,svcd 托管(板载 `services.d/meow_powermgr.conf`,`trigger=boot`;**HibyOS 模式只读降级**——只采样电池,绝不放策略/不碰 wlan0/bt/声卡)。双形态 CLI **`meow_power`**(同二进制 argv[0] 分流)。**期0 事实**(`devdocs/plan/POWER_HW_FACTS.md`):`battery/current_now` **不存在**→实时功率只能 `derived+estimated` 双轨、`cpufreq` **不存在**→省电档无降频点、**背光量程 0-101**(非 255!「亮度跳 100」=满量程、默认 101=满亮的根因)、`axp_battery` 是幻影设备、**mp2731 有 `current_now`**(充电功率可测)、rfkill×4 存在(期3 控制点)、无 thermal/hwmon。**期1**:权威充电态链复用(mp2731-charger/status)+ 经验模型 14 系数(`/data/meow/powermgr/power_model.conf` 可调)+ IPC 0x00D0-D3(QUERY/RESULT/POLICY_GET/POLICY_SET)+ CLI `status/estimate/model`。**期2(M6.1)**:`history`(**30s 环形、4h 不落盘**)+`top`(功耗分解排序,2s)+`events`(充放电/低电/亮灭屏/背光跳变事件流)+模型细化(**WiFi 未关联档**、**BT rfkill/ACL 档**)+ **mp2731 `current_now` 实测充电功率**(`charge_confidence=measured`;放电仍 `estimated`,诚实区分)。系数/状态路径 = **`/data/meow/powermgr/`**(组件持久化目录定案,SPEC_REF §1.1)。IPC 0x00D4-D7(期2 HISTORY/EVENTS)。`[patch_v2/meow_powermgr/]`
|
||||
- **网络管理服务 `meow_netmgrd`(期1 M6.0 已落地,期2/期3 M6.1,规范 `devdocs/plan/NETMGR_DESIGN.md`)**:板载 `/usr/bin/meow_netmgrd`,svcd 托管(板载 `services.d/meow_netmgrd.conf`,`trigger=boot`);双形态 CLI **`meow_net`**(`status/iface/wifi/bt/rfkill/apps/conns/top/dns/policy/help`)。**HibyOS 模式只读降级**(接口状态可查,绝不下发控制),RSS 预算 ≤1MB。期0 内核事实(`devdocs/plan/NETWORK_HW_FACTS.md`):**无 iptables 用户态/无 cgroup/无 conntrack → 内核级每应用管控不可行**,主杠杆 = **DNS 代理(域名级)+ 连接普查(inet_diag)+ SDK 自愿通道**;**双行 resolv.conf 崩溃零断流**。**期1 = 接口统一层**(iface 枚举+热插拔/WiFi `wpa_cli` 有界收割/BT `hciconfig`+`bluetoothctl`/rfkill/IP 路由 DNS 只读/udhcpc 续租释放仅 MeowOS)。**期2 = 连接普查 + DNS 代理**:2s `/proc/net/{tcp,udp}` 普查(inode→pid→**三实体** `app:<包名>`(查 amsd)/`svc:<服务名>`(查 svcd)/`proc:<comm>` 归因)+ DNS 代理(127.0.0.1:53,双行 resolv.conf+atexit 恢复+5s 自检,真机实测 enabled)+ 事件日志(1min 去重)+ CLI `apps/conns/top/dns`。**期3(M6.1)= 策略引擎**:`/data/meow/netmgrd/net_policy.conf`(组件持久化目录定案,SPEC_REF §1.1)读改写单键+原子写;**防自锁双层**(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init **拒绝置 block** + 策略文件被手工改坏时的加载校验强制复位);**SOCK_DESTROY 真机实测 = no-op(内核 4.4 接受请求但不销毁 socket)→ 连接级管控不可用,仅 DNS 层生效**(`meow_net policy show` 头明示 `enforce=dns_only`);classify_exe 面板白名单 + apps 归因修复 + WiFi `save_config`。日志 `MeowOS/logs/net_<bootts>.log`(无卡降级 /tmp)。`[patch_v2/meow_netmgrd/]`
|
||||
- **音频硬件事实(sndprobe/tonepcm2 真机实测,2026-08-31)**:播放 PCM = `/dev/snd/pcmC0D0p`(`cs43198-hifi-0`);`pcmC0D1p`=snd-soc-dummy(勿用)。能力:S16_LE/S24_LE、1-2ch、8k-768k、buffer 2048-1048576 帧、period 512-262144。⚠️ **ALSA UAPI 两坑**(流媒体客户端无声根因,勿再踩):①hw_params 必须「全放行」初始化(mask 全 1、interval [0,UINT_MAX],params_any 语义)再收窄——memset 0 的 interval=「只能取 0」求交必空 → HW_PARAMS 恒 EINVAL;②access/format/subformat mask 必须**先清零再单点**——留全 1 会被内核 choose 成 MMAP_INTERLEAVED,ch>1 时 write 恒 EINVAL。**CS43198 音量寄存器=衰减量(官方数据手册 DS1039 实证,用户供)**:PCM_VOLUME_A/B(0x0B/0x0C),0=0dB 满幅最大声、255=-127.5dB≈静音(0.5dB/步;DSD 通道独立 DSD_VOLUME_A/B)。**混音器 L/R Playback Volume 读回恒 0 是死镜像**(官方播放器走 sa_hgl_dma 硬直通不经此控制,HibyOS 对照实测同读 0),勿据此判音量。**音量寄存器=衰减量(0=最响 255=静音,M2.95 真机实测方向)**:四路(launcher/audio.c+audiod+watchdog+SDK vol)统一 `dac=255-pct*255/100`;mute 必须写 255 真静音(M2.96 炸耳修复)。混音器清单:numid=1/2 L/R Vol、3 Mute Output、4 Digital Filter、5 Soft Mute、6 NOS_EN、7 DRE_EN、8 Balance Lineout En、9 Output Port Switch(0 空闲/1 lineout/2 3.5/3 4.4/4 SPDIF;0 危险勿写;**5 = audiod 内部「强制重烧 FPGA」约定值**(M2.91 开机武装同值改写路径),非常规路由值,App/面板勿写)、10 DOP_EN。`[patch_v2/tools/sndprobe.c][tonepcm2.c][APPS §3.7]`
|
||||
- 后台权限:`MSG_HELLO/MSG_REGISTER.flags` 扩展 `MF_BACKGROUND`/`MF_PLAYBACK`(**已落地 M4**:app.json `background`/`background_audio` 字段 + MSG_REGISTER 校验链;播放中退出转后台保活见 FRAMEWORK_DESIGN §9);`PERF_BACKGROUND`/`PERF_AUDIO_BACKGROUND`(规划)`[FRAMEWORK_DESIGN 附录 A]`
|
||||
- 后台权限:`MSG_HELLO/MSG_REGISTER.flags` 扩展 `MF_BACKGROUND`/`MF_PLAYBACK`(**已落地 M4**:app.json `background`/`background_audio` 字段 + MSG_REGISTER 校验链;播放中退出转后台保活见 FRAMEWORK_DESIGN §9);`PERF_BACKGROUND`/`PERF_AUDIO_BACKGROUND`(规划)`[FRAMEWORK_DESIGN 附录 A]`。**M6.34+ 追加后台常驻框架**:app.json `background_keepalive`(无限期保活)+ `meow_bg_should_keep` 三参判定 + 宽限期 `MEOW_BG_GRACE_MS`(30min)回收 + amsd `EV BG/FG` 上报(批③后台管理页数据源),设计见 `devdocs/plan/APP_BACKGROUND_FRAMEWORK.md` §7 / `APP_BACKGROUND_KEEPALIVE_P1.md`。
|
||||
|
||||
---
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
- **⑨ 面板「跟手下拉、半程可回」(launcher 侧,M2.65)**:面板打开/关闭由触发式 180ms 过渡升级为**手指拖动实时跟手**——顶部热区下拉 `progress = 手指位移映射`(复用 `mnp_anim_off`,∈[0,1024],1:1 跟手);松手 `progress≥40%×1024≈410` → 时间轴补到 1024(展开);`<阈值 或 反向拖回` → 回弹到 0 收回(半程取消);关闭同理可跟手。与行左滑(⑦ 横向主导)/列表滚动(竖向溢出)/`meow_edge_back`(左缘)互斥分流。`[NOTIFICATION_SYSTEM §9]`
|
||||
|
||||
### 5.4 配置版本单点 / 刷新率 / 息屏
|
||||
- **现行:M2.96(`/etc/meowos_version` = `0.7.91`,已刷;261 audiod 电平监控已提交、待 M2.97 固件化;M2.95/0.7.90、M2.94/0.7.89、M2.93/0.7.88、M2.92/0.7.87、M2.91/0.7.86、M2.90/0.7.85、M2.89/0.7.84、M2.88/0.7.83、M2.83/0.7.78、M2.82/0.7.77、M2.79/0.7.74、M2.78/0.7.73、M2.77/0.7.72、M2.76/0.7.71、M2.75/0.7.70、M2.74/0.7.69、M2.73/0.7.68、M2.72/0.7.67、M2.71/0.7.66、M2.70/0.7.65、M2.69/0.7.64 …)**;版本单点 = `/etc/meowos_version`(见 §1.1,`build_fw_apps.sh -v` 写入)`[DEV_NOTES #4][fw_content.conf]`
|
||||
- **现行:M6.35(`/etc/meowos_version` = `0.8.35`,已上卡;M6.34/0.8.34、M6.33/0.8.33、M6.32/0.8.32、M6.31/0.8.31、M6.30/0.8.30、M6.29/0.8.29、M6.28/0.8.28、M6.27/0.8.27、M6.26/0.8.26、M6.25/0.8.25、M6.24/0.8.24、M6.23/0.8.23、M6.22/0.8.22、M6.21/0.8.21、M6.20/0.8.20、M6.19/0.8.19;更早 M6.18-0.8.18 …)**;版本单点 = `/etc/meowos_version`(见 §1.1,`build_fw_apps.sh -v` 写入)`[DEV_NOTES #4][fw_content.conf]`
|
||||
- 刷新率:`display.refresh_rate` enum 60/30/15(默认 60);`display.refresh_always_max` bool false(关=允许 60→30→15 降档)。governor:连续 12 帧超预算降一档,连续 4 批≈48 帧远低预算升一档 `[SYSTEM_SETTINGS §1][PERF_SCHEDULER][MIGRATION_AC §12.1]`
|
||||
- 息屏:`display.screen_off_timeout` int 秒 默认 **30**(0=常亮);`display.screen_off_mode` enum `panel`(面板下电+背光0+FBIOPBLANK)/`black`(背光0全黑+双击亮屏) `[SYSTEM_SETTINGS §1]`
|
||||
- 亮度 clamp:`display.brightness` 0-255 默认 101(clamp ≥ `display.brightness_min`);`display.brightness_min` 默认 **1**(M2.39 由 25 改 1)——**与旧文档 25 冲突见 §A** `[SYSTEM_SETTINGS §1]`
|
||||
@@ -236,7 +236,7 @@
|
||||
- `<pkg>/` = 程序主体+app.json+资源;`MeowData/<pkg>/{data,cache}` = 数据;首次启动自动建 data/cache 并经 env 注入,exFAT 无软链 `[APPS 附录 §1/§2]`
|
||||
- `.mpk` = tar.gz(app.json + meow 二进制 + 资源),**不含 data/cache**;导入校验 app.json/哈希→解压→数据 init→登入列表 `[APPS 附录 §3]`
|
||||
- 包管理 **CLI `nyai`**(静态 MIPS,部署 `MeowOS/opt/bin/nyai`):install/remove/list/info/search/index/export/upgrade/scan;源 `MeowOS/opt/nyai/sources.list`;索引 `Packages`(Package/Version/Filename/Size/SHA256/Depends/Description)`[APPS 附录 §5]`
|
||||
- 应用数上限 **8**(单屏 8 行);扫描深度 1 级;`app.json` 字段:name(仅 ASCII)/exec(静态 MIPS32r2 ELF)/version/author + **`package`(反域名权威 app_id;==目录名;缺省回退目录名)** + 扩展 `framework`/`immersive`/`icon`/`route.<id>`/`awake.<id>`(keep_awake)/`service.<svc>`(M4 起服务二进制=App 同目录 `<svc>` 文件)/**`background`/`background_audio`(M4 已落地:无头服务注册权限/后台播放保活)**/`fullscreen`(app 级;`route.<id>.fullscreen`=activity 级;**本次实现**:launcher 启动/切 activity 据声明预设 SDK 全屏,见 §5.1/§3.1/§A-12)/`fonts`(字体档位 `"full"|"gbk"|"common"`,需大字库时声明,见 §5.7.8)/`depends`(**二进制依赖**声明:`"curl,jq:optional"` 或行式 `depends=curl,jq:optional,ffmpeg:required`;comma 分项 `<name>[:<mod>]`,mod=required|req(默认)/optional|opt;launcher 启动前查 `/usr/bin/<name>` 与 `/usr/data/mnt/sd_0/MeowOS/opt/bin/<name>`(X_OK,命一即可),required 缺失阻断启动(「缺依赖」toast)、optional 缺失仅 WARN,**仅启动时检查**;nyai install 同查并提示) `[APPS §2][MEOW_APP_SDK §9][FRAMEWORK_DESIGN §2][源:审计 2026-08-30][源:用户 2026-08-30]`
|
||||
- 应用数上限 **8**(单屏 8 行);扫描深度 1 级;`app.json` 字段:name(仅 ASCII)/exec(静态 MIPS32r2 ELF)/version/author + **`package`(反域名权威 app_id;==目录名;缺省回退目录名)** + 扩展 `framework`/`immersive`/`icon`/`route.<id>`/`awake.<id>`(keep_awake)/`service.<svc>`(M4 起服务二进制=App 同目录 `<svc>` 文件)/**`background`/`background_audio`(M4 已落地:无头服务注册权限/后台播放保活)**/**`background_keepalive`(M6.34 起:后台常驻声明——`1`=返回桌面无限期保活不清;缺省走 30min `MEOW_BG_GRACE_MS` 宽限期回收;与 `background_audio`+媒体活跃可叠加无限期保活)**/`fullscreen`(app 级;`route.<id>.fullscreen`=activity 级;**本次实现**:launcher 启动/切 activity 据声明预设 SDK 全屏,见 §5.1/§3.1/§A-12)/`fonts`(字体档位 `"full"|"gbk"|"common"`,需大字库时声明,见 §5.7.8)/`depends`(**二进制依赖**声明:`"curl,jq:optional"` 或行式 `depends=curl,jq:optional,ffmpeg:required`;comma 分项 `<name>[:<mod>]`,mod=required|req(默认)/optional|opt;launcher 启动前查 `/usr/bin/<name>` 与 `/usr/data/mnt/sd_0/MeowOS/opt/bin/<name>`(X_OK,命一即可),required 缺失阻断启动(「缺依赖」toast)、optional 缺失仅 WARN,**仅启动时检查**;nyai install 同查并提示) `[APPS §2][MEOW_APP_SDK §9][FRAMEWORK_DESIGN §2][源:审计 2026-08-30][源:用户 2026-08-30]`
|
||||
|
||||
### 5.6 冷启动充电屏
|
||||
- 触发(**三者同时**):冷启动(`poweroff` 写 `/data/meow_cold_boot=1`;`reboot` 写 0)+ 在充电(AC online=1 或 battery status=Charging)+(开机瞬间一次性快照,判定后不回头)`[CHARGING_SCREEN §0]`
|
||||
|
||||
@@ -135,6 +135,10 @@ def build_charset(tier="common", extra=""):
|
||||
add(ord(bytes([hi, lo]).decode("gb2312")))
|
||||
except UnicodeDecodeError:
|
||||
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"):
|
||||
# CJK 统一表意(含常用简繁/生僻):U+4E00-9FFF ≈ 2万+
|
||||
for cp in range(0x4E00, 0xA000):
|
||||
|
||||
Executable
+102
@@ -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
|
||||
Executable
+124
@@ -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
|
||||
@@ -22,7 +22,8 @@ patch_v2/files_app/
|
||||
│ ├── fsx.h # 纯业务接口(不依赖 meow_ui)
|
||||
│ └── fsx.c # 路径安全/格式化/枚举排序/虚拟化区间/写操作(可宿主测)
|
||||
└── test/
|
||||
└── test_fsx.c # 宿主单元自测(gcc 直接编,指向临时卡根)
|
||||
├── test_fsx.c # 宿主单元自测(gcc 直接编,指向临时卡根)
|
||||
└── test_row_text_fit.c # 宿主几何断言:列表行高 ≥ 双行文本块(M17)
|
||||
```
|
||||
|
||||
## 构建
|
||||
@@ -55,12 +56,20 @@ v1 无需持久化,暂未使用(启动恒进卡根 `/usr/data/mnt/sd_0/`)
|
||||
cd patch_v2/files_app
|
||||
gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \
|
||||
test/test_fsx.c logic/fsx.c -o /tmp/test_fsx && /tmp/test_fsx
|
||||
|
||||
# 列表行高几何(无需 meow_ui.a;Termux 下产物落 $TMPDIR)
|
||||
gcc -O2 -Wall -Wextra -I. -I../sdk test/test_row_text_fit.c \
|
||||
-o "$TMPDIR/test_row_text_fit" && "$TMPDIR/test_row_text_fit"
|
||||
```
|
||||
|
||||
覆盖:路径安全(`within_sd`/`join`/`sanitize`/`dirname`/`basename`)、展示格式化(`fmt_size`/`fmt_time`)、
|
||||
目录枚举排序(`scan_dir`/`sort`)、虚拟化区间(`visible_range`/`max_scroll`)、写操作(`mkdir`/`rename`/
|
||||
`delete`,含卡外/越界拒绝)、非文本写拒绝。宿主全 PASS。
|
||||
|
||||
`test_row_text_fit` 是 M17 行高回归:目录列表行恒为双行档位,行高必须 ≥ 文本块
|
||||
`2*meow_ui_text_height(font_body) + 2`(真机 20px 字库行高 30 → 62),否则 SDK 居中后首行标题
|
||||
`ty < 视口顶` 被裁剪守卫整条跳过。断言矩阵覆盖真机五种字库行高 + 旧固定 56 的 bug 形态。
|
||||
|
||||
## 交互
|
||||
|
||||
- 触摸:行 body=进目录/开文本预览;右侧 `⋯` 或长按→BottomSheet(删除[确认弹窗]/重命名[IME]/新建目录[IME]/属性[大小·mtime]);
|
||||
@@ -78,6 +87,10 @@ gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \
|
||||
位移 ≤ 容忍」→ 经 `meow_ui_set_long_press_cb` 注册的回调带坐标交给本页;`browser_on_long_press`
|
||||
用 `meow_listview_hit` 行命中打开 BottomSheet。App 层长按计时(`browser_tick`/`g_press_ms`)已删除,
|
||||
其抬手 TAP 由 SDK 抑制(长按不再触发点击)。
|
||||
- **行高按字体行高派生(M17)**:`files_row_h()` = `max(APP_ROW_H, 2*行高 + 2)`(当前主题正文
|
||||
`font_body` 的 `meow_ui_text_height`)。目录行是双行档位,行高小于双行文本块时 SDK 居中后首行
|
||||
标题落在视口顶之上被裁剪(20px 字库行高 30 → 块 62,旧的显式 56 首行名称空白)。滚动/命中/
|
||||
最大滚动量由 SDK 按同一行高换算,页脚几何独立(`r_list` 单独扣 `APP_FOOTER_H`)。
|
||||
2. **中文字形**:SDK 默认 `common` 字库(≈4k)不含 CJK 全量;exFAT 中文文件名/中文文本预览在 common 档会显示缺字豆腐。
|
||||
预览页不声明 `fonts`(app.json 未加 `"fonts":"full"`/`"gbk"`),如需中文预览可在 app.json 声明大字号档。
|
||||
3. **预览仅文本**:按扩展名白名单 + 首 512 字节 NUL 嗅探判文本;二进制/图片无预览(提示「not a text file」)。
|
||||
@@ -88,7 +101,7 @@ gcc -O2 -Wall -Wextra -DFSX_SD_ROOT='"/tmp/meowfsx_fsx_test"' -Ilogic \
|
||||
## 验证状态
|
||||
|
||||
- 编译:`-Wall -Wextra` 0 warning/0 error,产物 `files`(MIPS32r2 静态 ELF)。
|
||||
- 宿主:`test/test_fsx.c` 全 PASS。
|
||||
- 宿主:`test/test_fsx.c` 全 PASS;`test/test_row_text_fit.c` 32 断言全 PASS(M17 行高几何)。
|
||||
- qemu-mipsel(无 /dev/fb0 → SDK 离屏 480×720):正常运行到事件循环,导出离屏帧,
|
||||
确认 init/建页/topbar(返回+NEW)/空态(NO STORAGE CARD)渲染正确、SIGTERM 干净退出。
|
||||
- 实机目录列表渲染(有真实 TF 卡目录)需刷机/设备挂载验证——宿主模拟无法造出 `/usr/data/mnt/sd_0`。
|
||||
Binary file not shown.
@@ -37,13 +37,38 @@ void picker_cancel(void); /* 取消:发 code=MEOW_ERR
|
||||
#define APP_W 480
|
||||
#define APP_H 720
|
||||
#define APP_TOPBAR_H 56
|
||||
#define APP_ROW_H 56 /* 列表行高 */
|
||||
#define APP_ROW_H 56 /* 列表行高下限(旧固定值;实际按字体行高派生,见 files_row_h) */
|
||||
#define APP_PAD 16
|
||||
#define APP_ICON_W 26 /* 图标占宽 */
|
||||
#define APP_OP_W 52 /* 右侧操作钮占宽(触达区) */
|
||||
#define APP_FOOTER_H 26 /* 底部信息行高(列表/文本内容在其之上滚动) */
|
||||
#define APP_READ_MAX (256 * 1024) /* 文本预览读取上限 */
|
||||
|
||||
/* ---- 列表行高:按字体行高派生(M17,首行标题不显示根因修复)----
|
||||
* SDK meow_listview 的双行渲染把「标题+副标题」文本块
|
||||
* block = 2 * meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2)
|
||||
* 在行内垂直居中(ty = 行顶 + (item - block) / 2),标题只在 ty >= 视口顶时才画。
|
||||
* item < block 时 ty = 行顶 - Δ:第一行(行顶 == 视口顶)标题被整条跳过、只剩副标题。
|
||||
* 真机 source_han_serif 20px(ascent 24 + descent 6)行高 30 → block 62,旧的显式 56 → Δ=3。
|
||||
* 故行高按当前主题正文行高派生:max(APP_ROW_H, 2*line_h + 2)(行高 30 → 62)。
|
||||
* 本页行恒为双行档位(browser_src2 填 title + subtitle)→ 用 lines=2 公式。
|
||||
* 行几何(draw / hit / max_scroll)全由 SDK 按传入的这一个值换算,故滚动与命中天然同步;
|
||||
* 页脚几何与行高无关(r_list 单独扣 APP_FOOTER_H)。 */
|
||||
#define FILES_ROW_SUB_GAP 2 /* 与 SDK MEOW_LISTV_SUB_GAP 同口径(标题/副标题行距) */
|
||||
|
||||
static inline int files_row_h_for_line_h(int line_h)
|
||||
{
|
||||
if (line_h < 1) line_h = 1; /* 字体未就绪 → 退回下限 */
|
||||
int h = 2 * line_h + FILES_ROW_SUB_GAP;
|
||||
return (h > APP_ROW_H) ? h : APP_ROW_H;
|
||||
}
|
||||
/* 当前主题正文行高下的两行列表行高(页面 create 时取一次;字体档位切换走页面重建)。 */
|
||||
static inline int files_row_h(void)
|
||||
{
|
||||
const meow_theme *th = meow_theme_get();
|
||||
return files_row_h_for_line_h(th ? meow_ui_text_height(th->font_body) : 0);
|
||||
}
|
||||
|
||||
/* ---- App 级复用矩形/几何 ---- */
|
||||
typedef struct { int x, y, w, h; } app_rect_t;
|
||||
#define AR(x,y,w,h) ((app_rect_t){ (x),(y),(w),(h) })
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* test/test_row_text_fit.c — 宿主回归(M17):目录列表行高必须容纳双行文本块。
|
||||
*
|
||||
* 背景(真机根因,承接 M12/M15):SDK meow_listview 的双行渲染把
|
||||
* block = 2 * meow_ui_text_height(font_body) + 2 (标题行 + 2px 行距 + 副标题行)
|
||||
* 在行内垂直居中:ty = 行顶 + (item - block) / 2;且标题只在 `ty >= 视口顶` 时才画(越界钳制)。
|
||||
* 行高 item < block 时 ty < 行顶 → **第一行(行顶 == 视口顶)标题被整条跳过**、副标题仍在。
|
||||
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 → 行高 30)block = 62,旧的
|
||||
* 显式 set_item_h(56) → ty = 行顶 - 3(本 App 目录列表首行名称空白)。
|
||||
*
|
||||
* 本测试只验 app 侧行高派生(files_row_h_for_line_h,纯整数、不链 SDK):
|
||||
* 行高 = max(APP_ROW_H, 2*行高 + FILES_ROW_SUB_GAP) → block ≤ item,首行标题不被钳掉。
|
||||
*
|
||||
* 编译/运行(宿主 gcc,只需 SDK 头,不需要 meow_ui.a):
|
||||
* cd patch_v2/files_app
|
||||
* gcc -O2 -Wall -Wextra -I. -I../sdk test/test_row_text_fit.c -o "$TMPDIR/test_row_text_fit" \
|
||||
* && "$TMPDIR/test_row_text_fit"
|
||||
*
|
||||
* 断言矩阵 = 真机已装字库 20px 面实测行高:source_han_serif 30(当前生效)、
|
||||
* misans/default 27、google_sans/meslo 26、source_han_sans 21;14 = 宿主 5x7 回退。
|
||||
* ① block ≤ item(否则居中偏移为负)
|
||||
* ② 首行 ty ≥ 0 → 绝对 ty_abs ≥ 视口顶(标题不被裁剪守卫跳过)
|
||||
* ③ 副标题底边 ty + block ≤ item(两行都不越行)
|
||||
* ④ item ≥ APP_ROW_H 下限(小字库/字体未就绪不缩回固定小值)
|
||||
* ⑤ 旧固定 56 在行高 30 下 ty < 0(bug 形态复现,防止再写死 56)
|
||||
*/
|
||||
#include "../files_internal.h"
|
||||
#include <stdio.h>
|
||||
|
||||
static int g_checks, g_fail;
|
||||
|
||||
static void chk(int cond, int line_h, const char *what)
|
||||
{
|
||||
g_checks++;
|
||||
if (!cond) {
|
||||
g_fail++;
|
||||
printf(" FAIL lh=%d: %s\n", line_h, what);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
static const int lh_all[] = { 14, 21, 26, 27, 30 };
|
||||
const int n = (int)(sizeof lh_all / sizeof lh_all[0]);
|
||||
const int list_y = 120; /* 任意列表顶(只验相对几何) */
|
||||
|
||||
for (int k = 0; k < n; k++) {
|
||||
int lh = lh_all[k];
|
||||
int item = files_row_h_for_line_h(lh);
|
||||
int block = 2 * lh + FILES_ROW_SUB_GAP;
|
||||
int ty = (item - block) / 2; /* SDK 双行文本块居中偏移 */
|
||||
|
||||
printf("lh=%2d item=%3d block=%3d ty=%d\n", lh, item, block, ty);
|
||||
|
||||
chk(block <= item, lh, "文本块必须 <= 行高(否则首行标题被视口顶裁剪)");
|
||||
chk(ty >= 0, lh, "首行标题 y 不得高于行顶/视口顶");
|
||||
chk(ty + block <= item, lh, "副标题底边仍在行内");
|
||||
chk(item >= APP_ROW_H, lh, "行高不得小于 APP_ROW_H 下限");
|
||||
|
||||
/* 首行(scroll=0 → 行顶 == 视口顶)的绝对坐标 */
|
||||
chk(list_y + ty >= list_y, lh, "首行标题绝对 y 不得被视口顶裁掉");
|
||||
}
|
||||
|
||||
/* 行高随字体行高单调不减 */
|
||||
chk(files_row_h_for_line_h(30) >= files_row_h_for_line_h(26), 30, "行高随字体行高单调不减");
|
||||
|
||||
/* 真机当前生效字库(source_han_serif 20px,行高 30):block 62 → item 62 */
|
||||
chk(files_row_h_for_line_h(30) == 62, 30, "行高 30 → 派生行高 62");
|
||||
chk(2 * 30 + FILES_ROW_SUB_GAP <= files_row_h_for_line_h(30), 30, "派生行高容纳 62px 文本块");
|
||||
|
||||
/* 下限:小字库 / 字体未就绪(line_h=0)仍不缩回固定小值 */
|
||||
chk(files_row_h_for_line_h(14) == APP_ROW_H, 14, "小字库退回 APP_ROW_H 下限");
|
||||
chk(files_row_h_for_line_h(0) == APP_ROW_H, 0, "字体未就绪(行高 0)退回下限");
|
||||
|
||||
/* 回归哨兵:旧固定行高 56 + 当前默认字体(行高 30)就是 bug 形态 */
|
||||
{
|
||||
const int lh = 30;
|
||||
int block = 2 * lh + FILES_ROW_SUB_GAP; /* 62 */
|
||||
int ty_legacy = (APP_ROW_H - block) / 2; /* -3 */
|
||||
chk(ty_legacy < 0, lh, "旧行高 56 + 行高 30 → ty<0(首行标题被跳过,bug 复现)");
|
||||
chk(files_row_h_for_line_h(lh) > APP_ROW_H, lh, "默认字体下行高必须大于旧值 56");
|
||||
}
|
||||
|
||||
printf("%s: %d checks, %d fail (font line-height matrix: %d)\n",
|
||||
g_fail ? "FAIL" : "PASS", g_checks, g_fail, n);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -421,7 +421,7 @@ static void browser_page_create(meow_view *v, void *ctx)
|
||||
/* 结构化列表:行数据源 browser_src2;单选模式(行高亮用 selected,§5.8.3) */
|
||||
g_list = meow_listview_create(r_list.x, r_list.y, r_list.w, r_list.h, 0,
|
||||
browser_src2, NULL, MEOW_LIST_SEL_SINGLE);
|
||||
meow_listview_set_item_h(g_list, APP_ROW_H);
|
||||
meow_listview_set_item_h(g_list, files_row_h()); /* M17:按字体行高派生,容纳双行文本块 */
|
||||
/* 空态/页脚注册为非控件绘制物(列表视口不含页脚;invalidate/paint_full 自动重画) */
|
||||
g_empty_id = meow_draw_custom_register(1, r_list.x, r_list.y, r_list.w, r_list.h,
|
||||
browser_draw_empty, NULL);
|
||||
|
||||
@@ -39,7 +39,27 @@ static int viewer_vp_h(void) { return r_content.h - APP_FOOTER_H; }
|
||||
/* 屏幕行高(px)。 */
|
||||
static int viewer_rowh(const meow_theme *th) { return meow_ui_text_height(th->font_body) + 6; }
|
||||
|
||||
/* 折行:返回逻辑行 li 折成的屏幕行数;若 intra>=0,把该屏幕行文本写入 out(NUL 结尾)。 */
|
||||
/* UTF-8 码位字节长(1..4):s[bi] 起、串内有效字节数 lim。
|
||||
* 首字节非法/续字节非法/序列不完整 → 保守按 1(单字节);保证折行绝不断在多字节码位中间,
|
||||
* 也保证宽测量(meow_ui_text_width 按码位测)对中文/混合文本正确。 */
|
||||
static int viewer_cp_len(const unsigned char *s, int bi, int lim)
|
||||
{
|
||||
unsigned char c = s[bi];
|
||||
int n;
|
||||
if (c < 0x80) return 1;
|
||||
else if ((c & 0xE0) == 0xC0) n = 2;
|
||||
else if ((c & 0xF0) == 0xE0) n = 3;
|
||||
else if ((c & 0xF8) == 0xF0) n = 4;
|
||||
else return 1;
|
||||
if (lim - bi < n) return 1;
|
||||
for (int i = 1; i < n; i++)
|
||||
if ((s[bi + i] & 0xC0) != 0x80) return 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 折行:返回逻辑行 li 折成的屏幕行数;若 intra>=0,把该屏幕行文本写入 out(NUL 结尾)。
|
||||
* 按「UTF-8 码位」逐字累加显示宽度(meow_ui_text_width 按码位测>0),到内容宽 colw 即断行;
|
||||
* 多字节(CJK)整字不断开。 */
|
||||
static int viewer_wrap_line(int li, int intra, char *out, int outsz)
|
||||
{
|
||||
if (!g_buf || li < 0 || li >= g_nline) return 0;
|
||||
@@ -49,13 +69,15 @@ static int viewer_wrap_line(int li, int intra, char *out, int outsz)
|
||||
while (len > 0 && (g_buf[start + len - 1] == '\n' || g_buf[start + len - 1] == '\r')) len--;
|
||||
int colw = viewer_colw();
|
||||
int col = 0, row = 0, outlen = 0;
|
||||
char ch[2]; ch[1] = '\0';
|
||||
for (int i = 0; i < len; i++) {
|
||||
ch[0] = g_buf[start + i];
|
||||
if (ch[0] == '\r') continue;
|
||||
int w = meow_ui_text_width(ch, 20);
|
||||
const unsigned char *s = (const unsigned char *)g_buf + start;
|
||||
for (int bi = 0; bi < len; ) {
|
||||
int clen = viewer_cp_len(s, bi, len);
|
||||
if (clen == 1 && s[bi] == '\r') { bi++; continue; }
|
||||
char cp[5]; cp[clen] = '\0';
|
||||
memcpy(cp, s + bi, (size_t)clen); bi += clen;
|
||||
int w = meow_ui_text_width(cp, 20);
|
||||
if (col + w > colw && col > 0) { row++; col = 0; }
|
||||
if (row == intra && out && outlen < outsz - 1) out[outlen++] = ch[0];
|
||||
if (row == intra && out && outlen + clen < outsz) { memcpy(out + outlen, cp, (size_t)clen); outlen += clen; }
|
||||
col += w;
|
||||
}
|
||||
if (out) out[outlen] = '\0';
|
||||
|
||||
Binary file not shown.
@@ -33,6 +33,31 @@ extern meow_sheet *g_sheet; /* BottomSheet(阻塞模态;main 创建
|
||||
typedef struct { int x, y, w, h; } rect_t;
|
||||
#define RX(x,y,w,h) ((rect_t){ (x), (y), (w), (h) })
|
||||
|
||||
/* ---- 两行列表行高:按字体行高派生(M17,首行标题不显示根因修复)----
|
||||
* SDK meow_listview 的双行渲染把「标题+副标题」文本块
|
||||
* block = 2 * meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2)
|
||||
* 在行内垂直居中(ty = 行顶 + (item - block) / 2),标题只在 ty >= 视口顶时才画。
|
||||
* item < block 时 ty = 行顶 - Δ:第一行(行顶 == 视口顶)标题被整条跳过、只剩副标题。
|
||||
* 真机 source_han_serif 20px(ascent 24 + descent 6)行高 30 → block 62;demo 的 swipe 列表
|
||||
* 是双行档位(set_item_renderer(2,...)),旧的显式 48 比 block 小 14 → 首行标题必丢。
|
||||
* 故行高按当前主题正文行高派生:max(HELLO_ROW_H_MIN, 2*line_h + 2)(行高 30 → 62)。
|
||||
* 行几何(draw / hit / max_scroll)全由 SDK 按传入的这一个值换算,滚动与命中天然同步。 */
|
||||
#define HELLO_ROW_H_MIN 56
|
||||
#define HELLO_ROW_SUB_GAP 2 /* 与 SDK MEOW_LISTV_SUB_GAP 同口径(标题/副标题行距) */
|
||||
|
||||
static inline int hello_row_h_for_line_h(int line_h)
|
||||
{
|
||||
if (line_h < 1) line_h = 1; /* 字体未就绪 → 退回下限 */
|
||||
int h = 2 * line_h + HELLO_ROW_SUB_GAP;
|
||||
return (h > HELLO_ROW_H_MIN) ? h : HELLO_ROW_H_MIN;
|
||||
}
|
||||
/* 当前主题正文行高下的两行列表行高(建列表时取一次;字体档位切换走内容重建)。 */
|
||||
static inline int hello_row_h(void)
|
||||
{
|
||||
const meow_theme *th = meow_theme_get();
|
||||
return hello_row_h_for_line_h(th ? meow_ui_text_height(th->font_body) : 0);
|
||||
}
|
||||
|
||||
/* ---- 视图控制器(ctx = biz-UI 绑定点)----
|
||||
* 本批控制器为薄壳:持 meow_view 回指针;页面细粒度视图态(g_ds/g_drag_* /scroll 等)留在各页
|
||||
* 模块作用域全局(设计定案 B:demo 视图态不迁 logic)。控制器未来可吸收这些状态。 */
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/* test/test_row_text_fit.c — 宿主回归(M17):demo 的 swipe 列表行高必须容纳双行文本块。
|
||||
*
|
||||
* 背景(真机根因,承接 M12/M15):SDK meow_listview 的双行渲染把
|
||||
* block = 2 * meow_ui_text_height(font_body) + 2 (标题行 + 2px 行距 + 副标题行)
|
||||
* 在行内垂直居中:ty = 行顶 + (item - block) / 2;且标题只在 `ty >= 视口顶` 时才画(越界钳制)。
|
||||
* 行高 item < block 时 ty < 行顶 → **第一行(行顶 == 视口顶)标题被整条跳过**、副标题仍在。
|
||||
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 → 行高 30)block = 62;
|
||||
* demo 的 swipe 列表是双行档位(set_item_renderer(2,...))却显式 set_item_h(48) → ty = 行顶 - 7,
|
||||
* 「notif 0」首行标题在真机上必然空白。
|
||||
*
|
||||
* 本测试只验 app 侧行高派生(hello_row_h_for_line_h,纯整数、不链 SDK):
|
||||
* 行高 = max(HELLO_ROW_H_MIN, 2*行高 + HELLO_ROW_SUB_GAP) → block ≤ item,首行标题不被钳掉。
|
||||
*
|
||||
* 编译/运行(宿主 gcc,只需 SDK 头,不需要 meow_ui.a):
|
||||
* cd patch_v2/hello_app
|
||||
* gcc -O2 -Wall -Wextra -I. -I../sdk test/test_row_text_fit.c -o "$TMPDIR/test_row_text_fit" \
|
||||
* && "$TMPDIR/test_row_text_fit"
|
||||
*
|
||||
* 断言矩阵 = 真机已装字库 20px 面实测行高:source_han_serif 30(当前生效)、
|
||||
* misans/default 27、google_sans/meslo 26、source_han_sans 21;14 = 宿主 5x7 回退。
|
||||
* ① block ≤ item(否则居中偏移为负)
|
||||
* ② 首行 ty ≥ 0 → 绝对 ty_abs ≥ 视口顶(标题不被裁剪守卫跳过)
|
||||
* ③ 副标题底边 ty + block ≤ item(两行都不越行)
|
||||
* ④ item ≥ HELLO_ROW_H_MIN 下限(小字库/字体未就绪不缩回固定小值)
|
||||
* ⑤ 旧固定 48 在行高 30 下 ty < 0(bug 形态复现,防止再写死 48)
|
||||
*/
|
||||
#include "../hello_internal.h"
|
||||
#include <stdio.h>
|
||||
|
||||
static int g_checks, g_fail;
|
||||
|
||||
static void chk(int cond, int line_h, const char *what)
|
||||
{
|
||||
g_checks++;
|
||||
if (!cond) {
|
||||
g_fail++;
|
||||
printf(" FAIL lh=%d: %s\n", line_h, what);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
static const int lh_all[] = { 14, 21, 26, 27, 30 };
|
||||
const int n = (int)(sizeof lh_all / sizeof lh_all[0]);
|
||||
const int list_y = 400; /* 任意列表顶(只验相对几何) */
|
||||
|
||||
for (int k = 0; k < n; k++) {
|
||||
int lh = lh_all[k];
|
||||
int item = hello_row_h_for_line_h(lh);
|
||||
int block = 2 * lh + HELLO_ROW_SUB_GAP;
|
||||
int ty = (item - block) / 2; /* SDK 双行文本块居中偏移 */
|
||||
|
||||
printf("lh=%2d item=%3d block=%3d ty=%d\n", lh, item, block, ty);
|
||||
|
||||
chk(block <= item, lh, "文本块必须 <= 行高(否则首行标题被视口顶裁剪)");
|
||||
chk(ty >= 0, lh, "首行标题 y 不得高于行顶/视口顶");
|
||||
chk(ty + block <= item, lh, "副标题底边仍在行内");
|
||||
chk(item >= HELLO_ROW_H_MIN, lh, "行高不得小于 HELLO_ROW_H_MIN 下限");
|
||||
|
||||
/* 首行(scroll=0 → 行顶 == 视口顶)的绝对坐标 */
|
||||
chk(list_y + ty >= list_y, lh, "首行标题绝对 y 不得被视口顶裁掉");
|
||||
}
|
||||
|
||||
/* 行高随字体行高单调不减 */
|
||||
chk(hello_row_h_for_line_h(30) >= hello_row_h_for_line_h(26), 30, "行高随字体行高单调不减");
|
||||
|
||||
/* 真机当前生效字库(source_han_serif 20px,行高 30):block 62 → item 62 */
|
||||
chk(hello_row_h_for_line_h(30) == 62, 30, "行高 30 → 派生行高 62");
|
||||
chk(2 * 30 + HELLO_ROW_SUB_GAP <= hello_row_h_for_line_h(30), 30, "派生行高容纳 62px 文本块");
|
||||
|
||||
/* 下限:小字库 / 字体未就绪(line_h=0)仍不缩回固定小值 */
|
||||
chk(hello_row_h_for_line_h(14) == HELLO_ROW_H_MIN, 14, "小字库退回 HELLO_ROW_H_MIN 下限");
|
||||
chk(hello_row_h_for_line_h(0) == HELLO_ROW_H_MIN, 0, "字体未就绪(行高 0)退回下限");
|
||||
|
||||
/* 回归哨兵:旧固定行高 48 + 当前默认字体(行高 30)就是 bug 形态 */
|
||||
{
|
||||
const int lh = 30;
|
||||
int block = 2 * lh + HELLO_ROW_SUB_GAP; /* 62 */
|
||||
int ty_legacy = (48 - block) / 2; /* -7 */
|
||||
chk(ty_legacy < 0, lh, "旧行高 48 + 行高 30 → ty<0(首行标题被跳过,bug 复现)");
|
||||
chk(hello_row_h_for_line_h(lh) > 48, lh, "默认字体下行高必须大于旧值 48");
|
||||
}
|
||||
|
||||
printf("%s: %d checks, %d fail (font line-height matrix: %d)\n",
|
||||
g_fail ? "FAIL" : "PASS", g_checks, g_fail, n);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -266,7 +266,7 @@ int sdk2_build_at(int yb_base, int ct, int scroll)
|
||||
g_lv = meow_listview_create(r_lv.x, r_lv.y, r_lv.w, r_lv.h, 0,
|
||||
NULL, NULL, MEOW_LIST_SEL_NONE);
|
||||
if (g_lv) {
|
||||
meow_listview_set_item_h(g_lv, 48);
|
||||
meow_listview_set_item_h(g_lv, hello_row_h()); /* M17:双行文本块按字体行高派生 */
|
||||
meow_listview_set_vsource(g_lv, lv_count, lv_bind, NULL);
|
||||
meow_listview_set_item_renderer(g_lv, 2, 1, 0); /* 双行、左图标、无右尾件 */
|
||||
meow_listview_set_empty_text(g_lv, "All rows deleted");
|
||||
|
||||
Binary file not shown.
@@ -784,11 +784,35 @@ static void out_append(const char *pkg, pid_t pid, long epoch, long up, const ch
|
||||
}
|
||||
|
||||
/* ---- launcher IPC 转发(xchg 一帧,读回一帧) ---- */
|
||||
/* 最近一次 ipc_xchg 的失败原因:区分「连不上」与「连上但没及时回」。此前两者都记成
|
||||
* "launcher offline",2026-11-04 真机即误诊——launcher 活着且回了 ACK,只是晚于 1200ms 超时。 */
|
||||
enum { IPC_ERR_NONE = 0, IPC_ERR_CONN, IPC_ERR_IO, IPC_ERR_TIMEOUT, IPC_ERR_PROTO };
|
||||
static int g_ipc_err = IPC_ERR_NONE;
|
||||
|
||||
static const char *ipc_err_str(void)
|
||||
{
|
||||
switch (g_ipc_err) {
|
||||
case IPC_ERR_CONN: return "connect failed";
|
||||
case IPC_ERR_IO: return "io error";
|
||||
case IPC_ERR_TIMEOUT: return "timeout, no reply";
|
||||
case IPC_ERR_PROTO: return "unexpected reply";
|
||||
default: return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
static int ipc_fail(int fd, int err)
|
||||
{
|
||||
if (fd >= 0) close(fd);
|
||||
g_ipc_err = err;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int ipc_xchg(uint16_t type, const void *payload, uint32_t len,
|
||||
uint8_t *rbuf, uint32_t rcap)
|
||||
{
|
||||
g_ipc_err = IPC_ERR_NONE;
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
if (fd < 0) { g_ipc_err = IPC_ERR_IO; return -1; }
|
||||
/* 收发加超时:launcher 刚刚重启/主循环未就绪(接受但不回包)时,amsd 的阻塞
|
||||
* read 会永远挂起,冻结整个单线程守护。设 SO_SNDTIMEO/SO_RCVTIMEO 让单次转发
|
||||
* 有界,超时即返回失败 → ams_request_ctl 退避重连,绝不无限阻塞。
|
||||
@@ -802,7 +826,7 @@ static int ipc_xchg(uint16_t type, const void *payload, uint32_t len,
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sun_family = AF_UNIX;
|
||||
strncpy(a.sun_path, g_ipc_sock, sizeof a.sun_path - 1);
|
||||
if (connect(fd, (struct sockaddr *)&a, sizeof a) != 0) { close(fd); return -1; }
|
||||
if (connect(fd, (struct sockaddr *)&a, sizeof a) != 0) return ipc_fail(fd, IPC_ERR_CONN);
|
||||
uint8_t hdr[MEOW_IPC_HDR_LEN];
|
||||
uint32_t magic = MEOW_IPC_MAGIC, rmagic, rlen;
|
||||
uint32_t reqid = 0; /* amsd→launcher 控制请求无需回显 */
|
||||
@@ -813,31 +837,35 @@ static int ipc_xchg(uint16_t type, const void *payload, uint32_t len,
|
||||
size_t off = 0;
|
||||
while (off < sizeof hdr) {
|
||||
ssize_t w = write(fd, hdr + off, sizeof hdr - off);
|
||||
if (w <= 0) { close(fd); return -1; }
|
||||
if (w <= 0) return ipc_fail(fd, (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
|
||||
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
|
||||
off += (size_t)w;
|
||||
}
|
||||
if (len && payload) {
|
||||
size_t p = 0;
|
||||
while (p < len) {
|
||||
ssize_t w = write(fd, (const uint8_t *)payload + p, len - p);
|
||||
if (w <= 0) { close(fd); return -1; }
|
||||
if (w <= 0) return ipc_fail(fd, (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
|
||||
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
|
||||
p += (size_t)w;
|
||||
}
|
||||
}
|
||||
off = 0;
|
||||
while (off < sizeof hdr) {
|
||||
ssize_t r = read(fd, hdr + off, sizeof hdr - off);
|
||||
if (r <= 0) { close(fd); return -1; }
|
||||
if (r <= 0) return ipc_fail(fd, (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
|
||||
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
|
||||
off += (size_t)r;
|
||||
}
|
||||
memcpy(&rmagic, hdr, 4); memcpy(&rver, hdr + 4, 2);
|
||||
memcpy(&rtype, hdr + 6, 2); memcpy(&rlen, hdr + 12, 4);
|
||||
if (rmagic != MEOW_IPC_MAGIC || rver != MEOW_IPC_VERSION) { close(fd); return -1; }
|
||||
if (rmagic != MEOW_IPC_MAGIC || rver != MEOW_IPC_VERSION) return ipc_fail(fd, IPC_ERR_PROTO);
|
||||
if (rlen > rcap) rlen = rcap;
|
||||
off = 0;
|
||||
while (off < rlen) {
|
||||
ssize_t r = read(fd, rbuf + off, rlen - off);
|
||||
if (r <= 0) { close(fd); return -1; }
|
||||
if (r <= 0) return ipc_fail(fd, (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))
|
||||
? IPC_ERR_TIMEOUT : IPC_ERR_IO);
|
||||
off += (size_t)r;
|
||||
}
|
||||
close(fd);
|
||||
@@ -858,11 +886,12 @@ static int ams_request_ctl(uint16_t type, const void *payload, uint32_t len, uin
|
||||
if (code) *code = a.code;
|
||||
return 0;
|
||||
}
|
||||
if (rt == MSG_ERR) return MEOW_ERR_INTERNAL;
|
||||
if (rt == MSG_ERR) { g_ipc_err = IPC_ERR_PROTO; return MEOW_ERR_INTERNAL; }
|
||||
/* 一次连接失败/超时(launcher 未就绪)→ 短退避再试;仍失败则返回 -1。 */
|
||||
if (attempt + 1 < AMS_IPC_RETRY)
|
||||
usleep((useconds_t)(AMS_IPC_BACKOFF_MS * (attempt + 1)) * 1000);
|
||||
}
|
||||
if (g_ipc_err == IPC_ERR_NONE) g_ipc_err = IPC_ERR_PROTO; /* 收到非 ACK/ERR 帧:不是「无回执」 */
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -1228,9 +1257,14 @@ static int cmd_start(const char *spec, char *resp, int rn)
|
||||
/* launcher ACK ack.code 携带正幅度(busy=8,见 util.c `rc<0?-rc:MEOW_OK`),非二补。 */
|
||||
snprintf(detail, sizeof detail, "busy (app running)");
|
||||
emit_log("AMS start %s -> BUSY (app running)", pkg);
|
||||
} else if (rt == 0) {
|
||||
snprintf(detail, sizeof detail, "launcher refused code=%d", (int)code);
|
||||
emit_log("AMS start %s -> launcher refused code=%u(%d)", pkg, code, (int)code);
|
||||
} else {
|
||||
snprintf(detail, sizeof detail, "launcher offline");
|
||||
emit_log("AMS start %s -> launcher offline", pkg);
|
||||
/* rt!=0 只代表「这次没拿到回执」,不等于 launcher 死了(同 cmd_kill 注释)。
|
||||
* 响应串保留 "launcher offline"(MeowPanel 据此映射 503),日志补真实原因。 */
|
||||
snprintf(detail, sizeof detail, "launcher offline (%s)", ipc_err_str());
|
||||
emit_log("AMS start %s -> launcher offline (%s)", pkg, ipc_err_str());
|
||||
}
|
||||
snprintf(resp, rn, "%s %s/%s\n", strstr(detail, "ok forwarded") ? "OK" : "ERR",
|
||||
pkg, act[0] ? act : "(default)");
|
||||
@@ -1251,14 +1285,21 @@ static int cmd_kill(const char *pkg, char *resp, int rn)
|
||||
uint32_t code = 0;
|
||||
int rt = ams_request_ctl(MSG_AMS_KILL, &k, sizeof k, &code);
|
||||
if (rt != 0 && a->pid > 0) {
|
||||
/* 兜底:launcher 不在,直接 SIGTERM(与 launcher 的 SIGTERM 语义一致) */
|
||||
/* 兜底:launcher 没给回执 → 直接 SIGTERM(与 launcher 的 SIGTERM 语义一致)。
|
||||
* 注意 rt!=0 **不等于 launcher 死了**:主循环被 IPC 洪泛拖慢时 ACK 会晚于
|
||||
* AMS_IPC_TIMEOUT_MS,故日志写实际原因(connect failed / timeout, no reply / ...),
|
||||
* 而不是笼统的 "launcher offline"(2026-11-04 真机即此误判)。 */
|
||||
kill(a->pid, SIGTERM);
|
||||
emit_log("AMS kill %s -> direct SIGTERM (launcher offline)", pkg);
|
||||
emit_log("AMS kill %s -> direct SIGTERM (launcher %s)", pkg, ipc_err_str());
|
||||
} else if (rt != 0) {
|
||||
return snprintf(resp, rn, "ERR launcher offline: %s\n", pkg);
|
||||
/* 响应串保留 "launcher offline" 字样(MeowPanel api_ams_ctl 依此映射 503 AMS_UNAVAILABLE),
|
||||
* 只在括号里补真实原因,便于区分「连不上」与「连上但超时」。 */
|
||||
return snprintf(resp, rn, "ERR launcher offline (%s): %s\n", ipc_err_str(), pkg);
|
||||
} else if (code != MEOW_OK) {
|
||||
emit_log("AMS kill %s -> launcher refused code=%u", pkg, code);
|
||||
return snprintf(resp, rn, "ERR launcher refused: %s (code=%u)\n", pkg, code);
|
||||
/* launcher 明确拒绝:不改判为直杀(pid 可能已被回收,盲杀会打错进程),如实回错。
|
||||
* code 是负错误码的 uint32 呈现(-2 → 4294967294),括号里给有符号值便于对照。 */
|
||||
emit_log("AMS kill %s -> launcher refused code=%u(%d)", pkg, code, (int)code);
|
||||
return snprintf(resp, rn, "ERR launcher refused: %s (code=%d)\n", pkg, (int)code);
|
||||
}
|
||||
hist_push(a, "ams_kill", "terminate (via launcher)", (long)time(NULL), read_uptime_sec());
|
||||
int m = snprintf(resp, rn, "OK terminate %s\n", pkg);
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
* 核心设计(用户定案,勿推翻):
|
||||
* - 独占 + 抢占(后开流赢,旧流收 MSG_AUDIO_PREEMPTED 并被置 draining,PCM 丢弃);
|
||||
* 软混音已否决(MIPS32 软 SRC 伤音质),留混音器接口位(v1 不实现)。
|
||||
* - 单进程双线程(+路由线程):IO 线程(accept/读帧/解析/ring 写)、播放线程
|
||||
* - 单进程四线程:IO 线程(accept/读帧/解析/ring 写)、播放线程
|
||||
* (独占 ALSA 直开,从 ring 取数写 DAC,xrun PREPARE 恢复)、路由线程
|
||||
* (轮询 headset/balance 插孔 switch 状态 → amixer cset numid=9)。
|
||||
* (轮询 headset/balance 插孔 switch 状态 → amixer cset numid=9)、
|
||||
* 音量执行线程(MSG_AUDIO_VOLUME 异步落 amixer + 持久化,末值去抖,见事项三)。
|
||||
* - 格式归一 = 协商:grant_rate/ch/fmt 回客户端应产出的格式(SDK 负责 mono→stereo
|
||||
* 复制 / S16↔S32 定值升降位);audiod 只做「硬件最优」格式的字节管道。
|
||||
* - DAC 原生率跟随(无 SRC);切率 mute-重配-恢复。
|
||||
@@ -130,6 +131,7 @@ static void session_clean_locked(void);
|
||||
static void _audio_mark_reconfig_needed(void);
|
||||
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
static pthread_cond_t g_cond;
|
||||
static pthread_cond_t g_vol_cond; /* 音量异步执行线程等待(CLOCK_MONOTONIC,main 中初始化) */
|
||||
|
||||
struct client {
|
||||
int used;
|
||||
@@ -137,10 +139,23 @@ struct client {
|
||||
char app_id[MEOW_APP_ID_MAX];
|
||||
uint32_t sid;
|
||||
int state; /* CLS_* */
|
||||
uint32_t gen; /* 分配代际(音量异步 ACK 防 fd/槽复用竞态) */
|
||||
int vol_ack; /* 音量请求已执行,待 IO 线程回 ACK(vol 线程写/IO 线程清) */
|
||||
uint32_t vol_ack_req; /* 对应 req_id */
|
||||
uint32_t vol_ack_status; /* ACK status(应用后 g_vol_pct) */
|
||||
uint8_t rbuf[READ_BUF_MAX];
|
||||
size_t rlen;
|
||||
};
|
||||
static struct client g_cli[CLI_MAX];
|
||||
static uint32_t g_cli_gen = 0; /* 客户端分配代际计数(cli_alloc 递增) */
|
||||
|
||||
/* ---- 音量异步化(事项三):IO 线程只入队末值,专用 vol 线程落 amixer+持久化后
|
||||
* 经 IO 线程回 ACK。驱动 wedge 时 3×popen≈4.5s 不再卡持锁的 dispatch/IO 循环。 ---- */
|
||||
static int g_vol_pending = 0; /* 有待执行音量请求(g_mu 保护) */
|
||||
static int g_vol_pending_pct = 0; /* 末值(0-100;连续请求只保留最新=去抖) */
|
||||
static struct client *g_vol_pending_cli = NULL; /* 末次请求方(ACK 用;可能已回收,靠 gen 校验) */
|
||||
static uint32_t g_vol_pending_req = 0; /* 末次请求 req_id */
|
||||
static uint32_t g_vol_pending_gen = 0; /* 末次请求方 gen(防槽复用误 ACK) */
|
||||
|
||||
/* 当前授权流(喂养 DAC 的唯一流) */
|
||||
struct stream_info {
|
||||
@@ -308,15 +323,16 @@ static int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payloa
|
||||
}
|
||||
|
||||
/* cond 有界等待(g_cond 为 CLOCK_MONOTONIC;调用方已持 g_mu)。 */
|
||||
static void cond_wait_tmo(int ms)
|
||||
static void cond_wait_tmo_cv(pthread_cond_t *cv, int ms)
|
||||
{
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
ts.tv_sec += ms / 1000;
|
||||
ts.tv_nsec += (long)(ms % 1000) * 1000000L;
|
||||
if (ts.tv_nsec >= 1000000000L) { ts.tv_sec++; ts.tv_nsec -= 1000000000L; }
|
||||
pthread_cond_timedwait(&g_cond, &g_mu, &ts);
|
||||
pthread_cond_timedwait(cv, &g_mu, &ts);
|
||||
}
|
||||
static void cond_wait_tmo(int ms) { cond_wait_tmo_cv(&g_cond, ms); }
|
||||
|
||||
/* ---- frame 大小/位深 ---- */
|
||||
static int fmt_bps(int fmt)
|
||||
@@ -1135,12 +1151,14 @@ static void bt_vol_full(void)
|
||||
}
|
||||
|
||||
/* 音量应用(含 BT 隔离):BT → 只通知 AVRCP(best-effort),绝不动 DAC numid=1/2、不持久化
|
||||
* audio.volume(那是 DAC 音量键);DAC → 原感知锥度反转 + 持久化。 */
|
||||
static void vol_apply(int pct)
|
||||
* audio.volume(那是 DAC 音量键);DAC → 原感知锥度反转 + 持久化。
|
||||
* target 为调用方在持锁下快照的 g_target;本函数不持 g_mu 执行(g_vol_pct 为 plain int
|
||||
* 松散写,同 route_thread 的 g_route_cur 语义,MIPS32 int 写天然原子)。 */
|
||||
static void vol_apply(int pct, int target)
|
||||
{
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
if (g_target == AUDIO_OUT_BT) {
|
||||
if (target == AUDIO_OUT_BT) {
|
||||
logmsg("vol -> %d%% (BT/AVRCP, DAC numid=1/2 NOT written)", pct);
|
||||
return;
|
||||
}
|
||||
@@ -1551,6 +1569,7 @@ static struct client *cli_alloc(int fd)
|
||||
freec->fd = fd;
|
||||
freec->state = CLS_OPEN;
|
||||
freec->sid = 0;
|
||||
freec->gen = ++g_cli_gen; /* 代际递增:音量异步 ACK 校验用,防槽复用误发 */
|
||||
return freec;
|
||||
}
|
||||
static void cli_free(struct client *c)
|
||||
@@ -1771,13 +1790,20 @@ static void dispatch(struct client *c, uint16_t type, uint32_t req_id, const uin
|
||||
int32_t pct = -1;
|
||||
if (len >= sizeof(int32_t)) memcpy(&pct, pl, sizeof pct);
|
||||
if (pct >= 0) {
|
||||
/* 音量应用含 BT 隔离:target=BT 时绝不下发 DAC 反转(vol_apply 内判断) */
|
||||
vol_apply((int)pct);
|
||||
/* 异步化(事项三):只入队末值,**不在持 g_mu 下 popen amixer**——驱动 wedge 时
|
||||
* 一次 set = 3×popen ≈ 4.5s,会把 OPEN/QUERY/喂流一起卡死。连续请求合并为末值
|
||||
* (去抖),由 vol 线程应用+持久化后经 IO 线程回 ACK。 */
|
||||
g_vol_pending = 1;
|
||||
g_vol_pending_pct = (pct > 100) ? 100 : (int)pct;
|
||||
g_vol_pending_cli = c;
|
||||
g_vol_pending_req = req_id;
|
||||
g_vol_pending_gen = c->gen;
|
||||
pthread_cond_signal(&g_vol_cond);
|
||||
} else {
|
||||
g_vol_pct = settings_get_volume();
|
||||
g_vol_pct = settings_get_volume(); /* 读当前:文件读非 popen,可同步 */
|
||||
m_ack_t a; a.code = MEOW_OK; a.status = (uint32_t)g_vol_pct;
|
||||
send_frame(c->fd, MSG_ACK, req_id, &a, sizeof a);
|
||||
}
|
||||
m_ack_t a; a.code = MEOW_OK; a.status = (uint32_t)g_vol_pct;
|
||||
send_frame(c->fd, MSG_ACK, req_id, &a, sizeof a);
|
||||
break;
|
||||
}
|
||||
case MSG_AUDIO_OUTPUT: {
|
||||
@@ -1911,6 +1937,14 @@ static void *io_thread(void *arg)
|
||||
if (snaps[i].revents & (POLLHUP | POLLERR | POLLNVAL)) { cli_free(c); continue; }
|
||||
if (snaps[i].revents & POLLIN) cx_read_frames(c);
|
||||
}
|
||||
/* 音量 ACK 回写(事项三):vol 线程执行完只置标记,由 IO 线程统一发送——客户端 fd
|
||||
* 生命周期归 IO 线程独占,跨线程直接 send_frame 有 fd 复用竞态。 */
|
||||
for (int i = 0; i < CLI_MAX; i++) {
|
||||
if (!g_cli[i].used || !g_cli[i].vol_ack) continue;
|
||||
m_ack_t va; va.code = MEOW_OK; va.status = g_cli[i].vol_ack_status;
|
||||
send_frame(g_cli[i].fd, MSG_ACK, g_cli[i].vol_ack_req, &va, sizeof va);
|
||||
g_cli[i].vol_ack = 0;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
/* 路由线程与播放线程独立;此处不再做 */
|
||||
}
|
||||
@@ -1921,6 +1955,40 @@ static void *io_thread(void *arg)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- 音量执行线程(事项三):异步落 amixer + 持久化,不在 IO 线程持锁 popen ----
|
||||
* 等待 g_vol_cond 取末值(连续请求合并=去抖),放锁后执行 dac_vol_set(3×popen,有界)
|
||||
* + settings_set_volume(文件 IO),完成后只置 ACK 标记,由 IO 线程统一发送(防 fd 复用竞态)。
|
||||
* BT 目标只记日志不动 DAC/不持久化(AVRCP 隔离,g_vol_pct 保持不变)。 */
|
||||
static void *vol_thread(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
for (;;) {
|
||||
pthread_mutex_lock(&g_mu);
|
||||
while (!g_vol_pending && !g_stop)
|
||||
cond_wait_tmo_cv(&g_vol_cond, COND_WAIT_MS);
|
||||
if (g_stop) { pthread_mutex_unlock(&g_mu); break; }
|
||||
int pct = g_vol_pending_pct;
|
||||
int target = g_target; /* 持锁快照:vol_apply 不读全局 */
|
||||
struct client *c = g_vol_pending_cli;
|
||||
uint32_t req = g_vol_pending_req;
|
||||
uint32_t cgen = g_vol_pending_gen;
|
||||
g_vol_pending = 0;
|
||||
g_vol_pending_cli = NULL;
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
|
||||
vol_apply(pct, target);
|
||||
|
||||
pthread_mutex_lock(&g_mu);
|
||||
if (c && c->used && c->gen == cgen) { /* 槽复用校验:只 ACK 原请求方 */
|
||||
c->vol_ack = 1;
|
||||
c->vol_ack_req = req;
|
||||
c->vol_ack_status = (uint32_t)g_vol_pct;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mu);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- 路由线程主体 ---- */
|
||||
static void *route_thread(void *arg)
|
||||
{
|
||||
@@ -1930,8 +1998,10 @@ static void *route_thread(void *arg)
|
||||
/* M2.91 挂死根治(AUDIO_HANG_INVESTIGATION.md):hbc3000 FPGA 未烧录+enable 时
|
||||
* 启动 PCM 会在第二次 hbc3000_start 死锁整机。写 Output Port Switch 是烧录触发器
|
||||
* (驱动 put:set_spdif_mode→program_ram→enabled)。开机端口恒 0=未烧,故启动时
|
||||
* 无条件写一次:4.4 在→3,3.5 在→2,都没→4(官方播放器拔插孔后的选择,保 FPGA 已烧)。
|
||||
* 端口已是目标值(audiod 重启场景,驱动同值短路不写)→ 先写 5(disable) 强制重烧。
|
||||
* 武装一次:4.4 在→3,3.5 在→2,都没→4(官方播放器拔插孔后的选择,保 FPGA 已烧)。
|
||||
* 多余重烧收敛(AUDIO_HANG_INVESTIGATION):端口已是目标值(audiod 重启场景)时
|
||||
* **不再**先写 5(disable) 强制重烧——每次开机做 5→目标 双写等于把 FPGA 烧录暴露翻倍,
|
||||
* 正是驱动级非确定楔死触发面;改为只需读回一致即跳过,仅当未武装/端口不对时才写目标。
|
||||
* ⚠️ P2 蓝牙:numnid=9/FPGA 武装只作用于 **DAC 物理路径**(plan §五解耦);target=BT 时
|
||||
* 不开 hw:0,0,无 hbc3000 start 死锁风险,故 BT 期间跳过 numid=9/FPGA(切回 DAC 再武装)。 */
|
||||
int need_arm = 1; /* 启动(DAC 起点)武装一次;BT 期间置 1(回 DAC 再武装) */
|
||||
@@ -1951,8 +2021,19 @@ static void *route_thread(void *arg)
|
||||
int bal = read_switch("/sys/class/switch/balance/state");
|
||||
int target = (bal == 1) ? 3 : (hb == 1) ? 2 : 4;
|
||||
int cur = amixer_get(9, 1);
|
||||
if (cur == target) { logmsg("route: port already %d, force re-arm via 5", target); apply_route(5); }
|
||||
apply_route(target);
|
||||
if (cur == target) {
|
||||
/* 端口已正确:跳过 5→target 双重强烧,只确认读回一致(cur==target 已由
|
||||
* 上一拍 amixer_get 证实)。原逻辑「cur==target 时先写 5(disable) 再回
|
||||
* target」是给 audiod 重启/同值短路场景强制重烧用的,但每次开机无条件做
|
||||
* 等于把 FPGA 烧录暴露翻倍——正是驱动级非确定楔死的触发面,故收敛掉。 */
|
||||
g_route_cur = target; /* 保持 QUERY/monitor 最近端口与读回一致 */
|
||||
logmsg("route: port already %d, skip re-arm (no 5->%d re-burn)", target, target);
|
||||
} else {
|
||||
/* 未武装(cur=0/读失败)或端口不对:写目标端口。这是 M2.91「未武装则武装」
|
||||
* 防挂死核心(FPGA 未烧+enable 时二次 hbc3000_start 死锁整机),绝不可裸奔;
|
||||
* 直接写 target 即触发烧录,无需再绕写 5(同值短路场景已被上一分支跳过)。 */
|
||||
apply_route(target);
|
||||
}
|
||||
last_hb = hb; last_bal = bal; last_target = (hb == 0 && bal == 0) ? -1 : target;
|
||||
logmsg("route: boot/DAC-return arm done (port %d %s)", target, port_name(target));
|
||||
need_arm = 0;
|
||||
@@ -2136,19 +2217,21 @@ int main(int argc, char **argv)
|
||||
logmsg("volume init = %d%% target=%s codec=%s volctl=%s",
|
||||
g_vol_pct, target_name(), g_bt_codec, volctl_name());
|
||||
|
||||
/* g_cond 用 CLOCK_MONOTONIC(所有 timedwait 的基准;防系统时间跳变影响护栏超时) */
|
||||
/* g_cond/g_vol_cond 用 CLOCK_MONOTONIC(所有 timedwait 的基准;防系统时间跳变影响护栏超时) */
|
||||
{
|
||||
pthread_condattr_t ca;
|
||||
pthread_condattr_init(&ca);
|
||||
pthread_condattr_setclock(&ca, CLOCK_MONOTONIC);
|
||||
pthread_cond_init(&g_cond, &ca);
|
||||
pthread_cond_init(&g_vol_cond, &ca);
|
||||
pthread_condattr_destroy(&ca);
|
||||
}
|
||||
|
||||
pthread_t ti, tp, tr;
|
||||
pthread_t ti, tp, tr, tv;
|
||||
pthread_create(&ti, NULL, io_thread, NULL);
|
||||
pthread_create(&tp, NULL, playback_thread, NULL);
|
||||
pthread_create(&tr, NULL, route_thread, NULL);
|
||||
pthread_create(&tv, NULL, vol_thread, NULL);
|
||||
|
||||
/* 主线程 = watchdog:心跳静默 >WD_SILENT_SEC 秒即告警(只告警不强杀——
|
||||
* 对卡在内核的线程任何外部干预都不安全;护栏保证其它线程/连接继续服务)。 */
|
||||
@@ -2178,6 +2261,7 @@ int main(int argc, char **argv)
|
||||
pthread_join(ti, NULL);
|
||||
pthread_join(tp, NULL);
|
||||
pthread_join(tr, NULL);
|
||||
pthread_join(tv, NULL);
|
||||
unlink(g_sock);
|
||||
if (g_logfd >= 0) close(g_logfd);
|
||||
return 0;
|
||||
|
||||
@@ -187,6 +187,24 @@ static int start_audiod_trace(const char *sink, const char *settings_path, const
|
||||
return pid;
|
||||
}
|
||||
|
||||
/* 带假 amixer 的启动(事项三异步化验证:假 amixer 每次 cset 睡 1s,模拟慢 popen)。
|
||||
* 用于断言「MSG_AUDIO_VOLUME 在 IO 线程只入队、不放锁 popen」——慢 amixer 期间 QUERY 仍即时。 */
|
||||
static int start_audiod_fake_amixer(const char *sink, const char *settings_path, const char *amixer_script)
|
||||
{
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
setenv("MEOW_AUDIO_SINK", sink, 1);
|
||||
setenv("MEOW_AUDIO_OUT", TWAV, 1);
|
||||
setenv("MEOW_AUDIO_SOCK", TSOCK, 1);
|
||||
setenv("MEOW_AUDIO_FORCE", "1", 1);
|
||||
if (settings_path) setenv("MEOW_AUDIO_SETTINGS", settings_path, 1);
|
||||
if (amixer_script) setenv("MEOW_AUDIO_AMIXER", amixer_script, 1);
|
||||
execl("./meow_audiod_host", "meow_audiod_host", (char *)0);
|
||||
_exit(127);
|
||||
}
|
||||
return pid;
|
||||
}
|
||||
|
||||
/* 直接客户端 open(带 flags:AUDIO_FLAG_DSD 用于 DoP) */
|
||||
static int do_open_f(struct cli *c, int rate, int ch, int fmt, uint32_t flags, m_audio_open_ack_t *ack)
|
||||
{
|
||||
@@ -1080,6 +1098,62 @@ int main(void)
|
||||
}
|
||||
stop_audiod();
|
||||
|
||||
/* ---- T17:MSG_AUDIO_VOLUME 异步化(事项三)。假 amixer 每次 cset 睡 1s(一次 set=3×cset≈3s)。
|
||||
* 断言:慢 amixer 期间 QUERY 即时返回(dispatch 不放锁 popen);应用后回 ACK + 双组写 + 持久化。 */
|
||||
printf("[T17 volume async (dispatch not blocked by slow amixer)]\n");
|
||||
unlink(TSOCK); unlink(TWAV);
|
||||
const char *tconf17 = "/tmp/meow_test_settings17.conf";
|
||||
FILE *tf17 = fopen(tconf17, "w");
|
||||
if (tf17) { fprintf(tf17, "# meow-settings 1\naudio.volume = 60\n"); fclose(tf17); }
|
||||
const char *fake17 = "/tmp/meow_fake_amixer17.sh";
|
||||
const char *trace17 = "/tmp/meow_fake_amixer17_trace.txt";
|
||||
unlink(trace17);
|
||||
{
|
||||
FILE *fs = fopen(fake17, "w");
|
||||
if (fs) {
|
||||
fprintf(fs, "#!/bin/sh\necho \"cset $4 $5\" >> %s\nsleep 1\nexit 0\n", trace17);
|
||||
fclose(fs);
|
||||
chmod(fake17, 0755);
|
||||
}
|
||||
}
|
||||
g_audiod_pid = start_audiod_fake_amixer("file", tconf17, fake17);
|
||||
usleep(400000);
|
||||
{
|
||||
struct cli v17;
|
||||
check(cli_connect(&v17) == 0, "connect V17");
|
||||
int32_t pct = 40;
|
||||
check(sf(v17.fd, MSG_AUDIO_VOLUME, 17, &pct, sizeof pct) == 0, "send async volume 40");
|
||||
/* 慢 amixer 执行期间 QUERY 必须即时(<600ms),证明 IO 线程没被 popen 卡住 */
|
||||
struct timespec t0, t1;
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
char q17[1024];
|
||||
int qok = do_query(&v17, q17, sizeof q17);
|
||||
clock_gettime(CLOCK_MONOTONIC, &t1);
|
||||
long qms = (t1.tv_sec - t0.tv_sec) * 1000L + (t1.tv_nsec - t0.tv_nsec) / 1000000L;
|
||||
check(qok == 0 && qms < 600, "QUERY immediate during slow amixer (async dispatch)");
|
||||
if (qok == 0 && qms >= 600) printf(" (query took %ldms)\n", qms);
|
||||
/* 应用后回 ACK(末值 40) */
|
||||
uint8_t ab[64]; uint16_t at;
|
||||
int rlen = rf_tmo(v17.fd, 4000, &at, ab, sizeof ab);
|
||||
check(rlen >= 0 && at == MSG_ACK, "async volume ACK returned after apply");
|
||||
usleep(100000);
|
||||
{
|
||||
FILE *chk = fopen(tconf17, "r"); int has40 = 0; char l[128];
|
||||
if (chk) { while (fgets(l, sizeof l, chk)) if (strstr(l, "audio.volume = 40")) has40 = 1; fclose(chk); }
|
||||
check(has40, "async volume persisted = 40");
|
||||
}
|
||||
{
|
||||
char trb[2048]; int trl = 0;
|
||||
int fd = open(trace17, O_RDONLY);
|
||||
if (fd >= 0) { trl = (int)read(fd, trb, sizeof trb - 1); close(fd); if (trl > 0) trb[trl] = 0; }
|
||||
check(trl > 0 && strstr(trb, "numid=1 ") && strstr(trb, "numid=2 ") && strstr(trb, "numid=11 "),
|
||||
"async dual-group write (numid=1/2/11) via fake amixer");
|
||||
}
|
||||
close(v17.fd);
|
||||
}
|
||||
stop_audiod();
|
||||
unlink(fake17); unlink(trace17);
|
||||
|
||||
printf("=== SUMMARY: pass=%d fail=%d ===\n", g_pass, g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -23,6 +23,28 @@ static void app_name_copy(char *dst, int cap, const char *src)
|
||||
}
|
||||
|
||||
|
||||
/* M6.40 启动失败原因透传:launch_app/launch_activity(→launch_app_chain)返回 -1 前按实际
|
||||
* 失败点填具体原因(fork OOM / exec 失败 / 缺依赖 / 路径不存在等),供 main_menu.c 的
|
||||
* launch_fail_toast 显示「CANNOT START: %s(%s)」;success 路径恒为 ""。 */
|
||||
static char g_launch_fail_reason[160] = "";
|
||||
|
||||
const char *launch_fail_reason(void)
|
||||
{
|
||||
return g_launch_fail_reason;
|
||||
}
|
||||
|
||||
/* 内部失败出口:设原因 + 记统一失败日志 + 返回 -1(各失败分支复用)。 */
|
||||
static int launch_fail(struct app *a, const char *fmt, ...)
|
||||
{
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(g_launch_fail_reason, sizeof g_launch_fail_reason, fmt, ap);
|
||||
va_end(ap);
|
||||
logmsg("launch failed: %s reason=%s", a->name, g_launch_fail_reason);
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
/* ---- amsd 上报 / 控制挂点(AMSD_DESIGN §4):best-effort,amsd 不在不影响 launcher ---- */
|
||||
#define MEOW_AMS_SOCK_DEFAULT "/usr/data/meow_ams.sock"
|
||||
|
||||
@@ -1394,6 +1416,22 @@ static void notify_app_crash(const char *pkg, int status)
|
||||
close(fd);
|
||||
}
|
||||
|
||||
/* 写 §10.5 SDK 自抑标记(/tmp/meow_notif_active)。**内容 = 当前前台屏幕归属者 app_id**;
|
||||
* 空(传 NULL / "")= 菜单前台 / 叠加层覆盖 / 无前台单元(SDK 判「抑制全部 App」)。
|
||||
* SDK(meow_ui_gfx.c / meow_ui_input.c 共享判定 meow_ui_overlay_suppressed_active)据内容判
|
||||
* 本进程是否前台:内容==本进程 app_id → 前台不抑制;内容为空 → 全抑制;内容为其它 app_id
|
||||
* → 后台常驻/非前台 → 自抑上屏 + 排空输入。
|
||||
* 批①多 App 后台常驻:取代旧「单标志写 1」——单标志无法区分前台/后台,清标志会连后台 App
|
||||
* 一起放行(真机「前台 Hello 叠后台 ncm 帧 + 触摸穿透一次交互双响应」的根因)。 */
|
||||
static void notif_active_set(const char *owner)
|
||||
{
|
||||
int nfd = open(NOTIF_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (nfd >= 0) {
|
||||
if (owner && owner[0]) (void)write(nfd, owner, strlen(owner));
|
||||
close(nfd);
|
||||
}
|
||||
}
|
||||
|
||||
/* §10.5:App 前台顶部下滑 → 展开「由 launcher 渲染」的通知中心叠加会话。
|
||||
* 复用电源菜单对 App 的冻结模板(show_power_menu 的 SIGSTOP/读回/叠加/接管/恢复):
|
||||
* ① SIGSTOP App → ② 写 /tmp/meow_notif_active(+ /tmp/meow_power_menu_active 让 watchdog 让位)
|
||||
@@ -1433,9 +1471,9 @@ static int run_app_notif_overlay(struct app *a, pid_t pid)
|
||||
}
|
||||
}
|
||||
/* 让位标记:/tmp/meow_notif_active(§10.5 语义同 powmenu_active)。当前 watchdog 只认
|
||||
* POWMENU_ACTIVE,故同时写它让 watchdog 对电源长按让位;会话结束统一 unlink。 */
|
||||
int nfd = open(NOTIF_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (nfd >= 0) { write(nfd, "1", 1); close(nfd); }
|
||||
* POWMENU_ACTIVE,故同时写它让 watchdog 对电源长按让位;会话结束统一恢复通知中心前台。
|
||||
* 内容为空=抑制全部 App(叠加层覆盖前台 App + 后台常驻 App 一并自抑)。 */
|
||||
notif_active_set(""); /* 叠加期:全抑制(面板/锁屏盖在前台 App 上) */
|
||||
int pfd = open(POWMENU_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (pfd >= 0) { write(pfd, "1", 1); close(pfd); }
|
||||
|
||||
@@ -1453,30 +1491,32 @@ static int run_app_notif_overlay(struct app *a, pid_t pid)
|
||||
int died = g_notif_ctrl_died;
|
||||
|
||||
if (died) {
|
||||
/* App 已死:不 SIGCONT、不恢复死帧;back_buf 交还 run_menu 统一重绘 */
|
||||
unlink(NOTIF_ACTIVE);
|
||||
/* App 已死:不 SIGCONT、不恢复死帧;back_buf 交还 run_menu 统一重绘。
|
||||
* 标记置空(全抑制):菜单前台,后台常驻 App 仍自抑(不再放行帧/输入)。 */
|
||||
notif_active_set("");
|
||||
unlink(POWMENU_ACTIVE);
|
||||
logmsg("notif-overlay: app died during session -> unified exit path");
|
||||
return 1;
|
||||
}
|
||||
/* 恢复 App 帧(run_notif_center 关面板后 back_buf 已还原为背景=App 帧;再拍一次兜底
|
||||
* 覆盖非动画关闭路径)。媒体保活路径(keep_running):先恢复帧再 unlink 标记——
|
||||
* App 未被冻结仍在跑,标记多留一拍可让 SDK present 自抑覆盖到恢复帧上屏之后,
|
||||
* 避免「标记先消失 → App 抢在恢复帧前 present 新帧 → 又被旧帧盖回」的一帧回闪。 */
|
||||
* 覆盖非动画关闭路径)。媒体保活路径(keep_running):先恢复帧再把标记更新为前台归属
|
||||
* (a->id)——App 未被冻结仍在跑,标记多留一拍可让 SDK present 自抑覆盖到恢复帧上屏之后,
|
||||
* 避免「标记先消失 → App 抢在恢复帧前 present 新帧 → 又被旧帧盖回」的一帧回闪。
|
||||
* 标记翻回 a->id:本 App 恢复前台不抑制,后台常驻 App(如 ncm)随之继续自抑(不叠帧/不穿输入)。 */
|
||||
pm_capture_app_frame();
|
||||
meow_ui_flush();
|
||||
if (!keep_running) {
|
||||
/* §310 输入穿透定案(与 lock-overlay 同源):App 被 SIGSTOP 冻结过 → 在标记仍存在时
|
||||
* 先 SIGCONT,让 SDK 输入线程排空冻结期缓冲的 evdev 事件 + 对齐 EVIOCGKEY,再等
|
||||
* OVERLAY_DRAIN_WINDOW_MS 后 unlink——防关面板瞬间把积累触摸回放成 App 内点击。 */
|
||||
* OVERLAY_DRAIN_WINDOW_MS 后把标记翻回前台——防关面板瞬间把积累触摸回放成 App 内点击。 */
|
||||
kill(pid, SIGCONT);
|
||||
usleep(OVERLAY_DRAIN_WINDOW_MS * 1000);
|
||||
logmsg("notif-overlay: closed -> SIGCONT app, drain %dms, then unlink mark",
|
||||
logmsg("notif-overlay: closed -> SIGCONT app, drain %dms, then mark fg",
|
||||
OVERLAY_DRAIN_WINDOW_MS);
|
||||
} else {
|
||||
logmsg("notif-overlay: closed -> app never stopped (media keep-alive), no SIGCONT");
|
||||
}
|
||||
unlink(NOTIF_ACTIVE);
|
||||
notif_active_set(a ? a->id : ""); /* 标记翻回前台归属(非空→本 App 解除自抑) */
|
||||
unlink(POWMENU_ACTIVE);
|
||||
return 0;
|
||||
}
|
||||
@@ -1504,8 +1544,8 @@ static int run_app_lock_overlay(struct app *a, pid_t pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
int nfd = open(NOTIF_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (nfd >= 0) { write(nfd, "1", 1); close(nfd); }
|
||||
/* 标记置空=全抑制(锁屏叠在前台 App + 后台常驻 App 上;解锁后翻回前台归属)。 */
|
||||
notif_active_set("");
|
||||
int pfd = open(POWMENU_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (pfd >= 0) { write(pfd, "1", 1); close(pfd); }
|
||||
|
||||
@@ -1520,9 +1560,9 @@ static int run_app_lock_overlay(struct app *a, pid_t pid)
|
||||
g_lock_overlay_pid = 0;
|
||||
|
||||
if (lr == LCK_OVERLAY_RET_DIED) {
|
||||
/* 叠加期 App 已死:不 SIGCONT(死进程)、不恢复死帧;unlink 标记交回 waitpid 循环回收,
|
||||
* run_menu 统一重绘主屏(含状态栏)。launcher 不崩。 */
|
||||
unlink(NOTIF_ACTIVE);
|
||||
/* 叠加期 App 已死:不 SIGCONT(死进程)、不恢复死帧;标记置空(菜单前台,后台常驻 App
|
||||
* 仍自抑)交回 waitpid 循环回收,run_menu 统一重绘主屏(含状态栏)。launcher 不崩。 */
|
||||
notif_active_set("");
|
||||
unlink(POWMENU_ACTIVE);
|
||||
logmsg("lock-overlay: app pid %d died during lock -> unified exit path", pid);
|
||||
return 1;
|
||||
@@ -1532,17 +1572,19 @@ static int run_app_lock_overlay(struct app *a, pid_t pid)
|
||||
meow_ui_flush();
|
||||
if (!keep_running) {
|
||||
/* §310 输入穿透定案(overlay_gate.h):App 被 SIGSTOP 冻结过 → 在 /tmp/meow_notif_active
|
||||
* 仍未 unlink 时**先 SIGCONT**,让 SDK 输入线程排空冻结期缓冲的 evdev 事件副本 + 对齐
|
||||
* EVIOCGKEY,再等 OVERLAY_DRAIN_WINDOW_MS(> SDK 200ms 节流窗)后 unlink——防解锁瞬间
|
||||
* 把积累的触摸回放成 App 内点击(锁屏穿透→「启动奇怪界面」→ 状态栏消失的触发源)。 */
|
||||
* 仍未更新时**先 SIGCONT**,让 SDK 输入线程排空冻结期缓冲的 evdev 事件副本 + 对齐
|
||||
* EVIOCGKEY,再等 OVERLAY_DRAIN_WINDOW_MS(> SDK 200ms 节流窗)后把标记翻回前台归属——
|
||||
* 防解锁瞬间把积累的触摸回放成 App 内点击(锁屏穿透→「启动奇怪界面」→ 状态栏消失的触发源)。 */
|
||||
kill(pid, SIGCONT);
|
||||
usleep(OVERLAY_DRAIN_WINDOW_MS * 1000);
|
||||
logmsg("lock-overlay: closed (lr=%d) -> SIGCONT app, drain %dms, then unlink mark",
|
||||
logmsg("lock-overlay: closed (lr=%d) -> SIGCONT app, drain %dms, then mark fg",
|
||||
lr, OVERLAY_DRAIN_WINDOW_MS);
|
||||
} else {
|
||||
logmsg("lock-overlay: closed (lr=%d) -> app never stopped (media keep-alive), no SIGCONT", lr);
|
||||
}
|
||||
unlink(NOTIF_ACTIVE);
|
||||
/* 标记翻回前台归属(a->id):本 App 解除自抑恢复前台;后台常驻 App(如 ncm)随之继续自抑
|
||||
* (不叠帧 / 不穿输入——修复「锁屏解锁后 ncm 歌单帧叠加在 Hello 上 + 交互双响应」)。 */
|
||||
notif_active_set(a ? a->id : "");
|
||||
unlink(POWMENU_ACTIVE);
|
||||
/* §310 状态栏恢复:解锁后强制 g_sb_mode_last=-1 + 立即重叠加顶条(不等 app 循环 1s 慢刷),
|
||||
* 消除「解锁回 App 无状态栏」的残留态。overlay_force_sb_resync() = 定案恒真。 */
|
||||
@@ -1565,8 +1607,9 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
uint32_t pick_req = 0;
|
||||
meow_ams_take_picker(pick_action, sizeof pick_action,
|
||||
pick_caller, sizeof pick_caller, &pick_req);
|
||||
if (!a->ok) { logmsg("launch blocked: exec not ok"); return -1; }
|
||||
meow_dep_clear_reason(); /* 每次 launch 入口清 stale 依赖阻断原因(防上一 app 残留) */
|
||||
g_launch_fail_reason[0] = 0; /* M6.40:同上,清 stale 启动失败原因 */
|
||||
if (!a->ok) { logmsg("launch blocked: exec not ok"); return launch_fail(a, "no such file: %s", a->exec); }
|
||||
|
||||
/* M4 后台保活恢复:该 App 驻留后台(播放中经 nav-exit 转过)→ 原进程回前台,
|
||||
* 不 fork/exec(进程仍存无法注入新 env:chain/picker 上下文忽略,记日志)。
|
||||
@@ -1579,9 +1622,9 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
logmsg("launch: %s resume from background (pid=%d)", a->name, (int)pid);
|
||||
if (chain && chain[0]) logmsg("launch: resume ignores chain=\"%s\"", chain);
|
||||
if (pick_action[0]) logmsg("launch: resume ignores picker action=%s", pick_action);
|
||||
/* 回前台:清 §10.5 SDK present 自抑标记(无其他 bg App 且无叠加会话时) */
|
||||
if (meow_bg_count() == 0 && g_notif_ctrl_pid <= 0)
|
||||
unlink(NOTIF_ACTIVE);
|
||||
/* 回前台:标记内容置为前台归属(a->id)——本 App 解除自抑恢复前台;仍驻留后台的其它
|
||||
* App(如 ncm)随内容≠自身而继续自抑(不叠帧/不穿输入;批①多 App 常驻关键)。 */
|
||||
notif_active_set(a->id);
|
||||
drain_input();
|
||||
meow_nav_begin(a->id, NULL); /* 转后台时导航栈已清;回前台从裸态重建 */
|
||||
ams_report_fg(a->id); /* 批①:单向 best-effort 上报 amsd 置 AMS_FG */
|
||||
@@ -1593,9 +1636,13 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
char dep_missing[32] = "";
|
||||
if (meow_check_app_depends(a, dep_missing, sizeof dep_missing) != 0) {
|
||||
logmsg("launch blocked: dep '%s' missing (CANNOT START: %s)", dep_missing, a->name);
|
||||
return -1;
|
||||
return launch_fail(a, "missing dep: %s", dep_missing);
|
||||
}
|
||||
}
|
||||
/* 冷启动:标记内容置为前台归属(a->id),让本 App 首帧即不被自抑(后台常驻 App 继续自抑)。
|
||||
* 旧版此处不写标记:若已有后台常驻 App(ncm)残留「1/空」标记,新前台 App 会被误自抑
|
||||
* → 主界面点 Hello 渲染卡死(状态栏正常)的根因。 */
|
||||
notif_active_set(a->id);
|
||||
meow_nav_begin(a->id, chain); /* M3:冷启动/裸启动都以当前链深初始化导航栈(chain 空→depth 0) */
|
||||
|
||||
/* M2.12:启动前准备 MeowData/<pkg>/{data,cache}(幂等;卡不在/失败只 log 不崩)。
|
||||
@@ -1617,7 +1664,7 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
* watchdog and waitpid — so an exec failure never leaves an orphan watchdog
|
||||
* and never masquerades as "app ran and exited". */
|
||||
int execpipe[2];
|
||||
if (pipe(execpipe) < 0) { logmsg("exec pipe failed: %s", strerror(errno)); return -1; }
|
||||
if (pipe(execpipe) < 0) { logmsg("exec pipe failed: %s", strerror(errno)); return launch_fail(a, "exec pipe failed: %s", strerror(errno)); }
|
||||
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
|
||||
|
||||
/* AMSD_DESIGN §5:stdout/stderr 管道捕获(logcat 思路)。fork 前建管道;子进程 dup2 到
|
||||
@@ -1637,7 +1684,7 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
close(execpipe[0]); close(execpipe[1]);
|
||||
if (out_pipe[0] >= 0) close(out_pipe[0]);
|
||||
if (out_pipe[1] >= 0) close(out_pipe[1]);
|
||||
logmsg("fork failed: %s", strerror(errno)); return -1;
|
||||
logmsg("fork failed: %s", strerror(errno)); return launch_fail(a, "fork failed: %s", strerror(errno));
|
||||
}
|
||||
if (pid == 0) {
|
||||
/* child: exec the app. M2.12 注入 MEOW_LOG_DIR + MEOW_DATA_DIR/MEOW_CACHE_DIR
|
||||
@@ -1826,7 +1873,7 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
int st = 0;
|
||||
reap_wait(pid, &st); /* reap the failed child(有界) */
|
||||
if (out_fd >= 0) close(out_fd);
|
||||
return -1;
|
||||
return launch_fail(a, "exec failed: %s (errno %d)", strerror(exe_err), exe_err);
|
||||
}
|
||||
} /* end if (!resumed):冷启动分支(prepare/fork/exec/确认) */
|
||||
|
||||
@@ -1905,6 +1952,8 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
int nav_clean_exit = 0; /* M3:MSG_NAV{EXIT} 主动退出 → 不按崩溃上报 */
|
||||
int to_background = 0; /* M4:nav-exit 转后台保活(进程保留,跳过退出尾) */
|
||||
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;
|
||||
unsigned long long app_seq_last = 0;
|
||||
struct vol_snap app_vol0 = { -1, -1 };
|
||||
@@ -1944,6 +1993,13 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
* 清残留 pending(防上一会话未消费的 OPEN 被本会话误展开)。 */
|
||||
g_app_session_active = 1;
|
||||
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 (;;) {
|
||||
int r = waitpid(pid, &status, WNOHANG);
|
||||
if (r == pid) { ams_status_valid = 1; break; }
|
||||
@@ -2033,6 +2089,21 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
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 → 息屏;
|
||||
* 息屏后再按键 → 唤醒(fb_pan_buf0_force 兜底 LCDC 落 buffer0,内容随应用输入重绘)。
|
||||
* M6.5 §310 问题1:watchdog 电源键手动息屏(背光 0、不置 meow_auto_slept)→ 唤醒时按
|
||||
@@ -2199,6 +2270,7 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
if (to_background) {
|
||||
g_app_session_active = 0; /* 前台会话结束(ipc_dispatch 不再受理 NOTIF_OPEN) */
|
||||
meow_notif_open_clear();
|
||||
meow_volpage_sink_clear(); /* E:离开前台撤翻页 sink(音量键交还系统) */
|
||||
if (wpid > 0) {
|
||||
/* 看门狗随前台会话结束回收(后台无手势监听;有界等防挂死) */
|
||||
kill(wpid, SIGTERM);
|
||||
@@ -2208,10 +2280,10 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
usleep(50000);
|
||||
}
|
||||
}
|
||||
/* 写 §10.5 SDK 自抑标记:App 进程见 /tmp/meow_notif_active → present 画进
|
||||
* back_buf 不上屏 + 输入线程排空(=UI 冻结);音频经 audiod socket 天然续播。 */
|
||||
int nfd = open(NOTIF_ACTIVE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (nfd >= 0) { write(nfd, "1", 1); close(nfd); }
|
||||
/* 写 §10.5 SDK 自抑标记(内容空=菜单前台全抑制):App 进程见 /tmp/meow_notif_active
|
||||
* 且内容≠自身 → present 画进 back_buf 不上屏 + 输入线程排空(=UI 冻结,不叠前台/不穿输入);
|
||||
* 音频经 audiod socket 天然续播。 */
|
||||
notif_active_set("");
|
||||
meow_write_app_state(-1);
|
||||
unlink(MEOW_SBAR_MODE_FILE); /* 顶条模式文件由 App SDK 写;转后台清理复位菜单整条 */
|
||||
g_sb_mode_last = -1;
|
||||
@@ -2227,6 +2299,7 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
/* §10.5:App 前台会话结束,清会话标记(ipc_dispatch 不再受理 MSG_NOTIF_OPEN) */
|
||||
g_app_session_active = 0;
|
||||
meow_notif_open_clear();
|
||||
meow_volpage_sink_clear(); /* E:离开前台撤翻页 sink(音量键交还系统) */
|
||||
/* M2.17 兜底:App 会话结束(waitpid 回收/崩溃/被杀,回主界面)清 fg_act ——
|
||||
* App 在 keep_awake activity(如 ncm 播放页)被 OOM/崩溃杀死且未弹回 home 时,g_fg_act 会
|
||||
* 滞留 "<act>",使主页 fg_keep_awake()==1 不再自动息屏。此处兜底清空(App 已退出/死亡,
|
||||
@@ -2279,6 +2352,9 @@ static int launch_app_chain(struct app *a, const char *chain)
|
||||
* 让菜单音量/状态栏与真实 DAC 一致(watchdog 可能已改)。 */
|
||||
settings_load();
|
||||
meow_nav_exit(); /* M3:前台应用会话结束,清导航栈 */
|
||||
/* 回菜单:标记置空(菜单前台全抑制)——若仍驻留后台常驻 App(批①),其继续自抑不叠帧/不穿输入;
|
||||
* 若已无后台 App,置空标记无害(SDK 无抑制目标)。 */
|
||||
notif_active_set("");
|
||||
/* 阶段D附录B:源 App 退出 → 清其媒体会话(如该 App 上报过 META),通知中心/播放页回空态 */
|
||||
meow_media_clear(a->id);
|
||||
logmsg("app %s exited status=%d", a->name, WEXITSTATUS(status));
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/* bg_page.c — 批③ 后台管理页(桌面顶栏居右按钮进入)。
|
||||
*
|
||||
* 容器:sub_page_t 脚手架(subpage.c,与 wifi_page/bt_page 同款)。顶栏=meow_topappbar:
|
||||
* 返回箭头 + 标题「后台管理」+ **顶栏标题栏右侧「一键清除后台」按钮**(sub_page_t.top_action,
|
||||
* act=bgm_top_action → bgmgr_kill_all,**不杀受保护/当前前台/非后台**,经 bgmgr_can_kill 门控)。
|
||||
* 列表(live=1 约 2s 重扫,实时增删):
|
||||
* - 每后台 App 一行 SUB_ROW:label=名字,sub=「CPU x% · yMB · 状态」,**行最右一个「杀」按钮**
|
||||
* (sub_page_t.row_draw 行自绘 + row_tap 自定义命中:命中按钮区=kill 该 App;行体不响应)。
|
||||
* - 行=MD3 圆角卡(meow_gfx_fill_round r=12 surfaceContainer;卡在行高内上下对称内缩
|
||||
* BGM_CARD_GAP/2,行中心=卡中心,相邻卡 8px 外距——与主界面列表/ ncm 歌单同手法)。
|
||||
* - 空态「无后台 App」。
|
||||
* 数据源=amsd FG/BG 注册表(后台常驻集=launcher meow_bg 表,EV BG/FG 上报进 amsd)+ 每进程
|
||||
* pcpu/rss(meow_svc_perf 同源 /proc 聚合,本页在 launcher 内同口径取)。
|
||||
*
|
||||
* 按钮化(相对 agent-425 的「点行 kill + 顶置清除动作行」):
|
||||
* - kill:改「点整行」为「行最右独立杀按钮」——行自绘(bgm_row_draw)+ 自定义命中(bgm_row_tap),
|
||||
* 命中几何=bgm_btn_rect(行右 46×34 圆角按钮);行体其它区域 point 回 0(不杀)。
|
||||
* - 清:删顶置「一键清除后台」SUB_ACT 动作行,改为顶栏标题栏右侧 action 按钮(top_action)。
|
||||
*/
|
||||
#include "meow_launcher_internal.h"
|
||||
#include "meow_bgplay.h"
|
||||
#include "meow_bgmgr.h"
|
||||
#include <ctype.h>
|
||||
|
||||
/* 行内杀按钮尺寸(圆心/圆角定位用;命中与绘制共用同一几何,保证所见即所点) */
|
||||
#define BGM_BTN_W 46
|
||||
#define BGM_BTN_H 34
|
||||
|
||||
/* 圆角卡列表项外边距(item margin/gap):相邻卡片间可见空隙 px。SDK listview 行距(=SUB_ITEM_H)
|
||||
* 不变——卡片在其行高内上下对称内缩 BGM_CARD_GAP/2,故行内内容(按行高垂直居中)仍落卡中心;
|
||||
* 相邻卡顶间距恒=SUB_ITEM_H=卡高+BGM_CARD_GAP。要求偶数,保证上下对称。 */
|
||||
#define BGM_CARD_GAP 8
|
||||
static inline int bgm_row_card_y(int row_top) { return row_top + BGM_CARD_GAP / 2; }
|
||||
static inline int bgm_row_card_h(int h) { return h - BGM_CARD_GAP; }
|
||||
|
||||
typedef struct {
|
||||
struct app *apps;
|
||||
int napps;
|
||||
bgmgr_row_t rows[MEOW_BGMR_ROW_MAX];
|
||||
int n;
|
||||
/* 行副文本存储:sub_row_t.sub 被 SDK 行池/自绘在整页会话内持续读取,
|
||||
* 不能指向 bgm_rows 的栈帧(曾用函数内 char sub[80] → 悬垂,页面副标题为乱码)。 */
|
||||
char subs[MEOW_BGMR_ROW_MAX][80];
|
||||
} bgm_ctx_t;
|
||||
|
||||
/* 找 struct app(launcher 应用表)by id */
|
||||
static struct app *bgm_app_find(struct app *apps, int napps, const char *app_id)
|
||||
{
|
||||
if (!apps || !app_id) return NULL;
|
||||
for (int i = 0; i < napps; i++)
|
||||
if (apps[i].id[0] && !strcmp(apps[i].id, app_id)) return &apps[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ---- meow_bgmgr ops 注入(UI 侧真实实现)---- */
|
||||
static int bgm_app_meta(void *ctx, const char *app_id, pid_t pid,
|
||||
char *name, int nl, char *icon, int il,
|
||||
char *protected_app, int *is_fg)
|
||||
{
|
||||
(void)pid;
|
||||
bgm_ctx_t *b = ctx;
|
||||
struct app *a = bgm_app_find(b->apps, b->napps, app_id);
|
||||
*protected_app = 0;
|
||||
*is_fg = 0;
|
||||
if (!a) return -1; /* 未注册 → name 回退 app_id */
|
||||
if (a->name[0]) snprintf(name, nl, "%s", a->name);
|
||||
if (icon && il > 0) snprintf(icon, il, "%s", a->icon[0] ? a->icon : "");
|
||||
/* 刻意**不**用 a->framework:app.json 的 framework=1 意为「用 meow_ui.a 的 SDK 框架 App」
|
||||
* (docs/APP_DEV_GUIDE.md §9,推荐且现有 App 全写 1),不是「系统 App」。曾把它当「禁止 kill」
|
||||
* 门控 → 后台页所有行的「杀」按钮被置灰、点了不杀也不留日志(2026-11-04 真机 dev.player.ncm)。
|
||||
* launcher 自身组件(状态栏/设置页/子页…)不在 App 表也不在后台表,无需此位保护。 */
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int bgm_enumerate(void *ctx, bgmgr_row_t *rows, int max)
|
||||
{
|
||||
(void)ctx;
|
||||
/* 逐槽扫描并**压缩**(g_bg 可能有空洞),跳过已发出终止的条目:
|
||||
* meow_bg_terminate 只置 term_at_ms 发 SIGTERM,真正的 reap/摘表在 meow_bg_tick(launcher
|
||||
* 主循环),它在本页 meow_subpage_run 阻塞期间**不跑**——若不在此过滤,kill/清后旧行会一直
|
||||
* 残留(pid 已死/0 CPU),页面上看就是「点不动」。过滤后 kill/一键清当下即从列表消失(可见反馈)。 */
|
||||
int k = 0;
|
||||
for (int i = 0; i < MEOW_BG_MAX && k < max; i++) {
|
||||
const meow_bg_entry_t *e = meow_bg_at(i);
|
||||
if (!e || !e->used || !e->app_id[0]) continue;
|
||||
if (e->term_at_ms) continue; /* 已进入终止序列(kill/宽限回收/卡拔)→ 行不再显示 */
|
||||
memset(&rows[k], 0, sizeof rows[k]);
|
||||
snprintf(rows[k].app_id, sizeof rows[k].app_id, "%s", e->app_id);
|
||||
rows[k].pid = e->pid;
|
||||
rows[k].keepalive = (e->keepalive ? 1 : 0);
|
||||
rows[k].on_card = (e->on_card ? 1 : 0);
|
||||
rows[k].bg_since_ms = e->bg_since_ms;
|
||||
k++;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
/* proc_sample:/proc/<pid>/stat(utime+stime 差分) + /proc/<pid>/status(VmRSS)。
|
||||
* 与 meow_svc_perf 897-983 每进程 pcpu/rss 聚合同口径(同 /proc 源)。 */
|
||||
static long g_cpu_pid[MEOW_BG_MAX] = {0};
|
||||
static unsigned long long g_cpu_tick[MEOW_BG_MAX] = {0};
|
||||
|
||||
static int bgm_proc_sample(void *ctx, pid_t pid, double *pcpu, long *rss_kb)
|
||||
{
|
||||
(void)ctx;
|
||||
if (pid <= 0) return -1;
|
||||
char path[64], line[512];
|
||||
long rss = 0;
|
||||
snprintf(path, sizeof path, "/proc/%d/status", pid);
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
ssize_t nr = read(fd, line, sizeof line - 1); close(fd);
|
||||
if (nr > 0) { line[nr] = 0;
|
||||
char *p = strstr(line, "VmRSS:");
|
||||
if (p) p = strchr(p, ' ');
|
||||
if (p) { while (*p == ' ' || *p == '\t') p++; rss = atol(p); }
|
||||
}
|
||||
}
|
||||
double pc = 0.0;
|
||||
snprintf(path, sizeof path, "/proc/%d/stat", pid);
|
||||
fd = open(path, O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
ssize_t nr = read(fd, line, sizeof line - 1); close(fd);
|
||||
if (nr > 0) {
|
||||
line[nr] = 0;
|
||||
char *rp = strrchr(line, ')');
|
||||
if (rp) {
|
||||
char *p = rp + 2;
|
||||
unsigned long long ut[2] = {0, 0};
|
||||
for (int f = 0; f < 14; f++) {
|
||||
while (*p == ' ') p++;
|
||||
char *end; unsigned long long v = strtoull(p, &end, 10);
|
||||
if (end == p) { p++; continue; }
|
||||
p = end;
|
||||
if (f == 11) ut[0] = v; /* utime */
|
||||
if (f == 12) ut[1] = v; /* stime */
|
||||
}
|
||||
unsigned long long cur = ut[0] + ut[1];
|
||||
static double hz = 0.0;
|
||||
if (hz <= 0) { hz = (double)sysconf(_SC_CLK_TCK); if (hz <= 0) hz = 100.0; }
|
||||
unsigned long long prev = 0; int had = 0, slot = -1;
|
||||
for (int i = 0; i < MEOW_BG_MAX; i++) {
|
||||
if (g_cpu_pid[i] == pid) { prev = g_cpu_tick[i]; had = 1; slot = i; break; }
|
||||
}
|
||||
if (!had) {
|
||||
for (int i = 0; i < MEOW_BG_MAX; i++) if (g_cpu_pid[i] == 0) { slot = i; break; }
|
||||
if (slot < 0) slot = 0;
|
||||
}
|
||||
if (slot >= 0) { g_cpu_pid[slot] = pid; g_cpu_tick[slot] = cur; }
|
||||
if (had) { pc = 100.0 * (double)(cur - prev) / hz; if (pc < 0) pc = 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
*pcpu = pc;
|
||||
*rss_kb = rss;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- rows(填 sub_row_t 列表;每后台 App 一行,空态一行)---- */
|
||||
static int bgm_rows(void *ctx, sub_row_t *rbuf, int max)
|
||||
{
|
||||
bgm_ctx_t *b = ctx;
|
||||
bgmgr_ops_t ops;
|
||||
memset(&ops, 0, sizeof ops);
|
||||
ops.ctx = b;
|
||||
ops.enumerate = bgm_enumerate;
|
||||
ops.app_meta = bgm_app_meta;
|
||||
ops.proc_sample = bgm_proc_sample;
|
||||
b->n = bgmgr_collect(&ops, b->rows, MEOW_BGMR_ROW_MAX);
|
||||
|
||||
int r = 0;
|
||||
if (b->n == 0) {
|
||||
if (r < max) {
|
||||
rbuf[r].kind = SUB_ROW;
|
||||
rbuf[r].label = meow_str_zh("bgmgr.empty", "无后台 App");
|
||||
rbuf[r].sub = NULL;
|
||||
rbuf[r].act = -1;
|
||||
r++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
for (int i = 0; i < b->n && r < max; i++) {
|
||||
/* 副文本写进 ctx 的持久缓冲(b->subs):sub_row_t.sub 会被 SDK 行池在整页会话内反复读取,
|
||||
* 指向本函数栈帧的 buffer 会在返回后变成乱码/越界(真机实证:副标题全是乱码字节)。 */
|
||||
snprintf(b->subs[i], sizeof b->subs[i], "CPU %.1f%% · %s · %s", b->rows[i].pcpu,
|
||||
bgmgr_mem_str(b->rows[i].rss_kb), bgmgr_status_str(&b->rows[i]));
|
||||
rbuf[r].kind = SUB_ROW;
|
||||
rbuf[r].label = b->rows[i].name[0] ? b->rows[i].name : b->rows[i].app_id;
|
||||
rbuf[r].sub = b->subs[i];
|
||||
rbuf[r].act = i; /* row_tap/row_draw 用 i 定位行 */
|
||||
r++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/* kill 通道(bgmgr_kill_all 的函数指针签名:void(*)(const char*)) */
|
||||
static void bgm_kill_fn(const char *app_id) { meow_bg_terminate(app_id); }
|
||||
|
||||
/* ---- 行内杀按钮矩形(绘制与命中共用;行视口矩形 rx,ry,rw,rh → 右侧 46×34 圆角钮)----
|
||||
* 卡在行高内上下对称内缩 BGM_CARD_GAP/2(行中心=卡中心);按钮垂直居中于卡内,故其中心
|
||||
* =卡中心=行中心。命中矩形仍为整行距(row_tap 传整行视口),按钮几何不变 → 卡内缩后命中不破。 */
|
||||
static void bgm_btn_rect(int rx, int ry, int rw, int rh, int *bx, int *by, int *bw, int *bh)
|
||||
{
|
||||
*bw = BGM_BTN_W;
|
||||
*bh = BGM_BTN_H;
|
||||
*bx = rx + rw - 12 - BGM_BTN_W;
|
||||
*by = bgm_row_card_y(ry) + (bgm_row_card_h(rh) - BGM_BTN_H) / 2;
|
||||
}
|
||||
|
||||
/* ---- 行完全自绘(sub_page_t.row_draw;覆盖默认 renderer)----
|
||||
* 空态行(act=-1)居中画「无后台 App」;后台 App 行画 名字 + CPU/内存/状态 + 行右「杀」按钮。 */
|
||||
static void bgm_row_draw(void *ctx, int idx, sub_row_t *r,
|
||||
int x, int y, int w, int h, int state)
|
||||
{
|
||||
(void)idx;
|
||||
bgm_ctx_t *b = ctx;
|
||||
const meow_theme *mt = meow_theme_get();
|
||||
int i = r->act;
|
||||
|
||||
/* MD3 圆角卡:行高内上下对称内缩 BGM_CARD_GAP/2(行中心=卡中心),替代扁平行+底部分隔线。 */
|
||||
int cy = bgm_row_card_y(y);
|
||||
int ch = bgm_row_card_h(h);
|
||||
meow_gfx_fill_round(x, cy, w, ch, 12, mt->surfaceContainer);
|
||||
if (state & MEOW_LISTVIEW_ST_PRESSED)
|
||||
meow_gfx_fill_round_alpha(x, cy, w, ch, 12, mt->primary, 26); /* 按压态(实时,限卡内) */
|
||||
|
||||
if (i < 0 || i >= b->n) { /* 空态行 */
|
||||
int th = meow_ui_text_height(18);
|
||||
meow_ui_text_center(x + w / 2, cy + (ch - th) / 2,
|
||||
r->label ? r->label : meow_str_zh("bgmgr.empty", "无后台 App"),
|
||||
18, mt->onSurfaceVariant);
|
||||
return;
|
||||
}
|
||||
int th = meow_ui_text_height(20), tsub = meow_ui_text_height(16);
|
||||
int rx = x + 16; /* 左内边距 */
|
||||
int ry = cy + (ch - (th + tsub + 4)) / 2; /* 内容在卡内垂直居中(行中心=卡中心) */
|
||||
if (r->label) meow_ui_text(rx, ry, r->label, 20, mt->onSurface);
|
||||
if (r->sub) meow_ui_text(rx, ry + th + 4, r->sub, 16, mt->onSurfaceVariant);
|
||||
/* 行内杀按钮(全形=胶囊(bh/2);「杀」;error=红=杀)——P1 按钮胶囊化。
|
||||
* 不可杀行(受保护/前台/无进程,bgmgr_can_kill=0)→ 置灰禁点外观,避免误导(按钮可点但不可杀)。 */
|
||||
int bx, by, bw, bh; bgm_btn_rect(x, y, w, h, &bx, &by, &bw, &bh);
|
||||
uint32_t btn_col = mt->error; /* 可杀:error 红 */
|
||||
if (i >= 0 && i < b->n && !b->rows[i].killable)
|
||||
btn_col = mt->onSurfaceVariant; /* 禁点:置灰(onSurfaceVariant) */
|
||||
meow_ui_draw_rect_round(bx, by, bw, bh, bh / 2, btn_col);
|
||||
int bth = meow_ui_text_height(16);
|
||||
if (bth > bh) bth = bh;
|
||||
meow_ui_text_center(bx + bw / 2, by + (bh - bth) / 2, "杀", 16, btn_col);
|
||||
}
|
||||
|
||||
/* ---- 行内自定义命中(sub_page_t.row_tap;仅按钮区 kill,行体不响应/不误杀)---- */
|
||||
static int bgm_row_tap(void *ctx, int idx, sub_row_t *r, int x, int y,
|
||||
int rx, int ry, int rw, int rh)
|
||||
{
|
||||
(void)idx;
|
||||
bgm_ctx_t *b = ctx;
|
||||
int i = r->act;
|
||||
if (i < 0 || i >= b->n) return 0; /* 空态行无按钮 */
|
||||
int bx, by, bw, bh; bgm_btn_rect(rx, ry, rw, rh, &bx, &by, &bw, &bh);
|
||||
if (x < bx || x >= bx + bw || y < by || y >= by + bh) return 0; /* 行体/按钮外 → 不杀 */
|
||||
if (!bgmgr_can_kill(&b->rows[i])) {
|
||||
/* 禁点:受保护/前台/无进程 → 按钮已置灰,不 kill(返回 1=重绘)。
|
||||
* 留一行日志:「按了没反应」当年因为没有这行日志,只能靠翻 app.json 才查出是门控。 */
|
||||
logmsg("bgpage: kill 被门控 app=%s pid=%d fg=%d protected=%d",
|
||||
b->rows[i].app_id, (int)b->rows[i].pid, b->rows[i].is_fg,
|
||||
b->rows[i].protected_app ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
int rc = meow_bg_terminate(b->rows[i].app_id); /* 置 term_at_ms → bgm_enumerate 下一跳行消失(可见反馈) */
|
||||
if (rc != 0)
|
||||
logmsg("bgpage: kill app=%s 未命中后台表 rc=%d(列表/后台表不同步)", b->rows[i].app_id, rc);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- 行体点击(sub_page_t.detail):不响应(kill 只认行内按钮)---- */
|
||||
static int bgm_detail(void *ctx, int idx, int act)
|
||||
{
|
||||
(void)ctx; (void)idx; (void)act;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ---- 顶栏右侧「一键清除后台」按钮(sub_page_t.top_action_cb)---- */
|
||||
static int bgm_top_action(void *ctx)
|
||||
{
|
||||
bgm_ctx_t *b = ctx;
|
||||
bgmgr_ops_t ops;
|
||||
memset(&ops, 0, sizeof ops);
|
||||
ops.ctx = b;
|
||||
ops.enumerate = bgm_enumerate; /* 行已由 bgm_rows 建 */
|
||||
/* kill 只置 term_at_ms(真退/摘表在 launcher 主循环 bgm_tick),此处由 bgm_enumerate 过滤
|
||||
* term_at_ms 让清后的行当下消失(可见反馈;不可杀行本就在上/一键不杀)。 */
|
||||
int k = bgmgr_kill_all(&ops, b->rows, b->n, bgm_kill_fn);
|
||||
if (k < b->n)
|
||||
logmsg("bgpage: 一键清除 杀 %d/%d(其余为前台/无进程/受保护)", k, b->n);
|
||||
return 1; /* 重绘:重扫行(已清行消失) */
|
||||
}
|
||||
|
||||
/* ---- 入口:运行后台管理页(阻塞到返回)---- */
|
||||
int run_bg_page(struct app *apps, int napps)
|
||||
{
|
||||
static bgm_ctx_t b;
|
||||
memset(&b, 0, sizeof b);
|
||||
b.apps = apps;
|
||||
b.napps = napps;
|
||||
sub_page_t p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.title = "后台管理";
|
||||
p.rows = bgm_rows;
|
||||
p.detail = bgm_detail;
|
||||
p.live = 1; /* 后台表/进程变化实时重扫 */
|
||||
p.ctx = &b;
|
||||
p.top_action = "一键清除后台"; /* 顶栏标题栏右侧按钮 */
|
||||
p.top_action_cb = bgm_top_action;
|
||||
p.row_draw = bgm_row_draw; /* 行自绘(名字/CPU/内存/状态 + 行右杀按钮) */
|
||||
p.row_tap = bgm_row_tap; /* 行内自定义命中(仅按钮区 kill) */
|
||||
meow_subpage_run(&p);
|
||||
|
||||
/* 清除 CPU 差分缓存(页面会话结束) */
|
||||
memset(g_cpu_pid, 0, sizeof g_cpu_pid);
|
||||
memset(g_cpu_tick, 0, sizeof g_cpu_tick);
|
||||
return 0;
|
||||
}
|
||||
@@ -25,9 +25,10 @@ SYSROOT="$ROOT/.tools/mipsel-linux-muslsf-cross/mipsel-linux-muslsf"
|
||||
"$CC" -I"../sdk" -Os -static \
|
||||
main_menu.c statusbar.c fb_compositor.c settings_page.c power_menu.c \
|
||||
notif_center.c app_launcher.c audio.c backlight.c net.c about.c \
|
||||
subpage.c wifi_page.c bt_page.c netmgr_ipc.c now_playing.c \
|
||||
font_theme.c keys.c util.c meow_dep.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 key_router.c \
|
||||
meow_touch.c meow_settings.c meow_notif.c meow_media.c meow_bgplay.c \
|
||||
meow_bgmgr.c \
|
||||
meow_ams_start.c lock_screen.c lock_screen_ui.c lock_settings.c \
|
||||
"../sdk/meow_lyric.c" "../sdk/meow_lyric_view.c" "../sdk/meow_img_color.c" \
|
||||
"../sdk/meow_ui.a" "$SYSROOT/lib/libz.a" -lpthread -o meow_launcherd
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -212,3 +212,92 @@ void load_config(void)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@ static void lk_key_render(meow_widget *w, const meow_theme *th, void *ctx)
|
||||
const char *label;
|
||||
int size;
|
||||
if (key >= 0) { size = 30; label = lk_key_digit(key); }
|
||||
else if (key == LK_ACT_CLR) { size = 22; label = mfont_ready ? "清空" : "C"; }
|
||||
else { size = 22; label = mfont_ready ? "退格" : "DEL"; }
|
||||
else if (key == LK_ACT_CLR) { size = 22; label = mfont_ready ? meow_str_zh("lock.clear", "清空") : "C"; }
|
||||
else { size = 22; label = mfont_ready ? meow_str_zh("lock.backspace", "退格") : "DEL"; }
|
||||
int cx = x + ww / 2;
|
||||
int cy = y + hh / 2 - meow_ui_text_height(size) / 2;
|
||||
meow_ui_text_center(cx, cy, label, size, th->onSurface);
|
||||
@@ -252,12 +252,13 @@ static void lk_draw(int entered, const char *status, time_t locked_until)
|
||||
meow_ui_fill_rect(0, 0, fb_xres, fb_yres, mt->surface); /* 全屏不透明表面底 */
|
||||
lk_widgets_build(); /* 懒创建 12 键 */
|
||||
/* 时钟/提示(居中,SDK meow_font):锁定倒计时标题 */
|
||||
meow_ui_text_center(fb_xres / 2, 120, locked_until > 0 ? "已锁定" : "输入 PIN 解锁",
|
||||
meow_ui_text_center(fb_xres / 2, 120, locked_until > 0 ? meow_str_zh("lock.locked", "已锁定")
|
||||
: meow_str_zh("lock.prompt", "输入 PIN 解锁"),
|
||||
24, mt->onSurface);
|
||||
/* PIN 输入圆点(● 遮罩) */
|
||||
lk_draw_pin_dots(entered, mt);
|
||||
/* 状态行:错误提示(PIN 错误)着 error;倒计时/其它信息用 onSurfaceVariant */
|
||||
int is_err = status[0] && (strstr(status, "PIN 错误") != NULL ||
|
||||
int is_err = status[0] && (strstr(status, meow_str_zh("lock.pin_wrong", "PIN 错误")) != NULL ||
|
||||
strstr(status, "WRONG PIN") != NULL);
|
||||
uint32_t status_col = status[0] ? (is_err ? mt->error : mt->onSurfaceVariant)
|
||||
: mt->onSurfaceVariant;
|
||||
@@ -303,7 +304,7 @@ int run_lock_screen(void)
|
||||
time_t lock_until = 0;
|
||||
{
|
||||
time_t rem = lck_lock_remaining();
|
||||
if (rem > 0) { lock_until = time(NULL) + rem; snprintf(status, sizeof status, "已锁定"); }
|
||||
if (rem > 0) { lock_until = time(NULL) + rem; snprintf(status, sizeof status, "%s", meow_str_zh("lock.locked", "已锁定")); }
|
||||
}
|
||||
time_t last_countdown = 0;
|
||||
|
||||
@@ -392,7 +393,7 @@ int run_lock_screen(void)
|
||||
if (lock_until > 0 && now != last_countdown) {
|
||||
last_countdown = now;
|
||||
time_t rem = lock_until - now;
|
||||
if (mfont_ready) snprintf(status, sizeof status, "已锁定,请 %d:%02d 后重试",
|
||||
if (mfont_ready) snprintf(status, sizeof status, meow_str_zh("lock.locked_wait", "已锁定,请 %d:%02d 后重试"),
|
||||
(int)(rem / 60), (int)(rem % 60));
|
||||
else snprintf(status, sizeof status, "LOCKED %d:%02d", (int)(rem / 60), (int)(rem % 60));
|
||||
redraw = 1;
|
||||
@@ -545,9 +546,9 @@ int run_lock_screen(void)
|
||||
if (rem > 0) {
|
||||
lock_until = time(NULL) + rem;
|
||||
last_countdown = 0;
|
||||
snprintf(status, sizeof status, "已锁定");
|
||||
snprintf(status, sizeof status, "%s", meow_str_zh("lock.locked", "已锁定"));
|
||||
} else {
|
||||
if (mfont_ready) snprintf(status, sizeof status, "PIN 错误");
|
||||
if (mfont_ready) snprintf(status, sizeof status, "%s", meow_str_zh("lock.pin_wrong", "PIN 错误"));
|
||||
else snprintf(status, sizeof status, "WRONG PIN");
|
||||
}
|
||||
}
|
||||
@@ -671,10 +672,10 @@ int lck_input_pin_modal(const char *title, char *out, int outcap)
|
||||
draw_text(x + w / 2 - 7, y + LK_ROW_H / 2 - 14, d, 2, th->on_surface);
|
||||
}
|
||||
} else if (key == LK_ACT_CLR) {
|
||||
const char *t2 = "C"; if (mfont_ready) t2 = "清空";
|
||||
const char *t2 = "C"; if (mfont_ready) t2 = meow_str_zh("lock.clear", "清空");
|
||||
lk_text(x + w / 2, y + LK_ROW_H / 2 - 9, t2, 22, th->on_surface);
|
||||
} else {
|
||||
const char *t2 = "DEL"; if (mfont_ready) t2 = "退格";
|
||||
const char *t2 = "DEL"; if (mfont_ready) t2 = meow_str_zh("lock.backspace", "退格");
|
||||
lk_text(x + w / 2, y + LK_ROW_H / 2 - 9, t2, 22, th->on_surface);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,30 +20,30 @@ static char g_ls_status[48]; /* 状态行摘要(rows 填,paint
|
||||
|
||||
static void ls_status_text(char *buf, int len)
|
||||
{
|
||||
if (!lck_pin_configured()) { snprintf(buf, len, "未设置"); return; }
|
||||
if (!lck_pin_configured()) { snprintf(buf, len, "%s", meow_str_zh("lock.status_unset", "未设置")); return; }
|
||||
time_t rem = lck_lock_remaining();
|
||||
if (rem > 0) snprintf(buf, len, "锁定中(%d:%02d)", (int)(rem / 60), (int)(rem % 60));
|
||||
else snprintf(buf, len, "已设置");
|
||||
if (rem > 0) snprintf(buf, len, meow_str_zh("lock.status_locked", "锁定中(%d:%02d)"), (int)(rem / 60), (int)(rem % 60));
|
||||
else snprintf(buf, len, "%s", meow_str_zh("lock.status_set", "已设置"));
|
||||
}
|
||||
|
||||
static int ls_rows(void *ctx, sub_row_t *rbuf, int max)
|
||||
{
|
||||
(void)ctx;
|
||||
int n = 0;
|
||||
if (n < max) { rbuf[n].kind = SUB_HDR; rbuf[n].label = "锁屏 PIN"; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++; }
|
||||
if (n < max) { rbuf[n].kind = SUB_HDR; rbuf[n].label = meow_str_zh("lock.title", "锁屏 PIN"); rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = 0; n++; }
|
||||
ls_status_text(g_ls_status, sizeof g_ls_status);
|
||||
if (n < max) { rbuf[n].kind = SUB_ROW; rbuf[n].label = "状态"; rbuf[n].sub = g_ls_status; rbuf[n].on = 0; rbuf[n].act = LS_ACT_STATUS; n++; }
|
||||
if (n < max) { rbuf[n].kind = SUB_ROW; rbuf[n].label = meow_str_zh("lock.status_row", "状态"); rbuf[n].sub = g_ls_status; rbuf[n].on = 0; rbuf[n].act = LS_ACT_STATUS; n++; }
|
||||
int configured = lck_pin_configured();
|
||||
if (!configured) {
|
||||
if (n < max) { rbuf[n].kind = SUB_ACT; rbuf[n].label = "设置 PIN"; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = LS_ACT_SET; n++; }
|
||||
if (n < max) { rbuf[n].kind = SUB_ACT; rbuf[n].label = meow_str_zh("lock.act_set", "设置 PIN"); rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = LS_ACT_SET; n++; }
|
||||
} else {
|
||||
if (n < max) { rbuf[n].kind = SUB_ACT; rbuf[n].label = "修改 PIN"; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = LS_ACT_MODIFY; n++; }
|
||||
if (n < max) { rbuf[n].kind = SUB_ACT; rbuf[n].label = "清除 PIN"; rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = LS_ACT_CLEAR; n++; }
|
||||
if (n < max) { rbuf[n].kind = SUB_ACT; rbuf[n].label = meow_str_zh("lock.act_modify", "修改 PIN"); rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = LS_ACT_MODIFY; n++; }
|
||||
if (n < max) { rbuf[n].kind = SUB_ACT; rbuf[n].label = meow_str_zh("lock.act_clear", "清除 PIN"); rbuf[n].sub = NULL; rbuf[n].on = 0; rbuf[n].act = LS_ACT_CLEAR; n++; }
|
||||
}
|
||||
if (n < max) {
|
||||
int wake = 1;
|
||||
if (meow_settings_get_int("system.pin_lock_on_wake", &wake) != 0) wake = 1;
|
||||
rbuf[n].kind = SUB_SW; rbuf[n].label = "唤醒后锁屏"; rbuf[n].sub = NULL;
|
||||
rbuf[n].kind = SUB_SW; rbuf[n].label = meow_str_zh("lock.wake_lock", "唤醒后锁屏"); rbuf[n].sub = NULL;
|
||||
rbuf[n].on = wake; rbuf[n].act = LS_ACT_WAKE; n++;
|
||||
}
|
||||
return n;
|
||||
@@ -58,35 +58,35 @@ static void ls_modal(const char *title, const char *line)
|
||||
static int ls_do_modify(void)
|
||||
{
|
||||
char old[LCK_PIN_LEN + 1];
|
||||
if (!lck_input_pin_modal("输入当前 PIN", old, sizeof old)) return 0;
|
||||
if (!lck_pin_check(old)) { ls_modal("修改 PIN", "当前 PIN 错误"); return 1; }
|
||||
if (!lck_input_pin_modal(meow_str_zh("lock.enter_cur", "输入当前 PIN"), old, sizeof old)) return 0;
|
||||
if (!lck_pin_check(old)) { ls_modal(meow_str_zh("lock.act_modify", "修改 PIN"), meow_str_zh("lock.cur_wrong", "当前 PIN 错误")); return 1; }
|
||||
char a[LCK_PIN_LEN + 1], b[LCK_PIN_LEN + 1];
|
||||
if (!lck_input_pin_modal("设置新 PIN", a, sizeof a)) return 0;
|
||||
if (!lck_input_pin_modal("再次确认新 PIN", b, sizeof b)) return 0;
|
||||
if (strcmp(a, b) != 0) { ls_modal("修改 PIN", "两次输入不一致"); return 1; }
|
||||
if (lck_pin_set(a) == MEOW_OK) ls_modal("修改 PIN", "已修改");
|
||||
else ls_modal("修改 PIN", "设置失败");
|
||||
if (!lck_input_pin_modal(meow_str_zh("lock.set_new", "设置新 PIN"), a, sizeof a)) return 0;
|
||||
if (!lck_input_pin_modal(meow_str_zh("lock.confirm_new", "再次确认新 PIN"), b, sizeof b)) return 0;
|
||||
if (strcmp(a, b) != 0) { ls_modal(meow_str_zh("lock.act_modify", "修改 PIN"), meow_str_zh("lock.mismatch", "两次输入不一致")); return 1; }
|
||||
if (lck_pin_set(a) == MEOW_OK) ls_modal(meow_str_zh("lock.act_modify", "修改 PIN"), meow_str_zh("lock.modified", "已修改"));
|
||||
else ls_modal(meow_str_zh("lock.act_modify", "修改 PIN"), meow_str_zh("lock.set_failed", "设置失败"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int ls_do_set(void)
|
||||
{
|
||||
char a[LCK_PIN_LEN + 1], b[LCK_PIN_LEN + 1];
|
||||
if (!lck_input_pin_modal("设置 4 位 PIN", a, sizeof a)) return 0;
|
||||
if (!lck_input_pin_modal("再次输入确认", b, sizeof b)) return 0;
|
||||
if (strcmp(a, b) != 0) { ls_modal("设置 PIN", "两次输入不一致"); return 1; }
|
||||
if (lck_pin_set(a) == MEOW_OK) ls_modal("设置 PIN", "已设置");
|
||||
else ls_modal("设置 PIN", "设置失败");
|
||||
if (!lck_input_pin_modal(meow_str_zh("lock.enter_4", "设置 4 位 PIN"), a, sizeof a)) return 0;
|
||||
if (!lck_input_pin_modal(meow_str_zh("lock.confirm_input", "再次输入确认"), b, sizeof b)) return 0;
|
||||
if (strcmp(a, b) != 0) { ls_modal(meow_str_zh("lock.act_set", "设置 PIN"), meow_str_zh("lock.mismatch", "两次输入不一致")); return 1; }
|
||||
if (lck_pin_set(a) == MEOW_OK) ls_modal(meow_str_zh("lock.act_set", "设置 PIN"), meow_str_zh("lock.status_set", "已设置"));
|
||||
else ls_modal(meow_str_zh("lock.act_set", "设置 PIN"), meow_str_zh("lock.set_failed", "设置失败"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int ls_do_clear(void)
|
||||
{
|
||||
const char *items[2]; items[0] = "是"; items[1] = "否";
|
||||
int pick = s_dd_modal("确定清除锁屏 PIN?", items, 2, 1); /* 默认「否」(安全) */
|
||||
const char *items[2]; items[0] = meow_str_zh("power.confirm_yes", "是"); items[1] = meow_str_zh("power.confirm_no", "否");
|
||||
int pick = s_dd_modal(meow_str_zh("lock.clear_confirm", "确定清除锁屏 PIN?"), items, 2, 1); /* 默认「否」(安全) */
|
||||
if (pick != 0) return 1;
|
||||
if (lck_pin_clear() == MEOW_OK) ls_modal("清除 PIN", "已清除");
|
||||
else ls_modal("清除 PIN", "清除失败(settings 不可写)");
|
||||
if (lck_pin_clear() == MEOW_OK) ls_modal(meow_str_zh("lock.act_clear", "清除 PIN"), meow_str_zh("lock.cleared", "已清除"));
|
||||
else ls_modal(meow_str_zh("lock.act_clear", "清除 PIN"), meow_str_zh("lock.clear_failed", "清除失败(settings 不可写)"));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -118,10 +118,10 @@ static int ls_detail(void *ctx, int idx, int act)
|
||||
int rem = (int)lck_lock_remaining();
|
||||
if (lck_pin_configured() && rem > 0) {
|
||||
char buff[96];
|
||||
snprintf(buff, sizeof buff, "连续错误已锁定,剩余 %d:%02d 后重试", rem / 60, rem % 60);
|
||||
ls_modal("锁屏 PIN", buff);
|
||||
snprintf(buff, sizeof buff, meow_str_zh("lock.locked_retry", "连续错误已锁定,剩余 %d:%02d 后重试"), rem / 60, rem % 60);
|
||||
ls_modal(meow_str_zh("lock.title", "锁屏 PIN"), buff);
|
||||
} else {
|
||||
ls_modal("锁屏 PIN", line);
|
||||
ls_modal(meow_str_zh("lock.title", "锁屏 PIN"), line);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ int run_lock_settings_page(void)
|
||||
{
|
||||
sub_page_t p;
|
||||
memset(&p, 0, sizeof p);
|
||||
p.title = "锁屏 PIN";
|
||||
p.title = meow_str_zh("lock.title", "锁屏 PIN");
|
||||
p.top_sw = NULL;
|
||||
p.top_sw_set = NULL;
|
||||
p.rows = ls_rows;
|
||||
|
||||
@@ -16,11 +16,14 @@ int menu_opened_blank = 0;
|
||||
|
||||
|
||||
/* launch_app/launch_activity 返回 -1 时的 toast:缺二进制依赖(app.json depends)→ 「缺依赖: <name>」;
|
||||
* 其余启动失败沿用「CANNOT START: <name>」。原因由 meow_dep.c 的 meow_dep_block_reason() 提供。 */
|
||||
* 其余启动失败优先显示具体失败原因(app_launcher.c 的 launch_fail_reason():fork OOM / exec 失败 /
|
||||
* 路径不存在等)→ 「CANNOT START: <name>(<reason>)」;无具体原因则回退「CANNOT START: <name>」。 */
|
||||
static void launch_fail_toast(char *toast, size_t n, const char *name)
|
||||
{
|
||||
const char *reason = launch_fail_reason(); /* M6.40:具体失败原因(成功/未失败时为 "") */
|
||||
const char *bd = meow_dep_block_reason();
|
||||
if (bd && bd[0]) snprintf(toast, n, meow_str_zh("menu.missing_dep", "缺依赖: %s"), bd);
|
||||
else if (reason && reason[0]) snprintf(toast, n, "CANNOT START: %s(%s)", name, reason);
|
||||
else snprintf(toast, n, "CANNOT START: %s", name);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
* 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
|
||||
* flashes back to the player — Bug 4). We drain the queue immediately after
|
||||
* 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). */
|
||||
* flashes back to the player — Bug 4).
|
||||
*
|
||||
* 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;
|
||||
|
||||
/* 排空本进程触摸队列(非阻塞读到 EAGAIN)+ 复位手势状态机。
|
||||
* 队列/down 边沿都要清:只清队列而保留 t_down,会让「按下已被 drain 掉、抬手稍后才到」的
|
||||
* 这次手势被当成一次有效 tap(ghost touch 重放的另一半)。 */
|
||||
static void menu_drain_touch(void)
|
||||
{
|
||||
if (touch_fd >= 0) {
|
||||
unsigned char ev[16];
|
||||
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)
|
||||
@@ -73,18 +87,19 @@ static void menu_arm_touch(long ms)
|
||||
}
|
||||
|
||||
|
||||
/* Drain the touch queue + arm the post-app cooldown (call right after the
|
||||
* app exits, before the menu resumes polling touch). The drain always runs so
|
||||
* touches queued by the launch-attempt tail are cleared; the cooldown only
|
||||
* arms when an app actually ran (app_ran) so a failed launch doesn't lock the
|
||||
* menu out for 2.5s. */
|
||||
/* Drain the touch queue + arm the post-app guard (call right after the app
|
||||
* exits, before the menu resumes polling touch). The drain always runs so
|
||||
* touches queued by the launch-attempt tail are cleared; the guard only arms
|
||||
* when an app actually ran (app_ran) so a failed launch doesn't lock the menu
|
||||
* out. */
|
||||
static void menu_after_app_exit(int app_ran)
|
||||
{
|
||||
menu_drain_touch();
|
||||
/* M2.22(D):2500ms 冷却把正常的「app 退出后立即点下一个应用」也吞掉(点几下才进)。
|
||||
* 用户要求点一下就能进 → 调窄到 1200ms,仍覆盖 goodix ghost 窗口(1.2–2.25s 实测略短,
|
||||
* 与 watchdog COOLDOWN_MS 同阶),防启动重放。只调窄,保留 cooldown 本身。 */
|
||||
if (app_ran) menu_touch_cooldown_until = now_ms() + 1200;
|
||||
/* M39:M2.6 的 2500ms → M2.22 的 1200ms → 现在的 MENU_TOUCH_GUARD_MS(150ms)。
|
||||
* 依据:重放的来源是本进程 fd 里 App 运行期的事件,menu_drain_touch() 已一次性清空
|
||||
* (含 t_down/原点,见上),窗口只兜「面板重配后驱动补发的幽灵尾巴」。1200ms 会稳定吞掉
|
||||
* 用户「返回桌面后立即点下一个图标」——正是要修的症状。 */
|
||||
if (app_ran) menu_touch_cooldown_until = now_ms() + MENU_TOUCH_GUARD_MS;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,11 +109,38 @@ static void menu_after_app_exit(int app_ran)
|
||||
* background then calls draw_header_content. The menu is pre-drawn before
|
||||
* fb_norm_buf0 so the initial pan shows a complete frame. */
|
||||
static int menu_title_size(void); /* 前向声明:draw_header_content 用(定义在下方布局帮助块) */
|
||||
static uint32_t menu_contrast(uint32_t c); /* 前向声明(定义在下方布局帮助块) */
|
||||
|
||||
/* 批③:桌面顶栏居右「后台管理」按钮(柔和圆角底 + 层叠卡片图标 + 小字,替下原先整块
|
||||
* primary 方块)。命中区见 BGMR_BTN_*(不变)。 */
|
||||
#define BGMR_BTN_S 46 /* 按钮边长 */
|
||||
#define BGMR_BTN_X0 (fb_xres - BGMR_BTN_S - 14) /* 右缘留 14px 边距 */
|
||||
#define BGMR_BTN_Y0 40 /* 顶缘(标题行内,apps 区之上) */
|
||||
static void menu_draw_bgm_btn(const meow_ui_theme_t *th)
|
||||
{
|
||||
int x = BGMR_BTN_X0, y = BGMR_BTN_Y0, s = BGMR_BTN_S;
|
||||
/* 柔和圆角底(surface_container 卡片语义),与主界面卡片/容器视觉统一 */
|
||||
meow_gfx_fill_round(x, y, s, s, 12, th->surface_container);
|
||||
/* 层叠卡片图标 = 后台/应用栈:后卡 muted、前卡 primary(前卡后画盖住后卡中部) */
|
||||
int cx = x + s / 2;
|
||||
meow_gfx_fill_round(cx - 13, y + 13, 18, 12, 4, th->on_surface_variant); /* 后卡 */
|
||||
meow_gfx_fill_round(cx - 5, y + 6, 18, 12, 4, th->primary); /* 前卡 */
|
||||
/* 小字标签(on_surface,落在 surface_container 底上对比足够) */
|
||||
const char *lab = meow_str_zh("bgmgr.topbtn", "后台");
|
||||
int tw = meow_ui_text_width(lab, 14);
|
||||
ui_draw(cx - tw / 2, y + 27, lab, th->on_surface, 14);
|
||||
}
|
||||
static int menu_hit_bgm_btn(int tx, int ty)
|
||||
{
|
||||
return (tx >= BGMR_BTN_X0 && tx < BGMR_BTN_X0 + BGMR_BTN_S &&
|
||||
ty >= BGMR_BTN_Y0 && ty < BGMR_BTN_Y0 + BGMR_BTN_S);
|
||||
}
|
||||
|
||||
static void draw_header_content(void)
|
||||
{
|
||||
draw_statusbar();
|
||||
const meow_ui_theme_t *th = s_ui_theme();
|
||||
menu_draw_bgm_btn(th); /* 批③:顶栏居右「后台管理」按钮 */
|
||||
int tsize = menu_title_size(); /* 标题字号接 font.size.title */
|
||||
/* M2.6 标题真字体(字号接 font.size.title;回退点阵3x);MD3 on_surface 主文字色 */
|
||||
if (mfont_ready) {
|
||||
@@ -166,6 +208,13 @@ void s_draw_chevron_left(int x, int y, uint32_t color)
|
||||
#define MENU_LIST_ROW_H 60 /* 列表行高(大图标行) */
|
||||
#define MENU_LIST_ICON 40 /* 列表图标 ink 高(方) */
|
||||
#define MENU_LIST_ICON_X 24 /* 列表图标左缘 */
|
||||
/* 圆角卡列表项外边距(item margin/gap):相邻卡片间可见空隙 px。SDK listview 行距(=MENU_LIST_ROW_H)
|
||||
* 不变——卡片在其行高内上下对称内缩 MENU_LIST_ROW_GAP/2,故内容(按行高垂直居中)仍落卡中心;
|
||||
* 相邻卡顶间距恒=MENU_LIST_ROW_H=卡高+MENU_LIST_ROW_GAP。要求偶数,保证上下对称。 */
|
||||
#define MENU_LIST_ROW_GAP 8
|
||||
/* 第 idx 行(行顶 row_top,行高 h)的圆角卡:顶 y 与高。卡片上下对称内缩,行中心=卡中心。 */
|
||||
static inline int menu_row_card_y(int row_top) { return row_top + MENU_LIST_ROW_GAP / 2; }
|
||||
static inline int menu_row_card_h(int h) { return h - MENU_LIST_ROW_GAP; }
|
||||
|
||||
/* 应用图标缓存(进主界面加载一次,不每帧解码;index 对齐 apps[]) */
|
||||
static meow_img *s_app_icons[MAX_APPS];
|
||||
@@ -598,10 +647,14 @@ static void menu_draw_list_cell(const struct app *a, meow_img *icon, int x, int
|
||||
int w, int h, int sel, int pressed,
|
||||
const meow_ui_theme_t *th)
|
||||
{
|
||||
meow_gfx_fill_round(x, y, w, h, 12, th->surface_container);
|
||||
/* 圆角卡在行高内上下对称内缩(card center = 行中心):给相邻圆角卡之间留出可见外距,
|
||||
* 消除原来「卡高==行距」导致的紧贴。内容仍按整行高垂直居中(行中心=卡中心)。 */
|
||||
int cy = menu_row_card_y(y);
|
||||
int ch = menu_row_card_h(h);
|
||||
meow_gfx_fill_round(x, cy, w, ch, 12, th->surface_container);
|
||||
if (sel || pressed) {
|
||||
int alpha = pressed ? 72 : 40;
|
||||
meow_gfx_fill_round_alpha(x, y, w, h, 12, th->primary, alpha);
|
||||
meow_gfx_fill_round_alpha(x, cy, w, ch, 12, th->primary, alpha);
|
||||
}
|
||||
int sz = MENU_LIST_ICON;
|
||||
int icy = y + (h - sz) / 2;
|
||||
@@ -1083,7 +1136,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
int cursor = 0;
|
||||
unsigned long long t0 = now_ms();
|
||||
int toast_ms = 0;
|
||||
char toast[64] = "";
|
||||
char toast[256] = ""; /* M6.40:失败 toast 带具体原因(CANNOT START: <name>(<reason>))需更大缓冲 */
|
||||
int want_exit = 0;
|
||||
/* M2.17 超时自动息屏:任何输入重置 last_input;空闲 > screen_off_timeout → meow_panel_off()。
|
||||
* meow_auto_slept 记录「自动息屏」态(手动电源息屏不置位),任意输入唤醒 + meow_repaint_surface。 */
|
||||
@@ -1166,17 +1219,19 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
/* touch: event reads (event1 is not grabbed) */
|
||||
touch_fd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
|
||||
if (touch_fd < 0) logmsg("touch open failed: %s", strerror(errno));
|
||||
/* M2.6 幽灵触摸防护:菜单激活(启动)即 drain + 冷却 */
|
||||
menu_arm_touch(TAP_ARM_DELAY_MS);
|
||||
/* M39:菜单激活(启动)即 drain + 极短守卫(M2.6 原为 300ms;界面此时已整帧画完,
|
||||
* 窗口只会吞用户看到首页后的第一下,故收窄到 MENU_TOUCH_GUARD_MS) */
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS);
|
||||
|
||||
/* key polling */
|
||||
struct keypoll kp_power, kp_keys;
|
||||
kp_open(&kp_power, DEV_POWER);
|
||||
kp_open(&kp_keys, DEV_KEYS);
|
||||
/* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动 */
|
||||
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
|
||||
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
|
||||
/* M2.16 按键防抖状态(每键独立);kd_tick 40ms 稳定后才认,抗 ADC 抖动。
|
||||
* 音量键防抖/基线已上收 key_router(进程唯一);本页只保留播放/电源键自家防抖。 */
|
||||
struct kd_state kd_play, kd_pwr;
|
||||
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
key_router_rebase(); /* 进入主菜单:音量基线对齐当前 raw(防残留边沿) */
|
||||
|
||||
logmsg("menu open: napps=%d state=%d cursor=%d",
|
||||
napps, state, cursor);
|
||||
@@ -1185,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
|
||||
* edges; kp.prev cannot be used for this because kp_poll() syncs it
|
||||
* to cur before we compare */
|
||||
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
|
||||
int m_prev_play = 0, m_prev_pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
|
||||
}
|
||||
@@ -1295,7 +1348,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
sim_exit_tap_done = 1;
|
||||
if (meow_mode) {
|
||||
int rb = confirm_reboot_player();
|
||||
menu_arm_touch(TAP_ARM_DELAY_MS);
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS);
|
||||
dirty = 1;
|
||||
logmsg("meow mode: BACK TO PLAYER -> confirm reboot (rb=%d)", rb);
|
||||
} else {
|
||||
@@ -1392,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
|
||||
* or exit the menu on it. Exit is only via the BACK TO PLAYER touch
|
||||
* row (below) or sim-exit. */
|
||||
/* 音量键走 key_router(进程唯一防抖/基线,产边沿/held/ramp);播放/电源键仍本页自理。 */
|
||||
key_evt_t kev = key_router_pump();
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
int vold = 0, volu = 0, play = 0, pwr = 0;
|
||||
int play = 0, pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
|
||||
}
|
||||
if (sim_power_hold_ms > 0 && el >= sim_power_hold_ms) pwr = 1; /* sim:按住电源 */
|
||||
/* 原始(未防抖)键值:长按连续(vol_ramp held)用,避免防抖延迟让单击仍连爬多档。 */
|
||||
int rvold = vold, rvolu = volu, rplay = play, rpwr = pwr; /* 原始值:长按连续用 rvolu/rvold */
|
||||
(void)rplay; (void)rpwr; /* play/pwr 只用稳定边沿,不需原始 held */
|
||||
/* M2.16 防抖:每键值须稳定 KEY_DEBOUNCE_MS(40ms) 才认可,抗 ADC 抖动("点一下跳5-6下")。
|
||||
* 稳定态 vold/volu/play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */
|
||||
(void)kd_tick(&kd_vold, vold);
|
||||
(void)kd_tick(&kd_volu, volu);
|
||||
* 稳定态 play/pwr 与 m_prev_*(上一稳定值)比较得稳定边沿;长按(连续)用 .down。 */
|
||||
(void)kd_tick(&kd_play, play);
|
||||
(void)kd_tick(&kd_pwr, pwr);
|
||||
vold = kd_vold.stable; volu = kd_volu.stable;
|
||||
play = kd_play.stable; pwr = kd_pwr.stable;
|
||||
/* M2.6 电源语义(MeowOS 自持面板,无播放器拔河):
|
||||
* 单击(短按松开 <2s)= 息屏/亮屏;2s = 电源菜单;8s = 强制关机。 */
|
||||
@@ -1448,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_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击熄屏 */
|
||||
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_* 防抖状态冻结。
|
||||
* 用户常「松开电源 → 用触摸点取消」,返回后 kd_pwr.stable 仍是旧的「按住=1」,
|
||||
* 而 m_prev_pwr 已按 raw 重播为 0 → 二者错配误判「新按下」刷新 pwr_hold_at,
|
||||
@@ -1456,15 +1503,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
* 难唤醒:launcher 以为屏开着,电源唤醒逻辑不触发)。电源键确认取消时 raw
|
||||
* 仍=按住=1,无此错配,所以只有触摸路径黑屏。此处重播 kd_* 并以当前 raw
|
||||
* 对齐 m_prev_*,杜绝该假边沿(真实新按键仍正常防抖/单击)。 */
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
continue;
|
||||
}
|
||||
@@ -1489,15 +1534,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
if (lck_wake_should_lock()) {
|
||||
int lr = run_lock_screen();
|
||||
/* 重播按键防抖基线(阻塞期间按键可能已变化,防误判新边沿) */
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
if (lr == 1)
|
||||
meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */
|
||||
@@ -1526,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),
|
||||
* 状态栏即时显示、持久化。有播放器的覆盖态才保留音量键=菜单光标导航。 */
|
||||
if (meow_mode) {
|
||||
if (vold && !m_prev_vold) {
|
||||
if (kev.vol_dn_edge) {
|
||||
any_input = 1;
|
||||
settings_volume_adj(-settings_volume_step); /* 起线性渐变(1% 爬) */
|
||||
logmsg("volume DOWN <- ramp step=%d", settings_volume_step);
|
||||
} else if (volu && !m_prev_volu) {
|
||||
} else if (kev.vol_up_edge) {
|
||||
any_input = 1;
|
||||
settings_volume_adj(+settings_volume_step);
|
||||
logmsg("volume UP <- ramp step=%d", settings_volume_step);
|
||||
@@ -1538,22 +1581,23 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
} else {
|
||||
/* M2.36 B2b:光标范围扩展为 [0..napps](napps=「系统设置」行,位于 Exit 行上方)。
|
||||
* 音量下移越过最后应用 → 设置行;设置行再下移 → 回 0。应用行仍 0..napps-1。 */
|
||||
if (vold && !m_prev_vold && napps > 0 && state == 0) {
|
||||
if (kev.vol_dn_edge && napps > 0 && state == 0) {
|
||||
any_input = 1;
|
||||
cursor++;
|
||||
if (cursor > napps) cursor = 0; /* napps=设置行 */
|
||||
logmsg("menu cursor -> %d", cursor);
|
||||
}
|
||||
if (volu && !m_prev_volu && napps > 0 && state == 0) {
|
||||
if (kev.vol_up_edge && napps > 0 && state == 0) {
|
||||
any_input = 1;
|
||||
cursor--;
|
||||
if (cursor < 0) cursor = napps; /* 回绕到设置行 */
|
||||
logmsg("menu cursor -> %d", cursor);
|
||||
}
|
||||
}
|
||||
/* M2.14 线性渐变:每 tick 推进 1% 并刷新状态栏(显示爬升中的值或最终值) */
|
||||
/* M2.14 线性渐变:每 tick 推进 1% 并刷新状态栏(显示爬升中的值或最终值)。
|
||||
* 长按 raw held 取自 key_router(kev.vol_held_*,同现状 rvolu/rvold),松开立即停止。 */
|
||||
if (meow_mode && vol_ramp_dir != 0) {
|
||||
int held = (vol_ramp_dir > 0) ? rvolu : rvold; /* 长按=该方向键仍按住(用原始值:松开立即停止) */
|
||||
int held = (vol_ramp_dir > 0) ? kev.vol_held_up : kev.vol_held_dn;
|
||||
int going = vol_ramp_tick(held);
|
||||
g_sys.vol = settings_volume_get();
|
||||
paint_sb_right(); /* M2.16:只重画右侧信息格(VOL 变化),不动列表/时钟 */
|
||||
@@ -1577,7 +1621,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
logmsg("play key: media-reserved (no media source)");
|
||||
}
|
||||
}
|
||||
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play;
|
||||
m_prev_play = play;
|
||||
|
||||
/* touch (event reads) */
|
||||
if (touch_fd >= 0 && meow_screen_on) { /* 息屏时不处理触摸(goodix 已被 FBIOBLANK(4) 挂起) */
|
||||
@@ -1635,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)",
|
||||
t_last_y - loy);
|
||||
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;
|
||||
memset(&click, 0, sizeof click);
|
||||
click.app_idx = -1;
|
||||
click.status = NOTIF_CLICK_STATUS_NONE;
|
||||
run_notif_center(apps, napps, &click, 1, t_down_x, loy);
|
||||
/* 重播按键防抖基线(同原 top-swipe 路径),防面板内电源/音量边沿被 run_menu 误判 */
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
last_input_ms = now_ms();
|
||||
menu_arm_touch(800);
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS);
|
||||
dirty = 1;
|
||||
/* M5:通知点击按 route_status 产出 click(0 跳转 / 1 NO ROUTE / 2 SOURCE NOT FOUND) */
|
||||
if (click.status == NOTIF_CLICK_STATUS_OK &&
|
||||
@@ -1735,25 +1777,23 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
* 本函数只在顶部热区(down_y<SB_BAR_H)+向下主导时返回 1,与左缘互斥。 */
|
||||
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)");
|
||||
menu_arm_touch(TAP_ARM_DELAY_MS);
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS);
|
||||
notif_click_t click;
|
||||
memset(&click, 0, sizeof click);
|
||||
click.app_idx = -1;
|
||||
click.status = NOTIF_CLICK_STATUS_NONE;
|
||||
run_notif_center(apps, napps, &click, 0, 0, 0); /* ⑨ live_open=0 → 触发式自动展开(防御) */
|
||||
/* 重播按键防抖基线,防面板内电源/音量边沿被 run_menu 误判(M2.25 同款)。 */
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
last_input_ms = now_ms();
|
||||
menu_arm_touch(800);
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS);
|
||||
dirty = 1;
|
||||
/* M5:通知点击按 route_status 产出 click(0 跳转 / 1 NO ROUTE / 2 SOURCE NOT FOUND) */
|
||||
if (click.status == NOTIF_CLICK_STATUS_OK &&
|
||||
@@ -1791,27 +1831,43 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
if (dx <= -MENU_SWIPE_THRESH && adx > ady &&
|
||||
tx >= MEOW_EDGE_W && ty >= SB_BAR_H) {
|
||||
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_menu 误判 */
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
last_input_ms = now_ms();
|
||||
menu_arm_touch(800);
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS);
|
||||
dirty = 1;
|
||||
continue;
|
||||
}
|
||||
if (adx >= MENU_SWIPE_THRESH || ady >= MENU_SWIPE_THRESH) {
|
||||
logmsg("menu swipe (dx=%d,dy=%d) — no launch", dx, dy);
|
||||
} else if (tx >= 0 && ty >= 0) {
|
||||
/* 批③:顶栏居右「后台管理」按钮(最高优先级;阻挡非阻塞,返回后重画) */
|
||||
if (menu_hit_bgm_btn(tx, ty)) {
|
||||
logmsg("menu: touch topbar bg-manager button");
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫 */
|
||||
run_bg_page(apps, napps);
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
last_input_ms = now_ms();
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS);
|
||||
dirty = 1;
|
||||
continue;
|
||||
}
|
||||
if (ty >= EXIT_ROW - 4 && ty < EXIT_ROW + ROW_H - 8) {
|
||||
logmsg("menu: touch exit row");
|
||||
if (meow_mode) {
|
||||
@@ -1820,7 +1876,7 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
* 绝不恢复到播放器死帧、绝不对死进程 SIGCONT。 */
|
||||
logmsg("meow mode: BACK TO PLAYER -> confirm reboot");
|
||||
int rb = confirm_reboot_player();
|
||||
menu_arm_touch(TAP_ARM_DELAY_MS); /* 幽灵触摸防护 */
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫 */
|
||||
dirty = 1;
|
||||
if (rb) { logmsg("reboot-to-player: yes, rebooting"); }
|
||||
/* rb==1 时 reboot 已发起,通常不回;rb==0 留在菜单 */
|
||||
@@ -1835,20 +1891,18 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
* 返回」的抬起边沿在 run_menu 被误判为面板单击而熄屏。 */
|
||||
else if (ty >= EXIT_ROW - ROW_H - 4 && ty < EXIT_ROW - ROW_H + ROW_H - 8) {
|
||||
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();
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
last_input_ms = now_ms(); /* 回主界面即视为输入 */
|
||||
menu_arm_touch(800); /* 设置页返回后再忽略近期触摸 */
|
||||
menu_arm_touch(MENU_TOUCH_GUARD_MS); /* M39:drain + 短守卫(原设置页返回 800ms) */
|
||||
dirty = 1; /* 全帧重画应用列表 */
|
||||
}
|
||||
/* M2.18 排查乱跳:改为 else if —— 原为两个独立 if,退出行与
|
||||
@@ -1914,15 +1968,13 @@ static void run_menu(struct app apps[MAX_APPS], int napps, int state,
|
||||
/* M2.x 锁屏:自动息屏唤醒后若需锁屏 → 弹阻塞式锁屏页;解锁才重绘,息屏留 dirty */
|
||||
if (lck_wake_should_lock()) {
|
||||
int lr = run_lock_screen();
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
if (lr == 1)
|
||||
meow_repaint_surface(apps, napps, state, cursor); /* 已解锁重绘主屏 */
|
||||
@@ -2158,6 +2210,7 @@ int main(int argc, char **argv)
|
||||
load_config(); /* hotkey_seq / hotkey_seq_gap / bg_color (also run per
|
||||
* menu open so edits apply without a daemon restart) */
|
||||
settings_load(); /* M2.11:读系统设置(audio.volume 等),供音量/状态栏使用 */
|
||||
key_router_init(); /* M6.x:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1)单开 kp_*/
|
||||
|
||||
/* M2.6:启动扫描一次 TF 卡 MeowOS/fonts/<family>/<weight>/,填充「字体文件」下拉 preset。
|
||||
* 无卡/无字库目录 → 仅默认字体(等价现状)。进设置页时 run_settings_page 会再扫一次(verify=0)。 */
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* meow_bgmgr.c — 批③ 后台管理页 逻辑层(数据汇聚 + kill 门控)。实现见 meow_bgmgr.h。
|
||||
* 纯 C(无 launcher static/SDK 依赖),宿主 gcc 直编:gcc -Wall -Wextra -std=gnu99 \
|
||||
* test_bgmgr.c meow_bgmgr.c -o test_bgmgr。 */
|
||||
#include "meow_bgmgr.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
int bgmgr_collect(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int max)
|
||||
{
|
||||
if (!ops || !ops->enumerate || !ops->app_meta || !rows || max <= 0) return 0;
|
||||
int n = ops->enumerate(ops->ctx, rows, max);
|
||||
if (n < 0) n = 0;
|
||||
if (n > max) n = max;
|
||||
for (int i = 0; i < n; i++) {
|
||||
bgmgr_row_t *r = &rows[i];
|
||||
r->protected_app = 0;
|
||||
r->is_fg = 0;
|
||||
r->name[0] = 0;
|
||||
r->icon[0] = 0;
|
||||
r->pcpu = 0.0;
|
||||
r->rss_kb = 0;
|
||||
ops->app_meta(ops->ctx, r->app_id, r->pid,
|
||||
r->name, (int)sizeof r->name, r->icon, (int)sizeof r->icon,
|
||||
&r->protected_app, &r->is_fg);
|
||||
if (!r->name[0]) snprintf(r->name, sizeof r->name, "%s", r->app_id);
|
||||
if (ops->proc_sample && ops->proc_sample(ops->ctx, r->pid, &r->pcpu, &r->rss_kb) < 0) {
|
||||
r->pcpu = 0.0; r->rss_kb = 0;
|
||||
}
|
||||
r->killable = bgmgr_can_kill(r);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
int bgmgr_can_kill(const bgmgr_row_t *r)
|
||||
{
|
||||
if (!r) return 0;
|
||||
if (r->protected_app) return 0; /* 系统级受保护 App 不 kill(铁律;framework 是 SDK 标志,不在此列) */
|
||||
if (r->is_fg) return 0; /* 当前前台 App 不 kill */
|
||||
if (r->pid <= 0) return 0; /* 非后台/无进程 */
|
||||
return 1;
|
||||
}
|
||||
|
||||
int bgmgr_kill_all(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int n,
|
||||
void (*kill_fn)(const char *app_id))
|
||||
{
|
||||
(void)ops;
|
||||
if (!kill_fn) return 0;
|
||||
int k = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (bgmgr_can_kill(&rows[i])) { kill_fn(rows[i].app_id); k++; }
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
const char *bgmgr_status_str(const bgmgr_row_t *r)
|
||||
{
|
||||
if (!r) return "";
|
||||
if (r->is_fg) return "前台";
|
||||
if (r->keepalive) return "常驻";
|
||||
return "后台";
|
||||
}
|
||||
|
||||
const char *bgmgr_mem_str(long rss_kb)
|
||||
{
|
||||
static char buf[24];
|
||||
if (rss_kb >= 1024) snprintf(buf, sizeof buf, "%.1fMB", (double)rss_kb / 1024.0);
|
||||
else snprintf(buf, sizeof buf, "%ldKB", rss_kb);
|
||||
return buf;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/* meow_bgmgr.h — 批③ 后台管理页 逻辑层(数据汇聚 + kill 门控)。自包含、依赖注入、可独立单测。
|
||||
*
|
||||
* 定位:把「后台常驻集 → 展示行(图标/名字/CPU/内存/状态)→ kill/一键清」的**纯逻辑**从 UI 抽出,
|
||||
* 便于宿主单测(test_bgmgr.c)与 launcher 页面(bg_page.c)复用。同 meow_bgplay 的 ops 注入手法:
|
||||
* UI 提供真实 enumerate/app_meta/proc_sample;单测用假件。**单一写者=launcher 主循环**,无锁。
|
||||
*
|
||||
* 铁律遵循:kill/一键清 带 !protected_app 门控(防误杀系统/受保护 App,对齐 app_launcher.c:2088 教训)。
|
||||
* **收口:门控位不是 app.json 的 `framework`**——那是「用 meow_ui.a 的 SDK 框架 App」标志
|
||||
* (docs/APP_DEV_GUIDE.md §9,推荐且现有 App 全写 1),拿它当「禁止 kill」会让所有后台 App 变成
|
||||
* 不可杀(2026-11-04 真机:dev.player.ncm 声明 framework:1 → 后台页杀按钮全被置灰点不动)。
|
||||
* 不新建 daemon;不引 GDBus;不碰 /data/recovery_all。
|
||||
*/
|
||||
#ifndef MEOW_BGMGR_H
|
||||
#define MEOW_BGMGR_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h> /* pid_t */
|
||||
#include "meow_ipc.h" /* MEOW_APP_ID_MAX */
|
||||
|
||||
/* 定义于 meow_bgplay.h,此处仅注释:与 launcher 后台表同槽数(MEOW_BG_MAX=4)。 */
|
||||
#define MEOW_BGMR_ROW_MAX 4
|
||||
|
||||
/* 后台管理页一行。enumerate 填 app_id/pid/keepalive/on_card/bg_since_ms;
|
||||
* bgmgr_collect 再经 app_meta/proc_sample 补元数据/CPU/RSS,并预判 killable。 */
|
||||
typedef struct bgmgr_row {
|
||||
char app_id[MEOW_APP_ID_MAX];
|
||||
char name[49]; /* display name(回退 app_id) */
|
||||
char icon[129]; /* 图标 path(空=无,页面用首字/占位) */
|
||||
char protected_app; /* '1'=系统级受保护 App(禁止 kill)。**非** app.json framework;
|
||||
* 目前 launcher 自身组件(状态栏/设置页…)不在 App 表/后台表里,
|
||||
* 故实际恒为 0,保留为将来受保护组件的显式标记位。 */
|
||||
pid_t pid; /* >0 后台进程 */
|
||||
int keepalive; /* 1=声明常驻(background_keepalive,无限期不清) */
|
||||
int is_fg; /* 1=当前前台 App(不 kill,标注「前台」) */
|
||||
int on_card; /* 1=二进制在 TF 卡 */
|
||||
uint64_t bg_since_ms; /* 转后台时刻(monotonic ms) */
|
||||
double pcpu; /* 0-100 */
|
||||
long rss_kb; /* 常驻内存 */
|
||||
int killable; /* 预判:bgmgr_can_kill()==1 */
|
||||
} bgmgr_row_t;
|
||||
|
||||
/* 依赖注入(**必须全非空**;enumerate 返回行数,app_meta/proc_sample 返回 0=成功)。 */
|
||||
typedef struct bgmgr_ops {
|
||||
/* 枚举后台常驻集:每调一次填 rows 的 app_id/pid/keepalive/on_card/bg_since_ms,
|
||||
* 返回行数(0..max;<0 视 0)。UI=读 launcher meow_bg 表;单测=假表。 */
|
||||
int (*enumerate)(void *ctx, bgmgr_row_t *rows, int max);
|
||||
/* 按 app_id 补 App 元数据:name/icon/protected_app/is_fg。返回 0=找到;<0=未注册(name 回退 app_id)。 */
|
||||
int (*app_meta)(void *ctx, const char *app_id, pid_t pid,
|
||||
char *name, int nl, char *icon, int il,
|
||||
char *protected_app, int *is_fg);
|
||||
/* 每进程 CPU/RSS 取样:返回 0=ok(写 pcpu 与 rss_kb);<0=无数据(pcpu=0/rss=0)。 */
|
||||
int (*proc_sample)(void *ctx, pid_t pid, double *pcpu, long *rss_kb);
|
||||
void (*log)(const char *fmt, ...); /* 可空=静默 */
|
||||
void *ctx;
|
||||
} bgmgr_ops_t;
|
||||
|
||||
/* 枚举后台常驻并补全元数据/CPU/RSS。返回行数(>=0);app_id 空/重复加固在 enumerate 侧。
|
||||
* 行序=enumerate 返回序(UI 端期望 bg_since_ms 升序=最久在前)。 */
|
||||
int bgmgr_collect(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int max);
|
||||
|
||||
/* !protected_app 门控 + 非前台 + pid>0:返回 1=可 kill;0=禁止(受保护/当前前台/无进程)。 */
|
||||
int bgmgr_can_kill(const bgmgr_row_t *r);
|
||||
|
||||
/* 一键清:遍历 rows,bgmgr_can_kill() 的条目逐一调 kill_fn(app_id)。返回发出 kill 数。
|
||||
* **不杀受保护 App/当前前台/非后台**;调用方负责对返回 0 弹「无后台 App 可清」之类提示。 */
|
||||
int bgmgr_kill_all(const bgmgr_ops_t *ops, bgmgr_row_t *rows, int n,
|
||||
void (*kill_fn)(const char *app_id));
|
||||
|
||||
/* 状态文案(用于行内「状态」列):「常驻」keepalive=1;「后台」宽限期类;「前台」is_fg=1;空=非后台。 */
|
||||
const char *bgmgr_status_str(const bgmgr_row_t *r);
|
||||
|
||||
/* 内存文案(rss_kb → "x.xMB"/"xxKB")。返回静态缓冲区;勿复用变长调用。 */
|
||||
const char *bgmgr_mem_str(long rss_kb);
|
||||
|
||||
#endif /* MEOW_BGMGR_H */
|
||||
@@ -270,6 +270,30 @@ void meow_bg_tick(void)
|
||||
}
|
||||
}
|
||||
|
||||
/* 批③后台管理页 / `am kill`:请求终止某个后台保活 App(走既有 SIGTERM→SIGKILL 真退序列)。
|
||||
* 只置 term_at_ms(tick 续做 SIGTERM→TERM_GRACE_MS→SIGKILL→reap→on_bg_dead→摘表),
|
||||
* 与宽限超时/卡拔回收同一条终止通道,**不在此处直接 reap**(保持单一写者 tick 收口)。
|
||||
* 调用者:bg_page.c 行内「杀」/「一键清除」+ util.c MSG_AMS_KILL(`am kill`,无前台会话时)
|
||||
* ——两个入口同一通道,故页面按钮与面板/CLI kill 行为对称。 */
|
||||
int meow_bg_terminate(const char *app_id)
|
||||
{
|
||||
if (!app_id || !app_id[0]) return MEOW_ERR_INVAL;
|
||||
int i;
|
||||
for (i = 0; i < MEOW_BG_MAX; i++) {
|
||||
meow_bg_entry_t *e = &g_bg[i];
|
||||
if (e->used && e->pid > 0 && !strcmp(e->app_id, app_id)) {
|
||||
if (e->term_at_ms == 0) {
|
||||
bg_log("[bgplay] 用户终止后台App: %s pid=%d(后台管理页/am kill 同通道)",
|
||||
app_id, (int)e->pid);
|
||||
kill(e->pid, SIGTERM);
|
||||
e->term_at_ms = bg_now_ms();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return MEOW_ERR_NOENT;
|
||||
}
|
||||
|
||||
/* ================= 无头后台服务 ================= */
|
||||
|
||||
static meow_svc_rt_t *svc_find_mut(const char *app_id, const char *svc)
|
||||
|
||||
@@ -122,6 +122,11 @@ int meow_bg_add(const char *app_id, pid_t pid, int out_fd, int on_card, int kee
|
||||
int meow_bg_take(const char *app_id, int *out_fd, pid_t *pid);
|
||||
/* 该 App 是否在后台保活。 */
|
||||
int meow_bg_is_backgrounded(const char *app_id);
|
||||
/* 用户终止后台保活 App(走既有 SIGTERM→SIGKILL 真退序列)。调用者:后台管理页行内「杀」/
|
||||
* 「一键清除」+ launcher IPC MSG_AMS_KILL(`am kill`)——两入口同通道,页面与面板/CLI 行为对称。
|
||||
* 置 e->term_at_ms=now 由 meow_bg_tick 续做 SIGTERM→TERM_GRACE_MS→SIGKILL→reap→on_bg_dead→摘表。
|
||||
* 返回 0=已受理(在后台表且 pid>0);MEOW_ERR_NOENT=不在后台/无进程;MEOW_ERR_INVAL=app_id 空。 */
|
||||
int meow_bg_terminate(const char *app_id);
|
||||
/* 当前后台保活数。 */
|
||||
int meow_bg_count(void);
|
||||
/* 取第 i 槽(0..MEOW_BG_MAX-1)只读指针;空槽/越界返回 NULL。供 drain out_fd。 */
|
||||
|
||||
@@ -82,10 +82,19 @@ enum {
|
||||
MSG_META = 0x0072, /* ↑ 媒体会话上报元数据/进度/状态(阶段 D 附录 B) */
|
||||
MSG_PLAYCTL = 0x0073, /* ↓ 播放控制下发/↑ 控制器转发到最近活跃媒体会话 */
|
||||
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 为「启动/终止」决策者 → launcher(执行器)执行。payload 见下。 */
|
||||
MSG_AMS_START = 0x00A0, /* → 请求 launcher 拉起某 App(pkg + 可选 activity) */
|
||||
MSG_AMS_KILL = 0x00A1, /* → 请求 launcher 终止某 App(SIGTERM 语义) */
|
||||
MSG_AMS_KILL = 0x00A1, /* → 请求 launcher 终止某 App(SIGTERM 语义):后台保活表命中→就地真退序列;
|
||||
* 否则有前台会话→置 pending 交会话消费;都没有→ACK -2(MEOW_ERR_NOENT) */
|
||||
/* ---- audiod 独占音频服务(devdocs/plan/AUDIOD_DESIGN.md §2):0x00C0-0x00CA ----
|
||||
* 语义:App 只经 SDK 客户端 API(meow_ui_audio.c)喂 PCM,audiod 独占 hw:0,0。
|
||||
* 独占+抢占(后开赢);软混音已否决(MIPS32 软 SRC 伤音质,留混音器接口位)。 */
|
||||
@@ -455,7 +464,9 @@ typedef struct m_ams_start {
|
||||
char picker_caller[MEOW_APP_ID_MAX]; /* 32:发起方 app_id(""=系统/CLI) */
|
||||
} m_ams_start_t; /* 160 B */
|
||||
|
||||
/* MSG_AMS_KILL →:请求 launcher 终止某 App(SIGTERM 语义,执行器=launcher)。 */
|
||||
/* MSG_AMS_KILL →:请求 launcher 终止某 App(SIGTERM 语义,执行器=launcher)。
|
||||
* 后台保活 App:launcher 就地走 meow_bg_terminate 真退序列(与后台管理页「杀」同一通道);
|
||||
* 前台 App:置 pending 交前台会话消费;两者皆非 → ACK code=(uint32_t)MEOW_ERR_NOENT(-2)。 */
|
||||
typedef struct m_ams_kill {
|
||||
char app_id[MEOW_APP_ID_MAX]; /* 32 */
|
||||
} m_ams_kill_t; /* 32 B */
|
||||
|
||||
@@ -28,6 +28,7 @@ extern "C" {
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h> /* Bug 2:直连 reboot(2) syscall 用 */
|
||||
#include <pthread.h> /* M0:IPC 线程 */
|
||||
#include "key_router.h" /* M6.x:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1) */
|
||||
#include <sys/eventfd.h> /* M0:IPC→主循环唤醒(EFD_NONBLOCK) */
|
||||
#include <sys/socket.h> /* M0:AF_UNIX IPC */
|
||||
#include <sys/un.h> /* M0:sockaddr_un */
|
||||
@@ -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 NCP_DRAG_ENTER_SLOP 16
|
||||
#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_R 50 /* 圆钮半径(直径 100) */
|
||||
#define PM_GAPX 130 /* 两列圆心 x 间距(175->305,居中 240) */
|
||||
@@ -493,12 +499,26 @@ typedef struct sub_page {
|
||||
int (*detail)(void *ctx, int idx, int act);/* 点 SUB_ROW 行(如连接/设备详情);返回 1=重绘 */
|
||||
int live; /* 1=驻留时周期性(约2s)重扫行(蓝牙/附近扫描刷新用) */
|
||||
void *ctx; /* 页面私有上下文 */
|
||||
|
||||
/* ---- 批③ 后台管理页按钮化(可选;NULL/0=按旧路径,未填页/WiFi/蓝牙/锁屏等不受影响)----
|
||||
* 提供下方任一回调即启用对应渲染/命中定制;全 NULL 时行为与旧版完全一致。 */
|
||||
const char *top_action; /* 顶栏标题栏右侧动作按钮文案(NULL=无按钮) */
|
||||
int (*top_action_cb)(void *ctx); /* 点顶栏动作按钮;返回 1=重绘 */
|
||||
/* 行完全自绘(覆盖默认 renderer;页内由 r->kind 区分行型)。state=MEOW_LISTVIEW_ST_*(选中/按压/禁用)。 */
|
||||
void (*row_draw)(void *ctx, int idx, sub_row_t *r,
|
||||
int x, int y, int w, int h, int state);
|
||||
/* 点 SUB_ROW 行(自定义命中:页内自行区分「行内按钮区 vs 行体」;x,y=tap,rx..rh=行视口矩形);
|
||||
* 返回 1=重绘。仅 kind==SUB_ROW 且本回调非空时走此路径,否则走 detail。 */
|
||||
int (*row_tap)(void *ctx, int idx, sub_row_t *r, int x, int y,
|
||||
int rx, int ry, int rw, int rh);
|
||||
} sub_page_t;
|
||||
|
||||
/* 运行子页(阻塞到返回;返回 0)。由 wifi_page.c / bt_page.c 各自实现。 */
|
||||
int meow_subpage_run(sub_page_t *p);
|
||||
int run_wifi_page(void);
|
||||
int run_bt_page(void);
|
||||
/* 批③:后台管理页(bg_page.c;桌面顶栏居右按钮进入;阻塞到返回)。 */
|
||||
int run_bg_page(struct app *apps, int napps);
|
||||
|
||||
|
||||
/* chip 索引(⑥:第 5 个 = 电源菜单,点击唤起既有系统电源菜单 show_power_menu) */
|
||||
@@ -661,6 +681,10 @@ int kp_open(struct keypoll *kp, const char *dev);
|
||||
int kp_poll(struct keypoll *kp, int (*cb)(int code, int down, void *ctx),
|
||||
void *ctx);
|
||||
int launch_app(struct app *a);
|
||||
/* M6.40:最近一次启动失败的具体原因(fork OOM / exec 失败 / 缺依赖 / 路径不存在等;
|
||||
* launch 成功或未失败则 "")。launch_app/launch_activity 返回 -1 前由 app_launcher.c 填好,
|
||||
* main_menu.c 的 launch_fail_toast 据以显示「CANNOT START: %s(%s)」。 */
|
||||
const char *launch_fail_reason(void);
|
||||
/* ---- app.json 二进制依赖(depends)解析与启动前检查(meow_dep.c)---- */
|
||||
int meow_parse_depends(const char *val, struct meow_dep *deps, int max);
|
||||
int meow_check_app_depends(const struct app *a, char *missing, int mlen);
|
||||
@@ -742,6 +766,15 @@ int meow_ipc_conn_alive(int fd);
|
||||
/* M4:ipc_send 公开包装(meow_bgplay send_frame 回调用;同 meow_media 的注入口径)。 */
|
||||
size_t meow_ipc_send_pub(int fd, uint16_t type, uint32_t req_id,
|
||||
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 前台顶部下滑请求展开通知中心叠加会话)。
|
||||
* 调用方=launch_app waitpid 循环;返回 1 并填 *out(待处理),0=无。 */
|
||||
int meow_notif_open_take(m_notif_open_t *out);
|
||||
@@ -820,6 +853,9 @@ int nm_wifi_saved(meow_net_t *out, int max);
|
||||
int nm_wifi_scan(meow_net_t *out, int max);
|
||||
void nm_wifi_scan_trigger(void);
|
||||
int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen); /* MEOW_OK=成功 */
|
||||
/* WiFi 设置页 bug2(2026-09-15):hidden=1 走 netmgrd connecth(scan_ssid=1 + 更长 COMPLETED 等待),
|
||||
* 回退直连同步对齐;err 回填 netmgrd/直连的诚实结果文本(成功含 ip / 失败含原因)。 */
|
||||
int nm_wifi_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen);
|
||||
int nm_wifi_disconnect(char *err, int elen);
|
||||
int nm_wifi_toggle(int on);
|
||||
|
||||
|
||||
Binary file not shown.
@@ -15,6 +15,14 @@
|
||||
#define NETMGR_SOCK_DEFAULT "/usr/data/meow_net.sock"
|
||||
/* NETMGR_UNREACH 定义在 meow_launcher_internal.h(测试/调用方共用) */
|
||||
|
||||
/* IPC 读超时(ms)。netmgrd 单线程 poll,connect/connecth 会阻塞 ~10s/~15s 有界等待
|
||||
* wpa_state=COMPLETED(bug1),scan 内含 1.5s 触发等待(P3)——客户端 per-command 超时需
|
||||
* 覆盖这些同步等待,其余读级子命令维持 1.5s 不被卡死。 */
|
||||
#define NM_TMO_DFL 1500
|
||||
#define NM_TMO_SCAN 5000
|
||||
#define NM_TMO_CONNECT 12000
|
||||
#define NM_TMO_CONNECTH 17000
|
||||
|
||||
/* 可达性缓存:netmgrd 不可达时每次 connect 都秒回 ECONNREFUSED,无成本;缓存约 1s 防热循环。 */
|
||||
static int g_nm_ok = -1; /* -1 未知 / 0 down / 1 up */
|
||||
static time_t g_nm_ok_ts = 0;
|
||||
@@ -50,7 +58,7 @@ static int nm_r_all(int fd, uint8_t *b, size_t n)
|
||||
return 0;
|
||||
}
|
||||
static int nm_xchg(int fd, uint16_t type, uint32_t req, const void *pl, uint32_t len,
|
||||
uint8_t *rbuf, uint32_t rcap, uint16_t *rtype)
|
||||
uint8_t *rbuf, uint32_t rcap, uint16_t *rtype, int tmo_ms)
|
||||
{
|
||||
uint8_t hdr[MEOW_IPC_HDR_LEN];
|
||||
uint32_t magic = MEOW_IPC_MAGIC, rlen = len;
|
||||
@@ -60,7 +68,7 @@ static int nm_xchg(int fd, uint16_t type, uint32_t req, const void *pl, uint32_t
|
||||
if (nm_w_all(fd, hdr, sizeof hdr) < 0) return -1;
|
||||
if (len && pl && nm_w_all(fd, pl, len) < 0) return -1;
|
||||
struct pollfd pf = { fd, POLLIN, 0 };
|
||||
int pr = poll(&pf, 1, 1500);
|
||||
int pr = poll(&pf, 1, tmo_ms);
|
||||
if (pr <= 0) return -1;
|
||||
uint8_t rhdr[MEOW_IPC_HDR_LEN];
|
||||
if (nm_r_all(fd, rhdr, sizeof rhdr) < 0) return -1;
|
||||
@@ -81,15 +89,16 @@ static int nm_hello(int fd)
|
||||
snprintf(h.app_id, sizeof h.app_id, "%s", "os.meow.launcher");
|
||||
h.version = 1;
|
||||
uint8_t rbuf[256]; uint16_t rtype = 0;
|
||||
if (nm_xchg(fd, MSG_HELLO, 0x100, &h, sizeof h, rbuf, sizeof rbuf, &rtype) < 0 || rtype != MSG_ACK)
|
||||
if (nm_xchg(fd, MSG_HELLO, 0x100, &h, sizeof h, rbuf, sizeof rbuf, &rtype, NM_TMO_DFL) < 0 || rtype != MSG_ACK)
|
||||
return -1;
|
||||
m_ack_t ak; memcpy(&ak, rbuf, sizeof ak);
|
||||
return (ak.code == MEOW_OK) ? 0 : -1;
|
||||
}
|
||||
|
||||
/* 发一条 WIFI_CTL/BT_CTL 子命令,收 RESULT。返回 netmgrd 业务 code;NETMGR_UNREACH=不可达。 */
|
||||
/* 发一条 WIFI_CTL/BT_CTL 子命令,收 RESULT。返回 netmgrd 业务 code;NETMGR_UNREACH=不可达。
|
||||
* tmo_ms:读超时(connect/connecth/scan 需覆盖 netmgrd 同步等待,见 NM_TMO_*)。 */
|
||||
static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const char *arg,
|
||||
char *text, int tlen)
|
||||
char *text, int tlen, int tmo_ms)
|
||||
{
|
||||
m_net_cmd_t q;
|
||||
memset(&q, 0, sizeof q);
|
||||
@@ -100,7 +109,7 @@ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const c
|
||||
if (nm_hello(fd) < 0) { close(fd); nm_cache(0); return NETMGR_UNREACH; }
|
||||
uint8_t rbuf[MEOW_IPC_MAX]; uint16_t rtype = 0;
|
||||
memset(rbuf, 0, sizeof rbuf);
|
||||
if (nm_xchg(fd, ctl_type, 0x101, &q, sizeof q, rbuf, sizeof rbuf, &rtype) < 0 || rtype != res_type) {
|
||||
if (nm_xchg(fd, ctl_type, 0x101, &q, sizeof q, rbuf, sizeof rbuf, &rtype, tmo_ms) < 0 || rtype != res_type) {
|
||||
close(fd); nm_cache(0); return NETMGR_UNREACH;
|
||||
}
|
||||
m_net_cmd_result_t res; memcpy(&res, rbuf, sizeof res);
|
||||
@@ -112,11 +121,14 @@ static int nm_cmd(uint16_t ctl_type, uint16_t res_type, const char *cmd, const c
|
||||
return (int)res.code;
|
||||
}
|
||||
|
||||
int netmgr_wifi_tmo(const char *cmd, const char *arg, char *text, int tlen, int tmo_ms)
|
||||
{ return nm_cmd(MSG_NET_WIFI_CTL, MSG_NET_WIFI_RESULT, cmd, arg, text, tlen, tmo_ms); }
|
||||
|
||||
int netmgr_wifi(const char *cmd, const char *arg, char *text, int tlen)
|
||||
{ return nm_cmd(MSG_NET_WIFI_CTL, MSG_NET_WIFI_RESULT, cmd, arg, text, tlen); }
|
||||
{ return netmgr_wifi_tmo(cmd, arg, text, tlen, NM_TMO_DFL); }
|
||||
|
||||
int netmgr_bt(const char *cmd, const char *arg, char *text, int tlen)
|
||||
{ return nm_cmd(MSG_NET_BT_CTL, MSG_NET_BT_RESULT, cmd, arg, text, tlen); }
|
||||
{ return nm_cmd(MSG_NET_BT_CTL, MSG_NET_BT_RESULT, cmd, arg, text, tlen, NM_TMO_DFL); }
|
||||
|
||||
int netmgr_ok(void)
|
||||
{
|
||||
@@ -361,7 +373,7 @@ int nm_wifi_saved(meow_net_t *out, int max)
|
||||
int nm_wifi_scan(meow_net_t *out, int max)
|
||||
{
|
||||
char text[MEOW_IPC_MAX - 32];
|
||||
int rc = netmgr_wifi("scan", NULL, text, sizeof text); /* netmgrd 内含触发+有界等待+读结果 */
|
||||
int rc = netmgr_wifi_tmo("scan", NULL, text, sizeof text, NM_TMO_SCAN); /* netmgrd 内含触发+有界等待+读结果 */
|
||||
if (rc == NETMGR_UNREACH) return net_wifi_scan(out, max);
|
||||
if (rc != MEOW_OK) return 0;
|
||||
return nm_parse_scan(text, out, max);
|
||||
@@ -374,49 +386,133 @@ void nm_wifi_scan_trigger(void)
|
||||
if (!netmgr_ok()) net_wifi_scan_trigger();
|
||||
}
|
||||
|
||||
/* —— 直连回退:wpa_cli add/set/enable/select 新网络(复刻 netmgrd wifi_connect 逻辑)—— */
|
||||
static int nm_direct_connect(const char *ssid, const char *psk, char *err, int elen)
|
||||
/* —— 直连回退:与 netmgrd wifi_connect/connecth 对齐(bug1/bug2/bug4)。
|
||||
* 匹配既有(\xNN 还原同表示)→ 新建或(命中且带新 psk)更新凭据 → hidden 补 scan_ssid=1 →
|
||||
* enable+select → 有界等待 wpa_state=COMPLETED → 成功 enable_network all + save_config(校验重试);
|
||||
* 失败时本次新建的网络回收,报文明示原因。仅在 netmgrd 不可达时走此路径。 */
|
||||
static const char NM_WPA_PREFIX[] = "PATH=/bin:/sbin:/usr/bin:/usr/sbin wpa_cli -i wlan0";
|
||||
|
||||
static int nm_direct_wpa(char *out, int cap, const char *args, int tmo)
|
||||
{
|
||||
const char *wpa = "PATH=/bin:/sbin:/usr/bin:/usr/sbin wpa_cli -i wlan0";
|
||||
char cmd[256];
|
||||
snprintf(cmd, sizeof cmd, "%s add_network 2>/dev/null", wpa);
|
||||
char *o = popen_read_tmo(1200, cmd);
|
||||
int id = o ? atoi(o) : -1;
|
||||
if (o) free(o);
|
||||
if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; }
|
||||
{
|
||||
char *r;
|
||||
snprintf(cmd, sizeof cmd, "%s set_network %d ssid '\"%s\"' 2>/dev/null", wpa, id, ssid);
|
||||
if ((r = popen_read_tmo(1200, cmd))) free(r);
|
||||
if (psk && psk[0]) {
|
||||
snprintf(cmd, sizeof cmd, "%s set_network %d psk '\"%s\"' 2>/dev/null", wpa, id, psk);
|
||||
if ((r = popen_read_tmo(1200, cmd))) free(r);
|
||||
} else {
|
||||
snprintf(cmd, sizeof cmd, "%s set_network %d key_mgmt NONE 2>/dev/null", wpa, id);
|
||||
if ((r = popen_read_tmo(1200, cmd))) free(r);
|
||||
char cmd[384];
|
||||
snprintf(cmd, sizeof cmd, "%s %s 2>/dev/null", NM_WPA_PREFIX, args);
|
||||
char *o = popen_read_tmo(tmo, cmd);
|
||||
if (!o) { out[0] = 0; return -1; }
|
||||
int l = (int)strlen(o);
|
||||
if (l >= cap) l = cap - 1;
|
||||
memcpy(out, o, (size_t)l); out[l] = 0;
|
||||
free(o);
|
||||
return l;
|
||||
}
|
||||
|
||||
static int nm_direct_save_config(void)
|
||||
{
|
||||
char out[128];
|
||||
if (nm_direct_wpa(out, sizeof out, "save_config", 1500) > 0 && strstr(out, "OK")) return 1;
|
||||
nm_direct_wpa(out, sizeof out, "set update_config 1", 1500);
|
||||
if (nm_direct_wpa(out, sizeof out, "save_config", 1500) > 0 && strstr(out, "OK")) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int nm_direct_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen)
|
||||
{
|
||||
char out[1024]; char args[256];
|
||||
int id = -1;
|
||||
if (nm_direct_wpa(out, sizeof out, "list_networks", 1500) > 0) {
|
||||
char *ln = strtok(out, "\n");
|
||||
while (ln) {
|
||||
char *tab0 = strchr(ln, '\t');
|
||||
if (tab0) {
|
||||
int this_id = atoi(ln);
|
||||
char *ss = tab0 + 1;
|
||||
char *tab1 = strchr(ss, '\t');
|
||||
if (tab1) *tab1 = 0;
|
||||
char clean[128], unesc[160], norm[128];
|
||||
size_t cl = strlen(ss);
|
||||
if (cl >= 2 && ss[0] == '"' && ss[cl - 1] == '"') {
|
||||
memcpy(clean, ss + 1, cl - 2); clean[cl - 2] = 0;
|
||||
} else {
|
||||
snprintf(clean, sizeof clean, "%s", ss);
|
||||
}
|
||||
nm_decode_ssid(clean, unesc, sizeof unesc);
|
||||
char *p = unesc; while (*p == ' ' || *p == '\t') p++;
|
||||
snprintf(norm, sizeof norm, "%s", p);
|
||||
size_t nl = strlen(norm);
|
||||
while (nl > 0 && (norm[nl - 1] == ' ' || norm[nl - 1] == '\t')) norm[--nl] = 0;
|
||||
if (strcmp(norm, ssid) == 0) { id = this_id; break; }
|
||||
}
|
||||
ln = strtok(NULL, "\n");
|
||||
}
|
||||
snprintf(cmd, sizeof cmd, "%s enable_network %d 2>/dev/null", wpa, id);
|
||||
if ((r = popen_read_tmo(1200, cmd))) free(r);
|
||||
snprintf(cmd, sizeof cmd, "%s select_network %d 2>/dev/null", wpa, id);
|
||||
if ((r = popen_read_tmo(1200, cmd))) free(r);
|
||||
}
|
||||
if (err) snprintf(err, elen, "connect: ssid=%s network_id=%d", ssid, id);
|
||||
logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s id=%d", ssid, id);
|
||||
int created = 0;
|
||||
if (id < 0) {
|
||||
if (nm_direct_wpa(out, sizeof out, "add_network", 1500) <= 0) {
|
||||
if (err) snprintf(err, elen, "add_network 无 id");
|
||||
return MEOW_ERR_INVAL;
|
||||
}
|
||||
id = atoi(out);
|
||||
if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; }
|
||||
created = 1;
|
||||
snprintf(args, sizeof args, "set_network %d ssid '\"%s\"'", id, ssid);
|
||||
nm_direct_wpa(out, sizeof out, args, 1500);
|
||||
if (psk && psk[0]) { snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk); nm_direct_wpa(out, sizeof out, args, 1500); }
|
||||
else { snprintf(args, sizeof args, "set_network %d key_mgmt NONE", id); nm_direct_wpa(out, sizeof out, args, 1500); }
|
||||
} else if (psk && psk[0]) {
|
||||
snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk);
|
||||
nm_direct_wpa(out, sizeof out, args, 1500);
|
||||
}
|
||||
if (hidden) { snprintf(args, sizeof args, "set_network %d scan_ssid 1", id); nm_direct_wpa(out, sizeof out, args, 1500); }
|
||||
snprintf(args, sizeof args, "enable_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500);
|
||||
snprintf(args, sizeof args, "select_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500);
|
||||
|
||||
int wait_ms = hidden ? 15000 : 10000;
|
||||
int waited = 0, ok = 0; char ip[40] = "";
|
||||
for (;;) {
|
||||
char st[512];
|
||||
if (nm_direct_wpa(st, sizeof st, "status", 1500) > 0) {
|
||||
char *ws = strstr(st, "wpa_state=");
|
||||
if (ws && !strncmp(ws + 10, "COMPLETED", 9)) {
|
||||
ok = 1;
|
||||
char *ib = strstr(st, "ip_address=");
|
||||
if (ib) { ib += 11; char *ie = ib; while (*ie && *ie != '\n' && *ie != '\r') ie++;
|
||||
int il = (int)(ie - ib); if (il >= (int)sizeof ip) il = (int)sizeof ip - 1;
|
||||
memcpy(ip, ib, (size_t)il); ip[il] = 0; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (waited >= wait_ms) break;
|
||||
usleep(200000);
|
||||
waited += 200;
|
||||
}
|
||||
if (!ok) {
|
||||
if (created) { snprintf(args, sizeof args, "remove_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500); nm_direct_save_config(); }
|
||||
if (err) snprintf(err, elen, "连接失败:密码错误或信号不佳(超时未关联)");
|
||||
logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s hidden=%d id=%d timeout", ssid, hidden, id);
|
||||
return MEOW_ERR_NODEV;
|
||||
}
|
||||
nm_direct_wpa(out, sizeof out, "enable_network all", 1500);
|
||||
int persisted = nm_direct_save_config();
|
||||
if (err) snprintf(err, elen, "已连接:%s ip=%s%s", ssid, ip[0] ? ip : "-", persisted ? "" : "(记录未持久化)");
|
||||
logmsg("netmgr_ipc: [direct-fallback] wifi connect ssid=%s hidden=%d id=%d ip=%s", ssid, hidden, id, ip[0] ? ip : "-");
|
||||
return MEOW_OK;
|
||||
}
|
||||
|
||||
int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen)
|
||||
int nm_wifi_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen)
|
||||
{
|
||||
if (!ssid || !ssid[0]) { if (err) snprintf(err, elen, "ssid 缺失"); return MEOW_ERR_INVAL; }
|
||||
char arg[132];
|
||||
snprintf(arg, sizeof arg, "%s,%s", ssid, psk ? psk : ""); /* connect 带空 psk → netmgrd 复用既有网络 */
|
||||
snprintf(arg, sizeof arg, "%s,%s", ssid, psk ? psk : ""); /* 带空 psk → netmgrd 复用既有网络 */
|
||||
char text[192];
|
||||
int rc = netmgr_wifi("connect", arg, text, sizeof text);
|
||||
if (rc == NETMGR_UNREACH) return nm_direct_connect(ssid, psk, err, elen);
|
||||
int rc = netmgr_wifi_tmo(hidden ? "connecth" : "connect", arg, text, sizeof text,
|
||||
hidden ? NM_TMO_CONNECTH : NM_TMO_CONNECT);
|
||||
if (rc == NETMGR_UNREACH) return nm_direct_connect_ex(ssid, psk, hidden, err, elen);
|
||||
if (err && elen > 0) snprintf(err, elen, "%s", text);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int nm_wifi_connect(const char *ssid, const char *psk, char *err, int elen)
|
||||
{ return nm_wifi_connect_ex(ssid, psk, 0, err, elen); }
|
||||
|
||||
int nm_wifi_disconnect(char *err, int elen)
|
||||
{
|
||||
char text[128];
|
||||
|
||||
@@ -900,6 +900,7 @@ static void ncp_media_cover_load(const meow_media_session_t *s)
|
||||
{
|
||||
if (!s) return;
|
||||
if (strcmp(g_ncp_media_cover_path, s->artwork) == 0) return;
|
||||
if (!s->artwork[0]) return; /* 用户 2026-09-04:空 artwork 保留上一张媒体卡封面(勿清,防暂停/开歌单后封面闪没) */
|
||||
if (g_ncp_media_cover) { meow_img_free(g_ncp_media_cover); g_ncp_media_cover = NULL; }
|
||||
ncp_strncpy(g_ncp_media_cover_path, s->artwork, sizeof g_ncp_media_cover_path);
|
||||
if (!s->artwork[0]) return;
|
||||
@@ -1167,16 +1168,15 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
|
||||
|
||||
struct keypoll kp_power, kp_keys;
|
||||
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
|
||||
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
|
||||
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
|
||||
/* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
|
||||
struct kd_state kd_play, kd_pwr;
|
||||
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
|
||||
int m_prev_play = 0, m_prev_pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
|
||||
}
|
||||
key_router_rebase(); /* 进入通知中心:音量基线对齐当前 raw(防残留边沿) */
|
||||
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
|
||||
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
|
||||
unsigned long long touch_cooldown_until = now_ms() + 300;
|
||||
@@ -1303,12 +1303,11 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
|
||||
}
|
||||
if (wak) {
|
||||
meow_panel_on(); meow_auto_slept = 0; wake_suppress = 1; last_act = now_ms();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
|
||||
/* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
td = 0; td_ok = 0; drag_slider = 0; tlx = tly = -1;
|
||||
@@ -1330,23 +1329,20 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
|
||||
}
|
||||
|
||||
/* 按键:系统语义(APP §3.4.1 红线——不映射返回/确认) */
|
||||
key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
int vold = 0, volu = 0, play = 0, pwr = 0;
|
||||
int play = 0, pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
|
||||
}
|
||||
(void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu);
|
||||
(void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr);
|
||||
vold = kd_vold.stable; volu = kd_volu.stable;
|
||||
play = kd_play.stable; pwr = kd_pwr.stable;
|
||||
if (wake_suppress) {
|
||||
if (!pwr && !vold && !volu && !play) wake_suppress = 0;
|
||||
if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0;
|
||||
} else {
|
||||
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
|
||||
if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
|
||||
if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
|
||||
if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
|
||||
if (play && !m_prev_play) {
|
||||
logmsg("notif-center: play key caught (media-reserved, no exit)");
|
||||
last_act = now;
|
||||
@@ -1365,12 +1361,11 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
|
||||
show_power_menu();
|
||||
last_act = now_ms();
|
||||
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0; pwr_suppress_click = 1;
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
|
||||
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
after_modal = 1; need_paint = 1;
|
||||
goto ncp_keys_seeded;
|
||||
@@ -1389,14 +1384,14 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
|
||||
}
|
||||
}
|
||||
if (vol_ramp_dir != 0) {
|
||||
int held = (vol_ramp_dir > 0) ? volu : vold;
|
||||
int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn;
|
||||
int going = vol_ramp_tick(held);
|
||||
g_sys.vol = settings_volume_get();
|
||||
paint_sb_right();
|
||||
need_paint = 1;
|
||||
if (!going) logmsg("notif-center vol ramp end -> %d", g_sys.vol);
|
||||
}
|
||||
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr;
|
||||
m_prev_play = play; m_prev_pwr = pwr;
|
||||
ncp_keys_seeded:
|
||||
;
|
||||
|
||||
@@ -1561,15 +1556,13 @@ void run_notif_center(struct app *apps, int napps, notif_click_t *click,
|
||||
after_modal = 1; /* 电源菜单已弹 → 整帧重绘还原 */
|
||||
/* 电源菜单返回:菜单期间用户可能按电源确认/取消,键边沿可能错配,
|
||||
* 同电源键长按路径(line 7480-7488)重 poll + 重采 m_prev_*,防误判「单击」→ 误切 panel。 */
|
||||
/* 菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL);
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
/* 菜单期间用户的触摸经 evdev 广播也会进本 panel 的 tfd → 立即排空,
|
||||
* 防返回后把「菜单里的点击」误判为 panel 内 tap/滑动。 */
|
||||
|
||||
@@ -17,20 +17,28 @@
|
||||
|
||||
#define NP_COVER 300 /* 封面边长(方,居中) */
|
||||
#define NP_COVER_X ((fb_xres - NP_COVER) / 2)
|
||||
#define NP_COVER_Y 60
|
||||
#define NP_TITLE_Y 412
|
||||
#define NP_ARTIST_Y 450
|
||||
#define NP_ALBUM_Y 480
|
||||
#define NP_COVER_Y 84 /* 用户 2026-09-04:控件组整体下移,消除底部大留白 */
|
||||
#define NP_TITLE_Y 436
|
||||
#define NP_ARTIST_Y 476
|
||||
#define NP_ALBUM_Y 508
|
||||
#define NP_PROG_X 40
|
||||
#define NP_PROG_W (fb_xres - 80)
|
||||
#define NP_PROG_Y 518
|
||||
#define NP_TIME_Y 492
|
||||
#define NP_BTN_Y 568 /* 控制按钮中心 y */
|
||||
#define NP_PROG_Y 548
|
||||
#define NP_TIME_Y 526
|
||||
#define NP_BTN_Y 612 /* 控制按钮中心 y */
|
||||
#define NP_PLAY_CX (fb_xres / 2)
|
||||
#define NP_PREV_CX (NP_PLAY_CX - 78)
|
||||
#define NP_NEXT_CX (NP_PLAY_CX + 78)
|
||||
#define NP_PLAY_R 30
|
||||
#define NP_PREV_R 22
|
||||
/* 批② home 指示条(底部上滑回桌面):位于屏底左缘、避开左缘返回区(NP_EDGE_W=24)。
|
||||
* 与 np_gesture.h np_home_gesture 的起滑区一致(down_x>=NP_EDGE_W 才认 home),保证
|
||||
* 「指示条可触发手势」自洽。y 距屏底 16px,高 6px(镜像 SDK meow_ui_swipe_hint 提示线高一档)。
|
||||
*/
|
||||
#define NP_HOME_BAR_X (NP_EDGE_W + 8) /* 左缘 32:紧贴左缘返回区右侧 */
|
||||
#define NP_HOME_BAR_W 48
|
||||
#define NP_HOME_BAR_H 6
|
||||
#define NP_HOME_BAR_Y (fb_yres - 16)
|
||||
|
||||
static meow_img *g_np_cover = NULL;
|
||||
static char g_np_cover_path[200] = "";
|
||||
@@ -59,6 +67,8 @@ static void np_cover_load(const meow_media_session_t *s)
|
||||
{
|
||||
if (!s) return;
|
||||
if (g_np_cover && strcmp(g_np_cover_path, s->artwork) == 0) return; /* 已载且未变 → 跳过 */
|
||||
if (!s->artwork[0]) return; /* 用户 2026-09-04:空 artwork(如打开歌单清 g_cover_path 致 META 报空)
|
||||
* 时保留上一张已解码封面,勿据此清封面 → 播放页/歌词页封面不闪没 */
|
||||
if (g_np_cover) { meow_img_free(g_np_cover); g_np_cover = NULL; }
|
||||
snprintf(g_np_cover_path, sizeof g_np_cover_path, "%s", s->artwork);
|
||||
if (!s->artwork[0]) return;
|
||||
@@ -188,6 +198,54 @@ static void np_center_text(int y, const char *s, int size, uint32_t color, int m
|
||||
ui_draw((W - ui_width(buf, size)) / 2, y, buf, color, size);
|
||||
}
|
||||
|
||||
/* 左对齐 + 超宽截断(UTF-8 安全,近似按字节比例切;歌词页顶部元信息用)。 */
|
||||
static void np_left_text(int x, int y, const char *s, int size, uint32_t color, int maxw)
|
||||
{
|
||||
if (!s) s = "";
|
||||
if (ui_width(s, size) <= maxw) {
|
||||
ui_draw(x, y, s, color, size);
|
||||
return;
|
||||
}
|
||||
char buf[160];
|
||||
int keep = (int)(strlen(s) * maxw / (ui_width(s, size) + 1));
|
||||
if (keep < 0) keep = 0;
|
||||
if (keep > (int)sizeof buf - 4) keep = (int)sizeof buf - 4;
|
||||
memcpy(buf, s, (size_t)keep); buf[keep] = 0;
|
||||
strcat(buf, "...");
|
||||
ui_draw(x, y, buf, color, size);
|
||||
}
|
||||
|
||||
/* 歌词页顶部元信息(两行居左,复刻 ncm 歌词页):第一行歌名(大字白)、第二行歌手(小字灰)。
|
||||
* 多歌手已由源侧拼串(ncm art_name 即「A / B」),此处原样显示 + 超宽截断。
|
||||
* 左边距与歌词正文一致(LYR_PAD=24);离状态栏留边距(SB_BAR_H + 20);右缘同距截断。 */
|
||||
static void np_draw_lyric_title(const meow_media_session_t *s)
|
||||
{
|
||||
if (!s) return;
|
||||
if (!s->title[0] && !s->artist[0]) return;
|
||||
int pad = 24;
|
||||
int y = SB_BAR_H + 20;
|
||||
int maxw = fb_xres - 2 * pad;
|
||||
if (s->title[0])
|
||||
np_left_text(pad, y, s->title, 28, 0xFFFFFF, maxw);
|
||||
if (s->artist[0])
|
||||
np_left_text(pad, y + meow_font_line_height(28) + 6, s->artist, 20, 0xBBBBBB, maxw);
|
||||
}
|
||||
|
||||
/* 歌词页强制 ncm 配色:活动行恒纯白 + 上下逐级灰阶(对齐 ncm lyr_gray_tbl
|
||||
* {FFFFFF,BBBBBB,929292,808080,6A6A6A,555555}——gray[0]=0 按 cur=0xFFFFFF 自动递减即为该表)。
|
||||
* 背景恒为 np_draw_lyric_page 铺的黑色半透明叠加层,故关掉 lyric_view 自带 scrim
|
||||
* (scrim_alpha=0)避免双重压暗;当前行/灰阶不随主题/封面取色(保黑底对比度,夜间可读)。 */
|
||||
static void np_set_lyric_ncm_colors(void)
|
||||
{
|
||||
if (!g_np_lyric) return;
|
||||
meow_lyric_view_colors c;
|
||||
memset(&c, 0, sizeof c);
|
||||
c.scrim = 0x000000;
|
||||
c.cur = 0xFFFFFF;
|
||||
c.scrim_alpha = 0; /* 不画 scrim:由 launcher 黑底铺(单一叠加层) */
|
||||
meow_lyric_view_set_colors(g_np_lyric, &c);
|
||||
}
|
||||
|
||||
/* ==================================================================
|
||||
* M7.0b Now Playing 控件化(launcher 无 meow_ui_run,手动驱动 SDK 控件):
|
||||
* - 进度条 → meow_progress(线性,SDK 主题 track/fill);
|
||||
@@ -326,43 +384,60 @@ static void np_lyric_refresh(const meow_media_session_t *s)
|
||||
g_np_lyric_last_valid = valid;
|
||||
}
|
||||
|
||||
/* 歌词页封面作整页背景:等比 cover 到 max(W, H-SB_BAR_H) 方块、居中铺满内容区(裁剪溢出
|
||||
* 部分),衬在 lyric_view 半透明 scrim 之下形成压暗封面;无封面不画(回退渐变/主题面)。
|
||||
* 批6:批5 只铺渐变、从不画封面 → 歌词页无封面;此处补上封面背景,取色只做附加。 */
|
||||
static void np_draw_lyric_coverbg(int W, int H)
|
||||
/* 批② home 指示条:底部上滑回桌面的可见反馈(镜向左缘返回提示条;唯位置=屏底左缘)。
|
||||
* 主题用 on_surface_variant(淡灰指示,iOS home 指示同款语义);draw 在整页/空态/歌词页
|
||||
* 均调用一次(np_draw_page 播放分支/空态分支 + np_draw_lyric_page)。 */
|
||||
static void np_draw_home_bar(void)
|
||||
{
|
||||
if (!g_np_cover) return;
|
||||
int size = W > (H - SB_BAR_H) ? W : (H - SB_BAR_H);
|
||||
int x = (W - size) / 2;
|
||||
int y = SB_BAR_H + (H - SB_BAR_H - size) / 2;
|
||||
if (y < SB_BAR_H) y = SB_BAR_H;
|
||||
meow_ui_img_cover(x, y, size, g_np_cover);
|
||||
const meow_ui_theme_t *th = s_ui_theme();
|
||||
meow_gfx_fill_round(NP_HOME_BAR_X, NP_HOME_BAR_Y, NP_HOME_BAR_W, NP_HOME_BAR_H,
|
||||
NP_HOME_BAR_H / 2, th->on_surface_variant);
|
||||
}
|
||||
|
||||
/* 歌词页整页绘制:铺封面取色渐变(或 theme surface)底 → lyric_view(自绘 scrim + 歌词/空态)。 */
|
||||
/* 网易云式歌词页封面背景(用户 2026-09-05):有封面 → 等比居中放大铺满内容区(cover-fit,
|
||||
* 超出部分由画布裁剪,镜像 ncm plr_draw_lyrics 的 meow_ui_img_scaled 算法);无封面 → 纯黑底
|
||||
* (现状回退)。本函数只铺底;压暗由调用方随后叠黑色半透明叠加层(alpha176)完成。 */
|
||||
static void np_draw_lyric_cover_bg(int W, int H)
|
||||
{
|
||||
int x = 0, y = SB_BAR_H, cw = W, ch = H - SB_BAR_H; /* 只盖内容区(状态栏下),与播放页一致 */
|
||||
if (!g_np_cover || meow_img_w(g_np_cover) <= 0 || meow_img_h(g_np_cover) <= 0) {
|
||||
fill_rect(x, y, cw, ch, 0x000000); /* 无封面 → 纯黑底(回退现状) */
|
||||
return;
|
||||
}
|
||||
int iw = meow_img_w(g_np_cover), ih = meow_img_h(g_np_cover);
|
||||
long dw = cw, dh = (long)ih * cw / iw; /* cover-fit:铺满宽度,高按等比 */
|
||||
if (dh < ch) { dh = ch; dw = (long)iw * ch / ih; } /* 宽度不足再铺满高度(超出裁剪) */
|
||||
meow_ui_img_scaled(x + (cw - (int)dw) / 2, y + (ch - (int)dh) / 2, (int)dw, (int)dh, g_np_cover);
|
||||
}
|
||||
|
||||
/* 歌词页整页绘制:网易云式封面背景(封面图当底 + 黑色半透明叠加层压暗,保证歌词对比度)→
|
||||
* lyric_view(自绘 scrim 关闭,仅歌词/空态)→ 顶部元信息(歌名/歌手)。 */
|
||||
static void np_draw_lyric_page(int redraw_sb)
|
||||
{
|
||||
const meow_ui_theme_t *th = s_ui_theme();
|
||||
int W = fb_xres, H = fb_yres;
|
||||
if (!g_np_init) np_widgets_create();
|
||||
if (redraw_sb) fill_rect(0, 0, W, SB_BAR_H, th->surface);
|
||||
/* 批5:封面取色背景(主→次渐变)或主题面;并注入歌词页 set_colors。
|
||||
* 批6:有封面时以封面作整页背景(衬在 lyric_view 半透明 scrim 之下),取色只做附加;
|
||||
* 无封面回退渐变/主题面。
|
||||
/* 批5:np_cover_theme_update 取色(仅为播放页文字/按钮主题;歌词页背景不走取色,恒 ncm 白/灰)。
|
||||
* 批6:有封面时以封面作整页背景(网易云式 cover-fit 铺满,见 np_draw_lyric_cover_bg),
|
||||
* 无封面回退纯黑底;压暗由下方黑色半透明叠加层(alpha176)统一完成,lyric_view 关内置 scrim。
|
||||
* M6.33:先 np_cover_load(artwork 空→复原/换源时重载封面)再取色——顺序不能反,
|
||||
* 取色读 g_np_cover,须在封面载入后(否则歌词页在 artwork 复原后仍无封面)。 */
|
||||
np_cover_load(meow_media_current());
|
||||
np_cover_theme_update();
|
||||
np_apply_lyric_colors();
|
||||
if (g_np_cover)
|
||||
np_draw_lyric_coverbg(W, H);
|
||||
else if (g_np_cc_valid)
|
||||
np_fill_gradient(0, SB_BAR_H, W, H - SB_BAR_H, g_np_cc.bg_top, g_np_cc.bg_bottom);
|
||||
else
|
||||
fill_rect(0, SB_BAR_H, W, H - SB_BAR_H, th->surface);
|
||||
/* 网易云式封面背景(用户 2026-09-05):先铺封面底(cover-fit 原图铺满),无封面回退纯黑底;
|
||||
* 随后统一叠黑色半透明叠加层(alpha176)压暗,保证歌词对比度。歌词配色仍恒 ncm 白/灰
|
||||
* (不随封面取色——取色仅服务播放页文字/按钮)。歌词行布局/滚动逻辑不变,只换背景。 */
|
||||
np_draw_lyric_cover_bg(W, H);
|
||||
np_set_lyric_ncm_colors();
|
||||
/* 用户 2026-09-04:黑色半透明叠加层(alpha176)——压暗封面底,保证歌词可读;无封面时叠于
|
||||
* 纯黑底之上(仍纯黑,即现状回退)。 */
|
||||
meow_ui_fill_rect_alpha(0, SB_BAR_H, W, H - SB_BAR_H, 0x000000, 176);
|
||||
np_lyric_refresh(meow_media_current());
|
||||
if (g_np_lyric)
|
||||
meow_widget_draw(meow_lyric_view_widget(g_np_lyric));
|
||||
np_draw_lyric_title(meow_media_current()); /* 顶部元信息(歌名/歌手,居左,避状态栏) */
|
||||
np_draw_home_bar(); /* 批②:歌词页也示 home 指示(底部上滑可回桌面) */
|
||||
if (redraw_sb) draw_statusbar();
|
||||
}
|
||||
|
||||
@@ -388,6 +463,7 @@ static void np_draw_page(int redraw_sb)
|
||||
ui_draw((W - ui_width(t2, 18)) / 2, 348, t2, th->on_surface_variant, 18);
|
||||
const char *t3 = meow_str_zh("np.edge_back", "左缘右滑返回");
|
||||
ui_draw((W - ui_width(t3, 16)) / 2, 396, t3, th->on_surface_variant, 16);
|
||||
np_draw_home_bar(); /* 批②:空态也示 home 指示(底部上滑可回桌面) */
|
||||
if (redraw_sb) draw_statusbar();
|
||||
return;
|
||||
}
|
||||
@@ -424,8 +500,9 @@ static void np_draw_page(int redraw_sb)
|
||||
np_draw_progress(s, pos, th);
|
||||
/* 控制按钮(SDK meow_iconbtn,命中/状态走 SDK) */
|
||||
np_draw_ctl(s);
|
||||
/* 播放页底部干净(M6.33:歌词占位已移除——已有独立歌词页,占位多余;
|
||||
* 歌词页无歌词时的空态由歌词页自身承载,播放页不再显示歌词占位文案)。 */
|
||||
/* 播放页底部(M6.33:歌词占位已移除——已有独立歌词页,占位多余;批②:底部仅示
|
||||
* home 指示条,非歌词占位)。 */
|
||||
np_draw_home_bar(); /* 批②:播放页示 home 指示(底部上滑可回桌面) */
|
||||
if (redraw_sb) draw_statusbar();
|
||||
}
|
||||
|
||||
@@ -478,6 +555,31 @@ static int64_t np_progress_seek(int tx, int ty)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 播放页按键动作应用(音量 + 播放切换)。语义与主界面/子页一致:
|
||||
* - 音量键 = 系统音量(settings_volume_adj 内含插孔消抖 + 线性渐变);
|
||||
* - 播放键 = 媒体播放/暂停切换(路由最近活跃媒体会话,与主界面同判);
|
||||
* 边沿由调用方按防抖后稳定值计算(vold_edge = 稳定按下 ∧ 上一稳定为抬起)。
|
||||
* 抽成独立小函数供宿主单测(np_apply_key_actions 仅依赖 settings_volume_adj/
|
||||
* meow_media_playctl/now_ms/logmsg,可 -ffunction-sections 链接隔离)。 */
|
||||
static void np_apply_key_actions(int vold_edge, int volu_edge, int play_edge,
|
||||
const meow_media_session_t *s,
|
||||
unsigned long long *last_input)
|
||||
{
|
||||
if (vold_edge) {
|
||||
settings_volume_adj(-settings_volume_step);
|
||||
if (last_input) *last_input = now_ms();
|
||||
logmsg("np: volume DOWN <- ramp step=%d", settings_volume_step);
|
||||
} else if (volu_edge) {
|
||||
settings_volume_adj(+settings_volume_step);
|
||||
if (last_input) *last_input = now_ms();
|
||||
logmsg("np: volume UP <- ramp step=%d", settings_volume_step);
|
||||
}
|
||||
if (play_edge) {
|
||||
if (s) meow_media_playctl((s->state == MEOW_MEDIA_PLAYING) ? 2u : 1u, 0);
|
||||
if (last_input) *last_input = now_ms();
|
||||
}
|
||||
}
|
||||
|
||||
void run_now_playing(struct app *apps, int napps)
|
||||
{
|
||||
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
|
||||
@@ -487,15 +589,17 @@ void run_now_playing(struct app *apps, int napps)
|
||||
kp_open(&kp_keys, DEV_KEYS);
|
||||
struct keypoll kp_power; /* M2.92:NP 页接管电源键(修「页内电源键无反应」) */
|
||||
kp_open(&kp_power, DEV_POWER);
|
||||
struct kd_state kd_play;
|
||||
struct kd_state kd_play; /* 音量键防抖/基线上收 key_router(进程唯一) */
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
/* 读播放键按住态(用于边沿) */
|
||||
int m_prev_play = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++)
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
|
||||
}
|
||||
int np_prev_power = 0; /* 电源键边沿跟踪 */
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++)
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) np_prev_power = 1;
|
||||
key_router_rebase(); /* 进入 Now Playing:音量基线对齐当前 raw(防残留边沿) */
|
||||
unsigned long long np_last_input = now_ms(); /* 自动息屏计时(M2.92:NP 页也遵守 display.screen_off_timeout) */
|
||||
|
||||
int td = 0, td_ok = 0, tdx = -1, tdy = -1, tlx = -1, tly = -1;
|
||||
@@ -513,6 +617,7 @@ void run_now_playing(struct app *apps, int napps)
|
||||
* 控件 owner 一直钉哨兵,命中要走 meow_widget_hit 的
|
||||
* owner==active 闸门——漏 push 则 active()!=0x51,
|
||||
* 三钮 meow_widget_hit 恒 false(「播放页三键无效」根因)。 */
|
||||
key_router_page_enter(NP_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
|
||||
g_np_page = NP_PAGE_PLAY; /* 批4:每次进入 Now Playing 从播放页起(上次退出页不残留) */
|
||||
g_np_lyric_last_valid = -1; /* 批4:重置歌词「是否有词」过渡态,防首帧多清一次 */
|
||||
g_np_lyric_seq = 0xFFFFFFFFu; /* 批4:强制首次进歌词页重解析(防跨进入残留缓存) */
|
||||
@@ -553,6 +658,11 @@ void run_now_playing(struct app *apps, int napps)
|
||||
* 的方向感知判定(真机定位「返回滑不出」根因曾依赖此日志;
|
||||
* 保留,键改为 ges=np_gesture_decide 返回值)。 */
|
||||
np_gest_t gnp = np_gesture_decide(g_np_page, tdx, tdy, dx, dy);
|
||||
/* 批②:底部上滑(home)优先——该手势竖向主导、起点在底部起滑区,
|
||||
* 与左缘返回(横向+左缘起点)/顶部下滑(顶部热区)互斥,故先判 home
|
||||
* 覆盖(np_gesture_decide 对纵滑返回 NONE,不冲突)。 */
|
||||
if (np_home_gesture(tdx, tdy, dx, dy, fb_yres))
|
||||
gnp = NP_GEST_HOME;
|
||||
logmsg("np up: page=%d down=(%d,%d) d=(%d,%d) adx=%d ady=%d ges=%d",
|
||||
g_np_page, tdx, tdy, dx, dy, adx, ady, (int)gnp);
|
||||
switch (gnp) {
|
||||
@@ -576,6 +686,12 @@ void run_now_playing(struct app *apps, int napps)
|
||||
logmsg("np: BACK break (exit, page=%d)", g_np_page);
|
||||
np_exit = 1;
|
||||
break;
|
||||
case NP_GEST_HOME:
|
||||
/* 批②:底部上滑 → 回桌面(后台)。媒体会话续播/进程保活归批①
|
||||
* keepalive(本页退出不改保活核心),此处仅走回桌面的既有路径。 */
|
||||
logmsg("np: HOME bottom-swipe-up -> back to desktop (bg)");
|
||||
np_exit = 1;
|
||||
break;
|
||||
case NP_GEST_NEXT:
|
||||
/* 播放页 右→左滑 → 进歌词页。 */
|
||||
g_np_page = NP_PAGE_LYRIC;
|
||||
@@ -612,17 +728,30 @@ void run_now_playing(struct app *apps, int napps)
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放键边沿 → 播放/暂停切换 */
|
||||
/* 播放键/音量键边沿 → 媒体播放/暂停切换 + 系统音量(与主界面/子页一致)。
|
||||
* 修复:run_now_playing 原先只接播放/电源键,未接音量键 → 播放页/歌词页(同一循环,
|
||||
* 与页无关)音量键无反应。 */
|
||||
key_evt_t kev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
|
||||
kp_poll(&kp_keys, NULL, NULL);
|
||||
int play = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++)
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
|
||||
if (play && !m_prev_play) {
|
||||
const meow_media_session_t *s = meow_media_current();
|
||||
if (s) meow_media_playctl((s->state == MEOW_MEDIA_PLAYING) ? 2u : 1u, 0);
|
||||
np_last_input = now_ms();
|
||||
}
|
||||
(void)kd_tick(&kd_play, play);
|
||||
play = kd_play.stable;
|
||||
/* 音量键/播放键边沿 → 动作(与主界面/子页同语义;见 np_apply_key_actions) */
|
||||
np_apply_key_actions(kev.vol_dn_edge, kev.vol_up_edge,
|
||||
play && !m_prev_play, meow_media_current(), &np_last_input);
|
||||
m_prev_play = play;
|
||||
/* 音量线性渐变(按住长爬;与主界面/子页同语义,状态栏即时刷新)。
|
||||
* Now Playing 用 raw held(kev.vol_held_*,同现状 rvold/rvolu)。 */
|
||||
if (vol_ramp_dir != 0) {
|
||||
int held = (vol_ramp_dir > 0) ? kev.vol_held_up : kev.vol_held_dn;
|
||||
int going = vol_ramp_tick(held);
|
||||
g_sys.vol = settings_volume_get();
|
||||
paint_sb_right();
|
||||
if (!going) logmsg("np: volume ramp end -> %d", g_sys.vol);
|
||||
}
|
||||
|
||||
/* M2.92:NP 页电源键接管——单击边沿 = 息屏/亮屏(与主界面语义一致;
|
||||
* 修「NP 页电源键无反应」。3s 电源菜单仍由 watchdog→/tmp 标记→主循环消费,
|
||||
@@ -695,6 +824,7 @@ void run_now_playing(struct app *apps, int napps)
|
||||
usleep(10000);
|
||||
}
|
||||
meow_ui_activity_pop(); /* M7.0b:退出 Now Playing,回主 activity(控件 owner 仍钉哨兵,复用) */
|
||||
key_router_page_exit(NP_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
|
||||
if (tfd >= 0) close(tfd);
|
||||
kp_close(&kp_keys);
|
||||
kp_close(&kp_power);
|
||||
|
||||
@@ -25,6 +25,7 @@ typedef enum {
|
||||
NP_GEST_NEXT, /* 播放页 右→左滑 → 进入歌词页 */
|
||||
NP_GEST_PREV, /* 歌词页 左→右滑 → 回到播放页 */
|
||||
NP_GEST_EXIT, /* 播放页 任意右滑 → 返回桌面 */
|
||||
NP_GEST_HOME, /* 批②:底部上滑 → 返回桌面(后台保留) */
|
||||
NP_GEST_TAP, /* 近点按(按当前页分派:播放页=按钮/进度看点) */
|
||||
} np_gest_t;
|
||||
|
||||
@@ -47,6 +48,12 @@ typedef enum {
|
||||
#ifndef NP_EDGE_SWIPE
|
||||
#define NP_EDGE_SWIPE 65 /* 左缘右滑位移阈值(镜像 meow_touch.h MEOW_EDGE_SWIPE) */
|
||||
#endif
|
||||
#ifndef NP_HOME_EDGE_H
|
||||
#define NP_HOME_EDGE_H 28 /* 底部起滑区高(自屏底向上;镜像顶部热区 NP_TOP_HOTZONE 同为 28) */
|
||||
#endif
|
||||
#ifndef NP_HOME_SWIPE
|
||||
#define NP_HOME_SWIPE 65 /* 底部上滑位移阈值(镜像 NP_SWIPE_THRESH / meow_edge_back 同口径) */
|
||||
#endif
|
||||
|
||||
/* 左缘返回(镜像 meow_touch.c meow_edge_back:down_x∈[0,NP_EDGE_W) 且 dx>=NP_EDGE_SWIPE
|
||||
* 且横向主导)。为宿主测试免链 meow_touch.c,此处 static inline 复刻同口径。 */
|
||||
@@ -58,6 +65,20 @@ static inline int np_edge_back(int down_x, int dx, int dy)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 批② 底部上滑返回桌面(home,与左缘返回 np_edge_back 对称):原点在底部起滑区
|
||||
* (down_y∈[fb_h-NP_HOME_EDGE_H, fb_h))且**不在左缘**(down_x>=NP_EDGE_W,与 np_edge_back 按
|
||||
* 原点分界互斥——左缘 24px 列留给左缘返回)+ 向上位移≥NP_HOME_SWIPE 且竖向主导(adx<-dy)。
|
||||
* fb_h=屏高(调用方喂 fb_yres,宿主单测亦可喂固定值)。其余(顶部/中部起滑、横向主导、
|
||||
* 向下滑、未达阈值)一律 0,不与顶部下滑/横向切页/点按冲突。 */
|
||||
static inline int np_home_gesture(int down_x, int down_y, int dx, int dy, int fb_h)
|
||||
{
|
||||
int adx = dx < 0 ? -dx : dx;
|
||||
if (down_x >= NP_EDGE_W && down_y >= fb_h - NP_HOME_EDGE_H && down_y >= 0 &&
|
||||
dy <= -NP_HOME_SWIPE && adx < -dy)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 按当前页把手势几何 → 动作。down_x/down_y=抬手原点;dx/dy=抬手位移(释放-原点,即 tlx-tdx)。
|
||||
* 优先级:顶部下滑 > 横向主导切页/返回 > 点按。横向反向或无主导 → NONE(调用方忽略,不误切)。 */
|
||||
static inline np_gest_t np_gesture_decide(int page, int down_x, int down_y, int dx, int dy)
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
*
|
||||
* ① 输入穿透(S1 根因):App 被 SIGSTOP 冻结过 → 关闭时必须在 /tmp/meow_notif_active 仍存在
|
||||
* 时先 SIGCONT,让 SDK 输入线程(meow_ui_input.c overlay_input_suppressed)排空冻结期缓冲的
|
||||
* evdev 事件副本 + 对齐 EVIOCGKEY,再等 OVERLAY_DRAIN_WINDOW_MS 后 unlink 标记。
|
||||
* 若反序(unlink->SIGCONT,改动前写法),App 在无隔离态恢复 → 解冻瞬间把积累的触摸回放成
|
||||
* evdev 事件副本 + 对齐 EVIOCGKEY,再等 OVERLAY_DRAIN_WINDOW_MS 后把标记内容更新为前台归属
|
||||
* (notif_active_set(a->id),批①内容化标记取代旧 unlink;清标记/置空会连后台常驻 App 一起放行)。
|
||||
* 若反序(清标记->SIGCONT,改动前写法),App 在无隔离态恢复 → 解冻瞬间把积累的触摸回放成
|
||||
* App 内点击(锁屏穿透 /「启动奇怪界面」/ 解锁后状态栏消失的触发源)。
|
||||
* ② 状态栏恢复(S2):解锁后强制 g_sb_mode_last=-1 让 app 循环立即重叠加,不等 1s 慢刷。
|
||||
* ③ 死亡重绘(S3):锁屏叠加中 App 崩溃(/proc/<pid>/stat==Z)→ 中止锁屏走统一退出路径,
|
||||
|
||||
@@ -23,6 +23,26 @@ static const char *const out_presets[] = { "hw:0,0" };
|
||||
|
||||
static const char *const out_preset_labels[] = { "CS43198 (hw:0,0)" };
|
||||
|
||||
/* i18n:SC_PRESET 选项中文标签键(meow_str_zh:未知 id → 中文兜底,行为/显示不变)。
|
||||
* 字体行(font.file/font.class_*)用运行时扫描表 font_presets/font_preset_labels,无键表,原样。 */
|
||||
static const char *const bg_preset_i18n[] = { "settings.bg_black", "settings.bg_blue", "settings.bg_green" };
|
||||
static const char *const hk_preset_i18n[] = { "settings.hk_powervol_pr", "settings.hk_sequence" };
|
||||
static const char *const seq_preset_i18n[] = { "settings.seq_volup3", "settings.seq_voldown3" };
|
||||
static const char *const out_preset_i18n[] = { "settings.out_cs43198" };
|
||||
|
||||
/* SC_PRESET 行选项标签本地化:按该行 presets 表匹配对应键表(字体行用运行时动态表,无键 → 原样)。 */
|
||||
static const char *s_preset_label(const srow_t *r, int i)
|
||||
{
|
||||
const char *const *keys = NULL;
|
||||
if (r->presets == bg_presets) keys = bg_preset_i18n;
|
||||
else if (r->presets == hk_presets) keys = hk_preset_i18n;
|
||||
else if (r->presets == seq_presets) keys = seq_preset_i18n;
|
||||
else if (r->presets == out_presets) keys = out_preset_i18n;
|
||||
const char *lbl = (r->preset_labels && r->preset_labels[i]) ? r->preset_labels[i] : "";
|
||||
if (keys && keys[i]) return meow_str_zh(keys[i], lbl);
|
||||
return lbl;
|
||||
}
|
||||
|
||||
|
||||
/* M2.37:息屏时间滑条改为「离散档位」而非线性 0~86400 秒(线性步进 1 根本调不到 30s,
|
||||
* 上限 24h 过于抽象)。档位 0=常亮;从 5s 起步的细粒度段可精调「30s 左右」,随后按
|
||||
@@ -123,6 +143,77 @@ static const srow_t SMENU[] = {
|
||||
{ "关于本机", NULL, SC_LIST, 0,0,0, NULL,NULL,0, NET_LIST_ABOUT },
|
||||
};
|
||||
|
||||
/* 设置行标题 i18n 键:与 SMENU 一一对应(含分组标题 / SC_LIST 导航行),settings.* 域。
|
||||
* 默认值取 SMENU 行 label 原中文,行为/显示不变;TF 覆盖表/后续 SDK 内嵌并入后自动换语。 */
|
||||
static const char *const s_row_i18n[SMENU_N] = {
|
||||
"settings.sec_display", /* 显示 */
|
||||
"settings.brightness", /* 亮度 */
|
||||
"settings.screen_off_mode", /* 息屏模式 */
|
||||
"settings.screen_off_timeout",/* 息屏时间 */
|
||||
"settings.refresh_rate", /* 刷新率 */
|
||||
"settings.refresh_always_max",/* 最高刷新率 */
|
||||
"settings.theme_mode", /* 主题 */
|
||||
"settings.theme", /* 主题深浅 */
|
||||
"settings.theme_dark_start", /* 深色时段开始 */
|
||||
"settings.theme_dark_end", /* 深色时段结束 */
|
||||
"settings.background", /* 背景 */
|
||||
"settings.bg_overlay_alpha", /* 背景叠加强度 */
|
||||
"settings.home_layout", /* 主界面布局 */
|
||||
"settings.sec_network", /* 网络 */
|
||||
"settings.wifi", /* WiFi */
|
||||
"settings.bt", /* 蓝牙 */
|
||||
"settings.sec_power", /* 电源 */
|
||||
"settings.longpress_menu", /* 长按弹菜单 */
|
||||
"settings.forceoff", /* 长按强关 */
|
||||
"settings.defeat_hotkey", /* 退出热键 */
|
||||
"settings.sec_boot", /* 开机 */
|
||||
"settings.boot_default", /* 默认系统 */
|
||||
"settings.boot_menu", /* 显示选单 */
|
||||
"settings.boot_timeout", /* 选单超时 */
|
||||
"settings.sec_input", /* 输入 */
|
||||
"settings.hotkey_seq", /* 热键序列 */
|
||||
"settings.hotkey_window", /* 热键窗口 */
|
||||
"settings.touch_arm", /* 触摸布防 */
|
||||
"settings.swipe_invert", /* 滑动方向 */
|
||||
"settings.sec_font", /* 字体主题 */
|
||||
"settings.font_file", /* 字体文件 */
|
||||
"settings.font_load_full", /* 加载大字库 */
|
||||
"settings.multi_font", /* 多字体 */
|
||||
"settings.font_cjk", /* 中文(CJK) */
|
||||
"settings.font_cjk_sym", /* 中文标点 */
|
||||
"settings.font_num", /* 数字 */
|
||||
"settings.font_latin", /* 拉丁字母 */
|
||||
"settings.font_latin_sym", /* 半角标点 */
|
||||
"settings.font_other", /* 其他字符 */
|
||||
"settings.font_title_size", /* 标题字号 */
|
||||
"settings.font_body_size", /* 正文字号 */
|
||||
"settings.ui_style", /* UI风格 */
|
||||
"settings.sec_notify", /* 通知 */
|
||||
"settings.notify_center", /* 通知中心 */
|
||||
"settings.notify_app_quota", /* 应用配额 */
|
||||
"settings.sec_log", /* 日志 */
|
||||
"settings.log_level", /* 日志级别 */
|
||||
"settings.log_to_card", /* 写入TF卡 */
|
||||
"settings.sec_audio", /* 音频 */
|
||||
"settings.volume", /* 音量 */
|
||||
"settings.volume_max", /* 音量上限 */
|
||||
"settings.volume_step", /* 音量跳步 */
|
||||
"settings.audio_output", /* 输出设备 */
|
||||
"settings.sec_security", /* 安全 */
|
||||
"settings.pin", /* 锁屏 PIN */
|
||||
"settings.sec_about", /* 关于 */
|
||||
"settings.about", /* 关于本机 */
|
||||
};
|
||||
|
||||
/* SMENU 行标题本地化:能定位到 SMENU 行 → 按对应键取文案(默认=原中文);否则原样返回。 */
|
||||
static const char *s_row_label(const srow_t *r)
|
||||
{
|
||||
for (int i = 0; i < SMENU_N; i++) {
|
||||
if (&SMENU[i] == r) return meow_str_zh(s_row_i18n[i], r->label);
|
||||
}
|
||||
return meow_str_zh("settings.unknown", r->label);
|
||||
}
|
||||
|
||||
/* M2.39 左缘返回手势已抽为共享函数 meow_edge_back()(meow_touch.h MEOW_EDGE_W/SWIPE 由
|
||||
* 其定义),设置页运行期调用;此处不再定义独立 S_EDGE_W/S_SWIPE_THRESH。 */
|
||||
|
||||
@@ -252,10 +343,10 @@ static int s_row_at_content_y(int cy)
|
||||
/* ---- 值格式化/展示 ---- */
|
||||
static void s_fmt_time(int sec, char *buf, int len)
|
||||
{
|
||||
if (sec <= 0) { snprintf(buf, len, "常亮"); return; }
|
||||
if (sec < 60) { snprintf(buf, len, "%d 秒", sec); return; }
|
||||
if (sec < 3600) { snprintf(buf, len, "%d 分", sec / 60); return; }
|
||||
snprintf(buf, len, "%d 时", sec / 3600);
|
||||
if (sec <= 0) { snprintf(buf, len, "%s", meow_str_zh("settings.always_on", "常亮")); return; }
|
||||
if (sec < 60) { snprintf(buf, len, meow_str_zh("settings.time_sec", "%d 秒"), sec); return; }
|
||||
if (sec < 3600) { snprintf(buf, len, meow_str_zh("settings.time_min", "%d 分"), sec / 60); return; }
|
||||
snprintf(buf, len, meow_str_zh("settings.time_hr", "%d 时"), sec / 3600);
|
||||
}
|
||||
|
||||
/* B2b 主题时段:分钟(0-1439) → "HH:MM"(如 1140→"19:00")。注意与 s_fmt_time 的秒语义区分。 */
|
||||
@@ -298,14 +389,14 @@ static void s_fmt_value(const srow_t *r, char *buf, int len)
|
||||
int s2 = 0, e2 = 0;
|
||||
meow_settings_get_int("display.theme_dark_start", &s2);
|
||||
meow_settings_get_int("display.theme_dark_end", &e2);
|
||||
if (s2 == e2) { snprintf(buf, len, "未设置"); return; }
|
||||
if (s2 == e2) { snprintf(buf, len, "%s", meow_str_zh("settings.unset", "未设置")); return; }
|
||||
s_fmt_minutes(v, buf, len);
|
||||
return;
|
||||
}
|
||||
/* 长按弹菜单/强关:毫秒换算成秒(0.5 步进,如 "2.5 秒") */
|
||||
if (!strcmp(r->key, "power.longpress_menu_ms") || !strcmp(r->key, "power.forceoff_ms")) {
|
||||
if (v % 1000 == 0) snprintf(buf, len, "%d 秒", v / 1000);
|
||||
else snprintf(buf, len, "%.1f 秒", v / 1000.0);
|
||||
if (v % 1000 == 0) snprintf(buf, len, meow_str_zh("settings.sec_val", "%d 秒"), v / 1000);
|
||||
else snprintf(buf, len, meow_str_zh("settings.sec_val_f", "%.1f 秒"), v / 1000.0);
|
||||
return;
|
||||
}
|
||||
snprintf(buf, len, "%d", v);
|
||||
@@ -324,14 +415,14 @@ static void s_display_value(const srow_t *r, char *buf, int len)
|
||||
if (meow_settings_get_str(r->key, cur, sizeof cur) != 0) { snprintf(buf, len, "-"); return; }
|
||||
/* M2.5x 多字体:class 行空值 = 跟随全局 font.file */
|
||||
if (r->key && !strncmp(r->key, "font.class_", 11) && !cur[0]) {
|
||||
snprintf(buf, len, "跟随全局");
|
||||
snprintf(buf, len, "%s", meow_str_zh("settings.follow_global", "跟随全局"));
|
||||
return;
|
||||
}
|
||||
int pn = 0;
|
||||
const char *const *pps = s_row_presets(r, &pn);
|
||||
for (int i = 0; i < pn; i++) {
|
||||
if (!strcmp(pps[i], cur)) {
|
||||
snprintf(buf, len, "%s", (r->preset_labels && r->preset_labels[i][0]) ? r->preset_labels[i] : cur);
|
||||
snprintf(buf, len, "%s", (r->preset_labels && r->preset_labels[i][0]) ? s_preset_label(r, i) : cur);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -655,14 +746,14 @@ static int s_dropdown(const srow_t *r)
|
||||
n = base + cnt;
|
||||
if (is_cls) {
|
||||
names[0] = "";
|
||||
snprintf(labels[0], sizeof labels[0], "跟随全局字体");
|
||||
snprintf(labels[0], sizeof labels[0], "%s", meow_str_zh("settings.follow_global_font", "跟随全局字体"));
|
||||
item[0] = labels[0];
|
||||
if (!cstr[0]) cur = 0;
|
||||
}
|
||||
for (int i = 0; i < cnt; i++) {
|
||||
names[base + i] = pps[i];
|
||||
snprintf(labels[base + i], sizeof labels[base + i], "%s",
|
||||
(r->preset_labels && r->preset_labels[i][0]) ? r->preset_labels[i] : pps[i]);
|
||||
(r->preset_labels && r->preset_labels[i][0]) ? s_preset_label(r, i) : pps[i]);
|
||||
item[base + i] = labels[base + i];
|
||||
if (!strcmp(pps[i], cstr)) cur = base + i;
|
||||
}
|
||||
@@ -671,7 +762,7 @@ static int s_dropdown(const srow_t *r)
|
||||
if (cur < 0 || cur >= n) cur = 0;
|
||||
/* M7.2:枚举/预设选择器迁 SDK meow_select_show(BottomSheet+单选列表,批次2 新控件),
|
||||
* 替换自绘 s_dd_modal(后者仍供 about/网络列表/字体两级子菜单用,勿删)。 */
|
||||
return meow_select_show(r->label, item, n, cur);
|
||||
return meow_select_show(s_row_label(r), item, n, cur);
|
||||
}
|
||||
|
||||
|
||||
@@ -689,8 +780,8 @@ static void dd_modal_paint(const meow_ui_theme_t *th, int W, int H, int d_x, int
|
||||
int content_top, int content_bot, int dd_scroll)
|
||||
{
|
||||
meow_ui_fill_rect_alpha(0, 0, W, H, 0x000000, 150);
|
||||
meow_gfx_fill_round(d_x, d_y, d_w, d_h, 16, th->surface_container);
|
||||
meow_ui_draw_rect_round(d_x, d_y, d_w, d_h, 16, th->outline);
|
||||
meow_gfx_fill_round(d_x, d_y, d_w, d_h, 28, th->surface_container); /* MD3 extraLarge 28(原 16 偏小) */
|
||||
meow_ui_draw_rect_round(d_x, d_y, d_w, d_h, 28, th->outline);
|
||||
if (mfont_ready) ui_draw(d_x + 18, d_y + 8, title, th->on_surface, 20);
|
||||
else draw_text(d_x + 18, d_y + 8, title, 2, th->on_surface);
|
||||
for (int i = 0; i < n; i++) {
|
||||
@@ -906,8 +997,9 @@ static int s_font_dropdown(const srow_t *r, char *out_val, int vlen)
|
||||
char fam_slug[S_NET_MAX][64], fam_disp[S_NET_MAX][64];
|
||||
int famn = 0;
|
||||
if (is_cls) {
|
||||
if (famn < S_NET_MAX) { snprintf(fam_slug[famn], sizeof fam_slug[0], "");
|
||||
snprintf(fam_disp[famn], sizeof fam_disp[0], "跟随全局字体"); famn++; }
|
||||
if (famn < S_NET_MAX) { snprintf(fam_slug[famn], sizeof fam_slug[0], "%s", "");
|
||||
snprintf(fam_disp[famn], sizeof fam_disp[0], "%s",
|
||||
meow_str_zh("settings.follow_global_font", "跟随全局字体")); famn++; }
|
||||
}
|
||||
for (int i = 0; i < font_presets_n && famn < S_NET_MAX; i++) {
|
||||
const char *f = font_preset_fams[i] ? font_preset_fams[i] : "";
|
||||
@@ -926,9 +1018,9 @@ static int s_font_dropdown(const srow_t *r, char *out_val, int vlen)
|
||||
int cur_fam = is_cls ? 0 : -1;
|
||||
for (int i = is_cls ? 1 : 0; i < famn; i++)
|
||||
if (curfam[0] && !strcmp(fam_slug[i], curfam)) cur_fam = i;
|
||||
int pick = s_dd_modal(r->label, fitem, famn, cur_fam);
|
||||
int pick = s_dd_modal(s_row_label(r), fitem, famn, cur_fam);
|
||||
if (pick < 0) return 0;
|
||||
if (is_cls && pick == 0) { snprintf(out_val, vlen, ""); return 1; } /* 跟随全局 */
|
||||
if (is_cls && pick == 0) { snprintf(out_val, vlen, "%s", ""); return 1; } /* 跟随全局 */
|
||||
const char *fam = fam_slug[pick];
|
||||
|
||||
/* ---- 二级:该 family 的字重列表(去重:同 (family,weight) 只显一行)----
|
||||
@@ -972,7 +1064,7 @@ static int s_font_dropdown(const srow_t *r, char *out_val, int vlen)
|
||||
for (int i = 0; i < wn; i++)
|
||||
if (cwt2[0] && !strcmp(wt_pick[i], cwt2)) { cur_wt = i; break; }
|
||||
}
|
||||
int wp = s_dd_modal(r->label, witem, wn, cur_wt);
|
||||
int wp = s_dd_modal(s_row_label(r), witem, wn, cur_wt);
|
||||
if (wp < 0) return 0;
|
||||
snprintf(out_val, vlen, "%s", wt_val[wp]);
|
||||
return 1;
|
||||
@@ -1002,6 +1094,11 @@ static int s_font_dropdown(const srow_t *r, char *out_val, int vlen)
|
||||
#define SET_TRACK_CY 48 /* 滑条 track 中心相对行顶 y(=SET_SLIDER_YOFF+3,与绘制对齐) */
|
||||
#define SET_SLIDER_LY 12 /* 滑条行 label 顶相对行顶 y(上移给 track 留位) */
|
||||
#define SET_SWITCH_POOL 12
|
||||
/* MD3 容器化(P0):设置页行=surface 上 surfaceContainer 圆角卡 + 行间空隙(去分隔线);
|
||||
* 卡横向外边距=SET_CARD_X(8),卡在行高内上下对称内缩(行中心=卡中心,同主界面/ncm 歌单手法)。 */
|
||||
#define SET_ROW_CARD_X SET_CARD_X /* 8:卡左缘 = 内容区左缘 */
|
||||
#define SET_ROW_CARD_G 8 /* 卡纵向间隙(px) */
|
||||
#define SET_ROW_CARD_R 12 /* 卡圆角(12dp) */
|
||||
|
||||
static meow_widget *g_set_lv = NULL; /* meow_listview(虚拟化) */
|
||||
static meow_scroll *g_set_sc = NULL; /* 滚动物理 */
|
||||
@@ -1029,7 +1126,7 @@ static void set_lv_bind(int idx, meow_lv_item *out, void *ctx)
|
||||
out->enabled = 1;
|
||||
if (idx < 0 || idx >= s_vis_count()) { out->title = ""; return; }
|
||||
const srow_t *r = &SMENU[s_vis_at(idx)];
|
||||
out->title = r->label ? r->label : "";
|
||||
out->title = r->label ? s_row_label(r) : "";
|
||||
out->section = (r->ctrl == SC_HEADER) ? 1 : 0;
|
||||
if (r->ctrl == SC_SWITCH) {
|
||||
int on = 0; meow_settings_get_switch(r->key, &on);
|
||||
@@ -1063,24 +1160,30 @@ static void set_lv_draw(int idx, int x, int y, int w, int h, int state, void *ct
|
||||
uint32_t textcol = sel ? mt->onPrimaryContainer : mt->onSurface;
|
||||
uint32_t subcol = sel ? mt->onPrimaryContainer : mt->onSurfaceVariant;
|
||||
|
||||
/* MD3 容器化(P0):行=surface 上 surfaceContainer 圆角卡;段头=surface 上的 primary 段标签(无卡)。 */
|
||||
int cx = x + SET_ROW_CARD_X; /* 卡左缘 */
|
||||
int cw = w - 2 * SET_ROW_CARD_X; /* 卡宽 */
|
||||
int cy = y + SET_ROW_CARD_G / 2; /* 卡顶(行内对称内缩) */
|
||||
int ch = SET_ITEM_H - SET_ROW_CARD_G; /* 卡高 */
|
||||
|
||||
if (r->ctrl == SC_HEADER) {
|
||||
meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->surfaceContainerHigh);
|
||||
int ty = y + (SET_ITEM_H - thh) / 2;
|
||||
if (ty >= 0) meow_ui_text(x + 16, ty, r->label, mt->font_body, mt->primary);
|
||||
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant);
|
||||
if (ty >= 0) meow_ui_text(cx + 16, ty, s_row_label(r), mt->font_body, mt->primary);
|
||||
return;
|
||||
}
|
||||
if (sel) meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->primaryContainer);
|
||||
else if (press) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->primary, 26);
|
||||
meow_gfx_fill_round(cx, cy, cw, ch, SET_ROW_CARD_R, mt->surfaceContainer);
|
||||
if (sel) meow_gfx_fill_round(cx, cy, cw, ch, SET_ROW_CARD_R, mt->primaryContainer);
|
||||
else if (press) meow_gfx_fill_round_alpha(cx, cy, cw, ch, SET_ROW_CARD_R, mt->primary, 26);
|
||||
|
||||
int ly = y + (SET_ITEM_H - thh) / 2; /* label 单行垂直居中 */
|
||||
int rgt = cx + cw - 16; /* 卡内右缘(内容右对齐锚点) */
|
||||
switch (r->ctrl) {
|
||||
case SC_SWITCH: {
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, ly, s_row_label(r), mt->font_body, textcol);
|
||||
meow_widget *sw = set_sw_take();
|
||||
if (sw) {
|
||||
int on = 0; meow_settings_get_switch(r->key, &on);
|
||||
meow_widget_set_rect(sw, x + w - 16 - 56, y + (SET_ITEM_H - 32) / 2, 56, 32);
|
||||
meow_widget_set_rect(sw, rgt - 56, y + (SET_ITEM_H - 32) / 2, 56, 32);
|
||||
meow_switch_set(sw, on);
|
||||
meow_widget_draw(sw);
|
||||
}
|
||||
@@ -1088,9 +1191,9 @@ static void set_lv_draw(int idx, int x, int y, int w, int h, int state, void *ct
|
||||
}
|
||||
case SC_SLIDER: {
|
||||
/* label 上移一行,track 放行下方(中心=SET_SLIDER_YOFF+3=48),与拖动命中带对齐 */
|
||||
meow_ui_text(x + 16, y + SET_SLIDER_LY, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, y + SET_SLIDER_LY, s_row_label(r), mt->font_body, textcol);
|
||||
char vb[32]; s_fmt_value(r, vb, sizeof vb);
|
||||
meow_ui_text(x + w - 16 - meow_ui_text_width(vb, mt->font_body), y + SET_SLIDER_LY, vb, mt->font_body, subcol);
|
||||
meow_ui_text(rgt - meow_ui_text_width(vb, mt->font_body), y + SET_SLIDER_LY, vb, mt->font_body, subcol);
|
||||
int v = 0; meow_settings_get_int(r->key, &v);
|
||||
/* 息屏时间为档位索引型(秒→索引 等距画);其余 clamp 到 [min,max] */
|
||||
if (!strcmp(r->key, "display.screen_off_timeout")) v = s_time_tier_idx(v);
|
||||
@@ -1099,30 +1202,29 @@ static void set_lv_draw(int idx, int x, int y, int w, int h, int state, void *ct
|
||||
break;
|
||||
}
|
||||
case SC_INFO: {
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, ly, s_row_label(r), mt->font_body, textcol);
|
||||
char vb[48]; s_net_get(r, vb, sizeof vb);
|
||||
meow_ui_text(x + w - 16 - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol);
|
||||
meow_ui_text(rgt - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol);
|
||||
break;
|
||||
}
|
||||
case SC_LIST: {
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, ly, s_row_label(r), mt->font_body, textcol);
|
||||
char vb[48]; s_net_get(r, vb, sizeof vb);
|
||||
int vx = x + w - 16 - 12 - meow_ui_text_width(vb, mt->font_body);
|
||||
int vx = rgt - 12 - meow_ui_text_width(vb, mt->font_body);
|
||||
meow_ui_text(vx, ly, vb, mt->font_body, subcol);
|
||||
s_draw_chevron(x + w - 16, y + SET_ITEM_H / 2, subcol);
|
||||
s_draw_chevron(rgt, y + SET_ITEM_H / 2, subcol);
|
||||
break;
|
||||
}
|
||||
default: { /* SC_ENUM / SC_PRESET */
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, ly, s_row_label(r), mt->font_body, textcol);
|
||||
char vb[40]; s_display_value(r, vb, sizeof vb);
|
||||
int vx = x + w - 16 - meow_ui_text_width(vb, mt->font_body);
|
||||
int vx = rgt - meow_ui_text_width(vb, mt->font_body);
|
||||
meow_ui_text(vx, ly, vb, mt->font_body, subcol);
|
||||
s_draw_chevron(vx - 8, y + SET_ITEM_H / 2, subcol);
|
||||
break;
|
||||
}
|
||||
}
|
||||
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant);
|
||||
if (dis) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->onSurface, 90);
|
||||
if (dis) meow_gfx_fill_round_alpha(cx, cy, cw, ch, SET_ROW_CARD_R, mt->onSurface, 90);
|
||||
}
|
||||
|
||||
/* ---- meow_scroll 回调(包 listview)---- */
|
||||
@@ -1137,7 +1239,7 @@ static void set_redraw_region(void *ctx)
|
||||
int lvx, lvy, lvw, lvh; set_lv_rect(&lvx, &lvy, &lvw, &lvh);
|
||||
if (lvh <= 0) return;
|
||||
const meow_ui_theme_t *th = s_ui_theme();
|
||||
meow_ui_fill_rect(lvx, lvy, lvw, lvh, th->surface_variant);
|
||||
meow_ui_fill_rect(lvx, lvy, lvw, lvh, th->surface); /* P0:内容底改 surface(卡在其上抬升) */
|
||||
if (g_set_lv) { g_set_sw_use = 0; meow_widget_draw(g_set_lv); }
|
||||
meow_dirty_add(lvx, lvy, lvw, lvh);
|
||||
meow_ui_flush();
|
||||
@@ -1179,10 +1281,10 @@ static void set_paint_page(int sel)
|
||||
int W = fb_xres, H = fb_yres;
|
||||
fill_rect(0, 0, W, H, th->surface);
|
||||
draw_statusbar();
|
||||
if (mfont_ready) ui_draw(24, 50, "系统设置", th->on_surface, 28);
|
||||
if (mfont_ready) ui_draw(24, 50, meow_str_zh("menu.settings", "系统设置"), th->on_surface, 28);
|
||||
else draw_text(24, 30, "SETTINGS", 3, th->on_surface);
|
||||
fill_rect(0, 50 + (mfont_ready ? meow_font_line_height(28) : 24) + 2, W, 2, th->outline);
|
||||
fill_rect(0, SET_CARD_Y, W, H - SET_CARD_Y, th->surface_variant);
|
||||
fill_rect(0, SET_CARD_Y, W, H - SET_CARD_Y, th->surface); /* P0:内容底 surface,行用圆角卡抬升 */
|
||||
if (g_set_lv) {
|
||||
g_set_sw_use = 0;
|
||||
int p = s_vis_of(sel); if (p >= 0) meow_listview_set_sel(g_set_lv, p);
|
||||
@@ -1193,7 +1295,7 @@ static void set_paint_page(int sel)
|
||||
snprintf(ver, sizeof ver, "MeowOS v%s dev", g_meowos_version);
|
||||
int foot_y = fb_yres - meow_font_line_height(20) - 8;
|
||||
s_draw_chevron_left(SET_CARD_X + 12, foot_y + meow_font_line_height(20) / 2, th->on_surface_variant);
|
||||
ui_draw(SET_CARD_X + 24, foot_y, "返回", th->on_surface_variant, 20);
|
||||
ui_draw(SET_CARD_X + 24, foot_y, meow_str_zh("settings.back", "返回"), th->on_surface_variant, 20);
|
||||
ui_draw(fb_xres - 8 - ui_width(ver, 20), foot_y, ver, th->outline, 20);
|
||||
}
|
||||
|
||||
@@ -1213,16 +1315,15 @@ void run_settings_page(void)
|
||||
|
||||
struct keypoll kp_power, kp_keys;
|
||||
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
|
||||
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
|
||||
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
|
||||
/* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
|
||||
struct kd_state kd_play, kd_pwr;
|
||||
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
|
||||
int m_prev_play = 0, m_prev_pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
|
||||
}
|
||||
key_router_rebase(); /* 进入设置页:音量基线对齐当前 raw(防残留边沿) */
|
||||
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
|
||||
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
|
||||
unsigned long long touch_cooldown_until = now_ms() + 300;
|
||||
@@ -1256,6 +1357,7 @@ void run_settings_page(void)
|
||||
logmsg("settings entry: net toggle-sync+kick %llums", now_ms() - t_sets);
|
||||
if (!g_set_init) set_widgets_create(); /* M7.2:lazy 建 SDK 控件集(一次,跨次进入复用) */
|
||||
meow_ui_activity_push(SET_SENTINEL_ACT); /* 控件 owner 闸门打开(hit/按压态生效) */
|
||||
key_router_page_enter(SET_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
|
||||
set_paint_page(sel);
|
||||
meow_dirty_full(); meow_ui_flush();
|
||||
logmsg("settings entry: page shown in %llums", now_ms() - t_sets);
|
||||
@@ -1294,13 +1396,12 @@ void run_settings_page(void)
|
||||
meow_auto_slept = 0;
|
||||
wake_suppress = 1;
|
||||
last_act = now_ms();
|
||||
/* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸 */
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
|
||||
/* 重置按键防抖与 m_prev_*(含唤醒键原始态对齐),并丢唤醒的这次触摸。
|
||||
* 音量防抖/基线上收 key_router:唤醒时重播音量基线;播放/电源仍本页自理。 */
|
||||
key_router_rebase(); /* 音量:kd_vold/volu 清零 + m_prev_vold/volu=当前 raw */
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
td = 0; td_ok = 0; drag = -1; scroll_drag = 0; tlx = tly = -1;
|
||||
@@ -1321,28 +1422,25 @@ void run_settings_page(void)
|
||||
}
|
||||
|
||||
/* ---- 按键 ---- */
|
||||
key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
int vold = 0, volu = 0, play = 0, pwr = 0;
|
||||
int play = 0, pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
|
||||
}
|
||||
(void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu);
|
||||
(void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr);
|
||||
vold = kd_vold.stable; volu = kd_volu.stable;
|
||||
play = kd_play.stable; pwr = kd_pwr.stable;
|
||||
if (wake_suppress) {
|
||||
/* 刚被唤醒:吸收唤醒键的边沿,等所有按键完全抬起后再接管动作,
|
||||
* 避免「电源唤醒→抬起→被当成电源单击返回」或音量唤醒→误导航。 */
|
||||
if (!pwr && !vold && !volu && !play) wake_suppress = 0;
|
||||
if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0;
|
||||
} else {
|
||||
/* M2.39:设置页音量键 = 调系统音量(写 DAC),不做光标移动(用户约束:
|
||||
* 设置页仅触摸导航;音量键在电菜单/应用列表的系统级导航保持不动)。
|
||||
* 按下边沿即起线性渐变(vol_ramp_begin),下方 vol_ramp_tick 逐 1% 爬 + 刷状态栏。 */
|
||||
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
|
||||
if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
|
||||
if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
|
||||
if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
|
||||
/* M2.x(KEY_CONTROL_DESIGN §2/§3)播放键在设置页同样被系统「抓住」:
|
||||
* 捕获+日志+消费,不再映射为「返回/确认」(原 play 边沿=break 已删,播放键不再退出设置页)。
|
||||
* 仍视为输入(重置自动息屏计时,与主界面 play 的媒体预留/any_input 一致)。 */
|
||||
@@ -1378,14 +1476,11 @@ void run_settings_page(void)
|
||||
pwr_suppress_click = 1; /* M2.34:菜单返回后抑制本次电源松开被误判单击息屏 */
|
||||
/* M2.25 同款:阻塞式 show_power_menu() 期间 kd_* 冻结,m_prev_* 与稳定值可能错配,
|
||||
* 重播 kd_* 并以当前 raw 对齐 m_prev_*,杜绝「菜单内触摸点取消->返回后被误判单击息屏」。 */
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
/* 重播音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase(); /* 音量:kd_vold/volu 清零 + m_prev_vold/volu=当前 raw */
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
after_modal = 1; /* 全屏 dim 覆盖过状态栏,需整帧重绘还原 */
|
||||
need_paint = 1;
|
||||
@@ -1412,14 +1507,14 @@ void run_settings_page(void)
|
||||
}
|
||||
/* M2.39:驱动音量线性渐变(长按续爬/松开即止)+ 刷新状态栏 VOL;「音量」行随 need_paint 更新 */
|
||||
if (vol_ramp_dir != 0) {
|
||||
int held = (vol_ramp_dir > 0) ? volu : vold;
|
||||
int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn;
|
||||
int going = vol_ramp_tick(held);
|
||||
g_sys.vol = settings_volume_get();
|
||||
paint_sb_right();
|
||||
need_paint = 1;
|
||||
if (!going) logmsg("settings vol ramp end -> %d", g_sys.vol);
|
||||
}
|
||||
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr;
|
||||
m_prev_play = play; m_prev_pwr = pwr;
|
||||
settings_keys_seeded:
|
||||
;
|
||||
|
||||
@@ -1556,14 +1651,10 @@ void run_settings_page(void)
|
||||
scroll_y0 = 0;
|
||||
tdx = tdy = tlx = tly = -1;
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
key_router_rebase(); /* 音量基线重播 */
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
need_paint = 1;
|
||||
} else if (r->ctrl == SC_ENUM || r->ctrl == SC_PRESET) {
|
||||
@@ -1601,14 +1692,10 @@ void run_settings_page(void)
|
||||
tdx = tdy = tlx = tly = -1;
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
/* 重置设置页按键防抖基线,防残边 */
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
key_router_rebase(); /* 音量基线重播 */
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
need_paint = 1;
|
||||
} else if (r->ctrl == SC_SLIDER && tlx >= 0) {
|
||||
@@ -1666,6 +1753,7 @@ void run_settings_page(void)
|
||||
done:
|
||||
meow_input_touch_set(0, 0, 0, 0); /* M7.2 防残留按压态 */
|
||||
meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */
|
||||
key_router_page_exit(SET_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
|
||||
if (tfd >= 0) close(tfd);
|
||||
kp_close(&kp_power); kp_close(&kp_keys);
|
||||
logmsg("settings page closed");
|
||||
|
||||
@@ -247,12 +247,11 @@ int meow_subpage_run(sub_page_t *p)
|
||||
meow_auto_slept = 0;
|
||||
wake_suppress = 1;
|
||||
last_act = now;
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
|
||||
/* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
td = 0; td_ok = 0; scroll_drag = 0; tlx = tly = -1;
|
||||
@@ -302,14 +301,12 @@ int meow_subpage_run(sub_page_t *p)
|
||||
last_act = now_ms();
|
||||
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0;
|
||||
pwr_suppress_click = 1;
|
||||
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
after_modal = 1;
|
||||
need_paint = 1;
|
||||
@@ -456,14 +453,12 @@ int meow_subpage_run(sub_page_t *p)
|
||||
scroll_base = 0; scroll_y0 = 0;
|
||||
tdx = tdy = tlx = tly = -1;
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
/* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
after_modal = 1;
|
||||
need_paint = 1;
|
||||
@@ -589,6 +584,45 @@ static void sp_lv_bind(int idx, meow_lv_item *out, void *ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 行完全自绘(批③:页面经 sub_page_t.row_draw 定制整行;默认 renderer 被覆盖)---- */
|
||||
static void sp_lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!g_sp || !g_sp->row_draw) return;
|
||||
if (idx < 0 || idx >= g_nrows) return;
|
||||
g_sp->row_draw(g_sp->ctx, idx, &g_rows[idx], x, y, w, h, state);
|
||||
}
|
||||
|
||||
/* ---- 顶栏标题栏右侧动作按钮矩形(批③:sub_page_t.top_action)---- */
|
||||
static void sp_top_action_rect(int *x, int *y, int *w, int *h)
|
||||
{
|
||||
if (x) *x = 0;
|
||||
if (y) *y = 0;
|
||||
if (w) *w = 0;
|
||||
if (h) *h = 0;
|
||||
if (!g_sp || !g_sp->top_action) return;
|
||||
int tw = ui_width(g_sp->top_action, 20);
|
||||
if (tw <= 0) return;
|
||||
int bw = tw + 24, bh = 40; /* 含内边距的圆角按钮 */
|
||||
*x = fb_xres - 12 - bw;
|
||||
*y = SUB_TB_Y + (SUB_TB_H - bh) / 2;
|
||||
*w = bw; *h = bh;
|
||||
}
|
||||
|
||||
/* 绘制顶栏动作按钮(sp_paint_page 内调用)。 */
|
||||
static void sp_paint_top_action(void)
|
||||
{
|
||||
if (!g_sp || !g_sp->top_action) return;
|
||||
int x, y, w, h; sp_top_action_rect(&x, &y, &w, &h);
|
||||
if (w <= 0 || h <= 0) return;
|
||||
const meow_theme *mt = meow_theme_get();
|
||||
uint32_t col = mt->primary;
|
||||
meow_ui_draw_rect_round(x, y, w, h, h / 2, col); /* 全形=胶囊描边按钮(P1) */
|
||||
int th = meow_ui_text_height(20);
|
||||
if (th > h) th = h;
|
||||
meow_ui_text_center(x + w / 2, y + (h - th) / 2, g_sp->top_action, 20, col);
|
||||
}
|
||||
|
||||
/* ---- meow_scroll 回调(包 listview)---- */
|
||||
static void sp_sc_set(void *ctx, int off) { (void)ctx; if (g_lv) meow_listview_set_scroll(g_lv, off); }
|
||||
static int sp_sc_max(void *ctx) { (void)ctx; return g_lv ? meow_listview_max_scroll(g_lv) : 0; }
|
||||
@@ -622,6 +656,9 @@ static void sp_widgets_create(void)
|
||||
/* 双行富 item + 无左图标 + 右尾件(SWITCH/ARROW);行高 56 */
|
||||
meow_listview_set_item_renderer(g_lv, 2, 0, 1);
|
||||
meow_listview_set_item_h(g_lv, SUB_ITEM_H);
|
||||
/* MD3(P0):默认 renderer 容器模式——行=surfaceContainer 圆角卡+行间空隙,
|
||||
* 选中/按压 tonal(primaryContainer/primary α26)、去冗余分隔线;段头=surface 上的 primary 段标签。 */
|
||||
meow_listview_set_container(g_lv, 1);
|
||||
}
|
||||
/* 注:刻意不 set_snap —— SDK meow_scroll 的 snap 以「fling 起点」为吸附目标,快速滑动时会
|
||||
* 从自然停点往回拉一整行(实测 697→336),UX 不佳;默认 ease-out 惯性停(无 snap)更顺滑。
|
||||
@@ -648,6 +685,8 @@ static void sp_configure_page(const sub_page_t *p, int top_sw_en, int top_on)
|
||||
meow_switch_set(g_sw, top_on);
|
||||
}
|
||||
}
|
||||
/* 批③:行完全自绘 on/off —— 页提供 row_draw 用自绘;否则清 NULL 回默认 renderer。 */
|
||||
if (g_lv) meow_listview_set_item_draw(g_lv, p->row_draw ? sp_lv_draw : NULL, NULL);
|
||||
}
|
||||
|
||||
/* 整页绘制(进 back_buf;调用方 meow_dirty_full + flush 上屏)。 */
|
||||
@@ -659,6 +698,7 @@ static void sp_paint_page(void)
|
||||
draw_statusbar();
|
||||
if (g_tb) meow_widget_draw(g_tb); /* topappbar(含底部分隔线) */
|
||||
if (g_sw && g_top_sw_en) meow_widget_draw(g_sw); /* 顶开关(叠顶栏右缘) */
|
||||
sp_paint_top_action(); /* 批③:顶栏右侧动作按钮 */
|
||||
int lvx, lvy, lvw, lvh;
|
||||
sp_lv_rect(&lvx, &lvy, &lvw, &lvh);
|
||||
meow_ui_fill_rect(lvx, lvy, lvw, lvh, mt->surface); /* 内容底 surface */
|
||||
@@ -701,16 +741,15 @@ int meow_subpage_run(sub_page_t *p)
|
||||
{
|
||||
struct keypoll kp_power, kp_keys;
|
||||
kp_open(&kp_power, DEV_POWER); kp_open(&kp_keys, DEV_KEYS);
|
||||
struct kd_state kd_vold, kd_volu, kd_play, kd_pwr;
|
||||
memset(&kd_vold, 0, sizeof kd_vold); memset(&kd_volu, 0, sizeof kd_volu);
|
||||
/* 音量键防抖/基线已上收 key_router(进程唯一)——本页只保留播放/电源键自家防抖。 */
|
||||
struct kd_state kd_play, kd_pwr;
|
||||
memset(&kd_play, 0, sizeof kd_play); memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
int m_prev_vold = 0, m_prev_volu = 0, m_prev_play = 0, m_prev_pwr = 0;
|
||||
int m_prev_play = 0, m_prev_pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) m_prev_vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) m_prev_volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) m_prev_play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) m_prev_pwr = 1;
|
||||
}
|
||||
key_router_rebase(); /* 进入子页:音量基线对齐当前 raw(防残留边沿) */
|
||||
int tfd = open(DEV_TOUCH, O_RDONLY | O_NONBLOCK);
|
||||
if (tfd >= 0) { unsigned char ev[16]; while (read(tfd, ev, sizeof ev) == 16) {} }
|
||||
unsigned long long touch_cooldown_until = now_ms() + 300;
|
||||
@@ -738,6 +777,7 @@ int meow_subpage_run(sub_page_t *p)
|
||||
sp_configure_page(p, top_sw_en, top_on);
|
||||
|
||||
meow_ui_activity_push(SUB_SENTINEL_ACT); /* 控件 owner 闸门打开(hit 生效) */
|
||||
key_router_page_enter(SUB_SENTINEL_ACT); /* 路由登记活动页(与 activity 栈交叉校验) */
|
||||
sp_paint_page();
|
||||
meow_dirty_full(); meow_ui_flush();
|
||||
|
||||
@@ -772,12 +812,11 @@ int meow_subpage_run(sub_page_t *p)
|
||||
meow_auto_slept = 0;
|
||||
wake_suppress = 1;
|
||||
last_act = now;
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu); memset(&kd_play, 0, sizeof kd_play);
|
||||
/* 唤醒:音量基线重播(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr); memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
td = 0; td_ok = 0; scroll_drag = 0; drag_last_y = 0; tlx = tly = -1;
|
||||
@@ -790,23 +829,20 @@ int meow_subpage_run(sub_page_t *p)
|
||||
}
|
||||
|
||||
/* ---- 按键(与旧版逐字一致:音量=系统音量/播放=媒体预留/电源=系统级语义)---- */
|
||||
key_evt_t ev = key_router_pump(); /* 音量边沿/held/ramp(进程唯一防抖基线) */
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
int vold = 0, volu = 0, play = 0, pwr = 0;
|
||||
int play = 0, pwr = 0;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) {
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
|
||||
if (kp_keys.cur[w] & (1UL << (KEY_PLAYPAUSE % 32))) play = 1;
|
||||
if (kp_power.cur[w] & (1UL << (KEY_POWER % 32))) pwr = 1;
|
||||
}
|
||||
(void)kd_tick(&kd_vold, vold); (void)kd_tick(&kd_volu, volu);
|
||||
(void)kd_tick(&kd_play, play); (void)kd_tick(&kd_pwr, pwr);
|
||||
vold = kd_vold.stable; volu = kd_volu.stable;
|
||||
play = kd_play.stable; pwr = kd_pwr.stable;
|
||||
if (wake_suppress) {
|
||||
if (!pwr && !vold && !volu && !play) wake_suppress = 0;
|
||||
if (!pwr && !ev.vol_st_dn && !ev.vol_st_up && !play) wake_suppress = 0;
|
||||
} else {
|
||||
if (vold && !m_prev_vold) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
|
||||
if (volu && !m_prev_volu) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
|
||||
if (ev.vol_dn_edge) { settings_volume_adj(-settings_volume_step); need_paint = 1; }
|
||||
if (ev.vol_up_edge) { settings_volume_adj(+settings_volume_step); need_paint = 1; }
|
||||
if (play && !m_prev_play) { logmsg("subpage: play key caught (media-reserved)"); last_act = now; }
|
||||
if (pwr && !m_prev_pwr) {
|
||||
pwr_hold_at = now; pwr_menu_state = 0;
|
||||
@@ -825,14 +861,12 @@ int meow_subpage_run(sub_page_t *p)
|
||||
last_act = now_ms();
|
||||
pwr_hold_at = 0; pwr_menu_state = 0; pwr_woke = 0;
|
||||
pwr_suppress_click = 1;
|
||||
/* 电源菜单返回:音量基线重播(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
after_modal = 1;
|
||||
need_paint = 1;
|
||||
@@ -857,13 +891,13 @@ int meow_subpage_run(sub_page_t *p)
|
||||
}
|
||||
}
|
||||
if (vol_ramp_dir != 0) {
|
||||
int held = (vol_ramp_dir > 0) ? volu : vold;
|
||||
int held = (vol_ramp_dir > 0) ? ev.vol_held_up : ev.vol_held_dn;
|
||||
(void)vol_ramp_tick(held);
|
||||
g_sys.vol = settings_volume_get();
|
||||
paint_sb_right();
|
||||
need_paint = 1;
|
||||
}
|
||||
m_prev_vold = vold; m_prev_volu = volu; m_prev_play = play; m_prev_pwr = pwr;
|
||||
m_prev_play = play; m_prev_pwr = pwr;
|
||||
sub_keys_seeded:
|
||||
;
|
||||
|
||||
@@ -929,6 +963,18 @@ int meow_subpage_run(sub_page_t *p)
|
||||
if (tfd >= 0) { unsigned char e[16]; while (read(tfd, e, sizeof e) == 16) {} }
|
||||
goto subdone;
|
||||
}
|
||||
/* 批③:顶栏标题栏右侧动作按钮(一键清除;先于顶开关判定,二者页间互斥) */
|
||||
if (g_sp && g_sp->top_action) {
|
||||
int ax, ay, aw, ah; sp_top_action_rect(&ax, &ay, &aw, &ah);
|
||||
if (aw > 0 && tx >= ax && tx < ax + aw && ty >= ay && ty < ay + ah) {
|
||||
if (g_sp->top_action_cb) g_sp->top_action_cb(g_sp->ctx);
|
||||
last_act = now;
|
||||
sp_refresh_rows(); /* 清后立即重扫行 */
|
||||
need_paint = 1;
|
||||
if (tfd >= 0) { unsigned char e[16]; while (read(tfd, e, sizeof e) == 16) {} }
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (g_top_sw_en && g_sw && meow_widget_hit(g_sw, tx, ty)) {
|
||||
sp_top_sw_toggle();
|
||||
last_act = now;
|
||||
@@ -940,7 +986,13 @@ int meow_subpage_run(sub_page_t *p)
|
||||
int row_hit = g_lv ? meow_listview_hit(g_lv, tx, ty) : -1;
|
||||
if (row_hit >= 0) {
|
||||
sub_kind_t kind = g_rows[row_hit].kind;
|
||||
if (kind == SUB_SW || kind == SUB_ACT) {
|
||||
if (kind == SUB_ROW && g_sp->row_tap) {
|
||||
/* 批③:行内自定义命中(行内杀按钮 vs 行体;页自判,返回 1=重绘) */
|
||||
int rry = SUB_CONT_TOP + row_hit * SUB_ITEM_H -
|
||||
(g_lv ? meow_listview_scroll(g_lv) : 0);
|
||||
g_sp->row_tap(g_sp->ctx, row_hit, &g_rows[row_hit],
|
||||
tx, ty, 0, rry, fb_xres, SUB_ITEM_H);
|
||||
} else if (kind == SUB_SW || kind == SUB_ACT) {
|
||||
if (g_sp->act) g_sp->act(g_sp->ctx, row_hit, g_rows[row_hit].act);
|
||||
} else if (kind == SUB_ROW) {
|
||||
if (g_sp->detail) g_sp->detail(g_sp->ctx, row_hit, g_rows[row_hit].act);
|
||||
@@ -961,14 +1013,12 @@ int meow_subpage_run(sub_page_t *p)
|
||||
drag_last_y = 0;
|
||||
tdx = tdy = tlx = tly = -1;
|
||||
touch_cooldown_until = now_ms() + 300;
|
||||
/* 返回重播:音量基线(key_router)+ 本页播放/电源基线 */
|
||||
key_router_rebase();
|
||||
memset(&kd_pwr, 0, sizeof kd_pwr);
|
||||
memset(&kd_vold, 0, sizeof kd_vold);
|
||||
memset(&kd_volu, 0, sizeof kd_volu);
|
||||
memset(&kd_play, 0, sizeof kd_play);
|
||||
kp_poll(&kp_power, NULL, NULL); kp_poll(&kp_keys, NULL, NULL);
|
||||
m_prev_pwr = kp_is_down(&kp_power, KEY_POWER);
|
||||
m_prev_vold = kp_is_down(&kp_keys, KEY_VOLUMEDOWN);
|
||||
m_prev_volu = kp_is_down(&kp_keys, KEY_VOLUMEUP);
|
||||
m_prev_play = kp_is_down(&kp_keys, KEY_PLAYPAUSE);
|
||||
after_modal = 1;
|
||||
need_paint = 1;
|
||||
@@ -1027,6 +1077,7 @@ int meow_subpage_run(sub_page_t *p)
|
||||
subdone:
|
||||
meow_input_touch_set(0, 0, 0, 0); /* 防残留按压态 */
|
||||
meow_ui_activity_pop(); /* 关 owner 闸门(控件 owner 仍钉哨兵,复用) */
|
||||
key_router_page_exit(SUB_SENTINEL_ACT); /* 路由:退出登记 + 重播音量基线 */
|
||||
if (tfd >= 0) close(tfd);
|
||||
kp_close(&kp_power); kp_close(&kp_keys);
|
||||
logmsg("subpage '%s' closed", p->title ? p->title : "");
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/* meow_launcherd/test_bg_row_card.c — 宿主验证:后台管理页(bg_page)MD3 圆角卡行 + 行内杀按钮
|
||||
* 的几何与命中(纯整数运算,不链 SDK / 不开设备):
|
||||
* gcc -Wall -Wextra test_bg_row_card.c -o test_bg_row_card && ./test_bg_row_card
|
||||
*
|
||||
* 断言集(对应「相邻 item 顶之间 = item 高 + gap」的可见形式):
|
||||
* ① 卡高 = 行高 - BGM_CARD_GAP;卡在其行高内上下对称内缩 gap/2(行中心=卡中心);
|
||||
* ② 相邻卡顶间距 = SUB_ITEM_H(= 卡高 + gap;SDK 行距不变),卡片间可见外距 = gap;
|
||||
* ③ 杀按钮垂直居中于卡(中心=卡中心=行中心),完整落卡内,右缘距行右 12px;
|
||||
* ④ 命中不破:卡内缩后按钮几何不变(相对整行视口)——按钮区内点按命中(1),
|
||||
* 行体/卡内空白点按不命中(0);命中矩形仍为整行距;
|
||||
* ⑤ 滚动偏移只整体平移,不改变上述间距/对称/命中。
|
||||
*
|
||||
* 几何公式与 patch_v2/meow_launcherd/bg_page.c 的 bgm_row_card_y/bgm_row_card_h/bgm_btn_rect 同源。
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <assert.h>
|
||||
|
||||
#define SUB_ITEM_H 56 /* 行距(SDK listview item_h,subpage.c,不变) */
|
||||
#define BGM_CARD_GAP 8
|
||||
#define BGM_BTN_W 46
|
||||
#define BGM_BTN_H 34
|
||||
|
||||
/* 与 bg_page.c bgm_row_card_y / bgm_row_card_h 同源(上下对称内缩)。 */
|
||||
static int bgm_row_card_y(int row_top) { return row_top + BGM_CARD_GAP / 2; }
|
||||
static int bgm_row_card_h(int h) { return h - BGM_CARD_GAP; }
|
||||
|
||||
/* 与 bg_page.c bgm_btn_rect 同源(按钮垂直居中于卡;卡中心=行中心 → 命中不破)。 */
|
||||
static void bgm_btn_rect(int rx, int ry, int rw, int rh, int *bx, int *by, int *bw, int *bh)
|
||||
{
|
||||
*bw = BGM_BTN_W;
|
||||
*bh = BGM_BTN_H;
|
||||
*bx = rx + rw - 12 - BGM_BTN_W;
|
||||
*by = bgm_row_card_y(ry) + (bgm_row_card_h(rh) - BGM_BTN_H) / 2;
|
||||
}
|
||||
|
||||
/* 行顶 = list_y + idx*item - off(SDK listview 布局:行距=item)。 */
|
||||
static int row_top(int idx, int item, int list_y, int off) { return list_y + idx * item - off; }
|
||||
|
||||
/* 复刻 bgm_row_tap 的按钮命中判定(仅几何):命中按钮区=1,行体/卡内空白=0。 */
|
||||
static int btn_hit(int tx, int ty, int rx, int ry, int rw, int rh)
|
||||
{
|
||||
int bx, by, bw, bh; bgm_btn_rect(rx, ry, rw, rh, &bx, &by, &bw, &bh);
|
||||
if (tx < bx || tx >= bx + bw || ty < by || ty >= by + bh) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const int item = SUB_ITEM_H;
|
||||
const int gap = BGM_CARD_GAP;
|
||||
const int list_y = 100; /* 内容区顶缘(SUB_CONT_TOP 等值,任意值皆可) */
|
||||
const int rw = 480; /* 行视口宽(fb_xres,480×720) */
|
||||
const int rx = 0; /* 行视口左缘 */
|
||||
|
||||
int y0 = bgm_row_card_y(row_top(0, item, list_y, 0));
|
||||
int y1 = bgm_row_card_y(row_top(1, item, list_y, 0));
|
||||
int h = bgm_row_card_h(item);
|
||||
|
||||
/* ① 卡相对行顶对称内缩 gap/2;卡高=item-gap;卡高+gap=item */
|
||||
assert(y0 == list_y + gap / 2);
|
||||
assert(h == item - gap);
|
||||
assert(h + gap == item);
|
||||
|
||||
/* ① 相邻卡顶间距 = item(顶到顶 = 卡高 + gap) */
|
||||
assert(y1 - y0 == item);
|
||||
|
||||
/* ② 卡片间可见外距 = gap(卡 0 底 到 卡 1 顶) */
|
||||
assert(y1 - (y0 + h) == gap);
|
||||
|
||||
/* ③ 卡中心 == 行中心(内容按行高居中 → 落卡中心) */
|
||||
assert(y0 - gap / 2 + item / 2 == y0 + h / 2);
|
||||
/* 下行底到卡底 = gap/2(下对称) */
|
||||
assert((list_y + item) - (y0 + h) == gap / 2);
|
||||
|
||||
/* ③④ 杀按钮:相对整行视口定位,垂直居中于卡(中心=卡中心=行中心),完整落卡内,
|
||||
* 右缘距行右 12px;滚动偏移只平移,不改变以上(③④⑤合并循环)。 */
|
||||
for (int off = 0; off <= item; off += item / 2) {
|
||||
int r0 = row_top(0, item, list_y, off);
|
||||
int bx, by, bw, bh; bgm_btn_rect(rx, r0, rw, item, &bx, &by, &bw, &bh);
|
||||
int card_top = bgm_row_card_y(r0);
|
||||
int card_bot = card_top + bgm_row_card_h(item);
|
||||
int row_cy = r0 + item / 2;
|
||||
int card_cy = card_top + bgm_row_card_h(item) / 2;
|
||||
|
||||
/* 按钮完整落卡内(含圆角内边距;上下由卡限,左右由行限) */
|
||||
assert(bx >= rx && bx + bw <= rx + rw);
|
||||
assert(by >= card_top && by + bh <= card_bot);
|
||||
/* 按钮中心 = 卡中心 = 行中心(垂直) */
|
||||
assert(by + bh / 2 == card_cy);
|
||||
assert(by + bh / 2 == row_cy);
|
||||
/* 按钮右缘距行右 12px(与绘制一致) */
|
||||
assert(rx + rw - (bx + bw) == 12);
|
||||
|
||||
/* ④ 命中:按钮中心 → 命中(1);卡内空白(左上圆角内) / 行体左缘 → 不命中(0) */
|
||||
assert(btn_hit(bx + bw / 2, by + bh / 2, rx, r0, rw, item) == 1);
|
||||
assert(btn_hit(card_top + 2, card_top + 2, rx, r0, rw, item) == 0);
|
||||
assert(btn_hit(rx + 16, row_cy, rx, r0, rw, item) == 0);
|
||||
/* 卡上/下外侧(gap 带内)点按 → 不命中(命中仍按整行距,含 gap 带) */
|
||||
assert(btn_hit(rx + rw / 2, r0 + gap / 2 - 1, rx, r0, rw, item) == 0);
|
||||
}
|
||||
|
||||
printf("PASS bg_row_card_gap=%d card_h=%d top2top=%d visible_gap=%d "
|
||||
"btn_in_card=1 btn_center==row_center=1 hit_unbroken=1\n",
|
||||
gap, h, item, gap);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/* test_bgmgr.c — 批③ 后台管理页 逻辑层宿主单测(meow_bgmgr)。
|
||||
*
|
||||
* 验证:
|
||||
* 1) bgmgr_collect:enumerate 后台表 → app_meta 补 name/icon/protected_app/is_fg → proc_sample 补
|
||||
* pcpu/rss_kb;行 killable 预判正确;name 回退 app_id。
|
||||
* 2) bgmgr_can_kill 门控矩阵:protected_app=1 → 0(**受保护 App 不 kill**);is_fg=1 → 0(当前前台不 kill);
|
||||
* pid<=0 → 0(非后台/无进程);普通后台(非受保护/非前台/pid>0)→ 1。
|
||||
* 3) bgmgr_kill_all 一键清:受保护 App 不杀、当前前台不杀、非后台不杀;其余逐一 kill;返回 kill 数。
|
||||
* 3b) 回归:app.json framework=1(SDK 框架 App 标志,现有 App 全写 1)**不影响**可杀性。
|
||||
* 4) bgmgr_status_str / bgmgr_mem_str 文案。
|
||||
* 只依赖 meow_bgmgr.c + meow_ipc.h(纯 C);宿主 gcc 直编:
|
||||
* gcc -Wall -Wextra -std=gnu99 test_bgmgr.c meow_bgmgr.c -o test_bgmgr
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include "meow_bgmgr.h"
|
||||
|
||||
static int g_fails = 0;
|
||||
static int g_checks = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
g_checks++; \
|
||||
if (cond) printf("PASS: %s\n", msg); \
|
||||
else { printf("FAIL: %s\n", msg); g_fails++; } } while (0)
|
||||
|
||||
/* ---- 假件(enumerate/app_meta/proc_sample 注入)---- */
|
||||
static const char g_meta_app[][24] = {
|
||||
"os.meow.hello", "os.meow.reader", "os.meow.sysguard", "os.meow.fgapp",
|
||||
};
|
||||
static int g_killed_n = 0;
|
||||
static char g_killed_app[8][40];
|
||||
static int g_meta_use_unknown = 0; /* 第 4 行 meta 返回 -1(未注册),测 name 回退 */
|
||||
|
||||
static int fake_enumerate(void *ctx, bgmgr_row_t *rows, int max)
|
||||
{
|
||||
(void)ctx;
|
||||
if (max < 4) return 0;
|
||||
/* 4 个后台行:hello=普通、reader=常驻、sysguard=受保护、fgapp=当前前台 */
|
||||
memset(&rows[0], 0, sizeof rows[0]);
|
||||
snprintf(rows[0].app_id, sizeof rows[0].app_id, "%s", g_meta_app[0]);
|
||||
rows[0].pid = 1001; rows[0].keepalive = 0; rows[0].on_card = 0; rows[0].bg_since_ms = 1000;
|
||||
|
||||
memset(&rows[1], 0, sizeof rows[1]);
|
||||
snprintf(rows[1].app_id, sizeof rows[1].app_id, "%s", g_meta_app[1]);
|
||||
rows[1].pid = 1002; rows[1].keepalive = 1; rows[1].on_card = 0; rows[1].bg_since_ms = 2000;
|
||||
|
||||
memset(&rows[2], 0, sizeof rows[2]);
|
||||
snprintf(rows[2].app_id, sizeof rows[2].app_id, "%s", g_meta_app[2]);
|
||||
rows[2].pid = 1003; rows[2].keepalive = 0; rows[2].on_card = 0; rows[2].bg_since_ms = 3000;
|
||||
|
||||
memset(&rows[3], 0, sizeof rows[3]);
|
||||
snprintf(rows[3].app_id, sizeof rows[3].app_id, "%s", g_meta_app[3]);
|
||||
rows[3].pid = 1004; rows[3].keepalive = 0; rows[3].on_card = 0; rows[3].bg_since_ms = 4000;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/* meta:hello/reader/sysguard/fgapp 各给名字;sysguard 标受保护;pid==1004 标前台。 */
|
||||
static int fake_app_meta(void *ctx, const char *app_id, pid_t pid,
|
||||
char *name, int nl, char *icon, int il,
|
||||
char *protected_app, int *is_fg)
|
||||
{
|
||||
(void)ctx;
|
||||
if (!strcmp(app_id, "os.meow.hello")) snprintf(name, nl, "播放");
|
||||
else if (!strcmp(app_id, "os.meow.reader")) snprintf(name, nl, "阅读");
|
||||
else if (!strcmp(app_id, "os.meow.sysguard")) snprintf(name, nl, "系统守护");
|
||||
else if (!strcmp(app_id, "os.meow.fgapp")) snprintf(name, nl, "前台应用");
|
||||
else return -1; /* 未注册 → name 回退 app_id */
|
||||
snprintf(icon, il, "/tmp/icon_%s.png", app_id);
|
||||
*protected_app = (!strcmp(app_id, "os.meow.sysguard")) ? '1' : 0;
|
||||
*is_fg = (pid == 1004) ? 1 : 0; /* fgapp 标前台 */
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int fake_proc_sample(void *ctx, pid_t pid, double *pcpu, long *rss_kb)
|
||||
{
|
||||
(void)ctx;
|
||||
if (pid <= 0) return -1;
|
||||
*pcpu = (double)(pid % 100) * 0.5; /* 1001->0.5% ... 1004->2.0% */
|
||||
*rss_kb = (long)(pid % 10) * 2048; /* 1001->2048 ... 1004->8192 */
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void fake_log(const char *fmt, ...) { (void)fmt; }
|
||||
|
||||
static void fake_kill(const char *app_id)
|
||||
{
|
||||
if (g_killed_n < 8) snprintf(g_killed_app[g_killed_n++], 40, "%s", app_id);
|
||||
}
|
||||
|
||||
/* enumerate 只回一行:未注册 app_id,测 name 回退。 */
|
||||
static int fake_enumerate_unknown(void *ctx, bgmgr_row_t *rows, int max)
|
||||
{
|
||||
(void)ctx;
|
||||
if (max < 1) return 0;
|
||||
(void)g_meta_use_unknown;
|
||||
memset(&rows[0], 0, sizeof rows[0]);
|
||||
snprintf(rows[0].app_id, sizeof rows[0].app_id, "os.meow.noreg");
|
||||
rows[0].pid = 2001; rows[0].keepalive = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
bgmgr_ops_t ops;
|
||||
memset(&ops, 0, sizeof ops);
|
||||
ops.enumerate = fake_enumerate;
|
||||
ops.app_meta = fake_app_meta;
|
||||
ops.proc_sample = fake_proc_sample;
|
||||
ops.log = fake_log;
|
||||
|
||||
bgmgr_row_t rows[MEOW_BGMR_ROW_MAX];
|
||||
int n = bgmgr_collect(&ops, rows, MEOW_BGMR_ROW_MAX);
|
||||
|
||||
printf("=== 1) collect ===\n");
|
||||
CHECK(n == 4, "collect returns 4 rows");
|
||||
CHECK(!strcmp(rows[0].name, "播放") && rows[0].pid == 1001, "row0 hello name/pid");
|
||||
CHECK(rows[0].protected_app == 0, "row0 hello not protected");
|
||||
CHECK(!strcmp(rows[1].name, "阅读") && rows[1].keepalive == 1, "row1 reader keepalive");
|
||||
CHECK(rows[2].protected_app == '1', "row2 sysguard protected");
|
||||
CHECK(rows[3].is_fg == 1, "row3 fgapp is_fg");
|
||||
CHECK(rows[0].pcpu > 0 && rows[0].rss_kb > 0, "row0 pcpu/rss populated");
|
||||
CHECK(rows[0].killable == 1 && rows[2].killable == 0, "killable prefilled by can_kill");
|
||||
CHECK(!strcmp(rows[2].icon, "/tmp/icon_os.meow.sysguard.png"), "icon populated");
|
||||
|
||||
/* name 回退:未注册 app_id(app_meta 返回 -1)→ name==app_id */
|
||||
printf("=== 1b) name fallback ===\n");
|
||||
{
|
||||
bgmgr_ops_t o2 = ops; o2.enumerate = fake_enumerate_unknown;
|
||||
bgmgr_row_t r1[1]; memset(r1, 0, sizeof r1);
|
||||
int n2 = bgmgr_collect(&o2, r1, 1);
|
||||
CHECK(n2 == 1, "unknown row collected");
|
||||
CHECK(!strcmp(r1[0].name, "os.meow.noreg"), "name falls back to app_id");
|
||||
CHECK(bgmgr_can_kill(&r1[0]) == 1, "unknown bg app still killable");
|
||||
}
|
||||
|
||||
printf("=== 2) can_kill gating ===\n");
|
||||
CHECK(bgmgr_can_kill(&rows[2]) == 0, "protected app NOT killable");
|
||||
CHECK(bgmgr_can_kill(&rows[3]) == 0, "fg app NOT killable (is_fg)");
|
||||
CHECK(bgmgr_can_kill(&rows[0]) == 1, "normal bg app killable");
|
||||
CHECK(bgmgr_can_kill(&rows[1]) == 1, "keepalive bg app killable");
|
||||
{
|
||||
bgmgr_row_t dead; memset(&dead, 0, sizeof dead);
|
||||
snprintf(dead.app_id, sizeof dead.app_id, "x");
|
||||
dead.pid = 0;
|
||||
CHECK(bgmgr_can_kill(&dead) == 0, "pid<=0 NOT killable");
|
||||
CHECK(bgmgr_can_kill(NULL) == 0, "NULL NOT killable");
|
||||
}
|
||||
|
||||
printf("=== 3) kill_all (one-key clear) ===\n");
|
||||
g_killed_n = 0;
|
||||
int k = bgmgr_kill_all(&ops, rows, n, fake_kill);
|
||||
CHECK(k == 2, "kill_all kills 2 (hello+reader)");
|
||||
CHECK(g_killed_n == 2, "kill_all actually invoked kill_fn 2x");
|
||||
int killed_fw = 0, killed_fg = 0, killed_bg = 0;
|
||||
for (int i = 0; i < g_killed_n; i++) {
|
||||
if (!strcmp(g_killed_app[i], "os.meow.sysguard")) killed_fw = 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;
|
||||
}
|
||||
CHECK(killed_fw == 0, "kill_all did NOT kill protected app");
|
||||
CHECK(killed_fg == 0, "kill_all did NOT kill fg app");
|
||||
CHECK(killed_bg == 1, "kill_all killed a real bg app");
|
||||
CHECK(bgmgr_kill_all(&ops, rows, n, NULL) == 0, "kill_all NULL kill_fn -> 0");
|
||||
|
||||
/* 回归(2026-11-04 真机):app.json framework=1 是「SDK 框架 App」标志(现有 App 全写 1),
|
||||
* **不是**受保护位。旧实现把它当门控 → dev.player.ncm 等所有后台 App 都不可杀、按钮置灰。 */
|
||||
printf("=== 3b) framework(SDK) ≠ 受保护 ===\n");
|
||||
{
|
||||
bgmgr_row_t sdk; memset(&sdk, 0, sizeof sdk);
|
||||
snprintf(sdk.app_id, sizeof sdk.app_id, "dev.player.ncm");
|
||||
sdk.pid = 7520; sdk.protected_app = 0;
|
||||
CHECK(bgmgr_can_kill(&sdk) == 1, "SDK 框架 App(framework=1)后台仍可杀");
|
||||
}
|
||||
|
||||
printf("=== 4) status/mem str ===\n");
|
||||
CHECK(!strcmp(bgmgr_status_str(&rows[1]), "常驻"), "status_str keepalive=常驻");
|
||||
CHECK(!strcmp(bgmgr_status_str(&rows[0]), "后台"), "status_str normal=后台");
|
||||
CHECK(!strcmp(bgmgr_status_str(&rows[3]), "前台"), "status_str is_fg=前台");
|
||||
CHECK(!strcmp(bgmgr_mem_str(512), "512KB"), "mem_str(<1MB)=KB");
|
||||
CHECK(!strcmp(bgmgr_mem_str(2048), "2.0MB"), "mem_str(>=1MB)=MB");
|
||||
|
||||
if (g_fails == 0) printf("\nALL PASS (%d checks)\n", g_checks);
|
||||
else printf("\n%d FAILED\n", g_fails);
|
||||
return g_fails ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/* test_bgpage.c — 批③ 后台管理页 UI 按钮化 宿主子测(bg_page.c 按钮命中/顶栏清除命中)。
|
||||
*
|
||||
* 通过 #include bg_page.c 把其 FILE-STATIC 的 bgm_rows/bgm_btn_rect/bgm_row_tap/bgm_top_action
|
||||
* 平移进本 TU;桩掉 launcher 后台表(meow_bg_count/at/terminate)+ 时钟 + meow_str/logmsg,
|
||||
* 直接验证:
|
||||
* P1 bgm_rows 行骨架:后台 App 行(act=i)+ 空态行(act=-1);name 经 app_meta 正确;
|
||||
* **行副文本指向 ctx 持久缓冲且扛得住栈抖动**(曾指向 bgm_rows 栈帧 → 真机副标题乱码);
|
||||
* P2 bgm_btn_rect 按钮几何:行右 46×34 圆角钮(rx+rw-12-46 定位);
|
||||
* P3 bgm_row_tap 行内命中:**仅按钮区 kill(bgm_terminate 一次),行体/按钮外不误杀**;
|
||||
* **app.json framework=1(SDK 框架 App)照样可杀**(2026-11-04 真机回归);
|
||||
* 受保护/前台行按钮点击 → 不 terminate(门控,留诊断日志);空态行不响应;
|
||||
* P4 bgm_top_action 顶栏清除:kill_all 不杀受保护/前台、杀普通后台(含 framework=1 的);
|
||||
* P5 门控矩阵:bgmgr_can_kill(protected_app/is_fg/pid<=0/普通)。
|
||||
* 不调用行自绘 bgm_row_draw(纯逻辑),故无需桩 SDK 绘制;行自绘段经 -ffunction-sections/--gc-sections 剔除。
|
||||
*
|
||||
* 编译(在 patch_v2/meow_launcherd 下):
|
||||
* gcc -I../sdk -I. -Wall -Wextra -fsanitize=undefined \
|
||||
* -ffunction-sections -fdata-sections test_bgpage.c meow_bgmgr.c \
|
||||
* -Wl,--gc-sections -o test_bgpage
|
||||
* 运行:./test_bgpage
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdarg.h>
|
||||
|
||||
#include "meow_launcher_internal.h"
|
||||
#include "bg_page.c" /* FILE-STATIC 函数平移进本 TU */
|
||||
|
||||
/* ---------- 桩:launcher 后台表(meow_bgplay 视角) ---------- */
|
||||
static meow_bg_entry_t g_bg[MEOW_BG_MAX];
|
||||
static int g_bg_n;
|
||||
static int g_term_calls;
|
||||
static char g_terminated[16][64];
|
||||
|
||||
int meow_bg_count(void) { return g_bg_n; }
|
||||
const meow_bg_entry_t *meow_bg_at(int i)
|
||||
{
|
||||
if (i < 0 || i >= g_bg_n) return NULL;
|
||||
return &g_bg[i];
|
||||
}
|
||||
int meow_bg_terminate(const char *app_id)
|
||||
{
|
||||
if (g_term_calls < 16) snprintf(g_terminated[g_term_calls++], 64, "%s", app_id);
|
||||
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 */
|
||||
static int g_log_n;
|
||||
void logmsg(const char *fmt, ...) { (void)fmt; g_log_n++; } /* 只计数:门控/未命中应留诊断日志 */
|
||||
|
||||
/* 栈抖动:bgm_rows 返回后调用,用来暴露「sub 指向栈帧」的悬垂指针。 */
|
||||
static void stack_clobber(void)
|
||||
{
|
||||
volatile char buf[256];
|
||||
memset((void *)buf, 'X', sizeof buf);
|
||||
buf[0] = buf[0]; /* 防优化掉 */
|
||||
}
|
||||
|
||||
/* ---------- 测试态:launcher 应用表(app_meta 用) ---------- */
|
||||
static struct app g_apps[4];
|
||||
|
||||
static void set_app(int i, const char *id, const char *name, char fw)
|
||||
{
|
||||
memset(&g_apps[i], 0, sizeof g_apps[i]);
|
||||
snprintf(g_apps[i].id, sizeof g_apps[i].id, "%s", id);
|
||||
snprintf(g_apps[i].name, sizeof g_apps[i].name, "%s", name);
|
||||
g_apps[i].framework = fw;
|
||||
}
|
||||
|
||||
static void bg_reset(int n, const char *id0, pid_t pid0, const char *id1, pid_t pid1)
|
||||
{
|
||||
g_bg_n = n; g_term_calls = 0;
|
||||
memset(g_bg, 0, sizeof g_bg);
|
||||
if (n >= 1 && id0) {
|
||||
g_bg[0].used = 1; snprintf(g_bg[0].app_id, sizeof g_bg[0].app_id, "%s", id0);
|
||||
g_bg[0].pid = pid0; g_bg[0].keepalive = 0;
|
||||
}
|
||||
if (n >= 2 && id1) {
|
||||
g_bg[1].used = 1; snprintf(g_bg[1].app_id, sizeof g_bg[1].app_id, "%s", id1);
|
||||
g_bg[1].pid = pid1; g_bg[1].keepalive = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int g_fails = 0;
|
||||
static int g_checks = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
g_checks++; \
|
||||
if (cond) printf("PASS: %s\n", msg); \
|
||||
else { printf("FAIL: %s\n", msg); g_fails++; } } while (0)
|
||||
|
||||
static int term_count_app(const char *app_id)
|
||||
{
|
||||
int c = 0;
|
||||
for (int i = 0; i < g_term_calls; i++) if (!strcmp(g_terminated[i], app_id)) c++;
|
||||
return c;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
static bgm_ctx_t b;
|
||||
memset(&b, 0, sizeof b);
|
||||
b.apps = g_apps; b.napps = 3;
|
||||
set_app(0, "os.meow.hello", "播放", 0);
|
||||
set_app(1, "dev.player.ncm", "流媒体客户端", '1'); /* SDK 框架 App(真机 app.json framework:1) */
|
||||
set_app(2, "os.meow.sysguard", "系统守护", 0);
|
||||
static sub_row_t rows[SUB_ROWS_MAX];
|
||||
|
||||
printf("=== P1) bgm_rows 行骨架 + 副文本持久化 ===\n");
|
||||
bg_reset(2, "os.meow.hello", 1001, "dev.player.ncm", 7520);
|
||||
int rn = bgm_rows(&b, rows, SUB_ROWS_MAX);
|
||||
CHECK(rn == 2, "2 后台 App → 2 行(无空态/无清除动作行)");
|
||||
CHECK(rows[0].kind == SUB_ROW && rows[0].act == 0, "row0 act=0");
|
||||
CHECK(!strcmp(rows[0].label, "播放"), "row0 label=名字(播放)");
|
||||
CHECK(rows[1].kind == SUB_ROW && rows[1].act == 1, "row1 act=1");
|
||||
CHECK(!strcmp(rows[1].label, "流媒体客户端"), "row1 label=流媒体客户端");
|
||||
CHECK(rows[1].sub != NULL && !strncmp(rows[1].sub, "CPU ", 4), "row1 sub=CPU/内存/状态");
|
||||
CHECK(b.n == 2, "bgm_ctx.n=2");
|
||||
/* 悬垂指针回归:sub 必须落在 ctx 缓冲里,且栈被改写后内容不变 */
|
||||
uintptr_t subp = (uintptr_t)rows[1].sub;
|
||||
uintptr_t lob = (uintptr_t)(char *)&b;
|
||||
uintptr_t hib = lob + sizeof b;
|
||||
CHECK(subp >= lob && subp < hib, "sub 指向 ctx 持久缓冲(不是 bgm_rows 栈帧)");
|
||||
char sub_before[80];
|
||||
snprintf(sub_before, sizeof sub_before, "%s", rows[1].sub);
|
||||
stack_clobber();
|
||||
CHECK(!strcmp(rows[1].sub, sub_before), "栈抖动后 sub 内容不变(无悬垂)");
|
||||
|
||||
printf("=== P2) bgm_btn_rect 按钮几何(行右 46×34) ===\n");
|
||||
{
|
||||
int bx, by, bw, bh; bgm_btn_rect(0, 0, 480, 56, &bx, &by, &bw, &bh);
|
||||
CHECK(bw == 46 && bh == 34, "按钮 46×34");
|
||||
CHECK(bx == 480 - 12 - 46, "按钮左缘=行宽-12-46");
|
||||
CHECK(by == (56 - 34) / 2, "按钮垂直居中");
|
||||
}
|
||||
|
||||
printf("=== P3) bgm_row_tap 行内杀按钮命中(仅按钮区 kill,行区不误杀) ===\n");
|
||||
bg_reset(2, "os.meow.hello", 1001, "dev.player.ncm", 7520);
|
||||
bgm_rows(&b, rows, SUB_ROWS_MAX);
|
||||
/* 按钮区命中:行(0,0,480,56),按钮 x 422-468, y 11-45 */
|
||||
CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 28, 0, 0, 480, 56) == 1, "按钮点击→返回1(重绘)");
|
||||
CHECK(term_count_app("os.meow.hello") == 1, "按钮点击→terminate hello 一次");
|
||||
/* 行体(按钮左/其下区)命中 → 不 kill */
|
||||
CHECK(bgm_row_tap(&b, 0, &rows[0], 200, 28, 0, 0, 480, 56) == 0, "行体点击→返回0(不杀)");
|
||||
CHECK(term_count_app("os.meow.hello") == 1, "行体点击→不误杀(仍1次)");
|
||||
CHECK(bgm_row_tap(&b, 0, &rows[0], 410, 28, 0, 0, 480, 56) == 0, "按钮左侧缘(410)→不杀");
|
||||
CHECK(bgm_row_tap(&b, 0, &rows[0], 475, 28, 0, 0, 480, 56) == 0, "按钮右侧(475)→不杀");
|
||||
CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 60, 0, 0, 480, 56) == 0, "按钮下方(60)→不杀");
|
||||
/* 回归(2026-11-04 真机「杀后台按钮按下去没反应」):dev.player.ncm 的 app.json 写 framework:1
|
||||
* (= 用 SDK 框架,现有 App 全写 1),**必须照杀**——旧实现把它当受保护位,按钮置灰点不动。 */
|
||||
CHECK(b.rows[1].killable == 1, "framework=1(SDK 框架)App 仍判可杀");
|
||||
CHECK(bgm_row_tap(&b, 1, &rows[1], 445, 28, 0, 0, 480, 56) == 1, "framework=1 App 按钮→返回1");
|
||||
CHECK(term_count_app("dev.player.ncm") == 1, "framework=1 App 按钮→真 terminate(无反应 bug 回归)");
|
||||
/* 真·受保护行(未来系统组件/前台):按钮命中但不 terminate,且留诊断日志 */
|
||||
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002);
|
||||
bgm_rows(&b, rows, SUB_ROWS_MAX);
|
||||
b.rows[1].protected_app = '1';
|
||||
g_log_n = 0;
|
||||
CHECK(bgm_row_tap(&b, 1, &rows[1], 445, 28, 0, 0, 480, 56) == 1, "受保护行按钮→返回1(重绘)");
|
||||
CHECK(term_count_app("os.meow.sysguard") == 0, "受保护行按钮→不 terminate");
|
||||
CHECK(g_log_n >= 1, "受保护行按钮→留一行诊断日志(可查「点了没反应」)");
|
||||
|
||||
printf("=== P3b) 空态行不响应 ===\n");
|
||||
bg_reset(0, NULL, 0, NULL, 0);
|
||||
int rn0 = bgm_rows(&b, rows, SUB_ROWS_MAX);
|
||||
CHECK(rn0 == 1 && rows[0].act == -1, "0 后台 App → 1 空态行(act=-1)");
|
||||
CHECK(bgm_row_tap(&b, 0, &rows[0], 445, 28, 0, 0, 480, 56) == 0, "空态行点击→返回0(不杀)");
|
||||
g_term_calls = 0;
|
||||
|
||||
printf("=== P4) 顶栏清除(一键清除后台)不杀受保护/前台 ===\n");
|
||||
bg_reset(2, "os.meow.hello", 1001, "os.meow.sysguard", 1002);
|
||||
bgm_rows(&b, rows, SUB_ROWS_MAX);
|
||||
b.rows[1].protected_app = '1';
|
||||
CHECK(bgm_top_action(&b) == 1, "顶栏清除按钮:返回 1(重绘约定)");
|
||||
CHECK(term_count_app("os.meow.hello") == 1, "清除→terminate 普通后台 hello");
|
||||
CHECK(term_count_app("os.meow.sysguard") == 0, "清除→不杀受保护 sysguard");
|
||||
/* 回归:framework=1 的 SDK 框架 App 也在「一键清」范围内 */
|
||||
bg_reset(2, "os.meow.hello", 1001, "dev.player.ncm", 7520);
|
||||
bgm_rows(&b, rows, SUB_ROWS_MAX);
|
||||
g_term_calls = 0;
|
||||
CHECK(bgm_top_action(&b) == 1, "framework=1 App:一键清返回 1");
|
||||
CHECK(term_count_app("dev.player.ncm") == 1, "一键清→terminate framework=1 App(回归)");
|
||||
/* 前台 App(is_fg)不杀:给 hello 标前台再清 */
|
||||
bg_reset(1, "os.meow.hello", 1001, NULL, 0);
|
||||
bgm_rows(&b, rows, SUB_ROWS_MAX);
|
||||
for (int i = 0; i < b.n; i++) if (!strcmp(b.rows[i].app_id, "os.meow.hello")) b.rows[i].is_fg = 1;
|
||||
g_term_calls = 0;
|
||||
CHECK(bgm_top_action(&b) == 1, "前台 App 仍在:顶栏清除返回 1(重绘)");
|
||||
CHECK(g_term_calls == 0, "前台 App → kill_all 不杀(0 terminate)");
|
||||
CHECK(bgm_top_action(&b) == 1, "二次清除(仍前台/不可杀):返回 1");
|
||||
CHECK(g_term_calls == 0, "二次清除 → 仍不 terminate");
|
||||
|
||||
printf("=== P5) 门控矩阵(bgmgr_can_kill) ===\n");
|
||||
{
|
||||
bgmgr_row_t r; memset(&r, 0, sizeof r);
|
||||
r.pid = 5; CHECK(bgmgr_can_kill(&r) == 1, "普通后台可杀");
|
||||
r.protected_app = '1'; CHECK(bgmgr_can_kill(&r) == 0, "protected_app 不可杀");
|
||||
r.protected_app = 0; r.is_fg = 1; CHECK(bgmgr_can_kill(&r) == 0, "前台不可杀");
|
||||
r.is_fg = 0; r.pid = 0; CHECK(bgmgr_can_kill(&r) == 0, "pid<=0 不可杀");
|
||||
}
|
||||
|
||||
if (g_fails == 0) printf("\nALL PASS (%d checks)\n", g_checks);
|
||||
else printf("\n%d FAILED\n", g_fails);
|
||||
return g_fails ? 1 : 0;
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
* should_keep=0(停播后再退出=真退);
|
||||
* 3) 后台 App 崩溃照收:子进程被杀 → bg_tick WNOHANG 回收 → on_bg_dead + 清槽;
|
||||
* 4) 卡拔例外:on_card=1 + card_present 假 0 → tick SIGTERM 回收(防 SIGBUS);
|
||||
* 4b) meow_bg_terminate(后台管理页「杀」按钮 / `am kill` 共用通道):受理置 term_at_ms →
|
||||
* tick SIGTERM 回收 + on_bg_dead;不在表/空 id 分别 NOENT/INVAL;
|
||||
* 4b) 批①+宽限:未声明(keepalive=0)媒体不活跃超 MEOW_BG_GRACE_MS 回收(含恰等边界不回收);
|
||||
* 声明常驻(keepalive=1)或媒体活跃(META PLAYING)超 grace 不清;
|
||||
* 5) bg 表满(MEOW_BG_MAX)→ LRU 回收最久未激活的未声明槽新增成功;全为声明常驻 → MEOW_ERR_NOMEM
|
||||
@@ -138,11 +140,17 @@ static int cond_svc_gone(void *ctx)
|
||||
return meow_svc_find("dev.test.svc", "daemon") == NULL;
|
||||
}
|
||||
|
||||
/* 服务测试用长命可执行:shell 脚本 exec sleep 300(svc_spawn 内部 execve 走 shebang)。 */
|
||||
#define TEST_SVC_PATH "/tmp/meow_bgplay_test_svc.sh"
|
||||
/* 服务测试用长命可执行:shell 脚本 exec sleep 300(svc_spawn 内部 execve 走 shebang)。
|
||||
* 路径取 $TMPDIR(Termux/Android 主机没有 /tmp),缺省回退 /tmp。 */
|
||||
static char g_svc_path[256];
|
||||
#define TEST_SVC_PATH g_svc_path
|
||||
static void write_test_svc(void)
|
||||
{
|
||||
const char *tmp = getenv("TMPDIR");
|
||||
if (!tmp || !tmp[0]) tmp = "/tmp";
|
||||
snprintf(g_svc_path, sizeof g_svc_path, "%s/meow_bgplay_test_svc.sh", tmp);
|
||||
FILE *f = fopen(TEST_SVC_PATH, "w");
|
||||
if (!f) { printf("FAIL: 无法创建测试脚本 %s\n", TEST_SVC_PATH); exit(1); }
|
||||
fputs("#!/bin/sh\nexec sleep 300\n", f);
|
||||
fclose(f);
|
||||
chmod(TEST_SVC_PATH, 0755);
|
||||
@@ -271,6 +279,31 @@ int main(void)
|
||||
"卡拔 → 卡上后台 App SIGTERM 回收(防 SIGBUS)");
|
||||
g_card = 1;
|
||||
|
||||
/* ---- 4b) meow_bg_terminate:后台管理页「杀」按钮 / `am kill` 共用的终止通道 ---- */
|
||||
{
|
||||
g_bg_dead = 0;
|
||||
pid_t tk = spawn_sleeper();
|
||||
CHECK(meow_bg_add("dev.test.term", tk, -1, 0, 0) == 0, "bg_add(term)");
|
||||
CHECK(meow_bg_terminate("") == MEOW_ERR_INVAL, "terminate 空 app_id → INVAL");
|
||||
CHECK(meow_bg_terminate("dev.test.nothere") == MEOW_ERR_NOENT, "terminate 不在后台表 → NOENT");
|
||||
CHECK(meow_bg_terminate("dev.test.term") == 0, "terminate 受理(在后台表且 pid>0)");
|
||||
{
|
||||
int found = 0;
|
||||
for (int i = 0; i < MEOW_BG_MAX; i++) {
|
||||
const meow_bg_entry_t *be = meow_bg_at(i);
|
||||
if (be && !strcmp(be->app_id, "dev.test.term")) {
|
||||
CHECK(be->term_at_ms > 0, "terminate 置 term_at_ms(bgm_enumerate 据此隐藏行)");
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
CHECK(found == 1, "terminate 后槽仍在(等 tick 回收)");
|
||||
}
|
||||
pump_bg_until(cond_bg_dead, NULL);
|
||||
CHECK(g_bg_dead == 1 && strcmp(g_bg_dead_app, "dev.test.term") == 0,
|
||||
"terminate → tick SIGTERM 回收 + on_bg_dead 上报 amsd");
|
||||
CHECK(meow_bg_count() == 0, "terminate 回收后清槽");
|
||||
}
|
||||
|
||||
/* ---- 5) 表满:LRU 回收最久未激活的未声明槽 / 全常驻满→NOMEM / 重复 / 参数 ---- */
|
||||
{
|
||||
pid_t k[MEOW_BG_MAX];
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/* test_key_router_vol.c — 宿主单测:launcher-UI 页物理键统一路由(KEY_ROUTER_UNIFY P1)。
|
||||
*
|
||||
* 测「key_router 自身的音量编排」——它只产「音量边沿 + 原始 held + ramp 状态」,
|
||||
* 由调用页决定 settings_volume_adj(±step) / 光标导航 / ramp 推进。本测用内存桩:
|
||||
* - kp_poll/kp_open/kp_is_down → 注入 raw 键位矩阵到 key_router 的静态 kp_keys.cur[];
|
||||
* - kd_tick → 用 keys.c 的真实现(40ms 防抖,V4 依赖);keys.c 内 kp_open/kp_poll/
|
||||
* kp_is_down/meow_volpage_armed 用宏重命名避开真实 ioctl 与桩冲突;
|
||||
* - settings_volume_adj/settings_volume_step/vol_ramp_dir/vol_ramp_tick → 内存桩;
|
||||
* - now_ms → 假时钟(手动推进,40ms 防抖可瞬时测);
|
||||
* - meow_ui_activity_* → 内存桩(V5 嵌套:真语义=纯静态数组,桩复刻)。
|
||||
*
|
||||
* 断言:
|
||||
* V1 音量减边沿 → settings_volume_adj(-step) 恰一次 + last_input 复位;
|
||||
* V2 同会话重复(同 raw 仍按住)→ 不再触发 settings_volume_adj;
|
||||
* V3 长按 held → vol_ramp_tick 被驱动(vol_ramp_dir!=0,held=正确方向 raw);松开 → dir=0;
|
||||
* V4 40ms 防抖:raw 在 40ms 内抖动 → 不产边沿;稳定按下 ≥40ms → 才产边沿;
|
||||
* V5 嵌套返回:page_enter(0x53)→page_exit(0x53)→page_enter(0x55),基线重播后 held 键不产假边沿;
|
||||
* V6 meow_volpage_armed() → 音量边沿被抑制(不下发,不调 settings_volume_adj)。
|
||||
*
|
||||
* 编译(在 patch_v2/meow_launcherd 下):
|
||||
* gcc -Wall -Wextra -I../sdk -I. -ffunction-sections -fdata-sections \
|
||||
* -Wl,--gc-sections test_key_router_vol.c -o test_key_router_vol
|
||||
* ./test_key_router_vol
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* ---- 假时钟 ---- */
|
||||
static unsigned long long g_now = 100000;
|
||||
unsigned long long now_ms(void) { return g_now; }
|
||||
static void advance_ms(unsigned long long d) { g_now += d; }
|
||||
|
||||
/* ---- 音量机外部符号桩(key_router 依赖)---- */
|
||||
int settings_volume_step = 5;
|
||||
static int g_adj_count = 0, g_adj_delta = 0;
|
||||
int vol_ramp_dir = 0; /* 桩全局:模拟 audio.c 的 ramp 状态 */
|
||||
static int g_tick_count = 0, g_tick_held = -1;
|
||||
|
||||
void settings_volume_adj(int delta)
|
||||
{
|
||||
g_adj_count++; g_adj_delta = delta;
|
||||
if (delta > 0) vol_ramp_dir = 1; else if (delta < 0) vol_ramp_dir = -1;
|
||||
}
|
||||
|
||||
int vol_ramp_tick(int key_held)
|
||||
{
|
||||
g_tick_count++; g_tick_held = key_held;
|
||||
if (!key_held) vol_ramp_dir = 0;
|
||||
return vol_ramp_dir != 0;
|
||||
}
|
||||
|
||||
static int g_volpage = 0; /* 桩:meow_volpage_armed */
|
||||
|
||||
/* ---- keys.c 内 kp_open/kp_poll/kp_is_down/meow_volpage_armed 用宏重命名,避开真实 ioctl ----
|
||||
* (宏在 #include keys.c 前生效,仅重命名 keys.c 体内的调用/定义) ---- */
|
||||
#define kp_open kp_open_rt_
|
||||
#define kp_poll kp_poll_rt_
|
||||
#define kp_is_down kp_is_down_rt_
|
||||
#define meow_volpage_armed meow_volpage_armed_rt_
|
||||
#include "keys.c" /* 真 kd_tick(40ms 防抖素材) */
|
||||
#undef kp_open
|
||||
#undef kp_poll
|
||||
#undef kp_is_down
|
||||
#undef meow_volpage_armed
|
||||
|
||||
/* host 桩声明(key_router.c 需要;恢复到正常名,与 keys.c 被更名后的定义不冲突) */
|
||||
struct keypoll;
|
||||
int kp_open(struct keypoll *kp, const char *dev);
|
||||
int kp_poll(struct keypoll *kp, int (*cb)(int code, int down, void *ctx), void *ctx);
|
||||
int kp_is_down(const struct keypoll *k, int code);
|
||||
int meow_volpage_armed(void);
|
||||
|
||||
#include "key_router.c" /* 同 TU #include:拿到静态 kp_keys/kp_power/kd_vold/kd_volu + 全部函数 */
|
||||
#include "key_router.h"
|
||||
|
||||
/* ---- meow_ui_activity_* 桩(复刻 sdk/meow_ui_activity.c:纯静态数组,真语义)---- */
|
||||
static int g_act[32]; static int g_act_n = 0;
|
||||
void meow_ui_activity_push(int id) { if (g_act_n < 32) g_act[g_act_n++] = id; }
|
||||
void meow_ui_activity_pop(void) { if (g_act_n > 0) g_act_n--; }
|
||||
int meow_ui_activity_active(void) { return g_act_n > 0 ? g_act[g_act_n - 1] : 0; }
|
||||
|
||||
/* ---- 注入 raw 到 key_router 静态 kp_keys ---- */
|
||||
static void set_vol(int vold, int volu)
|
||||
{
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) kp_keys.cur[w] = 0;
|
||||
if (vold) kp_keys.cur[KEY_VOLUMEDOWN / 32] |= (1UL << (KEY_VOLUMEDOWN % 32));
|
||||
if (volu) kp_keys.cur[KEY_VOLUMEUP / 32] |= (1UL << (KEY_VOLUMEUP % 32));
|
||||
}
|
||||
|
||||
/* ---- host 桩:kp_*(prev=cur 模拟真实 poll 的键位更新)+ meow_volpage_armed ---- */
|
||||
int kp_open(struct keypoll *kp, const char *dev)
|
||||
{ (void)dev; memset(kp, 0, sizeof *kp); kp->ok = 1; return 0; }
|
||||
int kp_poll(struct keypoll *kp, int (*cb)(int, int, void *), void *ctx)
|
||||
{
|
||||
(void)cb; (void)ctx;
|
||||
for (int w = 0; w < KEYBUF_WORDS; w++) kp->prev[w] = kp->cur[w];
|
||||
return 0;
|
||||
}
|
||||
int kp_is_down(const struct keypoll *k, int code)
|
||||
{ return (k->cur[code / 32] >> (code % 32)) & 1; }
|
||||
int meow_volpage_armed(void) { return g_volpage; }
|
||||
|
||||
/* 其它全局桩(keys.c 内被更名函数引用;本测 GC 后不保留,仅满足声明一致性) */
|
||||
uint32_t menu_bg = 0;
|
||||
int screen_off_timeout = 0, screen_off_mode = 0, power_longpress_menu_ms = 0;
|
||||
int power_forceoff_ms = 0, refresh_rate_cfg = 60, refresh_always_max = 0;
|
||||
|
||||
void logmsg(const char *fmt, ...) { (void)fmt; }
|
||||
|
||||
static int g_fails = 0;
|
||||
#define CHECK(cond, name) do { \
|
||||
if (cond) printf(" PASS %s\n", name); \
|
||||
else { printf(" FAIL %s (%s:%d)\n", name, __FILE__, __LINE__); g_fails++; } } while (0)
|
||||
|
||||
static void reset_router(void)
|
||||
{
|
||||
set_vol(0, 0);
|
||||
key_router_rebase();
|
||||
g_adj_count = 0; g_adj_delta = 0; g_tick_count = 0; g_tick_held = -1;
|
||||
vol_ramp_dir = 0; g_volpage = 0;
|
||||
g_act_n = 0;
|
||||
}
|
||||
|
||||
/* 按下并稳定:register(pump) → hold ≥40ms → pump(stable=1, 边沿)。 */
|
||||
static key_evt_t press_settle(int vold, int volu)
|
||||
{
|
||||
set_vol(vold, volu);
|
||||
key_router_pump(); /* 登记 raw 跳变(防抖窗口起点) */
|
||||
advance_ms(50); /* ≥KEY_DEBOUNCE_MS(40) */
|
||||
return key_router_pump(); /* stable→1,边沿 */
|
||||
}
|
||||
|
||||
/* 抬起并稳定:register(pump) → → pump(stable=0)。 */
|
||||
static key_evt_t release_settle(void)
|
||||
{
|
||||
set_vol(0, 0);
|
||||
key_router_pump(); /* 登记 release */
|
||||
advance_ms(50);
|
||||
return key_router_pump(); /* stable→0 */
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
unsigned long long last_input = 0;
|
||||
|
||||
/* ---- V1 音量减边沿 → settings_volume_adj(-step) 恰一次 + last_input 复位 ---- */
|
||||
reset_router();
|
||||
set_vol(0, 0); advance_ms(10); key_router_pump(); /* 全抬起基线 */
|
||||
last_input = 0;
|
||||
key_evt_t ev = press_settle(1, 0);
|
||||
CHECK(ev.vol_dn_edge == 1, "V1 vol-down edge fires");
|
||||
key_router_default_volume(&ev, &last_input);
|
||||
CHECK(g_adj_count == 1 && g_adj_delta == -settings_volume_step,
|
||||
"V1 vol-down -> settings_volume_adj(-step) exactly once");
|
||||
CHECK(last_input == g_now, "V1 last_input reset on edge");
|
||||
|
||||
/* ---- V2 同会话重复(同 raw 仍按住)→ 不再触发 ---- */
|
||||
reset_router();
|
||||
set_vol(0, 0); advance_ms(10); key_router_pump();
|
||||
ev = press_settle(1, 0);
|
||||
CHECK(ev.vol_dn_edge == 1, "V2a press edge fires");
|
||||
ev = key_router_pump(); /* 同 raw 仍按住,无新边沿 */
|
||||
CHECK(ev.vol_dn_edge == 0, "V2 held: no re-trigger edge");
|
||||
key_router_default_volume(&ev, &last_input);
|
||||
CHECK(g_adj_count == 0, "V2 held: no double settings_volume_adj");
|
||||
|
||||
/* ---- V3 长按 held → vol_ramp_tick 被驱动(held=正确方向 raw);松开 → dir=0 ---- */
|
||||
reset_router();
|
||||
set_vol(0, 0); advance_ms(10); key_router_pump(); last_input = 0;
|
||||
ev = press_settle(1, 0); /* 按下(vol down)并转稳定 */
|
||||
key_router_default_volume(&ev, &last_input);
|
||||
CHECK(g_tick_count == 1 && g_tick_held == ev.vol_held_dn && vol_ramp_dir != 0,
|
||||
"V3 hold vol-down -> ramp driven with held=raw down");
|
||||
advance_ms(50);
|
||||
ev = key_router_pump(); /* 继续长按(raw=1 仍按住) */
|
||||
key_router_default_volume(&ev, &last_input);
|
||||
CHECK(g_tick_count == 2 && g_tick_held == 1, "V3 long-press continues ramp");
|
||||
ev = release_settle(); /* 松开:raw held 立即为 0 */
|
||||
key_router_default_volume(&ev, &last_input);
|
||||
CHECK(vol_ramp_dir == 0, "V3 release -> ramp dir 0 (stops)");
|
||||
|
||||
/* ---- V4 40ms 防抖:raw 在 40ms 内抖动不产边沿;稳定 ≥40ms 才产 ---- */
|
||||
reset_router();
|
||||
set_vol(0, 0); advance_ms(10); key_router_pump();
|
||||
set_vol(1, 0); advance_ms(20); key_router_pump(); /* 按下未满 40ms */
|
||||
set_vol(0, 0); advance_ms(20); key_router_pump(); /* 40ms 内又抬起 */
|
||||
ev = key_router_pump();
|
||||
CHECK(ev.vol_dn_edge == 0, "V4 <40ms jitter -> no edge");
|
||||
ev = press_settle(1, 0); /* 稳定按下 ≥40ms */
|
||||
CHECK(ev.vol_dn_edge == 1, "V4 stable >=40ms press -> edge");
|
||||
ev = release_settle(); /* 抬起稳定 */
|
||||
CHECK(ev.vol_dn_edge == 0 && ev.vol_up_edge == 0, "V4 released -> no residual edge");
|
||||
ev = press_settle(0, 1); /* 稳定按音量加 */
|
||||
CHECK(ev.vol_up_edge == 1, "V4 vol-up stable press -> up edge");
|
||||
|
||||
/* ---- V5 嵌套返回:page_enter/exit 重播基线,held 键不产假边沿 ---- */
|
||||
reset_router();
|
||||
meow_ui_activity_push(0x55); key_router_page_enter(0x55); /* 主菜单 */
|
||||
set_vol(0, 0); advance_ms(10); key_router_pump();
|
||||
ev = press_settle(1, 0);
|
||||
CHECK(ev.vol_dn_edge == 1, "V5a press edge in menu");
|
||||
/* 嵌套进设置页:压栈 + 登记 */
|
||||
meow_ui_activity_push(0x53); key_router_page_enter(0x53);
|
||||
CHECK(meow_ui_activity_active() == 0x53, "V5b settings active sentinel");
|
||||
ev = release_settle(); /* 设置页内把键抬起 */
|
||||
CHECK(ev.vol_dn_edge == 0, "V5c settings-up: no fresh edge (consumer)");
|
||||
/* 设置页返回(模拟 run_settings_page 结束):弹栈 + 重播基线 → m_prev=当前 raw(0) */
|
||||
key_router_page_exit(0x53); meow_ui_activity_pop();
|
||||
key_router_page_enter(0x55);
|
||||
CHECK(meow_ui_activity_active() == 0x55, "V5d menu active again");
|
||||
/* 主菜单基线已重播;再次按下应为新边沿,而非残留/双发 */
|
||||
ev = press_settle(1, 0);
|
||||
CHECK(ev.vol_dn_edge == 1, "V5e fresh press after nest return -> single edge");
|
||||
|
||||
/* ---- V6 meow_volpage_armed() → 音量边沿被抑制(不下发)---- */
|
||||
reset_router();
|
||||
set_vol(0, 0); advance_ms(10); key_router_pump();
|
||||
g_volpage = 1;
|
||||
ev = press_settle(1, 0);
|
||||
CHECK(ev.vol_dn_edge == 0, "V6 volpage armed -> edge suppressed");
|
||||
key_router_default_volume(&ev, &last_input);
|
||||
CHECK(g_adj_count == 0, "V6 volpage armed -> settings_volume_adj NOT called");
|
||||
g_volpage = 0;
|
||||
|
||||
printf("\nRESULT: %s (%d fails)\n", g_fails ? "FAIL" : "ALL PASS", g_fails);
|
||||
return g_fails ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/* meow_launcherd/test_menu_row_gap.c — 宿主验证:主界面列表模式圆角卡列表项外边距
|
||||
* (item margin/gap)几何。纯整数运算,不链 SDK / 不开设备:
|
||||
* gcc test_menu_row_gap.c -o test_menu_row_gap && ./test_menu_row_gap
|
||||
*
|
||||
* 断言集(对应「相邻 item 顶之间 = item 高 + gap」的可见形式):
|
||||
* ① 相邻卡顶间距 = MENU_LIST_ROW_H(= 卡高 + gap;SDK 行距不变);
|
||||
* ② 卡片间可见外距 = MENU_LIST_ROW_GAP;
|
||||
* ③ 卡在其行高内上下对称内缩 → 内容(按行高垂直居中)落卡中心;
|
||||
* ④ 滚动偏移只整体平移,不改变上述间距/对称。
|
||||
*
|
||||
* 几何公式与 patch_v2/meow_launcherd/main_menu.c 的 menu_row_card_y/menu_row_card_h 同源:
|
||||
* 卡顶 = 行顶 + gap/2;卡高 = 行高 - gap。
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <assert.h>
|
||||
|
||||
#define MENU_LIST_ROW_H 60 /* 行距(SDK listview item_h,不变) */
|
||||
#define MENU_LIST_ROW_GAP 8
|
||||
|
||||
/* 与 main_menu.c menu_row_card_y / menu_row_card_h 同源(上下对称内缩)。 */
|
||||
static int menu_row_card_y(int row_top) { return row_top + MENU_LIST_ROW_GAP / 2; }
|
||||
static int menu_row_card_h(int h) { return h - MENU_LIST_ROW_GAP; }
|
||||
/* 行顶 = list_y + idx*item(SDK listview 布局:行距=item)。 */
|
||||
static int row_top(int idx, int item, int list_y, int off) { return list_y + idx * item - off; }
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const int item = MENU_LIST_ROW_H;
|
||||
const int gap = MENU_LIST_ROW_GAP;
|
||||
const int list_y = 100;
|
||||
|
||||
int y0 = menu_row_card_y(row_top(0, item, list_y, 0));
|
||||
int y1 = menu_row_card_y(row_top(1, item, list_y, 0));
|
||||
int h = menu_row_card_h(item);
|
||||
|
||||
/* ① 卡相对行顶对称内缩 gap/2;卡高=item-gap;卡高+gap=item */
|
||||
assert(y0 == list_y + gap / 2);
|
||||
assert(h == item - gap);
|
||||
assert(h + gap == item);
|
||||
|
||||
/* ① 相邻卡顶间距 = item(顶到顶 = 卡高 + gap) */
|
||||
assert(y1 - y0 == item);
|
||||
|
||||
/* ② 卡片间可见外距 = gap(卡 0 底 到 卡 1 顶) */
|
||||
assert(y1 - (y0 + h) == gap);
|
||||
|
||||
/* ③ 卡中心 == 行中心(内容按行高居中 → 落卡中心) */
|
||||
assert(y0 - gap / 2 + item / 2 == y0 + h / 2);
|
||||
/* 上行底到卡底 = gap/2(下对称) */
|
||||
assert((list_y + item) - (y0 + h) == gap / 2);
|
||||
|
||||
/* ④ 滚动偏移只平移,不改变间距/对称(任意 off) */
|
||||
for (int off = 0; off <= 2 * item; off += item / 2) {
|
||||
int r2 = row_top(2, item, list_y, off);
|
||||
int r3 = row_top(3, item, list_y, off);
|
||||
int a2 = menu_row_card_y(r2);
|
||||
int a3 = menu_row_card_y(r3);
|
||||
assert(a3 - a2 == item);
|
||||
assert(a3 - (a2 + h) == gap);
|
||||
assert(a2 - r2 == gap / 2);
|
||||
assert((r2 + item) - (a2 + h) == gap / 2);
|
||||
}
|
||||
|
||||
printf("PASS menu_row_gap=%d card_h=%d top2top=%d visible_gap=%d symmetric=1\n",
|
||||
gap, h, item, gap);
|
||||
return 0;
|
||||
}
|
||||
@@ -308,6 +308,50 @@ int main(void)
|
||||
CHECK(np_gesture_decide(NP_PAGE_PLAY, 200, 300, 5, 80) == NP_GEST_NONE,
|
||||
"vertical dominant -> NONE");
|
||||
|
||||
/* ============ G5b 批② 底部上滑(home)手势(np_gesture.h np_home_gesture,纯逻辑) ============ */
|
||||
printf("G5b bottom-swipe-up home gesture\n");
|
||||
const int FBRES = 720; /* 屏高(宿主 480x720;真机 fb_yres=720) */
|
||||
const int BZ = FBRES - NP_HOME_EDGE_H; /* 底部起滑区上界 692 */
|
||||
/* 底部起滑 + 向上主导 ≥阈值 → HOME */
|
||||
CHECK(np_home_gesture(200, BZ + 10, 0, -120, FBRES) == 1,
|
||||
"home: bottom zone + up-swipe -> HOME");
|
||||
CHECK(np_home_gesture(100, FBRES - 1, 20, -80, FBRES) == 1,
|
||||
"home: very bottom + slight right-drift up -> HOME");
|
||||
/* 与左缘返回互斥:左缘起点(down_x<NP_EDGE_W)不认 home(让给左缘返回) */
|
||||
CHECK(np_home_gesture(10, BZ + 10, 0, -120, FBRES) == 0,
|
||||
"home: left-edge origin -> NOT home (reserved for edge-back)");
|
||||
/* 起点不在底部起滑区(顶部/中部)不认 home */
|
||||
CHECK(np_home_gesture(200, 10, 0, -120, FBRES) == 0,
|
||||
"home: top origin -> NOT home");
|
||||
CHECK(np_home_gesture(200, 300, 0, -120, FBRES) == 0,
|
||||
"home: mid origin -> NOT home");
|
||||
/* 横向主导(向右滑)不认 home(该判给左缘返回/切页) */
|
||||
CHECK(np_home_gesture(200, BZ + 10, 120, 0, FBRES) == 0,
|
||||
"home: right-dominant -> NOT home");
|
||||
/* 向下滑 / 未达阈值 / 竖向不主导 不认 home */
|
||||
CHECK(np_home_gesture(200, BZ + 10, 0, 120, FBRES) == 0,
|
||||
"home: downward -> NOT home");
|
||||
CHECK(np_home_gesture(200, BZ + 10, 0, -NP_HOME_SWIPE + 1, FBRES) == 0,
|
||||
"home: below up-threshold -> NOT home");
|
||||
CHECK(np_home_gesture(200, BZ + 10, -120, -80, FBRES) == 0,
|
||||
"home: horizontal-dominant up -> NOT home");
|
||||
/* 与 now_playing.c 集成顺序一致:np_gesture_decide 对底部纵滑返回 NONE,再由 home 覆盖为 HOME */
|
||||
{
|
||||
int dx = 0, dy = -120, tdx = 200, tdy = BZ + 10;
|
||||
np_gest_t gnp = np_gesture_decide(NP_PAGE_PLAY, tdx, tdy, dx, dy);
|
||||
if (np_home_gesture(tdx, tdy, dx, dy, FBRES)) gnp = NP_GEST_HOME;
|
||||
CHECK(gnp == NP_GEST_HOME,
|
||||
"integration: bottom up-swipe -> HOME (overrides decide-NONE)");
|
||||
}
|
||||
/* 集成:左缘起点底部上滑不被误判为 home(保留给左缘返回) */
|
||||
{
|
||||
int tdx = 8, tdy = BZ + 10, dx = 0, dy = -120; /* 左缘 + 上滑(竖向主导) */
|
||||
np_gest_t gnp = np_gesture_decide(NP_PAGE_PLAY, tdx, tdy, dx, dy);
|
||||
if (np_home_gesture(tdx, tdy, dx, dy, FBRES)) gnp = NP_GEST_HOME;
|
||||
CHECK(gnp != NP_GEST_HOME,
|
||||
"integration: left-edge origin up-swipe -> NOT home");
|
||||
}
|
||||
|
||||
/* ============ G6 歌词页:空态 + 渲染(批2 控件 + 批3 数据语义) ============ */
|
||||
printf("G6 lyric view: empty + render\n");
|
||||
meow_ui_activity_push(NP_SENTINEL);
|
||||
|
||||
@@ -48,6 +48,7 @@ static void np_cover_load(const meow_media_session_t *s)
|
||||
{
|
||||
if (!s) return;
|
||||
if (g_np_cover && strcmp(g_np_cover_path, s->artwork) == 0) return; /* 已载且未变 → 跳过 */
|
||||
if (!s->artwork[0]) return; /* 镜像 now_playing.c 2026-09-04:空 artwork 保留上一张封面(勿清) */
|
||||
if (g_np_cover) { meow_img_free(g_np_cover); g_np_cover = NULL; }
|
||||
snprintf(g_np_cover_path, sizeof g_np_cover_path, "%s", s->artwork);
|
||||
if (!s->artwork[0]) return;
|
||||
@@ -135,12 +136,12 @@ int main(void)
|
||||
np_cover_load(&s);
|
||||
CHECK(g_np_cover == keep, "same path unchanged -> cache hit (no reload)");
|
||||
|
||||
/* ============ C2 artwork 空 → 封面清空(回退主题占位) ============ */
|
||||
printf("C2 artwork empty -> cover cleared (theme placeholder)\n");
|
||||
/* ============ C2 artwork 空 → 封面保留(用户 2026-09-04:勿因空 artwork 清封面) ============ */
|
||||
printf("C2 artwork empty -> cover kept (NOT cleared)\n");
|
||||
memset(s.artwork, 0, sizeof s.artwork);
|
||||
np_cover_load(&s);
|
||||
CHECK(g_np_cover == NULL, "artwork empty -> cover NULL (theme placeholder)");
|
||||
CHECK(g_np_cover_path[0] == 0, "empty -> cached path reset to \"\"");
|
||||
CHECK(g_np_cover == keep, "artwork empty -> cover kept (non-NULL, NOT cleared)");
|
||||
CHECK(strcmp(g_np_cover_path, pa) == 0, "empty -> cached path kept (still pa)");
|
||||
|
||||
/* ============ C3 artwork 从空复原(同路径)→ 封面重载(M6.33 修复) ============ */
|
||||
printf("C3 artwork restored (same path) -> cover reloads (M6.33)\n");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -186,23 +186,29 @@ static void lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx)
|
||||
uint32_t textcol = sel ? mt->onPrimaryContainer : mt->onSurface;
|
||||
uint32_t subcol = sel ? mt->onPrimaryContainer : mt->onSurfaceVariant;
|
||||
|
||||
/* MD3 容器化(P0):行=surface 上 surfaceContainer 圆角卡;段头=surface 上的 primary 段标签(无卡
|
||||
* 无分隔线);与 settings_page.c set_lv_draw 同构造。 */
|
||||
int cx = x + SET_CARD_X;
|
||||
int cw = w - 2 * SET_CARD_X;
|
||||
int cy = y + 8 / 2;
|
||||
int ch = SET_ITEM_H - 8;
|
||||
if (r->ctrl == SC_HEADER) {
|
||||
meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->surfaceContainerHigh);
|
||||
int ty = y + (SET_ITEM_H - thh) / 2;
|
||||
if (ty >= 0) meow_ui_text(x + 16, ty, r->label, mt->font_body, mt->primary);
|
||||
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant);
|
||||
if (ty >= 0) meow_ui_text(cx + 16, ty, r->label, mt->font_body, mt->primary);
|
||||
return;
|
||||
}
|
||||
if (sel) meow_ui_fill_rect(x, y, w, SET_ITEM_H, mt->primaryContainer);
|
||||
else if (press) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->primary, 26);
|
||||
meow_gfx_fill_round(cx, cy, cw, ch, 12, mt->surfaceContainer);
|
||||
if (sel) meow_gfx_fill_round(cx, cy, cw, ch, 12, mt->primaryContainer);
|
||||
else if (press) meow_gfx_fill_round_alpha(cx, cy, cw, ch, 12, mt->primary, 26);
|
||||
int ly = y + (SET_ITEM_H - thh) / 2;
|
||||
int rgt = cx + cw - 16;
|
||||
switch (r->ctrl) {
|
||||
case SC_SWITCH: {
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_widget *sw = set_sw_take();
|
||||
if (sw) {
|
||||
int on = 0; meow_settings_get_switch(r->key, &on);
|
||||
meow_widget_set_rect(sw, x + w - 16 - 56, y + (SET_ITEM_H - 32) / 2, 56, 32);
|
||||
meow_widget_set_rect(sw, rgt - 56, y + (SET_ITEM_H - 32) / 2, 56, 32);
|
||||
meow_switch_set(sw, on);
|
||||
meow_widget_draw(sw);
|
||||
}
|
||||
@@ -211,26 +217,25 @@ static void lv_draw(int idx, int x, int y, int w, int h, int state, void *ctx)
|
||||
case SC_SLIDER: {
|
||||
/* 宿主测试不引入 launcher s_draw_slider/s_ui_theme:只验证行文字渲染 + 命中,track 视觉由
|
||||
* 真机 s_draw_slider 呈现(settings_page.c set_lv_draw 已复用)。 */
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
|
||||
char vb[32]; s_fmt_value(r, vb, sizeof vb);
|
||||
meow_ui_text(x + w - 16 - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol);
|
||||
meow_ui_text(rgt - meow_ui_text_width(vb, mt->font_body), ly, vb, mt->font_body, subcol);
|
||||
break;
|
||||
}
|
||||
case SC_LIST: {
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(x + w - 16 - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol);
|
||||
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(rgt - meow_ui_text_width("已连接", mt->font_body), ly, "已连接", mt->font_body, subcol);
|
||||
break;
|
||||
}
|
||||
default: { /* SC_ENUM / SC_PRESET */
|
||||
meow_ui_text(x + 16, ly, r->label, mt->font_body, textcol);
|
||||
meow_ui_text(cx + 16, ly, r->label, mt->font_body, textcol);
|
||||
char vb[40]; s_display_value(r, vb, sizeof vb);
|
||||
int vx = x + w - 16 - meow_ui_text_width(vb, mt->font_body);
|
||||
int vx = rgt - meow_ui_text_width(vb, mt->font_body);
|
||||
meow_ui_text(vx, ly, vb, mt->font_body, subcol);
|
||||
break;
|
||||
}
|
||||
}
|
||||
meow_ui_fill_rect(x, y + SET_ITEM_H - 1, w, 1, mt->outlineVariant);
|
||||
if (state & MEOW_LISTVIEW_ST_DISABLED) meow_ui_fill_rect_alpha(x, y, w, SET_ITEM_H, mt->onSurface, 90);
|
||||
if (state & MEOW_LISTVIEW_ST_DISABLED) meow_gfx_fill_round_alpha(cx, cy, cw, ch, 12, mt->onSurface, 90);
|
||||
}
|
||||
|
||||
static void mk_widgets(void)
|
||||
@@ -247,7 +252,7 @@ static void draw_all(void)
|
||||
{
|
||||
const meow_theme *th = meow_theme_get();
|
||||
meow_ui_fill_rect(0, 0, XRES, YRES, th->surface);
|
||||
meow_ui_fill_rect(0, SET_LIST_Y, XRES, LV_H, th->surfaceVariant);
|
||||
meow_ui_fill_rect(0, SET_LIST_Y, XRES, LV_H, th->surface); /* P0:内容底 surface(卡在其上抬升) */
|
||||
if (lv) { g_set_sw_use = 0; meow_widget_draw(lv); }
|
||||
}
|
||||
|
||||
@@ -305,16 +310,17 @@ int main(void)
|
||||
meow_ui_activity_push(SET_SENTINEL);
|
||||
const meow_theme *th = meow_theme_get();
|
||||
draw_all();
|
||||
/* 段头行整体填 surfaceContainerHigh(覆盖列表底 surfaceVariant)→ 对列表底背景即非背景;
|
||||
* 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「段头底 vs 列表底」判渲染而非文字像素。 */
|
||||
int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant);
|
||||
int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surfaceVariant);
|
||||
CHECK(hdrcnt > 0, "section header band renders (hdr=%d)", hdrcnt);
|
||||
CHECK(rowcnt > 40, "row band renders (title/value/switch) cnt=%d", rowcnt);
|
||||
/* P1b:开关行(ROWS[2]=最高刷新率 right switch)中心右缘区非背景 */
|
||||
/* MD3 容器化(P0):段头=surface 上的 primary 段标签(无满幅 band/卡,宿主文本为 no-op →
|
||||
* 段头区应回 surface);普通行=surfaceContainer 圆角卡(region_nonbg 应见大量非 surface 像素)。
|
||||
* 注意宿主无 mfont 时 CJK 标题回退点阵不显形,故以「卡/段头 vs 列表底」判渲染而非文字像素。 */
|
||||
int hdrcnt = region_nonbg(2, SET_LIST_Y + 4, XRES - 4, SET_ITEM_H - 8, th->surface);
|
||||
int rowcnt = region_nonbg(2, SET_LIST_Y + SET_ITEM_H + 4, XRES - 4, SET_ITEM_H - 8, th->surface);
|
||||
CHECK(hdrcnt == 0, "section header no-band(label-on-surface) nb=%d", hdrcnt);
|
||||
CHECK(rowcnt > 40, "row card renders (title/value/switch) cnt=%d", rowcnt);
|
||||
/* P1b:开关行(ROWS[2]=最高刷新率 right switch)中心右缘区非背景(卡内 switch) */
|
||||
{
|
||||
int swy = SET_LIST_Y + 2 * SET_ITEM_H + SET_ITEM_H / 2;
|
||||
int swc = region_nonbg(XRES - 16 - 56, swy - 4, 56, 8, th->surfaceVariant);
|
||||
int swc = region_nonbg(XRES - 16 - 56, swy - 4, 56, 8, th->surface);
|
||||
CHECK(swc > 0, "switch row right tail (meow_switch) renders swc=%d", swc);
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ static void mk_widgets(void)
|
||||
meow_listview_set_vsource(lv, sp_lv_count, sp_lv_bind, NULL);
|
||||
meow_listview_set_item_renderer(lv, 2, 0, 1);
|
||||
meow_listview_set_item_h(lv, SUB_ITEM_H);
|
||||
meow_listview_set_container(lv, 1); /* MD3 容器模式(M7.1a P0):圆角卡+空隙+去分隔线 */
|
||||
sw = meow_switch_create(XRES - 16 - 56, SUB_TB_Y + (SUB_TB_H - 32) / 2, 1);
|
||||
meow_ui_activity_pop();
|
||||
}
|
||||
@@ -153,12 +154,14 @@ int main(void)
|
||||
meow_ui_activity_push(SUB_SENTINEL);
|
||||
const meow_theme *th = meow_theme_get();
|
||||
draw_all();
|
||||
/* MD3 容器模式:段头=surface 上的 primary 段标签(无满幅 band/卡,宿主文本为 no-op →
|
||||
* 段头区应回 surface);普通行=surfaceContainer 圆角卡(region_nonbg 应见大量非 surface 像素)。 */
|
||||
int hdrcnt = region_nonbg(2, SUB_CONT_TOP + 4, XRES - 4, SUB_ITEM_H - 8, th->surface);
|
||||
int rowcnt = region_nonbg(2, SUB_CONT_TOP + SUB_ITEM_H + 4, XRES - 4, SUB_ITEM_H - 8, th->surface);
|
||||
char nm[80];
|
||||
snprintf(nm, sizeof nm, "section header renders (band=%d)", hdrcnt);
|
||||
CHECK(hdrcnt > 0, "%s", nm);
|
||||
snprintf(nm, sizeof nm, "row renders (title hdr/switch/arrow) cnt=%d", rowcnt);
|
||||
snprintf(nm, sizeof nm, "section header no-band(label-on-surface) nb=%d", hdrcnt);
|
||||
CHECK(hdrcnt == 0, "%s", nm);
|
||||
snprintf(nm, sizeof nm, "row renders (card) cnt=%d", rowcnt);
|
||||
CHECK(rowcnt > 40, "%s", nm);
|
||||
|
||||
/* ============ P2 listview 命中(行号 + right 操作区) ============ */
|
||||
@@ -190,6 +193,7 @@ int main(void)
|
||||
meow_listview_set_vsource(scroll_lv, big_count, big_bind, NULL);
|
||||
meow_listview_set_item_renderer(scroll_lv, 2, 0, 1);
|
||||
meow_listview_set_item_h(scroll_lv, SUB_ITEM_H);
|
||||
meow_listview_set_container(scroll_lv, 1); /* 与子页一致:MD3 容器模式 */
|
||||
meow_scroll *s = meow_scroll_wrap(scroll_lv, 1); /* 1=W_LISTVIEW */
|
||||
CHECK(s != NULL, "meow_scroll_wrap(listview)");
|
||||
int maxs = meow_listview_max_scroll(scroll_lv);
|
||||
|
||||
@@ -907,6 +907,52 @@ int meow_ipc_conn_alive(int fd)
|
||||
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
|
||||
* 循环 meow_notif_open_take 读取并清除)。仅当 g_app_session_active(App 前台会话)期间才置位,
|
||||
* 否则回 ERR(无 App 前台,不展开)。 */
|
||||
@@ -1099,6 +1145,7 @@ static void *ipc_thread(void *arg)
|
||||
if (ipc_conn_read(cc) < 0) {
|
||||
logmsg("ipc: conn %d closed", cc->fd);
|
||||
close(cc->fd);
|
||||
volpage_fd_clear_fd(cc->fd); /* E:清已断连接的翻页 fd 映射 */
|
||||
for (int j = i; j < nfds - 1; j++) {
|
||||
fds[j] = fds[j + 1];
|
||||
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);
|
||||
h.app_id[MEOW_APP_ID_MAX - 1] = 0;
|
||||
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;
|
||||
a.code = MEOW_OK;
|
||||
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);
|
||||
k.app_id[MEOW_APP_ID_MAX - 1] = 0;
|
||||
m_ack_t a;
|
||||
if (g_app_session_active) {
|
||||
a.status = 0;
|
||||
/* 先打后台保活表:后台 App 的终止由主循环单写者就地执行(meow_bg_terminate 置
|
||||
* term_at_ms + SIGTERM,meow_bg_tick 续 SIGKILL→reap→on_bg_dead 上报 amsd)——
|
||||
* 与后台管理页「杀」按钮**同一条通道**,故 `am kill` 与页面按钮现行为对称。
|
||||
* 此前只置 pending 交前台会话消费:用户不在 App 里时(桌面/后台页/面板)没有前台会话
|
||||
* → 只能回 -2(MEOW_ERR_NOENT),amsd 判「拒绝」不杀(ACK 及时)或超时后盲发
|
||||
* SIGTERM(ACK 迟到)——2026-11-04 真机 ACK 0xFFFFFFFE 即此,行为随主循环负载漂移。 */
|
||||
if (meow_bg_terminate(k.app_id) == 0) {
|
||||
a.code = MEOW_OK;
|
||||
} else if (g_app_session_active) {
|
||||
meow_ams_pending_kill_set(k.app_id);
|
||||
a.code = MEOW_OK;
|
||||
} else {
|
||||
a.code = (uint32_t)MEOW_ERR_NOENT; /* 无前台 App 可终止 */
|
||||
a.code = (uint32_t)MEOW_ERR_NOENT; /* 既非后台保活 App,也无前台 App 会话 */
|
||||
}
|
||||
a.status = 0;
|
||||
ipc_send(m->fd, MSG_ACK, m->req_id, &a, sizeof a);
|
||||
logmsg("ipc: AMS_KILL app=%s -> ACK code=%u", k.app_id, a.code);
|
||||
logmsg("ipc: AMS_KILL app=%s -> ACK code=%u(%d)", k.app_id, a.code, (int)a.code);
|
||||
break;
|
||||
}
|
||||
/* AMSD_DESIGN §6.2 二期:picker 跨 App 调用(App A → launcher → amsd 路由)。
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#define WF_ACT_CURR (-1) /* 当前连接(SUB_ROW,detail) */
|
||||
#define WF_ACT_SAVED 0 /* 已保存网络 idx(SUB_ACT,connect) */
|
||||
#define WF_ACT_SCAN_BASE 1000 /* 附近网络 1000+idx(SUB_ROW,连接) */
|
||||
#define WF_ACT_MANUAL 2000 /* 「手动添加隐藏网络」动作行(SUB_ACT,connecth) */
|
||||
|
||||
typedef struct {
|
||||
meow_net_t saved[S_NET_MAX];
|
||||
@@ -122,13 +123,22 @@ static int wifi_rows(void *ctx, sub_row_t *rbuf, int max)
|
||||
rbuf[n].on = 0; rbuf[n].act = WF_ACT_SCAN_BASE + i;
|
||||
n++;
|
||||
}
|
||||
/* bug2:附近网络末尾的「手动添加隐藏网络」入口(SUB_ACT → wifi_act)。 */
|
||||
if (n < max) {
|
||||
rbuf[n].kind = SUB_ACT;
|
||||
rbuf[n].label = "手动添加隐藏网络";
|
||||
rbuf[n].sub = "";
|
||||
rbuf[n].on = 0;
|
||||
rbuf[n].act = WF_ACT_MANUAL;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
|
||||
/* SDK IME 输密码(PW 模式=ASCII 软键盘输入,现有输入法复用;回车提交/ESC或点背景取消)。
|
||||
/* SDK IME 通用文本输入(MEOW_IME_ASCII:QWERTY,顶条「中」可切拼音输中文 SSID)。
|
||||
* 返回 1=已输入;0=取消。 */
|
||||
static int wifi_ask_psk(char *psk, int maxlen)
|
||||
static int wifi_ask_text(char *out, int maxlen)
|
||||
{
|
||||
meow_ime_ctx ic;
|
||||
memset(&ic, 0, sizeof ic);
|
||||
@@ -136,36 +146,72 @@ static int wifi_ask_psk(char *psk, int maxlen)
|
||||
ic.maxlen = maxlen;
|
||||
ic.buf[0] = 0;
|
||||
int res = meow_ui_text_input(&ic); /* 阻塞:提交后 ctx.buf 回填最终文本 */
|
||||
if (res && ic.buf[0]) { snprintf(psk, maxlen, "%s", ic.buf); return 1; }
|
||||
if (res && ic.buf[0]) { snprintf(out, maxlen, "%s", ic.buf); return 1; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/* bug1:connect 阻塞前画非模态「正在连接…」横幅。子页单线程,阻塞期间画面停在此横幅。 */
|
||||
static void wifi_connecting_banner(const char *ssid)
|
||||
{
|
||||
const meow_ui_theme_t *th = s_ui_theme();
|
||||
int W = meow_ui_width();
|
||||
int y = 108, h = 44;
|
||||
char line[160];
|
||||
snprintf(line, sizeof line, "正在连接 %s ...", ssid && ssid[0] ? ssid : "网络");
|
||||
meow_ui_fill_rect(0, y, W, h, th->surface_container);
|
||||
meow_ui_text_center(W / 2, y + h / 2 - meow_ui_text_height(20) / 2, line, 20, th->on_surface);
|
||||
meow_dirty_full();
|
||||
meow_ui_flush();
|
||||
}
|
||||
|
||||
|
||||
/* bug2:手动添加隐藏网络(附近网络占位行点击 / 「手动添加隐藏网络」动作行共用)。 */
|
||||
static void wifi_manual_add(wifi_ctx_t *c)
|
||||
{
|
||||
char ssid[96] = "";
|
||||
if (!wifi_ask_text(ssid, sizeof ssid)) return;
|
||||
const char *items[2];
|
||||
items[0] = "加密网络(WPA/WPA2/WPA3)";
|
||||
items[1] = "开放网络";
|
||||
int pick = s_dd_modal("网络加密方式", items, 2, -1);
|
||||
if (pick < 0) return;
|
||||
int open = (pick == 1);
|
||||
char psk[96] = "";
|
||||
if (!open && !wifi_ask_text(psk, sizeof psk)) return;
|
||||
char err[160]; err[0] = 0;
|
||||
wifi_connecting_banner(ssid);
|
||||
int rc = nm_wifi_connect_ex(ssid, open ? NULL : psk, 1, err, sizeof err);
|
||||
char line[192]; const char *txt[1]; txt[0] = line;
|
||||
snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败"));
|
||||
logmsg("wifi_page: manual connect ssid=%s hidden=1 open=%d rc=%d err=%s", ssid, open, rc, err);
|
||||
s_dd_modal(ssid, txt, 1, -1);
|
||||
nm_wifi_scan_trigger();
|
||||
c->last_scan_ms = now_ms();
|
||||
}
|
||||
|
||||
|
||||
/* 附近网络点选 →【WiFi 新网络录入】连接流程。返回 1=重绘。 */
|
||||
static int wifi_connect_scan(wifi_ctx_t *c, int scan_idx)
|
||||
{
|
||||
if (scan_idx < 0 || scan_idx >= c->scan_n) return 0;
|
||||
meow_net_t *s = &c->scan[scan_idx];
|
||||
if (!s->name[0] || !strcmp(s->name, "(隐藏网络)")) {
|
||||
const char *txt[1]; txt[0] = "隐藏网络无法直接连接";
|
||||
s_dd_modal("附近网络", txt, 1, -1);
|
||||
wifi_manual_add(c); /* bug2:占位隐藏网络 → 手动录入(SSID 待输) */
|
||||
return 1;
|
||||
}
|
||||
int open = (s->sec[0] == 0 || !strcmp(s->sec, "Open"));
|
||||
char psk[96] = "";
|
||||
if (!open) {
|
||||
if (!wifi_ask_psk(psk, sizeof psk)) return 1; /* 用户取消输密码 */
|
||||
if (!wifi_ask_text(psk, sizeof psk)) return 1; /* 用户取消输密码 */
|
||||
}
|
||||
char err[160]; err[0] = 0;
|
||||
wifi_connecting_banner(s->name); /* bug1:阻塞前横幅,避免「点完无反馈」 */
|
||||
int rc = nm_wifi_connect(s->name, open ? NULL : psk, err, sizeof err);
|
||||
char line[192];
|
||||
const char *txt[1]; txt[0] = line;
|
||||
if (rc == MEOW_OK) {
|
||||
snprintf(line, sizeof line, "连接成功:%s%s", s->name,
|
||||
err[0] ? "" : "(已启用)");
|
||||
} else {
|
||||
snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误");
|
||||
}
|
||||
/* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */
|
||||
snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败"));
|
||||
logmsg("wifi_page: scan connect ssid=%s open=%d rc=%d err=%s", s->name, open, rc, err);
|
||||
/* 连接结果反馈(成功后进已保存网络列表由 rows() 重扫刷新) */
|
||||
s_dd_modal(s->name, txt, 1, -1);
|
||||
@@ -208,20 +254,23 @@ static int wifi_detail(void *ctx, int idx, int act)
|
||||
}
|
||||
|
||||
|
||||
/* 动作行(SUB_ACT)点击:已保存网络 → netmgr connect(ssid;复用既有网络) */
|
||||
/* 动作行(SUB_ACT)点击:已保存网络 → netmgr connect(ssid;复用既有网络)。
|
||||
* 另承接「手动添加隐藏网络」入口(bug2)。 */
|
||||
static int wifi_act(void *ctx, int idx, int act)
|
||||
{
|
||||
(void)idx;
|
||||
wifi_ctx_t *c = ctx;
|
||||
if (act == WF_ACT_MANUAL) { wifi_manual_add(c); return 1; }
|
||||
if (act >= WF_ACT_SCAN_BASE) return 0; /* 附近是 SUB_ROW(detail),不走这里 */
|
||||
int sidx = act - WF_ACT_SAVED;
|
||||
if (sidx < 0 || sidx >= c->saved_n) return 0;
|
||||
if (c->saved[sidx].id < 0) return 0;
|
||||
char err[160]; err[0] = 0;
|
||||
wifi_connecting_banner(c->saved[sidx].name); /* bug1:阻塞前横幅 */
|
||||
int rc = nm_wifi_connect(c->saved[sidx].name, NULL, err, sizeof err);
|
||||
char line[192]; const char *txt[1]; txt[0] = line;
|
||||
if (rc == MEOW_OK) snprintf(line, sizeof line, "已连接:%s", c->saved[sidx].name);
|
||||
else snprintf(line, sizeof line, "连接失败:%s", err[0] ? err : "netmgr 返回错误");
|
||||
/* bug1:结果文案直接采用 netmgrd/直连回填的诚实文本(成功含 ip / 失败含原因)。 */
|
||||
snprintf(line, sizeof line, "%s", err[0] ? err : (rc == MEOW_OK ? "已连接" : "连接失败"));
|
||||
logmsg("wifi_page: saved connect ssid=%s rc=%d err=%s", c->saved[sidx].name, rc, err);
|
||||
s_dd_modal(c->saved[sidx].name, txt, 1, -1);
|
||||
nm_wifi_scan_trigger();
|
||||
|
||||
@@ -85,6 +85,10 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET
|
||||
|
||||
#define NET_POLL_SEC_DEF 2 /* 接口轮询/状态节拍(秒;MEOW_NET_POLL_SEC 覆盖,测试用) */
|
||||
#define RUNCMD_TMO_MS 1500 /* 后端命令(wpa_cli/hciconfig/etc)有界收割限时(ms) */
|
||||
/* WiFi 设置页 bug1(2026-09-15):connect 有界等待 wpa_state=COMPLETED 的上限(ms)。
|
||||
* 普通 ~10s;隐藏网络(connecth,主动探测+关联更慢)~15s。env 可调(宿主桩测试缩短)。 */
|
||||
#define WIFI_CONNECT_WAIT_MS 10000 /* MEOW_WIFI_CONNECT_WAIT_MS */
|
||||
#define WIFI_CONNECTH_WAIT_MS 15000 /* MEOW_WIFI_CONNECTH_WAIT_MS */
|
||||
#define IFACE_MAX 16
|
||||
#define RFKILL_MAX 8
|
||||
#define DNS_MAX 8
|
||||
@@ -112,6 +116,8 @@ static char g_net_policy[160]= "/data/meow/netmgrd/net_policy.conf"; /* MEOW_NET
|
||||
#define DNS_SELF_IP "127.0.0.1"
|
||||
|
||||
static int g_poll_sec = NET_POLL_SEC_DEF;
|
||||
static int g_connect_wait_ms = WIFI_CONNECT_WAIT_MS; /* MEOW_WIFI_CONNECT_WAIT_MS */
|
||||
static int g_connecth_wait_ms = WIFI_CONNECTH_WAIT_MS; /* MEOW_WIFI_CONNECTH_WAIT_MS */
|
||||
static int g_dns_port = DNS_PORT_DEF; /* MEOW_DNS_PORT */
|
||||
static int g_dns_disabled = 0; /* MEOW_DNS_DISABLE=1:禁用 DNS 代理(宿主测试无 root 绑 53) */
|
||||
|
||||
@@ -2650,13 +2656,82 @@ static const char *str_capsafe(const char *s, char *tmp, size_t cap)
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/* wpa_cli 单命令封装:拼 PATH 前缀 + 有界收割(RUNCMD_TMO_MS)。返回 run_cmd 读到的字节数。 */
|
||||
static int wpa_cmd(char *out, int cap, const char *args)
|
||||
{
|
||||
char cmd[512];
|
||||
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s %s 2>/dev/null", g_wpa_cli, args);
|
||||
return run_cmd(out, cap, RUNCMD_TMO_MS, cmd);
|
||||
}
|
||||
|
||||
/* WiFi 设置页 bug4-②(2026-09-15):list_networks/scan_results 对非 ASCII 字节输出 \xNN 转义
|
||||
* (中文 SSID)——还原为原始字节串再与输入 SSID 做同表示比较;不还原则中文 SSID 永不命中
|
||||
* 既有网络 → 新建同名 open 记录顶影原记录。net.c wpa_decode_ssid / netmgr_ipc.c nm_decode_ssid
|
||||
* 已有两份同功能实现(后续可收拢,本次先在 netmgrd 补齐)。 */
|
||||
static int wpa_hexval(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
static void wpa_unescape_ssid(const char *src, char *dst, size_t dcap)
|
||||
{
|
||||
size_t o = 0;
|
||||
while (*src && o + 1 < dcap) {
|
||||
if (src[0] == '\\' && src[1] == 'x' && wpa_hexval(src[2]) >= 0 && wpa_hexval(src[3]) >= 0) {
|
||||
dst[o++] = (char)((wpa_hexval(src[2]) << 4) | wpa_hexval(src[3]));
|
||||
src += 4;
|
||||
} else {
|
||||
dst[o++] = *src++;
|
||||
}
|
||||
}
|
||||
dst[o] = 0;
|
||||
}
|
||||
|
||||
/* WiFi 设置页 bug4-③(2026-09-15):save_config 结果校验。wpa_supplicant 仅在 conf 含
|
||||
* update_config=1 时允许写盘;固件模板 /etc/wpa_supplicant.conf 无此项 → 首次 FAIL 时先
|
||||
* `set update_config 1`(运行期开关,wpa_supplicant 标准用法;成功落盘后文件自带
|
||||
* update_config=1)再重试一次。返回 1=已持久化;0=仍失败(调用方须在报文/日志明示
|
||||
* 「记录未持久化」)。仅 MeowOS 模式调用(写盘红线不变,NETMGR_DESIGN §14)。 */
|
||||
static int wifi_save_config(void)
|
||||
{
|
||||
char out[160];
|
||||
if (wpa_cmd(out, sizeof out, "save_config") > 0 && strstr(out, "OK")) return 1;
|
||||
wpa_cmd(out, sizeof out, "set update_config 1");
|
||||
if (wpa_cmd(out, sizeof out, "save_config") > 0 && strstr(out, "OK")) {
|
||||
net_log("info", "write", "save_config 初次 FAIL → set update_config 1 重试成功(此后 conf 自带该位)");
|
||||
return 1;
|
||||
}
|
||||
net_log("warn", "write", "save_config 失败(含 set update_config 1 重试):记录未持久化");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* WiFi 设置页 bug1(2026-09-15):连接状态机——有界等待 wpa_state=COMPLETED(每 200ms 查
|
||||
* status,上限 timeout_ms,防卡死)。返回 1=已关联(out 带回 ssid/ip 供诚实报文);0=超时。 */
|
||||
static int wifi_wait_completed(int timeout_ms, wifi_t *out)
|
||||
{
|
||||
int waited = 0;
|
||||
for (;;) {
|
||||
wifi_t w;
|
||||
wifi_status(&w);
|
||||
if (w.connected) { if (out) *out = w; return 1; }
|
||||
if (waited >= timeout_ms) return 0;
|
||||
struct timespec ts = { 0, 200 * 1000000L };
|
||||
nanosleep(&ts, NULL);
|
||||
waited += 200;
|
||||
}
|
||||
}
|
||||
|
||||
static void wifi_scan(ctl_result_t *r)
|
||||
{
|
||||
char cmd[160];
|
||||
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s scan 2>/dev/null", g_wpa_cli);
|
||||
char buf[128];
|
||||
run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd);
|
||||
struct timespec ts = { 0, 300 * 1000000L }; /* 触发后稍候再读结果(有界)。 */
|
||||
/* P3(WiFi 设置页附带,2026-09-15):触发后 300ms→1500ms 再读结果——真机扫描常 1-2s,
|
||||
* 300ms 读到的多是上一轮陈旧结果。IPC 客户端 per-cmd 超时相应放宽(netmgr_ipc scan=3.5s)。 */
|
||||
struct timespec ts = { 1, 500 * 1000000L };
|
||||
nanosleep(&ts, NULL);
|
||||
char bufout[MEOW_IPC_MAX - 16];
|
||||
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s scan_results 2>/dev/null", g_wpa_cli);
|
||||
@@ -2675,7 +2750,13 @@ static void wifi_networks(ctl_result_t *r)
|
||||
ctl_sets(r, MEOW_OK, buf);
|
||||
}
|
||||
|
||||
static void wifi_connect(const char *arg, ctl_result_t *r)
|
||||
/* WiFi 连接(bug1/bug2/bug4 修复,2026-09-15)。hidden=1 走隐藏网络流程(connecth):
|
||||
* 对所用 id(新建/命中皆然)追加 scan_ssid=1,并把 COMPLETED 等待上限放宽(主动探测+关联更慢)。
|
||||
* 流程:匹配既有(\xNN 还原同表示比较)→ 新建或(命中且带新凭据时)更新 psk →
|
||||
* enable+select → 有界等待 wpa_state=COMPLETED(不再无条件报成功)→
|
||||
* 成功:enable_network all(消除 select 的 disabled 副作用)+ save_config(校验+重试);
|
||||
* 失败:本次新建的网络 remove 回收(防坏记录堆积),报文明示失败原因。 */
|
||||
static void wifi_connect_ex(const char *arg, ctl_result_t *r, int hidden)
|
||||
{
|
||||
char ssid[96] = "", psk[96] = "";
|
||||
if (arg && arg[0]) {
|
||||
@@ -2694,11 +2775,10 @@ static void wifi_connect(const char *arg, ctl_result_t *r)
|
||||
str_capsafe(psk, tmp_psk, sizeof tmp_psk);
|
||||
if (!tmp_ssid[0]) { ctl_sets(r, MEOW_ERR_INVAL, "connect: ssid 缺失"); return; }
|
||||
|
||||
/* 找既有网络:list_networks → 匹配 ssid(剥引号比较);命中则复用其 id。 */
|
||||
char cmd[200];
|
||||
/* 找既有网络:list_networks → 匹配 ssid(剥引号 + \xNN 还原后同表示比较);命中则复用其 id。 */
|
||||
char cmd[256];
|
||||
char list[1024];
|
||||
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s list_networks 2>/dev/null", g_wpa_cli);
|
||||
int n = run_cmd(list, sizeof list, RUNCMD_TMO_MS, cmd);
|
||||
int n = wpa_cmd(list, sizeof list, "list_networks");
|
||||
int id = -1;
|
||||
if (n > 0) {
|
||||
char *ln = strtok(list, "\n");
|
||||
@@ -2709,61 +2789,81 @@ static void wifi_connect(const char *arg, ctl_result_t *r)
|
||||
char *ss = tab0 + 1;
|
||||
char *tab1 = strchr(ss, '\t');
|
||||
if (tab1) *tab1 = 0;
|
||||
char clean[128], norm_clean[128];
|
||||
char clean[128], unesc[160], norm_clean[128];
|
||||
size_t cl = strlen(ss);
|
||||
if (cl >= 2 && ss[0] == '"' && ss[cl-1] == '"') {
|
||||
memcpy(clean, ss + 1, cl - 2); clean[cl - 2] = 0;
|
||||
} else {
|
||||
snprintf(clean, sizeof clean, "%s", ss);
|
||||
}
|
||||
str_capsafe(clean, norm_clean, sizeof norm_clean);
|
||||
wpa_unescape_ssid(clean, unesc, sizeof unesc); /* bug4-②:中文 SSID 转义还原 */
|
||||
str_capsafe(unesc, norm_clean, sizeof norm_clean);
|
||||
if (strcmp(norm_clean, tmp_ssid) == 0) id = this_id;
|
||||
}
|
||||
ln = strtok(NULL, "\n");
|
||||
}
|
||||
}
|
||||
char out[128];
|
||||
int created = 0;
|
||||
if (id < 0) {
|
||||
snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s add_network 2>/dev/null", g_wpa_cli);
|
||||
if (run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd) <= 0) {
|
||||
if (wpa_cmd(out, sizeof out, "add_network") <= 0) {
|
||||
ctl_sets(r, MEOW_ERR_NODEV, "connect: add_network 失败 (wpa_cli 不可用?)");
|
||||
return;
|
||||
}
|
||||
id = atoi(out);
|
||||
if (id < 0) { ctl_sets(r, MEOW_ERR_INVAL, "connect: add_network 无 id"); return; }
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d ssid '\"%s\"' 2>/dev/null",
|
||||
g_wpa_cli, id, tmp_ssid);
|
||||
run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd);
|
||||
if (tmp_psk[0]) {
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d psk '\"%s\"' 2>/dev/null",
|
||||
g_wpa_cli, id, tmp_psk);
|
||||
} else {
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"PATH=/bin:/sbin:/usr/bin:/usr/sbin %s set_network %d key_mgmt NONE 2>/dev/null",
|
||||
g_wpa_cli, id);
|
||||
}
|
||||
run_cmd(out, sizeof out, RUNCMD_TMO_MS, cmd);
|
||||
created = 1;
|
||||
snprintf(cmd, sizeof cmd, "set_network %d ssid '\"%s\"'", id, tmp_ssid);
|
||||
wpa_cmd(out, sizeof out, cmd);
|
||||
if (tmp_psk[0]) snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk);
|
||||
else snprintf(cmd, sizeof cmd, "set_network %d key_mgmt NONE", id);
|
||||
wpa_cmd(out, sizeof out, cmd);
|
||||
} else if (tmp_psk[0]) {
|
||||
/* bug4-①/bug1-④:命中既有网络且调用方带了新 psk → 先更新凭据再连
|
||||
* (旧记录密码错误可经扫描路径重输修复;psk 为空则不动既有凭据)。 */
|
||||
snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk);
|
||||
wpa_cmd(out, sizeof out, cmd);
|
||||
}
|
||||
if (hidden) {
|
||||
/* bug2:隐藏 AP 必须 scan_ssid=1,supplicant 才会主动发 Probe Request(命中既有也要补)。 */
|
||||
snprintf(cmd, sizeof cmd, "set_network %d scan_ssid 1", id);
|
||||
wpa_cmd(out, sizeof out, cmd);
|
||||
}
|
||||
{ /* enable + select 该网络(复用/新建皆用)。 */
|
||||
char en[256], sel[256];
|
||||
snprintf(en, sizeof en, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s enable_network %d 2>/dev/null",
|
||||
g_wpa_cli, id);
|
||||
run_cmd(out, sizeof out, RUNCMD_TMO_MS, en);
|
||||
snprintf(sel, sizeof sel, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s select_network %d 2>/dev/null",
|
||||
g_wpa_cli, id);
|
||||
run_cmd(out, sizeof out, RUNCMD_TMO_MS, sel);
|
||||
/* 期3:MeowOS 模式 connect 后持久化 wpa_supplicant.conf(HibyOS 只读由服务端门控,这里仅 MeowOS 生效)。 */
|
||||
if (g_mode_meow) {
|
||||
char sc[160];
|
||||
snprintf(sc, sizeof sc, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s save_config 2>/dev/null", g_wpa_cli);
|
||||
run_cmd(out, sizeof out, RUNCMD_TMO_MS, sc);
|
||||
}
|
||||
snprintf(cmd, sizeof cmd, "enable_network %d", id);
|
||||
wpa_cmd(out, sizeof out, cmd);
|
||||
snprintf(cmd, sizeof cmd, "select_network %d", id);
|
||||
wpa_cmd(out, sizeof out, cmd);
|
||||
}
|
||||
char msg[140];
|
||||
snprintf(msg, sizeof msg, "connect: ssid=%s network_id=%d (已 enable+select%s)", tmp_ssid, id,
|
||||
g_mode_meow ? "+save_config" : "");
|
||||
/* bug1:有界等待 wpa_state=COMPLETED——密码错误/AP 不在范围/握手失败不再误报成功。 */
|
||||
wifi_t w;
|
||||
int wait_ms = hidden ? g_connecth_wait_ms : g_connect_wait_ms;
|
||||
if (!wifi_wait_completed(wait_ms, &w)) {
|
||||
if (created) { /* 本次新建且失败 → 回收防坏记录堆积(bug4 联动) */
|
||||
snprintf(cmd, sizeof cmd, "remove_network %d", id);
|
||||
wpa_cmd(out, sizeof out, cmd);
|
||||
if (g_mode_meow) wifi_save_config();
|
||||
}
|
||||
net_log("info", "write", "wifi connect ssid=%s id=%d 超时未关联(%dms)", tmp_ssid, id, wait_ms);
|
||||
char msg[128];
|
||||
snprintf(msg, sizeof msg, "连接失败:密码错误或信号不佳(超时未关联)");
|
||||
ctl_sets(r, MEOW_ERR_NODEV, msg);
|
||||
return;
|
||||
}
|
||||
/* bug4-④:验证成功后 enable_network all 复位其它网络 disabled 态(select_network 副作用),
|
||||
* 再 save_config 落盘(bug4-③:结果校验,FAIL 时 set update_config 1 重试)。
|
||||
* 写盘仍受 MeowOS 模式门控(红线不变,NETMGR_DESIGN §14)。 */
|
||||
int persisted = 0;
|
||||
if (g_mode_meow) {
|
||||
wpa_cmd(out, sizeof out, "enable_network all");
|
||||
persisted = wifi_save_config();
|
||||
}
|
||||
net_log("info", "write", "wifi connect ssid=%s id=%d 已连接 ip=%s persisted=%d",
|
||||
tmp_ssid, id, w.ip[0] ? w.ip : "-", persisted);
|
||||
char msg[160];
|
||||
snprintf(msg, sizeof msg, "已连接:%s ip=%s%s",
|
||||
w.ssid[0] ? w.ssid : tmp_ssid, w.ip[0] ? w.ip : "-",
|
||||
(g_mode_meow && !persisted) ? "(记录未持久化)" : "");
|
||||
ctl_sets(r, MEOW_OK, msg);
|
||||
}
|
||||
|
||||
@@ -2840,7 +2940,9 @@ static void wifi_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r)
|
||||
} else if (!strcmp(cmd, "networks")) {
|
||||
wifi_networks(r);
|
||||
} else if (!strcmp(cmd, "connect")) {
|
||||
wifi_connect(arg, r);
|
||||
wifi_connect_ex(arg, r, 0);
|
||||
} else if (!strcmp(cmd, "connecth")) {
|
||||
wifi_connect_ex(arg, r, 1); /* bug2:隐藏网络(scan_ssid=1 + 更长 COMPLETED 等待) */
|
||||
} else if (!strcmp(cmd, "disconnect")) {
|
||||
char cmd2[160];
|
||||
snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s disconnect 2>/dev/null", g_wpa_cli);
|
||||
@@ -2857,13 +2959,13 @@ static void wifi_ctl_execute(const char *cmd, const char *arg, ctl_result_t *r)
|
||||
g_wpa_cli, nid);
|
||||
char buf[128];
|
||||
int n = run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2);
|
||||
/* 期4:移除后持久化(MeowOS 模式;HibyOS 只读由服务端门控)。 */
|
||||
if (g_mode_meow) {
|
||||
snprintf(cmd2, sizeof cmd2, "PATH=/bin:/sbin:/usr/bin:/usr/sbin %s save_config 2>/dev/null", g_wpa_cli);
|
||||
run_cmd(buf, sizeof buf, RUNCMD_TMO_MS, cmd2);
|
||||
}
|
||||
char msg[64];
|
||||
snprintf(msg, sizeof msg, "wifi forget: network_id=%d 已移除%s", nid, g_mode_meow ? "+save_config" : "");
|
||||
/* 期4:移除后持久化(MeowOS 模式;HibyOS 只读由服务端门控)。
|
||||
* bug4-③:save_config 结果校验(FAIL 时 set update_config 1 重试),报文不再谎称已落盘。 */
|
||||
int persisted = 0;
|
||||
if (g_mode_meow) persisted = wifi_save_config();
|
||||
char msg[96];
|
||||
snprintf(msg, sizeof msg, "wifi forget: network_id=%d 已移除%s", nid,
|
||||
g_mode_meow ? (persisted ? "+save_config" : "(记录未持久化)") : "");
|
||||
ctl_sets(r, n >= 0 ? MEOW_OK : MEOW_ERR_NODEV, msg);
|
||||
} else if (!strcmp(cmd, "rfkill")) {
|
||||
rfkill_ctl(arg, r);
|
||||
@@ -3704,7 +3806,7 @@ static int cli_policy(int op, const char *entity, const char *arg)
|
||||
|
||||
static const char *usage(void)
|
||||
{
|
||||
return "status | iface [name] | wifi scan|networks|connect <ssid>[,psk]|disconnect|on|off"
|
||||
return "status | iface [name] | wifi scan|networks|connect <ssid>[,psk]|connecth <ssid>[,psk]|disconnect|on|off"
|
||||
" | bt status|on|off|paired|connected|a2dp|devices|scan on|off|connect|disconnect|pair|unpair <mac>"
|
||||
" | rfkill [<type> <on|off>] | udhcpc renew|release [if]"
|
||||
" | apps | conns [entity] | top | dns status|stats|flush"
|
||||
@@ -3757,9 +3859,9 @@ static int cli_run(int n, char **v)
|
||||
if (!strcmp(cmd, "wifi")) {
|
||||
if (!arg) { fprintf(stderr, "wifi 子命令缺失(%s)\n", usage()); return 1; }
|
||||
if (!strcmp(arg, "on") || !strcmp(arg, "off")) return cli_cmd_result(MSG_NET_WIFI_CTL, arg, NULL);
|
||||
if (!strcmp(arg, "connect")) {
|
||||
if (!arg2) { fprintf(stderr, "connect 需 <ssid>[,psk]\n"); return 1; }
|
||||
return cli_cmd_result(MSG_NET_WIFI_CTL, "connect", arg2);
|
||||
if (!strcmp(arg, "connect") || !strcmp(arg, "connecth")) {
|
||||
if (!arg2) { fprintf(stderr, "%s 需 <ssid>[,psk]\n", arg); return 1; }
|
||||
return cli_cmd_result(MSG_NET_WIFI_CTL, arg, arg2);
|
||||
}
|
||||
if (!strcmp(arg, "forget")) {
|
||||
if (!arg2) { fprintf(stderr, "forget 需 <network_id>\n"); return 1; }
|
||||
@@ -3821,6 +3923,8 @@ int main(int argc, char **argv)
|
||||
if ((e = getenv("MEOW_WIFI_IF")) && e[0]) g_wifi_if = e;
|
||||
if ((e = getenv("MEOW_NET_SOCK")) && e[0]) g_sock = e;
|
||||
if ((e = getenv("MEOW_NET_POLL_SEC")) && atoi(e) > 0) g_poll_sec = atoi(e);
|
||||
if ((e = getenv("MEOW_WIFI_CONNECT_WAIT_MS")) && atoi(e) >= 200) g_connect_wait_ms = atoi(e);
|
||||
if ((e = getenv("MEOW_WIFI_CONNECTH_WAIT_MS")) && atoi(e) >= 200) g_connecth_wait_ms = atoi(e);
|
||||
if ((e = getenv("MEOW_TCP")) && e[0]) snprintf(g_proc_tcp, sizeof g_proc_tcp, "%s", e);
|
||||
if ((e = getenv("MEOW_UDP")) && e[0]) snprintf(g_proc_udp, sizeof g_proc_udp, "%s", e);
|
||||
if ((e = getenv("MEOW_AMS_SOCK")) && e[0]) snprintf(g_ams_sock, sizeof g_ams_sock, "%s", e);
|
||||
|
||||
@@ -423,6 +423,25 @@ kill $D2PID 2>/dev/null; wait $D2PID 2>/dev/null
|
||||
# 恢复守护 A 的 MEOW_RESOLV(其未被 DNS 触碰,此处确认)
|
||||
grep -q "nameserver 223.5.5.5" "$T/resolv" && { PASS=$((PASS+1)); echo "PASS T14f daemonA resolv untouched"; } || { FAIL=$((FAIL+1)); echo "FAIL T14f daemonA resolv touched"; }
|
||||
|
||||
# ---- T19: WiFi connect 状态机/持久化/隐藏网络/中文 SSID(宿主桩直驱 wifi_connect_ex)----
|
||||
# daemon IPC 写级需系统身份(SO_PEERCRED /proc/exe),宿主 CLI=CALL_OTHER 必拒;故用 #include
|
||||
# meow_netmgrd.c 的独立宿主二进制直驱 static wifi_connect_ex,桩 wpa_cli 由二进制自建。
|
||||
if [ -z "$Q" ]; then
|
||||
WCON="$T/test_wifi_connect"
|
||||
if cc -Os -Wall -Wextra -Wno-format-truncation -I../meow_launcherd \
|
||||
test_wifi_connect.c -o "$WCON" >/dev/null 2>&1; then
|
||||
if "$WCON" >"$T/wcon.out" 2>/dev/null && grep -q "FAIL=0" "$T/wcon.out"; then
|
||||
PASS=$((PASS+1)); echo "PASS T19 wifi-connect host (12 asserts, FAIL=0)"
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "FAIL T19 wifi-connect host"; cat "$T/wcon.out"
|
||||
fi
|
||||
else
|
||||
FAIL=$((FAIL+1)); echo "FAIL T19 wifi-connect host build"
|
||||
fi
|
||||
else
|
||||
echo "SKIP T19 wifi-connect(qemu 下 fork/exec 宿主 /bin/sh 不便,宿主已覆盖)"
|
||||
fi
|
||||
|
||||
echo "=============================="
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ $FAIL -eq 0 ]
|
||||
@@ -0,0 +1,168 @@
|
||||
/* test_wifi_connect.c — WiFi 设置页 bug1/bug2/bug4 宿主桩测试(2026-09-15)。
|
||||
*
|
||||
* 通过 #include meow_netmgrd.c 直接驱动 static 的 wifi_connect_ex(),绕过 daemon 的
|
||||
* SO_PEERCRED 系统身份鉴权(宿主 CLI=CALL_OTHER 写级必拒,无法经 IPC 测连接路径)。
|
||||
* 桩 wpa_cli 由本程序写入临时目录;控制文件(status_mode/list/save_fail_once)决定
|
||||
* status 序列 / list_networks 原文 / save_config 是否首败,调用日志写入 <dir>/log 供断言。
|
||||
*
|
||||
* 编译:cc -Wall -Wextra -I../meow_launcherd test_wifi_connect.c -o test_wifi_connect
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#define main meow_netmgrd_main
|
||||
#include "meow_netmgrd.c"
|
||||
#undef main
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
static char g_dir[128];
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
static void ck(const char *name, int cond, const char *detail)
|
||||
{
|
||||
if (cond) { g_pass++; printf("PASS %s\n", name); }
|
||||
else { g_fail++; printf("FAIL %s: %s\n", name, detail ? detail : ""); }
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static void putf(const char *name, const char *fmt, ...)
|
||||
{
|
||||
char path[192], body[512];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vsnprintf(body, sizeof body, fmt, ap);
|
||||
va_end(ap);
|
||||
snprintf(path, sizeof path, "%s/%s", g_dir, name);
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f) { fputs(body, f); fclose(f); }
|
||||
}
|
||||
|
||||
static int log_has(const char *needle)
|
||||
{
|
||||
char path[192], buf[8192];
|
||||
snprintf(path, sizeof path, "%s/log", g_dir);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return 0;
|
||||
size_t n = fread(buf, 1, sizeof buf - 1, f);
|
||||
fclose(f);
|
||||
buf[n] = 0;
|
||||
return strstr(buf, needle) != NULL;
|
||||
}
|
||||
|
||||
static void reset_log(void)
|
||||
{
|
||||
char path[192];
|
||||
snprintf(path, sizeof path, "%s/log", g_dir);
|
||||
FILE *f = fopen(path, "w");
|
||||
if (f) fclose(f);
|
||||
}
|
||||
|
||||
static void write_stub(void)
|
||||
{
|
||||
char path[192];
|
||||
snprintf(path, sizeof path, "%s/wpa_cli", g_dir);
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) { fprintf(stderr, "stub open fail\n"); exit(2); }
|
||||
fputs(
|
||||
"#!/bin/sh\n"
|
||||
"D=\"${MN_WIFI_DIR:-/tmp}\"\n"
|
||||
"echo \"$@\" >> \"$D/log\"\n"
|
||||
"case \"$1\" in\n"
|
||||
" status)\n"
|
||||
" m=$(cat \"$D/status_mode\" 2>/dev/null || echo completed)\n"
|
||||
" if [ \"$m\" = \"associating\" ]; then\n"
|
||||
" echo \"wpa_state=ASSOCIATING\"; echo \"ssid=MyWiFi\"\n"
|
||||
" else\n"
|
||||
" echo \"wpa_state=COMPLETED\"; echo \"ssid=MyWiFi\"\n"
|
||||
" echo \"ip_address=10.78.193.50\"; echo \"signal=-45\"\n"
|
||||
" fi\n"
|
||||
" ;;\n"
|
||||
" list_networks)\n"
|
||||
" if [ -f \"$D/list\" ]; then cat \"$D/list\"\n"
|
||||
" else printf 'network id / ssid / bssid / flags\\n0\\tMyWiFi\\tany\\t[CURRENT]\\n'\n"
|
||||
" fi\n"
|
||||
" ;;\n"
|
||||
" add_network) echo 1 ;;\n"
|
||||
" set_network) echo OK ;;\n"
|
||||
" enable_network) echo OK ;;\n"
|
||||
" select_network) echo OK ;;\n"
|
||||
" disconnect) echo OK ;;\n"
|
||||
" save_config)\n"
|
||||
" if [ -f \"$D/save_fail_once\" ]; then rm -f \"$D/save_fail_once\"; echo FAIL; else echo OK; fi\n"
|
||||
" ;;\n"
|
||||
" set) echo OK ;;\n"
|
||||
" remove_network) echo OK ;;\n"
|
||||
" *) echo OK ;;\n"
|
||||
"esac\n"
|
||||
"exit 0\n",
|
||||
f);
|
||||
fclose(f);
|
||||
chmod(path, 0755);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
snprintf(g_dir, sizeof g_dir, "/tmp/mnwifi.XXXXXX");
|
||||
if (!mkdtemp(g_dir)) { fprintf(stderr, "mkdtemp fail\n"); return 2; }
|
||||
write_stub();
|
||||
setenv("MN_WIFI_DIR", g_dir, 1);
|
||||
|
||||
g_mode_meow = 1;
|
||||
g_connect_wait_ms = 400;
|
||||
g_connecth_wait_ms = 400;
|
||||
{
|
||||
char p[192];
|
||||
snprintf(p, sizeof p, "%s/wpa_cli", g_dir);
|
||||
g_wpa_cli = p;
|
||||
}
|
||||
|
||||
ctl_result_t r;
|
||||
|
||||
/* ① 成功:命中既有 id=0 + 带新 psk → 更新凭据,等待 COMPLETED,成功报 ip。 */
|
||||
putf("status_mode", "completed");
|
||||
ctl_reset(&r, MEOW_OK);
|
||||
reset_log();
|
||||
wifi_connect_ex("MyWiFi,secret", &r, 0);
|
||||
ck("connect-success code", r.code == MEOW_OK, r.text);
|
||||
ck("connect-success text ip", strstr(r.text, "已连接") && strstr(r.text, "ip=10.78.193.50"), r.text);
|
||||
ck("connect-success psk update", log_has("set_network 0 psk"), "");
|
||||
ck("connect-success enable all", log_has("enable_network all"), "");
|
||||
|
||||
/* ② 错误密码/不在范围:status 恒 ASSOCIATING → 超时失败,新建 id 被回收。 */
|
||||
putf("status_mode", "associating");
|
||||
reset_log();
|
||||
ctl_reset(&r, MEOW_OK);
|
||||
wifi_connect_ex("OtherWiFi", &r, 0);
|
||||
ck("connect-timeout code", r.code == MEOW_ERR_NODEV, r.text);
|
||||
ck("connect-timeout text", strstr(r.text, "连接失败") != NULL, r.text);
|
||||
ck("connect-timeout remove new", log_has("remove_network 1"), "");
|
||||
|
||||
/* ③ 中文 SSID \xNN 还原匹配:list_networks 原文转义,输入 UTF-8 名 → 命中 id=0 不新建。 */
|
||||
putf("status_mode", "completed");
|
||||
putf("list", "network id / ssid / bssid / flags\n0\t\\xe5\\xa4\\xa9\\xe5\\xa4\\xa9\tany\t[CURRENT]\n");
|
||||
reset_log();
|
||||
ctl_reset(&r, MEOW_OK);
|
||||
wifi_connect_ex("天天", &r, 0);
|
||||
ck("connect-escape match", r.code == MEOW_OK, r.text);
|
||||
ck("connect-escape no add", !log_has("add_network"), "");
|
||||
|
||||
/* ④ connecth:隐藏网络命中既有 id → 追加 scan_ssid 1。 */
|
||||
putf("status_mode", "completed");
|
||||
putf("list", "network id / ssid / bssid / flags\n0\tMyWiFi\tany\t[CURRENT]\n");
|
||||
reset_log();
|
||||
ctl_reset(&r, MEOW_OK);
|
||||
wifi_connect_ex("MyWiFi", &r, 1);
|
||||
ck("connecth scan_ssid", r.code == MEOW_OK && log_has("set_network 0 scan_ssid 1"), r.text);
|
||||
|
||||
/* ⑤ save_config 首败 → set update_config 1 重试 → 持久化成功。 */
|
||||
putf("status_mode", "completed");
|
||||
putf("list", "network id / ssid / bssid / flags\n0\tMyWiFi\tany\t[CURRENT]\n");
|
||||
putf("save_fail_once", "1");
|
||||
reset_log();
|
||||
ctl_reset(&r, MEOW_OK);
|
||||
wifi_connect_ex("MyWiFi", &r, 0);
|
||||
ck("save-config retry", r.code == MEOW_OK && !strstr(r.text, "记录未持久化"), r.text);
|
||||
ck("save-config set update_config", log_has("set update_config 1"), "");
|
||||
|
||||
printf("==============================\nPASS=%d FAIL=%d\n", g_pass, g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -8,16 +8,17 @@ set -e
|
||||
cd "$(dirname "$0")"
|
||||
ROOT=../..
|
||||
CC="$ROOT/.tools/zig-mipsel-cc"
|
||||
INC="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(音量收编 v2:MSG_AUDIO_VOLUME/MSG_ACK)
|
||||
|
||||
if [ "$1" = "host" ]; then
|
||||
gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
|
||||
test_wd_fw.c -o test_wd_fw -lm
|
||||
gcc -Wall -Wextra -Os -I"$INC" -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
|
||||
-Wno-unused-variable test_wd_fw.c -o test_wd_fw -lm
|
||||
./test_wd_fw
|
||||
exit $?
|
||||
fi
|
||||
|
||||
echo "== meow_watchdog (mipsel static) =="
|
||||
"$CC" -Os -Wall -Wextra -static meow_watchdog.c -o meow_watchdog
|
||||
"$CC" -Os -Wall -Wextra -static -I"$INC" meow_watchdog.c -o meow_watchdog
|
||||
ls -la meow_watchdog
|
||||
md5sum meow_watchdog
|
||||
file meow_watchdog
|
||||
Binary file not shown.
@@ -19,7 +19,10 @@
|
||||
* (launcher 在 waitpid 轮询中消费;菜单期该进程靠 /tmp/meow_power_menu_active
|
||||
* 让位,绝不与 launcher 抢屏/抢键);launcher 画菜单,本进程不自绘。
|
||||
* 电源长按 ≥8s -> 强制关机兜底(仅当菜单未生效/launcher 未接管)。
|
||||
* 音量±(115/114) -> 写真实 DAC(CS43198 amixer numid=1/2)+ 持久化 settings.conf(同 launcher 同源)。
|
||||
* 音量±(115/114) -> 音量收编 v2(FUTURE_AUDIO 事项三):首选发 audiod 权威
|
||||
* MSG_AUDIO_VOLUME(audiod 写 DAC numid=1/2 + DSD_VOLUME + 持久化
|
||||
* settings.conf,同源同键单写者);audiod 未起(HibyOS/开机早期)
|
||||
* 才回退本地 amixer 直写兜底 + 本地持久化。
|
||||
* 插孔消抖:/sys/class/switch/{headset,balance} 变化后 800ms 内忽略音量键边沿
|
||||
* (插拔电气瞬态会在 ADC 键上产生幽灵按下,真机实证 2026-09-01)。
|
||||
* 播放/上下曲(164/165/163/19) -> 捕获 + 日志 + 消费,不路由(媒体会话落地后转活跃会话)。
|
||||
@@ -28,6 +31,9 @@
|
||||
* (true|1|yes)。判定为 SDK 框架 App -> 音量/媒体键让位 SDK 输入层(meow_ui_input.c 已消化,
|
||||
* 防同一次按键双步进);电源仍由本进程兜底(SDK 对电源是 no-op)。裸自绘 App(framework=0/
|
||||
* 无 framework)或 app.json 不可读 -> 音量/媒体键仍由本进程兜底(不造成功能真空)。
|
||||
* - M20 亮屏亮度:settings.conf 的 display.brightness(clamp 到 brightness_min..100)为唯一
|
||||
* 权威;settings 瞬时不可读用**真实观测快照**;两者都未知时**不写背光**(不再回退固定 60,
|
||||
* 那会覆盖 launcher 刚设好的用户值 → 开机/亮屏跳 60)。面板已黑且无任何值来源才写安全低亮。
|
||||
* Exits when the app process disappears (kill(pid,0) -> ESRCH) or on
|
||||
* SIGTERM from the parent.
|
||||
*
|
||||
@@ -50,8 +56,12 @@
|
||||
#include <sys/mman.h>
|
||||
#include <poll.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <sys/time.h>
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
#include "meow_ipc.h" /* MSG_AUDIO_VOLUME/MSG_ACK/帧格式(音量收编 v2:首选 audiod 权威) */
|
||||
|
||||
/* device-verified: VSCREENINFO=0x4600, FSCREENINFO=0x4602 (do not swap) */
|
||||
#define FBIOGET_VSCREENINFO 0x4600
|
||||
@@ -185,16 +195,44 @@ static int g_fw_app = 0;
|
||||
|
||||
/* ---- M2.22(A)+ M2.24 电源键接管:息屏/亮屏 + 长按菜单/强关 ---- */
|
||||
static int wd_fbfd = -1; /* fb0 fd(main 里打开,供 FBIOBLANK/FBIOPAN 用) */
|
||||
static int wd_saved_bri = 60; /* 熄屏前亮度快照 */
|
||||
|
||||
/* M20(远古 bug 回归:开机/亮屏背光跳到固定 60):0 是「未知」哨兵,不是亮度值
|
||||
* (0 = 面板全黑/息屏,任何情况下都不是有效用户亮度)。旧代码让 60 同时表达
|
||||
* 「未知快照」和「兜底值」,于是一旦没有可信来源就把 60 写进背光,覆盖 launcher
|
||||
* 刚按 display.brightness 设好的用户值(真机 0.8.46:用户亮度 1,亮屏仍写 60)。 */
|
||||
#define WD_BRI_UNKNOWN 0
|
||||
/* 唯一保留的固定亮度:settings 读不到 + 快照未知 + **当前亮度确实是 0**(面板黑)时才用。
|
||||
* 这条退路上不存在任何可保留的有效用户值,不写就是永久黑屏;取低亮(≈10% of
|
||||
* max_brightness 101)而不是 60 —— 宁可暗到用户手动调亮,也不要夜里突然刺眼。
|
||||
* 注意 60 是固件 S11jpeg_display_shell 画完开机 logo 后写死的值(/etc/init.d,不在本仓库),
|
||||
* 本进程任何分支都不再写 60。 */
|
||||
#define WD_BRI_SAFE_LOW 10
|
||||
/* M20 短期修复 A:settings 读取重试。settings.conf 由 launcher/audiod 以
|
||||
* 「写 .tmp + rename」原子替换,开机早期 /usr/data(UBIFS) 也可能尚未就绪,
|
||||
* fopen 存在瞬时失败窗口。3 次 × 10ms 只覆盖该窗口:最坏总阻塞 (3-1)*10 = 20ms,
|
||||
* 且只在失败时发生(成功路径 0 开销)。亮屏轮询粒度本身是 REFRESH_MS=100ms,
|
||||
* 20ms 在「按键→亮屏」路径上不可感知(见 M20 报文)。 */
|
||||
#define WD_SETTINGS_TRIES 3
|
||||
#define WD_SETTINGS_GAP_MS 10
|
||||
|
||||
static int wd_saved_bri = WD_BRI_UNKNOWN; /* 熄屏前亮度快照;未知=WD_BRI_UNKNOWN(不再用 60) */
|
||||
static int pwr_down = 0; /* 电源当前按住 */
|
||||
static unsigned long long pwr_down_ms = 0;
|
||||
static int pwr_woke = 0; /* 按下时屏已黑 → 本次只作唤醒,松开不再 toggle */
|
||||
static int pwr_menu_notified = 0; /* 已写 /tmp/meow_power_menu 请 launcher 弹菜单 */
|
||||
static int pwr_force_done = 0; /* 8s 强关已发(防重复) */
|
||||
|
||||
/* 背光 sysfs 路径(缺省 BACKLIGHT;env MEOW_BACKLIGHT 覆盖,供宿主测试隔离——与
|
||||
* MEOW_SETTINGS_FILE 同款测试钩子,真机无 env 走默认,行为不变)。 */
|
||||
static const char *wd_backlight_path(void)
|
||||
{
|
||||
const char *e = getenv("MEOW_BACKLIGHT");
|
||||
return (e && e[0]) ? e : BACKLIGHT;
|
||||
}
|
||||
|
||||
static int read_brightness(void)
|
||||
{
|
||||
int fd = open(BACKLIGHT, O_RDONLY);
|
||||
int fd = open(wd_backlight_path(), O_RDONLY);
|
||||
if (fd < 0) return -1;
|
||||
char buf[16];
|
||||
int n = read(fd, buf, sizeof buf - 1);
|
||||
@@ -208,24 +246,40 @@ static void write_brightness(int val)
|
||||
{
|
||||
char buf[8];
|
||||
int l = snprintf(buf, sizeof buf, "%d\n", val);
|
||||
int fd = open(BACKLIGHT, O_WRONLY);
|
||||
int fd = open(wd_backlight_path(), O_WRONLY);
|
||||
if (fd < 0) return;
|
||||
if (write(fd, buf, l) < 0) wd_log("backlight write %d: %s", val, strerror(errno));
|
||||
close(fd);
|
||||
}
|
||||
|
||||
/* settings.conf 路径(缺省 /usr/data/meow/settings.conf;env MEOW_SETTINGS_FILE 覆盖,
|
||||
* 供宿主测试隔离——与 audiod MEOW_AUDIO_SETTINGS 同款测试钩子,真机无 env 走默认)。 */
|
||||
static const char *wd_settings_path(void)
|
||||
{
|
||||
const char *e = getenv("MEOW_SETTINGS_FILE");
|
||||
return (e && e[0]) ? e : MEOW_SETTINGS_FILE;
|
||||
}
|
||||
|
||||
/* M6.23:亮屏恢复亮度以**用户设置**为准(/usr/data/meow/settings.conf 的 display.brightness),
|
||||
* 而非 wd_saved_bri 快照——该快照可能被污染:panel OFF 前 read_brightness() 可能读到 launcher
|
||||
* 先写好的 0(launcher meow_panel_off 已 write_brightness(0)),`wd_saved_bri=(br>0)?br:60` 便落到
|
||||
* 先写好的 0(launcher meow_panel_off 已 write_brightness(0)),旧代码 `(br>0)?br:60` 便落到
|
||||
* 默认 60;panel ON 恢复 60 与 launcher 按用户设置恢复 7 竞态 → 亮屏亮度跳变(真机「跳高到 60 /
|
||||
* 又降回 7」,2026-09-03)。
|
||||
* 又降回 7」,2026-09-03)。旧公式里的 `:60` 已在 M20 移除。
|
||||
* 与 launcher meow_settings_brightness_clamped() 同源(同文件、同 clamp:下限 display.brightness_min
|
||||
* 默认 1=极暗但可见、上限 100=防闪屏);本进程自身 wd_vol_load 早已读该文件,不引入 launcher 全量
|
||||
* 依赖,保持 watchdog 独立性。返回 clamped 值;settings 文件不可读(S70 早启动 /usr/data 未就绪)
|
||||
* → 返回 -1,调用方回退 wd_saved_bri(保持旧行为,防开机竞态)。 */
|
||||
* M20 起:fopen 失败做 WD_SETTINGS_TRIES 次、间隔 WD_SETTINGS_GAP_MS 的短重试,
|
||||
* 覆盖 settings.conf 原子替换 / 开机早期 /usr/data 未就绪的瞬时窗口;仍失败才返回 -1。
|
||||
* 只重试 open:rename 是原子的,已打开的 fd 指向旧 inode、内容完整,读内容无需重试。
|
||||
* 返回 -1 时调用方**不再**回退「默认 60」,取值语义统一由 wd_bri_decide() 决定。 */
|
||||
static int wd_settings_brightness(void)
|
||||
{
|
||||
FILE *f = fopen(MEOW_SETTINGS_FILE, "r");
|
||||
FILE *f = NULL;
|
||||
for (int i = 0; i < WD_SETTINGS_TRIES; i++) {
|
||||
f = fopen(wd_settings_path(), "r");
|
||||
if (f) break;
|
||||
if (i + 1 < WD_SETTINGS_TRIES) usleep(WD_SETTINGS_GAP_MS * 1000);
|
||||
}
|
||||
if (!f) return -1;
|
||||
int b = 101; /* display.brightness 默认(与 launcher 设置表同值,键缺失时 clamp→100) */
|
||||
int mn = 1; /* display.brightness_min 默认(极暗但可见;键缺失时保底) */
|
||||
@@ -261,26 +315,53 @@ static void wd_pan_buf0_force(void)
|
||||
ioctl(wd_fbfd, FBIOPAN_DISPLAY, v);
|
||||
}
|
||||
|
||||
/* 亮屏亮度决策(纯函数:宿主单测直接断言,见 test_wd_fw.c)。
|
||||
* 返回 >0 = 要写入的背光值;返回 -1 = **不写背光**(保持当前值)。
|
||||
* *src 输出取值来源,原样写进日志供真机排查:
|
||||
* settings settings.conf 的 display.brightness(已 clamp)—— 唯一权威
|
||||
* snapshot 本次运行期熄屏前读到的真实亮度(>0 才算数)—— settings 瞬时不可读时的过渡值
|
||||
* untouched settings 与快照都未知、但面板已亮 → 不覆盖现场(M20 核心修复)
|
||||
* safe-low settings/快照都未知且面板当前为 0(黑)→ 点亮,用低亮而不是 60
|
||||
* 优先级:settings → 快照(真实近似值)→ 保持现状 → 安全低亮。
|
||||
* 任何分支都不再写「固定 60」:60 只在固件 S11 画完开机 logo 后写一次(固件侧问题,非本进程)。 */
|
||||
static int wd_bri_decide(int user, int saved, int cur, const char **src)
|
||||
{
|
||||
if (user > 0) { *src = "settings"; return user; } /* 用户设置优先 */
|
||||
if (saved > 0) { *src = "snapshot"; return saved; } /* 真实观测到的近似值 */
|
||||
if (cur > 0) { *src = "untouched"; return -1; } /* 已有可见亮度:绝不覆盖 */
|
||||
*src = "safe-low"; return WD_BRI_SAFE_LOW; /* 面板黑 + 无任何值来源 */
|
||||
}
|
||||
|
||||
static void wd_panel_off(void)
|
||||
{
|
||||
int br = read_brightness();
|
||||
wd_saved_bri = (br > 0) ? br : 60;
|
||||
/* M20:快照只记「真实观测到的正亮度」。br<=0(读失败,或 launcher meow_panel_off 抢先
|
||||
* 写成 0)时保留上一次已知真实值,绝不写死 60 —— 否则 60 会在下次亮屏被当成用户值回放
|
||||
* (远古 bug 的燃料)。真的一次都没观测到正亮度时保持 WD_BRI_UNKNOWN。 */
|
||||
if (br > 0) wd_saved_bri = br;
|
||||
write_brightness(0);
|
||||
if (wd_fbfd >= 0) ioctl(wd_fbfd, FBIOBLANK, 4); /* 面板下电 + 挂起 goodix 触摸 */
|
||||
wd_log("power: panel OFF (was %d -> 0, blank 4)", br);
|
||||
wd_log("power: panel OFF (was %d -> 0, blank 4, saved=%d)", br, wd_saved_bri);
|
||||
}
|
||||
|
||||
static void wd_panel_on(void)
|
||||
{
|
||||
if (wd_fbfd >= 0) ioctl(wd_fbfd, FBIOBLANK, 0);
|
||||
/* M6.23:恢复亮度以**用户设置**为准(wd_settings_brightness),settings 不可用时才回退
|
||||
* wd_saved_bri 快照(再退默认 60)——与 launcher meow_panel_on 同语义,杜绝「亮屏恢复 60
|
||||
* 与 launcher 恢复 7 竞态 → 跳变」。 */
|
||||
int b = wd_settings_brightness();
|
||||
int restore = (b > 0) ? b : (wd_saved_bri > 0 ? wd_saved_bri : 60);
|
||||
write_brightness(restore);
|
||||
/* M6.23 + M20:用户设置为唯一权威;settings 瞬时不可读用真实快照;两者都未知时
|
||||
* **不写背光**(旧代码在这里写死 60,直接覆盖 launcher 刚按 display.brightness 设好的
|
||||
* 用户值 → 开机/亮屏跳 60,真机 0.8.46 实证)。取值分支见 wd_bri_decide()。 */
|
||||
int user = wd_settings_brightness(); /* >0=用户设置;-1=不可读 */
|
||||
int cur = read_brightness(); /* 当前系统背光:判定「不写」是否安全 + 日志 */
|
||||
const char *src = "?";
|
||||
int restore = wd_bri_decide(user, wd_saved_bri, cur, &src);
|
||||
if (restore > 0) write_brightness(restore);
|
||||
wd_pan_buf0_force(); /* 唤醒后强制回 buffer0 */
|
||||
wd_log("power: panel ON (blank 0 + brightness %d, user=%d saved=%d)", restore, b, wd_saved_bri);
|
||||
if (restore > 0)
|
||||
wd_log("power: panel ON (blank 0 + brightness %d src=%s user=%d saved=%d cur=%d)",
|
||||
restore, src, user, wd_saved_bri, cur);
|
||||
else
|
||||
wd_log("power: panel ON (blank 0 + brightness UNTOUCHED cur=%d src=%s user=%d saved=%d)",
|
||||
cur, src, user, wd_saved_bri);
|
||||
}
|
||||
|
||||
/* launcher 正在画电源菜单(/tmp/meow_power_menu_active 存在)→ watchdog 完全让位。 */
|
||||
@@ -372,7 +453,7 @@ static int wd_vol_step = 1; /* 跳步 */
|
||||
static void wd_vol_load(void)
|
||||
{
|
||||
wd_vol = 60; wd_vol_max = 100; wd_vol_step = 1;
|
||||
FILE *f = fopen(MEOW_SETTINGS_FILE, "r");
|
||||
FILE *f = fopen(wd_settings_path(), "r");
|
||||
if (!f) { wd_log("vol: no settings, default %d", wd_vol); return; }
|
||||
char buf[128];
|
||||
while (fgets(buf, sizeof buf, f)) {
|
||||
@@ -391,13 +472,17 @@ static void wd_vol_load(void)
|
||||
wd_log("vol: loaded volume=%d max=%d step=%d", wd_vol, wd_vol_max, wd_vol_step);
|
||||
}
|
||||
|
||||
/* 写真实 DAC(CS43198, numid 1/2, 0-255),与 launcher meow_dac_vol_write 同路径。 */
|
||||
/* 写真实 DAC(CS43198, numid 1/2, 0-255),与 launcher meow_dac_vol_write 同路径。
|
||||
* ⚠️ 音量收编 v2 兜底函数:仅 audiod 未起(HibyOS/开机早期)时由 wd_vol_adj 回退调用;
|
||||
* MeowOS 常态一律走 wd_audiod_set_volume(audiod 唯一权威,防多写者漂移)。
|
||||
* 锥度为定案平方锥度(与 audiod dac_vol_set 同款,勿改语义:CS43198=衰减量,0 最响/255 静音)。 */
|
||||
static void wd_vol_write(int pct)
|
||||
{
|
||||
int t = 100 - pct, dac;
|
||||
if (pct <= 0) dac = 255; /* 真静音(DS1039:255=-127.5dB) */
|
||||
else dac = t * t * 192 / 10000; /* 感知平方锥度:dB 衰减=(1-pct)^2×96dB */
|
||||
if (dac > 255) dac = 255; if (dac < 0) dac = 0; /* CS43198=衰减量(0最响255静音)+平方锥度(听感线性) */
|
||||
if (dac > 255) dac = 255;
|
||||
if (dac < 0) dac = 0; /* CS43198=衰减量(0最响255静音)+平方锥度(听感线性) */
|
||||
char cmd[160];
|
||||
snprintf(cmd, sizeof cmd,
|
||||
"amixer -c 0 cset numid=1 %d >/dev/null 2>&1; amixer -c 0 cset numid=2 %d >/dev/null 2>&1",
|
||||
@@ -406,14 +491,97 @@ static void wd_vol_write(int pct)
|
||||
wd_log("DAC vol -> L/R=%d (pct %d)", dac, pct);
|
||||
}
|
||||
|
||||
/* ---- 音量收编 v2(FUTURE_AUDIO 事项三):首选 audiod 权威 MSG_AUDIO_VOLUME ----
|
||||
* watchdog 为 per-App 小进程,audiod sock 0666 无鉴权可直接连。一次性短连接(按键 rare,
|
||||
* 无常驻连接/陈旧态负担):非阻塞 connect + poll 300ms,SO_SNDTIMEO/RCVTIMEO 1s,发 i32 pct
|
||||
* 等 MSG_ACK。audiod 侧已异步化(末值去抖,ACK=已应用+已持久化),正常 <<1s 返回。
|
||||
* 返回 0=audiod 已受理(DAC 写+持久化均由 audiod 完成,本进程不再写 settings 防双写);
|
||||
* -1=audiod 缺席/失败(调用方回退 wd_vol_write 本地直写 + wd_vol_save 本地持久化)。 */
|
||||
#define WD_AUD_SOCK_DEFAULT "/usr/data/meow_audio.sock" /* 同 audiod AUD_SOCK_DEFAULT */
|
||||
#define WD_AUD_CONN_TMO_MS 300
|
||||
#define WD_AUD_IO_TMO_MS 1000
|
||||
|
||||
static const char *wd_aud_sock(void)
|
||||
{
|
||||
const char *e = getenv("MEOW_AUDIO_SOCK"); /* 与 SDK sock_path() 同款 env(宿主测覆盖) */
|
||||
return (e && e[0]) ? e : WD_AUD_SOCK_DEFAULT;
|
||||
}
|
||||
|
||||
static int wd_wr_all(int fd, const void *p, size_t n)
|
||||
{
|
||||
const uint8_t *b = p; size_t o = 0;
|
||||
while (o < n) { ssize_t r = write(fd, b + o, n - o); if (r < 0) { if (errno == EINTR) continue; return -1; } o += (size_t)r; }
|
||||
return 0;
|
||||
}
|
||||
static int wd_rd_all(int fd, void *p, size_t n)
|
||||
{
|
||||
uint8_t *b = p; size_t o = 0;
|
||||
while (o < n) { ssize_t r = read(fd, b + o, n - o); if (r < 0) { if (errno == EINTR) continue; return -1; } if (r == 0) return -1; o += (size_t)r; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int wd_audiod_set_volume(int pct)
|
||||
{
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
int fl = fcntl(fd, F_GETFL, 0);
|
||||
if (fl >= 0) fcntl(fd, F_SETFL, fl | O_NONBLOCK);
|
||||
struct sockaddr_un a;
|
||||
memset(&a, 0, sizeof a);
|
||||
a.sun_family = AF_UNIX;
|
||||
snprintf(a.sun_path, sizeof a.sun_path, "%s", wd_aud_sock());
|
||||
int r = connect(fd, (struct sockaddr *)&a, sizeof a);
|
||||
if (r < 0 && errno == EINPROGRESS) {
|
||||
struct pollfd p;
|
||||
p.fd = fd; p.events = POLLOUT; p.revents = 0;
|
||||
if (poll(&p, 1, WD_AUD_CONN_TMO_MS) > 0) {
|
||||
int err = 0;
|
||||
socklen_t el = sizeof err;
|
||||
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &el) == 0 && err != 0) { errno = err; r = -1; }
|
||||
} else {
|
||||
r = -1; /* 超时/错误 */
|
||||
}
|
||||
}
|
||||
if (r < 0) { close(fd); return -1; }
|
||||
if (fl >= 0) fcntl(fd, F_SETFL, fl); /* 回阻塞模式;读写超时由 SO_*TIMEO 兜底 */
|
||||
struct timeval tv;
|
||||
tv.tv_sec = WD_AUD_IO_TMO_MS / 1000; tv.tv_usec = (WD_AUD_IO_TMO_MS % 1000) * 1000;
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
||||
/* 发 MSG_AUDIO_VOLUME(i32 pct);req_id 任意(一次性连接单请求) */
|
||||
uint8_t frame[MEOW_IPC_HDR_LEN + sizeof(int32_t)];
|
||||
uint32_t magic = MEOW_IPC_MAGIC, flen = sizeof(int32_t), freq = 1;
|
||||
uint16_t ver = MEOW_IPC_VERSION, type = MSG_AUDIO_VOLUME;
|
||||
memcpy(frame + 0, &magic, 4);
|
||||
memcpy(frame + 4, &ver, 2);
|
||||
memcpy(frame + 6, &type, 2);
|
||||
memcpy(frame + 8, &freq, 4);
|
||||
memcpy(frame + 12, &flen, 4);
|
||||
int32_t p32 = pct;
|
||||
memcpy(frame + MEOW_IPC_HDR_LEN, &p32, sizeof p32);
|
||||
if (wd_wr_all(fd, frame, sizeof frame) != 0) { close(fd); return -1; }
|
||||
/* 等 MSG_ACK(有界:RCVTIMEO;audiod 异步执行点应用后回 ACK) */
|
||||
uint8_t hdr[MEOW_IPC_HDR_LEN];
|
||||
if (wd_rd_all(fd, hdr, sizeof hdr) != 0) { close(fd); return -1; }
|
||||
uint32_t rmagic, rlen;
|
||||
uint16_t rver, rtype;
|
||||
memcpy(&rmagic, hdr, 4); memcpy(&rver, hdr + 4, 2); memcpy(&rtype, hdr + 6, 2); memcpy(&rlen, hdr + 12, 4);
|
||||
if (rmagic != MEOW_IPC_MAGIC || rver != MEOW_IPC_VERSION || rtype != MSG_ACK || rlen > 64) { close(fd); return -1; }
|
||||
uint8_t pl[64];
|
||||
if (rlen && wd_rd_all(fd, pl, rlen) != 0) { close(fd); return -1; }
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* M2.92+ 修复(真机实证 settings.conf 被毁):旧版 O_TRUNC 整文件重写只剩 4 行 ——
|
||||
* 抹掉全部其它设置;与其它写者交错还叠出重复 audio.volume 行、丢 volume_max/step。
|
||||
* 改为读改写单键(流式拷贝、命中行全部改写去重、缺失末尾追加、rename 原子替换),
|
||||
* 与 SDK meow_ui_vol_set 同款语义。 */
|
||||
static void wd_vol_save(void)
|
||||
{
|
||||
system("mkdir -p /usr/data/meow 2>/dev/null");
|
||||
const char *path = MEOW_SETTINGS_FILE;
|
||||
const char *path = wd_settings_path();
|
||||
if (strcmp(path, MEOW_SETTINGS_FILE) == 0) /* 仅默认路径补目录(env 覆盖=宿主测,目录自备) */
|
||||
system("mkdir -p /usr/data/meow 2>/dev/null");
|
||||
char tmp[300];
|
||||
snprintf(tmp, sizeof tmp, "%s.tmp", path);
|
||||
FILE *in = fopen(path, "r");
|
||||
@@ -490,7 +658,9 @@ static int wd_jack_debounce(void)
|
||||
(now_ms() - wd_jack_changed_at < (unsigned long long)WD_JACK_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/* 音量步进:+1/-1(每按下边沿一次;clamp 到极大/最小)。 */
|
||||
/* 音量步进:+1/-1(每按下边沿一次;clamp 到极大/最小)。
|
||||
* 音量收编 v2:首选 audiod 权威(IPC;DAC 写+持久化 audiod 完成);audiod 缺席才本地
|
||||
* amixer 直写 + 本地持久化兜底(wd_vol 本地缓存仅作步进基数,两路都维护)。 */
|
||||
static void wd_vol_adj(int dir)
|
||||
{
|
||||
int target = wd_vol + dir * wd_vol_step;
|
||||
@@ -498,9 +668,13 @@ static void wd_vol_adj(int dir)
|
||||
if (target > wd_vol_max) target = wd_vol_max;
|
||||
if (target == wd_vol) { wd_log("volume %s at limit (%d)", dir > 0 ? "UP" : "DOWN", wd_vol); return; }
|
||||
wd_vol = target;
|
||||
wd_vol_write(wd_vol);
|
||||
if (wd_audiod_set_volume(wd_vol) == 0) {
|
||||
wd_log("volume %s -> %d (via audiod)", dir > 0 ? "UP" : "DOWN", wd_vol);
|
||||
return;
|
||||
}
|
||||
wd_vol_write(wd_vol); /* 兜底:仅 audiod 未起(HibyOS/开机早期) */
|
||||
wd_vol_save();
|
||||
wd_log("volume %s -> %d", dir > 0 ? "UP" : "DOWN", wd_vol);
|
||||
wd_log("volume %s -> %d (local amixer fallback, audiod absent)", dir > 0 ? "UP" : "DOWN", wd_vol);
|
||||
}
|
||||
|
||||
static void handle_touch(int immersive, int app_pid,
|
||||
|
||||
Binary file not shown.
@@ -1,11 +1,14 @@
|
||||
/* test_wd_fw.c — 宿主单测:meow_watchdog 的「SDK 框架 App 判定」逻辑(M2.86, issue 226)。
|
||||
/* test_wd_fw.c — 宿主单测:meow_watchdog 的纯逻辑(M2.86 框架 App 判定 + M20 亮屏亮度)。
|
||||
*
|
||||
* 编译:#include meow_watchdog.c(去 main 拿 wd_app_is_framework/wd_json_str/wd_framework_from_pid)。
|
||||
* 编译:#include meow_watchdog.c(去 main 拿 wd_app_is_framework/json_str/from_pid、
|
||||
* wd_bri_decide、wd_settings_brightness、wd_panel_off/on)。
|
||||
* gcc -Wall -Wextra -Os -DWd_NO_MAIN -Wno-unused-function -Wno-unused-parameter \
|
||||
* test_wd_fw.c -o test_wd_fw
|
||||
* (meow_watchdog.c 含仅 main 使用的静态函数,宿主去 main 后须 -Wno-unused-function 静音;
|
||||
* mips 目标构建不定义 WD_NO_MAIN,不受影响。)
|
||||
* 运行:./test_wd_fw;全 PASS -> exit 0。
|
||||
* M20 用例用 env MEOW_BACKLIGHT / MEOW_SETTINGS_FILE 把背光与 settings 指到临时文件,
|
||||
* 真的走 wd_panel_off/wd_panel_on 的读写路径(真机无这两个 env,走 /sys 与 /usr/data 默认)。
|
||||
*/
|
||||
#define WD_NO_MAIN
|
||||
#include "meow_watchdog.c"
|
||||
@@ -24,6 +27,14 @@ static void check(const char *name, int got, int want)
|
||||
else { g_fail++; printf("FAIL %s: got %d want %d\n", name, got, want); }
|
||||
}
|
||||
|
||||
/* 临时目录根:优先 $TMPDIR(Termux/Android 上 /tmp 属于 shell 用户、本进程不可写),
|
||||
* 否则 /tmp(Linux 构建容器)。让 `build_watchdog.sh host` 在两种环境都能直接跑。 */
|
||||
static const char *tmp_root(void)
|
||||
{
|
||||
const char *t = getenv("TMPDIR");
|
||||
return (t && t[0]) ? t : "/tmp";
|
||||
}
|
||||
|
||||
/* ---- M2.87:SIGUSR1 手势让位(handle_touch 左缘滑动分支)----
|
||||
* 判定(meow_watchdog.c):`!g_fw_app && immersive` 才发 SIGUSR1(只给裸沉浸 App);
|
||||
* SDK 框架 App(g_fw_app=1)不发(返回由 SDK on_swipe 处理),防 SIGUSR1 杀死 App。 */
|
||||
@@ -68,10 +79,155 @@ static int expect_fw(const char *base, const char *name, const char *appjson, in
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- M20:亮屏亮度决策(wd_bri_decide 纯函数)----
|
||||
* 真机 bug(0.8.46,2026-11-05):settings display.brightness=1,亮屏时 watchdog 写入固定 60
|
||||
* 覆盖它。新语义:settings 优先 → 真实快照 → 面板已亮则不写 → 面板黑才写安全低亮。 */
|
||||
static void test_bri_decide(void)
|
||||
{
|
||||
const char *src = NULL;
|
||||
int v;
|
||||
|
||||
v = wd_bri_decide(1, 60, 60, &src); /* 用户值 1 对上被污染的 60/当前 60:必须写 1 */
|
||||
check("bri: settings wins over saved/cur", v == 1 && !strcmp(src, "settings"), 1);
|
||||
v = wd_bri_decide(7, 0, 7, &src);
|
||||
check("bri: settings 7", v == 7 && !strcmp(src, "settings"), 1);
|
||||
v = wd_bri_decide(-1, 42, 3, &src); /* settings 不可读 → 用真实快照 */
|
||||
check("bri: snapshot fallback", v == 42 && !strcmp(src, "snapshot"), 1);
|
||||
v = wd_bri_decide(-1, 0, 3, &src); /* 都未知但面板已亮 → 不写(核心修复) */
|
||||
check("bri: untouched keeps cur", v == -1 && !strcmp(src, "untouched"), 1);
|
||||
v = wd_bri_decide(-1, 0, 0, &src); /* 都未知且面板黑 → 安全低亮,绝不 60 */
|
||||
check("bri: safe-low not 60", v == WD_BRI_SAFE_LOW && v != 60 && v > 0 &&
|
||||
!strcmp(src, "safe-low"), 1);
|
||||
v = wd_bri_decide(-1, 0, -1, &src); /* 背光读失败按「黑」处理,仍要点亮 */
|
||||
check("bri: read-fail -> safe-low", v == WD_BRI_SAFE_LOW && !strcmp(src, "safe-low"), 1);
|
||||
v = wd_bri_decide(0, -1, 5, &src); /* 非正值一律按「未知」,不当作亮度 */
|
||||
check("bri: non-positive treated unknown", v == -1 && !strcmp(src, "untouched"), 1);
|
||||
}
|
||||
|
||||
/* ---- M20:settings 解析 + 重试 ----
|
||||
* 用真机 /usr/data/meow/settings.conf 的关键行断言「路径/键名/解析/clamp 都是对的」,
|
||||
* 证明真机写 60 不是本函数返回 -1(原因见报文:出货二进制根本没编进这段代码)。 */
|
||||
static void test_settings_brightness(void)
|
||||
{
|
||||
char path[600];
|
||||
snprintf(path, sizeof path, "%s/wdsetXXXXXX", tmp_root());
|
||||
int fd = mkstemp(path);
|
||||
if (fd < 0) { g_fail++; printf("FAIL settings: mkstemp\n"); return; }
|
||||
FILE *f = fdopen(fd, "w");
|
||||
fputs("# meow-settings 1\n"
|
||||
"display.brightness = 1\n"
|
||||
"display.brightness_min = 1\n"
|
||||
"audio.volume = 10\n", f);
|
||||
fclose(f);
|
||||
setenv("MEOW_SETTINGS_FILE", path, 1);
|
||||
check("settings: device file -> 1", wd_settings_brightness(), 1);
|
||||
|
||||
f = fopen(path, "w"); /* clamp 抬到 brightness_min */
|
||||
fputs("display.brightness = 0\ndisplay.brightness_min = 3\n", f);
|
||||
fclose(f);
|
||||
check("settings: clamp up to min", wd_settings_brightness(), 3);
|
||||
|
||||
f = fopen(path, "w"); /* clamp 压到 100 */
|
||||
fputs("display.brightness = 200\ndisplay.brightness_min = 1\n", f);
|
||||
fclose(f);
|
||||
check("settings: clamp down to 100", wd_settings_brightness(), 100);
|
||||
|
||||
f = fopen(path, "w"); /* 键缺失:与 launcher 设置表同默认 101 -> clamp 100 */
|
||||
fputs("audio.volume = 10\n", f);
|
||||
fclose(f);
|
||||
check("settings: missing key -> 100", wd_settings_brightness(), 100);
|
||||
|
||||
/* 不可读(原子替换/未挂载窗口):open 重试 3 次仍失败才 -1,总耗时 ≈ 20ms */
|
||||
unlink(path);
|
||||
unsigned long long t0 = now_ms();
|
||||
int r = wd_settings_brightness();
|
||||
unsigned long long dt = now_ms() - t0;
|
||||
check("settings: missing -> -1", r, -1);
|
||||
check("settings: open retried (>=15ms)", dt >= 15, 1);
|
||||
unsetenv("MEOW_SETTINGS_FILE");
|
||||
}
|
||||
|
||||
static void put_file(const char *path, const char *body)
|
||||
{
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) return;
|
||||
fputs(body, f);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static int get_file_int(const char *path)
|
||||
{
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return -1;
|
||||
int v = -1;
|
||||
if (fscanf(f, "%d", &v) != 1) v = -1;
|
||||
fclose(f);
|
||||
return v;
|
||||
}
|
||||
|
||||
/* ---- M20:panel OFF/ON 端到端(真的读写一个「背光文件」,不 mock 函数)----
|
||||
* 复现真机场景:settings display.brightness=1 → 熄屏 → 亮屏。旧代码亮屏写 60,
|
||||
* 新代码必须写 1;且 settings 不可读、快照未知时保持当前值而不是回退 60。 */
|
||||
static void test_panel_brightness_e2e(void)
|
||||
{
|
||||
char bl[600], st[600];
|
||||
snprintf(bl, sizeof bl, "%s/wdblXXXXXX", tmp_root());
|
||||
snprintf(st, sizeof st, "%s/wdstXXXXXX", tmp_root());
|
||||
int fd = mkstemp(bl);
|
||||
if (fd < 0) { g_fail++; printf("FAIL e2e: mkstemp bl\n"); return; }
|
||||
close(fd);
|
||||
fd = mkstemp(st);
|
||||
if (fd < 0) { g_fail++; printf("FAIL e2e: mkstemp st\n"); unlink(bl); return; }
|
||||
close(fd);
|
||||
setenv("MEOW_BACKLIGHT", bl, 1);
|
||||
setenv("MEOW_SETTINGS_FILE", st, 1);
|
||||
|
||||
/* 1) 真机 bug 场景:settings=1、屏亮 1 → OFF 快照 1;ON 必须写 1,绝不 60 */
|
||||
put_file(bl, "1\n");
|
||||
put_file(st, "display.brightness = 1\ndisplay.brightness_min = 1\n");
|
||||
wd_saved_bri = WD_BRI_UNKNOWN;
|
||||
wd_panel_off();
|
||||
check("e2e: panel OFF writes 0", get_file_int(bl), 0);
|
||||
check("e2e: panel OFF snapshot = 1", wd_saved_bri, 1);
|
||||
wd_panel_on();
|
||||
check("e2e: panel ON restores settings 1 (not 60)", get_file_int(bl), 1);
|
||||
|
||||
/* 2) 用户改了亮度:settings 仍是唯一权威(旧快照 1 不得覆盖 5) */
|
||||
put_file(st, "display.brightness = 5\ndisplay.brightness_min = 1\n");
|
||||
put_file(bl, "0\n");
|
||||
wd_panel_on();
|
||||
check("e2e: panel ON follows new settings 5", get_file_int(bl), 5);
|
||||
|
||||
/* 3) settings 不可读 + 快照未知 + 屏亮 3 → 不写(保持 3,不再回退 60) */
|
||||
unlink(st);
|
||||
put_file(bl, "3\n");
|
||||
wd_saved_bri = WD_BRI_UNKNOWN;
|
||||
wd_panel_on();
|
||||
check("e2e: panel ON leaves 3 untouched", get_file_int(bl), 3);
|
||||
|
||||
/* 4) settings 不可读 + 快照未知 + 屏黑 0 → 写安全低亮(>0 且 != 60) */
|
||||
put_file(bl, "0\n");
|
||||
wd_panel_on();
|
||||
check("e2e: panel ON dark -> safe-low", get_file_int(bl), WD_BRI_SAFE_LOW);
|
||||
|
||||
/* 5) settings 不可读但本次运行期真观测到 7 → 快照优先于「不动」 */
|
||||
put_file(bl, "7\n");
|
||||
wd_saved_bri = WD_BRI_UNKNOWN;
|
||||
wd_panel_off(); /* 观测到 7,写 0 */
|
||||
check("e2e: panel OFF snapshot = 7", wd_saved_bri, 7);
|
||||
wd_panel_on();
|
||||
check("e2e: panel ON uses real snapshot 7", get_file_int(bl), 7);
|
||||
|
||||
unlink(bl);
|
||||
unsetenv("MEOW_BACKLIGHT");
|
||||
unsetenv("MEOW_SETTINGS_FILE");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
signal(SIGUSR1, on_sigusr1); /* 捕获 handle_touch 可能发出的 SIGUSR1(测命中) */
|
||||
char dir[] = "/tmp/wdfwXXXXXX";
|
||||
char dir[600];
|
||||
snprintf(dir, sizeof dir, "%s/wdfwXXXXXX", tmp_root());
|
||||
if (!mkdtemp(dir)) { perror("mkdtemp"); return 2; }
|
||||
|
||||
/* 框架 App:各值形态 */
|
||||
@@ -109,6 +265,11 @@ int main(void)
|
||||
check("sigusr1 bare-immersive -> 1", sim_swipe_sigusr1(0, 1), 1); /* 裸沉浸:发(命中) */
|
||||
check("sigusr1 bare-nonimmersive -> 0", sim_swipe_sigusr1(0, 0), 0); /* 裸非沉浸:不发 */
|
||||
|
||||
/* ---- M20:亮屏亮度(决策 + settings 解析/重试 + panel OFF/ON 端到端)---- */
|
||||
test_bri_decide();
|
||||
test_settings_brightness();
|
||||
test_panel_brightness_e2e();
|
||||
|
||||
/* 清理 */
|
||||
{
|
||||
char path[560];
|
||||
|
||||
@@ -25,6 +25,10 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
|
||||
./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` 亦通过)。
|
||||
- 产出(本次构建实测):
|
||||
- `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) |
|
||||
| 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/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/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/:pkg` | 应用详情 | ✅ |
|
||||
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
|
||||
@@ -396,6 +411,33 @@ http://<host>.local:8080/
|
||||
同款统计);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)
|
||||
@@ -486,17 +528,46 @@ http://<host>.local:8080/
|
||||
|
||||
```
|
||||
patch_v2/meowpanel/
|
||||
├── meowpanel.c # 后端源码(约 5590 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序/audiod 状态快照 + 批2 崩溃/日志中心 + 音频写 + overview DNS 聚合)
|
||||
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning)
|
||||
├── meowpanel.c # 拆分前单体源码(约 6372 行,保留作参考,**不在构建源列表**;构建以 src/*.c 为准)
|
||||
├── 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)
|
||||
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;meowpanel.c + mp_jpeg.c)
|
||||
├── meowpanel # 交叉产物(mipsel 静态,约 218 KB,md5 015f9cf1…)
|
||||
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;src/*.c + mp_jpeg.c + -I.)
|
||||
├── meowpanel # 交叉产物(mipsel 静态,本次 md5 fead4852…)
|
||||
├── meowpanel_host # 宿主编译(本机 ld,调试用)
|
||||
├── services.d/
|
||||
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
|
||||
├── web/ # 前端(已存在;部署时拷到后端同目录)
|
||||
│ ├── index.html # 含登录页 overlay + 设置密码对话框 + 音频/字体导航
|
||||
│ ├── meowpanel.css # 含登录页样式
|
||||
│ └── meowpanel.js # API 客户端带 token/401 登录、设置页鉴权卡片、音频页(5s轮询)/字体页
|
||||
└── README.md # 本文件
|
||||
└── web/ # 前端(模块化拆分;部署时拷到后端同目录)
|
||||
├── index.html # 含登录页 overlay + 设置密码对话框 + 音频/字体导航;脚本按序加载
|
||||
├── meowpanel.css # 含登录页样式 + 「屏幕·遥控」合并页样式(screen-remote/remote-btn)
|
||||
├── mp_shared.js # 共享框架:全局状态 + API 客户端(token/401) + 公共工具 + 主题/弹窗/登录/滑条(window.MP)
|
||||
├── 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),未提供注入按钮(遥控板附说明)。
|
||||
@@ -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_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 power_policy_append_flag(sbuf_t *out, const char *obj); /* 拼 guard③ policy_write_enabled 顶层键(自校验) */
|
||||
int api_power_history(int sock);
|
||||
int api_power_events(int sock);
|
||||
const char *ams_bin(void);
|
||||
@@ -287,17 +288,25 @@ int api_net_ifaces(int sock);
|
||||
void net_policy_parse(const char *txt, sbuf_t *s);
|
||||
int api_net_policy_get(int sock);
|
||||
int api_net_policy_set(int sock, const char *body, size_t body_len);
|
||||
int net_locked_entity(const char *entity); /* 防自锁:系统关键网络/服务组件(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init) */
|
||||
int net_bt_aggregate(const char *st, const char *paired, const char *connected, const char *a2dp, const char *devices, sbuf_t *s); /* F1-③ 0xE6 五读文本聚合(selftest 可测) */
|
||||
int api_net_bt(int sock); /* F1-③ GET /api/net/bt 蓝牙只读详情 */
|
||||
int panel_policy_write_enabled(void); /* policy 默认关 gate:settings.conf `panel.policy_enabled`,缺省 0=关(只读透传) */
|
||||
void panel_strip_ansi(char *s);
|
||||
int txt_tokens(char *buf, char **toks, int maxtoks);
|
||||
void join_tokens(char **toks, int from, int to, char *out, size_t cap);
|
||||
void ams_fill(sbuf_t *s);
|
||||
void ams_fg_fill(sbuf_t *s);
|
||||
void ams_bg_fill(sbuf_t *s);
|
||||
void ams_bg_parse(char *list_text, sbuf_t *s); /* 已 strip 的 am list 文本 → bg 列表 JSON(含 /proc 采样) */
|
||||
int api_ams(int sock);
|
||||
int api_ams_bg(int sock);
|
||||
int ams_pkg_ok(const char *pkg);
|
||||
void ams_history_parse(char *out, sbuf_t *s, const char *pkg);
|
||||
void ams_history_fill(sbuf_t *s, const char *pkg);
|
||||
int api_ams_history(int sock, const char *pkg);
|
||||
int api_ams_ctl(int sock, const char *pkg, int kill);
|
||||
int api_ams_kill_fg(int sock, const char *pkg); /* 前台强制终止(救命):am kill 优先,launcher 拒→直接 pid 信号 */
|
||||
void svcd_fill(sbuf_t *s);
|
||||
int api_svcd(int sock);
|
||||
int api_overview(int sock);
|
||||
@@ -408,6 +417,8 @@ int api_notification_dismiss_all(int sock);
|
||||
int api_files_list(int sock, const req_t *r);
|
||||
int api_files_read(int sock, const req_t *r);
|
||||
int api_files_download(int sock, const req_t *r);
|
||||
int files_search_json(const char *abs_start, const char *name, const char *typef, int max, sbuf_t *out); /* F1-④ 搜索核心(selftest 可测) */
|
||||
int api_files_search(int sock, const req_t *r); /* F1-④ GET /api/files/search(只读) */
|
||||
int api_files_upload(int sock, const req_t *r);
|
||||
int rm_recursive(const char *path);
|
||||
int api_files_mkdir(int sock, const req_t *r);
|
||||
@@ -440,7 +451,9 @@ int api_screen_jpeg(int sock, const req_t *r);
|
||||
int64_t now_mono_ms(void);
|
||||
int64_t touch_last_local_ms(const char *dev);
|
||||
int inject_tap(const char *dev, int x, int y);
|
||||
int inject_swipe(const char *dev, int x0, int y0, int x1, int y1);
|
||||
int api_input_tap(int sock, const req_t *r);
|
||||
int api_input_gesture(int sock, const req_t *r);
|
||||
const char *web_root(void);
|
||||
const char *mime_for(const char *path);
|
||||
int serve_static(int sock, const char *path);
|
||||
|
||||
@@ -194,3 +194,251 @@ int api_ams_ctl(int sock, const char *pkg, int kill)
|
||||
if (strstr(detail, "launcher offline")) return send_err(sock, 503, "AMS_UNAVAILABLE", "amsd/launcher 不可达");
|
||||
return send_err(sock, 500, "INTERNAL", detail);
|
||||
}
|
||||
|
||||
/* ---- 批④ 后台活动列表:/api/ams/bg(只读) ----
|
||||
* 数据源=amsd FG/BG 注册表(am list 的 `state=bg` 行)+ 每进程 pcpu/rss(读 /proc/<pid>,
|
||||
* 与 launcher bg_page 的 bgm_proc_sample 同源同口径,单线程静态差分缓存取 CPU 占用)。
|
||||
* CPU/内存诚实原则:pid 读不到 / 首采样无差分基线 → 对应字段为 null(前端显示「—」),绝不编造。 */
|
||||
#define BG_CACHE_MAX 8
|
||||
static long g_bg_pid[BG_CACHE_MAX]; /* /proc/<pid>/stat utime+stime 差分缓存(单线程串行持有) */
|
||||
static unsigned long long g_bg_tick[BG_CACHE_MAX];
|
||||
|
||||
/* 采样单进程 CPU/内存:*pcpu<0=未知(无基线/读不到)、*rss_kb<0=未知。 */
|
||||
static void bg_proc_sample(int pid, double *pcpu, long *rss_kb)
|
||||
{
|
||||
*pcpu = -1.0;
|
||||
*rss_kb = -1;
|
||||
if (pid <= 0) return;
|
||||
char path[64], line[1024];
|
||||
/* 内存:VmRSS(KB) */
|
||||
snprintf(path, sizeof path, "/proc/%d/status", pid);
|
||||
if (read_small_file(path, line, sizeof line) >= 0) {
|
||||
char *p = strstr(line, "VmRSS:");
|
||||
if (p) {
|
||||
p = strchr(p, ' ');
|
||||
if (p) { while (*p == ' ' || *p == '\t') p++; if (*p) *rss_kb = atol(p); }
|
||||
}
|
||||
}
|
||||
/* CPU:/proc/<pid>/stat 的 utime(14)+stime(15) 差分 / hz(%) */
|
||||
snprintf(path, sizeof path, "/proc/%d/stat", pid);
|
||||
if (read_small_file(path, line, sizeof line) >= 0) {
|
||||
char *rp = strrchr(line, ')');
|
||||
if (rp) {
|
||||
char *p = rp + 2;
|
||||
unsigned long long ut = 0, st = 0;
|
||||
for (int f = 0; f < 14 && *p; f++) {
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
if (!*p) break;
|
||||
char *end;
|
||||
unsigned long long v = strtoull(p, &end, 10);
|
||||
if (end == p) { p++; continue; }
|
||||
p = end;
|
||||
if (f == 11) ut = v; /* utime */
|
||||
if (f == 12) st = v; /* stime */
|
||||
}
|
||||
unsigned long long cur = ut + st;
|
||||
static double hz = 0.0;
|
||||
if (hz <= 0) { hz = (double)sysconf(_SC_CLK_TCK); if (hz <= 0) hz = 100.0; }
|
||||
int slot = -1, had = 0;
|
||||
unsigned long long prev = 0;
|
||||
for (int i = 0; i < BG_CACHE_MAX; i++)
|
||||
if (g_bg_pid[i] == pid) { had = 1; slot = i; prev = g_bg_tick[i]; break; }
|
||||
if (!had) {
|
||||
for (int i = 0; i < BG_CACHE_MAX; i++) if (g_bg_pid[i] == 0) { slot = i; break; }
|
||||
if (slot < 0) slot = 0;
|
||||
}
|
||||
if (slot >= 0) { g_bg_pid[slot] = pid; g_bg_tick[slot] = cur; }
|
||||
if (had) { *pcpu = 100.0 * (double)(cur - prev) / hz; if (*pcpu < 0) *pcpu = 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 发射单条 APP 行 JSON(复用于 bg/fg 列表;带 state + is_fg + cpu_pct/rss_kb)。
|
||||
* toks 为已切分的 `APP ...` 行(n>=9);*first 控制数组元素逗号(调用方初始化为 1)。
|
||||
* cpu_pct/rss_kb 诚实原则:pid 读不到/不作为可用进程 → null(前端显「—」),绝不编造。 */
|
||||
static void ams_entry_emit(sbuf_t *out, char **toks, int n, int *first)
|
||||
{
|
||||
const char *st = toks[n - 6]; /* 自右向左:state=tok[n-6], pid=n-5(与 ams_fill 同布局) */
|
||||
char nm[128];
|
||||
join_tokens(toks, 2, n - 6, nm, sizeof nm);
|
||||
int pid = atoi(toks[n - 5]);
|
||||
double pcpu = -1.0; long rss = -1;
|
||||
if (pid > 0) bg_proc_sample(pid, &pcpu, &rss);
|
||||
if (!*first) sA(out, ",");
|
||||
*first = 0;
|
||||
sA(out, "{\"pkg\":"); sA_str(out, toks[1]);
|
||||
sA(out, ",\"name\":"); sA_str(out, nm);
|
||||
sA(out, ",\"pid\":%d", pid);
|
||||
sA(out, ",\"state\":"); sA_str(out, st);
|
||||
sA(out, ",\"is_fg\":%s", strcmp(st, "fg") == 0 ? "true" : "false");
|
||||
sA(out, ",\"cpu_pct\":");
|
||||
if (pcpu < 0) sA(out, "null"); else sA(out, "%.1f", pcpu);
|
||||
sA(out, ",\"rss_kb\":");
|
||||
if (rss < 0) sA(out, "null"); else sA(out, "%ld", rss);
|
||||
sA(out, "}");
|
||||
}
|
||||
|
||||
/* 解析已 strip 的 `am list` 全文 → bg 列表 JSON(state=bg 的 APP 行;向后兼容专用,selftest 覆盖)。 */
|
||||
void ams_bg_parse(char *list_text, sbuf_t *s)
|
||||
{
|
||||
char *toks[256];
|
||||
sbuf_t apps = {0};
|
||||
int first = 1, count = 0;
|
||||
char *ln = list_text;
|
||||
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) {
|
||||
count++;
|
||||
ams_entry_emit(&apps, toks, n, &first);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nl) { *nl = '\n'; ln = nl + 1; } else break;
|
||||
}
|
||||
sA(s, "{\"available\":true,\"count\":%d,\"ts\":%ld,\"apps\":[%s]}",
|
||||
count, (long)time(NULL), apps.b ? apps.b : "");
|
||||
sbuf_free(&apps);
|
||||
}
|
||||
|
||||
/* am list 全文 → bg+fg 双列表 JSON。`apps`(bg)保持向后兼容(count=bg 数量);
|
||||
* 追加 `fg` + `fg_count`(前台 App,前端救命分组)。state=stop 无进程,不在本页(proc 页可见)。 */
|
||||
void ams_bg_fill(sbuf_t *s)
|
||||
{
|
||||
char out[16384];
|
||||
char cmd[300];
|
||||
snprintf(cmd, sizeof cmd, "%s list 2>/dev/null", ams_bin());
|
||||
int code = run_cmd(cmd, out, sizeof out);
|
||||
if (code != 0 || !strstr(out, "APP")) { sA(s, "{\"available\":false}"); return; }
|
||||
panel_strip_ansi(out);
|
||||
char *toks[256];
|
||||
sbuf_t 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)
|
||||
{
|
||||
sbuf_t s = {0};
|
||||
ams_bg_fill(&s);
|
||||
int rc = send_json(sock, 200, s.b ? s.b : "{\"available\":false}");
|
||||
sbuf_free(&s);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* 从 am list 解析某 pkg 的当前 pid(未注册/无进程 → 0)。 */
|
||||
static int ams_pkg_pid(const char *pkg)
|
||||
{
|
||||
char out[16384];
|
||||
char cmd[300];
|
||||
snprintf(cmd, sizeof cmd, "%s list 2>/dev/null", ams_bin());
|
||||
if (run_cmd(cmd, out, sizeof out) != 0 || !strstr(out, "APP")) return 0;
|
||||
panel_strip_ansi(out);
|
||||
char *toks[256];
|
||||
char *ln = out;
|
||||
while (*ln) {
|
||||
char *nl = strchr(ln, '\n');
|
||||
if (nl) *nl = 0;
|
||||
if (strncmp(ln, "APP ", 4) == 0) {
|
||||
int n = txt_tokens(ln, toks, 256);
|
||||
if (n >= 9 && strcmp(toks[1], pkg) == 0) {
|
||||
int pid = atoi(toks[n - 5]);
|
||||
if (nl) *nl = '\n';
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
if (nl) { *nl = '\n'; ln = nl + 1; } else break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* /proc/<pid>/stat 进程是否已消失/僵尸:-1=已消失,1=僵尸,0=存活。 */
|
||||
static int proc_zombie(int pid)
|
||||
{
|
||||
char path[64], line[256];
|
||||
snprintf(path, sizeof path, "/proc/%d/stat", pid);
|
||||
if (read_small_file(path, line, sizeof line) < 0) return -1;
|
||||
char *rp = strrchr(line, ')');
|
||||
if (!rp) return -1;
|
||||
const char *p = rp + 2;
|
||||
while (*p == ' ' || *p == '\t') p++;
|
||||
return (*p == 'Z') ? 1 : 0;
|
||||
}
|
||||
|
||||
/* 前台强制终止(救命):优先走 `am kill`(launcher 干净 SIGTERM 序列,accepted);若 launcher 拒
|
||||
* (framework / 无前台会话 → "launcher refused"),回退:解析 am list 拿当前 pid → 直接 SIGTERM,
|
||||
* 宽限后仍存活走 SIGKILL(与 launcher 杀序列同语义)。这是用户明示「前台卡死救命」的强通道——
|
||||
* 前端已二次确认 + 标注「前台」。require_auth 在路由层已护。 */
|
||||
int api_ams_kill_fg(int sock, const char *pkg)
|
||||
{
|
||||
if (!ams_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
|
||||
|
||||
/* ① 干净通道:am kill(launcher 执行 SIGTERM 序列)。 */
|
||||
{
|
||||
char out[256];
|
||||
char cmd[320];
|
||||
snprintf(cmd, sizeof cmd, "%s kill %s 2>/dev/null", ams_bin(), pkg);
|
||||
int code = run_cmd(cmd, out, sizeof out);
|
||||
if (code == 0 && strstr(out, "OK ")) {
|
||||
sbuf_t s = {0};
|
||||
sA(&s, "{\"accepted\":true,\"op\":\"kill-fg\",\"pkg\":");
|
||||
sA_str(&s, pkg);
|
||||
sA(&s, ",\"force\":false,\"message\":"); sA_str(&s, out);
|
||||
sA(&s, "}");
|
||||
int rc = send_json(sock, 200, s.b ? s.b : "");
|
||||
sbuf_free(&s);
|
||||
return rc;
|
||||
}
|
||||
panel_log("ams kill-fg %s via am refused: %s -> direct signal fallback", pkg,
|
||||
out[0] ? out : "(empty)");
|
||||
}
|
||||
|
||||
/* ② 回退:直接按 pid 信号(绕过 launcher 门控)。 */
|
||||
int pid = ams_pkg_pid(pkg);
|
||||
if (pid <= 0) return send_err(sock, 409, "CONFLICT", "该 App 当前无进程可终止");
|
||||
|
||||
kill(pid, SIGTERM);
|
||||
int killed_hard = 0;
|
||||
for (int i = 0; i < 5; i++) { /* SIGTERM 宽限 ~0.6s(与 launcher 杀序列同语义) */
|
||||
struct timespec ts = {0, 120 * 1000 * 1000};
|
||||
nanosleep(&ts, NULL);
|
||||
int z = proc_zombie(pid);
|
||||
if (z < 0 || z == 1) break; /* 消失/僵尸 → 已终止 */
|
||||
}
|
||||
if (proc_zombie(pid) == 0) { kill(pid, SIGKILL); killed_hard = 1; }
|
||||
|
||||
char msg[192];
|
||||
snprintf(msg, sizeof msg, "force terminated pid %d via direct signal%s", pid,
|
||||
killed_hard ? " (SIGKILL 强杀)" : " (SIGTERM)");
|
||||
sbuf_t s = {0};
|
||||
sA(&s, "{\"accepted\":true,\"op\":\"kill-fg\",\"pkg\":");
|
||||
sA_str(&s, pkg);
|
||||
sA(&s, ",\"force\":%s,\"pid\":%d,\"message\":", killed_hard ? "true" : "false", pid);
|
||||
sA_str(&s, msg);
|
||||
sA(&s, "}");
|
||||
int rc = send_json(sock, 200, s.b ? s.b : "");
|
||||
sbuf_free(&s);
|
||||
return rc;
|
||||
}
|
||||
@@ -571,6 +571,142 @@ int api_files_delete(int sock, const req_t *r)
|
||||
return send_json(sock, 200, "{\"removed\":true}");
|
||||
}
|
||||
|
||||
/* ---- F1-④ 文件搜索(FUTURE_MEOWPANEL §2.3,只读):白名单内 readdir 递归(限深 4 / 限量 200 /
|
||||
* 限遍历 8000 条),不 run_cmd("find")(防注入、可控)。name=名子串(ASCII 大小写不敏感);
|
||||
* type=file|dir 可选过滤。不跟符号链接(防循环/逃逸出白名单根)。 ---- */
|
||||
#define SEARCH_MAX_DEPTH 4
|
||||
#define SEARCH_MAX_ITEMS 200
|
||||
#define SEARCH_MAX_VISIT 8000
|
||||
|
||||
typedef struct {
|
||||
const char *needle; /* 已小写化的匹配子串 */
|
||||
int want_type; /* 0=不限 1=仅文件 2=仅目录 */
|
||||
int max;
|
||||
int count;
|
||||
long visited;
|
||||
sbuf_t *s;
|
||||
} search_ctx_t;
|
||||
|
||||
static void name_lower(const char *in, char *out, size_t cap)
|
||||
{
|
||||
size_t i = 0;
|
||||
for (; in[i] && i + 1 < cap; i++) {
|
||||
unsigned char c = (unsigned char)in[i];
|
||||
out[i] = (c >= 'A' && c <= 'Z') ? (char)(c + 32) : (char)c;
|
||||
}
|
||||
out[i] = 0;
|
||||
}
|
||||
|
||||
/* 返回 1=到达上限应全程停止(truncated)。 */
|
||||
static int search_walk(search_ctx_t *c, const char *dir, int depth)
|
||||
{
|
||||
if (c->count >= c->max || c->visited >= SEARCH_MAX_VISIT) return 1;
|
||||
DIR *d = opendir(dir);
|
||||
if (!d) return 0;
|
||||
int stop = 0;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d))) {
|
||||
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue;
|
||||
if (++c->visited > SEARCH_MAX_VISIT) { stop = 1; break; }
|
||||
char full[1400];
|
||||
snprintf(full, sizeof full, "%s/%s", dir, e->d_name);
|
||||
struct stat st;
|
||||
if (lstat(full, &st) < 0) continue;
|
||||
if (S_ISLNK(st.st_mode)) continue; /* 不跟符号链接 */
|
||||
int isdir = S_ISDIR(st.st_mode);
|
||||
int type_ok = !c->want_type || (c->want_type == 1 && !isdir) || (c->want_type == 2 && isdir);
|
||||
if (type_ok) {
|
||||
char low[300];
|
||||
name_lower(e->d_name, low, sizeof low);
|
||||
if (strstr(low, c->needle)) {
|
||||
if (c->count >= c->max) { stop = 1; break; }
|
||||
char rel[1200];
|
||||
make_rel(full, rel, sizeof rel);
|
||||
if (c->count) sA(c->s, ",");
|
||||
sA(c->s, "{\"name\":");
|
||||
sA_str(c->s, e->d_name);
|
||||
sA(c->s, ",\"type\":\"%s\",\"size\":%lld,\"mtime\":%lld,\"rel\":",
|
||||
isdir ? "dir" : "file", isdir ? 0LL : (long long)st.st_size, (long long)st.st_mtime);
|
||||
sA_str(c->s, rel);
|
||||
sA(c->s, "}");
|
||||
c->count++;
|
||||
}
|
||||
}
|
||||
if (isdir && depth < SEARCH_MAX_DEPTH && search_walk(c, full, depth + 1)) { stop = 1; break; }
|
||||
}
|
||||
closedir(d);
|
||||
return stop;
|
||||
}
|
||||
|
||||
/* 0=OK(JSON 入 out);-1 白名单外;-2 路径不存在/非目录;-3 name 空。 */
|
||||
int files_search_json(const char *abs_start, const char *name, const char *typef, int max, sbuf_t *out)
|
||||
{
|
||||
if (!path_in_whitelist(abs_start)) return -1;
|
||||
struct stat st;
|
||||
if (stat(abs_start, &st) < 0 || !S_ISDIR(st.st_mode)) return -2;
|
||||
char needle[300];
|
||||
name_lower(name ? name : "", needle, sizeof needle);
|
||||
if (!needle[0]) return -3;
|
||||
int want_type = 0;
|
||||
if (typef && !strcmp(typef, "file")) want_type = 1;
|
||||
else if (typef && !strcmp(typef, "dir")) want_type = 2;
|
||||
else if (typef && typef[0]) return -4; /* 非法 type 值(api 层映射 422) */
|
||||
if (max <= 0 || max > SEARCH_MAX_ITEMS) max = SEARCH_MAX_ITEMS;
|
||||
search_ctx_t c;
|
||||
memset(&c, 0, sizeof c);
|
||||
c.needle = needle; c.want_type = want_type; c.max = max; c.s = out;
|
||||
char rel[1200];
|
||||
make_rel(abs_start, rel, sizeof rel);
|
||||
sA(out, "{\"path\":");
|
||||
sA_str(out, abs_start);
|
||||
sA(out, ",\"rel\":");
|
||||
sA_str(out, rel);
|
||||
sA(out, ",\"name\":");
|
||||
sA_str(out, name);
|
||||
sA(out, ",\"items\":[");
|
||||
int stopped = search_walk(&c, abs_start, 1);
|
||||
sA(out, "],\"count\":%d,\"truncated\":%s}", c.count,
|
||||
(stopped || c.visited >= SEARCH_MAX_VISIT) ? "true" : "false");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int api_files_search(int sock, const req_t *r)
|
||||
{
|
||||
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
|
||||
char path[1024] = "/";
|
||||
if (r->query[0]) qparam(r->query, "path", path, sizeof path);
|
||||
char name[512] = "";
|
||||
if (!qparam(r->query, "name", name, sizeof name) || !name[0])
|
||||
return send_err(sock, 400, "BAD_REQUEST", "缺 name(按名子串搜索)");
|
||||
char typef[16] = "";
|
||||
qparam(r->query, "type", typef, sizeof typef);
|
||||
char maxs[16] = "";
|
||||
int max = SEARCH_MAX_ITEMS;
|
||||
if (qparam(r->query, "max", maxs, sizeof maxs)) { int m = atoi(maxs); if (m > 0) max = m; }
|
||||
char abs[1024];
|
||||
if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs))
|
||||
return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸");
|
||||
sbuf_t s = {0};
|
||||
int rc = files_search_json(abs, name, typef, max, &s);
|
||||
if (rc == -1) { sbuf_free(&s); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); }
|
||||
if (rc == -2) { sbuf_free(&s); return send_err(sock, 404, "NOT_FOUND", "路径不存在或非目录"); }
|
||||
if (rc == -4) { sbuf_free(&s); return send_err(sock, 422, "UNSUPPORTED", "type 须为 file|dir"); }
|
||||
if (rc != 0) { sbuf_free(&s); return send_err(sock, 400, "BAD_REQUEST", "name 为空"); }
|
||||
/* 放行前自校验(与 overview 同口径:括号平衡/内嵌 NUL/坏字节,浏览器 JSON.parse 不容忍) */
|
||||
jv_t *chk = jv_parse(s.b ? s.b : "{}", (int)(s.b ? s.n : 2));
|
||||
int bad = !s.b || !chk || (strlen(s.b) != s.n) ||
|
||||
!utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n);
|
||||
if (chk) jv_free(chk);
|
||||
if (bad) {
|
||||
panel_log("files/search: SELF-CHECK fail (n=%zu)", s.n);
|
||||
sbuf_free(&s);
|
||||
return send_err(sock, 500, "INTERNAL", "搜索结果输出未通过 JSON 自校验");
|
||||
}
|
||||
int out = send_json(sock, 200, s.b);
|
||||
sbuf_free(&s);
|
||||
return out;
|
||||
}
|
||||
|
||||
int multipart_extract_file(const req_t *r, const char *outpath)
|
||||
{
|
||||
const char *b = strstr(r->content_type, "boundary=");
|
||||
|
||||
@@ -175,6 +175,160 @@ int api_net_dns_flush(int sock)
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* ---- F1-③ 蓝牙只读详情(FUTURE_MEOWPANEL §2.6):netmgrd MSG_NET_BT_CTL(0xE6) 读级五命令
|
||||
* (status/paired/connected/a2dp/devices)聚合为一份 JSON。读级任意调用者(bt_cmd_is_write=读),
|
||||
* 无阻塞(net_ipc 2s 超时兜底);子命令失败/坏字节 → 该子块空数组降级,绝不拖垮整卡、绝不发非法 JSON。 ---- */
|
||||
|
||||
/* 单发 0xE6 读命令;成功返回文本长度(>0),IPC/服务端失败 -1,坏 UTF-8 -2(该子块降级)。 */
|
||||
static int bt_ctl_read(const char *cmd, char *out, size_t cap)
|
||||
{
|
||||
m_net_cmd_t q; memset(&q, 0, sizeof q);
|
||||
snprintf(q.cmd, sizeof q.cmd, "%s", cmd);
|
||||
uint16_t t; uint32_t code;
|
||||
int n = net_ipc(MSG_NET_BT_CTL, &q, sizeof q, &t, &code, NULL, out, cap);
|
||||
if (n <= 0 || t != MSG_NET_BT_RESULT || code != MEOW_OK) return -1;
|
||||
if (!utf8_valid_n(out, (size_t)n)) return -2;
|
||||
return n;
|
||||
}
|
||||
|
||||
static int bt_mac_ok(const char *m)
|
||||
{
|
||||
for (int i = 0; i < 17; i++) {
|
||||
if (i % 3 == 2) { if (m[i] != ':') return 0; }
|
||||
else if (!is_hex(m[i])) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* "Device <MAC> <Name>" 行集 → JSON 数组项(不合法行跳过;名经 sA_str_n 消毒)。返回条数。 */
|
||||
static int bt_devlines_json(sbuf_t *s, const char *txt, int max)
|
||||
{
|
||||
int n = 0;
|
||||
const char *ln = txt;
|
||||
while (ln && *ln && n < max) {
|
||||
const char *nl = strchr(ln, '\n');
|
||||
size_t ll = nl ? (size_t)(nl - ln) : strlen(ln);
|
||||
const char *p = ln;
|
||||
while ((size_t)(p - ln) < ll && (*p == ' ' || *p == '\t')) p++;
|
||||
if ((size_t)(ln + ll - p) >= 7 + 17 && !strncmp(p, "Device ", 7) && bt_mac_ok(p + 7)) {
|
||||
const char *mac = p + 7;
|
||||
const char *nm = mac + 17;
|
||||
while ((size_t)(nm - ln) < ll && (*nm == ' ' || *nm == '\t')) nm++;
|
||||
size_t nlen = ll - (size_t)(nm - ln);
|
||||
while (nlen && (nm[nlen - 1] == ' ' || nm[nlen - 1] == '\t' || nm[nlen - 1] == '\r')) nlen--;
|
||||
if (n) sA(s, ",");
|
||||
sA(s, "{\"mac\":");
|
||||
sA_str_n(s, mac, 17);
|
||||
sA(s, ",\"name\":");
|
||||
sA_str_n(s, nm, nlen);
|
||||
sA(s, "}");
|
||||
n++;
|
||||
}
|
||||
ln = nl ? nl + 1 : NULL;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 行内 " key=value"(空白/行尾收尾)取值。 */
|
||||
static void bt_line_kv(const char *line, const char *key, char *out, size_t cap)
|
||||
{
|
||||
out[0] = 0;
|
||||
const char *v = strstr(line, key);
|
||||
if (!v) return;
|
||||
v += strlen(key);
|
||||
size_t i = 0;
|
||||
while (v[i] && v[i] != ' ' && v[i] != '\t' && v[i] != '\n' && v[i] != '\r' && i + 1 < cap) { out[i] = v[i]; i++; }
|
||||
out[i] = 0;
|
||||
}
|
||||
|
||||
/* "A2DP <mac> codec=.. rate=.. channels=.. bits=.. acquired=<0|1> role=<..>" 行集 → JSON 数组项。 */
|
||||
static int bt_a2dp_json(sbuf_t *s, const char *txt, int max)
|
||||
{
|
||||
int n = 0;
|
||||
const char *ln = txt;
|
||||
while (ln && *ln && n < max) {
|
||||
const char *nl = strchr(ln, '\n');
|
||||
size_t ll = nl ? (size_t)(nl - ln) : strlen(ln);
|
||||
char line[320];
|
||||
if (ll >= sizeof line) ll = sizeof line - 1;
|
||||
memcpy(line, ln, ll); line[ll] = 0;
|
||||
if (!strncmp(line, "A2DP ", 5) && strncmp(line, "A2DP none", 9) && bt_mac_ok(line + 5)) {
|
||||
char codec[24], rate[16], chn[8], bits[8], acq[8], role[8];
|
||||
bt_line_kv(line, " codec=", codec, sizeof codec);
|
||||
bt_line_kv(line, " rate=", rate, sizeof rate);
|
||||
bt_line_kv(line, " channels=", chn, sizeof chn);
|
||||
bt_line_kv(line, " bits=", bits, sizeof bits);
|
||||
bt_line_kv(line, " acquired=", acq, sizeof acq);
|
||||
bt_line_kv(line, " role=", role, sizeof role);
|
||||
if (n) sA(s, ",");
|
||||
sA(s, "{\"mac\":");
|
||||
sA_str_n(s, line + 5, 17);
|
||||
sA(s, ",\"codec\":"); sA_str(s, codec);
|
||||
sA(s, ",\"rate\":"); sA_str(s, rate);
|
||||
sA(s, ",\"channels\":"); sA_str(s, chn);
|
||||
sA(s, ",\"bits\":"); sA_str(s, bits);
|
||||
sA(s, ",\"acquired\":%s", !strcmp(acq, "1") ? "true" : "false");
|
||||
sA(s, ",\"role\":"); sA_str(s, role);
|
||||
sA(s, "}");
|
||||
n++;
|
||||
}
|
||||
ln = nl ? nl + 1 : NULL;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 五读文本聚合(任一 NULL=该子块空数组;st=NULL → {"available":false})。 */
|
||||
int net_bt_aggregate(const char *st, const char *paired, const char *connected,
|
||||
const char *a2dp, const char *devices, sbuf_t *s)
|
||||
{
|
||||
if (!st) { sA(s, "{\"available\":false}"); return -1; }
|
||||
int avail = 0, powered = 0; long long paired_n = -1, connected_n = -1;
|
||||
char addr[24] = "";
|
||||
(void)sscanf(st, "bt available=%d powered=%d address=%23s paired=%lld connected=%lld",
|
||||
&avail, &powered, addr, &paired_n, &connected_n);
|
||||
if (!strcmp(addr, "-")) addr[0] = 0;
|
||||
sA(s, "{\"available\":%s", avail ? "true" : "false");
|
||||
sA(s, ",\"powered\":%s", powered ? "true" : "false");
|
||||
sA(s, ",\"address\":"); sA_str(s, addr);
|
||||
if (paired_n >= 0) sA(s, ",\"paired_n\":%lld", paired_n); else sA(s, ",\"paired_n\":null");
|
||||
if (connected_n >= 0) sA(s, ",\"connected_n\":%lld", connected_n); else sA(s, ",\"connected_n\":null");
|
||||
sA(s, ",\"paired\":["); if (paired) bt_devlines_json(s, paired, 32); sA(s, "]");
|
||||
sA(s, ",\"connected\":["); if (connected) bt_devlines_json(s, connected, 16); sA(s, "]");
|
||||
sA(s, ",\"a2dp\":["); if (a2dp) bt_a2dp_json(s, a2dp, 8); sA(s, "]");
|
||||
sA(s, ",\"nearby\":["); if (devices) bt_devlines_json(s, devices, 16); sA(s, "]");
|
||||
sA(s, "}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int api_net_bt(int sock)
|
||||
{
|
||||
char st[MEOW_IPC_MAX], pa[MEOW_IPC_MAX], co[MEOW_IPC_MAX], a2[MEOW_IPC_MAX], dv[MEOW_IPC_MAX];
|
||||
int ns = bt_ctl_read("status", st, sizeof st);
|
||||
if (ns <= 0) /* netmgrd 不可达/status 坏字节 → 整体降级(net 域惯例:200 available:false) */
|
||||
return send_json(sock, 200, "{\"available\":false}");
|
||||
int np = bt_ctl_read("paired", pa, sizeof pa);
|
||||
int nc = bt_ctl_read("connected", co, sizeof co);
|
||||
int na = bt_ctl_read("a2dp", a2, sizeof a2);
|
||||
int nd = bt_ctl_read("devices", dv, sizeof dv);
|
||||
sbuf_t s = {0};
|
||||
net_bt_aggregate(st, np > 0 ? pa : NULL, nc > 0 ? co : NULL, na > 0 ? a2 : NULL, nd > 0 ? dv : NULL, &s);
|
||||
/* 放行前自校验(与 overview 同口径:浏览器 JSON.parse 不容忍尾随垃圾/括号错位/内嵌 NUL/坏字节) */
|
||||
jv_t *chk = jv_parse(s.b ? s.b : "{}", (int)(s.b ? s.n : 2));
|
||||
int bad = !s.b || !chk || (strlen(s.b) != s.n) ||
|
||||
!utf8_valid_n(s.b, s.n) || !json_strict_balanced(s.b, s.n);
|
||||
if (chk) jv_free(chk);
|
||||
if (bad) {
|
||||
FILE *df = fopen("/tmp/net_bt_bad.json", "w");
|
||||
if (df && s.b) { fwrite(s.b, 1, s.n, df); fclose(df); }
|
||||
panel_log("net/bt: SELF-CHECK fail, raw dumped /tmp/net_bt_bad.json");
|
||||
sbuf_free(&s);
|
||||
return send_err(sock, 500, "INTERNAL", "蓝牙聚合输出未通过 JSON 自校验(原始数据已留 /tmp/net_bt_bad.json)");
|
||||
}
|
||||
int rc = send_json(sock, 200, s.b);
|
||||
sbuf_free(&s);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int api_net_log(int sock)
|
||||
{
|
||||
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
|
||||
@@ -285,11 +439,30 @@ int api_net_ifaces(int sock)
|
||||
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)
|
||||
{
|
||||
char lvl[24] = "", enf[24] = "dns_only";
|
||||
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 后面紧跟全角「(」多字节说明,须在非 ASCII 字节处停(否则会吞「(SOCK_DESTROY...)」)。 */
|
||||
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)
|
||||
{
|
||||
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);
|
||||
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");
|
||||
@@ -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 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");
|
||||
/* 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);
|
||||
q.op = (uint32_t)nop;
|
||||
|
||||
@@ -71,6 +71,18 @@ done:
|
||||
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)
|
||||
{
|
||||
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 不可达)");
|
||||
if (rt != MSG_PM_RESULT || !utf8_valid_n(js, strlen(js)))
|
||||
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)
|
||||
{
|
||||
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);
|
||||
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");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* mp_screen.c — 实时画面域:/api/screen.jpeg(fb0→JPEG,增量帧)+ 触摸注入 /api/input/tap。 */
|
||||
/* mp_screen.c — 实时画面域:/api/screen.jpeg(fb0→JPEG,增量帧)+ 触摸注入 /api/input/tap
|
||||
* 与系统手势注入 /api/input/gesture(back=左缘右滑 / home=底部上滑回桌面)。 */
|
||||
#include "mp.h"
|
||||
|
||||
static uint8_t *g_screen_prev = NULL; /* 上一帧紧凑 RGB565 */
|
||||
@@ -6,6 +7,17 @@ static int g_screen_pw = 0, g_screen_ph = 0;
|
||||
static int64_t g_last_self_inject_ms = 0;
|
||||
#define TOUCH_ACTIVE_MS 5000
|
||||
|
||||
/* 手势注入阈值(镜像 meow_ui_input.c / np_gesture.h 系统已识别手势,勿新造语义):
|
||||
* 返回=左缘(起)右移 ≥SWIPE_THRESH 且横向主导;回桌面=底部热区(起)上移 ≥HOME_SWIPE
|
||||
* 且竖向主导 + down_x≥EDGE_W(避左缘返回列)。此处位移留容差(≥65)。 */
|
||||
#define GESTURE_EDGE_W 24 /* 左缘宽度:返回手势起点 x 须 < 此值(meow_ui_input.c EDGE_W) */
|
||||
#define GESTURE_SWIPE_DX 78 /* 返回手势右移位移(≥ SDK SWIPE_THRESH 65,留容差) */
|
||||
#define GESTURE_HOME_EDGE_H 28 /* 底部热区高:回桌面起点 y 须 >= fb_h-此值(HOME_EDGE_H) */
|
||||
#define GESTURE_HOME_DY 80 /* 回桌面手势上移位移(≥ SDK HOME_SWIPE 65,留容差) */
|
||||
#define GESTURE_STEPS 10 /* 线性轨迹插值点数(营造真实滑动轨迹) */
|
||||
#define GESTURE_STEP_MS 12 /* 每步间隔毫秒 */
|
||||
#define GESTURE_HOLD_MS 60 /* 末端按住毫秒再抬起(防 fling 误判) */
|
||||
|
||||
|
||||
int screen_read_frame(const char *dev, uint8_t **out, int *w, int *h)
|
||||
{
|
||||
@@ -230,6 +242,47 @@ int inject_tap(const char *dev, int x, int y)
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* 线性滑动注入:按下(原点) → 插值移动 → 末端按住 → 抬起。事件序同 inject_tap
|
||||
* (BTN_TOUCH 边沿先于 ABS,goodix 时序免疫;首帧锁原点),设备手势机据此判
|
||||
* 「左缘右滑=返回」或「底部上滑=回桌面」(复用系统已识别信号,不新造)。 */
|
||||
int inject_swipe(const char *dev, int x0, int y0, int x1, int y1)
|
||||
{
|
||||
int fd = open(dev, O_WRONLY);
|
||||
if (fd < 0) return -1;
|
||||
struct input_event ev;
|
||||
#define __WE(t_, c_, v_) do { memset(&ev, 0, sizeof ev); ev.type = (t_); ev.code = (c_); \
|
||||
ev.value = (v_); if (write(fd, &ev, sizeof ev) != (ssize_t)sizeof ev) { close(fd); return -1; } } while (0)
|
||||
/* 按下边沿 + 首帧 ABS 锁原点(与 tap 同序) */
|
||||
__WE(EV_KEY, BTN_TOUCH, 1);
|
||||
__WE(EV_ABS, ABS_MT_SLOT, 0);
|
||||
__WE(EV_ABS, ABS_MT_TRACKING_ID, 7);
|
||||
__WE(EV_ABS, ABS_MT_POSITION_X, x0);
|
||||
__WE(EV_ABS, ABS_MT_POSITION_Y, y0);
|
||||
__WE(EV_ABS, ABS_MT_TOUCH_MAJOR, 6);
|
||||
__WE(EV_ABS, ABS_X, x0);
|
||||
__WE(EV_ABS, ABS_Y, y0);
|
||||
__WE(EV_SYN, SYN_REPORT, 0);
|
||||
/* 插值逐点移动:每步坐标更新 + 同步,营造连续轨迹 */
|
||||
for (int i = 1; i <= GESTURE_STEPS; i++) {
|
||||
int xi = x0 + (x1 - x0) * i / GESTURE_STEPS;
|
||||
int yi = y0 + (y1 - y0) * i / GESTURE_STEPS;
|
||||
__WE(EV_ABS, ABS_MT_POSITION_X, xi);
|
||||
__WE(EV_ABS, ABS_MT_POSITION_Y, yi);
|
||||
__WE(EV_ABS, ABS_X, xi);
|
||||
__WE(EV_ABS, ABS_Y, yi);
|
||||
__WE(EV_SYN, SYN_REPORT, 0);
|
||||
usleep((useconds_t)GESTURE_STEP_MS * 1000);
|
||||
}
|
||||
/* 末端短暂按住再抬起(与 tap 同:抬起到按下边沿被手势机视为一次抬手) */
|
||||
usleep((useconds_t)GESTURE_HOLD_MS * 1000);
|
||||
__WE(EV_KEY, BTN_TOUCH, 0);
|
||||
__WE(EV_ABS, ABS_MT_TRACKING_ID, -1);
|
||||
__WE(EV_SYN, SYN_REPORT, 0);
|
||||
#undef __WE
|
||||
close(fd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int api_input_tap(int sock, const req_t *r)
|
||||
{
|
||||
if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
|
||||
@@ -275,3 +328,71 @@ int api_input_tap(int sock, const req_t *r)
|
||||
sbuf_free(&s);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* /api/input/gesture:注入设备已识别的系统手势(back=左缘右滑 / home=底部上滑回桌面)。
|
||||
* 复用现有触摸注入通道(/dev/input/event1 滑动轨迹),不触碰被物理键红线禁注入的
|
||||
* /api/key 158/172;由设备自身手势机判成 MEOW_SWIPE_LEFT / MEOW_EV_HOME。 */
|
||||
int api_input_gesture(int sock, const req_t *r)
|
||||
{
|
||||
if (!r->body || !r->body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
|
||||
jv_t *root = jv_parse(r->body, (int)r->body_len);
|
||||
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
|
||||
jv_t *jg = jv_obj_get(root, "gesture");
|
||||
if (!jg || jg->type != J_STR) {
|
||||
jv_free(root);
|
||||
return send_err(sock, 400, "BAD_REQUEST", "缺 gesture(back|home)");
|
||||
}
|
||||
const char *gesture = jg->str;
|
||||
int is_back = (strcmp(gesture, "back") == 0);
|
||||
int is_home = (strcmp(gesture, "home") == 0);
|
||||
jv_free(root);
|
||||
if (!is_back && !is_home)
|
||||
return send_err(sock, 400, "BAD_REQUEST", "gesture 非法(仅 back|home)");
|
||||
|
||||
const char *dev = getenv("MEOW_TOUCH_DEV"); if (!dev || !dev[0]) dev = TOUCH_DEV;
|
||||
const char *fbdev = getenv("MEOW_FB_DEV"); if (!fbdev || !fbdev[0]) fbdev = FB_DEV;
|
||||
int sw = 480, sh = 720;
|
||||
{ int fx = 0, fy = 0; if (fb_dims(fbdev, &fx, &fy) == 0) { sw = fx; sh = fy; } }
|
||||
if (sw < 1) sw = 480;
|
||||
if (sh < 1) sh = 720;
|
||||
|
||||
/* 几何(镜像 SDK 判据 + 留容差,见上 GESTURE_* 宏) */
|
||||
int x0 = 0, y0 = 0, x1 = 0, y1 = 0;
|
||||
if (is_back) {
|
||||
x0 = 4; /* 左缘内(< EDGE_W=24) */
|
||||
if (x0 >= sw) x0 = sw - 1;
|
||||
if (x0 >= GESTURE_EDGE_W) x0 = GESTURE_EDGE_W - 1;
|
||||
y0 = y1 = sh / 2; /* 屏中部,避开顶部下拉/底部上滑热区 */
|
||||
x1 = x0 + GESTURE_SWIPE_DX;
|
||||
if (x1 >= sw) x1 = sw - 1; /* 横向主导 + 右移≥阈值 */
|
||||
} else { /* home */
|
||||
x0 = x1 = sw / 2; /* 屏底中部,避开左缘返回列 */
|
||||
if (x0 < GESTURE_EDGE_W) x0 = x1 = GESTURE_EDGE_W;
|
||||
y0 = sh - 2; /* 底部热区(>= fb_h-HOME_EDGE_H) */
|
||||
if (y0 >= sh) y0 = sh - 1;
|
||||
y1 = y0 - GESTURE_HOME_DY;
|
||||
if (y1 < 0) y1 = 0; /* 竖向主导 + 上移≥阈值 */
|
||||
}
|
||||
|
||||
/* 仲裁:本机最近触摸后 5s 内忽略注入(与 tap 同) */
|
||||
int64_t now = now_mono_ms();
|
||||
int64_t last = touch_last_local_ms(dev);
|
||||
if (last > 0 && (now - last) < TOUCH_ACTIVE_MS) {
|
||||
sbuf_t s = {0};
|
||||
sA(&s, "{\"dispatched\":false,\"reason\":\"local_touch_active\",\"cooldown_ms\":%lld}",
|
||||
(long long)(TOUCH_ACTIVE_MS - (now - last)));
|
||||
int ret = send_json(sock, 200, s.b ? s.b : "");
|
||||
sbuf_free(&s);
|
||||
return ret;
|
||||
}
|
||||
|
||||
g_last_self_inject_ms = now_mono_ms();
|
||||
if (inject_swipe(dev, x0, y0, x1, y1) < 0)
|
||||
return send_err(sock, 500, "INTERNAL", "手势注入失败(无法打开触摸设备)");
|
||||
sbuf_t s = {0};
|
||||
sA(&s, "{\"dispatched\":true,\"gesture\":\"%s\",\"from\":[%d,%d],\"to\":[%d,%d]}",
|
||||
gesture, x0, y0, x1, y1);
|
||||
int ret = send_json(sock, 200, s.b ? s.b : "");
|
||||
sbuf_free(&s);
|
||||
return ret;
|
||||
}
|
||||
@@ -289,6 +289,7 @@ int handle(int sock, req_t *r)
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/power/history")) return api_power_history(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/power/events")) return api_power_events(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/ams")) return api_ams(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/ams/bg")) return api_ams_bg(sock); /* 批④ 后台活动列表(只读) */
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/svcd")) return api_svcd(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/overview")) return api_overview(sock);
|
||||
if (!strcmp(r->method, "GET")) {
|
||||
@@ -300,6 +301,7 @@ int handle(int sock, req_t *r)
|
||||
/* ---- 网络管理(meow_netmgrd 期2/期3 透传:读级任意调用者;flush/policy 写级走 require_auth) ---- */
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/status")) return api_net_status(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/ifaces")) return api_net_ifaces(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/bt")) return api_net_bt(sock); /* F1-③ 蓝牙只读详情(0xE6 读级聚合) */
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/policy")) return api_net_policy_get(sock);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/conns")) return api_net_conns(sock, r->query);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/net/dns")) return api_net_dns(sock);
|
||||
@@ -338,16 +340,22 @@ int handle(int sock, req_t *r)
|
||||
if (!strcmp(p, "/api/audio/volume")) return api_audio_volume(sock, r->body, r->body_len);
|
||||
if (!strcmp(p, "/api/key")) return api_key_batch(sock, r);
|
||||
if (!strcmp(p, "/api/input/tap")) return api_input_tap(sock, r);
|
||||
if (!strcmp(p, "/api/input/gesture")) return api_input_gesture(sock, r);
|
||||
char seg[256];
|
||||
if (path_segment("/api/key/", p, seg, sizeof seg))
|
||||
return api_key_single(sock, atoi(seg), r);
|
||||
/* POST /api/ams/<pkg>/start|kill(管启/停;require_auth 已护,kill 前台前端二次确认) */
|
||||
/* POST /api/ams/<pkg>/start|kill|killfg(管启/停;require_auth 已护,kill 前台前端二次确认;
|
||||
* killfg=前台救命强制终止:am kill 优先,launcher 拒→直接 pid 信号,前端二次确认+标注前台) */
|
||||
if (!strncmp(p, "/api/ams/", 9)) {
|
||||
size_t pl = strlen(p);
|
||||
if (pl > 5 && !strcmp(p + pl - 6, "/start")) {
|
||||
char pkg[256];
|
||||
if (path_segment("/api/ams/", p, pkg, sizeof pkg))
|
||||
return api_ams_ctl(sock, pkg, 0);
|
||||
} else if (pl > 7 && !strcmp(p + pl - 7, "/killfg")) {
|
||||
char pkg[256];
|
||||
if (path_segment("/api/ams/", p, pkg, sizeof pkg))
|
||||
return api_ams_kill_fg(sock, pkg);
|
||||
} else if (pl > 4 && !strcmp(p + pl - 5, "/kill")) {
|
||||
char pkg[256];
|
||||
if (path_segment("/api/ams/", p, pkg, sizeof pkg))
|
||||
@@ -374,6 +382,7 @@ int handle(int sock, req_t *r)
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files")) return api_files_list(sock, r);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/read")) return api_files_read(sock, r);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/download")) return api_files_download(sock, r);
|
||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/files/search")) return api_files_search(sock, r); /* F1-④ 搜索(只读) */
|
||||
if (!strcmp(r->method, "DELETE") && !strcmp(p, "/api/files")) return api_files_delete(sock, r);
|
||||
|
||||
/* ---- 应用 ---- */
|
||||
@@ -516,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; }
|
||||
jv_t *enf = jv_obj_get(pr, "enforce");
|
||||
if (!enf || enf->type != J_STR || !enf->str || strcmp(enf->str, "dns_only") != 0) { printf("SELFTEST FAIL: net_policy_parse enforce 未如实透传 dns_only\n"); jv_free(pr); sbuf_free(&p); return 1; }
|
||||
jv_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);
|
||||
sbuf_free(&p);
|
||||
}
|
||||
@@ -536,7 +547,205 @@ int panel_selftest(void)
|
||||
sbuf_free(&h2);
|
||||
}
|
||||
|
||||
printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 新增端点池 → 合法 JSON + 无非法字节\n");
|
||||
/* 6) ams_bg_parse:喂含 bg/fg 混合 state 的 am list 文本 → 仅 bg 条目、合法 JSON,
|
||||
* cpu_pct/rss_kb 为 null(读不到/首采样)或数字——绝不编造不一致字段。 */
|
||||
{
|
||||
char list[] =
|
||||
"# amd register+state\n"
|
||||
"APP os.meow.a 名A bg 1 1 0 clean 0\n"
|
||||
"APP os.meow.b 名B fg 9999992 1 0 clean 0\n"
|
||||
"APP os.meow.c 名C bg 9999991 2 1 exit 1\n";
|
||||
sbuf_t b = {0};
|
||||
ams_bg_parse(list, &b);
|
||||
if (!b.b || !utf8_valid_n(b.b, b.n)) { printf("SELFTEST FAIL: ams_bg_parse 产物非法字节/为空\n"); sbuf_free(&b); return 1; }
|
||||
jv_t *br = jv_parse(b.b, (int)b.n);
|
||||
if (!br) { printf("SELFTEST FAIL: ams_bg_parse 产物 JSON 无法解析\n"); sbuf_free(&b); return 1; }
|
||||
jv_t *bapps = jv_obj_get(br, "apps");
|
||||
if (!bapps || bapps->type != J_ARR) { printf("SELFTEST FAIL: ams_bg_parse 缺 apps 数组\n"); jv_free(br); sbuf_free(&b); return 1; }
|
||||
int bcnt = 0;
|
||||
for (jv_t *e = bapps->child; e; e = e->next) {
|
||||
bcnt++;
|
||||
jv_t *st = jv_obj_get(e, "state");
|
||||
if (!st || st->type != J_STR || strcmp(st->str, "bg") != 0) { printf("SELFTEST FAIL: ams_bg_parse 条目 state 非 bg\n"); jv_free(br); sbuf_free(&b); return 1; }
|
||||
jv_t *cp = jv_obj_get(e, "cpu_pct");
|
||||
jv_t *rs = jv_obj_get(e, "rss_kb");
|
||||
if (!cp || (cp->type != J_NULL && cp->type != J_NUM)) { printf("SELFTEST FAIL: ams_bg_parse cpu_pct 非 null/数字\n"); jv_free(br); sbuf_free(&b); return 1; }
|
||||
if (!rs || (rs->type != J_NULL && rs->type != J_NUM)) { printf("SELFTEST FAIL: ams_bg_parse rss_kb 非 null/数字\n"); jv_free(br); sbuf_free(&b); return 1; }
|
||||
}
|
||||
if (bcnt != 2) { printf("SELFTEST FAIL: ams_bg_parse bg 条目应 2 个,实际 %d\n", bcnt); jv_free(br); sbuf_free(&b); return 1; }
|
||||
jv_free(br);
|
||||
sbuf_free(&b);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ static const setdef_t g_sets[] = {
|
||||
{ "panel.pin", ST_STR, "", 0,0, NULL, NULL, "面板" },
|
||||
{ "panel.auth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" },
|
||||
{ "panel.auth_pass", ST_STR, "", 0,0, NULL, NULL, "面板" },
|
||||
/* 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]))
|
||||
|
||||
@@ -143,6 +147,11 @@ int settings_get_int(const char *key, int defv)
|
||||
return r;
|
||||
}
|
||||
|
||||
int panel_policy_write_enabled(void)
|
||||
{
|
||||
return settings_get_int("panel.policy_enabled", 0) ? 1 : 0;
|
||||
}
|
||||
|
||||
int brightness_clamp(int v)
|
||||
{
|
||||
int mn = settings_get_int("display.brightness_min", 1);
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="9" cy="6" r="2.2" fill="var(--bg)"/><circle cx="15" cy="12" r="2.2" fill="var(--bg)"/><circle cx="7" cy="18" r="2.2" fill="var(--bg)"/></svg></span>
|
||||
<span class="nav-label">进程·服务</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="bg" title="App 活动(前台可终止救命 · 后台常驻:CPU/内存占用 + 一键清后台)">
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><path d="M12 3 3 8l9 5 9-5-9-5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M3 12l9 5 9-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M3 16l9 5 9-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg></span>
|
||||
<span class="nav-label">后台</span>
|
||||
</button>
|
||||
<button class="nav-item" data-route="system" title="系统信息 / 升级">
|
||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="6" y="3" width="12" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 8h6M9 12h6M9 16h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||
<span class="nav-label">系统</span>
|
||||
@@ -92,6 +96,7 @@
|
||||
</button>
|
||||
<h1 class="topbar-title" id="page-title">首页仪表盘</h1>
|
||||
<div class="topbar-actions">
|
||||
<button class="btn topbar-btn" id="bg-clear" hidden>一键清后台</button>
|
||||
<span class="conn" id="conn-dot" data-state="unknown" title="后端连接">
|
||||
<span class="conn-dot"></span><span class="conn-text" id="conn-text">检测中…</span>
|
||||
</span>
|
||||
|
||||
@@ -171,6 +171,8 @@ body{
|
||||
.conn[data-state="bad"] .conn-dot{ background:var(--error); }
|
||||
.conn[data-state="ok"]{ color:var(--success); }
|
||||
.conn[data-state="bad"]{ color:var(--error); }
|
||||
.topbar-actions{ display:flex; align-items:center; gap:8px; }
|
||||
.topbar-btn{ padding:6px 12px; font-size:12px; }
|
||||
|
||||
/* 内容区 */
|
||||
.content{ flex:1; overflow-y:auto; padding:20px; }
|
||||
@@ -268,6 +270,25 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
|
||||
.app-card .app-pkg{ font-size:12px; color:var(--on-surface-variant); font-family:var(--mono); }
|
||||
.app-card .app-meta{ font-size:12px; color:var(--on-surface-variant); }
|
||||
.app-card .app-actions{ display:flex; gap:6px; margin-top:auto; flex-wrap:wrap; }
|
||||
.app-detail-card{ grid-column:1/-1; }
|
||||
.app-detail-card .app-detail-head{ display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:6px; }
|
||||
.app-detail-card .app-detail-head strong{ font-family:var(--mono); }
|
||||
|
||||
/* 后台活动列表(批④,与启动器后台管理页观感呼应:MD3 圆角卡 + 行右杀按钮) */
|
||||
.bg-list{ display:flex; flex-direction:column; gap:8px; }
|
||||
.bg-row{
|
||||
display:flex; align-items:center; gap:12px;
|
||||
padding:12px 14px; background:var(--surface-variant);
|
||||
border-radius:var(--radius-l); border:var(--outline-w) solid var(--outline-variant);
|
||||
}
|
||||
.bg-row .bg-ico{ width:40px; height:40px; border-radius:var(--radius-m); background:var(--primary); color:var(--on-primary); display:flex; align-items:center; justify-content:center; font-weight:700; font-size:16px; flex:0 0 40px; }
|
||||
.bg-row .bg-info{ flex:1; min-width:0; }
|
||||
.bg-row .bg-name{ font-weight:600; }
|
||||
.bg-row .bg-sub{ font-size:12px; color:var(--on-surface-variant); margin-top:2px; display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||||
.bg-row .btn{ flex:0 0 auto; }
|
||||
.bg-group{ margin:14px 0 8px; font-weight:600; font-size:13px; color:var(--on-surface-variant); }
|
||||
.bg-group:first-of-type{ margin-top:0; }
|
||||
.bg-group + .bg-list{ margin-bottom:4px; }
|
||||
|
||||
/* 实时屏幕(屏幕页) */
|
||||
.screen-frame{ display:flex; justify-content:center; max-width:480px; margin:0 auto; }
|
||||
@@ -439,6 +460,20 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
|
||||
.lvl-spark .grid{ stroke:var(--outline-variant); stroke-width:1; }
|
||||
.lvl-note{ color:var(--on-surface-variant); font-size:12px; margin-top:6px; }
|
||||
|
||||
/* 性能趋势 sparkline(F1-① 多序列:CPU/内存/负载/温度)+ 功耗历史曲线(F1-②) */
|
||||
.trend-spark polyline{ vector-effect:non-scaling-stroke; }
|
||||
.trend-cpu{ stroke:#4a8bf5; }
|
||||
.trend-mem{ stroke:#00bfa5; }
|
||||
.trend-load{ stroke:#ffb300; }
|
||||
.trend-temp{ stroke:#ff7043; }
|
||||
.trend-legend{ display:flex; flex-wrap:wrap; gap:8px 16px; margin-top:12px; font-size:12px; }
|
||||
.trend-legend .sw{ display:inline-block; width:12px; height:12px; border-radius:3px; margin-right:5px; vertical-align:middle; }
|
||||
.trend-legend .seg{ display:inline-flex; align-items:center; }
|
||||
.pwr-chart{ display:block; width:100%; height:60px; margin-top:8px; }
|
||||
.pwr-chart .grid{ stroke:var(--outline-variant); stroke-width:1; }
|
||||
.pwr-mw{ stroke:#ff7043; }
|
||||
.pwr-pct{ stroke:#4a8bf5; }
|
||||
|
||||
/* 置信度徽标(measured/derived/estimated) */
|
||||
.conf-badge{ display:inline-flex; align-items:center; gap:5px; padding:3px 12px; border-radius:999px; font-size:12px; font-weight:600; }
|
||||
.conf-badge.measured{ background:rgba(76,175,80,.18); color:var(--success); }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
var MP = window.MP;
|
||||
const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, pushSpark, refreshAuthGate, resetSpark, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, updateLevelMeter, volTimer, absFor, appInfo, appStatePill, appUninstall, applyScreenDeltaDom, audioCard, audioCtlHtml, audioFlowHtml, audioPortName, audioRouteLabel, batteryCard, batteryCardBody, bindApps, bindAudio, bindFiles, bindFonts, bindHome, bindLogs, bindNet, bindNotif, bindPin, bindPower, bindProc, bindScreen, bindSettings, bindSystem, breakdownCardBody, btCard, createImgFromBlob, crumbHtml, deviceCard, doDownload, ensureScreenCanvas, escSafeAttr, estimateCardBody, eventsBar, fetchAudioOnce, fetchHomeLevel, fetchHomeSystem, fetchNetConns, fetchNetLog, fetchNetOnce, fetchOverviewOnce, fetchPerfOnce, fetchPowerOnce, fetchProcOnce, fetchScreen, fmtDur, fmtFrames, groupName, handleRemoteGesture, healthCard, importFont, importFontPage, installFromFile, installFromPath, joinRel, loadFonts, loadFontsPage, loadPinStatus, logsCrashCard, mkdirFile, navFiles, netBadge, netBannerErr, netCard, netConnsBody, netDnsBody, netIfaceCards, netPolicyCard, netStateCls, netStatusCards, newestStdout, perfCard, pinEdit, pinEditDlg, powerEventsCard, powerHistoryCard, powerPolicyCard, powerProfileLabel, powerSummaryCard, pubBytesCard, remoteBtn, remotePadHtml, remotePointerDown, remotePointerUp, removeFile, renameFile, renderApps, renderAudio, renderBright, renderFiles, renderFontsPage, renderHome, renderHomeCards, renderLogs, renderLogsFiles, renderNet, renderNotif, renderPerf, renderPerfData, renderPower, renderPowerBody, renderProc, renderProcBody, renderScreen, renderSettings, renderSystem, restartScreenPoll, row, screenTap, seedScreenCanvas, segments, sendKey, sendKeyLabel, sendKeyRepeat, sendTheme, setBrightCtl, setPanelPassword, settingControl, srcPill, stopAudio, stopHome, stopNet, stopPerf, stopPower, stopProc, stopScreenPoll, storageCard, svcStatePill, uploadFiles, viewAppStdout, viewLogsCrashTail, volSteps } = MP;
|
||||
const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, pushSpark, refreshAuthGate, resetSpark, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, updateLevelMeter, volTimer, absFor, appInfo, appStatePill, appUninstall, applyScreenDeltaDom, audioCard, audioCtlHtml, audioFlowHtml, audioPortName, audioRouteLabel, batteryCard, batteryCardBody, bindApps, bindAudio, bindBg, bindFiles, bindFonts, bindHome, bindLogs, bindNet, bindNotif, bindPin, bindPower, bindProc, bindScreen, bindSettings, bindSystem, breakdownCardBody, btCard, createImgFromBlob, crumbHtml, deviceCard, doDownload, ensureScreenCanvas, escSafeAttr, estimateCardBody, eventsBar, fetchAudioOnce, fetchHomeLevel, fetchHomeSystem, fetchNetConns, fetchNetLog, fetchNetOnce, fetchOverviewOnce, fetchPerfOnce, fetchPowerOnce, fetchProcOnce, fetchScreen, fmtDur, fmtFrames, groupName, handleRemoteGesture, healthCard, importFont, importFontPage, installFromFile, installFromPath, joinRel, loadFonts, loadFontsPage, loadPinStatus, logsCrashCard, mkdirFile, navFiles, netBadge, netBannerErr, netCard, netConnsBody, netDnsBody, netIfaceCards, netPolicyCard, netStateCls, netStatusCards, newestStdout, perfCard, pinEdit, pinEditDlg, powerEventsCard, powerHistoryCard, powerPolicyCard, powerProfileLabel, powerSummaryCard, pubBytesCard, remoteBtn, remotePadHtml, remotePointerDown, remotePointerUp, removeFile, renameFile, renderApps, renderAudio, renderBg, renderBgBody, renderBright, renderFiles, renderFontsPage, renderHome, renderHomeCards, renderLogs, renderLogsFiles, renderNet, renderNotif, renderPerf, renderPerfData, renderPower, renderPowerBody, renderProc, renderProcBody, renderScreen, renderSettings, renderSystem, restartScreenPoll, row, screenTap, seedScreenCanvas, segments, sendKey, sendKeyLabel, sendKeyRepeat, sendTheme, setBrightCtl, setPanelPassword, settingControl, srcPill, stopAudio, stopBg, stopHome, stopNet, stopPerf, stopPower, stopProc, stopScreenPoll, storageCard, svcStatePill, uploadFiles, viewAppStdout, viewLogsCrashTail, volSteps } = MP;
|
||||
|
||||
const ROUTES = {
|
||||
home: { title: '首页仪表盘', render: renderHome, bind: bindHome },
|
||||
@@ -20,7 +20,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
logs: { title: '系统日志', render: renderLogs, bind: bindLogs },
|
||||
notif: { title: '通知中心', render: renderNotif, bind: bindNotif },
|
||||
power: { title: '电源 / 功耗', render: renderPower, bind: bindPower },
|
||||
proc: { title: '进程 / 服务', render: renderProc, bind: bindProc }
|
||||
proc: { title: '进程 / 服务', render: renderProc, bind: bindProc },
|
||||
bg: { title: 'App 活动(前台可终止 · 后台管理)', render: renderBg, bind: bindBg }
|
||||
};
|
||||
let started = false;
|
||||
|
||||
@@ -33,7 +34,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
bindNav();
|
||||
bindThemeUi();
|
||||
bindSlider();
|
||||
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindPin(); bindAudio();
|
||||
bindFiles(); bindApps(); bindFonts(); bindSettings(); bindScreen(); bindSystem(); bindLogs(); bindNotif(); bindNet(); bindHome(); bindProc(); bindBg(); bindPin(); bindAudio();
|
||||
bindLogin();
|
||||
bindPwDlg();
|
||||
// 首次进入:访问级 PIN 鉴权——已登录直接进;未设 PIN(且无旧密码)→ 首次设置流程;已设 → 登录
|
||||
@@ -58,8 +59,12 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
stopHome();
|
||||
stopPower();
|
||||
stopProc();
|
||||
stopBg();
|
||||
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
|
||||
$('#page-title').textContent = r.title;
|
||||
// 顶栏「一键清后台」仅后台管理页显示(内容由 fetchBgOnce 按数据更新)
|
||||
const bc = $('#bg-clear');
|
||||
if (bc) { bc.hidden = (id !== 'bg'); if (id !== 'bg') bc.disabled = false; }
|
||||
r.render();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
var MP = window.MP = window.MP || {};
|
||||
const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, pushSpark, refreshAuthGate, resetSpark, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, updateLevelMeter, volTimer } = MP;
|
||||
const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, drawTrendSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, polylinePoints, pushSpark, pushTrend, refreshAuthGate, resetSpark, resetTrend, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, trendCpu, trendLoad, trendMem, trendSvgHtml, trendTemp, updateLevelMeter, volTimer } = MP;
|
||||
|
||||
let audioTimer = null;
|
||||
let audioVolDrag = false; // 音量滑条拖拽中(暂停 1s 重渲染,防打断交互)
|
||||
@@ -14,6 +14,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
let powerData = null, powerEvents = null, powerHistory = null;
|
||||
let procTimer = null;
|
||||
let expandedProc = null; // { pkg, file, lines, truncated }:已展开的 App stdout 尾部(重渲染后重开,防轮询清空)
|
||||
let appDetailPkg = null; // F1-⑤ 当前展开「App 详情四源聚合」的包名
|
||||
let netTimer = null;
|
||||
let netEntityFilter = '';
|
||||
let netLoaded = false; // 是否已成功加载过一次网络状态(用于「首次失败只显示横幅 + 占位」语义)
|
||||
@@ -39,7 +40,9 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
power: '<svg viewBox="0 0 24 24"><path d="M12 3v8" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M6.6 6.6a7.5 7.5 0 1 0 10.8 0" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
|
||||
play: '<svg viewBox="0 0 24 24"><path d="M7 5.5v13l11-6.5z" fill="currentColor"/></svg>',
|
||||
next: '<svg viewBox="0 0 24 24"><path d="M5 5.5l9 6.5-9 6.5z" fill="currentColor"/><rect x="16.2" y="5" width="2.4" height="14" fill="currentColor"/></svg>',
|
||||
prev: '<svg viewBox="0 0 24 24"><path d="M19 5.5l-9 6.5 9 6.5z" fill="currentColor"/><rect x="5.4" y="5" width="2.4" height="14" fill="currentColor"/></svg>'
|
||||
prev: '<svg viewBox="0 0 24 24"><path d="M19 5.5l-9 6.5 9 6.5z" fill="currentColor"/><rect x="5.4" y="5" width="2.4" height="14" fill="currentColor"/></svg>',
|
||||
back: '<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" fill="currentColor"/></svg>',
|
||||
home: '<svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor"/></svg>'
|
||||
};
|
||||
let ctlBright = { min: 1, max: 100, value: 100 }; // 控制板亮度(读取自 /api/settings/display.brightness)
|
||||
let logsGroup = 'all', logsPrefix = '';
|
||||
@@ -90,7 +93,28 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
h += stat('温度', '<span class="stat-value">' + (tmpCpu == null ? '—' : tmpCpu) + '<span class="stat-unit">℃</span></span>',
|
||||
tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : '');
|
||||
h += '</div></div>';
|
||||
/* F1-① 趋势(2s 节拍,最近 30 点≈1 分钟):CPU%/内存%/负载(1m/核)/温度(20-90℃ 归一) */
|
||||
const loadN = (cpu.load && cpu.load[0] != null && cpu.cores) ? (cpu.load[0] / cpu.cores) * 100 : null;
|
||||
pushTrend(trendCpu, cpu.usage);
|
||||
pushTrend(trendMem, mem.total ? memP : null);
|
||||
pushTrend(trendLoad, loadN);
|
||||
pushTrend(trendTemp, tmpCpu != null ? (tmpCpu - 20) / 70 * 100 : null);
|
||||
const perfTrend = [
|
||||
{ cls: 'trend-cpu', arr: trendCpu },
|
||||
{ cls: 'trend-mem', arr: trendMem },
|
||||
{ cls: 'trend-load', arr: trendLoad },
|
||||
{ cls: 'trend-temp', arr: trendTemp }
|
||||
];
|
||||
h += '<div class="card"><h3 class="card-title">趋势(2s 采样 · 最近 ' + trendCpu.length + ' 点)</h3>' +
|
||||
'<div class="trend-legend">' +
|
||||
'<span class="seg"><span class="sw" style="background:#4a8bf5"></span>CPU ' + (cpu.usage == null ? '—' : (+cpu.usage).toFixed(1) + '%') + '</span>' +
|
||||
'<span class="seg"><span class="sw" style="background:#00bfa5"></span>内存 ' + (mem.total ? memP.toFixed(1) + '%' : '—') + '</span>' +
|
||||
'<span class="seg"><span class="sw" style="background:#ffb300"></span>负载1m/核 ' + (cpu.load && cpu.load[0] != null ? (+cpu.load[0]).toFixed(2) : '—') + '</span>' +
|
||||
'<span class="seg"><span class="sw" style="background:#ff7043"></span>温度 ' + (tmpCpu == null ? '—' : tmpCpu + '℃') + '</span>' +
|
||||
'</div>' + trendSvgHtml(perfTrend) +
|
||||
'<p class="muted">温度曲线按 20–90℃ 归一;负载按 load1/核(100%=单核满载)。进页起累积,切页清零。</p></div>';
|
||||
page.innerHTML = h;
|
||||
drawTrendSpark(page, perfTrend);
|
||||
}
|
||||
|
||||
async function fetchPerfOnce() {
|
||||
@@ -107,6 +131,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
function renderPerf() {
|
||||
$('#content').innerHTML = '<div class="page" id="perf-page"><p class="placeholder">加载中…</p></div>';
|
||||
if (state.perfTimer) stopPerf();
|
||||
resetTrend(); // 进页重置趋势累积(F1-①)
|
||||
fetchPerfOnce();
|
||||
state.perfTimer = setInterval(fetchPerfOnce, 2000);
|
||||
}
|
||||
@@ -441,11 +466,21 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
const cpu = perf.cpu || {}, mem = perf.memory || {}, tmp = perf.temp || {};
|
||||
const cpuU = cpu.usage;
|
||||
const memP = mem.total ? (mem.used / mem.total) * 100 : 0;
|
||||
/* F1-① 首页迷你趋势(5s overview 节拍,CPU/内存/温度) */
|
||||
pushTrend(trendCpu, cpuU);
|
||||
pushTrend(trendMem, mem.total ? memP : null);
|
||||
pushTrend(trendTemp, tmp.cpu != null ? ((+tmp.cpu) - 20) / 70 * 100 : null);
|
||||
const homeTrend = [
|
||||
{ cls: 'trend-cpu', arr: trendCpu },
|
||||
{ cls: 'trend-mem', arr: trendMem },
|
||||
{ cls: 'trend-temp', arr: trendTemp }
|
||||
];
|
||||
return homeCard('性能小条', '<div class="stat-grid" style="grid-template-columns:repeat(auto-fill,minmax(120px,1fr))">' +
|
||||
'<div class="stat"><div class="stat-label">CPU</div><div class="stat-value">' + (cpuU == null ? '—' : (+cpuU).toFixed(1)) + '<span class="stat-unit">%</span></div>' + barHtml(cpuU || 0) + '</div>' +
|
||||
'<div class="stat"><div class="stat-label">内存</div><div class="stat-value">' + (mem.total ? fmtBytes(mem.used * 1024) : '—') + '</div>' + barHtml(mem.used && mem.total ? memP : 0) + '</div>' +
|
||||
'<div class="stat"><div class="stat-label">温度</div><div class="stat-value">' + (tmp.cpu != null ? (+tmp.cpu).toFixed(1) + '<span class="stat-unit">℃</span>' : '—') + '</div></div>' +
|
||||
'</div>');
|
||||
'</div>' + trendSvgHtml(homeTrend) +
|
||||
'<p class="muted">趋势 5s 采样 · <span style="color:#4a8bf5">CPU</span> / <span style="color:#00bfa5">内存</span> / <span style="color:#ff7043">温度(20–90℃)</span></p>');
|
||||
}
|
||||
|
||||
function powerSummaryCard() {
|
||||
@@ -481,6 +516,11 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
deviceCard() + batteryCard() + audioCard() + netCard() + btCard() + healthCard() + storageCard() + perfCard() +
|
||||
'</div>';
|
||||
body.innerHTML = h;
|
||||
drawTrendSpark(body, [
|
||||
{ cls: 'trend-cpu', arr: trendCpu },
|
||||
{ cls: 'trend-mem', arr: trendMem },
|
||||
{ cls: 'trend-temp', arr: trendTemp }
|
||||
]); // F1-① 首页迷你趋势(perfCard 内 .trend-spark)
|
||||
const ad = homeData.audio;
|
||||
if (isAvail(ad)) updateLevelMeter($('#home-audio-level'), ad);
|
||||
}
|
||||
@@ -520,6 +560,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
function renderHome() {
|
||||
stopHome();
|
||||
resetSpark(); // 进入首页重置 sparkline 累积
|
||||
resetTrend(); // 进入首页重置性能趋势累积(F1-①)
|
||||
homeData = null; homeSys = null;
|
||||
$('#content').innerHTML =
|
||||
'<div class="page" id="home-page">' +
|
||||
@@ -609,21 +650,24 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
|
||||
function powerPolicyCard(pol) {
|
||||
if (!pol) return '<div class="card"><h3 class="card-title">电源策略</h3>' + homeUnavailable() + '</div>';
|
||||
const pwe = !(pol.policy_write_enabled === false); // 缺省视为已开启(兼容旧 powermgr 不带该键)
|
||||
const profiles = ['performance', 'balanced', 'powersave', 'ultra'];
|
||||
const cur = pol.profile || 'balanced';
|
||||
let btns = profiles.map(p => '<button class="btn btn-ghost pol-btn' + (p === cur ? ' pol-on' : '') + '" data-pol="' + esc(p) + '">' + powerProfileLabel(p) + '</button>').join(' ');
|
||||
return '<div class="card"><h3 class="card-title">电源策略</h3>' +
|
||||
'<div class="field-row" style="margin-bottom:12px"><span class="muted">省电档位:</span>' + btns + '</div>' +
|
||||
'<table class="table"><tbody>' +
|
||||
row('低电阈值', '<input type="number" id="pol-low" class="num pol-num" value="' + esc(pol.low_pct) + '" min="1" max="99"> %') +
|
||||
row('临界阈值', '<input type="number" id="pol-crit" class="num pol-num" value="' + esc(pol.critical_pct) + '" min="1" max="99"> %') +
|
||||
row('亮度上限', '<input type="number" id="pol-bri" class="num pol-num" value="' + esc(pol.brightness_cap) + '" min="0" max="101"> <span class="muted">0=不生效</span>') +
|
||||
row('息屏上限', '<input type="number" id="pol-scr" class="num pol-num" value="' + esc(pol.screen_timeout_cap) + '" min="0"> <span class="muted">秒 · 0=不生效</span>') +
|
||||
let h = '<div class="card"><h3 class="card-title">电源策略</h3>';
|
||||
if (!pwe) h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">🔒</span><span>策略写默认关闭(panel.policy_enabled=0):当前只读,切档/阈值需先开启。</span><button class="btn" id="pol-enable">开启策略写</button></div>';
|
||||
const btns = profiles.map(p => '<button class="btn btn-ghost pol-btn' + (p === cur ? ' pol-on' : '') + '" data-pol="' + esc(p) + '"' + (!pwe ? ' disabled' : '') + '>' + powerProfileLabel(p) + '</button>').join(' ');
|
||||
h += '<div class="field-row" style="margin-bottom:12px"><span class="muted">省电档位:</span>' + btns + '</div>';
|
||||
h += '<table class="table"><tbody>' +
|
||||
row('低电阈值', '<input type="number" id="pol-low" class="num pol-num" value="' + esc(pol.low_pct) + '" min="1" max="99"' + (!pwe ? ' disabled' : '') + '> %') +
|
||||
row('临界阈值', '<input type="number" id="pol-crit" class="num pol-num" value="' + esc(pol.critical_pct) + '" min="1" max="99"' + (!pwe ? ' disabled' : '') + '> %') +
|
||||
row('亮度上限', '<input type="number" id="pol-bri" class="num pol-num" value="' + esc(pol.brightness_cap) + '" min="0" max="101"' + (!pwe ? ' disabled' : '') + '> <span class="muted">0=不生效</span>') +
|
||||
row('息屏上限', '<input type="number" id="pol-scr" class="num pol-num" value="' + esc(pol.screen_timeout_cap) + '" min="0"' + (!pwe ? ' disabled' : '') + '> <span class="muted">秒 · 0=不生效</span>') +
|
||||
row('状态', (pol.enacted ? '<span class="home-chip ok">已手动开启</span>' : '<span class="home-chip mut">默认全关</span>')) +
|
||||
'</tbody></table>' +
|
||||
'<div class="field-row"><button class="btn btn-primary" id="pol-apply">保存阈值</button><span class="muted">切档即时生效(需确认);阈值点「保存阈值」。</span></div>' +
|
||||
'<p class="muted">' + esc(pol.note || '') + '</p>' +
|
||||
'</div>';
|
||||
'</tbody></table>';
|
||||
h += '<div class="field-row"><button class="btn btn-primary" id="pol-apply"' + (!pwe ? ' disabled' : '') + '>保存阈值</button><span class="muted">切档即时生效(需确认);阈值点「保存阈值」。</span></div>';
|
||||
h += '<p class="muted">' + esc(pol.note || '') + '</p>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
function powerEventsCard(ev) {
|
||||
@@ -640,6 +684,27 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
'<table class="table"><thead><tr><th>类型</th><th>电量</th><th>说明</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function powerHistoryChart(h) {
|
||||
const samples = (h && h.samples) || []; // [mono, pct, est_mw, chg, scr, bri](30s 环形 ≈4h)
|
||||
if (!samples.length) return '';
|
||||
let maxMw = 0, hasMw = false;
|
||||
samples.forEach(function (s2) { if (s2[2] != null && s2[2] >= 0) { hasMw = true; if (s2[2] > maxMw) maxMw = s2[2]; } });
|
||||
if (!hasMw) return '<p class="muted">本窗口无 est_mw 估算采样(只读模式/未知),不画曲线。</p>';
|
||||
const W = 100, H = 30;
|
||||
const mwVals = samples.map(function (s2) { return (s2[2] != null && s2[2] >= 0 && maxMw > 0) ? s2[2] / maxMw * 100 : null; });
|
||||
const pctVals = samples.map(function (s2) { return (s2[1] != null && s2[1] >= 0) ? s2[1] : null; });
|
||||
return '<div class="trend-legend">' +
|
||||
'<span class="seg"><span class="sw" style="background:#ff7043"></span>est_mw(窗内峰值 ' + maxMw + ' mW)</span>' +
|
||||
'<span class="seg"><span class="sw" style="background:#4a8bf5"></span>电量 %</span>' +
|
||||
'</div>' +
|
||||
'<svg class="pwr-chart" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true">' +
|
||||
'<line class="grid" x1="0" y1="29" x2="100" y2="29"/>' +
|
||||
'<polyline class="pwr-mw" points="' + polylinePoints(mwVals, W, H) + '" fill="none" stroke-width="1.4" vector-effect="non-scaling-stroke"/>' +
|
||||
'<polyline class="pwr-pct" points="' + polylinePoints(pctVals, W, H) + '" fill="none" stroke-width="1.1" vector-effect="non-scaling-stroke"/>' +
|
||||
'</svg>' +
|
||||
'<p class="muted">30s 采样 · 内存环形约 4h · est_mw 为经验估算(confidence 见「功耗估算」卡),非实测功率;X 轴为采样序(等间隔)。</p>';
|
||||
}
|
||||
|
||||
function powerHistoryCard(h) {
|
||||
if (!h) return '';
|
||||
if (h.available === false) return '<div class="card"><h3 class="card-title">功耗历史</h3>' + homeUnavailable() + '</div>';
|
||||
@@ -649,6 +714,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
row('起止电量', (h.start_pct != null ? h.start_pct : '—') + '% → ' + (h.end_pct != null ? h.end_pct : '—') + '%') +
|
||||
row('平均功耗', (h.avg_mw != null ? h.avg_mw + ' mW' : '—')) +
|
||||
'</tbody></table>';
|
||||
body += powerHistoryChart(h); // F1-② est_mw 4h 曲线(纯前端画既有 samples)
|
||||
return '<div class="card"><h3 class="card-title">功耗历史</h3>' + body + '</div>';
|
||||
}
|
||||
|
||||
@@ -690,6 +756,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
|
||||
function bindPower() {
|
||||
$('#content').addEventListener('click', async function (e) {
|
||||
if (e.target.closest('#pol-enable')) { enablePolicyWrite(fetchPowerOnce); return; }
|
||||
const pb = e.target.closest('.pol-btn');
|
||||
if (pb) {
|
||||
const p = pb.dataset.pol;
|
||||
@@ -885,6 +952,130 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- 批④ App 活动页(#/bg):前台「终止」救命 + 后台列表/行内「杀死」 + 顶栏「一键清后台」
|
||||
*(复用 /api/ams/bg 只读:apps=bg 向后兼容 + fg=前台;前台终止走 /api/ams/:pkg/killfg,后台杀死走 /api/ams/:pkg/kill) ---- */
|
||||
let bgTimer = null;
|
||||
let bgData = null; // {available,count,apps:[{pkg,name,pid,state,is_fg,cpu_pct,rss_kb}],fg_count,fg:[...]}
|
||||
|
||||
function stopBg() { if (bgTimer) { clearInterval(bgTimer); bgTimer = null; } }
|
||||
|
||||
function bgIconHtml(a) {
|
||||
const ch = esc((a.name || a.pkg || '?').charAt(0).toUpperCase());
|
||||
return '<span class="bg-ico">' + ch + '</span>';
|
||||
}
|
||||
|
||||
/* 状态徽标:前台=ok/绿,后台=warn/黄(复用已定义的 home-chip 组件样式)。 */
|
||||
function bgStateBadge(a) {
|
||||
const isFg = a.is_fg || a.state === 'fg';
|
||||
return '<span class="home-chip ' + (isFg ? 'ok' : 'warn') + '">' + (isFg ? '前台' : '后台') + '</span>';
|
||||
}
|
||||
|
||||
/* 单行 App 卡(kind='fg'=前台可终止/救命;'bg'=后台可杀死)。 */
|
||||
function bgRowHtml(a, kind) {
|
||||
const cpu = (a.cpu_pct == null) ? '—' : a.cpu_pct.toFixed(1) + '%';
|
||||
const mem = (a.rss_kb == null) ? '—' : fmtBytes(Number(a.rss_kb) * 1024);
|
||||
const sub = 'CPU ' + cpu + ' · ' + mem +
|
||||
(a.pid > 0 ? ' · pid ' + esc(a.pid) : '') + ' · ' + bgStateBadge(a);
|
||||
const btn = (kind === 'fg')
|
||||
? '<button class="btn btn-danger" data-bgkillfg="' + esc(a.pkg) + '">终止</button>'
|
||||
: '<button class="btn btn-danger" data-bgkill="' + esc(a.pkg) + '">杀死</button>';
|
||||
return '<div class="bg-row">' + bgIconHtml(a) +
|
||||
'<div class="bg-info"><div class="bg-name">' + esc(a.name || a.pkg || '未命名') + '</div>' +
|
||||
'<div class="bg-sub">' + sub + '<span class="mono">' + esc(a.pkg) + '</span></div></div>' +
|
||||
btn + '</div>';
|
||||
}
|
||||
|
||||
function renderBgBody(d) {
|
||||
let h = '<div class="page" id="bg-page">';
|
||||
h += '<div class="card"><h3 class="card-title">App 活动(前台可终止 · 后台管理)</h3>';
|
||||
if (!d || d.available === false) {
|
||||
h += homeUnavailable();
|
||||
} else {
|
||||
const fg = d.fg || [];
|
||||
const apps = d.apps || [];
|
||||
h += '<p class="muted">前台 App 可「终止」(救命:前台卡死/无响应时强制结束,需二次确认);后台 App 可「杀死」或一键清。</p>';
|
||||
h += '<div class="bg-group">' + (fg.length ? '前台 App(' + fg.length + ')' : '前台 App') + '</div>';
|
||||
if (!fg.length) h += '<div class="empty">当前无前台 App</div>';
|
||||
else { h += '<div class="bg-list">'; fg.forEach(a => { h += bgRowHtml(a, 'fg'); }); h += '</div>'; }
|
||||
h += '<div class="bg-group">' + (apps.length ? '后台 App(' + apps.length + ')' : '后台 App') + '</div>';
|
||||
if (!apps.length) h += '<div class="empty">无后台 App</div>';
|
||||
else { h += '<div class="bg-list">'; apps.forEach(a => { h += bgRowHtml(a, 'bg'); }); h += '</div>'; }
|
||||
h += '<p class="muted">占用数据读自 /proc/<pid>(与启动器后台管理页同源、同口径);「终止/杀死」经 amsd(require_auth),前台终止为救命操作需二次确认。CPU 首采样为「—」,轮询后显示真实占用;数据不可得时如实显示「—」,绝不编造。</p>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
async function fetchBgOnce() {
|
||||
const el = $('#bg-page');
|
||||
if (!el || state.route !== 'bg') return;
|
||||
let d = null;
|
||||
try { d = await api('GET', '/api/ams/bg'); }
|
||||
catch (e) {
|
||||
if (state.route === 'bg') el.innerHTML = '<div class="page" id="bg-page">' + emptyState('加载失败:' + errText(e), e.code) + '</div>';
|
||||
const bc = $('#bg-clear'); if (bc) bc.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (state.route !== 'bg') return;
|
||||
bgData = d;
|
||||
el.innerHTML = renderBgBody(d);
|
||||
const bc = $('#bg-clear');
|
||||
if (bc) {
|
||||
const n = (d && d.apps && d.apps.length) ? d.apps.length : 0;
|
||||
bc.disabled = !n;
|
||||
bc.textContent = '一键清后台' + (n ? ' (' + n + ')' : '');
|
||||
}
|
||||
}
|
||||
|
||||
function renderBg() {
|
||||
stopBg();
|
||||
$('#content').innerHTML = '<div class="page" id="bg-page"><p class="placeholder">加载后台 App…</p></div>';
|
||||
fetchBgOnce();
|
||||
bgTimer = setInterval(fetchBgOnce, 4000);
|
||||
}
|
||||
|
||||
async function bgKill(pkg) {
|
||||
const ok = await confirmDlg('结束 App', '确认结束后台 App ' + pkg + '?(SIGTERM 语义)', true);
|
||||
if (!ok) return;
|
||||
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/kill'); toast('已请求结束 ' + pkg); }
|
||||
catch (e) { toast('结束失败:' + errText(e)); }
|
||||
fetchBgOnce();
|
||||
}
|
||||
|
||||
/* 前台强制终止(救命):明确标注「前台」,二次确认;后端 am kill 优先、launcher 拒则直接按进程信号。 */
|
||||
async function bgKillFg(pkg) {
|
||||
const ok = await confirmDlg('终止前台 App', '『' + pkg + '』当前位于前台。若它已卡死/无响应,可强制终止(救命):先请求干净结束,启动器拒绝时直接按进程信号(SIGTERM/SIGKILL)。确认终止?', true);
|
||||
if (!ok) return;
|
||||
try { await api('POST', '/api/ams/' + encodeURIComponent(pkg) + '/killfg'); toast('已请求终止前台 ' + pkg); }
|
||||
catch (e) { toast('终止失败:' + errText(e)); }
|
||||
fetchBgOnce();
|
||||
}
|
||||
|
||||
async function bgClearAll() {
|
||||
const list = (bgData && bgData.apps) ? bgData.apps : [];
|
||||
if (!list.length) { toast('无后台 App 可清'); return; }
|
||||
const ok = await confirmDlg('一键清除后台', '将结束 ' + list.length + ' 个后台 App,确认?(SIGTERM 语义)', true);
|
||||
if (!ok) return;
|
||||
let done = 0, fail = 0;
|
||||
for (const a of list) {
|
||||
try { await api('POST', '/api/ams/' + encodeURIComponent(a.pkg) + '/kill'); done++; }
|
||||
catch (e) { fail++; }
|
||||
}
|
||||
toast('已清 ' + done + ' 个后台 App' + (fail ? ',' + fail + ' 个失败' : ''));
|
||||
fetchBgOnce();
|
||||
}
|
||||
|
||||
function bindBg() {
|
||||
$('#content').addEventListener('click', async function (e) {
|
||||
const kfg = e.target.closest('[data-bgkillfg]');
|
||||
if (kfg) { await bgKillFg(kfg.dataset.bgkillfg); return; }
|
||||
const kl = e.target.closest('[data-bgkill]');
|
||||
if (kl) { await bgKill(kl.dataset.bgkill); return; }
|
||||
});
|
||||
const bc = $('#bg-clear');
|
||||
if (bc) bc.addEventListener('click', bgClearAll);
|
||||
}
|
||||
|
||||
function stopNet() { if (netTimer) { clearInterval(netTimer); netTimer = null; } }
|
||||
|
||||
function fmtDur(sec) { // 连接寿命(秒级精度)
|
||||
@@ -904,6 +1095,19 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
(i > 0 ? ' <span class="net-entity">' + esc(e.slice(i + 1)) + '</span>' : '');
|
||||
}
|
||||
|
||||
const NET_LOCKEDS = ['dropbear', 'wpa_supplicant', 'udhcpc', 'netmgrd', 'svcd', 'sysui', 'init'];
|
||||
function netLockedEntity(e) { // 与后端 net_locked_entity 同口径(取 `svc:|app:|proc:` 后裸名精确比对)
|
||||
if (!e) return false;
|
||||
const i = e.indexOf(':');
|
||||
return NET_LOCKEDS.indexOf(i > 0 ? e.slice(i + 1) : e) !== -1;
|
||||
}
|
||||
|
||||
async function enablePolicyWrite(fetchFn) { // 开启 panel.policy_enabled(guard③ 手动开启入口)
|
||||
try { await api('PUT', '/api/settings/panel.policy_enabled', { value: 1 }); toast('策略写已开启(panel.policy_enabled=1)'); }
|
||||
catch (err) { toast('开启失败:' + errText(err)); }
|
||||
if (fetchFn) fetchFn();
|
||||
}
|
||||
|
||||
function netStateCls(st) {
|
||||
if (!st) return '';
|
||||
if (st === 'ESTABLISHED') return 'net-state ok';
|
||||
@@ -991,18 +1195,44 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
h += row('状态', '<span class="net-state warn">未连接</span>');
|
||||
}
|
||||
h += '</tbody></table></div>';
|
||||
// 蓝牙摘要
|
||||
const bt = (d && d.bt) || {};
|
||||
h += '<div class="card"><h3 class="card-title">蓝牙</h3><table class="table"><tbody>';
|
||||
if (bt.available) {
|
||||
h += row('状态', '<span class="net-state ' + (bt.powered ? 'ok' : 'warn') + '">' + (bt.powered ? '已开启' : '已关闭') + '</span>');
|
||||
h += row('地址', esc(bt.address || '—'));
|
||||
h += row('已配对', esc(bt.paired != null ? bt.paired : '—'));
|
||||
h += row('DNS', esc(((bt.dns) || []).join(' / ') || '—'));
|
||||
} else {
|
||||
h += row('状态', '<span class="net-state warn">无蓝牙</span>');
|
||||
return h;
|
||||
}
|
||||
|
||||
/* F1-③ 蓝牙详情(GET /api/net/bt 只读聚合:status/paired/connected/a2dp/nearby) */
|
||||
function btDevTable(title, list) {
|
||||
let h = '<div class="home-sub" style="margin-top:10px">' + esc(title) + '</div>';
|
||||
if (!list || !list.length) return h + '<div class="empty">无</div>';
|
||||
h += '<table class="table"><thead><tr><th>MAC</th><th>名称</th></tr></thead><tbody>';
|
||||
list.forEach(function (d) { h += '<tr><td class="mono">' + esc(d.mac) + '</td><td>' + esc(d.name || '—') + '</td></tr>'; });
|
||||
return h + '</tbody></table>';
|
||||
}
|
||||
|
||||
function netBtCard(bt) {
|
||||
if (!bt || bt.available === false) return '<div class="card"><h3 class="card-title">蓝牙</h3>' + homeUnavailable() + '</div>';
|
||||
let h = '<div class="card"><h3 class="card-title">蓝牙</h3>';
|
||||
h += '<table class="table"><tbody>';
|
||||
h += row('状态', '<span class="net-state ' + (bt.powered ? 'ok' : 'warn') + '">' + (bt.powered ? '已开启' : '已关闭') + '</span>');
|
||||
h += row('地址', esc(bt.address || '—'));
|
||||
h += row('已配对 / 已连接', (bt.paired_n != null ? esc(bt.paired_n) : '—') + ' / ' + (bt.connected_n != null ? esc(bt.connected_n) : '—'));
|
||||
h += '</tbody></table>';
|
||||
h += btDevTable('已配对设备', bt.paired);
|
||||
h += btDevTable('已连接设备(内核 ACL)', bt.connected);
|
||||
const a2 = bt.a2dp || [];
|
||||
h += '<div class="home-sub" style="margin-top:10px">A2DP 传输</div>';
|
||||
if (!a2.length) h += '<div class="empty">无 A2DP 端点</div>';
|
||||
else {
|
||||
h += '<table class="table"><thead><tr><th>设备</th><th>编解码</th><th>采样率</th><th>声道/位深</th><th>传输</th><th>角色</th></tr></thead><tbody>';
|
||||
a2.forEach(function (a) {
|
||||
h += '<tr><td class="mono">' + esc(a.mac) + '</td><td>' + esc(a.codec || '—') + '</td><td>' + esc(a.rate || '—') + '</td>' +
|
||||
'<td class="muted">' + esc(a.channels || '—') + ' / ' + esc(a.bits || '—') + '</td>' +
|
||||
'<td>' + (a.acquired ? '<span class="home-chip ok">RUNNING</span>' : '<span class="home-chip mut">空闲</span>') + '</td>' +
|
||||
'<td class="muted">' + esc(a.role || '—') + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
h += '</tbody></table></div>';
|
||||
h += btDevTable('附近设备(扫描缓存)', bt.nearby);
|
||||
h += '<p class="muted">只读聚合(netmgrd 0xE6:status/paired/connected/a2dp/devices);配对/连接/开关等写操作走实体机,面板未开放。</p>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
@@ -1010,7 +1240,9 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
if (!pol || pol.available === false) return '<div class="card"><h3 class="card-title">网络策略</h3>' + homeUnavailable() + '</div>';
|
||||
const enforce = pol.enforce || '';
|
||||
const dnsOnly = enforce === 'dns_only';
|
||||
const pwe = !(pol.policy_write_enabled === false); // 缺省视为已开启(兼容旧 powermgr/netmgrd 不带该键)
|
||||
let h = '<div class="card"><h3 class="card-title">网络策略</h3>';
|
||||
if (!pwe) h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">🔒</span><span>策略写默认关闭(panel.policy_enabled=0):当前只读,阻断/放行/域名规则需先开启。</span><button class="btn" id="pol-enable">开启策略写</button></div>';
|
||||
h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">' + (dnsOnly ? 'ℹ️' : '⚠️') + '</span><span>' +
|
||||
(dnsOnly ? '执行模式:仅 DNS 层拒绝(enforce=dns_only;本内核 SOCK_DESTROY 为 no-op,不会真正断连)' : '执行模式:' + esc(enforce)) + '</span></div>';
|
||||
const rules = pol.rules || [];
|
||||
@@ -1018,21 +1250,24 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
else {
|
||||
h += '<table class="table"><thead><tr><th>实体</th><th>网络</th><th>白名单域名</th><th>黑名单域名</th><th style="text-align:right">操作</th></tr></thead><tbody>';
|
||||
rules.forEach(r => {
|
||||
const targetNet = r.net === 'block' ? 'allow' : 'block';
|
||||
const locked = targetNet === 'block' && netLockedEntity(r.entity);
|
||||
h += '<tr><td class="mono">' + netBadge(r.entity) + '</td>' +
|
||||
'<td><span class="home-chip ' + (r.net === 'block' ? 'bad' : 'ok') + '">' + (r.net === 'block' ? '阻断' : '放行') + '</span></td>' +
|
||||
'<td class="muted">' + esc((r.allow_hosts || []).join(', ') || '—') + '</td>' +
|
||||
'<td class="muted">' + esc((r.deny_hosts || []).join(', ') || '—') + '</td>' +
|
||||
'<td style="text-align:right"><div class="row-actions">' +
|
||||
'<button class="btn btn-ghost" data-ptoggle="' + esc(r.entity) + '" data-net="' + (r.net === 'block' ? 'allow' : 'block') + '">' + (r.net === 'block' ? '改放行' : '阻断') + '</button>' +
|
||||
'<button class="btn btn-ghost" data-pdel="' + esc(r.entity) + '">删除</button></div></td></tr>';
|
||||
'<button class="btn btn-ghost" data-ptoggle="' + esc(r.entity) + '" data-net="' + targetNet + '"' +
|
||||
' title="' + (locked ? '防自锁:禁止阻断系统关键服务' : '') + '"' + ((!pwe || locked) ? ' disabled' : '') + '>' + (r.net === 'block' ? '改放行' : '阻断') + '</button>' +
|
||||
'<button class="btn btn-ghost" data-pdel="' + esc(r.entity) + '"' + ((!pwe) ? ' disabled' : '') + '>删除</button></div></td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
}
|
||||
h += '<div class="field-row" style="margin-top:12px"><input type="text" id="pol-entity" placeholder="app:os.meow.ncm / svc:x / proc:y" style="max-width:280px">' +
|
||||
'<button class="btn btn-primary" id="pol-block">设为阻断</button>' +
|
||||
'<button class="btn btn-ghost" id="pol-allow">设为放行</button></div>';
|
||||
'<button class="btn btn-primary" id="pol-block"' + (!pwe ? ' disabled' : '') + '>设为阻断</button>' +
|
||||
'<button class="btn btn-ghost" id="pol-allow"' + (!pwe ? ' disabled' : '') + '>设为放行</button></div>';
|
||||
h += '<div class="field-row" style="margin-top:8px"><input type="text" id="pol-domain" placeholder="域名(+白 / -黑,如 +music.example.com)" style="max-width:280px">' +
|
||||
'<button class="btn" id="pol-dom">添加域名规则</button></div>';
|
||||
'<button class="btn" id="pol-dom"' + (!pwe ? ' disabled' : '') + '>添加域名规则</button></div>';
|
||||
h += '<p class="muted">防自锁实体(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)拒 block。</p>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
@@ -1091,8 +1326,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
async function fetchNetOnce() {
|
||||
if (state.route !== 'net') return;
|
||||
const stEl = $('#net-status'), cbEl = $('#net-conns-body'), dbEl = $('#net-dns-body'),
|
||||
ifEl = $('#net-ifaces'), poEl = $('#net-policy');
|
||||
let st, cn, dn, ifd, pol;
|
||||
ifEl = $('#net-ifaces'), poEl = $('#net-policy'), btEl = $('#net-bt');
|
||||
let st, cn, dn, ifd, pol, bt;
|
||||
try { st = await api('GET', '/api/net/status'); }
|
||||
catch (e) { netBannerErr(errText(e)); return; }
|
||||
netLoaded = true;
|
||||
@@ -1103,9 +1338,11 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
catch (e) { dn = null; }
|
||||
try { ifd = await api('GET', '/api/net/ifaces'); } catch (e) { ifd = null; }
|
||||
try { pol = await api('GET', '/api/net/policy'); } catch (e) { pol = null; }
|
||||
try { bt = await api('GET', '/api/net/bt'); } catch (e) { bt = null; } /* F1-③ 蓝牙只读详情 */
|
||||
if (state.route !== 'net') return;
|
||||
if (ifEl) ifEl.innerHTML = netIfaceCards(ifd);
|
||||
if (stEl) stEl.innerHTML = netStatusCards(st);
|
||||
if (btEl) btEl.innerHTML = netBtCard(bt);
|
||||
if (poEl) poEl.innerHTML = netPolicyCard(pol);
|
||||
if (cbEl) cbEl.innerHTML = netConnsBody(cn);
|
||||
if (dbEl) dbEl.innerHTML = netDnsBody(dn);
|
||||
@@ -1145,6 +1382,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
'<div id="net-banner" class="page-banner" hidden></div>' +
|
||||
'<div id="net-ifaces"><p class="placeholder">加载网络接口…</p></div>' +
|
||||
'<div id="net-status"><p class="placeholder">加载网络状态…</p></div>' +
|
||||
'<div id="net-bt"><p class="placeholder">加载蓝牙详情…</p></div>' +
|
||||
'<div id="net-policy"><p class="placeholder">加载网络策略…</p></div>' +
|
||||
'<div class="card"><h3 class="card-title">每实体连接表</h3>' +
|
||||
'<div class="field-row" style="margin-bottom:12px">' +
|
||||
@@ -1185,6 +1423,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
flush.disabled = false;
|
||||
fetchNetOnce();
|
||||
}
|
||||
if (e.target.closest('#pol-enable')) { enablePolicyWrite(fetchNetOnce); return; }
|
||||
/* ---- 网络策略写(require_auth + 二次确认;enforce=dns_only 如实显示,不谎称断连) ---- */
|
||||
const tgl = e.target.closest('[data-ptoggle]');
|
||||
if (tgl) {
|
||||
@@ -1210,6 +1449,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
if (blk) {
|
||||
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
|
||||
if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; }
|
||||
if (netLockedEntity(ent)) { toast('防自锁:禁止阻断系统关键服务(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)'); return; }
|
||||
const ok = await confirmDlg('阻断实体', '确认将 ' + ent + ' 设为阻断(DNS 层拒绝,不会真断连)?', true);
|
||||
if (!ok) { fetchNetOnce(); return; }
|
||||
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'block' }); toast('已阻断:' + ent); }
|
||||
@@ -1231,6 +1471,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
|
||||
const hst = ($('#pol-domain') ? $('#pol-domain').value.trim() : '');
|
||||
if (!ent) { toast('请输入实体'); return; }
|
||||
if (netLockedEntity(ent)) { toast('防自锁:禁止为系统关键服务配置域名限制(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)'); return; }
|
||||
if (!hst || (hst[0] !== '+' && hst[0] !== '-')) { toast('域名须以 +(白)或 -(黑)开头'); return; }
|
||||
const ok = await confirmDlg('域名规则', '确认对 ' + ent + ' ' + (hst[0] === '+' ? '加入白名单' : '加入黑名单') + ' ' + hst.slice(1) + '?', hst[0] === '-');
|
||||
if (!ok) { fetchNetOnce(); return; }
|
||||
@@ -1274,6 +1515,10 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
'<button class="btn" id="file-upload">上传文件</button>' +
|
||||
'<button class="btn" id="file-refresh">刷新</button>' +
|
||||
'<input type="file" id="file-input" multiple hidden></div>';
|
||||
/* F1-④ 搜索(只读,当前目录起递归限深 4 层) */
|
||||
h += '<div class="field-row wrap" style="margin-bottom:12px">' +
|
||||
'<input type="text" id="file-search" placeholder="按名称搜索本目录(含子目录,限 4 层)" style="max-width:300px">' +
|
||||
'<button class="btn" id="file-search-go">搜索</button></div>';
|
||||
h += '<div class="card"><table class="table"><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
|
||||
if (!d.items.length) h += '<tr><td colspan="4" class="empty">空目录</td></tr>';
|
||||
d.items.forEach(function (it) {
|
||||
@@ -1299,6 +1544,49 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
|
||||
function renderFiles() { $('#content').innerHTML = '<div class="page" id="files-page"></div>'; navFiles(state.filesRel || '/'); }
|
||||
|
||||
/* F1-④ 搜索结果视图(GET /api/files/search,只读;下载/进入仍走既有端点) */
|
||||
function searchParentRel(it) {
|
||||
const rel = it.rel || '/';
|
||||
if (it.type === 'dir') return rel;
|
||||
const i = rel.lastIndexOf('/');
|
||||
return i > 0 ? rel.slice(0, i) : '/';
|
||||
}
|
||||
|
||||
async function searchFiles() {
|
||||
const q = ($('#file-search') ? $('#file-search').value : '').trim();
|
||||
if (!q) return toast('请输入搜索关键字');
|
||||
const page = $('#files-page'); if (!page) return;
|
||||
const abs = state.filesAbs || '/';
|
||||
page.innerHTML = '<p class="placeholder">在 ' + esc(state.filesRel || '/') + ' 内搜索「' + esc(q) + '」…</p>';
|
||||
let d;
|
||||
try { d = await api('GET', '/api/files/search?path=' + encodeURIComponent(abs) + '&name=' + encodeURIComponent(q)); }
|
||||
catch (e) { page.innerHTML = emptyState('搜索失败:' + errText(e), e.code); return; }
|
||||
let h = crumbHtml(segments(d.rel), d.rel);
|
||||
h += '<div class="field-row wrap" style="margin-bottom:12px">' +
|
||||
'<span class="muted">「' + esc(q) + '」在 ' + esc(d.rel) + ' 内命中 ' + (d.count || 0) + ' 条' +
|
||||
(d.truncated ? '(已达上限,结果截断)' : '') + '</span>' +
|
||||
'<button class="btn" id="file-search-back">返回目录</button></div>';
|
||||
h += '<div class="card"><table class="table"><thead><tr><th>名称</th><th>位置</th><th>大小</th><th>修改时间</th><th style="text-align:right">操作</th></tr></thead><tbody>';
|
||||
if (!d.items.length) h += '<tr><td colspan="5" class="empty">无匹配</td></tr>';
|
||||
d.items.forEach(function (it) {
|
||||
const isDir = it.type === 'dir';
|
||||
const icon = isDir ? '📁' : '📄';
|
||||
const parent = searchParentRel(it);
|
||||
h += '<tr><td>' + icon + ' ' + esc(it.name) + '</td>' +
|
||||
'<td class="muted mono">' + esc(parent) + '</td>' +
|
||||
'<td class="muted">' + (isDir ? '—' : fmtBytes(it.size)) + '</td>' +
|
||||
'<td class="muted">' + fmtTime(it.mtime) + '</td>' +
|
||||
'<td><div class="row-actions">' +
|
||||
(isDir
|
||||
? '<button class="btn btn-ghost" data-sopen="' + esc(it.rel) + '">打开</button>'
|
||||
: '<button class="btn btn-ghost" data-sopen="' + esc(parent) + '">所在目录</button>' +
|
||||
'<a class="btn btn-ghost" data-sdl="' + esc(it.rel) + '" data-name="' + esc(it.name) + '" href="#">下载</a>') +
|
||||
'</div></td></tr>';
|
||||
});
|
||||
h += '</tbody></table></div>';
|
||||
page.innerHTML = h;
|
||||
}
|
||||
|
||||
function absFor(name) { return state.filesAbs ? (state.filesAbs.endsWith('/') ? state.filesAbs + name : state.filesAbs + '/' + name) : null; }
|
||||
|
||||
function bindFiles() {
|
||||
@@ -1309,6 +1597,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
const ren = t.closest('[data-ren]'); const del = t.closest('[data-del]');
|
||||
const dirlink = t.closest('[data-dir]'); const nav = t.closest('[data-nav]');
|
||||
const crumb = t.closest('[data-rel]');
|
||||
const sopen = t.closest('[data-sopen]'); const sdl = t.closest('[data-sdl]');
|
||||
const btn = t.closest('button[id]');
|
||||
if (crumb) { navFiles(crumb.dataset.rel); return; }
|
||||
if (nav) { navFiles(nav.dataset.nav); return; }
|
||||
@@ -1318,11 +1607,22 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
doDownload('/api/files/download?path=' + encodeURIComponent(abs), dl.dataset.dl);
|
||||
return;
|
||||
}
|
||||
if (sopen) { navFiles(sopen.dataset.sopen); return; } /* F1-④ 搜索结果:打开目录/所在目录 */
|
||||
if (sdl) { /* F1-④ 搜索结果:下载(rel 已是卡根相对) */
|
||||
e.preventDefault();
|
||||
doDownload('/api/files/download?path=' + encodeURIComponent(sdl.dataset.sdl), sdl.dataset.name || sdl.dataset.sdl);
|
||||
return;
|
||||
}
|
||||
if (ren) { renameFile(ren.dataset.ren); return; }
|
||||
if (del) { removeFile(del.dataset.del); return; }
|
||||
if (btn && btn.id === 'file-newdir') { mkdirFile(); return; }
|
||||
if (btn && btn.id === 'file-upload') { const fi = $('#file-input'); if (fi) { fi.value = ''; fi.click(); } return; }
|
||||
if (btn && btn.id === 'file-refresh') { navFiles(state.filesRel); return; }
|
||||
if (btn && btn.id === 'file-search-go') { searchFiles(); return; } /* F1-④ */
|
||||
if (btn && btn.id === 'file-search-back') { navFiles(state.filesRel); return; } /* F1-④ */
|
||||
});
|
||||
c.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' && e.target && e.target.id === 'file-search') { e.preventDefault(); searchFiles(); }
|
||||
});
|
||||
c.addEventListener('change', function (e) {
|
||||
if (e.target.id === 'file-input') uploadFiles(e.target.files);
|
||||
@@ -1424,9 +1724,11 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
const install = t.closest('#app-install'); const refresh = t.closest('#app-refresh');
|
||||
const info = t.closest('[data-ainfo]'); const exp = t.closest('[data-aexport]');
|
||||
const uninst = t.closest('[data-auninst]'); const goPath = t.closest('#app-install-path-go');
|
||||
const aclose = t.closest('[data-aclose]');
|
||||
if (install) { const fi = $('#app-file'); if (fi) { fi.value = ''; fi.click(); } return; }
|
||||
if (refresh) { renderApps(); return; }
|
||||
if (info) { appInfo(info.dataset.ainfo); return; }
|
||||
if (aclose) { appDetailRemove(); return; }
|
||||
if (exp) {
|
||||
e.preventDefault();
|
||||
if (exp.dataset.busy) return; /* 打包期间防连点 */
|
||||
@@ -1473,14 +1775,110 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
catch (e) { toast('卸载失败:' + errText(e)); }
|
||||
}
|
||||
|
||||
async function appInfo(pkg) {
|
||||
/* ---- F1-⑤ 单 App 详情四源聚合(FUTURE_MEOWPANEL §2.4,纯前端):apps 页行内展开,
|
||||
* 并发聚合既有端点:①安装域 /api/apps/:pkg ②运行崩溃史 /api/ams/:pkg ③实时连接
|
||||
* /api/net/conns?entity=app:<pkg> ④stdout 尾部(newestStdout + /api/logs/read)。
|
||||
* 单源失败 → 该子块「—/不可用」,绝不拖垮整卡(overview 同款降级)。 ---- */
|
||||
function appDetailRemove() {
|
||||
const el = document.querySelector('.app-detail-card');
|
||||
if (el) el.remove();
|
||||
appDetailPkg = null;
|
||||
}
|
||||
|
||||
async function appStdoutBlock(pkg) {
|
||||
try {
|
||||
const d = await api('GET', '/api/apps/' + pkg);
|
||||
let txt = '包名: ' + d.package + '\n名称: ' + d.name + '\n版本: ' + d.version + '\nexec: ' + d.exec +
|
||||
'\nsize: ' + fmtBytes(d.size) + '\n路径: ' + d.path;
|
||||
if (d.description) txt += '\n描述: ' + d.description;
|
||||
alert(txt);
|
||||
} catch (e) { toast('获取详情失败:' + errText(e)); }
|
||||
const file = await newestStdout(pkg);
|
||||
if (!file) return '<div class="empty">无 stdout 日志</div>';
|
||||
const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(pkg + '/' + file) + '&lines=200');
|
||||
const lines = d.lines || [];
|
||||
return '<pre class="log-view">' + esc(lines.join('\n') || '(空)') +
|
||||
(d.truncated ? '\n…(超出尾部上限,部分被截断)' : '') + '</pre>' +
|
||||
'<div class="muted">' + esc(pkg + '/' + file) + ' · 末尾 ' + lines.length + ' 行</div>';
|
||||
} catch (e) { return '<div class="empty">stdout 读取失败:' + esc(errText(e)) + '</div>'; }
|
||||
}
|
||||
|
||||
async function appDetailBody(pkg) {
|
||||
const r = await Promise.allSettled([
|
||||
api('GET', '/api/apps/' + pkg),
|
||||
api('GET', '/api/ams/' + pkg),
|
||||
api('GET', '/api/net/conns?entity=' + encodeURIComponent('app:' + pkg)),
|
||||
appStdoutBlock(pkg)
|
||||
]);
|
||||
|
||||
let h = '';
|
||||
/* ① 安装域(app.json) */
|
||||
const ap = r[0].status === 'fulfilled' ? r[0].value : null;
|
||||
if (ap) {
|
||||
h += '<table class="table"><tbody>' +
|
||||
row('名称', esc(ap.name || '—')) +
|
||||
row('版本', esc(ap.version || '—')) +
|
||||
row('执行', '<span class="mono">' + esc(ap.exec || '—') + '</span>') +
|
||||
row('体积', fmtBytes(ap.size)) +
|
||||
row('路径', '<span class="mono muted">' + esc(ap.path || '—') + '</span>') +
|
||||
'</tbody></table>';
|
||||
} else {
|
||||
h += '<div class="empty">安装详情不可用' + (r[0].status === 'rejected' ? ':' + esc(errText(r[0].reason)) : '') + '</div>';
|
||||
}
|
||||
|
||||
/* ② 运行域崩溃/事件史(am history) */
|
||||
h += '<div class="home-sub" style="margin-top:12px">崩溃 / 事件历史(amsd)</div>';
|
||||
const am = r[1].status === 'fulfilled' ? r[1].value : null;
|
||||
const evs = (am && am.available && am.events) || [];
|
||||
if (evs.length) {
|
||||
h += '<table class="table"><thead><tr><th>时间</th><th>类型</th><th>详情</th></tr></thead><tbody>';
|
||||
evs.slice().reverse().slice(0, 10).forEach(function (ev) {
|
||||
h += '<tr><td class="muted">' + fmtTime(ev.ts) + '</td><td>' + esc(ev.kind || '—') + '</td>' +
|
||||
'<td class="muted">' + esc(ev.detail || '—') + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
} else {
|
||||
h += '<div class="empty">无事件记录' + (r[1].status === 'rejected' ? '(' + esc(errText(r[1].reason)) + ')' : '') + '</div>';
|
||||
}
|
||||
|
||||
/* ③ 实时连接(app:<pkg>) */
|
||||
h += '<div class="home-sub" style="margin-top:12px">实时连接(app:' + esc(pkg) + ')</div>';
|
||||
const cn = r[2].status === 'fulfilled' ? r[2].value : null;
|
||||
const conns = (cn && cn.conns) || [];
|
||||
if (conns.length) {
|
||||
h += '<table class="table"><thead><tr><th>远端</th><th>本端</th><th>状态</th><th>寿命</th></tr></thead><tbody>';
|
||||
conns.forEach(function (c) {
|
||||
h += '<tr><td class="mono">' + esc(c.domain || c.remote || '—') + '</td>' +
|
||||
'<td class="mono muted">' + esc(c.local || '—') + '</td>' +
|
||||
'<td>' + esc(c.state || '—') + '</td><td class="muted">' + fmtDur(c.age) + '</td></tr>';
|
||||
});
|
||||
h += '</tbody></table>';
|
||||
} else {
|
||||
h += '<div class="empty">无连接' + (r[2].status === 'rejected' ? '(' + esc(errText(r[2].reason)) + ')' : '') + '</div>';
|
||||
}
|
||||
|
||||
/* ④ stdout 尾部 */
|
||||
h += '<div class="home-sub" style="margin-top:12px">stdout 尾部</div>';
|
||||
h += (r[3].status === 'fulfilled' && r[3].value) ? r[3].value : '<div class="empty">stdout 读取失败</div>';
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
async function appInfo(pkg) {
|
||||
if (appDetailPkg === pkg) { appDetailRemove(); return; } // 已展开 → 收起
|
||||
appDetailRemove(); // 收起上一个
|
||||
const card = document.querySelector('.app-card[data-pkg="' + escSafeAttr(pkg) + '"]');
|
||||
if (!card) return;
|
||||
const box = document.createElement('div');
|
||||
box.className = 'card app-detail-card';
|
||||
box.innerHTML = '<div class="app-detail-head"><strong>' + esc(pkg) + '</strong>' +
|
||||
'<button class="btn btn-ghost" data-aclose="' + esc(pkg) + '">收起</button></div>' +
|
||||
'<p class="placeholder">并发聚合四源详情…</p>';
|
||||
card.after(box);
|
||||
appDetailPkg = pkg;
|
||||
try {
|
||||
box.innerHTML = '<div class="app-detail-head"><strong>' + esc(pkg) + '</strong>' +
|
||||
'<button class="btn btn-ghost" data-aclose="' + esc(pkg) + '">收起</button></div>' + (await appDetailBody(pkg));
|
||||
} catch (e) {
|
||||
box.innerHTML = '<div class="app-detail-head"><strong>' + esc(pkg) + '</strong>' +
|
||||
'<button class="btn btn-ghost" data-aclose="' + esc(pkg) + '">收起</button></div>' +
|
||||
'<div class="empty">详情加载失败:' + esc(errText(e)) + '</div>';
|
||||
appDetailPkg = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFontsPage() {
|
||||
@@ -1843,7 +2241,17 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
else if (dir === 'voldown') sendKeyRepeat(114, volSteps(ay), '音量−');
|
||||
else if (dir === 'next') sendKeyLabel(163, '下一曲');
|
||||
else if (dir === 'prev') sendKeyLabel(165, '上一曲');
|
||||
else if (dir === 'back') sendKeyLabel(158, '返回');
|
||||
else if (dir === 'back') sendGesture('back'); // 左缘右滑 → 注入设备已识别的返回手势
|
||||
}
|
||||
|
||||
async function sendGesture(name) {
|
||||
const label = name === 'back' ? '返回' : '回桌面';
|
||||
try {
|
||||
const d = await api('POST', '/api/input/gesture', { gesture: name });
|
||||
if (d && d.dispatched) toast(label + ' 已触发');
|
||||
else if (d && d.reason === 'local_touch_active') toast('本机触摸中,注入已暂停');
|
||||
else toast(label + ' 触发未派发');
|
||||
} catch (e) { toast(label + ' 触发失败:' + errText(e)); }
|
||||
}
|
||||
|
||||
function remotePointerDown(e) {
|
||||
@@ -1886,6 +2294,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
h += remoteBtn('next', 'next', '下一曲', '下一曲(code=163)');
|
||||
h += remoteBtn('prev', 'prev', '上一曲', '上一曲(code=165)');
|
||||
h += remoteBtn('power', 'power', '电源', '电源键(单击;code=116)');
|
||||
h += remoteBtn('back', 'back', '返回', '左缘右滑返回(注入设备已识别的返回手势)');
|
||||
h += remoteBtn('home', 'home', '回桌面', '底部上滑回桌面(注入设备已识别的回桌面手势)');
|
||||
h += '</div>';
|
||||
h += '<div class="remote-bright">' +
|
||||
'<span class="r-lbl">指定亮度</span>' +
|
||||
@@ -1894,7 +2304,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
'</div>';
|
||||
h += '<button class="btn" id="ctl-powerlong">' + (MP_ICONS.power || '') + ' <span>长按电源(电源菜单)</span></button>';
|
||||
h += '<p class="muted" style="margin-top:8px;line-height:1.8">滑动手势(在遥控板上):上滑=音量+ · 下滑=音量- · 左滑=下一曲 · 右滑=上一曲 · 左缘右滑=返回。<br>' +
|
||||
'<b>返回/主页</b>属物理键红线(<code>/api/key</code> 后端禁注入 158/172),故未提供注入按钮。</p>';
|
||||
'<b>返回/回桌面</b>走手势注入(<code>/api/input/gesture</code>,模拟设备已识别的左缘右滑/底部上滑),<b>不走</b>被物理键红线禁注入的 <code>/api/key</code> 158/172。</p>';
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
@@ -2360,6 +2770,8 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
else if (a === 'next') sendKey(163, 'tap');
|
||||
else if (a === 'prev') sendKey(165, 'tap');
|
||||
else if (a === 'power') sendKey(116, 'tap');
|
||||
else if (a === 'back') sendGesture('back');
|
||||
else if (a === 'home') sendGesture('home');
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#ctl-powerlong')) { sendKey(116, 'long', 3000); return; }
|
||||
@@ -2376,5 +2788,5 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(MP, { absFor, appInfo, appStatePill, appUninstall, applyScreenDeltaDom, audioCard, audioCtlHtml, audioFlowHtml, audioPortName, audioRouteLabel, batteryCard, batteryCardBody, bindApps, bindAudio, bindFiles, bindFonts, bindHome, bindLogs, bindNet, bindNotif, bindPin, bindPower, bindProc, bindScreen, bindSettings, bindSystem, breakdownCardBody, btCard, createImgFromBlob, crumbHtml, deviceCard, doDownload, ensureScreenCanvas, escSafeAttr, estimateCardBody, eventsBar, fetchAudioOnce, fetchHomeLevel, fetchHomeSystem, fetchNetConns, fetchNetLog, fetchNetOnce, fetchOverviewOnce, fetchPerfOnce, fetchPowerOnce, fetchProcOnce, fetchScreen, fmtDur, fmtFrames, groupName, handleRemoteGesture, healthCard, importFont, importFontPage, installFromFile, installFromPath, joinRel, loadFonts, loadFontsPage, loadPinStatus, logsCrashCard, mkdirFile, navFiles, netBadge, netBannerErr, netCard, netConnsBody, netDnsBody, netIfaceCards, netPolicyCard, netStateCls, netStatusCards, newestStdout, perfCard, pinEdit, pinEditDlg, powerEventsCard, powerHistoryCard, powerPolicyCard, powerProfileLabel, powerSummaryCard, pubBytesCard, remoteBtn, remotePadHtml, remotePointerDown, remotePointerUp, removeFile, renameFile, renderApps, renderAudio, renderBright, renderFiles, renderFontsPage, renderHome, renderHomeCards, renderLogs, renderLogsFiles, renderNet, renderNotif, renderPerf, renderPerfData, renderPower, renderPowerBody, renderProc, renderProcBody, renderScreen, renderSettings, renderSystem, restartScreenPoll, row, screenTap, seedScreenCanvas, segments, sendKey, sendKeyLabel, sendKeyRepeat, sendTheme, setBrightCtl, setPanelPassword, settingControl, srcPill, stopAudio, stopHome, stopNet, stopPerf, stopPower, stopProc, stopScreenPoll, storageCard, svcStatePill, uploadFiles, viewAppStdout, viewLogsCrashTail, volSteps });
|
||||
Object.assign(MP, { absFor, appInfo, appStatePill, appUninstall, applyScreenDeltaDom, audioCard, audioCtlHtml, audioFlowHtml, audioPortName, audioRouteLabel, batteryCard, batteryCardBody, bgClearAll, bgIconHtml, bindBg, bindApps, bindAudio, bindFiles, bindFonts, bindHome, bindLogs, bindNet, bindNotif, bindPin, bindPower, bindProc, bindScreen, bindSettings, bindSystem, breakdownCardBody, btCard, createImgFromBlob, crumbHtml, deviceCard, doDownload, ensureScreenCanvas, escSafeAttr, estimateCardBody, eventsBar, fetchAudioOnce, fetchBgOnce, fetchHomeLevel, fetchHomeSystem, fetchNetConns, fetchNetLog, fetchNetOnce, fetchOverviewOnce, fetchPerfOnce, fetchPowerOnce, fetchProcOnce, fetchScreen, fmtDur, fmtFrames, groupName, handleRemoteGesture, healthCard, importFont, importFontPage, installFromFile, installFromPath, joinRel, loadFonts, loadFontsPage, loadPinStatus, logsCrashCard, mkdirFile, navFiles, netBadge, netBannerErr, netCard, netConnsBody, netDnsBody, netIfaceCards, netPolicyCard, netStateCls, netStatusCards, newestStdout, perfCard, pinEdit, pinEditDlg, powerEventsCard, powerHistoryCard, powerPolicyCard, powerProfileLabel, powerSummaryCard, pubBytesCard, remoteBtn, remotePadHtml, remotePointerDown, remotePointerUp, removeFile, renameFile, renderApps, renderAudio, renderBg, renderBgBody, renderBright, renderFiles, renderFontsPage, renderHome, renderHomeCards, renderLogs, renderLogsFiles, renderNet, renderNotif, renderPerf, renderPerfData, renderPower, renderPowerBody, renderProc, renderProcBody, renderScreen, renderSettings, renderSystem, restartScreenPoll, row, screenTap, seedScreenCanvas, segments, sendKey, sendKeyLabel, sendKeyRepeat, sendTheme, setBrightCtl, setPanelPassword, settingControl, srcPill, stopAudio, stopBg, stopHome, stopNet, stopPerf, stopPower, stopProc, stopScreenPoll, storageCard, svcStatePill, uploadFiles, viewAppStdout, viewLogsCrashTail, volSteps });
|
||||
})();
|
||||
@@ -99,6 +99,53 @@
|
||||
if (arr.length > SPARK_MAX) arr.shift();
|
||||
}
|
||||
|
||||
/* ---- F1-① 性能趋势(FUTURE_MEOWPANEL §2.1):pushSpark 模式泛化到任意 0..100 归一值 ---- */
|
||||
const trendCpu = [], trendMem = [], trendLoad = [], trendTemp = [];
|
||||
|
||||
function pushTrend(arr, v) { // 0..100 归一值入队(<=SPARK_MAX 点);null/NaN 不入队(诚实空缺)
|
||||
if (v == null || isNaN(v)) return;
|
||||
arr.push(Math.max(0, Math.min(100, Number(v))));
|
||||
if (arr.length > SPARK_MAX) arr.shift();
|
||||
}
|
||||
|
||||
function resetTrend() { trendCpu.length = 0; trendMem.length = 0; trendLoad.length = 0; trendTemp.length = 0; }
|
||||
|
||||
/* series: [{cls,arr}],cls 决定颜色(CSS .trend-cpu/.trend-mem/.trend-load/.trend-temp),
|
||||
* 位置稳定(首页 3 序列、perf 页 4 序列共用同一语义色,不靠序号猜色)。 */
|
||||
function trendSvgHtml(series) {
|
||||
let polys = '';
|
||||
series.forEach(function (s) {
|
||||
polys += '<polyline class="' + s.cls + '" points="" fill="none" stroke-width="1.6" vector-effect="non-scaling-stroke"/>';
|
||||
});
|
||||
return '<svg class="lvl-spark trend-spark" viewBox="0 0 100 46" preserveAspectRatio="none" aria-hidden="true">' +
|
||||
'<line class="grid" x1="0" y1="46" x2="100" y2="46"/>' +
|
||||
'<line class="grid" x1="0" y1="23" x2="100" y2="23"/>' + polys + '</svg>';
|
||||
}
|
||||
|
||||
function drawTrendSpark(rootEl, series) {
|
||||
const svg = rootEl && rootEl.querySelector('.trend-spark');
|
||||
if (!svg) return;
|
||||
series.forEach(function (s) {
|
||||
const p = svg.querySelector('.' + s.cls);
|
||||
if (p) p.setAttribute('points', sparklinePoints(s.arr));
|
||||
});
|
||||
}
|
||||
|
||||
/* 通用折线点串(任意长度数组,值 0..100 归一;null 跳过):x 铺满 w,y 反向映射 h 内(F1-② 功耗 4h 曲线用) */
|
||||
function polylinePoints(vals, w, h) {
|
||||
const n = vals.length;
|
||||
if (!n) return '';
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = vals[i];
|
||||
if (v == null || isNaN(v)) continue;
|
||||
const x = n > 1 ? (i / (n - 1)) * w : w / 2;
|
||||
const y = h - 2 - Math.max(0, Math.min(100, v)) / 100 * (h - 4);
|
||||
out.push(x.toFixed(1) + ',' + y.toFixed(1));
|
||||
}
|
||||
return out.join(' ');
|
||||
}
|
||||
|
||||
function drawSpark(rootEl) {
|
||||
const svg = rootEl && rootEl.querySelector('.lvl-spark');
|
||||
if (!svg) return;
|
||||
@@ -463,5 +510,5 @@
|
||||
});
|
||||
}
|
||||
|
||||
Object.assign(MP, { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, pushSpark, refreshAuthGate, resetSpark, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, updateLevelMeter, volTimer });
|
||||
Object.assign(MP, { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode, authRequired, barHtml, bindLogin, bindPwDlg, bindSlider, bindThemeUi, browserMode, confirmDlg, dbPct, dbg, dbgErr, debounce, deciToDb, devStyleLabel, devThemeLabel, doLogin, drawSpark, drawTrendSpark, emptyState, errText, esc, fmtBytes, fmtDb, fmtTime, fmtUptime, hideLogin, homeCard, homeKv, homeUnavailable, initTheme, isAvail, levelMeterHtml, loginErr, modePill, passwordDlg, pct, polylinePoints, pushSpark, pushTrend, refreshAuthGate, resetSpark, resetTrend, resolveMode, resolveStyle, saveSetting, setConn, showLogin, slur, sparkPeak, sparkRms, sparklineHtml, sparklinePoints, stat, state, storageDel, storageGet, storageSet, storedMode, storedStyle, toast, toastTimer, trendCpu, trendLoad, trendMem, trendSvgHtml, trendTemp, updateLevelMeter, volTimer });
|
||||
})();
|
||||
Binary file not shown.
+680
-153
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
/* test_fmt_utc.c — fmt_utc() 边界单测(宿主)。
|
||||
*
|
||||
* fmt_utc 是 nyai.c 里的 static 函数;这里把 main 改名后直接 include 源码来测,
|
||||
* 不引入测试框架、不复制实现(避免单测与实现漂移)。
|
||||
* 覆盖:epoch/负数、1 月、2 月(闰/平)、3 月、12 月 31 日、闰年 2/29、2100 世纪闰年规则、
|
||||
* 2038 int32 边界。真机时区无关(fmt_utc 固定 UTC)。
|
||||
*
|
||||
* 用法:cc -Wall -Wextra test_fmt_utc.c -o test_fmt_utc && ./test_fmt_utc
|
||||
*/
|
||||
#define main nyai_main_unused
|
||||
#include "nyai.c"
|
||||
#undef main
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
struct utc_case { long long t; const char *want; };
|
||||
|
||||
int main(void)
|
||||
{
|
||||
static const struct utc_case cases[] = {
|
||||
{ 0LL, "t0" }, /* epoch 由调用方定义为「无时间」 */
|
||||
{ -1LL, "t0" },
|
||||
{ 1LL, "19700101-000001" },
|
||||
{ 1LL * 1767225600, "20260101-000000" }, /* 2026-01-01(曾打成 20251901) */
|
||||
{ 1769904000LL, "20260201-000000" }, /* 2026-02-01(曾打成 20251931) */
|
||||
{ 1772236800LL, "20260228-000000" }, /* 2026-02-28(平年) */
|
||||
{ 1709164800LL, "20240229-000000" }, /* 2024-02-29(闰年) */
|
||||
{ 951782400LL, "20000229-000000" }, /* 2000-02-29(400 年闰) */
|
||||
{ 4102444800LL, "21000101-000000" }, /* 2100-01-01(非闰世纪) */
|
||||
{ 1772323200LL, "20260301-000000" }, /* 2026-03-01(March-based 起点) */
|
||||
{ 1798761599LL, "20261231-235959" }, /* 年末最后一秒 */
|
||||
{ 2145916800LL, "20380101-000000" },
|
||||
{ 2147483647LL, "20380119-031407" }, /* int32 time_t 上界 */
|
||||
};
|
||||
int fail = 0;
|
||||
for (unsigned i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
char got[32];
|
||||
fmt_utc(cases[i].t, got, sizeof got);
|
||||
if (strcmp(got, cases[i].want) != 0) {
|
||||
printf("FAIL fmt_utc(%lld) = %s, want %s\n",
|
||||
cases[i].t, got, cases[i].want);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
if (fail == 0) {
|
||||
printf("PASS fmt_utc: %u cases\n", (unsigned)(sizeof cases / sizeof cases[0]));
|
||||
return 0;
|
||||
}
|
||||
printf("FAILED %d/%u\n", fail, (unsigned)(sizeof cases / sizeof cases[0]));
|
||||
return 1;
|
||||
}
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/bin/bash
|
||||
# test_nyai.sh — nyai 宿主回归(M35:app.json 清单权威 + SD 暂存 + 无条件清理)
|
||||
#
|
||||
# 覆盖:
|
||||
# 1 文件名/后缀与包名完全不同(weird-name.bin)仍按清单装进 MeowAPPs/<app.json.package>
|
||||
# 2 真实反例:签名 os.meow.reader 包命名成 os.meow.reader-0.1.7.mpk 仍装进 MeowAPPs/os.meow.reader
|
||||
# 3 footer.package ≠ app.json.package → 拒装,错误文案同时给出两个名字
|
||||
# 4 os.meow.* 无签名 → 拒装
|
||||
# 5 第三方无签名 → 放行(行为不变)
|
||||
# 6 恶意 app.json.package(../x、a/b、.、..)→ 拒装且不落盘到目录外
|
||||
# 7 成功 / 各种失败 / 解压失败 / SIGTERM 中断 → MeowOS/tmp/pkgs 无残留
|
||||
# 8 索引路径:Packages 的 Package 字段取自清单(文件改名后按包名仍可装)
|
||||
# 9 upgrade 复用 install 引擎:清单包名/目标包名不一致、降级都拒
|
||||
#
|
||||
# 依赖:宿主 cc(编 nyai_host / meow_verify_host / meow_sign)。
|
||||
# 签名用例需要维护者私钥:默认 ~/.keys/meowos-signing/signing.key,
|
||||
# 可用 NYAI_TEST_SIGN_KEY 覆盖;无私钥时签名用例 SKIP(不判 FAIL)。
|
||||
# 真实反例包默认 ~/.cache/os.meow.reader-0.1.7-signed.mpk,可用 NYAI_TEST_REAL_MPK 覆盖。
|
||||
#
|
||||
# 用法:./test_nyai.sh [-v]
|
||||
set -u
|
||||
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT=$(cd "$HERE/../.." && pwd)
|
||||
VERBOSE=0
|
||||
[ "${1:-}" = "-v" ] && VERBOSE=1
|
||||
WORK=${NYAI_TEST_WORK:-$(mktemp -d "${TMPDIR:-/tmp}/nyai-m35.XXXXXX")}
|
||||
mkdir -p "$WORK"
|
||||
CLEANUP_WORK=1
|
||||
[ -n "${NYAI_TEST_WORK:-}" ] && CLEANUP_WORK=0
|
||||
trap '[ "$CLEANUP_WORK" = 1 ] && rm -rf "$WORK"' EXIT
|
||||
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
ok() { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
|
||||
skip() { SKIP=$((SKIP+1)); printf 'SKIP %s (%s)\n' "$1" "${2:-}"; }
|
||||
|
||||
KEY=${NYAI_TEST_SIGN_KEY:-$HOME/.keys/meowos-signing/signing.key}
|
||||
[ -r "$KEY" ] || KEY=""
|
||||
REAL_MPK=${NYAI_TEST_REAL_MPK:-$HOME/.cache/os.meow.reader-0.1.7-signed.mpk}
|
||||
|
||||
# ---------------- 构建 ----------------
|
||||
echo "== build =="
|
||||
( cd "$HERE" && ./build_nyai.sh host >/dev/null ) || { echo "build nyai_host failed"; exit 1; }
|
||||
NYAI="$HERE/nyai_host"
|
||||
CC=${CC_HOST:-cc}
|
||||
V="$ROOT/patch_v2/meow_verify"
|
||||
mkdir -p "$WORK/tools"
|
||||
"$CC" -Os -Wno-sign-compare -Wno-unterminated-string-initialization -Wno-unused-function \
|
||||
-c "$V/tweetnacl.c" -o "$WORK/tools/tn.o" || { echo "build tweetnacl failed"; exit 1; }
|
||||
"$CC" -Os -c "$V/sha256.c" -o "$WORK/tools/sha.o" || exit 1
|
||||
"$CC" -Os -I"$V" -c "$V/meow_verify.c" -o "$WORK/tools/mv.o" || exit 1
|
||||
"$CC" "$WORK/tools/mv.o" "$WORK/tools/sha.o" "$WORK/tools/tn.o" -o "$WORK/tools/meow_verify_host" || exit 1
|
||||
if [ -n "$KEY" ]; then
|
||||
"$CC" -Os -I"$V" -I"$ROOT/patch_v2/meow_sign" "$ROOT/patch_v2/meow_sign/meow_sign.c" \
|
||||
"$WORK/tools/tn.o" "$WORK/tools/sha.o" -o "$WORK/tools/meow_sign" || exit 1
|
||||
fi
|
||||
VERIFY="$WORK/tools/meow_verify_host"
|
||||
|
||||
# ---------------- 辅助 ----------------
|
||||
sb() { # sb <name> → 打印沙箱 MeowROOT
|
||||
local d="$WORK/case-$1"
|
||||
rm -rf "$d"
|
||||
mkdir -p "$d/root/MeowAPPs" "$d/root/MeowOS/opt/nyai"
|
||||
printf '%s\n' "$d/root"
|
||||
}
|
||||
mkpkg() { # mkpkg <dir> <package> <version_code>
|
||||
rm -rf "$1"; mkdir -p "$1"
|
||||
printf '{"name":"Test","package":"%s","exec":"bin","version":"0.1.%s","version_code":%s}\n' \
|
||||
"$2" "$3" "$3" > "$1/app.json"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$1/bin"; chmod +x "$1/bin"
|
||||
}
|
||||
pack() { tar czf "$2" -C "$1" .; }
|
||||
mk_evil_tar() { # mk_evil_tar <out.mpk> <member-name> <package>:成员名可含 ../ 或绝对路径
|
||||
python3 - "$1" "$2" "$3" <<'PY'
|
||||
import sys, tarfile, io, gzip
|
||||
out, name, pkg = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode='w') as t:
|
||||
ti = tarfile.TarInfo(name); ti.size = 3; t.addfile(ti, io.BytesIO(b'pwn'))
|
||||
j = ('{"name":"X","package":"%s","exec":"x","version":"1","version_code":1}' % pkg).encode()
|
||||
tj = tarfile.TarInfo('app.json'); tj.size = len(j); t.addfile(tj, io.BytesIO(j))
|
||||
open(out, 'wb').write(gzip.compress(buf.getvalue()))
|
||||
PY
|
||||
}
|
||||
mk_bomb_tar() { # mk_bomb_tar <out.mpk>:成员头声明 700MB 的解压炸弹
|
||||
python3 - "$1" <<'PY'
|
||||
import sys, tarfile, gzip
|
||||
ti = tarfile.TarInfo('big.bin'); ti.size = 700 * 1024 * 1024
|
||||
ti.mtime = 0; ti.mode = 0o644; ti.uid = ti.gid = 0
|
||||
hdr = ti.tobuf(format=tarfile.USTAR_FORMAT)
|
||||
open(sys.argv[1], 'wb').write(gzip.compress(hdr + b'\0' * 1024))
|
||||
PY
|
||||
}
|
||||
sign() { # sign <pkg> <mpk>:文件名茎必须等于包名(meow_sign 约定),签完覆盖原文件
|
||||
[ -n "$KEY" ] || return 2
|
||||
mkdir -p "$WORK/sign"
|
||||
cp "$2" "$WORK/sign/$1.mpk"
|
||||
"$WORK/tools/meow_sign" sign "$WORK/sign/$1.mpk" "$KEY" >/dev/null 2>&1 || return 1
|
||||
cp "$WORK/sign/$1.mpk" "$2"
|
||||
}
|
||||
nyai_run() { # nyai_run <root> <args...>
|
||||
local r=$1; shift
|
||||
MEOWROOT="$r" MEOW_VERIFY_BIN="$VERIFY" "$NYAI" "$@"
|
||||
}
|
||||
tmp_clean() { # tmp_clean <root>:MeowOS/tmp/pkgs 下无任何条目
|
||||
local n
|
||||
n=$(find "$1/MeowOS/tmp/pkgs" -mindepth 1 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ "$n" = "0" ]
|
||||
}
|
||||
show() { [ "$VERBOSE" = 1 ] && printf '%s\n' "$1"; }
|
||||
expect() { # expect <label> <cond...>
|
||||
local l=$1; shift
|
||||
if "$@"; then ok "$l"; else bad "$l"; fi
|
||||
}
|
||||
|
||||
echo "== tests =="
|
||||
|
||||
# ---- 0 fmt_utc 边界(直接 include nyai.c 的 static 函数,2026-01/02 曾算错) ----
|
||||
if "$CC" -Wall -Wextra "$HERE/test_fmt_utc.c" -o "$WORK/tools/test_fmt_utc" 2>"$WORK/fmt_utc.build.log"; then
|
||||
if "$WORK/tools/test_fmt_utc" >"$WORK/fmt_utc.log" 2>&1; then
|
||||
ok "0 fmt_utc 边界($(cat "$WORK/fmt_utc.log"))"
|
||||
else
|
||||
bad "0 fmt_utc 边界" "$(cat "$WORK/fmt_utc.log")"
|
||||
fi
|
||||
else
|
||||
bad "0 fmt_utc 编译" "$(cat "$WORK/fmt_utc.build.log")"
|
||||
fi
|
||||
|
||||
# ---- 1 文件名与包名完全不同 ----
|
||||
r=$(sb 1); mkpkg "$WORK/b1" os.meow.demo 1; pack "$WORK/b1" "$WORK/p1.mpk"
|
||||
if sign os.meow.demo "$WORK/p1.mpk"; then
|
||||
cp "$WORK/p1.mpk" "$WORK/weird-name.bin"
|
||||
out=$(nyai_run "$r" install "$WORK/weird-name.bin" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "1a weird-name.bin(无 .mpk 后缀)按清单装入 MeowAPPs/os.meow.demo" \
|
||||
test $rc -eq 0 -a -f "$r/MeowAPPs/os.meow.demo/app.json"
|
||||
expect "1b 未按文件名生成目录" test ! -e "$r/MeowAPPs/weird-name"
|
||||
expect "1c 成功后 MeowOS/tmp/pkgs 无残留" tmp_clean "$r"
|
||||
else
|
||||
skip "1 文件名解绑(签名)" "无签名私钥"
|
||||
fi
|
||||
|
||||
# ---- 2 真实反例:reader 包改名 ----
|
||||
if [ -r "$REAL_MPK" ]; then
|
||||
r=$(sb 2)
|
||||
cp "$REAL_MPK" "$WORK/os.meow.reader-0.1.7.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/os.meow.reader-0.1.7.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "2a os.meow.reader-0.1.7.mpk 仍装进 MeowAPPs/os.meow.reader(不再按文件名取包名)" \
|
||||
test $rc -eq 0 -a -f "$r/MeowAPPs/os.meow.reader/app.json"
|
||||
expect "2b 没有生成 MeowAPPs/os.meow.reader-0.1.7" test ! -e "$r/MeowAPPs/os.meow.reader-0.1.7"
|
||||
expect "2c 成功后无暂存残留" tmp_clean "$r"
|
||||
else
|
||||
skip "2 真实 reader 包改名安装" "缺 $REAL_MPK"
|
||||
fi
|
||||
|
||||
# ---- 3 footer.package ≠ app.json.package ----
|
||||
if [ -n "$KEY" ]; then
|
||||
r=$(sb 3); mkpkg "$WORK/b3" os.meow.mismatch-b 1; pack "$WORK/b3" "$WORK/mis-a.mpk"
|
||||
# 签名时文件名茎 = 包名 → footer 写 os.meow.mismatch-a,清单里却是 os.meow.mismatch-b
|
||||
sign os.meow.mismatch-a "$WORK/mis-a.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/mis-a.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "3a footer.package ≠ app.json.package → 拒装" test $rc -ne 0
|
||||
expect "3b 错误文案含两个包名与「不一致」" \
|
||||
sh -c 'printf "%s" "$1" | grep -q "签名包名(os.meow.mismatch-a) 与 app.json(os.meow.mismatch-b) 不一致"' _ "$out"
|
||||
expect "3c 拒装后未落盘任何 App" test ! -e "$r/MeowAPPs/os.meow.mismatch-b" -a ! -e "$r/MeowAPPs/os.meow.mismatch-a"
|
||||
expect "3d 拒装后无暂存残留" tmp_clean "$r"
|
||||
else
|
||||
skip "3 footer/清单不一致" "无签名私钥"
|
||||
fi
|
||||
|
||||
# ---- 4 os.meow.* 无签名 ----
|
||||
r=$(sb 4); mkpkg "$WORK/b4" os.meow.unsigned 1; pack "$WORK/b4" "$WORK/unsigned-sys.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/unsigned-sys.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "4a os.meow.* 无签名 → 拒装" test $rc -ne 0
|
||||
expect "4b 错误文案含「必须签名」" sh -c 'printf "%s" "$1" | grep -q "必须签名"' _ "$out"
|
||||
expect "4c 未落盘 + 无残留" test ! -e "$r/MeowAPPs/os.meow.unsigned"
|
||||
expect "4d 拒装后无暂存残留" tmp_clean "$r"
|
||||
|
||||
# ---- 5 第三方无签名放行 ----
|
||||
r=$(sb 5); mkpkg "$WORK/b5" dev.local.thirdparty 1; pack "$WORK/b5" "$WORK/thirdparty.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/thirdparty.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "5a 第三方无签名 → 放行安装(行为不变)" \
|
||||
test $rc -eq 0 -a -f "$r/MeowAPPs/dev.local.thirdparty/app.json"
|
||||
expect "5b 成功后无残留" tmp_clean "$r"
|
||||
|
||||
# ---- 6 恶意 package 名 ----
|
||||
for badname in "../escape" "a/b" "." ".."; do
|
||||
r=$(sb 6)
|
||||
mkpkg "$WORK/b6" "$badname" 1; pack "$WORK/b6" "$WORK/evil.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/evil.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "6 [$badname] 拒装" test $rc -ne 0
|
||||
expect "6 [$badname] 错误文案指向 app.json.package" \
|
||||
sh -c 'printf "%s" "$1" | grep -q "app.json.package 非法"' _ "$out"
|
||||
expect "6 [$badname] 未落盘到目录外(root/escape、root/a、沙箱父目录)" \
|
||||
test ! -e "$r/escape" -a ! -e "$r/a" -a ! -e "$WORK/case-6/escape" -a ! -e "$WORK/case-6/a"
|
||||
expect "6 [$badname] MeowAPPs/MeowOS 目录未被破坏" \
|
||||
test -d "$r/MeowAPPs" -a -d "$r/MeowOS"
|
||||
expect "6 [$badname] 无暂存残留" tmp_clean "$r"
|
||||
done
|
||||
|
||||
# ---- 7 解压失败 / SIGTERM 中断 ----
|
||||
r=$(sb 7)
|
||||
printf 'this is not a gzip stream\n' > "$WORK/broken.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/broken.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "7a 非法包(非 gzip/tar)→ 拒装" test $rc -ne 0
|
||||
expect "7b 解压失败后无暂存残留" tmp_clean "$r"
|
||||
|
||||
mkpkg "$WORK/b7" dev.test.slow 1
|
||||
head -c 134217728 /dev/zero > "$WORK/b7/big.bin" # 128MB,拉长解包时间以便打信号
|
||||
pack "$WORK/b7" "$WORK/slow.mpk"
|
||||
r=$(sb 7s)
|
||||
MEOWROOT="$r" MEOW_VERIFY_BIN="$VERIFY" "$NYAI" install "$WORK/slow.mpk" >"$WORK/sig.log" 2>&1 &
|
||||
pid=$!
|
||||
sleep 0.30
|
||||
kill -TERM "$pid" 2>/dev/null
|
||||
wait "$pid"; rc=$?
|
||||
show "$(cat "$WORK/sig.log")"
|
||||
if grep -q "收到信号" "$WORK/sig.log"; then
|
||||
expect "7c SIGTERM 中断:退出码 143" test "$rc" -eq 143
|
||||
expect "7d SIGTERM 中断后无暂存残留" tmp_clean "$r"
|
||||
expect "7e SIGTERM 中断后未落盘 App" test ! -e "$r/MeowAPPs/dev.test.slow"
|
||||
else
|
||||
skip "7c-e SIGTERM 中断清理" "解包在信号前已完成(rc=$rc)"
|
||||
fi
|
||||
rm -f "$WORK/b7/big.bin"
|
||||
|
||||
# ---- 8 索引路径:Package 取自清单 ----
|
||||
r=$(sb 8)
|
||||
mkdir -p "$WORK/repo"
|
||||
mkpkg "$WORK/b8" os.meow.idx 3; pack "$WORK/b8" "$WORK/idx.mpk"
|
||||
if sign os.meow.idx "$WORK/idx.mpk"; then
|
||||
cp "$WORK/idx.mpk" "$WORK/repo/zzz-renamed-file.mpk" # 文件名与包名完全不同
|
||||
printf 'local %s\n' "$WORK/repo" > "$r/MeowOS/opt/nyai/sources.list"
|
||||
"$NYAI" index "$WORK/repo" >"$WORK/idx.log" 2>&1
|
||||
expect "8a 索引 Package 字段来自 app.json(不是文件名)" \
|
||||
grep -q '^Package: os.meow.idx$' "$WORK/repo/Packages"
|
||||
out=$(nyai_run "$r" install os.meow.idx 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "8b 按索引包名安装成功" test $rc -eq 0 -a -f "$r/MeowAPPs/os.meow.idx/app.json"
|
||||
expect "8c 索引安装后无残留" tmp_clean "$r"
|
||||
else
|
||||
skip "8 索引路径" "无签名私钥"
|
||||
fi
|
||||
|
||||
# ---- 9 upgrade 复用 install 引擎 ----
|
||||
r=$(sb 9)
|
||||
mkpkg "$WORK/b9" dev.local.up 1; pack "$WORK/b9" "$WORK/up1.mpk"
|
||||
nyai_run "$r" install "$WORK/up1.mpk" >/dev/null 2>&1
|
||||
mkpkg "$WORK/b9" dev.local.up 2; pack "$WORK/b9" "$WORK/up2-any-name.bin"
|
||||
out=$(nyai_run "$r" upgrade dev.local.up "$WORK/up2-any-name.bin" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "9a upgrade 接受任意文件名(清单包名 == 目标包名)" \
|
||||
test $rc -eq 0 -a "$(grep -o '"version_code":[0-9]*' "$r/MeowAPPs/dev.local.up/app.json")" = '"version_code":2'
|
||||
expect "9b upgrade 后无残留" tmp_clean "$r"
|
||||
mkpkg "$WORK/b9" dev.local.up 0; pack "$WORK/b9" "$WORK/up0.mpk"
|
||||
out=$(nyai_run "$r" upgrade dev.local.up "$WORK/up0.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "9c 降级被拒" sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "不允许降级"' _ "$rc" "$out"
|
||||
mkpkg "$WORK/b9" dev.local.other 3; pack "$WORK/b9" "$WORK/upother.mpk"
|
||||
out=$(nyai_run "$r" upgrade dev.local.up "$WORK/upother.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "9d 清单包名 ≠ upgrade 目标 → 拒" \
|
||||
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "与目标包名(dev.local.up) 不一致"' _ "$rc" "$out"
|
||||
expect "9e 以上失败均无残留" tmp_clean "$r"
|
||||
|
||||
# ---- 10 评审 r1 回归:大小写绕过(P1)/ 尾点 / 名字冲突 / 成员名 / 解压炸弹 ----
|
||||
# 10a 先装一个合法签名系统包,再试图用大写包名顶掉它(exFAT 名字不区分大小写)
|
||||
if [ -n "$KEY" ]; then
|
||||
r=$(sb 10a); mkpkg "$WORK/b10a" os.meow.demo 1; pack "$WORK/b10a" "$WORK/demo10a.mpk"
|
||||
sign os.meow.demo "$WORK/demo10a.mpk"
|
||||
nyai_run "$r" install "$WORK/demo10a.mpk" >/dev/null 2>&1
|
||||
before=$(md5sum "$r/MeowAPPs/os.meow.demo/app.json" | cut -d' ' -f1)
|
||||
mkpkg "$WORK/b10ae" "OS.MEOW.DEMO" 99; pack "$WORK/b10ae" "$WORK/evil-case.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/evil-case.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
after=$(md5sum "$r/MeowAPPs/os.meow.demo/app.json" | cut -d' ' -f1)
|
||||
expect "10a 大写包名 OS.MEOW.DEMO 被拒(valid_pkg 只收小写)" test $rc -ne 0
|
||||
expect "10a 错误文案指向 app.json.package 非法" \
|
||||
sh -c 'printf "%s" "$1" | grep -q "app.json.package 非法"' _ "$out"
|
||||
expect "10a 已装 os.meow.demo 未被顶掉(app.json 未变)" test "$before" = "$after"
|
||||
expect "10a 未生成大写目录" test ! -e "$r/MeowAPPs/OS.MEOW.DEMO"
|
||||
expect "10a 无残留" tmp_clean "$r"
|
||||
else
|
||||
skip "10a 大小写绕过" "无签名私钥"
|
||||
fi
|
||||
|
||||
# 10b 尾点包名(exFAT/Windows 尾点映射怪癖)
|
||||
r=$(sb 10b); mkpkg "$WORK/b10b" "dev.test." 1; pack "$WORK/b10b" "$WORK/trailing-dot.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/trailing-dot.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "10b 尾点包名 dev.test. 被拒" \
|
||||
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "app.json.package 非法"' _ "$rc" "$out"
|
||||
expect "10b 无残留" tmp_clean "$r"
|
||||
|
||||
# 10c 已存在仅大小写不同的目录 → 部署前拦(exFAT 上会顶掉已装 App)
|
||||
r=$(sb 10c); mkdir -p "$r/MeowAPPs/DEV.TEST.CLASH"
|
||||
mkpkg "$WORK/b10c" dev.test.clash 1; pack "$WORK/b10c" "$WORK/clash.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/clash.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "10c 与已装目录仅大小写不同 → 拒装" \
|
||||
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "仅大小写不同"' _ "$rc" "$out"
|
||||
expect "10c 已存在的目录未被改动" test -d "$r/MeowAPPs/DEV.TEST.CLASH"
|
||||
expect "10c 无残留" tmp_clean "$r"
|
||||
|
||||
# 10d tar 成员名净化(不依赖外部 tar 的路径处理)
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
for nm in "/tmp/evil.txt:dev.evil.abs" "../evil.txt:dev.evil.dd" "-evil.txt:dev.evil.dash"; do
|
||||
member=${nm%%:*}; pkg=${nm##*:}
|
||||
r=$(sb "10d-$pkg")
|
||||
mk_evil_tar "$WORK/evil-member.mpk" "$member" "$pkg"
|
||||
out=$(nyai_run "$r" install "$WORK/evil-member.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "10d [$member] 成员名非法 → 拒装" \
|
||||
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "包成员路径非法"' _ "$rc" "$out"
|
||||
expect "10d [$member] 无残留且未落盘" test ! -e "$r/MeowAPPs/$pkg" -a -z "$(find "$r/MeowOS/tmp/pkgs" -mindepth 1 2>/dev/null)"
|
||||
done
|
||||
|
||||
# 10e 控制字符成员名(tar -tv 可能把它转义成可见文本 → 靠解包后的 on-disk 名字复核)
|
||||
r=$(sb 10e)
|
||||
python3 - "$WORK/evil-ctrl.mpk" <<'PY'
|
||||
import sys, tarfile, io, gzip
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode='w') as t:
|
||||
ti = tarfile.TarInfo('ev\x01il.txt'); ti.size = 3; t.addfile(ti, io.BytesIO(b'pwn'))
|
||||
j = b'{"name":"X","package":"dev.evil.ctrl","exec":"x","version":"1","version_code":1}'
|
||||
tj = tarfile.TarInfo('app.json'); tj.size = len(j); t.addfile(tj, io.BytesIO(j))
|
||||
open(sys.argv[1], 'wb').write(gzip.compress(buf.getvalue()))
|
||||
PY
|
||||
out=$(nyai_run "$r" install "$WORK/evil-ctrl.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "10e 控制字符成员名 → 拒装" \
|
||||
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "控制字符"' _ "$rc" "$out"
|
||||
expect "10e 无残留" tmp_clean "$r"
|
||||
|
||||
# 10f 解压炸弹:成员头声明 700MB(上限 512MB)
|
||||
r=$(sb 10f)
|
||||
mk_bomb_tar "$WORK/bomb.mpk"
|
||||
out=$(nyai_run "$r" install "$WORK/bomb.mpk" 2>&1); rc=$?
|
||||
show "$out"
|
||||
expect "10f 解压炸弹(700MB)→ 拒装" \
|
||||
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "防解压炸弹"' _ "$rc" "$out"
|
||||
expect "10f 无残留" tmp_clean "$r"
|
||||
else
|
||||
skip "10d-f 成员名/解压炸弹" "无 python3"
|
||||
fi
|
||||
|
||||
# ---------------- 汇总 ----------------
|
||||
printf '\n== summary: %d passed, %d failed, %d skipped ==\n' "$PASS" "$FAIL" "$SKIP"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
@@ -3293,3 +3293,533 @@ d102c64f7fab73e981a789c5960e6bd9 ncm_host # 宿主调试二进
|
||||
- **`account.json` 旧账号首次升级**:旧文件无 `last_refresh_ts` 字段 → `cookie_last_refresh_ts=0` → 视为临期,开机续期一次;成功即落盘,避免反复判临期。
|
||||
- **eapi 会员播放(song_url 等)因 MUSIC_U 头注入而改变行为**:此前 eapi 请求不带 MUSIC_U(§36.5 遗留),登录态 eapi 播放按游客鉴权;本批起带真实 MUSIC_U,会员档/登录态更准确——需在 eapi 可达网络下观察歌单/播放回归(`music.163.com` 域不受影响)。
|
||||
- **未部署**:`ncm` 产物未上卡;真机仅做了只读探针(不动 App 二进制),`app.json`/`ncm` 未替换。
|
||||
|
||||
## 38. M5:已缓存列表页 + 缓存文件内元数据嵌入(2026-11-04)
|
||||
|
||||
用户原话:「优化一下网易云的部分逻辑。在首页顶栏新加一个已缓存列表入口。另外,缓存文件的配置改一下:
|
||||
在保留元数据分离的同时,将歌曲元数据嵌入缓存文件中(不然在其他播放器中读不到任何信息)。」
|
||||
|
||||
### 38.1 功能①:首页顶栏「已缓存」入口 + 缓存列表页
|
||||
|
||||
- 首页 topbar 新增动作 3(`MEOW_ICON_DOWNLOAD` 位图,`app/ui/home.c`),原「回播放页」顺延为动作 4;
|
||||
点击 `cache_open()` → push `ACT_CACHE`(新 activity id 7)。
|
||||
- 新页 `app/ui/cache.c`(对齐 queue.c 惯例:圆角卡 + 红 mini-vinyl + 按压红条 + 当前播放行高亮):
|
||||
- 行:歌名 / `歌手 · 实际档位 · 大小`;空态「暂无缓存」;页脚「N 首 · 合计 X MB」。
|
||||
- 点行 = 点播(切 `NCM_QUEUE_CACHE` 队列,上/下一首在缓存列表内循环);行右「删除」= 单曲删除;
|
||||
顶栏「清空」= 确认框 + `ncm_cache_clear`(与设置页「清理缓存」同语义:音频 + 封面条目)。
|
||||
- **数据全在 worker**:`cache_reload` 起线程 → `appstate_cache_list_load()` →
|
||||
`ncm_cache_scan()`(读目录 + 文件头 tag + 音频参数探测)→ 快照;UI 线程只读快照(零磁盘 IO)。
|
||||
- 播放链路:`NCM_QUEUE_CACHE`(`appstate_queue_count/song`、`player_play_cache_song`);
|
||||
`player_manager.c` 的 `song_local_file` 对缓存队列用**条目真实键**(id + 文件名请求档 + 实际扩展名)
|
||||
直接命中缓存文件(不按用户当前档键重下),`eff_level` 回填实际档。
|
||||
|
||||
### 38.2 功能②:缓存文件内元数据嵌入(保留元数据分离)
|
||||
|
||||
- 新模块 `core/ncm_tag.c/.h`(纯 libc,宿主可单测):
|
||||
- **FLAC → VORBIS_COMMENT**(type 4,TITLE/ARTIST/ALBUM;插在既有元数据块之后、音频帧之前,
|
||||
旧 VORBIS_COMMENT 被替换、PADDING/PICTURE 等原样保留);
|
||||
- **MP3 → ID3v2.3**(TIT2/TPE1/TALB,UTF-16LE + BOM,中英文/emoji 均可;源已带 ID3v2 时先剥旧标签)。
|
||||
- **按实际容器分支**(读 `fLaC` 魔数)——缓存名里的档位段是**请求档**,真机存在
|
||||
`song_171650_hires.mp3`(无损请求被降级链落到 exhigh mp3),不能按名字判容器。
|
||||
- 写入 = 同目录 `<path>.tag.tmp` + 64KB 分块搬运 + fsync + rename 原子替换;失败即 unlink 临时件、
|
||||
原文件不变 → 调用方 commit 裸流(**降级不影响播放主链路**)。
|
||||
- 接入点 `app/play/player_manager.c`:下载完整(`dl_rc==OK`)→ `stream_src_finish`(读端先自然收尾)→
|
||||
`ncm_tag_embed_file(.tmp, ext, {name, art_name, album_name})` → 播放线程 `dispose` join 后才 commit。
|
||||
解码端全程持有原 inode 的 fd(tag 重写是 rename 换 inode),播放/seek/续传字节语义零改动。
|
||||
- **元数据分离体系不动**:`<cache_dir>/meta/<id>.bin` 旁路照旧写读;LRU 只 stat 文件(无 size 索引,
|
||||
故无「回填 size/etag」需求——重写后 stat 自然得到新 size)。
|
||||
|
||||
### 38.3 实际档位(用户/塔台取证要求)
|
||||
|
||||
`ncm_audio_probe_file`(ncm_tag.c)只读文件头:FLAC STREAMINFO(采样率/位深)、MP3 首帧(码率);
|
||||
`ncm_cache_scan` 用「请求档 ∩ 实际格式能力」得实际档(只降不升):
|
||||
FLAC 16bit→无损 SQ;24bit/48k→Hi-Res;24bit/96k→高清臻音;24bit/192k→超清母带;
|
||||
MP3 ≤128k→标准,>128k→极高 HQ。列表显示**实际档**(如 hires 名 + mp3 体 → 极高 HQ)。
|
||||
|
||||
### 38.4 内存(设备 56MB,硬约束)
|
||||
|
||||
| 项 | 峰值 |
|
||||
|---|---|
|
||||
| tag 嵌入(每次 commit 前) | malloc **64KB** 分块缓冲 + ~2KB 栈(ID3 构建)+ 结构体 ≈ **68KB 瞬时**,完毕即释放 |
|
||||
| 缓存页扫描(worker) | 快照临时数组 512×232B ≈ **116KB** + 单文件 tag/探测 64KB 顺序复用 ≈ **180KB 瞬时** |
|
||||
| 常驻 bss | `g_st.cache_items[512]` ≈ **116KB**(+8.5% 于原 1364516B bss;RSS 占比 <0.3%) |
|
||||
|
||||
大文件不整读:FLAC 元数据块/PICTURE、MP3 标签区、音频体全部 ≤64KB 分块;tag 读取只读文件头(≤64KB)。
|
||||
|
||||
### 38.5 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| 新 `test/test_ncm_tag.c` + `build_ncm_tag_host.sh`(FLAC/MP3 构建+读回、中文 roundtrip、旧标签替换、容器判定、3MB 分块、失败降级、音频头探测) | host **51 PASS / 0 FAIL** |
|
||||
| `test_ncm_core.c` 新增 `test_cache_tag`(writer→embed→read→scan→remove→clear,含 hires 名/mp3 体实际档断言) | 已加入 core 测试集(本机缺 vendor mbedtls/jsmn 无法整链,见 38.6) |
|
||||
| 临时单测(真实 ncm_cache.c + ncm_api.c + ncm_tag.c,仅桩 `ncm_strlcpy`/`mkdir_p`) | 16 PASS / 0 FAIL(scan 两条、actual=EXHIGH/HIRES、name/artist 来自文件内 tag、remove/clear) |
|
||||
| 全部改动文件 `gcc -Wall -Wextra`(core + app + tests,-Wpedantic 对 ncm_tag) | **0 warning** |
|
||||
| `build_ncm.sh` / `build_core.sh` / `build_ncm_host.sh` 源列表 | 已加入 `core/ncm_tag.c`、`app/ui/cache.c`(交叉/宿主编译本机缺工具链与 vendor,未执行) |
|
||||
|
||||
### 38.6 诚实出入 / 遗留
|
||||
|
||||
- **交叉与整链测试未在本机执行**:worktree 里 `vendor/mbedtls|jsmn|minimp3|qrcodegen` 均被 .gitignore 排除且
|
||||
本机无交叉工具链(`libs_common.sh` 指向 /home/Aska/Project/R3ProII/.tools)。故 `build_core.sh`/
|
||||
`build_ncm.sh`/`build_ncm_host.sh` 与本仓库 core 测试**未运行**;已用真实源码单独编译(0 warning)并跑
|
||||
ncm_tag 全量与临时 cache-scan 单测覆盖。**需在构建环境补跑 `build_core.sh` 的 host+mipsel 两套测试**。
|
||||
- **未上真机**:不碰真机 Music/MeowData(红线)。ta 嵌入后「外部播放器可读」「minimp3/dr_flac 读带 tag 的
|
||||
缓存文件仍正常」需真机抽验;minimp3_ex 对 ID3v2 有跳过逻辑(VENDOR_NOTES:「minimp3_ex 带 ID3/CRC 处理」),
|
||||
dr_flac 原生解析 VORBIS_COMMENT 元数据块,理论兼容,但未实测。
|
||||
- **tag 重写是一次额外整文件读+写**(TF 顺序 IO):在下载线程内、播放收尾前完成;短曲/快速拖动到底时
|
||||
dispose 的 join 可能多等 ~0.3–1s。磁盘瞬时要 2× 单曲空间(失败自动降级裸流)。
|
||||
- **缓存队列点播的时长/封面**取自旁路 `meta/<id>.bin`(单次 ≤600B 读);无旁路的旧条目只有歌名/歌手/大小。
|
||||
- **列表上限 512 条**(最近访问优先);池内更多时页脚仍显示展示条数,超出部分需 LRU 或搜索触达。
|
||||
|
||||
## 39. M6:缓存文件名改为「实际档」(2026-11-04)
|
||||
|
||||
用户原话:「有降级显示,但是那个缓存文件名误导人」。背景:现缓存命名 `song_<id>_<请求档>.<实际格式>`,
|
||||
当服务端对无损档只给 mp3 时降级链落到 exhigh,文件名却仍写 hires(真机 `song_171650_hires.mp3`),名不副实。
|
||||
|
||||
### 39.1 设计(先写方案再动代码)
|
||||
|
||||
- **命名**:新写入一律 `song_<id>_<实际档>.<实际格式>`,实际档由 `ncm_cache_actual_level(req, 音频参数,
|
||||
响应生效档)` 判定(**单一事实源**:写盘名 / 播放页显示 / 已缓存列表三处共用)。
|
||||
规则:`min(请求档, 容器可承载档, 降级链生效档)`,只降不升——
|
||||
mp3 码率 >128k→exhigh、≤128k→standard(mp3 永不高于极高,码率未知时也钳到极高);
|
||||
flac 16bit/44.1k→lossless、24/48→hires、24/96→jyeffect、24/192→jymaster。
|
||||
典型:hires 请求 + 320k mp3 响应 → `song_171650_exhigh.mp3`。
|
||||
- **命中语义(同歌同档不重复下载)**:`ncm_cache_lookup` 在请求档精确 miss 后**只降不升**逐档下探
|
||||
(`surround→sky→jymaster→jyeffect→hires→lossless→exhigh→higher→standard`,DOLBY 不在链内)。
|
||||
于是 hires 请求命中已存的 `song_<id>_exhigh.mp3` / `song_<id>_lossless.flac`,不重下;低档请求
|
||||
不会向上命中高档实体(不偷跑流量/存储)。`.part` 续传键也用实际档,同名才续传/commit。
|
||||
- **旧文件兼容(不强制迁移)**:旧命名 `song_<id>_hires.mp3`(及无档位段 `song_<id>.mp3`、历史双点名)
|
||||
原样保留:按原键可命中、可播放、可单曲删除、可 LRU 淘汰;列表按文件头探测显示实际档(M5 既有链路)。
|
||||
播放/列表的生效档由 `ncm_cache_level_from_path`(文件名档位)+ 容器钳顶得出:mp3 实体不可能高于
|
||||
极高,故旧 `hires.mp3` 标注「极高」,与实际一致。**不再**像 §NEW 任务2 那样把旧 mp3 改名 + 强制重下。
|
||||
- **去重展示**:同曲同实际档、两种命名并存时(旧请求档名 + 新实际档名),`ncm_cache_scan` 只保留
|
||||
最近访问的一条;不同实际档(如 lossless.flac + exhigh.mp3)照常各列一行。
|
||||
|
||||
### 39.2 改动
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `core/ncm_cache.c/.h` | 新增 `ncm_cache_actual_level`(容器/码率/位深/响应档→实际档,只降不升)、`ncm_cache_level_from_path`(文件名档位解析,含 `sky_c51`);`ncm_cache_lookup` 拆出精确查找 + 档位下探链;`ncm_cache_scan` 复用实际档判定并按 (id, actual_level, ext) 去重 |
|
||||
| `app/play/player_manager.c` | 写盘键改用响应实际档(`ext` 判定后计算,`.part`/`open`/日志同键);`eff_level` = 实际档;命中路径按文件名档位 + mp3 钳顶标注实际档;删除旧「stale-degraded 改名+强制重下」与「无档位键迁移」块(老卡文件免迁移) |
|
||||
| `test/test_ncm_cache.c` + `build_ncm_cache_host.sh` | 新增纯 libc 宿主单测:命名生成/解析、降级后写入名、hires 命中已存 exhigh/lossless、低档不命中高档、旧命名命中/扫描/删除、双点名迁移、列表去重、LRU/清空/删除对两种命名兼容、`level_from_path` 边界 |
|
||||
| `test/test_ncm_core.c` | 新增 `test_cache_actual_naming`(同一组语义接入 core 全量测试集;本机缺 vendor 未整链,仅 `-fsyntax-only` 0 warning) |
|
||||
| `bench/player_flow_sim.c` | 适配:补 `ncm_cache_actual_level`/`ncm_cache_level_from_path` 桩与 M5 后缺失的链接桩(`appstate_cache_*`、`ncm_tag_embed_file`);P7/P12 期望键改实际档;P13 改为「旧命名命中即播 + 实际档显示,不重下不迁移」 |
|
||||
|
||||
### 39.3 验证
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `./build_ncm_cache_host.sh`(真实 `core/ncm_cache.c` + `core/ncm_tag.c`,仅桩 `ncm_strlcpy`/`mkdir_p`/档位映射) | **63 PASS / 0 FAIL**,`-Wall -Wextra` 0 warning |
|
||||
| `player_manager.c` / `player_flow_sim.c` 语法+告警检查(`gcc -O2 -Wall -Wextra -fsyntax-only`,临时 jsmn 头垫片) | 0 warning |
|
||||
| `bench/player_flow_sim.c` 链接(真实 player_manager + stream_src + mem_watch + meow_cachegov) | 链接通过(运行需可写 `/tmp`,本机 Termux 不可写,见 39.4) |
|
||||
| `test_ncm_tag_host.sh` 既有链路 | 未改 `ncm_tag.c`,未重跑(非本批改动面) |
|
||||
|
||||
### 39.4 诚实出入 / 遗留
|
||||
|
||||
- **本机缺 vendor(mbedtls/jsmn/minimp3/qrcodegen)与交叉工具链**,`build_core.sh`/`build_ncm.sh`/
|
||||
`build_ncm_host.sh` 未执行;`test_ncm_core_host` 未重编(M5 已记录同一限制)。新语义用
|
||||
`test/test_ncm_cache_host`(真实 core 源)全量覆盖,构建环境需补跑 core 全量回归。
|
||||
- **`player_flow_sim` 未在本机运行**:其路径硬编码 `/tmp`(Termux 不可写),M5 后还缺若干链接桩
|
||||
(本批已补齐);需在常规构建机跑 P1–P15 全量。
|
||||
- **质量取舍(任务口径)**:命中下探后,hires 请求会直接播放已存的 exhigh 实体,不再像 §NEW 任务2
|
||||
那样强制为「更高档」重下。这是「实际档命名 + 同名去重」的必然结果(用户已接受降级显示);
|
||||
如需「换高档时重取」,应作为显式「重新下载」交互另开需求。
|
||||
- **flac 头参数在响应时点未知**:落盘名以降级链生效档为上限,下载完成后由列表探测显示实际档;
|
||||
服务端「声称 hires 实为 16bit」的病态情况,文件名会高于列表实测档(既有取舍,未引入回归)。
|
||||
- **旧命名文件的跨档复用**:旧 `song_<id>_hires.mp3`(实际 exhigh)在 hires 请求下直接命中;若用户
|
||||
切到「极高」档再播同名曲,会按实际档新落一份 `song_<id>_exhigh.mp3`(一次性重下,旧文件不删,
|
||||
列表按 (id,实际档,ext) 去重只显示一行,旧文件由 LRU/清空自然回收)。未对旧名做向上探测扫描
|
||||
(避免每次 miss 扫目录 + 读多份文件头);如需「旧名跨档零重下」,可作为后续优化加一次目录级兼容探测。
|
||||
|
||||
## 40. M8:交叉编译 + 真机热更(2026-11-04)
|
||||
|
||||
用户原话:「跑」(把 M5/M6 已合并代码交叉编译并热更上卡真机验证)。
|
||||
|
||||
### 40.1 宿主适配层(新增入库)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `build_ncm_cross_host.sh`(新) | 不改原脚本的容器路径约定:临时布局同构 `$HOME/Project/R3ProII`(`.tools/zig-mipsel-cc`、`mipsel-linux-muslsf-cross/mipsel-linux-muslsf` → 本机 `~/.tools/mipsel-linux-musleabi-sysroot`、`zig-env/bin/zig` → 系统 `ar` 包装)+ launcher 镜像(仓库头文件 + 取回的 `meow_font.a`),导出 `CC_MIPS`/`AR_MIPS`/`TMPDIR` 后按 `sdk → vendor → host-mbedtls → core → tests → ncm` 原样执行;每步日志落 `~/.cache/meow-ncm-build/logs/`。缺省 `all`,也可单步跑。 |
|
||||
| `libs_common.sh` | `CC_MIPS`/`AR_MIPS` 由硬编码 `/home/Aska/...` 改为 `MEOW_R3PROII_ROOT`(缺省 `$HOME/Project/R3ProII`)+ 环境变量可覆盖;容器行为不变。 |
|
||||
| `build_mbedtls.sh` | 清旧对象从 `library/*.o` 改为递归清 `library/` + `3rdparty/`(everest/p256-m 的对象在 3rdparty),否则混架构对象会被归档进库。 |
|
||||
| `test/test_ncm_core.c` | 三处硬编码 `/tmp`(store/cache/tag)改为 `$TMPDIR` 优先,默认 data_dir 同改;Termux `/tmp` 不可写,否则 deviceId 持久化/机器密钥直接 FAIL。 |
|
||||
|
||||
> 任务建议的 `build_ncm_host.sh` 名称已被「宿主 UI 截图/内存 harness」占用,适配层改用 `build_ncm_cross_host.sh`。
|
||||
|
||||
### 40.2 构建结果
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `build_sdk_host.sh --verify` | 75 成员(31 OBJS + meow_font.o + 43 freetype),全 ELF32 MIPS32 rel2(o32);`meow_ui.a` 5,701,866 B;本机约 16s |
|
||||
| mbedtls(裁剪 config,102 enabled) | mipsel:crypto text 267,933 / x509 39,074 / tls 112,485(合计 419,492 ≈ 410 KB) |
|
||||
| `build_ncm.sh` | `ncm` **1,193,464 B**;text 1,084,494 / data 1,948 / bss 1,493,360;ELF32 MIPS32 rel2、静态链接、已 strip;**0 warning**(自有代码 -Wall -Wextra) |
|
||||
| md5 | `1bbd2a5527dc992de60cdf4b0ba8656d` |
|
||||
| 耗时 | 首次整链约 13s;清空 `out/app-mipsel` 后重编 39 个对象约 5s(zig cc 进程级缓存命中) |
|
||||
|
||||
### 40.3 宿主全量测试(补齐 M5/M6 缺口)
|
||||
|
||||
| 测试 | 结果 |
|
||||
|---|---|
|
||||
| `test/test_ncm_core_host`(全量,含 M5 `test_cache_tag` / M6 `test_cache_actual_naming`) | **139 PASS / 0 FAIL / 2 SKIP**(SKIP 均为 live 网络项:Termux 环境 unikey rc=-5;计数不含 `== RESULT: PASS ==` 横幅) |
|
||||
| `test/test_ncm_tag_host`(FLAC/MP3 tag 嵌入/读回/分块/降级) | **52 PASS / 0 FAIL** |
|
||||
| `test/test_ncm_cache_host`(实际档命名/命中下探/旧名兼容/LRU) | **63 PASS / 0 FAIL** |
|
||||
| 告警 | core/tag/cache/ncm 各构建日志 `warning:` 计数均为 0 |
|
||||
| mipsel 测试二进制 | `out/test_ncm_core_mipsel` text 545,098 链接成功;本机无 `qemu-mipsel`,未执行 |
|
||||
|
||||
### 40.4 真机热更(白名单仅 MeowAPPs/dev.player.ncm/)
|
||||
|
||||
- 设备 `root@10.76.208.50`,`app.json.exec=/usr/data/mnt/sd_0/MeowAPPs/dev.player.ncm/ncm`。
|
||||
- 旧件 md5 `276b6caaf3212389f7f9dab1c289ac27`(1,160,092 B)→ 备份 `ncm.bak.276b6caaf3212389f7f9dab1c289ac27`(同目录;原有 `ncm.bak` 保留)。
|
||||
- 新件经 `ssh 'cat > ncm.new'` 流式写入 → 卡上 md5 核对一致 → `chmod 755` + `mv` → `sync`。
|
||||
- 卡上 md5 `1bbd2a5527dc992de60cdf4b0ba8656d` == 本地;大小 1,193,464 B。
|
||||
- **红线校验**:`du -s /usr/data/mnt/sd_0/Music` 写前/写后均为 **199,164,288 B**,恒定;未触碰 Music/MeowData/固件,未重启设备。
|
||||
- 生效方式:launcher 直接执行 `MeowAPPs/<pkg>/` 二进制,替换文件即生效,app 列表无需 rescan。热更时 ncm 未在运行,用户重新打开 App 即加载新件(物理验收由用户做)。
|
||||
|
||||
### 40.5 遗留
|
||||
|
||||
- **mipsel 侧未运行**:本机无 `qemu-mipsel`,`out/test_ncm_core_mipsel` 只完成编译+链接;真机功能验证依赖用户打开 App(顶栏入口/列表)。
|
||||
- **live 网络测试跳过**:Termux 环境 HTTPS 到 music.163.com 返回 rc=-5(网络/证书被拒),2 项 SKIP;加密/缓存/API 解析等离线项全部 PASS。
|
||||
- **产物不入库**:`ncm` 二进制、`meow_ui.a`、vendor、`out/` 均只在本机产出;仓库中原本被跟踪的旧 `ncm`/`test_ncm_core_host` 已恢复为 HEAD 版本,避免把本机构建产物带进提交。
|
||||
- 卡上保留两份备份(`ncm.bak` 与 `ncm.bak.<旧md5>`),需要回滚可 `cp` 任一覆盖。
|
||||
|
||||
## 41. M9:断网保持登录态 + 离线模式(账号态不被网络故障否决)(2026-11-05)
|
||||
|
||||
> 用户原话:「网易云有个问题,只要设备一断网 它就会自动退出到扫码登录界面,而不会保持当前账号状态
|
||||
> 切换到离线模式。这样导致昨天刚做的那个缓存列表形同虚设。估计是没有判断网络状态 把网络访问不可达
|
||||
> 当成了登录状态过期」。
|
||||
>
|
||||
> 目标:网络不可达/超时/DNS/连接失败/服务端 5xx → **保持账号态**、进入离线模式(界面可见提示、
|
||||
> 缓存列表可打开可播放、不弹扫码),网络恢复后回在线;只有服务端**明确**未登录/鉴权失败
|
||||
> (HTTP 401、业务码 301/302)才沿用原退出登录逻辑。排除:不做离线下载/搜索等在线功能。
|
||||
> 红线:0warning;宿主可验证错误分类单测必过;不碰真机;不 push。
|
||||
>
|
||||
> 编号说明:§40 为 M8 交叉编译/热更(本分支已 rebase 到含 §40 的 main),本节顺延 §41。
|
||||
|
||||
### 41.1 判定点盘点:谁会把用户踢回扫码
|
||||
|
||||
| # | 位置 | 触发 | 后果 |
|
||||
|---|------|------|------|
|
||||
| ① | `app/app.c:176-180` `app_on_tick` | `account_cookie_need_relogin()`(仅由 `account_cookie_maybe_refresh` 置位) | `appstate_logout()`(删 `session.enc`、清 uid/vip)+ `meow_view_page_replace(ACT_LOGIN)` |
|
||||
| ② | `app/logic/appstate.c:231-256` `appstate_ensure_uid` | `ncm_login_status_check` 返回 `NCM_ERR_AUTH` | `ncm_ctx_clear_cookie` + `logged_in=0`(首页转「未登录/会话过期」) |
|
||||
| ③ | `app/app.c:245-252` 启动根页 + `app/logic/appstate.c:100` | `g_st.logged_in = (cookie[0] != 0)`;cookie 只在 `ncm_ctx_load_cookie` 解密成功时非空 | cookie 解密失败 → 直接进 `ACT_LOGIN`(扫码页) |
|
||||
| ④ | `app/ui/settings.c` | 用户点「退出登录」 | 主动登出(保留原逻辑) |
|
||||
|
||||
调用链(①):`home_worker`(`app/ui/home.c`) → `account_cookie_maybe_refresh`(`app/logic/account.c:181`)
|
||||
→ `ncm_login_refresh`(`core/ncm_login.c:190`) → 返回码 → `s_cookie_need_relogin=1`(`account.c:210-215`)
|
||||
→ `app_on_tick`(`app.c:176`) → `appstate_logout()` + 扫码页。
|
||||
调用链(②):`appstate_load_playlists`(`appstate.c:227`) → `appstate_ensure_uid`(`appstate.c:231`)
|
||||
→ `ncm_login_status_check`(`core/ncm_login.c:142`) → 返回码 → 清 cookie(`appstate.c:246-250`)。
|
||||
|
||||
### 41.2 根因:哪些失败被误判成「登录失效」
|
||||
|
||||
代码级证据(均为本批修改前的行为):
|
||||
|
||||
1. **任何业务码非 200 都当会话失效**:`core/ncm_login.c:227`(改前)`if (code != 200) return NCM_ERR_AUTH;`
|
||||
—— `-460`(风控/请求被拒,换网/恢复网络时常见)也被判 AUTH。`core/ncm_api.c:104`(改前)
|
||||
把 `-460/2000/201` 一律映射 `NCM_ERR_AUTH`。→ 网络环境变化触发的风控被当成「会话过期」。
|
||||
2. **HTTP 状态被完全忽略**:`api_post`(`core/ncm_api.c:123`)与 `ncm_login_status_check`
|
||||
(`core/ncm_login.c:162`)只看 body 里的业务码。HTTP 401/403 与 5xx/网关 HTML 都落进
|
||||
「JSON 解析失败 / 缺字段」;反过来 5xx 与网络故障无法区分,离线判定拿不到可靠输入。
|
||||
3. **mbedtls 裸负码穿透**:`http_run` 的握手/写入失败分支 `goto done` 直接把 mbedtls 的负码
|
||||
(如 `-0x7280`)返回给上层(`core/ncm_http.c` 改前 `done:` 段);上层按 `NCM_ERR_*` 比较,
|
||||
落进「未知错误」,既判不了离线也判不了鉴权。
|
||||
4. **启动无网的持久化缺陷(与错误分类无关,但症状一致)**:会话 cookie 的加密密钥是
|
||||
`sha256(首选网卡 MAC + 盐)`(`core/ncm_store.c:102-135 read_mac` 遍历 `/sys/class/net` 取首个
|
||||
非 lo 网卡 + `151-171` 派生)。网卡集合/readdir 顺序随网络状态变化(断网时 Wi-Fi 网卡可能
|
||||
从 `/sys/class/net` 消失,或选到另一块网卡)→ 派生密钥变化 → `session.enc` 解密失败
|
||||
(`ncm_store.c:260-268` → `ncm_ctx_load_cookie` → `appstate.c:100`)→ cookie 为空 →
|
||||
`logged_in=0` → **启动即进扫码页**。这正是「一断网就退到扫码」在启动侧的机制。
|
||||
|
||||
结论:设计上缺少「失败分类」这一层——传输层/服务端暂不可用、鉴权失败、数据解析失败共用
|
||||
「负错误码」这一个通道,登录门(`app.c` / `appstate_ensure_uid`)只能按 AUTH 一个值判断,
|
||||
于是任何上游映射偏差都会变成「退出登录」。
|
||||
|
||||
### 41.3 实现
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `core/ncm_session.h/.c`(新) | 纯逻辑分类层:`ncm_err_classify`(NETWORK/AUTH/OTHER;`-4/-5/-6/-17` 与历史 mbedtls 裸负码→NETWORK,仅 `-13`→AUTH)、`ncm_http_status_class/err`(401/403→AUTH;408/425/429/5xx→SVC;其它非 2xx→HTTP)、`ncm_gate_decide`(一次请求→在线/离线/退登录/保持)、`ncm_session_boot_decide`(有本地会话就先信它,无网→离线,绝不因连不上进扫码)。 |
|
||||
| `core/ncm_common.h` | 新增 `NCM_ERR_SVC = -17`(服务端 5xx/429/-460:暂不可用,可重试,**不是**登录失效)。 |
|
||||
| `core/ncm_http.c` | 新增 `http_ret_norm`:超时→`NCM_ERR_TIMEOUT`、证书→`NCM_ERR_TLS`、其它 mbedtls 负码→`NCM_ERR_NET`,在 `done:` 统一归一(532 行)。**已是 NCM 错误码的一律原样返回**(含 `NCM_ERR_JSON`:畸形分块/帧体是数据错误,不得改判成网络不可达——评审 P2-1)。 |
|
||||
| `core/ncm_api.c` | `api_status_gate`:非 2xx 先按状态定性(401/403→AUTH、5xx/429→SVC、其它→HTTP),不再把 5xx HTML 丢给 JSON 解析;业务码映射加 302→AUTH、`-460`→SVC(113-141 行)。`api_post_cached` 新增 `from_cache` 出参;新增 `ncm_api_user_playlist_ex`(回填「本次是否接口缓存命中」;`out_n` 与原接口一致必填、`out_from_cache` 可 NULL),旧接口为薄包装(评审 P2-2)。 |
|
||||
| `core/ncm_login.c` | `ncm_login_status_check` / `ncm_login_refresh` 先过 HTTP 状态门,再按业务码:301/302→AUTH、`-460`→SVC、其它→HTTP(163-249 行)。`-460` 不再触发退扫码。 |
|
||||
| `core/ncm_store.c/.h` | 会话密钥改为设备私有 `<data_dir>/.mkey` 派生(`key_from_mkey`/`session_key`,175-193 行);旧 MAC 派生密文仍可解(`cookie_decrypt_sel`,270 行)且 `load_session` 命中旧密钥时自动重写迁移(306-317 行)。 |
|
||||
| `core/ncm_apicache.c/.h` | 盘缓存命中分支的两行合并为一行(行为不变,重构)。**不再提供全局命中计数**(评审 P2-2:跨 worker 差值会误判、自增在锁外有竞争;改为单请求出参,见下)。 |
|
||||
| `app/ncm_app_internal.h` | `ncm_app_state_t` 增 `offline`;新增 `appstate_session_gate/is_offline/offline_set`。 |
|
||||
| `app/logic/appstate.c` | 新增离线状态机(177-197 行);`appstate_ensure_uid` 仅 AUTH 清 cookie,网络类只切离线(231-256 行);init/logout 清零离线态;新增 `appstate_load_playlists_ex(from_cache)` 透传单请求缓存命中标志。 |
|
||||
| `app/app.c` | 启动根页改由 `ncm_session_boot_decide` 决定并打日志(245-252 行);`need_relogin` 路径保持(仅 AUTH 会置位)。 |
|
||||
| `app/ui/home.c` | worker 结果经 `appstate_session_gate`;「本次是否接口缓存命中」用 `appstate_load_playlists_ex(&from_cache)` 的单请求出参判定(缓存命中→不判回在线),个人卡右侧「离线模式」红标 + 页脚提示 + 空态「离线模式/可播放已缓存」;离线时每 30s 静默重试一次(强制绕过接口缓存)。 |
|
||||
| `app/ui/playlist.c` | 首屏**失败**时同步离线态(成功不判回在线:歌单详情可能命中接口缓存;锁外应用);空态「离线模式」。 |
|
||||
| `app/ui/search.c` / `app/ui/player.c` / `app/ui/cache.c` | 离线提示文案:搜索需网络、播放页「离线模式:网络不可用,可播放已缓存」、缓存页页脚「· 离线模式(可播放)」。 |
|
||||
| `build_core.sh` / `build_ncm.sh` | 核心源列表加 `core/ncm_session.c`。 |
|
||||
| `build_ncm_sims.sh` | window/search/plpage/apicache/cookie_refresh 链入 `ncm_session.o`;新增 `session_gate_sim` 构建/运行。 |
|
||||
| `bench/session_gate_sim.c`(新) | 分类矩阵 + HTTP 状态矩阵 + 登录门/启动门矩阵 + 真实 `ncm_api`/`ncm_login` 状态/业务码映射 + 会话密钥稳定性/旧密文迁移(83 断言)。 |
|
||||
| `bench/http_truncate_sim.c` | 追加 4 个传输层归一用例(握手 fatal alert→NET、握手超时→TIMEOUT、读 fatal alert→NET、CA 失败→TLS)。 |
|
||||
|
||||
行为不变量(本次确立):**只有 `NCM_ERR_AUTH`(HTTP 401/403、业务码 301/302/profile 为空)会清
|
||||
`session.enc` 并回扫码;其余一切失败最多进入离线模式,绝不动账号态。**
|
||||
|
||||
### 41.4 验证(宿主,0 真实 NCM 请求)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `bench/session_gate_sim`(新,真实 core + `ncm_http_request` 桩) | **88 PASS / 0 FAIL**:A 分类矩阵 11 例(-5/-4/-6/-17/裸 mbedtls/…)、B HTTP 状态 14 例、C 登录门 10 + 启动门 6 例、D `ncm_api` 15 例(401/403、5xx/429、业务 301/302、-460、非 JSON、传输码)、D2 `from_cache` 出参 5 例(miss→0/命中→1 且 0 请求/失败→0/兼容入口/out_n 必填契约)、E `login_status` 8 + `login_refresh` 8 例、F 会话密钥 9 例(换 MAC 仍可解、旧密文可解并自动迁移) |
|
||||
| `bench/http_truncate_sim` | **13 PASS**(原 8 截断用例零回归 + 4 个传输层归一用例 + 1 个畸形分块(NCM_ERR_JSON 不被改判 NET)用例) |
|
||||
| `test/test_ncm_core_host`(全量核心库) | **139 PASS / 0 FAIL**(`TMPDIR` 指向可写目录;含 crypto/store/cache/tag/api/降级链全量) |
|
||||
| `bench/window_sim` / `bench/search_sim` | PASS(window 需可写 TMPDIR:本机 `/tmp` 不可写时盘缓存用例环境性失败,换 `$TMPDIR` 后 6000/6000 落盘通过) |
|
||||
| `bench/apicache_sim` / `bench/cookie_refresh_sim` | PASS(apicache 同样仅盘缓存文件路径受 `/tmp` 影响,换可写目录后通过) |
|
||||
| 宿主告警 | 核心 13 个 TU(含新 `ncm_session.c`)+ 应用 13 个 TU(login/home/playlist/player/search/cache/appstate/account/settings/queue/minibar/chrome/colors)`-Wall -Wextra` **0 warning** |
|
||||
| mipsel / 真机 | **未跑**(本机无 zig 交叉链,M8 适配层未并入;设备当前离线,真机验证后置) |
|
||||
|
||||
### 41.5 诚实出入 / 遗留
|
||||
|
||||
- **5xx 归类为「离线/可重试」**(`NCM_ERR_SVC`),UI 统一显示「离线模式」——网络其实可能是通的,
|
||||
只是服务端暂不可用;文案取「可播放已缓存」这一用户可执行动作,未再细分。
|
||||
- **HTTP 403 仍按 AUTH**(标准语义:服务端理解请求但拒绝)。若真机遇到 CDN/WAF 的 403,可能造成
|
||||
一次误登出;NCM 的风控通常以业务码 `-460` 表达(已改为 SVC)。如真机复现可再收窄为「仅 401」。
|
||||
- **离线时接口缓存命中不算「回在线」**:用 `ncm_api_user_playlist_ex` 的 `from_cache` 出参判定本次
|
||||
结果是否真走网络(评审 P2-2:不用全局计数差值);离线恢复探测(home 每 30s)强制
|
||||
`ncm_api_cache_refresh_path` 绕过缓存,确保成功即真在线。歌单页的缓存命中成功同样不判回在线
|
||||
(该路径只在失败时更新离线态,在线恢复由 home 探测统一裁决)。
|
||||
- **畸形分块/帧体归 `NCM_ERR_JSON`(数据错误)**,`http_ret_norm` 不再把它改判为网络不可达
|
||||
(评审 P2-1:否则会误报离线)。
|
||||
- **离线播放未缓存曲目**仍走原 `song_url` 重试(15s 超时 ×3),未改播放重试策略(不属本任务);
|
||||
已缓存曲目离线可播(`song_local_file` 先查缓存)。
|
||||
- **`app/app.c` 未能宿主编译**:其 SIGSEGV 定位处理器用 MIPS `ucontext.gregs[]`,aarch64 宿主
|
||||
无法编译(既有问题,`build_ncm_host.sh` 本身也排除 app.c)。本批对该文件的改动仅十余行
|
||||
(`ncm_gate_t` + 日志 + 三元切页),交叉构建时需复核 0 warning。
|
||||
- **会话密钥迁移**:旧 `session.enc`(MAC 派生)在当前 MAC 未变时可解并自动重写为新密钥;
|
||||
若升级时 MAC 已变化(例如换网卡后首次启动),旧会话仍会解不开 —— 这是旧方案的固有缺陷,
|
||||
无法事后补救;迁移后再不会因网络状态变化丢会话。
|
||||
- **未部署/未 push**:设备离线,真机联调后置;本节所有验证均在宿主完成。
|
||||
## 42. M10:断网重试风暴 / META 洪泛(真机卡死两分钟 + 歌单滑动卡)(2026-11-05)
|
||||
|
||||
> 用户原话:「我打开网易云 现在设备卡死了 你帮我抓日志调试一下」→「刚才卡了两分钟 现在恢复了 但是
|
||||
> 网易云的歌单列表滑动特别卡」。
|
||||
>
|
||||
> 现场:ncm CPU 48%、load 3.65,整机交互卡死两分钟(塔台 kill pid 7534 恢复)。
|
||||
> 红线:不依赖真机即可完成(宿主覆盖计时逻辑);0warning;不 push。
|
||||
|
||||
### 42.1 真机证据(只读抓取,未动设备)
|
||||
|
||||
**App 日志** `/usr/data/mnt/sd_0/MeowOS/logs/dev.player.ncm/19000100_000000_7534.log`(44 行,
|
||||
跨度 74s)。稳定循环三个一组:
|
||||
|
||||
```
|
||||
... home: playlists cache hit count=120 ts=1793794687
|
||||
... home: playlists rc=-4 count=120 uid=408996274
|
||||
... session: offline
|
||||
... home: offline retry ← 每轮一次
|
||||
... home: playlists cache hit count=120 ts=1793794687
|
||||
... home: playlists rc=-6 count=120 uid=408996274
|
||||
... home: offline retry ← 又一轮
|
||||
```
|
||||
|
||||
74s 内 **10 次** `home: offline retry`(≈7s 一次;旧代码本意是 30s 一次)。
|
||||
|
||||
**launcher 日志** `/usr/data/mnt/sd_0/MeowOS/logs/meow_20261105_122541.log`:单次启动
|
||||
**21016 条** `ipc: META app=dev.player.ncm state=0 pos=0/0 rc=0 -> ACK`,尾部连续帧时间戳
|
||||
`1203566 / 1203667 / 1203767 / 1203868 …`——**每 ~100ms 一帧**,且 `state=0 pos=0/0` 完全静止
|
||||
(没有任何元数据变化)。旧代码本意是 1s 一帧。
|
||||
|
||||
**关键新证据:`meow_ui_time_ms()` 在真机上是坏的。** 把上面两个日志里每行的首列(该函数返回值)
|
||||
按 64 位拆开看:
|
||||
|
||||
| 日志行 | 返回的 64 位值 | 低 32 位 | 高 32 位 |
|
||||
|---|---|---|---|
|
||||
| `home: offline retry` | 6607363222717682072 | 1135000 | 1538396632 |
|
||||
| `cache hit` | 8754846870717682072 | 1135000 | 2038396632 |
|
||||
| `rc=-6` | 18138485921460348192 | 1140000 | 4223195352 |
|
||||
| `offline retry` | 395341259877737760 | 1140000 | 92047560 |
|
||||
|
||||
- 低 32 位 = **开机秒数 × 1000**(1130000 → 1204000 共 74s,与 launcher 的 `up=1129` 吻合),
|
||||
**整秒粒度、毫秒位恒为 0**:`now_ms()` 里的 `tv_nsec/1e6` 项要么被读成 0 要么被读错;
|
||||
- 高 32 位**每次调用都变、无任何单调性**(同一秒内 1538396632 → 2038396632 → …)。
|
||||
|
||||
对照:同一进程里 core 的 `ncm_now_ms()` 落盘时间戳正确(`ts=1793794687` ≈ 2026-11-05),
|
||||
HTTP 超时按秒级生效(重试周期 5-7s 正是连接超时量级)——**app/core 侧(`clock_gettime` 走 app
|
||||
的 TU)的时钟是好的,只有 SDK 侧 `meow_ui_time_ms()` 返回坏值**。反汇编
|
||||
`patch_v2/sdk/meow_ui.a(meow_ui_gfx.o)` 的 `meow_ui_time_ms`(`meow_ui_gfx.c:175-182 now_ms`)
|
||||
可见它按 **64 位 `tv_sec`** 的 `struct timespec` 布局取数(`lw 0x10/0x14`),与真机 libc 的
|
||||
32 位 `time_t` 布局不一致:低字拿到正确的 `tv_sec`,高字拿到 `tv_nsec`(×1000 后表现为随机),
|
||||
`tv_nsec` 项则从缓冲外读。这与观测到的「低 32 位=整秒×1000、高 32 位随机」完全吻合。
|
||||
(`patch_v2/sdk/**` 不在本 mission 范围,已作为 finding 上报给塔台。)
|
||||
|
||||
### 42.2 根因:100ms/7s 循环与 META 洪泛是同一个根因
|
||||
|
||||
**① 离线重试风暴**(`app/ui/home.c` 改前 300-314 行,`app/app.c:172`):
|
||||
|
||||
```c
|
||||
uint64_t now = meow_ui_time_ms(); // app.c:172 —— 高 32 位是垃圾
|
||||
if (!g_home_thr_on && (!g_offline_retry_ms || now - g_offline_retry_ms >= 30000)) { ... }
|
||||
```
|
||||
|
||||
`now` 与 `g_offline_retry_ms` 各自带随机高 32 位,`now - last` 的 64 位差值几乎总是巨大正数
|
||||
(≈2^63),**守卫恒真** → 只要 `!g_home_thr_on`(worker 收工后的下一个 tick)就再发一次重试。
|
||||
真机上「每轮间隔 5-7s」不是定时器的节奏,而是**一次失败请求本身的耗时**(连接超时)——
|
||||
定时器实际上从未生效。第二条触发链是 `else { g_offline_retry_ms = 0; }`:离线→在线→离线
|
||||
只要在一个 tick 内抖动,基准就被清零,立刻再试。
|
||||
|
||||
**② META 洪泛**(`app/play/ipc_media.c` 改前 200-204 行,`ipc_thread` 主循环):
|
||||
|
||||
```c
|
||||
uint64_t now = meow_ui_time_ms();
|
||||
if (now - last_meta >= 1000) { send_meta(fd, ...); last_meta = now; }
|
||||
```
|
||||
|
||||
同一个坏时钟 → 条件恒真 → **每个 IPC 轮次(poll 200ms)都发一帧 META**;每次上报在 launcher 侧
|
||||
要写 2 行日志(`util.c:1674` + `meow_media.c:98`)并做会话归并,SD 写把 launcher 拖到 ~100ms/帧。
|
||||
而且 `state=0 pos=0/0` 说明这些帧**没有任何新信息**。
|
||||
|
||||
**③ 卡顿的直接来源(UI 线程)**:重试路径走的是 `home_reload()`(进页用的完整刷新):
|
||||
`appstate_playlists_cache_load()` 在 **UI 线程同步读盘**(`playlists.bin`,120 条歌单),随后
|
||||
`home_apply()` **把列表滚动位置复位为 0 并 `meow_ui_paint_full()` 整帧重绘**。风暴期间每次重试
|
||||
都把用户正在滑的列表拽回顶部 + 全屏重绘 + UI 线程写 SD 日志 → 「歌单列表滑动特别卡」。
|
||||
另外 `home_reload()` 每轮 `ncm_thread_create` 后从不 join/detach,线程栈也不回收。
|
||||
|
||||
结论:**一个坏时钟打穿了所有「`now - last >= N`」型守卫**(重试节奏 + META 节奏),UI 线程上的
|
||||
同步读盘/滚动复位/整帧重绘又把风暴放大成整机卡死。
|
||||
|
||||
### 42.3 实现
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/logic/retry_ctl.h/.c`(新) | 离线重试节奏闸(纯逻辑,零依赖):①时钟由调用方给**可信单调钟**;②回退(`now < last`)不下溢成「巨大已超时」,只重定基准、不重试;③`busy` 期间单飞;④在线/离线抖动**不重置**基准(抖动不得加速)。首次观察到离线立即探测一次,之后严格 ≥ interval。 |
|
||||
| `app/ui/home.c` | `home_tick`:节奏改用 `ncm_retry_ctl_poll(&g_retry, offline, g_home_thr_on, ncm_monotonic_ms(), 30000)`;入参 `now` 不再参与判定(真机坏时钟无效化)。新增 `home_reload_ex(silent)`:静默重试**不预读盘、不复位滚动、不重绘**;`home_apply_ex(keep_scroll)`:后台刷新保留滚动位置;worker 记 `g_home_changed`(离线态翻转/歌单数量变化/空态才需要重绘);worker 线程 `pthread_detach`(旧代码每轮泄漏栈)。新增 `home_retry_reset()`(页创建/登出/宿主测试)。 |
|
||||
| `app/play/meta_gate.h/.c`(新) | META 上报闸(纯逻辑):状态/时长/标题/歌手/专辑/封面/歌词标志**变化立即报**;播放中位置偏移 ≥5s 重锚(launcher `meow_media_display_pos` 自己外推);心跳播放中 5s / 非播放 30s;**硬下限 250ms**(任何路径 ≤4 帧/秒);时间同样用单调钟,回退不下溢。字符串比较按发送侧截断长度进行(防超长标题恒判「变了」)。 |
|
||||
| `app/play/ipc_media.c` | `send_meta()` 改为「组快照 → meta_gate 判定 → 才发」,删除 1s 定时器;主循环时间改 `ncm_monotonic_ms()`;首帧仍必发(注册媒体源);发送时打 DEBUG 日志(默认 INFO 级不落盘)。 |
|
||||
| `app/ncm_app_internal.h` | 新增 `home_retry_reset()` 声明。 |
|
||||
| `bench/retry_storm_sim.c`(新) | 节奏闸 + META 闸的纯逻辑回归(真实 `retry_ctl.c`/`meta_gate.c`):100ms tick 断网 120s、状态抖动、时钟回退、单飞;META 空闲/播放/抖动/元数据变化/seek/超长标题/回退。 |
|
||||
| `bench/home_retry_sim.c`(新) | **真实 `app/ui/home.c`** 编进宿主(`-ffunction-sections --gc-sections` 只保留 `home_tick` 链),用桩替换网络/状态/UI,驱动 `home_tick` 断言重试次数/间隔/重绘次数;其中一个用例把**真机形态的垃圾 64 位 now** 喂进 `home_tick`。 |
|
||||
| `bench/meta_ipc_sim.c`(新) | **真实 `app/play/ipc_media.c`** 起线程连到临时目录里的「假 launcher」AF_UNIX socket,统计 App 真发出的 `MSG_META` 帧数。 |
|
||||
| `build_ncm.sh` / `build_ncm_host.sh` | 源列表加 `app/logic/retry_ctl.c`、`app/play/meta_gate.c`(host 构建同时补上漏掉的 `app/play/mem_watch.c`,否则 `player_manager` 链接失败)。 |
|
||||
| `build_ncm_sims.sh` | 新增三个 sim 的构建/运行(产物落 `out/chk/`,不入库)。 |
|
||||
|
||||
### 42.4 验证(宿主,0 真机、0 真实 NCM 请求)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `bench/retry_storm_sim`(新) | **25 PASS / 0 FAIL**:A 断网 120s→4 次(0/30/60/90s,间隔全 ≥30000ms);B 每 tick 抖动→2 次(旧实现随抖动加速);C 时钟回退 30s→回退瞬间不重试、真实间隔仍 ≥30s、闸不失锁;D worker 跑 45s>间隔→不并发;E1 空闲 META 60s→3 帧(旧 600);E2 播放中→13 帧(≤1/5s);E3 状态每 tick 抖动→≤4 帧/秒且最小间隔 ≥250ms;E4 元数据/seek 立即报、超长标题截断后不重报;E5 时钟回退不连发。 |
|
||||
| `bench/home_retry_sim`(新,真实 home.c) | **13 PASS / 0 FAIL**:断网 120s→4 次重试、每次只起 1 个 worker、**全帧重绘 0 次**(旧实现每轮 1 次);**入参 now 用真机垃圾 64 位值→仍是 4 次/≥30s**;抖动→2 次;回在线再断网→间隔仍 30s(在线不得清零基准)。 |
|
||||
| `bench/meta_ipc_sim`(新,真实 ipc_media.c + 假 launcher) | **6 PASS / 0 FAIL**:空闲 2s 只收到 **1 帧** META(旧实现 ~10 帧);播放态翻转→恰好多 1 帧,停止→再多 1 帧,其余时间 0 帧;帧头 magic/version/长度合法。 |
|
||||
| `bench/session_gate_sim`(M9) | **88 PASS / 0 FAIL**(`TMPDIR` 可写;http 桩 34 次) |
|
||||
| `bench/http_truncate_sim`(M9) | **13 PASS / 0 FAIL** |
|
||||
| `test/test_ncm_core_host`(全量核心库) | **139 PASS / 0 FAIL** |
|
||||
| 宿主告警 | 改动/新增的 6 个 TU(home.c、ipc_media.c、retry_ctl.c、meta_gate.c + 2 个 sim)`-Wall -Wextra` **0 warning**;全量宿主 app 链接(`ncm_host`,`NCM_PAGE=home` 出图)通过 |
|
||||
| mipsel / 真机 | **未跑**(本机无 zig 交叉链;交叉编译 + 热更由塔台做) |
|
||||
|
||||
### 42.5 诚实出入 / 遗留
|
||||
|
||||
- **根因不止在 app**:`meow_ui_time_ms()` 在真机返回坏值是 SDK/构建侧问题(`patch_v2/sdk/meow_ui_gfx.c`
|
||||
的 `now_ms` 与真机 libc 的 `struct timespec` 宽度不一致)。本 mission 范围内只能**在 app 侧绕开**:
|
||||
重试与 META 的节奏全部改用 core 的 `ncm_monotonic_ms()`(同进程实测正确)。**SDK 侧仍需修**,
|
||||
否则所有依赖「经过时间」的 SDK/App 逻辑(动画、IME 冷却、snack 计时、其他 App 的定时器)在真机
|
||||
上都会表现异常——已作为 finding 上报塔台。
|
||||
- **低 32 位只有整秒精度**:即使将来某些代码继续用 `meow_ui_time_ms()`,也拿不到毫秒;本批新代码
|
||||
已全部改用 `ncm_monotonic_ms()`。
|
||||
- **META 硬下限 250ms**:极端情况下(播放状态每 100ms 抖动一次,例如缓冲/播放反复切换)仍可能
|
||||
4 帧/秒;这是刻意的安全上限,正常状态稳定时不会触发。
|
||||
- **首次进离线立即探测一次**:进离线先探一次(用户体验:恢复即回在线),之后严格 30s;若首次
|
||||
进离线的同时页刚加载过,会多一次请求。**不合并**是因为「网络刚恢复」是最常见的时刻。
|
||||
- **静默重试不重绘**:歌单数量/离线态都没变时界面完全不重绘(滑动不受打扰);若服务端返回了
|
||||
不同的歌单数量,会在下一次 tick 保留滚动位置地重绘一次。
|
||||
- **真机验证建议**(塔台做热更时):断网启动 → 看 App 日志 `home: offline retry` 应约每 30s 一条
|
||||
(旧:5-7s);launcher 日志 `ipc: META app=dev.player.ncm` 空闲时应约每 30s 一条(旧:~10/s);
|
||||
播放中约每 5s 一条、暂停/切歌各多一条;同时观察 ncm CPU 与首屏/列表滑动是否恢复。
|
||||
- **未部署/未 push**:设备只读访问,未改设备;本节所有验证均在宿主完成。
|
||||
|
||||
## 43. M12:列表首行主标题不显示(两行列表行高 < 文本块,SDK 视口顶裁剪把首行标题整条跳过)(2026-11-05)
|
||||
|
||||
### 43.1 现象(用户原话)
|
||||
|
||||
> 「网易云有一个问题 歌单列表的第1个歌单名字不显示 副标题(歌曲数目)有显示 。点开歌单 加载歌曲列表
|
||||
> 第1个歌曲名字不显示 副标题(歌手名)有显示。」
|
||||
|
||||
特征:两类列表(首页歌单列表 / 歌单详情曲目列表)都只有**首行主标题完全空白**、副标题正常,
|
||||
其余行标题/副标题都正常。设备二进制 431ca4dd(含 M5/M6/M10/M11)。
|
||||
|
||||
### 43.2 根因(代码路径 + 触发机制,已坐实)
|
||||
|
||||
SDK `meow_listview` 的双行渲染(`patch_v2/sdk/meow_ui_widget.c:2061`,src2 分支):
|
||||
|
||||
```c
|
||||
ty = ry + (item - (2 * thh + MEOW_LISTV_SUB_GAP)) / 2;
|
||||
if (it.title && ty >= vy0 && ty + thh <= vy1)
|
||||
listv_text_trunc(rtx, ty, it.title, ...); /* 标题:视口顶裁剪守卫 */
|
||||
int sy = ty + thh + MEOW_LISTV_SUB_GAP;
|
||||
if (sy >= vy0 && sy + thh <= vy1)
|
||||
listv_text_trunc(rtx, sy, it.subtitle, ...); /* 副标题 */
|
||||
```
|
||||
|
||||
- `thh = meow_ui_text_height(theme->font_body)`。真机 ncm 进程(pid 6693)环境:
|
||||
`MEOW_MULTI_FONT=1`、主库 `MEOW_FONT_APP=source_han_serif`;从设备
|
||||
`/usr/data/mnt/sd_0/MeowOS/fonts/source_han_serif/regular/font.idx` 的 MFNI 面头直接读出
|
||||
20px 面 `ascent=24 / descent=6` → `meow_font_line_height(20) = 30`
|
||||
(多字库取最大:google_sans 类库 20px 面 = 26,不改变结果)→ **文本块 = 2*30 + 2 = 62**。
|
||||
- ncm 行高 `APP_ROW_H = 56`(与 SDK `item_h` 默认 56 相同)→ `ty = ry + (56-62)/2 = ry - 3`。
|
||||
- 首行(scroll=0)`ry == vy0`(行顶 == 控件视口顶)→ `ty = vy0 - 3 < vy0` → **标题被整条跳过**
|
||||
(不是画了被裁,是守卫直接不画);`sy = vy0 + 29` 在视口内 → 副标题照画。
|
||||
- 其余行 `ty = ry - 3 > vy0` → 标题仍画(比本行顶高 3px,文字整体仍在视口内,肉眼难察觉)。
|
||||
- 两类列表同用 `meow_listview_create(..., src2, ...)` + 双行 + `APP_ROW_H`,故症状完全一致。
|
||||
|
||||
排除的其它方向:
|
||||
|
||||
| 怀疑方向 | 结论 |
|
||||
|---|---|
|
||||
| ① title 指针悬垂/空串(绑定生命周期) | **排除**:`home_src2`/`pl_src2` 每帧刷新静态缓冲,副标题取自同一缓冲且可见 |
|
||||
| ② 首行被顶部栏/裁剪区遮住(布局偏移) | **排除**:组头红条在 `r_list` 上方 8px;行卡片 z=5、文本 z=10、叠层 z=60 均不覆盖首行标题位 |
|
||||
| ③ 首行字体色 = 背景色(选中/主题色) | **排除**:同一次绘制里 title=`onSurface`、subtitle=`onSurfaceVariant`;且用户确认「完全空白」非同色 |
|
||||
| ④ 首项特殊路径(我喜欢的音乐/当前播放) | **排除**:歌单详情首行是任意曲目,无特殊渲染分支 |
|
||||
|
||||
### 43.3 修复(app 侧,不动 SDK / 不动 meow_ui.a)
|
||||
|
||||
行高改为**按当前主题字体实测行高派生**,保证文本块装得下(卡高 == 文本块):
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/ncm_app_internal.h` | 新增 `NCM_ROW_SUB_GAP`(与 SDK 同口径 2)、`ncm_row_h_for_line_h(line_h)`(= `max(APP_ROW_H, 2*行高 + 2 + NCM_ROW_GAP)`,偶数)、`ncm_row_h()`(取 `meow_ui_text_height(meow_theme_get()->font_body)`)。`APP_ROW_H` 降级为**行高下限**(注释同步)。 |
|
||||
| `app/ui/home.c` / `playlist.c` / `queue.c` / `cache.c` / `search.c` | 各页 `g_item_h = ncm_row_h()` 在 page create 取一次(字体档位切换走重建页面,避免同一页内行高漂移),`meow_listview_set_item_h` 与全部卡片几何/可见行推算/页脚「x-y / n 首」/补载视口换算统一用 `g_item_h`。 |
|
||||
| `app/test/test_meow_row_text_fit.c`(新) | 纯头内联几何回归(不链 SDK):真机已装字库 20px 面实测行高矩阵 14/21/26/27/30 → 断言文本块 ≤ 行高、首行 ty ≥ 0、副标题不越行、卡高 ≥ 文本块、行高偶数且 ≥ 下限;并用旧 56 + 行高 30 断言 `ty<0`(bug 形态哨兵,防再写死)。 |
|
||||
| `app/test/test_first_row_title_render.c`(新) | **渲染级回归**:宿主 5x7 回退行高是 14,照原样渲染复现不了。用 `-Wl,--wrap=meow_ui_text_height` 把行高替换成真机实测 30,驱动**真实 SDK listview 双行渲染**,读回 `MEOW_UI_DUMP` 的 PPM 数像素:旧行高 56 → 首行标题带 0 文字像素、副标题带 186;新行高 70 → 标题带 84、副标题带 100。 |
|
||||
| `bench/plpage_sim.c` | 滚动换行高改用 `ncm_row_h()`;临时目录/出图改走 `$TMPDIR`(Termux 下 `/tmp` 不可写,原写死 `/tmp` 无法运行)。 |
|
||||
| `build_ncm_host.sh` | **补 `meow_ui_time`**:M11 新增 `sdk/meow_ui_time.c` 后本脚本未同步,main 上宿主链接报 `undefined symbol: meow_ui_time_ms`(与 M12 无关的既有断裂,不补则本 mission 无法出图验证)。 |
|
||||
|
||||
行高取值(本机默认字体 source_han_serif,行高 30):`2*30 + 2 + 8 = 70` → 卡高 62 == 文本块 62,
|
||||
`ty = +4`(首行标题正常落卡内)。其它字库:行高 27 → 64;26 → 62;21 → 52 但受下限钳到 56(与旧视觉一致)。
|
||||
|
||||
### 43.4 验证(宿主,0 真机写、0 真实 NCM 请求)
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `app/test/test_meow_row_text_fit`(新) | **PASS:48 checks / 0 fail**(行高矩阵 5 档;含「旧 56 + 行高 30 → ty=-3」哨兵) |
|
||||
| `app/test/test_first_row_title_render`(新,真实 SDK 渲染 + PPM 像素计数) | **PASS**:legacy `item=56 ty=-3 → title_ink=0 sub_ink=186`(复现用户症状);fixed `item=70 ty=4 → title_ink=84 sub_ink=100`(首行标题正常) |
|
||||
| `app/test/test_meow_row_gap`(既有卡片几何) | **PASS**(row_gap=8 card_h=48 top2top=56 symmetric=1) |
|
||||
| `bench/plpage_sim`(真实 playlist.c 全链路:懒加载分页/触底/回滚补物化) | **RESULT: PASS**(全用例 PASS) |
|
||||
| `ncm_host` 出图(home/playlist/cache/search/searchpl/plsplay/queuetest) | 全部 rc=0;与**改动前基线逐字节相同**(宿主行高 14 → `ncm_row_h()` 仍为 56,几何零变化 → 证明重构本身无视觉回归) |
|
||||
| `ncm_host` 页面自检(queuetest/modetest/barhit/searchtap/cachetest) | 全 PASS(covswitch 因测试自身写死 `/tmp/*.bmp`、Termux 下不可写而 `init FAIL`——与 M12 无关的既有环境限制) |
|
||||
| 宿主告警 | 改动/新增 TU(5 个 ui + ncm_app_internal.h + 2 个 test + 1 个 sim)`-Wall -Wextra` **0 warning**;`build_ncm_host.sh` 全量链接通过 |
|
||||
| 真机只读取证 | 读 `/proc/6693/environ`(字体档/多字体开关)+ 设备 `font.idx` 面头(ascent/descent);**未改设备** |
|
||||
| mipsel / 真机 | **未跑**(本机无 zig 交叉链;交叉编译 + 热更由塔台做) |
|
||||
|
||||
### 43.5 诚实出入 / 遗留
|
||||
|
||||
- **修复形态是「行高变高」**:本机默认字库下行高 56 → 70(每屏可见行数略减)。这是必然代价——旧行高
|
||||
本来就装不下 62px 的两行文本(文字一直溢出卡外 3~7px),只是首行标题被守卫整条吞掉才暴露。
|
||||
若用户切到行高 21 的字库(source_han_sans 等),行高回到 56,视觉与旧版一致。
|
||||
- **滚动时顶部半露行仍会隐藏标题**:SDK 的语义是「标题不完全在视口内就不画」(`ty >= vy0`),
|
||||
这是滚动裁剪的正常表现,不属于本 bug;scroll=0 的首行标题现已正常。
|
||||
- **根因在 SDK 通用渲染**:2 行档默认 `item_h=56` 假定字体行高 ≤27;任何 App 的两行列表在 20px
|
||||
字库(行高 30)下都会丢首行标题。本 mission scope 只到 `patch_v2/ncm_app/**`,故 app 侧派生行高
|
||||
规避;**SDK 侧仍应修**(2 行分支把 `ty` 钳到 `vy0` 或按文本块反推行高),已作为 finding 上报塔台。
|
||||
- **`build_ncm_host.sh` 的 M11 漏编**:本脚本补了 `meow_ui_time`(否则宿主链接失败)。同类还写死
|
||||
`/tmp` 的两处(`bench/plpage_sim.c` 已改 `$TMPDIR`;`app/test/test_ncm_app.c` covswitch 仍写死,
|
||||
未改——与本 bug 无关)。
|
||||
- **未部署/未 push**:设备只读访问,未改设备;所有验证在宿主完成。
|
||||
|
||||
### 43.6 真机验证要点(塔台热更后按此验收)
|
||||
|
||||
1. 首页「我的音乐」歌单列表:**第 1 个歌单名字可见**,副标题「N 首」仍在;第 2 行起视觉正常。
|
||||
2. 点开任一歌单(含「我喜欢的音乐」):**第 1 首歌名可见**,副标题「歌手 / 专辑」仍在。
|
||||
3. 同类页面顺带确认:播放队列(ACT_QUEUE)、已缓存列表(ACT_CACHE)、搜索结果单曲页签,首行标题都在。
|
||||
4. 预期可见变化:每行变高(本机字库下 56 → 70px)、每屏可见行数略减;卡片内两行文字完整、不再溢出卡片。
|
||||
5. 滚动列表:顶部半露行标题不可见属正常裁剪;回到顶部(scroll=0)后首行标题必须重新出现。
|
||||
6. 若系统字体切到 source_han_sans 等小行高字库:行高回落 56,视觉与旧版一致且首行标题仍正常。
|
||||
@@ -24,7 +24,7 @@ volatile int g_net_ready = 0;
|
||||
int g_host_mock = 0;
|
||||
|
||||
static meow_view *g_login_view, *g_home_view, *g_pl_view, *g_plr_view, *g_set_view,
|
||||
*g_search_view, *g_queue_view;
|
||||
*g_search_view, *g_queue_view, *g_cache_view;
|
||||
|
||||
static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); }
|
||||
|
||||
@@ -93,6 +93,7 @@ static void app_on_tap(int x, int y)
|
||||
if (settings_is_active()) { settings_on_tap(x, y); return; }
|
||||
if (playlist_is_active()) { playlist_on_tap(x, y); return; }
|
||||
if (queue_is_active()) { queue_on_tap(x, y); return; }
|
||||
if (cache_is_active()) { cache_on_tap(x, y); return; }
|
||||
if (search_is_active()) { search_on_tap(x, y); return; }
|
||||
home_on_tap(x, y);
|
||||
}
|
||||
@@ -107,6 +108,7 @@ static void app_on_swipe(int dir, int dx, int dy)
|
||||
if (settings_is_active()) { meow_view_page_pop(); return; }
|
||||
if (playlist_is_active()) { meow_view_page_pop(); return; }
|
||||
if (queue_is_active()) { meow_view_page_pop(); return; }
|
||||
if (cache_is_active()) { meow_view_page_pop(); return; }
|
||||
if (search_is_active()) { meow_view_page_pop(); return; }
|
||||
if (home_is_active()) {
|
||||
/* 任务2:home 根页左滑不再退出进程 → 请求 launcher 退到桌面/后台(进程+媒体会话保留)。
|
||||
@@ -124,6 +126,7 @@ static void app_on_swipe(int dir, int dx, int dy)
|
||||
if (home_is_active()) { home_swipe(dir, dy); return; }
|
||||
if (playlist_is_active()) { playlist_swipe(dir, dy); return; }
|
||||
if (queue_is_active()) { queue_swipe(dir, dy); return; }
|
||||
if (cache_is_active()) { cache_swipe(dir, dy); return; }
|
||||
if (search_is_active()) { search_swipe(dir, dy); return; }
|
||||
if (player_is_active()) { player_on_swipe(dir, dy); return; }
|
||||
}
|
||||
@@ -134,6 +137,7 @@ static void app_on_touch_down(int x, int y)
|
||||
else if (home_is_active()) home_touch_down(x, y);
|
||||
else if (playlist_is_active()) playlist_touch_down(x, y);
|
||||
else if (queue_is_active()) queue_touch_down(x, y);
|
||||
else if (cache_is_active()) cache_touch_down(x, y);
|
||||
else if (settings_is_active()) settings_touch_down(x, y);
|
||||
else if (search_is_active()) search_touch_down(x, y);
|
||||
}
|
||||
@@ -144,6 +148,7 @@ static void app_on_touch_move(int x, int y)
|
||||
else if (home_is_active()) home_touch_move(x, y);
|
||||
else if (playlist_is_active()) playlist_touch_move(x, y);
|
||||
else if (queue_is_active()) queue_touch_move(x, y);
|
||||
else if (cache_is_active()) cache_touch_move(x, y);
|
||||
else if (settings_is_active()) settings_touch_move(x, y);
|
||||
else if (search_is_active()) search_touch_move(x, y);
|
||||
}
|
||||
@@ -194,6 +199,7 @@ static void app_on_tick(void)
|
||||
if (player_is_active()) { player_tick(now); return; }
|
||||
if (search_is_active()) { search_tick(now); return; }
|
||||
if (queue_is_active()) { queue_tick(now); return; }
|
||||
if (cache_is_active()) { cache_tick(now); return; }
|
||||
if (home_is_active()) { home_tick(now); }
|
||||
if (playlist_is_active()) { playlist_tick(now); }
|
||||
chrome_tick();
|
||||
@@ -233,10 +239,18 @@ int main(void)
|
||||
g_set_view = settings_view_create();
|
||||
g_search_view = search_view_create();
|
||||
g_queue_view = queue_view_create(); /* §NEW 当前播放队列页 */
|
||||
g_cache_view = cache_view_create(); /* M5 已缓存列表页 */
|
||||
mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */
|
||||
|
||||
/* 进入根页:已登录→home,否则→login */
|
||||
meow_view_page_push(g_st.logged_in ? ACT_HOME : ACT_LOGIN);
|
||||
/* 进入根页(M9 启动决策):本地有会话就先信它——无网也进 home(缓存可看可播),
|
||||
* 只有「无本地会话」才进扫码页。网络类失败在首个请求后由 appstate_session_gate
|
||||
* 切离线模式,不再把「连不上」当登录过期。 */
|
||||
{
|
||||
ncm_gate_t boot = ncm_session_boot_decide(g_st.logged_in, NCM_OK);
|
||||
meow_log(MEOW_LOG_INFO, "boot: session gate=%s (cookie=%d uid=%s)",
|
||||
ncm_gate_name(boot), g_st.logged_in, g_st.uid[0] ? g_st.uid : "-");
|
||||
meow_view_page_push(boot == NCM_GATE_RELOGIN ? ACT_LOGIN : ACT_HOME);
|
||||
}
|
||||
|
||||
meow_ui_events ev = {
|
||||
app_on_key, app_on_tap, app_on_swipe, app_on_tick, app_on_swipe_progress,
|
||||
@@ -256,6 +270,7 @@ int main(void)
|
||||
meow_view_free(g_login_view);
|
||||
meow_view_free(g_search_view);
|
||||
meow_view_free(g_queue_view);
|
||||
meow_view_free(g_cache_view);
|
||||
meow_ui_shutdown();
|
||||
meow_log(MEOW_LOG_INFO, "exit");
|
||||
return 0;
|
||||
|
||||
@@ -98,6 +98,7 @@ int appstate_init(void)
|
||||
|
||||
appstate_load_settings();
|
||||
g_st.logged_in = (g_st.ctx.cookie[0] != 0);
|
||||
g_st.offline = 0; /* M9:启动先按在线渲染;首个网络动作失败再进离线(本地会话不被否决) */
|
||||
g_st.active_playlist = -1;
|
||||
g_st.playing_song = -1;
|
||||
g_st.qr_size = 0;
|
||||
@@ -169,6 +170,33 @@ void appstate_set_logged_in(const char *uid, const char *nickname)
|
||||
g_st.logged_in = 1;
|
||||
}
|
||||
|
||||
/* ---- 会话保持 / 离线模式(M9)----
|
||||
* 判定本身在 core(ncm_gate_decide,宿主可单测);这里只把结果落到 App 状态并打日志。
|
||||
* 关键不变量:**只有 NCM_ERR_AUTH 会走退出登录**;传输层/服务端暂不可用只切离线,
|
||||
* 绝不清 cookie(清 cookie 才是「退到扫码登录」的实质)。 */
|
||||
void appstate_offline_set(int on)
|
||||
{
|
||||
int v = on ? 1 : 0;
|
||||
if (g_st.offline == v) return;
|
||||
g_st.offline = v;
|
||||
meow_log(v ? MEOW_LOG_WARN : MEOW_LOG_INFO, "session: %s", v ? "offline" : "online");
|
||||
}
|
||||
|
||||
int appstate_is_offline(void) { return g_st.offline; }
|
||||
|
||||
int appstate_session_gate(int rc)
|
||||
{
|
||||
ncm_gate_t g = ncm_gate_decide(g_st.logged_in, rc);
|
||||
switch (g) {
|
||||
case NCM_GATE_ONLINE: appstate_offline_set(0); break;
|
||||
case NCM_GATE_OFFLINE: appstate_offline_set(1); break;
|
||||
/* RELOGIN:登录态处置由调用方(app.c tick / settings 登出)执行;
|
||||
* HOLD:解析/参数类错误,保持现状。 */
|
||||
default: break;
|
||||
}
|
||||
return (int)g;
|
||||
}
|
||||
|
||||
void appstate_logout(void)
|
||||
{
|
||||
ncm_ctx_clear_cookie(&g_st.ctx);
|
||||
@@ -176,6 +204,7 @@ void appstate_logout(void)
|
||||
* 被下一账号复用;盘缓存目录一并清)。 */
|
||||
ncm_apicache_clear();
|
||||
g_st.logged_in = 0;
|
||||
g_st.offline = 0;
|
||||
g_st.uid[0] = 0;
|
||||
g_st.nickname[0] = 0;
|
||||
g_st.vip = NCM_VIP_NONE; /* §20:登出清会员态(下一账号不继承) */
|
||||
@@ -199,7 +228,9 @@ void appstate_logout(void)
|
||||
cover_cache_reset();
|
||||
}
|
||||
|
||||
/* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。 */
|
||||
/* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。
|
||||
* M9:只有服务端**明确**否定会话(NCM_ERR_AUTH:HTTP 401/403、业务码 301/302)才清 cookie;
|
||||
* 网络不可达/超时/服务端 5xx 一律保留 cookie 与登录态,只切离线模式。 */
|
||||
int appstate_ensure_uid(void)
|
||||
{
|
||||
if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_ERR_AUTH;
|
||||
@@ -209,12 +240,16 @@ int appstate_ensure_uid(void)
|
||||
nick, sizeof nick);
|
||||
if (rc == NCM_OK) {
|
||||
appstate_set_logged_in(uid, nick);
|
||||
appstate_offline_set(0);
|
||||
return NCM_OK;
|
||||
}
|
||||
/* 会话失效:清 cookie → 未登录 */
|
||||
if (rc == NCM_ERR_AUTH) {
|
||||
/* 会话确失效:清 cookie → 未登录(原逻辑) */
|
||||
ncm_ctx_clear_cookie(&g_st.ctx);
|
||||
g_st.logged_in = 0;
|
||||
g_st.offline = 0;
|
||||
} else if (ncm_err_is_network(rc)) {
|
||||
appstate_offline_set(1); /* 只是连不上:保留账号态,离线可看缓存 */
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
@@ -224,8 +259,11 @@ int appstate_ensure_uid(void)
|
||||
* 之间的发布点。持久化(playlists.bin)同单写者(home worker)在发布后读,无相互竞态。 */
|
||||
static pthread_mutex_t g_pls_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
int appstate_load_playlists(void)
|
||||
/* from_cache(可 NULL):透传 ncm_api_user_playlist_ex 的「本次是否接口缓存命中」。
|
||||
* 仅真正走网络成功时才应把 App 判为在线(M9 评审 P2-2:缓存命中不代表网络恢复)。 */
|
||||
int appstate_load_playlists_ex(int *from_cache)
|
||||
{
|
||||
if (from_cache) *from_cache = 0;
|
||||
if (!g_st.logged_in) return NCM_ERR_AUTH;
|
||||
int rc = appstate_ensure_uid();
|
||||
if (rc != NCM_OK) return rc;
|
||||
@@ -238,8 +276,9 @@ int appstate_load_playlists(void)
|
||||
* 单飞),免 96KB 栈。 */
|
||||
static ncm_playlist_t s_pls[NCM_PLAYLIST_MAX];
|
||||
int n = 0;
|
||||
int rc2 = ncm_api_user_playlist(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid,
|
||||
NCM_PLAYLIST_MAX, 0, s_pls, NCM_PLAYLIST_MAX, &n);
|
||||
int rc2 = ncm_api_user_playlist_ex(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid,
|
||||
NCM_PLAYLIST_MAX, 0, s_pls, NCM_PLAYLIST_MAX, &n,
|
||||
from_cache);
|
||||
if (rc2 == NCM_OK) {
|
||||
int cnt = (n > NCM_PLAYLIST_MAX) ? NCM_PLAYLIST_MAX : n;
|
||||
pthread_mutex_lock(&g_pls_mu);
|
||||
@@ -248,11 +287,14 @@ int appstate_load_playlists(void)
|
||||
pthread_mutex_unlock(&g_pls_mu);
|
||||
appstate_playlists_cache_save(); /* §26:网络成功 → 覆写盘缓存(stale-while-revalidate) */
|
||||
}
|
||||
meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s", rc2,
|
||||
(rc2 == NCM_OK) ? n : g_st.playlist_count, g_st.uid);
|
||||
meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s%s", rc2,
|
||||
(rc2 == NCM_OK) ? n : g_st.playlist_count, g_st.uid,
|
||||
(from_cache && *from_cache) ? " (cache)" : "");
|
||||
return rc2;
|
||||
}
|
||||
|
||||
int appstate_load_playlists(void) { return appstate_load_playlists_ex(NULL); }
|
||||
|
||||
/* ---- 歌单列表磁盘缓存(§26,NCM_CACHE_DESIGN §2.2)----
|
||||
* 快照 <data_dir>/playlists.bin:magic(4) + ver(2) + 时间戳(8) + count(4) + 数组(ncm_playlist_t)。
|
||||
* 进首页先渲染缓存秒开(断网可离线浏览),后台 worker 拉网络成功后覆写;结构体改版靠 ver 判 miss。
|
||||
@@ -783,6 +825,7 @@ void appstate_apply_play_mode(int m)
|
||||
int appstate_queue_count(void)
|
||||
{
|
||||
int c;
|
||||
if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_count();
|
||||
pthread_mutex_lock(&g_search_mu);
|
||||
c = (g_st.queue_src == NCM_QUEUE_SEARCH) ? g_st.search_song_count : g_st.track_ids_count;
|
||||
pthread_mutex_unlock(&g_search_mu);
|
||||
@@ -792,6 +835,7 @@ int appstate_queue_count(void)
|
||||
int appstate_queue_song(int idx, ncm_song_t *out)
|
||||
{
|
||||
if (!out) return NCM_ERR_ARG;
|
||||
if (g_st.queue_src == NCM_QUEUE_CACHE) return appstate_cache_song(idx, out);
|
||||
if (g_st.queue_src == NCM_QUEUE_SEARCH) {
|
||||
pthread_mutex_lock(&g_search_mu);
|
||||
int ok = (idx >= 0 && idx < g_st.search_song_count);
|
||||
@@ -803,6 +847,105 @@ int appstate_queue_song(int idx, ncm_song_t *out)
|
||||
return appstate_song_fetch(idx, out);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 已缓存列表(ACT_CACHE / NCM_QUEUE_CACHE;数据源 = songs/ 池 + 文件内 tag)
|
||||
* 数组读写经 g_cache_mu;扫描走 worker(磁盘 IO 不进 UI 线程)。
|
||||
* ============================================================ */
|
||||
static pthread_mutex_t g_cache_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
int appstate_cache_list_load(void)
|
||||
{
|
||||
/* 堆上临时数组(≈116KB):扫描期间不持锁,UI/播放线程读旧快照不被 IO 阻塞。 */
|
||||
ncm_cache_item_t *tmp = (ncm_cache_item_t *)malloc(sizeof(ncm_cache_item_t) * NCM_CACHE_LIST_MAX);
|
||||
if (!tmp) return NCM_ERR_OOM;
|
||||
int n = ncm_cache_scan(&g_st.ctx.cache, tmp, NCM_CACHE_LIST_MAX);
|
||||
if (n < 0) { free(tmp); return n; }
|
||||
/* 旧无 tag 缓存:回退旁路元数据(songmeta)补名称/歌手,保持列表可读。 */
|
||||
for (int i = 0; i < n; i++) {
|
||||
ncm_song_t s;
|
||||
if (tmp[i].name[0] && tmp[i].artist[0]) continue;
|
||||
if (songmeta_load(g_st.cache_dir, tmp[i].id, &s) != NCM_OK) continue;
|
||||
if (!tmp[i].name[0]) ncm_strlcpy(tmp[i].name, s.name, sizeof tmp[i].name);
|
||||
if (!tmp[i].artist[0]) ncm_strlcpy(tmp[i].artist, s.art_name, sizeof tmp[i].artist);
|
||||
}
|
||||
pthread_mutex_lock(&g_cache_mu);
|
||||
memcpy(g_st.cache_items, tmp, sizeof(ncm_cache_item_t) * (size_t)n);
|
||||
g_st.cache_item_count = n;
|
||||
pthread_mutex_unlock(&g_cache_mu);
|
||||
free(tmp);
|
||||
return n;
|
||||
}
|
||||
|
||||
int appstate_cache_count(void)
|
||||
{
|
||||
int n;
|
||||
pthread_mutex_lock(&g_cache_mu);
|
||||
n = g_st.cache_item_count;
|
||||
pthread_mutex_unlock(&g_cache_mu);
|
||||
return n < 0 ? 0 : n;
|
||||
}
|
||||
|
||||
int appstate_cache_item(int i, ncm_cache_item_t *out)
|
||||
{
|
||||
if (!out) return NCM_ERR_ARG;
|
||||
pthread_mutex_lock(&g_cache_mu);
|
||||
int ok = (i >= 0 && i < g_st.cache_item_count);
|
||||
if (ok) *out = g_st.cache_items[i];
|
||||
pthread_mutex_unlock(&g_cache_mu);
|
||||
return ok ? NCM_OK : NCM_ERR_ARG;
|
||||
}
|
||||
|
||||
int appstate_cache_song(int i, ncm_song_t *out)
|
||||
{
|
||||
ncm_cache_item_t it;
|
||||
ncm_song_t m;
|
||||
if (!out) return NCM_ERR_ARG;
|
||||
if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG;
|
||||
memset(out, 0, sizeof *out);
|
||||
ncm_strlcpy(out->id, it.id, sizeof out->id);
|
||||
ncm_strlcpy(out->name, it.name[0] ? it.name : it.id, sizeof out->name);
|
||||
ncm_strlcpy(out->art_name, it.artist, sizeof out->art_name);
|
||||
/* 旁路元数据(≤600B 单次读)补时长/封面/fee;播放页与上/下一首逻辑照旧可用。 */
|
||||
if (songmeta_load(g_st.cache_dir, it.id, &m) == NCM_OK) {
|
||||
if (!it.name[0] && m.name[0]) ncm_strlcpy(out->name, m.name, sizeof out->name);
|
||||
if (!it.artist[0] && m.art_name[0]) ncm_strlcpy(out->art_name, m.art_name, sizeof out->art_name);
|
||||
out->duration_ms = m.duration_ms;
|
||||
out->fee = m.fee;
|
||||
ncm_strlcpy(out->al_pic, m.al_pic, sizeof out->al_pic);
|
||||
}
|
||||
return NCM_OK;
|
||||
}
|
||||
|
||||
int appstate_cache_remove(int i)
|
||||
{
|
||||
ncm_cache_item_t it;
|
||||
int rc;
|
||||
if (appstate_cache_item(i, &it) != NCM_OK) return NCM_ERR_ARG;
|
||||
/* 条目可能已被 LRU/清空删掉:NOENT 也算「已不在」,照样摘除列表项。 */
|
||||
rc = ncm_cache_remove(&g_st.ctx.cache, it.id, it.level, it.ext);
|
||||
if (rc != NCM_OK && rc != NCM_ERR_NOENT) return rc;
|
||||
pthread_mutex_lock(&g_cache_mu);
|
||||
for (int k = 0; k < g_st.cache_item_count; k++) {
|
||||
if (strcmp(g_st.cache_items[k].id, it.id) != 0 ||
|
||||
strcmp(g_st.cache_items[k].level, it.level) != 0 ||
|
||||
strcmp(g_st.cache_items[k].ext, it.ext) != 0) continue;
|
||||
memmove(&g_st.cache_items[k], &g_st.cache_items[k + 1],
|
||||
sizeof(ncm_cache_item_t) * (size_t)(g_st.cache_item_count - k - 1));
|
||||
g_st.cache_item_count--;
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&g_cache_mu);
|
||||
return NCM_OK;
|
||||
}
|
||||
|
||||
void appstate_cache_clear(void)
|
||||
{
|
||||
ncm_cache_clear(&g_st.ctx.cache); /* songs/ + covers/ 的 song_*(设置页同语义) */
|
||||
pthread_mutex_lock(&g_cache_mu);
|
||||
g_st.cache_item_count = 0;
|
||||
pthread_mutex_unlock(&g_cache_mu);
|
||||
}
|
||||
|
||||
int appstate_search_count(int tab)
|
||||
{
|
||||
int c;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/* logic/retry_ctl.c — 见 retry_ctl.h 头注释(M10 离线重试节奏闸)。 */
|
||||
#include "retry_ctl.h"
|
||||
|
||||
void ncm_retry_ctl_init(ncm_retry_ctl *c)
|
||||
{
|
||||
if (!c) return;
|
||||
c->last_ms = 0;
|
||||
c->have_last = 0;
|
||||
}
|
||||
|
||||
int ncm_retry_ctl_poll(ncm_retry_ctl *c, int offline, int busy, int64_t now_ms,
|
||||
int64_t interval_ms)
|
||||
{
|
||||
if (!c || interval_ms <= 0) return 0;
|
||||
if (!offline) return 0; /* 在线:不动基准 —— 抖动不得加速重试 */
|
||||
if (busy) return 0; /* 单飞:上一个 worker 还在跑 */
|
||||
|
||||
if (!c->have_last) { /* 首次进离线:立即探测一次 */
|
||||
c->have_last = 1;
|
||||
c->last_ms = now_ms;
|
||||
return 1;
|
||||
}
|
||||
if (now_ms < c->last_ms) { /* 时钟回退/异常:绝不让差值下溢成「巨大已超时」 */
|
||||
c->last_ms = now_ms; /* 重定基准 → 仍需重新等满 interval */
|
||||
return 0;
|
||||
}
|
||||
if (now_ms - c->last_ms < interval_ms) return 0;
|
||||
c->last_ms = now_ms;
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* logic/retry_ctl.h — 离线自动重试节奏闸(M10:断网重试风暴的修复点)。
|
||||
*
|
||||
* 背景(真机 19000100_000000_7534.log + launcher meow_20261105_122541.log):
|
||||
* M9 的 30s 守卫写成 `if (!thr_on && (!last || now - last >= 30000))`,节奏完全依赖
|
||||
* meow_ui_time_ms() 的 64 位差值。真机实测该值「低 32 位 = 整秒×1000(无毫秒)、高 32 位是垃圾」
|
||||
* (见 REPORT §42),于是 now-last 几乎恒为巨大正数 → 每个 UI tick 都判「该重试」,
|
||||
* 断网时把「30s 一次」退化成「每个 tick 一次」的重试风暴。
|
||||
*
|
||||
* 本模块把节奏判定收敛成三条硬约束(纯逻辑、零依赖,宿主编译可直接驱动):
|
||||
* 1) 时钟由调用方供给**可信单调钟**(home.c 传 core 的 ncm_monotonic_ms(),不再用 SDK 的
|
||||
* meow_ui_time_ms());本模块只做差值,不取时间。
|
||||
* 2) 时钟回退(now < last)绝不下溢成「巨大已超时」:按「刚试过」处理并重定基准,
|
||||
* 下一次尝试仍需重新等满 interval。
|
||||
* 3) 单飞:busy(上一个 worker 未收工)期间任何时钟跳变都不发新尝试。
|
||||
* 在线/离线抖动不重置基准 —— 抖动无法加速重试(旧实现 else 分支清零 g_offline_retry_ms,
|
||||
* 状态一抖就立刻再试,是风暴的第二条触发链)。
|
||||
*/
|
||||
#ifndef NCM_RETRY_CTL_H
|
||||
#define NCM_RETRY_CTL_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
int64_t last_ms; /* 上次发起尝试的单调毫秒时刻(have_last=0 时无意义) */
|
||||
int have_last; /* 1=至少尝试过一次 */
|
||||
} ncm_retry_ctl;
|
||||
|
||||
void ncm_retry_ctl_init(ncm_retry_ctl *c);
|
||||
|
||||
/* 每个 UI tick 调一次。返回 1=现在应当发起一次重试(内部已记账:调用方随即起 worker)。
|
||||
* offline=当前是否离线;busy=是否已有 worker 在跑(单飞);now_ms=单调毫秒;
|
||||
* interval_ms=两次尝试的最小间隔(M10 定 30000)。
|
||||
*
|
||||
* 语义:
|
||||
* - offline=0:返回 0,且**不动**基准(在线期间与状态抖动都不重置节奏)。
|
||||
* - busy=1:返回 0。
|
||||
* - 首次观察到离线:返回 1(进离线先立即探测一次,恢复后由会话门切回在线)。
|
||||
* - 之后:仅当 now-last >= interval 才返回 1;now<last(时钟回退)→ 重置基准、返回 0。 */
|
||||
int ncm_retry_ctl_poll(ncm_retry_ctl *c, int offline, int busy, int64_t now_ms,
|
||||
int64_t interval_ms);
|
||||
|
||||
#endif /* NCM_RETRY_CTL_H */
|
||||
@@ -31,6 +31,7 @@
|
||||
#define ACT_SETTINGS 4 /* 设置页 */
|
||||
#define ACT_SEARCH 5 /* 搜索页(顶栏搜索按钮进入) */
|
||||
#define ACT_QUEUE 6 /* 当前播放队列页(播放页「队列」钮进入,展示实时播放列表) */
|
||||
#define ACT_CACHE 7 /* 已缓存列表页(首页顶栏「已缓存」入口,NCM_CACHE_DESIGN §2.5) */
|
||||
|
||||
/* ---- 播放模式三态(REPORT §NEW:随机 / 列表循环 / 单曲循环)----
|
||||
* 默认=列表循环(延续既有「顺序下一首」语义;列表循环下自动顺延会在队尾回绕)。
|
||||
@@ -61,14 +62,14 @@ enum {
|
||||
#define APP_W 480
|
||||
#define APP_H 720
|
||||
#define APP_TOPBAR_H 56
|
||||
#define APP_ROW_H 56 /* 列表行高 */
|
||||
#define APP_ROW_H 56 /* 列表行高下限(旧固定值;实际行高按字体行高派生,见 ncm_row_h) */
|
||||
#define APP_PAD 16
|
||||
#define APP_OP_W 48 /* 行右侧操作区触达宽 */
|
||||
#define APP_FOOTER_H 26
|
||||
|
||||
/* 圆角卡列表项外边距(item margin/gap):相邻卡片间可见空隙 px。SDK listview 行距(=APP_ROW_H)
|
||||
* 不变——卡片在其行高内上下对称内缩 NCM_ROW_GAP/2,故文本(SDK 按 APP_ROW_H 垂直居中)仍落卡中心;
|
||||
* 相邻卡顶间距恒=APP_ROW_H=卡高+NCM_ROW_GAP。要求偶数,保证上下对称。 */
|
||||
/* 圆角卡列表项外边距(item margin/gap):相邻卡片间可见空隙 px。SDK listview 行距(=ncm_row_h())
|
||||
* 不变——卡片在其行高内上下对称内缩 NCM_ROW_GAP/2,故文本(SDK 按行高垂直居中)仍落卡中心;
|
||||
* 相邻卡顶间距恒=行高=卡高+NCM_ROW_GAP。要求偶数,保证上下对称。 */
|
||||
#define NCM_ROW_GAP 8
|
||||
/* 第 idx 行(行高 item,滚动 off,列表顶 list_y)的圆角卡:顶 y 与高 h。 */
|
||||
static inline int ncm_row_card_y(int item, int idx, int off, int list_y)
|
||||
@@ -77,6 +78,32 @@ static inline int ncm_row_card_y(int item, int idx, int off, int list_y)
|
||||
}
|
||||
static inline int ncm_row_card_h(int item) { return item - NCM_ROW_GAP; }
|
||||
|
||||
/* ---- 列表行高(M12:首行标题不显示根因修复)----
|
||||
* SDK meow_listview 的双行渲染(sdk/meow_ui_widget.c 的 2 行分支)把「标题+副标题」文本块
|
||||
* block = 2 * meow_ui_text_height(font_body) + MEOW_LISTV_SUB_GAP(2)
|
||||
* 在行内垂直居中(ty = 行顶 + (item - block) / 2),且标题只在 `ty >= 视口顶` 时才画
|
||||
* (越界钳制,防泄漏)。行高 item 小于 block 时 ty = 行顶 - Δ,于是:
|
||||
* - 第一行(行顶 == 视口顶)标题被钳掉、副标题(ty + thh + 2)仍在视口内 → 真机
|
||||
* 「歌单列表/歌曲列表第 1 行只有副标题」,其余行标题画在自己行顶上方 Δ(不易察觉);
|
||||
* - 文本块也比圆角卡高,两行文字溢出卡外。
|
||||
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 = 30)block = 62,旧固定行高
|
||||
* 56 → Δ = 3。故行高必须由字体实测行高派生:block + NCM_ROW_GAP → 卡高 == 文本块,
|
||||
* 文本完整落在卡内并仍居中。行高由 NCM_ROW_GAP 为偶数、2*行高 为偶数保证偶数(卡对称内缩)。 */
|
||||
#define NCM_ROW_SUB_GAP 2 /* 与 SDK MEOW_LISTV_SUB_GAP 同口径(标题/副标题行间距) */
|
||||
static inline int ncm_row_h_for_line_h(int line_h)
|
||||
{
|
||||
if (line_h < 1) line_h = 1; /* 字体未就绪的防御(0 高 → 退回下限) */
|
||||
int h = 2 * line_h + NCM_ROW_SUB_GAP + NCM_ROW_GAP;
|
||||
if (h < APP_ROW_H) h = APP_ROW_H;
|
||||
if (h & 1) h++;
|
||||
return h;
|
||||
}
|
||||
/* 当前主题字体下的两行列表行高(页面 create 时取值并缓存;字体档位切换走重建页面)。 */
|
||||
static inline int ncm_row_h(void)
|
||||
{
|
||||
return ncm_row_h_for_line_h(meow_ui_text_height(meow_theme_get()->font_body));
|
||||
}
|
||||
|
||||
/* ---- 播放页(§22 重做:封面页/歌词页双页,NCM_CACHE_DESIGN §6)----
|
||||
* 沉浸底恒 NCM_PLAYER_BG(不随系统明暗反转——「不动沉浸底」);日夜间主题配色只映射到
|
||||
* 半透明叠加层(歌词页 scrim)与 accent(红)。 */
|
||||
@@ -115,6 +142,11 @@ static inline int ncm_row_card_h(int item) { return item - NCM_ROW_GAP; }
|
||||
/* 播放队列来源(§23:搜索单曲起播建立「搜索队列」上下文,不覆写歌单窗口) */
|
||||
#define NCM_QUEUE_PLAYLIST 0 /* 默认:歌单滑动窗口 */
|
||||
#define NCM_QUEUE_SEARCH 1 /* 搜索单曲结果队列 */
|
||||
#define NCM_QUEUE_CACHE 2 /* 已缓存列表队列(点播缓存页条目;上/下一首在缓存列表内循环) */
|
||||
|
||||
/* 已缓存列表条目上限(NCM_CACHE_LIST_MAX 条 × sizeof(ncm_cache_item_t)≈232B ≈ 116KB bss)。
|
||||
* 缓存池条目更多时只展示最近访问的 max 条(scan 按 mtime 降序保留最近的)。 */
|
||||
#define NCM_CACHE_LIST_MAX 512
|
||||
|
||||
/* active_playlist 哨兵:搜索歌单详情(复用 playlist_open 链路,读 g_st.search_pl_open) */
|
||||
#define NCM_PL_SEARCH (-2)
|
||||
@@ -199,6 +231,10 @@ typedef struct ncm_app_state {
|
||||
int qr_size;
|
||||
int login_state; /* NCM_LOGIN_* */
|
||||
int logged_in;
|
||||
/* 离线模式(M9):网络类失败(不可达/超时/DNS/连接失败/服务端 5xx)时为 1——账号态与
|
||||
* cookie 一律保留,UI 显示可见提示、已缓存列表照常可开可播;任何一次请求成功即回 0。
|
||||
* 鉴权失败(NCM_ERR_AUTH)不走这里:沿用原退出登录→扫码逻辑。 */
|
||||
int offline;
|
||||
|
||||
/* 账号 */
|
||||
char uid[32];
|
||||
@@ -269,6 +305,10 @@ typedef struct ncm_app_state {
|
||||
|
||||
/* 播放队列来源(§23):默认 NCM_QUEUE_PLAYLIST;搜索单曲起播切 NCM_QUEUE_SEARCH */
|
||||
int queue_src;
|
||||
|
||||
/* 已缓存列表(ACT_CACHE 页 + NCM_QUEUE_CACHE 队列;worker 扫描磁盘填充,UI 只读) */
|
||||
ncm_cache_item_t cache_items[NCM_CACHE_LIST_MAX];
|
||||
int cache_item_count;
|
||||
} ncm_app_state_t;
|
||||
|
||||
extern ncm_app_state_t g_st; /* 全局状态(app.c 定义) */
|
||||
@@ -331,6 +371,7 @@ void home_touch_down(int x, int y);
|
||||
void home_touch_move(int x, int y);
|
||||
void home_reload(void); /* 刷新我的歌单(后台 worker,非阻塞) */
|
||||
void home_tick(uint64_t now); /* UI tick:检查歌单 worker 是否完成并刷新 */
|
||||
void home_retry_reset(void); /* M10:清离线自动重试节奏(页创建/登出/宿主测试) */
|
||||
int home_go_up(void); /* 左缘返回:到底则退出 */
|
||||
|
||||
/* playlist */
|
||||
@@ -353,6 +394,7 @@ void player_tick(uint64_t now); /* 播放进度/歌词滚动 */
|
||||
void player_play_song(int idx); /* 从歌单窗口第 idx 首开始播放(queue=PLAYLIST) */
|
||||
void player_play_search_song(int i); /* 从搜索单曲第 i 首开始播放(queue=SEARCH,§23) */
|
||||
void player_play_queue_song(int idx);/* 从「当前实时播放队列」第 idx 首播放(保持当前 queue_src,§NEW) */
|
||||
void player_play_cache_song(int i); /* 从已缓存列表第 i 首点播(切 NCM_QUEUE_CACHE 队列) */
|
||||
void player_pause_toggle(int on);
|
||||
void player_next(int dir); /* dir=+1 下一首 / -1 上一首 */
|
||||
void player_seek(int64_t ms);
|
||||
@@ -390,6 +432,19 @@ void queue_touch_move(int x, int y);
|
||||
void queue_open(void); /* 进当前播放队列页 */
|
||||
void queue_tick(uint64_t now); /* UI tick:检查队列 worker 完成并刷新 */
|
||||
|
||||
/* cache(已缓存列表页,ACT_CACHE:首页顶栏「已缓存」钮进入,NCM_CACHE_DESIGN §2.5)
|
||||
* 列表 = songs/ 池扫描(worker 读盘,UI 零 IO);行显示 歌名/歌手/实际档位/大小;
|
||||
* 点行=点播(NCM_QUEUE_CACHE),行右「删除」=单曲删除,顶栏「清空」=清空缓存池。 */
|
||||
meow_view *cache_view_create(void);
|
||||
int cache_is_active(void);
|
||||
int cache_on_tap(int x, int y);
|
||||
void cache_swipe(int dir, int dist);
|
||||
void cache_touch_down(int x, int y);
|
||||
void cache_touch_move(int x, int y);
|
||||
void cache_open(void); /* 进已缓存列表页(push ACT_CACHE) */
|
||||
void cache_reload(void); /* 起 worker 重扫缓存池(非阻塞) */
|
||||
void cache_tick(uint64_t now); /* UI tick:检查扫描 worker 完成并刷新 */
|
||||
|
||||
/* search */
|
||||
meow_view *search_view_create(void);
|
||||
int search_is_active(void);
|
||||
@@ -409,7 +464,20 @@ void appstate_save_settings(void);
|
||||
void appstate_set_logged_in(const char *uid, const char *nickname);
|
||||
void appstate_logout(void);
|
||||
int appstate_ensure_uid(void); /* 若 uid 缺失则 login_status_check 取回;0=成功 */
|
||||
|
||||
/* ---- 会话保持 / 离线模式(M9)----
|
||||
* 一次网络动作的返回码 → 会话状态机(core ncm_session.h 的纯判定在上,这里落到 App 状态):
|
||||
* 成功 → 在线(解除离线);网络类失败 → 离线(保留登录态/cookie);
|
||||
* 鉴权失败 → 由调用方沿用原退出登录逻辑(本函数不改登录态);其它 → 保持现状。
|
||||
* 返回 ncm_gate_t。可在 worker 线程调用(只写 g_st.offline,值域 0/1 幂等)。 */
|
||||
int appstate_session_gate(int rc);
|
||||
int appstate_is_offline(void); /* 1=离线模式(UI 画「离线模式」提示) */
|
||||
void appstate_offline_set(int on); /* 直接置离线态(登出/初始化清零) */
|
||||
int appstate_load_playlists(void); /* 拉我的歌单 */
|
||||
int appstate_load_playlists_ex(int *from_cache);
|
||||
/* 同上,额外回填「本次是否接口缓存命中」:
|
||||
* 1=缓存(未走网络,不能判为已回在线);0=走网络/失败。
|
||||
* from_cache 可为 NULL(等价 appstate_load_playlists)。 */
|
||||
int appstate_load_playlist_songs(int idx); /* 兼容:载索引 + 首个窗口(内部走 ensure) */
|
||||
int appstate_load_playlist_songs_page(int idx, int offset, int limit);
|
||||
/* 兼容入口(滑动窗口语义,REPORT §17):
|
||||
@@ -470,6 +538,16 @@ int appstate_queue_count(void); /* 当前播放队列长度(
|
||||
int appstate_queue_song(int idx, ncm_song_t *out); /* 队列第 idx 首;0=ok(按 queue_src) */
|
||||
void appstate_apply_play_mode(int m); /* 设 g_st.play_mode + 落盘(持久化「记住模式」,§NEW) */
|
||||
|
||||
/* ---- 已缓存列表(NCM_CACHE_LIST_MAX;磁盘扫描/删除只在 worker 或 UI 单次小 IO)----
|
||||
* queue_src==NCM_QUEUE_CACHE 时,appstate_queue_count/song 走本数组(播放页上/下一首同源)。
|
||||
* 名称/歌手优先取文件内 tag(与外部播放器同源),空则回退旁路元数据 <cache_dir>/meta/<id>.bin。 */
|
||||
int appstate_cache_list_load(void); /* worker:ncm_cache_scan + 旁路补名;返回条数或负错误码 */
|
||||
int appstate_cache_count(void);
|
||||
int appstate_cache_item(int i, ncm_cache_item_t *out); /* 0=ok */
|
||||
int appstate_cache_song(int i, ncm_song_t *out); /* 合成 ncm_song_t(时长/封面/fee 取旁路) */
|
||||
int appstate_cache_remove(int i); /* 删文件 + 摘除条目;0=成功 */
|
||||
void appstate_cache_clear(void); /* 清空缓存池(含封面池,同设置页语义)+ 清列表 */
|
||||
|
||||
/* ---- 搜索结果独立数组(§23;g_search_mu 保护,UI/worker 经此访问)---- */
|
||||
int appstate_search_count(int tab); /* tab: 0=单曲 1=歌单 */
|
||||
int appstate_search_offset(int tab); /* 下次 offset(=已加载条数) */
|
||||
@@ -537,7 +615,10 @@ int audio_out_pause(int paused); /* 1=暂停(停 PCM 时钟不丢流) 0=恢
|
||||
void audio_out_close(void);
|
||||
int audio_out_sync_volume(void); /* 任务1:读 settings.conf 当前系统音量并下发到 audiod
|
||||
(MSG_AUDIO_VOLUME→写 DAC+持久化)。0=下发成功/-1=失败。
|
||||
audio_out_open SINK_AUDIOD 成功后已自动调;独立导出供宿主验证与复调。 */
|
||||
每音频会话(新 audiod 流 sid)首同步一次 + 值变才重发——切歌/新建
|
||||
会话时 DAC 音量不残留默认响度(修「切歌音量骤增」);同会话内不
|
||||
重复建连(不做 fd 风暴)。audio_out_open SINK_AUDIOD 成功后已自动调;
|
||||
独立导出供宿主验证与复调。 */
|
||||
void audio_out_set_target(int ms); /* 预留:缓冲目标 */
|
||||
int audio_out_buffered_ms(void);
|
||||
|
||||
|
||||
@@ -233,20 +233,27 @@ static void audiod_sync_volume(int pct)
|
||||
close(fd);
|
||||
}
|
||||
|
||||
/* 每进程同步一次 + 值变才重发:杜绝每次 audio_out_open 新建短连接(fd=N accept 风暴根因)。
|
||||
* g_vol_synced=1 即已同步过;g_vol_last=上次下发的 pct,二者相同则零连接直接返回。 */
|
||||
static int g_vol_synced = 0;
|
||||
static int g_vol_last = -1;
|
||||
/* 会话身份 = audiod 流 sid(g_sid):切歌/新建音频会话 = 每次 audio_out_open 都向 audiod 开一条
|
||||
* 新流、audiod 分配新 sid(单调不重用)。此前用进程级 g_vol_synced「首次同步一次」——切歌(新会话)
|
||||
* 时音量不再被重发到 audiod,DAC 落在会话建立/重配后的默认/上次残留值(衰减量 0=最响),造成
|
||||
* 「切歌音量骤增,按一下音量键才回落到设定值」(用户真机 2026-09-05)。audiod 只在 reconfigure
|
||||
* 且参数变化时才 re-apply 音量(_audio_mark_reconfig_needed 同参数跳过),本会话重发是权威兜底。
|
||||
* 修法:**每会话首同步一次**——新 sid(新会话)必重发一次当前音量;同会话内仅值变才重发。
|
||||
* 每会话恰 1 条短连接,不回到「每次 open 都建连」的 fd 风暴(§2026-09-02 根因,vol_sync_sim 60 次
|
||||
* 同值→1 连接)。音量值取自 settings.conf(audiod 权威同源同键),反转/真静音(255) 由 audiod
|
||||
* dac_vol_set 负责,本层只传 pct(0-100)。 */
|
||||
static int g_vol_synced_sid = -1; /* 上次音量同步针对的 audiod 流 sid;-1=从未(必发) */
|
||||
static int g_vol_last = -1;
|
||||
|
||||
int audio_out_sync_volume(void)
|
||||
{
|
||||
int pct = settings_read_volume();
|
||||
if (g_vol_synced && pct == g_vol_last) return 0; /* 已同步且未变:零新建连接 */
|
||||
g_vol_synced = 1;
|
||||
g_vol_last = pct;
|
||||
if (g_vol_synced_sid == g_sid && pct == g_vol_last) return 0; /* 本会话已同步且值未变:零新建连接 */
|
||||
g_vol_synced_sid = g_sid;
|
||||
g_vol_last = pct;
|
||||
audiod_sync_volume(pct);
|
||||
meow_log(MEOW_LOG_INFO, "audio: sync volume -> audiod pct=%d (settings %s)",
|
||||
pct, settings_conf_path());
|
||||
meow_log(MEOW_LOG_INFO, "audio: sync volume -> audiod pct=%d (settings %s, sid=%d)",
|
||||
pct, settings_conf_path(), g_sid);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -379,6 +386,7 @@ int audio_out_pause(int paused)
|
||||
}
|
||||
g_sid = sid;
|
||||
meow_log(MEOW_LOG_INFO, "audio: audiod reopen ok sid=%d", sid);
|
||||
audio_out_sync_volume(); /* 新会话(新 sid):音量权威兜底重发一次(DAC 复位后不残留默认响度) */
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/* play/ipc_media.c — 媒体会话 IPC 最小客户端(SDK 尚无媒体封装,App 内用 meow_ipc.h 帧格式直连)。
|
||||
*
|
||||
* 连接:AF_UNIX SOCK_STREAM 到 MEOW_IPC_SOCK(缺省 /usr/data/meow_ipc.sock)。HELLO(app_id,
|
||||
* MF_PLAYBACK) 注册为媒体源;之后**同一连接**周期发 MSG_META(~1s,进度随播放推进)并阻塞等
|
||||
* launcher 下发 MSG_PLAYCTL(播放/暂停/上下曲/seek/toggle)。launcher 经该连接 fd 回发(meow_media.c
|
||||
* send_playctl 用最近活跃会话的 fd)。无 socket/launcher 不跑 → 优雅跳过(打日志不崩)。
|
||||
* MF_PLAYBACK) 注册为媒体源;之后**同一连接**上报 MSG_META 并阻塞等 launcher 下发 MSG_PLAYCTL
|
||||
* (播放/暂停/上下曲/seek/toggle)。launcher 经该连接 fd 回发(meow_media.c send_playctl 用最近
|
||||
* 活跃会话的 fd)。META 节奏见 meta_gate.h(变化 + 低频心跳,不再是 1s 定时;M10 修洪泛)。
|
||||
* 无 socket/launcher 不跑 → 优雅跳过(打日志不崩)。
|
||||
* 线程模型:独立线程(main 起了 meow_ui_run 阻塞;播放线程归 player_manager,本线程只做 IPC 收发)。
|
||||
*/
|
||||
#include "../ncm_app_internal.h"
|
||||
#include "meta_gate.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -23,6 +25,12 @@ static int g_thr_on;
|
||||
static int g_quit;
|
||||
static volatile int g_req_focus_player = 0; /* 任务3:launcher 请求「回到播放页」(UI 线程消费) */
|
||||
|
||||
/* M10:META 上报闸(变化 + 低频心跳;时间用 core 单调钟)。
|
||||
* 旧实现 `if (now - last_meta >= 1000)` 依赖 meow_ui_time_ms(),真机该值高 32 位是垃圾
|
||||
* (REPORT §42)→ 条件恒真 → 每个 IPC 轮次都发 META(真机 launcher 日志 ~100ms 一帧、
|
||||
* 单次启动 21016 帧),launcher 每帧写日志+归并会话,SD 写放大到整机卡死。 */
|
||||
static ncm_meta_gate g_meta_gate;
|
||||
|
||||
static const char *sock_path(void)
|
||||
{
|
||||
const char *e = getenv("MEOW_IPC_SOCK");
|
||||
@@ -92,8 +100,10 @@ static int read_frame(int fd, uint16_t *type, uint8_t *rbuf, uint32_t rcap)
|
||||
return (int)len;
|
||||
}
|
||||
|
||||
/* 组并发送一条 META。 */
|
||||
static void send_meta(int fd, uint32_t req_id)
|
||||
/* 组并发送一条 META。返回 1=已发 / 0=闸判定不必发 / -1=写失败。
|
||||
* 是否发由 meta_gate 决定:状态/元数据变化立即发;播放中位置偏移 ≥5s 重锚;心跳 5s/30s;
|
||||
* 硬下限 250ms(任何路径都不快于 4 帧/秒)。now_ms 必须是单调毫秒(ncm_monotonic_ms)。 */
|
||||
static int send_meta(int fd, uint32_t req_id, int64_t now_ms)
|
||||
{
|
||||
m_meta_t mm;
|
||||
memset(&mm, 0, sizeof mm);
|
||||
@@ -135,7 +145,21 @@ static void send_meta(int fd, uint32_t req_id)
|
||||
mm.flags |= MF_ART | MF_COVER;
|
||||
}
|
||||
if (lyric_count() > 0) mm.flags |= MF_LYRIC;
|
||||
send_frame(fd, MSG_META, req_id, &mm, sizeof mm);
|
||||
|
||||
ncm_meta_snap s;
|
||||
s.state = (int)mm.state;
|
||||
s.pos_ms = (int)mm.pos_ms;
|
||||
s.dur_ms = (int)mm.dur_ms;
|
||||
s.flags = (int)mm.flags;
|
||||
s.title = mm.title;
|
||||
s.artist = mm.artist;
|
||||
s.album = mm.album;
|
||||
s.artwork = mm.artwork;
|
||||
if (!ncm_meta_gate_should_send(&g_meta_gate, &s, now_ms)) return 0;
|
||||
|
||||
if (send_frame(fd, MSG_META, req_id, &mm, sizeof mm) != 0) return -1;
|
||||
meow_log(MEOW_LOG_DEBUG, "ipc: meta sent state=%u pos=%u/%u", mm.state, mm.pos_ms, mm.dur_ms);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void apply_playctl(const m_playctl_t *c)
|
||||
@@ -177,9 +201,8 @@ static void *ipc_thread(void *arg)
|
||||
uint16_t htype;
|
||||
if (read_frame(fd, &htype, hb, sizeof hb) < 0) { close(fd); return NULL; }
|
||||
|
||||
send_meta(fd, 2); /* 首条注册媒体源(state=0 未播也登记,start 播后自动成源) */
|
||||
send_meta(fd, 2, ncm_monotonic_ms()); /* 首条注册媒体源(state=0 未播也登记,start 播后自动成源) */
|
||||
|
||||
uint64_t last_meta = 0;
|
||||
while (!g_quit && !g_app_quit) {
|
||||
struct pollfd p;
|
||||
p.fd = fd; p.events = POLLIN;
|
||||
@@ -197,11 +220,9 @@ static void *ipc_thread(void *arg)
|
||||
} else if (pr < 0 && errno != EINTR) {
|
||||
break;
|
||||
}
|
||||
uint64_t now = meow_ui_time_ms();
|
||||
if (now - last_meta >= 1000) {
|
||||
send_meta(fd, (uint32_t)(now & 0x7FFFFFFF));
|
||||
last_meta = now;
|
||||
}
|
||||
/* M10:不再定时无脑发;send_meta 内部按 meta_gate 判「变化 / 位置偏移 / 低频心跳」。 */
|
||||
int64_t now_ms = ncm_monotonic_ms();
|
||||
send_meta(fd, (uint32_t)(now_ms & 0x7FFFFFFF), now_ms);
|
||||
}
|
||||
close(fd);
|
||||
return NULL;
|
||||
@@ -242,6 +263,7 @@ static const char *ncm_act_name(int act)
|
||||
case ACT_PLAYER: return "player";
|
||||
case ACT_SETTINGS: return "settings";
|
||||
case ACT_SEARCH: return "search";
|
||||
case ACT_CACHE: return "cache"; /* M5 已缓存列表(app.json 未声明常亮 → 照常息屏) */
|
||||
default: return NULL;
|
||||
}
|
||||
}
|
||||
@@ -283,6 +305,7 @@ int ipc_media_start(void)
|
||||
{
|
||||
if (g_thr_on) return 0;
|
||||
g_quit = 0;
|
||||
ncm_meta_gate_init(&g_meta_gate); /* M10:首帧必发(注册媒体源),之后按变化/心跳 */
|
||||
if (ncm_thread_create(&g_thr, ipc_thread, NULL) != 0) return -1;
|
||||
pthread_detach(g_thr);
|
||||
g_thr_on = 1;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/* play/meta_gate.c — 见 meta_gate.h 头注释(M10 媒体会话 META 上报闸)。 */
|
||||
#include "meta_gate.h"
|
||||
#include <string.h>
|
||||
|
||||
static void gate_cp(char *dst, size_t cap, const char *src)
|
||||
{
|
||||
size_t i = 0;
|
||||
if (!dst || cap == 0) return;
|
||||
if (src) {
|
||||
for (; src[i] && i + 1 < cap; i++) dst[i] = src[i];
|
||||
}
|
||||
dst[i] = 0;
|
||||
}
|
||||
|
||||
void ncm_meta_gate_init(ncm_meta_gate *g)
|
||||
{
|
||||
if (g) memset(g, 0, sizeof *g);
|
||||
}
|
||||
|
||||
static void gate_record(ncm_meta_gate *g, const ncm_meta_snap *s, int64_t now_ms)
|
||||
{
|
||||
g->inited = 1;
|
||||
g->state = s->state;
|
||||
g->dur_ms = s->dur_ms;
|
||||
g->flags = s->flags;
|
||||
g->last_pos_ms = s->pos_ms;
|
||||
g->last_send_ms = now_ms;
|
||||
gate_cp(g->title, sizeof g->title, s->title);
|
||||
gate_cp(g->artist, sizeof g->artist, s->artist);
|
||||
gate_cp(g->album, sizeof g->album, s->album);
|
||||
gate_cp(g->artwork, sizeof g->artwork, s->artwork);
|
||||
}
|
||||
|
||||
/* 只比较「会被发送的那部分」:发送侧同样按 cap 截断,超长串不得因截断而恒判「变了」。 */
|
||||
static int str_diff(const char *stored, size_t cap, const char *now)
|
||||
{
|
||||
size_t i = 0;
|
||||
if (!now) now = "";
|
||||
for (; i + 1 < cap && stored[i] && stored[i] == now[i]; i++) { }
|
||||
if (i + 1 >= cap) return 0; /* 前 cap-1 字符一致且已到截断长度 → 相同 */
|
||||
return stored[i] != now[i];
|
||||
}
|
||||
|
||||
int ncm_meta_gate_should_send(ncm_meta_gate *g, const ncm_meta_snap *s, int64_t now_ms)
|
||||
{
|
||||
if (!g || !s) return 0;
|
||||
if (!g->inited) { gate_record(g, s, now_ms); return 1; } /* 首帧:注册媒体源 */
|
||||
|
||||
int64_t gap = now_ms - g->last_send_ms;
|
||||
if (gap < 0) { /* 时钟回退/异常:重定基准,差值按 0 处理(防下溢成「恒该发」) */
|
||||
g->last_send_ms = now_ms;
|
||||
gap = 0;
|
||||
}
|
||||
|
||||
int changed = (s->state != g->state) ||
|
||||
(s->dur_ms != g->dur_ms) ||
|
||||
(s->flags != g->flags) ||
|
||||
str_diff(g->title, sizeof g->title, s->title) ||
|
||||
str_diff(g->artist, sizeof g->artist, s->artist) ||
|
||||
str_diff(g->album, sizeof g->album, s->album) ||
|
||||
str_diff(g->artwork, sizeof g->artwork, s->artwork);
|
||||
|
||||
int64_t dpos = (int64_t)s->pos_ms - (int64_t)g->last_pos_ms;
|
||||
if (dpos < 0) dpos = -dpos; /* seek 前后跳都算偏移 */
|
||||
|
||||
int playing = (s->state == NCM_META_ST_PLAYING || s->state == NCM_META_ST_BUFFER);
|
||||
int64_t hb = playing ? NCM_META_HB_PLAY_MS : NCM_META_HB_IDLE_MS;
|
||||
|
||||
if (!changed && dpos < NCM_META_POS_RESEND_MS && gap < hb) return 0;
|
||||
if (gap < NCM_META_MIN_GAP_MS) return 0; /* 硬下限:变化挂起,下一轮再发(不快于 4 帧/秒) */
|
||||
|
||||
gate_record(g, s, now_ms);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/* play/meta_gate.h — 媒体会话 META 上报闸(M10:META 洪泛修复点;纯逻辑,宿主可驱动)。
|
||||
*
|
||||
* 背景(真机证据):launcher meow_20261105_122541.log 里 21016 条 `ipc: META app=dev.player.ncm
|
||||
* state=0 pos=0/0 rc=0 -> ACK`,间隔 ~100ms(每次 launcher 收帧都要写日志+归并会话)。
|
||||
* 旧 ipc_media 的节奏是 `if (now - last_meta >= 1000)`,同样依赖 meow_ui_time_ms() 的 64 位差值;
|
||||
* 真机该值高 32 位是垃圾(REPORT §42)→ 条件几乎恒真 → 每个 IPC 轮次都发 META,
|
||||
* 1s 心跳退化成 10Hz 洪泛,把 launcher 的 SD 写/会话归并拖满,整机卡死。
|
||||
*
|
||||
* 修复后的上报策略(不再是定时器,而是「变化 + 低频心跳」):
|
||||
* - 状态/时长/标题/歌手/专辑/封面/歌词标志变化 → 立即上报(用户操作要即时可见);
|
||||
* - 播放中位置相对上次上报偏移 ≥ 5s(含 seek 前后跳)→ 上报,给 launcher 重锚进度
|
||||
* (launcher meow_media_display_pos 会按 last_active 自行外推,5s 足够);
|
||||
* - 心跳:播放中 5s、非播放 30s(保活会话/防 launcher 会话超时清理);
|
||||
* - 硬下限 250ms:任何路径(含状态抖动)都不快于 4 帧/秒。
|
||||
* 时间基准由调用方给单调钟(ipc_media 传 core 的 ncm_monotonic_ms())。
|
||||
*/
|
||||
#ifndef NCM_META_GATE_H
|
||||
#define NCM_META_GATE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* 与 meow_ipc.h 的 m_meta_t.state 同口径(MF_PLAYBACK 会话状态)。 */
|
||||
enum {
|
||||
NCM_META_ST_STOPPED = 0,
|
||||
NCM_META_ST_PLAYING = 1,
|
||||
NCM_META_ST_PAUSED = 2,
|
||||
NCM_META_ST_BUFFER = 3,
|
||||
};
|
||||
|
||||
/* 一次上报快照(字符串由调用方持有,本模块只拷贝比较,不接管所有权)。 */
|
||||
typedef struct {
|
||||
int state; /* NCM_META_ST_* */
|
||||
int pos_ms;
|
||||
int dur_ms;
|
||||
int flags; /* MF_ART/MF_COVER/MF_LYRIC 位 */
|
||||
const char *title;
|
||||
const char *artist;
|
||||
const char *album;
|
||||
const char *artwork;
|
||||
} ncm_meta_snap;
|
||||
|
||||
typedef struct {
|
||||
int inited;
|
||||
int state;
|
||||
int dur_ms;
|
||||
int flags;
|
||||
int last_pos_ms; /* 上次上报时的播放位置(判进度偏移/seek) */
|
||||
int64_t last_send_ms; /* 上次上报的单调毫秒 */
|
||||
char title[128];
|
||||
char artist[128];
|
||||
char album[128];
|
||||
char artwork[192];
|
||||
} ncm_meta_gate;
|
||||
|
||||
#define NCM_META_POS_RESEND_MS 5000 /* 播放中位置偏移(ms)触发重报 */
|
||||
#define NCM_META_HB_PLAY_MS 5000 /* 播放中心跳 */
|
||||
#define NCM_META_HB_IDLE_MS 30000 /* 非播放心跳 */
|
||||
#define NCM_META_MIN_GAP_MS 250 /* 硬下限:任何情况下 ≤ 4 帧/秒 */
|
||||
|
||||
void ncm_meta_gate_init(ncm_meta_gate *g);
|
||||
|
||||
/* 返回 1=现在应当发送(内部已把本次快照记为「已上报」);0=不必发。
|
||||
* now_ms=单调毫秒(建议 ncm_monotonic_ms())。未初始化时首调必发(注册媒体源)。 */
|
||||
int ncm_meta_gate_should_send(ncm_meta_gate *g, const ncm_meta_snap *s, int64_t now_ms);
|
||||
|
||||
#endif /* NCM_META_GATE_H */
|
||||
@@ -167,9 +167,10 @@ void player_manager_set_quality(int level, int cache_gb)
|
||||
static int play_is_same_song(int song_idx)
|
||||
{
|
||||
ncm_song_t cand;
|
||||
int have = (appstate_get_queue() == NCM_QUEUE_SEARCH)
|
||||
? (appstate_search_song(song_idx, &cand) == NCM_OK)
|
||||
: (appstate_song_copy(song_idx, &cand) == NCM_OK);
|
||||
int src = appstate_get_queue();
|
||||
int have = (src == NCM_QUEUE_SEARCH) ? (appstate_search_song(song_idx, &cand) == NCM_OK)
|
||||
: (src == NCM_QUEUE_CACHE) ? (appstate_cache_song(song_idx, &cand) == NCM_OK)
|
||||
: (appstate_song_copy(song_idx, &cand) == NCM_OK);
|
||||
if (!have || !cand.id[0]) return 0; /* 未物化:不能确证同曲 → 交下方正常起播 */
|
||||
pthread_mutex_lock(&g_play.mu);
|
||||
int same = g_play.play_song_valid && g_play.play_song.id[0] &&
|
||||
@@ -348,6 +349,11 @@ typedef struct {
|
||||
char url[1024];/* 下载线程用(song_local_file 栈上 u.url 生命周期不够,拷贝) */
|
||||
size_t maxb; /* 下载限幅 */
|
||||
int64_t total; /* 标称大小(song_url size;0=未知;mp3 seek 分母) */
|
||||
/* 缓存文件元数据嵌入(NCM_CACHE_DESIGN §2.5 + M5):下载完整后由下载线程把
|
||||
* id3/vorbis tag 写进 .tmp(按实际容器分支),再交播放线程 commit。失败只记日志,
|
||||
* commit 裸流——播放主链路不受影响。 */
|
||||
ncm_tag_meta_t tag;
|
||||
char ext[8]; /* 实际容器(song_url type / url 扩展名) */
|
||||
/* 阶段3 .part 续传(§5.2) */
|
||||
uint64_t resume_base; /* >0=断点续传起点(.part 大小);Http 带 Range: bytes=<n>- */
|
||||
int first_body; /* 首个 body 回调未处理(按响应状态切换写姿态) */
|
||||
@@ -440,8 +446,16 @@ static void *dl_thread(void *arg)
|
||||
if (!ok_st) rc = NCM_ERR_HTTP;
|
||||
}
|
||||
ss->dl_rc = rc;
|
||||
/* 结束通知:成功 final=全长;失败/中止 final=截断点+failed(读端播到截断点自然结束,F5) */
|
||||
/* 缓存文件元数据嵌入(M5):下载完整 → 把 id3/vorbis tag 流式写进 .tmp(64KB 分块,
|
||||
* 同 inode 替换:解码端持有旧 fd 继续播,不受影响)。先 finish 让读端自然收尾,再重写;
|
||||
* dispose 的 join 保证「重写完成 → commit」顺序。任何失败都只降级为裸流。 */
|
||||
stream_src_finish(ss->src, rc == NCM_OK ? 0 : rc);
|
||||
if (rc == NCM_OK && ss->w && !g_app_quit) {
|
||||
int trc = ncm_tag_embed_file(ncm_cache_writer_tmp_path(ss->w), ss->ext, &ss->tag);
|
||||
if (trc != NCM_OK)
|
||||
meow_log(MEOW_LOG_WARN, "play: tag embed skipped rc=%d ext=%s (raw stream kept)",
|
||||
trc, ss->ext);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -485,7 +499,9 @@ static void song_gov_cleanup(song_src_t *ss)
|
||||
meow_cachegov_drop_finished(ss->gov_final_path);
|
||||
}
|
||||
|
||||
/* 缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 音质档位 → "song_<id>_<level>.<ext>"。
|
||||
/* 请求缓存键(用户定案 NCM_CACHE_DESIGN §2.5):歌曲ID + 用户音质档位 → "song_<id>_<level>.<ext>"。
|
||||
* 只用于**查找**(命中下探由 ncm_cache_lookup 处理);**写入**的正式名用响应实际档
|
||||
* (M6:hires 请求被降级链落到 exhigh mp3 → 落盘为 song_<id>_exhigh.mp3,名实相符)。
|
||||
* 同歌同档命中不重复下载;换档=新条目,旧档留给 LRU 自然淘汰。
|
||||
* §20:键后缀用 ncm_api_level_key(sky 与 sky_c51 可区分)。 */
|
||||
static void audio_cache_key(char *out, size_t sz, const char *id, int level)
|
||||
@@ -493,19 +509,17 @@ static void audio_cache_key(char *out, size_t sz, const char *id, int level)
|
||||
snprintf(out, sz, "%s_%s", id, ncm_api_level_key(level));
|
||||
}
|
||||
|
||||
/* §档位保真(REPORT §NEW 任务2②):显示档以响应实际格式/码率为准——
|
||||
* - type=flac:采信降级链 eff(无损档链已保证 flac,br/采样由解码后定)。
|
||||
* - type=mp3:绝不显示 Hi-Res 及以上(Hi-Res 需 >16bit/高采样,mp3 达不到)。按响应 br
|
||||
* 参与判定:≤128k→标准;否则→极高 HQ(≤320k,仅降级链中间档「较高 192k」不进 UI,不上屏)。
|
||||
* - br 未知:钳 chain_eff 到 ≤EXHIGH(保守不夸大)。
|
||||
* 返回 UI 显示用档位(g_play.eff_level)。 */
|
||||
static int eff_level_for_resp(const ncm_play_url_t *u, int chain_eff, int want)
|
||||
/* 响应 → 实际档(写盘文件名 / 播放页显示 / 已缓存列表三处同源,core ncm_cache_actual_level):
|
||||
* 容器 type 权威;mp3 按响应 br(≤128k→标准,否则极高,永不高于极高);flac 取降级链
|
||||
* 生效档 eff(头参数下载后才能测,落盘名不夸大)。只降不升(min(请求档, 实际档))。
|
||||
* 返回实际档(g_play.eff_level 用)。 */
|
||||
static int actual_level_for_resp(const ncm_play_url_t *u, const char *ext, int chain_eff, int want)
|
||||
{
|
||||
if (u && ncm_streq(u->type, "flac")) return (chain_eff >= 0) ? chain_eff : want;
|
||||
int br = u ? u->br : 0;
|
||||
if (br > 0) return (br <= 128000) ? NCM_LEVEL_STANDARD : NCM_LEVEL_EXHIGH; /* 按实际码率 */
|
||||
int e = (chain_eff >= 0) ? chain_eff : want;
|
||||
return (e < NCM_LEVEL_EXHIGH) ? e : NCM_LEVEL_EXHIGH; /* 未知:钳到 ≤EXHIGH */
|
||||
ncm_audio_info_t ai;
|
||||
memset(&ai, 0, sizeof ai);
|
||||
ncm_strlcpy(ai.container, ext ? ext : "", sizeof ai.container);
|
||||
if (u && u->br > 0 && ncm_streq(ai.container, "mp3")) ai.bitrate_kbps = u->br / 1000;
|
||||
return ncm_cache_actual_level(want, &ai, chain_eff);
|
||||
}
|
||||
|
||||
/* 同曲重播护栏(§25 切音质后重播同一曲,替代 §18 的 still_current):仅 cur/state 不够——
|
||||
@@ -526,7 +540,7 @@ static int still_intent(int idx, int my_seq)
|
||||
* 返回:NCM_OK=src 就绪(下载在后台继续);DL_RETRYABLE=预缓冲前网络失败(调用方整首重试);
|
||||
* DL_ABORTED=切歌/退出(静默放弃,不重试);其它负数=硬错误(不重试)。
|
||||
* *real_err 回填预缓冲失败的真实错误码。失败时 ss->w/src/dl_thr 已清理干净。 */
|
||||
static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const char *key, int want,
|
||||
static int song_download_attempt(const ncm_song_t *s, int idx, int seq, int want,
|
||||
song_src_t *ss, char *path, size_t psz, int64_t *bytes,
|
||||
int *real_err)
|
||||
{
|
||||
@@ -539,14 +553,26 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
|
||||
account_vip(), s->fee, want, &u, &eff);
|
||||
if (rc != NCM_OK) return rc;
|
||||
if (!u.url[0]) return NCM_ERR_NOTFOUND;
|
||||
pthread_mutex_lock(&g_play.mu);
|
||||
g_play.eff_level = eff_level_for_resp(&u, eff, want); /* §2②:显示档按实际格式/码率 */
|
||||
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
|
||||
pthread_mutex_unlock(&g_play.mu);
|
||||
/* 落盘扩展名:type 权威;type 缺失时看 url 扩展名,默认 mp3 */
|
||||
const char *ext = "mp3";
|
||||
if (ncm_streq(u.type, "flac")) ext = "flac";
|
||||
else if (!u.type[0] && strstr(u.url, ".flac")) ext = "flac";
|
||||
ncm_strlcpy(ss->ext, ext, sizeof ss->ext);
|
||||
/* M6:写盘名用**响应实际档**(不是请求档)。hires 请求被降级链落到 exhigh mp3
|
||||
* → song_<id>_exhigh.mp3,文件名与内容相符;同档再播按实际档键直接命中。
|
||||
* .part 续传也用同一实际档键(同名才可续传/commit)。 */
|
||||
int actual = actual_level_for_resp(&u, ext, eff, want);
|
||||
char akey[64];
|
||||
audio_cache_key(akey, sizeof akey, id, actual);
|
||||
pthread_mutex_lock(&g_play.mu);
|
||||
g_play.eff_level = actual; /* 显示档 = 实际档(与文件名/列表同源) */
|
||||
g_play.dl_total = (u.size > 0) ? u.size : 0; /* 缓冲进度% 分母(0=未知只转菊花) */
|
||||
pthread_mutex_unlock(&g_play.mu);
|
||||
/* 文件内 tag 字段(下载完整后由 dl_thread 写入;空字段不写)。 */
|
||||
memset(&ss->tag, 0, sizeof ss->tag);
|
||||
ncm_strlcpy(ss->tag.title, s->name, sizeof ss->tag.title);
|
||||
ncm_strlcpy(ss->tag.artist, s->art_name, sizeof ss->tag.artist);
|
||||
ncm_strlcpy(ss->tag.album, s->album_name, sizeof ss->tag.album);
|
||||
|
||||
/* 写前检查:池大小超限先按 LRU 淘汰(半成品 .tmp/.part 已被 evict 跳过,无在途误删风险) */
|
||||
ncm_cache_evict(&g_st.ctx.cache);
|
||||
@@ -554,17 +580,17 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
|
||||
/* 断点续传起点(§5.2):.part 残体存在且本次 URL 的 size > 残体大小 → 续传:
|
||||
* 追加打开 .part(O_APPEND,保留已写字节),written 从 part_size 起算(读端秒开播)。 */
|
||||
uint64_t part_size = 0;
|
||||
int have_part = (ncm_cache_part_size(&g_st.ctx.cache, key, ext, &part_size) == NCM_OK) &&
|
||||
int have_part = (ncm_cache_part_size(&g_st.ctx.cache, akey, ext, &part_size) == NCM_OK) &&
|
||||
part_size > 0;
|
||||
int resuming = 0;
|
||||
if (have_part && (long)part_size < u.size) {
|
||||
rc = ncm_cache_open_resume(&g_st.ctx.cache, key, ext, &ss->w);
|
||||
rc = ncm_cache_open_resume(&g_st.ctx.cache, akey, ext, &ss->w);
|
||||
if (rc == NCM_OK) { ss->resume_base = part_size; resuming = 1; }
|
||||
else have_part = 0; /* 残体打开失败:转整首 */
|
||||
}
|
||||
if (!resuming) {
|
||||
if (have_part) ncm_cache_part_remove(&g_st.ctx.cache, key, ext); /* 残体过期(size 不符):删 */
|
||||
rc = ncm_cache_open(&g_st.ctx.cache, key, ext, &ss->w);
|
||||
if (have_part) ncm_cache_part_remove(&g_st.ctx.cache, akey, ext); /* 残体过期(size 不符):删 */
|
||||
rc = ncm_cache_open(&g_st.ctx.cache, akey, ext, &ss->w);
|
||||
if (rc != NCM_OK) return rc;
|
||||
}
|
||||
if (stream_src_open_stream_at(&ss->src, ncm_cache_writer_tmp_path(ss->w),
|
||||
@@ -576,8 +602,8 @@ static int song_download_attempt(const ncm_song_t *s, int idx, int seq, const ch
|
||||
ss->total = (u.size > 0) ? u.size : 0;
|
||||
/* §NEW 任务1(SIGSEGV 定位):下载链路诊断日志——起播即留档,断流/崩溃后从 log 回溯。 */
|
||||
meow_log(MEOW_LOG_INFO,
|
||||
"play: dl start id=%s lv=%s eff=%s size=%lld resume=%s url=%.56s",
|
||||
id, ncm_api_level_key(want), ncm_api_level_key(eff >= 0 ? eff : want),
|
||||
"play: dl start id=%s req=%s actual=%s size=%lld resume=%s url=%.56s",
|
||||
id, ncm_api_level_key(want), ncm_api_level_key(actual),
|
||||
(long long)u.size, resuming ? "yes" : "no", u.url);
|
||||
/* 上限:Hi-Res flac 单曲可达数百 MB,flac 上限 1GB;mp3 维持 64MB。
|
||||
* size 可信时按 size 限幅(防超额),未知按格式默认上限(续传 body ≤ size 亦安全)。 */
|
||||
@@ -645,74 +671,60 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
|
||||
char key[64];
|
||||
audio_cache_key(key, sizeof key, id, g_level);
|
||||
|
||||
/* 缓存命中 → 静态 stream_src(新键两扩展名都试;档位=用户设定档,降级产物也归该档键) */
|
||||
/* 已缓存列表点播(NCM_QUEUE_CACHE):用条目**真实键**(id+文件名档位)与实际扩展名直接
|
||||
* 命中,不走用户当前档键——否则别的档位/降级产物条目会 miss 而被重下(与「点缓存就该放缓存」
|
||||
* 相悖,也会白耗流量)。条目已被删/被 LRU 淘汰 → NOENT(不重下,UI 报错)。 */
|
||||
if (appstate_get_queue() == NCM_QUEUE_CACHE) {
|
||||
ncm_cache_item_t it;
|
||||
if (appstate_cache_item(idx, &it) == NCM_OK) {
|
||||
char ck[48];
|
||||
if (it.level[0]) snprintf(ck, sizeof ck, "%s_%s", it.id, it.level);
|
||||
else ncm_strlcpy(ck, it.id, sizeof ck);
|
||||
if (ncm_cache_lookup(&g_st.ctx.cache, ck, it.ext, path, psz) == NCM_OK) {
|
||||
struct stat st;
|
||||
if (stat(path, &st) == 0 &&
|
||||
stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) == 0) {
|
||||
ss->total = (int64_t)st.st_size;
|
||||
*bytes = ss->total;
|
||||
pthread_mutex_lock(&g_play.mu);
|
||||
g_play.eff_level = it.actual_level; /* 实际档(非请求档),UI 标注用 */
|
||||
pthread_mutex_unlock(&g_play.mu);
|
||||
return NCM_OK;
|
||||
}
|
||||
}
|
||||
}
|
||||
return NCM_ERR_NOENT;
|
||||
}
|
||||
|
||||
/* 缓存命中 → 静态 stream_src。查找顺序(档位下探在 ncm_cache_lookup 内):
|
||||
* ① 请求档精确(flac 优先于 mp3)
|
||||
* ② 请求档以下逐档下探——缓存名已按**实际档**命名,hires 请求命中已存的
|
||||
* song_<id>_exhigh.mp3(降级产物不重下)
|
||||
* ③ 历史无档位键 song_<id>.mp3 / 双点名(老卡存量:读可用,**不强制迁移**) */
|
||||
int hit = 0;
|
||||
if (ncm_cache_lookup(&g_st.ctx.cache, key, "flac", path, psz) == NCM_OK ||
|
||||
ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, psz) == NCM_OK) {
|
||||
ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, psz) == NCM_OK ||
|
||||
ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
|
||||
hit = 1;
|
||||
} else if (ncm_cache_lookup(&g_st.ctx.cache, id, "mp3", path, psz) == NCM_OK) {
|
||||
/* §19 前存量:无档位后缀的 song_<id>[.]mp3(含历史双点名 song_<id>..mp3)。
|
||||
* §NEW 任务5:命中即**迁移重命名**到当前档位键 song_<id>_<level>.mp3(下方 hit==2
|
||||
* 分支做 rename,不重下),之后不再回退旧键。 */
|
||||
hit = 2;
|
||||
}
|
||||
if (hit) {
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0) return NCM_ERR_NOENT;
|
||||
if (hit == 2) {
|
||||
/* §NEW 任务5:存量旧键(无档位后缀 song_<id>.mp3 / 历史双点 song_<id>..mp3)
|
||||
* 迁移重命名到当前档位键 song_<id>_<g_level>.<ext>(**不重下**,仅改名)。
|
||||
* 目标已存在(该档已有正式条目)则删旧;迁移失败回退旧路径照常播。 */
|
||||
char nk[64], np[512];
|
||||
audio_cache_key(nk, sizeof nk, id, g_level);
|
||||
const char *dot = strrchr(path, '.');
|
||||
const char *ext = dot ? dot + 1 : "mp3";
|
||||
if (ncm_cache_path(&g_st.ctx.cache, nk, ext, np, sizeof np) == NCM_OK) {
|
||||
struct stat nst;
|
||||
if (stat(np, &nst) == 0) unlink(path); /* 目标已在(或并发建成):删旧双点/旧名 */
|
||||
else rename(path, np);
|
||||
ncm_strlcpy(path, np, psz); /* 迁移后即用新路径(无论 rename 成败,
|
||||
* 覆盖到同一目标;失败则下次会再试) */
|
||||
if (stat(np, &st) != 0) return NCM_ERR_NOENT;
|
||||
}
|
||||
}
|
||||
/* §NEW 任务2:静默降级时代产物修复(用户实锤:hires 档键下存着 mp3,播放命中旧档)。
|
||||
* 用户档 > EXHIGH(无损/更高)且缓存实体是 .mp3 = flac 门禁前把 exhigh 的 mp3 写进了
|
||||
* 无损域键(当年降级产物归用户档键)。迁移重命名到 exhigh 键 song_<id>_exhigh.mp3
|
||||
* (选「极高」档时仍可用;目标已存在则删旧),然后本档视为未命中走下载——
|
||||
* 311 的 flac 门禁会拿到真无损或正确降级。放在 hit==2 迁移块之后、静态流打开之前。 */
|
||||
const char *mdot = strrchr(path, '.');
|
||||
if (g_level > NCM_LEVEL_EXHIGH && mdot && ncm_streq(mdot, ".mp3")) {
|
||||
char kh[64], ph[512];
|
||||
audio_cache_key(kh, sizeof kh, id, NCM_LEVEL_EXHIGH);
|
||||
if (ncm_cache_path(&g_st.ctx.cache, kh, "mp3", ph, sizeof ph) == NCM_OK) {
|
||||
struct stat hst;
|
||||
if (stat(ph, &hst) == 0) unlink(path); /* exhigh 目标已在:删旧(降级产物) */
|
||||
else rename(path, ph);
|
||||
meow_log(MEOW_LOG_INFO,
|
||||
"play: stale-degraded id=%s lv=%s entity=mp3 -> exhigh key; redownload",
|
||||
id, ncm_api_level_key(g_level));
|
||||
hit = 0; /* 本档不符 → 走下载(flac 门禁取真档) */
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
if (stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) != 0)
|
||||
return NCM_ERR_IO;
|
||||
ss->total = (int64_t)st.st_size;
|
||||
*bytes = ss->total;
|
||||
/* 生效档推断(缓存键=用户档,降级产物归用户档键,无法精确还原):
|
||||
* mp3 实体 + 用户档 > 极高 → 必为降级产物,标 EXHIGH;其余按用户档显示。
|
||||
* (flac 实体视为与用户档一致;hires→lossless 档内降级会高估显示,已知取舍。)
|
||||
* 注:mp3 + 用户档 > 极高 的组合已被上方迁移块拦截走下载,此处仅存防御。 */
|
||||
const char *dot = strrchr(path, '.');
|
||||
int eff = g_level;
|
||||
if (dot && ncm_streq(dot, ".mp3") && g_level > NCM_LEVEL_EXHIGH)
|
||||
eff = NCM_LEVEL_EXHIGH;
|
||||
pthread_mutex_lock(&g_play.mu);
|
||||
g_play.eff_level = eff;
|
||||
pthread_mutex_unlock(&g_play.mu);
|
||||
return NCM_OK;
|
||||
}
|
||||
/* 生效/显示档:新命名文件名即实际档;旧命名(请求档)向下钳——mp3 实体不可能高于
|
||||
* 极高,故旧 song_<id>_hires.mp3 标注 EXHIGH;旧无档位键条目按用户档同规则钳。
|
||||
* 只改显示不改文件名(老卡文件继续可播/可删/可淘汰,免迁移)。 */
|
||||
const char *dot = strrchr(path, '.');
|
||||
int eff = ncm_cache_level_from_path(path);
|
||||
if (eff < 0) eff = g_level;
|
||||
if (dot && ncm_streq(dot, ".mp3") && eff > NCM_LEVEL_EXHIGH) eff = NCM_LEVEL_EXHIGH;
|
||||
if (stream_src_open_static(&ss->src, path, (uint64_t)st.st_size) != 0)
|
||||
return NCM_ERR_IO;
|
||||
ss->total = (int64_t)st.st_size;
|
||||
*bytes = ss->total;
|
||||
pthread_mutex_lock(&g_play.mu);
|
||||
g_play.eff_level = eff;
|
||||
pthread_mutex_unlock(&g_play.mu);
|
||||
return NCM_OK;
|
||||
}
|
||||
|
||||
/* fee 门禁:VIP 才能听(1=VIP 歌曲 / 4=付费数字专辑)且非会员 → 不请求 url。
|
||||
@@ -736,7 +748,7 @@ static int song_local_file(const ncm_song_t *s, int idx, int seq, song_src_t *ss
|
||||
if (g_app_quit || !still_intent(idx, seq)) return NCM_ERR_IO;
|
||||
}
|
||||
int real_err = NCM_ERR_NET;
|
||||
int rc = song_download_attempt(s, idx, seq, key, want, ss, path, psz, bytes, &real_err);
|
||||
int rc = song_download_attempt(s, idx, seq, want, ss, path, psz, bytes, &real_err);
|
||||
if (rc == NCM_OK) return NCM_OK;
|
||||
if (rc == DL_RETRYABLE) { last_rc = real_err; continue; } /* 重试整首 */
|
||||
return rc; /* DL_ABORTED / 硬错误:不重试 */
|
||||
@@ -1032,10 +1044,18 @@ static int g_lyr_thr_on;
|
||||
static volatile int g_lyr_state; /* 0=空闲/未拉 1=加载中 2=已拉完 */
|
||||
static char g_lyr_song_id[24]; /* 歌词归属歌曲 id */
|
||||
|
||||
/* 歌词拉取失败后的重试退避(ms,指数翻倍封顶 OS):掉线瞬间失败的请求**不**写死「无歌词」,
|
||||
* 交回退避重试;网络恢复后下一轮到点即重拉成功(真机症状:掉线一会儿再重连后恒「暂无歌词」)。 */
|
||||
#define LYRIC_RETRY_BASE_MS 1000
|
||||
#define LYRIC_RETRY_MAX_MS (30 * 1000)
|
||||
|
||||
static void *lyric_worker(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
char last_id[24] = "";
|
||||
int last_ok = 1; /* last_id 对应结果是否成功(成功不重拉;失败记退避待重试) */
|
||||
int retry_delay = 0; /* 失败后逐次翻倍的退避基值(ms) */
|
||||
int64_t retry_at = 0; /* 单调时刻:下次重试;0=无待重试 */
|
||||
while (!g_app_quit) {
|
||||
usleep(300 * 1000);
|
||||
char id[24];
|
||||
@@ -1051,14 +1071,28 @@ static void *lyric_worker(void *arg)
|
||||
g_lyr_state = 0;
|
||||
pthread_mutex_unlock(&g_lyrmu);
|
||||
last_id[0] = 0;
|
||||
last_ok = 1;
|
||||
retry_delay = 0;
|
||||
retry_at = 0;
|
||||
continue;
|
||||
}
|
||||
if (!id[0]) continue;
|
||||
if (strcmp(id, last_id) == 0) continue; /* 同一曲已拉/在拉 */
|
||||
if (meow_cachegov_prefetch_paused()) continue; /* page-cache 治理(§2.3/§3):暂停歌词预取
|
||||
* (A 回退硬暂停 OR svc_perf C Cached 降载),
|
||||
* 不置 last_id → 降载恢复后下轮再拉。 */
|
||||
ncm_strlcpy(last_id, id, sizeof last_id);
|
||||
|
||||
int is_new = (strcmp(id, last_id) != 0);
|
||||
if (is_new) {
|
||||
if (meow_cachegov_prefetch_paused()) continue; /* page-cache 治理(§2.3/§3):暂停歌词预取
|
||||
* (A 回退硬暂停 OR svc_perf C Cached 降载),
|
||||
* 不置 last_id → 降载恢复后下轮再拉。 */
|
||||
ncm_strlcpy(last_id, id, sizeof last_id);
|
||||
last_ok = 0;
|
||||
retry_delay = 0;
|
||||
retry_at = 0;
|
||||
} else {
|
||||
if (last_ok) continue; /* 同一曲已成功拉完(含 NCM_OK 空词=真无词),不重拉 */
|
||||
if (meow_cachegov_prefetch_paused()) continue;
|
||||
if (retry_at > ncm_monotonic_ms()) continue; /* 失败退避未到 */
|
||||
/* 到点重试:保留 retry_delay 以便失败时继续翻倍 */
|
||||
}
|
||||
lyric_clear();
|
||||
pthread_mutex_lock(&g_lyrmu);
|
||||
g_lyr_state = 1; /* 加载中 */
|
||||
@@ -1068,22 +1102,32 @@ static void *lyric_worker(void *arg)
|
||||
char *tl = (char *)malloc(32 * 1024);
|
||||
if (!lrc || !tl) {
|
||||
free(lrc); free(tl);
|
||||
retry_delay = retry_delay ? (retry_delay * 2) : LYRIC_RETRY_BASE_MS;
|
||||
if (retry_delay > LYRIC_RETRY_MAX_MS) retry_delay = LYRIC_RETRY_MAX_MS;
|
||||
retry_at = ncm_monotonic_ms() + retry_delay;
|
||||
pthread_mutex_lock(&g_lyrmu); g_lyr_state = 0; pthread_mutex_unlock(&g_lyrmu);
|
||||
continue;
|
||||
}
|
||||
lrc[0] = tl[0] = 0; /* 默认空串(fetch 失败/空词时上报清词,防会话留旧曲歌词) */
|
||||
int rc = ncm_api_lyric(g_st.ctx.device_id, g_st.ctx.cookie, id, lrc, 64 * 1024, tl, 32 * 1024);
|
||||
if (rc == NCM_OK) {
|
||||
last_ok = 1; /* 真正结果:空词(NCM_OK 无词也算「已拉完」) */
|
||||
retry_delay = 0;
|
||||
retry_at = 0;
|
||||
lyric_parse(lrc, tl);
|
||||
ipc_media_lyric_report(lrc, tl); /* 批3:上报 live 歌词到媒体会话 */
|
||||
} else {
|
||||
/* 拉取失败(网络/HTTP/会话):不写死「无词」,交回退避重试;UI 呈「加载中」而非「暂无歌词」 */
|
||||
retry_delay = retry_delay ? (retry_delay * 2) : LYRIC_RETRY_BASE_MS;
|
||||
if (retry_delay > LYRIC_RETRY_MAX_MS) retry_delay = LYRIC_RETRY_MAX_MS;
|
||||
retry_at = ncm_monotonic_ms() + retry_delay;
|
||||
ipc_media_lyric_report("", ""); /* 无词/拉取失败:清 media session 该曲歌词 */
|
||||
}
|
||||
free(lrc); free(tl);
|
||||
|
||||
pthread_mutex_lock(&g_lyrmu);
|
||||
ncm_strlcpy(g_lyr_song_id, id, sizeof g_lyr_song_id);
|
||||
g_lyr_state = 2; /* 拉完(可能 0 行=无词) */
|
||||
g_lyr_state = last_ok ? 2 : 0; /* 2=已拉完(可 0 行=无词);0=失败待重试(UI 呈加载中) */
|
||||
pthread_mutex_unlock(&g_lyrmu);
|
||||
}
|
||||
return NULL;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/* app/test/test_first_row_title_render.c — 宿主渲染回归(M12):列表首行标题必须真正画出来。
|
||||
*
|
||||
* 目的:在宿主上用**真实 SDK listview 双行渲染**复现/验证真机根因,而不只是验几何公式。
|
||||
* 真机字体(source_han_serif 20px,font.idx: ascent 24 + descent 6)行高 = 30,
|
||||
* 双行文本块 = 2*30 + 2 = 62。旧固定行高 56 < 62 → SDK 的 2 行分支
|
||||
* ty = 行顶 + (item - block)/2 = 行顶 - 3
|
||||
* 而标题只在 `ty >= 视口顶` 时才画 → 第一行(行顶 == 视口顶)标题被整条跳过、副标题仍在。
|
||||
*
|
||||
* 宿主 5x7 字体的行高是 14(gfx 回退),照原样渲染永远复现不了。故用链接期
|
||||
* `-Wl,--wrap=meow_ui_text_height` 把行高替换成真机实测的 30,驱动同一份 SDK 代码:
|
||||
* A 旧行高 56 → 首行标题带无文字像素(bug 形态)
|
||||
* B ncm_row_h_for_line_h(30) = 70 → 首行标题带/副标题带都有文字像素(修复形态)
|
||||
* 断言直接读回 MEOW_UI_DUMP 写出的 PPM(按像素计数)。
|
||||
*
|
||||
* 编译/运行(先跑 build_ncm_host.sh 产出 out/host/sdk_*.o):
|
||||
* gcc -O2 -Wall -Wextra -DMEOW_UI_HOST_TEST -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
|
||||
* -Icore -Ivendor/jsmn -Ivendor/mbedtls/include -Ivendor/minimp3 -Ivendor/dr_flac \
|
||||
* app/test/test_first_row_title_render.c out/host/sdk_*.o \
|
||||
* -Wl,--wrap=meow_ui_text_height -lz -lpthread -lm -o test_first_row_title_render
|
||||
* ./test_first_row_title_render # 产物落 $TMPDIR/first_row_*.ppm
|
||||
*
|
||||
* 断言:
|
||||
* ① 行高替换生效(meow_ui_text_height(20) == 30);
|
||||
* ② A:首行标题带零文字像素、副标题带有 → 复现「只有副标题」;
|
||||
* ③ B:首行标题带与副标题带都有像素 → 修复后首行标题正常;
|
||||
* ④ A/B 副标题带像素数都不为 0(副标题路径未受影响)。
|
||||
*/
|
||||
#include "../ncm_app_internal.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* --wrap 的替换实现:真机 source_han_serif 20px 实测行高(font.idx ascent+descent)。 */
|
||||
int __wrap_meow_ui_text_height(int size)
|
||||
{
|
||||
(void)size;
|
||||
return 30;
|
||||
}
|
||||
|
||||
#define LV_X 16
|
||||
#define LV_Y 40
|
||||
#define LV_W 448
|
||||
#define LV_H 300
|
||||
|
||||
static int src2(int idx, meow_list_item *out, void *ctx)
|
||||
{
|
||||
(void)ctx;
|
||||
static char title[32], sub[32];
|
||||
if (idx < 0 || idx >= 5) return 0;
|
||||
snprintf(title, sizeof title, "T%d", idx + 1);
|
||||
snprintf(sub, sizeof sub, "S%d", idx + 1);
|
||||
memset(out, 0, sizeof *out);
|
||||
out->icon_kind = MEOW_LIST_ICON_NONE;
|
||||
out->title = title;
|
||||
out->subtitle = sub;
|
||||
out->right = MEOW_LIST_RIGHT_NONE;
|
||||
out->enabled = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* ---- 极简 PPM(P6) 读取 + 区域文字像素计数 ---- */
|
||||
static unsigned char *ppm_load(const char *path, int *w, int *h)
|
||||
{
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) { fprintf(stderr, "open %s failed\n", path); return NULL; }
|
||||
char magic[3] = {0};
|
||||
int maxv = 0;
|
||||
if (fscanf(f, "%2s %d %d %d", magic, w, h, &maxv) != 4 ||
|
||||
strcmp(magic, "P6") != 0 || *w <= 0 || *h <= 0) {
|
||||
fclose(f); fprintf(stderr, "bad ppm %s\n", path); return NULL;
|
||||
}
|
||||
if (fgetc(f) < 0) { fclose(f); fprintf(stderr, "truncated ppm %s\n", path); return NULL; } /* 单个空白 */
|
||||
size_t n = (size_t)*w * (size_t)*h * 3u;
|
||||
unsigned char *buf = (unsigned char *)malloc(n);
|
||||
if (!buf || fread(buf, 1, n, f) != n) { free(buf); fclose(f); return NULL; }
|
||||
fclose(f);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* 区域内「与页底色不同」的像素数。页底色取 (470,700)(远离列表控件)。 */
|
||||
static int ink_count(const unsigned char *img, int w, int x0, int y0, int x1, int y1)
|
||||
{
|
||||
const unsigned char *bg = img + ((size_t)700 * (size_t)w + 470u) * 3u;
|
||||
int n = 0;
|
||||
for (int y = y0; y < y1; y++)
|
||||
for (int x = x0; x < x1; x++) {
|
||||
const unsigned char *p = img + ((size_t)y * (size_t)w + (size_t)x) * 3u;
|
||||
int d = abs((int)p[0] - (int)bg[0]) + abs((int)p[1] - (int)bg[1]) +
|
||||
abs((int)p[2] - (int)bg[2]);
|
||||
if (d > 24) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 造一个双行列表(容器=关、无图标/右尾件)并整帧绘制(paint_full 内部 flush → dump PPM)。 */
|
||||
static void render_once(int item)
|
||||
{
|
||||
meow_widget *lv = meow_listview_create(LV_X, LV_Y, LV_W, LV_H, 5,
|
||||
src2, NULL, MEOW_LIST_SEL_NONE);
|
||||
meow_listview_set_container(lv, 0);
|
||||
meow_listview_set_item_renderer(lv, 2, 0, 0);
|
||||
meow_listview_set_item_h(lv, item);
|
||||
meow_listview_set_count(lv, 5);
|
||||
meow_ui_set_page_bg(meow_theme_get()->bg);
|
||||
meow_ui_paint_full(); /* 画 + meow_ui_flush → MEOW_UI_DUMP 落 PPM */
|
||||
meow_widget_free(lv);
|
||||
}
|
||||
|
||||
static char g_dump[256];
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const char *td = getenv("TMPDIR");
|
||||
if (!td || !td[0]) td = "/tmp";
|
||||
snprintf(g_dump, sizeof g_dump, "%s/first_row.ppm", td);
|
||||
setenv("MEOW_UI_DUMP", g_dump, 1);
|
||||
|
||||
meow_ui_opts o = { APP_W, APP_H };
|
||||
if (meow_ui_init(&o) != 0) { fprintf(stderr, "init failed\n"); return 1; }
|
||||
meow_theme_set_mode(MEOW_THEME_DARK);
|
||||
|
||||
int fail = 0;
|
||||
if (meow_ui_text_height(20) != 30) {
|
||||
printf(" FAIL 行高替换未生效(expected 30,got %d)\n", meow_ui_text_height(20));
|
||||
fail = 1;
|
||||
}
|
||||
|
||||
const int lh = 30;
|
||||
const int block = 2 * lh + NCM_ROW_SUB_GAP; /* 62 */
|
||||
const int fixed = ncm_row_h_for_line_h(lh); /* 70 */
|
||||
const int ty_legacy = (APP_ROW_H - block) / 2; /* -3 */
|
||||
const int ty_fixed = (fixed - block) / 2; /* +4 */
|
||||
|
||||
int n_title_a = 0, n_sub_a = 0, n_title_b = 0, n_sub_b = 0;
|
||||
|
||||
/* A:旧固定行高 56(bug 形态)。标题带取「文本行顶 ∩ 控件视口」(ty<0 时从 0 起),
|
||||
* 这样「被裁剪守卫整条跳过」与「只画出上半截」也能区分开。 */
|
||||
render_once(APP_ROW_H);
|
||||
{
|
||||
int pw = 0, ph = 0;
|
||||
unsigned char *img = ppm_load(g_dump, &pw, &ph);
|
||||
if (!img) return 1;
|
||||
int ty0 = (ty_legacy > 0) ? ty_legacy : 0;
|
||||
n_title_a = ink_count(img, pw, LV_X + 16, LV_Y + ty0,
|
||||
LV_X + 16 + 40, LV_Y + ty_legacy + lh);
|
||||
n_sub_a = ink_count(img, pw, LV_X + 16, LV_Y + ty_legacy + lh + NCM_ROW_SUB_GAP,
|
||||
LV_X + 16 + 40, LV_Y + ty_legacy + 2 * lh + NCM_ROW_SUB_GAP);
|
||||
free(img);
|
||||
}
|
||||
|
||||
/* B:修复后的行高(ncm_row_h_for_line_h) */
|
||||
render_once(fixed);
|
||||
{
|
||||
int pw = 0, ph = 0;
|
||||
unsigned char *img = ppm_load(g_dump, &pw, &ph);
|
||||
if (!img) return 1;
|
||||
n_title_b = ink_count(img, pw, LV_X + 16, LV_Y + ty_fixed,
|
||||
LV_X + 16 + 40, LV_Y + ty_fixed + lh);
|
||||
n_sub_b = ink_count(img, pw, LV_X + 16, LV_Y + ty_fixed + lh + NCM_ROW_SUB_GAP,
|
||||
LV_X + 16 + 40, LV_Y + ty_fixed + 2 * lh + NCM_ROW_SUB_GAP);
|
||||
free(img);
|
||||
}
|
||||
|
||||
printf("legacy item=%d ty=%d: title_ink=%d sub_ink=%d\n", APP_ROW_H, ty_legacy, n_title_a, n_sub_a);
|
||||
printf("fixed item=%d ty=%d: title_ink=%d sub_ink=%d\n", fixed, ty_fixed, n_title_b, n_sub_b);
|
||||
|
||||
if (!(n_title_a == 0 && n_sub_a > 0)) fail = 1; /* ② 旧行高首行标题被跳过(bug 复现) */
|
||||
if (!(n_title_b > 0 && n_sub_b > 0)) fail = 1; /* ③ 修复后首行标题/副标题都在 */
|
||||
|
||||
printf("%s: first-row title render (legacy skipped=%d, fixed drawn=%d)\n",
|
||||
fail ? "FAIL" : "PASS", n_title_a == 0, n_title_b > 0);
|
||||
return fail ? 1 : 0;
|
||||
}
|
||||
@@ -9,10 +9,12 @@
|
||||
* test_meow_row_gap.c -o test_meow_row_gap && ./test_meow_row_gap
|
||||
*
|
||||
* 断言集(对应「相邻 item 顶之间 = item 高 + gap」的可见形式):
|
||||
* ① 相邻卡顶间距 = APP_ROW_H(= 卡高 + gap;SDK 行距不变);
|
||||
* ① 相邻卡顶间距 = item 高(= 卡高 + gap;SDK 行距不变);
|
||||
* ② 卡片间可见外距 = NCM_ROW_GAP;
|
||||
* ③ 卡在其行高内上下对称内缩 → SDK 文本(按 APP_ROW_H 垂直居中)落卡中心;
|
||||
* ③ 卡在其行高内上下对称内缩 → SDK 文本(按行高垂直居中)落卡中心;
|
||||
* ④ 滚动偏移只整体平移,不改变上述间距/对称。
|
||||
* 行高在这里固定用 APP_ROW_H(几何对任意 item 成立;M12 起真机行高由 ncm_row_h() 按字体
|
||||
* 行高派生,文本块必须装得下,见 test_meow_row_text_fit.c)。
|
||||
*/
|
||||
#include "../ncm_app_internal.h" /* APP_ROW_H / NCM_ROW_GAP / ncm_row_card_y / ncm_row_card_h */
|
||||
#include <stdio.h>
|
||||
@@ -20,7 +22,7 @@
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const int item = APP_ROW_H; /* 行距(SDK listview item_h,不变) */
|
||||
const int item = APP_ROW_H; /* 几何取固定行高(对任意 item 成立) */
|
||||
const int gap = NCM_ROW_GAP;
|
||||
const int list_y = 100;
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* app/test/test_meow_row_text_fit.c — 宿主回归(M12):两行列表行高必须容纳行文本块。
|
||||
*
|
||||
* 背景(真机根因):SDK meow_listview 的双行渲染(sdk/meow_ui_widget.c 的 2 行分支)把
|
||||
* block = 2 * meow_ui_text_height(font_body) + 2 (标题行 + 2px 行距 + 副标题行)
|
||||
* 在行内垂直居中:ty = 行顶 + (item - block) / 2;且标题只在 `ty >= 视口顶` 时才画(越界钳制)。
|
||||
* 行高 item < block 时 ty < 行顶 → **第一行(行顶 == 视口顶)标题被整条跳过**、副标题仍在,
|
||||
* 即用户报的「歌单列表/歌曲列表第 1 行只有副标题,名字空白」。
|
||||
* 真机字库实测(source_han_serif 20px:ascent 24 + descent 6 → 行高 30)block = 62,旧固定
|
||||
* 行高 56 → ty = 行顶 - 3。
|
||||
*
|
||||
* 本测试只验 app 侧行高派生(ncm_row_h_for_line_h,纯整数、不链 SDK):
|
||||
* 行高 = max(APP_ROW_H, block + NCM_ROW_GAP)(偶数)→ 卡高 == 文本块,文本完整落在卡内。
|
||||
*
|
||||
* 编译/运行(同 test_meow_row_gap.c,需仓库根 ROOT 下的 SDK 头):
|
||||
* gcc -O2 -Wall -Wextra -I. -I../sdk -I../meow_launcherd -Icore \
|
||||
* -Ivendor/mbedtls/include -Ivendor/jsmn -Ivendor/qrcodegen/c \
|
||||
* -Ivendor/minimp3 -Ivendor/dr_flac \
|
||||
* test_meow_row_text_fit.c -o test_meow_row_text_fit && ./test_meow_row_text_fit
|
||||
*
|
||||
* 断言矩阵 = 真机已装字库 20px 面实测行高(font.idx 的 ascent+descent):
|
||||
* source_han_serif 30(当前生效)、misans/default 27、google_sans/meslo 26、
|
||||
* source_han_sans 21;14 = 宿主 5x7 回退。逐项断言:
|
||||
* ① block ≤ item(否则居中偏移为负)
|
||||
* ② 首行 ty ≥ 行顶(绝对:ty_abs ≥ 视口顶)→ 标题不被裁剪守卫跳过
|
||||
* ③ 副标题底边 sy + 行高 ≤ item(副标题不越行)
|
||||
* ④ 卡高(item - NCM_ROW_GAP)≥ block(两行文本全落在圆角卡内)
|
||||
* ⑤ item ≥ APP_ROW_H 下限且为偶数(卡上下对称内缩 gap/2)
|
||||
* ⑥ 旧固定行高 56 在行高 30 下 ty < 0(bug 形态复现,防止再写死 56)
|
||||
*/
|
||||
#include "../ncm_app_internal.h"
|
||||
#include <stdio.h>
|
||||
|
||||
static int g_checks, g_fail;
|
||||
|
||||
static void chk(int cond, int line_h, const char *what)
|
||||
{
|
||||
g_checks++;
|
||||
if (!cond) {
|
||||
g_fail++;
|
||||
printf(" FAIL lh=%d: %s\n", line_h, what);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
static const int lh_all[] = { 14, 21, 26, 27, 30 };
|
||||
const int n = (int)(sizeof lh_all / sizeof lh_all[0]);
|
||||
const int list_y = 120; /* 任意列表顶(只验相对几何) */
|
||||
|
||||
for (int k = 0; k < n; k++) {
|
||||
int lh = lh_all[k];
|
||||
int item = ncm_row_h_for_line_h(lh);
|
||||
int block = 2 * lh + NCM_ROW_SUB_GAP;
|
||||
int ty = (item - block) / 2; /* SDK 双行文本块居中偏移 */
|
||||
int sy = ty + lh + NCM_ROW_SUB_GAP; /* 副标题行顶 */
|
||||
int card_h = ncm_row_card_h(item);
|
||||
|
||||
printf("lh=%2d item=%3d block=%3d ty=%d sy=%d card_h=%3d\n",
|
||||
lh, item, block, ty, sy, card_h);
|
||||
|
||||
chk(block <= item, lh, "文本块必须 <= 行高(否则首行标题被视口顶裁剪)");
|
||||
chk(ty >= 0, lh, "首行标题 y 不得高于行顶/视口顶");
|
||||
chk(sy + lh <= item, lh, "副标题底边仍在行内");
|
||||
chk(card_h >= block, lh, "两行文本块必须完整落在圆角卡内");
|
||||
chk(item >= APP_ROW_H, lh, "行高不得小于 APP_ROW_H 下限");
|
||||
chk((item & 1) == 0, lh, "行高必须偶数(卡上下对称内缩 gap/2)");
|
||||
|
||||
/* 首行(scroll=0 → 行顶 == 视口顶)的绝对坐标 */
|
||||
int ty_abs = list_y + ty;
|
||||
chk(ty_abs >= list_y, lh, "首行标题绝对 y 不得被视口顶裁掉");
|
||||
chk(ty_abs + lh <= list_y + item, lh, "首行标题底边在行内");
|
||||
|
||||
/* 卡在行内对称 → 卡中心 == 行中心 */
|
||||
int cy = ncm_row_card_y(item, 0, 0, list_y);
|
||||
chk(cy + card_h / 2 == list_y + item / 2, lh, "卡中心 == 行中心");
|
||||
}
|
||||
|
||||
/* 行高随字体行高单调不减 */
|
||||
chk(ncm_row_h_for_line_h(30) >= ncm_row_h_for_line_h(26), 30, "行高随字体行高单调不减");
|
||||
|
||||
/* ⑥ 回归哨兵:旧固定行高 56 + 当前默认字体(行高 30)就是 bug 形态 */
|
||||
{
|
||||
const int lh = 30;
|
||||
int block = 2 * lh + NCM_ROW_SUB_GAP; /* 62 */
|
||||
int ty_legacy = (APP_ROW_H - block) / 2; /* -3 */
|
||||
chk(ty_legacy < 0, lh, "旧行高 56 + 行高 30 → ty<0(首行标题被跳过,bug 复现)");
|
||||
chk(ncm_row_h_for_line_h(lh) > APP_ROW_H, lh, "默认字体下行高必须大于旧值 56");
|
||||
}
|
||||
|
||||
printf("%s: %d checks, %d fail (font line-height matrix: %d)\n",
|
||||
g_fail ? "FAIL" : "PASS", g_checks, g_fail, n);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -72,6 +72,37 @@ static void mock_lyric(void)
|
||||
lyric_parse(lrc, tl);
|
||||
}
|
||||
|
||||
/* M5:已缓存列表页 mock —— 造 3 条 songs/ 缓存(mp3 帧 + 嵌入 ID3v2.3),再同步扫描进快照。 */
|
||||
static void mock_cache(void)
|
||||
{
|
||||
static const char *names[] = { "Song Cached One", "Song Cached Two", "Song Cached Three" };
|
||||
static const char *arts[] = { "Artist X", "Artist Y", "Artist Z" };
|
||||
static const char *lvks[] = { "exhigh", "hires", "lossless" };
|
||||
ncm_cache_init(&g_st.ctx.cache, g_st.cache_dir);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
char id[24], key[48];
|
||||
unsigned char fr[4096];
|
||||
ncm_cache_writer_t *w;
|
||||
snprintf(id, sizeof id, "%ld", 5000L + i);
|
||||
snprintf(key, sizeof key, "%s_%s", id, lvks[i]);
|
||||
for (size_t k = 0; k < sizeof fr; k++) fr[k] = (unsigned char)(0x20 + (k * 7));
|
||||
fr[0] = 0xFF; fr[1] = 0xFB; fr[2] = 0xE0; fr[3] = 0x00; /* 320kbps / 44.1kHz */
|
||||
if (ncm_cache_open(&g_st.ctx.cache, key, "mp3", &w) != NCM_OK) continue;
|
||||
ncm_cache_write(w, fr, sizeof fr);
|
||||
if (ncm_cache_commit(w) != NCM_OK) continue;
|
||||
char path[NCM_CACHE_DIR_MAX + 64];
|
||||
if (ncm_cache_lookup(&g_st.ctx.cache, key, "mp3", path, sizeof path) == NCM_OK) {
|
||||
ncm_tag_meta_t t;
|
||||
memset(&t, 0, sizeof t);
|
||||
snprintf(t.title, sizeof t.title, "%s", names[i]);
|
||||
snprintf(t.artist, sizeof t.artist, "%s", arts[i]);
|
||||
snprintf(t.album, sizeof t.album, "Album %d", i + 1);
|
||||
ncm_tag_embed_file(path, "mp3", &t);
|
||||
}
|
||||
}
|
||||
appstate_cache_list_load();
|
||||
}
|
||||
|
||||
static void mock_qr(void)
|
||||
{
|
||||
g_st.qr_size = 29;
|
||||
@@ -137,6 +168,7 @@ static int page_id(const char *name)
|
||||
if (strcmp(name, "searchtap") == 0) return ACT_SEARCH; /* §23 误触抑制自检 */
|
||||
if (strcmp(name, "modetest") == 0) return ACT_PLAYER; /* §NEW 播放模式三态逻辑自检 */
|
||||
if (strcmp(name, "queuetest") == 0) return ACT_QUEUE; /* §NEW 播放队列页/点播语义自检 */
|
||||
if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) return ACT_CACHE; /* M5 */
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -268,8 +300,7 @@ int main(int argc, char **argv)
|
||||
appstate_search_history_add("Jay \u5553\u8868");
|
||||
appstate_search_history_add("lofi beat");
|
||||
appstate_search_history_add("Jay \u5553\u8868"); /* 去重 → 仍 3 条 */
|
||||
} else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) {
|
||||
/* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */
|
||||
} else if (strcmp(name, "modetest") == 0 || strcmp(name, "queuetest") == 0) { /* §NEW 播放模式 / 播放队列自检:歌单滑窗队列(6 首)+ 播放态 */
|
||||
mock_playlists(); mock_songs();
|
||||
pthread_mutex_lock(&g_play.mu);
|
||||
g_play.state = NCM_PC_PLAYING;
|
||||
@@ -290,6 +321,8 @@ int main(int argc, char **argv)
|
||||
g_play.play_song = g_st.songs[0]; /* 播放快照(§17) */
|
||||
g_play.play_song_valid = 1;
|
||||
pthread_mutex_unlock(&g_play.mu);
|
||||
} else if (strcmp(name, "cache") == 0 || strcmp(name, "cachetest") == 0) {
|
||||
mock_cache(); /* M5:假缓存池 + 文件内 tag + 快照扫描 */
|
||||
} else {
|
||||
mock_playlists();
|
||||
}
|
||||
@@ -301,6 +334,7 @@ int main(int argc, char **argv)
|
||||
strcmp(name, "barhit") == 0 || strcmp(name, "covswitch") == 0) v = playlist_view_create();
|
||||
else if (page_id(name) == ACT_PLAYER) v = player_view_create(); /* player/lands/playerbuf/playererr/modetest */
|
||||
else if (page_id(name) == ACT_QUEUE) v = queue_view_create(); /* §NEW 播放队列页 */
|
||||
else if (page_id(name) == ACT_CACHE) v = cache_view_create(); /* M5 已缓存列表页 */
|
||||
else if (strcmp(name, "settings") == 0) v = settings_view_create();
|
||||
else if (strcmp(name, "search") == 0 || strcmp(name, "searchhist") == 0 ||
|
||||
strcmp(name, "searchpl") == 0 || strcmp(name, "searchtap") == 0) v = search_view_create();
|
||||
|
||||
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user