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

- + {releaseUrl && ( + + )} + {showReleaseNotes && ( + + )}
@@ -918,7 +957,7 @@ function ErrorCardContent({ summary: string explainer?: string detail?: string - releaseUrl: string + releaseUrl?: string manualLabel?: string primaryAction?: { label: string @@ -1018,14 +1057,16 @@ function ErrorCardContent({ : primaryAction.label} )} - + {releaseUrl && ( + + )}
) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 0e4b1675223..b9e6635f36e 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -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.' )}{' '} - - {translate( - 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', - 'Release notes' - )} - + {updateStatus.source !== 'local' && ( + + {translate( + 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', + 'Release notes' + )} + + )} )} {updateStatus.state === 'not-available' && @@ -206,20 +208,22 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { 'auto.components.settings.GeneralUpdateSettingsSection.d89806cc89', 'is ready to install.' )}{' '} - - {translate( - 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', - 'Release notes' - )} - + {updateStatus.source !== 'local' && ( + + {translate( + 'auto.components.settings.GeneralUpdateSettingsSection.8a52ca1d02', + 'Release notes' + )} + + )} )} {updateStatus.state === 'error' && diff --git a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx index 1c6e282be74..9fee62dde2a 100644 --- a/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx @@ -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 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f1a8649e2d1..aff168275ff 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1894,7 +1894,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "Move", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0af2565aa6a..aa923f40785 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1871,7 +1871,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "Mover", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 687ef61b746..08a59b9ac77 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1871,7 +1871,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "移動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ba9b3574f0f..d78d8646753 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1871,7 +1871,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "이동", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 456b7aa4b7e..fc71363f6a7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1871,7 +1871,12 @@ "c9ff9b9ec2": "Check official releases", "a05992a26b": "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases.", "5194358929": "Hide details", - "8bc9e17d8f": "Show details" + "8bc9e17d8f": "Show details", + "8cf17b10af": "Local Build Error", + "a4650b0dc4": "Could Not Use Local Build", + "b1e390250d": "Could not complete the local build switch.", + "d29740d175": "The selected build could not be used.", + "37d45c9ec1": "Choose Another Build" }, "WorktreeJumpPalette": { "ac037cfac2": "移动", diff --git a/src/renderer/src/lib/update-check-click-options.test.ts b/src/renderer/src/lib/update-check-click-options.test.ts index 02f66401301..3bb19ea150e 100644 --- a/src/renderer/src/lib/update-check-click-options.test.ts +++ b/src/renderer/src/lib/update-check-click-options.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest' import { getUpdateCheckClickOptions, getUpdateCheckHint } from './update-check-click-options' -function clickEvent(overrides: Partial>) { +function clickEvent( + overrides: Partial> +) { return { + altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...overrides - } as Pick + } as Pick } 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.' diff --git a/src/renderer/src/lib/update-check-click-options.ts b/src/renderer/src/lib/update-check-click-options.ts index 2ee4191c34b..1734c4f1968 100644 --- a/src/renderer/src/lib/update-check-click-options.ts +++ b/src/renderer/src/lib/update-check-click-options.ts @@ -1,7 +1,7 @@ import type { UpdateCheckOptions } from '../../../shared/types' import { getShortcutPlatform } from './shortcut-platform' -type UpdateCheckClickEvent = Pick +type UpdateCheckClickEvent = Pick 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 diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json new file mode 100644 index 00000000000..8c2bb743b44 --- /dev/null +++ b/src/shared/local-build-compatibility-contract.json @@ -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 + ] +} diff --git a/src/shared/local-build-compatibility.test.ts b/src/shared/local-build-compatibility.test.ts new file mode 100644 index 00000000000..45f9a21ae33 --- /dev/null +++ b/src/shared/local-build-compatibility.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + getLocalBuildCompatibilityError, + parseLocalBuildCompatibility, + type LocalBuildCompatibility +} from './local-build-compatibility' + +function target(overrides: Partial = {}): 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') + }) +}) diff --git a/src/shared/local-build-compatibility.ts b/src/shared/local-build-compatibility.ts new file mode 100644 index 00000000000..0bd152cbf07 --- /dev/null +++ b/src/shared/local-build-compatibility.ts @@ -0,0 +1,89 @@ +import compatibilityContract from './local-build-compatibility-contract.json' + +export const LOCAL_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json' +export const LOCAL_BUILD_COMPATIBILITY_FORMAT_VERSION = compatibilityContract.formatVersion +export const ORCA_APP_ID = compatibilityContract.appId + +export type LocalBuildArchitecture = 'arm64' | 'x64' + +export type LocalBuildCompatibility = { + formatVersion: number + appId: string + buildId: string + version: string + commit: string + stateSchemaVersion: number + readableStateSchemaVersions: number[] + daemonProtocolVersion: number + attachableDaemonProtocolVersions: number[] + platform: 'darwin' + architecture: LocalBuildArchitecture +} + +function isIntegerArray(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.length <= 64 && + value.every((entry) => Number.isSafeInteger(entry) && entry > 0) + ) +} + +export function parseLocalBuildCompatibility(value: unknown): LocalBuildCompatibility { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The selected build has invalid compatibility metadata.') + } + const record = value as Record + 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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 6f2b1c1d5c1..fbf05a5c00e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2333,9 +2333,12 @@ export type ChangelogData = { export type UpdateCheckOptions = { includePrerelease?: boolean includePerfPrerelease?: boolean + localBuild?: boolean } -export type UpdateStatus = +export type UpdateSource = 'local' + +export type UpdateStatus = ( | { state: 'idle' } | { state: 'checking'; userInitiated?: boolean } | { @@ -2358,6 +2361,7 @@ export type UpdateStatus = | { state: 'downloading'; percent: number; version: string; activeNudgeId?: string } | { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string } | { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string } +) & { source?: UpdateSource } // ─── Settings ──────────────────────────────────────────────────────── export type NotificationSettings = {