openpencil/apps/desktop/main.ts
Kayshen Xu 52efa884e6 V0.7.1 (#102)
* fix(desktop,web): rebuild Electron dev sync + bitmap dragging fix on v0.7.1 (#99)

Re-applies b046a0d from the closed PR #97 now that the base is v0.7.1.
Original conflict against v0.7.0 came from the release branch churn —
the cherry-pick onto v0.7.1 applies cleanly.

Keeps dev-startup, sync-noise, and bitmap-dragging fixes; drops the
loopback proxy helper scripts upstream rejected in PR #92. Readiness
probe now does direct socket checks inside the existing dev entrypoint;
sync hardening stays focused on request diagnostics, backpressure, and
drag-time clip-rect correctness.

Original commit: b046a0d
Supersedes: #97 (closed, head branch deleted)

Co-authored-by: Rais <vdcoolzi@gmail.com>

* fix(canvas): use ImageFill.url in skia-interaction test

The image-backed rectangle fixture in the skia-interaction test used
`{ type: 'image', src: '…' }`, but `ImageFill` in pen-types declares
the field as `url`. `npx tsc --noEmit` flagged it as TS2352 on the
`as PenNode` cast. One-word rename.

The squash-merge of #99 captured an earlier snapshot that did not
include this fix, so re-apply directly on v0.7.1.

* feat(cli): add `op install` / `op uninstall` for openpencil-skill

Bundle skill files at build time (scripts/bundle-skill.ts → skill-bundle.json)
so users without GitHub access can install directly. Falls back to git clone
when the bundle is empty.

Supports auto-detection of: Claude Code, Codex, Cursor, Gemini CLI, OpenCode.
CI workflows updated to checkout openpencil-skill before cli:compile.

* fix(panels): allow reparenting nodes into rectangle in layer panel

CONTAINER_TYPES was missing 'rectangle', preventing drag-drop into
rectangles even though the data model (ContainerProps) and store
(moveNode) both support it.

* fix(agent,ai): tool_exec reset + insert_node with after + move_node + CRUD tools

- fix(agent): reset StreamingToolExecutor between turns — prevents stale
  tool_use IDs that caused 400 errors on multi-turn tool calls (MiniMax etc.)
- feat(ai): add insert_node "after" parameter — auto-resolves sibling's
  parent and position for intuitive node insertion
- feat(ai): add move_node and insert_node to CRUD tool set
- feat(ai): add Chinese keywords (增加/添加/插入) to design intent detection
- fix(ai): insert_node uses addNode directly for existing parents instead
  of streaming pipeline, fixing parent resolution

* feat(ai): route CRUD intents to lightweight prompt and tool set

CRUD operations (read/update/delete) now get a focused system prompt
without design generation instructions, and use getCrudToolDefs()
(which includes insert_node and move_node) instead of the full design set.

* fix(agent,mcp): submodule update + MCP tool improvements

- Update agent-native submodule (tool_exec reset, HTTP error diagnostics)
- Improve MCP tool descriptions and parameter schemas
- Enhance agent.ts error handling

* feat(ai): add PenNode examples to CRUD prompt for complete node generation

The CRUD system prompt now includes button and text node examples
showing the full structure (fills, children, icons, layout) so models
generate complete nodes instead of empty frames.

* Update agent-native submodule to commit f9633a8, ensuring compatibility with recent changes and improvements in the agent's functionality.

* fix(ci,agent): generate skill-bundle before type check + fix moveNode arg

- Add `bun run cli:bundle-skill` step in CI before `tsc --noEmit` so
  skill-bundle.json exists when type-checking the CLI
- Fix moveNode index parameter: default to -1 when undefined

* fix(ci): add pen-engine and pen-react to npm publish workflow

Insert pen-engine and pen-react in topological order between
pen-renderer and pen-mcp so they are published before pen-sdk.

* docs: add MIT LICENSE and README to all packages

- Add MIT LICENSE to pen-ai-skills, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk, pen-types
- Add README.md to pen-engine, pen-react, pen-mcp, pen-ai-skills

* docs: comprehensive package metadata, README, CLAUDE.md, and LICENSE

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add CLAUDE.md to pen-types, pen-core, pen-engine, pen-figma,
  pen-mcp, pen-react, pen-renderer, pen-sdk
- Add MIT LICENSE to all packages
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg → ZSeven-W

* docs: package metadata, README, LICENSE, and CLAUDE.md for all packages

- Add author, license, repository, bugs, homepage to all package.json
- Homepage points to each package's own directory on GitHub
- Rewrite README for pen-engine, pen-react, pen-mcp, pen-ai-skills
  with full API docs, usage examples, and feature tables
- Add MIT LICENSE to all packages missing it
- Add CLAUDE.md to pen-types
- Update root CLAUDE.md with index of all sub-CLAUDE.md files
- Fix git URL from nicepkg to ZSeven-W

* docs: rewrite README for pen-core, pen-figma, pen-renderer, pen-sdk

Comprehensive READMEs with full API reference, usage examples,
feature tables, and architecture overview for each package.

* feat(acp): acpAgents store — persist, hydrate, CRUD actions

* feat(acp): pen-acp package + agent settings types + store

- pen-acp/types.ts: AcpAgentConfig, AcpAgentInfo, AcpConnectResult, AcpConnectionState
- pen-acp/client.ts: connectAcpAgent (local stdio + remote WebSocket), disconnectAcpAgent
- pen-acp/event-adapter.ts: acpUpdateToSSE (ACP session/update → SSE events)
- agent-settings.ts: AcpAgentConfig type, widen ModelGroup/GroupedModel.provider
- agent-settings-store.ts: acpAgents persist/hydrate/CRUD + acpConnectionStatus

* fix(acp): remove unused type imports in client.ts

* feat(acp): ACP agent settings UI — form, cards, connect/disconnect

* feat(acp): add AcpAgentSection to Agents settings tab

* refactor(agent): AgentSession as discriminated union (native | acp)

* feat(acp): connection manager — connect, disconnect, cleanup

* feat(acp): connect/disconnect API route

* feat(acp): ACP branch in agent result handler

* fix(agent): use NativeAgentSession type for runDelegateMember

* feat(acp): ACP prompt SSE stream in agent endpoint

* feat(acp): ACP agents in model list + request routing

* i18n(acp): add ACP agent translation keys for all 15 locales

* fix(i18n): translate ACP keys for all 15 locales + fix missing key references

- zh.ts/zh-tw.ts: proper Chinese translations (Agent not translated)
- All other locales: translated from English placeholders
- Fix acp.add → acp.addAgent, acp.disconnected → acp.notConnected

* chore: bump agent-native submodule — surface upstream HTTP errors

* feat(acp,build,codegen): comprehensive fixes for ACP integration + prod build

ACP agent integration:
- Rewrite system prompt to enforce layered design pipeline (get_design_prompt
  → design_skeleton → design_content → design_refine) for higher quality output
- Use correct PenNode field names: content for text, iconFontName for icons
- Strict JSON rules to prevent empty-key / trailing-comma / smart-quote errors
- Prefer icon_font over path icons (standalone MCP has no hooks registered)
- Auto-start MCP server before ACP session (lazy bootstrap)
- Auto-reconnect ACP on stale connection (dev server restart scenario)
- Auto-approve tool permission requests (trust model: user configured agent)
- Use type: 'http' + headers: [] for MCP server config (SDK schema requirement)
- Persist ACP connections via globalThis so they survive Vite HMR

Build / packaging:
- Place agent-native under server/node_modules for Nitro to resolve at runtime
- Copy agent_napi.node to napi/ as extraResource
- Kill detached MCP server on Electron quit (before-quit + dev SIGINT handlers)
- Capture drag-dropped filesystem path via webUtils.getPathForFile so recent
  files entries are clickable after reopening

Codegen:
- Compact JSON (no indent) + strip noise fields (id, parentId, default rotation
  /opacity/visible, layout-managed x/y) to reduce request body size by 60-70%
  so proxies don't reject with 403 'Request not allowed'

MCP batch_design robustness:
- splitOperations tracks bracket/quote balance → multi-line JSON now works
- Auto-normalize fill/stroke shorthand forms
- Collect per-line errors instead of aborting whole batch
- Repair empty keys, trailing commas, smart quotes in JSON
- Bindless I(...) form supported (auto-generates binding)

UI:
- ModelDropdown / ChatInput handle ACP model icons (Plug)
- Reset streaming state + abort controller on ACP error path
- Strip h3 JSON error wrapper so chat shows clean error messages
- ACP agent settings form + cards + connect/disconnect

* fix(types): resolve TS errors in CI typecheck

- acp-connection-manager.ts: correct relative import path (utils/ → src/types)
- ai-chat-handlers.ts: cast currentProvider to AIProviderType at design-generator callsites
- ai-chat-panel.tsx: explicitly type groups as ModelGroup[] so 'acp' string fits the widened union
- acp-agent-settings.tsx: cast window through unknown for Record lookup
- electron.d.ts: add getPathForFile to ElectronAPI declaration
- builtin-provider-presets.ts: drop now-redundant config.preset !== 'custom' check (handled by early return)
- pen-acp/client.ts: cast Writable/Readable.toWeb to typed Streams; coerce nullish agentInfo fields to undefined

---------

Co-authored-by: Rais <vdcoolzi@gmail.com>
2026-04-13 21:30:23 +08:00

766 lines
24 KiB
TypeScript

import {
app,
BrowserWindow,
ipcMain,
dialog,
type BrowserWindowConstructorOptions,
} from 'electron';
import { execSync } from 'node:child_process';
import { fork, type ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import { join, extname } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
import { buildAppMenu } from './app-menu';
import {
PORT_FILE_DIR_NAME,
PORT_FILE_NAME,
VITE_DEV_PORT,
WINDOW_WIDTH,
WINDOW_HEIGHT,
WINDOW_MIN_WIDTH,
WINDOW_MIN_HEIGHT,
TITLEBAR_OVERLAY_HEIGHT,
MACOS_TRAFFIC_LIGHT_POSITION,
MACOS_TRAFFIC_LIGHT_PAD,
WIN_CONTROLS_PAD,
LINUX_CONTROLS_PAD,
NITRO_HOST,
NITRO_FALLBACK_TIMEOUT_WIN,
NITRO_FALLBACK_TIMEOUT_DEFAULT,
} from './constants';
import {
setupAutoUpdater,
broadcastUpdaterState,
setUpdaterState,
clearUpdateTimer,
setAutoUpdateEnabled,
} from './auto-updater';
import { initLogger, log } from './logger';
import { setupIPC } from './ipc-handlers';
import {
buildUnsavedChangesDialogOptions,
mapUnsavedChangesResponse,
} from './unsaved-changes-dialog';
import { setupGitIPC } from './git/ipc-handlers';
let mainWindow: BrowserWindow | null = null;
let nitroProcess: ChildProcess | null = null;
let serverPort = 0;
let pendingFilePath: string | null = null;
const isDev = !app.isPackaged;
// Settings stored in platform-standard app data dir (Electron-managed):
// macOS: ~/Library/Application Support/OpenPencil/
// Windows: %APPDATA%\OpenPencil\
// Linux: ~/.config/OpenPencil/
const SETTINGS_PATH = join(app.getPath('userData'), 'settings.json');
const PREFS_PATH = join(app.getPath('userData'), 'preferences.json');
// ---------------------------------------------------------------------------
// Renderer preferences (replaces localStorage which is origin-scoped)
// ---------------------------------------------------------------------------
let prefsCache: Record<string, string> = {};
let prefsDirty = false;
let prefsWriteTimer: ReturnType<typeof setTimeout> | null = null;
async function loadPrefs(): Promise<void> {
try {
const raw = await readFile(PREFS_PATH, 'utf-8');
prefsCache = JSON.parse(raw);
} catch {
prefsCache = {};
}
}
function schedulePrefsWrite(): void {
if (prefsWriteTimer) return;
prefsDirty = true;
prefsWriteTimer = setTimeout(async () => {
prefsWriteTimer = null;
if (!prefsDirty) return;
prefsDirty = false;
try {
await mkdir(app.getPath('userData'), { recursive: true });
await writeFile(PREFS_PATH, JSON.stringify(prefsCache, null, 2), 'utf-8');
} catch (err) {
log.error(`[prefs] Failed to write preferences: ${err}`);
}
}, 500);
}
// ---------------------------------------------------------------------------
// Fix PATH for GUI apps (shell PATH not inherited)
// ---------------------------------------------------------------------------
function fixPath(): void {
if (process.platform === 'win32') {
// Windows GUI apps inherit PATH from the system, but common tool install
// dirs (npm global, scoop, cargo, etc.) may be missing in packaged apps.
const home = homedir();
const extraDirs = [
join(home, 'AppData', 'Roaming', 'npm'), // npm global
join(home, 'AppData', 'Local', 'Programs', 'Microsoft VS Code', 'bin'), // VS Code CLI
join(home, '.cargo', 'bin'), // Rust/cargo
join(home, 'scoop', 'shims'), // scoop
join(home, '.bun', 'bin'), // bun
];
const current = process.env.PATH || '';
const existing = new Set(current.split(';').map((p) => p.toLowerCase()));
const additions = extraDirs.filter((d) => !existing.has(d.toLowerCase()));
if (additions.length > 0) {
process.env.PATH = [...additions, current].join(';');
}
return;
}
if (process.platform !== 'darwin' && process.platform !== 'linux') return;
try {
const shell = process.env.SHELL || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash');
const shellPath = execSync(`${shell} -ilc 'echo -n "$PATH"'`, {
encoding: 'utf-8',
timeout: 5000,
}).trim();
if (shellPath) {
const current = process.env.PATH || '';
process.env.PATH = [...new Set([...shellPath.split(':'), ...current.split(':')])]
.filter(Boolean)
.join(':');
}
} catch {
// Packaged app may not have a login shell — add common tool dirs as fallback
const home = homedir();
const fallbackDirs = [
join(home, '.local', 'bin'),
join(home, '.cargo', 'bin'),
join(home, '.bun', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
];
const current = process.env.PATH || '';
const existing = new Set(current.split(':'));
const additions = fallbackDirs.filter((d) => !existing.has(d));
if (additions.length > 0) {
process.env.PATH = [...additions, current].join(':');
}
}
}
// ---------------------------------------------------------------------------
// App settings
// ---------------------------------------------------------------------------
interface AppSettings {
autoUpdate?: boolean;
}
async function readAppSettings(): Promise<AppSettings> {
try {
const raw = await readFile(SETTINGS_PATH, 'utf-8');
return JSON.parse(raw);
} catch {
return {};
}
}
async function writeAppSettings(patch: Partial<AppSettings>): Promise<void> {
const current = await readAppSettings();
const merged = { ...current, ...patch };
await mkdir(app.getPath('userData'), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8');
}
// ---------------------------------------------------------------------------
// Port file for MCP sync discovery (~/.openpencil/.port)
// ---------------------------------------------------------------------------
const PORT_FILE_DIR = join(homedir(), PORT_FILE_DIR_NAME);
const PORT_FILE_PATH = join(PORT_FILE_DIR, PORT_FILE_NAME);
async function writePortFile(port: number): Promise<void> {
try {
await mkdir(PORT_FILE_DIR, { recursive: true });
await writeFile(
PORT_FILE_PATH,
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
'utf-8',
);
} catch (err) {
log.error(`[port-file] Failed to write port file: ${err}`);
}
}
async function cleanupPortFile(): Promise<void> {
try {
await unlink(PORT_FILE_PATH);
} catch {
// Ignore if already removed
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getFreePorts(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, NITRO_HOST, () => {
const addr = server.address();
if (addr && typeof addr === 'object') {
const { port } = addr;
server.close(() => resolve(port));
} else {
reject(new Error('Failed to get free port'));
}
});
server.on('error', reject);
});
}
function getServerEntry(): string {
if (isDev) {
// In dev, the Nitro output lives at out/web/server/index.mjs
return join(app.getAppPath(), 'out', 'web', 'server', 'index.mjs');
}
// In production, extraResources copies out/web into the resources folder
return join(process.resourcesPath, 'server', 'index.mjs');
}
// ---------------------------------------------------------------------------
// Nitro server
// ---------------------------------------------------------------------------
async function startNitroServer(): Promise<number> {
const port = await getFreePorts();
const entry = getServerEntry();
return new Promise((resolve, reject) => {
const child = fork(entry, [], {
env: {
...process.env,
HOST: NITRO_HOST,
PORT: String(port),
NITRO_HOST: NITRO_HOST,
NITRO_PORT: String(port),
ELECTRON_RESOURCES_PATH: process.resourcesPath,
},
stdio: 'pipe',
});
nitroProcess = child;
child.stdout?.on('data', (data: Buffer) => {
const msg = data.toString();
log.info(`[nitro] ${msg.trimEnd()}`);
// Resolve once Nitro reports it's listening
if (msg.includes('Listening') || msg.includes('ready')) {
resolve(port);
}
});
child.stderr?.on('data', (data: Buffer) => {
log.error(`[nitro:err] ${data.toString().trimEnd()}`);
});
child.on('error', reject);
child.on('exit', (code) => {
if (code !== 0 && code !== null) {
log.error(`Nitro exited with code ${code}`);
}
nitroProcess = null;
// Auto-restart Nitro server if it crashes while app is running
if (code !== 0 && code !== null && mainWindow && !mainWindow.isDestroyed()) {
log.info('[nitro] Restarting server after crash...');
startNitroServer()
.then((newPort) => {
serverPort = newPort;
writePortFile(newPort);
log.info(`[nitro] Restarted on port ${newPort}`);
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.loadURL(`http://${NITRO_HOST}:${newPort}/editor`);
}
})
.catch((err) => {
log.error(`[nitro] Failed to restart: ${err}`);
});
}
});
// Fallback: if no stdout "ready" message comes, wait then resolve anyway.
// Use longer timeout on Windows (slower process creation).
const fallbackMs =
process.platform === 'win32' ? NITRO_FALLBACK_TIMEOUT_WIN : NITRO_FALLBACK_TIMEOUT_DEFAULT;
setTimeout(() => resolve(port), fallbackMs);
});
}
// ---------------------------------------------------------------------------
// Linux window-controls side detection
// ---------------------------------------------------------------------------
/** Cached result for Linux controls side detection. */
let cachedLinuxControlsSide: 'left' | 'right' | null = null;
/**
* Detect whether Linux DE places window controls on the left or right.
* Uses gsettings (GNOME/Cinnamon/MATE) as primary check, checks XDG_CURRENT_DESKTOP
* for known right-side DEs, then defaults to right. Result is cached.
*/
function getLinuxControlsSide(): 'left' | 'right' {
if (cachedLinuxControlsSide) return cachedLinuxControlsSide;
let result: 'left' | 'right' = 'right';
// Try gsettings (works for GNOME, Cinnamon, MATE, Budgie)
try {
const layout = execSync('gsettings get org.gnome.desktop.wm.preferences button-layout', {
encoding: 'utf-8',
timeout: 3000,
})
.trim()
.replace(/'/g, '');
const colonIndex = layout.indexOf(':');
if (colonIndex >= 0) {
const beforeColon = layout.slice(0, colonIndex);
if (
beforeColon.includes('close') ||
beforeColon.includes('minimize') ||
beforeColon.includes('maximize')
) {
result = 'left';
}
}
} catch {
// gsettings not available — check desktop environment
const desktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
// KDE, XFCE, LXQt default to right. elementary OS defaults to left.
if (desktop.includes('pantheon')) {
result = 'left';
}
}
cachedLinuxControlsSide = result;
return result;
}
// ---------------------------------------------------------------------------
// Window
// ---------------------------------------------------------------------------
function createWindow(): void {
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux';
const windowOptions: BrowserWindowConstructorOptions = {
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
minWidth: WINDOW_MIN_WIDTH,
minHeight: WINDOW_MIN_HEIGHT,
title: 'OpenPencil',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
...(isWinOrLinux
? {
titleBarOverlay: {
// Windows supports transparent overlay; Linux uses solid color (updated via theme:set IPC)
color: process.platform === 'win32' ? 'rgba(0,0,0,0)' : '#1a1a1a',
symbolColor: '#d4d4d8',
height: TITLEBAR_OVERLAY_HEIGHT,
},
}
: {}),
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
// Persist localStorage/cookies in a fixed partition so data survives
// across random Nitro server port changes (origin-independent storage).
partition: 'persist:openpencil',
// Disable DevTools entirely in packaged builds. This blocks the API,
// any role-based menu item, and keyboard shortcuts at the lowest level.
devTools: isDev,
},
};
if (process.platform === 'darwin') {
windowOptions.trafficLightPosition = MACOS_TRAFFIC_LIGHT_POSITION;
}
// Start hidden to avoid visual flash before CSS injection
windowOptions.show = false;
mainWindow = new BrowserWindow(windowOptions);
// Hide native menu bar on Windows/Linux (shortcuts still work via Alt key)
if (isWinOrLinux) {
mainWindow.setAutoHideMenuBar(true);
mainWindow.setMenuBarVisibility(false);
}
// Block reload / DevTools keyboard shortcuts in packaged builds. The View
// menu hides the items in prod (see app-menu.ts), but Chromium still ships
// built-in handlers for Cmd/Ctrl+R, Cmd/Ctrl+Shift+R, F5 and the DevTools
// chord — swallow them at the input layer.
if (!isDev) {
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.type !== 'keyDown') return;
const cmdOrCtrl = process.platform === 'darwin' ? input.meta : input.control;
const key = input.key.toLowerCase();
const isReload = (cmdOrCtrl && key === 'r') || key === 'f5';
const isDevTools =
key === 'f12' ||
(cmdOrCtrl && input.shift && (key === 'i' || key === 'j' || key === 'c')) ||
(process.platform === 'darwin' && input.meta && input.alt && key === 'i');
if (isReload || isDevTools) {
event.preventDefault();
}
});
}
const url = isDev
? `http://localhost:${VITE_DEV_PORT}/editor`
: `http://${NITRO_HOST}:${serverPort}/editor`;
// Inject traffic-light padding CSS then show window (no flash)
mainWindow.webContents.on('did-finish-load', async () => {
if (!mainWindow) return;
if (process.platform === 'darwin') {
await mainWindow.webContents.insertCSS(
`.electron-traffic-light-pad { margin-left: ${MACOS_TRAFFIC_LIGHT_PAD}px; }` +
'.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }',
);
}
if (process.platform === 'win32') {
await mainWindow.webContents.insertCSS(
`.electron-win-controls-pad { margin-right: ${WIN_CONTROLS_PAD}px; }`,
);
}
if (process.platform === 'linux') {
const side = getLinuxControlsSide();
if (side === 'left') {
await mainWindow.webContents.insertCSS(
`.electron-traffic-light-pad { margin-left: ${LINUX_CONTROLS_PAD}px; }`,
);
} else {
await mainWindow.webContents.insertCSS(
`.electron-win-controls-pad { margin-right: ${LINUX_CONTROLS_PAD}px; }`,
);
}
}
mainWindow.show();
broadcastUpdaterState();
});
// Toggle fullscreen class to remove traffic-light padding in fullscreen
if (process.platform === 'darwin') {
mainWindow.on('enter-full-screen', () => {
mainWindow?.webContents.executeJavaScript(
'document.body.classList.add("electron-fullscreen")',
);
});
mainWindow.on('leave-full-screen', () => {
mainWindow?.webContents.executeJavaScript(
'document.body.classList.remove("electron-fullscreen")',
);
});
}
mainWindow.loadURL(url);
if (isDev) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
// ---------------------------------------------------------------------------
// Unsaved-changes close confirmation
// ---------------------------------------------------------------------------
let forceClose = false;
mainWindow.on('close', (event) => {
if (forceClose || !mainWindow || mainWindow.isDestroyed()) return;
// preventDefault must be called synchronously — check dirty state after
event.preventDefault();
const win = mainWindow;
win.webContents
.executeJavaScript(
'({ dirty: window.__documentIsDirty === true,' +
' message: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmMessage") : "",' +
' detail: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmDetail") : "",' +
' yes: typeof window.__i18nT === "function" ? window.__i18nT("common.yes") : "",' +
' no: typeof window.__i18nT === "function" ? window.__i18nT("common.no") : "",' +
' cancel: typeof window.__i18nT === "function" ? window.__i18nT("common.cancel") : "" })',
)
.then(
(result: {
dirty: boolean;
message: string;
detail: string;
yes: string;
no: string;
cancel: string;
}) => {
if (!result.dirty) {
// Not dirty — allow close
forceClose = true;
win.close();
return;
}
// Show native save dialog with i18n strings
return dialog
.showMessageBox(
win,
buildUnsavedChangesDialogOptions({
yesLabel: result.yes || 'Yes',
noLabel: result.no || 'No',
cancelLabel: result.cancel || 'Cancel',
message: result.message || 'Do you want to save changes before closing?',
detail: result.detail || "Your changes will be lost if you don't save them.",
}),
)
.then(({ response }) => {
const decision = mapUnsavedChangesResponse(response);
if (decision === 'save') {
// Save — tell renderer to save then confirm close
win.webContents.send('menu:action', 'save-and-close');
} else if (decision === 'discard') {
// No — force close without saving
forceClose = true;
win.close();
}
// Cancel — do nothing, window stays open
});
},
)
.catch(() => {
// Page not loaded or crashed — allow close
forceClose = true;
win.close();
});
});
// Renderer confirms save completed → force close
ipcMain.on('window:confirmClose', () => {
forceClose = true;
mainWindow?.close();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// ---------------------------------------------------------------------------
// IPC: native file dialogs & updater (extracted to ipc-handlers.ts)
// ---------------------------------------------------------------------------
function initIPC(): void {
setupIPC({
getMainWindow: () => mainWindow,
getPendingFilePath: () => pendingFilePath,
clearPendingFilePath: () => {
pendingFilePath = null;
},
prefsCache,
schedulePrefsWrite,
writeAppSettings,
});
setupGitIPC();
}
// ---------------------------------------------------------------------------
// File association: open .op files
// ---------------------------------------------------------------------------
/** Extract .op file path from command-line arguments. */
function getFilePathFromArgs(args: string[]): string | null {
for (const arg of args) {
// Skip flags and the Electron binary/script path
if (arg.startsWith('-') || arg.startsWith('--')) continue;
const ext = extname(arg).toLowerCase();
if (ext === '.op' || ext === '.pen') {
return arg;
}
}
return null;
}
/** Send a file path to the renderer for loading. */
function sendOpenFile(filePath: string): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file:open', filePath);
} else {
pendingFilePath = filePath;
}
}
// macOS: open-file fires when user double-clicks a .op file
app.on('open-file', (event, filePath) => {
event.preventDefault();
if (app.isReady()) {
sendOpenFile(filePath);
} else {
pendingFilePath = filePath;
}
});
// Single instance lock (Windows/Linux: second instance passes file path as arg)
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (_event, argv) => {
const filePath = getFilePathFromArgs(argv);
if (filePath) {
sendOpenFile(filePath);
}
// Focus existing window
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
app.on('ready', async () => {
await initLogger(app.getPath('userData'));
fixPath();
await loadPrefs();
initIPC();
buildAppMenu();
if (!isDev) {
try {
serverPort = await startNitroServer();
log.info(`Nitro server started on port ${serverPort}`);
await writePortFile(serverPort);
} catch (err) {
log.error(`Failed to start Nitro server: ${err}`);
dialog.showErrorBox(
'OpenPencil',
`Failed to start the application server.\n\n${err instanceof Error ? err.message : String(err)}\n\nThe application will now quit.`,
);
app.quit();
return;
}
} else {
// Dev mode: Vite dev server runs on port 3000
await writePortFile(VITE_DEV_PORT);
}
createWindow();
// Check for file to open: pending open-file event or CLI args (Windows/Linux).
// The file path is stored in pendingFilePath and pulled by the renderer
// via file:getPending IPC when the React app mounts (useElectronMenu hook).
if (!pendingFilePath) {
pendingFilePath = getFilePathFromArgs(process.argv);
}
if (!isDev) {
const settings = await readAppSettings();
const autoUpdate = settings.autoUpdate !== false;
setAutoUpdateEnabled(autoUpdate);
if (autoUpdate) {
setupAutoUpdater();
} else {
setUpdaterState({ status: 'disabled' });
}
}
});
app.on('window-all-closed', () => {
app.quit();
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
app.on('before-quit', () => {
clearUpdateTimer();
killNitroProcess();
killMcpServer();
cleanupPortFile().catch(() => {});
});
/** Kill the detached MCP server child (spawned by Nitro via mcp-server-manager). */
function killMcpServer(): void {
// PID file path matches apps/web/server/utils/mcp-server-manager.ts
const pidFile = join(tmpdir(), 'openpencil-mcp-server.pid');
const portFile = join(tmpdir(), 'openpencil-mcp-server.port');
try {
if (!existsSync(pidFile)) return;
const pid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
if (!Number.isFinite(pid)) return;
try {
process.kill(pid, 'SIGTERM');
} catch {
/* already dead */
}
} catch {
/* ignore */
}
try {
unlinkSync(pidFile);
} catch {
/* ignore */
}
try {
unlinkSync(portFile);
} catch {
/* ignore */
}
}
/** Platform-aware Nitro process termination. */
function killNitroProcess(): void {
if (!nitroProcess) return;
const pid = nitroProcess.pid;
if (process.platform === 'win32') {
// SIGTERM is unreliable on Windows; use taskkill for proper tree-kill
try {
if (pid) {
execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' });
}
} catch {
/* process may have already exited */
}
} else {
// SIGTERM may not be processed before the main process exits,
// leaving orphan Nitro processes. Kill the entire process group
// with SIGKILL for reliable cleanup.
try {
if (pid) process.kill(-pid, 'SIGKILL');
} catch {
/* process may have already exited */
}
try {
nitroProcess.kill('SIGKILL');
} catch {
/* ignore */
}
}
nitroProcess = null;
}
// Ensure child process cleanup on unexpected termination (Linux/macOS signals)
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
process.on(signal, () => {
killNitroProcess();
cleanupPortFile().finally(() => process.exit(0));
});
}