@capgo/capacitor-updater 7.43.3 → 7.50.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.
Files changed (36) hide show
  1. package/Package.swift +5 -2
  2. package/README.md +643 -115
  3. package/android/build.gradle +3 -3
  4. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3121 -353
  6. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +924 -290
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +49 -13
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  9. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +75 -32
  10. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  12. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +401 -27
  13. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  14. package/dist/docs.json +1590 -196
  15. package/dist/esm/definitions.d.ts +755 -66
  16. package/dist/esm/definitions.js.map +1 -1
  17. package/dist/esm/web.d.ts +11 -2
  18. package/dist/esm/web.js +59 -5
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +59 -5
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +59 -5
  23. package/dist/plugin.js.map +1 -1
  24. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  25. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  27. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  28. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  29. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2732 -417
  30. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1191 -363
  31. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  32. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +37 -16
  33. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +80 -1
  34. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +438 -39
  35. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  36. package/package.json +24 -9
@@ -7,6 +7,90 @@
7
7
  import UIKit
8
8
  import Capacitor
9
9
 
10
+ private var lastShakeMenuShownAt: TimeInterval = 0
11
+ private let shakeMenuCooldownSeconds: TimeInterval = 1.2
12
+ private let threeFingerPinchScaleDelta: CGFloat = 0.12
13
+
14
+ final class ThreeFingerPinchGestureRecognizer: UIGestureRecognizer {
15
+ private var initialSpan: CGFloat = 0
16
+ private(set) var scale: CGFloat = 1
17
+ var onTrackingStarted: (() -> Void)?
18
+
19
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
20
+ super.touchesBegan(touches, with: event)
21
+ guard let view = self.view, let activeTouches = activeTouches(with: event), activeTouches.count <= 3 else {
22
+ self.state = .failed
23
+ return
24
+ }
25
+
26
+ if activeTouches.count == 3 {
27
+ self.initialSpan = span(for: activeTouches, in: view)
28
+ self.scale = 1
29
+ if self.initialSpan > 0 {
30
+ self.onTrackingStarted?()
31
+ } else {
32
+ self.state = .failed
33
+ }
34
+ }
35
+ }
36
+
37
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
38
+ super.touchesMoved(touches, with: event)
39
+ guard let view = self.view,
40
+ let activeTouches = activeTouches(with: event),
41
+ activeTouches.count == 3,
42
+ self.initialSpan > 0 else {
43
+ self.state = .failed
44
+ return
45
+ }
46
+
47
+ self.scale = span(for: activeTouches, in: view) / self.initialSpan
48
+ if abs(self.scale - 1) >= threeFingerPinchScaleDelta {
49
+ self.state = .recognized
50
+ }
51
+ }
52
+
53
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
54
+ super.touchesEnded(touches, with: event)
55
+ if self.state == .possible {
56
+ self.state = .failed
57
+ }
58
+ }
59
+
60
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
61
+ super.touchesCancelled(touches, with: event)
62
+ self.state = .cancelled
63
+ }
64
+
65
+ override func reset() {
66
+ super.reset()
67
+ self.initialSpan = 0
68
+ self.scale = 1
69
+ }
70
+
71
+ private func activeTouches(with event: UIEvent) -> [UITouch]? {
72
+ event.touches(for: self)?.filter { touch in
73
+ touch.phase != .ended && touch.phase != .cancelled
74
+ }
75
+ }
76
+
77
+ private func span(for touches: [UITouch], in view: UIView) -> CGFloat {
78
+ guard !touches.isEmpty else {
79
+ return 0
80
+ }
81
+
82
+ let points = touches.map { $0.location(in: view) }
83
+ let center = points.reduce(CGPoint.zero) { result, point in
84
+ CGPoint(x: result.x + point.x, y: result.y + point.y)
85
+ }
86
+ let centerPoint = CGPoint(x: center.x / CGFloat(points.count), y: center.y / CGFloat(points.count))
87
+ let totalDistance = points.reduce(CGFloat(0)) { result, point in
88
+ result + hypot(point.x - centerPoint.x, point.y - centerPoint.y)
89
+ }
90
+ return totalDistance / CGFloat(points.count)
91
+ }
92
+ }
93
+
10
94
  extension UIApplication {
11
95
  // swiftlint:disable:next line_length
12
96
  public class func topViewController(_ base: UIViewController? = UIApplication.shared.windows.first?.rootViewController) -> UIViewController? {
@@ -23,37 +107,208 @@ extension UIApplication {
23
107
  }
24
108
  }
25
109
 
110
+ extension CapacitorUpdaterPlugin: UIGestureRecognizerDelegate {
111
+ func syncShakeMenuGestureRecognizer() {
112
+ DispatchQueue.main.async { [weak self] in
113
+ guard let self else { return }
114
+
115
+ let shouldInstall = self.shakeMenuGesture == Self.shakeMenuGestureThreeFingerPinch &&
116
+ (self.shakeMenuEnabled || self.shakeChannelSelectorEnabled)
117
+
118
+ guard shouldInstall, let targetView = self.bridge?.viewController?.view ?? self.bridge?.webView else {
119
+ self.removeShakeMenuGestureRecognizer()
120
+ return
121
+ }
122
+
123
+ if self.shakeMenuPinchGestureRecognizer?.view === targetView {
124
+ return
125
+ }
126
+
127
+ self.removeShakeMenuGestureRecognizer()
128
+
129
+ let recognizer = ThreeFingerPinchGestureRecognizer(target: self, action: #selector(self.handleShakeMenuPinch(_:)))
130
+ recognizer.cancelsTouchesInView = false
131
+ recognizer.delaysTouchesBegan = false
132
+ recognizer.delaysTouchesEnded = false
133
+ recognizer.delegate = self
134
+ recognizer.onTrackingStarted = { [weak self] in
135
+ self?.logger.info("Three finger pinch tracking started")
136
+ }
137
+ targetView.addGestureRecognizer(recognizer)
138
+ self.shakeMenuPinchGestureRecognizer = recognizer
139
+ self.logger.info("Three finger pinch menu gesture initialized")
140
+ }
141
+ }
142
+
143
+ func removeShakeMenuGestureRecognizer() {
144
+ if let recognizer = self.shakeMenuPinchGestureRecognizer {
145
+ recognizer.view?.removeGestureRecognizer(recognizer)
146
+ self.shakeMenuPinchGestureRecognizer = nil
147
+ self.shakeMenuPinchGestureTriggered = false
148
+ self.logger.info("Three finger pinch menu gesture stopped")
149
+ }
150
+ }
151
+
152
+ @objc func handleShakeMenuPinch(_ recognizer: ThreeFingerPinchGestureRecognizer) {
153
+ guard recognizer.state == .recognized, !self.shakeMenuPinchGestureTriggered else {
154
+ return
155
+ }
156
+ guard abs(recognizer.scale - 1) >= threeFingerPinchScaleDelta else {
157
+ return
158
+ }
159
+ guard self.shakeMenuGesture == Self.shakeMenuGestureThreeFingerPinch, let bridge = self.bridge else {
160
+ return
161
+ }
162
+ guard let window = recognizer.view?.window ?? bridge.viewController?.view.window else {
163
+ return
164
+ }
165
+
166
+ self.shakeMenuPinchGestureTriggered = true
167
+ self.logger.info("Three finger pinch detected")
168
+ _ = window.showCapacitorUpdaterMenu(plugin: self, bridge: bridge, gestureName: "Three finger pinch")
169
+ }
170
+
171
+ public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
172
+ if gestureRecognizer === self.shakeMenuPinchGestureRecognizer {
173
+ self.shakeMenuPinchGestureTriggered = false
174
+ }
175
+ return true
176
+ }
177
+
178
+ public func gestureRecognizer(
179
+ _: UIGestureRecognizer,
180
+ shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer
181
+ ) -> Bool {
182
+ true
183
+ }
184
+ }
185
+
26
186
  extension UIWindow {
27
187
  override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
28
188
  if motion == .motionShake {
29
- // Find the CapacitorUpdaterPlugin instance
30
- guard let bridge = (rootViewController as? CAPBridgeProtocol),
31
- let plugin = bridge.plugin(withName: "CapacitorUpdaterPlugin") as? CapacitorUpdaterPlugin else {
189
+ guard let bridgeViewController = rootViewController as? CAPBridgeViewController,
190
+ let bridge = bridgeViewController.bridge,
191
+ let plugin = bridge.plugin(withName: "CapacitorUpdater") as? CapacitorUpdaterPlugin else {
32
192
  return
33
193
  }
34
-
35
- // Check if shake menu is enabled
36
- if !plugin.shakeMenuEnabled {
194
+ guard plugin.shakeMenuGesture == CapacitorUpdaterPlugin.shakeMenuGestureShake else {
37
195
  return
38
196
  }
39
197
 
40
- // Check if channel selector mode is enabled
41
- if plugin.shakeChannelSelectorEnabled {
42
- showChannelSelector(plugin: plugin, bridge: bridge)
43
- } else {
44
- showDefaultMenu(plugin: plugin, bridge: bridge)
198
+ _ = showCapacitorUpdaterMenu(plugin: plugin, bridge: bridge, gestureName: "Shake")
199
+ }
200
+ }
201
+
202
+ @discardableResult
203
+ fileprivate func showCapacitorUpdaterMenu(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol, gestureName: String) -> Bool {
204
+ let canShowPreviewMenu = plugin.shakeMenuEnabled && plugin.hasActivePreviewSession()
205
+ let canShowChannelSelector = plugin.shakeChannelSelectorEnabled
206
+
207
+ if !canShowPreviewMenu && !canShowChannelSelector {
208
+ if plugin.shakeMenuEnabled {
209
+ plugin.logger.info("\(gestureName) preview menu ignored because no preview session is active")
45
210
  }
211
+ return false
212
+ }
213
+
214
+ let now = Date().timeIntervalSince1970
215
+ guard now - lastShakeMenuShownAt >= shakeMenuCooldownSeconds else {
216
+ plugin.logger.info("\(gestureName) menu ignored because cooldown is active")
217
+ return false
46
218
  }
219
+
220
+ let didShow = canShowPreviewMenu
221
+ ? showDefaultMenu(plugin: plugin, bridge: bridge)
222
+ : showChannelSelector(plugin: plugin, bridge: bridge)
223
+
224
+ if didShow {
225
+ lastShakeMenuShownAt = now
226
+ }
227
+ return didShow
47
228
  }
48
229
 
49
- private func showDefaultMenu(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) {
230
+ @discardableResult
231
+ private func showDefaultMenu(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) -> Bool {
50
232
  // Prevent multiple alerts from showing
51
- if let topVC = UIApplication.topViewController(),
52
- topVC.isKind(of: UIAlertController.self) {
233
+ guard let topVC = UIApplication.topViewController() else {
234
+ return false
235
+ }
236
+ if topVC.isKind(of: UIAlertController.self) {
53
237
  plugin.logger.info("UIAlertController is already presented")
54
- return
238
+ return false
239
+ }
240
+
241
+ guard plugin.hasActivePreviewSession() else {
242
+ plugin.logger.info("Shake preview menu ignored because no preview session is active")
243
+ return false
55
244
  }
56
245
 
246
+ let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "App"
247
+ let title = "Preview \(appName) Menu"
248
+ let message = "Reload, switch, or leave the current preview."
249
+ let okButtonTitle = "Leave test app"
250
+ let reloadButtonTitle = "Reload preview"
251
+ let cancelButtonTitle = "Close menu"
252
+
253
+ let alertShake = UIAlertController(title: title, message: message, preferredStyle: .alert)
254
+
255
+ alertShake.addAction(UIAlertAction(title: reloadButtonTitle, style: .default) { _ in
256
+ DispatchQueue.global(qos: .userInitiated).async {
257
+ if !plugin.reloadPreviewSessionFromShakeMenu() {
258
+ DispatchQueue.main.async {
259
+ self.showError(message: "Could not reload the test app.", plugin: plugin)
260
+ }
261
+ }
262
+ }
263
+ })
264
+
265
+ if !plugin.previewMenuPreviews().isEmpty {
266
+ alertShake.addAction(UIAlertAction(title: "Switch preview", style: .default) { _ in
267
+ let showSelector = {
268
+ self.showPreviewSelector(plugin: plugin)
269
+ }
270
+
271
+ if let presenter = alertShake.presentingViewController {
272
+ presenter.dismiss(animated: true, completion: showSelector)
273
+ } else {
274
+ DispatchQueue.main.async(execute: showSelector)
275
+ }
276
+ })
277
+ }
278
+
279
+ if plugin.shakeChannelSelectorEnabled {
280
+ alertShake.addAction(UIAlertAction(title: "Switch channel", style: .default) { _ in
281
+ let showSelector = {
282
+ _ = self.showChannelSelector(plugin: plugin, bridge: bridge)
283
+ }
284
+
285
+ if let presenter = alertShake.presentingViewController {
286
+ presenter.dismiss(animated: true, completion: showSelector)
287
+ } else {
288
+ DispatchQueue.main.async(execute: showSelector)
289
+ }
290
+ })
291
+ }
292
+
293
+ alertShake.addAction(UIAlertAction(title: okButtonTitle, style: .default) { _ in
294
+ DispatchQueue.global(qos: .userInitiated).async {
295
+ if !plugin.leavePreviewSessionFromShakeMenu() {
296
+ DispatchQueue.main.async {
297
+ self.showError(message: "Could not leave the test app.", plugin: plugin)
298
+ }
299
+ }
300
+ }
301
+ })
302
+
303
+ alertShake.addAction(UIAlertAction(title: cancelButtonTitle, style: .default))
304
+
305
+ DispatchQueue.main.async {
306
+ topVC.present(alertShake, animated: true)
307
+ }
308
+ return true
309
+ }
310
+
311
+ private func showConfiguredDefaultMenu(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) {
57
312
  let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "App"
58
313
  let title = "Preview \(appName) Menu"
59
314
  let message = "What would you like to do?"
@@ -116,14 +371,141 @@ extension UIWindow {
116
371
  }
117
372
  }
118
373
 
119
- private func showChannelSelector(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) {
120
- // Prevent multiple alerts from showing
121
- if let topVC = UIApplication.topViewController(),
122
- topVC.isKind(of: UIAlertController.self) {
374
+ private func previewLabel(_ preview: [String: Any]) -> String {
375
+ let bundle = preview["bundle"] as? [String: Any]
376
+ let name = preview["name"] as? String
377
+ let version = bundle?["version"] as? String
378
+ var label = [name, version, preview["id"] as? String]
379
+ .compactMap { value in
380
+ guard let value, !value.isEmpty else { return nil }
381
+ return value
382
+ }
383
+ .first ?? "Preview"
384
+ if preview["isActive"] as? Bool == true {
385
+ label += " (current)"
386
+ }
387
+ return label
388
+ }
389
+
390
+ private func showPreviewSelector(plugin: CapacitorUpdaterPlugin) {
391
+ guard let topVC = UIApplication.topViewController() else {
392
+ return
393
+ }
394
+ if topVC.isKind(of: UIAlertController.self) {
123
395
  plugin.logger.info("UIAlertController is already presented")
124
396
  return
125
397
  }
126
398
 
399
+ let previews = plugin.previewMenuPreviews()
400
+ guard !previews.isEmpty else {
401
+ self.showError(message: "No saved previews available on this device.", plugin: plugin)
402
+ return
403
+ }
404
+
405
+ let alert = UIAlertController(title: "Select Preview", message: "Choose a local preview to open", preferredStyle: .actionSheet)
406
+ let previewsToShow = Array(previews.prefix(5))
407
+ for preview in previewsToShow {
408
+ let title = self.previewLabel(preview)
409
+ let id = preview["id"] as? String ?? ""
410
+ alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
411
+ self?.selectPreview(id: id, plugin: plugin)
412
+ })
413
+ }
414
+
415
+ if previews.count > 5 {
416
+ alert.addAction(UIAlertAction(title: "More previews...", style: .default) { [weak self] _ in
417
+ self?.showSearchablePreviewPicker(previews: previews, plugin: plugin)
418
+ })
419
+ }
420
+
421
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
422
+
423
+ if let popoverController = alert.popoverPresentationController {
424
+ popoverController.sourceView = self
425
+ popoverController.sourceRect = CGRect(x: self.bounds.midX, y: self.bounds.midY, width: 0, height: 0)
426
+ popoverController.permittedArrowDirections = []
427
+ }
428
+
429
+ topVC.present(alert, animated: true)
430
+ }
431
+
432
+ private func showSearchablePreviewPicker(previews: [[String: Any]], plugin: CapacitorUpdaterPlugin) {
433
+ let alert = UIAlertController(title: "Search Previews", message: "Enter preview name or version", preferredStyle: .alert)
434
+
435
+ alert.addTextField { textField in
436
+ textField.placeholder = "Preview name..."
437
+ }
438
+
439
+ alert.addAction(UIAlertAction(title: "Search", style: .default) { [weak self, weak alert] _ in
440
+ guard let self else { return }
441
+ guard let searchText = alert?.textFields?.first?.text?.lowercased(), !searchText.isEmpty else {
442
+ self.showPreviewSelector(plugin: plugin)
443
+ return
444
+ }
445
+
446
+ let filtered = previews.filter { self.previewLabel($0).lowercased().contains(searchText) }
447
+ if filtered.isEmpty {
448
+ self.showError(message: "No previews found matching '\(searchText)'", plugin: plugin)
449
+ } else if filtered.count == 1, let id = filtered[0]["id"] as? String {
450
+ self.selectPreview(id: id, plugin: plugin)
451
+ } else {
452
+ self.presentPreviewPicker(previews: filtered, plugin: plugin)
453
+ }
454
+ })
455
+
456
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
457
+
458
+ DispatchQueue.main.async {
459
+ if let topVC = UIApplication.topViewController() {
460
+ topVC.present(alert, animated: true)
461
+ }
462
+ }
463
+ }
464
+
465
+ private func presentPreviewPicker(previews: [[String: Any]], plugin: CapacitorUpdaterPlugin) {
466
+ let alert = UIAlertController(title: "Select Preview", message: "Choose a local preview to open", preferredStyle: .actionSheet)
467
+ for preview in previews.prefix(5) {
468
+ let id = preview["id"] as? String ?? ""
469
+ alert.addAction(UIAlertAction(title: self.previewLabel(preview), style: .default) { [weak self] _ in
470
+ self?.selectPreview(id: id, plugin: plugin)
471
+ })
472
+ }
473
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
474
+
475
+ if let popoverController = alert.popoverPresentationController {
476
+ popoverController.sourceView = self
477
+ popoverController.sourceRect = CGRect(x: self.bounds.midX, y: self.bounds.midY, width: 0, height: 0)
478
+ popoverController.permittedArrowDirections = []
479
+ }
480
+
481
+ DispatchQueue.main.async {
482
+ if let topVC = UIApplication.topViewController() {
483
+ topVC.present(alert, animated: true)
484
+ }
485
+ }
486
+ }
487
+
488
+ private func selectPreview(id: String, plugin: CapacitorUpdaterPlugin) {
489
+ DispatchQueue.global(qos: .userInitiated).async {
490
+ if !plugin.setPreviewFromShakeMenu(id: id) {
491
+ DispatchQueue.main.async {
492
+ self.showError(message: "Could not switch preview.", plugin: plugin)
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ @discardableResult
499
+ private func showChannelSelector(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) -> Bool {
500
+ // Prevent multiple alerts from showing
501
+ guard let topVC = UIApplication.topViewController() else {
502
+ return false
503
+ }
504
+ if topVC.isKind(of: UIAlertController.self) {
505
+ plugin.logger.info("UIAlertController is already presented")
506
+ return false
507
+ }
508
+
127
509
  let updater = plugin.implementation
128
510
 
129
511
  // Show loading indicator
@@ -145,28 +527,27 @@ extension UIWindow {
145
527
  })
146
528
 
147
529
  DispatchQueue.main.async {
148
- if let topVC = UIApplication.topViewController() {
149
- topVC.present(loadingAlert, animated: true) {
150
- // Fetch channels in background
151
- DispatchQueue.global(qos: .userInitiated).async {
152
- let result = updater.listChannels()
153
-
154
- DispatchQueue.main.async {
155
- loadingAlert.dismiss(animated: true) {
156
- guard !didCancel else { return }
157
- if !result.error.isEmpty {
158
- self.showError(message: "Failed to load channels: \(result.error)", plugin: plugin)
159
- } else if result.channels.isEmpty {
160
- self.showError(message: "No channels available for self-assignment", plugin: plugin)
161
- } else {
162
- self.presentChannelPicker(channels: result.channels, plugin: plugin, bridge: bridge)
163
- }
530
+ topVC.present(loadingAlert, animated: true) {
531
+ // Fetch channels in background
532
+ DispatchQueue.global(qos: .userInitiated).async {
533
+ let result = updater.listChannels()
534
+
535
+ DispatchQueue.main.async {
536
+ loadingAlert.dismiss(animated: true) {
537
+ guard !didCancel else { return }
538
+ if !result.error.isEmpty {
539
+ self.showError(message: "Failed to load channels: \(result.error)", plugin: plugin)
540
+ } else if result.channels.isEmpty {
541
+ self.showError(message: "No channels available for self-assignment", plugin: plugin)
542
+ } else {
543
+ self.presentChannelPicker(channels: result.channels, plugin: plugin, bridge: bridge)
164
544
  }
165
545
  }
166
546
  }
167
547
  }
168
548
  }
169
549
  }
550
+ return true
170
551
  }
171
552
 
172
553
  private func presentChannelPicker(channels: [[String: Any]], plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) {
@@ -275,7 +656,8 @@ extension UIWindow {
275
656
  let setResult = updater.setChannel(
276
657
  channel: name,
277
658
  defaultChannelKey: "CapacitorUpdater.defaultChannel",
278
- allowSetDefaultChannel: plugin.allowSetDefaultChannel
659
+ allowSetDefaultChannel: plugin.allowSetDefaultChannel,
660
+ configDefaultChannel: plugin.getConfig().getString("defaultChannel", "") ?? ""
279
661
  )
280
662
 
281
663
  if !setResult.error.isEmpty {
@@ -308,19 +690,36 @@ extension UIWindow {
308
690
  }
309
691
 
310
692
  let latest = updater.getLatest(url: updateUrl, channel: name)
693
+ let latestKind = latest.kind
694
+
695
+ let detail = [latest.message, latest.error, latestKind]
696
+ .compactMap { value in
697
+ guard let value, !value.isEmpty else { return nil }
698
+ return value
699
+ }
700
+ .first ?? "server did not provide a message"
311
701
 
312
702
  // Handle update errors first (before "no new version" check)
313
- if let error = latest.error, !error.isEmpty && error != "no_new_version_available" {
703
+ if latestKind == "failed" || (latest.error?.isEmpty == false && latestKind != "up_to_date" && latestKind != "blocked") {
704
+ DispatchQueue.main.async {
705
+ progressAlert.dismiss(animated: true) {
706
+ self.showError(message: "Channel set to \(name). Update check failed: \(detail)", plugin: plugin)
707
+ }
708
+ }
709
+ return
710
+ }
711
+
712
+ if latestKind == "blocked" {
314
713
  DispatchQueue.main.async {
315
714
  progressAlert.dismiss(animated: true) {
316
- self.showError(message: "Channel set to \(name). Update check failed: \(error)", plugin: plugin)
715
+ self.showError(message: "Channel set to \(name). Update check blocked: \(detail)", plugin: plugin)
317
716
  }
318
717
  }
319
718
  return
320
719
  }
321
720
 
322
721
  // Check if there's an actual update available
323
- if latest.error == "no_new_version_available" || latest.url.isEmpty {
722
+ if latestKind == "up_to_date" || latest.url.isEmpty {
324
723
  DispatchQueue.main.async {
325
724
  progressAlert.dismiss(animated: true) {
326
725
  self.showSuccess(message: "Channel set to \(name). Already on latest version.", plugin: plugin)