npm package: @ruhiverse/ota
Description: Capacitor plugin for OTA updates - dynamically replace web assets at runtime
Author: Ruhiverse
Latest version: 0.0.10

<p align="center">
  <br />
  <img src="https://user-images.githubusercontent.com/assets/capacitor-logo.png" width="60" alt="Capacitor" />
  <br />
</p>

<h1 align="center">@ruhiverse/ota</h1>

<p align="center">
  Capacitor plugin to deliver OTA (Over-The-Air) updates — dynamically replace web assets at runtime without app store releases.
</p>

<p align="center">
  <a href="https://www.npmjs.com/package/@ruhiverse/ota"><img src="https://img.shields.io/npm/v/@ruhiverse/ota.svg?style=flat-square" alt="npm version" /></a>
  <a href="https://www.npmjs.com/package/@ruhiverse/ota"><img src="https://img.shields.io/npm/l/@ruhiverse/ota.svg?style=flat-square" alt="license" /></a>
  <a href="https://www.npmjs.com/package/@ruhiverse/ota"><img src="https://img.shields.io/badge/capacitor-5%2B%20%7C%206%2B-blue?style=flat-square" alt="capacitor" /></a>
</p>

---

## Table of Contents

- [Install](#install)
- [Supported Platforms](#supported-platforms)
- [Quick Start](#quick-start)
- [API](#api)
  - [downloadUpdate()](#downloadupdateoptions)
  - [applyUpdate()](#applyupdate)
  - [getCurrentPath()](#getcurrentpath)
  - [resetToBundle()](#resettobundle)
- [Interfaces](#interfaces)
- [How It Works](#how-it-works)
- [ZIP Requirements](#zip-requirements)
- [Security](#security)
- [Rollback & Error Handling](#rollback--error-handling)
- [Configuration](#configuration)
- [Full Example](#full-example)
- [License](#license)

---

## Install

```bash
npm install @ruhiverse/ota
npx cap sync
```

## Supported Platforms

| Platform | Supported          | Native Implementation              |
| -------- | ------------------ | ---------------------------------- |
| Android  | Capacitor 5+ / 6+  | Java (`ZipInputStream`, `HttpURLConnection`)  |
| iOS      | Capacitor 5+ / 6+  | Swift (`SSZipArchive`, `CryptoKit`)           |
| Web      | Not supported       | —                                  |

> OTA updates replace **web assets only** (HTML, CSS, JS). Native code is never modified.

---

## Quick Start

```typescript
import { Ota } from '@ruhiverse/ota';

// Download update from your server
await Ota.downloadUpdate({
  url: 'https://your-server.com/releases/v2.0.0.zip',
});

// Extract & apply — WebView reloads automatically
await Ota.applyUpdate();
```

---

## API

### downloadUpdate(options)

Downloads a ZIP file from the given URL and stores it locally at `<app_data>/ota/update.zip`.

```typescript
const result = await Ota.downloadUpdate({
  url: 'https://example.com/update-v2.zip',
  checksum: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
});
console.log('Downloaded to:', result.path);
```

**Parameters:**

| Param        | Type     | Required | Description                                              |
| ------------ | -------- | -------- | -------------------------------------------------------- |
| **url**      | `string` | Yes      | URL of the ZIP file containing updated web assets        |
| **checksum** | `string` | No       | SHA-256 checksum to validate the download before storing |

**Returns:** `Promise<DownloadUpdateResult>`

| Property | Type     | Description                         |
| -------- | -------- | ----------------------------------- |
| path     | `string` | Local file path of the saved ZIP    |

---

### applyUpdate()

Extracts the previously downloaded ZIP to `<app_data>/ota/www/`, validates the contents, sets the WebView server base path, and reloads the app.

```typescript
const result = await Ota.applyUpdate();
console.log('Now serving from:', result.path);
```

**Returns:** `Promise<ApplyUpdateResult>`

| Property | Type     | Description                              |
| -------- | -------- | ---------------------------------------- |
| path     | `string` | The new server base path being served    |

> If extraction fails or `index.html` is missing, the operation is rolled back automatically.

---

### getCurrentPath()

Returns the current server base path. If the app is using the original bundled assets, returns `"default"`.

```typescript
const result = await Ota.getCurrentPath();
if (result.path !== 'default') {
  console.log('Running OTA version from:', result.path);
}
```

**Returns:** `Promise<GetCurrentPathResult>`

| Property | Type     | Description                                          |
| -------- | -------- | ---------------------------------------------------- |
| path     | `string` | Current OTA path, or `"default"` for bundled assets  |

---

### resetToBundle()

Resets the WebView to serve the original bundled assets. Removes all downloaded OTA files and reloads the app.

```typescript
const result = await Ota.resetToBundle();
console.log('Reset successful:', result.success);
```

**Returns:** `Promise<ResetToDefaultResult>`

| Property | Type      | Description                     |
| -------- | --------- | ------------------------------- |
| success  | `boolean` | Whether the reset succeeded     |

---

## Interfaces

```typescript
interface DownloadUpdateOptions {
  url: string;
  checksum?: string;
}

interface DownloadUpdateResult {
  path: string;
}

interface ApplyUpdateResult {
  path: string;
}

interface GetCurrentPathResult {
  path: string;
}

interface ResetToDefaultResult {
  success: boolean;
}

interface OtaPlugin {
  downloadUpdate(options: DownloadUpdateOptions): Promise<DownloadUpdateResult>;
  applyUpdate(): Promise<ApplyUpdateResult>;
  getCurrentPath(): Promise<GetCurrentPathResult>;
  resetToBundle(): Promise<ResetToDefaultResult>;
}
```

---

## How It Works

```
┌─────────────────────────────────────────────────────────┐
│                    OTA Update Flow                       │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  1. downloadUpdate()                                    │
│     ┌──────────┐      ┌──────────────────────────┐     │
│     │  Server  │ ───> │ <app_data>/ota/update.zip │     │
│     └──────────┘      └──────────────────────────┘     │
│                              │                          │
│  2. applyUpdate()            ▼                          │
│     ┌──────────────────────────┐                        │
│     │  <app_data>/ota/www/     │                        │
│     │  ├── index.html          │                        │
│     │  ├── main.js             │                        │
│     │  └── assets/...          │                        │
│     └──────────────────────────┘                        │
│                              │                          │
│  3. setServerBasePath()      ▼                          │
│     ┌──────────────────────────┐                        │
│     │  WebView reloads with    │                        │
│     │  new assets              │                        │
│     └──────────────────────────┘                        │
│                                                         │
│  4. Path persisted — survives app restarts              │
│                                                         │
│  5. resetToBundle() — clears OTA, restores original     │
│                                                         │
└─────────────────────────────────────────────────────────┘
```

### Lifecycle Detail

1. **Download** — `downloadUpdate()` fetches the ZIP via HTTP and saves it to `<app_data>/ota/update.zip`. If a SHA-256 checksum is provided, the file is validated immediately after download. On mismatch, the file is deleted and an error is thrown.

2. **Extract** — `applyUpdate()` unzips the archive into `<app_data>/ota/www/`. Old extracted files are removed first to prevent stale content.

3. **Validate** — The plugin checks that `index.html` exists in the extracted directory. If not, the extracted files are deleted and the update is rejected.

4. **Apply** — The native bridge's server base path is set to the extracted directory. The WebView reloads automatically to serve the new assets.

5. **Persist** — The OTA path is saved to `SharedPreferences` (Android) / `UserDefaults` (iOS). On app launch, the plugin's `load()` method restores this path so the OTA version survives restarts.

6. **Reset** — `resetToBundle()` clears the saved path, deletes all OTA files, resets the server base path, and reloads the WebView with the original bundled assets.

### On-Device File Structure

```
Android: /data/user/0/{app.package}/files/ota/
iOS:     <AppSupport>/ota/

├── update.zip          ← downloaded archive
└── www/                ← extracted web assets
    ├── index.html
    ├── main.js
    ├── styles.css
    └── assets/
        └── ...
```

---

## ZIP Requirements

Your ZIP file **must** have `index.html` at the root level. The plugin validates this before applying the update.

**Correct structure:**

```
update.zip
├── index.html       ✅  Required at root
├── main.js
├── styles.css
└── assets/
    ├── icon.png
    └── ...
```

**Incorrect structure (nested folder):**

```
update.zip
└── www/             ❌  index.html is not at root
    ├── index.html
    └── ...
```

> When building your Ionic app, use the contents of the `www/` build output folder — not the folder itself.

---

## Security

The plugin includes multiple layers of protection:

| Protection              | Description                                                        |
| ----------------------- | ------------------------------------------------------------------ |
| **SHA-256 Checksum**    | Optional integrity check — if provided, the downloaded ZIP is validated against the expected hash before it's stored. Mismatches cause immediate deletion. |
| **Zip-Slip Prevention** | Both Android and iOS implementations verify that extracted file paths don't escape the target directory (path traversal attack protection). |
| **Content Validation**  | The extracted directory must contain `index.html`. Without it, the update is rejected and cleaned up. |
| **Atomic Writes (iOS)** | ZIP data is written with `.atomic` option to prevent partial/corrupt files on disk. |
| **HTTPS**               | Always use HTTPS URLs for your update server to prevent man-in-the-middle attacks. |

---

## Rollback & Error Handling

The plugin handles failures gracefully at every stage:

| Failure Scenario         | What Happens                                                    |
| ------------------------ | --------------------------------------------------------------- |
| **Download fails**       | Partial ZIP is deleted. Error thrown to caller.                  |
| **Checksum mismatch**    | Downloaded ZIP is deleted. Error thrown with expected vs actual. |
| **Corrupt ZIP**          | Extraction fails, extracted files are cleaned up automatically. |
| **Missing index.html**   | Extracted files are deleted. Error thrown to caller.             |
| **Zip-slip detected**    | Extraction aborted with `SecurityException`.                    |
| **App won't load after update** | Call `resetToBundle()` to revert to original assets.     |

### Manual Rollback

```typescript
try {
  await Ota.applyUpdate();
} catch (error) {
  console.error('Update failed, rolling back:', error);
  await Ota.resetToBundle();
}
```

### Check Before Applying

```typescript
const { path } = await Ota.getCurrentPath();
if (path !== 'default') {
  console.log('Already running OTA version from:', path);
}
```

---

## Configuration

### Android

No extra permissions required. The plugin uses the app's internal files directory (`context.getFilesDir()`), which doesn't need `WRITE_EXTERNAL_STORAGE`.

The plugin requires `INTERNET` permission for downloading, which is already included by default in Capacitor apps.

### iOS

The `SSZipArchive` dependency is pulled in automatically via CocoaPods when you run `npx cap sync`. No manual setup needed.

The plugin uses `ApplicationSupport` directory for storage, which doesn't require any special entitlements.

---

## Full Example

```typescript
import { Ota } from '@ruhiverse/ota';

interface UpdateInfo {
  url: string;
  version: string;
  checksum: string;
}

async function checkForUpdate(): Promise<void> {
  // 1. Fetch update metadata from your API
  const response = await fetch('https://api.your-server.com/updates/latest');
  const update: UpdateInfo = await response.json();

  // 2. Check if we're already on this version
  const { path } = await Ota.getCurrentPath();
  if (path.includes(update.version)) {
    console.log('Already up to date');
    return;
  }

  try {
    // 3. Download with checksum validation
    console.log(`Downloading v${update.version}...`);
    await Ota.downloadUpdate({
      url: update.url,
      checksum: update.checksum,
    });

    // 4. Extract & apply — WebView reloads automatically
    console.log('Applying update...');
    const result = await Ota.applyUpdate();
    console.log('Update applied from:', result.path);
  } catch (error) {
    console.error('OTA update failed:', error);
    await Ota.resetToBundle();
  }
}

// Run on app startup
checkForUpdate();
```

### Server-Side ZIP Generation

Example script to generate an update ZIP from an Ionic build:

```bash
# Build your Ionic app
ionic build --prod

# Create the update ZIP from the build output
cd www
zip -r ../update-v2.0.0.zip ./*
cd ..

# Generate SHA-256 checksum
shasum -a 256 update-v2.0.0.zip
# e3b0c44298fc1c149afbf4c8996fb924...  update-v2.0.0.zip
```

> Zip the **contents** of `www/`, not the `www/` folder itself.

---

## License

MIT

---

<p align="center">
  Made with ❤️ by <a href="https://github.com/ruhiverse">Ruhiverse</a>
</p>