diff --git a/src/main/index.ts b/src/main/index.ts index 3626f0a4c..e9347aba3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -44,6 +44,7 @@ import { } from './menu/register-app-menu' import { checkForUpdatesFromMenu, isQuittingForUpdate } from './updater' import { + configureElectronNetworkCompatibility, configureDevUserDataPath, configureOrcaUserDataPathEnv, enableMainProcessGpuFeatures, @@ -370,6 +371,7 @@ if (hasSingleInstanceLock) { packaged: app.isPackaged, platform: process.platform }) + configureElectronNetworkCompatibility() enableMainProcessGpuFeatures() } @@ -543,6 +545,7 @@ function openMainWindow(): BrowserWindow { { onBeforeRelaunch: () => { isQuitting = true + store?.flush() } } ) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 370b1d3bd..e5df0c531 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,4 +1,5 @@ -import { homedir } from 'os' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { homedir, tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -189,6 +190,76 @@ describe('shouldInstallManagedHooks', () => { }) }) +describe('configureElectronNetworkCompatibility', () => { + const tempDirs: string[] = [] + const originalEnvValue = process.env.ORCA_DISABLE_HTTP2 + + function createUserDataDir(settings: Record): string { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-http1-compat-')) + tempDirs.push(userDataPath) + writeFileSync(join(userDataPath, 'orca-data.json'), JSON.stringify({ settings }), 'utf-8') + return userDataPath + } + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + if (originalEnvValue === undefined) { + delete process.env.ORCA_DISABLE_HTTP2 + } else { + process.env.ORCA_DISABLE_HTTP2 = originalEnvValue + } + }) + + it('enables HTTP/1.1 compatibility when the persisted setting is on', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const userDataPath = createUserDataDir({ electronHttp1CompatibilityMode: true }) + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(true) + }) + + it('leaves HTTP/2 enabled by default', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + const userDataPath = createUserDataDir({}) + + expect(shouldDisableHttp2ForElectronNetworking({ env: {}, userDataPath })).toBe(false) + }) + + it('lets the environment override force compatibility on', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + + expect( + shouldDisableHttp2ForElectronNetworking({ + env: { ORCA_DISABLE_HTTP2: 'true' }, + userDataPath: createUserDataDir({ electronHttp1CompatibilityMode: false }) + }) + ).toBe(true) + }) + + it('lets the environment override force compatibility off', async () => { + const { shouldDisableHttp2ForElectronNetworking } = await import('./configure-process') + + expect( + shouldDisableHttp2ForElectronNetworking({ + env: { ORCA_DISABLE_HTTP2: '0' }, + userDataPath: createUserDataDir({ electronHttp1CompatibilityMode: true }) + }) + ).toBe(false) + }) + + it('appends Electron disable-http2 before sessions are created', async () => { + const { app } = await import('electron') + const { configureElectronNetworkCompatibility } = await import('./configure-process') + const userDataPath = createUserDataDir({ electronHttp1CompatibilityMode: true }) + + vi.mocked(app.commandLine.appendSwitch).mockClear() + configureElectronNetworkCompatibility({ env: {}, userDataPath }) + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-http2') + }) +}) + describe('enableMainProcessGpuFeatures', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 4a859d645..be1a3c462 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -1,11 +1,71 @@ import { app } from 'electron' +import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 +const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' +const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']) +const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']) let devParentShutdownRequested = false +type NetworkCompatibilityOptions = { + env?: NodeJS.ProcessEnv + userDataPath?: string +} + +function parseBooleanEnvFlag(value: string | undefined): boolean | null { + if (value === undefined) { + return null + } + const normalized = value.trim().toLowerCase() + if (TRUE_ENV_VALUES.has(normalized)) { + return true + } + if (FALSE_ENV_VALUES.has(normalized)) { + return false + } + return null +} + +function readPersistedHttp1CompatibilityMode(userDataPath: string): boolean { + const dataFile = join(userDataPath, 'orca-data.json') + if (!existsSync(dataFile)) { + return false + } + + try { + const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as { + settings?: { electronHttp1CompatibilityMode?: unknown } + } + return parsed.settings?.electronHttp1CompatibilityMode === true + } catch { + return false + } +} + +export function shouldDisableHttp2ForElectronNetworking( + options: NetworkCompatibilityOptions = {} +): boolean { + const envValue = parseBooleanEnvFlag(options.env?.[HTTP1_COMPATIBILITY_ENV_VAR]) + if (envValue !== null) { + return envValue + } + return readPersistedHttp1CompatibilityMode(options.userDataPath ?? app.getPath('userData')) +} + +export function configureElectronNetworkCompatibility( + options: NetworkCompatibilityOptions = {} +): void { + if (!shouldDisableHttp2ForElectronNetworking(options)) { + return + } + // Why: Chromium's HTTP/2 switch is process-wide and only works before the + // first session exists, so read the persisted setting during early startup. + app.commandLine.appendSwitch('disable-http2') +} + function getProcessPathDelimiter(): string { return process.platform === 'win32' ? ';' : ':' } diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index d69a8436b..59501eecd 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -6,6 +6,7 @@ import { getDefaultUIState } from '../../../shared/constants' import type { ChangelogData, UpdateStatus } from '../../../shared/types' import { createUISlice } from '../store/slices/ui' import type { AppState } from '../store/types' +import { isHttp2ProtocolError } from './UpdateCard' // ── Helpers ────────────────────────────────────────────────────────── @@ -527,6 +528,15 @@ describe('UpdateCard visibility gates', () => { }) }) +describe('HTTP/2 update error detection', () => { + it('recognizes Electron HTTP/2 protocol failures without matching generic errors', () => { + expect(isHttp2ProtocolError('net::ERR_HTTP2_PROTOCOL_ERROR')).toBe(true) + expect(isHttp2ProtocolError('Download failed: HTTP/2 protocol error')).toBe(true) + expect(isHttp2ProtocolError('Download failed: socket hang up')).toBe(false) + expect(isHttp2ProtocolError('HTTP proxy authentication failed')).toBe(false) + }) +}) + // ── Full update lifecycle through the store ────────────────────────── describe('full update lifecycle through setUpdateStatus', () => { diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 6f542e338..25ea95ee5 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -7,7 +7,7 @@ import { useAppStore } from '../store' import { Card } from './ui/card' import { Button } from './ui/button' import { Progress } from './ui/progress' -import { AlertCircle, Check, Loader2, Minus, X } from 'lucide-react' +import { AlertCircle, Check, Loader2, Minus, Network, RotateCw, X } from 'lucide-react' import type { ChangelogData } from '../../../shared/types' // ── Helpers ────────────────────────────────────────────────────────── @@ -26,13 +26,25 @@ function isAnimatedGif(url: string | undefined): boolean { return typeof url === 'string' && url.toLowerCase().endsWith('.gif') } +export function isHttp2ProtocolError(message: string): boolean { + const normalized = message.toLowerCase() + return ( + normalized.includes('err_http2_protocol_error') || + normalized.includes('http2_protocol_error') || + (normalized.includes('http/2') && normalized.includes('protocol')) + ) +} + type ErrorCardModel = { + variant?: 'default' | 'http1Compatibility' title: string summary: string message: string releaseUrl: string primaryAction?: { label: string + pendingLabel?: string + isPending?: boolean onClick: () => void } } @@ -100,6 +112,8 @@ export function UpdateCard() { const [mediaFailed, setMediaFailed] = useState(false) const [mediaLoaded, setMediaLoaded] = useState(false) const [installError, setInstallError] = useState(null) + const [compatibilityRelaunching, setCompatibilityRelaunching] = useState(false) + const [compatibilitySetupError, setCompatibilitySetupError] = useState(null) // Why: the version-based dismiss gate at the bottom of the visibility // section intentionally keeps error cards visible so a download failure // still surfaces even if the user previously dismissed the "available" @@ -332,32 +346,61 @@ export function UpdateCard() { }) } + const handleEnableHttp1Compatibility = () => { + setCompatibilityRelaunching(true) + setCompatibilitySetupError(null) + void window.api.settings + .set({ electronHttp1CompatibilityMode: true }) + .then(() => window.api.app.relaunch()) + .catch((error) => { + const message = String((error as Error)?.message ?? error) + console.error('[updates] failed to enable HTTP/1.1 compatibility:', error) + setCompatibilitySetupError(`Could not enable compatibility mode. ${message}`) + setCompatibilityRelaunching(false) + }) + } + + const isHttp2UpdateError = status.state === 'error' && isHttp2ProtocolError(status.message) const errorCard: ErrorCardModel | null = status.state === 'error' - ? { - // Why: title is scoped to the operation that failed so check-time - // failures (commonly GitHub-side) don't read as a bug in Orca. - title: cachedVersion ? 'Update Error' : 'Update Check Failed', - summary: cachedVersion - ? 'Could not complete the update.' - : 'Could not check for updates.', - message: status.message, - releaseUrl: releaseUrlForVersion(cachedVersion), - // Why: check-time failures are often transient (offline, GitHub - // hiccup), so offer a Re-check next to "Download Manually" instead - // of forcing the user into the manual fallback. - primaryAction: cachedVersion - ? { - label: 'Retry Download', - onClick: handleUpdate - } - : { - label: 'Re-check', - onClick: () => { - void window.api.updater.check({ includePrerelease: false }) + ? isHttp2UpdateError + ? { + variant: 'http1Compatibility', + title: 'HTTP/2 Download Blocked', + summary: 'Orca can retry through HTTP/1.1 compatibility mode.', + message: compatibilitySetupError ?? status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + primaryAction: { + label: 'Enable & Restart', + pendingLabel: 'Restarting...', + isPending: compatibilityRelaunching, + onClick: handleEnableHttp1Compatibility + } + } + : { + // Why: title is scoped to the operation that failed so check-time + // failures (commonly GitHub-side) don't read as a bug in Orca. + title: cachedVersion ? 'Update Error' : 'Update Check Failed', + summary: cachedVersion + ? 'Could not complete the update.' + : 'Could not check for updates.', + message: status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + // Why: check-time failures are often transient (offline, GitHub + // hiccup), so offer a Re-check next to "Download Manually" instead + // of forcing the user into the manual fallback. + primaryAction: cachedVersion + ? { + label: 'Retry Download', + onClick: handleUpdate } - } - } + : { + label: 'Re-check', + onClick: () => { + void window.api.updater.check({ includePrerelease: false }) + } + } + } : installError ? { title: 'Update Error', @@ -466,6 +509,7 @@ export function UpdateCard() { summary={errorCard.summary} message={errorCard.message} releaseUrl={errorCard.releaseUrl} + variant={errorCard.variant} primaryAction={errorCard.primaryAction} onClose={handleCollapseWithAnimation} /> @@ -831,6 +875,7 @@ function DownloadingContent({ // ── Error card content ─────────────────────────────────────────────── function ErrorCardContent({ + variant = 'default', title, summary, message, @@ -838,20 +883,31 @@ function ErrorCardContent({ primaryAction, onClose }: { + variant?: 'default' | 'http1Compatibility' title: string summary: string message: string releaseUrl: string primaryAction?: { label: string + pendingLabel?: string + isPending?: boolean onClick: () => void } onClose: () => void }) { + const isCompatibility = variant === 'http1Compatibility' + const Icon = isCompatibility ? Network : AlertCircle return (
-
-

