diff --git a/.github/workflows/desktop-manual-build.yml b/.github/workflows/desktop-manual-build.yml index 8c9b1a8..18d0edf 100644 --- a/.github/workflows/desktop-manual-build.yml +++ b/.github/workflows/desktop-manual-build.yml @@ -71,15 +71,19 @@ jobs: "packages/desktop/release/latest*.yml" ;; darwin-arm64) - write_common_outputs "macOS arm64" "macos-14" "--mac dmg --arm64" "desktop-darwin-arm64" \ + write_common_outputs "macOS arm64" "macos-14" "--mac dmg zip --arm64" "desktop-darwin-arm64" \ "packages/desktop/release/*.dmg" \ "packages/desktop/release/*.dmg.blockmap" \ + "packages/desktop/release/*.zip" \ + "packages/desktop/release/*.zip.blockmap" \ "packages/desktop/release/latest*.yml" ;; darwin-x64) - write_common_outputs "macOS x64" "macos-15-intel" "--mac dmg --x64" "desktop-darwin-x64" \ + write_common_outputs "macOS x64" "macos-15-intel" "--mac dmg zip --x64" "desktop-darwin-x64" \ "packages/desktop/release/*.dmg" \ "packages/desktop/release/*.dmg.blockmap" \ + "packages/desktop/release/*.zip" \ + "packages/desktop/release/*.zip.blockmap" \ "packages/desktop/release/latest*.yml" ;; linux-x64) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 854e617..821d3bb 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -28,18 +28,22 @@ jobs: runner: macos-14 target_os: darwin target_arch: arm64 - electron_target: "--mac dmg --arm64" + electron_target: "--mac dmg zip --arm64" artifact_files: | packages/desktop/release/*.dmg packages/desktop/release/*.dmg.blockmap + packages/desktop/release/*.zip + packages/desktop/release/*.zip.blockmap - label: macOS x64 runner: macos-15-intel target_os: darwin target_arch: x64 - electron_target: "--mac dmg --x64" + electron_target: "--mac dmg zip --x64" artifact_files: | packages/desktop/release/*.dmg packages/desktop/release/*.dmg.blockmap + packages/desktop/release/*.zip + packages/desktop/release/*.zip.blockmap - label: Windows x64 runner: windows-latest target_os: win32 @@ -48,6 +52,7 @@ jobs: artifact_files: | packages/desktop/release/*.exe packages/desktop/release/*.exe.blockmap + packages/desktop/release/latest*.yml - label: Linux x64 runner: ubuntu-22.04 target_os: linux @@ -56,6 +61,7 @@ jobs: artifact_files: | packages/desktop/release/*.AppImage packages/desktop/release/*.deb + packages/desktop/release/latest*.yml - label: Linux arm64 runner: ubuntu-22.04-arm target_os: linux @@ -63,6 +69,7 @@ jobs: electron_target: "--linux AppImage --arm64" artifact_files: | packages/desktop/release/*.AppImage + packages/desktop/release/latest*.yml steps: - name: Checkout repository @@ -148,9 +155,50 @@ jobs: shell: bash run: npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never + - name: Upload macOS update manifest artifact + if: matrix.target_os == 'darwin' + uses: actions/upload-artifact@v4 + with: + name: latest-mac-${{ matrix.target_arch }} + path: packages/desktop/release/latest-mac.yml + if-no-files-found: error + retention-days: 1 + - name: Upload artifacts to release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} fail_on_unmatched_files: true files: ${{ matrix.artifact_files }} + + mac-update-manifest: + name: Merge macOS updater manifest + needs: desktop + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || github.event.inputs.tag }} + + - name: Download macOS update manifests + uses: actions/download-artifact@v4 + with: + pattern: latest-mac-* + path: /tmp/hermes-mac-manifests + merge-multiple: false + + - name: Merge macOS update manifests + shell: bash + run: | + node packages/desktop/scripts/merge-mac-latest-yml.mjs \ + /tmp/hermes-mac-manifests/latest-mac-arm64/latest-mac.yml \ + /tmp/hermes-mac-manifests/latest-mac-x64/latest-mac.yml \ + > /tmp/latest-mac.yml + + - name: Upload merged macOS updater manifest to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} + fail_on_unmatched_files: true + files: /tmp/latest-mac.yml diff --git a/docs/harness/validation.md b/docs/harness/validation.md index 3159287..5371b2c 100644 --- a/docs/harness/validation.md +++ b/docs/harness/validation.md @@ -51,7 +51,7 @@ Expected desktop release outputs: | Target | Required release globs | | --- | --- | -| macOS | `*.dmg`, `*.dmg.blockmap`, `latest*.yml` | +| macOS | `*.dmg`, `*.dmg.blockmap`, `*.zip`, `*.zip.blockmap`, `latest*.yml` | | Windows | `*.exe`, `*.exe.blockmap`, `latest*.yml` | | Linux x64 | `*.AppImage`, `*.deb`, `latest*.yml` | | Linux arm64 | `*.AppImage`, `latest*.yml` | diff --git a/package-lock.json b/package-lock.json index ddec392..ab84cce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hermes-web-ui", - "version": "0.6.7", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hermes-web-ui", - "version": "0.6.7", + "version": "0.6.8", "license": "BSL-1.1", "dependencies": { "@vscode/markdown-it-katex": "^1.1.2", diff --git a/package.json b/package.json index b66fb5b..bdc3f7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.6.7", + "version": "0.6.8", "description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration", "repository": { "type": "git", @@ -131,4 +131,4 @@ "vue-virtual-scroller": "^3.0.4", "ws": "^8.20.0" } -} +} \ No newline at end of file diff --git a/packages/desktop/build/trayTemplate.png b/packages/desktop/build/trayTemplate.png new file mode 100644 index 0000000..c458a32 Binary files /dev/null and b/packages/desktop/build/trayTemplate.png differ diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index d0d227d..f4209db 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -6,6 +6,10 @@ 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 @@ -25,6 +29,7 @@ extraResources: to: "build" filter: - "icon.png" + - "trayTemplate.png" - from: "../.." to: "webui" filter: @@ -49,6 +54,8 @@ mac: target: - target: dmg arch: [arm64, x64] + - target: zip + arch: [arm64, x64] category: public.app-category.developer-tools hardenedRuntime: true gatekeeperAssess: false diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 0a2ad77..3d2081f 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "hermes-studio", - "version": "0.6.7", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hermes-studio", - "version": "0.6.7", + "version": "0.6.8", "license": "MIT", "dependencies": { "electron-updater": "^6.3.9" diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ef37858..8906cd5 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "hermes-studio", - "version": "0.6.7", + "version": "0.6.8", "description": "Hermes Studio desktop distribution with bundled Python runtime and hermes-agent", "homepage": "https://ekkolearnai.com", "author": { @@ -33,4 +33,4 @@ "dependencies": { "electron-updater": "^6.3.9" } -} +} \ No newline at end of file diff --git a/packages/desktop/src/main/desktop-i18n.ts b/packages/desktop/src/main/desktop-i18n.ts new file mode 100644 index 0000000..8d5ccdd --- /dev/null +++ b/packages/desktop/src/main/desktop-i18n.ts @@ -0,0 +1,279 @@ +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.packagedOnlyMessage' + | 'common.ok' + +const supportedLocales: DesktopLocale[] = ['en', 'zh', 'zh-TW', 'ja', 'ko', 'fr', 'es', 'de', 'pt'] + +const translations: Record> = { + 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.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.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.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.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.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.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.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.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.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 { + const message = translations[resolveLocale()][key] || translations.en[key] + return Object.entries(params).reduce( + (value, [name, replacement]) => value.replaceAll(`{${name}}`, replacement), + message, + ) +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 86a227e..345e996 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -1,13 +1,106 @@ -import { app, BrowserWindow, Menu, shell, ipcMain } from 'electron' +import { app, BrowserWindow, Menu, Tray, shell, ipcMain, nativeImage } from 'electron' import { join } from 'node:path' import { startWebUiServer, stopWebUiServer, getToken } from './webui-server' -import { desktopIcon, hermesBinExists, hermesBin } from './paths' -import { initAutoUpdater } from './updater' +import { desktopIcon, desktopTrayTemplateIcon, hermesBinExists, hermesBin } from './paths' +import { checkForDesktopUpdates, initAutoUpdater } from './updater' +import { t } from './desktop-i18n' const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748 +const START_HIDDEN = process.argv.includes('--hidden') let mainWindow: BrowserWindow | null = null let serverUrl: string | null = null +let tray: Tray | null = null +let isQuitting = 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 getOpenAtLogin(): boolean { + return app.getLoginItemSettings().openAtLogin +} + +function setOpenAtLogin(openAtLogin: boolean) { + app.setLoginItemSettings({ + openAtLogin, + openAsHidden: true, + path: process.execPath, + args: ['--hidden'], + }) +} + +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() : desktopIcon() + const icon = nativeImage.createFromPath(source).resize({ + width: process.platform === 'darwin' ? 18 : 16, + height: process.platform === 'darwin' ? 18 : 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({ @@ -18,6 +111,7 @@ function createWindow() { title: 'Hermes Studio', backgroundColor: '#1a1a1a', autoHideMenuBar: true, + show: !START_HIDDEN, ...(process.platform === 'linux' ? { icon: desktopIcon() } : {}), webPreferences: { preload: join(__dirname, '..', 'preload', 'index.js'), @@ -27,6 +121,16 @@ function createWindow() { }, }) + 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')) { @@ -44,6 +148,7 @@ function createWindow() { } else { mainWindow.loadURL(splashHtml()) } + updateTrayMenu() } function splashHtml(): string { @@ -95,10 +200,7 @@ if (!gotLock) { app.quit() } else { app.on('second-instance', () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.focus() - } + showMainWindow() }) app.whenReady().then(() => { @@ -107,25 +209,34 @@ if (!gotLock) { // visual clutter. macOS keeps a menu (system requirement) but Electron's // default is fine there. if (process.platform !== 'darwin') Menu.setApplicationMenu(null) + createTray() createWindow() bootstrap() - initAutoUpdater() + initAutoUpdater({ + beforeQuitAndInstall: () => { + isQuitting = true + }, + }) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } else if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.show() - mainWindow.focus() + showMainWindow() } }) }) app.on('window-all-closed', () => { - if (process.platform !== 'darwin') app.quit() + 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) diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index 15bb5fb..8e9801a 100644 --- a/packages/desktop/src/main/paths.ts +++ b/packages/desktop/src/main/paths.ts @@ -45,6 +45,11 @@ export function desktopIcon(): string { return resolve(app.getAppPath(), 'build', 'icon.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') } diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index 817cf64..38f878e 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -1,46 +1,165 @@ import { app, dialog } from 'electron' -import { autoUpdater } from 'electron-updater' +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 -export function initAutoUpdater() { +const LATEST_RELEASE_URL = 'https://api.github.com/repos/EKKOLearnAI/hermes-web-ui/releases/latest' +const CLOUDFLARE_DOWNLOAD_BASE_URL = 'https://download.ekkolearnai.com' + +interface GitHubRelease { + tag_name?: string +} + +interface AutoUpdaterOptions { + beforeQuitAndInstall?: () => void +} + +let options: AutoUpdaterOptions = {} + +async function getLatestReleaseTag(): Promise { + const res = await fetch(LATEST_RELEASE_URL, { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': `Hermes-Studio/${app.getVersion()}`, + }, + }) + if (!res.ok) throw new Error(`GitHub returned ${res.status}`) + + const release = await res.json() as GitHubRelease + const tag = release.tag_name?.trim() + if (!tag) throw new Error('Latest release response did not include a tag') + return tag.startsWith('v') ? tag : `v${tag}` +} + +async function configureFeedFromLatestRelease(): Promise { + const tag = await getLatestReleaseTag() + autoUpdater.setFeedURL({ + provider: 'generic', + url: `${CLOUDFLARE_DOWNLOAD_BASE_URL}/${tag}`, + }) +} + +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 detail = err instanceof Error ? err.message : String(err) + dialog.showMessageBox({ + type: 'error', + title: t('update.failedTitle'), + message: t('update.failedMessage'), + detail, + 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 - if (process.env.HERMES_DESKTOP_ENABLE_AUTO_UPDATE !== 'true') return 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', () => { + 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('update-downloaded', async info => { + 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: 'Update ready', - message: `Hermes Studio ${info.version} is ready to install.`, - detail: 'Restart now to apply the update, or it will be installed on next quit.', - buttons: ['Restart now', 'Later'], + 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) autoUpdater.quitAndInstall() + if (response === 0) { + options.beforeQuitAndInstall?.() + autoUpdater.quitAndInstall() + } }) - autoUpdater.checkForUpdates().catch(err => { - console.error('[updater] initial check failed:', err) - }) + 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(() => { - autoUpdater.checkForUpdates().catch(() => undefined) + checkForDesktopUpdates(false).catch(() => undefined) }, 6 * 60 * 60 * 1000) } + +export async function checkForDesktopUpdates(manual: boolean): Promise { + 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 + } +} diff --git a/packages/desktop/src/main/webui-server.ts b/packages/desktop/src/main/webui-server.ts index 0fdd218..c5bbc34 100644 --- a/packages/desktop/src/main/webui-server.ts +++ b/packages/desktop/src/main/webui-server.ts @@ -15,6 +15,27 @@ 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 @@ -207,12 +228,14 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise { // 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 bundledPythonNoWindow = isWin ? join(pythonDir(), 'pythonw.exe') - : bundledPython + : join(pythonDir(), 'bin', 'python3') + const bundledPython = isWin && existsSync(bundledPythonNoWindow) + ? bundledPythonNoWindow + : isWin + ? join(pythonDir(), 'python.exe') + : join(pythonDir(), 'bin', 'python3') const bridgePort = await getFreeTcpPort() const workerPortBase = await getFreeTcpPortInRange(20000, 59000) const loginShellPath = await getLoginShellPath() @@ -312,7 +335,7 @@ export function stopWebUiServer(): Promise { if (!serverProc || serverProc.killed) return resolve() const proc = serverProc const timer = setTimeout(() => { - try { proc.kill('SIGKILL') } catch { /* */ } + killProcessTree(proc) resolve() }, 3000) proc.once('exit', () => { diff --git a/packages/server/src/services/hermes/agent-bridge/manager.ts b/packages/server/src/services/hermes/agent-bridge/manager.ts index 6d91ac1..fc5d0b3 100644 --- a/packages/server/src/services/hermes/agent-bridge/manager.ts +++ b/packages/server/src/services/hermes/agent-bridge/manager.ts @@ -1,6 +1,6 @@ import { execFileSync, spawn, type ChildProcess } from 'child_process' import { existsSync, readFileSync } from 'fs' -import { createServer } from 'net' +import { createConnection, createServer } from 'net' import { dirname, isAbsolute, join, resolve } from 'path' import { logger } from '../../logger' import { detectHermesHome, getHermesBin } from '../hermes-path' @@ -245,6 +245,10 @@ function isTcpEndpoint(endpoint: string): boolean { return endpoint.startsWith('tcp://') } +function isDesktopRuntime(): boolean { + return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true' +} + async function canListenTcpEndpoint(endpoint: string): Promise { const url = new URL(endpoint) const host = url.hostname || '127.0.0.1' @@ -264,6 +268,26 @@ async function canListenTcpEndpoint(endpoint: string): Promise { }) } +async function canConnectTcpEndpoint(endpoint: string): Promise { + const url = new URL(endpoint) + const host = url.hostname || '127.0.0.1' + const port = Number(url.port) + if (!Number.isFinite(port) || port <= 0) return false + + return await new Promise((resolveConnected) => { + const socket = createConnection({ port, host }) + const done = (connected: boolean) => { + socket.removeAllListeners() + socket.destroy() + resolveConnected(connected) + } + socket.setTimeout(250) + socket.once('connect', () => done(true)) + socket.once('timeout', () => done(false)) + socket.once('error', () => done(false)) + }) +} + function tcpEndpointPort(endpoint: string): number | undefined { if (!isTcpEndpoint(endpoint)) return undefined const url = new URL(endpoint) @@ -416,6 +440,16 @@ export class AgentBridgeManager { child.off('error', onError) } + const markReady = () => { + if (readyResolved) return + this.ready = true + this.restartAttempts = 0 + readyResolved = true + cleanup() + child.stdout?.off('data', onStdout) + resolveReady() + } + const onError = (err: Error) => { cleanup() child.stdout?.off('data', onStdout) @@ -443,11 +477,7 @@ export class AgentBridgeManager { try { const parsed = JSON.parse(line) if (parsed?.event === 'ready') { - this.ready = true - this.restartAttempts = 0 - readyResolved = true - cleanup() - resolveReady() + markReady() return } } catch {} @@ -458,6 +488,19 @@ export class AgentBridgeManager { child.once('error', onError) child.once('exit', onExitBeforeReady) child.stdout?.on('data', onStdout) + + if (isDesktopRuntime() && isTcpEndpoint(this.endpoint)) { + const probe = async () => { + while (!readyResolved && !child.killed) { + if (await canConnectTcpEndpoint(this.endpoint)) { + markReady() + return + } + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + probe().catch(onError) + } }) logger.info('[agent-bridge] ready at %s', this.endpoint)