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
This commit is contained in:
Neil 2026-07-27 16:36:39 -07:00 committed by GitHub
parent 97cb32c1cc
commit 10ca89ac8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1769 additions and 175 deletions

View File

@ -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

View File

@ -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'
}
)
}

View File

@ -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')
})
})

View File

@ -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)

View File

@ -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
}

View File

@ -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')
})
})

View File

@ -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",

View File

@ -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')
})
})

View File

@ -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<string, { file: FileHandle; size: number }>
close: () => Promise<void>
}
type LocalBuildCandidateLoaderOptions = {
readCompatibility?: (zipFile: FileHandle) => Promise<LocalBuildCompatibility>
}
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<string, unknown>
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<string, unknown>).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<string, unknown>
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<string> {
const hash = createHash('sha512')
await new Promise<void>((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<void> {
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<string> {
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<LocalBuildCompatibility> {
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<LocalBuildCompatibility>
): 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<LocalBuildCandidate> {
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()
}
}
}

View File

@ -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]
})
})
})

View File

@ -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<LocalBuildCompatibilityResult> {
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)
}
}

View File

@ -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 })
}
})
})

View File

@ -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<void>
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve())
})
}
export async function startLocalBuildFeed(candidate: LocalBuildCandidate): Promise<LocalBuildFeed> {
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<void>((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()
}
}
}

View File

@ -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<LocalBuildCandidate | null> {
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
}

View File

@ -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 }]
])
})

View File

@ -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 = {

View File

@ -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<void>
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()
}
})
}

View File

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

View File

@ -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<string, unknown>).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')

View File

@ -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<void> {
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<void> {
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/<tag>/ 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)
}
})
}

View File

@ -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(<UpdateCard />)
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 })
})
})

View File

