图库: M5 设置页(阅读方向/PIN/主题状态)与启动 PIN 锁屏;阅读方向持久化(评审 P1)

logic/pin(纯 C):加盐迭代哈希(FNV-1a ×1000 轮 + 每次设置新随机盐,16 位 hex)经
meow_config_* 持久化 pin.enabled/pin.hash/pin.salt;提供 set/verify/change/disable 与
4-8 位纯数字校验;enabled=1 但盐/哈希残缺时 fail-closed;落盘半途失败会还原旧盐,不留
「新盐+旧哈希」的双失效状态。

ui/settings(ACT_SETTINGS):MD3 分组表单(meow_listview 容器圆角卡 + 段头标签)——
阅读方向 RTL/LTR 走 mr_reader_set_direction;启动 PIN 开关与修改(开启/修改均两遍
MEOW_IME_NUM 确认);版本与主题状态行(跟随系统 + 当前明暗,随 app.c 主题 listener
整帧重绘)。控件一律在 on_enter 建(栈顶=ACT_SETTINGS),避免 §6.1 owner 绑到
ACT_LIBRARY 的跨页命中泄漏。

ui/lock(ACT_LOCK):lock_view_should_show() 读 logic/pin;启用时启动先入锁屏,验证前
不绘制任何书库内容;「输入 PIN」按钮弹系统数字键盘(MEOW_IME_NUM),错 PIN 就地提示
重试(无惩罚性锁定),正确则 page_pop 回书库;on_event 消费 SWIPE——左缘返回不能绕过
PIN 或退出 App(meow_view_dispatch_event 先于 app.c 的 on_swipe)。

logic/reader(评审 P1 修复,最小持久化):mr_reader_set_direction 成功切换后 best-effort
写 meow_config("reader.dir","rtl"/"ltr"),写失败只打 WARN 不阻断本次切换;
mr_reader_get_direction 首次调用惰性读一次配置恢复(无配置/非法值 → 默认 RTL),之后
纯内存快路径。宿主测试经 -DMR_READER_TEST_HOOKS 的 mr_reader_test_reset() 模拟重启。

测试/构建:test_pin(85 项,PIN 全分支,含单键半途落盘失败);test_settings_reader_persist
(19 项:默认/落盘/重启恢复/坏值/非法值/只读一次/写失败只告警);可选宿主集成
test_settings_lock_host(真实 SDK view/activity/widget + IME 注入,端到端验证错/对 PIN
解锁与设置页两遍开启;SDK 无源码时 SKIP);run_tests.sh 保持 46/46 并纳入上述测试
(reader.c 的配置/日志依赖由 test/settings_reader_stub.c 存根),build_tuku.sh 加入
logic/pin.c。交叉构建静态 MIPS ELF(zig-mipsel)零警告。

安全口径:PIN 防随手翻看,不是安全边界——TF 卡可拔出直读、配置可改;存储不使用加密库,
锁屏与设置页文案均如实标注该局限。
This commit is contained in:
AskaEth committed 2026-11-04 21:25:17 +08:00
1 parent 47325f2b81
commit cca3906c80
12 files changed
+1749 -60

No files matched your search

