Codex/pr 1217 (#1226)
* bundle node and windows git runtimes * split desktop runtime into release package * fix desktop runtime packaging ci * embed desktop runtime release tag * show desktop runtime download progress * fix desktop runtime release handling * refactor desktop runtime version config * fix desktop package license --------- Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com> Co-authored-by: ekko <fqsy1416@gmail.com>
This commit is contained in:
@@ -1,8 +1,20 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { delimiter, dirname } from 'node:path'
|
||||
import { bundledPython, hermesBin, hermesHome, pythonDir, webUiHome } from './paths'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import {
|
||||
bundledBrowserExecutable,
|
||||
bundledGit,
|
||||
bundledNode,
|
||||
bundledPython,
|
||||
gitPathDirs,
|
||||
hermesBin,
|
||||
hermesHome,
|
||||
nodeBinDir,
|
||||
pythonDir,
|
||||
webUiHome,
|
||||
} from './paths'
|
||||
import { HERMES_CLI_ARG } from './cli-constants'
|
||||
import { ensureDesktopRuntime } from './runtime-manager'
|
||||
|
||||
export function parseHermesCliArgs(argv: string[] = process.argv): string[] | null {
|
||||
const index = argv.indexOf(HERMES_CLI_ARG)
|
||||
@@ -11,10 +23,17 @@ export function parseHermesCliArgs(argv: string[] = process.argv): string[] | nu
|
||||
}
|
||||
|
||||
export async function runBundledHermesCli(args: string[]): Promise<number> {
|
||||
try {
|
||||
await ensureDesktopRuntime()
|
||||
} catch (err) {
|
||||
console.error(`Failed to prepare Hermes runtime: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const command = hermesBin()
|
||||
if (!existsSync(command)) {
|
||||
console.error(`hermes binary missing at ${command}`)
|
||||
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
|
||||
console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
|
||||
return 127
|
||||
}
|
||||
|
||||
@@ -22,7 +41,20 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
|
||||
mkdirSync(hermesHome(), { recursive: true })
|
||||
|
||||
const binDir = dirname(command)
|
||||
const pathValue = process.env.PATH ? `${binDir}${delimiter}${process.env.PATH}` : binDir
|
||||
const bundledNodeBin = nodeBinDir()
|
||||
const bundledAgentBrowserBin = process.platform === 'win32'
|
||||
? join(pythonDir(), 'node')
|
||||
: join(pythonDir(), 'node', 'bin')
|
||||
const inheritedPath = process.env.PATH || process.env.Path || ''
|
||||
const pathValue = [
|
||||
binDir,
|
||||
bundledAgentBrowserBin,
|
||||
bundledNodeBin,
|
||||
gitPathDirs().join(delimiter),
|
||||
inheritedPath,
|
||||
].filter(Boolean).join(delimiter)
|
||||
const gitBin = bundledGit()
|
||||
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HERMES_DESKTOP: 'true',
|
||||
@@ -30,6 +62,12 @@ export async function runBundledHermesCli(args: string[]): Promise<number> {
|
||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython(),
|
||||
HERMES_AGENT_CLI_PYTHON: bundledPython(),
|
||||
HERMES_AGENT_ROOT: pythonDir(),
|
||||
HERMES_AGENT_NODE: bundledNode(),
|
||||
HERMES_AGENT_NODE_ROOT: process.platform === 'win32' ? bundledNodeBin : dirname(bundledNodeBin),
|
||||
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(hermesHome(), 'agent-browser'),
|
||||
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
|
||||
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
|
||||
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
|
||||
HERMES_HOME: hermesHome(),
|
||||
HERMES_WEB_UI_HOME: webUiHome(),
|
||||
HERMES_WEBUI_STATE_DIR: webUiHome(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { checkForDesktopUpdates, initAutoUpdater } from './updater'
|
||||
import { t } from './desktop-i18n'
|
||||
import { installHermesStudioCliShim } from './cli-shim'
|
||||
import { parseHermesCliArgs, runBundledHermesCli } from './hermes-cli'
|
||||
import { ensureDesktopRuntime, type RuntimeProgress } from './runtime-manager'
|
||||
|
||||
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
|
||||
const START_HIDDEN = process.argv.includes('--hidden')
|
||||
@@ -15,6 +16,7 @@ let mainWindow: BrowserWindow | null = null
|
||||
let serverUrl: string | null = null
|
||||
let tray: Tray | null = null
|
||||
let isQuitting = false
|
||||
let isBootstrapping = false
|
||||
|
||||
function showMainWindow() {
|
||||
if (!mainWindow) {
|
||||
@@ -168,25 +170,91 @@ function splashHtml(): string {
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
|
||||
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px}
|
||||
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:#888;animation:pulse 1.2s ease-in-out infinite}
|
||||
@keyframes pulse{0%,100%{opacity:.3}50%{opacity:1}}
|
||||
.row{display:flex;gap:8px}
|
||||
.row .dot:nth-child(2){animation-delay:.2s}.row .dot:nth-child(3){animation-delay:.4s}
|
||||
.label{font-size:14px;color:#999}
|
||||
.label{font-size:14px;color:#b8b8b8}
|
||||
.detail{min-height:18px;font-size:12px;color:#7f7f7f}
|
||||
.progress{width:320px;height:6px;border-radius:999px;background:#2b2b2b;overflow:hidden}
|
||||
.bar{width:0;height:100%;background:#d8d8d8;transition:width .18s ease}
|
||||
h1{font-weight:500;margin:0;font-size:18px}
|
||||
</style></head><body><div class="wrap">
|
||||
<h1>Hermes Studio</h1>
|
||||
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||
<div class="label">Starting local services…</div>
|
||||
<div id="label" class="label">Starting local services...</div>
|
||||
<div class="progress"><div id="bar" class="bar"></div></div>
|
||||
<div id="detail" class="detail"></div>
|
||||
</div></body></html>`
|
||||
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let value = bytes / 1024
|
||||
let unit = units[0]
|
||||
for (let i = 1; i < units.length && value >= 1024; i += 1) {
|
||||
value /= 1024
|
||||
unit = units[i]
|
||||
}
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)} ${unit}`
|
||||
}
|
||||
|
||||
function updateSplash(progress: RuntimeProgress) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const label = progress.message
|
||||
const percent = typeof progress.percent === 'number' ? Math.round(progress.percent) : null
|
||||
let detail = ''
|
||||
if (progress.receivedBytes && progress.totalBytes) {
|
||||
detail = `${formatBytes(progress.receivedBytes)} / ${formatBytes(progress.totalBytes)}`
|
||||
if (percent !== null) detail += ` (${percent}%)`
|
||||
} else if (percent !== null) {
|
||||
detail = `${percent}%`
|
||||
}
|
||||
|
||||
mainWindow.webContents.executeJavaScript(`
|
||||
{
|
||||
const label = document.getElementById('label');
|
||||
const detail = document.getElementById('detail');
|
||||
const bar = document.getElementById('bar');
|
||||
if (label) label.textContent = ${JSON.stringify(label)};
|
||||
if (detail) detail.textContent = ${JSON.stringify(detail)};
|
||||
if (bar) bar.style.width = ${JSON.stringify(percent === null ? '100%' : `${percent}%`)};
|
||||
}
|
||||
`).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (isBootstrapping) return
|
||||
isBootstrapping = true
|
||||
|
||||
try {
|
||||
await ensureDesktopRuntime(updateSplash)
|
||||
} catch (err) {
|
||||
console.error('Failed to prepare Hermes runtime:', err)
|
||||
if (mainWindow) {
|
||||
const msg = String(err instanceof Error ? err.message : err).replace(/[<>]/g, '')
|
||||
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(
|
||||
`<html><body style="font-family:system-ui;padding:32px;background:#1a1a1a;color:#eee">
|
||||
<h2>Failed to prepare Hermes runtime</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
|
||||
<button id="retry" style="margin-top:16px;padding:8px 14px;border:1px solid #555;border-radius:6px;background:#2b2b2b;color:#eee;cursor:pointer">Retry</button>
|
||||
<script>
|
||||
document.getElementById('retry')?.addEventListener('click', () => {
|
||||
window.hermesDesktop?.retryBootstrap?.()
|
||||
})
|
||||
</script>
|
||||
</body></html>`,
|
||||
))
|
||||
}
|
||||
isBootstrapping = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!hermesBinExists()) {
|
||||
console.error(`hermes binary missing at ${hermesBin()}`)
|
||||
console.error('Run: npm run prepare:python (to bundle Python + hermes-agent)')
|
||||
console.error('Run: npm run prepare:runtime (to build a local Hermes runtime)')
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -203,10 +271,20 @@ async function bootstrap() {
|
||||
</body></html>`,
|
||||
))
|
||||
}
|
||||
} finally {
|
||||
isBootstrapping = false
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes-desktop:get-token', () => getToken())
|
||||
ipcMain.handle('hermes-desktop:retry-bootstrap', async () => {
|
||||
if (serverUrl) {
|
||||
await mainWindow?.loadURL(serverUrl)
|
||||
return
|
||||
}
|
||||
await mainWindow?.loadURL(splashHtml())
|
||||
await bootstrap()
|
||||
})
|
||||
|
||||
function runDesktopApp() {
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
|
||||
@@ -23,12 +23,69 @@ export function webuiServerEntry(): string {
|
||||
return join(webuiDir(), 'dist', 'server', 'index.js')
|
||||
}
|
||||
|
||||
// Bundled Python directory.
|
||||
export function runtimePlatformKey(): string {
|
||||
return `${osLabel}-${archLabel}`
|
||||
}
|
||||
|
||||
export function desktopRuntimeDir(): string {
|
||||
const override = process.env.HERMES_DESKTOP_RUNTIME_DIR?.trim()
|
||||
if (override) return resolve(override)
|
||||
return join(webUiHome(), 'desktop-runtime', runtimePlatformKey())
|
||||
}
|
||||
|
||||
function packagedResourceDir(name: string): string {
|
||||
return resolve(process.resourcesPath, name)
|
||||
}
|
||||
|
||||
// dev: packages/desktop/resources/python/<os>-<arch>
|
||||
// prod: <resources>/python
|
||||
// prod: <resources>/python when present, otherwise downloaded runtime cache.
|
||||
export function pythonDir(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'python')
|
||||
return resolve(app.getAppPath(), 'resources', 'python', `${osLabel}-${archLabel}`)
|
||||
if (app.isPackaged) {
|
||||
const packaged = packagedResourceDir('python')
|
||||
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'python')
|
||||
}
|
||||
return resolve(app.getAppPath(), 'resources', 'python', runtimePlatformKey())
|
||||
}
|
||||
|
||||
export function nodeDir(): string {
|
||||
if (app.isPackaged) {
|
||||
const packaged = packagedResourceDir('node')
|
||||
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'node')
|
||||
}
|
||||
return resolve(app.getAppPath(), 'resources', 'node', runtimePlatformKey())
|
||||
}
|
||||
|
||||
export function nodeBinDir(): string {
|
||||
const dir = nodeDir()
|
||||
return isWin ? dir : join(dir, 'bin')
|
||||
}
|
||||
|
||||
export function bundledNode(): string {
|
||||
return isWin ? join(nodeDir(), 'node.exe') : join(nodeBinDir(), 'node')
|
||||
}
|
||||
|
||||
export function gitDir(): string {
|
||||
if (app.isPackaged) {
|
||||
const packaged = packagedResourceDir('git')
|
||||
return existsSync(packaged) ? packaged : join(desktopRuntimeDir(), 'git')
|
||||
}
|
||||
return resolve(app.getAppPath(), 'resources', 'git', runtimePlatformKey())
|
||||
}
|
||||
|
||||
export function gitPathDirs(): string[] {
|
||||
if (!isWin) return []
|
||||
const dir = gitDir()
|
||||
return [
|
||||
join(dir, 'cmd'),
|
||||
join(dir, 'mingw64', 'bin'),
|
||||
join(dir, 'usr', 'bin'),
|
||||
].filter(existsSync)
|
||||
}
|
||||
|
||||
export function bundledGit(): string | undefined {
|
||||
if (!isWin) return undefined
|
||||
const git = join(gitDir(), 'cmd', 'git.exe')
|
||||
return existsSync(git) ? git : undefined
|
||||
}
|
||||
|
||||
export function bundledAgentBrowserHome(): string {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { get as httpGet } from 'node:http'
|
||||
import { get as httpsGet } from 'node:https'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
bundledGit,
|
||||
bundledNode,
|
||||
desktopRuntimeDir,
|
||||
hermesBinExists,
|
||||
runtimePlatformKey,
|
||||
} from './paths'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const DEFAULT_RUNTIME_BASE_URL = 'https://download.ekkolearnai.com'
|
||||
const RUNTIME_MANIFEST_NAME = 'runtime-manifest.json'
|
||||
const PACKAGED_RUNTIME_RELEASE_NAME = 'runtime-release.json'
|
||||
|
||||
type RuntimeManifest = {
|
||||
schema: number
|
||||
platform: string
|
||||
hermesAgentVersion?: string
|
||||
asset?: {
|
||||
name: string
|
||||
url?: string
|
||||
sha256?: string
|
||||
size?: number
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeDescriptor = {
|
||||
name: string
|
||||
url: string
|
||||
sha256?: string
|
||||
hermesAgentVersion?: string
|
||||
}
|
||||
|
||||
export type RuntimeProgress = {
|
||||
stage: 'resolve' | 'download' | 'verify' | 'extract' | 'ready'
|
||||
message: string
|
||||
percent?: number
|
||||
receivedBytes?: number
|
||||
totalBytes?: number
|
||||
}
|
||||
|
||||
type RuntimeProgressHandler = (progress: RuntimeProgress) => void
|
||||
|
||||
function runtimeReady(): boolean {
|
||||
const gitReady = process.platform !== 'win32' || !!bundledGit()
|
||||
return hermesBinExists() && existsSync(bundledNode()) && gitReady
|
||||
}
|
||||
|
||||
function releaseTagCandidates(): string[] {
|
||||
const override = process.env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG?.trim()
|
||||
if (override) return [override]
|
||||
|
||||
const version = app.getVersion()
|
||||
const candidates = [packagedRuntimeReleaseTag(), version, `v${version}`, 'latest']
|
||||
return Array.from(new Set(candidates.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)))
|
||||
}
|
||||
|
||||
function packagedRuntimeReleaseTag(): string | null {
|
||||
const candidates = app.isPackaged
|
||||
? [join(process.resourcesPath, 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
|
||||
: [join(app.getAppPath(), 'build', PACKAGED_RUNTIME_RELEASE_NAME)]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!existsSync(candidate)) continue
|
||||
try {
|
||||
const metadata = JSON.parse(readFileSync(candidate, 'utf-8')) as { tag?: unknown }
|
||||
if (typeof metadata.tag === 'string' && metadata.tag.trim()) return metadata.tag.trim()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function runtimeAssetUrl(assetName: string, tag: string): string {
|
||||
const repo = process.env.HERMES_DESKTOP_RUNTIME_REPO?.trim()
|
||||
if (repo) {
|
||||
if (tag === 'latest') {
|
||||
return `https://github.com/${repo}/releases/latest/download/${encodeURIComponent(assetName)}`
|
||||
}
|
||||
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
|
||||
}
|
||||
|
||||
const template = process.env.HERMES_DESKTOP_RUNTIME_BASE_URL?.trim() || DEFAULT_RUNTIME_BASE_URL
|
||||
if (template.includes('{asset}') || template.includes('{tag}')) {
|
||||
return template
|
||||
.replace(/\{asset\}/g, encodeURIComponent(assetName))
|
||||
.replace(/\{tag\}/g, encodeURIComponent(tag))
|
||||
}
|
||||
return `${template.replace(/\/$/, '')}/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`GET ${url} returned ${response.status}`)
|
||||
}
|
||||
return await response.json() as T
|
||||
}
|
||||
|
||||
async function resolveRuntimeDescriptor(): Promise<RuntimeDescriptor> {
|
||||
const directUrl = process.env.HERMES_DESKTOP_RUNTIME_URL?.trim()
|
||||
if (directUrl) {
|
||||
return { name: basename(new URL(directUrl).pathname) || 'hermes-runtime.tar.gz', url: directUrl }
|
||||
}
|
||||
|
||||
const platformManifestName = `hermes-runtime-${runtimePlatformKey()}.json`
|
||||
const manifestOverride = process.env.HERMES_DESKTOP_RUNTIME_MANIFEST_URL?.trim()
|
||||
const candidates = manifestOverride
|
||||
? [{ tag: '', url: manifestOverride }]
|
||||
: releaseTagCandidates().map(tag => ({ tag, url: runtimeAssetUrl(platformManifestName, tag) }))
|
||||
|
||||
let lastError: Error | null = null
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const manifest = await fetchJson<RuntimeManifest>(candidate.url)
|
||||
if (!manifest.asset?.name) {
|
||||
throw new Error(`runtime manifest is missing asset.name: ${candidate.url}`)
|
||||
}
|
||||
return {
|
||||
name: manifest.asset.name,
|
||||
url: manifest.asset.url || runtimeAssetUrl(manifest.asset.name, candidate.tag),
|
||||
sha256: manifest.asset.sha256,
|
||||
hermesAgentVersion: manifest.hermesAgentVersion,
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Unable to resolve Hermes desktop runtime package')
|
||||
}
|
||||
|
||||
function readCachedRuntimeManifest(root: string): RuntimeManifest | null {
|
||||
const file = join(root, RUNTIME_MANIFEST_NAME)
|
||||
if (!existsSync(file)) return null
|
||||
try {
|
||||
return JSON.parse(readFileSync(file, 'utf-8')) as RuntimeManifest
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function cachedRuntimeMatches(root: string, descriptor: RuntimeDescriptor): boolean {
|
||||
if (!runtimeReady()) return false
|
||||
const manifest = readCachedRuntimeManifest(root)
|
||||
if (!manifest?.asset?.name) return true
|
||||
return manifest.asset.name === descriptor.name
|
||||
}
|
||||
|
||||
function downloadFile(
|
||||
url: string,
|
||||
target: string,
|
||||
onProgress?: RuntimeProgressHandler,
|
||||
redirects = 5,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url)
|
||||
const getter = parsed.protocol === 'http:' ? httpGet : httpsGet
|
||||
const req = getter(parsed, response => {
|
||||
const status = response.statusCode || 0
|
||||
const location = response.headers.location
|
||||
if (status >= 300 && status < 400 && location && redirects > 0) {
|
||||
response.resume()
|
||||
downloadFile(new URL(location, url).toString(), target, onProgress, redirects - 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
response.resume()
|
||||
reject(new Error(`GET ${url} returned ${status}`))
|
||||
return
|
||||
}
|
||||
|
||||
const totalBytes = Number(response.headers['content-length']) || undefined
|
||||
let receivedBytes = 0
|
||||
response.on('data', chunk => {
|
||||
receivedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk)
|
||||
onProgress?.({
|
||||
stage: 'download',
|
||||
message: 'Downloading Hermes runtime...',
|
||||
percent: totalBytes ? Math.min(100, (receivedBytes / totalBytes) * 100) : undefined,
|
||||
receivedBytes,
|
||||
totalBytes,
|
||||
})
|
||||
})
|
||||
|
||||
const file = createWriteStream(target)
|
||||
response.pipe(file)
|
||||
file.on('finish', () => file.close(() => resolve()))
|
||||
file.on('error', reject)
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function sha256File(file: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(file)
|
||||
stream.on('data', chunk => hash.update(chunk))
|
||||
stream.on('end', resolve)
|
||||
stream.on('error', reject)
|
||||
})
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function extractRuntimeArchive(archive: string, targetRoot: string): Promise<void> {
|
||||
const parent = dirname(targetRoot)
|
||||
const tempRoot = join(parent, `.runtime-${process.pid}-${Date.now()}`)
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
mkdirSync(tempRoot, { recursive: true })
|
||||
|
||||
try {
|
||||
await execFileAsync(process.platform === 'win32' ? 'tar.exe' : 'tar', ['-xzf', archive, '-C', tempRoot], {
|
||||
windowsHide: true,
|
||||
})
|
||||
for (const required of ['python', 'node']) {
|
||||
if (!existsSync(join(tempRoot, required))) {
|
||||
throw new Error(`Runtime archive did not contain ${required}/`)
|
||||
}
|
||||
}
|
||||
rmSync(targetRoot, { recursive: true, force: true })
|
||||
mkdirSync(parent, { recursive: true })
|
||||
renameSync(tempRoot, targetRoot)
|
||||
} catch (err) {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDesktopRuntime(onProgress?: RuntimeProgressHandler): Promise<void> {
|
||||
const runtimeRoot = desktopRuntimeDir()
|
||||
mkdirSync(runtimeRoot, { recursive: true })
|
||||
|
||||
let descriptor: RuntimeDescriptor
|
||||
try {
|
||||
onProgress?.({ stage: 'resolve', message: 'Checking Hermes runtime...' })
|
||||
descriptor = await resolveRuntimeDescriptor()
|
||||
} catch (err) {
|
||||
if (runtimeReady() && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) {
|
||||
console.warn(`[runtime] using cached Hermes runtime because update check failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (cachedRuntimeMatches(runtimeRoot, descriptor) && !process.env.HERMES_DESKTOP_RUNTIME_FORCE_UPDATE) return
|
||||
|
||||
const archive = join(dirname(runtimeRoot), `${descriptor.name}.download`)
|
||||
console.log(`[runtime] downloading Hermes runtime ${descriptor.name}`)
|
||||
onProgress?.({ stage: 'download', message: `Downloading ${descriptor.name}...` })
|
||||
await downloadFile(descriptor.url, archive, onProgress)
|
||||
if (descriptor.sha256) {
|
||||
onProgress?.({ stage: 'verify', message: 'Verifying Hermes runtime...' })
|
||||
const actual = await sha256File(archive)
|
||||
if (actual !== descriptor.sha256) {
|
||||
throw new Error(`Runtime checksum mismatch for ${descriptor.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.({ stage: 'extract', message: 'Extracting Hermes runtime...' })
|
||||
await extractRuntimeArchive(archive, runtimeRoot)
|
||||
const archiveSize = statSync(archive).size
|
||||
rmSync(archive, { force: true })
|
||||
|
||||
const manifestPath = join(runtimeRoot, RUNTIME_MANIFEST_NAME)
|
||||
if (!existsSync(manifestPath)) {
|
||||
writeFileSync(manifestPath, JSON.stringify({
|
||||
schema: 1,
|
||||
platform: runtimePlatformKey(),
|
||||
hermesAgentVersion: descriptor.hermesAgentVersion,
|
||||
asset: {
|
||||
name: descriptor.name,
|
||||
sha256: descriptor.sha256,
|
||||
size: archiveSize,
|
||||
},
|
||||
}, null, 2))
|
||||
}
|
||||
onProgress?.({ stage: 'ready', message: 'Hermes runtime ready.' })
|
||||
console.log(`[runtime] Hermes runtime ready at ${runtimeRoot}`)
|
||||
}
|
||||
@@ -8,11 +8,15 @@ import { promisify } from 'node:util'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
bundledBrowserExecutable,
|
||||
bundledGit,
|
||||
bundledNode,
|
||||
gitPathDirs,
|
||||
webuiServerEntry,
|
||||
webuiDir,
|
||||
hermesBin,
|
||||
webUiHome,
|
||||
hermesHome,
|
||||
nodeBinDir,
|
||||
tokenFile,
|
||||
pythonDir,
|
||||
} from './paths'
|
||||
@@ -296,22 +300,28 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
const bundledPython = isWin
|
||||
? join(pythonDir(), 'python.exe')
|
||||
: join(pythonDir(), 'bin', 'python3')
|
||||
const bundledNodeBin = isWin
|
||||
const bundledAgentBrowserBin = isWin
|
||||
? join(pythonDir(), 'node')
|
||||
: join(pythonDir(), 'node', 'bin')
|
||||
const bundledNodeBin = nodeBinDir()
|
||||
const bundledGitPath = gitPathDirs().join(delimiter)
|
||||
const bridgePort = await getFreeTcpPort()
|
||||
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
||||
const loginShellPath = await getLoginShellPath()
|
||||
const nvmNodeBinPaths = getNvmNodeBinPaths()
|
||||
const runtimePath = mergePathEntries(
|
||||
dirname(hermesBin()),
|
||||
bundledAgentBrowserBin,
|
||||
bundledNodeBin,
|
||||
bundledGitPath,
|
||||
loginShellPath,
|
||||
nvmNodeBinPaths,
|
||||
process.env.PATH,
|
||||
process.env.Path,
|
||||
COMMON_USER_BIN_DIRS.join(delimiter),
|
||||
)
|
||||
const browserExecutable = process.env.AGENT_BROWSER_EXECUTABLE_PATH?.trim() || bundledBrowserExecutable()
|
||||
const gitBin = bundledGit()
|
||||
|
||||
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
@@ -326,9 +336,12 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
HERMES_AGENT_BRIDGE_PYTHON: bundledPython,
|
||||
HERMES_AGENT_CLI_PYTHON: bundledPython,
|
||||
HERMES_AGENT_ROOT: pythonDir(),
|
||||
HERMES_AGENT_NODE: bundledNode(),
|
||||
HERMES_AGENT_NODE_ROOT: isWin ? bundledNodeBin : dirname(bundledNodeBin),
|
||||
AGENT_BROWSER_HOME: process.env.AGENT_BROWSER_HOME?.trim() || join(agentHome, 'agent-browser'),
|
||||
...(browserExecutable ? { AGENT_BROWSER_EXECUTABLE_PATH: browserExecutable } : {}),
|
||||
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || join(pythonDir(), 'ms-playwright'),
|
||||
...(gitBin ? { HERMES_AGENT_GIT: gitBin } : {}),
|
||||
// Force TCP loopback for the agent bridge. The default `ipc:///tmp/...`
|
||||
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
||||
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
|
||||
|
||||
Reference in New Issue
Block a user