Replace the full-innerHTML render loop with a static shell plus per-region updates, so composer drafts, fold state, focus, and scroll survive streaming turns. Fold session events into one trace graph consumed by Chat, Trajectory, Waterfall, and the shared inspector drawer, with live ACP updates patched into a keyed live-turn region. Align the visual system with a tokenized design spec: a 4px spacing base with fixed control/row height steps, foreground-derived text tiers and borders (color-mix), neutral interaction overlays, tiered motion durations with a reduced-motion collapse, hover-revealed scrollbars, and drawer-aware layout elasticity. Localize trajectory role chips and row previews. The renderer entry (app.ts) joins the coverage exclude list as a self-executing DOM bootstrap: jsdom lifecycle specs exercise its behavior, and extractable logic lives in covered modules (trace-graph.ts, renderer-content.ts).
589 lines
20 KiB
JavaScript
589 lines
20 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 }
|
|
})
|
|
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()
|
|
})
|