add desktop tray and updater support (#1193)
This commit is contained in:
@@ -71,15 +71,19 @@ jobs:
|
|||||||
"packages/desktop/release/latest*.yml"
|
"packages/desktop/release/latest*.yml"
|
||||||
;;
|
;;
|
||||||
darwin-arm64)
|
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" \
|
||||||
"packages/desktop/release/*.dmg.blockmap" \
|
"packages/desktop/release/*.dmg.blockmap" \
|
||||||
|
"packages/desktop/release/*.zip" \
|
||||||
|
"packages/desktop/release/*.zip.blockmap" \
|
||||||
"packages/desktop/release/latest*.yml"
|
"packages/desktop/release/latest*.yml"
|
||||||
;;
|
;;
|
||||||
darwin-x64)
|
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" \
|
||||||
"packages/desktop/release/*.dmg.blockmap" \
|
"packages/desktop/release/*.dmg.blockmap" \
|
||||||
|
"packages/desktop/release/*.zip" \
|
||||||
|
"packages/desktop/release/*.zip.blockmap" \
|
||||||
"packages/desktop/release/latest*.yml"
|
"packages/desktop/release/latest*.yml"
|
||||||
;;
|
;;
|
||||||
linux-x64)
|
linux-x64)
|
||||||
|
|||||||
@@ -28,18 +28,22 @@ jobs:
|
|||||||
runner: macos-14
|
runner: macos-14
|
||||||
target_os: darwin
|
target_os: darwin
|
||||||
target_arch: arm64
|
target_arch: arm64
|
||||||
electron_target: "--mac dmg --arm64"
|
electron_target: "--mac dmg zip --arm64"
|
||||||
artifact_files: |
|
artifact_files: |
|
||||||
packages/desktop/release/*.dmg
|
packages/desktop/release/*.dmg
|
||||||
packages/desktop/release/*.dmg.blockmap
|
packages/desktop/release/*.dmg.blockmap
|
||||||
|
packages/desktop/release/*.zip
|
||||||
|
packages/desktop/release/*.zip.blockmap
|
||||||
- label: macOS x64
|
- label: macOS x64
|
||||||
runner: macos-15-intel
|
runner: macos-15-intel
|
||||||
target_os: darwin
|
target_os: darwin
|
||||||
target_arch: x64
|
target_arch: x64
|
||||||
electron_target: "--mac dmg --x64"
|
electron_target: "--mac dmg zip --x64"
|
||||||
artifact_files: |
|
artifact_files: |
|
||||||
packages/desktop/release/*.dmg
|
packages/desktop/release/*.dmg
|
||||||
packages/desktop/release/*.dmg.blockmap
|
packages/desktop/release/*.dmg.blockmap
|
||||||
|
packages/desktop/release/*.zip
|
||||||
|
packages/desktop/release/*.zip.blockmap
|
||||||
- label: Windows x64
|
- label: Windows x64
|
||||||
runner: windows-latest
|
runner: windows-latest
|
||||||
target_os: win32
|
target_os: win32
|
||||||
@@ -48,6 +52,7 @@ jobs:
|
|||||||
artifact_files: |
|
artifact_files: |
|
||||||
packages/desktop/release/*.exe
|
packages/desktop/release/*.exe
|
||||||
packages/desktop/release/*.exe.blockmap
|
packages/desktop/release/*.exe.blockmap
|
||||||
|
packages/desktop/release/latest*.yml
|
||||||
- label: Linux x64
|
- label: Linux x64
|
||||||
runner: ubuntu-22.04
|
runner: ubuntu-22.04
|
||||||
target_os: linux
|
target_os: linux
|
||||||
@@ -56,6 +61,7 @@ jobs:
|
|||||||
artifact_files: |
|
artifact_files: |
|
||||||
packages/desktop/release/*.AppImage
|
packages/desktop/release/*.AppImage
|
||||||
packages/desktop/release/*.deb
|
packages/desktop/release/*.deb
|
||||||
|
packages/desktop/release/latest*.yml
|
||||||
- label: Linux arm64
|
- label: Linux arm64
|
||||||
runner: ubuntu-22.04-arm
|
runner: ubuntu-22.04-arm
|
||||||
target_os: linux
|
target_os: linux
|
||||||
@@ -63,6 +69,7 @@ jobs:
|
|||||||
electron_target: "--linux AppImage --arm64"
|
electron_target: "--linux AppImage --arm64"
|
||||||
artifact_files: |
|
artifact_files: |
|
||||||
packages/desktop/release/*.AppImage
|
packages/desktop/release/*.AppImage
|
||||||
|
packages/desktop/release/latest*.yml
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -148,9 +155,50 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never
|
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
|
- name: Upload artifacts to release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }}
|
tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }}
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
files: ${{ matrix.artifact_files }}
|
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
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Expected desktop release outputs:
|
|||||||
|
|
||||||
| Target | Required release globs |
|
| 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` |
|
| Windows | `*.exe`, `*.exe.blockmap`, `latest*.yml` |
|
||||||
| Linux x64 | `*.AppImage`, `*.deb`, `latest*.yml` |
|
| Linux x64 | `*.AppImage`, `*.deb`, `latest*.yml` |
|
||||||
| Linux arm64 | `*.AppImage`, `latest*.yml` |
|
| Linux arm64 | `*.AppImage`, `latest*.yml` |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.6.7",
|
"version": "0.6.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.6.7",
|
"version": "0.6.8",
|
||||||
"license": "BSL-1.1",
|
"license": "BSL-1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vscode/markdown-it-katex": "^1.1.2",
|
"@vscode/markdown-it-katex": "^1.1.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"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",
|
"description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -6,6 +6,10 @@ directories:
|
|||||||
output: release
|
output: release
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
|
||||||
|
publish:
|
||||||
|
provider: generic
|
||||||
|
url: https://download.ekkolearnai.com
|
||||||
|
|
||||||
# Don't auto-prune our root node_modules; we curate `files` and `extraResources` ourselves.
|
# Don't auto-prune our root node_modules; we curate `files` and `extraResources` ourselves.
|
||||||
buildDependenciesFromSource: false
|
buildDependenciesFromSource: false
|
||||||
nodeGypRebuild: false
|
nodeGypRebuild: false
|
||||||
@@ -25,6 +29,7 @@ extraResources:
|
|||||||
to: "build"
|
to: "build"
|
||||||
filter:
|
filter:
|
||||||
- "icon.png"
|
- "icon.png"
|
||||||
|
- "trayTemplate.png"
|
||||||
- from: "../.."
|
- from: "../.."
|
||||||
to: "webui"
|
to: "webui"
|
||||||
filter:
|
filter:
|
||||||
@@ -49,6 +54,8 @@ mac:
|
|||||||
target:
|
target:
|
||||||
- target: dmg
|
- target: dmg
|
||||||
arch: [arm64, x64]
|
arch: [arm64, x64]
|
||||||
|
- target: zip
|
||||||
|
arch: [arm64, x64]
|
||||||
category: public.app-category.developer-tools
|
category: public.app-category.developer-tools
|
||||||
hardenedRuntime: true
|
hardenedRuntime: true
|
||||||
gatekeeperAssess: false
|
gatekeeperAssess: false
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-studio",
|
"name": "hermes-studio",
|
||||||
"version": "0.6.7",
|
"version": "0.6.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hermes-studio",
|
"name": "hermes-studio",
|
||||||
"version": "0.6.7",
|
"version": "0.6.8",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.3.9"
|
"electron-updater": "^6.3.9"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-studio",
|
"name": "hermes-studio",
|
||||||
"version": "0.6.7",
|
"version": "0.6.8",
|
||||||
"description": "Hermes Studio desktop distribution 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": {
|
||||||
|
|||||||
@@ -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<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.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, string> = {}): string {
|
||||||
|
const message = translations[resolveLocale()][key] || translations.en[key]
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(value, [name, replacement]) => value.replaceAll(`{${name}}`, replacement),
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { join } from 'node:path'
|
||||||
import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
|
import { startWebUiServer, stopWebUiServer, getToken } from './webui-server'
|
||||||
import { desktopIcon, hermesBinExists, hermesBin } from './paths'
|
import { desktopIcon, desktopTrayTemplateIcon, hermesBinExists, hermesBin } from './paths'
|
||||||
import { initAutoUpdater } from './updater'
|
import { checkForDesktopUpdates, initAutoUpdater } from './updater'
|
||||||
|
import { t } from './desktop-i18n'
|
||||||
|
|
||||||
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
|
const PORT = Number(process.env.HERMES_DESKTOP_PORT) || 8748
|
||||||
|
const START_HIDDEN = process.argv.includes('--hidden')
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let serverUrl: string | 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() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -18,6 +111,7 @@ function createWindow() {
|
|||||||
title: 'Hermes Studio',
|
title: 'Hermes Studio',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
show: !START_HIDDEN,
|
||||||
...(process.platform === 'linux' ? { icon: desktopIcon() } : {}),
|
...(process.platform === 'linux' ? { icon: desktopIcon() } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, '..', 'preload', 'index.js'),
|
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
|
// External links → system browser
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) {
|
if (url.startsWith('http://127.0.0.1') || url.startsWith('http://localhost')) {
|
||||||
@@ -44,6 +148,7 @@ function createWindow() {
|
|||||||
} else {
|
} else {
|
||||||
mainWindow.loadURL(splashHtml())
|
mainWindow.loadURL(splashHtml())
|
||||||
}
|
}
|
||||||
|
updateTrayMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
function splashHtml(): string {
|
function splashHtml(): string {
|
||||||
@@ -95,10 +200,7 @@ if (!gotLock) {
|
|||||||
app.quit()
|
app.quit()
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
if (mainWindow) {
|
showMainWindow()
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
||||||
mainWindow.focus()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
@@ -107,25 +209,34 @@ if (!gotLock) {
|
|||||||
// visual clutter. macOS keeps a menu (system requirement) but Electron's
|
// visual clutter. macOS keeps a menu (system requirement) but Electron's
|
||||||
// default is fine there.
|
// default is fine there.
|
||||||
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
|
if (process.platform !== 'darwin') Menu.setApplicationMenu(null)
|
||||||
|
createTray()
|
||||||
createWindow()
|
createWindow()
|
||||||
bootstrap()
|
bootstrap()
|
||||||
initAutoUpdater()
|
initAutoUpdater({
|
||||||
|
beforeQuitAndInstall: () => {
|
||||||
|
isQuitting = true
|
||||||
|
},
|
||||||
|
})
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow()
|
createWindow()
|
||||||
} else if (mainWindow) {
|
} else if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
showMainWindow()
|
||||||
mainWindow.show()
|
|
||||||
mainWindow.focus()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') app.quit()
|
if (isQuitting && process.platform !== 'darwin') app.quit()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', async (e) => {
|
app.on('before-quit', async (e) => {
|
||||||
|
if (!isQuitting && process.platform !== 'darwin') {
|
||||||
|
e.preventDefault()
|
||||||
|
mainWindow?.hide()
|
||||||
|
updateTrayMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await stopWebUiServer().catch(() => undefined)
|
await stopWebUiServer().catch(() => undefined)
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export function desktopIcon(): string {
|
|||||||
return resolve(app.getAppPath(), 'build', 'icon.png')
|
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 {
|
export function webUiHome(): string {
|
||||||
return process.env.HERMES_WEB_UI_HOME?.trim() || resolve(homedir(), '.hermes-web-ui')
|
return process.env.HERMES_WEB_UI_HOME?.trim() || resolve(homedir(), '.hermes-web-ui')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,165 @@
|
|||||||
import { app, dialog } from 'electron'
|
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 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<string> {
|
||||||
|
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<void> {
|
||||||
|
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
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
|
|
||||||
if (!app.isPackaged) return // dev mode: skip
|
if (!app.isPackaged) return // dev mode: skip
|
||||||
if (process.env.HERMES_DESKTOP_ENABLE_AUTO_UPDATE !== 'true') return
|
|
||||||
|
|
||||||
autoUpdater.autoDownload = true
|
autoUpdater.autoDownload = true
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
|
|
||||||
autoUpdater.on('update-available', info => {
|
autoUpdater.on('update-available', info => {
|
||||||
console.log(`[updater] update available: ${info.version}`)
|
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')
|
console.log('[updater] up to date')
|
||||||
|
if (checking) showUpToDate(info)
|
||||||
})
|
})
|
||||||
autoUpdater.on('error', err => {
|
autoUpdater.on('error', err => {
|
||||||
console.error('[updater] 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({
|
const { response } = await dialog.showMessageBox({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: 'Update ready',
|
title: t('update.readyTitle'),
|
||||||
message: `Hermes Studio ${info.version} is ready to install.`,
|
message: t('update.readyMessage', { version: info.version }),
|
||||||
detail: 'Restart now to apply the update, or it will be installed on next quit.',
|
detail: t('update.readyDetail'),
|
||||||
buttons: ['Restart now', 'Later'],
|
buttons: [t('update.restartNow'), t('update.later')],
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 1,
|
cancelId: 1,
|
||||||
})
|
})
|
||||||
if (response === 0) autoUpdater.quitAndInstall()
|
if (response === 0) {
|
||||||
|
options.beforeQuitAndInstall?.()
|
||||||
|
autoUpdater.quitAndInstall()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.checkForUpdates().catch(err => {
|
if (process.env.HERMES_DESKTOP_ENABLE_AUTO_UPDATE !== 'false') {
|
||||||
console.error('[updater] initial check failed:', err)
|
checkForDesktopUpdates(false).catch(err => {
|
||||||
})
|
console.error('[updater] initial check failed:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Recheck every 6h while app is running
|
// Recheck every 6h while app is running
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
autoUpdater.checkForUpdates().catch(() => undefined)
|
checkForDesktopUpdates(false).catch(() => undefined)
|
||||||
}, 6 * 60 * 60 * 1000)
|
}, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,27 @@ const execFileAsync = promisify(execFile)
|
|||||||
let serverProc: ChildProcess | null = null
|
let serverProc: ChildProcess | null = null
|
||||||
let cachedToken: string | 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 {
|
function envPositiveInt(name: string): number | undefined {
|
||||||
const raw = process.env[name]
|
const raw = process.env[name]
|
||||||
if (!raw) return undefined
|
if (!raw) return undefined
|
||||||
@@ -207,12 +228,14 @@ export async function startWebUiServer(port = DEFAULT_PORT): Promise<string> {
|
|||||||
// setup is a #!/bin/sh wrapper, not a python interpreter, so detection
|
// setup is a #!/bin/sh wrapper, not a python interpreter, so detection
|
||||||
// resolves to /bin/sh and the bridge crashes (exit code 2) immediately.
|
// resolves to /bin/sh and the bridge crashes (exit code 2) immediately.
|
||||||
const isWin = process.platform === 'win32'
|
const isWin = process.platform === 'win32'
|
||||||
const bundledPython = isWin
|
|
||||||
? join(pythonDir(), 'python.exe')
|
|
||||||
: join(pythonDir(), 'bin', 'python3')
|
|
||||||
const bundledPythonNoWindow = isWin
|
const bundledPythonNoWindow = isWin
|
||||||
? join(pythonDir(), 'pythonw.exe')
|
? 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 bridgePort = await getFreeTcpPort()
|
||||||
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
const workerPortBase = await getFreeTcpPortInRange(20000, 59000)
|
||||||
const loginShellPath = await getLoginShellPath()
|
const loginShellPath = await getLoginShellPath()
|
||||||
@@ -312,7 +335,7 @@ export function stopWebUiServer(): Promise<void> {
|
|||||||
if (!serverProc || serverProc.killed) return resolve()
|
if (!serverProc || serverProc.killed) return resolve()
|
||||||
const proc = serverProc
|
const proc = serverProc
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
try { proc.kill('SIGKILL') } catch { /* */ }
|
killProcessTree(proc)
|
||||||
resolve()
|
resolve()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
proc.once('exit', () => {
|
proc.once('exit', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFileSync, spawn, type ChildProcess } from 'child_process'
|
import { execFileSync, spawn, type ChildProcess } from 'child_process'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync } from 'fs'
|
||||||
import { createServer } from 'net'
|
import { createConnection, createServer } from 'net'
|
||||||
import { dirname, isAbsolute, join, resolve } from 'path'
|
import { dirname, isAbsolute, join, resolve } from 'path'
|
||||||
import { logger } from '../../logger'
|
import { logger } from '../../logger'
|
||||||
import { detectHermesHome, getHermesBin } from '../hermes-path'
|
import { detectHermesHome, getHermesBin } from '../hermes-path'
|
||||||
@@ -245,6 +245,10 @@ function isTcpEndpoint(endpoint: string): boolean {
|
|||||||
return endpoint.startsWith('tcp://')
|
return endpoint.startsWith('tcp://')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDesktopRuntime(): boolean {
|
||||||
|
return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
async function canListenTcpEndpoint(endpoint: string): Promise<boolean> {
|
async function canListenTcpEndpoint(endpoint: string): Promise<boolean> {
|
||||||
const url = new URL(endpoint)
|
const url = new URL(endpoint)
|
||||||
const host = url.hostname || '127.0.0.1'
|
const host = url.hostname || '127.0.0.1'
|
||||||
@@ -264,6 +268,26 @@ async function canListenTcpEndpoint(endpoint: string): Promise<boolean> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function canConnectTcpEndpoint(endpoint: string): Promise<boolean> {
|
||||||
|
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<boolean>((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 {
|
function tcpEndpointPort(endpoint: string): number | undefined {
|
||||||
if (!isTcpEndpoint(endpoint)) return undefined
|
if (!isTcpEndpoint(endpoint)) return undefined
|
||||||
const url = new URL(endpoint)
|
const url = new URL(endpoint)
|
||||||
@@ -416,6 +440,16 @@ export class AgentBridgeManager {
|
|||||||
child.off('error', onError)
|
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) => {
|
const onError = (err: Error) => {
|
||||||
cleanup()
|
cleanup()
|
||||||
child.stdout?.off('data', onStdout)
|
child.stdout?.off('data', onStdout)
|
||||||
@@ -443,11 +477,7 @@ export class AgentBridgeManager {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line)
|
const parsed = JSON.parse(line)
|
||||||
if (parsed?.event === 'ready') {
|
if (parsed?.event === 'ready') {
|
||||||
this.ready = true
|
markReady()
|
||||||
this.restartAttempts = 0
|
|
||||||
readyResolved = true
|
|
||||||
cleanup()
|
|
||||||
resolveReady()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -458,6 +488,19 @@ export class AgentBridgeManager {
|
|||||||
child.once('error', onError)
|
child.once('error', onError)
|
||||||
child.once('exit', onExitBeforeReady)
|
child.once('exit', onExitBeforeReady)
|
||||||
child.stdout?.on('data', onStdout)
|
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)
|
logger.info('[agent-bridge] ready at %s', this.endpoint)
|
||||||
|
|||||||
Reference in New Issue
Block a user