Skip to content
Merged
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
96 changes: 96 additions & 0 deletions BundleDrop.podspec
Original file line number Diff line number Diff line change
@@ -1,7 +1,85 @@
require "digest"
require "fileutils"
require "json"
require "open3"
require "shellwords"

bundle_drop_native_runtime_identity_resource = lambda do
return nil unless defined?(Pod::Config)

project_root = File.expand_path("..", Pod::Config.instance.installation_root.to_s)
config_path = File.join(project_root, "bundle.drop.config.js")
writer_path = File.join(
__dir__,
"lib",
"CLI",
"scripts",
"native",
"write-runtime-identity.js"
)
unless File.file?(writer_path)
raise Pod::Informative,
"Bundle Drop native runtime identity writer is missing: #{writer_path}"
end

resource_path = File.join(
"ios",
"build",
"generated",
"runtime-identity",
Digest::SHA256.hexdigest(project_root)[0, 16],
"bundle-drop-build-identity.json"
)
output_path = File.join(__dir__, resource_path)

if File.file?(config_path)
stdout, stderr, status = Open3.capture3(
ENV.fetch("NODE_BINARY", "node"),
writer_path,
"--project-root", project_root,
"--platform", "ios",
"--output", output_path
)
unless status.success?
detail = stderr.strip.empty? ? stdout.strip : stderr.strip
raise Pod::Informative,
"Bundle Drop could not generate the iOS runtime identity: #{detail}"
end

identity = JSON.parse(File.read(output_path))
return nil if identity["source"] == "expo"
unless identity["runtimeVersion"].is_a?(String) && !identity["runtimeVersion"].empty?
raise Pod::Informative, "Bundle Drop generated an invalid iOS runtime identity."
end
else
# CocoaPods evaluates the podspec before `bundle-drop login` in the documented
# fresh-install flow. Keep the resource and build phase in the Pods project so
# the first native build can replace this inert placeholder after setup.
FileUtils.mkdir_p(File.dirname(output_path))
File.write(
output_path,
JSON.generate({
"schemaVersion" => 1,
"platform" => "ios",
"source" => "unconfigured"
}) + "\n"
)
end
resource_path
end

package = JSON.parse(File.read(File.join(__dir__, "package.json")))
native_version = package["nativeVersion"] || package["version"]
native_runtime_identity_resource = bundle_drop_native_runtime_identity_resource.call
native_runtime_identity_project_root = if native_runtime_identity_resource
File.expand_path("..", Pod::Config.instance.installation_root.to_s)
end
native_runtime_identity_writer = if native_runtime_identity_resource
File.join(__dir__, "lib", "CLI", "scripts", "native", "write-runtime-identity.js")
end
native_runtime_identity_output = if native_runtime_identity_resource
File.join(__dir__, native_runtime_identity_resource)
end

Pod::Spec.new do |s|
s.name = "BundleDrop"
Expand All @@ -15,6 +93,24 @@ Pod::Spec.new do |s|
s.source = { :git => ".git", :tag => "#{s.version}" }

s.source_files = "ios/**/*.{h,m,mm,swift}", "third_party/xdelta/**/*.{c,h}"
s.resources = native_runtime_identity_resource if native_runtime_identity_resource
if native_runtime_identity_resource
# Intentionally omit output files so Xcode reruns this when a bare app builds,
# even when CocoaPods has not been reinstalled since its config changed.
s.script_phase = {
:name => "Regenerate Bundle Drop runtime identity",
:execution_position => :before_compile,
:show_env_vars_in_log => "0",
:script => <<-SCRIPT
set -e
"${NODE_BINARY:-node}" \
#{Shellwords.escape(native_runtime_identity_writer)} \
--project-root #{Shellwords.escape(native_runtime_identity_project_root)} \
--platform ios \
--output #{Shellwords.escape(native_runtime_identity_output)}
SCRIPT
}
end
s.public_header_files = "ios/BundleDropLocator.h", "ios/BundleDropZipExtractor.h"
s.private_header_files = "third_party/xdelta/**/*.h"
s.pod_target_xcconfig = {
Expand Down
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ let package = Package(
"BundleDropBundleVerifier.swift",
"BundleDropLocator.swift",
"BundleDropOtaResolver.swift",
"BundleDropStartupRecovery.swift",
"BundleDropStartupRecoveryAdapter.swift",
"BundleDropRuntimeCrypto.swift",
]
),
Expand Down
43 changes: 34 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,14 @@ npx bundle-drop doctor
Apps upgrading from an inline `runtimeDelivery` block or a direct Metro alias should
remove that stale block and run `npx bundle-drop init` once to install the
package-managed Metro wrapper. Inline delivery data is ignored: the validated
generated bootstrap is the sole trust source. After that one-time migration, `sync`
runtime-delivery lockfile is the sole trust source. After that one-time migration, `sync`
is the narrow command for refreshing trust data.

