From 96bdf8d1af089f65f1f435c807a71ac8f334b0d2 Mon Sep 17 00:00:00 2001 From: sir1st <1174702930@qq.com> Date: Sun, 31 May 2026 09:17:49 +0800 Subject: [PATCH] fix Windows desktop startup readiness (#1167) * fix desktop startup readiness on windows * add manual desktop build workflow * hide Windows desktop server process window * hide Windows Python bridge worker windows * use no-window Python for Windows desktop CLI calls --------- Co-authored-by: xingzhi --- .github/workflows/desktop-manual-build.yml | 203 ++++++++++++++++++ packages/desktop/src/main/webui-server.ts | 20 +- packages/server/src/index.ts | 43 ++-- .../hermes/agent-bridge/hermes_bridge.py | 10 + .../src/services/hermes/gateway-autostart.ts | 5 +- .../src/services/hermes/hermes-process.ts | 8 +- tests/server/gateway-autostart.test.ts | 4 + tests/server/hermes-process.test.ts | 12 +- 8 files changed, 276 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/desktop-manual-build.yml diff --git a/.github/workflows/desktop-manual-build.yml b/.github/workflows/desktop-manual-build.yml new file mode 100644 index 0000000..8c9b1a8 --- /dev/null +++ b/.github/workflows/desktop-manual-build.yml @@ -0,0 +1,203 @@ +name: Manual Desktop Build + +on: + workflow_dispatch: + inputs: + target_os: + description: "Desktop target OS" + required: true + type: choice + default: win32 + options: + - win32 + - darwin + - linux + target_arch: + description: "Desktop target architecture" + required: true + type: choice + default: x64 + options: + - x64 + - arm64 + release_tag: + description: "Optional release tag to attach artifacts to" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: desktop-manual-${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + label: ${{ steps.target.outputs.label }} + runner: ${{ steps.target.outputs.runner }} + target_os: ${{ steps.target.outputs.target_os }} + target_arch: ${{ steps.target.outputs.target_arch }} + electron_target: ${{ steps.target.outputs.electron_target }} + artifact_name: ${{ steps.target.outputs.artifact_name }} + artifact_files: ${{ steps.target.outputs.artifact_files }} + steps: + - name: Select requested target + id: target + shell: bash + run: | + write_common_outputs() { + { + echo "label=$1" + echo "runner=$2" + echo "target_os=${{ github.event.inputs.target_os }}" + echo "target_arch=${{ github.event.inputs.target_arch }}" + echo "electron_target=$3" + echo "artifact_name=$4" + echo "artifact_files<> "$GITHUB_OUTPUT" + } + + case "${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}" in + win32-x64) + write_common_outputs "Windows x64" "windows-latest" "--win nsis --x64" "desktop-win32-x64" \ + "packages/desktop/release/*.exe" \ + "packages/desktop/release/*.exe.blockmap" \ + "packages/desktop/release/latest*.yml" + ;; + darwin-arm64) + write_common_outputs "macOS arm64" "macos-14" "--mac dmg --arm64" "desktop-darwin-arm64" \ + "packages/desktop/release/*.dmg" \ + "packages/desktop/release/*.dmg.blockmap" \ + "packages/desktop/release/latest*.yml" + ;; + darwin-x64) + write_common_outputs "macOS x64" "macos-15-intel" "--mac dmg --x64" "desktop-darwin-x64" \ + "packages/desktop/release/*.dmg" \ + "packages/desktop/release/*.dmg.blockmap" \ + "packages/desktop/release/latest*.yml" + ;; + linux-x64) + write_common_outputs "Linux x64" "ubuntu-22.04" "--linux AppImage deb --x64" "desktop-linux-x64" \ + "packages/desktop/release/*.AppImage" \ + "packages/desktop/release/*.deb" \ + "packages/desktop/release/latest*.yml" + ;; + linux-arm64) + write_common_outputs "Linux arm64" "ubuntu-22.04-arm" "--linux AppImage --arm64" "desktop-linux-arm64" \ + "packages/desktop/release/*.AppImage" \ + "packages/desktop/release/latest*.yml" + ;; + *) + echo "Unsupported desktop target: ${{ github.event.inputs.target_os }} ${{ github.event.inputs.target_arch }}" >&2 + exit 1 + ;; + esac + + desktop: + name: Desktop (${{ needs.validate.outputs.label }}) + needs: validate + runs-on: ${{ needs.validate.outputs.runner }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + packages/desktop/package-lock.json + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install web UI dependencies + run: | + npm ci --ignore-scripts + npm rebuild node-pty + + - name: Build web UI + run: npm run build + + - name: Keep production web UI dependencies only + run: npm prune --omit=dev --no-audit --no-fund + + - name: Install desktop dependencies + run: npm ci --prefix packages/desktop --no-audit --no-fund + + - name: Prepare bundled Python + env: + TARGET_OS: ${{ needs.validate.outputs.target_os }} + TARGET_ARCH: ${{ needs.validate.outputs.target_arch }} + run: npm --prefix packages/desktop run prepare:python + + - name: Configure macOS signing + if: needs.validate.outputs.target_os == 'darwin' + shell: bash + env: + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + MAC_APPLE_ID: ${{ secrets.APPLE_ID }} + MAC_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + MAC_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + write_env() { + local name="$1" + local value="$2" + if [ -n "$value" ]; then + { + echo "$name<> "$GITHUB_ENV" + fi + } + + if [ -z "${MAC_CSC_LINK:-}" ]; then + echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV" + echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV" + echo "No macOS signing certificate configured; building unsigned and skipping notarization." + exit 0 + fi + + write_env "CSC_LINK" "$MAC_CSC_LINK" + write_env "CSC_KEY_PASSWORD" "$MAC_CSC_KEY_PASSWORD" + + if [ -n "${MAC_APPLE_ID:-}" ] && [ -n "${MAC_APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${MAC_APPLE_TEAM_ID:-}" ]; then + write_env "APPLE_ID" "$MAC_APPLE_ID" + write_env "APPLE_APP_SPECIFIC_PASSWORD" "$MAC_APPLE_APP_SPECIFIC_PASSWORD" + write_env "APPLE_TEAM_ID" "$MAC_APPLE_TEAM_ID" + echo "macOS signing and notarization are configured." + else + echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV" + echo "macOS signing certificate configured; Apple notarization credentials incomplete, skipping notarization." + fi + + - name: Build desktop artifact + shell: bash + run: npm --prefix packages/desktop run dist -- ${{ needs.validate.outputs.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.validate.outputs.artifact_name }} + path: ${{ needs.validate.outputs.artifact_files }} + if-no-files-found: error + retention-days: 7 + + - name: Upload artifacts to release + if: github.event.inputs.release_tag != '' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.release_tag }} + fail_on_unmatched_files: true + files: ${{ needs.validate.outputs.artifact_files }} diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index 39abb30..0fdd218 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -9,12 +9,23 @@ import { app } from 'electron' import { webuiServerEntry, webuiDir, hermesBin, webUiHome, hermesHome, tokenFile, pythonDir } from './paths' const DEFAULT_PORT = 8748 -const READY_TIMEOUT_MS = 30_000 +const DEFAULT_READY_TIMEOUT_MS = 30_000 const execFileAsync = promisify(execFile) let serverProc: ChildProcess | null = null let cachedToken: string | null = null +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 ensureToken(): string { if (cachedToken) return cachedToken const file = tokenFile() @@ -199,6 +210,9 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { const bundledPython = isWin ? join(pythonDir(), 'python.exe') : join(pythonDir(), 'bin', 'python3') + const bundledPythonNoWindow = isWin + ? join(pythonDir(), 'pythonw.exe') + : bundledPython const bridgePort = await getFreeTcpPort() const workerPortBase = await getFreeTcpPortInRange(20000, 59000) const loginShellPath = await getLoginShellPath() @@ -219,6 +233,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { HERMES_DESKTOP: 'true', HERMES_BIN: hermesBin(), HERMES_AGENT_BRIDGE_PYTHON: bundledPython, + HERMES_AGENT_CLI_PYTHON: existsSync(bundledPythonNoWindow) ? bundledPythonNoWindow : bundledPython, HERMES_AGENT_ROOT: pythonDir(), // Force TCP loopback for the agent bridge. The default `ipc:///tmp/...` // unix socket is rejected on macOS in some EDR/sandbox setups (silent @@ -256,6 +271,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { serverProc = spawn(process.execPath, [entry], { env, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, }) serverProc.stdout?.on('data', (chunk: Buffer) => { @@ -272,7 +288,7 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { } }) - await waitForReady(port, READY_TIMEOUT_MS) + await waitForReady(port, readyTimeoutMs()) return getServerUrl(port) } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 86f5587..5dfa166 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,7 +18,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat' import { setChatRunServer } from './routes/hermes/chat-run' import { GroupChatServer } from './services/hermes/group-chat' import { ChatRunSocket } from './services/hermes/run-chat' -import { startAgentBridgeManager } from './services/hermes/agent-bridge' +import { getAgentBridgeManager, startAgentBridgeManager } from './services/hermes/agent-bridge' import { HermesSkillInjector } from './services/hermes/skill-injector' import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart' import { logger } from './services/logger' @@ -81,6 +81,28 @@ function safeNetworkInterfaces() { } } +function startRuntimeServicesAfterListen(): void { + void (async () => { + try { + await ensureProfileGatewaysRunning() + console.log('[bootstrap] profile gateways checked') + } catch (err) { + logger.warn(err, '[bootstrap] failed to ensure profile gateways') + console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err) + } + })() + + void (async () => { + try { + agentBridgeManager = await startAgentBridgeManager() + console.log('[bootstrap] agent bridge started') + } catch (err) { + logger.warn(err, '[bootstrap] agent bridge failed to start') + console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err) + } + })() +} + export async function bootstrap() { console.log(`hermes-web-ui v${APP_VERSION} starting...`) await mkdir(config.uploadDir, { recursive: true }) @@ -109,23 +131,7 @@ export async function bootstrap() { console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err) } - try { - await ensureProfileGatewaysRunning() - console.log('[bootstrap] profile gateways checked') - } catch (err) { - logger.warn(err, '[bootstrap] failed to ensure profile gateways') - console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err) - } - const app = new Koa() - - try { - agentBridgeManager = await startAgentBridgeManager() - console.log('[bootstrap] agent bridge started') - } catch (err) { - logger.warn(err, '[bootstrap] agent bridge failed to start') - console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err) - } await new Promise(resolve => setTimeout(resolve, 1000)) // Initialize all web-ui SQLite tables const { initAllStores } = await import('./db/hermes/init') @@ -201,6 +207,9 @@ export async function bootstrap() { console.log(`Log: ${config.appHome}/logs/server.log`) logger.info('Server: http://localhost:%d (LAN: http://%s:%d)', config.port, localIp, config.port) + agentBridgeManager = getAgentBridgeManager() + startRuntimeServicesAfterListen() + // Restore group chat agents after server is ready. groupChatServer.restoreWhenReady() diff --git a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py index 9d818b1..657b052 100755 --- a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +++ b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py @@ -65,6 +65,12 @@ def _positive_int(value: str | None) -> int | None: return parsed if parsed > 0 else None +def _hidden_subprocess_kwargs() -> dict[str, int]: + if os.name != "nt": + return {} + return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)} + + def _process_exists(pid: int) -> bool: if pid <= 0: return False @@ -76,6 +82,7 @@ def _process_exists(pid: int) -> bool: capture_output=True, text=True, timeout=5, + **_hidden_subprocess_kwargs(), ) return str(pid) in (result.stdout or "") except Exception: @@ -2785,6 +2792,7 @@ class WorkerProcess: stderr=subprocess.PIPE, text=True, bufsize=1, + **_hidden_subprocess_kwargs(), ) self._pipe_stderr() self._wait_ready() @@ -2957,6 +2965,7 @@ def _windows_listening_pids_on_port(port: int) -> list[int]: encoding=_platform_text_encoding(), errors="ignore", timeout=5, + **_hidden_subprocess_kwargs(), ) except Exception: return [] @@ -2999,6 +3008,7 @@ def _kill_windows_endpoint_occupants(endpoint: str) -> None: capture_output=True, text=True, timeout=10, + **_hidden_subprocess_kwargs(), ) except Exception as exc: print( diff --git a/packages/server/src/services/hermes/gateway-autostart.ts b/packages/server/src/services/hermes/gateway-autostart.ts index 5040761..00c79b8 100644 --- a/packages/server/src/services/hermes/gateway-autostart.ts +++ b/packages/server/src/services/hermes/gateway-autostart.ts @@ -52,10 +52,11 @@ export function shouldUseManagedGatewayRun(): boolean { process.platform === 'win32' } -export function shouldUseManagedGatewayRunForAutostart(): boolean { +export function shouldUseManagedGatewayRunForAutostart(platform: NodeJS.Platform = process.platform): boolean { return envFlagEnabled('HERMES_WEB_UI_MANAGED_GATEWAY') || isDockerRuntime() || - isTermuxRuntime() + isTermuxRuntime() || + platform === 'win32' } export function gatewayStatusLooksRunning(output: string): boolean { diff --git a/packages/server/src/services/hermes/hermes-process.ts b/packages/server/src/services/hermes/hermes-process.ts index f1df810..eca150c 100644 --- a/packages/server/src/services/hermes/hermes-process.ts +++ b/packages/server/src/services/hermes/hermes-process.ts @@ -17,18 +17,20 @@ export function resolveHermesBin(customBin?: string): string { return customBin?.trim() || process.env.HERMES_BIN?.trim() || 'hermes' } -function bundledPythonForWindows(hermesBin: string): string | null { - const envPython = process.env.HERMES_AGENT_BRIDGE_PYTHON?.trim() +function bundledCliPythonForWindows(hermesBin: string): string | null { + const envPython = process.env.HERMES_AGENT_CLI_PYTHON?.trim() if (envPython) return envPython if (basename(hermesBin).toLowerCase() !== 'hermes.exe') return null + const pythonw = resolve(dirname(hermesBin), '..', 'pythonw.exe') + if (existsSync(pythonw)) return pythonw const python = resolve(dirname(hermesBin), '..', 'python.exe') return existsSync(python) ? python : null } export function resolveHermesInvocation(hermesBin = resolveHermesBin()): HermesInvocation { if (process.platform === 'win32') { - const python = bundledPythonForWindows(hermesBin) + const python = bundledCliPythonForWindows(hermesBin) if (python) return { command: python, argsPrefix: ['-m', 'hermes_cli.main'] } } diff --git a/tests/server/gateway-autostart.test.ts b/tests/server/gateway-autostart.test.ts index 679522c..fce8860 100644 --- a/tests/server/gateway-autostart.test.ts +++ b/tests/server/gateway-autostart.test.ts @@ -66,6 +66,10 @@ describe('gateway autostart status parsing', () => { } }) + it('uses managed gateway autostart on Windows', () => { + expect(shouldUseManagedGatewayRunForAutostart('win32')).toBe(true) + }) + it('detects managed gateway state files with a live pid', () => { const dir = mkdtempSync(join(tmpdir(), 'hermes-gateway-state-')) try { diff --git a/tests/server/hermes-process.test.ts b/tests/server/hermes-process.test.ts index ebb29b9..3972a0a 100644 --- a/tests/server/hermes-process.test.ts +++ b/tests/server/hermes-process.test.ts @@ -22,6 +22,7 @@ function setPlatform(platform: NodeJS.Platform): void { afterEach(() => { execFileCalls.length = 0 delete process.env.HERMES_AGENT_BRIDGE_PYTHON + delete process.env.HERMES_AGENT_CLI_PYTHON if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) vi.resetModules() }) @@ -29,7 +30,7 @@ afterEach(() => { describe('Hermes process invocation', () => { it('bypasses the uv hermes.exe trampoline on Windows packaged installs', async () => { setPlatform('win32') - process.env.HERMES_AGENT_BRIDGE_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\python.exe' + process.env.HERMES_AGENT_CLI_PYTHON = 'C:\\Users\\me\\AppData\\Local\\Programs\\Hermes Studio\\resources\\python\\pythonw.exe' const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') const result = await execHermesWithBin( @@ -40,25 +41,26 @@ describe('Hermes process invocation', () => { expect(result.stdout).toBe('ok\n') expect(execFileCalls[0]).toMatchObject({ - command: process.env.HERMES_AGENT_BRIDGE_PYTHON, + command: process.env.HERMES_AGENT_CLI_PYTHON, args: ['-m', 'hermes_cli.main', 'kanban', '--board', 'default', 'create', 'demo', '--json'], }) }) - it('discovers sibling python.exe for a Windows hermes.exe launcher', async () => { + it('prefers sibling pythonw.exe for a Windows hermes.exe launcher', async () => { setPlatform('win32') - const root = mkdtempSync(join(tmpdir(), 'hermes-process-')) + const root = mkdtempSync(join(tmpdir(), 'hermes-process-')) try { const scripts = join(root, 'Scripts') mkdirSync(scripts) writeFileSync(join(root, 'python.exe'), '') + writeFileSync(join(root, 'pythonw.exe'), '') writeFileSync(join(scripts, 'hermes.exe'), '') const { execHermesWithBin } = await import('../../packages/server/src/services/hermes/hermes-process') await execHermesWithBin(join(scripts, 'hermes.exe'), ['--version'], { windowsHide: true }) expect(execFileCalls[0]).toMatchObject({ - command: join(root, 'python.exe'), + command: join(root, 'pythonw.exe'), args: ['-m', 'hermes_cli.main', '--version'], }) } finally {