feat(meowpanel): 阶段C收尾——实时画面+远程遥控 (agent-224) — /api/screen.jpeg(fb0活动帧→自研最小baseline JPEG编码器,480x720≈24KB/帧,无libm零依赖) + /api/input/tap(坐标注入,BTN_TOUCH先行时序,本机触摸优先5s仲裁); 前端「屏幕」页(1fps可调,点击映射,息屏提示)
meowpanel→e98aec66 已部署; 真机验证: jpeg非全黑+注入tap拉起Files(日志+截图双证) 注: 注入走 write event1(实测本机可投递,未用uinput)
This commit is contained in:
1 parent
6c78a88b8c
commit
fbdd77a72e
8 files changed
+333
-7
No files matched your search
@@ -28,10 +28,12 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
|
|||||||
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**。
|
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**。
|
||||||
- 产出(本次构建实测):
|
- 产出(本次构建实测):
|
||||||
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
|
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
|
||||||
体积约 **183 KB**(固件阈值内)。md5:`f91856a44de0c8d12aba00cd40b44b91`。
|
体积约 **202 KB**(固件阈值内)。md5:`e98aec6682b7387a035ab5f63c6777e7`。
|
||||||
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **142 KB**。
|
- `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **146 KB**。
|
||||||
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自
|
- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。鉴权用**内联 SHA-256**(拷贝自
|
||||||
`meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。
|
`meow_verify/sha256.c` 公有领域参考写法,前缀 `mp_sha256_*` 避免符号冲突;**未改 `meow_verify/` 目录**)。
|
||||||
|
阶段 C 实时画面用**自研最小 baseline JPEG 编码器** `mp_jpeg.c`(顺序 DCT + 4:2:0 + 标准 Huffman 表 Annex K;
|
||||||
|
无 libm、零依赖,仅 malloc/realloc),质量 70 下 480×720 帧约 24 KB。
|
||||||
- 头文件引用:`-I patch_v2/meow_launcherd`(`meow_ipc.h` 帧格式 / `m_perf_*`/`m_power_*`/`POWER_*`,
|
- 头文件引用:`-I patch_v2/meow_launcherd`(`meow_ipc.h` 帧格式 / `m_perf_*`/`m_power_*`/`POWER_*`,
|
||||||
**header 为权威源**,结构体字段以它为准)。
|
**header 为权威源**,结构体字段以它为准)。
|
||||||
|
|
||||||
@@ -78,6 +80,8 @@ http://<host>.local:8080/
|
|||||||
| 方法 | 路径 | 说明 | 实现 |
|
| 方法 | 路径 | 说明 | 实现 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/<f>` | 静态托管 | ✅ |
|
| GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/<f>` | 静态托管 | ✅ |
|
||||||
|
| GET | `/api/screen.jpeg` | 实时画面:fb0 当前帧 → RGB565→JPEG(`?q=1-100` 质量,默认 70) | ✅(阶段 C) |
|
||||||
|
| POST | `/api/input/tap` | 触摸注入:`{x,y}`(480×720 原生坐标;write 进 goodix event1,本机实测可投递);本机触摸后 5s 内忽略注入 | ✅(阶段 C) |
|
||||||
| GET | `/api/auth/status` | 鉴权状态(enabled/会话有效) | ✅(公开) |
|
| GET | `/api/auth/status` | 鉴权状态(enabled/会话有效) | ✅(公开) |
|
||||||
| POST | `/api/auth/login` | 密码登录发 token(sha256 带盐校验) | ✅(公开;错→401) |
|
| POST | `/api/auth/login` | 密码登录发 token(sha256 带盐校验) | ✅(公开;错→401) |
|
||||||
| POST | `/api/auth/logout` | 登出失效 token | ✅(公开;幂等) |
|
| POST | `/api/auth/logout` | 登出失效 token | ✅(公开;幂等) |
|
||||||
@@ -233,6 +237,25 @@ http://<host>.local:8080/
|
|||||||
首次进入以 `/api/auth/status` 决定是否弹登录。设置页「面板鉴权」卡片含开关(`panel.auth_enabled`)+「设置/修改密码」
|
首次进入以 `/api/auth/status` 决定是否弹登录。设置页「面板鉴权」卡片含开关(`panel.auth_enabled`)+「设置/修改密码」
|
||||||
按钮(`panel.auth_pass`,走 `passwordDlg` 输入)。
|
按钮(`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`,
|
||||||
|
`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。
|
||||||
|
- **触摸注入**:`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 →
|
||||||
|
`BTN_TOUCH=0` + `ABS_MT_TRACKING_ID=-1` + `SYN`。**关键**:launcher/`meow_touch` 在按下边沿会**重置坐标 -1**,
|
||||||
|
只在 down 之后到来的首个有效 ABS 才锁原点(goodix 时序免疫)——故坐标必须**在 BTN_TOUCH=1 之后**,否则原点永不
|
||||||
|
锁定 → 抬手被判无效 tap(只高亮不启动)。注入前不写 uinput(本设备厂商内核 write 进 goodix 即可投递,逐 UB 少)。
|
||||||
|
- **仲裁(本机优先打断)**:注入前 `touch_last_local_ms()` 排空 event1,取「最近一次本机真实触摸」的单调毫秒时间
|
||||||
|
(剔除本进程自注入事件:事件时间 ≤ 上次注入时刻+200ms),若 `now-last < 5000` → **忽略注入**,返回
|
||||||
|
`{"dispatched":false,"reason":"local_touch_active","cooldown_ms":N}`;否则注入并返回 `{"dispatched":true,"x","y"}`。
|
||||||
|
MIPS 的 `struct input_event` 无 `time` 成员(ABI),按 launcher 同款读裸 16 字节取偏移 0/4 的 `tv_sec/tv_usec`(单调时钟)。
|
||||||
|
- **前端「屏幕」页**(`web/#/screen`,导航「屏幕」):`<img>` 轮询 `GET /api/screen.jpeg?t=…&q=70`(`fetch`→blob→objectURL,
|
||||||
|
防重叠 + 旧 URL 回收;后端不可达提示「后端未连接」);默认 **1fps**,滑条 **0.2–2fps**(存 `screen.fps`,页面隐藏/切走即停刷,
|
||||||
|
`visibilitychange` 回前台续刷);点击画面按 480×720 比例换算坐标 → `POST /api/input/tap`,`dispatched:false` 时 toast「本机触摸中」。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
|
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
|
||||||
@@ -259,6 +282,9 @@ http://<host>.local:8080/
|
|||||||
恒 ENOTDIR 失败)。上传体上限 `MAX_UPLOAD_BODY`=8MB,超限 → 413 `PAYLOAD_TOO_LARGE`。
|
恒 ENOTDIR 失败)。上传体上限 `MAX_UPLOAD_BODY`=8MB,超限 → 413 `PAYLOAD_TOO_LARGE`。
|
||||||
10. **TF 卡根可覆盖(宿主测试)**:`env MEOW_TF_ROOT` 覆盖卡根(设备上不设,恒用 `/usr/data/mnt/sd_0`);`env MEOW_NOTIF_LOG`
|
10. **TF 卡根可覆盖(宿主测试)**:`env MEOW_TF_ROOT` 覆盖卡根(设备上不设,恒用 `/usr/data/mnt/sd_0`);`env MEOW_NOTIF_LOG`
|
||||||
覆盖通知转储日志路径。设 `MEOW_TF_ROOT` 时 `tf_present()` 恒真(测试用)。
|
覆盖通知转储日志路径。设 `MEOW_TF_ROOT` 时 `tf_present()` 恒真(测试用)。
|
||||||
|
11. **实时画面/触摸注入(阶段 C)**:协议 §6 待定案项称「实时画面(`/api/frame` 等,本文件暂未定义)」。本面板落地为
|
||||||
|
**`GET /api/screen.jpeg` + `POST /api/input/tap`**(命名自定:`.jpeg` 表达二进制帧 + `input/tap` 表达触摸注入),
|
||||||
|
见 §5.10。回改协议时可补这两端点(以及 `frames` 语义、`/api/input/tap` 的 `dispatched:false/reason` 软失败约定)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -282,6 +308,15 @@ http://<host>.local:8080/
|
|||||||
- **上传为「限长缓冲」非全流式**:multipart 请求体整体读入(上限 8MB,超限 413),下载为流式(8KB 分块)。严格「socket 逐字节流式上传
|
- **上传为「限长缓冲」非全流式**:multipart 请求体整体读入(上限 8MB,超限 413),下载为流式(8KB 分块)。严格「socket 逐字节流式上传
|
||||||
解析」未实现(需增量 multipart 解析器),列为后续;14MB 内存约束下 8MB 上限 + 流式下载已覆盖大文件读侧。
|
解析」未实现(需增量 multipart 解析器),列为后续;14MB 内存约束下 8MB 上限 + 流式下载已覆盖大文件读侧。
|
||||||
- **存储为 `meowpanel` 编译时刻写死**的 socket/路径会因 `--sock-perf/--sock-power`(qemu sim)可覆盖,但默认走 `/usr/data/meow_*.sock`。
|
- **存储为 `meowpanel` 编译时刻写死**的 socket/路径会因 `--sock-perf/--sock-power`(qemu sim)可覆盖,但默认走 `/usr/data/meow_*.sock`。
|
||||||
|
- **触摸注入在设备息屏时不生效**:launcher 主界面在 `meow_screen_on==0` 时**不处理触摸**(main_menu.c,息屏防误触);设备自动息屏
|
||||||
|
(30s 空闲)后注入的 tap 会被 launcher 忽略——面板无法用触摸唤醒(需先电源键点亮)。前端「屏幕」页已提示「设备息屏请先点亮」;
|
||||||
|
对「正在看」设备的正常使用场景(屏幕亮着)注入即有效。
|
||||||
|
- **实时画面读取 fb0 当前帧(只读)**:双层缓冲下 `pread` 活动缓冲,存在翻页瞬间撕裂一行的可能(1fps 预览可接受);
|
||||||
|
`/dev/fb0` 不可读/非 16bpp → 500 `INTERNAL`(绝不静默)。
|
||||||
|
- **JPEG 编码器为 baseline 4:2:0**:质量 70 下 480×720 ≈ 24KB;质量越高体积越大(`q=100` 约 60-80KB)。编码为单线程
|
||||||
|
串行(面板本就单线程),一帧编码约几十 ms(MIPS 单核),1fps 轮询无压力。
|
||||||
|
- **触摸注入为即时性注入**:不维护长按/多指手势状态机(与按键注入一致);`dispatched:false`(本机触摸仲裁)属软失败,
|
||||||
|
前端可按 `reason` 提示,不视为错误。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -289,9 +324,11 @@ http://<host>.local:8080/
|
|||||||
|
|
||||||
```
|
```
|
||||||
patch_v2/meowpanel/
|
patch_v2/meowpanel/
|
||||||
├── meowpanel.c # 后端源码(单文件,约 3667 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256)
|
├── meowpanel.c # 后端源码(约 3650 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面/触摸注入)
|
||||||
|
├── 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)
|
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning)
|
||||||
├── meowpanel # 交叉产物(mipsel 静态,约 183 KB,md5 f91856a4…)
|
├── meowpanel # 交叉产物(mipsel 静态,约 202 KB,md5 e98aec66…)
|
||||||
├── meowpanel_host # 宿主编译(本机 ld,调试用)
|
├── meowpanel_host # 宿主编译(本机 ld,调试用)
|
||||||
├── services.d/
|
├── services.d/
|
||||||
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
|
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
# 用法:
|
# 用法:
|
||||||
# ./build_meowpanel.sh # 交叉编译 → patch_v2/meowpanel/meowpanel(mipsel 静态)
|
# ./build_meowpanel.sh # 交叉编译 → patch_v2/meowpanel/meowpanel(mipsel 静态)
|
||||||
# ./build_meowpanel.sh host # 宿主编译 → patch_v2/meowpanel/meowpanel_host
|
# ./build_meowpanel.sh host # 宿主编译 → patch_v2/meowpanel/meowpanel_host
|
||||||
|
# 源码:meowpanel.c + mp_jpeg.c(最小 baseline JPEG 编码器,阶段 C 实时画面)。
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
ROOT="$HOME/Project/R3ProII"
|
ROOT="$HOME/Project/R3ProII"
|
||||||
@@ -15,14 +16,14 @@ I_HDR="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(帧/m_perf_query_t/m_perf
|
|||||||
|
|
||||||
if [ "$1" = "host" ]; then
|
if [ "$1" = "host" ]; then
|
||||||
HOST_CC="${CC_HOST:-cc}"
|
HOST_CC="${CC_HOST:-cc}"
|
||||||
"$HOST_CC" -Os -Wall -Wextra -I"$I_HDR" meowpanel.c -o meowpanel_host
|
"$HOST_CC" -Os -Wall -Wextra -I"$I_HDR" meowpanel.c mp_jpeg.c -o meowpanel_host
|
||||||
echo "== host build =="
|
echo "== host build =="
|
||||||
ls -la meowpanel_host
|
ls -la meowpanel_host
|
||||||
file meowpanel_host
|
file meowpanel_host
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meowpanel.c -o meowpanel
|
"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meowpanel.c mp_jpeg.c -o meowpanel
|
||||||
echo "== meowpanel (mipsel static) =="
|
echo "== meowpanel (mipsel static) =="
|
||||||
ls -la meowpanel
|
ls -la meowpanel
|
||||||
md5sum meowpanel
|
md5sum meowpanel
|
||||||
|
|||||||
Binary file not shown.
@@ -3427,6 +3427,187 @@ static int api_apps_remove(int sock, const char *pkg, const req_t *r)
|
|||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 实时画面(GET /api/screen.jpeg) ---------------- */
|
||||||
|
/* fb0 读当前显示帧(只读,不影响渲染):按 yoffset 读活动缓冲,16bpp RGB565。 */
|
||||||
|
static int screen_read_frame(const char *dev, uint8_t **out, int *w, int *h)
|
||||||
|
{
|
||||||
|
int fd = open(dev, O_RDONLY);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
unsigned char vinfo[160], finfo[68];
|
||||||
|
memset(vinfo, 0, sizeof vinfo);
|
||||||
|
memset(finfo, 0, sizeof finfo);
|
||||||
|
if (ioctl(fd, FBIOGET_VSCREENINFO, vinfo) < 0 ||
|
||||||
|
ioctl(fd, FBIOGET_FSCREENINFO, finfo) < 0) { close(fd); return -1; }
|
||||||
|
uint32_t xres = *(uint32_t *)(vinfo + 0);
|
||||||
|
uint32_t yres = *(uint32_t *)(vinfo + 4);
|
||||||
|
uint32_t yoff = *(uint32_t *)(vinfo + 20);
|
||||||
|
uint32_t bpp = *(uint32_t *)(vinfo + 24);
|
||||||
|
uint32_t line = *(uint32_t *)(finfo + 44);
|
||||||
|
if (!line) line = xres * (bpp / 8);
|
||||||
|
if (bpp != 16 || xres == 0 || yres == 0 || line < xres * 2) { close(fd); return -1; }
|
||||||
|
size_t framelen = (size_t)line * (size_t)yres;
|
||||||
|
uint8_t *frame = (uint8_t *)malloc(framelen);
|
||||||
|
if (!frame) { close(fd); return -1; }
|
||||||
|
ssize_t got = pread(fd, frame, framelen, (off_t)line * (off_t)yoff);
|
||||||
|
close(fd);
|
||||||
|
if (got != (ssize_t)framelen) { free(frame); return -1; }
|
||||||
|
/* 若 line != xres*2(行尾填充),压缩为紧凑 RGB565(xres*yres*2) */
|
||||||
|
if (line != xres * 2) {
|
||||||
|
size_t cplen = (size_t)xres * 2;
|
||||||
|
for (uint32_t y = 0; y < yres; y++)
|
||||||
|
memmove(frame + (size_t)y * cplen, frame + (size_t)y * line, cplen);
|
||||||
|
}
|
||||||
|
*out = frame; *w = (int)xres; *h = (int)yres;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅读 fb 几何(不抓帧),用于坐标 clamp 上限。 */
|
||||||
|
static int fb_dims(const char *dev, int *w, int *h)
|
||||||
|
{
|
||||||
|
int fd = open(dev, O_RDONLY);
|
||||||
|
if (fd < 0) return -1;
|
||||||
|
unsigned char vinfo[160];
|
||||||
|
memset(vinfo, 0, sizeof vinfo);
|
||||||
|
if (ioctl(fd, FBIOGET_VSCREENINFO, vinfo) < 0) { close(fd); return -1; }
|
||||||
|
uint32_t xres = *(uint32_t *)(vinfo + 0);
|
||||||
|
uint32_t yres = *(uint32_t *)(vinfo + 4);
|
||||||
|
close(fd);
|
||||||
|
if (!xres || !yres) return -1;
|
||||||
|
*w = (int)xres; *h = (int)yres;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int api_screen_jpeg(int sock, const req_t *r)
|
||||||
|
{
|
||||||
|
int q = 70; /* 默认质量(体积/清晰折中) */
|
||||||
|
char qv[8];
|
||||||
|
if (qparam(r->query, "q", qv, sizeof qv)) {
|
||||||
|
int t = atoi(qv);
|
||||||
|
if (t >= 1 && t <= 100) q = t;
|
||||||
|
}
|
||||||
|
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)");
|
||||||
|
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);
|
||||||
|
free(jpeg);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 触摸注入(POST /api/input/tap) ----------------
|
||||||
|
* 本机厂商内核 write 进 goodix(事件1) 会投递给读者(实测:写 tap 打开 App 成功),故复用
|
||||||
|
* 「写 evdev」注入(同按键注入机制,但用 MT 协议 B + legacy ABS_X/Y + BTN_TOUCH,等于真实
|
||||||
|
* goodix 事件序列)。仲裁:本机 event1 最近活动后 5s 内忽略注入(本机优先打断)。 */
|
||||||
|
#define TOUCH_ACTIVE_MS 5000
|
||||||
|
|
||||||
|
static int64_t now_mono_ms(void)
|
||||||
|
{
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||||
|
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int64_t g_last_self_inject_ms = 0; /* 本进程上次注入时刻(用于剔除自注入事件) */
|
||||||
|
|
||||||
|
/* 排空触摸设备,返回「最近一次本机真实触摸」的单调毫秒时间(0=无)。剔除自注入事件。
|
||||||
|
* MIPS 的 struct input_event 无 time 成员(ABI),按 launcher 同款读裸 16 字节、
|
||||||
|
* 取偏移 0(int32 tv_sec)/4(int32 tv_usec)。 */
|
||||||
|
static int64_t touch_last_local_ms(const char *dev)
|
||||||
|
{
|
||||||
|
int fd = open(dev, O_RDONLY | O_NONBLOCK);
|
||||||
|
if (fd < 0) return 0;
|
||||||
|
int64_t last = 0;
|
||||||
|
unsigned char ev[16];
|
||||||
|
int64_t inject_deadline = g_last_self_inject_ms + 200; /* 注入事件时间 ≈ 注入时刻 */
|
||||||
|
while (read(fd, ev, sizeof ev) == (ssize_t)sizeof ev) {
|
||||||
|
int64_t sec = (int64_t)(*(int32_t *)(ev + 0));
|
||||||
|
int64_t usec = (int64_t)(*(int32_t *)(ev + 4));
|
||||||
|
int64_t t = sec * 1000 + usec / 1000;
|
||||||
|
if (t <= inject_deadline) continue; /* 自注入,跳过 */
|
||||||
|
if (t > last) last = t;
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int inject_tap(const char *dev, int x, int y)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
/* 事件顺序:先 BTN_TOUCH=1(按下边沿),后 ABS 坐标 —— launcher/meow_touch 在按下边沿
|
||||||
|
* 会重置坐标 (-1),只在 down 之后到来的首个有效 ABS 才锁原点(goodix 时序免疫)。若先发
|
||||||
|
* ABS 再发 BTN_TOUCH,原点永不锁定 → 抬手表被判为无效 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, x);
|
||||||
|
__WE(EV_ABS, ABS_MT_POSITION_Y, y);
|
||||||
|
__WE(EV_ABS, ABS_MT_TOUCH_MAJOR, 6);
|
||||||
|
__WE(EV_ABS, ABS_X, x);
|
||||||
|
__WE(EV_ABS, ABS_Y, y);
|
||||||
|
__WE(EV_SYN, SYN_REPORT, 0);
|
||||||
|
usleep(100000); /* 按住 100ms 再抬起,抬起到按下的边沿被 launcher 视为一次 tap */
|
||||||
|
__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;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int api_input_tap(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 *jx = jv_obj_get(root, "x");
|
||||||
|
jv_t *jy = jv_obj_get(root, "y");
|
||||||
|
if (!jx || jx->type != J_NUM || !jy || jy->type != J_NUM) {
|
||||||
|
jv_free(root);
|
||||||
|
return send_err(sock, 400, "BAD_REQUEST", "缺 x/y(数值)");
|
||||||
|
}
|
||||||
|
int x = (int)jx->inum, y = (int)jy->inum;
|
||||||
|
jv_free(root);
|
||||||
|
|
||||||
|
const char *dev = getenv("MEOW_TOUCH_DEV"); if (!dev || !dev[0]) dev = TOUCH_DEV;
|
||||||
|
/* 坐标 clamp 到屏幕范围(前端已按 480x720 映射,此处再兜底) */
|
||||||
|
int sw = 480, sh = 720;
|
||||||
|
{ int fx = 0, fy = 0; const char *fbdev = getenv("MEOW_FB_DEV"); if (!fbdev || !fbdev[0]) fbdev = FB_DEV;
|
||||||
|
if (fb_dims(fbdev, &fx, &fy) == 0) { sw = fx; sh = fy; } }
|
||||||
|
if (x < 0) x = 0;
|
||||||
|
if (x >= sw) x = sw - 1;
|
||||||
|
if (y < 0) y = 0;
|
||||||
|
if (y >= sh) y = sh - 1;
|
||||||
|
|
||||||
|
/* 仲裁:本机最近触摸后 5s 内忽略注入 */
|
||||||
|
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_tap(dev, x, y) < 0)
|
||||||
|
return send_err(sock, 500, "INTERNAL", "触摸注入失败(无法打开触摸设备)");
|
||||||
|
sbuf_t s = {0};
|
||||||
|
sA(&s, "{\"dispatched\":true,\"x\":%d,\"y\":%d}", x, y);
|
||||||
|
int ret = send_json(sock, 200, s.b ? s.b : "");
|
||||||
|
sbuf_free(&s);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 静态文件托管(web_root = 二进制所在目录) ---------------- */
|
/* ---------------- 静态文件托管(web_root = 二进制所在目录) ---------------- */
|
||||||
static char g_webroot[512];
|
static char g_webroot[512];
|
||||||
static const char *web_root(void)
|
static const char *web_root(void)
|
||||||
@@ -3503,6 +3684,10 @@ static int handle(int sock, req_t *r)
|
|||||||
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/system"))
|
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/system"))
|
||||||
return api_system(sock);
|
return api_system(sock);
|
||||||
|
|
||||||
|
/* ---- 实时画面(阶段 C) ---- */
|
||||||
|
if (!strcmp(r->method, "GET") && !strcmp(p, "/api/screen.jpeg"))
|
||||||
|
return api_screen_jpeg(sock, r);
|
||||||
|
|
||||||
/* ---- 系统动作 ---- */
|
/* ---- 系统动作 ---- */
|
||||||
if (!strcmp(r->method, "POST")) {
|
if (!strcmp(r->method, "POST")) {
|
||||||
if (!strcmp(p, "/api/system/reboot")) return api_system_action(sock, "reboot", r);
|
if (!strcmp(p, "/api/system/reboot")) return api_system_action(sock, "reboot", r);
|
||||||
@@ -3517,6 +3702,7 @@ static int handle(int sock, req_t *r)
|
|||||||
if (!strcmp(p, "/api/apps/install")) return api_apps_install(sock, r);
|
if (!strcmp(p, "/api/apps/install")) return api_apps_install(sock, r);
|
||||||
if (!strcmp(p, "/api/notifications/dismiss_all") || !strcmp(p, "/api/notifications/clear")) return api_notification_dismiss_all(sock);
|
if (!strcmp(p, "/api/notifications/dismiss_all") || !strcmp(p, "/api/notifications/clear")) return api_notification_dismiss_all(sock);
|
||||||
if (!strcmp(p, "/api/key")) return api_key_batch(sock, r);
|
if (!strcmp(p, "/api/key")) return api_key_batch(sock, r);
|
||||||
|
if (!strcmp(p, "/api/input/tap")) return api_input_tap(sock, r);
|
||||||
char seg[256];
|
char seg[256];
|
||||||
if (path_segment("/api/key/", p, seg, sizeof seg))
|
if (path_segment("/api/key/", p, seg, sizeof seg))
|
||||||
return api_key_single(sock, atoi(seg), r);
|
return api_key_single(sock, atoi(seg), r);
|
||||||
|
|||||||
Binary file not shown.
@@ -45,6 +45,10 @@
|
|||||||
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="7" y="3" width="10" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="15" r="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M10 7h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="7" y="3" width="10" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="15" r="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M10 7h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
||||||
<span class="nav-label">远程</span>
|
<span class="nav-label">远程</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-route="screen" title="实时画面 / 点击遥控">
|
||||||
|
<span class="nav-ico"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="8.5" cy="11" r="1.2" fill="currentColor"/><path d="M12 11l3.5 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></span>
|
||||||
|
<span class="nav-label">屏幕</span>
|
||||||
|
</button>
|
||||||
<button class="nav-item" data-route="system" title="系统信息 / 升级">
|
<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-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>
|
<span class="nav-label">系统</span>
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ input[type=range]{ width:100%; accent-color:var(--primary); }
|
|||||||
.app-card .app-meta{ font-size:12px; color:var(--on-surface-variant); }
|
.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-card .app-actions{ display:flex; gap:6px; margin-top:auto; flex-wrap:wrap; }
|
||||||
|
|
||||||
|
/* 实时屏幕(屏幕页) */
|
||||||
|
.screen-frame{ display:flex; justify-content:center; max-width:480px; margin:0 auto; }
|
||||||
|
.screen-frame img{
|
||||||
|
width:100%; max-width:480px; height:auto; display:block;
|
||||||
|
border-radius:var(--radius-m); background:#000; cursor:crosshair;
|
||||||
|
aspect-ratio:480/720; object-fit:contain; border:var(--outline-w) solid var(--outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
/* 远程按键区 */
|
/* 远程按键区 */
|
||||||
.key-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:12px; }
|
.key-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:12px; }
|
||||||
.key-btn{
|
.key-btn{
|
||||||
|
|||||||
@@ -805,6 +805,94 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- 10. 实时屏幕 / 点击遥控 ---------------- */
|
||||||
|
let screenTimer = null, screenBusy = false, screenOn = false;
|
||||||
|
let screenObjUrl = null, screenFps = 1;
|
||||||
|
function stopScreenPoll() {
|
||||||
|
if (screenTimer) { clearInterval(screenTimer); screenTimer = null; }
|
||||||
|
screenOn = false;
|
||||||
|
}
|
||||||
|
function restartScreenPoll() {
|
||||||
|
if (screenTimer) { clearInterval(screenTimer); screenTimer = null; }
|
||||||
|
const img = $('#screen-img'); if (!img) return;
|
||||||
|
screenOn = true;
|
||||||
|
const ms = Math.max(500, Math.round(1000 / screenFps));
|
||||||
|
screenTimer = setInterval(fetchScreen, ms);
|
||||||
|
}
|
||||||
|
async function fetchScreen() {
|
||||||
|
if (screenBusy) return;
|
||||||
|
screenBusy = true;
|
||||||
|
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',
|
||||||
|
Object.keys(headers).length ? { headers: headers } : undefined);
|
||||||
|
if (!res.ok) { $('#screen-status').textContent = '获取失败(HTTP ' + res.status + ')'; screenBusy = false; return; }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const img = $('#screen-img');
|
||||||
|
if (!img) { URL.revokeObjectURL(url); screenBusy = false; return; }
|
||||||
|
img.src = url;
|
||||||
|
if (screenObjUrl) URL.revokeObjectURL(screenObjUrl);
|
||||||
|
screenObjUrl = url;
|
||||||
|
$('#screen-status').textContent = '已更新 · ' + new Date().toLocaleTimeString();
|
||||||
|
} catch (e) {
|
||||||
|
$('#screen-status').textContent = '后端未连接';
|
||||||
|
}
|
||||||
|
screenBusy = false;
|
||||||
|
}
|
||||||
|
async function screenTap(e) {
|
||||||
|
const img = $('#screen-img'); if (!img) return;
|
||||||
|
const r = img.getBoundingClientRect();
|
||||||
|
if (!r.width || !r.height) return;
|
||||||
|
// 前端按 480x720(设备原生分辨率)换算
|
||||||
|
const nx = Math.round((e.clientX - r.left) / r.width * 480);
|
||||||
|
const ny = Math.round((e.clientY - r.top) / r.height * 720);
|
||||||
|
const nxCL = Math.max(0, Math.min(479, nx)), nyCL = Math.max(0, Math.min(719, ny));
|
||||||
|
try {
|
||||||
|
const d = await api('POST', '/api/input/tap', { x: nxCL, y: nyCL });
|
||||||
|
if (d && d.dispatched) toast('已注入 (' + nxCL + ',' + nyCL + ')');
|
||||||
|
else if (d && d.reason === 'local_touch_active') toast('本机触摸中,注入已暂停');
|
||||||
|
else toast('注入未派发');
|
||||||
|
} catch (err) { toast('注入失败:' + errText(err)); }
|
||||||
|
}
|
||||||
|
function renderScreen() {
|
||||||
|
screenFps = parseFloat(storageGet('screen.fps') || '1');
|
||||||
|
if (!(screenFps >= 0.2 && screenFps <= 2)) screenFps = 1;
|
||||||
|
let h = '<div class="page" id="screen-page">';
|
||||||
|
h += '<div class="card"><h3 class="card-title">实时画面</h3>' +
|
||||||
|
'<p class="muted">点击画面 = 在设备上点击该处。本机有人触摸时,注入自动暂停 5 秒;设备息屏请先点亮。</p>' +
|
||||||
|
'<div class="screen-frame">' +
|
||||||
|
'<img id="screen-img" alt="设备实时画面">' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="field-row" style="margin-top:12px">' +
|
||||||
|
'<label class="muted" for="screen-fps">刷新率</label>' +
|
||||||
|
'<input type="range" id="screen-fps" min="0.2" max="2" step="0.1" value="' + screenFps + '">' +
|
||||||
|
'<span class="muted" id="screen-fps-lbl" style="min-width:56px">' + screenFps.toFixed(1) + ' fps</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p class="muted" id="screen-status">载入中…</p>' +
|
||||||
|
'</div></div>';
|
||||||
|
$('#content').innerHTML = h;
|
||||||
|
const slider = $('#screen-fps');
|
||||||
|
slider.addEventListener('input', function () {
|
||||||
|
const v = parseFloat(slider.value);
|
||||||
|
screenFps = v; storageSet('screen.fps', String(v));
|
||||||
|
$('#screen-fps-lbl').textContent = v.toFixed(1) + ' fps';
|
||||||
|
restartScreenPoll();
|
||||||
|
});
|
||||||
|
$('#screen-img').addEventListener('click', screenTap);
|
||||||
|
restartScreenPoll();
|
||||||
|
fetchScreen();
|
||||||
|
}
|
||||||
|
function bindScreen() {
|
||||||
|
// 页面隐藏(切到其它标签页)时停刷;回到本页继续
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (document.hidden) stopScreenPoll();
|
||||||
|
else if (state.route === 'screen') restartScreenPoll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- 路由 ---------------- */
|
/* ---------------- 路由 ---------------- */
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
perf: { title: '实时性能', render: renderPerf, bind: null },
|
perf: { title: '实时性能', render: renderPerf, bind: null },
|
||||||
@@ -813,6 +901,7 @@
|
|||||||
apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps },
|
apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps },
|
||||||
settings: { title: '设置镜像', render: renderSettings, bind: bindSettings },
|
settings: { title: '设置镜像', render: renderSettings, bind: bindSettings },
|
||||||
remote: { title: '远程控制', render: renderRemote, bind: bindRemote },
|
remote: { title: '远程控制', render: renderRemote, bind: bindRemote },
|
||||||
|
screen: { title: '实时屏幕', render: renderScreen, bind: bindScreen },
|
||||||
system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem },
|
system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem },
|
||||||
logs: { title: '系统日志', render: renderLogs, bind: bindLogs },
|
logs: { title: '系统日志', render: renderLogs, bind: bindLogs },
|
||||||
notif: { title: '通知中心', render: renderNotif, bind: bindNotif }
|
notif: { title: '通知中心', render: renderNotif, bind: bindNotif }
|
||||||
@@ -828,6 +917,7 @@
|
|||||||
const r = ROUTES[id] || ROUTES.perf;
|
const r = ROUTES[id] || ROUTES.perf;
|
||||||
state.route = id;
|
state.route = id;
|
||||||
stopPerf();
|
stopPerf();
|
||||||
|
stopScreenPoll();
|
||||||
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
|
$$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id));
|
||||||
$('#page-title').textContent = r.title;
|
$('#page-title').textContent = r.title;
|
||||||
r.render();
|
r.render();
|
||||||
@@ -846,7 +936,7 @@
|
|||||||
bindNav();
|
bindNav();
|
||||||
bindThemeUi();
|
bindThemeUi();
|
||||||
bindSlider();
|
bindSlider();
|
||||||
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif();
|
bindFiles(); bindApps(); bindSettings(); bindRemote(); bindScreen(); bindSystem(); bindLogs(); bindNotif();
|
||||||
bindLogin();
|
bindLogin();
|
||||||
bindPwDlg();
|
bindPwDlg();
|
||||||
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用
|
// 首次进入探鉴权状态:启用且无有效会话 → 登录页;否则直接进应用
|
||||||
|
|||||||
Reference in new issue
Block a user