背景
- 目标:未来通过 gomobile / FFI 把 Go 核心嵌入到移动端/桌面应用中,减少“先启动核心进程,再连接 HTTP 服务”的操作成本。
- 短中期现实:UI 仍然通过 HTTP 调用本地/局域网的 Go 核心。
- 痛点:
- 客户端很难“快速判断”当前核心和 S3 的状态:是否在线、桶是否可用、当前桶里有没有已经初始化的加密库。
- 初次启动时,我这边看到的是一片空白,很难知道“下一步应该是初始化密码、解锁现有库还是处理冲突”。
- 逻辑分散在多处,特别是 Home 页面的启动流程、离线处理、自动解锁逻辑,之前比较绕。
本轮改动的目标:
- 在服务端增加/梳理“调试友好”的接口:
/health、/bootstrap等。 - 在客户端围绕这些接口,重写启动与引导的复杂逻辑,使得:
- 无论将来是 HTTP 还是 gomobile,核心能力的语义是一致的。
- 逻辑集中到少数几个函数里,更容易在切换实现时整体替换。
服务端:调试友好的状态接口
1. /health:快速健康检查
- 定义位置:
core/internal/api/server.go:114-120,171-220 - 路由注册:
- 在
setupRoutes中作为无需认证的公共接口:s.router.GET("/health", s.healthCheck)
- 在
- 行为:
- 为当前请求上下文包装 5 秒超时:
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
- 调用
s.s3.Ping(ctx)来检测 S3 连通性:Ping底层实现是ListBuckets:- 位置:
core/internal/storage/s3.go:130-159
- 位置:
- 若 S3 检查失败:
- 返回
503 ServiceUnavailable。 - body:
{"status":"error","error":"...详细错误..."}。
- 返回
- 若成功:
- 返回
200 OK。 - body:
{"status":"ok","timestamp":<当前秒级时间戳>}。
- 返回
- 为当前请求上下文包装 5 秒超时:
设计意图:
- 提供一个极轻量的“核心是否在线且能访问 S3”的探针。
- 为客户端的以下场景服务:
- 应用启动时的初始在线/离线判定。
- 后台定时心跳,随时更新 UI 的“离线”提示。
- 将来 gomobile 嵌入时,即便不再通过 HTTP,也可以用同样的语义:
healthCheck()表示“核心处于可工作状态”。
2. /bootstrap:引导初始化/解锁流程
- 定义位置:
core/internal/api/server.go:114-120,171-201 - 路由注册:
- 同样是无需认证接口:
s.router.GET("/bootstrap", s.bootstrapStatus)
- 同样是无需认证接口:
- 返回结构:
BootstrapStatusResponse,core/internal/api/server.go:30-37Status string "json:\"status\""Bucket string "json:\"bucket\""ObjectCount int "json:\"objectCount\""
- 内部逻辑:
- 列出当前桶根目录下的所有对象:
objects, err := s.s3.ListObjects(ctx, "")
- 统计对象数量:
count := len(objects)。
- 填充响应体基础字段:
Bucket: s.s3.Bucket()。ObjectCount: count。
- 根据状态分三类:
empty:- 对象数为 0。
- 说明当前桶是全新的,尚未初始化加密库。
ready:- 对象数 > 0,且存在
.e2eepan/salt:s.s3.GetObjectInfo(ctx, ".e2eepan/salt")成功。
- 说明这个桶中已经有一个按约定初始化好的加密库,可以直接解锁。
- 对象数 > 0,且存在
conflict:- 对象数 > 0,但没有找到
.e2eepan/salt。 - 推断:
- 这个桶中有其他内容,但不是本应用的加密库,或者结构被破坏。
- 需要手动决策(换桶、清空、或手动迁移)。
- 对象数 > 0,但没有找到
- 列出当前桶根目录下的所有对象:
这个接口是整个“初次启动引导”和“调试当前桶状态”的关键,对 gomobile 也非常重要:
- 无论核心是独立服务还是嵌入式库,只要暴露同样语义的
bootstrapStatus,UI 就能复用当前的引导逻辑。 - 对调试来说,在浏览器或 curl 中访问
/bootstrap就可以一眼看出:- 当前桶名。
- 对象个数。
- 是否已经初始化加密库/是否存在冲突。
3. 元数据同步调试:/api/v1/metadata/sync
- 位置:
core/internal/api/server.go:903-943 - 原有职责:
- 接收客户端上传的完整元数据索引(
MetadataIndex),并覆盖存储。 - 这是一个“重同步”接口。
- 接收客户端上传的完整元数据索引(
- 为了排查问题,增加了简单的调试输出:
- 使用
c.GetRawData()读取原始请求体,并打印:fmt.Printf("syncMetadata received: %s\n", string(body))。
- JSON 解析失败时,也打印具体错误:
fmt.Printf("syncMetadata parse error: %v\n", err)。
- 使用
- 虽然这算不上正式的“调试接口”,但对于开发阶段排查元数据结构错误和 gomobile 适配时验证序列化格式很有帮助。
4. 监听地址:为移动端访问准备
Server.Run定义:core/internal/api/server.go:1067-1079- 采用:
addr := fmt.Sprintf("0.0.0.0:%d", s.cfg.Server.Port)。 - 所以无论是桌面还是 Android 设备,只要在同一局域网,都可以通过 IP+端口访问。
- 采用:
- 对 gomobile 而言:
- 在“过渡期”可以仍然跑一个本地 HTTP 核心,让移动端通过 Wi-Fi 调试。
- 长期看则可以把这部分逻辑提炼为“路由表”和“服务实现”,HTTP/Native 两个入口共同复用。
客户端:围绕调试接口重构启动与引导逻辑
1. 健康检查链路:从 HTTP 到 AppState
ApiClient.healthCheck,client/lib/core/api/api_client.dart:70-107- GET
/health,使用较短的超时:sendTimeout/receiveTimeout/connectionTimeout均为 3 秒。
- 所有异常都视为
false(离线)。
- GET
AppState._checkInitialNetworkStatus,client/lib/core/state/app_state.dart:196-219- 应用启动时调用:
- 稍微等待 100ms,确保
ApiClient初始化完成。 - 调用
api.healthCheck()。 - 根据结果设置
_isOffline并notifyListeners()。 - 若在线:立即加载元数据
_loadMetadata()。 - 若离线:尽量加载本地数据库缓存
_loadFromLocalDb()。
- 稍微等待 100ms,确保
- 应用启动时调用:
AppState构造函数中的心跳定时器,client/lib/core/state/app_state.dart:88-133- 每 15 秒执行一次:
- 再次调用
api.healthCheck()。 - 若离线状态有变化,更新
_isOffline并通知 UI。
- 再次调用
- 每 15 秒执行一次:
- 辅助接口:
checkConnection():显式检查一次连接并更新_isOffline(client/lib/core/state/app_state.dart:346-352)。setOffline()/canWrite()/offlineError等封装了离线模式下的行为。
这一整条链路把 /health 的语义映射为:
- “核心是否处于可工作的在线状态”,并被整个 UI 统一使用。
- 将来如果改用 gomobile,只要在新的实现里保持
healthCheck()语义不变,AppState 和 UI 可以不改。
2. 引导逻辑重构:Home 页面的 _checkBootstrapStatus
- 入口:
HomePage.initState,client/lib/ui/home_page.dart:49-65- 初始化后,在
addPostFrameCallback中依次做:appState.addListener(_onStateChanged);appState.refreshFiles();(乐观加载本地文件并后台同步)。_checkBootstrapStatus();(只在首次且未解锁时运行)。
- 初始化后,在
- 引导核心逻辑:
_checkBootstrapStatus,client/lib/ui/home_page.dart:88-140- 防重入:
- 已经检查过 (
_bootstrapChecked) 或正在检查 (_bootstrapChecking) 时直接返回。 - 若
appState.isOffline或已解锁,直接返回。
- 已经检查过 (
- 调用服务端
/bootstrap:final result = await appState.api.getBootstrapStatus();- 对应
ApiClient.getBootstrapStatus,client/lib/core/api/api_client.dart:109-117。
- 失败处理:
- 若调用失败或无数据:
- 重置
_bootstrapChecking/_bootstrapChecked/_vaultStatus/_conflictBucket/_conflictCount。 - 返回,UI 退回到普通“空态”。
- 重置
- 若调用失败或无数据:
- 成功时解析:
status:empty/ready/conflict中的一种。bucket:当前桶名。objectCount:对象数。
- 特殊优化:
ready+ 已保存密码时自动解锁:- 若
status == 'ready':- 通过
appState.getSavedPassword()获取本地保存的密码。 - 若存在,尝试
appState.unlock(saved)。 - 成功则:
- 清除
_vaultStatus和冲突信息。 - 标记
_bootstrapChecked = true,不再显示引导区域。
- 清除
- 通过
- 若
- 其他情况:
- 将
_vaultStatus/_conflictBucket/_conflictCount写入状态,用于后续 UI 渲染。
- 将
- 防重入:
这段逻辑把 /bootstrap 的三种状态映射为 UI 行为:
ready:- 如果密码已记住,自动尝试解锁。
- 自动解锁失败或无密码,则展示“解锁”界面。
empty:- 展示“初始化密码”界面。
conflict:- 提示当前桶存在冲突(已有非本应用内容),引导处理。
3. 内联引导 UI: _buildVaultInline 及其分支
- 入口:
_buildFilesBody,client/lib/ui/home_page.dart:615-654- 决定是否在空列表时展示“加密库引导区”:
showVaultInline = !state.isOffline && !state.isUnlocked && _vaultStatus != null;
- 若
files.isEmpty且需要引导:- 显示
_buildVaultInline(state)。
- 显示
- 否则显示普通空态或文件列表。
- 决定是否在空列表时展示“加密库引导区”:
- 引导区渲染:
_buildVaultInline,client/lib/ui/home_page.dart:684-705_bootstrapChecking == true:- 显示“正在检测存储状态…”圈圈。
_vaultStatus == 'ready':- 调用
_buildVaultUnlockInline():- 内联解锁密码输入框 + “解锁”按钮。
- 按钮触发
_handleVaultUnlock()(client/lib/ui/home_page.dart:142-164)。
- 调用
_vaultStatus == 'empty':- 调用
_buildVaultInitInline():- 两个密码框(密码与确认)+ “初始化”按钮。
- 按钮触发
_handleVaultInit()(client/lib/ui/home_page.dart:166-201)。
- 调用
_vaultStatus == 'conflict':- 调用
_buildVaultConflictInline():- 文案提示当前桶中已有其他内容,并显示桶名/对象数,供手动处理。
- 调用
- 其他情况:
- 回退到
_buildEmptyPlaceholder(state)。
- 回退到
这套 UI 逻辑本质上就是 /bootstrap 的前端投影:
- 把底层“桶中状态”翻译成“该给操作建议”。
- 同样的结构将来可以复用到 gomobile 模式,只要 native 实现能给出相同的状态信息。
4. 调试核心地址:隐藏的内核地址设置弹窗
- 入口:设置页里的“关于”区域,
client/lib/ui/settings_page.dart:711-775- 连续点击“关于 E2E Pan”5 次,触发隐藏菜单:
- 调用
_showCoreAddressDialog()。
- 调用
- 连续点击“关于 E2E Pan”5 次,触发隐藏菜单:
main.dart中的默认核心地址,client/lib/main.dart:12-24- 默认使用:
http://127.0.0.1:18520。 - 若
SharedPreferences中存在core_base_url则覆盖默认。
- 默认使用:
_showCoreAddressDialog实现:client/lib/ui/settings_page.dart:303-359- 文本框预填当前/保存的核心 URL。
- 可以:
- 恢复默认(删除
core_base_url)。 - 设置新的 URL(写入
core_base_url)。
- 恢复默认(删除
- 修改后提示“重启应用后生效”。
这相当于是一个“调试服务器地址”的隐藏开关,有两个用途:
- 当前阶段:
- 方便在 PC 和手机间切换不同核心实例(本机、局域网其他机器等)。
- 结合
/health和/bootstrap,可以快速验证不同核心的状态。
- 将来 gomobile:
- 在完全 native 化之前,可以在测试版中维持 HTTP+gomobile 双通道,通过这个入口自由切换。
- 也可以作为“开发模式”的配置项保留,用于回退到 HTTP 调试路径。
与 gomobile 的关系:为什么要先把这些逻辑理顺
-
统一“状态查询”语义
- 现在通过
/health和/bootstrap两个接口,把“核心可用性”和“存储桶/加密库状态”抽象成稳定的语义。 - gomobile 实现时,只要提供同样语义的函数(例如
HealthCheck()/BootstrapStatus()),UI 层不需要重新设计。
- 现在通过
-
集中而非分散的复杂逻辑
- 启动、离线处理、自动解锁、冲突提示等复杂逻辑都集中在:
AppState._checkInitialNetworkStatus/refreshFiles/forceRefresh。HomePage._checkBootstrapStatus+_buildVaultInline相关分支。
- 未来如果切换到 NativeCore,只需要在少数几个“获取状态”的入口替换实现,而不必全局搜一堆“零散 if/else”。
- 启动、离线处理、自动解锁、冲突提示等复杂逻辑都集中在:
-
更好的调试体验
- 即便暂时还没接入 gomobile,现在的 HTTP 模式已经非常易调试:
- 浏览器访问
/health//bootstrap即可看到核心状态。 - 通过隐藏的“内核地址”设置,可以随时切换到不同核心实例。
- 浏览器访问
- 这些调试能力在 gomobile 集成阶段也会非常重要:
- 可以对比“HTTP 核心”和“Native 核心”的行为是否一致。
- 便于在出现问题时快速回退到 HTTP 模式做 A/B 验证。
- 即便暂时还没接入 gomobile,现在的 HTTP 模式已经非常易调试:
-
为 NativeCoreService 预留清晰的接口层
ICoreService/HttpCoreService/NativeCoreService的分层(client/lib/core/services/*.dart)已经在架构上说明了:- HTTP 和 Native 只是实现差异。
- 上层真正关心的是“健康检查、初始化、解锁、元数据、文件操作、流式播放”的领域能力。
- 本轮围绕
/health、/bootstrap所做的规范化,为未来把这套能力迁移到 gomobile 提供了很好的锚点。
小结
- 在服务端,我们补齐/整理了适合调试和引导的状态接口:
/health:快速判断核心和 S3 是否可用。/bootstrap:描述当前桶与加密库状态,为 UI 引导提供依据。- 元数据同步时增加原始请求体的调试输出,便于排查序列化问题。
- 在客户端,我们围绕这些接口重写了启动和引导的复杂逻辑:
- AppState 统一负责健康检查、在线/离线判定和数据加载策略。
- Home 页内联的“初始化/解锁/冲突”引导区完全由
/bootstrap驱动。 - 通过隐藏的“内核地址设置”弹窗,增强了调试不同核心实例的能力。
- 这些工作一方面让当前 HTTP 架构的开发体验更好,另一方面也为 gomobile/NativeCore 的未来集成打好了地基:
核心能力的语义先收敛,再谈实现的替换。