一名宅。 - 技术随笔
type: channel
link: https://zhaiyiming.com/tag/%E6%8A%80%E6%9C%AF%E9%9A%8F%E7%AC%94/
items: 把一个 OS 子系统拆成独立进程：video app 的解耦实录
items: 一次 SMB 视频点击引发的 SIGSEGV：当 ffmpeg 和 libvips 各带一份 glib
items: 浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路
items: 用 Opus 半小时把博客从 Typecho 搬到 GitHub Pages
items: 为什么我们给 AI 沙箱手写了一套跨平台用户态 TCP/IP 栈
items: 在浏览器里给 AI 跑命令：Tokimo sandbox 三平台 backend 的现状切片
items: manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验

把一个 OS 子系统拆成独立进程：video app 的解耦实录
type: item
date: 2026-05-21T09:18:03+08:00
description: 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 运行模型可用”。…

一次 SMB 视频点击引发的 SIGSEGV：当 ffmpeg 和 libvips 各带一份 glib
type: item
date: 2026-05-14T12:48:37+08:00
description: 一次 SMB 视频点击引发的 SIGSEGV：当 ffmpeg 和 libvips 各带一份 glib 用户："我点了 SMB 上的电影，server 直接崩了。" 我："…让我看看。" 目录 现象：一点视频，服务器整个退出 第一直觉：又是 LD_LIBRARY_PATH 顺序问题 真相：两个 bundle，两份 glib 为什么"一份 glib"是天大的事 短期回避 vs. 真正修复 tokimo-lib：把 ffmpeg 和 libvips 关进同一个 Docker CI 里如何强制"只有一份 glib" 迁移：6 个 commit + 5 个 submodule 踩到的小坑 总结 现象：一点视频，服务器整个退出 Tokimo 是个浏览器桌面 OS，后端 Rust + Axum，前端点开 SMB 网盘里的电影会经过这条链路： 前端点击 .mkv ↓ 后端 SMB 驱动取流 ↓ 后台 job：libvips 抽缩略图 + ffmpeg 出 hover 预览 ↓ WebSocket 推回前端 往常一切正常。直到某天用户报：点电影 = server 进程直接退。不是 panic，不是 500，是整个进程从父进程的眼皮底下消失。 $ make dev … [server] Listening on 0.0.0.0:5678 … （用户点视频） [server] (no logs, no panic, no graceful shutdown — just gone) $ dmesg 里赤裸裸一行： [xxx] traps: tokimo-server[1234] general protection fault ip:7f… sp:7f… error:0 in libgobject-2.0.so.0[7f…] libgobject 段错误。眼熟。 第一直觉：又是 LD_LIBRARY_PATH 顺序问题 我们之前为了这事儿调过一次 LD_LIBRARY_PATH 的顺序——两个 bundle 都带了一套 glib，loader 按谁先出现就拿谁的。所以 scripts/dev/rust-run.ts 里有这么一段防御： // libvips 的 lib 目录必须在 ffmpeg 之前 // 否则 libvips 会拿到 ffmpeg 那一份的 glib，符号偏移对不上 if (existsSync(libvipsLibDir)) extras.push(libvipsLibDir); if (existsSync(ffmpegLibDir)) extras.push(ffmpegLibDir); 字面意思看着就很魔幻——两个独立组件的初始化顺序，竟然要靠 npm 脚本里的字符串顺序来保证。当时已经知道这是个炸弹，但没炸就先放着了。 现在炸了。第一反应是顺序在哪退化了。打了一圈日志： tracing::info!("LD_LIBRARY_PATH = {}", env::var("LD_LIBRARY_PATH").unwrap_or_default()); // LD_LIBRARY_PATH = /…/bin/libvips/current/lib:/…/bin/ffmpeg/current/lib:… 顺序对的。但还是炸。 那就更深一层——看实际加载到进程里的那个 libgobject 究竟来自哪里： $ cat /proc/$(pgrep tokimo-server)/maps | grep -E "libglib|libgobject|libgio" 7f1234… r-xp … /home/lab/.../bin/libvips/current/lib/libglib-2.0.so.0 7f5678… r-xp … /home/lab/.../bin/ffmpeg/current/lib/libgobject-2.0.so.0 7f9abc… r-xp … /home/lab/.../bin/libvips/current/lib/libgio-2.0.so.0 🚨 libglib-2.0.so.0 ← libvips 的 bundle libgobject-2.0.so.0 ← ffmpeg 的 bundle libgio-2.0.so.0 ← libvips 的 bundle GLib 三件套来自两个不同的 bundle。 LD_LIBRARY_PATH 顺序根本没法保证三件套整齐落到同一个 bundle——loader 是按"找到哪个 SONAME 就用哪个"来工作的，不会"看在 X 来自 A 的份上 Y 也优先从 A 选"。 真相：两个 bundle，两份 glib 事情是这样的： 我们的 tokimo-pack…

浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路
type: item
date: 2026-05-13T00:11:54+08:00
description: 浏览器里的 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；正常情况下 …

用 Opus 半小时把博客从 Typecho 搬到 GitHub Pages
type: item
date: 2026-05-12T23:59:52+08:00
description: 用 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 形式发出。

为什么我们给 AI 沙箱手写了一套跨平台用户态 TCP/IP 栈
type: item
date: 2026-05-12T13:30:00+08:00
description: 为什么我们给 AI 沙箱手写了一套跨平台用户态 TCP/IP 栈 目录 问题为什么难 为什么不是容器 / userns 业界正在做什么 网络方案怎么选 现在的架构 性能目标不是越高越好 我们诚实承认的取舍 AI sandbox 的网络问题，表面上只是"让 guest 能访问外网"。但我担心的不是能不能 ping 通，而是：未知代码会在高频网络路径里做什么，以及我们能不能在事后解释它为什么能做。 AI agent 跑的是用户给的仓库、脚本、二进制和临时命令。它可能 curl | sh，可能跑包管理器，也可能启动一个开发服务器。网络是高频路径：DNS、TLS、包管理器、Git、模型下载、webhook、local service probe，全部都会从这里经过。如果这些流量直接进宿主内核网络栈，我们很难在跨 Linux / macOS / Windows 的语义上稳定地做审计、DLP、egress allowlist，甚至很难解释一次连接到底从哪里来、为什么被允许。 所以 Tokimo 最后选择了一条看起来更重、但边界更清楚的路：guest 里只放 tokimo-tun-pump 搬运 Ethernet frame，host 侧自己跑基于 smoltcp 的 userspace netstack。guest 可以像平常一样发包，但真正决定 flow 怎么建、怎么路由、怎么记账、怎么被策略拦截的地方，回到了 Tokimo 自己的 Rust 代码里。 问题为什么难 AI sandbox 的网络需求和传统 VM 网络不同，面临三个同时成立的约束： ┌──────────────────────────────────────────────────────────┐ │ 约束 1: 未知代码 │ │ Guest 运行任意用户仓库 / 脚本 / 二进制 │ │ 网络行为不可预测，必须从外部可解释 │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ 约束 2: 三平台一致性 │ │ Linux Auto (CH → bwrap) / macOS (VZ) / Windows (Hyper-V) │ │ 策略语义必须统一，不能每 backend 各写一套 │ └──────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────┐ │ 约束 3: 可审计 / 可解释 │ │ 每次连接需能追溯到 session、task、进程 │ │ flow log 要产品侧可控，而非依赖宿主平台状态 │ └──────────────────────────────────────────────────────────┘ 这三个约束加在一起，就排除了大部分"接通即可"的常规方案。传统的平台原生网络（nftables / pf / WFP）能解决单平台的某个约束，但无法同时满足三个。 AI sandbox 网络的真正难题不是吞吐，而是同时满足跨平台一致性和完整的流量可解释性。 为什么不是容器 / userns 容器路线我们不是没想过。userns、Landlock、seccomp、cgroup 全部拼起来，看起来像一个便宜的 sandbox，而且启动快、镜像生态成熟、调试体验也好。问题在于它们共享宿主内核，爆炸半径不够小。CVE-2022-0185、CVE-2024-1086 这种例子不需要过度展开；结论就是，只要未知代码还能打到同一个内核，AI shell 的风险就没有被切开。 对传统 CI 来说，容器可能是足够好的折中；对一个会让用户把任意仓库、任意命令、任意 agent loop 丢进去的产品来说，我们不想把"请相信 namespace 组合得足够好"当成核心安全边界。尤其网络审计还要和进程、文件、会话身份绑定时，共享内核会让很多边界变成策略约定，而不是故障域隔离。 于是主路线切到 VM：Linux 优先 Cloud Hypervisor，macOS 用 Virtualization.framework，Windows 用 Hyper-V。VM 至少给了一个硬边界：guest 用户态和宿主内核不是同一个故障域。但 Linux 现在还有一个必须诚实写出的可用性兜底：当 CH 不可用时，Auto backend 会退到 bubblewrap。bwrap 是 namespaces-based …

