feat(updater): switch to validated local mac builds

This commit is contained in:
Neil
2026-07-27 01:30:15 -07:00
parent 2b244fa0ea
commit eb256ffba3
35 changed files with 1452 additions and 180 deletions
+24 -1
View File
@@ -12,9 +12,12 @@ 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 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'
@@ -52,8 +55,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'
},
@@ -158,6 +162,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
+46
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'
}
)
}
+15
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')
})
})
@@ -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([
@@ -165,6 +171,51 @@ 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
try {
delete require.cache[configPath]
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 (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)
@@ -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
}
@@ -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')
})
})
+1 -1
View File
@@ -75,7 +75,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",
@@ -0,0 +1,113 @@
import { createHash } from 'node:crypto'
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
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'
const tempDirectories: string[] = []
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')
})
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('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')
})
})
@@ -0,0 +1,207 @@
import { createHash } from 'node:crypto'
import { execFile } from 'node:child_process'
import { createReadStream } from 'node:fs'
import { lstat, readFile, realpath, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { promisify } from 'node:util'
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 execFileAsync = promisify(execFile)
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, string>
}
type LocalBuildCandidateLoaderOptions = {
readCompatibility?: (zipPath: string) => 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(filePath: string): Promise<string> {
const hash = createHash('sha512')
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath)
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.')
}
}
async function readCompatibility(zipPath: string): Promise<LocalBuildCompatibility> {
let stdout: string
try {
;({ stdout } = await execFileAsync(
'/usr/bin/unzip',
['-p', zipPath, `Orca.app/Contents/Resources/${LOCAL_BUILD_COMPATIBILITY_FILENAME}`],
{ encoding: 'utf8', maxBuffer: MAX_COMPATIBILITY_BYTES }
))
} catch {
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: (zipPath: string) => Promise<LocalBuildCompatibility>
): Promise<{ compatibility: LocalBuildCompatibility; path: string }> {
const filePath = join(rootPath, manifestFile.url)
await assertContainedRegularFile(rootPath, filePath)
const fileStats = await stat(filePath)
if (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(filePath)) !== manifestFile.sha512) {
throw new Error(`SHA-512 verification failed for ${manifestFile.url}.`)
}
const compatibility = await compatibilityReader(filePath)
if (compatibility.appId !== ORCA_APP_ID || compatibility.version !== manifestVersion) {
throw new Error('The selected ZIP does not match its update manifest.')
}
return { compatibility, path: filePath }
}
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 manifestStats = await stat(manifestPath)
if (manifestStats.size <= 0 || manifestStats.size > MAX_MANIFEST_BYTES) {
throw new Error('The selected update manifest is too large.')
}
const manifest = parseManifest(parse(await readFile(manifestPath, 'utf8'), { maxAliasCount: 0 }))
const rootPath = dirname(manifestPath)
const compatibilityReader = options.readCompatibility ?? readCompatibility
const validated = await Promise.all(
manifest.files.map((file) =>
validateArtifact(rootPath, file, manifest.version, compatibilityReader)
)
)
const matching = validated
.map((entry, index) => ({ ...entry, manifestFile: manifest.files[index] }))
.filter((entry) => entry.compatibility.architecture === architecture)
if (matching.length !== 1) {
throw new Error(`The manifest must contain exactly one ${architecture} Orca ZIP.`)
}
const target = matching[0]
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, target.path]])
}
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { SCHEMA_VERSION } from '../../shared/constants'
import compatibilityContract from '../../shared/local-build-compatibility-contract.json'
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(compatibilityContract).toMatchObject({
appId: 'com.stablyai.orca',
stateSchemaVersion: SCHEMA_VERSION,
readableStateSchemaVersions: [SCHEMA_VERSION],
daemonProtocolVersion: PROTOCOL_VERSION,
attachableDaemonProtocolVersions: [...PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION]
})
})
})
@@ -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)
}
}
@@ -0,0 +1,35 @@
import { mkdtemp, 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 candidate = {
version: '1.2.3-local.1',
manifestContent: 'version: 1.2.3-local.1\n',
artifacts: new Map([['orca-macos-arm64.zip', artifactPath]])
} 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 })
}
})
})
@@ -0,0 +1,75 @@
import { randomBytes } from 'node:crypto'
import { createReadStream } from 'node:fs'
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 artifactPath = candidate.artifacts.get(filename)
if (!artifactPath) {
response.writeHead(404).end()
return
}
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': 'application/zip'
})
const stream = createReadStream(artifactPath)
stream.on('error', () => response.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()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
await closeServer(server)
throw new Error('Could not start the local update feed.')
}
return {
url: `http://127.0.0.1:${address.port}${prefix}`,
close: () => closeServer(server)
}
}
@@ -0,0 +1,50 @@
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)
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))
return confirmation.response === 0 ? candidate : null
}
+12
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 }]
])
})
+8 -3
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 = {
+33 -25
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
@@ -70,7 +71,8 @@ export function registerAutoUpdaterHandlers({
getUserInitiatedCheck,
handleQuitAndInstallFailure,
isQuitAndInstallHandoffActive,
hasNewerDownloadedVersion,
hasInstallableDownloadedVersion,
isLocalBuildCheck,
shouldHandleUpdaterErrorEvent,
clearUpdateAvailableEventPending,
isActiveUpdateCheckAttempt,
@@ -93,9 +95,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 +123,7 @@ export function registerAutoUpdaterHandlers({
if (
deferMacQuitUntilInstallerReady(
getCurrentStatus(),
hasNewerDownloadedVersion(),
hasInstallableDownloadedVersion(),
getPendingInstallVersion,
sendStatus
)
@@ -158,8 +160,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 +180,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 +195,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)
}
}
}
@@ -219,13 +225,15 @@ export function registerAutoUpdaterHandlers({
const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck()
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 (!isLocalBuildCheck()) {
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 })
@@ -242,8 +250,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
+3
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'
+87
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,67 @@ 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('deduplicates identical check errors from the event and rejected promise', async () => {
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
+120 -12
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,23 @@ 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 clearPrereleaseFallbackContext(): void {
pendingPrereleaseFallback = null
}
@@ -219,7 +240,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 +546,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 +795,12 @@ async function sendCheckFailureStatus(
source: CheckFailureSource = 'promise',
sourceError?: unknown
): Promise<void> {
if (activeUpdateSource === 'local') {
closeLocalBuildFeed()
clearAvailableUpdateContext()
sendErrorStatus(message, userInitiated)
return
}
const failureKey = getCheckFailureKey(message, userInitiated)
if (
source === 'promise' &&
@@ -1172,6 +1204,9 @@ function retryPrereleaseFallbackAfterMissingManifest(
function runBackgroundUpdateCheck(
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
): void {
if (activeUpdateSource === 'local' || localBuildSelectionInProgress) {
return
}
if (backgroundCheckLaunchPending || currentStatus.state === 'checking') {
return
}
@@ -1238,6 +1273,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 +1352,64 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
})
}
async function checkForLocalBuildFromMenu(): Promise<void> {
if (process.platform !== 'darwin') {
activeUpdateSource = 'local'
sendErrorStatus('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) {
closeLocalBuildFeed()
clearAvailableUpdateContext()
activeUpdateSource = 'local'
userInitiatedCheck = false
sendErrorStatus(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 +1420,7 @@ export function quitAndInstall(): void {
if (
deferMacQuitUntilInstallerReady(
currentStatus,
hasNewerDownloadedVersion(),
hasInstallableDownloadedVersion(),
getPendingInstallVersion,
sendStatus
)
@@ -1441,6 +1542,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 +1562,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 +1587,8 @@ export function setupAutoUpdater(
getUserInitiatedCheck: () => userInitiatedCheck,
handleQuitAndInstallFailure,
isQuitAndInstallHandoffActive,
hasNewerDownloadedVersion,
hasInstallableDownloadedVersion,
isLocalBuildCheck: () => activeUpdateSource === 'local',
shouldHandleUpdaterErrorEvent,
performQuitAndInstall,
clearUpdateAvailableEventPending,
@@ -1544,13 +1652,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
}
@@ -94,3 +94,35 @@ 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()
})
})
+139 -98
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,109 @@ 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),
primaryAction: {
label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'),
pendingLabel: 'Restarting...',
isPending: compatibilityRelaunching,
onClick: handleEnableHttp1Compatibility
}
}
: isSignatureMismatchError
? {
// 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."
),
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'
)
}
: isSignatureCheckBlockedError
? {
title: translate(
'auto.components.UpdateCard.e944c2de43',
'Update Verification Blocked'
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.'
),
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: {
detail: status.message,
primaryAction: cachedVersion
? {
label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'),
onClick: handleUpdate
}
: {
label: translate('auto.components.UpdateCard.37d45c9ec1', 'Choose Another Build'),
onClick: () => {
void window.api.updater.check({ localBuild: true })
}
}
}
: isHttp2UpdateError
? {
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),
primaryAction: {
label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'),
pendingLabel: 'Restarting...',
isPending: compatibilityRelaunching,
onClick: handleEnableHttp1Compatibility
}
: {
// 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.',
}
: isSignatureMismatchError
? {
// 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."
),
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: 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 })
}
}
}
: installError
? {
title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'),
@@ -558,6 +589,7 @@ export function UpdateCard() {
onMediaError={() => setMediaFailed(true)}
onMediaLoad={() => setMediaLoaded(true)}
onCollapse={handleCollapseWithAnimation}
showReleaseNotes={!isLocalBuild}
/>
)
}
@@ -568,9 +600,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 +783,7 @@ function SimpleCardContent({
onClose
}: {
version: string
releaseUrl: string
releaseUrl?: string
onUpdate: () => void
onClose: () => void
}) {
@@ -781,12 +814,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 +846,8 @@ function DownloadingContent({
mediaLoaded,
onMediaError,
onMediaLoad,
onCollapse
onCollapse,
showReleaseNotes
}: {
version: string
percent: number
@@ -822,6 +858,7 @@ function DownloadingContent({
onMediaError: () => void
onMediaLoad: () => void
onCollapse: () => void
showReleaseNotes: boolean
}) {
const release = changelog?.release
const showMedia =
@@ -877,18 +914,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 +957,7 @@ function ErrorCardContent({
summary: string
explainer?: string
detail?: string
releaseUrl: string
releaseUrl?: string
manualLabel?: string
primaryAction?: {
label: string
@@ -1018,14 +1057,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>
)
@@ -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' &&
@@ -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
+6 -1
View File
@@ -1894,7 +1894,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",
+6 -1
View File
@@ -1871,7 +1871,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",
+6 -1
View File
@@ -1871,7 +1871,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": "移動",
+6 -1
View File
@@ -1871,7 +1871,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": "이동",
+6 -1
View File
@@ -1871,7 +1871,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": "移动",
@@ -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.'
@@ -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
@@ -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
]
}
@@ -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')
})
})
+89
View File
@@ -0,0 +1,89 @@
import compatibilityContract from './local-build-compatibility-contract.json'
export const LOCAL_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json'
export const LOCAL_BUILD_COMPATIBILITY_FORMAT_VERSION = compatibilityContract.formatVersion
export const ORCA_APP_ID = compatibilityContract.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
}
+5 -1
View File
@@ -2333,9 +2333,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 }
| {
@@ -2358,6 +2361,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 = {