native-update 1.0.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/CapacitorNativeUpdate.podspec +18 -0
- package/LICENSE +21 -0
- package/Readme.md +451 -0
- package/android/build.gradle +92 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/android/gradle.properties +17 -0
- package/android/proguard-rules.pro +29 -0
- package/android/settings.gradle +2 -0
- package/android/src/main/AndroidManifest.xml +34 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/AppReviewPlugin.kt +153 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/AppUpdatePlugin.kt +275 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundNotificationManager.kt +390 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateManager.kt +46 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdatePlugin.kt +333 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateWorker.kt +251 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/CapacitorNativeUpdatePlugin.kt +265 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/LiveUpdatePlugin.kt +526 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/NotificationActionReceiver.kt +99 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/SecurityManager.kt +249 -0
- package/dist/esm/__tests__/bundle-manager.test.d.ts +1 -0
- package/dist/esm/__tests__/bundle-manager.test.js +123 -0
- package/dist/esm/__tests__/bundle-manager.test.js.map +1 -0
- package/dist/esm/__tests__/config.test.d.ts +1 -0
- package/dist/esm/__tests__/config.test.js +69 -0
- package/dist/esm/__tests__/config.test.js.map +1 -0
- package/dist/esm/__tests__/integration.test.d.ts +1 -0
- package/dist/esm/__tests__/integration.test.js +78 -0
- package/dist/esm/__tests__/integration.test.js.map +1 -0
- package/dist/esm/__tests__/security.test.d.ts +1 -0
- package/dist/esm/__tests__/security.test.js +54 -0
- package/dist/esm/__tests__/security.test.js.map +1 -0
- package/dist/esm/__tests__/version-manager.test.d.ts +1 -0
- package/dist/esm/__tests__/version-manager.test.js +45 -0
- package/dist/esm/__tests__/version-manager.test.js.map +1 -0
- package/dist/esm/app-review/app-review-manager.d.ts +24 -0
- package/dist/esm/app-review/app-review-manager.js +195 -0
- package/dist/esm/app-review/app-review-manager.js.map +1 -0
- package/dist/esm/app-review/index.d.ts +5 -0
- package/dist/esm/app-review/index.js +6 -0
- package/dist/esm/app-review/index.js.map +1 -0
- package/dist/esm/app-review/platform-review-handler.d.ts +20 -0
- package/dist/esm/app-review/platform-review-handler.js +138 -0
- package/dist/esm/app-review/platform-review-handler.js.map +1 -0
- package/dist/esm/app-review/review-conditions-checker.d.ts +22 -0
- package/dist/esm/app-review/review-conditions-checker.js +155 -0
- package/dist/esm/app-review/review-conditions-checker.js.map +1 -0
- package/dist/esm/app-review/review-rate-limiter.d.ts +23 -0
- package/dist/esm/app-review/review-rate-limiter.js +164 -0
- package/dist/esm/app-review/review-rate-limiter.js.map +1 -0
- package/dist/esm/app-review/types.d.ts +41 -0
- package/dist/esm/app-review/types.js +2 -0
- package/dist/esm/app-review/types.js.map +1 -0
- package/dist/esm/app-update/app-update-checker.d.ts +13 -0
- package/dist/esm/app-update/app-update-checker.js +104 -0
- package/dist/esm/app-update/app-update-checker.js.map +1 -0
- package/dist/esm/app-update/app-update-installer.d.ts +19 -0
- package/dist/esm/app-update/app-update-installer.js +123 -0
- package/dist/esm/app-update/app-update-installer.js.map +1 -0
- package/dist/esm/app-update/app-update-manager.d.ts +28 -0
- package/dist/esm/app-update/app-update-manager.js +199 -0
- package/dist/esm/app-update/app-update-manager.js.map +1 -0
- package/dist/esm/app-update/app-update-notifier.d.ts +14 -0
- package/dist/esm/app-update/app-update-notifier.js +100 -0
- package/dist/esm/app-update/app-update-notifier.js.map +1 -0
- package/dist/esm/app-update/index.d.ts +6 -0
- package/dist/esm/app-update/index.js +7 -0
- package/dist/esm/app-update/index.js.map +1 -0
- package/dist/esm/app-update/platform-app-update.d.ts +19 -0
- package/dist/esm/app-update/platform-app-update.js +129 -0
- package/dist/esm/app-update/platform-app-update.js.map +1 -0
- package/dist/esm/app-update/types.d.ts +58 -0
- package/dist/esm/app-update/types.js +12 -0
- package/dist/esm/app-update/types.js.map +1 -0
- package/dist/esm/background-update/background-scheduler.d.ts +17 -0
- package/dist/esm/background-update/background-scheduler.js +195 -0
- package/dist/esm/background-update/background-scheduler.js.map +1 -0
- package/dist/esm/background-update/index.d.ts +3 -0
- package/dist/esm/background-update/index.js +3 -0
- package/dist/esm/background-update/index.js.map +1 -0
- package/dist/esm/background-update/notification-manager.d.ts +29 -0
- package/dist/esm/background-update/notification-manager.js +89 -0
- package/dist/esm/background-update/notification-manager.js.map +1 -0
- package/dist/esm/core/analytics.d.ts +70 -0
- package/dist/esm/core/analytics.js +137 -0
- package/dist/esm/core/analytics.js.map +1 -0
- package/dist/esm/core/cache-manager.d.ts +72 -0
- package/dist/esm/core/cache-manager.js +275 -0
- package/dist/esm/core/cache-manager.js.map +1 -0
- package/dist/esm/core/config.d.ts +48 -0
- package/dist/esm/core/config.js +83 -0
- package/dist/esm/core/config.js.map +1 -0
- package/dist/esm/core/errors.d.ts +51 -0
- package/dist/esm/core/errors.js +80 -0
- package/dist/esm/core/errors.js.map +1 -0
- package/dist/esm/core/logger.d.ts +21 -0
- package/dist/esm/core/logger.js +109 -0
- package/dist/esm/core/logger.js.map +1 -0
- package/dist/esm/core/performance.d.ts +53 -0
- package/dist/esm/core/performance.js +140 -0
- package/dist/esm/core/performance.js.map +1 -0
- package/dist/esm/core/plugin-manager.d.ts +66 -0
- package/dist/esm/core/plugin-manager.js +148 -0
- package/dist/esm/core/plugin-manager.js.map +1 -0
- package/dist/esm/core/security.d.ts +93 -0
- package/dist/esm/core/security.js +315 -0
- package/dist/esm/core/security.js.map +1 -0
- package/dist/esm/definitions.d.ts +639 -0
- package/dist/esm/definitions.js +103 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +12 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/live-update/bundle-manager.d.ts +94 -0
- package/dist/esm/live-update/bundle-manager.js +310 -0
- package/dist/esm/live-update/bundle-manager.js.map +1 -0
- package/dist/esm/live-update/certificate-pinning.d.ts +38 -0
- package/dist/esm/live-update/certificate-pinning.js +78 -0
- package/dist/esm/live-update/certificate-pinning.js.map +1 -0
- package/dist/esm/live-update/download-manager.d.ts +67 -0
- package/dist/esm/live-update/download-manager.js +319 -0
- package/dist/esm/live-update/download-manager.js.map +1 -0
- package/dist/esm/live-update/update-manager.d.ts +52 -0
- package/dist/esm/live-update/update-manager.js +294 -0
- package/dist/esm/live-update/update-manager.js.map +1 -0
- package/dist/esm/live-update/version-manager.d.ts +84 -0
- package/dist/esm/live-update/version-manager.js +335 -0
- package/dist/esm/live-update/version-manager.js.map +1 -0
- package/dist/esm/plugin.d.ts +6 -0
- package/dist/esm/plugin.js +283 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/security/crypto.d.ts +25 -0
- package/dist/esm/security/crypto.js +70 -0
- package/dist/esm/security/crypto.js.map +1 -0
- package/dist/esm/security/validator.d.ts +60 -0
- package/dist/esm/security/validator.js +143 -0
- package/dist/esm/security/validator.js.map +1 -0
- package/dist/esm/web.d.ts +74 -0
- package/dist/esm/web.js +595 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +2 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.esm.js +2 -0
- package/dist/plugin.esm.js.map +1 -0
- package/dist/plugin.js +3 -0
- package/dist/plugin.js.map +1 -0
- package/docs/APP_REVIEW_GUIDE.md +768 -0
- package/docs/BUNDLE_SIGNING.md +264 -0
- package/docs/LIVE_UPDATES_GUIDE.md +650 -0
- package/docs/MIGRATION.md +192 -0
- package/docs/NATIVE_UPDATES_GUIDE.md +694 -0
- package/docs/QUICK_START.md +606 -0
- package/docs/README.md +111 -0
- package/docs/REMAINING_FEATURES.md +139 -0
- package/docs/api/app-review-api.md +259 -0
- package/docs/api/app-update-api.md +238 -0
- package/docs/api/events-api.md +451 -0
- package/docs/api/live-update-api.md +265 -0
- package/docs/background-updates.md +392 -0
- package/docs/examples/advanced-scenarios.md +410 -0
- package/docs/examples/basic-usage.md +185 -0
- package/docs/features/app-reviews.md +975 -0
- package/docs/features/app-updates.md +785 -0
- package/docs/features/live-updates.md +633 -0
- package/docs/getting-started/configuration.md +468 -0
- package/docs/getting-started/installation.md +209 -0
- package/docs/getting-started/quick-start.md +379 -0
- package/docs/guides/deployment-guide.md +333 -0
- package/docs/guides/migration-from-codepush.md +142 -0
- package/docs/guides/security-best-practices.md +1057 -0
- package/docs/guides/testing-guide.md +373 -0
- package/docs/production-readiness.md +478 -0
- package/docs/security/certificate-pinning.md +122 -0
- package/docs/server-requirements.md +147 -0
- package/ios/Plugin/AppReview/AppReviewPlugin.swift +158 -0
- package/ios/Plugin/AppUpdate/AppUpdatePlugin.swift +234 -0
- package/ios/Plugin/BackgroundUpdate/BackgroundNotificationManager.swift +329 -0
- package/ios/Plugin/BackgroundUpdate/BackgroundUpdatePlugin.swift +396 -0
- package/ios/Plugin/CapacitorNativeUpdatePlugin.m +45 -0
- package/ios/Plugin/CapacitorNativeUpdatePlugin.swift +190 -0
- package/ios/Plugin/Info.plist +43 -0
- package/ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift +689 -0
- package/ios/Plugin/LiveUpdate/WebViewConfiguration.swift +45 -0
- package/ios/Plugin/Security/SecurityManager.swift +289 -0
- package/package.json +90 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import CommonCrypto
|
|
4
|
+
|
|
5
|
+
class LiveUpdatePlugin {
|
|
6
|
+
private weak var plugin: CAPPlugin?
|
|
7
|
+
private var config: [String: Any]?
|
|
8
|
+
private var progressListener: (([String: Any]) -> Void)?
|
|
9
|
+
private var stateChangeListener: (([String: Any]) -> Void)?
|
|
10
|
+
private var session: URLSession
|
|
11
|
+
private var downloadTask: URLSessionDownloadTask?
|
|
12
|
+
private let securityManager = SecurityManager()
|
|
13
|
+
|
|
14
|
+
init(plugin: CAPPlugin) {
|
|
15
|
+
self.plugin = plugin
|
|
16
|
+
|
|
17
|
+
// Create initial session with default configuration
|
|
18
|
+
self.session = createURLSession()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private func createURLSession() -> URLSession {
|
|
22
|
+
let config = URLSessionConfiguration.default
|
|
23
|
+
config.timeoutIntervalForRequest = 30
|
|
24
|
+
config.timeoutIntervalForResource = 300
|
|
25
|
+
config.tlsMinimumSupportedProtocolVersion = .TLSv12
|
|
26
|
+
|
|
27
|
+
// Configure certificate pinning if available
|
|
28
|
+
if let securityConfig = self.config?["security"] as? [String: Any],
|
|
29
|
+
let certificatePinning = securityConfig["certificatePinning"] as? [String: Any],
|
|
30
|
+
certificatePinning["enabled"] as? Bool == true {
|
|
31
|
+
|
|
32
|
+
return URLSession(configuration: config, delegate: CertificatePinningDelegate(securityManager: securityManager), delegateQueue: nil)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return URLSession(configuration: config)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func configure(_ config: [String: Any]) throws {
|
|
39
|
+
self.config = config
|
|
40
|
+
|
|
41
|
+
// Configure security manager
|
|
42
|
+
try securityManager.configure(config)
|
|
43
|
+
|
|
44
|
+
// Recreate URLSession with new configuration
|
|
45
|
+
self.session = createURLSession()
|
|
46
|
+
|
|
47
|
+
// Validate configuration
|
|
48
|
+
if let serverUrl = config["serverUrl"] as? String,
|
|
49
|
+
!serverUrl.hasPrefix("https://") {
|
|
50
|
+
let enforceHttps = (config["security"] as? [String: Any])?["enforceHttps"] as? Bool ?? true
|
|
51
|
+
if enforceHttps {
|
|
52
|
+
throw NSError(domain: "LiveUpdatePlugin", code: 1, userInfo: [
|
|
53
|
+
NSLocalizedDescriptionKey: "Server URL must use HTTPS"
|
|
54
|
+
])
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func setProgressListener(_ listener: @escaping ([String: Any]) -> Void) {
|
|
60
|
+
self.progressListener = listener
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func setStateChangeListener(_ listener: @escaping ([String: Any]) -> Void) {
|
|
64
|
+
self.stateChangeListener = listener
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func sync(_ call: CAPPluginCall) {
|
|
68
|
+
Task {
|
|
69
|
+
do {
|
|
70
|
+
let channel = call.getString("channel") ?? config?["channel"] as? String ?? "production"
|
|
71
|
+
guard let serverUrl = config?["serverUrl"] as? String else {
|
|
72
|
+
call.reject("Server URL not configured")
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check for updates
|
|
77
|
+
if let latestVersion = try await checkForUpdates(serverUrl: serverUrl, channel: channel) {
|
|
78
|
+
call.resolve([
|
|
79
|
+
"status": "UPDATE_AVAILABLE",
|
|
80
|
+
"version": latestVersion["version"] ?? "",
|
|
81
|
+
"description": latestVersion["description"] ?? ""
|
|
82
|
+
])
|
|
83
|
+
} else {
|
|
84
|
+
call.resolve([
|
|
85
|
+
"status": "UP_TO_DATE",
|
|
86
|
+
"version": getCurrentVersion()
|
|
87
|
+
])
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
call.resolve([
|
|
91
|
+
"status": "ERROR",
|
|
92
|
+
"error": [
|
|
93
|
+
"code": "NETWORK_ERROR",
|
|
94
|
+
"message": error.localizedDescription
|
|
95
|
+
]
|
|
96
|
+
])
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func download(_ call: CAPPluginCall) {
|
|
102
|
+
guard let url = call.getString("url") else {
|
|
103
|
+
call.reject("URL is required")
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
guard let version = call.getString("version") else {
|
|
108
|
+
call.reject("Version is required")
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
guard let checksum = call.getString("checksum") else {
|
|
113
|
+
call.reject("Checksum is required")
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Validate URL
|
|
118
|
+
if !url.hasPrefix("https://") {
|
|
119
|
+
let enforceHttps = (config?["security"] as? [String: Any])?["enforceHttps"] as? Bool ?? true
|
|
120
|
+
if enforceHttps {
|
|
121
|
+
call.reject("INSECURE_URL", "Download URL must use HTTPS")
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Task {
|
|
127
|
+
do {
|
|
128
|
+
let bundleId = "bundle-\(Date().timeIntervalSince1970)"
|
|
129
|
+
let downloadDir = getUpdatesDirectory().appendingPathComponent(bundleId)
|
|
130
|
+
try FileManager.default.createDirectory(at: downloadDir, withIntermediateDirectories: true)
|
|
131
|
+
|
|
132
|
+
// Start download
|
|
133
|
+
let downloadedFile = try await downloadBundle(from: url, to: downloadDir, bundleId: bundleId)
|
|
134
|
+
|
|
135
|
+
// Verify checksum
|
|
136
|
+
let calculatedChecksum = try calculateChecksum(for: downloadedFile)
|
|
137
|
+
if calculatedChecksum != checksum {
|
|
138
|
+
try FileManager.default.removeItem(at: downloadedFile)
|
|
139
|
+
call.reject("CHECKSUM_ERROR", "Bundle checksum validation failed")
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Verify signature if provided
|
|
144
|
+
if let signature = call.getString("signature"),
|
|
145
|
+
let publicKey = (config?["security"] as? [String: Any])?["publicKey"] as? String,
|
|
146
|
+
(config?["security"] as? [String: Any])?["enableSignatureValidation"] as? Bool == true {
|
|
147
|
+
|
|
148
|
+
let fileData = try Data(contentsOf: downloadedFile)
|
|
149
|
+
let securityManager = SecurityManager()
|
|
150
|
+
|
|
151
|
+
if !securityManager.verifySignature(data: fileData, signature: signature, publicKeyString: publicKey) {
|
|
152
|
+
try FileManager.default.removeItem(at: downloadedFile)
|
|
153
|
+
call.reject("SIGNATURE_ERROR", "Bundle signature validation failed")
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Create bundle info
|
|
159
|
+
let bundleInfo: [String: Any] = [
|
|
160
|
+
"bundleId": bundleId,
|
|
161
|
+
"version": version,
|
|
162
|
+
"path": downloadedFile.path,
|
|
163
|
+
"downloadTime": Date().timeIntervalSince1970 * 1000,
|
|
164
|
+
"size": try FileManager.default.attributesOfItem(atPath: downloadedFile.path)[.size] ?? 0,
|
|
165
|
+
"status": "READY",
|
|
166
|
+
"checksum": checksum,
|
|
167
|
+
"verified": true
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
// Save bundle info
|
|
171
|
+
saveBundleInfo(bundleInfo)
|
|
172
|
+
|
|
173
|
+
// Extract the bundle
|
|
174
|
+
try extractAndApplyBundle(downloadedFile, bundleId: bundleId)
|
|
175
|
+
|
|
176
|
+
// Notify state change
|
|
177
|
+
stateChangeListener?([
|
|
178
|
+
"status": "READY",
|
|
179
|
+
"bundleId": bundleId,
|
|
180
|
+
"version": version
|
|
181
|
+
])
|
|
182
|
+
|
|
183
|
+
call.resolve(bundleInfo)
|
|
184
|
+
} catch {
|
|
185
|
+
call.reject("DOWNLOAD_ERROR", error.localizedDescription)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func set(_ call: CAPPluginCall) {
|
|
191
|
+
guard let bundleId = call.getString("bundleId") else {
|
|
192
|
+
call.reject("Bundle ID is required")
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
setActiveBundle(bundleId)
|
|
197
|
+
call.resolve()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func reload(_ call: CAPPluginCall) {
|
|
201
|
+
// In iOS, we need to reload the WebView with the new bundle path
|
|
202
|
+
DispatchQueue.main.async { [weak self] in
|
|
203
|
+
guard let self = self,
|
|
204
|
+
let webView = self.plugin?.webView else {
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Get the active bundle path
|
|
209
|
+
if let activeBundleId = UserDefaults.standard.string(forKey: "native_update_active_bundle"),
|
|
210
|
+
let bundles = UserDefaults.standard.dictionary(forKey: "native_update_bundles"),
|
|
211
|
+
let bundleInfo = bundles[activeBundleId] as? [String: Any],
|
|
212
|
+
let extractedPath = bundleInfo["extractedPath"] as? String {
|
|
213
|
+
|
|
214
|
+
// Load from extracted bundle path
|
|
215
|
+
let indexPath = URL(fileURLWithPath: extractedPath).appendingPathComponent("index.html")
|
|
216
|
+
webView.load(URLRequest(url: indexPath))
|
|
217
|
+
} else {
|
|
218
|
+
// Fallback to regular reload
|
|
219
|
+
webView.reload()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
call.resolve()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func reset(_ call: CAPPluginCall) {
|
|
226
|
+
clearAllBundles()
|
|
227
|
+
call.resolve()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func current(_ call: CAPPluginCall) {
|
|
231
|
+
let currentBundle = getCurrentBundleInfo()
|
|
232
|
+
call.resolve(currentBundle)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func list(_ call: CAPPluginCall) {
|
|
236
|
+
let bundles = getAllBundles()
|
|
237
|
+
call.resolve(["bundles": bundles])
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func delete(_ call: CAPPluginCall) {
|
|
241
|
+
if let bundleId = call.getString("bundleId") {
|
|
242
|
+
deleteBundle(bundleId)
|
|
243
|
+
} else if let keepVersions = call.getInt("keepVersions") {
|
|
244
|
+
cleanupOldBundles(keepVersions: keepVersions)
|
|
245
|
+
}
|
|
246
|
+
call.resolve()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func notifyAppReady(_ call: CAPPluginCall) {
|
|
250
|
+
markBundleAsVerified()
|
|
251
|
+
call.resolve()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func getLatest(_ call: CAPPluginCall) {
|
|
255
|
+
Task {
|
|
256
|
+
do {
|
|
257
|
+
guard let serverUrl = config?["serverUrl"] as? String else {
|
|
258
|
+
call.reject("Server URL not configured")
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let channel = config?["channel"] as? String ?? "production"
|
|
263
|
+
|
|
264
|
+
if let latestVersion = try await checkForUpdates(serverUrl: serverUrl, channel: channel) {
|
|
265
|
+
call.resolve([
|
|
266
|
+
"available": true,
|
|
267
|
+
"version": latestVersion["version"] ?? "",
|
|
268
|
+
"url": latestVersion["url"] ?? "",
|
|
269
|
+
"notes": latestVersion["notes"] ?? ""
|
|
270
|
+
])
|
|
271
|
+
} else {
|
|
272
|
+
call.resolve(["available": false])
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
call.reject("NETWORK_ERROR", error.localizedDescription)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func setChannel(_ call: CAPPluginCall) {
|
|
281
|
+
guard let channel = call.getString("channel") else {
|
|
282
|
+
call.reject("Channel is required")
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
config?["channel"] = channel
|
|
287
|
+
call.resolve()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func setUpdateUrl(_ call: CAPPluginCall) {
|
|
291
|
+
guard let url = call.getString("url") else {
|
|
292
|
+
call.reject("URL is required")
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if !url.hasPrefix("https://") {
|
|
297
|
+
let enforceHttps = (config?["security"] as? [String: Any])?["enforceHttps"] as? Bool ?? true
|
|
298
|
+
if enforceHttps {
|
|
299
|
+
call.reject("INSECURE_URL", "Update URL must use HTTPS")
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
config?["serverUrl"] = url
|
|
305
|
+
call.resolve()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func validateUpdate(_ call: CAPPluginCall) {
|
|
309
|
+
guard let bundlePath = call.getString("bundlePath") else {
|
|
310
|
+
call.reject("Bundle path is required")
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
guard let checksum = call.getString("checksum") else {
|
|
315
|
+
call.reject("Checksum is required")
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
do {
|
|
320
|
+
let url = URL(fileURLWithPath: bundlePath)
|
|
321
|
+
let calculatedChecksum = try calculateChecksum(for: url)
|
|
322
|
+
let isValid = calculatedChecksum == checksum
|
|
323
|
+
|
|
324
|
+
call.resolve([
|
|
325
|
+
"isValid": isValid,
|
|
326
|
+
"details": [
|
|
327
|
+
"checksumValid": isValid
|
|
328
|
+
]
|
|
329
|
+
])
|
|
330
|
+
} catch {
|
|
331
|
+
call.reject("VALIDATION_ERROR", error.localizedDescription)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// MARK: - Async Methods for Background Updates
|
|
336
|
+
|
|
337
|
+
func getLatestVersionAsync() async -> LatestVersion? {
|
|
338
|
+
do {
|
|
339
|
+
guard let serverUrl = config?["serverUrl"] as? String else {
|
|
340
|
+
NSLog("Server URL not configured")
|
|
341
|
+
return nil
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let channel = config?["channel"] as? String ?? "production"
|
|
345
|
+
|
|
346
|
+
if let latestVersion = try await checkForUpdates(serverUrl: serverUrl, channel: channel) {
|
|
347
|
+
return LatestVersion(
|
|
348
|
+
available: true,
|
|
349
|
+
version: latestVersion["version"] as? String
|
|
350
|
+
)
|
|
351
|
+
} else {
|
|
352
|
+
return LatestVersion(
|
|
353
|
+
available: false,
|
|
354
|
+
version: nil
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
NSLog("Failed to check live update: \(error.localizedDescription)")
|
|
359
|
+
return nil
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// MARK: - Private Methods
|
|
364
|
+
|
|
365
|
+
private func checkForUpdates(serverUrl: String, channel: String) async throws -> [String: Any]? {
|
|
366
|
+
guard let url = URL(string: "\(serverUrl)/check?channel=\(channel)") else {
|
|
367
|
+
throw NSError(domain: "LiveUpdatePlugin", code: 2, userInfo: [
|
|
368
|
+
NSLocalizedDescriptionKey: "Invalid server URL"
|
|
369
|
+
])
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let (data, _) = try await session.data(from: url)
|
|
373
|
+
|
|
374
|
+
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
375
|
+
let available = json["available"] as? Bool,
|
|
376
|
+
available {
|
|
377
|
+
return json
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return nil
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private func downloadBundle(from urlString: String, to directory: URL, bundleId: String) async throws -> URL {
|
|
384
|
+
guard let url = URL(string: urlString) else {
|
|
385
|
+
throw NSError(domain: "LiveUpdatePlugin", code: 3, userInfo: [
|
|
386
|
+
NSLocalizedDescriptionKey: "Invalid download URL"
|
|
387
|
+
])
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let destinationURL = directory.appendingPathComponent("bundle.zip")
|
|
391
|
+
|
|
392
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
393
|
+
let task = session.downloadTask(with: url) { [weak self] tempURL, response, error in
|
|
394
|
+
if let error = error {
|
|
395
|
+
continuation.resume(throwing: error)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
guard let tempURL = tempURL else {
|
|
400
|
+
continuation.resume(throwing: NSError(domain: "LiveUpdatePlugin", code: 4, userInfo: [
|
|
401
|
+
NSLocalizedDescriptionKey: "Download failed"
|
|
402
|
+
]))
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
do {
|
|
407
|
+
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
|
|
408
|
+
continuation.resume(returning: destinationURL)
|
|
409
|
+
} catch {
|
|
410
|
+
continuation.resume(throwing: error)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Track progress
|
|
415
|
+
let observation = task.progress.observe(\.fractionCompleted) { [weak self] progress, _ in
|
|
416
|
+
let percent = Int(progress.fractionCompleted * 100)
|
|
417
|
+
self?.progressListener?([
|
|
418
|
+
"percent": percent,
|
|
419
|
+
"bytesDownloaded": progress.completedUnitCount,
|
|
420
|
+
"totalBytes": progress.totalUnitCount,
|
|
421
|
+
"bundleId": bundleId
|
|
422
|
+
])
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
task.resume()
|
|
426
|
+
self.downloadTask = task
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private func calculateChecksum(for url: URL) throws -> String {
|
|
431
|
+
let data = try Data(contentsOf: url)
|
|
432
|
+
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
433
|
+
|
|
434
|
+
data.withUnsafeBytes { bytes in
|
|
435
|
+
_ = CC_SHA256(bytes.baseAddress, CC_LONG(data.count), &digest)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return digest.map { String(format: "%02x", $0) }.joined()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private func getUpdatesDirectory() -> URL {
|
|
442
|
+
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
443
|
+
return documentsDirectory.appendingPathComponent("updates")
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private func getCurrentVersion() -> String {
|
|
447
|
+
if let bundle = getCurrentBundleInfo(),
|
|
448
|
+
let version = bundle["version"] as? String {
|
|
449
|
+
return version
|
|
450
|
+
}
|
|
451
|
+
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private func saveBundleInfo(_ bundleInfo: [String: Any]) {
|
|
455
|
+
guard let bundleId = bundleInfo["bundleId"] as? String else { return }
|
|
456
|
+
|
|
457
|
+
let defaults = UserDefaults.standard
|
|
458
|
+
var bundles = defaults.dictionary(forKey: "native_update_bundles") ?? [:]
|
|
459
|
+
bundles[bundleId] = bundleInfo
|
|
460
|
+
defaults.set(bundles, forKey: "native_update_bundles")
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private func setActiveBundle(_ bundleId: String) {
|
|
464
|
+
UserDefaults.standard.set(bundleId, forKey: "native_update_active_bundle")
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private func clearAllBundles() {
|
|
468
|
+
let updateDir = getUpdatesDirectory()
|
|
469
|
+
try? FileManager.default.removeItem(at: updateDir)
|
|
470
|
+
|
|
471
|
+
UserDefaults.standard.removeObject(forKey: "native_update_bundles")
|
|
472
|
+
UserDefaults.standard.removeObject(forKey: "native_update_active_bundle")
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private func getCurrentBundleInfo() -> [String: Any] {
|
|
476
|
+
let defaults = UserDefaults.standard
|
|
477
|
+
|
|
478
|
+
if let activeBundleId = defaults.string(forKey: "native_update_active_bundle"),
|
|
479
|
+
let bundles = defaults.dictionary(forKey: "native_update_bundles"),
|
|
480
|
+
let bundleInfo = bundles[activeBundleId] as? [String: Any] {
|
|
481
|
+
return bundleInfo
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Return default bundle
|
|
485
|
+
return [
|
|
486
|
+
"bundleId": "default",
|
|
487
|
+
"version": getCurrentVersion(),
|
|
488
|
+
"path": "/",
|
|
489
|
+
"downloadTime": Date().timeIntervalSince1970 * 1000,
|
|
490
|
+
"size": 0,
|
|
491
|
+
"status": "ACTIVE",
|
|
492
|
+
"checksum": "",
|
|
493
|
+
"verified": true
|
|
494
|
+
]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private func getAllBundles() -> [[String: Any]] {
|
|
498
|
+
let defaults = UserDefaults.standard
|
|
499
|
+
let bundles = defaults.dictionary(forKey: "native_update_bundles") ?? [:]
|
|
500
|
+
return bundles.values.compactMap { $0 as? [String: Any] }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private func deleteBundle(_ bundleId: String) {
|
|
504
|
+
// Delete bundle files
|
|
505
|
+
let bundleDir = getUpdatesDirectory().appendingPathComponent(bundleId)
|
|
506
|
+
try? FileManager.default.removeItem(at: bundleDir)
|
|
507
|
+
|
|
508
|
+
// Remove from UserDefaults
|
|
509
|
+
let defaults = UserDefaults.standard
|
|
510
|
+
var bundles = defaults.dictionary(forKey: "native_update_bundles") ?? [:]
|
|
511
|
+
bundles.removeValue(forKey: bundleId)
|
|
512
|
+
defaults.set(bundles, forKey: "native_update_bundles")
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private func cleanupOldBundles(keepVersions: Int) {
|
|
516
|
+
let bundles = getAllBundles().sorted { bundle1, bundle2 in
|
|
517
|
+
let time1 = bundle1["downloadTime"] as? Double ?? 0
|
|
518
|
+
let time2 = bundle2["downloadTime"] as? Double ?? 0
|
|
519
|
+
return time1 > time2
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if bundles.count > keepVersions {
|
|
523
|
+
let bundlesToDelete = bundles.dropFirst(keepVersions)
|
|
524
|
+
for bundle in bundlesToDelete {
|
|
525
|
+
if let bundleId = bundle["bundleId"] as? String {
|
|
526
|
+
deleteBundle(bundleId)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private func markBundleAsVerified() {
|
|
533
|
+
UserDefaults.standard.set(true, forKey: "native_update_current_bundle_verified")
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// MARK: - Bundle Extraction and WebView Configuration
|
|
537
|
+
|
|
538
|
+
private func extractAndApplyBundle(_ bundleUrl: URL, bundleId: String) throws {
|
|
539
|
+
let extractedPath = getUpdatesDirectory().appendingPathComponent(bundleId).appendingPathComponent("www")
|
|
540
|
+
|
|
541
|
+
// Extract the zip bundle
|
|
542
|
+
try extractZipBundle(from: bundleUrl, to: extractedPath)
|
|
543
|
+
|
|
544
|
+
// Update bundle info with extracted path
|
|
545
|
+
var bundleInfo = getAllBundles().first { $0["bundleId"] as? String == bundleId } ?? [:]
|
|
546
|
+
bundleInfo["extractedPath"] = extractedPath.path
|
|
547
|
+
bundleInfo["status"] = "READY"
|
|
548
|
+
saveBundleInfo(bundleInfo)
|
|
549
|
+
|
|
550
|
+
// Configure WebView to use new path
|
|
551
|
+
configureWebViewPath(extractedPath.path)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private func extractZipBundle(from zipUrl: URL, to destinationUrl: URL) throws {
|
|
555
|
+
// Create destination directory
|
|
556
|
+
try FileManager.default.createDirectory(at: destinationUrl, withIntermediateDirectories: true)
|
|
557
|
+
|
|
558
|
+
// Use system unzip command
|
|
559
|
+
let process = Process()
|
|
560
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
|
561
|
+
process.arguments = ["-o", zipUrl.path, "-d", destinationUrl.path]
|
|
562
|
+
|
|
563
|
+
try process.run()
|
|
564
|
+
process.waitUntilExit()
|
|
565
|
+
|
|
566
|
+
if process.terminationStatus != 0 {
|
|
567
|
+
throw NSError(domain: "LiveUpdatePlugin", code: 5, userInfo: [
|
|
568
|
+
NSLocalizedDescriptionKey: "Failed to extract bundle"
|
|
569
|
+
])
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private func configureWebViewPath(_ path: String) {
|
|
574
|
+
// Store the active bundle path
|
|
575
|
+
UserDefaults.standard.set(path, forKey: "native_update_webview_path")
|
|
576
|
+
|
|
577
|
+
// The actual WebView path configuration happens in the main plugin
|
|
578
|
+
// when the WebView is loaded or reloaded
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// MARK: - Data Models
|
|
583
|
+
|
|
584
|
+
struct LatestVersion {
|
|
585
|
+
let available: Bool
|
|
586
|
+
let version: String?
|
|
587
|
+
|
|
588
|
+
func toDictionary() -> [String: Any] {
|
|
589
|
+
var obj: [String: Any] = [
|
|
590
|
+
"available": available
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
if let version = version {
|
|
594
|
+
obj["version"] = version
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return obj
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// MARK: - Certificate Pinning Delegate
|
|
602
|
+
|
|
603
|
+
class CertificatePinningDelegate: NSObject, URLSessionDelegate {
|
|
604
|
+
private let securityManager: SecurityManager
|
|
605
|
+
|
|
606
|
+
init(securityManager: SecurityManager) {
|
|
607
|
+
self.securityManager = securityManager
|
|
608
|
+
super.init()
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
|
612
|
+
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
|
613
|
+
let serverTrust = challenge.protectionSpace.serverTrust else {
|
|
614
|
+
completionHandler(.performDefaultHandling, nil)
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Get certificate pins from security manager configuration
|
|
619
|
+
let certificatePins = getCertificatePins(for: challenge.protectionSpace.host)
|
|
620
|
+
|
|
621
|
+
if certificatePins.isEmpty {
|
|
622
|
+
// No pins configured for this host, use default handling
|
|
623
|
+
completionHandler(.performDefaultHandling, nil)
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Evaluate server trust
|
|
628
|
+
var error: CFError?
|
|
629
|
+
let isValid = SecTrustEvaluateWithError(serverTrust, &error)
|
|
630
|
+
|
|
631
|
+
if !isValid {
|
|
632
|
+
print("Certificate validation failed: \(error?.localizedDescription ?? "Unknown error")")
|
|
633
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Check certificate pinning
|
|
638
|
+
if validateCertificatePins(serverTrust: serverTrust, expectedPins: certificatePins) {
|
|
639
|
+
let credential = URLCredential(trust: serverTrust)
|
|
640
|
+
completionHandler(.useCredential, credential)
|
|
641
|
+
} else {
|
|
642
|
+
print("Certificate pinning validation failed for host: \(challenge.protectionSpace.host)")
|
|
643
|
+
completionHandler(.cancelAuthenticationChallenge, nil)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
private func getCertificatePins(for host: String) -> [String] {
|
|
648
|
+
// Get pins from security manager configuration
|
|
649
|
+
guard let securityInfo = securityManager.getSecurityInfo(),
|
|
650
|
+
let certificatePinning = securityInfo["certificatePinning"] as? [String: Any],
|
|
651
|
+
certificatePinning["enabled"] as? Bool == true,
|
|
652
|
+
let hostPins = certificatePinning["pins"] as? [String: [String]] else {
|
|
653
|
+
return []
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Return pins for the specific host
|
|
657
|
+
return hostPins[host] ?? []
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private func validateCertificatePins(serverTrust: SecTrust, expectedPins: [String]) -> Bool {
|
|
661
|
+
let certificateCount = SecTrustGetCertificateCount(serverTrust)
|
|
662
|
+
|
|
663
|
+
for i in 0..<certificateCount {
|
|
664
|
+
guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, i) else { continue }
|
|
665
|
+
|
|
666
|
+
let certificateData = SecCertificateCopyData(certificate) as Data
|
|
667
|
+
let hash = certificateData.sha256()
|
|
668
|
+
let pin = "sha256/" + hash.base64EncodedString()
|
|
669
|
+
|
|
670
|
+
if expectedPins.contains(pin) {
|
|
671
|
+
return true
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return false
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// MARK: - Data Extension for SHA256
|
|
680
|
+
|
|
681
|
+
extension Data {
|
|
682
|
+
func sha256() -> Data {
|
|
683
|
+
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
|
684
|
+
self.withUnsafeBytes { bytes in
|
|
685
|
+
_ = CC_SHA256(bytes.baseAddress, CC_LONG(self.count), &hash)
|
|
686
|
+
}
|
|
687
|
+
return Data(hash)
|
|
688
|
+
}
|
|
689
|
+
}
|