diff --git a/.project-docs/30-worklog/tasks/20260812-client-built-release-makelore-7e5b.md b/.project-docs/30-worklog/tasks/20260812-client-built-release-makelore-7e5b.md new file mode 100644 index 0000000..9064c6a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260812-client-built-release-makelore-7e5b.md @@ -0,0 +1,74 @@ +# Task: 在 Makelore 构建并上传静态 Release + +## Identity + +- Task ID: 20260812-client-built-release-makelore-7e5b +- Mode: Feature +- Branch: codex/20260812-client-built-release-makelore-7e5b-client-built-release +- Worktree: D:\Datas\OthersProjects\makelore-client-built-release-7e5b +- Base commit: 3a80625fe2fb74ab4e0a883188a52eade8cd23b9 +- Owner: codex +- Status: Completed + +## Scope + +- 在 Electron Main 内为可发布小游戏/小程序执行一次受控本地 Vite 构建,并把临时 `dist` 作为新版本协议的一部分上传。 +- 让内置 Electron 浏览器预检绑定到本次 Main-owned 构建产物,而不是任意已打开的开发预览 URL。 +- 保持 Renderer capability、凭据、临时路径、归档和上传都由 Main 持有;Renderer 只获得稳定状态和可操作提示。 +- 更新发布状态轮询和旧服务端/旧客户端协议错误提示;不恢复 watcher、手工 ZIP、Compose 或云部署协调器。 +- 不直接修改 canonical `.project-docs`;通过任务记录提出 Promotion Candidates。 + +## Intent And Constraints + +- 用户明确要求本地构建以移除服务端 Docker 构建成本;浏览器能力复用 Electron 内置 Chromium,不新增 Playwright/Chromium 下载。 +- 必须使用安装版可控、可验证的 Electron Node 与固定 npm 11.6.2;Vite 必须来自本次 `npm ci` 按项目 `package-lock.json` 安装的精确版本,不得依赖开发机全局 PATH 或预存 `node_modules`。协议 v1 不强制所有项目使用同一个 Vite 版本。 +- 构建与临时 HTTP origin 均由 Main 创建和销毁,Renderer 不得提供任意本地路径、归档或预检 URL。 +- 客户端构建/预检可被非官方客户端绕过,不上传可信 receipt;服务端仍将产物视为不可信字节并独立重算。 +- 旧客户端缺新协议时由服务端明确拒绝;本客户端应把该错误映射为升级/重新提交,而不是云端沙箱故障。 + +## Project Context Loaded + +- Task identity / ownership: branch、worktree、base 与 registry 匹配,Feature ownership 已建立。 +- Integrated snapshot: 当前唯一发布链为 Main 打包源代码上传;`preflightCurrentProject` 只检查已附着 loopback 开发预览,不构建或证明上传产物。 +- Relevant decisions: 当前客户端 canonical 明确禁止本地执行 Vite;本任务由用户确认后显式替换该边界,但仍不把客户端结果提升为安全信任结论。 +- Peer overlap: browser preflight、static-release-only、cloud-build diagnosis 均已集成或为历史;Windows 打包任务只生成安装包,与本任务不共享生产代码写入。 +- Planning Gate: Passed. + +## Plan + +1. 核对安装包中的 Node/npm/Vite 能力并定义 Main-owned client build 模块。 +2. 让真实临时 `dist` 通过内置 Electron 双视口预检,确保 origin、网络和 cleanup fail closed。 +3. 扩展 source package/upload 合同携带预构建产物与摘要,更新 Renderer 状态/错误投影。 +4. 增加单元与真实 Electron E2E,验证无 Playwright、无任意路径/URL、无临时文件泄漏。 +5. 完成任务记录、Promotion Candidates、文档门禁、独立终审和 feature commit。 + +## Outcome + +- 新增 Main-owned release builder:对安全快照运行固定 npm 11.6.2 `ci --ignore-scripts` 和显式项目 Vite CLI,限制环境、时间、输出与进程树,并生成确定性 source/built ZIP 与严格 v1 contract。 +- 内置 Electron 通过 Main-only `preflightStaticArtifact(...)` 验证与最终上传 ZIP 共用的内存文件快照;临时 loopback origin、非持久 partition、外域阻断和清理均由 Main 持有,不再要求用户预先打开开发预览。 +- 发布请求改为上传 `archive`、`built_archive`、`artifact_contract`;Renderer 只传项目元数据且永不接触路径、归档、origin、Host token 或 Works 凭据。 +- 发布 UI 已把旧 sandbox/browser 错误标为历史协议,提示升级客户端后重新构建提交;新本地构建、产物预检和服务端字节拒绝均使用固定中文操作提示。 +- 新协议的 `BUILD_STALE`、归档存储和 ReleaseStore 瞬时失败不再伪装成旧任务;提示运营在“构建异常”中重新处理,旧 sandbox/browser 任务仍要求新版重提。 +- electron-builder 从固定 npm 11.6.2 发布包自动生成有界且完整的运行时闭包;packaged verifier 会从真实 `resources/publish-runtime` 定位并用安装版 Electron Node 执行 `npm --version`。 + +## Verification + +- 最终相关 Vitest:6 files / 129 passed。 +- `pnpm typecheck`、scoped ESLint、`pnpm verify:publish-runtime`(npm 11.6.2)、`pnpm build:vite` 通过。 +- 真实 Electron production seam E2E:1 passed,覆盖 Main-owned dist、双视口、外域请求发送前阻断和临时 WebContents 清理。 +- 一次性真实构建 smoke(测试文件随后删除):Electron 40 Node + 固定 npm 11.6.2 实际执行 `npm ci` 与项目 Vite 7.3.1,约 11.77 秒通过,并验证 dispose 后临时 dist 不可访问。 +- 全量 Vitest:1532 passed / 3 failed;三项在未修改主分支可复现(缺失 `.opencode/agent` 测试目录、两个 OpenCode 并发/超时用例),不属于本次改动。 +- `git diff --check` 通过。 +- 最终工作树的 Windows 安装产物已实际重建并通过 `pnpm verify:artifact:win -- --allow-dirty`:packaged Electron 40.10.6 / Node 24.15.0 可执行固定 npm 11.6.2;安装器 223,547,912 bytes,SHA-256 `08A0BB7BC66EE4AD8B120E8955B149951CD86AF53CC966C7973AD4D77A5815C1`。 + +## Follow-ups + +- 发布流水线必须继续对每个 Windows 安装产物运行 `pnpm verify:artifact:win`;本任务已验证当前本地构建产物,但没有上传或发布该安装器。 +- 本地 npm/Vite 构建会以桌面用户权限执行项目 Vite config/plugins;这是用户项目代码受信任的客户端边界,不是 sandbox,也不能成为服务端授权或审核 receipt。 +- Electron 预检是可绕过的 UX fail-fast,且不复刻生产 opaque-origin CSP;若需不可绕过 runtime 验收,应由未来独立可信 verifier 检查 immutable Release。 + +## Promotion Candidates + +- Target: Makelore current-state/architecture/domain rules。Proposal: 发布链改为 Main-owned local npm/Vite build → exact dist Electron UX preflight → source+built+contract upload;删除“客户端不得执行 Vite”和“必须已有开发预览”的现行描述。Evidence: 124 focused tests、真实 Electron E2E、实际 npm/Vite smoke。Future impact: 安装包包含固定 npm,提交依赖锁文件与本地网络。Semantic conflict: 显式反转旧客户端边界;用户已确认,无需再次确认。 +- Target: success criteria/API integration/skills。Proposal: 明确预检可绕过且无可信 receipt,服务端仍逐字节验证并要求人工审核;旧客户端必须升级重提。Evidence: route/API contract 与错误投影测试。Future impact: 后续客户端不得把 preflight 结果当平台证明。Semantic conflict: 无。 +- Target: evidence/commitments。Proposal: 记录 packaged verifier 已证明安装产物中的固定 npm 11.6.2 完整闭包可由 Electron Node 执行,并保留客户端 Vite config 执行权限边界。Evidence: 安装器 SHA-256 与 `verify:artifact:win` 输出。Future impact: Windows 发布前保留硬门禁。Semantic conflict: 无,不宣称该本地产物已上传或发布。 diff --git a/README.md b/README.md index 96c9009..f67290e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、图片/视频生成任务和私有结果。 - `Makelore Learning|AI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。 -作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 +作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Electron Main 对源码快照运行安装包内固定 npm 和项目 `package-lock.json` 锁定的 Vite,使用内置浏览器预检同一份输出字节,再将源码归档、构建归档与校验清单一并提交。平台独立重算摘要、固化不可变 Release 并进入运营审核;客户端预检不构成可信审核凭据。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 ## 当前产品状态 @@ -81,7 +81,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。 - Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成。 - Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在系统提供受保护凭据存储时加密落盘;Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 -- AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识和非敏感作品资料;Main 持有文件读取、安全打包、Works Token、版本生成、幂等重试和安全状态投影。 +- AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识和非敏感作品资料;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话保持本地。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、断点续传与事件契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 @@ -92,8 +92,8 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。 - 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 - 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板收起或被弹窗遮挡时隐藏原生页面并暂停 Agent 调试;该能力独立于发布和部署。 -- 一键提交前,Makelore 会复用当前项目已在内置浏览器打开的本地回环预览地址,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理。 -- 客户端不会为此安装 Playwright/Chromium、下载依赖或执行项目构建脚本。没有活动的本地预览时会提示先在内置浏览器打开预览;该检查只改善提交前反馈,服务端仍独立重建、校验和发布,客户端不会上传“已通过”声明。 +- 一键提交时,Makelore 会从待上传构建归档的同一组 Main-owned 内存字节启动临时回环站点,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理,也不要求用户预先打开开发预览。 +- 客户端复用 Electron 内置 Chromium,不安装 Playwright 或额外浏览器。预检只改善提交前反馈,可被非官方客户端绕过,也不会上传“已通过”凭据;平台仍把源码、构建归档和清单视为不可信输入,逐字节重算并在人工审核后发布。安装包携带固定 npm 运行时,项目依赖和 Vite 版本由 `package-lock.json` 锁定;依赖准备需要本地网络。 ### 真机预览 diff --git a/electron-builder.yml b/electron-builder.yml index 50fe870..aeef5aa 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -13,6 +13,8 @@ files: - dist-electron - package.json +beforePack: ./scripts/before-pack.cjs + extraResources: - from: resources/ to: resources/ diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index a330fee..b6c10d4 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -19,6 +19,7 @@ import { AgentBrowserCdpGuard } from './cdp-guard'; import { AgentBrowserEventBuffer } from './event-buffer'; import { AgentBrowserFault } from './fault'; import { AgentBrowserPayloadStore } from './payload-store'; +import { startStaticReleaseServer, type StaticArtifactSnapshot } from '../services/static-release-server'; const CDP_PROTOCOL_VERSION = '1.3'; const INLINE_RESULT_BYTES = 64 * 1024; @@ -59,6 +60,7 @@ const PUBLISH_PREFLIGHT_INSPECTION = `(() => { }; })()`; const SAFE_PREFLIGHT_MESSAGES = { + PUBLISH_PREFLIGHT_UNAVAILABLE: '暂时无法启动作品检查,请稍后重试。', PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。', PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。', PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。', @@ -202,6 +204,28 @@ export class AgentBrowserModule { return { ok: true }; } + async preflightStaticArtifact(snapshot: StaticArtifactSnapshot): Promise<{ ok: true }> { + this.assertAvailable(); + const deadline = Date.now() + PUBLISH_PREFLIGHT_TIMEOUT_MS; + let artifactServer: Awaited>; + try { + artifactServer = await beforePublishPreflightDeadline( + startStaticReleaseServer(snapshot), + deadline, + ); + } catch { + throw new PublishPreflightFault('PUBLISH_PREFLIGHT_UNAVAILABLE'); + } + try { + for (const viewport of PUBLISH_PREFLIGHT_VIEWPORTS) { + await this.preflightStaticViewport(artifactServer.entryUrl, viewport, deadline); + } + return { ok: true }; + } finally { + await beforePublishPreflightDeadline(artifactServer.close(), deadline).catch(() => undefined); + } + } + async getSnapshot(projectPath?: string): Promise { if (!this.record) return this.closedSnapshot(); if (projectPath) this.assertProject(this.record, projectPath); @@ -495,6 +519,7 @@ export class AgentBrowserModule { const previewOrigin = new URL(targetUrl).origin; let view: AgentBrowserViewPort | null = null; let releaseOriginRestriction: (() => void) | null = null; + let contentLoadStarted = false; let runtimeError = false; const onDebuggerMessage: PortListener = (_event, methodValue, paramsValue) => { if (typeof methodValue !== 'string') return; @@ -569,13 +594,14 @@ export class AgentBrowserModule { }), deadline, ); + contentLoadStarted = true; await beforePublishPreflightDeadline(view.webContents.loadURL(targetUrl), deadline); await beforePublishPreflightDeadline(delay(PUBLISH_PREFLIGHT_SETTLE_MS), deadline); const inspected = await beforePublishPreflightDeadline( view.webContents.executeJavaScript(PUBLISH_PREFLIGHT_INSPECTION), deadline, ); - if (new URL(view.webContents.getURL()).origin !== previewOrigin) { + if (view.webContents.getURL() !== targetUrl) { throw new PublishPreflightFault('PUBLISH_PREFLIGHT_LOAD_FAILED'); } if (runtimeError) { @@ -596,7 +622,11 @@ export class AgentBrowserModule { } catch (error) { if (error instanceof PublishPreflightFault) throw error; throw new PublishPreflightFault( - isTimeoutError(error) ? 'PUBLISH_PREFLIGHT_TIMEOUT' : 'PUBLISH_PREFLIGHT_LOAD_FAILED', + isTimeoutError(error) + ? 'PUBLISH_PREFLIGHT_TIMEOUT' + : contentLoadStarted + ? 'PUBLISH_PREFLIGHT_LOAD_FAILED' + : 'PUBLISH_PREFLIGHT_UNAVAILABLE', ); } finally { bestEffortCleanup(() => view?.webContents.debugger.removeListener('message', onDebuggerMessage)); diff --git a/electron/api/context.ts b/electron/api/context.ts index 689fcae..0d75dfa 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -11,10 +11,12 @@ import type { AgentBrowserPayloadChunk, AgentBrowserSnapshot, } from '../../shared/agent-browser'; +import type { StaticArtifactSnapshot } from '../services/static-release-server'; export type WorksSubmissionBindingStore = ReturnType; export interface AgentBrowserService { + preflightStaticArtifact(snapshot: StaticArtifactSnapshot): Promise<{ ok: true }>; preflightCurrentProject(projectPath: string): Promise<{ ok: true }>; getSnapshot(projectPath?: string): Promise | AgentBrowserSnapshot; open(input: { diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index dd43171..a51736e 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { randomUUID } from 'node:crypto'; -import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { app } from 'electron'; +import { lstat, mkdir, open, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; @@ -10,9 +10,9 @@ import { trustedWorksProjectPlayUrl } from '../works-play-url'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { - createStaticProjectPackage, ProjectPackageError, } from '../../services/project-packager'; +import { prepareProjectRelease, ProjectReleaseBuildError } from '../../services/project-release-builder'; import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; import { logger } from '../../utils/logger'; @@ -126,14 +126,12 @@ function sendPublishSourceFailure( } const SAFE_PUBLISH_PREFLIGHT_CODES = new Set([ - 'PREVIEW_REQUIRED', 'PUBLISH_PREFLIGHT_LOAD_FAILED', 'PUBLISH_PREFLIGHT_RUNTIME_ERROR', 'PUBLISH_PREFLIGHT_BLANK', 'PUBLISH_PREFLIGHT_TIMEOUT', ]); const SAFE_PUBLISH_PREFLIGHT_MESSAGES: Record = { - PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。', PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。', PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。', PUBLISH_PREFLIGHT_BLANK: '作品打开后没有可见内容。', @@ -157,7 +155,17 @@ async function sendPublishSourceUpstreamError( ): Promise { let code = fallbackCode; let error = fallbackMessage; - if (response.status === 401) { + let payload: unknown = null; + try { payload = await readResponsePayload(response); } catch { /* project only stable codes */ } + const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; + const upstreamCode = isRecord(payload) + ? readOptionalString(payload.code) ?? (detail ? readOptionalString(detail.code) : undefined) + : undefined; + if (upstreamCode === 'CLIENT_BUILD_PROTOCOL_REQUIRED') { + code = upstreamCode; + error = '请升级 Makelore 并重新提交。'; + } + else if (response.status === 401) { code = 'AUTH_REQUIRED'; error = '登录状态已失效,请重新登录。'; } else if (response.status === 403) { @@ -748,6 +756,9 @@ async function readAutomaticVersionName(projectPath: string): Promise { function createSourceUploadForm( archiveBytes: Buffer, archiveName: string, + builtArchiveBytes: Buffer, + builtArchiveName: string, + artifactContract: object, versionName: string, ): FormData { const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' }); @@ -755,6 +766,8 @@ function createSourceUploadForm( form.set('version_name', versionName); form.set('change_log', SOURCE_PUBLISH_CHANGE_LOG); form.set('archive', archiveBlob, archiveName); + form.set('built_archive', new Blob([new Uint8Array(builtArchiveBytes)], { type: 'application/zip' }), builtArchiveName); + form.set('artifact_contract', JSON.stringify(artifactContract)); return form; } @@ -763,6 +776,9 @@ async function uploadSourceProjectVersion(input: { appId: string; archiveBytes: Buffer; archiveName: string; + builtArchiveBytes: Buffer; + builtArchiveName: string; + artifactContract: object; versionName: string; idempotencyKey: string; }): Promise { @@ -780,6 +796,9 @@ async function uploadSourceProjectVersion(input: { body: createSourceUploadForm( input.archiveBytes, input.archiveName, + input.builtArchiveBytes, + input.builtArchiveName, + input.artifactContract, input.versionName, ), }, @@ -847,24 +866,11 @@ async function handlePublishProjectSource( return; } - const temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-publish-')); - const archivePath = join(temporaryDirectory, 'project.zip'); + let prepared: Awaited> | null = null; try { - if (!ctx.agentBrowser) { - sendPublishSourceFailure( - res, - 400, - 'PREVIEW_REQUIRED', - '请先在 Makelore 内置浏览器中打开当前项目预览。', - ); - return; - } - await ctx.agentBrowser.preflightCurrentProject(localProject.path); - const packageSummary = await createStaticProjectPackage({ - projectPath: localProject.path, - archivePath, - }); - const archiveBytes = await readFile(archivePath); + if (!ctx.agentBrowser) throw new ProjectReleaseBuildError('LOCAL_BUILD_RUNTIME_UNAVAILABLE'); + prepared = await prepareProjectRelease({ projectPath: localProject.path, clientVersion: app.getVersion() }); + await ctx.agentBrowser.preflightStaticArtifact(prepared.staticArtifact); const versionName = await readAutomaticVersionName(localProject.path); const idempotencyKey = `makelore-${randomUUID()}`; @@ -910,8 +916,11 @@ async function handlePublishProjectSource( const uploadResponse = await uploadSourceProjectVersion({ accessToken, appId, - archiveBytes, - archiveName: packageSummary.archiveName, + archiveBytes: prepared.sourceArchive.bytes, + archiveName: prepared.sourceArchive.name, + builtArchiveBytes: prepared.builtArchive.bytes, + builtArchiveName: prepared.builtArchive.name, + artifactContract: prepared.contract, versionName, idempotencyKey, }); @@ -943,7 +952,7 @@ async function handlePublishProjectSource( versionId: uploadPayload.version_id, versionName, reviewStatus: uploadPayload.review_status, - zipSha256: packageSummary.sha256, + zipSha256: prepared.contract.source_digest, }); } catch { logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved'); @@ -952,7 +961,7 @@ async function handlePublishProjectSource( } else { bindingWarning = LOCAL_PREVIEW_BINDING_WARNING; } - const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary; + const { archivePath: _archivePath, ...rendererPackageSummary } = prepared.sourceArchive.summary; sendJson(res, uploadResponse.status, { success: true, package: rendererPackageSummary, @@ -960,7 +969,7 @@ async function handlePublishProjectSource( ...(bindingWarning ? { binding_warning: bindingWarning } : {}), }); } finally { - await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined); + await prepared?.dispose().catch(() => undefined); } } @@ -1147,12 +1156,15 @@ export async function handleWorksRoutes( const isProjectStatus = /^\/api\/works\/projects\/mine\/[^/]+\/status$/.test(url.pathname); if (isProjectSourcePublish) { const isPackageError = error instanceof ProjectPackageError; + const isLocalBuildError = error instanceof ProjectReleaseBuildError; sendPublishSourceFailure( res, - isPackageError || isSafePublishPreflightError(error) ? 400 : 503, - isPackageError || isSafePublishPreflightError(error) ? error.code : 'WORKS_SQUARE_UNAVAILABLE', + isPackageError || isLocalBuildError || isSafePublishPreflightError(error) ? 400 : 503, + isPackageError || isLocalBuildError || isSafePublishPreflightError(error) ? error.code : 'WORKS_SQUARE_UNAVAILABLE', isPackageError ? error.message + : isLocalBuildError + ? error.code : isSafePublishPreflightError(error) ? SAFE_PUBLISH_PREFLIGHT_MESSAGES[error.code] : '发布服务暂时不可用,请稍后重试。', diff --git a/electron/main/index.ts b/electron/main/index.ts index 79199c9..4050b08 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -4,7 +4,8 @@ */ import { app, BrowserWindow, nativeImage, shell } from 'electron'; import type { Server } from 'node:http'; -import { join } from 'path'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; import { OpencodeManager } from '../opencode/manager'; import { buildOpencodeRuntimeConfigFromNianCodeProviders } from '../opencode/provider-config'; import { @@ -66,6 +67,7 @@ import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browse import { browserOAuthManager } from '../utils/browser-oauth'; import { createProjectProgressSync } from '../services/project-progress-sync'; import { createWorksSubmissionBindingStore } from '../services/works-submission-binding'; +import { createStaticArtifactSnapshot } from '../services/static-release-server'; import { consumeWorksSquareStartupRuntimeCleanupRequired, getWorksSquareSessionRestoreStatus, @@ -162,6 +164,7 @@ let hostApiServer: Server | null = null; let projectProgressSync: ReturnType | null = null; let worksSubmissionBinding: ReturnType | null = null; let agentBrowser: AgentBrowserModule | null = null; +let applicationInitialization: Promise | null = null; let imageWorkspaceModule: DesignWorkspaceModule | null = null; const mainWindowFocusState = createMainWindowFocusState(); const quitLifecycleState = createQuitLifecycleState(); @@ -653,7 +656,8 @@ if (gotTheLock) { // Application lifecycle app.whenReady().then(() => { - void initialize().catch((error) => { + applicationInitialization = initialize(); + void applicationInitialization.catch((error) => { logger.error('Application initialization failed:', error); }); @@ -754,20 +758,52 @@ if (gotTheLock) { // Export for testing export { mainWindow, opencodeManager, opencodeProjectStore }; -export async function runLocalPreviewPreflightE2E(url: string): Promise<{ ok: true }> { - if (!isE2EMode || !agentBrowser) throw new Error('E2E local preview preflight is unavailable'); - const projectPath = join(app.getPath('temp'), 'makelore-local-preview-preflight-e2e'); - await agentBrowser.open({ - projectId: 'local-preview-preflight-e2e', - projectPath, - url, - visible: false, +export async function runLocalPreviewPreflightE2E( + scenario: 'success' | 'external' = 'success', +): Promise<{ ok: boolean; externalRequests: number; code?: string }> { + if (!isE2EMode) throw new Error('E2E local preview preflight is unavailable'); + const initialization = applicationInitialization; + if (!initialization) { + throw new Error('E2E local preview preflight is not ready'); + } + await initialization; + const browser = agentBrowser; + if (!browser) { + throw new Error('E2E local preview preflight is not ready'); + } + let externalRequests = 0; + const externalServer = createServer((_request, response) => { + externalRequests += 1; + response.end('blocked'); }); try { - return await agentBrowser.preflightCurrentProject(projectPath); + await new Promise((resolveListen, reject) => { + externalServer.once('error', reject); + externalServer.listen(0, '127.0.0.1', resolveListen); + }); + const address = externalServer.address(); + if (!address || typeof address === 'string') throw new Error('E2E external server unavailable'); + const externalScript = scenario === 'external' + ? `` + : ''; + const artifact = createStaticArtifactSnapshot([{ + path: 'index.html', + bytes: Buffer.from(`
Playable
${externalScript}`), + }]); + try { + await browser.preflightStaticArtifact(artifact); + return { ok: true, externalRequests }; + } catch (error) { + return { + ok: false, + externalRequests, + code: error && typeof error === 'object' && 'code' in error + ? String(error.code) + : 'PUBLISH_PREFLIGHT_UNAVAILABLE', + }; + } } finally { - await agentBrowser.close(projectPath).catch(() => undefined); - await agentBrowser.resetProfile(projectPath).catch(() => undefined); + await new Promise((resolveClose) => externalServer.close(() => resolveClose())); } } diff --git a/electron/services/project-release-builder.ts b/electron/services/project-release-builder.ts new file mode 100644 index 0000000..38817f4 --- /dev/null +++ b/electron/services/project-release-builder.ts @@ -0,0 +1,202 @@ +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, sep } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager'; +import { PublishRuntimeError, resolvePublishRuntime, runElectronNode } from './publish-runtime'; +import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server'; + +const require = createRequire(import.meta.url); +const AdmZip = require('adm-zip') as typeof import('adm-zip'); +const FIXED_ZIP_TIME = new Date(1980, 0, 1); +const FILE_MODE = 0o100644; +const MAX_OUTPUT_FILES = 2_000; +const MAX_OUTPUT_BYTES = 50 * 1024 * 1024; + +export type ArtifactContract = { + schema_version: 1; + entry_path: 'index.html'; + source_digest: string; + built_archive_digest: string; + artifact_digest: string; + file_count: number; + total_bytes: number; + files: Array<{ path: string; size: number; sha256: string }>; + security_profile: 'works-square-static-sandbox-v1'; + toolchain: { client: 'makelore'; client_version: string; node: string; npm: string; vite: string }; +}; + +export type PreparedRelease = { + sourceArchive: { path: string; name: 'project.zip'; bytes: Buffer; summary: StaticProjectPackageSummary }; + builtArchive: { path: string; name: 'built-project.zip'; bytes: Buffer }; + contract: ArtifactContract; + distRoot: string; + staticArtifact: StaticArtifactSnapshot; + dispose(): Promise; +}; + +export class ProjectReleaseBuildError extends Error { + constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_OUTPUT_MISSING') { + super(code); + this.name = 'ProjectReleaseBuildError'; + } +} + +const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).digest('hex'); +const archivePath = (value: string) => sep === '/' ? value : value.split(sep).join('/'); +const comparePaths = (left: string, right: string) => Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); + +function isSafeArchiveFile(entry: import('adm-zip').IZipEntry): boolean { + const unixMode = (entry.attr >>> 16) & 0xffff; + const unixType = unixMode & 0xf000; + return unixType === 0 || unixType === 0x8000; +} + +function safeArchivePath(name: string): string[] | null { + if (!name || name.includes('\\') || name.startsWith('/') || name.includes('\0')) return null; + const parts = name.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return null; + if (parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) return null; + return parts; +} + +async function extractSnapshot(archive: string, target: string): Promise { + const zip = new AdmZip(archive); + const destinations = new Set(); + for (const entry of zip.getEntries()) { + const name = entry.entryName; + const parts = safeArchivePath(name); + if (entry.header.encripted || !parts || (!entry.isDirectory && !isSafeArchiveFile(entry))) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + } + if (entry.isDirectory) { + const unixType = ((entry.attr >>> 16) & 0xffff) & 0xf000; + if (unixType !== 0 && unixType !== 0x4000) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + continue; + } + const destinationKey = name.toLowerCase(); + if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + destinations.add(destinationKey); + if (entry.header.size > MAX_OUTPUT_BYTES) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + } + const destination = join(target, ...parts); + await mkdir(dirname(destination), { recursive: true }); + try { + const data = entry.getData(); + await writeFile(destination, data, { flag: 'wx', mode: 0o600 }); + } catch { + throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + } + } +} + +async function collectOutput(root: string): Promise> { + const files: Array<{ path: string; bytes: Buffer }> = []; + let total = 0; + async function walk(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((a, b) => comparePaths(a.name, b.name)); + for (const entry of entries) { + const absolute = join(directory, entry.name); + const stats = await lstat(absolute); + if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + if (stats.isDirectory()) await walk(absolute); + else { + total += stats.size; + if (files.length + 1 > MAX_OUTPUT_FILES || total > MAX_OUTPUT_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + files.push({ path: archivePath(relative(root, absolute)), bytes: await readFile(absolute) }); + } + } + } + try { await walk(root); } catch (error) { + if (error instanceof ProjectReleaseBuildError) throw error; + throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + } + files.sort((a, b) => comparePaths(a.path, b.path)); + if (files.some((file) => file.path.toLowerCase() === 'release.json')) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + const index = files.find((file) => file.path === 'index.html'); + if (!index) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + try { + if (!new TextDecoder('utf-8', { fatal: true }).decode(index.bytes).trim()) throw new Error('empty'); + } catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } + return files; +} + +export async function prepareProjectRelease(input: { projectPath: string; clientVersion: string }): Promise { + const taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-')); + try { + const sourcePath = join(taskRoot, 'project.zip'); + const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath }); + const sourceBytes = await readFile(sourcePath); + const snapshot = join(taskRoot, 'snapshot'); + const distRoot = join(taskRoot, 'owned-dist'); + const npmUserConfig = join(taskRoot, 'empty-npmrc'); + const npmCache = join(taskRoot, 'npm-cache'); + await mkdir(snapshot, { recursive: true }); + await writeFile(npmUserConfig, '', 'utf8'); + await extractSnapshot(sourcePath, snapshot); + const runtime = await resolvePublishRuntime(); + await runElectronNode({ + args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--userconfig', npmUserConfig, '--cache', npmCache], + cwd: snapshot, + deadlineMs: 5 * 60_000, + }); + let viteVersion = ''; + try { + const vitePackage = JSON.parse(await readFile(join(snapshot, 'node_modules', 'vite', 'package.json'), 'utf8')) as { version?: unknown }; + if (typeof vitePackage.version !== 'string') throw new Error('missing vite version'); + viteVersion = vitePackage.version; + } catch { throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); } + // Vite config and plugins execute with desktop-user authority. This build is not a sandbox or trust proof. + await runElectronNode({ + args: [join(snapshot, 'node_modules', 'vite', 'bin', 'vite.js'), 'build', '--base', './', '--outDir', distRoot, '--emptyOutDir'], + cwd: snapshot, + deadlineMs: 3 * 60_000, + }); + const output = await collectOutput(distRoot); + const zip = new AdmZip(); + const files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) })); + for (const file of output) { + zip.addFile(file.path, file.bytes, '', FILE_MODE); + const entry = zip.getEntry(file.path); + if (entry) entry.header.time = FIXED_ZIP_TIME; + } + const builtBytes = zip.toBuffer(); + const builtPath = join(taskRoot, 'built-project.zip'); + await writeFile(builtPath, builtBytes); + const totalBytes = files.reduce((sum, file) => sum + file.size, 0); + // Matches the server's canonical JSON: sorted files, sorted object keys, no whitespace. + const artifactDigest = sha256(JSON.stringify(files.map((file) => ({ + path: file.path, + sha256: file.sha256, + size: file.size, + })))); + const staticArtifact = createStaticArtifactSnapshot(output); + return { + sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes, summary }, + builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes }, + distRoot, + staticArtifact, + contract: { + schema_version: 1, + entry_path: 'index.html', + source_digest: sha256(sourceBytes), + built_archive_digest: sha256(builtBytes), + artifact_digest: artifactDigest, + file_count: files.length, + total_bytes: totalBytes, + files, + security_profile: 'works-square-static-sandbox-v1', + toolchain: { client: 'makelore', client_version: input.clientVersion, node: runtime.nodeVersion, npm: runtime.npmVersion, vite: viteVersion }, + }, + dispose: async () => { await rm(taskRoot, { recursive: true, force: true }); }, + }; + } catch (error) { + await rm(taskRoot, { recursive: true, force: true }).catch(() => undefined); + if (error instanceof ProjectReleaseBuildError) throw error; + if (error instanceof PublishRuntimeError) throw new ProjectReleaseBuildError(error.code); + throw error; + } +} diff --git a/electron/services/publish-runtime.ts b/electron/services/publish-runtime.ts new file mode 100644 index 0000000..4da2c95 --- /dev/null +++ b/electron/services/publish-runtime.ts @@ -0,0 +1,108 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { app } from 'electron'; + +const require = createRequire(import.meta.url); +const OUTPUT_LIMIT = 64 * 1024; + +export class PublishRuntimeError extends Error { + constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT') { + super(code); + this.name = 'PublishRuntimeError'; + } +} + +export type PublishRuntime = { + npmCli: string; + nodeVersion: string; + npmVersion: string; +}; + +function childEnvironment(): NodeJS.ProcessEnv { + const allowed = ['ALLUSERSPROFILE', 'APPDATA', 'COMMONPROGRAMFILES', 'COMMONPROGRAMFILES(X86)', + 'COMMONPROGRAMW6432', 'COMSPEC', 'HOME', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA', + 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', + 'http_proxy', 'https_proxy', 'no_proxy', + 'NUMBER_OF_PROCESSORS', 'OS', 'PATH', 'PATHEXT', 'PROGRAMDATA', 'PROGRAMFILES', + 'PROGRAMFILES(X86)', 'PROGRAMW6432', 'SYSTEMDRIVE', 'SYSTEMROOT', 'TEMP', 'TMP', + 'USERPROFILE', 'WINDIR', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME']; + const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1', CI: '1', npm_config_update_notifier: 'false' }; + for (const key of allowed) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + return env; +} + +async function terminateTree(child: ChildProcess): Promise { + if (!child.pid || child.exitCode !== null) return; + if (process.platform === 'win32') { + const taskkill = join(process.env.SYSTEMROOT ?? 'C:\\Windows', 'System32', 'taskkill.exe'); + await new Promise((resolve) => { + const killer = spawn(taskkill, ['/pid', String(child.pid), '/t', '/f'], { shell: false, windowsHide: true }); + killer.once('close', () => resolve()); + killer.once('error', () => resolve()); + }); + } else { + try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } + } +} + +export async function runElectronNode(input: { + args: string[]; + cwd?: string; + deadlineMs: number; +}): Promise<{ stdout: string; stderr: string }> { + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, input.args, { + cwd: input.cwd, + env: childEnvironment(), + shell: false, + windowsHide: true, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const append = (current: string, chunk: Buffer) => (current + chunk.toString('utf8')).slice(-OUTPUT_LIMIT); + child.stdout?.on('data', (chunk: Buffer) => { stdout = append(stdout, chunk); }); + child.stderr?.on('data', (chunk: Buffer) => { stderr = append(stderr, chunk); }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + void terminateTree(child); + }, input.deadlineMs); + child.once('error', () => { + clearTimeout(timer); + reject(new PublishRuntimeError('LOCAL_BUILD_RUNTIME_UNAVAILABLE')); + }); + child.once('close', (code) => { + clearTimeout(timer); + if (timedOut) reject(new PublishRuntimeError('LOCAL_BUILD_TIMEOUT')); + else if (code !== 0) reject(new PublishRuntimeError('LOCAL_BUILD_FAILED')); + else resolve({ stdout, stderr }); + }); + }); +} + +export async function resolvePublishRuntime(): Promise { + try { + const packagedNpmPackage = join(process.resourcesPath, 'publish-runtime', 'package.json'); + const npmPackage = app.isPackaged && existsSync(packagedNpmPackage) + ? packagedNpmPackage + : require.resolve('npm/package.json'); + const npmCli = join(dirname(npmPackage), 'bin', 'npm-cli.js'); + const npmResult = await runElectronNode({ args: [npmCli, '--version'], deadlineMs: 10_000 }); + const nodeResult = await runElectronNode({ args: ['--version'], deadlineMs: 10_000 }); + return { + npmCli, + nodeVersion: nodeResult.stdout.trim().replace(/^v/, ''), + npmVersion: npmResult.stdout.trim(), + }; + } catch (error) { + if (error instanceof PublishRuntimeError) throw error; + throw new PublishRuntimeError('LOCAL_BUILD_RUNTIME_UNAVAILABLE'); + } +} diff --git a/electron/services/static-release-server.ts b/electron/services/static-release-server.ts new file mode 100644 index 0000000..c0f0e47 --- /dev/null +++ b/electron/services/static-release-server.ts @@ -0,0 +1,182 @@ +import { randomBytes } from 'node:crypto'; +import { createServer, type ServerResponse } from 'node:http'; +import type { Socket } from 'node:net'; +import { extname } from 'node:path'; + +const MIME_TYPES: Readonly> = { + '.avif': 'image/avif', + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.mjs': 'text/javascript; charset=utf-8', + '.otf': 'font/otf', + '.ogg': 'audio/ogg', + '.png': 'image/png', + '.svg': 'image/svg+xml; charset=utf-8', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.wasm': 'application/wasm', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.wav': 'audio/wav', +}; +const MAX_FILES = 2_000; +const MAX_BYTES = 50 * 1024 * 1024; +declare const staticArtifactSnapshotBrand: unique symbol; + +export interface StaticArtifactSnapshot { + readonly [staticArtifactSnapshotBrand]: true; +} + +export interface StaticArtifactFile { + readonly path: string; + readonly bytes: Buffer; +} + +export interface StaticReleaseServer { + readonly entryUrl: string; + close(): Promise; +} + +const snapshotFiles = new WeakMap>(); + +export function createStaticArtifactSnapshot( + files: readonly StaticArtifactFile[], +): StaticArtifactSnapshot { + if (!Array.isArray(files) || files.length === 0 || files.length > MAX_FILES) { + throw new Error('Static artifact file set is invalid.'); + } + const owned = new Map(); + const destinationKeys = new Set(); + let totalBytes = 0; + for (const file of files) { + if (!file || !isSafeArtifactPath(file.path) || !Buffer.isBuffer(file.bytes)) { + throw new Error('Static artifact file is invalid.'); + } + const destinationKey = file.path.toLowerCase(); + if (destinationKeys.has(destinationKey)) { + throw new Error('Static artifact paths must be unique.'); + } + destinationKeys.add(destinationKey); + const bytes = Buffer.from(file.bytes); + totalBytes += bytes.length; + if (totalBytes > MAX_BYTES) throw new Error('Static artifact is too large.'); + owned.set(file.path, bytes); + } + const entry = owned.get('index.html'); + if (!entry?.length) throw new Error('Static artifact entry is missing.'); + + const handle = Object.create(null) as StaticArtifactSnapshot; + Object.defineProperty(handle, 'toJSON', { + value: () => { throw new Error('Static artifact snapshots are Main-owned and cannot be serialized.'); }, + }); + Object.freeze(handle); + snapshotFiles.set(handle, owned); + return handle; +} + +export function staticArtifactSnapshotFiles( + snapshot: StaticArtifactSnapshot, +): readonly StaticArtifactFile[] { + const files = snapshotFiles.get(snapshot); + if (!files) throw new Error('Static artifact snapshot must be Main-owned.'); + return Array.from(files, ([path, bytes]) => ({ path, bytes: Buffer.from(bytes) })); +} + +export async function startStaticReleaseServer( + snapshot: StaticArtifactSnapshot, +): Promise { + const files = snapshotFiles.get(snapshot); + if (!files) throw new Error('Static artifact snapshot must be Main-owned.'); + + const nonce = randomBytes(24).toString('hex'); + const sockets = new Set(); + const server = createServer((request, response) => { + response.setHeader('Cache-Control', 'no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.setHeader('Allow', 'GET, HEAD'); + sendEmpty(response, 405); + return; + } + try { + const rawUrl = request.url ?? ''; + if (rawUrl.includes('\0') || rawUrl.includes('\\')) throw new Error('unsafe'); + const url = new URL(rawUrl, 'http://127.0.0.1'); + const prefix = `/${nonce}/`; + if (!url.pathname.startsWith(prefix)) throw new Error('outside'); + const rawPath = url.pathname.slice(prefix.length); + if (!rawPath || rawPath.endsWith('/')) throw new Error('directory'); + let decoded: string; + try { + decoded = decodeURIComponent(rawPath); + } catch { + throw new Error('encoding'); + } + if (!isSafeArtifactPath(decoded)) throw new Error('unsafe'); + const bytes = files.get(decoded); + if (!bytes) throw new Error('missing'); + response.statusCode = 200; + response.setHeader('Content-Type', MIME_TYPES[extname(decoded).toLowerCase()] ?? 'application/octet-stream'); + response.setHeader('Content-Length', String(bytes.length)); + response.end(request.method === 'HEAD' ? undefined : bytes); + } catch { + sendEmpty(response, 404); + } + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + try { + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolveListen(); + }); + }); + } catch (error) { + for (const socket of sockets) socket.destroy(); + throw error; + } + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Static artifact server did not bind TCP.'); + let closed: Promise | null = null; + return { + entryUrl: `http://127.0.0.1:${address.port}/${nonce}/index.html`, + close: () => { + closed ??= new Promise((resolveClose) => { + server.close(() => resolveClose()); + server.closeAllConnections?.(); + for (const socket of sockets) socket.destroy(); + setTimeout(resolveClose, 1_000).unref?.(); + }); + return closed; + }, + }; +} + +function isSafeArtifactPath(path: string): boolean { + if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0')) return false; + const parts = path.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..' || part.includes(':') || /[. ]$/.test(part))) return false; + return !parts.some((part) => /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)); +} + +function sendEmpty(response: ServerResponse, status: number): void { + if (response.headersSent) return; + response.statusCode = status; + response.setHeader('Content-Length', '0'); + response.end(); +} diff --git a/package.json b/package.json index 99a2936..33a0844 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "test:electron:windows": "node scripts/run-electron-vitest.mjs", "verify:electron:release": "node scripts/run-electron-vitest.mjs --verify-release-runtime", "verify:artifact:win": "node scripts/verify-packaged-electron-runtime.mjs", + "verify:publish-runtime": "node scripts/verify-publish-runtime.mjs", "test:e2e": "pnpm run build:vite && playwright test", "test:e2e:headed": "pnpm run build:vite && playwright test --headed", "smoke:opencode:real": "node scripts/opencode-real-runtime-smoke.mjs", @@ -96,6 +97,7 @@ "ms": "^2.1.3", "msgpackr": "2.0.5", "node-machine-id": "^1.1.12", + "npm": "11.6.2", "pdfjs-dist": "^5.7.284", "posthog-node": "^5.28.0", "qrcode.react": "4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fa4f45..04f6712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: node-machine-id: specifier: ^1.1.12 version: 1.1.12 + npm: + specifier: 11.6.2 + version: 11.6.2 pdfjs-dist: specifier: ^5.7.284 version: 5.7.284 @@ -3892,6 +3895,77 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + npm@11.6.2: + resolution: {integrity: sha512-7iKzNfy8lWYs3zq4oFPa8EXZz5xt9gQNKJZau3B1ErLBb6bF7sBJ00x09485DOvRT2l5Gerbl3VlZNT57MxJVA==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + bundledDependencies: + - '@isaacs/string-locale-compare' + - '@npmcli/arborist' + - '@npmcli/config' + - '@npmcli/fs' + - '@npmcli/map-workspaces' + - '@npmcli/package-json' + - '@npmcli/promise-spawn' + - '@npmcli/redact' + - '@npmcli/run-script' + - '@sigstore/tuf' + - abbrev + - archy + - cacache + - chalk + - ci-info + - cli-columns + - fastest-levenshtein + - fs-minipass + - glob + - graceful-fs + - hosted-git-info + - ini + - init-package-json + - is-cidr + - json-parse-even-better-errors + - libnpmaccess + - libnpmdiff + - libnpmexec + - libnpmfund + - libnpmorg + - libnpmpack + - libnpmpublish + - libnpmsearch + - libnpmteam + - libnpmversion + - make-fetch-happen + - minimatch + - minipass + - minipass-pipeline + - ms + - node-gyp + - nopt + - npm-audit-report + - npm-install-checks + - npm-package-arg + - npm-pick-manifest + - npm-profile + - npm-registry-fetch + - npm-user-validate + - p-map + - pacote + - parse-conflict-json + - proc-log + - qrcode-terminal + - read + - semver + - spdx-expression-parse + - ssri + - supports-color + - tar + - text-table + - tiny-relative-date + - treeverse + - validate-npm-package-name + - which + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -9081,6 +9155,8 @@ snapshots: normalize-url@6.1.0: {} + npm@11.6.2: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 26fdae1..4334173 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -1,5 +1,5 @@ const { execFileSync } = require('child_process'); -const { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } = require('fs'); +const { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } = require('fs'); const { join } = require('path'); const MEOWA_RELEASE_CREDENTIAL_FILE_NAME = 'meowa-game-assets-credential.json'; @@ -9,6 +9,7 @@ exports.default = async function afterPack(context) { console.log(`[after-pack] Target: ${platform}/${context.arch}`); writeMeowaReleaseCredential(context); + copyPublishRuntime(context); assertNoPersistedUserData(context.appOutDir); if (platform !== 'win32') { @@ -19,6 +20,18 @@ exports.default = async function afterPack(context) { patchNsisExtractionMacro(); }; +function copyPublishRuntime(context) { + const source = join(__dirname, '..', 'build', 'publish-runtime'); + const resourcesDir = context.packager.getResourcesDir(context.appOutDir); + const destination = join(resourcesDir, 'publish-runtime'); + if (!existsSync(join(source, 'node_modules', 'proc-log', 'package.json'))) { + throw new Error(`Prepared publish runtime is incomplete: ${source}`); + } + rmSync(destination, { recursive: true, force: true }); + cpSync(source, destination, { recursive: true }); + console.log('[after-pack] Copied self-contained npm publish runtime.'); +} + function writeMeowaReleaseCredential(context) { const apiKey = process.env.MEOWART_API_KEY?.trim(); if (!apiKey) { diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs new file mode 100644 index 0000000..1895132 --- /dev/null +++ b/scripts/before-pack.cjs @@ -0,0 +1,9 @@ +const { execFileSync } = require('node:child_process'); +const { join } = require('node:path'); + +exports.default = async function beforePack() { + execFileSync(process.execPath, [join(__dirname, 'prepare-publish-runtime.mjs')], { + cwd: join(__dirname, '..'), + stdio: 'inherit', + }); +}; diff --git a/scripts/prepare-publish-runtime.mjs b/scripts/prepare-publish-runtime.mjs new file mode 100644 index 0000000..067a54d --- /dev/null +++ b/scripts/prepare-publish-runtime.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +import { cpSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const expectedVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).dependencies?.npm; +const npmPackagePath = require.resolve('npm/package.json'); +const npmPackage = JSON.parse(readFileSync(npmPackagePath, 'utf8')); +const npmCli = join(dirname(npmPackagePath), 'bin', 'npm-cli.js'); +const destination = join(root, 'build', 'publish-runtime'); + +if (npmPackage.version !== expectedVersion) { + throw new Error(`Publish npm version mismatch: expected ${expectedVersion}, got ${npmPackage.version}`); +} +if (!npmPackage.bundleDependencies?.includes('proc-log')) { + throw new Error('Publish npm package does not declare its runtime dependencies as bundled dependencies'); +} + +const temporaryRoot = mkdtempSync(join(tmpdir(), 'makelore-publish-runtime-')); +try { + const packResult = spawnSync(process.execPath, [ + npmCli, + 'pack', + dirname(npmPackagePath), + '--ignore-scripts', + '--json', + '--pack-destination', + temporaryRoot, + ], { cwd: root, encoding: 'utf8', shell: false }); + if (packResult.status !== 0) { + throw new Error(`Unable to pack publish npm runtime: ${packResult.stderr || packResult.stdout}`); + } + const packMetadata = JSON.parse(packResult.stdout); + if (!Array.isArray(packMetadata) || packMetadata.length !== 1) { + throw new Error('npm pack returned unexpected metadata'); + } + const filename = packMetadata[0]?.filename; + if (typeof filename !== 'string' + || !/^[^/\\]+\.tgz$/.test(filename) + || resolve(temporaryRoot, filename) !== join(temporaryRoot, filename)) { + throw new Error(`npm pack returned an unsafe filename: ${JSON.stringify(filename)}`); + } + mkdirSync(join(temporaryRoot, 'runtime'), { recursive: true }); + await import('tar').then(({ x }) => x({ + file: join(temporaryRoot, filename), + cwd: join(temporaryRoot, 'runtime'), + strip: 1, + })); + + const stagedRuntime = join(temporaryRoot, 'runtime'); + for (const dependency of npmPackage.bundleDependencies) { + if (!existsSync(join(stagedRuntime, 'node_modules', ...dependency.split('/'), 'package.json'))) { + throw new Error(`Packed npm runtime is missing bundled dependency ${dependency}`); + } + } + + rmSync(destination, { recursive: true, force: true }); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(stagedRuntime, destination, { recursive: true }); +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} + +console.log(`Prepared self-contained npm ${npmPackage.version} runtime at ${destination}`); diff --git a/scripts/verify-packaged-electron-runtime.mjs b/scripts/verify-packaged-electron-runtime.mjs index d4b163d..e4afb71 100644 --- a/scripts/verify-packaged-electron-runtime.mjs +++ b/scripts/verify-packaged-electron-runtime.mjs @@ -141,6 +141,9 @@ const expectedCommit = option('--expected-commit', null); const expectedBuildId = option('--expected-build-id', null); const manifestPath = option('--manifest', null); const resourcesDir = join(dirname(appExecutable), 'resources'); +const npmRuntimeDir = join(resourcesDir, 'publish-runtime'); +const npmPackagePath = join(npmRuntimeDir, 'package.json'); +const npmCliPath = join(npmRuntimeDir, 'bin', 'npm-cli.js'); const appAsarPath = join(resourcesDir, 'app.asar'); const appAsarUnpackedPath = join(resourcesDir, 'app.asar.unpacked'); const opencodeRuntimeDir = join(resourcesDir, 'opencode-ai'); @@ -154,6 +157,8 @@ for (const filePath of [ appExecutable, installerPath, appAsarPath, + npmPackagePath, + npmCliPath, opencodePackagePath, opencodeExecutable, pythonExecutable, @@ -317,6 +322,13 @@ if (!isPathInside(appAsarPath, probe.playwrightPackageResolved)) { if (!isPathInside(appAsarPath, probe.playwrightCliResolved)) { throw new Error(`@playwright/mcp CLI was not resolved from app.asar: ${probe.playwrightCliResolved}`); } +const packagedNpm = JSON.parse(readFileSync(npmPackagePath, 'utf8')); +assertEqual(packagedNpm.version, packageJson.dependencies.npm, 'packaged npm version'); +const npmVersion = run(appExecutable, [npmCliPath, '--version'], { + cwd: dirname(appExecutable), + env: { ...installLocalProbeEnv, ELECTRON_RUN_AS_NODE: '1' }, +}); +assertEqual(npmVersion, packageJson.dependencies.npm, 'packaged npm CLI version'); run(appExecutable, [probe.playwrightCliResolved, '--help'], { cwd: dirname(appExecutable), env: { ...installLocalProbeEnv, ELECTRON_RUN_AS_NODE: '1' }, @@ -371,6 +383,7 @@ const evidence = { }, python: pythonProbe, uv: { executable: uvExecutable, version: uvVersion }, + npm: { packageJson: npmPackagePath, cli: npmCliPath, version: npmVersion }, }, nativeModules: { msgpackr: probe.msgpackResolved, diff --git a/scripts/verify-publish-runtime.mjs b/scripts/verify-publish-runtime.mjs new file mode 100644 index 0000000..fcfdd7a --- /dev/null +++ b/scripts/verify-publish-runtime.mjs @@ -0,0 +1,29 @@ +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const prepare = spawnSync(process.execPath, [join(root, 'scripts', 'prepare-publish-runtime.mjs')], { + cwd: root, + encoding: 'utf8', + shell: false, +}); +if (prepare.status !== 0) { + throw new Error(`Unable to stage fixed publish runtime: ${prepare.stderr || prepare.stdout}`); +} +const npmCli = join(root, 'build', 'publish-runtime', 'bin', 'npm-cli.js'); +const procLogPackage = join(root, 'build', 'publish-runtime', 'node_modules', 'proc-log', 'package.json'); +if (!existsSync(procLogPackage)) { + throw new Error(`Staged publish runtime is missing proc-log: ${procLogPackage}`); +} +const result = spawnSync(process.execPath, [npmCli, '--version'], { + cwd: root, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + shell: false, + encoding: 'utf8', +}); +if (result.status !== 0 || !/^11\.6\.2\s*$/.test(result.stdout)) { + throw new Error(`Fixed publish npm runtime unavailable: ${result.stderr || result.stdout}`); +} +console.log(`publish npm runtime OK: ${result.stdout.trim()}`); diff --git a/src/components/works/ProjectPublishAction.tsx b/src/components/works/ProjectPublishAction.tsx index 003c769..bd41833 100644 --- a/src/components/works/ProjectPublishAction.tsx +++ b/src/components/works/ProjectPublishAction.tsx @@ -57,7 +57,7 @@ function failureFromVersion(version: BuildVersion): WorksPublishFailure { } function buttonLabel(phase: PublishPhase): string { - if (phase === 'submitting') return '正在检查并提交…'; + if (phase === 'submitting') return '正在生成本次构建结果…'; if (phase === 'polling') return '正在等待云端检查…'; if (phase === 'succeeded') return '已提交,等待运营审核'; if (phase === 'uncertain') return '已提交,请稍后查看'; @@ -181,9 +181,9 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct className="max-w-md text-xs font-medium text-muted-foreground" > {phase === 'submitting' - ? 'Makelore 正在自动检查、打包并提交项目,不需要准备 ZIP。' + ? 'Makelore 正在本地安装依赖、生成本次构建结果并提交,不需要准备 ZIP。' : phase === 'polling' - ? '本地预览检查已完成,项目已上传,正在等待云端受控构建与平台校验。' + ? '本次构建结果已通过本地预览检查并上传,正在等待平台校验。' : '已提交,等待运营审核。审核通过后会直接发布。'}

) : null} diff --git a/src/lib/works-project-publish.ts b/src/lib/works-project-publish.ts index 7b08830..1abe3c2 100644 --- a/src/lib/works-project-publish.ts +++ b/src/lib/works-project-publish.ts @@ -6,11 +6,49 @@ export type WorksPublishFailure = { nextStep: string; }; +const LEGACY_BUILD_FAILURE: WorksPublishFailure = { + title: '旧版提交无法继续处理', + reason: '这是旧版客户端创建的云端构建任务,不能在新的本地构建链路中重试。', + nextStep: '升级 Makelore 并重新提交。', +}; + +const CLIENT_ARTIFACT_REJECTED: WorksPublishFailure = { + title: '本次构建结果未通过平台校验', + reason: '平台重新校验本次构建产物时发现摘要、文件或输出限制不匹配。', + nextStep: '升级 Makelore,并检查本次构建结果后重新提交。', +}; + +const PLATFORM_FINALIZATION_RETRYABLE: WorksPublishFailure = { + title: '平台校验任务暂未完成', + reason: '本次构建结果已上传,但平台校验或发布文件固化过程暂时中断。', + nextStep: '无需修改项目,请联系运营人员在“构建异常”中重新处理。', +}; + const PUBLISH_FAILURES: Record = { - PREVIEW_REQUIRED: { - title: '请先打开项目预览', - reason: '提交前检查需要当前项目正在 Makelore 内置浏览器中运行。', - nextStep: '先在内置浏览器中打开当前项目的本地预览,确认页面可用后再提交。', + LOCAL_BUILD_RUNTIME_UNAVAILABLE: { + title: '本地构建环境暂不可用', + reason: 'Makelore 安装包中的固定 Node/npm 构建组件无法启动。', + nextStep: '重新启动或升级 Makelore 后再提交。', + }, + LOCAL_BUILD_FAILED: { + title: '本次构建结果未生成', + reason: '项目依赖安装或 Vite 构建没有成功完成。', + nextStep: '请让开发助手修复依赖或构建错误后重新提交。', + }, + LOCAL_BUILD_TIMEOUT: { + title: '本次构建结果生成超时', + reason: '依赖安装或 Vite 构建超过了 Makelore 的时间限制。', + nextStep: '请精简依赖或构建步骤后重新提交。', + }, + LOCAL_BUILD_OUTPUT_MISSING: { + title: '本次构建结果不完整', + reason: 'Vite 没有生成可发布的 index.html,或输出不符合静态作品限制。', + nextStep: '请检查 Vite 输出、资源大小和文件类型后重新提交。', + }, + CLIENT_BUILD_PROTOCOL_REQUIRED: { + title: '需要升级 Makelore', + reason: '平台只接受新版 Makelore 生成的本地构建结果。', + nextStep: '升级 Makelore 并重新提交。', }, PUBLISH_PREFLIGHT_LOAD_FAILED: { title: '作品主页无法打开', @@ -133,9 +171,9 @@ const PUBLISH_FAILURES: Record = { nextStep: '请联系运营人员处理作品归属。', }, SOURCE_PACKAGE_REJECTED: { - title: '项目没有通过平台检查', - reason: '云端发现项目包不符合安全构建要求。', - nextStep: '请让开发助手检查依赖、入口文件和敏感文件后重试。', + title: '本次构建结果没有通过平台检查', + reason: '平台对上传字节和归档结构的独立校验没有通过。', + nextStep: '请检查源码与本次构建结果的文件结构后重新提交。', }, PUBLISH_FORBIDDEN: { title: '当前账号不能发布这个作品', @@ -157,101 +195,34 @@ const PUBLISH_FAILURES: Record = { reason: '作品仍可能在云端处理中。', nextStep: '稍后重新打开项目查看,不需要重复提交。', }, - DEPENDENCY_PREFETCH_FAILED: { - title: '暂时无法下载项目依赖', - reason: '云端没有成功准备 package-lock.json 中的依赖。', - nextStep: '请检查 package-lock.json 是否已提交,并确认依赖名称和版本有效后重试。', - }, - DEPENDENCY_PREFETCH_TIMEOUT: { - title: '下载项目依赖超时', - reason: '云端准备依赖的时间超过限制。', - nextStep: '稍后重试;如果持续失败,请让开发助手精简依赖。', - }, - BUILD_COMMAND_FAILED: { - title: '项目没有构建成功', - reason: '云端执行项目构建时发现代码或配置错误。', - nextStep: '请让开发助手运行 npm run build,修复错误后提交新版本。', - }, - BUILD_TIMEOUT: { - title: '项目构建超时', - reason: '云端构建时间超过平台限制。', - nextStep: '请让开发助手减少构建步骤或过大的依赖后重试。', - }, - BUILD_SANDBOX_UNAVAILABLE: { - title: '云端构建环境暂时不可用', - reason: '平台暂时无法启动安全构建环境。', - nextStep: '稍后重新提交,无需修改项目。', - }, - BUILD_PIPELINE_MISMATCH: { - title: '项目发布方式不匹配', - reason: '当前提交不是平台支持的 Makelore 静态项目格式。', - nextStep: '请从当前项目重新点击一键提交,不要手工修改发布包。', - }, - BUILD_PIPELINE_UNSUPPORTED: { - title: '这个项目暂不支持自动发布', - reason: '平台没有找到适合当前项目类型的安全构建方式。', - nextStep: '请联系运营人员确认项目类型。', - }, + DEPENDENCY_PREFETCH_FAILED: LEGACY_BUILD_FAILURE, + DEPENDENCY_PREFETCH_TIMEOUT: LEGACY_BUILD_FAILURE, + BUILD_COMMAND_FAILED: LEGACY_BUILD_FAILURE, + BUILD_TIMEOUT: LEGACY_BUILD_FAILURE, + BUILD_SANDBOX_UNAVAILABLE: LEGACY_BUILD_FAILURE, + BUILD_PIPELINE_MISMATCH: LEGACY_BUILD_FAILURE, + BUILD_PIPELINE_UNSUPPORTED: LEGACY_BUILD_FAILURE, + SOURCE_DIGEST_MISMATCH: CLIENT_ARTIFACT_REJECTED, + OUTPUT_MISSING: CLIENT_ARTIFACT_REJECTED, + OUTPUT_INVALID: CLIENT_ARTIFACT_REJECTED, + OUTPUT_LIMIT_EXCEEDED: CLIENT_ARTIFACT_REJECTED, + CLIENT_ARTIFACT_INVALID: CLIENT_ARTIFACT_REJECTED, + CLIENT_ARTIFACT_DIGEST_MISMATCH: CLIENT_ARTIFACT_REJECTED, VERSION_NOT_FOUND: { title: '平台没有找到本次提交', reason: '构建服务无法读取刚刚上传的版本。', nextStep: '请重新提交;如果持续失败,请联系运营人员。', }, - SOURCE_DIGEST_MISMATCH: { - title: '上传内容校验失败', - reason: '平台收到的项目内容与提交时不一致。', - nextStep: '重新提交一次;如果持续失败,请联系运营人员。', - }, - OUTPUT_MISSING: { - title: '没有生成可运行页面', - reason: '构建完成后没有找到发布所需的页面文件。', - nextStep: '请让开发助手检查 Vite 输出目录和 index.html。', - }, - OUTPUT_INVALID: { - title: '生成的页面无法发布', - reason: '构建输出缺少必要文件或包含不安全内容。', - nextStep: '请让开发助手检查 Vite 构建输出后重试。', - }, - OUTPUT_LIMIT_EXCEEDED: { - title: '生成的页面文件太大', - reason: '构建结果超过平台允许范围。', - nextStep: '请让开发助手压缩素材、拆分资源或删除无用输出。', - }, RELEASE_STORE_FAILED: { - title: '平台暂时无法保存发布文件', - reason: '作品已经构建,但发布存储当前不可用。', - nextStep: '稍后重新提交,无需修改项目。', - }, - BUILD_STALE: { - title: '构建任务已中断', - reason: '云端构建任务长时间没有继续运行。', - nextStep: '重新提交一次;如果持续发生,请联系运营人员。', - }, - BROWSER_SMOKE_FAILED: { - title: '自动打开作品时发现问题', - reason: '云端浏览器检测到了白屏、运行错误或资源加载失败。', - nextStep: '请让开发助手运行 npm run build,并在浏览器中检查构建结果后重试。', - }, - BROWSER_SMOKE_TIMEOUT: { - title: '自动打开作品超时', - reason: '作品在规定时间内没有完成加载。', - nextStep: '请让开发助手检查首屏资源大小和启动逻辑,优化后重新提交。', - }, - BROWSER_SMOKE_UNAVAILABLE: { - title: '自动验收环境暂时不可用', - reason: '平台暂时无法启动用于检查作品的安全浏览器。', - nextStep: '稍后重新提交,无需修改项目。', - }, - BUILD_CANCELLED: { - title: '构建已取消', - reason: '平台没有继续处理这个版本。', - nextStep: '确认没有其他版本正在处理后重新提交。', - }, - BUILD_FAILED: { - title: '项目没有构建成功', - reason: '云端未能生成可审核的作品。', - nextStep: '请让开发助手先确认 npm run build 成功,再提交新版本。', + ...PLATFORM_FINALIZATION_RETRYABLE, }, + ARTIFACT_STORAGE_UNAVAILABLE: PLATFORM_FINALIZATION_RETRYABLE, + BUILD_STALE: PLATFORM_FINALIZATION_RETRYABLE, + BROWSER_SMOKE_FAILED: LEGACY_BUILD_FAILURE, + BROWSER_SMOKE_TIMEOUT: LEGACY_BUILD_FAILURE, + BROWSER_SMOKE_UNAVAILABLE: LEGACY_BUILD_FAILURE, + BUILD_CANCELLED: LEGACY_BUILD_FAILURE, + BUILD_FAILED: LEGACY_BUILD_FAILURE, }; export function describeWorksPublishFailure( diff --git a/tests/e2e/local-preview-preflight.spec.ts b/tests/e2e/local-preview-preflight.spec.ts index d832de0..cc4eab3 100644 --- a/tests/e2e/local-preview-preflight.spec.ts +++ b/tests/e2e/local-preview-preflight.spec.ts @@ -1,57 +1,28 @@ -import { createServer } from 'node:http'; import { test, expect } from './fixtures/electron'; test('the production local preview preflight isolates requests and cleans up its temporary renderers', async ({ electronApp }) => { - let externalRequests = 0; - let externalPageLoads = 0; - const externalServer = createServer((_request, response) => { - externalRequests += 1; - response.writeHead(200, { 'Content-Type': 'application/javascript' }); - response.end('globalThis.externalLoaded = true;'); - }); - await new Promise((resolve, reject) => { - externalServer.once('error', reject); - externalServer.listen(0, '127.0.0.1', () => resolve()); - }); - const externalAddress = externalServer.address(); - if (!externalAddress || typeof externalAddress === 'string') throw new Error('Expected an ephemeral TCP port'); - - const server = createServer((request, response) => { - response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - if (request.url === '/external') externalPageLoads += 1; - const externalScript = request.url === '/external' && externalPageLoads > 1 - ? `` - : ''; - response.end(`
Playable
${externalScript}`); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolve()); - }); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Expected an ephemeral TCP port'); - - try { - const initialWebContentsCount = await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length); - const runPreflight = async (url: string) => await electronApp.evaluate(async (_electron, targetUrl) => { + const initialWebContentsCount = await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length); + const runPreflight = async (scenario: 'success' | 'external') => await electronApp.evaluate(async (_electron, value) => { const mainGlobal = globalThis as typeof globalThis & { - __niancodeRunLocalPreviewPreflightE2E?: (value: string) => Promise<{ ok: true }>; + __niancodeRunLocalPreviewPreflightE2E?: (scenario: 'success' | 'external') => Promise<{ + ok: boolean; externalRequests: number; code?: string; + }>; }; if (!mainGlobal.__niancodeRunLocalPreviewPreflightE2E) throw new Error('E2E preflight seam is unavailable'); - return await mainGlobal.__niancodeRunLocalPreviewPreflightE2E(targetUrl); - }, url); + return await mainGlobal.__niancodeRunLocalPreviewPreflightE2E(value); + }, scenario); - await expect(runPreflight(`http://127.0.0.1:${address.port}/external`)).rejects.toThrow(); - expect(externalRequests).toBe(0); + const blocked = await runPreflight('external'); + expect(blocked).toEqual({ + ok: false, + externalRequests: 0, + code: 'PUBLISH_PREFLIGHT_RUNTIME_ERROR', + }); - const result = await runPreflight(`http://127.0.0.1:${address.port}/`); + const result = await runPreflight('success'); - expect(result).toEqual({ ok: true }); - await expect.poll( - async () => await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length), - ).toBe(initialWebContentsCount); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - await new Promise((resolve) => externalServer.close(() => resolve())); - } + expect(result).toEqual({ ok: true, externalRequests: 0 }); + await expect.poll( + async () => await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length), + ).toBe(initialWebContentsCount); }); diff --git a/tests/unit/agent-browser-core.test.ts b/tests/unit/agent-browser-core.test.ts index 56e325c..65180b3 100644 --- a/tests/unit/agent-browser-core.test.ts +++ b/tests/unit/agent-browser-core.test.ts @@ -16,6 +16,7 @@ import { agentBrowserPartition, } from '@electron/agent-browser/module'; import { AgentBrowserPayloadStore } from '@electron/agent-browser/payload-store'; +import { createStaticArtifactSnapshot } from '@electron/services/static-release-server'; class FakeDebugger implements AgentBrowserDebuggerPort { readonly events = new EventEmitter(); @@ -252,6 +253,32 @@ async function openBrowser(adapter = new FakeAdapter()) { } describe('AgentBrowserModule', () => { + it('preflights a Main-owned static artifact without requiring or mutating a browser record', async () => { + const artifact = createStaticArtifactSnapshot([{ path: 'index.html', bytes: Buffer.from('
ok
') }]); + const adapter = new FakeAdapter(); + const module = new AgentBrowserModule(adapter); + try { + await expect(module.preflightStaticArtifact(artifact)).resolves.toEqual({ ok: true }); + expect(await module.getSnapshot()).toMatchObject({ state: 'closed', browserId: null }); + expect(adapter.views.map((view) => view.bounds)).toEqual([ + { x: 0, y: 0, width: 1280, height: 720 }, + { x: 0, y: 0, width: 390, height: 844 }, + ]); + expect(adapter.destroyed).toBe(2); + expect(adapter.resetPartitions).toEqual(adapter.partitions); + } finally { + await module.dispose(); + } + }); + + it('reports forged artifact snapshots as infrastructure unavailable', async () => { + const module = new AgentBrowserModule(new FakeAdapter()); + await expect(module.preflightStaticArtifact({} as never)).rejects.toMatchObject({ + code: 'PUBLISH_PREFLIGHT_UNAVAILABLE', + message: '暂时无法启动作品检查,请稍后重试。', + }); + }); + it('preflights desktop and mobile viewports in temporary non-persistent profiles', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { diff --git a/tests/unit/project-publish-action.test.tsx b/tests/unit/project-publish-action.test.tsx index 4393189..e3e6cd5 100644 --- a/tests/unit/project-publish-action.test.tsx +++ b/tests/unit/project-publish-action.test.tsx @@ -79,11 +79,11 @@ describe('ProjectPublishAction', () => { render(); fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); - expect(screen.getByRole('button', { name: '正在检查并提交…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '正在生成本次构建结果…' })).toBeDisabled(); await flushSubmission(); expect(screen.getByTestId('project-publish-status')).toHaveTextContent( - '本地预览检查已完成,项目已上传,正在等待云端受控构建与平台校验。', + '本次构建结果已通过本地预览检查并上传,正在等待平台校验。', ); expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({ @@ -120,8 +120,8 @@ describe('ProjectPublishAction', () => { await advancePoll(); const failure = screen.getByTestId('project-publish-failure'); - expect(failure).toHaveTextContent('自动打开作品时发现问题'); - expect(failure).toHaveTextContent('下一步:请让开发助手运行 npm run build'); + expect(failure).toHaveTextContent('旧版提交无法继续处理'); + expect(failure).toHaveTextContent('下一步:升级 Makelore 并重新提交'); expect(failure).not.toHaveTextContent('BROWSER_SMOKE_FAILED'); expect(failure).not.toHaveTextContent('Traceback'); expect(failure).not.toHaveTextContent('/srv/private'); diff --git a/tests/unit/project-release-builder.test.ts b/tests/unit/project-release-builder.test.ts new file mode 100644 index 0000000..bc85b2f --- /dev/null +++ b/tests/unit/project-release-builder.test.ts @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createProjectConfig } from '../../shared/project-config'; + +const runElectronNodeMock = vi.hoisted(() => vi.fn()); +vi.mock('@electron/services/publish-runtime', () => ({ + resolvePublishRuntime: async () => ({ nodeExecutable: 'electron', npmCli: 'npm-cli.js', nodeVersion: '22.0.0', npmVersion: '11.6.2' }), + runElectronNode: runElectronNodeMock, + PublishRuntimeError: class extends Error {}, +})); +import { prepareProjectRelease } from '@electron/services/project-release-builder'; +import { startStaticReleaseServer, staticArtifactSnapshotFiles } from '@electron/services/static-release-server'; + +describe('project release builder', () => { + let root: string | null = null; + afterEach(async () => { if (root) await rm(root, { recursive: true, force: true }); root = null; vi.clearAllMocks(); }); + + it('builds from the exact source archive and returns the protocol-v1 canonical contract', async () => { + root = await mkdtemp(join(tmpdir(), 'release-builder-test-')); + await mkdir(join(root, '.niancode'), { recursive: true }); + await writeFile(join(root, '.niancode', 'project.json'), JSON.stringify(createProjectConfig(new Date().toISOString(), 'mini_game'))); + await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'demo', packageManager: 'npm@11.6.2', devDependencies: { vite: '7.3.1' } })); + await writeFile(join(root, 'package-lock.json'), JSON.stringify({ name: 'demo', lockfileVersion: 3, requires: true, packages: {} })); + await writeFile(join(root, 'index.html'), '
source
'); + runElectronNodeMock.mockImplementation(async (input: { args: string[]; cwd: string }) => { + if (input.args.includes('ci')) { + await mkdir(join(input.cwd, 'node_modules', 'vite', 'bin'), { recursive: true }); + await writeFile(join(input.cwd, 'node_modules', 'vite', 'package.json'), JSON.stringify({ version: '7.3.1' })); + } else if (input.args.includes('build')) { + const output = input.args[input.args.indexOf('--outDir') + 1]; + await mkdir(join(output, 'assets'), { recursive: true }); + await writeFile(join(output, 'index.html'), '
built
'); + await writeFile(join(output, 'assets', '中.js'), 'ok'); + } + return { stdout: '', stderr: '' }; + }); + const release = await prepareProjectRelease({ projectPath: root, clientVersion: '2.0.0' }); + try { + expect(release.contract).toMatchObject({ + schema_version: 1, entry_path: 'index.html', security_profile: 'works-square-static-sandbox-v1', + toolchain: { client: 'makelore', client_version: '2.0.0', node: '22.0.0', npm: '11.6.2', vite: '7.3.1' }, + }); + expect(release.contract.files.map((file) => file.path)).toEqual(['assets/中.js', 'index.html']); + const canonical = JSON.stringify(release.contract.files.map(({ path, sha256, size }) => ({ path, sha256, size }))); + expect(release.contract.artifact_digest).toBe(createHash('sha256').update(canonical).digest('hex')); + expect(release.contract.source_digest).toBe(createHash('sha256').update(await readFile(release.sourceArchive.path)).digest('hex')); + expect(release.contract.built_archive_digest).toBe(createHash('sha256').update(release.builtArchive.bytes).digest('hex')); + expect(runElectronNodeMock.mock.calls[0][0].args).toEqual([ + 'npm-cli.js', 'ci', '--ignore-scripts', '--no-audit', '--no-fund', + '--userconfig', expect.stringMatching(/empty-npmrc$/), '--cache', expect.stringMatching(/npm-cache$/), + ]); + await rm(release.distRoot, { recursive: true, force: true }); + expect(staticArtifactSnapshotFiles(release.staticArtifact).map(({ path, bytes }) => ({ + path, + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }))).toEqual(release.contract.files); + const server = await startStaticReleaseServer(release.staticArtifact); + try { + expect(await (await fetch(server.entryUrl)).text()).toBe('
built
'); + expect(await (await fetch(new URL('assets/%E4%B8%AD.js', server.entryUrl))).text()).toBe('ok'); + } finally { + await server.close(); + } + } finally { await release.dispose(); } + await expect(readFile(release.sourceArchive.path)).rejects.toThrow(); + }); +}); diff --git a/tests/unit/static-release-server.test.ts b/tests/unit/static-release-server.test.ts new file mode 100644 index 0000000..37a249a --- /dev/null +++ b/tests/unit/static-release-server.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { createStaticArtifactSnapshot, startStaticReleaseServer } from '@electron/services/static-release-server'; + +function fixture() { + const source = [ + { path: 'index.html', bytes: Buffer.from('
ok
') }, + { path: 'assets/app.js', bytes: Buffer.from('globalThis.ok=true') }, + ]; + return { snapshot: createStaticArtifactSnapshot(source), source }; +} + +describe('startStaticReleaseServer', () => { + it('serves only exact regular files under an unguessable root', async () => { + const { snapshot, source } = fixture(); + source[0].bytes.fill(0); + const server = await startStaticReleaseServer(snapshot); + try { + const entry = new URL(server.entryUrl); + expect(entry.hostname).toBe('127.0.0.1'); + expect(entry.pathname).toMatch(/^\/[a-f0-9]{48}\/index\.html$/); + const response = await fetch(server.entryUrl); + expect(await response.text()).toBe('
ok
'); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect((await fetch(new URL('missing', server.entryUrl))).status).toBe(404); + expect((await fetch(new URL('.', server.entryUrl))).status).toBe(404); + expect((await fetch(server.entryUrl, { method: 'POST' })).status).toBe(405); + } finally { + await server.close(); + } + await expect(fetch(server.entryUrl)).rejects.toThrow(); + }); + + it('rejects traversal and backslashes while serving GET/HEAD from memory', async () => { + const server = await startStaticReleaseServer(fixture().snapshot); + try { + const entry = new URL(server.entryUrl); + const prefix = entry.pathname.slice(0, entry.pathname.lastIndexOf('/') + 1); + for (const path of [`${prefix}%2e%2e%2findex.html`, `${prefix}assets%5capp.js`]) { + const response = await fetch(`${entry.origin}${path}`); + expect(response.status).toBe(404); + } + const scriptUrl = `${entry.origin}${prefix}assets/app.js`; + expect((await fetch(scriptUrl)).headers.get('content-type')).toBe('text/javascript; charset=utf-8'); + const head = await fetch(scriptUrl, { method: 'HEAD' }); + expect(head.status).toBe(200); + expect(head.headers.get('content-length')).toBe(String(Buffer.byteLength('globalThis.ok=true'))); + expect(await head.text()).toBe(''); + } finally { + await server.close(); + } + }); + + it('rejects forged, duplicate and unsafe snapshots', async () => { + await expect(startStaticReleaseServer({} as never)).rejects.toThrow('Main-owned'); + for (const files of [ + [{ path: '../index.html', bytes: Buffer.from('bad') }], + [{ path: 'index.html', bytes: Buffer.from('a') }, { path: 'INDEX.HTML', bytes: Buffer.from('b') }], + ]) { + expect(() => createStaticArtifactSnapshot(files)).toThrow(); + } + }); + + it.each([ + ['source.map', 'application/json; charset=utf-8'], + ['sound.ogg', 'audio/ogg'], + ['notes.txt', 'text/plain; charset=utf-8'], + ])('serves %s with the production media type', async (path, expectedType) => { + const snapshot = createStaticArtifactSnapshot([ + { path: 'index.html', bytes: Buffer.from('
ok
') }, + { path, bytes: Buffer.from('data') }, + ]); + const server = await startStaticReleaseServer(snapshot); + try { + const entry = new URL(server.entryUrl); + const prefix = entry.pathname.slice(0, entry.pathname.lastIndexOf('/') + 1); + const response = await fetch(`${entry.origin}${prefix}${path}`); + expect(response.headers.get('content-type')).toBe(expectedType); + } finally { + await server.close(); + } + }); +}); diff --git a/tests/unit/works-project-publish.test.ts b/tests/unit/works-project-publish.test.ts index b2d97de..a23cee9 100644 --- a/tests/unit/works-project-publish.test.ts +++ b/tests/unit/works-project-publish.test.ts @@ -6,18 +6,19 @@ import { describe('works project publish guidance', () => { it.each([ - ['PREVIEW_REQUIRED', '请先打开项目预览', '内置浏览器'], ['PUBLISH_PREFLIGHT_RUNTIME_ERROR', '作品打开时发生错误', '项目预览'], ['PUBLISH_PREFLIGHT_BLANK', '作品打开后没有内容', '移动端布局'], ['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'], ['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建小游戏或小程序项目'], - ['DEPENDENCY_PREFETCH_FAILED', '暂时无法下载项目依赖', 'package-lock.json'], - ['OUTPUT_MISSING', '没有生成可运行页面', 'index.html'], - ['RELEASE_STORE_FAILED', '平台暂时无法保存发布文件', '无需修改项目'], - ['BUILD_PIPELINE_UNSUPPORTED', '这个项目暂不支持自动发布', '联系运营人员'], - ['BROWSER_SMOKE_FAILED', '自动打开作品时发现问题', 'npm run build'], - ['BROWSER_SMOKE_TIMEOUT', '自动打开作品超时', '首屏资源'], - ['BROWSER_SMOKE_UNAVAILABLE', '自动验收环境暂时不可用', '无需修改项目'], + ['DEPENDENCY_PREFETCH_FAILED', '旧版提交无法继续处理', '升级 Makelore'], + ['OUTPUT_MISSING', '本次构建结果未通过平台校验', '本次构建结果'], + ['RELEASE_STORE_FAILED', '平台校验任务暂未完成', '构建异常'], + ['ARTIFACT_STORAGE_UNAVAILABLE', '平台校验任务暂未完成', '构建异常'], + ['BUILD_STALE', '平台校验任务暂未完成', '构建异常'], + ['BUILD_PIPELINE_UNSUPPORTED', '旧版提交无法继续处理', '升级 Makelore'], + ['BROWSER_SMOKE_FAILED', '旧版提交无法继续处理', '升级 Makelore'], + ['BROWSER_SMOKE_TIMEOUT', '旧版提交无法继续处理', '升级 Makelore'], + ['BROWSER_SMOKE_UNAVAILABLE', '旧版提交无法继续处理', '升级 Makelore'], ])('maps %s to actionable Chinese guidance', (code, title, nextStep) => { const failure = describeWorksPublishFailure(code); diff --git a/tests/unit/works-routes.test.ts b/tests/unit/works-routes.test.ts index da8682f..e41469f 100644 --- a/tests/unit/works-routes.test.ts +++ b/tests/unit/works-routes.test.ts @@ -13,10 +13,31 @@ import { import { createProjectConfig } from '../../shared/project-config'; const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn()); +const prepareProjectReleaseMock = vi.hoisted(() => vi.fn()); vi.mock('@electron/services/works-square-session', () => ({ getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args), })); +vi.mock('@electron/services/project-release-builder', async (importOriginal) => ({ + ...await importOriginal(), + prepareProjectRelease: prepareProjectReleaseMock, +})); + +function preparedRelease(projectPath: string) { + const bytes = Buffer.from('zip'); + return { + sourceArchive: { path: join(projectPath, 'private-source.zip'), name: 'project.zip', bytes, summary: { + archivePath: join(projectPath, 'private-source.zip'), archiveName: 'project.zip', sha256: 'a'.repeat(64), fileCount: 5, + sourceBytes: 10, archiveBytes: 3, excludedCount: 0, excludedPaths: [], + manifest: { schema_version: 1, project_type: 'mini_game', kind: 'web', runtime: 'static', build: { preset: 'vite', package_manager: 'npm', entry: 'index.html' } }, + } }, + builtArchive: { path: join(projectPath, 'private-built.zip'), name: 'built-project.zip', bytes: Buffer.from('built') }, + distRoot: join(projectPath, 'private-dist'), + staticArtifact: { files: [{ path: 'index.html', bytes: Buffer.from('built') }] }, + contract: { schema_version: 1, entry_path: 'index.html', source_digest: 'a'.repeat(64), built_archive_digest: 'b'.repeat(64), artifact_digest: 'c'.repeat(64), file_count: 1, total_bytes: 5, files: [{ path: 'index.html', size: 5, sha256: 'd'.repeat(64) }], security_profile: 'works-square-static-sandbox-v1', toolchain: { client: 'makelore', client_version: '2.0.0', node: '22', npm: '11.6.2', vite: '7.3.1' } }, + dispose: vi.fn(async () => undefined), + }; +} function createResponse() { @@ -89,6 +110,8 @@ describe('works square host api routes', () => { beforeEach(() => { vi.restoreAllMocks(); + prepareProjectReleaseMock.mockReset(); + prepareProjectReleaseMock.mockImplementation(async ({ projectPath }: { projectPath: string }) => preparedRelease(projectPath)); rotateRendererCapability(); getValidWorksSquareAccessTokenMock.mockReset(); getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token'); @@ -1083,7 +1106,9 @@ describe('works square host api routes', () => { vi.stubGlobal('fetch', fetchMock); const response = createResponse(); const recordSubmitted = vi.fn(async () => undefined); - const preflightCurrentProject = vi.fn(async () => ({ ok: true as const })); + const preflightStaticArtifact = vi.fn(async () => ({ ok: true as const })); + const release = preparedRelease(tempDir); + prepareProjectReleaseMock.mockResolvedValueOnce(release); const handled = await handleWorksRoutes( createRendererRequest('POST', { projectId: project.id, project: projectMetadata }), @@ -1091,7 +1116,7 @@ describe('works square host api routes', () => { new URL('http://127.0.0.1/api/works/projects/publish-source'), { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - agentBrowser: { preflightCurrentProject }, + agentBrowser: { preflightStaticArtifact }, worksSubmissionBinding: { recordSubmitted }, } as never, ); @@ -1149,6 +1174,14 @@ describe('works square host api routes', () => { expect(archive).toBeInstanceOf(File); expect((archive as File).name).toBe('project.zip'); expect((archive as File).size).toBeGreaterThan(0); + const builtArchive = form.get('built_archive'); + expect(builtArchive).toBeInstanceOf(File); + expect((builtArchive as File).name).toBe('built-project.zip'); + expect(JSON.parse(String(form.get('artifact_contract')))).toMatchObject({ + schema_version: 1, + source_digest: 'a'.repeat(64), + built_archive_digest: 'b'.repeat(64), + }); expect(recordSubmitted).toHaveBeenCalledWith(project.id, { appId: 'space-cleaner', versionId: 'version-1', @@ -1156,13 +1189,18 @@ describe('works square host api routes', () => { reviewStatus: 'building', zipSha256: expect.stringMatching(/^[a-f0-9]{64}$/), }); - expect(preflightCurrentProject).toHaveBeenCalledWith(tempDir); + expect(preflightStaticArtifact).toHaveBeenCalledWith(release.staticArtifact); + expect(response.json()).not.toHaveProperty('contract'); + expect(JSON.stringify(response.json())).not.toContain('private-dist'); + expect(JSON.stringify(response.json())).not.toContain('private-source.zip'); }); it('stops before creating or uploading when the local browser preflight fails', async () => { tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-preflight-failure-')); await writePublishableProject(tempDir); - const preflightCurrentProject = vi.fn(async () => { + const release = preparedRelease(tempDir); + prepareProjectReleaseMock.mockResolvedValueOnce(release); + const preflightStaticArtifact = vi.fn(async () => { throw Object.assign(new Error(`${tempDir} token=secret`), { code: 'PUBLISH_PREFLIGHT_BLANK', }); @@ -1186,7 +1224,7 @@ describe('works square host api routes', () => { opencodeProjectStore: { listProjects: vi.fn(async () => [{ id: 'project-1', path: tempDir, name: 'space-cleaner' }]), }, - agentBrowser: { preflightCurrentProject }, + agentBrowser: { preflightStaticArtifact }, } as never, ); @@ -1198,10 +1236,31 @@ describe('works square host api routes', () => { error: '作品打开后没有可见内容。', }); expect(fetchMock).not.toHaveBeenCalled(); + expect(release.dispose).toHaveBeenCalledOnce(); expect(JSON.stringify(response.json())).not.toContain(tempDir); expect(JSON.stringify(response.json())).not.toContain('token=secret'); }); + it('stops before preflight or upload and disposes when the local build fails', async () => { + prepareProjectReleaseMock.mockRejectedValueOnce(Object.assign(new Error('private path token=secret'), { + name: 'ProjectReleaseBuildError', + code: 'LOCAL_BUILD_FAILED', + })); + const preflightStaticArtifact = vi.fn(); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + await handleWorksRoutes( + createRendererRequest('POST', { projectId: 'project-1', project: { app_id: 'space-cleaner', title: 'Space', summary: 'Clean' } }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects: vi.fn(async () => [{ id: 'project-1', path: 'private-project' }]) }, agentBrowser: { preflightStaticArtifact } } as never, + ); + expect(preflightStaticArtifact).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.stringify(response.json())).not.toContain('token=secret'); + }); + it('keeps a confirmed submission successful when the local preview mapping cannot be saved', async () => { tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-mapping-failure-')); await writePublishableProject(tempDir); @@ -1228,7 +1287,7 @@ describe('works square host api routes', () => { new URL('http://127.0.0.1/api/works/projects/publish-source'), { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) }, + agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) }, worksSubmissionBinding: { recordSubmitted: vi.fn(async () => { throw new Error('disk unavailable'); }), }, @@ -1275,7 +1334,7 @@ describe('works square host api routes', () => { new URL('http://127.0.0.1/api/works/projects/publish-source'), { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) }, + agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) }, } as never, ); @@ -1306,7 +1365,7 @@ describe('works square host api routes', () => { vi.stubGlobal('fetch', fetchMock); const ctx = { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) }, + agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) }, } as never; for (let index = 0; index < 2; index += 1) { @@ -1363,7 +1422,7 @@ describe('works square host api routes', () => { new URL('http://127.0.0.1/api/works/projects/publish-source'), { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) }, + agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) }, } as never, ); @@ -1402,7 +1461,7 @@ describe('works square host api routes', () => { new URL('http://127.0.0.1/api/works/projects/publish-source'), { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) }, + agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) }, } as never, ); @@ -1418,6 +1477,22 @@ describe('works square host api routes', () => { expect(cancelSpy).toHaveBeenCalledOnce(); }); + it('projects the nested FastAPI client protocol error without exposing its detail', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'makelore-protocol-required-')); + await writePublishableProject(tempDir); + const upstream = new Response(JSON.stringify({ detail: { code: 'CLIENT_BUILD_PROTOCOL_REQUIRED', message: 'private token=secret' } }), { status: 422 }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(upstream)); + const response = createResponse(); + await handleWorksRoutes( + createRendererRequest('POST', { projectId: 'project-1', project: { app_id: 'space-cleaner', title: 'Space', summary: 'Clean' } }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects: vi.fn(async () => [{ id: 'project-1', path: tempDir }]) }, agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) } } as never, + ); + expect(response.json()).toEqual({ success: false, status: 422, code: 'CLIENT_BUILD_PROTOCOL_REQUIRED', error: '请升级 Makelore 并重新提交。' }); + expect(JSON.stringify(response.json())).not.toContain('token=secret'); + }); + it('fails safely before packaging when the Main session is unavailable', async () => { getValidWorksSquareAccessTokenMock.mockResolvedValueOnce(null); const fetchMock = vi.fn();