From 0f9db1d6dc718e96d2267107fde84010d65367a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=AF=E5=9F=BA=E9=AD=81?= <1412414664@qq.com> Date: Mon, 6 Jul 2026 12:52:08 +0800 Subject: [PATCH] fix(settings): link release notes before updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 冯基魁 <1412414664@qq.com> --- common/app-info.ts | 15 ++++++- main/src/auto-update.ts | 27 ++++++++++--- main/src/tests/auto-update.test.ts | 40 ++++++++++++++++--- .../tabs/__tests__/version-tab.test.tsx | 7 +++- .../components/settings/tabs/version-tab.tsx | 37 +++++++++++------ 5 files changed, 98 insertions(+), 28 deletions(-) diff --git a/common/app-info.ts b/common/app-info.ts index 0fba5e314..b4f0510f7 100644 --- a/common/app-info.ts +++ b/common/app-info.ts @@ -49,8 +49,19 @@ export const GITHUB_REPO = 'toolhive-studio' export const GITHUB_REPO_URL = 'https://github.com/stacklok/toolhive-studio' export const GITHUB_ISSUES_URL = 'https://github.com/stacklok/toolhive-studio/issues' -export const GITHUB_RELEASES_URL = - 'https://github.com/stacklok/toolhive-studio/releases/latest' +export const GITHUB_RELEASES_BASE_URL = + 'https://github.com/stacklok/toolhive-studio/releases' +export const GITHUB_RELEASES_URL = `${GITHUB_RELEASES_BASE_URL}/latest` +export function getGitHubReleaseUrl(version?: string | null) { + const trimmedVersion = version?.trim() + if (!trimmedVersion) { + return GITHUB_RELEASES_URL + } + const tagName = trimmedVersion.startsWith('v') + ? trimmedVersion + : `v${trimmedVersion}` + return `${GITHUB_RELEASES_BASE_URL}/tag/${encodeURIComponent(tagName)}` +} export const TOOLHIVE_CLI_OWNER = 'stacklok' export const TOOLHIVE_CLI_REPO = 'toolhive' diff --git a/main/src/auto-update.ts b/main/src/auto-update.ts index 51587b152..ee0ab8a7c 100644 --- a/main/src/auto-update.ts +++ b/main/src/auto-update.ts @@ -1,4 +1,11 @@ -import { app, autoUpdater, dialog, ipcMain, type BrowserWindow } from 'electron' +import { + app, + autoUpdater, + dialog, + ipcMain, + shell, + type BrowserWindow, +} from 'electron' import { updateElectronApp, UpdateSourceType } from 'update-electron-app' import * as Sentry from '@sentry/electron/main' import { stopAllServers } from './graceful-exit' @@ -9,7 +16,7 @@ import { getAppVersion, pollWindowReady } from './util' import { delay } from '../../utils/delay' import log from './logger' import { setQuittingState, setTearingDownState } from './app-state' -import { RELEASES_BASE_URL } from '@common/app-info' +import { getGitHubReleaseUrl, RELEASES_BASE_URL } from '@common/app-info' import Store from 'electron-store' import { fetchLatestRelease } from './utils/toolhive-version' import { writeSetting } from './db/writers/settings-writer' @@ -299,10 +306,11 @@ async function handleUpdateDownloaded({ } // Phase 2: Show dialog and wait for user decision (user interaction time) + const releaseNotesUrl = getGitHubReleaseUrl(releaseName) const dialogOpts = { type: 'info' as const, - buttons: ['Restart', 'Later'], - cancelId: 1, + buttons: ['Restart', 'View Release Notes', 'Later'], + cancelId: 2, defaultId: 0, title: `Release ${releaseName}`, message: @@ -316,11 +324,18 @@ async function handleUpdateDownloaded({ icon: undefined, } - let userChoice: 'restart' | 'later' | 'error' = 'error' + let userChoice: 'restart' | 'release-notes' | 'later' | 'error' = 'error' try { const returnValue = await dialog.showMessageBox(mainWindow, dialogOpts) - userChoice = returnValue.response === 0 ? 'restart' : 'later' + if (returnValue.response === 0) { + userChoice = 'restart' + } else if (returnValue.response === 1) { + userChoice = 'release-notes' + shell.openExternal(releaseNotesUrl) + } else { + userChoice = 'later' + } } catch (error) { log.error('[update] Dialog error in update-downloaded handler:', error) userChoice = 'error' diff --git a/main/src/tests/auto-update.test.ts b/main/src/tests/auto-update.test.ts index ea5ec567f..7f1cb8f65 100644 --- a/main/src/tests/auto-update.test.ts +++ b/main/src/tests/auto-update.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { EventEmitter } from 'node:events' import type { BrowserWindow, Tray } from 'electron' -import { app, autoUpdater, dialog, ipcMain } from 'electron' +import { app, autoUpdater, dialog, ipcMain, shell } from 'electron' +import { getGitHubReleaseUrl } from '@common/app-info' import { initAutoUpdate, resetUpdateState, @@ -49,6 +50,9 @@ vi.mock('electron', () => { dialog: { showMessageBox: vi.fn(), }, + shell: { + openExternal: vi.fn(), + }, ipcMain: { handle: vi.fn(), removeHandler: vi.fn(), @@ -205,7 +209,7 @@ describe('auto-update', () => { vi.mocked(pollWindowReady).mockResolvedValue(undefined) vi.mocked(delay).mockResolvedValue(undefined) vi.mocked(dialog.showMessageBox).mockResolvedValue({ - response: 1, + response: 2, checkboxChecked: false, }) // "Later" by default @@ -318,7 +322,7 @@ describe('auto-update', () => { it('handles update-downloaded event with user clicking later', async () => { vi.mocked(dialog.showMessageBox).mockResolvedValue({ - response: 1, + response: 2, checkboxChecked: false, }) // "Later" @@ -334,6 +338,32 @@ describe('auto-update', () => { expect(vi.mocked(stopAllServers)).not.toHaveBeenCalled() }) + it('opens release notes from the update confirmation dialog', async () => { + vi.mocked(dialog.showMessageBox).mockResolvedValue({ + response: 1, + checkboxChecked: false, + }) // "View Release Notes" + + vi.mocked(autoUpdater).emit('update-downloaded', null, null, 'v1.2.3') + + await vi.runAllTimersAsync() + + expect(vi.mocked(dialog.showMessageBox)).toHaveBeenCalledWith( + mockMainWindow, + expect.objectContaining({ + buttons: ['Restart', 'View Release Notes', 'Later'], + cancelId: 2, + }) + ) + expect(vi.mocked(shell.openExternal)).toHaveBeenCalledWith( + getGitHubReleaseUrl('v1.2.3') + ) + expect(vi.mocked(stopAllServers)).not.toHaveBeenCalled() + expect(mockMainWindow.webContents.send).toHaveBeenCalledWith( + 'update-downloaded' + ) + }) + it('prevents concurrent update operations', async () => { vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 0, @@ -367,7 +397,7 @@ describe('auto-update', () => { const createWindowSpy = vi.fn().mockResolvedValue(newWindow) vi.mocked(dialog.showMessageBox).mockResolvedValue({ - response: 1, + response: 2, checkboxChecked: false, }) // "Later" @@ -930,7 +960,7 @@ describe('auto-update', () => { const createWindow = vi.fn(async () => newWindow) vi.mocked(dialog.showMessageBox).mockResolvedValue({ - response: 1, + response: 2, checkboxChecked: false, }) // Later diff --git a/renderer/src/common/components/settings/tabs/__tests__/version-tab.test.tsx b/renderer/src/common/components/settings/tabs/__tests__/version-tab.test.tsx index 9b236cc1b..30d9ac2ba 100644 --- a/renderer/src/common/components/settings/tabs/__tests__/version-tab.test.tsx +++ b/renderer/src/common/components/settings/tabs/__tests__/version-tab.test.tsx @@ -7,7 +7,7 @@ import type { AppVersionInfo } from '@/common/hooks/use-app-version' import userEvent from '@testing-library/user-event' import { PermissionsProvider } from '@/common/contexts/permissions/permissions-provider' import type { Permissions } from '@/common/contexts/permissions' -import { GITHUB_RELEASES_URL } from '@common/app-info' +import { getGitHubReleaseUrl } from '@common/app-info' const mockIsAutoUpdateEnabled = vi.fn() const mockSetAutoUpdate = vi.fn() @@ -196,6 +196,9 @@ describe('VersionTab', () => { expect(screen.getByText(/A new version 2.0.0 is available/i)).toBeVisible() expect(screen.getByRole('button', { name: 'Download' })).toBeVisible() + expect( + screen.getByRole('link', { name: /View release notes/i }) + ).toHaveAttribute('href', getGitHubReleaseUrl('2.0.0')) expect(screen.getByText('ToolHive binary version')).toBeVisible() }) @@ -217,7 +220,7 @@ describe('VersionTab', () => { const downloadButton = screen.getByRole('button', { name: 'Download' }) await user.click(downloadButton) - expect(window.open).toHaveBeenCalledWith(GITHUB_RELEASES_URL) + expect(window.open).toHaveBeenCalledWith(getGitHubReleaseUrl('2.0.0')) expect(mockManualUpdate).not.toHaveBeenCalled() }) diff --git a/renderer/src/common/components/settings/tabs/version-tab.tsx b/renderer/src/common/components/settings/tabs/version-tab.tsx index 2245831d1..7f293b841 100644 --- a/renderer/src/common/components/settings/tabs/version-tab.tsx +++ b/renderer/src/common/components/settings/tabs/version-tab.tsx @@ -10,7 +10,7 @@ import { } from '@/common/hooks/use-auto-update' import { Button } from '../../ui/button' import { Alert, AlertDescription } from '../../ui/alert' -import { AlertCircleIcon, Download } from 'lucide-react' +import { AlertCircleIcon, Download, ExternalLink } from 'lucide-react' import { trackEvent } from '@/common/lib/analytics' import { Separator } from '../../ui/separator' import { SettingsSectionTitle } from './components/settings-section-title' @@ -18,7 +18,7 @@ import { SettingsRow } from './components/settings-row' import { WrapperField } from './components/wrapper-field' import { usePermissions } from '@/common/contexts/permissions' import { PERMISSION_KEYS } from '@/common/contexts/permissions/permission-keys' -import { APP_DISPLAY_NAME, GITHUB_RELEASES_URL } from '@common/app-info' +import { APP_DISPLAY_NAME, getGitHubReleaseUrl } from '@common/app-info' interface VersionTabProps { appInfo: AppVersionInfo | undefined @@ -58,6 +58,7 @@ export function VersionTab({ appInfo, isLoading, error }: VersionTabProps) { const canShowAutoUpdate = canShow(PERMISSION_KEYS.AUTO_UPDATE) const canShowUpdateAlert = canShowAutoUpdate && appInfo?.isNewVersionAvailable && isProduction + const releaseNotesUrl = getGitHubReleaseUrl(appInfo?.latestVersion) if (isLoading) { return ( @@ -84,7 +85,7 @@ export function VersionTab({ appInfo, isLoading, error }: VersionTabProps) { const handleManualUpdate = () => { const isLinux = window.electronAPI?.isLinux if (isLinux) { - window.open(GITHUB_RELEASES_URL) + window.open(releaseNotesUrl) trackEvent('redirect to github releases', { pageName: '/settings/version', @@ -173,19 +174,29 @@ export function VersionTab({ appInfo, isLoading, error }: VersionTabProps) { -
+
A new version {appInfo.latestVersion} is available
- +
+ + +