@capgo/capacitor-updater 6.14.29 → 6.14.36

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.
@@ -54,7 +54,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
54
54
  CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
55
55
  ]
56
56
  public var implementation = CapgoUpdater()
57
- private let PLUGIN_VERSION: String = "6.14.29"
57
+ private let PLUGIN_VERSION: String = "6.14.31"
58
58
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
59
59
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
60
60
  static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
@@ -76,6 +76,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
76
76
  private var directUpdate = false
77
77
  private var directUpdateMode: String = "false"
78
78
  private var wasRecentlyInstalledOrUpdated = false
79
+ private var onLaunchDirectUpdateUsed = false
79
80
  private var autoSplashscreen = false
80
81
  private var autoSplashscreenLoader = false
81
82
  private var autoSplashscreenTimeout = 10000
@@ -113,9 +114,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
113
114
  #endif
114
115
 
115
116
  self.semaphoreUp()
116
- self.implementation.deviceID = (UserDefaults.standard.string(forKey: "appUUID") ?? UUID().uuidString).lowercased()
117
- UserDefaults.standard.set( self.implementation.deviceID, forKey: "appUUID")
118
- UserDefaults.standard.synchronize()
117
+ // Use DeviceIdHelper to get or create device ID that persists across reinstalls
118
+ self.implementation.deviceID = DeviceIdHelper.getOrCreateDeviceId()
119
119
  persistCustomId = getConfig().getBoolean("persistCustomId", false)
120
120
  if persistCustomId {
121
121
  let storedCustomId = UserDefaults.standard.string(forKey: customIdDefaultsKey) ?? ""
@@ -148,7 +148,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
148
148
  // Handle directUpdate configuration - support string values and backward compatibility
149
149
  if let directUpdateString = getConfig().getString("directUpdate") {
150
150
  directUpdateMode = directUpdateString
151
- directUpdate = directUpdateString == "always" || directUpdateString == "atInstall"
151
+ directUpdate = directUpdateString == "always" || directUpdateString == "atInstall" || directUpdateString == "onLaunch"
152
+ // Validate directUpdate value
153
+ if directUpdateString != "false" && directUpdateString != "always" && directUpdateString != "atInstall" && directUpdateString != "onLaunch" {
154
+ logger.error("Invalid directUpdate value: \"\(directUpdateString)\". Supported values are: false, \"always\", \"atInstall\", \"onLaunch\". Defaulting to false.")
155
+ directUpdateMode = "false"
156
+ directUpdate = false
157
+ }
152
158
  } else {
153
159
  let directUpdateBool = getConfig().getBoolean("directUpdate", false)
154
160
  if directUpdateBool {
@@ -1196,7 +1202,13 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1196
1202
  return true
1197
1203
  }
1198
1204
  return false
1205
+ case "onLaunch":
1206
+ if !self.onLaunchDirectUpdateUsed {
1207
+ return true
1208
+ }
1209
+ return false
1199
1210
  default:
1211
+ logger.error("Invalid directUpdateMode: \"\(self.directUpdateMode)\". Supported values are: \"false\", \"always\", \"atInstall\", \"onLaunch\". Defaulting to false behavior.")
1200
1212
  return false
1201
1213
  }
1202
1214
  }
@@ -1287,6 +1299,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1287
1299
  let directUpdateAllowed = plannedDirectUpdate && !self.autoSplashscreenTimedOut
1288
1300
  if directUpdateAllowed {
1289
1301
  self.logger.info("Direct update to builtin version")
1302
+ if self.directUpdateMode == "onLaunch" {
1303
+ self.onLaunchDirectUpdateUsed = true
1304
+ self.directUpdate = false
1305
+ }
1290
1306
  _ = self._reset(toLastSuccessful: false)
1291
1307
  self.endBackGroundTaskWithNotif(msg: "Updated to builtin version", latestVersionName: res.version, current: self.implementation.getCurrentBundle(), error: false)
1292
1308
  } else {
@@ -1361,6 +1377,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1361
1377
  self.endBackGroundTaskWithNotif(msg: "Update delayed until delay conditions met", latestVersionName: latestVersionName, current: next, error: false)
1362
1378
  return
1363
1379
  }
1380
+ if self.directUpdateMode == "onLaunch" {
1381
+ self.onLaunchDirectUpdateUsed = true
1382
+ self.directUpdate = false
1383
+ }
1364
1384
  _ = self.implementation.set(bundle: next)
1365
1385
  _ = self._reload()
1366
1386
  self.endBackGroundTaskWithNotif(msg: "update installed", latestVersionName: latestVersionName, current: next, error: false)
