这几天集中时间在 ACKS Mobile 的 Android 版本上做了一轮比较系统的功能完善和问题修复,踩了不少坑,最终都解决了。记录一下过程。
背景
ACKS Mobile 是我基于 Capacitor 7.x 封装的 React 移动端应用,主要用来在手机上访问 Hermes(我的 AI 任务系统),支持对话、看板任务管理、笔记、图像生成、图床管理,以及 Hermes Dashboard 的 iframe 嵌入。
这次迭代的起点是几个积压的 UX 问题,但越做越多,最后演变成一次比较完整的重构和安全加固。
一、MarkdownToolbar 键盘遮挡
输入框弹起软键盘后,Markdown 工具栏被键盘遮住了。之前用 React state 来跟踪键盘高度,但 state 更新有渲染延迟,工具栏会出现短暂的错位闪烁。
解决方案:改用 useRef 直接操作 DOM,在 visualViewport 的 resize 事件里直接更新 CSS bottom 值,绕过 React 渲染周期。效果比 state 驱动的方式流畅很多。
二、导航重构 + 主页面板
原来的导航顺序比较随意,用起来不顺手。这次重新整理了顺序:主页 → 对话 → Dashboard → 任务中心 → 笔记 → 图像生成 → 卡片工坊 → 图床。
同时设计了一个新的主页(HomePanel),启动后默认进入,不再直接跳到对话列表。主页包含:
- 个性化问候语(根据时段显示不同内容)
- 服务连接状态指示灯
- 一键新建对话的 CTA 卡片(整张卡片都可以点击,不只是角落的按钮)
- 4 个快捷入口(对话、生成图片、新建笔记、设计卡片)
- 今日任务预览
- 最近对话列表
设计参考了几张 dashboard 类应用的界面,风格保持 ACKS 一贯的深色主题 + 橙色强调色。
三、图床上传 UX 重构
原来的图床上传流程有个严重问题:要先手动切换到「文件夹」标签页选好目录,再切回「上传」标签页才能正常上传到指定位置。这个交互路径太反直觉了。
解决方案:在上传标签页顶部加了一个文件夹选择器,点击弹出底部抽屉,可以选择上传目标目录(包括根目录)。选择结果用 localStorage 持久化,下次打开自动记住上次选择。不再需要在标签页之间来回切换。
四、图床 Lightbox 安全区问题
图片点开后的全屏预览,顶部工具栏和手机状态栏重叠了,操作按钮被遮住一部分。
解决方案:给 Lightbox 顶部工具栏加上 paddingTop: 'calc(10px + env(safe-area-inset-top, 0px))',让内容自动避开刘海和状态栏区域。这是处理 Capacitor 应用安全区的标准做法。
五、Dashboard 移动端访问鉴权
Dashboard 面板通过 iframe 嵌入了一个内部服务,之前一直用 nginx IP 白名单做访问控制。但移动端的公网 IP 经常变,白名单完全不现实。
同时,如果去掉白名单,这个地址就完全暴露了,安全上也不行。
解决方案:部署 Authelia 作为身份验证网关,实现用户名 + 密码的单因素认证。
技术实现:
- Authelia 运行在 Docker 容器里,本地 SQLite 存储,用户数据库 YAML 文件管理
- nginx 去掉 IP 白名单,改用
auth_request指令接入 Authelia 的验证端点 - 验证失败时 302 重定向到 Authelia 的登录页
- 单因素认证(用户名 + 密码),不用验证码,不用邮件,路径最短
这里踩了一个比较隐蔽的坑:内部服务的后端对 Host 请求头有特定要求,nginx 如果透传了公网域名作为 Host,后端会返回 400。需要在 nginx 配置里手动指定正确的 Host 值。
六、CDN 重新注入 Security Header
Authelia 的登录页需要能被嵌入 iframe,但 Authelia 默认带 X-Frame-Options: DENY 和严格的 CSP。在 nginx 层用 proxy_hide_header 把这些 header 去掉之后,直接连 nginx 测试是正常的——但通过 CDN 代理的外部请求,这些 header 又回来了。
原因:CDN(Cloudflare)自己在响应里加回了这些 security header。
解决方案:在 CDN 控制台创建 Response Header Transform Rule,针对认证子域名:
- Remove
X-Frame-Options - Set
Content-Security-Policy: frame-ancestors *;
这是个很容易忽视的问题——nginx 层面改干净了,但 CDN 层面又加回来,需要在两个地方都处理。
七、iframe 登录死循环:SameSite Cookie 问题
这是整个过程中最难调的问题,也是最值得记录的。
Authelia 配置好之后,登录流程表面上看是通的,但实际上每次在 iframe 里登录完都会跳回空白登录页,循环不止。
根本原因:
ACKS Mobile 在 Capacitor 里运行,主页面的 origin 是 capacitor://localhost。iframe 里嵌入的是内部服务域名,这构成了一个跨站 iframe 上下文。
Authelia 的 session cookie 默认是 SameSite=Lax。Chrome/Android WebView 的规则是:在跨站 iframe 中,SameSite=Lax 的 cookie 不会被发送,即使 iframe 内部的两个子域互相之间是 same-site 的。
所以循环是这样的:
- iframe 加载内部服务 → WebView 不发送 Lax cookie(跨站 iframe 上下文)→ 被重定向到认证页
- 用户在 iframe 里登录 → Authelia 设置 Lax cookie
- iframe 跳转回内部服务 → WebView 还是不发送 Lax cookie → 又被重定向到认证页
- 死循环
解决方案:在 Authelia 配置里加上 same_site: none,让 session cookie 变成 SameSite=None; Secure,允许在跨站 iframe 上下文中发送。
同时把 Dashboard 面板的逻辑简化:检测到需要认证时,直接渲染 iframe,让 Authelia 的 web 登录页在 iframe 里处理完整的认证流程。之前尝试过用 Capacitor 的原生 HTTP 客户端做登录,但原生 HTTP 的 cookie 不会同步到 WebView 的 CookieManager,会导致另一套问题。
这样一来:打开 Dashboard → iframe 显示 Authelia 登录页 → 输入账号密码 → 认证成功 → 跳转到 Dashboard。流程顺畅,不再有循环。
总结
这次迭代改动比较分散,但核心收益有两点:
一是 UX 层面:主页让 app 有了更清晰的入口和信息密度,图床上传的交互更直觉,界面的安全区适配也更完整。
二是安全层面:内部服务的访问控制从”靠 IP 白名单”升级到了”用户名密码认证”,覆盖了移动端 IP 不固定的场景,也不需要任何额外的客户端配置。
最头疼的问题是 SameSite cookie 在 Capacitor iframe 里的行为——Capacitor 的 origin 是 capacitor://localhost,导致所有 iframe 内容都在跨站上下文里,SameSite=Lax 的 cookie 一律不发送。这个问题在文档里几乎没有直接的描述,只能靠逐步排查才找到原因。如果你也在用 Capacitor 做 WebView 嵌套或 iframe,这个细节值得留意。
了解 ~/jintaoblog/朝夕见闻志⚡️ 的更多信息
订阅后即可通过电子邮件收到最新文章。
