开发文档
查看 MarkdownKasumi 双端 WebView 框架开发文档
项目名称:Kasumi(宿主侧标识符前缀
KSM)
适用范围:Windows(C++/WebView2)、Android(Kotlin/WebView)
读者:框架维护者、Windows/Android/前端开发者
本文档描述项目约束和实现规范。文中“必须/禁止”为硬性要求,“建议/可以”为推荐或可选做法。
https://blog.miomoe.cn/docs/agents.mdx。该文档定义 Docs Agent 的鉴权方式、接口约束与写入规范;若任务涉及线上文档站读写,必须优先遵循该文档。本文档与 agents.mdx 冲突时,以 agents.mdx 为准。protocol/commands.schema.json 和 protocol/errors.json;仍无法确定时,在代码中留 // TODO(protocol):,不要自行发明协议。protocol/ 中定义。KSM 前缀规范;协议字符串不得加前缀。Kasumi 是专为 Windows + Android 双端设计的「一套 Web 前端、双端原生独立壳」跨平台应用框架。
项目在文档、目录、应用名中一律以 Kasumi 指代;宿主侧(C++ / Kotlin / TypeScript)的可命名标识符一律使用 KSM 前缀,落地形态见 §10.5。
| 编号 | 原则 | 说明 |
|---|---|---|
| P1 | 协议统一,实现分离 | 命令签名双端一致,底层各写各的 |
| P2 | 前端零平台感知 | 业务代码不得出现平台分支 |
| P3 | 原生层只提供原子能力 | 原生层不得承载业务逻辑 |
| P4 | 权限原生强制 | 权限校验必须在 C++ 和 Kotlin 两侧实现 |
| P5 | 不做多余平台 | 仅支持 Windows + Android |
| P6 | 不引入中间 runtime | 不得引入 Rust / Node / JVM 之外的运行时 |
| P7 | 内置 API 少而精 | 只覆盖高频系统能力 |
| P8 | 入口唯一 | 宿主侧能力必须全部经 KSM 根入口暴露(§10.5),标识符统一 KSM 前缀 |
addJavascriptInterface| 维度 | 约定 | 示例 |
|---|---|---|
| 项目名 | Kasumi | 文档标题、仓库名、应用名、Windows 数据目录 |
| 前端包名 | @kasumi/* | @kasumi/bridge、@kasumi/api |
| CLI 命令 | ksm | ksm dev / ksm build / ksm run |
| Android 包名 | 反写域名,且必须包含 kasumi,默认 dev.kasumi.app | dev.kasumi.app |
| 宿主类 / 结构体 / 类型 | KSM 前缀 + 领域名(PascalCase) | KSMBridge、KSMFs、KSMPermission |
| 调用形态 | KSM::<子模块>::<动作> | KSM::Fs::ReadText(...) |
| 协议命令 / 事件 / 权限名 | 不加前缀,<domain>.<action> | fs.readText、window.resized |
| Windows 数据目录 | %APPDATA%/Kasumi/... | %APPDATA%/Kasumi/data |
约束:
KSM 前缀。命令名是跨端契约,前缀会污染 JSON 载荷、日志检索与契约测试断言。KSM 前缀,形态见 §10.5。@kasumi/* 与 KSM.* 访问能力,不得直接引用宿主注入对象。applicationId 必须包含 kasumi,默认 dev.kasumi.app。| 项 | 约束 |
|---|---|
| 语言 | C++20 |
| 编译器 | MSVC 2022 (v143) 或更高 |
| 构建 | CMake ≥ 3.25 + vcpkg |
| 渲染 | WebView2 (Evergreen Runtime) |
| UI | Win32 API(不使用 MFC/Qt) |
| 内存 | RAII + ComPtr,禁止裸 new/delete |
| 项 | 约束 |
|---|---|
| 语言 | Kotlin 1.9+ |
| minSdk | 24 |
| targetSdk | 34+ |
| 渲染 | 系统 WebView + androidx.webkit |
| 桥接 | WebViewCompat.addWebMessageListener |
| 异步 | Kotlin Coroutines |
| 资源加载 | WebViewAssetLoader |
| 项 | 约束 |
|---|---|
| 语言 | TypeScript 5.x(strict 模式) |
| 构建 | Vite |
| 包管理 | pnpm |
| 目标 | ES2020+ |
| 项 | 约束 |
|---|---|
| 消息格式 | JSON(UTF-8) |
| 协议风格 | JSON-RPC 2.0 简化变体 |
| 契约源 | protocol/commands.schema.json |
| 错误源 | protocol/errors.json |
/
├── protocol/ # 唯一事实源
│ ├── commands.schema.json # 所有命令契约
│ ├── errors.json # 错误码全集
│ ├── permissions.schema.json # 权限声明
│ ├── README.md # 协议与权限说明
│ └── codegen/ # 代码生成脚本
├── frontend/ # 统一 Web 前端(包名 @kasumi/*)
│ ├── src/
│ │ ├── bridge/ # invoke / event / capabilities(KSM 根入口)
│ │ ├── api/ # 内置 API 的 TS 封装
│ │ └── app/ # 业务代码
│ └── package.json
├── windows/ # C++ / WebView2 宿主
│ ├── src/
│ │ ├── main.cpp
│ │ ├── bridge/ # 协议解析 / 路由 / 回调
│ │ ├── commands/ # 内置命令实现
│ │ ├── permissions/ # 权限校验
│ │ └── platform/ # Win32 封装
│ ├── CMakeLists.txt
│ └── vcpkg.json
├── android/ # Kotlin / WebView 宿主
│ ├── app/src/main/
│ │ ├── java/.../bridge/ # 协议解析 / 路由 / 回调
│ │ ├── java/.../commands/ # 内置命令实现
│ │ ├── java/.../permissions/ # 权限校验
│ │ └── assets/ # 前端构建产物
│ └── build.gradle.kts
├── cli/ # 统一 dev/build/run 工具(命令名 ksm)
├── tests/
│ ├── contract/ # 双端契约测试(JS 用例)
│ └── e2e/
└── DEVELOPMENT.md # 本文档约束:
protocol/ 必须是唯一事实源,任何命令变更先改这里。windows/ 和 android/ 不得互相引用。frontend/ 不得出现 if (platform === 'windows') 之类的分支。applicationId 必须包含 kasumi,默认 dev.kasumi.app。共四类:请求、响应、事件、取消。
{
"type": "request",
"id": "req-0001",
"cmd": "fs.readText",
"args": { "path": "app://data/config.json" },
"timeoutMs": 5000,
"windowId": "win-1"
}id:必须全局唯一,格式 req-<递增数字>。cmd:必须在 commands.schema.json 中已定义。args:必须符合该命令的 schema。timeoutMs:可以省略,默认 10000。windowId:可以省略。多窗口场景下,原生必须用它隔离请求路由表;单窗口可忽略。成功:
{ "type": "response", "id": "req-0001", "ok": true, "data": { ... } }失败:
{
"type": "response",
"id": "req-0001",
"ok": false,
"error": { "code": "PERMISSION_DENIED", "message": "...", "detail": { ... } }
}error.code 必须属于 errors.json 全集。{ "type": "event", "event": "window.resized", "payload": { "width": 800, "height": 600 } }id。commands.schema.json 的 events 段中定义。event.subscribe 显式订阅。{ "type": "cancel", "id": "req-0001" }CANCELLED 响应。protocol/errors.json 至少包含:
| code | 含义 |
|---|---|
INVALID_ARGS | 参数不符合 schema |
UNKNOWN_COMMAND | 命令未注册 |
PERMISSION_DENIED | 权限拒绝 |
NOT_SUPPORTED | 当前平台不支持 |
NOT_FOUND | 资源不存在 |
IO_ERROR | IO 失败 |
CANCELLED | 被取消 |
TIMEOUT | 超时 |
BUSY | 资源忙 |
INTERNAL_ERROR | 内部错误 |
约束:
message 可以不同,但必须是英文,便于日志检索。detail 结构由各命令自定义,可以省略。commands.schema.json 中每个命令:
{
"fs.readText": {
"args": {
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
},
"result": {
"oneOf": [
{ "type": "string" },
{ "type": "object", "properties": { "file": { "type": "string" } }, "required": ["file"] }
]
},
"permissions": ["fs.read"],
"platforms": ["windows", "android"],
"since": "<兼容性标记>"
}
}platforms 标注支持平台,任一平台缺失时,该端必须返回 NOT_SUPPORTED。result 若可能返回大文件句柄,必须使用联合类型 string 或 { file: string }。{ "file": "app://tmp/xxx" },前端再读取。fs.readText 等命令若结果超过 64KB,必须返回 { "file": "app://tmp/xxx" },前端必须能处理联合类型。timeoutMs 内返回响应或 TIMEOUT。前端必须通过以下命令订阅/退订事件:
{ "cmd": "event.subscribe", "args": { "event": "window.resized" } }
{ "cmd": "event.unsubscribe", "args": { "event": "window.resized" } }event.subscribe 必须双端实现。webview.reloaded 为系统事件,原生必须在 WebView 加载完成后主动推送,无需前端订阅。权限在 protocol/permissions.schema.json 中定义:
{
"fs.read": {
"description": "读取沙箱内文件",
"params": { "path": "app://data/**" }
},
"dialog.openFile": { "description": "打开文件选择器" },
"notification.show": { "description": "显示系统通知", "runtime": "android" }
}runtime 可以标注需要运行时授权的平台。PERMISSION_DENIED,不得返回 NOT_SUPPORTED。fs.read 必须校验 path 是否落在允许前缀内。..、~、绝对路径、空字节),再做规范化(resolve)与前缀校验。顺序不可颠倒。app://res/../data/x 解析为 app://data/x 而误放行。前端可以调用:
{ "cmd": "capabilities.query", "args": {} }返回:
{
"platform": "windows",
"commands": ["fs.readText", "dialog.message"],
"permissions": ["fs.read"]
}需要运行时授权的权限(如 Android 通知),前端必须调用:
{ "cmd": "permission.request", "args": { "permission": "notification.show" } }{ "granted": true | false }。PERMISSION_DENIED。permission.request 必须双端实现;Windows 端若无需运行时授权,可直接返回 { "granted": true }。前端必须只使用虚拟路径:
| 虚拟路径 | 含义 | 可写 |
|---|---|---|
app://data/** | 应用私有数据目录 | 是 |
app://cache/** | 缓存目录 | 是 |
app://tmp/** | 临时目录 | 是 |
app://res/** | 只读资源(打包内) | 否 |
app://assets/** | 前端构建产物 | 否 |
| 虚拟路径 | Windows | Android |
|---|---|---|
app://data | %APPDATA%/Kasumi/data | filesDir/data |
app://cache | %LOCALAPPDATA%/Kasumi/cache | cacheDir |
app://tmp | %TEMP%/Kasumi | cacheDir/tmp |
app://res | 可执行文件同目录 res/ | assets/res |
app://assets | 内嵌资源目录 | assets/web |
约束:
SetVirtualHostNameToFolderMapping("app.local", ...)。WebViewAssetLoader 映射 https://app.local/。http://localhost:5173。https://app.local/index.html。Activity.onCreate 中初始化 WebView。前端必须处理 webview.reloaded 事件:
capabilities.query 并重建状态。event.subscribe 订阅所需事件。configChanges 处理,避免重建 WebView。onTrimMemory 清理缓存。windowId 用于路由。KSM 根入口必须通过 windowId 或上下文对象区分窗口,不得假设全局单窗口。[WebView 线程/主线程]
│ 接收消息
▼
[桥接层] ── 解析、权限校验、路由
│
▼
[命令执行线程池] ── 实际执行
│
▼
[WebView 线程/主线程] ── 回发响应BUSY。PostWebMessageAsJson 回发。std::thread 或 Windows ThreadPool。Dispatchers.Main 回发。Dispatchers.IO 或 Dispatchers.Default。unsafe-eval、unsafe-inline。app.local / localhost 的 URL 必须交给系统浏览器。window.open,行为同上。protocol/commands.schema.json 的 shell.openExternal 契约中声明。WebMessageReceived 来源。AreDevToolsEnabled=false。addJavascriptInterface。WebViewCompat.addWebMessageListener 并限制 allowedOriginRules。setAllowFileAccess(false)、setAllowContentAccess(false)。setMixedContentMode(MIXED_CONTENT_NEVER_ALLOW)。permission.request 触发。..、~、绝对路径、空字节的输入。protocol/commands.schema.json 生成或校验。每个命令实现必须:
| 场景 | 处理 |
|---|---|
| 平台不支持 | 返回 NOT_SUPPORTED |
| 语义可映射 | 映射为统一语义(如返回虚拟句柄) |
| 无法映射 | 返回 NOT_SUPPORTED,并在 capabilities.query 中排除 |
协议层(跨端契约,不得加 KSM 前缀):
<domain>.<action>,全小写驼峰。<domain>.<event>,全小写。<domain>.<action>,与命令名对齐。宿主层(C++ / Kotlin / TypeScript,必须加 KSM 前缀):见 §10.5。
统一入口是名为 KSM 的根类,能力以子模块挂在其上,调用形如 KSM::Fs::ReadText(...)(C++)/ KSM.fs.readText(...)(Kotlin、TS)。命令入口不得散落在全局函数或无前缀裸类上。
| 项 | C++(Windows) | Kotlin(Android) | TypeScript(前端) |
|---|---|---|---|
| 根入口 | struct KSM(静态聚合,禁止实例化) | object KSM | class KSM(静态成员) |
| 子模块 | 嵌套 struct KSM::Fs、struct KSM::Dialog | object KSMFs、object KSMDialog | class KSMFs、class KSMDialog |
| 挂载名 | PascalCase 嵌套类型 Fs | camelCase 属性 fs | camelCase 静态成员 fs |
| 调用形态 | KSM::Fs::ReadText(path) | KSM.fs.readText(path) | KSM.fs.readText(path) |
| 命令处理器 | class KSMFsReadTextHandler | class KSMFsReadTextHandler | — |
| 基础设施 | KSMRouter、KSMPermission、KSMCommandRegistry | KSMRouter、KSMPermission、KSMCommandRegistry | KSMTransport、KSMBus |
| 文件命名 | ksm_<domain>.h / .cpp | KSM<Domain>.kt | ksm/<domain>.ts |
| 常量 / 枚举 | kKsmMaxMessageBytes、enum class KSMMode | const val KSM_MAX_MESSAGE_BYTES、enum class KSMMode | const KSM_MAX_MESSAGE_BYTES |
| 日志 tag | KSM | KSM | KSM |
| 局部 / 参数 / 数据字段 | camelCase,不带前缀 | camelCase,不带前缀 | camelCase,不带前缀 |
约束:
KSM,不得出现 KSMApp / KasumiApp 之类平行入口。KSM + 领域名:KSMFs、KSMDialog、KSMClipboard、KSMNotification、KSMWindow。KSMFs::ReadText 正确,KSMFs::KSMReadText 错误。KSM 或 KSM<Domain> 的静态成员。KSM 前缀。| 成员 | C++ | Kotlin / TS | 说明 |
|---|---|---|---|
| 生命周期 | KSM::Init() / KSM::Shutdown() | KSM.init() / KSM.shutdown() | 建/销桥接层与请求表 |
| 统一调用 | KSM::Invoke(windowId, cmd, args) | KSM.invoke(cmd, args) | 走 §4.1.1 请求结构 |
| 事件订阅 | KSM::On(event, handler) | KSM.on(event, handler) | 走 §4.1.3 |
| 运行时信息 | KSM::Get() | KSM.get() | 返回 capabilities.query 的缓存结果(§5.4) |
KSM::Get() 必须返回缓存值,不得每次调用都发一次 capabilities.query。
// windows/src/ksm.h
#pragma once
#include <string>
#include <string_view>
// 根入口:只做聚合与生命周期,不承载业务逻辑(P3)
struct KSM final {
KSM() = delete; // 禁止实例化
// 子模块:文件系统
struct Fs final {
// 只接受虚拟路径;真实路径解析集中在此处,便于审计(§6.2)
static std::string ReadText(std::string_view virtualPath);
static void WriteText(std::string_view virtualPath, std::string_view content);
};
// 子模块:对话框
struct Dialog final {
static void Message(std::string_view title, std::string_view content);
static bool Confirm(std::string_view title, std::string_view content);
};
static void Init();
static void Shutdown();
static void Invoke(std::string_view windowId, std::string_view cmd, std::string_view argsJson);
};// android/.../bridge/KSM.kt
object KSM {
val fs = KSMFs
val dialog = KSMDialog
fun init() { /* 注册 WebMessageListener 与请求表 */ }
fun shutdown() { /* 丢弃未完成请求,见 §7.3 */ }
fun invoke(windowId: String?, cmd: String, args: Map<String, Any?>): Any? {
// 走原生路由,不经过前端 transport
return KSMRouter.route(windowId, cmd, args)
}
}// frontend/src/bridge/ksm.ts
import { KSMBus } from './ksm/bus';
import { KSMFs } from './ksm/fs';
import { KSMDialog } from './ksm/dialog';
import { KSMTransport } from './ksm/transport';
export class KSM {
private static readonly transport = new KSMTransport();
private static readonly bus = new KSMBus();
static readonly fs = new KSMFs(KSM.transport);
static readonly dialog = new KSMDialog(KSM.transport);
static invoke<TResult>(cmd: string, args?: Record<string, unknown>): Promise<TResult> {
return KSM.transport.send<TResult>(cmd, args);
}
static on(event: string, handler: (payload: unknown) => void): () => void {
return KSM.bus.subscribe(event, handler);
}
}反例:
// 错误:绕过 KSM 根入口直连宿主注入对象
(window as unknown as { chrome: { webview: { postMessage: (v: unknown) => void } } })
.chrome.webview.postMessage({ cmd: 'fs.readText' });
// 错误:宿主侧类型不带 KSM 前缀,跨端符号审计失效
class FsApi { /* ... */ }必须实现:
| 命令 | Windows | Android | 说明 |
|---|---|---|---|
ping | 支持 | 支持 | 测试通路 |
capabilities.query | 支持 | 支持 | 能力查询 |
app.getInfo | 支持 | 支持 | 版本、平台、语言 |
fs.readText | 支持 | 支持 | 沙箱内读文本,返回 string 或 { file } |
fs.writeText | 支持 | 支持 | 沙箱内写文本 |
fs.exists | 支持 | 支持 | 判断存在 |
fs.remove | 支持 | 支持 | 删除 |
dialog.message | 支持 | 支持 | 提示框 |
dialog.confirm | 支持 | 支持 | 确认框 |
dialog.openFile | 支持 | 支持 | 文件选择,返回虚拟句柄 |
dialog.saveFile | 支持 | 支持 | 保存选择 |
clipboard.readText | 支持 | 支持 | 读剪贴板 |
clipboard.writeText | 支持 | 支持 | 写剪贴板 |
notification.show | 支持 | 支持 | 系统通知 |
permission.request | 支持 | 支持 | 运行时权限请求 |
event.subscribe | 支持 | 支持 | 订阅事件 |
event.unsubscribe | 支持 | 支持 | 退订事件 |
window.setTitle | 支持 | 不支持 | Android 返回 NOT_SUPPORTED |
window.minimize | 支持 | 不支持 | 同上 |
window.maximize | 支持 | 不支持 | 同上 |
window.close | 支持 | 支持 | 关闭应用 |
app.exit | 支持 | 支持 | 退出 |
建议实现(后续版本):
fs.readDir、fs.mkdir、fs.stathttp.downloadshell.openExternaldevice.getInfo(Android)window.setSize(Windows)保证双端对同一命令的行为一致,不只是签名一致。
tests/contract/
├── cases/
│ ├── ping.test.ts
│ ├── fs.readText.test.ts
│ └── dialog.message.test.ts
├── helpers.ts
└── runner.tsimport { invoke } from '@kasumi/bridge';
import { expectError, expectOk } from './helpers';
test('fs.readText 正常读取', async () => {
await invoke('fs.writeText', { path: 'app://data/a.txt', content: 'hi' });
const text = await invoke('fs.readText', { path: 'app://data/a.txt' });
expect(text).toBe('hi');
});
test('fs.readText 拒绝越权路径', async () => {
await expectError(
invoke('fs.readText', { path: 'app://res/../data/x' }),
'PERMISSION_DENIED'
);
});runner.ts 必须提供与 @kasumi/bridge 一致的 invoke 接口,并注入到测试环境。INVALID_ARGS,不得崩溃。traceId,便于跨端日志检索。ComPtr 管理 COM。std::string / std::wstring_view,避免裸 wchar_t*。suspend 函数处理异步。onPostMessage 中做耗时操作。try/catch 包裹所有 JNI/SDK 调用。KSMFs 等对象必须直接实现命令逻辑,不得通过 KSM.invoke 递归调用自身。any(除非有 // eslint-disable)。KSM 前缀,入口为 KSM(§10.5)。window.chrome.webview 或 window.androidBridge,必须走 KSM.invoke / KSM.fs.*。protocol/commands.schema.json 增加命令。protocol/permissions.schema.json 增加权限(如需)。pnpm codegen 生成 TS/C++/Kotlin 常量、参数校验器、命令注册表、事件常量、错误码常量、权限常量。frontend/src/api/ 增加 TS 封装,并挂到 KSM 根入口(§10.5)。tests/contract/cases/ 增加用例。protocol/README.md 更新协议说明。不得跳过第 1~3 步。
feat(protocol): add fs.readDir
feat(windows): implement fs.readDir
feat(android): implement fs.readDir
test(contract): add fs.readDir cases按顺序执行,不得跳级:
pingcapabilities.queryfs.readText / fs.writeTextwebview.reloaded 事件event.subscribe / event.unsubscribedialog.*notification.showpermission.requestclipboard.*window.* / app.*addJavascriptInterface。windows/ 和 android/ 之间共享代码。any 绕过类型检查。protocol/ 的情况下改任一端的命令行为。KSM 前缀的宿主侧类 / 结构体 / 类型(协议字符串除外)。KSM 根入口直连 window.chrome.webview / WebViewCompat。KSM 前缀。KSM.invoke 递归调用自身。| 术语 | 含义 |
|---|---|
| 前端层 | Web 技术栈实现的业务层 |
| 桥接层 | 协议解析、路由、权限校验、回调派发 |
| 原生层 | C++/WebView2 或 Kotlin/WebView |
| 命令 | 前端可调用的原生能力 |
| 事件 | 原生主动推送给前端的消息 |
| 虚拟路径 | app:// 开头的路径,前端唯一可见路径 |
| 契约 | protocol/ 下的 schema |
| 契约测试 | 双端跑同一套用例验证行为一致 |
| Kasumi | 项目名称;文档、应用名、Windows 数据目录的正式指代 |
| KSM | 宿主侧标识符前缀与统一调用入口,形如 KSM::Fs::ReadText(§10.5) |
文档结束。
实现前检查:
- 对应契约是否已定义。
- 权限是否已声明。
- 是否同时更新了双端与测试。
- 是否违反第 16 节任意一条。
评论