Compare commits

...

5 commits

Author SHA1 Message Date
github-actions[bot]
8c2554ab28 release: 1.3.8-rc.2 [rc-slot:2026-04-21-03] 2026-04-21 10:26:10 +00:00
Neil
d66d86645d
feat(settings): Cmd+Shift affordances for RC channel and hidden experimental (#887)
Cmd+Shift-click "Check for Updates" (menu or Settings > General) opts
into the RC release channel by switching the feed to the github provider
with allowPrerelease=true for the rest of the process.

Cmd+Shift-click the Experimental sidebar entry reveals a "Hidden
experimental" group with an orange-tinted header and a disabled
placeholder toggle — the slot for future unfinished/staff-only options.

Also reword the Experimental description to something less alarmist:
"New features that are still taking shape. Give them a try."
2026-04-21 00:07:02 -07:00
Drakontia
53f911ddc3
Support GitHub Copilot CLI agent detection (#866)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 23:44:37 -07:00
Jinwoo Hong
633204babd
fix(editor): apply dark scrollbar styling to rich markdown editor (#886) 2026-04-20 23:00:18 -07:00
Jinwoo Hong
55e935ff32
fix(browser): improve Cloudflare Turnstile compatibility (#885) 2026-04-20 22:36:30 -07:00
25 changed files with 530 additions and 78 deletions

View file

@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.3.8-rc.1",
"version": "1.3.8-rc.2",
"description": "An Electron application with React and TypeScript",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",

View file

@ -0,0 +1,88 @@
// Why: Cloudflare Turnstile and similar bot detectors probe multiple browser
// APIs beyond navigator.webdriver. This script runs via
// Page.addScriptToEvaluateOnNewDocument before any page JS to mask automation
// signals that CDP debugger attachment and Electron's webview expose.
export const ANTI_DETECTION_SCRIPT = `(function() {
Object.defineProperty(navigator, 'webdriver', { get: () => false });
// Why: Electron webviews expose an empty plugins array. Real Chrome always
// has at least a few default plugins (PDF Viewer, etc.). An empty array is
// a strong automation signal.
if (navigator.plugins.length === 0) {
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' }
]
});
}
// Why: Electron webviews may not have the window.chrome object that real
// Chrome exposes. Turnstile checks for its presence. The csi() and
// loadTimes() stubs satisfy deeper probes that check for these Chrome-
// specific APIs beyond just chrome.runtime.
if (!window.chrome) {
window.chrome = {};
}
if (!window.chrome.runtime) {
window.chrome.runtime = {};
}
if (!window.chrome.csi) {
window.chrome.csi = function() {
return {
startE: Date.now(),
onloadT: Date.now(),
pageT: performance.now(),
tran: 15
};
};
}
if (!window.chrome.loadTimes) {
window.chrome.loadTimes = function() {
return {
commitLoadTime: Date.now() / 1000,
connectionInfo: 'h2',
finishDocumentLoadTime: Date.now() / 1000,
finishLoadTime: Date.now() / 1000,
firstPaintAfterLoadTime: 0,
firstPaintTime: Date.now() / 1000,
navigationType: 'Other',
npnNegotiatedProtocol: 'h2',
requestTime: Date.now() / 1000 - 0.16,
startLoadTime: Date.now() / 1000 - 0.3,
wasAlternateProtocolAvailable: false,
wasFetchedViaSpdy: true,
wasNpnNegotiated: true
};
};
}
// Why: Electron's Permission API defaults to 'denied' for most permissions,
// but real Chrome returns 'prompt' for ungranted permissions. Returning
// 'denied' is a strong bot signal. Override the query result for common
// permissions that Turnstile and similar detectors probe.
const promptPerms = new Set([
'notifications', 'geolocation', 'camera', 'microphone',
'midi', 'idle-detection', 'storage-access'
]);
const origQuery = Permissions.prototype.query;
Permissions.prototype.query = function(desc) {
if (promptPerms.has(desc.name)) {
return Promise.resolve({ state: 'prompt', onchange: null });
}
return origQuery.call(this, desc);
};
// Why: Electron may report Notification.permission as 'denied' by default
// whereas real Chrome reports 'default' for sites that haven't been granted
// or blocked. Turnstile cross-references this with the Permissions API.
try {
Object.defineProperty(Notification, 'permission', {
get: () => 'default'
});
} catch {}
// Why: Electron webviews may have an empty languages array. Real Chrome
// always has at least one entry. An empty array is an automation signal.
if (!navigator.languages || navigator.languages.length === 0) {
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en']
});
}
})()`

View file

@ -46,6 +46,7 @@ import type {
BrowserSessionProfileSource
} from '../../shared/types'
import { browserSessionRegistry } from './browser-session-registry'
import { setupClientHintsOverride } from './browser-session-ua'
// ---------------------------------------------------------------------------
// Browser detection
@ -1578,7 +1579,7 @@ export async function importCookiesFromBrowser(
const ua = getUserAgentForBrowser(browser.family)
if (ua) {
targetSession.setUserAgent(ua)
browserSessionRegistry.setupClientHintsOverride(targetSession, ua)
setupClientHintsOverride(targetSession, ua)
browserSessionRegistry.persistUserAgent(ua)
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
}

View file

@ -33,6 +33,7 @@ import {
setupGuestContextMenu,
setupGuestShortcutForwarding
} from './browser-guest-ui'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
export type BrowserGuestRegistration = {
browserPageId?: string
@ -98,6 +99,66 @@ export class BrowserManager {
private readonly downloadsById = new Map<string, ActiveDownload>()
private readonly grabSessionController = new BrowserGrabSessionController()
// Why: Page.addScriptToEvaluateOnNewDocument (via the CDP debugger) is the
// only reliable way to run JS before page scripts on every navigation.
// The previous approach — executeJavaScript on did-start-navigation — ran
// on the OLD page context during navigation, so overrides were never
// present when the new page's Turnstile script executed.
//
// Returns a cleanup function that removes the detach listener and prevents
// further re-attach attempts.
private injectAntiDetection(guest: Electron.WebContents): () => void {
let disposed = false
const attach = (): void => {
if (disposed || guest.isDestroyed()) {
return
}
try {
if (!guest.debugger.isAttached()) {
guest.debugger.attach('1.3')
}
void guest.debugger
.sendCommand('Page.enable', {})
.then(() =>
guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
)
.catch(() => {})
} catch {
/* best-effort — debugger may be unavailable */
}
}
// Why: the CDP proxy and bridge detach the debugger when they stop,
// which removes addScriptToEvaluateOnNewDocument injections. Re-attach
// so manual browsing retains anti-detection overrides after agent
// sessions end. The 500ms delay avoids racing with the proxy/bridge if
// it is mid-restart (detach → re-attach).
const onDetach = (): void => {
if (!disposed && !guest.isDestroyed()) {
setTimeout(attach, 500)
}
}
try {
attach()
guest.debugger.on('detach', onDetach)
} catch {
/* best-effort */
}
return () => {
disposed = true
try {
guest.debugger.off('detach', onDetach)
} catch {
/* guest may already be destroyed */
}
}
}
private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null {
return this.tabIdByWebContentsId.get(guestWebContentsId) ?? null
}
@ -333,6 +394,13 @@ export class BrowserManager {
return
}
this.policyAttachedGuestIds.add(guest.id)
// Why: Cloudflare Turnstile and similar bot detectors probe browser APIs
// (navigator.webdriver, plugins, window.chrome) that differ in Electron
// webviews vs real Chrome. Inject overrides on every page load so manual
// browsing passes challenges even without the CDP debugger attached.
const disposeAntiDetection = this.injectAntiDetection(guest)
// Why: background throttling must be disabled so agent-driven screenshots
// (Page.captureScreenshot via CDP proxy) can capture frames even when the
// Orca window is not the focused foreground app. With throttling enabled,
@ -373,6 +441,14 @@ export class BrowserManager {
})
const navigationGuard = (event: Electron.Event, url: string): void => {
// Why: blob: URLs are same-origin (inherit the creator's origin) and are
// used by Cloudflare Turnstile to load challenge resources inside iframes.
// Blocking them triggers error 600010 ("bot behavior detected"). Only
// allow blobs whose embedded origin is http(s) to maintain defense-in-depth
// against blob:null or other opaque-origin blobs.
if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) {
return
}
if (!normalizeBrowserNavigationUrl(url)) {
// Why: `will-attach-webview` only validates the initial src. Main must
// keep enforcing the same allowlist for later guest navigations too.
@ -405,6 +481,7 @@ export class BrowserManager {
// guest surface is torn down, preventing the callbacks from preventing GC of
// the underlying WebContents wrapper.
this.policyCleanupByGuestId.set(guest.id, () => {
disposeAntiDetection()
if (!guest.isDestroyed()) {
guest.off('will-navigate', navigationGuard)
guest.off('will-redirect', navigationGuard)

View file

@ -18,6 +18,7 @@ vi.mock('./browser-manager', () => ({
}))
import { browserSessionRegistry } from './browser-session-registry'
import { setupClientHintsOverride } from './browser-session-ua'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
describe('BrowserSessionRegistry', () => {
@ -153,7 +154,7 @@ describe('BrowserSessionRegistry', () => {
const edgeUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5'
browserSessionRegistry.setupClientHintsOverride(mockSess, edgeUa)
setupClientHintsOverride(mockSess, edgeUa)
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
{ urls: ['https://*/*'] },
@ -178,7 +179,7 @@ describe('BrowserSessionRegistry', () => {
const chromeUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
browserSessionRegistry.setupClientHintsOverride(mockSess, chromeUa)
setupClientHintsOverride(mockSess, chromeUa)
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
@ -192,10 +193,7 @@ describe('BrowserSessionRegistry', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
browserSessionRegistry.setupClientHintsOverride(
mockSess,
'Mozilla/5.0 (compatible; MSIE 10.0)'
)
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
expect(onBeforeSendHeaders).not.toHaveBeenCalled()
})
@ -203,10 +201,7 @@ describe('BrowserSessionRegistry', () => {
it('leaves non-Client-Hints headers unchanged', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
browserSessionRegistry.setupClientHintsOverride(
mockSess,
'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36'
)
setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36')
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]

View file

@ -1,4 +1,8 @@
import { app, type Session, session } from 'electron'
/* eslint-disable max-lines -- Why: the registry is the single source of truth for
browser session profiles, partition allowlisting, cookie import staging, and
per-partition permission/download policies. Splitting further would scatter the
security boundary across modules. */
import { app, session } from 'electron'
import { randomUUID } from 'node:crypto'
import {
copyFileSync,
@ -12,6 +16,7 @@ import { join } from 'node:path'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
import { browserManager } from './browser-manager'
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
type BrowserSessionMeta = {
defaultSource: BrowserSessionProfile['source']
@ -107,7 +112,18 @@ class BrowserSessionRegistry {
if (meta.userAgent) {
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
sess.setUserAgent(meta.userAgent)
this.setupClientHintsOverride(sess, meta.userAgent)
setupClientHintsOverride(sess, meta.userAgent)
} else {
// Why: even without an imported session, the default Electron UA contains
// "Electron/X.X.X" and the app name which trip Cloudflare Turnstile.
try {
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
}
if (meta.defaultSource) {
const current = this.profiles.get('default')
@ -120,46 +136,6 @@ class BrowserSessionRegistry {
}
}
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
// browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers
// reveal the real version, creating a mismatch that Google's anti-fraud
// detection flags as CookieMismatch on accounts.google.com. Override Client
// Hints on outgoing requests to match the source browser's UA.
setupClientHintsOverride(sess: Session, ua: string): void {
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
if (!chromeMatch) {
return
}
const fullChromeVersion = chromeMatch[1]
const majorVersion = fullChromeVersion.split('.')[0]
let brand = 'Google Chrome'
let brandFullVersion = fullChromeVersion
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
if (edgeMatch) {
brand = 'Microsoft Edge'
brandFullVersion = edgeMatch[1]
}
const brandMajor = brandFullVersion.split('.')[0]
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase()
if (lower === 'sec-ch-ua') {
headers[key] = secChUa
} else if (lower === 'sec-ch-ua-full-version-list') {
headers[key] = secChUaFull
}
}
callback({ requestHeaders: headers })
})
}
// Why: the import writes cookies to a staging DB because CookieMonster holds
// the live DB's data in memory and would overwrite our changes on its next
// flush. This method MUST run before any session.fromPartition() call so
@ -373,6 +349,11 @@ class BrowserSessionRegistry {
this.configuredPartitions.add(partition)
const sess = session.fromPartition(partition)
if (typeof sess.getUserAgent === 'function') {
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
}
// Why: clipboard-read and clipboard-sanitized-write are required for agent-browser's
// clipboard commands to work. Without these, navigator.clipboard.writeText/readText
// throws NotAllowedError even when invoked via CDP with userGesture:true.

View file

@ -0,0 +1,55 @@
import type { Session } from 'electron'
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
// flag as non-human traffic. Strip those tokens so the webview's UA and
// sec-ch-ua Client Hints look like standard Chrome.
export function cleanElectronUserAgent(ua: string): string {
return (
ua
.replace(/\s+Electron\/\S+/, '')
// Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0")
// including pre-release semver strings that [\d.]+ would miss.
.replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
)
}
// Why: Electron's actual Chromium version (e.g. 134) differs from the source
// browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers
// reveal the real version, creating a mismatch that Google's anti-fraud
// detection flags as CookieMismatch on accounts.google.com. Override Client
// Hints on outgoing requests to match the source browser's UA.
export function setupClientHintsOverride(sess: Session, ua: string): void {
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
if (!chromeMatch) {
return
}
const fullChromeVersion = chromeMatch[1]
const majorVersion = fullChromeVersion.split('.')[0]
let brand = 'Google Chrome'
let brandFullVersion = fullChromeVersion
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
if (edgeMatch) {
brand = 'Microsoft Edge'
brandFullVersion = edgeMatch[1]
}
const brandMajor = brandFullVersion.split('.')[0]
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase()
if (lower === 'sec-ch-ua') {
headers[key] = secChUa
} else if (lower === 'sec-ch-ua-full-version-list') {
headers[key] = secChUaFull
}
}
callback({ requestHeaders: headers })
})
}

View file

@ -159,6 +159,8 @@ function createMockGuest(id: number, url: string, title: string) {
return {}
case 'Target.setAutoAttach':
return {}
case 'Page.addScriptToEvaluateOnNewDocument':
return { identifier: 'mock-script-id' }
case 'Runtime.enable':
return {}
default:

View file

@ -46,6 +46,7 @@ import {
type SnapshotResult
} from './snapshot-engine'
import type { BrowserManager } from './browser-manager'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
export class BrowserError extends Error {
constructor(
@ -1158,6 +1159,13 @@ export class CdpBridge {
flatten: true
})
// Why: attaching the CDP debugger sets navigator.webdriver = true and
// exposes other automation signals that Cloudflare Turnstile checks.
// Override them on every new document load so challenges succeed.
await sender('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
// Why: remove any stale listeners from a previous attach cycle to prevent
// listener accumulation. After a detach+reattach, the old handlers would
// still fire alongside the new ones, causing duplicate log entries,

View file

@ -2,6 +2,7 @@ import { WebSocketServer, WebSocket } from 'ws'
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'
import type { WebContents } from 'electron'
import { captureScreenshot } from './cdp-screenshot'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
export class CdpWsProxy {
private httpServer: Server | null = null
@ -96,9 +97,15 @@ export class CdpWsProxy {
const url = req.url ?? ''
if (url === '/json/version' || url === '/json/version/') {
res.writeHead(200, { 'Content-Type': 'application/json' })
// Why: agent-browser reads this endpoint to identify the browser. Returning
// "Orca/CdpWsProxy" leaks that this is an embedded automation surface, which
// could affect downstream detection heuristics.
// Why: process.versions.chrome contains the exact Chromium version
// bundled with Electron, producing a realistic version string.
const chromeVersion = process.versions.chrome ?? '134.0.0.0'
res.end(
JSON.stringify({
Browser: 'Orca/CdpWsProxy',
Browser: `Chrome/${chromeVersion}`,
'Protocol-Version': '1.3',
webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}`
})
@ -134,6 +141,19 @@ export class CdpWsProxy {
}
}
this.attached = true
// Why: attaching the CDP debugger sets navigator.webdriver = true and
// exposes other automation signals that Cloudflare Turnstile checks.
// Inject before any page loads so challenges succeed.
try {
await this.webContents.debugger.sendCommand('Page.enable', {})
await this.webContents.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', {
source: ANTI_DETECTION_SCRIPT
})
} catch {
/* best-effort — page domain may not be ready yet */
}
this.debuggerMessageHandler = (_event: unknown, ...rest: unknown[]) => {
const [method, params, sessionId] = rest as [
string,
@ -209,11 +229,14 @@ export class CdpWsProxy {
return
}
if (msg.method === 'Browser.getVersion') {
// Why: returning "Orca/Electron" identifies this as an embedded automation
// surface to agent-browser. Use a generic Chrome product string instead.
const chromeVersion = process.versions.chrome ?? '134.0.0.0'
this.sendResult(
clientId,
{
protocolVersion: '1.3',
product: 'Orca/Electron',
product: `Chrome/${chromeVersion}`,
userAgent: '',
jsVersion: ''
},

View file

@ -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')
},

View file

@ -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())

View file

@ -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',

View file

@ -48,12 +48,14 @@ const {
autoUpdaterMock.downloadUpdate.mockReset()
autoUpdaterMock.quitAndInstall.mockReset()
autoUpdaterMock.setFeedURL.mockClear()
autoUpdaterMock.allowPrerelease = false
delete (autoUpdaterMock as Record<string, unknown>).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()

View file

@ -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,

View file

@ -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())

View file

@ -484,7 +484,7 @@ export type PreloadApi = {
updater: {
getVersion: () => Promise<string>
getStatus: () => Promise<UpdateStatus>
check: () => Promise<void>
check: (options?: { includePrerelease?: boolean }) => Promise<void>
download: () => Promise<void>
quitAndInstall: () => Promise<void>
dismissNudge: () => Promise<void>

View file

@ -826,7 +826,8 @@ const api = {
updater: {
getStatus: (): Promise<unknown> => ipcRenderer.invoke('updater:getStatus'),
getVersion: (): Promise<string> => ipcRenderer.invoke('updater:getVersion'),
check: (): Promise<void> => ipcRenderer.invoke('updater:check'),
check: (options?: { includePrerelease?: boolean }): Promise<void> =>
ipcRenderer.invoke('updater:check', options),
download: (): Promise<void> => ipcRenderer.invoke('updater:download'),
dismissNudge: (): Promise<void> => ipcRenderer.invoke('updater:dismissNudge'),
quitAndInstall: async (): Promise<void> => {

View file

@ -505,7 +505,7 @@ export default function RichMarkdownEditor({
query={searchQuery}
searchInputRef={searchInputRef}
/>
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto scrollbar-editor">
<EditorContent editor={editor} />
</div>
{linkBubble ? (

View file

@ -13,11 +13,15 @@ export { EXPERIMENTAL_PANE_SEARCH_ENTRIES }
type ExperimentalPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => 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}
</SearchableSetting>
) : null}
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
</div>
)
}
// 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 (
<section className="space-y-3 rounded-lg border border-orange-500/40 bg-orange-500/5 p-3">
<div className="space-y-0.5">
<h4 className="text-sm font-semibold text-orange-500 dark:text-orange-300">
Hidden experimental
</h4>
<p className="text-xs text-orange-500/80 dark:text-orange-300/80">
Unlisted toggles for internal testing. Nothing here is supported.
</p>
</div>
<div className="flex items-start justify-between gap-4 rounded-md border border-orange-500/30 bg-orange-500/10 px-3 py-2.5">
<div className="min-w-0 shrink space-y-0.5">
<Label className="text-orange-600 dark:text-orange-300">Placeholder toggle</Label>
<p className="text-xs text-orange-600/80 dark:text-orange-300/80">
Does nothing today. Reserved as the first slot for hidden experimental options.
</p>
</div>
<button
type="button"
aria-label="Placeholder toggle"
className="relative inline-flex h-5 w-9 shrink-0 cursor-not-allowed items-center rounded-full border border-orange-500/40 bg-orange-500/20 opacity-70"
disabled
>
<span className="inline-block h-3.5 w-3.5 translate-x-0.5 transform rounded-full bg-orange-200 shadow-sm dark:bg-orange-100" />
</button>
</div>
</section>
)
}

View file

@ -768,7 +768,14 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
<Button
variant="outline"
size="sm"
onClick={() => window.api.updater.check()}
// Why: Cmd+Shift-click (Ctrl+Shift on win/linux) opts this check
// into the release-candidate channel. Keep the affordance hidden
// — it's a power-user shortcut, not a discoverable toggle.
onClick={(event) =>
window.api.updater.check({
includePrerelease: (event.metaKey || event.ctrlKey) && event.shiftKey
})
}
disabled={updateStatus.state === 'checking' || updateStatus.state === 'downloading'}
className="gap-2"
>

View file

@ -137,6 +137,11 @@ function Settings(): React.JSX.Element {
getFallbackTerminalFonts()
)
const [activeSectionId, setActiveSectionId] = useState('general')
// Why: the hidden-experimental group is an unlock — Cmd+Shift-clicking the
// Experimental sidebar entry reveals it for the remainder of the session.
// Not persisted on purpose: it's a power-user affordance we don't want to
// leak through into a normal reopen of Settings.
const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false)
const contentScrollRef = useRef<HTMLDivElement | null>(null)
const terminalFontsLoadedRef = useRef(false)
const pendingNavSectionRef = useRef<string | null>(null)
@ -343,7 +348,7 @@ function Settings(): React.JSX.Element {
{
id: 'experimental',
title: 'Experimental',
description: 'Features that are still being stabilized. Enable at your own risk.',
description: 'New features that are still taking shape. Give them a try.',
icon: FlaskConical,
searchEntries: EXPERIMENTAL_PANE_SEARCH_ENTRIES
},
@ -464,11 +469,30 @@ function Settings(): React.JSX.Element {
}
}, [visibleNavSections])
const scrollToSection = useCallback((sectionId: string) => {
scrollSectionIntoView(sectionId, contentScrollRef.current)
flashSectionHighlight(sectionId)
setActiveSectionId(sectionId)
}, [])
const scrollToSection = useCallback(
(
sectionId: string,
modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
) => {
// Why: Cmd+Shift-clicking (Ctrl+Shift on win/linux) the Experimental
// sidebar entry unlocks a hidden power-user group. Keep this scoped to
// the Experimental row so normal shortcut combos on other rows don't
// accidentally flip state. The unlock persists for the life of the
// Settings view (resets when Settings is reopened).
if (
sectionId === 'experimental' &&
modifiers &&
(modifiers.metaKey || modifiers.ctrlKey) &&
modifiers.shiftKey
) {
setHiddenExperimentalUnlocked((previous) => !previous)
}
scrollSectionIntoView(sectionId, contentScrollRef.current)
flashSectionHighlight(sectionId)
setActiveSectionId(sectionId)
},
[]
)
if (!settings) {
return (
@ -617,10 +641,14 @@ function Settings(): React.JSX.Element {
<SettingsSection
id="experimental"
title="Experimental"
description="Features that are still being stabilized. Enable at your own risk."
description="New features that are still taking shape. Give them a try."
searchEntries={EXPERIMENTAL_PANE_SEARCH_ENTRIES}
>
<ExperimentalPane settings={settings} updateSettings={updateSettings} />
<ExperimentalPane
settings={settings}
updateSettings={updateSettings}
hiddenExperimentalUnlocked={hiddenExperimentalUnlocked}
/>
</SettingsSection>
{repos.map((repo) => {

View file

@ -22,7 +22,10 @@ type SettingsSidebarProps = {
searchQuery: string
onBack: () => void
onSearchChange: (query: string) => void
onSelectSection: (sectionId: string) => void
onSelectSection: (
sectionId: string,
modifiers: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
) => void
}
export function SettingsSidebar({
@ -71,7 +74,14 @@ export function SettingsSidebar({
return (
<button
key={section.id}
onClick={() => onSelectSection(section.id)}
onClick={(event) =>
onSelectSection(section.id, {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey
})
}
className={`flex w-full items-center rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'
@ -103,7 +113,14 @@ export function SettingsSidebar({
return (
<button
key={section.id}
onClick={() => onSelectSection(section.id)}
onClick={(event) =>
onSelectSection(section.id, {
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey
})
}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
isActive
? 'bg-accent font-medium text-accent-foreground'

View file

@ -245,6 +245,12 @@ describe('getAgentLabel', () => {
expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code')
expect(getAgentLabel('⠋ Codex is thinking')).toBe('Codex')
})
it('labels GitHub Copilot CLI', () => {
expect(getAgentLabel('copilot working')).toBe('GitHub Copilot')
expect(getAgentLabel('copilot idle')).toBe('GitHub Copilot')
expect(getAgentLabel('GitHub Copilot CLI')).toBe('GitHub Copilot')
})
})
describe('createAgentStatusTracker', () => {

View file

@ -16,7 +16,7 @@ const GEMINI_SILENT_WORKING = '\u23F2' // ⏲
const GEMINI_IDLE = '\u25C7' // ◇
const GEMINI_PERMISSION = '\u270B' // ✋
export const AGENT_NAMES = ['claude', 'codex', 'gemini', 'opencode', 'aider']
export const AGENT_NAMES = ['claude', 'codex', 'copilot', 'gemini', 'opencode', 'aider']
const PI_IDLE_PREFIX = '\u03c0 - ' // π - (Pi titlebar extension idle format)
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
@ -255,6 +255,9 @@ export function getAgentLabel(title: string): string | null {
if (lower.includes('codex')) {
return 'Codex'
}
if (lower.includes('copilot')) {
return 'GitHub Copilot'
}
if (lower.includes('opencode')) {
return 'OpenCode'
}