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();