From 3cb485318704e6ffe2bf0492875e893cd2fc2cd6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:29:16 -0800 Subject: [PATCH] Keep dropped-macOS updaters on their last compatible build and explain why (#38777) * Keep dropped-macOS updaters on their last compatible build and explain why Stamp minimumSystemVersion into every mac update manifest from the built app's LSMinimumSystemVersion, and refuse to publish an ungated release. When the updater withholds a newer release for this macOS, show a one-time notice and say so in Settings and the update card. * Trim the unsupported-Mac notice copy --- .github/workflows/adhoc-mac-build.yml | 10 +- .github/workflows/daily-mac-build.yml | 12 ++ .github/workflows/hourly-mac-build.yml | 11 ++ .github/workflows/release-mac-build.yml | 16 ++ .../macos/gate-mac-update-manifests.mjs | 76 ++++++++ .../macos/gate-mac-update-manifests.test.mjs | 22 +++ .../publish-complete-draft-releases.test.mjs | 6 +- .../verify-release-required-assets.mjs | 4 + src/main/ipc/app.ts | 4 + .../macos-upgrade-support.ts | 61 +++++++ src/main/updater/releases/updater-fallback.ts | 5 +- .../updater-system-requirement.test.ts | 30 ++++ .../releases/updater-system-requirement.ts | 51 ++++++ src/main/updater/updater-events.ts | 10 +- src/preload/api/app-api.ts | 3 + src/preload/api/app-bridge.ts | 3 + .../src/app-shell/AppRootSurfaces.tsx | 2 + .../MacosUpdateRequirementDialog.fixture.tsx | 23 +++ .../MacosUpdateRequirementDialog.tsx | 168 ++++++++++++++++++ .../MacosUpdateRequirementHost.tsx | 32 ++++ .../MacosUpdateRequirementNotice.tsx | 52 ++++++ .../update-card/UpdateCardStateContent.tsx | 11 +- .../updates/GeneralUpdateSettingsSection.tsx | 45 +++-- src/renderer/src/i18n/locales/en.json | 20 +++ .../ui/ui-slice-contract-preferences.ts | 2 + .../slices/ui/ui-slice-hydration-actions.ts | 1 + .../slices/ui/ui-slice-update-actions.ts | 7 + .../src/web/preload-api/web-app-api.ts | 1 + src/shared/macos-upgrade-support.ts | 2 + src/shared/persisted-ui-state-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 1 + src/shared/update-status-types.ts | 9 + .../wsl-move/wsl-move-ui-dispositions.ts | 1 + 33 files changed, 678 insertions(+), 25 deletions(-) create mode 100644 config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs create mode 100644 config/scripts/desktop-distribution/macos/gate-mac-update-manifests.test.mjs create mode 100644 src/main/macos-update-requirement/macos-upgrade-support.ts create mode 100644 src/main/updater/releases/updater-system-requirement.test.ts create mode 100644 src/main/updater/releases/updater-system-requirement.ts create mode 100644 src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementDialog.fixture.tsx create mode 100644 src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementDialog.tsx create mode 100644 src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementHost.tsx create mode 100644 src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementNotice.tsx create mode 100644 src/shared/macos-upgrade-support.ts diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 16fb29c2ea7..a40bed3ff72 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -401,7 +401,9 @@ jobs: with: ref: ${{ github.workflow_sha }} path: .workflow-tools - sparse-checkout: config/scripts/desktop-distribution/macos/adhoc-mac-manifests.mjs + sparse-checkout: | + config/scripts/desktop-distribution/macos/adhoc-mac-manifests.mjs + config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs sparse-checkout-cone-mode: false persist-credentials: false @@ -440,7 +442,11 @@ jobs: env: MAC_ARCH: ${{ matrix.arch }} MAC_VERSION: ${{ needs.orcad-template-support.outputs.version }} - run: node .workflow-tools/config/scripts/desktop-distribution/macos/adhoc-mac-manifests.mjs stage "$MAC_ARCH" "$MAC_VERSION" dist .build/mac-manifests + run: | + set -euo pipefail + # Builder cannot write this field, so older macOS updaters would install a build that won't launch. + node .workflow-tools/config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs dist + node .workflow-tools/config/scripts/desktop-distribution/macos/adhoc-mac-manifests.mjs stage "$MAC_ARCH" "$MAC_VERSION" dist .build/mac-manifests - name: Mint adhoc repo token id: app_token diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 39784f29920..ec0e08a3644 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -330,6 +330,18 @@ jobs: owner: stablyai repositories: orca-daily + # Builder cannot write this field, so older macOS updaters would install a build that won't launch. + - name: Gate the update manifest on the minimum macOS + if: steps.freshness.outputs.should_build == 'true' + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_final.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + node config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs dist + gh release upload "$TAG" dist/*-mac.yml --repo "$DAILY_REPO" --clobber + # Verify latest-mac.yml before exposing its release to prevent picker downloads returning 404. - name: Verify update manifest published if: steps.freshness.outputs.should_build == 'true' diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index cd5195ec05c..bce2d132f30 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -298,6 +298,17 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Builder cannot write this field, so older macOS updaters would install a build that won't launch. + - name: Gate the update manifest on the minimum macOS + shell: bash + env: + GH_TOKEN: ${{ steps.app_token_publish.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + node config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs dist + gh release upload "$TAG" dist/*-mac.yml --repo "$HOURLY_REPO" --clobber + # Verify latest-mac.yml before exposing release; missing manifests cause picker download 404s. - name: Verify update manifest published shell: bash diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 6bffa49288e..dabbe336dfe 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -240,6 +240,22 @@ jobs: APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Builder cannot write this field, so older macOS updaters would install a build that won't launch. + - name: Gate the update manifest on the minimum macOS + # Tags cut before the gate keep their manifest unchanged. + if: hashFiles('config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs') != '' + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag }} + PUBLISH: ${{ !inputs.qualification_only }} + run: | + set -euo pipefail + node config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs dist + if [ "$PUBLISH" = true ]; then + gh release upload "$TAG" dist/*-mac.yml --repo "$GITHUB_REPOSITORY" --clobber + fi + - name: Verify release remains draft after artifact upload if: ${{ !inputs.qualification_only }} # Re-draft premature builder publication before failing; latest must not keep an incomplete Windows release. diff --git a/config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs b/config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs new file mode 100644 index 00000000000..f15033191de --- /dev/null +++ b/config/scripts/desktop-distribution/macos/gate-mac-update-manifests.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// Stamps each macOS update manifest with the app's minimum macOS so the updater on an older +// macOS stays on its last compatible build instead of installing one that will not launch. +// Usage: node gate-mac-update-manifests.mjs +import { execFileSync } from 'node:child_process' +import { readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +// electron-updater compares `minimumSystemVersion` with os.release(), the Darwin version. +export function darwinVersionForMacos(macosVersion) { + const major = Number.parseInt(String(macosVersion).split('.')[0] ?? '', 10) + if (major >= 11 && major <= 15) { + return `${major + 9}.0.0` + } + if (major >= 26) { + return `${major - 1}.0.0` + } + throw new Error(`Unsupported LSMinimumSystemVersion: ${macosVersion}`) +} + +export function withMinimumSystemVersion(manifest, darwinVersion) { + const lines = manifest + .replace(/\n+$/, '') + .split('\n') + .filter((line) => !line.startsWith('minimumSystemVersion:')) + lines.push(`minimumSystemVersion: ${darwinVersion}`) + return `${lines.join('\n')}\n` +} + +function readMinimumMacosVersion(distDir) { + const versions = new Set() + for (const entry of readdirSync(distDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('mac')) { + continue + } + for (const app of readdirSync(join(distDir, entry.name))) { + if (!app.endsWith('.app')) { + continue + } + const plist = join(distDir, entry.name, app, 'Contents', 'Info.plist') + versions.add( + execFileSync('plutil', ['-extract', 'LSMinimumSystemVersion', 'raw', plist], { + encoding: 'utf8' + }).trim() + ) + } + } + if (versions.size !== 1) { + throw new Error( + `Expected one LSMinimumSystemVersion across ${distDir}, found ${[...versions].join(', ')}` + ) + } + return [...versions][0] +} + +function main(distDir) { + const macosVersion = readMinimumMacosVersion(distDir) + const darwinVersion = darwinVersionForMacos(macosVersion) + const manifests = readdirSync(distDir).filter((name) => name.endsWith('-mac.yml')) + if (manifests.length === 0) { + throw new Error(`No *-mac.yml manifest in ${distDir}`) + } + for (const name of manifests) { + const path = join(distDir, name) + writeFileSync(path, withMinimumSystemVersion(readFileSync(path, 'utf8'), darwinVersion)) + console.log(`${name}: minimumSystemVersion ${darwinVersion} (macOS ${macosVersion})`) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + if (!process.argv[2]) { + console.error('Usage: node gate-mac-update-manifests.mjs ') + process.exit(1) + } + main(process.argv[2]) +} diff --git a/config/scripts/desktop-distribution/macos/gate-mac-update-manifests.test.mjs b/config/scripts/desktop-distribution/macos/gate-mac-update-manifests.test.mjs new file mode 100644 index 00000000000..a1573809b69 --- /dev/null +++ b/config/scripts/desktop-distribution/macos/gate-mac-update-manifests.test.mjs @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { darwinVersionForMacos, withMinimumSystemVersion } from './gate-mac-update-manifests.mjs' + +describe('darwinVersionForMacos', () => { + it('maps macOS releases to the Darwin version os.release() reports', () => { + expect(darwinVersionForMacos('12.0')).toBe('21.0.0') + expect(darwinVersionForMacos('13.0')).toBe('22.0.0') + expect(darwinVersionForMacos('15.4')).toBe('24.0.0') + expect(darwinVersionForMacos('26.0')).toBe('25.0.0') + expect(() => darwinVersionForMacos('10.15')).toThrow() + }) +}) + +describe('withMinimumSystemVersion', () => { + it('replaces any earlier gate with exactly one top-level entry', () => { + const manifest = + 'version: 1.5.0\nfiles:\n - url: Orca-1.5.0-mac.zip\nminimumSystemVersion: 21.0.0\n' + expect(withMinimumSystemVersion(manifest, '22.0.0')).toBe( + 'version: 1.5.0\nfiles:\n - url: Orca-1.5.0-mac.zip\nminimumSystemVersion: 22.0.0\n' + ) + }) +}) diff --git a/config/scripts/release/publication/publish-complete-draft-releases.test.mjs b/config/scripts/release/publication/publish-complete-draft-releases.test.mjs index 0d69cdfc32c..8cb53aa0c84 100644 --- a/config/scripts/release/publication/publish-complete-draft-releases.test.mjs +++ b/config/scripts/release/publication/publish-complete-draft-releases.test.mjs @@ -141,7 +141,11 @@ describe('publishCompleteDraftReleases', () => { .filter(({ name }) => name.startsWith('latest')) .map(({ id, name }) => [ `https://api.github.com/repos/stablyai/orca/releases/assets/${id}`, - name === 'latest.yml' ? 'path: orca-windows-setup.exe\n' : '' + name === 'latest.yml' + ? 'path: orca-windows-setup.exe\n' + : name === 'latest-mac.yml' + ? 'minimumSystemVersion: 22.0.0\n' + : '' ]) ) ) diff --git a/config/scripts/release/publication/verify-release-required-assets.mjs b/config/scripts/release/publication/verify-release-required-assets.mjs index d19d5093114..98a469bbb7e 100644 --- a/config/scripts/release/publication/verify-release-required-assets.mjs +++ b/config/scripts/release/publication/verify-release-required-assets.mjs @@ -103,6 +103,10 @@ export async function verifyRequiredReleaseAssets({ repo, tag, token }) { continue } const manifestText = await fetchAssetText(repo, manifestAsset, token) + // Without it, updaters on a dropped macOS install a build that cannot launch. + if (manifestName === 'latest-mac.yml' && !/^minimumSystemVersion:/m.test(manifestText)) { + throw new Error(`Release ${tag} latest-mac.yml has no minimumSystemVersion gate.`) + } for (const referencedName of extractManifestAssetNames(manifestText)) { requiredNames.add(referencedName) } diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 79319d43cf0..75686db022e 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -40,6 +40,7 @@ import { registerMacKeyboardLayoutChangeNotifications } from './macos-keyboard-l import { isProfileStateSaveDelayed } from '../startup/profile-state-save-delay' import { readCommandStdout, readKeyboardInputSourceId } from './macos-keyboard-input-source' import { getWindowsAdministratorStatus } from '../windows/windows-administrator-status' +import { getMacosUpgradeSupport } from '../macos-update-requirement/macos-upgrade-support' import type { ReconnectFactsFor } from '../persistence/window-save-view' @@ -148,6 +149,9 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp return detection }) ipcMain.handle('app:getWindowsAdministratorStatus', getWindowsAdministratorStatus) + ipcMain.handle('app:getMacosUpgradeSupport', (_event, minimumMacosVersion: unknown) => + getMacosUpgradeSupport(minimumMacosVersion) + ) ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl()) diff --git a/src/main/macos-update-requirement/macos-upgrade-support.ts b/src/main/macos-update-requirement/macos-upgrade-support.ts new file mode 100644 index 00000000000..1f5c300c24c --- /dev/null +++ b/src/main/macos-update-requirement/macos-upgrade-support.ts @@ -0,0 +1,61 @@ +import { app } from 'electron' +import { runProcess } from '@orca/process-host' +import type { MacosUpgradeSupport } from '../../shared/macos-upgrade-support' + +// Ventura's Intel model boundaries: https://support.apple.com/en-us/102861. +const VENTURA_INTEL_MODELS = { + MacBook: [10, 10], + MacBookAir: [8, 9], + MacBookPro: [14, 16], + Macmini: [8, 8], + iMac: [18, 20], + iMacPro: [1, 1], + MacPro: [7, 7] +} as const + +export function getIntelVenturaSupport(modelIdentifier: string): MacosUpgradeSupport { + const match = + /^(MacBookAir|MacBookPro|MacBook|Macmini|iMacPro|iMac|MacPro)([1-9]\d*),[1-9]\d*$/u.exec( + modelIdentifier + ) + const limits = match + ? Object.entries(VENTURA_INTEL_MODELS).find(([name]) => name === match[1])?.[1] + : undefined + if (!match || !limits) { + return 'unknown' + } + const major = Number(match[2]) + if (major < limits[0]) { + return 'unsupported' + } + return major <= limits[1] ? 'supported' : 'unknown' +} + +let intelVenturaSupport: Promise | undefined + +async function readIntelVenturaSupport(): Promise { + const result = await runProcess({ + program: '/usr/sbin/sysctl', + args: ['-n', 'hw.model'], + timeoutMs: 1_000, + maxOutputBytes: 128 + }) + const model = + result.code === 0 && !result.timedOut && !result.outputTruncated ? result.stdout.trim() : '' + return model ? getIntelVenturaSupport(model) : 'unknown' +} + +/** Whether Apple supports `minimumMacosVersion` on this Mac; only macOS 13 has model data. */ +export function getMacosUpgradeSupport(minimumMacosVersion: unknown): Promise { + if (process.platform !== 'darwin') { + return Promise.resolve('unknown') + } + // Every Apple Silicon Mac supports macOS 13 through 26. + if (process.arch === 'arm64' || app.runningUnderARM64Translation) { + return Promise.resolve('supported') + } + if (minimumMacosVersion !== '13') { + return Promise.resolve('unknown') + } + return (intelVenturaSupport ??= readIntelVenturaSupport().catch(() => 'unknown')) +} diff --git a/src/main/updater/releases/updater-fallback.ts b/src/main/updater/releases/updater-fallback.ts index 3576327062c..e707afe005a 100644 --- a/src/main/updater/releases/updater-fallback.ts +++ b/src/main/updater/releases/updater-fallback.ts @@ -25,7 +25,10 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean left.delayedUpdate?.version === right.delayedUpdate?.version && left.delayedUpdate?.availableAt === right.delayedUpdate?.availableAt && left.delayedUpdate?.releasedAt === right.delayedUpdate?.releasedAt && - left.delayedUpdate?.delayDays === right.delayedUpdate?.delayDays + left.delayedUpdate?.delayDays === right.delayedUpdate?.delayDays && + left.unsupportedSystemUpdate?.version === right.unsupportedSystemUpdate?.version && + left.unsupportedSystemUpdate?.minimumMacosVersion === + right.unsupportedSystemUpdate?.minimumMacosVersion ) case 'available': return ( diff --git a/src/main/updater/releases/updater-system-requirement.test.ts b/src/main/updater/releases/updater-system-requirement.test.ts new file mode 100644 index 00000000000..d76e07e3dbf --- /dev/null +++ b/src/main/updater/releases/updater-system-requirement.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { getUnsupportedSystemUpdate } from './updater-system-requirement' + +describe('getUnsupportedSystemUpdate', () => { + const gated = { version: '1.5.0', minimumSystemVersion: '22.0.0' } + + it('reports a newer release gated above this macOS', () => { + expect(getUnsupportedSystemUpdate(gated, '1.4.9', 'darwin', '21.6.0')).toEqual({ + version: '1.5.0', + minimumMacosVersion: '13' + }) + expect( + getUnsupportedSystemUpdate( + { version: '2.0.0', minimumSystemVersion: '25.0.0' }, + '1.4.9', + 'darwin', + '24.6.0' + ) + ).toEqual({ version: '2.0.0', minimumMacosVersion: '26' }) + }) + + it('ignores releases this Mac can install or that are not newer', () => { + expect(getUnsupportedSystemUpdate(gated, '1.4.9', 'darwin', '22.0.0')).toBeUndefined() + expect(getUnsupportedSystemUpdate(gated, '1.5.0', 'darwin', '21.6.0')).toBeUndefined() + expect(getUnsupportedSystemUpdate(gated, '1.4.9', 'win32', '10.0.22631')).toBeUndefined() + expect( + getUnsupportedSystemUpdate({ version: '1.5.0' }, '1.4.9', 'darwin', '21.6.0') + ).toBeUndefined() + }) +}) diff --git a/src/main/updater/releases/updater-system-requirement.ts b/src/main/updater/releases/updater-system-requirement.ts new file mode 100644 index 00000000000..ca2afdf2c54 --- /dev/null +++ b/src/main/updater/releases/updater-system-requirement.ts @@ -0,0 +1,51 @@ +import { release } from 'node:os' +import type { UpdateInfo } from 'electron-updater' +import type { UnsupportedSystemUpdate } from '../../../shared/update-status-types' +import { compareVersions } from './updater-fallback' + +function darwinParts(version: string): number[] | null { + const parts = version.split('.').map((part) => Number.parseInt(part, 10)) + return parts.every(Number.isInteger) ? parts : null +} + +function isDarwinOlder(current: number[], minimum: number[]): boolean { + for (let index = 0; index < minimum.length; index += 1) { + const difference = (current[index] ?? 0) - (minimum[index] ?? 0) + if (difference !== 0) { + return difference < 0 + } + } + return false +} + +// macOS 11-15 run Darwin 20-24; macOS 26 runs Darwin 25. +function macosMajorForDarwinMajor(darwinMajor: number): number | null { + if (darwinMajor < 20) { + return null + } + return darwinMajor <= 24 ? darwinMajor - 9 : darwinMajor + 1 +} + +/** The newer release electron-updater withheld because this Mac's macOS is below its manifest gate. */ +export function getUnsupportedSystemUpdate( + info: Pick | null | undefined, + currentVersion: string, + platform: NodeJS.Platform = process.platform, + darwinVersion: string = release() +): UnsupportedSystemUpdate | undefined { + if (platform !== 'darwin' || !info?.minimumSystemVersion) { + return undefined + } + if (compareVersions(info.version, currentVersion) <= 0) { + return undefined + } + const minimum = darwinParts(info.minimumSystemVersion) + const current = darwinParts(darwinVersion) + if (!minimum || !current || !isDarwinOlder(current, minimum)) { + return undefined + } + const macosMajor = macosMajorForDarwinMajor(minimum[0]) + return macosMajor === null + ? undefined + : { version: info.version, minimumMacosVersion: String(macosMajor) } +} diff --git a/src/main/updater/updater-events.ts b/src/main/updater/updater-events.ts index 734d5ec0d82..652f6238f5e 100644 --- a/src/main/updater/updater-events.ts +++ b/src/main/updater/updater-events.ts @@ -6,6 +6,7 @@ import { } from './mac-install/updater-mac-install' import { compareVersions } from './releases/updater-fallback' import { fetchChangelog } from './releases/updater-changelog' +import { getUnsupportedSystemUpdate } from './releases/updater-system-requirement' import type { UpdaterHandlerContext } from './updater-handler-context' import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' import { @@ -171,7 +172,7 @@ export function registerAutoUpdaterHandlers({ })() }) - autoUpdater.on('update-not-available', () => { + autoUpdater.on('update-not-available', (info) => { if (getActiveUpdateCheckEventAttemptId() === null) { return } @@ -197,9 +198,14 @@ export function registerAutoUpdaterHandlers({ } } } + const unsupportedSystemUpdate = getUnsupportedSystemUpdate(info, app.getVersion()) // Preserve manual-install recovery when a recheck finds no newer release. sendStatus( - retainedStatus ?? { state: 'not-available', userInitiated: wasUserInitiated || undefined } + retainedStatus ?? { + state: 'not-available', + userInitiated: wasUserInitiated || undefined, + ...(unsupportedSystemUpdate ? { unsupportedSystemUpdate } : {}) + } ) if (localBuildCheck || pinnedBuildCheck) { restoreReleaseUpdateSource() diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 3cc6a76b7aa..d1794fc923a 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -1,6 +1,7 @@ import type { UiLanguageDetection } from '../../shared/ui-locale' import type { WindowMaterial } from '../../shared/window-material' import type { AppIdentity } from '../../shared/app-identity' +import type { MacosUpgradeSupport } from '../../shared/macos-upgrade-support' import type { E2EConfig } from '../../shared/e2e-config' import type { ExecutionHostId } from '../../shared/execution-host' import type { @@ -24,6 +25,8 @@ export type AppApi = { getIdentity: () => Promise /** Local Windows desktop elevation; null when unavailable. Does not describe remote hosts. */ getWindowsAdministratorStatus: () => Promise + /** Local Mac only; optional for older clients. */ + getMacosUpgradeSupport?: (minimumMacosVersion: string) => Promise /** Restarts the installed Windows desktop with administrator approval and replaces its local terminal host. */ restartAsAdministrator: () => Promise /** Append filenames to this base: Vite /@fs in dev, file:// resources when packaged. */ diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index b34af124ccf..113f18603d1 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { AppIdentity } from '../../shared/app-identity' +import type { MacosUpgradeSupport } from '../../shared/macos-upgrade-support' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { MacCapturedDigitRowChord } from '../../shared/macos-symbolic-hotkeys' import type { MarkdownDocument } from '../../shared/filesystem-entry-types' @@ -26,6 +27,8 @@ export const appApi = { getIdentity: (): Promise => ipcRenderer.invoke('app:getIdentity'), getWindowsAdministratorStatus: (): Promise => ipcRenderer.invoke('app:getWindowsAdministratorStatus'), + getMacosUpgradeSupport: (minimumMacosVersion: string): Promise => + ipcRenderer.invoke('app:getMacosUpgradeSupport', minimumMacosVersion), restartAsAdministrator: (): Promise => prepareAndInvokeAppRestart( window, diff --git a/src/renderer/src/app-shell/AppRootSurfaces.tsx b/src/renderer/src/app-shell/AppRootSurfaces.tsx index a3093931ea3..2855989dec5 100644 --- a/src/renderer/src/app-shell/AppRootSurfaces.tsx +++ b/src/renderer/src/app-shell/AppRootSurfaces.tsx @@ -18,6 +18,7 @@ import { StarNagToastHost } from '../components/star-nag/StarNagToastHost' import { TelemetryFirstLaunchSurface } from '../components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from '../components/ZoomOverlay' import { WslMoveDialogHost } from '../components/wsl-move/WslMoveDialog' +import { MacosUpdateRequirementHost } from '../components/macos-update-requirement/MacosUpdateRequirementHost' import { shouldRenderPetOverlay } from '../components/pet/pet-overlay-visibility' import { useAppStore } from '../store' import type { UpdateStatus } from '../../../shared/update-status-types' @@ -296,6 +297,7 @@ export const AppRootSurfaces = memo(function AppRootSurfaces(props: { ) : null} + ( + {}} + requirement={requirement} + support={support} + onOpenUrl={() => {}} + /> + ) + }) +} + +export const canUpgrade = fixture('supported') +export const cannotUpgrade = fixture('unsupported') +export const unknown = fixture('unknown') +export const checking = fixture(undefined) diff --git a/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementDialog.tsx b/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementDialog.tsx new file mode 100644 index 00000000000..8c3a08d7d11 --- /dev/null +++ b/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementDialog.tsx @@ -0,0 +1,168 @@ +import { useRef, type JSX, type MouseEvent } from 'react' +import { ArrowUpRight, ChevronDown } from 'lucide-react' +import type { MacosUpgradeSupport } from '../../../../shared/macos-upgrade-support' +import type { UnsupportedSystemUpdate } from '../../../../shared/update-status-types' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +const MACOS_UPDATE_GUIDE_URL = 'https://support.apple.com/en-us/108382' +const VENTURA_COMPATIBILITY_URL = 'https://support.apple.com/en-us/102861' +const MACOS_SUPPORT_DISCUSSION_URL = 'https://github.com/stablyai/orca/discussions/38765' + +export function MacosUpdateRequirementDialog({ + open, + onOpenChange, + requirement, + support, + onOpenUrl +}: { + open: boolean + onOpenChange: (open: boolean) => void + requirement: UnsupportedSystemUpdate + /** Undefined while the local probe runs. */ + support: MacosUpgradeSupport | undefined + onOpenUrl: (url: string, event: MouseEvent) => void +}): JSX.Element { + const primaryButtonRef = useRef(null) + const macos = requirement.minimumMacosVersion + const checking = support === undefined + const canUpgrade = support === 'supported' + const cannotUpgrade = support === 'unsupported' + const primaryUrl = canUpgrade + ? MACOS_UPDATE_GUIDE_URL + : cannotUpgrade + ? MACOS_SUPPORT_DISCUSSION_URL + : macos === '13' + ? VENTURA_COMPATIBILITY_URL + : MACOS_UPDATE_GUIDE_URL + + return ( + + { + if (!checking) { + event.preventDefault() + primaryButtonRef.current?.focus({ preventScroll: true }) + } + }} + > + + + {translate('macosUpdateRequirement.title', 'Orca updates now need macOS {{macos}}', { + macos + })} + + + {translate( + 'macosUpdateRequirement.description', + 'Orca {{version}} requires macOS {{macos}} or later. This version keeps working, but it won’t get new updates on this Mac.', + { version: requirement.version, macos } + )} + + + +
+

+ {checking + ? translate( + 'macosUpdateRequirement.checking', + 'Checking whether this Mac can upgrade…' + ) + : canUpgrade + ? translate( + 'macosUpdateRequirement.upgradeDescription', + 'Your Mac can upgrade. Update macOS to get the latest Orca.' + ) + : cannotUpgrade + ? translate( + 'macosUpdateRequirement.unsupportedDescription', + 'Apple doesn’t support macOS {{macos}} on this Mac.', + { macos } + ) + : translate( + 'macosUpdateRequirement.unknownDescription', + 'We couldn’t confirm an upgrade for this Mac. Apple’s compatibility guide can help.' + )} +

+
+ + + + {translate('macosUpdateRequirement.whyTitle', 'Why is this changing?')} + + + +
+

+ {translate( + 'macosUpdateRequirement.whyIntro', + 'We updated Electron, the foundation Orca runs on, to bring:' + )} +

+
    +
  • + {translate( + 'macosUpdateRequirement.whySecurity', + 'Ongoing security fixes from Electron and Chromium.' + )} +
  • +
  • + {translate( + 'macosUpdateRequirement.whyPerformance', + 'Faster startup and more efficient native API calls.' + )} +
  • +
  • + {translate( + 'macosUpdateRequirement.whySystemApis', + 'Support for current macOS notification APIs and secure Keychain storage.' + )} +
  • +
+

+ {translate( + 'macosUpdateRequirement.whyRequirement', + 'That newer foundation requires macOS {{macos}} or later.', + { macos } + )} +

+
+
+
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementHost.tsx b/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementHost.tsx new file mode 100644 index 00000000000..c6542725eef --- /dev/null +++ b/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementHost.tsx @@ -0,0 +1,32 @@ +import { Suspense, type JSX } from 'react' +import { ModalBoundary } from '@/app-shell/app-root-surface-boundaries' +import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' +import { isWebClientLocation } from '@/lib/web-client-location' +import { useAppStore } from '@/store' + +const MacosUpdateRequirementNotice = lazy(() => import('./MacosUpdateRequirementNotice')) + +/** Loads the notice only once the updater withholds a release this Mac cannot run. */ +export function MacosUpdateRequirementHost(): JSX.Element | null { + const requirement = useAppStore((s) => + s.updateStatus.state === 'not-available' ? s.updateStatus.unsupportedSystemUpdate : undefined + ) + const seen = useAppStore((s) => s.seenMacosUpdateRequirement) + const persistedUIReady = useAppStore((s) => s.persistedUIReady) + // A web client's update status describes its host, not the browser's machine. + if ( + !requirement || + !persistedUIReady || + isWebClientLocation() || + requirement.minimumMacosVersion === seen + ) { + return null + } + return ( + + + + + + ) +} diff --git a/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementNotice.tsx b/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementNotice.tsx new file mode 100644 index 00000000000..a734a4cdc20 --- /dev/null +++ b/src/renderer/src/components/macos-update-requirement/MacosUpdateRequirementNotice.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState, type JSX } from 'react' +import type { MacosUpgradeSupport } from '../../../../shared/macos-upgrade-support' +import type { UnsupportedSystemUpdate } from '../../../../shared/update-status-types' +import { openAppHttpLink } from '@/lib/app-http-link-open' +import { useAppStore } from '@/store' +import { MacosUpdateRequirementDialog } from './MacosUpdateRequirementDialog' + +/** One-time notice when the updater withholds a release this Mac's macOS cannot run. */ +export default function MacosUpdateRequirementNotice({ + requirement +}: { + requirement: UnsupportedSystemUpdate +}): JSX.Element { + const markSeen = useAppStore((s) => s.markMacosUpdateRequirementSeen) + const [open, setOpen] = useState(true) + const [support, setSupport] = useState() + const { minimumMacosVersion } = requirement + + useEffect(() => { + let cancelled = false + void Promise.resolve(window.api.app.getMacosUpgradeSupport?.(minimumMacosVersion)) + .catch(() => undefined) + .then((result) => { + if (!cancelled) { + setSupport(result ?? 'unknown') + } + }) + return () => { + cancelled = true + } + }, [minimumMacosVersion]) + + const handleOpenChange = (next: boolean): void => { + setOpen(next) + if (!next) { + markSeen(minimumMacosVersion) + } + } + + return ( + { + handleOpenChange(false) + openAppHttpLink(url, { event }) + }} + /> + ) +} diff --git a/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx b/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx index 8bd1e2db406..1ad59550248 100644 --- a/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx +++ b/src/renderer/src/components/maintenance/update-card/UpdateCardStateContent.tsx @@ -60,10 +60,19 @@ export function UpdateCardStateContent({ ) } if (status.state === 'not-available') { + const unsupported = status.unsupportedSystemUpdate return ( ) } diff --git a/src/renderer/src/components/settings/updates/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/updates/GeneralUpdateSettingsSection.tsx index c2bd609ab14..87ad13e8cf4 100644 --- a/src/renderer/src/components/settings/updates/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/updates/GeneralUpdateSettingsSection.tsx @@ -212,25 +212,34 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { )} {updateStatus.state === 'not-available' && - (updateStatus.delayedUpdate - ? isDelayedUpdatePending(updateStatus.delayedUpdate, now) - ? translate( - 'settings.updateDelay.upToDateWithHeld', - 'You’re up to date. A newer release is on hold until {{date}}.', - { - date: formatUpdateDelayDate( - Date.parse(updateStatus.delayedUpdate.availableAt) - ) - } - ) + (updateStatus.unsupportedSystemUpdate + ? translate( + 'macosUpdateRequirement.settingsStatus', + 'Orca {{version}} requires macOS {{macos}} or later. This is the latest version for this Mac.', + { + version: updateStatus.unsupportedSystemUpdate.version, + macos: updateStatus.unsupportedSystemUpdate.minimumMacosVersion + } + ) + : updateStatus.delayedUpdate + ? isDelayedUpdatePending(updateStatus.delayedUpdate, now) + ? translate( + 'settings.updateDelay.upToDateWithHeld', + 'You’re up to date. A newer release is on hold until {{date}}.', + { + date: formatUpdateDelayDate( + Date.parse(updateStatus.delayedUpdate.availableAt) + ) + } + ) + : translate( + 'settings.updateDelay.heldReleaseReady', + 'A newer release is ready. Check for updates to get it.' + ) : translate( - 'settings.updateDelay.heldReleaseReady', - 'A newer release is ready. Check for updates to get it.' - ) - : translate( - 'auto.components.settings.GeneralUpdateSettingsSection.f40d88390d', - 'You’re on the latest version.' - ))} + 'auto.components.settings.GeneralUpdateSettingsSection.f40d88390d', + 'You’re on the latest version.' + ))} {updateStatus.state === 'downloading' && translate( 'auto.components.settings.GeneralUpdateSettingsSection.2a48034c4c', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 278e79a8768..624358ecc24 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -23932,5 +23932,25 @@ }, "checks": { "rerunUnsuccessful": "Rerun unsuccessful" + }, + "macosUpdateRequirement": { + "title": "Orca updates now need macOS {{macos}}", + "description": "Orca {{version}} requires macOS {{macos}} or later. This version keeps working, but it won’t get new updates on this Mac.", + "checking": "Checking whether this Mac can upgrade…", + "upgradeDescription": "Your Mac can upgrade. Update macOS to get the latest Orca.", + "unsupportedDescription": "Apple doesn’t support macOS {{macos}} on this Mac.", + "unknownDescription": "We couldn’t confirm an upgrade for this Mac. Apple’s compatibility guide can help.", + "whyTitle": "Why is this changing?", + "whyIntro": "We updated Electron, the foundation Orca runs on, to bring:", + "whySecurity": "Ongoing security fixes from Electron and Chromium.", + "whyPerformance": "Faster startup and more efficient native API calls.", + "whySystemApis": "Support for current macOS notification APIs and secure Keychain storage.", + "whyRequirement": "That newer foundation requires macOS {{macos}} or later.", + "dismiss": "Got it", + "checkingAction": "Checking your Mac…", + "updateAction": "How to update macOS", + "discussionAction": "Join the discussion", + "checkAction": "Check compatibility", + "settingsStatus": "Orca {{version}} requires macOS {{macos}} or later. This is the latest version for this Mac." } } diff --git a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts index 517d730d9d4..f4a49fda36c 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-contract-preferences.ts @@ -209,6 +209,8 @@ export type UISlicePersistence = { setUpdateCardCollapsed: (collapsed: boolean) => void updateReassuranceSeen: boolean markUpdateReassuranceSeen: () => void + seenMacosUpdateRequirement: string | null + markMacosUpdateRequirementSeen: (minimumMacosVersion: string) => void /** True on the launch where the OSC 52 default-on migration overrode a persisted `false`. */ osc52ClipboardDefaultOnNoticePending: boolean clearOsc52ClipboardDefaultOnNotice: () => void diff --git a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts index 749ae692cd1..415480cb657 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-hydration-actions.ts @@ -217,6 +217,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par ? ui.releaseChannelOverride : null, updateReassuranceSeen: ui.updateReassuranceSeen ?? false, + seenMacosUpdateRequirement: ui.seenMacosUpdateRequirement ?? null, osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, browserDefaultUrl: ui.browserDefaultUrl ?? null, browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, diff --git a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts index a924985f8a8..6a081d7608f 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-update-actions.ts @@ -101,6 +101,13 @@ export function createUiUpdateActions(set: UISliceSet, get: UISliceGet): Partial void window.api.ui.set({ updateReassuranceSeen: true }).catch(console.error) set({ updateReassuranceSeen: true }) }, + seenMacosUpdateRequirement: null, + markMacosUpdateRequirementSeen: (minimumMacosVersion) => { + void window.api.ui + .set({ seenMacosUpdateRequirement: minimumMacosVersion }) + .catch(console.error) + set({ seenMacosUpdateRequirement: minimumMacosVersion }) + }, osc52ClipboardDefaultOnNoticePending: false, clearOsc52ClipboardDefaultOnNotice: () => { // Clear locally before persisting to prevent repeat toasts on failure; next launch may re-arm the notice. diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index ac7cd659c49..61d3daee347 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -12,6 +12,7 @@ export function createWebAppApi(): Partial { isProfileStateSaveDelayed: () => Promise.resolve(false), onProfileStateSaveDelayChanged: () => () => undefined, getWindowsAdministratorStatus: () => Promise.resolve(null), + getMacosUpgradeSupport: () => Promise.resolve('unknown'), restartAsAdministrator: () => Promise.reject(new Error('Administrator restart requires the Windows desktop app.')), getIdentity: () => diff --git a/src/shared/macos-upgrade-support.ts b/src/shared/macos-upgrade-support.ts new file mode 100644 index 00000000000..166778b0135 --- /dev/null +++ b/src/shared/macos-upgrade-support.ts @@ -0,0 +1,2 @@ +/** Whether Apple supports the macOS an update needs on this Mac. */ +export type MacosUpgradeSupport = 'supported' | 'unsupported' | 'unknown' diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index 729c759a8f9..ca153069657 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -156,6 +156,8 @@ export type PersistedUIState = { notificationPermissionRequested?: boolean /** Once the "your sessions won't be interrupted" reassurance card is seen, never show it again. */ updateReassuranceSeen?: boolean + /** macOS major whose update-requirement notice was seen; a later requirement shows it again. */ + seenMacosUpdateRequirement?: string | null /** Renderer ui:set acknowledgments by pane/turn timestamp; persist so seen rows stay read after restart. */ acknowledgedAgentsByPaneKey?: Record /** Renderer ui:set cutoffs hide events at/before the timestamp; persist so cleared rows stay hidden after restart. */ diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index 4ec7bb6e335..76404b878c9 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -202,6 +202,7 @@ export const UiUpdateFields = z releaseChannelOverride: z.custom(isReleaseChannel).nullable().optional(), notificationPermissionRequested: z.boolean().optional(), updateReassuranceSeen: z.boolean().optional(), + seenMacosUpdateRequirement: NullableString.optional(), osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), activityClearedAtByPaneKey: z.record(z.string(), z.number().finite()).optional(), diff --git a/src/shared/update-status-types.ts b/src/shared/update-status-types.ts index 9f206e3b7a1..d4230feee4a 100644 --- a/src/shared/update-status-types.ts +++ b/src/shared/update-status-types.ts @@ -62,6 +62,13 @@ export type LinuxPackageInstallInstructions = | { ok: true; command: string; packageFileName: string } | { ok: false; reason: LinuxPackageCommandUnavailableReason; message: string } +/** A newer release this Mac's macOS is too old to run; the updater keeps the current build. */ +export type UnsupportedSystemUpdate = { + version: string + /** Major macOS release the update needs, e.g. "13". */ + minimumMacosVersion: string +} + export type UpdateInstallBlocker = { pid: number kind: 'window' | 'server' | 'browser' | 'unknown' @@ -92,6 +99,8 @@ export type UpdateStatus = ( userInitiated?: boolean /** Optional so older paired clients keep rendering "up to date". */ delayedUpdate?: DelayedUpdate + /** Optional so older paired clients keep rendering "up to date". */ + unsupportedSystemUpdate?: UnsupportedSystemUpdate } | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } | { diff --git a/src/shared/wsl-move/wsl-move-ui-dispositions.ts b/src/shared/wsl-move/wsl-move-ui-dispositions.ts index 7f79c467d8b..7ad127fbcbb 100644 --- a/src/shared/wsl-move/wsl-move-ui-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-ui-dispositions.ts @@ -128,6 +128,7 @@ export const UI_STATE_GLOBAL_FIELDS = [ 'dismissedUpdateNudgeId', 'notificationPermissionRequested', 'updateReassuranceSeen', + 'seenMacosUpdateRequirement', 'setupGuideSidebarDismissed', 'setupGuideBrowserMilestoneMigrated', 'setupGuideBrowserMilestoneLegacyComplete',