@capgo/capacitor-updater 7.45.10 → 7.50.2

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 (33) hide show
  1. package/Package.swift +1 -1
  2. package/README.md +510 -92
  3. package/android/src/main/java/ee/forgr/capacitor_updater/AndroidAppExitReporter.java +92 -0
  4. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +3192 -777
  5. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +798 -299
  6. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +45 -30
  7. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +46 -28
  8. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +2 -0
  9. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +3 -3
  10. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +359 -25
  11. package/android/src/main/java/ee/forgr/capacitor_updater/ThreeFingerPinchDetector.java +323 -0
  12. package/dist/docs.json +1283 -169
  13. package/dist/esm/definitions.d.ts +621 -44
  14. package/dist/esm/definitions.js.map +1 -1
  15. package/dist/esm/web.d.ts +11 -1
  16. package/dist/esm/web.js +59 -1
  17. package/dist/esm/web.js.map +1 -1
  18. package/dist/plugin.cjs.js +59 -1
  19. package/dist/plugin.cjs.js.map +1 -1
  20. package/dist/plugin.js +59 -1
  21. package/dist/plugin.js.map +1 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +0 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/AppHealthTracker.swift +82 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -16
  25. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +2 -2
  26. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +78 -2
  27. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2116 -223
  28. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +997 -332
  29. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +0 -1
  30. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +78 -1
  31. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +418 -36
  32. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +276 -0
  33. package/package.json +15 -3
@@ -6,7 +6,6 @@
6
6
 
7
7
  import Foundation
8
8
  import CryptoKit
9
- import BigInt
10
9
 
11
10
  public struct CryptoCipher {
12
11
  private static var logger: Logger!
@@ -70,7 +70,7 @@ extension GetChannel {
70
70
  }
71
71
  // swiftlint:disable identifier_name
72
72
  struct ChannelInfo: Codable {
73
- let id: String?
73
+ let id: Int
74
74
  let name: String?
75
75
  let `public`: Bool?
76
76
  let allow_self_set: Bool?
@@ -129,10 +129,61 @@ struct InfoObject: Codable {
129
129
  let plugin_version: String?
130
130
  let is_emulator: Bool?
131
131
  let is_prod: Bool?
132
+ let installSource: String?
132
133
  var action: String?
133
134
  var channel: String?
134
135
  var defaultChannel: String?
135
136
  var key_id: String?
137
+
138
+ enum CodingKeys: String, CodingKey {
139
+ case platform
140
+ case device_id
141
+ case app_id
142
+ case custom_id
143
+ case version_build
144
+ case version_code
145
+ case version_os
146
+ case version_name
147
+ case old_version_name
148
+ case plugin_version
149
+ case is_emulator
150
+ case is_prod
151
+ case installSource = "install_source"
152
+ case action
153
+ case channel
154
+ case defaultChannel
155
+ case key_id
156
+ }
157
+ }
158
+
159
+ extension InfoObject {
160
+ func toParameters() -> [String: Any] {
161
+ var parameters: [String: Any] = [:]
162
+ func set(_ key: String, _ value: Any?) {
163
+ guard let value = value else {
164
+ return
165
+ }
166
+ parameters[key] = value
167
+ }
168
+ set("platform", platform)
169
+ set("device_id", device_id)
170
+ set("app_id", app_id)
171
+ set("custom_id", custom_id)
172
+ set("version_build", version_build)
173
+ set("version_code", version_code)
174
+ set("version_os", version_os)
175
+ set("version_name", version_name)
176
+ set("old_version_name", old_version_name)
177
+ set("plugin_version", plugin_version)
178
+ set("is_emulator", is_emulator)
179
+ set("is_prod", is_prod)
180
+ set("install_source", installSource)
181
+ set("action", action)
182
+ set("channel", channel)
183
+ set("defaultChannel", defaultChannel)
184
+ set("key_id", key_id)
185
+ return parameters
186
+ }
136
187
  }
137
188
  // swiftlint:enable identifier_name
138
189
 
@@ -150,11 +201,35 @@ struct StatsEvent: Codable {
150
201
  let plugin_version: String?
151
202
  let is_emulator: Bool?
152
203
  let is_prod: Bool?
204
+ let installSource: String?
153
205
  let action: String?
154
206
  let channel: String?
155
207
  let defaultChannel: String?
156
208
  let key_id: String?
209
+ let metadata: [String: String]?
157
210
  let timestamp: Int64
211
+
212
+ enum CodingKeys: String, CodingKey {
213
+ case platform
214
+ case device_id
215
+ case app_id
216
+ case custom_id
217
+ case version_build
218
+ case version_code
219
+ case version_os
220
+ case version_name
221
+ case old_version_name
222
+ case plugin_version
223
+ case is_emulator
224
+ case is_prod
225
+ case installSource = "install_source"
226
+ case action
227
+ case channel
228
+ case defaultChannel
229
+ case key_id
230
+ case metadata
231
+ case timestamp
232
+ }
158
233
  }
159
234
  // swiftlint:enable identifier_name
160
235
 
@@ -210,6 +285,8 @@ public class AppVersion: NSObject {
210
285
  var breaking: Bool?
211
286
  var data: [String: String]?
212
287
  var manifest: [ManifestEntry]?
288
+ var missing: [String: Any]?
289
+ var downloadSize: [String: Any]?
213
290
  var link: String?
214
291
  var comment: String?
215
292
  var statusCode: Int = 0
@@ -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
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
46
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
244
+ }
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
+ })
55
291
  }
56
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 {