From 10ca89ac8b474dec1e0e5966f53c2828abd0750f Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:36:39 -0700
Subject: [PATCH] feat(updater): switch to validated local mac builds (#10889)
* feat(updater): switch to validated local mac builds
* test(updater): cover local build recovery actions
* fix(types): keep local build contract in project sources
---
config/electron-builder.config.cjs | 25 +-
config/scripts/build-mac-local.mjs | 46 +++
config/scripts/build-mac-local.test.mjs | 15 +
.../scripts/electron-builder-config.test.mjs | 58 ++++
config/scripts/mac-build-compatibility.cjs | 34 +++
.../scripts/mac-build-compatibility.test.mjs | 36 +++
package.json | 2 +-
.../local-build-candidate.test.ts | 172 +++++++++++
.../local-builds/local-build-candidate.ts | 279 ++++++++++++++++++
...local-build-compatibility-contract.test.ts | 21 ++
.../local-builds/local-build-compatibility.ts | 71 +++++
.../local-build-feed-server.test.ts | 37 +++
.../local-builds/local-build-feed-server.ts | 92 ++++++
src/main/local-builds/local-build-switch.ts | 59 ++++
src/main/menu/register-app-menu.test.ts | 12 +
src/main/menu/register-app-menu.ts | 11 +-
src/main/updater-events.ts | 67 +++--
src/main/updater-fallback.ts | 3 +
src/main/updater.test.ts | 204 +++++++++++++
src/main/updater.ts | 151 +++++++++-
.../components/UpdateCard.error-card.test.tsx | 38 ++-
src/renderer/src/components/UpdateCard.tsx | 218 ++++++++------
.../settings/GeneralUpdateSettingsSection.tsx | 60 ++--
.../sidebar/SidebarSettingsHelpMenu.tsx | 8 +-
src/renderer/src/i18n/locales/en.json | 7 +-
src/renderer/src/i18n/locales/es.json | 7 +-
src/renderer/src/i18n/locales/ja.json | 7 +-
src/renderer/src/i18n/locales/ko.json | 7 +-
src/renderer/src/i18n/locales/zh.json | 7 +-
.../lib/update-check-click-options.test.ts | 19 +-
.../src/lib/update-check-click-options.ts | 8 +-
.../local-build-compatibility-contract.json | 11 +
.../local-build-compatibility-contract.ts | 11 +
src/shared/local-build-compatibility.test.ts | 45 +++
src/shared/local-build-compatibility.ts | 90 ++++++
src/shared/types.ts | 6 +-
36 files changed, 1769 insertions(+), 175 deletions(-)
create mode 100644 config/scripts/build-mac-local.mjs
create mode 100644 config/scripts/build-mac-local.test.mjs
create mode 100644 config/scripts/mac-build-compatibility.cjs
create mode 100644 config/scripts/mac-build-compatibility.test.mjs
create mode 100644 src/main/local-builds/local-build-candidate.test.ts
create mode 100644 src/main/local-builds/local-build-candidate.ts
create mode 100644 src/main/local-builds/local-build-compatibility-contract.test.ts
create mode 100644 src/main/local-builds/local-build-compatibility.ts
create mode 100644 src/main/local-builds/local-build-feed-server.test.ts
create mode 100644 src/main/local-builds/local-build-feed-server.ts
create mode 100644 src/main/local-builds/local-build-switch.ts
create mode 100644 src/shared/local-build-compatibility-contract.json
create mode 100644 src/shared/local-build-compatibility-contract.ts
create mode 100644 src/shared/local-build-compatibility.test.ts
create mode 100644 src/shared/local-build-compatibility.ts
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index e9a3bb6b9..e731cb7f1 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -12,10 +12,13 @@ const {
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
+const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
+const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
+const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
@@ -59,8 +62,9 @@ const winSpeechNativeResource = {
/** @type {import('electron-builder').Configuration} */
module.exports = {
- appId: 'com.stablyai.orca',
+ appId,
productName: 'Orca',
+ ...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
directories: {
buildResources: 'resources/build'
},
@@ -171,6 +175,25 @@ module.exports = {
if (!existsSync(resourcesDir)) {
return
}
+ if (context.electronPlatformName === 'darwin') {
+ const architectureByEnum = { 1: 'x64', 3: 'arm64' }
+ const architecture = architectureByEnum[context.arch]
+ if (!architecture) {
+ throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`)
+ }
+ const version = context.packager.appInfo.version
+ let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown'
+ if (commit === 'unknown') {
+ try {
+ commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
+ encoding: 'utf8'
+ }).trim()
+ } catch {
+ // Source archives can still produce a signed build with an explicit version.
+ }
+ }
+ writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
+ }
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
diff --git a/config/scripts/build-mac-local.mjs b/config/scripts/build-mac-local.mjs
new file mode 100644
index 000000000..d0426a946
--- /dev/null
+++ b/config/scripts/build-mac-local.mjs
@@ -0,0 +1,46 @@
+import { execFileSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+export function createLocalBuildVersion(baseVersion, timestamp, commit) {
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(baseVersion)) {
+ throw new Error(`Package version is not valid semver: ${baseVersion}`)
+ }
+ if (!Number.isSafeInteger(timestamp) || timestamp <= 0) {
+ throw new Error('Local build timestamp is invalid.')
+ }
+ const sanitizedCommit = commit.replace(/[^0-9A-Za-z-]/g, '').slice(0, 12)
+ if (!sanitizedCommit) {
+ throw new Error('Git commit identity is empty.')
+ }
+ const suffix = `local.${timestamp}.${sanitizedCommit}`
+ return baseVersion.includes('-') ? `${baseVersion}.${suffix}` : `${baseVersion}-${suffix}`
+}
+
+export function getLocalBuildIdentity() {
+ const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
+ const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
+ encoding: 'utf8'
+ }).trim()
+ return {
+ commit,
+ version: createLocalBuildVersion(packageJson.version, Date.now(), commit)
+ }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
+ const identity = getLocalBuildIdentity()
+ console.log(`[build:mac] local update version ${identity.version}`)
+ execFileSync(
+ process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
+ ['exec', 'electron-builder', '--config', 'config/electron-builder.config.cjs', '--mac'],
+ {
+ env: {
+ ...process.env,
+ ORCA_BUILD_COMMIT: identity.commit,
+ ORCA_LOCAL_BUILD_VERSION: identity.version
+ },
+ stdio: 'inherit'
+ }
+ )
+}
diff --git a/config/scripts/build-mac-local.test.mjs b/config/scripts/build-mac-local.test.mjs
new file mode 100644
index 000000000..21c587432
--- /dev/null
+++ b/config/scripts/build-mac-local.test.mjs
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest'
+import { createLocalBuildVersion } from './build-mac-local.mjs'
+
+describe('createLocalBuildVersion', () => {
+ it('creates unique valid prerelease versions without changing the release base', () => {
+ expect(createLocalBuildVersion('1.4.159-rc.0', 123456, 'abc123')).toBe(
+ '1.4.159-rc.0.local.123456.abc123'
+ )
+ expect(createLocalBuildVersion('1.4.159', 123456, 'abc123')).toBe('1.4.159-local.123456.abc123')
+ })
+
+ it('sanitizes commit identifiers', () => {
+ expect(createLocalBuildVersion('1.0.0', 1, 'abc/def')).toBe('1.0.0-local.1.abcdef')
+ })
+})
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index 4d492d68c..0d4c7dd71 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -19,6 +19,12 @@ const {
} = require('../packaged-runtime-node-modules.cjs')
describe('electron-builder config', () => {
+ it('keeps the packaged app identity aligned with local-build validation', () => {
+ expect(electronBuilderConfig.appId).toBe(
+ require('../../src/shared/local-build-compatibility-contract.json').appId
+ )
+ })
+
it('excludes repo-only source trees from app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining([
@@ -173,6 +179,58 @@ describe('electron-builder config', () => {
}
})
+ it('overrides packaged semver only for local macOS builds', () => {
+ const configPath = require.resolve('../electron-builder.config.cjs')
+ const original = process.env.ORCA_LOCAL_BUILD_VERSION
+ const originalMacRelease = process.env.ORCA_MAC_RELEASE
+ try {
+ delete require.cache[configPath]
+ delete process.env.ORCA_MAC_RELEASE
+ process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc'
+ expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({
+ version: '1.4.159-rc.0.local.123.abc'
+ })
+ } finally {
+ if (originalMacRelease === undefined) {
+ delete process.env.ORCA_MAC_RELEASE
+ } else {
+ process.env.ORCA_MAC_RELEASE = originalMacRelease
+ }
+ if (original === undefined) {
+ delete process.env.ORCA_LOCAL_BUILD_VERSION
+ } else {
+ process.env.ORCA_LOCAL_BUILD_VERSION = original
+ }
+ delete require.cache[configPath]
+ require('../electron-builder.config.cjs')
+ }
+ })
+
+ it('never applies local semver to release packaging', () => {
+ const configPath = require.resolve('../electron-builder.config.cjs')
+ const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION
+ const originalMacRelease = process.env.ORCA_MAC_RELEASE
+ try {
+ delete require.cache[configPath]
+ process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc'
+ process.env.ORCA_MAC_RELEASE = '1'
+ expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined()
+ } finally {
+ if (originalLocalVersion === undefined) {
+ delete process.env.ORCA_LOCAL_BUILD_VERSION
+ } else {
+ process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion
+ }
+ if (originalMacRelease === undefined) {
+ delete process.env.ORCA_MAC_RELEASE
+ } else {
+ process.env.ORCA_MAC_RELEASE = originalMacRelease
+ }
+ delete require.cache[configPath]
+ require('../electron-builder.config.cjs')
+ }
+ })
+
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
diff --git a/config/scripts/mac-build-compatibility.cjs b/config/scripts/mac-build-compatibility.cjs
new file mode 100644
index 000000000..4df85d4fe
--- /dev/null
+++ b/config/scripts/mac-build-compatibility.cjs
@@ -0,0 +1,34 @@
+const { writeFileSync } = require('node:fs')
+const { join } = require('node:path')
+const compatibilityContract = require('../../src/shared/local-build-compatibility-contract.json')
+
+const MAC_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json'
+
+function createMacBuildCompatibility({ version, commit, architecture }) {
+ if (architecture !== 'arm64' && architecture !== 'x64') {
+ throw new Error(`Unsupported macOS build architecture: ${architecture}`)
+ }
+ return {
+ ...compatibilityContract,
+ buildId: `${version}-${commit}-${architecture}`,
+ version,
+ commit,
+ platform: 'darwin',
+ architecture
+ }
+}
+
+function writeMacBuildCompatibility(resourcesDir, identity) {
+ const compatibility = createMacBuildCompatibility(identity)
+ writeFileSync(
+ join(resourcesDir, MAC_BUILD_COMPATIBILITY_FILENAME),
+ `${JSON.stringify(compatibility, null, 2)}\n`,
+ 'utf8'
+ )
+}
+
+module.exports = {
+ MAC_BUILD_COMPATIBILITY_FILENAME,
+ createMacBuildCompatibility,
+ writeMacBuildCompatibility
+}
diff --git a/config/scripts/mac-build-compatibility.test.mjs b/config/scripts/mac-build-compatibility.test.mjs
new file mode 100644
index 000000000..81b642c50
--- /dev/null
+++ b/config/scripts/mac-build-compatibility.test.mjs
@@ -0,0 +1,36 @@
+import { createRequire } from 'node:module'
+import { describe, expect, it } from 'vitest'
+
+const require = createRequire(import.meta.url)
+const { createMacBuildCompatibility } = require('./mac-build-compatibility.cjs')
+
+describe('mac build compatibility metadata', () => {
+ it('binds version, commit, and architecture into the packaged contract', () => {
+ expect(
+ createMacBuildCompatibility({
+ version: '1.2.3-local.1',
+ commit: 'abc123',
+ architecture: 'arm64'
+ })
+ ).toMatchObject({
+ formatVersion: 1,
+ appId: 'com.stablyai.orca',
+ buildId: '1.2.3-local.1-abc123-arm64',
+ version: '1.2.3-local.1',
+ commit: 'abc123',
+ stateSchemaVersion: 1,
+ platform: 'darwin',
+ architecture: 'arm64'
+ })
+ })
+
+ it('rejects unsupported architecture metadata', () => {
+ expect(() =>
+ createMacBuildCompatibility({
+ version: '1.2.3',
+ commit: 'abc123',
+ architecture: 'universal'
+ })
+ ).toThrow('Unsupported macOS build architecture')
+ })
+})
diff --git a/package.json b/package.json
index b68b1a2b8..1df039081 100644
--- a/package.json
+++ b/package.json
@@ -77,7 +77,7 @@
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
"build:icons": "bash resources/icon-source/generate.sh",
- "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac",
+ "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
diff --git a/src/main/local-builds/local-build-candidate.test.ts b/src/main/local-builds/local-build-candidate.test.ts
new file mode 100644
index 000000000..2807164ed
--- /dev/null
+++ b/src/main/local-builds/local-build-candidate.test.ts
@@ -0,0 +1,172 @@
+import { createHash } from 'node:crypto'
+import { execFile } from 'node:child_process'
+import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { promisify } from 'node:util'
+import { afterEach, describe, expect, it } from 'vitest'
+import { stringify } from 'yaml'
+import type { LocalBuildCompatibility } from '../../shared/local-build-compatibility'
+import { loadLocalBuildCandidate } from './local-build-candidate'
+import { startLocalBuildFeed } from './local-build-feed-server'
+
+const tempDirectories: string[] = []
+const execFileAsync = promisify(execFile)
+
+function compatibility(): LocalBuildCompatibility {
+ return {
+ formatVersion: 1,
+ appId: 'com.stablyai.orca',
+ buildId: '1.2.3-local.1-abc-arm64',
+ version: '1.2.3-local.1',
+ commit: 'abc',
+ stateSchemaVersion: 1,
+ readableStateSchemaVersions: [1],
+ daemonProtocolVersion: 28,
+ attachableDaemonProtocolVersions: [28],
+ platform: 'darwin',
+ architecture: 'arm64'
+ }
+}
+
+async function fixture(options: { sha512?: string; url?: string } = {}) {
+ const directory = await mkdtemp(join(tmpdir(), 'orca-local-build-'))
+ tempDirectories.push(directory)
+ const artifactName = 'orca-macos-arm64.zip'
+ const artifactPath = join(directory, artifactName)
+ const content = Buffer.from('signed-zip-placeholder')
+ await writeFile(artifactPath, content)
+ const manifestPath = join(directory, 'latest-mac.yml')
+ await writeFile(
+ manifestPath,
+ stringify({
+ version: compatibility().version,
+ files: [
+ {
+ url: options.url ?? artifactName,
+ sha512: options.sha512 ?? createHash('sha512').update(content).digest('base64'),
+ size: content.length
+ },
+ {
+ url: 'orca-macos-arm64.dmg',
+ sha512: Buffer.alloc(64).toString('base64'),
+ size: 1
+ }
+ ]
+ })
+ )
+ return { artifactPath, directory, manifestPath }
+}
+
+afterEach(async () => {
+ await Promise.all(
+ tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
+ )
+})
+
+describe('loadLocalBuildCandidate', () => {
+ it('returns a sanitized, architecture-specific feed after hash validation', async () => {
+ const { manifestPath } = await fixture()
+ const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64', {
+ readCompatibility: async () => compatibility()
+ })
+
+ expect(candidate.version).toBe('1.2.3-local.1')
+ expect([...candidate.artifacts.keys()]).toEqual(['orca-macos-arm64.zip'])
+ expect(candidate.manifestContent).toContain('orca-macos-arm64.zip')
+ await candidate.close()
+ })
+
+ it('rejects mismatched hashes and traversal paths', async () => {
+ const badHash = await fixture({ sha512: Buffer.alloc(64).toString('base64') })
+ await expect(
+ loadLocalBuildCandidate(badHash.manifestPath, 'arm64', {
+ readCompatibility: async () => compatibility()
+ })
+ ).rejects.toThrow('SHA-512 verification failed')
+
+ const traversal = await fixture({ url: '../orca-macos-arm64.zip' })
+ await expect(
+ loadLocalBuildCandidate(traversal.manifestPath, 'arm64', {
+ readCompatibility: async () => compatibility()
+ })
+ ).rejects.toThrow('invalid file entry')
+ })
+
+ it('rejects symlinked artifacts', async () => {
+ const { artifactPath, directory, manifestPath } = await fixture()
+ const realArtifact = join(directory, 'real.zip')
+ await writeFile(realArtifact, 'signed-zip-placeholder')
+ await rm(artifactPath)
+ await symlink(realArtifact, artifactPath)
+
+ await expect(
+ loadLocalBuildCandidate(manifestPath, 'arm64', {
+ readCompatibility: async () => compatibility()
+ })
+ ).rejects.toThrow('regular files, not links')
+ })
+
+ it('serves the same artifact descriptor that passed validation', async () => {
+ const { artifactPath, directory, manifestPath } = await fixture()
+ const movedArtifactPath = join(directory, 'validated.zip')
+ const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64', {
+ readCompatibility: async () => {
+ await rename(artifactPath, movedArtifactPath)
+ await writeFile(artifactPath, 'replacement')
+ return compatibility()
+ }
+ })
+ const feed = await startLocalBuildFeed(candidate)
+ try {
+ await expect(
+ fetch(`${feed.url}orca-macos-arm64.zip`).then((response) => response.text())
+ ).resolves.toBe('signed-zip-placeholder')
+ } finally {
+ await feed.close()
+ }
+ })
+
+ it.runIf(process.platform === 'darwin')(
+ 'reads signed compatibility metadata through the held artifact descriptor',
+ async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'orca-local-build-zip-'))
+ tempDirectories.push(directory)
+ const zipRoot = join(directory, 'zip-root')
+ const resources = join(zipRoot, 'Orca.app', 'Contents', 'Resources')
+ await mkdir(resources, { recursive: true })
+ await writeFile(join(resources, 'orca-local-build.json'), JSON.stringify(compatibility()))
+ const artifactName = 'orca-macos-arm64.zip'
+ const artifactPath = join(directory, artifactName)
+ await execFileAsync('/usr/bin/zip', ['-qry', artifactPath, 'Orca.app'], { cwd: zipRoot })
+ const artifact = await readFile(artifactPath)
+ const manifestPath = join(directory, 'latest-mac.yml')
+ await writeFile(
+ manifestPath,
+ stringify({
+ version: compatibility().version,
+ files: [
+ {
+ url: artifactName,
+ sha512: createHash('sha512').update(artifact).digest('base64'),
+ size: artifact.length
+ }
+ ]
+ })
+ )
+
+ const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64')
+ expect(candidate.compatibility).toEqual(compatibility())
+ await candidate.close()
+ }
+ )
+
+ it('requires exactly one ZIP for the running architecture', async () => {
+ const { manifestPath } = await fixture()
+ await expect(
+ loadLocalBuildCandidate(manifestPath, 'x64', {
+ readCompatibility: async () => compatibility()
+ })
+ ).rejects.toThrow('exactly one x64 Orca ZIP')
+ })
+})
diff --git a/src/main/local-builds/local-build-candidate.ts b/src/main/local-builds/local-build-candidate.ts
new file mode 100644
index 000000000..772a17bb7
--- /dev/null
+++ b/src/main/local-builds/local-build-candidate.ts
@@ -0,0 +1,279 @@
+import { createHash } from 'node:crypto'
+import { spawn } from 'node:child_process'
+import { constants } from 'node:fs'
+import { lstat, open, realpath, type FileHandle } from 'node:fs/promises'
+import { basename, dirname, join } from 'node:path'
+import { parse, stringify } from 'yaml'
+import {
+ LOCAL_BUILD_COMPATIBILITY_FILENAME,
+ ORCA_APP_ID,
+ parseLocalBuildCompatibility,
+ type LocalBuildCompatibility
+} from '../../shared/local-build-compatibility'
+import { isValidAppVersion } from '../../shared/app-version'
+
+const MAX_MANIFEST_BYTES = 256 * 1024
+const MAX_COMPATIBILITY_BYTES = 64 * 1024
+const MAX_UPDATE_FILES = 8
+const MAX_ZIP_BYTES = 8 * 1024 * 1024 * 1024
+const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._ ()+-]*\.zip$/
+
+type ManifestFile = {
+ url: string
+ sha512: string
+ size?: number
+}
+
+export type LocalBuildCandidate = {
+ version: string
+ compatibility: LocalBuildCompatibility
+ manifestContent: string
+ artifacts: Map
+ close: () => Promise
+}
+
+type LocalBuildCandidateLoaderOptions = {
+ readCompatibility?: (zipFile: FileHandle) => Promise
+}
+
+function parseManifestFile(value: unknown): ManifestFile {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ throw new Error('The local update manifest contains an invalid file entry.')
+ }
+ const record = value as Record
+ if (
+ typeof record.url !== 'string' ||
+ !SAFE_ARTIFACT_NAME.test(record.url) ||
+ basename(record.url) !== record.url ||
+ typeof record.sha512 !== 'string' ||
+ !/^[A-Za-z0-9+/]{86}==$/.test(record.sha512) ||
+ (record.size !== undefined && (!Number.isSafeInteger(record.size) || Number(record.size) <= 0))
+ ) {
+ throw new Error('The local update manifest contains an invalid file entry.')
+ }
+ return {
+ url: record.url,
+ sha512: record.sha512,
+ ...(record.size === undefined ? {} : { size: Number(record.size) })
+ }
+}
+
+function isZipManifestFile(value: unknown): boolean {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return false
+ }
+ const url = (value as Record).url
+ return typeof url === 'string' && url.toLowerCase().endsWith('.zip')
+}
+
+function parseManifest(value: unknown): { version: string; files: ManifestFile[] } {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ throw new Error('The selected file is not a valid macOS update manifest.')
+ }
+ const record = value as Record
+ if (
+ typeof record.version !== 'string' ||
+ record.version.length === 0 ||
+ record.version.length > 100 ||
+ !isValidAppVersion(record.version) ||
+ !Array.isArray(record.files) ||
+ record.files.length === 0 ||
+ record.files.length > MAX_UPDATE_FILES
+ ) {
+ throw new Error('The selected file is not a valid macOS update manifest.')
+ }
+ const zipFiles = record.files.filter(isZipManifestFile)
+ if (zipFiles.length === 0) {
+ throw new Error('The selected macOS update manifest does not contain a ZIP.')
+ }
+ return { version: record.version, files: zipFiles.map(parseManifestFile) }
+}
+
+async function hashFile(file: FileHandle): Promise {
+ const hash = createHash('sha512')
+ await new Promise((resolve, reject) => {
+ const stream = file.createReadStream({ autoClose: false, start: 0 })
+ stream.on('data', (chunk) => hash.update(chunk))
+ stream.on('error', reject)
+ stream.on('end', resolve)
+ })
+ return hash.digest('base64')
+}
+
+async function assertContainedRegularFile(rootPath: string, filePath: string): Promise {
+ const fileInfo = await lstat(filePath)
+ if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) {
+ throw new Error('Local update artifacts must be regular files, not links.')
+ }
+ const [resolvedRoot, resolvedFile] = await Promise.all([realpath(rootPath), realpath(filePath)])
+ if (dirname(resolvedFile) !== resolvedRoot) {
+ throw new Error('Local update artifacts must stay beside latest-mac.yml.')
+ }
+}
+
+function extractCompatibility(zipFile: FileHandle): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(
+ '/usr/bin/unzip',
+ ['-p', '/dev/fd/3', `Orca.app/Contents/Resources/${LOCAL_BUILD_COMPATIBILITY_FILENAME}`],
+ { stdio: ['ignore', 'pipe', 'ignore', zipFile.fd] }
+ )
+ const chunks: Buffer[] = []
+ let outputBytes = 0
+ let outputError: Error | null = null
+ const stdout = child.stdout
+ if (!stdout) {
+ child.kill()
+ reject(new Error('Could not read compatibility metadata.'))
+ return
+ }
+ stdout.on('data', (chunk: Buffer) => {
+ outputBytes += chunk.length
+ if (outputBytes > MAX_COMPATIBILITY_BYTES) {
+ outputError = new Error('Compatibility metadata is too large.')
+ child.kill()
+ return
+ }
+ chunks.push(chunk)
+ })
+ child.on('error', reject)
+ child.on('close', (code) => {
+ if (outputError) {
+ reject(outputError)
+ } else if (code !== 0) {
+ reject(new Error(`unzip exited with status ${code ?? 'unknown'}.`))
+ } else {
+ resolve(Buffer.concat(chunks).toString('utf8'))
+ }
+ })
+ })
+}
+
+async function readCompatibility(zipFile: FileHandle): Promise {
+ let stdout: string
+ try {
+ stdout = await extractCompatibility(zipFile)
+ } catch (error) {
+ console.warn('[local-build] Could not read compatibility metadata:', error)
+ throw new Error(
+ 'This build predates local switching or is missing its signed compatibility metadata.'
+ )
+ }
+ try {
+ return parseLocalBuildCompatibility(JSON.parse(stdout))
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ throw new Error('The selected build has malformed compatibility metadata.')
+ }
+ throw error
+ }
+}
+
+async function validateArtifact(
+ rootPath: string,
+ manifestFile: ManifestFile,
+ manifestVersion: string,
+ compatibilityReader: (zipFile: FileHandle) => Promise
+): Promise<{ compatibility: LocalBuildCompatibility; file: FileHandle; size: number }> {
+ const filePath = join(rootPath, manifestFile.url)
+ await assertContainedRegularFile(rootPath, filePath)
+ const file = await open(
+ filePath,
+ constants.O_RDONLY | (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0)
+ )
+ try {
+ const fileStats = await file.stat()
+ if (!fileStats.isFile() || fileStats.size <= 0 || fileStats.size > MAX_ZIP_BYTES) {
+ throw new Error('The selected local build ZIP has an invalid size.')
+ }
+ if (manifestFile.size !== undefined && fileStats.size !== manifestFile.size) {
+ throw new Error(`Size verification failed for ${manifestFile.url}.`)
+ }
+ if ((await hashFile(file)) !== manifestFile.sha512) {
+ throw new Error(`SHA-512 verification failed for ${manifestFile.url}.`)
+ }
+ const compatibility = await compatibilityReader(file)
+ if (compatibility.appId !== ORCA_APP_ID || compatibility.version !== manifestVersion) {
+ throw new Error('The selected ZIP does not match its update manifest.')
+ }
+ return { compatibility, file, size: fileStats.size }
+ } catch (error) {
+ await file.close()
+ throw error
+ }
+}
+
+export async function loadLocalBuildCandidate(
+ manifestPath: string,
+ architecture: NodeJS.Architecture,
+ options: LocalBuildCandidateLoaderOptions = {}
+): Promise {
+ if (basename(manifestPath) !== 'latest-mac.yml') {
+ throw new Error('Select the latest-mac.yml generated by pn build:mac.')
+ }
+ await assertContainedRegularFile(dirname(manifestPath), manifestPath)
+ const manifestFile = await open(
+ manifestPath,
+ constants.O_RDONLY | (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0)
+ )
+ let manifestText: string
+ try {
+ const manifestStats = await manifestFile.stat()
+ if (
+ !manifestStats.isFile() ||
+ manifestStats.size <= 0 ||
+ manifestStats.size > MAX_MANIFEST_BYTES
+ ) {
+ throw new Error('The selected update manifest is too large.')
+ }
+ manifestText = await manifestFile.readFile('utf8')
+ } finally {
+ await manifestFile.close()
+ }
+ const manifest = parseManifest(parse(manifestText, { maxAliasCount: 0 }))
+ const rootPath = dirname(manifestPath)
+ const compatibilityReader = options.readCompatibility ?? readCompatibility
+ const validationResults = await Promise.allSettled(
+ manifest.files.map((file) =>
+ validateArtifact(rootPath, file, manifest.version, compatibilityReader)
+ )
+ )
+ const validated = validationResults
+ .filter((result) => result.status === 'fulfilled')
+ .map((result) => result.value)
+ const failed = validationResults.find((result) => result.status === 'rejected')
+ if (failed?.status === 'rejected') {
+ await Promise.all(validated.map((entry) => entry.file.close()))
+ throw failed.reason
+ }
+ const matching = validated
+ .map((entry, index) => ({ ...entry, manifestFile: manifest.files[index] }))
+ .filter((entry) => entry.compatibility.architecture === architecture)
+ if (matching.length !== 1) {
+ await Promise.all(validated.map((entry) => entry.file.close()))
+ throw new Error(`The manifest must contain exactly one ${architecture} Orca ZIP.`)
+ }
+ const target = matching[0]
+ await Promise.all(
+ validated.filter((entry) => entry.file !== target.file).map((entry) => entry.file.close())
+ )
+ const sanitizedFile = {
+ url: target.manifestFile.url,
+ sha512: target.manifestFile.sha512,
+ ...(target.manifestFile.size === undefined ? {} : { size: target.manifestFile.size })
+ }
+ return {
+ version: manifest.version,
+ compatibility: target.compatibility,
+ manifestContent: stringify({
+ version: manifest.version,
+ files: [sanitizedFile],
+ path: sanitizedFile.url,
+ sha512: sanitizedFile.sha512
+ }),
+ artifacts: new Map([[target.manifestFile.url, { file: target.file, size: target.size }]]),
+ close: async () => {
+ await target.file.close()
+ }
+ }
+}
diff --git a/src/main/local-builds/local-build-compatibility-contract.test.ts b/src/main/local-builds/local-build-compatibility-contract.test.ts
new file mode 100644
index 000000000..dc2e2af54
--- /dev/null
+++ b/src/main/local-builds/local-build-compatibility-contract.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { SCHEMA_VERSION } from '../../shared/constants'
+import compatibilityContract from '../../shared/local-build-compatibility-contract.json'
+import { LOCAL_BUILD_COMPATIBILITY_CONTRACT } from '../../shared/local-build-compatibility-contract'
+import {
+ PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
+ PROTOCOL_VERSION
+} from '../daemon/daemon-protocol-version'
+
+describe('packaged local build compatibility contract', () => {
+ it('stays aligned with runtime state and daemon constants', () => {
+ expect(LOCAL_BUILD_COMPATIBILITY_CONTRACT).toEqual(compatibilityContract)
+ expect(compatibilityContract).toMatchObject({
+ appId: 'com.stablyai.orca',
+ stateSchemaVersion: SCHEMA_VERSION,
+ readableStateSchemaVersions: [SCHEMA_VERSION],
+ daemonProtocolVersion: PROTOCOL_VERSION,
+ attachableDaemonProtocolVersions: [...PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION]
+ })
+ })
+})
diff --git a/src/main/local-builds/local-build-compatibility.ts b/src/main/local-builds/local-build-compatibility.ts
new file mode 100644
index 000000000..015377f9d
--- /dev/null
+++ b/src/main/local-builds/local-build-compatibility.ts
@@ -0,0 +1,71 @@
+import { SCHEMA_VERSION } from '../../shared/constants'
+import {
+ getLocalBuildCompatibilityError,
+ type LocalBuildCompatibility
+} from '../../shared/local-build-compatibility'
+import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter'
+import { DaemonPtyRouter } from '../daemon/daemon-pty-router'
+import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider'
+import { getDaemonProvider } from '../daemon/daemon-init'
+import { getLocalPtyProvider } from '../ipc/pty'
+import { LocalPtyProvider } from '../providers/local-pty-provider'
+
+export type LocalBuildCompatibilityResult = {
+ liveTerminalCount: number
+ liveDaemonProtocols: number[]
+}
+
+async function getLiveDaemonProtocols(): Promise<{
+ count: number
+ protocols: number[]
+}> {
+ const provider = getDaemonProvider()
+ if (!provider) {
+ const localProvider = getLocalPtyProvider()
+ if (!(localProvider instanceof LocalPtyProvider)) {
+ throw new Error('Could not verify terminal preservation. Restart Orca and try again.')
+ }
+ const localProcesses = await localProvider.listProcesses()
+ if (localProcesses.length > 0) {
+ throw new Error(
+ 'Local build switching is blocked while non-persistent fallback terminals are running.'
+ )
+ }
+ throw new Error('The terminal service is still starting. Try again in a moment.')
+ }
+ if (provider instanceof DegradedDaemonPtyProvider) {
+ throw new Error(
+ 'Local build switching is blocked while the terminal service is in fallback mode. Restart Orca first.'
+ )
+ }
+ const adapters =
+ provider instanceof DaemonPtyRouter ? provider.getAllAdapters() : [provider as DaemonPtyAdapter]
+ const sessions = await Promise.all(
+ adapters.map(async (adapter) => ({
+ protocol: adapter.protocolVersion,
+ count: (await adapter.listSessions()).length
+ }))
+ )
+ return {
+ count: sessions.reduce((sum, entry) => sum + entry.count, 0),
+ protocols: sessions.filter((entry) => entry.count > 0).map((entry) => entry.protocol)
+ }
+}
+
+export async function assertLocalBuildCompatibility(
+ target: LocalBuildCompatibility
+): Promise {
+ const stateCompatibilityError = getLocalBuildCompatibilityError(target, SCHEMA_VERSION, [])
+ if (stateCompatibilityError) {
+ throw new Error(stateCompatibilityError)
+ }
+ const live = await getLiveDaemonProtocols()
+ const compatibilityError = getLocalBuildCompatibilityError(target, SCHEMA_VERSION, live.protocols)
+ if (compatibilityError) {
+ throw new Error(compatibilityError)
+ }
+ return {
+ liveTerminalCount: live.count,
+ liveDaemonProtocols: [...new Set(live.protocols)].sort((left, right) => left - right)
+ }
+}
diff --git a/src/main/local-builds/local-build-feed-server.test.ts b/src/main/local-builds/local-build-feed-server.test.ts
new file mode 100644
index 000000000..6c020c10f
--- /dev/null
+++ b/src/main/local-builds/local-build-feed-server.test.ts
@@ -0,0 +1,37 @@
+import { mkdtemp, open, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { LocalBuildCandidate } from './local-build-candidate'
+import { startLocalBuildFeed } from './local-build-feed-server'
+
+describe('startLocalBuildFeed', () => {
+ it('serves only tokenized manifest and validated artifact routes', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'orca-local-feed-'))
+ const artifactPath = join(directory, 'orca-macos-arm64.zip')
+ await writeFile(artifactPath, 'zip')
+ const artifactFile = await open(artifactPath, 'r')
+ const candidate = {
+ version: '1.2.3-local.1',
+ manifestContent: 'version: 1.2.3-local.1\n',
+ artifacts: new Map([['orca-macos-arm64.zip', { file: artifactFile, size: 3 }]]),
+ close: () => artifactFile.close()
+ } as LocalBuildCandidate
+ const feed = await startLocalBuildFeed(candidate)
+ try {
+ await expect(
+ fetch(`${feed.url}latest-mac.yml`).then((response) => response.text())
+ ).resolves.toContain('1.2.3-local.1')
+ await expect(
+ fetch(`${feed.url}orca-macos-arm64.zip`).then((response) => response.text())
+ ).resolves.toBe('zip')
+ const baseUrl = new URL(feed.url)
+ await expect(
+ fetch(`${baseUrl.origin}/latest-mac.yml`).then((response) => response.status)
+ ).resolves.toBe(404)
+ } finally {
+ await feed.close()
+ await rm(directory, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/src/main/local-builds/local-build-feed-server.ts b/src/main/local-builds/local-build-feed-server.ts
new file mode 100644
index 000000000..443cf17bf
--- /dev/null
+++ b/src/main/local-builds/local-build-feed-server.ts
@@ -0,0 +1,92 @@
+import { randomBytes } from 'node:crypto'
+import { createServer, type Server } from 'node:http'
+import type { LocalBuildCandidate } from './local-build-candidate'
+
+export type LocalBuildFeed = {
+ url: string
+ close: () => Promise
+}
+
+function closeServer(server: Server): Promise {
+ return new Promise((resolve) => {
+ server.close(() => resolve())
+ })
+}
+
+export async function startLocalBuildFeed(candidate: LocalBuildCandidate): Promise {
+ const token = randomBytes(24).toString('hex')
+ const prefix = `/${token}/`
+ const server = createServer((request, response) => {
+ if (request.method !== 'GET' || !request.url) {
+ response.writeHead(404).end()
+ return
+ }
+ let pathname: string
+ try {
+ pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname)
+ } catch {
+ response.writeHead(400).end()
+ return
+ }
+ if (!pathname.startsWith(prefix)) {
+ response.writeHead(404).end()
+ return
+ }
+ const filename = pathname.slice(prefix.length)
+ if (filename === 'latest-mac.yml') {
+ response.writeHead(200, {
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'application/yaml; charset=utf-8'
+ })
+ response.end(candidate.manifestContent)
+ return
+ }
+ const artifact = candidate.artifacts.get(filename)
+ if (!artifact) {
+ response.writeHead(404).end()
+ return
+ }
+ response.writeHead(200, {
+ 'Cache-Control': 'no-store',
+ 'Content-Type': 'application/zip'
+ })
+ const stream = artifact.file.createReadStream({
+ autoClose: false,
+ start: 0,
+ end: artifact.size - 1
+ })
+ stream.on('error', () => response.destroy())
+ response.on('error', () => stream.destroy())
+ response.on('close', () => stream.destroy())
+ stream.pipe(response)
+ })
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => {
+ server.off('error', reject)
+ server.on('error', (error) => console.warn('[updater] Local build feed error:', error))
+ resolve()
+ })
+ }).catch(async (error) => {
+ await candidate.close()
+ throw error
+ })
+ const address = server.address()
+ if (!address || typeof address === 'string') {
+ await closeServer(server)
+ await candidate.close()
+ throw new Error('Could not start the local update feed.')
+ }
+ let closed = false
+ return {
+ url: `http://127.0.0.1:${address.port}${prefix}`,
+ close: async () => {
+ if (closed) {
+ return
+ }
+ closed = true
+ await closeServer(server)
+ await candidate.close()
+ }
+ }
+}
diff --git a/src/main/local-builds/local-build-switch.ts b/src/main/local-builds/local-build-switch.ts
new file mode 100644
index 000000000..2f1982c72
--- /dev/null
+++ b/src/main/local-builds/local-build-switch.ts
@@ -0,0 +1,59 @@
+import { app, dialog, type BrowserWindow } from 'electron'
+import { SCHEMA_VERSION } from '../../shared/constants'
+import { compareAppVersions } from '../../shared/app-version'
+import { assertLocalBuildCompatibility } from './local-build-compatibility'
+import { loadLocalBuildCandidate, type LocalBuildCandidate } from './local-build-candidate'
+
+export async function chooseLocalBuild(
+ window: BrowserWindow | null
+): Promise {
+ const openDialogOptions: Electron.OpenDialogOptions = {
+ title: 'Choose a Local Orca Build',
+ buttonLabel: 'Choose Build',
+ properties: ['openFile'],
+ filters: [{ name: 'Orca update manifest', extensions: ['yml'] }]
+ }
+ const selection = await (window
+ ? dialog.showOpenDialog(window, openDialogOptions)
+ : dialog.showOpenDialog(openDialogOptions))
+ const manifestPath = selection.filePaths[0]
+ if (selection.canceled || !manifestPath) {
+ return null
+ }
+ const candidate = await loadLocalBuildCandidate(manifestPath, process.arch)
+ try {
+ if (compareAppVersions(candidate.version, app.getVersion()) === 0) {
+ throw new Error(
+ 'This build has the same version as the running app. Run pn build:mac again to create a uniquely versioned build.'
+ )
+ }
+ const compatibility = await assertLocalBuildCompatibility(candidate.compatibility)
+ const terminalSummary =
+ compatibility.liveTerminalCount === 0
+ ? 'No live terminals need to reconnect.'
+ : `${compatibility.liveTerminalCount} live terminal${
+ compatibility.liveTerminalCount === 1 ? '' : 's'
+ } will reconnect after restart.`
+ const messageBoxOptions: Electron.MessageBoxOptions = {
+ type: 'question',
+ title: 'Use Local Orca Build?',
+ message: `${app.getVersion()} → ${candidate.version}`,
+ detail: `${terminalSummary}\nWorkspace cards and settings are compatible with state schema ${SCHEMA_VERSION}.\n\nThe build must have the same valid code signature as Orca or installation will stop.`,
+ buttons: ['Use Local Build', 'Cancel'],
+ defaultId: 0,
+ cancelId: 1,
+ noLink: true
+ }
+ const confirmation = await (window
+ ? dialog.showMessageBox(window, messageBoxOptions)
+ : dialog.showMessageBox(messageBoxOptions))
+ if (confirmation.response === 0) {
+ return candidate
+ }
+ } catch (error) {
+ await candidate.close()
+ throw error
+ }
+ await candidate.close()
+ return null
+}
diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts
index fa636e862..8433fd93e 100644
--- a/src/main/menu/register-app-menu.test.ts
+++ b/src/main/menu/register-app-menu.test.ts
@@ -171,6 +171,11 @@ describe('registerAppMenu', () => {
undefined as never,
(isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent
)
+ item?.click?.(
+ {} as never,
+ undefined as never,
+ { altKey: true, shiftKey: true } as Electron.KeyboardEvent
+ )
item?.click?.(
{} as never,
undefined as never,
@@ -187,6 +192,13 @@ describe('registerAppMenu', () => {
[{ includePrerelease: true, includePerfPrerelease: true }],
[{ includePrerelease: false, includePerfPrerelease: true }],
[{ includePrerelease: false, includePerfPrerelease: false }],
+ [
+ {
+ includePrerelease: !isMac,
+ includePerfPrerelease: false,
+ ...(isMac ? { localBuild: true } : {})
+ }
+ ],
[{ includePrerelease: false, includePerfPrerelease: false }]
])
})
diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts
index 2f3a3d70a..d1e43d4fb 100644
--- a/src/main/menu/register-app-menu.ts
+++ b/src/main/menu/register-app-menu.ts
@@ -96,10 +96,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
event
) => {
const modifierClick = !event.triggeredByAccelerator
+ const localBuild = isMac && modifierClick && event.altKey === true
const includePerfPrerelease =
- modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true)
- const includePrerelease = modifierClick && event.shiftKey === true
- onCheckForUpdates({ includePrerelease, includePerfPrerelease })
+ !localBuild && modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true)
+ const includePrerelease = !localBuild && modifierClick && event.shiftKey === true
+ onCheckForUpdates({
+ includePrerelease,
+ includePerfPrerelease,
+ ...(localBuild ? { localBuild: true } : {})
+ })
}
const checkForUpdatesItem: Electron.MenuItemConstructorOptions = {
diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts
index 516416185..411bb1a71 100644
--- a/src/main/updater-events.ts
+++ b/src/main/updater-events.ts
@@ -30,7 +30,8 @@ type UpdaterHandlerContext = {
getUserInitiatedCheck: () => boolean
handleQuitAndInstallFailure: () => boolean
isQuitAndInstallHandoffActive: () => boolean
- hasNewerDownloadedVersion: () => boolean
+ hasInstallableDownloadedVersion: () => boolean
+ isLocalBuildCheck: () => boolean
shouldHandleUpdaterErrorEvent: () => boolean
clearUpdateAvailableEventPending: (attemptId: number | null) => void
isActiveUpdateCheckAttempt: (attemptId: number) => boolean
@@ -40,6 +41,7 @@ type UpdaterHandlerContext = {
performQuitAndInstall: () => void | Promise
shouldDeferMacQuitForInstall: () => boolean
recordCompletedUpdateCheck: () => void
+ restoreReleaseUpdateSource: () => void
sendCheckFailureStatus: (
message: string,
userInitiated?: boolean,
@@ -70,7 +72,8 @@ export function registerAutoUpdaterHandlers({
getUserInitiatedCheck,
handleQuitAndInstallFailure,
isQuitAndInstallHandoffActive,
- hasNewerDownloadedVersion,
+ hasInstallableDownloadedVersion,
+ isLocalBuildCheck,
shouldHandleUpdaterErrorEvent,
clearUpdateAvailableEventPending,
isActiveUpdateCheckAttempt,
@@ -80,6 +83,7 @@ export function registerAutoUpdaterHandlers({
performQuitAndInstall,
shouldDeferMacQuitForInstall,
recordCompletedUpdateCheck,
+ restoreReleaseUpdateSource,
sendCheckFailureStatus,
sendErrorStatus,
sendStatus,
@@ -93,9 +97,9 @@ export function registerAutoUpdaterHandlers({
// Why: electron-updater fires 'update-downloaded' before Squirrel.Mac finishes; track readiness to avoid a premature "ready".
if (process.platform === 'darwin') {
nativeUpdater.on('update-downloaded', () => {
- const hasNewerVersion = hasNewerDownloadedVersion()
- handleMacInstallerReady(hasNewerVersion, performQuitAndInstall, () => {
- // Send the held 'downloaded' status now, only if the staged version is newer.
+ const hasInstallableVersion = hasInstallableDownloadedVersion()
+ handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => {
+ // Send the held status only while its staged build is still installable.
sendStatus({
state: 'downloaded',
version: getPendingInstallVersion(),
@@ -121,7 +125,7 @@ export function registerAutoUpdaterHandlers({
if (
deferMacQuitUntilInstallerReady(
getCurrentStatus(),
- hasNewerDownloadedVersion(),
+ hasInstallableDownloadedVersion(),
getPendingInstallVersion,
sendStatus
)
@@ -158,8 +162,8 @@ export function registerAutoUpdaterHandlers({
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
setUserInitiatedCheck(false)
- // Guard: don't show an update that isn't actually newer than what's running.
- if (compareVersions(info.version, app.getVersion()) <= 0) {
+ // Release checks remain newer-only; validated local builds may intentionally downgrade.
+ if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) {
clearAvailableUpdateContext()
if (missingManifestFallback || publishingWindowLastGoodCheck) {
// Why: a current-version fallback manifest means the primary is transiently missing; keep the short retry cadence.
@@ -178,7 +182,9 @@ export function registerAutoUpdaterHandlers({
markUpdateAvailableEventPending(attemptId)
void (async () => {
try {
- const changelog = await fetchChangelog(info.version, app.getVersion()).catch(() => null)
+ const changelog = isLocalBuildCheck()
+ ? null
+ : await fetchChangelog(info.version, app.getVersion()).catch(() => null)
// Why: async fetch may take seconds; bail if a newer event superseded this attempt to avoid a stale 'available' broadcast.
if (!isActiveUpdateCheckAttempt(attemptId)) {
@@ -191,13 +197,15 @@ export function registerAutoUpdaterHandlers({
// Why: side effects must run after the guard so a concurrent 'error' during the fetch can't leave orphaned state.
setAvailableVersion(info.version)
setAvailableReleaseUrl(null)
- if (missingManifestFallback || publishingWindowLastGoodCheck) {
- // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes.
- scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
- } else {
- recordCompletedUpdateCheck()
- if (!wasUserInitiated) {
- scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
+ if (!isLocalBuildCheck()) {
+ if (missingManifestFallback || publishingWindowLastGoodCheck) {
+ // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes.
+ scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
+ } else {
+ recordCompletedUpdateCheck()
+ if (!wasUserInitiated) {
+ scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
+ }
}
}
@@ -217,18 +225,24 @@ export function registerAutoUpdaterHandlers({
const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult()
const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck()
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
+ const localBuildCheck = isLocalBuildCheck()
setUserInitiatedCheck(false)
clearAvailableUpdateContext()
- if (missingManifestFallback || publishingWindowLastGoodCheck) {
- // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h.
- scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
- } else {
- recordCompletedUpdateCheck()
- if (!wasUserInitiated) {
- scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
+ if (!localBuildCheck) {
+ if (missingManifestFallback || publishingWindowLastGoodCheck) {
+ // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h.
+ scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS)
+ } else {
+ recordCompletedUpdateCheck()
+ if (!wasUserInitiated) {
+ scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS)
+ }
}
}
sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined })
+ if (localBuildCheck) {
+ restoreReleaseUpdateSource()
+ }
})
autoUpdater.on('download-progress', (progress) => {
@@ -242,8 +256,8 @@ export function registerAutoUpdaterHandlers({
autoUpdater.on('update-downloaded', (info) => {
clearBackgroundCheckLaunchPending()
- // Skip the banner for non-newer versions (same-version or stale cached updates).
- if (compareVersions(info.version, app.getVersion()) <= 0) {
+ // Release downloads remain newer-only; the local source was validated before checking.
+ if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) {
clearAvailableUpdateContext()
sendStatus({ state: 'not-available' })
return
@@ -288,5 +302,8 @@ export function registerAutoUpdaterHandlers({
return
}
sendErrorStatus(message, wasUserInitiated || undefined)
+ if (isLocalBuildCheck()) {
+ restoreReleaseUpdateSource()
+ }
})
}
diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts
index 411ddbd0a..d91cdf75e 100644
--- a/src/main/updater-fallback.ts
+++ b/src/main/updater-fallback.ts
@@ -10,6 +10,9 @@ export const isPrereleaseVersion = isPrereleaseAppVersion
export const isValidVersion = isValidAppVersion
export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean {
+ if (left.source !== right.source) {
+ return false
+ }
switch (left.state) {
case 'idle':
return right.state === 'idle'
diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts
index b09c727ee..730dd5be9 100644
--- a/src/main/updater.test.ts
+++ b/src/main/updater.test.ts
@@ -50,6 +50,8 @@ const {
autoUpdaterMock.setFeedURL.mockClear()
autoUpdaterMock.updateConfigPath = undefined
autoUpdaterMock.allowPrerelease = false
+ autoUpdaterMock.allowDowngrade = false
+ autoUpdaterMock.disableDifferentialDownload = false
autoUpdaterMock.autoRunAppAfterInstall = true
delete (autoUpdaterMock as Record).verifyUpdateCodeSignature
}
@@ -59,6 +61,8 @@ const {
autoInstallOnAppQuit: false,
autoRunAppAfterInstall: true,
allowPrerelease: false,
+ allowDowngrade: false,
+ disableDifferentialDownload: false,
on,
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
@@ -146,6 +150,14 @@ const { fetchNewerReleaseTagsMock } = vi.hoisted(() => ({
fetchNewerReleaseTagsMock: vi.fn()
}))
+const { chooseLocalBuildMock, startLocalBuildFeedMock, closeLocalBuildFeedMock } = vi.hoisted(
+ () => ({
+ chooseLocalBuildMock: vi.fn(),
+ startLocalBuildFeedMock: vi.fn(),
+ closeLocalBuildFeedMock: vi.fn()
+ })
+)
+
vi.mock('./updater-prerelease-feed', () => ({
fetchNewerReleaseTagsWithReadiness: async (...args: unknown[]) => {
const result = await fetchNewerReleaseTagsMock(...args)
@@ -157,6 +169,14 @@ vi.mock('./updater-prerelease-feed', () => ({
`https://github.com/stablyai/orca/releases/download/${tag}`
}))
+vi.mock('./local-builds/local-build-switch', () => ({
+ chooseLocalBuild: chooseLocalBuildMock
+}))
+
+vi.mock('./local-builds/local-build-feed-server', () => ({
+ startLocalBuildFeed: startLocalBuildFeedMock
+}))
+
describe('updater', () => {
beforeEach(() => {
vi.resetModules()
@@ -177,6 +197,12 @@ describe('updater', () => {
shouldApplyNudgeMock.mockReset().mockReturnValue(false)
fetchChangelogMock.mockReset().mockResolvedValue(null)
fetchNewerReleaseTagsMock.mockReset().mockResolvedValue([])
+ chooseLocalBuildMock.mockReset()
+ closeLocalBuildFeedMock.mockReset()
+ startLocalBuildFeedMock.mockReset().mockResolvedValue({
+ url: 'http://127.0.0.1:1234/token/',
+ close: closeLocalBuildFeedMock
+ })
vi.unstubAllGlobals()
vi.useRealTimers()
})
@@ -196,6 +222,184 @@ describe('updater', () => {
expect(powerMonitorOnMock).not.toHaveBeenCalled()
})
+ it.runIf(process.platform === 'darwin')(
+ 'allows a validated local build to downgrade through the normal updater lifecycle',
+ async () => {
+ chooseLocalBuildMock.mockResolvedValue({
+ version: '0.9.0-local.1',
+ manifestContent: 'version: 0.9.0-local.1',
+ artifacts: new Map()
+ })
+ autoUpdaterMock.checkForUpdates.mockImplementation(() => {
+ autoUpdaterMock.emit('checking-for-update')
+ autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' })
+ return Promise.resolve(undefined)
+ })
+ const send = vi.fn()
+ const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
+ setupAutoUpdater({ webContents: { send } } as never, {
+ getLastUpdateCheckAt: () => Date.now()
+ })
+
+ checkForUpdatesFromMenu({ localBuild: true })
+
+ await vi.waitFor(() => {
+ expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
+ })
+ expect(autoUpdaterMock.allowDowngrade).toBe(true)
+ expect(autoUpdaterMock.disableDifferentialDownload).toBe(true)
+ expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
+ provider: 'generic',
+ url: 'http://127.0.0.1:1234/token/'
+ })
+ await vi.waitFor(() => {
+ expect(send).toHaveBeenCalledWith(
+ 'updater:status',
+ expect.objectContaining({
+ state: 'available',
+ version: '0.9.0-local.1',
+ source: 'local'
+ })
+ )
+ })
+
+ setupAutoUpdater({ webContents: { send } } as never, {
+ getLastUpdateCheckAt: () => Date.now()
+ })
+ expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
+ provider: 'generic',
+ url: 'http://127.0.0.1:1234/token/'
+ })
+ expect(autoUpdaterMock.allowDowngrade).toBe(true)
+
+ autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
+ checkForUpdatesFromMenu()
+ await vi.waitFor(() => {
+ expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
+ })
+ expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1)
+ expect(autoUpdaterMock.allowDowngrade).toBe(false)
+ expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
+ }
+ )
+
+ it.runIf(process.platform === 'darwin')(
+ 'restores ordinary release checks after local build selection fails',
+ async () => {
+ chooseLocalBuildMock.mockRejectedValue(new Error('invalid local build'))
+ const send = vi.fn()
+ const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } =
+ await import('./updater')
+ setupAutoUpdater({ webContents: { send } } as never, {
+ getLastUpdateCheckAt: () => Date.now()
+ })
+
+ checkForUpdatesFromMenu({ localBuild: true })
+ await vi.waitFor(() => {
+ expect(send).toHaveBeenCalledWith('updater:status', {
+ state: 'error',
+ message: 'invalid local build',
+ userInitiated: true,
+ source: 'local'
+ })
+ })
+
+ checkForUpdates()
+ await vi.waitFor(() => {
+ expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
+ })
+ expect(autoUpdaterMock.allowDowngrade).toBe(false)
+ expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
+ expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
+ provider: 'generic',
+ url: 'https://github.com/stablyai/orca/releases/latest/download'
+ })
+ }
+ )
+
+ it.runIf(process.platform === 'darwin')(
+ 'restores ordinary release checks after a local build is unavailable',
+ async () => {
+ chooseLocalBuildMock.mockResolvedValue({
+ version: '0.9.0-local.1',
+ manifestContent: 'version: 0.9.0-local.1',
+ artifacts: new Map()
+ })
+ autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => {
+ autoUpdaterMock.emit('checking-for-update')
+ autoUpdaterMock.emit('update-not-available')
+ return Promise.resolve(undefined)
+ })
+ const send = vi.fn()
+ const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } =
+ await import('./updater')
+ setupAutoUpdater({ webContents: { send } } as never, {
+ getLastUpdateCheckAt: () => Date.now()
+ })
+
+ checkForUpdatesFromMenu({ localBuild: true })
+ await vi.waitFor(() => {
+ expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1)
+ })
+ expect(send).toHaveBeenCalledWith('updater:status', {
+ state: 'not-available',
+ userInitiated: true,
+ source: 'local'
+ })
+ expect(autoUpdaterMock.allowDowngrade).toBe(false)
+ expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
+
+ checkForUpdates()
+ await vi.waitFor(() => {
+ expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
+ })
+ expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
+ provider: 'generic',
+ url: 'https://github.com/stablyai/orca/releases/latest/download'
+ })
+ }
+ )
+
+ it.runIf(process.platform === 'darwin')(
+ 'restores ordinary release checks after a local updater failure',
+ async () => {
+ chooseLocalBuildMock.mockResolvedValue({
+ version: '0.9.0-local.1',
+ manifestContent: 'version: 0.9.0-local.1',
+ artifacts: new Map()
+ })
+ autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('local feed failed'))
+ const send = vi.fn()
+ const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } =
+ await import('./updater')
+ setupAutoUpdater({ webContents: { send } } as never, {
+ getLastUpdateCheckAt: () => Date.now()
+ })
+
+ checkForUpdatesFromMenu({ localBuild: true })
+ await vi.waitFor(() => {
+ expect(send).toHaveBeenCalledWith('updater:status', {
+ state: 'error',
+ message: 'local feed failed',
+ userInitiated: true,
+ source: 'local'
+ })
+ })
+ expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1)
+ expect(autoUpdaterMock.allowDowngrade).toBe(false)
+ expect(autoUpdaterMock.disableDifferentialDownload).toBe(false)
+
+ checkForUpdates()
+ await vi.waitFor(() => {
+ expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
+ })
+ expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
+ provider: 'generic',
+ url: 'https://github.com/stablyai/orca/releases/latest/download'
+ })
+ }
+ )
+
it('deduplicates identical check errors from the event and rejected promise', async () => {
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
diff --git a/src/main/updater.ts b/src/main/updater.ts
index a1373af8a..34067e03b 100644
--- a/src/main/updater.ts
+++ b/src/main/updater.ts
@@ -44,6 +44,7 @@ import {
hasServeUpdateSupervisor,
requestServeUpdateHandoff
} from './serve-update-handoff'
+import type { LocalBuildFeed } from './local-builds/local-build-feed-server'
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
@@ -128,6 +129,9 @@ let downloadInFlight = false
/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */
let quittingForUpdate = false
let autoUpdater: ElectronAutoUpdater | null = null
+let activeUpdateSource: 'release' | 'local' = 'release'
+let activeLocalBuildFeed: LocalBuildFeed | null = null
+let localBuildSelectionInProgress = false
function getAutoUpdater(): ElectronAutoUpdater {
if (!autoUpdater) {
@@ -141,6 +145,36 @@ function clearAvailableUpdateContext(): void {
availableReleaseUrl = null
}
+function closeLocalBuildFeed(): void {
+ const feed = activeLocalBuildFeed
+ activeLocalBuildFeed = null
+ if (feed) {
+ void feed.close()
+ }
+}
+
+function restoreReleaseUpdateSource(): void {
+ closeLocalBuildFeed()
+ activeUpdateSource = 'release'
+ if (autoUpdater) {
+ autoUpdater.allowDowngrade = false
+ autoUpdater.disableDifferentialDownload = false
+ }
+}
+
+function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void {
+ clearAvailableUpdateContext()
+ if (
+ currentStatus.state !== 'error' ||
+ currentStatus.message !== message ||
+ currentStatus.userInitiated !== userInitiated ||
+ currentStatus.source !== 'local'
+ ) {
+ sendStatus({ state: 'error', message, userInitiated, source: 'local' })
+ }
+ restoreReleaseUpdateSource()
+}
+
function clearPrereleaseFallbackContext(): void {
pendingPrereleaseFallback = null
}
@@ -219,7 +253,9 @@ function sendStatus(status: UpdateStatus): void {
}
}
- const decoratedStatus = decorateStatusWithActiveNudge(status)
+ const sourcedStatus: UpdateStatus =
+ activeUpdateSource === 'local' ? { ...status, source: 'local' } : status
+ const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus)
if (isUpdateCheckResultState(status.state)) {
finishActiveUpdateCheckAttempt()
@@ -523,8 +559,11 @@ function getKnownReleaseUrl(): string | undefined {
return availableReleaseUrl ?? undefined
}
-function hasNewerDownloadedVersion(): boolean {
- return availableVersion !== null && compareVersions(availableVersion, app.getVersion()) > 0
+function hasInstallableDownloadedVersion(): boolean {
+ return (
+ availableVersion !== null &&
+ (activeUpdateSource === 'local' || compareVersions(availableVersion, app.getVersion()) > 0)
+ )
}
function getPendingInstallVersion(): string {
@@ -769,6 +808,10 @@ async function sendCheckFailureStatus(
source: CheckFailureSource = 'promise',
sourceError?: unknown
): Promise {
+ if (activeUpdateSource === 'local') {
+ sendLocalBuildErrorAndRestore(message, userInitiated)
+ return
+ }
const failureKey = getCheckFailureKey(message, userInitiated)
if (
source === 'promise' &&
@@ -1172,6 +1215,9 @@ function retryPrereleaseFallbackAfterMissingManifest(
function runBackgroundUpdateCheck(
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
): void {
+ if (activeUpdateSource === 'local' || localBuildSelectionInProgress) {
+ return
+ }
if (backgroundCheckLaunchPending || currentStatus.state === 'checking') {
return
}
@@ -1238,6 +1284,20 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
sendStatus({ state: 'not-available', userInitiated: true })
return
}
+ if (options?.localBuild) {
+ void checkForLocalBuildFromMenu()
+ return
+ }
+ if (localBuildSelectionInProgress) {
+ return
+ }
+ if (
+ activeUpdateSource === 'local' &&
+ (currentStatus.state === 'checking' || currentStatus.state === 'downloading')
+ ) {
+ return
+ }
+ restoreReleaseUpdateSource()
const checkVariant = getUpdateCheckVariant(options)
if (checkVariant === 'prerelease') {
@@ -1303,12 +1363,63 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
})
}
+async function checkForLocalBuildFromMenu(): Promise {
+ if (process.platform !== 'darwin') {
+ sendLocalBuildErrorAndRestore(
+ 'Local build switching is currently available only on macOS.',
+ true
+ )
+ return
+ }
+ if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') {
+ return
+ }
+ if (localBuildSelectionInProgress) {
+ return
+ }
+ localBuildSelectionInProgress = true
+ try {
+ const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([
+ import('./local-builds/local-build-switch'),
+ import('./local-builds/local-build-feed-server')
+ ])
+ const candidate = await chooseLocalBuild(mainWindowRef)
+ if (!candidate) {
+ return
+ }
+ closeLocalBuildFeed()
+ const feed = await startLocalBuildFeed(candidate)
+ activeLocalBuildFeed = feed
+ activeUpdateSource = 'local'
+ clearPrereleaseFallbackContext()
+ clearPublishingWindowLastGoodCheck()
+ clearAvailableUpdateContext()
+ activeUpdateNudgeId = null
+ userInitiatedCheck = true
+ sendStatus({ state: 'checking', userInitiated: true })
+
+ const updater = getAutoUpdater()
+ updater.allowDowngrade = true
+ updater.disableDifferentialDownload = true
+ updater.setFeedURL({ provider: 'generic', url: feed.url })
+ const attemptId = beginUpdateCheckAttempt()
+ markUpdateCheckLaunched(attemptId)
+ await updater.checkForUpdates()
+ handleSettledUpdateCheckPromise(attemptId)
+ } catch (error) {
+ userInitiatedCheck = false
+ sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true)
+ } finally {
+ localBuildSelectionInProgress = false
+ }
+}
+
export function isQuittingForUpdate(): boolean {
return quittingForUpdate
}
export function quitAndInstall(): void {
- if (pendingQuitAndInstallTimer || quitAndInstallInProgress) {
+ if (localBuildSelectionInProgress || pendingQuitAndInstallTimer || quitAndInstallInProgress) {
return
}
@@ -1319,7 +1430,7 @@ export function quitAndInstall(): void {
if (
deferMacQuitUntilInstallerReady(
currentStatus,
- hasNewerDownloadedVersion(),
+ hasInstallableDownloadedVersion(),
getPendingInstallVersion,
sendStatus
)
@@ -1441,6 +1552,10 @@ export function setupAutoUpdater(
const autoUpdater = getAutoUpdater()
autoUpdater.autoDownload = false
+ if (activeUpdateSource === 'release') {
+ autoUpdater.allowDowngrade = false
+ autoUpdater.disableDifferentialDownload = false
+ }
// Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly.
autoUpdater.autoInstallOnAppQuit = updateInstallMode === 'interactive'
// Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner.
@@ -1457,10 +1572,12 @@ export function setupAutoUpdater(
// Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer.
// Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download// URL avoids /latest redirect drift between check and download.
- autoUpdater.setFeedURL({
- provider: 'generic',
- url: 'https://github.com/stablyai/orca/releases/latest/download'
- })
+ if (activeUpdateSource === 'release') {
+ autoUpdater.setFeedURL({
+ provider: 'generic',
+ url: 'https://github.com/stablyai/orca/releases/latest/download'
+ })
+ }
if (autoUpdaterInitialized) {
return
@@ -1480,7 +1597,8 @@ export function setupAutoUpdater(
getUserInitiatedCheck: () => userInitiatedCheck,
handleQuitAndInstallFailure,
isQuitAndInstallHandoffActive,
- hasNewerDownloadedVersion,
+ hasInstallableDownloadedVersion,
+ isLocalBuildCheck: () => activeUpdateSource === 'local',
shouldHandleUpdaterErrorEvent,
performQuitAndInstall,
clearUpdateAvailableEventPending,
@@ -1494,6 +1612,7 @@ export function setupAutoUpdater(
shouldSuppressMissingManifestPrereleaseFallbackEvent,
suppressMissingManifestPrereleaseFallbackPromiseFailure,
recordCompletedUpdateCheck,
+ restoreReleaseUpdateSource,
sendStatus,
scheduleAutomaticUpdateCheck,
clearBackgroundCheckLaunchPending,
@@ -1544,13 +1663,13 @@ export function setupAutoUpdater(
}
export function downloadUpdate(): void {
- if (downloadInFlight) {
+ if (localBuildSelectionInProgress || downloadInFlight) {
return
}
// Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works.
const canStart =
currentStatus.state === 'available' ||
- (currentStatus.state === 'error' && hasNewerDownloadedVersion())
+ (currentStatus.state === 'error' && hasInstallableDownloadedVersion())
if (!canStart) {
return
}
@@ -1562,6 +1681,7 @@ export function downloadUpdate(): void {
return
}
downloadInFlight = true
+ const localBuildDownload = activeUpdateSource === 'local'
beginMacUpdateDownload()
// Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert.
sendStatus({ state: 'downloading', percent: 0, version })
@@ -1569,6 +1689,11 @@ export function downloadUpdate(): void {
.downloadUpdate()
.catch((err) => {
downloadInFlight = false
- sendErrorStatus(String(err?.message ?? err))
+ const message = String(err?.message ?? err)
+ if (localBuildDownload) {
+ sendLocalBuildErrorAndRestore(message)
+ } else {
+ sendErrorStatus(message)
+ }
})
}
diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx
index cbe84536b..e440729f2 100644
--- a/src/renderer/src/components/UpdateCard.error-card.test.tsx
+++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx
@@ -6,6 +6,7 @@ import { UpdateCard } from './UpdateCard'
const openUrl = vi.fn()
const download = vi.fn()
+const check = vi.fn()
function renderAfterAvailableStatus(): void {
useAppStore.setState({
@@ -26,6 +27,7 @@ beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
openUrl.mockReset()
download.mockReset()
+ check.mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
@@ -34,7 +36,7 @@ beforeEach(() => {
shell: { openUrl },
ui: { set: vi.fn().mockResolvedValue(undefined) },
updater: {
- check: vi.fn(),
+ check,
dismissNudge: vi.fn(),
download,
quitAndInstall: vi.fn().mockResolvedValue(undefined)
@@ -94,3 +96,37 @@ describe('UpdateCard Windows signature failures', () => {
)
})
})
+
+describe('UpdateCard local builds', () => {
+ it('does not link local versions to GitHub release downloads', () => {
+ useAppStore.setState({
+ updateStatus: {
+ state: 'available',
+ version: '1.4.100-local.1.abc',
+ changelog: null,
+ source: 'local'
+ },
+ updateChangelog: null,
+ dismissedUpdateVersion: null,
+ updateCardCollapsed: false,
+ updateReassuranceSeen: true
+ })
+ render()
+
+ expect(screen.queryByText('Release notes')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Update' }))
+ expect(download).toHaveBeenCalledTimes(1)
+
+ act(() =>
+ useAppStore.getState().setUpdateStatus({
+ state: 'error',
+ message: 'signature rejected',
+ source: 'local'
+ })
+ )
+ expect(screen.getByText('Local Build Error')).toBeTruthy()
+ expect(screen.queryByRole('button', { name: 'Download Manually' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Choose Another Build' }))
+ expect(check).toHaveBeenCalledWith({ localBuild: true })
+ })
+})
diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx
index 0612c91fd..1e04d8df2 100644
--- a/src/renderer/src/components/UpdateCard.tsx
+++ b/src/renderer/src/components/UpdateCard.tsx
@@ -53,7 +53,7 @@ type ErrorCardModel = {
explainer?: string
/** Raw error text, shown only when the user expands "Show details". */
detail?: string
- releaseUrl: string
+ releaseUrl?: string
/** Overrides the secondary button label (defaults to "Download Manually"). */
manualLabel?: string
primaryAction?: {
@@ -137,6 +137,7 @@ export function UpdateCard() {
// Tracks card exit so the fade-out animation plays before unmount.
const [exiting, setExiting] = useState(false)
const changelog: ChangelogData | null = storeChangelog
+ const isLocalBuild = status.source === 'local'
// Why: the 'error' variant carries no version, but the card needs it for the fallback URL and dismiss; cache from states that have it.
const versionRef = useRef(null)
@@ -324,79 +325,104 @@ export function UpdateCard() {
status.state === 'error' && isWindowsSignatureCheckUnavailableFailure(status.message)
const errorCard: ErrorCardModel | null =
status.state === 'error'
- ? isHttp2UpdateError
+ ? isLocalBuild
? {
- variant: 'http1Compatibility',
- title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'),
- summary: 'Orca can retry through HTTP/1.1 compatibility mode.',
- 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),
+ title: cachedVersion
+ ? translate('auto.components.UpdateCard.8cf17b10af', 'Local Build Error')
+ : translate('auto.components.UpdateCard.a4650b0dc4', 'Could Not Use Local Build'),
+ summary: cachedVersion
+ ? translate(
+ 'auto.components.UpdateCard.b1e390250d',
+ 'Could not complete the local build switch.'
+ )
+ : translate(
+ 'auto.components.UpdateCard.d29740d175',
+ 'The selected build could not be used.'
+ ),
+ detail: status.message,
primaryAction: {
- label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'),
- pendingLabel: 'Restarting...',
- isPending: compatibilityRelaunching,
- onClick: handleEnableHttp1Compatibility
+ label: translate('auto.components.UpdateCard.37d45c9ec1', 'Choose Another Build'),
+ onClick: () => {
+ void window.api.updater.check({ localBuild: true })
+ }
}
}
- : isSignatureMismatchError
+ : isHttp2UpdateError
? {
- // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path.
- variant: 'security',
- title: translate('auto.components.UpdateCard.5b309b19f3', "Update Wasn't Installed"),
- summary: translate(
- 'auto.components.UpdateCard.092f09fc14',
- "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version."
+ variant: 'http1Compatibility',
+ title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'),
+ summary: 'Orca can retry through HTTP/1.1 compatibility mode.',
+ 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: status.message,
- // Why: linking the rejected version would let users bypass the publisher check by re-running it.
- releaseUrl: releaseUrlForVersion(null),
- manualLabel: translate(
- 'auto.components.UpdateCard.c9ff9b9ec2',
- 'Check official releases'
- )
+ detail: compatibilitySetupError ?? status.message,
+ releaseUrl: releaseUrlForVersion(cachedVersion),
+ primaryAction: {
+ label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'),
+ pendingLabel: 'Restarting...',
+ isPending: compatibilityRelaunching,
+ onClick: handleEnableHttp1Compatibility
+ }
}
- : isSignatureCheckBlockedError
+ : isSignatureMismatchError
? {
+ // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path.
+ variant: 'security',
title: translate(
- 'auto.components.UpdateCard.e944c2de43',
- 'Update Verification Blocked'
+ 'auto.components.UpdateCard.5b309b19f3',
+ "Update Wasn't Installed"
),
summary: translate(
- 'auto.components.UpdateCard.a05992a26b',
- "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases."
+ 'auto.components.UpdateCard.092f09fc14',
+ "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version."
),
detail: status.message,
- releaseUrl: releaseUrlForVersion(cachedVersion),
- primaryAction: {
- label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'),
- onClick: handleUpdate
+ // Why: linking the rejected version would let users bypass the publisher check by re-running it.
+ releaseUrl: releaseUrlForVersion(null),
+ manualLabel: translate(
+ 'auto.components.UpdateCard.c9ff9b9ec2',
+ 'Check 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 because antivirus software blocked it. Retry the download, or get the installer from our official releases."
+ ),
+ detail: status.message,
+ releaseUrl: releaseUrlForVersion(cachedVersion),
+ primaryAction: {
+ label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'),
+ onClick: handleUpdate
+ }
}
- }
- : {
- // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug.
- 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, so offer a Re-check instead of forcing manual download.
- 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 })
+ : {
+ // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug.
+ 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, so offer a Re-check instead of forcing manual download.
+ 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'),
@@ -558,6 +584,7 @@ export function UpdateCard() {
onMediaError={() => setMediaFailed(true)}
onMediaLoad={() => setMediaLoaded(true)}
onCollapse={handleCollapseWithAnimation}
+ showReleaseNotes={!isLocalBuild}
/>
)
}
@@ -568,9 +595,10 @@ export function UpdateCard() {
return null
}
- const releaseUrl =
- ('releaseUrl' in status ? status.releaseUrl : undefined) ??
- releaseUrlForVersion(status.version)
+ const releaseUrl = isLocalBuild
+ ? undefined
+ : (('releaseUrl' in status ? status.releaseUrl : undefined) ??
+ releaseUrlForVersion(status.version))
if (isRichMode && changelog) {
return (
@@ -750,7 +778,7 @@ function SimpleCardContent({
onClose
}: {
version: string
- releaseUrl: string
+ releaseUrl?: string
onUpdate: () => void
onClose: () => void
}) {
@@ -781,12 +809,14 @@ function SimpleCardContent({
{translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")}
-
+ {releaseUrl && (
+
+ )}