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

- + {releaseUrl && ( + + )} + {showReleaseNotes && ( + + )}
@@ -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} )} - + {releaseUrl && ( + + )}
) diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 0e4b16752..b9e6635f3 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 1c6e282be..9fee62dde 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 f5cf363fb..44b4124a0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e8158d266..8e6783cf3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 26922f91d..1448421ca 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "移動", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e80c196ab..22a54ebb5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "이동", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b88d6bf99..390037d42 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "移动", 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 02f664013..3bb19ea15 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 2ee4191c3..1734c4f19 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 000000000..8c2bb743b --- /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-contract.ts b/src/shared/local-build-compatibility-contract.ts new file mode 100644 index 000000000..f05be487d --- /dev/null +++ b/src/shared/local-build-compatibility-contract.ts @@ -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 diff --git a/src/shared/local-build-compatibility.test.ts b/src/shared/local-build-compatibility.test.ts new file mode 100644 index 000000000..45f9a21ae --- /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 000000000..203ffe464 --- /dev/null +++ b/src/shared/local-build-compatibility.ts @@ -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 + 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 e34ed5bc2..cb63444b4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = {