@shipvo/capacitor 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Package.swift ADDED
@@ -0,0 +1,19 @@
1
+ // swift-tools-version:5.9
2
+ // Entry point for Capacitor's Swift Package Manager integration (`cap sync ios` links this package).
3
+ // The sources live in ios/Sources; ios/Package.swift runs the same ShipvoBundle target's tests.
4
+ import PackageDescription
5
+
6
+ let package = Package(
7
+ name: "ShipvoCapacitor",
8
+ platforms: [.iOS(.v15)],
9
+ products: [.library(name: "ShipvoCapacitor", targets: ["ShipvoCapacitor"])],
10
+ dependencies: [.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")],
11
+ targets: [
12
+ .target(name: "ShipvoBundle", path: "ios/Sources/ShipvoBundle"),
13
+ .target(
14
+ name: "ShipvoCapacitor",
15
+ dependencies: [.product(name: "Capacitor", package: "capacitor-swift-pm"), "ShipvoBundle"],
16
+ path: "ios/Sources/ShipvoCapacitor"
17
+ ),
18
+ ]
19
+ )
package/README.md CHANGED
@@ -2,7 +2,40 @@
2
2
 
3
3
  Signed over-the-air updates for the web bundle of a Capacitor app. It makes the same release decisions and keeps the same trust model as `@shipvo/runtime` (see `docs/adapters.md`).
4
4
 
5
- This is the core, in TypeScript. It does not yet apply a bundle on a device. The native layer (next slice) backs two small interfaces, `KeyValueStore` and `BundleFiles`, and calls `boot()` when the web view loads.
5
+ The core is TypeScript and makes every decision: it verifies the signature and the hash, stages, promotes and rolls back. The native layer only stores bytes and switches the web view.
6
+
7
+ | Platform | Native layer |
8
+ |---|---|
9
+ | Android | `android/`: stores the verified bundle zip in app-private files and the state in SharedPreferences; switches the web view with `Bridge.setServerBasePath` to the unpacked bundle |
10
+ | iOS | Next slice, tracked in its own issue. It will use the same JavaScript core and the same `boot` and `bootAndServe` calls as Android. |
11
+
12
+ ## Android
13
+
14
+ `android/` is a Capacitor plugin named `Shipvo`. It backs `KeyValueStore` with SharedPreferences and `BundleFiles` with the app-private files directory, and points the web view at the staged bundle. A web bundle is a zip of your web assets with an `index.html` at its root.
15
+
16
+ 1. Register it in `MainActivity` with `registerPlugin(ShipvoPlugin.class)` before `super.onCreate`.
17
+ 2. Include the module in `settings.gradle` and add `implementation project(':shipvo-capacitor')` (see `apps/capacitor-sample/android`).
18
+ 3. When the page loads, call `bootAndServe`. It runs `boot()` (rollback included) and tells native to serve the chosen file. Native checks the sha-256 the core verified once more before it unpacks, and refuses zip entries that leave the directory.
19
+
20
+ ```ts
21
+ import { registerPlugin } from "@capacitor/core";
22
+ import { ShipvoCapacitor, NativeStore, NativeFiles, bootAndServe, bootAndSwitch } from "@shipvo/capacitor";
23
+ import type { ShipvoNativePlugin } from "@shipvo/capacitor";
24
+
25
+ const native = registerPlugin<ShipvoNativePlugin>("Shipvo");
26
+ const shipvo: ShipvoCapacitor = new ShipvoCapacitor({
27
+ appId: "tide", shellMajor: 1, shellVersion: "1.2.0", platform: "android",
28
+ publicKey: "<base64 Ed25519 key>", baseUrl: "https://cp.example.com",
29
+ store: new NativeStore(native), files: new NativeFiles(native),
30
+ onReload: async () => { await bootAndSwitch(shipvo, native); },
31
+ });
32
+ await bootAndServe(shipvo, native);
33
+ await shipvo.markHealthy();
34
+ ```
35
+
36
+ Each launch starts on the embedded bundle, then `bootAndServe` switches to the pending or current release. The page of a served bundle runs the same code; it resumes the release `boot()` already chose and does not count another trial boot.
37
+
38
+ Tests: `pnpm --filter @shipvo/capacitor test` runs the stage, apply, crash and rollback sequence through the native bridge. The plugin's JVM tests run with `./gradlew :shipvo-capacitor:testDebugUnitTest` in `apps/capacitor-sample/android` (after `pnpm --filter capacitor-sample sync`).
6
39
 
7
40
  ## Install
8
41
 
@@ -19,7 +52,7 @@ const shipvo = new ShipvoCapacitor({
19
52
  appId: "tide", shellMajor: 1, shellVersion: "1.2.0", platform: "android",
20
53
  publicKey: "<base64 Ed25519 key baked into the shell>",
21
54
  baseUrl: "https://cp.example.com",
22
- store: new MemoryStore(), files: new MemoryFiles(), // the native layer provides real ones
55
+ store: new MemoryStore(), files: new MemoryFiles(), // Android: NativeStore and NativeFiles
23
56
  });
24
57
 
25
58
  const { release, file } = await shipvo.boot(); // which bundle to serve
@@ -0,0 +1,18 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ android {
4
+ namespace = "dev.shipvo.capacitor"
5
+ compileSdk = rootProject.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
6
+ defaultConfig {
7
+ minSdkVersion rootProject.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
8
+ }
9
+ compileOptions {
10
+ sourceCompatibility JavaVersion.VERSION_21
11
+ targetCompatibility JavaVersion.VERSION_21
12
+ }
13
+ }
14
+
15
+ dependencies {
16
+ implementation project(':capacitor-android')
17
+ testImplementation "junit:junit:4.13.2"
18
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,116 @@
1
+ package dev.shipvo.capacitor;
2
+
3
+ import java.io.ByteArrayOutputStream;
4
+ import java.io.File;
5
+ import java.io.FileInputStream;
6
+ import java.io.FileOutputStream;
7
+ import java.io.IOException;
8
+ import java.io.InputStream;
9
+ import java.io.OutputStream;
10
+ import java.security.MessageDigest;
11
+ import java.util.ArrayList;
12
+ import java.util.Arrays;
13
+ import java.util.List;
14
+ import java.util.zip.ZipEntry;
15
+ import java.util.zip.ZipInputStream;
16
+
17
+ /**
18
+ * Bundle files in a private directory. No Android classes, so it runs in a plain JVM test.
19
+ * It never decides anything: the TypeScript core verifies the signature and hash; this class
20
+ * only stores bytes and re-checks the hash it is given before it unpacks.
21
+ */
22
+ public final class BundleStore {
23
+ private final File dir;
24
+ private final File served;
25
+
26
+ public BundleStore(File root) {
27
+ this.dir = new File(root, "bundles");
28
+ this.served = new File(root, "served");
29
+ dir.mkdirs();
30
+ }
31
+
32
+ private File file(String name) throws IOException {
33
+ if (name == null || !name.matches("[A-Za-z0-9._-]{1,128}") || name.contains("..")) {
34
+ throw new IOException("bad file name");
35
+ }
36
+ return new File(dir, name);
37
+ }
38
+
39
+ public void write(String name, byte[] bytes) throws IOException {
40
+ File f = file(name);
41
+ File tmp = new File(dir, name + ".tmp");
42
+ try (FileOutputStream o = new FileOutputStream(tmp)) { o.write(bytes); o.getFD().sync(); }
43
+ try { java.nio.file.Files.move(tmp.toPath(), f.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); }
44
+ catch (IOException e) { tmp.delete(); throw new IOException("could not write " + name); }
45
+ }
46
+
47
+ public byte[] read(String name) throws IOException {
48
+ try (InputStream in = new FileInputStream(file(name))) {
49
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
50
+ copy(in, out);
51
+ return out.toByteArray();
52
+ }
53
+ }
54
+
55
+ private static void copy(InputStream in, OutputStream out) throws IOException {
56
+ byte[] buf = new byte[8192];
57
+ for (int n; (n = in.read(buf)) > 0; ) out.write(buf, 0, n);
58
+ }
59
+
60
+ public void remove(String name) throws IOException { file(name).delete(); }
61
+
62
+ public List<String> list() {
63
+ String[] names = dir.list();
64
+ List<String> out = new ArrayList<>();
65
+ if (names != null) { Arrays.sort(names); for (String n : names) if (!n.endsWith(".tmp")) out.add(n); }
66
+ return out;
67
+ }
68
+
69
+ public void clear() {
70
+ String[] names = dir.list();
71
+ if (names != null) for (String n : names) new File(dir, n).delete();
72
+ deleteTree(served);
73
+ }
74
+
75
+ public static String sha256Hex(byte[] bytes) throws IOException {
76
+ try {
77
+ byte[] d = MessageDigest.getInstance("SHA-256").digest(bytes);
78
+ StringBuilder sb = new StringBuilder();
79
+ for (byte b : d) sb.append(String.format("%02x", b));
80
+ return sb.toString();
81
+ } catch (java.security.NoSuchAlgorithmException e) { throw new IOException(e); }
82
+ }
83
+
84
+ /**
85
+ * Unpacks a stored zip into a fresh directory, but only if its sha-256 equals the hash the
86
+ * core verified against the signed pointer. Entries that escape the directory are refused.
87
+ */
88
+ public File unpack(String name, String expectedSha256) throws IOException {
89
+ byte[] bytes = read(name);
90
+ if (expectedSha256 == null || !sha256Hex(bytes).equalsIgnoreCase(expectedSha256)) {
91
+ throw new IOException("bundle hash does not match");
92
+ }
93
+ deleteTree(served);
94
+ File target = new File(served, name);
95
+ target.mkdirs();
96
+ String root = target.getCanonicalPath() + File.separator;
97
+ try (ZipInputStream z = new ZipInputStream(new java.io.ByteArrayInputStream(bytes))) {
98
+ ZipEntry e;
99
+ while ((e = z.getNextEntry()) != null) {
100
+ File out = new File(target, e.getName());
101
+ if (!out.getCanonicalPath().startsWith(root)) { deleteTree(served); throw new IOException("bad entry in bundle"); }
102
+ if (e.isDirectory()) { out.mkdirs(); continue; }
103
+ out.getParentFile().mkdirs();
104
+ try (OutputStream o = new FileOutputStream(out)) { copy(z, o); }
105
+ }
106
+ }
107
+ if (!new File(target, "index.html").isFile()) { deleteTree(served); throw new IOException("bundle has no index.html"); }
108
+ return target;
109
+ }
110
+
111
+ private static void deleteTree(File f) {
112
+ File[] kids = f.listFiles();
113
+ if (kids != null) for (File k : kids) deleteTree(k);
114
+ f.delete();
115
+ }
116
+ }
@@ -0,0 +1,107 @@
1
+ package dev.shipvo.capacitor;
2
+
3
+ import android.content.Context;
4
+ import android.content.SharedPreferences;
5
+ import android.util.Base64;
6
+ import com.getcapacitor.JSArray;
7
+ import com.getcapacitor.JSObject;
8
+ import com.getcapacitor.Plugin;
9
+ import com.getcapacitor.PluginCall;
10
+ import com.getcapacitor.PluginMethod;
11
+ import com.getcapacitor.annotation.CapacitorPlugin;
12
+ import java.io.File;
13
+
14
+ /**
15
+ * The native half of @shipvo/capacitor. It backs the core's KeyValueStore (SharedPreferences)
16
+ * and BundleFiles (app-private files), and points the web view at an unpacked bundle.
17
+ * It makes no release decisions and verifies no signatures: the TypeScript core does both.
18
+ */
19
+ @CapacitorPlugin(name = "Shipvo")
20
+ public class ShipvoPlugin extends Plugin {
21
+ private static final String PREFS = "shipvo";
22
+ // An instance field: a Plugin lives as long as its Bridge, and the web view's base path belongs to the Bridge.
23
+ // A new Bridge (Activity recreated in the same process) starts on the embedded assets, so it must boot again.
24
+ private volatile String serving;
25
+ private volatile String servingPath;
26
+ private BundleStore store;
27
+
28
+ private BundleStore store() {
29
+ if (store == null) store = new BundleStore(getContext().getFilesDir());
30
+ return store;
31
+ }
32
+
33
+ private SharedPreferences prefs() { return getContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE); }
34
+
35
+ @PluginMethod
36
+ public void kvGet(PluginCall call) {
37
+ String key = call.getString("key");
38
+ JSObject r = new JSObject();
39
+ if (key != null && prefs().contains(key)) r.put("value", prefs().getString(key, null));
40
+ call.resolve(r);
41
+ }
42
+
43
+ @PluginMethod
44
+ public void kvSet(PluginCall call) {
45
+ String key = call.getString("key"), value = call.getString("value");
46
+ if (key == null || value == null) { call.reject("key and value are required"); return; }
47
+ // commit(), not apply(): the boot counter must be on disk before the page runs.
48
+ if (!prefs().edit().putString(key, value).commit()) { call.reject("could not save"); return; }
49
+ call.resolve();
50
+ }
51
+
52
+ @PluginMethod
53
+ public void fileWrite(PluginCall call) {
54
+ try {
55
+ store().write(call.getString("name"), Base64.decode(call.getString("data", ""), Base64.NO_WRAP));
56
+ call.resolve();
57
+ } catch (Exception e) { call.reject(e.getMessage()); }
58
+ }
59
+
60
+ @PluginMethod
61
+ public void fileRead(PluginCall call) {
62
+ try {
63
+ JSObject r = new JSObject();
64
+ r.put("data", Base64.encodeToString(store().read(call.getString("name")), Base64.NO_WRAP));
65
+ call.resolve(r);
66
+ } catch (Exception e) { call.reject(e.getMessage()); }
67
+ }
68
+
69
+ @PluginMethod
70
+ public void fileRemove(PluginCall call) {
71
+ try { store().remove(call.getString("name")); call.resolve(); } catch (Exception e) { call.reject(e.getMessage()); }
72
+ }
73
+
74
+ @PluginMethod
75
+ public void fileList(PluginCall call) {
76
+ JSObject r = new JSObject();
77
+ r.put("names", new JSArray(store().list()));
78
+ call.resolve(r);
79
+ }
80
+
81
+ @PluginMethod
82
+ public void fileClear(PluginCall call) {
83
+ store().clear(); serving = null; servingPath = null; call.resolve();
84
+ }
85
+
86
+ /** Serve a stored bundle. `sha256` is the hash the core verified; it is checked again before unpacking. */
87
+ @PluginMethod
88
+ public void serve(PluginCall call) {
89
+ try {
90
+ String name = call.getString("name");
91
+ File dir = store().unpack(name, call.getString("sha256"));
92
+ getBridge().setServerBasePath(dir.getAbsolutePath());
93
+ serving = name;
94
+ servingPath = dir.getAbsolutePath();
95
+ call.resolve();
96
+ } catch (Exception e) { call.reject(e.getMessage()); }
97
+ }
98
+
99
+ /** Which bundle this process is already serving, so the page it loads does not boot again. */
100
+ @PluginMethod
101
+ public void status(PluginCall call) {
102
+ JSObject r = new JSObject();
103
+ // Report serving only while this Bridge still points at the unpacked directory.
104
+ if (serving != null && servingPath != null && servingPath.equals(getBridge().getServerBasePath())) r.put("serving", serving);
105
+ call.resolve(r);
106
+ }
107
+ }
package/dist/engine.d.ts CHANGED
@@ -48,6 +48,14 @@ export declare class ShipvoCapacitor implements ShipvoCapacitorPlugin {
48
48
  release: number;
49
49
  file?: string;
50
50
  }>;
51
+ /** sha-256 of a stored release's archive, as checked against the signed pointer when it was staged. The native layer re-checks it before it serves the file. */
52
+ bundleHash(release: number): Promise<string | undefined>;
53
+ /**
54
+ * The web view loaded a page of a release that `boot()` already chose in this process
55
+ * (the native layer switched bundles and the new page starts the core again). Sets the running
56
+ * release without counting another trial boot. Ignored unless it is the pending or current release.
57
+ */
58
+ resume(release: number): Promise<void>;
51
59
  /** The app stayed up long enough (the native layer decides how long). Clears the boot counter. */
52
60
  markStable(): Promise<void>;
53
61
  getState(): Promise<RuntimeState>;
package/dist/engine.js CHANGED
@@ -107,6 +107,18 @@ export class ShipvoCapacitor {
107
107
  await this.save();
108
108
  return { release: s.current, file: s.current ? bundleName(s.current) : undefined };
109
109
  }
110
+ /** sha-256 of a stored release's archive, as checked against the signed pointer when it was staged. The native layer re-checks it before it serves the file. */
111
+ async bundleHash(release) { return (await this.load()).hashes[String(release)]; }
112
+ /**
113
+ * The web view loaded a page of a release that `boot()` already chose in this process
114
+ * (the native layer switched bundles and the new page starts the core again). Sets the running
115
+ * release without counting another trial boot. Ignored unless it is the pending or current release.
116
+ */
117
+ async resume(release) {
118
+ const s = await this.load();
119
+ if (release && (release === s.pending || release === s.current))
120
+ this.running = release;
121
+ }
110
122
  /** The app stayed up long enough (the native layer decides how long). Clears the boot counter. */
111
123
  async markStable() { const s = await this.load(); s.bootTimes = []; await this.save(); }
112
124
  async getState() {
package/dist/index.d.ts CHANGED
@@ -3,3 +3,5 @@ export { ShipvoCapacitor, bundleName, MAX_BOOTS, WINDOW_MS } from "./engine.js";
3
3
  export type { ShipvoConfig } from "./engine.js";
4
4
  export { MemoryStore, MemoryFiles } from "./memory.js";
5
5
  export { verifyEd25519, sha256Hex, semverLess } from "./verify.js";
6
+ export { NativeStore, NativeFiles, bootAndServe, bootAndSwitch } from "./native.js";
7
+ export type { ShipvoNativePlugin } from "./native.js";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export * from "./types.js";
2
2
  export { ShipvoCapacitor, bundleName, MAX_BOOTS, WINDOW_MS } from "./engine.js";
3
3
  export { MemoryStore, MemoryFiles } from "./memory.js";
4
4
  export { verifyEd25519, sha256Hex, semverLess } from "./verify.js";
5
+ export { NativeStore, NativeFiles, bootAndServe, bootAndSwitch } from "./native.js";
@@ -0,0 +1,72 @@
1
+ import type { ShipvoCapacitor } from "./engine.js";
2
+ import type { BundleFiles, KeyValueStore } from "./types.js";
3
+ /** The methods the Android plugin (`android/`, plugin name `Shipvo`) exposes through the Capacitor bridge. Binary data travels as base64. */
4
+ export interface ShipvoNativePlugin {
5
+ kvGet(o: {
6
+ key: string;
7
+ }): Promise<{
8
+ value?: string;
9
+ }>;
10
+ kvSet(o: {
11
+ key: string;
12
+ value: string;
13
+ }): Promise<void>;
14
+ fileWrite(o: {
15
+ name: string;
16
+ data: string;
17
+ }): Promise<void>;
18
+ fileRead(o: {
19
+ name: string;
20
+ }): Promise<{
21
+ data: string;
22
+ }>;
23
+ fileRemove(o: {
24
+ name: string;
25
+ }): Promise<void>;
26
+ fileList(): Promise<{
27
+ names: string[];
28
+ }>;
29
+ fileClear(): Promise<void>;
30
+ /** Unpack the stored bundle and point the web view at it. Native re-checks `sha256` first. */
31
+ serve(o: {
32
+ name: string;
33
+ sha256: string;
34
+ }): Promise<void>;
35
+ /** The bundle this process already serves, if any. */
36
+ status(): Promise<{
37
+ serving?: string;
38
+ }>;
39
+ }
40
+ /** `KeyValueStore` backed by the native plugin (SharedPreferences on Android). */
41
+ export declare class NativeStore implements KeyValueStore {
42
+ private n;
43
+ constructor(n: ShipvoNativePlugin);
44
+ get(key: string): Promise<string | undefined>;
45
+ set(key: string, value: string): Promise<void>;
46
+ }
47
+ /** `BundleFiles` backed by the native plugin (the app-private files directory on Android). */
48
+ export declare class NativeFiles implements BundleFiles {
49
+ private n;
50
+ constructor(n: ShipvoNativePlugin);
51
+ write(name: string, bytes: Uint8Array): Promise<void>;
52
+ read(name: string): Promise<Uint8Array<ArrayBuffer>>;
53
+ remove(name: string): Promise<void>;
54
+ list(): Promise<string[]>;
55
+ clear(): Promise<void>;
56
+ }
57
+ /**
58
+ * Run this first when the page loads. It asks the core which release to serve (`boot()`, which also
59
+ * decides rollback) and tells the native layer to serve that file. Native never picks a bundle on its own.
60
+ * Returns `served: true` when the web view is on a staged bundle.
61
+ */
62
+ export declare function bootAndServe(shipvo: ShipvoCapacitor, native: ShipvoNativePlugin): Promise<{
63
+ release: number;
64
+ served: boolean;
65
+ error?: string;
66
+ }>;
67
+ /** Run `boot()` and serve its choice now. Use it as `onReload` so `checkForUpdate({ apply: true })` switches the web view. */
68
+ export declare function bootAndSwitch(shipvo: ShipvoCapacitor, native: ShipvoNativePlugin): Promise<{
69
+ release: number;
70
+ served: boolean;
71
+ error?: string;
72
+ }>;
package/dist/native.js ADDED
@@ -0,0 +1,55 @@
1
+ const toB64 = (u) => { let s = ""; for (let i = 0; i < u.length; i += 0x8000)
2
+ s += String.fromCharCode(...u.subarray(i, i + 0x8000)); return btoa(s); };
3
+ const fromB64 = (b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0));
4
+ /** `KeyValueStore` backed by the native plugin (SharedPreferences on Android). */
5
+ export class NativeStore {
6
+ n;
7
+ constructor(n) {
8
+ this.n = n;
9
+ }
10
+ async get(key) { return (await this.n.kvGet({ key })).value; }
11
+ async set(key, value) { await this.n.kvSet({ key, value }); }
12
+ }
13
+ /** `BundleFiles` backed by the native plugin (the app-private files directory on Android). */
14
+ export class NativeFiles {
15
+ n;
16
+ constructor(n) {
17
+ this.n = n;
18
+ }
19
+ async write(name, bytes) { await this.n.fileWrite({ name, data: toB64(bytes) }); }
20
+ async read(name) { return fromB64((await this.n.fileRead({ name })).data); }
21
+ async remove(name) { await this.n.fileRemove({ name }); }
22
+ async list() { return (await this.n.fileList()).names; }
23
+ async clear() { await this.n.fileClear(); }
24
+ }
25
+ /**
26
+ * Run this first when the page loads. It asks the core which release to serve (`boot()`, which also
27
+ * decides rollback) and tells the native layer to serve that file. Native never picks a bundle on its own.
28
+ * Returns `served: true` when the web view is on a staged bundle.
29
+ */
30
+ export async function bootAndServe(shipvo, native) {
31
+ const { serving } = await native.status();
32
+ if (serving) {
33
+ // This page was loaded by an earlier `serve` in this process: `boot()` already ran.
34
+ const release = Number(/^release-(\d+)\.bundle$/.exec(serving)?.[1] ?? 0);
35
+ await shipvo.resume(release);
36
+ return { release, served: true };
37
+ }
38
+ return bootAndSwitch(shipvo, native);
39
+ }
40
+ /** Run `boot()` and serve its choice now. Use it as `onReload` so `checkForUpdate({ apply: true })` switches the web view. */
41
+ export async function bootAndSwitch(shipvo, native) {
42
+ const { release, file } = await shipvo.boot();
43
+ if (!file)
44
+ return { release: 0, served: false };
45
+ const sha256 = await shipvo.bundleHash(release);
46
+ if (!sha256)
47
+ return { release, served: false, error: "no verified hash for the stored bundle" };
48
+ try {
49
+ await native.serve({ name: file, sha256 });
50
+ }
51
+ catch (e) {
52
+ return { release, served: false, error: String(e?.message ?? e) };
53
+ }
54
+ return { release, served: true };
55
+ }
@@ -0,0 +1,71 @@
1
+ #if canImport(CryptoKit)
2
+ import CryptoKit
3
+ #else
4
+ import Crypto
5
+ #endif
6
+ import Foundation
7
+
8
+ public enum BundleError: Error, Equatable {
9
+ case badSignature, badPointer, badHash, badStorage, shellTooOld
10
+ /// Same codes as `expect` in test-vectors/bundle.json.
11
+ public var code: String {
12
+ switch self {
13
+ case .badSignature: return "bad_signature"
14
+ case .badPointer: return "bad_pointer"
15
+ case .badHash: return "bad_hash"
16
+ case .badStorage: return "bad_storage"
17
+ case .shellTooOld: return "shell_too_old"
18
+ }
19
+ }
20
+ }
21
+
22
+ /// Fields of a signed web release pointer that the bundle check reads.
23
+ struct Pointer: Decodable {
24
+ struct Core: Decodable { let sha256: String; let size: Int }
25
+ let kind: String, platform: String?, app_id: String, shell_major: Int, band: String, release: Int, min_shell_version: String, core: Core
26
+ }
27
+
28
+ public struct Identity {
29
+ public var appId: String, shellMajor: Int, band: String
30
+ /// Installed app version, x.y.z.
31
+ public var shellVersion: String
32
+ /// Ed25519 public key (base64) built into the app. Never read from the network.
33
+ public var publicKey: String
34
+ public init(appId: String, shellMajor: Int, band: String, shellVersion: String, publicKey: String) {
35
+ self.appId = appId; self.shellMajor = shellMajor; self.band = band; self.shellVersion = shellVersion; self.publicKey = publicKey
36
+ }
37
+ }
38
+
39
+ /// a < b for x.y.z. Pre-release tags are ignored. Same as `semverLess` in the TypeScript core.
40
+ func semverLess(_ a: String, _ b: String) -> Bool {
41
+ func parts(_ v: String) -> [Int] { String(v.split(separator: "-", omittingEmptySubsequences: false).first ?? "").split(separator: ".", omittingEmptySubsequences: false).map { Int($0) ?? 0 } }
42
+ let pa = parts(a), pb = parts(b)
43
+ for i in 0..<3 {
44
+ let x = i < pa.count ? pa[i] : 0, y = i < pb.count ? pb[i] : 0
45
+ if x != y { return x < y }
46
+ }
47
+ return false
48
+ }
49
+
50
+ public enum BundleVerify {
51
+ public static func sha256Hex(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
52
+
53
+ static func signatureOK(body: String, signature: String, publicKey: String) -> Bool {
54
+ guard let sig = Data(base64Encoded: signature), let raw = Data(base64Encoded: publicKey),
55
+ let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw) else { return false }
56
+ return key.isValidSignature(sig, for: Data(body.utf8))
57
+ }
58
+
59
+ /// Same order as the TypeScript core: signature over the exact body bytes first, then the body, then the minimum shell version, then size and sha-256.
60
+ /// Returns the release number. Any failure throws and nothing is stored.
61
+ public static func check(body: String, signature: String, archive: Data, identity: Identity) throws -> Int {
62
+ guard signatureOK(body: body, signature: signature, publicKey: identity.publicKey) else { throw BundleError.badSignature }
63
+ guard let p = try? JSONDecoder().decode(Pointer.self, from: Data(body.utf8)),
64
+ p.kind == "web", (p.platform ?? "android") == "ios", p.app_id == identity.appId,
65
+ p.shell_major == identity.shellMajor, p.band == identity.band, p.release > 0 else { throw BundleError.badPointer }
66
+ // Rule 5: minimum shell version.
67
+ if semverLess(identity.shellVersion, p.min_shell_version) { throw BundleError.shellTooOld }
68
+ guard archive.count == p.core.size, sha256Hex(archive) == p.core.sha256 else { throw BundleError.badHash }
69
+ return p.release
70
+ }
71
+ }
@@ -0,0 +1,120 @@
1
+ import Foundation
2
+
3
+ /// Bundles under `<root>/bundles/release-<n>/bundle.zip` and release state in `<root>/state.json`.
4
+ /// Same release rules as the TypeScript core (10, 11, 12, 14): three trial boots for a pending release,
5
+ /// more than three boots in a minute reverts the current one, rollback blocks it.
6
+ public final class BundleStore {
7
+ public static let maxBoots = 3
8
+ public static let windowMs = 60_000
9
+
10
+ struct State: Codable {
11
+ var current = 0, pending = 0, previous = 0, blocked = 0, pendingBoots = 0
12
+ var bootTimes: [Int] = []
13
+ var hashes: [String: String] = [:]
14
+ }
15
+
16
+ private let root: URL
17
+ private let identity: Identity
18
+ private let now: () -> Int
19
+ private var s: State
20
+
21
+ public init(root: URL, identity: Identity, now: @escaping () -> Int = { Int(Date().timeIntervalSince1970 * 1000) }) throws {
22
+ self.root = root; self.identity = identity; self.now = now
23
+ do { try FileManager.default.createDirectory(at: root.appendingPathComponent("bundles"), withIntermediateDirectories: true) }
24
+ catch { throw BundleError.badStorage }
25
+ // A state file that cannot be read starts clean: no release is trusted without a verified record.
26
+ if let d = try? Data(contentsOf: root.appendingPathComponent("state.json")), let st = try? JSONDecoder().decode(State.self, from: d) { s = st }
27
+ else { s = State() }
28
+ }
29
+
30
+ public var current: Int { s.current }
31
+ public var pending: Int { s.pending }
32
+ public var previous: Int { s.previous }
33
+ public var blocked: Int { s.blocked }
34
+
35
+ public func directory(for release: Int) -> URL { root.appendingPathComponent("bundles/release-\(release)") }
36
+ public func archiveURL(for release: Int) -> URL { directory(for: release).appendingPathComponent("bundle.zip") }
37
+
38
+ /// Verifies, then stores the archive and makes it the pending release. A refused bundle changes nothing.
39
+ @discardableResult
40
+ public func stage(body: String, signature: String, archive: Data) throws -> Int {
41
+ let release: Int
42
+ do { release = try BundleVerify.check(body: body, signature: signature, archive: archive, identity: identity) }
43
+ catch BundleError.badHash {
44
+ // Like the TypeScript core: a release whose size or sha-256 does not match is blocked, so it is not tried again.
45
+ // The signature was valid, so the release number is trusted.
46
+ if let r = signedRelease(body: body, signature: signature) { s.blocked = max(s.blocked, r); try save() }
47
+ throw BundleError.badHash
48
+ }
49
+ if release <= max(s.current, s.pending, s.blocked) { throw BundleError.badPointer }
50
+ let dir = directory(for: release)
51
+ do {
52
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
53
+ try archive.write(to: archiveURL(for: release), options: .atomic)
54
+ } catch { throw BundleError.badStorage }
55
+ let old = s.pending
56
+ s.hashes[String(release)] = BundleVerify.sha256Hex(archive)
57
+ s.pending = release; s.pendingBoots = 0
58
+ if old != 0 && old != release { drop(old) }
59
+ try save()
60
+ return release
61
+ }
62
+
63
+ /// The release number of a pointer whose signature is valid, else nil.
64
+ private func signedRelease(body: String, signature: String) -> Int? {
65
+ struct R: Decodable { let release: Int }
66
+ guard BundleVerify.signatureOK(body: body, signature: signature, publicKey: identity.publicKey),
67
+ let r = try? JSONDecoder().decode(R.self, from: Data(body.utf8)) else { return nil }
68
+ return r.release
69
+ }
70
+
71
+ /// The release to serve now, or 0 for the embedded bundle. The stored hash is re-checked before a file is chosen.
72
+ public func boot() throws -> Int {
73
+ let t = now()
74
+ if s.pending != 0 {
75
+ if s.pendingBoots >= Self.maxBoots {
76
+ blockAndDrop(s.pending); s.pending = 0; s.pendingBoots = 0
77
+ } else if intact(s.pending) {
78
+ s.pendingBoots += 1; try save(); return s.pending
79
+ } else { blockAndDrop(s.pending); s.pending = 0; s.pendingBoots = 0 }
80
+ }
81
+ if s.current != 0 {
82
+ s.bootTimes = s.bootTimes.filter { t - $0 < Self.windowMs } + [t]
83
+ if s.bootTimes.count > Self.maxBoots || !intact(s.current) {
84
+ blockAndDrop(s.current); s.current = s.previous; s.previous = 0; s.bootTimes = []
85
+ }
86
+ }
87
+ try save()
88
+ return s.current
89
+ }
90
+
91
+ /// Rule 12: the pending release that is running becomes current.
92
+ public func markHealthy(running: Int) throws {
93
+ guard s.pending != 0, s.pending == running else { return }
94
+ if s.previous != 0 { drop(s.previous) }
95
+ s.previous = s.current; s.current = s.pending; s.pending = 0; s.pendingBoots = 0; s.bootTimes = []
96
+ try save()
97
+ }
98
+
99
+ /// Rule 14: drop pending, block the current release, go back to the previous one.
100
+ public func rollback() throws {
101
+ if s.pending != 0 { drop(s.pending); s.pending = 0; s.pendingBoots = 0 }
102
+ if s.current != 0 { blockAndDrop(s.current) }
103
+ s.current = s.previous; s.previous = 0; s.bootTimes = []
104
+ try save()
105
+ }
106
+
107
+ private func intact(_ release: Int) -> Bool {
108
+ guard let d = try? Data(contentsOf: archiveURL(for: release)) else { return false }
109
+ return BundleVerify.sha256Hex(d) == s.hashes[String(release)]
110
+ }
111
+ private func blockAndDrop(_ release: Int) { s.blocked = max(s.blocked, release); drop(release) }
112
+ private func drop(_ release: Int) {
113
+ s.hashes[String(release)] = nil
114
+ try? FileManager.default.removeItem(at: directory(for: release))
115
+ }
116
+ private func save() throws {
117
+ do { try JSONEncoder().encode(s).write(to: root.appendingPathComponent("state.json"), options: .atomic) }
118
+ catch { throw BundleError.badStorage }
119
+ }
120
+ }
@@ -0,0 +1,116 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import ShipvoBundle
4
+
5
+ /// The native half of @shipvo/capacitor on iOS. Same plugin name (`Shipvo`), methods and results as the Android plugin
6
+ /// (`ShipvoNativePlugin` in src/native.ts). It backs the core's key-value store and bundle files, and points the web view
7
+ /// at an unpacked bundle. It makes no release decisions: the TypeScript core verifies the signature and hash, and this
8
+ /// class checks the hash it is given again before it unpacks.
9
+ @objc(ShipvoPlugin)
10
+ public class ShipvoPlugin: CAPPlugin, CAPBridgedPlugin {
11
+ public let identifier = "ShipvoPlugin"
12
+ public let jsName = "Shipvo"
13
+ public let pluginMethods: [CAPPluginMethod] = [
14
+ CAPPluginMethod(name: "kvGet", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "kvSet", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "fileWrite", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "fileRead", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "fileRemove", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "fileList", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "fileClear", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "serve", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "status", returnType: CAPPluginReturnPromise),
23
+ ]
24
+
25
+ private let defaults = UserDefaults(suiteName: "shipvo") ?? .standard
26
+ // Instance state, like the Android plugin: it lasts as long as the bridge, and a new bridge starts on the embedded assets.
27
+ private var serving: String?
28
+ private var servingPath: String?
29
+
30
+ private lazy var root: URL = {
31
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("shipvo")
32
+ try? FileManager.default.createDirectory(at: base.appendingPathComponent("bundles"), withIntermediateDirectories: true)
33
+ return base
34
+ }()
35
+ private var bundles: URL { root.appendingPathComponent("bundles") }
36
+ private var served: URL { root.appendingPathComponent("served") }
37
+
38
+ private func file(_ name: String?) throws -> URL {
39
+ guard let n = name, n.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil, !n.contains("..") else {
40
+ throw Unzip.Failure(message: "bad file name")
41
+ }
42
+ return bundles.appendingPathComponent(n)
43
+ }
44
+
45
+ @objc func kvGet(_ call: CAPPluginCall) {
46
+ guard let key = call.getString("key"), let v = defaults.string(forKey: key) else { call.resolve([:]); return }
47
+ call.resolve(["value": v])
48
+ }
49
+
50
+ @objc func kvSet(_ call: CAPPluginCall) {
51
+ guard let key = call.getString("key"), let value = call.getString("value") else { call.reject("key and value are required"); return }
52
+ defaults.set(value, forKey: key)
53
+ // The boot counter must be on disk before the page runs.
54
+ defaults.synchronize()
55
+ call.resolve()
56
+ }
57
+
58
+ @objc func fileWrite(_ call: CAPPluginCall) {
59
+ do {
60
+ guard let data = Data(base64Encoded: call.getString("data") ?? "") else { throw Unzip.Failure(message: "bad data") }
61
+ try data.write(to: try file(call.getString("name")), options: .atomic)
62
+ call.resolve()
63
+ } catch { call.reject("could not write: \(error.localizedDescription)") }
64
+ }
65
+
66
+ @objc func fileRead(_ call: CAPPluginCall) {
67
+ do { call.resolve(["data": try Data(contentsOf: try file(call.getString("name"))).base64EncodedString()]) }
68
+ catch { call.reject(error.localizedDescription) }
69
+ }
70
+
71
+ @objc func fileRemove(_ call: CAPPluginCall) {
72
+ do { try? FileManager.default.removeItem(at: try file(call.getString("name"))); call.resolve() }
73
+ catch { call.reject(error.localizedDescription) }
74
+ }
75
+
76
+ @objc func fileList(_ call: CAPPluginCall) {
77
+ let names = ((try? FileManager.default.contentsOfDirectory(atPath: bundles.path)) ?? []).filter { !$0.hasSuffix(".tmp") }.sorted()
78
+ call.resolve(["names": names])
79
+ }
80
+
81
+ @objc func fileClear(_ call: CAPPluginCall) {
82
+ for n in (try? FileManager.default.contentsOfDirectory(atPath: bundles.path)) ?? [] { try? FileManager.default.removeItem(at: bundles.appendingPathComponent(n)) }
83
+ try? FileManager.default.removeItem(at: served)
84
+ serving = nil; servingPath = nil
85
+ call.resolve()
86
+ }
87
+
88
+ /// Serve a stored bundle. `sha256` is the hash the core verified; it is checked again before unpacking.
89
+ @objc func serve(_ call: CAPPluginCall) {
90
+ do {
91
+ let name = call.getString("name")
92
+ let zip = try Data(contentsOf: try file(name))
93
+ guard let want = call.getString("sha256"), BundleVerify.sha256Hex(zip).lowercased() == want.lowercased() else {
94
+ throw Unzip.Failure(message: "bundle hash does not match")
95
+ }
96
+ try? FileManager.default.removeItem(at: served)
97
+ let target = served.appendingPathComponent(name!)
98
+ try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
99
+ do {
100
+ try Unzip.extract(zip, to: target)
101
+ guard FileManager.default.fileExists(atPath: target.appendingPathComponent("index.html").path) else { throw Unzip.Failure(message: "bundle has no index.html") }
102
+ } catch { try? FileManager.default.removeItem(at: served); throw error }
103
+ DispatchQueue.main.async {
104
+ guard let vc = self.bridge?.viewController as? CAPBridgeViewController else { call.reject("no web view to point at the bundle"); return }
105
+ vc.setServerBasePath(path: target.path)
106
+ self.serving = name; self.servingPath = target.path
107
+ call.resolve()
108
+ }
109
+ } catch { call.reject(error.localizedDescription) }
110
+ }
111
+
112
+ /// Which bundle this process is already serving, so the page it loads does not boot again.
113
+ @objc func status(_ call: CAPPluginCall) {
114
+ if let s = serving, servingPath != nil { call.resolve(["serving": s]) } else { call.resolve([:]) }
115
+ }
116
+ }
@@ -0,0 +1,50 @@
1
+ import Compression
2
+ import Foundation
3
+
4
+ /// Reads a zip archive held in memory (stored and deflate entries). Entries that would leave the target directory are refused.
5
+ enum Unzip {
6
+ struct Failure: Error, LocalizedError { let message: String; var errorDescription: String? { message } }
7
+ static let maxEntryBytes = 256 * 1024 * 1024
8
+
9
+ static func extract(_ zip: Data, to target: URL) throws {
10
+ let b = [UInt8](zip)
11
+ func u16(_ o: Int) -> Int { Int(b[o]) | Int(b[o + 1]) << 8 }
12
+ func u32(_ o: Int) -> Int { u16(o) | u16(o + 2) << 16 }
13
+ guard b.count >= 22 else { throw Failure(message: "bad entry in bundle") }
14
+ // End of central directory record: the last 22+ bytes, signature 0x06054b50.
15
+ var eocd = -1
16
+ var i = b.count - 22
17
+ while i >= max(0, b.count - 22 - 65_535) { if u32(i) == 0x0605_4b50 { eocd = i; break }; i -= 1 }
18
+ guard eocd >= 0 else { throw Failure(message: "bad entry in bundle") }
19
+ let count = u16(eocd + 10)
20
+ var p = u32(eocd + 16)
21
+ let root = target.standardizedFileURL.path + "/"
22
+ let fm = FileManager.default
23
+ for _ in 0..<count {
24
+ guard p + 46 <= b.count, u32(p) == 0x0201_4b50 else { throw Failure(message: "bad entry in bundle") }
25
+ let method = u16(p + 10), csize = u32(p + 20), usize = u32(p + 24)
26
+ let nlen = u16(p + 28), elen = u16(p + 30), clen = u16(p + 32), local = u32(p + 42)
27
+ guard p + 46 + nlen <= b.count, local + 30 <= b.count, u32(local) == 0x0403_4b50, usize <= maxEntryBytes else { throw Failure(message: "bad entry in bundle") }
28
+ let name = String(decoding: b[(p + 46)..<(p + 46 + nlen)], as: UTF8.self)
29
+ p += 46 + nlen + elen + clen
30
+ let out = target.appendingPathComponent(name).standardizedFileURL
31
+ guard out.path.hasPrefix(root) else { throw Failure(message: "bad entry in bundle") }
32
+ if name.hasSuffix("/") { try fm.createDirectory(at: out, withIntermediateDirectories: true); continue }
33
+ let start = local + 30 + u16(local + 26) + u16(local + 28)
34
+ guard start + csize <= b.count else { throw Failure(message: "bad entry in bundle") }
35
+ let raw = Data(b[start..<(start + csize)])
36
+ var data: Data
37
+ if method == 0 { data = raw }
38
+ else if method == 8 {
39
+ var dst = [UInt8](repeating: 0, count: max(usize, 1))
40
+ let n = raw.withUnsafeBytes { src in
41
+ compression_decode_buffer(&dst, dst.count, src.bindMemory(to: UInt8.self).baseAddress!, raw.count, nil, COMPRESSION_ZLIB)
42
+ }
43
+ guard n == usize else { throw Failure(message: "bad entry in bundle") }
44
+ data = Data(dst.prefix(n))
45
+ } else { throw Failure(message: "bad entry in bundle") }
46
+ try fm.createDirectory(at: out.deletingLastPathComponent(), withIntermediateDirectories: true)
47
+ try data.write(to: out)
48
+ }
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@shipvo/capacitor",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Signed over-the-air updates for Capacitor web bundles: the same release decisions and trust model as the Shipvo React Native runtime.",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
9
  "dependencies": {
10
- "@shipvo/schema": "^0.0.5"
10
+ "@shipvo/schema": "^0.0.7"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/node": "^22.8.0",
@@ -31,6 +31,10 @@
31
31
  },
32
32
  "files": [
33
33
  "dist",
34
+ "android/build.gradle",
35
+ "android/src/main",
36
+ "Package.swift",
37
+ "ios/Sources",
34
38
  "README.md"
35
39
  ],
36
40
  "publishConfig": {
@@ -40,6 +44,11 @@
40
44
  "engines": {
41
45
  "node": ">=20"
42
46
  },
47
+ "capacitor": {
48
+ "ios": {
49
+ "src": "ios"
50
+ }
51
+ },
43
52
  "scripts": {
44
53
  "build": "tsc -p tsconfig.json",
45
54
  "test": "vitest run"