@shipvo/capacitor 0.0.3 → 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.
@@ -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
- try {
174
- const url = new URL(p.core.url, this.c.baseUrl.replace(/\/?$/, "/")).toString();
175
- const r = await f(url);
176
- if (!r.ok)
177
- throw new Error(`HTTP ${r.status}`);
178
- bytes = new Uint8Array(await r.arrayBuffer());
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
- catch (e) {
181
- this.push(s, "download_failed");
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
- await this.c.files.write(bundleName(p.release), bytes);
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
- constructor(n: ShipvoNativePlugin);
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
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@shipvo/capacitor",
3
- "version": "0.0.3",
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.7"
10
+ "@shipvo/schema": "^0.0.8"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/node": "^22.8.0",