feat(electron): integrate Electron framework for desktop application support

- Add Electron configuration and main process setup for building a desktop application.
- Implement IPC communication for file operations (open, save) between the renderer and main processes.
- Create a preload script to expose Electron APIs to the renderer.
- Update package.json to include Electron and related dependencies.
- Enhance the build process with electron-builder for packaging the application.
- Introduce a new electron-builder.yml configuration file for build settings.
- Modify Vite configuration to support Electron-specific builds.
- Update UI components to accommodate Electron's window management and drag regions.
This commit is contained in:
Kayshen-X 2026-02-20 20:19:06 +08:00
parent 0569932381
commit d51510d7e9
17 changed files with 1409 additions and 51 deletions

2
.gitignore vendored
View file

@ -12,3 +12,5 @@ count.txt
.vinxi
__unconfig*
todos.json
electron-dist/
dist-electron/

835
bun.lock

File diff suppressed because it is too large Load diff

51
electron-builder.yml Normal file
View file

@ -0,0 +1,51 @@
appId: dev.openpencil.app
productName: OpenPencil
copyright: Copyright (c) 2024-2026 OpenPencil contributors
directories:
output: dist-electron
buildResources: build
files:
- electron-dist/**/*
- "!node_modules"
extraResources:
- from: .output/server
to: server
- from: .output/public
to: public
mac:
category: public.app-category.graphics-design
icon: build/icon.icns
target:
- dmg
- zip
hardenedRuntime: true
gatekeeperAssess: false
dmg:
title: "${productName} ${version}"
win:
icon: build/icon.ico
target:
- nsis
- portable
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
linux:
icon: build/icon.png
category: Graphics
target:
- AppImage
- deb
asar: true
publish: null

256
electron/main.ts Normal file
View file

