<p align="center"><img src="logo.png" width="88" alt="Pulse Updates" /></p>
<h1 align="center">Pulse Updates — React Native SDK</h1>
<p align="center">
  <b>Lightweight over-the-air (OTA) updates for React Native.</b><br/>
  Ship JavaScript &amp; assets instantly — a simpler, self-hostable alternative to expo-updates,
  with full New Architecture (Fabric/Bridgeless) support and Ed25519 signature verification.
</p>

---

## Features

- Full support for React Native New Architecture (Bridgeless mode)
- Automatic asset resolution with embedded fallback
- Hermes bytecode compilation for faster startup
- Incremental updates (only changed assets are downloaded)
- Rollback protection with health checks
- Channel-based deployments (production, staging, etc.)
- Compatible with expo-asset resolution

## Installation

```bash
npm install pulse-updates
# or
yarn add pulse-updates
```

### iOS Setup

Add to your `Podfile`:

```ruby
pod 'pulse-updates', :path => '../node_modules/pulse-updates'
```

Then run:

```bash
cd ios && pod install
```

Add these keys to your `Info.plist`:

```xml
<key>PulseUpdatesEnabled</key>
<true/>
<key>PulseUpdatesURL</key>
<string>https://your-update-server.com</string>
<key>PulseUpdatesRuntimeVersion</key>
<string>$(MARKETING_VERSION)</string>
<key>PulseUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<!-- Code signing (REQUIRED in release builds) -->
<key>PulseUpdatesRequireSignature</key>
<true/>
<key>PulseUpdatesSigningKeyId</key>
<string>your-key-id</string>
<key>PulseUpdatesSigningPublicKey</key>
<string>base64-ed25519-public-key</string>
```

> **Security:** In release builds `PulseUpdatesRequireSignature` defaults to `true`. When it is on, both `PulseUpdatesSigningKeyId` and `PulseUpdatesSigningPublicKey` are **required** — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See [SECURITY.md](./SECURITY.md).

### Android Setup

Add to your `android/app/build.gradle`:

```gradle
dependencies {
    implementation project(':pulse-updates')
}
```

Add metadata to `AndroidManifest.xml` inside `<application>`:

```xml
<meta-data android:name="PulseUpdatesEnabled" android:value="true" />
<meta-data android:name="PulseUpdatesURL" android:value="https://your-update-server.com" />
<meta-data android:name="PulseUpdatesRuntimeVersion" android:value="@string/app_version" />
<meta-data android:name="PulseUpdatesCheckOnLaunch" android:value="ALWAYS" />
<!-- Code signing (REQUIRED in release builds) -->
<meta-data android:name="PulseUpdatesRequireSignature" android:value="true" />
<meta-data android:name="PulseUpdatesSigningKeyId" android:value="your-key-id" />
<meta-data android:name="PulseUpdatesSigningPublicKey" android:value="base64-ed25519-public-key" />
```

> **Security:** In release builds `PulseUpdatesRequireSignature` defaults to `true`. When it is on, both `PulseUpdatesSigningKeyId` and `PulseUpdatesSigningPublicKey` are **required** — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See [SECURITY.md](./SECURITY.md).

#### New Architecture (Bridgeless) Setup

For React Native 0.76+ with New Architecture enabled, update your `MainApplication.kt`:

```kotlin
import app.pulse.updates.PulseUpdatesModule
import app.pulse.updates.PulseReactHostFactory

class MainApplication : Application(), ReactApplication {

    // Extract packages into a reusable method
    private fun buildPackages(): List<ReactPackage> {
        val packages = PackageList(this).packages.toMutableList()
        // Add your custom packages here
        return packages
    }

    override val reactNativeHost: ReactNativeHost =
        object : DefaultReactNativeHost(this) {
            override fun getPackages(): List<ReactPackage> = buildPackages()
            override fun getJSMainModuleName(): String = "index"

            // Use Pulse Updates bundle
            override fun getJSBundleFile(): String? {
                return PulseUpdatesModule.getBundleFile(applicationContext)?.absolutePath
                    ?: super.getJSBundleFile()
            }

            override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
            override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
        }

    // For New Architecture: use PulseReactHostFactory
    override val reactHost: ReactHost
        get() = PulseReactHostFactory.createReactHost(
            applicationContext,
            packages = buildPackages(),
            jsMainModuleName = "index",
            useDevSupport = BuildConfig.DEBUG
        )
}
```

## Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `PulseUpdatesEnabled` | boolean | `true` | Enable/disable OTA updates |
| `PulseUpdatesURL` | string | required | Your update server URL |
| `PulseUpdatesRuntimeVersion` | string | app version | Version for update compatibility |
| `PulseUpdatesCheckOnLaunch` | string | `ALWAYS` | When to check: `ALWAYS`, `WIFI_ONLY`, `NEVER` |
| `PulseUpdatesChannel` | string | `production` | Update channel |
| `PulseUpdatesLaunchWaitMs` | number | `0` | Wait time for update check on launch |
| `PulseUpdatesRequireSignature` | boolean | `true` in release, `false` in debug | Reject manifests without a valid Ed25519 signature (fail-closed). When `true`, the signing keys below are **required** |
| `PulseUpdatesSigningKeyId` | string | required in release | Key id that must match the manifest's `signature.keyId` |
| `PulseUpdatesSigningPublicKey` | string | required in release | Base64 Ed25519 public key used to verify the manifest signature |

## JavaScript API

### Basic Usage

```typescript
import * as PulseUpdates from 'pulse-updates';
import { initializeAssetResolver } from 'pulse-updates';

// Initialize at app startup
async function initUpdates() {
  // Refresh state from native module
  await PulseUpdates.refreshStateAsync();

  // Initialize asset resolver for images
  initializeAssetResolver(PulseUpdates.localAssets);

  // Check current state
  console.log('Update ID:', PulseUpdates.updateId);
  console.log('Is embedded:', PulseUpdates.isEmbeddedLaunch);
}

// Check for updates
async function checkUpdates() {
  const result = await PulseUpdates.checkForUpdateAsync();

  if (result.isAvailable) {
    console.log('Update available:', result.manifest?.updateId);

    // Download the update
    const fetchResult = await PulseUpdates.fetchUpdateAsync();

    if (fetchResult.isNew) {
      // Reload to apply
      await PulseUpdates.reloadAsync();
    }
  }
}
```

### React Hook

```typescript
import { usePulseUpdates } from 'pulse-updates';

function UpdateBanner() {
  const {
    isChecking,
    isDownloading,
    availableUpdate,
    downloadedUpdate,
    checkForUpdate,
    downloadUpdate,
    reload
  } = usePulseUpdates();

  if (downloadedUpdate) {
    return (
      <View>
        <Text>Update ready!</Text>
        <Button title="Restart" onPress={reload} />
      </View>
    );
  }

  if (availableUpdate) {
    return (
      <View>
        <Text>Update available</Text>
        <Button
          title={isDownloading ? "Downloading..." : "Download"}
          onPress={downloadUpdate}
          disabled={isDownloading}
        />
      </View>
    );
  }

  return null;
}
```

### API Reference

#### State Properties

```typescript
PulseUpdates.isEnabled        // boolean - Updates enabled
PulseUpdates.updateId         // string | null - Current update ID
PulseUpdates.runtimeVersion   // string | null - Runtime version
PulseUpdates.channel          // string | null - Update channel
PulseUpdates.isEmbeddedLaunch // boolean - Running embedded bundle
PulseUpdates.manifest         // PulseManifest | null - Current manifest
PulseUpdates.localAssets      // Record<string, string> | null - Local asset map
```

#### Methods

```typescript
// Refresh state from native
await PulseUpdates.refreshStateAsync()

// Check for available updates
const result = await PulseUpdates.checkForUpdateAsync()
// Returns: { isAvailable: boolean, manifest?: PulseManifest }

// Download available update
const result = await PulseUpdates.fetchUpdateAsync()
// Returns: { isNew: boolean, manifest?: PulseManifest }

// Reload app with new update
await PulseUpdates.reloadAsync()

// Mark app as successfully launched (for rollback protection)
await PulseUpdates.markAppReady()

// Report launch failure (triggers rollback)
await PulseUpdates.reportLaunchFailure(reason: string)
```

## CLI Commands

### Publishing Updates

```bash
# Publish an update
npx pulse-updates publish --platform <ios|android> --build <number>

# With all options
npx pulse-updates publish \
  --platform ios \
  --build 42 \
  --channel production \
  --api-key your-api-key \
  --api-url https://your-server.com \
  --runtime-version 1.0.0 \
  --message "Bug fixes and improvements"
```

### CLI Options

| Option | Description |
|--------|-------------|
| `--platform` | Target platform: `ios` or `android` (required) |
| `--build` | Build number for this update (optional metadata) |
| `--dry-run` | Build + validate locally without creating a release on the server |
| `--channel` | Update channel (default: `production`) |
| `--api-key` | Server API key (or set in `pulse.config.json`) |
| `--api-url` | Server URL (auto-detected from native config) |
| `--runtime-version` | Runtime version (auto-detected from native config) |
| `--message` | Release notes |
| `--skip-bundle` | Skip bundle creation (use existing) |
| `--strict` | Abort the publish if new native modules are detected (default: warn only) |

### Generating Signing Keys

Manifest signing uses Ed25519. Generate a matched keypair and paste each half into the right place:

```bash
npx pulse-updates keygen [--key-id <id>]
```

It prints the **private** seed for the server (`Pulse:SigningKey` / `SIGNING_KEY`) and the **public**
key for the app (`PulseUpdatesSigningPublicKey` in Info.plist / AndroidManifest). The server's
per-app Setup panel and `GET /api/signing` expose the public key too.

### Registering Native Capabilities

For capability-based crash-prediction, record what native modules the shipped binary actually provides:

```bash
npx pulse-updates register-capabilities --platform <ios|android> [--api-key <key>]
```

Run it at build time (after a native build) so the server can warn when a future JS-only update
references native modules the installed binary doesn't have.

### Configuration File

Create `pulse.config.json` in your project root:

```json
{
  "apiKey": "your-api-key",
  "apiUrl": "https://your-server.com",
  "channel": "production"
}
```

## Server Requirements

Pulse Updates requires a compatible server. The server must implement:

- `POST /api/releases` — create a release (CLI, `X-API-Key`)
- `POST /api/releases/:id/assets/check` — check which assets already exist (dedup)
- `POST /api/releases/:id/assets` — upload an asset
- `POST /api/releases/:id/finalize` — finalize the release
- `GET /pulse/manifest/:appSlug` — public signed manifest the on-device SDK fetches

## Embedded Manifest

For offline-first support, generate an embedded manifest during your build:

```bash
node node_modules/pulse-updates/scripts/generate-embedded-manifest.mjs \
  --bundle path/to/index.bundle \
  --assets path/to/assets \
  --out path/to/output \
  --platform ios \
  --runtime-version 1.0.0
```

## Troubleshooting

### Images not loading after update

Ensure you initialize the asset resolver at app startup:

```typescript
import { initializeAssetResolver } from 'pulse-updates';
import * as PulseUpdates from 'pulse-updates';

await PulseUpdates.refreshStateAsync();
initializeAssetResolver(PulseUpdates.localAssets);
```

### Update not applying on reload

For New Architecture apps, ensure you're using `PulseReactHostFactory` in your `MainApplication.kt`.

### Debug Logging

Set `PULSE_DEBUG=true` environment variable to enable verbose logging in native code.

## License

MIT