@@ -1442,6 +1462,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1442
1462
  if self._isAutoUpdateEnabled() {
1443
1463
  self.backgroundDownload()
1444
1464
  } else {
1465
+ let instanceDescriptor = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor()
1466
+ if instanceDescriptor?.serverURL != nil {
1467
+ self.implementation.sendStats(action: "blocked_by_server_url", versionName: current.getVersionName())
1468
+ }
1445
1469
  logger.info("Auto update is disabled")
1446
1470
  self.sendReadyToJs(current: current, msg: "disabled")
1447
1471
  }
@@ -1495,7 +1519,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1495
1519
  }
1496
1520
 
1497
1521
  if !self.shouldUseDirectUpdate() {
1498
- logger.warn("autoSplashscreen is enabled but directUpdate is not configured for immediate updates. Set directUpdate to 'always' or 'atInstall', or disable autoSplashscreen.")
1522
+ if self.directUpdateMode == "false" {
1523
+ logger.warn("autoSplashscreen is enabled but directUpdate is not configured for immediate updates. Set directUpdate to 'always' or disable autoSplashscreen.")
1524
+ } else if self.directUpdateMode == "atInstall" || self.directUpdateMode == "onLaunch" {
1525
+ logger.info("autoSplashscreen is enabled but directUpdate is set to \"\(self.directUpdateMode)\". This is normal. Skipping autoSplashscreen logic.")
1526
+ }
1499
1527
  canShowSplashscreen = false
1500
1528
  }
1501
1529
 
@@ -47,6 +47,9 @@ import UIKit
47
47
  // Flag to track if we received a 429 response - stops requests until app restart
48
48
  private static var rateLimitExceeded = false
49
49
 