The generated bootstrap is not a secret and should be committed. Setup keeps the
The runtime-delivery lockfile is not a secret and should be committed. Setup keeps the
generated Metro wrapper, build receipts, and other transient `.bundle-drop` files
ignored while allowing `.bundle-drop/runtime-delivery.generated.json` into source
control.
ignored while allowing `.bundle-drop/runtime-delivery.lock.json` into source
control. Projects with the former `runtime-delivery.generated.json` filename remain
readable; run `bundle-drop sync` to migrate and remove that legacy file.

### Manual setup without AI planning

Expand Down Expand Up @@ -189,9 +190,9 @@ npx bundle-drop sync
npx bundle-drop doctor
```

`sync` creates `.bundle-drop/runtime-delivery.generated.json`, recreates it if it or
`sync` creates `.bundle-drop/runtime-delivery.lock.json`, recreates it if it or
the entire `.bundle-drop` directory was deleted, and repairs the corresponding
`.gitignore` rules. The bootstrap contains public identity and verification material,
`.gitignore` rules. The lockfile contains public identity and verification material,
not secrets, so commit it with the application.

## Initialize the Runtime
Expand Down Expand Up @@ -320,6 +321,30 @@ example, an iOS-only native change should bump `runtimeVersion.ios`; Android can
its existing value. This makes the compatibility boundary explicit and prevents an
update from reaching a binary that cannot run it.

## Native Startup Recovery

Bundle Drop records every OTA startup attempt in native storage before React Native
receives the bundle path. If an attempt does not reach its configured health boundary,
the next distinct app launch counts it as incomplete. The candidate is retried until
`rollback.maxCrashCount` is reached, then quarantined locally and replaced with the
previous native-proven healthy OTA bundle. If that bundle is missing, corrupt,
incompatible, or locally revoked, startup falls back to the bundle embedded in the app.

Automatic health waits for React content to appear and then applies
`rollback.healthyAfterSec`. Apps with a stronger readiness boundary can opt into
`healthCheckMode: 'manual'` and call `BundleDrop.reportHealthy()` after hydration,
migrations, authentication bootstrap, or navigation setup completes. Health is committed
against the exact native launch attempt, so a delayed callback from an older React runtime
cannot approve a newer launch.

Recovery is local and offline; it never waits for a network request during startup. With
`maxCrashCount: 0`, launch-health counting and automatic crash-loop rollback are disabled,
while integrity, runtime compatibility, quarantine, and locally persisted revocation checks
still apply. Native startup recovery requires a binary built with the corresponding SDK
`nativeVersion`; it cannot be added to an older installed binary through OTA JavaScript.
Crashes after an attempt is healthy do not trigger this rollback path and should use normal
crash reporting plus fix-forward or explicit rollback controls.

## Managed Runtime Delivery

Runtime delivery is package-managed. `bundle.drop.config.js` stays focused on
Expand All @@ -330,14 +355,14 @@ a delivery-mode switch.
`bundle-drop login` and `bundle-drop init` synchronize the trust bootstrap during
setup. `bundle-drop sync` performs the same narrow operation later for repair or key
rotation. Each command validates authenticated project credentials and writes the
identity-bound `.bundle-drop/runtime-delivery.generated.json`. Metro confirms that
identity-bound `.bundle-drop/runtime-delivery.lock.json`. Metro confirms that
the bootstrap belongs to the same server, organization, and project before merging
it into the runtime module. Malformed, copied, unsupported, or private-key-bearing
data is rejected.

Older apps with an inline `runtimeDelivery` block keep their ordinary project
configuration, but the inline block is ignored and should be removed during
migration. Only the identity-bound generated bootstrap can enable managed delivery.
migration. Only the identity-bound runtime-delivery lockfile can enable managed delivery.
If the server explicitly disables delivery for a project, synchronization removes a
stale bootstrap and the SDK continues through the compatible `/ota/resolve` path.

Expand Down Expand Up @@ -529,7 +554,7 @@ Import the runtime API from `@gfean/react-native-bundle-drop`.
| `BundleDrop.init(options)` | Initialize the runtime for the app process. |
| `BundleDrop.setChannel(name)` / `setChannel` | Change the active channel for singleton actions. |
| `BundleDrop.getChannelName()` / `getChannelName` | Read the active channel. |
| `BundleDrop.reportHealthy()` / `reportHealthy` | Mark the running OTA candidate healthy for this device. |
| `BundleDrop.reportHealthy()` / `reportHealthy` | In manual health mode, ask native recovery to mark the exact running OTA attempt healthy. |

**`BundleDrop.init(options)`**

Expand Down
65 changes: 65 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
import javax.inject.Inject
import org.gradle.process.ExecOperations

abstract class BundleDropNativeIdentityCommandExecutor {
@Inject
abstract ExecOperations getExecOperations()

void execute(File workingDirectory, String executable, List<?> arguments) {
execOperations.exec { spec ->
spec.workingDir workingDirectory
spec.executable executable
spec.args arguments
}.assertNormalExitValue()
}
}

buildscript {
ext.getExtOrDefault = {name ->
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['BundleDrop_' + name]
Expand Down Expand Up @@ -88,6 +104,49 @@ android {
}
}

if (!project.hasProperty("standalone") && rootProject.findProject(":bundledrop-expo") == null) {
def bundleDropProjectRoot = rootProject.projectDir.parentFile.canonicalFile
def bundleDropConfigFile = new File(bundleDropProjectRoot, "bundle.drop.config.js")
if (bundleDropConfigFile.isFile()) {
def bundleDropIdentityWriter = file(
"../lib/CLI/scripts/native/write-runtime-identity.js"
).canonicalFile
if (!bundleDropIdentityWriter.isFile()) {
throw new GradleException(
"Bundle Drop native runtime identity writer is missing: ${bundleDropIdentityWriter}"
)
}
def bundleDropGeneratedAssets = file("${buildDir}/generated/bundleDropIdentity/assets")
def bundleDropIdentityOutput = new File(
bundleDropGeneratedAssets,
"bundle-drop/build-identity.json"
)
android.sourceSets.main.assets.srcDir(bundleDropGeneratedAssets)
def bundleDropCommandExecutor = objects.newInstance(
BundleDropNativeIdentityCommandExecutor
)
def generateBundleDropNativeRuntimeIdentity = tasks.register(
"generateBundleDropNativeRuntimeIdentity"
) { task ->
task.inputs.file(bundleDropConfigFile)
task.inputs.file(bundleDropIdentityWriter)
task.outputs.file(bundleDropIdentityOutput)
task.doLast {
def nodeBinary = System.getenv("NODE_BINARY") ?: "node"
bundleDropCommandExecutor.execute(bundleDropProjectRoot, nodeBinary, [
bundleDropIdentityWriter.path,
"--project-root", bundleDropProjectRoot.path,
"--platform", "android",
"--output", bundleDropIdentityOutput.path
])
}
}
tasks.named("preBuild").configure { task ->
task.dependsOn(generateBundleDropNativeRuntimeIdentity)
}
}
}

afterEvaluate {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinTask ->
def javaTask = tasks.findByName(kotlinTask.name.replace("Kotlin", "JavaWithJavac"))
Expand Down Expand Up @@ -200,6 +259,12 @@ if (project.hasProperty("standalone")) {
limit { counter = "LINE"; minimum = 0.90 }
limit { counter = "BRANCH"; minimum = 0.85 }
}
rule {
element = "CLASS"
includes = ["com.bundledrop.BundleDropStartupRecoveryController"]
limit { counter = "LINE"; minimum = 0.90 }
limit { counter = "BRANCH"; minimum = 0.70 }
}
rule {
element = "CLASS"
includes = ["com.bundledrop.BundleDropFileOps"]
Expand Down
Loading