From 821c8b69fbd6279af07e65b75db0de0ad28cb84c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 3 Jul 2026 20:25:55 -0400 Subject: [PATCH] Add perf prerelease update check modifier Co-authored-by: Orca --- src/main/menu/register-app-menu.test.ts | 39 ++++-- src/main/menu/register-app-menu.ts | 16 ++- src/main/updater-prerelease-feed.test.ts | 57 +++++++++ src/main/updater-prerelease-feed.ts | 22 +++- src/main/updater.test.ts | 54 +++++++++ src/main/updater.ts | 111 ++++++++++++++---- .../window/attach-main-window-services.ts | 8 +- src/preload/api-types.ts | 3 +- .../settings/GeneralUpdateSettingsSection.tsx | 18 +-- .../sidebar/SidebarSettingsHelpMenu.test.tsx | 45 ++++++- .../sidebar/SidebarSettingsHelpMenu.tsx | 10 +- .../lib/update-check-click-options.test.ts | 52 ++++++++ .../src/lib/update-check-click-options.ts | 22 ++++ src/shared/types.ts | 5 + 14 files changed, 404 insertions(+), 58 deletions(-) create mode 100644 src/renderer/src/lib/update-check-click-options.test.ts create mode 100644 src/renderer/src/lib/update-check-click-options.ts diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index 39b266b6846..fa636e8623c 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -139,7 +139,7 @@ describe('registerAppMenu', () => { expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 }) }) - it('includes prereleases when Check for Updates is clicked with shift held', () => { + it('routes Check for Updates modifier clicks to prerelease and perf checks', () => { const options = buildMenuOptions() registerAppMenu(options) @@ -152,19 +152,42 @@ describe('registerAppMenu', () => { ) item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent) + item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent) item?.click?.( {} as never, undefined as never, - { metaKey: true, shiftKey: true } as Electron.KeyboardEvent + { + shiftKey: true, + ...(isMac ? { metaKey: true } : { ctrlKey: true }) + } as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + (isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + (isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + { + triggeredByAccelerator: true, + shiftKey: true, + ...(isMac ? { metaKey: true } : { ctrlKey: true }) + } as Electron.KeyboardEvent ) - item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent) - item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent) expect(options.onCheckForUpdates.mock.calls).toEqual([ - [{ includePrerelease: true }], - [{ includePrerelease: true }], - [{ includePrerelease: false }], - [{ includePrerelease: false }] + [{ includePrerelease: true, includePerfPrerelease: false }], + [{ includePrerelease: false, includePerfPrerelease: false }], + [{ includePrerelease: true, includePerfPrerelease: true }], + [{ includePrerelease: false, includePerfPrerelease: true }], + [{ includePrerelease: false, includePerfPrerelease: false }], + [{ includePrerelease: false, includePerfPrerelease: false }] ]) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index a5390e36afc..c36aca6973a 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -5,6 +5,7 @@ import { type KeybindingActionId, type KeybindingOverrides } from '../../shared/keybindings' +import type { UpdateCheckOptions } from '../../shared/types' import { translateMain } from '../i18n/main-i18n' export type AppearanceMenuState = { @@ -26,7 +27,7 @@ type RegisterAppMenuOptions = { onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void onOpenCrashReport: (window?: Electron.BaseWindow | null) => void - onCheckForUpdates: (options: { includePrerelease: boolean }) => void + onCheckForUpdates: (options: UpdateCheckOptions) => void onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void onZoomIn: () => void onZoomOut: () => void @@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { webContents.reload() } - // Why: holding Shift while clicking Check for Updates opts this check into - // the release-candidate channel. Extracted so both the macOS app-menu entry - // and the Windows/Linux Help-menu entry share the exact same behavior. + // Why: modifier-click update checks are hidden power-user affordances. + // Extracted so the macOS app-menu entry and Windows/Linux Help entry share + // identical RC/perf channel routing. const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = ( _menuItem, _window, event ) => { - const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true - onCheckForUpdates({ includePrerelease }) + const modifierClick = !event.triggeredByAccelerator + const includePerfPrerelease = + modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true) + const includePrerelease = modifierClick && event.shiftKey === true + onCheckForUpdates({ includePrerelease, includePerfPrerelease }) } const checkForUpdatesItem: Electron.MenuItemConstructorOptions = { diff --git a/src/main/updater-prerelease-feed.test.ts b/src/main/updater-prerelease-feed.test.ts index e9f40a72a09..81ee19b5057 100644 --- a/src/main/updater-prerelease-feed.test.ts +++ b/src/main/updater-prerelease-feed.test.ts @@ -187,6 +187,63 @@ describe('fetchNewerReleaseTag', () => { expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1') }) + it('picks the semver-newest perf-tagged prerelease', async () => { + respondWithAtom([ + 'v1.4.121-rc.6.perf', + 'v1.4.121-rc.7', + 'v1.4.121-rc.5.perf', + 'v1.4.121-rc.6', + 'v1.4.122' + ]) + + const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') + + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('matches only literal rc.N.perf prerelease tags for perf checks', async () => { + respondWithAtom([ + 'v1.4.121-rc.7.performance', + 'v1.4.121-beta.7.perf', + 'v1.4.121-rc.7.perf.extra', + 'v1.4.121-rc.6.perf' + ]) + + const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed') + + expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false) + expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false) + expect( + await fetchNewerReleaseTag('1.4.120', { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).toBe('v1.4.121-rc.6.perf') + }) + + it('reports no perf update instead of falling back to stable or RC tags', async () => { + respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121']) + + const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed') + + await expect( + fetchNewerReleaseTagsWithReadiness('1.4.120', 1, { + includePrerelease: true, + releaseFilter: 'perf' + }) + ).resolves.toEqual({ + tags: [], + state: 'no-newer' + }) + }) + it('returns a bounded fallback candidate after the newest newer tag', async () => { respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5']) const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed') diff --git a/src/main/updater-prerelease-feed.ts b/src/main/updater-prerelease-feed.ts index ec328db10eb..fb6f5964487 100644 --- a/src/main/updater-prerelease-feed.ts +++ b/src/main/updater-prerelease-feed.ts @@ -43,6 +43,18 @@ type ReleaseFeedTag = { version: string } +export function isPerfPrereleaseTag(tag: string): boolean { + const version = normalizeTagToVersion(tag) + const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/) + const identifiers = match?.[1]?.split('.') ?? [] + return ( + identifiers.length === 3 && + identifiers[0] === 'rc' && + /^\d+$/.test(identifiers[1]) && + identifiers[2] === 'perf' + ) +} + async function fetchReleaseFeedTags(): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) @@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise { */ type FetchNewerReleaseTagOptions = { includePrerelease?: boolean + releaseFilter?: 'perf' } export type FetchNewerReleaseTagsResult = { @@ -190,9 +203,12 @@ export async function fetchNewerReleaseTagsWithReadiness( return { tags: [], state: 'unavailable' } } - const candidates = includePrerelease - ? tags - : tags.filter(({ version }) => !isPrereleaseVersion(version)) + const candidates = + options.releaseFilter === 'perf' + ? tags.filter(({ tag }) => isPerfPrereleaseTag(tag)) + : includePrerelease + ? tags + : tags.filter(({ version }) => !isPrereleaseVersion(version)) const newestNewerIndex = candidates.findIndex( ({ version }) => compareVersions(version, currentVersion) > 0 ) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index 60ff680a012..195d15aca5e 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1012,6 +1012,60 @@ describe('updater', () => { expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1) }) + it('pins the generic feed to a perf-tagged prerelease when requested', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf']) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf' + }) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowPrerelease).toBe(true) + }) + + it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => { + appMock.getVersion.mockReturnValue('1.4.120') + fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' }) + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length + + checkForUpdatesFromMenu({ includePerfPrerelease: true }) + + await vi.waitFor(() => { + expect(sendMock).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: true + }) + }) + expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, { + includePrerelease: true, + releaseFilter: 'perf' + }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls) + }) + it('leaves the feed URL alone for a normal user-initiated check', async () => { autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) const mainWindow = { webContents: { send: vi.fn() } } diff --git a/src/main/updater.ts b/src/main/updater.ts index 0d383880088..2054c6a96ba 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, powerMonitor } from 'electron' import type { NsisUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' -import type { UpdateStatus } from '../shared/types' +import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' @@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } type PrimaryEventSuppression = { failureKey: string; error: unknown } +type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +type ReleaseFeedPreflightResult = 'ready' | 'not-available' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 @@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' } let userInitiatedCheck = false let onBeforeQuitCleanup: (() => void | Promise) | null = null let autoUpdaterInitialized = false -// Why: Shift-clicking "Check for Updates" opts the user into the RC release -// channel for the rest of this process. The generic feed still gets pinned to -// a concrete tag on every check so cancelled RCs without manifests are skipped. +// Why: modifier-clicking "Check for Updates" can target prerelease manifests. +// The generic feed still gets pinned to a concrete tag on every check so +// cancelled prereleases without manifests are skipped. let includePrereleaseActive = false let availableVersion: string | null = null let availableReleaseUrl: string | null = null @@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null let activeUpdateCheckLaunchAttemptId: number | null = null let activeUpdateCheckEventAttemptId: number | null = null let updateAvailableEventPendingAttemptId: number | null = null -let pendingPrereleaseUserInitiatedCheckAfterInFlight = false +let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null let activeUpdateNudgeId: string | null = null let awaitingNudgeCheckOutcome = false let nudgeCheckInFlight = false @@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { } function sendStatus(status: UpdateStatus): void { - const shouldLaunchPendingPrereleaseCheck = - pendingPrereleaseUserInitiatedCheckAfterInFlight && + const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && (status.state === 'idle' || status.state === 'not-available' || status.state === 'available' || @@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void { ) { downloadInFlight = false } - if (shouldLaunchPendingPrereleaseCheck) { - launchPendingPrereleaseUserInitiatedCheckAfterInFlight() + if (shouldLaunchPendingUserInitiatedCheck) { + launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) return } if (statusesEqual(currentStatus, decoratedStatus)) { @@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void { mainWindowRef?.webContents.send('updater:status', decoratedStatus) } -function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void { - pendingPrereleaseUserInitiatedCheckAfterInFlight = false +function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } +} + +function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + return 'default' +} + +function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + pendingUserInitiatedCheckAfterInFlight = null setTimeout(() => { // Why: electron-updater clears its in-flight promise after emitting the - // terminal event. Deferring one tick lets the queued RC check start fresh - // instead of being deduped into the just-finished stable check. + // terminal event. Deferring one tick lets the queued modifier check start + // fresh instead of being deduped into the just-finished stable check. if (currentStatus.state === 'checking') { currentStatus = { state: 'idle' } } - checkForUpdatesFromMenu({ includePrerelease: true }) + checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) }, 0) } @@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v ) } -async function pinDefaultReleaseFeed(): Promise { +async function pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' +): Promise { const autoUpdater = getAutoUpdater() // Why: the /releases/latest/download/ redirect can move between the update // check and the later manual download click. Pinning to the concrete tag @@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise { // Prerelease users still need any-channel resolution so they can move to a // newer RC or the next stable. Stable users should only resolve stable tags. const currentVersion = app.getVersion() - const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion) + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( currentVersion, includePrerelease ? 2 : 1, { - includePrerelease + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) } ) const newerTag = releaseTagsResult.tags[0] ?? null @@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } else if (releaseTagsResult.state === 'not-ready') { clearPrereleaseFallbackContext() if (releaseTagsResult.lastGoodTag) { @@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise { ) publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } autoUpdater.setFeedURL({ provider: 'generic', url }) - return + return 'ready' } clearPublishingWindowLastGoodCheck() console.info( `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing` ) throw new Error('Latest release assets are still publishing') + } else if (isPerfCheck) { + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') } else { clearPrereleaseFallbackContext() clearPublishingWindowLastGoodCheck() @@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise { `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` ) autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' } } @@ -985,13 +1026,14 @@ function enableIncludePrerelease(): void { } /** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ -export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void { +export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { if (!app.isPackaged || is.dev) { sendStatus({ state: 'not-available', userInitiated: true }) return } - if (options?.includePrerelease) { + const checkVariant = getUpdateCheckVariant(options) + if (checkVariant !== 'default') { clearPrereleaseFallbackContext() enableIncludePrerelease() } @@ -1009,10 +1051,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean if (checkAlreadyInFlight) { backgroundCheckPromotedToUserInitiated = true rearmActiveUpdateCheckStallTimer() - if (options?.includePrerelease) { - // Why: the in-flight check may have already pinned the stable feed. - // Queue a fresh RC check so Shift-click doesn't inherit a stable result. - pendingPrereleaseUserInitiatedCheckAfterInFlight = true + if (checkVariant !== 'default') { + // Why: the in-flight check may have already pinned the stable feed. Queue + // a fresh modifier check so it doesn't inherit a stale-channel result. + pendingUserInitiatedCheckAfterInFlight = checkVariant } return } @@ -1026,9 +1068,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean markUpdateCheckLaunched(attemptId) return autoUpdater.checkForUpdates() } - const run = pinDefaultReleaseFeed().then(launch) + const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!isActiveUpdateCheckAttempt(attemptId)) { + return false + } + userInitiatedCheck = false + finishActiveUpdateCheckAttempt() + recordCompletedUpdateCheck() + sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) + .then((launchResult) => { + if (launchResult === false) { + return + } + handleSettledUpdateCheckPromise(attemptId) + }) .catch((err) => { if (!isActiveUpdateCheckAttempt(attemptId)) { return diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index f496ebb6ed8..e072ccc6be8 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { Store } from '../persistence' -import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types' +import type { + CreateWorktreeResult, + UpdateCheckOptions, + WorktreeStartupLaunch +} from '../../shared/types' import { registerRepoHandlers } from '../ipc/repos' import { registerWorktreeHandlers } from '../ipc/worktrees' import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' @@ -445,7 +449,7 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) - ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => { + ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => { ensureAutoUpdaterConfigured() return checkForUpdatesFromMenu(options) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e5cb1ffb907..b09c59972d8 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -162,6 +162,7 @@ import type { StatsSummary, MemorySnapshot, TuiAgent, + UpdateCheckOptions, UpdateStatus, Worktree, WorktreeBaseStatusEvent, @@ -2158,7 +2159,7 @@ export type PreloadApi = { updater: { getVersion: () => Promise getStatus: () => Promise - check: (options?: { includePrerelease?: boolean }) => Promise + check: (options?: UpdateCheckOptions) => Promise download: () => Promise quitAndInstall: () => Promise dismissNudge: () => Promise diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index c67c40f8b73..a840fee8115 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -7,6 +7,10 @@ import { Button } from '../ui/button' import { SearchableSetting } from './SearchableSetting' import { SettingsSubsectionHeader } from './SettingsFormControls' import { translate } from '@/i18n/i18n' +import { + getPerfUpdateModifierLabel, + getUpdateCheckClickOptions +} from '@/lib/update-check-click-options' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -36,6 +40,8 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { } const [appVersion, setAppVersion] = useState(null) + const perfUpdateModifierLabel = getPerfUpdateModifierLabel() + const updateCheckHint = `Shift-click checks the latest RC; ${perfUpdateModifierLabel}-click checks the latest perf build.` useEffect(() => { let cancelled = false @@ -87,14 +93,10 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { ), @@ -250,6 +265,32 @@ describe('SidebarSettingsHelpMenu', () => { it('renders Check for Updates menu item', () => { const html = renderToStaticMarkup() expect(html).toContain('Check for Updates') + expect(html).toContain('Shift-click checks the latest RC') + expect(html).toMatch(/(⌘|Ctrl)-click checks the latest perf build/) + }) + + it('passes update-check modifier options through the updater bridge', async () => { + const container = await renderMenu() + const checkButton = findMenuItem(container, 'Check for Updates') + const primaryModifier = navigator.userAgent.includes('Mac') + ? { metaKey: true } + : { ctrlKey: true } + + await act(async () => { + checkButton.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })) + }) + await act(async () => { + checkButton.dispatchEvent(new MouseEvent('click', { bubbles: true, ...primaryModifier })) + }) + + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(1, { + includePrerelease: true, + includePerfPrerelease: false + }) + expect(mocks.updaterCheck).toHaveBeenNthCalledWith(2, { + includePrerelease: false, + includePerfPrerelease: true + }) }) it('renders shortcut keys in the settings tooltip', () => { diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index d74fcfb11aa..33c6dac18b5 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -33,6 +33,10 @@ import { SetupGuideProgressRing } from '../setup-guide/SetupGuideProgressRing' import { useSetupGuideProgress } from '../setup-guide/use-setup-guide-progress' import { SidebarFeedbackDialog } from './SidebarFeedbackDialog' import { translate } from '@/i18n/i18n' +import { + getPerfUpdateModifierLabel, + getUpdateCheckClickOptions +} from '@/lib/update-check-click-options' const DOCS_URL = 'https://www.onorca.dev/docs' const CHANGELOG_URL = 'https://onorca.dev/changelog' @@ -92,6 +96,8 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { const [isRestartingOrca, setIsRestartingOrca] = useState(false) const lastShowOnboardingAtRef = React.useRef(0) const mountedRef = useMountedRef() + const perfUpdateModifierLabel = getPerfUpdateModifierLabel() + const updateCheckHint = `Shift-click checks the latest RC; ${perfUpdateModifierLabel}-click checks the latest perf build.` const showMilestones = setupProgress.ready && setupProgress.coreDoneCount < setupProgress.coreTotal @@ -148,8 +154,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { } const handleCheckForUpdates = (event: Event): void => { - const shiftKey = (event as PointerEvent).shiftKey - void window.api.updater.check({ includePrerelease: shiftKey }) + void window.api.updater.check(getUpdateCheckClickOptions(event as PointerEvent)) } const openMilestones = (): void => { @@ -300,6 +305,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element { {updateStatus.state === 'checking' ? ( diff --git a/src/renderer/src/lib/update-check-click-options.test.ts b/src/renderer/src/lib/update-check-click-options.test.ts new file mode 100644 index 00000000000..a39cc9e6464 --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { + getPerfUpdateModifierLabel, + getUpdateCheckClickOptions +} from './update-check-click-options' + +function clickEvent(overrides: Partial>) { + return { + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides + } as Pick +} + +describe('getUpdateCheckClickOptions', () => { + it('uses Cmd on macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), true)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('uses Ctrl outside macOS for perf prerelease checks', () => { + expect(getUpdateCheckClickOptions(clickEvent({ ctrlKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: true + }) + expect(getUpdateCheckClickOptions(clickEvent({ metaKey: true }), false)).toEqual({ + includePrerelease: false, + includePerfPrerelease: false + }) + }) + + it('keeps Shift as the RC prerelease modifier', () => { + expect( + getUpdateCheckClickOptions(clickEvent({ shiftKey: true, ctrlKey: true }), false) + ).toEqual({ + includePrerelease: true, + includePerfPrerelease: true + }) + }) + + it('formats the perf modifier label by platform', () => { + expect(getPerfUpdateModifierLabel(true)).toBe('⌘') + expect(getPerfUpdateModifierLabel(false)).toBe('Ctrl') + }) +}) diff --git a/src/renderer/src/lib/update-check-click-options.ts b/src/renderer/src/lib/update-check-click-options.ts new file mode 100644 index 00000000000..d0824875ed3 --- /dev/null +++ b/src/renderer/src/lib/update-check-click-options.ts @@ -0,0 +1,22 @@ +import type { UpdateCheckOptions } from '../../../shared/types' +import { getShortcutPlatform } from './shortcut-platform' + +type UpdateCheckClickEvent = Pick + +function isMacShortcutPlatform(): boolean { + return getShortcutPlatform() === 'darwin' +} + +export function getPerfUpdateModifierLabel(isMac = isMacShortcutPlatform()): string { + return isMac ? '⌘' : 'Ctrl' +} + +export function getUpdateCheckClickOptions( + event: UpdateCheckClickEvent, + isMac = isMacShortcutPlatform() +): UpdateCheckOptions { + return { + includePrerelease: event.shiftKey, + includePerfPrerelease: isMac ? event.metaKey : event.ctrlKey + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index a17889b49cf..9cf6680e6ce 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2182,6 +2182,11 @@ export type ChangelogData = { releasesBehind: number | null } +export type UpdateCheckOptions = { + includePrerelease?: boolean + includePerfPrerelease?: boolean +} + export type UpdateStatus = | { state: 'idle' } | { state: 'checking'; userInitiated?: boolean }