@protobase/mobile 0.45.1 → 0.45.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.
@@ -13,6 +13,9 @@ buildscript {
13
13
 
14
14
  apply plugin: 'com.android.library'
15
15
 
16
+ // The live updater's project, present only in an app that includes it (capacitor.settings.gradle lists every plugin).
17
+ def liveUpdate = findProject(':capawesome-capacitor-live-update')
18
+
16
19
  android {
17
20
  namespace = "net.protobase.mobile"
18
21
  compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
@@ -31,6 +34,11 @@ android {
31
34
  lintOptions {
32
35
  abortOnError = false
33
36
  }
37
+ // GuardedLiveUpdate extends the live updater's plugin, so it compiles only beside it. Outside src/main, the Capacitor
38
+ // CLI never lists it on its own (@capacitor/cli android/update.js:63).
39
+ if (liveUpdate != null) {
40
+ sourceSets.main.java.srcDirs += 'src/liveupdate/java'
41
+ }
34
42
  compileOptions {
35
43
  sourceCompatibility JavaVersion.VERSION_21
36
44
  targetCompatibility JavaVersion.VERSION_21
@@ -44,4 +52,30 @@ repositories {
44
52
 
45
53
  dependencies {
46
54
  implementation project(':capacitor-android')
55
+ if (liveUpdate != null) {
56
+ implementation liveUpdate
57
+ }
58
+ }
59
+
60
+ // The plugin list the app loads must match what this build compiles, however the build was started (protobase mobile,
61
+ // gradlew, Android Studio, after an `npx cap sync`): Capacitor loads no plugin at all when one listed class is missing
62
+ // (PluginManager.java:25-36, BridgeActivity.java:36-40), which would drop every Protobase stand-in, and the stock live
63
+ // updater listed hands page script all its methods. The CLI swaps the stock class for GuardedLiveUpdate from the same
64
+ // list the source set above follows.
65
+ def pluginList = rootProject.file('app/src/main/assets/capacitor.plugins.json')
66
+ def checkPluginList = tasks.register('checkProtobasePluginList') {
67
+ inputs.file(pluginList)
68
+ def compilesGuard = liveUpdate != null
69
+ doLast {
70
+ def entries = new groovy.json.JsonSlurper().parse(pluginList)
71
+ entries.each { entry ->
72
+ if (entry.pkg == '@capawesome/capacitor-live-update' || entry.classpath == 'io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdatePlugin') {
73
+ throw new GradleException("${pluginList} lists ${entry.classpath}, which hands page script the live updater unguarded: run protobase mobile again instead of cap sync")
74
+ }
75
+ if (entry.classpath == 'net.protobase.mobile.GuardedLiveUpdate' && !compilesGuard) {
76
+ throw new GradleException("${pluginList} lists ${entry.classpath}, which this app does not compile without the live updater: run protobase mobile again")
77
+ }
78
+ }
79
+ }
47
80
  }
81
+ tasks.matching { it.name == 'preBuild' }.configureEach { dependsOn checkPluginList }
@@ -0,0 +1,304 @@
1
+ package net.protobase.mobile;
2
+
3
+ import com.getcapacitor.JSObject;
4
+ import com.getcapacitor.Logger;
5
+ import com.getcapacitor.Plugin;
6
+ import com.getcapacitor.PluginCall;
7
+ import com.getcapacitor.PluginConfig;
8
+ import com.getcapacitor.PluginMethod;
9
+ import com.getcapacitor.annotation.CapacitorPlugin;
10
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdatePlugin;
11
+ import java.lang.reflect.Method;
12
+ import java.net.URI;
13
+ import java.net.URISyntaxException;
14
+ import java.util.ArrayList;
15
+ import java.util.Arrays;
16
+ import java.util.Iterator;
17
+ import java.util.List;
18
+ import java.util.concurrent.atomic.AtomicInteger;
19
+ import org.json.JSONObject;
20
+
21
+ /**
22
+ * Takes the stock LiveUpdate plugin's (8.7.1) place, so page script cannot steer the live updater. The CLI lists this
23
+ * class in capacitor.plugins.json instead of the stock one: listed beside it, two instances would each run their own
24
+ * rollback timer (LiveUpdate.java:147-171). Capacitor reaches only the methods a class's own overrides annotate
25
+ * (PluginHandle.java:145-157), so this subclass decides what page script reaches and keeps the stock lifecycle.
26
+ *
27
+ * - Every reachable method is declared here or by Capacitor's Plugin; load() refuses to load otherwise, so a plugin
28
+ * upgrade that adds a method leaves no LiveUpdate at all rather than an open one.
29
+ * - The methods the shell needs that take no input clear what page script passed before the stock code reads it.
30
+ * - downloadBundle takes only a manifest from the app's own releases URL, `releasesUrl + "/" + bundleId`, over https
31
+ * or loopback http, with `publicKey` pinned, so the stock code checks each file's signature (LiveUpdate.java:1633-1654)
32
+ * and a caller's checksum and signature are dropped. A few per run of the app, so a loop cannot drain the data plan.
33
+ * - Ids are a single safe component naming a direct child of the bundles folder: the stock code joins them to its path
34
+ * unchecked (LiveUpdate.java:665-667,1299-1302).
35
+ * - Everything else is refused: deleting, resetting, the vendor's cloud, config, channel and ids.
36
+ * - load() also refuses background updates, which sync with the vendor's cloud outside every method here
37
+ * (LiveUpdate.java:420-433), and needs `autoDeleteBundles` and a `readyTimeout`, so ready() drops every bundle but the
38
+ * current and next one and a bundle that never gets ready rolls back: page script cannot pin an older one.
39
+ *
40
+ * The config comes from capacitor.config.json, which ships in the binary outside the web part, beyond page script and
41
+ * a live update. This source set compiles only when the app has the plugin's Gradle project, which the CLI's swap and
42
+ * the build's plugin-list check take from the same list (@capacitor/cli android/update.js:20-36).
43
+ */
44
+ @CapacitorPlugin(name = "LiveUpdate")
45
+ public class GuardedLiveUpdate extends LiveUpdatePlugin {
46
+
47
+ private static final int MAX_DOWNLOADS = 3;
48
+ private static final AtomicInteger downloads = new AtomicInteger();
49
+
50
+ @Override
51
+ public void load() {
52
+ for (Method method : getClass().getMethods()) {
53
+ if (!method.isAnnotationPresent(PluginMethod.class)) continue;
54
+ Class<?> declaring = method.getDeclaringClass();
55
+ if (declaring == GuardedLiveUpdate.class || declaring == Plugin.class) continue;
56
+ refuseToLoad("LiveUpdate." + method.getName() + " has no guard in this Protobase app");
57
+ }
58
+ PluginConfig config = getConfig();
59
+ if (!"none".equals(config.getString("autoUpdateStrategy", "none"))) {
60
+ refuseToLoad("plugins.LiveUpdate.autoUpdateStrategy must be \"none\" in a Protobase app");
61
+ }
62
+ if (!config.getBoolean("autoDeleteBundles", false)) {
63
+ refuseToLoad("plugins.LiveUpdate.autoDeleteBundles must be true in a Protobase app");
64
+ }
65
+ if (config.getInt("readyTimeout", 0) <= 0) {
66
+ refuseToLoad("plugins.LiveUpdate.readyTimeout must be above 0 in a Protobase app");
67
+ }
68
+ super.load();
69
+ }
70
+
71
+ // Capacitor then leaves the plugin unregistered, logging only that it failed to load (PluginHandle.java:98-109), so
72
+ // the reason is logged here.
73
+ private static void refuseToLoad(String reason) {
74
+ Logger.error(reason);
75
+ throw new IllegalStateException(reason);
76
+ }
77
+
78
+ // Guarded
79
+
80
+ @Override
81
+ @PluginMethod
82
+ public void downloadBundle(PluginCall call) {
83
+ String releasesUrl = getConfig().getString("releasesUrl");
84
+ String publicKey = getConfig().getString("publicKey");
85
+ if (!ownSource(releasesUrl) || publicKey == null || publicKey.trim().isEmpty()) {
86
+ Refusal.rejectMethod(call, "LiveUpdate", "downloadBundle");
87
+ return;
88
+ }
89
+ String bundleId = call.getString("bundleId");
90
+ boolean allowed =
91
+ "manifest".equals(call.getString("artifactType")) &&
92
+ BundlesFolder.isBundleId(BundlesFolder.of(getContext()), bundleId) &&
93
+ (releasesUrl + "/" + bundleId).equals(call.getString("url"));
94
+ if (!allowed) {
95
+ Refusal.rejectMethod(call, "LiveUpdate", "downloadBundle");
96
+ return;
97
+ }
98
+ if (downloads.incrementAndGet() > MAX_DOWNLOADS) {
99
+ call.reject("LiveUpdate.downloadBundle was called too often in this run of the app");
100
+ return;
101
+ }
102
+ JSObject data = clear(call);
103
+ data.put("artifactType", "manifest");
104
+ data.put("bundleId", bundleId);
105
+ data.put("url", releasesUrl + "/" + bundleId);
106
+ super.downloadBundle(call);
107
+ }
108
+
109
+ @Override
110
+ @PluginMethod
111
+ public void setNextBundle(PluginCall call) {
112
+ Object bundleId = call.getData().opt("bundleId");
113
+ if (bundleId == null || bundleId == JSONObject.NULL) {
114
+ // The stock code resets to the built-in bundle.
115
+ clear(call);
116
+ super.setNextBundle(call);
117
+ return;
118
+ }
119
+ if (!(bundleId instanceof String) || !BundlesFolder.isBundleId(BundlesFolder.of(getContext()), (String) bundleId)) {
120
+ Refusal.rejectMethod(call, "LiveUpdate", "setNextBundle");
121
+ return;
122
+ }
123
+ clear(call).put("bundleId", bundleId);
124
+ super.setNextBundle(call);
125
+ }
126
+
127
+ // Allowed: no input
128
+
129
+ @Override
130
+ @PluginMethod
131
+ public void ready(PluginCall call) {
132
+ clear(call);
133
+ super.ready(call);
134
+ }
135
+
136
+ @Override
137
+ @PluginMethod
138
+ public void reload(PluginCall call) {
139
+ clear(call);
140
+ super.reload(call);
141
+ }
142
+
143
+ @Override
144
+ @PluginMethod
145
+ public void getCurrentBundle(PluginCall call) {
146
+ clear(call);
147
+ super.getCurrentBundle(call);
148
+ }
149
+
150
+ @Override
151
+ @PluginMethod
152
+ public void getNextBundle(PluginCall call) {
153
+ clear(call);
154
+ super.getNextBundle(call);
155
+ }
156
+
157
+ @Override
158
+ @PluginMethod
159
+ public void getDownloadedBundles(PluginCall call) {
160
+ clear(call);
161
+ super.getDownloadedBundles(call);
162
+ }
163
+
164
+ @Override
165
+ @PluginMethod
166
+ public void getVersionCode(PluginCall call) {
167
+ clear(call);
168
+ super.getVersionCode(call);
169
+ }
170
+
171
+ @Override
172
+ @PluginMethod
173
+ public void getVersionName(PluginCall call) {
174
+ clear(call);
175
+ super.getVersionName(call);
176
+ }
177
+
178
+ // Refused
179
+
180
+ @Override
181
+ @PluginMethod
182
+ public void clearBlockedBundles(PluginCall call) {
183
+ Refusal.rejectMethod(call, "LiveUpdate", "clearBlockedBundles");
184
+ }
185
+
186
+ @Override
187
+ @PluginMethod
188
+ public void deleteBundle(PluginCall call) {
189
+ Refusal.rejectMethod(call, "LiveUpdate", "deleteBundle");
190
+ }
191
+
192
+ @Override
193
+ @PluginMethod
194
+ public void fetchChannels(PluginCall call) {
195
+ Refusal.rejectMethod(call, "LiveUpdate", "fetchChannels");
196
+ }
197
+
198
+ @Override
199
+ @PluginMethod
200
+ public void fetchLatestBundle(PluginCall call) {
201
+ Refusal.rejectMethod(call, "LiveUpdate", "fetchLatestBundle");
202
+ }
203
+
204
+ @Override
205
+ @PluginMethod
206
+ public void getBlockedBundles(PluginCall call) {
207
+ Refusal.rejectMethod(call, "LiveUpdate", "getBlockedBundles");
208
+ }
209
+
210
+ @Override
211
+ @PluginMethod
212
+ public void getBundles(PluginCall call) {
213
+ Refusal.rejectMethod(call, "LiveUpdate", "getBundles");
214
+ }
215
+
216
+ @Override
217
+ @PluginMethod
218
+ public void getChannel(PluginCall call) {
219
+ Refusal.rejectMethod(call, "LiveUpdate", "getChannel");
220
+ }
221
+
222
+ @Override
223
+ @PluginMethod
224
+ public void getConfig(PluginCall call) {
225
+ Refusal.rejectMethod(call, "LiveUpdate", "getConfig");
226
+ }
227
+
228
+ @Override
229
+ @PluginMethod
230
+ public void getCustomId(PluginCall call) {
231
+ Refusal.rejectMethod(call, "LiveUpdate", "getCustomId");
232
+ }
233
+
234
+ @Override
235
+ @PluginMethod
236
+ public void getDeviceId(PluginCall call) {
237
+ Refusal.rejectMethod(call, "LiveUpdate", "getDeviceId");
238
+ }
239
+
240
+ @Override
241
+ @PluginMethod
242
+ public void isSyncing(PluginCall call) {
243
+ Refusal.rejectMethod(call, "LiveUpdate", "isSyncing");
244
+ }
245
+
246
+ @Override
247
+ @PluginMethod
248
+ public void reset(PluginCall call) {
249
+ Refusal.rejectMethod(call, "LiveUpdate", "reset");
250
+ }
251
+
252
+ @Override
253
+ @PluginMethod
254
+ public void resetConfig(PluginCall call) {
255
+ Refusal.rejectMethod(call, "LiveUpdate", "resetConfig");
256
+ }
257
+
258
+ @Override
259
+ @PluginMethod
260
+ public void setChannel(PluginCall call) {
261
+ Refusal.rejectMethod(call, "LiveUpdate", "setChannel");
262
+ }
263
+
264
+ @Override
265
+ @PluginMethod
266
+ public void setConfig(PluginCall call) {
267
+ Refusal.rejectMethod(call, "LiveUpdate", "setConfig");
268
+ }
269
+
270
+ @Override
271
+ @PluginMethod
272
+ public void setCustomId(PluginCall call) {
273
+ Refusal.rejectMethod(call, "LiveUpdate", "setCustomId");
274
+ }
275
+
276
+ @Override
277
+ @PluginMethod
278
+ public void sync(PluginCall call) {
279
+ Refusal.rejectMethod(call, "LiveUpdate", "sync");
280
+ }
281
+
282
+ // https, or http to the device itself, where no one sits on the path; a bare origin and path only.
283
+ private static boolean ownSource(String releasesUrl) {
284
+ if (releasesUrl == null) return false;
285
+ URI uri;
286
+ try {
287
+ uri = new URI(releasesUrl);
288
+ } catch (URISyntaxException e) {
289
+ return false;
290
+ }
291
+ if (uri.getHost() == null || uri.getRawUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null) return false;
292
+ if ("https".equals(uri.getScheme())) return true;
293
+ return "http".equals(uri.getScheme()) && Arrays.asList("localhost", "127.0.0.1", "[::1]").contains(uri.getHost());
294
+ }
295
+
296
+ // The live data the stock code reads (PluginCall.java:150-152), emptied: keys collected first, then removed.
297
+ private static JSObject clear(PluginCall call) {
298
+ JSObject data = call.getData();
299
+ List<String> keys = new ArrayList<>();
300
+ for (Iterator<String> it = data.keys(); it.hasNext(); ) keys.add(it.next());
301
+ for (String key : keys) data.remove(key);
302
+ return data;
303
+ }
304
+ }
@@ -0,0 +1,46 @@
1
+ package net.protobase.mobile;
2
+
3
+ import android.content.Context;
4
+ import java.io.File;
5
+ import java.io.IOException;
6
+ import java.util.regex.Pattern;
7
+
8
+ /** The folder the live updater keeps its bundles in, a fixed name below the app's files (LiveUpdate.java:114). */
9
+ final class BundlesFolder {
10
+
11
+ static final String NAME = "_capacitor_live_update_bundles";
12
+
13
+ // One path component: no separator, no `.` or `..`, no NUL.
14
+ private static final Pattern BUNDLE_ID = Pattern.compile("[A-Za-z0-9_-]{1,64}");
15
+
16
+ private BundlesFolder() {}
17
+
18
+ static File of(Context context) {
19
+ return new File(context.getFilesDir(), NAME);
20
+ }
21
+
22
+ // Compares whole path components of the canonical paths, so a sibling such as `<bundles>_x` is not inside. One
23
+ // that cannot be made canonical is refused.
24
+ static boolean contains(File root, File file) {
25
+ String canonical;
26
+ String rootPath;
27
+ try {
28
+ canonical = file.getCanonicalPath();
29
+ rootPath = root.getCanonicalPath();
30
+ } catch (IOException e) {
31
+ return false;
32
+ }
33
+ return canonical.equals(rootPath) || canonical.startsWith(rootPath + File.separator);
34
+ }
35
+
36
+ // An id that names a direct child of the folder once symlinks are resolved, so an entry linked elsewhere is refused.
37
+ static boolean isBundleId(File root, String id) {
38
+ if (id == null || !BUNDLE_ID.matcher(id).matches()) return false;
39
+ try {
40
+ File parent = new File(root, id).getCanonicalFile().getParentFile();
41
+ return parent != null && parent.getPath().equals(root.getCanonicalPath());
42
+ } catch (IOException e) {
43
+ return false;
44
+ }
45
+ }
46
+ }
@@ -7,7 +7,6 @@ import com.getcapacitor.Bridge;
7
7
  import com.getcapacitor.BridgeWebViewClient;
8
8
  import java.io.ByteArrayInputStream;
9
9
  import java.io.File;
10
- import java.io.IOException;
11
10
  import java.util.Arrays;
12
11
  import java.util.Collections;
13
12
  import java.util.Objects;
@@ -50,21 +49,7 @@ final class GuardedWebViewClient extends BridgeWebViewClient {
50
49
  if (path.startsWith(Bridge.CAPACITOR_FILE_START) || path.startsWith(Bridge.CAPACITOR_CONTENT_START)) return true;
51
50
  if (Arrays.asList(path.split("/")).contains("..")) return true;
52
51
  if (basePath == null || !new File(basePath).isAbsolute()) return false;
53
- return !inside(new File(basePath + path));
54
- }
55
-
56
- // Compares whole path components of the canonical paths, so a sibling such as `<bundles>_x` is not inside. One
57
- // that cannot be made canonical is refused.
58
- private boolean inside(File file) {
59
- String canonical;
60
- String root;
61
- try {
62
- canonical = file.getCanonicalPath();
63
- root = bundles.getCanonicalPath();
64
- } catch (IOException e) {
65
- return false;
66
- }
67
- return canonical.equals(root) || canonical.startsWith(root + File.separator);
52
+ return !BundlesFolder.contains(bundles, new File(basePath + path));
68
53
  }
69
54
 
70
55
  private static WebResourceResponse forbidden() {
@@ -2,7 +2,6 @@ package net.protobase.mobile;
2
2
 
3
3
  import com.getcapacitor.Plugin;
4
4
  import com.getcapacitor.annotation.CapacitorPlugin;
5
- import java.io.File;
6
5
 
7
6
  /**
8
7
  * Puts GuardedWebViewClient in front of the app's local server. It has no methods: it only acts when it loads, which
@@ -12,11 +11,8 @@ import java.io.File;
12
11
  @CapacitorPlugin(name = "ProtobaseLocalServerGuard")
13
12
  public class LocalServerGuard extends Plugin {
14
13
 
15
- // The folder the live updater keeps its bundles in, a fixed name below the app's files (LiveUpdate.java:114).
16
- private static final String LIVE_UPDATE_BUNDLES = "_capacitor_live_update_bundles";
17
-
18
14
  @Override
19
15
  public void load() {
20
- getBridge().setWebViewClient(new GuardedWebViewClient(getBridge(), new File(getContext().getFilesDir(), LIVE_UPDATE_BUNDLES)));
16
+ getBridge().setWebViewClient(new GuardedWebViewClient(getBridge(), BundlesFolder.of(getContext())));
21
17
  }
22
18
  }
@@ -10,4 +10,8 @@ final class Refusal {
10
10
  static void reject(PluginCall call, String plugin) {
11
11
  call.reject(plugin + " is not available to page script in a Protobase app");
12
12
  }
13
+
14
+ static void rejectMethod(PluginCall call, String plugin, String method) {
15
+ call.reject(plugin + "." + method + " is not available to page script in a Protobase app");
16
+ }
13
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/mobile",
3
- "version": "0.45.1",
3
+ "version": "0.45.3",
4
4
  "description": "The Capacitor that protobase mobile builds a Protobase app's iOS and Android projects with, at fixed versions, and the native plugin that keeps page script away from the session; installed by the CLI on first use.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -16,9 +16,10 @@
16
16
  "files": [
17
17
  "LICENSE",
18
18
  "android/src/main/",
19
+ "android/src/liveupdate/",
19
20
  "android/build.gradle"
20
21
  ],
21
- "nativeContract": 1,
22
+ "nativeContract": 2,
22
23
  "capacitor": {
23
24
  "android": {
24
25
  "src": "android"