diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md index b167843..19cf79d 100644 --- a/patch_v2/meowpanel/README.md +++ b/patch_v2/meowpanel/README.md @@ -28,7 +28,7 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel - 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。 - 产出(本次构建实测): - `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`, - 体积约 **201 KB**(固件阈值内)。md5:`f310a00e11729162e6960f1dd7c85259`。 + 体积约 **206 KB**(固件阈值内)。md5:`24f53b40fd556f758e5eba3417a52c3c`(本次含通知 IPC 实时化 + 增量帧)。 - `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **146 KB**。 - 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自 `meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。 @@ -84,7 +84,7 @@ http://.local:8080/ | 方法 | 路径 | 说明 | 实现 | |---|---|---|---| | GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/` | 静态托管 | ✅ | -| GET | `/api/screen.jpeg` | 实时画面:fb0 当前帧 → RGB565→JPEG(`?q=1-100` 质量,默认 70) | ✅(阶段 C) | +| GET | `/api/screen.jpeg` | 实时画面:fb0 当前帧 → RGB565→JPEG(`?q=1-100` 质量,默认 70;`?delta=1` 开启增量帧,见 §5.10) | ✅(阶段 C) | | POST | `/api/input/tap` | 触摸注入:`{x,y}`(480×720 原生坐标;write 进 goodix event1,本机实测可投递);本机触摸后 5s 内忽略注入 | ✅(阶段 C) | | GET | `/api/auth/status` | 鉴权状态(enabled/会话有效) | ✅(公开) | | POST | `/api/auth/login` | 密码登录发 token(sha256 带盐校验) | ✅(公开;错→401) | @@ -116,9 +116,9 @@ http://.local:8080/ | POST | `/api/key` | 批量/顺序注入 | ✅ | | GET | `/api/logs` | `MeowOS/logs` 文件列表 | ✅(无卡→503) | | GET | `/api/logs/read?file=&lines=` | 读日志(上限 1000 行;`..`/绝对→403;敏感名→403) | ✅ | -| GET | `/api/notifications` | 通知列表(读 TF 转储日志;见 §5.8) | ✅(无源→空态 `source:none`,不 503) | -| DELETE | `/api/notifications/:id` | 单条删除(重写日志移除该 id) | ✅(不存在→404) | -| POST | `/api/notifications/dismiss_all`(别名 `/api/notifications/clear`) | 一键清空:**保留活动中的媒体/进度通知**,移除其余 | ✅ | +| GET | `/api/notifications` | 通知列表(**优先 launcher IPC 内存活跃集** `source:"ipc"`;IPC 不可达回退 TF 转储日志 `source:"tf"`;见 §5.8) | ✅(无源→空态 `source:none`,不 503) | +| DELETE | `/api/notifications/:id` | 单条删除(**优先 IPC 撤下 launcher 内存**;IPC 不可达回退重写日志移除该 id) | ✅(不存在→404) | +| POST | `/api/notifications/dismiss_all`(别名 `/api/notifications/clear`) | 一键清空:**优先 IPC 联动 launcher 内存**,保留活动中的媒体/进度通知;IPC 不可达回退重写日志 | ✅ | | GET | `/api/files/read?path=` | 读文本:预览上限 1MB,超限 `truncated:true`(不再 404);二进制→415 | ✅ | ### 返回 501 `NOT_IMPLEMENTED` 的端点(诚实标注) @@ -128,9 +128,10 @@ http://.local:8080/ | `/api/key/:code` 的 `press:"hold"` | `hold` 需常驻持续注入通道(阶段 D),后端未实现;`tap`/`long` 已实现。 | | OTA 升级模式(`/api/system/ota` 带 `url`) | 升级为草案(MEOWPANEL_PROTOCOL §6 待定案);当前 `POST /api/system/ota` 空 body 触发 `meow_svc_power` 的 `POWER_UPGRADE`(写 mtd5 进升级模式),带 `url` 的下载+升级未实现。 | -> 通知端点已从 501 落地为「读 TF 卡通知转储日志」实现。因 launcher 侧 **`MSG_NOTIFY_LIST` 为下行推送(守护→App), -> 无「客户端查询通知清单」请求类型**(launcher `util.c` `case MSG_NOTIFY_LIST: "not dispatched here"`),后端(svcd 独立进程) -> 无法经 IPC 取活跃清单,故回退到 TF 转储文件。详见 §5.8 与输出「需 launcher 侧配合项」。 +> 通知端点已从 501 落地,且现为**实时**:优先经 launcher IPC `MSG_NOTIFY_QUERY(0x25)→QUERY_RESULT(0x27)` 取 +> **内存活跃集**(`source:"ipc"`);IPC 不可达/超时回退读 TF 卡通知转储日志(`source:"tf"`,会话级归档);两者皆无 +> `source:"none"`。撤/清(`DELETE` / `dismiss_all`)同样**优先走 IPC**联动 launcher 内存(launcher 撤下时自动归档留档)。 +> 详见 §5.8。 > 前端对 `501` 显示「该功能后端尚未实现」(`ok:false,error.code=NOT_IMPLEMENTED`)。 @@ -207,23 +208,28 @@ http://.local:8080/ - `GET /api/logs/read?file=&lines=`:`file` 必须相对 `MeowOS/logs/`,**禁 `..`/绝对路径 → 403**,敏感名 → 403; 缺省 `lines=200`,上限 `1000`;返回 `{file,lines[],total,truncated}`。 -### 5.8 通知(数据源 = TF 卡通知转储日志) -- **数据源优先级**:① launcher IPC 查询 →② TF 转储文件 →③ 空态。当前 ① 不可用(launcher 侧 `MSG_NOTIFY_LIST` 仅下行推送、 - 无客户端查询请求类型,见「需 launcher 侧配合项」),故以 ② 为准。 -- **TF 转储文件**:`<卡根>/MeowOS/notifications/notifications.log`(launcher `meow_notif_dump_tf` 一行一条 CSV, - 字段 `timestamp,id,app_id,app_icon,category,urgency,progress,title,body,target_activity,ongoing,media,dismissed`; - `meow_notif_clear_tf` 开机清空 → 会话级归档)。后端逐行流式读取(环形保留最新 `NOTIF_PREVIEW_MAX=200` 条,不全量进内存)。 +### 5.8 通知(数据源 = 优先 launcher 内存活跃集,回退 TF 归档) +- **数据源优先级**:① launcher IPC 内存活跃集(实时,`source:"ipc"`)→② TF 转储文件(`source:"tf"`)→③ 空态(`source:"none"`)。 + - ① **launcher IPC**:后端连 `/usr/data/meow_ipc.sock`,`MSG_HELLO` 握手(`app_id=os.meow.panel`)→ `MSG_NOTIFY_QUERY(0x25)` + (空 payload=全部 app)→ 期望 `MSG_NOTIFY_QUERY_RESULT(0x27)`(`m_notify_query_result_t`,JSON 数组文本,字段同 TF 归档, + `ts` 为 epoch **毫秒**)。launcher 在**主循环单写者**序列化(绝不把通知存储读到 IPC 线程)。后端按 `ms` 升序重排 + (oldest..newest,与 TF 一致),emit 时倒序= newest first;`ts` 除以 1000 出秒(与 TF 同口径)。 + - ② **TF 转储文件**(回退):`<卡根>/MeowOS/notifications/notifications.log`(launcher `meow_notif_dump_tf` 一行一条 CSV, + 字段 `timestamp,id,app_id,app_icon,category,urgency,progress,title,body,target_activity,ongoing,media,dismissed`; + `meow_notif_clear_tf` 开机清空 → 会话级归档)。后端逐行流式读取(环形保留最新 `NOTIF_PREVIEW_MAX=200` 条,不全量进内存)。 + 注意 TF 是**归档快照**(归档时 `dismissed` 尚未置 1,故该列恒 ≈0 不可靠,且非活跃快照)。 - **`GET /api/notifications`**:返回 `{count, items:[...], notifications:[...], source}`;`items`= `notifications` 别名 (兼容前端读 `.notifications` 与任务约定空态 `items:[]`)。字段含 `id/app_id/app_name/app_icon/category/summary/body/ urgency/ts/progress/ongoing/media/target_activity/dismissed`。`progress<0`→`null`;`app_name` 对 `system`→`系统`,其余用 `app_id` 兜底。 无源(无卡/文件不存在)→ `{count:0,items:[],notifications:[],source:"none"}`(**不 503、不伪造数据**)。 -- **`DELETE /api/notifications/:id`**:从日志移除该行(重写临时文件后 rename)。不存在 → 404。 -- **`POST /api/notifications/dismiss_all`(别名 `/api/notifications/clear`)**:重写日志,**只保留活动中的媒体/进度通知** - (`ongoing||media||progress>=0||category=="media"`),移除其余;返回 `{dismissed:<移除数>}`。 -- **语义边界**:日志是**归档快照**(归档时 `dismissed` 尚未置 1,故该列恒 ≈0 不可靠),非活跃快照;且面板清空只改 - 日志视图,**不联动 launcher 进程内存**(launcher 通知中心仍显示其内存活跃集)。无 launcher 查询/清撤 IPC 前的诚实降级。 -- **并发**:面板清空/删除用「写临时 + rename」重写日志;launcher 侧仍以 `O_APPEND` 追加,二者为竞写(低并发、会话级日志, - 可接受),已写入「已知边界」(§7)。 +- **`DELETE /api/notifications/:id`**:**优先 IPC** `MSG_NOTIFY_DISMISS(0x22)`(4B id → `MSG_ACK`;launcher 撤下时自动归档留档); + IPC 不可达/未受理 → 回退从日志移除该行(写临时 + rename)。IPC 未受理且日志也无该 id → 404。 +- **`POST /api/notifications/dismiss_all`**:**优先 IPC** `MSG_NOTIFY_DISMISS_ALL(0x26)`(→ `MSG_ACK.status`=清除数; + launcher 保留 ongoing/media/progress 红线项);IPC 不可达 → 回退重写日志(`ongoing||media||progress>=0||category=="media"` 保留)。 +- **实时性**:IPC 路径直接反映 launcher **内存活跃集**——新通知(含未到期/未归档)**立即**出现在面板(实测:post 一条 + 通知后面板同时刻即见,`source:"ipc"`);撤/清经 IPC 联动 launcher 内存(实测:面板 DELETE 后 launcher 活跃集即无该 id)。 + TF 路径仅为 launcher 不可达时的诚实降级(会显示自动过期的历史通知,直到 clear)。 +- **并发**:IPC 可用时面板不写 TF(撤清由 launcher 自行归档),避免与 launcher `O_APPEND` 竞写;仅 IPC 回退到「写临时 + rename」。 ### 5.9 可选鉴权(panel.auth_enabled / panel.auth_pass) - **设置键**:`panel.auth_enabled`(bool,默认 `0`=关)+ `panel.auth_pass`(str,存 `salt:hex`)。两者写 `settings.conf`, @@ -241,11 +247,19 @@ http://.local:8080/ 首次进入以 `/api/auth/status` 决定是否弹登录。设置页「面板鉴权」卡片含开关(`panel.auth_enabled`)+「设置/修改密码」 按钮(`panel.auth_pass`,走 `passwordDlg` 输入)。 -### 5.10 实时画面(`/api/screen.jpeg`)+ 触摸注入(`/api/input/tap`)— 阶段 C -- **实时画面**:`GET /api/screen.jpeg` 读 `/dev/fb0` **当前显示帧**(`FBIOGET_VSCREENINFO` 取 `yoffset`/`xres`/`yres`/`bpp`, +### 5.10 实时画面(`/api/screen.jpeg`,含增量帧 `?delta=1`)+ 触摸注入(`/api/input/tap`)— 阶段 C +- **实时画面(整帧,默认)**:`GET /api/screen.jpeg` 读 `/dev/fb0` **当前显示帧**(`FBIOGET_VSCREENINFO` 取 `yoffset`/`xres`/`yres`/`bpp`, `FBIOGET_FSCREENINFO` 取 `line_length`,`pread` 活动缓冲,**只读,不影响渲染**;16bpp RGB565)→ `mp_jpeg_encode_rgb565()` 编码为内存 JPEG(自研最小 baseline 编码器,见 §1),返回 `Content-Type: image/jpeg`。 `?q=1-100` 控制质量(默认 70)。恢复 `line_length != xres*2`(行尾填充)时先压缩为紧凑 RGB565。 +- **增量帧(`?delta=1`,阶段 C 增强)**:服务端**缓存上一帧紧凑 RGB565**(单线程 accept 循环跨请求持有),按 **8×8 块**逐行 + `memcmp` 比对(1 块=8×8 像素×2B),只编码**变化块的外接矩形子图**(8 对齐、钳到屏内)为独立 JPEG,并以**响应头**回传偏移/几何 + (`X-Meow-Delta:1`/`X-Meow-Full:0|1`/`X-Meow-W/H`(整屏)/`X-Meow-Sub-X/Y/W/H`(子图原点与尺寸)/`X-Meow-Changed`/`X-Meow-Blocks`)。 + - 冷启动/几何变化/变化块覆盖全屏 → 发**整帧**(`X-Meow-Full:1`);帧无变化 → `Content-Length:0` + `X-Meow-Changed:0`(前端跳过叠加)。 + - 体积:近静态帧(只刷新状态栏时钟等)增量子图可低至数百 B(实测 6 变化块=**804 B**,对比整帧 ~27 KB @q70)。 + - **不破坏默认**:未带 `?delta=1` 仍为整帧 `image/jpeg`(零回归)。 + - 前端「屏幕」页新增「增量传输(省流量)」开关:开启后走 `?delta=1`,用 `` 叠加(整帧播种/替换,子图 `drawImage(x,y)`); + 关闭走原 ``+objectURL 整帧。默认关。 - **触摸注入**:`POST /api/input/tap`,body `{x,y}`(0..479 × 0..719 原生像素坐标,前端按 480×720 映射,后端再 clamp)。 **机制 = write 进 goodix evdev(`/dev/input/event1`)**——**本机实测 write 注入可投递**(见 §7 修正历史结论), 事件序列与真实 goodix 一致:先 `BTN_TOUCH=1`(按下边沿)**后** `ABS_MT_*`/`ABS_X/Y`(坐标),`SYN` → 按住 100ms → @@ -275,10 +289,11 @@ http://.local:8080/ 5. **`/api/files` 上传/删除/重命名等 path**:前端对这些操作传**绝对路径**(`d.path/子项`),列表传**相对 root**; 后端 `normalize_path` 对两者均容错(相对则拼 root,绝对则直用),符合协议「白名单根 + 规范化」。 6. **`GET /api/files` 返回值**:`files_list` 返回 `500` 语义时以 500 封装(协议未定义 `root` 之外错误枚举,用通用错误信封)。 -7. **通知清单数据源**:协议 §3.8 称后端「经通知后端读取」;因 launcher 无「客户端查询通知清单」IPC(`MSG_NOTIFY_LIST` 仅下行), - 后端**改读 TF 卡通知转储日志**(`MeowOS/notifications/notifications.log`)。响应额外带 `source`(`tf`/`none`)与 `items` 别名 - (与 `notifications` 同内容);`items` 为任务约定空态形状,`notifications` 为前端实际读取字段,二者并存。`clear` 语义为 - 「保留活动中的媒体/进度通知」(`ongoing||media||progress>=0`),对应通知中心既定语义。 +7. **通知清单数据源**:协议 §3.8 称后端「经通知后端读取」。launcher 现已有**客户端查询/撤/清 IPC**(`MSG_NOTIFY_QUERY(0x25)`/ + `MSG_NOTIFY_DISMISS(0x22)`/`MSG_NOTIFY_DISMISS_ALL(0x26)`,agent-199 落地),后端改为**优先经这些 IPC** 取 launcher **内存活跃集** + (实时,`source:"ipc"`);IPC 不可达/超时回退读 TF 卡通知转储日志(`source:"tf"`,会话级归档)。响应额外带 `source` 与 `items` 别名 + (与 `notifications` 同内容);`items` 为任务约定空态形状,`notifications` 为前端实际读取字段,二者并存。撤/清同样**优先走 IPC**联动 + launcher 内存;`clear` 语义为「保留活动中的媒体/进度通知」(`ongoing||media||progress>=0`),对应通知中心既定语义。 8. **`/api/files/read` 文本截断**:协议 §3.4 称「仅文本(<1MB)→ 二进制 415」;现实现为「预览上限 1MB,超限 `truncated:true`, 不再整体 404」,二进制仍 415(比协议更友好,返回文件真实 `size` + `truncated`)。 9. **`/api/files/upload`/`rename` 修正**:上传文件名经 `safe_filename` 取 basename、拒绝 `..`/分隔符(防 `../../etc/passwd` 逃逸), @@ -305,10 +320,14 @@ http://.local:8080/ - **`/api/files/upload` 上传路径**:multipart 需要 `path` 字段(目标目录);缺失时上传文件会被忽略(前端总是带着 `path`)。 - **数据一致性**:设置直接写 `settings.conf`;`display.brightness`/`boot.default` 的 clamp/同步与 launcher `meow_settings_*` 同口径。 若 launcher 未来改 clamp 逻辑,需同步本表。 -- **通知视图为「会话级归档」**:TF 转储日志由 launcher 开机清空、每通知归档时追加一次;非活动(非 ongoing/无进度)通知 - 10s TTL 自动归档后仍留在日志,故面板会**显示已自动过期但未手动清理的历史通知**(直到 clear)。媒体/进度/ongoing 通知 - 持续保留。无 launcher 查询 IPC 前的诚实降级(见 §5.8)。 -- **通知清空不联动 launcher 内存**:面板 clear/delete 只重写日志视图,launcher 通知中心内存活跃集不受影响(两者数据源分离)。 +- **通知 IPC 路径为 launcher 内存活跃集(实时)**:优先 `MSG_NOTIFY_QUERY` 取活跃集;TF 归档日志仅在 launcher 不可达时作为 + 回退(此时才出现「会话级归档」语义——非 activity 通知 10s TTL 自动归档后仍留日志,会显示已过期历史直到 clear;媒体/进度/ongoing 常驻)。 + IPC 响应为 launcher 的 `m_notify_query_result_t` 单帧文本(≤4KB),活跃通知超限时 launcher 侧 `meow_notif_serialize_json` 保前缀截断 + (理论上最多 ~30 条,实际通知数远小,可接受)。 +- **通知撤/清联动 launcher 内存**:面板 delete/dismiss_all **优先走 IPC**(launcher 撤下时自动归档留档);仅 IPC 不可达时回退重写 TF 日志。 +- **增量帧依赖单线程基线**:`/api/screen.jpeg?delta=1` 用上一帧缓存比对,**面板为单线程 accept 循环**(跨请求持久);对**单面板用户**直接有效。 + 多客户端混用(一全帧+一增量、跨浏览器)时增量基线可能被另一客户端发的全帧刷新,前端「canvas 未播种则整帧取图」兜底自愈;极端并发下增量图 + 可能基于过期基线(可容忍,仅多发一帧整帧即校准)。子图为独立 JPEG(4:2:0 在子图内自采样),与整帧同源同质量,无拼接色差。 - **上传为「限长缓冲」非全流式**:multipart 请求体整体读入(上限 8MB,超限 413),下载为流式(8KB 分块)。严格「socket 逐字节流式上传 解析」未实现(需增量 multipart 解析器),列为后续;14MB 内存约束下 8MB 上限 + 流式下载已覆盖大文件读侧。 - **存储为 `meowpanel` 编译时刻写死**的 socket/路径会因 `--sock-perf/--sock-power`(qemu sim)可覆盖,但默认走 `/usr/data/meow_*.sock`。 @@ -328,11 +347,11 @@ http://.local:8080/ ``` patch_v2/meowpanel/ -├── meowpanel.c # 后端源码(约 3650 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面/触摸注入) +├── meowpanel.c # 后端源码(约 4100 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化) ├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning) ├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG) ├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning) -├── meowpanel # 交叉产物(mipsel 静态,约 201 KB,md5 f310a00e…) +├── meowpanel # 交叉产物(mipsel 静态,约 206 KB,md5 24f53b40…) ├── meowpanel_host # 宿主编译(本机 ld,调试用) ├── services.d/ │ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf diff --git a/patch_v2/meowpanel/meowpanel b/patch_v2/meowpanel/meowpanel index d701f1d..f62fa1c 100755 Binary files a/patch_v2/meowpanel/meowpanel and b/patch_v2/meowpanel/meowpanel differ diff --git a/patch_v2/meowpanel/meowpanel.c b/patch_v2/meowpanel/meowpanel.c index 63135b2..b423b70 100644 --- a/patch_v2/meowpanel/meowpanel.c +++ b/patch_v2/meowpanel/meowpanel.c @@ -89,6 +89,7 @@ static const char *g_notif_log = NULL; /* 通知转储日志:/notificat #define LOGS_DIR g_logs_dir #define PERF_SOCK "/usr/data/meow_perf.sock" #define POWER_SOCK "/usr/data/meow_power.sock" +#define LAUNCHER_SOCK "/usr/data/meow_ipc.sock" /* launcher 主 IPC(通知活跃集查询/撤清) */ #define NYAI_PATH "/usr/bin/nyai" /* 固件内置(权威) */ #define NYAI_PATH_CARD "/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai" /* 旧卡上路径(回退) */ #define BOOT_DEFAULT_FILE "/data/meow_boot_default" @@ -118,6 +119,14 @@ static const char *g_sensitive_names[] = { static const char *g_sock_perf = PERF_SOCK; static const char *g_sock_power = POWER_SOCK; +static const char *g_sock_launcher = LAUNCHER_SOCK; + +/* ---------------- 增量帧缓存(GET /api/screen.jpeg?delta=1,阶段 C 增强) ---------------- + * 服务端缓存上一帧紧凑 RGB565,按 8×8 块比对,只编码变化块(外接 bbox 子图)并以响应头回传偏移。 + * 单线程 accept 循环持有,跨请求持久(见 §5.10)。 */ +#define SCREEN_BLOCK 8 /* 比对块边长(像素) */ +static uint8_t *g_screen_prev = NULL; /* 上一帧紧凑 RGB565(w*2 行距) */ +static int g_screen_pw = 0, g_screen_ph = 0; /* 初始化 TF 卡派生路径(main 开头调用)。env MEOW_TF_ROOT 可覆盖卡根(宿主测试用); * env MEOW_NOTIF_LOG 可覆盖通知转储日志路径(宿主测试用)。默认设备卡路径。 */ @@ -2791,20 +2800,20 @@ static int api_logs_read(int sock, const req_t *r) return rc; } -/* ---------------- 通知(数据源 = TF 卡通知转储日志) ---------------- +/* ---------------- 通知(数据源 = 优先 launcher 内存活跃集,回退 TF 归档) ---------------- * 数据源优先级(MEOWPANEL_PROTOCOL §3.8 / 任务): - * 1) launcher IPC 查询 —— meow_ipc.h 已定义 MSG_NOTIFY_LIST(0x23),但 launcher 侧仅作 - * 守护→App 下发行(util.c:1079「not dispatched here」),**无客户端查询请求类型**, - * 后端(svcd 独立进程)无法据此取到活跃清单 → 跳过,回退 ②。详见报告「需 launcher 侧配合项」。 - * 2) TF 卡通知转储文件 `<卡根>/MeowOS/notifications/notifications.log`: + * 1) launcher IPC 内存活跃集(实时)—— `MSG_NOTIFY_QUERY`(0x25)→`MSG_NOTIFY_QUERY_RESULT`(0x27) + * (launcher `util.c` `case MSG_NOTIFY_QUERY`,**主循环单写者**序列化;agent-199 已落地)。 + * 2) IPC 不可达/超时/应答失败 → 回退 TF 卡通知转储文件 `<卡根>/MeowOS/notifications/notifications.log`: * launcher 把每条通知归档时追加一行 CSV(meow_notif.c tf_line:CSV 追加 + 10MB 滚动,开机清空)。 * 字段顺序:timestamp,id,app_id,app_icon,category,urgency,progress,title,body,target_activity, * ongoing,media,dismissed(前 6/7/11/12/13 为裸整数,其余引号包裹 + 内部 " 翻倍)。 * 注意:**归档发生在置 dismissed=1 之前(meow_notif_dismiss 先 archive_one 后置 dismissed)**, - * 故日志的 dismissed 列恒 ≈0(不可靠),且日志是「会话级归档」,非活跃快照。 + * 故日志的 dismissed 列恒 ≈0(不可靠),且日志是「会话级归档」,非活跃快照(回退时为此语义)。 * 3) 都没有 → 明确空态 {items:[],notifications:[],source:"none"},**不伪造数据**。 - * clear(dismiss_all)语义:保留活动中的媒体/进度通知(ongoing||media||progress>=0||category=="media"), - * 其余从日志移除。单条删除:按 id 移除该行。 */ + * 撤/清(delete / dismiss_all)同样**优先走 IPC**联动 launcher 内存(launcher 撤下时自动归档留档), + * IPC 不可达时回退重写 TF 日志。clear(dismiss_all)语义:保留活动中的媒体/进度通知 + * (ongoing||media||progress>=0||category=="media"),其余移除。 */ typedef struct { long long ms; /* created_ms(epoch ms) */ unsigned id; @@ -2935,11 +2944,124 @@ static void notif_emit_item(sbuf_t *out, const notif_rec_t *r) sA(out, ",\"dismissed\":%s}", r->dismissed ? "true" : "false"); } +/* ---------------- 通知实时化:launcher IPC 活跃集 ---------------- + * 数据源优先级(MEOWPANEL_PROTOCOL §3.8 / 任务): + * 1) launcher IPC 内存活跃集(实时)—— `MSG_NOTIFY_QUERY`(0x25)→`MSG_NOTIFY_QUERY_RESULT`(0x27), + * JSON 字段同 TF 归档(id/app_id/category/summary/body/urgency/ts(ms)/progress/ongoing/media/ + * target_activity/app_icon)。launcher 在**主循环单写者**序列化(绝不把通知存储读到 IPC 线程)。 + * 2) IPC 不可达/超时/应答失败 → 回退 TF 归档 `notifications.log`(保留旧行为,见 §5.8)。 + * 3) 都没有 → 明确空态 {items:[],notifications:[],source:"none"},不伪造数据。 + * 撤/清(delete / dismiss_all)同样**优先走 IPC**联动 launcher 内存(launcher 撤下时自动归档留档), + * IPC 不可达时回退重写 TF 日志。 */ + +/* 发起 MSG_NOTIFY_QUERY(空 payload=全部 app),把 launcher 返回的 JSON 数组解析为 recs[]。 + * 返回条数(>=0,按 ms 升序 oldest..newest,与 TF 路径一致);-1=IPC 不可达/应答失败/解析失败。 + * 注:launcher 按固定 slot 表顺序序列化,非时间序,故此处按 ms 升序重排后再返回。 */ +static int notif_ipc_query(notif_rec_t *recs, int maxrec) +{ + int fd = ipc_connect_tmo(g_sock_launcher, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + int cnt = -1; + if (ipc_hello(fd) < 0) goto done; + if (send_frame(fd, MSG_NOTIFY_QUERY, 8, NULL, 0) < 0) goto done; + uint8_t buf[MEOW_IPC_MAX + MEOW_IPC_HDR_LEN]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_notify_query_result_t)) goto done; + if (type != MSG_NOTIFY_QUERY_RESULT) goto done; + m_notify_query_result_t qr; + memcpy(&qr, buf, sizeof qr); + if (qr.code != MEOW_OK) goto done; + uint16_t tl = qr.text_len; + if (tl >= sizeof qr.text) tl = (uint16_t)(sizeof qr.text - 1); + if (tl == 0) { cnt = 0; goto done; } /* 无活跃通知 */ + jv_t *arr = jv_parse(qr.text, tl); + if (!arr) goto done; + if (arr->type != J_ARR) { jv_free(arr); goto done; } + int c = 0; + for (jv_t *e = arr->child; e && c < maxrec; e = e->next) { + if (e->type != J_OBJ) continue; + notif_rec_t r; memset(&r, 0, sizeof r); + r.progress = -1; + r.urgency = 0; + jv_t *v; + if ((v = jv_obj_get(e, "id")) && v->type == J_NUM) r.id = (unsigned)v->inum; + if ((v = jv_obj_get(e, "app_id")) && v->type == J_STR) snprintf(r.app_id, sizeof r.app_id, "%s", v->str); + if ((v = jv_obj_get(e, "app_icon")) && v->type == J_STR) snprintf(r.app_icon, sizeof r.app_icon, "%s", v->str); + if ((v = jv_obj_get(e, "category")) && v->type == J_STR) snprintf(r.category, sizeof r.category, "%s", v->str); + if ((v = jv_obj_get(e, "summary")) && v->type == J_STR) snprintf(r.summary, sizeof r.summary, "%s", v->str); + if ((v = jv_obj_get(e, "body")) && v->type == J_STR) snprintf(r.body, sizeof r.body, "%s", v->str); + if ((v = jv_obj_get(e, "target_activity")) && v->type == J_STR) snprintf(r.target, sizeof r.target, "%s", v->str); + if ((v = jv_obj_get(e, "urgency")) && v->type == J_NUM) r.urgency = (int)v->inum; + /* ts 为 epoch 毫秒 → 存入 r.ms(emit 时 /1000 出秒,与 TF 同口径) */ + if ((v = jv_obj_get(e, "ts")) && v->type == J_NUM) r.ms = (long long)v->inum; + if ((v = jv_obj_get(e, "progress")) && v->type == J_NUM) r.progress = (int)v->inum; /* <0=无;json 无该键则 -1 */ + if ((v = jv_obj_get(e, "ongoing")) && v->type == J_NUM) r.ongoing = (int)v->inum; + if ((v = jv_obj_get(e, "media")) && v->type == J_NUM) r.media = (int)v->inum; + recs[c++] = r; + } + jv_free(arr); + /* 按 ms 升序(oldest..newest),与 TF 路径一致(emit 时倒序遍历= newest first)。 */ + for (int i = 1; i < c; i++) { notif_rec_t key = recs[i]; int j = i - 1; + while (j >= 0 && recs[j].ms > key.ms) { recs[j + 1] = recs[j]; j--; } recs[j + 1] = key; } + cnt = c; +done: + close(fd); + return cnt; +} + +/* 撤下单条通知 → MSG_NOTIFY_DISMISS(0x22),期望 MSG_ACK。返回 0=受理;-1=IPC 不可达/应答失败/未受理。 */ +static int notif_ipc_dismiss(unsigned id) +{ + int fd = ipc_connect_tmo(g_sock_launcher, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + int rc = -1; + if (ipc_hello(fd) < 0) goto done; + uint32_t nid = (uint32_t)id; + if (send_frame(fd, MSG_NOTIFY_DISMISS, 9, &nid, sizeof nid) < 0) goto done; + uint8_t buf[64]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_ack_t)) goto done; + if (type != MSG_ACK) goto done; + m_ack_t a; memcpy(&a, buf, sizeof a); + rc = (a.code == MEOW_OK) ? 0 : -1; +done: + close(fd); + return rc; +} + +/* 一键清除 → MSG_NOTIFY_DISMISS_ALL(0x26),期望 MSG_ACK(status=清除数,保留 ongoing/media/progress)。 + * 返回 0=受理(*out_count 填清除数);-1=IPC 不可达/应答失败。 */ +static int notif_ipc_dismiss_all(int *out_count) +{ + int fd = ipc_connect_tmo(g_sock_launcher, IPC_TIMEOUT_MS); + if (fd < 0) return -1; + int rc = -1; + if (ipc_hello(fd) < 0) goto done; + if (send_frame(fd, MSG_NOTIFY_DISMISS_ALL, 10, NULL, 0) < 0) goto done; + uint8_t buf[64]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_ack_t)) goto done; + if (type != MSG_ACK) goto done; + m_ack_t a; memcpy(&a, buf, sizeof a); + if (a.code == MEOW_OK) { if (out_count) *out_count = (int)a.status; rc = 0; } +done: + close(fd); + return rc; +} + static int api_notifications(int sock) { notif_rec_t recs[NOTIF_PREVIEW_MAX]; - int n = notif_load_list(recs, NOTIF_PREVIEW_MAX); - if (n < 0) return send_json(sock, 200, "{\"count\":0,\"items\":[],\"notifications\":[],\"source\":\"none\"}"); + const char *source = "ipc"; + int n = notif_ipc_query(recs, NOTIF_PREVIEW_MAX); + if (n < 0) { /* IPC 不可达 → 回退 TF 归档 */ + n = notif_load_list(recs, NOTIF_PREVIEW_MAX); + source = (n < 0) ? "none" : "tf"; + if (n < 0) n = 0; + } sbuf_t arr = {0}; int first = 1; for (int i = n - 1; i >= 0; i--) { /* newest first */ @@ -2948,7 +3070,7 @@ static int api_notifications(int sock) notif_emit_item(&arr, &recs[i]); } sbuf_t s = {0}; - sA(&s, "{\"count\":%d,\"source\":\"tf\",\"items\":[", n); + sA(&s, "{\"count\":%d,\"source\":\"%s\",\"items\":[", n, source); if (arr.n) sA_append(&s, arr.b, arr.n); sA(&s, "],\"notifications\":["); if (arr.n) sA_append(&s, arr.b, arr.n); @@ -2994,6 +3116,12 @@ static int notif_rewrite(int mode, unsigned del_id) static int api_notification_delete(int sock, unsigned id) { + /* 优先 IPC 联动 launcher 内存(launcher 撤下时自动归档留档);IPC 不可达/未受理回退 TF 重写。 */ + if (notif_ipc_dismiss(id) == 0) { + char d[64]; + snprintf(d, sizeof d, "{\"id\":%u,\"dismissed\":true}", id); + return send_json(sock, 200, d); + } int removed = notif_rewrite(0, id); if (removed < 0) return send_err(sock, 500, "INTERNAL", "写通知存储失败"); if (removed == 0) return send_err(sock, 404, "NOT_FOUND", "通知不存在"); @@ -3004,7 +3132,13 @@ static int api_notification_delete(int sock, unsigned id) static int api_notification_dismiss_all(int sock) { - int removed = notif_rewrite(1, 0); + int cleared = 0; + if (notif_ipc_dismiss_all(&cleared) == 0) { /* 联动 launcher 内存,返回清除数 */ + char d[64]; + snprintf(d, sizeof d, "{\"dismissed\":%d}", cleared); + return send_json(sock, 200, d); + } + int removed = notif_rewrite(1, 0); /* IPC 不可达 → 回退重写 TF 日志 */ if (removed < 0) return send_err(sock, 500, "INTERNAL", "写通知存储失败"); char d[64]; snprintf(d, sizeof d, "{\"dismissed\":%d}", removed); @@ -3477,6 +3611,54 @@ static int fb_dims(const char *dev, int *w, int *h) return 0; } +/* 把压缩帧缓存为「上一帧」基线(几何变化时重分配)。供 delta 模式 8×8 块比对。 */ +static void screen_prev_store(const uint8_t *frame, int w, int h) +{ + size_t need = (size_t)w * (size_t)h * 2; + if (w != g_screen_pw || h != g_screen_ph) { + free(g_screen_prev); + g_screen_prev = NULL; + g_screen_pw = w; g_screen_ph = h; + g_screen_prev = (uint8_t *)malloc(need); + } + if (g_screen_prev) memcpy(g_screen_prev, frame, need); +} + +/* 8×8 块比对:返回变化块数;bbox 为块坐标(bx/by=min 块,bw/bh=块数),像素原点=bx*8/by*8。 + * changed==0 时 bbox 无效。*total_blocks 返回总块数。 */ +static int screen_diff_blocks(const uint8_t *cur, const uint8_t *prev, int w, int h, + int *bx, int *by, int *bw, int *bh, int *total_blocks) +{ + const int B = SCREEN_BLOCK; + int nbx = (w + B - 1) / B, nby = (h + B - 1) / B; + if (total_blocks) *total_blocks = nbx * nby; + int changed = 0; + int minx = nbx, miny = nby, maxx = -1, maxy = -1; + for (int byi = 0; byi < nby; byi++) { + for (int bxi = 0; bxi < nbx; bxi++) { + int x0 = bxi * B, y0 = byi * B; + int wdt = B, hgt = B; + if (x0 + wdt > w) wdt = w - x0; + if (y0 + hgt > h) hgt = h - y0; + int diff = 0; + for (int y = 0; y < hgt && !diff; y++) { + const uint8_t *a = cur + ((size_t)(y0 + y) * (size_t)w + (size_t)x0) * 2; + const uint8_t *b = prev + ((size_t)(y0 + y) * (size_t)w + (size_t)x0) * 2; + if (memcmp(a, b, (size_t)wdt * 2) != 0) diff = 1; + } + if (diff) { + changed++; + if (bxi < minx) minx = bxi; + if (byi < miny) miny = byi; + if (bxi > maxx) maxx = bxi; + if (byi > maxy) maxy = byi; + } + } + } + if (changed) { *bx = minx; *by = miny; *bw = maxx - minx + 1; *bh = maxy - miny + 1; } + return changed; +} + static int api_screen_jpeg(int sock, const req_t *r) { int q = 70; /* 默认质量(体积/清晰折中) */ @@ -3485,16 +3667,80 @@ static int api_screen_jpeg(int sock, const req_t *r) int t = atoi(qv); if (t >= 1 && t <= 100) q = t; } + char dv[8]; + int delta = (qparam(r->query, "delta", dv, sizeof dv) && atoi(dv) == 1); + const char *dev = getenv("MEOW_FB_DEV"); if (!dev || !dev[0]) dev = FB_DEV; uint8_t *frame = NULL; int w = 0, h = 0; if (screen_read_frame(dev, &frame, &w, &h) != 0) return send_err(sock, 500, "INTERNAL", "读取帧缓冲失败(/dev/fb0)"); + + if (!delta) { /* 默认整帧(不破坏原行为) */ + uint8_t *jpeg = NULL; size_t jlen = 0; + int rc = mp_jpeg_encode_rgb565(frame, w, h, q, &jpeg, &jlen); + if (rc != 0) { free(frame); return send_err(sock, 500, "INTERNAL", "JPEG 编码失败"); } + screen_prev_store(frame, w, h); /* 维护 delta 基线 */ + int ret = send_raw(sock, 200, "image/jpeg", jpeg, jlen); + free(jpeg); + free(frame); + return ret; + } + + /* ---- delta 模式 ---- */ + const int B = SCREEN_BLOCK; + int changed = 0, bx = 0, by = 0, bw = 0, bh = 0, total = 0; + int force_full = (!g_screen_prev || g_screen_pw != w || g_screen_ph != h); + if (!force_full) { + changed = screen_diff_blocks(frame, g_screen_prev, w, h, &bx, &by, &bw, &bh, &total); + if (bw * B >= w && bh * B >= h) force_full = 1; /* 全屏变化 → 直接发整帧 */ + } else { + total = ((w + B - 1) / B) * ((h + B - 1) / B); + } + screen_prev_store(frame, w, h); /* 基线更新为当前帧 */ + + char hdr[224]; + int ret = -1; + if (changed == 0 && !force_full) { /* 无变化:空 body,前端跳过叠加 */ + snprintf(hdr, sizeof hdr, "X-Meow-Delta: 1\r\nX-Meow-Full: 0\r\nX-Meow-W: %d\r\nX-Meow-H: %d\r\n" + "X-Meow-Changed: 0\r\nX-Meow-Blocks: %d", w, h, total); + ret = send_head(sock, 200, "image/jpeg", 0, hdr); + free(frame); + return ret; + } + if (force_full) { /* 冷藏起/几何变化/全屏变化 → 整帧 */ + uint8_t *jpeg = NULL; size_t jlen = 0; + int rc = mp_jpeg_encode_rgb565(frame, w, h, q, &jpeg, &jlen); + if (rc != 0) { free(frame); return send_err(sock, 500, "INTERNAL", "JPEG 编码失败"); } + snprintf(hdr, sizeof hdr, "X-Meow-Delta: 1\r\nX-Meow-Full: 1\r\nX-Meow-W: %d\r\nX-Meow-H: %d\r\n" + "X-Meow-Changed: %d\r\nX-Meow-Blocks: %d", w, h, changed ? changed : total, total); + ret = send_head(sock, 200, "image/jpeg", (long long)jlen, hdr); + if (ret == 0 && jlen && write_full(sock, jpeg, jlen) < 0) ret = -1; + free(jpeg); + free(frame); + return ret; + } + /* 子图 = 变化块 bbox 外接矩形(8 对齐,钳到屏内),重编码为独立 JPEG 并以响应头回传偏移。 */ + int x0 = bx * B, y0 = by * B; + int sw = bw * B, sh = bh * B; + if (x0 + sw > w) sw = w - x0; + if (y0 + sh > h) sh = h - y0; + uint8_t *sub = (uint8_t *)malloc((size_t)sw * (size_t)sh * 2); + if (!sub) { free(frame); return send_err(sock, 500, "INTERNAL", "内存不足"); } + for (int y = 0; y < sh; y++) + memcpy(sub + (size_t)y * (size_t)sw * 2, + frame + ((size_t)(y0 + y) * (size_t)w + (size_t)x0) * 2, + (size_t)sw * 2); uint8_t *jpeg = NULL; size_t jlen = 0; - int rc = mp_jpeg_encode_rgb565(frame, w, h, q, &jpeg, &jlen); - free(frame); - if (rc != 0) return send_err(sock, 500, "INTERNAL", "JPEG 编码失败"); - int ret = send_raw(sock, 200, "image/jpeg", jpeg, jlen); + int rc = mp_jpeg_encode_rgb565(sub, sw, sh, q, &jpeg, &jlen); + free(sub); + if (rc != 0) { free(frame); return send_err(sock, 500, "INTERNAL", "JPEG 编码失败"); } + snprintf(hdr, sizeof hdr, "X-Meow-Delta: 1\r\nX-Meow-Full: 0\r\nX-Meow-W: %d\r\nX-Meow-H: %d\r\n" + "X-Meow-Sub-X: %d\r\nX-Meow-Sub-Y: %d\r\nX-Meow-Sub-W: %d\r\nX-Meow-Sub-H: %d\r\n" + "X-Meow-Changed: %d\r\nX-Meow-Blocks: %d", w, h, x0, y0, sw, sh, changed, total); + ret = send_head(sock, 200, "image/jpeg", (long long)jlen, hdr); + if (ret == 0 && jlen && write_full(sock, jpeg, jlen) < 0) ret = -1; free(jpeg); + free(frame); return ret; } @@ -3811,10 +4057,11 @@ int main(int argc, char **argv) if (!strcmp(argv[i], "-p") && i + 1 < argc) port = atoi(argv[++i]); else if (!strcmp(argv[i], "--sock-perf") && i + 1 < argc) g_sock_perf = argv[++i]; else if (!strcmp(argv[i], "--sock-power") && i + 1 < argc) g_sock_power = argv[++i]; + else if (!strcmp(argv[i], "--sock-launcher") && i + 1 < argc) g_sock_launcher = argv[++i]; else if (!strcmp(argv[i], "--sock") && i + 1 < argc) { g_sock_perf = argv[++i]; g_sock_power = g_sock_perf; } else if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) { printf("MeowPanel backend %s\n", PANEL_VERSION); - printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P]\n"); + printf("usage: meowpanel [-p PORT] [--sock-perf P] [--sock-power P] [--sock-launcher P]\n"); return 0; } } @@ -3823,6 +4070,8 @@ int main(int argc, char **argv) if (env && env[0]) g_sock_perf = env; env = getenv("MEOW_IPC_POWER_SOCK"); if (env && env[0]) g_sock_power = env; + env = getenv("MEOW_IPC_LAUNCHER_SOCK"); + if (env && env[0]) g_sock_launcher = env; env = getenv("MEOW_SETTINGS_FILE"); if (env && env[0]) g_settings_file = env; /* 设置文件覆盖(宿主测试用;设备默认 /usr/data/meow/settings.conf) */ diff --git a/patch_v2/meowpanel/meowpanel_host b/patch_v2/meowpanel/meowpanel_host index 24da4c1..a83d7f5 100755 Binary files a/patch_v2/meowpanel/meowpanel_host and b/patch_v2/meowpanel/meowpanel_host differ diff --git a/patch_v2/meowpanel/web/meowpanel.css b/patch_v2/meowpanel/web/meowpanel.css index bf75dbe..f62179e 100644 --- a/patch_v2/meowpanel/web/meowpanel.css +++ b/patch_v2/meowpanel/web/meowpanel.css @@ -243,6 +243,11 @@ input[type=range]{ width:100%; accent-color:var(--primary); } border-radius:var(--radius-m); background:#000; cursor:crosshair; aspect-ratio:480/720; object-fit:contain; border:var(--outline-w) solid var(--outline-variant); } +.screen-frame canvas{ + width:100%; max-width:480px; height:auto; display:none; + border-radius:var(--radius-m); background:#000; cursor:crosshair; + aspect-ratio:480/720; border:var(--outline-w) solid var(--outline-variant); +} /* 远程按键区 */ .key-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:12px; } diff --git a/patch_v2/meowpanel/web/meowpanel.js b/patch_v2/meowpanel/web/meowpanel.js index 89bcc3f..a55f019 100644 --- a/patch_v2/meowpanel/web/meowpanel.js +++ b/patch_v2/meowpanel/web/meowpanel.js @@ -812,6 +812,7 @@ let screenTimer = null, screenBusy = false, screenOn = false; let screenObjUrl = null, screenFps = 1; let screenLastMs = 0, screenMeasFps = 0; // 实测帧率(帧间间隔 EMA) + let screenDelta = false, screenCanvasRef = null, screenCanvasReady = false; // 增量模式(canvas 叠加) function stopScreenPoll() { screenOn = false; if (screenTimer) { clearTimeout(screenTimer); screenTimer = null; } @@ -826,20 +827,53 @@ if (screenBusy) return; screenBusy = true; const t0 = performance.now(); + let statusMsg = ''; try { const headers = {}; const token = storageGet('meowpanel.token'); if (token) headers['X-MeowToken'] = token; - const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70', + const deltaQ = screenDelta ? '&delta=1' : ''; + const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70' + deltaQ, Object.keys(headers).length ? { headers: headers } : undefined); if (!res.ok) { $('#screen-status').textContent = '获取失败(HTTP ' + res.status + ')'; return; } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const img = $('#screen-img'); - if (!img) { URL.revokeObjectURL(url); return; } - img.src = url; - if (screenObjUrl) URL.revokeObjectURL(screenObjUrl); - screenObjUrl = url; + const hd = (k) => res.headers.get(k); + if (screenDelta) { + const isDelta = hd('X-Meow-Delta') === '1'; + const full = hd('X-Meow-Full') === '1'; + const changed = parseInt(hd('X-Meow-Changed') || '-1', 10); + const fw = parseInt(hd('X-Meow-W') || '480', 10); + const fh = parseInt(hd('X-Meow-H') || '720', 10); + const blob = await res.blob(); + if (!isDelta || full) { // 整帧(或旧后端无 delta 头)→ 播种/替换 canvas + const img = await createImgFromBlob(blob); + const cv = ensureScreenCanvas(fw, fh); + if (cv) { cv.getContext('2d').drawImage(img, 0, 0, fw, fh); screenCanvasReady = true; } + statusMsg = '已更新' + (changed >= 0 ? '(整帧)' : ''); + } else if (!screenCanvasReady) { // 尚未播种:取一整帧再继续 + await seedScreenCanvas(fw, fh); + if (screenCanvasReady) statusMsg = '已更新(播种)'; + } else if (changed === 0) { // 无变化:跳过叠加 + statusMsg = '无变化'; + } else { // 子图叠加 + const subX = parseInt(hd('X-Meow-Sub-X') || '0', 10); + const subY = parseInt(hd('X-Meow-Sub-Y') || '0', 10); + const subW = parseInt(hd('X-Meow-Sub-W') || fw, 10); + const subH = parseInt(hd('X-Meow-Sub-H') || fh, 10); + const img = await createImgFromBlob(blob); + const cv = ensureScreenCanvas(fw, fh); + if (cv) cv.getContext('2d').drawImage(img, subX, subY, subW, subH); + statusMsg = '已更新(增量 ' + changed + ' 块)'; + } + } else { + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const img = $('#screen-img'); + if (!img) { URL.revokeObjectURL(url); return; } + img.src = url; + if (screenObjUrl) URL.revokeObjectURL(screenObjUrl); + screenObjUrl = url; + statusMsg = '已更新'; + } // 实测帧率:上一帧到本帧间隔的 EMA(首帧无间隔) const now = performance.now(); if (screenLastMs) { @@ -847,7 +881,7 @@ screenMeasFps = screenMeasFps ? screenMeasFps * 0.7 + (1 / dt) * 0.3 : 1 / dt; } screenLastMs = now; - $('#screen-status').textContent = '已更新' + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString(); + $('#screen-status').textContent = statusMsg + (screenMeasFps ? ' · ' + screenMeasFps.toFixed(1) + ' fps' : '') + ' · ' + new Date().toLocaleTimeString(); } catch (e) { $('#screen-status').textContent = '后端未连接'; } @@ -859,9 +893,48 @@ screenTimer = setTimeout(fetchScreen, wait); } } + // 增量模式助手:blob → Image(一次性 objectURL,加载后即回收) + async function createImgFromBlob(blob) { + const url = URL.createObjectURL(blob); + try { + const img = new Image(); + await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; }); + return img; + } finally { URL.revokeObjectURL(url); } + } + function ensureScreenCanvas(fw, fh) { + let cv = screenCanvasRef; + if (!cv) cv = screenCanvasRef = $('#screen-canvas'); + if (!cv) return null; + if (cv.width !== fw || cv.height !== fh) { cv.width = fw; cv.height = fh; } + return cv; + } + async function seedScreenCanvas(fw, fh) { + const headers = {}; + const token = storageGet('meowpanel.token'); + if (token) headers['X-MeowToken'] = token; + const res = await fetch('/api/screen.jpeg?t=' + Date.now() + '&q=70', + Object.keys(headers).length ? { headers: headers } : undefined); + if (!res.ok) return false; + const img = await createImgFromBlob(await res.blob()); + const cv = ensureScreenCanvas(fw, fh); + if (!cv) return false; + cv.getContext('2d').drawImage(img, 0, 0, fw, fh); + screenCanvasReady = true; + return true; + } + function applyScreenDeltaDom() { + const img = $('#screen-img'), cv = $('#screen-canvas'); + if (!img || !cv) return; + if (screenDelta) { img.style.display = 'none'; cv.style.display = 'block'; } + else { img.style.display = 'block'; cv.style.display = 'none'; } + } async function screenTap(e) { - const img = $('#screen-img'); if (!img) return; - const r = img.getBoundingClientRect(); + const img = $('#screen-img'), cv = $('#screen-canvas'); + const useCanvas = screenDelta && cv && cv.style.display !== 'none'; + const el = useCanvas ? cv : img; + if (!el) return; + const r = el.getBoundingClientRect(); if (!r.width || !r.height) return; // 前端按 480x720(设备原生分辨率)换算 const nx = Math.round((e.clientX - r.left) / r.width * 480); @@ -882,15 +955,20 @@ '

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

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

载入中…

' + ''; $('#content').innerHTML = h; + screenCanvasRef = null; screenCanvasReady = false; // 切换页面重置增量 canvas 状态 const slider = $('#screen-fps'); slider.addEventListener('input', function () { const v = parseFloat(slider.value); @@ -898,7 +976,16 @@ $('#screen-fps-lbl').textContent = v.toFixed(1) + ' fps'; restartScreenPoll(); }); + const deltaBox = $('#screen-delta'); + deltaBox.addEventListener('change', function () { + screenDelta = deltaBox.checked; + screenCanvasRef = null; screenCanvasReady = false; // 换增量模式:强制重新播种 + applyScreenDeltaDom(); + restartScreenPoll(); + }); $('#screen-img').addEventListener('click', screenTap); + $('#screen-canvas').addEventListener('click', screenTap); + applyScreenDeltaDom(); // restartScreenPoll 已排程首帧(上一帧到了再取下一帧),勿再显式 fetch 以免双发 restartScreenPoll(); }