diff --git a/.github/workflows/desktop-release-preview.yml b/.github/workflows/desktop-release-preview.yml new file mode 100644 index 0000000..eb843a1 --- /dev/null +++ b/.github/workflows/desktop-release-preview.yml @@ -0,0 +1,182 @@ +name: Desktop preview release + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - os: windows-2022 + target: windows-x64 + - os: macos-15 + target: macos-arm64 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Assert native architecture matches target + shell: bash + run: | + echo "runner.arch=${RUNNER_ARCH}" + echo "uname -m: $(uname -m)" + node -v + case "${{ matrix.target }}" in + windows-x64) + [ "$(node -p process.arch)" = x64 ] || { echo "node arch mismatch" >&2; exit 1; } + [ "$PROCESSOR_ARCHITECTURE" = AMD64 ] || { echo "PROCESSOR_ARCHITECTURE mismatch" >&2; exit 1; } + ;; + macos-arm64) + [ "$(uname -m)" = arm64 ] || { echo "uname mismatch" >&2; exit 1; } + [ "$(node -p process.arch)" = arm64 ] || { echo "node arch mismatch" >&2; exit 1; } + ;; + esac + + - name: Install dependencies + shell: bash + run: | + corepack enable + corepack yarn install --immutable + + - name: Install Godot 4.7.2 (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $archive = Join-Path $env:RUNNER_TEMP 'godot.zip' + $destination = Join-Path $env:RUNNER_TEMP 'godot-4.7.2' + Invoke-WebRequest 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_win64.exe.zip' -OutFile $archive + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne '731980f9608d61333e5baf54a2ef17210acc7a538446c0cb9969f002aca1e953') { + throw 'Godot archive checksum mismatch' + } + Expand-Archive $archive -DestinationPath $destination + Add-Content -Path $env:GITHUB_ENV -Value "GODOT_BIN=$(Join-Path $destination 'Godot_v4.7.2-stable_win64_console.exe')" + $tpz = Join-Path $env:RUNNER_TEMP 'godot-templates.tpz' + Invoke-WebRequest 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz' -OutFile $tpz + if ((Get-FileHash $tpz -Algorithm SHA512).Hash.ToLower() -ne 'ca4d71c4d7b81dfc15d1a98baa07534aa95b03fdda78a0075b06672e1648d2e5f40980c9adc28d23e1b92e732ee7bf3461997aa804af74ec2fcd7a93ccb84079') { throw 'Godot export templates checksum mismatch' } + $templates = Join-Path $env:APPDATA 'Godot\export_templates\4.7.2.stable' + New-Item -ItemType Directory -Force -Path $templates | Out-Null + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [IO.Compression.ZipFile]::OpenRead($tpz) + try { + foreach ($name in @('web_debug.zip', 'web_release.zip', 'web_nothreads_debug.zip', 'web_nothreads_release.zip')) { + $entry = $zip.Entries | Where-Object { $_.FullName -eq "templates/$name" } + if (-not $entry) { throw "template entry $name missing from tpz" } + [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, (Join-Path $templates $name), $true) + } + } finally { $zip.Dispose() } + + - name: Install Godot 4.7.2 (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + archive="$RUNNER_TEMP/godot.zip" + curl -fsSL -o "$archive" 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_macos.universal.zip' + echo "38aa16e5bba2083941fc5b3e54be0089bd4cc35e32415f5b9fd9a8a6a7b9818255d44532ea8ef94b5aef56c4b407c2d634fa4f657e4ebe681ebbf59b7bac69ca $archive" | shasum -a 512 -c - + unzip -q "$archive" -d "$RUNNER_TEMP/godot-4.7.2" + echo "GODOT_BIN=$RUNNER_TEMP/godot-4.7.2/Godot.app/Contents/MacOS/Godot" >> "$GITHUB_ENV" + tpz="$RUNNER_TEMP/godot-templates.tpz" + curl -fsSL -o "$tpz" 'https://github.com/godotengine/godot/releases/download/4.7.2-stable/Godot_v4.7.2-stable_export_templates.tpz' + echo "ca4d71c4d7b81dfc15d1a98baa07534aa95b03fdda78a0075b06672e1648d2e5f40980c9adc28d23e1b92e732ee7bf3461997aa804af74ec2fcd7a93ccb84079 $tpz" | shasum -a 512 -c - + templates="$HOME/Library/Application Support/Godot/export_templates/4.7.2.stable" + mkdir -p "$templates" + unzip -q -j "$tpz" 'templates/web_debug.zip' 'templates/web_release.zip' 'templates/web_nothreads_debug.zip' 'templates/web_nothreads_release.zip' -d "$templates" + + - name: Build Web plugin and world + shell: bash + run: | + "$GODOT_BIN" --headless --path games/mosslight --editor --import + corepack yarn build:world + corepack yarn build:web + test -f games/mosslight/build/web/index.wasm + + - name: Build portable package (Windows) + if: runner.os == 'Windows' + shell: bash + run: node apps/desktop/build.mjs + + - name: Verify installer and run from distribution (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $build = (Get-Content dist/desktop-latest.txt -Raw).Trim() + pwsh -NoProfile -File apps/desktop/verify.ps1 -BuildDirectory $build + $work = Join-Path $env:RUNNER_TEMP ('zip-smoke-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + Expand-Archive -Path (Join-Path $build 'qcode-windows-x64.zip') -DestinationPath $work + $exe = Get-ChildItem -LiteralPath $work -Recurse -Filter qcode.exe | Select-Object -First 1 + if (-not $exe) { throw 'Portable launcher executable missing from zip' } + $data = Join-Path $env:RUNNER_TEMP ('zip-smoke-data-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + $env:QCODE_DATA_HOME = $data + try { + $proc = Start-Process -FilePath $exe.FullName -WindowStyle Hidden -PassThru + $urlFile = Join-Path $data 'browser-url.txt' + $deadline = (Get-Date).AddMinutes(4) + while (-not (Test-Path $urlFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 500 } + if (-not (Test-Path $urlFile)) { throw 'Service did not become ready from unpacked zip' } + $url = [uri](Get-Content $urlFile -Raw) + $response = Invoke-WebRequest $url.AbsoluteUri -UseBasicParsing + if ($response.StatusCode -ne 200) { throw "Unpacked zip smoke got HTTP $($response.StatusCode)" } + Stop-Process -Id $proc.Id -Force + $proc.WaitForExit() + Start-Sleep -Seconds 2 + if (Get-NetTCPConnection -LocalPort $url.Port -State Listen -ErrorAction SilentlyContinue) { throw 'Service survived launcher termination' } + } finally { + Remove-Item Env:QCODE_DATA_HOME -ErrorAction SilentlyContinue + } + + - name: Build portable package (macOS) + if: runner.os == 'macOS' + shell: bash + run: corepack yarn build:desktop:darwin + + - name: Verify from unpacked distribution (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + build="$(cat dist/desktop-darwin-latest.txt)" + work="$RUNNER_TEMP/zip-smoke" + mkdir -p "$work" + ditto -x -k "$build/qcode-darwin-arm64.zip" "$work" + corepack yarn verify:desktop:darwin "$work" + (cd "$build" && shasum -a 256 -c SHA256SUMS.txt) + + - name: Checksums + shell: bash + run: | + build="$(cat dist/desktop-latest.txt 2>/dev/null || cat dist/desktop-darwin-latest.txt)" + echo "distribution dir: $build" + ls -la "$build" + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }}-preview + path: | + dist/desktop-*/qcode-*.zip + dist/desktop-*/qcode-setup-x64.exe + dist/desktop-*/SHA256SUMS.txt + if-no-files-found: error + + summary: + name: Coverage summary + needs: build + if: always() + runs-on: ubuntu-latest + steps: + - name: Record matrix coverage + shell: bash + run: | + { + echo "## Desktop preview release coverage" + echo "- windows-x64: ${{ needs.build.result }}" + echo "- macos-arm64: ${{ needs.build.result }}" + echo "- **darwin-x64: 未覆盖** — GitHub 无真实 Intel macOS runner;按约定不使用 Rosetta 代产。" + echo "- 触发方式:workflow_dispatch 手动,不自动发布;冒烟通过不替代 #19 真实模型与干净机器人工验收。" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/desktop/macos/Launcher.swift b/apps/desktop/macos/Launcher.swift index 5ce91f5..1fdfebe 100644 --- a/apps/desktop/macos/Launcher.swift +++ b/apps/desktop/macos/Launcher.swift @@ -39,7 +39,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { let alert = NSAlert() alert.messageText = "QCode 无法启动" alert.informativeText = error.localizedDescription - if !smoke { alert.runModal() } + if smoke { + // NSApp.terminate 会以 0 退出:冒烟模式必须把失败如实传给调用方,否则冲突检测形同虚设。 + FileHandle.standardError.write(Data(("QCode 无法启动: " + error.localizedDescription + "\n").utf8)) + exit(1) + } + alert.runModal() NSApp.terminate(nil) return } diff --git a/apps/desktop/pack-app.mjs b/apps/desktop/pack-app.mjs index e05a767..4fe77fc 100644 --- a/apps/desktop/pack-app.mjs +++ b/apps/desktop/pack-app.mjs @@ -1,5 +1,5 @@ // 桌面发行:把 Web 插件、世界资源与 node_modules 打进安装根目录(Win/Mac 共用)。 -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { assertWorldExportCurrent } from '../../scripts/world-export-state.mjs' @@ -56,6 +56,16 @@ export function materializeAppTree(app) { export async function embedNodeRuntime(app, { binaryName }) { mkdirSync(path.join(app, 'runtime'), { recursive: true }) cpSync(process.execPath, path.join(app, 'runtime', binaryName)) + // 官方 darwin 发行版 node 动态链接 libnode(rpath 含 @loader_path), + // 只拷二进制会得到坏包;构建用 node 静态链接时该目录不存在,自然跳过。 + if (process.platform === 'darwin') { + const libDir = path.join(path.dirname(realpathSync(process.execPath)), '..', 'lib') + if (existsSync(libDir)) { + for (const lib of readdirSync(libDir).filter(name => /^libnode\.\d+\.dylib$/.test(name))) { + cpSync(path.join(libDir, lib), path.join(app, 'runtime', lib)) + } + } + } const licenseCache = path.join(root, 'dist', `node-${process.version}-LICENSE`) if (!existsSync(licenseCache)) { mkdirSync(path.join(root, 'dist'), { recursive: true }) diff --git a/docs/construction-plan.md b/docs/construction-plan.md index 4e402dc..f4ad967 100644 --- a/docs/construction-plan.md +++ b/docs/construction-plan.md @@ -4,6 +4,8 @@ ## 产品目标 +桌面预览发行 CI(2026-09-22,已实现待验收):新增 `desktop-release-preview.yml`,仅手动触发、不自动发布;`windows-2022` / `macos-15` 矩阵在 job 开始断言 `uname -m` 与 `process.arch` 与目标一致,复用各平台现有打包与验证脚本,并从解包后的 ZIP 直接启动做原生冒烟(token 化 URL、退出清端口),上传 ZIP、安装包与 SHA256SUMS;darwin-x64 在汇总中如实标记未覆盖,不以 Rosetta 代产。fork 矩阵首跑两平台全绿,并暴露修复了官方动态链接 Node 坏包与冒烟冲突静默退出 0 两个真实缺陷;上游内首跑与评审待验收。自动冒烟不替代 #19 真实模型与干净机器验收。阶段、约束与首跑证据见 [桌面预览发行 CI 施工](desktop-release-preview-plan.md)。 + macOS 原生架构门禁(2026-09-20,已实现待验收):`build:desktop:darwin` 与 `verify-darwin.sh` 在产出构建目录或启动验证进程前,按硬件级 `hw.optional.arm64` 与 Node 自身架构判定执行环境,拒绝 Apple Silicon 上的 Rosetta 翻译执行并给出手工切换指引,不静默更改输出架构;架构判定收敛到 `apps/desktop/darwin-target.mjs` 单一来源,验证脚本经其 `--print-shell-env` 复用同一规则。11 项目标解析测试(含注入式 sysctl 分支)通过;Apple Silicon 实机覆盖原生通过、翻译 x86_64 Node 拒绝(官方 darwin-x64 Node 实测)与 Rosetta shell 内原生 arm64 Node 正常放行三条路径;Intel Mac 真机与完整重新打包未覆盖。详见 [桌面启动器说明](../apps/desktop/README.md)。 macOS 便携启动器应用图标(2026-09-20,已实现待验收):`apps/desktop/darwin-icon.mjs` 在打包时用系统 `sips` 与 `iconutil` 从 `assets/brand/android-chrome-512x512.png`(512px、透明)生成 16–512px 含 Retina 的 `AppIcon.icns` 写入 bundle Resources,`Info.plist` 声明 `CFBundleIconFile`;替换原先静默复制 `favicon.ico` 的路径,源图缺失时在产生任何构建产物前立即失败。5 项注入式图标测试通过并加入 CI;Apple M5 Pro / macOS 26.5.1 / arm64 实机重新导出世界并完成完整打包,产出 743,818 字节 ICNS,`iconutil` 反解确认 9 档完整,ZIP 解压往返后清单与图标一致,缺失源图构建即时拒绝。Finder 与应用信息目视截图见 PR 记录;Intel 机器未覆盖(图标流水线与架构无关,未在真机重复执行)。详见 [桌面启动器说明](../apps/desktop/README.md)。 diff --git a/docs/desktop-release-preview-plan.md b/docs/desktop-release-preview-plan.md new file mode 100644 index 0000000..f923dcc --- /dev/null +++ b/docs/desktop-release-preview-plan.md @@ -0,0 +1,28 @@ +# 桌面预览发行 CI 施工 + +状态:已实现待验收。工作流已落地并在 fork 完成 windows-2022 / macos-15 矩阵首跑(见下文证据);上游仓库内的首跑需维护者(admin)触发,评审与合并待验收。 + +## 阶段与范围 + +- `.github/workflows/desktop-release-preview.yml`:仅 `workflow_dispatch` 手动触发,不自动发布、不创建 GitHub Release、不推送任何渠道。 +- 矩阵:`windows-2022` → `windows-x64`;`macos-15` → `macos-arm64`。`fail-fast: false`,两平台独立判定,任何一方的通过都不替代另一方。 +- 架构守卫:job 开始即以 `uname -m`、`node -p process.arch`(Windows 另加 `PROCESSOR_ARCHITECTURE` 与 `RUNNER_ARCH`)断言与目标矩阵一致,不一致立即失败;不凭 runner 标签推断 CPU。macOS 标签的实际架构以首跑守卫结果为准,通过后在本文件与 workflow 注释中固定。 +- 构建:immutable 安装 → Godot 4.7.2(win64 / universal 固定 SHA-256)+ 官方校验和的 Web 导出模板(tpz 仅解出四个 web 变体,runner 默认不带模板)→ `build:world` + `build:web` → 各平台现有打包脚本(`apps/desktop/build.mjs` / `build:desktop:darwin`)。 +- 冒烟:Windows 运行现有 `verify.ps1`(安装提取、外部链接检查、隔离数据、单实例、退出清端口),并额外从解包后的 `qcode-windows-x64.zip` 直接启动 `QCode.exe`,轮询 token 化 `browser-url.txt` 请求完整 URL(200/303),终止后断言端口释放;macOS 解包 zip 后把 `app/` 交给 `verify-darwin.sh`(含 Rosetta 拒绝门禁),并 `shasum -c SHA256SUMS.txt`。 +- 产物:白名单上传 `qcode-*.zip`、`qcode-setup-x64.exe`、`SHA256SUMS.txt`;失败日志仅来自 runner 临时目录(`RUNNER_TEMP`),`QCODE_DATA_HOME` / 隔离数据全部位于临时目录。 +- 汇总 job:输出各矩阵结果,并如实记录 **darwin-x64 未覆盖**(无真实 Intel runner,禁止 Rosetta 代产)。 + +## 验收证据 + +fork 首跑(https://github.com/ruijayfeng/agent-isles/actions/runs/35699586487 ,head `f778d5613e91f347303890f9c11d9a16283f31f1`): + +- 架构守卫:macos-15 输出 `runner.arch=ARM64`、`uname -m: arm64`、`process.arch=arm64`;windows-2022 输出 `runner.arch=X64`、`uname -m: x86_64`、`PROCESSOR_ARCHITECTURE=AMD64`。macOS arm64 标签按首跑结果固定为 `macos-15`。 +- Windows(Microsoft Windows Server 2022):`verify.ps1` 全过(安装提取、无外部链接、认证页面、正常 / 崩溃清理、单实例、原生模块、旧数据迁移 / 冲突),并从解包 ZIP 启动 `QCode.exe` 跟随 token 认证跳转得到 200,终止后端口释放。 +- macOS:`verify-darwin.sh` 对解包 ZIP 全过(原生架构、冒烟就绪、退出清理、单实例、原生模块、迁移 / 冲突),`shasum -c SHA256SUMS.txt` 通过。 +- 产物:`windows-x64-preview` 287,523,735 字节、`macos-arm64-preview` 139,974,843 字节(ZIP / 安装包 / SHA256SUMS 白名单)。 +- 首跑发现并修复两个真实缺陷:`embedNodeRuntime` 只拷二进制导致官方动态链接 Node 产出坏包(补 `libnode.*.dylib`);`Launcher.swift` 冒烟模式数据冲突经 `NSApp.terminate` 静默以 0 退出(改 `exit(1)`,否则 verify 冲突断言形同虚设)。 + +## 限制 + +- 自动冒烟不替代 #19 的真实模型端到端与干净机器人工验收。 +- Dependabot / 第三方 action 版本固定为当前 major(checkout@v4、upload-artifact@v4)。