mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
0569932381
commit
d51510d7e9
17 changed files with 1409 additions and 51 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -12,3 +12,5 @@ count.txt
|
|||
.vinxi
|
||||
__unconfig*
|
||||
todos.json
|
||||
electron-dist/
|
||||
dist-electron/
|
||||
|
|
|
|||
51
electron-builder.yml
Normal file
51
electron-builder.yml
Normal 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
256
electron/main.ts
Normal 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
25
electron/preload.ts
Normal 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
15
electron/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
package.json
12
package.json
|
|
@ -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
110
scripts/electron-dev.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.' }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
server/utils/opencode-client.ts
Normal file
41
server/utils/opencode-client.ts
Normal 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)
|
||||
}
|
||||
36
server/utils/resolve-claude-cli.ts
Normal file
36
server/utils/resolve-claude-cli.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
13
src/types/electron.d.ts
vendored
Normal 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
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue