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',