openpencil/electron/main.ts
Kayshen Xu ca1b5370ae
V0.3.0 (#24)
* feat(boolean-operations): implement boolean operations in the editor

- Added a new BooleanToolbar component for union, subtract, and intersect operations.
- Integrated boolean operations into the layer context menu and keyboard shortcuts.
- Enhanced the editor layout to include the boolean toolbar for improved user interaction.
- Updated internationalization support with new translation keys for boolean operations.
- Bumped version to 0.3.0 to reflect the addition of these features.

* refactor(editor): update editor layout and panels for improved functionality

- Replaced the PropertyPanel with a new RightPanel that includes both Property and Code panels.
- Removed the CodePanel from the main editor layout and integrated it into the RightPanel.
- Updated keyboard shortcuts to switch the right panel to the code tab.
- Enhanced the LayerPanel with a resizable width feature for better user experience.
- Added internationalization support for new right panel labels and code panel features.
- Introduced new code generation capabilities for various frameworks in the CodePanel.
- Improved overall layout structure for better responsiveness and usability.

* feat(electron): implement .op file association and enhance file handling

- Added support for .op file association in electron-builder, allowing OpenPencil documents to be opened directly from the file system.
- Implemented IPC handlers for opening and reading .op files, ensuring proper loading of document content.
- Enhanced the main process to handle file opening events on macOS and single-instance locking on Windows/Linux.
- Updated the renderer to listen for file open events and load documents accordingly.
- Improved README to reflect new file association feature.

* fix(canvas): improve layout accuracy for AI-generated designs

- Unify lineHeight default via canonical defaultLineHeight() function
- Unify text measurement by removing duplicate estimators in generation-utils
- Fix optical centering formula to scale proportionally with fontSize
- Round layout positions to whole pixels to prevent sub-pixel artifacts
- Recursively sanitize nested x/y in streaming layout containers
- Fix input trailing icon alignment using fill_container instead of space_between

* feat(canvas): right-align agent badge and add breathing glow border

- Agent badge now right-aligned to frame's right edge instead of after label
- Added breathing glow border around agent-owned frames during generation
- Glow border uses same color and lifecycle as the agent badge
- Removed unused BADGE_GAP constant and useDocumentStore import

* feat(code-panel): enhance tab scrolling functionality and add scrollbar utility

- Introduced left and right scroll buttons for tab navigation in the CodePanel, improving user experience for navigating long tab lists.
- Added a custom utility to hide scrollbars for a cleaner interface.
- Updated styles for better responsiveness and usability in the CodePanel layout.

* fix(docs): update Discord invite links in multiple README files

- Replaced outdated Discord invite links with the new link across all language-specific README files.
- Ensured consistency in the documentation for community engagement.

* feat(code-panel): enhance system prompt for responsive design

- Updated the ENHANCE_SYSTEM_PROMPT to emphasize the importance of responsive design in code rewriting.
- Added detailed guidelines for converting fixed pixel widths to relative units and using responsive Tailwind breakpoints.
- Ensured that the output remains visually faithful on desktop while adapting gracefully across screen sizes.

* feat(docs): add WeChat group information to README.zh.md and include group image

- Introduced a new section in the Chinese README to provide details about the WeChat group for community engagement.
- Added an image representing the WeChat group for better visibility and user interaction.

* feat(electron): enhance theme management and title bar overlay for Windows/Linux

- Updated the `setTheme` method in the Electron API to accept custom colors for the title bar overlay, improving theme synchronization across platforms.
- Adjusted title bar overlay colors for Windows and Linux to ensure proper visibility and aesthetics.
- Enhanced the top bar component to read computed CSS colors and apply them dynamically, ensuring a consistent user interface.
- Improved handling of theme changes in the application to support background and foreground color customization.

* fix(screenshot): update screenshot image for improved clarity and quality

* fix(docs): update WeChat group image path in README.zh.md for consistency

* fix(ai): fix post-generation validation pipeline and text centering

- Fix Agent SDK validation: save temp screenshots inside project dir
  (.openpencil-tmp/) so Claude Code plan mode can read them, instead
  of /tmp/ which is outside the project sandbox
- Enrich validation tree dump with fill colors, stroke, fontSize,
  fontWeight, textAlign, cornerRadius, opacity for comprehensive
  visual analysis
- Add multi-round validation with quality scoring (threshold 8/10),
  500ms stabilization delay between rounds
- Add detailed debug logging to applyValidationFixes showing which
  nodes were found/skipped and property changes
- Fix canvas sync needsTextbox check to also account for textAlign
  (matching isFixedWidthText in factory), preventing IText↔Textbox
  thrashing on every sync tick
- Auto-center text in vertical+center layouts by expanding to full
  container width and injecting textAlign:'center'
- Force Textbox for non-left-aligned text so textAlign is respected
  (IText ignores width and computes its own)

* fix(canvas): use precise text width estimation for fit-content layout

Remove the 14% safety factor from text width estimation when computing
fit-content/natural-width text dimensions. IText auto-computes its own
width and ignores our setting, so the safety margin only inflated the
layout allocation, making text appear left-shifted within its container.

* fix(canvas): center fit-content text in horizontal layouts

For text nodes with fit-content width in horizontal layouts, set
textAlign:'center' to compensate for width estimation inaccuracy.
The estimated box is typically wider than the actual rendered text,
causing left-aligned text to appear visually shifted. Centering
distributes the estimation error evenly on both sides.

* feat(ai): show validation details in checklist panel

- Accumulate validation log (screenshot, analysis, fixes) instead of
  overwriting status messages, so the full process is visible
- Preserve step thinking content in buildFinalStepTags (was discarded)
- Add details field to pipeline items and render in checklist UI
- Each validation step now shows: screenshot captured, issues found,
  quality score, fixes applied

* feat(ai): add visual reference pipeline types and integration hooks

- Add DesignSystem and VisualReference types to ai-types
- Add 'visual-ref' mode to AIDesignRequest and SubTask.htmlReference
- Detect visual-ref candidates in chat handlers (landing pages, websites)
- Wire visual-ref mode in design-generator and orchestrator
- Inject HTML reference snippets into sub-agent prompts

* feat(ai): add modular design principles for sub-agent context

- Add design-principles module with topic files: color, typography,
  spacing, composition, components
- Selectively load relevant principles based on prompt content
- Inject design principles into sub-agent system prompts

* feat(ai): implement visual reference pipeline

- Add design-system-generator: generates color/typography/spacing tokens
- Add design-code-generator: generates HTML/CSS from design system
- Add html-renderer: renders HTML to screenshot via html2canvas
- Add visual-ref-orchestrator: coordinates the full pipeline
  (design system → HTML code → screenshot → enrich subtasks)
- Add html2canvas dependency for client-side HTML rendering

* feat(mcp): default filePath to live canvas and fix cross-platform issues

- Default all MCP tool filePath to live://canvas when omitted, so tools
  operate on the real-time canvas instead of stale files
- Remove filePath from required params in all tool schemas (21 interfaces)
- Fix mcp-server-manager.ts using process.cwd() which fails in Electron
  production on Linux — now checks ELECTRON_RESOURCES_PATH first
- Fix stopMcpHttpServer using SIGTERM on Windows — use taskkill instead
- Force new children reference in applyExternalDocument to ensure canvas
  sync subscriber always detects MCP-pushed document updates

* feat(mcp): enhance design prompt with semantic roles, CJK typography, and layout rules

Add comprehensive design knowledge to MCP design prompt for better
AI-generated designs: design type detection (mobile vs desktop), full
semantic role reference with context-aware defaults, CJK typography
rules, expanded text/layout/form guidelines, and detailed post-processing
documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(ai): implement intent classification for chat handlers

- Replace hardcoded keyword matching with a lightweight LLM call to classify user intent in chat messages.
- Introduce a new function `classifyIntent` to determine if the request is for design generation or conversation.
- Update design request handling in `useChatHandlers` to utilize the new classification method.
- Enhance design prompt documentation to reflect changes in design type detection based on intent rather than keywords.

* fix(ai): handle string qualityScore in validation response parsing

The LLM sometimes returns qualityScore as a string (e.g. "8" instead of 8),
causing it to fall through to 0. Also hide misleading "quality: 0/10" display
when the score couldn't be determined, and log raw response for debugging.

* fix(ai): increase validation timeout to 90s and fix quality score parsing

Agent SDK validation requires spawning a process, reading the image, and
analyzing it — 30s was consistently timing out. Also handle string
qualityScore values from LLM responses and hide misleading 0/10 display.

* fix(ai): fix validation timeout and response parsing

- Increase validation timeout from 30s to 180s (Agent SDK needs time
  for subprocess spawn + OAuth auth + multi-turn image reading)
- Strip <tool_use> XML blocks from Agent SDK response before extracting
  JSON — the tool call XML was confusing the regex, causing qualityScore
  to parse as 0 despite valid JSON being present
- Handle string qualityScore values and hide misleading "quality: 0/10"
- Revert unnecessary direct API key approach for validation

* fix(ai): prevent node ID collisions between generations

When generating new content on a canvas with existing nodes, AI-generated
IDs (e.g. brand-spacer) would collide with previous generations. Now
captures pre-existing node IDs at generation start and checks against
them during upsert sanitization. Remapped IDs are tracked in
generationRemappedIds so progressive streaming updates can still find
their nodes.

* fix(ai): require styleGuide in orchestrator plan and fix validation detail icons

- Add fallback default styleGuide when orchestrator LLM omits it
- Strengthen prompt to mark styleGuide as REQUIRED
- Replace emoji icons in validation details with [done]/[pending]/[error]
  markers for consistent styling with the checklist design system

* feat(server): add port file plugin for server instance discovery

- Introduce a new Nitro plugin that writes a port file on server startup to allow the MCP server to discover the running instance, whether it's a development server or Electron.
- Implement error handling in the Electron main process for writing the port file, logging any failures.
- Update Vite configuration to include additional external dependencies in the rollup configuration.

* feat(electron): implement IPC for retrieving pending file paths

- Added a new IPC handler `file:getPending` to retrieve and clear the pending file path when the React app mounts.
- Updated the Electron API to include `getPendingFile` for renderer access.
- Enhanced the `useElectronMenu` hook to load any pending file on application startup.
- Updated UI components to reflect changes in file handling and improved user experience.

* fix(panels): replace emoji icons with styled icons in validation checklist

- Parse [done]/[pending]/[error] prefixes in detail lines and render as
  styled circle icons matching the parent checklist design system
- Replace remaining emoji markers in design-validation.ts with text prefixes
- Fix isApplied detection to recognize new [done] Applied marker

* refactor(electron): update settings path to use platform-standard app data directory

- Changed the settings file path to utilize Electron's user data directory for better cross-platform compatibility.
- Updated the settings writing function to ensure the user data directory is created if it doesn't exist.
- Added comments to clarify the storage location for different operating systems.
- Implemented a fixed partition for localStorage/cookies to maintain data across server port changes.

* feat(ai): enhance validation with pre-checks, structural fixes, and border detection

- Add design-pre-validation.ts: pure code checks before LLM validation
  - Invisible container detection (same fill as parent → auto-add border)
  - Sibling consistency (majority-rule for height/cornerRadius)
- Add structural fixes to validation: addChild/removeNode operations
  - Icon injection via lookupIconByName with server fallback
  - autoFixParentLayout with child count guard to prevent layout breakage
- Add strokeColor/strokeWidth to safe fix properties for border fixes
- Simplify intent classification: all design requests use visual-ref pipeline
- Fix checklist: "Found N issues" now shows [done] instead of [pending]
- Fix qualityScore: only update when > 0 to preserve valid round scores

* fix(ai): cherry-pick safe validation improvements, drop aggressive pre-checks

Keep: stroke tree dump bug fix (object not array), qualityScore=0 false
positive detection, fit_content→fixed safety guard, empty path removal,
type-specific sibling consistency, repeated fix filtering, screenshot
extraction to design-screenshot.ts.

Drop: detectForcedFixedHeight (destroyed input/button heights),
MAX_VALIDATION_ROUNDS 5 (too many rounds), removal of quality threshold
early stop, section regeneration phase.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:55:35 +08:00

853 lines
24 KiB
TypeScript

import {
app,
BrowserWindow,
Menu,
ipcMain,
dialog,
type BrowserWindowConstructorOptions,
} from 'electron'
import { autoUpdater } from 'electron-updater'
import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider'
import { execSync } from 'node:child_process'
import { fork, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
import { join, resolve, extname } from 'node:path'
import { homedir } from 'node:os'
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
let mainWindow: BrowserWindow | null = null
let nitroProcess: ChildProcess | null = null
let serverPort = 0
let autoUpdateEnabled = true
let updateCheckTimer: ReturnType<typeof setInterval> | null = null
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')
type UpdaterStatus =
| 'disabled'
| 'idle'
| 'checking'
| 'available'
| 'downloading'
| 'downloaded'
| 'not-available'
| 'error'
interface UpdaterState {
status: UpdaterStatus
currentVersion: string
latestVersion?: string
downloadProgress?: number
releaseDate?: string
error?: string
}
let updaterState: UpdaterState = {
status: isDev ? 'disabled' : 'idle',
currentVersion: app.getVersion(),
}
const MacGitHubUpdateProvider = class {
constructor(options: unknown, updater: unknown, runtimeOptions: unknown) {
const provider = new (GitHubProvider as any)(options, updater, runtimeOptions) as any
if (process.platform === 'darwin') {
provider.getDefaultChannelName = () =>
process.arch === 'arm64' ? 'latest-mac-arm64' : 'latest-mac'
provider.getCustomChannelName = (channel: string) => channel
}
return provider
}
}
let lastUpdateCheckAt = 0
function broadcastUpdaterState(): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('updater:state', updaterState)
}
}
}
function setUpdaterState(next: Partial<UpdaterState>): void {
updaterState = {
...updaterState,
...next,
currentVersion: app.getVersion(),
}
broadcastUpdaterState()
}
async function checkForAppUpdates(force = false): Promise<void> {
if (isDev) return
const now = Date.now()
if (!force && now - lastUpdateCheckAt < 60 * 1000) {
return
}
lastUpdateCheckAt = now
try {
await autoUpdater.checkForUpdates()
} catch (err) {
const error = err instanceof Error ? err.message : String(err)
setUpdaterState({ status: 'error', error })
}
}
function setupAutoUpdater(): void {
if (isDev) return
if (process.platform === 'darwin') {
autoUpdater.setFeedURL({
provider: 'custom',
updateProvider: MacGitHubUpdateProvider as any,
owner: 'ZSeven-W',
repo: 'openpencil',
releaseType: 'release',
} as any)
}
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.allowPrerelease = true
autoUpdater.on('checking-for-update', () => {
setUpdaterState({ status: 'checking', error: undefined, downloadProgress: undefined })
})
autoUpdater.on('update-available', (info) => {
setUpdaterState({
status: 'available',
latestVersion: info.version,
releaseDate: info.releaseDate,
error: undefined,
})
})
autoUpdater.on('download-progress', (progress) => {
setUpdaterState({
status: 'downloading',
downloadProgress: Math.round(progress.percent),
error: undefined,
})
})
autoUpdater.on('update-downloaded', (info) => {
setUpdaterState({
status: 'downloaded',
latestVersion: info.version,
releaseDate: info.releaseDate,
downloadProgress: 100,
error: undefined,
})
})
autoUpdater.on('update-not-available', (info) => {
setUpdaterState({
status: 'not-available',
latestVersion: info.version,
downloadProgress: undefined,
error: undefined,
})
})
autoUpdater.on('error', (err) => {
setUpdaterState({
status: 'error',
error: err?.message ?? String(err),
})
})
if (autoUpdateEnabled) {
// Delay first check until app startup work is done.
setTimeout(() => {
void checkForAppUpdates(true)
}, 5000)
// Poll for new versions while app is running.
updateCheckTimer = setInterval(() => {
void checkForAppUpdates(false)
}, 60 * 60 * 1000)
updateCheckTimer.unref()
}
}
// ---------------------------------------------------------------------------
// Fix PATH for macOS GUI apps (Finder doesn't inherit shell PATH)
// ---------------------------------------------------------------------------
function fixPath(): void {
if (process.platform !== 'darwin' && process.platform !== 'linux') return
try {
const shell = process.env.SHELL || '/bin/zsh'
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 — ignore
}
}
// ---------------------------------------------------------------------------
// App settings (~/.openpencil/settings.json)
// ---------------------------------------------------------------------------
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(), '.openpencil')
const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port')
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) {
console.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, '127.0.0.1', () => {
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 .output/server/index.mjs
return join(app.getAppPath(), '.output', 'server', 'index.mjs')
}
// In production, extraResources copies .output 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: '127.0.0.1',
PORT: String(port),
NITRO_HOST: '127.0.0.1',
NITRO_PORT: String(port),
ELECTRON_RESOURCES_PATH: process.resourcesPath,
},
stdio: 'pipe',
})
nitroProcess = child
child.stdout?.on('data', (data: Buffer) => {
const msg = data.toString()
console.log('[nitro]', msg)
// Resolve once Nitro reports it's listening
if (msg.includes('Listening') || msg.includes('ready')) {
resolve(port)
}
})
child.stderr?.on('data', (data: Buffer) => {
console.error('[nitro:err]', data.toString())
})
child.on('error', reject)
child.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`Nitro exited with code ${code}`)
}
nitroProcess = null
})
// Fallback: if no stdout "ready" message comes, wait then resolve anyway
setTimeout(() => resolve(port), 3000)
})
}
// ---------------------------------------------------------------------------
// Linux window-controls side detection
// ---------------------------------------------------------------------------
/**
* Detect whether Linux DE places window controls on the left or right.
* Uses gsettings (GNOME/Cinnamon/MATE) as primary check, defaults to right
* for KDE/XFCE and when detection fails.
*/
function getLinuxControlsSide(): 'left' | 'right' {
try {
const layout = execSync(
'gsettings get org.gnome.desktop.wm.preferences button-layout',
{ encoding: 'utf-8', timeout: 3000 },
)
.trim()
.replace(/'/g, '')
// Format: "close,minimize,maximize:" → left, ":minimize,maximize,close" → right
const colonIndex = layout.indexOf(':')
if (colonIndex < 0) return 'right'
const beforeColon = layout.slice(0, colonIndex)
if (
beforeColon.includes('close') ||
beforeColon.includes('minimize') ||
beforeColon.includes('maximize')
) {
return 'left'
}
return 'right'
} catch {
return 'right'
}
}
// ---------------------------------------------------------------------------
// Window
// ---------------------------------------------------------------------------
function createWindow(): void {
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
const windowOptions: BrowserWindowConstructorOptions = {
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 600,
title: 'OpenPencil',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
...(isWinOrLinux
? {
titleBarOverlay: {
// Windows supports transparent overlay; Linux needs solid color
color: process.platform === 'win32' ? 'rgba(0,0,0,0)' : '#111',
symbolColor: '#d4d4d8',
height: 36,
},
}
: {}),
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',
},
}
if (process.platform === 'darwin') {
windowOptions.trafficLightPosition = { x: 16, y: 11 }
}
// 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)
}
const url = isDev
? 'http://localhost:3000/editor'
: `http://127.0.0.1:${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: 74px; }' +
'.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }',
)
}
if (process.platform === 'win32') {
await mainWindow.webContents.insertCSS(
'.electron-win-controls-pad { margin-right: 140px; }',
)
}
if (process.platform === 'linux') {
const side = getLinuxControlsSide()
if (side === 'left') {
await mainWindow.webContents.insertCSS(
'.electron-traffic-light-pad { margin-left: 140px; }',
)
} else {
await mainWindow.webContents.insertCSS(
'.electron-win-controls-pad { margin-right: 140px; }',
)
}
}
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' })
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
// ---------------------------------------------------------------------------
// IPC: native file dialogs
// ---------------------------------------------------------------------------
function setupIPC(): void {
ipcMain.handle('dialog:openFile', async () => {
if (!mainWindow) return null
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Open .op file',
filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const content = await readFile(filePath, 'utf-8')
return { filePath, content }
})
ipcMain.handle(
'dialog:saveFile',
async (_event, payload: { content: string; defaultPath?: string }) => {
if (!mainWindow) return null
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Save .op file',
defaultPath: payload.defaultPath,
filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
})
if (result.canceled || !result.filePath) return null
await writeFile(result.filePath, payload.content, 'utf-8')
return result.filePath
},
)
ipcMain.handle(
'dialog:saveToPath',
async (_event, payload: { filePath: string; content: string }) => {
const resolved = resolve(payload.filePath)
if (resolved.includes('\0')) {
throw new Error('Invalid file path')
}
const ext = extname(resolved).toLowerCase()
if (ext !== '.op' && ext !== '.pen') {
throw new Error('Only .op and .pen file extensions are allowed')
}
// Directory allowlist: only allow writes under user home or OS temp
const allowedRoots = [app.getPath('home'), app.getPath('temp')]
const inAllowedDir = allowedRoots.some(
(root) => resolved === root || resolved.startsWith(root + '/') || resolved.startsWith(root + '\\'),
)
if (!inAllowedDir) {
throw new Error('File path must be within the user home or temp directory')
}
await writeFile(resolved, payload.content, 'utf-8')
return resolved
},
)
ipcMain.handle('file:getPending', () => {
if (pendingFilePath) {
const filePath = pendingFilePath
pendingFilePath = null
return filePath
}
return null
})
ipcMain.handle('file:read', async (_event, filePath: string) => {
const resolved = resolve(filePath)
const ext = extname(resolved).toLowerCase()
if (ext !== '.op' && ext !== '.pen') return null
try {
const content = await readFile(resolved, 'utf-8')
return { filePath: resolved, content }
} catch {
return null
}
})
// Theme sync for Windows/Linux title bar overlay
ipcMain.handle(
'theme:set',
(_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => {
if (!mainWindow || mainWindow.isDestroyed()) return
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
if (!isWinOrLinux) return
const isLinux = process.platform === 'linux'
const fallbackBg = theme === 'dark' ? '#111' : '#fff'
const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46'
mainWindow.setTitleBarOverlay({
// Windows supports transparent overlay; Linux uses actual CSS card color
color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)',
symbolColor: colors?.fg || fallbackFg,
})
},
)
ipcMain.handle('updater:getState', () => updaterState)
ipcMain.handle('updater:checkForUpdates', async () => {
await checkForAppUpdates(true)
return updaterState
})
ipcMain.handle('updater:quitAndInstall', () => {
if (!isDev && updaterState.status === 'downloaded') {
autoUpdater.quitAndInstall()
return true
}
return false
})
ipcMain.handle('updater:getAutoCheck', () => autoUpdateEnabled)
ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => {
autoUpdateEnabled = enabled
await writeAppSettings({ autoUpdate: enabled })
if (enabled) {
// Start polling if not already running
if (!updateCheckTimer) {
updateCheckTimer = setInterval(() => {
void checkForAppUpdates(false)
}, 60 * 60 * 1000)
updateCheckTimer.unref()
}
setUpdaterState({ status: 'idle' })
} else {
// Stop polling
if (updateCheckTimer) {
clearInterval(updateCheckTimer)
updateCheckTimer = null
}
setUpdaterState({ status: 'disabled' })
}
return enabled
})
}
// ---------------------------------------------------------------------------
// Application menu
// ---------------------------------------------------------------------------
function sendMenuAction(action: string): void {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
win?.webContents.send('menu:action', action)
}
function buildAppMenu(): void {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [
// macOS app menu
...(isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' as const },
{ type: 'separator' as const },
{ role: 'services' as const },
{ type: 'separator' as const },
{ role: 'hide' as const },
{ role: 'hideOthers' as const },
{ role: 'unhide' as const },
{ type: 'separator' as const },
{ role: 'quit' as const },
],
},
]
: []),
// File menu
{
label: 'File',
submenu: [
{
label: 'New',
accelerator: 'CmdOrCtrl+N',
click: () => sendMenuAction('new'),
},
{
label: 'Open\u2026',
accelerator: 'CmdOrCtrl+O',
click: () => sendMenuAction('open'),
},
{ type: 'separator' },
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => sendMenuAction('save'),
},
{ type: 'separator' },
{
label: 'Import Figma\u2026',
accelerator: 'CmdOrCtrl+Shift+F',
click: () => sendMenuAction('import-figma'),
},
...(!isMac
? [{ type: 'separator' as const }, { role: 'quit' as const }]
: []),
],
},
// Edit menu (role-based for native text input support)
{
label: 'Edit',
submenu: [
{
label: 'Undo',
accelerator: 'CmdOrCtrl+Z',
click: () => sendMenuAction('undo'),
},
{
label: 'Redo',
accelerator: 'CmdOrCtrl+Shift+Z',
click: () => sendMenuAction('redo'),
},
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac ? [{ role: 'pasteAndMatchStyle' as const }] : []),
{ role: 'selectAll' },
],
},
// View menu
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
// Window menu
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' as const },
{ role: 'front' as const },
]
: [{ role: 'close' as const }]),
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
// ---------------------------------------------------------------------------
// File association: open .op files
// ---------------------------------------------------------------------------
/** Extract .op file path from command-line arguments. */
function getFilePathFromArgs(args: string[]): string | null {
for (const arg of args) {
if (arg.endsWith('.op') || arg.endsWith('.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 () => {
fixPath()
setupIPC()
buildAppMenu()
if (!isDev) {
try {
serverPort = await startNitroServer()
console.log(`Nitro server started on port ${serverPort}`)
await writePortFile(serverPort)
} catch (err) {
console.error('Failed to start Nitro server:', err)
app.quit()
return
}
} else {
// Dev mode: Vite dev server runs on port 3000
await writePortFile(3000)
}
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()
autoUpdateEnabled = settings.autoUpdate !== false
if (autoUpdateEnabled) {
setupAutoUpdater()
} else {
setUpdaterState({ status: 'disabled' })
}
}
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
app.on('before-quit', async () => {
await cleanupPortFile()
if (nitroProcess) {
nitroProcess.kill()
nitroProcess = null
}
})