在浏览器里给 AI 跑命令：Tokimo sandbox 三平台 backend 的现状切片
type: item
date: 2026-05-11T18:00:00+08:00
description: 在浏览器里给 AI 跑命令：Tokimo sandbox 三平台 backend 的现状切片 目录 问题为什么难 一张总图：三平台不是三套产品 Linux / Auto backend：Cloud Hypervisor 优先，bubblewrap 自动兜底 macOS / Virtualization.framework：原生 VM，协议尽量不特殊化 Windows / Hyper-V：service、镜像打包和诊断链路正在持续推进 共同基础：tun-pump、vsock 抽象与 guest-agent 协议 为什么不直接 Docker / WSL2 业界做法 我们诚实承认的缺口 问题为什么难 在浏览器里让 AI agent 跑命令，难点不在“把一串 argv 丢给 shell”。难点是：这串命令可能来自模型、用户、网页和历史上下文的混合产物，我们无法提前审查它会不会 curl | sh、跑包管理器、开端口、扫内网、读工作区外的文件，或者把一台开发机拖进 fork bomb。 传统 Web 应用把不可信输入关在进程边界里；AI coding agent 则反过来，主动要求我们给它一个“像真机器一样”的 shell。它需要 PTY、文件系统、网络、DNS、包管理器、长进程、信号和退出码。这里的工程悖论是：越像真机器，隔离越难；越像沙箱，工具越不像真机器。 Tokimo sandbox 当前的答案不是押一个平台原语，而是把 Linux、macOS、Windows 各自最靠谱的 VM / namespace 能力收敛到同一组语义：启动 sandbox、打开 shell、转发 stdio/PTY、挂载工作区、给网络加策略、最后销毁会话状态。 金句：AI shell 的安全边界不是一条 syscall 黑名单，而是一台可预测、可销毁、可观测的小机器。 一张总图：三平台不是三套产品 flowchart TD Browser[Browser / AI tool call] --> Server[tokimo-server] Server --> API[tokimo-package-sandbox API] API --> Auto[Linux: Auto backend] Auto -->|preferred| CH[Cloud Hypervisor microVM] Auto -->|fallback| BW[bubblewrap namespaces] API --> VZ[macOS: Virtualization.framework] API --> HV[Windows: Hyper-V HCS + service] CH --> GA[tokimo-guest-agent / sandbox-init] BW --> GA VZ --> GA HV --> GA GA --> Shell[exec / PTY / shell] GA --> FS[workspace mount] GA --> Pump[tokimo-tun-pump] Pump --> Netstack[host smoltcp netstack] Netstack --> Policy[policy / audit hooks] Policy --> Internet[(network)] 跨平台一致性视图 ┌─────────────── public API ───────────────┐ │ configure / create / start / shell / stop│ └───────────────┬───────────────┬──────────┘ │ │ ┌───────▼────────┐ ┌────▼──────┐ ┌────────▼────────┐ │ Linux Auto │ │ macOS VZ │ │ Windows Hyper-V │ │ ch → bwrap │ │ virtio-vsock │ HvSocket + pipe │ └───────┬────────┘ └────┬──────┘ └────────┬────────┘ └───────────────▼─────────────────┘ guest-agent protocol + netstack 最近的主仓和 sandbox 子包 commit 说明这条线已经发生了两类重要变化。第一，Linux 产品路径从“只讲 CH”变成 Auto：优先 Cloud Hypervisor，遇到无 KVM、无 vhost-vsock、缺 CH binary 等条件时自动降级到 bubblewrap（886048c5、c2b4979）。第二，Windows…

manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验
type: item
date: 2026-05-11T18:00:00+08:00
description: 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,…