diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 788d3c4e..7df2935a 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -14,6 +14,10 @@ const buildScript = fs.readFileSync(buildScriptPath, 'utf8'); const cargoManifestPath = new URL('../src-tauri/Cargo.toml', import.meta.url); const cargoManifest = fs.readFileSync(cargoManifestPath, 'utf8'); const iconDirPath = new URL('../src-tauri/icons/', import.meta.url); +const generatedPermissionDir = new URL( + '../src-tauri/permissions/autogenerated/', + import.meta.url, +); const sharedContractPath = new URL( '../../../packages/shared/src/contracts/hostBridge.ts', import.meta.url, @@ -228,6 +232,66 @@ function assertSameList(actual, expected, label) { } } +function extractTauriBuildCommands(source) { + const match = source.match(/\.commands\(\s*&\[\s*([^\]]*?)\s*\]\s*\)/); + if (!match) { + throw new Error('unable to read Tauri build manifest commands'); + } + + return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); +} + +function extractTauriInvokeCommands(source) { + const match = source.match(/tauri::generate_handler!\[\s*([^\]]*?)\s*\]/); + if (!match) { + throw new Error('unable to read Tauri invoke handler commands'); + } + + return match[1] + .split(',') + .map((command) => command.trim()) + .filter(Boolean); +} + +function assertGeneratedPermissions(commandNames) { + if (!fs.existsSync(generatedPermissionDir)) { + return; + } + + const expectedPermissionFiles = new Set( + commandNames.map((command) => `${command}.toml`), + ); + for (const entry of fs.readdirSync(generatedPermissionDir, { + withFileTypes: true, + })) { + if (entry.isDirectory()) { + throw new Error( + `desktop shell generated permissions must not include nested directory ${entry.name}`, + ); + } + if (!expectedPermissionFiles.has(entry.name)) { + throw new Error( + `desktop shell generated permission exposes an unexpected command: ${entry.name}`, + ); + } + + const permissionFile = new URL(entry.name, generatedPermissionDir); + const permissionSource = fs.readFileSync(permissionFile, 'utf8'); + const commandName = entry.name.replace(/\.toml$/, ''); + for (const expectedSnippet of [ + `identifier = "allow-${commandName.replaceAll('_', '-')}"`, + `commands.allow = ["${commandName}"]`, + `commands.deny = ["${commandName}"]`, + ]) { + if (!permissionSource.includes(expectedSnippet)) { + throw new Error( + `desktop shell generated permission ${entry.name} drifted from ${commandName}`, + ); + } + } + } +} + const sharedCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_CAPABILITIES', @@ -344,11 +408,11 @@ assertSameList( 'desktop shell dev hostCapabilities', ); -const requiredPermissions = [ +const allowedPermissions = [ 'core:default', 'allow-host-bridge-request', ]; -const requiredBuildCommands = ['host_bridge_request']; +const allowedTauriCommands = ['host_bridge_request']; const requiredMainSnippets = [ 'tauri_plugin_single_instance::init', 'resolve_desktop_single_instance_action', @@ -414,17 +478,22 @@ const requiredMainSnippets = [ 'app.notification().builder()', ]; -for (const permission of requiredPermissions) { - if (!capability.permissions?.includes(permission)) { - throw new Error(`desktop shell capability missing ${permission}`); - } -} - -for (const command of requiredBuildCommands) { - if (!buildScript.includes(command)) { - throw new Error(`desktop shell build manifest missing ${command}`); - } -} +assertSameList( + capability.permissions ?? [], + allowedPermissions, + 'desktop shell capability permissions', +); +assertSameList( + extractTauriBuildCommands(buildScript), + allowedTauriCommands, + 'desktop shell build manifest commands', +); +assertSameList( + extractTauriInvokeCommands(main), + allowedTauriCommands, + 'desktop shell invoke handler commands', +); +assertGeneratedPermissions(allowedTauriCommands); if (buildScript.includes('resolve_desktop_shell_runtime')) { throw new Error('desktop shell build manifest exposes an unused runtime command'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 70ad59c5..24e98843 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -52,6 +52,7 @@ - 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。 - 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd / 深链内容作为事件透传给 H5。桌面深链后续如需接入,必须先定义受控 URL 归一和宿主边界,不能借单实例回调直接开放任意启动参数。 - 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载打包的 `index.html` 和根 `dist` H5 资产,dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。 +- 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,防止把临时替身、占位文案或伪实现带进可分发壳。 - 2026-06-18 移动壳启动页与 adaptive icon:Expo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。 - 2026-06-18 桌面壳 bundle 图标集:Tauri 桌面壳从现有真实品牌 PNG `apps/desktop-shell/src-tauri/icons/icon.png` 派生 `32x32.png`、`128x128.png`、`128x128@2x.png`、`icon.ico` 和 `icon.icns`,并在 `bundle.icon` 中同时声明这些平台图标。检查脚本会校验 PNG 尺寸、ICO 多尺寸头部、ICNS 容器长度和 bundle 图标列表,避免后续退回单图标或替换为非品牌 / 占位素材。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index d0fdd907..e5283c64 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -307,6 +307,8 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳安装包身份固定为 `world.genarrative.desktop`,产品名为 `Genarrative`,Tauri、Node package 与 Cargo package 版本统一为 `0.1.0`。Release 主窗口只能从打包进二进制的 `index.html` 进入根 `dist` H5 资产,dev URL 只能指向本机 Vite 调试入口;CSP 必须保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:` 这类扩大桌面攻击面的来源。当前不配置自动更新器,直到存在真实更新端点、签名密钥和发布流程再接入;`apps/desktop-shell/scripts/check-config.mjs` 会校验这些包身份、版本、CSP 和 updater 禁用约束。 +2026-06-18 追加:桌面壳命令暴露面收紧为唯一受控入口。Tauri `build.rs` manifest、Rust `generate_handler!`、主窗口 capability 权限列表和本地自动生成权限目录都只能出现 `host_bridge_request`;如果本地构建残留了其它 command 的自动生成权限文件,`apps/desktop-shell/scripts/check-config.mjs` 会直接失败。后续接入新的桌面系统能力时仍先扩展 HostBridge method 和 Rust 内部分发,不新增可被 H5 直接 invoke 的 Tauri command,也不把插件 JS guest API 授权给主窗口。 + 2026-06-18 追加:两端壳的生产源码和配置禁止出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 `vi.mock` 或等价测试替身。`apps/mobile-shell/scripts/check-config.mjs` 与 `apps/desktop-shell/scripts/check-config.mjs` 会扫描各自生产入口、配置和壳实现,防止把临时替身或占位文案带进可分发壳。 2026-06-18 追加:移动壳启动页与 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;Expo `splash` 使用同一图标 `contain` 展示,Android `adaptiveIcon.foregroundImage` 使用同一透明前景图,`check-config.mjs` 会校验图标尺寸、透明像素、启动页和 adaptive icon 配置。