diff --git a/MEOW_APP_SDK.md b/MEOW_APP_SDK.md index 7f1b71b..b00f871 100644 --- a/MEOW_APP_SDK.md +++ b/MEOW_APP_SDK.md @@ -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 追加) diff --git a/patch_v2/meow_launcherd/app_launcher.c b/patch_v2/meow_launcherd/app_launcher.c index 20744f8..729bffb 100644 --- a/patch_v2/meow_launcherd/app_launcher.c +++ b/patch_v2/meow_launcherd/app_launcher.c @@ -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 会 * 滞留 "",使主页 fg_keep_awake()==1 不再自动息屏。此处兜底清空(App 已退出/死亡, diff --git a/patch_v2/meow_launcherd/keys.c b/patch_v2/meow_launcherd/keys.c index 539e7d6..619209c 100644 --- a/patch_v2/meow_launcherd/keys.c +++ b/patch_v2/meow_launcherd/keys.c @@ -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 内调用) */ + +/* 读 //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; +} + diff --git a/patch_v2/meow_launcherd/meow_ipc.h b/patch_v2/meow_launcherd/meow_ipc.h index 027160b..b263b7a 100644 --- a/patch_v2/meow_launcherd/meow_ipc.h +++ b/patch_v2/meow_launcherd/meow_ipc.h @@ -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) */ diff --git a/patch_v2/meow_launcherd/meow_launcher_internal.h b/patch_v2/meow_launcherd/meow_launcher_internal.h index 4df5d43..7677d04 100644 --- a/patch_v2/meow_launcherd/meow_launcher_internal.h +++ b/patch_v2/meow_launcherd/meow_launcher_internal.h @@ -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); diff --git a/patch_v2/meow_launcherd/util.c b/patch_v2/meow_launcherd/util.c index cafe692..a3f00e7 100644 --- a/patch_v2/meow_launcherd/util.c +++ b/patch_v2/meow_launcherd/util.c @@ -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; diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md index 2d08b8a..c6c4fa4 100644 --- a/patch_v2/meowpanel/README.md +++ b/patch_v2/meowpanel/README.md @@ -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://.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 `;**写级**,PIN 会话) | ✅(包名非法→400,launcher 离线→503) | +| POST | `/api/ams/:pkg/kill` | 结束 App(`am kill `;**写级**,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://.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":,"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 `;包名经 `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://.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),未提供注入按钮(遥控板附说明)。 diff --git a/patch_v2/meowpanel/src/mp.h b/patch_v2/meowpanel/src/mp.h index 5929473..e5da5d7 100644 --- a/patch_v2/meowpanel/src/mp.h +++ b/patch_v2/meowpanel/src/mp.h @@ -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); diff --git a/patch_v2/meowpanel/src/mp_net.c b/patch_v2/meowpanel/src/mp_net.c index f2f4cc9..51783f4 100644 --- a/patch_v2/meowpanel/src/mp_net.c +++ b/patch_v2/meowpanel/src/mp_net.c @@ -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; diff --git a/patch_v2/meowpanel/src/mp_power.c b/patch_v2/meowpanel/src/mp_power.c index c4b3c24..c23cb68 100644 --- a/patch_v2/meowpanel/src/mp_power.c +++ b/patch_v2/meowpanel/src/mp_power.c @@ -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"); diff --git a/patch_v2/meowpanel/src/mp_server.c b/patch_v2/meowpanel/src/mp_server.c index 8bf9d61..3d09c28 100644 --- a/patch_v2/meowpanel/src/mp_server.c +++ b/patch_v2/meowpanel/src/mp_server.c @@ -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; } diff --git a/patch_v2/meowpanel/src/mp_store.c b/patch_v2/meowpanel/src/mp_store.c index a69368c..dab5047 100644 --- a/patch_v2/meowpanel/src/mp_store.c +++ b/patch_v2/meowpanel/src/mp_store.c @@ -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); diff --git a/patch_v2/meowpanel/web/mp_pages.js b/patch_v2/meowpanel/web/mp_pages.js index d959bcc..b8c08a3 100644 --- a/patch_v2/meowpanel/web/mp_pages.js +++ b/patch_v2/meowpanel/web/mp_pages.js @@ -609,21 +609,24 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode function powerPolicyCard(pol) { if (!pol) return '

电源策略

