Add HTTP/1.1 compatibility mode (#4509)
This commit is contained in:
parent
0d342235a7
commit
d879cfde37
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, unknown>): 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
|
||||
|
|
|
|||
|
|
@ -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' ? ';' : ':'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [compatibilityRelaunching, setCompatibilityRelaunching] = useState(false)
|
||||
const [compatibilitySetupError, setCompatibilitySetupError] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -863,14 +919,39 @@ function ErrorCardContent({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{summary} {message}
|
||||
</p>
|
||||
{isCompatibility ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
This turns on a process-wide Electron networking switch after restart. Use it for
|
||||
corporate VPNs or proxies that reject HTTP/2 update downloads.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase text-muted-foreground">Last error</p>
|
||||
<p className="scrollbar-sleek max-h-20 overflow-auto break-words font-mono text-xs leading-relaxed text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{primaryAction && (
|
||||
<Button variant="default" size="sm" onClick={primaryAction.onClick} className="flex-1">
|
||||
{primaryAction.label}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.isPending}
|
||||
className="flex-1 gap-1.5"
|
||||
>
|
||||
{primaryAction.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : isCompatibility ? (
|
||||
<RotateCw className="size-3.5" />
|
||||
) : null}
|
||||
{primaryAction.isPending && primaryAction.pendingLabel
|
||||
? primaryAction.pendingLabel
|
||||
: primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { AdvancedPane } from './AdvancedPane'
|
||||
import { ADVANCED_SEARCH_ENTRY } from './advanced-search'
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) =>
|
||||
selector({ settingsSearchQuery: '' })
|
||||
}))
|
||||
|
||||
describe('AdvancedPane', () => {
|
||||
it('renders HTTP/1.1 compatibility as a neutral advanced setting', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AdvancedPane settings={getDefaultSettings('/tmp')} updateSettings={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(markup).toContain('Compatibility')
|
||||
expect(markup).toContain('HTTP/1.1 Compatibility')
|
||||
expect(markup).toContain('aria-checked="false"')
|
||||
expect(markup).toContain('Explain HTTP/1.1 compatibility')
|
||||
expect(ADVANCED_SEARCH_ENTRY.http1Compatibility.keywords).toContain('support')
|
||||
expect(ADVANCED_SEARCH_ENTRY.http1Compatibility.keywords).toContain('troubleshooting')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { Info, Loader2, RotateCw } from 'lucide-react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsSubsectionHeader, SettingsSwitch } from './SettingsFormControls'
|
||||
import { ADVANCED_PANE_SEARCH_ENTRIES, ADVANCED_SEARCH_ENTRY } from './advanced-search'
|
||||
|
||||
export { ADVANCED_PANE_SEARCH_ENTRIES }
|
||||
|
||||
type AdvancedPaneProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
}
|
||||
|
||||
export function AdvancedPane({ settings, updateSettings }: AdvancedPaneProps): React.JSX.Element {
|
||||
const mountedRef = useMountedRef()
|
||||
const http1CompatibilityInitialRef = useRef(Boolean(settings.electronHttp1CompatibilityMode))
|
||||
const [http1CompatibilityRelaunching, setHttp1CompatibilityRelaunching] = useState(false)
|
||||
const http1CompatibilityEnabled = Boolean(settings.electronHttp1CompatibilityMode)
|
||||
const http1CompatibilityRestartRequired =
|
||||
http1CompatibilityEnabled !== http1CompatibilityInitialRef.current
|
||||
|
||||
const toggleHttp1CompatibilityMode = (): void => {
|
||||
updateSettings({ electronHttp1CompatibilityMode: !http1CompatibilityEnabled })
|
||||
}
|
||||
|
||||
const handleHttp1CompatibilityRelaunch = (): void => {
|
||||
setHttp1CompatibilityRelaunching(true)
|
||||
void window.api.app.relaunch().catch((error) => {
|
||||
console.error('[settings] failed to relaunch for HTTP/1.1 compatibility:', error)
|
||||
if (mountedRef.current) {
|
||||
setHttp1CompatibilityRelaunching(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-3">
|
||||
<SettingsSubsectionHeader
|
||||
title="Compatibility"
|
||||
description="Low-level workarounds for support troubleshooting."
|
||||
/>
|
||||
|
||||
<SearchableSetting
|
||||
title={ADVANCED_SEARCH_ENTRY.http1Compatibility.title}
|
||||
description={ADVANCED_SEARCH_ENTRY.http1Compatibility.description}
|
||||
keywords={ADVANCED_SEARCH_ENTRY.http1Compatibility.keywords}
|
||||
className="space-y-2 py-2"
|
||||
id="advanced-http1-compatibility"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 shrink">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label id="advanced-http1-compatibility-label">HTTP/1.1 Compatibility</Label>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Explain HTTP/1.1 compatibility"
|
||||
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="max-w-[280px] leading-relaxed"
|
||||
>
|
||||
Use only when a corporate VPN or proxy breaks update downloads with HTTP/2
|
||||
protocol errors. It affects all Electron networking after restart.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsSwitch
|
||||
checked={http1CompatibilityEnabled}
|
||||
onChange={toggleHttp1CompatibilityMode}
|
||||
ariaLabelledBy="advanced-http1-compatibility-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{http1CompatibilityRestartRequired ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/30 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium">Restart required</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Orca applies this networking mode at startup.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleHttp1CompatibilityRelaunch}
|
||||
disabled={http1CompatibilityRelaunching}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{http1CompatibilityRelaunching ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-3.5" />
|
||||
)}
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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') ? <PrivacyPane settings={settings} /> : null}
|
||||
</SettingsSection>
|
||||
|
||||
{showDesktopOnlySettings ? (
|
||||
<SettingsSection
|
||||
id="advanced"
|
||||
title="Advanced"
|
||||
description="Low-level compatibility settings for troubleshooting."
|
||||
searchEntries={getSectionSearchEntries('advanced')}
|
||||
>
|
||||
{isSectionMounted('advanced') ? (
|
||||
<AdvancedPane settings={settings} updateSettings={updateSettings} />
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
<SettingsSection
|
||||
id="experimental"
|
||||
title="Experimental"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const ADVANCED_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'HTTP/1.1 Compatibility',
|
||||
description: 'Use HTTP/1.1 for Electron networking when HTTP/2 fails behind a proxy.',
|
||||
keywords: [
|
||||
'advanced',
|
||||
'networking',
|
||||
'network',
|
||||
'http',
|
||||
'http2',
|
||||
'http/2',
|
||||
'http1',
|
||||
'http/1.1',
|
||||
'compatibility',
|
||||
'proxy',
|
||||
'vpn',
|
||||
'support',
|
||||
'troubleshooting',
|
||||
'updates',
|
||||
'updater'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function findEntry(title: string): SettingsSearchEntry {
|
||||
const entry = ADVANCED_PANE_SEARCH_ENTRIES.find((e) => 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export type SettingsNavTarget =
|
|||
| 'computer-use'
|
||||
| 'developer-permissions'
|
||||
| 'privacy'
|
||||
| 'advanced'
|
||||
| 'voice'
|
||||
| 'shortcuts'
|
||||
| 'stats'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
terminalScrollbackBytes: 10_000_000,
|
||||
httpProxyUrl: '',
|
||||
httpProxyBypassRules: '',
|
||||
electronHttp1CompatibilityMode: false,
|
||||
openLinksInApp: true,
|
||||
openInApplications: [],
|
||||
rightSidebarOpenByDefault: true,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue