diff --git a/src/main/index.ts b/src/main/index.ts index 29996484..efbd2809 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -163,7 +163,7 @@ app.whenReady().then(async () => { runtime.setAgentBrowserBridge(new AgentBrowserBridge(browserManager)) nativeTheme.themeSource = store.getSettings().theme ?? 'system' registerAppMenu({ - onCheckForUpdates: () => checkForUpdatesFromMenu(), + onCheckForUpdates: (options) => checkForUpdatesFromMenu(options), onOpenSettings: () => { mainWindow?.webContents.send('ui:openSettings') }, diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index f53bbfa2..06f8d881 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -110,6 +110,36 @@ describe('registerAppMenu', () => { expect(reloadMock).not.toHaveBeenCalled() }) + it('includes prereleases when Check for Updates is clicked with cmd+shift', () => { + const options = buildMenuOptions() + registerAppMenu(options) + + const template = buildFromTemplateMock.mock.calls[0][0] as Electron.MenuItemConstructorOptions[] + const appMenu = template.find((item) => item.label === 'Orca') + const submenu = appMenu?.submenu as Electron.MenuItemConstructorOptions[] + const item = submenu.find((entry) => entry.label === 'Check for Updates...') + + item?.click?.( + {} as never, + undefined as never, + { metaKey: true, shiftKey: true } as Electron.KeyboardEvent + ) + item?.click?.( + {} as never, + undefined as never, + { ctrlKey: true, shiftKey: 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 }] + ]) + }) + it('shows the worktree palette shortcut as a display-only menu hint', () => { registerAppMenu(buildMenuOptions()) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 94440ed5..e2d560a4 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -2,7 +2,7 @@ import { BrowserWindow, Menu, app } from 'electron' type RegisterAppMenuOptions = { onOpenSettings: () => void - onCheckForUpdates: () => void + onCheckForUpdates: (options: { includePrerelease: boolean }) => void onZoomIn: () => void onZoomOut: () => void onZoomReset: () => void @@ -38,7 +38,19 @@ export function registerAppMenu({ { role: 'about' }, { label: 'Check for Updates...', - click: () => onCheckForUpdates() + // Why: holding Cmd+Shift (or Ctrl+Shift on win/linux) while clicking + // opts this check into the release-candidate channel. The event + // carries the modifier keys down from the native menu — we only act + // on the mouse chord, not accelerator-triggered invocations (there + // is no accelerator on this item, so triggeredByAccelerator should + // always be false here, but guarding makes the intent explicit). + click: (_menuItem, _window, event) => { + const includePrerelease = + !event.triggeredByAccelerator && + (event.metaKey === true || event.ctrlKey === true) && + event.shiftKey === true + onCheckForUpdates({ includePrerelease }) + } }, { label: 'Settings', diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index e55bc913..89e935ee 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -48,12 +48,14 @@ const { autoUpdaterMock.downloadUpdate.mockReset() autoUpdaterMock.quitAndInstall.mockReset() autoUpdaterMock.setFeedURL.mockClear() + autoUpdaterMock.allowPrerelease = false delete (autoUpdaterMock as Record).verifyUpdateCodeSignature } const autoUpdaterMock = { autoDownload: false, autoInstallOnAppQuit: false, + allowPrerelease: false, on, checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), @@ -203,6 +205,47 @@ describe('updater', () => { ) }) + it('opts into the RC channel when checkForUpdatesFromMenu is called with includePrerelease', async () => { + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + // Why: pass a recent timestamp so the startup background check is + // deferred. We want to observe the state of the updater *before* any + // RC-mode call, not race with the startup check. + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length + expect(autoUpdaterMock.allowPrerelease).not.toBe(true) + + checkForUpdatesFromMenu({ includePrerelease: true }) + + expect(autoUpdaterMock.allowPrerelease).toBe(true) + const newCalls = autoUpdaterMock.setFeedURL.mock.calls.slice(setupFeedUrlCalls) + expect(newCalls).toEqual([[{ provider: 'github', owner: 'stablyai', repo: 'orca' }]]) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + // Second RC-mode invocation should not re-set the feed URL. + checkForUpdatesFromMenu({ includePrerelease: true }) + expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1) + }) + + it('leaves the feed URL alone for a normal user-initiated check', async () => { + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + const mainWindow = { webContents: { send: vi.fn() } } + + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + const initialFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length + + checkForUpdatesFromMenu() + checkForUpdatesFromMenu({ includePrerelease: false }) + + expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(initialFeedUrlCalls) + expect(autoUpdaterMock.allowPrerelease).not.toBe(true) + }) + it('defers quitAndInstall through the shared main-process entrypoint', async () => { vi.useFakeTimers() diff --git a/src/main/updater.ts b/src/main/updater.ts index a599bd4b..7e788ca8 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -25,6 +25,14 @@ let currentStatus: UpdateStatus = { state: 'idle' } let userInitiatedCheck = false let onBeforeQuitCleanup: (() => void) | null = null let autoUpdaterInitialized = false +// Why: Cmd+Shift-clicking "Check for Updates" opts the user into the RC +// release channel for the rest of this process. We switch to the GitHub +// provider with allowPrerelease=true so both the check AND any follow-up +// download resolve against the same (possibly prerelease) release manifest. +// Resetting only after the check would leave a downloaded RC pointing at a +// feed URL that no longer advertises it. See design comment in +// enableIncludePrerelease. +let includePrereleaseActive = false let availableVersion: string | null = null let availableReleaseUrl: string | null = null let pendingCheckFailureKey: string | null = null @@ -267,13 +275,36 @@ export function checkForUpdates(): void { runBackgroundUpdateCheck() } +function enableIncludePrerelease(): void { + if (includePrereleaseActive) { + return + } + // Why: the default feed points at GitHub's /releases/latest/download/ + // manifest, which is scoped to the most recent non-prerelease release. + // Switch to the native github provider with allowPrerelease so latest.yml + // is sourced from the newest release on the repo regardless of the + // prerelease flag. Staying on this feed for the rest of the process + // keeps the download manifest consistent with the check result. + autoUpdater.allowPrerelease = true + autoUpdater.setFeedURL({ + provider: 'github', + owner: 'stablyai', + repo: 'orca' + }) + includePrereleaseActive = true +} + /** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ -export function checkForUpdatesFromMenu(): void { +export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void { if (!app.isPackaged || is.dev) { sendStatus({ state: 'not-available', userInitiated: true }) return } + if (options?.includePrerelease) { + enableIncludePrerelease() + } + userInitiatedCheck = true // Why: a manual check is independent of any active nudge campaign. Reset the // nudge marker so the resulting status is not decorated with activeNudgeId, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 60d7f21f..fbee21bb 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -231,7 +231,9 @@ export function registerUpdaterHandlers(_store: Store): void { ipcMain.handle('updater:getStatus', () => getUpdateStatus()) ipcMain.handle('updater:getVersion', () => app.getVersion()) - ipcMain.handle('updater:check', () => checkForUpdatesFromMenu()) + ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => + checkForUpdatesFromMenu(options) + ) ipcMain.handle('updater:download', () => downloadUpdate()) ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()) ipcMain.handle('updater:dismissNudge', () => dismissNudge()) diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 1a33968d..d8549873 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -484,7 +484,7 @@ export type PreloadApi = { updater: { getVersion: () => Promise getStatus: () => Promise - check: () => Promise + check: (options?: { includePrerelease?: boolean }) => Promise download: () => Promise quitAndInstall: () => Promise dismissNudge: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 44b461fb..1aa3460a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -826,7 +826,8 @@ const api = { updater: { getStatus: (): Promise => ipcRenderer.invoke('updater:getStatus'), getVersion: (): Promise => ipcRenderer.invoke('updater:getVersion'), - check: (): Promise => ipcRenderer.invoke('updater:check'), + check: (options?: { includePrerelease?: boolean }): Promise => + ipcRenderer.invoke('updater:check', options), download: (): Promise => ipcRenderer.invoke('updater:download'), dismissNudge: (): Promise => ipcRenderer.invoke('updater:dismissNudge'), quitAndInstall: async (): Promise => { diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 4e3cdc6e..d7464d0f 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -13,11 +13,15 @@ export { EXPERIMENTAL_PANE_SEARCH_ENTRIES } type ExperimentalPaneProps = { settings: GlobalSettings updateSettings: (updates: Partial) => void + /** Hidden-experimental group is only rendered once the user has unlocked + * it via Cmd+Shift-clicking the Experimental sidebar entry. */ + hiddenExperimentalUnlocked?: boolean } export function ExperimentalPane({ settings, - updateSettings + updateSettings, + hiddenExperimentalUnlocked = false }: ExperimentalPaneProps): React.JSX.Element { const searchQuery = useAppStore((s) => s.settingsSearchQuery) // Why: "daemon enabled at startup" is the effective runtime state, read @@ -134,6 +138,44 @@ export function ExperimentalPane({ ) : null} ) : null} + + {hiddenExperimentalUnlocked ? : null} ) } + +// Why: anything in this group is deliberately unfinished or staff-only. The +// orange treatment (header tint, label colors) is the shared visual signal +// for hidden-experimental items so future entries inherit the same +// affordance without another round of styling decisions. +function HiddenExperimentalGroup(): React.JSX.Element { + return ( +
+
+

+ Hidden experimental +

+

+ Unlisted toggles for internal testing. Nothing here is supported. +

+
+ +
+
+ +

+ Does nothing today. Reserved as the first slot for hidden experimental options. +

+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index dd6ea4b3..ead6694d 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -768,7 +768,14 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea