From 01a346a9b09429549992c75b606900eba6ea926d Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 08:42:18 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A6=81=E6=AD=A2=E5=A3=B3=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=9D=E7=95=99=E4=B8=B4=E6=97=B6=E6=9B=BF?= =?UTF-8?q?=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳配置检查扫描生产源码脚手架词 桌面壳配置检查扫描生产源码脚手架词 保留测试文件使用 mock 的边界 更新原生壳方案和团队决策记录 --- apps/desktop-shell/scripts/check-config.mjs | 72 +++++++++++++++++++ apps/mobile-shell/scripts/check-config.mjs | 70 ++++++++++++++++++ .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + 4 files changed, 145 insertions(+) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 167fdc71b..31a8e7c8f 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -21,6 +21,26 @@ const sharedContractPath = new URL( const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url); const main = fs.readFileSync(mainPath, 'utf8'); +const productionSourceRoots = [ + new URL('../package.json', import.meta.url), + new URL('../src-tauri/Cargo.toml', import.meta.url), + new URL('../src-tauri/build.rs', import.meta.url), + new URL('../src-tauri/capabilities/main.json', import.meta.url), + new URL('../src-tauri/src/', import.meta.url), + new URL('../src-tauri/tauri.conf.json', import.meta.url), +]; +const productionFileExtensions = new Set(['.json', '.mjs', '.rs', '.toml']); +const devScaffoldTerms = [ + 'mo' + 'ck', + 'fa' + 'ke', + 'place' + 'holder', + 'st' + 'ub', + 'TO' + 'DO', + 'FIX' + 'ME', + '占' + '位', + '模' + '拟', + '伪' + '造', +]; function extractCargoPackageString(source, key) { const match = source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm')); @@ -31,6 +51,58 @@ function extractCargoPackageString(source, key) { return match[1]; } +function collectProductionSourceFiles(entry) { + const stats = fs.statSync(entry); + if (stats.isDirectory()) { + const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`); + return fs + .readdirSync(entry, { withFileTypes: true }) + .flatMap((child) => + collectProductionSourceFiles( + new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory), + ), + ); + } + + const path = entry.pathname; + const extension = path.match(/\.[^.]+$/)?.[0] ?? ''; + if (!productionFileExtensions.has(extension)) { + return []; + } + if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) { + return []; + } + + return [entry]; +} + +function assertNoDevScaffoldTerms(files) { + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const lineStarts = [0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') { + lineStarts.push(index + 1); + } + } + + for (const term of devScaffoldTerms) { + const matchIndex = source.toLowerCase().indexOf(term.toLowerCase()); + if (matchIndex === -1) { + continue; + } + const line = lineStarts.filter((start) => start <= matchIndex).length; + throw new Error( + `desktop shell production source must not include ${term}: ${file.pathname}:${line}`, + ); + } + } +} + +assertNoDevScaffoldTerms( + productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)), +); + function extractStringArrayExport(source, exportName) { const match = source.match( new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 761963fc2..00d9f9180 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -19,6 +19,24 @@ const packagePath = new URL('../package.json', import.meta.url); const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const iconPath = new URL('../assets/icon.png', import.meta.url); const icon = PNG.sync.read(fs.readFileSync(iconPath)); +const productionSourceRoots = [ + new URL('../App.tsx', import.meta.url), + new URL('../app.json', import.meta.url), + new URL('../package.json', import.meta.url), + new URL('../src/', import.meta.url), +]; +const productionFileExtensions = new Set(['.json', '.mjs', '.ts', '.tsx']); +const devScaffoldTerms = [ + 'mo' + 'ck', + 'fa' + 'ke', + 'place' + 'holder', + 'st' + 'ub', + 'TO' + 'DO', + 'FIX' + 'ME', + '占' + '位', + '模' + '拟', + '伪' + '造', +]; function extractStringArrayExport(source, exportName) { const match = source.match( @@ -41,6 +59,58 @@ function extractStringArrayExport(source, exportName) { return entries; } +function collectProductionSourceFiles(entry) { + const stats = fs.statSync(entry); + if (stats.isDirectory()) { + const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`); + return fs + .readdirSync(entry, { withFileTypes: true }) + .flatMap((child) => + collectProductionSourceFiles( + new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory), + ), + ); + } + + const path = entry.pathname; + const extension = path.match(/\.[^.]+$/)?.[0] ?? ''; + if (!productionFileExtensions.has(extension)) { + return []; + } + if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) { + return []; + } + + return [entry]; +} + +function assertNoDevScaffoldTerms(files) { + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + const lineStarts = [0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') { + lineStarts.push(index + 1); + } + } + + for (const term of devScaffoldTerms) { + const matchIndex = source.toLowerCase().indexOf(term.toLowerCase()); + if (matchIndex === -1) { + continue; + } + const line = lineStarts.filter((start) => start <= matchIndex).length; + throw new Error( + `mobile shell production source must not include ${term}: ${file.pathname}:${line}`, + ); + } + } +} + +assertNoDevScaffoldTerms( + productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)), +); + const sharedCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_CAPABILITIES', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a2b56d311..34efa6811 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 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,防止把临时替身、占位文案或伪实现带进可分发壳。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 5f5310e1f..68d2fc318 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 追加:两端壳的生产源码和配置禁止出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 `vi.mock` 或等价测试替身。`apps/mobile-shell/scripts/check-config.mjs` 与 `apps/desktop-shell/scripts/check-config.mjs` 会扫描各自生产入口、配置和壳实现,防止把临时替身或占位文案带进可分发壳。 + ### Phase 4:宿主能力扩展 - 移动端接入系统分享、推送、原生登录和渠道支付。