@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 +19 -0
- 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/ios/Sources/ShipvoBundle/Bundle.swift +71 -0
- package/ios/Sources/ShipvoBundle/BundleStore.swift +120 -0
- package/ios/Sources/ShipvoCapacitor/ShipvoPlugin.swift +116 -0
- package/ios/Sources/ShipvoCapacitor/Unzip.swift +50 -0
- package/package.json +11 -2
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
|
-
|
|
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
|
+
}
|
|
@@ -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.
|
|
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.
|
|
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"
|