* docs: add image search & generation design spec and implementation plan - Spec: dual-source image search (Openverse + Wikimedia), multi-provider image generation - Plan: 16 tasks covering types, server endpoints, settings UI, property panel, auto-search pipeline, MCP integration * feat(types): add image service types and imagePrompt to ImageNode * feat(server): add image service API key validation endpoint Adds POST /api/ai/image-service-test that validates credentials for openverse (client_credentials), openai/custom (Bearer + /v1/models), gemini (API key + v1beta/models), and replicate (Bearer + /v1/models). * feat(server): add multi-provider image generation endpoint * feat(server): add dual-source image search endpoint (Openverse + Wikimedia) POST /api/ai/image-search searches freely-licensed images via Openverse with automatic fallback to Wikimedia Commons on 429 rate-limit responses. Supports optional OAuth credentials for authenticated Openverse requests. * feat(store): add imageSearchStatuses to canvas store for runtime status tracking * feat(store): add image generation config and Openverse OAuth to agent settings * feat(editor): add Images tab to agent settings dialog Adds Popover primitive, ImagesPage component with Image Search (Openverse OAuth, test) and Image Generation (provider select, API key, model, base URL) sections, and wires them into the settings dialog sidebar. * feat(panels): add image search popover with Openverse/Wikimedia results grid * feat(panels): add image generate popover with multi-provider support * feat(panels): add Search and Generate buttons to image property section * feat(ai): update prompts to use imagePrompt instead of src for image nodes * feat(ai): add auto-search pipeline with Openverse/Wikimedia fallback * feat(ai): trigger auto image search after design generation completes * feat(mcp): implement G() operation for image search in batch design DSL Adds the G(parent, mode, prompt) operation to batch_design DSL that creates an image node and optionally fetches a real image URL via the image-search API when mode is "search". Converts executeLine to async to support the network call. * feat(mcp): auto-fill images after design refinement in layered pipeline * feat(ai): split imageSearchQuery and imagePrompt for search vs generation - ImageNode now has both imageSearchQuery (short keywords for search) and imagePrompt (long description for AI image generation) - AI prompts instruct LLM to generate both fields - Search pipeline and popovers use imageSearchQuery - Generate popover uses imagePrompt - Server-side simplifySearchQuery kept as fallback for manual input * fix(ai): hook auto image search into orchestrator completion path The primary generation path uses executeOrchestration -> insertStreamingNode, not applyNodesToCanvas/animateNodesToCanvas. Added scanAndFillImages call to orchestrator.ts after all sub-agents complete. Added debug logging. Removed plan/spec docs from git. * style(editor): remove provider names from image search ready status * fix(panels): clean up image gen error display and settings UI - Parse API error response to show concise message instead of raw JSON - Limit error text to 2 lines with line-clamp - Fix image gen test button sending wrong service name - Inline Image Search ready indicator with section header - Remove debug logging from image search pipeline * style(panels): allow up to 4 lines for image gen error message * fix: avoid 1-frame delay when resizing canvas (#60) rAF callbacks run before ResizeObserver in the same frame. Scheduling render in ResizeObserver via rAF defers it to the next frame. Invoke render() synchronously to leverage ResizeObserver's pre-paint timing and ensure immediate visual update. * feat(electron): implement desktop application structure and auto-updater - Introduced a new Electron desktop application with a structured directory for apps and packages. - Added auto-updater functionality to manage application updates seamlessly. - Created a comprehensive menu system for the desktop app. - Implemented logging capabilities for better debugging and error tracking. - Configured build settings for various platforms (macOS, Windows, Linux) using electron-builder. - Established TypeScript configurations for both the desktop and web applications. - Integrated Vite for the web application with support for React and Tailwind CSS. - Added icons and assets for the desktop application. * chore: update package versions to 0.5.0 across all package.json files and add pre-commit hook for version synchronization - Bumped version to 0.5.0 in package.json files for the main project, desktop app, web app, and all packages. - Introduced a pre-commit hook to automatically sync version numbers from branch names to all package.json files. * chore: update package versions to 0.5.0 and refactor Skia components - Bumped version to 0.5.0 in bun.lock and all relevant package.json files. - Refactored Skia components to utilize shared functionality from @zseven-w/pen-renderer, including image loading, hit testing, and path utilities. - Removed redundant code and improved modularity by re-exporting necessary functions and classes from the renderer package. * fix(panels): handle string fill values in icon nodes (#61) AI-generated icon/path nodes may have fill stored as a raw string instead of a PenFill[] array, causing "Cannot use 'in' operator" crash when selecting the node in the property panel. * chore: update documentation and project structure for monorepo organization - Added a new version bump command to synchronize all package.json files. - Updated the project structure to reflect a monorepo setup with organized workspaces for apps and packages. - Enhanced README files in multiple languages to include the new structure and commands. - Adjusted image paths in documentation to point to the correct locations for the desktop application. * feat(ai): incremental image search and improved image generation prompts - Refactor image search from batch post-generation to incremental queue: enqueueImageForSearch() triggers as each image node is inserted during streaming, so images appear progressively instead of all at once after generation completes. scanAndFillImages() remains as a final sweep. - Update imagePrompt guidance to avoid "transparent background" and similar phrases that many models cannot reliably produce. - Pass node width/height from image panel to generation endpoint for aspect-ratio-aware output (Gemini aspect ratio mapping, OpenAI size selection, Replicate dimensions). * feat(ai): multi-profile image generation config and cleaner error messages - Support multiple image generation profiles with active selection; first configured profile becomes default. Old single-config migrated automatically on hydrate. - Fix Gemini aspect ratio: move to generationConfig.imageConfig per API spec. - Extract clean error messages from provider JSON responses (Gemini error.message, OpenAI error.message, Replicate detail) instead of returning raw JSON text. - Remove destructive client-side regex that mangled error display. * feat(design-md): integrate design system panel and functionality - Added a new DesignMdPanel component for managing design system specifications. - Implemented functionality to toggle the design system panel in the editor layout and toolbar. - Introduced new commands for importing, exporting, and auto-generating design.md content. - Updated AI chat handlers to utilize design.md data for enhanced design generation. - Enhanced localization support for design system features across multiple languages. * perf(canvas): skip draw calls for nodes outside the viewport (#64) Add viewport culling in render() to avoid issuing CanvasKit draw calls for off-screen nodes. A 64px screen-space buffer is kept around the viewport edges so nearby nodes are pre-rendered, preventing pop-in during fast panning. * feat(utils): enhance Windows process spawning for CLI scripts - Updated the buildSpawnClaudeCodeProcess function to handle .cmd and .ps1 scripts appropriately. - Implemented PowerShell invocation for .ps1 files and ensured safe defaults for .cmd and .exe files. - Improved handling of command execution to avoid limitations of cmd.exe. * feat(ai): add support for Gemini CLI integration - Extended the AI provider options to include 'gemini' across various components and APIs. - Implemented functions for generating, validating, and connecting to the Gemini CLI. - Added Gemini-specific error handling and model fetching logic. - Updated UI components to display Gemini as a selectable provider with appropriate icons and labels. - Enhanced localization support for Gemini-related features in multiple languages. * feat(editor): warn before closing with unsaved changes Intercept window/tab close when isDirty is true: - Electron: native dialog with Save / Don't Save / Cancel - Web: beforeunload handler + confirm on New/Open actions - i18n: close-confirm strings for all 15 locales * feat(ipc): extract IPC handlers to a dedicated module - Moved IPC dialog handling and updater functions from main.ts to ipc-handlers.ts for better organization and maintainability. - Implemented file open/save dialogs, theme setting, and preferences management through IPC. - Enhanced updater functionality with state management and auto-update settings. - Improved code structure by separating concerns, making it easier to manage IPC-related logic. * feat(docs): update CLAUDE documentation and add new files for desktop and web apps - Enhanced CLAUDE.md with detailed module documentation references for `packages/` and `apps/`. - Updated `pen-core` description to include clone utilities in `pen-core`. - Added new documentation files for the desktop and web applications, outlining their structure, components, and functionalities. - Included IPC handler details in the desktop app documentation for better clarity on file dialogs and theme synchronization. * feat(docker): add Gemini CLI support and update documentation - Introduced a new Docker build stage for the Gemini CLI, allowing users to install and run it. - Updated the Dockerfile to include the installation of the Gemini CLI alongside existing CLI tools. - Enhanced README files in multiple languages to document the new `openpencil-gemini` image variant. - Added Gemini CLI connection instructions to the main README for better user guidance. * feat(docs): add Gemini CLI connection instructions to multiple language READMEs - Updated README files in German, Spanish, French, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Thai, Turkish, Vietnamese, and both Traditional and Simplified Chinese to include connection instructions for the Gemini CLI. - Enhanced documentation to improve user guidance for connecting the Gemini CLI in agent settings. * perf(renderer): replace count-based text cache limits with memory-based eviction (#66) previous limits (PARA_CACHE_MAX=200, TEXT_CACHE_MAX=300) were too small for scenes with many nodes, causing constant cache churn and paragraph rebuilds every frame, which dropped FPS significantly during canvas pan. - switch to byte-budget limits (64 MB paragraphs, 256 MB bitmaps) - bitmap size measured exactly as cw*ch*4; paragraph WASM heap estimated as content.length*64+4096 - eviction uses Map insertion order (FIFO) instead of a separate string[] array, replacing O(n) array.shift() with O(1) Map.entries().next() - evict before insert so the budget check includes the incoming entry * feat(docker): update Dockerfile to include additional package.json files - Added package.json files for multiple packages (pen-types, pen-core, pen-codegen, pen-figma, pen-renderer, pen-sdk) and apps (web, desktop) to the Docker build context. - Ensured all necessary dependencies are included for a complete build process. * refactor(docker): update Dockerfile to use new output directory structure - Changed the output directory from `/.output` to `/out/web` for all build stages in the Dockerfile. - Updated CMD instructions to reflect the new path for the server entry point. - Ensured consistency across all CLI variants for improved maintainability. * fix(main): update server entry point paths for development and production - Changed the development server entry point path from `.output/server/index.mjs` to `out/web/server/index.mjs`. - Updated the production comment to reflect the new output directory structure for consistency. --------- Co-authored-by: Fini <fini.yang@gmail.com> Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
689 lines
22 KiB
TypeScript
689 lines
22 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 } from 'node:os'
|
|
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'
|
|
|
|
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',
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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") : "",' +
|
|
' save: typeof window.__i18nT === "function" ? window.__i18nT("common.save") : "",' +
|
|
' dontSave: typeof window.__i18nT === "function" ? window.__i18nT("topbar.dontSave") : "",' +
|
|
' cancel: typeof window.__i18nT === "function" ? window.__i18nT("common.cancel") : "" })',
|
|
)
|
|
.then((result: { dirty: boolean; message: string; detail: string; save: string; dontSave: 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, {
|
|
type: 'question',
|
|
buttons: [
|
|
result.save || 'Save',
|
|
result.dontSave || "Don't Save",
|
|
result.cancel || 'Cancel',
|
|
],
|
|
defaultId: 0,
|
|
cancelId: 2,
|
|
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 }) => {
|
|
if (response === 0) {
|
|
// Save — tell renderer to save then confirm close
|
|
win.webContents.send('menu:action', 'save-and-close')
|
|
} else if (response === 1) {
|
|
// Don't Save — force close
|
|
forceClose = true
|
|
win.close()
|
|
}
|
|
// Cancel (response === 2) — 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,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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()
|
|
cleanupPortFile().catch(() => {})
|
|
})
|
|
|
|
/** 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))
|
|
})
|
|
}
|