fix desktop preload build and rename app (#1150)
* fix desktop preload fetch typing * rename desktop app to Hermes Studio * rename desktop package to Hermes Studio * update Hermes Studio desktop icons * configure desktop signing and app id * force desktop api calls to local server * isolate desktop agent bridge ports * bundle MCP support in desktop Python * change desktop default port to 8748 * restore webui production port copy
This commit is contained in:
@@ -89,6 +89,12 @@ jobs:
|
|||||||
run: npm --prefix packages/desktop run prepare:python
|
run: npm --prefix packages/desktop run prepare:python
|
||||||
|
|
||||||
- name: Build desktop artifact
|
- name: Build desktop artifact
|
||||||
|
env:
|
||||||
|
CSC_LINK: ${{ matrix.target_os == 'darwin' && secrets.MAC_CSC_LINK || '' }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ matrix.target_os == 'darwin' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
|
||||||
|
APPLE_ID: ${{ matrix.target_os == 'darwin' && secrets.APPLE_ID || '' }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.target_os == 'darwin' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
|
||||||
|
APPLE_TEAM_ID: ${{ matrix.target_os == 'darwin' && secrets.APPLE_TEAM_ID || '' }}
|
||||||
run: npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} --publish never
|
run: npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} --publish never
|
||||||
|
|
||||||
- name: Upload artifacts to release
|
- name: Upload artifacts to release
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import router from '@/router'
|
|||||||
|
|
||||||
const DEFAULT_BASE_URL = ''
|
const DEFAULT_BASE_URL = ''
|
||||||
|
|
||||||
|
function isDesktopShell(): boolean {
|
||||||
|
return typeof window !== 'undefined' &&
|
||||||
|
(window as typeof window & { hermesDesktop?: { isDesktop?: boolean } }).hermesDesktop?.isDesktop === true
|
||||||
|
}
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
function getBaseUrl(): string {
|
||||||
if (import.meta.env.VITE_HERMES_PREVIEW === '1') return DEFAULT_BASE_URL
|
if (import.meta.env.VITE_HERMES_PREVIEW === '1') return DEFAULT_BASE_URL
|
||||||
|
if (isDesktopShell()) return DEFAULT_BASE_URL
|
||||||
return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
|
return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hermes Desktop
|
# Hermes Studio
|
||||||
|
|
||||||
Electron desktop distribution for Hermes Web UI.
|
Electron desktop distribution for Hermes Studio.
|
||||||
|
|
||||||
## Data directories
|
## Data directories
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 552 KiB |
@@ -1,5 +1,5 @@
|
|||||||
appId: com.hermesagent.desktop
|
appId: com.hermeswebui.studio
|
||||||
productName: Hermes Desktop
|
productName: Hermes Studio
|
||||||
copyright: Copyright © 2026
|
copyright: Copyright © 2026
|
||||||
|
|
||||||
directories:
|
directories:
|
||||||
@@ -45,9 +45,9 @@ mac:
|
|||||||
- target: dmg
|
- target: dmg
|
||||||
arch: [arm64, x64]
|
arch: [arm64, x64]
|
||||||
category: public.app-category.developer-tools
|
category: public.app-category.developer-tools
|
||||||
hardenedRuntime: false
|
hardenedRuntime: true
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
identity: null
|
notarize: true
|
||||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||||
|
|
||||||
win:
|
win:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-desktop",
|
"name": "hermes-studio",
|
||||||
"version": "0.6.5",
|
"version": "0.6.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hermes-desktop",
|
"name": "hermes-studio",
|
||||||
"version": "0.6.5",
|
"version": "0.6.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-desktop",
|
"name": "hermes-studio",
|
||||||
"version": "0.6.5",
|
"version": "0.6.5",
|
||||||
"description": "Desktop distribution for Hermes Web UI with bundled Python runtime and hermes-agent",
|
"description": "Hermes Studio desktop distribution with bundled Python runtime and hermes-agent",
|
||||||
"homepage": "https://ekkolearnai.com",
|
"homepage": "https://ekkolearnai.com",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Hermes Desktop Contributors",
|
"name": "Hermes Studio Contributors",
|
||||||
"email": "noreply@hermes-desktop.local"
|
"email": "noreply@hermes-studio.local"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const ROOT = resolve(__dirname, '..')
|
|||||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||||
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2'
|
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2'
|
||||||
|
const HERMES_PACKAGE = process.env.HERMES_PACKAGE || `hermes-agent[mcp]==${HERMES_VERSION}`
|
||||||
|
|
||||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
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 PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||||
@@ -33,23 +34,32 @@ function hasUv() {
|
|||||||
|
|
||||||
let r
|
let r
|
||||||
if (hasUv()) {
|
if (hasUv()) {
|
||||||
console.log(`→ Installing hermes-agent==${HERMES_VERSION} via uv`)
|
console.log(`→ Installing ${HERMES_PACKAGE} via uv`)
|
||||||
r = spawnSync('uv', [
|
r = spawnSync('uv', [
|
||||||
'pip', 'install',
|
'pip', 'install',
|
||||||
'--python', pyBin,
|
'--python', pyBin,
|
||||||
`hermes-agent==${HERMES_VERSION}`,
|
HERMES_PACKAGE,
|
||||||
], { stdio: 'inherit' })
|
], { stdio: 'inherit' })
|
||||||
} else {
|
} else {
|
||||||
console.log(`→ Installing hermes-agent==${HERMES_VERSION} via pip`)
|
console.log(`→ Installing ${HERMES_PACKAGE} via pip`)
|
||||||
r = spawnSync(pyBin, [
|
r = spawnSync(pyBin, [
|
||||||
'-m', 'pip', 'install',
|
'-m', 'pip', 'install',
|
||||||
`hermes-agent==${HERMES_VERSION}`,
|
HERMES_PACKAGE,
|
||||||
'--no-warn-script-location',
|
'--no-warn-script-location',
|
||||||
'--disable-pip-version-check',
|
'--disable-pip-version-check',
|
||||||
], { stdio: 'inherit' })
|
], { stdio: 'inherit' })
|
||||||
}
|
}
|
||||||
if (r.status !== 0) process.exit(r.status ?? 1)
|
if (r.status !== 0) process.exit(r.status ?? 1)
|
||||||
|
|
||||||
|
r = spawnSync(pyBin, [
|
||||||
|
'-c',
|
||||||
|
'import mcp; import tools.mcp_tool as t; assert t._MCP_AVAILABLE',
|
||||||
|
], { stdio: 'inherit' })
|
||||||
|
if (r.status !== 0) {
|
||||||
|
console.error('MCP Python SDK sanity check failed')
|
||||||
|
process.exit(r.status ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
const hermesBin = TARGET_OS === 'win32'
|
const hermesBin = TARGET_OS === 'win32'
|
||||||
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
||||||
: resolve(PY_DIR, 'bin', 'hermes')
|
: resolve(PY_DIR, 'bin', 'hermes')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
|
|||||||
import { hermesBinExists, hermesBin } from './paths'
|
import { hermesBinExists, hermesBin } from './paths'
|
||||||
import { initAutoUpdater } from './updater'
|
import { initAutoUpdater } from './updater'
|
||||||
|
|
||||||
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8648
|
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let serverUrl: string | null = null
|
let serverUrl: string | null = null
|
||||||
@@ -15,7 +15,7 @@ function createWindow() {
|
|||||||
height: 820,
|
height: 820,
|
||||||
minWidth: 960,
|
minWidth: 960,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
title: 'Hermes Desktop',
|
title: 'Hermes Studio',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -46,7 +46,7 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splashHtml(): string {
|
function splashHtml(): string {
|
||||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Desktop</title>
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Hermes Studio</title>
|
||||||
<style>
|
<style>
|
||||||
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
|
html,body{margin:0;height:100%;background:#1a1a1a;color:#e5e5e5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;}
|
||||||
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px}
|
.wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:24px}
|
||||||
@@ -57,7 +57,7 @@ function splashHtml(): string {
|
|||||||
.label{font-size:14px;color:#999}
|
.label{font-size:14px;color:#999}
|
||||||
h1{font-weight:500;margin:0;font-size:18px}
|
h1{font-weight:500;margin:0;font-size:18px}
|
||||||
</style></head><body><div class="wrap">
|
</style></head><body><div class="wrap">
|
||||||
<h1>Hermes Desktop</h1>
|
<h1>Hermes Studio</h1>
|
||||||
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
<div class="row"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||||
<div class="label">Starting local services…</div>
|
<div class="label">Starting local services…</div>
|
||||||
</div></body></html>`
|
</div></body></html>`
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function initAutoUpdater() {
|
|||||||
const { response } = await dialog.showMessageBox({
|
const { response } = await dialog.showMessageBox({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: 'Update ready',
|
title: 'Update ready',
|
||||||
message: `Hermes Desktop ${info.version} is ready to install.`,
|
message: `Hermes Studio ${info.version} is ready to install.`,
|
||||||
detail: 'Restart now to apply the update, or it will be installed on next quit.',
|
detail: 'Restart now to apply the update, or it will be installed on next quit.',
|
||||||
buttons: ['Restart now', 'Later'],
|
buttons: ['Restart now', 'Later'],
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ChildProcess, spawn } from 'node:child_process'
|
import { ChildProcess, spawn } from 'node:child_process'
|
||||||
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
|
||||||
|
import { createServer } from 'node:net'
|
||||||
import { dirname, delimiter, join } from 'node:path'
|
import { dirname, delimiter, join } from 'node:path'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths'
|
import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths'
|
||||||
|
|
||||||
const DEFAULT_PORT = 8648
|
const DEFAULT_PORT = 8748
|
||||||
const READY_TIMEOUT_MS = 30_000
|
const READY_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
let serverProc: ChildProcess | null = null
|
let serverProc: ChildProcess | null = null
|
||||||
@@ -50,6 +51,43 @@ export function getServerUrl(port = DEFAULT_PORT): string {
|
|||||||
return `http://127.0.0.1:${port}`
|
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> {
|
export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
||||||
ensureNativeModules()
|
ensureNativeModules()
|
||||||
const token = ensureToken()
|
const token = ensureToken()
|
||||||
@@ -71,6 +109,8 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
const bundledPython = isWin
|
const bundledPython = isWin
|
||||||
? join(pythonDir(), 'python.exe')
|
? join(pythonDir(), 'python.exe')
|
||||||
: join(pythonDir(), 'bin', 'python3')
|
: join(pythonDir(), 'bin', 'python3')
|
||||||
|
const bridgePort = await getFreeTcpPort()
|
||||||
|
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
||||||
|
|
||||||
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
// Run via Electron's "run as Node" mode — Electron binary doubles as Node.
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
@@ -85,10 +125,11 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
// unix socket is rejected on macOS in some EDR/sandbox setups (silent
|
// 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
|
// SIGKILL of the bridge child within ~150ms). TCP on 127.0.0.1 works
|
||||||
// identically and avoids the issue cross-platform.
|
// identically and avoids the issue cross-platform.
|
||||||
HERMES_AGENT_BRIDGE_ENDPOINT: 'tcp://127.0.0.1:18765',
|
HERMES_AGENT_BRIDGE_ENDPOINT: `tcp://127.0.0.1:${bridgePort}`,
|
||||||
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
|
// Force TCP for worker endpoints too (upstream #1106). Same EDR/sandbox
|
||||||
// reason as above — default ipc:// unix sockets in /tmp get killed.
|
// reason as above — default ipc:// unix sockets in /tmp get killed.
|
||||||
HERMES_AGENT_BRIDGE_WORKER_TRANSPORT: 'tcp',
|
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.
|
// And for preview-mode bridges spawned by the in-app update controller.
|
||||||
HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT: 'tcp',
|
HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT: 'tcp',
|
||||||
// Suppress the npm-registry update prompt (upstream #1105). hermes-web-ui
|
// Suppress the npm-registry update prompt (upstream #1105). hermes-web-ui
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function stripCredentialFlag(text: string): string {
|
|||||||
|
|
||||||
function installFetchPatch(): void {
|
function installFetchPatch(): void {
|
||||||
const origFetch = window.fetch.bind(window)
|
const origFetch = window.fetch.bind(window)
|
||||||
window.fetch = async (input, init) => {
|
const patchedFetch = (async (input, init) => {
|
||||||
const res = await origFetch(input, init)
|
const res = await origFetch(input, init)
|
||||||
try {
|
try {
|
||||||
const url = typeof input === 'string' ? input : (input as Request).url
|
const url = typeof input === 'string' ? input : (input as Request).url
|
||||||
@@ -73,7 +73,8 @@ function installFetchPatch(): void {
|
|||||||
}
|
}
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
return res
|
return res
|
||||||
}
|
}) as typeof window.fetch
|
||||||
|
window.fetch = patchedFetch
|
||||||
|
|
||||||
const OrigXHR = window.XMLHttpRequest
|
const OrigXHR = window.XMLHttpRequest
|
||||||
type XHRWithDesktop = XMLHttpRequest & { __hermesDesktopUrl?: string }
|
type XHRWithDesktop = XMLHttpRequest & { __hermesDesktopUrl?: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user