@capgo/capacitor-updater 8.51.15 → 8.51.16

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.
@@ -16,7 +16,5 @@ Pod::Spec.new do |s|
16
16
  s.dependency 'ZIPFoundation', '~> 0.9'
17
17
  s.dependency 'Alamofire', '5.10.2'
18
18
  s.dependency 'Version', '0.8.0'
19
- # We cannot update to latest because it not published anymore in cocoa https://github.com/attaswift/BigInt/issues/94
20
- s.dependency 'BigInt', '5.2.0'
21
19
  s.swift_version = '5.1'
22
20
  end
package/Package.swift CHANGED
@@ -13,8 +13,7 @@ let package = Package(
13
13
  .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0"),
14
14
  .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.12.0")),
15
15
  .package(url: "https://github.com/weichsel/ZIPFoundation.git", from: "0.9.20"),
16
- .package(url: "https://github.com/mrackwitz/Version.git", exact: "0.8.0"),
17
- .package(url: "https://github.com/attaswift/BigInt.git", from: "5.7.0")
16
+ .package(url: "https://github.com/mrackwitz/Version.git", exact: "0.8.0")
18
17
  ],
19
18
  targets: [
20
19
  .target(
@@ -24,8 +23,7 @@ let package = Package(
24
23
  .product(name: "Cordova", package: "capacitor-swift-pm"),
25
24
  .product(name: "ZIPFoundation", package: "ZIPFoundation"),
26
25
  .product(name: "Alamofire", package: "Alamofire"),
27
- .product(name: "Version", package: "Version"),
28
- .product(name: "BigInt", package: "BigInt")
26
+ .product(name: "Version", package: "Version")
29
27
  ],
30
28
  path: "ios/Sources/CapacitorUpdaterPlugin"),
31
29
  .testTarget(
@@ -3,6 +3,7 @@ ext {
3
3
  androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
4
4
  androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
5
5
  androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
6
+ okhttpVersion = project.hasProperty('okhttpVersion') ? rootProject.ext.okhttpVersion : '4.12.0'
6
7
  }
7
8
 
8
9
  buildscript {
@@ -54,7 +55,6 @@ dependencies {
54
55
  implementation "androidx.work:work-runtime:$work_version"
55
56
  implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
56
57
  implementation "com.google.android.gms:play-services-tasks:18.4.1"
57
- implementation "com.google.guava:guava:33.6.0-android"
58
58
  implementation fileTree(dir: 'libs', include: ['*.jar'])
59
59
  implementation project(':capacitor-android')
60
60
  implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
@@ -65,11 +65,11 @@ dependencies {
65
65
  testImplementation "junit:junit:$junitVersion"
66
66
  testImplementation 'org.mockito:mockito-core:5.23.0'
67
67
  testImplementation 'org.json:json:20250517'
68
- testImplementation 'org.robolectric:robolectric:4.16.1'
68
+ testImplementation 'org.robolectric:robolectric:4.17'
69
69
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
70
70
  androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
71
71
  implementation 'org.brotli:dec:0.1.2'
72
- implementation 'com.squareup.okhttp3:okhttp:5.4.0'
72
+ implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
73
73
  }
74
74
 
75
75
  tasks.withType(Test) {
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
146
146
  static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
147
147
  static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
148
148
 
149
- private final String pluginVersion = "8.51.15";
149
+ private final String pluginVersion = "8.51.16";
150
150
  private static final String DELAY_CONDITION_PREFERENCES = "";
151
151
 
152
152
  private SharedPreferences.Editor editor;
@@ -2532,11 +2532,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
2532
2532
  call.reject("setUpdateUrl called without url");
2533
2533
  return;
2534
2534
  }
2535
- this.updateUrl = url;
2536
2535
  if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2537
2536
  this.editor.putString(UPDATE_URL_PREF_KEY, url);
2538
- this.editor.apply();
2537
+ if (!this.editor.commit()) {
2538
+ logger.error("Failed to persist updateUrl");
2539
+ call.reject("Failed to persist updateUrl");
2540
+ return;
2541
+ }
2539
2542
  }
2543
+ this.updateUrl = url;
2540
2544
  call.resolve();
2541
2545
  }
2542
2546
 
@@ -2553,11 +2557,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
2553
2557
  call.reject("setStatsUrl called without url");
2554
2558
  return;
2555
2559
  }
2556
- this.implementation.statsUrl = url;
2557
2560
  if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2558
2561
  this.editor.putString(STATS_URL_PREF_KEY, url);
2559
- this.editor.apply();
2562
+ if (!this.editor.commit()) {
2563
+ logger.error("Failed to persist statsUrl");
2564
+ call.reject("Failed to persist statsUrl");
2565
+ return;
2566
+ }
2560
2567
  }
2568
+ this.implementation.statsUrl = url;
2561
2569
  call.resolve();
2562
2570
  }
2563
2571
 
@@ -2574,11 +2582,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
2574
2582
  call.reject("setChannelUrl called without url");
2575
2583
  return;
2576
2584
  }
2577
- this.implementation.channelUrl = url;
2578
2585
  if (Boolean.TRUE.equals(this.persistModifyUrl)) {
2579
2586
  this.editor.putString(CHANNEL_URL_PREF_KEY, url);
2580
- this.editor.apply();
2587
+ if (!this.editor.commit()) {
2588
+ logger.error("Failed to persist channelUrl");
2589
+ call.reject("Failed to persist channelUrl");
2590
+ return;
2591
+ }
2581
2592
  }