' + homeUnavailable() + '
'; + const pwe = !(pol.policy_write_enabled === false); // 缺省视为已开启(兼容旧 powermgr 不带该键) const profiles = ['performance', 'balanced', 'powersave', 'ultra']; const cur = pol.profile || 'balanced'; - let btns = profiles.map(p => '').join(' '); - return '

电源策略

' + - '
省电档位:' + btns + '
' + - '' + - row('低电阈值', ' %') + - row('临界阈值', ' %') + - row('亮度上限', '0=不生效') + - row('息屏上限', '秒 · 0=不生效') + + let h = '

电源策略

'; + if (!pwe) h += '
🔒策略写默认关闭(panel.policy_enabled=0):当前只读,切档/阈值需先开启。
'; + const btns = profiles.map(p => '').join(' '); + h += '
省电档位:' + btns + '
'; + h += '
' + + row('低电阈值', ' %') + + row('临界阈值', ' %') + + row('亮度上限', '0=不生效') + + row('息屏上限', '秒 · 0=不生效') + row('状态', (pol.enacted ? '已手动开启' : '默认全关')) + - '
' + - '
切档即时生效(需确认);阈值点「保存阈值」。
' + - '

' + esc(pol.note || '') + '

' + - '
'; + ''; + h += '
切档即时生效(需确认);阈值点「保存阈值」。
'; + h += '

' + esc(pol.note || '') + '

'; + h += ''; + 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 ? ' ' + esc(e.slice(i + 1)) + '' : ''); } + 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 '

网络策略

' + homeUnavailable() + '
'; const enforce = pol.enforce || ''; const dnsOnly = enforce === 'dns_only'; + const pwe = !(pol.policy_write_enabled === false); // 缺省视为已开启(兼容旧 powermgr/netmgrd 不带该键) let h = '

网络策略

'; + if (!pwe) h += '
🔒策略写默认关闭(panel.policy_enabled=0):当前只读,阻断/放行/域名规则需先开启。
'; h += '
' + (dnsOnly ? 'ℹ️' : '⚠️') + '' + (dnsOnly ? '执行模式:仅 DNS 层拒绝(enforce=dns_only;本内核 SOCK_DESTROY 为 no-op,不会真正断连)' : '执行模式:' + esc(enforce)) + '
'; const rules = pol.rules || []; @@ -1117,21 +1136,24 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode else { h += ''; rules.forEach(r => { + const targetNet = r.net === 'block' ? 'allow' : 'block'; + const locked = targetNet === 'block' && netLockedEntity(r.entity); h += '' + '' + '' + '' + ''; + '' + + ''; }); h += '
实体网络白名单域名黑名单域名操作
' + netBadge(r.entity) + '' + (r.net === 'block' ? '阻断' : '放行') + '' + esc((r.allow_hosts || []).join(', ') || '—') + '' + esc((r.deny_hosts || []).join(', ') || '—') + '
' + - '' + - '
'; } h += '
' + - '' + - '
'; + '' + + '
'; h += '
' + - '
'; + ''; h += '

防自锁实体(dropbear/wpa_supplicant/udhcpc/netmgrd/svcd/sysui/init)拒 block。

