From 179c2bd29e358e3373722ee0b92538fef3fdbecb Mon Sep 17 00:00:00 2001
From: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Date: Thu, 25 Jun 2026 14:23:46 +0200
Subject: [PATCH 1/3] feat(updates): persist a Stable / Pre-Release channel
selector in Settings > Updates
Add a Release Channel segmented control (Stable / Pre-Release) under
Settings > Updates so users can opt into release-candidate builds
without the hidden Shift-click menu shortcut. The choice persists via
the existing -> IPC channel
and is honored by both manual menu checks and the auto background
check (24h timer, focus resume, nudge-driven).
- shared/types: add to PersistedUIState.
- main/updater: inject a callback at setupAutoUpdater
and consult it through , which opts
into the RC feed when the persisted channel is 'prerelease' OR a
Shift-click one-shot fires. runBackgroundUpdateCheck enables
prerelease before pinDefaultReleaseFeed so the right tags are fetched.
- main/window: store.getUI().releaseChannel feeds the new callback.
- store/slices/ui: hydrate the persisted channel via normalizeReleaseChannel;
setReleaseChannel short-circuits unchanged writes and persists via
window.api.ui.set, mirroring the setupGuideSidebarDismissed pattern.
- settings: new SearchableSetting row with SettingsSegmentedControl.
- Shift-click affordance in register-app-menu is preserved as a
power-user one-shot that overrides a single check.
Tests:
- updater.test.ts: persisted 'prerelease' drives a menu check, the
startup background check, and a stable-channel check onto the right
feed tags. Feed URL and allowPrerelease assertions mirror the
existing Shift-click test.
- updater.test.ts: persisted 'prerelease' is honored on the startup
background check.
- updater.test.ts: persisted 'stable' keeps the stable feed pinned.
- ui.test.ts: idempotent persistence (call once, no double-write);
hydration restores explicit 'prerelease' and falls back to 'stable'.
---
src/main/updater.test.ts | 88 +++++++++++++++++++
src/main/updater.ts | 24 +++++
.../window/attach-main-window-services.ts | 2 +
.../settings/GeneralUpdateSettingsSection.tsx | 58 +++++++++++-
src/renderer/src/i18n/locales/en.json | 8 +-
src/renderer/src/i18n/locales/es.json | 8 +-
src/renderer/src/i18n/locales/ja.json | 8 +-
src/renderer/src/i18n/locales/ko.json | 8 +-
src/renderer/src/i18n/locales/zh.json | 8 +-
src/renderer/src/store/slices/ui.test.ts | 31 +++++++
src/renderer/src/store/slices/ui.ts | 20 +++++
src/shared/types.ts | 4 +
12 files changed, 261 insertions(+), 6 deletions(-)
diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts
index 60ff680a01..f569e291c9 100644
--- a/src/main/updater.test.ts
+++ b/src/main/updater.test.ts
@@ -1012,6 +1012,94 @@ 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('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 0d38388008..40c2134d21 100644
--- a/src/main/updater.ts
+++ b/src/main/updater.ts
@@ -58,6 +58,10 @@ let pendingQuitAndInstallTimer: ReturnType | 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.
@@ -919,6 +923,13 @@ 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. Done before pinDefaultReleaseFeed
+ // reads `includePrereleaseActive` so the right tags are fetched.
+ if (shouldIncludePrerelease(false)) {
+ enableIncludePrerelease()
+ }
// 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
@@ -984,6 +995,13 @@ function enableIncludePrerelease(): void {
includePrereleaseActive = true
}
+/** 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'
+}
+
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
if (!app.isPackaged || is.dev) {
@@ -993,6 +1011,8 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
if (options?.includePrerelease) {
clearPrereleaseFallbackContext()
+ }
+ if (shouldIncludePrerelease(options?.includePrerelease)) {
enableIncludePrerelease()
}
@@ -1142,6 +1162,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
@@ -1152,6 +1175,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
diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts
index eace514bcc..ab7f9dd20e 100644
--- a/src/main/window/attach-main-window-services.ts
+++ b/src/main/window/attach-main-window-services.ts
@@ -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?.()
diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
index c67c40f8b7..3a3c923df2 100644
--- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
+++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
@@ -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
@@ -242,6 +248,56 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
))}
+
+
+
+ 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'
+ )
+ }
+ ]}
+ />
+ }
+ />
+
)
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 57eb0df34b..9389fecfb8 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -5026,7 +5026,13 @@
"31fd7150cf": "Checking for updates...",
"3394d1f663": "checking",
"d69a09b672": "Updates are checked automatically on launch.",
- "7173352632": "idle"
+ "7173352632": "idle",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check.",
+ "stable": "Stable",
+ "prerelease": "Pre-Release"
+ }
},
"GeneralWorkspaceSettingsSection": {
"3d538a98f7": "Choose apps available from a workspace's Open in menu.",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 0199066602..4835619f0a 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -5026,7 +5026,13 @@
"31fd7150cf": "Buscando actualizaciones...",
"3394d1f663": "de cheques",
"d69a09b672": "Las actualizaciones se verifican automáticamente al iniciarse.",
- "7173352632": "idle"
+ "7173352632": "idle",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check.",
+ "stable": "Stable",
+ "prerelease": "Pre-Release"
+ }
},
"GeneralWorkspaceSettingsSection": {
"3d538a98f7": "Elija las aplicaciones disponibles en el menú Abrir en de un espacio de trabajo.",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 27eb518527..df064a2b98 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -5011,7 +5011,13 @@
"31fd7150cf": "アップデートをチェックしています...",
"3394d1f663": "チェック中",
"d69a09b672": "アップデートは起動時に自動的にチェックされます。",
- "7173352632": "idle"
+ "7173352632": "idle",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check.",
+ "stable": "Stable",
+ "prerelease": "Pre-Release"
+ }
},
"GeneralWorkspaceSettingsSection": {
"3d538a98f7": "ワークスペースの「開く」メニューから利用可能なアプリを選択します。",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 8f4501add1..baf4023902 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -5011,7 +5011,13 @@
"31fd7150cf": "업데이트 확인 중...",
"3394d1f663": "확인 중",
"d69a09b672": "업데이트는 시작 시 자동으로 확인됩니다.",
- "7173352632": "idle"
+ "7173352632": "idle",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check.",
+ "stable": "Stable",
+ "prerelease": "Pre-Release"
+ }
},
"GeneralWorkspaceSettingsSection": {
"3d538a98f7": "워크스페이스의 열기 메뉴에서 사용 가능한 앱을 선택하세요.",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 9d4f9cd8db..e8bc09efdf 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -5011,7 +5011,13 @@
"31fd7150cf": "正在检查更新...",
"3394d1f663": "检查中",
"d69a09b672": "启动时会自动检查更新。",
- "7173352632": "空闲"
+ "7173352632": "空闲",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check.",
+ "stable": "Stable",
+ "prerelease": "Pre-Release"
+ }
},
"GeneralWorkspaceSettingsSection": {
"3d538a98f7": "从工作区的“打开方式”菜单中选择可用的应用程序。",
diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts
index 4b64524c78..3a137844a4 100644
--- a/src/renderer/src/store/slices/ui.test.ts
+++ b/src/renderer/src/store/slices/ui.test.ts
@@ -3032,3 +3032,34 @@ describe('createUISlice space navigation', () => {
expect(store.getState().activeView).toBe('tasks')
})
})
+
+describe('createUISlice release channel', () => {
+ it('persists a release channel change once', () => {
+ const setMock = vi.fn(() => Promise.resolve())
+ vi.stubGlobal('window', {
+ api: {
+ ui: {
+ set: setMock
+ }
+ }
+ })
+ const store = createUIStore()
+
+ store.getState().setReleaseChannel('prerelease')
+ store.getState().setReleaseChannel('prerelease')
+
+ expect(store.getState().releaseChannel).toBe('prerelease')
+ expect(setMock).toHaveBeenCalledTimes(1)
+ expect(setMock).toHaveBeenCalledWith({ releaseChannel: 'prerelease' })
+ })
+
+ it('hydrates an explicit prerelease channel and falls back to stable when missing', () => {
+ const store = createUIStore()
+
+ store.getState().hydratePersistedUI(makePersistedUI({ releaseChannel: 'prerelease' }))
+ expect(store.getState().releaseChannel).toBe('prerelease')
+
+ store.getState().hydratePersistedUI(makePersistedUI({ releaseChannel: undefined }))
+ expect(store.getState().releaseChannel).toBe('stable')
+ })
+})
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 5e0c78c294..070d1c81d8 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -451,6 +451,14 @@ function hydratedUIPartialMatchesState(state: AppState, hydrated: Partial void
editorFontZoomLevel: number
setEditorFontZoomLevel: (level: number) => void
+ releaseChannel: 'stable' | 'prerelease'
+ setReleaseChannel: (channel: 'stable' | 'prerelease') => void
hydratePersistedUI: (ui: PersistedUIState) => void
updateStatus: UpdateStatus
setUpdateStatus: (status: UpdateStatus) => void
@@ -2187,6 +2197,15 @@ export const createUISlice: StateCreator = (set, get)
setUIZoomLevel: (level) => set({ uiZoomLevel: level }),
editorFontZoomLevel: 0,
setEditorFontZoomLevel: (level) => set({ editorFontZoomLevel: level }),
+ releaseChannel: 'stable',
+ setReleaseChannel: (channel) =>
+ set((s) => {
+ if (s.releaseChannel === channel) {
+ return s
+ }
+ window.api.ui.set({ releaseChannel: channel }).catch(console.error)
+ return { releaseChannel: channel }
+ }),
hydratePersistedUI: (ui) =>
set((s) => {
@@ -2279,6 +2298,7 @@ export const createUISlice: StateCreator = (set, get)
collapsedGroups: new Set(ui.collapsedGroups ?? []),
uiZoomLevel: ui.uiZoomLevel ?? 0,
editorFontZoomLevel: ui.editorFontZoomLevel ?? 0,
+ releaseChannel: normalizeReleaseChannel(ui.releaseChannel),
worktreeCardProperties: normalizeWorktreeCardProperties(ui.worktreeCardProperties),
_worktreeCardModeDefaulted: ui._worktreeCardModeDefaulted === true,
agentActivityDisplayMode: normalizeAgentActivityDisplayMode(ui.agentActivityDisplayMode),
diff --git a/src/shared/types.ts b/src/shared/types.ts
index ff4992dee3..daaf21ee4c 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -3104,6 +3104,10 @@ export type PersistedUIState = {
collapsedGroups: string[]
uiZoomLevel: number
editorFontZoomLevel: number
+ /** Update feed the auto-updater fetches. 'stable' (default) skips release
+ * candidates; 'prerelease' opts into rc.* builds. The Shift-click menu
+ * one-shot overrides this only for a single manual check. */
+ releaseChannel?: 'stable' | 'prerelease'
worktreeCardProperties: WorktreeCardProperty[]
/** One-shot migration flag for deriving card properties from the two
* user-facing worktree card modes. */
From 85b47e79fb3830337e83f0e315f1a3fc9e567222 Mon Sep 17 00:00:00 2001
From: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Date: Thu, 25 Jun 2026 14:36:17 +0200
Subject: [PATCH 2/3] fix(updates): reset the RC feed flag when the persisted
channel reverts to stable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CodeRabbit (🟠 Major) on PR #6359: once `includePrereleaseActive` becomes
true (via Shift-click or the persisted Pre-Release channel), there was
no disable path — switching the channel back to 'stable' in Settings >
Updates left subsequent menu and background checks still consuming
release-candidate tags until the process restarted.
Add `disableIncludePrerelease()` mirroring `enableIncludePrerelease()`
and a `syncIncludePrereleaseToPersistedChannel()` wrapper. Both the
menu and the background check now mirror the persisted channel onto the
auto-updater's `allowPrerelease` flag before pinning the release feed:
- Shift-click (`includePrerelease:true`) still wins as a per-click
override and calls `enableIncludePrerelease` directly.
- A plain menu check or background check now enables OR disables based
on the persisted channel, so a UI revert Pre-Release -> Stable takes
effect on the very next check.
Test:
- New: 'clears a session-sticky RC flag when a plain menu check runs
after the channel reverts to stable' — captures the regression from
CodeRabbit's sticky-state analysis: Shift-click sets
autoUpdater.allowPrerelease = true, the persisted channel then flips
to 'stable', the next plain menu check resets the flag and fetches
the stable feed (tag count = 1, includePrerelease = false).
Verification:
- pnpm typecheck clean.
- pnpm lint clean (incl. switch-exhaustiveness + localization catalog/parity).
- pnpm vitest run src/main/updater.test.ts --config config/vitest.config.ts
-> 76 tests pass (added 1, total +1 vs prior commit's 75).
---
src/main/updater.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++
src/main/updater.ts | 39 +++++++++++++++++++++-----
2 files changed, 92 insertions(+), 7 deletions(-)
diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts
index f569e291c9..4035301ef4 100644
--- a/src/main/updater.test.ts
+++ b/src/main/updater.test.ts
@@ -1100,6 +1100,66 @@ describe('updater', () => {
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() } }
diff --git a/src/main/updater.ts b/src/main/updater.ts
index 40c2134d21..bcbba6ef1b 100644
--- a/src/main/updater.ts
+++ b/src/main/updater.ts
@@ -925,11 +925,10 @@ function runBackgroundUpdateCheck(
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. Done before pinDefaultReleaseFeed
- // reads `includePrereleaseActive` so the right tags are fetched.
- if (shouldIncludePrerelease(false)) {
- enableIncludePrerelease()
- }
+ // 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
@@ -995,6 +994,18 @@ 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`. */
@@ -1002,6 +1013,17 @@ function shouldIncludePrerelease(perClick?: boolean): boolean {
return perClick === true || _getReleaseChannel?.() === 'prerelease'
}
+/** 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) {
@@ -1010,10 +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()
- }
- if (shouldIncludePrerelease(options?.includePrerelease)) {
enableIncludePrerelease()
+ } else {
+ syncIncludePrereleaseToPersistedChannel()
}
const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking'
From 2557a2fd8ca080bbd9a64811a9eaffbee41e2bd0 Mon Sep 17 00:00:00 2001
From: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Date: Thu, 25 Jun 2026 14:50:06 +0200
Subject: [PATCH 3/3] fix(settings): index the Updates Release Channel row for
Settings search
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Searching "updates" in Settings revealed the Updates section (because the
section-level check matched the existing "Check for Updates" entry) but the
Release Channel SearchableSetting returned null — its own inline keywords
list omitted "update"/"updates"/"channel", so the row was hidden while the
section was visible, leaving the title appearing without its segmented
control.
- inline keywords on the Release Channel SearchableSetting now include
update / updates / channel in addition to release channel / stable /
prerelease / rc / release candidate / beta.
- getGeneralUpdateSearchEntries now includes the Release Channel row
(title + description + matching localized keyword set) so section-level
filter logic matches against the row content as well.
Verification:
- pnpm typecheck clean, pnpm lint clean (incl. localization catalog/parity).
- 224 tests pass (updater + ui slice + GeneralPane).
- Dev-run confirms searching "updates" / "channel" now renders the
Release Channel row with its Stable / Pre-Release segmented control.
---
.../settings/GeneralUpdateSettingsSection.tsx | 12 +++++-
.../src/components/settings/general-search.ts | 38 +++++++++++++++++++
src/renderer/src/i18n/locales/en.json | 6 ++-
src/renderer/src/i18n/locales/es.json | 6 ++-
src/renderer/src/i18n/locales/ja.json | 6 ++-
src/renderer/src/i18n/locales/ko.json | 6 ++-
src/renderer/src/i18n/locales/zh.json | 6 ++-
7 files changed, 74 insertions(+), 6 deletions(-)
diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
index 3a3c923df2..6e1578a812 100644
--- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
+++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx
@@ -258,7 +258,17 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
'auto.components.settings.GeneralUpdateSettingsSection.releaseChannel.description',
'Stable skips release candidates; Pre-Release opts into early rc builds for every check.'
)}
- keywords={['release channel', 'stable', 'prerelease', 'rc', 'release candidate', 'beta']}
+ keywords={[
+ 'update',
+ 'updates',
+ 'channel',
+ 'release channel',
+ 'stable',
+ 'prerelease',
+ 'rc',
+ 'release candidate',
+ 'beta'
+ ]}
>
[
),
...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'
+ )
+ ]
}
])
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 9389fecfb8..3d0c6072ed 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -7215,7 +7215,11 @@
"161a86a9da": "Confirm before closing pinned tabs",
"8e593f04fc": "Show a confirmation dialog before a pinned tab is closed.",
"defaultProjectRuntime": "Default Project Runtime",
- "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
+ "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check."
+ }
}
},
"git": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 4835619f0a..555c1d7b1d 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -7178,7 +7178,11 @@
"161a86a9da": "Confirmar antes de cerrar pestañas fijadas",
"8e593f04fc": "Muestra un diálogo de confirmación antes de cerrar una pestaña fijada.",
"defaultProjectRuntime": "Default Project Runtime",
- "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
+ "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check."
+ }
}
},
"git": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index df064a2b98..cf377e3446 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -7200,7 +7200,11 @@
"161a86a9da": "ピン留めしたタブを閉じる前に確認",
"8e593f04fc": "ピン留めしたタブを閉じる前に確認ダイアログを表示します。",
"defaultProjectRuntime": "Default Project Runtime",
- "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects."
+ "defaultProjectRuntimeDescription": "Choose the runtime inherited by local Windows projects.",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check."
+ }
}
},
"git": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index baf4023902..d81a383963 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -7163,7 +7163,11 @@
"161a86a9da": "고정된 탭을 닫기 전에 확인",
"8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.",
"defaultProjectRuntime": "기본 프로젝트 런타임",
- "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다."
+ "defaultProjectRuntimeDescription": "로컬 Windows 프로젝트가 상속할 런타임을 선택합니다.",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check."
+ }
}
},
"git": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index e8bc09efdf..aa4a83421d 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -7163,7 +7163,11 @@
"161a86a9da": "关闭固定标签页前确认",
"8e593f04fc": "关闭固定标签页前显示确认对话框。",
"defaultProjectRuntime": "默认项目运行时",
- "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。"
+ "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。",
+ "releaseChannel": {
+ "title": "Release Channel",
+ "description": "Stable skips release candidates; Pre-Release opts into early rc builds for every check."
+ }
}
},
"git": {