@protobase/mobile 0.57.1 → 0.57.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/android/build.gradle +17 -0
- package/android/src/liveupdate/java/net/protobase/mobile/Bounded.java +30 -0
- package/android/src/liveupdate/java/net/protobase/mobile/BundleCheck.java +53 -0
- package/android/src/liveupdate/java/net/protobase/mobile/BundleRefused.java +9 -0
- package/android/src/liveupdate/java/net/protobase/mobile/BundleRemoval.java +33 -0
- package/android/src/liveupdate/java/net/protobase/mobile/GuardedLiveUpdate.java +138 -15
- package/android/src/liveupdate/java/net/protobase/mobile/Hex.java +42 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseBinary.java +30 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseDownload.java +128 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseManifest.java +98 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseOrder.java +46 -0
- package/android/src/liveupdate/java/net/protobase/mobile/ReleaseStatement.java +124 -0
- package/android/src/liveupdate/java/net/protobase/mobile/Sha256.java +53 -0
- package/ios/Sources/ProtobaseMobile/BundlesFolder.swift +26 -0
- package/ios/Sources/ProtobaseMobile/GuardedLiveUpdatePlugin.swift +95 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateDownload.swift +39 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateLoadCheck.swift +21 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateMethods.swift +15 -0
- package/ios/Sources/ProtobaseMobile/LiveUpdateSettings.swift +25 -0
- package/ios/Sources/ProtobaseMobile/LocalFileGuard.swift +8 -15
- package/ios/Sources/ProtobaseMobile/PageScriptFilter.swift +5 -3
- package/ios/Sources/ProtobaseMobile/RealPath.swift +8 -0
- package/ios/Sources/ProtobaseMobile/Refusal.swift +12 -0
- package/ios/Sources/ProtobaseMobile/ReleasesUrl.swift +13 -0
- package/ios/Sources/ProtobaseMobile/StockLiveUpdate.swift +58 -0
- package/package.json +2 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
import java.io.File;
|
|
4
|
+
import java.nio.charset.StandardCharsets;
|
|
5
|
+
import java.util.ArrayList;
|
|
6
|
+
import java.util.Collections;
|
|
7
|
+
import java.util.HashSet;
|
|
8
|
+
import java.util.List;
|
|
9
|
+
import java.util.Set;
|
|
10
|
+
import org.json.JSONArray;
|
|
11
|
+
import org.json.JSONException;
|
|
12
|
+
import org.json.JSONObject;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A release's manifest (`releaseManifestFile` in @protobase/schema): every file by `href`, with its sha256 and size.
|
|
16
|
+
* Each href is checked as the very string the guard joins to a folder, before any file of it is read or written.
|
|
17
|
+
*/
|
|
18
|
+
final class ReleaseManifest {
|
|
19
|
+
|
|
20
|
+
static final String FILE = "capawesome-live-update-manifest.json";
|
|
21
|
+
static final int MAX_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
static final int MAX_FILES = 10_000;
|
|
23
|
+
static final long MAX_FILE_BYTES = 64L * 1024 * 1024;
|
|
24
|
+
static final long MAX_TOTAL_BYTES = 256L * 1024 * 1024;
|
|
25
|
+
private static final int MAX_HREF = 1024;
|
|
26
|
+
|
|
27
|
+
static final class Item {
|
|
28
|
+
|
|
29
|
+
final String href;
|
|
30
|
+
final String checksum;
|
|
31
|
+
final long size;
|
|
32
|
+
|
|
33
|
+
Item(String href, String checksum, long size) {
|
|
34
|
+
this.href = href;
|
|
35
|
+
this.checksum = checksum;
|
|
36
|
+
this.size = size;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
final List<Item> items;
|
|
41
|
+
|
|
42
|
+
private ReleaseManifest(List<Item> items) {
|
|
43
|
+
this.items = Collections.unmodifiableList(items);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The manifest `bytes` hold, when they are the ones `statement` names, its files to lie inside `dir`. */
|
|
47
|
+
static ReleaseManifest verify(byte[] bytes, ReleaseStatement statement, File dir) throws BundleRefused {
|
|
48
|
+
if (!Sha256.of(bytes).equals(statement.manifest)) throw new BundleRefused("its manifest is not the one its statement names");
|
|
49
|
+
return parse(bytes, dir);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The manifest `bytes` hold, each href naming a file inside `dir`. */
|
|
53
|
+
static ReleaseManifest parse(byte[] bytes, File dir) throws BundleRefused {
|
|
54
|
+
if (bytes.length > MAX_BYTES) throw new BundleRefused("its manifest is too large");
|
|
55
|
+
try {
|
|
56
|
+
JSONArray array = new JSONArray(new String(bytes, StandardCharsets.UTF_8));
|
|
57
|
+
if (array.length() > MAX_FILES) throw new BundleRefused("its manifest lists too many files");
|
|
58
|
+
List<Item> items = new ArrayList<>();
|
|
59
|
+
Set<String> hrefs = new HashSet<>();
|
|
60
|
+
long total = 0;
|
|
61
|
+
for (int i = 0; i < array.length(); i++) {
|
|
62
|
+
Item item = item(array.getJSONObject(i), dir);
|
|
63
|
+
if (!hrefs.add(item.href)) throw new BundleRefused("its manifest lists " + item.href + " twice");
|
|
64
|
+
total += item.size;
|
|
65
|
+
if (total > MAX_TOTAL_BYTES) throw new BundleRefused("its files are too large");
|
|
66
|
+
items.add(item);
|
|
67
|
+
}
|
|
68
|
+
return new ReleaseManifest(items);
|
|
69
|
+
} catch (JSONException e) {
|
|
70
|
+
throw new BundleRefused("its manifest is malformed");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private static Item item(JSONObject entry, File dir) throws JSONException, BundleRefused {
|
|
75
|
+
if (entry.length() != 3) throw new BundleRefused("its manifest has an entry that is not an href, checksum and size");
|
|
76
|
+
String href = entry.getString("href");
|
|
77
|
+
String checksum = entry.getString("checksum");
|
|
78
|
+
Object size = entry.get("sizeInBytes");
|
|
79
|
+
if (!isRelativePath(href) || href.equals(FILE) || href.equals(ReleaseStatement.FILE) || !BundlesFolder.contains(dir, new File(dir, href))) {
|
|
80
|
+
throw new BundleRefused("its manifest names a file outside its folder");
|
|
81
|
+
}
|
|
82
|
+
if (!Hex.isHex(checksum, 64)) throw new BundleRefused("its manifest names no checksum for " + href);
|
|
83
|
+
if (!(size instanceof Integer || size instanceof Long) || ((Number) size).longValue() < 0 || ((Number) size).longValue() > MAX_FILE_BYTES) {
|
|
84
|
+
throw new BundleRefused("its manifest names no size for " + href);
|
|
85
|
+
}
|
|
86
|
+
return new Item(href, checksum, ((Number) size).longValue());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Path components joined by `/`: none empty, `.` or `..`, and no `\` or NUL, so neither an absolute path nor one
|
|
90
|
+
// that climbs, nor one another platform reads otherwise.
|
|
91
|
+
static boolean isRelativePath(String href) {
|
|
92
|
+
if (href.isEmpty() || href.length() > MAX_HREF || href.indexOf('\\') >= 0 || href.indexOf('\0') >= 0) return false;
|
|
93
|
+
for (String part : href.split("/", -1)) {
|
|
94
|
+
if (part.isEmpty() || part.equals(".") || part.equals("..")) return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Release orders, compared element by element, a missing element counting as 0, so a later server can add one without
|
|
5
|
+
* breaking binaries already shipped. Kept as text such as `1800000000000,0`.
|
|
6
|
+
*/
|
|
7
|
+
final class ReleaseOrder {
|
|
8
|
+
|
|
9
|
+
/** The built-in bundle's order. */
|
|
10
|
+
static final long[] BUILT_IN = { 0 };
|
|
11
|
+
|
|
12
|
+
private ReleaseOrder() {}
|
|
13
|
+
|
|
14
|
+
static int compare(long[] a, long[] b) {
|
|
15
|
+
for (int i = 0; i < Math.max(a.length, b.length); i++) {
|
|
16
|
+
long x = i < a.length ? a[i] : 0;
|
|
17
|
+
long y = i < b.length ? b[i] : 0;
|
|
18
|
+
if (x != y) return x < y ? -1 : 1;
|
|
19
|
+
}
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Refuses a bundle of order `next` as the next bundle unless it is above the running one's, `current`, and not below
|
|
25
|
+
* the highest order the app downloaded, `highest`.
|
|
26
|
+
*/
|
|
27
|
+
static void acceptNext(long[] next, long[] current, long[] highest) throws BundleRefused {
|
|
28
|
+
if (compare(next, current) <= 0) throw new BundleRefused("it is not newer than the bundle the app runs");
|
|
29
|
+
if (compare(next, highest) < 0) throw new BundleRefused("it is older than a release the app downloaded");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static String format(long[] order) {
|
|
33
|
+
StringBuilder text = new StringBuilder();
|
|
34
|
+
for (int i = 0; i < order.length; i++) text.append(i == 0 ? "" : ",").append(order[i]);
|
|
35
|
+
return text.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The order `text` holds, or the built-in bundle's for none. Only this class writes it. */
|
|
39
|
+
static long[] parse(String text) {
|
|
40
|
+
if (text == null || text.isEmpty()) return BUILT_IN;
|
|
41
|
+
String[] parts = text.split(",");
|
|
42
|
+
long[] order = new long[parts.length];
|
|
43
|
+
for (int i = 0; i < parts.length; i++) order[i] = Long.parseLong(parts[i]);
|
|
44
|
+
return order;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
import java.nio.charset.StandardCharsets;
|
|
4
|
+
import java.security.InvalidKeyException;
|
|
5
|
+
import java.security.NoSuchAlgorithmException;
|
|
6
|
+
import java.security.PublicKey;
|
|
7
|
+
import java.security.Signature;
|
|
8
|
+
import java.security.SignatureException;
|
|
9
|
+
import java.util.Arrays;
|
|
10
|
+
import java.util.HashSet;
|
|
11
|
+
import java.util.Iterator;
|
|
12
|
+
import java.util.Set;
|
|
13
|
+
import java.util.regex.Pattern;
|
|
14
|
+
import org.json.JSONArray;
|
|
15
|
+
import org.json.JSONException;
|
|
16
|
+
import org.json.JSONObject;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A release's statement (`releaseStatementFile` in @protobase/schema): what binds its manifest to its order, the app,
|
|
20
|
+
* the binary and the shell, signed with the app's key. Only bytes the signature covers are parsed for what they say.
|
|
21
|
+
*/
|
|
22
|
+
final class ReleaseStatement {
|
|
23
|
+
|
|
24
|
+
static final String FILE = "protobase-release.json";
|
|
25
|
+
static final int MAX_BYTES = 64 * 1024;
|
|
26
|
+
private static final byte[] CONTEXT = "protobase-mobile-release\n".getBytes(StandardCharsets.UTF_8);
|
|
27
|
+
private static final Set<String> KEYS = new HashSet<>(Arrays.asList("manifest", "order", "appId", "fingerprint", "shellVersion"));
|
|
28
|
+
private static final Pattern VERSION = Pattern.compile("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)");
|
|
29
|
+
private static final int MAX_ORDER = 8;
|
|
30
|
+
|
|
31
|
+
final String manifest;
|
|
32
|
+
final long[] order;
|
|
33
|
+
final String appId;
|
|
34
|
+
final String fingerprint;
|
|
35
|
+
final String shellVersion;
|
|
36
|
+
|
|
37
|
+
private ReleaseStatement(String manifest, long[] order, String appId, String fingerprint, String shellVersion) {
|
|
38
|
+
this.manifest = manifest;
|
|
39
|
+
this.order = order;
|
|
40
|
+
this.appId = appId;
|
|
41
|
+
this.fingerprint = fingerprint;
|
|
42
|
+
this.shellVersion = shellVersion;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The statement `file` holds, when it is the one bundle `bundleId` names, the first 16 hex of the sha256 of its bytes
|
|
47
|
+
* and of `key`'s SPKI DER, as `composeRelease` derives it, and `key` signed what it says.
|
|
48
|
+
*/
|
|
49
|
+
static ReleaseStatement verify(byte[] file, String bundleId, PublicKey key) throws BundleRefused {
|
|
50
|
+
if (file.length > MAX_BYTES) throw new BundleRefused("its statement is too large");
|
|
51
|
+
if (!Sha256.of(file, key.getEncoded()).substring(0, 16).equals(bundleId)) {
|
|
52
|
+
throw new BundleRefused("its id does not name its statement and the app's key");
|
|
53
|
+
}
|
|
54
|
+
byte[] payload;
|
|
55
|
+
byte[] signature;
|
|
56
|
+
try {
|
|
57
|
+
JSONObject outer = new JSONObject(new String(file, StandardCharsets.UTF_8));
|
|
58
|
+
payload = Hex.decode(outer.getString("payload"), "its statement's payload");
|
|
59
|
+
signature = Hex.decode(outer.getString("signature"), "its statement's signature");
|
|
60
|
+
} catch (JSONException e) {
|
|
61
|
+
throw new BundleRefused("its statement is malformed");
|
|
62
|
+
}
|
|
63
|
+
if (!signed(payload, signature, key)) throw new BundleRefused("its statement is not signed with the app's key");
|
|
64
|
+
try {
|
|
65
|
+
return parse(new JSONObject(new String(payload, StandardCharsets.UTF_8)));
|
|
66
|
+
} catch (JSONException e) {
|
|
67
|
+
throw new BundleRefused("its statement says nothing it can read");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private static boolean signed(byte[] payload, byte[] signature, PublicKey key) {
|
|
72
|
+
try {
|
|
73
|
+
Signature verifier = Signature.getInstance("SHA256withRSA");
|
|
74
|
+
verifier.initVerify(key);
|
|
75
|
+
verifier.update(CONTEXT);
|
|
76
|
+
verifier.update(payload);
|
|
77
|
+
return verifier.verify(signature);
|
|
78
|
+
} catch (SignatureException e) {
|
|
79
|
+
// A signature of the wrong length or shape.
|
|
80
|
+
return false;
|
|
81
|
+
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
|
82
|
+
// Every runtime has SHA256withRSA, and load() takes only an RSA key.
|
|
83
|
+
throw new IllegalStateException(e);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private static ReleaseStatement parse(JSONObject payload) throws JSONException, BundleRefused {
|
|
88
|
+
Set<String> keys = new HashSet<>();
|
|
89
|
+
for (Iterator<String> it = payload.keys(); it.hasNext(); ) keys.add(it.next());
|
|
90
|
+
if (!keys.equals(KEYS)) throw new BundleRefused("its statement does not say exactly what binds it");
|
|
91
|
+
String manifest = payload.getString("manifest");
|
|
92
|
+
String appId = payload.getString("appId");
|
|
93
|
+
String fingerprint = payload.getString("fingerprint");
|
|
94
|
+
String shellVersion = payload.getString("shellVersion");
|
|
95
|
+
if (!Hex.isHex(manifest, 64)) throw new BundleRefused("its statement names no manifest hash");
|
|
96
|
+
if (appId.isEmpty()) throw new BundleRefused("its statement names no app");
|
|
97
|
+
if (!Hex.isHex(fingerprint, 16)) throw new BundleRefused("its statement names no fingerprint");
|
|
98
|
+
if (!VERSION.matcher(shellVersion).matches()) throw new BundleRefused("its statement names no shell version");
|
|
99
|
+
return new ReleaseStatement(manifest, order(payload.getJSONArray("order")), appId, fingerprint, shellVersion);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private static long[] order(JSONArray array) throws JSONException, BundleRefused {
|
|
103
|
+
if (array.length() == 0 || array.length() > MAX_ORDER) throw new BundleRefused("its statement names no order");
|
|
104
|
+
long[] order = new long[array.length()];
|
|
105
|
+
for (int i = 0; i < order.length; i++) {
|
|
106
|
+
Object part = array.get(i);
|
|
107
|
+
if (!(part instanceof Integer || part instanceof Long) || ((Number) part).longValue() < 0) {
|
|
108
|
+
throw new BundleRefused("its statement names no order");
|
|
109
|
+
}
|
|
110
|
+
order[i] = ((Number) part).longValue();
|
|
111
|
+
}
|
|
112
|
+
return order;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The major and minor of an x.y.z version this class accepted. */
|
|
116
|
+
static long[] majorMinor(String version) {
|
|
117
|
+
String[] parts = version.split("\\.");
|
|
118
|
+
return new long[] { Long.parseLong(parts[0]), Long.parseLong(parts[1]) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static boolean isVersion(String version) {
|
|
122
|
+
return version != null && VERSION.matcher(version).matches();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package net.protobase.mobile;
|
|
2
|
+
|
|
3
|
+
import java.io.File;
|
|
4
|
+
import java.io.FileInputStream;
|
|
5
|
+
import java.io.IOException;
|
|
6
|
+
import java.io.InputStream;
|
|
7
|
+
import java.io.OutputStream;
|
|
8
|
+
import java.security.MessageDigest;
|
|
9
|
+
import java.security.NoSuchAlgorithmException;
|
|
10
|
+
|
|
11
|
+
/** SHA-256 in lowercase hex, of bytes, of a file, and of a bounded copy from a stream into a file. */
|
|
12
|
+
final class Sha256 {
|
|
13
|
+
|
|
14
|
+
private Sha256() {}
|
|
15
|
+
|
|
16
|
+
static MessageDigest digest() {
|
|
17
|
+
try {
|
|
18
|
+
return MessageDigest.getInstance("SHA-256");
|
|
19
|
+
} catch (NoSuchAlgorithmException e) {
|
|
20
|
+
// Every Java and Android runtime has SHA-256.
|
|
21
|
+
throw new IllegalStateException(e);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static String of(byte[]... parts) {
|
|
26
|
+
MessageDigest digest = digest();
|
|
27
|
+
for (byte[] part : parts) digest.update(part);
|
|
28
|
+
return Hex.encode(digest.digest());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static String of(File file) throws IOException {
|
|
32
|
+
try (InputStream in = new FileInputStream(file)) {
|
|
33
|
+
return copy(in, null, Long.MAX_VALUE);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hashes what `in` holds while writing it to `out` (none: only hashes), refusing more than `limit` bytes. Returns
|
|
39
|
+
* the hash, or null when `in` held more.
|
|
40
|
+
*/
|
|
41
|
+
static String copy(InputStream in, OutputStream out, long limit) throws IOException {
|
|
42
|
+
MessageDigest digest = digest();
|
|
43
|
+
byte[] buffer = new byte[64 * 1024];
|
|
44
|
+
long total = 0;
|
|
45
|
+
for (int read; (read = in.read(buffer)) != -1; ) {
|
|
46
|
+
total += read;
|
|
47
|
+
if (total > limit) return null;
|
|
48
|
+
digest.update(buffer, 0, read);
|
|
49
|
+
if (out != null) out.write(buffer, 0, read);
|
|
50
|
+
}
|
|
51
|
+
return Hex.encode(digest.digest());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the live updater unzips bundles (capacitor-live-update's LiveUpdate.swift) and Capacitor serves a persisted
|
|
5
|
+
* server base path from (CAPBridgeViewController.swift).
|
|
6
|
+
*/
|
|
7
|
+
enum BundlesFolder {
|
|
8
|
+
static var path: String { NSHomeDirectory() + "/Library/NoCloud/ionic_built_snapshots" }
|
|
9
|
+
|
|
10
|
+
// One path component: no separator, no `.` or `..`, no NUL, ASCII only.
|
|
11
|
+
private static let bundleId = try! NSRegularExpression(pattern: "^[A-Za-z0-9_-]{1,64}$")
|
|
12
|
+
|
|
13
|
+
static func isBundleIdShape(_ id: String) -> Bool {
|
|
14
|
+
bundleId.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An id that names an existing direct child of `root` once symlinks are resolved, by its exact name on disk: the stock
|
|
19
|
+
* code joins ids to the folder unchecked, and the volume ignores case, so `ABC` would open `abc`.
|
|
20
|
+
*/
|
|
21
|
+
static func isBundleId(_ id: String, in root: String = path) -> Bool {
|
|
22
|
+
guard isBundleIdShape(id), let rootPath = realPath(root), let resolved = realPath(root + "/" + id) else { return false }
|
|
23
|
+
let url = URL(fileURLWithPath: resolved)
|
|
24
|
+
return url.deletingLastPathComponent().path == rootPath && url.lastPathComponent == id
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Takes the stock live updater's (8.7.1) place, so page script cannot steer it. The stock class is `public`, not
|
|
5
|
+
* `open`, so it cannot be subclassed here; the CLI lists this class instead of it in capacitor.config.json, and this
|
|
6
|
+
* plugin holds the stock one unregistered (StockLiveUpdate). Capacitor dispatches only the methods a plugin's own
|
|
7
|
+
* `pluginMethods` names (CapacitorBridge.swift, handleJSCall), so this list, every stock method, decides what page
|
|
8
|
+
* script reaches; page script's input never reaches the stock code, which gets a new call with what the guard built.
|
|
9
|
+
*
|
|
10
|
+
* - The methods the shell needs that take no input get none.
|
|
11
|
+
* - downloadBundle: see LiveUpdateDownload and DownloadBudget.
|
|
12
|
+
* - setNextBundle takes null, which resets to the built-in bundle, or an id naming a bundle (BundlesFolder.isBundleId):
|
|
13
|
+
* the stock code joins ids to its path unchecked (LiveUpdate.swift, buildBundleURLFor).
|
|
14
|
+
* - Everything else is refused, and so is every method when load() refuses to load (LiveUpdateLoadCheck): the stock
|
|
15
|
+
* plugin then never loads.
|
|
16
|
+
*/
|
|
17
|
+
@objc(GuardedLiveUpdatePlugin)
|
|
18
|
+
public class GuardedLiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
19
|
+
public let identifier = "GuardedLiveUpdatePlugin"
|
|
20
|
+
public let jsName = "LiveUpdate"
|
|
21
|
+
public let pluginMethods = LiveUpdateMethods.declared.map { CAPPluginMethod(name: $0, returnType: CAPPluginReturnPromise)! }
|
|
22
|
+
|
|
23
|
+
private var stock: StockLiveUpdate?
|
|
24
|
+
private var settings: LiveUpdateSettings?
|
|
25
|
+
|
|
26
|
+
override public func load() {
|
|
27
|
+
start(LiveUpdateSettings(getConfig()), StockLiveUpdate.make())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func start(_ settings: LiveUpdateSettings, _ stock: StockLiveUpdate?) {
|
|
31
|
+
if let reason = LiveUpdateLoadCheck.refusal(settings, stockMethods: stock?.methodNames, stockResponds: { stock?.responds(to: $0) ?? false }) {
|
|
32
|
+
CAPLog.print("⚡️ ", reason)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
guard let stock else { return }
|
|
36
|
+
stock.load(guardedBy: self)
|
|
37
|
+
self.settings = settings
|
|
38
|
+
self.stock = stock
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private func refuse(_ call: CAPPluginCall) {
|
|
42
|
+
Refusal.reject(call, plugin: jsName)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private func forwardCleared(_ call: CAPPluginCall) {
|
|
46
|
+
guard let stock else { return refuse(call) }
|
|
47
|
+
stock.forward(call, call.methodName, options: [:])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Checked
|
|
51
|
+
|
|
52
|
+
@objc func downloadBundle(_ call: CAPPluginCall) {
|
|
53
|
+
guard let stock, let settings, let options = LiveUpdateDownload.options(call.options, settings) else { return refuse(call) }
|
|
54
|
+
guard DownloadBudget.shared.take() else { return call.reject("LiveUpdate.downloadBundle was called too often in this run of the app") }
|
|
55
|
+
stock.forward(call, "downloadBundle", options: options)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@objc func setNextBundle(_ call: CAPPluginCall) {
|
|
59
|
+
guard let stock else { return refuse(call) }
|
|
60
|
+
let bundleId = call.options["bundleId"]
|
|
61
|
+
if bundleId == nil || bundleId is NSNull { return stock.forward(call, "setNextBundle", options: [:]) }
|
|
62
|
+
guard let bundleId = bundleId as? String, BundlesFolder.isBundleId(bundleId) else { return refuse(call) }
|
|
63
|
+
stock.forward(call, "setNextBundle", options: ["bundleId": bundleId])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cleared
|
|
67
|
+
|
|
68
|
+
@objc func ready(_ call: CAPPluginCall) { forwardCleared(call) }
|
|
69
|
+
@objc func reload(_ call: CAPPluginCall) { forwardCleared(call) }
|
|
70
|
+
@objc func getCurrentBundle(_ call: CAPPluginCall) { forwardCleared(call) }
|
|
71
|
+
@objc func getNextBundle(_ call: CAPPluginCall) { forwardCleared(call) }
|
|
72
|
+
@objc func getDownloadedBundles(_ call: CAPPluginCall) { forwardCleared(call) }
|
|
73
|
+
@objc func getVersionCode(_ call: CAPPluginCall) { forwardCleared(call) }
|
|
74
|
+
@objc func getVersionName(_ call: CAPPluginCall) { forwardCleared(call) }
|
|
75
|
+
|
|
76
|
+
// Refused
|
|
77
|
+
|
|
78
|
+
@objc func clearBlockedBundles(_ call: CAPPluginCall) { refuse(call) }
|
|
79
|
+
@objc func deleteBundle(_ call: CAPPluginCall) { refuse(call) }
|
|
80
|
+
@objc func fetchChannels(_ call: CAPPluginCall) { refuse(call) }
|
|
81
|
+
@objc func fetchLatestBundle(_ call: CAPPluginCall) { refuse(call) }
|
|
82
|
+
@objc func getBlockedBundles(_ call: CAPPluginCall) { refuse(call) }
|
|
83
|
+
@objc func getBundles(_ call: CAPPluginCall) { refuse(call) }
|
|
84
|
+
@objc func getChannel(_ call: CAPPluginCall) { refuse(call) }
|
|
85
|
+
@objc func getConfig(_ call: CAPPluginCall) { refuse(call) }
|
|
86
|
+
@objc func getCustomId(_ call: CAPPluginCall) { refuse(call) }
|
|
87
|
+
@objc func getDeviceId(_ call: CAPPluginCall) { refuse(call) }
|
|
88
|
+
@objc func isSyncing(_ call: CAPPluginCall) { refuse(call) }
|
|
89
|
+
@objc func reset(_ call: CAPPluginCall) { refuse(call) }
|
|
90
|
+
@objc func resetConfig(_ call: CAPPluginCall) { refuse(call) }
|
|
91
|
+
@objc func setChannel(_ call: CAPPluginCall) { refuse(call) }
|
|
92
|
+
@objc func setConfig(_ call: CAPPluginCall) { refuse(call) }
|
|
93
|
+
@objc func setCustomId(_ call: CAPPluginCall) { refuse(call) }
|
|
94
|
+
@objc func sync(_ call: CAPPluginCall) { refuse(call) }
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What page script may download: a manifest from the app's own releases URL, `releasesUrl + "/" + bundleId`, with
|
|
5
|
+
* `publicKey` pinned, so the stock code checks the manifest and each file against it with the response's signature
|
|
6
|
+
* (LiveUpdate.swift, downloadAndVerifyFile and verifyFile). A caller's checksum and signature are dropped.
|
|
7
|
+
*/
|
|
8
|
+
enum LiveUpdateDownload {
|
|
9
|
+
/** The options the stock code downloads with, or nil when the call is refused. */
|
|
10
|
+
static func options(_ call: [AnyHashable: Any], _ settings: LiveUpdateSettings) -> [String: Any]? {
|
|
11
|
+
guard let releasesUrl = settings.releasesUrl, ReleasesUrl.isOwnSource(releasesUrl) else { return nil }
|
|
12
|
+
guard let publicKey = settings.publicKey, !publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
13
|
+
guard call["artifactType"] as? String == "manifest", let bundleId = call["bundleId"] as? String else { return nil }
|
|
14
|
+
let url = releasesUrl + "/" + bundleId
|
|
15
|
+
guard BundlesFolder.isBundleIdShape(bundleId), call["url"] as? String == url else { return nil }
|
|
16
|
+
return ["artifactType": "manifest", "bundleId": bundleId, "url": url]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A few downloads per run of the app, so a loop cannot drain the data plan; a reload of the page keeps the count. */
|
|
21
|
+
final class DownloadBudget {
|
|
22
|
+
static let shared = DownloadBudget(limit: 3)
|
|
23
|
+
|
|
24
|
+
private let lock = NSLock()
|
|
25
|
+
private let limit: Int
|
|
26
|
+
private var used = 0
|
|
27
|
+
|
|
28
|
+
init(limit: Int) {
|
|
29
|
+
self.limit = limit
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func take() -> Bool {
|
|
33
|
+
lock.lock()
|
|
34
|
+
defer { lock.unlock() }
|
|
35
|
+
if used >= limit { return false }
|
|
36
|
+
used += 1
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why the guard refuses to load the live updater, or nil. A stock method the guard does not declare means a plugin
|
|
3
|
+
* upgrade added one: no live updater then rather than an open one. Background updates sync with the vendor's cloud
|
|
4
|
+
* outside every method (LiveUpdate.swift, handleLoad); `autoDeleteBundles` and a `readyTimeout` make ready() drop every
|
|
5
|
+
* bundle but the current and next one and roll back a bundle that never gets ready, so page script cannot pin an older one.
|
|
6
|
+
*/
|
|
7
|
+
enum LiveUpdateLoadCheck {
|
|
8
|
+
static func refusal(_ settings: LiveUpdateSettings, stockMethods: [String]?, stockResponds: (String) -> Bool) -> String? {
|
|
9
|
+
guard let stockMethods else { return "LiveUpdate has no stock LiveUpdatePlugin in this Protobase app" }
|
|
10
|
+
if let unguarded = stockMethods.first(where: { !LiveUpdateMethods.declared.contains($0) }) {
|
|
11
|
+
return "LiveUpdate.\(unguarded) has no guard in this Protobase app"
|
|
12
|
+
}
|
|
13
|
+
if let missing = LiveUpdateMethods.forwarded.first(where: { !stockResponds($0) }) {
|
|
14
|
+
return "LiveUpdate.\(missing) is missing from the stock LiveUpdatePlugin"
|
|
15
|
+
}
|
|
16
|
+
if settings.autoUpdateStrategy != "none" { return "plugins.LiveUpdate.autoUpdateStrategy must be \"none\" in a Protobase app" }
|
|
17
|
+
if !settings.autoDeleteBundles { return "plugins.LiveUpdate.autoDeleteBundles must be true in a Protobase app" }
|
|
18
|
+
if settings.readyTimeout <= 0 { return "plugins.LiveUpdate.readyTimeout must be above 0 in a Protobase app" }
|
|
19
|
+
return nil
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The stock live updater's (8.7.1) methods, by what page script may do with them. */
|
|
2
|
+
enum LiveUpdateMethods {
|
|
3
|
+
/** Forwarded with their input cleared. */
|
|
4
|
+
static let cleared = ["ready", "reload", "getCurrentBundle", "getNextBundle", "getDownloadedBundles", "getVersionCode", "getVersionName"]
|
|
5
|
+
/** Forwarded with only the input the guard checked and built. */
|
|
6
|
+
static let checked = ["downloadBundle", "setNextBundle"]
|
|
7
|
+
/** Never forwarded: deleting, resetting, the vendor's cloud, config, channel and ids. */
|
|
8
|
+
static let refused = [
|
|
9
|
+
"clearBlockedBundles", "deleteBundle", "fetchChannels", "fetchLatestBundle", "getBlockedBundles", "getBundles", "getChannel",
|
|
10
|
+
"getConfig", "getCustomId", "getDeviceId", "isSyncing", "reset", "resetConfig", "setChannel", "setConfig", "setCustomId", "sync",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
static var forwarded: [String] { cleared + checked }
|
|
14
|
+
static var declared: [String] { forwarded + refused }
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The live updater's `plugins.LiveUpdate` settings the guard checks, read with the stock plugin's own keys and defaults
|
|
5
|
+
* (capacitor-live-update's LiveUpdatePlugin.swift, liveUpdateConfig), plus Protobase's `releasesUrl`. They come from
|
|
6
|
+
* capacitor.config.json, which ships in the binary outside the web part, beyond page script and a live update.
|
|
7
|
+
*/
|
|
8
|
+
struct LiveUpdateSettings {
|
|
9
|
+
var autoUpdateStrategy: String?
|
|
10
|
+
var autoDeleteBundles: Bool
|
|
11
|
+
var readyTimeout: Int
|
|
12
|
+
var releasesUrl: String?
|
|
13
|
+
var publicKey: String?
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
extension LiveUpdateSettings {
|
|
17
|
+
init(_ config: PluginConfig) {
|
|
18
|
+
self.init(
|
|
19
|
+
autoUpdateStrategy: config.getString("autoUpdateStrategy", "none"),
|
|
20
|
+
autoDeleteBundles: config.getBoolean("autoDeleteBundles", false),
|
|
21
|
+
readyTimeout: config.getInt("readyTimeout", 0),
|
|
22
|
+
releasesUrl: config.getString("releasesUrl"),
|
|
23
|
+
publicKey: config.getString("publicKey"))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -7,9 +7,6 @@ import Foundation
|
|
|
7
7
|
* live updater's bundles folder. Each check runs on the decoded path, the one the asset handler opens.
|
|
8
8
|
*/
|
|
9
9
|
final class LocalFileGuard {
|
|
10
|
-
// Where the live updater unzips bundles (capacitor-live-update's LiveUpdate.swift) and Capacitor serves a persisted
|
|
11
|
-
// server base path from (CAPBridgeViewController.swift).
|
|
12
|
-
private static let bundlesFolder = "Library/NoCloud/ionic_built_snapshots"
|
|
13
10
|
private static let refusedPrefixes = [
|
|
14
11
|
CapacitorBridge.fileStartIdentifier,
|
|
15
12
|
CapacitorBridge.httpInterceptorStartIdentifier,
|
|
@@ -18,14 +15,15 @@ final class LocalFileGuard {
|
|
|
18
15
|
|
|
19
16
|
private let lock = NSLock()
|
|
20
17
|
private let appBundle: String?
|
|
21
|
-
// Resolved once:
|
|
22
|
-
|
|
18
|
+
// Resolved once it exists, under the lock: a fresh install has none until its first download, which the app then
|
|
19
|
+
// loads in the same run. Until then nothing outside the app bundle is served.
|
|
20
|
+
private var bundles: String?
|
|
23
21
|
var basePath: String
|
|
24
22
|
|
|
25
23
|
init(basePath: String) {
|
|
26
24
|
self.basePath = basePath
|
|
27
|
-
appBundle =
|
|
28
|
-
bundles =
|
|
25
|
+
appBundle = realPath(Bundle.main.bundlePath)
|
|
26
|
+
bundles = realPath(BundlesFolder.path)
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
func withLock<T>(_ body: () throws -> T) rethrows -> T {
|
|
@@ -37,23 +35,18 @@ final class LocalFileGuard {
|
|
|
37
35
|
func refuses(path: String) -> Bool {
|
|
38
36
|
if Self.refusedPrefixes.contains(where: { path.hasPrefix($0) }) { return true }
|
|
39
37
|
if path.contains("\0") || path.split(separator: "/", omittingEmptySubsequences: false).contains("..") { return true }
|
|
40
|
-
guard let base =
|
|
38
|
+
guard let base = realPath(basePath) else { return true }
|
|
41
39
|
if let appBundle, Self.isInside(base, appBundle) { return false }
|
|
40
|
+
if bundles == nil { bundles = realPath(BundlesFolder.path) }
|
|
42
41
|
guard let bundles else { return true }
|
|
43
42
|
// The file the asset handler opens, by Capacitor's own routing.
|
|
44
43
|
var router = CapacitorRouter()
|
|
45
44
|
router.basePath = basePath
|
|
46
|
-
guard let file =
|
|
45
|
+
guard let file = realPath(router.route(for: path)) else { return true }
|
|
47
46
|
return !Self.isInside(file, bundles)
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
private static func isInside(_ path: String, _ folder: String) -> Bool {
|
|
51
50
|
path == folder || path.hasPrefix(folder.hasSuffix("/") ? folder : folder + "/")
|
|
52
51
|
}
|
|
53
|
-
|
|
54
|
-
private static func realPath(_ path: String) -> String? {
|
|
55
|
-
guard let resolved = realpath(path, nil) else { return nil }
|
|
56
|
-
defer { free(resolved) }
|
|
57
|
-
return String(cString: resolved)
|
|
58
|
-
}
|
|
59
52
|
}
|
|
@@ -5,7 +5,9 @@ import WebKit
|
|
|
5
5
|
* Stands in front of Capacitor's `bridge` script message handler, the way page script calls native code. It drops
|
|
6
6
|
* messages from any frame but the main one of the app's own origin, as Capacitor Android's MessageHandler does, and
|
|
7
7
|
* refuses `CapacitorHttp`, `CapacitorCookies` and `WebView`, which hand over or plant the session, and any plugin id
|
|
8
|
-
* the bridge has not loaded: Capacitor would otherwise load a plugin by class name, such as `CAPHttpPlugin
|
|
8
|
+
* the bridge has not loaded: Capacitor would otherwise load a plugin by class name, such as `CAPHttpPlugin` or the stock
|
|
9
|
+
* `LiveUpdatePlugin`. `LiveUpdate` passes only when GuardedLiveUpdatePlugin holds the name: an `npx cap sync` lists the
|
|
10
|
+
* stock class again, which then registers under it beside or instead of the guard.
|
|
9
11
|
*/
|
|
10
12
|
final class PageScriptFilter: NSObject, WKScriptMessageHandler {
|
|
11
13
|
private static let handlerName = "bridge"
|
|
@@ -32,7 +34,7 @@ final class PageScriptFilter: NSObject, WKScriptMessageHandler {
|
|
|
32
34
|
}
|
|
33
35
|
guard type == "message" else { return }
|
|
34
36
|
let pluginId = body["pluginId"] as? String ?? ""
|
|
35
|
-
if !Self.refusedPlugins.contains(pluginId), bridge.plugin(withName: pluginId) !=
|
|
37
|
+
if !Self.refusedPlugins.contains(pluginId), let plugin = bridge.plugin(withName: pluginId), pluginId != "LiveUpdate" || plugin is GuardedLiveUpdatePlugin {
|
|
36
38
|
bridge.webViewDelegationHandler.userContentController(userContentController, didReceive: message)
|
|
37
39
|
return
|
|
38
40
|
}
|
|
@@ -47,7 +49,7 @@ final class PageScriptFilter: NSObject, WKScriptMessageHandler {
|
|
|
47
49
|
"pluginId": pluginId,
|
|
48
50
|
"methodName": body["methodName"] as? String ?? "",
|
|
49
51
|
"success": false,
|
|
50
|
-
"error": ["message":
|
|
52
|
+
"error": ["message": Refusal.message(pluginId)],
|
|
51
53
|
]
|
|
52
54
|
guard let json = try? JSONSerialization.data(withJSONObject: answer), let text = String(data: json, encoding: .utf8) else { return }
|
|
53
55
|
webView?.evaluateJavaScript("window.Capacitor.fromNative(\(text))")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/** A path with its symlinks, `.` and `..` resolved and each component in its case on disk, or nil when it does not exist. */
|
|
4
|
+
func realPath(_ path: String) -> String? {
|
|
5
|
+
guard let resolved = realpath(path, nil) else { return nil }
|
|
6
|
+
defer { free(resolved) }
|
|
7
|
+
return String(cString: resolved)
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Capacitor
|
|
2
|
+
|
|
3
|
+
/** How Protobase answers page script that calls a plugin or method it may not reach. */
|
|
4
|
+
enum Refusal {
|
|
5
|
+
static func message(_ name: String) -> String {
|
|
6
|
+
"\(name) is not available to page script in a Protobase app"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static func reject(_ call: CAPPluginCall, plugin: String) {
|
|
10
|
+
call.reject(message("\(plugin).\(call.methodName ?? "")"))
|
|
11
|
+
}
|
|
12
|
+
}
|