'; h += ''; 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; } diff --git a/patch_v2/reader_app/app.c b/patch_v2/reader_app/app.c index 286a73e..973627c 100644 --- a/patch_v2/reader_app/app.c +++ b/patch_v2/reader_app/app.c @@ -6,10 +6,12 @@ */ #include "reader_internal.h" #include "meow_ui_internal.h" /* 阅读页 blit 需要 back_buf 访问器(决策见报告) */ +#include "app/reader_ipc.h" #include 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(); diff --git a/patch_v2/reader_app/app.json b/patch_v2/reader_app/app.json index 038b3eb..a93499f 100644 --- a/patch_v2/reader_app/app.json +++ b/patch_v2/reader_app/app.json @@ -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} diff --git a/patch_v2/reader_app/app/reader.c b/patch_v2/reader_app/app/reader.c index e664d7d..81592f9 100644 --- a/patch_v2/reader_app/app/reader.c +++ b/patch_v2/reader_app/app/reader.c @@ -9,6 +9,7 @@ #include "meow_ui_internal.h" /* back_buf/pitch/bpp 访问器(决策见报告) */ #include "book.h" #include "progress.h" +#include "reader_conf.h" #include #include #include @@ -17,7 +18,10 @@ #include #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(); - reader_reflow_persist(); - } - } - break; + case 2: /* 界面(阅读排版设置) */ + g_ui_open = 1; g_menu_open = 0; reader_redraw(); + return; + case 3: /* 设置 → App 设置页 */ + reader_reflow_persist(); + 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(); } } diff --git a/patch_v2/reader_app/app/reader_conf.c b/patch_v2/reader_app/app/reader_conf.c new file mode 100644 index 0000000..c12a640 --- /dev/null +++ b/patch_v2/reader_app/app/reader_conf.c @@ -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 +#include +#include +#include +#include + +/* 从 MEOW_DATA_DIR(/MeowData//data)剥掉尾 "/data",得到 /MeowData/。 */ +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; +} diff --git a/patch_v2/reader_app/app/reader_conf.h b/patch_v2/reader_app/app/reader_conf.h new file mode 100644 index 0000000..203ba5f --- /dev/null +++ b/patch_v2/reader_app/app/reader_conf.h @@ -0,0 +1,45 @@ +/* app/reader_conf.h — 阅读器 App 私有配置(reader.conf,key=value 文本)。 + * + * 存储域:/MeowData/os.meow.reader/reader.conf(复用 progress.c 的 meow_config_dir() + * 去皮 /data 派生包根;无 env/宿主回退 /tmp/meowcfg_/)。不进系统 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 + +#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 */ diff --git a/patch_v2/reader_app/app/reader_ipc.c b/patch_v2/reader_app/app/reader_ipc.c new file mode 100644 index 0000000..0c291d8 --- /dev/null +++ b/patch_v2/reader_app/app/reader_ipc.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/patch_v2/reader_app/app/reader_ipc.h b/patch_v2/reader_app/app/reader_ipc.h new file mode 100644 index 0000000..3565ab9 --- /dev/null +++ b/patch_v2/reader_app/app/reader_ipc.h @@ -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 */ diff --git a/patch_v2/reader_app/app/settings.c b/patch_v2/reader_app/app/settings.c new file mode 100644 index 0000000..94f4d2e --- /dev/null +++ b/patch_v2/reader_app/app/settings.c @@ -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 +#include +#include + +#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; +} diff --git a/patch_v2/reader_app/app/shelf.c b/patch_v2/reader_app/app/shelf.c index 587532a..d9af781 100644 --- a/patch_v2/reader_app/app/shelf.c +++ b/patch_v2/reader_app/app/shelf.c @@ -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 #include #include #include #include -#define SHELF_LIST_X 0 -#define SHELF_LIST_Y 64 -#define SHELF_LIST_W 480 -#define SHELF_LIST_H (720 - 64) +#define SHELF_APPBAR_H 56 +#define SHELF_LIST_X 0 +#define SHELF_LIST_W 480 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); - 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); + /* 副题: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_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(); } +} diff --git a/patch_v2/reader_app/build_reader.sh b/patch_v2/reader_app/build_reader.sh index aee28ec..06a12f5 100755 --- a/patch_v2/reader_app/build_reader.sh +++ b/patch_v2/reader_app/build_reader.sh @@ -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 diff --git a/patch_v2/reader_app/reader_internal.h b/patch_v2/reader_app/reader_internal.h index a746b38..42b3541 100644 --- a/patch_v2/reader_app/reader_internal.h +++ b/patch_v2/reader_app/reader_internal.h @@ -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" diff --git a/patch_v2/sdk/meow_select.c b/patch_v2/sdk/meow_select.c index 2a26d1f..054e577 100644 --- a/patch_v2/sdk/meow_select.c +++ b/patch_v2/sdk/meow_select.c @@ -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(); diff --git a/patch_v2/sdk/meow_ui_dialog.c b/patch_v2/sdk/meow_ui_dialog.c index 44dc4ec..79386ec 100644 --- a/patch_v2/sdk/meow_ui_dialog.c +++ b/patch_v2/sdk/meow_ui_dialog.c @@ -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); } diff --git a/patch_v2/sdk/test_meow_ui_select.c b/patch_v2/sdk/test_meow_ui_select.c index 3d6f0fd..08c0726 100644 --- a/patch_v2/sdk/test_meow_ui_select.c +++ b/patch_v2/sdk/test_meow_ui_select.c @@ -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);