manifest 驱动的桌面应用:在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验

浏览器给了我们 DOM,但没有给我们桌面;React 给了我们组件,但没有给我们应用生命周期。

目录

为什么这件事难:窗口不是路由,应用也不是组件

在传统 Web 里,我们习惯把「一个功能」想成一条 route:URL 变化,组件树变化,状态跟着 React 生命周期走。但 tokimo 要做的是浏览器里的桌面 OS:窗口可以拖拽、缩放、最小化、恢复、层叠;同一个 app 可以同时开多个实例;一个文件可以被不同 viewer 打开;一个设置页可能只是系统窗口;一个传输进度条又不应该被持久化成长期任务。

难点不在「渲染一个窗口」,而在三件事同时成立:

  1. shell 层不能 import 每个业务 app,否则系统层会被应用层反向污染;
  2. 新 app 不能改一堆 switch/case,否则桌面会变成手工路由表;
  3. 窗口状态要能持久化,但显示名、组件、默认尺寸又要跟 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 分成四类:pagesystemapppopup。它们不是视觉风格分类,而是生命周期、路由方式和持久化语义的分类。

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 一开始只是一个空对象,连 pagesystem 两个元类型也是由 app framework 注入。系统层只知道「type → lazy component」,不知道 VideoApp、FinderContent、TerminalContent 的存在。

更有意思的是 appNameWindowState.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.tswindowType: "text" 注册成一个 category: "app" 的 viewer。打开文件时,OpenWindowParams 里携带 fileNamefilePathfileSystemId,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.tsxemitPick(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,给 idcategorywindowTypecomponentdefaultSizeicon/color/appNameapp-registry.ts 会自动发现并注入 registry。差别只是:内置 app 编译进 shell;第三方 app 通过 installed-apps API 和 dynamic import 接入 shell。

金句:从框架视角看,内置 app 和第三方 app 的区别,不是能不能打开窗口,而是谁负责生成 manifest。

framework-level 的一句话

Tokimo 的 manifest 桌面模型,本质上是在浏览器里给「应用」补上宿主协议:声明身份、声明窗口、声明运行时,再让 shell 用 registry 把它们调度成可持久、可通信、可组合的桌面体验。