2593
+ this.implementation.channelUrl = url;
2582
2594
  call.resolve();
2583
2595
  }
2584
2596
 
@@ -23,8 +23,6 @@ import androidx.work.OneTimeWorkRequest;
23
23
  import androidx.work.PeriodicWorkRequest;
24
24
  import androidx.work.WorkInfo;
25
25
  import androidx.work.WorkManager;
26
- import com.google.common.util.concurrent.Futures;
27
- import com.google.common.util.concurrent.ListenableFuture;
28
26
  import java.io.BufferedInputStream;
29
27
  import java.io.BufferedReader;
30
28
  import java.io.File;
@@ -2901,6 +2899,11 @@ public class CapgoUpdater {
2901
2899
  statsQueue.add(new QueuedStatsEvent(arr.getJSONObject(i), null));
2902
2900
  }
2903
2901
  }
2902
+ if (backup.exists() && !backup.delete()) {
2903
+ if (logger != null) {
2904
+ logger.error("Failed to delete stats backup");
2905
+ }
2906
+ }
2904
2907
  if (!statsQueue.isEmpty()) {
2905
2908
  if (logger != null) {
2906
2909
  logger.info("Restored " + statsQueue.size() + " pending stats events");
@@ -2956,9 +2959,11 @@ public class CapgoUpdater {
2956
2959
  }
2957
2960
  try {
2958
2961
  if (arr.length() == 0) {
2959
- if (file.exists() && !file.delete()) {
2962
+ writeFileAtomically(file, "[]".getBytes(StandardCharsets.UTF_8));
2963
+ File backup = new File(file.getAbsolutePath() + ".bak");
2964
+ if (backup.exists() && !backup.delete()) {
2960
2965
  if (logger != null) {
2961
- logger.error("Failed to delete empty stats queue file");
2966
+ logger.error("Failed to delete empty stats backup");
2962
2967
  }
2963
2968
  }
2964
2969
  return;
@@ -2989,7 +2994,7 @@ public class CapgoUpdater {
2989
2994
  }
2990
2995
  }
2991
2996
 
2992
- private static void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
2997
+ private void writeFileAtomically(final File file, final byte[] bytes) throws IOException {
2993
2998
  final File tmp = new File(file.getAbsolutePath() + ".tmp");
2994
2999
  File backup = null;
2995
3000
  try {
@@ -3011,7 +3016,9 @@ public class CapgoUpdater {
3011
3016
  }
3012
3017
  if (tmp.renameTo(file)) {
3013
3018
  if (backup != null && backup.exists() && !backup.delete()) {
3014
- backup.deleteOnExit();
3019
+ if (logger != null) {
3020
+ logger.error("Failed to delete stats backup");
3021
+ }
3015
3022
  }
3016
3023
  backup = null;
3017
3024
  return;
@@ -75,6 +75,17 @@ public class DelayUpdateUtils {
75
75
  ", longValue: " +
76
76
  longValue
77
77
  );
78
+ } else {
79
+ delayConditionListToKeep.add(condition);
80
+ logger.info(
81
+ "Background delay (value: " +
82
+ value +
83
+ ") condition kept at index " +
84
+ index +
85
+ " (source: " +
86
+ source.toString() +
87
+ ")"
88
+ );
78
89
  }
79
90
  } else {
80
91
  delayConditionListToKeep.add(condition);
@@ -69,6 +69,10 @@ import Foundation
69
69
  return BundleStatus.DELETING == self.status
70
70
  }
71
71
 
72
+ public func isDownloading() -> Bool {
73
+ return BundleStatus.DOWNLOADING == self.status
74
+ }
75
+
72
76
  public func isDownloaded() -> Bool {
73
77
  return !self.isBuiltin() && self.downloaded != "" &&
74
78
  self.downloaded != BundleInfo.DOWNLOADED_BUILTIN && !self.isDeleted() && !self.isDeleting()
@@ -96,7 +96,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
96
96
  deinit {
97
97
  implementation.shutdown()
98
98
  }
99
- private let pluginVersion: String = "8.51.15"
99
+ private let pluginVersion: String = "8.51.16"
100
100
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
101
101
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
102
102
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -191,9 +191,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
191
191
 
192
192
  // Lock to ensure cleanup completes before downloads start
193
193
  private let cleanupLock = NSLock()
194
+ // Short lock for cleanupComplete/cleanupTimedOut only. Never hold across cleanupGroup.wait.
195
+ private let cleanupStateLock = NSLock()
194
196
  private let defaultChannelStateLock = NSLock()
195
197
  private let cleanupGroup = DispatchGroup()
196
198
  private var cleanupComplete = false
199
+ private var cleanupTimedOut = false
197
200
  private var cleanupThread: Thread?
198
201
  private var defaultChannelCleanupMustRetry = false
199
202
  private var persistCustomId = false
@@ -216,6 +219,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
216
219
  // Best-effort flag: set before we expect notifyAppReady (load/reload).
217
220
  // No lock — never held across waits; a rare race only mis-times one wait.
218
221
  private var pendingNotifyAppReady = false
222
+ private let semaphoreWaitTestingLock = NSLock()
223
+ private var didEnterSemaphoreWaitForTesting = false
219
224
 
220
225
  private var delayUpdateUtils: DelayUpdateUtils!
221
226
 
@@ -427,7 +432,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
427
432
 
428
433
  // Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
429
434
  self.implementation.beforeDownload = { [weak self] in
430
- self?.waitForCleanupIfNeeded()
435
+ try self?.waitForCleanupIfNeeded()
431
436
  }
432
437
  // Always run async cleanup: delete obsolete bundles on native update (when enabled)
433
438
  // and sweep orphan directories every launch. Must not block app startup.
@@ -594,6 +599,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
594
599
 
595
600
  private func semaphoreWait(waitTime: Int) {
596
601
  // print("\\(CapgoUpdater.TAG) semaphoreWait \\(waitTime)")
602
+ semaphoreWaitTestingLock.lock()
603
+ didEnterSemaphoreWaitForTesting = true
604
+ semaphoreWaitTestingLock.unlock()
597
605
  let result = semaphoreReady.wait(timeout: .now() + .milliseconds(waitTime))
598
606
  if result == .timedOut {
599
607
  logger.error("Semaphore wait timed out after \(waitTime)ms")
@@ -1035,9 +1043,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1035
1043
  }
1036
1044
 
1037
1045
  private func cleanupObsoleteVersions(resetWhenUpdate: Bool = true, didResetCurrentBundle: Bool = false) {
1038
- // Enter before start so waiters never race past an unstarted cleanup thread.
1046
+ // Enter before publishing incomplete state so waiters cannot hit an empty group.
1047
+ self.cleanupStateLock.lock()
1039
1048
  self.cleanupComplete = false
1049
+ self.cleanupTimedOut = false
1040
1050
  self.cleanupGroup.enter()
1051
+ self.cleanupStateLock.unlock()
1041
1052
  cleanupThread = Thread {
1042
1053
  let bgTaskLock = NSLock()
1043
1054
  var cleanupBackgroundTask = UIBackgroundTaskIdentifier.invalid
@@ -1054,7 +1065,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1054
1065
  }
1055
1066
  self.cleanupLock.lock()
1056
1067
  defer {
1068
+ self.cleanupStateLock.lock()
1057
1069
  self.cleanupComplete = true
1070
+ self.cleanupStateLock.unlock()
1058
1071
  self.cleanupLock.unlock()
1059
1072
  self.cleanupGroup.leave()
1060
1073
  endCleanupBackgroundTask()
@@ -1139,17 +1152,39 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1139
1152
  cleanupThread?.start()
1140
1153
  }
1141
1154
 
1142
- private func waitForCleanupIfNeeded() {
1143
- if cleanupComplete {
1155
+ private func cleanupTimeoutError() -> NSError {
1156
+ NSError(
1157
+ domain: "CapacitorUpdater",
1158
+ code: 1,
1159
+ userInfo: [NSLocalizedDescriptionKey: "Cleanup did not finish before download"]
1160
+ )
1161
+ }
1162
+
1163
+ private func waitForCleanupIfNeeded() throws {
1164
+ cleanupStateLock.lock()
1165
+ let alreadyDone = cleanupComplete
1166
+ let alreadyTimedOut = cleanupTimedOut
1167
+ cleanupStateLock.unlock()
1168
+ if alreadyDone {
1144
1169
  return // Already done, no need to wait
1145
1170
  }
1171
+ if alreadyTimedOut {
1172
+ throw cleanupTimeoutError()
1173
+ }
1146
1174
 
1147
1175
  logger.info("Waiting for cleanup to complete before starting download...")
1148
1176
  let result = cleanupGroup.wait(timeout: .now() + .seconds(60))
1149
1177
  if result == .timedOut {
1150
1178
  logger.warn("Cleanup wait timed out after 60s, cancelling leftover cleanup")
1151
1179
  cleanupThread?.cancel()
1152
- _ = cleanupGroup.wait(timeout: .now() + .seconds(60))
1180
+ let cancelled = cleanupGroup.wait(timeout: .now() + .seconds(60))
1181
+ if cancelled == .timedOut {
1182
+ cleanupStateLock.lock()
1183
+ cleanupTimedOut = true
1184
+ cleanupStateLock.unlock()
1185
+ logger.error("Cleanup did not finish after cancel, aborting download")
1186
+ throw cleanupTimeoutError()
1187
+ }
1153
1188
  return
1154
1189
  }
1155
1190
  logger.info("Cleanup finished, proceeding with download")
@@ -1568,8 +1603,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1568
1603
  }
1569
1604
 
1570
1605
  private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
1571
- // Manual/preview downloads must wait too - launch orphan sweep can delete their temps.
1572
- self.waitForCleanupIfNeeded()
1573
1606
  guard let url = URL(string: urlString) else {
1574
1607
  throw makePreviewError("Invalid download URL")
1575
1608
  }
@@ -1809,6 +1842,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1809
1842
  }
1810
1843
  }
1811
1844
 
1845
+ private func queueBundleForNextBackgroundInstall(_ next: BundleInfo) -> Bool {
1846
+ guard self.implementation.setNextBundle(next: next.getId()) else {
1847
+ self.logger.error("Failed to queue downloaded bundle as next: \(next.toString())")
1848
+ return false
1849
+ }
1850
+ return true
1851
+ }
1852
+
1812
1853
  private func applyDownloadedBundleForDirectUpdate(_ next: BundleInfo) -> Bool {
1813
1854
  let previousState = self.implementation.captureResetState()
1814
1855
  let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
@@ -3993,6 +4034,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3993
4034
  isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
3994
4035
  }
3995
4036
 
4037
+ /// Incomplete leftovers must be downloaded again. Android already skips
4038
+ /// `DOWNLOADING` here; iOS used to treat them as ready, then `setNextBundle`
4039
+ /// failed quietly and the update never applied.
4040
+ static func shouldRetryDownloadForExistingBundle(_ bundle: BundleInfo) -> Bool {
4041
+ bundle.isDeleted() || bundle.isDeleting() || bundle.isDownloading()
4042
+ }
4043
+
3996
4044
  static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
3997
4045
  directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
3998
4046
  }
@@ -4063,6 +4111,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4063
4111
  pendingNotifyAppReady = true
4064
4112
  }
4065
4113
 
4114
+ func resetSemaphoreWaitTestingStateForTesting() {
4115
+ semaphoreWaitTestingLock.lock()
4116
+ didEnterSemaphoreWaitForTesting = false
4117
+ semaphoreWaitTestingLock.unlock()
4118
+ }
4119
+
4120
+ var didEnterSemaphoreWaitForTestingState: Bool {
4121
+ semaphoreWaitTestingLock.lock()
4122
+ defer { semaphoreWaitTestingLock.unlock() }
4123
+ return didEnterSemaphoreWaitForTesting
4124
+ }
4125
+
4066
4126
  func clearPendingNotifyAppReadyForTesting() {
4067
4127
  clearPendingNotifyAppReady()
4068
4128
  }
@@ -4298,7 +4358,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4298
4358
  return
4299
4359
  }
4300
4360
  // File mutations wait here. getLatest already ran in parallel with cleanup.
4301
- self.waitForCleanupIfNeeded()
4361
+ do {
4362
+ try self.waitForCleanupIfNeeded()
4363
+ } catch {
4364
+ self.logger.error("Cleanup still running, skipping download")
4365
+ self.clearDownloadInProgressState()
4366
+ self.endBackGroundTask()
4367
+ return
4368
+ }
4302
4369
  current = self.implementation.getCurrentBundle()
4303
4370
  if res.version == "builtin" {
4304
4371
  self.logger.info("Latest version is builtin")
@@ -4361,14 +4428,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4361
4428
  do {
4362
4429
  self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
4363
4430
  var nextImpl = self.implementation.getBundleInfoByVersionName(version: latestVersionName)
4364
- if nextImpl == nil || nextImpl?.isDeleted() == true {
4365
- if nextImpl?.isDeleted() == true {
4366
- self.logger.info("Latest bundle already exists and will be deleted, download will overwrite it.")
4367
- let res = self.implementation.delete(id: nextImpl!.getId(), removeInfo: true)
4368
- if res {
4369
- self.logger.info("Failed bundle deleted: \(nextImpl!.toString())")
4431
+ let needsDownload = nextImpl.map(Self.shouldRetryDownloadForExistingBundle) ?? true
4432
+ if needsDownload {
4433
+ if let existing = nextImpl {
4434
+ self.logger.info("Latest bundle already exists in incomplete state (\(existing.getStatus())) and will be deleted, download will overwrite it.")
4435
+ _ = self.implementation.setNextBundle(next: Optional<String>.none)
4436
+ let deleted = self.implementation.delete(id: existing.getId(), removeInfo: true)
4437
+ if deleted {
4438
+ self.logger.info("Incomplete bundle deleted: \(existing.toString())")
4370
4439
  } else {
4371
- self.logger.error("Failed to delete failed bundle: \(nextImpl!.toString())")
4440
+ self.logger.error("Failed to delete incomplete bundle: \(existing.toString())")
4372
4441
  }
4373
4442
  }
4374
4443
  self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
@@ -4455,8 +4524,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4455
4524
  error: false,
4456
4525
  plannedDirectUpdate: plannedDirectUpdate
4457
4526
  )
4458
- } else {
4459
- _ = self.implementation.setNextBundle(next: next.getId())
4527
+ } else if self.queueBundleForNextBackgroundInstall(next) {
4460
4528
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4461
4529
  self.endBackGroundTaskWithNotif(
4462
4530
  msg: "Direct update reload failed, update will install next background",
@@ -4465,20 +4533,35 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4465
4533
  error: false,
4466
4534
  plannedDirectUpdate: plannedDirectUpdate
4467
4535
  )
4536
+ } else {
4537
+ self.endBackGroundTaskWithNotif(
4538
+ msg: "Direct update reload failed, and next bundle could not be queued",
4539
+ latestVersionName: latestVersionName,
4540
+ current: current,
4541
+ plannedDirectUpdate: plannedDirectUpdate
4542
+ )
4468
4543
  }
4469
4544
  } else if self.shouldAutoSetNextBundle() {
4470
4545
  if plannedDirectUpdate && !directUpdateAllowed {
4471
4546
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
4472
4547
  }
4473
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4474
- _ = self.implementation.setNextBundle(next: next.getId())
4475
- self.endBackGroundTaskWithNotif(
4476
- msg: "update downloaded, will install next background",
4477
- latestVersionName: latestVersionName,
4478
- current: current,
4479
- error: false,
4480
- plannedDirectUpdate: plannedDirectUpdate
4481
- )
4548
+ if self.queueBundleForNextBackgroundInstall(next) {
4549
+ self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4550
+ self.endBackGroundTaskWithNotif(
4551
+ msg: "update downloaded, will install next background",
4552
+ latestVersionName: latestVersionName,
4553
+ current: current,
4554
+ error: false,
4555
+ plannedDirectUpdate: plannedDirectUpdate
4556
+ )
4557
+ } else {
4558
+ self.endBackGroundTaskWithNotif(
4559
+ msg: "Update downloaded, but next bundle could not be queued",
4560
+ latestVersionName: latestVersionName,
4561
+ current: current,
4562
+ plannedDirectUpdate: plannedDirectUpdate
4563
+ )
4564
+ }
4482
4565
  } else {
4483
4566
  self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
4484
4567
  self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
@@ -0,0 +1,36 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+ import Security
9
+ import os.log
10
+
11
+ /// Isolated raw RSA primitives for Capgo v2 checksum/session-key recovery.
12
+ /// SonarCloud automatic analysis has no Swift // NOSONAR; swift:S5542 is ignored
13
+ /// via `.sonarcloud.properties` multicriteria because `.rsaEncryptionRaw` is
14
+ /// intentional wire compatibility with Node `privateEncrypt` / `publicDecrypt`.
15
+ enum CapgoRawRsa {
16
+ private static let log = OSLog(subsystem: "ee.forgr.capacitor_updater", category: "RSA")
17
+
18
+ static func supportsCapgoRecovery(key: SecKey) -> Bool {
19
+ SecKeyGetBlockSize(key) == 256
20
+ && SecKeyIsAlgorithmSupported(key, .encrypt, .rsaEncryptionRaw) // NOSONAR intentional raw RSA for Node privateEncrypt / Capgo checksum recovery
21
+ }
22
+
23
+ static func recoverSignatureBlock(key: SecKey, data: Data) -> Data? {
24
+ var error: Unmanaged<CFError>?
25
+ guard let recovered = SecKeyCreateEncryptedData(key, .rsaEncryptionRaw, data as CFData, &error) as Data? else { // NOSONAR intentional raw RSA for Node privateEncrypt / Capgo checksum recovery
26
+ os_log(
27
+ "RSA raw recovery failed: %{public}@",
28
+ log: log,
29
+ type: .error,
30
+ String(describing: error?.takeRetainedValue())
31
+ )
32
+ return nil
33
+ }
34
+ return recovered
35
+ }
36
+ }
@@ -45,7 +45,7 @@ import UIKit
45
45
  public var timeout: Double = 20
46
46
  public var statsUrl: String = ""
47
47
  /// Optional gate run before any download touches disk (e.g. wait for launch cleanup).
48
- public var beforeDownload: (() -> Void)?
48
+ public var beforeDownload: (() throws -> Void)?
49
49
  public var channelUrl: String = ""
50
50
  public var defaultChannel: String = ""
51
51
  public var appId: String = ""
@@ -928,10 +928,7 @@ import UIKit
928
928
 
929
929
  struct ManifestLookupEntry {
930
930
  let hash: String
931
- /// The manifest's own file name, `.br` suffix included when present. The
932
- /// built-in bundle stores files under this exact name (see
933
- /// isManifestEntryAvailableLocally), unlike the extracted/cached copy which
934
- /// is always named without the suffix.
931
+ /// The manifest's own file name, `.br` suffix included when present.
935
932
  let originalFileName: String
936
933
  }
937
934
 
@@ -994,10 +991,14 @@ import UIKit
994
991
 
995
992
  // Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
996
993
  // so there's no need to also duplicate this file into the delta cache
997
- let builtinRelativePath = knownEntry?.originalFileName ?? relativePath
998
- let builtinFilePath = builtinFolder.appendingPathComponent(builtinRelativePath)
999
- let isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
1000
- verifyChecksum(file: builtinFilePath, expectedHash: checksum)
994
+ let builtinLookupName = knownEntry?.originalFileName ?? relativePath
995
+ let isBuiltinOrigin: Bool
996
+ if let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: builtinLookupName) {
997
+ isBuiltinOrigin = fileManager.fileExists(atPath: builtinFilePath.path) &&
998
+ verifyChecksum(file: builtinFilePath, expectedHash: checksum)
999
+ } else {
1000
+ isBuiltinOrigin = false
1001
+ }
1001
1002
  if isBuiltinOrigin {
1002
1003
  continue
1003
1004
  }
@@ -1231,7 +1232,10 @@ import UIKit
1231
1232
  }
1232
1233
 
1233
1234
  let builtinFolder = self.builtinFolderURL()
1234
- let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
1235
+ // The .br suffix describes transport; builtin assets are uncompressed.
1236
+ guard let builtinFilePath = try? Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName) else {
1237
+ return false
1238
+ }
1235
1239
  if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
1236
1240
  return true
1237
1241
  }
@@ -1365,12 +1369,12 @@ import UIKit
1365
1369
  return json
1366
1370
  }
1367
1371
 
1368
- private func runBeforeDownload() {
1369
- beforeDownload?()
1372
+ private func runBeforeDownload() throws {
1373
+ try beforeDownload?()
1370
1374
  }
1371
1375
 
1372
1376
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1373
- self.runBeforeDownload()
1377
+ try self.runBeforeDownload()
1374
1378
  let id = self.randomString(length: 10)
1375
1379
  logger.info("downloadManifest start \(id)")
1376
1380
  let destFolder = self.getBundleDirectory(id: id)
@@ -1474,7 +1478,7 @@ import UIKit
1474
1478
  let builtinFilePath: URL
1475
1479
  do {
1476
1480
  destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1477
- builtinFilePath = try Self.resolvePathInsideDirectory(baseDirectory: builtinFolder, relativePath: fileName)
1481
+ builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
1478
1482
  } catch {
1479
1483
  logger.error("Invalid manifest file path: \(fileName)")
1480
1484
  self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
@@ -1973,7 +1977,7 @@ import UIKit
1973
1977
  }
1974
1978
 
1975
1979
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1976
- self.runBeforeDownload()
1980
+ try self.runBeforeDownload()
1977
1981
  let id: String = self.randomString(length: 10)
1978
1982
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
1979
1983
  if version != getLocalUpdateVersion(for: id) {
@@ -1,274 +1,96 @@
1
- import BigInt
2
- import Foundation
3
- import CommonCrypto
4
- import CryptoKit
5
-
6
- ///
7
- /// Constants
8
- ///
9
- private enum RSAConstants {
10
- static let rsaKeySizeInBits: NSNumber = 2048
11
- static let rsaAlgorithm: SecKeyAlgorithm = .rsaEncryptionOAEPSHA256
12
- }
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
13
6
 
14
- // We do all this stuff because ios is shit and open source libraries allow to do decryption with public key
15
- // So we have to do it manually, while in nodejs or Java it's ok and done at language level.
7
+ import Foundation
8
+ import Security
9
+ import os.log
16
10
 
17
- ///
18
- /// The RSA public key.
19
- ///
11
+ /// Capgo v2 uses Node's privateEncrypt with PKCS#1 v1.5 type-1 padding for
12
+ /// checksums and session keys. Recover the signed payload using Apple's RSA
13
+ /// implementation instead of custom modular exponentiation.
20
14
  public struct RSAPublicKey {
21
- private let manualKey: ManualRSAPublicKey
22
-
23
- fileprivate init(manualKey: ManualRSAPublicKey) {
24
- self.manualKey = manualKey
25
- }
26
-
27
- ///
28
- /// Takes the data and uses the public key to decrypt it.
29
- /// Returns the decrypted data.
30
- ///
31
- public func decrypt(data: Data) -> Data? {
32
- return manualKey.decrypt(data)
33
- }
15
+ private let key: SecKey
16
+ private static let keyLock = NSLock()
17
+ private static let log = OSLog(subsystem: "ee.forgr.capacitor_updater", category: "RSA")
18
+ // One public key only: bounded storage, invalid/rotated keys never fall back
19
+ // to a cached key with different material. SecKey is immutable after import.
20
+ private static var cachedKey: (der: Data, key: SecKey)?
34
21
 
35
- ///
36
- /// Allows you to load an RSA public key (i.e. one downloaded from the net).
37
- ///
38
22
  public static func load(rsaPublicKey: String) -> RSAPublicKey? {
39
- // Clean up the key string
40
- var pubKey: String = rsaPublicKey
41
- pubKey = pubKey.replacingOccurrences(of: "-----BEGIN RSA PUBLIC KEY-----", with: "")
42
- pubKey = pubKey.replacingOccurrences(of: "-----END RSA PUBLIC KEY-----", with: "")
43
- pubKey = pubKey.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
44
- pubKey = pubKey.replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
45
- pubKey = pubKey.replacingOccurrences(of: "\\n+", with: "", options: .regularExpression)
46
- pubKey = pubKey.replacingOccurrences(of: "\n", with: "")
47
- pubKey = pubKey.trimmingCharacters(in: .whitespacesAndNewlines)
48
-
49
- do {
50
- guard let rsaPublicKeyData: Data = Data(base64Encoded: String(pubKey)) else {
51
- throw CustomError.cannotDecode
52
- }
53
-
54
- // Try parsing as PKCS#1
55
- if let manualKey = ManualRSAPublicKey.fromPKCS1(rsaPublicKeyData) {
56
- return RSAPublicKey(manualKey: manualKey)
57
- }
58
-
59
- // Most common public exponent is 65537 (0x010001)
60
- let commonExponent = Data([0x01, 0x00, 0x01]) // 65537 in big-endian
61
-
62
- // Assume the entire key data is the modulus
63
- let lastResortKey = ManualRSAPublicKey(modulus: rsaPublicKeyData, exponent: commonExponent)
64
- return RSAPublicKey(manualKey: lastResortKey)
65
- } catch {
66
- return nil
67
- }
68
- }
69
- }
70
-
71
- // Manual RSA Public Key Implementation using the BigInt library
72
- struct ManualRSAPublicKey {
73
- let modulus: BigInt
74
- let exponent: BigInt
75
-
76
- init(modulus: Data, exponent: Data) {
77
- // Create positive BigInts from Data
78
- let modulusBytes = [UInt8](modulus)
79
- var modulusValue = BigUInt(0)
80
- for byte in modulusBytes {
81
- modulusValue = (modulusValue << 8) | BigUInt(byte)
82
- }
83
- self.modulus = BigInt(modulusValue)
84
- let exponentBytes = [UInt8](exponent)
85
- var exponentValue = BigUInt(0)
86
- for byte in exponentBytes {
87
- exponentValue = (exponentValue << 8) | BigUInt(byte)
88
- }
89
- self.exponent = BigInt(exponentValue)
90
- }
91
-
92
- // Parse PKCS#1 format public key
93
- static func fromPKCS1(_ publicKeyData: Data) -> ManualRSAPublicKey? {
94
- // Parse ASN.1 DER encoded RSA public key
95
- // Format: RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }
96
-
97
- guard publicKeyData.count > 0 else {
98
- return nil
99
- }
100
-
101
- let bytes = [UInt8](publicKeyData)
102
-
103
- // Check for sequence tag (0x30)
104
- guard bytes[0] == 0x30 else {
105
- // Try direct modulus/exponent approach as fallback
106
- if publicKeyData.count >= 3 {
107
- // Assume this is a raw RSA public key with modulus + exponent
108
- // Most common: modulus is 256 bytes (2048 bits), exponent is 3 bytes (0x010001 = 65537)
109
- let modulusSize = publicKeyData.count - 3
110
- if modulusSize > 0 {
111
- let modulusData = publicKeyData.prefix(modulusSize)
112
- let exponentData = publicKeyData.suffix(3)
113
- return ManualRSAPublicKey(modulus: modulusData, exponent: exponentData)
114
- }
115
- }
116
-
117
- return nil
118
- }
119
-
120
- var index = 1
121
-
122
- // Skip length
123
- if bytes[index] & 0x80 != 0 {
124
- let lenBytes = Int(bytes[index] & 0x7F)
125
- if (index + 1 + lenBytes) >= bytes.count {
126
- return nil
127
- }
128
- index += 1 + lenBytes
129
- } else {
130
- index += 1
131
- }
132
-
133
- // Check for INTEGER tag for modulus (0x02)
134
- if index >= bytes.count {
135
- return nil
136
- }
137
-
138
- guard bytes[index] == 0x02 else {
139
- return nil
140
- }
141
- index += 1
142
-
143
- // Get modulus length
144
- if index >= bytes.count {
145
- return nil
146
- }
147
-
148
- var modulusLength = 0
149
- if bytes[index] & 0x80 != 0 {
150
- let lenBytes = Int(bytes[index] & 0x7F)
151
- if (index + 1 + lenBytes) >= bytes.count {
152
- return nil
153
- }
154
- index += 1
155
- for i in 0..<lenBytes {
156
- modulusLength = (modulusLength << 8) | Int(bytes[index + i])
157
- }
158
- index += lenBytes
159
- } else {
160
- modulusLength = Int(bytes[index])
161
- index += 1
162
- }
163
-
164
- // Skip any leading zero in modulus (for unsigned integer)
165
- if index < bytes.count && bytes[index] == 0x00 {
166
- index += 1
167
- modulusLength -= 1
168
- }
169
-
170
- // Extract modulus
171
- if (index + modulusLength) > bytes.count {
172
- return nil
173
- }
174
-
175
- let modulusData = Data(bytes[index..<(index + modulusLength)])
176
- index += modulusLength
177
-
178
- // Check for INTEGER tag for exponent
179
- if index >= bytes.count {
23
+ guard let der = pemToDer(rsaPublicKey) else {
24
+ os_log("RSA key import failed: invalid PEM/base64", log: Self.log, type: .error)
180
25
  return nil
181
26
  }
182
27
 
183
- guard bytes[index] == 0x02 else {
28
+ keyLock.lock()
29
+ defer { keyLock.unlock() }
30
+ if let cached = cachedKey, cached.der == der {
31
+ return RSAPublicKey(key: cached.key)
32
+ }
33
+
34
+ let attributes: [CFString: Any] = [
35
+ kSecAttrKeyType: kSecAttrKeyTypeRSA,
36
+ kSecAttrKeyClass: kSecAttrKeyClassPublic
37
+ ]
38
+ var error: Unmanaged<CFError>?
39
+ guard let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error) else {
40
+ os_log(
41
+ "RSA key import failed: %{public}@",
42
+ log: Self.log,
43
+ type: .error,
44
+ String(describing: error?.takeRetainedValue())
45
+ )
184
46
  return nil
185
47
  }
186
- index += 1
187
-
188
- // Get exponent length
189
- if index >= bytes.count {
48
+ guard CapgoRawRsa.supportsCapgoRecovery(key: key) else {
49
+ os_log(
50
+ "RSA key import failed: unsupported key (Capgo requires RSA-2048 with raw encryption)",
51
+ log: Self.log,
52
+ type: .error
53
+ )
190
54
  return nil
191
55
  }
56
+ cachedKey = (der, key)
57
+ return RSAPublicKey(key: key)
58
+ }
192
59
 
193
- var exponentLength = 0
194
- if bytes[index] & 0x80 != 0 {
195
- let lenBytes = Int(bytes[index] & 0x7F)
196
- if (index + 1 + lenBytes) >= bytes.count {
197
- return nil
198
- }
199
- index += 1
200
- for i in 0..<lenBytes {
201
- exponentLength = (exponentLength << 8) | Int(bytes[index + i])
202
- }
203
- index += lenBytes
204
- } else {
205
- exponentLength = Int(bytes[index])
206
- index += 1
207
- }
208
-
209
- // Extract exponent
210
- if (index + exponentLength) > bytes.count {
60
+ public func decrypt(data: Data) -> Data? {
61
+ let blockSize = SecKeyGetBlockSize(key)
62
+ guard data.count == blockSize else { return nil }
63
+ // Raw public-key encryption is the same c^e mod n operation as Node's
64
+ // publicDecrypt. Do NOT use encryption PKCS#1 padding here: the recovered
65
+ // block already contains signature (type-1) padding, checked below.
66
+ guard let recovered = CapgoRawRsa.recoverSignatureBlock(key: key, data: data) else {
211
67
  return nil
212
68
  }
213
-
214
- let exponentData = Data(bytes[index..<(index + exponentLength)])
215
- return ManualRSAPublicKey(modulus: modulusData, exponent: exponentData)
69
+ return Self.unpadSignature(recovered, blockSize: blockSize)
216
70
  }
217
71
 
218
- // Decrypt data using raw RSA operation (c^d mod n)
219
- func decrypt(_ encryptedData: Data) -> Data? {
220
- // Create positive BigInt from encrypted data
221
- let encryptedBytes = [UInt8](encryptedData)
222
- var encryptedValue = BigUInt(0)
223
- for byte in encryptedBytes {
224
- encryptedValue = (encryptedValue << 8) | BigUInt(byte)
72
+ static func unpadSignature(_ block: Data, blockSize: Int = 256) -> Data? {
73
+ let bytes = [UInt8](block)
74
+ // RFC 8017: 00 01, at least eight FF bytes, 00, then the payload.
75
+ guard bytes.count == blockSize, bytes[0] == 0, bytes[1] == 1 else { return nil }
76
+ var separator = 2
77
+ while separator < bytes.count && bytes[separator] == 0xff {
78
+ separator += 1
225
79
  }
226
- let encrypted = BigInt(encryptedValue)
227
-
228
- // In Node.js:
229
- // privateEncrypt uses the private key (d) to encrypt
230
- // publicDecrypt uses the public key (e) to decrypt
231
- // The operation we want is: ciphertext^e mod n
232
-
233
- // RSA operation: c^e mod n
234
- let decrypted = encrypted.manualPower(exponent, modulus: modulus)
235
-
236
- // Convert to bytes with proper padding
237
- guard let bigUIntValue = decrypted.magnitude as? BigUInt else {
80
+ guard separator >= 10, separator < bytes.count - 1, bytes[separator] == 0 else {
238
81
  return nil
239
82
  }
83
+ return Data(bytes[(separator + 1)...])
84
+ }
240
85
 
241
- // Convert BigUInt to bytes with padding
242
- var resultBytes = [UInt8]()
243
- var tempValue = bigUIntValue
244
- while tempValue > 0 {
245
- let byte = UInt8(tempValue & 0xFF)
246
- resultBytes.insert(byte, at: 0) // Prepend to get big-endian
247
- tempValue >>= 8
248
- }
249
-
250
- // Ensure we have at least 256 bytes (2048 bits) with leading zeros
251
- let paddedBytes = [UInt8](repeating: 0, count: max(0, 256 - resultBytes.count)) + resultBytes
252
-
253
- // For PKCS1 padding from Node.js privateEncrypt, the format is:
254
- // 0x00 || 0x01 || PS || 0x00 || actual data
255
- // where PS is a string of 0xFF bytes
256
-
257
- // Check for privateEncrypt padding format (0x00 || 0x01 || PS || 0x00)
258
- var startIndex = 0
259
- if paddedBytes.count > 2 && paddedBytes[0] == 0x00 && paddedBytes[1] == 0x01 {
260
- for i in 2..<paddedBytes.count {
261
- if paddedBytes[i] == 0x00 {
262
- startIndex = i + 1
263
- break
264
- }
265
- }
266
- }
267
- if startIndex < paddedBytes.count {
268
- let result = Data(paddedBytes[startIndex...])
269
- return result
270
- } else {
271
- return Data(paddedBytes)
272
- }
86
+ private static func pemToDer(_ pem: String) -> Data? {
87
+ let encoded = pem
88
+ .replacingOccurrences(of: "-----BEGIN RSA PUBLIC KEY-----", with: "")
89
+ .replacingOccurrences(of: "-----END RSA PUBLIC KEY-----", with: "")
90
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
91
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
92
+ .replacingOccurrences(of: "\\n", with: "")
93
+ .components(separatedBy: .whitespacesAndNewlines).joined()
94
+ return Data(base64Encoded: encoded)
273
95
  }
274
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.15",
3
+ "version": "8.51.16",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",
@@ -89,7 +89,7 @@
89
89
  "@ionic/eslint-config": "^0.4.0",
90
90
  "@ionic/prettier-config": "^4.0.0",
91
91
  "@ionic/swiftlint-config": "^2.0.0",
92
- "@types/node": "^24.3.0",
92
+ "@types/node": "^26.0.0",
93
93
  "eslint": "^8.57.0",
94
94
  "eslint-plugin-import": "^2.32.0",
95
95
  "husky": "^9.1.7",
@@ -1,39 +0,0 @@
1
- import BigInt
2
-
3
- // Add this custom power function to ensure safer handling of power operations
4
-
5
- // Manual exponentiation using the square-and-multiply algorithm
6
- // which is more efficient and avoids using the built-in functions that might handle BigInt differently
7
- extension BigInt {
8
- func manualPower(_ exponent: BigInt, modulus: BigInt) -> BigInt {
9
- // Quick checks
10
- if modulus == 0 {
11
- return 0
12
- }
13
-
14
- if exponent == 0 {
15
- return 1
16
- }
17
-
18
- guard let base = self.magnitude as? BigUInt,
19
- let exp = exponent.magnitude as? BigUInt,
20
- let mod = modulus.magnitude as? BigUInt else {
21
- return 0
22
- }
23
-
24
- // Square and multiply algorithm for modular exponentiation
25
- var result = BigUInt(1)
26
- var currentBase = base % mod
27
- var currentExp = exp
28
-
29
- while currentExp > 0 {
30
- if currentExp & 1 == 1 {
31
- result = (result * currentBase) % mod
32
- }
33
- currentBase = (currentBase * currentBase) % mod
34
- currentExp >>= 1
35
- }
36
-
37
- return BigInt(result)
38
- }
39
- }