@ -0,0 +1,256 @@
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 } from 'node:path'
import { readFile, writeFile } from 'node:fs/promises'
let mainWindow: BrowserWindow | null = null
let nitroProcess: ChildProcess | null = null
let serverPort = 0
const isDev = !app.isPackaged
// ---------------------------------------------------------------------------
// 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
}
}
// ---------------------------------------------------------------------------
// 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),
},
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)
})
}
// ---------------------------------------------------------------------------
// Window
// ---------------------------------------------------------------------------
function createWindow(): void {
const windowOptions: BrowserWindowConstructorOptions = {
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 600,
title: 'OpenPencil',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
},
}
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)
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; }',
)
}
mainWindow.show()
})
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 .pen file',
filters: [{ name: 'Pen Files', extensions: ['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 .pen file',
defaultPath: payload.defaultPath,
filters: [{ name: 'Pen Files', extensions: ['pen'] }],
})
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 }) => {
await writeFile(payload.filePath, payload.content, 'utf-8')
return payload.filePath
},
)
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
app.on('ready', async () => {
fixPath()
setupIPC()
if (!isDev) {
try {
serverPort = await startNitroServer()
console.log(`Nitro server started on port ${serverPort}`)
} catch (err) {
console.error('Failed to start Nitro server:', err)
app.quit()
return
}
}
createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
app.on('before-quit', () => {
if (nitroProcess) {
nitroProcess.kill()
nitroProcess = null
}
})

25
electron/preload.ts Normal file
View file

@ -0,0 +1,25 @@
import { contextBridge, ipcRenderer } from 'electron'
export interface ElectronAPI {
isElectron: true
openFile: () => Promise<{ filePath: string; content: string } | null>
saveFile: (
content: string,
defaultPath?: string,
) => Promise<string | null>
saveToPath: (filePath: string, content: string) => Promise<string>
}
const api: ElectronAPI = {
isElectron: true,
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (content: string, defaultPath?: string) =>
ipcRenderer.invoke('dialog:saveFile', { content, defaultPath }),
saveToPath: (filePath: string, content: string) =>
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
}
contextBridge.exposeInMainWorld('electronAPI', api)

15
electron/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "../electron-dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["./**/*.ts"]
}

View file

@ -1,12 +1,19 @@
{
"name": "openpencil",
"version": "0.0.1",
"description": "Open-source vector design tool with Design-as-Code philosophy",
"author": "ZSeven-W",
"private": true,
"type": "module",
"main": "electron-dist/main.cjs",
"scripts": {
"dev": "bun --bun vite dev --port 3000",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
"test": "bun --bun vitest run"
"test": "bun --bun vitest run",
"electron:dev": "bun run scripts/electron-dev.ts",
"electron:compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node20 --outdir=electron-dist --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap",
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && npx electron-builder --config electron-builder.yml"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
@ -46,6 +53,9 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"electron": "^35.0.0",
"electron-builder": "^26.0.0",
"esbuild": "^0.25.0",
"jsdom": "^27.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",

110
scripts/electron-dev.ts Normal file
View file

@ -0,0 +1,110 @@
/**
* Electron development workflow orchestrator.
*
* 1. Start Vite dev server (bun run dev)
* 2. Wait for it to be ready on port 3000
* 3. Compile electron/ with esbuild
* 4. Launch Electron pointing at the dev server
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { build } from 'esbuild'
import { join } from 'node:path'
const ROOT = join(import.meta.dirname, '..')
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function waitForServer(
url: string,
timeoutMs = 30_000,
): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url)
if (res.ok || res.status < 500) return
} catch {
// server not ready yet
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`Timeout waiting for ${url}`)
}
async function compileElectron(): Promise<void> {
const common: Parameters<typeof build>[0] = {
platform: 'node',
bundle: true,
sourcemap: true,
external: ['electron'],
target: 'node20',
outdir: join(ROOT, 'electron-dist'),
outExtension: { '.js': '.cjs' },
format: 'cjs' as const,
}
await Promise.all([
build({
...common,
entryPoints: [join(ROOT, 'electron', 'main.ts')],
}),
build({
...common,
entryPoints: [join(ROOT, 'electron', 'preload.ts')],
}),
])
console.log('[electron-dev] Electron files compiled')
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
// 1. Start Vite dev server
console.log('[electron-dev] Starting Vite dev server...')
const vite = spawn('bun', ['--bun', 'run', 'dev'], {
cwd: ROOT,
stdio: 'inherit',
env: { ...process.env },
})
// Ensure cleanup on exit
const cleanup = () => {
vite.kill()
process.exit()
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
// 2. Wait for Vite to be ready
console.log('[electron-dev] Waiting for Vite on port 3000...')
await waitForServer('http://localhost:3000')
console.log('[electron-dev] Vite is ready')
// 3. Compile Electron files
await compileElectron()
// 4. Launch Electron
console.log('[electron-dev] Starting Electron...')
const electronBin = join(ROOT, 'node_modules', '.bin', 'electron')
const electron = spawn(electronBin, [join(ROOT, 'electron-dist', 'main.cjs')], {
cwd: ROOT,
stdio: 'inherit',
env: { ...process.env },
}) as ChildProcess
electron.on('exit', () => {
vite.kill()
process.exit()
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View file

@ -1,4 +1,5 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
interface ChatBody {
system: string
@ -123,6 +124,8 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const claudePath = resolveClaudeCli()
const q = query({
prompt,
options: {
@ -134,6 +137,7 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
permissionMode: 'plan',
persistSession: false,
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
@ -200,12 +204,13 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
let ocServer: { close(): void } | undefined
try {
const { createOpencode } = await import('@opencode-ai/sdk/v2')
const oc = await createOpencode()
const { getOpencodeClient } = await import('../../utils/opencode-client')
const oc = await getOpencodeClient()
const ocClient = oc.client
ocServer = oc.server
// Create a session for this conversation
const { data: session, error: sessionError } = await oc.client.session.create({
const { data: session, error: sessionError } = await ocClient.session.create({
title: 'OpenPencil Chat',
})
if (sessionError || !session) {
@ -213,7 +218,7 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
}
// Inject system prompt as context (no AI reply)
await oc.client.session.prompt({
await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
@ -226,7 +231,7 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
const parsed = parseOpenCodeModel(model)
// Send prompt and await full response
const { data: result, error: promptError } = await oc.client.session.prompt({
const { data: result, error: promptError } = await ocClient.session.prompt({
sessionID: session.id,
...(parsed ? { model: parsed } : {}),
parts: [{ type: 'text', text: prompt }],
@ -256,7 +261,8 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
)
} finally {
ocServer?.close()
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
releaseOpencodeServer(ocServer)
clearInterval(pingTimer)
controller.close()
}

View file

@ -1,5 +1,6 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import type { GroupedModel } from '../../../src/types/agent-settings'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
interface ConnectBody {
agent: 'claude-code' | 'codex-cli' | 'opencode'
@ -46,6 +47,8 @@ async function connectClaudeCode(): Promise<ConnectResult> {
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const claudePath = resolveClaudeCli()
const q = query({
prompt: '',
options: {
@ -55,6 +58,7 @@ async function connectClaudeCode(): Promise<ConnectResult> {
permissionMode: 'plan',
persistSession: false,
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
@ -156,14 +160,14 @@ async function connectCodexCli(): Promise<ConnectResult> {
}
}
/** Connect to OpenCode via createOpencode() and fetch its configured providers/models */
/** Connect to OpenCode and fetch its configured providers/models. */
async function connectOpenCode(): Promise<ConnectResult> {
try {
const { createOpencode } = await import('@opencode-ai/sdk/v2')
const oc = await createOpencode()
const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client')
const { client, server } = await getOpencodeClient()
const { data, error } = await oc.client.config.providers()
oc.server.close()
const { data, error } = await client.config.providers()
releaseOpencodeServer(server)
if (error) {
return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' }

View file

@ -1,4 +1,5 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
interface GenerateBody {
system: string
@ -66,6 +67,8 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
const env = { ...process.env } as Record<string, string | undefined>
delete env.CLAUDECODE
const claudePath = resolveClaudeCli()
const q = query({
prompt: body.message,
options: {
@ -76,6 +79,7 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
permissionMode: 'plan',
persistSession: false,
env,
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
},
})
@ -100,11 +104,12 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
let ocServer: { close(): void } | undefined
try {
const { createOpencode } = await import('@opencode-ai/sdk/v2')
const oc = await createOpencode()
const { getOpencodeClient } = await import('../../utils/opencode-client')
const oc = await getOpencodeClient()
const ocClient = oc.client
ocServer = oc.server
const { data: session, error: sessionError } = await oc.client.session.create({
const { data: session, error: sessionError } = await ocClient.session.create({
title: 'OpenPencil Generate',
})
if (sessionError || !session) {
@ -112,7 +117,7 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<
}
// Inject system prompt as context (no AI reply)
await oc.client.session.prompt({
await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
@ -126,7 +131,7 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<
}
// Send main prompt and await full response
const { data: result, error: promptError } = await oc.client.session.prompt({
const { data: result, error: promptError } = await ocClient.session.prompt({
sessionID: session.id,
...(modelOption ? { model: modelOption } : {}),
parts: [{ type: 'text', text: body.message }],
@ -151,6 +156,7 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<
const message = error instanceof Error ? error.message : 'Unknown error'
return { error: message }
} finally {
ocServer?.close()
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
releaseOpencodeServer(ocServer)
}
}

View file

@ -0,0 +1,41 @@
/**
* Shared OpenCode client manager.
* Reuses an existing server on port 4096; starts one on a random port as fallback.
* Tracks spawned servers so they can be cleaned up on process exit.
*/
const activeServers = new Set<{ close(): void }>()
// Clean up spawned OpenCode servers on process exit
function cleanup() {
for (const server of activeServers) {
try { server.close() } catch { /* ignore */ }
}
activeServers.clear()
}
process.on('beforeExit', cleanup)
process.on('SIGTERM', cleanup)
process.on('SIGINT', cleanup)
export async function getOpencodeClient() {
const { createOpencodeClient, createOpencode } = await import('@opencode-ai/sdk/v2')
// Try connecting to an existing server first
try {
const client = createOpencodeClient()
await client.config.providers() // probe
return { client, server: undefined }
} catch {
// No running server — start a temporary one on a random port
const oc = await createOpencode({ port: 0 })
activeServers.add(oc.server)
return { client: oc.client, server: oc.server }
}
}
export function releaseOpencodeServer(server: { close(): void } | undefined) {
if (!server) return
try { server.close() } catch { /* ignore */ }
activeServers.delete(server)
}

View file

@ -0,0 +1,36 @@
import { execSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
/**
* Resolve the absolute path to the standalone `claude` binary.
*
* When Nitro bundles @anthropic-ai/claude-agent-sdk, the SDK's internal
* `import.meta.url`-based resolution to find its own `cli.js` breaks.
* Instead we locate the standalone native binary and pass it via
* `pathToClaudeCodeExecutable` the SDK detects non-.js paths as native
* binaries and spawns them directly (no `node` wrapper needed).
*/
export function resolveClaudeCli(): string | undefined {
// 1. Try `which claude` (works when PATH is correctly set)
try {
const p = execSync('which claude 2>/dev/null', {
encoding: 'utf-8',
timeout: 3000,
}).trim()
if (p && existsSync(p)) return p
} catch { /* not in PATH */ }
// 2. Common install locations
const candidates = [
join(homedir(), '.local', 'bin', 'claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
]
for (const c of candidates) {
if (existsSync(c)) return c
}
return undefined
}

View file

@ -137,9 +137,9 @@ export default function TopBar() {
const displayName = fileName ?? 'Untitled'
return (
<div className="h-10 bg-card border-b border-border flex items-center px-2 shrink-0 select-none">
<div className="h-10 bg-card border-b border-border flex items-center px-2 shrink-0 select-none app-region-drag">
{/* Left section */}
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-0.5 app-region-no-drag electron-traffic-light-pad">
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -199,7 +199,7 @@ export default function TopBar() {
</div>
{/* Right section */}
<div className="flex items-center gap-0.5">
<div className="flex items-center gap-0.5 app-region-no-drag">
<Tooltip>
<TooltipTrigger asChild>
<Button

View file

@ -108,3 +108,8 @@ code {
.syn-comment { color: #546e7a; font-style: italic; }
.syn-number { color: #f78c6c; }
.syn-bracket { color: #89ddff; }
/* Electron window drag regions */
.app-region-drag { -webkit-app-region: drag; }
.app-region-no-drag { -webkit-app-region: no-drag; }

13
src/types/electron.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
interface ElectronAPI {
isElectron: true
openFile: () => Promise<{ filePath: string; content: string } | null>
saveFile: (
content: string,
defaultPath?: string,
) => Promise<string | null>
saveToPath: (filePath: string, content: string) => Promise<string>
}
interface Window {
electronAPI?: ElectronAPI
}

View file

@ -7,6 +7,8 @@ import { fileURLToPath, URL } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
const isElectronBuild = process.env.BUILD_TARGET === 'electron'
const config = defineConfig({
resolve: {
alias: {
@ -21,6 +23,7 @@ const config = defineConfig({
nitro({
rollupConfig: { external: [/^@sentry\//, /^@opencode-ai\//] },
serverDir: './server',
...(isElectronBuild ? { preset: 'node-server' } : {}),
}),
// this is the plugin that enables path aliases
viteTsConfigPaths({