mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Compare commits
5 commits
v1.3.8-rc.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2554ab28 | ||
|
|
d66d86645d | ||
|
|
53f911ddc3 | ||
|
|
633204babd | ||
|
|
55e935ff32 |
25 changed files with 530 additions and 78 deletions
|
|
@ -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",
|
||||
|
|
|
|||
88
src/main/browser/anti-detection.ts
Normal file
88
src/main/browser/anti-detection.ts
Normal 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']
|
||||
});
|
||||
}
|
||||
})()`
|
||||
|
|
@ -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)}...`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
55
src/main/browser/browser-session-ua.ts
Normal file
55
src/main/browser/browser-session-ua.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: ''
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
2
src/preload/api-types.d.ts
vendored
2
src/preload/api-types.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue