diff --git a/README.md b/README.md index f9babdf..3655835 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传。Canvas 侧栏在“新建设计项目”上方提供“获取灵感”,进入服务端驱动的提示词博物馆。 -- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 +- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 - `Makelore Learning|AI 学习`:浏览和下载 Works 课程,也可以从需求、材料与多媒体选项发起后台单课生成;下载后的 frozen 课程包由内置 OpenMAIC production Stage 播放。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入浏览器授权,授权成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 @@ -16,7 +16,9 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 -- AI 编程运行时:Electron Main 只启动安装目录 `resources/opencode-ai/bin/` 中固定版本的 OpenCode;Renderer 不直接启动或调用运行时。已有登录态、已初始化项目和可用 Provider 时,Main 会在应用启动后后台预热运行时;首次设置、条件不完整或预热失败时仍由 Chat 按需启动。首选回环端口被不健康进程占用时,Main 会改用系统分配的临时端口,并把实际 URL 贯穿到所有运行时请求。 +- AI 编程运行时:Electron Main 只启动安装目录 `resources/opencode-ai/bin/` 中固定版本的 OpenCode;Renderer 不直接启动或调用运行时。OpenCode 不参与首屏启动,只在进入 Code 或发起 Code 操作时按需启动;离开 Code 且无活动任务 60 秒后由 Main 关闭 runtime、SSE 与项目监听。首选回环端口被不健康进程占用时,Main 会改用系统分配的临时端口,并把实际 URL 贯穿到所有运行时请求。 +- 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 +- 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 @@ -28,7 +30,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 学习:默认主区直接展示课程广场,生成课程从全局左栏打开宽工作台;无材料请求使用 JSON,有材料请求使用严格有界 multipart(最多 5 个、单个 50 MiB、总计 150 MiB,需求最多 4,000 字)。大课在课程广场保持一张课程卡,进入后按有序模块切换同一 production Stage。课程包最大 512 MiB,先校验大小与 SHA-256 再原子安装;课程媒体只允许与 MIME 匹配的被动图片、音频、视频和字体类型,同源 HTML/SVG/XML/脚本/PDF 等在安装与服务阶段都被拒绝。本地课程按 Main 确认的账号隔离,Agent、ASR、PBL 与主观题评分通过 Main 白名单桥接并在离线或无权益时明确提示。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 会话输入框,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 -- 字体系统:Renderer UI 内嵌 Inter Variable 与 Source Han Sans SC,按字符范围统一中英文;代码、路径和日志使用独立等宽字体。 +- 字体系统:Renderer UI 内嵌 Inter Variable 与经过字符子集化的 Source Han Sans SC WOFF2,按字符范围统一中英文并保留系统中文字体 fallback;代码、路径和日志使用独立等宽字体。 - 界面语言:仅保留中文;系统语言和历史设置中的其他语言会自动归一为中文。 - 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`。 @@ -56,6 +58,7 @@ pnpm run typecheck pnpm run lint:check pnpm test pnpm run build:vite +pnpm run perf:budget ``` Electron E2E: @@ -88,11 +91,11 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode ## 架构约束 -- Renderer 的后端调用统一经过 `src/lib/host-api.ts` 或 `src/lib/api-client.ts`。 +- Renderer 的后端调用统一经过 `src/lib/host-api.ts` 或 `src/lib/api-client.ts`;请求先经 Main-owned IPC,再由兼容 Host API 路由处理。只有真正需要 URL 的资源和流会把 loopback 地址暴露给 Renderer。 - Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。 -- Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成。 -- Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在系统提供受保护凭据存储时加密落盘;Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 -- AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 Vite config/plugins 会以当前桌面用户权限执行,因此该链路只适用于用户信任的本地项目,不是 sandbox。 +- Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成;所有 stream、watcher、poller、loopback server 与子进程必须登记到模块活动和任务租约,不允许页面自行创建无托管后台任务。 +- Works Square 登录态按真实键盘、鼠标或触摸活动滑动续期;持续使用无需反复授权,连续 7 天未使用才清除会话并要求重新登录。刷新凭据只由 Electron Main 持有,并在正式安装包中通过系统受保护凭据存储加密落盘;未打包开发版只在内存持有凭据,Main 重启后需要重新登录,避免未签名 Electron 调试进程触发 macOS 钥匙串。Renderer 仅保存短效公开会话状态(旧版升级迁移时仅暂存既有刷新凭据,Main 成功接管后立即删除)。 +- AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识、非敏感作品资料和有界封面 DTO;Main 持有源码快照、本地 npm/Vite 构建、精确产物预检、双归档、Works Token、版本生成、幂等重试和安全状态投影。发布构建同时提供 Main-owned `ReleaseJob` 的 start/progress/status/cancel 契约,同一项目串行执行并支持取消;异步 Job 的扫描、依赖安装、构建和双归档均在独立 `utilityProcess` 中以流式文件处理,Main 只接收进度、摘要和契约,旧的同步提交接口继续兼容已有客户端。首次项目 create 使用 `/api/projects/with-cover` multipart 原子写入资料与封面;已有项目只提交版本,状态竞态会固定失败并要求重新确认,不执行无条件 metadata PATCH 或封面替换。项目的 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。 @@ -102,9 +105,9 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode ### 共享开发浏览器 -- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。 +- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和按需 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。 - 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 -- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板收起或被弹窗遮挡时隐藏原生页面并暂停 Agent 调试;该能力独立于发布和部署。 +- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板关闭时销毁 `WebContentsView`、detach debugger 并释放页面;诊断域只在用户打开诊断视图时连接,面板重新打开时按 URL 和轻量历史元数据恢复。该能力独立于发布和部署。 - 一键提交时,Makelore 会从待上传构建归档的同一组 Main-owned 内存字节启动临时回环站点,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理,也不要求用户预先打开开发预览。 - 客户端复用 Electron 内置 Chromium,不安装 Playwright 或额外浏览器。预检只改善提交前反馈,可被非官方客户端绕过,也不会上传“已通过”凭据;平台仍把源码、构建归档和清单视为不可信输入,逐字节重算并在人工审核后发布。安装包携带固定 npm 运行时,项目依赖和 Vite 版本由 `package-lock.json` 锁定;依赖准备需要本地网络。 @@ -123,6 +126,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - 技能资源入口使用扳手图标;点击已安装 Skill 后先展示其目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 - 一个伙伴可以拥有多条互相独立的 OpenCode Session;伙伴、会话、归档时间、置顶和未读数分别由项目配置与 `.niancode/conversations.json` 保存。 - 伙伴展开后按时间展示会话,默认显示前五条,更多会话通过“更多会话”展开;每条会话只显示一行精简的最新消息预览和右侧时间,归档按钮仅在悬浮或聚焦会话卡片时出现,选中伙伴会在卡片上保持明确的展开状态反馈。 +- 会话历史按需加载:项目事件流重连只恢复当前选中或仍有活动任务的会话,旧会话在用户打开时从 OpenCode runtime 完整读取;Renderer 最多保留 8 个非活动会话的消息缓存,淘汰只影响内存副本,不删除或改写任何服务端历史。连接稳定时以事件流为准,只有断流才启用低频 REST 回退,并对同一会话的并发历史读取去重。 - 创建伙伴后先进入伙伴对话,首条消息发送时才懒创建 OpenCode Session;新会话从干净上下文开始,标题从“新对话”在首条消息发送后自动生成,也支持手动重命名。 - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。共享 runtime/provider 是否真正并发执行不同 Session 尚未经过自动化真实运行 smoke 验证,运行时仍可能自行串行、限流或拒绝请求。 - 已在当前 fresh OpenCode 运行代际加载且通过 live Agent registry 校验的项目伙伴,会按 Session 独立提交和跟踪回复。若共享 runtime/provider 未确认新 Session 已开始,该 Session 会在 10 秒后单独终止并提示手动重试,不会自动重发或改写其他 Session 状态。运行期间新建、编辑或删除伙伴只更新项目配置与生成文件,不会自动重启、reload 或 dispose 共享运行时,以免打断其他 Session;这类变更会保持“等待运行时重新加载”,用户需等当前回复完成后手动重启运行时再发送。 diff --git a/electron-builder.yml b/electron-builder.yml index 8bf3873..299fb7b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -45,10 +45,6 @@ afterPack: ./scripts/after-pack.cjs asar: true asarUnpack: - "**/*.node" - # lru-cache CJS/ESM interop: older CJS versions (v5, v6, v7) don't export - # `LRUCache` as a named property, breaking `import { LRUCache }` in Node.js - # 22+ (Electron 40+). Unpacking lets afterPack patch them in place. - - "**/node_modules/lru-cache/**" # Native addons in the desktop package use prebuilt/N-API artifacts. # Keep rebuild disabled, but verify packaged msgpackr and @napi-rs/canvas loading diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index b6c10d4..c4cff45 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -76,7 +76,8 @@ class PublishPreflightFault extends Error { this.name = 'PublishPreflightFault'; } } -const ENABLED_DOMAINS = ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable'] as const; +const DIAGNOSTIC_DOMAINS = ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable'] as const; +const DIAGNOSTIC_DISABLE_DOMAINS = ['Page.disable', 'Network.disable', 'Log.disable', 'Runtime.disable'] as const; const STREAM_ISSUING_METHODS = new Set([ 'Fetch.takeResponseBodyAsStream', 'Network.loadNetworkResource', @@ -95,6 +96,7 @@ interface BrowserRecord { url: string; title: string; visible: boolean; + diagnosticsEnabled: boolean; bounds: AgentBrowserBounds | null; error?: { code: AgentBrowserErrorCode; @@ -279,6 +281,7 @@ export class AgentBrowserModule { url: targetUrl, title: '', visible: Boolean(bounds) && (input.visible ?? true), + diagnosticsEnabled: false, bounds, eventBuffer: new AgentBrowserEventBuffer(), childSessions: new Set(), @@ -356,6 +359,14 @@ export class AgentBrowserModule { }); } + setDiagnostics(input: { projectPath: string; enabled: boolean }): Promise { + return this.serialize(async () => { + const record = this.requireRecord(input.projectPath); + await this.configureDiagnostics(record, input.enabled); + return this.snapshot(record); + }); + } + navigate(input: AgentBrowserNavigateInput): Promise { return this.serialize(async () => { const record = this.requireAttached(input.projectPath); @@ -665,14 +676,16 @@ export class AgentBrowserModule { record.error = undefined; if (needsAttach) port.attach(CDP_PROTOCOL_VERSION); - for (const method of ENABLED_DOMAINS) { - await port.sendCommand(method); - } await port.sendCommand('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, }); + if (record.diagnosticsEnabled) { + for (const method of DIAGNOSTIC_DOMAINS) { + await port.sendCommand(method); + } + } if (record !== this.record || record.view.webContents.isDestroyed()) { throw new AgentBrowserFault( 'CLOSED', @@ -789,12 +802,45 @@ export class AgentBrowserModule { }); } + private async configureDiagnostics(record: BrowserRecord, enabled: boolean): Promise { + if (record !== this.record || record.view.webContents.isDestroyed()) { + throw new AgentBrowserFault( + 'TARGET_GONE', + '开发浏览器页面已关闭。', + true, + record.generation, + ); + } + const debuggerPort = record.view.webContents.debugger; + if (enabled) { + if (record.diagnosticsEnabled) return; + for (const method of DIAGNOSTIC_DOMAINS) { + await debuggerPort.sendCommand(method); + } + record.diagnosticsEnabled = true; + return; + } + if (!record.diagnosticsEnabled) return; + for (const method of DIAGNOSTIC_DISABLE_DOMAINS) { + try { + await debuggerPort.sendCommand(method); + } catch { + // The page may have navigated or crashed while diagnostics closed. + } + } + record.diagnosticsEnabled = false; + record.eventBuffer.clear(); + this.payloadStore.clear(); + this.notifyEventWaiters(); + } + private appendEvent( record: BrowserRecord, method: string, params: unknown, sessionRef?: string, ): void { + if (!record.diagnosticsEnabled) return; const base = { generation: record.generation, timestamp: Date.now(), diff --git a/electron/api/context.ts b/electron/api/context.ts index 0d75dfa..d474850 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -12,6 +12,8 @@ import type { AgentBrowserSnapshot, } from '../../shared/agent-browser'; import type { StaticArtifactSnapshot } from '../services/static-release-server'; +import type { BackgroundLifecycleController } from '../main/background-lifecycle'; +import type { ReleaseJobManager } from '../services/release-job'; export type WorksSubmissionBindingStore = ReturnType; @@ -31,6 +33,10 @@ export interface AgentBrowserService { visible: boolean; bounds?: AgentBrowserBounds; }): Promise; + setDiagnostics(input: { + projectPath: string; + enabled: boolean; + }): Promise; navigate(input: { projectPath: string; action: 'url' | 'back' | 'forward' | 'reload'; @@ -69,4 +75,6 @@ export interface HostApiContext { agentBrowser?: AgentBrowserService; worksSubmissionBinding?: WorksSubmissionBindingStore; imageWorkspace?: DesignWorkspaceModule; + lifecycle?: BackgroundLifecycleController; + releaseJobs?: ReleaseJobManager; } diff --git a/electron/api/host-api-dispatcher.ts b/electron/api/host-api-dispatcher.ts new file mode 100644 index 0000000..d1eae87 --- /dev/null +++ b/electron/api/host-api-dispatcher.ts @@ -0,0 +1,169 @@ +import { Readable, Writable } from 'node:stream'; +import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http'; +import type { HostApiContext } from './context'; +import { hostApiRouteHandlers } from './route-handlers'; +import { requireJsonContentType, sendJson } from './route-utils'; + +export type HostApiDispatchInput = { + path: string; + method?: string; + headers?: Record; + body?: string; +}; + +export type HostApiDispatchData = { + status: number; + ok: boolean; + json?: unknown; + text?: string; +}; + +class DispatchResponse extends Writable { + statusCode = 200; + statusMessage?: string; + headersSent = false; + socket = null; + + private readonly headers = new Map(); + private readonly chunks: Buffer[] = []; + + constructor() { + super({ + write: (chunk: Buffer | string, _encoding, callback) => { + this.chunks.push(Buffer.isBuffer(chunk) ? Buffer.from(chunk) : Buffer.from(chunk)); + callback(); + }, + }); + } + + setHeader(name: string, value: string | number | readonly string[]): this { + this.headers.set(name.toLowerCase(), Array.isArray(value) ? [...value] : String(value)); + return this; + } + + getHeader(name: string): string | string[] | undefined { + return this.headers.get(name.toLowerCase()); + } + + hasHeader(name: string): boolean { + return this.headers.has(name.toLowerCase()); + } + + removeHeader(name: string): void { + this.headers.delete(name.toLowerCase()); + } + + writeHead( + statusCode: number, + statusMessageOrHeaders?: string | Record, + maybeHeaders?: Record, + ): this { + this.statusCode = statusCode; + if (typeof statusMessageOrHeaders === 'string') { + this.statusMessage = statusMessageOrHeaders; + for (const [name, value] of Object.entries(maybeHeaders ?? {})) this.setHeader(name, value); + } else { + for (const [name, value] of Object.entries(statusMessageOrHeaders ?? {})) this.setHeader(name, value); + } + this.headersSent = true; + return this; + } + + flushHeaders(): void { + this.headersSent = true; + } + + body(): Buffer { + return Buffer.concat(this.chunks); + } + + contentType(): string { + const value = this.getHeader('content-type'); + return Array.isArray(value) ? value[0] ?? '' : value ?? ''; + } +} + +function createRequest(input: HostApiDispatchInput): IncomingMessage { + const body = input.body ?? ''; + const request = Readable.from(body ? [Buffer.from(body, 'utf8')] : []) as Readable & Partial; + const headers: IncomingHttpHeaders = Object.create(null) as IncomingHttpHeaders; + for (const [name, value] of Object.entries(input.headers ?? {})) { + headers[name.toLowerCase()] = value; + } + if (body && headers['content-length'] === undefined) { + headers['content-length'] = String(Buffer.byteLength(body, 'utf8')); + } + request.method = (input.method ?? 'GET').toUpperCase(); + request.url = input.path; + request.headers = headers; + return request as IncomingMessage; +} + +function readResponseData(response: DispatchResponse): HostApiDispatchData { + const body = response.body(); + if (response.statusCode === 204 || body.length === 0) { + return { status: response.statusCode, ok: response.statusCode >= 200 && response.statusCode < 300 }; + } + const text = body.toString('utf8'); + if (response.contentType().toLowerCase().includes('json')) { + try { + return { + status: response.statusCode, + ok: response.statusCode >= 200 && response.statusCode < 300, + json: JSON.parse(text) as unknown, + }; + } catch { + // Keep malformed route output observable to the Renderer instead of + // silently treating it as an empty response. + } + } + return { + status: response.statusCode, + ok: response.statusCode >= 200 && response.statusCode < 300, + text, + }; +} + +/** + * Dispatch a Renderer Host API request without a second IPC -> loopback HTTP + * hop. This adapter deliberately reuses the existing Node route handlers so + * authentication, validation and response contracts remain unchanged. + */ +export async function dispatchHostApiRequest( + ctx: HostApiContext, + input: HostApiDispatchInput, +): Promise { + const request = createRequest(input); + const response = new DispatchResponse(); + const url = new URL(input.path, 'http://127.0.0.1'); + + try { + if (!requireJsonContentType(request)) { + sendJson(response as unknown as ServerResponse, 415, { + success: false, + error: 'Content-Type must be application/json', + }); + return readResponseData(response); + } + + for (const handler of hostApiRouteHandlers) { + if (await handler(request, response as unknown as ServerResponse, url, ctx)) { + return readResponseData(response); + } + } + sendJson(response as unknown as ServerResponse, 404, { + success: false, + error: `No route for ${request.method} ${url.pathname}`, + }); + return readResponseData(response); + } catch (error) { + if (!response.headersSent && !response.writableEnded) { + sendJson(response as unknown as ServerResponse, 500, { + success: false, + error: error instanceof Error ? error.message : String(error), + }); + return readResponseData(response); + } + throw error; + } +} diff --git a/electron/api/host-api-transport.ts b/electron/api/host-api-transport.ts new file mode 100644 index 0000000..4c2823a --- /dev/null +++ b/electron/api/host-api-transport.ts @@ -0,0 +1,18 @@ +/** + * Transport-only path classification. Kept free of route imports so the + * lightweight IPC registration path does not eagerly load every Host API + * service before the first window is painted. + */ +export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean { + const normalizedMethod = method.toUpperCase(); + const pathname = path.split('?', 1)[0] || path; + if (pathname === '/api/events' || pathname === '/api/opencode/events') return true; + if (pathname.startsWith('/api/ai-proxy/')) return true; + if (pathname.includes('/events') && pathname.startsWith('/api/image-workspace/')) return true; + if ( + pathname.includes('/assets/') + && pathname.endsWith('/content') + && (normalizedMethod === 'GET' || normalizedMethod === 'HEAD') + ) return true; + return false; +} diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts new file mode 100644 index 0000000..7f67fa7 --- /dev/null +++ b/electron/api/route-handlers.ts @@ -0,0 +1,52 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { HostApiContext } from './context'; +import { handleAiProxyRoutes } from './routes/ai-proxy'; +import { handleAiHardwareRoutes } from './routes/ai-hardware'; +import { handleAppRoutes } from './routes/app'; +import { handleAuthRoutes } from './routes/auth'; +import { handleOpencodeRoutes } from './routes/opencode'; +import { handleImageWorkspaceRoutes } from './routes/image-workspace'; +import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum'; +import { handleLearningRoutes } from './routes/learning'; +import { handleWorksRoutes } from './routes/works'; +import { handleUserSyncRoutes } from './routes/user-sync'; +import { handleSettingsRoutes } from './routes/settings'; +import { handleProviderRoutes } from './routes/providers'; +import { handleLogRoutes } from './routes/logs'; +import { handleUsageRoutes } from './routes/usage'; +import { handleFileRoutes } from './routes/files'; +import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets'; +import { handleAgentBrowserRoutes } from './routes/agent-browser'; + +export type HostApiRouteHandler = ( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +) => Promise; + +/** + * The same route list is used by the loopback compatibility server and by the + * Main-owned IPC dispatcher. Keeping one ordered list prevents the two + * transports from drifting into subtly different authorization or response + * behavior. + */ +export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ + handleAiProxyRoutes, + handleAiHardwareRoutes, + handleAppRoutes, + handleAuthRoutes, + handleImageWorkspaceRoutes, + handleImagePromptMuseumRoutes, + handleLearningRoutes, + handleWorksRoutes, + handleAgentBrowserRoutes, + handleUserSyncRoutes, + handleOpencodeRoutes, + handleSettingsRoutes, + handleProviderRoutes, + handleFileRoutes, + handleMeowaGameAssetsRoutes, + handleLogRoutes, + handleUsageRoutes, +]; diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts index 2700b41..d6dd14a 100644 --- a/electron/api/routes/agent-browser.ts +++ b/electron/api/routes/agent-browser.ts @@ -11,6 +11,7 @@ type AgentBrowserBody = { url?: unknown; action?: unknown; visible?: unknown; + enabled?: unknown; bounds?: unknown; method?: unknown; params?: unknown; @@ -237,6 +238,20 @@ export async function handleAgentBrowserRoutes( bounds: parseBounds(body.bounds), }); await ensureProjectStillActive(ctx, project); + emitState(ctx, 'agent-browser:state', browser); + sendJson(res, 200, { success: true, browser }); + return true; + } + + if (url.pathname === '/api/agent-browser/diagnostics' && req.method === 'POST') { + requireRendererPresentation(req); + const body = await readBody(req); + const project = await resolveActiveProject(ctx, body.project_path); + const browser = await service.setDiagnostics({ + projectPath: project.path, + enabled: body.enabled === true, + }); + await ensureProjectStillActive(ctx, project); sendJson(res, 200, { success: true, browser }); return true; } diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index dad5f50..de55cf5 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -1089,6 +1089,10 @@ async function handlePublishProjectSource( } let prepared: Awaited> | null = null; + const releaseLease = ctx.lifecycle?.acquireLease({ + id: `release:${projectId}:${randomUUID()}`, + kind: 'release-build', + }); try { if (!ctx.agentBrowser) throw new ProjectReleaseBuildError('LOCAL_BUILD_RUNTIME_UNAVAILABLE'); prepared = await prepareProjectRelease({ projectPath: localProject.path, clientVersion: app.getVersion() }); @@ -1242,10 +1246,77 @@ async function handlePublishProjectSource( ...(publishedMetadataPreserved ? { metadata_disposition: PUBLISHED_METADATA_PRESERVED } : {}), }); } finally { + releaseLease?.(); await prepared?.dispose().catch(() => undefined); } } +async function handleStartReleaseJob( + req: IncomingMessage, + res: ServerResponse, + ctx: HostApiContext, +): Promise { + if (!ctx.releaseJobs) { + sendJson(res, 503, { success: false, code: 'RELEASE_JOBS_UNAVAILABLE', error: '发布构建暂不可用。' }); + return; + } + const body = await parseJsonBody<{ projectId?: unknown }>(req); + const projectId = readOptionalString(body.projectId); + if (!projectId) { + sendJson(res, 400, { success: false, code: 'PROJECT_ID_REQUIRED', error: '缺少项目标识。' }); + return; + } + const project = (await ctx.opencodeProjectStore.listProjects()).find((candidate) => candidate.id === projectId); + if (!project) { + sendJson(res, 404, { success: false, code: 'PROJECT_NOT_FOUND', error: '本地项目不存在。' }); + return; + } + try { + const job = ctx.releaseJobs.start({ + projectId, + projectPath: project.path, + clientVersion: app.getVersion(), + }); + sendJson(res, 202, { success: true, job }); + } catch (error) { + if (error instanceof ProjectReleaseBuildError) { + sendJson(res, error.code === 'LOCAL_BUILD_BUSY' ? 409 : 400, { + success: false, + code: error.code, + error: error.message, + }); + return; + } + throw error; + } +} + +function handleReleaseJobStatus( + res: ServerResponse, + ctx: HostApiContext, + jobId: string, +): void { + const job = ctx.releaseJobs?.get(jobId); + if (!job) { + sendJson(res, 404, { success: false, code: 'RELEASE_JOB_NOT_FOUND', error: '发布任务不存在。' }); + return; + } + sendJson(res, 200, { success: true, job }); +} + +function handleCancelReleaseJob( + res: ServerResponse, + ctx: HostApiContext, + jobId: string, +): void { + const job = ctx.releaseJobs?.cancel(jobId); + if (!job) { + sendJson(res, 404, { success: false, code: 'RELEASE_JOB_NOT_FOUND', error: '发布任务不存在。' }); + return; + } + sendJson(res, 200, { success: true, job }); +} + function createSpeechTranscriptionFormData(body: SpeechTranscriptionInput): FormData { const audioBase64 = readRequiredString(body.audioBase64, 'audioBase64'); const fileName = readOptionalString(body.fileName) ?? 'voice.wav'; @@ -1404,6 +1475,39 @@ export async function handleWorksRoutes( return true; } + if (url.pathname === '/api/works/projects/release-jobs' && req.method === 'POST') { + if (!hasRendererCapability(req)) { + sendJson(res, 403, { + success: false, + status: 403, + code: 'RENDERER_CAPABILITY_REQUIRED', + error: 'Renderer capability required', + }); + return true; + } + await handleStartReleaseJob(req, res, ctx); + return true; + } + + const releaseJobMatch = url.pathname.match(/^\/api\/works\/projects\/release-jobs\/([^/]+)(?:\/(cancel))?$/); + if (releaseJobMatch && req.method === 'GET' && !releaseJobMatch[2]) { + handleReleaseJobStatus(res, ctx, decodeURIComponent(releaseJobMatch[1])); + return true; + } + if (releaseJobMatch && req.method === 'POST' && (releaseJobMatch[2] === 'cancel' || url.searchParams.get('action') === 'cancel')) { + if (!hasRendererCapability(req)) { + sendJson(res, 403, { + success: false, + status: 403, + code: 'RENDERER_CAPABILITY_REQUIRED', + error: 'Renderer capability required', + }); + return true; + } + handleCancelReleaseJob(res, ctx, decodeURIComponent(releaseJobMatch[1])); + return true; + } + if (url.pathname === '/api/works/projects/mine' && req.method === 'GET') { await handleListMyProjects(req, res, url); return true; diff --git a/electron/api/server.ts b/electron/api/server.ts index b1534fd..094410e 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -1,58 +1,11 @@ import { randomBytes } from 'node:crypto'; -import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { createServer, type Server } from 'node:http'; import { getPort } from '../utils/config'; import { logger } from '../utils/logger'; import type { HostApiContext } from './context'; -import { handleAiProxyRoutes } from './routes/ai-proxy'; -import { handleAiHardwareRoutes } from './routes/ai-hardware'; -import { handleAppRoutes } from './routes/app'; -import { handleAuthRoutes } from './routes/auth'; -import { handleOpencodeRoutes } from './routes/opencode'; -import { handleImageWorkspaceRoutes } from './routes/image-workspace'; -import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum'; -import { handleLearningRoutes } from './routes/learning'; -import { handleWorksRoutes } from './routes/works'; -import { handleUserSyncRoutes } from './routes/user-sync'; -import { handleSettingsRoutes } from './routes/settings'; -import { handleProviderRoutes } from './routes/providers'; -import { handleLogRoutes } from './routes/logs'; -import { handleUsageRoutes } from './routes/usage'; -import { handleFileRoutes } from './routes/files'; -import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets'; -import { handleAgentBrowserRoutes } from './routes/agent-browser'; import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils'; import { rotateRendererCapability } from './renderer-capability'; - -type RouteHandler = ( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, -) => Promise; - -const coreRouteHandlers: RouteHandler[] = [ - handleAiProxyRoutes, - handleAiHardwareRoutes, - handleAppRoutes, - handleAuthRoutes, - handleImageWorkspaceRoutes, - handleImagePromptMuseumRoutes, - handleLearningRoutes, - handleWorksRoutes, - handleAgentBrowserRoutes, - handleUserSyncRoutes, - handleOpencodeRoutes, - handleSettingsRoutes, - handleProviderRoutes, - handleFileRoutes, - handleMeowaGameAssetsRoutes, - handleLogRoutes, - handleUsageRoutes, -]; - -function buildRouteHandlers(): RouteHandler[] { - return coreRouteHandlers; -} +import { hostApiRouteHandlers } from './route-handlers'; /** * Per-session secret token used to authenticate Host API requests. @@ -110,8 +63,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE return; } - const routeHandlers = buildRouteHandlers(); - for (const handler of routeHandlers) { + for (const handler of hostApiRouteHandlers) { if (await handler(req, res, requestUrl, ctx)) { return; } diff --git a/electron/main/background-lifecycle.ts b/electron/main/background-lifecycle.ts new file mode 100644 index 0000000..2aaa804 --- /dev/null +++ b/electron/main/background-lifecycle.ts @@ -0,0 +1,111 @@ +import { EventEmitter } from 'node:events'; + +export type DesktopModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other'; + +export type DesktopActivity = { + visible: boolean; + module: DesktopModule | null; +}; + +/** Public contract names used by Main-owned lifecycle integrations. */ +export type DesktopActivityState = DesktopActivity; + +export type BackgroundLease = { + id: string; + kind: string; +}; + +export type ModuleLease = BackgroundLease; +export type BackgroundTaskLease = BackgroundLease; + +export type BackgroundLifecycleOptions = { + idleStopMs?: number; + onSleep: () => void | Promise; + onStopRuntime: () => void | Promise; +}; + +/** + * Main-owned activity/lease state machine. A hidden or inactive window gets a + * grace period, while a lease held by an active generation, prompt or build + * prevents the runtime from being stopped underneath the work. + */ +export class BackgroundLifecycleController extends EventEmitter { + private readonly idleStopMs: number; + private readonly leases = new Map(); + private activity: DesktopActivity = { visible: true, module: null }; + private idleTimer: ReturnType | null = null; + private stopping = false; + + constructor(private readonly options: BackgroundLifecycleOptions) { + super(); + this.idleStopMs = options.idleStopMs ?? 60_000; + } + + getActivity(): DesktopActivity { + return { ...this.activity }; + } + + getLeaseCount(): number { + return this.leases.size; + } + + setActivity(activity: DesktopActivity): void { + this.activity = { + visible: Boolean(activity.visible), + module: activity.module ?? null, + }; + this.emit('activity', this.getActivity()); + if (this.shouldSleep()) this.scheduleIdleStop(); + else this.cancelIdleStop(); + } + + acquireLease(lease: BackgroundLease): () => void { + if (!lease.id.trim()) throw new Error('Background lease id is required'); + this.leases.set(lease.id, { ...lease }); + this.cancelIdleStop(); + this.emit('lease-count', this.leases.size); + return () => this.releaseLease(lease.id); + } + + releaseLease(id: string): void { + if (!this.leases.delete(id)) return; + this.emit('lease-count', this.leases.size); + if (this.shouldSleep()) this.scheduleIdleStop(); + } + + dispose(): void { + this.cancelIdleStop(); + this.leases.clear(); + this.removeAllListeners(); + } + + private shouldSleep(): boolean { + return !this.activity.visible || this.activity.module !== 'programming'; + } + + private cancelIdleStop(): void { + if (this.idleTimer !== null) clearTimeout(this.idleTimer); + this.idleTimer = null; + } + + private scheduleIdleStop(): void { + if (this.idleTimer !== null || this.stopping) return; + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + void this.sleepIfIdle(); + }, this.idleStopMs); + } + + private async sleepIfIdle(): Promise { + if (!this.shouldSleep() || this.leases.size > 0 || this.stopping) return; + this.stopping = true; + try { + await this.options.onSleep(); + await this.options.onStopRuntime(); + this.emit('sleep'); + } finally { + this.stopping = false; + if (this.shouldSleep() && this.leases.size === 0) this.scheduleIdleStop(); + } + } +} diff --git a/electron/main/gpu-fallback.ts b/electron/main/gpu-fallback.ts new file mode 100644 index 0000000..fa12ba8 --- /dev/null +++ b/electron/main/gpu-fallback.ts @@ -0,0 +1,65 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export const GPU_CRASH_WINDOW_MS = 10 * 60 * 1000; +export const GPU_CRASH_THRESHOLD = 2; + +export type GpuFallbackState = { + crashCount: number; + lastCrashAt: number; + reason: string; +}; + +function isState(value: unknown): value is GpuFallbackState { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return Number.isFinite(record.crashCount) + && Number.isFinite(record.lastCrashAt) + && typeof record.reason === 'string'; +} + +export function readGpuFallbackState( + filePath: string, + now = Date.now(), +): GpuFallbackState | null { + try { + const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8')); + if (!isState(parsed)) return null; + if (now - parsed.lastCrashAt > GPU_CRASH_WINDOW_MS) return null; + return parsed; + } catch { + return null; + } +} + +export function shouldUseSoftwareRendering(input: { + envDisabled?: boolean; + cliDisabled?: boolean; + state?: GpuFallbackState | null; + now?: number; +}): boolean { + if (input.envDisabled || input.cliDisabled) return true; + const state = input.state; + if (!state) return false; + const now = input.now ?? Date.now(); + return now - state.lastCrashAt <= GPU_CRASH_WINDOW_MS + && state.crashCount >= GPU_CRASH_THRESHOLD; +} + +export function recordGpuCrash( + filePath: string, + reason: string, + now = Date.now(), +): GpuFallbackState { + const previous = readGpuFallbackState(filePath, now); + const next: GpuFallbackState = { + crashCount: previous ? previous.crashCount + 1 : 1, + lastCrashAt: now, + reason, + }; + mkdirSync(dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + writeFileSync(temporaryPath, `${JSON.stringify(next)}\n`, 'utf8'); + renameSync(temporaryPath, filePath); + return next; +} diff --git a/electron/main/index.ts b/electron/main/index.ts index e10eb0d..e5cd58a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -2,7 +2,7 @@ * Electron Main Process Entry * Manages window creation, system tray, and IPC handlers */ -import { app, BrowserWindow, nativeImage, shell } from 'electron'; +import { app, BrowserWindow, nativeImage, safeStorage, shell } from 'electron'; import type { Server } from 'node:http'; import { createServer } from 'node:http'; import { join } from 'node:path'; @@ -22,8 +22,6 @@ import { createProjectStore, type OpencodeProjectStore, } from '../opencode/project-store'; -import { readProjectConfig } from '../opencode/project-config'; -import { warmupOpencodeRuntime } from '../opencode/startup-warmup'; import { registerIpcHandlers } from './ipc-handlers'; import { createTray } from './tray'; import { createMenu } from './menu'; @@ -32,7 +30,6 @@ import { getNativeWindowMaterialOptions } from './window-material'; import { appUpdater, registerUpdateHandlers } from './updater'; import { logger } from '../utils/logger'; -import { warmupNetworkOptimization } from '../utils/uv-env'; import { resolvePythonRuntime, resolveUvRuntime } from '../utils/python-runtime'; import { initTelemetry } from '../utils/telemetry'; @@ -62,13 +59,22 @@ import { } from './relaunch-on-replaced-app'; import { createSignalQuitHandler } from './signal-quit'; import { acquireProcessInstanceFileLock } from './process-instance-lock'; +import { BackgroundLifecycleController } from './background-lifecycle'; +import { + readGpuFallbackState, + recordGpuCrash, + shouldUseSoftwareRendering, +} from './gpu-fallback'; import { getHostApiToken, startHostApiServer } from '../api/server'; +import type { HostApiContext } from '../api/context'; import { HostEventBus } from '../api/event-bus'; import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browser'; import { browserOAuthManager } from '../utils/browser-oauth'; import { createProjectProgressSync } from '../services/project-progress-sync'; import { createWorksSubmissionBindingStore } from '../services/works-submission-binding'; +import { ReleaseJobManager } from '../services/release-job'; +import { createReleaseUtilityPreparer } from '../services/release-utility-process'; import { createStaticArtifactSnapshot } from '../services/static-release-server'; import { consumeWorksSquareStartupRuntimeCleanupRequired, @@ -78,6 +84,7 @@ import { subscribeWorksSquareSession, type WorksSquareSessionChangeReason, } from '../services/works-square-session'; +import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy'; import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime'; import { getPort } from '../utils/config'; import { initializeMeowaGameAssetsCredential } from '../api/routes/meowa-game-assets'; @@ -91,6 +98,9 @@ import type { DesignWorkspaceModule } from '../image-workspace/module'; const WINDOWS_APP_USER_MODEL_ID = 'app.niancode.desktop'; const isE2EMode = process.env.NIANCODE_E2E === '1'; const requestedUserDataDir = process.env.NIANCODE_USER_DATA_DIR?.trim(); +const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSessionPersistence( + app.isPackaged, +); async function buildMakeloreOpencodeRuntimeConfig() { return await buildOpencodeRuntimeConfigFromNianCodeProviders({ @@ -100,50 +110,34 @@ async function buildMakeloreOpencodeRuntimeConfig() { }); } -function scheduleOpencodeRuntimeWarmup(): void { - if (isE2EMode) return; - - void warmupOpencodeRuntime({ - hasAuthenticatedSession: () => Boolean(getWorksSquareSessionSnapshot()), - getStatus: () => opencodeManager.getStatus(), - getActiveProject: () => opencodeProjectStore.getActiveProject(), - readProjectConfig, - getConfiguredProviderCount: async () => { - const runtime = await buildMakeloreOpencodeRuntimeConfig(); - return Object.keys(runtime.config.provider).length; - }, - start: () => opencodeManager.start(), - onError: (error, phase) => { - logger.warn(`[opencode-runtime] Startup warmup ${phase} failed`, error); - }, - }).then((result) => { - if (result.started) { - logger.info('[opencode-runtime] Startup warmup completed'); - } - }).catch((error) => { - logger.warn('[opencode-runtime] Startup warmup could not be scheduled', error); - }); -} - if (isE2EMode && requestedUserDataDir) { app.setPath('userData', requestedUserDataDir); } -// Disable GPU hardware acceleration globally for maximum stability across -// all GPU configurations (no GPU, integrated, discrete). -// -// Rationale (following VS Code's philosophy): -// - Page/file loading is async data fetching — zero GPU dependency. -// - The original per-platform GPU branching was added to avoid CPU rendering -// competing with sync I/O on Windows, but all file I/O is now async -// (fs/promises), so that concern no longer applies. -// - Software rendering is deterministic across all hardware; GPU compositing -// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and -// driver versions, making it the #1 source of rendering bugs in Electron. -// -// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or -// set `"disable-hardware-acceleration": false` in the app config (future). -app.disableHardwareAcceleration(); +// Hardware compositing is the fast path for Makelore's opaque light surface. +// Keep an explicit escape hatch for driver-specific failures rather than +// forcing every installation through Chromium's software compositor. +const gpuFallbackPath = join(app.getPath('userData'), 'gpu-fallback.json'); +const disableHardwareAcceleration = shouldUseSoftwareRendering({ + envDisabled: process.env.MAKELORE_DISABLE_GPU === '1', + cliDisabled: process.argv.includes('--disable-gpu'), + state: readGpuFallbackState(gpuFallbackPath), +}); +if (disableHardwareAcceleration) { + app.disableHardwareAcceleration(); +} + +app.on('child-process-gone', (_event, details) => { + if (details.type !== 'GPU' || !['crashed', 'abnormal-exit', 'oom'].includes(details.reason)) { + return; + } + try { + const state = recordGpuCrash(gpuFallbackPath, `${details.reason}:${details.exitCode}`); + logger.warn('[gpu] GPU process failure recorded; software rendering will be used after repeated failures', state); + } catch (error) { + logger.warn('[gpu] Failed to persist GPU process failure state', error); + } +}); // On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file. // On Wayland this maps the running window to niancode.desktop (→ icon + app grouping); @@ -201,6 +195,8 @@ let worksSubmissionBinding: ReturnType let agentBrowser: AgentBrowserModule | null = null; let applicationInitialization: Promise | null = null; let imageWorkspaceModule: DesignWorkspaceModule | null = null; +let backgroundLifecycle!: BackgroundLifecycleController; +let releaseJobs: ReleaseJobManager | null = null; const mainWindowFocusState = createMainWindowFocusState(); const quitLifecycleState = createQuitLifecycleState(); const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv); @@ -430,6 +426,25 @@ function createMainWindow(): BrowserWindow { } }); + win.on('hide', () => { + backgroundLifecycle?.setActivity({ + ...backgroundLifecycle.getActivity(), + visible: false, + }); + // The browser is a disposable renderer and must not survive a hidden + // application window. Active cloud tasks are reconciled when reopened. + void agentBrowser?.close().catch((error) => { + logger.warn('Failed to close Agent Browser after the main window was hidden:', error); + }); + }); + + win.on('show', () => { + backgroundLifecycle?.setActivity({ + ...backgroundLifecycle.getActivity(), + visible: true, + }); + }); + win.on('closed', () => { unsubscribeAuthSession(); const browser = agentBrowser; @@ -465,32 +480,14 @@ async function initialize(): Promise { handleAppDeepLinkActivation(launchDeepLinkUrl); } - if (!isE2EMode) { - // Warm up network optimization (non-blocking) - void warmupNetworkOptimization(); - - // Initialize Telemetry early - await initTelemetry(); - - // Apply persisted proxy settings before creating windows or network requests. - await applyProxySettings(); - const meowaCredential = await initializeMeowaGameAssetsCredential(); - if (meowaCredential.source !== 'none') { - logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`); - } - await syncLaunchAtStartupSettingFromStore(); - } else { + if (isE2EMode) { logger.info('Running in E2E mode: startup side effects minimized'); } - // Restore authentication before any background service or renderer can request a token. - await initializeWorksSquareSession(); - + // Only local storage and the lightweight workspace object are needed to + // construct the first window. Authentication and remote setup are restored + // after the renderer has had a chance to paint. opencodeProjectStore = createProjectStore(await createElectronProjectStorage()); - - if (!isE2EMode) { - projectProgressSync = createProjectProgressSync(opencodeProjectStore); - } worksSubmissionBinding = createWorksSubmissionBindingStore(opencodeProjectStore); const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({ @@ -502,12 +499,6 @@ async function initialize(): Promise { ? new LocalImageWorkspace({ userDataDir: app.getPath('userData') }) : new WorksSquareDesignWorkspace(); imageWorkspaceModule = imageWorkspace; - if (consumeWorksSquareStartupRuntimeCleanupRequired()) { - await clearManagedWorksSquareRuntimeBestEffort({ - opencodeManager, - imageWorkspace, - }, 'expired persisted session during startup'); - } if (localImageWorkspaceEnabled) { logger.info('AI painting workspace is using local development storage'); } else { @@ -520,10 +511,6 @@ async function initialize(): Promise { // Create the main window const window = createMainWindow(); agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window)); - if (!isE2EMode) { - void projectProgressSync?.start(); - void worksSubmissionBinding.start(); - } // Create system tray if (!isE2EMode) { @@ -531,9 +518,33 @@ async function initialize(): Promise { } // Register IPC handlers - registerIpcHandlers(undefined, opencodeManager, undefined, window); - - hostApiServer = startHostApiServer({ + backgroundLifecycle = new BackgroundLifecycleController({ + onSleep: () => { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('lifecycle:sleep'); + } + }, + onStopRuntime: () => opencodeManager.stop(), + }); + const releaseUtilityPreparer = createReleaseUtilityPreparer({ + runtimeContext: { + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + npmCacheDir: join(app.getPath('userData'), 'npm-cache'), + }, + }); + releaseJobs = new ReleaseJobManager({ + prepare: releaseUtilityPreparer.prepare, + dispose: releaseUtilityPreparer.dispose, + acquireLease: (lease) => backgroundLifecycle.acquireLease(lease), + }); + releaseJobs.on('status', (status) => { + hostEventBus.emit('release-job:status', status); + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('release-job:status', status); + } + }); + const hostApiContext: HostApiContext = { opencodeManager, opencodeProjectStore, eventBus: hostEventBus, @@ -541,7 +552,12 @@ async function initialize(): Promise { agentBrowser, worksSubmissionBinding: worksSubmissionBinding ?? undefined, imageWorkspace, - }); + lifecycle: backgroundLifecycle, + releaseJobs, + }; + registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext); + + hostApiServer = startHostApiServer(hostApiContext); // Register update handlers registerUpdateHandlers(appUpdater, window); @@ -549,6 +565,70 @@ async function initialize(): Promise { // Note: Auto-check for updates is driven by the renderer (update store init) // so it respects the user's "Auto-check for updates" setting. + const initializeBackgroundServices = async (): Promise => { + const runDeferred = async (label: string, task: () => Promise): Promise => { + try { + await task(); + } catch (error) { + logger.warn(`Deferred ${label} initialization failed:`, error); + } + }; + + if (!isE2EMode) { + // Apply the proxy before any external request made by background + // services, but never before the first local paint. + await runDeferred('proxy', applyProxySettings); + await runDeferred('telemetry', initTelemetry); + await runDeferred('game-assets credential', async () => { + const meowaCredential = await initializeMeowaGameAssetsCredential(); + if (meowaCredential.source !== 'none') { + logger.info(`Meowa game-assets credential initialized via ${meowaCredential.source}`); + } + }); + await runDeferred('launch-at-startup setting', syncLaunchAtStartupSettingFromStore); + } + + if (!useSecureWorksSquareSessionPersistence) { + logger.info('[works-square-session] Unpackaged development keeps credentials in memory; OS secure storage is disabled'); + } + await runDeferred('Works Square session', async () => { + await initializeWorksSquareSession({ + secureStorage: useSecureWorksSquareSessionPersistence ? safeStorage : null, + }); + }); + if (consumeWorksSquareStartupRuntimeCleanupRequired()) { + await runDeferred('expired runtime cleanup', async () => { + await clearManagedWorksSquareRuntimeBestEffort({ + opencodeManager, + imageWorkspace, + }, 'expired persisted session during startup'); + }); + } + if (!isE2EMode) { + projectProgressSync = createProjectProgressSync(opencodeProjectStore); + const activateProgrammingServices = (): void => { + void projectProgressSync?.start() + .then(() => projectProgressSync?.activate()) + .catch((error) => logger.warn('Failed to activate project progress sync:', error)); + void worksSubmissionBinding?.start() + .catch((error) => logger.warn('Failed to migrate active submission binding:', error)); + }; + backgroundLifecycle.on('activity', (activity) => { + if (activity.visible && activity.module === 'programming') { + activateProgrammingServices(); + } else { + projectProgressSync?.stop(); + } + }); + const activity = backgroundLifecycle.getActivity(); + if (activity.visible && activity.module === 'programming') { + activateProgrammingServices(); + } + // Project watchers and binding migration now start from module + // activation; the idle shell does not scan every project. + } + }; + opencodeManager.on('status', (status) => { logger.info( `opencode runtime status: state=${status.state}, port=${status.port}, pid=${status.pid ?? 'n/a'}, url=${status.url ?? 'n/a'}${status.error ? `, error=${status.error}` : ''}`, @@ -580,10 +660,12 @@ async function initialize(): Promise { hostEventBus.emit('oauth:error', error); }); - // Start the local Code runtime in the background once the Main process has - // restored session state and registered the Host API. Chat still retains - // its lazy-start fallback for first-run and failed-warmup cases. - scheduleOpencodeRuntimeWarmup(); + // OpenCode is started by the Code module on demand. Keeping the runtime + // cold for Canvas/Learning/Robot sessions avoids a resident child process + // and its project event stream on every launch. + void initializeBackgroundServices().catch((error) => { + logger.warn('Deferred background initialization failed:', error); + }); } if (gotTheLock) { @@ -736,6 +818,8 @@ if (gotTheLock) { hostEventBus.closeAll(); hostApiServer?.close(); projectProgressSync?.stop(); + backgroundLifecycle?.dispose(); + releaseJobs?.dispose(); const stopOpencodePromise = opencodeManager.stop().catch((err) => { logger.warn('opencodeManager.stop() error during quit:', err); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 3f2f786..08f84c0 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -36,6 +36,9 @@ import { createLearningSpeechClient, type LearningSpeechRequest } from '../servi import { createLearningGenerationClient } from '../services/learning-generation-client'; import { createLearningRuntimeBridge } from '../services/learning-runtime-bridge'; import type { LearningGenerationUploadRequest, LearningRuntimeBridgeRequest } from '../../shared/learning'; +import type { BackgroundLifecycleController, DesktopActivity } from './background-lifecycle'; +import { collectPerformanceSnapshot } from './performance-diagnostics'; +import type { HostApiContext } from '../api/context'; type UnifiedRequest = { id?: string; @@ -372,9 +375,22 @@ export function registerIpcHandlers( opencodeManager: OpencodeManager, _legacyMarketplaceService: unknown, mainWindow: BrowserWindow, + lifecycle?: BackgroundLifecycleController, + hostApiContext?: HostApiContext, ): void { - registerHostApiProxyHandlers(); + registerHostApiProxyHandlers(hostApiContext); registerTranscriptExportHandler(mainWindow); + const rendererLeases = new Set(); + const releaseRendererLeases = (): void => { + if (rendererLeases.size === 0) return; + for (const id of rendererLeases) lifecycle?.releaseLease(id); + rendererLeases.clear(); + }; + const rendererWebContents = mainWindow.webContents; + if (rendererWebContents && typeof rendererWebContents.on === 'function') { + rendererWebContents.on('render-process-gone', releaseRendererLeases); + rendererWebContents.on('destroyed', releaseRendererLeases); + } const learningCourseLibrary = createLearningCourseLibrary({ rootDirectory: app.getPath('userData') }); let learningAgentClient = createLearningAgentClient(); const learningSpeechClient = createLearningSpeechClient(); @@ -415,6 +431,7 @@ export function registerIpcHandlers( assertCurrentAccount(); return (await getLearningPlayerServer(binding.accountKey)).url; })); + ipcMain.handle('learning:closePlayer', () => closeLearningPlayerServer()); ipcMain.handle('learning:agentAsk', (_event, request: unknown) => withLearningBinding( async (binding, assertCurrentAccount) => { const identity = requireLearningAgentIdentity(request); @@ -508,6 +525,7 @@ export function registerIpcHandlers( app.relaunch(); app.exit(0); }); + ipcMain.handle('app:performance', () => collectPerformanceSnapshot(lifecycle)); ipcMain.handle('opencode:status', () => opencodeManager.getStatus()); ipcMain.handle('opencode:start', () => opencodeManager.start()); @@ -518,6 +536,35 @@ export function registerIpcHandlers( ipcMain.handle('opencode:restart', () => opencodeManager.restart()); ipcMain.handle('opencode:health', () => opencodeManager.checkHealth()); + ipcMain.handle('lifecycle:activity', (_event, activity: DesktopActivity) => { + const nextActivity = activity && typeof activity === 'object' + ? activity + : { visible: true, module: null }; + lifecycle?.setActivity(nextActivity); + if ((!nextActivity.visible || nextActivity.module !== 'painting') + && !mainWindow.isDestroyed() + && !mainWindow.webContents.isDestroyed()) { + mainWindow.webContents.send('lifecycle:pause'); + } + return lifecycle?.getActivity() ?? nextActivity; + }); + ipcMain.handle('lifecycle:lease', (_event, input: { id: string; kind: string; active: boolean }) => { + if (!lifecycle) return { count: 0 }; + const id = typeof input?.id === 'string' ? input.id.trim() : ''; + if (!id) return { count: lifecycle.getLeaseCount() }; + if (input?.active) { + lifecycle.acquireLease({ + id, + kind: typeof input.kind === 'string' && input.kind.trim() ? input.kind.trim() : 'unknown', + }); + rendererLeases.add(id); + } else { + lifecycle.releaseLease(id); + rendererLeases.delete(id); + } + return { count: lifecycle.getLeaseCount() }; + }); + ipcMain.handle('settings:getAll', () => getAllSettings()); ipcMain.handle('settings:get', (_event, payload: { key?: keyof AppSettings } | keyof AppSettings) => { const key = typeof payload === 'string' ? payload : payload?.key; diff --git a/electron/main/ipc/host-api-proxy.ts b/electron/main/ipc/host-api-proxy.ts index d2f1d2e..29a2d3b 100644 --- a/electron/main/ipc/host-api-proxy.ts +++ b/electron/main/ipc/host-api-proxy.ts @@ -1,6 +1,8 @@ import { ipcMain } from 'electron'; import { getPort } from '../../utils/config'; import { getHostApiToken } from '../../api/server'; +import type { HostApiContext } from '../../api/context'; +import { shouldUseLoopbackHostApi } from '../../api/host-api-transport'; import { getRendererCapability, RENDERER_CAPABILITY_HEADER, @@ -13,7 +15,7 @@ type HostApiFetchRequest = { body?: unknown; }; -export function registerHostApiProxyHandlers(): void { +export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): void { const hostApiPort = getPort('NIANCODE_HOST_API'); // Expose the per-session auth token to the renderer so the browser-fallback @@ -56,6 +58,17 @@ export function registerHostApiProxyHandlers(): void { } } + if (hostApiContext && !shouldUseLoopbackHostApi(path, method)) { + const { dispatchHostApiRequest } = await import('../../api/host-api-dispatcher'); + const data = await dispatchHostApiRequest(hostApiContext, { + path, + method, + headers, + ...(body === undefined ? {} : { body }), + }); + return { ok: true, data: { ...data, transport: 'dispatcher' as const } }; + } + // The Host API is an in-process loopback server. Using Electron's // proxy-aware network stack here can incorrectly send 127.0.0.1 through // a configured system proxy and surface a generic `fetch failed` before diff --git a/electron/main/performance-diagnostics.ts b/electron/main/performance-diagnostics.ts new file mode 100644 index 0000000..9c33fc5 --- /dev/null +++ b/electron/main/performance-diagnostics.ts @@ -0,0 +1,53 @@ +import { app, BrowserWindow, webContents } from 'electron'; +import { monitorEventLoopDelay } from 'node:perf_hooks'; + +export type PerformanceSnapshot = { + capturedAt: number; + eventLoopDelayMs: number; + gpu: ReturnType; + processes: Array<{ + pid: number; + type: string; + cpuPercent: number; + workingSetSize: number; + }>; + windows: number; + webContents: number; + background: { + activity: { visible: boolean; module: string | null }; + leaseCount: number; + }; +}; + +type LifecycleDiagnostics = { + getActivity(): { visible: boolean; module: string | null }; + getLeaseCount(): number; +}; + +// Keep the sampler alive for the process lifetime, but only expose aggregate +// histogram values. No request payloads, URLs or project data enter this +// diagnostic path. +const eventLoopMonitor = monitorEventLoopDelay({ resolution: 20 }); +eventLoopMonitor.enable(); + +export function collectPerformanceSnapshot(lifecycle?: LifecycleDiagnostics): PerformanceSnapshot { + const eventLoopDelayMs = eventLoopMonitor.max / 1e6; + eventLoopMonitor.reset(); + return { + capturedAt: Date.now(), + eventLoopDelayMs: Math.round(eventLoopDelayMs * 100) / 100, + gpu: app.getGPUFeatureStatus(), + processes: app.getAppMetrics().map((metric) => ({ + pid: metric.pid, + type: metric.type, + cpuPercent: metric.cpu.percentCPUUsage, + workingSetSize: metric.memory.workingSetSize, + })), + windows: BrowserWindow.getAllWindows().length, + webContents: webContents.getAllWebContents().length, + background: { + activity: lifecycle?.getActivity() ?? { visible: true, module: null }, + leaseCount: lifecycle?.getLeaseCount() ?? 0, + }, + }; +} diff --git a/electron/main/window-material.ts b/electron/main/window-material.ts index e6c98e4..01bc657 100644 --- a/electron/main/window-material.ts +++ b/electron/main/window-material.ts @@ -12,12 +12,13 @@ type NativeWindowMaterialOptions = Pick< export function getNativeWindowMaterialOptions( platform: NodeJS.Platform, ): NativeWindowMaterialOptions { - if (platform !== 'darwin') return {}; - + // Keep every platform on the same opaque surface. Native vibrancy forces + // transparent compositing and makes every backdrop-filter pay the cost of + // sampling the window behind it. Makelore's light palette already provides + // the visual hierarchy without a native material layer. return { - backgroundColor: '#00000000', - transparent: true, - vibrancy: 'under-window', - visualEffectState: 'active', + backgroundColor: '#FFFFFF', + transparent: false, + ...(platform === 'darwin' ? { visualEffectState: 'inactive' as const } : {}), }; } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index d09a82f..2ab9f3f 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -18,6 +18,9 @@ const validInvokeChannels = [ 'opencode:stop', 'opencode:restart', 'opencode:health', + 'lifecycle:activity', + 'lifecycle:lease', + 'learning:closePlayer', 'shell:openExternal', 'shell:showItemInFolder', 'shell:openPath', @@ -31,6 +34,7 @@ const validInvokeChannels = [ 'app:platform', 'app:quit', 'app:relaunch', + 'app:performance', 'app:request', 'window:minimize', 'window:maximize', @@ -93,6 +97,9 @@ const validEventChannels = [ 'agent-browser:show', 'agent-browser:state', 'auth:session-changed', + 'lifecycle:pause', + 'lifecycle:sleep', + 'release-job:status', 'learning:runtime-event', ]; diff --git a/electron/services/project-packager.ts b/electron/services/project-packager.ts index a45ee70..c458511 100644 --- a/electron/services/project-packager.ts +++ b/electron/services/project-packager.ts @@ -1,20 +1,16 @@ -import { createHash } from 'node:crypto'; import type { Stats } from 'node:fs'; -import { createRequire } from 'node:module'; import { lstat, mkdir, open, readdir, realpath, - writeFile, + rm, } from 'node:fs/promises'; import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import type { ProjectType } from '../../shared/project-config'; import { readProjectConfig } from '../opencode/project-config'; - -const require = createRequire(import.meta.url); -const AdmZip = require('adm-zip') as typeof import('adm-zip'); +import { writeStoredZip } from './streaming-zip'; type PublishableProjectType = Exclude; @@ -31,7 +27,6 @@ function generatedManifest(projectType: PublishableProjectType): string { ); } const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0); -const FILE_MODE = 0o100644; const DEFAULT_LIMITS: ProjectPackageLimits = { maxFiles: 2_000, maxSourceBytes: 200 * 1024 * 1024, @@ -203,36 +198,58 @@ export async function createStaticProjectPackage(input: { ); } - const zip = new AdmZip(); const filesByArchivePath = new Map(scan.files.map((file) => [file.archivePath, file])); const archivePaths = ['niancode.yml', ...filesByArchivePath.keys()] .sort((left, right) => left.localeCompare(right, 'en')); - for (const archivePath of archivePaths) { - const data = archivePath === 'niancode.yml' - ? manifestBytes - : await readStableFile(projectPath, filesByArchivePath.get(archivePath)!); - zip.addFile(archivePath, data, '', FILE_MODE); - const entry = zip.getEntry(archivePath); - if (entry) entry.header.time = FIXED_ZIP_TIME; + + await mkdir(dirname(input.archivePath), { recursive: true }); + let streamed: Awaited>; + try { + streamed = await writeStoredZip( + archivePaths.map((archivePath) => { + if (archivePath === 'niancode.yml') { + return { path: archivePath, source: { bytes: manifestBytes } }; + } + const file = filesByArchivePath.get(archivePath); + if (!file) throw new ProjectPackageError('PROJECT_CHANGED_DURING_PACKAGING', `找不到待打包文件:${archivePath}`); + return { + path: archivePath, + source: { filePath: file.absolutePath }, + beforeRead: async () => assertStablePackageFile(projectPath, file), + afterRead: async ({ size }) => { + if (size !== file.size) { + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目文件发生变化:${file.archivePath}`, + ); + } + await assertStablePackageFile(projectPath, file); + }, + }; + }), + input.archivePath, + { date: FIXED_ZIP_TIME }, + ); + } catch (error) { + await rm(input.archivePath, { force: true }).catch(() => undefined); + throw error; } - const archiveBytes = zip.toBuffer(); - if (archiveBytes.length > limits.maxArchiveBytes) { + if (streamed.archiveBytes > limits.maxArchiveBytes) { + await rm(input.archivePath, { force: true }).catch(() => undefined); throw new ProjectPackageError( 'ARCHIVE_TOO_LARGE', `压缩包超过 ${formatBytes(limits.maxArchiveBytes)} 限制`, ); } - await mkdir(dirname(input.archivePath), { recursive: true }); - await writeFile(input.archivePath, archiveBytes); return { archivePath: input.archivePath, archiveName: 'project.zip', - sha256: createHash('sha256').update(archiveBytes).digest('hex'), + sha256: streamed.sha256, fileCount: archivePaths.length, sourceBytes, - archiveBytes: archiveBytes.length, + archiveBytes: streamed.archiveBytes, excludedCount: scan.excludedCount, excludedPaths: scan.excludedPaths, manifest: { @@ -583,6 +600,19 @@ async function readStableFile(projectPath: string, file: PackageFile): Promise { + try { + await assertDirectoryIdentityChain(projectPath, file.directoryChain); + assertStableFileStats(file, await lstat(file.absolutePath)); + } catch (error) { + if (error instanceof ProjectPackageError) throw error; + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目文件发生变化:${file.archivePath}`, + ); + } +} + function assertStableFileStats(file: PackageFile, stats: Stats): void { if ( !stats.isFile() diff --git a/electron/services/project-progress-sync.ts b/electron/services/project-progress-sync.ts index 6df885e..1765e8c 100644 --- a/electron/services/project-progress-sync.ts +++ b/electron/services/project-progress-sync.ts @@ -39,7 +39,7 @@ type WatchDirectory = ( onChange: (filename: string | null) => void, ) => ProjectProgressWatcher; -type ProjectProgressProjectStore = Pick; +type ProjectProgressProjectStore = Pick & Partial>; export type ProjectProgressSyncOptions = { apiBaseUrl?: string; @@ -159,6 +159,7 @@ export function createProjectProgressSync( let unsubscribeProjectStore: (() => void) | null = null; let unsubscribeSession: (() => void) | null = null; let started = false; + let activeProjectId: string | null = null; function clearTimer(state: ProjectProgressState): void { if (state.timer) { @@ -373,20 +374,21 @@ export function createProjectProgressSync( async function start(): Promise { if (started) return; started = true; + activeProjectId = null; unsubscribeProjectStore = projectStore.subscribe((change) => { if (change.type === 'remove') { disposeProject(change.projectId); + if (activeProjectId === change.projectId) activeProjectId = null; return; } - attachProject(change.project); + if (activeProjectId === change.project.id) attachProject(change.project); }); unsubscribeSession = subscribeWorksSquareSession((session) => { if (!session) return; for (const projectId of states.keys()) scheduleProject(projectId, true); }); - const projects = await projectStore.listProjects(); - for (const project of projects) attachProject(project); + await activate(); } function stop(): void { @@ -398,11 +400,39 @@ export function createProjectProgressSync( unsubscribeSession = null; for (const state of states.values()) closeWatcher(state); states.clear(); + activeProjectId = null; + } + + async function activate(projectId?: string | null): Promise { + if (!started) return; + let project = await projectStore.getActiveProject(); + // A project id is supplied when the renderer just switched projects. Use + // the store's direct lookup list only for that explicit activation (or for + // a single-project legacy store with no persisted active id); never scan + // and watch the full project collection during normal startup. + if (projectId && project?.id !== projectId && projectStore.listProjects) { + project = (await projectStore.listProjects()).find((item) => item.id === projectId) ?? null; + } else if (!project && projectStore.listProjects) { + const projects = await projectStore.listProjects(); + project = projects.length === 1 ? projects[0] : null; + } + if (!project || (projectId && project.id !== projectId)) { + for (const state of states.values()) closeWatcher(state); + states.clear(); + activeProjectId = null; + return; + } + activeProjectId = project.id; + for (const id of states.keys()) { + if (id !== project.id) disposeProject(id); + } + attachProject(project); } return { start, stop, + activate, flushProject: async (projectId: string): Promise => { const state = states.get(projectId); if (!state) return; diff --git a/electron/services/project-release-builder.ts b/electron/services/project-release-builder.ts index 38817f4..feb4ffc 100644 --- a/electron/services/project-release-builder.ts +++ b/electron/services/project-release-builder.ts @@ -1,11 +1,20 @@ 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 { createReadStream, createWriteStream } from 'node:fs'; +import { lstat, mkdir, mkdtemp, open, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { pipeline } from 'node:stream/promises'; +import { dirname, join, relative, resolve, sep } from 'node:path'; import { tmpdir } from 'node:os'; +import { app } from 'electron'; import { createStaticProjectPackage, type StaticProjectPackageSummary } from './project-packager'; -import { PublishRuntimeError, resolvePublishRuntime, runElectronNode } from './publish-runtime'; +import { + PublishRuntimeError, + resolvePublishRuntime, + runElectronNode, + type PublishRuntimeContext, +} from './publish-runtime'; import { createStaticArtifactSnapshot, type StaticArtifactSnapshot } from './static-release-server'; +import { writeStoredZip } from './streaming-zip'; const require = createRequire(import.meta.url); const AdmZip = require('adm-zip') as typeof import('adm-zip'); @@ -13,6 +22,7 @@ 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; +const activeReleaseProjects = new Set(); export type ArtifactContract = { schema_version: 1; @@ -36,8 +46,15 @@ export type PreparedRelease = { dispose(): Promise; }; +export type PreparedReleaseSummary = { + sourceArchive: { path: string; name: 'project.zip'; summary: StaticProjectPackageSummary }; + builtArchive: { path: string; name: 'built-project.zip' }; + contract: ArtifactContract; + dispose(): Promise; +}; + export class ProjectReleaseBuildError extends Error { - constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_OUTPUT_MISSING') { + constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_CANCELLED' | 'LOCAL_BUILD_OUTPUT_MISSING' | 'LOCAL_BUILD_BUSY') { super(code); this.name = 'ProjectReleaseBuildError'; } @@ -47,10 +64,8 @@ const sha256 = (bytes: Buffer | string) => createHash('sha256').update(bytes).di 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 throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED'); } function safeArchivePath(name: string): string[] | null { @@ -61,44 +76,177 @@ function safeArchivePath(name: string): string[] | 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))) { +const ZIP_EOCD_SIGNATURE = 0x06054b50; +const ZIP_CENTRAL_SIGNATURE = 0x02014b50; +const ZIP_LOCAL_SIGNATURE = 0x04034b50; +const ZIP_STORE_METHOD = 0; +const ZIP_MAX_COMMENT_BYTES = 0xffff; +const MAX_SOURCE_ARCHIVE_BYTES = 200 * 1024 * 1024; + +type StoredZipCentralEntry = { + name: string; + parts: string[]; + compressedSize: number; + uncompressedSize: number; + localOffset: number; +}; + +async function readZipBytes( + handle: Awaited>, + position: number, + length: number, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal); + const result = Buffer.alloc(length); + let offset = 0; + while (offset < length) { + throwIfAborted(signal); + const read = await handle.read(result, offset, length - offset, position + offset); + if (read.bytesRead <= 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + offset += read.bytesRead; + } + return result; +} + +function findZipEndOfCentralDirectory(tail: Buffer): number { + for (let offset = tail.length - 22; offset >= 0; offset -= 1) { + if (tail.readUInt32LE(offset) === ZIP_EOCD_SIGNATURE) return offset; + } + return -1; +} + +async function readStoredZipEntries( + archive: string, + signal?: AbortSignal, +): Promise<{ handle: Awaited>; size: number; entries: StoredZipCentralEntry[] }> { + const handle = await open(archive, 'r'); + try { + const stats = await handle.stat(); + if (!stats.isFile() || stats.size > MAX_SOURCE_ARCHIVE_BYTES) { 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) { + const tailLength = Math.min(stats.size, 22 + ZIP_MAX_COMMENT_BYTES); + const tail = await readZipBytes(handle, stats.size - tailLength, tailLength, signal); + const endOffset = findZipEndOfCentralDirectory(tail); + if (endOffset < 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const entryCount = tail.readUInt16LE(endOffset + 10); + const centralSize = tail.readUInt32LE(endOffset + 12); + const centralOffset = tail.readUInt32LE(endOffset + 16); + if ( + tail.readUInt16LE(endOffset + 4) !== 0 + || tail.readUInt16LE(endOffset + 6) !== 0 + || entryCount === 0 + || entryCount > MAX_OUTPUT_FILES + || centralSize > stats.size + || centralOffset > stats.size + || centralOffset + centralSize > stats.size + ) { 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'); + + const entries: StoredZipCentralEntry[] = []; + let cursor = centralOffset; + const centralEnd = centralOffset + centralSize; + for (let index = 0; index < entryCount; index += 1) { + throwIfAborted(signal); + if (cursor + 46 > centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const header = await readZipBytes(handle, cursor, 46, signal); + if (header.readUInt32LE(0) !== ZIP_CENTRAL_SIGNATURE) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const nameLength = header.readUInt16LE(28); + const extraLength = header.readUInt16LE(30); + const commentLength = header.readUInt16LE(32); + const recordLength = 46 + nameLength + extraLength + commentLength; + if (cursor + recordLength > centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const name = (await readZipBytes(handle, cursor + 46, nameLength, signal)).toString('utf8'); + const parts = safeArchivePath(name); + const flags = header.readUInt16LE(8); + const method = header.readUInt16LE(10); + const compressedSize = header.readUInt32LE(20); + const uncompressedSize = header.readUInt32LE(24); + const localOffset = header.readUInt32LE(42); + const unixMode = (header.readUInt32LE(38) >>> 16) & 0xffff; + const unixType = unixMode & 0xf000; + if ( + !parts + || (flags & 0x0001) !== 0 + || method !== ZIP_STORE_METHOD + || compressedSize === 0xffffffff + || uncompressedSize === 0xffffffff + || localOffset >= centralOffset + || (unixType !== 0 && unixType !== 0x8000) + ) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + } + entries.push({ name, parts, compressedSize, uncompressedSize, localOffset }); + cursor += recordLength; } + if (cursor !== centralEnd) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + return { handle, size: stats.size, entries }; + } catch (error) { + await handle.close().catch(() => undefined); + throw error; } } -async function collectOutput(root: string): Promise> { +async function extractSnapshot(archive: string, target: string, signal?: AbortSignal): Promise { + const zip = await readStoredZipEntries(archive, signal); + const destinations = new Set(); + let totalBytes = 0; + try { + for (const entry of zip.entries) { + throwIfAborted(signal); + const destinationKey = entry.name.toLowerCase(); + if (destinations.has(destinationKey)) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + destinations.add(destinationKey); + if (entry.uncompressedSize > MAX_SOURCE_ARCHIVE_BYTES || entry.uncompressedSize !== entry.compressedSize) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + } + totalBytes += entry.uncompressedSize; + if (totalBytes > MAX_SOURCE_ARCHIVE_BYTES) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const localHeader = await readZipBytes(zip.handle, entry.localOffset, 30, signal); + if (localHeader.readUInt32LE(0) !== ZIP_LOCAL_SIGNATURE) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const localFlags = localHeader.readUInt16LE(6); + const localMethod = localHeader.readUInt16LE(8); + const localNameLength = localHeader.readUInt16LE(26); + const localExtraLength = localHeader.readUInt16LE(28); + if ((localFlags & 0x0001) !== 0 || localMethod !== ZIP_STORE_METHOD) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const localName = (await readZipBytes(zip.handle, entry.localOffset + 30, localNameLength, signal)).toString('utf8'); + if (localName !== entry.name) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const dataStart = entry.localOffset + 30 + localNameLength + localExtraLength; + const dataEnd = dataStart + entry.compressedSize; + if (dataStart > zip.size || dataEnd > zip.size || dataEnd < dataStart) throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + const destination = join(target, ...entry.parts); + await mkdir(dirname(destination), { recursive: true }); + try { + if (entry.compressedSize === 0) { + await writeFile(destination, Buffer.alloc(0), { flag: 'wx', mode: 0o600 }); + } else { + const input = zip.handle.createReadStream({ start: dataStart, end: dataEnd - 1, autoClose: false }); + const output = createWriteStream(destination, { flags: 'wx', mode: 0o600 }); + await pipeline(input, output, { signal }); + } + const written = await lstat(destination); + if (!written.isFile() || written.size !== entry.uncompressedSize) throw new Error('size mismatch'); + } catch { + await rm(destination, { force: true }).catch(() => undefined); + throwIfAborted(signal); + throw new ProjectReleaseBuildError('LOCAL_BUILD_FAILED'); + } + } + } finally { + await zip.handle.close().catch(() => undefined); + } +} + +async function collectOutput(root: string, signal?: AbortSignal): 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) { + throwIfAborted(signal); 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'); @@ -124,25 +272,131 @@ async function collectOutput(root: string): Promise { - const taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-')); +async function hashFile(filePath: string, signal?: AbortSignal): Promise<{ bytes: number; sha256: string }> { + const hash = createHash('sha256'); + let bytes = 0; + const stream = createReadStream(filePath); + try { + for await (const chunk of stream) { + throwIfAborted(signal); + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += value.length; + hash.update(value); + } + } finally { + stream.destroy(); + } + return { bytes, sha256: hash.digest('hex') }; +} + +async function collectOutputMetadata( + root: string, + signal?: AbortSignal, +): Promise> { + const files: Array<{ path: string; filePath: string; size: number; sha256: string }> = []; + 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) { + throwIfAborted(signal); + 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); + continue; + } + const path = archivePath(relative(root, absolute)); + if (files.length + 1 > MAX_OUTPUT_FILES || total + stats.size > MAX_OUTPUT_BYTES) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + } + const digest = await hashFile(absolute, signal); + if (digest.bytes !== stats.size) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + total += digest.bytes; + files.push({ path, filePath: absolute, size: digest.bytes, sha256: digest.sha256 }); + } + } + + 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 || index.size === 0) throw new ProjectReleaseBuildError('LOCAL_BUILD_OUTPUT_MISSING'); + return files; +} + +export type ReleaseBuildProgress = { + phase: 'packaging' | 'installing' | 'building' | 'archiving' | 'complete'; + percent: number; +}; + +export type ReleaseBuildRuntimeContext = Partial & { + /** Persistent npm cache supplied by Main so utility processes never need app.getPath(). */ + npmCacheDir?: string; +}; + +type ReleaseBuildInput = { + projectPath: string; + clientVersion: string; + signal?: AbortSignal; + onProgress?: (progress: ReleaseBuildProgress) => void; + runtimeContext?: ReleaseBuildRuntimeContext; +}; + +async function prepareProjectReleaseInternal( + input: ReleaseBuildInput, + retainBuffers: boolean, +): Promise { + const projectKey = resolve(input.projectPath); + if (activeReleaseProjects.has(projectKey)) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_BUSY'); + } + activeReleaseProjects.add(projectKey); + const progress = (phase: ReleaseBuildProgress['phase'], percent: number): void => { + input.onProgress?.({ phase, percent }); + throwIfAborted(input.signal); + }; + let taskRoot: string; + try { + taskRoot = await mkdtemp(join(tmpdir(), 'makelore-release-')); + } catch (error) { + activeReleaseProjects.delete(projectKey); + throw error; + } try { const sourcePath = join(taskRoot, 'project.zip'); + progress('packaging', 5); const summary = await createStaticProjectPackage({ projectPath: input.projectPath, archivePath: sourcePath }); - const sourceBytes = await readFile(sourcePath); + progress('packaging', 20); + const sourceBytes = retainBuffers ? await readFile(sourcePath) : undefined; const snapshot = join(taskRoot, 'snapshot'); const distRoot = join(taskRoot, 'owned-dist'); const npmUserConfig = join(taskRoot, 'empty-npmrc'); - const npmCache = join(taskRoot, 'npm-cache'); + const npmCache = input.runtimeContext?.npmCacheDir + ?? join(app.getPath('userData'), 'npm-cache'); await mkdir(snapshot, { recursive: true }); await writeFile(npmUserConfig, '', 'utf8'); - await extractSnapshot(sourcePath, snapshot); - const runtime = await resolvePublishRuntime(); + await extractSnapshot(sourcePath, snapshot, input.signal); + progress('installing', 25); + const runtime = await resolvePublishRuntime(input.runtimeContext); await runElectronNode({ - args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--userconfig', npmUserConfig, '--cache', npmCache], + args: [runtime.npmCli, 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--prefer-offline', '--progress=false', '--userconfig', npmUserConfig, '--cache', npmCache], cwd: snapshot, deadlineMs: 5 * 60_000, + signal: input.signal, }); + progress('installing', 55); let viteVersion = ''; try { const vitePackage = JSON.parse(await readFile(join(snapshot, 'node_modules', 'vite', 'package.json'), 'utf8')) as { version?: unknown }; @@ -154,18 +408,38 @@ export async function prepareProjectRelease(input: { projectPath: string; client args: [join(snapshot, 'node_modules', 'vite', 'bin', 'vite.js'), 'build', '--base', './', '--outDir', distRoot, '--emptyOutDir'], cwd: snapshot, deadlineMs: 3 * 60_000, + signal: input.signal, }); - 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(); + progress('building', 80); + progress('archiving', 90); const builtPath = join(taskRoot, 'built-project.zip'); - await writeFile(builtPath, builtBytes); + let files: Array<{ path: string; size: number; sha256: string }>; + let builtBytes: Buffer | undefined; + let builtArchiveDigest: string; + let staticArtifact: StaticArtifactSnapshot | undefined; + if (retainBuffers) { + const output = await collectOutput(distRoot, input.signal); + files = output.map(({ path, bytes }) => ({ path, size: bytes.length, sha256: sha256(bytes) })); + const zip = new AdmZip(); + 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; + } + builtBytes = zip.toBuffer(); + await writeFile(builtPath, builtBytes); + builtArchiveDigest = sha256(builtBytes); + staticArtifact = createStaticArtifactSnapshot(output); + } else { + const metadata = await collectOutputMetadata(distRoot, input.signal); + files = metadata.map(({ path, size, sha256: digest }) => ({ path, size, sha256: digest })); + const streamed = await writeStoredZip( + metadata.map((file) => ({ path: file.path, source: { filePath: file.filePath } })), + builtPath, + { signal: input.signal, date: FIXED_ZIP_TIME }, + ); + builtArchiveDigest = streamed.sha256; + } 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) => ({ @@ -173,30 +447,50 @@ export async function prepareProjectRelease(input: { projectPath: string; client 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 }); }, + const contract: ArtifactContract = { + schema_version: 1, + entry_path: 'index.html', + source_digest: sourceBytes ? sha256(sourceBytes) : (await hashFile(sourcePath, input.signal)).sha256, + built_archive_digest: builtArchiveDigest, + 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 }, }; + const dispose = async (): Promise => { await rm(taskRoot, { recursive: true, force: true }); }; + const prepared: PreparedRelease | PreparedReleaseSummary = retainBuffers + ? { + sourceArchive: { path: sourcePath, name: 'project.zip', bytes: sourceBytes!, summary }, + builtArchive: { path: builtPath, name: 'built-project.zip', bytes: builtBytes! }, + distRoot, + staticArtifact: staticArtifact!, + contract, + dispose, + } + : { + sourceArchive: { path: sourcePath, name: 'project.zip', summary }, + builtArchive: { path: builtPath, name: 'built-project.zip' }, + contract, + dispose, + }; + progress('complete', 100); + activeReleaseProjects.delete(projectKey); + return prepared; } catch (error) { + activeReleaseProjects.delete(projectKey); 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; } } + +export async function prepareProjectRelease(input: ReleaseBuildInput): Promise { + return await prepareProjectReleaseInternal(input, true) as PreparedRelease; +} + +export async function prepareProjectReleaseSummary(input: ReleaseBuildInput): Promise { + return await prepareProjectReleaseInternal(input, false) as PreparedReleaseSummary; +} diff --git a/electron/services/publish-runtime.ts b/electron/services/publish-runtime.ts index 4da2c95..321c637 100644 --- a/electron/services/publish-runtime.ts +++ b/electron/services/publish-runtime.ts @@ -8,7 +8,7 @@ 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') { + constructor(readonly code: 'LOCAL_BUILD_RUNTIME_UNAVAILABLE' | 'LOCAL_BUILD_FAILED' | 'LOCAL_BUILD_TIMEOUT' | 'LOCAL_BUILD_CANCELLED') { super(code); this.name = 'PublishRuntimeError'; } @@ -20,6 +20,13 @@ export type PublishRuntime = { npmVersion: string; }; +export type PublishRuntimeContext = { + isPackaged: boolean; + resourcesPath: string; +}; + +const cachedPublishRuntimes = new Map>(); + function childEnvironment(): NodeJS.ProcessEnv { const allowed = ['ALLUSERSPROFILE', 'APPDATA', 'COMMONPROGRAMFILES', 'COMMONPROGRAMFILES(X86)', 'COMMONPROGRAMW6432', 'COMSPEC', 'HOME', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA', @@ -54,8 +61,13 @@ export async function runElectronNode(input: { args: string[]; cwd?: string; deadlineMs: number; + signal?: AbortSignal; }): Promise<{ stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { + if (input.signal?.aborted) { + reject(new PublishRuntimeError('LOCAL_BUILD_CANCELLED')); + return; + } const child = spawn(process.execPath, input.args, { cwd: input.cwd, env: childEnvironment(), @@ -70,27 +82,57 @@ export async function runElectronNode(input: { child.stdout?.on('data', (chunk: Buffer) => { stdout = append(stdout, chunk); }); child.stderr?.on('data', (chunk: Buffer) => { stderr = append(stderr, chunk); }); let timedOut = false; + let cancelled = false; + let settled = false; + const abort = () => { + cancelled = true; + void terminateTree(child); + }; + input.signal?.addEventListener('abort', abort, { once: true }); const timer = setTimeout(() => { timedOut = true; void terminateTree(child); }, input.deadlineMs); child.once('error', () => { clearTimeout(timer); - reject(new PublishRuntimeError('LOCAL_BUILD_RUNTIME_UNAVAILABLE')); + input.signal?.removeEventListener('abort', abort); + if (settled) return; + settled = true; + reject(new PublishRuntimeError(cancelled ? 'LOCAL_BUILD_CANCELLED' : 'LOCAL_BUILD_RUNTIME_UNAVAILABLE')); }); child.once('close', (code) => { clearTimeout(timer); - if (timedOut) reject(new PublishRuntimeError('LOCAL_BUILD_TIMEOUT')); + input.signal?.removeEventListener('abort', abort); + if (settled) return; + settled = true; + if (cancelled) reject(new PublishRuntimeError('LOCAL_BUILD_CANCELLED')); + else 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 { +export async function resolvePublishRuntime(context?: Partial): Promise { + const isPackaged = context?.isPackaged ?? Boolean(app?.isPackaged); + const resourcesPath = context?.resourcesPath ?? process.resourcesPath; + const cacheKey = `${isPackaged ? 'packaged' : 'development'}:${resourcesPath}`; + const cachedPublishRuntime = cachedPublishRuntimes.get(cacheKey); + if (cachedPublishRuntime) return cachedPublishRuntime; + const nextRuntime = resolvePublishRuntimeUncached({ isPackaged, resourcesPath }); + cachedPublishRuntimes.set(cacheKey, nextRuntime); try { - const packagedNpmPackage = join(process.resourcesPath, 'publish-runtime', 'package.json'); - const npmPackage = app.isPackaged && existsSync(packagedNpmPackage) + return await nextRuntime; + } catch (error) { + cachedPublishRuntimes.delete(cacheKey); + throw error; + } +} + +async function resolvePublishRuntimeUncached(context: PublishRuntimeContext): Promise { + try { + const packagedNpmPackage = join(context.resourcesPath, 'publish-runtime', 'package.json'); + const npmPackage = context.isPackaged && existsSync(packagedNpmPackage) ? packagedNpmPackage : require.resolve('npm/package.json'); const npmCli = join(dirname(npmPackage), 'bin', 'npm-cli.js'); diff --git a/electron/services/release-job.ts b/electron/services/release-job.ts new file mode 100644 index 0000000..5b4f6dc --- /dev/null +++ b/electron/services/release-job.ts @@ -0,0 +1,196 @@ +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { resolve } from 'node:path'; +import { + prepareProjectRelease, + ProjectReleaseBuildError, + type PreparedRelease, + type ReleaseBuildProgress, + type ReleaseBuildRuntimeContext, +} from './project-release-builder'; + +type ReleaseJobPackageSummary = Omit; + +export type ReleaseJobState = 'queued' | 'running' | 'complete' | 'failed' | 'cancelled'; + +export type ReleaseJobStatus = { + jobId: string; + projectId: string; + state: ReleaseJobState; + phase: ReleaseBuildProgress['phase'] | 'queued'; + percent: number; + errorCode?: string; + errorMessage?: string; + result?: { + package: ReleaseJobPackageSummary; + contract: PreparedRelease['contract']; + }; +}; + +/** + * A utility-process build only needs to return the immutable package summary + * and contract to Main. Archive bytes stay in the worker and are discarded + * before it exits, so a release status request cannot duplicate a large build + * buffer in the browser process. + */ +export type ReleaseJobPreparation = { + sourceArchive: Pick; + contract: PreparedRelease['contract']; + dispose(): Promise; +}; + +export type ReleaseJobPrepare = (input: { + projectPath: string; + clientVersion: string; + signal?: AbortSignal; + onProgress?: (progress: ReleaseBuildProgress) => void; + runtimeContext?: ReleaseBuildRuntimeContext; +}) => Promise; + +type ReleaseJobRecord = ReleaseJobStatus & { + controller: AbortController; + projectPath: string; + projectKey: string; +}; + +export type ReleaseJobManagerOptions = { + prepare?: ReleaseJobPrepare; + acquireLease?: (lease: { id: string; kind: string }) => () => void; + dispose?: () => void; +}; + +/** + * Owns the asynchronous local release build contract. The manager is kept + * independent of HTTP so Main can expose start/progress/status/cancel over + * either Host API or IPC without putting project state in the Renderer. + */ +export class ReleaseJobManager extends EventEmitter { + private readonly jobs = new Map(); + private readonly activeProjects = new Map(); + private readonly prepare: ReleaseJobPrepare; + + constructor(private readonly options: ReleaseJobManagerOptions = {}) { + super(); + this.prepare = options.prepare ?? prepareProjectRelease; + } + + start(input: { projectId: string; projectPath: string; clientVersion: string }): ReleaseJobStatus { + const projectKey = resolve(input.projectPath); + if (this.activeProjects.has(projectKey)) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_BUSY'); + } + const jobId = randomUUID(); + const record: ReleaseJobRecord = { + jobId, + projectId: input.projectId, + projectPath: input.projectPath, + projectKey, + state: 'queued', + phase: 'queued', + percent: 0, + controller: new AbortController(), + }; + this.jobs.set(jobId, record); + this.activeProjects.set(projectKey, jobId); + void this.run(record, input.clientVersion); + return this.snapshot(record); + } + + get(jobId: string): ReleaseJobStatus | null { + const record = this.jobs.get(jobId); + return record ? this.snapshot(record) : null; + } + + cancel(jobId: string): ReleaseJobStatus | null { + const record = this.jobs.get(jobId); + if (!record) return null; + if (record.state === 'queued' || record.state === 'running') { + record.controller.abort(); + this.update(record, { + state: 'cancelled', + errorCode: 'LOCAL_BUILD_CANCELLED', + errorMessage: '构建已取消。', + }); + } + return this.snapshot(record); + } + + dispose(): void { + for (const record of this.jobs.values()) record.controller.abort(); + this.jobs.clear(); + this.activeProjects.clear(); + this.options.dispose?.(); + this.removeAllListeners(); + } + + private async run(record: ReleaseJobRecord, clientVersion: string): Promise { + const releaseLease = this.options.acquireLease?.({ id: `release-job:${record.jobId}`, kind: 'release-build' }); + let prepared: PreparedRelease | null = null; + this.update(record, { state: 'running', phase: 'packaging', percent: 1 }); + try { + prepared = await this.prepare({ + projectPath: record.projectPath, + clientVersion, + signal: record.controller.signal, + onProgress: (progress) => this.update(record, { + state: record.controller.signal.aborted ? 'cancelled' : 'running', + phase: progress.phase, + percent: progress.percent, + }), + }); + if (record.controller.signal.aborted) { + this.update(record, { + state: 'cancelled', + errorCode: 'LOCAL_BUILD_CANCELLED', + errorMessage: '构建已取消。', + }); + return; + } + this.update(record, { + state: 'complete', + phase: 'complete', + percent: 100, + result: { + package: (() => { + const { archivePath: _archivePath, ...summary } = prepared.sourceArchive.summary; + return summary; + })(), + contract: prepared.contract, + }, + }); + } catch (error) { + const code = error instanceof ProjectReleaseBuildError + ? error.code + : 'LOCAL_BUILD_FAILED'; + const cancelled = code === 'LOCAL_BUILD_CANCELLED' || record.controller.signal.aborted; + this.update(record, { + state: cancelled ? 'cancelled' : 'failed', + errorCode: cancelled ? 'LOCAL_BUILD_CANCELLED' : code, + errorMessage: cancelled ? '构建已取消。' : (error instanceof Error ? error.message : String(error)), + }); + } finally { + releaseLease?.(); + await prepared?.dispose().catch(() => undefined); + if (this.activeProjects.get(record.projectKey) === record.jobId) { + this.activeProjects.delete(record.projectKey); + } + } + } + + private update(record: ReleaseJobRecord, patch: Partial): void { + Object.assign(record, patch); + const status = this.snapshot(record); + this.emit('status', status); + this.emit(`status:${record.jobId}`, status); + } + + private snapshot(record: ReleaseJobRecord): ReleaseJobStatus { + const { + controller: _controller, + projectKey: _projectKey, + projectPath: _projectPath, + ...status + } = record; + return structuredClone(status); + } +} diff --git a/electron/services/release-utility-process.ts b/electron/services/release-utility-process.ts new file mode 100644 index 0000000..be8d7df --- /dev/null +++ b/electron/services/release-utility-process.ts @@ -0,0 +1,191 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { utilityProcess, type UtilityProcess } from 'electron'; +import { + prepareProjectReleaseSummary, + ProjectReleaseBuildError, + type ReleaseBuildProgress, + type ReleaseBuildRuntimeContext, +} from './project-release-builder'; +import type { ReleaseJobPrepare, ReleaseJobPreparation } from './release-job'; + +type UtilityStartMessage = { + type: 'start'; + jobId: string; + projectPath: string; + clientVersion: string; + runtimeContext: ReleaseBuildRuntimeContext; +}; + +type UtilityCancelMessage = { + type: 'cancel'; + jobId: string; +}; + +type UtilityMessage = + | { type: 'progress'; jobId: string; progress: ReleaseBuildProgress } + | { + type: 'complete'; + jobId: string; + packageSummary: ReleaseJobPreparation['sourceArchive']['summary']; + contract: ReleaseJobPreparation['contract']; + } + | { + type: 'error'; + jobId: string; + code: string; + message: string; + }; + +type UtilityProcessFactoryOptions = { + workerPath?: string; + runtimeContext: ReleaseBuildRuntimeContext; + /** Useful for diagnostics and a safe development fallback before a build. */ + forceDirect?: boolean; +}; + +export type ReleaseUtilityPreparer = { + prepare: ReleaseJobPrepare; + dispose(): void; +}; + +const knownBuildCodes = new Set([ + 'LOCAL_BUILD_RUNTIME_UNAVAILABLE', + 'LOCAL_BUILD_FAILED', + 'LOCAL_BUILD_TIMEOUT', + 'LOCAL_BUILD_CANCELLED', + 'LOCAL_BUILD_OUTPUT_MISSING', + 'LOCAL_BUILD_BUSY', +]); + +function workerMessage(value: unknown): UtilityMessage | null { + const candidate = value && typeof value === 'object' && 'data' in value + ? (value as { data?: unknown }).data + : value; + if (!candidate || typeof candidate !== 'object') return null; + const message = candidate as Partial; + return typeof message.type === 'string' && typeof message.jobId === 'string' + ? message as UtilityMessage + : null; +} + +function toBuildError(code: string, message?: string): ProjectReleaseBuildError { + const safeCode = knownBuildCodes.has(code) + ? code as ConstructorParameters[0] + : 'LOCAL_BUILD_FAILED'; + const error = new ProjectReleaseBuildError(safeCode); + if (message && message !== error.message) { + error.message = message; + } + return error; +} + +function defaultWorkerPath(): string { + // Main is bundled into dist-electron/main; the utility entry is emitted next + // to it so the packaged asar contains an explicit, inspectable worker. + return join(__dirname, '../utility/release-utility-worker.js'); +} + +export function createReleaseUtilityPreparer( + options: UtilityProcessFactoryOptions, +): ReleaseUtilityPreparer { + const workers = new Set(); + const workerPath = options.workerPath ?? defaultWorkerPath(); + const canFork = !options.forceDirect + && process.env.NIANCODE_DISABLE_RELEASE_UTILITY !== '1' + && existsSync(workerPath); + + const prepare: ReleaseJobPrepare = async (input) => { + if (!canFork) { + return await prepareProjectReleaseSummary({ + ...input, + runtimeContext: { ...options.runtimeContext, ...input.runtimeContext }, + }); + } + + const child = utilityProcess.fork(workerPath, [], { + serviceName: 'Makelore Release Build', + stdio: 'ignore', + }); + workers.add(child); + const jobId = randomUUID(); + + return await new Promise((resolve, reject) => { + let settled = false; + let cancelTimer: ReturnType | null = null; + + const cleanup = (): void => { + workers.delete(child); + if (cancelTimer !== null) clearTimeout(cancelTimer); + input.signal?.removeEventListener('abort', abort); + }; + + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; + + const abort = (): void => { + if (settled) return; + child.postMessage({ type: 'cancel', jobId } satisfies UtilityCancelMessage); + cancelTimer = setTimeout(() => { + if (!settled) { + child.kill(); + finish(() => reject(new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED'))); + } + }, 2_000); + }; + + child.on('message', (value) => { + const message = workerMessage(value); + if (!message || message.jobId !== jobId) return; + if (message.type === 'progress') { + input.onProgress?.(message.progress); + return; + } + if (message.type === 'complete') { + finish(() => resolve({ + sourceArchive: { summary: message.packageSummary }, + contract: message.contract, + dispose: async () => undefined, + })); + return; + } + if (message.type === 'error') { + finish(() => reject(toBuildError(message.code, message.message))); + } + }); + child.on('error', () => { + finish(() => reject(new ProjectReleaseBuildError('LOCAL_BUILD_RUNTIME_UNAVAILABLE'))); + }); + child.on('exit', (code) => { + if (settled) return; + finish(() => reject( + input.signal?.aborted + ? new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED') + : new ProjectReleaseBuildError(code === 0 ? 'LOCAL_BUILD_FAILED' : 'LOCAL_BUILD_RUNTIME_UNAVAILABLE'), + )); + }); + + input.signal?.addEventListener('abort', abort, { once: true }); + child.postMessage({ + type: 'start', + jobId, + projectPath: input.projectPath, + clientVersion: input.clientVersion, + runtimeContext: { ...options.runtimeContext, ...input.runtimeContext }, + } satisfies UtilityStartMessage); + }); + }; + + return { + prepare, + dispose: () => { + for (const child of workers) child.kill(); + workers.clear(); + }, + }; +} diff --git a/electron/services/release-utility-worker.ts b/electron/services/release-utility-worker.ts new file mode 100644 index 0000000..fb07ffd --- /dev/null +++ b/electron/services/release-utility-worker.ts @@ -0,0 +1,90 @@ +import { parentPort } from 'electron'; +import { + prepareProjectReleaseSummary, + ProjectReleaseBuildError, + type ReleaseBuildRuntimeContext, +} from './project-release-builder'; + +type StartMessage = { + type: 'start'; + jobId: string; + projectPath: string; + clientVersion: string; + runtimeContext: ReleaseBuildRuntimeContext; +}; + +type CancelMessage = { + type: 'cancel'; + jobId: string; +}; + +type WorkerMessage = StartMessage | CancelMessage; + +const controllers = new Map(); + +function readMessage(value: unknown): WorkerMessage | null { + const candidate = value && typeof value === 'object' && 'data' in value + ? (value as { data?: unknown }).data + : value; + if (!candidate || typeof candidate !== 'object') return null; + const message = candidate as Partial; + if (message.type !== 'start' && message.type !== 'cancel') return null; + return message as WorkerMessage; +} + +function post(message: unknown): void { + parentPort?.postMessage(message); +} + +async function run(message: StartMessage): Promise { + const controller = new AbortController(); + controllers.set(message.jobId, controller); + try { + const prepared = await prepareProjectReleaseSummary({ + projectPath: message.projectPath, + clientVersion: message.clientVersion, + runtimeContext: message.runtimeContext, + signal: controller.signal, + onProgress: (progress) => post({ type: 'progress', jobId: message.jobId, progress }), + }); + try { + if (controller.signal.aborted) { + throw new ProjectReleaseBuildError('LOCAL_BUILD_CANCELLED'); + } + post({ + type: 'complete', + jobId: message.jobId, + packageSummary: prepared.sourceArchive.summary, + contract: prepared.contract, + }); + } finally { + await prepared.dispose(); + } + } catch (error) { + const code = error instanceof ProjectReleaseBuildError + ? error.code + : 'LOCAL_BUILD_FAILED'; + post({ + type: 'error', + jobId: message.jobId, + code, + message: error instanceof Error ? error.message : String(error), + }); + } finally { + controllers.delete(message.jobId); + // The worker is single-job by design. Exiting here releases any native + // module state and makes the process count observable and bounded. + setImmediate(() => process.exit(0)); + } +} + +parentPort?.on('message', (value) => { + const message = readMessage(value); + if (!message) return; + if (message.type === 'cancel') { + controllers.get(message.jobId)?.abort(); + return; + } + if (controllers.has(message.jobId)) return; + void run(message); +}); diff --git a/electron/services/streaming-zip.ts b/electron/services/streaming-zip.ts new file mode 100644 index 0000000..aa0f25b --- /dev/null +++ b/electron/services/streaming-zip.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { open } from 'node:fs/promises'; + +const ZIP_LOCAL_FILE_HEADER = 0x04034b50; +const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50; +const ZIP_DATA_DESCRIPTOR = 0x08074b50; +const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; +const ZIP_VERSION = 20; +const ZIP_DATA_DESCRIPTOR_FLAG = 0x0008; +const ZIP_STORE_METHOD = 0; +const ZIP_FILE_MODE = 0o100644; +const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0); + +export type StreamingZipEntry = { + path: string; + source: { filePath: string } | { bytes: Buffer }; + beforeRead?: () => Promise; + afterRead?: (result: { crc32: number; size: number }) => Promise; +}; + +export type StreamingZipResult = { + archiveBytes: number; + sha256: string; +}; + +type CentralEntry = { + path: string; + crc32: number; + size: number; + offset: number; +}; + +type OutputState = { + error: Error | null; +}; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < table.length; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + table[index] = value >>> 0; + } + return table; +})(); + +function updateCrc32(current: number, chunk: Uint8Array): number { + let value = current ^ 0xffffffff; + for (const byte of chunk) value = CRC_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8); + return (value ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date: Date): { time: number; day: number } { + const year = Math.max(1980, Math.min(2107, date.getFullYear())); + return { + time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), + day: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + }; +} + +function pathBytes(path: string): Buffer { + const bytes = Buffer.from(path, 'utf8'); + if (bytes.length > 0xffff) throw new Error('ZIP entry path is too long'); + return bytes; +} + +function localHeader(path: Buffer, date: Date): Buffer { + const header = Buffer.alloc(30 + path.length); + const dos = dosDateTime(date); + header.writeUInt32LE(ZIP_LOCAL_FILE_HEADER, 0); + header.writeUInt16LE(ZIP_VERSION, 4); + header.writeUInt16LE(ZIP_DATA_DESCRIPTOR_FLAG, 6); + header.writeUInt16LE(ZIP_STORE_METHOD, 8); + header.writeUInt16LE(dos.time, 10); + header.writeUInt16LE(dos.day, 12); + header.writeUInt16LE(0, 14); + header.writeUInt32LE(0, 18); + header.writeUInt32LE(0, 22); + header.writeUInt16LE(path.length, 26); + header.writeUInt16LE(0, 28); + path.copy(header, 30); + return header; +} + +function dataDescriptor(crc32: number, size: number): Buffer { + const descriptor = Buffer.alloc(16); + descriptor.writeUInt32LE(ZIP_DATA_DESCRIPTOR, 0); + descriptor.writeUInt32LE(crc32 >>> 0, 4); + descriptor.writeUInt32LE(size >>> 0, 8); + descriptor.writeUInt32LE(size >>> 0, 12); + return descriptor; +} + +function centralHeader(entry: CentralEntry, date: Date): Buffer { + const path = pathBytes(entry.path); + const header = Buffer.alloc(46 + path.length); + const dos = dosDateTime(date); + header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_HEADER, 0); + header.writeUInt16LE(ZIP_VERSION, 4); + header.writeUInt16LE(ZIP_VERSION, 6); + header.writeUInt16LE(ZIP_DATA_DESCRIPTOR_FLAG, 8); + header.writeUInt16LE(ZIP_STORE_METHOD, 10); + header.writeUInt16LE(dos.time, 12); + header.writeUInt16LE(dos.day, 14); + header.writeUInt32LE(entry.crc32 >>> 0, 16); + header.writeUInt32LE(entry.size >>> 0, 20); + header.writeUInt32LE(entry.size >>> 0, 24); + header.writeUInt16LE(path.length, 28); + header.writeUInt16LE(0, 30); + header.writeUInt16LE(0, 32); + header.writeUInt16LE(0, 34); + header.writeUInt16LE(0, 36); + header.writeUInt32LE((ZIP_FILE_MODE << 16) >>> 0, 38); + header.writeUInt32LE(entry.offset >>> 0, 42); + path.copy(header, 46); + return header; +} + +function endOfCentralDirectory(count: number, size: number, offset: number): Buffer { + const footer = Buffer.alloc(22); + footer.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY, 0); + footer.writeUInt16LE(0, 4); + footer.writeUInt16LE(0, 6); + footer.writeUInt16LE(count, 8); + footer.writeUInt16LE(count, 10); + footer.writeUInt32LE(size, 12); + footer.writeUInt32LE(offset, 16); + footer.writeUInt16LE(0, 20); + return footer; +} + +async function writeChunk( + output: ReturnType, + hash: ReturnType, + chunk: Buffer, + state: OutputState, +): Promise { + if (state.error) throw state.error; + hash.update(chunk); + if (output.write(chunk)) return; + await new Promise((resolve, reject) => { + const onDrain = () => { + cleanup(); + resolve(); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + output.removeListener('drain', onDrain); + output.removeListener('error', onError); + }; + output.once('drain', onDrain); + output.once('error', onError); + }); + if (state.error) throw state.error; +} + +async function writeEntryData( + output: ReturnType, + hash: ReturnType, + entry: StreamingZipEntry, + state: OutputState, + signal?: AbortSignal, +): Promise<{ crc32: number; size: number }> { + let crc32 = 0; + let size = 0; + const writeData = async (chunk: Buffer): Promise => { + if (signal?.aborted) throw new Error('ZIP_WRITE_CANCELLED'); + crc32 = updateCrc32(crc32, chunk); + size += chunk.length; + if (size > 0xffffffff) throw new Error('ZIP entry exceeds 4 GiB limit'); + await writeChunk(output, hash, chunk, state); + }; + + if ('bytes' in entry.source) { + await entry.beforeRead?.(); + await writeData(entry.source.bytes); + } else { + // Open the file before reading so a later path replacement cannot redirect + // the stream outside the package scan's validated inode. The explicit + // open also lets callers validate a stable-file race immediately before + // the descriptor is acquired. + const handle = await open(entry.source.filePath, 'r'); + try { + await entry.beforeRead?.(); + const input = handle.createReadStream({ autoClose: false }); + for await (const chunk of input) { + await writeData(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + } finally { + await handle.close(); + } + } + await entry.afterRead?.({ crc32, size }); + return { crc32, size }; +} + +/** Writes a deterministic, uncompressed ZIP without retaining file contents. */ +export async function writeStoredZip( + entries: readonly StreamingZipEntry[], + archivePath: string, + options: { signal?: AbortSignal; date?: Date } = {}, +): Promise { + if (entries.length > 0xffff) throw new Error('ZIP contains too many entries'); + const date = options.date ?? FIXED_ZIP_TIME; + const output = createWriteStream(archivePath, { mode: 0o600 }); + const outputState: OutputState = { error: null }; + output.on('error', (error) => { + outputState.error = error; + }); + const hash = createHash('sha256'); + const central: CentralEntry[] = []; + let offset = 0; + + try { + for (const entry of entries) { + const path = pathBytes(entry.path); + const start = offset; + const header = localHeader(path, date); + await writeChunk(output, hash, header, outputState); + offset += header.length; + const data = await writeEntryData(output, hash, entry, outputState, options.signal); + offset += data.size; + const descriptor = dataDescriptor(data.crc32, data.size); + await writeChunk(output, hash, descriptor, outputState); + offset += descriptor.length; + central.push({ path: entry.path, crc32: data.crc32, size: data.size, offset: start }); + } + + const centralOffset = offset; + for (const entry of central) { + const header = centralHeader(entry, date); + await writeChunk(output, hash, header, outputState); + offset += header.length; + } + const footer = endOfCentralDirectory(central.length, offset - centralOffset, centralOffset); + await writeChunk(output, hash, footer, outputState); + offset += footer.length; + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => output.removeListener('error', onError); + output.once('error', onError); + output.end(() => { + cleanup(); + if (outputState.error) reject(outputState.error); + else resolve(); + }); + }); + return { archiveBytes: offset, sha256: hash.digest('hex') }; + } catch (error) { + output.destroy(); + throw error; + } +} diff --git a/electron/services/works-square-session-persistence-policy.ts b/electron/services/works-square-session-persistence-policy.ts new file mode 100644 index 0000000..1331408 --- /dev/null +++ b/electron/services/works-square-session-persistence-policy.ts @@ -0,0 +1,8 @@ +export function shouldUseSecureWorksSquareSessionPersistence( + isPackaged: boolean, +): boolean { + // An unpackaged Electron binary does not have the stable code identity that + // macOS Keychain requires. Keep development credentials in memory so local + // startup never creates or opens an OS credential item. + return isPackaged; +} diff --git a/electron/services/works-square-session.ts b/electron/services/works-square-session.ts index c45de7c..1a7f9af 100644 --- a/electron/services/works-square-session.ts +++ b/electron/services/works-square-session.ts @@ -55,6 +55,13 @@ export interface WorksSquareSessionPersistence { save(session: WorksSquareSessionInput | null): Promise; } +export interface WorksSquareSecureStorage { + isEncryptionAvailable(): boolean; + getSelectedStorageBackend(): string; + encryptString(plainText: string): Buffer; + decryptString(encrypted: Buffer): string; +} + export type WorksSquareSessionChangeReason = 'changed' | 'terminal'; export type WorksSquareSessionListener = ( session: WorksSquareSessionSnapshot | null, @@ -279,11 +286,10 @@ function replaceCredentialSession( return { generation, persisted, previousSession }; } -async function createElectronSessionPersistence(): Promise { - const [{ default: Store }, { safeStorage }] = await Promise.all([ - import('electron-store'), - import('electron'), - ]); +async function createElectronSessionPersistence( + safeStorage: WorksSquareSecureStorage, +): Promise { + const { default: Store } = await import('electron-store'); const usesUnprotectedLinuxBackend = process.platform === 'linux' && safeStorage.getSelectedStorageBackend() === 'basic_text'; if (!safeStorage.isEncryptionAvailable() || usesUnprotectedLinuxBackend) { @@ -347,16 +353,20 @@ async function createElectronSessionPersistence(): Promise { persistenceQueue = Promise.resolve(); credentialPersistenceBarrier = null; startupRuntimeCleanupRequired = false; - sessionPersistenceFactory = options.persistence ? null : createElectronSessionPersistence; + const secureStorage = options.secureStorage ?? null; + sessionPersistenceFactory = options.persistence || !secureStorage + ? null + : () => createElectronSessionPersistence(secureStorage); try { sessionPersistence = options.persistence - ?? await sessionPersistenceFactory!(); + ?? (sessionPersistenceFactory ? await sessionPersistenceFactory() : null); } catch (error) { logger.warn('[works-square-session] Failed to initialize secure session persistence', error); sessionPersistence = null; diff --git a/electron/services/works-submission-binding.ts b/electron/services/works-submission-binding.ts index 28fdb72..de45643 100644 --- a/electron/services/works-submission-binding.ts +++ b/electron/services/works-submission-binding.ts @@ -17,7 +17,8 @@ const LEGACY_PENDING_STATUSES = new Set([ ]); const LEGACY_RETIRED_MESSAGE = '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。'; -type ProjectStore = Pick; +type ProjectStore = Pick + & Partial>; type SubmittedBindingInput = { appId: string; @@ -211,8 +212,24 @@ export function createWorksSubmissionBindingStore(projectStore: ProjectStore) { return project ? readWorksSubmissionBinding(project.path, project.id) : null; } - async function start(): Promise { - for (const project of await projectStore.listProjects()) { + async function start(projectId?: string | null): Promise { + // Migration is deliberately scoped to the active project. The previous + // implementation enumerated every remembered project during app startup, + // which turned a compatibility read into a cold-start I/O spike. Tests + // and legacy stores that expose only listProjects retain a safe + // single-project fallback. + let projects: Awaited>; + if (projectId) { + projects = (await projectStore.listProjects()).filter((project) => project.id === projectId); + } else if (projectStore.getActiveProject) { + const activeProject = await projectStore.getActiveProject(); + projects = activeProject ? [activeProject] : []; + } else { + const listedProjects = await projectStore.listProjects(); + projects = listedProjects.length === 1 ? listedProjects : []; + } + + for (const project of projects) { try { await readWorksSubmissionBinding(project.path, project.id); } catch (error) { diff --git a/electron/utils/proxy-fetch.ts b/electron/utils/proxy-fetch.ts index cd9c63e..da1c23d 100644 --- a/electron/utils/proxy-fetch.ts +++ b/electron/utils/proxy-fetch.ts @@ -4,13 +4,14 @@ * for non-Electron test environments. */ +import { net } from 'electron'; + export async function proxyAwareFetch( input: string | URL, init?: RequestInit ): Promise { if (process.versions.electron) { try { - const { net } = await import('electron'); return await net.fetch(input, init); } catch { // Fall through to the global fetch. diff --git a/package.json b/package.json index 88c810b..b63da88 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "dev:image-workspace:local": "node scripts/run-local-image-workspace-dev.mjs", "build": "pnpm run sync:learning-player && pnpm run build:vite && node scripts/bundle-opencode.mjs && node scripts/run-electron-builder.mjs", "build:vite": "node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", + "perf:budget": "node scripts/check-performance-budget.mjs", "bundle:opencode": "node scripts/bundle-opencode.mjs", "sync:learning-player": "node scripts/sync-learning-player.mjs", "lint": "eslint . --fix", @@ -82,33 +83,24 @@ "postversion": "node scripts/post-version-push.mjs" }, "dependencies": { - "@gsap/react": "^2.1.2", + "@napi-rs/canvas": "0.1.100", "@monaco-editor/react": "^4.7.0", "@playwright/mcp": "0.0.78", - "@sinclair/typebox": "^0.34.48", - "@types/diff": "^8.0.0", "adm-zip": "0.5.16", - "chokidar": "^5.0.0", "diff": "^9.0.0", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", - "gsap": "^3.15.0", "katex": "^0.16.45", "koffi": "2.16.3", - "lru-cache": "^11.2.6", "monaco-editor": "^0.55.1", "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", "rehype-katex": "^7.0.1", - "remark-frontmatter": "^5.0.0", "remark-math": "^6.0.0", - "tar": "^6.2.1", "ws": "^8.19.0", - "xlsx": "^0.18.5", "yaml": "2.8.3" }, "devDependencies": { @@ -150,12 +142,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "discord-api-types": "^0.38.47", - "electron": "40.10.6", + "electron": "43.4.0", "electron-builder": "^26.8.1", "eslint": "^10.0.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.0", - "framer-motion": "^12.34.2", "globals": "^17.3.0", "grammy": "^1.42.0", "https-proxy-agent": "^9.0.0", @@ -180,6 +171,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", + "tar": "^6.2.1", "typescript": "^5.9.3", "undici": "8.1.0", "use-stick-to-bottom": "^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83cc4be..4a8d2d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,27 +11,18 @@ importers: .: dependencies: - '@gsap/react': - specifier: ^2.1.2 - version: 2.1.2(gsap@3.15.0)(react@19.2.4) '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@napi-rs/canvas': + specifier: 0.1.100 + version: 0.1.100 '@playwright/mcp': specifier: 0.0.78 version: 0.0.78 - '@sinclair/typebox': - specifier: ^0.34.48 - version: 0.34.48 - '@types/diff': - specifier: ^8.0.0 - version: 8.0.0 adm-zip: specifier: 0.5.16 version: 0.5.16 - chokidar: - specifier: ^5.0.0 - version: 5.0.0 diff: specifier: ^9.0.0 version: 9.0.0 @@ -41,18 +32,12 @@ importers: electron-updater: specifier: ^6.8.3 version: 6.8.3 - gsap: - specifier: ^3.15.0 - version: 3.15.0 katex: specifier: ^0.16.45 version: 0.16.45 koffi: specifier: 2.16.3 version: 2.16.3 - lru-cache: - specifier: ^11.2.6 - version: 11.2.7 monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -68,30 +53,18 @@ importers: npm: specifier: 11.6.2 version: 11.6.2 - pdfjs-dist: - specifier: ^5.7.284 - version: 5.7.284 posthog-node: specifier: ^5.28.0 version: 5.28.5 rehype-katex: specifier: ^7.0.1 version: 7.0.1 - remark-frontmatter: - specifier: ^5.0.0 - version: 5.0.0 remark-math: specifier: ^6.0.0 version: 6.0.0 - tar: - specifier: ^6.2.1 - version: 6.2.1 ws: specifier: ^8.19.0 version: 8.20.0 - xlsx: - specifier: ^0.18.5 - version: 0.18.5 yaml: specifier: 2.8.3 version: 2.8.3 @@ -208,8 +181,8 @@ importers: specifier: ^0.38.47 version: 0.38.47 electron: - specifier: 40.10.6 - version: 40.10.6 + specifier: 43.4.0 + version: 43.4.0 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) @@ -222,9 +195,6 @@ importers: eslint-plugin-react-refresh: specifier: ^0.5.0 version: 0.5.2(eslint@10.1.0(jiti@1.21.7)) - framer-motion: - specifier: ^12.34.2 - version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) globals: specifier: ^17.3.0 version: 17.4.0 @@ -300,6 +270,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + tar: + specifier: ^6.2.1 + version: 6.2.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -799,12 +772,6 @@ packages: '@grammyjs/types@3.26.0': resolution: {integrity: sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==} - '@gsap/react@2.1.2': - resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==} - peerDependencies: - gsap: ^3.12.5 - react: '>=17' - '@homebridge/ciao@1.3.7': resolution: {integrity: sha512-ncvcXQe4vrqBLNqnVjQjke5NpNin6SO9bStfBZ4jgZk/xIjD9GMcH8vp8XKd7hw5akIzwITMiDMysIKvE5rHBw==} hasBin: true @@ -1815,9 +1782,6 @@ packages: cpu: [x64] os: [win32] - '@sinclair/typebox@0.34.48': - resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} - '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1983,9 +1947,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/diff@8.0.0': - resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} - '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -2118,6 +2079,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} @@ -2160,6 +2122,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -2184,10 +2147,6 @@ packages: engines: {node: '>=22.12.0'} hasBin: true - adler-32@1.3.1: - resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} - engines: {node: '>=0.8'} - adm-zip@0.5.16: resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} engines: {node: '>=12.0'} @@ -2391,6 +2350,7 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} @@ -2460,10 +2420,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - cfb@1.2.2: - resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} - engines: {node: '>=0.8'} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2488,10 +2444,6 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -2541,10 +2493,6 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - codepage@1.15.0: - resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2600,11 +2548,6 @@ packages: core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} @@ -2789,8 +2732,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@40.10.6: - resolution: {integrity: sha512-TGjlkOU9Lg6K4KjDbsErywCWCIDaNgLh0q+xj0nlpRoQhevI7VBIxBTtJI/V30lypyLAaXMpnP9O9jui1/qRFw==} + electron@43.4.0: + resolution: {integrity: sha512-3qxGF0CeQbiox5oWV1JlbWGQ1VerbmDhTFqW4sJ8h7uqTHniFYPObXJcDna0DMh32et0fFyKzz0YY8lJv3t5jg==} engines: {node: '>= 22.12.0'} hasBin: true @@ -2975,9 +2918,6 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fault@2.0.1: - resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3029,31 +2969,9 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - format@0.2.2: - resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} - engines: {node: '>=0.4.x'} - - frac@1.1.2: - resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} - engines: {node: '>=0.8'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.38.0: - resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3135,10 +3053,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -3167,9 +3087,6 @@ packages: resolution: {integrity: sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==} engines: {node: ^12.20.0 || >=14.13.1} - gsap@3.15.0: - resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3296,6 +3213,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3481,6 +3399,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3557,9 +3476,6 @@ packages: mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} - mdast-util-frontmatter@2.0.1: - resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} - mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} @@ -3612,9 +3528,6 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - micromark-extension-frontmatter@2.0.0: - resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} - micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -3806,12 +3719,6 @@ packages: monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} - motion-dom@12.38.0: - resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} - - motion-utils@12.36.0: - resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} - mpg123-decoder@1.0.3: resolution: {integrity: sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==} @@ -4117,10 +4024,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pdfjs-dist@5.7.284: - resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==} - engines: {node: '>=22.13.0 || >=24'} - pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -4413,10 +4316,6 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -4424,9 +4323,6 @@ packages: rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} - remark-frontmatter@5.0.0: - resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} - remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4636,10 +4532,6 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - ssf@0.11.2: - resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} - engines: {node: '>=0.8'} - ssri@12.0.0: resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4741,6 +4633,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.13: resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} @@ -5122,18 +5015,10 @@ packages: engines: {node: '>=8'} hasBin: true - wmf@1.0.2: - resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} - engines: {node: '>=0.8'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - word@0.3.0: - resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} - engines: {node: '>=0.8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5157,11 +5042,6 @@ packages: utf-8-validate: optional: true - xlsx@0.18.5: - resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} - engines: {node: '>=0.8'} - hasBin: true - xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -5747,11 +5627,6 @@ snapshots: '@grammyjs/types@3.26.0': {} - '@gsap/react@2.1.2(gsap@3.15.0)(react@19.2.4)': - dependencies: - gsap: 3.15.0 - react: 19.2.4 - '@homebridge/ciao@1.3.7': dependencies: debug: 4.4.3 @@ -6002,7 +5877,6 @@ snapshots: '@napi-rs/canvas-linux-x64-musl': 0.1.100 '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 '@napi-rs/canvas-win32-x64-msvc': 0.1.100 - optional: true '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.9.1)': dependencies: @@ -6614,8 +6488,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true - '@sinclair/typebox@0.34.48': {} - '@sindresorhus/is@4.6.0': {} '@snazzah/davey-android-arm-eabi@0.1.11': @@ -6780,10 +6652,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/diff@8.0.0': - dependencies: - diff: 9.0.0 - '@types/esrecurse@4.3.1': {} '@types/estree-jsx@1.0.5': @@ -7037,8 +6905,6 @@ snapshots: - bare-buffer - react-native-b4a - adler-32@1.3.1: {} - adm-zip@0.5.16: {} agent-base@7.1.4: {} @@ -7352,11 +7218,6 @@ snapshots: ccount@2.0.1: {} - cfb@1.2.2: - dependencies: - adler-32: 1.3.1 - crc-32: 1.2.2 - chai@6.2.2: {} chalk@4.1.2: @@ -7384,10 +7245,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - chownr@2.0.0: {} chownr@3.0.0: {} @@ -7428,8 +7285,6 @@ snapshots: clsx@2.1.1: {} - codepage@1.15.0: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -7476,8 +7331,6 @@ snapshots: core-util-is@1.0.2: optional: true - crc-32@1.2.2: {} - crc@3.8.0: dependencies: buffer: 5.7.1 @@ -7725,7 +7578,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron@40.10.6: + electron@43.4.0: dependencies: '@electron-internal/extract-zip': 1.0.4 '@electron/get': 5.0.0 @@ -7949,10 +7802,6 @@ snapshots: dependencies: reusify: 1.1.0 - fault@2.0.1: - dependencies: - format: 0.2.2 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -7998,21 +7847,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - format@0.2.2: {} - - frac@1.1.2: {} - fraction.js@5.3.4: {} - framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - motion-dom: 12.38.0 - motion-utils: 12.36.0 - tslib: 2.8.1 - optionalDependencies: - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -8166,8 +8002,6 @@ snapshots: - encoding - supports-color - gsap@3.15.0: {} - has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -8598,17 +8432,6 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-frontmatter@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - escape-string-regexp: 5.0.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - micromark-extension-frontmatter: 2.0.0 - transitivePeerDependencies: - - supports-color - mdast-util-gfm-autolink-literal@2.0.1: dependencies: '@types/mdast': 4.0.4 @@ -8773,13 +8596,6 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-frontmatter@2.0.0: - dependencies: - fault: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -9055,12 +8871,6 @@ snapshots: dompurify: 3.2.7 marked: 14.0.0 - motion-dom@12.38.0: - dependencies: - motion-utils: 12.36.0 - - motion-utils@12.36.0: {} - mpg123-decoder@1.0.3: dependencies: '@wasm-audio-decoders/common': 9.0.7 @@ -9288,10 +9098,6 @@ snapshots: pathe@2.0.3: {} - pdfjs-dist@5.7.284: - optionalDependencies: - '@napi-rs/canvas': 0.1.100 - pe-library@0.4.1: {} picocolors@1.1.1: {} @@ -9544,8 +9350,6 @@ snapshots: dependencies: picomatch: 2.3.2 - readdirp@5.0.0: {} - redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -9561,15 +9365,6 @@ snapshots: unist-util-visit-parents: 6.0.2 vfile: 6.0.3 - remark-frontmatter@5.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-frontmatter: 2.0.1 - micromark-extension-frontmatter: 2.0.0 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -9856,10 +9651,6 @@ snapshots: sprintf-js@1.1.3: optional: true - ssf@0.11.2: - dependencies: - frac: 1.1.2 - ssri@12.0.0: dependencies: minipass: 7.1.3 @@ -10351,12 +10142,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wmf@1.0.2: {} - word-wrap@1.2.5: {} - word@0.3.0: {} - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -10373,16 +10160,6 @@ snapshots: ws@8.20.0: {} - xlsx@0.18.5: - dependencies: - adler-32: 1.3.1 - cfb: 1.2.2 - codepage: 1.15.0 - crc-32: 1.2.2 - ssf: 0.11.2 - wmf: 1.0.2 - word: 0.3.0 - xml-name-validator@5.0.0: {} xmlbuilder@15.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1719de4..f8b8f2b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,14 @@ allowBuilds: + '@discordjs/opus': true electron: true electron-winstaller: true esbuild: true + koffi: true msgpackr-extract: true + node-llama-cpp: true opencode-ai: true protobufjs: true sharp: true + +overrides: + isbinaryfile: ^5.0.0 diff --git a/scripts/check-performance-budget.mjs b/scripts/check-performance-budget.mjs new file mode 100644 index 0000000..690241a --- /dev/null +++ b/scripts/check-performance-budget.mjs @@ -0,0 +1,56 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { basename, join, resolve } from 'node:path'; + +const root = resolve(process.cwd()); +const dist = resolve(root, 'dist'); +const assets = join(dist, 'assets'); +const budgets = { + initialJsGzip: 350 * 1024, + initialCssGzip: 30 * 1024, + fontsBytes: 2 * 1024 * 1024, + moduleMediaBytes: 1 * 1024 * 1024, +}; + +function readAsset(relativePath) { + return readFileSync(join(dist, relativePath)); +} + +function gzipBytes(bytes) { + return gzipSync(bytes, { level: 9 }).byteLength; +} + +function formatBytes(bytes) { + return `${(bytes / 1024).toFixed(1)} KiB`; +} + +if (!existsSync(join(dist, 'index.html')) || !existsSync(assets)) { + console.error('性能预算检查需要先运行 pnpm run build:vite。'); + process.exit(1); +} + +const html = readFileSync(join(dist, 'index.html'), 'utf8'); +const initialJs = [...html.matchAll(/]+src="\.\/([^\"]+\.js)"/g)] + .map((match) => match[1]) + .reduce((total, relativePath) => total + gzipBytes(readAsset(relativePath)), 0); +const initialCss = [...html.matchAll(/]+href="\.\/([^\"]+\.css)"/g)] + .map((match) => match[1]) + .reduce((total, relativePath) => total + gzipBytes(readAsset(relativePath)), 0); +const assetNames = readdirSync(assets); +const fontBytes = assetNames + .filter((name) => /\.(woff2?|otf|ttf)$/i.test(name)) + .reduce((total, name) => total + statSync(join(assets, name)).size, 0); +const moduleMediaBytes = assetNames + .filter((name) => /^module-.*\.(avif|webp|png|jpe?g)$/i.test(name)) + .reduce((total, name) => total + statSync(join(assets, name)).size, 0); + +const metrics = { initialJsGzip: initialJs, initialCssGzip: initialCss, fontsBytes: fontBytes, moduleMediaBytes }; +console.log(JSON.stringify({ metrics, budgets }, null, 2)); + +const failures = Object.entries(budgets) + .filter(([key, budget]) => metrics[key] > budget) + .map(([key, budget]) => `${key} ${formatBytes(metrics[key])} > ${formatBytes(budget)}`); +if (failures.length > 0) { + console.error(`性能预算超标:${failures.join('; ')}`); + process.exit(1); +} diff --git a/scripts/verify-packaged-electron-runtime.mjs b/scripts/verify-packaged-electron-runtime.mjs index e4afb71..61b2b57 100644 --- a/scripts/verify-packaged-electron-runtime.mjs +++ b/scripts/verify-packaged-electron-runtime.mjs @@ -135,8 +135,8 @@ const installerPath = resolve(option( '--installer', join(root, 'release', `Makelore-${packageJson.version}-win-x64.exe`), )); -const expectedElectron = option('--expected-electron', '40.10.6'); -const expectedNode = option('--expected-node', '24.15.0'); +const expectedElectron = option('--expected-electron', '43.4.0'); +const expectedNode = option('--expected-node', '24.18.1'); const expectedCommit = option('--expected-commit', null); const expectedBuildId = option('--expected-build-id', null); const manifestPath = option('--manifest', null); diff --git a/src/App.tsx b/src/App.tsx index 5dba06e..cb430b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,29 +3,12 @@ * Handles routing and global providers */ import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; -import { Component, useEffect, useState } from 'react'; +import { Component, lazy, Suspense, useEffect, useState } from 'react'; import type { ErrorInfo, ReactNode } from 'react'; import { Toaster } from 'sonner'; import i18n from './i18n'; import { MainLayout } from './components/layout/MainLayout'; import { TooltipProvider } from '@/components/ui/tooltip'; -import { Models } from './pages/Models'; -import { Projects } from './pages/Projects'; -import { Sessions } from './pages/Sessions'; -import { Chat } from './pages/Chat'; -import { Home } from './pages/Home'; -import { PreviewScene } from './pages/Makelore'; -import { ProjectConfiguration } from './pages/ProjectConfiguration'; -import { Workbench } from './pages/Workbench'; -import { ImageCanvas } from './pages/ImageCanvas'; -import { ImagePromptMuseum } from './pages/ImagePromptMuseum'; -import { AiHardware } from './pages/AiHardware'; -import { Settings } from './pages/Settings'; -import { Setup } from './pages/Setup'; -import { Login } from './pages/Login'; -import { ModuleSelection } from './pages/ModuleSelection'; -import { Learning } from './pages/Learning'; -import { LearningCoursePlayer } from './pages/Learning/CoursePlayer'; import { useSettingsStore } from './stores/settings'; import { useProviderStore } from './stores/providers'; import { useAuthStore } from './stores/auth'; @@ -40,8 +23,27 @@ import { import { useUserSyncStore } from './stores/user-sync'; import { flushPendingAgentSessionSync } from '@/lib/agent-session-sync'; import { subscribeHostEvent } from '@/lib/host-events'; +import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api'; +import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics'; import { resolveSupportedLanguage } from '../shared/language'; +const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component }))); +const Projects = lazy(() => import('./pages/Projects').then(({ Projects: component }) => ({ default: component }))); +const Sessions = lazy(() => import('./pages/Sessions').then(({ Sessions: component }) => ({ default: component }))); +const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ default: component }))); +const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component }))); +const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component }))); +const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component }))); +const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component }))); +const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component }))); +const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component }))); +const AiHardware = lazy(() => import('./pages/AiHardware').then(({ AiHardware: component }) => ({ default: component }))); +const Settings = lazy(() => import('./pages/Settings').then(({ Settings: component }) => ({ default: component }))); +const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: component }))); +const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component }))); +const ModuleSelection = lazy(() => import('./pages/ModuleSelection').then(({ ModuleSelection: component }) => ({ default: component }))); +const Learning = lazy(() => import('./pages/Learning').then(({ Learning: component }) => ({ default: component }))); +const LearningCoursePlayer = lazy(() => import('./pages/Learning/CoursePlayer').then(({ LearningCoursePlayer: component }) => ({ default: component }))); /** * Error Boundary to catch and display React rendering errors @@ -123,6 +125,26 @@ function getReturnPath(location: ReturnType): string { return `${location.pathname}${location.search}`; } +function moduleForPath(pathname: string): DesktopActivityModule | null { + const module = getGuardedAiModuleForPath(pathname); + if (module === 'programming') return 'programming'; + if (module === 'painting') return 'painting'; + if (module === 'learning') return 'learning'; + if (module === 'robot') return 'robot'; + return null; +} + +function publishDesktopActivity(input: { + visible: boolean; + module: DesktopActivityModule | null; +}): void { + try { + void Promise.resolve(reportDesktopActivity(input)).catch(() => undefined); + } catch { + // Renderer-only hosts may not expose lifecycle IPC; do not block routing. + } +} + function ProtectedLayout({ authReady, authRequired, @@ -262,6 +284,8 @@ function App() { const authRequired = !skipSetupForE2E && !rendererOnlyPreview; const imageWorkspaceLocalDevelopment = window.electron?.imageWorkspaceLocalDevelopment === true; + useEffect(() => installRendererPerformanceDiagnostics(), []); + useEffect(() => { void initAuth(); }, [initAuth]); @@ -273,6 +297,13 @@ function App() { return unsubscribe; }, []); + useEffect(() => subscribeHostEvent('lifecycle:sleep', () => { + // Main owns the runtime shutdown decision. Once it has confirmed that + // there are no leases, close renderer-owned SSE/listener state as well so + // a hidden shell cannot keep reconnecting against a stopped runtime. + useOpencodeStore.getState().pauseBackgroundStreams(); + }), []); + useEffect(() => { if (rendererOnlyPreview || !authInitialized || !authAccessToken) return; @@ -347,6 +378,19 @@ function App() { }); }, [authenticated, authInitialized, bootstrapUserSync, rendererOnlyPreview, setupReady]); + useEffect(() => { + if (rendererOnlyPreview) return undefined; + const publish = () => { + publishDesktopActivity({ + visible: document.visibilityState === 'visible', + module: moduleForPath(location.pathname), + }); + }; + publish(); + document.addEventListener('visibilitychange', publish, { passive: true }); + return () => document.removeEventListener('visibilitychange', publish); + }, [location.pathname, rendererOnlyPreview]); + // Redirect to setup wizard if not complete useEffect(() => { if (rendererOnlyPreview && location.pathname.startsWith('/setup')) { @@ -389,7 +433,8 @@ function App() { return ( - + }> + {/* Setup wizard (shown on first launch) */} } /> } /> @@ -435,7 +480,8 @@ function App() { } /> } /> - + + {/* Global toast notifications */} handleSelectProject(project.workspaceId)} > @@ -407,24 +407,24 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba {project.title} -
+
navigate('/learning')} className={cn( - 'motion-press flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-semibold transition-colors active:scale-[0.985]', + 'motion-press flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-semibold transition-colors duration-100', catalogActive ? 'bg-brand-soft text-foreground' : 'text-foreground hover:bg-surface-subtle', sidebarCollapsed && 'justify-center px-0', )} @@ -96,7 +96,7 @@ export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) { aria-label="生成课程" onClick={() => navigate('/learning?generate=1')} className={cn( - 'motion-press mt-1 flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-surface-subtle active:scale-[0.985]', + 'motion-press mt-1 flex min-h-9 w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-medium text-foreground transition-colors duration-100 hover:bg-surface-subtle', sidebarCollapsed && 'justify-center px-0', )} > @@ -146,7 +146,7 @@ export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) { disabled={downloading === course.id} onClick={() => void openCourse(course)} className={cn( - 'motion-press group/course flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left transition-[background-color,color,transform] active:scale-[0.985]', + 'motion-press group/course flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left transition-colors duration-100', isActive ? 'bg-brand-selected text-foreground ring-1 ring-brand/20' : 'hover:bg-surface-tertiary', )} > diff --git a/src/components/layout/ModuleSwitcher.tsx b/src/components/layout/ModuleSwitcher.tsx index 22db26d..467960a 100644 --- a/src/components/layout/ModuleSwitcher.tsx +++ b/src/components/layout/ModuleSwitcher.tsx @@ -21,7 +21,7 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false }: { sidebarC title="返回首页" onClick={() => navigate(AI_MODULE_SELECTION_PATH)} className={cn( - 'motion-press items-center bg-transparent text-left font-semibold text-foreground transition-[background-color,color,transform] hover:bg-surface-subtle active:scale-[0.985]', + 'motion-press items-center bg-transparent text-left font-semibold text-foreground transition-colors duration-100 hover:bg-surface-subtle', compactTrigger ? 'inline-flex min-h-9 w-fit translate-y-2 justify-start rounded-xl p-2 text-xs' : 'flex min-h-11 w-full justify-between gap-2 rounded-2xl p-3 text-sm', diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ec242e6..8776a65 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; import { BarChart3, + Bot, Cpu, ChevronRight, Crown, @@ -19,6 +20,13 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DisclosureContent } from '@/components/ui/disclosure'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; +import { getAiHardwareOverview, type AiHardwareOverview } from '@/lib/ai-hardware'; +import { + AI_HARDWARE_NAVIGATION_EVENT, + AI_HARDWARE_OVERVIEW_EVENT, + announceAiHardwareNavigation, + readAiHardwareAgentId, +} from '@/lib/ai-hardware-navigation'; import { getAiModuleForPath } from '@/lib/ai-modules'; import { invokeIpc } from '@/lib/api-client'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; @@ -159,6 +167,9 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const [creatingProject, setCreatingProject] = useState(false); const [projectEntryError, setProjectEntryError] = useState(null); const [removeProjectCandidate, setRemoveProjectCandidate] = useState(null); + const [robotOverview, setRobotOverview] = useState(null); + const [robotOverviewLoading, setRobotOverviewLoading] = useState(false); + const [robotSelectedAgentId, setRobotSelectedAgentId] = useState(() => readAiHardwareAgentId()); const accountButtonRef = useRef(null); const accountMenuRef = useRef(null); const tokenUsageRequestIdRef = useRef(0); @@ -168,6 +179,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; const isLearningModule = activeModule === 'learning'; + const isRobotModule = activeModule === 'robot'; const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const projectConfigPath = '/project-config'; const visibleProjects = opencodeProjects; @@ -182,6 +194,16 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState); const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at); const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at); + const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null; + + const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => { + const params = new URLSearchParams(); + if (agentId) params.set('agent', agentId); + if (openCreate) params.set('create', '1'); + const search = params.toString(); + navigate({ pathname: '/ai-hardware', search: search ? `?${search}` : '' }); + announceAiHardwareNavigation({ agentId, openCreate }); + }, [navigate]); useEffect(() => { if (!isProgrammingModule) return; @@ -200,6 +222,54 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi void refreshProviderSnapshot().catch(() => undefined); }, [isProgrammingModule, refreshProviderSnapshot]); + useEffect(() => { + if (!isRobotModule) { + setRobotOverview(null); + setRobotOverviewLoading(false); + return undefined; + } + + let active = true; + const handleOverview = (event: Event) => { + const next = (event as CustomEvent).detail; + if (!next || !Array.isArray(next.agents) || !Array.isArray(next.devices)) return; + setRobotOverview(next); + setRobotOverviewLoading(false); + }; + window.addEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview); + setRobotOverviewLoading(true); + void getAiHardwareOverview() + .then((next) => { + if (!active) return; + setRobotOverview(next); + setRobotOverviewLoading(false); + }) + .catch(() => { + if (!active) return; + setRobotOverviewLoading(false); + }); + + return () => { + active = false; + window.removeEventListener(AI_HARDWARE_OVERVIEW_EVENT, handleOverview); + }; + }, [isRobotModule]); + + useEffect(() => { + if (!isRobotModule) { + setRobotSelectedAgentId(null); + return undefined; + } + + setRobotSelectedAgentId(readAiHardwareAgentId(location.search)); + const handleNavigation = (event: Event) => { + const detail = (event as CustomEvent<{ agentId?: string | null; openCreate?: boolean }>).detail; + if (!detail?.openCreate && detail?.agentId !== undefined) setRobotSelectedAgentId(detail.agentId); + }; + window.addEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation); + return () => window.removeEventListener(AI_HARDWARE_NAVIGATION_EVENT, handleNavigation); + }, [isRobotModule, location.search]); + useEffect(() => { if (profileRequired || profileSyncError) setProfileDialogOpen(true); }, [profileRequired, profileSyncError]); @@ -455,7 +525,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')} onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')} className={cn( - 'sidebar-glass-surface flex min-h-0 shrink-0 flex-col overflow-hidden border-r border-border/80 text-foreground transition-[width,opacity,box-shadow] duration-300 ease-spring motion-reduce:transition-none', + 'sidebar-glass-surface flex min-h-0 shrink-0 flex-col overflow-hidden border-r border-border/80 text-foreground', sidebarCollapsed ? 'pointer-events-none w-0 border-r-0 opacity-0' : workspaceLayout @@ -489,7 +559,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi type="button" data-testid="sidebar-create-project" className={cn( - 'motion-press mb-1 flex min-h-9 w-full items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors hover:bg-surface-subtle active:scale-[0.985]', + 'motion-press mb-1 flex min-h-9 w-full items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors duration-100 hover:bg-surface-subtle', sidebarCollapsed ? 'justify-center px-0' : '', )} aria-label="新建项目" @@ -536,7 +606,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi aria-current={active ? 'page' : undefined} onClick={() => void openProject(project)} className={cn( - 'motion-press group/project flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left transition-[background-color,color,box-shadow,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1 active:scale-[0.985]', + 'motion-press group/project flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1', active ? 'bg-brand-selected text-foreground shadow-soft ring-1 ring-brand/25' : 'bg-transparent text-foreground hover:bg-surface-tertiary', @@ -574,8 +644,8 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi {!sidebarCollapsed ? ( -

- 管理机器人智能体、设备绑定与配置。 -

+
+
+ 智能体 + +
+
+ {robotOverviewLoading ? ( +

正在读取智能体…

+ ) : robotOverview ? ( + robotOverview.agents.length > 0 ? robotOverview.agents.map((agent) => { + const active = activeRobotAgentId === agent.id; + const deviceCount = robotOverview.devices.filter((device) => device.agent_id === agent.id).length; + return ( + + ); + }) : ( +

还没有智能体,点击右上角 + 创建。

+ ) + ) : ( +

暂时无法加载智能体列表。

+ )} +
+
) : null}
)} @@ -639,7 +755,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi