feat: M6.40批 — 阅读器UI改版(书架list·grid+空态骨架+界面设置+App设置页+header·footer+背景黑默认) + 系统级音量翻页映射 + P1 MD3(select面板28/对话框按钮胶囊) + MeowPanel写端点护栏 + SDK公开仓·文档同步

- reader: app.c/shelf.c/reader.c 全套UI; 新增 reader_conf(reader.conf)/settings(App设置页)/reader_ipc(音量翻页IPC响应); app.json加volume_page
- launcher: keys.c meow_volpage_*门 + util.c app_id→fd映射(HELLO记fd/断开清) + app_launcher.c 前台三处接线(进入sink/按键路径volpage_key/离开clear) → 前台声明volume_page的App音量键→MSG_KEY_PAGEUP/PAGEDOWN翻页(非阅读器前台音量照旧)
- sdk: meow_select.c 底部面板28 + meow_ui_dialog.c 对话框按钮胶囊(h/2) + test_meow_ui_select.c修采样
- meowpanel: /api/net/policy·/api/power/policy 加防自锁+policy默认关两条护栏(panel.policy_enabled默认0)
- 文档: MEOW_APP_SDK.md §12.6 + meowpanel README端点表

0warning;宿主测试全过;二进制留批量批次未入库
This commit is contained in:
AskaEth committed 2026-09-04 18:13:18 +08:00
1 parent b364b6371a
commit 2826c1e905
27 files changed
+1700 -125

No files matched your search

