native-update 1.4.9 → 3.1.0

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 (265) hide show
  1. package/Readme.md +35 -9
  2. package/android/manifest-additions.xml +28 -0
  3. package/android/src/main/AndroidManifest.xml +3 -7
  4. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundNotificationManager.kt +21 -13
  5. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdatePlugin.kt +15 -0
  6. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateWorker.kt +23 -7
  7. package/android/src/main/java/com/aoneahsan/nativeupdate/LiveUpdatePlugin.kt +346 -31
  8. package/android/src/main/java/com/aoneahsan/nativeupdate/NativeUpdatePlugin.kt +32 -34
  9. package/android/src/main/java/com/aoneahsan/nativeupdate/NotificationActionReceiver.kt +10 -1
  10. package/android/src/main/java/com/aoneahsan/nativeupdate/SecurityManager.kt +18 -18
  11. package/cli/AGENTS.md +43 -0
  12. package/cli/CLAUDE.md +65 -0
  13. package/cli/commands/backend-create.js +56 -15
  14. package/cli/commands/bundle-create.js +39 -7
  15. package/cli/commands/monitor.js +72 -51
  16. package/cli/index.js +10 -24
  17. package/cli/package.json +11 -3
  18. package/dist/esm/app-review/app-review-manager.js +0 -1
  19. package/dist/esm/app-review/index.js +0 -1
  20. package/dist/esm/app-review/platform-review-handler.js +0 -1
  21. package/dist/esm/app-review/review-conditions-checker.js +0 -1
  22. package/dist/esm/app-review/review-rate-limiter.js +0 -1
  23. package/dist/esm/app-review/types.js +0 -1
  24. package/dist/esm/app-update/app-update-checker.js +0 -1
  25. package/dist/esm/app-update/app-update-installer.d.ts +1 -0
  26. package/dist/esm/app-update/app-update-installer.js +19 -8
  27. package/dist/esm/app-update/app-update-manager.js +0 -1
  28. package/dist/esm/app-update/app-update-notifier.js +0 -1
  29. package/dist/esm/app-update/index.js +0 -1
  30. package/dist/esm/app-update/platform-app-update.js +12 -3
  31. package/dist/esm/app-update/types.js +0 -1
  32. package/dist/esm/background-update/background-scheduler.js +0 -1
  33. package/dist/esm/background-update/index.js +0 -1
  34. package/dist/esm/background-update/notification-manager.js +0 -1
  35. package/dist/esm/config/support.js +0 -1
  36. package/dist/esm/core/analytics.d.ts +13 -0
  37. package/dist/esm/core/analytics.js +15 -1
  38. package/dist/esm/core/cache-manager.js +0 -1
  39. package/dist/esm/core/config.d.ts +6 -30
  40. package/dist/esm/core/config.js +1 -8
  41. package/dist/esm/core/errors.d.ts +1 -0
  42. package/dist/esm/core/errors.js +1 -1
  43. package/dist/esm/core/event-emitter.d.ts +0 -3
  44. package/dist/esm/core/event-emitter.js +8 -3
  45. package/dist/esm/core/logger.js +0 -1
  46. package/dist/esm/core/performance.js +0 -1
  47. package/dist/esm/core/plugin-manager.js +3 -1
  48. package/dist/esm/core/security.d.ts +22 -3
  49. package/dist/esm/core/security.js +46 -7
  50. package/dist/esm/definitions.d.ts +13 -40
  51. package/dist/esm/definitions.js +0 -1
  52. package/dist/esm/index.d.ts +3 -4
  53. package/dist/esm/index.js +3 -3
  54. package/dist/esm/live-update/bundle-manager.d.ts +8 -0
  55. package/dist/esm/live-update/bundle-manager.js +20 -1
  56. package/dist/esm/live-update/certificate-pinning.js +0 -1
  57. package/dist/esm/live-update/delta-processor.d.ts +1 -1
  58. package/dist/esm/live-update/delta-processor.js +3 -1
  59. package/dist/esm/live-update/download-manager.d.ts +36 -5
  60. package/dist/esm/live-update/download-manager.js +70 -24
  61. package/dist/esm/live-update/rollout-checker.d.ts +1 -1
  62. package/dist/esm/live-update/rollout-checker.js +5 -6
  63. package/dist/esm/live-update/update-manager.d.ts +12 -1
  64. package/dist/esm/live-update/update-manager.js +38 -11
  65. package/dist/esm/live-update/version-manager.d.ts +9 -12
  66. package/dist/esm/live-update/version-manager.js +40 -68
  67. package/dist/esm/plugin.js +82 -89
  68. package/dist/esm/security/crypto.js +11 -7
  69. package/dist/esm/security/validator.js +0 -1
  70. package/dist/esm/types/rollout.d.ts +85 -0
  71. package/dist/esm/types/rollout.js +25 -0
  72. package/dist/esm/web.d.ts +26 -1
  73. package/dist/esm/web.js +214 -75
  74. package/dist/plugin.cjs.js +1 -2
  75. package/dist/plugin.esm.js +1 -2
  76. package/dist/plugin.js +2 -3
  77. package/docs/AGENTS.md +38 -0
  78. package/docs/CHANGELOG.md +275 -0
  79. package/docs/CLAUDE.md +101 -0
  80. package/docs/MANUAL-TASKS.md +17 -0
  81. package/docs/MARKETING_WEBSITE_TRACKER.md +18 -2
  82. package/docs/MIGRATION.md +170 -0
  83. package/docs/PACKAGES.md +167 -0
  84. package/docs/PERFORMANCE.md +110 -0
  85. package/docs/PROJECT_COMPLETION_TRACKER.md +4 -35
  86. package/docs/README.md +12 -2
  87. package/docs/REMAINING_FEATURES.md +123 -76
  88. package/docs/ROADMAP.md +200 -120
  89. package/docs/SECURITY-IF-REPO-PUBLIC.md +232 -0
  90. package/docs/TESTING_REQUIREMENTS.md +1 -112
  91. package/docs/ai-knowledge-base/00-project-profile.md +38 -0
  92. package/docs/ai-knowledge-base/01-features.md +46 -0
  93. package/docs/ai-knowledge-base/02-routes.md +72 -0
  94. package/docs/ai-knowledge-base/03-tech-stack.md +57 -0
  95. package/docs/ai-knowledge-base/README.md +23 -0
  96. package/docs/analytics-tracking.md +37 -0
  97. package/docs/capacitor-official-plugins.md +46 -0
  98. package/docs/capawesome-plugins.md +29 -0
  99. package/docs/deployment/HOSTINGER_DEPLOY.md +370 -0
  100. package/docs/deployment/PRODUCTION-DEPLOY-CHECKLIST.md +184 -0
  101. package/docs/docs-site/plan.md +201 -0
  102. package/docs/docs-site/scope.md +127 -0
  103. package/docs/docs-site/tracker.json +235 -0
  104. package/docs/error-handling-tracking.md +37 -0
  105. package/docs/features/laravel-nova-backend/ASSESSMENT-SUMMARY.md +96 -0
  106. package/docs/features/laravel-nova-backend/IMPLEMENTATION-PLAN.md +504 -0
  107. package/docs/features/laravel-nova-backend/progress-tracker.json +184 -0
  108. package/docs/guides/testing-guide.md +15 -41
  109. package/docs/package-audit.md +41 -0
  110. package/docs/play-store/ASO-METADATA.md +5 -0
  111. package/docs/play-store/DATA-SAFETY.md +5 -0
  112. package/docs/play-store/DECLARATIONS.md +5 -0
  113. package/docs/play-store/README.md +27 -0
  114. package/docs/play-store/RELEASE-NOTES.md +99 -0
  115. package/docs/play-store/RELEASE-STEPS.md +6 -0
  116. package/docs/play-store/STORE-LISTING.md +7 -0
  117. package/docs/play-store/SUBMISSION-CHECKLIST.md +5 -0
  118. package/docs/production-readiness.md +1 -1
  119. package/docs/project-audit/2026-05-16/00-FINALIZATION-PLAN.md +123 -0
  120. package/docs/project-audit/2026-05-16/01-BATCH-PLAN.md +227 -0
  121. package/docs/project-audit/2026-05-16/02-AUDIT-FINDINGS.md +144 -0
  122. package/docs/project-audit/2026-05-16/sessions/batch-01.md +42 -0
  123. package/docs/project-audit/2026-05-16/sessions/batch-02.md +74 -0
  124. package/docs/project-audit/2026-05-16/sessions/batch-03.md +71 -0
  125. package/docs/project-audit/2026-05-16/sessions/batch-04.md +88 -0
  126. package/docs/project-audit/2026-05-16/sessions/batch-05.md +63 -0
  127. package/docs/project-audit/2026-05-16/sessions/batch-06.md +103 -0
  128. package/docs/project-audit/2026-05-16/sessions/batch-07.md +61 -0
  129. package/docs/project-audit/2026-05-16/sessions/batch-08.md +101 -0
  130. package/docs/project-audit/2026-05-16/sessions/batch-09.md +105 -0
  131. package/docs/project-audit/2026-05-16/sessions/batch-10.md +102 -0
  132. package/docs/project-audit/AUDIT-HISTORY.md +74 -0
  133. package/docs/project-audit/BATCH-PLAN.md +168 -0
  134. package/docs/project-audit/MARKETING-WEBSITE-FINDINGS-2026-05-11.md +73 -0
  135. package/docs/project-audit/PRODUCTION-READINESS-AUDIT-2026-05-08.md +275 -0
  136. package/docs/project-finalization/00-tracker.json +60 -0
  137. package/docs/project-knowledge-base/01-system-overview.md +227 -0
  138. package/docs/project-knowledge-base/02-routes-pages-forms-users.md +352 -0
  139. package/docs/project-knowledge-base/03-tech-stack-modules-services.md +379 -0
  140. package/docs/project-knowledge-base/04-data-models-integrations.md +244 -0
  141. package/docs/project-knowledge-base/05-docs-corpus-inventory.md +256 -0
  142. package/docs/project-knowledge-base/06-operations-testing-legal-content.md +208 -0
  143. package/docs/project-knowledge-base/README.md +103 -0
  144. package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-05-19.md +81 -0
  145. package/docs/rules-tracker.md +102 -0
  146. package/docs/security/PRE-LAUNCH-PENTEST-CHECKLIST.md +116 -0
  147. package/docs/seo-aeo-rules.json +3043 -0
  148. package/docs/social-media-marketing/README.md +81 -0
  149. package/docs/social-media-marketing/posts-batch-01.md +613 -0
  150. package/docs/social-media-marketing/posts-batch-02.md +528 -0
  151. package/docs/social-media-marketing/whatsapp-messages-batch-01.md +458 -0
  152. package/docs/social-media-marketing/whatsapp-status-batch-01.md +355 -0
  153. package/docs/tracking/finalization-2026-05-16-tracker.json +346 -0
  154. package/docs/tracking/full-audit-tracker.json +121 -0
  155. package/docs/tracking/production-readiness-audit-tracker.json +429 -0
  156. package/docs/tracking/seo-checklist-tracker.json +333 -0
  157. package/ios/Plugin/BackgroundUpdate/BackgroundUpdatePlugin.swift +66 -9
  158. package/ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift +306 -52
  159. package/ios/Plugin/NativeUpdatePlugin.swift +22 -9
  160. package/ios/Plugin/Security/SecurityManager.swift +13 -14
  161. package/package.json +37 -38
  162. package/cli/node_modules/.yarn-integrity +0 -16
  163. package/cli/node_modules/commander/LICENSE +0 -22
  164. package/cli/node_modules/commander/Readme.md +0 -1148
  165. package/cli/node_modules/commander/esm.mjs +0 -16
  166. package/cli/node_modules/commander/index.js +0 -26
  167. package/cli/node_modules/commander/lib/argument.js +0 -145
  168. package/cli/node_modules/commander/lib/command.js +0 -2179
  169. package/cli/node_modules/commander/lib/error.js +0 -43
  170. package/cli/node_modules/commander/lib/help.js +0 -462
  171. package/cli/node_modules/commander/lib/option.js +0 -329
  172. package/cli/node_modules/commander/lib/suggestSimilar.js +0 -100
  173. package/cli/node_modules/commander/package-support.json +0 -16
  174. package/cli/node_modules/commander/package.json +0 -80
  175. package/cli/node_modules/commander/typings/esm.d.mts +0 -3
  176. package/cli/node_modules/commander/typings/index.d.ts +0 -884
  177. package/cli/yarn.lock +0 -8
  178. package/dist/esm/__tests__/bundle-manager.test.d.ts +0 -1
  179. package/dist/esm/__tests__/bundle-manager.test.js +0 -151
  180. package/dist/esm/__tests__/bundle-manager.test.js.map +0 -1
  181. package/dist/esm/__tests__/config.test.d.ts +0 -1
  182. package/dist/esm/__tests__/config.test.js +0 -70
  183. package/dist/esm/__tests__/config.test.js.map +0 -1
  184. package/dist/esm/__tests__/delta-processor.test.d.ts +0 -1
  185. package/dist/esm/__tests__/delta-processor.test.js +0 -77
  186. package/dist/esm/__tests__/delta-processor.test.js.map +0 -1
  187. package/dist/esm/__tests__/firestore-schema.test.d.ts +0 -1
  188. package/dist/esm/__tests__/firestore-schema.test.js +0 -74
  189. package/dist/esm/__tests__/firestore-schema.test.js.map +0 -1
  190. package/dist/esm/__tests__/integration.test.d.ts +0 -1
  191. package/dist/esm/__tests__/integration.test.js +0 -78
  192. package/dist/esm/__tests__/integration.test.js.map +0 -1
  193. package/dist/esm/__tests__/manifest-reader.test.d.ts +0 -1
  194. package/dist/esm/__tests__/manifest-reader.test.js +0 -271
  195. package/dist/esm/__tests__/manifest-reader.test.js.map +0 -1
  196. package/dist/esm/__tests__/rollout-checker.test.d.ts +0 -1
  197. package/dist/esm/__tests__/rollout-checker.test.js +0 -210
  198. package/dist/esm/__tests__/rollout-checker.test.js.map +0 -1
  199. package/dist/esm/__tests__/security.test.d.ts +0 -1
  200. package/dist/esm/__tests__/security.test.js +0 -54
  201. package/dist/esm/__tests__/security.test.js.map +0 -1
  202. package/dist/esm/__tests__/version-manager.test.d.ts +0 -1
  203. package/dist/esm/__tests__/version-manager.test.js +0 -45
  204. package/dist/esm/__tests__/version-manager.test.js.map +0 -1
  205. package/dist/esm/app-review/app-review-manager.js.map +0 -1
  206. package/dist/esm/app-review/index.js.map +0 -1
  207. package/dist/esm/app-review/platform-review-handler.js.map +0 -1
  208. package/dist/esm/app-review/review-conditions-checker.js.map +0 -1
  209. package/dist/esm/app-review/review-rate-limiter.js.map +0 -1
  210. package/dist/esm/app-review/types.js.map +0 -1
  211. package/dist/esm/app-update/app-update-checker.js.map +0 -1
  212. package/dist/esm/app-update/app-update-installer.js.map +0 -1
  213. package/dist/esm/app-update/app-update-manager.js.map +0 -1
  214. package/dist/esm/app-update/app-update-notifier.js.map +0 -1
  215. package/dist/esm/app-update/index.js.map +0 -1
  216. package/dist/esm/app-update/platform-app-update.js.map +0 -1
  217. package/dist/esm/app-update/types.js.map +0 -1
  218. package/dist/esm/background-update/background-scheduler.js.map +0 -1
  219. package/dist/esm/background-update/index.js.map +0 -1
  220. package/dist/esm/background-update/notification-manager.js.map +0 -1
  221. package/dist/esm/config/support.js.map +0 -1
  222. package/dist/esm/core/analytics.js.map +0 -1
  223. package/dist/esm/core/cache-manager.js.map +0 -1
  224. package/dist/esm/core/config.js.map +0 -1
  225. package/dist/esm/core/errors.js.map +0 -1
  226. package/dist/esm/core/event-emitter.js.map +0 -1
  227. package/dist/esm/core/logger.js.map +0 -1
  228. package/dist/esm/core/performance.js.map +0 -1
  229. package/dist/esm/core/plugin-manager.js.map +0 -1
  230. package/dist/esm/core/security.js.map +0 -1
  231. package/dist/esm/definitions.js.map +0 -1
  232. package/dist/esm/firestore/firestore-client.d.ts +0 -109
  233. package/dist/esm/firestore/firestore-client.js +0 -264
  234. package/dist/esm/firestore/firestore-client.js.map +0 -1
  235. package/dist/esm/firestore/index.d.ts +0 -11
  236. package/dist/esm/firestore/index.js +0 -11
  237. package/dist/esm/firestore/index.js.map +0 -1
  238. package/dist/esm/firestore/manifest-reader.d.ts +0 -87
  239. package/dist/esm/firestore/manifest-reader.js +0 -294
  240. package/dist/esm/firestore/manifest-reader.js.map +0 -1
  241. package/dist/esm/firestore/schema.d.ts +0 -507
  242. package/dist/esm/firestore/schema.js +0 -73
  243. package/dist/esm/firestore/schema.js.map +0 -1
  244. package/dist/esm/index.js.map +0 -1
  245. package/dist/esm/live-update/bundle-manager.js.map +0 -1
  246. package/dist/esm/live-update/certificate-pinning.js.map +0 -1
  247. package/dist/esm/live-update/delta-processor.js.map +0 -1
  248. package/dist/esm/live-update/download-manager.js.map +0 -1
  249. package/dist/esm/live-update/rollout-checker.js.map +0 -1
  250. package/dist/esm/live-update/update-manager.js.map +0 -1
  251. package/dist/esm/live-update/version-manager.js.map +0 -1
  252. package/dist/esm/plugin.js.map +0 -1
  253. package/dist/esm/security/crypto.js.map +0 -1
  254. package/dist/esm/security/validator.js.map +0 -1
  255. package/dist/esm/web.js.map +0 -1
  256. package/dist/plugin.cjs.js.map +0 -1
  257. package/dist/plugin.esm.js.map +0 -1
  258. package/dist/plugin.js.map +0 -1
  259. package/docs/FIREBASE_INTEGRATION_TRACKER.md +0 -321
  260. package/docs/FIREBASE_QUERIES_AND_INDEXES_AUDIT.md +0 -221
  261. package/docs/examples/firebase-backend-example.md +0 -27
  262. package/docs/guides/no-cost-backend-implementation-plan.md +0 -77
  263. package/docs/guides/no-cost-firestore-google-drive-backend.md +0 -60
  264. package/docs/play-console-rejection-rules.json +0 -428
  265. /package/docs/{MARKETING_WEBSITE_PLAN.md → archive/MARKETING_WEBSITE_PLAN.md} +0 -0