+415 -27
View File
@@ -1,45 +1,420 @@
/* ui/settings/settings.c — 设置页(ACT_SETTINGS)M3 骨架。
/* ui/settings/settings.c — 设置页(ACT_SETTINGS):MD3 分组表单。
*
* M5 在此实现 APP 启动 PIN 等设置项;本批只落可编译桩,入口为书库 TopAppBar ⚙。
* 返回:左缘返回(app.c SWIPE_LEFT → page_pop)。
* 内容(用户原话「允许用户在APP设置中设置APP启动 PIN」+「同步系统主题状态」):
* 阅读:阅读方向 RTL/LTR —— 直接读写 M3 的 mr_reader_get/set_direction(不另立状态);
* 安全:启动 PIN 开关(开启需两遍 MEOW_IME_NUM 输入确认)、修改 PIN;
* 关于:版本号、主题状态(跟随系统 + 当前明暗,随系统实时同步)。
*
* 接缝注意(M5):meow_view 的 on_create 在 meow_view_create 内运行(activity 栈空);
* 本页是非根页,控件必须 lazy 到 on_enter 建,或照 hello demo_init 的模式
* meow_ui_activity_push(ACT_SETTINGS) → 建控件 → meow_ui_activity_pop()
* (examples/hello/ui/demo/demo.c:515-522);否则 owner 绑到 ACT_LIBRARY,设置控件会从
* 书库页被命中(§6.1 跨页泄漏)。
* 观感/主题:SDK 的 meow_listview 容器模式(MD3 surfaceContainer 圆角卡 + 行间空隙)
* + 段头标签;开关/箭头由 SDK renderer 用主题语义色绘制;所有颜色取 meow_theme_get(),
* 不缓存颜色——app.c 的主题 listener 整帧 invalidate,本页 on_draw/src 回调下一帧即用新色。
*
* 控件归属(§6.1,M3 桩注释①):本页非根页,控件**必须**在 on_enter 里建(此时
* activity 栈顶=ACT_SETTINGS),否则 owner 会绑到 ACT_LIBRARY 造成跨页命中。
*
* 返回:左缘返回由 app.c 的 on_swipe 统一 page_pop(本页 on_event 不消费 SWIPE)。
* PIN 输入:SDK 系统数字键盘(MEOW_IME_NUM),不自绘键盘;文本经 on_change 实时收集,
* 「非空 + 关闭键盘 = 校验、空 = 取消」(SDK 阻塞 IME 的返回值不能当确认判据,见
* settings_read_pin 注释);键盘打开期间步骤提示画在键盘面板上方(置顶 widget)。
*/
#include "tuku_internal.h"
#include "logic/pin.h"
#include <stdio.h>
#include <string.h>
#define ST_TOP_H 56
#define ST_SEC_H 34
#define ST_ROW_H 64
#define ST_GAP 12
/* meow_view on_event 的事件码:公共伞头未导出(定义在 sdk/meow_ui_internal.h:
* MEOW_EV_TAP=2 / MEOW_EV_SWIPE=3,值稳定)。此处按契约镜像,避免 App include SDK 内部头。
* (已在交付报告里作为 SDK 缺口提出:建议公共头导出事件枚举。) */
#define MR_VEV_TAP 2
/* PIN 行下标(g_lv_pin) */
#define ST_PIN_ROW_SWITCH 0
#define ST_PIN_ROW_CHANGE 1
static mr_page_ctrl g_ctrl;
static meow_widget *g_tb;
static meow_widget *g_lv_dir; /* 阅读:1 行 */
static meow_widget *g_lv_pin; /* 安全:2 行 */
static meow_widget *g_lv_about; /* 关于:2 行 */
static meow_widget *g_overlay; /* 置顶提示条(仅拨号键盘期间显示步骤文案) */
static int g_yl_dir, g_yl_pin, g_yl_about; /* 段头标签 y */
static int g_note_y;
static int g_ov_y, g_ov_h; /* 提示条几何(App 自记;widget 句柄不透明) */
static char g_note[96];
static int g_note_err;
static int g_pin_on; /* 缓存的 PIN 启用态(避免每帧读 3 个小配置文件的 TF IO) */
static char g_theme_line[64];
static char g_prompt[64]; /* 非空=正在输入 PIN(键盘打开期间顶部提示条) */
static char g_ime_pin[MR_PIN_MAX_LEN + 12];
/* ============================================================ */
/* 文案/状态 */
/* ============================================================ */
static int theme_is_dark(const meow_theme *th)
{
int r = (int)((th->surface >> 16) & 0xFF);
int g = (int)((th->surface >> 8) & 0xFF);
int b = (int)(th->surface & 0xFF);
return (r * 299 + g * 587 + b * 114) / 1000 < 128;
}
static void theme_line_update(void)
{
const meow_theme *th = meow_theme_get();
meow_theme_mode m = meow_theme_get_mode();
const char *mode = (m == MEOW_THEME_AUTO) ? "跟随系统"
: (m == MEOW_THEME_DARK) ? "固定深色" : "固定浅色";
snprintf(g_theme_line, sizeof g_theme_line, "%s · 当前%s", mode,
theme_is_dark(th) ? "深色" : "浅色");
}
static void settings_note(const char *msg, int is_err)
{
snprintf(g_note, sizeof g_note, "%s", msg ? msg : "");
g_note_err = is_err;
}
static void settings_repaint(void)
{
meow_ui_invalidate(0, 0, meow_ui_width(), meow_ui_height());
}
/* ============================================================ */
/* PIN 输入(系统数字键盘) */
/* ============================================================ */
static void ime_pin_change(const char *text, void *ctx)
{
(void)ctx;
snprintf(g_ime_pin, sizeof g_ime_pin, "%s", text ? text : "");
}
/* 阻塞弹出 MEOW_IME_NUM 收集 4-8 位数字。返回 1=收到非空文本;0=空(视为取消)。
* 说明(SDK 现状,如实记录):阻塞 IME 只以「关闭键盘」结束;数字键盘的「确定」只置内部
* submit 标记而不关闭,随后「取消/点背景」关闭时该标记又被清 0(sdk/meow_ui_ime.c 的
* K_ESC/背景路径),故 meow_ui_text_input 的返回值不可作为「确认」判据。本页改用
* on_change 实时收集文本:非空 + 关闭键盘 = 校验;空 = 取消(提示条照此告知用户)。
* 键盘面板覆盖屏底(y≥420),步骤提示画在其上方,否则打字时看不见当前步骤。 */
static int settings_read_pin(const char *prompt, char *out, int outsz)
{
meow_ime_ctx c;
memset(&c, 0, sizeof c);
c.mode = MEOW_IME_NUM;
c.maxlen = (int)sizeof g_ime_pin;
c.on_change = ime_pin_change;
c.ctx = NULL;
g_ime_pin[0] = '\0';
settings_note("", 0);
snprintf(g_prompt, sizeof g_prompt, "%s", prompt);
settings_repaint(); /* 先画提示条(键盘区背景快照从当前帧取) */
(void)meow_ui_text_input(&c);
g_prompt[0] = '\0';
snprintf(out, (size_t)outsz, "%s", g_ime_pin);
return out[0] != '\0';
}
/* 两遍输入(开启/修改共用):1=一致且合法(写入 out);0=取消/不一致/非法(已置 note)。 */
static int settings_pin_two_pass(const char *prompt, char *out, int outsz)
{
char a[32] = "", b[32] = "";
if (!settings_read_pin(prompt, a, (int)sizeof a)) {
settings_note("已取消", 0);
return 0;
}
if (!mr_pin_valid(a)) {
settings_note("PIN 需为 4-8 位数字,未保存", 1);
return 0;
}
if (!settings_read_pin("再次输入以确认", b, (int)sizeof b)) {
settings_note("已取消", 0);
return 0;
}
if (strcmp(a, b) != 0) {
settings_note("两次输入不一致,未保存", 1);
return 0;
}
snprintf(out, (size_t)outsz, "%s", a);
return 1;
}
static void settings_toggle_pin(void)
{
if (!g_pin_on) {
char pin[32] = "";
if (!settings_pin_two_pass("设置启动 PIN(4-8 位数字)", pin, (int)sizeof pin)) return;
int r = mr_pin_set(pin);
if (r == MR_PIN_OK) settings_note("启动 PIN 已开启 · 下次启动生效", 0);
else settings_note("保存失败:数据目录不可写", 1);
g_pin_on = mr_pin_enabled();
return;
}
int r = mr_pin_disable();
if (r == MR_PIN_OK) settings_note("启动 PIN 已关闭", 0);
else settings_note("关闭失败:配置不可写", 1);
g_pin_on = mr_pin_enabled();
}
static void settings_change_pin(void)
{
if (!g_pin_on) {
settings_note("请先开启启动 PIN", 0);
return;
}
char pin[32] = "";
if (!settings_pin_two_pass("设置新 PIN(4-8 位数字)", pin, (int)sizeof pin)) return;
int r = mr_pin_set(pin); /* 重设即换新盐;旧 PIN 失效 */
if (r == MR_PIN_OK) settings_note("PIN 已更新 · 下次启动生效", 0);
else settings_note("保存失败:数据目录不可写", 1);
g_pin_on = mr_pin_enabled();
}
/* ============================================================ */
/* 行数据源(src2:每帧重取 → 开关/副标题随状态即时刷新) */
/* ============================================================ */
static int src_dir(int idx, meow_list_item *it, void *ctx)
{
(void)ctx;
if (idx != 0) return 0;
memset(it, 0, sizeof *it);
int rtl = mr_reader_get_direction() == MR_DIR_RTL;
it->title = "阅读方向";
it->subtitle = rtl ? "右到左 · 日式漫画(RTL)" : "左到右 · 常规漫画(LTR)";
it->right = MEOW_LIST_RIGHT_SWITCH;
it->on = rtl;
it->enabled = 1;
return 1;
}
static int src_pin(int idx, meow_list_item *it, void *ctx)
{
(void)ctx;
memset(it, 0, sizeof *it);
int on = g_pin_on;
if (idx == ST_PIN_ROW_SWITCH) {
it->title = "启动 PIN";
it->subtitle = on ? "已启用 · 启动时先输入 PIN"
: "未设置 · 启动直接进入书库";
it->right = MEOW_LIST_RIGHT_SWITCH;
it->on = on;
it->enabled = 1;
return 1;
}
if (idx == ST_PIN_ROW_CHANGE) {
it->title = "修改 PIN";
it->subtitle = on ? "重新设置 4-8 位数字" : "开启后可修改";
it->right = MEOW_LIST_RIGHT_ARROW;
it->enabled = on; /* 未启用时变淡且不响应命中 */
return 1;
}
return 0;
}
static int src_about(int idx, meow_list_item *it, void *ctx)
{
(void)ctx;
memset(it, 0, sizeof *it);
if (idx == 0) {
it->title = "版本";
it->subtitle = "0.1 · os.meow.picbrowser";
it->enabled = 1;
return 1;
}
if (idx == 1) {
theme_line_update();
it->title = "主题";
it->subtitle = g_theme_line;
it->enabled = 1;
return 1;
}
return 0;
}
/* ============================================================ */
/* 布局 */
/* ============================================================ */
/* 置顶提示条(z=MEOW_Z_TOP):键盘面板从屏底覆盖,步骤文案必须画在其上方;
* 仅 g_prompt 非空时绘制,否则透明不挡顶栏。
* 注:meow_widget 对 App 不透明(公共头只给句柄),坐标由本文件自己记录。 */
static void settings_overlay_render(meow_widget *w, const meow_theme *th, void *ctx)
{
(void)w; (void)ctx;
if (!g_prompt[0]) return;
int wd = meow_ui_width();
meow_ui_fill_rect(0, g_ov_y, wd, g_ov_h, th->surfaceContainerHighest);
meow_ui_text_center(wd / 2, g_ov_y + 28, g_prompt, th->font_title, th->onSurface);
meow_ui_text_center(wd / 2, g_ov_y + 76, "输入后关闭键盘即校验 · 空输入=取消",
th->font_body, th->onSurfaceVariant);
meow_ui_fill_rect(0, g_ov_y + g_ov_h - 1, wd, 1, th->outlineVariant);
}
static void settings_build(void)
{
/* 控件先以占位矩形创建,统一由 settings_place() 摆位(避免两处布局数学不一致)。 */
g_tb = meow_topappbar_create(0, 0, 1, 1, "设置");
if (g_tb) meow_topappbar_set_back(g_tb, 1);
g_lv_dir = meow_listview_create(0, 0, 1, 1, 1, src_dir, NULL, MEOW_LIST_SEL_NONE);
g_lv_pin = meow_listview_create(0, 0, 1, 1, 2, src_pin, NULL, MEOW_LIST_SEL_NONE);
g_lv_about = meow_listview_create(0, 0, 1, 1, 2, src_about, NULL, MEOW_LIST_SEL_NONE);
/* 列表:MD3 容器卡 + 双行(标题/副标题)+ 右尾件(开关/箭头)。
* 容器模式由 SDK 默认 renderer 绘制(surfaceContainer 圆角卡 + 行间空隙)。 */
meow_widget *lvs[3] = { g_lv_dir, g_lv_pin, g_lv_about };
for (int i = 0; i < 3; i++) {
if (!lvs[i]) continue;
meow_listview_set_item_h(lvs[i], ST_ROW_H);
meow_listview_set_item_renderer(lvs[i], 2, 0, 1);
meow_listview_set_container(lvs[i], 1);
}
g_overlay = meow_widget_custom_create(0, 0, 1, 1,
settings_overlay_render, NULL, NULL);
if (g_overlay) meow_widget_set_z(g_overlay, MEOW_Z_TOP);
}
/* 布局参数(窗口可能变化;每次进入重新摆位)。 */
static void settings_place(meow_view *v)
{
int w = meow_ui_width();
int sb = meow_ui_statusbar_height();
v->x = 0; v->y = 0; v->w = w; v->h = meow_ui_height();
int lx = 8, lw = w - 16;
int y = sb + ST_TOP_H + 6;
if (g_tb) meow_widget_set_rect(g_tb, 0, sb, w, ST_TOP_H);
g_yl_dir = y; y += ST_SEC_H;
if (g_lv_dir) meow_widget_set_rect(g_lv_dir, lx, y, lw, ST_ROW_H);
y += ST_ROW_H + ST_GAP;
g_yl_pin = y; y += ST_SEC_H;
if (g_lv_pin) meow_widget_set_rect(g_lv_pin, lx, y, lw, ST_ROW_H * 2);
y += ST_ROW_H * 2 + ST_GAP;
g_yl_about = y; y += ST_SEC_H;
if (g_lv_about) meow_widget_set_rect(g_lv_about, lx, y, lw, ST_ROW_H * 2);
y += ST_ROW_H * 2 + 14;
g_note_y = y;
g_ov_y = sb;
g_ov_h = 128;
if (g_overlay) meow_widget_set_rect(g_overlay, 0, g_ov_y, w, g_ov_h);
}
static void settings_section_label(int y, const char *text, const meow_theme *th)
{
meow_ui_text(24, y + 6, text, th->font_body, th->primary);
}
/* ============================================================ */
/* 绘制 */
/* ============================================================ */
static void settings_draw(meow_view *v, const meow_theme *th, void *ctx)
{
(void)v; (void)ctx;
int w = meow_ui_width();
int sb = meow_ui_statusbar_height();
meow_ui_fill_rect(0, sb, w, 56, th->surfaceContainer);
meow_ui_text(w / 2, sb + 16, "Settings", th->font_title, th->onSurface);
meow_ui_text_center(w / 2, sb + 100, "APP options", th->font_body, th->onSurfaceVariant);
meow_ui_text_center(w / 2, sb + 140, "Startup PIN — M5", th->font_body, th->onSurface);
meow_ui_text_center(w / 2, sb + 180, "Reader direction — M4", th->font_body,
th->onSurface);
meow_ui_text_center(w / 2, sb + 240, "Theme follows the system automatically",
th->font_body, th->onSurfaceVariant);
meow_ui_text_center(w / 2, sb + 300, "left-edge swipe = back", th->font_body,
th->onSurfaceVariant);
settings_section_label(g_yl_dir, "阅读", th);
settings_section_label(g_yl_pin, "安全", th);
settings_section_label(g_yl_about, "关于", th);
if (g_note[0]) {
meow_ui_text(24, g_note_y, g_note, th->font_body,
g_note_err ? th->error : th->primary);
}
int hy = g_note_y + (g_note[0] ? 30 : 0);
meow_ui_text(24, hy, "改动即时保存,下次启动生效", th->font_body, th->onSurfaceVariant);
meow_ui_text(24, hy + 28, "PIN 仅防随手查看,非安全边界", th->font_body,
th->onSurfaceVariant);
meow_ui_fill_rect(0, meow_ui_height() - 1, w, 1, th->outlineVariant);
}
/* ============================================================ */
/* 事件 */
/* ============================================================ */
static int settings_on_event(meow_view *v, int ev, int a, int b, void *ctx)
{
(void)v; (void)ctx;
if (ev == MR_VEV_TAP) {
int x = a, y = b;
if (g_tb) {
int hit = meow_topappbar_hit(g_tb, x, y);
if (hit == MEOW_TOPBAR_BACK) { meow_view_page_pop(); return 1; }
if (hit >= 0) return 1;
}
if (g_lv_dir) {
int idx = meow_listview_hit(g_lv_dir, x, y);
if (idx == 0) {
mr_reader_set_direction(mr_reader_get_direction() == MR_DIR_RTL
? MR_DIR_LTR : MR_DIR_RTL);
settings_note(mr_reader_get_direction() == MR_DIR_RTL
? "阅读方向:右到左(RTL)"
: "阅读方向:左到右(LTR)", 0);
settings_repaint();
return 1;
}
}
if (g_lv_pin) {
int idx = meow_listview_hit(g_lv_pin, x, y);
if (idx == ST_PIN_ROW_SWITCH) {
settings_toggle_pin();
settings_repaint();
return 1;
}
if (idx == ST_PIN_ROW_CHANGE) {
settings_change_pin();
settings_repaint();
return 1;
}
}
if (g_lv_about) {
int idx = meow_listview_hit(g_lv_about, x, y);
if (idx == 1) {
settings_note("主题跟随系统设置,App 内不可改", 0);
settings_repaint();
return 1;
}
if (idx == 0) return 1; /* 版本行:仅展示 */
}
return 0;
}
/* SWIPE 不消费:app.c 对非 library 页的左缘返回统一 page_pop。 */
return 0;
}
/* ============================================================ */
/* view 生命周期 */
/* ============================================================ */
static void settings_page_enter(meow_view *v, void *ctx)
{
((mr_page_ctrl *)ctx)->active = 1;
v->x = 0;
v->y = 0;
v->w = meow_ui_width();
v->h = meow_ui_height();
meow_ui_set_fullscreen(0);
meow_ui_set_fullscreen(0); /* 保留系统状态栏(与书库一致) */
meow_ui_set_page_bg(meow_theme_get()->bg);
if (!g_tb) {
/* 栈顶此时已是 ACT_SETTINGS(page_push 先 push 再 on_enter)→ 控件 owner 正确。 */
settings_build();
meow_widget *all[5] = { g_tb, g_lv_dir, g_lv_pin, g_lv_about, g_overlay };
for (int i = 0; i < 5; i++)
if (all[i]) meow_view_add_widget(v, all[i]);
}
settings_place(v);
g_pin_on = mr_pin_enabled(); /* 进入页时读一次;页内改动由操作函数同步 */
mr_nav_report();
meow_ui_paint_full();
}
@@ -50,12 +425,25 @@ static void settings_page_leave(meow_view *v, void *ctx)
((mr_page_ctrl *)ctx)->active = 0;
}
static void settings_page_destroy(meow_view *v, void *ctx)
{
(void)v; (void)ctx;
/* 控件随 meow_view_free 统一释放(v->widgets);这里只清指针,避免悬垂。 */
g_tb = NULL;
g_lv_dir = NULL;
g_lv_pin = NULL;
g_lv_about = NULL;
g_overlay = NULL;
}
meow_view *settings_view_create(void)
{
static const meow_view_hooks h = {
.on_enter = settings_page_enter,
.on_leave = settings_page_leave,
.on_draw = settings_draw,
.on_enter = settings_page_enter,
.on_leave = settings_page_leave,
.on_destroy = settings_page_destroy,
.on_draw = settings_draw,
.on_event = settings_on_event,
};
return meow_view_create(ACT_SETTINGS, MEOW_VIEW_ACTIVITY, &h, &g_ctrl);
}