diff --git a/src/main/updater.ts b/src/main/updater.ts index 290100f0f63..297b804aece 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -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 }) } diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 806540832a3..ebf9d5435fb 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -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() { 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 (
-
+
@@ -933,25 +1005,22 @@ function ErrorCardContent({
- {isCompatibility ? ( + {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.' - )} -

+

{explainer}

) : null} -
-

- {translate('auto.components.UpdateCard.3553a8672f', 'Last error')} -

-

- {message} -

-
+ {detail && showDetails ? ( +
+

+ {translate('auto.components.UpdateCard.3553a8672f', 'Last error')} +

+

+ {detail} +

+
+ ) : null}
{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')}
+ {/* Why: the raw-error toggle sits on its own row so it never crowds the + two action buttons out of the narrow card. */} + {detail ? ( + + ) : null}
) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5fa8b146a61..9de76008ccd 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 45e1afec7a4..ce16d4a4825 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c454ec9ce47..30dd4239234 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "移動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b1f7cca3488..2a7c845f16a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "이동", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1e59503c12c..94394b32623 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "手机", diff --git a/src/shared/updater-windows-signature-check.test.ts b/src/shared/updater-windows-signature-check.test.ts new file mode 100644 index 00000000000..648630f58a2 --- /dev/null +++ b/src/shared/updater-windows-signature-check.test.ts @@ -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) + }) +}) diff --git a/src/shared/updater-windows-signature-check.ts b/src/shared/updater-windows-signature-check.ts new file mode 100644 index 00000000000..58be137f236 --- /dev/null +++ b/src/shared/updater-windows-signature-check.ts @@ -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') +}