mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
@@ -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 +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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user