diff --git a/src/main/runtime/mobile-android-release-feed.test.ts b/src/main/runtime/mobile-android-release-feed.test.ts new file mode 100644 index 00000000000..b01fbfcab07 --- /dev/null +++ b/src/main/runtime/mobile-android-release-feed.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { extractLatestAndroidVersion } from './mobile-android-release-feed' + +describe('extractLatestAndroidVersion', () => { + it('picks the newest stable mobile-android tag', () => { + const version = extractLatestAndroidVersion([ + { tag_name: 'mobile-android-v0.0.30' }, + { tag_name: 'mobile-android-v0.0.31' }, + { tag_name: 'mobile-android-v0.0.9' }, + { tag_name: 'v1.4.150' }, // desktop release — ignored + { tag_name: 'mobile-ios-v0.0.31' } // iOS is never sourced here + ]) + // Why: numeric compare, so 0.0.31 beats 0.0.9 (string compare would invert). + expect(version).toBe('0.0.31') + }) + + it('skips drafts and prereleases', () => { + const version = extractLatestAndroidVersion([ + { tag_name: 'mobile-android-v0.0.40', prerelease: true }, + { tag_name: 'mobile-android-v0.0.41', draft: true }, + { tag_name: 'mobile-android-v0.0.31' } + ]) + expect(version).toBe('0.0.31') + }) + + it('rejects non-semver and malformed tags, failing open to null', () => { + expect( + extractLatestAndroidVersion([ + { tag_name: 'mobile-android-vlatest' }, + { tag_name: 'mobile-android-v0.0' }, + { tag_name: 42 }, + {} + ]) + ).toBeNull() + expect(extractLatestAndroidVersion([])).toBeNull() + }) +}) diff --git a/src/main/runtime/mobile-android-release-feed.ts b/src/main/runtime/mobile-android-release-feed.ts new file mode 100644 index 00000000000..cc214ec774d --- /dev/null +++ b/src/main/runtime/mobile-android-release-feed.ts @@ -0,0 +1,117 @@ +import { net } from 'electron' +import { compareVersions, isValidVersion } from '../updater-fallback' + +// Why: the Android app is distributed as an APK from GitHub Releases, so the +// release list IS the source of truth for "latest Android build" — never a +// hand-maintained constant. iOS is deliberately absent: it ships through the +// App Store, which auto-updates, and the hard minimum-version cutoff is handled +// separately by ProtocolBlockScreen, so no soft iOS nudge is derived here. + +const RELEASES_API_URL = 'https://api.github.com/repos/stablyai/orca/releases?per_page=100' +const FETCH_TIMEOUT_MS = 5000 +// Why: a mobile build ships ~monthly, so a long TTL keeps this to a few +// unauthenticated GitHub calls per day per host while staying fresh enough. +const CACHE_TTL_MS = 6 * 60 * 60 * 1000 +// Why: only stable Android tags count as a recommendation; skip anything with a +// prerelease suffix so an rc APK never nudges stable users. +const ANDROID_TAG_RE = /^mobile-android-v(\d+\.\d+\.\d+)$/ + +type ReleaseFeedEntry = { + tag_name?: unknown + draft?: unknown + prerelease?: unknown +} + +type CacheState = { + version: string | null + fetchedAt: number +} + +// Module-level cache shared across every status.get on this host. +let cache: CacheState | null = null +let inFlight: Promise | null = null + +export function extractLatestAndroidVersion(entries: ReleaseFeedEntry[]): string | null { + let latest: string | null = null + for (const entry of entries) { + if (entry.draft === true || entry.prerelease === true) { + continue + } + if (typeof entry.tag_name !== 'string') { + continue + } + const match = entry.tag_name.match(ANDROID_TAG_RE) + const version = match?.[1] + if (!version || !isValidVersion(version)) { + continue + } + if (latest === null || compareVersions(version, latest) > 0) { + latest = version + } + } + return latest +} + +async function fetchLatestAndroidVersion(): Promise { + try { + const response = await net.fetch(RELEASES_API_URL, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'orca-runtime' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (!response.ok) { + return null + } + const body = (await response.json()) as unknown + if (!Array.isArray(body)) { + return null + } + return extractLatestAndroidVersion(body as ReleaseFeedEntry[]) + } catch { + // Why: fail open — an unreachable/rate-limited/offline host simply advertises + // no recommendation, and mobile shows no banner. + return null + } +} + +function refreshInBackground(now: number): void { + if (inFlight) { + return + } + inFlight = fetchLatestAndroidVersion() + .then((version) => { + // Why: keep the last-known version on a failed refresh (null result) so a + // transient outage doesn't drop an already-correct nudge; only the + // timestamp advances, deferring the next retry by one TTL. + cache = { version: version ?? cache?.version ?? null, fetchedAt: now } + }) + .catch(() => { + cache = { version: cache?.version ?? null, fetchedAt: now } + }) + .finally(() => { + inFlight = null + }) +} + +// Why: status.get is a hot, synchronous RPC, so never block it on the network — +// return the cached value and kick a background refresh when stale +// (stale-while-revalidate). First call returns null (no banner) until the first +// fetch lands, which is the correct fail-open default. +export function getRecommendedAndroidVersion(nowMs: number): string | null { + if (cache === null || nowMs - cache.fetchedAt > CACHE_TTL_MS) { + refreshInBackground(nowMs) + } + return cache?.version ?? null +} + +// Test-only: reset module cache between cases. +export function __resetAndroidReleaseFeedCacheForTests(): void { + cache = null + inFlight = null +} + +// Test-only: seed the cache with a fresh value so status.get reads it without +// hitting the network. +export function __setAndroidReleaseFeedCacheForTests(version: string | null): void { + cache = { version, fetchedAt: Date.now() } + inFlight = null +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 4e98215e983..2d1aa48d811 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -21,7 +21,10 @@ import type { WorkspaceSessionState } from '../../shared/types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' -import { RECOMMENDED_MOBILE_APP_VERSIONS } from '../../shared/protocol-version' +import { + __resetAndroidReleaseFeedCacheForTests, + __setAndroidReleaseFeedCacheForTests +} from './mobile-android-release-feed' import { detectAgentStatusFromTitle, MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection' import { addWorktree, @@ -1706,21 +1709,32 @@ describe('OrcaRuntimeService', () => { expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0) }) - it('reports app version and the recommended mobile app version on status', () => { + it('reports app version on status', () => { vi.stubEnv('ORCA_APP_VERSION', '9.9.9-test') try { const status = createRuntime().getStatus() expect(status.appVersion).toBe('9.9.9-test') - expect(status.recommendedMobileAppVersions).toBe(RECOMMENDED_MOBILE_APP_VERSIONS) - // Why: the nudge constants must stay plain semver strings mobile can compare. - for (const version of Object.values(RECOMMENDED_MOBILE_APP_VERSIONS)) { - expect(version).toMatch(/^\d+\.\d+\.\d+$/) - } } finally { vi.unstubAllEnvs() } }) + it('omits the mobile recommendation until the release feed resolves, then reports Android', () => { + __resetAndroidReleaseFeedCacheForTests() + // Why: the feed is fetched lazily and fail-open, so a cold host advertises + // nothing (no banner) rather than a fabricated version. + expect(createRuntime().getStatus().recommendedMobileAppVersions).toBeUndefined() + + // Once the feed has a value, status.get carries only Android — iOS defers to + // the App Store, so it is never populated. + __setAndroidReleaseFeedCacheForTests('0.0.42') + const status = createRuntime().getStatus() + expect(status.recommendedMobileAppVersions).toEqual({ android: '0.0.42' }) + expect(status.recommendedMobileAppVersions?.ios).toBeUndefined() + expect(status.recommendedMobileAppVersions?.android).toMatch(/^\d+\.\d+\.\d+$/) + __resetAndroidReleaseFeedCacheForTests() + }) + it('reports the configured Windows terminal shell on status', () => { const runtime = new OrcaRuntimeService({ ...store, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index cb8667060de..eab18e0d762 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -293,7 +293,6 @@ import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - RECOMMENDED_MOBILE_APP_VERSIONS, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, type RuntimeCapability @@ -409,6 +408,7 @@ import { type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' +import { getRecommendedAndroidVersion } from './mobile-android-release-feed' import { collectMemorySnapshot } from '../memory/collector' import { BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' @@ -3263,7 +3263,10 @@ export class OrcaRuntimeService { // Why: the main process stamps its version into the env at startup; // omit rather than fabricate when running outside that bootstrap (tests). appVersion: process.env.ORCA_APP_VERSION, - recommendedMobileAppVersions: RECOMMENDED_MOBILE_APP_VERSIONS, + // Why: derived live from GitHub Releases (cached, fail-open) instead of a + // hand-bumped constant, so the Android nudge can never go stale; omitted + // entirely when unknown. iOS defers to the App Store (no soft nudge). + recommendedMobileAppVersions: buildRecommendedMobileAppVersions(), protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } @@ -29698,3 +29701,11 @@ function compareWorktreePs( } return left.path.localeCompare(right.path) } + +// Why: only Android has a soft nudge (APK from GitHub Releases); iOS defers to +// App Store auto-update. Returns undefined when the latest Android version is +// unknown so status.get omits the field and mobile shows no banner. +function buildRecommendedMobileAppVersions(): { android: string } | undefined { + const android = getRecommendedAndroidVersion(Date.now()) + return android ? { android } : undefined +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d4ea6ab1b76..ff83d70ce9c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -21,16 +21,13 @@ export const RUNTIME_PROTOCOL_VERSION = 3 export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 +// Why: advisory "update your app" recommendation carried on status.get. Only +// Android is populated — it ships as an APK from GitHub Releases, derived live +// by the desktop (see mobile-android-release-feed.ts) rather than hand-bumped. +// iOS defers to App Store auto-update, so it is optional and normally absent. export type RecommendedMobileAppVersions = { - readonly ios: string - readonly android: string -} - -// Why: iOS and Android ship independently; track the newest version actually -// available on each platform so a release delay never advertises a missing build. -export const RECOMMENDED_MOBILE_APP_VERSIONS: RecommendedMobileAppVersions = { - ios: '0.0.31', - android: '0.0.31' + readonly android?: string + readonly ios?: string } export const PROJECT_HOST_SETUP_RUNTIME_CAPABILITY = 'project-host-setup.v1' as const