Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,303 changes: 1,290 additions & 13 deletions bun.lock

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions cli/src/bundle/partial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { parse } from '@std/semver'
import * as micromatch from 'micromatch'
import * as tus from 'tus-js-client'
import { encryptChecksum, encryptChecksumV3, encryptSource } from '../api/crypto'
import { BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, findRoot, generateManifest, getContentType, getInstalledVersion, getLocalConfig, isDeprecatedPluginVersion, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils'
import { BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, findRoot, generateManifest, getCapgoUpdaterPackageVersion, getContentType, getLocalConfig, isDeprecatedPluginVersion, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils'

// Check if file already exists on server (bypass cache and force storage lookup)
async function fileExists(localConfig: any, filename: string): Promise<boolean> {
Expand Down Expand Up @@ -42,16 +42,23 @@ const BROTLI_MIN_SIZE = 8192
// Check if the updater version supports .br extension
async function getUpdaterVersion(uploadOptions: OptionsUpload): Promise<{ version: string | null, supportsBrotliV2: boolean }> {
const root = findRoot(cwd())
const updaterVersion = await getInstalledVersion('@capgo/capacitor-updater', root, uploadOptions.packageJson)
const updater = await getCapgoUpdaterPackageVersion(root, uploadOptions.packageJson)
if (!updater)
return { version: null, supportsBrotliV2: false }

// React Native updater ships with Capgo file-level delta + brotli support.
if (updater.kind === 'react-native')
return { version: updater.version, supportsBrotliV2: true }

let coerced
try {
coerced = updaterVersion ? parse(updaterVersion) : undefined
coerced = parse(updater.version)
}
catch {
coerced = undefined
}

if (!updaterVersion || !coerced)
if (!coerced)
return { version: null, supportsBrotliV2: false }

// Brotli is supported in updater versions >= 5.10.0 (v5), >= 6.25.0 (v6) or >= 7.0.35 (v7)
Expand Down Expand Up @@ -210,7 +217,7 @@ export async function uploadPartial(

// Check for incompatible options with older updater versions
if (!supportsBrotliV2) {
throw new Error(`Your project is using an older version of @capgo/capacitor-updater (${version || 'unknown'}). To use Delta updates, please upgrade to version ${BROTLI_MIN_UPDATER_VERSION_V5} (v5), ${BROTLI_MIN_UPDATER_VERSION_V6} (v6) or ${BROTLI_MIN_UPDATER_VERSION_V7} (v7) or higher.`)
throw new Error(`Your project is using an older Capgo updater package (${version || 'unknown'}). To use Delta updates, please upgrade to version ${BROTLI_MIN_UPDATER_VERSION_V5} (v5), ${BROTLI_MIN_UPDATER_VERSION_V6} (v6) or ${BROTLI_MIN_UPDATER_VERSION_V7} (v7) or higher.`)
}
else {
// Only newer versions can use Brotli with .br extension
Expand Down
26 changes: 20 additions & 6 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Compatibility, manifestType } from '../utils'
import type { OptionsUpload } from './upload_interface'
import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { cwd } from 'node:process'
import { S3Client } from '@bradenmacdonald/s3-lite-client'
import { intro, log, outro, confirm as pConfirm, isCancel as pIsCancel, select as pSelect, spinner as spinnerC } from '@clack/prompts'
Expand All @@ -23,7 +24,7 @@ import { confirmWithRememberedChoice } from '../promptPreferences'
import { showReplicationProgress } from '../replicationProgress'
import { formatTable } from '../terminal-table'
import { usesAlwaysDirectUpdate } from '../updaterConfig'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, isCompatible, isDeprecatedPluginVersion, regexSemver, resolveUserIdFromApiKey, sendEvent, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getCapgoUpdaterPackageVersion, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, isCompatible, isDeprecatedPluginVersion, regexSemver, resolveUserIdFromApiKey, sendEvent, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
import { getVersionSuggestions, interactiveVersionBump } from '../versionHelpers'
import { maybePromptBuilderCta, shouldBlockIncompatibleUpload } from './builder-cta'
import { checkIndexPosition, searchInDirectory } from './check'
Expand Down Expand Up @@ -126,10 +127,18 @@ function getAppIdAndPath(appId: string | undefined, options: OptionsUpload, conf
return { appid: finalAppId, path }
}

function isReactNativeExportFolder(path: string) {
return existsSync(join(path, 'index.android.bundle')) || existsSync(join(path, 'main.jsbundle'))
}

function checkNotifyAppReady(options: OptionsUpload, path: string) {
const checkNotifyAppReady = options.codeCheck

if (typeof checkNotifyAppReady === 'undefined' || checkNotifyAppReady) {
// React Native Metro exports do not embed notifyAppReady / index.html in the bundle folder.
if (isReactNativeExportFolder(path))
return

const isPluginConfigured = searchInDirectory(path, 'notifyAppReady')
if (!isPluginConfigured) {
uploadFail(`notifyAppReady() is missing in the build folder of your app. see: https://capgo.app/docs/plugin/api/#notifyappready
Expand Down Expand Up @@ -412,7 +421,8 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s
zipped = await zipFile(path)
s.message(`Calculating checksum`)
const root = findRoot(cwd())
const updaterVersion = await getInstalledVersion('@capgo/capacitor-updater', root, options.packageJson)
const updaterPackage = await getCapgoUpdaterPackageVersion(root, options.packageJson)
const updaterVersion = updaterPackage?.version ?? null
let useSha256 = false
let coerced
try {
Expand All @@ -421,15 +431,19 @@ async function prepareBundleFile(path: string, options: OptionsUpload, apikey: s
catch {
coerced = undefined
}
if (!updaterVersion) {
uploadFail('Cannot find @capgo/capacitor-updater in node_modules, please install it first with your package manager')
if (!updaterPackage) {
uploadFail('Cannot find @capgo/capacitor-updater or @capgo/react-native-updater in node_modules, please install one first with your package manager')
}
else if (updaterPackage.kind === 'react-native') {
// RN updater uses Capgo file-level delta + SHA256 from day one
useSha256 = true
}
else if (coerced) {
// Use SHA256 for v5.10.0+, v6.25.0+ and v7.0.30+
useSha256 = !isDeprecatedPluginVersion(coerced, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7)
}
else if (updaterVersion === 'link:@capgo/capacitor-updater' || updaterVersion === 'file:..' || updaterVersion === 'file:../') {
log.warn('Using local @capgo/capacitor-updater. Assuming latest version for checksum calculation.')
else if (updaterVersion === 'link:@capgo/capacitor-updater' || updaterVersion === 'file:..' || updaterVersion === 'file:../' || updaterVersion === 'link:@capgo/react-native-updater') {
log.warn(`Using local ${updaterPackage.packageName}. Assuming latest version for checksum calculation.`)
useSha256 = true
}
const forceCrc32 = options.forceCrc32Checksum === true
Expand Down
71 changes: 71 additions & 0 deletions cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,77 @@ function returnVersion(version: string) {
* @param rootDir - The root directory of the project
* @param packageJsonPath - Optional custom package.json path provided by user (takes priority if provided)
*/

export const CAPGO_CAPACITOR_UPDATER_PACKAGE = '@capgo/capacitor-updater'
export const CAPGO_RN_UPDATER_PACKAGE = '@capgo/react-native-updater'

export interface CapgoUpdaterPackageVersion {
packageName: string
version: string
kind: 'capacitor' | 'react-native'
}

/**
* Resolve either Capacitor or React Native Capgo updater package version.
* RN updater always supports file-level delta + brotli (shipped with those features).
*/
function readPackageDependencyNames(rootDir: string, packageJsonPath?: string): Set<string> {
const names = new Set<string>()
const candidates = packageJsonPath
? packageJsonPath.split(',').map(p => p.trim()).filter(Boolean).map(p => resolve(rootDir, p))
: [resolve(rootDir, 'package.json')]
for (const file of candidates) {
if (!existsSync(file))
continue
try {
const pkg = JSON.parse(readFileSync(file, 'utf-8')) as {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
}
for (const bucket of [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies]) {
if (!bucket)
continue
for (const name of Object.keys(bucket))
names.add(name)
}
}
catch {
// ignore invalid package.json
}
}
return names
}

export async function getCapgoUpdaterPackageVersion(
rootDir: string = cwd(),
packageJsonPath?: string,
): Promise<CapgoUpdaterPackageVersion | null> {
const declared = readPackageDependencyNames(rootDir, packageJsonPath)
const prefersReactNative = declared.has(CAPGO_RN_UPDATER_PACKAGE) && !declared.has(CAPGO_CAPACITOR_UPDATER_PACKAGE)
const prefersCapacitor = declared.has(CAPGO_CAPACITOR_UPDATER_PACKAGE)

if (prefersReactNative) {
const reactNative = await getInstalledVersion(CAPGO_RN_UPDATER_PACKAGE, rootDir, packageJsonPath)
if (reactNative) {
return { packageName: CAPGO_RN_UPDATER_PACKAGE, version: reactNative, kind: 'react-native' }
}
}

if (prefersCapacitor || !prefersReactNative) {
const capacitor = await getInstalledVersion(CAPGO_CAPACITOR_UPDATER_PACKAGE, rootDir, packageJsonPath)
if (capacitor) {
return { packageName: CAPGO_CAPACITOR_UPDATER_PACKAGE, version: capacitor, kind: 'capacitor' }
}
}

const reactNative = await getInstalledVersion(CAPGO_RN_UPDATER_PACKAGE, rootDir, packageJsonPath)
if (reactNative) {
return { packageName: CAPGO_RN_UPDATER_PACKAGE, version: reactNative, kind: 'react-native' }
}
return null
}

export async function getInstalledVersion(packageName: string, rootDir: string = cwd(), packageJsonPath?: string): Promise<string | null> {
const providedPackageJsonFiles = packageJsonPath
? packageJsonPath
Expand Down
27 changes: 27 additions & 0 deletions cli/test/test-rn-updater-version.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

// Dynamic import after build; for source we import ts via bun
const { getCapgoUpdaterPackageVersion } = await import('../src/utils.ts')

describe('getCapgoUpdaterPackageVersion', () => {
test('detects react-native updater package', async () => {
const dir = mkdtempSync(join(tmpdir(), 'capgo-rn-'))
try {
mkdirSync(join(dir, 'node_modules', '@capgo', 'react-native-updater'), { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'app', dependencies: { '@capgo/react-native-updater': '0.1.0' } }))
writeFileSync(
join(dir, 'node_modules', '@capgo', 'react-native-updater', 'package.json'),
JSON.stringify({ name: '@capgo/react-native-updater', version: '0.1.0' }),
)
const result = await getCapgoUpdaterPackageVersion(dir)
expect(result?.kind).toBe('react-native')
expect(result?.version).toBe('0.1.0')
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
"cli:typecheck": "bun run --cwd cli typecheck",
"cli:test": "bun run --cwd cli test",
"cli:check": "bun run cli:lint && bun run cli:typecheck && bun run cli:build && bun run cli:test",
"rn-cli:test": "bun run --cwd packages/rn-cli test",
"rn-updater:test": "bun run --cwd packages/react-native-updater test",
"cli:test:capgo": "bun run test:cli",
"api:dev": "wrangler dev -c cloudflare_workers/api/wrangler.jsonc --env=dev --env-file=internal/cloudflare/.env.local",
"plugin:dev": "wrangler dev -c cloudflare_workers/plugin/wrangler.jsonc --env=dev --env-file=internal/cloudflare/.env.local",
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native-updater/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
lib/
node_modules/
android/build/
ios/build/
*.tsbuildinfo
17 changes: 17 additions & 0 deletions packages/react-native-updater/CapgoReactNativeUpdater.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require "json"

package = JSON.parse(File.read(File.join(__dir__, "package.json")))

Pod::Spec.new do |s|
s.name = "CapgoReactNativeUpdater"
s.version = package["version"]
s.summary = package["description"]
s.homepage = package["homepage"]
s.license = package["license"]
s.authors = { "Capgo" => "martin@capgo.app" }
s.platforms = { :ios => "13.0" }
s.source = { :git => "https://github.com/Cap-go/capgo.git", :tag => "rn-updater-#{s.version}" }
s.source_files = "ios/**/*.{h,m,mm,swift}"
s.dependency "React-Core"
s.swift_version = "5.0"
end
70 changes: 70 additions & 0 deletions packages/react-native-updater/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# @capgo/react-native-updater

Capgo live updates for React Native. Uses the same Capgo Cloud backend and **file-level delta** system as `@capgo/capacitor-updater` (per-file SHA-256 manifests + optional Brotli), not binary bspatch.

## Install

```bash
npm install @capgo/react-native-updater
cd ios && pod install
```

## Native wiring

### Android

In `MainApplication`, override `getJSBundleFile()`:

```kotlin
override fun getJSBundleFile(): String {
return CapgoUpdater.getJSBundleFile(applicationContext)
}
```

Add meta-data in `AndroidManifest.xml`:

```xml
<meta-data android:name="CapgoAppId" android:value="com.example.app" />
<meta-data android:name="CapgoUpdateUrl" android:value="https://plugin.capgo.app/updates" />
<meta-data android:name="CapgoStatsUrl" android:value="https://plugin.capgo.app/stats" />
```

### iOS

In `AppDelegate`, return Capgo's bundle URL in release:

```swift
#if !DEBUG
return CapgoUpdater.getJSBundleURL()
#endif
```
Comment on lines +34 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fall back to the packaged bundle URL.

CapgoUpdater.getJSBundleURL() returns nil for "builtin" (CapgoUpdater.swift, Lines 27-28). Returning it directly can leave first installs and reset installations without a release bundle.

Proposed documentation fix
 `#if` !DEBUG
-return CapgoUpdater.getJSBundleURL()
+return CapgoUpdater.getJSBundleURL()
+  ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
 `#endif`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
In `AppDelegate`, return Capgo's bundle URL in release:
```swift
#if !DEBUG
return CapgoUpdater.getJSBundleURL()
#endif
```
In `AppDelegate`, return Capgo's bundle URL in release:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-updater/README.md` around lines 34 - 40, Update the
AppDelegate release bundle URL guidance to fall back to the packaged bundle URL
when CapgoUpdater.getJSBundleURL() returns nil, preserving the Capgo URL when
available. Reference CapgoUpdater.getJSBundleURL() and the existing
packaged-bundle URL implementation, and ensure first and reset installations
always return a valid release bundle.


Add `CapgoAppId`, `CapgoUpdateUrl`, and `CapgoStatsUrl` to `Info.plist`.

## JS usage

```ts
import { CapgoUpdater } from '@capgo/react-native-updater'

await CapgoUpdater.notifyAppReady()

const latest = await CapgoUpdater.getLatest()
if (latest.url || latest.manifest?.length) {
const bundle = await CapgoUpdater.download({
url: latest.url ?? 'https://404.capgo.app/no.zip',
version: latest.version,
sessionKey: latest.sessionKey,
checksum: latest.checksum ?? undefined,
manifest: latest.manifest,
})
await CapgoUpdater.set({ id: bundle.id })
}
```

## Upload with CLI

```bash
npx @capgo/rn-cli@latest upload appId --channel production
```

This runs Metro, exports `index.android.bundle` + `main.jsbundle` + assets, then uploads with Capgo `--delta`.
38 changes: 38 additions & 0 deletions packages/react-native-updater/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
buildscript {
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}

apply plugin: "com.android.library"
apply plugin: "org.jetbrains.kotlin.android"

android {
namespace "app.capgo.rnupdater"
compileSdkVersion safeExtGet("compileSdkVersion", 34)

defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}

sourceSets {
main {
java.srcDirs = ["src/main/java"]
}
}
}

// NOSONAR text:S8569 - library pins versions; consuming app owns Gradle dependency locking

@cubic-dev-ai cubic-dev-ai Bot Jul 16, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This comment does not suppress the intended text:S8569 finding: NOSONAR applies only to issues raised on the comment's own line, not subsequent Gradle declarations. Consider a scoped sonar.issue.ignore.multicriteria entry for this rule and file instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-native-updater/android/build.gradle, line 26:

<comment>This comment does not suppress the intended `text:S8569` finding: `NOSONAR` applies only to issues raised on the comment's own line, not subsequent Gradle declarations. Consider a scoped `sonar.issue.ignore.multicriteria` entry for this rule and file instead.</comment>

<file context>
@@ -23,6 +23,7 @@ android {
   }
 }
 
+// NOSONAR text:S8569 - library pins versions; consuming app owns Gradle dependency locking
 configurations.configureEach {
   resolutionStrategy {
</file context>
Fix with cubic

configurations.configureEach {
resolutionStrategy {
force "com.squareup.okhttp3:okhttp:4.12.0"
force "org.brotli:dec:0.1.2"
}
}

dependencies {
implementation "com.facebook.react:react-android"
implementation "org.brotli:dec:0.1.2"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
}
1 change: 1 addition & 0 deletions packages/react-native-updater/android/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
android.useAndroidX=true
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.capgo.rnupdater">
</manifest>
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Declare the network permission required by the updater.

The module performs HTTP downloads, but its merged manifest contributes no android.permission.INTERNET; host apps that do not already declare it cannot check or download updates.

Proposed fix
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="app.capgo.rnupdater">
+  <uses-permission android:name="android.permission.INTERNET" />
 </manifest>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.capgo.rnupdater">
</manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="app.capgo.rnupdater">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-native-updater/android/src/main/AndroidManifest.xml` around
lines 1 - 3, Add the android.permission.INTERNET uses-permission declaration to
the AndroidManifest manifest so the updater can perform HTTP update checks and
downloads when the host application does not declare network access.

Loading
Loading