@capgo/capacitor-webview-crash 7.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.
@@ -0,0 +1,359 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import ObjectiveC.runtime
4
+ import UIKit
5
+ import WebKit
6
+
7
+ enum WebViewCrashBridge {
8
+ static let crashEventName = "webViewRestoredAfterCrash"
9
+ static let restartEventName = "webViewRestoredAfterRestart"
10
+ static let periodicRestartReason = "periodicRestart"
11
+ static let manualRestartReason = "manualRestart"
12
+
13
+ static func pendingResult(_ value: [String: Any]?) -> [String: Any] {
14
+ [
15
+ "value": value ?? NSNull()
16
+ ]
17
+ }
18
+ }
19
+
20
+ struct WebViewCrashRestartOptions {
21
+ let restartOnCrash: Bool
22
+ let restartIntervalMs: Int
23
+ let restartCron: WebViewCrashCronSchedule?
24
+ let restartAfterCrashDelayMs: Int
25
+
26
+ init(config: PluginConfig? = nil) {
27
+ let intervalMs = max(0, config?.getInt("restartIntervalMs", 0) ?? 0)
28
+ let cronExpression = config?.getString("restartCron", "") ?? ""
29
+
30
+ if intervalMs > 0 && !cronExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
31
+ fatalError("Invalid WebViewCrash config: set either restartIntervalMs or restartCron, not both.")
32
+ }
33
+
34
+ restartOnCrash = config?.getBoolean("restartOnCrash", true) ?? true
35
+ restartIntervalMs = intervalMs
36
+ restartCron = WebViewCrashCronSchedule(cronExpression)
37
+ restartAfterCrashDelayMs = max(0, config?.getInt("restartAfterCrashDelayMs", 0) ?? 0)
38
+ }
39
+
40
+ var restartIntervalSeconds: TimeInterval {
41
+ TimeInterval(restartIntervalMs) / 1_000
42
+ }
43
+
44
+ var nextRestartDelaySeconds: TimeInterval? {
45
+ if let restartCron {
46
+ return restartCron.nextDelaySeconds()
47
+ }
48
+
49
+ return restartIntervalMs > 0 ? restartIntervalSeconds : nil
50
+ }
51
+
52
+ var restartAfterCrashDelaySeconds: TimeInterval {
53
+ TimeInterval(restartAfterCrashDelayMs) / 1_000
54
+ }
55
+ }
56
+
57
+ struct WebViewCrashCronSchedule {
58
+ private static let searchLimitMinutes = 366 * 24 * 60 * 5
59
+
60
+ private let minutes: CronField
61
+ private let hours: CronField
62
+ private let daysOfMonth: CronField
63
+ private let months: CronField
64
+ private let daysOfWeek: CronField
65
+
66
+ init?(_ expression: String?) {
67
+ guard let expression else {
68
+ return nil
69
+ }
70
+
71
+ let parts = expression
72
+ .split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
73
+ .map(String.init)
74
+
75
+ guard parts.count == 5,
76
+ let minutes = CronField(parts[0], min: 0, max: 59),
77
+ let hours = CronField(parts[1], min: 0, max: 23),
78
+ let daysOfMonth = CronField(parts[2], min: 1, max: 31),
79
+ let months = CronField(parts[3], min: 1, max: 12),
80
+ let daysOfWeek = CronField(parts[4], min: 0, max: 7, normalizeSunday: true) else {
81
+ return nil
82
+ }
83
+
84
+ self.minutes = minutes
85
+ self.hours = hours
86
+ self.daysOfMonth = daysOfMonth
87
+ self.months = months
88
+ self.daysOfWeek = daysOfWeek
89
+ }
90
+
91
+ func nextDelaySeconds(from date: Date = Date(), calendar sourceCalendar: Calendar = .current) -> TimeInterval? {
92
+ var calendar = sourceCalendar
93
+ calendar.timeZone = .current
94
+
95
+ guard var candidate = calendar.date(byAdding: .minute, value: 1, to: date) else {
96
+ return nil
97
+ }
98
+
99
+ var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: candidate)
100
+ components.second = 0
101
+ components.nanosecond = 0
102
+ guard let roundedCandidate = calendar.date(from: components) else {
103
+ return nil
104
+ }
105
+ candidate = roundedCandidate
106
+
107
+ for _ in 0..<Self.searchLimitMinutes {
108
+ if matches(candidate, calendar: calendar) {
109
+ return max(0, candidate.timeIntervalSince(date))
110
+ }
111
+
112
+ guard let nextCandidate = calendar.date(byAdding: .minute, value: 1, to: candidate) else {
113
+ return nil
114
+ }
115
+ candidate = nextCandidate
116
+ }
117
+
118
+ return nil
119
+ }
120
+
121
+ private func matches(_ date: Date, calendar: Calendar) -> Bool {
122
+ let components = calendar.dateComponents([.minute, .hour, .day, .month, .weekday], from: date)
123
+
124
+ guard let minute = components.minute,
125
+ let hour = components.hour,
126
+ let day = components.day,
127
+ let month = components.month,
128
+ let weekday = components.weekday else {
129
+ return false
130
+ }
131
+
132
+ guard minutes.matches(minute), hours.matches(hour), months.matches(month) else {
133
+ return false
134
+ }
135
+
136
+ let cronWeekday = (weekday - 1) % 7
137
+ let dayOfMonthMatches = daysOfMonth.matches(day)
138
+ let dayOfWeekMatches = daysOfWeek.matches(cronWeekday)
139
+
140
+ if daysOfMonth.restricted && daysOfWeek.restricted {
141
+ return dayOfMonthMatches || dayOfWeekMatches
142
+ }
143
+
144
+ return dayOfMonthMatches && dayOfWeekMatches
145
+ }
146
+
147
+ private struct CronField {
148
+ let values: Set<Int>
149
+ let restricted: Bool
150
+
151
+ init?(_ expression: String, min: Int, max: Int, normalizeSunday: Bool = false) {
152
+ var values = Set<Int>()
153
+
154
+ for part in expression.split(separator: ",", omittingEmptySubsequences: false) {
155
+ guard Self.apply(String(part), to: &values, min: min, max: max, normalizeSunday: normalizeSunday) else {
156
+ return nil
157
+ }
158
+ }
159
+
160
+ guard !values.isEmpty else {
161
+ return nil
162
+ }
163
+
164
+ let allValueCount = normalizeSunday ? 7 : max - min + 1
165
+ self.values = values
166
+ self.restricted = values.count != allValueCount
167
+ }
168
+
169
+ func matches(_ value: Int) -> Bool {
170
+ values.contains(value)
171
+ }
172
+
173
+ private static func apply(_ part: String, to values: inout Set<Int>, min: Int, max: Int, normalizeSunday: Bool) -> Bool {
174
+ let stepParts = part.split(separator: "/", omittingEmptySubsequences: false)
175
+ guard stepParts.count <= 2 else {
176
+ return false
177
+ }
178
+
179
+ var step = 1
180
+ if stepParts.count == 2 {
181
+ guard let parsedStep = Int(stepParts[1]), parsedStep > 0 else {
182
+ return false
183
+ }
184
+ step = parsedStep
185
+ }
186
+
187
+ let rangePart = String(stepParts[0])
188
+ let start: Int
189
+ let end: Int
190
+
191
+ if rangePart == "*" {
192
+ start = min
193
+ end = max
194
+ } else if rangePart.contains("-") {
195
+ let range = rangePart.split(separator: "-", omittingEmptySubsequences: false)
196
+ guard range.count == 2, let rangeStart = Int(range[0]), let rangeEnd = Int(range[1]) else {
197
+ return false
198
+ }
199
+ start = rangeStart
200
+ end = rangeEnd
201
+ } else {
202
+ guard let value = Int(rangePart) else {
203
+ return false
204
+ }
205
+ start = value
206
+ end = value
207
+ }
208
+
209
+ guard start >= min, end <= max, start <= end else {
210
+ return false
211
+ }
212
+
213
+ var value = start
214
+ while value <= end {
215
+ values.insert(normalize(value, normalizeSunday: normalizeSunday))
216
+ value += step
217
+ }
218
+
219
+ return true
220
+ }
221
+
222
+ private static func normalize(_ value: Int, normalizeSunday: Bool) -> Int {
223
+ normalizeSunday && value == 7 ? 0 : value
224
+ }
225
+ }
226
+ }
227
+
228
+ enum WebViewCrashRuntime {
229
+ private static var options = WebViewCrashRestartOptions()
230
+
231
+ static func update(options newOptions: WebViewCrashRestartOptions) {
232
+ options = newOptions
233
+ }
234
+
235
+ static var restartOnCrash: Bool {
236
+ options.restartOnCrash
237
+ }
238
+
239
+ static var restartAfterCrashDelaySeconds: TimeInterval {
240
+ options.restartAfterCrashDelaySeconds
241
+ }
242
+ }
243
+
244
+ enum WebViewCrashStore {
245
+ private static let pendingCrashKey = "CapgoWebViewCrash.pendingInfo"
246
+ private static let timestampFormatter = ISO8601DateFormatter()
247
+
248
+ static func read() -> [String: Any]? {
249
+ UserDefaults.standard.dictionary(forKey: pendingCrashKey)
250
+ }
251
+
252
+ static func write(_ value: [String: Any]) {
253
+ UserDefaults.standard.set(value, forKey: pendingCrashKey)
254
+ }
255
+
256
+ static func clear() {
257
+ UserDefaults.standard.removeObject(forKey: pendingCrashKey)
258
+ }
259
+
260
+ static func buildCrashInfo(platform: String, reason: String, url: String?, appState: String? = nil) -> [String: Any] {
261
+ let date = Date()
262
+ let timestamp = Int(date.timeIntervalSince1970 * 1000)
263
+ var value: [String: Any] = [
264
+ "platform": platform,
265
+ "timestamp": timestamp,
266
+ "timestampISO": timestampFormatter.string(from: date),
267
+ "reason": reason
268
+ ]
269
+
270
+ if let url, !url.isEmpty {
271
+ value["url"] = url
272
+ }
273
+
274
+ if let appState {
275
+ value["appState"] = appState
276
+ }
277
+
278
+ return value
279
+ }
280
+
281
+ static func shouldDispatch(eventName: String, crashInfo: [String: Any]) -> Bool {
282
+ if eventName == WebViewCrashBridge.restartEventName {
283
+ return true
284
+ }
285
+
286
+ guard eventName == WebViewCrashBridge.crashEventName else {
287
+ return false
288
+ }
289
+
290
+ let reason = crashInfo["reason"] as? String
291
+ return reason != WebViewCrashBridge.periodicRestartReason && reason != WebViewCrashBridge.manualRestartReason
292
+ }
293
+ }
294
+
295
+ enum WebViewCrashSwizzler {
296
+ private static var didInstall = false
297
+
298
+ static func installIfNeeded() {
299
+ guard !didInstall else {
300
+ return
301
+ }
302
+
303
+ let originalSelector = #selector(WebViewDelegationHandler.webViewWebContentProcessDidTerminate(_:))
304
+ let swizzledSelector = #selector(WebViewDelegationHandler.capgo_webViewCrash_webViewWebContentProcessDidTerminate(_:))
305
+
306
+ guard
307
+ let originalMethod = class_getInstanceMethod(WebViewDelegationHandler.self, originalSelector),
308
+ let swizzledMethod = class_getInstanceMethod(WebViewDelegationHandler.self, swizzledSelector)
309
+ else {
310
+ return
311
+ }
312
+
313
+ method_exchangeImplementations(originalMethod, swizzledMethod)
314
+ didInstall = true
315
+ }
316
+ }
317
+
318
+ private extension WebViewDelegationHandler {
319
+ @objc func capgo_webViewCrash_webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
320
+ let crashInfo = WebViewCrashStore.buildCrashInfo(
321
+ platform: "ios",
322
+ reason: "webContentProcessDidTerminate",
323
+ url: webView.url?.absoluteString,
324
+ appState: UIApplication.shared.applicationState.capgoWebViewCrashValue
325
+ )
326
+
327
+ WebViewCrashStore.write(crashInfo)
328
+
329
+ guard WebViewCrashRuntime.restartOnCrash else {
330
+ return
331
+ }
332
+
333
+ let restart = {
334
+ self.capgo_webViewCrash_webViewWebContentProcessDidTerminate(webView)
335
+ }
336
+
337
+ let delay = WebViewCrashRuntime.restartAfterCrashDelaySeconds
338
+ if delay > 0 {
339
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: restart)
340
+ } else {
341
+ restart()
342
+ }
343
+ }
344
+ }
345
+
346
+ extension UIApplication.State {
347
+ var capgoWebViewCrashValue: String {
348
+ switch self {
349
+ case .active:
350
+ return "active"
351
+ case .inactive:
352
+ return "inactive"
353
+ case .background:
354
+ return "background"
355
+ @unknown default:
356
+ return "unknown"
357
+ }
358
+ }
359
+ }
@@ -0,0 +1,152 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import UIKit
4
+
5
+ @objc(WebViewCrashPlugin)
6
+ public class WebViewCrashPlugin: CAPPlugin, CAPBridgedPlugin {
7
+ public let identifier = "WebViewCrashPlugin"
8
+ public let jsName = "WebViewCrash"
9
+ public let pluginMethods: [CAPPluginMethod] = [
10
+ CAPPluginMethod(name: "getPendingCrashInfo", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "clearPendingCrashInfo", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "simulateCrashRecovery", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "restartWebView", returnType: CAPPluginReturnPromise)
14
+ ]
15
+
16
+ private var dispatchedPendingEvents = Set<String>()
17
+ private var restartOptions = WebViewCrashRestartOptions()
18
+ private var restartTimer: Timer?
19
+
20
+ override public func load() {
21
+ restartOptions = WebViewCrashRestartOptions(config: getConfig())
22
+ WebViewCrashRuntime.update(options: restartOptions)
23
+ WebViewCrashSwizzler.installIfNeeded()
24
+ schedulePeriodicRestart()
25
+ }
26
+
27
+ deinit {
28
+ restartTimer?.invalidate()
29
+ }
30
+
31
+ @objc override public func addListener(_ call: CAPPluginCall) {
32
+ super.addListener(call)
33
+
34
+ if let eventName = call.getString("eventName"),
35
+ eventName == WebViewCrashBridge.crashEventName || eventName == WebViewCrashBridge.restartEventName {
36
+ dispatchPendingCrashIfNeeded(eventName: eventName)
37
+ }
38
+ }
39
+
40
+ @objc func getPendingCrashInfo(_ call: CAPPluginCall) {
41
+ call.resolve(WebViewCrashBridge.pendingResult(WebViewCrashStore.read()))
42
+ }
43
+
44
+ @objc func clearPendingCrashInfo(_ call: CAPPluginCall) {
45
+ WebViewCrashStore.clear()
46
+ dispatchedPendingEvents.removeAll()
47
+ call.resolve()
48
+ }
49
+
50
+ @objc func simulateCrashRecovery(_ call: CAPPluginCall) {
51
+ let crashInfo = WebViewCrashStore.buildCrashInfo(
52
+ platform: "ios",
53
+ reason: "simulated",
54
+ url: bridge?.webView?.url?.absoluteString,
55
+ appState: UIApplication.shared.applicationState.capgoWebViewCrashValue
56
+ )
57
+
58
+ WebViewCrashStore.write(crashInfo)
59
+ dispatchedPendingEvents.removeAll()
60
+ dispatchPendingCrashIfNeeded(eventName: WebViewCrashBridge.crashEventName)
61
+ dispatchPendingCrashIfNeeded(eventName: WebViewCrashBridge.restartEventName)
62
+
63
+ call.resolve(WebViewCrashBridge.pendingResult(crashInfo))
64
+ }
65
+
66
+ @objc func restartWebView(_ call: CAPPluginCall) {
67
+ guard bridge?.viewController is CAPBridgeViewController else {
68
+ call.reject("Unable to restart WebView because the bridge view controller is unavailable.")
69
+ return
70
+ }
71
+
72
+ let restartInfo = WebViewCrashStore.buildCrashInfo(
73
+ platform: "ios",
74
+ reason: WebViewCrashBridge.manualRestartReason,
75
+ url: bridge?.webView?.url?.absoluteString,
76
+ appState: UIApplication.shared.applicationState.capgoWebViewCrashValue
77
+ )
78
+
79
+ WebViewCrashStore.write(restartInfo)
80
+ dispatchedPendingEvents.removeAll()
81
+ call.resolve(WebViewCrashBridge.pendingResult(restartInfo))
82
+
83
+ DispatchQueue.main.async { [weak self] in
84
+ _ = self?.recreateBridgeWebView()
85
+ }
86
+ }
87
+
88
+ private func dispatchPendingCrashIfNeeded(eventName: String) {
89
+ guard !dispatchedPendingEvents.contains(eventName),
90
+ let crashInfo = WebViewCrashStore.read(),
91
+ WebViewCrashStore.shouldDispatch(eventName: eventName, crashInfo: crashInfo) else {
92
+ return
93
+ }
94
+
95
+ dispatchedPendingEvents.insert(eventName)
96
+ notifyListeners(eventName, data: crashInfo)
97
+ }
98
+
99
+ private func schedulePeriodicRestart() {
100
+ restartTimer?.invalidate()
101
+
102
+ guard let restartDelaySeconds = restartOptions.nextRestartDelaySeconds, restartDelaySeconds > 0 else {
103
+ return
104
+ }
105
+
106
+ DispatchQueue.main.async { [weak self] in
107
+ guard let self else {
108
+ return
109
+ }
110
+
111
+ restartTimer = Timer.scheduledTimer(withTimeInterval: restartDelaySeconds, repeats: false) { [weak self] _ in
112
+ self?.restartWebView(reason: WebViewCrashBridge.periodicRestartReason)
113
+ }
114
+ }
115
+ }
116
+
117
+ private func restartWebView(reason: String) {
118
+ DispatchQueue.main.async { [weak self] in
119
+ guard let self, let webView = bridge?.webView else {
120
+ return
121
+ }
122
+
123
+ let restartInfo = WebViewCrashStore.buildCrashInfo(
124
+ platform: "ios",
125
+ reason: reason,
126
+ url: webView.url?.absoluteString,
127
+ appState: UIApplication.shared.applicationState.capgoWebViewCrashValue
128
+ )
129
+
130
+ WebViewCrashStore.write(restartInfo)
131
+ dispatchedPendingEvents.removeAll()
132
+ if !recreateBridgeWebView() {
133
+ webView.reload()
134
+ schedulePeriodicRestart()
135
+ }
136
+ }
137
+ }
138
+
139
+ private func recreateBridgeWebView() -> Bool {
140
+ guard let viewController = bridge?.viewController as? CAPBridgeViewController else {
141
+ return false
142
+ }
143
+
144
+ restartTimer?.invalidate()
145
+ viewController.webView?.stopLoading()
146
+ viewController.webView?.navigationDelegate = nil
147
+ viewController.webView?.uiDelegate = nil
148
+ viewController.loadView()
149
+ viewController.loadWebView()
150
+ return true
151
+ }
152
+ }
@@ -0,0 +1,12 @@
1
+ import XCTest
2
+ @testable import WebViewCrashPlugin
3
+
4
+ final class WebViewCrashPluginTests: XCTestCase {
5
+ func testPluginMetadata() {
6
+ let plugin = WebViewCrashPlugin()
7
+
8
+ XCTAssertEqual(plugin.identifier, "WebViewCrashPlugin")
9
+ XCTAssertEqual(plugin.jsName, "WebViewCrash")
10
+ XCTAssertEqual(plugin.pluginMethods.count, 3)
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "@capgo/capacitor-webview-crash",
3
+ "version": "7.1.0",
4
+ "description": "Capacitor plugin for detecting WebView crash recovery and restarting long-running WebViews natively.",
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
+ "CapgoCapacitorWebViewCrash.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-crash.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Cap-go/capacitor-webview-crash/issues"
26
+ },
27
+ "homepage": "https://capgo.app/docs/plugins/webview-crash/",
28
+ "keywords": [
29
+ "capacitor",
30
+ "plugin",
31
+ "webview-crash",
32
+ "webview-restart",
33
+ "oom-recovery",
34
+ "crash-recovery",
35
+ "ios",
36
+ "android",
37
+ "capgo"
38
+ ],
39
+ "scripts": {
40
+ "verify": "bun run verify:ios && bun run verify:android && bun run verify:web",
41
+ "verify:ios": "xcodebuild -scheme CapgoCapacitorWebviewCrash -destination generic/platform=iOS",
42
+ "verify:android": "cd android && ./gradlew clean build test && cd ..",
43
+ "verify:web": "bun run build && bun test",
44
+ "test": "bun test",
45
+ "lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",
46
+ "fmt": "bun run eslint -- --fix && bun run prettier -- --write && bun run swiftlint -- --fix --format",
47
+ "eslint": "eslint . --ext .ts",
48
+ "prettier": "prettier-pretty-check \"**/*.{css,html,ts,js,java,json,md}\" --plugin=prettier-plugin-java",
49
+ "swiftlint": "node-swiftlint",
50
+ "docgen": "docgen --api WebViewCrashPlugin --output-readme README.md --output-json dist/docs.json && prettier --write README.md",
51
+ "build": "bun run clean && bun run docgen && tsc && rollup -c rollup.config.mjs",
52
+ "clean": "rimraf ./dist",
53
+ "watch": "tsc --watch",
54
+ "prepublishOnly": "bun run build",
55
+ "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs"
56
+ },
57
+ "devDependencies": {
58
+ "@capacitor/android": "^7.0.0",
59
+ "@capacitor/cli": "^7.0.0",
60
+ "@capacitor/core": "^7.0.0",
61
+ "@capacitor/docgen": "^0.3.1",
62
+ "@capacitor/ios": "^7.0.0",
63
+ "@ionic/eslint-config": "^0.4.0",
64
+ "@ionic/prettier-config": "^4.0.0",
65
+ "@ionic/swiftlint-config": "^2.0.0",
66
+ "@types/node": "^24.10.1",
67
+ "eslint": "^8.57.1",
68
+ "eslint-plugin-import": "^2.31.0",
69
+ "husky": "^9.1.7",
70
+ "prettier": "^3.6.2",
71
+ "prettier-pretty-check": "^0.2.0",
72
+ "prettier-plugin-java": "^2.7.7",
73
+ "rimraf": "^6.1.0",
74
+ "rollup": "^4.53.2",
75
+ "swiftlint": "^2.0.0",
76
+ "typescript": "^5.9.3"
77
+ },
78
+ "peerDependencies": {
79
+ "@capacitor/core": ">=7.0.0 <8.0.0"
80
+ },
81
+ "prettier": "@ionic/prettier-config",
82
+ "swiftlint": "@ionic/swiftlint-config",
83
+ "eslintConfig": {
84
+ "extends": "@ionic/eslint-config/recommended"
85
+ },
86
+ "capacitor": {
87
+ "ios": {
88
+ "src": "ios"
89
+ },
90
+ "android": {
91
+ "src": "android"
92
+ }
93
+ }
94
+ }