Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions common/app-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
27 changes: 21 additions & 6 deletions main/src/auto-update.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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:
Expand All @@ -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'
Expand Down
40 changes: 35 additions & 5 deletions main/src/tests/auto-update.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -49,6 +50,9 @@ vi.mock('electron', () => {
dialog: {
showMessageBox: vi.fn(),
},
shell: {
openExternal: vi.fn(),
},
ipcMain: {
handle: vi.fn(),
removeHandler: vi.fn(),
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"

Expand All @@ -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,
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
})

Expand All @@ -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()
})

Expand Down
37 changes: 24 additions & 13 deletions renderer/src/common/components/settings/tabs/version-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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'
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
Expand Down Expand Up @@ -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 (
Expand All @@ -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',
Expand Down Expand Up @@ -173,19 +174,29 @@ export function VersionTab({ appInfo, isLoading, error }: VersionTabProps) {
<Alert className="flex h-full items-center">
<AlertDescription className="flex w-full items-center gap-2">
<AlertCircleIcon className="flex size-4 items-center" />
<div className="flex w-full items-center justify-between">
<div
className="flex w-full flex-wrap items-center justify-between
gap-3"
>
<div className="font-medium">
A new version {appInfo.latestVersion} is available
</div>
<Button
variant="outline"
disabled={isCheckingOrDownloading}
onClick={() => {
handleManualUpdate()
}}
>
<Download className="size-4" /> {getButtonText()}
</Button>
<div className="flex flex-wrap items-center justify-end gap-3">
<Button asChild variant="link">
<a href={releaseNotesUrl} target="_blank" rel="noreferrer">
View release notes <ExternalLink className="size-4" />
</a>
</Button>
<Button
variant="outline"
disabled={isCheckingOrDownloading}
onClick={() => {
handleManualUpdate()
}}
>
<Download className="size-4" /> {getButtonText()}
</Button>
</div>
</div>
</AlertDescription>
</Alert>
Expand Down