@capgo/capacitor-updater 8.48.0 → 8.49.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.
@@ -9,7 +9,87 @@ import Capacitor
9
9
 
10
10
  private var lastShakeMenuShownAt: TimeInterval = 0
11
11
  private let shakeMenuCooldownSeconds: TimeInterval = 1.2
12
- private let threeFingerPinchScaleDelta: CGFloat = 0.30
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
+ }
13
93
 
14
94
  extension UIApplication {
15
95
  // swiftlint:disable:next line_length
@@ -35,7 +115,7 @@ extension CapacitorUpdaterPlugin: UIGestureRecognizerDelegate {
35
115
  let shouldInstall = self.shakeMenuGesture == Self.shakeMenuGestureThreeFingerPinch &&
36
116
  (self.shakeMenuEnabled || self.shakeChannelSelectorEnabled)
37
117
 
38
- guard shouldInstall, let targetView = self.bridge?.webView ?? self.bridge?.viewController?.view else {
118
+ guard shouldInstall, let targetView = self.bridge?.viewController?.view ?? self.bridge?.webView else {
39
119
  self.removeShakeMenuGestureRecognizer()
40
120
  return
41
121
  }
@@ -46,11 +126,14 @@ extension CapacitorUpdaterPlugin: UIGestureRecognizerDelegate {
46
126
 
47
127
  self.removeShakeMenuGestureRecognizer()
48
128
 
49
- let recognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handleShakeMenuPinch(_:)))
129
+ let recognizer = ThreeFingerPinchGestureRecognizer(target: self, action: #selector(self.handleShakeMenuPinch(_:)))
50
130
  recognizer.cancelsTouchesInView = false
51
131
  recognizer.delaysTouchesBegan = false
52
132
  recognizer.delaysTouchesEnded = false
53
133
  recognizer.delegate = self
134
+ recognizer.onTrackingStarted = { [weak self] in
135
+ self?.logger.info("Three finger pinch tracking started")
136
+ }
54
137
  targetView.addGestureRecognizer(recognizer)
55
138
  self.shakeMenuPinchGestureRecognizer = recognizer
56
139
  self.logger.info("Three finger pinch menu gesture initialized")
@@ -66,12 +149,8 @@ extension CapacitorUpdaterPlugin: UIGestureRecognizerDelegate {
66
149
  }
67
150
  }
68
151
 
69
- @objc func handleShakeMenuPinch(_ recognizer: UIPinchGestureRecognizer) {
70
- if recognizer.state == .ended || recognizer.state == .cancelled || recognizer.state == .failed {
71
- self.shakeMenuPinchGestureTriggered = false
72
- return
73
- }
74
- guard recognizer.state == .changed, !self.shakeMenuPinchGestureTriggered, recognizer.numberOfTouches == 3 else {
152
+ @objc func handleShakeMenuPinch(_ recognizer: ThreeFingerPinchGestureRecognizer) {
153
+ guard recognizer.state == .recognized, !self.shakeMenuPinchGestureTriggered else {
75
154
  return
76
155
  }
77
156
  guard abs(recognizer.scale - 1) >= threeFingerPinchScaleDelta else {
@@ -85,6 +164,7 @@ extension CapacitorUpdaterPlugin: UIGestureRecognizerDelegate {
85
164
  }
86
165
 
87
166
  self.shakeMenuPinchGestureTriggered = true
167
+ self.logger.info("Three finger pinch detected")
88
168
  _ = window.showCapacitorUpdaterMenu(plugin: self, bridge: bridge, gestureName: "Three finger pinch")
89
169
  }
90
170
 
@@ -165,23 +245,13 @@ extension UIWindow {
165
245
 
166
246
  let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "App"
167
247
  let title = "Preview \(appName) Menu"
168
- let message = "Reload the current preview or leave the test app."
248
+ let message = "Reload, switch, or leave the current preview."
169
249
  let okButtonTitle = "Leave test app"
170
- let reloadButtonTitle = "Reload app"
250
+ let reloadButtonTitle = "Reload preview"
171
251
  let cancelButtonTitle = "Close menu"
172
252
 
173
253
  let alertShake = UIAlertController(title: title, message: message, preferredStyle: .alert)
174
254
 
175
- alertShake.addAction(UIAlertAction(title: okButtonTitle, style: .default) { _ in
176
- DispatchQueue.global(qos: .userInitiated).async {
177
- if !plugin.leavePreviewSessionFromShakeMenu() {
178
- DispatchQueue.main.async {
179
- self.showError(message: "Could not leave the test app.", plugin: plugin)
180
- }
181
- }
182
- }
183
- })
184
-
185
255
  alertShake.addAction(UIAlertAction(title: reloadButtonTitle, style: .default) { _ in
186
256
  DispatchQueue.global(qos: .userInitiated).async {
187
257
  if !plugin.reloadPreviewSessionFromShakeMenu() {
@@ -192,6 +262,20 @@ extension UIWindow {
192
262
  }
193
263
  })
194
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
+
195
279
  if plugin.shakeChannelSelectorEnabled {
196
280
  alertShake.addAction(UIAlertAction(title: "Switch channel", style: .default) { _ in
197
281
  let showSelector = {
@@ -206,6 +290,16 @@ extension UIWindow {
206
290
  })
207
291
  }
208
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
+
209
303
  alertShake.addAction(UIAlertAction(title: cancelButtonTitle, style: .default))
210
304
 
211
305
  DispatchQueue.main.async {
@@ -277,6 +371,130 @@ extension UIWindow {
277
371
  }
278
372
  }
279
373
 
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) {
395
+ plugin.logger.info("UIAlertController is already presented")
396
+ return
397
+ }
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
+
280
498
  @discardableResult
281
499
  private func showChannelSelector(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) -> Bool {
282
500
  // Prevent multiple alerts from showing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.48.0",
3
+ "version": "8.49.1",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",