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.
- package/Readme.md +35 -9
- package/android/manifest-additions.xml +28 -0
- package/android/src/main/AndroidManifest.xml +3 -7
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundNotificationManager.kt +21 -13
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdatePlugin.kt +15 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateWorker.kt +23 -7
- package/android/src/main/java/com/aoneahsan/nativeupdate/LiveUpdatePlugin.kt +346 -31
- package/android/src/main/java/com/aoneahsan/nativeupdate/NativeUpdatePlugin.kt +32 -34
- package/android/src/main/java/com/aoneahsan/nativeupdate/NotificationActionReceiver.kt +10 -1
- package/android/src/main/java/com/aoneahsan/nativeupdate/SecurityManager.kt +18 -18
- package/cli/AGENTS.md +43 -0
- package/cli/CLAUDE.md +65 -0
- package/cli/commands/backend-create.js +56 -15
- package/cli/commands/bundle-create.js +39 -7
- package/cli/commands/monitor.js +72 -51
- package/cli/index.js +10 -24
- package/cli/package.json +11 -3
- package/dist/esm/app-review/app-review-manager.js +0 -1
- package/dist/esm/app-review/index.js +0 -1
- package/dist/esm/app-review/platform-review-handler.js +0 -1
- package/dist/esm/app-review/review-conditions-checker.js +0 -1
- package/dist/esm/app-review/review-rate-limiter.js +0 -1
- package/dist/esm/app-review/types.js +0 -1
- package/dist/esm/app-update/app-update-checker.js +0 -1
- package/dist/esm/app-update/app-update-installer.d.ts +1 -0
- package/dist/esm/app-update/app-update-installer.js +19 -8
- package/dist/esm/app-update/app-update-manager.js +0 -1
- package/dist/esm/app-update/app-update-notifier.js +0 -1
- package/dist/esm/app-update/index.js +0 -1
- package/dist/esm/app-update/platform-app-update.js +12 -3
- package/dist/esm/app-update/types.js +0 -1
- package/dist/esm/background-update/background-scheduler.js +0 -1
- package/dist/esm/background-update/index.js +0 -1
- package/dist/esm/background-update/notification-manager.js +0 -1
- package/dist/esm/config/support.js +0 -1
- package/dist/esm/core/analytics.d.ts +13 -0
- package/dist/esm/core/analytics.js +15 -1
- package/dist/esm/core/cache-manager.js +0 -1
- package/dist/esm/core/config.d.ts +6 -30
- package/dist/esm/core/config.js +1 -8
- package/dist/esm/core/errors.d.ts +1 -0
- package/dist/esm/core/errors.js +1 -1
- package/dist/esm/core/event-emitter.d.ts +0 -3
- package/dist/esm/core/event-emitter.js +8 -3
- package/dist/esm/core/logger.js +0 -1
- package/dist/esm/core/performance.js +0 -1
- package/dist/esm/core/plugin-manager.js +3 -1
- package/dist/esm/core/security.d.ts +22 -3
- package/dist/esm/core/security.js +46 -7
- package/dist/esm/definitions.d.ts +13 -40
- package/dist/esm/definitions.js +0 -1
- package/dist/esm/index.d.ts +3 -4
- package/dist/esm/index.js +3 -3
- package/dist/esm/live-update/bundle-manager.d.ts +8 -0
- package/dist/esm/live-update/bundle-manager.js +20 -1
- package/dist/esm/live-update/certificate-pinning.js +0 -1
- package/dist/esm/live-update/delta-processor.d.ts +1 -1
- package/dist/esm/live-update/delta-processor.js +3 -1
- package/dist/esm/live-update/download-manager.d.ts +36 -5
- package/dist/esm/live-update/download-manager.js +70 -24
- package/dist/esm/live-update/rollout-checker.d.ts +1 -1
- package/dist/esm/live-update/rollout-checker.js +5 -6
- package/dist/esm/live-update/update-manager.d.ts +12 -1
- package/dist/esm/live-update/update-manager.js +38 -11
- package/dist/esm/live-update/version-manager.d.ts +9 -12
- package/dist/esm/live-update/version-manager.js +40 -68
- package/dist/esm/plugin.js +82 -89
- package/dist/esm/security/crypto.js +11 -7
- package/dist/esm/security/validator.js +0 -1
- package/dist/esm/types/rollout.d.ts +85 -0
- package/dist/esm/types/rollout.js +25 -0
- package/dist/esm/web.d.ts +26 -1
- package/dist/esm/web.js +214 -75
- package/dist/plugin.cjs.js +1 -2
- package/dist/plugin.esm.js +1 -2
- package/dist/plugin.js +2 -3
- package/docs/AGENTS.md +38 -0
- package/docs/CHANGELOG.md +275 -0
- package/docs/CLAUDE.md +101 -0
- package/docs/MANUAL-TASKS.md +17 -0
- package/docs/MARKETING_WEBSITE_TRACKER.md +18 -2
- package/docs/MIGRATION.md +170 -0
- package/docs/PACKAGES.md +167 -0
- package/docs/PERFORMANCE.md +110 -0
- package/docs/PROJECT_COMPLETION_TRACKER.md +4 -35
- package/docs/README.md +12 -2
- package/docs/REMAINING_FEATURES.md +123 -76
- package/docs/ROADMAP.md +200 -120
- package/docs/SECURITY-IF-REPO-PUBLIC.md +232 -0
- package/docs/TESTING_REQUIREMENTS.md +1 -112
- package/docs/ai-knowledge-base/00-project-profile.md +38 -0
- package/docs/ai-knowledge-base/01-features.md +46 -0
- package/docs/ai-knowledge-base/02-routes.md +72 -0
- package/docs/ai-knowledge-base/03-tech-stack.md +57 -0
- package/docs/ai-knowledge-base/README.md +23 -0
- package/docs/analytics-tracking.md +37 -0
- package/docs/capacitor-official-plugins.md +46 -0
- package/docs/capawesome-plugins.md +29 -0
- package/docs/deployment/HOSTINGER_DEPLOY.md +370 -0
- package/docs/deployment/PRODUCTION-DEPLOY-CHECKLIST.md +184 -0
- package/docs/docs-site/plan.md +201 -0
- package/docs/docs-site/scope.md +127 -0
- package/docs/docs-site/tracker.json +235 -0
- package/docs/error-handling-tracking.md +37 -0
- package/docs/features/laravel-nova-backend/ASSESSMENT-SUMMARY.md +96 -0
- package/docs/features/laravel-nova-backend/IMPLEMENTATION-PLAN.md +504 -0
- package/docs/features/laravel-nova-backend/progress-tracker.json +184 -0
- package/docs/guides/testing-guide.md +15 -41
- package/docs/package-audit.md +41 -0
- package/docs/play-store/ASO-METADATA.md +5 -0
- package/docs/play-store/DATA-SAFETY.md +5 -0
- package/docs/play-store/DECLARATIONS.md +5 -0
- package/docs/play-store/README.md +27 -0
- package/docs/play-store/RELEASE-NOTES.md +99 -0
- package/docs/play-store/RELEASE-STEPS.md +6 -0
- package/docs/play-store/STORE-LISTING.md +7 -0
- package/docs/play-store/SUBMISSION-CHECKLIST.md +5 -0
- package/docs/production-readiness.md +1 -1
- package/docs/project-audit/2026-05-16/00-FINALIZATION-PLAN.md +123 -0
- package/docs/project-audit/2026-05-16/01-BATCH-PLAN.md +227 -0
- package/docs/project-audit/2026-05-16/02-AUDIT-FINDINGS.md +144 -0
- package/docs/project-audit/2026-05-16/sessions/batch-01.md +42 -0
- package/docs/project-audit/2026-05-16/sessions/batch-02.md +74 -0
- package/docs/project-audit/2026-05-16/sessions/batch-03.md +71 -0
- package/docs/project-audit/2026-05-16/sessions/batch-04.md +88 -0
- package/docs/project-audit/2026-05-16/sessions/batch-05.md +63 -0
- package/docs/project-audit/2026-05-16/sessions/batch-06.md +103 -0
- package/docs/project-audit/2026-05-16/sessions/batch-07.md +61 -0
- package/docs/project-audit/2026-05-16/sessions/batch-08.md +101 -0
- package/docs/project-audit/2026-05-16/sessions/batch-09.md +105 -0
- package/docs/project-audit/2026-05-16/sessions/batch-10.md +102 -0
- package/docs/project-audit/AUDIT-HISTORY.md +74 -0
- package/docs/project-audit/BATCH-PLAN.md +168 -0
- package/docs/project-audit/MARKETING-WEBSITE-FINDINGS-2026-05-11.md +73 -0
- package/docs/project-audit/PRODUCTION-READINESS-AUDIT-2026-05-08.md +275 -0
- package/docs/project-finalization/00-tracker.json +60 -0
- package/docs/project-knowledge-base/01-system-overview.md +227 -0
- package/docs/project-knowledge-base/02-routes-pages-forms-users.md +352 -0
- package/docs/project-knowledge-base/03-tech-stack-modules-services.md +379 -0
- package/docs/project-knowledge-base/04-data-models-integrations.md +244 -0
- package/docs/project-knowledge-base/05-docs-corpus-inventory.md +256 -0
- package/docs/project-knowledge-base/06-operations-testing-legal-content.md +208 -0
- package/docs/project-knowledge-base/README.md +103 -0
- package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-05-19.md +81 -0
- package/docs/rules-tracker.md +102 -0
- package/docs/security/PRE-LAUNCH-PENTEST-CHECKLIST.md +116 -0
- package/docs/seo-aeo-rules.json +3043 -0
- package/docs/social-media-marketing/README.md +81 -0
- package/docs/social-media-marketing/posts-batch-01.md +613 -0
- package/docs/social-media-marketing/posts-batch-02.md +528 -0
- package/docs/social-media-marketing/whatsapp-messages-batch-01.md +458 -0
- package/docs/social-media-marketing/whatsapp-status-batch-01.md +355 -0
- package/docs/tracking/finalization-2026-05-16-tracker.json +346 -0
- package/docs/tracking/full-audit-tracker.json +121 -0
- package/docs/tracking/production-readiness-audit-tracker.json +429 -0
- package/docs/tracking/seo-checklist-tracker.json +333 -0
- package/ios/Plugin/BackgroundUpdate/BackgroundUpdatePlugin.swift +66 -9
- package/ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift +306 -52
- package/ios/Plugin/NativeUpdatePlugin.swift +22 -9
- package/ios/Plugin/Security/SecurityManager.swift +13 -14
- package/package.json +37 -38
- package/cli/node_modules/.yarn-integrity +0 -16
- package/cli/node_modules/commander/LICENSE +0 -22
- package/cli/node_modules/commander/Readme.md +0 -1148
- package/cli/node_modules/commander/esm.mjs +0 -16
- package/cli/node_modules/commander/index.js +0 -26
- package/cli/node_modules/commander/lib/argument.js +0 -145
- package/cli/node_modules/commander/lib/command.js +0 -2179
- package/cli/node_modules/commander/lib/error.js +0 -43
- package/cli/node_modules/commander/lib/help.js +0 -462
- package/cli/node_modules/commander/lib/option.js +0 -329
- package/cli/node_modules/commander/lib/suggestSimilar.js +0 -100
- package/cli/node_modules/commander/package-support.json +0 -16
- package/cli/node_modules/commander/package.json +0 -80
- package/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/cli/node_modules/commander/typings/index.d.ts +0 -884
- package/cli/yarn.lock +0 -8
- package/dist/esm/__tests__/bundle-manager.test.d.ts +0 -1
- package/dist/esm/__tests__/bundle-manager.test.js +0 -151
- package/dist/esm/__tests__/bundle-manager.test.js.map +0 -1
- package/dist/esm/__tests__/config.test.d.ts +0 -1
- package/dist/esm/__tests__/config.test.js +0 -70
- package/dist/esm/__tests__/config.test.js.map +0 -1
- package/dist/esm/__tests__/delta-processor.test.d.ts +0 -1
- package/dist/esm/__tests__/delta-processor.test.js +0 -77
- package/dist/esm/__tests__/delta-processor.test.js.map +0 -1
- package/dist/esm/__tests__/firestore-schema.test.d.ts +0 -1
- package/dist/esm/__tests__/firestore-schema.test.js +0 -74
- package/dist/esm/__tests__/firestore-schema.test.js.map +0 -1
- package/dist/esm/__tests__/integration.test.d.ts +0 -1
- package/dist/esm/__tests__/integration.test.js +0 -78
- package/dist/esm/__tests__/integration.test.js.map +0 -1
- package/dist/esm/__tests__/manifest-reader.test.d.ts +0 -1
- package/dist/esm/__tests__/manifest-reader.test.js +0 -271
- package/dist/esm/__tests__/manifest-reader.test.js.map +0 -1
- package/dist/esm/__tests__/rollout-checker.test.d.ts +0 -1
- package/dist/esm/__tests__/rollout-checker.test.js +0 -210
- package/dist/esm/__tests__/rollout-checker.test.js.map +0 -1
- package/dist/esm/__tests__/security.test.d.ts +0 -1
- package/dist/esm/__tests__/security.test.js +0 -54
- package/dist/esm/__tests__/security.test.js.map +0 -1
- package/dist/esm/__tests__/version-manager.test.d.ts +0 -1
- package/dist/esm/__tests__/version-manager.test.js +0 -45
- package/dist/esm/__tests__/version-manager.test.js.map +0 -1
- package/dist/esm/app-review/app-review-manager.js.map +0 -1
- package/dist/esm/app-review/index.js.map +0 -1
- package/dist/esm/app-review/platform-review-handler.js.map +0 -1
- package/dist/esm/app-review/review-conditions-checker.js.map +0 -1
- package/dist/esm/app-review/review-rate-limiter.js.map +0 -1
- package/dist/esm/app-review/types.js.map +0 -1
- package/dist/esm/app-update/app-update-checker.js.map +0 -1
- package/dist/esm/app-update/app-update-installer.js.map +0 -1
- package/dist/esm/app-update/app-update-manager.js.map +0 -1
- package/dist/esm/app-update/app-update-notifier.js.map +0 -1
- package/dist/esm/app-update/index.js.map +0 -1
- package/dist/esm/app-update/platform-app-update.js.map +0 -1
- package/dist/esm/app-update/types.js.map +0 -1
- package/dist/esm/background-update/background-scheduler.js.map +0 -1
- package/dist/esm/background-update/index.js.map +0 -1
- package/dist/esm/background-update/notification-manager.js.map +0 -1
- package/dist/esm/config/support.js.map +0 -1
- package/dist/esm/core/analytics.js.map +0 -1
- package/dist/esm/core/cache-manager.js.map +0 -1
- package/dist/esm/core/config.js.map +0 -1
- package/dist/esm/core/errors.js.map +0 -1
- package/dist/esm/core/event-emitter.js.map +0 -1
- package/dist/esm/core/logger.js.map +0 -1
- package/dist/esm/core/performance.js.map +0 -1
- package/dist/esm/core/plugin-manager.js.map +0 -1
- package/dist/esm/core/security.js.map +0 -1
- package/dist/esm/definitions.js.map +0 -1
- package/dist/esm/firestore/firestore-client.d.ts +0 -109
- package/dist/esm/firestore/firestore-client.js +0 -264
- package/dist/esm/firestore/firestore-client.js.map +0 -1
- package/dist/esm/firestore/index.d.ts +0 -11
- package/dist/esm/firestore/index.js +0 -11
- package/dist/esm/firestore/index.js.map +0 -1
- package/dist/esm/firestore/manifest-reader.d.ts +0 -87
- package/dist/esm/firestore/manifest-reader.js +0 -294
- package/dist/esm/firestore/manifest-reader.js.map +0 -1
- package/dist/esm/firestore/schema.d.ts +0 -507
- package/dist/esm/firestore/schema.js +0 -73
- package/dist/esm/firestore/schema.js.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/live-update/bundle-manager.js.map +0 -1
- package/dist/esm/live-update/certificate-pinning.js.map +0 -1
- package/dist/esm/live-update/delta-processor.js.map +0 -1
- package/dist/esm/live-update/download-manager.js.map +0 -1
- package/dist/esm/live-update/rollout-checker.js.map +0 -1
- package/dist/esm/live-update/update-manager.js.map +0 -1
- package/dist/esm/live-update/version-manager.js.map +0 -1
- package/dist/esm/plugin.js.map +0 -1
- package/dist/esm/security/crypto.js.map +0 -1
- package/dist/esm/security/validator.js.map +0 -1
- package/dist/esm/web.js.map +0 -1
- package/dist/plugin.cjs.js.map +0 -1
- package/dist/plugin.esm.js.map +0 -1
- package/dist/plugin.js.map +0 -1
- package/docs/FIREBASE_INTEGRATION_TRACKER.md +0 -321
- package/docs/FIREBASE_QUERIES_AND_INDEXES_AUDIT.md +0 -221
- package/docs/examples/firebase-backend-example.md +0 -27
- package/docs/guides/no-cost-backend-implementation-plan.md +0 -77
- package/docs/guides/no-cost-firestore-google-drive-backend.md +0 -60
- package/docs/play-console-rejection-rules.json +0 -428
- /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
|
-
//
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
567
|
-
|
|
676
|
+
rollbackToPrevious(
|
|
677
|
+
reason: "bundle checksum computation failed: \(error.localizedDescription)",
|
|
678
|
+
fromBundleId: activeBundleId
|
|
679
|
+
)
|
|
680
|
+
return
|
|
681
|
+
}
|
|
568
682
|
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
573
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
)
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
192
|
+
return true
|
|
194
193
|
}
|
|
195
194
|
|
|
196
195
|
func isInputValidationEnabled() -> Bool {
|