50
+ // Flag to track if we've already sent the rate limit statistic - prevents infinite loop
51
+ private static var rateLimitStatisticSent = false
52
+
50
53
  private var userAgent: String {
51
54
  let safePluginVersion = PLUGIN_VERSION.isEmpty ? "unknown" : PLUGIN_VERSION
52
55
  let safeAppId = appId.isEmpty ? "unknown" : appId
@@ -95,6 +98,12 @@ import UIKit
95
98
  */
96
99
  private func checkAndHandleRateLimitResponse(statusCode: Int?) -> Bool {
97
100
  if statusCode == 429 {
101
+ // Send a statistic about the rate limit BEFORE setting the flag
102
+ // Only send once to prevent infinite loop if the stat request itself gets rate limited
103
+ if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
104
+ CapgoUpdater.rateLimitStatisticSent = true
105
+ self.sendRateLimitStatistic()
106
+ }
98
107
  CapgoUpdater.rateLimitExceeded = true
99
108
  logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
100
109
  return true
@@ -102,6 +111,40 @@ import UIKit
102
111
  return false
103
112
  }
104
113
 
114
+ /**
115
+ * Send a synchronous statistic about rate limiting
116
+ */
117
+ private func sendRateLimitStatistic() {
118
+ guard !statsUrl.isEmpty else {
119
+ return
120
+ }
121
+
122
+ let current = getCurrentBundle()
123
+ var parameters = createInfoObject()
124
+ parameters.action = "rate_limit_reached"
125
+ parameters.version_name = current.getVersionName()
126
+ parameters.old_version_name = ""
127
+
128
+ // Send synchronously to ensure it goes out before the flag is set
129
+ let semaphore = DispatchSemaphore(value: 0)
130
+ self.alamofireSession.request(
131
+ self.statsUrl,
132
+ method: .post,
133
+ parameters: parameters,
134
+ encoder: JSONParameterEncoder.default,
135
+ requestModifier: { $0.timeoutInterval = self.timeout }
136
+ ).responseData { response in
137
+ switch response.result {
138
+ case .success:
139
+ self.logger.info("Rate limit statistic sent")
140
+ case let .failure(error):
141
+ self.logger.error("Error sending rate limit statistic: \(error.localizedDescription)")
142
+ }
143
+ semaphore.signal()
144
+ }
145
+ semaphore.wait()
146
+ }
147
+
105
148
  // MARK: Private
106
149
  private func hasEmbeddedMobileProvision() -> Bool {
107
150
  guard Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") == nil else {
@@ -0,0 +1,120 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+ import Security
9
+
10
+ /**
11
+ * Helper class to manage device ID persistence across app installations.
12
+ * Uses iOS Keychain to persist the device ID.
13
+ */
14
+ class DeviceIdHelper {
15
+ private static let keychainService = "app.capgo.updater"
16
+ private static let keychainAccount = "deviceId"
17
+ private static let legacyDefaultsKey = "appUUID"
18
+
19
+ /**
20
+ * Gets or creates a device ID that persists across reinstalls.
21
+ *
22
+ * This method:
23
+ * 1. First checks for an existing ID in Keychain (persists across reinstalls)
24
+ * 2. Falls back to UserDefaults (for migration from older versions)
25
+ * 3. Generates a new UUID if neither exists
26
+ * 4. Stores the ID in Keychain for future use
27
+ *
28
+ * @return Device ID as a lowercase UUID string
29
+ */
30
+ static func getOrCreateDeviceId() -> String {
31
+ // Try to get device ID from Keychain first
32
+ if let keychainDeviceId = getDeviceIdFromKeychain() {
33
+ return keychainDeviceId.lowercased()
34
+ }
35
+
36
+ // Migration: Check UserDefaults for existing device ID
37
+ var deviceId = UserDefaults.standard.string(forKey: legacyDefaultsKey)
38
+
39
+ if deviceId == nil || deviceId!.isEmpty {
40
+ // Generate new device ID if none exists
41
+ deviceId = UUID().uuidString
42
+ }
43
+
44
+ // Ensure lowercase for consistency
45
+ deviceId = deviceId!.lowercased()
46
+
47
+ // Save to Keychain for persistence across reinstalls
48
+ saveDeviceIdToKeychain(deviceId: deviceId!)
49
+
50
+ return deviceId!
51
+ }
52
+
53
+ /**
54
+ * Retrieves the device ID from iOS Keychain.
55
+ *
56
+ * @return Device ID string or nil if not found
57
+ */
58
+ private static func getDeviceIdFromKeychain() -> String? {
59
+ let query: [String: Any] = [
60
+ kSecClass as String: kSecClassGenericPassword,
61
+ kSecAttrService as String: keychainService,
62
+ kSecAttrAccount as String: keychainAccount,
63
+ kSecReturnData as String: true,
64
+ kSecMatchLimit as String: kSecMatchLimitOne
65
+ ]
66
+
67
+ var result: AnyObject?
68
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
69
+
70
+ guard status == errSecSuccess,
71
+ let data = result as? Data,
72
+ let deviceId = String(data: data, encoding: .utf8) else {
73
+ return nil
74
+ }
75
+
76
+ return deviceId
77
+ }
78
+
79
+ /**
80
+ * Saves the device ID to iOS Keychain with appropriate accessibility settings.
81
+ *
82
+ * Uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:
83
+ * - Data persists across reinstalls
84
+ * - Data is NOT synced to iCloud
85
+ * - Data is accessible after first device unlock
86
+ * - Data stays on this device only (privacy-friendly)
87
+ *
88
+ * @param deviceId The device ID to save
89
+ */
90
+ private static func saveDeviceIdToKeychain(deviceId: String) {
91
+ guard let data = deviceId.data(using: .utf8) else {
92
+ return
93
+ }
94
+
95
+ // Delete any existing entry first
96
+ let deleteQuery: [String: Any] = [
97
+ kSecClass as String: kSecClassGenericPassword,
98
+ kSecAttrService as String: keychainService,
99
+ kSecAttrAccount as String: keychainAccount
100
+ ]
101
+ SecItemDelete(deleteQuery as CFDictionary)
102
+
103
+ // Add new entry with appropriate accessibility
104
+ let addQuery: [String: Any] = [
105
+ kSecClass as String: kSecClassGenericPassword,
106
+ kSecAttrService as String: keychainService,
107
+ kSecAttrAccount as String: keychainAccount,
108
+ kSecValueData as String: data,
109
+ // This ensures data persists across reinstalls but stays on device (not synced)
110
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
111
+ ]
112
+
113
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
114
+
115
+ if status != errSecSuccess {
116
+ // Log error but don't crash - we'll fall back to UserDefaults on next launch
117
+ print("Failed to save device ID to Keychain: \(status)")
118
+ }
119
+ }
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "6.14.29",
3
+ "version": "6.14.36",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",
@@ -52,8 +52,7 @@
52
52
  "eslint": "eslint . --ext .ts",
53
53
  "prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
54
54
  "swiftlint": "node-swiftlint",
55
- "docgen": "docgen --api CapacitorUpdaterPlugin --output-readme README.md --output-json dist/docs.json",
56
- "docgen:api": "node scripts/generate-docs.js",
55
+ "docgen": "node scripts/generate-docs.js",
57
56
  "build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
58
57
  "clean": "rimraf ./dist",
59
58
  "watch": "tsc --watch",