fix(updater): humanize update-error card and hide raw error behind Show details

Windows auto-update failures surfaced the raw electron-updater message as the
card headline — most visibly the PowerShell "Command failed: … Get-Authenticode
Signature …" dump when antivirus/EDR blocks the post-download signature check.
That reads as a crash, not an actionable state.

Classify each failure and lead with one plain-language sentence + the right
action, keeping the raw error one click away:

- New shared classifier (updater-windows-signature-check): distinguishes an
  AV/EDR-blocked signature check (environment) from a genuine wrong-publisher
  mismatch (security). The two are mutually exclusive so a real integrity
  failure is never softened into "try again".
- UpdateCard: raw error moves behind a collapsed "Show details" toggle; adds a
  security-stop variant (wrong publisher → no retry, "Open official releases")
  and the AV-blocked variant ("Update Verification Blocked"). HTTP/2 and generic
  paths keep their existing actions, now with the same details disclosure.
- Main process records a windows_signature_check_blocked lifecycle event so we
  can size the affected Windows cohort in the field.

Verified each error scenario in a running Electron build (signature-check
blocked, wrong-publisher security stop, HTTP/2, generic, and the expanded
details view).
This commit is contained in:
Brennan Benson
2026-07-17 16:57:14 -07:00
parent 92fc79ee66
commit 02da1cfb79
9 changed files with 270 additions and 54 deletions
+9
View File
@@ -2,6 +2,7 @@
import { app, BrowserWindow, powerMonitor } from 'electron'
import { is } from '@electron-toolkit/utils'
import type { UpdateCheckOptions, UpdateStatus } from '../shared/types'
import { isWindowsSignatureCheckUnavailableFailure } from '../shared/updater-windows-signature-check'
import { killAllPty } from './ipc/pty'
import { withUpdaterSpan } from './observability/instrumentation'
import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader'
@@ -523,6 +524,14 @@ function sendErrorStatus(message: string, userInitiated?: boolean): void {
) {
return
}
// Why: counts AV/EDR-blocked Windows signature checks in the field so we can
// size the affected cohort before investing in bigger updater changes.
if (isWindowsSignatureCheckUnavailableFailure(message)) {
recordUpdaterLifecycle('windows_signature_check_blocked', undefined, {
level: 'warn',
message: 'Windows update signature check could not run'
})
}
sendStatus({ state: 'error', message, userInitiated })
}
+130 -49
View File
@@ -7,8 +7,12 @@ import { useAppStore } from '../store'
import { Card } from './ui/card'
import { Button } from './ui/button'
import { Progress } from './ui/progress'
import { AlertCircle, Check, Loader2, Minus, Network, RotateCw, X } from 'lucide-react'
import { AlertCircle, Check, Loader2, Minus, Network, RotateCw, ShieldAlert, X } from 'lucide-react'
import type { ChangelogData } from '../../../shared/types'
import {
isWindowsSignatureCheckUnavailableFailure,
isWindowsSignatureMismatchFailure
} from '../../../shared/updater-windows-signature-check'
import { translate } from '@/i18n/i18n'
// ── Helpers ──────────────────────────────────────────────────────────
@@ -37,11 +41,16 @@ export function isHttp2ProtocolError(message: string): boolean {
}
type ErrorCardModel = {
variant?: 'default' | 'http1Compatibility'
variant?: 'default' | 'http1Compatibility' | 'security'
title: string
summary: string
message: string
/** Optional guidance box between the summary and the raw error output. */
explainer?: string
/** Raw error text, shown only when the user expands "Show details". */
detail?: string
releaseUrl: string
/** Overrides the secondary button label (defaults to "Download Manually"). */
manualLabel?: string
primaryAction?: {
label: string
pendingLabel?: string
@@ -337,7 +346,16 @@ export function UpdateCard() {
})
}
// Why: classify each failure so the card leads with one plain sentence and
// the right action, and keeps the raw error behind "Show details" instead of
// dumping a PowerShell/stack trace as the headline. Order matters: the
// security-stop (wrong publisher) must win over the AV/EDR "check couldn't
// run" case so a real integrity failure is never softened into "try again".
const isHttp2UpdateError = status.state === 'error' && isHttp2ProtocolError(status.message)
const isSignatureMismatchError =
status.state === 'error' && isWindowsSignatureMismatchFailure(status.message)
const isSignatureCheckBlockedError =
status.state === 'error' && isWindowsSignatureCheckUnavailableFailure(status.message)
const errorCard: ErrorCardModel | null =
status.state === 'error'
? isHttp2UpdateError
@@ -345,7 +363,11 @@ export function UpdateCard() {
variant: 'http1Compatibility',
title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'),
summary: 'Orca can retry through HTTP/1.1 compatibility mode.',
message: compatibilitySetupError ?? status.message,
explainer: translate(
'auto.components.UpdateCard.90559b14e3',
'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.'
),
detail: compatibilitySetupError ?? status.message,
releaseUrl: releaseUrlForVersion(cachedVersion),
primaryAction: {
label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'),
@@ -354,35 +376,69 @@ export function UpdateCard() {
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
? {
: isSignatureMismatchError
? {
// Security stop: the installer is readable but signed by the wrong
// publisher. No retry — only a path to the verified download.
variant: 'security',
title: translate('auto.components.UpdateCard.5b309b19f3', "Update Wasn't Installed"),
summary: translate(
'auto.components.UpdateCard.092f09fc14',
"This download isn't signed by Orca, so we stopped it. Get the latest version from our official releases instead."
),
detail: status.message,
releaseUrl: releaseUrlForVersion(cachedVersion),
manualLabel: translate(
'auto.components.UpdateCard.c9ff9b9ec2',
'Open official releases'
)
}
: isSignatureCheckBlockedError
? {
title: translate(
'auto.components.UpdateCard.e944c2de43',
'Update Verification Blocked'
),
summary: translate(
'auto.components.UpdateCard.a05992a26b',
"The signature check couldn't run — usually antivirus software blocking it. Retry, or download it manually; Windows verifies the installer when it runs."
),
detail: status.message,
releaseUrl: releaseUrlForVersion(cachedVersion),
primaryAction: {
label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'),
onClick: handleUpdate
}
: {
label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'),
onClick: () => {
void window.api.updater.check({ includePrerelease: false })
}
}
}
}
: {
// 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.',
detail: 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: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'),
onClick: handleUpdate
}
: {
label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'),
onClick: () => {
void window.api.updater.check({ includePrerelease: false })
}
}
}
: installError
? {
title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'),
summary: 'Could not restart to install the update.',
message: installError,
detail: installError,
releaseUrl: releaseUrlForVersion(cachedVersion),
primaryAction: {
label: translate('auto.components.UpdateCard.2c2d3e03ca', 'Try Again'),
@@ -494,8 +550,10 @@ export function UpdateCard() {
<ErrorCardContent
title={errorCard.title}
summary={errorCard.summary}
message={errorCard.message}
explainer={errorCard.explainer}
detail={errorCard.detail}
releaseUrl={errorCard.releaseUrl}
manualLabel={errorCard.manualLabel}
variant={errorCard.variant}
primaryAction={errorCard.primaryAction}
onClose={handleCollapseWithAnimation}
@@ -892,16 +950,20 @@ function ErrorCardContent({
variant = 'default',
title,
summary,
message,
explainer,
detail,
releaseUrl,
manualLabel,
primaryAction,
onClose
}: {
variant?: 'default' | 'http1Compatibility'
variant?: 'default' | 'http1Compatibility' | 'security'
title: string
summary: string
message: string
explainer?: string
detail?: string
releaseUrl: string
manualLabel?: string
primaryAction?: {
label: string
pendingLabel?: string
@@ -910,12 +972,22 @@ function ErrorCardContent({
}
onClose: () => void
}) {
// Why: the raw error is kept for support but starts collapsed so the card
// leads with the plain-language summary, not a PowerShell/stack dump.
const [showDetails, setShowDetails] = useState(false)
const isCompatibility = variant === 'http1Compatibility'
const Icon = isCompatibility ? Network : AlertCircle
const isSecurity = variant === 'security'
const Icon = isCompatibility ? Network : isSecurity ? ShieldAlert : AlertCircle
return (
<div className="flex flex-col gap-3 p-4">
<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">
<div
className={`mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md border bg-muted/50 ${
isSecurity
? 'border-destructive/30 text-destructive'
: 'border-border text-muted-foreground'
}`}
>
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 space-y-1">
@@ -933,25 +1005,22 @@ function ErrorCardContent({
</Button>
</div>
{isCompatibility ? (
{explainer ? (
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.UpdateCard.90559b14e3',
'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>
<p className="text-xs leading-relaxed text-muted-foreground">{explainer}</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">
{translate('auto.components.UpdateCard.3553a8672f', '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>
{detail && showDetails ? (
<div className="rounded-md bg-muted/40 px-3 py-2">
<p className="mb-1 text-[11px] font-medium uppercase text-muted-foreground">
{translate('auto.components.UpdateCard.3553a8672f', 'Last error')}
</p>
<p className="scrollbar-sleek max-h-20 overflow-auto break-words font-mono text-xs leading-relaxed text-muted-foreground">
{detail}
</p>
</div>
) : null}
<div className="flex gap-2">
{primaryAction && (
@@ -976,11 +1045,23 @@ function ErrorCardContent({
variant="outline"
size="sm"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
className={primaryAction ? 'flex-1' : 'w-full'}
className="flex-1"
>
{translate('auto.components.UpdateCard.47126bcf57', 'Download Manually')}
{manualLabel ?? translate('auto.components.UpdateCard.47126bcf57', 'Download Manually')}
</Button>
</div>
{/* Why: the raw-error toggle sits on its own row so it never crowds the
two action buttons out of the narrow card. */}
{detail ? (
<button
className="-mt-1 self-start text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setShowDetails((prev) => !prev)}
>
{showDetails
? translate('auto.components.UpdateCard.5194358929', 'Hide details')
: translate('auto.components.UpdateCard.8bc9e17d8f', 'Show details')}
</button>
) : null}
</div>
)
}
+8 -1
View File
@@ -1815,7 +1815,14 @@
"a726967bd3": "Dismiss",
"d5253b54af": "error",
"7ffc08506e": "check",
"522df222b9": "spinner"
"522df222b9": "spinner",
"e944c2de43": "Update Verification Blocked",
"5b309b19f3": "Update Wasn't Installed",
"092f09fc14": "This download isn't signed by Orca, so we stopped it. Get the latest version from our official releases instead.",
"c9ff9b9ec2": "Open official releases",
"a05992a26b": "The signature check couldn't run — usually antivirus software blocking it. Retry, or download it manually; Windows verifies the installer when it runs.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
},
"WorktreeJumpPalette": {
"ac037cfac2": "Move",
+8 -1
View File
@@ -1815,7 +1815,14 @@
"a726967bd3": "Descartar",
"d5253b54af": "error",
"7ffc08506e": "check",
"522df222b9": "spinner"
"522df222b9": "spinner",
"e944c2de43": "Update Verification Blocked",
"5b309b19f3": "Update Wasn't Installed",
"092f09fc14": "This download isn't signed by Orca, so we stopped it. Get the latest version from our official releases instead.",
"c9ff9b9ec2": "Open official releases",
"a05992a26b": "The signature check couldn't run — usually antivirus software blocking it. Retry, or download it manually; Windows verifies the installer when it runs.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
},
"WorktreeJumpPalette": {
"ac037cfac2": "Mover",
+8 -1
View File
@@ -1815,7 +1815,14 @@
"a726967bd3": "閉じる",
"d5253b54af": "エラー",
"7ffc08506e": "チェック",
"522df222b9": "スピナー"
"522df222b9": "スピナー",
"e944c2de43": "Update Verification Blocked",
"5b309b19f3": "Update Wasn't Installed",
"092f09fc14": "This download isn't signed by Orca, so we stopped it. Get the latest version from our official releases instead.",
"c9ff9b9ec2": "Open official releases",
"a05992a26b": "The signature check couldn't run — usually antivirus software blocking it. Retry, or download it manually; Windows verifies the installer when it runs.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
},
"WorktreeJumpPalette": {
"ac037cfac2": "移動",
+8 -1
View File
@@ -1815,7 +1815,14 @@
"a726967bd3": "닫기",
"d5253b54af": "오류",
"7ffc08506e": "확인",
"522df222b9": "스피너"
"522df222b9": "스피너",
"e944c2de43": "Update Verification Blocked",
"5b309b19f3": "Update Wasn't Installed",
"092f09fc14": "This download isn't signed by Orca, so we stopped it. Get the latest version from our official releases instead.",
"c9ff9b9ec2": "Open official releases",
"a05992a26b": "The signature check couldn't run — usually antivirus software blocking it. Retry, or download it manually; Windows verifies the installer when it runs.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
},
"WorktreeJumpPalette": {
"ac037cfac2": "이동",
+8 -1
View File
@@ -1815,7 +1815,14 @@
"a726967bd3": "关闭",
"d5253b54af": "错误",
"7ffc08506e": "查看",
"522df222b9": "旋转器"
"522df222b9": "旋转器",
"e944c2de43": "Update Verification Blocked",
"5b309b19f3": "Update Wasn't Installed",
"092f09fc14": "This download isn't signed by Orca, so we stopped it. Get the latest version from our official releases instead.",
"c9ff9b9ec2": "Open official releases",
"a05992a26b": "The signature check couldn't run — usually antivirus software blocking it. Retry, or download it manually; Windows verifies the installer when it runs.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
},
"WorktreeJumpPalette": {
"ac037cfac2": "手机",
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
isWindowsSignatureCheckUnavailableFailure,
isWindowsSignatureMismatchFailure
} from './updater-windows-signature-check'
describe('isWindowsSignatureCheckUnavailableFailure', () => {
it('matches the PowerShell command failure shape (timeout / non-zero exit)', () => {
expect(
isWindowsSignatureCheckUnavailableFailure(
'Command failed: set "PSModulePath=" & chcp 65001 >NUL & powershell.exe -NoProfile ' +
'-NonInteractive -InputFormat None -Command "Get-AuthenticodeSignature -LiteralPath ' +
"'C:\\Users\\u\\AppData\\Local\\orca-updater\\pending\\orca-windows-setup.exe' | " +
'ConvertTo-Json -Compress"'
)
).toBe(true)
})
it('matches the stderr failure shape', () => {
expect(
isWindowsSignatureCheckUnavailableFailure(
'Cannot execute Get-AuthenticodeSignature, stderr: Access is denied. ' +
'Failing signature validation due to unknown stderr.'
)
).toBe(true)
})
it('does not match a genuine signature mismatch', () => {
expect(
isWindowsSignatureCheckUnavailableFailure(
'New version 1.4.144 is not signed by the application owner: ' +
'publisherNames: SignPath Foundation, raw info: {"Status": 0}'
)
).toBe(false)
})
it('does not match unrelated update errors', () => {
expect(isWindowsSignatureCheckUnavailableFailure('net::ERR_HTTP2_PROTOCOL_ERROR')).toBe(false)
expect(
isWindowsSignatureCheckUnavailableFailure('Cannot find channel "latest.yml" (404)')
).toBe(false)
})
})
describe('isWindowsSignatureMismatchFailure', () => {
it('matches the wrong-publisher integrity failure', () => {
expect(
isWindowsSignatureMismatchFailure(
'New version 1.4.144 is not signed by the application owner: ' +
'publisherNames: SignPath Foundation, raw info: {"Status": 0}'
)
).toBe(true)
})
it('is mutually exclusive with the check-unavailable classifier', () => {
const mismatch = 'New version 1.4.144 is not signed by the application owner: publisherNames: X'
expect(isWindowsSignatureMismatchFailure(mismatch)).toBe(true)
expect(isWindowsSignatureCheckUnavailableFailure(mismatch)).toBe(false)
const blocked = 'Command failed: … Get-AuthenticodeSignature -LiteralPath …'
expect(isWindowsSignatureCheckUnavailableFailure(blocked)).toBe(true)
expect(isWindowsSignatureMismatchFailure(blocked)).toBe(false)
})
it('does not match unrelated errors', () => {
expect(isWindowsSignatureMismatchFailure('net::ERR_HTTP2_PROTOCOL_ERROR')).toBe(false)
})
})
@@ -0,0 +1,23 @@
// Why: electron-updater verifies Windows installers by spawning PowerShell's
// Get-AuthenticodeSignature. Antivirus/EDR interception, its hardcoded 20s
// timeout, or stalled revocation lookups kill that spawn, and the raw
// child-process failure becomes the update error. Detect that shape so the UI
// can explain it instead of dumping the command line. A true signature
// mismatch ("not signed by the application owner") must NOT match — that is a
// real integrity failure, not environment interference.
export function isWindowsSignatureCheckUnavailableFailure(message: string): boolean {
const normalized = message.toLowerCase()
if (normalized.includes('not signed by the application owner')) {
return false
}
return normalized.includes('get-authenticodesignature')
}
// Why: electron-updater throws ERR_UPDATER_INVALID_SIGNATURE with this exact
// phrase when the downloaded installer is validly readable but signed by the
// wrong publisher — a genuine integrity failure, NOT environment interference.
// This must drive a security-stop message (no silent "retry" framing) and stay
// distinct from isWindowsSignatureCheckUnavailableFailure above.
export function isWindowsSignatureMismatchFailure(message: string): boolean {
return message.toLowerCase().includes('not signed by the application owner')
}