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": {