{title}

+
+
+ +
+
+

{title}

+

{summary}

+
-

- {summary} {message} -

+ {isCompatibility ? ( +
+

+ This turns on a process-wide Electron networking switch after restart. Use it for + corporate VPNs or proxies that reject HTTP/2 update downloads. +

+
+ ) : null} + +
+

Last error

+

+ {message} +

+
{primaryAction && ( - )} + + + Use only when a corporate VPN or proxy breaks update downloads with HTTP/2 + protocol errors. It affects all Electron networking after restart. + + + +
+
+ +
+ + {http1CompatibilityRestartRequired ? ( +
+
+

Restart required

+

+ Orca applies this networking mode at startup. +

+
+ +
+ ) : null} + + + + ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index c2df08280..f81c9992b 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -43,6 +43,7 @@ import { ComputerUsePane } from './ComputerUsePane' import { MobileSettingsPane } from './MobileSettingsPane' import { RuntimeEnvironmentsPane } from './RuntimeEnvironmentsPane' import { PrivacyPane } from './PrivacyPane' +import { AdvancedPane } from './AdvancedPane' import { SettingsSidebar } from './SettingsSidebar' import { SettingsSetupGuideCard } from './SettingsSetupGuideCard' import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection' @@ -88,6 +89,7 @@ const SETTINGS_NAV_GROUPS = [ { id: 'interface', title: 'Interface' }, { id: 'remote', title: 'Remote Access' }, { id: 'security', title: 'Privacy & Security' }, + { id: 'advanced', title: 'Advanced' }, { id: 'experimental', title: 'Experimental' } ] as const @@ -1247,6 +1249,19 @@ function Settings(): React.JSX.Element { {isSectionMounted('privacy') ? : null} + {showDesktopOnlySettings ? ( + + {isSectionMounted('advanced') ? ( + + ) : null} + + ) : null} + e.title === title) + if (!entry) { + throw new Error(`Missing advanced-pane search entry: "${title}"`) + } + return entry +} + +export const ADVANCED_SEARCH_ENTRY = { + http1Compatibility: findEntry('HTTP/1.1 Compatibility') +} as const diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts index 05b6ebfc4..564744b25 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.test.ts @@ -55,6 +55,7 @@ describe('settings navigation metadata', () => { expect(webIds).not.toContain('mobile') expect(webIds).not.toContain('computer-use') expect(webIds).not.toContain('voice') + expect(webIds).not.toContain('advanced') expect(webIds).toContain('servers') expect(webIds).toContain('repo-repo-1') }) @@ -71,6 +72,14 @@ describe('settings navigation metadata', () => { expect(sections.find((section) => section.id === 'voice')?.badge).toBeUndefined() }) + it('places Advanced near the bottom on desktop without putting it under Experimental', () => { + const desktopIds = ids() + + expect(desktopIds).toContain('advanced') + expect(desktopIds.indexOf('advanced')).toBeLessThan(desktopIds.indexOf('experimental')) + expect(desktopIds.indexOf('privacy')).toBeLessThan(desktopIds.indexOf('advanced')) + }) + it('keeps macOS permissions mac-only', () => { expect(ids({ isMac: false })).not.toContain('developer-permissions') expect(ids({ isMac: true })).toContain('developer-permissions') diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 66fc5e842..fa02a715d 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -27,7 +27,8 @@ import { Smartphone, SquareTerminal, TextCursorInput, - UserCog + UserCog, + Wrench } from 'lucide-react' import type { Repo } from '../../../shared/types' import { getRepoKindLabel } from '../../../shared/repo-kind' @@ -59,6 +60,7 @@ import { COMPUTER_USE_PANE_SEARCH_ENTRIES } from '@/components/settings/computer import { VOICE_PANE_SEARCH_ENTRIES } from '@/components/settings/voice-pane-search' import { DEVELOPER_PERMISSIONS_PANE_SEARCH_ENTRIES } from '@/components/settings/developer-permissions-search' import { PRIVACY_PANE_SEARCH_ENTRIES } from '@/components/settings/privacy-search' +import { ADVANCED_PANE_SEARCH_ENTRIES } from '@/components/settings/advanced-search' import { SHORTCUTS_PANE_SEARCH_ENTRIES } from '@/components/settings/shortcuts-search' import { STATS_PANE_SEARCH_ENTRIES } from '@/components/stats/stats-search' import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from '@/components/settings/experimental-search' @@ -305,6 +307,18 @@ export function buildSettingsNavigationMetadata({ searchEntries: PRIVACY_PANE_SEARCH_ENTRIES, group: 'security' }, + ...(showDesktopOnlySettings + ? [ + { + id: 'advanced', + title: 'Advanced', + description: 'Low-level compatibility settings for troubleshooting.', + icon: Wrench, + searchEntries: ADVANCED_PANE_SEARCH_ENTRIES, + group: 'advanced' + } + ] + : []), { id: 'experimental', title: 'Experimental', diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index daf2902f6..28f401105 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -19,6 +19,7 @@ export type SettingsNavTarget = | 'computer-use' | 'developer-permissions' | 'privacy' + | 'advanced' | 'voice' | 'shortcuts' | 'stats' diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 0c8595f56..6d70a70fa 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -65,6 +65,7 @@ import { } from '../../../../shared/workspace-statuses' import { normalizeKagiSessionLink } from '../../../../shared/browser-url' import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' +import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { filterSetupScriptPromptDismissalsToValidRepos, getSetupScriptPromptDismissalKey @@ -605,32 +606,7 @@ export type UISlice = { openSettingsPage: () => void closeSettingsPage: () => void settingsNavigationTarget: { - pane: - | 'general' - | 'integrations' - | 'accounts' - | 'browser' - | 'git' - | 'appearance' - | 'input' - | 'tasks' - | 'floating-workspace' - | 'terminal' - | 'quick-commands' - | 'notifications' - | 'computer-use' - | 'developer-permissions' - | 'privacy' - | 'shortcuts' - | 'stats' - | 'repo' - | 'agents' - | 'voice' - | 'experimental' - | 'orchestration' - | 'servers' - | 'mobile' - | 'ssh' + pane: SettingsNavTarget repoId: string | null sectionId?: string intent?: 'add-quick-command' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index df637c221..9bd30629b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -231,6 +231,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { terminalScrollbackBytes: 10_000_000, httpProxyUrl: '', httpProxyBypassRules: '', + electronHttp1CompatibilityMode: false, openLinksInApp: true, openInApplications: [], rightSidebarOpenByDefault: true, diff --git a/src/shared/types.ts b/src/shared/types.ts index 4320d4a7a..70f46cbac 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2029,6 +2029,9 @@ export type GlobalSettings = { httpProxyUrl?: string /** Optional semicolon/comma/newline-separated bypass rules for httpProxyUrl. */ httpProxyBypassRules?: string + /** Why: corporate TLS-intercepting proxies can break Electron HTTP/2 downloads; + * this opt-in compatibility mode applies Chromium's process-wide HTTP/1.1 switch. */ + electronHttp1CompatibilityMode?: boolean /** Why: opening arbitrary links inside Orca uses an isolated guest browser surface. * The setting stays opt-in so existing workflows continue to use the system browser * until the user explicitly wants worktree-scoped in-app browsing. */