feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,40 @@
|
||||
# Hermes Studio
|
||||
|
||||
Electron desktop distribution for Hermes Studio.
|
||||
|
||||
## Install
|
||||
|
||||
Download the latest macOS, Windows, or Linux installer for your CPU
|
||||
architecture from the project
|
||||
[GitHub Releases](https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest).
|
||||
|
||||
The desktop app bundles the Web UI runtime and launches it locally from the
|
||||
native shell app.
|
||||
|
||||
## Data directories
|
||||
|
||||
Hermes Agent data is stored in the same platform-specific location as native
|
||||
Hermes installs:
|
||||
|
||||
- Windows: `%LOCALAPPDATA%\hermes` (falls back to `%APPDATA%\hermes`)
|
||||
- macOS/Linux: `~/.hermes`
|
||||
|
||||
The desktop wrapper's own Web UI state is stored separately in
|
||||
`~/.hermes-web-ui` unless `HERMES_WEB_UI_HOME` is set.
|
||||
|
||||
## China mirror environment
|
||||
|
||||
These mirrors are optional and are not required in CI:
|
||||
|
||||
```sh
|
||||
export NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
|
||||
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
```
|
||||
|
||||
If GitHub release downloads are slow, `fetch-python.mjs` can also use a compatible
|
||||
python-build-standalone release mirror:
|
||||
|
||||
```sh
|
||||
export PBS_BASE_URL=https://github.com/astral-sh/python-build-standalone/releases/download
|
||||
```
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 821 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,8 @@
|
||||
!macro customInit
|
||||
IfFileExists "$INSTDIR\Hermes Studio.exe" 0 hermesStudioStopDone
|
||||
DetailPrint "Stopping Hermes Studio..."
|
||||
nsExec::ExecToLog '"$INSTDIR\Hermes Studio.exe" --quit'
|
||||
Sleep 5000
|
||||
nsExec::ExecToLog 'taskkill.exe /IM "Hermes Studio.exe" /T /F'
|
||||
hermesStudioStopDone:
|
||||
!macroend
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"tag": "hermes-0.15.2-runtime"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,85 @@
|
||||
appId: com.hermeswebui.studio
|
||||
productName: Hermes Studio
|
||||
copyright: Copyright © 2026
|
||||
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://download.ekkolearnai.com
|
||||
|
||||
# Don't auto-prune our root node_modules; we curate `files` and `extraResources` ourselves.
|
||||
buildDependenciesFromSource: false
|
||||
nodeGypRebuild: false
|
||||
npmRebuild: false
|
||||
|
||||
files:
|
||||
- "dist/**/*"
|
||||
- "package.json"
|
||||
- "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme,LICENSE,LICENSE.txt,license,*.d.ts}"
|
||||
- "!**/node_modules/.bin"
|
||||
- "!**/{.DS_Store,.git,.gitignore,.eslintrc*,.prettierrc*,*.map,tsconfig.json}"
|
||||
|
||||
# Web UI source (built dist) lives outside the asar. Python/Node/Git runtime
|
||||
# assets are downloaded into the user's Web UI home on first launch.
|
||||
# This package lives at packages/desktop, so ../.. is the hermes-web-ui repo root.
|
||||
extraResources:
|
||||
- from: "build"
|
||||
to: "build"
|
||||
filter:
|
||||
- "icon.png"
|
||||
- "icon.ico"
|
||||
- "trayTemplate.png"
|
||||
- "trayWindows.png"
|
||||
- "runtime-release.json"
|
||||
- from: "../.."
|
||||
to: "webui"
|
||||
filter:
|
||||
- "package.json"
|
||||
- "dist/**"
|
||||
- "node_modules/**"
|
||||
# Drop other-platform node-pty prebuilds (saves ~45MB)
|
||||
- "!node_modules/node-pty/prebuilds/!(${platform}-${arch})/**"
|
||||
- "!node_modules/node-pty/build/**"
|
||||
- "!packages/desktop/**"
|
||||
- "!**/{.git,.github,docs,tests,playwright.config.ts,README*,scripts,*.map}"
|
||||
- "!node_modules/**/*.md"
|
||||
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
|
||||
mac:
|
||||
target:
|
||||
- target: dmg
|
||||
arch: [arm64, x64]
|
||||
- target: zip
|
||||
arch: [arm64, x64]
|
||||
category: public.app-category.developer-tools
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
notarize: true
|
||||
artifactName: "Hermes.Studio-${version}-${arch}.${ext}"
|
||||
|
||||
win:
|
||||
target:
|
||||
- target: nsis
|
||||
arch: [x64]
|
||||
artifactName: "Hermes.Studio-${version}-${arch}.${ext}"
|
||||
|
||||
linux:
|
||||
icon: build/icons
|
||||
target:
|
||||
- target: AppImage
|
||||
arch: [x64, arm64]
|
||||
- target: deb
|
||||
arch: [x64] # fpm has no arm64 binary; deb only on x64
|
||||
category: Development
|
||||
artifactName: "Hermes.Studio-${version}-${arch}.${ext}"
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
include: build/installer.nsh
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "hermes-studio",
|
||||
"version": "0.6.9",
|
||||
"description": "Hermes Studio desktop distribution with bundled Python runtime and hermes-agent",
|
||||
"homepage": "https://hermes-studio.ai",
|
||||
"author": {
|
||||
"name": "Hermes Studio Contributors",
|
||||
"email": "noreply@hermes-studio.local"
|
||||
},
|
||||
"license": "BSL-1.1",
|
||||
"private": true,
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"build": "npm run build:main",
|
||||
"fetch:node": "node scripts/fetch-node.mjs",
|
||||
"fetch:git": "node scripts/fetch-git.mjs",
|
||||
"fetch:python": "node scripts/fetch-python.mjs",
|
||||
"install:hermes": "node scripts/install-hermes.mjs",
|
||||
"patch:hermes": "node scripts/apply-hermes-patches.mjs",
|
||||
"write:runtime-release": "node scripts/write-runtime-release.mjs",
|
||||
"prepare:runtime": "npm run fetch:node && npm run fetch:git && npm run fetch:python && npm run install:hermes && npm run patch:hermes && npm run prune:python",
|
||||
"prepare:python": "npm run prepare:runtime",
|
||||
"package:runtime": "node scripts/package-runtime.mjs",
|
||||
"runtime:asset-name": "node scripts/runtime-asset-name.mjs",
|
||||
"prune:python": "node scripts/prune-python.mjs",
|
||||
"dev": "npm run build:main && electron .",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.2",
|
||||
"electron": "^42.3.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env node
|
||||
// Apply locally-curated patches to hermes-agent inside the bundled venv.
|
||||
// Each patch is idempotent: a marker string is searched for first, and the
|
||||
// edit is skipped if the patch is already in place.
|
||||
//
|
||||
// Run after `install-hermes.mjs`. Designed to be safe to re-run.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
// Allow the CI sanity-check path to point at a temp install dir without
|
||||
// the full bundled-Python layout (e.g. `pip install --target /tmp/foo`).
|
||||
const sitePkgs = process.env.HERMES_AGENT_SITE_PACKAGES ?? (
|
||||
TARGET_OS === 'win32'
|
||||
? join(PY_DIR, 'Lib', 'site-packages')
|
||||
: (() => {
|
||||
const libDir = join(PY_DIR, 'lib')
|
||||
if (!existsSync(libDir)) throw new Error(`No lib dir at ${libDir}`)
|
||||
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
|
||||
if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`)
|
||||
return join(libDir, py, 'site-packages')
|
||||
})()
|
||||
)
|
||||
|
||||
const dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py')
|
||||
const browserToolPath = join(sitePkgs, 'tools', 'browser_tool.py')
|
||||
const sitecustomizePath = join(sitePkgs, 'sitecustomize.py')
|
||||
if (!existsSync(dtPath)) {
|
||||
console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let src = readFileSync(dtPath, 'utf-8')
|
||||
const before = src
|
||||
let applied = 0
|
||||
let skipped = 0
|
||||
|
||||
function patch(id, marker, find, replace) {
|
||||
if (src.includes(marker)) {
|
||||
console.log(` · ${id} (already applied)`)
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
if (!src.includes(find)) {
|
||||
console.log(` ✗ ${id} (anchor not found — upstream changed?)`)
|
||||
return
|
||||
}
|
||||
src = src.replace(find, replace)
|
||||
console.log(` ✓ ${id}`)
|
||||
applied++
|
||||
}
|
||||
|
||||
function patchText(text, id, marker, find, replace) {
|
||||
if (text.includes(marker)) {
|
||||
console.log(` · ${id} (already applied)`)
|
||||
skipped++
|
||||
return text
|
||||
}
|
||||
if (!text.includes(find)) {
|
||||
console.log(` ✗ ${id} (anchor not found — upstream changed?)`)
|
||||
return text
|
||||
}
|
||||
applied++
|
||||
console.log(` ✓ ${id}`)
|
||||
return text.replace(find, replace)
|
||||
}
|
||||
|
||||
console.log(`Patching ${dtPath}`)
|
||||
|
||||
// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships
|
||||
// `_IncomingHandler.pre_start()` natively (present in 0.15.x and on main), so
|
||||
// re-adding it just injected a duplicate method.
|
||||
|
||||
// ── dt-card-tpl-env ─────────────────────────────────────────────
|
||||
// Fall back to DINGTALK_CARD_TEMPLATE_ID env var.
|
||||
patch(
|
||||
'dt-card-tpl-env',
|
||||
'# patch:dt-card-tpl-env',
|
||||
` self._card_template_id: Optional[str] = extra.get("card_template_id")`,
|
||||
` # patch:dt-card-tpl-env — env var fallback
|
||||
self._card_template_id: Optional[str] = (
|
||||
extra.get("card_template_id") or os.getenv("DINGTALK_CARD_TEMPLATE_ID")
|
||||
)`,
|
||||
)
|
||||
|
||||
// ── dt-card-before-webhook ──────────────────────────────────────
|
||||
// Try AI Card *before* validating session_webhook — Card SDK does not need
|
||||
// a webhook URL. Move the lookup of `current_message` and the AI Card block
|
||||
// up before the webhook gate.
|
||||
patch(
|
||||
'dt-card-before-webhook',
|
||||
'# patch:dt-card-before-webhook',
|
||||
` # Check metadata first (for direct webhook sends)
|
||||
session_webhook = metadata.get("session_webhook")
|
||||
if not session_webhook:
|
||||
webhook_info = self._get_valid_webhook(chat_id)
|
||||
if not webhook_info:
|
||||
logger.warning(
|
||||
"[%s] No valid session_webhook for chat_id=%s",
|
||||
self.name, chat_id,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="No valid session_webhook available. Reply must follow an incoming message.",
|
||||
)
|
||||
session_webhook, _ = webhook_info
|
||||
|
||||
if not self._http_client:
|
||||
return SendResult(success=False, error="HTTP client not initialized")
|
||||
|
||||
# Look up the inbound message for this chat (for AI Card routing)
|
||||
current_message = self._message_contexts.get(chat_id)`,
|
||||
` # patch:dt-card-before-webhook — try AI Card first; webhook gate moved below.
|
||||
if not self._http_client:
|
||||
return SendResult(success=False, error="HTTP client not initialized")
|
||||
|
||||
# Look up the inbound message for this chat (for AI Card routing)
|
||||
current_message = self._message_contexts.get(chat_id)
|
||||
session_webhook = metadata.get("session_webhook")`,
|
||||
)
|
||||
|
||||
// The above leaves the existing AI Card block intact; we still need to add
|
||||
// the deferred webhook gate AFTER the AI Card attempt. The original code
|
||||
// had `logger.debug("[%s] Sending via webhook", self.name)` immediately
|
||||
// after the AI Card fallback log. Insert the gate right before that.
|
||||
patch(
|
||||
'dt-card-before-webhook-gate',
|
||||
'# patch:dt-card-before-webhook-gate',
|
||||
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
|
||||
|
||||
logger.debug("[%s] Sending via webhook", self.name)`,
|
||||
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
|
||||
|
||||
# patch:dt-card-before-webhook-gate — webhook required only for fallback path
|
||||
if not session_webhook:
|
||||
webhook_info = self._get_valid_webhook(chat_id)
|
||||
if not webhook_info:
|
||||
logger.warning(
|
||||
"[%s] No valid session_webhook for chat_id=%s",
|
||||
self.name, chat_id,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="No valid session_webhook available. Reply must follow an incoming message.",
|
||||
)
|
||||
session_webhook, _ = webhook_info
|
||||
|
||||
logger.debug("[%s] Sending via webhook", self.name)`,
|
||||
)
|
||||
|
||||
// ── dt-dm-robot-code ────────────────────────────────────────────
|
||||
patch(
|
||||
'dt-dm-robot-code',
|
||||
'# patch:dt-dm-robot-code',
|
||||
` im_robot_open_deliver_model=(
|
||||
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
|
||||
space_type="IM_ROBOT",
|
||||
)
|
||||
),`,
|
||||
` im_robot_open_deliver_model=(
|
||||
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
|
||||
space_type="IM_ROBOT",
|
||||
robot_code=self._robot_code, # patch:dt-dm-robot-code
|
||||
)
|
||||
),`,
|
||||
)
|
||||
|
||||
// ── dt-card-autolayout ──────────────────────────────────────────
|
||||
patch(
|
||||
'dt-card-autolayout',
|
||||
'# patch:dt-card-autolayout',
|
||||
` card_data=dingtalk_card_models.CreateCardRequestCardData(
|
||||
card_param_map={"content": ""},
|
||||
),`,
|
||||
` card_data=dingtalk_card_models.CreateCardRequestCardData(
|
||||
# patch:dt-card-autolayout — wide-screen via sys_full_json_obj
|
||||
card_param_map={
|
||||
"content": "",
|
||||
"sys_full_json_obj": json.dumps({"config": {"autoLayout": True}}),
|
||||
},
|
||||
),`,
|
||||
)
|
||||
|
||||
if (src !== before) {
|
||||
writeFileSync(dtPath, src)
|
||||
}
|
||||
|
||||
if (existsSync(browserToolPath)) {
|
||||
console.log(`Patching ${browserToolPath}`)
|
||||
let browserSrc = readFileSync(browserToolPath, 'utf-8')
|
||||
const browserBefore = browserSrc
|
||||
|
||||
browserSrc = patchText(
|
||||
browserSrc,
|
||||
'browser-stdout-decode-fallback',
|
||||
'# patch:browser-stdout-decode-fallback',
|
||||
`from hermes_cli.config import cfg_get\n`,
|
||||
`from hermes_cli.config import cfg_get
|
||||
|
||||
# patch:browser-stdout-decode-fallback
|
||||
def _hermes_read_browser_output(path: str) -> str:
|
||||
data = Path(path).read_bytes()
|
||||
for encoding in ("utf-8", "gb18030"):
|
||||
try:
|
||||
return data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return data.decode("utf-8", errors="replace")
|
||||
`,
|
||||
)
|
||||
|
||||
for (const [id, find, replace] of [
|
||||
[
|
||||
'browser-fallback-stdout-read',
|
||||
` with open(stdout_path, "r", encoding="utf-8") as f:
|
||||
stdout = f.read().strip()`,
|
||||
` # patch:browser-fallback-stdout-read
|
||||
stdout = _hermes_read_browser_output(stdout_path).strip()`,
|
||||
],
|
||||
[
|
||||
'browser-command-stdout-read',
|
||||
` with open(stdout_path, "r", encoding="utf-8") as f:
|
||||
stdout = f.read()
|
||||
with open(stderr_path, "r", encoding="utf-8") as f:
|
||||
stderr = f.read()`,
|
||||
` # patch:browser-command-stdout-read
|
||||
stdout = _hermes_read_browser_output(stdout_path)
|
||||
stderr = _hermes_read_browser_output(stderr_path)`,
|
||||
],
|
||||
]) {
|
||||
browserSrc = patchText(
|
||||
browserSrc,
|
||||
id,
|
||||
`# patch:${id}`,
|
||||
find,
|
||||
replace,
|
||||
)
|
||||
}
|
||||
|
||||
if (browserSrc !== browserBefore) {
|
||||
writeFileSync(browserToolPath, browserSrc)
|
||||
}
|
||||
}
|
||||
|
||||
const brotlicffiCompatMarker = '# patch:brotlicffi-error-compat'
|
||||
const brotlicffiCompat = `
|
||||
${brotlicffiCompatMarker}
|
||||
try:
|
||||
import brotlicffi as _hermes_brotlicffi
|
||||
if not hasattr(_hermes_brotlicffi, "error"):
|
||||
_hermes_brotlicffi.error = (
|
||||
getattr(_hermes_brotlicffi, "Error", None)
|
||||
or getattr(_hermes_brotlicffi, "BrotliError", None)
|
||||
or Exception
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
`
|
||||
|
||||
const desktopHiddenSubprocessMarker = '# patch:desktop-hidden-subprocess-defaults'
|
||||
const desktopHiddenSubprocessDefaults = `
|
||||
${desktopHiddenSubprocessMarker}
|
||||
try:
|
||||
import os as _hermes_os
|
||||
if _hermes_os.name == "nt" and _hermes_os.environ.get("HERMES_DESKTOP", "").strip().lower() == "true":
|
||||
import asyncio as _hermes_asyncio
|
||||
import subprocess as _hermes_subprocess
|
||||
if not getattr(_hermes_subprocess, "_hermes_desktop_hidden_defaults_installed", False):
|
||||
_hermes_create_no_window = getattr(_hermes_subprocess, "CREATE_NO_WINDOW", 0) or 0x08000000
|
||||
|
||||
def _hermes_apply_hidden_process_options(kwargs):
|
||||
flags = kwargs.get("creationflags", 0) or 0
|
||||
try:
|
||||
kwargs["creationflags"] = int(flags) | _hermes_create_no_window
|
||||
except Exception:
|
||||
kwargs["creationflags"] = _hermes_create_no_window
|
||||
|
||||
startupinfo = kwargs.get("startupinfo")
|
||||
if startupinfo is None:
|
||||
try:
|
||||
startupinfo = _hermes_subprocess.STARTUPINFO()
|
||||
except Exception:
|
||||
return
|
||||
kwargs["startupinfo"] = startupinfo
|
||||
try:
|
||||
startupinfo.dwFlags |= getattr(_hermes_subprocess, "STARTF_USESHOWWINDOW", 1)
|
||||
startupinfo.wShowWindow = getattr(_hermes_subprocess, "SW_HIDE", 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_hermes_original_popen = _hermes_subprocess.Popen
|
||||
_hermes_original_create_subprocess_exec = _hermes_asyncio.create_subprocess_exec
|
||||
_hermes_original_create_subprocess_shell = _hermes_asyncio.create_subprocess_shell
|
||||
|
||||
class _HermesHiddenPopen(_hermes_original_popen):
|
||||
def __init__(self, *args, **kwargs):
|
||||
_hermes_apply_hidden_process_options(kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def _hermes_hidden_create_subprocess_exec(*args, **kwargs):
|
||||
_hermes_apply_hidden_process_options(kwargs)
|
||||
return await _hermes_original_create_subprocess_exec(*args, **kwargs)
|
||||
|
||||
async def _hermes_hidden_create_subprocess_shell(*args, **kwargs):
|
||||
_hermes_apply_hidden_process_options(kwargs)
|
||||
return await _hermes_original_create_subprocess_shell(*args, **kwargs)
|
||||
|
||||
_hermes_subprocess.Popen = _HermesHiddenPopen
|
||||
_hermes_asyncio.create_subprocess_exec = _hermes_hidden_create_subprocess_exec
|
||||
_hermes_asyncio.create_subprocess_shell = _hermes_hidden_create_subprocess_shell
|
||||
_hermes_subprocess._hermes_desktop_hidden_defaults_installed = True
|
||||
except Exception:
|
||||
pass
|
||||
`
|
||||
|
||||
function appendSitecustomizePatch(id, marker, body) {
|
||||
const sitecustomize = existsSync(sitecustomizePath) ? readFileSync(sitecustomizePath, 'utf-8') : ''
|
||||
if (sitecustomize.includes(marker)) {
|
||||
console.log(` · ${id} (already applied)`)
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
const nextSitecustomize = `${sitecustomize.replace(/\s*$/, '')}\n${body.trim()}\n`
|
||||
writeFileSync(sitecustomizePath, nextSitecustomize)
|
||||
console.log(` ✓ ${id}`)
|
||||
applied++
|
||||
}
|
||||
|
||||
appendSitecustomizePatch('brotlicffi-error-compat', brotlicffiCompatMarker, brotlicffiCompat)
|
||||
appendSitecustomizePatch('desktop-hidden-subprocess-defaults', desktopHiddenSubprocessMarker, desktopHiddenSubprocessDefaults)
|
||||
|
||||
console.log(`Done. Applied ${applied}, skipped ${skipped}.`)
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
// Download Git for Windows MinGit for Windows builds. Other platforms create
|
||||
// an empty resource directory so electron-builder can use the same resource map.
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const OUT_DIR = resolve(ROOT, 'resources', 'git', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
|
||||
if (TARGET_OS !== 'win32') {
|
||||
writeFileSync(resolve(OUT_DIR, '.placeholder'), 'Git for Windows is only bundled on Windows.\n')
|
||||
console.log(`Git resource placeholder ready at ${OUT_DIR}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (TARGET_ARCH !== 'x64') {
|
||||
console.error(`Unsupported Git for Windows target: ${TARGET_OS}-${TARGET_ARCH}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (existsSync(resolve(OUT_DIR, 'cmd', 'git.exe'))) {
|
||||
console.log(`Git for Windows already present at ${OUT_DIR}, skipping`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
async function latestMinGitUrl() {
|
||||
if (process.env.GIT_FOR_WINDOWS_URL?.trim()) return process.env.GIT_FOR_WINDOWS_URL.trim()
|
||||
|
||||
const headers = { 'User-Agent': 'hermes-studio-desktop-build' }
|
||||
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
||||
if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}`
|
||||
|
||||
const response = await fetch('https://api.github.com/repos/git-for-windows/git/releases/latest', {
|
||||
headers,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}`)
|
||||
}
|
||||
const release = await response.json()
|
||||
const asset = release.assets?.find(candidate =>
|
||||
typeof candidate?.name === 'string'
|
||||
&& /^MinGit-.*-64-bit\.zip$/.test(candidate.name)
|
||||
&& typeof candidate.browser_download_url === 'string',
|
||||
)
|
||||
if (!asset) throw new Error('Could not find MinGit 64-bit zip in latest Git for Windows release')
|
||||
return asset.browser_download_url
|
||||
}
|
||||
|
||||
let url
|
||||
try {
|
||||
url = await latestMinGitUrl()
|
||||
} catch (err) {
|
||||
console.error(`Failed to resolve Git for Windows download URL: ${err instanceof Error ? err.message : String(err)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const file = url.split('/').pop() || 'mingit.zip'
|
||||
const archivePath = resolve(tmpdir(), file)
|
||||
|
||||
console.log(`Fetching ${url}`)
|
||||
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' })
|
||||
if (curl.status !== 0) {
|
||||
console.error('curl failed')
|
||||
process.exit(curl.status ?? 1)
|
||||
}
|
||||
|
||||
console.log(`Extracting into ${OUT_DIR}`)
|
||||
const extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR], { stdio: 'inherit' })
|
||||
if (extract.status !== 0) {
|
||||
console.error('extract failed')
|
||||
process.exit(extract.status ?? 1)
|
||||
}
|
||||
|
||||
rmSync(archivePath, { force: true })
|
||||
console.log(`Git for Windows ready at ${OUT_DIR}`)
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
// Download a portable Node.js runtime for the current (or target) platform/arch
|
||||
// and extract into resources/node/<os>-<arch>/.
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const NODE_VERSION = (process.env.HERMES_DESKTOP_NODE_VERSION || process.env.NODE_VERSION || process.versions.node).replace(/^v/, '')
|
||||
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const OUT_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
const DIST_PLATFORM = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'darwin' : TARGET_OS
|
||||
const DIST_ARCH = TARGET_ARCH === 'x64' ? 'x64' : TARGET_ARCH === 'arm64' ? 'arm64' : ''
|
||||
if (!DIST_ARCH || !['win', 'darwin', 'linux'].includes(DIST_PLATFORM)) {
|
||||
console.error(`Unsupported target: ${TARGET_OS}-${TARGET_ARCH}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ext = TARGET_OS === 'win32' ? 'zip' : 'tar.gz'
|
||||
const file = `node-v${NODE_VERSION}-${DIST_PLATFORM}-${DIST_ARCH}.${ext}`
|
||||
const baseUrl = (process.env.NODE_DIST_BASE_URL || 'https://nodejs.org/dist').replace(/\/$/, '')
|
||||
const url = `${baseUrl}/v${NODE_VERSION}/${file}`
|
||||
const marker = TARGET_OS === 'win32' ? 'node.exe' : join('bin', 'node')
|
||||
|
||||
if (existsSync(resolve(OUT_DIR, marker))) {
|
||||
console.log(`Node.js already present at ${OUT_DIR}, skipping`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
const archivePath = resolve(tmpdir(), file)
|
||||
|
||||
console.log(`Fetching ${url}`)
|
||||
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', archivePath, url], { stdio: 'inherit' })
|
||||
if (curl.status !== 0) {
|
||||
console.error('curl failed')
|
||||
process.exit(curl.status ?? 1)
|
||||
}
|
||||
|
||||
console.log(`Extracting into ${OUT_DIR}`)
|
||||
let extract
|
||||
if (TARGET_OS === 'win32') {
|
||||
extract = spawnSync('tar', ['-xf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
|
||||
} else {
|
||||
extract = spawnSync('tar', ['-xzf', archivePath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
|
||||
}
|
||||
if (extract.status !== 0) {
|
||||
console.error('extract failed')
|
||||
process.exit(extract.status ?? 1)
|
||||
}
|
||||
|
||||
rmSync(archivePath, { force: true })
|
||||
console.log(`Node.js ready at ${OUT_DIR}`)
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// Download python-build-standalone for the current (or target) platform/arch
|
||||
// and extract into resources/python/<os>-<arch>/
|
||||
import { mkdirSync, existsSync, createWriteStream, rmSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir, platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
// Pin a known-good python-build-standalone release. Bump intentionally.
|
||||
const PBS_TAG = process.env.PBS_TAG || '20260510'
|
||||
const PYTHON_VERSION = process.env.PBS_PY || '3.12.13'
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform() // darwin | win32 | linux
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch() // arm64 | x64
|
||||
|
||||
const TRIPLE_MAP = {
|
||||
'darwin-arm64': 'aarch64-apple-darwin',
|
||||
'darwin-x64': 'x86_64-apple-darwin',
|
||||
'win32-x64': 'x86_64-pc-windows-msvc',
|
||||
'linux-x64': 'x86_64-unknown-linux-gnu',
|
||||
'linux-arm64': 'aarch64-unknown-linux-gnu',
|
||||
}
|
||||
|
||||
const key = `${TARGET_OS}-${TARGET_ARCH}`
|
||||
const triple = TRIPLE_MAP[key]
|
||||
if (!triple) {
|
||||
console.error(`Unsupported target: ${key}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// electron-builder uses `mac`/`win`/`linux` for `${os}` — match that
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const OUT_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
const FLAVOR = 'install_only_stripped'
|
||||
const FILE = `cpython-${PYTHON_VERSION}+${PBS_TAG}-${triple}-${FLAVOR}.tar.gz`
|
||||
const PBS_BASE_URL = (process.env.PBS_BASE_URL || 'https://github.com/astral-sh/python-build-standalone/releases/download').replace(/\/$/, '')
|
||||
const URL = `${PBS_BASE_URL}/${PBS_TAG}/${FILE}`
|
||||
|
||||
if (existsSync(resolve(OUT_DIR, 'python')) || existsSync(resolve(OUT_DIR, 'bin', 'python3'))) {
|
||||
console.log(`✓ Python already present at ${OUT_DIR}, skipping`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
const tarPath = resolve(tmpdir(), FILE)
|
||||
|
||||
console.log(`→ Fetching ${URL}`)
|
||||
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', tarPath, URL], { stdio: 'inherit' })
|
||||
if (curl.status !== 0) {
|
||||
console.error('curl failed')
|
||||
process.exit(curl.status ?? 1)
|
||||
}
|
||||
|
||||
console.log(`→ Extracting into ${OUT_DIR}`)
|
||||
// PBS tarballs unpack to a top-level "python/" directory; --strip-components=1 flattens it
|
||||
const tar = spawnSync('tar', ['-xzf', tarPath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
|
||||
if (tar.status !== 0) {
|
||||
console.error('tar failed')
|
||||
process.exit(tar.status ?? 1)
|
||||
}
|
||||
|
||||
rmSync(tarPath, { force: true })
|
||||
console.log(`✓ Python ready at ${OUT_DIR}`)
|
||||
@@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env node
|
||||
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
|
||||
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { basename, resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { platform as osPlatform, arch as osArch, homedir as osHomedir } from 'node:os'
|
||||
import { hermesVersion } from './runtime-config.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const HERMES_VERSION = hermesVersion()
|
||||
// Match the packaged runtime to the channel list exposed at /hermes/channels.
|
||||
// Telegram, Discord, and Slack are covered by "messaging". We intentionally
|
||||
// install Matrix's plaintext deps below instead of using the "matrix" extra:
|
||||
// that extra pulls mautrix[encryption] -> python-olm, which needs a fragile
|
||||
// native build on desktop packaging machines. WhatsApp, QQBot, and Weixin do
|
||||
// not expose dedicated hermes-agent extras; their deps are covered by base or
|
||||
// the channel extras below.
|
||||
const HERMES_EXTRAS = [
|
||||
'mcp',
|
||||
'messaging',
|
||||
'slack',
|
||||
'wecom',
|
||||
'dingtalk',
|
||||
'feishu',
|
||||
].join(',')
|
||||
const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[${HERMES_EXTRAS}]==${HERMES_VERSION}`
|
||||
const EXTRA_PYTHON_PACKAGES = splitPackageList(
|
||||
process.env.HERMES_EXTRA_PYTHON_PACKAGES || [
|
||||
'websockets',
|
||||
'mautrix==0.21.0',
|
||||
'Markdown==3.10.2',
|
||||
'aiosqlite==0.22.1',
|
||||
'asyncpg==0.31.0',
|
||||
'aiohttp-socks==0.11.0',
|
||||
].join(' '),
|
||||
)
|
||||
const BROWSER_PACKAGES = splitPackageList(
|
||||
process.env.HERMES_BROWSER_PACKAGES || 'agent-browser@^0.26.0 @askjo/camofox-browser@^1.5.2',
|
||||
)
|
||||
const SKIP_BROWSER_RUNTIME = process.env.HERMES_SKIP_BROWSER_RUNTIME === '1'
|
||||
|| process.env.HERMES_SKIP_BROWSER_RUNTIME?.toLowerCase() === 'true'
|
||||
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
const NODE_DIR = resolve(ROOT, 'resources', 'node', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
const NODE_PREFIX = resolve(PY_DIR, 'node')
|
||||
const AGENT_BROWSER_HOME = resolve(PY_DIR, 'agent-browser')
|
||||
const PLAYWRIGHT_BROWSERS_PATH = resolve(PY_DIR, 'ms-playwright')
|
||||
|
||||
const pyBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'python.exe')
|
||||
: resolve(PY_DIR, 'bin', 'python3')
|
||||
|
||||
if (!existsSync(pyBin)) {
|
||||
console.error(`Python not found at ${pyBin}. Run: npm run fetch:python`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function hasUv() {
|
||||
const r = spawnSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
function splitPackageList(value) {
|
||||
return value
|
||||
.split(/[,\s]+/)
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { stdio: 'inherit', ...options })
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
return result
|
||||
}
|
||||
|
||||
function optionalRun(command, args, options = {}) {
|
||||
return spawnSync(command, args, { stdio: 'inherit', ...options })
|
||||
}
|
||||
|
||||
function commandInvocation(command) {
|
||||
if (TARGET_OS === 'win32' && command.toLowerCase().endsWith('.cmd')) {
|
||||
const cmdCommand = /[\s&()[\]{}^=;!'+,`~]/.test(command) ? `"${command}"` : command
|
||||
return { command: 'cmd.exe', argsPrefix: ['/d', '/s', '/c', cmdCommand] }
|
||||
}
|
||||
return { command, argsPrefix: [] }
|
||||
}
|
||||
|
||||
function runInvocation(invocation, args, options = {}) {
|
||||
return run(invocation.command, [...invocation.argsPrefix, ...args], options)
|
||||
}
|
||||
|
||||
function optionalRunInvocation(invocation, args, options = {}) {
|
||||
return optionalRun(invocation.command, [...invocation.argsPrefix, ...args], options)
|
||||
}
|
||||
|
||||
function pythonBuildEnv() {
|
||||
if (TARGET_OS !== 'darwin') return process.env
|
||||
|
||||
const env = { ...process.env }
|
||||
if (!env.AR && existsSync('/usr/bin/ar')) env.AR = '/usr/bin/ar'
|
||||
if (!env.RANLIB && existsSync('/usr/bin/ranlib')) env.RANLIB = '/usr/bin/ranlib'
|
||||
return env
|
||||
}
|
||||
|
||||
function installPythonPackages(packages, label) {
|
||||
if (packages.length === 0) return
|
||||
const env = pythonBuildEnv()
|
||||
if (hasUv()) {
|
||||
console.log(`→ Installing ${label} via uv: ${packages.join(' ')}`)
|
||||
run('uv', [
|
||||
'pip', 'install',
|
||||
'--python', pyBin,
|
||||
...packages,
|
||||
], { env })
|
||||
} else {
|
||||
console.log(`→ Installing ${label} via pip: ${packages.join(' ')}`)
|
||||
run(pyBin, [
|
||||
'-m', 'pip', 'install',
|
||||
...packages,
|
||||
'--no-warn-script-location',
|
||||
'--disable-pip-version-check',
|
||||
], { env })
|
||||
}
|
||||
}
|
||||
|
||||
function npmCommand() {
|
||||
const bundled = TARGET_OS === 'win32'
|
||||
? resolve(NODE_DIR, 'npm.cmd')
|
||||
: resolve(NODE_DIR, 'bin', 'npm')
|
||||
const candidates = TARGET_OS === 'win32'
|
||||
? [bundled, 'npm.cmd', 'npm.exe', 'npm']
|
||||
: [bundled, 'npm']
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === bundled && !existsSync(candidate)) continue
|
||||
const invocation = commandInvocation(candidate)
|
||||
const result = optionalRunInvocation(invocation, ['--version'], { stdio: 'ignore', env: browserRuntimeEnv(false) })
|
||||
if (result.status === 0) return invocation
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function agentBrowserCommand() {
|
||||
if (TARGET_OS === 'win32') {
|
||||
return resolve(NODE_PREFIX, 'agent-browser.cmd')
|
||||
}
|
||||
return resolve(NODE_PREFIX, 'bin', 'agent-browser')
|
||||
}
|
||||
|
||||
function browserRuntimeEnv(includeAgentBrowser = true) {
|
||||
const bundledNodeBin = TARGET_OS === 'win32'
|
||||
? NODE_DIR
|
||||
: resolve(NODE_DIR, 'bin')
|
||||
const nodePath = TARGET_OS === 'win32'
|
||||
? NODE_PREFIX
|
||||
: resolve(NODE_PREFIX, 'bin')
|
||||
const inheritedPath = process.env.PATH || process.env.Path || ''
|
||||
const pathKey = TARGET_OS === 'win32' ? 'Path' : 'PATH'
|
||||
const browserExecutable = includeAgentBrowser ? ensureBundledBrowserExecutable() : null
|
||||
const pathEntries = includeAgentBrowser
|
||||
? [nodePath, bundledNodeBin, inheritedPath]
|
||||
: [bundledNodeBin, inheritedPath]
|
||||
const env = {
|
||||
...process.env,
|
||||
[pathKey]: pathEntries.filter(Boolean).join(TARGET_OS === 'win32' ? ';' : ':'),
|
||||
HERMES_AGENT_NODE: TARGET_OS === 'win32' ? resolve(NODE_DIR, 'node.exe') : resolve(NODE_DIR, 'bin', 'node'),
|
||||
HERMES_AGENT_NODE_ROOT: NODE_DIR,
|
||||
AGENT_BROWSER_HOME,
|
||||
PLAYWRIGHT_BROWSERS_PATH,
|
||||
}
|
||||
if (browserExecutable) env.AGENT_BROWSER_EXECUTABLE_PATH = browserExecutable
|
||||
return env
|
||||
}
|
||||
|
||||
function bundledBrowserExecutableNames() {
|
||||
if (TARGET_OS === 'win32') return new Set(['chrome.exe'])
|
||||
if (TARGET_OS === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome'])
|
||||
return new Set(['chrome', 'chromium', 'chromium-browser'])
|
||||
}
|
||||
|
||||
function defaultAgentBrowserHomes() {
|
||||
const candidates = [
|
||||
process.env.USERPROFILE,
|
||||
process.env.UserProfile,
|
||||
process.env.HOME,
|
||||
process.env.HOMEDRIVE && process.env.HOMEPATH
|
||||
? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
||||
: null,
|
||||
osHomedir(),
|
||||
]
|
||||
return Array.from(new Set(
|
||||
candidates
|
||||
.map(home => home?.trim())
|
||||
.filter(Boolean)
|
||||
.map(home => resolve(home, '.agent-browser')),
|
||||
))
|
||||
}
|
||||
|
||||
function findBrowserInstallInHome(home) {
|
||||
const names = bundledBrowserExecutableNames()
|
||||
const browsersDir = join(home, 'browsers')
|
||||
const bundleDirs = []
|
||||
|
||||
if (existsSync(browsersDir)) {
|
||||
try {
|
||||
for (const entry of readdirSync(browsersDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) bundleDirs.push(join(browsersDir, entry.name))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const bundleDir of bundleDirs) {
|
||||
const executable = findBrowserExecutableUnder(bundleDir, names)
|
||||
if (executable) return { executable, bundleDir }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findBrowserExecutableUnder(root, names) {
|
||||
const stack = [root].filter(existsSync)
|
||||
const visited = new Set()
|
||||
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
if (!dir || visited.has(dir)) continue
|
||||
visited.add(dir)
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isFile() && names.has(entry.name)) return path
|
||||
if (entry.isDirectory()) stack.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function findBundledBrowserExecutable() {
|
||||
return findBrowserInstallInHome(AGENT_BROWSER_HOME)?.executable ?? null
|
||||
}
|
||||
|
||||
function ensureBundledBrowserExecutable() {
|
||||
const bundled = findBrowserInstallInHome(AGENT_BROWSER_HOME)
|
||||
if (bundled) return bundled.executable
|
||||
|
||||
const searchedHomes = []
|
||||
for (const fallbackHome of defaultAgentBrowserHomes()) {
|
||||
if (fallbackHome === AGENT_BROWSER_HOME) continue
|
||||
searchedHomes.push(fallbackHome)
|
||||
|
||||
const fallback = findBrowserInstallInHome(fallbackHome)
|
||||
if (!fallback) continue
|
||||
|
||||
const targetBrowsersDir = join(AGENT_BROWSER_HOME, 'browsers')
|
||||
const targetBundleDir = join(targetBrowsersDir, basename(fallback.bundleDir))
|
||||
mkdirSync(targetBrowsersDir, { recursive: true })
|
||||
cpSync(fallback.bundleDir, targetBundleDir, { recursive: true, force: true, verbatimSymlinks: true })
|
||||
console.log(`✓ copied Chrome bundle into ${targetBundleDir}`)
|
||||
|
||||
return findBundledBrowserExecutable()
|
||||
}
|
||||
|
||||
if (searchedHomes.length > 0) {
|
||||
console.warn(`! no Chrome bundle found in fallback agent-browser homes: ${searchedHomes.join(', ')}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function sitePackagesDir() {
|
||||
if (TARGET_OS === 'win32') {
|
||||
return resolve(PY_DIR, 'Lib', 'site-packages')
|
||||
}
|
||||
const libDir = resolve(PY_DIR, 'lib')
|
||||
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
|
||||
if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`)
|
||||
return resolve(libDir, py, 'site-packages')
|
||||
}
|
||||
|
||||
function pythonModuleExists(moduleName) {
|
||||
const result = optionalRun(pyBin, [
|
||||
'-c',
|
||||
`import importlib.util, sys; sys.exit(0 if importlib.util.find_spec(${JSON.stringify(moduleName)}) else 1)`,
|
||||
], { stdio: 'ignore' })
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function removeBrokenDashboardAuthPlugin() {
|
||||
if (pythonModuleExists('hermes_cli.dashboard_auth')) return
|
||||
|
||||
const pluginDir = resolve(sitePackagesDir(), 'plugins', 'dashboard_auth', 'nous')
|
||||
if (!existsSync(pluginDir)) return
|
||||
|
||||
rmSync(pluginDir, { recursive: true, force: true })
|
||||
console.warn(
|
||||
'! Removed bundled dashboard_auth/nous plugin because hermes_cli.dashboard_auth is missing from the hermes-agent package',
|
||||
)
|
||||
}
|
||||
|
||||
function installBrowserRuntime() {
|
||||
if (SKIP_BROWSER_RUNTIME) {
|
||||
console.warn('! Skipping bundled browser runtime because HERMES_SKIP_BROWSER_RUNTIME is set')
|
||||
return
|
||||
}
|
||||
if (BROWSER_PACKAGES.length === 0) {
|
||||
console.warn('! Skipping bundled browser runtime because HERMES_BROWSER_PACKAGES is empty')
|
||||
return
|
||||
}
|
||||
|
||||
const npm = npmCommand()
|
||||
if (!npm) {
|
||||
console.error('npm not found; bundled browser runtime requires Node.js/npm')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`→ Installing browser runtime via npm prefix ${NODE_PREFIX}`)
|
||||
runInvocation(npm, [
|
||||
'install',
|
||||
'-g',
|
||||
'--prefix',
|
||||
NODE_PREFIX,
|
||||
'--silent',
|
||||
'--ignore-scripts',
|
||||
...BROWSER_PACKAGES,
|
||||
])
|
||||
|
||||
const ab = agentBrowserCommand()
|
||||
if (!existsSync(ab)) {
|
||||
console.error(`agent-browser binary not found at ${ab} after npm install`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`→ Installing Chromium for bundled agent-browser at ${AGENT_BROWSER_HOME}`)
|
||||
runInvocation(commandInvocation(ab), ['install'], { env: browserRuntimeEnv() })
|
||||
|
||||
const browserExecutable = ensureBundledBrowserExecutable()
|
||||
if (!browserExecutable) {
|
||||
console.error(`Bundled Chrome executable not found under ${AGENT_BROWSER_HOME} after agent-browser install`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`✓ bundled Chrome executable available at ${browserExecutable}`)
|
||||
}
|
||||
|
||||
installPythonPackages([HERMES_PACKAGE], 'hermes-agent')
|
||||
installPythonPackages(EXTRA_PYTHON_PACKAGES, 'extra Python runtime packages')
|
||||
removeBrokenDashboardAuthPlugin()
|
||||
installBrowserRuntime()
|
||||
|
||||
run(pyBin, [
|
||||
'-c',
|
||||
[
|
||||
'import importlib.util',
|
||||
'import mcp',
|
||||
'import tools.mcp_tool as t',
|
||||
'assert t._MCP_AVAILABLE',
|
||||
'assert importlib.util.find_spec("websockets") is not None',
|
||||
].join('; '),
|
||||
])
|
||||
|
||||
const hermesBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
||||
: resolve(PY_DIR, 'bin', 'hermes')
|
||||
const hermesCheckCommand = TARGET_OS === 'win32' ? pyBin : hermesBin
|
||||
const hermesCheckArgs = TARGET_OS === 'win32' ? ['-m', 'hermes_cli.main', '--version'] : ['--version']
|
||||
|
||||
if (!existsSync(hermesBin)) {
|
||||
console.error(`hermes binary not found at ${hermesBin} after install`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// hermes-web-ui's agent-bridge searches for `run_agent.py` at <python_root>/run_agent.py
|
||||
// (and a few neighbouring dirs). pip places it at site-packages/run_agent.py — surface
|
||||
// it at the venv root with a *relative* symlink so the venv stays portable when copied
|
||||
// into the packaged .app/.exe (an absolute symlink would break the moment the bundle
|
||||
// is moved to /Applications/...).
|
||||
function siteRunAgentRelative() {
|
||||
if (TARGET_OS === 'win32') {
|
||||
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
|
||||
}
|
||||
return `${sitePackagesDir().slice(PY_DIR.length + 1)}/run_agent.py`
|
||||
}
|
||||
{
|
||||
const relSrc = siteRunAgentRelative()
|
||||
const absSrc = resolve(PY_DIR, relSrc)
|
||||
const dst = resolve(PY_DIR, 'run_agent.py')
|
||||
if (existsSync(absSrc)) {
|
||||
try { lstatSync(dst); unlinkSync(dst) } catch {}
|
||||
if (TARGET_OS === 'win32') copyFileSync(absSrc, dst)
|
||||
else symlinkSync(relSrc, dst)
|
||||
console.log(`✓ run_agent.py linked at venv root (relative → ${relSrc})`)
|
||||
} else {
|
||||
console.warn(`! run_agent.py not found at ${absSrc} — agent-bridge may fail`)
|
||||
}
|
||||
}
|
||||
|
||||
// Relocate: replace the pip-generated launcher (which embeds an absolute
|
||||
// shebang to the build-time Python path) with a relative wrapper so the
|
||||
// bundled venv works after being moved into the .app/.exe payload.
|
||||
if (TARGET_OS === 'win32') {
|
||||
// Windows: pip generates a .exe launcher that embeds a relative shebang
|
||||
// already. Add a .cmd wrapper that prefers the colocated python.exe.
|
||||
const cmdPath = resolve(PY_DIR, 'Scripts', 'hermes.cmd')
|
||||
writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
'@echo off',
|
||||
'set "PY=%~dp0..\\python.exe"',
|
||||
'"%PY%" -m hermes_cli.main %*',
|
||||
].join('\r\n'),
|
||||
)
|
||||
} else {
|
||||
const launcher = [
|
||||
'#!/bin/sh',
|
||||
'DIR="$(cd "$(dirname "$0")" && pwd)"',
|
||||
'exec "$DIR/python3" -m hermes_cli.main "$@"',
|
||||
'',
|
||||
].join('\n')
|
||||
writeFileSync(hermesBin, launcher, { mode: 0o755 })
|
||||
chmodSync(hermesBin, 0o755)
|
||||
// Same for hermes-agent / hermes-acp (they all just dispatch into modules)
|
||||
for (const [name, mod] of [
|
||||
['hermes-agent', 'run_agent'],
|
||||
['hermes-acp', 'acp_adapter.entry'],
|
||||
]) {
|
||||
const p = resolve(PY_DIR, 'bin', name)
|
||||
if (existsSync(p)) {
|
||||
writeFileSync(p, launcher.replace('hermes_cli.main', mod), { mode: 0o755 })
|
||||
chmodSync(p, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
|
||||
|
||||
run(hermesCheckCommand, hermesCheckArgs)
|
||||
|
||||
if (!SKIP_BROWSER_RUNTIME) {
|
||||
run(pyBin, [
|
||||
'-c',
|
||||
[
|
||||
'import os, shutil',
|
||||
`os.environ["PLAYWRIGHT_BROWSERS_PATH"] = ${JSON.stringify(PLAYWRIGHT_BROWSERS_PATH)}`,
|
||||
'from tools.browser_tool import _chromium_installed',
|
||||
'assert shutil.which("agent-browser") is not None',
|
||||
'assert _chromium_installed()',
|
||||
].join('; '),
|
||||
], { env: browserRuntimeEnv() })
|
||||
}
|
||||
|
||||
if (SKIP_BROWSER_RUNTIME) {
|
||||
console.log('✓ hermes Python, MCP, and websockets checks passed; browser runtime skipped')
|
||||
} else {
|
||||
console.log('✓ hermes Python, MCP, websockets, agent-browser, and Chromium checks passed')
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
// Merge two per-arch `latest-mac.yml` manifests (arm64 + x64) into a single
|
||||
// manifest whose `files:` array lists BOTH dmgs, so electron-updater can pick
|
||||
// the right architecture.
|
||||
//
|
||||
// Why this exists: our Release workflow builds macOS arm64 and x64 in separate
|
||||
// matrix jobs, each emitting its own `latest-mac.yml`. When the publish job
|
||||
// flattens the artifacts they collide and only one arch survives — leaving the
|
||||
// other arch's users served a mismatched dmg (runs under Rosetta / fails the
|
||||
// updater signature check). Merging the `files` lists fixes that.
|
||||
//
|
||||
// Usage: node merge-mac-latest-yml.mjs <a.yml> <b.yml> > latest-mac.yml
|
||||
//
|
||||
// The manifest shape electron-builder emits is small and regular, so we parse
|
||||
// it with a focused extractor rather than pulling in a YAML dependency.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
function parse(path) {
|
||||
const text = readFileSync(path, 'utf-8')
|
||||
const version = (text.match(/^version:\s*(.+)$/m) || [])[1]?.trim()
|
||||
const releaseDate = (text.match(/^releaseDate:\s*(.+)$/m) || [])[1]?.trim()
|
||||
// Each entry under `files:` is `- url: ...` then indented sha512/size lines.
|
||||
const files = []
|
||||
const re = /- url:\s*(\S+)\s*\n\s*sha512:\s*(\S+)\s*\n\s*size:\s*(\d+)/g
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
files.push({ url: m[1], sha512: m[2], size: Number(m[3]) })
|
||||
}
|
||||
if (!version || files.length === 0) {
|
||||
throw new Error(`Could not parse manifest at ${path} (version=${version}, files=${files.length})`)
|
||||
}
|
||||
return { version, releaseDate, files }
|
||||
}
|
||||
|
||||
const [, , aPath, bPath] = process.argv
|
||||
if (!aPath || !bPath) {
|
||||
console.error('Usage: merge-mac-latest-yml.mjs <a.yml> <b.yml>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const a = parse(aPath)
|
||||
const b = parse(bPath)
|
||||
|
||||
if (a.version !== b.version) {
|
||||
console.error(`Version mismatch: ${aPath}=${a.version} vs ${bPath}=${b.version}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Dedupe by url, preserving order (a first, then b).
|
||||
const seen = new Set()
|
||||
const files = []
|
||||
for (const f of [...a.files, ...b.files]) {
|
||||
if (seen.has(f.url)) continue
|
||||
seen.add(f.url)
|
||||
files.push(f)
|
||||
}
|
||||
|
||||
// Top-level path/sha512/size are the legacy single-file fields; point them at
|
||||
// the first entry (arm64 when arm64 is passed first). electron-updater >=6
|
||||
// selects from `files` by arch; these remain as a fallback for old clients.
|
||||
const head = files[0]
|
||||
const releaseDate = a.releaseDate || b.releaseDate
|
||||
|
||||
const lines = [`version: ${a.version}`, 'files:']
|
||||
for (const f of files) {
|
||||
lines.push(` - url: ${f.url}`)
|
||||
lines.push(` sha512: ${f.sha512}`)
|
||||
lines.push(` size: ${f.size}`)
|
||||
}
|
||||
lines.push(`path: ${head.url}`)
|
||||
lines.push(`sha512: ${head.sha512}`)
|
||||
if (releaseDate) lines.push(`releaseDate: ${releaseDate}`)
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
// Package prepared Python/Node/Git runtime resources into a release asset.
|
||||
import {
|
||||
cpSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PLATFORM = `${OS_LABEL}-${TARGET_ARCH}`
|
||||
const OUT_DIR = resolve(ROOT, 'release', 'runtime')
|
||||
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', PLATFORM)
|
||||
const NODE_DIR = resolve(ROOT, 'resources', 'node', PLATFORM)
|
||||
const GIT_DIR = resolve(ROOT, 'resources', 'git', PLATFORM)
|
||||
const pyBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'python.exe')
|
||||
: resolve(PY_DIR, 'bin', 'python3')
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, { stdio: 'inherit', ...options })
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
return result
|
||||
}
|
||||
|
||||
function output(command, args) {
|
||||
const result = spawnSync(command, args, { encoding: 'utf-8' })
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(result.stderr || result.stdout || '')
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
async function sha256File(file) {
|
||||
const hash = createHash('sha256')
|
||||
await new Promise((resolvePromise, rejectPromise) => {
|
||||
const stream = createReadStream(file)
|
||||
stream.on('data', chunk => hash.update(chunk))
|
||||
stream.on('end', resolvePromise)
|
||||
stream.on('error', rejectPromise)
|
||||
})
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
for (const dir of [PY_DIR, NODE_DIR]) {
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`Runtime directory missing: ${dir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const hermesAgentVersion = output(pyBin, [
|
||||
'-c',
|
||||
'import importlib.metadata as m; print(m.version("hermes-agent"))',
|
||||
])
|
||||
const assetName = `hermes-runtime-hermes-agent-${hermesAgentVersion}-${PLATFORM}.tar.gz`
|
||||
const manifestName = `hermes-runtime-${PLATFORM}.json`
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
const stage = mkdtempSync(join(tmpdir(), `hermes-runtime-${PLATFORM}-`))
|
||||
|
||||
try {
|
||||
cpSync(PY_DIR, join(stage, 'python'), { recursive: true, force: true, verbatimSymlinks: true })
|
||||
cpSync(NODE_DIR, join(stage, 'node'), { recursive: true, force: true, verbatimSymlinks: true })
|
||||
if (existsSync(GIT_DIR)) {
|
||||
cpSync(GIT_DIR, join(stage, 'git'), { recursive: true, force: true, verbatimSymlinks: true })
|
||||
} else {
|
||||
mkdirSync(join(stage, 'git'), { recursive: true })
|
||||
writeFileSync(join(stage, 'git', '.placeholder'), 'Git for Windows is only bundled on Windows.\n')
|
||||
}
|
||||
|
||||
const runtimeManifest = {
|
||||
schema: 1,
|
||||
platform: PLATFORM,
|
||||
targetOs: TARGET_OS,
|
||||
targetArch: TARGET_ARCH,
|
||||
hermesAgentVersion,
|
||||
asset: {
|
||||
name: assetName,
|
||||
},
|
||||
}
|
||||
writeFileSync(join(stage, 'runtime-manifest.json'), JSON.stringify(runtimeManifest, null, 2) + '\n')
|
||||
|
||||
const assetPath = resolve(OUT_DIR, assetName)
|
||||
rmSync(assetPath, { force: true })
|
||||
run('tar', ['-czf', assetPath, '-C', stage, '.'])
|
||||
|
||||
const sha256 = await sha256File(assetPath)
|
||||
writeFileSync(`${assetPath}.sha256`, `${sha256} ${assetName}\n`)
|
||||
|
||||
const platformManifest = {
|
||||
...runtimeManifest,
|
||||
createdAt: new Date().toISOString(),
|
||||
asset: {
|
||||
name: assetName,
|
||||
sha256,
|
||||
size: statSync(assetPath).size,
|
||||
},
|
||||
}
|
||||
writeFileSync(resolve(OUT_DIR, manifestName), JSON.stringify(platformManifest, null, 2) + '\n')
|
||||
|
||||
console.log(`Runtime asset: ${assetPath}`)
|
||||
console.log(`Runtime manifest: ${resolve(OUT_DIR, manifestName)}`)
|
||||
} finally {
|
||||
rmSync(stage, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
// Strip __pycache__, *.pyc, tests, idle, tkinter from bundled Python to shrink the installer.
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readdirSync, statSync, rmSync, existsSync } from 'node:fs'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
if (!existsSync(PY_DIR)) {
|
||||
console.error(`No bundled python at ${PY_DIR}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PRUNE_DIR_NAMES = new Set(['__pycache__', 'test', 'tests', 'idle_test', 'idlelib', 'turtledemo', 'tkinter', 'ensurepip'])
|
||||
const PRUNE_FILE_SUFFIXES = ['.pyc', '.pyo']
|
||||
|
||||
let bytesFreed = 0
|
||||
function walk(dir) {
|
||||
let entries
|
||||
try { entries = readdirSync(dir) } catch { return }
|
||||
for (const name of entries) {
|
||||
const p = join(dir, name)
|
||||
let st
|
||||
try { st = statSync(p) } catch { continue }
|
||||
if (st.isDirectory()) {
|
||||
if (PRUNE_DIR_NAMES.has(name)) {
|
||||
bytesFreed += dirSize(p)
|
||||
rmSync(p, { recursive: true, force: true })
|
||||
} else {
|
||||
walk(p)
|
||||
}
|
||||
} else if (PRUNE_FILE_SUFFIXES.some(s => name.endsWith(s))) {
|
||||
bytesFreed += st.size
|
||||
rmSync(p, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
function dirSize(dir) {
|
||||
let total = 0
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
const st = statSync(p)
|
||||
total += st.isDirectory() ? dirSize(p) : st.size
|
||||
}
|
||||
} catch {}
|
||||
return total
|
||||
}
|
||||
|
||||
walk(PY_DIR)
|
||||
console.log(`✓ Pruned ~${(bytesFreed / 1024 / 1024).toFixed(1)} MB from ${PY_DIR}`)
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
import { arch as osArch, platform as osPlatform } from 'node:os'
|
||||
import { hermesVersion, runtimeReleaseTag } from './runtime-config.mjs'
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const HERMES_VERSION = hermesVersion()
|
||||
const RUNTIME_RELEASE_TAG = runtimeReleaseTag()
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
|
||||
if (!['win', 'mac', 'linux'].includes(OS_LABEL) || !['x64', 'arm64'].includes(TARGET_ARCH)) {
|
||||
console.error(`Unsupported runtime target: ${TARGET_OS}-${TARGET_ARCH}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const platform = `${OS_LABEL}-${TARGET_ARCH}`
|
||||
const asset = `hermes-runtime-hermes-agent-${HERMES_VERSION}-${platform}.tar.gz`
|
||||
const manifest = `hermes-runtime-${platform}.json`
|
||||
|
||||
if (process.argv.includes('--manifest')) {
|
||||
console.log(manifest)
|
||||
} else if (process.argv.includes('--platform')) {
|
||||
console.log(platform)
|
||||
} else if (process.argv.includes('--release-tag')) {
|
||||
console.log(RUNTIME_RELEASE_TAG)
|
||||
} else {
|
||||
console.log(asset)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const DEFAULT_HERMES_VERSION = '0.15.2'
|
||||
|
||||
export function hermesVersion(env = process.env) {
|
||||
return env.HERMES_VERSION || DEFAULT_HERMES_VERSION
|
||||
}
|
||||
|
||||
export function runtimeReleaseTag(env = process.env) {
|
||||
const version = hermesVersion(env)
|
||||
return env.HERMES_DESKTOP_RUNTIME_RELEASE_TAG
|
||||
|| env.RUNTIME_RELEASE_TAG
|
||||
|| `hermes-${version}-runtime`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { runtimeReleaseTag } from './runtime-config.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
const outFile = resolve(ROOT, 'build', 'runtime-release.json')
|
||||
const tag = runtimeReleaseTag()
|
||||
|
||||
mkdirSync(dirname(outFile), { recursive: true })
|
||||
writeFileSync(outFile, JSON.stringify({ tag }, null, 2) + '\n')
|
||||
console.log(`Runtime release metadata: ${tag}`)
|
||||
@@ -0,0 +1 @@
|
||||
export const HERMES_CLI_ARG = '--hermes-cli'
|
||||
@@ -0,0 +1,232 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { delimiter, dirname, join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { HERMES_CLI_ARG } from './cli-constants'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const SHIM_MARKER = 'HERMES_STUDIO_CLI_SHIM'
|
||||
const PATH_MARKER_START = '# >>> Hermes Studio CLI shim >>>'
|
||||
const PATH_MARKER_END = '# <<< Hermes Studio CLI shim <<<'
|
||||
|
||||
type ShimInstallStatus = 'installed' | 'updated' | 'unchanged' | 'skipped'
|
||||
|
||||
export interface CliShimInstallResult {
|
||||
shimPath: string
|
||||
status: ShimInstallStatus
|
||||
pathUpdated: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface CliShimInstallOptions {
|
||||
env?: NodeJS.ProcessEnv
|
||||
executablePath?: string
|
||||
homeDir?: string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
function platformDelimiter(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? ';' : delimiter
|
||||
}
|
||||
|
||||
function pathKey(value: string, platform: NodeJS.Platform): string {
|
||||
const normalized = resolve(value)
|
||||
return platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
export function pathContainsDir(pathValue: string | undefined, binDir: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
if (!pathValue) return false
|
||||
const target = pathKey(binDir, platform)
|
||||
return pathValue
|
||||
.split(platformDelimiter(platform))
|
||||
.map(entry => entry.trim())
|
||||
.filter(Boolean)
|
||||
.some(entry => pathKey(entry, platform) === target)
|
||||
}
|
||||
|
||||
function executableForShim(options: Required<Pick<CliShimInstallOptions, 'env' | 'executablePath' | 'platform'>>): string {
|
||||
const appImage = options.platform === 'linux' ? options.env.APPIMAGE?.trim() : ''
|
||||
return appImage || options.executablePath
|
||||
}
|
||||
|
||||
export function shimPathForPlatform(binDir: string, platform: NodeJS.Platform = process.platform): string {
|
||||
return join(binDir, platform === 'win32' ? 'hermes-studio.cmd' : 'hermes-studio')
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
export function createShimContent(executablePath: string, platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
`rem ${SHIM_MARKER}`,
|
||||
`set "APP=${executablePath}"`,
|
||||
'if not exist "%APP%" (',
|
||||
' echo Hermes Studio executable not found at "%APP%" 1>&2',
|
||||
' exit /b 127',
|
||||
')',
|
||||
'set ELECTRON_RUN_AS_NODE=',
|
||||
`"${'%APP%'}" -- ${HERMES_CLI_ARG} %*`,
|
||||
'exit /b %ERRORLEVEL%',
|
||||
'',
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
`# ${SHIM_MARKER}`,
|
||||
`APP=${shellQuote(executablePath)}`,
|
||||
'if [ ! -x "$APP" ]; then',
|
||||
' echo "Hermes Studio executable not found at $APP" >&2',
|
||||
' exit 127',
|
||||
'fi',
|
||||
'unset ELECTRON_RUN_AS_NODE',
|
||||
`exec "$APP" -- ${HERMES_CLI_ARG} "$@"`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function isManagedShim(content: string): boolean {
|
||||
return content.includes(SHIM_MARKER) && content.includes(HERMES_CLI_ARG)
|
||||
}
|
||||
|
||||
function writeShim(shimPath: string, content: string, platform: NodeJS.Platform): ShimInstallStatus {
|
||||
if (existsSync(shimPath)) {
|
||||
const existing = readFileSync(shimPath, 'utf-8')
|
||||
if (existing === content) return 'unchanged'
|
||||
if (!isManagedShim(existing)) return 'skipped'
|
||||
writeFileSync(shimPath, content, 'utf-8')
|
||||
if (platform !== 'win32') chmodSync(shimPath, 0o755)
|
||||
return 'updated'
|
||||
}
|
||||
|
||||
writeFileSync(shimPath, content, { encoding: 'utf-8', mode: platform === 'win32' ? 0o644 : 0o755 })
|
||||
if (platform !== 'win32') chmodSync(shimPath, 0o755)
|
||||
return 'installed'
|
||||
}
|
||||
|
||||
function shellProfilePaths(homeDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string[] {
|
||||
if (platform === 'win32') return []
|
||||
|
||||
const shell = env.SHELL?.trim() || ''
|
||||
const name = shell.split('/').pop() || ''
|
||||
if (name === 'fish') return [join(homeDir, '.config', 'fish', 'conf.d', 'hermes-studio.fish')]
|
||||
if (name === 'bash') return [join(homeDir, '.bash_profile'), join(homeDir, '.bashrc')]
|
||||
if (name === 'zsh' || platform === 'darwin') return [join(homeDir, '.zprofile'), join(homeDir, '.zshrc')]
|
||||
return [join(homeDir, '.profile')]
|
||||
}
|
||||
|
||||
function profileMentionsUserBin(content: string, homeDir: string): boolean {
|
||||
return content.includes('$HOME/bin')
|
||||
|| content.includes('~/bin')
|
||||
|| content.includes(resolve(homeDir, 'bin'))
|
||||
}
|
||||
|
||||
function shellPathSnippet(platform: NodeJS.Platform, profilePath: string): string {
|
||||
if (platform !== 'win32' && profilePath.endsWith('.fish')) {
|
||||
return [
|
||||
'',
|
||||
PATH_MARKER_START,
|
||||
'fish_add_path -m "$HOME/bin"',
|
||||
PATH_MARKER_END,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'',
|
||||
PATH_MARKER_START,
|
||||
'case ":$PATH:" in',
|
||||
' *":$HOME/bin:"*) ;;',
|
||||
' *) export PATH="$HOME/bin:$PATH" ;;',
|
||||
'esac',
|
||||
PATH_MARKER_END,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function ensureWindowsUserPath(binDir: string): Promise<boolean> {
|
||||
let currentPath = ''
|
||||
try {
|
||||
const { stdout } = await execFileAsync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'Path'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1500,
|
||||
windowsHide: true,
|
||||
})
|
||||
const line = stdout.split(/\r?\n/).find(row => /^\s*Path\s+REG_/.test(row))
|
||||
if (line) currentPath = line.replace(/^\s*Path\s+REG_\w+\s+/, '').trim()
|
||||
} catch {
|
||||
currentPath = process.env.Path || process.env.PATH || ''
|
||||
}
|
||||
|
||||
if (pathContainsDir(currentPath, binDir, 'win32')) return false
|
||||
|
||||
const separator = currentPath ? ';' : ''
|
||||
await execFileAsync('reg.exe', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', `${binDir}${separator}${currentPath}`, '/f'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1500,
|
||||
windowsHide: true,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function ensureUnixShellPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
|
||||
if (pathContainsDir(env.PATH, binDir, platform)) return false
|
||||
|
||||
let updated = false
|
||||
for (const profilePath of shellProfilePaths(homeDir, platform, env)) {
|
||||
const existing = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : ''
|
||||
if (existing.includes(PATH_MARKER_START) || profileMentionsUserBin(existing, homeDir)) continue
|
||||
|
||||
mkdirSync(dirname(profilePath), { recursive: true })
|
||||
appendFileSync(profilePath, shellPathSnippet(platform, profilePath), 'utf-8')
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
async function ensureUserBinOnPath(homeDir: string, binDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv): Promise<boolean> {
|
||||
if (platform === 'win32') {
|
||||
return await ensureWindowsUserPath(binDir)
|
||||
}
|
||||
return ensureUnixShellPath(homeDir, binDir, platform, env)
|
||||
}
|
||||
|
||||
export async function installHermesStudioCliShim(options: CliShimInstallOptions = {}): Promise<CliShimInstallResult> {
|
||||
const platform = options.platform || process.platform
|
||||
const env = options.env || process.env
|
||||
const homeDir = options.homeDir || homedir()
|
||||
const binDir = resolve(homeDir, 'bin')
|
||||
const executablePath = executableForShim({
|
||||
env,
|
||||
executablePath: options.executablePath || process.execPath,
|
||||
platform,
|
||||
})
|
||||
const shimPath = shimPathForPlatform(binDir, platform)
|
||||
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
const status = writeShim(shimPath, createShimContent(executablePath, platform), platform)
|
||||
const pathUpdated = await ensureUserBinOnPath(homeDir, binDir, platform, env).catch((err) => {
|
||||
console.warn(`[cli-shim] failed to update PATH: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return false
|
||||
})
|
||||
|
||||
return {
|
||||
shimPath,
|
||||
status,
|
||||
pathUpdated,
|
||||
reason: status === 'skipped' ? 'existing hermes-studio shim is not managed by Hermes Studio' : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
type DesktopLocale = 'en' | 'zh' | 'zh-TW' | 'ja' | 'ko' | 'fr' | 'es' | 'de' | 'pt'
|
||||
|
||||
type TranslationKey =
|
||||
| 'tray.show'
|
||||
| 'tray.hide'
|
||||
| 'tray.checkForUpdates'
|
||||
| 'tray.openAtLogin'
|
||||
| 'tray.quit'
|
||||
| 'update.upToDateTitle'
|
||||
| 'update.upToDateMessage'
|
||||
| 'update.checkingTitle'
|
||||
| 'update.checkingMessage'
|
||||
| 'update.currentVersion'
|
||||
| 'update.availableTitle'
|
||||
| 'update.availableMessage'
|
||||
| 'update.downloading'
|
||||
| 'update.readyTitle'
|
||||
| 'update.readyMessage'
|
||||
| 'update.readyDetail'
|
||||
| 'update.restartNow'
|
||||
| 'update.download'
|
||||
| 'update.later'
|
||||
| 'update.failedTitle'
|
||||
| 'update.failedMessage'
|
||||
| 'update.noUpdateInfoMessage'
|
||||
| 'update.packagedOnlyMessage'
|
||||
| 'common.ok'
|
||||
|
||||
const supportedLocales: DesktopLocale[] = ['en', 'zh', 'zh-TW', 'ja', 'ko', 'fr', 'es', 'de', 'pt']
|
||||
|
||||
const translations: Record<DesktopLocale, Record<TranslationKey, string>> = {
|
||||
en: {
|
||||
'tray.show': 'Show Hermes Studio',
|
||||
'tray.hide': 'Hide Hermes Studio',
|
||||
'tray.checkForUpdates': 'Check for Updates',
|
||||
'tray.openAtLogin': 'Open at Login',
|
||||
'tray.quit': 'Quit Hermes Studio',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio is up to date.',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': 'Checking for updates...',
|
||||
'update.currentVersion': 'Current version: {version}',
|
||||
'update.availableTitle': 'Update available',
|
||||
'update.availableMessage': 'Hermes Studio {version} is available.',
|
||||
'update.downloading': 'The update is downloading in the background.',
|
||||
'update.readyTitle': 'Update ready',
|
||||
'update.readyMessage': 'Hermes Studio {version} is ready to install.',
|
||||
'update.readyDetail': 'Restart now to apply the update, or it will be installed on next quit.',
|
||||
'update.restartNow': 'Restart now',
|
||||
'update.download': 'Download',
|
||||
'update.later': 'Later',
|
||||
'update.failedTitle': 'Update check failed',
|
||||
'update.failedMessage': 'Could not check for Hermes Studio updates.',
|
||||
'update.noUpdateInfoMessage': 'Update information is not available for this platform yet.',
|
||||
'update.packagedOnlyMessage': 'Automatic updates are only available in the packaged desktop app.',
|
||||
'common.ok': 'OK',
|
||||
},
|
||||
zh: {
|
||||
'tray.show': '显示 Hermes Studio',
|
||||
'tray.hide': '隐藏 Hermes Studio',
|
||||
'tray.checkForUpdates': '检查更新',
|
||||
'tray.openAtLogin': '开机启动',
|
||||
'tray.quit': '退出 Hermes Studio',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio 已是最新版本。',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': '正在检查更新...',
|
||||
'update.currentVersion': '当前版本:{version}',
|
||||
'update.availableTitle': '发现新版本',
|
||||
'update.availableMessage': 'Hermes Studio {version} 可用。',
|
||||
'update.downloading': '更新正在后台下载。',
|
||||
'update.readyTitle': '更新已就绪',
|
||||
'update.readyMessage': 'Hermes Studio {version} 已准备好安装。',
|
||||
'update.readyDetail': '立即重启以应用更新,或下次退出时自动安装。',
|
||||
'update.restartNow': '立即重启',
|
||||
'update.download': '下载',
|
||||
'update.later': '稍后',
|
||||
'update.failedTitle': '检查更新失败',
|
||||
'update.failedMessage': '无法检查 Hermes Studio 更新。',
|
||||
'update.noUpdateInfoMessage': '当前平台的更新信息暂不可用。',
|
||||
'update.packagedOnlyMessage': '自动更新仅在打包后的桌面应用中可用。',
|
||||
'common.ok': '确定',
|
||||
},
|
||||
'zh-TW': {
|
||||
'tray.show': '顯示 Hermes Studio',
|
||||
'tray.hide': '隱藏 Hermes Studio',
|
||||
'tray.checkForUpdates': '檢查更新',
|
||||
'tray.openAtLogin': '開機啟動',
|
||||
'tray.quit': '結束 Hermes Studio',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio 已是最新版本。',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': '正在檢查更新...',
|
||||
'update.currentVersion': '目前版本:{version}',
|
||||
'update.availableTitle': '發現新版本',
|
||||
'update.availableMessage': 'Hermes Studio {version} 可用。',
|
||||
'update.downloading': '更新正在背景下載。',
|
||||
'update.readyTitle': '更新已就緒',
|
||||
'update.readyMessage': 'Hermes Studio {version} 已準備好安裝。',
|
||||
'update.readyDetail': '立即重新啟動以套用更新,或下次結束時自動安裝。',
|
||||
'update.restartNow': '立即重新啟動',
|
||||
'update.download': '下載',
|
||||
'update.later': '稍後',
|
||||
'update.failedTitle': '檢查更新失敗',
|
||||
'update.failedMessage': '無法檢查 Hermes Studio 更新。',
|
||||
'update.noUpdateInfoMessage': '目前平台的更新資訊暫不可用。',
|
||||
'update.packagedOnlyMessage': '自動更新僅可在打包後的桌面應用中使用。',
|
||||
'common.ok': '確定',
|
||||
},
|
||||
ja: {
|
||||
'tray.show': 'Hermes Studio を表示',
|
||||
'tray.hide': 'Hermes Studio を隠す',
|
||||
'tray.checkForUpdates': 'アップデートを確認',
|
||||
'tray.openAtLogin': 'ログイン時に開く',
|
||||
'tray.quit': 'Hermes Studio を終了',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio は最新です。',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': 'アップデートを確認しています...',
|
||||
'update.currentVersion': '現在のバージョン: {version}',
|
||||
'update.availableTitle': 'アップデートがあります',
|
||||
'update.availableMessage': 'Hermes Studio {version} が利用できます。',
|
||||
'update.downloading': 'アップデートをバックグラウンドでダウンロードしています。',
|
||||
'update.readyTitle': 'アップデートの準備ができました',
|
||||
'update.readyMessage': 'Hermes Studio {version} をインストールできます。',
|
||||
'update.readyDetail': '今すぐ再起動して適用するか、次回終了時にインストールされます。',
|
||||
'update.restartNow': '今すぐ再起動',
|
||||
'update.download': 'ダウンロード',
|
||||
'update.later': '後で',
|
||||
'update.failedTitle': 'アップデート確認に失敗しました',
|
||||
'update.failedMessage': 'Hermes Studio のアップデートを確認できませんでした。',
|
||||
'update.noUpdateInfoMessage': 'このプラットフォームのアップデート情報はまだ利用できません。',
|
||||
'update.packagedOnlyMessage': '自動アップデートはパッケージ版デスクトップアプリでのみ利用できます。',
|
||||
'common.ok': 'OK',
|
||||
},
|
||||
ko: {
|
||||
'tray.show': 'Hermes Studio 표시',
|
||||
'tray.hide': 'Hermes Studio 숨기기',
|
||||
'tray.checkForUpdates': '업데이트 확인',
|
||||
'tray.openAtLogin': '로그인 시 열기',
|
||||
'tray.quit': 'Hermes Studio 종료',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio가 최신 버전입니다.',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': '업데이트를 확인하는 중...',
|
||||
'update.currentVersion': '현재 버전: {version}',
|
||||
'update.availableTitle': '업데이트 사용 가능',
|
||||
'update.availableMessage': 'Hermes Studio {version}을 사용할 수 있습니다.',
|
||||
'update.downloading': '업데이트를 백그라운드에서 다운로드하고 있습니다.',
|
||||
'update.readyTitle': '업데이트 준비 완료',
|
||||
'update.readyMessage': 'Hermes Studio {version}을 설치할 준비가 되었습니다.',
|
||||
'update.readyDetail': '지금 다시 시작해 업데이트를 적용하거나 다음 종료 시 설치합니다.',
|
||||
'update.restartNow': '지금 다시 시작',
|
||||
'update.download': '다운로드',
|
||||
'update.later': '나중에',
|
||||
'update.failedTitle': '업데이트 확인 실패',
|
||||
'update.failedMessage': 'Hermes Studio 업데이트를 확인할 수 없습니다.',
|
||||
'update.noUpdateInfoMessage': '이 플랫폼의 업데이트 정보를 아직 사용할 수 없습니다.',
|
||||
'update.packagedOnlyMessage': '자동 업데이트는 패키징된 데스크톱 앱에서만 사용할 수 있습니다.',
|
||||
'common.ok': '확인',
|
||||
},
|
||||
fr: {
|
||||
'tray.show': 'Afficher Hermes Studio',
|
||||
'tray.hide': 'Masquer Hermes Studio',
|
||||
'tray.checkForUpdates': 'Rechercher les mises a jour',
|
||||
'tray.openAtLogin': 'Ouvrir a la connexion',
|
||||
'tray.quit': 'Quitter Hermes Studio',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio est a jour.',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': 'Recherche de mises a jour...',
|
||||
'update.currentVersion': 'Version actuelle : {version}',
|
||||
'update.availableTitle': 'Mise a jour disponible',
|
||||
'update.availableMessage': 'Hermes Studio {version} est disponible.',
|
||||
'update.downloading': 'La mise a jour se telecharge en arriere-plan.',
|
||||
'update.readyTitle': 'Mise a jour prete',
|
||||
'update.readyMessage': 'Hermes Studio {version} est pret a etre installe.',
|
||||
'update.readyDetail': 'Redemarrez maintenant pour appliquer la mise a jour, ou elle sera installee a la prochaine fermeture.',
|
||||
'update.restartNow': 'Redemarrer maintenant',
|
||||
'update.download': 'Telecharger',
|
||||
'update.later': 'Plus tard',
|
||||
'update.failedTitle': 'Echec de la recherche de mise a jour',
|
||||
'update.failedMessage': 'Impossible de rechercher les mises a jour de Hermes Studio.',
|
||||
'update.noUpdateInfoMessage': 'Les informations de mise a jour ne sont pas encore disponibles pour cette plateforme.',
|
||||
'update.packagedOnlyMessage': 'Les mises a jour automatiques ne sont disponibles que dans l application de bureau packagee.',
|
||||
'common.ok': 'OK',
|
||||
},
|
||||
es: {
|
||||
'tray.show': 'Mostrar Hermes Studio',
|
||||
'tray.hide': 'Ocultar Hermes Studio',
|
||||
'tray.checkForUpdates': 'Buscar actualizaciones',
|
||||
'tray.openAtLogin': 'Abrir al iniciar sesion',
|
||||
'tray.quit': 'Salir de Hermes Studio',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio esta actualizado.',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': 'Buscando actualizaciones...',
|
||||
'update.currentVersion': 'Version actual: {version}',
|
||||
'update.availableTitle': 'Actualizacion disponible',
|
||||
'update.availableMessage': 'Hermes Studio {version} esta disponible.',
|
||||
'update.downloading': 'La actualizacion se esta descargando en segundo plano.',
|
||||
'update.readyTitle': 'Actualizacion lista',
|
||||
'update.readyMessage': 'Hermes Studio {version} esta listo para instalarse.',
|
||||
'update.readyDetail': 'Reinicia ahora para aplicar la actualizacion, o se instalara al salir.',
|
||||
'update.restartNow': 'Reiniciar ahora',
|
||||
'update.download': 'Descargar',
|
||||
'update.later': 'Mas tarde',
|
||||
'update.failedTitle': 'Error al buscar actualizaciones',
|
||||
'update.failedMessage': 'No se pudieron buscar actualizaciones de Hermes Studio.',
|
||||
'update.noUpdateInfoMessage': 'La informacion de actualizacion aun no esta disponible para esta plataforma.',
|
||||
'update.packagedOnlyMessage': 'Las actualizaciones automaticas solo estan disponibles en la app de escritorio empaquetada.',
|
||||
'common.ok': 'Aceptar',
|
||||
},
|
||||
de: {
|
||||
'tray.show': 'Hermes Studio anzeigen',
|
||||
'tray.hide': 'Hermes Studio ausblenden',
|
||||
'tray.checkForUpdates': 'Nach Updates suchen',
|
||||
'tray.openAtLogin': 'Beim Anmelden offnen',
|
||||
'tray.quit': 'Hermes Studio beenden',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio ist auf dem neuesten Stand.',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': 'Suche nach Updates...',
|
||||
'update.currentVersion': 'Aktuelle Version: {version}',
|
||||
'update.availableTitle': 'Update verfugbar',
|
||||
'update.availableMessage': 'Hermes Studio {version} ist verfugbar.',
|
||||
'update.downloading': 'Das Update wird im Hintergrund heruntergeladen.',
|
||||
'update.readyTitle': 'Update bereit',
|
||||
'update.readyMessage': 'Hermes Studio {version} ist zur Installation bereit.',
|
||||
'update.readyDetail': 'Jetzt neu starten, um das Update anzuwenden, oder es wird beim nachsten Beenden installiert.',
|
||||
'update.restartNow': 'Jetzt neu starten',
|
||||
'update.download': 'Herunterladen',
|
||||
'update.later': 'Spater',
|
||||
'update.failedTitle': 'Update-Prufung fehlgeschlagen',
|
||||
'update.failedMessage': 'Updates fur Hermes Studio konnten nicht gepruft werden.',
|
||||
'update.noUpdateInfoMessage': 'Update-Informationen sind fur diese Plattform noch nicht verfugbar.',
|
||||
'update.packagedOnlyMessage': 'Automatische Updates sind nur in der paketierten Desktop-App verfugbar.',
|
||||
'common.ok': 'OK',
|
||||
},
|
||||
pt: {
|
||||
'tray.show': 'Mostrar Hermes Studio',
|
||||
'tray.hide': 'Ocultar Hermes Studio',
|
||||
'tray.checkForUpdates': 'Verificar atualizacoes',
|
||||
'tray.openAtLogin': 'Abrir ao iniciar sessao',
|
||||
'tray.quit': 'Sair do Hermes Studio',
|
||||
'update.upToDateTitle': 'Hermes Studio',
|
||||
'update.upToDateMessage': 'Hermes Studio esta atualizado.',
|
||||
'update.checkingTitle': 'Hermes Studio',
|
||||
'update.checkingMessage': 'Verificando atualizacoes...',
|
||||
'update.currentVersion': 'Versao atual: {version}',
|
||||
'update.availableTitle': 'Atualizacao disponivel',
|
||||
'update.availableMessage': 'Hermes Studio {version} esta disponivel.',
|
||||
'update.downloading': 'A atualizacao esta sendo baixada em segundo plano.',
|
||||
'update.readyTitle': 'Atualizacao pronta',
|
||||
'update.readyMessage': 'Hermes Studio {version} esta pronto para instalar.',
|
||||
'update.readyDetail': 'Reinicie agora para aplicar a atualizacao, ou ela sera instalada ao sair.',
|
||||
'update.restartNow': 'Reiniciar agora',
|
||||
'update.download': 'Baixar',
|
||||
'update.later': 'Depois',
|
||||
'update.failedTitle': 'Falha ao verificar atualizacoes',
|
||||
'update.failedMessage': 'Nao foi possivel verificar atualizacoes do Hermes Studio.',
|
||||
'update.noUpdateInfoMessage': 'As informacoes de atualizacao ainda nao estao disponiveis para esta plataforma.',
|
||||
'update.packagedOnlyMessage': 'Atualizacoes automaticas estao disponiveis apenas no app desktop empacotado.',
|
||||
'common.ok': 'OK',
|
||||
},
|
||||
}
|
||||
|
||||
function resolveLocale(): DesktopLocale {
|
||||
const tag = app.getLocale()
|
||||
const lower = tag.toLowerCase()
|
||||
if (lower.startsWith('zh')) {
|
||||
return lower.includes('hant') || lower.includes('-tw') || lower.includes('-hk') || lower.includes('-mo')
|
||||
? 'zh-TW'
|
||||
: 'zh'
|
||||
}
|
||||
|
||||
const short = tag.slice(0, 2) as DesktopLocale
|
||||
return supportedLocales.includes(short) ? short : 'en'
|
||||
}
|
||||
|
||||
export function t(key: TranslationKey, params: Record<string, string> = {}): string {
|
||||
const message = translations[resolveLocale()][key] || translations.en[key]
|
||||
return Object.entries(params).reduce(
|
||||
(value, [name, replacement]) => value.replaceAll(`{${name}}`, replacement),
|
||||
message,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
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)
|
||||
if (index < 0) return null
|
||||
return argv.slice(index + 1)
|
||||
}
|
||||
|
||||
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:runtime (to build a local Hermes runtime)')
|
||||
return 127
|
||||
}
|
||||
|
||||
mkdirSync(webUiHome(), { recursive: true })
|
||||
mkdirSync(hermesHome(), { recursive: true })
|
||||
|
||||
const binDir = dirname(command)
|
||||
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',
|
||||
HERMES_BIN: command,
|
||||
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(),
|
||||
PATH: pathValue,
|
||||
}
|
||||
|
||||
return await new Promise(resolve => {
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
windowsHide: false,
|
||||
})
|
||||
child.once('error', (err) => {
|
||||
console.error(`Failed to run bundled Hermes CLI: ${err.message}`)
|
||||
resolve(1)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
if (typeof code === 'number') {
|
||||
resolve(code)
|
||||
return
|
||||
}
|
||||
console.error(`Bundled Hermes CLI exited from signal ${signal || 'unknown'}`)
|
||||
resolve(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { app, BrowserWindow, Menu, Tray, shell, ipcMain, nativeImage } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
|
||||
import { desktopIcon, desktopTrayTemplateIcon, desktopWindowsTrayIcon, hermesBinExists, hermesBin } from './paths'
|
||||
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')
|
||||
const QUIT_EXISTING = process.argv.includes('--quit')
|
||||
|
||||
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) {
|
||||
createWindow()
|
||||
}
|
||||
if (!mainWindow) return
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function quitApp() {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
}
|
||||
|
||||
function loginItemOptions() {
|
||||
return {
|
||||
path: process.execPath,
|
||||
args: ['--hidden'],
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAtLogin(): boolean {
|
||||
return app.getLoginItemSettings(loginItemOptions()).openAtLogin
|
||||
}
|
||||
|
||||
function setOpenAtLogin(openAtLogin: boolean) {
|
||||
app.setLoginItemSettings({
|
||||
...loginItemOptions(),
|
||||
openAtLogin,
|
||||
openAsHidden: true,
|
||||
})
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return
|
||||
const isVisible = !!mainWindow && mainWindow.isVisible()
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: isVisible ? t('tray.hide') : t('tray.show'),
|
||||
click: () => {
|
||||
if (mainWindow?.isVisible()) {
|
||||
mainWindow.hide()
|
||||
} else {
|
||||
showMainWindow()
|
||||
}
|
||||
updateTrayMenu()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('tray.checkForUpdates'),
|
||||
click: () => {
|
||||
checkForDesktopUpdates(true).catch(err => {
|
||||
console.error('[tray] update check failed:', err)
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('tray.openAtLogin'),
|
||||
type: 'checkbox',
|
||||
checked: getOpenAtLogin(),
|
||||
click: (item) => {
|
||||
setOpenAtLogin(item.checked)
|
||||
updateTrayMenu()
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('tray.quit'),
|
||||
click: quitApp,
|
||||
},
|
||||
])
|
||||
tray.setContextMenu(menu)
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
if (tray) return
|
||||
const source = process.platform === 'darwin'
|
||||
? desktopTrayTemplateIcon()
|
||||
: process.platform === 'win32'
|
||||
? desktopWindowsTrayIcon()
|
||||
: desktopIcon()
|
||||
const icon = nativeImage.createFromPath(source).resize({
|
||||
width: process.platform === 'darwin' ? 18 : process.platform === 'win32' ? 24 : 16,
|
||||
height: process.platform === 'darwin' ? 18 : process.platform === 'win32' ? 24 : 16,
|
||||
quality: 'best',
|
||||
})
|
||||
if (process.platform === 'darwin') {
|
||||
icon.setTemplateImage(true)
|
||||
}
|
||||
tray = new Tray(icon)
|
||||
tray.setToolTip('Hermes Studio')
|
||||
tray.on('click', () => {
|
||||
showMainWindow()
|
||||
updateTrayMenu()
|
||||
})
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 820,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
title: 'Hermes Studio',
|
||||
backgroundColor: '#1a1a1a',
|
||||
autoHideMenuBar: true,
|
||||
show: !START_HIDDEN,
|
||||
...(process.platform === 'linux' ? { icon: desktopIcon() } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '..', 'preload', 'index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (isQuitting) return
|
||||
event.preventDefault()
|
||||
mainWindow?.hide()
|
||||
updateTrayMenu()
|
||||
})
|
||||
|
||||
mainWindow.on('show', updateTrayMenu)
|
||||
mainWindow.on('hide', updateTrayMenu)
|
||||
|
||||
// External links → system browser
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) {
|
||||
return { action: 'allow' }
|
||||
}
|
||||
shell.openExternal(url).catch(() => undefined)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// If the Web UI server is already up (re-opening window after close on
|
||||
// macOS), go straight to it. Otherwise show a loading splash; bootstrap()
|
||||
// will swap in the real URL once the server is ready.
|
||||
if (serverUrl) {
|
||||
mainWindow.loadURL(serverUrl)
|
||||
} else {
|
||||
mainWindow.loadURL(splashHtml())
|
||||
}
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
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: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:#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 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:runtime (to build a local Hermes runtime)')
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await startWebUiServer(PORT)
|
||||
serverUrl = url
|
||||
if (mainWindow) await mainWindow.loadURL(url)
|
||||
} catch (err) {
|
||||
console.error('Failed to start Web UI server:', 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 start local services</h2><pre style="white-space:pre-wrap;color:#f88">${msg}</pre>
|
||||
</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()
|
||||
if (!gotLock) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
if (argv.includes('--quit')) {
|
||||
quitApp()
|
||||
return
|
||||
}
|
||||
showMainWindow()
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (QUIT_EXISTING) {
|
||||
quitApp()
|
||||
return
|
||||
}
|
||||
|
||||
// Drop the default File/Edit/View/Window menu on Windows/Linux. The web
|
||||
// UI provides its own in-page controls, so the native menu bar is just
|
||||
// visual clutter. macOS keeps a menu (system requirement) but Electron's
|
||||
// default is fine there.
|
||||
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
|
||||
if (app.isPackaged) {
|
||||
installHermesStudioCliShim().then(result => {
|
||||
if (result.status === 'skipped') {
|
||||
console.warn(`[cli-shim] ${result.reason}: ${result.shimPath}`)
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn(`[cli-shim] failed to install hermes-studio command: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
}
|
||||
createTray()
|
||||
createWindow()
|
||||
bootstrap()
|
||||
initAutoUpdater({
|
||||
beforeQuitAndInstall: () => {
|
||||
isQuitting = true
|
||||
},
|
||||
})
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
} else if (mainWindow) {
|
||||
showMainWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (isQuitting && process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', async (e) => {
|
||||
if (!isQuitting && process.platform !== 'darwin') {
|
||||
e.preventDefault()
|
||||
mainWindow?.hide()
|
||||
updateTrayMenu()
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
await stopWebUiServer().catch(() => undefined)
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
const hermesCliArgs = parseHermesCliArgs(process.argv)
|
||||
if (hermesCliArgs) {
|
||||
runBundledHermesCli(hermesCliArgs)
|
||||
.then(code => app.exit(code))
|
||||
.catch(err => {
|
||||
console.error(`Failed to run bundled Hermes CLI: ${err instanceof Error ? err.message : String(err)}`)
|
||||
app.exit(1)
|
||||
})
|
||||
} else {
|
||||
runDesktopApp()
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { homedir, platform, arch } from 'node:os'
|
||||
|
||||
const isWin = platform() === 'win32'
|
||||
const osLabel = isWin ? 'win' : platform() === 'darwin' ? 'mac' : platform() // mac | linux | win
|
||||
const archLabel = arch() // arm64 | x64
|
||||
|
||||
export function isPackaged() {
|
||||
return app.isPackaged
|
||||
}
|
||||
|
||||
// Bundled web-ui directory.
|
||||
// dev: <repo root> (or HERMES_WEB_UI_DIR)
|
||||
// prod: <resources>/webui
|
||||
export function webuiDir(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'webui')
|
||||
return process.env.HERMES_WEB_UI_DIR?.trim() || resolve(app.getAppPath(), '..', '..')
|
||||
}
|
||||
|
||||
export function webuiServerEntry(): string {
|
||||
return join(webuiDir(), 'dist', 'server', 'index.js')
|
||||
}
|
||||
|
||||
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 when present, otherwise downloaded runtime cache.
|
||||
export function pythonDir(): string {
|
||||
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 {
|
||||
return join(pythonDir(), 'agent-browser')
|
||||
}
|
||||
|
||||
function browserExecutableNames(): Set<string> {
|
||||
if (isWin) return new Set(['chrome.exe'])
|
||||
if (platform() === 'darwin') return new Set(['Google Chrome for Testing', 'Google Chrome', 'Chromium', 'chrome'])
|
||||
return new Set(['chrome', 'chromium', 'chromium-browser'])
|
||||
}
|
||||
|
||||
export function bundledBrowserExecutable(): string | undefined {
|
||||
const names = browserExecutableNames()
|
||||
const stack = [join(bundledAgentBrowserHome(), 'browsers'), bundledAgentBrowserHome()].filter(existsSync)
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
if (!dir || visited.has(dir)) continue
|
||||
visited.add(dir)
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isFile() && names.has(entry.name)) return path
|
||||
if (entry.isDirectory()) stack.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function pythonBinDir(): string {
|
||||
const dir = pythonDir()
|
||||
return isWin ? join(dir, 'Scripts') : join(dir, 'bin')
|
||||
}
|
||||
|
||||
export function bundledPython(): string {
|
||||
const dir = pythonDir()
|
||||
return isWin ? join(dir, 'python.exe') : join(dir, 'bin', 'python3')
|
||||
}
|
||||
|
||||
export function hermesBin(): string {
|
||||
return isWin ? join(pythonBinDir(), 'hermes.exe') : join(pythonBinDir(), 'hermes')
|
||||
}
|
||||
|
||||
export function hermesBinExists(): boolean {
|
||||
return existsSync(hermesBin())
|
||||
}
|
||||
|
||||
export function desktopIcon(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'icon.png')
|
||||
return resolve(app.getAppPath(), 'build', 'icon.png')
|
||||
}
|
||||
|
||||
export function desktopWindowsTrayIcon(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'trayWindows.png')
|
||||
return resolve(app.getAppPath(), 'build', 'trayWindows.png')
|
||||
}
|
||||
|
||||
export function desktopTrayTemplateIcon(): string {
|
||||
if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'trayTemplate.png')
|
||||
return resolve(app.getAppPath(), 'build', 'trayTemplate.png')
|
||||
}
|
||||
|
||||
export function webUiHome(): string {
|
||||
return process.env.HERMES_WEB_UI_HOME?.trim() || resolve(homedir(), '.hermes-web-ui')
|
||||
}
|
||||
|
||||
export function hermesHome(): string {
|
||||
const override = process.env.HERMES_HOME?.trim()
|
||||
if (override) return resolve(override)
|
||||
|
||||
const defaultHome = resolve(homedir(), '.hermes')
|
||||
|
||||
if (isWin) {
|
||||
const candidates = [
|
||||
process.env.LOCALAPPDATA,
|
||||
process.env.APPDATA,
|
||||
]
|
||||
.map(value => value?.trim())
|
||||
.filter((value): value is string => !!value)
|
||||
.map(value => resolve(value, 'hermes'))
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return defaultHome
|
||||
}
|
||||
|
||||
export function tokenFile(): string {
|
||||
return join(webUiHome(), '.token')
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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, relative } 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 requiredRuntimeFiles(root: string): string[] {
|
||||
const pythonBin = process.platform === 'win32'
|
||||
? join(root, 'python', 'python.exe')
|
||||
: join(root, 'python', 'bin', 'python3')
|
||||
const hermesBin = process.platform === 'win32'
|
||||
? join(root, 'python', 'Scripts', 'hermes.exe')
|
||||
: join(root, 'python', 'bin', 'hermes')
|
||||
const nodeBin = process.platform === 'win32'
|
||||
? join(root, 'node', 'node.exe')
|
||||
: join(root, 'node', 'bin', 'node')
|
||||
const files = [pythonBin, hermesBin, nodeBin, join(root, RUNTIME_MANIFEST_NAME)]
|
||||
if (process.platform === 'win32') files.push(join(root, 'git', 'cmd', 'git.exe'))
|
||||
return files
|
||||
}
|
||||
|
||||
function missingRuntimeFiles(root: string): string[] {
|
||||
return requiredRuntimeFiles(root).filter(file => !existsSync(file))
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
const missing = missingRuntimeFiles(tempRoot)
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Runtime archive is missing required files: ${missing.map(file => relative(tempRoot, file)).join(', ')}`)
|
||||
}
|
||||
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}...` })
|
||||
let archiveSize = 0
|
||||
try {
|
||||
await downloadFile(descriptor.url, archive, onProgress)
|
||||
archiveSize = statSync(archive).size
|
||||
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)
|
||||
} finally {
|
||||
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}`)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { app, dialog } from 'electron'
|
||||
import { autoUpdater, type ProgressInfo, type UpdateDownloadedEvent, type UpdateInfo } from 'electron-updater'
|
||||
import { t } from './desktop-i18n'
|
||||
|
||||
let initialized = false
|
||||
let checking = false
|
||||
let updateDownloaded = false
|
||||
|
||||
const LATEST_RELEASE_DOWNLOAD_URL = 'https://github.com/EKKOLearnAI/hermes-web-ui/releases/latest/download'
|
||||
const CLOUDFLARE_DOWNLOAD_BASE_URL = 'https://download.ekkolearnai.com'
|
||||
|
||||
class MissingUpdateInfoError extends Error {
|
||||
constructor(public readonly url: string) {
|
||||
super(`Update information is not available at ${url}`)
|
||||
this.name = 'MissingUpdateInfoError'
|
||||
}
|
||||
}
|
||||
|
||||
interface AutoUpdaterOptions {
|
||||
beforeQuitAndInstall?: () => void
|
||||
}
|
||||
|
||||
let options: AutoUpdaterOptions = {}
|
||||
|
||||
async function getLatestReleaseTag(assetName: string): Promise<string> {
|
||||
const res = await fetch(`${LATEST_RELEASE_DOWNLOAD_URL}/${encodeURIComponent(assetName)}`, {
|
||||
method: 'HEAD',
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'User-Agent': `Hermes-Studio/${app.getVersion()}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.status < 300 || res.status >= 400) throw new Error(`GitHub returned ${res.status}`)
|
||||
|
||||
const location = res.headers.get('location')
|
||||
if (!location) throw new Error('Latest release redirect did not include a location')
|
||||
|
||||
const redirectUrl = new URL(location, LATEST_RELEASE_DOWNLOAD_URL)
|
||||
const parts = redirectUrl.pathname.split('/')
|
||||
const downloadIndex = parts.indexOf('download')
|
||||
const tag = downloadIndex >= 0 ? parts[downloadIndex + 1]?.trim() : ''
|
||||
if (!tag) throw new Error('Latest release redirect did not include a tag')
|
||||
return tag
|
||||
}
|
||||
|
||||
function updateManifestFile(): string {
|
||||
if (process.platform === 'darwin') return 'latest-mac.yml'
|
||||
if (process.platform === 'win32') return 'latest.yml'
|
||||
return 'latest-linux.yml'
|
||||
}
|
||||
|
||||
async function assertUpdateManifestExists(feedUrl: string): Promise<void> {
|
||||
const manifestUrl = `${feedUrl}/${updateManifestFile()}`
|
||||
const res = await fetch(manifestUrl, {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
'User-Agent': `Hermes-Studio/${app.getVersion()}`,
|
||||
},
|
||||
})
|
||||
if (res.status === 404) throw new MissingUpdateInfoError(manifestUrl)
|
||||
if (!res.ok) throw new Error(`Update feed returned ${res.status}`)
|
||||
}
|
||||
|
||||
async function configureFeedFromLatestRelease(): Promise<void> {
|
||||
const tag = await getLatestReleaseTag(updateManifestFile())
|
||||
const feedUrl = `${CLOUDFLARE_DOWNLOAD_BASE_URL}/${tag}`
|
||||
await assertUpdateManifestExists(feedUrl)
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: feedUrl,
|
||||
})
|
||||
}
|
||||
|
||||
function showUpToDate(info?: UpdateInfo) {
|
||||
const version = info?.version || app.getVersion()
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: t('update.upToDateTitle'),
|
||||
message: t('update.upToDateMessage'),
|
||||
detail: t('update.currentVersion', { version }),
|
||||
buttons: [t('common.ok')],
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
function showUpdateCheckFailed(err: unknown) {
|
||||
const isMissingUpdateInfo = err instanceof MissingUpdateInfoError
|
||||
dialog.showMessageBox({
|
||||
type: isMissingUpdateInfo ? 'info' : 'error',
|
||||
title: isMissingUpdateInfo ? t('update.upToDateTitle') : t('update.failedTitle'),
|
||||
message: isMissingUpdateInfo ? t('update.noUpdateInfoMessage') : t('update.failedMessage'),
|
||||
buttons: [t('common.ok')],
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function initAutoUpdater(nextOptions: AutoUpdaterOptions = {}) {
|
||||
options = { ...options, ...nextOptions }
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
if (!app.isPackaged) return // dev mode: skip
|
||||
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('update-available', info => {
|
||||
console.log(`[updater] update available: ${info.version}`)
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: t('update.availableTitle'),
|
||||
message: t('update.availableMessage', { version: info.version }),
|
||||
detail: t('update.downloading'),
|
||||
buttons: [t('common.ok')],
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
autoUpdater.on('update-not-available', info => {
|
||||
console.log('[updater] up to date')
|
||||
if (checking) showUpToDate(info)
|
||||
})
|
||||
autoUpdater.on('error', err => {
|
||||
console.error('[updater] error:', err)
|
||||
if (checking) showUpdateCheckFailed(err)
|
||||
})
|
||||
autoUpdater.on('download-progress', (info: ProgressInfo) => {
|
||||
console.log(`[updater] download ${Math.round(info.percent)}%`)
|
||||
})
|
||||
autoUpdater.on('update-downloaded', async (info: UpdateDownloadedEvent) => {
|
||||
updateDownloaded = true
|
||||
const { response } = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: t('update.readyTitle'),
|
||||
message: t('update.readyMessage', { version: info.version }),
|
||||
detail: t('update.readyDetail'),
|
||||
buttons: [t('update.restartNow'), t('update.later')],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
if (response === 0) {
|
||||
options.beforeQuitAndInstall?.()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
})
|
||||
|
||||
if (process.env.HERMES_DESKTOP_ENABLE_AUTO_UPDATE !== 'false') {
|
||||
checkForDesktopUpdates(false).catch(err => {
|
||||
console.error('[updater] initial check failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// Recheck every 6h while app is running
|
||||
setInterval(() => {
|
||||
checkForDesktopUpdates(false).catch(() => undefined)
|
||||
}, 6 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
export async function checkForDesktopUpdates(manual: boolean): Promise<void> {
|
||||
if (!app.isPackaged) {
|
||||
if (manual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: t('update.checkingTitle'),
|
||||
message: t('update.packagedOnlyMessage'),
|
||||
buttons: [t('common.ok')],
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (updateDownloaded) {
|
||||
options.beforeQuitAndInstall?.()
|
||||
autoUpdater.quitAndInstall()
|
||||
return
|
||||
}
|
||||
|
||||
if (manual) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: t('update.checkingTitle'),
|
||||
message: t('update.checkingMessage'),
|
||||
buttons: [t('common.ok')],
|
||||
})
|
||||
}
|
||||
|
||||
checking = manual
|
||||
try {
|
||||
await configureFeedFromLatestRelease()
|
||||
await autoUpdater.checkForUpdates()
|
||||
} catch (err) {
|
||||
if (manual) showUpdateCheckFailed(err)
|
||||
throw err
|
||||
} finally {
|
||||
checking = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { ChildProcess, execFile, spawn } from 'node:child_process'
|
||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { createServer } from 'node:net'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, delimiter, join } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { promisify } from 'node:util'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
bundledBrowserExecutable,
|
||||
bundledGit,
|
||||
bundledNode,
|
||||
gitPathDirs,
|
||||
webuiServerEntry,
|
||||
webuiDir,
|
||||
hermesBin,
|
||||
webUiHome,
|
||||
hermesHome,
|
||||
nodeBinDir,
|
||||
tokenFile,
|
||||
pythonDir,
|
||||
} from './paths'
|
||||
|
||||
const DEFAULT_PORT = 8748
|
||||
const DEFAULT_READY_TIMEOUT_MS = 120_000
|
||||
const AGENT_BRIDGE_STARTED_MARKER = '[bootstrap] agent bridge started'
|
||||
const AGENT_BRIDGE_FAILED_MARKER = '[bootstrap] agent bridge failed to start'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let serverProc: ChildProcess | null = null
|
||||
let cachedToken: string | null = null
|
||||
|
||||
function killProcessTree(proc: ChildProcess): void {
|
||||
if (!proc.pid || proc.killed) return
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const killer = spawn('taskkill.exe', ['/PID', String(proc.pid), '/T', '/F'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
})
|
||||
killer.once('error', () => undefined)
|
||||
return
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
try {
|
||||
proc.kill('SIGKILL')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function envPositiveInt(name: string): number | undefined {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function readyTimeoutMs(): number {
|
||||
return envPositiveInt('HERMES_DESKTOP_READY_TIMEOUT_MS') || DEFAULT_READY_TIMEOUT_MS
|
||||
}
|
||||
|
||||
function createAgentBridgeStartupTracker(): {
|
||||
observe: (chunk: Buffer) => void
|
||||
wait: (timeoutMs: number) => Promise<void>
|
||||
} {
|
||||
let output = ''
|
||||
let state: 'pending' | 'started' | 'failed' = 'pending'
|
||||
let resolveReady: (() => void) | null = null
|
||||
let rejectReady: ((err: Error) => void) | null = null
|
||||
|
||||
const settle = (nextState: 'started' | 'failed') => {
|
||||
if (state !== 'pending') return
|
||||
state = nextState
|
||||
if (nextState === 'started') {
|
||||
resolveReady?.()
|
||||
} else {
|
||||
rejectReady?.(new Error('Agent bridge failed to start'))
|
||||
}
|
||||
}
|
||||
|
||||
const observe = (chunk: Buffer) => {
|
||||
if (state !== 'pending') return
|
||||
output = (output + chunk.toString('utf-8')).slice(-4096)
|
||||
if (output.includes(AGENT_BRIDGE_STARTED_MARKER)) {
|
||||
settle('started')
|
||||
} else if (output.includes(AGENT_BRIDGE_FAILED_MARKER)) {
|
||||
settle('failed')
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (timeoutMs: number) => {
|
||||
if (state === 'started') return Promise.resolve()
|
||||
if (state === 'failed') return Promise.reject(new Error('Agent bridge failed to start'))
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (state !== 'pending') return
|
||||
state = 'failed'
|
||||
reject(new Error(`Agent bridge did not become ready within ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
resolveReady = () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
rejectReady = (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { observe, wait }
|
||||
}
|
||||
|
||||
function ensureToken(): string {
|
||||
if (cachedToken) return cachedToken
|
||||
const file = tokenFile()
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
if (existsSync(file)) {
|
||||
cachedToken = readFileSync(file, 'utf-8').trim()
|
||||
if (cachedToken) return cachedToken
|
||||
}
|
||||
cachedToken = randomBytes(32).toString('hex')
|
||||
writeFileSync(file, cachedToken + '\n', { mode: 0o600 })
|
||||
return cachedToken
|
||||
}
|
||||
|
||||
// node-pty ships per-platform prebuilds with a `spawn-helper` binary that
|
||||
// loses its +x bit when copied across some filesystems. Restore it.
|
||||
function ensureNativeModules() {
|
||||
try {
|
||||
const helper = join(
|
||||
webuiDir(),
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'prebuilds',
|
||||
`${process.platform}-${process.arch}`,
|
||||
'spawn-helper',
|
||||
)
|
||||
if (existsSync(helper)) chmodSync(helper, 0o755)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const COMMON_USER_BIN_DIRS = process.platform === 'win32'
|
||||
? []
|
||||
: [
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/usr/sbin',
|
||||
'/sbin',
|
||||
]
|
||||
const PATH_MARKER_START = '__HERMES_DESKTOP_PATH_START__'
|
||||
const PATH_MARKER_END = '__HERMES_DESKTOP_PATH_END__'
|
||||
|
||||
function mergePathEntries(...paths: Array<string | undefined | null>): string {
|
||||
const seen = new Set<string>()
|
||||
const entries: string[] = []
|
||||
for (const rawPath of paths) {
|
||||
if (!rawPath) continue
|
||||
for (const entry of rawPath.split(delimiter)) {
|
||||
const trimmed = entry.trim()
|
||||
if (!trimmed) continue
|
||||
const key = process.platform === 'win32' ? trimmed.toLowerCase() : trimmed
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
entries.push(trimmed)
|
||||
}
|
||||
}
|
||||
return entries.join(delimiter)
|
||||
}
|
||||
|
||||
function extractMarkedPath(output: string): string | null {
|
||||
const start = output.lastIndexOf(PATH_MARKER_START)
|
||||
const end = output.lastIndexOf(PATH_MARKER_END)
|
||||
if (start < 0 || end <= start) return null
|
||||
const value = output.slice(start + PATH_MARKER_START.length, end).trim()
|
||||
return value || null
|
||||
}
|
||||
|
||||
function compareNodeVersionDesc(left: string, right: string): number {
|
||||
const leftParts = left.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0)
|
||||
const rightParts = right.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0)
|
||||
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
||||
const diff = (rightParts[index] || 0) - (leftParts[index] || 0)
|
||||
if (diff !== 0) return diff
|
||||
}
|
||||
return right.localeCompare(left)
|
||||
}
|
||||
|
||||
function getNvmNodeBinPaths(): string {
|
||||
if (process.platform === 'win32') return ''
|
||||
|
||||
const nvmDir = process.env.NVM_DIR?.trim() || join(homedir(), '.nvm')
|
||||
const versionsDir = join(nvmDir, 'versions', 'node')
|
||||
if (!existsSync(versionsDir)) return ''
|
||||
|
||||
try {
|
||||
return readdirSync(versionsDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name)
|
||||
.sort(compareNodeVersionDesc)
|
||||
.map(version => join(versionsDir, version, 'bin'))
|
||||
.filter(binDir => existsSync(binDir))
|
||||
.join(delimiter)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function getLoginShellPath(): Promise<string | null> {
|
||||
if (process.platform === 'win32') return null
|
||||
|
||||
const shell = process.env.SHELL?.trim() || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh')
|
||||
if (!existsSync(shell)) return null
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(shell, ['-l', '-c', `printf '\\n${PATH_MARKER_START}%s${PATH_MARKER_END}\\n' "$PATH"`], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1500,
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
})
|
||||
return extractMarkedPath(stdout) || stdout.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string {
|
||||
return ensureToken()
|
||||
}
|
||||
|
||||
export function getServerUrl(port = DEFAULT_PORT): string {
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
async function getFreeTcpPort(): Promise<number> {
|
||||
return await new Promise((resolveFreePort, rejectFreePort) => {
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
server.once('error', rejectFreePort)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
server.close(() => {
|
||||
if (typeof address === 'object' && address?.port) {
|
||||
resolveFreePort(address.port)
|
||||
} else {
|
||||
rejectFreePort(new Error('Unable to allocate local TCP port'))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function canBindTcpPort(port: number): Promise<boolean> {
|
||||
return await new Promise((resolveCanBind) => {
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
server.once('error', () => resolveCanBind(false))
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
server.close(() => resolveCanBind(true))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function getFreeTcpPortInRange(min: number, max: number): Promise<number> {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const port = min + (randomBytes(2).readUInt16BE(0) % (max - min + 1))
|
||||
if (await canBindTcpPort(port)) return port
|
||||
}
|
||||
return getFreeTcpPort()
|
||||
}
|
||||
|
||||
export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||
ensureNativeModules()
|
||||
const token = ensureToken()
|
||||
const entry = webuiServerEntry()
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`Web UI server entry not found at ${entry}. Run: npm run build:webui`)
|
||||
}
|
||||
|
||||
const home = webUiHome()
|
||||
const agentHome = hermesHome()
|
||||
mkdirSync(home, { recursive: true })
|
||||
mkdirSync(agentHome, { recursive: true })
|
||||
|
||||
// Tell agent-bridge to use the bundled Python directly. Otherwise the
|
||||
// bridge auto-detects Python from HERMES_BIN's shebang — which on our
|
||||
// setup is a #!/bin/sh wrapper, not a python interpreter, so detection
|
||||
// resolves to /bin/sh and the bridge crashes (exit code 2) immediately.
|
||||
const isWin = process.platform === 'win32'
|
||||
const bundledPython = isWin
|
||||
? join(pythonDir(), 'python.exe')
|
||||
: join(pythonDir(), 'bin', 'python3')
|
||||
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 = {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
NODE_ENV: 'production',
|
||||
HERMES_DESKTOP: 'true',
|
||||
HERMES_BIN: hermesBin(),
|
||||
// The bridge and its per-profile workers need working stdout/stderr for
|
||||
// ready handshakes. Use python.exe on Windows and hide windows at the
|
||||
// process creation layer instead of switching the bridge to pythonw.exe.
|
||||
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
|
||||
// identically and avoids the issue cross-platform.
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT: `tcp://127.0.0.1:${bridgePort}`,
|
||||
// Desktop opens the UI as soon as the Web UI HTTP server is ready, while
|
||||
// the Python bridge starts in the background. Let the first chat/context
|
||||
// request wait for broker readiness instead of failing during cold start.
|
||||
HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS: process.env.HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS ?? '120000',
|
||||
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
|
||||
// reason as above — default ipc:// unix sockets in /tmp get killed.
|
||||
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp',
|
||||
HERMES_AGENT_BRIDGE_WORKER_PORT_BASE: String(workerPortBase),
|
||||
// And for preview-mode bridges spawned by the in-app update controller.
|
||||
HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT: 'tcp',
|
||||
// Suppress the npm-registry update prompt (upstream #1105). hermes-web-ui
|
||||
// is bundled here; users can't `npm i -g` to upgrade, they have to wait
|
||||
// for the wrapper app to ship a new release.
|
||||
HERMES_WEB_UI_DISABLE_UPDATE_CHECK: 'true',
|
||||
// Single-user desktop install: open the gateway's user allowlist by
|
||||
// default. Otherwise the gateway silently drops every inbound platform
|
||||
// message (DingTalk/Slack/Telegram) with a startup warning. Users can
|
||||
// still override by setting GATEWAY_ALLOW_ALL_USERS=false in their
|
||||
// HERMES_HOME/.env or by configuring per-platform allowlists.
|
||||
GATEWAY_ALLOW_ALL_USERS: process.env.GATEWAY_ALLOW_ALL_USERS ?? 'true',
|
||||
// Keep the bundled Hermes Agent, bridge, gateway, and Web UI path helpers
|
||||
// on the same data directory. Native Windows uses an existing
|
||||
// %LOCALAPPDATA%\hermes or %APPDATA%\hermes; otherwise all platforms keep
|
||||
// the standard ~/.hermes layout.
|
||||
HERMES_HOME: agentHome,
|
||||
HERMES_WEB_UI_HOME: home,
|
||||
HERMES_WEBUI_STATE_DIR: home,
|
||||
AUTH_TOKEN: token,
|
||||
PORT: String(port),
|
||||
// Prepend bundled Python's bin to PATH so any incidental `python` resolution lands on ours
|
||||
PATH: runtimePath,
|
||||
}
|
||||
|
||||
serverProc = spawn(process.execPath, [entry], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const bridgeStartup = createAgentBridgeStartupTracker()
|
||||
|
||||
serverProc.stdout?.on('data', (chunk: Buffer) => {
|
||||
bridgeStartup.observe(chunk)
|
||||
process.stdout.write(`[webui] ${chunk}`)
|
||||
})
|
||||
serverProc.stderr?.on('data', (chunk: Buffer) => {
|
||||
bridgeStartup.observe(chunk)
|
||||
process.stderr.write(`[webui] ${chunk}`)
|
||||
})
|
||||
serverProc.on('exit', (code, signal) => {
|
||||
console.error(`[webui] server exited code=${code} signal=${signal}`)
|
||||
serverProc = null
|
||||
if (!app.isReady() || code !== 0) {
|
||||
// Best-effort: if server dies abnormally during startup, surface to user
|
||||
}
|
||||
})
|
||||
|
||||
const timeoutMs = readyTimeoutMs()
|
||||
void bridgeStartup.wait(timeoutMs).catch(err => {
|
||||
console.warn(`[webui] agent bridge was not ready during startup: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
await waitForReady(port, timeoutMs)
|
||||
return getServerUrl(port)
|
||||
}
|
||||
|
||||
async function waitForReady(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const url = `http://127.0.0.1:${port}/api/health`
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(1000) })
|
||||
if (res.ok || res.status === 401) return // 401 = up but auth-gated, server is alive
|
||||
} catch {
|
||||
/* not ready yet */
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
throw new Error(`Web UI server did not become ready within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
export function stopWebUiServer(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
if (!serverProc || serverProc.killed) return resolve()
|
||||
const proc = serverProc
|
||||
const timer = setTimeout(() => {
|
||||
killProcessTree(proc)
|
||||
resolve()
|
||||
}, 3000)
|
||||
proc.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
try { proc.kill('SIGTERM') } catch { resolve() }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getToken: (): Promise<string> => ipcRenderer.invoke('hermes-desktop:get-token'),
|
||||
retryBootstrap: (): Promise<void> => ipcRenderer.invoke('hermes-desktop:retry-bootstrap'),
|
||||
platform: process.platform,
|
||||
isDesktop: true,
|
||||
})
|
||||
|
||||
const API_KEY_LS = 'hermes_api_key'
|
||||
const DEFAULT_USERNAME = 'admin'
|
||||
const DEFAULT_PASSWORD = '123456'
|
||||
|
||||
// Auto-login the bundled web UI so users don't see a login screen on launch.
|
||||
// We POST to /api/auth/login with the well-known default credentials, using
|
||||
// the server's AUTH_TOKEN as the bearer (the server requires *some* auth on
|
||||
// /api/auth/login from a packaged client). The returned JWT is dropped into
|
||||
// localStorage where the Vue client expects it.
|
||||
async function autoLogin(token: string): Promise<void> {
|
||||
if (localStorage.getItem(API_KEY_LS)) return
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD }),
|
||||
})
|
||||
if (!res.ok) return
|
||||
const body = await res.json().catch(() => null) as { token?: string; jwt?: string } | null
|
||||
const jwt = body?.token || body?.jwt
|
||||
if (jwt) localStorage.setItem(API_KEY_LS, jwt)
|
||||
} catch {
|
||||
/* ignore — first-load race or server still starting */
|
||||
}
|
||||
}
|
||||
|
||||
// Silently strip the "你必须修改默认密码" flag from /api/auth/me responses on
|
||||
// desktop. Users on a single-machine install don't benefit from a managed
|
||||
// password. The Web UI client uses BOTH fetch and axios (which goes through
|
||||
// XMLHttpRequest), so we patch both code paths.
|
||||
function isAuthMeUrl(url: string): boolean {
|
||||
return /\/api\/auth\/me(?:\?|$)/.test(url)
|
||||
}
|
||||
|
||||
function stripCredentialFlag(text: string): string {
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
if (data?.user && data.user.requiresCredentialChange) {
|
||||
data.user.requiresCredentialChange = false
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
return text
|
||||
}
|
||||
|
||||
function installFetchPatch(): void {
|
||||
const origFetch = window.fetch.bind(window)
|
||||
const patchedFetch = (async (input, init) => {
|
||||
const res = await origFetch(input, init)
|
||||
try {
|
||||
const url = typeof input === 'string' ? input : (input as Request).url
|
||||
if (url && isAuthMeUrl(url) && res.ok) {
|
||||
const text = await res.clone().text()
|
||||
const patched = stripCredentialFlag(text)
|
||||
if (patched !== text) {
|
||||
return new Response(patched, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return res
|
||||
}) as typeof window.fetch
|
||||
window.fetch = patchedFetch
|
||||
|
||||
const OrigXHR = window.XMLHttpRequest
|
||||
type XHRWithDesktop = XMLHttpRequest & { __hermesDesktopUrl?: string }
|
||||
const origOpen = OrigXHR.prototype.open
|
||||
OrigXHR.prototype.open = function (
|
||||
this: XHRWithDesktop,
|
||||
method: string,
|
||||
url: string | URL,
|
||||
...rest: unknown[]
|
||||
) {
|
||||
this.__hermesDesktopUrl = String(url)
|
||||
// @ts-expect-error — forwarding variadic
|
||||
return origOpen.call(this, method, url, ...rest)
|
||||
}
|
||||
const origGetResponse = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'response')
|
||||
const origGetResponseText = Object.getOwnPropertyDescriptor(OrigXHR.prototype, 'responseText')
|
||||
if (origGetResponse?.get && origGetResponseText?.get) {
|
||||
Object.defineProperty(OrigXHR.prototype, 'responseText', {
|
||||
configurable: true,
|
||||
get(this: XHRWithDesktop) {
|
||||
const raw = origGetResponseText.get!.call(this) as string
|
||||
if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl) && typeof raw === 'string') {
|
||||
return stripCredentialFlag(raw)
|
||||
}
|
||||
return raw
|
||||
},
|
||||
})
|
||||
Object.defineProperty(OrigXHR.prototype, 'response', {
|
||||
configurable: true,
|
||||
get(this: XHRWithDesktop) {
|
||||
const raw = origGetResponse.get!.call(this)
|
||||
if (this.__hermesDesktopUrl && isAuthMeUrl(this.__hermesDesktopUrl)) {
|
||||
if (typeof raw === 'string') return stripCredentialFlag(raw)
|
||||
if (raw && typeof raw === 'object' && (raw as { user?: { requiresCredentialChange?: boolean } }).user?.requiresCredentialChange) {
|
||||
return { ...(raw as object), user: { ...(raw as { user: object }).user, requiresCredentialChange: false } }
|
||||
}
|
||||
}
|
||||
return raw
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
installFetchPatch()
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const token = await ipcRenderer.invoke('hermes-desktop:get-token')
|
||||
if (token) {
|
||||
try { localStorage.setItem('AUTH_TOKEN', token) } catch { /* */ }
|
||||
await autoLogin(token)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||