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:
parent
97cb32c1cc
commit
10ca89ac8b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }]
|
||||
])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "移動",
|
||||
|
|
|
|||
|
|
@ -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": "이동",
|
||||
|
|
|
|||
|
|
@ -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": "移动",
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue