@capgo/capacitor-updater 8.51.0 → 8.51.1
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/README.md +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +143 -81
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +289 -59
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +1 -1
- package/dist/docs.json +8 -0
- package/dist/esm/definitions.d.ts +1 -1
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +5 -1
- package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +105 -49
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +186 -32
- package/package.json +1 -1
|
@@ -85,7 +85,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
85
85
|
CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
|
|
86
86
|
]
|
|
87
87
|
public var implementation = CapgoUpdater()
|
|
88
|
-
private let pluginVersion: String = "8.51.
|
|
88
|
+
private let pluginVersion: String = "8.51.1"
|
|
89
89
|
private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
90
90
|
static let updateUrlDefault = "https://plugin.capgo.app/updates"
|
|
91
91
|
static let statsUrlDefault = "https://plugin.capgo.app/stats"
|
|
@@ -175,6 +175,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
175
175
|
|
|
176
176
|
// Lock to ensure cleanup completes before downloads start
|
|
177
177
|
private let cleanupLock = NSLock()
|
|
178
|
+
private let cleanupGroup = DispatchGroup()
|
|
178
179
|
private var cleanupComplete = false
|
|
179
180
|
private var cleanupThread: Thread?
|
|
180
181
|
private var persistCustomId = false
|
|
@@ -194,6 +195,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
194
195
|
private var isLeavingPreviewForIncomingLink = false
|
|
195
196
|
private var previewTransitionClearWorkItem: DispatchWorkItem?
|
|
196
197
|
let semaphoreReady = DispatchSemaphore(value: 0)
|
|
198
|
+
// Best-effort flag: set before we expect notifyAppReady (load/reload).
|
|
199
|
+
// No lock — never held across waits; a rare race only mis-times one wait.
|
|
200
|
+
private var pendingNotifyAppReady = false
|
|
197
201
|
|
|
198
202
|
private var delayUpdateUtils: DelayUpdateUtils!
|
|
199
203
|
|
|
@@ -357,10 +361,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
357
361
|
}
|
|
358
362
|
self.leavePreviewSessionForLaunchURLIfNeeded()
|
|
359
363
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
self
|
|
364
|
+
// Downloads (including shake-menu / CapgoUpdater entry points) wait on this gate.
|
|
365
|
+
self.implementation.beforeDownload = { [weak self] in
|
|
366
|
+
self?.waitForCleanupIfNeeded()
|
|
363
367
|
}
|
|
368
|
+
// Always run async cleanup: delete obsolete bundles on native update (when enabled)
|
|
369
|
+
// and sweep orphan directories every launch. Must not block app startup.
|
|
370
|
+
if !resetWhenUpdate {
|
|
371
|
+
UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
|
|
372
|
+
UserDefaults.standard.synchronize()
|
|
373
|
+
}
|
|
374
|
+
let didResetCurrentBundle = resetWhenUpdate ? self.resetCurrentBundleForNativeBuildChangeIfNeeded() : false
|
|
375
|
+
self.cleanupObsoleteVersions(
|
|
376
|
+
resetWhenUpdate: resetWhenUpdate,
|
|
377
|
+
didResetCurrentBundle: didResetCurrentBundle
|
|
378
|
+
)
|
|
364
379
|
self.reportNativeVersionStatsIfChanged()
|
|
365
380
|
|
|
366
381
|
// Load the server
|
|
@@ -522,6 +537,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
522
537
|
}
|
|
523
538
|
|
|
524
539
|
private func semaphoreUp() {
|
|
540
|
+
pendingNotifyAppReady = true
|
|
525
541
|
DispatchQueue.global().async {
|
|
526
542
|
self.semaphoreWait(waitTime: 0)
|
|
527
543
|
}
|
|
@@ -633,12 +649,30 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
633
649
|
return true
|
|
634
650
|
}
|
|
635
651
|
|
|
636
|
-
private func cleanupObsoleteVersions(didResetCurrentBundle: Bool = false) {
|
|
652
|
+
private func cleanupObsoleteVersions(resetWhenUpdate: Bool = true, didResetCurrentBundle: Bool = false) {
|
|
653
|
+
// Enter before start so waiters never race past an unstarted cleanup thread.
|
|
654
|
+
self.cleanupComplete = false
|
|
655
|
+
self.cleanupGroup.enter()
|
|
637
656
|
cleanupThread = Thread {
|
|
657
|
+
let bgTaskLock = NSLock()
|
|
658
|
+
var cleanupBackgroundTask = UIBackgroundTaskIdentifier.invalid
|
|
659
|
+
let endCleanupBackgroundTask = {
|
|
660
|
+
bgTaskLock.lock()
|
|
661
|
+
defer { bgTaskLock.unlock() }
|
|
662
|
+
if cleanupBackgroundTask != .invalid {
|
|
663
|
+
UIApplication.shared.endBackgroundTask(cleanupBackgroundTask)
|
|
664
|
+
cleanupBackgroundTask = .invalid
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
cleanupBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "CapgoBundleCleanup") {
|
|
668
|
+
endCleanupBackgroundTask()
|
|
669
|
+
}
|
|
638
670
|
self.cleanupLock.lock()
|
|
639
671
|
defer {
|
|
640
672
|
self.cleanupComplete = true
|
|
641
673
|
self.cleanupLock.unlock()
|
|
674
|
+
self.cleanupGroup.leave()
|
|
675
|
+
endCleanupBackgroundTask()
|
|
642
676
|
self.logger.info("Cleanup complete")
|
|
643
677
|
}
|
|
644
678
|
|
|
@@ -669,54 +703,37 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
669
703
|
// 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
|
|
670
704
|
|
|
671
705
|
let previous = self.storedNativeBuildVersion()
|
|
672
|
-
|
|
706
|
+
let nativeVersionChanged = previous != "0" && self.currentBuildVersion != previous
|
|
707
|
+
if resetWhenUpdate && nativeVersionChanged {
|
|
673
708
|
if !didResetCurrentBundle {
|
|
674
709
|
self.logger.info("Native build version changed from \(previous) to \(self.currentBuildVersion). Resetting current bundle to builtin.")
|
|
675
710
|
self.implementation.reset(isInternal: true)
|
|
676
711
|
}
|
|
677
712
|
let res = self.implementation.list()
|
|
678
713
|
for version in res {
|
|
679
|
-
// Check if thread was cancelled
|
|
680
|
-
if Thread.current.isCancelled {
|
|
681
|
-
self.logger.warn("Cleanup was cancelled, stopping")
|
|
682
|
-
return
|
|
683
|
-
}
|
|
684
714
|
self.logger.info("Deleting obsolete bundle: \(version.getId())")
|
|
685
|
-
let
|
|
686
|
-
if !
|
|
715
|
+
let deleted = self.implementation.delete(id: version.getId())
|
|
716
|
+
if !deleted {
|
|
687
717
|
self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
|
|
688
718
|
}
|
|
719
|
+
Thread.sleep(forTimeInterval: 0.075)
|
|
689
720
|
}
|
|
690
|
-
|
|
691
|
-
let storedBundles = self.implementation.list(raw: true)
|
|
692
|
-
let allowedIds = Set(storedBundles.compactMap { info -> String? in
|
|
693
|
-
let id = info.getId()
|
|
694
|
-
return id.isEmpty ? nil : id
|
|
695
|
-
})
|
|
696
|
-
self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
|
|
697
|
-
self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
|
|
698
|
-
|
|
699
|
-
// Check again before the expensive delta cache cleanup
|
|
700
|
-
if Thread.current.isCancelled {
|
|
701
|
-
self.logger.warn("Cleanup was cancelled before delta cache cleanup")
|
|
702
|
-
return
|
|
703
|
-
}
|
|
704
|
-
self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
|
|
721
|
+
self.implementation.cleanupDeltaCache()
|
|
705
722
|
}
|
|
723
|
+
|
|
724
|
+
// Resume any DELETING leftovers from prior kills, one-by-one.
|
|
725
|
+
self.implementation.drainPendingDeletes()
|
|
726
|
+
|
|
727
|
+
// Always sweep orphan directories so incomplete prior cleanups (or failed deletes)
|
|
728
|
+
// cannot leave hundreds of MB behind across launches.
|
|
729
|
+
let allowedIds = self.implementation.allowedBundleIdsForCleanup()
|
|
730
|
+
self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds)
|
|
731
|
+
self.implementation.cleanupOrphanedTempFolders(threadToCheck: nil)
|
|
732
|
+
|
|
706
733
|
UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
|
|
707
734
|
UserDefaults.standard.synchronize()
|
|
708
735
|
}
|
|
709
736
|
cleanupThread?.start()
|
|
710
|
-
|
|
711
|
-
// Start a timeout watchdog thread to cancel cleanup if it takes too long
|
|
712
|
-
let timeout = Double(self.appReadyTimeout / 2) / 1000.0
|
|
713
|
-
Thread.detachNewThread {
|
|
714
|
-
Thread.sleep(forTimeInterval: timeout)
|
|
715
|
-
if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
|
|
716
|
-
self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
|
|
717
|
-
thread.cancel()
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
737
|
}
|
|
721
738
|
|
|
722
739
|
private func waitForCleanupIfNeeded() {
|
|
@@ -725,11 +742,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
725
742
|
}
|
|
726
743
|
|
|
727
744
|
logger.info("Waiting for cleanup to complete before starting download...")
|
|
728
|
-
|
|
729
|
-
// Wait for cleanup to complete - blocks until lock is released
|
|
730
|
-
cleanupLock.lock()
|
|
731
|
-
cleanupLock.unlock()
|
|
732
|
-
|
|
745
|
+
cleanupGroup.wait()
|
|
733
746
|
logger.info("Cleanup finished, proceeding with download")
|
|
734
747
|
}
|
|
735
748
|
|
|
@@ -1146,6 +1159,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1146
1159
|
}
|
|
1147
1160
|
|
|
1148
1161
|
private func downloadBundle(urlString: String, version: String, sessionKey: String, checksum rawChecksum: String, manifestEntries: [ManifestEntry]?) throws -> BundleInfo {
|
|
1162
|
+
// Manual/preview downloads must wait too - launch orphan sweep can delete their temps.
|
|
1163
|
+
self.waitForCleanupIfNeeded()
|
|
1149
1164
|
guard let url = URL(string: urlString) else {
|
|
1150
1165
|
throw makePreviewError("Invalid download URL")
|
|
1151
1166
|
}
|
|
@@ -1297,6 +1312,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1297
1312
|
|
|
1298
1313
|
let performReload: () -> Bool = {
|
|
1299
1314
|
guard self.applyCurrentBundleToBridge(bridge) else {
|
|
1315
|
+
self.clearPendingNotifyAppReady()
|
|
1300
1316
|
return false
|
|
1301
1317
|
}
|
|
1302
1318
|
self.checkAppReady()
|
|
@@ -2851,12 +2867,21 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
2851
2867
|
self.implementation.setError(bundle: current)
|
|
2852
2868
|
_ = self.performReset(toLastSuccessful: true, usePendingBundle: false, isInternal: true)
|
|
2853
2869
|
if self.autoDeleteFailed && !current.isBuiltin() {
|
|
2870
|
+
let failedId = current.getId()
|
|
2854
2871
|
logger.info("Deleting failing bundle: \(current.toString())")
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2872
|
+
// Mark before async work so kill/OOM still resumes via drainPendingDeletes.
|
|
2873
|
+
self.implementation.saveBundleInfo(
|
|
2874
|
+
id: failedId,
|
|
2875
|
+
bundle: current.setStatus(status: BundleStatus.DELETING.storedValue)
|
|
2876
|
+
)
|
|
2877
|
+
UserDefaults.standard.synchronize()
|
|
2878
|
+
DispatchQueue.global(qos: .utility).async {
|
|
2879
|
+
let res = self.implementation.delete(id: failedId, removeInfo: false)
|
|
2880
|
+
if res {
|
|
2881
|
+
self.logger.info("Failed bundle deleted: \(failedId)")
|
|
2882
|
+
} else {
|
|
2883
|
+
self.logger.error("Failed to delete failed bundle: \(failedId)")
|
|
2884
|
+
}
|
|
2860
2885
|
}
|
|
2861
2886
|
}
|
|
2862
2887
|
} else {
|
|
@@ -2881,7 +2906,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
2881
2906
|
func sendReadyToJs(current: BundleInfo, msg: String) {
|
|
2882
2907
|
logger.info("sendReadyToJs")
|
|
2883
2908
|
DispatchQueue.global().async {
|
|
2884
|
-
|
|
2909
|
+
// Only wait after load()/reload() armed the semaphore. Foreground resumes
|
|
2910
|
+
// with autoUpdate disabled call sendReadyToJs again without a fresh
|
|
2911
|
+
// notifyAppReady(), so waiting there always timed out after appReadyTimeout.
|
|
2912
|
+
if self.consumePendingNotifyAppReady() {
|
|
2913
|
+
self.semaphoreWait(waitTime: self.appReadyTimeout)
|
|
2914
|
+
}
|
|
2885
2915
|
self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
|
|
2886
2916
|
|
|
2887
2917
|
// Auto hide splashscreen if enabled
|
|
@@ -2893,6 +2923,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
2893
2923
|
}
|
|
2894
2924
|
}
|
|
2895
2925
|
|
|
2926
|
+
private func consumePendingNotifyAppReady() -> Bool {
|
|
2927
|
+
let shouldWait = pendingNotifyAppReady
|
|
2928
|
+
pendingNotifyAppReady = false
|
|
2929
|
+
return shouldWait
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
private func clearPendingNotifyAppReady() {
|
|
2933
|
+
pendingNotifyAppReady = false
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2896
2936
|
private func hideSplashscreen() {
|
|
2897
2937
|
if Thread.isMainThread {
|
|
2898
2938
|
self.performHideSplashscreen()
|
|
@@ -3506,6 +3546,22 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
3506
3546
|
self.currentBuildVersion = currentBuildVersion
|
|
3507
3547
|
}
|
|
3508
3548
|
|
|
3549
|
+
func setAppReadyTimeoutForTesting(_ timeout: Int) {
|
|
3550
|
+
self.appReadyTimeout = timeout
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
func armPendingNotifyAppReadyForTesting() {
|
|
3554
|
+
pendingNotifyAppReady = true
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3557
|
+
func clearPendingNotifyAppReadyForTesting() {
|
|
3558
|
+
clearPendingNotifyAppReady()
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
var isPendingNotifyAppReadyForTesting: Bool {
|
|
3562
|
+
pendingNotifyAppReady
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3509
3565
|
func shouldUseDirectUpdateForTesting() -> Bool {
|
|
3510
3566
|
self.shouldUseDirectUpdate()
|
|
3511
3567
|
}
|
|
@@ -22,8 +22,11 @@ import UIKit
|
|
|
22
22
|
private let FALLBACK_VERSION: String = "pastVersion"
|
|
23
23
|
private let NEXT_VERSION: String = "nextVersion"
|
|
24
24
|
private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
|
|
25
|
+
private let PENDING_DELETE_IDS: String = "pendingDeleteIds"
|
|
25
26
|
private var unzipPercent = 0
|
|
26
27
|
private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
|
|
28
|
+
private let deletePaceSeconds: TimeInterval = 0.075
|
|
29
|
+
private let deleteLock = NSLock()
|
|
27
30
|
|
|
28
31
|
// Add this line to declare cacheFolder
|
|
29
32
|
private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
|
|
@@ -34,6 +37,8 @@ import UIKit
|
|
|
34
37
|
public var pluginVersion: String = ""
|
|
35
38
|
public var timeout: Double = 20
|
|
36
39
|
public var statsUrl: String = ""
|
|
40
|
+
/// Optional gate run before any download touches disk (e.g. wait for launch cleanup).
|
|
41
|
+
public var beforeDownload: (() -> Void)?
|
|
37
42
|
public var channelUrl: String = ""
|
|
38
43
|
public var defaultChannel: String = ""
|
|
39
44
|
public var appId: String = ""
|
|
@@ -1052,7 +1057,12 @@ import UIKit
|
|
|
1052
1057
|
return json
|
|
1053
1058
|
}
|
|
1054
1059
|
|
|
1060
|
+
private func runBeforeDownload() {
|
|
1061
|
+
beforeDownload?()
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1055
1064
|
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
1065
|
+
self.runBeforeDownload()
|
|
1056
1066
|
let id = self.randomString(length: 10)
|
|
1057
1067
|
logger.info("downloadManifest start \(id)")
|
|
1058
1068
|
let destFolder = self.getBundleDirectory(id: id)
|
|
@@ -1535,6 +1545,7 @@ import UIKit
|
|
|
1535
1545
|
}
|
|
1536
1546
|
|
|
1537
1547
|
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
1548
|
+
self.runBeforeDownload()
|
|
1538
1549
|
let id: String = self.randomString(length: 10)
|
|
1539
1550
|
// Each download uses its own temp files keyed by bundle ID to prevent collisions
|
|
1540
1551
|
if version != getLocalUpdateVersion(for: id) {
|
|
@@ -1811,6 +1822,9 @@ import UIKit
|
|
|
1811
1822
|
}
|
|
1812
1823
|
|
|
1813
1824
|
public func delete(id: String, removeInfo: Bool) -> Bool {
|
|
1825
|
+
self.deleteLock.lock()
|
|
1826
|
+
defer { self.deleteLock.unlock() }
|
|
1827
|
+
|
|
1814
1828
|
let deleted: BundleInfo = self.getBundleInfo(id: id)
|
|
1815
1829
|
if deleted.isBuiltin() || self.getCurrentBundleId() == id {
|
|
1816
1830
|
logger.info("Cannot delete current or builtin bundle")
|
|
@@ -1821,6 +1835,7 @@ import UIKit
|
|
|
1821
1835
|
if let previewFallback = self.getPreviewFallbackBundle(),
|
|
1822
1836
|
!previewFallback.isDeleted(),
|
|
1823
1837
|
!previewFallback.isErrorStatus(),
|
|
1838
|
+
!previewFallback.isDeleting(),
|
|
1824
1839
|
previewFallback.getId() == id {
|
|
1825
1840
|
logger.info("Cannot delete the preview fallback bundle")
|
|
1826
1841
|
logger.debug("Bundle ID: \(id)")
|
|
@@ -1831,6 +1846,7 @@ import UIKit
|
|
|
1831
1846
|
if let next = self.getNextBundle(),
|
|
1832
1847
|
!next.isDeleted() &&
|
|
1833
1848
|
!next.isErrorStatus() &&
|
|
1849
|
+
!next.isDeleting() &&
|
|
1834
1850
|
next.getId() == id {
|
|
1835
1851
|
logger.info("Cannot delete the next bundle")
|
|
1836
1852
|
logger.debug("Bundle ID: \(id)")
|
|
@@ -1838,24 +1854,55 @@ import UIKit
|
|
|
1838
1854
|
}
|
|
1839
1855
|
|
|
1840
1856
|
let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
logger.error("
|
|
1845
|
-
logger.debug("
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1857
|
+
let hadRegistry = self.hasStoredBundleInfo(id: id)
|
|
1858
|
+
let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
|
|
1859
|
+
if !hadRegistry && !hadFolder {
|
|
1860
|
+
logger.error("Cannot delete unknown bundle")
|
|
1861
|
+
logger.debug("Bundle ID: \(id)")
|
|
1862
|
+
return false
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// Persist DELETING before touching disk so kill/OOM can resume on next launch.
|
|
1866
|
+
if !deleted.isDeleting() {
|
|
1867
|
+
if !self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETING.storedValue)) {
|
|
1868
|
+
logger.error("Failed to persist DELETING marker, aborting disk delete")
|
|
1869
|
+
logger.debug("Bundle ID: \(id)")
|
|
1870
|
+
return false
|
|
1849
1871
|
}
|
|
1850
|
-
|
|
1872
|
+
UserDefaults.standard.synchronize()
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
if FileManager.default.fileExists(atPath: destPersist.path) {
|
|
1876
|
+
do {
|
|
1877
|
+
try FileManager.default.removeItem(atPath: destPersist.path)
|
|
1878
|
+
} catch {
|
|
1879
|
+
logger.error("Bundle folder not removed, will retry later")
|
|
1880
|
+
logger.debug("Path: \(destPersist.path), Error: \(error.localizedDescription)")
|
|
1881
|
+
return false
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// Only drop registry after the folder is confirmed gone.
|
|
1886
|
+
if FileManager.default.fileExists(atPath: destPersist.path) {
|
|
1887
|
+
logger.error("Bundle folder still present after delete, will retry later")
|
|
1888
|
+
logger.debug("Bundle ID: \(id)")
|
|
1851
1889
|
return false
|
|
1852
1890
|
}
|
|
1891
|
+
|
|
1892
|
+
let finalized: Bool
|
|
1853
1893
|
if removeInfo {
|
|
1854
|
-
self.
|
|
1894
|
+
finalized = self.saveBundleInfo(id: id, bundle: nil)
|
|
1855
1895
|
} else {
|
|
1856
|
-
self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
|
|
1896
|
+
finalized = self.saveBundleInfo(id: id, bundle: deleted.setStatus(status: BundleStatus.DELETED.storedValue))
|
|
1857
1897
|
}
|
|
1858
|
-
|
|
1898
|
+
guard finalized else {
|
|
1899
|
+
logger.error("Failed to finalize delete registry update, will retry later")
|
|
1900
|
+
logger.debug("Bundle ID: \(id)")
|
|
1901
|
+
return false
|
|
1902
|
+
}
|
|
1903
|
+
UserDefaults.standard.synchronize()
|
|
1904
|
+
self.dequeuePendingDelete(id: id)
|
|
1905
|
+
logger.info("Bundle deleted and confirmed gone")
|
|
1859
1906
|
logger.debug("Version: \(deleted.getVersionName())")
|
|
1860
1907
|
self.sendStats(action: "delete", versionName: deleted.getVersionName())
|
|
1861
1908
|
return true
|
|
@@ -1865,6 +1912,47 @@ import UIKit
|
|
|
1865
1912
|
return self.delete(id: id, removeInfo: true)
|
|
1866
1913
|
}
|
|
1867
1914
|
|
|
1915
|
+
/// Resume incomplete deletes one-by-one. Safe across app kill / OOM because
|
|
1916
|
+
/// delete() marks DELETING before disk work and only clears registry after confirm.
|
|
1917
|
+
public func drainPendingDeletes() {
|
|
1918
|
+
var pendingIds = Set(self.list(raw: true).filter { $0.isDeleting() }.map { $0.getId() }.filter { !$0.isEmpty })
|
|
1919
|
+
pendingIds.formUnion(self.getPendingDeleteIds())
|
|
1920
|
+
for id in pendingIds {
|
|
1921
|
+
logger.info("Resuming pending delete for bundle: \(id)")
|
|
1922
|
+
if self.delete(id: id, removeInfo: true) {
|
|
1923
|
+
self.dequeuePendingDelete(id: id)
|
|
1924
|
+
}
|
|
1925
|
+
Thread.sleep(forTimeInterval: self.deletePaceSeconds)
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
private func getPendingDeleteIds() -> Set<String> {
|
|
1930
|
+
guard let raw = UserDefaults.standard.string(forKey: self.PENDING_DELETE_IDS), !raw.isEmpty else {
|
|
1931
|
+
return []
|
|
1932
|
+
}
|
|
1933
|
+
return Set(raw.split(separator: ",").map { String($0) }.filter { !$0.isEmpty })
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
private func enqueuePendingDelete(id: String) {
|
|
1937
|
+
guard !id.isEmpty else { return }
|
|
1938
|
+
var ids = self.getPendingDeleteIds()
|
|
1939
|
+
guard ids.insert(id).inserted else { return }
|
|
1940
|
+
UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
|
|
1941
|
+
UserDefaults.standard.synchronize()
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
private func dequeuePendingDelete(id: String) {
|
|
1945
|
+
guard !id.isEmpty else { return }
|
|
1946
|
+
var ids = self.getPendingDeleteIds()
|
|
1947
|
+
guard ids.remove(id) != nil else { return }
|
|
1948
|
+
if ids.isEmpty {
|
|
1949
|
+
UserDefaults.standard.removeObject(forKey: self.PENDING_DELETE_IDS)
|
|
1950
|
+
} else {
|
|
1951
|
+
UserDefaults.standard.set(ids.sorted().joined(separator: ","), forKey: self.PENDING_DELETE_IDS)
|
|
1952
|
+
}
|
|
1953
|
+
UserDefaults.standard.synchronize()
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1868
1956
|
public func cleanupDeltaCache() {
|
|
1869
1957
|
cleanupDeltaCache(threadToCheck: nil)
|
|
1870
1958
|
}
|
|
@@ -1924,6 +2012,11 @@ import UIKit
|
|
|
1924
2012
|
|
|
1925
2013
|
do {
|
|
1926
2014
|
try fileManager.removeItem(at: url)
|
|
2015
|
+
if fileManager.fileExists(atPath: url.path) {
|
|
2016
|
+
logger.error("Orphan bundle directory still present after delete")
|
|
2017
|
+
logger.debug("Bundle ID: \(id)")
|
|
2018
|
+
continue
|
|
2019
|
+
}
|
|
1927
2020
|
self.removeBundleInfo(id: id)
|
|
1928
2021
|
logger.info("Deleted orphan bundle directory")
|
|
1929
2022
|
logger.debug("Bundle ID: \(id)")
|
|
@@ -1938,6 +2031,40 @@ import UIKit
|
|
|
1938
2031
|
}
|
|
1939
2032
|
}
|
|
1940
2033
|
|
|
2034
|
+
public func allowedBundleIdsForCleanup() -> Set<String> {
|
|
2035
|
+
var allowedIds = Set(self.list(raw: true).compactMap { info -> String? in
|
|
2036
|
+
let id = info.getId()
|
|
2037
|
+
// DELETED tombstones must not protect leftover folders.
|
|
2038
|
+
// DELETING stays protected so drainPendingDeletes owns the removal.
|
|
2039
|
+
if id.isEmpty || info.isDeleted() {
|
|
2040
|
+
return nil
|
|
2041
|
+
}
|
|
2042
|
+
return id
|
|
2043
|
+
})
|
|
2044
|
+
let currentId = self.getCurrentBundleId()
|
|
2045
|
+
if !currentId.isEmpty {
|
|
2046
|
+
allowedIds.insert(currentId)
|
|
2047
|
+
}
|
|
2048
|
+
let fallback = self.getFallbackBundle()
|
|
2049
|
+
let fallbackId = fallback.getId()
|
|
2050
|
+
if !fallbackId.isEmpty && !fallback.isDeleting() {
|
|
2051
|
+
allowedIds.insert(fallbackId)
|
|
2052
|
+
}
|
|
2053
|
+
if let next = self.getNextBundle() {
|
|
2054
|
+
let nextId = next.getId()
|
|
2055
|
+
if !nextId.isEmpty && !next.isDeleting() {
|
|
2056
|
+
allowedIds.insert(nextId)
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
if let previewFallback = self.getPreviewFallbackBundle() {
|
|
2060
|
+
let previewId = previewFallback.getId()
|
|
2061
|
+
if !previewId.isEmpty && !previewFallback.isDeleting() {
|
|
2062
|
+
allowedIds.insert(previewId)
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
return allowedIds
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1941
2068
|
public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
|
|
1942
2069
|
let fileManager = FileManager.default
|
|
1943
2070
|
|
|
@@ -2079,7 +2206,8 @@ import UIKit
|
|
|
2079
2206
|
destPersist.isDirectory &&
|
|
2080
2207
|
!indexPersist.isDirectory &&
|
|
2081
2208
|
indexPersist.exist &&
|
|
2082
|
-
!bundleIndo.isDeleted()
|
|
2209
|
+
!bundleIndo.isDeleted() &&
|
|
2210
|
+
!bundleIndo.isDeleting() {
|
|
2083
2211
|
return true
|
|
2084
2212
|
}
|
|
2085
2213
|
return false
|
|
@@ -2169,17 +2297,39 @@ import UIKit
|
|
|
2169
2297
|
let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
|
|
2170
2298
|
logger.info("Fallback bundle is: \(fallback.toString())")
|
|
2171
2299
|
logger.info("Version successfully loaded: \(bundle.toString())")
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2300
|
+
let previousFallbackId = fallback.getId()
|
|
2301
|
+
let nextBundle = self.getNextBundle()
|
|
2302
|
+
let previousIsNext = nextBundle?.getId() == previousFallbackId &&
|
|
2303
|
+
!(nextBundle?.isDeleted() ?? true) &&
|
|
2304
|
+
!(nextBundle?.isErrorStatus() ?? true) &&
|
|
2305
|
+
!(nextBundle?.isDeleting() ?? true)
|
|
2306
|
+
let shouldDeletePrevious = autoDeletePrevious &&
|
|
2307
|
+
!fallback.isBuiltin() &&
|
|
2308
|
+
previousFallbackId != bundle.getId() &&
|
|
2309
|
+
!fallbackIsPreviewFallback &&
|
|
2310
|
+
!previousIsNext
|
|
2311
|
+
if shouldDeletePrevious {
|
|
2312
|
+
// Mark durable intent before fallback switch so a kill mid-flight still retries.
|
|
2313
|
+
if !self.saveBundleInfo(id: previousFallbackId, bundle: fallback.setStatus(status: BundleStatus.DELETING.storedValue)) {
|
|
2314
|
+
self.logger.error("Failed to persist DELETING for previous bundle; queueing durable retry")
|
|
2315
|
+
self.logger.debug("Bundle ID: \(previousFallbackId)")
|
|
2316
|
+
self.enqueuePendingDelete(id: previousFallbackId)
|
|
2180
2317
|
}
|
|
2318
|
+
UserDefaults.standard.synchronize()
|
|
2181
2319
|
}
|
|
2182
2320
|
self.setFallbackBundle(fallback: bundle)
|
|
2321
|
+
if shouldDeletePrevious {
|
|
2322
|
+
DispatchQueue.global(qos: .utility).async {
|
|
2323
|
+
let res = self.delete(id: previousFallbackId)
|
|
2324
|
+
if res {
|
|
2325
|
+
self.logger.info("Deleted previous bundle")
|
|
2326
|
+
self.logger.debug("Bundle ID: \(previousFallbackId)")
|
|
2327
|
+
} else {
|
|
2328
|
+
self.logger.info("Previous bundle delete incomplete, will retry")
|
|
2329
|
+
self.logger.debug("Bundle ID: \(previousFallbackId)")
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2183
2333
|
}
|
|
2184
2334
|
|
|
2185
2335
|
public func setError(bundle: BundleInfo) {
|
|
@@ -2680,26 +2830,30 @@ import UIKit
|
|
|
2680
2830
|
self.saveBundleInfo(id: id, bundle: nil)
|
|
2681
2831
|
}
|
|
2682
2832
|
|
|
2683
|
-
|
|
2833
|
+
@discardableResult
|
|
2834
|
+
public func saveBundleInfo(id: String, bundle: BundleInfo?) -> Bool {
|
|
2684
2835
|
if bundle != nil && (bundle!.isBuiltin() || bundle!.isUnknown()) {
|
|
2685
2836
|
logger.info("Not saving info for bundle [\(id)] \(bundle?.toString() ?? "")")
|
|
2686
|
-
return
|
|
2837
|
+
return false
|
|
2687
2838
|
}
|
|
2688
2839
|
if bundle == nil {
|
|
2689
2840
|
logger.info("Removing info for bundle [\(id)]")
|
|
2690
2841
|
UserDefaults.standard.removeObject(forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2842
|
+
return true
|
|
2843
|
+
}
|
|
2844
|
+
let update = bundle!.setId(id: id)
|
|
2845
|
+
logger.info("Storing info for bundle [\(id)] \(update.toString())")
|
|
2846
|
+
do {
|
|
2847
|
+
try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
|
|
2848
|
+
return true
|
|
2849
|
+
} catch {
|
|
2850
|
+
logger.error("Failed to save bundle info")
|
|
2851
|
+
logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
|
|
2852
|
+
return false
|
|
2700
2853
|
}
|
|
2701
2854
|
}
|
|
2702
2855
|
|
|
2856
|
+
|
|
2703
2857
|
private func setBundleStatus(id: String, status: BundleStatus) {
|
|
2704
2858
|
logger.info("Setting status for bundle [\(id)] to \(status)")
|
|
2705
2859
|
let info = self.getBundleInfo(id: id)
|