@ -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<string | null>(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.")}
</p>
<button
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground self-start"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
>
{translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
{releaseUrl && (
<button
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground self-start"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
>
{translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
)}
<Button
variant="default"
@ -811,7 +841,8 @@ function DownloadingContent({
mediaLoaded,
onMediaError,
onMediaLoad,
onCollapse
onCollapse,
showReleaseNotes
}: {
version: string
percent: number
@ -822,6 +853,7 @@ function DownloadingContent({
onMediaError: () => void
onMediaLoad: () => void
onCollapse: () => void
showReleaseNotes: boolean
}) {
const release = changelog?.release
const showMedia =
@ -877,18 +909,20 @@ function DownloadingContent({
})}
</p>
<button
className="text-xs text-muted-foreground underline hover:text-foreground self-start"
onClick={() =>
void window.api.shell.openUrl(
release ? release.releaseNotesUrl : releaseUrlForVersion(version)
)
}
>
{release
? translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')
: translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
{showReleaseNotes && (
<button
className="text-xs text-muted-foreground underline hover:text-foreground self-start"
onClick={() =>
void window.api.shell.openUrl(
release ? release.releaseNotesUrl : releaseUrlForVersion(version)
)
}
>
{release
? translate('auto.components.UpdateCard.aad383aecc', 'Read the full release notes')
: translate('auto.components.UpdateCard.44324ef542', 'Release notes')}
</button>
)}
<div className="flex flex-col gap-2 mt-1">
<Progress value={percent} className="h-1.5" />
@ -918,7 +952,7 @@ function ErrorCardContent({
summary: string
explainer?: string
detail?: string
releaseUrl: string
releaseUrl?: string
manualLabel?: string
primaryAction?: {
label: string
@ -1018,14 +1052,16 @@ function ErrorCardContent({
: primaryAction.label}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
className="flex-1"
>
{manualLabel ?? translate('auto.components.UpdateCard.47126bcf57', 'Download Manually')}
</Button>
{releaseUrl && (
<Button
variant="outline"
size="sm"
onClick={() => void window.api.shell.openUrl(releaseUrl)}
className="flex-1"
>
{manualLabel ?? translate('auto.components.UpdateCard.47126bcf57', 'Download Manually')}
</Button>
)}
</div>
</div>
)

View File

@ -168,20 +168,22 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
'auto.components.settings.GeneralUpdateSettingsSection.8311da27ba',
'is available. Click "Install Update" to download and install it.'
)}{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02',
'Release notes'
)}
</a>
{updateStatus.source !== 'local' && (
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02',
'Release notes'
)}
</a>
)}
</>
)}
{updateStatus.state === 'not-available' &&
@ -206,20 +208,22 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element {
'auto.components.settings.GeneralUpdateSettingsSection.d89806cc89',
'is ready to install.'
)}{' '}
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02',
'Release notes'
)}
</a>
{updateStatus.source !== 'local' && (
<a
href={
updateStatus.releaseUrl ??
`https://github.com/stablyai/orca/releases/tag/v${updateStatus.version}`
}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
{translate(
'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02',
'Release notes'
)}
</a>
)}
</>
)}
{updateStatus.state === 'error' &&

View File

@ -40,7 +40,12 @@ const CHANGELOG_URL = 'https://onorca.dev/changelog'
const GITHUB_URL = 'https://github.com/stablyai/orca'
const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q'
const X_URL = 'https://x.com/orca_build'
const NO_UPDATE_CHECK_MODIFIERS = { ctrlKey: false, metaKey: false, shiftKey: false }
const NO_UPDATE_CHECK_MODIFIERS = {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false
}
function openExternalUrl(url: string): void {
void window.api.shell.openUrl(url)
@ -154,6 +159,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
const handleCheckForUpdatesPointerDown = (event: React.PointerEvent): void => {
updateCheckModifiersRef.current = {
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey

View File

@ -1900,7 +1900,12 @@
"c9ff9b9ec2": "Check official releases",
"a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
"8bc9e17d8f": "Show details",
"8cf17b10af": "Local Build Error",
"a4650b0dc4": "Could Not Use Local Build",
"b1e390250d": "Could not complete the local build switch.",
"d29740d175": "The selected build could not be used.",
"37d45c9ec1": "Choose Another Build"
},
"WorktreeJumpPalette": {
"ac037cfac2": "Move",

View File

@ -1877,7 +1877,12 @@
"c9ff9b9ec2": "Check official releases",
"a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
"8bc9e17d8f": "Show details",
"8cf17b10af": "Local Build Error",
"a4650b0dc4": "Could Not Use Local Build",
"b1e390250d": "Could not complete the local build switch.",
"d29740d175": "The selected build could not be used.",
"37d45c9ec1": "Choose Another Build"
},
"WorktreeJumpPalette": {
"ac037cfac2": "Mover",

View File

@ -1877,7 +1877,12 @@
"c9ff9b9ec2": "Check official releases",
"a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
"8bc9e17d8f": "Show details",
"8cf17b10af": "Local Build Error",
"a4650b0dc4": "Could Not Use Local Build",
"b1e390250d": "Could not complete the local build switch.",
"d29740d175": "The selected build could not be used.",
"37d45c9ec1": "Choose Another Build"
},
"WorktreeJumpPalette": {
"ac037cfac2": "移動",

View File

@ -1877,7 +1877,12 @@
"c9ff9b9ec2": "Check official releases",
"a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
"8bc9e17d8f": "Show details",
"8cf17b10af": "Local Build Error",
"a4650b0dc4": "Could Not Use Local Build",
"b1e390250d": "Could not complete the local build switch.",
"d29740d175": "The selected build could not be used.",
"37d45c9ec1": "Choose Another Build"
},
"WorktreeJumpPalette": {
"ac037cfac2": "이동",

View File

@ -1877,7 +1877,12 @@
"c9ff9b9ec2": "Check official releases",
"a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.",
"5194358929": "Hide details",
"8bc9e17d8f": "Show details"
"8bc9e17d8f": "Show details",
"8cf17b10af": "Local Build Error",
"a4650b0dc4": "Could Not Use Local Build",
"b1e390250d": "Could not complete the local build switch.",
"d29740d175": "The selected build could not be used.",
"37d45c9ec1": "Choose Another Build"
},
"WorktreeJumpPalette": {
"ac037cfac2": "移动",

View File

@ -1,13 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getUpdateCheckClickOptions, getUpdateCheckHint } from './update-check-click-options'
function clickEvent(overrides: Partial<Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>>) {
function clickEvent(
overrides: Partial<Pick<MouseEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>>
) {
return {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...overrides
} as Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>
} as Pick<MouseEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>
}
describe('getUpdateCheckClickOptions', () => {
@ -42,9 +45,19 @@ describe('getUpdateCheckClickOptions', () => {
})
})
it('gives local build selection precedence over release channel modifiers', () => {
expect(
getUpdateCheckClickOptions(clickEvent({ altKey: true, shiftKey: true, metaKey: true }), true)
).toEqual({ localBuild: true })
expect(getUpdateCheckClickOptions(clickEvent({ altKey: true }), false)).toEqual({
includePrerelease: false,
includePerfPrerelease: false
})
})
it('formats the tooltip hint by platform', () => {
expect(getUpdateCheckHint(true)).toBe(
'⇧+click checks the latest RC; ⌘+click checks the latest perf build.'
'⇧+click checks the latest RC; ⌘+click checks the latest perf build. ⌥+click chooses a local macOS build.'
)
expect(getUpdateCheckHint(false)).toBe(
'Shift+click checks the latest RC; Ctrl+click checks the latest perf build.'

View File

@ -1,7 +1,7 @@
import type { UpdateCheckOptions } from '../../../shared/types'
import { getShortcutPlatform } from './shortcut-platform'
type UpdateCheckClickEvent = Pick<MouseEvent, 'ctrlKey' | 'metaKey' | 'shiftKey'>
type UpdateCheckClickEvent = Pick<MouseEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'>
function isMacShortcutPlatform(): boolean {
return getShortcutPlatform() === 'darwin'
@ -10,13 +10,17 @@ function isMacShortcutPlatform(): boolean {
export function getUpdateCheckHint(isMac = isMacShortcutPlatform()): string {
const rcClickLabel = isMac ? '⇧+click' : 'Shift+click'
const perfClickLabel = isMac ? '⌘+click' : 'Ctrl+click'
return `${rcClickLabel} checks the latest RC; ${perfClickLabel} checks the latest perf build.`
const releaseHints = `${rcClickLabel} checks the latest RC; ${perfClickLabel} checks the latest perf build.`
return isMac ? `${releaseHints} ⌥+click chooses a local macOS build.` : releaseHints
}
export function getUpdateCheckClickOptions(
event: UpdateCheckClickEvent,
isMac = isMacShortcutPlatform()
): UpdateCheckOptions {
if (isMac && event.altKey) {
return { localBuild: true }
}
return {
includePrerelease: event.shiftKey,
includePerfPrerelease: isMac ? event.metaKey : event.ctrlKey

View File

@ -0,0 +1,11 @@
{
"formatVersion": 1,
"appId": "com.stablyai.orca",
"stateSchemaVersion": 1,
"readableStateSchemaVersions": [1],
"daemonProtocolVersion": 28,
"attachableDaemonProtocolVersions": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28
]
}

View File

@ -0,0 +1,11 @@
export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
formatVersion: 1,
appId: 'com.stablyai.orca',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
daemonProtocolVersion: 28,
attachableDaemonProtocolVersions: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28
]
} as const

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
getLocalBuildCompatibilityError,
parseLocalBuildCompatibility,
type LocalBuildCompatibility
} from './local-build-compatibility'
function target(overrides: Partial<LocalBuildCompatibility> = {}): LocalBuildCompatibility {
return {
formatVersion: 1,
appId: 'com.stablyai.orca',
buildId: '1.2.3-abc-arm64',
version: '1.2.3-local.1.abc',
commit: 'abc',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
daemonProtocolVersion: 28,
attachableDaemonProtocolVersions: [27, 28],
platform: 'darwin',
architecture: 'arm64',
...overrides
}
}
describe('local build compatibility', () => {
it('rejects state and live-terminal protocol incompatibilities', () => {
expect(
getLocalBuildCompatibilityError(target({ readableStateSchemaVersions: [2] }), 1, [])
).toContain('cannot read Orca workspace state schema')
expect(getLocalBuildCompatibilityError(target(), 1, [26])).toContain(
'cannot reconnect terminal daemon protocol 26'
)
expect(getLocalBuildCompatibilityError(target(), 1, [27, 28])).toBeNull()
})
it('parses only bounded Orca compatibility contracts', () => {
expect(parseLocalBuildCompatibility(target())).toEqual(target())
expect(() => parseLocalBuildCompatibility(target({ appId: 'other.app' }))).toThrow(
'invalid compatibility metadata'
)
expect(() =>
parseLocalBuildCompatibility(target({ attachableDaemonProtocolVersions: [] }))
).toThrow('invalid compatibility metadata')
})
})

View File

@ -0,0 +1,90 @@
import { LOCAL_BUILD_COMPATIBILITY_CONTRACT } from './local-build-compatibility-contract'
export const LOCAL_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json'
export const LOCAL_BUILD_COMPATIBILITY_FORMAT_VERSION =
LOCAL_BUILD_COMPATIBILITY_CONTRACT.formatVersion
export const ORCA_APP_ID = LOCAL_BUILD_COMPATIBILITY_CONTRACT.appId
export type LocalBuildArchitecture = 'arm64' | 'x64'
export type LocalBuildCompatibility = {
formatVersion: number
appId: string
buildId: string
version: string
commit: string
stateSchemaVersion: number
readableStateSchemaVersions: number[]
daemonProtocolVersion: number
attachableDaemonProtocolVersions: number[]
platform: 'darwin'
architecture: LocalBuildArchitecture
}
function isIntegerArray(value: unknown): value is number[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.length <= 64 &&
value.every((entry) => Number.isSafeInteger(entry) && entry > 0)
)
}
export function parseLocalBuildCompatibility(value: unknown): LocalBuildCompatibility {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('The selected build has invalid compatibility metadata.')
}
const record = value as Record<string, unknown>
const architecture = record.architecture
if (
record.formatVersion !== LOCAL_BUILD_COMPATIBILITY_FORMAT_VERSION ||
record.appId !== ORCA_APP_ID ||
typeof record.buildId !== 'string' ||
record.buildId.length === 0 ||
record.buildId.length > 200 ||
typeof record.version !== 'string' ||
record.version.length === 0 ||
record.version.length > 100 ||
typeof record.commit !== 'string' ||
record.commit.length === 0 ||
record.commit.length > 100 ||
!Number.isSafeInteger(record.stateSchemaVersion) ||
Number(record.stateSchemaVersion) <= 0 ||
!isIntegerArray(record.readableStateSchemaVersions) ||
!Number.isSafeInteger(record.daemonProtocolVersion) ||
Number(record.daemonProtocolVersion) <= 0 ||
!isIntegerArray(record.attachableDaemonProtocolVersions) ||
record.platform !== 'darwin' ||
(architecture !== 'arm64' && architecture !== 'x64')
) {
throw new Error('The selected build has invalid compatibility metadata.')
}
if (
!(record.readableStateSchemaVersions as number[]).includes(
record.stateSchemaVersion as number
) ||
!(record.attachableDaemonProtocolVersions as number[]).includes(
record.daemonProtocolVersion as number
)
) {
throw new Error('The selected build has inconsistent compatibility metadata.')
}
return record as LocalBuildCompatibility
}
export function getLocalBuildCompatibilityError(
target: LocalBuildCompatibility,
currentStateSchemaVersion: number,
liveDaemonProtocols: readonly number[]
): string | null {
if (!target.readableStateSchemaVersions.includes(currentStateSchemaVersion)) {
return `This build cannot read Orca workspace state schema ${currentStateSchemaVersion}. Your workspace was not changed.`
}
const unsupportedProtocols = liveDaemonProtocols.filter(
(protocol) => !target.attachableDaemonProtocolVersions.includes(protocol)
)
if (unsupportedProtocols.length > 0) {
return `This build cannot reconnect terminal daemon protocol ${unsupportedProtocols.join(', ')}. Close those terminals or choose a compatible build.`
}
return null
}

View File

@ -2340,9 +2340,12 @@ export type ChangelogData = {
export type UpdateCheckOptions = {
includePrerelease?: boolean
includePerfPrerelease?: boolean
localBuild?: boolean
}
export type UpdateStatus =
export type UpdateSource = 'local'
export type UpdateStatus = (
| { state: 'idle' }
| { state: 'checking'; userInitiated?: boolean }
| {
@ -2365,6 +2368,7 @@ export type UpdateStatus =
| { state: 'downloading'; percent: number; version: string; activeNudgeId?: string }
| { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string }
| { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string }
) & { source?: UpdateSource }
// ─── Settings ────────────────────────────────────────────────────────
export type NotificationSettings = {