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
148 changes: 148 additions & 0 deletions src/main/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,154 @@ describe('updater', () => {
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
})

it('honors a persisted prerelease channel during a menu check without an explicit Shift-click', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }

const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')

// Why: defer the startup background check so we observe the channel-driven
// opt-in from the *menu* call, not the auto timer.
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
getReleaseChannel: () => 'prerelease'
})
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)

checkForUpdatesFromMenu()

await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18-rc.1'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
})

it('honors a persisted prerelease channel during the startup background check', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }

const { setupAutoUpdater } = await import('./updater')

// Why: lastUpdateCheckAt=null makes the startup block in setupAutoUpdater
// schedule an immediate runBackgroundUpdateCheck, so we can observe the
// channel-driven RC opt-in without any manual menu invocation.
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
getReleaseChannel: () => 'prerelease'
})

await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 2, {
includePrerelease: true
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18-rc.1'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).toBe(true)
})

it('keeps the stable feed when the persisted channel is stable or unset', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18'])
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }

const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')

setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
getReleaseChannel: () => 'stable'
})

checkForUpdatesFromMenu()

await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.3.17', 1, {
includePrerelease: false
})
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
provider: 'generic',
url: 'https://github.com/stablyai/orca/releases/download/v1.3.18'
})
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
expect(autoUpdaterMock.allowPrerelease).not.toBe(true)
})

it('clears a session-sticky RC flag when a plain menu check runs after the channel reverts to stable', async () => {
appMock.getVersion.mockReturnValue('1.3.17')
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.3.18-rc.1'])
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
const callCount = autoUpdaterMock.checkForUpdates.mock.calls.length
autoUpdaterMock.emit('checking-for-update')
// Why: emit 'update-not-available' for the first (Shift-click) check so its
// state settles out of 'checking' — otherwise the second plain-click
// check would short-circuit as in-flight and skip the disable fetch.
if (callCount === 1) {
queueMicrotask(() => {
autoUpdaterMock.emit('update-not-available')
})
}
return Promise.resolve(undefined)
})
const mainWindow = { webContents: { send: vi.fn() } }

const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')

let channel: 'stable' | 'prerelease' = 'prerelease'
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
getReleaseChannel: () => channel
})

// Shift-click once — leaves the session sticky on the RC feed.
checkForUpdatesFromMenu({ includePrerelease: true })
await vi.waitFor(() => {
expect(autoUpdaterMock.allowPrerelease).toBe(true)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
// Why: wait for the first check to fully settle (state -> 'not-available')
// so the second menu call is not short-circuited as in-flight.
await vi.waitFor(() => {
const calls = mainWindow.webContents.send.mock.calls
expect(calls.some((c) => c[0] === 'updater:status' && c[1]?.state === 'not-available')).toBe(
true
)
})

// Switch the persisted channel back to stable and fire a plain menu check.
// The disable path in checkForUpdatesFromMenu must revert allowPrerelease
// and let pinDefaultReleaseFeed fetch the stable feed again — without this,
// the user's UI revert Stable <- Pre-Release would be ignored until restart.
channel = 'stable'
checkForUpdatesFromMenu()

// Why: the disable runs synchronously inside the menu call; assert it
// before awaiting the async fetch so a future timing change can't mask the
// regression of the no-disable path CodeRabbit flagged.
expect(autoUpdaterMock.allowPrerelease).toBe(false)

await vi.waitFor(() => {
expect(fetchNewerReleaseTagsMock).toHaveBeenLastCalledWith('1.3.17', 1, {
includePrerelease: false
})
})
})

