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 把它们调度成可持久、可通信、可组合的桌面体验。
版权属于:一名宅。
本文链接:https://zhaiyiming.com/archives/90.html
转载时须注明出处及本声明