diff --git a/apps/desktop-shell/package.json b/apps/desktop-shell/package.json index a1d579fbb..f43d8a039 100644 --- a/apps/desktop-shell/package.json +++ b/apps/desktop-shell/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "WEB_PORT=3000 tauri dev", "build": "tauri build", + "stage-release-binary": "node scripts/stage-release-binary.mjs", "typecheck": "node scripts/check-config.mjs" }, "devDependencies": { diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 889d34bd2..831e95387 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -192,6 +192,11 @@ const desktopShellNavigationSource = fs.readFileSync( desktopShellNavigationPath, 'utf8', ); +const stageReleaseBinaryPath = new URL( + '../scripts/stage-release-binary.mjs', + import.meta.url, +); +const stageReleaseBinarySource = fs.readFileSync(stageReleaseBinaryPath, 'utf8'); const productionSourceRoots = [ new URL('../package.json', import.meta.url), new URL('../scripts/', import.meta.url), @@ -317,6 +322,7 @@ const expectedDesktopShellRustFiles = [ ]; const expectedDesktopShellScripts = [ 'check-config.mjs', + 'stage-release-binary.mjs', ]; function extractCargoPackageString(source, key) { @@ -644,12 +650,12 @@ assertDesktopSourceLayout(); for (const snippet of [ 'label: \'desktop-shell-release-build-smoke\'', "args: ['run', 'desktop-shell:build', '--', '--no-bundle']", + "label: 'desktop-shell-stage-release-binary'", + "args: ['run', 'desktop-shell:stage-release-binary']", 'function assertDesktopReleaseBinaryArtifact()', - "'apps'", - "'desktop-shell'", - "'src-tauri'", - "'target'", - "'release'", + "'build'", + "'native'", + "'desktop'", 'genarrative-desktop-shell.exe', 'genarrative-desktop-shell', 'desktop release binary is missing', @@ -674,9 +680,28 @@ for (const snippet of [ } } +for (const snippet of [ + "'apps'", + "'desktop-shell'", + "'src-tauri'", + "'target'", + "'release'", + "'build'", + "'native'", + "'desktop'", + 'fs.copyFileSync(sourcePath, stagedPath)', + 'fs.chmodSync(stagedPath, sourceMode & 0o777)', + "console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`)", +]) { + if (!stageReleaseBinarySource.includes(snippet)) { + throw new Error(`desktop shell release staging script missing ${snippet}`); + } +} + for (const [scriptName, expected] of Object.entries({ dev: 'WEB_PORT=3000 tauri dev', build: 'tauri build', + 'stage-release-binary': 'node scripts/stage-release-binary.mjs', typecheck: 'node scripts/check-config.mjs', })) { assertPackageScript(packageConfig, 'desktop shell package', scriptName, expected); @@ -685,6 +710,7 @@ for (const [scriptName, expected] of Object.entries({ for (const [scriptName, expected] of Object.entries({ 'desktop-shell:dev': 'npm --prefix apps/desktop-shell run dev', 'desktop-shell:build': 'npm --prefix apps/desktop-shell run build --', + 'desktop-shell:stage-release-binary': 'npm --prefix apps/desktop-shell run stage-release-binary', 'desktop-shell:typecheck': 'npm --prefix apps/desktop-shell run typecheck', 'desktop-shell:test': 'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml', })) { diff --git a/apps/desktop-shell/scripts/stage-release-binary.mjs b/apps/desktop-shell/scripts/stage-release-binary.mjs new file mode 100644 index 000000000..13ce5bac8 --- /dev/null +++ b/apps/desktop-shell/scripts/stage-release-binary.mjs @@ -0,0 +1,78 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(new URL('../../../', import.meta.url).pathname); +const releaseDir = path.join( + repoRoot, + 'apps', + 'desktop-shell', + 'src-tauri', + 'target', + 'release', +); +const executableName = + process.platform === 'win32' + ? 'genarrative-desktop-shell.exe' + : 'genarrative-desktop-shell'; +const sourcePath = path.join(releaseDir, executableName); +const stagedDir = path.join(repoRoot, 'build', 'native', 'desktop'); +const stagedPath = path.join(stagedDir, executableName); + +function assertExecutable(filePath, label) { + if (!fs.existsSync(filePath)) { + throw new Error(`desktop release binary is missing: ${filePath}`); + } + + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size < 1024 * 1024) { + throw new Error(`${label} must be a real non-empty executable file`); + } + + const handle = fs.openSync(filePath, 'r'); + try { + const header = Buffer.alloc(8); + fs.readSync(handle, header, 0, header.length, 0); + + if (process.platform === 'linux') { + const isElf = + header[0] === 0x7f && + header[1] === 0x45 && + header[2] === 0x4c && + header[3] === 0x46; + if (!isElf || (stat.mode & 0o111) === 0) { + throw new Error(`${label} must be an executable ELF file`); + } + return; + } + + if (process.platform === 'darwin') { + const machMagic = header.readUInt32BE(0); + const isMachO = + machMagic === 0xcafebabe || + machMagic === 0xcafed00d || + machMagic === 0xfeedface || + machMagic === 0xfeedfacf; + if (!isMachO || (stat.mode & 0o111) === 0) { + throw new Error(`${label} must be an executable Mach-O file`); + } + return; + } + + if (process.platform === 'win32') { + if (header[0] !== 0x4d || header[1] !== 0x5a) { + throw new Error(`${label} must be a PE executable`); + } + } + } finally { + fs.closeSync(handle); + } +} + +assertExecutable(sourcePath, 'desktop release binary'); +fs.mkdirSync(stagedDir, {recursive: true}); +fs.copyFileSync(sourcePath, stagedPath); +const sourceMode = fs.statSync(sourcePath).mode; +fs.chmodSync(stagedPath, sourceMode & 0o777); +assertExecutable(stagedPath, 'staged desktop release binary'); + +console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7bc1ead5f..bd26b1e05 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2988,8 +2988,8 @@ ## 2026-06-20 桌面壳 release 二进制产物验收 - 背景:桌面壳统一验收已经执行 `tauri build --no-bundle`,但如果只看命令退出码,后续产物路径、二进制名称或平台输出发生漂移时,可能无法证明本机确实产出了可执行桌面壳。 -- 决策:`npm run check:native-shells` 在桌面 release build smoke 后必须检查 `apps/desktop-shell/src-tauri/target/release/genarrative-desktop-shell` 存在、体积非空,并按当前平台校验 Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位。`apps/desktop-shell/scripts/check-config.mjs` 必须反查根级门禁仍保留 release build smoke 和二进制产物检查。该检查不启动 GUI,也不生成平台安装包。 -- 影响范围:`scripts/check-native-shells.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、原生壳方案文档。 +- 决策:`npm run check:native-shells` 在桌面 release build smoke 后必须运行 `desktop-shell:stage-release-binary`,把 `apps/desktop-shell/src-tauri/target/release/genarrative-desktop-shell` 或 Windows `.exe` 复制到根目录 `build/native/desktop/`,再检查 staged 二进制存在、体积非空,并按当前平台校验 Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位。`apps/desktop-shell/scripts/check-config.mjs` 必须反查根级门禁仍保留 release build smoke、staging 步骤和二进制产物检查。该检查不启动 GUI,也不生成平台安装包。 +- 影响范围:`scripts/check-native-shells.mjs`、`apps/desktop-shell/package.json`、`apps/desktop-shell/scripts/stage-release-binary.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、原生壳方案文档。 - 验证方式:`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 ## 2026-06-20 移动壳 smoke 脚本进入生产扫描 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index d4a953ea3..d02944179 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -509,7 +509,7 @@ GameBridge 禁止: 2026-06-19 追加:桌面壳 macOS 媒体权限说明进入门禁。Tauri 桌面壳仍不新增摄像头或麦克风 HostBridge method,不把系统媒体能力暴露成桌面命令;同源 H5 页面可继续使用浏览器标准 `getUserMedia` 承接儿童动作热身 Demo 的实时摄像头输入和汪汪声浪正式 runtime 的实时麦克风输入。macOS 分发包必须通过 `bundle.macOS.infoPlist="Info.plist"` 合并受控用途说明:`NSCameraUsageDescription` 只描述同源 H5 实时动作输入,`NSMicrophoneUsageDescription` 只描述同源 H5 实时声音玩法。`apps/desktop-shell/scripts/check-config.mjs` 会校验 plist 路径和两条文案,并把 `Info.plist` 纳入生产壳替身词扫描,防止桌面包缺少系统授权说明、把媒体权限扩写成通用采集能力,或在 macOS 分发配置里留下临时替身文本。 -2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;构建后还必须检查 `target/release/genarrative-desktop-shell` 存在、非空且符合当前平台可执行文件头。`apps/desktop-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 release build smoke、二进制路径、Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位检查,避免桌面产物验收被改成只看命令退出码。该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。 +2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;构建后 `desktop-shell:stage-release-binary` 会把当前平台二进制复制到根目录 `build/native/desktop/genarrative-desktop-shell` 或 `build/native/desktop/genarrative-desktop-shell.exe`,该目录沿用根 `build/` 的 gitignore,只作为本机或 CI 可收集产物目录。统一验收必须检查 staged 二进制存在、非空且符合当前平台可执行文件头。`apps/desktop-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 release build smoke、staging 步骤、二进制路径、Linux ELF / macOS Mach-O / Windows PE 文件头和可执行位检查,避免桌面产物验收被改成只看命令退出码。该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。 2026-06-18 追加:移动壳 Expo managed config 烟测进入统一验收。`npm run check:native-shells` 会执行 `npm run mobile-shell:config`,在 `apps/mobile-shell` 目录内调用 `expo config --type public --json`,校验 Expo CLI 实际解析结果中的包名、scheme、深链、ATS / cleartext / backup / 相机与麦克风权限、启动页、adaptive icon、插件配置和 HostBridge 版本没有漂移。`apps/mobile-shell/scripts/check-config.mjs` 会反查根级门禁仍保留 EAS build profile、Expo config 和 Metro export 三个移动分发烟测,避免移动壳验收退回到只看源码类型检查。 diff --git a/package.json b/package.json index e60cee015..7fda88060 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "mobile-shell:export": "npm --prefix apps/mobile-shell run export:smoke", "desktop-shell:dev": "npm --prefix apps/desktop-shell run dev", "desktop-shell:build": "npm --prefix apps/desktop-shell run build --", + "desktop-shell:stage-release-binary": "npm --prefix apps/desktop-shell run stage-release-binary", "desktop-shell:typecheck": "npm --prefix apps/desktop-shell run typecheck", "desktop-shell:test": "cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml", "check:native-shells": "node scripts/check-native-shells.mjs", diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index b50f4e4a6..fe473eec5 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1004,6 +1004,11 @@ const steps = [ command: npmCommand, args: ['run', 'desktop-shell:build', '--', '--no-bundle'], }, + { + label: 'desktop-shell-stage-release-binary', + command: npmCommand, + args: ['run', 'desktop-shell:stage-release-binary'], + }, ]; function shouldScanProductionShellFile(filePath) { @@ -2904,18 +2909,16 @@ function assertHostBridgeLayerLayout() { } function assertDesktopReleaseBinaryArtifact() { - const releaseDir = path.join( - 'apps', - 'desktop-shell', - 'src-tauri', - 'target', - 'release', - ); const executableName = process.platform === 'win32' ? 'genarrative-desktop-shell.exe' : 'genarrative-desktop-shell'; - const executablePath = path.join(releaseDir, executableName); + const executablePath = path.join( + 'build', + 'native', + 'desktop', + executableName, + ); if (!fs.existsSync(executablePath)) { throw new Error(`desktop release binary is missing: ${executablePath}`);