+33
View File
@@ -1632,6 +1632,39 @@ void meow_lazygrid_draw(meow_lazygrid *g, const meow_theme *th);
- **机制层新增**:`meow_ui_input.c` 主循环新增 `meow_anim_tick()` 调用(无动画零开销);`meow_ui_view.c` 新增布局登记表(未设布局的 view 行为不变);`meow_ui_widget.c` 新增 `meow_widget_set_rect`(只设字段不标脏,既有 `meow_layout_arrange` 不受影响)。
- **同步重编注意**:`meow_ui.a` 内容变化(新增 box/anim/scroll 三个 `.o`,`meow_ui_widget.o`/`meow_ui_view.o`/`meow_ui_input.o` 重编)→ 链 `meow_ui.a` 的 App 需**重链**(`build_sdk.sh` 一键重编 + 各 App `build_*.sh` 重链)。既有 App **不改源码**即可零回归。
### 12.6 SDK 默认列表/容器控件默认 MD3(`meow_list_set_container`)+ 全局 App 内底部上滑回桌面(M6.37–M6.39 批)
> **性质**:M6.37–M6.39 批,两条默认行为定案。
> ① **SDK 默认列表/容器控件默认 MD3**——`meow_list_create` / `meow_lazycolumn_create` / `meow_lazyrow_create`(皆 `W_LIST`)与
> `meow_listview` 容器控件**默认即容器模式**:每项套 `surfaceContainer` 圆角卡 + 项间空隙 + 去分隔线 + 卡内 tonal 选中/按压,
> 用默认 renderer 的 App **开箱即 MD3**(此前默认扁平)。需扁平/满幅分隔线的宿主可显式 `meow_list_set_container(w,0)` **回退**。
> ② **全局 App 内「底部上滑回桌面」为系统保留手势**——任意 App 内屏底边缘上滑即回桌面(后台保活、进程保留转后台),
> App **勿在 `on_swipe` 拦截上滑**,否则该手势不生效。
```c
/* MD3 容器模式(W_LIST 共用,§5.5/§5.8.6):0=扁平满宽项+分隔线(回退);1=每项外套 surfaceContainer
* 圆角卡+项间空隙(去分隔线),选中=primaryContainer/按压=primary α26(卡内 tonal)。
* 覆盖 meow_list_create / meow_lazycolumn_create / meow_lazyrow_create。返回 0=成功;无效=负值。 */
int meow_list_set_container(meow_widget *w, int on);
/* 底部热区上滑 → 回桌面(home)判定(镜像 launcher np_gesture.h 同口径):屏底热区(HOME_EDGE_H)起滑 +
* 上移 ≥HOME_SWIPE + 竖向主导(|dx|<|dy|)+ 避开左缘返回列(down_x≥EDGE_W)。1=命中(系统保留手势);0=否。
* 供宿主单测直调(判据与触摸状态机 touch_release 共用)。 */
int meow_ui_input_is_home_swipe(int down_x, int down_y, int dx, int dy, int fb_h);
```
- **MD3 默认**:两者 `container` 字段默认 1;`meow_list_set_container(w,0)` 回退至「满幅扁平行 + 分隔线」旧观感(宿主测试
`test_meow_ui_widgets`/`test_meow_ui_listvirt` 用 set_container(0) 锁扁平基线)。卡内未选中行以 `surfaceContainer` 呈现、
选中 `primaryContainer`、按压 `primary α26`;容器模式下分隔线去除,回退时以 `onSurfaceVariant` 分隔线呈现。
- **业务上滑不冲突**:`meow_ui_input_is_home_swipe` 只认「**屏底热区 + 竖向主导上滑**」;**中部上滑/滚动
(LazyColumn/LazyGrid/页面滚动)不受影响**,仍交 App 处理;底部热区的横向滑仍走左缘返回/横向逻辑。
- **App 勿拦截**:命中后 SDK 作为**系统保留手势**优先于 App `on_swipe`/视图树(否则已注册 `on_swipe` 的 App 会接管,
导致 App 内上滑不回桌面),路由 `meow_ui_nav_request_exit()`(MSG_NAV EXIT → launcher 批①后台保活,进程保留转后台、
present 自抑);**不**调 `meow_ui_request_exit()`(那会 `exit(0)` 断送保活)。IME 软键盘覆盖底部热区时手势让位 IME
(防输入时误退)。故 App 无需也不应在 `on_swipe` 处理「上滑回桌面」——交给 SDK 兜底。
- **系统边界**:与「顶部下拉通知中心」(§6)同理为系统保留手势;流媒体客户端/媒体 App 的列表、播放页照常用**中部**
手势(滚动/翻页),不外溢到屏底热区拦截回桌面。
---
## 13. 字符串资源 / i18n(`meow_str_*`,2026-09-02 追加)
+26
View File
@@ -1929,6 +1929,8 @@ static int launch_app_chain(struct app *a, const char *chain)
int nav_clean_exit = 0; /* M3:MSG_NAV{EXIT} 主动退出 → 不按崩溃上报 */
int to_background = 0; /* M4:nav-exit 转后台保活(进程保留,跳过退出尾) */
int app_prev_vold = 0;
int app_prev_volu = 0; /* E:VOL_UP 上一边沿(翻页门下边沿判定) */
int fg_volpage = 0; /* E:前台 App 是否声明 volume_page(会话期恒定) */
int app_seq_hits = 0;
unsigned long long app_seq_last = 0;
struct vol_snap app_vol0 = { -1, -1 };
@@ -1968,6 +1970,13 @@ static int launch_app_chain(struct app *a, const char *chain)
* 清残留 pending(防上一会话未消费的 OPEN 被本会话误展开)。 */
g_app_session_active = 1;
meow_notif_open_clear();
/* E:系统级音量翻页——前台 App 声明 volume_page 时武装 sink(fd 就绪即生效;断线重连由
* 本循环每圈重试 meow_volpage_sink 幂等更新,keys.c 门只消费 VOL 按下且已武装)。 */
fg_volpage = meow_volpage_app_allows(a->id) ? 1 : 0;
if (fg_volpage) {
int vfd = meow_ipc_app_fd(a->id);
if (vfd >= 0) meow_volpage_sink(a->id, vfd);
}
for (;;) {
int r = waitpid(pid, &status, WNOHANG);
if (r == pid) { ams_status_valid = 1; break; }
@@ -2057,6 +2066,21 @@ static int launch_app_chain(struct app *a, const char *chain)
for (int w = 0; w < KEYBUF_WORDS; w++) {
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEDOWN % 32))) vold = 1;
}
/* E:系统级音量翻页——前台 App 声明 volume_page 时,VOL_UP/VOL_DOWN 按下边沿 →
* 系统翻页信号(keys.c 门,已武装才消费下发;未武装返回 0 放行,音量/物理键语义全保留)。
* 每圈幂等重试武装(reader 持久连接 HELLO 后 fd 就绪;断线重连换 fd 自动更新)。 */
if (fg_volpage) {
int vfd = meow_ipc_app_fd(a->id);
if (vfd >= 0) meow_volpage_sink(a->id, vfd);
}
{
int volu = 0;
for (int w = 0; w < KEYBUF_WORDS; w++)
if (kp_keys.cur[w] & (1UL << (KEY_VOLUMEUP % 32))) volu = 1;
if (volu && !app_prev_volu) (void)meow_volpage_key(KEY_VOLUMEUP, 1);
if (vold && !app_prev_vold) (void)meow_volpage_key(KEY_VOLUMEDOWN, 1);
app_prev_volu = volu;
}
/* M2.17 自动息屏(app 会话):任意物理键边沿重置计时;空闲>timeout 且非 keep_awake → 息屏;
* 息屏后再按键 → 唤醒(fb_pan_buf0_force 兜底 LCDC 落 buffer0,内容随应用输入重绘)。
* M6.5 §310 问题1:watchdog 电源键手动息屏(背光 0、不置 meow_auto_slept)→ 唤醒时按
@@ -2223,6 +2247,7 @@ static int launch_app_chain(struct app *a, const char *chain)
if (to_background) {
g_app_session_active = 0; /* 前台会话结束(ipc_dispatch 不再受理 NOTIF_OPEN) */
meow_notif_open_clear();
meow_volpage_sink_clear(); /* E:离开前台撤翻页 sink(音量键交还系统) */
if (wpid > 0) {
/* 看门狗随前台会话结束回收(后台无手势监听;有界等防挂死) */
kill(wpid, SIGTERM);
@@ -2251,6 +2276,7 @@ static int launch_app_chain(struct app *a, const char *chain)
/* §10.5:App 前台会话结束,清会话标记(ipc_dispatch 不再受理 MSG_NOTIF_OPEN) */
g_app_session_active = 0;
meow_notif_open_clear();
meow_volpage_sink_clear(); /* E:离开前台撤翻页 sink(音量键交还系统) */
/* M2.17 兜底:App 会话结束(waitpid 回收/崩溃/被杀,回主界面)清 fg_act ——
* App 在 keep_awake activity(如 ncm 播放页)被 OOM/崩溃杀死且未弹回 home 时,g_fg_act 会
* 滞留 "<act>",使主页 fg_keep_awake()==1 不再自动息屏。此处兜底清空(App 已退出/死亡,
+89
View File
@@ -212,3 +212,92 @@ void load_config(void)
menu_bg, seq_len, seq_codes[0], seq_gap_ms);
}
/* =========================================================================
* 系统级音量键翻页门(READER_UI_DESIGN §E;用户 2026-09-04 拍板)。
*
* 语义:音量键是系统资源(默认调音量)。本门在「当前台 App 为其 app.json 声明
* volume_page 的应用(MVP:os.meow.reader)」时,把 VOL_UP/VOL_DOWN 转换成系统翻页
* 信号(MSG_KEY_PAGEUP/PAGEDOWN,meow_ipc.h)经 IPC 下发给该前台 App;其余情况一概
* 放行(return 0),音量基本行为/插孔消抖/音量反转等绝不破坏。
*
* 职责分离(关键,且最小侵入):
* - 本模块只提供「判定 + 门」:meow_volpage_app_allows() 读 app.json 声明;
* meow_volpage_key() 在「已武装 sink 且按下 VOL」时消费并发下发;
* - 武装(sink 注册)= 前台应用进入体积页声明 app 时调用 meow_volpage_sink()
* (应用存在连接后其 fd 生效);离开/退出时 meow_volpage_sink_clear()。
* 该接线落在「前台会话的按键路径」(app_launcher.c / meow_launcherd.c 主循环),
* 属主会话统一收编范围,此处不越界改动。
*
* 声明字段(app.json):`"volume_page": true`(或 1)。未来其它 App 可声明复用。
* ========================================================================= */
/* 前台翻页 sink(本模块私有;单写者=前台会话上下文,读=按键门)。 */
static char g_volpage_app[MEOW_APP_ID_MAX] = "";
static int g_volpage_fd = -1;
void meow_volpage_sink_clear(void); /* 前向声明(sink 内调用) */
/* 读 <MEOWAPPS>/<app_id>/app.json 是否声明 volume_page=true|1。1=声明;0=否/读不到。 */
int meow_volpage_app_allows(const char *app_id)
{
if (!app_id || !app_id[0]) return 0;
char path[320];
snprintf(path, sizeof path, "%s/%s/app.json", MEOWAPPS, app_id);
FILE *f = fopen(path, "r");
if (!f) return 0;
/* 轻量扫描:找 "volume_page" → 跳过空白 → true/1。app.json 为紧凑 JSON。 */
char buf[1024];
buf[0] = 0;
size_t rd = fread(buf, 1, sizeof buf - 1, f);
buf[rd] = 0;
fclose(f);
const char *p = buf;
while ((p = strstr(p, "volume_page")) != NULL) {
p += strlen("volume_page");
while (*p == ' ' || *p == '\t' || *p == ':' || *p == '"') p++;
if (strncmp(p, "true", 4) == 0) return 1;
if (*p == '1' && (p[1] < '0' || p[1] > '9')) return 1;
/* 未命中则继续找(防字段重复);跳过本段 */
while (*p && *p != ',' && *p != '}') p++;
}
return 0;
}
/* 武装/清除 sink。app_id 非空且 fd>=0 → 记录;否则清除。幂等(同 app_id+fd 不重复记日志,
* 供前台会话循环每圈安全重调,也兼容断线重连换 fd)。 */
void meow_volpage_sink(const char *app_id, int fd)
{
if (!app_id || !app_id[0] || fd < 0) { meow_volpage_sink_clear(); return; }
if (g_volpage_fd == fd && !strcmp(g_volpage_app, app_id)) return; /* 同 app_id+fd:幂等 */
snprintf(g_volpage_app, sizeof g_volpage_app, "%s", app_id);
g_volpage_fd = fd;
logmsg("volpage: sink armed app=%s fd=%d", g_volpage_app, g_volpage_fd);
}
void meow_volpage_sink_clear(void)
{
if (g_volpage_fd >= 0)
logmsg("volpage: sink cleared app=%s", g_volpage_app[0] ? g_volpage_app : "?");
g_volpage_app[0] = 0;
g_volpage_fd = -1;
}
int meow_volpage_armed(void) { return g_volpage_fd >= 0; }
/* 按键门:按下 VOL_UP/DOWN 且 sink 已武装 → 下发翻页信号并消费(返回 1)。
* 未武装 / 非 VOL / 抬起 → 返回 0(放行给音量路径)。 */
int meow_volpage_key(int code, int is_down)
{
if (!is_down) return 0; /* 只响应按下边沿 */
if (g_volpage_fd < 0) return 0; /* 未武装:音量照旧 */
if (code == KEY_VOLUMEUP) {
(void)meow_ipc_send_pub(g_volpage_fd, MSG_KEY_PAGEUP, 1, NULL, 0);
return 1;
}
if (code == KEY_VOLUMEDOWN) {
(void)meow_ipc_send_pub(g_volpage_fd, MSG_KEY_PAGEDOWN, 1, NULL, 0);
return 1;
}
return 0;
}
+8
View File
@@ -82,6 +82,14 @@ enum {
MSG_META = 0x0072, /* ↑ 媒体会话上报元数据/进度/状态(阶段 D 附录 B) */
MSG_PLAYCTL = 0x0073, /* ↓ 播放控制下发/↑ 控制器转发到最近活跃媒体会话 */
MSG_META_LYRIC = 0x0074, /* ↑ 媒体会话上报当前曲歌词 lrc/tlrc(阶段 D · 批3,分片) */
/* ---- 系统翻页信号(READER_UI_DESIGN §E,用户 2026-09-04 拍板):0x0080-0x0081 ----
* 语义:launcher 按键层(meow_launcherd/keys.c)在「当前台 App 为其 app.json 声明
* volume_page 的前台应用」时,把物理 VOL_UP/VOL_DOWN 转换成系统翻页信号下发(否则照旧
* 调音量——音量基本行为/插孔消抖/音量反转绝不破坏)。App 侧(如 reader_app/app/reader_ipc.c)
* 持持久连接收本消息 → 上一页/下一页。方向由消息号本身决定(UP=上一页,DOWN=下一页);
* 无负载(len=0)。属系统级能力,未来其它 App 可声明复用。 */
MSG_KEY_PAGEUP = 0x0080, /* ↓ 系统级:上一页(VOL_UP 语义,仅前台声明 app 收) */
MSG_KEY_PAGEDOWN = 0x0081, /* ↓ 系统级:下一页(VOL_DOWN 语义,仅前台声明 app 收) */
/* ---- amsd 控制(AMSD_DESIGN §4.2):amsd→launcher,申请拉起/终止某 App ----
* 语义:amsd 为「启动/终止」决策者 → launcher(执行器)执行。payload 见下。 */
MSG_AMS_START = 0x00A0, /* → 请求 launcher 拉起某 App(pkg + 可选 activity) */
@@ -756,6 +756,15 @@ int meow_ipc_conn_alive(int fd);
/* M4:ipc_send 公开包装(meow_bgplay send_frame 回调用;同 meow_media 的注入口径)。 */
size_t meow_ipc_send_pub(int fd, uint16_t type, uint32_t req_id,
const void *payload, uint32_t len);
/* ---- E:系统级音量翻页(READER_UI_DESIGN §E,用户 2026-09-04 拍板)----
* 前台 App 声明 app.json `volume_page` 时,launcher 把物理 VOL_UP/DOWN 转成系统翻页信号
* (MSG_KEY_PAGEUP/PAGEDOWN,meow_ipc.h)经 IPC 下发给该 App;否则照旧调音量。 */
int meow_volpage_app_allows(const char *app_id); /* keys.c:读 app.json 声明 */
void meow_volpage_sink(const char *app_id, int fd); /* keys.c:武装前台翻页 sink */
void meow_volpage_sink_clear(void); /* keys.c:撤武装 */
int meow_volpage_armed(void); /* keys.c:查询 */
int meow_volpage_key(int code, int is_down); /* keys.c:门(1=已消费/已下发) */
int meow_ipc_app_fd(const char *app_id); /* util.c:前台 App→持久 IPC fd(-1=无) */
/* §10.5:读并清除待处理的 MSG_NOTIF_OPEN(App 前台顶部下滑请求展开通知中心叠加会话)。
* 调用方=launch_app waitpid 循环;返回 1 并填 *out(待处理),0=无。 */
int meow_notif_open_take(m_notif_open_t *out);
+49
View File
@@ -907,6 +907,52 @@ int meow_ipc_conn_alive(int fd)
return 0;
}
/* ---- E:系统级音量翻页——app_id→持久 IPC fd 最近活跃映射(util.c 单写者 = ipc_dispatch)----
* App 的「持久连接」(如 reader_app/app/reader_ipc.c 后台线程)连上后发 MSG_HELLO,本映射在该
* HELLO 被 ipc_dispatch 处理时记录 app_id→m->fd;连接关闭(meow_ipc_process 剔除)时清对应项。
* 只记录「declares volume_page」的 App(meow_volpage_app_allows),保持映射极小且无歧义。
* meow_ipc_app_fd() 供 app_launcher.c 前台会话取持久 fd 武装翻页 sink。 */
#define VOLPAGE_FD_MAX 8
static struct { char app_id[MEOW_APP_ID_MAX]; int fd; } g_volpage_fd[VOLPAGE_FD_MAX];
static int g_volpage_fd_n;
static void volpage_fd_set(const char *app_id, int fd)
{
if (!app_id || !app_id[0] || fd < 0) return;
for (int i = 0; i < g_volpage_fd_n; i++) {
if (!strcmp(g_volpage_fd[i].app_id, app_id)) { g_volpage_fd[i].fd = fd; return; }
}
if (g_volpage_fd_n < VOLPAGE_FD_MAX) {
snprintf(g_volpage_fd[g_volpage_fd_n].app_id, sizeof g_volpage_fd[0].app_id, "%s", app_id);
g_volpage_fd[g_volpage_fd_n].fd = fd;
g_volpage_fd_n++;
}
}
static void volpage_fd_clear_fd(int fd)
{
for (int i = 0; i < g_volpage_fd_n; i++) {
if (g_volpage_fd[i].fd == fd) {
for (int j = i; j < g_volpage_fd_n - 1; j++) g_volpage_fd[j] = g_volpage_fd[j + 1];
g_volpage_fd_n--;
return;
}
}
}
int meow_ipc_app_fd(const char *app_id)
{
if (!app_id) return -1;
for (int i = 0; i < g_volpage_fd_n; i++) {
if (!strcmp(g_volpage_fd[i].app_id, app_id)) {
if (meow_ipc_conn_alive(g_volpage_fd[i].fd)) return g_volpage_fd[i].fd;
volpage_fd_clear_fd(g_volpage_fd[i].fd); /* fd 已死:清残留 */
return -1;
}
}
return -1;
}
/* §10.5:待处理的 App 前台「展开通知中心」请求(ipc_dispatch 单写者置位;launch_app waitpid
* 循环 meow_notif_open_take 读取并清除)。仅当 g_app_session_active(App 前台会话)期间才置位,
* 否则回 ERR(无 App 前台,不展开)。 */
@@ -1099,6 +1145,7 @@ static void *ipc_thread(void *arg)
if (ipc_conn_read(cc) < 0) {
logmsg("ipc: conn %d closed", cc->fd);
close(cc->fd);
volpage_fd_clear_fd(cc->fd); /* E:清已断连接的翻页 fd 映射 */
for (int j = i; j < nfds - 1; j++) {
fds[j] = fds[j + 1];
ipc_conns[j - 1] = ipc_conns[j];
@@ -1209,6 +1256,8 @@ static void ipc_dispatch(struct meow_ipc_msg *m)
memcpy(&h, m->payload, m->plen < (uint16_t)sizeof h ? m->plen : (uint16_t)sizeof h);
h.app_id[MEOW_APP_ID_MAX - 1] = 0;
meow_acct_hello(h.app_id, h.version, h.flags);
/* E:记录声明 volume_page 的 App 的持久连接 fd(HELLO=App 侧持久 IPC 连接;仅读卡 app.json 一次)。 */
if (meow_volpage_app_allows(h.app_id)) volpage_fd_set(h.app_id, m->fd);
m_ack_t a;
a.code = MEOW_OK;
a.status = (uint32_t)g_naccts;
+81 -10
View File
@@ -25,6 +25,10 @@ cd /home/Aska/Project/R3ProII/patch_v2/meowpanel
./build_meowpanel.sh host # 宿主编译 → meowpanel_host(本机 ld,仅供调试)
```
- **后端源码已按模块拆分**(`src/*.c` + 共享内部头 `src/mp.h`):`mp_server`(HTTP/路由/main) + `mp_util`/`mp_json` + `mp_store`(设置/主题) + `mp_ipc`(IPC 帧/单一收发) + 各 api_* 域 `mp_perf`/`mp_power`/`mp_audio`/`mp_net`/`mp_ams`/`mp_svcd`/`mp_files`/`mp_fonts`/`mp_apps`/`mp_logs`/`mp_notif`/`mp_auth`/`mp_key`/`mp_screen`。
`mp.h` 承载全部共享类型/常量/外部全局 + 跨模块函数原型;每个 `.c` 以 `#include "mp.h"` 起步。
拆分保持后端接口/行为**完全不变**(宿主 `-Wall -Wextra` 0 warning;重编产物与拆分前**逐字节一致**——同一 mock 环境全端点 diff 无差异)。
原单体 `meowpanel.c` 保留在根目录作参考,**不在构建源列表**(构建以 `src/*.c` 为准)。
- 两分支均以 `-Os -Wall -Wextra` 复核,**0 warning**(`-Werror` 亦通过)。
- 产出(本次构建实测):
- `meowpanel`:mipsel 静态,`ELF 32-bit LSB executable, MIPS, MIPS32 rel2, statically linked, stripped`,
@@ -120,8 +124,19 @@ http://<host>.local:8080/
| POST | `/api/audio/volume` | 写音量(**写级**,PIN 已护):`MSG_AUDIO_VOLUME`,**clamp 到系统 vol_max**(读 settings.conf `audio.volume_max`,缺省 100;设备当前 50);audiod 仅 clamp 0-100 无 vol_max 防护,面板是把守方(防炸耳红线) | ✅(clamp 后写;audiod 不在→503) |
| GET | `/api/power` | 功耗管理:meow_powermgr IPC `MSG_PM_QUERY→RESULT`(battery/estimate/breakdown,confidence 原样透传) | ✅(powermgr 不在→503 `POWER_UNAVAILABLE`) |
| GET | `/api/ams` | App 运行表:`am list`(pkg/name/state/pid/launches/crashes/last_kind/last_code) | ✅(amsd 不在→`available:false`) |
| GET | `/api/ams/bg` | **后台活动列表**(M6.38,批④):`am list` 仅取 `state=="bg"`,逐进程 `/proc` 采样 `VmRSS`/`cpu_pct`(首采样/不可得→`null` 前端显 `—`,绝不编造);同源于 amsd FG/BG 元数据,杀/清走既有 `/api/ams/:pkg/kill` | ✅(只读,需 PIN 会话;amsd 不在→`available:false`) |
| GET | `/api/svcd` | 服务表:`meow_svcd status all`(name/state/pid/restart/backoff_ms/trigger/src/exit/exec) | ✅(svcd 不在→`available:false`) |
| GET | `/api/overview` | **首页聚合**(单线程后端只暴露聚合):后端顺序 fan-out perf/power/audio/net/ams/svcd 六源合成一份 JSON;任一源失败→该子块 `available:false`,绝不假数据 | ✅(**读级,需 PIN 会话**) |
| GET | `/api/overview` | **首页聚合**(单线程后端只暴露聚合):后端顺序 fan-out perf/power+events/audio/net+union dns/ams+fg/svcd 六源合成一份 JSON;任一源失败→该子块 `available:false`,绝不假数据 | ✅(**读级,需 PIN 会话**) |
| GET | `/api/net/ifaces` | 接口列表详情(kind/ip/gw/rx/tx/rssi/rfkill/dns;netmgrd 0xE2+0xE0 合成) | ✅(只读,需 PIN 会话) |
| GET | `/api/net/policy` | 管控策略只读(0xEA SHOW;`enforce=dns_only` 如实)+ **`policy_write_enabled`**(guard③ 门禁状态)| ✅(只读,需 PIN 会话;上游坏字节→`available:false`) |
| POST | `/api/net/policy` | 写管控策略(`set_net`/`set_domain`/`del`):**guard① 防自锁**(`SELF_LOCK` 422)+ **guard③ 默认关**(`POLICY_DISABLED` 403)+ 二次确认 | ✅(**写级**,PIN 会话;自锁→422,门禁→403,IPC 权限→403 FORBIDDEN) |
| GET | `/api/power/policy` | 电源策略快照(profile 四档/阈值/`enacted`)+ **`policy_write_enabled`**(guard③;注入后 jv_parse 自校验) | ✅(只读,需 PIN 会话;坏字节→503) |
| POST | `/api/power/policy` | 写电源策略(**只写 settings.conf `power.*` 键**):**guard③ 默认关**(`POLICY_DISABLED` 403)+ 二次确认 | ✅(**写级**,PIN 会话;非法值→422,权限→403) |
| GET | `/api/power/events` | 电源异常事件(充放电/低电/亮灭屏/背光跳变;overview 摘要 top2 的来源) | ✅(只读,需 PIN 会话;powermgr 不在→503) |
| GET | `/api/power/history` | 功耗采样历史(30s 环形 4h,不落盘) | ✅(只读,需 PIN 会话;powermgr 不在→503) |
| GET | `/api/ams/:pkg` | 单 App incident 历史(`am history`,崩溃根因 detail;坏 detail→降级 `available:false`) | ✅(只读,需 PIN 会话) |
| POST | `/api/ams/:pkg/start` | 启动 App(`am start <pkg>`;**写级**,PIN 会话) | ✅(包名非法→400,launcher 离线→503) |
| POST | `/api/ams/:pkg/kill` | 结束 App(`am kill <pkg>`;**写级**,PIN 会话,前台前端二次确认) | ✅(包名非法→400,not registered→404,busy→409) |
| GET | `/api/apps` | 扫描 `MeowAPPs/` 读 `app.json` | ✅ |
| GET | `/api/apps/:pkg` | 应用详情 | ✅ |
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
@@ -396,6 +411,33 @@ http://<host>.local:8080/
同款统计);DNS 不可用则省略 `dns` 键仅保留网络状态,**绝不假数据**。首页 netCard 改读 `net.dns`,去掉单独 `/api/net/dns`
(首页严格单请求)。
### 5.16 批 P1 写端点(power/net/ams 写)+ 三条护栏(MEOWPANEL_ENHANCE_PLAN P1)
> 三个写端点全部落在 `POST` 分支 + `require_auth` 之后(无一漏到无鉴权路径);响应**如实回错误码**,绝不掩盖。
- **三条护栏**:
1. **guard① 防自锁**(仅 `/api/net/policy`):对系统关键网络/服务组件禁设「阻断」(`set_net`+`arg=block`、
或任何 `set_domain` 域名限制)。命中即 `422 {"code":"SELF_LOCK"}`。自锁实体(取 `svc:|app:|proc:` 后裸名),
`net_locked_entity()` 精确比对 `dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init`(netmgrd 加载时也剔除,面板侧抢先硬拒)。
「放行 / 删除规则」不阻断(恢复连通)仍放行。
2. **guard② PIN 会话鉴权**:复用全局 `require_auth`(§5.9);无会话 → `401 UNAUTHORIZED`。
3. **guard③ 策略写默认关**:`panel.policy_enabled`(新设置项,`settings.conf`「面板」组,默认 `0`)。
`0`(缺省)时 `POST /api/net/policy`、`POST /api/power/policy` 在解析 body **前**即拒 `403 POLICY_DISABLED`
(只读 GET 不受此限);置 `1`(设置页 / 电源·网络策略卡的「开启策略写」)放行。与 powermgr `enacted` **独立**。
- **`POST /api/net/policy`**:`{op:"set_net|set_domain|del", entity:"app:…|svc:…|proc:…", arg:"…"}`(`del` 免 `arg`)。
成功 `{"accepted":true,"message":…}`;权限→`403 FORBIDDEN`、非法→`422 UNSUPPORTED`、自锁→`422 SELF_LOCK`、门禁→`403 POLICY_DISABLED`、
IPC 不可达→`503 NET_UNAVAILABLE`。`enforce=dns_only` 如实显示「仅 DNS 层拒绝」,不谎称断连。
- **`GET /api/net/policy`**:`net_policy_parse` 输出 `{"available":true,"policy_write_enabled":<bool>,"enforce","log_level","rules":[…]}`;
坏字节(实体名/域名)降级 `{"available":false}`。前端据此渲染「默认关」横幅 + 「开启策略写」(`enablePolicyWrite`)。
- **`POST /api/power/policy`**:`{item:"profile|low-pct|crit-pct|bri-cap|scr-cap", value:"…"}`,**只写 settings.conf `power.*` 键**;
成功后透传 powermgr 应答。非法值→`422`(服务端 `ok:false` 按 `why` 映射)、权限→`403`、门禁→`403 POLICY_DISABLED`、
IPC 不可达→`503 POWER_UNAVAILABLE`。
- **`GET /api/power/policy`**:透传 powermgr 策略快照(含 `enacted`)后经 `power_policy_append_flag()` **追加顶层 `policy_write_enabled`**
(剥尾拼键 + jv_parse 自校验,绝不发非法 JSON);非对象答原样透传。
- **`POST /api/ams/:pkg/{start,kill}`**:`am start/kill <pkg>`;包名经 `ams_pkg_ok`(仅 ASCII 可见、禁 `/ : 空白`)。成功
`{"accepted":true,"op","pkg","message"}`;`not registered`→404、`busy`/`not running`→409、`launcher offline`→503、其余→500。
**kill 前台 App 前端二次确认**。
---
## 6. 与协议的出入(「实现为准 + 原因」,供回改 MEOWPANEL_PROTOCOL.md)
@@ -486,17 +528,46 @@ http://<host>.local:8080/
```
patch_v2/meowpanel/
├── meowpanel.c # 后端源码(约 5590 行,-Wall -Wextra 0 warning;含可选鉴权/内联 SHA-256/实时画面+增量帧/触摸注入/通知 IPC 实时化/字体库 tar.gz 导入导出/自然排序/audiod 状态快照 + 批2 崩溃/日志中心 + 音频写 + overview DNS 聚合)
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm,-Wall -Wextra 0 warning)
├── meowpanel.c # 拆分前单体源码(约 6372 行,保留作参考,**不在构建源列表**;构建以 src/*.c 为准)
├── src/ # 后端模块化源码(模块化拆分,接口/行为不变,-Wall -Wextra 0 warning)
│ ├── mp.h # 共享内部头:全部共享类型/常量/外部全局 + 跨模块函数原型(含鉴权/实时画面等)
│ ├── mp_server.c # HTTP 服务器/路由分发 + 静态托管 + main(单线程 + poll 串行)
│ ├── mp_util.c # sbuf/UTF-8 消毒/内存搜索/命令管道/路径与 token 工具
│ ├── mp_json.c # 最简 JSON 解析/序列化(jv_t 树)
│ ├── mp_store.c # 设置镜像(settings.conf)+ 主题 + /api/settings*,/api/theme* + g_sets 表
│ ├── mp_ipc.c # IPC 帧读写 + meow_svc_perf/power/netmgr 客户端(单点收发)+ socket 路径
│ ├── mp_perf.c # /api/perf,/api/system,action,pin-unlock(perf 聚合)
│ ├── mp_power.c # meow_powermgr IPC(/api/power*,policy,window,events)+ ipc_pm_send
│ ├── mp_audio.c # meow_audiod IPC(状态快照 + 写命令 route/volume)
│ ├── mp_net.c # meow_netmgrd IPC(状态/连接/DNS/ifaces/policy,期2/期3)
│ ├── mp_ams.c # App 运行表 + amd 历史 + start/kill 控制
│ ├── mp_svcd.c # svcd 服务表 + 首页聚合 /api/overview
│ ├── mp_files.c # 路径安全/白名单 + /api/files* + multipart 上传 + 卡根路径
│ ├── mp_fonts.c # 字体库 tar.gz 导入/导出 + 列表(busybox gzip|tar 管道)
│ ├── mp_apps.c # MeowAPPs 扫描 + nyai 安装/导出/移除
│ ├── mp_logs.c # /api/logs 列表 + 读 + 崩溃汇总
│ ├── mp_notif.c # 通知(优先 launcher IPC 活跃集,回退 TF 归档)
│ ├── mp_auth.c # 访问级 PIN 鉴权(panel.pin)+ 内联 SHA-256 + 内存会话
│ ├── mp_key.c # 按键注入(写 /dev/input/event* EV_KEY,物理键红线内)
│ └── mp_screen.c # 实时画面(fb0→JPEG,增量帧)+ 触摸注入 + delta 缓存
├── mp_jpeg.c # 最小 baseline JPEG 编码器(顺序 DCT + 4:2:0 + 标准 Huffman,整数定点,无 libm)
├── mp_jpeg.h # mp_jpeg.c 接口(RGB565 → 内存 JPEG)
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;meowpanel.c + mp_jpeg.c)
├── meowpanel # 交叉产物(mipsel 静态,约 218 KB,md5 015f9cf1…)
├── build_meowpanel.sh # 交叉编译 + host 分支(-Wall -Wextra 0 warning;src/*.c + mp_jpeg.c + -I.)
├── meowpanel # 交叉产物(mipsel 静态,本次 md5 fead4852…)
├── meowpanel_host # 宿主编译(本机 ld,调试用)
├── services.d/
│ └── meowpanel.conf # 部署到 TF 卡 MeowOS/services.d/meowpanel.conf
├── web/ # 前端(已存在;部署时拷到后端同目录)
│ ├── index.html # 含登录页 overlay + 设置密码对话框 + 音频/字体导航
│ ├── meowpanel.css # 含登录页样式
│ └── meowpanel.js # API 客户端带 token/401 登录、设置页鉴权卡片、音频页(5s轮询)/字体页
└── README.md # 本文件
└── web/ # 前端(模块化拆分;部署时拷到后端同目录)
├── index.html # 含登录页 overlay + 设置密码对话框 + 音频/字体导航;脚本按序加载
├── meowpanel.css # 含登录页样式 + 「屏幕·遥控」合并页样式(screen-remote/remote-btn)
├── mp_shared.js # 共享框架:全局状态 + API 客户端(token/401) + 公共工具 + 主题/弹窗/登录/滑条(window.MP)
├── mp_pages.js # 各页渲染/绑定:home/perf/power/audio/net/files/apps/fonts/settings/system/logs/notif/proc + 合并的屏幕·遥控
└── meowpanel.js # 前端入口:ROUTES/路由/启动(读取 window.MP 装配)
```
**前端拆分说明**:`meowpanel.js`(单体 IIFE)按「公共 util+api(client) / 各页渲染 / 应用入口」拆为
`mp_shared.js` + `mp_pages.js` + `meowpanel.js` 三文件(经典脚本按序加载,函数体保持原样,仅顶部从
`window.MP` 解构共享框架,**行为不变**)。
「屏幕」+「控制」两页**合并为一页**(route `screen`,导航「屏幕·遥控」):内容区左侧=屏幕实时画面
(复用 /api/screen.jpeg + 触摸注入 + fps 滑条 + 增量省流开关),右侧=MD3 矢量图标遥控板(音量± / 亮度± /
指定亮度滑条 / 播放·暂停 / 下一曲 / 上一曲 / 电源单击 / 电源长按),**全矢量内联 SVG,无 emoji**。
`返回`/`主页` 属物理键红线(后端 /api/key 禁注入 158/172),未提供注入按钮(遥控板附说明)。
+3
View File
@@ -270,6 +270,7 @@ int ipc_pm_send(uint16_t req_type, const void *pl, uint32_t plen, uint16_t *rtyp
int api_power_policy_get(int sock);
int api_power_policy_set(int sock, const char *body, size_t body_len);
int api_power_window(int sock, uint16_t req_type, uint16_t want_type, const char *unavail);
int power_policy_append_flag(sbuf_t *out, const char *obj); /* 拼 guard③ policy_write_enabled 顶层键(自校验) */
int api_power_history(int sock);
int api_power_events(int sock);
const char *ams_bin(void);
@@ -287,6 +288,8 @@ int api_net_ifaces(int sock);
void net_policy_parse(const char *txt, sbuf_t *s);
int api_net_policy_get(int sock);
int api_net_policy_set(int sock, const char *body, size_t body_len);
int net_locked_entity(const char *entity); /* 防自锁:系统关键网络/服务组件(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init) */
int panel_policy_write_enabled(void); /* policy 默认关 gate:settings.conf `panel.policy_enabled`,缺省 0=关(只读透传) */
void panel_strip_ansi(char *s);
int txt_tokens(char *buf, char **toks, int maxtoks);
void join_tokens(char **toks, int from, int to, char *out, size_t cap);
+31 -1
View File
@@ -285,11 +285,30 @@ int api_net_ifaces(int sock)
return rc;
}
/* 防自锁实体:对本机关键网络/服务组件禁设「阻断」(net=block / set_domain 域名限制),
* 否则会把能连上设备/面板/系统核心的网络能力一并锁死(SSH / WiFi / DHCP / DNS / 面板 / init)。
* netmgrd 加载时也会剔除自锁项,面板侧抢先硬拒(guard①),保证 0 warning + 如实回错误码。
* 匹配口径:取 `svc:|app:|proc:` 之后的裸名精确比对(`svc:netmgrd`/`proc:sysui`/裸 `udhcpc` 均命中)。 */
int net_locked_entity(const char *entity)
{
if (!entity || !entity[0]) return 0;
const char *base = entity;
const char *colon = strrchr(entity, ':');
if (colon) base = colon + 1;
static const char *locked[] = {
"dropbear", "wpa_supplicant", "udhcpc", "netmgrd", "svcd", "sysui", "init", NULL
};
for (int i = 0; locked[i]; i++)
if (!strcmp(base, locked[i])) return 1;
return 0;
}
void net_policy_parse(const char *txt, sbuf_t *s)
{
char lvl[24] = "", enf[24] = "dns_only";
const char *p = txt;
sA(s, "{\"available\":true,\"enforce\":");
sA(s, "{\"available\":true,\"policy_write_enabled\":%s,\"enforce\":",
panel_policy_write_enabled() ? "true" : "false");
/* 首行找 enforce=... */
/* enforce 后面紧跟全角「(」多字节说明,须在非 ASCII 字节处停(否则会吞「(SOCK_DESTROY...)」)。 */
const char *en = strstr(p, "enforce=");
@@ -399,6 +418,9 @@ int api_net_policy_get(int sock)
int api_net_policy_set(int sock, const char *body, size_t body_len)
{
if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
/* guard③ policy 默认关:写端点默认关闭,须在设置开启 panel.policy_enabled 才放行(只读 GET 不受此限)。 */
if (!panel_policy_write_enabled())
return send_err(sock, 403, "POLICY_DISABLED", "网络策略写默认关闭:先到面板设置开启 panel.policy_enabled(危险操作)");
jv_t *root = jv_parse(body, (int)body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *jo = jv_obj_get(root, "op"), *je = jv_obj_get(root, "entity"), *ja = jv_obj_get(root, "arg");
@@ -415,6 +437,14 @@ int api_net_policy_set(int sock, const char *body, size_t body_len)
else if (!strcmp(op, "del")) nop = NET_POLICY_DEL;
else return send_err(sock, 422, "UNSUPPORTED", "op 须为 set_net|set_domain|del");
if (nop != NET_POLICY_DEL && !arg[0]) return send_err(sock, 422, "UNSUPPORTED", "缺 arg");
/* guard① 防自锁:对系统关键服务(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)禁设阻断。
* 「放行 / 删除规则」不阻断(那是恢复连通),仍放行;仅 set_net=block 与 set_domain(域名限制)命中即拒。 */
{
int blocking = ((nop == NET_POLICY_SET_NET) && !strcmp(arg, "block")) ||
(nop == NET_POLICY_SET_DOMAIN);
if (blocking && net_locked_entity(entity))
return send_err(sock, 422, "SELF_LOCK", "防自锁:禁止阻断系统关键服务(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)的网络");
}
m_net_policy_t q; memset(&q, 0, sizeof q);
q.op = (uint32_t)nop;
+28 -1
View File
@@ -71,6 +71,18 @@ done:
return rc;
}
/* 在对象 JSON(已剥尾部 `}`、经 utf8 校验)上拼 guard③ `policy_write_enabled` 顶层键:
* 拼完 jv_parse 自校验,绝不发非法 JSON(防 overview 教训);失败返回 -1(调用方回 500,绝不带病下发)。 */
int power_policy_append_flag(sbuf_t *out, const char *obj)
{
sA(out, "%s", obj);
sA(out, ",\"policy_write_enabled\":%s}", panel_policy_write_enabled() ? "true" : "false");
jv_t *chk = jv_parse(out->b ? out->b : "{}", (int)(out->b ? out->n : 2));
if (!chk) return -1;
jv_free(chk);
return 0;
}
int api_power_policy_get(int sock)
{
char js[MEOW_IPC_MAX];
@@ -80,12 +92,27 @@ int api_power_policy_get(int sock)
return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务不可用(powermgr 未运行/IPC 不可达)");
if (rt != MSG_PM_RESULT || !utf8_valid_n(js, strlen(js)))
return send_err(sock, 503, "POWER_UNAVAILABLE", "电源管理服务应答包含非法字节");
return send_json(sock, 200, js);
/* 追加顶层 `policy_write_enabled`(面板侧 gate 状态,独立于 powermgr 的 `enacted`):
* 稳妥剥外层括号再拼(同 net_block_fill 手法)。上游非对象(数组/文本)时原样透传、不带该键
* (前端缺省视为已开启,兼容旧 powermgr)。 */
size_t l = strlen(js);
while (l && (js[l - 1] == ' ' || js[l - 1] == '\t' || js[l - 1] == '\n' || js[l - 1] == '\r')) js[--l] = 0;
if (l < 2 || js[0] != '{' || js[l - 1] != '}') return send_json(sock, 200, js);
js[l - 1] = 0; /* 去尾部 } */
sbuf_t s = {0};
if (power_policy_append_flag(&s, js) != 0) { sbuf_free(&s); return send_err(sock, 500, "INTERNAL", "电源策略聚合输出未通过 JSON 自校验"); }
int out = send_json(sock, 200, s.b ? s.b : "{}");
sbuf_free(&s);
return out;
}
int api_power_policy_set(int sock, const char *body, size_t body_len)
{
if (!body || !body[0]) return send_err(sock, 400, "BAD_REQUEST", "缺少 body");
/* guard③ policy 默认关:写端点默认关闭,须在设置开启 panel.policy_enabled 才放行(只读 GET 不受此限)。
* 注意:powermgr 侧另有 `enacted`(策略是否已应用),本 gate 是面板侧写入口门禁,二者独立。 */
if (!panel_policy_write_enabled())
return send_err(sock, 403, "POLICY_DISABLED", "电源策略写默认关闭:先到面板设置开启 panel.policy_enabled(危险操作)");
jv_t *root = jv_parse(body, (int)body_len);
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *ji = jv_obj_get(root, "item"), *jv = jv_obj_get(root, "value");
+62 -1
View File
@@ -517,6 +517,8 @@ int panel_selftest(void)
if (!rules || rules->type != J_ARR || !rules->child) { printf("SELFTEST FAIL: net_policy_parse 缺 rules 数组\n"); jv_free(pr); sbuf_free(&p); return 1; }
jv_t *enf = jv_obj_get(pr, "enforce");
if (!enf || enf->type != J_STR || !enf->str || strcmp(enf->str, "dns_only") != 0) { printf("SELFTEST FAIL: net_policy_parse enforce 未如实透传 dns_only\n"); jv_free(pr); sbuf_free(&p); return 1; }
jv_t *pwe = jv_obj_get(pr, "policy_write_enabled"); /* guard③ 状态键必须为布尔,且拼读无括号错位 */
if (!pwe || pwe->type != J_BOOL) { printf("SELFTEST FAIL: net_policy_parse 缺/损坏 policy_write_enabled\n"); jv_free(pr); sbuf_free(&p); return 1; }
jv_free(pr);
sbuf_free(&p);
}
@@ -567,7 +569,66 @@ int panel_selftest(void)
sbuf_free(&b);
}
printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 新增端点池 → 合法 JSON + 无非法字节\n");
/* 7) net_locked_entity:防自锁实体精确匹配(svc:/proc:/裸名);非关键实体放行(guard①)。 */
{
struct { const char *e; int want; } c[] = {
{ "svc:dropbear", 1 }, { "proc:netmgrd", 1 }, { "udhcpc", 1 },
{ "svc:sysui", 1 }, { "svc:wpa_supplicant", 1 }, { "svcd", 1 }, { "init", 1 },
{ "app:com.example", 0 }, { "svc:avahi", 0 }, { "proc:sh", 0 },
{ "app:com.netmgrd.helper", 0 }, /* 含子串但不等于裸名,不得误判 */
{ "", 0 }, { NULL, 0 }
};
for (int i = 0; c[i].e; i++) {
int got = net_locked_entity(c[i].e);
if (got != c[i].want) { printf("SELFTEST FAIL: net_locked_entity(%s)=%d 期望 %d\n", c[i].e, got, c[i].want); return 1; }
}
}
/* 8) policy 默认关 gate(guard③):无设置文件 → panel_policy_write_enabled()==0(默认关);
* settings_write_one("panel.policy_enabled","1") 后 ==1(写临时 settings 文件验证,测完清理)。 */
{
const char *saved = g_settings_file;
char tmpf[] = "/tmp/meowpanel_policy_gate_XXXXXX";
int tfd = mkstemp(tmpf);
if (tfd < 0) { printf("SELFTEST FAIL: mkstemp 失败\n"); return 1; }
close(tfd);
g_settings_file = tmpf;
int before = panel_policy_write_enabled();
if (settings_write_one("panel.policy_enabled", "1") != 0) {
printf("SELFTEST FAIL: 写 panel.policy_enabled 失败\n"); unlink(tmpf); g_settings_file = saved; return 1;
}
int after = panel_policy_write_enabled();
unlink(tmpf);
g_settings_file = saved;
if (before != 0 || after != 1) { printf("SELFTEST FAIL: policy gate 默认关/开启 %d->%d\n", before, after); return 1; }
}
/* 9) power_policy_append_flag:对象 JSON 剥尾拼入 policy_write_enabled 后仍可解析、键为布尔、原字段保留
* ——防括号错位重演(overview 教训)。测试在无 panel.policy_enabled 的临时 settings 下进行(期望 false=默认关)。 */
{
const char *saved = g_settings_file;
char tmpf[] = "/tmp/meowpanel_policy_append_XXXXXX";
int tfd = mkstemp(tmpf);
if (tfd >= 0) { close(tfd); g_settings_file = tmpf; }
sbuf_t p = {0};
if (power_policy_append_flag(&p, "{\"profile\":\"balanced\",\"enacted\":true") != 0) {
printf("SELFTEST FAIL: power_policy_append_flag 拼接失败\n"); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1;
}
if (!utf8_valid_n(p.b, p.n) || !strstr(p.b, "\"policy_write_enabled\":false")) {
printf("SELFTEST FAIL: power_policy_append_flag 产物异常\n"); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1;
}
jv_t *pr = jv_parse(p.b, (int)p.n);
if (!pr) { printf("SELFTEST FAIL: power_policy_append_flag 产物 JSON 无法解析\n"); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1; }
jv_t *pwe = jv_obj_get(pr, "policy_write_enabled");
if (!pwe || pwe->type != J_BOOL || pwe->bval != 0) { printf("SELFTEST FAIL: power_policy_append_flag policy_write_enabled 非 false(默认关)\n"); jv_free(pr); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1; }
jv_t *pfl = jv_obj_get(pr, "profile");
if (!pfl || pfl->type != J_STR || strcmp(pfl->str, "balanced") != 0) { printf("SELFTEST FAIL: power_policy_append_flag 丢失原字段\n"); jv_free(pr); sbuf_free(&p); if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; } return 1; }
jv_free(pr);
sbuf_free(&p);
if (tfd >= 0) { unlink(tmpf); g_settings_file = saved; }
}
printf("SELFTEST PASS: 截断 UTF-8「流媒体客户\\xE7」+ net/power/ams 新增端点池 → 合法 JSON + 无非法字节;防自锁实体 + policy 门禁 默认关/开启 校验通过\n");
return 0;
}
+9
View File
@@ -69,6 +69,10 @@ static const setdef_t g_sets[] = {
{ "panel.pin", ST_STR, "", 0,0, NULL, NULL, "面板" },
{ "panel.auth_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" },
{ "panel.auth_pass", ST_STR, "", 0,0, NULL, NULL, "面板" },
/* panel.policy_enabled — 策略写「默认关」门禁(guard③):POST /api/net/policy 与 /api/power/policy
* 默认关闭(缺省 0),置 1 才放行。落 settings.conf(「面板」组,前端设置页可开启),仅作面板侧门禁记录,
* 不影响 powermgr 的 `enacted`(那是服务端「策略是否已应用」,二者独立)。 */
{ "panel.policy_enabled", ST_BOOL, "0", 0,0, NULL, NULL, "面板" },
};
#define SETS_COUNT ((int)(sizeof g_sets / sizeof g_sets[0]))
@@ -143,6 +147,11 @@ int settings_get_int(const char *key, int defv)
return r;
}
int panel_policy_write_enabled(void)
{
return settings_get_int("panel.policy_enabled", 0) ? 1 : 0;
}
int brightness_clamp(int v)
{
int mn = settings_get_int("display.brightness_min", 1);
+42 -17
View File
@@ -609,21 +609,24 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
function powerPolicyCard(pol) {
if (!pol) return '<div class="card"><h3 class="card-title">电源策略</h3>' + homeUnavailable() + '</div>';
const pwe = !(pol.policy_write_enabled === false); // 缺省视为已开启(兼容旧 powermgr 不带该键)
const profiles = ['performance', 'balanced', 'powersave', 'ultra'];
const cur = pol.profile || 'balanced';
let btns = profiles.map(p => '<button class="btn btn-ghost pol-btn' + (p === cur ? ' pol-on' : '') + '" data-pol="' + esc(p) + '">' + powerProfileLabel(p) + '</button>').join(' ');
return '<div class="card"><h3 class="card-title">电源策略</h3>' +
'<div class="field-row" style="margin-bottom:12px"><span class="muted">省电档位:</span>' + btns + '</div>' +
'<table class="table"><tbody>' +
row('低电阈值', '<input type="number" id="pol-low" class="num pol-num" value="' + esc(pol.low_pct) + '" min="1" max="99"> %') +
row('临界阈值', '<input type="number" id="pol-crit" class="num pol-num" value="' + esc(pol.critical_pct) + '" min="1" max="99"> %') +
row('亮度上限', '<input type="number" id="pol-bri" class="num pol-num" value="' + esc(pol.brightness_cap) + '" min="0" max="101"> <span class="muted">0=不生效</span>') +
row('息屏上限', '<input type="number" id="pol-scr" class="num pol-num" value="' + esc(pol.screen_timeout_cap) + '" min="0"> <span class="muted">秒 · 0=不生效</span>') +
let h = '<div class="card"><h3 class="card-title">电源策略</h3>';
if (!pwe) h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">🔒</span><span>策略写默认关闭(panel.policy_enabled=0):当前只读,切档/阈值需先开启。</span><button class="btn" id="pol-enable">开启策略写</button></div>';
const btns = profiles.map(p => '<button class="btn btn-ghost pol-btn' + (p === cur ? ' pol-on' : '') + '" data-pol="' + esc(p) + '"' + (!pwe ? ' disabled' : '') + '>' + powerProfileLabel(p) + '</button>').join(' ');
h += '<div class="field-row" style="margin-bottom:12px"><span class="muted">省电档位:</span>' + btns + '</div>';
h += '<table class="table"><tbody>' +
row('低电阈值', '<input type="number" id="pol-low" class="num pol-num" value="' + esc(pol.low_pct) + '" min="1" max="99"' + (!pwe ? ' disabled' : '') + '> %') +
row('临界阈值', '<input type="number" id="pol-crit" class="num pol-num" value="' + esc(pol.critical_pct) + '" min="1" max="99"' + (!pwe ? ' disabled' : '') + '> %') +
row('亮度上限', '<input type="number" id="pol-bri" class="num pol-num" value="' + esc(pol.brightness_cap) + '" min="0" max="101"' + (!pwe ? ' disabled' : '') + '> <span class="muted">0=不生效</span>') +
row('息屏上限', '<input type="number" id="pol-scr" class="num pol-num" value="' + esc(pol.screen_timeout_cap) + '" min="0"' + (!pwe ? ' disabled' : '') + '> <span class="muted">秒 · 0=不生效</span>') +
row('状态', (pol.enacted ? '<span class="home-chip ok">已手动开启</span>' : '<span class="home-chip mut">默认全关</span>')) +
'</tbody></table>' +
'<div class="field-row"><button class="btn btn-primary" id="pol-apply">保存阈值</button><span class="muted">切档即时生效(需确认);阈值点「保存阈值」。</span></div>' +
'<p class="muted">' + esc(pol.note || '') + '</p>' +
'</div>';
'</tbody></table>';
h += '<div class="field-row"><button class="btn btn-primary" id="pol-apply"' + (!pwe ? ' disabled' : '') + '>保存阈值</button><span class="muted">切档即时生效(需确认);阈值点「保存阈值」。</span></div>';
h += '<p class="muted">' + esc(pol.note || '') + '</p>';
h += '</div>';
return h;
}
function powerEventsCard(ev) {
@@ -690,6 +693,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
function bindPower() {
$('#content').addEventListener('click', async function (e) {
if (e.target.closest('#pol-enable')) { enablePolicyWrite(fetchPowerOnce); return; }
const pb = e.target.closest('.pol-btn');
if (pb) {
const p = pb.dataset.pol;
@@ -1003,6 +1007,19 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
(i > 0 ? ' <span class="net-entity">' + esc(e.slice(i + 1)) + '</span>' : '');
}
const NET_LOCKEDS = ['dropbear', 'wpa_supplicant', 'udhcpc', 'netmgrd', 'svcd', 'sysui', 'init'];
function netLockedEntity(e) { // 与后端 net_locked_entity 同口径(取 `svc:|app:|proc:` 后裸名精确比对)
if (!e) return false;
const i = e.indexOf(':');
return NET_LOCKEDS.indexOf(i > 0 ? e.slice(i + 1) : e) !== -1;
}
async function enablePolicyWrite(fetchFn) { // 开启 panel.policy_enabled(guard③ 手动开启入口)
try { await api('PUT', '/api/settings/panel.policy_enabled', { value: 1 }); toast('策略写已开启(panel.policy_enabled=1)'); }
catch (err) { toast('开启失败:' + errText(err)); }
if (fetchFn) fetchFn();
}
function netStateCls(st) {
if (!st) return '';
if (st === 'ESTABLISHED') return 'net-state ok';
@@ -1109,7 +1126,9 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
if (!pol || pol.available === false) return '<div class="card"><h3 class="card-title">网络策略</h3>' + homeUnavailable() + '</div>';
const enforce = pol.enforce || '';
const dnsOnly = enforce === 'dns_only';
const pwe = !(pol.policy_write_enabled === false); // 缺省视为已开启(兼容旧 powermgr/netmgrd 不带该键)
let h = '<div class="card"><h3 class="card-title">网络策略</h3>';
if (!pwe) h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">🔒</span><span>策略写默认关闭(panel.policy_enabled=0):当前只读,阻断/放行/域名规则需先开启。</span><button class="btn" id="pol-enable">开启策略写</button></div>';
h += '<div class="page-banner" style="margin-bottom:10px"><span class="ico">' + (dnsOnly ? 'ℹ️' : '⚠️') + '</span><span>' +
(dnsOnly ? '执行模式:仅 DNS 层拒绝(enforce=dns_only;本内核 SOCK_DESTROY 为 no-op,不会真正断连)' : '执行模式:' + esc(enforce)) + '</span></div>';
const rules = pol.rules || [];
@@ -1117,21 +1136,24 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
else {
h += '<table class="table"><thead><tr><th>实体</th><th>网络</th><th>白名单域名</th><th>黑名单域名</th><th style="text-align:right">操作</th></tr></thead><tbody>';
rules.forEach(r => {
const targetNet = r.net === 'block' ? 'allow' : 'block';
const locked = targetNet === 'block' && netLockedEntity(r.entity);
h += '<tr><td class="mono">' + netBadge(r.entity) + '</td>' +
'<td><span class="home-chip ' + (r.net === 'block' ? 'bad' : 'ok') + '">' + (r.net === 'block' ? '阻断' : '放行') + '</span></td>' +
'<td class="muted">' + esc((r.allow_hosts || []).join(', ') || '—') + '</td>' +
'<td class="muted">' + esc((r.deny_hosts || []).join(', ') || '—') + '</td>' +
'<td style="text-align:right"><div class="row-actions">' +
'<button class="btn btn-ghost" data-ptoggle="' + esc(r.entity) + '" data-net="' + (r.net === 'block' ? 'allow' : 'block') + '">' + (r.net === 'block' ? '改放行' : '阻断') + '</button>' +
'<button class="btn btn-ghost" data-pdel="' + esc(r.entity) + '">删除</button></div></td></tr>';
'<button class="btn btn-ghost" data-ptoggle="' + esc(r.entity) + '" data-net="' + targetNet + '"' +
' title="' + (locked ? '防自锁:禁止阻断系统关键服务' : '') + '"' + ((!pwe || locked) ? ' disabled' : '') + '>' + (r.net === 'block' ? '改放行' : '阻断') + '</button>' +
'<button class="btn btn-ghost" data-pdel="' + esc(r.entity) + '"' + ((!pwe) ? ' disabled' : '') + '>删除</button></div></td></tr>';
});
h += '</tbody></table>';
}
h += '<div class="field-row" style="margin-top:12px"><input type="text" id="pol-entity" placeholder="app:os.meow.ncm / svc:x / proc:y" style="max-width:280px">' +
'<button class="btn btn-primary" id="pol-block">设为阻断</button>' +
'<button class="btn btn-ghost" id="pol-allow">设为放行</button></div>';
'<button class="btn btn-primary" id="pol-block"' + (!pwe ? ' disabled' : '') + '>设为阻断</button>' +
'<button class="btn btn-ghost" id="pol-allow"' + (!pwe ? ' disabled' : '') + '>设为放行</button></div>';
h += '<div class="field-row" style="margin-top:8px"><input type="text" id="pol-domain" placeholder="域名(+白 / -黑,如 +music.example.com)" style="max-width:280px">' +
'<button class="btn" id="pol-dom">添加域名规则</button></div>';
'<button class="btn" id="pol-dom"' + (!pwe ? ' disabled' : '') + '>添加域名规则</button></div>';
h += '<p class="muted">防自锁实体(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)拒 block。</p>';
h += '</div>';
return h;
@@ -1284,6 +1306,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
flush.disabled = false;
fetchNetOnce();
}
if (e.target.closest('#pol-enable')) { enablePolicyWrite(fetchNetOnce); return; }
/* ---- 网络策略写(require_auth + 二次确认;enforce=dns_only 如实显示,不谎称断连) ---- */
const tgl = e.target.closest('[data-ptoggle]');
if (tgl) {
@@ -1309,6 +1332,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
if (blk) {
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
if (!ent) { toast('请输入实体(app:xxx / svc:xxx / proc:xxx)'); return; }
if (netLockedEntity(ent)) { toast('防自锁:禁止阻断系统关键服务(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)'); return; }
const ok = await confirmDlg('阻断实体', '确认将 ' + ent + ' 设为阻断(DNS 层拒绝,不会真断连)?', true);
if (!ok) { fetchNetOnce(); return; }
try { await api('POST', '/api/net/policy', { op: 'set_net', entity: ent, arg: 'block' }); toast('已阻断:' + ent); }
@@ -1330,6 +1354,7 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
const ent = ($('#pol-entity') ? $('#pol-entity').value.trim() : '');
const hst = ($('#pol-domain') ? $('#pol-domain').value.trim() : '');
if (!ent) { toast('请输入实体'); return; }
if (netLockedEntity(ent)) { toast('防自锁:禁止为系统关键服务配置域名限制(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)'); return; }
if (!hst || (hst[0] !== '+' && hst[0] !== '-')) { toast('域名须以 +(白)或 -(黑)开头'); return; }
const ok = await confirmDlg('域名规则', '确认对 ' + ent + ' ' + (hst[0] === '+' ? '加入白名单' : '加入黑名单') + ' ' + hst.slice(1) + '?', hst[0] === '-');
if (!ok) { fetchNetOnce(); return; }
+9
View File
@@ -6,10 +6,12 @@
*/
#include "reader_internal.h"
#include "meow_ui_internal.h" /* 阅读页 blit 需要 back_buf 访问器(决策见报告) */
#include "app/reader_ipc.h"
#include <signal.h>
static meow_view *g_shelf_view;
static meow_view *g_reader_view;
static meow_view *g_settings_view;
static void on_sigterm(int s) { (void)s; meow_ui_request_exit(); }
@@ -17,6 +19,7 @@ static void app_on_key(int keycode, int is_down) { (void)keycode; (void)is_down;
static void app_on_tap(int x, int y)
{
if (settings_is_active()) { settings_on_tap(x, y); return; }
if (reader_is_active()) { reader_on_tap(x, y); return; }
shelf_on_tap(x, y);
}
@@ -24,7 +27,9 @@ static void app_on_tap(int x, int y)
static void app_on_swipe(int dir, int dx, int dy)
{
(void)dy; (void)dx;
if (settings_is_active()) { return; } /* 设置页滑动不翻页 */
if (reader_is_active()) { reader_on_swipe(dir, dx, dy); return; }
if (shelf_is_active()) { shelf_on_swipe(dir, dx, dy); return; }
if (dir == MEOW_SWIPE_LEFT) { meow_ui_request_exit(); }
}
@@ -53,13 +58,17 @@ int main(void)
g_shelf_view = shelf_view_create();
g_reader_view = reader_view_create();
g_settings_view = settings_view_create();
meow_view_page_push(ACT_SHELF);
reader_ipc_start(); /* E:系统翻页信号接收(后台线程,best-effort) */
meow_ui_events ev = { app_on_key, app_on_tap, app_on_swipe, app_on_tick,
NULL, app_on_touch_down, app_on_touch_move };
meow_ui_run(&ev);
reader_ipc_stop();
meow_view_free(g_settings_view);
meow_view_free(g_reader_view);
meow_view_free(g_shelf_view);
meow_ui_shutdown();
+1 -1
View File
@@ -1 +1 @@
{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1","version_code":1,"framework":1,"fonts":"full","author":"MeowOS","icon":"icon.png"}
{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1","version_code":1,"framework":1,"fonts":"full","author":"MeowOS","icon":"icon.png","volume_page":true}
+298 -54
View File
@@ -9,6 +9,7 @@
#include "meow_ui_internal.h" /* back_buf/pitch/bpp 访问器(决策见报告) */
#include "book.h"
#include "progress.h"
#include "reader_conf.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
@@ -17,7 +18,10 @@
#include <time.h>
#define RD_MENU_H 64
#define RD_MENU_BTN 7
#define RD_MENU_BTN 4 /* 返回 | 目录 | 界面 | 设置(MVP 单条) */
#define RD_HEADER_H 30 /* 页头条高(书名+章节名) */
#define RD_FOOT_H 18 /* 页底条高(章节+页码) */
#define RD_UI_H 268 /* 「界面」排版面板高 */
static rd_book *g_book;
static int g_reader_active;
@@ -29,6 +33,7 @@ static int g_fs, g_ls;
static uint16_t *g_page_buf[RD_PAGE_CACHE]; /* [0]=prev [1]=cur [2]=next(FreeType 页位图) */
static int g_has_font; /* 1=FreeType 路径;0=SDK 字库兜底 */
static int g_menu_open;
static int g_ui_open; /* 「界面」排版面板(B) */
static int g_dir_open; /* 目录覆盖层 */
static int g_dir_top, g_dir_row_h; /* 目录列表几何(rdsrc 目录项) */
static char g_font_path[400];
@@ -49,6 +54,8 @@ static int g_enc_idx; /* g_enc_cycle[] 下标(当前有效
static uint32_t g_page_bg, g_page_fg; /* 正文页底/字色(24bit) */
static char g_chapter[128]; /* 顶栏章节名(TXT 无章节=文件名) */
static int g_show_header, g_show_footer; /* 页头条/底条显隐(rdconf.rd.header/footer) */
static volatile int g_volpage_pending; /* 系统翻页信号待消费(IPC 线程置位,UI tick 消费,E) */
#define RD_READER_DEFAULT_FS 20
#define RD_READER_DEFAULT_LS 130
@@ -87,6 +94,41 @@ static uint16_t rgb565(uint32_t c)
return (uint16_t)(((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3));
}
/* 按 rdconf.bg/theme 解析阅读页底/字色(默认纯黑,B 定案)。 */
static void reader_resolve_bg(uint32_t *bg, uint32_t *fg)
{
const rdconf *c = rdconf_get();
if (!c) { *bg = 0x000000; *fg = 0x9AA4C8; return; }
if (strcmp(c->bg, "black") == 0) { *bg = 0x000000; *fg = 0x9AA4C8; return; }
if (strcmp(c->bg, "white") == 0) { *bg = 0xFFFFFF; *fg = 0x1A1A2A; return; }
if (strcmp(c->bg, "sepia") == 0) { *bg = 0xF5EFDC; *fg = 0x3B3A32; return; }
/* auto:跟随主题(rd.theme=light/dark 时强制该明暗的 surface/onSurface) */
meow_theme_mode m = MEOW_THEME_AUTO;
if (strcmp(c->theme, "light") == 0) m = MEOW_THEME_LIGHT;
else if (strcmp(c->theme, "dark") == 0) m = MEOW_THEME_DARK;
if (m == MEOW_THEME_AUTO) {
const meow_theme *th = meow_theme_get();
*bg = th->surface; *fg = th->onSurface;
} else {
meow_theme t = meow_theme_defaults(m);
*bg = t.surface; *fg = t.onSurface;
}
}
/* 「界面」面板改背景(写 rdconf.bg + 重解析颜色 + 重排版页缓冲)。 */
static void reader_set_bg(const char *bg)
{
rdconf *c = rdconf_get();
if (!c) return;
strncpy(c->bg, bg, sizeof c->bg - 1); c->bg[sizeof c->bg - 1] = 0;
if (strcmp(c->bg, "black") && strcmp(c->bg, "white") &&
strcmp(c->bg, "sepia") && strcmp(c->bg, "auto")) strcpy(c->bg, "black");
rdconf_persist();
reader_resolve_bg(&g_page_bg, &g_page_fg);
reader_refill_page();
reader_redraw();
}
/* 递归(深度≤3)扫 MeowOS/fonts/ 找第一个 .otf/.ttf。0=找到。 */
static int find_font_rec(const char *dir, int depth)
{
@@ -220,6 +262,152 @@ static void reader_draw_scrub(const meow_theme *th)
}
}
/* ----------------「界面」排版面板(B:字号/行距/背景/顶底栏/编码) ----------------
* 自绘 + 自制命中(README §5.2 建议:W_CUSTOM 画布 + 自制 hit,零 SDK widget 协同)。
* 几何 draw/hit 共用(常量)。 */
#define UI_LABEL_X 20
#define UI_CTRL_X 130
#define UI_CTRL_W (480 - UI_CTRL_X - 16)
#define UI_ROW_H 34
static int ui_panel_y(void) { return g_ph - RD_UI_H; }
/* 单个「值按钮」:[-]/[+] 或文本钮。cx=控件区起点;返回该按钮中点 x。 */
static void ui_draw_step(int x, int w, int y, const char *tag, int active, const meow_theme *th)
{
if (active) meow_ui_fill_rect(x, y, w, 28, th->primaryContainer);
else meow_ui_fill_rect(x, y, w, 28, th->surfaceContainerHighest);
uint32_t tc = active ? th->onPrimaryContainer : th->fg;
meow_ui_text_center(x + w / 2, y + 4, tag, 20, tc);
}
static void ui_draw_chips(const char *const *labels, int n, int cur, int x, int y, int w, const meow_theme *th)
{
int gap = 4;
int cw = (w - (n - 1) * gap) / n;
for (int i = 0; i < n; i++) {
int cx = x + i * (cw + gap);
if (i == cur) meow_gfx_fill_round(cx, y, cw, 28, 14, th->primaryContainer);
else meow_gfx_fill_round(cx, y, cw, 28, 14, th->surfaceContainerHighest);
uint32_t tc = (i == cur) ? th->onPrimaryContainer : th->fg;
meow_ui_text_center(cx + cw / 2, y + 4, labels[i], 20, tc);
}
}
static void reader_draw_ui_panel(const meow_theme *th)
{
int y0 = ui_panel_y();
int top = y0 + 30;
int ry;
const rdconf *c = rdconf_get();
/* 面板底 + scrim */
meow_ui_fill_rect_alpha(0, g_top, g_pw, g_ph - g_top, 0x000000, 150);
meow_ui_fill_rect(0, y0, g_pw, RD_UI_H, th->surfaceContainer);
meow_ui_draw_rect(0, y0, g_pw, 1, th->outlineVariant);
meow_ui_text_center(240, y0 + 5, "界面 · 排版", 20, th->fg);
/* 0 字号 */
ry = top + 0 * UI_ROW_H;
meow_ui_text(UI_LABEL_X, ry + 6, "字号", 20, th->fg);
char v[16]; snprintf(v, sizeof v, "%d", g_fs);
ui_draw_step(UI_CTRL_X, 40, ry + 3, "-", 0, th);
meow_ui_text_center(UI_CTRL_X + 40 + (UI_CTRL_W - 80) / 2, ry + 6, v, 20, th->fg);
ui_draw_step(UI_CTRL_X + UI_CTRL_W - 40, 40, ry + 3, "+", 0, th);
/* 1 行距 */
ry = top + 1 * UI_ROW_H;
meow_ui_text(UI_LABEL_X, ry + 6, "行距", 20, th->fg);
snprintf(v, sizeof v, "%d", g_ls);
ui_draw_step(UI_CTRL_X, 40, ry + 3, "-", 0, th);
meow_ui_text_center(UI_CTRL_X + 40 + (UI_CTRL_W - 80) / 2, ry + 6, v, 20, th->fg);
ui_draw_step(UI_CTRL_X + UI_CTRL_W - 40, 40, ry + 3, "+", 0, th);
/* 2 背景(4 档:黑/白/米黄/自动;当前=标亮) */
ry = top + 2 * UI_ROW_H;
meow_ui_text(UI_LABEL_X, ry + 6, "背景", 20, th->fg);
static const char *bg_lbl[4] = { "黑", "白", "米黄", "自动" };
int bgcur = 0;
if (c) { if (!strcmp(c->bg, "white")) bgcur = 1; else if (!strcmp(c->bg, "sepia")) bgcur = 2;
else if (!strcmp(c->bg, "auto")) bgcur = 3; }
ui_draw_chips(bg_lbl, 4, bgcur, UI_CTRL_X, ry + 3, UI_CTRL_W, th);
/* 3 顶栏显隐(开/关) */
ry = top + 3 * UI_ROW_H;
meow_ui_text(UI_LABEL_X, ry + 6, "顶栏", 20, th->fg);
static const char *yn[2] = { "开", "关" };
ui_draw_chips(yn, 2, g_show_header ? 0 : 1, UI_CTRL_X, ry + 3, UI_CTRL_W, th);
/* 4 底栏显隐 */
ry = top + 4 * UI_ROW_H;
meow_ui_text(UI_LABEL_X, ry + 6, "底栏", 20, th->fg);
ui_draw_chips(yn, 2, g_show_footer ? 0 : 1, UI_CTRL_X, ry + 3, UI_CTRL_W, th);
/* 5 编码(TXT;显示当前,点循环) */
ry = top + 5 * UI_ROW_H;
meow_ui_text(UI_LABEL_X, ry + 6, "编码", 20, th->fg);
if (g_book && !rd_book_is_image(g_book)) {
ui_draw_step(UI_CTRL_X + UI_CTRL_W - 120, 120, ry + 3, enc_cycle_name(g_enc_idx), 0, th);
} else {
meow_ui_text(UI_CTRL_X, ry + 6, "—", 20, th->dim);
}
/* 6 返回阅读 */
ry = top + 6 * UI_ROW_H;
ui_draw_step(UI_CTRL_X, UI_CTRL_W, ry + 2, "返回阅读", 1, th);
}
/* 「界面」面板命中;返回动作码(见下方 enum)。0=无。 */
enum {
UI_NONE = 0, UI_FS_DEC, UI_FS_INC, UI_LS_DEC, UI_LS_INC,
UI_BG_0, UI_BG_1, UI_BG_2, UI_BG_3, UI_HDR_TOGGLE, UI_FTR_TOGGLE, UI_ENC, UI_CLOSE
};
static int reader_ui_hit(int x, int y)
{
int y0 = ui_panel_y();
if (y < y0) return UI_NONE;
int top = y0 + 30;
int ry;
int cx = UI_CTRL_X, cw = UI_CTRL_W;
/* 0 字号 */
ry = top + 0 * UI_ROW_H;
if (y >= ry && y < ry + UI_ROW_H) {
if (x >= cx && x < cx + 40) return UI_FS_DEC;
if (x >= cx + cw - 40 && x < cx + cw) return UI_FS_INC;
return UI_NONE;
}
/* 1 行距 */
ry = top + 1 * UI_ROW_H;
if (y >= ry && y < ry + UI_ROW_H) {
if (x >= cx && x < cx + 40) return UI_LS_DEC;
if (x >= cx + cw - 40 && x < cx + cw) return UI_LS_INC;
return UI_NONE;
}
/* 2 背景 4 chips */
ry = top + 2 * UI_ROW_H;
if (y >= ry && y < ry + UI_ROW_H && x >= cx && x < cx + cw) {
int gap = 4, cwp = (cw - 3 * gap) / 4;
int k = (x - cx) / (cwp + gap);
if (k < 0) k = 0;
if (k > 3) k = 3;
return UI_BG_0 + k;
}
/* 3 顶栏 */
ry = top + 3 * UI_ROW_H;
if (y >= ry && y < ry + UI_ROW_H && x >= cx && x < cx + cw)
return UI_HDR_TOGGLE;
/* 4 底栏 */
ry = top + 4 * UI_ROW_H;
if (y >= ry && y < ry + UI_ROW_H && x >= cx && x < cx + cw)
return UI_FTR_TOGGLE;
/* 5 编码 */
ry = top + 5 * UI_ROW_H;
if (y >= ry && y < ry + UI_ROW_H && x >= cx && x < cx + cw)
return UI_ENC;
/* 6 返回阅读 */
ry = top + 6 * UI_ROW_H;
if (y >= ry && y < ry + UI_ROW_H && x >= cx && x < cx + cw)
return UI_CLOSE;
return UI_NONE;
}
static void reader_canvas_render(meow_widget *w, const meow_theme *th, void *ctx)
{
(void)w; (void)ctx;
@@ -233,40 +421,44 @@ static void reader_canvas_render(meow_widget *w, const meow_theme *th, void *ctx
rd_book_render_text(g_book, page, reader_draw_line_atlas, NULL);
}
}
/* 顶栏:章节名 + 页 n/N + 进度% + (TXT)编码 */
/* 页头条(书名 + 章节名;可选,rd.header) */
int sbh = meow_ui_statusbar_height();
int pages = g_book ? rd_book_pages(g_book) : 0;
int page = g_book ? rd_book_current(g_book) : 0;
char right[40];
int p = pages > 0 ? (page * 100 / pages) : 0;
if (pages > 0) snprintf(right, sizeof right, "%d/%d %d%%", page + 1, pages, p);
else snprintf(right, sizeof right, "%d%%", p);
meow_ui_fill_rect(0, sbh, g_pw, (g_top - 4 - sbh), g_page_bg);
meow_ui_text(16, sbh + 4, g_chapter[0] ? g_chapter : "阅读", 20, th->fg);
meow_ui_text_right(g_pw - 16, sbh + 4, right, 20, th->fg);
meow_ui_draw_rect(0, g_top - 5, g_pw, 1, th->outline);
const char *bk = g_book ? rd_book_basename(g_book) : "阅读";
if (g_show_header) {
meow_ui_fill_rect(0, sbh, g_pw, RD_HEADER_H, g_page_bg);
meow_ui_text(16, sbh + 6, bk, 16, g_page_fg);
meow_ui_text_right(g_pw - 16, sbh + 6, g_chapter[0] ? g_chapter : "阅读", 16, g_page_fg);
meow_ui_draw_rect(0, sbh + RD_HEADER_H - 1, g_pw, 1, th->outlineVariant);
}
/* 页底条(章节 + 页码;可选,rd.footer) */
if (g_show_footer) {
char ft[48];
if (pages > 0) snprintf(ft, sizeof ft, "%d/%d", page + 1, pages);
else snprintf(ft, sizeof ft, "0/0");
int fy = g_ph - RD_FOOT_H;
meow_ui_fill_rect(0, fy, g_pw, RD_FOOT_H, g_page_bg);
meow_ui_text(16, fy + 1, g_chapter[0] ? g_chapter : bk, 16, g_page_fg);
meow_ui_text_right(g_pw - 16, fy + 1, ft, 16, g_page_fg);
}
/* R3 刮擦指示条(非目录覆盖时显示;菜单为底部条,与右缘条并存即可) */
if (g_book && !g_dir_open) reader_draw_scrub(th);
/* R3 刮擦指示条(非目录/面板覆盖时显示;菜单为底部条,与右缘条并存即可) */
if (g_book && !g_dir_open && !g_ui_open) reader_draw_scrub(th);
if (g_menu_open) {
int y = g_ph - RD_MENU_H;
meow_ui_fill_rect_alpha(0, y, g_pw, RD_MENU_H, 0x000000, 160);
/* 当前编码提示(菜单上方淡条;仅文本类书有意义) */
if (g_book && !rd_book_is_image(g_book)) {
char en[64]; snprintf(en, sizeof en, "编码:%s", enc_cycle_name(g_enc_idx));
meow_ui_fill_rect_alpha(0, y - 26, g_pw, 24, 0x000000, 130);
meow_ui_text_right(g_pw - 16, y - 22, en, 16, th->dim);
}
/* 7 按钮:返回 | 目录 | 字− | 字+ | 距− | 距+ | 编码 */
/* 4 按钮(MVP 单条):返回 | 目录 | 界面 | 设置 */
int bw = g_pw / RD_MENU_BTN;
const char *labels[RD_MENU_BTN] = { "返回", "目录", "字-", "字+", "距-", "距+", "编码" };
const char *labels[RD_MENU_BTN] = { "返回", "目录", "界面", "设置" };
for (int i = 0; i < RD_MENU_BTN; i++) {
int bx = i * bw;
meow_ui_fill_rect(bx + 4, y + 12, bw - 8, RD_MENU_H - 24, th->surfaceContainer);
meow_ui_text_center(bx + bw / 2, y + (RD_MENU_H - 20) / 2 - 6, labels[i], 20, th->fg);
meow_ui_text_center(bx + bw / 2, y + (RD_MENU_H - 20) / 2, labels[i], 20, th->fg);
}
}
if (g_ui_open && g_book) reader_draw_ui_panel(th);
if (g_dir_open && g_book) {
/* 目录覆盖层:半透明底 + 条目列表(当前行高亮) */
int n = rd_book_toc_n(g_book);
@@ -389,28 +581,28 @@ static void reader_scrub_jump(int page)
reader_redraw(); /* 整页落定:正文 + 顶栏 + 刮擦条 */
reader_reflow_persist();
}
/* 拖动中局部重绘(不动正文页,仅顶栏 n/N + 右缘条/预览)。内容页保持(预览语义)。 */
/* 拖动中局部重绘(不动正文页,仅页底页码 + 右缘条/预览)。内容页保持(预览语义)。 */
static void reader_scrub_preview(int page)
{
g_scrub_page = page;
if (!g_book) return;
const meow_theme *th = meow_theme_get();
int sbh = meow_ui_statusbar_height();
int pages = rd_book_pages(g_book);
int y0 = rect_y0(), y1 = rect_y1();
char right[40];
int p = pages > 0 ? (g_scrub_page * 100 / pages) : 0;
if (pages > 0) snprintf(right, sizeof right, "%d/%d %d%%", g_scrub_page + 1, pages, p);
else snprintf(right, sizeof right, "%d%%", p);
/* 顶栏重画 */
meow_ui_fill_rect(0, sbh, g_pw, (g_top - 4 - sbh), g_page_bg);
meow_ui_text(16, sbh + 4, g_chapter[0] ? g_chapter : "阅读", 20, th->fg);
meow_ui_text_right(g_pw - 16, sbh + 4, right, 20, th->fg);
meow_ui_draw_rect(0, g_top - 5, g_pw, 1, th->outline);
char ft[48];
if (pages > 0) snprintf(ft, sizeof ft, "%d/%d", g_scrub_page + 1, pages);
else snprintf(ft, sizeof ft, "0/0");
/* 页底条页码重画(页码已移到底条) */
if (g_show_footer) {
int fy = g_ph - RD_FOOT_H;
meow_ui_fill_rect(0, fy, g_pw, RD_FOOT_H, g_page_bg);
meow_ui_text(16, fy + 1, g_chapter[0] ? g_chapter : "阅读", 16, g_page_fg);
meow_ui_text_right(g_pw - 16, fy + 1, ft, 16, g_page_fg);
}
/* 右缘条区清残留(恢复页面内容)再画条 + 预览框;无页位图(字库兜底)则跳过恢复 */
if (g_page_buf[RD_PAGE_SLOT_CUR]) blit_sub(g_page_buf[RD_PAGE_SLOT_CUR], g_pw - 120, y0, 120, y1 - y0);
reader_draw_scrub(th);
meow_ui_flush_region(0, sbh, g_pw, g_top - sbh);
if (g_show_footer) meow_ui_flush_region(0, g_ph - RD_FOOT_H, g_pw, RD_FOOT_H);
meow_ui_flush_region(g_pw - 120, y0, 120, y1 - y0);
}
static void reader_scrub_begin(int x, int y)
@@ -435,7 +627,13 @@ void reader_open(const char *path)
const char *fp = resolve_font();
g_has_font = (fp != NULL);
g_fs = RD_READER_DEFAULT_FS; g_ls = RD_READER_DEFAULT_LS;
/* 全局默认字号/行距(reader.conf;进度里的每书偏好随后覆盖) */
const rdconf *rdc = rdconf_get();
g_fs = (rdc && rdc->fs >= 14 && rdc->fs <= 40) ? rdc->fs : RD_READER_DEFAULT_FS;
g_ls = (rdc && rdc->ls >= 100 && rdc->ls <= 200) ? rdc->ls : RD_READER_DEFAULT_LS;
g_show_header = rdc ? (rdc->header ? 1 : 0) : 1;
g_show_footer = rdc ? (rdc->footer ? 1 : 0) : 1;
/* 进度(含字号/行距偏好) */
rdprog prog;
@@ -444,10 +642,10 @@ void reader_open(const char *path)
int sbh = meow_ui_statusbar_height();
g_pw = meow_ui_width(); g_ph = meow_ui_height();
g_top = sbh + 34; g_mx = 16; g_my = 20;
g_top = sbh + RD_HEADER_H; g_mx = 16; g_my = 20;
const meow_theme *th = meow_theme_get();
g_page_bg = th->surface; g_page_fg = th->onSurface;
/* 阅读页底/字色:rd.bg(默认纯黑,B)→ 覆盖 th->surface */
reader_resolve_bg(&g_page_bg, &g_page_fg);
/* 开书(FreeType 路径带齐几何;无字体则 g_has_font=0 → atlas 兜底) */
int rc = rd_book_open(&g_book, path, fp, g_fs, g_ls, g_pw, g_ph, g_mx, g_my, g_top);
@@ -469,7 +667,7 @@ void reader_open(const char *path)
/* 复位动画/刮擦状态(跨书) */
if (g_anim) { meow_anim_cancel(g_anim); g_anim = NULL; }
g_scrub = 0; g_menu_open = 0; g_dir_open = 0;
g_scrub = 0; g_menu_open = 0; g_dir_open = 0; g_ui_open = 0;
/* 编码:初始 = 打开探测结果;进度里的显式编码(Big5 用户选定)优先重开 */
if (g_book && !rd_book_is_image(g_book)) {
@@ -517,7 +715,7 @@ static void reader_on_destroy(meow_view *v, void *ctx)
if (g_book) rd_book_close(g_book);
g_book = NULL;
reader_free_page_bufs();
g_menu_open = 0; g_dir_open = 0; g_scrub = 0; g_reader_active = 0;
g_menu_open = 0; g_dir_open = 0; g_ui_open = 0; g_scrub = 0; g_reader_active = 0;
}
meow_view *reader_view_create(void)
@@ -565,6 +763,44 @@ void reader_on_tap(int x, int y)
g_dir_open = 0; reader_redraw();
return;
}
if (g_ui_open) {
int a = reader_ui_hit(x, y);
switch (a) {
case UI_FS_DEC: if (g_fs > 14) { g_fs -= 2; reader_reflow(); } break;
case UI_FS_INC: if (g_fs < 40) { g_fs += 2; reader_reflow(); } break;
case UI_LS_DEC: if (g_ls > 100) { g_ls -= 5; reader_reflow(); } break;
case UI_LS_INC: if (g_ls < 200) { g_ls += 5; reader_reflow(); } break;
case UI_BG_0: reader_set_bg("black"); break;
case UI_BG_1: reader_set_bg("white"); break;
case UI_BG_2: reader_set_bg("sepia"); break;
case UI_BG_3: reader_set_bg("auto"); break;
case UI_HDR_TOGGLE:
g_show_header = !g_show_header;
if (rdconf_get()) { rdconf_get()->header = g_show_header; rdconf_persist(); }
reader_redraw();
break;
case UI_FTR_TOGGLE:
g_show_footer = !g_show_footer;
if (rdconf_get()) { rdconf_get()->footer = g_show_footer; rdconf_persist(); }
reader_redraw();
break;
case UI_ENC: /* 编码切换(TXT/Big5 繁体书) */
if (g_book && !rd_book_is_image(g_book)) {
int ni = (g_enc_idx + 1) % RD_ENC_CYCLE_N;
if (rd_book_set_enc(g_book, g_enc_cycle[ni]) == 0) {
g_enc_idx = ni;
chapter_name();
reader_refill_page();
reader_redraw();
reader_reflow_persist();
}
}
break;
case UI_CLOSE: g_ui_open = 0; reader_redraw(); break;
default: if (a == UI_NONE) { g_ui_open = 0; reader_redraw(); } break;
}
return;
}
if (g_menu_open) {
/* 菜单按钮命中 */
int y0 = g_ph - RD_MENU_H;
@@ -581,22 +817,13 @@ void reader_on_tap(int x, int y)
if (rd_book_toc_n(g_book) > 0) { g_dir_open = 1; g_menu_open = 0; reader_redraw(); }
else { g_menu_open = 0; reader_redraw(); }
return;
case 2: if (g_fs > 14) { g_fs -= 2; reader_reflow(); } break;
case 3: if (g_fs < 40) { g_fs += 2; reader_reflow(); } break;
case 4: if (g_ls > 100) { g_ls -= 5; reader_reflow(); } break;
case 5: if (g_ls < 200) { g_ls += 5; reader_reflow(); } break;
case 6: /* 编码切换(TXT/Big5 繁体书) */
if (g_book && !rd_book_is_image(g_book)) {
int ni = (g_enc_idx + 1) % RD_ENC_CYCLE_N;
if (rd_book_set_enc(g_book, g_enc_cycle[ni]) == 0) {
g_enc_idx = ni;
chapter_name();
reader_refill_page();
reader_redraw();
case 2: /* 界面(阅读排版设置) */
g_ui_open = 1; g_menu_open = 0; reader_redraw();
return;
case 3: /* 设置 → App 设置页 */
reader_reflow_persist();
}
}
break;
meow_view_page_push(ACT_SETTINGS);
return;
}
if (y >= y0) return;
}
@@ -615,6 +842,7 @@ void reader_on_swipe(int dir, int dx, int dy)
(void)dx; (void)dy;
if (!g_book) return;
if (g_scrub) g_scrub = 0; /* 刮擦中被滑走 → 退出刮擦 */
if (g_ui_open) { g_ui_open = 0; reader_redraw(); return; }
if (g_menu_open) { g_menu_open = 0; reader_redraw(); return; }
if (dir == MEOW_SWIPE_LEFT) reader_turn_animated(1);
else if (dir == MEOW_SWIPE_RIGHT) reader_turn_animated(-1);
@@ -628,8 +856,24 @@ void reader_on_touch_move(int x, int y)
if (g_scrub && g_book) reader_scrub_preview(scrub_page_for_y(y));
}
/* 系统翻页信号(E):reader_ipc 线程收到 MSG_KEY_PAGEUP/PAGEDOWN 后置 pending 计数;
* 真正翻页在 UI 线程 reader_on_tick 消费(避免跨线程直接改页面状态)。 */
void reader_page_key(int dir)
{
int p = g_volpage_pending + dir;
if (p > 2) p = 2;
if (p < -2) p = -2;
g_volpage_pending = p;
}
void reader_on_tick(uint64_t now)
{
(void)now;
if (g_volpage_pending) {
int dir = g_volpage_pending > 0 ? 1 : -1;
g_volpage_pending = 0;
if (g_book && !g_menu_open && !g_dir_open && !g_ui_open)
reader_turn_animated(dir);
}
if (g_notice_ms > 0) { g_notice_ms--; if (g_notice_ms == 0) reader_redraw(); }
}
+117
View File
@@ -0,0 +1,117 @@
/* app/reader_conf.c — 阅读器 App 私有配置实现(见 app/reader_conf.h)。 */
#include "reader_conf.h"
#include "meow_ui.h" /* meow_config_dir() */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
/* 从 MEOW_DATA_DIR(<TF>/MeowData/<pkg>/data)剥掉尾 "/data",得到 <TF>/MeowData/<pkg>。 */
static void strip_data_suffix(const char *d, char *out, int outsz)
{
if (!d || !d[0]) { strncpy(out, "/tmp", (size_t)outsz - 1); out[outsz - 1] = 0; return; }
size_t n = strlen(d);
const char *suffix = "/data";
size_t sl = strlen(suffix);
size_t keep;
if (n >= sl && strcmp(d + n - sl, suffix) == 0) keep = n - sl;
else keep = n;
if (keep >= (size_t)outsz) keep = (size_t)outsz - 1;
memcpy(out, d, keep);
out[keep] = 0;
}
static void conf_path(char *out, int outsz)
{
const char *data = meow_config_dir();
char base[360];
strip_data_suffix(data ? data : "", base, sizeof base);
if (snprintf(out, (size_t)outsz, "%s/reader.conf", base) >= outsz) out[0] = 0;
/* mkdir -p base */
char tmp[400];
strncpy(tmp, base, sizeof tmp - 1); tmp[sizeof tmp - 1] = 0;
for (char *p = tmp + 1; *p; p++) {
if (*p == '/') { *p = 0; mkdir(tmp, 0755); *p = '/'; }
}
mkdir(tmp, 0755);
}
static void conf_defaults(rdconf *c)
{
c->fs = 20;
c->ls = 130;
strcpy(c->bg, "black"); /* B 定案:默认纯黑 */
strcpy(c->theme, "auto");
strcpy(c->shelf, "list"); /* A 定案:默认 list */
c->header = 1;
c->footer = 1;
c->font[0] = 0;
}
static void clamp_str(char *v, size_t sz)
{
v[sz - 1] = 0;
}
rdconf *rdconf_get(void)
{
static rdconf g;
static int loaded = 0;
if (loaded) return &g;
conf_defaults(&g);
char path[440];
conf_path(path, sizeof path);
FILE *f = path[0] ? fopen(path, "r") : NULL;
if (f) {
char line[512];
while (fgets(line, sizeof line, f)) {
line[strcspn(line, "\r\n")] = 0;
char *eq = strchr(line, '=');
if (!eq) continue;
*eq = 0;
const char *k = line, *v = eq + 1;
if (!strcmp(k, "ui.shelf_layout")) { strncpy(g.shelf, v, sizeof g.shelf - 1); g.shelf[sizeof g.shelf - 1] = 0; }
else if (!strcmp(k, "rd.fs")) { int t = atoi(v); if (t >= 14 && t <= 40) g.fs = t; }
else if (!strcmp(k, "rd.ls")) { int t = atoi(v); if (t >= 100 && t <= 200) g.ls = t; }
else if (!strcmp(k, "rd.bg")) { strncpy(g.bg, v, sizeof g.bg - 1); g.bg[sizeof g.bg - 1] = 0; }
else if (!strcmp(k, "rd.theme")) { strncpy(g.theme, v, sizeof g.theme - 1); g.theme[sizeof g.theme - 1] = 0; }
else if (!strcmp(k, "rd.header")) { g.header = atoi(v) ? 1 : 0; }
else if (!strcmp(k, "rd.footer")) { g.footer = atoi(v) ? 1 : 0; }
else if (!strcmp(k, "rd.font")) { strncpy(g.font, v, sizeof g.font - 1); g.font[sizeof g.font - 1] = 0; }
}
fclose(f);
}
/* 校验合法取值(越界回默认/回退),避免脏档破坏 UI */
if (strcmp(g.shelf, "grid") && strcmp(g.shelf, "list")) strcpy(g.shelf, "list");
if (strcmp(g.bg, "black") && strcmp(g.bg, "white") &&
strcmp(g.bg, "sepia") && strcmp(g.bg, "auto")) strcpy(g.bg, "black");
if (strcmp(g.theme, "light") && strcmp(g.theme, "dark") &&
strcmp(g.theme, "auto")) strcpy(g.theme, "auto");
clamp_str(g.shelf, sizeof g.shelf);
clamp_str(g.bg, sizeof g.bg);
clamp_str(g.theme, sizeof g.theme);
clamp_str(g.font, sizeof g.font);
loaded = 1;
return &g;
}
int rdconf_persist(void)
{
rdconf *c = rdconf_get();
char path[440];
conf_path(path, sizeof path);
if (!path[0]) return -1;
FILE *f = fopen(path, "wb");
if (!f) return -5;
fprintf(f, "ui.shelf_layout=%s\n", c->shelf);
fprintf(f, "rd.fs=%d\n", c->fs);
fprintf(f, "rd.ls=%d\n", c->ls);
fprintf(f, "rd.bg=%s\n", c->bg);
fprintf(f, "rd.theme=%s\n", c->theme);
fprintf(f, "rd.header=%d\n", c->header);
fprintf(f, "rd.footer=%d\n", c->footer);
fprintf(f, "rd.font=%s\n", c->font);
fclose(f);
return 0;
}
+45
View File
@@ -0,0 +1,45 @@
/* app/reader_conf.h — 阅读器 App 私有配置(reader.conf,key=value 文本)。
*
* 存储域:<TF>/MeowData/os.meow.reader/reader.conf(复用 progress.c 的 meow_config_dir()
* 去皮 /data 派生包根;无 env/宿主回退 /tmp/meowcfg_<pid>/)。不进系统 settings.conf。
*
* 键(本模块常量):
* ui.shelf_layout = list|grid 书架默认布局(shelf.c 读)
* rd.fs = int (14..40) 全局默认字号(无进度书时 reader_open 用)
* rd.ls = int (100..200) 全局默认行距
* rd.bg = auto|black|white|sepia 阅读页底色预设(默认 black;B 定案)
* rd.theme= auto|light|dark 明暗(auto=跟随系统)
* rd.header= 0|1 阅读页顶条(书名+章节名)显隐
* rd.footer= 0|1 阅读页底条(章节+页码)显隐
* rd.font = <路径>|空 字体(进阶;空=resolve_font 自动)
*/
#ifndef READER_CONF_H
#define READER_CONF_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct rdconf {
int fs; /* 默认字号 14..40 */
int ls; /* 默认行距 100..200 */
char bg[8]; /* auto|black|white|sepia */
char theme[8]; /* auto|light|dark */
char shelf[8]; /* list|grid */
int header; /* 1=阅读页顶条 */
int footer; /* 1=阅读页底条 */
char font[400]; /* 字体路径(空=自动) */
} rdconf;
/* 惰性载入 + 缓存(首次调用读盘;无档=默认)。返回指向静态副本,勿改字段外部生命周期。 */
rdconf *rdconf_get(void);
/* 把当前缓存写回 reader.conf。0=成功;负值=IO/路径错(不崩)。 */
int rdconf_persist(void);
#ifdef __cplusplus
}
#endif
#endif /* READER_CONF_H */
+186
View File
@@ -0,0 +1,186 @@
/* app/reader_ipc.c — 系统翻页信号接收(见 app/reader_ipc.h)。 */
#include "reader_ipc.h"
#include "../reader_internal.h" /* reader_page_key() */
#include "meow_ipc.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <poll.h>
#include <pthread.h>
#include <sys/socket.h>
#include <sys/un.h>
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
static int g_fd = -1;
static pthread_t g_thr;
static int g_thr_on = 0;
static volatile int g_quit = 0;
static const char *sock_path(void)
{
const char *e = getenv("MEOW_IPC_SOCK");
return (e && e[0]) ? e : MEOW_IPC_PATH;
}
static void app_id_of(char *out, int outsz)
{
const char *e = getenv("MEOW_LOG_APP");
snprintf(out, (size_t)outsz, "%s", (e && e[0]) ? e : "os.meow.reader");
}
static int connect_path(const char *path)
{
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, path, sizeof a.sun_path - 1);
if (connect(fd, (struct sockaddr *)&a, sizeof a) < 0) { close(fd); return -1; }
return fd;
}
static int write_all(int fd, const uint8_t *p, size_t n)
{
size_t off = 0;
while (off < n) {
ssize_t w = write(fd, p + off, n - off);
if (w < 0) { if (errno == EINTR) continue; return 0; }
if (w == 0) return 0;
off += (size_t)w;
}
return 1;
}
static int read_all(int fd, uint8_t *p, size_t n)
{
size_t off = 0;
while (off < n) {
ssize_t r = read(fd, p + off, n - off);
if (r < 0) { if (errno == EINTR) continue; return 0; }
if (r == 0) return 0;
off += (size_t)r;
}
return 1;
}
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;
uint16_t ver = MEOW_IPC_VERSION;
memcpy(hdr + 0, &magic, 4);
memcpy(hdr + 4, &ver, 2);
memcpy(hdr + 6, &type, 2);
memcpy(hdr + 8, &req_id, 4);
memcpy(hdr + 12, &len, 4);
if (!write_all(fd, hdr, sizeof hdr)) return -1;
if (len && payload && !write_all(fd, payload, len)) return -1;
return 0;
}
static int read_frame(int fd, uint16_t *type, uint8_t *rbuf, uint32_t rcap)
{
uint8_t hdr[MEOW_IPC_HDR_LEN];
if (!read_all(fd, hdr, sizeof hdr)) return -1;
uint32_t magic, len; uint16_t ver;
memcpy(&magic, hdr, 4); memcpy(&ver, hdr + 4, 2); memcpy(type, hdr + 6, 2);
memcpy(&len, hdr + 12, 4);
if (magic != MEOW_IPC_MAGIC || ver != MEOW_IPC_VERSION) return -1;
if (len > rcap) len = rcap;
if (len > 0 && !read_all(fd, rbuf, len)) return -1;
return (int)len;
}
static int conn_locked(void)
{
if (g_fd >= 0) return g_fd;
int fd = connect_path(sock_path());
if (fd < 0) return -1;
char app[MEOW_APP_ID_MAX];
app_id_of(app, (int)sizeof app);
m_hello_t h;
memset(&h, 0, sizeof h);
snprintf(h.app_id, sizeof h.app_id, "%s", app);
h.version = 1;
h.flags = 0; /* 非媒体源;仅声明存在,收系统翻页信号 */
if (send_frame(fd, MSG_HELLO, 1, &h, sizeof h) != 0) { close(fd); return -1; }
uint8_t rbuf[256];
uint16_t rt;
if (read_frame(fd, &rt, rbuf, sizeof rbuf) < 0 ||
(rt != MSG_ACK && rt != MSG_ERR)) { close(fd); return -1; }
g_fd = fd;
return fd;
}
static void mark_dead_locked(void)
{
if (g_fd >= 0) { close(g_fd); g_fd = -1; }
}
static void *ipc_thread_main(void *arg)
{
(void)arg;
while (!g_quit) {
pthread_mutex_lock(&g_mu);
int fd = g_fd;
if (fd < 0) fd = conn_locked(); /* 懒连/重连(best-effort;失败则重试) */
pthread_mutex_unlock(&g_mu);
if (fd < 0) { usleep(500000); continue; }
struct pollfd p;
p.fd = fd; p.events = POLLIN; p.revents = 0;
int pr = poll(&p, 1, 200);
if (pr < 0 && errno != EINTR) {
pthread_mutex_lock(&g_mu); mark_dead_locked(); pthread_mutex_unlock(&g_mu);
continue;
}
if (pr > 0 && (p.revents & (POLLHUP | POLLERR | POLLNVAL))) {
pthread_mutex_lock(&g_mu); mark_dead_locked(); pthread_mutex_unlock(&g_mu);
continue;
}
if (pr > 0 && (p.revents & POLLIN)) {
uint8_t rbuf[MEOW_IPC_MAX];
uint16_t type;
int n = read_frame(fd, &type, rbuf, sizeof rbuf);
if (n < 0) {
pthread_mutex_lock(&g_mu); mark_dead_locked(); pthread_mutex_unlock(&g_mu);
continue;
}
/* UI 线程经 reader_on_tick 消费(跨线程仅置 pending 计数,不直接改页) */
if (type == MSG_KEY_PAGEUP) reader_page_key(-1);
else if (type == MSG_KEY_PAGEDOWN) reader_page_key(1);
}
}
pthread_mutex_lock(&g_mu); mark_dead_locked(); pthread_mutex_unlock(&g_mu);
return NULL;
}
void reader_ipc_start(void)
{
pthread_mutex_lock(&g_mu);
int need = !g_thr_on;
if (need) { g_quit = 0; g_thr_on = 1; }
pthread_mutex_unlock(&g_mu);
if (need) {
pthread_attr_t at;
pthread_attr_init(&at);
pthread_attr_setstacksize(&at, 64 * 1024); /* 小栈:阅读态 ≤8MB 纪律 */
pthread_create(&g_thr, &at, ipc_thread_main, NULL);
pthread_attr_destroy(&at);
pthread_detach(g_thr);
}
}
void reader_ipc_stop(void)
{
pthread_mutex_lock(&g_mu);
g_quit = 1;
mark_dead_locked();
g_thr_on = 0;
pthread_mutex_unlock(&g_mu);
}
+32
View File
@@ -0,0 +1,32 @@
/* app/reader_ipc.h — 阅读器系统翻页信号接收(launcher 经 IPC 下发 MSG_KEY_PAGEUP/PAGEDOWN)。
*
* 系统级能力(READER_UI_DESIGN §E / 用户 2026-09-04 拍板):音量键翻页由 launcher 判决
* (仅当前台 App = 本阅读器且 app.json 声明 volume_page),下发消息;本模块在 App 侧
* 维持一条持久 IPC 连接接收并转成 `reader_page_key(±1)`。
*
* 设计(对齐 SDK meow_ui_media.c 模式):
* - 后台线程懒连 meow_ipc.sock,HELLO(app_id=os.meow.reader) 后 poll 读下发帧;
* - 收到 MSG_KEY_PAGEUP/PAGEDOWN → 调 reader_page_key(-1/+1)(UI 线程经 tick 消费,
* 避免跨线程直接改页面状态);
* - best-effort:launcher 不在 / 断线 → 重试,绝不崩;App 退出 reader_ipc_stop() 收尾。
*
* 内存纪律:显式小线程栈(64KB),符合阅读态 ≤8MB。
*/
#ifndef READER_IPC_H
#define READER_IPC_H
#ifdef __cplusplus
extern "C" {
#endif
/* 启动翻页信号接收线程(幂等)。读入 app_id = "os.meow.reader"。
* 转发目标:reader_page_key(dir)(reader_internal.h)。 */
void reader_ipc_start(void);
/* 停止线程并断开(App 退出时调用;幂等)。 */
void reader_ipc_stop(void);
#ifdef __cplusplus
}
#endif
#endif /* READER_IPC_H */
+250
View File
@@ -0,0 +1,250 @@
/* app/settings.c — 全局设置页(C,书架顶栏「设置」入口 → 独立页 ACT_SETTINGS)。
*
* 配置项(MVP,见 READER_UI_DESIGN §4.3):默认字号/默认行距/默认背景(黑白米黄)/主题(亮暗跟随
* 全局)/书架布局(list-grid)/页头条/页底条。全部存 App 私有 reader.conf(rdconf),不进系统
* settings.conf。UI:meow_topappbar(返回+标题) + meow_listview 行(每行 label + 当前值尾部箭头,
* 点行开 meow_dialog(SELECT) 单选;值即改即存)。
*/
#include "../reader_internal.h"
#include "progress.h"
#include "reader_conf.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define SET_X 0
#define SET_W 480
#define SET_APPBAR_H 56
static meow_widget *g_appbar;
static meow_widget *g_list;
static int g_active;
/* 行项枚举(顺序 = 列表顺序) */
enum { ITEM_FS = 0, ITEM_LS, ITEM_BG, ITEM_THEME, ITEM_SHELF, ITEM_HDR, ITEM_FTR, ITEM_COUNT };
static const char *const g_labels[ITEM_COUNT] = {
"默认字号", "默认行距", "默认背景", "主题", "书架布局", "页头条", "页底条"
};
static void settings_redraw(void);
static const char *bg_label(const rdconf *c)
{
if (!c) return "黑";
if (!strcmp(c->bg, "white")) return "白";
if (!strcmp(c->bg, "sepia")) return "米黄";
if (!strcmp(c->bg, "auto")) return "自动";
return "黑";
}
static const char *theme_label(const rdconf *c)
{
if (!c) return "跟随系统";
if (!strcmp(c->theme, "light")) return "日";
if (!strcmp(c->theme, "dark")) return "夜";
return "跟随系统";
}
static const char *shelf_label(const rdconf *c)
{
if (!c) return "列表";
return (!strcmp(c->shelf, "grid")) ? "宫格" : "列表";
}
/* 当前行值(subtitle)。 */
static void item_value(int item, char *out, int outsz)
{
const rdconf *c = rdconf_get();
if (!c) { snprintf(out, (size_t)outsz, "-"); return; }
switch (item) {
case ITEM_FS: snprintf(out, (size_t)outsz, "%d", c->fs); break;
case ITEM_LS: snprintf(out, (size_t)outsz, "%d", c->ls); break;
case ITEM_BG: snprintf(out, (size_t)outsz, "%s", bg_label(c)); break;
case ITEM_THEME: snprintf(out, (size_t)outsz, "%s", theme_label(c)); break;
case ITEM_SHELF: snprintf(out, (size_t)outsz, "%s", shelf_label(c)); break;
case ITEM_HDR: snprintf(out, (size_t)outsz, "%s", c->header ? "显示" : "隐藏"); break;
case ITEM_FTR: snprintf(out, (size_t)outsz, "%s", c->footer ? "显示" : "隐藏"); break;
default: snprintf(out, (size_t)outsz, "-"); break;
}
}
/* ---- listview 行源(src2:每次绘制读最新 rdconf 值) ---- */
static int settings_src(int idx, meow_list_item *out, void *ctx)
{
(void)ctx;
if (idx < 0 || idx >= ITEM_COUNT) return 0;
static char valbuf[16];
item_value(idx, valbuf, sizeof valbuf);
out->icon_kind = MEOW_LIST_ICON_NONE;
out->title = g_labels[idx];
out->subtitle = valbuf;
out->right = MEOW_LIST_RIGHT_ARROW;
out->on = 0;
out->enabled = 1;
return 1;
}
/* 由当前值求 SELECT 对话框的选中下标。 */
static int idx_for(int item, const char *const *opts, int n, const rdconf *c)
{
if (item == ITEM_FS) { for (int i = 0; i < n; i++) if (atoi(opts[i]) == c->fs) return i; return 0; }
if (item == ITEM_LS) { for (int i = 0; i < n; i++) if (atoi(opts[i]) == c->ls) return i; return 0; }
if (item == ITEM_BG) {
for (int i = 0; i < n; i++) if (!strcmp(opts[i], bg_label(c))) return i;
return 0;
}
if (item == ITEM_THEME) {
for (int i = 0; i < n; i++) if (!strcmp(opts[i], theme_label(c))) return i;
return 0;
}
if (item == ITEM_SHELF) {
for (int i = 0; i < n; i++) if (!strcmp(opts[i], shelf_label(c))) return i;
return 0;
}
if (item == ITEM_HDR) return c->header ? 0 : 1;
if (item == ITEM_FTR) return c->footer ? 0 : 1;
return 0;
}
/* 选完整数数组项(字号/行距);写回 rdconf。 */
static void apply_fs_ls(int item, const char *sel)
{
rdconf *c = rdconf_get();
if (!c) return;
int v = atoi(sel);
if (item == ITEM_FS) { if (v >= 14 && v <= 40) c->fs = v; }
else if (item == ITEM_LS) { if (v >= 100 && v <= 200) c->ls = v; }
rdconf_persist();
}
static void apply_choice(int item, int sel)
{
rdconf *c = rdconf_get();
if (!c) return;
switch (item) {
case ITEM_BG:
if (sel < 0) sel = 0;
if (sel > 3) sel = 3;
if (sel == 0) strcpy(c->bg, "black");
else if (sel == 1) strcpy(c->bg, "white");
else if (sel == 2) strcpy(c->bg, "sepia");
else strcpy(c->bg, "auto");
break;
case ITEM_THEME:
if (sel < 0) sel = 0;
if (sel > 2) sel = 2;
if (sel == 0) strcpy(c->theme, "auto");
else if (sel == 1) strcpy(c->theme, "light");
else strcpy(c->theme, "dark");
break;
case ITEM_SHELF:
shelf_set_view(sel ? 1 : 0); /* 写 rdconf + 重建书架 */
return;
case ITEM_HDR: c->header = sel ? 0 : 1; break; /* 0 显示 1 隐藏 */
case ITEM_FTR: c->footer = sel ? 0 : 1; break;
default: return;
}
rdconf_persist();
}
/* 打开一个 SELECT 对话框并应用。 */
static void settings_open_choice(int item)
{
const rdconf *c = rdconf_get();
if (!c) return;
const char *title = g_labels[item];
meow_dialog *d = meow_dialog_create(MEOW_DLG_SELECT, title);
if (!d) return;
const char *opts[16]; int n = 0; int cur = 0;
static char numopts[16][8];
if (item == ITEM_FS) {
for (int v = 14; v <= 40; v += 2) { snprintf(numopts[n], sizeof numopts[n], "%d", v); opts[n] = numopts[n]; n++; }
} else if (item == ITEM_LS) {
for (int v = 100; v <= 200; v += 10) { snprintf(numopts[n], sizeof numopts[n], "%d", v); opts[n] = numopts[n]; n++; }
} else if (item == ITEM_BG) {
static const char *b[4] = { "黑", "白", "米黄", "自动" };
for (int i = 0; i < 4; i++) opts[n++] = b[i];
} else if (item == ITEM_THEME) {
static const char *t[3] = { "跟随系统", "日", "夜" };
for (int i = 0; i < 3; i++) opts[n++] = t[i];
} else if (item == ITEM_SHELF) {
static const char *s[2] = { "列表", "宫格" };
for (int i = 0; i < 2; i++) opts[n++] = s[i];
} else if (item == ITEM_HDR || item == ITEM_FTR) {
static const char *h[2] = { "显示", "隐藏" };
for (int i = 0; i < 2; i++) opts[n++] = h[i];
}
cur = idx_for(item, opts, n, c);
meow_dialog_set_items(d, opts, n, cur);
meow_dialog_show(d);
int res = meow_dialog_result(d);
meow_dialog_free(d);
if (res < 0 || res >= n) return; /* 取消 */
if (item == ITEM_FS || item == ITEM_LS) apply_fs_ls(item, opts[res]);
else apply_choice(item, res);
settings_redraw();
}
static void settings_redraw(void)
{
const meow_theme *th = meow_theme_get();
meow_ui_clear(th->bg);
if (g_appbar) meow_widget_draw(g_appbar);
if (g_list) meow_widget_draw(g_list);
meow_ui_flush();
}
/* view 生命周期 */
static void settings_on_create(meow_view *v, void *ctx) { (void)v; (void)ctx; }
static void settings_on_enter(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
g_active = 1;
int sbh = meow_ui_statusbar_height();
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
g_appbar = meow_topappbar_create(0, sbh, 480, SET_APPBAR_H, "阅读设置");
if (g_appbar) meow_topappbar_set_back(g_appbar, 1);
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
int ly = sbh + SET_APPBAR_H + 8;
g_list = meow_listview_create(SET_X, ly, SET_W, 720 - ly, ITEM_COUNT,
settings_src, NULL, MEOW_LIST_SEL_SINGLE);
if (g_list) {
meow_listview_set_container(g_list, 1);
meow_listview_set_item_tier(g_list, 2);
}
settings_redraw();
}
static void settings_on_leave(meow_view *v, void *ctx) { (void)v; (void)ctx; g_active = 0; }
static void settings_on_destroy(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
}
meow_view *settings_view_create(void)
{
static meow_view_hooks hooks = { settings_on_create, settings_on_enter, settings_on_leave,
settings_on_destroy, NULL, NULL, NULL };
return meow_view_create(ACT_SETTINGS, MEOW_VIEW_ACTIVITY, &hooks, NULL);
}
int settings_is_active(void) { return g_active; }
void settings_reload(void) { if (g_active) settings_redraw(); }
int settings_on_tap(int x, int y)
{
/* 顶栏返回 */
if (g_appbar) {
int bi = meow_topappbar_hit(g_appbar, x, y);
if (bi == MEOW_TOPBAR_BACK) { meow_view_page_pop(); return 1; }
if (bi >= 0) return 1;
}
if (g_list) {
int idx = meow_listview_tap_at(g_list, x, y);
if (idx >= 0 && idx < ITEM_COUNT) {
settings_open_choice(idx);
return 1;
}
if (idx >= 0) { settings_redraw(); return 1; }
}
return 0;
}
+265 -32
View File
@@ -1,19 +1,26 @@
/* app/shelf.c — 书架:扫 TF 卡 `Books/`,识别 TXT/FB2/EPUB/MOBI/CBZ(R1 仅 TXT 可开,
* 其它显示「R2 支持」占位);卡片列表 + 最近阅读置顶(按 progress ts 降序)。
* 用 W_LISTVIEW(结构化列表)+ App 自绘标题。空态=清屏提示。
* 其它显示「R2 支持」占位);MD3 布局(meow_topappbar + list/grid)+ 最近阅读置顶 + 段头分组。
*
* 布局(README §3.2,用户 2026-09-04 拍板):
* - 默认 list(meow_listview_set_container(on) MD3 圆角卡),可切 grid(meow_lazygrid 4 列封面卡);
* 布局存 reader.conf `ui.shelf_layout`(rdconf_get()->shelf);
* - 顶栏 meow_topappbar:标题「书架」+ 动作(布局切换 / 刷新 / App 设置入口);
* - 无书也渲染完整骨架(顶栏 + 内容区 + 居中空态卡 + 指引 + 支持格式);
* - 封面:MVP 无封面提取(engine F1);无则圆角占位(surfaceContainerHigh + 格式徽标)。
*/
#include "../reader_internal.h"
#include "progress.h"
#include "reader_conf.h"
#include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#define SHELF_APPBAR_H 56
#define SHELF_LIST_X 0
#define SHELF_LIST_Y 64
#define SHELF_LIST_W 480
#define SHELF_LIST_H (720 - 64)
typedef struct shelf_book {
char path[400];
@@ -27,11 +34,21 @@ typedef struct shelf_book {
static shelf_book g_books[256];
static int g_nbooks;
static int g_active;
static meow_widget *g_list;
static meow_widget *g_list; /* listview(布局=list) */
static meow_lazygrid *g_grid; /* lazygrid(布局=grid) */
static meow_widget *g_appbar; /* meow_topappbar */
static int g_layout; /* 0=list 1=grid(读 rdconf->shelf) */
static uint32_t g_bg, g_fg, g_dim;
static char g_notice[160]; /* 空态/提示 */
static char g_notice[160]; /* 提示(opendir 失败等) */
static int g_notice_ms; /* 剩余显示 ms(0=无) */
/* 段头行(list 模式):把扫描结果排队为 [段头(fmt), 书, 书, ..., 段头, ...]。
* 书量小用段头(README §3.2),格式分组(TXT/FB2/EPUB/MOBI/CBZ)。 */
#define SHELF_MAX_ROWS 520
typedef struct shelf_row { char section; int fmt; int book; } shelf_row;
static shelf_row g_rows[SHELF_MAX_ROWS];
static int g_nrows;
static const char *fmt_name(int f)
{
switch (f) { case 0: return "TXT"; case 1: return "FB2"; case 2: return "EPUB";
@@ -96,28 +113,174 @@ static void scan_books(void)
qsort(g_books, (size_t)g_nbooks, sizeof(shelf_book), cmp_recent_then_name);
}
/* listview 行源 */
static int shelf_list_src(int idx, meow_list_item *out, void *ctx)
/* 排队段头(按格式):为 list 模式生成 [段头(fmt) | 书] 序列。 */
static void build_rows(void)
{
(void)ctx;
if (idx < 0 || idx >= g_nbooks) return 0;
const shelf_book *b = &g_books[idx];
out->icon_kind = MEOW_LIST_ICON_FILE;
out->title = b->title;
out->subtitle = b->sub;
out->right = MEOW_LIST_RIGHT_ARROW;
out->enabled = 1;
out->on = 0;
return 1;
g_nrows = 0;
static const int fmts[] = { 0, 1, 2, 3, 4 };
for (size_t fi = 0; fi < sizeof fmts / sizeof fmts[0]; fi++) {
int fmt = fmts[fi];
int have = 0;
if (g_nrows >= SHELF_MAX_ROWS) break;
for (int i = 0; i < g_nbooks; i++) {
if (g_books[i].fmt != fmt) continue;
if (!have) {
if (g_nrows >= SHELF_MAX_ROWS) break;
g_rows[g_nrows].section = 1; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = -1;
g_nrows++;
have = 1;
}
if (g_nrows >= SHELF_MAX_ROWS) break;
g_rows[g_nrows].section = 0; g_rows[g_nrows].fmt = fmt; g_rows[g_nrows].book = i;
g_nrows++;
}
}
}
/* ---- list 数据源(vsource + 段头;container on) ---- */
static int shelf_list_count(void *ctx) { (void)ctx; return g_nrows; }
static void shelf_list_bind(int idx, meow_lv_item *item, void *ctx)
{
(void)ctx;
item->icon = NULL;
item->icon_kind = MEOW_LIST_ICON_NONE;
item->right = MEOW_LIST_RIGHT_NONE;
item->on = 0;
item->enabled = 1;
item->user = NULL;
item->title = NULL;
item->subtitle = NULL;
item->section = 0;
if (idx < 0 || idx >= g_nrows) return;
if (g_rows[idx].section) {
item->section = 1;
item->title = fmt_name(g_rows[idx].fmt);
item->enabled = 0;
} else {
int bi = g_rows[idx].book;
if (bi < 0 || bi >= g_nbooks) return;
item->title = g_books[bi].title;
item->subtitle = g_books[bi].sub;
item->icon_kind = MEOW_LIST_ICON_FILE;
item->right = MEOW_LIST_RIGHT_ARROW;
}
}
static void shelf_list_tapcb(int idx, int op, void *ctx)
{
(void)op; (void)ctx;
if (idx < 0 || idx >= g_nrows || g_rows[idx].section) return;
int bi = g_rows[idx].book;
if (bi < 0 || bi >= g_nbooks) return;
reader_open(g_books[bi].path);
}
/* ---- grid 数据源(meow_lazygrid,4 列封面卡) ---- */
static int shelf_grid_count(void *ctx) { (void)ctx; return g_nbooks; }
static void shelf_grid_bind(int idx, meow_lazygrid_item *item, void *ctx)
{
(void)ctx;
item->title = NULL; item->subtitle = NULL; item->icon = NULL;
item->enabled = 1; item->user = NULL;
if (idx < 0 || idx >= g_nbooks) return;
item->title = g_books[idx].title;
item->subtitle = g_books[idx].sub;
}
/* 标题截断(格内防溢出):按字节收窄(UTF-8 截断可能断码,但仅显示用,断码由字库兜底为 tofu;
* 更稳妥在这里按格子宽度自适应——MVP 简单截 8 字节 + "…")。 */
static void grid_title_cut(const char *src, char *out, int outsz)
{
int n = 0;
while (src[n] && n < (int)sizeof g_books[0].title - 1 && n < 7 * 3) n++; /* ≤7 个 3 字节字 */
if ((size_t)n >= strlen(src)) { strncpy(out, src, (size_t)outsz - 1); out[outsz - 1] = 0; return; }
int m = n; while (m > 0 && ((unsigned char)src[m]) > 0x7F && m > n - 2) m--; /* 断码回退 */
if (m < 0) m = 0;
strncpy(out, src, (size_t)m); out[m] = 0;
size_t L = strlen(out);
if (L + 4 < (size_t)outsz) { out[L] = ' '; out[L + 1] = ' '; out[L + 2] = '.'; out[L + 3] = '.'; out[L + 4] = '.';
out[L + 5] = 0; }
}
static void shelf_grid_draw(int index, int x, int y, int w, int h, int state, void *ctx)
{
(void)ctx;
const meow_theme *th = meow_theme_get();
if (index < 0 || index >= g_nbooks) return;
const shelf_book *b = &g_books[index];
uint32_t card = th->surfaceContainerLow;
if (state & MEOW_LAZYGRID_ST_SELECTED) card = th->primaryContainer;
meow_gfx_fill_round(x, y, w, h, th->radius_card, card);
int pad = 8;
int cw = w - 2 * pad, chh = h - 2 * pad - meow_ui_text_height(16) - 6;
int cx = x + pad, cy = y + pad;
if (cw > 0 && chh > 0) {
meow_gfx_fill_round(cx, cy, cw, chh, MEOW_SHAPE_SMALL, th->surfaceContainerHigh);
/* 格式徽标(居中) */
char fc[16]; int fs = 24;
snprintf(fc, sizeof fc, "%s", fmt_name(b->fmt));
meow_ui_text_center(cx + cw / 2, cy + (chh - meow_ui_text_height(fs)) / 2, fc, fs, th->primary);
}
char t[128]; grid_title_cut(b->title, t, sizeof t);
meow_ui_text_center(x + w / 2, y + h - meow_ui_text_height(16) - 2, t, 16, th->onSurface);
if (state & MEOW_LAZYGRID_ST_SELECTED) {
meow_gfx_fill_round_alpha(x, y, w, h, th->radius_card, th->primary, 26);
}
}
static void shelf_grid_tapcb(int idx, void *ctx)
{
(void)ctx;
if (idx < 0 || idx >= g_nbooks) return;
reader_open(g_books[idx].path);
}
/* ---- 控件装配(按当前布局重建 list / grid) ---- */
static void shelf_build_content(void)
{
const meow_theme *th = meow_theme_get();
int sbh = meow_ui_statusbar_height();
int top = sbh + SHELF_APPBAR_H;
int list_y = top + 22; /* 副题区下 */
int list_h = 720 - list_y;
if (list_h < 0) list_h = 0;
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
if (g_grid) { meow_lazygrid_free(g_grid); g_grid = NULL; }
if (g_layout == 1) { /* grid */
int cols = 4, gap = 8;
int cw = (SHELF_LIST_W - (cols + 1) * 8) / cols; /* 16 边距 */
int chh = cw + 30; /* 封面 + 标题 */
g_grid = meow_lazygrid_create(SHELF_LIST_X, list_y, SHELF_LIST_W, list_h, cols, cw, chh, gap);
if (g_grid) {
meow_lazygrid_set_vsource(g_grid, shelf_grid_count, shelf_grid_bind, NULL);
meow_lazygrid_set_item_draw(g_grid, shelf_grid_draw, NULL);
meow_lazygrid_set_on_tap(g_grid, shelf_grid_tapcb, NULL);
}
} else { /* list */
g_list = meow_listview_create(SHELF_LIST_X, list_y, SHELF_LIST_W, list_h,
g_nrows, NULL, NULL, MEOW_LIST_SEL_SINGLE);
if (g_list) {
meow_listview_set_vsource(g_list, shelf_list_count, shelf_list_bind, NULL);
meow_listview_set_container(g_list, 1);
meow_listview_set_item_tier(g_list, 2);
meow_listview_set_on_tap(g_list, shelf_list_tapcb, NULL);
}
}
(void)th;
}
/* 顶栏动作:0=布局切换 1=刷新 2=设置。布局切换动作标签 = 将切换到的布局。 */
static void shelf_appbar_build(void)
{
int sbh = meow_ui_statusbar_height();
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
g_appbar = meow_topappbar_create(0, sbh, 480, SHELF_APPBAR_H, "书架");
if (!g_appbar) return;
meow_topappbar_add_action(g_appbar, g_layout ? "列表" : "宫格"); /* 0 布局切换 */
meow_topappbar_add_action(g_appbar, "刷新"); /* 1 刷新 */
meow_topappbar_add_action(g_appbar, "设置"); /* 2 设置 */
meow_topappbar_set_item_icon(g_appbar, 1, MEOW_ICON_REFRESH);
meow_topappbar_set_item_icon(g_appbar, 2, MEOW_ICON_SETTINGS);
}
/* view 生命周期 */
static void shelf_on_create(meow_view *v, void *ctx) { (void)v; (void)ctx; }
static void shelf_on_enter(meow_view *v, void *ctx)
@@ -125,13 +288,16 @@ static void shelf_on_enter(meow_view *v, void *ctx)
(void)v; (void)ctx;
g_active = 1;
scan_books(); /* 回到书架即重扫(新增/最近读置顶刷新) */
build_rows();
const meow_theme *th = meow_theme_get();
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
/* 无控件时重建列表(首次/回到书架) */
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
g_list = meow_listview_create(SHELF_LIST_X, SHELF_LIST_Y, SHELF_LIST_W, SHELF_LIST_H,
g_nbooks, shelf_list_src, NULL, MEOW_LIST_SEL_SINGLE);
meow_listview_set_on_tap(g_list, shelf_list_tapcb, NULL);
/* 布局:读 reader.conf ui.shelf_layout(A) */
const rdconf *c = rdconf_get();
g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0;
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
}
static void shelf_on_leave(meow_view *v, void *ctx) { (void)v; (void)ctx; g_active = 0; }
@@ -139,6 +305,8 @@ static void shelf_on_destroy(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
if (g_list) { meow_widget_free(g_list); g_list = NULL; }
if (g_grid) { meow_lazygrid_free(g_grid); g_grid = NULL; }
if (g_appbar) { meow_widget_free(g_appbar); g_appbar = NULL; }
}
meow_view *shelf_view_create(void)
@@ -150,20 +318,53 @@ meow_view *shelf_view_create(void)
int shelf_is_active(void) { return g_active; }
/* 切换 list/grid(书架顶栏「布局切换」动作;改 reader.conf 并重建)。 */
void shelf_set_view(int grid)
{
rdconf *c = rdconf_get();
if (!c) return;
strcpy(c->shelf, grid ? "grid" : "list");
rdconf_persist();
g_layout = grid ? 1 : 0;
if (g_active) {
shelf_appbar_build();
shelf_build_content();
shelf_redraw();
}
}
void shelf_redraw(void)
{
const meow_theme *th = meow_theme_get();
g_bg = th->bg; g_fg = th->fg; g_dim = th->dim;
meow_ui_clear(g_bg);
meow_ui_text(20, 22, "书架", 28, g_fg);
/* 副标题:格式化说明 */
char sub[96]; snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
meow_ui_text(20, 24 + 34, sub, 20, g_dim);
if (g_appbar) meow_widget_draw(g_appbar);
/* 副题:Books/ · N 本(有书时显示格式说明;无书并入空态卡) */
if (g_nbooks > 0) {
char sub[96]; snprintf(sub, sizeof sub, "Books/ · %d 本", g_nbooks);
int sbh = meow_ui_statusbar_height();
meow_ui_text(24, sbh + SHELF_APPBAR_H + 3, sub, 16, g_dim);
}
if (g_nbooks > 0) {
if (g_list) meow_widget_draw(g_list);
if (g_nbooks == 0) {
meow_ui_text(30, 320, "书架为空:请往 TF 卡 Books/ 放书籍", 20, g_dim);
meow_ui_text(30, 348, "支持 TXT / FB2 / EPUB / MOBI / CBZ", 20, g_dim);
if (g_grid) meow_lazygrid_draw(g_grid, th);
} else {
/* 无书也渲染完整骨架(顶栏已在)+ 居中空态卡 + 指引 + 支持格式 */
int sbh = meow_ui_statusbar_height();
int top = sbh + SHELF_APPBAR_H;
int area_h = 720 - top;
int cw = 360, chh = 200;
int cx = (480 - cw) / 2;
int cy = top + (area_h - chh) / 2;
if (cy < top) cy = top;
meow_gfx_fill_round(cx, cy, cw, chh, th->radius_card, th->surfaceContainer);
meow_ui_text_center(480 / 2, cy + 40, "书架为空", 28, th->fg);
meow_ui_text_center(480 / 2, cy + 76, "请往 TF 卡 Books/ 放书籍", 20, th->onSurfaceVariant);
meow_ui_text_center(480 / 2, cy + 104, "支持 TXT / FB2 / EPUB / MOBI / CBZ", 20, th->onSurfaceVariant);
/* 「去放书」文本按钮(提示) */
meow_ui_text_center(480 / 2, cy + 150, "Books/ 目录", 20, th->primary);
}
if (g_notice_ms > 0) {
meow_ui_fill_rect(0, 620, 480, 44, g_bg);
@@ -174,9 +375,41 @@ void shelf_redraw(void)
int shelf_on_tap(int x, int y)
{
if (g_list && g_nbooks > 0) {
/* 顶栏动作先命中 */
if (g_appbar) {
int bi = meow_topappbar_hit(g_appbar, x, y);
if (bi == 0) { /* 布局切换 */
shelf_set_view(g_layout ? 0 : 1);
return 1;
} else if (bi == 1) { /* 刷新:重扫 */
scan_books(); build_rows();
shelf_build_content();
shelf_redraw();
return 1;
} else if (bi == 2) { /* App 设置入口 */
meow_view_page_push(ACT_SETTINGS);
return 1;
}
}
if (g_nbooks <= 0) { shelf_redraw(); return 0; }
if (g_layout == 1 && g_grid) {
int idx = meow_lazygrid_tap_at(g_grid, x, y);
if (idx >= 0) shelf_redraw();
return 0;
}
if (g_list) {
int idx = meow_listview_tap_at(g_list, x, y);
if (idx >= 0) { shelf_redraw(); return 1; } /* 选中已由 SDK 标脏;重绘 */
if (idx >= 0) shelf_redraw();
return 0;
}
return 0;
}
/* 书架的滚动(list/grid;app.c on_swipe 路由到本页时调用)。 */
void shelf_on_swipe(int dir, int dx, int dy)
{
(void)dir; (void)dx;
if (g_nbooks <= 0) return;
if (g_layout == 1 && g_grid) { meow_lazygrid_scroll_by(g_grid, dy); meow_lazygrid_draw(g_grid, meow_theme_get()); meow_ui_flush(); }
else if (g_list) { meow_listview_scroll_by(g_list, dy); meow_widget_draw(g_list); meow_ui_flush(); }
}
+2 -2
View File
@@ -131,8 +131,8 @@ timeout 150 qemu-mipsel ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiSa
echo "==> reader binary"
CORE_L="core/rdenc.c core/rdlayout.c core/rdtxt.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c"
ENGINE_L="engine/rdft.c"
APP_L="app.c app/shelf.c app/reader.c app/book.c app/progress.c"
$CC -Os -static -Wall -Wextra -I../sdk -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \
APP_L="app.c app/shelf.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c"
$CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \
$CORE_L $APP_L $ENGINE_L "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o \
../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o reader
ls -la reader
+13
View File
@@ -11,12 +11,15 @@
#define ACT_SHELF 0
#define ACT_READER 1
#define ACT_SETTINGS 2
/* ---- 书架(app/shelf.c)---- */
meow_view *shelf_view_create(void);
int shelf_is_active(void);
int shelf_on_tap(int x, int y);
void shelf_on_swipe(int dir, int dx, int dy);
void shelf_redraw(void);
void shelf_set_view(int grid); /* 1=grid 0=list;读 reader.conf ui.shelf_layout(A) */
/* ---- 阅读页(app/reader.c)---- */
meow_view *reader_view_create(void);
@@ -30,6 +33,16 @@ void reader_on_touch_move(int x, int y);
/* 打开一本书(书架调;成功进入阅读页)。path=TXT 完整路径。 */
void reader_open(const char *path);
/* ---- 全局设置页(app/settings.c,C)---- */
meow_view *settings_view_create(void);
int settings_is_active(void);
int settings_on_tap(int x, int y);
void settings_reload(void); /* 改配置后让设置页/其它页重读 reader.conf */
/* 系统翻页信号(app/reader_ipc.c 后台线程收到 MSG_KEY_PAGEUP/PAGEDOWN 后调;
* 仅置 pending 计数,UI 线程 reader_on_tick 消费转 reader_turn_animated,E)。 */
void reader_page_key(int dir); /* dir=+1 下一页;-1 上一页 */
/* 返回可执行文件所在包根(用于定位 /usr/data/mnt/sd_0)——由 app.c 注入与否;
* R1 用固定 TF 根 /usr/data/mnt/sd_0。 */
#define READER_SD_ROOT "/usr/data/mnt/sd_0"
+4 -1
View File
@@ -103,7 +103,10 @@ int meow_select_open(meow_select *s)
meow_ui_modal_capture();
/* 首次绘制:遮罩 + 底部面板(顶部圆角)+ 标题 + 选项列表。 */
meow_ui_fill_rect_alpha(0, 0, W, H, 0x000000, 150);
meow_gfx_fill_round(0, py, W, ph, th->radius_card, th->surfaceContainerHighest);
/* MD3 sheet 顶角 = corner-extra-large-top(28)(material-web _md-comp-sheet-bottom.scss),
* 与 Dialog 一致;同 SDK meow_sheet(meow_ui_dialog.c:361)/dialog 卡(meow_ui_dialog.c:103) 取 28,
* 底部面板统一 extra-large,避免同 SDK 两个 bottom sheet 顶部圆角不一致(旧 th->radius_card=16)。 */
meow_gfx_fill_round(0, py, W, ph, MEOW_SHAPE_EXTRA_LARGE, th->surfaceContainerHighest);
meow_ui_text(pad, py + pad, s->title, th->font_title, th->onSurface);
meow_widget_draw(lv);
meow_ui_flush();
+4 -3
View File
@@ -134,12 +134,13 @@ static void dlg_card(meow_dialog *d, const meow_theme *th, const dlg_geom *g)
y += meow_ui_text_height(th->font_body) + 8;
}
}
/* 取消(左,text 型)/ 确定(右,filled) */
/* 取消(左,outlined)/ 确定(右,filled)——MD3 按钮统一为胶囊(full,全高一半),
* 与 SDK widget_btn_radius(meow_ui_widget.c:146) 同款;旧硬编码 radius 10 非 token。 */
int bx = g->dx + pad, cx = bx + g->btn_w + 12, by = g->btn_y;
meow_ui_draw_rect_round(bx, by, g->btn_w, g->btn_h, 10, th->primary);
meow_ui_draw_rect_round(bx, by, g->btn_w, g->btn_h, MEOW_SHAPE_FULL(g->btn_h), th->primary);
meow_ui_text_center(bx + g->btn_w / 2, by + (g->btn_h - meow_ui_text_height(th->font_body)) / 2,
meow_str("common.cancel"), th->font_body, th->primary);
meow_gfx_fill_round(cx, by, g->btn_w, g->btn_h, 10, th->primary);
meow_gfx_fill_round(cx, by, g->btn_w, g->btn_h, MEOW_SHAPE_FULL(g->btn_h), th->primary);
meow_ui_text_center(cx + g->btn_w / 2, by + (g->btn_h - meow_ui_text_height(th->font_body)) / 2,
meow_str("common.ok"), th->font_body, th->onPrimary);
}
+4 -2
View File
@@ -111,10 +111,12 @@ static void test_select_highlight(void)
pthread_t th; pthread_create(&th, NULL, sel_driver, &a);
/* 等 open 的 drain + 首帧绘制完成(高亮已画进 back_buf) */
usleep(150000);
/* 单选高亮:cur=2(第 3 行)选中背景 = primaryContainer */
/* 单选高亮:cur=2(第 3 行)选中背景 = primaryContainer。
* meow_listview 默认容器模式(行=surfaceContainer 12dp 圆角卡 + MEOW_LISTV_CARD_MARGIN=8 横向内缩),
* 选中卡左缘=8、文本左缘=24;采样点取 x=10(卡内、文本左侧),避免取到左侧空白边距(旧 x=6 落在卡外)。 */
const meow_theme *thm = meow_theme_get();
int pc_r, pc_g, pc_b; UNPK(thm->primaryContainer, pc_r, pc_g, pc_b);
int hx = 6, hy = list_y + 2 * SEL_ROW + SEL_ROW / 2;
int hx = 10, hy = list_y + 2 * SEL_ROW + SEL_ROW / 2;
int r, g, b; rd_px(hx, hy, &r, &g, &b);
CHECK(near2(r, pc_r) && near2(g, pc_g) && near2(b, pc_b),
"T1 highlight row2 primaryContainer got %02X%02X%02X", r, g, b);