mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 00:03:55 +00:00
Add perf prerelease update check modifier
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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 }]
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ReleaseFeedTag[] | null> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
@@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise<boolean> {
|
||||
*/
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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() } }
|
||||
|
||||
+85
-26
@@ -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<void>) | 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<void> {
|
||||
async function pinDefaultReleaseFeed(
|
||||
variant: UpdateCheckVariant = 'default'
|
||||
): Promise<ReleaseFeedPreflightResult> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
`[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<void> {
|
||||
)
|
||||
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<void> {
|
||||
`[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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -162,6 +162,7 @@ import type {
|
||||
StatsSummary,
|
||||
MemorySnapshot,
|
||||
TuiAgent,
|
||||
UpdateCheckOptions,
|
||||
UpdateStatus,
|
||||
Worktree,
|
||||
WorktreeBaseStatusEvent,
|
||||
@@ -2158,7 +2159,7 @@ export type PreloadApi = {
|
||||
updater: {
|
||||
getVersion: () => Promise<string>
|
||||
getStatus: () => Promise<UpdateStatus>
|
||||
check: (options?: { includePrerelease?: boolean }) => Promise<void>
|
||||
check: (options?: UpdateCheckOptions) => Promise<void>
|
||||
download: () => Promise<void>
|
||||
quitAndInstall: () => Promise<void>
|
||||
dismissNudge: () => Promise<void>
|
||||
|
||||
@@ -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<string | null>(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 {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
// Why: Shift-click opts this check into the release-candidate
|
||||
// channel. Keep the affordance hidden; it's a power-user
|
||||
// shortcut, not a discoverable toggle.
|
||||
onClick={(event) =>
|
||||
window.api.updater.check({
|
||||
includePrerelease: event.shiftKey
|
||||
})
|
||||
}
|
||||
// Why: modifier-click channels are power-user update affordances, not
|
||||
// persistent settings toggles.
|
||||
onClick={(event) => window.api.updater.check(getUpdateCheckClickOptions(event))}
|
||||
title={updateCheckHint}
|
||||
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
|
||||
className="gap-2"
|
||||
>
|
||||
|
||||
@@ -58,8 +58,23 @@ vi.mock('../setup-guide/SetupGuideProgressRing', () => ({
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
|
||||
<button data-testid="menu-item" onClick={onSelect}>
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
disabled,
|
||||
onSelect,
|
||||
title
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
onSelect?: (event: Event) => void
|
||||
title?: string
|
||||
}) => (
|
||||
<button
|
||||
data-testid="menu-item"
|
||||
disabled={disabled}
|
||||
onClick={(event) => onSelect?.(event.nativeEvent)}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
@@ -250,6 +265,32 @@ describe('SidebarSettingsHelpMenu', () => {
|
||||
it('renders Check for Updates menu item', () => {
|
||||
const html = renderToStaticMarkup(<SidebarSettingsHelpMenu />)
|
||||
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', () => {
|
||||
|
||||
@@ -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 {
|
||||
<DropdownMenuItem
|
||||
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
|
||||
onSelect={handleCheckForUpdates}
|
||||
title={updateCheckHint}
|
||||
>
|
||||
{updateStatus.state === 'checking' ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getPerfUpdateModifierLabel,
|
||||
getUpdateCheckClickOptions
|
||||
} from './update-check-click-options'
|
||||
|
||||
function clickEvent(overrides: Partial<Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>>) {
|
||||
return {
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...overrides
|
||||
} as Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { UpdateCheckOptions } from '../../../shared/types'
|
||||
import { getShortcutPlatform } from './shortcut-platform'
|
||||
|
||||
type UpdateCheckClickEvent = Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user