From b96bda48342ec1203729c28ff72fa92a4a2d203a Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:49:03 +0800 Subject: [PATCH] [codex] fix desktop tray icon and update checks (#1201) * fix desktop tray icon and update errors * fix brotlicffi error compatibility * fix windows installer app shutdown * fix desktop updater artifact names --- packages/desktop/build/installer.nsh | 8 ++++ packages/desktop/electron-builder.yml | 8 ++-- .../desktop/scripts/apply-hermes-patches.mjs | 28 +++++++++++++ packages/desktop/src/main/desktop-i18n.ts | 10 +++++ packages/desktop/src/main/index.ts | 24 ++++++++--- packages/desktop/src/main/paths.ts | 5 +++ packages/desktop/src/main/updater.ts | 40 +++++++++++++++---- 7 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 packages/desktop/build/installer.nsh diff --git a/packages/desktop/build/installer.nsh b/packages/desktop/build/installer.nsh new file mode 100644 index 0000000..ed2954d --- /dev/null +++ b/packages/desktop/build/installer.nsh @@ -0,0 +1,8 @@ +!macro customInit + IfFileExists "$INSTDIR\Hermes Studio.exe" 0 hermesStudioStopDone + DetailPrint "Stopping Hermes Studio..." + nsExec::ExecToLog '"$INSTDIR\Hermes Studio.exe" --quit' + Sleep 5000 + nsExec::ExecToLog 'taskkill.exe /IM "Hermes Studio.exe" /T /F' + hermesStudioStopDone: +!macroend diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index f4209db..0f48a20 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -29,6 +29,7 @@ extraResources: to: "build" filter: - "icon.png" + - "icon.ico" - "trayTemplate.png" - from: "../.." to: "webui" @@ -60,13 +61,13 @@ mac: hardenedRuntime: true gatekeeperAssess: false notarize: true - artifactName: "${productName}-${version}-${arch}.${ext}" + artifactName: "Hermes.Studio-${version}-${arch}.${ext}" win: target: - target: nsis arch: [x64] - artifactName: "${productName}-${version}-${arch}.${ext}" + artifactName: "Hermes.Studio-${version}-${arch}.${ext}" linux: icon: build/icons @@ -76,9 +77,10 @@ linux: - target: deb arch: [x64] # fpm has no arm64 binary; deb only on x64 category: Development - artifactName: "${productName}-${version}-${arch}.${ext}" + artifactName: "Hermes.Studio-${version}-${arch}.${ext}" nsis: oneClick: false allowToChangeInstallationDirectory: true perMachine: false + include: build/installer.nsh diff --git a/packages/desktop/scripts/apply-hermes-patches.mjs b/packages/desktop/scripts/apply-hermes-patches.mjs index dd73687..b34e3de 100644 --- a/packages/desktop/scripts/apply-hermes-patches.mjs +++ b/packages/desktop/scripts/apply-hermes-patches.mjs @@ -33,6 +33,7 @@ const sitePkgs = process.env.HERMES_AGENT_SITE_PACKAGES ?? ( ) const dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py') +const sitecustomizePath = join(sitePkgs, 'sitecustomize.py') if (!existsSync(dtPath)) { console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`) process.exit(1) @@ -177,4 +178,31 @@ patch( if (src !== before) { writeFileSync(dtPath, src) } + +const brotlicffiCompatMarker = '# patch:brotlicffi-error-compat' +const brotlicffiCompat = ` +${brotlicffiCompatMarker} +try: + import brotlicffi as _hermes_brotlicffi + if not hasattr(_hermes_brotlicffi, "error"): + _hermes_brotlicffi.error = ( + getattr(_hermes_brotlicffi, "Error", None) + or getattr(_hermes_brotlicffi, "BrotliError", None) + or Exception + ) +except Exception: + pass +` + +const sitecustomize = existsSync(sitecustomizePath) ? readFileSync(sitecustomizePath, 'utf-8') : '' +if (sitecustomize.includes(brotlicffiCompatMarker)) { + console.log(' · brotlicffi-error-compat (already applied)') + skipped++ +} else { + const nextSitecustomize = `${sitecustomize.replace(/\s*$/, '')}\n${brotlicffiCompat.trim()}\n` + writeFileSync(sitecustomizePath, nextSitecustomize) + console.log(' ✓ brotlicffi-error-compat') + applied++ +} + console.log(`Done. Applied ${applied}, skipped ${skipped}.`) diff --git a/packages/desktop/src/main/desktop-i18n.ts b/packages/desktop/src/main/desktop-i18n.ts index 8d5ccdd..7b59554 100644 --- a/packages/desktop/src/main/desktop-i18n.ts +++ b/packages/desktop/src/main/desktop-i18n.ts @@ -24,6 +24,7 @@ type TranslationKey = | 'update.later' | 'update.failedTitle' | 'update.failedMessage' + | 'update.noUpdateInfoMessage' | 'update.packagedOnlyMessage' | 'common.ok' @@ -52,6 +53,7 @@ const translations: Record> = { 'update.later': 'Later', 'update.failedTitle': 'Update check failed', 'update.failedMessage': 'Could not check for Hermes Studio updates.', + 'update.noUpdateInfoMessage': 'Update information is not available for this platform yet.', 'update.packagedOnlyMessage': 'Automatic updates are only available in the packaged desktop app.', 'common.ok': 'OK', }, @@ -77,6 +79,7 @@ const translations: Record> = { 'update.later': '稍后', 'update.failedTitle': '检查更新失败', 'update.failedMessage': '无法检查 Hermes Studio 更新。', + 'update.noUpdateInfoMessage': '当前平台的更新信息暂不可用。', 'update.packagedOnlyMessage': '自动更新仅在打包后的桌面应用中可用。', 'common.ok': '确定', }, @@ -102,6 +105,7 @@ const translations: Record> = { 'update.later': '稍後', 'update.failedTitle': '檢查更新失敗', 'update.failedMessage': '無法檢查 Hermes Studio 更新。', + 'update.noUpdateInfoMessage': '目前平台的更新資訊暫不可用。', 'update.packagedOnlyMessage': '自動更新僅可在打包後的桌面應用中使用。', 'common.ok': '確定', }, @@ -127,6 +131,7 @@ const translations: Record> = { 'update.later': '後で', 'update.failedTitle': 'アップデート確認に失敗しました', 'update.failedMessage': 'Hermes Studio のアップデートを確認できませんでした。', + 'update.noUpdateInfoMessage': 'このプラットフォームのアップデート情報はまだ利用できません。', 'update.packagedOnlyMessage': '自動アップデートはパッケージ版デスクトップアプリでのみ利用できます。', 'common.ok': 'OK', }, @@ -152,6 +157,7 @@ const translations: Record> = { 'update.later': '나중에', 'update.failedTitle': '업데이트 확인 실패', 'update.failedMessage': 'Hermes Studio 업데이트를 확인할 수 없습니다.', + 'update.noUpdateInfoMessage': '이 플랫폼의 업데이트 정보를 아직 사용할 수 없습니다.', 'update.packagedOnlyMessage': '자동 업데이트는 패키징된 데스크톱 앱에서만 사용할 수 있습니다.', 'common.ok': '확인', }, @@ -177,6 +183,7 @@ const translations: Record> = { 'update.later': 'Plus tard', 'update.failedTitle': 'Echec de la recherche de mise a jour', 'update.failedMessage': 'Impossible de rechercher les mises a jour de Hermes Studio.', + 'update.noUpdateInfoMessage': 'Les informations de mise a jour ne sont pas encore disponibles pour cette plateforme.', 'update.packagedOnlyMessage': 'Les mises a jour automatiques ne sont disponibles que dans l application de bureau packagee.', 'common.ok': 'OK', }, @@ -202,6 +209,7 @@ const translations: Record> = { 'update.later': 'Mas tarde', 'update.failedTitle': 'Error al buscar actualizaciones', 'update.failedMessage': 'No se pudieron buscar actualizaciones de Hermes Studio.', + 'update.noUpdateInfoMessage': 'La informacion de actualizacion aun no esta disponible para esta plataforma.', 'update.packagedOnlyMessage': 'Las actualizaciones automaticas solo estan disponibles en la app de escritorio empaquetada.', 'common.ok': 'Aceptar', }, @@ -227,6 +235,7 @@ const translations: Record> = { 'update.later': 'Spater', 'update.failedTitle': 'Update-Prufung fehlgeschlagen', 'update.failedMessage': 'Updates fur Hermes Studio konnten nicht gepruft werden.', + 'update.noUpdateInfoMessage': 'Update-Informationen sind fur diese Plattform noch nicht verfugbar.', 'update.packagedOnlyMessage': 'Automatische Updates sind nur in der paketierten Desktop-App verfugbar.', 'common.ok': 'OK', }, @@ -252,6 +261,7 @@ const translations: Record> = { 'update.later': 'Depois', 'update.failedTitle': 'Falha ao verificar atualizacoes', 'update.failedMessage': 'Nao foi possivel verificar atualizacoes do Hermes Studio.', + 'update.noUpdateInfoMessage': 'As informacoes de atualizacao ainda nao estao disponiveis para esta plataforma.', 'update.packagedOnlyMessage': 'Atualizacoes automaticas estao disponiveis apenas no app desktop empacotado.', 'common.ok': 'OK', }, diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 80c0979..22355ca 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -1,12 +1,13 @@ import { app, BrowserWindow, Menu, Tray, shell, ipcMain, nativeImage } from 'electron' import { join } from 'node:path' import { startWebUiServer, stopWebUiServer, getToken } from './webui-server' -import { desktopIcon, desktopTrayTemplateIcon, hermesBinExists, hermesBin } from './paths' +import { desktopIcon, desktopTrayTemplateIcon, desktopWindowsTrayIcon, 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') +const QUIT_EXISTING = process.argv.includes('--quit') let mainWindow: BrowserWindow | null = null let serverUrl: string | null = null @@ -90,10 +91,14 @@ function updateTrayMenu() { function createTray() { if (tray) return - const source = process.platform === 'darwin' ? desktopTrayTemplateIcon() : desktopIcon() + const source = process.platform === 'darwin' + ? desktopTrayTemplateIcon() + : process.platform === 'win32' + ? desktopWindowsTrayIcon() + : desktopIcon() const icon = nativeImage.createFromPath(source).resize({ - width: process.platform === 'darwin' ? 18 : 16, - height: process.platform === 'darwin' ? 18 : 16, + width: process.platform === 'darwin' ? 18 : process.platform === 'win32' ? 20 : 16, + height: process.platform === 'darwin' ? 18 : process.platform === 'win32' ? 20 : 16, quality: 'best', }) if (process.platform === 'darwin') { @@ -205,11 +210,20 @@ const gotLock = app.requestSingleInstanceLock() if (!gotLock) { app.quit() } else { - app.on('second-instance', () => { + app.on('second-instance', (_event, argv) => { + if (argv.includes('--quit')) { + quitApp() + return + } showMainWindow() }) app.whenReady().then(() => { + if (QUIT_EXISTING) { + quitApp() + return + } + // Drop the default File/Edit/View/Window menu on Windows/Linux. The web // UI provides its own in-page controls, so the native menu bar is just // visual clutter. macOS keeps a menu (system requirement) but Electron's diff --git a/packages/desktop/src/main/paths.ts b/packages/desktop/src/main/paths.ts index 8e9801a..9618f3d 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 desktopWindowsTrayIcon(): string { + if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'icon.ico') + return resolve(app.getAppPath(), 'build', 'icon.ico') +} + export function desktopTrayTemplateIcon(): string { if (app.isPackaged) return resolve(process.resourcesPath, 'build', 'trayTemplate.png') return resolve(app.getAppPath(), 'build', 'trayTemplate.png') diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index 38f878e..789b48a 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -13,6 +13,13 @@ interface GitHubRelease { tag_name?: string } +class MissingUpdateInfoError extends Error { + constructor(public readonly url: string) { + super(`Update information is not available at ${url}`) + this.name = 'MissingUpdateInfoError' + } +} + interface AutoUpdaterOptions { beforeQuitAndInstall?: () => void } @@ -31,14 +38,34 @@ async function getLatestReleaseTag(): Promise { 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}` + return tag +} + +function updateManifestFile(): string { + if (process.platform === 'darwin') return 'latest-mac.yml' + if (process.platform === 'win32') return 'latest.yml' + return 'latest-linux.yml' +} + +async function assertUpdateManifestExists(feedUrl: string): Promise { + const manifestUrl = `${feedUrl}/${updateManifestFile()}` + const res = await fetch(manifestUrl, { + method: 'HEAD', + headers: { + 'User-Agent': `Hermes-Studio/${app.getVersion()}`, + }, + }) + if (res.status === 404) throw new MissingUpdateInfoError(manifestUrl) + if (!res.ok) throw new Error(`Update feed returned ${res.status}`) } async function configureFeedFromLatestRelease(): Promise { const tag = await getLatestReleaseTag() + const feedUrl = `${CLOUDFLARE_DOWNLOAD_BASE_URL}/${tag}` + await assertUpdateManifestExists(feedUrl) autoUpdater.setFeedURL({ provider: 'generic', - url: `${CLOUDFLARE_DOWNLOAD_BASE_URL}/${tag}`, + url: feedUrl, }) } @@ -54,12 +81,11 @@ function showUpToDate(info?: UpdateInfo) { } function showUpdateCheckFailed(err: unknown) { - const detail = err instanceof Error ? err.message : String(err) + const isMissingUpdateInfo = err instanceof MissingUpdateInfoError dialog.showMessageBox({ - type: 'error', - title: t('update.failedTitle'), - message: t('update.failedMessage'), - detail, + type: isMissingUpdateInfo ? 'info' : 'error', + title: isMissingUpdateInfo ? t('update.upToDateTitle') : t('update.failedTitle'), + message: isMissingUpdateInfo ? t('update.noUpdateInfoMessage') : t('update.failedMessage'), buttons: [t('common.ok')], }).catch(() => undefined) }