@shipvo/capacitor 0.0.2 → 0.0.4
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/android/build.gradle +2 -0
- package/android/src/main/java/dev/shipvo/capacitor/BundleStore.java +12 -0
- package/android/src/main/java/dev/shipvo/capacitor/Patch.java +55 -0
- package/android/src/main/java/dev/shipvo/capacitor/PatchStep.java +35 -0
- package/android/src/main/java/dev/shipvo/capacitor/ShipvoPlugin.java +14 -0
- package/dist/engine.js +43 -14
- package/dist/index.d.ts +1 -1
- package/dist/native.d.ts +17 -1
- package/dist/native.js +4 -0
- package/dist/types.d.ts +18 -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 +9 -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/android/build.gradle
CHANGED
|
@@ -14,5 +14,7 @@ android {
|
|
|
14
14
|
|
|
15
15
|
dependencies {
|
|
16
16
|
implementation project(':capacitor-android')
|
|
17
|
+
// bzip2 for the BSDIFF40 patch step; the same version packages/runtime uses.
|
|
18
|
+
implementation 'org.apache.commons:commons-compress:1.21'
|
|
17
19
|
testImplementation "junit:junit:4.13.2"
|
|
18
20
|
}
|
|
@@ -57,6 +57,18 @@ public final class BundleStore {
|
|
|
57
57
|
for (int n; (n = in.read(buf)) > 0; ) out.write(buf, 0, n);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Builds `name` from the stored `baseName` and a patch. Writes the file only when the patch, the
|
|
62
|
+
* rebuilt size and the rebuilt hash all check out; otherwise the caller downloads the full bundle.
|
|
63
|
+
*/
|
|
64
|
+
public PatchStep.Outcome patch(String baseName, byte[] patch, String patchSha256, String name, long newSize, String newSha256, long maxMemory) {
|
|
65
|
+
byte[] base = null;
|
|
66
|
+
try { if (file(baseName).length() <= maxMemory) base = read(baseName); } catch (IOException | OutOfMemoryError e) { base = null; }
|
|
67
|
+
PatchStep.Outcome o = PatchStep.run(base, patch, patchSha256, newSize, newSha256, maxMemory);
|
|
68
|
+
if (o.bytes == null) return o;
|
|
69
|
+
try { write(name, o.bytes); return o; } catch (IOException e) { return new PatchStep.Outcome("full", null, e.getMessage()); }
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
public void remove(String name) throws IOException { file(name).delete(); }
|
|
61
73
|
|
|
62
74
|
public List<String> list() {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package dev.shipvo.capacitor;
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayInputStream;
|
|
4
|
+
import java.io.IOException;
|
|
5
|
+
import java.io.InputStream;
|
|
6
|
+
import java.nio.charset.StandardCharsets;
|
|
7
|
+
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
|
|
8
|
+
|
|
9
|
+
/** bspatch for the BSDIFF40 format (bsdiff 4.x): header, then bzip2 control, diff and extra blocks. Copied in small form from packages/runtime. */
|
|
10
|
+
final class Patch {
|
|
11
|
+
private Patch() {}
|
|
12
|
+
|
|
13
|
+
private static long offtin(byte[] b, int o) {
|
|
14
|
+
long y = b[o + 7] & 0x7F;
|
|
15
|
+
for (int i = 6; i >= 0; i--) y = (y << 8) | (b[o + i] & 0xFF);
|
|
16
|
+
return (b[o + 7] & 0x80) != 0 ? -y : y;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private static void readFully(InputStream in, byte[] buf, int off, int len) throws IOException {
|
|
20
|
+
int n = 0;
|
|
21
|
+
while (n < len) {
|
|
22
|
+
int r = in.read(buf, off + n, len - n);
|
|
23
|
+
if (r <= 0) throw new IOException("patch truncated");
|
|
24
|
+
n += r;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static byte[] apply(byte[] old, byte[] patch) throws IOException {
|
|
29
|
+
if (patch.length < 32 || !"BSDIFF40".equals(new String(patch, 0, 8, StandardCharsets.US_ASCII))) throw new IOException("not a BSDIFF40 patch");
|
|
30
|
+
long ctrlLen = offtin(patch, 8), diffLen = offtin(patch, 16), newSize = offtin(patch, 24);
|
|
31
|
+
if (ctrlLen < 0 || diffLen < 0 || newSize < 0 || newSize > (64L << 20) || 32 + ctrlLen + diffLen > patch.length) throw new IOException("bad patch header");
|
|
32
|
+
int extraStart = (int) (32 + ctrlLen + diffLen);
|
|
33
|
+
byte[] out = new byte[(int) newSize];
|
|
34
|
+
try (InputStream ctrl = new BZip2CompressorInputStream(new ByteArrayInputStream(patch, 32, (int) ctrlLen));
|
|
35
|
+
InputStream diff = new BZip2CompressorInputStream(new ByteArrayInputStream(patch, 32 + (int) ctrlLen, (int) diffLen));
|
|
36
|
+
InputStream extra = new BZip2CompressorInputStream(new ByteArrayInputStream(patch, extraStart, patch.length - extraStart))) {
|
|
37
|
+
long oldPos = 0, newPos = 0;
|
|
38
|
+
byte[] c = new byte[24];
|
|
39
|
+
while (newPos < newSize) {
|
|
40
|
+
readFully(ctrl, c, 0, 24);
|
|
41
|
+
long x = offtin(c, 0), y = offtin(c, 8), z = offtin(c, 16);
|
|
42
|
+
if (x < 0 || y < 0 || newPos + x + y > newSize) throw new IOException("corrupt patch control");
|
|
43
|
+
readFully(diff, out, (int) newPos, (int) x);
|
|
44
|
+
for (int i = 0; i < x; i++) {
|
|
45
|
+
long op = oldPos + i;
|
|
46
|
+
if (op >= 0 && op < old.length) out[(int) newPos + i] += old[(int) op];
|
|
47
|
+
}
|
|
48
|
+
newPos += x; oldPos += x;
|
|
49
|
+
readFully(extra, out, (int) newPos, (int) y);
|
|
50
|
+
newPos += y; oldPos += z;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package dev.shipvo.capacitor;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rule 9 on Android: apply a patch to the stored base bundle, check it, and say whether the
|
|
5
|
+
* caller may use the result. Anything else means "download the full bundle".
|
|
6
|
+
*/
|
|
7
|
+
public final class PatchStep {
|
|
8
|
+
private PatchStep() {}
|
|
9
|
+
|
|
10
|
+
/** Share of the app's heap limit that base + patch + output may use together. */
|
|
11
|
+
public static final double BUDGET_FRACTION = 0.4;
|
|
12
|
+
public static final String OUT_OF_MEMORY = "out_of_memory";
|
|
13
|
+
public static final String OVER_BUDGET = "over_memory_budget";
|
|
14
|
+
|
|
15
|
+
/** `bytes` is the rebuilt bundle when `via` is "patch"; otherwise `patchError` says why the full bundle is needed. */
|
|
16
|
+
public static final class Outcome {
|
|
17
|
+
public final String via; public final byte[] bytes; public final String patchError;
|
|
18
|
+
Outcome(String via, byte[] bytes, String patchError) { this.via = via; this.bytes = bytes; this.patchError = patchError; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public static boolean fits(long base, long patch, long out, long maxMemory) {
|
|
22
|
+
return base + patch + out <= (long) (maxMemory * BUDGET_FRACTION);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public static Outcome run(byte[] base, byte[] patch, String patchSha256, long newSize, String newSha256, long maxMemory) {
|
|
26
|
+
if (base == null || !fits(base.length, patch.length, newSize, maxMemory)) return new Outcome("full", null, OVER_BUDGET);
|
|
27
|
+
try {
|
|
28
|
+
if (!BundleStore.sha256Hex(patch).equalsIgnoreCase(patchSha256)) throw new IllegalStateException("patch hash mismatch");
|
|
29
|
+
byte[] out = Patch.apply(base, patch);
|
|
30
|
+
if (out.length != newSize || !BundleStore.sha256Hex(out).equalsIgnoreCase(newSha256)) throw new IllegalStateException("patched bundle hash mismatch");
|
|
31
|
+
return new Outcome("patch", out, null);
|
|
32
|
+
} catch (OutOfMemoryError e) { return new Outcome("full", null, OUT_OF_MEMORY); }
|
|
33
|
+
catch (Exception e) { return new Outcome("full", null, String.valueOf(e.getMessage())); }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -57,6 +57,20 @@ public class ShipvoPlugin extends Plugin {
|
|
|
57
57
|
} catch (Exception e) { call.reject(e.getMessage()); }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Rule 9: build `name` from the stored `base` and a patch. Resolves `via` "patch", or "full" with `patchError`. */
|
|
61
|
+
@PluginMethod
|
|
62
|
+
public void filePatch(PluginCall call) {
|
|
63
|
+
try {
|
|
64
|
+
byte[] patch = Base64.decode(call.getString("patch", ""), Base64.NO_WRAP);
|
|
65
|
+
PatchStep.Outcome o = store().patch(call.getString("base"), patch, call.getString("patchSha256"), call.getString("name"),
|
|
66
|
+
call.getLong("size", -1L), call.getString("sha256"), Runtime.getRuntime().maxMemory());
|
|
67
|
+
JSObject r = new JSObject();
|
|
68
|
+
r.put("via", o.via);
|
|
69
|
+
if (o.patchError != null) r.put("patchError", o.patchError);
|
|
70
|
+
call.resolve(r);
|
|
71
|
+
} catch (Exception e) { call.reject(e.getMessage()); }
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
@PluginMethod
|
|
61
75
|
public void fileRead(PluginCall call) {
|
|
62
76
|
try {
|
package/dist/engine.js
CHANGED
|
@@ -170,28 +170,57 @@ export class ShipvoCapacitor {
|
|
|
170
170
|
return { status: "none", release: p.release };
|
|
171
171
|
const t1 = this.now();
|
|
172
172
|
let bytes;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
// Rule 9: a patch only when its from_sha256 is the hash of the bundle this device runs (the current release).
|
|
174
|
+
let via = "full";
|
|
175
|
+
let patchError;
|
|
176
|
+
let patchedBytes = 0;
|
|
177
|
+
const baseSha = s.current ? s.hashes[String(s.current)] : undefined;
|
|
178
|
+
const chosen = baseSha && this.c.files.patch ? p.core.patches?.find((x) => x.from_sha256 === baseSha) : undefined;
|
|
179
|
+
if (chosen && this.c.files.patch) {
|
|
180
|
+
try {
|
|
181
|
+
const r = await f(new URL(chosen.url, this.c.baseUrl.replace(/\/?$/, "/")).toString());
|
|
182
|
+
if (!r.ok)
|
|
183
|
+
throw new Error(`patch HTTP ${r.status}`);
|
|
184
|
+
const patch = new Uint8Array(await r.arrayBuffer());
|
|
185
|
+
if (patch.byteLength !== chosen.size)
|
|
186
|
+
throw new Error("patch size mismatch");
|
|
187
|
+
const o = await this.c.files.patch({ base: bundleName(s.current), patch, patchSha256: chosen.sha256, name: bundleName(p.release), size: p.core.size, sha256: p.core.sha256 });
|
|
188
|
+
via = o.via;
|
|
189
|
+
patchError = o.patchError;
|
|
190
|
+
if (o.via === "patch")
|
|
191
|
+
patchedBytes = patch.byteLength;
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
patchError = String(e?.message ?? e);
|
|
195
|
+
}
|
|
179
196
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
await this.save();
|
|
183
|
-
return { status: "error", release: p.release, error: String(e?.message ?? e) };
|
|
197
|
+
if (via === "patch") {
|
|
198
|
+
bytes = new Uint8Array(0);
|
|
184
199
|
}
|
|
200
|
+
else
|
|
201
|
+
try {
|
|
202
|
+
const url = new URL(p.core.url, this.c.baseUrl.replace(/\/?$/, "/")).toString();
|
|
203
|
+
const r = await f(url);
|
|
204
|
+
if (!r.ok)
|
|
205
|
+
throw new Error(`HTTP ${r.status}`);
|
|
206
|
+
bytes = new Uint8Array(await r.arrayBuffer());
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
this.push(s, "download_failed");
|
|
210
|
+
await this.save();
|
|
211
|
+
return { status: "error", release: p.release, error: String(e?.message ?? e) };
|
|
212
|
+
}
|
|
185
213
|
const t2 = this.now();
|
|
186
|
-
// Size and sha-256 against the signed pointer.
|
|
187
|
-
if (bytes.byteLength !== p.core.size || (await sha256Hex(bytes)) !== p.core.sha256) {
|
|
214
|
+
// Size and sha-256 against the signed pointer. A patched bundle was already checked natively.
|
|
215
|
+
if (via === "full" && (bytes.byteLength !== p.core.size || (await sha256Hex(bytes)) !== p.core.sha256)) {
|
|
188
216
|
this.block(s, p.release);
|
|
189
217
|
return this.reject(s, "verify_failed", "bundle size or sha-256 does not match", p.release);
|
|
190
218
|
}
|
|
191
219
|
const t3 = this.now();
|
|
192
220
|
// Stage: write the file, then point `pending` at it. A replaced pending release is dropped.
|
|
193
221
|
const old = s.pending;
|
|
194
|
-
|
|
222
|
+
if (via === "full")
|
|
223
|
+
await this.c.files.write(bundleName(p.release), bytes);
|
|
195
224
|
s.hashes[String(p.release)] = p.core.sha256;
|
|
196
225
|
s.pending = p.release;
|
|
197
226
|
s.pendingBoots = 0;
|
|
@@ -200,7 +229,7 @@ export class ShipvoCapacitor {
|
|
|
200
229
|
await this.save();
|
|
201
230
|
if (apply)
|
|
202
231
|
await this.reload();
|
|
203
|
-
return { status: "staged", release: p.release, bytes: bytes.byteLength, downloadMs: t2 - t1, verifyMs: t3 - t2, issuedAt: p.issued_at };
|
|
232
|
+
return { status: "staged", release: p.release, bytes: via === "patch" ? patchedBytes : bytes.byteLength, via, ...(patchError ? { patchError } : {}), downloadMs: t2 - t1, verifyMs: t3 - t2, issuedAt: p.issued_at };
|
|
204
233
|
}
|
|
205
234
|
push(s, e) { if (!s.events.includes(e) || !e.startsWith("pointer_rejected"))
|
|
206
235
|
s.events.push(e); }
|
package/dist/index.d.ts
CHANGED
|
@@ -4,4 +4,4 @@ export type { ShipvoConfig } from "./engine.js";
|
|
|
4
4
|
export { MemoryStore, MemoryFiles } from "./memory.js";
|
|
5
5
|
export { verifyEd25519, sha256Hex, semverLess } from "./verify.js";
|
|
6
6
|
export { NativeStore, NativeFiles, bootAndServe, bootAndSwitch } from "./native.js";
|
|
7
|
-
export type { ShipvoNativePlugin } from "./native.js";
|
|
7
|
+
export type { ShipvoNativePlugin, ShipvoPatchPlugin } from "./native.js";
|
package/dist/native.d.ts
CHANGED
|
@@ -37,6 +37,20 @@ export interface ShipvoNativePlugin {
|
|
|
37
37
|
serving?: string;
|
|
38
38
|
}>;
|
|
39
39
|
}
|
|
40
|
+
/** Build a stored bundle from another stored bundle and a BSDIFF40 patch. Native checks the patch hash, the rebuilt size and hash, and the memory budget. */
|
|
41
|
+
export interface ShipvoPatchPlugin {
|
|
42
|
+
filePatch(o: {
|
|
43
|
+
base: string;
|
|
44
|
+
name: string;
|
|
45
|
+
patch: string;
|
|
46
|
+
patchSha256: string;
|
|
47
|
+
size: number;
|
|
48
|
+
sha256: string;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
via: "patch" | "full";
|
|
51
|
+
patchError?: string;
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
40
54
|
/** `KeyValueStore` backed by the native plugin (SharedPreferences on Android). */
|
|
41
55
|
export declare class NativeStore implements KeyValueStore {
|
|
42
56
|
private n;
|
|
@@ -47,7 +61,9 @@ export declare class NativeStore implements KeyValueStore {
|
|
|
47
61
|
/** `BundleFiles` backed by the native plugin (the app-private files directory on Android). */
|
|
48
62
|
export declare class NativeFiles implements BundleFiles {
|
|
49
63
|
private n;
|
|
50
|
-
|
|
64
|
+
/** Set only when the plugin has `filePatch`, so the core tries a patch only where one can be applied. */
|
|
65
|
+
patch?: BundleFiles["patch"];
|
|
66
|
+
constructor(n: ShipvoNativePlugin & Partial<ShipvoPatchPlugin>);
|
|
51
67
|
write(name: string, bytes: Uint8Array): Promise<void>;
|
|
52
68
|
read(name: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
53
69
|
remove(name: string): Promise<void>;
|
package/dist/native.js
CHANGED
|
@@ -13,8 +13,12 @@ export class NativeStore {
|
|
|
13
13
|
/** `BundleFiles` backed by the native plugin (the app-private files directory on Android). */
|
|
14
14
|
export class NativeFiles {
|
|
15
15
|
n;
|
|
16
|
+
/** Set only when the plugin has `filePatch`, so the core tries a patch only where one can be applied. */
|
|
17
|
+
patch;
|
|
16
18
|
constructor(n) {
|
|
17
19
|
this.n = n;
|
|
20
|
+
if (n.filePatch)
|
|
21
|
+
this.patch = (o) => n.filePatch({ base: o.base, name: o.name, patch: toB64(o.patch), patchSha256: o.patchSha256, size: o.size, sha256: o.sha256 });
|
|
18
22
|
}
|
|
19
23
|
async write(name, bytes) { await this.n.fileWrite({ name, data: toB64(bytes) }); }
|
|
20
24
|
async read(name) { return fromB64((await this.n.fileRead({ name })).data); }
|
package/dist/types.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type CheckResult = {
|
|
|
21
21
|
verifyMs?: number;
|
|
22
22
|
issuedAt?: string;
|
|
23
23
|
error?: string;
|
|
24
|
+
/** How the bundle arrived: a patch against the current bundle, or the full file. `patchError` says why a patch was not used. */
|
|
25
|
+
via?: "patch" | "full";
|
|
26
|
+
patchError?: string;
|
|
24
27
|
reason?: "hermes_bytecode_mismatch" | "native_module_missing" | "native_module_version";
|
|
25
28
|
};
|
|
26
29
|
export type HealthyResult = {
|
|
@@ -52,6 +55,21 @@ export interface KeyValueStore {
|
|
|
52
55
|
export interface BundleFiles {
|
|
53
56
|
write(name: string, bytes: Uint8Array): Promise<void>;
|
|
54
57
|
remove(name: string): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Optional. Build `name` from the stored `base` file and a patch (rule 9). Native checks the patch hash,
|
|
60
|
+
* then the rebuilt size and hash, and writes `name` only on success; otherwise it reports `via: "full"`.
|
|
61
|
+
*/
|
|
62
|
+
patch?(o: {
|
|
63
|
+
base: string;
|
|
64
|
+
patch: Uint8Array;
|
|
65
|
+
patchSha256: string;
|
|
66
|
+
name: string;
|
|
67
|
+
size: number;
|
|
68
|
+
sha256: string;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
via: "patch" | "full";
|
|
71
|
+
patchError?: string;
|
|
72
|
+
}>;
|
|
55
73
|
/** Remove every file. Used when a new shell is installed. */
|
|
56
74
|
clear(): Promise<void>;
|
|
57
75
|
}
|
|
@@ -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.4",
|
|
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.8"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@types/node": "^22.8.0",
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"dist",
|
|
34
34
|
"android/build.gradle",
|
|
35
35
|
"android/src/main",
|
|
36
|
+
"Package.swift",
|
|
37
|
+
"ios/Sources",
|
|
36
38
|
"README.md"
|
|
37
39
|
],
|
|
38
40
|
"publishConfig": {
|
|
@@ -42,6 +44,11 @@
|
|
|
42
44
|
"engines": {
|
|
43
45
|
"node": ">=20"
|
|
44
46
|
},
|
|
47
|
+
"capacitor": {
|
|
48
|
+
"ios": {
|
|
49
|
+
"src": "ios"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
45
52
|
"scripts": {
|
|
46
53
|
"build": "tsc -p tsconfig.json",
|
|
47
54
|
"test": "vitest run"
|