@capgo/capacitor-updater 8.50.2 → 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.
@@ -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)
@@ -1077,8 +1087,8 @@ import UIKit
1077
1087
 
1078
1088
  let totalFiles = manifest.count
1079
1089
 
1080
- // Keep this bounded because each manifest operation waits on a URLSession callback.
1081
- manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
1090
+ // Configure concurrent operation count similar to Android: min(64, max(32, totalFiles))
1091
+ manifestDownloadQueue.maxConcurrentOperationCount = min(64, max(32, totalFiles))
1082
1092
 
1083
1093
  // Thread-safe counters for concurrent operations
1084
1094
  let completedFiles = AtomicCounter()
@@ -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
- do {
1842
- try FileManager.default.removeItem(atPath: destPersist.path)
1843
- } catch {
1844
- logger.error("Bundle folder not removed")
1845
- logger.debug("Path: \(destPersist.path)")
1846
- // even if, we don;t care. Android doesn't care
1847
- if removeInfo {
1848
- self.removeBundleInfo(id: id)
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
- self.sendStats(action: "delete", versionName: deleted.getVersionName())
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.removeBundleInfo(id: id)
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
- logger.info("Bundle deleted successfully")
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
- if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
2173
- let res = self.delete(id: fallback.getId())
2174
- if res {
2175
- logger.info("Deleted previous bundle")
2176
- logger.debug("Bundle: \(fallback.toString())")
2177
- } else {
2178
- logger.error("Failed to delete previous bundle")
2179
- logger.debug("Bundle: \(fallback.toString())")
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
- public func saveBundleInfo(id: String, bundle: BundleInfo?) {
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
- } else {
2692
- let update = bundle!.setId(id: id)
2693
- logger.info("Storing info for bundle [\(id)] \(update.toString())")
2694
- do {
2695
- try UserDefaults.standard.setObj(update, forKey: "\(id)\(self.INFO_SUFFIX)")
2696
- } catch {
2697
- logger.error("Failed to save bundle info")
2698
- logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
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)
@@ -86,6 +86,10 @@ final class WebViewStatsReporter {
86
86
  }
87
87
  writeSession(true);
88
88
  setInterval(function(){writeSession(true);},15000);
89
+ function pageDuration(){
90
+ var started=Number(window.__capgoWebViewSessionStartedAt||Date.now());
91
+ return String(Math.max(0,Date.now()-started));
92
+ }
89
93
  function markClean(){writeSession(false);}
90
94
  window.addEventListener('pagehide',markClean,true);
91
95
  window.addEventListener('beforeunload',markClean,true);
@@ -120,6 +124,22 @@ final class WebViewStatsReporter {
120
124
  source:s(event&&event.blockedURI)
121
125
  });
122
126
  },true);
127
+ document.addEventListener('DOMContentLoaded',function(){
128
+ send({
129
+ type:'webview_dom_content_loaded',
130
+ message:'WebView DOM content loaded',
131
+ duration_ms:pageDuration(),
132
+ page_started_at:String(window.__capgoWebViewSessionStartedAt)
133
+ });
134
+ },true);
135
+ window.addEventListener('load',function(){
136
+ send({
137
+ type:'webview_page_loaded',
138
+ message:'WebView page loaded',
139
+ duration_ms:pageDuration(),
140
+ page_started_at:String(window.__capgoWebViewSessionStartedAt)
141
+ });
142
+ },true);
123
143
  document.addEventListener('deviceready',scheduleFlush,false);
124
144
  setTimeout(scheduleFlush,0);
125
145
  })();
@@ -164,6 +184,8 @@ final class WebViewStatsReporter {
164
184
  "href": call.getString("href"),
165
185
  "user_agent": call.getString("user_agent"),
166
186
  "session_id": call.getString("session_id"),
187
+ "duration_ms": call.getString("duration_ms"),
188
+ "page_started_at": call.getString("page_started_at"),
167
189
  "previous_session_id": call.getString("previous_session_id"),
168
190
  "previous_href": call.getString("previous_href"),
169
191
  "previous_started_at": call.getString("previous_started_at"),
@@ -187,6 +209,10 @@ final class WebViewStatsReporter {
187
209
  return "webview_render_process_gone"
188
210
  case "web_content_process_terminated":
189
211
  return "webview_content_process_terminated"
212
+ case "webview_dom_content_loaded":
213
+ return "webview_dom_content_loaded"
214
+ case "webview_page_loaded":
215
+ return "webview_page_loaded"
190
216
  case "javascript_error":
191
217
  return "webview_javascript_error"
192
218
  default:
@@ -206,6 +232,8 @@ final class WebViewStatsReporter {
206
232
  put(&metadata, key: "href", value: sanitizeUrl(payloadValue(values, "href")), maxLength: 512)
207
233
  put(&metadata, key: "user_agent", value: payloadValue(values, "user_agent"), maxLength: 256)
208
234
  put(&metadata, key: "session_id", value: payloadValue(values, "session_id"), maxLength: 128)
235
+ put(&metadata, key: "duration_ms", value: payloadValue(values, "duration_ms"), maxLength: 32)
236
+ put(&metadata, key: "page_started_at", value: payloadValue(values, "page_started_at"), maxLength: 64)
209
237
  put(&metadata, key: "previous_session_id", value: payloadValue(values, "previous_session_id"), maxLength: 128)
210
238
  put(&metadata, key: "previous_href", value: sanitizeUrl(payloadValue(values, "previous_href")), maxLength: 512)
211
239
  put(&metadata, key: "previous_started_at", value: payloadValue(values, "previous_started_at"), maxLength: 64)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.50.2",
3
+ "version": "8.51.1",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",
@@ -74,7 +74,11 @@
74
74
  "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
75
75
  "changelog:ai": "node scripts/generate-ai-changelog.mjs",
76
76
  "example:install": "cd example-app && bun install --frozen-lockfile",
77
- "example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build"
77
+ "example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build",
78
+ "native:contract:crypto": "bun run native:contract:crypto:ios && bun run native:contract:crypto:android",
79
+ "native:contract:crypto:ios": "./scripts/test-ios.sh -only-testing:CapacitorUpdaterPluginTests/RsaContractTests",
80
+ "native:contract:crypto:android": "cd android && ./gradlew testDebugUnitTest --tests ee.forgr.capacitor_updater.RsaContractTest && cd ..",
81
+ "generate:rsa-contract": "bun scripts/generate-rsa-contract-fixtures.mjs"
78
82
  },
79
83
  "devDependencies": {
80
84
  "@capacitor/android": "^8.0.0",