it('leaves the feed URL alone for a normal user-initiated check', async () => {
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const mainWindow = { webContents: { send: vi.fn() } }
Expand Down
49 changes: 49 additions & 0 deletions src/main/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
let quitAndInstallInProgress = false
let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null
let _getLastUpdateCheckAt: (() => number | null) | null = null
// Why: persisted channel selection lives in the UI store (Settings > Updates);
// injected here so background and menu checks can honor it without importing
// the store directly. `null` (test/dev) is treated as 'stable'.
let _getReleaseChannel: (() => 'stable' | 'prerelease' | null) | null = null
let backgroundCheckLaunchPending = false
// Why: a manually promoted background check can emit an error event before the
// paired promise catch runs; keep the promotion attached to that launch.
Expand Down Expand Up @@ -919,6 +923,12 @@ function runBackgroundUpdateCheck(
// the persisted pending id for ordinary background checks so a nudge-driven
// card can still be dismissed correctly after relaunch or a later 24h check.
activeUpdateNudgeId = nudgeId
// Why: a persisted 'prerelease' channel must drive background checks (auto
// 24h timer, app focus resume, nudge) onto the RC feed too — otherwise only
// the Shift-click one-shot would ever opt in. Mirroring (and clearing) the
// flag here, before pinDefaultReleaseFeed reads it, also lets a UI switch
// back to 'stable' take effect without a process restart.
syncIncludePrereleaseToPersistedChannel()
// Why: autoUpdater.checkForUpdates() is async and 'checking-for-update'
// arrives on a later tick, so a second focus/resume event can slip in before
// currentStatus flips to 'checking'. Track the launch in memory to dedupe
Expand Down Expand Up @@ -984,6 +994,36 @@ function enableIncludePrerelease(): void {
includePrereleaseActive = true
}

function disableIncludePrerelease(): void {
if (!includePrereleaseActive) {
return
}
// Why: once the persisted channel flips back to `stable` (or was already
// stable), a session that had previously opted into RC — via an earlier
// Shift-click or an earlier Pre-Release channel choice — must drop the
// RC flags so the next pinDefaultReleaseFeed fetches stable tags again.
getAutoUpdater().allowPrerelease = false
includePrereleaseActive = false
}

/** True when this check should consult the RC feed — either the user
* Shift-clicked this menu invocation (`perClick === true`) or the persisted
* Settings > Updates channel is `prerelease`. */
function shouldIncludePrerelease(perClick?: boolean): boolean {
return perClick === true || _getReleaseChannel?.() === 'prerelease'
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Mirror the persisted channel onto the auto-updater's RC flags. Used by
* every background check so a UI change Stable ↔ Pre-Release takes effect
* on the very next check instead of waiting for a process restart. */
function syncIncludePrereleaseToPersistedChannel(): void {
if (shouldIncludePrerelease(false)) {
enableIncludePrerelease()
} else {
disableIncludePrerelease()
}
}

/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
if (!app.isPackaged || is.dev) {
Expand All @@ -992,8 +1032,13 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
}

if (options?.includePrerelease) {
// Why: an explicit Shift-click wins for this single invocation. The
// persisted channel never disables below; only the absense of a Shift
// click (and a non-prerelease channel) clears an RC flag set earlier.
clearPrereleaseFallbackContext()
enableIncludePrerelease()
} else {
syncIncludePrereleaseToPersistedChannel()
}

const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking'
Expand Down Expand Up @@ -1142,6 +1187,9 @@ export function setupAutoUpdater(
getDismissedUpdateNudgeId?: () => string | null
setPendingUpdateNudgeId?: (id: string | null) => void
setDismissedUpdateNudgeId?: (id: string | null) => void
/** Reads the persisted UI channel selection so background and menu update
* checks can opt into release candidates without a hidden Shift-click. */
getReleaseChannel?: () => 'stable' | 'prerelease' | null
}
): void {
mainWindowRef = mainWindow
Expand All @@ -1152,6 +1200,7 @@ export function setupAutoUpdater(
_getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null
_setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null
_setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null
_getReleaseChannel = opts?.getReleaseChannel ?? null

if (!app.isPackaged && !is.dev) {
return
Expand Down
2 changes: 2 additions & 0 deletions src/main/window/attach-main-window-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export function attachMainWindowServices(
registerFileDropRelay(mainWindow)
setupAutoUpdater(mainWindow, {
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,
getReleaseChannel: () =>
store.getUI().releaseChannel === 'prerelease' ? 'prerelease' : 'stable',
onBeforeQuit: async () => {
try {
await options?.onBeforeUpdateQuit?.()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ import { toast } from 'sonner'
import { useAppStore } from '../../store'
import { Button } from '../ui/button'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import {
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader
} from './SettingsFormControls'
import { translate } from '@/i18n/i18n'

export function GeneralUpdateSettingsSection(): React.JSX.Element {
const updateStatus = useAppStore((s) => s.updateStatus)
const releaseChannel = useAppStore((s) => s.releaseChannel)
const setReleaseChannel = useAppStore((s) => s.setReleaseChannel)
// Why: the 'error' variant of UpdateStatus does not carry a `version` field.
// The main process emits `{ state: 'error' }` for both check failures (no
// version known yet) and download/install failures (version was known from
Expand Down Expand Up @@ -242,6 +248,66 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
))}
</p>
</SearchableSetting>

<SearchableSetting
title={translate(
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.title',
'Release Channel'
)}
description={translate(
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.description',
'Stable skips release candidates; Pre-Release opts into early rc builds for every check.'
)}
keywords={[
'update',
'updates',
'channel',
'release channel',
'stable',
'prerelease',
'rc',
'release candidate',
'beta'
]}
>
<SettingsRow
label={translate(
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.title',
'Release Channel'
)}
description={translate(
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.description',
'Stable skips release candidates; Pre-Release opts into early rc builds for every check.'
)}
control={
<SettingsSegmentedControl<'stable' | 'prerelease'>
ariaLabel={translate(
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.title',
'Release Channel'
)}
value={releaseChannel}
onChange={(channel) => setReleaseChannel(channel)}
size="sm"
options={[
{
value: 'stable',
label: translate(
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.stable',
'Stable'
)
},
{
value: 'prerelease',
label: translate(
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.prerelease',
'Pre-Release'
)
}
]}
/>
}
/>
</SearchableSetting>
</section>
)
}
38 changes: 38 additions & 0 deletions src/renderer/src/components/settings/general-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,44 @@ export const getGeneralUpdateSearchEntries = createLocalizedCatalog(() => [
),
...translateSearchKeyword('auto.components.settings.general.search.e49e739a59', 'download')
]
},
{
title: translate(
'auto.components.settings.general.search.releaseChannel.title',
'Release Channel'
),
description: translate(
'auto.components.settings.general.search.releaseChannel.description',
'Stable skips release candidates; Pre-Release opts into early rc builds for every check.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.general.search.f89a94773c', 'update'),
...translateSearchKeyword(
'auto.components.settings.general.search.releaseChannel.titleId',
'channel'
),
...translateSearchKeyword(
'auto.components.settings.general.search.releaseChannel.titleId',
'release channel'
),
...translateSearchKeyword(
'auto.components.settings.general.search.releaseChannel.stableId',
'stable'
),
...translateSearchKeyword(
'auto.components.settings.general.search.releaseChannel.prereleaseId',
'prerelease'
),
...translateSearchKeyword('auto.components.settings.general.search.releaseChannel.rc', 'rc'),
...translateSearchKeyword(
'auto.components.settings.general.search.releaseChannel.releaseCandidate',
'release candidate'
),
...translateSearchKeyword(
'auto.components.settings.general.search.releaseChannel.beta',
'beta'
)
]
}
])

Expand Down
Loading