#!/usr/bin/env node /** * Electron Builder Agent — SF-06 * * 将 SF-05 Fullstack Composer 输出的 Web 全栈项目封装为 Electron 桌面应用。 * * 职责单一:Web Fullstack Project × Desktop Shell = 桌面软件 * 不重新实现:页面生成、API 生成、数据库生成、Auth 生成、CRUD 生成 * * Usage: * node scripts/electron-builder-agent.mjs --input [options] * * @module electron-builder-agent */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(resolve(fileURLToPath(import.meta.url))); const WORKSPACE = resolve(__dirname, ".."); // ═══════════════════════════════════════════════════════ // 1. Package.json Generator // ═══════════════════════════════════════════════════════ function generatePackageJson(projectName, webPort, apiPort) { const safeName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-"); const displayName = projectName || "Desktop App"; return JSON.stringify({ name: safeName + "-desktop", version: "1.0.0", private: true, description: displayName + " — Desktop Application powered by Electron", main: "dist/electron/main.js", scripts: { dev: 'concurrently "npm run dev:web" "npm run dev:api" "npm run dev:electron"', "dev:electron": "tsc && electron .", "dev:web": "cd ../web && npm run dev", "dev:api": "cd ../api && npm run dev", build: "npm run build:web && npm run build:api && npm run build:electron", "build:web": "cd ../web && npm run build", "build:api": "cd ../api && npm run build", "build:electron": "tsc && electron-builder --config electron-builder.yml", "build:electron:win": "tsc && electron-builder --win --config electron-builder.yml", "build:electron:mac": "tsc && electron-builder --mac --config electron-builder.yml", "build:electron:linux": "tsc && electron-builder --linux --config electron-builder.yml", pack: "tsc && electron-builder --dir --config electron-builder.yml", typecheck: "tsc --noEmit", lint: "eslint electron/", }, dependencies: { "electron-updater": "^6.3.9", "electron-store": "^10.0.0", }, devDependencies: { electron: "^35.0.0", "electron-builder": "^25.1.8", concurrently: "^9.1.0", typescript: "^5.7.0", "@types/node": "^22.10.0", }, build: { productName: displayName, appId: "com." + safeName + ".desktop", directories: { output: "release", }, }, }, null, 2); } // ═══════════════════════════════════════════════════════ // 2. electron-builder.yml Generator // ═══════════════════════════════════════════════════════ function generateElectronBuilderYml(projectName) { const safeName = (projectName || "app").toLowerCase().replace(/[^a-z0-9-]/g, "-"); const displayName = projectName || "Desktop App"; return [ "# electron-builder.yml — Auto-generated by SF-06", "", "appId: com." + safeName + ".desktop", "productName: " + displayName, 'copyright: "Copyright © 2026 ' + displayName + '"', "", "# ─── Directories ─────────────────────────────────", "directories:", " output: release", " buildResources: assets", "", "# ─── Files ───────────────────────────────────────", "files:", " - dist/electron/**/*", ' - "!node_modules/**/*"', ' - "**/*.node"', "", "# ─── Extra Resources ─────────────────────────────", "extraResources:", ' - from: "../web/out"', ' to: "web"', " filter:", ' - "**/*"', ' - from: "../api/dist"', ' to: "api"', " filter:", ' - "**/*"', ' - from: "../api/data"', ' to: "data"', " filter:", ' - "**/*"', "", "# ─── macOS ───────────────────────────────────────", "mac:", " category: public.app-category.productivity", " icon: assets/icon.png", " target:", " - target: dmg", " arch:", " - x64", " - arm64", " - target: zip", " arch:", " - x64", " - arm64", " darkModeSupport: true", " hardenedRuntime: true", " gatekeeperAssess: false", " entitlements: entitlements.mac.plist", " entitlementsInherit: entitlements.mac.plist", "", "# ─── Windows ─────────────────────────────────────", "win:", " icon: assets/icon.png", " target:", " - target: nsis", " arch:", " - x64", " - target: portable", " arch:", " - x64", " publisherName: " + displayName, "", "nsis:", " oneClick: false", " perMachine: false", " allowToChangeInstallationDirectory: true", " installerIcon: assets/icon.ico", " uninstallerIcon: assets/icon.ico", " installerHeaderIcon: assets/icon.ico", " createDesktopShortcut: true", " createStartMenuShortcut: true", " shortcutName: " + displayName, "", "# ─── Linux ───────────────────────────────────────", "linux:", " icon: assets/icon.png", " category: Utility", " target:", " - target: AppImage", " arch:", " - x64", " - target: deb", " arch:", " - x64", " - target: rpm", " arch:", " - x64", " desktop:", " Name: " + displayName, " Comment: " + displayName + " Desktop Application", " Terminal: false", " Type: Application", " Categories: Utility;", "", "# ─── Auto Update ─────────────────────────────────", "publish:", " provider: generic", " url: https://releases.example.com/" + safeName + "/", " channel: latest", "", ].join("\n"); } // ═══════════════════════════════════════════════════════ // 3. main.ts — Main Process // ═══════════════════════════════════════════════════════ function generateMainTs(projectName, webPort, apiPort) { const displayName = projectName || "Desktop App"; return 'import { app, BrowserWindow, Menu, nativeImage, ipcMain } from "electron";\n' + 'import { join } from "path";\n' + 'import { existsSync } from "fs";\n' + 'import { setupTray } from "./tray";\n' + 'import { setupIPC } from "./ipc";\n' + 'import { setupUpdater } from "./updater";\n' + 'import { checkSingleInstance, getWebUrl, getApiPath, isDev } from "./utils";\n' + '\n' + '// ─── Constants ───────────────────────────────────\n' + 'const WEB_PORT = ' + webPort + ';\n' + 'const API_PORT = ' + apiPort + ';\n' + 'const APP_NAME = "' + displayName + '";\n' + '\n' + '// ─── Single Instance Lock ────────────────────────\n' + 'if (!checkSingleInstance()) {\n' + ' app.quit();\n' + ' process.exit(0);\n' + '}\n' + '\n' + '// ─── State ───────────────────────────────────────\n' + 'let mainWindow: BrowserWindow | null = null;\n' + 'let apiProcess: ReturnType | null = null;\n' + '\n' + '// ─── Create Window ───────────────────────────────\n' + 'function createWindow(): BrowserWindow {\n' + ' const preloadPath = join(__dirname, "preload.js");\n' + '\n' + ' const win = new BrowserWindow({\n' + ' title: APP_NAME,\n' + ' width: 1400,\n' + ' height: 900,\n' + ' minWidth: 800,\n' + ' minHeight: 600,\n' + ' show: false,\n' + ' icon: getIconPath(),\n' + ' webPreferences: {\n' + ' preload: preloadPath,\n' + ' contextIsolation: true,\n' + ' nodeIntegration: false,\n' + ' sandbox: false,\n' + ' },\n' + ' titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",\n' + ' trafficLightPosition: { x: 16, y: 16 },\n' + ' });\n' + '\n' + ' // ── Load content ──\n' + ' const url = getWebUrl(isDev(), WEB_PORT);\n' + ' if (url.startsWith("http")) {\n' + ' win.loadURL(url);\n' + ' } else {\n' + ' win.loadFile(url);\n' + ' }\n' + '\n' + ' // ── Show when ready ──\n' + ' win.once("ready-to-show", () => {\n' + ' win.show();\n' + ' if (isDev()) {\n' + ' win.webContents.openDevTools({ mode: "detach" });\n' + ' }\n' + ' });\n' + '\n' + ' // ── External links → default browser ──\n' + ' win.webContents.setWindowOpenHandler(({ url }) => {\n' + ' if (url.startsWith("http")) {\n' + ' require("electron").shell.openExternal(url);\n' + ' }\n' + ' return { action: "deny" };\n' + ' });\n' + '\n' + ' // ── Prevent navigation away ──\n' + ' win.webContents.on("will-navigate", (event, url) => {\n' + ' const allowed = isDev()\n' + ' ? url.startsWith("http://localhost:' + webPort + '")\n' + ' : url.startsWith("file://");\n' + ' if (!allowed) event.preventDefault();\n' + ' });\n' + '\n' + ' return win;\n' + '}\n' + '\n' + '// ─── Icon Path ───────────────────────────────────\n' + 'function getIconPath(): string {\n' + ' const candidates = [\n' + ' join(__dirname, "..", "assets", "icon.png"),\n' + ' join(__dirname, "..", "assets", "icon.icns"),\n' + ' join(__dirname, "..", "assets", "icon.ico"),\n' + ' ];\n' + ' for (const p of candidates) {\n' + ' if (existsSync(p)) return p;\n' + ' }\n' + ' return "";\n' + '}\n' + '\n' + '// ─── API Server (Production) ─────────────────────\n' + 'function startApiServer(): void {\n' + ' if (isDev()) return;\n' + '\n' + ' const apiPath = getApiPath();\n' + ' if (!apiPath || !existsSync(apiPath)) {\n' + ' console.warn("[main] API server not found at", apiPath);\n' + ' return;\n' + ' }\n' + '\n' + ' const { spawn } = require("child_process");\n' + ' apiProcess = spawn("node", [apiPath], {\n' + ' env: {\n' + ' ...process.env,\n' + ' NODE_ENV: "production",\n' + ' PORT: String(API_PORT),\n' + ' HOST: "127.0.0.1",\n' + ' },\n' + ' stdio: ["ignore", "pipe", "pipe"],\n' + ' });\n' + '\n' + ' apiProcess!.stdout?.on("data", (data: Buffer) => {\n' + ' console.log("[api]", data.toString().trim());\n' + ' });\n' + ' apiProcess!.stderr?.on("data", (data: Buffer) => {\n' + ' console.error("[api]", data.toString().trim());\n' + ' });\n' + ' apiProcess!.on("exit", (code: number) => {\n' + ' console.warn("[main] API server exited with code", code);\n' + ' apiProcess = null;\n' + ' });\n' + '}\n' + '\n' + 'function stopApiServer(): void {\n' + ' if (apiProcess) {\n' + ' apiProcess.kill("SIGTERM");\n' + ' apiProcess = null;\n' + ' }\n' + '}\n' + '\n' + '// ─── Menu ────────────────────────────────────────\n' + 'function buildMenu(): void {\n' + ' const isMac = process.platform === "darwin";\n' + '\n' + ' const template: Electron.MenuItemConstructorOptions[] = [\n' + ' ...(isMac\n' + ' ? [{\n' + ' label: APP_NAME,\n' + ' submenu: [\n' + ' { role: "about" as const },\n' + ' { type: "separator" as const },\n' + ' { role: "services" as const },\n' + ' { type: "separator" as const },\n' + ' { role: "hide" as const },\n' + ' { role: "hideOthers" as const },\n' + ' { role: "unhide" as const },\n' + ' { type: "separator" as const },\n' + ' { role: "quit" as const },\n' + ' ],\n' + ' }]\n' + ' : []),\n' + ' {\n' + ' label: "Edit",\n' + ' submenu: [\n' + ' { role: "undo" },\n' + ' { role: "redo" },\n' + ' { type: "separator" },\n' + ' { role: "cut" },\n' + ' { role: "copy" },\n' + ' { role: "paste" },\n' + ' { role: "selectAll" },\n' + ' ],\n' + ' },\n' + ' {\n' + ' label: "View",\n' + ' submenu: [\n' + ' { role: "reload" },\n' + ' { role: "forceReload" },\n' + ' { role: "toggleDevTools" },\n' + ' { type: "separator" },\n' + ' { role: "resetZoom" },\n' + ' { role: "zoomIn" },\n' + ' { role: "zoomOut" },\n' + ' { type: "separator" },\n' + ' { role: "togglefullscreen" },\n' + ' ],\n' + ' },\n' + ' {\n' + ' label: "Window",\n' + ' submenu: [\n' + ' { role: "minimize" },\n' + ' { role: "zoom" },\n' + ' ...(isMac\n' + ' ? [\n' + ' { type: "separator" as const },\n' + ' { role: "front" as const },\n' + ' ]\n' + ' : [{ role: "close" as const }]),\n' + ' ],\n' + ' },\n' + ' ];\n' + '\n' + ' Menu.setApplicationMenu(Menu.buildFromTemplate(template));\n' + '}\n' + '\n' + '// ─── App Lifecycle ───────────────────────────────\n' + 'app.whenReady().then(() => {\n' + ' startApiServer();\n' + ' mainWindow = createWindow();\n' + ' buildMenu();\n' + ' if (mainWindow) {\n' + ' setupTray(mainWindow, APP_NAME);\n' + ' setupIPC(mainWindow);\n' + ' }\n' + ' setupUpdater(APP_NAME);\n' + '\n' + ' app.on("activate", () => {\n' + ' if (BrowserWindow.getAllWindows().length === 0) {\n' + ' mainWindow = createWindow();\n' + ' if (mainWindow) {\n' + ' setupTray(mainWindow, APP_NAME);\n' + ' setupIPC(mainWindow);\n' + ' }\n' + ' }\n' + ' });\n' + '});\n' + '\n' + 'app.on("window-all-closed", () => {\n' + ' stopApiServer();\n' + ' if (process.platform !== "darwin") {\n' + ' app.quit();\n' + ' }\n' + '});\n' + '\n' + 'app.on("before-quit", () => {\n' + ' stopApiServer();\n' + '});\n' + '\n' + 'app.on("gpu-process-crashed" as any, () => {\n' + ' console.warn("[main] GPU process crashed — continuing");\n' + '});\n' + '\n' + 'process.on("exit", () => {\n' + ' stopApiServer();\n' + '});\n'; } // ═══════════════════════════════════════════════════════ // 4. preload.ts — Secure IPC Bridge // ═══════════════════════════════════════════════════════ function generatePreloadTs() { return 'import { contextBridge, ipcRenderer } from "electron";\n' + '\n' + '// ─── Expose safe API to renderer ─────────────────\n' + 'contextBridge.exposeInMainWorld("electronAPI", {\n' + ' // ── App Info ──\n' + ' getAppVersion: () => ipcRenderer.invoke("app:version"),\n' + ' getPlatform: () => ipcRenderer.invoke("app:platform"),\n' + ' getIsDev: () => ipcRenderer.invoke("app:isDev"),\n' + '\n' + ' // ── Window Controls ──\n' + ' minimizeWindow: () => ipcRenderer.send("window:minimize"),\n' + ' maximizeWindow: () => ipcRenderer.send("window:maximize"),\n' + ' closeWindow: () => ipcRenderer.send("window:close"),\n' + ' isMaximized: () => ipcRenderer.invoke("window:isMaximized"),\n' + '\n' + ' // ── File Dialogs ──\n' + ' openFile: (options?: Electron.OpenDialogOptions) =>\n' + ' ipcRenderer.invoke("dialog:openFile", options),\n' + ' saveFile: (options?: Electron.SaveDialogOptions) =>\n' + ' ipcRenderer.invoke("dialog:saveFile", options),\n' + '\n' + ' // ── Notifications ──\n' + ' showNotification: (title: string, body: string) =>\n' + ' ipcRenderer.send("notification:show", { title, body }),\n' + '\n' + ' // ── Shell ──\n' + ' openExternal: (url: string) => ipcRenderer.send("shell:openExternal", url),\n' + ' openPath: (path: string) => ipcRenderer.send("shell:openPath", path),\n' + '\n' + ' // ── Store ──\n' + ' storeGet: (key: string) => ipcRenderer.invoke("store:get", key),\n' + ' storeSet: (key: string, value: unknown) =>\n' + ' ipcRenderer.invoke("store:set", key, value),\n' + ' storeDelete: (key: string) => ipcRenderer.invoke("store:delete", key),\n' + '\n' + ' // ── Updates ──\n' + ' checkForUpdates: () => ipcRenderer.invoke("updater:check"),\n' + ' downloadUpdate: () => ipcRenderer.invoke("updater:download"),\n' + ' quitAndInstall: () => ipcRenderer.send("updater:quitAndInstall"),\n' + '\n' + ' // ── Update Events ──\n' + ' onUpdateAvailable: (callback: (info: unknown) => void) => {\n' + ' ipcRenderer.on("updater:available", (_event, info) => callback(info));\n' + ' },\n' + ' onUpdateDownloaded: (callback: (info: unknown) => void) => {\n' + ' ipcRenderer.on("updater:downloaded", (_event, info) => callback(info));\n' + ' },\n' + ' onUpdateProgress: (callback: (progress: unknown) => void) => {\n' + ' ipcRenderer.on("updater:progress", (_event, progress) => callback(progress));\n' + ' },\n' + ' onUpdateError: (callback: (error: string) => void) => {\n' + ' ipcRenderer.on("updater:error", (_event, error) => callback(error));\n' + ' },\n' + '\n' + ' // ── Remove listener ──\n' + ' removeAllListeners: (channel: string) => {\n' + ' ipcRenderer.removeAllListeners(channel);\n' + ' },\n' + '});\n' + '\n' + '// ─── Type declarations for renderer ──────────────\n' + 'export interface ElectronAPI {\n' + ' getAppVersion: () => Promise;\n' + ' getPlatform: () => Promise;\n' + ' getIsDev: () => Promise;\n' + ' minimizeWindow: () => void;\n' + ' maximizeWindow: () => void;\n' + ' closeWindow: () => void;\n' + ' isMaximized: () => Promise;\n' + ' openFile: (options?: Electron.OpenDialogOptions) => Promise;\n' + ' saveFile: (options?: Electron.SaveDialogOptions) => Promise;\n' + ' showNotification: (title: string, body: string) => void;\n' + ' openExternal: (url: string) => void;\n' + ' openPath: (path: string) => void;\n' + ' storeGet: (key: string) => Promise;\n' + ' storeSet: (key: string, value: unknown) => Promise;\n' + ' storeDelete: (key: string) => Promise;\n' + ' checkForUpdates: () => Promise;\n' + ' downloadUpdate: () => Promise;\n' + ' quitAndInstall: () => void;\n' + ' onUpdateAvailable: (callback: (info: unknown) => void) => void;\n' + ' onUpdateDownloaded: (callback: (info: unknown) => void) => void;\n' + ' onUpdateProgress: (callback: (progress: unknown) => void) => void;\n' + ' onUpdateError: (callback: (error: string) => void) => void;\n' + ' removeAllListeners: (channel: string) => void;\n' + '}\n' + '\n' + 'declare global {\n' + ' interface Window {\n' + ' electronAPI: ElectronAPI;\n' + ' }\n' + '}\n'; } // ═══════════════════════════════════════════════════════ // 5. ipc.ts — IPC Handlers // ═══════════════════════════════════════════════════════ function generateIpcTs() { return 'import { ipcMain, app, dialog, shell, BrowserWindow, Notification } from "electron";\n' + 'import Store from "electron-store";\n' + '\n' + 'const store = new Store() as any;\n' + '\n' + 'export function setupIPC(mainWindow: BrowserWindow): void {\n' + ' // ── App Info ──\n' + ' ipcMain.handle("app:version", () => app.getVersion());\n' + ' ipcMain.handle("app:platform", () => process.platform);\n' + ' ipcMain.handle("app:isDev", () => !app.isPackaged);\n' + '\n' + ' // ── Window Controls ──\n' + ' ipcMain.on("window:minimize", () => {\n' + ' mainWindow?.minimize();\n' + ' });\n' + '\n' + ' ipcMain.on("window:maximize", () => {\n' + ' if (mainWindow?.isMaximized()) {\n' + ' mainWindow.unmaximize();\n' + ' } else {\n' + ' mainWindow?.maximize();\n' + ' }\n' + ' });\n' + '\n' + ' ipcMain.on("window:close", () => {\n' + ' mainWindow?.close();\n' + ' });\n' + '\n' + ' ipcMain.handle("window:isMaximized", () => {\n' + ' return mainWindow?.isMaximized() ?? false;\n' + ' });\n' + '\n' + ' // ── File Dialogs ──\n' + ' ipcMain.handle("dialog:openFile", async (_event, options?) => {\n' + ' const result = await dialog.showOpenDialog(mainWindow, {\n' + ' properties: ["openFile"],\n' + ' ...options,\n' + ' });\n' + ' return result.canceled ? undefined : result.filePaths;\n' + ' });\n' + '\n' + ' ipcMain.handle("dialog:saveFile", async (_event, options?) => {\n' + ' const result = await dialog.showSaveDialog(mainWindow, {\n' + ' ...options,\n' + ' });\n' + ' return result.canceled ? undefined : result.filePath;\n' + ' });\n' + '\n' + ' // ── Notifications ──\n' + ' ipcMain.on("notification:show", (_event, { title, body }) => {\n' + ' if (Notification.isSupported()) {\n' + ' new Notification({ title, body }).show();\n' + ' }\n' + ' });\n' + '\n' + ' // ── Shell ──\n' + ' ipcMain.on("shell:openExternal", (_event, url: string) => {\n' + ' shell.openExternal(url);\n' + ' });\n' + '\n' + ' ipcMain.on("shell:openPath", (_event, path: string) => {\n' + ' shell.openPath(path);\n' + ' });\n' + '\n' + ' // ── Persistent Store ──\n' + ' ipcMain.handle("store:get", (_event, key: string) => {\n' + ' return store.get(key);\n' + ' });\n' + '\n' + ' ipcMain.handle("store:set", (_event, key: string, value: unknown) => {\n' + ' store.set(key, value);\n' + ' });\n' + '\n' + ' ipcMain.handle("store:delete", (_event, key: string) => {\n' + ' store.delete(key);\n' + ' });\n' + '}\n'; } // ═══════════════════════════════════════════════════════ // 6. tray.ts — System Tray // ═══════════════════════════════════════════════════════ function generateTrayTs() { return 'import { Tray, Menu, nativeImage, BrowserWindow, app } from "electron";\n' + 'import { join } from "path";\n' + 'import { existsSync } from "fs";\n' + '\n' + 'let tray: Tray | null = null;\n' + '\n' + 'export function setupTray(mainWindow: BrowserWindow, appName: string): void {\n' + ' const iconPath = getTrayIcon();\n' + ' if (!iconPath) {\n' + ' console.warn("[tray] No icon found, skipping tray setup");\n' + ' return;\n' + ' }\n' + '\n' + ' const icon = nativeImage.createFromPath(iconPath);\n' + ' tray = new Tray(icon.resize({ width: 16, height: 16 }));\n' + ' tray.setToolTip(appName);\n' + '\n' + ' const contextMenu = Menu.buildFromTemplate([\n' + ' {\n' + ' label: "Show " + appName,\n' + ' click: () => {\n' + ' mainWindow.show();\n' + ' mainWindow.focus();\n' + ' },\n' + ' },\n' + ' { type: "separator" },\n' + ' {\n' + ' label: "Quit",\n' + ' click: () => {\n' + ' app.quit();\n' + ' },\n' + ' },\n' + ' ]);\n' + '\n' + ' tray.setContextMenu(contextMenu);\n' + '\n' + ' tray.on("click", () => {\n' + ' if (mainWindow.isVisible()) {\n' + ' mainWindow.focus();\n' + ' } else {\n' + ' mainWindow.show();\n' + ' }\n' + ' });\n' + '\n' + ' tray.on("double-click", () => {\n' + ' mainWindow.show();\n' + ' mainWindow.focus();\n' + ' });\n' + '}\n' + '\n' + 'function getTrayIcon(): string | null {\n' + ' const candidates = [\n' + ' join(__dirname, "..", "assets", "tray-icon.png"),\n' + ' join(__dirname, "..", "assets", "icon.png"),\n' + ' join(__dirname, "..", "assets", "icon.ico"),\n' + ' ];\n' + ' for (const p of candidates) {\n' + ' if (existsSync(p)) return p;\n' + ' }\n' + ' return null;\n' + '}\n'; } // ═══════════════════════════════════════════════════════ // 7. updater.ts — Auto Updater // ═══════════════════════════════════════════════════════ function generateUpdaterTs() { return 'import { autoUpdater } from "electron-updater";\n' + 'import { ipcMain, BrowserWindow } from "electron";\n' + '\n' + 'let updateAvailable = false;\n' + 'let updateDownloaded = false;\n' + '\n' + 'export function setupUpdater(appName: string): void {\n' + ' autoUpdater.autoDownload = false;\n' + ' autoUpdater.autoInstallOnAppQuit = true;\n' + ' autoUpdater.logger = {\n' + ' info: (msg) => console.log("[updater]", msg),\n' + ' warn: (msg) => console.warn("[updater]", msg),\n' + ' error: (msg) => console.error("[updater]", msg),\n' + ' debug: (msg) => console.debug("[updater]", msg),\n' + ' };\n' + '\n' + ' autoUpdater.on("checking-for-update", () => {\n' + ' console.log("[updater] Checking for updates...");\n' + ' });\n' + '\n' + ' autoUpdater.on("update-available", (info) => {\n' + ' updateAvailable = true;\n' + ' console.log("[updater] Update available:", info.version);\n' + ' broadcast("updater:available", info);\n' + ' });\n' + '\n' + ' autoUpdater.on("update-not-available", (info) => {\n' + ' console.log("[updater] Up to date:", info.version);\n' + ' });\n' + '\n' + ' autoUpdater.on("download-progress", (progress) => {\n' + ' broadcast("updater:progress", {\n' + ' percent: progress.percent,\n' + ' bytesPerSecond: progress.bytesPerSecond,\n' + ' transferred: progress.transferred,\n' + ' total: progress.total,\n' + ' });\n' + ' });\n' + '\n' + ' autoUpdater.on("update-downloaded", (info) => {\n' + ' updateDownloaded = true;\n' + ' console.log("[updater] Update downloaded:", info.version);\n' + ' broadcast("updater:downloaded", info);\n' + ' });\n' + '\n' + ' autoUpdater.on("error", (err) => {\n' + ' console.error("[updater] Error:", err.message);\n' + ' broadcast("updater:error", err.message);\n' + ' });\n' + '\n' + ' ipcMain.handle("updater:check", async () => {\n' + ' try {\n' + ' const result = await autoUpdater.checkForUpdates();\n' + ' return result?.updateInfo ?? null;\n' + ' } catch (err) {\n' + ' console.error("[updater] Check failed:", err);\n' + ' return null;\n' + ' }\n' + ' });\n' + '\n' + ' ipcMain.handle("updater:download", async () => {\n' + ' if (!updateAvailable) return;\n' + ' try {\n' + ' await autoUpdater.downloadUpdate();\n' + ' } catch (err) {\n' + ' console.error("[updater] Download failed:", err);\n' + ' }\n' + ' });\n' + '\n' + ' ipcMain.on("updater:quitAndInstall", () => {\n' + ' if (updateDownloaded) {\n' + ' autoUpdater.quitAndInstall(false, true);\n' + ' }\n' + ' });\n' + '\n' + ' setTimeout(() => {\n' + ' autoUpdater.checkForUpdates().catch(() => {});\n' + ' }, 10_000);\n' + '}\n' + '\n' + 'function broadcast(channel: string, data: unknown): void {\n' + ' for (const win of BrowserWindow.getAllWindows()) {\n' + ' if (!win.isDestroyed()) {\n' + ' win.webContents.send(channel, data);\n' + ' }\n' + ' }\n' + '}\n'; } // ═══════════════════════════════════════════════════════ // 8. utils.ts — Shared Utilities // ═══════════════════════════════════════════════════════ function generateUtilsTs(webPort, apiPort) { return 'import { app } from "electron";\n' + 'import { join } from "path";\n' + 'import { existsSync } from "fs";\n' + '\n' + '// ─── Single Instance Lock ────────────────────────\n' + 'export function checkSingleInstance(): boolean {\n' + ' const gotLock = app.requestSingleInstanceLock();\n' + ' if (!gotLock) {\n' + ' app.quit();\n' + ' return false;\n' + ' }\n' + '\n' + ' app.on("second-instance", (_event, _argv, _cwd) => {\n' + ' const windows = require("electron").BrowserWindow.getAllWindows();\n' + ' if (windows.length > 0) {\n' + ' const win = windows[0];\n' + ' if (win.isMinimized()) win.restore();\n' + ' win.focus();\n' + ' }\n' + ' });\n' + '\n' + ' return true;\n' + '}\n' + '\n' + '// ─── Dev mode detection ──────────────────────────\n' + 'export function isDev(): boolean {\n' + ' return !app.isPackaged;\n' + '}\n' + '\n' + '// ─── Web URL ─────────────────────────────────────\n' + 'export function getWebUrl(dev: boolean, port: number): string {\n' + ' if (dev) {\n' + ' return "http://localhost:" + port;\n' + ' }\n' + '\n' + ' const appPath = app.isPackaged\n' + ' ? join(process.resourcesPath, "web")\n' + ' : join(__dirname, "..", "..", "web", "out");\n' + '\n' + ' const staticIndex = join(appPath, "index.html");\n' + ' if (existsSync(staticIndex)) {\n' + ' return staticIndex;\n' + ' }\n' + '\n' + ' const standalone = join(appPath, ".next", "standalone", "server.js");\n' + ' if (existsSync(standalone)) {\n' + ' return "http://localhost:' + webPort + '";\n' + ' }\n' + '\n' + ' const fallback = join(appPath, "index.html");\n' + ' if (existsSync(fallback)) {\n' + ' return fallback;\n' + ' }\n' + '\n' + ' console.warn("[utils] No web build found at", appPath);\n' + ' return "http://localhost:' + webPort + '";\n' + '}\n' + '\n' + '// ─── API Path ────────────────────────────────────\n' + 'export function getApiPath(): string | null {\n' + ' if (app.isPackaged) {\n' + ' const packed = join(process.resourcesPath, "api", "dist", "index.js");\n' + ' if (existsSync(packed)) return packed;\n' + ' const packedSrc = join(process.resourcesPath, "api", "src", "index.js");\n' + ' if (existsSync(packedSrc)) return packedSrc;\n' + ' }\n' + '\n' + ' const devPath = join(__dirname, "..", "..", "api", "dist", "index.js");\n' + ' if (existsSync(devPath)) return devPath;\n' + '\n' + ' const devSrc = join(__dirname, "..", "..", "api", "src", "index.js");\n' + ' if (existsSync(devSrc)) return devSrc;\n' + '\n' + ' return null;\n' + '}\n' + '\n' + '// ─── Get API port from env ───────────────────────\n' + 'export function getApiPort(): number {\n' + ' return ' + apiPort + ';\n' + '}\n'; } // ═══════════════════════════════════════════════════════ // 9. tsconfig.json Generator // ═══════════════════════════════════════════════════════ function generateTsConfig() { return JSON.stringify({ compilerOptions: { target: "ES2022", module: "commonjs", moduleResolution: "node", lib: ["ES2022"], outDir: "dist", rootDir: "electron", strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true, declaration: false, sourceMap: true, noUnusedLocals: false, noUnusedParameters: false, types: ["node"], }, include: ["electron/**/*.ts"], exclude: ["node_modules", "dist", "release"], }, null, 2); } // ═══════════════════════════════════════════════════════ // 10. README.md Generator // ═══════════════════════════════════════════════════════ function generateReadme(projectName) { const displayName = projectName || "Desktop App"; const bt = String.fromCharCode(96); // backtick const fence = bt + bt + bt; return "# " + displayName + " — Desktop\n" + "\n" + "> Electron desktop application wrapping the " + displayName + " fullstack web project.\n" + "\n" + "## Architecture\n" + "\n" + fence + "\n" + "Web Fullstack Project\n" + " ×\n" + " Desktop Shell (Electron)\n" + " =\n" + " Desktop Application\n" + fence + "\n" + "\n" + "- **Main Process** — Window management, menu, tray, IPC, auto-update\n" + "- **Preload** — Secure bridge between main and renderer\n" + "- **Renderer** — Your existing web frontend (Next.js)\n" + "\n" + "## Development\n" + "\n" + fence + "bash\n" + "# Install dependencies\n" + "npm install\n" + "\n" + "# Start dev mode (web + api + electron concurrently)\n" + "npm run dev\n" + fence + "\n" + "\n" + "Dev mode:\n" + "- Web frontend: " + bt + "http://localhost:3000" + bt + "\n" + "- API backend: " + bt + "http://localhost:3001" + bt + "\n" + "- Electron loads the web URL directly\n" + "\n" + "## Build\n" + "\n" + fence + "bash\n" + "# Build for current platform\n" + "npm run build\n" + "\n" + "# Platform-specific builds\n" + "npm run build:electron:mac\n" + "npm run build:electron:win\n" + "npm run build:electron:linux\n" + fence + "\n" + "\n" + "Output: " + bt + "release/" + bt + " directory with platform installers.\n" + "\n" + "## Project Structure\n" + "\n" + fence + "\n" + "electron/\n" + "├── main.ts — Main process (BrowserWindow, Menu, lifecycle)\n" + "├── preload.ts — Secure IPC bridge (contextBridge)\n" + "├── ipc.ts — IPC handlers (dialogs, store, notifications)\n" + "├── tray.ts — System tray\n" + "├── updater.ts — Auto-update (electron-updater)\n" + "└── utils.ts — Shared utilities\n" + fence + "\n" + "\n" + "## IPC API (available in renderer)\n" + "\n" + fence + "typescript\n" + "window.electronAPI.getAppVersion()\n" + "window.electronAPI.getPlatform()\n" + "window.electronAPI.minimizeWindow()\n" + "window.electronAPI.maximizeWindow()\n" + "window.electronAPI.closeWindow()\n" + "window.electronAPI.openFile(options?)\n" + "window.electronAPI.saveFile(options?)\n" + "window.electronAPI.showNotification(title, body)\n" + "window.electronAPI.openExternal(url)\n" + "window.electronAPI.storeGet(key)\n" + "window.electronAPI.storeSet(key, value)\n" + "window.electronAPI.checkForUpdates()\n" + "window.electronAPI.downloadUpdate()\n" + "window.electronAPI.quitAndInstall()\n" + fence + "\n" + "\n" + "## Auto Update\n" + "\n" + "Configured via " + bt + "electron-builder.yml" + bt + ". Uses " + bt + "electron-updater" + bt + " with generic provider.\n" + "Set your release URL in the " + bt + "publish" + bt + " section.\n"; } // ═══════════════════════════════════════════════════════ // 11. Placeholder Icon Generator // ═══════════════════════════════════════════════════════ function generatePlaceholderIcon() { // Minimal valid 1x1 transparent PNG return Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe5, 0x27, 0xde, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); } // ═══════════════════════════════════════════════════════ // 12. macOS Entitlements // ═══════════════════════════════════════════════════════ function generateEntitlements() { return '\n' + '\n' + '\n' + '\n' + ' com.apple.security.cs.allow-jit\n' + ' \n' + ' com.apple.security.cs.allow-unsigned-executable-memory\n' + ' \n' + ' com.apple.security.cs.allow-dyld-environment-variables\n' + ' \n' + ' com.apple.security.network.client\n' + ' \n' + ' com.apple.security.network.server\n' + ' \n' + ' com.apple.security.files.user-selected.read-write\n' + ' \n' + '\n' + '\n'; } // ═══════════════════════════════════════════════════════ // 13. Main Builder Function // ═══════════════════════════════════════════════════════ /** * Build Electron desktop app wrapper for a fullstack project. * * @param {object} options * @param {string} options.projectName — Project display name * @param {number} [options.webPort=3000] — Dev web port * @param {number} [options.apiPort=3001] — Dev API port * @returns {object} { files, stats, error, message } */ export function buildElectron(options = {}) { const { projectName = "Desktop App", webPort = 3000, apiPort = 3001, } = options; const files = {}; files["package.json"] = generatePackageJson(projectName, webPort, apiPort); files["electron-builder.yml"] = generateElectronBuilderYml(projectName); files["tsconfig.json"] = generateTsConfig(); files["electron/main.ts"] = generateMainTs(projectName, webPort, apiPort); files["electron/preload.ts"] = generatePreloadTs(); files["electron/ipc.ts"] = generateIpcTs(); files["electron/tray.ts"] = generateTrayTs(); files["electron/updater.ts"] = generateUpdaterTs(); files["electron/utils.ts"] = generateUtilsTs(webPort, apiPort); files["assets/icon.png"] = generatePlaceholderIcon(); files["entitlements.mac.plist"] = generateEntitlements(); files["README.md"] = generateReadme(projectName); const allPaths = Object.keys(files); const stats = { totalFiles: allPaths.length, electronFiles: allPaths.filter(p => p.startsWith("electron/")).length, configFiles: allPaths.filter(p => p.endsWith(".json") || p.endsWith(".yml") || p.endsWith(".plist") ).length, }; return { files, stats, error: null, message: null }; } // ═══════════════════════════════════════════════════════ // 14. File I/O // ═══════════════════════════════════════════════════════ export function writeElectron(result, outputDir) { mkdirSync(outputDir, { recursive: true }); for (const [relPath, content] of Object.entries(result.files)) { const fullPath = resolve(outputDir, relPath); mkdirSync(dirname(fullPath), { recursive: true }); if (typeof content === "string") { writeFileSync(fullPath, content, "utf-8"); } else { writeFileSync(fullPath, content); } } } // ═══════════════════════════════════════════════════════ // 15. CLI Entry // ═══════════════════════════════════════════════════════ function parseArgs() { const args = process.argv.slice(2); const opts = { input: null, prd: null, arch: null, output: null, webPort: 3000, apiPort: 3001, verbose: false, help: false, }; for (let i = 0; i < args.length; i++) { if (args[i] === "--input" && args[i + 1]) opts.input = args[++i]; else if (args[i] === "--prd" && args[i + 1]) opts.prd = args[++i]; else if (args[i] === "--arch" && args[i + 1]) opts.arch = args[++i]; else if (args[i] === "--output" && args[i + 1]) opts.output = args[++i]; else if (args[i] === "--web-port" && args[i + 1]) opts.webPort = parseInt(args[++i], 10); else if (args[i] === "--api-port" && args[i + 1]) opts.apiPort = parseInt(args[++i], 10); else if (args[i] === "--verbose") opts.verbose = true; else if (args[i] === "--help" || args[i] === "-h") opts.help = true; } return opts; } async function main() { const opts = parseArgs(); if (opts.help) { console.log( "\nElectron Builder Agent — SF-06\n\n" + "Wrap a fullstack web project as an Electron desktop app.\n\n" + "Usage:\n" + " node scripts/electron-builder-agent.mjs --input [options]\n" + " node scripts/electron-builder-agent.mjs --prd --arch [options]\n\n" + "Options:\n" + " --input SF-05 output directory (apps/web, apps/api, packages/)\n" + " --prd PRD JSON (SF-01 output)\n" + " --arch Architecture JSON (SF-02 output)\n" + " --output Output directory (default: /apps/desktop)\n" + " --web-port Dev web port (default: 3000)\n" + " --api-port Dev API port (default: 3001)\n" + " --verbose Verbose output\n" + " --help Show this help\n" ); return; } let projectName = "Desktop App"; if (opts.prd) { try { const prd = JSON.parse(readFileSync(opts.prd, "utf-8")); if (prd.projectName) projectName = prd.projectName; } catch {} } if (opts.input) { try { const rootPkg = JSON.parse(readFileSync(resolve(opts.input, "package.json"), "utf-8")); if (rootPkg.name) projectName = rootPkg.name; } catch {} } const result = buildElectron({ projectName, webPort: opts.webPort, apiPort: opts.apiPort, }); if (result.error) { console.error(result.message); process.exit(1); } const outputDir = opts.output ? resolve(opts.output) : opts.input ? resolve(opts.input, "apps", "desktop") : resolve(WORKSPACE, "desktop"); writeElectron(result, outputDir); if (opts.verbose) { console.error("Project: " + projectName); console.error("Files: " + result.stats.totalFiles); console.error("Electron files: " + result.stats.electronFiles); console.error("Config files: " + result.stats.configFiles); } console.log(JSON.stringify({ projectName, outputDir, stats: result.stats, }, null, 2)); } if (process.argv[1] && (import.meta.url === "file://" + process.argv[1] || import.meta.url.endsWith(process.argv[1]))) { main(); }