@shipvo/capacitor 0.0.1 → 0.0.2
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/README.md +35 -2
- package/android/build.gradle +18 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/shipvo/capacitor/BundleStore.java +116 -0
- package/android/src/main/java/dev/shipvo/capacitor/ShipvoPlugin.java +107 -0
- package/dist/engine.d.ts +8 -0
- package/dist/engine.js +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/native.d.ts +72 -0
- package/dist/native.js +55 -0
- package/package.json +4 -2
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
|
-
|
|
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(), //
|
|
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";
|
package/dist/native.d.ts
ADDED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipvo/capacitor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
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.
|
|
10
|
+
"@shipvo/schema": "^0.0.6"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@types/node": "^22.8.0",
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
|
+
"android/build.gradle",
|
|
35
|
+
"android/src/main",
|
|
34
36
|
"README.md"
|
|
35
37
|
],
|
|
36
38
|
"publishConfig": {
|