@protobase/mobile 0.57.0 → 0.57.2

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.
@@ -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: with no bundles folder at launch, nothing outside the app bundle is served.
22
- private let bundles: String?
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 = Self.realPath(Bundle.main.bundlePath)
28
- bundles = Self.realPath(NSHomeDirectory() + "/" + Self.bundlesFolder)
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 = Self.realPath(basePath) else { return true }
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 = Self.realPath(router.route(for: path)) else { return true }
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) != nil {
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": "\(pluginId) is not available to page script in a Protobase app"],
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
+ }
@@ -0,0 +1,13 @@
1
+ import Foundation
2
+
3
+ /** The app's own releases URL: https, or http to the device itself, where no one sits on the path; a bare origin and path. */
4
+ enum ReleasesUrl {
5
+ private static let loopbackHosts: Set<String> = ["localhost", "127.0.0.1", "::1", "[::1]"]
6
+
7
+ static func isOwnSource(_ text: String?) -> Bool {
8
+ guard let text, let parts = URLComponents(string: text), let host = parts.host, !host.isEmpty else { return false }
9
+ if parts.user != nil || parts.password != nil || parts.query != nil || parts.fragment != nil { return false }
10
+ if parts.scheme == "https" { return true }
11
+ return parts.scheme == "http" && loopbackHosts.contains(host)
12
+ }
13
+ }
@@ -0,0 +1,58 @@
1
+ import Capacitor
2
+
3
+ /**
4
+ * The stock live updater's plugin, held by the guard and never registered with the bridge, so page script reaches it by
5
+ * no name: Capacitor dispatches only to registered plugins, and PageScriptFilter refuses a plugin id the bridge has not
6
+ * registered before Capacitor could load it by class name. Looked up by its `@objc` name, so Protobase's package builds
7
+ * without the live updater's.
8
+ */
9
+ final class StockLiveUpdate {
10
+ private static let className = "LiveUpdatePlugin"
11
+
12
+ let plugin: CAPPlugin & CAPBridgedPlugin
13
+
14
+ init(plugin: CAPPlugin & CAPBridgedPlugin) {
15
+ self.plugin = plugin
16
+ }
17
+
18
+ /** The stock plugin, not loaded yet, or nil when the app does not include it. */
19
+ static func make() -> StockLiveUpdate? {
20
+ guard let type = NSClassFromString(className) as? CAPPlugin.Type, let plugin = type.init() as? CAPPlugin & CAPBridgedPlugin else { return nil }
21
+ return StockLiveUpdate(plugin: plugin)
22
+ }
23
+
24
+ var methodNames: [String] {
25
+ plugin.pluginMethods.map(\.name)
26
+ }
27
+
28
+ func responds(to method: String) -> Bool {
29
+ plugin.responds(to: Self.selector(method))
30
+ }
31
+
32
+ /**
33
+ * Loads it as the bridge would (Capacitor's CAPPlugin+LoadInstance.swift), on `guarding`'s bridge, config and
34
+ * listeners, so its events reach the listeners page script added to the guard.
35
+ */
36
+ func load(guardedBy guarding: CAPPlugin & CAPBridgedPlugin) {
37
+ plugin.bridge = guarding.bridge
38
+ plugin.webView = guarding.webView
39
+ plugin.shouldStringifyDatesInCalls = true
40
+ plugin.eventListeners = guarding.eventListeners
41
+ plugin.retainedEventArguments = guarding.retainedEventArguments
42
+ plugin.pluginId = plugin.identifier
43
+ plugin.pluginName = guarding.pluginName
44
+ plugin.load()
45
+ }
46
+
47
+ /** Runs `method` as Capacitor would (CapacitorBridge.swift, handleJSCall), on a new call that holds only `options`. */
48
+ func forward(_ call: CAPPluginCall, _ method: String, options: [String: Any]) {
49
+ guard let forwarded = CAPPluginCall(callbackId: call.callbackId, methodName: method, options: options, success: call.successHandler, error: call.errorHandler) else {
50
+ fatalError("Capacitor made no plugin call")
51
+ }
52
+ plugin.perform(Self.selector(method), with: forwarded)
53
+ }
54
+
55
+ private static func selector(_ method: String) -> Selector {
56
+ NSSelectorFromString(method + ":")
57
+ }
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/mobile",
3
- "version": "0.57.0",
3
+ "version": "0.57.2",
4
4
  "description": "The Capacitor that protobase mobile builds a Protobase app's iOS and Android projects with, at fixed versions, and the native plugins that keep page script away from the session and give the iOS app its bounce; installed by the CLI on first use.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "android/build.gradle",
23
23
  "icon"
24
24
  ],
25
- "nativeContract": 3,
25
+ "nativeContract": 5,
26
26
  "capacitor": {
27
27
  "ios": {
28
28
  "src": "ios"