1+ import UIKit
12import WebKit
23
34@MainActor class WebViewScriptExecutor : NSObject , WKScriptMessageHandler {
5+ static let defaultTimeout : Duration = . seconds( 30 )
6+ private static let maxAttachAttempts = 10
7+ private static let readyMessageID = " __iaps_ready__ "
8+
49 private var webView : WKWebView !
510 private var continuationStreams = [ String: AsyncThrowingStream < RawJSON , Error > . Continuation] ( )
11+
12+ /// True once the current WebView has loaded its document and every user script has run.
13+ private var isReady = false
14+ private var readyWaiters = [ CheckedContinuation < Void , Error > ] ( )
15+ private var webViewGeneration = 0
616 private var scripts = [
717 FunctionScript ( name: OpenAPS . Bundle. autosens, function: " freeaps_autosens " ) ,
818 FunctionScript ( name: OpenAPS . Bundle. autotuneCore, function: " freeaps_autotuneCore " ) ,
@@ -30,7 +40,67 @@ import WebKit
3040 init ( frame _: CGRect = . zero) {
3141 super. init ( )
3242
43+ replaceWebView ( )
44+ }
45+
46+ /// Tear down the current WebView and stand up a fresh one. Anything waiting on the
47+ /// outgoing view is failed rather than left parked, so a replacement can never
48+ /// strand a continuation.
49+ private func replaceWebView( ) {
50+ webView? . removeFromSuperview ( )
51+ failReadyWaiters (
52+ NSError (
53+ domain: " WebViewScriptExecutor " , code: 3 ,
54+ userInfo: [ NSLocalizedDescriptionKey: " WebView replaced before it became ready " ]
55+ )
56+ )
57+ isReady = false
58+ webViewGeneration &+= 1
3359 webView = createWebView ( )
60+ ensureAttachedToWindow ( )
61+ }
62+
63+ private func failReadyWaiters( _ error: Error ) {
64+ let waiters = readyWaiters
65+ readyWaiters. removeAll ( )
66+ for waiter in waiters {
67+ waiter. resume ( throwing: error)
68+ }
69+ }
70+
71+ private func awaitReady( ) async throws {
72+ if isReady { return }
73+ try await withCheckedThrowingContinuation { ( continuation: CheckedContinuation < Void , Error > ) in
74+ readyWaiters. append ( continuation)
75+ }
76+ }
77+
78+ private func ensureAttachedToWindow( attempt: Int = 0 ) {
79+ guard let webView, webView. superview == nil else { return }
80+
81+ guard let window = Self . hostWindow ( ) else {
82+ guard attempt < Self . maxAttachAttempts else {
83+ debug ( . openAPS, " WebView has no window to attach to; retrying on next JS call " )
84+ return
85+ }
86+ Task { @MainActor in
87+ try ? await Task . sleep ( for: . seconds( 1 ) )
88+ self . ensureAttachedToWindow ( attempt: attempt + 1 )
89+ }
90+ return
91+ }
92+
93+ webView. isUserInteractionEnabled = false
94+ window. addSubview ( webView)
95+ debug ( . openAPS, " WebView attached to window for background protection " )
96+ }
97+
98+ private static func hostWindow( ) -> UIWindow ? {
99+ let scenes = UIApplication . shared. connectedScenes
100+ let windowScene = scenes. first ( where: { $0. activationState == . foregroundActive } ) as? UIWindowScene
101+ ?? scenes. compactMap { $0 as? UIWindowScene } . first
102+ guard let windowScene else { return nil }
103+ return windowScene. windows. first ( where: \. isKeyWindow) ?? windowScene. windows. first
34104 }
35105
36106 private func createWebView( ) -> WKWebView {
@@ -39,18 +109,37 @@ import WebKit
39109 contentController. add ( self , name: " jsBridge " )
40110 contentController. add ( self , name: " scriptError " )
41111
112+ // Register the oref bundles as user scripts.
113+ // WebKit re-applies user scripts on every document load.
114+ for source in userScriptSources ( ) {
115+ contentController. addUserScript ( WKUserScript (
116+ source: source,
117+ injectionTime: . atDocumentStart,
118+ forMainFrameOnly: true
119+ ) )
120+ }
121+ contentController. addUserScript ( WKUserScript (
122+ source: """
123+ window.webkit.messageHandlers.jsBridge.postMessage({ id: " \( Self . readyMessageID) " , value: " " });
124+ """ ,
125+ injectionTime: . atDocumentEnd,
126+ forMainFrameOnly: true
127+ ) )
128+
42129 let config = WKWebViewConfiguration ( )
43130 config. userContentController = contentController
44131
45132 let webView = WKWebView ( frame: . zero, configuration: config)
133+ webView. navigationDelegate = self
46134
47- injectConsoleLogHandler ( webView: webView)
48- loadScripts ( webView: webView)
135+ // User scripts only run as part of a document load.
136+ // This is also what makes webViewWebContentProcessDidTerminate reliable.
137+ webView. loadHTMLString ( " <html><body></body></html> " , baseURL: nil )
49138
50139 return webView
51140 }
52141
53- private func injectConsoleLogHandler ( webView : WKWebView ) {
142+ private func userScriptSources ( ) -> [ String ] {
54143 let consoleScript = """
55144 var _consoleLog = function (message) {
56145 window.webkit.messageHandlers.consoleLog.postMessage(message.join( " " ));
@@ -60,30 +149,18 @@ import WebKit
60149 });
61150
62151 """
63- webView . evaluateJavaScript ( consoleScript , completionHandler : nil )
152+ return [ consoleScript ] + scripts . map ( \ . body ) + [ Script ( name : OpenAPS . Prepare . log ) . body ]
64153 }
65154
66155 private func script( for name: String ) -> FunctionScript ? {
67156 scripts. filter { $0. name == name } . first
68157 }
69158
70- private func loadScripts( webView: WKWebView ) {
71- for script in scripts {
72- includeScript ( webView: webView, script: script)
73- }
74-
75- includeScript ( webView: webView, script: Script ( name: OpenAPS . Prepare. log) )
76- }
77-
78- private func includeScript( webView: WKWebView , script: FunctionScript ) {
79- includeScript ( webView: webView, script: Script ( name: " Script " , body: script. body) )
80- }
81-
82- private func includeScript( webView: WKWebView , script: Script ) {
83- webView. evaluateJavaScript ( script. body)
84- }
85-
86- func call( name: String , with arguments: [ JSON ] , withBody body: String = " " ) async -> RawJSON {
159+ func call(
160+ name: String ,
161+ with arguments: [ JSON ] ,
162+ withBody body: String = " "
163+ ) async -> RawJSON {
87164 if let script = script ( for: name) {
88165 return await callFunctionAsync ( function: script, with: arguments, withBody: body)
89166 } else {
@@ -100,7 +177,11 @@ import WebKit
100177 await callFunctionAsync ( function: function. variable, with: arguments, withBody: body)
101178 }
102179
103- private func callFunctionAsync( function: String , with arguments: [ JSON ] , withBody body: String = " " ) async -> RawJSON {
180+ private func callFunctionAsync(
181+ function: String ,
182+ with arguments: [ JSON ] ,
183+ withBody body: String = " "
184+ ) async -> RawJSON {
104185 let joined = arguments. map ( \. rawJSON) . joined ( separator: " , " )
105186
106187 let script = """
@@ -110,17 +191,24 @@ import WebKit
110191 """
111192
112193 do {
113- let result = try await evaluateFunction ( body: script)
194+ let result = try await evaluateFunction ( name : function , body: script)
114195 return result
115196 } catch {
116- print ( error)
197+ warning ( . openAPS , " Javascript function ( \( function ) ) failed: \( error. localizedDescription ) " )
117198 return " "
118199 }
119200 }
120201
121- private func evaluateFunction( body: String , attempts: Int = 0 ) async throws -> RawJSON {
202+ private func evaluateFunction(
203+ name: String ,
204+ body: String ,
205+ attempts: Int = 0
206+ ) async throws -> RawJSON {
207+ ensureAttachedToWindow ( )
208+
122209 let maxAttempts = 2
123210 let requestId = UUID ( ) . uuidString
211+ let generation = webViewGeneration
124212
125213 let script = """
126214 (function () {
@@ -143,18 +231,35 @@ import WebKit
143231 }
144232
145233 do {
146- try await webView. evaluateJavaScript ( script)
234+ return try await withTimeout ( " js. \( name) " , Self . defaultTimeout) { [ self ] in
235+ try await Task { @MainActor in
236+ try await awaitReady ( )
237+
238+ try await webView. evaluateJavaScript ( script)
147239
148- for try await value in stream {
149- return value
240+ for try await value in stream {
241+ return value
242+ }
243+ throw NSError (
244+ domain: " WebViewScriptExecutor " , code: 2 ,
245+ userInfo: [ NSLocalizedDescriptionKey: " No result emitted " ]
246+ )
247+ } . value
150248 }
151- throw NSError ( domain: " WebViewScriptExecutor " , code: 2 , userInfo: [ NSLocalizedDescriptionKey: " No result emitted " ] )
152249 } catch {
153- print ( " Javascript function ( \( requestId) ) attempt \( attempts + 1 ) failed with error: \( error) " )
154- continuationStreams. removeValue ( forKey: requestId)
250+ warning (
251+ . openAPS,
252+ " Javascript function ( \( name) , \( requestId) ) attempt \( attempts + 1 ) failed with error: \( error) "
253+ )
254+ continuationStreams. removeValue ( forKey: requestId) ? . finish ( throwing: error)
255+ // Rebuild even when giving up: otherwise the next JS call in this cycle
256+ // inherits the wedged WebView and burns its own timeout rediscovering it.
257+ // Skipped if the terminate delegate (or another failing call) already did it.
258+ if webViewGeneration == generation {
259+ replaceWebView ( )
260+ }
155261 if attempts < maxAttempts {
156- webView = createWebView ( )
157- return try await evaluateFunction ( body: body, attempts: attempts + 1 )
262+ return try await evaluateFunction ( name: name, body: body, attempts: attempts + 1 )
158263 } else {
159264 throw error
160265 }
@@ -171,6 +276,23 @@ import WebKit
171276 if message. name == " scriptError " , let logMessage = message. body as? String {
172277 warning ( . openAPS, " JavaScript Error: \( logMessage) " )
173278 }
279+ // The documentEnd user script fired: the document (re)loaded and every oref
280+ // bundle has been injected, so queued calls may proceed.
281+ if message. name == " jsBridge " ,
282+ let body = message. body as? [ String : Any ] ,
283+ body [ " id " ] as? String == Self . readyMessageID,
284+ // A replaced WebView's in-flight ready message must not mark its successor
285+ // ready before that one has injected anything.
286+ message. webView === webView
287+ {
288+ isReady = true
289+ let waiters = readyWaiters
290+ readyWaiters. removeAll ( )
291+ for waiter in waiters {
292+ waiter. resume ( )
293+ }
294+ return
295+ }
174296 // Handle responses from evaluateFunction via jsBridge
175297 if message. name == " jsBridge " ,
176298 let body = message. body as? [ String : Any ] ,
@@ -196,3 +318,35 @@ import WebKit
196318 }
197319 }
198320}
321+
322+ extension WebViewScriptExecutor : WKNavigationDelegate {
323+ /// The WebContent (JS) process died — jetsam under memory pressure, a crash, or iOS
324+ /// reclaiming it in the background. Every in-flight call would otherwise wait for a
325+ /// reply that can no longer arrive, so fail them all now; their retry path picks up
326+ /// the rebuilt WebView. Unlike a suspended process this one WebKit tells us about,
327+ /// which is why it is worth handling separately from the watchdog.
328+ func webViewWebContentProcessDidTerminate( _ terminatedWebView: WKWebView ) {
329+ guard terminatedWebView === webView else { return }
330+
331+ let pending = continuationStreams
332+ continuationStreams. removeAll ( )
333+ warning (
334+ . openAPS,
335+ " WebContent process terminated by iOS - failing \( pending. count) in-flight JS call(s) and rebuilding the WebView "
336+ )
337+ let error = NSError (
338+ domain: " WebViewScriptExecutor " , code: 510 ,
339+ userInfo: [ NSLocalizedDescriptionKey: " WebContent process terminated " ]
340+ )
341+ for (_, continuation) in pending {
342+ continuation. finish ( throwing: error)
343+ }
344+ replaceWebView ( )
345+ }
346+
347+ func webView( _: WKWebView , didFailProvisionalNavigation _: WKNavigation ! , withError error: Error ) {
348+ // The blank document that carries the user scripts failed to load, so nothing
349+ // was injected and awaitReady() will time out. Log it or it is invisible.
350+ warning ( . openAPS, " WebView document load failed: \( error. localizedDescription) " )
351+ }
352+ }
0 commit comments