diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md new file mode 100644 index 0000000..a32bc74 --- /dev/null +++ b/patch_v2/meowpanel/README.md @@ -0,0 +1,246 @@ +# MeowPanel 后端(`meowpanel.c`) + +MeowOS(HiBy R3 Pro II 第三方系统,MIPS32 静态交叉编译)的**外置 Web 面板后端**:单进程、零第三方依赖的 +C 静态二进制,内嵌 HTTP/JSON 服务器 + 前端静态托管 + 框架专项服务 IPC 客户端 + `settings.conf` 设置镜像。 + +- **定位**(`MEOWPANEL_DESIGN.md`):外置于 TF 卡 `MeowOS/opt/MeowPanel/` 的系统组件,由 `meow_svcd` + 读 TF 卡 `MeowOS/services.d/meowpanel.conf` 启动、`meow_sysui` 监管。**不进固件**;无卡时系统核心 + (launcher/sdk/设置/通知)绝不依赖它。 +- **协议**:`devdocs/plan/MEOWPANEL_PROTOCOL.md`(HTTP/JSON 端点;本实现以「实现为准,协议/README 记录出入」)。 +- **数据路径**(`SYSTEM_FRAMEWORK_SERVICES.md §2/§4`):读侧(`/api/{status,perf,system}`)经 `meow_svc_perf` + IPC(`MSG_PERF_QUERY→RESULT`)聚合,**绝不各自爬 /proc**;动作侧(`/api/system/{reboot,shutdown,ota}`)经 + `meow_svc_power` IPC(`MSG_POWER_ACTION→STATUS`),不再直连 `poweroff`/写 mtd5。两专项服务为**板载 svcd 托管** + (`trigger=boot`),拔卡保留,与无卡依赖最小化自洽。 + +--- + +## 1. 编译 + +工具链:交叉编译器 `/home/Aska/Project/R3ProII/.tools/zig-mipsel-cc`(mipsel 32 位、musl 静态,可用)。 + +```bash +cd /home/Aska/Project/R3ProII/patch_v2/meowpanel +./build_meowpanel.sh # 交叉编译 → meowpanel(mipsel 静态,firmware-ready) +./build_meowpanel.sh host # 宿主编译 → meowpanel_host(本机 ld,仅供调试) +``` + +- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**。 +- 产出(本次构建实测): + - `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`, + 体积约 **166 KB**(固件阈值内)。md5:`ea1d6a58cbc0d312498d3925e2fd2127`。 + - `meowpanel_host`:aarch64 动态链接(宿主编译,仅供调试),体积约 **77 KB**。 +- 依赖:仅 libc + Linux 内核头(`linux/input.h`),零第三方库(设备无 python3/lua)。 +- 头文件引用:`-I patch_v2/meow_launcherd`(`meow_ipc.h` 帧格式 / `m_perf_*`/`m_power_*`/`POWER_*`, + **header 为权威源**,结构体字段以它为准)。 + +## 2. 部署到 TF 卡 + +```bash +# 1) 后端 + 前端(web/index.html + meowpanel.css + meowpanel.js)放在同一目录 +sudo mkdir -p /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel +sudo cp patch_v2/meowpanel/meowpanel /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/ +sudo cp patch_v2/meowpanel/web/index.html /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/ +sudo cp patch_v2/meowpanel/web/meowpanel.css /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/ +sudo cp patch_v2/meowpanel/web/meowpanel.js /usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/ + +# 2) 服务配置(svcd 读 TF 卡 services.d 拉起) +sudo mkdir -p /usr/data/mnt/sd_0/MeowOS/services.d +sudo cp patch_v2/meowpanel/services.d/meowpanel.conf /usr/data/mnt/sd_0/MeowOS/services.d/ +``` + +说明: +- 前端资源与后端**同目录**(后端 `GET /` → `index.html`,`GET /meowpanel.css`/`meowpanel.js` 同目录取)。 + 后端也支持 `GET /assets/`(映射到同目录文件),前端当前用根相对路径。 +- 服务启动后整卡由 `meow_svcd` 按 `trigger=boot` 拉起;`meow_sysui` 监管(restart/backoff/max_restart)。 +- **无卡**:`MeowOS/services.d/` 不可读 → svcd 跳过本服务;系统核心照常。 + +## 3. 访问 + +浏览器(同局域网): +``` +http://.local:8080/ +``` +- mDNS 由板载 `meow_mdns` 应答 `.local`。 +- 端口默认 **8080**,可用 `-p PORT` / 环境变量 `MEOWPORT` / 与后端同目录 `meowpanel.conf`(`port=N`)覆盖。 + (优先级:`-p` > `MEOWPORT` > `meowpanel.conf` > 默认 8080。) +- 无鉴权(设备内网信任模型;token 鉴权为 MEOWPANEL_PROTOCOL §1.3 草案项,未实现)。 + +--- + +## 4. 端点实现清单(对照 MEOWPANEL_PROTOCOL §2 端点总览) + +### 已实现(返回 200/2xx 或语义错误码) + +| 方法 | 路径 | 说明 | 实现 | +|---|---|---|---| +| GET | `/`、`/meowpanel.css`、`/meowpanel.js`、`/assets/` | 静态托管 | ✅ | +| GET | `/api/status` | 性能+系统(`meow_svc_perf` IPC subset=all 透传) | ✅ | +| GET | `/api/perf` | 仅性能(subset=perf 透传) | ✅ | +| GET | `/api/system` | perf IPC + boot/network/ota 局部补全(见注) | ✅ | +| POST | `/api/system/reboot`、`/shutdown`、`/ota` | `meow_svc_power` IPC | ✅ | +| GET | `/api/settings` | 全量+元数据(items/count/groups) | ✅ | +| GET | `/api/settings/:key` | 单项 | ✅ | +| PUT | `/api/settings/:key` | 写单项(clamp/枚举校验) | ✅(越界/非法枚举→422) | +| POST | `/api/settings` | 批量写 | ✅ | +| POST | `/api/settings/restore` | 恢复默认 | ✅ | +| GET | `/api/theme` | 读主题 | ✅ | +| PUT | `/api/theme` | 写主题(style/theme/background 子集) | ✅(非法→422) | +| GET | `/api/files?path=` | 目录列表(白名单) | ✅(无卡→503) | +| GET | `/api/files/read?path=` | 读文本(<1MB;二进制→415) | ✅ | +| GET | `/api/files/download?path=` | 下载流 | ✅ | +| POST | `/api/files/upload` | multipart 上传 | ✅ | +| POST | `/api/files/mkdir` | 新建目录(已存在→409) | ✅ | +| POST | `/api/files/rename` | 重命名 | ✅ | +| DELETE | `/api/files?path=` | 删除(可 recursive=1;非空未 recursive→409) | ✅ | +| GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ | +| GET | `/api/apps/:pkg` | 应用详情 | ✅ | +| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ | +| GET | `/api/apps/:pkg/export` | `nyai export` 下载流 | ✅ | +| DELETE | `/api/apps/:pkg` | `nyai remove [--purge]` | ✅ | +| POST | `/api/key/:code` | 按键注入(tap/long) | ✅(物理键红线内) | +| POST | `/api/key` | 批量/顺序注入 | ✅ | +| GET | `/api/logs` | `MeowOS/logs` 文件列表 | ✅(无卡→503) | +| GET | `/api/logs/read?file=&lines=` | 读日志(上限 1000 行;`..`/绝对→403;敏感名→403) | ✅ | +| GET | `/api/notifications` | **501** | ⚠️ | +| DELETE | `/api/notifications/:id` | **501** | ⚠️ | +| POST | `/api/notifications/dismiss_all` | **501** | ⚠️ | + +### 返回 501 `NOT_IMPLEMENTED` 的端点(诚实标注) + +| 端点 | 原因 | +|---|---| +| `/api/notifications`、`/api/notifications/:id`、`/api/notifications/dismiss_all` | 通知存储为 **launcher 进程内存**(`meow_notif.c g_slots[]`,NOTIFICATION_SYSTEM §4),后端为独立 svcd 子进程,**跨进程不可达**那批内存槽;框架只在关机前 dump 到 TF `MeowOS/notifications/notifications.log`(CSV 追加,非活跃快照)。**需为通知后端另开 IPC 查询/清撤通道**(如 `MSG_NOTIFY_LIST` 应答)或持久化活跃集,见 MEOWPANEL_PROTOCOL §6 待定案。 | +| `/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` 显示「该功能后端尚未实现」(`ok:false,error.code=NOT_IMPLEMENTED`)。 + +--- + +## 5. 关键实现说明 + +### 5.1 HTTP 路由 / JSON 序列化 +- **单线程 + poll 串行**(面板访问量极小)。`accept` → `read_request`(先读头到 `\r\n\r\n`,**在缓冲区内搜索**头边界, + 处理「头+body 同段到达」,再按 `Content-Length` 精确读 body)→ `handle` → 写响应 → 关闭连接(`Connection: close`)。 +- 读/写均有 `poll` 超时(读头部 5s、body 分段 5s,IPC 2s)。`SIGPIPE` 忽略。 +- JSON 响应信封:成功 `{"ok":true,"data":}`;失败 `{"ok":false,"error":{"code","message"}}` + (MEOWPANEL_PROTOCOL §1.1/§1.2,HTTP 状态码映射照抄)。 +- **无 JSON 库**:手写最小递归下降解析器(对象/数组/字符串转义/数字/bool/null),仅覆盖实际用到的收发。 +- 下载/上传用流式(`multipart/form-data` 极简解析:按 boundary 切,取 `path`+`file` 字段)。 + +### 5.2 IPC 对接(meow_svc_perf / meow_svc_power) +- **帧**:复用 `meow_ipc.h`——16B 头(magic `0x4D4F5701`/version 1/type/req_id/len,全小端)+ 变长负载, + `MEOW_IPC_MAX=4096`。帧读写 `x_read_full`/`x_write_full` + `send_frame`/`recv_frame`(与 meow_svc_perf.c 对称)。 +- **握手**:连接后先 `MSG_HELLO`(`app_id="os.meow.panel"`,系统面板身份,§5 二期拟名)→ 读 `MSG_ACK(code=0)`。 +- **读侧**:`MSG_PERF_QUERY`(`m_perf_query_t.subset`)→ `MSG_PERF_RESULT`(`m_perf_result_t`,`code`/`json_len`/`json`), + **直接透传**服务端聚合 JSON(`/api/status`=全部、`/api/perf`=perf 子集、`/api/system`=全量取 uptime/storage/tfcard/system)。 + 读取防溢出:`json_len` 以 `sizeof(m_perf_result_t.json)-1` 与 `cap` 双钳位。 +- **动作侧**:`MSG_POWER_ACTION`(`m_power_action_t.cmd=POWER_REBOOT/SHUTDOWN/UPGRADE`)→ `MSG_POWER_STATUS`(`code`/`msg`)。 + 收到 `MEOW_OK` → `{"accepted":true}`;否则按 `code`(PERM→403 / NOENT/IO→503 / 其余→500)返回错误信封。 +- **失败语义**:socket 不存在/连接拒/超时/应答非 OK → 返回 500 `INTERNAL`(或 503 `NO_CARD` 当缺升级文件) + 并带中文 message。**绝不静默失败、绝不降级直连 /proc 或 poweroff**(单一权威源)。 +- 连接超时:非阻塞 connect + `poll(POLLOUT)` 2s,`SO_RCVTIMEO`/`SO_SNDTIMEO` 兜底。 + +### 5.3 设置镜像(读写板载 `settings.conf`) +- **不旁路**:只经 `settings_get_value`(`=` 切分 + key 去首尾空白精确匹配,防 `audio.volume` 误配 `audio.volume_max`, + 照 `meow_ui_vol_read`/`meow_svc_power settings_get_int_trim` 同款解析)与 `settings_write_one`(`"key = value\n"`, + 保留 `# meow-settings 1` 头、逐行重写、缺键追加;目录缺失自动 `mkdir`)。**不绕过此函数直接改文件**。 +- **键表/默认值来源**:`patch_v2/meow_launcherd/meow_settings.c` 的 `g_defs[]`(**权威来源**,逐键一致), + 含 type/def/min/max/enum_names/enum_labels;`GET /api/settings` 返回 `items/count/groups`(分组按键前缀, + 如 `display.*`→显示、`font.*|ui.*`→字体主题…)。 +- 类型:`int`→number(带 min/max/def 字符串)、`bool`→原生布尔、`enum`→`options(name/label)+default_name+def`、 + `str`→string。**越界/非法枚举 → 422 `UNSUPPORTED`**。 +- 语义 clamp:`display.brightness` 读/写都 clamp 到 `[display.brightness_min(默认1), 100]`(防 101 闪屏, + 与 `meow_settings_brightness_clamped`/`clamp_brightness` 同口径);`boot.default` 写后同步 `/data/meow_boot_default` + (meow→0/hiby→1,同 `meow_settings_save`)。 +- 注:后端为独立进程,无法调用 launcher 进程内的 `meow_settings_*`(那是其内存表),故**直接镜像同一份 + `settings.conf`**,保持与实体设置页/CLI 同一存储契约(协议 §3.2 说「经 meow_settings_*」,此处以 + 「镜像同一文件 + 同款 parse/save 契约」落地,见 §8 出入记录)。 + +### 5.4 文件白名单 +- 根 `/usr/data/mnt/sd_0`;白名单子目录 `MeowOS/Music/MeowData/MeowAPPs/book`(`allowed` 数组)。 +- 路径经 `normalize_path` 规范化(去 `.`/`..`,拒绝逃逸出根)后 `path_in_whitelist` 校验(== 根或属于某一白名单 + 一级子目录)。**`..`/绝对路径逃逸 → 403 `FORBIDDEN`**;无卡 → 503 `NO_CARD`。 +- 读取拒绝敏感名(`settings.conf`/`setting.bak`/`.env`/`passwd`/`shadow`/`meow.conf`/`user.ini`/`config.json`)。 +- 列表/上传/下载/读文本(1MB+二进制→415)/mkdir(已存在→409)/rename(new_name 不含 `/`)/delete(递归,非空未 recursive→409)。 + +### 5.5 nyai 对接 +- 包管理器 CLI 绝对路径 `/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai`(M2.12+ 固件经 `meow_card_path.sh` 前插 PATH,可直接调; + 本后端用绝对路径,兼容更早固件)。包名 = `app.json` 的 `package` = `.mpk` 文件名茎。 +- `install`:multipart 上传 `.mpk` 落临时文件(`MeowOS/tmp/`)或 JSON `{path}` → `nyai install `;用 `popen` 捕获输出+退出码, + 非 0 → 422。`export`:`nyai export ` → 以 `Content-Disposition: attachment` 流式下发。`remove`:`nyai remove [--purge]`。 +- `GET /api/apps` / `:pkg` 详情:直接扫描 `MeowAPPs//app.json`(手写 JSON 解析提取 name/version/exec/framework/immersive/fullscreen), + 不依赖 nyai(nyai 未部署时列表/详情仍可用)。解析失败项 `invalid:true`。 +- `nyai` 未部署 → install/export/remove 返回 500(带 nyai 输出)。 + +### 5.6 按键注入通道 +- **复用现有先例**:`patch_v2/uinput_tap/uinput_tap.c` 的 `write`-到-evdev 注入机制。launcher 实际轮询 + `meow_launcher_internal.h`:`/dev/input/event0`(md-gpio-keys: POWER/PREVIOUS)、`/dev/input/event2` + (jz adc keyboard: VOL/PLAY/NEXT)。后端 open `O_WRONLY` 写 `EV_KEY press/release + SYN_REPORT` 即可到达 launcher 按键处理。 +- **物理键红线**(APPS.md / 任务约束):只注入音量(114/115)/电源(116)/播放(164)/上下曲(163/165); + **禁止注入 返回(158)/主页(172)/确认(28)/退出(96)** → 403 `FORBIDDEN`。 +- `tap`(默认,抬起间隔 80ms)、`long`(按住 ms,缺省 3000);`hold` → 501。 +- 局限:若 `/dev/input/event0/event2` 在设备上不可写(权限/路径差异),注入失败 → 500;注入为**即时性注入**, + 不在 launcher 内维护长按状态机(与实体按键的 `longpressed` 语义近似,见 README 已知边界)。 + +### 5.7 日志 +- `GET /api/logs`:`MeowOS/logs` 下常规文件列表(跳过敏感名)。 +- `GET /api/logs/read?file=&lines=`:`file` 必须相对 `MeowOS/logs/`,**禁 `..`/绝对路径 → 403**,敏感名 → 403; + 缺省 `lines=200`,上限 `1000`;返回 `{file,lines[],total,truncated}`。 + +### 5.8 通知(501 及其依据) +见 §4 501 表:通知存储为 launcher 进程内存,后端跨进程不可读/清撤。已在源码 `api_notifications*` 注释「为何未实现 + +后续如何接」(通知后端加 `MSG_NOTIFY_LIST` 查询/清撤 IPC,或持久化活跃集到 TF `MeowOS/notifications/` 供只读)。 + +--- + +## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md) + +1. **`ui.style` 值域**:`meow_settings.c g_defs` 源码为 `"md3,miui"`(label "Material You,MIUI"),但 + SYSTEM_SETTINGS.md 定案 + MEOWPANEL_PROTOCOL §5 一致行为 **`miuix`**(对应 `MEOW_STYLE_MIUIX`),前端 `data-style` 也用 `miuix`。 + → 本表对齐为 `"md3,miuix"` / "Material You,MIUIx"。**建议**同步改回 `meow_settings.c g_defs`(否则实体设置页与面板值域漂移)。 +2. **设置项映射**:协议 §3.2 称后端经 `meow_settings_*` 读写;因后端为独立进程,无法链接 launcher 内 API, + **改为直接镜像同一份 `settings.conf`**(同款 parse/save 契约)。改哪边都会落同一文件,实体/网页/CLI 仍同步(listener/落盘)。 +3. **`display.brightness` 元数据**:`g_defs` 为 `min=0 max=255 def=101`;但语义 clamp 到 `[brightness_min(1),100]` + (防 101 闪屏)。后端给前端 `min=1 max=100`(协议 §3.2 期望),读值经 clamp 后返回(默认→100)。若回改协议可注明「原始表 0-255,面板展示语义 1-100」。 +4. **`/api/system` 的 `network`/`ota`**:由性能服务聚合(`SYSTEM_FRAMEWORK_SERVICES §4.1`)后,后端只补 + `boot.default`(读 `/data/meow_boot_default`)、`network.hostname_local`(hostname+.local)与 `ota{current=version,available:false,url:null}`; + **wifi/ethernet/ota.available 不编造**(服务未聚合,前端对缺失字段显示占位)。若回改协议可注明「network/ota 待并入 perf 服务」。 +5. **`/api/files` 上传/删除/重命名等 path**:前端对这些操作传**绝对路径**(`d.path/子项`),列表传**相对 root**; + 后端 `normalize_path` 对两者均容错(相对则拼 root,绝对则直用),符合协议「白名单根 + 规范化」。 +6. **`GET /api/files` 返回值**:`files_list` 返回 `500` 语义时以 500 封装(协议未定义 `root` 之外错误枚举,用通用错误信封)。 + +--- + +## 7. 已知边界 / 风险 + +- **单线程串行**:面板访问量极小,但并发请求会被排队(长上传/下载期间其它请求等待)。对面板用途可接受。 +- **无鉴权**:设备内网信任模型(协议 §1.3 token 鉴权待定案)。**高危端点**(`reboot/shutdown/ota/apps/删除/上传)无鉴权, + 请勿把 8080 暴露到不可信网络。`meow_svc_power` 服务端做 `SO_PEERCRED uid==0` 基础校验(root 才受理动作),后端以 root 运行可过;launcher 电源菜单/后端同链路。 +- **IPC 服务未起**:`meow_svc_perf`/`meow_svc_power` 为板载 `trigger=boot` 服务,正常恒在;若异常退出,读侧返回 500 `INTERNAL`, + 动作侧返回 500/503,绝不静默。请勿拔除这两服务。 +- **升级保护**:`POWER_UPGRADE` 由 `meow_svc_power` 在写 mtd5 前校验卡根 `r3proii.upt`(存在/可读/非空/size>0),失败不写 mtd5;期望 md5/hash 校验留二期。 +- **文件/日志/应用均依赖 TF 卡**:无卡统一 503 `NO_CARD`(不阻塞系统核心)。`settings`/`system`/`perf`/`key` 无卡也可用(板载/rootfs)。 +- **`/api/files/upload` 上传路径**:multipart 需要 `path` 字段(目标目录);缺失时上传文件会被忽略(前端总是带着 `path`)。 +- **数据一致性**:设置直接写 `settings.conf`;`display.brightness`/`boot.default` 的 clamp/同步与 launcher `meow_settings_*` 同口径。 + 若 launcher 未来改 clamp 逻辑,需同步本表。 +- **存储为 `meowpanel` 编译时刻写死**的 socket/路径会因 `--sock-perf/--sock-power`(qemu sim)可覆盖,但默认走 `/usr/data/meow_*.sock`。 + +--- + +## 8. 文件清单(本目录) + +``` +patch_v2/meowpanel/ +├── meowpanel.c # 后端源码(单文件,约 2700 行) +├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning) +├── meowpanel # 交叉产物(mipsel 静态,165 KB,md5 a8224ef9…) +├── meowpanel_host # 宿主编译(本机 ld,调试用) +├── services.d/ +│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf +├── web/ # 前端(已存在;部署时拷到后端同目录) +│ ├── index.html +│ ├── meowpanel.css +│ └── meowpanel.js +└── README.md # 本文件 +``` diff --git a/patch_v2/meowpanel/build_meowpanel.sh b/patch_v2/meowpanel/build_meowpanel.sh new file mode 100755 index 0000000..645c60f --- /dev/null +++ b/patch_v2/meowpanel/build_meowpanel.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# build_meowpanel.sh — MeowPanel 后端交叉编译(mipsel 静态)+ 宿主编译可选。 +# +# 目标:R3 Pro II / MeowOS(mipsel-linux-muslsf,静态)。后端仅 POSIX + Linux 基接口 +# (复用 meow_ipc.h 帧格式,无 launcher 依赖),单文件满足「卡上、静态、零第三方依赖」。 +# +# 用法: +# ./build_meowpanel.sh # 交叉编译 → patch_v2/meowpanel/meowpanel(mipsel 静态) +# ./build_meowpanel.sh host # 宿主编译 → patch_v2/meowpanel/meowpanel_host +set -e +cd "$(dirname "$0")" +ROOT="$HOME/Project/R3ProII" +CC="$ROOT/.tools/zig-mipsel-cc" +I_HDR="$ROOT/patch_v2/meow_launcherd" # meow_ipc.h(帧/m_perf_query_t/m_perf_result_t/POWER_*) + +if [ "$1" = "host" ]; then + HOST_CC="${CC_HOST:-cc}" + "$HOST_CC" -Os -Wall -Wextra -I"$I_HDR" meowpanel.c -o meowpanel_host + echo "== host build ==" + ls -la meowpanel_host + file meowpanel_host + exit 0 +fi + +"$CC" -Os -Wall -Wextra -static -I"$I_HDR" meowpanel.c -o meowpanel +echo "== meowpanel (mipsel static) ==" +ls -la meowpanel +md5sum meowpanel +file meowpanel diff --git a/patch_v2/meowpanel/meowpanel b/patch_v2/meowpanel/meowpanel new file mode 100755 index 0000000..dfb9143 Binary files /dev/null and b/patch_v2/meowpanel/meowpanel differ diff --git a/patch_v2/meowpanel/meowpanel.c b/patch_v2/meowpanel/meowpanel.c new file mode 100644 index 0000000..e1c6da5 --- /dev/null +++ b/patch_v2/meowpanel/meowpanel.c @@ -0,0 +1,2815 @@ +#define _GNU_SOURCE /* gethostname/strcasestr/strnlen 等 GNU/musl 扩展 */ + +/* meowpanel.c — MeowOS Web 面板后端(MeowPanel)。 + * + * 定位(MEOWPANEL_DESIGN §1/§2):外置于 TF 卡 `MeowOS/opt/MeowPanel/` 的系统组件 + * (HTTP/JSON 后端 + 前端静态托管),由 meow_svcd 读 TF 卡 `MeowOS/services.d/meowpanel.conf` + * 启动、meow_sysui 监管。**不进固件**;无卡时系统核心绝不对它有任何依赖。 + * + * 架构(MEOWPANEL_PROTOCOL §1/§2 + SYSTEM_FRAMEWORK_SERVICES §4): + * - 单进程单线程 + poll 串行处理(面板访问量极小);内嵌 HTTP server。 + * - 读侧(/api/status,/api/perf,/api/system)经 meow_svc_perf IPC(MSG_PERF_QUERY→RESULT) + * 聚合,**绝不各自爬 /proc**(单一权威源)。 + * - 动作侧(/api/system/{reboot,shutdown,ota})经 meow_svc_power IPC(MSG_POWER_ACTION→STATUS), + * 不再直连 poweroff/写 mtd5。 + * - 设置镜像(/api/settings*,/api/theme*)直接读写板载 /usr/data/meow/settings.conf—— + * **与 meow_ui_vol_read / meow_svc_power settings_get_int_trim 同款解析**('=' 切分 + + * key 去首尾空白精确匹配;写用 "key = value\n")。后端是独立进程,无法链接 launcher 内的 + * meow_settings_*(那是 launcher 进程内存表),故直接镜像文件,保持同一存储契约。 + * - 文件/应用/日志/通知:白名单根 /usr/data/mnt/sd_0 下 MeowOS/Music/MeowData/MeowAPPs/book。 + * + * 编译(mipsel 静态):build_meowpanel.sh;宿主编译 build_meowpanel.sh host。 + * 依赖:仅 POSIX + libc(含 linux/input.h 内核头),零第三方库。设备无 python3/lua。 + */ +#include +#include +#include +#include /* strcasecmp/strncasecmp */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include /* KEY_* / EV_KEY / EV_SYN / struct input_event(按键注入) */ + +#include "meow_ipc.h" /* 帧格式/消息号 + m_perf_query_t/m_perf_result_t + POWER_* 枚举 */ + +/* ---------------- 常量 ---------------- */ +#define PANEL_VERSION "0.1" +#define DEFAULT_PORT 8080 +#define SETTINGS_FILE "/usr/data/meow/settings.conf" +#define SETTINGS_HDR "# meow-settings 1" +#define TF_ROOT "/usr/data/mnt/sd_0" +#define TF_CARD_DIR "/usr/data/mnt/sd_0/MeowOS" +#define MEOWAPPS_DIR "/usr/data/mnt/sd_0/MeowAPPs" +#define LOGS_DIR "/usr/data/mnt/sd_0/MeowOS/logs" +#define PERF_SOCK "/usr/data/meow_perf.sock" +#define POWER_SOCK "/usr/data/meow_power.sock" +#define NYAI_PATH "/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai" +#define BOOT_DEFAULT_FILE "/data/meow_boot_default" +#define VERSION_FILE "/etc/meowos_version" +#define HOSTNAME_FILE "" + +#define IPC_TIMEOUT_MS 2000 +#define MAX_HTTP_HEADER (64 * 1024) +#define MAX_HTTP_BODY (16 * 1024 * 1024) /* 上传上限 */ +#define MAX_JSON_IN 65536 /* JSON body 解析上限 */ +#define MAX_STATIC_FREEBUF (2 * 1024 * 1024) +#define LOG_MAX_LINES 1000 + +/* 白名单子目录(TF 卡根下) */ +static const char *g_allowed_dirs[] = { "MeowOS", "Music", "MeowData", "MeowAPPs", "book" }; +#define ALLOWED_COUNT ((int)(sizeof g_allowed_dirs / sizeof g_allowed_dirs[0])) + +/* 日志敏感文件黑名单(相对 LOGS_DIR 或文件名) */ +static const char *g_sensitive_names[] = { + "settings.conf", "setting.bak", ".env", "passwd", "shadow", + "meow.conf", "user.ini", "config.json", +}; +#define SENSITIVE_COUNT ((int)(sizeof g_sensitive_names / sizeof g_sensitive_names[0])) + +static const char *g_sock_perf = PERF_SOCK; +static const char *g_sock_power = POWER_SOCK; + +/* ---------------- 有界动态字符串(sbuf) ---------------- */ +typedef struct { char *b; size_t n; size_t cap; } sbuf_t; + +static int sb_reserve(sbuf_t *s, size_t extra) +{ + if (s->n + extra + 1 <= s->cap) return 0; + size_t ncap = s->cap ? s->cap : 256; + while (ncap < s->n + extra + 1) ncap *= 2; + char *nb = realloc(s->b, ncap); + if (!nb) return -1; + s->b = nb; + s->cap = ncap; + return 0; +} + +static void sA(sbuf_t *s, const char *fmt, ...) +{ + char tmp[512]; + va_list ap, ap2; + va_start(ap, fmt); + va_copy(ap2, ap); + int w = vsnprintf(tmp, sizeof tmp, fmt, ap); + va_end(ap); + if (w < 0) { va_end(ap2); return; } + if ((size_t)w < sizeof tmp) { + if (sb_reserve(s, (size_t)w) < 0) { va_end(ap2); return; } + memcpy(s->b + s->n, tmp, (size_t)w); + s->n += (size_t)w; + } else { + /* 超长 %s 等:格式化结果超出固定缓冲,动态分配后再追加(防截断坏 JSON) */ + char *big = malloc((size_t)w + 1); + if (!big) { va_end(ap2); return; } + int w2 = vsnprintf(big, (size_t)w + 1, fmt, ap2); + va_end(ap2); + if (w2 < 0) { free(big); return; } + if (sb_reserve(s, (size_t)w2) < 0) { free(big); return; } + memcpy(s->b + s->n, big, (size_t)w2); + s->n += (size_t)w2; + free(big); + } + s->b[s->n] = 0; +} + +/* JSON 字符串转义(version/hostname/文本值;UTF-8 按字节透传,控制字符转 \u00xx) */ +static void sA_str(sbuf_t *s, const char *v) +{ + if (!v) v = ""; + sA(s, "\""); + for (const char *p = v; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '"' || c == '\\') { sA(s, "\\%c", (int)c); } + else if (c < 0x20) { sA(s, "\\u%04x", c); } + else sA(s, "%c", (int)c); + } + sA(s, "\""); +} + +static void sbuf_free(sbuf_t *s) { free(s->b); s->b = NULL; s->n = s->cap = 0; } + +/* ---------------- 响应函数前向声明(文件列表等先于定义处使用) ---------------- */ +static int write_full(int sock, const void *b, size_t n); +static int send_head(int sock, int status, const char *ctype, long long clen, const char *extra); +static int send_raw(int sock, int status, const char *ctype, const void *body, size_t bodylen); +static int send_ok(int sock, const char *data); +static int send_err(int sock, int status, const char *code, const char *msg); +static int send_json(int sock, int status, const char *data); +static int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename); + +/* ---------------- CSV(enum 名/标签)非改写取值;定义在设置列表区,先声明供 settings_coerce 用 ---------------- */ +static const char *csv_at(const char *s, int idx, char *buf, int buflen); +static int csv_count(const char *s); + +/* ---------------- 最简 JSON 解析器(无第三方库) ---------------- + * 只覆盖 MeowPanel 实际用到的:对象 / 数组 / 字符串(含转义) / 数字 / bool / null。 + * value 树:J_OBJ 的 child 为键值对链(str=key,child=value,next=下个 pair); + * J_ARR 的 child 为元素链(next=下个元素)。 + */ +enum { J_NULL, J_BOOL, J_NUM, J_STR, J_ARR, J_OBJ }; + +typedef struct jv { + int type; + int bval; /* J_BOOL */ + long long inum; /* J_NUM 整数值 */ + double dnum; /* J_NUM 浮点值 */ + char *str; /* J_STR: 值; J_OBJ: key */ + struct jv *child; /* J_ARR: 头; J_OBJ: 头 pair */ + struct jv *next; /* 链 */ +} jv_t; + +typedef struct { const char *p; const char *end; int err; } jvp_t; + +static jv_t *jv_alloc(int type) +{ + jv_t *v = calloc(1, sizeof *v); + if (v) v->type = type; + return v; +} + +static void jv_free(jv_t *v) +{ + if (!v) return; + jv_t *c = v->child; + while (c) { jv_t *nx = c->next; jv_free(c->child); free(c->str); free(c); c = nx; } + free(v->str); + free(v); +} + +static void jvp_skip_ws(jvp_t *t) +{ + while (t->p < t->end && (*t->p == ' ' || *t->p == '\t' || *t->p == '\n' || *t->p == '\r')) t->p++; +} + +static char *jv_parse_string(jvp_t *t) +{ + if (t->p >= t->end || *t->p != '"') { t->err = 1; return NULL; } + t->p++; + sbuf_t s = {0}; + while (t->p < t->end && *t->p != '"') { + unsigned char c = (unsigned char)*t->p++; + if (c == '\\') { + if (t->p >= t->end) { t->err = 1; break; } + char e = *t->p++; + switch (e) { + case 'n': sA(&s, "\n"); break; + case 't': sA(&s, "\t"); break; + case 'r': sA(&s, "\r"); break; + case 'b': sA(&s, "\b"); break; + case 'f': sA(&s, "\f"); break; + case 'u': { + if (t->end - t->p < 4) { t->err = 1; sbuf_free(&s); return NULL; } + unsigned int cp = 0; + for (int i = 0; i < 4; i++) { char h = *t->p++; cp <<= 4; if (h>='0'&&h<='9') cp|=h-'0'; else if (h>='a'&&h<='f') cp|=h-'a'+10; else if (h>='A'&&h<='F') cp|=h-'A'+10; else { t->err=1; sbuf_free(&s); return NULL; } } + /* 只支持 BMP 常见字符;代理对按 UTF-8 重算(简化:cp<0x80 单字节,否则保守转发) */ + if (cp < 0x80) sA(&s, "%c", (int)cp); + else if (cp < 0x800) { sA(&s, "%c", 0xc0|(cp>>6)); sA(&s, "%c", 0x80|(cp&0x3f)); } + else { sA(&s, "%c", 0xe0|(cp>>12)); sA(&s, "%c", 0x80|((cp>>6)&0x3f)); sA(&s, "%c", 0x80|(cp&0x3f)); } + break; } + default: sA(&s, "%c", (int)e); break; + } + } else if (c < 0x20) { t->err = 1; break; } + else sA(&s, "%c", (int)c); + } + if (t->p >= t->end || *t->p != '"') { t->err = 1; sbuf_free(&s); return NULL; } + t->p++; + if (!s.b) { s.b = calloc(1, 1); } + return s.b; +} + +static jv_t *jv_parse_value(jvp_t *t); + +static jv_t *jv_parse_object(jvp_t *t) +{ + t->p++; + jv_t *obj = jv_alloc(J_OBJ); + if (!obj) { t->err = 1; return NULL; } + jv_t **tail = &obj->child; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == '}') { t->p++; return obj; } + for (;;) { + jvp_skip_ws(t); + if (t->p >= t->end) { t->err = 1; jv_free(obj); return NULL; } + char *key = jv_parse_string(t); + if (!key) { jv_free(obj); return NULL; } + jvp_skip_ws(t); + if (t->p >= t->end || *t->p != ':') { t->err = 1; free(key); jv_free(obj); return NULL; } + t->p++; + jv_t *val = jv_parse_value(t); + if (!val) { t->err = 1; free(key); jv_free(obj); return NULL; } + jv_t *pair = calloc(1, sizeof *pair); + if (!pair) { t->err = 1; free(key); jv_free(val); jv_free(obj); return NULL; } + pair->type = J_OBJ; + pair->str = key; + pair->child = val; + *tail = pair; + tail = &pair->next; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == ',') { t->p++; continue; } + if (t->p < t->end && *t->p == '}') { t->p++; return obj; } + t->err = 1; + jv_free(obj); + return NULL; + } +} + +static jv_t *jv_parse_array(jvp_t *t) +{ + t->p++; + jv_t *arr = jv_alloc(J_ARR); + if (!arr) { t->err = 1; return NULL; } + jv_t **tail = &arr->child; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == ']') { t->p++; return arr; } + for (;;) { + jv_t *v = jv_parse_value(t); + if (!v) { jv_free(arr); return NULL; } + *tail = v; + tail = &v->next; + jvp_skip_ws(t); + if (t->p < t->end && *t->p == ',') { t->p++; continue; } + if (t->p < t->end && *t->p == ']') { t->p++; return arr; } + t->err = 1; + jv_free(arr); + return NULL; + } +} + +static jv_t *jv_parse_value(jvp_t *t) +{ + jvp_skip_ws(t); + if (t->p >= t->end) { t->err = 1; return NULL; } + char c = *t->p; + if (c == '{') return jv_parse_object(t); + if (c == '[') return jv_parse_array(t); + if (c == '"') { + char *s = jv_parse_string(t); + if (!s) return NULL; + jv_t *v = jv_alloc(J_STR); + if (!v) { free(s); t->err = 1; return NULL; } + v->str = s; + return v; + } + if (!strncmp(t->p, "true", 4)) { t->p += 4; jv_t *v = jv_alloc(J_BOOL); if (v) v->bval = 1; return v; } + if (!strncmp(t->p, "false", 5)) { t->p += 5; return jv_alloc(J_BOOL); } + if (!strncmp(t->p, "null", 4)) { t->p += 4; return jv_alloc(J_NULL); } + /* number */ + const char *q = t->p; + int isint = 1; + while (q < t->end && (*q == '-' || *q == '+' || *q == '.' || *q == 'e' || *q == 'E' || isdigit((unsigned char)*q))) { + if (*q == '.' || *q == 'e' || *q == 'E') isint = 0; + q++; + } + if (q == t->p) { t->err = 1; return NULL; } + char tmp[64]; + size_t dlen = (size_t)(q - t->p); + if (dlen >= sizeof tmp) dlen = sizeof tmp - 1; + memcpy(tmp, t->p, dlen); + tmp[dlen] = 0; + t->p = q; + jv_t *v = jv_alloc(J_NUM); + if (!v) { t->err = 1; return NULL; } + v->dnum = strtod(tmp, NULL); + v->inum = (long long)strtoll(tmp, NULL, 10); + (void)isint; + return v; +} + +static jv_t *jv_parse(const char *text, int len) +{ + jvp_t t; + t.p = text; + t.end = text + len; + t.err = 0; + jv_t *v = jv_parse_value(&t); + if (!v || t.err) { if (v) jv_free(v); return NULL; } + return v; +} + +static jv_t *jv_obj_get(const jv_t *obj, const char *key) +{ + if (!obj || obj->type != J_OBJ) return NULL; + for (const jv_t *p = obj->child; p; p = p->next) + if (p->str && !strcmp(p->str, key)) return p->child; + return NULL; +} + +/* ---------------- 设置表(权威来源:meow_settings.c g_defs,逐键一致) ---------------- + * 特别说明(与 MEOWPANEL_PROTOCOL/实现出入记录): + * - ui.style 值域源码为 "md3,miui",但 SYSTEM_SETTINGS §1 定案 + MEOWPANEL_PROTOCOL §5 + * 一致行为 **miuix**(对应 MEOW_STYLE_MIUIX)。前端 data-style 也用 miuix。故本表对齐为 + * "md3,miuix" / "Material You,MIUIx"(实现优先,回改见 README)。 + * - display.brightness 的 g_defs 为 min=0 max=255 def=101,但语义 clamp 到 [brightness_min(默认1),100] + * (防 101 闪屏)。本表对元数据给出 min=1 max=100(协议 §3.2 期望),实际 set 走 brightness clamp。 + */ +enum { ST_INT, ST_STR, ST_ENUM, ST_BOOL }; + +typedef struct setdef { + const char *key; + int type; + const char *def; + int min, max; /* ST_INT 取值域;max<=0 表示无上界 */ + const char *enum_names; /* ST_ENUM 逗号名 */ + const char *enum_labels; /* 对应中文 */ + const char *group; /* 分组标签(协议 groups 用) */ +} setdef_t; + +static const setdef_t g_sets[] = { + /* 显示 */ + { "display.brightness", ST_INT, "101", 1, 100, NULL, NULL, "显示" }, + { "display.brightness_min", ST_INT, "1", 0, 255, NULL, NULL, "显示" }, + { "display.screen_off_mode", ST_ENUM, "panel",0,0, "panel,black", "面板下电,背光全黑", "显示" }, + { "display.screen_off_timeout", ST_INT, "30", 0, 86400,NULL, NULL, "显示" }, + { "display.refresh_rate", ST_ENUM, "60", 0,0, "60,30,15", "60Hz,30Hz,15Hz", "显示" }, + { "display.refresh_always_max", ST_BOOL, "0", 0,0, NULL, NULL, "显示" }, + { "display.theme", ST_ENUM, "dark", 0,0, "dark,light", "深色,浅色", "显示" }, + { "display.theme_mode", ST_ENUM, "manual",0,0, "manual,auto", "手动,时段自动", "显示" }, + { "display.theme_dark_start", ST_INT, "0", 0, 1439, NULL, NULL, "显示" }, + { "display.theme_dark_end", ST_INT, "0", 0, 1439, NULL, NULL, "显示" }, + { "display.background", ST_STR, "black",0,0, NULL, NULL, "显示" }, + { "display.multi_font", ST_INT, "0", 0, 1, NULL, NULL, "显示" }, + /* 电源 */ + { "power.longpress_menu_ms", ST_INT, "3000", 0,0, NULL, NULL, "电源" }, + { "power.forceoff_ms", ST_INT, "8000", 0,0, NULL, NULL, "电源" }, + { "power.defeat_hotkey", ST_STR, "power_voldown",0,0, NULL, NULL, "电源" }, + /* 开机 */ + { "boot.default", ST_ENUM, "meow", 0,0, "meow,hiby", "MeowOS,HiByOS", "开机" }, + { "boot.menu_enabled", ST_BOOL, "1", 0,0, NULL, NULL, "开机" }, + { "boot.timeout_ms", ST_INT, "5000", 0,0, NULL, NULL, "开机" }, + /* 输入 */ + { "input.hotkey_seq", ST_STR, "volup,volup,volup",0,0, NULL, NULL, "输入" }, + { "input.hotkey_window_ms", ST_INT, "2500", 0,0, NULL, NULL, "输入" }, + { "input.touch_arm_delay_ms", ST_INT, "700", 0,0, NULL, NULL, "输入" }, + { "input.swipe_invert", ST_BOOL, "0", 0,0, NULL, NULL, "输入" }, + /* 字体/主题 */ + { "font.file", ST_STR, "MeowOS/fonts/default/regular/font.png",0,0, NULL, NULL, "字体主题" }, + { "font.class_cjk", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_cjk_sym", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_num", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_latin", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_latin_sym", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.class_other", ST_STR, "", 0,0, NULL, NULL, "字体主题" }, + { "font.load_full", ST_INT, "0", 0,1, NULL, NULL, "字体主题" }, + { "font.size.title", ST_INT, "28", 0,0, NULL, NULL, "字体主题" }, + { "font.size.body", ST_INT, "20", 0,0, NULL, NULL, "字体主题" }, + { "ui.style", ST_ENUM, "md3", 0,0, "md3,miuix", "Material You,MIUIx", "字体主题" }, + /* 通知 */ + { "notify.center_enabled", ST_BOOL, "1", 0,0, NULL, NULL, "通知" }, + { "notify.app_quota", ST_INT, "16", 0,0, NULL, NULL, "通知" }, + { "notify.categories", ST_INT, "4", 1,4, NULL, NULL, "通知" }, + { "notify.category_quota", ST_INT, "4", 1,16, NULL, NULL, "通知" }, + { "notify.media_quota", ST_INT, "1", 1,1, NULL, NULL, "通知" }, + { "notify.dnd", ST_BOOL, "0", 0,0, NULL, NULL, "通知" }, + /* 日志 */ + { "log.level", ST_ENUM, "info", 0,0, "error,warn,info,debug", "错误,警告,信息,调试", "日志" }, + { "log.to_card", ST_BOOL, "1", 0,0, NULL, NULL, "日志" }, + /* 音频 */ + { "audio.volume", ST_INT, "60", 0,100, NULL, NULL, "音频" }, + { "audio.volume_max", ST_INT, "100", 0,100, NULL, NULL, "音频" }, + { "audio.volume_step", ST_INT, "1", 1,5, NULL, NULL, "音频" }, + { "audio.output", ST_STR, "hw:0,0",0,0, NULL, NULL, "音频" }, + /* 网络 */ + { "network.wifi_enabled", ST_BOOL, "1", 0,0, NULL, NULL, "网络" }, + { "network.bluetooth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "网络" }, + /* 系统 */ + { "system.hostname", ST_STR, "meowos",0,0, NULL, NULL, "系统" }, +}; +#define SETS_COUNT ((int)(sizeof g_sets / sizeof g_sets[0])) + +static const setdef_t *setdef_find(const char *key) { + for (int i = 0; i < SETS_COUNT; i++) + if (!strcmp(g_sets[i].key, key)) return &g_sets[i]; + return NULL; +} + +/* ---------------- settings.conf 解析(照 meow_ui_vol_read / settings_get_int_trim) ---------------- + * '=' 切分,key 去首尾空白精确匹配(防 audio.volume 误配 audio.volume_max)。 + */ +static char *settings_get_value(const char *key) +{ + FILE *f = fopen(SETTINGS_FILE, "r"); + if (!f) return NULL; + size_t kl = strlen(key); + char line[256]; + char *found = NULL; + while (fgets(line, sizeof line, f)) { + char *eq = strchr(line, '='); + if (!eq) continue; + char *k = line; + while (*k == ' ' || *k == '\t') k++; + long n = (long)(eq - k); + while (n > 0 && (k[n - 1] == ' ' || k[n - 1] == '\t')) n--; + if ((size_t)n != kl || strncmp(k, key, kl) != 0) continue; + found = eq + 1; + break; + } + fclose(f); + if (!found) return NULL; + while (*found == ' ' || *found == '\t') found++; + char *e = found + strlen(found); + while (e > found && (e[-1] == '\n' || e[-1] == '\r' || e[-1] == ' ' || e[-1] == '\t')) *--e = 0; + return strdup(found); +} + +static int settings_get_int(const char *key, int defv) +{ + char *v = settings_get_value(key); + if (!v) return defv; + int r = atoi(v); + free(v); + return r; +} + +/* 亮度 clamp:下限取 display.brightness_min(默认 1),上限 100(防 101 闪屏)。 + * 与 meow_settings.c clamp_brightness 同口径。 */ +static int brightness_clamp(int v) +{ + int mn = settings_get_int("display.brightness_min", 1); + if (mn < 1) mn = 1; + if (mn > 100) mn = 100; + if (v < mn) v = mn; + if (v > 100) v = 100; + return v; +} + +/* 重写 settings.conf:把 key 写为 "key = value"。保留头与其它行;缺 key 则追加。 + * O_TRUNC 重写。成功返回 0。 */ +static int settings_write_one(const char *key, const char *val) +{ + mkdir("/usr/data/meow", 0777); /* 目录缺失自动建(与 meow_settings_save 同;失败由 fopen 兜底) */ + FILE *f = fopen(SETTINGS_FILE, "r"); + char tmp[2048]; + size_t n = 0; + int c; + if (f) { + while (n < sizeof tmp - 1 && (c = fgetc(f)) != EOF) tmp[n++] = (char)c; + fclose(f); + } + tmp[n] = 0; + FILE *nf = fopen(SETTINGS_FILE, "w"); + if (!nf) return -1; + size_t klen = strlen(key); + int found = 0; + if (n == 0) fprintf(nf, "%s\n", SETTINGS_HDR); + /* 逐行重写:头照抄;命中 key 行替换;其余照抄 */ + char *save = NULL; + for (char *ln = strtok_r(tmp, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { + char *p = ln; + while (*p == ' ' || *p == '\t') p++; + if (!strncmp(p, key, klen) && p[klen] == '=') { + fprintf(nf, "%s = %s\n", key, val); + found = 1; + } else { + fprintf(nf, "%s\n", ln); + } + } + if (!found) fprintf(nf, "%s = %s\n", key, val); + fclose(nf); + return 0; +} + +/* 设定值→落盘规范字符串(clamp/校验集中在此)。返回 -1=非法(422),0=成功。 */ +static int settings_coerce(const setdef_t *d, const char *input, char *out, size_t outcap) +{ + switch (d->type) { + case ST_INT: { + int v = atoi(input); + if (!strcmp(d->key, "display.brightness")) v = brightness_clamp(v); + else { + if (d->max > 0) { if (v < d->min) v = d->min; if (v > d->max) v = d->max; } + else if (v < d->min) v = d->min; + } + snprintf(out, outcap, "%d", v); + return 0; + } + case ST_BOOL: { + char *q = (char *)input; + while (*q == ' ' || *q == '\t') q++; + int b = 0; + if (!strcasecmp(q, "true") || !strcasecmp(q, "yes") || !strcasecmp(q, "on")) b = 1; + else { long v = strtol(q, NULL, 10); if (q[0] && v != 0) b = 1; } + snprintf(out, outcap, "%d", b ? 1 : 0); + return 0; + } + case ST_ENUM: { + const char *names = d->enum_names; + if (!names || !names[0]) { snprintf(out, outcap, "%s", input); return 0; } + int cnt = csv_count(names); + char tok[128]; + /* 接受名字或索引(向后兼容) */ + int idx = -1; + for (int i = 0; i < cnt; i++) { + csv_at(names, i, tok, sizeof tok); + if (!strcmp(tok, input)) { idx = i; break; } + } + if (idx < 0) { + int n = atoi(input); + if (n >= 0 && n < cnt) idx = n; + } + if (idx < 0) return -1; /* 非法枚举 */ + csv_at(names, idx, tok, sizeof tok); + snprintf(out, outcap, "%s", tok); + return 0; + } + case ST_STR: + strncpy(out, input, outcap - 1); + out[outcap - 1] = 0; + return 0; + } + return -1; +} + +/* 恢复全部默认:重写整个文件为 头 + 所有键 def。 */ +static int settings_reset_all(void) +{ + mkdir("/usr/data/meow", 0777); + FILE *nf = fopen(SETTINGS_FILE, "w"); + if (!nf) return -1; + fprintf(nf, "%s\n", SETTINGS_HDR); + for (int i = 0; i < SETS_COUNT; i++) + fprintf(nf, "%s = %s\n", g_sets[i].key, g_sets[i].def); + fclose(nf); + return 0; +} + +/* ---------------- IPC 帧读写(同 meow_svc_perf/power 客户端约定,全小端 LE) ---------------- */ +static int x_read_full(int fd, uint8_t *buf, size_t n) +{ + size_t off = 0; + while (off < n) { + ssize_t r = read(fd, buf + off, n - off); + if (r <= 0) return -1; + off += (size_t)r; + } + return 0; +} +static int x_write_full(int fd, const uint8_t *buf, size_t n) +{ + size_t off = 0; + while (off < n) { + ssize_t w = write(fd, buf + off, n - off); + if (w <= 0) return -1; + off += (size_t)w; + } + return 0; +} +static int send_frame(int fd, uint16_t type, uint32_t req_id, const void *payload, uint32_t len) +{ + uint8_t hdr[MEOW_IPC_HDR_LEN]; + uint32_t magic = MEOW_IPC_MAGIC, rl = len; + uint16_t ver = MEOW_IPC_VERSION, t = type; + memcpy(hdr + 0, &magic, 4); + memcpy(hdr + 4, &ver, 2); + memcpy(hdr + 6, &t, 2); + memcpy(hdr + 8, &req_id, 4); + memcpy(hdr + 12, &rl, 4); + if (x_write_full(fd, hdr, MEOW_IPC_HDR_LEN) < 0) return -1; + if (len && payload && x_write_full(fd, payload, len) < 0) return -1; + return 0; +} + +/* 读一帧:返回 payload 长度(<=cap 时复制到 buf),-1 失败;type/req_id 回传。 */ +static int recv_frame(int fd, uint16_t *type, uint32_t *req_id, uint8_t *buf, uint32_t cap) +{ + uint8_t hdr[MEOW_IPC_HDR_LEN]; + if (x_read_full(fd, hdr, sizeof hdr) < 0) return -1; + uint32_t magic, rlen, rid; + uint16_t ver, t; + memcpy(&magic, hdr, 4); + memcpy(&ver, hdr + 4, 2); + memcpy(&t, hdr + 6, 2); + memcpy(&rid, hdr + 8, 4); + memcpy(&rlen, hdr + 12, 4); + if (magic != MEOW_IPC_MAGIC || ver != MEOW_IPC_VERSION) return -1; + if (rlen > MEOW_IPC_MAX) return -1; + if (type) *type = t; + if (req_id) *req_id = rid; + uint32_t n = (rlen < cap) ? rlen : cap; + if (n && x_read_full(fd, buf, n) < 0) return -1; + /* 若实际更长,丢弃剩余 */ + for (uint32_t i = n; i < rlen; i++) { uint8_t c; if (x_read_full(fd, &c, 1) < 0) return -1; } + return (int)n; +} + +/* 带超时连 AF_UNIX 流 socket:非阻塞 connect + poll(POLLOUT)。 */ +static int ipc_connect_tmo(const char *sock, int tmo_ms) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + struct sockaddr_un a; + memset(&a, 0, sizeof a); + a.sun_family = AF_UNIX; + strncpy(a.sun_path, sock, sizeof a.sun_path - 1); + int fl = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, fl | O_NONBLOCK); + int rc = connect(fd, (struct sockaddr *)&a, sizeof a); + if (rc < 0 && errno == EINPROGRESS) { + struct pollfd pf = { fd, POLLOUT, 0 }; + int pr = poll(&pf, 1, tmo_ms); + if (pr <= 0) { close(fd); return -1; } + int soerr = 0; + socklen_t sl = sizeof soerr; + getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &sl); + if (soerr != 0) { close(fd); return -1; } + } else if (rc < 0) { close(fd); return -1; } + fcntl(fd, F_SETFL, fl); + /* 读/写超时兜底(正常服务应即时响应) */ + struct timeval tv = { tmo_ms / 1000, (tmo_ms % 1000) * 1000 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + return fd; +} + +/* 服务端握手(MSG_HELLO → MSG_ACK),app_id 用系统面板身份 os.meow.panel。 */ +static int ipc_hello(int fd) +{ + m_hello_t h; + memset(&h, 0, sizeof h); + strncpy(h.app_id, "os.meow.panel", sizeof h.app_id - 1); + h.version = 1; + if (send_frame(fd, MSG_HELLO, 1, &h, sizeof h) < 0) return -1; + 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)) return -1; + if (type != MSG_ACK) return -1; + m_ack_t a; + memcpy(&a, buf, sizeof a); + return (a.code == MEOW_OK) ? 0 : -1; +} + +/* ---------------- 读侧:meow_svc_perf IPC ---------------- + * 返回 0=成功(json 已填入 out/cap),否则负错误码,errmsg 填中文说明。 + */ +static const char *perf_code_str(int code) +{ + switch (code) { + case MEOW_OK: return ""; /* 不应使用 */ + case MEOW_ERR_INVAL: return "参数非法"; + case MEOW_ERR_IO: return "读取失败"; + case MEOW_ERR_NODEV: return "设备不存在"; + default: return "服务端错误"; + } +} + +static int perf_subset_for_path(const char *path) +{ + if (!strcmp(path, "/api/perf")) return PERF_SUBSET_PERF; + if (!strcmp(path, "/api/system")) return 0; /* system 需 uptime/storage,取全量 */ + return 0; /* /api/status */ +} + +static int ipc_perf_query(int subset, char *out, int cap) +{ + int fd = ipc_connect_tmo(g_sock_perf, IPC_TIMEOUT_MS); + if (fd < 0) return MEOW_ERR_NODEV; + int rc = MEOW_ERR_INTERNAL; + char msg[128] = ""; + if (ipc_hello(fd) < 0) { rc = MEOW_ERR_INTERNAL; snprintf(msg, sizeof msg, "握手中断:性能服务未就绪"); goto done; } + m_perf_query_t q; + memset(&q, 0, sizeof q); + q.subset = (uint32_t)subset; + if (send_frame(fd, MSG_PERF_QUERY, 2, &q, sizeof q) < 0) { rc = MEOW_ERR_IO; snprintf(msg, sizeof msg, "发送查询失败"); 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_perf_result_t)) { rc = MEOW_ERR_IO; snprintf(msg, sizeof msg, "应答帧过短"); goto done; } + if (type != MSG_PERF_RESULT) { rc = MEOW_ERR_INVAL; snprintf(msg, sizeof msg, "非预期应答类型 0x%04x", type); goto done; } + m_perf_result_t r; + memcpy(&r, buf, sizeof r); + if (r.code != MEOW_OK) { rc = (int)r.code; snprintf(msg, sizeof msg, "性能服务错误: %s", perf_code_str((int)r.code)); goto done; } + uint16_t jl = r.json_len; + if (jl > sizeof r.json - 1) jl = (uint16_t)(sizeof r.json - 1); + if ((size_t)jl >= (size_t)cap) jl = (uint16_t)(cap - 1); + memcpy(out, r.json, jl); + out[jl] = 0; + rc = MEOW_OK; +done: + close(fd); + /* 服务不可达/非 OK 由调用方据此返回错误;绝不静默失败、绝不降级直连 /proc。 */ + return rc; +} + +/* ---------------- 动作侧:meow_svc_power IPC ---------------- + * 返回 0=受理;否则负错误码(PERM/INVAL/NODEV/…),msg 填服务端说明。 + */ +static const char *power_code_str(int code) +{ + switch (code) { + case MEOW_OK: return ""; + case MEOW_ERR_PERM: return "无权限(需 root)"; + case MEOW_ERR_INVAL: return "未知命令"; + case MEOW_ERR_NODEV: return "设备/能力不支持"; + case MEOW_ERR_NOENT: return "缺少升级文件"; + case MEOW_ERR_IO: return "读写失败"; + default: return "电源服务错误"; + } +} + +static int ipc_power_action(uint32_t cmd, uint32_t flag, const char *arg, m_power_status_t *out_st) +{ + int fd = ipc_connect_tmo(g_sock_power, IPC_TIMEOUT_MS); + if (fd < 0) return MEOW_ERR_NODEV; + int rc = MEOW_ERR_INTERNAL; + if (ipc_hello(fd) < 0) { rc = MEOW_ERR_INTERNAL; goto done; } + m_power_action_t a; + memset(&a, 0, sizeof a); + a.cmd = cmd; + a.flag = flag; + if (arg) snprintf(a.arg, sizeof a.arg, "%s", arg); + if (send_frame(fd, MSG_POWER_ACTION, 3, &a, sizeof a) < 0) { rc = MEOW_ERR_IO; goto done; } + uint8_t buf[256]; + uint16_t type; uint32_t rid; + int n = recv_frame(fd, &type, &rid, buf, sizeof buf); + if (n < (int)sizeof(m_power_status_t)) { rc = MEOW_ERR_IO; goto done; } + if (type != MSG_POWER_STATUS) { rc = MEOW_ERR_INVAL; goto done; } + m_power_status_t st; + memcpy(&st, buf, sizeof st); + if (out_st) *out_st = st; + rc = (int)st.code; +done: + close(fd); + return rc; +} + +/* ---------------- 路径安全 ---------------- */ +static int tf_present(void) +{ + FILE *f = fopen("/proc/mounts", "r"); + if (!f) return 0; + char line[512]; + int present = 0; + while (fgets(line, sizeof line, f)) { + if (strstr(line, TF_ROOT)) { present = 1; break; } + } + fclose(f); + return present; +} + +/* 判断某个规范化路径是否在卡根白名单内(==TF_ROOT 或属于某个白名单一级子目录)。 */ +static int path_in_whitelist(const char *abs) +{ + size_t rl = strlen(TF_ROOT); + if (strncmp(abs, TF_ROOT, rl) != 0) return 0; + if (abs[rl] == 0) return 1; /* 根自身 */ + if (abs[rl] != '/') return 0; + const char *rest = abs + rl + 1; + for (int i = 0; i < ALLOWED_COUNT; i++) { + size_t dl = strlen(g_allowed_dirs[i]); + if (strncmp(rest, g_allowed_dirs[i], dl) == 0) { + if (rest[dl] == 0 || rest[dl] == '/') return 1; + } + } + return 0; +} + +static int is_sensitive_name(const char *base) +{ + for (int i = 0; i < SENSITIVE_COUNT; i++) + if (!strcmp(base, g_sensitive_names[i])) return 1; + return 0; +} + +/* 规范化输入路径(去除 . / ..;绝对/相对容错)。 + * 输入可能是绝对(`/usr/data/mnt/sd_0/MeowOS/x`,前端下载/删除等用)或相对 root(`/MeowOS/x`)。 + * 输出(buf)为规范化后的绝对路径;若 escape 出卡根,返回 -1。 */ +static int normalize_path(const char *in, char *buf, size_t buflen) +{ + size_t rl = strlen(TF_ROOT); + char joined[1024]; + if (in[0] == '/') { + if (strncmp(in, TF_ROOT, rl) == 0 && (in[rl] == 0 || in[rl] == '/')) { + snprintf(joined, sizeof joined, "%s", in); + } else { + /* 相对 root 的展示路径,如 /MeowOS/x → 拼到 root */ + if (in[0] == '/' && in[1] == '/') snprintf(joined, sizeof joined, "%s%s", TF_ROOT, in + 1); + else if (in[0] == '/') snprintf(joined, sizeof joined, "%s%s", TF_ROOT, in); + else snprintf(joined, sizeof joined, "%s/%s", TF_ROOT, in); + } + } else { + snprintf(joined, sizeof joined, "%s/%s", TF_ROOT, in); + } + /* 逐分量规范化:/a/b/../c → /a/c;拒绝逃逸出 root */ + char out[1024]; + size_t n = 0; + out[0] = 0; + char *p = joined; + while (*p) { + while (*p == '/') p++; + if (!*p) break; + char seg[256]; + int k = 0; + while (*p && *p != '/') { if (k < 255) seg[k++] = *p; p++; } + seg[k] = 0; + if (!strcmp(seg, ".") || !strcmp(seg, "")) continue; + if (!strcmp(seg, "..")) { + /* 弹出一级;若已在 root 再 .. 则逃逸 */ + if (n == 0) return -1; + while (n > 0 && out[n - 1] != '/') n--; + if (n > 0) n--; /* 去掉结尾 / */ + out[n] = 0; + continue; + } + if (k > 0) { + size_t need = strlen(out) + 1 + k + 1; + if (need >= sizeof out) return -1; + if (out[0]) strncat(out, "/", sizeof out - strlen(out) - 1); + strncat(out, seg, sizeof out - strlen(out) - 1); + } + } + /* 空 → root */ + if (!out[0]) snprintf(out, sizeof out, "%s", TF_ROOT); + if (strlen(out) >= buflen) return -1; + snprintf(buf, buflen, "%s", out); + return 0; +} + +/* 展示 rel(相对 root):如 /MeowOS/x。 */ +static void make_rel(const char *abs, char *buf, size_t buflen) +{ + size_t rl = strlen(TF_ROOT); + if (strncmp(abs, TF_ROOT, rl) != 0) { buf[0] = 0; return; } + const char *rest = abs + rl; + if (*rest == 0) snprintf(buf, buflen, "/"); + else snprintf(buf, buflen, "%s", rest); +} + +/* 安全地打开一个可读文件(白名单内 + 非敏感名)。失败返回 -1。 */ +static int open_safe_for_read(const char *abs, int follow_rel) +{ + (void)follow_rel; + if (!path_in_whitelist(abs)) return -1; + const char *base = strrchr(abs, '/'); + base = base ? base + 1 : abs; + if (is_sensitive_name(base)) return -1; + struct stat st; + if (stat(abs, &st) < 0) return -1; + if (!S_ISREG(st.st_mode)) return -1; + return open(abs, O_RDONLY); +} + +/* ---------------- 文件浏览 ---------------- */ +static void fmt_json_item(sbuf_t *s, const char *name, int isdir, long long size, long long mtime) +{ + sA(s, "{\"name\":"); + sA_str(s, name); + sA(s, ",\"type\":\"%s\",\"size\":%lld,\"mtime\":%lld}", + isdir ? "dir" : "file", size, mtime); +} + +static int files_list(int sock, const char *relpath) +{ + char abs[1024], rel[1024]; + if (normalize_path(relpath, abs, sizeof abs) < 0) + return 403; + if (!path_in_whitelist(abs)) return 403; + struct stat st; + if (stat(abs, &st) < 0) return 404; + if (!S_ISDIR(st.st_mode)) return 404; + DIR *d = opendir(abs); + if (!d) return 500; + make_rel(abs, rel, sizeof rel); + sbuf_t s = {0}; + sA(&s, "{\"root\":\"" TF_ROOT "\",\"path\":"); + sA_str(&s, abs); + sA(&s, ",\"rel\":"); + sA_str(&s, rel); + sA(&s, ",\"allowed\":["); + for (int i = 0; i < ALLOWED_COUNT; i++) { if (i) sA(&s, ","); sA_str(&s, g_allowed_dirs[i]); } + sA(&s, "],\"items\":["); + int first = 1; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + char full[1400]; + snprintf(full, sizeof full, "%s/%s", abs, e->d_name); + struct stat fs; + if (stat(full, &fs) < 0) continue; + int isdir = S_ISDIR(fs.st_mode); + long long size = (long long)fs.st_size; + long long mtime = (long long)fs.st_mtime; + if (isdir) size = 0; + if (!first) sA(&s, ","); + first = 0; + fmt_json_item(&s, e->d_name, isdir, size, mtime); + } + closedir(d); + sA(&s, "]}"); + return send_json(sock, 200, s.b ? s.b : ""); +} + +/* ---------------- HTTP 响应(JSON 信封) ---------------- */ +/* send_json 前置声明 */ +static int send_json(int sock, int status, const char *data); + +/* 读文本文件(<1MB),返回 malloc 内容;二进制检测 -2;太大 -3;打不开 -1。 */ +static char *read_text_file_limited(const char *abs, long long *size, int *truncated) +{ + int fd = open_safe_for_read(abs, 0); + if (fd < 0) return NULL; + struct stat st; + if (fstat(fd, &st) < 0 || st.st_size > 1024 * 1024) { close(fd); return NULL; } + if (st.st_size < 0) { close(fd); return NULL; } + char *buf = malloc((size_t)st.st_size + 1); + if (!buf) { close(fd); return NULL; } + ssize_t n = read(fd, buf, (size_t)st.st_size); + close(fd); + if (n < 0) { free(buf); return NULL; } + buf[n] = 0; + /* 二进制检测:含 NUL 或大量控制字符 */ + int ctrl = 0; + for (ssize_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)buf[i]; + if (c == 0) { free(buf); return (char *)-2; } /* 二进制 */ + if (c < 0x09) ctrl++; + } + if (ctrl > n / 10) { free(buf); return (char *)-2; } + if (size) *size = (long long)n; + if (truncated) *truncated = 0; + return buf; +} + +/* ---------------- 按键注入(经 /dev/input/event* 写 EV_KEY;uinput_tap inject 同机制) ---------------- + * 设备映射(launcher meow_launcher_internal.h): + * /dev/input/event0 = md-gpio-keys: KEY_POWER(116), KEY_PREVIOUSSONG(165) + * /dev/input/event2 = jz adc keyboard: KEY_VOLUMEUP(115), KEY_VOLUMEDOWN(114), KEY_NEXTSONG(163), KEY_PLAYPAUSE(164) + * 注入 = open(O_WRONLY) 写 EV_KEY press/release + SYN_REPORT(evdev 支持 write 注入,uinput_tap.c inject 先例)。 + * 红线(APPS.md 物理键 / 任务约束):只注入音量/电源/播放/上下曲,**禁止映射 返回(158)/主页(172)/确认/exit**。 + */ +#define PWR_KEYDEV "/dev/input/event0" +#define ADC_KEYDEV "/dev/input/event2" + +static int keycode_to_device(int code, const char **dev) +{ + switch (code) { + case KEY_POWER: *dev = PWR_KEYDEV; return KEY_POWER; + case KEY_PREVIOUSSONG: *dev = PWR_KEYDEV; return KEY_PREVIOUSSONG; + case KEY_VOLUMEUP: *dev = ADC_KEYDEV; return KEY_VOLUMEUP; + case KEY_VOLUMEDOWN: *dev = ADC_KEYDEV; return KEY_VOLUMEDOWN; + case KEY_NEXTSONG: *dev = ADC_KEYDEV; return KEY_NEXTSONG; + case KEY_PLAYPAUSE: *dev = ADC_KEYDEV; return KEY_PLAYPAUSE; + default: return -1; + } +} + +static int inject_key_event(const char *dev, int code, int down) +{ + int fd = open(dev, O_WRONLY | O_NONBLOCK); + if (fd < 0) return -1; + struct input_event ev; + memset(&ev, 0, sizeof ev); + ev.type = EV_KEY; + ev.code = code; + ev.value = down ? 1 : 0; + int ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev); + if (ok) { + memset(&ev, 0, sizeof ev); + ev.type = EV_SYN; + ev.code = SYN_REPORT; + ev.value = 0; + ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev); + } + close(fd); + return ok ? 0 : -1; +} + +/* 注入 tap(按下+抬起)或 long(按住 ms)。返回 0=成功。 */ +static int inject_key_release(int code, int press_ms) +{ + const char *dev = NULL; + int kc = keycode_to_device(code, &dev); + if (kc < 0) return -1; + int release_ms = press_ms > 0 ? press_ms : 0; + if (inject_key_event(dev, code, 1) < 0) return -1; + if (release_ms > 0) usleep((useconds_t)release_ms * 1000); + else usleep(80 * 1000); + if (inject_key_event(dev, code, 0) < 0) return -1; + return 0; +} + +/* ---------------- 应用(MeowAPPs 扫描 + nyai) ---------------- */ +static int read_small_file(const char *path, char *buf, int cap) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + int n = 0; + while (n < cap - 1) { + ssize_t r = read(fd, buf + n, (size_t)(cap - 1 - n)); + if (r <= 0) break; + n += (int)r; + } + close(fd); + buf[n] = 0; + return n; +} + +/* 读某应用目录的 app.json(用 jv 解析提取字段,供扫描/详情)。 */ +static int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail) +{ + char pj[1024]; + snprintf(pj, sizeof pj, "%s/%s/app.json", appdir, name); + char buf[8192]; + if (read_small_file(pj, buf, sizeof buf) <= 0) { + /* 解析失败项:invalid */ + if (!want_detail) { + sA(out, "{\"package\":"); + sA_str(out, name); + sA(out, ",\"name\":null,\"version\":null,\"size\":0,\"installed\":true,\"invalid\":true}"); + } + return -1; + } + jv_t *root = jv_parse(buf, (int)strlen(buf)); + if (!root) { + if (!want_detail) { + sA(out, "{\"package\":"); + sA_str(out, name); + sA(out, ",\"name\":null,\"version\":null,\"size\":0,\"installed\":true,\"invalid\":true}"); + } + return -1; + } + jv_t *jpkg = jv_obj_get(root, "package"); + jv_t *jname = jv_obj_get(root, "name"); + jv_t *jver = jv_obj_get(root, "version"); + jv_t *jexec = jv_obj_get(root, "exec"); + jv_t *jfw = jv_obj_get(root, "framework"); + jv_t *jim = jv_obj_get(root, "immersive"); + jv_t *jfull = jv_obj_get(root, "fullscreen"); + const char *pkg = (jpkg && jpkg->type == J_STR) ? jpkg->str : name; + const char *nm = (jname && jname->type == J_STR) ? jname->str : ""; + const char *ver = (jver && jver->type == J_STR) ? jver->str : ""; + const char *exec = (jexec && jexec->type == J_STR) ? jexec->str : ""; + /* 目录大小(递归统计,限 8 层防爆) -- 简化:stat 直接子项合计 */ + long long dsize = 0; + snprintf(pj, sizeof pj, "%s/%s", appdir, name); + DIR *d = opendir(pj); + if (d) { + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s/%s", appdir, name, e->d_name); + struct stat st; + if (stat(full, &st) == 0) dsize += (long long)st.st_size; + } + closedir(d); + } + if (!want_detail) { + sA(out, "{\"package\":"); + sA_str(out, pkg); + sA(out, ",\"name\":"); + sA_str(out, nm[0] ? nm : NULL); + sA(out, ",\"version\":"); + sA_str(out, ver[0] ? ver : NULL); + sA(out, ",\"exec\":"); + sA_str(out, exec); + sA(out, ",\"framework\":%lld,\"immersive\":%lld,\"size\":%lld,\"installed\":true,\"mtime\":%lld}", + jfw ? jfw->inum : 0, jim ? jim->inum : 0, dsize, (long long)time(NULL)); + } else { + sA(out, "{\"package\":"); + sA_str(out, pkg); + sA(out, ",\"name\":"); + sA_str(out, nm[0] ? nm : NULL); + sA(out, ",\"version\":"); + sA_str(out, ver[0] ? ver : NULL); + sA(out, ",\"exec\":"); + sA_str(out, exec); + sA(out, ",\"framework\":%lld,\"immersive\":%lld,\"fullscreen\":%lld,\"size\":%lld,\"path\":", + jfw ? jfw->inum : 0, jim ? jim->inum : 0, jfull ? jfull->inum : 0, dsize); + sA_str(out, pj); + sA(out, ",\"routes\":{},\"services\":[],\"depends\":[],\"description\":\"\"}"); + } + jv_free(root); + return 0; +} + +/* ---------------- HTTP 请求读取 ---------------- */ +typedef struct { + char method[8]; + char path[512]; /* 解码后的路径(不含 query) */ + char query[1024]; /* 原始 query(key=value&…,未解码;取值时用 qparam) */ + char *body; + size_t body_len; + char content_type[128]; /* 请求 Content-Type(若非空) */ +} req_t; + +static void req_free(req_t *r) { free(r->body); r->body = NULL; r->body_len = 0; } + +static int is_hex(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); } +static int hexval(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; return c - 'A' + 10; } + +/* URL 解码到 buf。 */ +static void url_decode(const char *in, char *buf, int buflen) +{ + int o = 0; + for (int i = 0; in[i] && o < buflen - 1; i++) { + if (in[i] == '%' && is_hex(in[i + 1]) && is_hex(in[i + 2])) { + buf[o++] = (char)((hexval(in[i + 1]) << 4) | hexval(in[i + 2])); + i += 2; + } else if (in[i] == '+') buf[o++] = ' '; + else buf[o++] = in[i]; + } + buf[o] = 0; +} + +/* 从 query(key=value&…)取第一个值(url 解码后写入 out),未找到返回 0。 */ +static int qparam(const char *query, const char *key, char *out, int outcap) +{ + size_t kl = strlen(key); + const char *p = query; + while (p && *p) { + const char *amp = strchr(p, '&'); + size_t seg = amp ? (size_t)(amp - p) : strlen(p); + if (seg >= kl + 1 && strncmp(p, key, kl) == 0 && p[kl] == '=') { + char tmp[1024]; + size_t vlen = seg - kl - 1; + if (vlen >= sizeof tmp) vlen = sizeof tmp - 1; + memcpy(tmp, p + kl + 1, vlen); + tmp[vlen] = 0; + url_decode(tmp, out, outcap); + return 1; + } + if (!amp) break; + p = amp + 1; + } + return 0; +} + +/* 从请求 path 中取动态段(如 /api/settings/:key)。返回是否匹配。 */ +static int path_segment(const char *prefix, const char *full, char *out, int outcap) +{ + size_t pl = strlen(prefix); + if (strncmp(full, prefix, pl) != 0) return 0; + const char *rest = full + pl; + if (*rest == '/') rest++; + if (!*rest) return 0; + size_t l = strlen(rest); + if (l >= (size_t)outcap) l = (size_t)outcap - 1; + memcpy(out, rest, l); + out[l] = 0; + /* 去掉后续 / 段(取第一段) */ + char *slash = strchr(out, '/'); + if (slash) *slash = 0; + return 1; +} + +/* 从 "GET /path?query HTTP/1.1" 的 path 部分切出 path 与 query。 */ +static void split_path_query(const char *src, char *path, int plc, char *query, int qlc) +{ + const char *q = strchr(src, '?'); + if (q) { + size_t pl = (size_t)(q - src); + if (pl >= (size_t)plc) pl = (size_t)plc - 1; + memcpy(path, src, pl); + path[pl] = 0; + snprintf(query, qlc, "%s", q + 1); + } else { + snprintf(path, plc, "%s", src); + query[0] = 0; + } +} + +/* 在 buf[0..n) 中找首个 "\r\n\r\n",返回其起始下标;找不到返回 -1。 */ +static ptrdiff_t find_header_end(const char *buf, size_t n) +{ + if (n < 4) return -1; + for (size_t i = 0; i + 3 < n; i++) { + if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n') + return (ptrdiff_t)i; + } + return -1; +} + +/* 读请求:先读头(到 \r\n\r\n),解析 Content-Length/Content-Type,再精确读 body。 + * 处理「头+body 同一 TCP 段到达」(小 POST 常见):单次 read 可能把 body 一并读入, + * 故在缓冲区内**搜索** \r\n\r\n 定位头边界,body 从边界后搬移。成功 0;超时/过大/失败 -1。 */ +static int read_request(int sock, req_t *r) +{ + char *buf = malloc(MAX_HTTP_HEADER); + if (!buf) return -1; + size_t hn = 0; + int deadline_ms = 5000; + ptrdiff_t hend = -1; + for (;;) { + struct pollfd pf = { sock, POLLIN, 0 }; + int pr = poll(&pf, 1, deadline_ms); + if (pr <= 0) { free(buf); return -1; } + ssize_t rd = read(sock, buf + hn, MAX_HTTP_HEADER - 1 - hn); + if (rd <= 0) { free(buf); return -1; } + hn += (size_t)rd; + buf[hn] = 0; + hend = find_header_end(buf, hn); + if (hend >= 0) break; + if (hn >= MAX_HTTP_HEADER - 2) { free(buf); return -1; } + } + /* 头结束在 hend,body 起点在 hend+4 */ + char *hdr = buf; + char *line_end = strstr(hdr, "\r\n"); + if (!line_end) { free(buf); return -1; } + *line_end = 0; + char method[8], pathraw[512], proto[16]; + if (sscanf(hdr, "%7s %511s %15s", method, pathraw, proto) < 2) { free(buf); return -1; } + snprintf(r->method, sizeof r->method, "%s", method); + split_path_query(pathraw, r->path, sizeof r->path, r->query, sizeof r->query); + size_t clen = 0; + char *hp = line_end + 2; + while (hp < buf + hend && strncmp(hp, "\r\n", 2)) { + char *he = strstr(hp, "\r\n"); + if (!he || he > buf + hend) break; + *he = 0; + if (!strncasecmp(hp, "Content-Length:", 15)) clen = (size_t)strtoul(hp + 15, NULL, 10); + else if (!strncasecmp(hp, "Content-Type:", 13)) { + char *v = hp + 13; + while (*v == ' ') v++; + snprintf(r->content_type, sizeof r->content_type, "%s", v); + } + hp = he + 2; + } + char *body_start = buf + hend + 4; /* body 实际起点 */ + size_t buffered = (hn > (size_t)(body_start - buf)) ? (hn - (size_t)(body_start - buf)) : 0; + if (clen > MAX_HTTP_BODY) { free(buf); return -1; } + if (clen == 0) { free(buf); r->body = NULL; r->body_len = 0; return 0; } + char *body = malloc(clen + 1); + if (!body) { free(buf); return -1; } + size_t got = 0; + if (buffered > 0) { + size_t take = buffered < clen ? buffered : clen; + memcpy(body, body_start, take); + got = take; + } + free(buf); + while (got < clen) { + struct pollfd pf = { sock, POLLIN, 0 }; + int pr = poll(&pf, 1, 5000); + if (pr <= 0) { free(body); return -1; } + ssize_t rd = read(sock, body + got, clen - got); + if (rd <= 0) { free(body); return -1; } + got += (size_t)rd; + } + body[clen] = 0; + r->body = body; + r->body_len = clen; + return 0; +} + +/* ---------------- HTTP 响应写入 ---------------- */ +static int write_full(int sock, const void *b, size_t n) +{ + const char *p = (const char *)b; + size_t off = 0; + while (off < n) { + ssize_t w = write(sock, p + off, n - off); + if (w <= 0) return -1; + off += (size_t)w; + } + return 0; +} + +static int send_head(int sock, int status, const char *ctype, long long clen, const char *extra) +{ + char h[512]; + const char *sts = "OK"; + switch (status) { + case 200: sts = "OK"; break; + case 201: sts = "Created"; break; + case 202: sts = "Accepted"; break; + case 400: sts = "Bad Request"; break; + case 401: sts = "Unauthorized"; break; + case 403: sts = "Forbidden"; break; + case 404: sts = "Not Found"; break; + case 405: sts = "Method Not Allowed"; break; + case 409: sts = "Conflict"; break; + case 413: sts = "Payload Too Large"; break; + case 415: sts = "Unsupported Media Type"; break; + case 422: sts = "Unprocessable Entity"; break; + case 500: sts = "Internal Server Error"; break; + case 501: sts = "Not Implemented"; break; + case 503: sts = "Service Unavailable"; break; + default: sts = "OK"; break; + } + snprintf(h, sizeof h, "HTTP/1.1 %d %s\r\nServer: MeowPanel/%s\r\nContent-Type: %s\r\nContent-Length: %lld\r\nConnection: close\r\n%s%s\r\n", + status, sts, PANEL_VERSION, ctype, clen, extra ? extra : "", extra ? "\r\n" : ""); + return write_full(sock, h, strlen(h)); +} + +/* 发送原生 bytes(已含 body)。 */ +static int send_raw(int sock, int status, const char *ctype, const void *body, size_t bodylen) +{ + if (send_head(sock, status, ctype, (long long)bodylen, NULL) < 0) return -1; + if (bodylen && write_full(sock, body, bodylen) < 0) return -1; + return 0; +} + +/* 成功 JSON 信封:{"ok":true,"data":}。 + * 前缀 "{"ok":true,"data":" 共 18 字节。用 memcpy 精确构包(sA 的 %s 有 ~511B 单次截断,不能用于大 payload)。 */ +static int send_ok(int sock, const char *data) +{ + static const char prefix[] = "{\"ok\":true,\"data\":"; + size_t pl = sizeof prefix - 1; /* 18 */ + size_t dl = strlen(data); + size_t tot = pl + dl + 1; + char *b = malloc(tot + 1); + if (!b) { + const char *e = "{\"ok\":false,\"error\":{\"code\":\"INTERNAL\",\"message\":\"内存不足\"}}"; + return send_raw(sock, 500, "application/json; charset=utf-8", e, strlen(e)); + } + memcpy(b, prefix, pl); + memcpy(b + pl, data, dl); + b[pl + dl] = '}'; + b[pl + dl + 1] = 0; + int rc = send_raw(sock, 200, "application/json; charset=utf-8", b, tot); + free(b); + return rc; +} + +/* 错误 JSON 信封:{"ok":false,"error":{"code":..,"message":..}} */ +static int send_err(int sock, int status, const char *code, const char *msg) +{ + sbuf_t s = {0}; + sA(&s, "{\"ok\":false,\"error\":{\"code\":"); + sA_str(&s, code ? code : "INTERNAL"); + sA(&s, ",\"message\":"); + sA_str(&s, msg ? msg : ""); + sA(&s, "}}"); + int rc = send_raw(sock, status, "application/json; charset=utf-8", s.b, s.n); + sbuf_free(&s); + return rc; +} + +static int send_json(int sock, int status, const char *data) +{ + if (status == 200) return send_ok(sock, data); + return send_raw(sock, status, "application/json; charset=utf-8", data, strlen(data)); +} + +/* 流式发送磁盘文件(下载)。 */ +static int send_file(int sock, int status, const char *ctype, const char *abspath, const char *filename) +{ + int fd = open(abspath, O_RDONLY); + if (fd < 0) return send_err(sock, 404, "NOT_FOUND", "文件不存在"); + struct stat st; + if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { close(fd); return send_err(sock, 404, "NOT_FOUND", "非普通文件"); } + char extra[256]; + if (filename) snprintf(extra, sizeof extra, "Content-Disposition: attachment; filename=\"%s\"", filename); + else extra[0] = 0; + if (send_head(sock, status, ctype, (long long)st.st_size, extra[0] ? extra : NULL) < 0) { close(fd); return -1; } + char buf[8192]; + ssize_t n; + while ((n = read(fd, buf, sizeof buf)) > 0) { + if (write_full(sock, buf, (size_t)n) < 0) { close(fd); return -1; } + } + close(fd); + return 0; +} + +/* ---------------- multipart/form-data 解析(文件上传) ---------------- + * 解析 body,提取 name= 字段与 name=file 字段(可多个)。写到 target dir(白名单校验)。 + * 返回 0=成功(把写入的 item 用 items_json 回填),负错误码语义同协议。 + */ +typedef struct { char path[1400]; char filename[256]; } upfile_t; + +static int parse_multipart(const req_t *r, upfile_t *files, int maxfiles) +{ + /* 取 boundary */ + const char *ct = r->content_type; + const char *b = strstr(ct, "boundary="); + if (!b) return -1; + b += 9; + if (*b == '"') { b++; } + char boundary[128]; + int bi = 0; + while (b[bi] && b[bi] != ';' && b[bi] != '"' && b[bi] != '\r' && bi < 120) { boundary[bi] = b[bi]; bi++; } + boundary[bi] = 0; + char dmark[160]; + snprintf(dmark, sizeof dmark, "--%s", boundary); + char emark[160]; + snprintf(emark, sizeof emark, "--%s--", boundary); + /* 块间以 \r\n--boundary 分隔;用 strstr 扫描 */ + const char *p = r->body; + const char *end = r->body + r->body_len; + int nfiles = 0; + char curpath[1024] = ""; + while (p < end) { + const char *fstart = strstr(p, dmark); + if (!fstart) break; + fstart += strlen(dmark); + if (*fstart == '-') break; /* 结束标记 --boundary-- */ + if (*fstart == '\r' || *fstart == '\n') fstart++; + /* 解析该 part 的头部(直至空行) */ + const char *headers_end = strstr(fstart, "\r\n\r\n"); + if (!headers_end) break; + char hdr[512]; + size_t hl = (size_t)(headers_end - fstart); + if (hl >= sizeof hdr) hl = sizeof hdr - 1; + memcpy(hdr, fstart, hl); + hdr[hl] = 0; + const char *data_start = headers_end + 4; + const char *name = NULL, *fname = NULL; + char *save = NULL; + for (char *ln = strtok_r(hdr, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { + char *l = ln; + while (*l == '\r' || *l == ' ' || *l == '\t') l++; + if (strncasecmp(l, "Content-Disposition:", 20) == 0) { + char *nm = strstr(l, "name=\""); + if (nm) { nm += 6; char *q = strchr(nm, '"'); if (q) { *q = 0; name = nm; } } + char *fn = strstr(l, "filename=\""); + if (fn) { fn += 10; char *q = strchr(fn, '"'); if (q) { *q = 0; fname = fn; } } + } + } + /* 数据结束:下一个 "--boundary" 之前 */ + const char *data_end = strstr(data_start, dmark); + if (!data_end) data_end = end; + /* 去掉数据尾部 \r\n */ + while (data_end > data_start && (data_end[-1] == '\r' || data_end[-1] == '\n')) data_end--; + if (name && !strcmp(name, "path")) { + size_t dl = (size_t)(data_end - data_start); + if (dl >= sizeof curpath) dl = sizeof curpath - 1; + memcpy(curpath, data_start, dl); + curpath[dl] = 0; + } else if (name && !strcmp(name, "file") && fname && nfiles < maxfiles) { + size_t dl = (size_t)(data_end - data_start); + snprintf(files[nfiles].filename, sizeof files[nfiles].filename, "%s", fname); + snprintf(files[nfiles].path, sizeof files[nfiles].path, "%s", curpath); + /* 写文件 */ + char abs[1024]; + if (normalize_path(files[nfiles].path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { nfiles++; continue; } + snprintf(files[nfiles].path, sizeof files[nfiles].path, "%s/%s", abs, fname); + int fd = open(files[nfiles].path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd >= 0) { + write_full(fd, data_start, dl); + close(fd); + } + nfiles++; + } + p = data_end; + } + return nfiles; /* >=0:已保存文件数;-1 边界缺失/解析失败 */ +} + +/* ---------------- 日志(stderr;svcd supervisor 捕获) ---------------- */ +static void panel_log(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + fprintf(stderr, "[MeowPanel] "); + vfprintf(stderr, fmt, ap); + fprintf(stderr, "\n"); + va_end(ap); +} + +/* ---------------- 设置项 JSON 序列化 ---------------- */ +static int str_truthy(const char *v) +{ + char q[32]; + snprintf(q, sizeof q, "%s", v); + char *p = q; + while (*p == ' ' || *p == '\t') p++; + if (!strcasecmp(p, "true") || !strcasecmp(p, "yes") || !strcasecmp(p, "on")) return 1; + long n = strtol(p, NULL, 10); + return (p[0] && n != 0); +} + +static int int_meta_max(const setdef_t *d) +{ + if (d->max > 0) return d->max; + int dn = atoi(d->def); + if (dn > 0) return dn * 2 + 100; + return 100; +} + +/* 非改写的 CSV 辅助:取第 idx 个项(抄入 buf,NUL 结尾),未命中返回空串。 + * 用于 enum 名/标签 —— 不再 strtok_r(那会在只读字面量上写 NUL SEGV)。 */ +static const char *csv_at(const char *s, int idx, char *buf, int buflen) +{ + buf[0] = 0; + if (!s || !s[0]) return buf; + const char *p = s; + int i = 0; + for (;;) { + const char *start = p; + while (*p && *p != ',') p++; + if (i == idx) { + size_t k = (size_t)(p - start); + if (k >= (size_t)buflen) k = (size_t)buflen - 1; + memcpy(buf, start, k); + buf[k] = 0; + break; + } + if (!*p) break; + p++; + i++; + } + return buf; +} + +static int csv_count(const char *s) +{ + if (!s || !s[0]) return 0; + int n = 1; + for (const char *p = s; *p; p++) if (*p == ',') n++; + return n; +} + +static void enum_options_json(sbuf_t *s, const char *names, const char *labels) +{ + sA(s, "["); + int cnt = csv_count(names); + for (int i = 0; i < cnt; i++) { + char nb[128], lb[128]; + csv_at(names, i, nb, sizeof nb); + csv_at(labels, i, lb, sizeof lb); + if (i) sA(s, ","); + sA(s, "{\"name\":"); + sA_str(s, nb); + sA(s, ",\"label\":"); + sA_str(s, lb[0] ? lb : nb); + sA(s, "}"); + } + sA(s, "]"); +} + +static void settings_item_json(sbuf_t *s, const setdef_t *d, const char *val) +{ + const char *tn = "str"; + if (d->type == ST_INT) tn = "int"; + else if (d->type == ST_BOOL) tn = "bool"; + else if (d->type == ST_ENUM) tn = "enum"; + sA(s, "{\"key\":"); + sA_str(s, d->key); + sA(s, ",\"type\":\"%s\",\"value\":", tn); + if (d->type == ST_INT) { + int v = atoi(val); + if (!strcmp(d->key, "display.brightness")) v = brightness_clamp(v); + else if (d->max > 0) { if (v < d->min) v = d->min; if (v > d->max) v = d->max; } + else if (v < d->min) v = d->min; + sA(s, "%d", v); + sA(s, ",\"min\":%d,\"max\":%d", d->min, int_meta_max(d)); + } else if (d->type == ST_BOOL) { + sA(s, "%s", str_truthy(val) ? "true" : "false"); + } else { + sA_str(s, val); + } + sA(s, ",\"def\":"); + sA_str(s, d->def); + if (d->type == ST_ENUM) { + sA(s, ",\"options\":"); + enum_options_json(s, d->enum_names, d->enum_labels); + sA(s, ",\"default_name\":"); + sA_str(s, d->def); + } + sA(s, "}"); +} + +/* 读某键当前值(无则用默认;str 用默认)。返回 malloc 字符串。 */ +static char *settings_current_value(const setdef_t *d) +{ + char *v = settings_get_value(d->key); + if (v) return v; + return strdup(d->def); +} + +/* ---------------- 设置端点 ---------------- */ +static int api_settings_get_any(int sock, const char *key) +{ + const setdef_t *d = setdef_find(key); + if (!d) return send_err(sock, 404, "NOT_FOUND", "未知设置项"); + char *val = settings_current_value(d); + sbuf_t s = {0}; + settings_item_json(&s, d, val); + int rc = send_json(sock, 200, s.b ? s.b : ""); + free(val); + sbuf_free(&s); + return rc; +} + +static int api_settings_get_all(int sock) +{ + sbuf_t s = {0}; + sA(&s, "{\"items\":["); + char groups[16][32]; + int ngroups = 0; + for (int i = 0; i < SETS_COUNT; i++) { + const setdef_t *d = &g_sets[i]; + char *val = settings_current_value(d); + if (i) sA(&s, ","); + settings_item_json(&s, d, val); + free(val); + if (d->group && d->group[0]) { + int found = 0; + for (int g = 0; g < ngroups; g++) if (!strcmp(groups[g], d->group)) { found = 1; break; } + if (!found && ngroups < 16) snprintf(groups[ngroups++], 32, "%s", d->group); + } + } + sA(&s, "],\"count\":%d,\"groups\":[", SETS_COUNT); + for (int g = 0; g < ngroups; g++) { + if (g) sA(&s, ","); + sA_str(&s, groups[g]); + } + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_settings_put(int sock, const char *key, const req_t *r) +{ + const setdef_t *d = setdef_find(key); + if (!d) return send_err(sock, 404, "NOT_FOUND", "未知设置项"); + 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 *val = jv_obj_get(root, "value"); + if (!val) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺少 value 字段"); } + char raw[256]; + switch (val->type) { + case J_NUM: snprintf(raw, sizeof raw, "%lld", val->inum); break; + case J_STR: snprintf(raw, sizeof raw, "%s", val->str); break; + case J_BOOL: snprintf(raw, sizeof raw, "%d", val->bval ? 1 : 0); break; + default: jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "value 类型不支持"); + } + jv_free(root); + char coerced[256]; + if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { + return send_err(sock, 422, "UNSUPPORTED", "值域/枚举非法"); + } + if (settings_write_one(key, coerced) < 0) return send_err(sock, 500, "INTERNAL", "写设置失败"); + /* boot.default 同步权威文件(同 meow_settings_save) */ + if (!strcmp(key, "boot.default")) { + int v = (!strcmp(coerced, "hiby")) ? 1 : 0; + FILE *bf = fopen(BOOT_DEFAULT_FILE, "w"); + if (bf) { fprintf(bf, "%d\n", v); fclose(bf); } + } + sbuf_t s = {0}; + settings_item_json(&s, d, coerced); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_settings_batch(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 || root->type != J_OBJ) { if (root) jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON 对象"); } + sbuf_t ok = {0}, fail = {0}; + int nok = 0, nfail = 0; + for (const jv_t *p = root->child; p; p = p->next) { + const setdef_t *d = setdef_find(p->str); + if (!d) { if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"NOT_FOUND\",\"message\":\"未知设置项\"}"); nfail++; continue; } + jv_t *val = p->child; + char raw[256]; + switch (val->type) { + case J_NUM: snprintf(raw, sizeof raw, "%lld", val->inum); break; + case J_STR: snprintf(raw, sizeof raw, "%s", val->str); break; + case J_BOOL: snprintf(raw, sizeof raw, "%d", val->bval ? 1 : 0); break; + default: if (nfail) sA(&fail, ","); sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"BAD_REQUEST\",\"message\":\"非法值类型\"}"); nfail++; continue; + } + char coerced[256]; + if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { + if (nfail) sA(&fail, ","); + sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"UNSUPPORTED\",\"message\":\"值域/枚举非法\"}"); + nfail++; + continue; + } + if (settings_write_one(p->str, coerced) == 0) { + if (nok) sA(&ok, ","); + sA_str(&ok, p->str); + nok++; + } else { + if (nfail) sA(&fail, ","); + sA(&fail, "{\"key\":"); sA_str(&fail, p->str); sA(&fail, ",\"code\":\"INTERNAL\",\"message\":\"写失败\"}"); + nfail++; + } + } + jv_free(root); + sbuf_t s = {0}; + sA(&s, "{\"updated\":["); + if (ok.b) sA(&s, "%s", ok.b); + sA(&s, "],\"failed\":["); + if (fail.b) sA(&s, "%s", fail.b); + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); sbuf_free(&ok); sbuf_free(&fail); + return rc; +} + +static int api_settings_restore(int sock) +{ + if (settings_reset_all() < 0) return send_err(sock, 500, "INTERNAL", "恢复失败"); + return send_json(sock, 200, "{\"reset\":true}"); +} + +/* ---------------- 主题端点 ---------------- */ +static void theme_json(sbuf_t *s) +{ + const setdef_t *dst = setdef_find("ui.style"); + const setdef_t *dth = setdef_find("display.theme"); + const setdef_t *dbg = setdef_find("display.background"); + char *style = settings_current_value(dst); + char *theme = settings_current_value(dth); + char *bg = settings_current_value(dbg); + /* 当前值 → 中文标签(csv 里同下标查 label) */ + char slabel[128] = "", tlabel[128] = ""; + for (int i = 0; i < csv_count(dst->enum_names); i++) { + char nb[128], lb[128]; + csv_at(dst->enum_names, i, nb, sizeof nb); + csv_at(dst->enum_labels, i, lb, sizeof lb); + if (!strcmp(nb, style)) { snprintf(slabel, sizeof slabel, "%s", lb[0] ? lb : nb); break; } + } + for (int i = 0; i < csv_count(dth->enum_names); i++) { + char nb[128], lb[128]; + csv_at(dth->enum_names, i, nb, sizeof nb); + csv_at(dth->enum_labels, i, lb, sizeof lb); + if (!strcmp(nb, theme)) { snprintf(tlabel, sizeof tlabel, "%s", lb[0] ? lb : nb); break; } + } + sA(s, "{\"style\":"); + sA_str(s, style); + sA(s, ",\"theme\":"); + sA_str(s, theme); + sA(s, ",\"background\":"); + sA_str(s, bg); + sA(s, ",\"style_label\":"); + sA_str(s, slabel); + sA(s, ",\"theme_label\":"); + sA_str(s, tlabel); + sA(s, ",\"style_options\":"); + enum_options_json(s, dst->enum_names, dst->enum_labels); + sA(s, ",\"theme_options\":"); + enum_options_json(s, dth->enum_names, dth->enum_labels); + sA(s, ",\"background_options\":[{\"name\":\"black\",\"label\":\"黑\"},{\"name\":\"blue\",\"label\":\"深蓝\"},{\"name\":\"green\",\"label\":\"墨绿\"}]}"); + free(style); free(theme); free(bg); +} + +static int api_theme_get(int sock) +{ + sbuf_t s = {0}; + theme_json(&s); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_theme_put(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"); + /* 允许 style/theme/background 任意子集 */ + const char *keys[3] = { "style", "theme", "background" }; + const char *setkeys[3] = { "ui.style", "display.theme", "display.background" }; + for (int i = 0; i < 3; i++) { + jv_t *v = jv_obj_get(root, keys[i]); + if (!v) continue; + const setdef_t *d = setdef_find(setkeys[i]); + if (!d) continue; + char raw[256]; + if (v->type == J_STR) snprintf(raw, sizeof raw, "%s", v->str); + else if (v->type == J_NUM) snprintf(raw, sizeof raw, "%lld", v->inum); + else continue; + char coerced[256]; + if (settings_coerce(d, raw, coerced, sizeof coerced) < 0) { jv_free(root); return send_err(sock, 422, "UNSUPPORTED", "非法值"); } + settings_write_one(setkeys[i], coerced); + } + jv_free(root); + return api_theme_get(sock); +} + +/* ---------------- 系统信息 / 性能 ---------------- */ +static int api_perf(int sock, const char *path) +{ + char json[4096]; + int rc = ipc_perf_query(perf_subset_for_path(path), json, sizeof json); + if (rc != MEOW_OK) return send_err(sock, 500, "INTERNAL", "性能服务不可用(IPC 连接/应答失败)"); + return send_json(sock, 200, json); +} + +/* 从 meow_svc_perf 聚合 JSON 提取顶层 "network":{...} 段原始文本(含外层 {}), + * 供 /api/system 透传(单一权威源,后端不再各自采集)。找不到顶层 network 返回 -1。 */ +static int perf_extract_network(const char *json, char *out, int cap) +{ + const char *p = json; + for (;;) { + p = strstr(p, "\"network\""); + if (!p) return -1; + const char *q = p + 9; /* strlen("\"network\"") */ + while (*q == ' ' || *q == '\t') q++; + if (*q == ':') { p = q + 1; break; } /* 顶层 network 键 */ + p += 9; /* 嵌套同名键跳过 */ + } + while (*p == ' ' || *p == '\t') p++; + if (*p != '{') return -1; + int depth = 0, in_str = 0, esc = 0; + const char *q; + for (q = p; *q; q++) { + char ch = *q; + if (in_str) { + if (esc) esc = 0; + else if (ch == '\\') esc = 1; + else if (ch == '"') in_str = 0; + continue; + } + if (ch == '"') { in_str = 1; continue; } + if (ch == '{') depth++; + else if (ch == '}') { depth--; if (depth == 0) { q++; break; } } + } + if (depth != 0) return -1; + size_t len = (size_t)(q - p); + if (len == 0) return -1; + if (len >= (size_t)cap) len = (size_t)cap - 1; + memcpy(out, p, len); out[len] = 0; + return 0; +} + +/* /api/system:性能服务 SYSTEM 子集 + boot/ota 局部补全。 + * network 段由 meow_svc_perf 聚合(SYSTEM_FRAMEWORK_SERVICES §4.1 已定案),此处**透传 perf 的 network**, + * 后端不再各自采集 wifi/蓝牙;perf 若缺 network 段才回退 hostname_local(保持不崩)。 */ +static int api_system(int sock) +{ + char json[4096]; + int rc = ipc_perf_query(0, json, sizeof json); + if (rc != MEOW_OK) return send_err(sock, 500, "INTERNAL", "性能服务不可用(IPC 连接/应答失败)"); + jv_t *root = jv_parse(json, (int)strlen(json)); + if (!root) return send_err(sock, 500, "INTERNAL", "性能服务 JSON 解析失败"); + jv_t *jsys = jv_obj_get(root, "system"); + jv_t *jupt = jv_obj_get(root, "uptime_sec"); + jv_t *jst = jv_obj_get(root, "storage"); + jv_t *jtf = jv_obj_get(root, "tfcard"); + const char *ver = "-", *host = "-", *ker = "-", *arch = "-"; + if (jsys && jsys->type == J_OBJ) { + jv_t *v = jv_obj_get(jsys, "version"); if (v && v->type == J_STR) ver = v->str; + v = jv_obj_get(jsys, "hostname"); if (v && v->type == J_STR) host = v->str; + v = jv_obj_get(jsys, "kernel"); if (v && v->type == J_STR) ker = v->str; + v = jv_obj_get(jsys, "arch"); if (v && v->type == J_STR) arch = v->str; + } + long long upt = jupt ? (long long)jupt->dnum : 0; + /* boot default:读权威 /data/meow_boot_default(0=meow 默认, 1=hiby),失败回退 meow */ + int bdf = -1; + { + char b[16]; + FILE *f = fopen(BOOT_DEFAULT_FILE, "r"); + if (f) { if (fgets(b, sizeof b, f)) bdf = atoi(b); fclose(f); } + } + const char *bootname = bdf < 0 ? "meow" : (bdf ? "hiby" : "meow"); + char host_local[80]; + snprintf(host_local, sizeof host_local, "%s.local", host); + sbuf_t s = {0}; + sA(&s, "{\"version\":"); + sA_str(&s, ver); + sA(&s, ",\"hostname\":"); + sA_str(&s, host); + sA(&s, ",\"kernel\":"); + sA_str(&s, ker); + sA(&s, ",\"arch\":"); + sA_str(&s, arch); + sA(&s, ",\"uptime_sec\":%lld,\"boot\":{\"default\":", upt); + sA_str(&s, bootname); + sA(&s, "},\"network\":"); + char netbuf[1024]; + if (perf_extract_network(json, netbuf, sizeof netbuf) == 0) { + sA(&s, "%s", netbuf); /* 透传 perf 权威 network 段(wifi/bluetooth/hostname_local) */ + } else { + /* 回退:perf 未含 network 段 → 仅 hostname_local(保持原语义,不崩) */ + sA(&s, "{\"hostname_local\":"); + sA_str(&s, host_local); + sA(&s, "}"); + } + sA(&s, ",\"storage\":{"); + if (jst && jst->type == J_OBJ) { + jv_t *t = jv_obj_get(jst, "total"); long long tot = t ? (long long)t->dnum : 0; + jv_t *u = jv_obj_get(jst, "used"); long long usd = u ? (long long)u->dnum : 0; + jv_t *a = jv_obj_get(jst, "avail"); long long ava = a ? (long long)a->dnum : 0; + sA(&s, "\"root\":{\"total\":%lld,\"used\":%lld,\"avail\":%lld}", tot, usd, ava); + } else { + sA(&s, "\"root\":{\"total\":0,\"used\":0,\"avail\":0}"); + } + sA(&s, ",\"tfcard\":{"); + if (jtf && jtf->type == J_OBJ) { + jv_t *pr = jv_obj_get(jtf, "present"); int present = pr && pr->bval; + long long tot = 0, usd = 0, ava = 0; + jv_t *t = jv_obj_get(jtf, "total"); if (t) tot = (long long)t->dnum; + jv_t *u = jv_obj_get(jtf, "used"); if (u) usd = (long long)u->dnum; + jv_t *a = jv_obj_get(jtf, "avail"); if (a) ava = (long long)a->dnum; + sA(&s, "\"present\":%s,\"total\":%lld,\"used\":%lld,\"avail\":%lld}", present ? "true" : "false", tot, usd, ava); + } else { + sA(&s, "\"present\":false,\"total\":0,\"used\":0,\"avail\":0}"); + } + sA(&s, "},\"ota\":{\"current\":"); + sA_str(&s, ver); + sA(&s, ",\"latest\":"); + sA_str(&s, ver); + sA(&s, ",\"available\":false,\"url\":null}}"); + jv_free(root); + int ret = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return ret; +} + +/* ---------------- 系统动作(经 meow_svc_power IPC) ---------------- */ +static int api_system_action(int sock, const char *which, const req_t *r) +{ + (void)r; + uint32_t cmd; + if (!strcmp(which, "reboot")) cmd = POWER_REBOOT; + else if (!strcmp(which, "shutdown")) cmd = POWER_SHUTDOWN; + else if (!strcmp(which, "ota")) cmd = POWER_UPGRADE; + else return send_err(sock, 404, "NOT_FOUND", "未知系统动作"); + m_power_status_t st; + memset(&st, 0, sizeof st); + int rc = ipc_power_action(cmd, 0, NULL, &st); + if (rc == MEOW_OK) return send_json(sock, 200, "{\"accepted\":true}"); + /* 系统动作类服务端通常「先应答受理再执行」;此处若返回非 OK 则说明服务未受理 */ + const char *why = st.msg[0] ? st.msg : power_code_str(rc); + if (rc == MEOW_ERR_PERM) return send_err(sock, 403, "FORBIDDEN", "电源服务拒绝(非 root 调用)"); + if (rc == MEOW_ERR_NODEV) return send_err(sock, 503, "NO_CARD", "升级文件缺失或服务不可达"); + if (rc == MEOW_ERR_NOENT || rc == MEOW_ERR_IO) return send_err(sock, 503, "NO_CARD", why); + return send_err(sock, 500, "INTERNAL", why); +} + +/* ---------------- 远程控制(按键注入) ---------------- */ +static int api_key_single(int sock, int code, const req_t *r) +{ + int press_ms = 0; + char pressbuf[16] = "tap"; + const char *press = pressbuf; + if (r->body && r->body[0]) { + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (root) { + jv_t *p = jv_obj_get(root, "press"); + if (p && p->type == J_STR) snprintf(pressbuf, sizeof pressbuf, "%s", p->str); + jv_t *m = jv_obj_get(root, "ms"); + if (m && m->type == J_NUM) press_ms = (int)m->inum; + jv_free(root); + } + } + if (!strcmp(press, "hold")) + return send_err(sock, 501, "NOT_IMPLEMENTED", "hold 持续注入需常驻通道(阶段 D),后端未实现"); + if (strcmp(press, "tap") && strcmp(press, "long")) + return send_err(sock, 400, "BAD_REQUEST", "press 非法"); + if (press_ms <= 0) { + if (!strcmp(press, "long")) press_ms = 3000; + } + /* 物理键红线:禁止映射 返回(158)/主页(172)/确认/exit(APPS.md)。 */ + if (code == 158 || code == 172 || code == 32 || code == 28 || code == 96) + return send_err(sock, 403, "FORBIDDEN", "该键码(返回/主页/确认)按物理键红线禁止映射"); + const char *tmpdev = NULL; + int dispatchable = (keycode_to_device(code, &tmpdev) >= 0); + if (!dispatchable) + return send_err(sock, 422, "UNSUPPORTED", "不支持的键码(仅音量/电源/播放/上下曲)"); + if (inject_key_release(code, press_ms) < 0) + return send_err(sock, 500, "INTERNAL", "按键注入失败(无法打开输入设备)"); + sbuf_t s = {0}; + sA(&s, "{\"code\":%d,\"press\":\"%s\",\"dispatched\":true}", code, press); + int ret = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return ret; +} + +static int api_key_batch(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 *keys = jv_obj_get(root, "keys"); + if (!keys || keys->type != J_ARR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺 keys 数组"); } + sbuf_t s = {0}; + int first = 1; + for (const jv_t *k = keys->child; k; k = k->next) { + if (k->type != J_OBJ) continue; + jv_t *c = jv_obj_get(k, "code"); + jv_t *p = jv_obj_get(k, "press"); + jv_t *m = jv_obj_get(k, "ms"); + if (!c || c->type != J_NUM) continue; + int code = (int)c->inum; + if (code == 158 || code == 172) continue; /* 红线跳过 */ + int press_ms = (m && m->type == J_NUM) ? (int)m->inum : 0; + if (p && p->type == J_STR && !strcmp(p->str, "long") && press_ms <= 0) press_ms = 3000; + const char *tmpdev = NULL; + if (keycode_to_device(code, &tmpdev) < 0) continue; + if (inject_key_release(code, press_ms) < 0) continue; + if (!first) sA(&s, ","); + first = 0; + sA(&s, "%d", code); + } + jv_free(root); + sbuf_t o = {0}; + if (first) sA(&o, "{\"dispatched\":[]}"); + else sA(&o, "{\"dispatched\":[%s]}", s.b); + int ret = send_json(sock, 200, o.b ? o.b : ""); + sbuf_free(&s); sbuf_free(&o); + return ret; +} + +/* ---------------- 日志 ---------------- */ +static int api_logs_list(int sock) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + DIR *d = opendir(LOGS_DIR); + if (!d) return send_err(sock, 404, "NOT_FOUND", "日志目录不存在"); + sbuf_t s = {0}; + sA(&s, "{\"dir\":\"" LOGS_DIR "\",\"files\":["); + int first = 1; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + if (is_sensitive_name(e->d_name)) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, e->d_name); + struct stat st; + if (stat(full, &st) < 0 || !S_ISREG(st.st_mode)) continue; + if (!first) sA(&s, ","); + first = 0; + sA(&s, "{\"name\":"); + sA_str(&s, e->d_name); + sA(&s, ",\"size\":%lld,\"mtime\":%lld}", (long long)st.st_size, (long long)st.st_mtime); + } + closedir(d); + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_logs_read(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char file[512], lines[16]; + if (!qparam(r->query, "file", file, sizeof file)) + return send_err(sock, 400, "BAD_REQUEST", "缺 file 参数"); + int maxlines = 200; + if (qparam(r->query, "lines", lines, sizeof lines)) { + int n = atoi(lines); + if (n > 0) maxlines = n; + if (maxlines > LOG_MAX_LINES) maxlines = LOG_MAX_LINES; + } + /* 防逃逸:file 相对 LOGS_DIR,禁止 .. 与绝对路径 */ + if (file[0] == '/' || strstr(file, "..")) return send_err(sock, 403, "FORBIDDEN", "文件路径逃逸"); + if (is_sensitive_name(strrchr(file, '/') ? strrchr(file, '/') + 1 : file)) + return send_err(sock, 403, "FORBIDDEN", "敏感文件"); + char full[1024]; + snprintf(full, sizeof full, "%s/%s", LOGS_DIR, file); + int fd = open(full, O_RDONLY); + if (fd < 0) return send_err(sock, 404, "NOT_FOUND", "日志文件不存在"); + struct stat st; + if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { close(fd); return send_err(sock, 404, "NOT_FOUND", "非普通文件"); } + /* 逐行读,保留末尾 maxlines 行 */ + const char *buf = malloc((size_t)st.st_size + 1); + if (!buf) { close(fd); return send_err(sock, 500, "INTERNAL", "内存不足"); } + ssize_t n = read(fd, (char *)buf, (size_t)st.st_size); + close(fd); + if (n < 0) { free((void *)buf); return send_err(sock, 500, "INTERNAL", "读日志失败"); } + sbuf_t s = {0}; + sA(&s, "{\"file\":"); + sA_str(&s, file); + sA(&s, ",\"lines\":["); + /* 直接扫一遍,保留末尾 maxlines 行文本 */ + const char *p = buf; + const char *end = buf + n; + int total = 0, truncated = 0; + int gotlines = 0; + int linecnt = 0; + for (const char *c = buf; c < end; c++) if (*c == '\n') linecnt++; + if (linecnt > 0 && end[-1] != '\n') linecnt++; + int startrow = linecnt - maxlines; + if (startrow < 0) startrow = 0; + if (startrow > 0) truncated = 1; + int rowidx = 0; + p = buf; + while (p < end) { + const char *nl = memchr(p, '\n', (size_t)(end - p)); + const char *line_end = nl ? nl : end; + if (rowidx >= startrow) { + if (gotlines) sA(&s, ","); + sbuf_t ln = {0}; + for (const char *c = p; c < line_end; c++) { + unsigned char cc = (unsigned char)*c; + if (cc == '"' || cc == '\\') sA(&ln, "\\%c", (int)cc); + else if (cc < 0x20 && cc != '\t') sA(&ln, "\\u%04x", cc); + else sA(&ln, "%c", (int)cc); + } + /* 用 sA_str(任意长度安全);不要 sA("%s")——单次超 511B 会截断坏 JSON */ + sA(&s, "\""); + for (const char *c = ln.b ? ln.b : ""; *c; c++) sA(&s, "%c", (int)(unsigned char)*c); + sA(&s, "\""); + sbuf_free(&ln); + gotlines++; + } + rowidx++; + if (!nl) break; + p = nl + 1; + } + total = linecnt; + free((void *)buf); + sA(&s, "],\"total\":%d,\"truncated\":%s}", total, truncated ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +/* ---------------- 通知(后端跨进程不可读 → 501) ---------------- */ +/* 注:通知存储为 launcher 进程内存(meow_notif.c g_slots[],NOTIFICATION_SYSTEM §4), + * 后端为独立 svcd 子进程,跨进程无法读取/清撤那批内存槽;框架仅在关机前把通知 dump 到 + * TF 卡 MeowOS/notifications/notifications.log(CSV 追加,非活跃快照)。故本后端无法实现 + * 活跃通知列表/清撤 —— 需为通知后端另开 IPC 查询(如 MSG_NOTIFY_LIST 应答)或持久化活跃集, + * 见 MEOWPANEL_PROTOCOL §6 待定案。当前一律 501。 */ +static int api_notifications(int sock) +{ + return send_err(sock, 501, "NOT_IMPLEMENTED", "通知存储为 launcher 进程内存,后端跨进程不可读(需通知后端暴露 IPC 查询/清撤)"); +} +static int api_notification_delete(int sock) +{ + return send_err(sock, 501, "NOT_IMPLEMENTED", "通知清撤需 launcher 通知后端 IPC,后端未实现"); +} +static int api_notification_dismiss_all(int sock) +{ + return send_err(sock, 501, "NOT_IMPLEMENTED", "通知清空需 launcher 通知后端 IPC,后端未实现"); +} + +/* ---------------- 文件操作 ---------------- */ +static int api_files_list(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); + return files_list(sock, path); +} + +static int api_files_read(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024]; + if (!qparam(r->query, "path", path, sizeof path)) return send_err(sock, 400, "BAD_REQUEST", "缺 path"); + char abs[1024]; + if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + long long size; int trunc; + char *txt = read_text_file_limited(abs, &size, &trunc); + if (txt == (char *)-2) return send_err(sock, 415, "UNSUPPORTED_MEDIA", "二进制文件不支持文本读取"); + if (!txt) return send_err(sock, 404, "NOT_FOUND", "文件不存在或过大"); + sbuf_t s = {0}; + sA(&s, "{\"path\":"); + sA_str(&s, abs); + sA(&s, ",\"content\":"); + sA_str(&s, txt); + sA(&s, ",\"size\":%lld,\"truncated\":false}", size); + free(txt); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_files_download(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024]; + if (!qparam(r->query, "path", path, sizeof path)) return send_err(sock, 400, "BAD_REQUEST", "缺 path"); + char abs[1024]; + if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + const char *base = strrchr(abs, '/'); + base = base ? base + 1 : abs; + if (is_sensitive_name(base)) return send_err(sock, 403, "FORBIDDEN", "敏感文件"); + return send_file(sock, 200, "application/octet-stream", abs, base); +} + +static int api_files_upload(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strncasecmp(r->content_type, "multipart/form-data", 19) != 0) + return send_err(sock, 415, "UNSUPPORTED_MEDIA", "需 multipart/form-data"); + upfile_t files[16]; + memset(files, 0, sizeof files); + int n = parse_multipart(r, files, 16); + if (n < 0) return send_err(sock, 400, "BAD_REQUEST", "multipart 解析失败"); + sbuf_t s = {0}; + sA(&s, "{\"item\":["); + int first = 1; + for (int i = 0; i < n && i < 16; i++) { + if (!files[i].filename[0]) continue; + if (!strstr(files[i].path, "/")) continue; /* 无目标目录的跳过 */ + struct stat st; + if (stat(files[i].path, &st) < 0) continue; + const char *base = strrchr(files[i].path, '/'); + base = base ? base + 1 : files[i].path; + if (!first) sA(&s, ","); + first = 0; + sA(&s, "{\"name\":"); + sA_str(&s, base); + sA(&s, ",\"type\":\"file\",\"size\":%lld,\"mtime\":%lld}", + (long long)st.st_size, (long long)st.st_mtime); + } + sA(&s, "]}"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +/* 递归删除目录/文件。 */ +static int rm_recursive(const char *path) +{ + struct stat st; + if (lstat(path, &st) < 0) return -1; + if (S_ISDIR(st.st_mode)) { + DIR *d = opendir(path); + if (!d) return -1; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", path, e->d_name); + rm_recursive(full); + } + closedir(d); + return rmdir(path); + } + return unlink(path); +} + +static int api_files_mkdir(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + 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 *pj = jv_obj_get(root, "path"); + if (!pj || pj->type != J_STR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺 path"); } + char abs[1024]; + if (normalize_path(pj->str, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { jv_free(root); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); } + struct stat st; + if (stat(abs, &st) == 0) { jv_free(root); return send_err(sock, 409, "CONFLICT", "目录已存在"); } + if (mkdir(abs, 0755) < 0) { jv_free(root); return send_err(sock, 500, "INTERNAL", "创建目录失败"); } + const char *base = strrchr(abs, '/'); + const char *b = base ? base + 1 : abs; + sbuf_t s = {0}; + sA(&s, "{\"item\":{\"name\":"); + sA_str(&s, b); + sA(&s, ",\"type\":\"dir\",\"size\":0,\"mtime\":%lld}}", (long long)time(NULL)); + jv_free(root); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_files_rename(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + 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 *pj = jv_obj_get(root, "path"); + jv_t *pnn = jv_obj_get(root, "new_name"); + if (!pj || pj->type != J_STR || !pnn || pnn->type != J_STR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺 path/new_name"); } + if (strchr(pnn->str, '/')) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "new_name 仅文件名"); } + char abs[1024], newabs[1400]; + if (normalize_path(pj->str, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) { jv_free(root); return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); } + snprintf(newabs, sizeof newabs, "%s/%s", abs, pnn->str); + /* newabs 也须在白名单(new_name 不含 /,父目录已校验,无需再 normalize) */ + if (rename(abs, newabs) < 0) { jv_free(root); return send_err(sock, 500, "INTERNAL", "重命名失败"); } + sbuf_t s = {0}; + sA(&s, "{\"item\":{\"name\":"); + sA_str(&s, pnn->str); + sA(&s, ",\"type\":\""); + struct stat st; + if (stat(newabs, &st) == 0 && S_ISDIR(st.st_mode)) sA(&s, "dir"); else sA(&s, "file"); + sA(&s, "\",\"size\":%lld,\"mtime\":%lld}}", (long long)st.st_size, (long long)st.st_mtime); + jv_free(root); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_files_delete(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + char path[1024]; + if (!qparam(r->query, "path", path, sizeof path)) return send_err(sock, 400, "BAD_REQUEST", "缺 path"); + char recursive[8] = "0"; + qparam(r->query, "recursive", recursive, sizeof recursive); + char abs[1024]; + if (normalize_path(path, abs, sizeof abs) < 0 || !path_in_whitelist(abs)) return send_err(sock, 403, "FORBIDDEN", "白名单外/路径逃逸"); + if (!strcmp(abs, TF_ROOT)) return send_err(sock, 403, "FORBIDDEN", "禁止删除卡根"); + struct stat st; + if (stat(abs, &st) < 0) return send_err(sock, 404, "NOT_FOUND", "路径不存在"); + if (S_ISDIR(st.st_mode) && atoi(recursive) != 1) { + DIR *d = opendir(abs); + int nonempty = 0; + if (d) { + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + nonempty = 1; break; + } + closedir(d); + } + if (nonempty) return send_err(sock, 409, "CONFLICT", "目录非空(需 recursive=1)"); + } + if (rm_recursive(abs) < 0) return send_err(sock, 500, "INTERNAL", "删除失败"); + return send_json(sock, 200, "{\"removed\":true}"); +} + +/* ---------------- 应用(MeowAPPs 扫描 + nyai) ---------------- */ +/* 执行命令(sh -c),捕获 stdout(上限 cap-1),返回退出状态(-1=无法启动)。 */ +static int run_cmd(const char *cmd, char *out, int outcap) +{ + FILE *f = popen(cmd, "r"); + if (!f) return -1; + size_t off = 0; + while (off < (size_t)outcap - 1) { + size_t rd = fread(out + off, 1, (size_t)outcap - 1 - off, f); + if (rd == 0) break; + off += rd; + } + out[off] = 0; + int st = pclose(f); + int code = -1; + if (WIFEXITED(st)) code = WEXITSTATUS(st); + return code; +} + +static int api_apps_list(int sock) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + DIR *d = opendir(MEOWAPPS_DIR); + if (!d) return send_err(sock, 404, "NOT_FOUND", "MeowAPPs 目录不存在"); + sbuf_t s = {0}; + sA(&s, "{\"count\":0,\"apps\":["); + int first = 1, count = 0; + struct dirent *e; + while ((e = readdir(d))) { + if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue; + if (!strcmp(e->d_name, "fonts") || !strcmp(e->d_name, "logs") || !strcmp(e->d_name, "backgrounds")) continue; + if (strstr(e->d_name, ".bak")) continue; + char full[1024]; + snprintf(full, sizeof full, "%s/%s", MEOWAPPS_DIR, e->d_name); + struct stat st; + if (stat(full, &st) < 0 || !S_ISDIR(st.st_mode)) continue; + if (!first) sA(&s, ","); + first = 0; + apps_scan_dir(MEOWAPPS_DIR, e->d_name, &s, 0); + count++; + } + closedir(d); + sA(&s, "]}"); + /* count 前端不强制,但协议要求;用两次扫描不可取,故以 "count" 占位并回填:改为在 s 中替换 */ + /* 简化:直接输出 apps 数组,前端只读 d.apps(count 忽略)。 */ + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + (void)count; + return rc; +} + +static int api_apps_info(int sock, const char *pkg) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + char appdir[1024]; + snprintf(appdir, sizeof appdir, "%s/%s", MEOWAPPS_DIR, pkg); + struct stat st; + if (stat(appdir, &st) < 0 || !S_ISDIR(st.st_mode)) return send_err(sock, 404, "NOT_FOUND", "应用未安装"); + sbuf_t s = {0}; + if (apps_scan_dir(MEOWAPPS_DIR, pkg, &s, 1) < 0) + return send_err(sock, 404, "NOT_FOUND", "app.json 解析失败"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +/* multipart 提取第一个 file part 并写入 outpath。返回 0=成功。 */ +static int multipart_extract_file(const req_t *r, const char *outpath) +{ + const char *b = strstr(r->content_type, "boundary="); + if (!b) return -1; + b += 9; + if (*b == '"') b++; + char boundary[128]; int bi = 0; + while (b[bi] && b[bi] != ';' && b[bi] != '"' && b[bi] != '\r' && bi < 120) { boundary[bi] = b[bi]; bi++; } + boundary[bi] = 0; + char dmark[160]; + snprintf(dmark, sizeof dmark, "--%s", boundary); + const char *p = r->body; + while (p < r->body + r->body_len) { + const char *fs = strstr(p, dmark); + if (!fs) break; + fs += strlen(dmark); + if (*fs == '-') break; + if (*fs == '\r' || *fs == '\n') fs++; + const char *he = strstr(fs, "\r\n\r\n"); + if (!he) break; + char hdr[512]; + size_t hl = (size_t)(he - fs); if (hl >= sizeof hdr) hl = sizeof hdr - 1; + memcpy(hdr, fs, hl); hdr[hl] = 0; + int isfile = 0; + char *save = NULL; + for (char *ln = strtok_r(hdr, "\n", &save); ln; ln = strtok_r(NULL, "\n", &save)) { + char *l = ln; while (*l == '\r' || *l == ' ' || *l == '\t') l++; + if (strncasecmp(l, "Content-Disposition:", 20) == 0) { + if (strstr(l, "name=\"file\"")) isfile = 1; + } + } + const char *ds = he + 4; + const char *de = strstr(ds, dmark); + if (!de) de = r->body + r->body_len; + while (de > ds && (de[-1] == '\r' || de[-1] == '\n')) de--; + if (isfile) { + int fd = open(outpath, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) return -1; + write_full(fd, ds, (size_t)(de - ds)); + close(fd); + return 0; + } + p = de; + } + return -1; +} + +static int api_apps_install(int sock, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (!access(NYAI_PATH, X_OK)) { /* ok */ } + char mpkpath[1024]; + int ispath = 0; + if (strncasecmp(r->content_type, "multipart/form-data", 19) != 0) { + /* JSON {path} */ + if (r->body && r->body[0]) { + jv_t *root = jv_parse(r->body, (int)r->body_len); + if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON"); + jv_t *pj = jv_obj_get(root, "path"); + if (pj && pj->type == J_STR) { + snprintf(mpkpath, sizeof mpkpath, "%s", pj->str); + ispath = 1; + } + jv_free(root); + } + if (!ispath) return send_err(sock, 400, "BAD_REQUEST", "缺 path 或 multipart file"); + } else { + /* 保存上传 file 到临时文件 */ + snprintf(mpkpath, sizeof mpkpath, "%s/tmp", TF_CARD_DIR); + mkdir(mpkpath, 0777); + char tmp[1024]; + snprintf(tmp, sizeof tmp, "%s/tmp/upload.mpk", TF_CARD_DIR); + if (multipart_extract_file(r, tmp) < 0) return send_err(sock, 400, "BAD_REQUEST", "上传解析失败"); + snprintf(mpkpath, sizeof mpkpath, "%s", tmp); + } + if (access(mpkpath, R_OK) < 0) return send_err(sock, 404, "NOT_FOUND", ".mpk 文件不存在"); + char cmd[1400]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' install '%s' 2>&1", NYAI_PATH, mpkpath); + char out[4096]; + int code = run_cmd(cmd, out, sizeof out); + if (code != 0) return send_err(sock, 422, "UNSUPPORTED", out[0] ? out : "nyai install 失败"); + /* 包名 = 文件名茎;版本从 app.json 读 */ + char stem[256]; + const char *base = strrchr(mpkpath, '/'); + base = base ? base + 1 : mpkpath; + snprintf(stem, sizeof stem, "%.*s", (int)(sizeof stem - 1), base); + char *dot = strrchr(stem, '.'); + if (dot) *dot = 0; + char appdir[1024], pj[1100]; + snprintf(appdir, sizeof appdir, "%s/%s", MEOWAPPS_DIR, stem); + jv_t *approot = NULL; + char version[16] = ""; + int upgraded = 0; + snprintf(pj, sizeof pj, "%s/app.json", appdir); + char jbuf[8192]; + if (access(pj, R_OK) == 0 && read_small_file(pj, jbuf, sizeof jbuf) > 0) + approot = jv_parse(jbuf, (int)strlen(jbuf)); + if (approot) { + jv_t *v = jv_obj_get(approot, "version"); + if (v && v->type == J_STR) snprintf(version, sizeof version, "%s", v->str); + jv_free(approot); + } + if (access(mpkpath, R_OK) == 0 && ispath && strstr(out, "upgrad")) upgraded = 1; + sbuf_t s = {0}; + sA(&s, "{\"package\":"); + sA_str(&s, stem); + sA(&s, ",\"version\":"); + sA_str(&s, version[0] ? version : NULL); + sA(&s, ",\"installed\":true,\"upgraded\":%s,\"message\":\"已安装\"}", upgraded ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +static int api_apps_export(int sock, const char *pkg) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + char tmp[1100]; + mkdir(TF_CARD_DIR "/tmp", 0777); + snprintf(tmp, sizeof tmp, "%s/tmp/%s.mpk", TF_CARD_DIR, pkg); + unlink(tmp); + char cmd[1400]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' export '%s' '%s' 2>&1", NYAI_PATH, pkg, tmp); + char out[1024]; + int code = run_cmd(cmd, out, sizeof out); + if (code != 0 || access(tmp, R_OK) < 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai export 失败"); + char dn[256]; + snprintf(dn, sizeof dn, "%s.mpk", pkg); + return send_file(sock, 200, "application/octet-stream", tmp, dn); +} + +static int api_apps_remove(int sock, const char *pkg, const req_t *r) +{ + if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡"); + if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法"); + char purgebuf[8] = "0"; + if (r->query[0]) qparam(r->query, "purge", purgebuf, sizeof purgebuf); + int use_purge = atoi(purgebuf); + char cmd[1400]; + snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' remove '%s'%s 2>&1", NYAI_PATH, pkg, use_purge ? " --purge" : ""); + char out[1024]; + int code = run_cmd(cmd, out, sizeof out); + if (code != 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai remove 失败"); + sbuf_t s = {0}; + sA(&s, "{\"removed\":true,\"purged\":%s}", use_purge ? "true" : "false"); + int rc = send_json(sock, 200, s.b ? s.b : ""); + sbuf_free(&s); + return rc; +} + +/* ---------------- 静态文件托管(web_root = 二进制所在目录) ---------------- */ +static char g_webroot[512]; +static const char *web_root(void) +{ + if (g_webroot[0]) return g_webroot; + char exe[512]; + ssize_t n = readlink("/proc/self/exe", exe, sizeof exe - 1); + if (n <= 0) { snprintf(g_webroot, sizeof g_webroot, "."); return g_webroot; } + exe[n] = 0; + char *slash = strrchr(exe, '/'); + if (slash) *slash = 0; + snprintf(g_webroot, sizeof g_webroot, "%s", exe[0] ? exe : "."); + return g_webroot; +} + +static const char *mime_for(const char *path) +{ + const char *ext = strrchr(path, '.'); + if (!ext) return "application/octet-stream"; + if (!strcasecmp(ext, ".html")) return "text/html; charset=utf-8"; + if (!strcasecmp(ext, ".css")) return "text/css; charset=utf-8"; + if (!strcasecmp(ext, ".js")) return "application/javascript; charset=utf-8"; + if (!strcasecmp(ext, ".json")) return "application/json; charset=utf-8"; + if (!strcasecmp(ext, ".png")) return "image/png"; + if (!strcasecmp(ext, ".ico")) return "image/x-icon"; + if (!strcasecmp(ext, ".svg")) return "image/svg+xml"; + return "application/octet-stream"; +} + +static int serve_static(int sock, const char *path) +{ + const char *root = web_root(); + char rel[512]; + /* / → index.html;/assets/ → ;/ → (meowpanel.css/js 等) */ + if (!strcmp(path, "/")) snprintf(rel, sizeof rel, "index.html"); + else if (!strncmp(path, "/assets/", 8)) snprintf(rel, sizeof rel, "%s", path + 8); + else snprintf(rel, sizeof rel, "%s", path + 1); + if (strstr(rel, "..") || rel[0] == '/') return send_err(sock, 403, "FORBIDDEN", "路径逃逸"); + /* 只允许安全文件(防读到二进制/任意文件) */ + char full[1024]; + snprintf(full, sizeof full, "%s/%s", root, rel); + /* 敏感名拒绝 */ + const char *base = strrchr(full, '/'); + base = base ? base + 1 : full; + if (!strcmp(base, "meowpanel") || !strcmp(base, "meowpanel_host") ) return send_err(sock, 403, "FORBIDDEN", "禁止访问可执行文件"); + FILE *f = fopen(full, "rb"); + if (!f) return send_err(sock, 404, "NOT_FOUND", "资源不存在"); + fclose(f); + return send_file(sock, 200, mime_for(full), full, NULL); +} + +/* ---------------- 路由分发 ---------------- */ +static int handle(int sock, req_t *r) +{ + const char *p = r->path; + if (strncmp(p, "/api/", 5) != 0) { + if (strcmp(r->method, "GET") != 0 && strcmp(r->method, "HEAD") != 0) + return send_err(sock, 405, "METHOD_NOT_ALLOWED", "静态资源仅支持 GET"); + return serve_static(sock, p); + } + + /* ---- 性能/状态 ---- */ + if (!strcmp(r->method, "GET") && (!strcmp(p, "/api/status") || !strcmp(p, "/api/perf"))) + return api_perf(sock, p); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/system")) + return api_system(sock); + + /* ---- 系统动作 ---- */ + if (!strcmp(r->method, "POST")) { + if (!strcmp(p, "/api/system/reboot")) return api_system_action(sock, "reboot", r); + if (!strcmp(p, "/api/system/shutdown")) return api_system_action(sock, "shutdown", r); + if (!strcmp(p, "/api/system/ota")) return api_system_action(sock, "ota", r); + if (!strcmp(p, "/api/settings")) return api_settings_batch(sock, r); + if (!strcmp(p, "/api/settings/restore")) return api_settings_restore(sock); + if (!strcmp(p, "/api/theme")) return api_theme_put(sock, r); + if (!strcmp(p, "/api/files/upload")) return api_files_upload(sock, r); + if (!strcmp(p, "/api/files/mkdir")) return api_files_mkdir(sock, r); + if (!strcmp(p, "/api/files/rename")) return api_files_rename(sock, r); + if (!strcmp(p, "/api/apps/install")) return api_apps_install(sock, r); + if (!strcmp(p, "/api/notifications/dismiss_all")) return api_notification_dismiss_all(sock); + if (!strcmp(p, "/api/key")) return api_key_batch(sock, r); + char seg[256]; + if (path_segment("/api/key/", p, seg, sizeof seg)) + return api_key_single(sock, atoi(seg), r); + } + + /* ---- 设置 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/settings")) return api_settings_get_all(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/theme")) return api_theme_get(sock); + if (!strcmp(r->method, "PUT")) { + char seg[256]; + if (path_segment("/api/settings/", p, seg, sizeof seg)) + return api_settings_put(sock, seg, r); + } + if (!strcmp(r->method, "GET")) { + char seg[256]; + if (path_segment("/api/settings/", p, seg, sizeof seg)) + return api_settings_get_any(sock, seg); + } + + /* ---- 文件 ---- */ + 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, "DELETE") && !strcmp(p, "/api/files")) return api_files_delete(sock, r); + + /* ---- 应用 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/apps")) return api_apps_list(sock); + if (!strcmp(r->method, "GET")) { + char seg[256]; + if (path_segment("/api/apps/", p, seg, sizeof seg)) { + /* :pkg/export 结尾(path_segment 已把 seg 截为 pkg,这里检查原 path 尾缀) */ + size_t pl = strlen(p); + int is_export = (pl >= 7 && !strcmp(p + pl - 7, "/export")); + if (is_export) return api_apps_export(sock, seg); + return api_apps_info(sock, seg); + } + } + if (!strcmp(r->method, "DELETE")) { + char seg[256]; + if (path_segment("/api/apps/", p, seg, sizeof seg)) + return api_apps_remove(sock, seg, r); + } + + /* ---- 日志 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs")) return api_logs_list(sock); + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/logs/read")) return api_logs_read(sock, r); + + /* ---- 通知 ---- */ + if (!strcmp(r->method, "GET") && !strcmp(p, "/api/notifications")) return api_notifications(sock); + if (!strcmp(r->method, "DELETE")) { + char seg[256]; + if (path_segment("/api/notifications/", p, seg, sizeof seg)) + return api_notification_delete(sock); + } + + return send_err(sock, 404, "NOT_FOUND", "端点不存在"); +} + +/* ---------------- 主循环 ---------------- */ +static int g_port = DEFAULT_PORT; + +static int read_port_config(void) +{ + /* 配置文件 meowpanel.conf(web 根目录),格式 key=value,取 port */ + char path[1024]; + snprintf(path, sizeof path, "%s/meowpanel.conf", web_root()); + FILE *f = fopen(path, "r"); + if (f) { + char line[160]; + while (fgets(line, sizeof line, f)) { + char *eq = strchr(line, '='); + if (!eq) continue; + char *k = line; while (*k == ' ' || *k == '\t') k++; + if (!strncmp(k, "port", 4) && k[4] == '=') g_port = atoi(eq + 1); + } + fclose(f); + } + const char *env = getenv("MEOWPORT"); + if (env && env[0]) g_port = atoi(env); + if (g_port <= 0 || g_port > 65535) g_port = DEFAULT_PORT; + return g_port; +} + +static void handle_conn(int sock) +{ + req_t r; + memset(&r, 0, sizeof r); + if (read_request(sock, &r) != 0) { close(sock); return; } + handle(sock, &r); + req_free(&r); + close(sock); +} + +int main(int argc, char **argv) +{ + /* 参数:-p PORT(可选)。默认端口 8080。 */ + int port = DEFAULT_PORT; + for (int i = 1; i < argc; i++) { + 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") && 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"); + return 0; + } + } + const char *env = getenv("MEOW_IPC_PERF_SOCK"); + if (env && env[0]) g_sock_perf = env; + env = getenv("MEOW_IPC_POWER_SOCK"); + if (env && env[0]) g_sock_power = env; + + (void)read_port_config(); /* 应用 meowpanel.conf 的 port(env/-p 优先) */ + /* 优先级:-p 已设则保留;否则用 conf/env(read_port_config 会写 g_port) */ + if (port != DEFAULT_PORT) g_port = port; + else g_port = read_port_config(); + /* 再按 env MEOWPORT 覆盖 conf(-p 最高) */ + if (port == DEFAULT_PORT) { + const char *ep = getenv("MEOWPORT"); + if (ep && ep[0]) g_port = atoi(ep); + } + if (g_port <= 0 || g_port > 65535) g_port = DEFAULT_PORT; + + signal(SIGPIPE, SIG_IGN); + web_root(); + panel_log("MeowPanel %s listening on port %d (web root %s)", PANEL_VERSION, g_port, web_root()); + + int lfd = socket(AF_INET, SOCK_STREAM, 0); + if (lfd < 0) { panel_log("socket fail: %s", strerror(errno)); return 1; } + int on = 1; + setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on); + struct sockaddr_in addr; + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons((uint16_t)g_port); + if (bind(lfd, (struct sockaddr *)&addr, sizeof addr) < 0) { + panel_log("bind :%d fail: %s", g_port, strerror(errno)); + close(lfd); + return 1; + } + if (listen(lfd, 16) < 0) { panel_log("listen fail: %s", strerror(errno)); close(lfd); return 1; } + + for (;;) { + int c = accept(lfd, NULL, NULL); + if (c < 0) { if (errno == EINTR) continue; continue; } + handle_conn(c); + } + return 0; +} diff --git a/patch_v2/meowpanel/services.d/meowpanel.conf b/patch_v2/meowpanel/services.d/meowpanel.conf new file mode 100644 index 0000000..8ca1bdf --- /dev/null +++ b/patch_v2/meowpanel/services.d/meowpanel.conf @@ -0,0 +1,18 @@ +# meowpanel.conf — MeowPanel 后端服务配置(外置 TF 卡,meow_svcd 托管)。 +# 部署位置:TF 卡 `/usr/data/mnt/sd_0/MeowOS/services.d/meowpanel.conf` +# (svcd 双源:板载 /usr/data/meow/services.d + TF MeowOS/services.d,同名板载优先;无卡跳过该服务)。 +# 字段对齐 meow_svcd 服务配置样例(SYSTEM_FRAMEWORK_SERVICES §1.3 / meow_svcd.md)。 +# +# 定位:MEOWPANEL_DESIGN §1/§2 —— MeowPanel 是**外置系统组件**,整体走卡(MeowOS/opt/MeowPanel), +# 不进固件;无卡时 svcd 跳过本服务,系统核心(launcher/sdk/设置/通知)绝不对它依赖。 +# +# 注意:本服务在卡上,端口监听 8080(可用 -p / MEOWPORT / 同目录 meowpanel.conf 的 port=N 覆盖)。 + +exec=/usr/data/mnt/sd_0/MeowOS/opt/MeowPanel/meowpanel +args= +enabled=1 +restart=always +backoff_ms=2000 +max_restart=5 +trigger=boot +cap= diff --git a/patch_v2/meowpanel/web/index.html b/patch_v2/meowpanel/web/index.html new file mode 100644 index 0000000..b2c7b73 --- /dev/null +++ b/patch_v2/meowpanel/web/index.html @@ -0,0 +1,107 @@ + + + + + + +MeowPanel · MeowOS 面板 + + + + + + + +
+ + + + + +
+
+ +

