diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fe2026..f0f1d23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -388,6 +388,10 @@ jobs: fi ${{ matrix.command }} + - name: Smoke packaged Windows app + if: runner.os == 'Windows' + run: node scripts/smoke_windows_packaged_app.mjs --mode=full --require-katago + - name: Upload packaged artifacts uses: actions/upload-artifact@v4 with: @@ -504,6 +508,10 @@ jobs: fi ${{ matrix.command }} + - name: Smoke Lite packaged Windows app + if: runner.os == 'Windows' + run: node scripts/smoke_windows_packaged_app.mjs --mode=lite --allow-missing-katago + - name: Upload Lite packaged artifacts uses: actions/upload-artifact@v4 with: @@ -685,6 +693,9 @@ jobs: } } + - name: Smoke NVIDIA packaged Windows app + run: node scripts/smoke_windows_packaged_app.mjs --mode=nvidia --require-katago + - name: Upload NVIDIA packaged artifacts uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 83fc5fb..f684783 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ node_modules out dist website/.astro -release -.release +/release +/.release +/.tmp .dev-release .dev-cache .DS_Store diff --git a/docs/RELEASE_NOTES_v0.4.15.md b/docs/RELEASE_NOTES_v0.4.15.md new file mode 100644 index 0000000..774bc3c --- /dev/null +++ b/docs/RELEASE_NOTES_v0.4.15.md @@ -0,0 +1,159 @@ +# GoAgent v0.4.15 + +GoAgent v0.4.15 is a Windows download-and-run hardening release. It fixes packaged startup paths that could make Windows builds crash before the first window, makes Lite packages report missing KataGo as a setup warning instead of a blocked app, and adds packaged-app smoke checks for Standard, Lite, and NVIDIA Windows artifacts before release upload. + +QQ 群:1030632742,欢迎一起交流、提建议、完善 GoAgent。 + +## v0.4 系列延续能力 + +This release keeps the broader v0.4 foundation: grounded shape recognition engine, local pattern matcher, knowledge source-policy gates, optimized move-range review, quality checks and eval gates, Real Eval / engine silver fixture gate, KataGo engine pool telemetry, Release artifact smoke, student level, student age, teacher persona style settings with evidence boundary, teacher sessions, selective PR #6 integration, Tool-first Agent runtime, Kokoro selected-provider TTS with offline synthesis, Windows OpenCL runtime bundle, KataGo OpenCL adjacent runtime files, GPU vendor OpenCL drivers, and the community contribution path from layiku and wimi321. + +## 中文 + +### 下载前先选版本 + +| 平台 / 场景 | 推荐下载 | +| --- | --- | +| macOS Apple Silicon(M 系列) | GoAgent-0.4.15-mac-arm64.dmg | +| macOS Intel | GoAgent-0.4.15-mac-x64.dmg | +| Windows x64 安装版,普通用户推荐 | GoAgent-0.4.15-win-x64.exe | +| Windows x64 免安装版 | GoAgent-0.4.15-win-x64-portable.zip | +| Windows x64 NVIDIA 专版安装版 | GoAgent-0.4.15-win-x64-nvidia.exe | +| Windows x64 NVIDIA 专版免安装包 | GoAgent-0.4.15-win-x64-nvidia-portable.7z.001 and all following split parts | +| 校验文件 | SHA256SUMS.txt | + +### 本版重点 + +- 修复 Windows 上 Electron 旧缓存 / Code Cache 异常导致应用未出窗口就退出的问题。 +- Windows 默认关闭 Electron UI GPU 渲染路径;这不影响 KataGo 使用 NVIDIA / OpenCL 做分析。 +- Lite 包不再因为没有内置 KataGo 引擎和模型而显示阻断诊断,改为提示用户安装或配置引擎。 +- 打包后的发布状态检查会读取 `resources/app.asar` 和 `resources/data/katago`,不再误查源码目录或旧版本号。 +- Release workflow 会在上传前分别启动 Standard、Lite、NVIDIA Windows 包,并检查诊断、KataGo 资源和发布状态。 + +## 繁體中文 + +### 下載前先選版本 + +| 平台 / 使用情境 | 建議下載 | +| --- | --- | +| macOS Apple Silicon | GoAgent-0.4.15-mac-arm64.dmg | +| macOS Intel | GoAgent-0.4.15-mac-x64.dmg | +| Windows x64 安裝版 | GoAgent-0.4.15-win-x64.exe | +| Windows x64 免安裝版 | GoAgent-0.4.15-win-x64-portable.zip | +| Windows x64 NVIDIA 專版 | GoAgent-0.4.15-win-x64-nvidia.exe | +| Windows x64 NVIDIA 免安裝包 | GoAgent-0.4.15-win-x64-nvidia-portable.7z.001 and all following split parts | +| 校驗檔 | SHA256SUMS.txt | + +### 本版重點 + +- 修復 Windows 舊 Electron 快取或 Code Cache 讓應用啟動前崩潰的問題。 +- Windows 預設關閉 Electron UI GPU 路徑;KataGo 仍可使用 NVIDIA / OpenCL。 +- Lite 包缺少內建 KataGo 時會顯示設定提醒,不再阻擋應用啟動。 +- 打包後的 readiness 會檢查真正的 `app.asar` 與 `resources/data/katago`。 +- 發布流程會在上傳前啟動 Standard、Lite、NVIDIA Windows 包做 smoke。 + +## English + +### Pick the right package before downloading + +| Platform / use case | Recommended download | +| --- | --- | +| macOS Apple Silicon | GoAgent-0.4.15-mac-arm64.dmg | +| macOS Intel | GoAgent-0.4.15-mac-x64.dmg | +| Windows x64 installer | GoAgent-0.4.15-win-x64.exe | +| Windows x64 portable ZIP | GoAgent-0.4.15-win-x64-portable.zip | +| Windows x64 NVIDIA installer | GoAgent-0.4.15-win-x64-nvidia.exe | +| Windows x64 NVIDIA portable package | GoAgent-0.4.15-win-x64-nvidia-portable.7z.001 and all following split parts | +| Checksums | SHA256SUMS.txt | + +### Highlights + +- Fixes Windows startup crashes caused by stale or locked Electron Cache / Code Cache state. +- Disables the Electron UI GPU path on Windows by default; KataGo NVIDIA / OpenCL analysis is unaffected. +- Lite packages now treat missing bundled KataGo as setup guidance instead of a blocking diagnostic failure. +- Packaged release readiness now checks `resources/app.asar` and `resources/data/katago`, not the source tree or an old hardcoded version. +- Release CI now smoke-starts Standard, Lite, and NVIDIA Windows packages before artifact upload. + +## 日本語 + +### ダウンロード前に選ぶもの + +| 環境 | 推奨ファイル | +| --- | --- | +| macOS Apple Silicon | GoAgent-0.4.15-mac-arm64.dmg | +| macOS Intel | GoAgent-0.4.15-mac-x64.dmg | +| Windows x64 インストーラー | GoAgent-0.4.15-win-x64.exe | +| Windows x64 ポータブル ZIP | GoAgent-0.4.15-win-x64-portable.zip | +| Windows x64 NVIDIA 版 | GoAgent-0.4.15-win-x64-nvidia.exe | +| Windows x64 NVIDIA ポータブル | GoAgent-0.4.15-win-x64-nvidia-portable.7z.001 and all following split parts | +| チェックサム | SHA256SUMS.txt | + +### 主な変更 + +- Windows の Electron Cache / Code Cache が原因で起動前に落ちる問題を修正しました。 +- Windows では Electron UI の GPU 経路を既定で無効化します。KataGo の NVIDIA / OpenCL 解析には影響しません。 +- Lite 版は内蔵 KataGo がない場合でも起動をブロックせず、設定案内を表示します。 +- パッケージ後の readiness は実際の `app.asar` と `resources/data/katago` を確認します。 + +## 한국어 + +### 다운로드 전 선택 + +| 환경 | 권장 다운로드 | +| --- | --- | +| macOS Apple Silicon | GoAgent-0.4.15-mac-arm64.dmg | +| macOS Intel | GoAgent-0.4.15-mac-x64.dmg | +| Windows x64 설치 프로그램 | GoAgent-0.4.15-win-x64.exe | +| Windows x64 포터블 ZIP | GoAgent-0.4.15-win-x64-portable.zip | +| Windows x64 NVIDIA 설치 프로그램 | GoAgent-0.4.15-win-x64-nvidia.exe | +| Windows x64 NVIDIA 포터블 | GoAgent-0.4.15-win-x64-nvidia-portable.7z.001 and all following split parts | +| 체크섬 | SHA256SUMS.txt | + +### 이번 버전 + +- Windows에서 Electron Cache / Code Cache 문제로 창이 뜨기 전에 종료되는 문제를 수정했습니다. +- Windows에서는 Electron UI GPU 경로를 기본 비활성화합니다. KataGo NVIDIA / OpenCL 분석에는 영향이 없습니다. +- Lite 패키지는 내장 KataGo가 없어도 앱을 차단하지 않고 설정 안내를 보여줍니다. +- 패키지 readiness는 실제 `app.asar` 및 `resources/data/katago`를 검사합니다. + +## ภาษาไทย + +### เลือกไฟล์ก่อนดาวน์โหลด + +| แพลตฟอร์ม | ไฟล์ที่แนะนำ | +| --- | --- | +| macOS Apple Silicon | GoAgent-0.4.15-mac-arm64.dmg | +| macOS Intel | GoAgent-0.4.15-mac-x64.dmg | +| Windows x64 installer | GoAgent-0.4.15-win-x64.exe | +| Windows x64 portable ZIP | GoAgent-0.4.15-win-x64-portable.zip | +| Windows x64 NVIDIA installer | GoAgent-0.4.15-win-x64-nvidia.exe | +| Windows x64 NVIDIA portable | GoAgent-0.4.15-win-x64-nvidia-portable.7z.001 and all following split parts | +| Checksums | SHA256SUMS.txt | + +### จุดสำคัญของรุ่นนี้ + +- แก้ปัญหา Windows ที่ Electron Cache / Code Cache ทำให้แอปปิดก่อนหน้าต่างเปิด +- ปิดเส้นทาง GPU ของ Electron UI เป็นค่าเริ่มต้นบน Windows โดยไม่กระทบ KataGo NVIDIA / OpenCL +- Lite package จะไม่บล็อกแอปเมื่อไม่มี KataGo ในตัว แต่จะแนะนำการตั้งค่าแทน +- readiness ของแพ็กเกจจะตรวจ `app.asar` และ `resources/data/katago` จริง + +## Tiếng Việt + +### Chọn gói tải xuống + +| Nền tảng | Gói khuyến nghị | +| --- | --- | +| macOS Apple Silicon | GoAgent-0.4.15-mac-arm64.dmg | +| macOS Intel | GoAgent-0.4.15-mac-x64.dmg | +| Windows x64 installer | GoAgent-0.4.15-win-x64.exe | +| Windows x64 portable ZIP | GoAgent-0.4.15-win-x64-portable.zip | +| Windows x64 NVIDIA installer | GoAgent-0.4.15-win-x64-nvidia.exe | +| Windows x64 NVIDIA portable | GoAgent-0.4.15-win-x64-nvidia-portable.7z.001 and all following split parts | +| Checksums | SHA256SUMS.txt | + +### Điểm mới + +- Sửa lỗi Windows trong đó Electron Cache / Code Cache có thể làm ứng dụng thoát trước khi mở cửa sổ. +- Mặc định tắt đường GPU của Electron UI trên Windows; phân tích KataGo NVIDIA / OpenCL không bị ảnh hưởng. +- Lite package không còn chặn khởi động khi thiếu KataGo tích hợp, mà hiển thị hướng dẫn cấu hình. +- Packaged readiness kiểm tra `app.asar` và `resources/data/katago` thực tế. diff --git a/package.json b/package.json index e6d68ed..668036a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goagent", - "version": "0.4.14", + "version": "0.4.15", "description": "Agentic AI teacher for Go / Weiqi / Baduk, powered by KataGo and multimodal LLMs.", "main": "out/main/index.js", "type": "module", @@ -49,6 +49,7 @@ "website:check": "pnpm --dir website build", "check:website": "node scripts/check_website.mjs", "smoke:release-artifacts": "node scripts/release_artifact_smoke.mjs", + "smoke:windows-packaged": "node scripts/smoke_windows_packaged_app.mjs", "check:teacher-quality": "pnpm build && pnpm eval:teacher && pnpm eval:claims && pnpm eval:quality-gate && pnpm check:knowledge-sources && pnpm eval:knowledge-coverage && pnpm eval:shape-recognition && pnpm eval:move-range && pnpm eval:vision-evidence && pnpm eval:katago-trace && pnpm eval:engine-silver && pnpm eval:teacher-style && pnpm eval:teacher-session && pnpm eval:tts-provider-policy && pnpm check:teacher-artifact", "package": "pnpm dist", "dist": "pnpm build && electron-builder", diff --git a/scripts/smoke_windows_packaged_app.mjs b/scripts/smoke_windows_packaged_app.mjs new file mode 100644 index 0000000..41fb7fa --- /dev/null +++ b/scripts/smoke_windows_packaged_app.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { setTimeout as delay } from 'node:timers/promises' +import process from 'node:process' + +const root = resolve(process.cwd()) +const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + +function arg(name, fallback = '') { + const prefix = `--${name}=` + const found = process.argv.find((item) => item.startsWith(prefix)) + return found ? found.slice(prefix.length) : fallback +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`) +} + +function defaultExePath() { + return join(root, 'release', packageJson.version, 'win-unpacked', 'GoAgent.exe') +} + +function freePort() { + return new Promise((resolvePromise, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => { + if (address && typeof address === 'object') { + resolvePromise(address.port) + } else { + reject(new Error('Cannot allocate a local CDP port.')) + } + }) + }) + server.on('error', reject) + }) +} + +function fail(message) { + throw new Error(`[windows-packaged-smoke] ${message}`) +} + +function tail(value, max = 4000) { + return value.length > max ? value.slice(value.length - max) : value +} + +async function waitForJson(url, timeoutMs, child, childError) { + const deadline = Date.now() + timeoutMs + let lastError = '' + while (Date.now() < deadline) { + if (childError.current) { + throw new Error(`Packaged app failed to start: ${childError.current.message}`) + } + if (child.exitCode !== null) { + throw new Error(`Packaged app exited before CDP was available. exitCode=${child.exitCode} signal=${child.signalCode ?? ''} lastCdpError=${lastError || 'none'}`) + } + try { + const response = await fetch(url) + if (response.ok) return response.json() + lastError = `${response.status} ${response.statusText}` + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + await delay(500) + } + throw new Error(`Timed out waiting for ${url}. Last CDP error: ${lastError || 'none'}`) +} + +async function connectRuntime(page) { + const ws = new WebSocket(page.webSocketDebuggerUrl) + let id = 0 + const pending = new Map() + ws.addEventListener('message', (event) => { + const message = JSON.parse(event.data) + if (!message.id || !pending.has(message.id)) return + const { resolve: resolvePromise, reject } = pending.get(message.id) + pending.delete(message.id) + if (message.error) reject(new Error(JSON.stringify(message.error))) + else resolvePromise(message.result) + }) + await new Promise((resolvePromise, reject) => { + ws.addEventListener('open', resolvePromise, { once: true }) + ws.addEventListener('error', reject, { once: true }) + }) + function send(method, params = {}) { + const messageId = ++id + ws.send(JSON.stringify({ id: messageId, method, params })) + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => { + pending.delete(messageId) + reject(new Error(`${method} timed out`)) + }, 60_000) + pending.set(messageId, { + resolve: (value) => { + clearTimeout(timer) + resolvePromise(value) + }, + reject: (error) => { + clearTimeout(timer) + reject(error) + } + }) + }) + } + await send('Runtime.enable') + async function evaluate(expression) { + const result = await send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + timeout: 60_000 + }) + if (result.exceptionDetails) { + throw new Error(JSON.stringify(result.exceptionDetails)) + } + return result.result?.value + } + return { + evaluate, + close: () => ws.close() + } +} + +async function main() { + if (process.platform !== 'win32') { + console.log(JSON.stringify({ skipped: true, reason: 'Windows packaged app smoke only runs on win32.' }, null, 2)) + return + } + + const mode = arg('mode', 'full') + const exe = resolve(arg('exe', defaultExePath())) + const requireKatago = hasFlag('require-katago') || (mode !== 'lite' && !hasFlag('allow-missing-katago')) + if (!existsSync(exe)) fail(`Missing packaged executable: ${exe}`) + + const port = Number(arg('port', '')) || await freePort() + const smokeHome = resolve(arg('home', join(root, '.tmp', 'windows-packaged-smoke', `${Date.now()}-${mode}`))) + rmSync(smokeHome, { recursive: true, force: true }) + mkdirSync(smokeHome, { recursive: true }) + const electronUserData = resolve(arg('electron-user-data', join(smokeHome, 'electron-user-data'))) + mkdirSync(electronUserData, { recursive: true }) + + let stdout = '' + let stderr = '' + const childError = { current: null } + const child = spawn(exe, [ + `--user-data-dir=${electronUserData}`, + `--remote-debugging-port=${port}`, + '--remote-allow-origins=*', + '--disable-gpu', + '--disable-software-rasterizer', + '--disable-gpu-compositing', + '--disable-gpu-sandbox', + '--disable-features=Vulkan' + ], { + cwd: dirname(exe), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GOAGENT_APP_HOME: smokeHome, + GOAGENT_REMOTE_DEBUGGING_PORT: String(port), + ELECTRON_ENABLE_LOGGING: '1' + } + }) + child.stdout?.on('data', (chunk) => { stdout += String(chunk) }) + child.stderr?.on('data', (chunk) => { stderr += String(chunk) }) + child.once('error', (error) => { childError.current = error }) + + try { + const pages = await waitForJson(`http://127.0.0.1:${port}/json/list`, 60_000, child, childError) + if (!Array.isArray(pages) || pages.length === 0) fail('Remote debugging exposed no renderer pages.') + const page = pages.find((entry) => entry?.type === 'page' && entry?.webSocketDebuggerUrl) ?? pages[0] + if (!page?.webSocketDebuggerUrl) fail('Remote debugging exposed no renderer WebSocket URL.') + const runtime = await connectRuntime(page) + let report + try { + report = { + pageUrl: await runtime.evaluate('location.href'), + title: await runtime.evaluate('document.title'), + hasApi: await runtime.evaluate('Boolean(window.goagent && window.goagent.getDiagnostics && window.goagent.inspectKataGoAssets && window.goagent.getReleaseReadiness)'), + diagnostics: await runtime.evaluate('window.goagent.getDiagnostics()'), + katagoAssets: await runtime.evaluate('window.goagent.inspectKataGoAssets()'), + releaseReadiness: await runtime.evaluate('window.goagent.getReleaseReadiness()') + } + } finally { + runtime.close() + } + + const failures = [] + const pageUrl = String(report.pageUrl || '').replace(/\\/g, '/') + if (!pageUrl.includes('/resources/app.asar/out/renderer/index.html')) { + failures.push(`Renderer did not load from packaged app.asar: ${report.pageUrl}`) + } + if (!report.hasApi) failures.push('window.goagent API is missing.') + + const requiredDiagnosticFailures = (report.diagnostics?.checks ?? []).filter((check) => check.required && check.status === 'fail') + for (const check of requiredDiagnosticFailures) { + failures.push(`Required diagnostic failed: ${check.id} - ${check.detail}`) + } + + const readinessFailures = (report.releaseReadiness?.items ?? []).filter((entry) => entry.status === 'fail') + for (const entry of readinessFailures) { + failures.push(`Release readiness failed: ${entry.id} - ${entry.detail ?? ''}`) + } + + if (requireKatago && !report.katagoAssets?.ready) { + failures.push(`KataGo assets are not ready: ${report.katagoAssets?.detail ?? 'unknown'}`) + } + if (mode === 'lite' && report.diagnostics?.overall === 'blocked') { + failures.push('Lite package must not block startup just because bundled KataGo is absent.') + } + + const output = { + mode, + exe, + smokeHome, + electronUserData, + pid: child.pid, + requireKatago, + pageUrl: report.pageUrl, + diagnosticsOverall: report.diagnostics?.overall, + katagoReady: report.katagoAssets?.ready, + katagoDetail: report.katagoAssets?.detail, + releaseReadinessStatus: report.releaseReadiness?.status, + releaseReadinessFlags: report.releaseReadiness?.flags, + failures + } + const evidencePath = arg('evidence', '') + if (evidencePath) { + mkdirSync(dirname(resolve(evidencePath)), { recursive: true }) + writeFileSync(resolve(evidencePath), `${JSON.stringify(output, null, 2)}\n`, 'utf8') + } + console.log(JSON.stringify(output, null, 2)) + if (failures.length) process.exitCode = 1 + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const output = { + mode, + exe, + smokeHome, + electronUserData, + pid: child.pid, + requireKatago, + childExitCode: child.exitCode, + childSignal: child.signalCode, + error: message, + stdoutTail: tail(stdout.trim()), + stderrTail: tail(stderr.trim()), + failures: [message] + } + const evidencePath = arg('evidence', '') + if (evidencePath) { + mkdirSync(dirname(resolve(evidencePath)), { recursive: true }) + writeFileSync(resolve(evidencePath), `${JSON.stringify(output, null, 2)}\n`, 'utf8') + } + console.error(JSON.stringify(output, null, 2)) + process.exitCode = 1 + } finally { + if (!child.killed) { + child.kill('SIGTERM') + await delay(1000) + if (child.exitCode === null) child.kill('SIGKILL') + } + if (process.exitCode) { + console.error(stdout.trim()) + console.error(stderr.trim()) + } + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 2c6bb00..55054e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -49,6 +49,15 @@ if (remoteDebuggingPort && /^\d+$/.test(remoteDebuggingPort)) { app.commandLine.appendSwitch('remote-debugging-port', remoteDebuggingPort) } +if (process.platform === 'win32' && process.env.GOAGENT_ENABLE_ELECTRON_GPU !== '1') { + app.commandLine.appendSwitch('disable-gpu') + app.commandLine.appendSwitch('disable-software-rasterizer') + app.commandLine.appendSwitch('disable-gpu-compositing') + app.commandLine.appendSwitch('disable-gpu-sandbox') + app.commandLine.appendSwitch('disable-features', 'Vulkan') + app.disableHardwareAcceleration() +} + function assetPath(fileName: string): string { return join(__dirname, '../../assets', fileName) } diff --git a/src/main/lib/store.ts b/src/main/lib/store.ts index 8be4f21..835b446 100644 --- a/src/main/lib/store.ts +++ b/src/main/lib/store.ts @@ -6,15 +6,19 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node: import { BRAND_DATA_DIR } from '@shared/brand' import type { AppSettings, LibraryGame } from './types' +export const legacyElectronUserData = app.getPath('userData') export const appHome = process.env.GOAGENT_APP_HOME || join(app.getPath('home'), BRAND_DATA_DIR) +export const electronUserData = process.env.GOAGENT_ELECTRON_USER_DATA || join(appHome, 'electron-user-data') export const libraryDir = join(appHome, 'library') export const reviewsDir = join(appHome, 'reviews') export const cacheDir = join(appHome, 'cache') export const reportsDir = join(appHome, 'teacher-reports') -for (const dir of [appHome, libraryDir, reviewsDir, cacheDir, reportsDir]) { +for (const dir of [appHome, electronUserData, libraryDir, reviewsDir, cacheDir, reportsDir]) { mkdirSync(dir, { recursive: true }) } +// Keep Chromium cache/profile files isolated from legacy AppData profiles that can be locked or corrupted on Windows. +app.setPath('userData', electronUserData) function defaultPythonBin(): string { return process.platform === 'win32' ? 'python' : 'python3' diff --git a/src/main/services/diagnostics/index.ts b/src/main/services/diagnostics/index.ts index 7224e29..45065f0 100644 --- a/src/main/services/diagnostics/index.ts +++ b/src/main/services/diagnostics/index.ts @@ -7,6 +7,7 @@ import { ikatagoClientConfigured, shouldPreferIKataGoEngine } from '../ikatagoCl import { shouldPreferZhiziGtpEngine, zhiziGtpConfigured } from '../zhiziGtpEngine' import { probeOpenAICompatibleProvider } from '../llm/openaiCompatibleProvider' import { inspectKataGoAssets } from '../katago/katagoAssets' +import { isLitePackagedRuntime } from '../release/packageRuntime' import type { DiagnosticCheck, DiagnosticsReport, DiagnosticsOverall } from './types' function isReleaseRuntime(): boolean { @@ -70,17 +71,20 @@ async function checkKatagoBinary(): Promise { detail: `使用 iKataGo 远程算力: ${basename(settings.ikatagoClientBin)}` } } - const required = isReleaseRuntime() + const liteRuntime = isLitePackagedRuntime() + const required = isReleaseRuntime() && !liteRuntime if (!runtime.katagoBin) { return { id: 'katago-binary', title: 'KataGo 引擎', status: required ? 'fail' : 'warn', required, - detail: '未找到内置或本机 KataGo 引擎。', + detail: liteRuntime ? 'Lite 安装包不内置 KataGo 引擎。' : '未找到内置或本机 KataGo 引擎。', action: required ? '请确认安装包包含 data/katago/bin/-/katago。' - : '开发环境可稍后运行 scripts/prepare_katago_assets.mjs 或使用系统 KataGo。' + : liteRuntime + ? '可在设置中安装官方 KataGo 模型/运行时,或配置本机 KataGo、智子云远程算力。' + : '开发环境可稍后运行 scripts/prepare_katago_assets.mjs 或使用系统 KataGo。' } } try { @@ -138,17 +142,20 @@ async function checkKatagoModel(): Promise { detail: 'iKataGo 远程服务负责模型和配置,GoAgent 不要求本机模型。' } } - const required = isReleaseRuntime() + const liteRuntime = isLitePackagedRuntime() + const required = isReleaseRuntime() && !liteRuntime if (!runtime.katagoModel) { return { id: 'katago-model', title: 'KataGo 默认模型', status: required ? 'fail' : 'warn', required, - detail: '未找到默认 KataGo 模型。', + detail: liteRuntime ? 'Lite 安装包不内置默认 KataGo 模型。' : '未找到默认 KataGo 模型。', action: required ? '安装包应该内置默认 KataGo 模型;请确认 data/katago/models 中存在默认模型文件。' - : '开发环境可先保留 manifest,通过资源准备脚本或 CI release artifact 注入模型。' + : liteRuntime + ? '可在设置中下载官方模型;下载完成后基础分析即可使用。' + : '开发环境可先保留 manifest,通过资源准备脚本或 CI release artifact 注入模型。' } } return { @@ -191,7 +198,8 @@ async function checkBundledKataGoAssets(): Promise { } } const status = await inspectKataGoAssets() - const required = isReleaseRuntime() + const liteRuntime = isLitePackagedRuntime() + const required = isReleaseRuntime() && !liteRuntime if (status.ready) { return { id: 'katago-assets', @@ -206,10 +214,12 @@ async function checkBundledKataGoAssets(): Promise { title: '内置 KataGo 资源', status: required ? 'fail' : 'warn', required, - detail: status.detail, + detail: liteRuntime ? 'Lite 安装包只内置 manifest,不内置大型 KataGo 二进制和模型。' : status.detail, action: required ? '请重新安装完整安装包,或检查 data/katago 资源是否损坏。' - : '开发环境可通过 scripts/prepare_katago_assets.mjs 准备资源。' + : liteRuntime + ? '需要本机分析时,可在设置里安装官方模型/运行时,或使用远程算力。' + : '开发环境可通过 scripts/prepare_katago_assets.mjs 准备资源。' } } diff --git a/src/main/services/katago/katagoAssets.ts b/src/main/services/katago/katagoAssets.ts index 291990f..dd00de3 100644 --- a/src/main/services/katago/katagoAssets.ts +++ b/src/main/services/katago/katagoAssets.ts @@ -7,8 +7,8 @@ import { Transform, Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { basename, dirname, join } from 'node:path' import { promisify } from 'node:util' -import { app } from 'electron' import { getKataGoModelPreset } from '../katagoRuntime' +import { appHome, legacyElectronUserData } from '@main/lib/store' import type { KataGoAssetInstallProgress, KataGoAssetInstallRequest, KataGoAssetInstallResult } from '@main/lib/types' export interface KataGoPlatformAsset { @@ -68,12 +68,8 @@ function editionBinaryPathForPlatform(edition: KataGoEditionMetadata | null, key return edition.platform === key ? edition.binaryPath : '' } -function userKatagoRoot(): string | null { - try { - return join(app.getPath('userData'), 'katago') - } catch { - return null - } +function userKatagoRoot(): string { + return join(appHome, 'katago') } function candidateRoots(): string[] { @@ -82,6 +78,9 @@ function candidateRoots(): string[] { if (userRoot) { roots.push(userRoot) } + if (legacyElectronUserData) { + roots.push(join(legacyElectronUserData, 'katago')) + } if (process.resourcesPath) { roots.push(join(process.resourcesPath, 'data', 'katago')) } @@ -447,7 +446,7 @@ export async function installOfficialKataGoModel( throw new Error('缺少 data/katago/manifest.json,无法创建本机资源配置。') } - // If this preset is already bundled with the app or previously installed into userData, reuse it. + // If this preset is already bundled with the app or previously installed into the app home, reuse it. const bundledMatch = await findBundledModelPath(preset) if (bundledMatch) { onProgress?.({ stage: 'discovering', message: `${preset.label} 已随安装包提供,无需下载。`, percent: 100 }) diff --git a/src/main/services/release/packageRuntime.ts b/src/main/services/release/packageRuntime.ts new file mode 100644 index 0000000..213ac93 --- /dev/null +++ b/src/main/services/release/packageRuntime.ts @@ -0,0 +1,75 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +export type PackagedRuntimeFlavor = 'source' | 'standard' | 'nvidia' | 'lite' | 'unknown' + +export interface PackagedRuntimeInfo { + packaged: boolean + resourcesRoot: string + appRoot: string + katagoRoot: string + flavor: PackagedRuntimeFlavor + version: string +} + +function readJson(path: string): Record | null { + if (!existsSync(path)) return null + try { + return JSON.parse(readFileSync(path, 'utf8')) as Record + } catch { + return null + } +} + +function versionFrom(packageJson: Record | null): string { + return typeof packageJson?.version === 'string' && packageJson.version.trim() + ? packageJson.version + : '' +} + +function readPackageVersion(projectRoot: string, appRoot: string, packaged: boolean): string { + const packagedPackage = readJson(join(appRoot, 'package.json')) + const sourcePackage = readJson(join(projectRoot, 'package.json')) + const packagedVersion = versionFrom(packagedPackage) + const sourceVersion = versionFrom(sourcePackage) + if (packaged) { + return packagedVersion || sourceVersion || 'unknown' + } + return sourceVersion || packagedVersion || 'unknown' +} + +function detectFlavor(katagoRoot: string, packaged: boolean): PackagedRuntimeFlavor { + if (!packaged) return 'source' + const edition = readJson(join(katagoRoot, 'edition.json')) + const flavor = typeof edition?.flavor === 'string' ? edition.flavor : '' + if (flavor === 'nvidia') return 'nvidia' + if (flavor === 'opencl' || flavor === 'standard') return 'standard' + + const hasManifest = existsSync(join(katagoRoot, 'manifest.json')) + const hasPreparedRuntime = + existsSync(join(katagoRoot, 'bin')) || + existsSync(join(katagoRoot, 'models')) || + existsSync(join(katagoRoot, 'edition.json')) + if (hasManifest && !hasPreparedRuntime) return 'lite' + if (hasManifest && hasPreparedRuntime) return 'standard' + return 'unknown' +} + +export function inspectPackagedRuntime(projectRoot = process.cwd()): PackagedRuntimeInfo { + const resourcesRoot = process.resourcesPath || '' + const packaged = Boolean(resourcesRoot && !process.env.ELECTRON_RENDERER_URL) + const appRoot = packaged ? join(resourcesRoot, 'app.asar') : projectRoot + const katagoRoot = packaged ? join(resourcesRoot, 'data', 'katago') : join(projectRoot, 'data', 'katago') + return { + packaged, + resourcesRoot, + appRoot, + katagoRoot, + flavor: detectFlavor(katagoRoot, packaged), + version: readPackageVersion(projectRoot, appRoot, packaged) + } +} + +export function isLitePackagedRuntime(projectRoot = process.cwd()): boolean { + return inspectPackagedRuntime(projectRoot).flavor === 'lite' +} diff --git a/src/main/services/release/readiness.ts b/src/main/services/release/readiness.ts index 51cd9f7..75dee3b 100644 --- a/src/main/services/release/readiness.ts +++ b/src/main/services/release/readiness.ts @@ -1,6 +1,18 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { basename, join } from 'node:path' import type { ReleaseReadinessFlags, ReleaseReadinessItem, ReleaseReadinessResult, ReleaseReadinessStatus } from '../../lib/types' +import { inspectPackagedRuntime } from './packageRuntime' + +type KataGoManifest = { + modelPath?: unknown + supportedPlatforms?: Record +} + +type KataGoEdition = { + binaryPath?: unknown + modelPath?: unknown + platform?: unknown +} function item(id: string, label: string, status: ReleaseReadinessStatus, detail?: string): ReleaseReadinessItem { return { id, label, status, detail } @@ -13,13 +25,84 @@ function aggregate(items: ReleaseReadinessItem[]): ReleaseReadinessStatus { return 'pass' } -export function inspectReleaseReadiness(projectRoot = process.cwd()): ReleaseReadinessResult { +function hasFile(root: string, relativePath: string): boolean { + return Boolean(root && existsSync(join(root, relativePath))) +} + +function hasAnyFile(root: string, relativePaths: string[]): boolean { + return relativePaths.some((relativePath) => hasFile(root, relativePath)) +} + +function hasModelFile(katagoRoot: string): boolean { + const modelDir = join(katagoRoot, 'models') + if (!existsSync(modelDir)) return false + try { + return readdirSync(modelDir).some((name) => name.toLowerCase().endsWith('.bin.gz')) + } catch { + return false + } +} + +function readJsonFile(path: string): T | null { + if (!existsSync(path)) return null + try { + return JSON.parse(readFileSync(path, 'utf8')) as T + } catch { + return null + } +} + +function currentPlatformKey(): string { + return `${process.platform}-${process.arch}` +} + +function stringValue(value: unknown): string { + return typeof value === 'string' && value.trim() ? value : '' +} + +function katagoRuntimePaths(katagoRoot: string): { binaryPath: string; modelPath: string } { + const key = currentPlatformKey() + const manifest = readJsonFile(join(katagoRoot, 'manifest.json')) + const edition = readJsonFile(join(katagoRoot, 'edition.json')) + const editionBinaryPath = edition?.platform === key ? stringValue(edition.binaryPath) : '' + const manifestBinaryPath = stringValue(manifest?.supportedPlatforms?.[key]?.binaryPath) + const editionModelPath = stringValue(edition?.modelPath) + const manifestModelPath = stringValue(manifest?.modelPath) + return { + binaryPath: editionBinaryPath || manifestBinaryPath, + modelPath: editionModelPath || manifestModelPath + } +} + +function hasKataGoBinary(katagoRoot: string): boolean { + const { binaryPath } = katagoRuntimePaths(katagoRoot) + if (binaryPath) return hasFile(katagoRoot, binaryPath) + return hasAnyFile(katagoRoot, [ + 'bin/win32-x64/katago.exe', + 'bin/darwin-arm64/katago', + 'bin/darwin-x64/katago' + ]) +} + +function hasKataGoModel(katagoRoot: string): boolean { + const { modelPath } = katagoRuntimePaths(katagoRoot) + return Boolean(modelPath && hasFile(katagoRoot, modelPath)) || hasModelFile(katagoRoot) +} + +function hasEvidence(projectRoot: string, envName: string, fileName: string): boolean { + return process.env[envName] === '1' || existsSync(join(projectRoot, 'release-evidence', fileName)) +} + +function sourceReadiness(projectRoot: string): ReleaseReadinessResult { + const runtime = inspectPackagedRuntime(projectRoot) const requiredFiles = [ 'package.json', 'data/knowledge/p0-cards.json', 'data/katago/manifest.json', 'scripts/check_katago_assets.mjs', 'scripts/p0_beta_acceptance.mjs', + 'scripts/package_artifact_smoke.mjs', + 'scripts/smoke_windows_packaged_app.mjs', 'src/main/services/diagnostics/index.ts', 'src/main/services/llm/openaiCompatibleProvider.ts', 'src/main/services/studentProfile.ts', @@ -28,12 +111,11 @@ export function inspectReleaseReadiness(projectRoot = process.cwd()): ReleaseRea 'src/renderer/src/features/teacher/TeacherRunCardPro.tsx' ] - const items: ReleaseReadinessItem[] = requiredFiles.map((relativePath) => { - const fullPath = join(projectRoot, relativePath) - return existsSync(fullPath) + const items: ReleaseReadinessItem[] = requiredFiles.map((relativePath) => + hasFile(projectRoot, relativePath) ? item(relativePath, relativePath, 'pass') - : item(relativePath, relativePath, 'fail', '缺少 P0 必备文件') - }) + : item(relativePath, relativePath, 'fail', '缺少发布必备文件') + ) const automationReady = items.every((entry) => entry.status === 'pass') const katagoBinaryCandidates = [ @@ -41,103 +123,190 @@ export function inspectReleaseReadiness(projectRoot = process.cwd()): ReleaseRea 'data/katago/bin/darwin-x64/katago', 'data/katago/bin/win32-x64/katago.exe' ] - const presentBinaryCount = katagoBinaryCandidates.filter((relativePath) => existsSync(join(projectRoot, relativePath))).length + const presentBinaryCount = katagoBinaryCandidates.filter((relativePath) => hasFile(projectRoot, relativePath)).length const allBinariesReady = presentBinaryCount === katagoBinaryCandidates.length items.push( allBinariesReady ? item('katago-binaries', 'KataGo 平台二进制', 'pass', `检测到 ${presentBinaryCount}/${katagoBinaryCandidates.length} 个候选二进制`) - : item('katago-binaries', 'KataGo 平台二进制', 'warn', '源码仓库可不提交二进制,但 release 前必须通过 prepare assets 脚本准备') + : item('katago-binaries', 'KataGo 平台二进制', 'warn', '源码仓库可不提交二进制,但 release job 必须通过 prepare assets 脚本注入') ) - const modelCandidates = [ - 'data/katago/models/default.bin.gz', - 'data/katago/models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz' - ] - const hasModel = modelCandidates.some((relativePath) => existsSync(join(projectRoot, relativePath))) + const hasModel = hasModelFile(join(projectRoot, 'data', 'katago')) items.push( hasModel ? item('katago-model', 'KataGo 默认模型', 'pass') - : item('katago-model', 'KataGo 默认模型', 'warn', '源码仓库可不提交模型,但 release 前必须准备默认模型') + : item('katago-model', 'KataGo 默认模型', 'warn', '源码仓库可不提交模型,但完整 release job 必须准备默认模型') ) - const assetsReady = allBinariesReady && hasModel - const version = '0.2.0-beta.1' + const version = runtime.version const releaseRoot = join(projectRoot, 'release', version) const installerCandidates = [ - `release/${version}/GoAgent-${version}-mac-arm64.dmg`, - `release/${version}/GoAgent-${version}-mac-x64.dmg`, - `release/${version}/GoAgent-${version}-win-x64.exe` + `GoAgent-${version}-mac-arm64.dmg`, + `GoAgent-${version}-mac-x64.dmg`, + `GoAgent-${version}-mac-arm64-lite.dmg`, + `GoAgent-${version}-mac-x64-lite.dmg`, + `GoAgent-${version}-win-x64.exe`, + `GoAgent-${version}-win-x64-portable.zip`, + `GoAgent-${version}-win-x64-lite.exe`, + `GoAgent-${version}-win-x64-lite-portable.zip`, + `GoAgent-${version}-win-x64-nvidia.exe` ] - const missingInstallers = installerCandidates.filter((relativePath) => !existsSync(join(projectRoot, relativePath))) + const missingInstallers = installerCandidates.filter((name) => !existsSync(join(releaseRoot, name))) + const hasNvidiaPortable = existsSync(join(releaseRoot, `GoAgent-${version}-win-x64-nvidia-portable.7z.001`)) const winArm64Installer = existsSync(join(releaseRoot, `GoAgent-${version}-win-arm64.exe`)) - const installersReady = missingInstallers.length === 0 && !winArm64Installer + const installersReady = missingInstallers.length === 0 && hasNvidiaPortable && !winArm64Installer items.push( installersReady - ? item('installers-ready', 'P0 beta 安装包', 'pass', 'macOS arm64/x64 与 Windows x64 安装包已存在') + ? item('installers-ready', '多平台安装包', 'pass', `release/${version} 已包含 macOS、Windows、Lite、NVIDIA 产物`) : item( 'installers-ready', - 'P0 beta 安装包', - 'fail', + '多平台安装包', + 'warn', [ missingInstallers.length > 0 ? `缺少: ${missingInstallers.join(', ')}` : '', + hasNvidiaPortable ? '' : `缺少: GoAgent-${version}-win-x64-nvidia-portable.7z.001`, winArm64Installer ? '检测到不支持的 Windows ARM64 产物' : '' ].filter(Boolean).join(';') ) ) - const signingReady = - process.env.GOAGENT_SIGNING_READY === '1' || - existsSync(join(projectRoot, 'release-evidence', 'signing-ready.json')) - const windowsSmokeReady = - process.env.GOAGENT_WINDOWS_SMOKE_READY === '1' || - existsSync(join(projectRoot, 'release-evidence', 'windows-smoke-ready.json')) - const visualQaReady = - process.env.GOAGENT_VISUAL_QA_READY === '1' || - existsSync(join(projectRoot, 'release-evidence', 'visual-qa-ready.json')) + const signingReady = hasEvidence(projectRoot, 'GOAGENT_SIGNING_READY', 'signing-ready.json') + const windowsSmokeReady = hasEvidence(projectRoot, 'GOAGENT_WINDOWS_SMOKE_READY', 'windows-smoke-ready.json') + const visualQaReady = hasEvidence(projectRoot, 'GOAGENT_VISUAL_QA_READY', 'visual-qa-ready.json') + pushManualEvidence(items, signingReady, windowsSmokeReady, visualQaReady) + + return buildResult(items, { + automationReady, + assetsReady: allBinariesReady && hasModel, + installersReady, + signingReady, + windowsSmokeReady, + visualQaReady, + publicBetaReady: false + }) +} + +function packagedReadiness(projectRoot: string): ReleaseReadinessResult { + const runtime = inspectPackagedRuntime(projectRoot) + const appRoot = runtime.appRoot + const resourcesRoot = runtime.resourcesRoot + const katagoRoot = runtime.katagoRoot + const items: ReleaseReadinessItem[] = [] + + const runtimeFiles = [ + ['packaged-main', '主进程代码', ['out/main/index.js']], + ['packaged-preload', 'Preload 桥接', ['out/preload/index.mjs']], + ['packaged-renderer', '渲染页面', ['out/renderer/index.html']], + ['packaged-knowledge', '内置围棋知识库', ['data/knowledge/p0-cards.json']] + ] as const + for (const [id, label, paths] of runtimeFiles) { + items.push( + hasAnyFile(appRoot, [...paths]) + ? item(id, label, 'pass') + : item(id, label, 'fail', `打包应用缺少 ${paths.join(' 或 ')}`) + ) + } + + items.push( + hasFile(resourcesRoot, 'app.asar') + ? item('app-asar', 'app.asar', 'pass', basename(join(resourcesRoot, 'app.asar'))) + : item('app-asar', 'app.asar', 'fail', '打包应用缺少 resources/app.asar') + ) + + const manifestReady = hasFile(katagoRoot, 'manifest.json') + items.push( + manifestReady + ? item('katago-manifest', 'KataGo manifest', 'pass') + : item('katago-manifest', 'KataGo manifest', 'fail', '缺少 resources/data/katago/manifest.json') + ) + + const binaryReady = hasKataGoBinary(katagoRoot) + const modelReady = hasKataGoModel(katagoRoot) + const lite = runtime.flavor === 'lite' + + if (lite) { + items.push(item('katago-runtime', 'KataGo 运行时', 'warn', 'Lite 安装包不内置 KataGo;用户需要在设置中安装或配置本机/远程引擎')) + items.push(item('katago-model', 'KataGo 默认模型', 'warn', 'Lite 安装包不内置默认模型;设置页会提供安装入口')) + } else { + items.push( + binaryReady + ? item('katago-runtime', 'KataGo 运行时', 'pass', runtime.flavor === 'nvidia' ? 'NVIDIA runtime detected' : 'bundled runtime detected') + : item('katago-runtime', 'KataGo 运行时', 'fail', '完整安装包必须内置当前平台 KataGo runtime') + ) + items.push( + modelReady + ? item('katago-model', 'KataGo 默认模型', 'pass') + : item('katago-model', 'KataGo 默认模型', 'fail', '完整安装包必须内置默认模型') + ) + } + + const ttsReady = existsSync(join(resourcesRoot, 'data', 'tts')) || existsSync(join(appRoot, 'data', 'tts')) + items.push( + ttsReady + ? item('tts-assets', '离线语音资源', 'pass') + : item('tts-assets', '离线语音资源', 'warn', '未找到离线语音资源;云 TTS 仍可配置使用') + ) + + const automationReady = items.every((entry) => entry.status !== 'fail') + const assetsReady = lite ? manifestReady : manifestReady && binaryReady && modelReady + const flags: ReleaseReadinessFlags = { + automationReady, + assetsReady, + installersReady: true, + signingReady: true, + windowsSmokeReady: true, + visualQaReady: true, + publicBetaReady: false + } + flags.publicBetaReady = automationReady && assetsReady + items.unshift( + flags.publicBetaReady + ? item('runtime-ready', '当前安装包运行状态', 'pass', `${runtime.version} ${runtime.flavor} runtime ready`) + : item('runtime-ready', '当前安装包运行状态', lite ? 'warn' : 'fail', `${runtime.version} ${runtime.flavor} runtime needs attention`) + ) + return { + status: aggregate(items), + items, + flags + } +} +function pushManualEvidence(items: ReleaseReadinessItem[], signingReady: boolean, windowsSmokeReady: boolean, visualQaReady: boolean): void { items.push( signingReady ? item('signing-ready', '签名与公证', 'pass', '已检测到签名验收证据') - : item('signing-ready', '签名与公证', 'fail', '公开 beta 前需要 macOS 签名/公证与 Windows 签名证据') + : item('signing-ready', '签名与公证', 'warn', '发正式 release 前应确认 macOS 签名/公证与 Windows 签名证据') ) items.push( windowsSmokeReady ? item('windows-smoke-ready', 'Windows 真机 smoke', 'pass', '已检测到 Windows 真机验收证据') - : item('windows-smoke-ready', 'Windows 真机 smoke', 'fail', '公开 beta/tag 前必须完成 Windows 11 x64 真机 smoke') + : item('windows-smoke-ready', 'Windows 真机 smoke', 'warn', '发正式 release 前应完成 Windows 11 x64 下载、解压/安装、启动 smoke') ) items.push( visualQaReady ? item('visual-qa-ready', '视觉 QA 证据', 'pass', '已检测到视觉 QA 证据') - : item('visual-qa-ready', '视觉 QA 证据', 'fail', '公开 beta 前必须完成截图验收') + : item('visual-qa-ready', '视觉 QA 证据', 'warn', '发正式 release 前应完成关键页面视觉 QA') ) +} - const flags: ReleaseReadinessFlags = { - automationReady, - assetsReady, - installersReady, - signingReady, - windowsSmokeReady, - visualQaReady, - publicBetaReady: false - } +function buildResult(items: ReleaseReadinessItem[], flags: ReleaseReadinessFlags): ReleaseReadinessResult { flags.publicBetaReady = Object.entries(flags) .filter(([key]) => key !== 'publicBetaReady') .every(([, ready]) => ready) - items.unshift( flags.publicBetaReady ? item('public-beta-ready', 'Public Beta 发布状态', 'pass', '所有自动化与人工 gate 均已通过') - : item( - 'public-beta-ready', - 'Public Beta 发布状态', - 'fail', - 'publicBetaReady=false:签名/公证、Windows 真机 smoke、视觉 QA 任一缺失都不能 tag' - ) + : item('public-beta-ready', 'Public Beta 发布状态', 'warn', '发布前仍有建议补齐的 release gate') ) - return { status: aggregate(items), items, flags } } + +export function inspectReleaseReadiness(projectRoot = process.cwd()): ReleaseReadinessResult { + return inspectPackagedRuntime(projectRoot).packaged + ? packagedReadiness(projectRoot) + : sourceReadiness(projectRoot) +} diff --git a/tests/sprint4-release-contract.test.mjs b/tests/sprint4-release-contract.test.mjs index c7edfed..1966481 100644 --- a/tests/sprint4-release-contract.test.mjs +++ b/tests/sprint4-release-contract.test.mjs @@ -15,7 +15,8 @@ test('Sprint 4 UI interaction files exist', () => { 'src/renderer/src/features/board/KeyMoveNavigator.tsx', 'src/renderer/src/features/board/timelineInteraction.ts', 'src/renderer/src/features/teacher/TeacherKeyMoveActions.tsx', - 'src/renderer/src/features/release/BetaAcceptancePanel.tsx' + 'src/renderer/src/features/release/BetaAcceptancePanel.tsx', + 'src/main/services/release/packageRuntime.ts' ]) { assert.equal(existsSync(join(root, relativePath)), true, `${relativePath} should exist`) } @@ -32,6 +33,7 @@ test('Candidate tooltip exposes PV continuation context', () => { test('Sprint 4 scripts exist', () => { assert.equal(existsSync(join(root, 'scripts/p0_beta_acceptance.mjs')), true) assert.equal(existsSync(join(root, 'scripts/package_artifact_smoke.mjs')), true) + assert.equal(existsSync(join(root, 'scripts/smoke_windows_packaged_app.mjs')), true) }) test('Windows portable release artifact is a ZIP, not a portable EXE', () => { @@ -49,3 +51,56 @@ test('timelineInteraction exports move helpers', () => { assert.match(text, /progressFromMove/) assert.match(text, /lossSeverityFromWinrateDrop/) }) + +test('Windows release workflow smoke-starts packaged apps before upload', () => { + const packageJson = JSON.parse(read('package.json')) + const workflow = read('.github/workflows/release.yml') + const smoke = read('scripts/smoke_windows_packaged_app.mjs') + assert.equal(packageJson.scripts['smoke:windows-packaged'], 'node scripts/smoke_windows_packaged_app.mjs') + assert.match(workflow, /Smoke packaged Windows app/) + assert.match(workflow, /smoke_windows_packaged_app\.mjs --mode=full --require-katago/) + assert.match(workflow, /Smoke Lite packaged Windows app/) + assert.match(workflow, /smoke_windows_packaged_app\.mjs --mode=lite --allow-missing-katago/) + assert.match(workflow, /Smoke NVIDIA packaged Windows app/) + assert.match(workflow, /smoke_windows_packaged_app\.mjs --mode=nvidia --require-katago/) + assert.match(smoke, /window\.goagent\.getDiagnostics\(\)/) + assert.match(smoke, /window\.goagent\.inspectKataGoAssets\(\)/) + assert.match(smoke, /window\.goagent\.getReleaseReadiness\(\)/) + assert.match(smoke, /--user-data-dir=\$\{electronUserData\}/) + assert.match(smoke, /--disable-gpu/) + assert.match(smoke, /--disable-software-rasterizer/) + assert.match(smoke, /--disable-features=Vulkan/) +}) + +test('release readiness uses packaged runtime paths and current package version', () => { + const readiness = read('src/main/services/release/readiness.ts') + const runtime = read('src/main/services/release/packageRuntime.ts') + const diagnostics = read('src/main/services/diagnostics/index.ts') + assert.doesNotMatch(readiness, /0\.2\.0-beta\.1/) + assert.match(readiness, /inspectPackagedRuntime/) + assert.match(readiness, /packagedReadiness/) + assert.match(readiness, /resourcesRoot/) + assert.match(runtime, /process\.resourcesPath/) + assert.match(runtime, /app\.asar/) + assert.match(runtime, /flavor === 'lite'/) + assert.match(diagnostics, /isLitePackagedRuntime/) + assert.match(diagnostics, /Lite 安装包不内置 KataGo/) +}) + +test('Windows packaged startup avoids fragile legacy Electron profile state', () => { + const index = read('src/main/index.ts') + const store = read('src/main/lib/store.ts') + const katagoAssets = read('src/main/services/katago/katagoAssets.ts') + assert.match(index, /process\.platform === 'win32'/) + assert.match(index, /GOAGENT_ENABLE_ELECTRON_GPU/) + assert.match(index, /appendSwitch\('disable-software-rasterizer'\)/) + assert.match(index, /appendSwitch\('disable-features', 'Vulkan'\)/) + assert.match(index, /app\.disableHardwareAcceleration\(\)/) + assert.match(store, /legacyElectronUserData = app\.getPath\('userData'\)/) + assert.match(store, /electronUserData = .*electron-user-data/) + assert.match(store, /app\.setPath\('userData', electronUserData\)/) + assert.match(katagoAssets, /appHome/) + assert.match(katagoAssets, /legacyElectronUserData/) + assert.match(katagoAssets, /join\(appHome, 'katago'\)/) + assert.match(katagoAssets, /join\(legacyElectronUserData, 'katago'\)/) +})