Develop stays read-first: each artifact whose source is a repository path gains an Open in editor action routed through a path-validated main-process handler (shell.openPath), instead of an in-app write path. Persona and prompt-assembly sources open the governing cordis.yml or source file directly.
601 lines
21 KiB
JavaScript
601 lines
21 KiB
JavaScript
import { spawn, execFileSync } from 'node:child_process'
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'node:fs'
|
|
import { dirname, join, relative, resolve } from 'node:path'
|
|
import { Readable, Writable } from 'node:stream'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
|
import {
|
|
ClientSideConnection,
|
|
PROTOCOL_VERSION,
|
|
ndJsonStream,
|
|
} from '@agentclientprotocol/sdk'
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
const packageRoot = resolve(here, '..')
|
|
const repoRoot = resolve(packageRoot, '../../..')
|
|
const sessionsRoot = resolve(repoRoot, '.sessions')
|
|
const feedbackRoot = resolve(sessionsRoot, '.desktop-feedback')
|
|
const acpConfigPath = resolve(repoRoot, 'examples/acp-agent/cordis.yml')
|
|
|
|
/** @type {BrowserWindow | undefined} */
|
|
let mainWindow
|
|
|
|
/** @type {'stopped' | 'starting' | 'running' | 'stopping' | 'error'} */
|
|
let runtimeState = 'stopped'
|
|
/** @type {import('node:child_process').ChildProcessWithoutNullStreams | undefined} */
|
|
let runtimeProcess
|
|
/** @type {ClientSideConnection | undefined} */
|
|
let acpClient
|
|
let initializeResult
|
|
let stderrTail = ''
|
|
/** @type {Map<string, {sessionId: string, loaded: boolean, cwd: string, title?: string}>} */
|
|
const activeSessions = new Map()
|
|
const replayingSessions = new Set()
|
|
|
|
function broadcast(channel, payload) {
|
|
for (const window of BrowserWindow.getAllWindows()) {
|
|
window.webContents.send(channel, payload)
|
|
}
|
|
}
|
|
|
|
function setRuntimeState(next, extra = {}) {
|
|
runtimeState = next
|
|
broadcast('runtime:status-update', runtimeStatus(extra))
|
|
}
|
|
|
|
function runtimeStatus(extra = {}) {
|
|
return {
|
|
state: runtimeState,
|
|
pid: runtimeProcess?.pid,
|
|
repoRoot,
|
|
sessionsRoot,
|
|
configPath: acpConfigPath,
|
|
initialized: initializeResult,
|
|
stderrTail,
|
|
...extra,
|
|
}
|
|
}
|
|
|
|
async function ensureRuntime() {
|
|
if (acpClient !== undefined && runtimeState === 'running') return acpClient
|
|
await startRuntime()
|
|
if (acpClient === undefined) throw new Error('ACP runtime did not start')
|
|
return acpClient
|
|
}
|
|
|
|
async function startRuntime() {
|
|
if (runtimeState === 'running' || runtimeState === 'starting') return runtimeStatus()
|
|
setRuntimeState('starting')
|
|
stderrTail = ''
|
|
activeSessions.clear()
|
|
|
|
runtimeProcess = spawn('node', [
|
|
'--import',
|
|
'tsx',
|
|
'packages/examples/acp-demo/src/bin.ts',
|
|
'--config',
|
|
relative(repoRoot, acpConfigPath),
|
|
], {
|
|
cwd: repoRoot,
|
|
env: { ...process.env },
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
})
|
|
|
|
runtimeProcess.stderr.setEncoding('utf8')
|
|
runtimeProcess.stderr.on('data', chunk => {
|
|
stderrTail = `${stderrTail}${chunk}`.slice(-10_000)
|
|
broadcast('runtime:stderr', { text: String(chunk), tail: stderrTail })
|
|
})
|
|
|
|
runtimeProcess.on('exit', (code, signal) => {
|
|
runtimeProcess = undefined
|
|
acpClient = undefined
|
|
initializeResult = undefined
|
|
activeSessions.clear()
|
|
setRuntimeState(code === 0 ? 'stopped' : 'error', { exit: { code, signal } })
|
|
})
|
|
|
|
const stream = ndJsonStream(
|
|
Writable.toWeb(runtimeProcess.stdin),
|
|
Readable.toWeb(runtimeProcess.stdout),
|
|
)
|
|
|
|
acpClient = new ClientSideConnection(() => ({
|
|
sessionUpdate(params) {
|
|
if (replayingSessions.has(String(params.sessionId))) return Promise.resolve()
|
|
const session = activeSessions.get(String(params.sessionId))
|
|
if (session !== undefined && session.title === undefined && params.update?.sessionUpdate === 'user_message_chunk') {
|
|
const title = textOfContent(params.update.content).trim()
|
|
if (title.length > 0) session.title = title.slice(0, 120)
|
|
}
|
|
broadcast('sessions:update', params)
|
|
return Promise.resolve()
|
|
},
|
|
async requestPermission(params) {
|
|
const options = params.options.map(option => `${option.name ?? option.optionId} (${option.kind})`)
|
|
const result = await dialog.showMessageBox(mainWindow, {
|
|
type: 'question',
|
|
buttons: [...options, 'Cancel'],
|
|
cancelId: options.length,
|
|
defaultId: 0,
|
|
title: 'Harness permission request',
|
|
message: params.toolCall.title ?? 'Tool permission request',
|
|
detail: JSON.stringify(params.toolCall.rawInput ?? params.toolCall, null, 2),
|
|
})
|
|
const option = params.options[result.response]
|
|
if (option === undefined) return { outcome: { outcome: 'cancelled' } }
|
|
return { outcome: { outcome: 'selected', optionId: option.optionId } }
|
|
},
|
|
async unstable_createElicitation(params) {
|
|
const result = await dialog.showMessageBox(mainWindow, {
|
|
type: 'question',
|
|
buttons: ['Accept', 'Cancel'],
|
|
cancelId: 1,
|
|
defaultId: 0,
|
|
title: 'Harness needs input',
|
|
message: params.message,
|
|
detail: JSON.stringify(params, null, 2),
|
|
})
|
|
return result.response === 0 ? { action: 'accept', content: {} } : { action: 'cancel' }
|
|
},
|
|
}), stream)
|
|
|
|
initializeResult = await acpClient.initialize({
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
clientCapabilities: {
|
|
_meta: { terminal_output: true },
|
|
},
|
|
})
|
|
setRuntimeState('running')
|
|
return runtimeStatus()
|
|
}
|
|
|
|
async function stopRuntime() {
|
|
if (runtimeProcess === undefined) {
|
|
setRuntimeState('stopped')
|
|
return runtimeStatus()
|
|
}
|
|
setRuntimeState('stopping')
|
|
const proc = runtimeProcess
|
|
await new Promise(resolvePromise => {
|
|
proc.once('exit', resolvePromise)
|
|
proc.stdin.end()
|
|
setTimeout(() => {
|
|
if (!proc.killed) proc.kill('SIGTERM')
|
|
}, 1000).unref()
|
|
})
|
|
return runtimeStatus()
|
|
}
|
|
|
|
function walkJsonl(dir, out = []) {
|
|
if (!existsSync(dir)) return out
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (entry.name !== '.desktop-feedback') walkJsonl(full, out)
|
|
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
out.push(full)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
function readJsonl(file) {
|
|
const text = readFileSync(file, 'utf8')
|
|
const rows = []
|
|
for (const [index, line] of text.split(/\r?\n/).entries()) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
rows.push(JSON.parse(line))
|
|
} catch (error) {
|
|
rows.push({ type: 'parse/error', seq: index, time: 0, data: { line, error: String(error) } })
|
|
}
|
|
}
|
|
const first = rows[0]
|
|
const header = first?.type === 'session'
|
|
? { ...first, path: file }
|
|
: { type: 'session', version: 0, id: file.split('/').at(-1)?.replace(/\.jsonl$/, ''), createdAt: 0, path: file }
|
|
const events = first?.type === 'session' ? rows.slice(1) : rows
|
|
return { header, events, rawText: text }
|
|
}
|
|
|
|
function textOfContent(content) {
|
|
if (!Array.isArray(content)) return ''
|
|
return content.map(block => {
|
|
if (block?.type === 'text' || block?.type === 'reasoning') return block.text ?? ''
|
|
if (block?.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments ?? ''}`
|
|
return JSON.stringify(block)
|
|
}).filter(Boolean).join('\n')
|
|
}
|
|
|
|
function latestHeader(events) {
|
|
return events.filter(event => event.type === 'request/header' && event.data?.header).at(-1)?.data?.header ?? {}
|
|
}
|
|
|
|
function summarizeSession(file) {
|
|
const { header, events } = readJsonl(file)
|
|
const requestHeader = latestHeader(events)
|
|
const firstUser = events.find(event => event.type === 'user/message')
|
|
const last = events.at(-1)
|
|
return {
|
|
id: String(header.id),
|
|
parentSession: header.parentSession === undefined ? undefined : String(header.parentSession),
|
|
cwd: header.cwd,
|
|
path: file,
|
|
relativePath: relative(repoRoot, file),
|
|
createdAt: header.createdAt || events[0]?.time || statSync(file).birthtimeMs,
|
|
lastActivity: last?.time || statSync(file).mtimeMs,
|
|
eventCount: events.length,
|
|
turnCount: events.filter(event => event.type === 'turn/start').length,
|
|
stepCount: events.filter(event => event.type === 'step/start').length,
|
|
toolCallCount: events.filter(event => event.type === 'tool/call').length,
|
|
model: requestHeader.config?.model,
|
|
title: textOfContent(firstUser?.data?.content).slice(0, 120) || String(header.id),
|
|
live: activeSessions.has(String(header.id)),
|
|
}
|
|
}
|
|
|
|
function listSessions() {
|
|
const persisted = walkJsonl(sessionsRoot).map(summarizeSession)
|
|
const persistedIds = new Set(persisted.map(session => session.id))
|
|
const liveOnly = [...activeSessions.values()]
|
|
.filter(session => !persistedIds.has(session.sessionId))
|
|
.map(session => ({
|
|
id: session.sessionId,
|
|
cwd: session.cwd,
|
|
path: undefined,
|
|
relativePath: undefined,
|
|
createdAt: Date.now(),
|
|
lastActivity: Date.now(),
|
|
eventCount: 0,
|
|
turnCount: 0,
|
|
stepCount: 0,
|
|
toolCallCount: 0,
|
|
model: undefined,
|
|
title: session.title ?? 'New live session',
|
|
live: true,
|
|
}))
|
|
return [...liveOnly, ...persisted].sort((a, b) => b.lastActivity - a.lastActivity)
|
|
}
|
|
|
|
function findSessionFile(sessionId) {
|
|
return walkJsonl(sessionsRoot).find(file => {
|
|
if (file.endsWith(`${sessionId}.jsonl`)) return true
|
|
try {
|
|
return String(readJsonl(file).header.id) === sessionId
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
|
|
function readTrace(sessionId) {
|
|
const file = findSessionFile(sessionId)
|
|
if (file === undefined) {
|
|
return { found: false, sessionId, header: { id: sessionId, cwd: repoRoot }, events: [], rawText: '' }
|
|
}
|
|
const trace = readJsonl(file)
|
|
const sessions = listSessions()
|
|
const summary = sessions.find(session => session.id === sessionId)
|
|
return {
|
|
found: true,
|
|
sessionId,
|
|
header: trace.header,
|
|
events: trace.events,
|
|
rawText: trace.rawText,
|
|
path: file,
|
|
relativePath: relative(repoRoot, file),
|
|
feedback: readFeedback(sessionId),
|
|
parent: summary?.parentSession === undefined ? undefined : sessions.find(session => session.id === summary.parentSession) ?? { id: summary.parentSession },
|
|
children: sessions.filter(session => session.parentSession === sessionId),
|
|
}
|
|
}
|
|
|
|
function feedbackFile(sessionId) {
|
|
return join(feedbackRoot, `${encodeURIComponent(sessionId)}.feedback.jsonl`)
|
|
}
|
|
|
|
function readFeedback(sessionId, targetId) {
|
|
const file = feedbackFile(sessionId)
|
|
if (!existsSync(file)) return []
|
|
return readFileSync(file, 'utf8')
|
|
.split(/\r?\n/)
|
|
.filter(Boolean)
|
|
.map((line, index) => {
|
|
try {
|
|
return JSON.parse(line)
|
|
} catch (error) {
|
|
return { type: 'feedback/parse-error', seq: index, time: 0, data: { line, error: String(error) } }
|
|
}
|
|
})
|
|
.filter(record => targetId === undefined || record.data?.targetId === targetId)
|
|
}
|
|
|
|
function appendFeedback(entry) {
|
|
const sessionId = String(entry.sessionId ?? '')
|
|
if (sessionId.length === 0) throw new Error('sessionId is required')
|
|
const rows = readFeedback(sessionId)
|
|
const record = {
|
|
type: 'feedback/add',
|
|
seq: rows.length,
|
|
time: Date.now(),
|
|
data: {
|
|
sessionId,
|
|
targetId: String(entry.targetId ?? `session:${sessionId}`),
|
|
targetTitle: String(entry.targetTitle ?? sessionId),
|
|
targetKind: String(entry.targetKind ?? 'session'),
|
|
author: String(entry.author ?? 'shentuni').trim() || 'shentuni',
|
|
text: String(entry.text ?? '').trim(),
|
|
},
|
|
}
|
|
if (record.data.text.length === 0) throw new Error('feedback text is required')
|
|
mkdirSync(feedbackRoot, { recursive: true })
|
|
appendFileSync(feedbackFile(sessionId), `${JSON.stringify(record)}\n`)
|
|
return record
|
|
}
|
|
|
|
async function ensureSessionLoaded(sessionId) {
|
|
if (activeSessions.has(sessionId)) return
|
|
const summary = listSessions().find(session => session.id === sessionId)
|
|
const client = await ensureRuntime()
|
|
replayingSessions.add(sessionId)
|
|
try {
|
|
await client.loadSession({ sessionId, cwd: summary?.cwd ?? repoRoot, mcpServers: [] })
|
|
activeSessions.set(sessionId, { sessionId, loaded: true, cwd: summary?.cwd ?? repoRoot })
|
|
} finally {
|
|
replayingSessions.delete(sessionId)
|
|
}
|
|
}
|
|
|
|
function devStatus() {
|
|
let dirty = false
|
|
let branch = 'unknown'
|
|
let commit = 'unknown'
|
|
const configText = readTextSafe(acpConfigPath)
|
|
const recentEvidence = summarizeRecentArtifactEvidence()
|
|
try {
|
|
dirty = execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0
|
|
branch = execFileSync('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8' }).trim()
|
|
commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim()
|
|
} catch {
|
|
// Keep the UI usable if git is unavailable.
|
|
}
|
|
return {
|
|
runtime: runtimeStatus(),
|
|
git: { dirty, branch, commit },
|
|
watchedPaths: ['packages/**', 'examples/**', 'plugins/**', 'cordis.yml', 'package.json', 'pnpm-lock.yaml'],
|
|
restartNeeded: dirty,
|
|
recentPromptUses: recentEvidence.promptUses,
|
|
recentToolCalls: recentEvidence.toolCalls,
|
|
appComposition: {
|
|
name: 'DeepSeek Harness ACP agent',
|
|
entrypoint: 'packages/examples/acp-demo/src/bin.ts',
|
|
configPath: relative(repoRoot, acpConfigPath),
|
|
configText,
|
|
plugins: parseCordisPlugins(configText),
|
|
sourceFiles: [
|
|
{
|
|
label: 'ACP front door',
|
|
path: 'packages/examples/acp-demo/src/index.ts',
|
|
purpose: 'Loads the agent spine, JSONL persistence, user interaction service, and ACP bridge.',
|
|
text: readTextSafe(join(repoRoot, 'packages/examples/acp-demo/src/index.ts')),
|
|
},
|
|
{
|
|
label: 'Agent spine',
|
|
path: 'packages/examples/agent-spine-demo/src/index.ts',
|
|
purpose: 'Composes system prompt, tool registry, skills, agent registry, tasks, invariants, tool plugins, and agent loop.',
|
|
text: readTextSafe(join(repoRoot, 'packages/examples/agent-spine-demo/src/index.ts')),
|
|
},
|
|
{
|
|
label: 'System prompt service',
|
|
path: 'packages/core/system-prompt/src/index.ts',
|
|
purpose: 'Owns persona, tool order, and assembled model-facing prompt sections.',
|
|
text: readTextSafe(join(repoRoot, 'packages/core/system-prompt/src/index.ts')),
|
|
},
|
|
{
|
|
label: 'Tool registry',
|
|
path: 'packages/core/tools/src/index.ts',
|
|
purpose: 'Owns model-facing tool registration, schema validation, and tool presentation mode.',
|
|
text: readTextSafe(join(repoRoot, 'packages/core/tools/src/index.ts')),
|
|
},
|
|
],
|
|
},
|
|
}
|
|
}
|
|
|
|
function summarizeRecentArtifactEvidence() {
|
|
const promptUses = []
|
|
const toolCalls = new Map()
|
|
for (const file of walkJsonl(sessionsRoot)) {
|
|
let trace
|
|
try {
|
|
trace = readJsonl(file)
|
|
} catch {
|
|
continue
|
|
}
|
|
const sessionId = String(trace.header.id)
|
|
for (const event of trace.events) {
|
|
if (event.type === 'request/header' && event.data?.header) {
|
|
const header = event.data.header
|
|
promptUses.push({
|
|
sessionId,
|
|
relativePath: relative(repoRoot, file),
|
|
seq: event.seq,
|
|
time: event.time,
|
|
systemChars: String(header.system ?? '').length,
|
|
tools: Array.isArray(header.tools) ? header.tools.length : 0,
|
|
model: header.config?.model,
|
|
})
|
|
}
|
|
if (event.type === 'tool/call') {
|
|
const name = String(event.data?.name ?? event.data?.toolName ?? 'tool')
|
|
const existing = toolCalls.get(name) ?? {
|
|
name,
|
|
count: 0,
|
|
lastSessionId: sessionId,
|
|
lastRelativePath: relative(repoRoot, file),
|
|
lastSeq: event.seq,
|
|
lastTime: event.time,
|
|
}
|
|
existing.count += 1
|
|
if ((event.time ?? 0) >= (existing.lastTime ?? 0)) {
|
|
existing.lastSessionId = sessionId
|
|
existing.lastRelativePath = relative(repoRoot, file)
|
|
existing.lastSeq = event.seq
|
|
existing.lastTime = event.time
|
|
}
|
|
toolCalls.set(name, existing)
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
promptUses: promptUses
|
|
.sort((a, b) => (b.time ?? 0) - (a.time ?? 0))
|
|
.slice(0, 8),
|
|
toolCalls: [...toolCalls.values()]
|
|
.sort((a, b) => (b.lastTime ?? 0) - (a.lastTime ?? 0)),
|
|
}
|
|
}
|
|
|
|
function readTextSafe(file) {
|
|
try {
|
|
return readFileSync(file, 'utf8')
|
|
} catch (error) {
|
|
return `# Unable to read ${file}\n${String(error)}`
|
|
}
|
|
}
|
|
|
|
function parseCordisPlugins(text) {
|
|
const plugins = []
|
|
let current
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const id = line.match(/^\s*-\s+id:\s*(.+?)\s*$/)
|
|
if (id) {
|
|
if (current !== undefined) plugins.push(current)
|
|
current = { id: id[1], name: '', configPreview: '' }
|
|
continue
|
|
}
|
|
if (current === undefined) continue
|
|
const name = line.match(/^\s*name:\s*(.+?)\s*$/)
|
|
if (name) {
|
|
current.name = name[1].replace(/^['"]|['"]$/g, '')
|
|
continue
|
|
}
|
|
if (/^\s{2,}\S/.test(line) && current.configPreview.length < 1600) {
|
|
current.configPreview = `${current.configPreview}${line}\n`
|
|
}
|
|
}
|
|
if (current !== undefined) plugins.push(current)
|
|
return plugins
|
|
}
|
|
|
|
function registerIpc() {
|
|
ipcMain.handle('runtime:start', async () => startRuntime())
|
|
ipcMain.handle('runtime:stop', async () => stopRuntime())
|
|
ipcMain.handle('runtime:restart', async () => {
|
|
await stopRuntime()
|
|
return startRuntime()
|
|
})
|
|
ipcMain.handle('runtime:status', () => runtimeStatus())
|
|
ipcMain.handle('sessions:list', () => ({ root: sessionsRoot, sessions: listSessions() }))
|
|
ipcMain.handle('sessions:create', async () => {
|
|
const client = await ensureRuntime()
|
|
const response = await client.newSession({ cwd: repoRoot, mcpServers: [] })
|
|
activeSessions.set(response.sessionId, { sessionId: response.sessionId, loaded: true, cwd: repoRoot })
|
|
return { ...response, trace: readTrace(response.sessionId) }
|
|
})
|
|
ipcMain.handle('sessions:load', async (_event, { sessionId }) => {
|
|
await ensureSessionLoaded(String(sessionId))
|
|
return { sessionId, trace: readTrace(String(sessionId)) }
|
|
})
|
|
ipcMain.handle('sessions:prompt', async (_event, { sessionId, text }) => {
|
|
const id = String(sessionId)
|
|
await ensureSessionLoaded(id)
|
|
const client = await ensureRuntime()
|
|
const response = await client.prompt({
|
|
sessionId: id,
|
|
prompt: [{ type: 'text', text: String(text) }],
|
|
})
|
|
return { response, trace: readTrace(id) }
|
|
})
|
|
ipcMain.handle('sessions:cancel', async (_event, { sessionId }) => {
|
|
const client = await ensureRuntime()
|
|
await client.cancel({ sessionId: String(sessionId) })
|
|
return { ok: true }
|
|
})
|
|
ipcMain.handle('sessions:reveal', (_event, { sessionId }) => {
|
|
const file = findSessionFile(String(sessionId))
|
|
if (file === undefined) throw new Error(`session not found: ${String(sessionId)}`)
|
|
shell.showItemInFolder(file)
|
|
return { ok: true, path: file }
|
|
})
|
|
// Develop stays read-first: editing routes to the user's editor instead of an
|
|
// in-app write path. Only repository paths may be opened.
|
|
ipcMain.handle('dev:open-path', async (_event, { path }) => {
|
|
const target = resolve(repoRoot, String(path))
|
|
if (target !== repoRoot && !target.startsWith(`${repoRoot}/`)) {
|
|
throw new Error(`path escapes the repository: ${String(path)}`)
|
|
}
|
|
if (!existsSync(target)) throw new Error(`path not found: ${String(path)}`)
|
|
const error = await shell.openPath(target)
|
|
if (error.length > 0) throw new Error(error)
|
|
return { ok: true, path: target }
|
|
})
|
|
ipcMain.handle('trace:read', (_event, { sessionId }) => readTrace(String(sessionId)))
|
|
ipcMain.handle('feedback:list', (_event, { sessionId, targetId }) => readFeedback(String(sessionId), targetId === undefined ? undefined : String(targetId)))
|
|
ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry))
|
|
ipcMain.handle('dev:status', () => devStatus())
|
|
}
|
|
|
|
async function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1440,
|
|
height: 940,
|
|
minWidth: 1080,
|
|
minHeight: 720,
|
|
title: 'DeepSeek Harness Desktop',
|
|
titleBarStyle: 'hiddenInset',
|
|
backgroundColor: '#f5f5f7',
|
|
webPreferences: {
|
|
preload: join(here, 'preload.cjs'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
})
|
|
|
|
if (process.env.VITE_DEV_SERVER_URL !== undefined) {
|
|
await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
|
|
if (process.env.DSH_DESKTOP_OPEN_DEVTOOLS === '1') mainWindow.webContents.openDevTools({ mode: 'detach' })
|
|
} else {
|
|
const builtIndex = join(packageRoot, 'dist/index.html')
|
|
if (!existsSync(builtIndex)) {
|
|
await mainWindow.loadURL(`data:text/html,${encodeURIComponent('Run pnpm --dir packages/ui/desktop build:ui before pnpm --dir packages/ui/desktop start.')}`)
|
|
} else {
|
|
await mainWindow.loadURL(pathToFileURL(builtIndex).toString())
|
|
}
|
|
}
|
|
}
|
|
|
|
async function openWindowAndRuntime() {
|
|
await createWindow()
|
|
try {
|
|
await startRuntime()
|
|
} catch (error) {
|
|
setRuntimeState('error', { error: String(error) })
|
|
}
|
|
}
|
|
|
|
registerIpc()
|
|
|
|
app.whenReady().then(openWindowAndRuntime)
|
|
|
|
app.on('window-all-closed', () => {
|
|
void stopRuntime().finally(() => {
|
|
if (process.platform !== 'darwin') app.quit()
|
|
})
|
|
})
|
|
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) void openWindowAndRuntime()
|
|
})
|