feat(mobile): derive Android update recommendation live from GitHub Releases

Replace the hand-bumped RECOMMENDED_MOBILE_APP_VERSIONS constant with a
cached, fail-open lookup of the newest mobile-android-v* GitHub release, so
the soft update nudge can never go stale and needs no per-release bookkeeping.
iOS defers to App Store auto-update (soft) and ProtocolBlockScreen (hard), so
no iOS version is sourced. status.get reads a background-refreshed cache and
never blocks on the network; unknown latest = field omitted = no banner.
This commit is contained in:
Brennan Benson
2026-07-22 13:31:38 -07:00
parent 54bd4e6fdc
commit 287cbb77de
5 changed files with 194 additions and 18 deletions
@@ -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()
})
})
@@ -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<void> | 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<string | null> {
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
}
+21 -7
View File
@@ -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,
+13 -2
View File
@@ -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
}
+6 -9
View File
@@ -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