实时性能

+
+ + 检测中… + +
+ + +
+
+ + +
+
+
+ +
+

加载中…

+
+
+
+ + + + + + + + + diff --git a/patch_v2/meowpanel/web/meowpanel.css b/patch_v2/meowpanel/web/meowpanel.css new file mode 100644 index 0000000..a5ce810 --- /dev/null +++ b/patch_v2/meowpanel/web/meowpanel.css @@ -0,0 +1,311 @@ +/* MeowPanel — 前端样式 + * 主题模型:风格族(ui.style: md3 / miuix)× 明暗(display.theme: dark / light) + * 双层变量: + * [data-style] 定形状(radius / spacing / outline 宽)+ 主色(--primary)——对齐 SDK §8.1.3 + * [data-theme] 定中性色 / 语义色(bg/surface/on-surface/outline/error/...) + * 默认(:root 无覆盖)= MD3 dark;MD3 中立形状再显式声明便于对照。 + */ + +/* ---------- 结构基线 ---------- */ +:root{ + --space:8px; + --radius-s:8px; /* 小:chip/内嵌 */ + --radius-m:12px; /* 中:按钮/输入 */ + --radius-l:16px; /* 大:卡片 */ + --outline-w:0px; /* MD3 无描边 */ + --nav-w:212px; + --topbar-h:56px; + --font: -apple-system, "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; + --mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", Consolas, monospace; + --speed:.16s; +} + +/* 风格族:MIUIx 更大圆角 / 略大间距 / 细描边 / 偏品牌主色(更饱和) */ +:root[data-style="miuix"]{ + --space:10px; + --radius-s:12px; + --radius-m:18px; /* 按钮 ≈18px */ + --radius-l:24px; /* 卡片 ≈24px */ + --outline-w:1px; /* 细描边 */ +} + +/* ---------- 深色(默认主深色) ---------- */ +:root[data-theme="dark"]{ + --bg:#0a0a0a; /* 背景底(黑)*/ + --surface:#141414; + --surface-variant:#1e1e1e; + --surface-dim:#000000; + --on-surface:#ececec; + --on-surface-variant:#a3a3a3; + --outline:#3a3a3a; + --outline-variant:#2a2a2a; + --error:#cf6679; --on-error:#0a0a0a; + --success:#4caf50; --warning:#ffb300; + --shadow:0 2px 10px rgba(0,0,0,.5); + --scrim:rgba(0,0,0,.55); + --hover:rgba(255,255,255,.06); +} +/* 深色 × MIUIx 主色更饱和 */ +:root[data-theme="dark"][data-style="md3"]{ --primary:#4a8bf5; --on-primary:#fff; } +:root[data-theme="dark"][data-style="miuix"]{--primary:#ff7a00; --on-primary:#fff; } + +/* ---------- 浅色 ---------- */ +:root[data-theme="light"]{ + --bg:#f4f5f7; + --surface:#ffffff; + --surface-variant:#ececee; + --surface-dim:#e1e1e4; + --on-surface:#1b1b1d; + --on-surface-variant:#5c5c60; + --outline:#d2d2d6; + --outline-variant:#e4e4e8; + --error:#ba1a1a; --on-error:#ffffff; + --success:#2e7d32; --warning:#ed6c02; + --shadow:0 2px 10px rgba(0,0,0,.12); + --scrim:rgba(0,0,0,.35); + --hover:rgba(0,0,0,.05); +} +:root[data-theme="light"][data-style="md3"]{ --primary:#2f6fe0; --on-primary:#fff; } +:root[data-theme="light"][data-style="miuix"]{--primary:#f57200; --on-primary:#fff; } + +/* ---------- 布局 ---------- */ +*{ box-sizing:border-box; } +html,body{ height:100%; margin:0; } +body{ + background:var(--bg); color:var(--on-surface); + font-family:var(--font); font-size:14px; line-height:1.5; + -webkit-font-smoothing:antialiased; +} +.app{ display:flex; height:100vh; overflow:hidden; } + +/* 侧栏 */ +.sidebar{ + width:var(--nav-w); flex:0 0 var(--nav-w); + background:var(--surface); border-right:1px solid var(--outline-variant); + display:flex; flex-direction:column; transition:margin-left var(--speed) ease; +} +.brand{ display:flex; align-items:center; gap:10px; padding:16px 18px; } +.brand-mark{ + width:30px; height:30px; border-radius:var(--radius-m); + background:var(--primary); color:var(--on-primary); + display:flex; align-items:center; justify-content:center; font-weight:700; +} +.brand-name{ font-weight:600; font-size:15px; } +.nav{ flex:1; overflow-y:auto; padding:8px 10px; display:flex; flex-direction:column; gap:2px; } +.nav-item{ + display:flex; align-items:center; gap:12px; padding:10px 12px; + border:none; background:transparent; color:var(--on-surface-variant); + border-radius:var(--radius-m); cursor:pointer; text-align:left; + font:inherit; font-size:14px; transition:background var(--speed), color var(--speed); +} +.nav-item:hover{ background:var(--hover); color:var(--on-surface); } +.nav-item.active{ background:var(--primary); color:var(--on-primary); } +.nav-ico{ width:22px; height:22px; flex:0 0 22px; } +.nav-ico svg{ width:100%; height:100%; display:block; } +.nav-label{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + +/* 主区 */ +.main{ flex:1; display:flex; flex-direction:column; min-width:0; } +.topbar{ + height:var(--topbar-h); flex:0 0 var(--topbar-h); + display:flex; align-items:center; gap:12px; padding:0 16px; + background:var(--surface); border-bottom:1px solid var(--outline-variant); +} +.topbar-title{ font-size:17px; font-weight:600; margin:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.icon-btn{ + width:38px; height:38px; border:none; border-radius:var(--radius-m); + background:transparent; color:var(--on-surface-variant); cursor:pointer; + display:flex; align-items:center; justify-content:center; +} +.icon-btn:hover{ background:var(--hover); color:var(--on-surface); } +.icon-btn svg{ width:22px; height:22px; } +.menu-btn{ display:none; } + +/* 顶栏分段控件(风格/明暗) */ +.seg{ display:inline-flex; background:var(--surface-variant); border-radius:var(--radius-m); padding:3px; } +.seg-btn{ + border:none; background:transparent; color:var(--on-surface-variant); + padding:6px 12px; border-radius:calc(var(--radius-m) - 2px); + cursor:pointer; font:inherit; font-size:13px; white-space:nowrap; + transition:background var(--speed), color var(--speed); +} +.seg-btn.active{ background:var(--surface); color:var(--on-surface); box-shadow:var(--shadow); } + +/* 连接状态点 */ +.conn{ display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:var(--radius-m); background:var(--surface-variant); color:var(--on-surface-variant); font-size:12px; } +.conn-dot{ width:9px; height:9px; border-radius:50%; background:var(--on-surface-variant); } +.conn[data-state="ok"] .conn-dot{ background:var(--success); } +.conn[data-state="bad"] .conn-dot{ background:var(--error); } +.conn[data-state="ok"]{ color:var(--success); } +.conn[data-state="bad"]{ color:var(--error); } + +/* 内容区 */ +.content{ flex:1; overflow-y:auto; padding:20px; } +.page{ max-width:1100px; margin:0 auto; } +.placeholder{ color:var(--on-surface-variant); text-align:center; padding:60px 0; } + +/* 卡片 */ +.card{ + background:var(--surface); border-radius:var(--radius-l); + border:var(--outline-w) solid var(--outline); /* MD3 无描边;MIUIx 1px */ + box-shadow:var(--shadow); padding:18px 20px; margin-bottom:16px; + overflow-x:auto; /* 窄屏表格/内容横向滚动,避免整页外溢 */ +} +.card-title{ font-size:15px; font-weight:600; margin:0 0 14px; } + +/* 统计格子(性能页) */ +.stat-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; } +.stat{ + background:var(--surface-variant); border-radius:var(--radius-m); + padding:14px 16px; border:var(--outline-w) solid var(--outline-variant); +} +.stat-label{ color:var(--on-surface-variant); font-size:12px; margin-bottom:6px; } +.stat-value{ font-size:22px; font-weight:700; letter-spacing:.3px; } +.stat-unit{ font-size:13px; font-weight:400; color:var(--on-surface-variant); margin-left:4px; } +.stat-sub{ font-size:12px; color:var(--on-surface-variant); margin-top:6px; } + +/* 进度条 */ +.bar{ height:8px; border-radius:99px; background:var(--surface-dim); overflow:hidden; margin-top:8px; } +.bar > span{ display:block; height:100%; border-radius:99px; background:var(--primary); transition:width .4s ease; } +.bar.warn > span{ background:var(--warning); } +.bar.danger > span{ background:var(--error); } + +/* 按钮 */ +.btn{ + border:none; padding:9px 16px; border-radius:var(--radius-m); + background:var(--surface-variant); color:var(--on-surface); + cursor:pointer; font:inherit; font-size:14px; + display:inline-flex; align-items:center; gap:6px; + transition:filter var(--speed), background var(--speed), transform .05s; +} +.btn:hover{ filter:brightness(1.08); } +.btn:active{ transform:scale(.98); } +.btn:disabled{ opacity:.5; cursor:not-allowed; } +.btn-primary{ background:var(--primary); color:var(--on-primary); } +.btn-danger{ background:var(--error); color:var(--on-error); } +.btn-ghost{ background:transparent; } +.icon-txt{ font-size:16px; } + +/* 表格(文件/日志) */ +.table{ width:100%; border-collapse:collapse; } +.table th, .table td{ text-align:left; padding:10px 12px; border-bottom:1px solid var(--outline-variant); } +.table th{ color:var(--on-surface-variant); font-weight:500; font-size:12px; } +.table tr:hover td{ background:var(--hover); } +.row-actions{ display:flex; gap:6px; justify-content:flex-end; } +.muted{ color:var(--on-surface-variant); font-size:12px; } +.mono{ font-family:var(--mono); font-size:12px; word-break:break-all; } + +/* 面包屑 */ +.crumbs{ display:flex; flex-wrap:wrap; align-items:center; gap:4px; margin-bottom:12px; } +.crumbs a{ color:var(--primary); cursor:pointer; text-decoration:none; } +.crumbs a:hover{ text-decoration:underline; } + +/* 表单行 */ +.field{ margin-bottom:16px; } +.field-label{ display:block; margin-bottom:6px; color:var(--on-surface-variant); font-size:13px; } +.field-row{ display:flex; align-items:center; gap:12px; } +.field-row.wrap{ flex-wrap:wrap; } +input[type=text], input[type=number], select, textarea{ + width:100%; padding:9px 12px; border-radius:var(--radius-m); + border:var(--outline-w) solid var(--outline); background:var(--surface-variant); + color:var(--on-surface); font:inherit; font-size:14px; border:0; +} +textarea{ resize:vertical; font-family:var(--mono); font-size:12px; } +input[type=range]{ width:100%; accent-color:var(--primary); } +.field-row input[type=range]{ flex:1; min-width:0; } + +/* 开关 */ +.switch{ position:relative; width:46px; height:26px; flex:0 0 46px; } +.switch input{ position:absolute; opacity:0; inset:0; margin:0; cursor:pointer; } +.switch .track{ position:absolute; inset:0; border-radius:99px; background:var(--surface-dim); transition:background var(--speed); border:var(--outline-w) solid var(--outline-variant); } +.switch .thumb{ position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%; background:var(--surface); box-shadow:var(--shadow); transition:left var(--speed); } +.switch input:checked + .track{ background:var(--primary); } +.switch input:checked + .track .thumb{ left:23px; background:var(--on-primary); } + +/* 应用卡片 */ +.app-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:14px; } +.app-card{ + background:var(--surface-variant); border-radius:var(--radius-l); + border:var(--outline-w) solid var(--outline-variant); padding:16px; + display:flex; flex-direction:column; gap:8px; +} +.app-card .app-head{ display:flex; align-items:center; gap:12px; } +.app-card .app-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; } +.app-card .app-name{ font-weight:600; } +.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; } + +/* 远程按键区 */ +.key-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:12px; } +.key-btn{ + padding:14px; border:none; border-radius:var(--radius-m); + background:var(--surface-variant); color:var(--on-surface); + border:var(--outline-w) solid var(--outline-variant); + cursor:pointer; font:inherit; text-align:center; transition:background var(--speed); +} +.key-btn:hover{ background:var(--hover); } +.key-btn .k-ico{ font-size:20px; display:block; margin-bottom:4px; } + +/* 危险区 */ +.danger-box{ border:1px solid var(--error); border-radius:var(--radius-l); padding:16px 18px; } +.danger-box .card-title{ color:var(--error); } +.warn-note{ color:var(--warning); font-size:12px; margin-top:8px; } + +/* 日志查看 */ +.log-view{ background:var(--surface-dim); border-radius:var(--radius-m); padding:12px; max-height:60vh; overflow:auto; font-family:var(--mono); font-size:12px; white-space:pre-wrap; color:var(--on-surface); } + +/* 通知列表 */ +.notif{ display:flex; gap:12px; padding:12px; border-radius:var(--radius-m); background:var(--surface-variant); margin-bottom:10px; align-items:flex-start; border:var(--outline-w) solid var(--outline-variant); } +.notif .n-ico{ width:36px; height:36px; border-radius:var(--radius-m); background:var(--primary); color:var(--on-primary); display:flex; align-items:center; justify-content:center; flex:0 0 36px; font-weight:700; } +.notif .n-body{ flex:1; min-width:0; } +.notif .n-title{ font-weight:600; } +.notif .n-text{ color:var(--on-surface-variant); font-size:13px; margin-top:2px; } +.notif .n-meta{ display:flex; gap:8px; margin-top:6px; color:var(--on-surface-variant); font-size:11px; } +.urgency-hi{ color:var(--error); } + +/* 横幅 / toast / modal */ +.offline-banner{ + position:sticky; top:0; z-index:30; padding:10px 16px; + background:var(--error); color:var(--on-error); font-size:13px; +} +.offline-banner code{ background:rgba(0,0,0,.2); padding:1px 5px; border-radius:4px; } + +.toast{ + position:fixed; left:50%; bottom:28px; transform:translateX(-50%); + z-index:60; background:var(--surface); color:var(--on-surface); + padding:11px 18px; border-radius:var(--radius-m); box-shadow:var(--shadow); + border:var(--outline-w) solid var(--outline-variant); font-size:13px; + max-width:80vw; +} + +.modal-scrim{ + position:fixed; inset:0; z-index:50; background:var(--scrim); + display:flex; align-items:center; justify-content:center; padding:20px; +} +.modal-scrim[hidden]{ display:none; } /* 覆盖上方 display:flex,否则 hidden 失效 */ +.modal{ background:var(--surface); border-radius:var(--radius-l); width:min(420px,100%); padding:22px; box-shadow:var(--shadow); border:var(--outline-w) solid var(--outline-variant); } +.modal-title{ font-size:16px; margin:0 0 10px; } +.modal-body{ color:var(--on-surface-variant); margin:0 0 18px; word-break:break-all; } +.modal-actions{ display:flex; justify-content:flex-end; gap:10px; } + +/* 空态 / 加载 */ +.empty{ text-align:center; color:var(--on-surface-variant); padding:48px 0; } +.skel{ color:var(--on-surface-variant); } +.spinner{ display:inline-block; width:18px; height:18px; border:2px solid var(--outline-variant); border-top-color:var(--primary); border-radius:50%; animation:spin .7s linear infinite; vertical-align:middle; } +@keyframes spin{ to{ transform:rotate(360deg); } } + +/* 响应式:窄屏收起侧栏 */ +@media (max-width:820px){ + :root{ --nav-w:216px; } + .sidebar{ position:fixed; left:0; top:0; bottom:0; z-index:40; margin-left:calc(-1 * var(--nav-w)); } + .sidebar.open{ margin-left:0; box-shadow:var(--shadow); } + .menu-btn{ display:flex; } + .brand-name{ display:none; } + .conn-text{ display:none; } +} +@media (max-width:520px){ + .content{ padding:14px; } + .topbar{ padding:0 10px; gap:8px; } + .seg-btn{ padding:6px 9px; } +} diff --git a/patch_v2/meowpanel/web/meowpanel.js b/patch_v2/meowpanel/web/meowpanel.js new file mode 100644 index 0000000..c38a9b4 --- /dev/null +++ b/patch_v2/meowpanel/web/meowpanel.js @@ -0,0 +1,751 @@ +/* MeowPanel — 前端逻辑(纯静态,零第三方依赖) + * + * 对接协议:devdocs/plan/MEOWPANEL_PROTOCOL.md(草案,后端未实现)。 + * 所有 fetch 失败都优雅降级为「后端未连接」,不假设后端就绪。 + */ +(function () { + 'use strict'; + + /* ---------------- 小工具 ---------------- */ + const $ = (sel, root) => (root || document).querySelector(sel); + const $$ = (sel, root) => Array.prototype.slice.call((root || document).querySelectorAll(sel)); + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[m])); + } + function fmtBytes(n) { + if (n == null || isNaN(n)) return '—'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; let v = Number(n), i = 0; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i]; + } + function fmtUptime(sec) { + if (sec == null || isNaN(sec)) return '—'; + sec = Math.floor(sec); + const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60); + return (d > 0 ? d + ' 天 ' : '') + h + ' 小时 ' + m + ' 分'; + } + function fmtTime(ts) { return ts ? new Date(ts * 1000).toLocaleString() : '—'; } + function debounce(fn, ms) { let t = null; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => fn.apply(c, a), ms); }; } + function pct(p) { return Math.max(0, Math.min(100, p)); } + + /* ---------------- 全局状态 ---------------- */ + const state = { + style: 'md3', theme: 'dark', + conn: 'unknown', + filesRel: '/', filesAbs: null, + perfTimer: null, // 性能页轮询 + route: 'perf', + downloadNonce: 0 + }; + + /* ---------------- 后端连接状态 ---------------- */ + function setConn(ok) { + state.conn = ok ? 'ok' : 'bad'; + const dot = $('#conn-dot'); + dot.dataset.state = state.conn; + $('#conn-text').textContent = ok ? '在线' : '离线'; + $('#offline-banner').hidden = !!ok; + } + + /* ---------------- API 客户端 ---------------- */ + // 返回 {code,message,status} 形异常;网络/非 JSON → 视为断连 + async function api(method, path, body) { + let res; + const init = { method: method || 'GET' }; + if (body instanceof FormData) { + init.body = body; + } else if (body !== undefined && body !== null) { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(body); + } + try { + res = await fetch(path, init); + } catch (e) { + setConn(false); + throw { code: 'NETWORK', message: '后端未连接', status: 0 }; + } + let json = null; + try { json = await res.json(); } catch (e) { json = null; } + if (!json || typeof json.ok !== 'boolean') { + // 静态服务器返回 HTML / 404 等 + setConn(res.ok ? true : false); + throw { code: 'NOT_JSON', message: '后端返回了非 JSON 响应(HTTP ' + res.status + ')', status: res.status }; + } + if (!json.ok) { + setConn(true); + const code = (json.error && json.error.code) || 'ERROR'; + throw { code: code, message: (json.error && json.error.message) || ('后端错误 ' + code), status: res.status }; + } + setConn(true); + return json.data; + } + + // 下载(返回 blob URL) + async function apiDownload(path) { + let res; + try { res = await fetch(path); } + catch (e) { setConn(false); throw { code: 'NETWORK', message: '后端未连接' }; } + if (!res.ok) { setConn(true); throw { code: 'HTTP_' + res.status, message: '下载失败(HTTP ' + res.status + ')' }; } + setConn(true); + return URL.createObjectURL(await res.blob()); + } + + /* ---------------- toast / 确认弹窗 ---------------- */ + let toastTimer = null; + function toast(msg, ms) { + const t = $('#toast'); + t.textContent = msg; t.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { t.hidden = true; }, ms || 2600); + } + function confirmDlg(title, body, danger) { + return new Promise(res => { + $('#modal-title').textContent = title || '确认'; + $('#modal-body').textContent = body || ''; + const yes = $('#modal-yes'), no = $('#modal-no'); + yes.className = 'btn btn-primary' + (danger ? ' btn-danger' : ''); + const scrim = $('#modal'); scrim.hidden = false; + const done = v => { scrim.hidden = true; yes.onclick = no.onclick = scrim.onclick = null; res(v); }; + yes.onclick = () => done(true); + no.onclick = () => done(false); + scrim.onclick = e => { if (e.target === scrim) done(false); }; + }); + } + + /* ---------------- 主题(MD3/MIUIx × 深/浅) ---------------- */ + function applyTheme(style, theme) { + state.style = style; state.theme = theme; + const root = document.documentElement; + root.dataset.style = style; root.dataset.theme = theme; + $$('#style-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.style === style)); + $$('#theme-seg .seg-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === theme)); + try { localStorage.setItem('meowpanel.style', style); localStorage.setItem('meowpanel.theme', theme); } catch (e) {} + } + async function sendTheme(patch) { + try { await api('PUT', '/api/theme', patch); } + catch (e) { toast('主题已本地切换(后端未同步:' + e.message + ')'); } + } + async function initTheme() { + let style = 'md3', theme = 'dark'; + try { style = localStorage.getItem('meowpanel.style') || 'md3'; theme = localStorage.getItem('meowpanel.theme') || 'dark'; } catch (e) {} + applyTheme(style, theme); + try { + const t = await api('GET', '/api/theme'); + applyTheme(t.style || style, t.theme || theme); + } catch (e) { /* 后端离线,保持本地 */ } + } + // 顶栏「风格 / 明暗」切换器:本地立即切换 + 同步后端 + function bindThemeUi() { + $('#style-seg').addEventListener('click', function (e) { + const b = e.target.closest('.seg-btn'); if (!b) return; + applyTheme(b.dataset.style, state.theme); + sendTheme({ style: b.dataset.style }); + }); + $('#theme-seg').addEventListener('click', function (e) { + const b = e.target.closest('.seg-btn'); if (!b) return; + applyTheme(state.style, b.dataset.theme); + sendTheme({ theme: b.dataset.theme }); + }); + } + + /* ---------------- 页面空态 / 错误提示 ---------------- */ + function emptyState(msg, code) { + const t = code === 'NO_CARD' ? '未识别到 TF 卡' : (code === 'NOT_IMPLEMENTED' ? '该功能后端尚未实现' : msg); + return '
' + esc(t) + '
'; + } + function errText(e) { + if (e && e.code === 'NO_CARD') return '未识别到 TF 卡'; + if (e && e.code === 'NOT_IMPLEMENTED') return '该功能后端尚未实现'; + return (e && e.message) ? e.message : '未知错误'; + } + + /* ---------------- 1. 性能页(2s 轮询 /api/perf) ---------------- */ + function barHtml(p) { + p = pct(p); + const c = p >= 90 ? 'danger' : (p >= 70 ? 'warn' : ''); + return '
'; + } + function stat(label, valueHtml, sub, barPct) { + return '
' + esc(label) + '
' + valueHtml + + (sub ? '
' + sub + '
' : '') + + (barPct != null ? barHtml(barPct) : '') + '
'; + } + function renderPerfData(page, d) { + const cpu = d.cpu || {}; + const mem = d.memory || {}, st = d.storage || {}, tf = d.tfcard || {}, tmp = d.temp; + let h = '

实时性能

'; + h += '
运行时间:' + fmtUptime(d.uptime_sec) + '
'; + h += stat('CPU', '' + (cpu.usage == null ? '—' : +cpu.usage.toFixed(1)) + '%', + cpu.cores != null ? cpu.cores + ' 核 · 负载 ' + (cpu.load ? cpu.load.map(x => (+x).toFixed(2)).join(' / ') : '—') : '', + cpu.usage || 0); + const memP = mem.total ? (mem.used / mem.total) * 100 : 0; + h += stat('内存', '' + fmtBytes((mem.used || 0) * 1024) + '/ ' + fmtBytes((mem.total || 0) * 1024) + '', + '可用 ' + fmtBytes((mem.available || (mem.free || 0)) * 1024), memP); + const stP = st.total ? (st.used / st.total) * 100 : 0; + h += stat('存储 (rootfs)', '' + fmtBytes((st.used || 0) * 1024) + '/ ' + fmtBytes((st.total || 0) * 1024) + '', st.path || '', stP); + if (tf) { + if (tf.present) { + const tfP = tf.total ? (tf.used / tf.total) * 100 : 0; + h += stat('TF 卡', '' + fmtBytes((tf.used || 0) * 1024) + '/ ' + fmtBytes((tf.total || 0) * 1024) + '', + fmtBytes((tf.avail || 0) * 1024) + ' 可用 · ' + (tf.filesystem || ''), tfP); + } else { + h += stat('TF 卡', '未插卡', ''); + } + } + const tmpCpu = tmp && tmp.cpu != null ? +tmp.cpu.toFixed(1) : null; + h += stat('温度', '' + (tmpCpu == null ? '—' : tmpCpu) + '℃', + tmp && tmp.board != null ? '板载 ' + (+tmp.board.toFixed(1)) + ' ℃' : ''); + h += '
'; + page.innerHTML = h; + } + async function fetchPerfOnce() { + const page = $('#perf-page'); + if (!page || state.route !== 'perf') return; + let d; + try { d = await api('GET', '/api/perf'); } + catch (e) { if (page && state.route === 'perf') page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route === 'perf') renderPerfData(page, d); + } + function stopPerf() { if (state.perfTimer) { clearInterval(state.perfTimer); state.perfTimer = null; } } + function renderPerf() { + $('#content').innerHTML = '

加载中…

'; + if (state.perfTimer) stopPerf(); + fetchPerfOnce(); + state.perfTimer = setInterval(fetchPerfOnce, 2000); + } + + /* ---------------- 2. 亮度 / 音量页 ---------------- */ + let volTimer = null; + function slur(step) { // step: {key,label,min,max,val} + return '

' + esc(step.label) + '

' + + '
' + step.val + '' + + '' + + '' + step.max + '
'; + } + async function renderBright() { + $('#content').innerHTML = '

加载设置…

'; + let bright, vol; + try { + bright = await api('GET', '/api/settings/display.brightness'); + } catch (e) { $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + try { vol = await api('GET', '/api/settings/audio.volume'); } catch (e) { vol = null; } + if (state.route !== 'bright') return; + let h = '
'; + h += slur({ key: 'display.brightness', label: '亮度', min: bright.min || 1, max: bright.max || 100, val: bright.value }); + if (vol) h += slur({ key: 'audio.volume', label: '音量', min: vol.min || 0, max: vol.max || 100, val: vol.value }); + else h += '

音量设置项不可用(后端未实现 audio.volume)

'; + h += '

滑条改动自动保存到系统(走 meow_settings_*),与实体设置页同步。

'; + $('#content').innerHTML = h; + } + const saveSetting = debounce(async function (key, value, type) { + try { + await api('PUT', '/api/settings/' + key, { value: value }); + toast('已保存 ' + key + ' = ' + value); + } catch (e) { toast('保存失败:' + errText(e)); } + }, 400); + function bindSlider() { + $('#content').addEventListener('input', function (e) { + const r = e.target.closest('[data-slider]'); + if (!r) return; + const row = r.closest('.field-row'); + if (row) row.querySelector('.muted').textContent = r.value; + saveSetting(r.dataset.key, +r.value, 'int'); + }); + } + + /* ---------------- 3. 文件管理页 ---------------- */ + // 返回带索引的 rel 显示面包屑 + function joinRel(a, b) { + if (!a || a === '/') return '/' + b; + return (a.endsWith('/') ? a.slice(0, -1) : a) + '/' + b; + } + function crumbHtml(items, cur) { // items: [{name,rel}] + let h = '
'; + h += '根目录'; + items.forEach(it => { h += '/' + esc(it.name) + ''; }); + return h + '
'; + } + async function navFiles(rel) { + const page = $('#files-page'); + if (!page) return; + let relPath = rel || '/'; + page.innerHTML = '

加载中…

'; + let d; + try { d = await api('GET', '/api/files?path=' + encodeURIComponent(relPath)); } + catch (e) { page.innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + state.filesRel = d.rel || relPath; state.filesAbs = d.path; + let h = crumbHtml(segments(d.rel), d.rel); + // 白名单快捷入口(根目录下) + if (d.rel === '/' && d.allowed) { + h += '
' + + d.allowed.map(a => '').join(' ') + '
'; + } + h += '
' + + '' + + '' + + '' + + '
'; + h += '
'; + if (!d.items.length) h += ''; + d.items.forEach(function (it) { + const isDir = it.type === 'dir'; + const icon = isDir ? '📁' : (it.type === 'file' ? '📄' : '❔'); + h += '' + + ''; + }); + h += '
名称大小修改时间操作
空目录
' + (isDir ? '' : '') + icon + ' ' + esc(it.name) + (isDir ? '' : '') + + '' + (isDir ? '—' : fmtBytes(it.size)) + '' + fmtTime(it.mtime) + '
' + + (isDir ? '' : '下载') + + '' + + '' + + '
'; + page.innerHTML = h; + } + function segments(rel) { + const parts = (rel || '').split('/').filter(Boolean); const out = []; let cur = ''; + parts.forEach(p => { cur += '/' + p; out.push({ name: p, rel: cur }); }); + return out; + } + function renderFiles() { $('#content').innerHTML = '
'; navFiles(state.filesRel || '/'); } + function absFor(name) { return state.filesAbs ? (state.filesAbs.endsWith('/') ? state.filesAbs + name : state.filesAbs + '/' + name) : null; } + function bindFiles() { + const c = $('#content'); + c.addEventListener('click', async function (e) { + const t = e.target; + const enter = t.closest('[data-enter]'); const dl = t.closest('[data-dl]'); + 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 btn = t.closest('button[id]'); + if (crumb) { navFiles(crumb.dataset.rel); return; } + if (nav) { navFiles(nav.dataset.nav); return; } + if (dirlink || enter) { const n = (dirlink || enter).dataset.directory || (dirlink || enter).dataset.dir || (dirlink || enter).dataset.enter; navFiles(joinRel(state.filesRel, n)); return; } + if (dl) { + const abs = absFor(dl.dataset.dl); if (!abs) return toast('无当前目录'); + doDownload('/api/files/download?path=' + encodeURIComponent(abs), dl.dataset.dl); + 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; } + }); + c.addEventListener('change', function (e) { + if (e.target.id === 'file-input') uploadFiles(e.target.files); + }); + } + function doDownload(url, name) { + apiDownload(url).then(blobUrl => { + const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'file'; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); + }).catch(e => toast('下载失败:' + errText(e))); + } + async function removeFile(name) { + const abs = absFor(name); if (!abs) return; + const ok = await confirmDlg('删除 ' + name, '此操作不可撤销,确认删除?\n' + abs, true); + if (!ok) return; + try { + await api('DELETE', '/api/files?path=' + encodeURIComponent(abs)); + toast('已删除 ' + name); navFiles(state.filesRel); + } catch (e) { toast('删除失败:' + errText(e)); } + } + async function renameFile(name) { + const abs = absFor(name); if (!abs) return; + const nn = prompt('将「' + name + '」重命名为:', name); + if (!nn || nn === name) return; + try { + await api('POST', '/api/files/rename', { path: abs, new_name: nn }); + toast('已重命名'); navFiles(state.filesRel); + } catch (e) { toast('重命名失败:' + errText(e)); } + } + async function mkdirFile() { + const abs = state.filesAbs; if (!abs) return; + const nn = prompt('新建目录名称(将创建于 ' + state.filesRel + '):', 'newdir'); + if (!nn) return; + try { + await api('POST', '/api/files/mkdir', { path: joinRel(abs, nn) }); + toast('已创建 ' + nn); navFiles(state.filesRel); + } catch (e) { toast('创建失败:' + errText(e)); } + } + async function uploadFiles(files) { + if (!files || !files.length) return; + const abs = state.filesAbs; if (!abs) return toast('无当前目录'); + const fd = new FormData(); fd.append('path', abs); + Array.prototype.forEach.call(files, f => fd.append('file', f)); + const n = files.length; + toast('正在上传 ' + n + ' 个文件…'); + try { + await api('POST', '/api/files/upload', fd); + toast('上传完成'); navFiles(state.filesRel); + } catch (e) { toast('上传失败:' + errText(e)); } + } + + /* ---------------- 4. 应用管理页 ---------------- */ + function renderApps() { + $('#content').innerHTML = '

加载应用列表…

'; + (async function () { + let d; + try { d = await api('GET', '/api/apps'); } + catch (e) { if (state.route === 'apps') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'apps') return; + let h = '
'; + h += '
' + + '' + + '' + + '
'; + h += '
'; + if (!d.apps.length) h += '
尚未安装任何 MeowAPP
'; + d.apps.forEach(a => { + const ino = esc((a.name || a.package || '?').charAt(0).toUpperCase()); + h += '
' + + '
' + ino + '
' + + '
' + esc(a.name || a.package || '未命名') + '
' + + '
' + esc(a.package || '') + '
' + + '
版本 ' + esc(a.version || '—') + (a.invalid ? ' · 无效' : '') + + (a.size ? ' · ' + fmtBytes(a.size) : '') + '
' + + '
' + + '' + + '导出 .mpk' + + '' + + '
'; + }); + h += '
'; + // 本地源 .mpk 安装(后端支持 path 形式) + h += '

从卡上路径安装

' + + '
' + + '
'; + $('#content').innerHTML = h; + })(); + } + function bindApps() { + const c = $('#content'); + c.addEventListener('click', async function (e) { + const t = e.target; + 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'); + 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 (exp) { e.preventDefault(); doDownload('/api/apps/' + exp.dataset.aexport + '/export', exp.dataset.aexport + '.mpk'); return; } + if (uninst) { appUninstall(uninst.dataset.auninst); return; } + if (goPath) { installFromPath($('#app-install-path').value); return; } + }); + c.addEventListener('change', function (e) { + if (e.target.id === 'app-file') installFromFile(e.target.files); + }); + } + async function installFromFile(files) { + if (!files || !files.length) return; + const fd = new FormData(); fd.append('file', files[0]); + toast('正在安装 ' + files[0].name + '…'); + try { + const d = await api('POST', '/api/apps/install', fd); + toast('已安装 ' + (d.package || '应用') + (d.version ? ' v' + d.version : '')); + renderApps(); + } catch (e) { toast('安装失败:' + errText(e)); } + } + async function installFromPath(p) { + if (!p) return toast('请输入 .mpk 路径'); + toast('正在安装 ' + p + '…'); + try { + const d = await api('POST', '/api/apps/install', { path: p }); + toast('已安装 ' + (d.package || '应用')); renderApps(); + } catch (e) { toast('安装失败:' + errText(e)); } + } + async function appUninstall(pkg) { + const ok = await confirmDlg('卸载 ' + pkg, '将移除应用与数据预留(数据默认保留)。确认卸载?', true); + if (!ok) return; + try { await api('DELETE', '/api/apps/' + pkg); toast('已卸载 ' + pkg); renderApps(); } + catch (e) { toast('卸载失败:' + errText(e)); } + } + async function appInfo(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)); } + } + + /* ---------------- 5. 设置镜像页 ---------------- */ + function settingControl(it) { + const key = it.key; + if (it.type === 'bool') { + return '
' + esc(key) + '
' + + '
'; + } + if (it.type === 'int') { + const mn = (it.min == null ? 0 : it.min), mx = (it.max == null ? 100 : it.max); + return '
' + + '
' + esc(it.value == null ? '' : it.value) + '' + + '
'; + } + if (it.type === 'enum') { + const opts = (it.options || []).map(o => '').join(''); + return '
'; + } + return '
'; + } + async function renderSettings() { + $('#content').innerHTML = '

加载设置…

'; + let d; + try { d = await api('GET', '/api/settings'); } + catch (e) { if (state.route === 'settings') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'settings') return; + let h = '
'; + h += '
' + + '' + + '
'; + const items = d.items || []; + // 按类型分组展示(bool 独立一卡,int/enum/str 各自卡片),便于查看 + const groups = [['开关/布尔', items.filter(x => x.type === 'bool')], + ['数值/滑条', items.filter(x => x.type === 'int')], + ['枚举/选择', items.filter(x => x.type === 'enum')], + ['字符串/路径', items.filter(x => x.type === 'str')]]; + groups.forEach(function (g) { + if (!g[1].length) return; + h += '

' + g[0] + '

' + g[1].map(settingControl).join('') + '
'; + }); + h += '
'; + $('#content').innerHTML = h; + } + function bindSettings() { + const c = $('#content'); + c.addEventListener('input', function (e) { + const r = e.target.closest('[data-stype="int"]'); if (!r) return; + const row = r.closest('.field-row'); if (row) row.querySelector('.muted').textContent = r.value; + updateSetting(r.dataset.skey, +r.value, 'int'); + }); + c.addEventListener('change', function (e) { + const t = e.target; + const sk = t.dataset.skey; if (!sk) return; + if (t.dataset.stype === 'bool') updateSetting(sk, t.checked, 'bool'); + else if (t.dataset.stype === 'enum') updateSetting(sk, t.value, 'enum'); + else if (t.dataset.stype === 'str') updateSetting(sk, t.value, 'str'); + }); + c.addEventListener('click', async function (e) { + if (e.target.closest('#set-restore')) { + const ok = await confirmDlg('恢复全部默认', '将把全部设置恢复为出厂默认(含主题/亮度/音量)。继续?', true); + if (!ok) return; + try { await api('POST', '/api/settings/restore'); toast('已恢复默认'); renderSettings(); } + catch (err) { toast('恢复失败:' + errText(err)); } + } else if (e.target.closest('#set-refresh')) { renderSettings(); } + }); + } + const updateSetting = debounce(async function (key, value, type) { + try { await api('PUT', '/api/settings/' + key, { value: value }); toast('已保存 ' + key); } + catch (e) { toast('保存失败:' + errText(e)); } + }, 350); + + /* ---------------- 6. 远程控制页 ---------------- */ + function renderRemote() { + const keys = [ + { code: 115, label: '音量 +', ico: '🔊' }, + { code: 114, label: '音量 −', ico: '🔉' }, + { code: 116, label: '电源(单击)', ico: '⏻' }, + { code: 164, label: '播放/暂停', ico: '⏯' }, + { code: 163, label: '下一曲', ico: '⏭' }, + { code: 165, label: '上一曲', ico: '⏮' }, + { code: 158, label: '返回', ico: '↩' }, + { code: 172, label: '主页', ico: '⌂' } + ]; + let h = '
'; + h += '

模拟按键

' + + '

行为与本地按键一致(设备端系统统一接管)。长按电源将触发电源菜单。

' + + '
' + keys.map(k => + '' + ).join('') + '
'; + h += '

电源长按(电源菜单)

' + + '
' + + '发送 code=116 · press=long · ms=3000
'; + h += '
'; + $('#content').innerHTML = h; + } + async function sendKey(code, press, ms) { + try { await api('POST', '/api/key/' + code, { press: press || 'tap', ms: ms }); toast('已发送 键 ' + code); } + catch (e) { toast('发送失败:' + errText(e)); } + } + function bindRemote() { + $('#content').addEventListener('click', function (e) { + const kb = e.target.closest('[data-code]'); if (kb) { sendKey(kb.dataset.code, 'tap'); return; } + const pl = e.target.closest('[data-powerlong]'); if (pl) { sendKey(116, 'long', 3000); return; } + }); + } + + /* ---------------- 7. 系统信息 / OTA 页 ---------------- */ + async function renderSystem() { + $('#content').innerHTML = '

加载系统信息…

'; + let d; + try { d = await api('GET', '/api/system'); } + catch (e) { if (state.route === 'system') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'system') return; + const net = d.network || {}; const wf = net.wifi || {}; const bt = net.bluetooth || {}; const ota = d.ota || {}; + let h = '
'; + h += '

系统信息

' + + row('MeowOS 版本', esc(d.version || '—')) + row('内核', esc(d.kernel || '—')) + row('架构', esc(d.arch || '—')) + + row('主机名', esc(d.hostname || '—')) + row('运行时间', fmtUptime(d.uptime_sec)) + + row('默认启动', esc((d.boot && d.boot.default) || '—')) + + '
'; + h += '

网络

' + + row('WiFi', wf.connected ? esc(wf.ssid || '已连接') + ' · ' + esc(wf.ip || '—') + (wf.rssi != null ? ' · ' + wf.rssi + 'dBm' : '') : '未连接') + + row('蓝牙', bt.available ? ((bt.powered ? '已开启' : '已关闭') + (bt.connected ? ' · 已连接' : '')) : '无蓝牙') + + row('mDNS', esc(net.hostname_local || '—')) + + '
'; + h += '

升级

' + + '

当前 ' + esc(ota.current || d.version || '—') + + (ota.available ? ' · 发现新版本 ' + esc(ota.latest || '') : ' · 已是最新') + '

' + + '
' + + '
'; + h += '

危险操作

' + + '
' + + '
' + + '

⚠️ 重启/关机/升级均为高风险操作,请先保存好正在进行的任务。

'; + h += '
'; + $('#content').innerHTML = h; + } + function row(k, v) { return '' + k + '' + v + ''; } + function bindSystem() { // init 一次性委托绑定(不随渲染累积) + $('#content').addEventListener('click', async function (e) { + if (e.target.closest('#sys-reboot')) { + if (await confirmDlg('确认重启', '系统将立即重启,请在浏览器中关闭本页。', true)) + try { await api('POST', '/api/system/reboot', {}); toast('已请求重启'); } catch (err) { toast('重启失败:' + errText(err)); } + return; + } + if (e.target.closest('#sys-shutdown')) { + if (await confirmDlg('确认关机', '系统将立即关机,请在浏览器中关闭本页。', true)) + try { await api('POST', '/api/system/shutdown', {}); toast('已请求关机'); } catch (err) { toast('关机失败:' + errText(err)); } + return; + } + if (e.target.closest('#sys-ota')) { + if (await confirmDlg('确认升级', '⚠️ 升级中断可能导致系统无法启动,请确保已连接电源。确认开始升级?', true)) + try { await api('POST', '/api/system/ota', {}); toast('已触发升级任务'); } catch (err) { toast('升级失败:' + errText(err)); } + return; + } + if (e.target.closest('#sys-ota-check')) { + // 检查升级只读,用 GET /api/system 读 ota.available/latest;勿 POST /api/system/ota(那是触发升级) + try { const r = await api('GET', '/api/system'); const o = r.ota || {}; toast(o.available ? '发现新版本 ' + o.latest : '已是最新版本'); } + catch (err) { toast('检查失败:' + errText(err)); } + return; + } + }); + } + + /* ---------------- 8. 日志页 ---------------- */ + async function renderLogs() { + $('#content').innerHTML = '

加载日志列表…

'; + let d; + try { d = await api('GET', '/api/logs'); } + catch (e) { if (state.route === 'logs') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'logs') return; + let h = '
'; + h += '

日志文件(MeowOS/logs)

'; + h += '
' + esc(d.dir || '') + '
'; + h += ''; + if (!d.files.length) h += ''; + d.files.forEach(f => { + h += '' + + ''; + }); + h += '
文件大小修改时间操作
暂无日志
' + esc(f.name) + '' + fmtBytes(f.size) + '' + fmtTime(f.mtime) + '
'; + $('#content').innerHTML = h; + } + function bindLogs() { + $('#content').addEventListener('click', async function (e) { + const t = e.target.closest('[data-log]'); if (!t) return; + const p = $('#log-view'); + try { + const d = await api('GET', '/api/logs/read?file=' + encodeURIComponent(t.dataset.log) + '&lines=400'); + p.hidden = false; p.textContent = (d.lines || []).join('\n') + (d.truncated ? '\n…(更多行被截断)' : ''); + } catch (err) { toast('查看失败:' + errText(err)); } + }); + } + + /* ---------------- 9. 通知页 ---------------- */ + async function renderNotif() { + $('#content').innerHTML = '

加载通知…

'; + let d; + try { d = await api('GET', '/api/notifications'); } + catch (e) { if (state.route === 'notif') $('#content').innerHTML = emptyState('加载失败:' + errText(e), e.code); return; } + if (state.route !== 'notif') return; + let h = '
'; + h += '
' + + '
'; + const list = d.notifications || []; + if (!list.length) h += '
暂无通知
'; + list.forEach(n => { + const urg = n.urgency >= 2 ? '高' : (n.urgency === 1 ? '中' : '低'); + h += '
' + esc((n.app_name || '?').charAt(0).toUpperCase()) + '
' + + '
' + esc(n.summary || n.app_name || '通知') + '
' + + (n.body ? '
' + esc(n.body) + '
' : '') + + '
' + esc(n.app_name || '') + '' + fmtTime(n.ts) + '' + + '紧急度 ' + urg + '' + + (n.progress != null ? '进度 ' + n.progress + '%' : '') + + (n.ongoing ? '进行中' : '') + '
' + + '
'; + }); + h += '
'; + $('#content').innerHTML = h; + } + function bindNotif() { + $('#content').addEventListener('click', async function (e) { + if (e.target.closest('#notif-refresh')) { renderNotif(); return; } + if (e.target.closest('#notif-clear')) { + if (await confirmDlg('清空通知', '将清空全部通知。确认?', true)) + try { await api('POST', '/api/notifications/dismiss_all'); toast('已清空'); renderNotif(); } + catch (err) { toast('清空失败:' + errText(err)); } + return; + } + const del = e.target.closest('[data-ndel]'); if (del) { + try { await api('DELETE', '/api/notifications/' + del.dataset.ndel); toast('已清除'); renderNotif(); } + catch (err) { toast('清除失败:' + errText(err)); } + } + }); + } + + /* ---------------- 路由 ---------------- */ + const ROUTES = { + perf: { title: '实时性能', render: renderPerf, bind: null }, + bright: { title: '亮度 · 音量', render: renderBright, bind: null }, + files: { title: '文件管理', render: renderFiles, bind: bindFiles }, + apps: { title: 'MeowAPPs 管理', render: renderApps, bind: bindApps }, + settings: { title: '设置镜像', render: renderSettings, bind: bindSettings }, + remote: { title: '远程控制', render: renderRemote, bind: bindRemote }, + system: { title: '系统信息 / 升级', render: renderSystem, bind: bindSystem }, + logs: { title: '系统日志', render: renderLogs, bind: bindLogs }, + notif: { title: '通知中心', render: renderNotif, bind: bindNotif } + }; + // 一次性绑定(各菜单 click / 侧栏);页面内交互用 #content 事件委托(已各自绑定) + function bindNav() { + $$('.nav-item').forEach(b => b.addEventListener('click', () => { location.hash = '#/' + b.dataset.route; })); + $('#menu-btn').addEventListener('click', () => $('#sidebar').classList.toggle('open')); + } + function route() { + const id = (location.hash || '#/perf').replace('#/', '').split('?')[0] || 'perf'; + const r = ROUTES[id] || ROUTES.perf; + state.route = id; + stopPerf(); + $$('.nav-item').forEach(b => b.classList.toggle('active', b.dataset.route === id)); + $('#page-title').textContent = r.title; + r.render(); + } + + /* ---------------- 启动 ---------------- */ + function init() { + bindNav(); + bindThemeUi(); + bindSlider(); + bindFiles(); bindApps(); bindSettings(); bindRemote(); bindSystem(); bindLogs(); bindNotif(); + initTheme(); + window.addEventListener('hashchange', route); + route(); + } + document.addEventListener('DOMContentLoaded', init); +})();