把一个 OS 子系统拆成独立进程：video app 的解耦实录
TL;DR 把一个浏览器桌面 OS 里最复杂的子系统从主进程里拆出来跑成独立进程，听起来像"挪个目录"，实际要重新回答四个问题：谁负责接 HTTP，谁负责排任务，DB 怎么分家，前端怎么跨 bundle 借能力。这篇讲我们怎么回答这四个问题，以及拆离过程踩到的三个真问题。 目录 TL;DR 目录 Part 1 · video 从内置域模型迁移为独立 app §1.1 项目定位与职责边界变化 §1.2 video 的职责归属冲突 §1.3 video 是最关键的一次抽离 Part 2 · 设计约束 Part 3 · 四条核心架构边界 §3.1 Bus 通信架构 §3.2 Job 系统架构 §3.3 Player 能力注册架构 §3.4 数据库边界：从隐式漂移到 multiSchema Part 4 · 抽离后的目标架构与可观测收益 Part 5 · 验收方法论 Part 1 · video 从内置域模型迁移为独立 app §1.1 项目定位与职责边界变化 tokimo 立项时更像媒体管理系统：视频库、刮削、转码、字幕、播放状态，是产品的主轴。随后系统逐步加入文件管理、SSH、AI、dashcam、docs、自动化、数据库会话等能力，形态从“一个媒体产品”变成“浏览器里的桌面 OS”。在这个语境里，OS 的职责不再是理解每一种内容业务，而是提供窗口、身份、任务、文件、播放、通知、事件这些可复用能力，让 app 在上面运行。 身份漂移的关键不是功能数量变多，而是“谁为谁服务”的方向反了。媒体系统里，文件系统、任务队列、播放器都是为了 video 服务；webOS 里，video 只是众多 app 之一，反过来消费 OS 提供的通用能力。只要主 server 仍然把 TMDB、剧集、字幕当作内置领域，其他 app 就只能围着 video 的历史结构让路，OS 的抽象也会不断被媒体业务拉偏。 flowchart LR A[媒体管理系统] --> B[文件 / SSH / AI] B --> C[dashcam / docs / automation] C --> D[浏览器桌面 OS] D --> E[30+ apps] A -.早期 core.-> V[video] D -.运行于 OS 之上的 app.-> V 维度 媒体管理系统语境 webOS 语境 video 的角色 核心域模型 第三方 app 能力 OS 应该知道什么 TMDB、字幕、剧集、刮削 streamUrl、窗口、任务、权限 失败影响面 产品核心失败 单 app 降级 发布节奏 跟主 server 一起发 可独立迭代 §1.2 video 的职责归属冲突 video 又早又重：它是项目最早、最复杂的子系统之一，占主仓 Rust 代码显著比例，并留下约 101 处 raw SQL。这样的复杂度在“媒体系统”里合理，因为它就是中心；但在“webOS”里会变成反向依赖：OS 需要硬编码 TMDB 刮削、NFO、HLS、字幕、人物关系、剧集季集等业务概念。更直接的问题是，任何 video 崩溃都可能污染主 server 的心智模型：故障归因无法区分 OS 故障与 app 故障。如果回答不清，抽离边界就还没建立。 尴尬还体现在 API 归属上。/api/apps/video/* 从 URL 看已经像 app，但实现却长期散落在主仓 handler、queue handler、前端 app registry、播放器上下文里。URL 先 app 化，进程却仍 monolith，这会制造一种危险错觉：调用者以为边界已经存在，维护者却仍能在主仓里随手 import video 内部类型。抽离 sidecar 后，URL 语义与运行时边界保持一致。 Before monorepo server ├─ auth / window / vfs / jobs ├─ video: tmdb / hls / subtitle / scrape / raw sql └─ other apps After main server = OS capabilities video sidecar = business domain + own router + own workers 判断题 留在 OS 放入 video sidecar 用户身份、会话、窗口容器 ✅ ❌ TMDB lookup / NFO parse ❌ ✅ HLS 字节流反代能力 ✅ 数据面 ✅ 业务 handler 字幕选择器 UI ❌ ✅ Media Session / controls ✅ ❌ §1.3 video 是最关键的一次抽离 dashcam-archive、docs 等 app 已经证明“sidecar 运行模型可用”。但 video 的价值不在首创，而在复杂度：它横跨 Rust handler、队列、DB schema、前端 bundle、播放器扩展、VFS、ffmpeg 路径和崩溃 UX。它触及的是之前简单 app 抽离尚未验证的边界：父子 job、流式数据面、Player 扩展、跨 schema 访问、大量 raw SQL 和长时间后台任务。 关键决策不是“为什么优先拆 video”，而是“为什么不绕过 video，继续抽别的简单 app”。答案是：只要 video 还留在主进程，OS 的 jobs/player/schema 抽象就会继续被媒体业务拉偏。抽完 video，sidecar 平台才不只是能承载轻量 app，而是能承载最复杂的 OS 子系统级业务。 quadrantChart title sidecar 抽离的复杂度与平台定型价值 x-axis 简单 --> 复杂 y-axis 局部收益 --> 平台契约定型 quadrant-1 已抽离：常规 app quadrant-2 关键抽离：平台基准 quadrant-3 低优先级 quadrant-4 复杂但收益不足 dashcam: [0.42, 0.58] docs: [0.32, 0.52] music: [0.58, 0.68] video: [0.9, 0.95] 方案 优点 缺点 决策 继续抽简单 app 风险低，流程熟 避开 jobs/player/schema 真边界 不选 抽离 video 一次验证最复杂承载能力 迁移面最大 选择 不抽离，保留 feature flag 短期稳定 OS 与媒体业务继续耦合 不选 Part 2 · 设计约束 拆离不是“移动目录”，而是重新划定 OS 与 app 的职责。约束必须先固定，否则后续局部优化会破坏职责边界。这里的约束分两类：硬约束决定系统不变量，不做的事限制迁移范围。前者防止抽离后退化成“另一个进程里的 monolith”，后者防止一次迁移同时引入数据库拆分、协议迁移和 URL 破坏，导致风险无法归因。 因此，抽离策略刻意保持外部形状稳定：URL 不变、DB 实例不变、播放器入口不变；只改变内部运行时归属。这样验收可以集中在进程隔离、协议边界、ownership 和 UX 上，而不是把所有变量混在一起。 flowchart TD C[设计约束] --> A[崩溃隔离] C --> B[零 API 破坏] C --> D[双轨过渡] C --> E[身份字段不污染业务字段] C --> F[崩溃 UX 明确] C --> N1[不拆 DB] C --> N2[不上 gRPC] C --> N3[不改外部 URL] 类型 约束 含义 硬约束 崩溃隔离 sidecar 死亡不能拖垮主 server、桌面 shell、其他 app 硬约束 零 API 破坏 浏览器外部仍访问 /api/apps/video/* 硬约束 双轨过渡 video-builtin feature 可作为回退，默认走 sidecar 硬约束 身份字段不污染业务字段 user_id/app_id 属于调用上下文，不属于业务 payload 硬约束 崩溃 UX 明确 502 表示上游不可达，504 表示上游超时 不做 不拆 DB 先用 Postgres multi-schema，不引入独立数据库 不做 不上 gRPC 避免工具链和 schema 迁移成本 不做 不改外部 URL 前端和外部调用者不感知进程位置 Part 3 · 四条核心架构边界 §3.1 Bus 通信架构 Bus 是这次抽离的进程边界，但它不是所有 /api/apps/video/* 请求的唯一入口。目标架构里有三条路径，必须分开理解：第一，浏览器访问 /api/apps/video/* 时，main server 的 wildcard 路由直接走 data-plane reverse proxy，把请求转发到 video sidecar 自己的 Axum router；这是绝大多数 video REST API、HLS、图片代理、字幕等浏览器路径。第二，main server 的 queue / handler 在需要把任务派给 video 时，走 bus control plane 调用 sidecar 暴露的 dispatch_* 方法。第三，sidecar 需要 OS 能力时，反向调用 bus local services，例如 jobs、vfs、playback_state。 这个拆分避免把视频流、Range 请求、大文件 body 塞进控制帧，也避免把内部 RPC 暴露成浏览器通用入口。控制面适合“命令”：job dispatch、能力查询、状态写入；数据面适合“搬运字节”：HLS 切片、Range 请求、图片代理、字幕文件。把两者混在一个 RPC 层里，结果要么 RPC 变成半个 HTTP server，要么视频流量使 broker 成为性能瓶颈。 BusClient 长连接复用，sidecar 启动时注册 service、版本和 data-plane socket。dispatch_app_call + MethodDecl 主要服务 bus local services 的 typed route 和内部控制面能力；浏览器 REST API 主路径则由 data-plane wildcard 交给 sidecar router 自己处理。错误码仍然是边界语言：连接失败、握手失败、连接关闭映射为 502；上游响应超时映射为 504；sidecar 内部业务错误保持自己的响应。 错误码区分直接决定故障定位语义。502 表示“OS 找不到或连不上 app”，处理方向是拉起、重启、检查注册表；504 表示“app 存在但没有及时响应”，处理方向是超时、负载、死锁、慢查询。若都返回 500，Shell 和用户只能看到“服务器错误”，进程隔离的可观测性就丢了。 flowchart TD Browser[Browser /api/apps/video/*] --> DP[main server data-plane wildcard] DP --> Router[video sidecar Axum router] Router --> VideoDB[(video schema)] Queue[main server queue / handler] --> CP[bus control plane rmp-serde + UDS/TCP] CP --> Dispatch[sidecar dispatch_* methods] Sidecar[video sidecar services] --> Local[bus local services] Local --> Jobs[OS jobs] Local --> Vfs[OS vfs] Local --> Playback[OS playback_state] 协议 协议成本 类型化 跨进程 工具链 tokimo bus 中：自有 broker + DTO 强，Rust/TS 可生成 原生支持 UDS/TCP 项目内可控 gRPC 高：proto、生成、gateway 强 原生支持 引入新栈 HTTP REST 低 中，靠约定 支持 简单但易漂移 直接 DB 低 弱 “跨进程”但无协议 破坏边界 let stream = BusStream::connect(socket) .await .map_err(|e| AppError::BadGateway(format!("app sidecar unreachable: {e}")))?; let upstream_resp = match timeout(Duration::from_secs(30), sender.send_request(upstream_req)).await { Ok(Ok(resp)) => resp, Ok(Err(e)) => return Err(AppError::BadGateway(format!("app sidecar request failed: {e}"))), Err(_) => return Err(AppError::GatewayTimeout("app sidecar response timeout after 30s".into())), }; 除可达性与超时语义外，bus 还必须承载跨进程调用身份。单进程时代可以依赖请求上下文、thread-local 或 handler 参数一路传递 CallerCtx。跨进程后，身份必须显式进入协议。这里有三种选择：让业务 payload 自带身份、放到 bus 协议头、或者做 TLS/证书级鉴权。最终选择协议头，因为它既能防止业务字段伪造，又不会让每个 DTO 都多出身份字段。 协议头还有一个组织收益：所有 local service 都可以用同一种 caller 结构做鉴权，jobs、vfs、task_queue、media_tools 不需要各自发明“当前 app 是谁”的字段。只要 broker 负责盖章，service 负责校验，业务代码就不必在每个请求里重复解析身份。 flowchart LR HTTP[session cookie] --> Bridge[HTTP bridge] Bridge --> Ctx[CallerCtx\nuser_id + caller_app_id] Ctx --> Header[bus protocol header] Header --> Sidecar[sidecar method] Sidecar -.业务 payload 不带身份.-> Payload[domain payload] 方案 实现复杂度 防 spoof 业务侵入 性能 payload 自带 userId/appId 低 弱，易伪造 高 好 bus 协议头 中 强，broker 注入 低 好 TLS / mTLS 高 强 低 中 协议头方案带来一个副作用：appId/userId 成为保留字段。若业务 payload 也有 appId，系统必须决定怎么处理。选择不是“静默覆盖”，而是 fail-fast：一旦业务 payload 里的保留字段与 caller 注入值不一致，立即报错。 fn ensure_reserved_field_matches(value: Option<&JsonValue>, field: &str, expected: &str) -> Result<(), BusError> { let Some(value) = value else { return Ok(()); }; if value.as_str() == Some(expected) { return Ok(()); } Err(BusError::BadRequest(format!( "payload contains reserved field '{field}' that conflicts with caller-injected value '{expected}'" ))) } 反直觉决策 看起来的好处 实际问题 结论 静默覆盖 + warning 兼容旧 payload 业务字段被改写后仍可能显示成功 不选 自动 prefix 业务字段 减少报错 协议层猜业务语义 不选 fail-fast 拒绝 迁移期更吵 在 dev/CI 暴露约定冲突 选择 让违反约定的请求直接失败，比让迁移者改字段名更可靠。字段命名是局部成本，身份污染是平台级成本。 §3.2 Job 系统架构 job 是 OS 能力，不是 video 私有表。原因是：持久化队列、状态机、retry、取消、父子任务、WebSocket 事件，都应该被多个 app 共享。video 定义 library_scan、item_scrape、tmdb_lookup 这样的业务 job 类型，但不能绕过 OS 直接写 public.jobs。因此 sidecar 只能通过 bus 调用 jobs.create / update_status / batch_children / query / cancel。 这个边界也避免了“表共享即接口共享”的误解。public.jobs 的列名、索引、状态枚举、重试策略都可能随 OS 演进；sidecar 真正需要的是“创建一个属于当前用户和当前 app 的任务”“更新它的状态”“查询自己的任务”。bus endpoint 把需求压缩成窄接口，repo 细节继续留在主 server。 Ownership 模型把 jobs 表的"谁拥有"收敛到当前用户和当前 app。sidecar 创建 job 时，bus service 从 caller 派生 owner = (appId, userId)。query、cancel、update_status、batch_children 各自在 bus service / repo 层按 owner 过滤或校验，业务 payload 不需要自带身份字段。这样 jobs 边界保持单一职责：sidecar 定义业务 job 类型，OS 保留队列能力与 ownership 判定。 sequenceDiagram participant UI as video UI participant V as video sidecar participant B as bus jobs service participant J as public.jobs UI->>V: start library_scan V->>B: jobs.create(type=library_scan) B->>J: insert owner = current user + video V->>B: jobs.batch_children(item_scrape[]) B->>J: insert children(parent_job_id) V->>B: jobs.update_status(tmdb_lookup) UI->>B: jobs.query(type/status) B->>J: filter by caller user + app endpoint 关键校验 防止的问题 数据路径 jobs.create 从 caller 派生 owner 伪造 owner bus → repo insert jobs.update_status 当前 job 属于 caller 修改他人 job bus → repo update jobs.query SQL 层 owner 过滤 + 分页 全表扫 / N+1 bus → list_jobs_for_owner jobs.cancel parent job owner 匹配 取消他人任务 bus → cancel + children jobs.batch_children parent owner 匹配，children 继承 owner 借父任务混入子任务 bus → batch insert fn model_owned_by(model: &jobs::Model, user_id: Uuid, app_id: &str) -> bool { model.user_id == Some(user_id) && model.payload.get("appId").and_then(JsonValue::as_str) == Some(app_id) } let result = JobRepo::list_jobs_for_owner( &state.db, user_id, &app_id, req.job_type.as_deref(), req.status.as_deref(), &PageInput { page, page_size }, ).await?; 父子 job 是 video 抽离里最能暴露边界的场景：library_scan 只是根任务，真正的数据变化发生在大量 item_scrape、tmdb_person_scrape、image_upload 子任务中。若 sidecar 直写 public.jobs，性能可能少一跳，但 OS 将失去 ownership、retry、取消和 WS 统一语义。选择 bus 是为了让“任务系统”继续属于 OS。 §3.3 Player 能力注册架构 播放器最容易被误判成“video 私有能力”。实际需要拆成两层：Shell 保留跨 app 能力，video 注册业务扩展。Shell 层负责 streamUrl、controls、窗口容器、Media Session、当前播放项快照；video bundle 只负责 episode menu、library nav、字幕选择器、下一集推荐等业务 UI。 这条边界直接决定后续 app 能不能复用播放器。music 需要队列和歌词，dashcam 需要时间轴和事件标记，docs 也可能需要嵌入式媒体预览。如果 Player 被 video 私有化，其他 app 会复制一套播放器；如果 Player 完全留在 OS，OS 又会硬编码 video 的剧集概念。注册式扩展让 Shell 拥有“播放能力”，app 拥有“播放语义”。 OS Shell layer ├─ PlayerProvider / Media Session / controls ├─ Window content container └─ PlayerExtensionRegistry ├─ global extensions ├─ video namespace: episode picker / next item / taskbar actions ├─ music namespace: queue / lyrics └─ dashcam namespace: timeline / event markers 能力 OS Shell video bundle 说明 控制与 Media Session ✅ ❌ 跨 app 共享 streamUrl 解析入口 ✅ ✅ 提供业务源 Shell 调扩展 剧集菜单 ❌ ✅ video 专属 字幕选择器 ❌ ✅ video 专属 WindowPortal / container ✅ ✅ 使用 容器归 OS，内容归 app PlayerExtensionRegistry 的核心是 register/unregister/list，并用 app_id namespace 防止多个 app 互相污染。这里有一个 SDK contract 硬约束：所有跨 app 播放源必须在 sourceMetadata 中携带 appId，缺失会退化为对所有 app namespace 的全量 extension 广播，必须视为 SDK contract violation 而非"兼容路径"。bundle unmount 时必须调用 unregister，否则扩展函数会在 app 不存在时继续被播放器调用，形成隐性状态泄漏。 registerExtension = (...args: RegisterArgs): (() => void) => { const [appId, ext] = typeof args[0] === "string" ? [args[0], args[1]] : [null, args[0]]; let appExtensions = this.extensionsByAppId.get(appId ?? ""); if (!appExtensions) this.extensionsByAppId.set(appId ?? "", (appExtensions = new Set())); appExtensions.add(ext); return () => { appExtensions.delete(ext); if (appExtensions.size === 0) this.extensionsByAppId.delete(appId ?? ""); }; }; 在 namespace 与 unregister 已能表达扩展生命周期后，问题就不是引入 Module Federation。tokimo 已经有 manifest 注册、bundle 加载、WindowPortal、SDK shim，真正缺的是运行时能力注册与卸载生命周期，不是"如何加载远程 bundle"。Module Federation 解决的是运行时 remote 模块加载与 shared singleton 依赖协商，与 Player Extension 的 mount/register/unregister 生命周期正交；引入它还会增加 host/remote 版本耦合，包括 webpack runtime、shared singletons 和版本协商。 方案 解决的问题 生命周期能力 版本耦合 决策 注册式 API 运行时能力挂载 强：register/unregister 显式 低：只依赖 SDK contract 选择 直接 import 复用 host 内部代码 弱：host 特判 app 高：路径即 API 不选 Module Federation 运行时 remote 加载 + shared singleton 协商 正交：仍需自建注册生命周期 高：runtime + shared singleton 协商 不选 video bundle 不只是页面，它需要 host 能力：Player、WindowPortal、i18n、Bridge、theme、message、typed API client。若直接 import 主仓内部模块，sidecar UI 就会变成“搬出去的源码目录”。选择注册式 API + namespace：host 暴露能力，bundle 在 mount 时注册扩展，在 unmount 时注销。 UI 边界比后端边界更容易被忽略，因为 TypeScript import 的耦合成本容易被低估。但一旦 remote bundle 直接 import host 内部路径，host 的文件结构就成了 public API，重构成本会比 HTTP API 更高。SDK contract 的价值，是把“可以依赖什么”写成显式对象，而不是让 bundler 解析路径来决定边界。 sequenceDiagram participant Host as OS Shell participant Loader as loadBundle participant Video as video bundle participant Reg as PlayerExtensionRegistry Host->>Loader: load tokimo-app-video bundle Loader->>Video: mount({ shell, i18n, bridge }) Video->>Reg: register("video", extension) Host->>Reg: getExtensions(appId from sourceMetadata) Video-->>Reg: unregister on unmount 这里的关键不是“保证 video bundle 可运行”，而是让多个 bundle 能同时扩展 OS。注册式 API 把能力契约固定在 SDK 层，避免一个 app 的 UI 结构反向塑造 host。 §3.4 数据库边界：从隐式漂移到 multiSchema 通信、任务和播放器边界固定后，剩下的核心问题是数据归属。DB 没有被拆成独立实例，而是用 Postgres multi-schema：video 私有表进入 video.*，OS 公共表留在 public.*。sidecar 直接访问 public.jobs、public.vfs 可减少一次调用链路，但会把 schema 变成隐式 API，任何主仓表结构变化都会破坏 sidecar。最终边界是：sidecar 可以直读写自己的 video.*；跨到 public.* 必须走 bus。 不拆 DB 是务实选择：一次性引入独立数据库会让迁移、事务、备份、开发环境和权限模型同时变化。multi-schema 则把“数据归属”先表达出来，同时保留同一个 Postgres 运维面。终态依赖显式边界：video 表在 entity 上标注 #[sea_orm(schema_name = "video")]，raw SQL 使用明确 schema 前缀；跨到 OS 公共能力时只走 bus。过渡期保护栏可以存在，但不能成为设计本身。 flowchart TD subgraph Sidecar[video sidecar] VCode[handlers / services / workers] end subgraph DB[Postgres] VS[(video.*)] PS[(public.jobs / public.vfs / users)] end subgraph OS[main server] Bus[bus local services] end VCode -->|合法：私有业务表| VS VCode -->|非法：直连公共表| PS VCode -->|合法：跨边界| Bus --> PS 表 / 能力 sidecar 可直读写 必须走 bus 原因 video.videos / video.video_items ✅ ❌ 业务私有表 video.subtitles / video.playback_sessions ✅ ❌ video 域模型 public.jobs ❌ ✅ OS 队列与状态机 public.vfs driver config ❌ ✅ 含凭据和可见性策略 public.users ❌ ✅ 身份归 OS Part 4 · 抽离后的目标架构与可观测收益 bus、jobs、player 与 schema 四条边界收敛后，目标形态可以落到进程、仓库和运行时三层结构。 tokimo/ ├─ packages/rust-server/ # OS server: bus, jobs, vfs, shell API ├─ packages/web/ # desktop shell + PlayerProvider ├─ packages/ui/ # shared UI primitives └─ apps/ └─ tokimo-app-video/ # independent sidecar app ├─ src/main.rs # clap + AppCtx + bus registration ├─ src/handlers/ # browse / hls / subtitle / sync ├─ src/bus_clients/ # jobs / vfs / playback_state ├─ src/db/entities/ # video schema entities └─ ui/src/ # bundle UI + player extension flowchart LR U[用户打开 video 库] --> Shell[Desktop Shell] Shell --> Bundle[loadBundle tokimo-app-video] Bundle --> API[fetch /api/apps/video/*] API --> DP[main server data-plane reverse proxy] DP --> Sidecar[video sidecar Axum router] Sidecar --> DB[(video.*)] Sidecar --> Bus[bus local services for public.*] 指标 抽离前 抽离后 结果 主 server 启动 编译/初始化 video 模块 默认 video-builtin OFF 启动路径变轻 编译影响面 video 改动牵动主仓 sidecar 独立 binary/UI 影响面收缩 崩溃影响 容易被误判为 OS 问题 502/504 限定到 app 隔离更清楚 发布节奏 跟主仓一起 submodule + sidecar 可独立演进 sidecar binary 是 clap CLI + AppCtx 的组合：无命令且存在 TOKIMO_BUS_SOCKET 时进入服务模式，初始化 DB pool，启动 app Axum server，注册 bus service 和 data-plane socket；显式命令则可执行 version、standalone 等开发入口。 这种入口设计让同一个 binary 同时满足生产和开发：生产由主 server supervisor 拉起并注入 bus 环境；开发可以用 standalone HTTP server 单独调试 handler；CLI 子命令则用于低成本健康检查和运维动作。sidecar 不需要知道自己被哪个窗口打开，只需要在 bus 上声明“当前服务是 video、可用 method 列表以及数据面 socket 位置”。 let db = db::init_pool().await?; let ctx = Arc::new(AppCtx::new(db, Arc::clone(&client_slot)).await?); let app_socket = app_server::spawn("video", Arc::clone(&ctx)).await?; let client = bus_services::video_jobs::register( BusClient::builder(cfg).service("video", env!("CARGO_PKG_VERSION")).data_plane(app_socket), Arc::clone(&ctx), ).build().await?; 故障场景 sidecar 死 拉起失败 假死 bundle 404 / runtime throw 主 server 仍健康 skip 单 app 504 仍健康 桌面 shell 单窗错误 单 app 缺失 窗口卡住/超时 fallback 占位 其他 app 不受影响 不受影响 不受影响 不受影响 video 自己 502 manifest 找不到 504 加载失败 用户观察 app 不可用 “Library not found” 类提示 等待后超时 “Failed to load” Part 5 · 验收方法论 这类拆分的验收不能仅以页面可访问作为验收标准。更有效的方法是把系统按风险切成 workstream：后端 bus、性能热路径、前端 parity、Shell SDK contract、submodule 完整性、崩溃隔离，再加主 agent 自查。每条线独立找 P0/P1/P2，再用 rubber-duck 复审找报告盲区。再用威胁模型倒推优先级：若 sidecar 都是可信内置 app，一些 bus 能力可按 P1 处理；若允许第三方 sidecar，同样的问题会升级为 P0。 这种验收方式的核心是避免“局部通过”。例如前端 parity 通过，不代表 bus ownership 安全；HLS 流畅，不代表 job 分页正确；sidecar 能启动，不代表 bundle runtime throw 时有可理解 UX。把问题按威胁模型重新分级，才能知道哪些缺陷只是低风险质量问题，哪些会破坏 sidecar 平台本身。 mindmap root((验收)) WS1 后端 bus WS2 性能热路径 WS3 前端 parity WS4 Shell SDK contract WS5 submodule 终态 WS6 崩溃隔离 WS7 主 agent grep rubber-duck 复审 threat model -> P 级 workstream 关注点 典型输出 后端 bus auth、ownership、协议保留字段 P0/P1 列表 性能热路径 HLS、Range、batch children 是否走数据面 前端 parity 残留 import、URL、fallback 删除/迁移清单 SDK contract Player、WindowPortal、i18n API 边界 崩溃隔离 502/504、单窗错误 隔离矩阵 项目开源地址：tokimo-lab
浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路
浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路 目录 为什么一次「看目录」不只是一次命令执行 链路全景：从 React 到 guest-agent 前端：按钮、SSE、WS 与 typed client 的边界 后端：Axum 把聊天流和工具执行拆开 sandbox：为什么是 vsock，不是 TCP PTY：为什么不是直接 exec 错误与回流：stdout、exit、error 都是协议 业界正在做什么 一个可复用的工程框架 为什么一次「看目录」不只是一次命令执行 用户在浏览器 chat 输入「帮我看一下当前目录」，表面上只是 pwd && ls。但在 agent 产品里，它同时穿过四个边界：浏览器进程、Rust server、sandbox runtime、microVM guest。每一层都可能丢事件、阻塞、错认工作目录，或者把错误包装成一段模型看不懂的文本。 真正难的不是执行命令，而是让一次工具调用具备这些性质：用户能看到流式反馈；模型拿到结构化结果；命令运行在隔离环境；刷新页面后状态还能恢复；超时、退出码、stderr 都能被解释；交互式 shell 和一次性命令不能互相污染。 这里还有一个容易被忽略的产品问题：用户以为自己在和「一个助手」对话，但系统实际由很多异步参与者组成。LLM 可能还在思考，SSE 已经断线；命令可能已经退出，消息缓存还没 flush；sandbox 可能刚被重建，旧的 PTY tab 还在尝试写 stdin；用户切换 agent 后，工作目录、技能开关、模型参数都要跟着换。如果这些边界没有清晰协议，最后出现的 bug 往往不是「命令跑错了」，而是「结果跑对了，但出现在错误的会话、错误的窗口、错误的工作目录」。 难点 表面现象 工程解法 事件先后 POST 成功但前端没收到首个 token 先建 SSE 订阅，再触发 send 执行边界 模型命令误跑在 host executor 显式携带 sandbox handle 工作目录 多轮 Bash cwd 漂移 执行后回读 pwd -P 并更新状态 页面刷新 terminal 输出丢失 PTY registry 保存 history 并支持 attach 错误表达 stderr 被当普通文本 exit/error/stderr 分帧再映射 ToolOutput ┌──────────────┐ POST /chat/send ┌──────────────┐ │ Browser Chat │ ───────────────────▶ │ Axum AI API │ │ React + SSE │ ◀── data: AgentEvent │ ChatService │ └──────┬───────┘ └──────┬───────┘ │ │ tool: Bash │ WS: app events / terminal ▼ │ ┌──────────────┐ │ │ AgentSandbox │ │ │ per-agent cwd│ │ └──────┬───────┘ │ │ spawn_shell │ ▼ │ ┌──────────────┐ └────────────────────────────▶ │ microVM guest│ │ vsock + PTY │ └──────────────┘ Agent 工程的本质，是把「模型想做一件事」变成跨进程、跨协议、跨权限边界的可恢复事务。 链路全景：从 React 到 guest-agent 当前代码里，AI chat 的入口主要在 packages/web/src/apps/ai-chat/。AiChatContext.tsx 维护 agent、conversation、panel 状态；useSendMessage.ts 把编辑器内容、附件和模型选择组装成发送输入；useChatSSE.ts 负责先连 /api/apps/ai/chat/{conversationId}/events，再 POST /api/apps/ai/chat/send，避免服务端广播时前端还没订阅。 服务端路由集中在 packages/rust-server/src/apps/ai/router.rs：stream::chat_events 提供 SSE，stream::chat_send 触发 ChatService::stream_completion。工具层落在 packages/rust-server/src/apps/ai/tools/，其中 Bash 工具最终会走 LocalToolExecutor::exec_bash_streaming；正常情况下 sandbox always-on，executor 进入 AgentSandbox::exec，由 tokimo-package-sandbox 的 Auto backend 决定 Linux CH / bwrap 等实际路径。只有当 sandbox boot 进入 cached Failed state 时，chat 才按 6432e4ab 的策略退回 host execution，避免每次请求都卡 multi-second timeout；/api/apps/ai/sandbox/restart 用于清掉 failed runtime 并重试。 这篇文章按「理想的一次看目录工具调用」来讲链路，但会保留当前代码的真实边界：chat send 不是 generated client 的 mutation，而是 authFetch 手写；packages/web/src/lib/api/ 这个旧路径不存在，真实 typed client 在 generated/rust-api/；packages/tokimo-guest-agent/ 也不是独立 package，而是 sandbox 包里的 binary。写技术随笔最怕把架构图画得很顺，代码却不是这么跑的，所以这里宁可把这些不那么优雅的过渡态写出来。 graph TD A[ChatInput / Send button] --> B[useSendMessage] B --> C[useChatSSE] C --> D[authFetch /api/apps/ai/chat/send] C --> E[SSE /api/apps/ai/chat/:id/events] D --> F[Axum stream::chat_send] E --> G[React Query message cache] F --> H[ChatService::stream_completion] H --> I[BashTool] I --> J[LocalToolExecutor] J --> K{sandbox runtime healthy?} K -->|yes| L[AgentSandbox::exec] K -->|boot failed / Failed cached| M[host process fallback] L --> N[tokimo-package-sandbox Auto backend] N --> O[tokimo-sandbox-init / guest bash] R[/api/apps/ai/sandbox/restart] --> K 一次调用的时序条带 Browser Axum Agent runtime Sandbox host Guest | | | | | |--SSE open-->| chat_events | subscribe bus | | |<--initial---| | | | |--POST------>| chat_send | stream_completion | | | |------------------->| model/tool loop | | | | | BashTool | | | | |------------------->| spawn_shell | | | | |--vsock JSON--->| | | | |<--frames-------| |<--SSE data--|<-------------------| AgentEvent | | 这条链路不是单向 RPC，而是「触发通道 + 事件通道 + 执行通道」三条线并行协作。 前端：按钮、SSE、WS 与 typed client 的边界 前端有两类网络通道。第一类是 AI chat 自己的 SSE：useChatSSE.ts 用 authFetch(rustUrl(...)) 手动读取 response body，解析 data: 行，再用 applySSEEvent patch React Query cache。这里没有直接使用浏览器原生 EventSource，因为代码需要精确控制「先订阅、再发送」以及 AbortController 生命周期。 第二类是全局 WS：packages/web/src/system/events/ws.tsx 定义 JSON envelope：{ type, data, reqId, error }；useJobEvents.ts 明确把 app-wide 的 job_update、person_scraped、download:progress 放到 WS 上，并替代旧 SSE。也就是说，chat token 和工具事件偏 SSE，系统级广播偏 WS。 typed client 的真实位置不是旧路径 packages/web/src/lib/api/，而是生成目录 packages/web/src/generated/rust-api/。例如 ai.ts 里有 api.ai.agents.*、api.ai.conversations.*、api.ai.messages.*、sandbox status、terminal URL 等。chat send 当前仍是手写 authFetch，但 agent/conversation/message 的 CRUD 已经由 generated client 承担。 这个边界值得保留：不是所有请求都应该强行塞进同一个 client 抽象。普通 CRUD 追求类型、缓存键和失效策略一致；chat stream 追求连接时序、增量解析和取消语义可控。把二者分开，反而能让「控制面」和「数据面」各自简单，也方便后续把 stream 协议单独演进，不牵动普通查询接口，并保持可测试、可回放、可追踪。 通道 当前用途 代码位置 为什么这样分 POST 触发一次用户消息 useChatSSE.ts → /chat/send 请求有明确成功/失败 SSE assistant/message/tool 增量 chat_events + ConversationJournal 单向流、易恢复、适合文本增量 WS 全局事件、terminal、job system/events/ws.tsx、terminal_ws.rs 双向、可复用、适合交互 generated API CRUD / 查询 generated/rust-api/ai.ts 类型稳定、React Query 集成 graph LR subgraph React Input[ChatInput] Hook[useSendMessage] SSE[useChatSSE] Cache[React Query Cache] WS[WsProvider] end Input --> Hook --> SSE SSE -->|POST trigger| Send[/chat/send/] SSE -->|read stream| Events[/chat/:id/events/] Events --> Cache WS --> Jobs[job_update etc] API[generated rust-api] --> Cache 前端不是「发一个请求等结果」，而是在维护一个可断线重连的 agent 运行投影。 后端：Axum 把聊天流和工具执行拆开 router.rs 里有三组关键路由：/api/apps/ai/chat/send 触发运行；/api/apps/ai/chat/{conversationId}/events 输出 SSE；/api/apps/ai/agents/{id}/terminal/ws 给用户打开同一个 sandbox 内的交互式终端。stream.rs 的 chat_send 把 user、conversation、provider、model、attachments 和 sandbox_runtime 一起传进 ChatService::stream_completion。 工具执行上下文由 ServerToolExt 描述。它把 workspace 级资源（DB、HTTP、ConversationJournal、MCP、sandbox manager）和 per-agent 资源（agent id、cwd、模型参数、tool whitelist、sandbox handle）拆开。这样 DispatchAgent、Skill、session memory extractor 都能继承同一个逻辑 sandbox，而不是复制一个可能已经死掉的 Arc。 sequenceDiagram participant U as Browser participant A as Axum stream.rs participant C as ChatService participant T as BashTool participant E as LocalToolExecutor participant S as AgentSandbox participant G as sandbox-init / guest bash U->>A: POST /api/apps/ai/chat/send A->>C: stream_completion(input, sandbox_runtime, bus) C-->>U: SSE AgentEvent conversation:start C->>T: model requests Bash T->>E: exec_bash(args) E->>S: exec(cmd, timeout) S->>G: spawn argv=[/bin/bash,-lc,cmd] G-->>S: stdout/stderr/exit/error frames S-->>E: ExecResult E-->>T: ToolOutput C-->>U: SSE message/tool_result/end Axum 层只负责把运行接上轨道，真正的复杂度在「事件 journal」和「工具 executor」之间。 sandbox：为什么是 vsock，不是 TCP tokimo-package-sandbox 是跨平台 sandbox 包，公开 Sandbox、ShellOpts、Event、JobId 等 API。Linux 现在不应只写 Cloud Hypervisor：默认是 Auto backend，先探测 CH，失败时回退到 bubblewrap。CH 分支仍通过 Cloud Hypervisor hybrid-vsock UDS 连接 guest；bwrap fallback 则用 namespaces + socketpair 承载同一套 init / RPC / netstack 语义。对 Bash tool 来说，关键变化是 guest 里应有真 /bin/bash，同时有大量 busybox applet symlinks 补齐常用命令。 Guest 侧并没有独立的 packages/tokimo-guest-agent/，而是 packages/tokimo-package-sandbox 里的 binary：src/bin/tokimo-guest-agent/。main.rs 默认在 1024 开 one-shot RPC，在 1025 开 PTY；server.rs 读一行 JSON request，返回多行 JSON frames；exec.rs 定义 Spawn/Ping/QueryMount；pty.rs 用 forkpty() 处理交互式会话。 维度 vsock TCP 地址模型 host/guest CID + port IP + port 暴露面 不进入普通网络栈 容易被路由、防火墙、代理影响 microVM 语义 天然 host↔guest 通道 需要额外网络配置 安全边界 不需要给 guest 开公网/局域网入口 需要认真处理监听地址和 ACL 当前代码 ch/rpc.rs hybrid-vsock UDS + CONNECT 不是主路径 graph TD Host[Sandbox host process] --> UDS[Cloud Hypervisor vsock UDS] UDS -->|CONNECT 1024| RPC[guest-agent RPC listener] UDS -->|CONNECT 1025| PTY[guest-agent PTY listener] RPC --> Spawn[Command::output] PTY --> Forkpty[forkpty + exec] Spawn --> Frames[stdout/stderr/exit/error] Forkpty --> PtyFrames[stdin/resize/stdout/exit] vsock 的价值不是更快，而是把「只允许宿主机和 guest 说话」变成默认拓扑。 但这句话只适用于 VM backend：Linux CH、macOS VZ、Windows Hyper-V 分别对应 AF_VSOCK / virtio-vsock / HvSocket。Linux bwrap fallback 不走 vsock，而是用 socketpair 承载同一类 frame / RPC 语义；文中后续提到“vsock”时，应理解为 VM backend 的 transport，而不是所有 backend 的唯一传输。 PTY：为什么不是直接 exec 一次性工具调用和交互式终端是两种协议。GuestRpc::spawn_command_with_options 每次连接只发一个 spawn request，guest 用 Command::output() 等命令结束后再返回 stdout/stderr/exit。这适合「帮我看目录」这种短命令，因为模型需要的是完整结果。 但用户打开 terminal 或 agent 运行交互式程序时，直接 exec 不够。TTY 程序会检查是否有终端、需要窗口尺寸、需要 Ctrl-C、需要 resize、会输出 ANSI 控制序列。AgentSandbox::open_pty 传 ShellOpts { pty: Some((rows, cols)), argv, env, cwd }；terminal_ws.rs 再把浏览器 WS 的 binary/text 输入转成 write_stdin 和 resize_shell，把 sandbox Event::Stdout 广播给所有 attach 的浏览器 tab，并保留 scrollback。 维度 one-shot exec PTY 生命周期 请求结束即结束 长连接，直到 shell 退出 stdin 基本不交互 持续双向输入 输出 stdout/stderr/exit 结果 ANSI 字节流 + exit 窗口尺寸 无意义 rows/cols + SIGWINCH 适合 pwd && ls、测试命令 shell、REPL、top、vim 类程序 当前实现 GuestRpc::spawn_command_with_options open_pty_with_options + forkpty() PTY 不是「exec 多一个参数」，而是一条活的终端电路： Browser xterm │ key bytes / resize ▼ terminal_ws.rs │ PtyInput::Data / Resize ▼ Sandbox::write_stdin / resize_shell │ VM: vsock JSON frame / bwrap: socketpair frame ▼ sandbox-init pty.rs │ forkpty master fd ▼ /bin/bash -l（Bash tool 语义）或 ShellOpts 指定的交互式 shell ❓ exec 给模型结果，PTY 给用户现场；把两者混成一个接口，最后会两边都难用。 错误与回流：stdout、exit、error 都是协议 错误不能只靠字符串。当前链路里至少有三种错误：请求错误（POST 失败，前端 onError）、运行错误（AgentEvent 里的 conversation/message/turn error）、sandbox 错误（Event::Error { fatal, message } 或 guest Response::Error { msg }）。这里需要删除 caller-owned JobId 的叙述：该 race fix 已被 b797c2975 revert，exec() 回到 spawn-then-subscribe。项目组判断原始竞态并不存在，因为事件发布路径要经过 guest fork+exec / vsock / HCS pump，远晚于 host 侧 synchronous insert；反而是只在 ChBackend 实现 spawn_shell_with_id、其他 backend 走 trait 默认 fallback，导致 Windows/macOS/bwrap 丢弃 job_id，最终让 Bash tool 在 Windows 卡 120s。 这个 revert 的教训比 race 本身更重要：跨平台 sandbox 的 API 不能只在一个 backend 上实现，然后依赖 trait 默认实现“看起来编译通过”。如果 API 改动影响 job id、事件订阅、stdin/stdout 语义或 host-exec response，就必须让 CH、bwrap、macOS、Windows 全部显式实现或显式报错；静默 fallback 会把 bug 推迟到最难调的平台上爆炸。 输出回流也分层：guest-agent 把 stdout/stderr/exit/error 变成 JSON lines；ch/backend.rs 映射成 tokimo_package_sandbox::Event；AgentSandbox::exec 收集成 ExecResult；Bash tool 格式化成 ToolOutput；ChatService 再投影成前端可消费的 AgentEvent。对于 chat，前端以 SSE patch message cache；对于 terminal，WS 直接推原始字节给浏览器。 对比项 SSE WS 方向 server → browser 双向 chat 增量 很适合，浏览器只读 也能做，但需要自定义重连语义 terminal 不适合 stdin/resize 适合二进制输入和输出 当前代码 useChatSSE.ts、stream::chat_events WsProvider、terminal_ws.rs 失败恢复 重新连接并通过初始状态补齐 需要业务自己处理 session/history 好的 agent 运行时不会隐藏错误，而是让每一层都能保留自己的错误类型和恢复策略。 业界正在做什么 OpenAI Code Interpreter 的路线是「托管沙箱 + 文件挂载 + 结果工件」，用户不关心底层 VM，但工具调用被严格关在会话环境里。Cursor agent 更像「IDE 内本地代理」，强调 workspace 上下文、diff、终端和用户确认。Claude Code 则把 terminal、文件系统、任务子代理和工具权限做成 CLI 里的长期会话，重点是把工程项目当运行现场，而不是把 prompt 当唯一上下文。 Tokimo 这条链路更接近「浏览器桌面 OS 里的 agent runtime」：前端是窗口和消息投影，后端是 agent orchestrator，sandbox 是可共享但 per-agent 隔离的执行层。它不是复制某一家产品，而是在浏览器、Rust server、microVM 三者之间明确切边界。 系统 用户界面 执行位置 强项 OpenAI Code Interpreter Chat 托管沙箱 工件和数据分析闭环 Cursor agent IDE 本地 workspace/终端 代码上下文和编辑体验 Claude Code CLI 本机工程目录 长任务、工具权限、子任务 Tokimo Browser desktop Rust server + microVM sandbox Web UI、窗口系统、隔离执行统一 业界共识正在形成：强 agent 不是一个模型接口，而是一套带 UI、状态、沙箱和权限的运行时。 一个可复用的工程框架 如果把这条链路抽象出来，我们会得到一个四层框架：UI projection、run orchestration、tool execution、isolation transport。UI projection 负责把运行状态变成用户可理解的消息、spinner、terminal；run orchestration 负责模型循环、工具调度、取消和恢复；tool execution 负责 cwd、env、timeout、输出裁剪；isolation transport 负责把命令送进受控环境。 graph TB L1[UI Projection\nReact Query / SSE / WS] --> L2[Run Orchestration\nChatService / ConversationJournal] L2 --> L3[Tool Execution\nBashTool / LocalToolExecutor] L3 --> L4[Isolation Transport\nAgentSandbox / Sandbox / vsock] L4 --> L5[Guest Runtime\nguest-agent / exec / PTY] L5 --> L4 L4 --> L3 L3 --> L2 L2 --> L1 这也是为什么「帮我看一下当前目录」值得被认真拆解。它不是 demo，而是 agent 产品最小但完整的工程切片：一次用户意图，被模型转成工具调用，被 server 约束成执行计划，优先被 sandbox backend 执行，再被事件流还原成用户能看懂的状态。只有当 sandbox boot 已进入 cached Failed state 时，chat 才退回 host execution；这是一条可用性 fallback，不应被写成常规执行路径。 Framework-level 结论：浏览器里的 AI agent 工具调用，应该被设计成事件驱动的分层运行时，而不是从按钮一路硬连到 shell 的远程命令。
用 Opus 半小时把博客从 Typecho 搬到 GitHub Pages 旧博客一直跑在 Typecho 上：PHP + MySQL + Nginx + 主题 + 评论插件。可以工作，但每次想动它都要先盘一遍 PHP 版本、SSL 证书、备份是否完整、主题是否还兼容。维护成本远高于写作成本。 今晚用 Claude Opus 4.7 把整套迁到 GitHub Pages，耗时约半小时。 新架构 前端：React + Vite + TypeScript，构建产物推 gh-pages 内容存储：当前仓库的 GitHub Issues。一篇文章 = 一个 Issue，标签作分类与 tag，评论复用 Issue 评论 图片：仓库 assets 分支，正文以 raw.githubusercontent.com 引用 后端 / 数据库 / 鉴权：无 部署：actions/deploy-pages，push 即发布 整个站静态，无运行时依赖。 半小时内 AI 完成的工作 ① 仓库初始化。 我给的需求是"静态博客 + 内容走 Issues"。Opus 自行选定 React + Vite + TS 栈，生成脚手架、路由（Home / Blog / PostDetail / About）、Issues 列表与详情、评论组件、Markdown 渲染、CI 部署、CNAME 配置。整个过程我只回答了两个澄清问题。 ② 历史数据迁移。 旧 Typecho 备份是单个 SQL dump。Opus 直接读 typecho_contents 与 typecho_metas，按文章 / 分类 / tag 关系重建数据，正文中的旧附件 URL 一并改写为新的 raw 路径，结果以 issue 草稿形式产出。我只做抽样验收。 ③ DNS 切换。 仓库加 public/CNAME 后 GitHub 侧自动接管，剩余工作只在 DNS 控制台。Opus 给出完整记录清单：4 条 A 记录指向 GitHub Pages 固定 IP，加一条 CNAME 兜底，并附每条记录的作用与 TTL 建议。我按表录入，由它执行 dig 验证生效。 ④ 文章发布。 当前这篇即为示例：草稿生成 → review → gh issue create 直接落地为 Issue。无后台，无富文本编辑器，全程 CLI。 几点观察 整个过程 AI 处理的不是"写代码"这一项孤立任务，而是连同环境配置、数据迁移、域名、CI、发布流程在内的完整工程链路。每一项单独都不复杂，过去阻碍它们一次性完成的是上下文切换成本。 工具链从 Typecho（管理后台 + 编辑器 + FTP）变为 GitHub（仓库 + Issues + Actions），运维面积归零，写作面积反而扩大。 "迁移"本身被压成了一次对话的副产品。下一次想换栈、换平台、加功能，按相同流程跑即可。 迁移完成。后续文章会陆续以 Issue 形式发出。
manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验
manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验 浏览器给了我们 DOM，但没有给我们桌面；React 给了我们组件，但没有给我们应用生命周期。 目录 为什么这件事难：窗口不是路由，应用也不是组件 四种窗口类型：page / system / app / popup manifest 如何把 app 注册进桌面 文件窗口和 app 窗口如何共存 跨窗口通信：bridge 不是全局事件总线 业界正在做什么 从 0 加一个 Hello World app framework-level 的一句话 为什么这件事难：窗口不是路由，应用也不是组件 在传统 Web 里，我们习惯把「一个功能」想成一条 route：URL 变化，组件树变化，状态跟着 React 生命周期走。但 tokimo 要做的是浏览器里的桌面 OS：窗口可以拖拽、缩放、最小化、恢复、层叠；同一个 app 可以同时开多个实例；一个文件可以被不同 viewer 打开；一个设置页可能只是系统窗口；一个传输进度条又不应该被持久化成长期任务。 难点不在「渲染一个窗口」，而在三件事同时成立： shell 层不能 import 每个业务 app，否则系统层会被应用层反向污染； 新 app 不能改一堆 switch/case，否则桌面会变成手工路由表； 窗口状态要能持久化，但显示名、组件、默认尺寸又要跟 manifest 更新。 ┌──────────────────────────────────────────┐ │ Browser Desktop Shell │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Window A │ │ Window B │ │ │ │ type=system │ │ type=text │ │ │ └──────────────┘ └──────────────┘ │ │ ▲ ▲ │ │ │ │ │ │ WINDOW_REGISTRY manifest / metadata │ └──────────────────────────────────────────┘ 所以 tokimo 的答案不是「写一个 App 组件」，而是把 app 变成一份 AppManifest，再由框架把它注册成 Launchpad 项、窗口类型、系统页、运行时、菜单栏、设置项。真实入口在 packages/web/src/apps/_framework/app-registry.ts，类型定义在 packages/web/src/system/window/manifest-types.ts。 金句：桌面应用的抽象边界，不是 React component，而是可被 shell 理解的 manifest。 四种窗口类型：page / system / app / popup Tokimo 的 manifest 把 app 分成四类：page、system、app、popup。它们不是视觉风格分类，而是生命周期、路由方式和持久化语义的分类。 category 典型窗口 type 注册入口 适合场景 关键字段 page type: "page" TYPE_TO_MANIFEST DB 驱动的用户实例，如媒体库、相册库 supportedTypes, component, views system type: "system" SYSTEM_TO_MANIFEST / SYSTEM_COMPONENT Finder、Search、Settings、Video 这类内置系统应用 id, pageIds, component, pages app 自定义，如 text / terminal APP_WINDOW_TYPE_REGISTRY + WINDOW_REGISTRY 自治窗口、viewer、第三方 app windowType, windowComponent popup 自定义，如 transfer WINDOW_REGISTRY 进度条、临时任务、不可 resize 的小窗口 windowType, singletonKey, noResize 真实案例非常直观：packages/web/src/apps/terminal/manifest.ts 是 system + 独立 windowType 的双形态应用，管理页走 system，真实终端会话走 terminal windowType。 export const manifest: AppManifest = { id: "terminal", category: "system", defaultSize: { width: 1100, height: 700 }, appName: "dashboard.menu.terminalManagement", component: () => import("./pages/TerminalAppPage"), windowType: "terminal", windowComponent: () => import("./components/TerminalContent").then((m) => ({ default: m.default, })), fullBleed: true, }; 四类窗口最终都会回到同一个 dispatcher：WINDOW_REGISTRY。不同点是 page / system 先进入元 dispatcher，再二次查 manifest；app / popup 直接把自己的 windowType 注入 registry。 graph TD O[openWindow params.type] --> R[WINDOW_REGISTRY] R -->|page| P[PageContent] P --> T[TYPE_TO_MANIFEST] R -->|system| S[SystemContent] S --> M[SYSTEM_COMPONENT] R -->|custom app type| A[manifest.windowComponent] R -->|popup type| U[popup component] 金句：window type 是 shell 的调度协议，category 是 app 对自己生命周期的声明。 manifest 如何把 app 注册进桌面 app-registry.ts 做了一件很关键的事：用 import.meta.glob eager 扫描 packages/web/src/apps/**/manifest.ts，然后把同一份 manifest 投影到多个运行时索引里。 flowchart LR G[import.meta.glob manifests] --> A[registerAppManifest] G --> B{supportedTypes?} B -->|yes| C[TYPE_TO_MANIFEST] G --> D{system pageIds?} D -->|yes| E[SYSTEM_TO_MANIFEST + SYSTEM_COMPONENT] G --> F{windowType?} F -->|yes| H[APP_WINDOW_TYPE_REGISTRY] F -->|yes| I[WINDOW_REGISTRY custom type] G --> J{icon/color/appName?} J -->|yes| K[Launchpad] 这让 shell 层保持干净：packages/web/src/system/window/window-registry.ts 一开始只是一个空对象，连 page 和 system 两个元类型也是由 app framework 注入。系统层只知道「type → lazy component」，不知道 VideoApp、FinderContent、TerminalContent 的存在。 更有意思的是 appName。WindowState.appName 存的是完整 i18n key，比如 dashboard.menu.video，不是翻译后的「视频」。渲染时统一走 resolveWindowAppName(win, t)：如果 t(appName) 找到翻译，就显示当前语言；如果找不到，就把它当字面量返回。文件窗口因此可以直接显示 notes.txt，而带 manifest 的 app 又能随语言切换。 server task restore │ ├─ 不信任持久化 appName ▼ manifest / registry 重新派生 appName │ ├─ i18n key: dashboard.menu.video → t() └─ literal : notes.txt → 原样显示 这避免了一个常见坑：如果把翻译后的字符串持久化，用户切换语言后，任务栏、Dock、菜单栏会同时出现旧语言和新语言。Tokimo 在 packages/web/src/system/window/window-sync.ts 里明确不从 server restore appName，而是从 manifest / registry 重新推导。 金句：持久化应该存身份，不应该存会随语言、版本和主题变化的显示结果。 文件窗口和 app 窗口如何共存 文件窗口看起来像「没有 manifest 的例外」，其实它们也走同一套 registry。比如 packages/web/src/apps/viewers/text/manifest.ts 把 windowType: "text" 注册成一个 category: "app" 的 viewer。打开文件时，OpenWindowParams 里携带 fileName、filePath、fileSystemId，WindowManager 会把 title 设为文件名；如果 manifest 没有 appName，最终 appName 也会落到 title。 flowchart TD F[open file: README.md] --> W[openWindow type=text] W --> V[WINDOW_REGISTRY.text] V --> C[TextViewer] W --> N{appName source} N -->|explicit param| E[params.appName] N -->|manifest has appName| M[manifest.appName] N -->|fallback| L[fileName/title] 这就是文件窗口和 app 窗口能共存的原因：shell 不问「你是不是应用」，只问「你的 type 有没有 registry entry」。Video、PDF、Image、Text 是 viewer；Terminal、Database 是自治工具；Transfer 是 popup；它们都只是不同 metadata shape 下的窗口。 Desktop Window Stack z=42 ┌──────────────────────────────┐ │ transfer: job progress │ popup / singletonKey z=31 ├──────────────────────────────┤ │ terminal: ssh session │ app custom windowType z=18 ├──────────────────────────────┤ │ system: finder pageId=finder │ system dispatcher z=07 ├──────────────────────────────┤ │ text: /docs/README.md │ file viewer └──────────────────────────────┘ 这里的统一不是「所有东西都继承同一个基类」，而是「所有窗口都遵守同一个调度协议」。 金句：桌面 shell 不需要理解业务，它只需要稳定地理解窗口协议。 跨窗口通信：bridge 不是全局事件总线 窗口一多，通信就会变难。最容易犯的错是把所有事情塞进全局事件总线：picker 选中文件、modal 取消、子窗口回传配置、后台任务更新，全都广播。短期快，长期灾难。 Tokimo 的 packages/web/src/system/window-bridge/ 选择了更窄的抽象：bridge 是进程内、一次性、按 id 定向的事件 registry。父窗口创建 bridge，把 bridgeId 放进 modal metadata；子窗口只向这个 bridge emit；窗口关闭时，WindowManager 自动发 system:close。 sequenceDiagram participant Parent participant Bridge participant WM as WindowManager participant Picker Parent->>Bridge: createBridge() Parent->>Bridge: subscribe("pick") / subscribe("system:close") Parent->>WM: openModalWindow(metadata.bridgeId) WM->>Picker: mount with win.metadata Picker->>Bridge: emitPick(win, selectedFile) Picker->>WM: closeWindow(win.id) WM->>Bridge: emit("system:close") Bridge-->>Parent: Promise resolve before cleanup 真实调用在 packages/web/src/apps/docs/pages/useDocsPage.ts：文档编辑器用 pickWithBridge<VfsFileSelection> 打开 VfsFilePickerWindow；子窗口在 packages/web/src/apps/docs/components/VfsFilePickerWindow.tsx 里 emitPick(win, file) 后关闭窗口。 Picker event flow Parent component ├─ pickWithBridge(openModalWindow, params) │ ├─ create bridge id │ ├─ subscribe pick │ └─ subscribe system:close │ └─ modal window metadata.bridgeId │ ▼ Picker window ├─ emitPick(win, value) ───────► resolve(value) └─ closeWindow(win.id) ────────► system:close fallback 机制 生命周期 适合数据 不适合什么 bridge 父子窗口之间，通常一次性 picker 返回值、modal 确认/取消 全局状态、跨刷新恢复 全局事件总线 进程内长期存在 主题切换、菜单栏局部广播 点对点请求/响应，容易误收 WebSocket 服务端事件流，可跨窗口同步 job_update、下载进度、窗口状态同步 单次 picker 选择，太重 这个边界很重要：bridge 解决的是「打开一个窗口，等它给我一个值」；WebSocket 解决的是「服务端状态变化，所有相关窗口都要知道」。两者不混用，系统复杂度会低很多。 金句：越临时的通信，越应该局部；越持久的状态，越应该有服务端来源。 业界正在做什么 VS Code Webview 把扩展 UI 放在隔离 webview 里，扩展贡献点决定它出现在哪里；Tokimo 的 manifest 更像桌面 shell 的贡献点，只是目标是窗口而不是编辑器面板。 Tauri 多窗口把窗口创建交给 Rust/系统 WebView，适合 native shell；Tokimo 反过来，在单浏览器进程里模拟窗口管理，所以 registry 和 metadata 比 OS window handle 更重要。 Electron 的 BrowserWindow 是进程级窗口抽象，强大但重；Tokimo 的 FloatingWindow 是 React 内的窗口抽象，轻，但必须自己处理 z-index、modal blocking、持久化和恢复。 Chrome Extension 用 manifest.json 声明权限、入口和 background/content scripts；Tokimo 的 AppManifest 也是声明式入口，不过它声明的是桌面 app 如何被 shell 调度。 金句：业界都在用 manifest 降低宿主和插件的耦合，只是宿主不同，manifest 的语义边界也不同。 从 0 加一个 Hello World app 仓库里已经有真实第三方样板：apps/tokimo-app-helloworld/。它不是前端假 demo，而是多进程 app：Rust binary 通过 UDS 暴露服务，主 server 反代 /api/apps/helloworld/*，UI bundle 通过 assets 加载。它的 tokimo-app.toml 是给主 server / shell 读的安装 manifest。 [app] id = "helloworld" display = "Hello World" version = "0.1.0" [ui] entry = "assets/index.js" window_type = "helloworld" category = "app" icon = "Sparkles" width = 720 height = 560 从 0 加一个 app，大致是这棵树： apps/tokimo-app-helloworld/ ├─ tokimo-app.toml # server/shell 安装描述 ├─ src/ # axum app server + handlers + db └─ ui/ ├─ src/index.tsx # default export mount(container, ctx) └─ dist/index.js # 被主 shell dynamic import 注册流程如下： flowchart TD T[tokimo-app.toml] --> S[tokimo-server installed-apps API] S --> D[InstalledAppDescriptor] D --> L[load-third-party.tsx] L --> M[synthetic AppManifest] M --> R[registerAppManifest] M --> W[registerWindowTypeManifest] M --> P[WINDOW_REGISTRY.helloworld] P --> A[Adapter lazy-imports assets/index.js] A --> U[third-party mount(container, ctx)] 注意这里有一个桥接动作：第三方 descriptor 的 category: "app" 在 load-third-party.tsx 里会被包装成 shell 可展示的 manifest，并注册到 Launchpad；真正打开窗口时用的是 windowType，不是 system pageId。这让第三方 app 可以像原生 app 一样出现在桌面，又不需要进入 packages/web/src/apps/ 的源码扫描。 如果是内置 Hello World，则更简单：在 packages/web/src/apps/hello/manifest.ts 写一个 AppManifest，给 id、category、windowType、component、defaultSize、icon/color/appName，app-registry.ts 会自动发现并注入 registry。差别只是：内置 app 编译进 shell；第三方 app 通过 installed-apps API 和 dynamic import 接入 shell。 金句：从框架视角看，内置 app 和第三方 app 的区别，不是能不能打开窗口，而是谁负责生成 manifest。 framework-level 的一句话 Tokimo 的 manifest 桌面模型，本质上是在浏览器里给「应用」补上宿主协议：声明身份、声明窗口、声明运行时，再让 shell 用 registry 把它们调度成可持久、可通信、可组合的桌面体验。
通过 JavaScript 脚本解决移动端弹出层滚动穿透的问题
前端开发中经常会遇到一个麻烦的问题：在通过fixed或者absolute定位的弹出层上进行滚动操作的时候，滚动事件会穿透到底部导致body跟着滚动，及其影响交互体验。常规做法是在弹出层时候禁止body的滚动，将其设置为overflow: hidden，但是会导致滚动高度丢失从而造成视觉上一次弹动，所以我决定从弹出层本身考虑这个问题。 Body content. Popup layer. 如果直接禁止touchmove事件，那么子元素有滚动区域时将也无法滚动，所以我们用上面一段代码来判断，动态判断是否要禁止事件。 如果你是Vue开发者，直接使用封装好的包即可：https://www.npmjs.com/package/vue-prevent-overscroll.js
.fill { overflow: hidden; background-size: cover; background-position: center; background-image: url('path/to/image.jpg'); }
最近没事做 Chrome 插件时，突发奇想，觉得可以用 Vue 来构建，于是折腾了两天完成，随手总结下。 尝试与思考过程 由于手头有一个现成的脚手架，于是在产生这个想法后，我立刻将自己的 Vue 脚手架拉到插件目录中，企图直接使用。编译本地运行，似乎一点问题也没有，然而当我配置好插件信息加载到 Chrome 中时，发现页面一片空白，一时不知原因为何。 接下来我在查了一些资料后得知 Chrome 插件环境下的 内容安全策略(CSP) 为了安全会禁止某些函数的执行，于是Vue.js中的模板渲染会遇到问题。接着我回到Vue.js的文档中查找解决方法，找到了关于CSP环境下的说明。 Some environments, such as Google Chrome Apps, enforce Content Security Policy (CSP), which prohibits the use of new Function() for evaluating expressions. The full build depends on this feature to compile templates, so is unusable in these environments. 问题出在了模板渲染函数使用了new Function，官方这段文档建议我们使用 CSP 版本的 Vue.js，然而我到处找却并没有找到 CSP 版本的 Vue.js 2.0，最新的也还是 Vue.js 1.0。 找不到可用的 CSP 版本 的，我将目标放在插件配置上。查看了插件开发文档后，看到这么一段关于插件 CSP 配置的，在 manifest.json 中加入如下一行，问题解决。 "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';", 然而这么做，很不科学也不安全。除了这种方法，就只能使用低版本的 Vue.js 么？实际上并不是，不知道为何 Vue.js 的官方文档没有提及。 新建一个 Vue.js 的项目的时候，有一个如下的选项： ? Vue build (Use arrow keys) ❯ Runtime + Compiler: recommended for most users Runtime-only: about 6KB lighter min+gzip, but templates (or any Vue-specific HTML) are ONLY allowed in .vue files - render functions are required elsewhere 实际上，当我们选择 Runtime-only 的话，所有的模板都将会在编译的时候转换成函数，而不是运行时生成了。这样的话，就完全兼容了 CSP，于是我开始修改我现有的脚手架。 具体解决步骤 安装模板编译依赖项 npm install vue-template-compiler@xxx # xxx 需要和你当前的 vue 版本一致 修改 new Vue 中渲染方式 new Vue({ el: '#app', router, store, template: '<App/>', components: { App }, }); 改为 new Vue({ el: '#app', router, store, render: h => h(App), }); 重新编译，运行，问题解决。 参考 我的 Vue.js 2.0 for Chrome Ext 脚手架 基于上述脚手架构建的插件 FL Opt 及其 源码
CSS父元素设置最小高度 子元素设置百分比高度不生效的问题
今天写一个页面时，发现一个有趣的事情，我试图让一个遮罩层高度为100%，结果一直不生效，情况如下： 如上方示例代码，外部是主容器，内部是一个遮罩层。 父层设置了百分比min-height，同时没有设置height。 子层height设置为100%。 按照思路，子层应该和父层拥有一样的高度，然而浏览器渲染结果并不是这样。查了下CSS规范文档(CSS 2.1 Specification)发现了问题所在，于是做一些记录。 CSS 2.1 规范 CSS 2.1 规范关于百分比高度的说明： The percentage is calculated with respect to the height of the generated box’s containing block. If the height of the containing block is not specified explicitly (i.e., it depends on content height), and this element is not absolutely positioned, the value computes to ‘auto’. 规范里所说的the containing block就是本次示例中的父层，正如加粗部分所说，示例中父层确实没有被指定明确的高度(explicitly height)，但是它有指定min-height值，于是它的高度取决于它的子内容的高度。 而文档中关于最小高度的规范原文如下： Specifies a percentage for determining the used value. The percentage is calculated with respect to the height of the generated box's containing block. If the height of the containing block is not specified explicitly (i.e., it depends on content height), and this element is not absolutely positioned, the percentage value is treated as '0' (for 'min-height') or 'none' (for 'max-height'). 由于一路往上层高度都是100%，所以高度理论上应该是如文中加粗部分所描述的一路继承下来。 浏览器表现 我试过的所有浏览器都将子元素高度设置为它的内容的高度，而忽略了子元素的100%高度设置。不知所措一波操作之后发现，如果我给父层设置一个任何数值的高度之后，子元素的高度居然正常了。 所以，当百分比高度的父元素被设置了高度，就算是0，百分比高度的子元素也会被计算。 然而，进一步测试，又发现了问题。当父层的实际高度超出了它的min-height时，子层的高度并没有跟着增加。此时仿佛子层的height数值被设置成了父层的min-height，所以当有滚动的时候，子层会不足以撑满整个父层大小。于是，碰到这个问题的时候，如果你的父层会出现滚动，还是不能简单的设置高度为0来解决。