@capgo/capacitor-updater 8.51.15 → 8.51.17
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/CapgoCapacitorUpdater.podspec +0 -2
- package/Package.swift +2 -4
- package/android/build.gradle +3 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +37 -14
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +13 -6
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +11 -0
- package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +4 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +113 -28
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoRawRsa.swift +36 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +19 -15
- package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +5 -5
- package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +73 -251
- package/package.json +3 -2
- package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -39
|
@@ -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(
|
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
149
|
+
private final String pluginVersion = "8.51.17";
|
|
150
150
|
private static final String DELAY_CONDITION_PREFERENCES = "";
|
|
151
151
|
|
|
152
152
|
private SharedPreferences.Editor editor;
|
|
@@ -290,6 +290,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
290
290
|
|
|
291
291
|
// Play Store In-App Updates
|
|
292
292
|
private AppUpdateManager appUpdateManager;
|
|
293
|
+
private PluginCall pendingAppUpdateCall;
|
|
293
294
|
private AppUpdateInfo cachedAppUpdateInfo;
|
|
294
295
|
private static final int APP_UPDATE_REQUEST_CODE = 9001;
|
|
295
296
|
private InstallStateUpdatedListener installStateUpdatedListener;
|
|
@@ -2532,11 +2533,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2532
2533
|
call.reject("setUpdateUrl called without url");
|
|
2533
2534
|
return;
|
|
2534
2535
|
}
|
|
2535
|
-
this.updateUrl = url;
|
|
2536
2536
|
if (Boolean.TRUE.equals(this.persistModifyUrl)) {
|
|
2537
2537
|
this.editor.putString(UPDATE_URL_PREF_KEY, url);
|
|
2538
|
-
this.editor.
|
|
2538
|
+
if (!this.editor.commit()) {
|
|
2539
|
+
logger.error("Failed to persist updateUrl");
|
|
2540
|
+
call.reject("Failed to persist updateUrl");
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2539
2543
|
}
|
|
2544
|
+
this.updateUrl = url;
|
|
2540
2545
|
call.resolve();
|
|
2541
2546
|
}
|
|
2542
2547
|
|
|
@@ -2553,11 +2558,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2553
2558
|
call.reject("setStatsUrl called without url");
|
|
2554
2559
|
return;
|
|
2555
2560
|
}
|
|
2556
|
-
this.implementation.statsUrl = url;
|
|
2557
2561
|
if (Boolean.TRUE.equals(this.persistModifyUrl)) {
|
|
2558
2562
|
this.editor.putString(STATS_URL_PREF_KEY, url);
|
|
2559
|
-
this.editor.
|
|
2563
|
+
if (!this.editor.commit()) {
|
|
2564
|
+
logger.error("Failed to persist statsUrl");
|
|
2565
|
+
call.reject("Failed to persist statsUrl");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2560
2568
|
}
|
|
2569
|
+
this.implementation.statsUrl = url;
|
|
2561
2570
|
call.resolve();
|
|
2562
2571
|
}
|
|
2563
2572
|
|
|
@@ -2574,11 +2583,15 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
2574
2583
|
call.reject("setChannelUrl called without url");
|
|
2575
2584
|
return;
|
|
2576
2585
|
}
|
|
2577
|
-
this.implementation.channelUrl = url;
|
|
2578
2586
|
if (Boolean.TRUE.equals(this.persistModifyUrl)) {
|
|
2579
2587
|
this.editor.putString(CHANNEL_URL_PREF_KEY, url);
|
|
2580
|
-
this.editor.
|
|
2588
|
+
if (!this.editor.commit()) {
|
|
2589
|
+
logger.error("Failed to persist channelUrl");
|
|
2590
|
+
call.reject("Failed to persist channelUrl");
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2581
2593
|
}
|
|
2594
|
+
this.implementation.channelUrl = url;
|
|
2582
2595
|
call.resolve();
|
|
2583
2596
|
}
|
|
2584
2597
|
|
|
@@ -5667,8 +5680,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
5667
5680
|
return;
|
|
5668
5681
|
}
|
|
5669
5682
|
|
|
5670
|
-
|
|
5671
|
-
|
|
5683
|
+
if (pendingAppUpdateCall != null) {
|
|
5684
|
+
call.reject("An app update flow is already in progress");
|
|
5685
|
+
return;
|
|
5686
|
+
}
|
|
5687
|
+
|
|
5688
|
+
call.setKeepAlive(true);
|
|
5689
|
+
pendingAppUpdateCall = call;
|
|
5672
5690
|
|
|
5673
5691
|
AppUpdateManager manager = getAppUpdateManager();
|
|
5674
5692
|
manager.startUpdateFlowForResult(
|
|
@@ -5740,8 +5758,13 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
5740
5758
|
|
|
5741
5759
|
manager.registerListener(installStateUpdatedListener);
|
|
5742
5760
|
|
|
5743
|
-
|
|
5744
|
-
|
|
5761
|
+
if (pendingAppUpdateCall != null) {
|
|
5762
|
+
call.reject("An app update flow is already in progress");
|
|
5763
|
+
return;
|
|
5764
|
+
}
|
|
5765
|
+
|
|
5766
|
+
call.setKeepAlive(true);
|
|
5767
|
+
pendingAppUpdateCall = call;
|
|
5745
5768
|
|
|
5746
5769
|
manager.startUpdateFlowForResult(
|
|
5747
5770
|
cachedAppUpdateInfo,
|
|
@@ -5782,11 +5805,11 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
5782
5805
|
super.handleOnActivityResult(requestCode, resultCode, data);
|
|
5783
5806
|
|
|
5784
5807
|
if (requestCode == APP_UPDATE_REQUEST_CODE) {
|
|
5785
|
-
PluginCall savedCall =
|
|
5808
|
+
PluginCall savedCall = pendingAppUpdateCall;
|
|
5786
5809
|
if (savedCall == null) {
|
|
5787
|
-
// Try to get any saved call (for backward compatibility)
|
|
5788
5810
|
return;
|
|
5789
5811
|
}
|
|
5812
|
+
pendingAppUpdateCall = null;
|
|
5790
5813
|
|
|
5791
5814
|
JSObject result = new JSObject();
|
|
5792
5815
|
if (resultCode == Activity.RESULT_OK) {
|
|
@@ -5796,8 +5819,8 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
5796
5819
|
} else {
|
|
5797
5820
|
result.put("code", RESULT_FAILED);
|
|
5798
5821
|
}
|
|
5822
|
+
savedCall.setKeepAlive(false);
|
|
5799
5823
|
savedCall.resolve(result);
|
|
5800
|
-
bridge.releaseCall(savedCall);
|
|
5801
5824
|
}
|
|
5802
5825
|
}
|
|
5803
5826
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
99
|
+
private let pluginVersion: String = "8.51.17"
|
|
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
|
|
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
|
|
1143
|
-
|
|
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
|
-
|
|
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")
|
|
@@ -1159,6 +1194,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1159
1194
|
let resolve = {
|
|
1160
1195
|
let savedCall = self.bridge?.savedCall(withID: call.callbackId)
|
|
1161
1196
|
let targetCall = savedCall ?? call
|
|
1197
|
+
targetCall.keepAlive = false
|
|
1162
1198
|
|
|
1163
1199
|
if let data {
|
|
1164
1200
|
targetCall.resolve(data)
|
|
@@ -1184,6 +1220,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1184
1220
|
let reject = {
|
|
1185
1221
|
let savedCall = self.bridge?.savedCall(withID: call.callbackId)
|
|
1186
1222
|
let targetCall = savedCall ?? call
|
|
1223
|
+
targetCall.keepAlive = false
|
|
1187
1224
|
|
|
1188
1225
|
targetCall.reject(message, code, error, data)
|
|
1189
1226
|
|
|
@@ -1202,7 +1239,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1202
1239
|
}
|
|
1203
1240
|
|
|
1204
1241
|
private func saveCallForAsyncHandling(_ call: CAPPluginCall) {
|
|
1205
|
-
|
|
1242
|
+
call.keepAlive = true
|
|
1206
1243
|
}
|
|
1207
1244
|
|
|
1208
1245
|
private func notifyListenersOnMain(_ eventName: String, data: JSObject) {
|
|
@@ -1568,8 +1605,6 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1568
1605
|
}
|
|
1569
1606
|
|
|
1570
1607
|
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
1608
|
guard let url = URL(string: urlString) else {
|
|
1574
1609
|
throw makePreviewError("Invalid download URL")
|
|
1575
1610
|
}
|
|
@@ -1809,6 +1844,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1809
1844
|
}
|
|
1810
1845
|
}
|
|
1811
1846
|
|
|
1847
|
+
private func queueBundleForNextBackgroundInstall(_ next: BundleInfo) -> Bool {
|
|
1848
|
+
guard self.implementation.setNextBundle(next: next.getId()) else {
|
|
1849
|
+
self.logger.error("Failed to queue downloaded bundle as next: \(next.toString())")
|
|
1850
|
+
return false
|
|
1851
|
+
}
|
|
1852
|
+
return true
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1812
1855
|
private func applyDownloadedBundleForDirectUpdate(_ next: BundleInfo) -> Bool {
|
|
1813
1856
|
let previousState = self.implementation.captureResetState()
|
|
1814
1857
|
let previousBundleName = self.implementation.getCurrentBundle().getVersionName()
|
|
@@ -3993,6 +4036,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3993
4036
|
isAutoUpdateModeEnabled(autoUpdateMode) && autoUpdateMode != autoUpdateModeOnlyDownload
|
|
3994
4037
|
}
|
|
3995
4038
|
|
|
4039
|
+
/// Incomplete leftovers must be downloaded again. Android already skips
|
|
4040
|
+
/// `DOWNLOADING` here; iOS used to treat them as ready, then `setNextBundle`
|
|
4041
|
+
/// failed quietly and the update never applied.
|
|
4042
|
+
static func shouldRetryDownloadForExistingBundle(_ bundle: BundleInfo) -> Bool {
|
|
4043
|
+
bundle.isDeleted() || bundle.isDeleting() || bundle.isDownloading()
|
|
4044
|
+
}
|
|
4045
|
+
|
|
3996
4046
|
static func isDirectUpdateMode(_ directUpdateMode: String) -> Bool {
|
|
3997
4047
|
directUpdateMode == autoUpdateModeInstall || directUpdateMode == autoUpdateModeLaunch || directUpdateMode == autoUpdateModeAlways
|
|
3998
4048
|
}
|
|
@@ -4063,6 +4113,18 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
4063
4113
|
pendingNotifyAppReady = true
|
|
4064
4114
|
}
|
|
4065
4115
|
|
|
4116
|
+
func resetSemaphoreWaitTestingStateForTesting() {
|
|
4117
|
+
semaphoreWaitTestingLock.lock()
|
|
4118
|
+
didEnterSemaphoreWaitForTesting = false
|
|
4119
|
+
semaphoreWaitTestingLock.unlock()
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
var didEnterSemaphoreWaitForTestingState: Bool {
|
|
4123
|
+
semaphoreWaitTestingLock.lock()
|
|
4124
|
+
defer { semaphoreWaitTestingLock.unlock() }
|
|
4125
|
+
return didEnterSemaphoreWaitForTesting
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4066
4128
|
func clearPendingNotifyAppReadyForTesting() {
|
|
4067
4129
|
clearPendingNotifyAppReady()
|
|
4068
4130
|
}
|
|
@@ -4298,7 +4360,14 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
4298
4360
|
return
|
|
4299
4361
|
}
|
|
4300
4362
|
// File mutations wait here. getLatest already ran in parallel with cleanup.
|
|
4301
|
-
|
|
4363
|
+
do {
|
|
4364
|
+
try self.waitForCleanupIfNeeded()
|
|
4365
|
+
} catch {
|
|
4366
|
+
self.logger.error("Cleanup still running, skipping download")
|
|
4367
|
+
self.clearDownloadInProgressState()
|
|
4368
|
+
self.endBackGroundTask()
|
|
4369
|
+
return
|
|
4370
|
+
}
|
|
4302
4371
|
current = self.implementation.getCurrentBundle()
|
|
4303
4372
|
if res.version == "builtin" {
|
|
4304
4373
|
self.logger.info("Latest version is builtin")
|
|
@@ -4361,14 +4430,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
4361
4430
|
do {
|
|
4362
4431
|
self.logger.info("New bundle: \(latestVersionName) found. Current is: \(current.getVersionName()). \(messageUpdate)")
|
|
4363
4432
|
var nextImpl = self.implementation.getBundleInfoByVersionName(version: latestVersionName)
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4433
|
+
let needsDownload = nextImpl.map(Self.shouldRetryDownloadForExistingBundle) ?? true
|
|
4434
|
+
if needsDownload {
|
|
4435
|
+
if let existing = nextImpl {
|
|
4436
|
+
self.logger.info("Latest bundle already exists in incomplete state (\(existing.getStatus())) and will be deleted, download will overwrite it.")
|
|
4437
|
+
_ = self.implementation.setNextBundle(next: Optional<String>.none)
|
|
4438
|
+
let deleted = self.implementation.delete(id: existing.getId(), removeInfo: true)
|
|
4439
|
+
if deleted {
|
|
4440
|
+
self.logger.info("Incomplete bundle deleted: \(existing.toString())")
|
|
4370
4441
|
} else {
|
|
4371
|
-
self.logger.error("Failed to delete
|
|
4442
|
+
self.logger.error("Failed to delete incomplete bundle: \(existing.toString())")
|
|
4372
4443
|
}
|
|
4373
4444
|
}
|
|
4374
4445
|
self.consumeOnLaunchDirectUpdateAttempt(plannedDirectUpdate: plannedDirectUpdate)
|
|
@@ -4455,8 +4526,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
4455
4526
|
error: false,
|
|
4456
4527
|
plannedDirectUpdate: plannedDirectUpdate
|
|
4457
4528
|
)
|
|
4458
|
-
} else {
|
|
4459
|
-
_ = self.implementation.setNextBundle(next: next.getId())
|
|
4529
|
+
} else if self.queueBundleForNextBackgroundInstall(next) {
|
|
4460
4530
|
self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
|
|
4461
4531
|
self.endBackGroundTaskWithNotif(
|
|
4462
4532
|
msg: "Direct update reload failed, update will install next background",
|
|
@@ -4465,20 +4535,35 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
4465
4535
|
error: false,
|
|
4466
4536
|
plannedDirectUpdate: plannedDirectUpdate
|
|
4467
4537
|
)
|
|
4538
|
+
} else {
|
|
4539
|
+
self.endBackGroundTaskWithNotif(
|
|
4540
|
+
msg: "Direct update reload failed, and next bundle could not be queued",
|
|
4541
|
+
latestVersionName: latestVersionName,
|
|
4542
|
+
current: current,
|
|
4543
|
+
plannedDirectUpdate: plannedDirectUpdate
|
|
4544
|
+
)
|
|
4468
4545
|
}
|
|
4469
4546
|
} else if self.shouldAutoSetNextBundle() {
|
|
4470
4547
|
if plannedDirectUpdate && !directUpdateAllowed {
|
|
4471
4548
|
self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
|
|
4472
4549
|
}
|
|
4473
|
-
self.
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4550
|
+
if self.queueBundleForNextBackgroundInstall(next) {
|
|
4551
|
+
self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
|
|
4552
|
+
self.endBackGroundTaskWithNotif(
|
|
4553
|
+
msg: "update downloaded, will install next background",
|
|
4554
|
+
latestVersionName: latestVersionName,
|
|
4555
|
+
current: current,
|
|
4556
|
+
error: false,
|
|
4557
|
+
plannedDirectUpdate: plannedDirectUpdate
|
|
4558
|
+
)
|
|
4559
|
+
} else {
|
|
4560
|
+
self.endBackGroundTaskWithNotif(
|
|
4561
|
+
msg: "Update downloaded, but next bundle could not be queued",
|
|
4562
|
+
latestVersionName: latestVersionName,
|
|
4563
|
+
current: current,
|
|
4564
|
+
plannedDirectUpdate: plannedDirectUpdate
|
|
4565
|
+
)
|
|
4566
|
+
}
|
|
4482
4567
|
} else {
|
|
4483
4568
|
self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
|
|
4484
4569
|
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.
|
|
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
|
|
998
|
-
let
|
|
999
|
-
let
|
|
1000
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|
|
@@ -138,17 +138,17 @@ public class Logger {
|
|
|
138
138
|
self.tag = tag
|
|
139
139
|
if let config = config {
|
|
140
140
|
// The logger plugin's name is LoggerBridge, we want to look at the config
|
|
141
|
-
// named "Logger", so we can't use plugin.
|
|
142
|
-
if let configLevel =
|
|
141
|
+
// named "Logger", so we can't use the Capacitor plugin config helper.
|
|
142
|
+
if let configLevel = instanceConfigValue("level", from: config) as? String,
|
|
143
143
|
let logLevel = LogLevel[configLevel] {
|
|
144
144
|
level = logLevel
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
if let configLabels =
|
|
147
|
+
if let configLabels = instanceConfigValue("labels", from: config) as? [String: String] {
|
|
148
148
|
labels = configLabels
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
if let configSyslog =
|
|
151
|
+
if let configSyslog = instanceConfigValue("useSyslog", from: config) as? Bool {
|
|
152
152
|
useSyslog = configSyslog
|
|
153
153
|
}
|
|
154
154
|
}
|
|
@@ -160,7 +160,7 @@ public class Logger {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
private func
|
|
163
|
+
private func instanceConfigValue(_ configKey: String, from config: InstanceConfiguration) -> Any? {
|
|
164
164
|
if let config = config.pluginConfigurations as? JSObject {
|
|
165
165
|
return config[keyPath: KeyPath(stringLiteral: "Logger.\(configKey)")]
|
|
166
166
|
}
|
|
@@ -1,274 +1,96 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
15
|
-
|
|
7
|
+
import Foundation
|
|
8
|
+
import Security
|
|
9
|
+
import os.log
|
|
16
10
|
|
|
17
|
-
///
|
|
18
|
-
///
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
var
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
3
|
+
"version": "8.51.17",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "Live update for capacitor apps",
|
|
6
6
|
"main": "dist/plugin.cjs.js",
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"watch": "tsc --watch",
|
|
73
73
|
"prepublishOnly": "bun run build",
|
|
74
74
|
"check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
|
|
75
|
+
"check:cap9-deprecated": "bun scripts/check-cap9-deprecated.mjs",
|
|
75
76
|
"changelog:ai": "node scripts/generate-ai-changelog.mjs",
|
|
76
77
|
"example:install": "cd example-app && bun install --frozen-lockfile",
|
|
77
78
|
"example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build",
|
|
@@ -89,7 +90,7 @@
|
|
|
89
90
|
"@ionic/eslint-config": "^0.4.0",
|
|
90
91
|
"@ionic/prettier-config": "^4.0.0",
|
|
91
92
|
"@ionic/swiftlint-config": "^2.0.0",
|
|
92
|
-
"@types/node": "^
|
|
93
|
+
"@types/node": "^26.0.0",
|
|
93
94
|
"eslint": "^8.57.0",
|
|
94
95
|
"eslint-plugin-import": "^2.32.0",
|
|
95
96
|
"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
|
-
}
|