Files
16gagent/scripts/electron-builder-agent.mjs
T
2026-06-06 10:40:48 +08:00

1201 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <fullstack-dir> [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<typeof import("child_process").spawn> | 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<string>;\n' +
' getPlatform: () => Promise<string>;\n' +
' getIsDev: () => Promise<boolean>;\n' +
' minimizeWindow: () => void;\n' +
' maximizeWindow: () => void;\n' +
' closeWindow: () => void;\n' +
' isMaximized: () => Promise<boolean>;\n' +
' openFile: (options?: Electron.OpenDialogOptions) => Promise<string[] | undefined>;\n' +
' saveFile: (options?: Electron.SaveDialogOptions) => Promise<string | undefined>;\n' +
' showNotification: (title: string, body: string) => void;\n' +
' openExternal: (url: string) => void;\n' +
' openPath: (path: string) => void;\n' +
' storeGet: (key: string) => Promise<unknown>;\n' +
' storeSet: (key: string, value: unknown) => Promise<void>;\n' +
' storeDelete: (key: string) => Promise<void>;\n' +
' checkForUpdates: () => Promise<void>;\n' +
' downloadUpdate: () => Promise<void>;\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 '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
'<plist version="1.0">\n' +
'<dict>\n' +
' <key>com.apple.security.cs.allow-jit</key>\n' +
' <true/>\n' +
' <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n' +
' <true/>\n' +
' <key>com.apple.security.cs.allow-dyld-environment-variables</key>\n' +
' <true/>\n' +
' <key>com.apple.security.network.client</key>\n' +
' <true/>\n' +
' <key>com.apple.security.network.server</key>\n' +
' <true/>\n' +
' <key>com.apple.security.files.user-selected.read-write</key>\n' +
' <true/>\n' +
'</dict>\n' +
'</plist>\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 <fullstack-dir> [options]\n" +
" node scripts/electron-builder-agent.mjs --prd <path> --arch <path> [options]\n\n" +
"Options:\n" +
" --input <dir> SF-05 output directory (apps/web, apps/api, packages/)\n" +
" --prd <path> PRD JSON (SF-01 output)\n" +
" --arch <path> Architecture JSON (SF-02 output)\n" +
" --output <dir> Output directory (default: <input>/apps/desktop)\n" +
" --web-port <port> Dev web port (default: 3000)\n" +
" --api-port <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();
}