@capgo/capacitor-webview-version-checker 8.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.
@@ -0,0 +1,394 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import UIKit
4
+
5
+ @objc(WebviewVersionCheckerPlugin)
6
+ public class WebviewVersionCheckerPlugin: CAPPlugin, CAPBridgedPlugin {
7
+ public let identifier = "WebviewVersionCheckerPlugin"
8
+ public let jsName = "WebviewVersionChecker"
9
+ public let pluginMethods: [CAPPluginMethod] = [
10
+ CAPPluginMethod(name: "check", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "startMonitoring", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "stopMonitoring", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "getLastStatus", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "showUpdatePrompt", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "openUpdatePage", returnType: CAPPluginReturnPromise)
16
+ ]
17
+
18
+ private let implementation = WebviewVersionChecker()
19
+
20
+ private var runtimeOptions = RuntimeOptions()
21
+ private var monitoringEnabled = false
22
+ private var checkOnResume = true
23
+ private var lastStatus: [String: Any]?
24
+ private var lastPromptFingerprint: String?
25
+
26
+ private var didBecomeActiveObserver: NSObjectProtocol?
27
+
28
+ override public func load() {
29
+ super.load()
30
+
31
+ runtimeOptions = RuntimeOptions(config: getConfig())
32
+ monitoringEnabled = runtimeOptions.autoCheckOnResume
33
+ checkOnResume = runtimeOptions.autoCheckOnResume
34
+
35
+ didBecomeActiveObserver = NotificationCenter.default.addObserver(
36
+ forName: UIApplication.didBecomeActiveNotification,
37
+ object: nil,
38
+ queue: .main
39
+ ) { [weak self] _ in
40
+ guard let self else { return }
41
+ guard self.monitoringEnabled, self.checkOnResume else { return }
42
+ self.runCheck(source: "resume", options: self.runtimeOptions, forcePrompt: false, completion: nil)
43
+ }
44
+
45
+ if runtimeOptions.autoCheckOnLoad {
46
+ runCheck(source: "load", options: runtimeOptions, forcePrompt: false, completion: nil)
47
+ }
48
+ }
49
+
50
+ deinit {
51
+ if let observer = didBecomeActiveObserver {
52
+ NotificationCenter.default.removeObserver(observer)
53
+ }
54
+ }
55
+
56
+ @objc func check(_ call: CAPPluginCall) {
57
+ let options = runtimeOptions.merged(with: call)
58
+ let source = call.getString("source") ?? "manual"
59
+
60
+ runCheck(source: source, options: options, forcePrompt: options.showPromptOnOutdated) { status in
61
+ call.resolve(status)
62
+ }
63
+ }
64
+
65
+ @objc func startMonitoring(_ call: CAPPluginCall) {
66
+ runtimeOptions = runtimeOptions.merged(with: call)
67
+ monitoringEnabled = true
68
+ checkOnResume = call.getBool("checkOnResume", runtimeOptions.autoCheckOnResume)
69
+
70
+ if call.getBool("checkOnStart", true) {
71
+ runCheck(source: "startMonitoring", options: runtimeOptions, forcePrompt: runtimeOptions.showPromptOnOutdated, completion: nil)
72
+ }
73
+
74
+ call.resolve(monitoringStatePayload())
75
+ }
76
+
77
+ @objc func stopMonitoring(_ call: CAPPluginCall) {
78
+ monitoringEnabled = false
79
+ call.resolve(monitoringStatePayload())
80
+ }
81
+
82
+ @objc func getLastStatus(_ call: CAPPluginCall) {
83
+ if let lastStatus {
84
+ call.resolve(["status": lastStatus])
85
+ return
86
+ }
87
+
88
+ call.resolve(["status": NSNull()])
89
+ }
90
+
91
+ @objc func showUpdatePrompt(_ call: CAPPluginCall) {
92
+ let promptOptions = PromptOptions(call: call, defaults: runtimeOptions)
93
+ showUpdatePrompt(promptOptions: promptOptions, force: true) { shown, opened in
94
+ call.resolve([
95
+ "shown": shown,
96
+ "openedUpdatePage": opened
97
+ ])
98
+ }
99
+ }
100
+
101
+ @objc func openUpdatePage(_ call: CAPPluginCall) {
102
+ let url = resolveUpdateUrl(explicitUpdateUrl: call.getString("updateUrl"), status: lastStatus)
103
+
104
+ openUpdatePageInternal(url: url) { opened in
105
+ call.resolve([
106
+ "opened": opened,
107
+ "url": url
108
+ ])
109
+ }
110
+ }
111
+
112
+ private func runCheck(
113
+ source: String,
114
+ options: RuntimeOptions,
115
+ forcePrompt: Bool,
116
+ completion: (([String: Any]) -> Void)?
117
+ ) {
118
+ let status = implementation.buildStatus(
119
+ source: source,
120
+ latestVersion: options.latestVersion,
121
+ minimumMajorVersion: options.minimumMajorVersion,
122
+ updateUrl: options.updateUrl
123
+ )
124
+
125
+ lastStatus = status
126
+
127
+ DispatchQueue.main.async { [weak self] in
128
+ guard let self else {
129
+ completion?(status)
130
+ return
131
+ }
132
+
133
+ self.notifyStatus(status)
134
+ self.maybeAutoPrompt(status: status, options: options, forcePrompt: forcePrompt)
135
+ completion?(status)
136
+ }
137
+ }
138
+
139
+ private func notifyStatus(_ status: [String: Any]) {
140
+ notifyListeners("statusChanged", data: status)
141
+
142
+ guard let state = status["state"] as? String else { return }
143
+ if state == "latest" {
144
+ notifyListeners("webViewLatest", data: status)
145
+ } else if state == "outdated" {
146
+ notifyListeners("webViewOutdated", data: status)
147
+ }
148
+ }
149
+
150
+ private func maybeAutoPrompt(status: [String: Any], options: RuntimeOptions, forcePrompt: Bool) {
151
+ guard let state = status["state"] as? String, state == "outdated" else {
152
+ return
153
+ }
154
+
155
+ guard forcePrompt || options.autoPromptOnOutdated else {
156
+ return
157
+ }
158
+
159
+ showUpdatePrompt(promptOptions: PromptOptions(defaults: options), force: forcePrompt, completion: nil)
160
+ }
161
+
162
+ private func showUpdatePrompt(
163
+ promptOptions: PromptOptions,
164
+ force: Bool,
165
+ completion: ((Bool, Bool) -> Void)?
166
+ ) {
167
+ let resolvedUrl = resolveUpdateUrl(explicitUpdateUrl: promptOptions.updateUrl, status: lastStatus)
168
+
169
+ let fingerprint = implementation.promptFingerprint(status: lastStatus, updateUrl: resolvedUrl)
170
+ if !force, fingerprint == lastPromptFingerprint {
171
+ completion?(false, false)
172
+ return
173
+ }
174
+
175
+ lastPromptFingerprint = fingerprint
176
+
177
+ let title = promptOptions.title ?? "WebView update available"
178
+ let message = promptOptions.message ?? implementation.defaultPromptMessage(status: lastStatus)
179
+ let updateButtonText = promptOptions.updateButtonText ?? "Update"
180
+ let cancelButtonText = promptOptions.cancelButtonText ?? "Later"
181
+
182
+ DispatchQueue.main.async {
183
+ guard let presenter = self.topViewController(from: self.bridge?.viewController) else {
184
+ completion?(false, false)
185
+ return
186
+ }
187
+
188
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
189
+ alert.addAction(UIAlertAction(title: cancelButtonText, style: .cancel) { _ in
190
+ completion?(true, false)
191
+ })
192
+ alert.addAction(UIAlertAction(title: updateButtonText, style: .default) { _ in
193
+ self.openUpdatePageInternal(url: resolvedUrl) { opened in
194
+ completion?(true, opened)
195
+ }
196
+ })
197
+
198
+ presenter.present(alert, animated: true)
199
+ }
200
+ }
201
+
202
+ private func monitoringStatePayload() -> [String: Any] {
203
+ [
204
+ "monitoring": monitoringEnabled,
205
+ "checkOnResume": checkOnResume,
206
+ "autoPromptOnOutdated": runtimeOptions.autoPromptOnOutdated
207
+ ]
208
+ }
209
+
210
+ private func resolveUpdateUrl(explicitUpdateUrl: String?, status: [String: Any]?) -> String {
211
+ if let explicitUpdateUrl, !explicitUpdateUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
212
+ return explicitUpdateUrl
213
+ }
214
+
215
+ if let statusUpdateUrl = status?["updateUrl"] as? String,
216
+ !statusUpdateUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
217
+ return statusUpdateUrl
218
+ }
219
+
220
+ return implementation.normalizedUpdateUrl(runtimeOptions.updateUrl)
221
+ }
222
+
223
+ private func openUpdatePageInternal(url: String, completion: @escaping (Bool) -> Void) {
224
+ guard let parsedUrl = URL(string: url) else {
225
+ completion(false)
226
+ return
227
+ }
228
+
229
+ DispatchQueue.main.async {
230
+ guard UIApplication.shared.canOpenURL(parsedUrl) else {
231
+ completion(false)
232
+ return
233
+ }
234
+
235
+ UIApplication.shared.open(parsedUrl, options: [:]) { opened in
236
+ completion(opened)
237
+ }
238
+ }
239
+ }
240
+
241
+ private func topViewController(from controller: UIViewController?) -> UIViewController? {
242
+ if let navigationController = controller as? UINavigationController {
243
+ return topViewController(from: navigationController.visibleViewController)
244
+ }
245
+
246
+ if let tabController = controller as? UITabBarController,
247
+ let selected = tabController.selectedViewController {
248
+ return topViewController(from: selected)
249
+ }
250
+
251
+ if let presented = controller?.presentedViewController {
252
+ return topViewController(from: presented)
253
+ }
254
+
255
+ return controller
256
+ }
257
+ }
258
+
259
+ private struct PromptOptions {
260
+ var title: String?
261
+ var message: String?
262
+ var updateButtonText: String?
263
+ var cancelButtonText: String?
264
+ var updateUrl: String?
265
+
266
+ init(defaults: RuntimeOptions) {
267
+ title = defaults.promptTitle
268
+ message = defaults.promptMessage
269
+ updateButtonText = defaults.promptUpdateButtonText
270
+ cancelButtonText = defaults.promptCancelButtonText
271
+ updateUrl = defaults.updateUrl
272
+ }
273
+
274
+ init(call: CAPPluginCall, defaults: RuntimeOptions) {
275
+ self.init(defaults: defaults)
276
+
277
+ if Self.hasValue(call, key: "title") {
278
+ title = call.getString("title")
279
+ }
280
+ if Self.hasValue(call, key: "message") {
281
+ message = call.getString("message")
282
+ }
283
+ if Self.hasValue(call, key: "updateButtonText") {
284
+ updateButtonText = call.getString("updateButtonText")
285
+ }
286
+ if Self.hasValue(call, key: "cancelButtonText") {
287
+ cancelButtonText = call.getString("cancelButtonText")
288
+ }
289
+ if Self.hasValue(call, key: "updateUrl") {
290
+ updateUrl = call.getString("updateUrl")
291
+ }
292
+ }
293
+
294
+ private static func hasValue(_ call: CAPPluginCall, key: String) -> Bool {
295
+ guard let value = call.options[key] else {
296
+ return false
297
+ }
298
+ return !(value is NSNull)
299
+ }
300
+ }
301
+
302
+ private struct RuntimeOptions {
303
+ var autoCheckOnLoad = true
304
+ var autoCheckOnResume = true
305
+ var autoPromptOnOutdated = false
306
+ var showPromptOnOutdated = false
307
+
308
+ var latestVersion: String?
309
+ var minimumMajorVersion: Int?
310
+ var latestVersionApiUrl: String?
311
+ var updateUrl: String?
312
+
313
+ var promptTitle: String?
314
+ var promptMessage: String?
315
+ var promptUpdateButtonText: String?
316
+ var promptCancelButtonText: String?
317
+
318
+ init() {}
319
+
320
+ init(config: PluginConfig) {
321
+ autoCheckOnLoad = config.getBoolean("autoCheckOnLoad", true)
322
+ autoCheckOnResume = config.getBoolean("autoCheckOnResume", true)
323
+ autoPromptOnOutdated = config.getBoolean("autoPromptOnOutdated", false)
324
+ latestVersion = trimToNil(config.getString("latestVersion"))
325
+ minimumMajorVersion = config.getConfigJSON()["minimumMajorVersion"] as? Int
326
+ latestVersionApiUrl = trimToNil(config.getString("latestVersionApiUrl"))
327
+ updateUrl = trimToNil(config.getString("updateUrl"))
328
+ promptTitle = trimToNil(config.getString("promptTitle"))
329
+ promptMessage = trimToNil(config.getString("promptMessage"))
330
+ promptUpdateButtonText = trimToNil(config.getString("promptUpdateButtonText"))
331
+ promptCancelButtonText = trimToNil(config.getString("promptCancelButtonText"))
332
+ }
333
+
334
+ func merged(with call: CAPPluginCall) -> RuntimeOptions {
335
+ var out = self
336
+
337
+ if Self.hasValue(call, key: "autoCheckOnLoad") {
338
+ out.autoCheckOnLoad = call.getBool("autoCheckOnLoad", out.autoCheckOnLoad)
339
+ }
340
+ if Self.hasValue(call, key: "autoCheckOnResume") {
341
+ out.autoCheckOnResume = call.getBool("autoCheckOnResume", out.autoCheckOnResume)
342
+ }
343
+ if Self.hasValue(call, key: "autoPromptOnOutdated") {
344
+ out.autoPromptOnOutdated = call.getBool("autoPromptOnOutdated", out.autoPromptOnOutdated)
345
+ }
346
+ if Self.hasValue(call, key: "showPromptOnOutdated") {
347
+ out.showPromptOnOutdated = call.getBool("showPromptOnOutdated", out.showPromptOnOutdated)
348
+ }
349
+
350
+ if Self.hasValue(call, key: "latestVersion") {
351
+ out.latestVersion = trimToNil(call.getString("latestVersion"))
352
+ }
353
+ if Self.hasValue(call, key: "minimumMajorVersion") {
354
+ out.minimumMajorVersion = call.getInt("minimumMajorVersion")
355
+ }
356
+ if Self.hasValue(call, key: "latestVersionApiUrl") {
357
+ out.latestVersionApiUrl = trimToNil(call.getString("latestVersionApiUrl"))
358
+ }
359
+ if Self.hasValue(call, key: "updateUrl") {
360
+ out.updateUrl = trimToNil(call.getString("updateUrl"))
361
+ }
362
+
363
+ if Self.hasValue(call, key: "promptTitle") {
364
+ out.promptTitle = trimToNil(call.getString("promptTitle"))
365
+ }
366
+ if Self.hasValue(call, key: "promptMessage") {
367
+ out.promptMessage = trimToNil(call.getString("promptMessage"))
368
+ }
369
+ if Self.hasValue(call, key: "promptUpdateButtonText") {
370
+ out.promptUpdateButtonText = trimToNil(call.getString("promptUpdateButtonText"))
371
+ }
372
+ if Self.hasValue(call, key: "promptCancelButtonText") {
373
+ out.promptCancelButtonText = trimToNil(call.getString("promptCancelButtonText"))
374
+ }
375
+
376
+ return out
377
+ }
378
+
379
+ private static func hasValue(_ call: CAPPluginCall, key: String) -> Bool {
380
+ guard let value = call.options[key] else {
381
+ return false
382
+ }
383
+ return !(value is NSNull)
384
+ }
385
+
386
+ private func trimToNil(_ value: String?) -> String? {
387
+ guard let value else {
388
+ return nil
389
+ }
390
+
391
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
392
+ return trimmed.isEmpty ? nil : trimmed
393
+ }
394
+ }
@@ -0,0 +1,22 @@
1
+ import XCTest
2
+ @testable import WebviewVersionCheckerPlugin
3
+
4
+ class WebviewVersionCheckerPluginTests: XCTestCase {
5
+ func testBuildStatusWithMinimumMajorVersion() {
6
+ let implementation = WebviewVersionChecker()
7
+ let status = implementation.buildStatus(source: "unit-test", latestVersion: nil, minimumMajorVersion: 1, updateUrl: nil)
8
+
9
+ XCTAssertEqual(status["platform"] as? String, "ios")
10
+ XCTAssertEqual(status["state"] as? String, "latest")
11
+ XCTAssertEqual(status["isLatest"] as? Bool, true)
12
+ XCTAssertNotNil(status["currentVersion"] as? String)
13
+ }
14
+
15
+ func testBuildStatusWithoutPolicyIsUnknown() {
16
+ let implementation = WebviewVersionChecker()
17
+ let status = implementation.buildStatus(source: "unit-test", latestVersion: nil, minimumMajorVersion: nil, updateUrl: nil)
18
+
19
+ XCTAssertEqual(status["state"] as? String, "unknown")
20
+ XCTAssertEqual(status["isLatest"] as? Bool, false)
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@capgo/capacitor-webview-version-checker",
3
+ "version": "8.0.0",
4
+ "description": "Capacitor plugin to detect outdated WebView engines, emit status events, and present native update prompts.",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "dist/",
13
+ "ios/Sources",
14
+ "ios/Tests",
15
+ "Package.swift",
16
+ "CapgoCapacitorWebviewVersionChecker.podspec"
17
+ ],
18
+ "author": "Martin Donadieu <martin@capgo.app>",
19
+ "license": "MPL-2.0",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Cap-go/capacitor-webview-version-checker.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Cap-go/capacitor-webview-version-checker/issues"
26
+ },
27
+ "homepage": "https://capgo.app/docs/plugins/webview-version-checker/",
28
+ "keywords": [
29
+ "capacitor",
30
+ "plugin",
31
+ "webview",
32
+ "version",
33
+ "update"
34
+ ],
35
+ "scripts": {
36
+ "verify": "bun run verify:ios && bun run verify:android && bun run verify:web",
37
+ "verify:ios": "xcodebuild -scheme CapgoCapacitorWebviewVersionChecker -destination generic/platform=iOS",
38
+ "verify:android": "cd android && ./gradlew clean build test && cd ..",
39
+ "verify:web": "bun run build",
40
+ "lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",
41
+ "fmt": "bun run eslint -- --fix && bun run prettier -- --write && bun run swiftlint -- --fix --format",
42
+ "eslint": "eslint . --ext .ts",
43
+ "prettier": "prettier-pretty-check \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
44
+ "swiftlint": "node-swiftlint",
45
+ "docgen": "docgen --api WebviewVersionCheckerPlugin --output-readme README.md --output-json dist/docs.json",
46
+ "build": "bun run clean && bun run docgen && tsc && rollup -c rollup.config.mjs",
47
+ "clean": "rimraf ./dist",
48
+ "watch": "tsc --watch",
49
+ "prepublishOnly": "bun run build",
50
+ "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs"
51
+ },
52
+ "devDependencies": {
53
+ "@capacitor/android": "^8.0.0",
54
+ "@capacitor/cli": "^8.0.0",
55
+ "@capacitor/core": "^8.0.0",
56
+ "@capacitor/docgen": "^0.3.1",
57
+ "@capacitor/ios": "^8.0.0",
58
+ "@ionic/eslint-config": "^0.4.0",
59
+ "@ionic/prettier-config": "^4.0.0",
60
+ "@ionic/swiftlint-config": "^2.0.0",
61
+ "@types/node": "^24.10.1",
62
+ "eslint": "^8.57.1",
63
+ "eslint-plugin-import": "^2.31.0",
64
+ "husky": "^9.1.7",
65
+ "prettier": "^3.6.2",
66
+ "prettier-pretty-check": "^0.2.0",
67
+ "prettier-plugin-java": "^2.7.7",
68
+ "rimraf": "^6.1.0",
69
+ "rollup": "^4.53.2",
70
+ "swiftlint": "^2.0.0",
71
+ "typescript": "^5.9.3"
72
+ },
73
+ "peerDependencies": {
74
+ "@capacitor/core": ">=8.0.0"
75
+ },
76
+ "prettier": "@ionic/prettier-config",
77
+ "swiftlint": "@ionic/swiftlint-config",
78
+ "eslintConfig": {
79
+ "extends": "@ionic/eslint-config/recommended"
80
+ },
81
+ "capacitor": {
82
+ "ios": {
83
+ "src": "ios"
84
+ },
85
+ "android": {
86
+ "src": "android"
87
+ }
88
+ }
89
+ }