@@ -11,6 +11,15 @@ class LiveUpdatePlugin {
11
11
  private var session: URLSession!
12
12
  private var downloadTask: URLSessionDownloadTask?
13
13
  private let securityManager = SecurityManager()
14
+
15
+ // UserDefaults keys for the A2 (boot-time re-verify) and A3
16
+ // (crash-loop auto-rollback) defenses. Mirrors the Android constants
17
+ // in LiveUpdatePlugin.kt so the two platforms stay in lockstep.
18
+ private static let activeBundleKey = "native_update_active_bundle"
19
+ private static let previousBundleKey = "native_update_previous_active_bundle"
20
+ private static let pendingBundleKey = "native_update_pending_verify_bundle"
21
+ private static let pendingAttemptsKey = "native_update_pending_verify_attempts"
22
+ private static let maxPendingAttempts = 2
14
23
 
15
24
  init(plugin: CAPPlugin) {
16
25
  self.plugin = plugin
@@ -125,76 +134,103 @@ class LiveUpdatePlugin {
125
134
  }
126
135
 
127
136
  Task {
137
+ let bundleId = "bundle-\(Date().timeIntervalSince1970)"
138
+ let downloadDir = getUpdatesDirectory().appendingPathComponent(bundleId)
139
+ var downloadSucceeded = false
140
+ defer {
141
+ // Remove the download directory if anything in download/
142
+ // verify/extract failed. Without this, every failed
143
+ // download leaks an empty bundle-<ts>/ directory that
144
+ // never gets cleaned up.
145
+ if !downloadSucceeded {
146
+ try? FileManager.default.removeItem(at: downloadDir)
147
+ }
148
+ }
128
149
  do {
129
- let bundleId = "bundle-\(Date().timeIntervalSince1970)"
130
- let downloadDir = getUpdatesDirectory().appendingPathComponent(bundleId)
131
150
  try FileManager.default.createDirectory(at: downloadDir, withIntermediateDirectories: true)
132
-
133
- // Start download
151
+
134
152
  let downloadedFile = try await downloadBundle(from: url, to: downloadDir, bundleId: bundleId)
135
-
136
- // Verify checksum
153
+
137
154
  let calculatedChecksum = try calculateChecksum(for: downloadedFile)
138
155
  if calculatedChecksum != checksum {
139
- try FileManager.default.removeItem(at: downloadedFile)
140
156
  call.reject("CHECKSUM_ERROR", "Bundle checksum validation failed")
141
157
  return
142
158
  }
143
-
144
- // Verify signature if provided
159
+
145
160
  if let signature = call.getString("signature"),
146
161
  let publicKey = (config?["security"] as? [String: Any])?["publicKey"] as? String,
147
162
  (config?["security"] as? [String: Any])?["enableSignatureValidation"] as? Bool == true {
148
-
163
+
149
164
  let fileData = try Data(contentsOf: downloadedFile)
150
165
  let securityManager = SecurityManager()
151
-
166
+
152
167
  if !securityManager.verifySignature(data: fileData, signature: signature, publicKeyString: publicKey) {
153
- try FileManager.default.removeItem(at: downloadedFile)
154
168
  call.reject("SIGNATURE_ERROR", "Bundle signature validation failed")
155
169
  return
156
170
  }
157
171
  }
158
-
159
- // Create bundle info
160
- let bundleInfo: [String: Any] = [
172
+
173
+ // Extract immediately so the bundle is bootable, but DO
174
+ // NOT swap the WebView's serverBasePath here. set() does
175
+ // that. This matches Android's contract — download stages
176
+ // a verified bundle, set() activates it.
177
+ try extractZipBundleForBundle(bundleId: bundleId, zipUrl: downloadedFile)
178
+
179
+ let extractedPath = getUpdatesDirectory()
180
+ .appendingPathComponent(bundleId)
181
+ .appendingPathComponent("www")
182
+ .path
183
+
184
+ var bundleInfo: [String: Any] = [
161
185
  "bundleId": bundleId,
162
186
  "version": version,
163
187
  "path": downloadedFile.path,
188
+ "extractedPath": extractedPath,
164
189
  "downloadTime": Date().timeIntervalSince1970 * 1000,
165
190
  "size": try FileManager.default.attributesOfItem(atPath: downloadedFile.path)[.size] ?? 0,
166
191
  "status": "READY",
167
192
  "checksum": checksum,
168
193
  "verified": true
169
194
  ]
170
-
171
- // Save bundle info
195
+ if let signature = call.getString("signature") {
196
+ bundleInfo["signature"] = signature
197
+ }
172
198
  saveBundleInfo(bundleInfo)
173
-
174
- // Extract the bundle
175
- try extractAndApplyBundle(downloadedFile, bundleId: bundleId)
176
-
177
- // Notify state change
199
+
178
200
  stateChangeListener?([
179
201
  "status": "READY",
180
202
  "bundleId": bundleId,
181
203
  "version": version
182
204
  ])
183
-
205
+
206
+ downloadSucceeded = true
184
207
  call.resolve(bundleInfo)
185
208
  } catch {
186
209
  call.reject("DOWNLOAD_ERROR", error.localizedDescription)
187
210
  }
188
211
  }
189
212
  }
190
-
213
+
191
214
  func set(_ call: CAPPluginCall) {
192
215
  guard let bundleId = call.getString("bundleId") else {
193
216
  call.reject("Bundle ID is required")
194
217
  return
195
218
  }
196
-
219
+
220
+ // Activation = update the active-bundle pointer AND swap the
221
+ // WebView's serverBasePath. Previously the swap happened in
222
+ // download(), which auto-activated bundles before the host app
223
+ // had a chance to call set(). That broke parity with Android
224
+ // and meant a partially-completed download could end up live.
225
+ let bundles = UserDefaults.standard.dictionary(forKey: "native_update_bundles") ?? [:]
226
+ guard let bundleInfo = bundles[bundleId] as? [String: Any],
227
+ let extractedPath = bundleInfo["extractedPath"] as? String else {
228
+ call.reject("Bundle not found or not extracted: \(bundleId)")
229
+ return
230
+ }
231
+
197
232
  setActiveBundle(bundleId)
233
+ configureWebViewPath(extractedPath)
198
234
  call.resolve()
199
235
  }
200
236
 
@@ -467,7 +503,22 @@ class LiveUpdatePlugin {
467
503
  }
468
504
 
469
505
  private func setActiveBundle(_ bundleId: String) {
470
- UserDefaults.standard.set(bundleId, forKey: "native_update_active_bundle")
506
+ let defaults = UserDefaults.standard
507
+ let currentActive = defaults.string(forKey: Self.activeBundleKey)
508
+
509
+ defaults.set(bundleId, forKey: Self.activeBundleKey)
510
+
511
+ // Preserve the previous active bundle as a rollback target. Skip
512
+ // self-activation so we don't nuke a real previous-bundle pointer.
513
+ if let previous = currentActive, previous != bundleId {
514
+ defaults.set(previous, forKey: Self.previousBundleKey)
515
+ }
516
+
517
+ // Mark this activation as pending-verify. notifyAppReady() clears
518
+ // it; otherwise the next cold start counts against the bundle and
519
+ // eventually rolls back to the previous known-good bundle.
520
+ defaults.set(bundleId, forKey: Self.pendingBundleKey)
521
+ defaults.set(0, forKey: Self.pendingAttemptsKey)
471
522
  }
472
523
 
473
524
  private func clearAllBundles() {
@@ -542,46 +593,249 @@ class LiveUpdatePlugin {
542
593
  }
543
594
 
544
595
  private func markBundleAsVerified() {
545
- UserDefaults.standard.set(true, forKey: "native_update_current_bundle_verified")
596
+ // notifyAppReady() is the host app's "I booted clean on this bundle"
597
+ // signal. Clear the pending-verify state so cold starts after this
598
+ // no longer count against the bundle.
599
+ let defaults = UserDefaults.standard
600
+ defaults.set(true, forKey: "native_update_current_bundle_verified")
601
+ defaults.removeObject(forKey: Self.pendingBundleKey)
602
+ defaults.removeObject(forKey: Self.pendingAttemptsKey)
546
603
  }
547
-
548
- // MARK: - Bundle Extraction and WebView Configuration
549
-
550
- private func extractAndApplyBundle(_ bundleUrl: URL, bundleId: String) throws {
551
- let extractedPath = getUpdatesDirectory().appendingPathComponent(bundleId).appendingPathComponent("www")
552
604
 
553
- do {
554
- // Extract the zip bundle
555
- try extractZipBundle(from: bundleUrl, to: extractedPath)
605
+ /// Called from NativeUpdatePlugin.load() on every cold start. Runs two
606
+ /// independent defenses, both silent when no OTA bundle is active:
607
+ ///
608
+ /// A2 — re-hash the active bundle and verify it still matches the
609
+ /// checksum stored at install time. If signature + publicKey are
610
+ /// configured, also re-verify the signature. Catches on-disk
611
+ /// tampering on jailbroken devices.
612
+ /// A3 — if the active bundle was never confirmed by notifyAppReady()
613
+ /// on the previous launch, treat this cold start as a failed
614
+ /// boot. After maxPendingAttempts failures, rollback to the
615
+ /// previous known-good bundle to break the crash loop.
616
+ ///
617
+ /// On any failure the method rolls back and fires an
618
+ /// updateStateChanged event with status "ROLLBACK" so the host app
619
+ /// and analytics layer can observe it.
620
+ func verifyActiveBundleOnBoot() {
621
+ let defaults = UserDefaults.standard
556
622
 
557
- // Update bundle info with extracted path
558
- var bundleInfo = getAllBundles().first { $0["bundleId"] as? String == bundleId } ?? [:]
559
- bundleInfo["extractedPath"] = extractedPath.path
560
- bundleInfo["status"] = "READY"
561
- saveBundleInfo(bundleInfo)
623
+ // A3: crash-loop detection.
624
+ if let pendingBundleId = defaults.string(forKey: Self.pendingBundleKey) {
625
+ let nextAttempt = defaults.integer(forKey: Self.pendingAttemptsKey) + 1
626
+ if nextAttempt > Self.maxPendingAttempts {
627
+ rollbackToPrevious(
628
+ reason: "auto-rollback after \(nextAttempt) failed cold starts",
629
+ fromBundleId: pendingBundleId
630
+ )
631
+ defaults.removeObject(forKey: Self.pendingBundleKey)
632
+ defaults.removeObject(forKey: Self.pendingAttemptsKey)
633
+ return
634
+ }
635
+ defaults.set(nextAttempt, forKey: Self.pendingAttemptsKey)
636
+ }
637
+
638
+ // A2: integrity re-verify. Only for non-default active bundles.
639
+ guard let activeBundleId = defaults.string(forKey: Self.activeBundleKey),
640
+ activeBundleId != "default" else {
641
+ return
642
+ }
643
+
644
+ guard let bundleInfo = getBundleInfoById(activeBundleId) else {
645
+ rollbackToPrevious(
646
+ reason: "active bundle info missing from UserDefaults",
647
+ fromBundleId: activeBundleId
648
+ )
649
+ return
650
+ }
651
+
652
+ guard let bundlePath = bundleInfo["path"] as? String, !bundlePath.isEmpty,
653
+ let expectedChecksum = bundleInfo["checksum"] as? String, !expectedChecksum.isEmpty else {
654
+ return
655
+ }
562
656
 
563
- // Configure WebView to use new path
564
- configureWebViewPath(extractedPath.path)
657
+ let bundleURL = URL(fileURLWithPath: bundlePath)
658
+ guard FileManager.default.fileExists(atPath: bundlePath) else {
659
+ rollbackToPrevious(
660
+ reason: "bundle file missing on disk",
661
+ fromBundleId: activeBundleId
662
+ )
663
+ return
664
+ }
665
+
666
+ do {
667
+ let actualChecksum = try calculateChecksum(for: bundleURL)
668
+ if actualChecksum != expectedChecksum {
669
+ rollbackToPrevious(
670
+ reason: "bundle checksum mismatch on boot",
671
+ fromBundleId: activeBundleId
672
+ )
673
+ return
674
+ }
565
675
  } catch {
566
- // Get the safe bundle path (current working bundle)
567
- let safePath = UserDefaults.standard.string(forKey: "native_update_webview_path") ?? "/"
676
+ rollbackToPrevious(
677
+ reason: "bundle checksum computation failed: \(error.localizedDescription)",
678
+ fromBundleId: activeBundleId
679
+ )
680
+ return
681
+ }
568
682
 
569
- // Rollback to safe bundle
570
- try? rollbackToSafeBundle(currentPath: extractedPath.path, safePath: safePath)
683
+ // Signature re-verify when both a signature and a public key are
684
+ // available. Absence of either is treated as "not configured" and
685
+ // skipped — Phase A1 already enforces fail-closed at download time.
686
+ if let signature = bundleInfo["signature"] as? String, !signature.isEmpty,
687
+ let publicKey = (config?["security"] as? [String: Any])?["publicKey"] as? String,
688
+ !publicKey.isEmpty {
689
+ do {
690
+ let bytes = try Data(contentsOf: bundleURL)
691
+ if !securityManager.verifySignature(
692
+ data: bytes,
693
+ signature: signature,
694
+ publicKeyString: publicKey
695
+ ) {
696
+ rollbackToPrevious(
697
+ reason: "bundle signature mismatch on boot",
698
+ fromBundleId: activeBundleId
699
+ )
700
+ }
701
+ } catch {
702
+ rollbackToPrevious(
703
+ reason: "bundle signature check failed: \(error.localizedDescription)",
704
+ fromBundleId: activeBundleId
705
+ )
706
+ }
707
+ }
708
+ }
571
709
 
572
- // Re-throw the error
573
- throw error
710
+ private func rollbackToPrevious(reason: String, fromBundleId: String) {
711
+ let defaults = UserDefaults.standard
712
+ let previous = defaults.string(forKey: Self.previousBundleKey)
713
+ if let previous = previous {
714
+ defaults.set(previous, forKey: Self.activeBundleKey)
715
+ } else {
716
+ defaults.removeObject(forKey: Self.activeBundleKey)
574
717
  }
718
+ defaults.removeObject(forKey: Self.previousBundleKey)
719
+
720
+ stateChangeListener?([
721
+ "status": "ROLLBACK",
722
+ "bundleId": fromBundleId,
723
+ "rolledBackTo": previous ?? "default",
724
+ "reason": reason
725
+ ])
726
+ }
727
+
728
+ private func getBundleInfoById(_ bundleId: String) -> [String: Any]? {
729
+ let defaults = UserDefaults.standard
730
+ let bundles = defaults.dictionary(forKey: "native_update_bundles") ?? [:]
731
+ return bundles[bundleId] as? [String: Any]
575
732
  }
576
733
 
734
+ // MARK: - Bundle Extraction and WebView Configuration
735
+
736
+ /// Extract a downloaded zip into `bundles/{bundleId}/www/`. Does NOT
737
+ /// touch `setServerBasePath` — activation is owned by set().
738
+ private func extractZipBundleForBundle(bundleId: String, zipUrl: URL) throws {
739
+ let extractedPath = getUpdatesDirectory()
740
+ .appendingPathComponent(bundleId)
741
+ .appendingPathComponent("www")
742
+ do {
743
+ try extractZipBundle(from: zipUrl, to: extractedPath)
744
+ } catch {
745
+ // Roll back the partial extraction so a re-download is clean.
746
+ try? FileManager.default.removeItem(at: extractedPath)
747
+ throw error
748
+ }
749
+ }
750
+
751
+ private func extractAndApplyBundle(_ bundleUrl: URL, bundleId: String) throws {
752
+ // Legacy path retained for any caller still using it. Extracts
753
+ // and immediately activates — equivalent to the old behavior.
754
+ try extractZipBundleForBundle(bundleId: bundleId, zipUrl: bundleUrl)
755
+ let extractedPath = getUpdatesDirectory()
756
+ .appendingPathComponent(bundleId)
757
+ .appendingPathComponent("www")
758
+ var bundleInfo = getAllBundles().first { $0["bundleId"] as? String == bundleId } ?? [:]
759
+ bundleInfo["extractedPath"] = extractedPath.path
760
+ bundleInfo["status"] = "READY"
761
+ saveBundleInfo(bundleInfo)
762
+ configureWebViewPath(extractedPath.path)
763
+ }
764
+
765
+ /// Hard cap on total uncompressed size of an OTA bundle. Protects
766
+ /// against zip-bombs (a small zip that expands to gigabytes and fills
767
+ /// the device). Chosen as 5x the default 100MB downloaded-bundle limit
768
+ /// — enough headroom for typical web-app bundles after minification
769
+ /// reverses, below a threshold that would be hostile on mobile.
770
+ private static let maxUncompressedBundleBytes: UInt64 = 500 * 1024 * 1024
771
+
577
772
  private func extractZipBundle(from zipUrl: URL, to destinationUrl: URL) throws {
578
- // Ensure destination directory exists
773
+ // Ensure destination directory exists.
579
774
  try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true, attributes: nil)
580
775
 
581
- // Extract the ZIP archive using ZIPFoundation
776
+ // Inspect every entry before extracting anything. Three defenses:
777
+ // 1. zip-slip — an entry whose destination path resolves outside
778
+ // the intended directory would let a malicious bundle write
779
+ // over any file in the app sandbox (e.g. overwriting
780
+ // built-in code) when unzipItem later runs.
781
+ // 2. symlink attack — symlinks in the archive can redirect
782
+ // subsequent writes outside the sandbox even if the entry
783
+ // itself resolves cleanly.
784
+ // 3. zip-bomb — sum of uncompressed sizes capped; refuses to
785
+ // extract an archive that would exhaust device storage.
786
+ guard let archive = Archive(url: zipUrl, accessMode: .read) else {
787
+ throw NSError(domain: "LiveUpdatePlugin", code: 7, userInfo: [
788
+ NSLocalizedDescriptionKey: "Could not open bundle archive for inspection"
789
+ ])
790
+ }
791
+
792
+ let canonicalDestination = destinationUrl
793
+ .standardizedFileURL
794
+ .resolvingSymlinksInPath()
795
+ .path
796
+ let destinationPrefix = canonicalDestination.hasSuffix("/")
797
+ ? canonicalDestination
798
+ : canonicalDestination + "/"
799
+
800
+ var totalUncompressed: UInt64 = 0
801
+
802
+ for entry in archive {
803
+ if entry.type == .symlink {
804
+ throw NSError(domain: "LiveUpdatePlugin", code: 8, userInfo: [
805
+ NSLocalizedDescriptionKey: "Bundle contains symlink entry (refused): \(entry.path)"
806
+ ])
807
+ }
808
+
809
+ if entry.path.hasPrefix("/") {
810
+ throw NSError(domain: "LiveUpdatePlugin", code: 9, userInfo: [
811
+ NSLocalizedDescriptionKey: "Bundle entry has absolute path (refused): \(entry.path)"
812
+ ])
813
+ }
814
+
815
+ let entryURL = destinationUrl
816
+ .appendingPathComponent(entry.path)
817
+ .standardizedFileURL
818
+ let entryPath = entryURL.resolvingSymlinksInPath().path
819
+
820
+ if entryPath != canonicalDestination && !entryPath.hasPrefix(destinationPrefix) {
821
+ throw NSError(domain: "LiveUpdatePlugin", code: 10, userInfo: [
822
+ NSLocalizedDescriptionKey: "Bundle entry escapes destination (zip-slip refused): \(entry.path)"
823
+ ])
824
+ }
825
+
826
+ let (newTotal, overflow) = totalUncompressed.addingReportingOverflow(entry.uncompressedSize)
827
+ if overflow || newTotal > Self.maxUncompressedBundleBytes {
828
+ throw NSError(domain: "LiveUpdatePlugin", code: 11, userInfo: [
829
+ NSLocalizedDescriptionKey: "Bundle exceeds maximum uncompressed size (\(Self.maxUncompressedBundleBytes) bytes) — possible zip-bomb"
830
+ ])
831
+ }
832
+ totalUncompressed = newTotal
833
+ }
834
+
835
+ // All entries passed inspection — safe to extract.
582
836
  try FileManager.default.unzipItem(at: zipUrl, to: destinationUrl)
583
837
 
584
- // Verify extraction succeeded by checking for index.html
838
+ // Verify extraction succeeded by checking for index.html.
585
839
  let indexPath = destinationUrl.appendingPathComponent("index.html")
586
840
  guard FileManager.default.fileExists(atPath: indexPath.path) else {
587
841
  throw NSError(domain: "LiveUpdatePlugin", code: 6, userInfo: [
@@ -589,7 +843,7 @@ class LiveUpdatePlugin {
589
843
  ])
590
844
  }
591
845
 
592
- // Clean up the ZIP file after successful extraction
846
+ // Clean up the ZIP file after successful extraction.
593
847
  try? FileManager.default.removeItem(at: zipUrl)
594
848
  }
595
849
 
@@ -33,6 +33,14 @@ public class NativeUpdatePlugin: CAPPlugin {
33
33
  appUpdatePlugin.setEventListener { [weak self] eventName, data in
34
34
  self?.notifyListeners(eventName, data: data)
35
35
  }
36
+
37
+ // Boot-time integrity re-verify + crash-loop auto-rollback. Runs
38
+ // before any host-app code touches the WebView so a tampered or
39
+ // crash-looping bundle never gets a chance to load. Signature
40
+ // re-verify runs again after configure() when the publicKey
41
+ // arrives from the host app; checksum re-verify here is
42
+ // sufficient to catch most tampering.
43
+ liveUpdatePlugin.verifyActiveBundleOnBoot()
36
44
  }
37
45
 
38
46
  @objc func initialize(_ call: CAPPluginCall) {
@@ -60,16 +68,21 @@ public class NativeUpdatePlugin: CAPPlugin {
60
68
  }
61
69
 
62
70
  @objc func cleanup(_ call: CAPPluginCall) {
63
- // Clean up any resources
64
71
  liveUpdatePlugin.cleanup()
65
- // Disable background updates through the plugin method
66
- let disableCall = CAPPluginCall(
67
- callbackId: "cleanup_disable",
68
- options: [:],
69
- success: { _ in },
70
- error: { _ in }
71
- )
72
- backgroundUpdatePlugin.disableBackgroundUpdates(disableCall)
72
+ // CAPACITOR-SDK-WORKAROUND (assumes Capacitor 8.x):
73
+ // We cannot manufacture a synthetic CAPPluginCall to forward into
74
+ // backgroundUpdatePlugin.disableBackgroundUpdates(_:) because
75
+ // CAPPluginCall(callbackId:options:success:error:) is `internal`
76
+ // in the public Capacitor SDK — referencing it produces a
77
+ // compile error against the shipped framework. The internal
78
+ // `disableBackgroundUpdatesInternal()` shim wraps the same
79
+ // teardown logic and is the ONLY entry point that doesn't
80
+ // require a CAPPluginCall.
81
+ //
82
+ // Revisit on every Capacitor major bump: if the constructor
83
+ // becomes public, replace the shim with a real synthetic call.
84
+ // Tracked in docs/project-audit/PRODUCTION-READINESS-AUDIT-2026-05-08.md (item I2).
85
+ backgroundUpdatePlugin.disableBackgroundUpdatesInternal()
73
86
  call.resolve()
74
87
  }
75
88
 
@@ -25,7 +25,8 @@ class SecurityManager {
25
25
  "pins": getCertificatePins()
26
26
  ],
27
27
  "validateInputs": config?["validateInputs"] as? Bool ?? true,
28
- "secureStorage": config?["secureStorage"] as? Bool ?? true
28
+ // Hard-coded true since v2 — secure storage is no longer opt-out.
29
+ "secureStorage": true
29
30
  ]
30
31
  }
31
32
 
@@ -142,21 +143,17 @@ class SecurityManager {
142
143
  return digest.map { String(format: "%02x", $0) }.joined()
143
144
  }
144
145
 
146
+ /// Writes always land in the Keychain since v2. The old
147
+ /// `secureStorage: false` opt-out was removed because the default
148
+ /// ran in plaintext UserDefaults unless a host app explicitly opted
149
+ /// in — which meant most integrations stored API keys in a trivially
150
+ /// readable location. See matching change in SecurityManager.kt.
145
151
  func saveSecureData(key: String, value: String) {
146
- if isSecureStorageEnabled() {
147
- keychain.set(value, forKey: key)
148
- } else {
149
- // Fallback to UserDefaults (not recommended)
150
- UserDefaults.standard.set(value, forKey: key)
151
- }
152
+ keychain.set(value, forKey: key)
152
153
  }
153
-
154
+
154
155
  func getSecureData(key: String) -> String? {
155
- if isSecureStorageEnabled() {
156
- return keychain.string(forKey: key)
157
- } else {
158
- return UserDefaults.standard.string(forKey: key)
159
- }
156
+ return keychain.string(forKey: key)
160
157
  }
161
158
 
162
159
  func validatePath(_ path: String) -> Bool {
@@ -189,8 +186,10 @@ class SecurityManager {
189
186
  return config?["enforceHttps"] as? Bool ?? true
190
187
  }
191
188
 
189
+ /// Unconditionally true since v2. Kept so `getSecurityInfo()` still
190
+ /// reports the capability to callers.
192
191
  func isSecureStorageEnabled() -> Bool {
193
- return config?["secureStorage"] as? Bool ?? true
192
+ return true
194
193
  }
195
194
 
196
195
  func isInputValidationEnabled() -> Bool {