|
| 1 | +// |
| 2 | +// CoverageEstimateRunner.swift |
| 3 | +// Meshtastic |
| 4 | +// |
| 5 | +// Drives the hosted Meshtastic Site Planner headlessly to compute a coverage |
| 6 | +// estimate and hand the styled GeoJSON straight back to the app — no visible |
| 7 | +// browser hop or manual share-sheet tap. |
| 8 | +// |
| 9 | +// There is no headless params→GeoJSON HTTP API; the planner is a client-side |
| 10 | +// WASM SPLAT!/ITM simulator, so coverage is only ever computed in a WebView. |
| 11 | +// We load the planner with `run=1&bridge=1`, inject a tiny `__meshtasticNative` |
| 12 | +// shim (the object-injection contract the planner expects, since WKWebView has |
| 13 | +// no `addJavascriptInterface`), and receive the result via a script message. |
| 14 | +// |
| 15 | +// See meshtastic/Meshtastic-Apple#2058. |
| 16 | +// |
| 17 | + |
| 18 | +import Foundation |
| 19 | +import CoreLocation |
| 20 | +import WebKit |
| 21 | +import OSLog |
| 22 | + |
| 23 | +/// Progress of a single headless coverage run. |
| 24 | +enum CoverageEstimateState: Equatable { |
| 25 | + case idle |
| 26 | + case running |
| 27 | + case imported |
| 28 | + case failed(String) |
| 29 | +} |
| 30 | + |
| 31 | +/// The result of a successful run: the imported overlay's metadata plus the |
| 32 | +/// transmitter coordinate to recenter the map on. `Equatable` (by a per-result |
| 33 | +/// token) so it can drive a SwiftUI `onChange`. |
| 34 | +struct CoverageEstimateResult: Equatable { |
| 35 | + let token = UUID() |
| 36 | + let metadata: MapDataMetadata |
| 37 | + let coordinate: CLLocationCoordinate2D |
| 38 | + |
| 39 | + static func == (lhs: CoverageEstimateResult, rhs: CoverageEstimateResult) -> Bool { |
| 40 | + lhs.token == rhs.token |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +@MainActor |
| 45 | +final class CoverageEstimateRunner: NSObject, ObservableObject { |
| 46 | + |
| 47 | + /// The message-handler name the injected shim forwards coverage GeoJSON to. |
| 48 | + private static let bridgeName = "coverageBridge" |
| 49 | + /// Matches the Android reference timeout for a headless run. |
| 50 | + private static let timeout: TimeInterval = 45 |
| 51 | + |
| 52 | + /// JS injected at `.atDocumentStart` so `__meshtasticNative` exists before the |
| 53 | + /// planner's success handler (`postCoverageToBridge`) runs. It defines the |
| 54 | + /// object-shaped contract the planner checks for and forwards to the WKWebView |
| 55 | + /// message handler. |
| 56 | + private static let shimJS = """ |
| 57 | + window.__meshtasticNative = { |
| 58 | + onCoverage: function (geojson) { |
| 59 | + window.webkit.messageHandlers.\(bridgeName).postMessage(geojson); |
| 60 | + } |
| 61 | + }; |
| 62 | + """ |
| 63 | + |
| 64 | + @Published private(set) var state: CoverageEstimateState = .idle |
| 65 | + |
| 66 | + /// The most recent successful import (overlay metadata + transmitter coordinate). |
| 67 | + /// Observe via `onChange` to recenter the map and enable the overlay. |
| 68 | + @Published private(set) var importedResult: CoverageEstimateResult? |
| 69 | + |
| 70 | + private var webView: WKWebView? |
| 71 | + private var timeoutTask: Task<Void, Never>? |
| 72 | + private var params: SitePlannerParameters? |
| 73 | + private var didFinish = false |
| 74 | + |
| 75 | + /// Whether a run is currently in flight. |
| 76 | + var isRunning: Bool { state == .running } |
| 77 | + |
| 78 | + /// Kick off a headless coverage run for `params`. Requires a valid coordinate. |
| 79 | + func start(params: SitePlannerParameters) { |
| 80 | + cancel() // tear down any prior run first |
| 81 | + guard params.isValid, let url = params.queryURL(autorun: true, bridge: true) else { |
| 82 | + state = .failed(String(localized: "Invalid coverage parameters.")) |
| 83 | + return |
| 84 | + } |
| 85 | + guard let hostWindow = Self.keyWindow() else { |
| 86 | + state = .failed(String(localized: "Could not start the coverage estimate.")) |
| 87 | + return |
| 88 | + } |
| 89 | + |
| 90 | + self.params = params |
| 91 | + self.didFinish = false |
| 92 | + state = .running |
| 93 | + |
| 94 | + let config = WKWebViewConfiguration() |
| 95 | + let controller = WKUserContentController() |
| 96 | + controller.add(self, name: Self.bridgeName) |
| 97 | + controller.addUserScript(WKUserScript(source: Self.shimJS, injectionTime: .atDocumentStart, forMainFrameOnly: true)) |
| 98 | + config.userContentController = controller |
| 99 | + |
| 100 | + // The WebView must be attached to a window and non-zero size, or WebGL/WASM |
| 101 | + // never gets a GL context and the planner's autorun stalls on map-load. Keep |
| 102 | + // it full-size but invisible and non-interactive behind the progress UI. |
| 103 | + let webView = WKWebView(frame: hostWindow.bounds, configuration: config) |
| 104 | + webView.navigationDelegate = self |
| 105 | + webView.isUserInteractionEnabled = false |
| 106 | + webView.alpha = 0 |
| 107 | + webView.isHidden = false |
| 108 | + hostWindow.addSubview(webView) |
| 109 | + self.webView = webView |
| 110 | + |
| 111 | + Logger.services.info("🗺️ [SitePlanner] Starting headless coverage run: \(url.absoluteString, privacy: .public)") |
| 112 | + webView.load(URLRequest(url: url)) |
| 113 | + |
| 114 | + timeoutTask = Task { [weak self] in |
| 115 | + try? await Task.sleep(nanoseconds: UInt64(Self.timeout * 1_000_000_000)) |
| 116 | + guard !Task.isCancelled else { return } |
| 117 | + await self?.fail(String(localized: "Coverage estimate timed out.")) |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + /// Cancel any in-flight run and tear down the WebView. |
| 122 | + func cancel() { |
| 123 | + didFinish = true |
| 124 | + timeoutTask?.cancel() |
| 125 | + timeoutTask = nil |
| 126 | + teardownWebView() |
| 127 | + if state == .running { state = .idle } |
| 128 | + } |
| 129 | + |
| 130 | + /// Reset back to idle (e.g. when the form is dismissed after success/failure). |
| 131 | + func reset() { |
| 132 | + cancel() |
| 133 | + state = .idle |
| 134 | + } |
| 135 | + |
| 136 | + // MARK: - Private |
| 137 | + |
| 138 | + private func teardownWebView() { |
| 139 | + if let webView { |
| 140 | + webView.stopLoading() |
| 141 | + webView.navigationDelegate = nil |
| 142 | + webView.configuration.userContentController.removeAllUserScripts() |
| 143 | + webView.configuration.userContentController.removeScriptMessageHandler(forName: Self.bridgeName) |
| 144 | + webView.removeFromSuperview() |
| 145 | + } |
| 146 | + webView = nil |
| 147 | + } |
| 148 | + |
| 149 | + private func fail(_ message: String) { |
| 150 | + guard !didFinish else { return } |
| 151 | + didFinish = true |
| 152 | + Logger.services.error("🗺️ [SitePlanner] Coverage run failed: \(message, privacy: .public)") |
| 153 | + teardownWebView() |
| 154 | + timeoutTask?.cancel() |
| 155 | + timeoutTask = nil |
| 156 | + state = .failed(message) |
| 157 | + } |
| 158 | + |
| 159 | + private func handleCoverage(_ geoJSON: String) { |
| 160 | + guard !didFinish, let params else { return } |
| 161 | + didFinish = true |
| 162 | + timeoutTask?.cancel() |
| 163 | + timeoutTask = nil |
| 164 | + teardownWebView() |
| 165 | + |
| 166 | + let coordinate = CLLocationCoordinate2D(latitude: params.latitude, longitude: params.longitude) |
| 167 | + let layerName = params.name.trimmingCharacters(in: .whitespacesAndNewlines) |
| 168 | + let importName = layerName.isEmpty ? "Coverage" : layerName |
| 169 | + |
| 170 | + // This method is `@MainActor`, so the Task inherits MainActor isolation — after each |
| 171 | + // `await` we're back on the main actor and can assign published state directly. |
| 172 | + Task { [weak self] in |
| 173 | + do { |
| 174 | + let metadata = try await MapDataManager.shared.importFromString(geoJSON, name: importName) |
| 175 | + guard let self else { return } |
| 176 | + self.importedResult = CoverageEstimateResult(metadata: metadata, coordinate: coordinate) |
| 177 | + self.state = .imported |
| 178 | + } catch { |
| 179 | + self?.state = .failed(error.localizedDescription) |
| 180 | + } |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + /// The foreground key window to host the headless WebView. |
| 185 | + private static func keyWindow() -> UIWindow? { |
| 186 | + UIApplication.shared.connectedScenes |
| 187 | + .compactMap { $0 as? UIWindowScene } |
| 188 | + .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive } |
| 189 | + .flatMap { $0.windows } |
| 190 | + .first { $0.isKeyWindow } ?? |
| 191 | + UIApplication.shared.connectedScenes |
| 192 | + .compactMap { $0 as? UIWindowScene } |
| 193 | + .flatMap { $0.windows } |
| 194 | + .first |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +// MARK: - WKScriptMessageHandler |
| 199 | + |
| 200 | +extension CoverageEstimateRunner: WKScriptMessageHandler { |
| 201 | + nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { |
| 202 | + guard message.name == Self.bridgeName, let body = message.body as? String else { return } |
| 203 | + Task { @MainActor [weak self] in |
| 204 | + self?.handleCoverage(body) |
| 205 | + } |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +// MARK: - WKNavigationDelegate |
| 210 | + |
| 211 | +extension CoverageEstimateRunner: WKNavigationDelegate { |
| 212 | + nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { |
| 213 | + Task { @MainActor [weak self] in |
| 214 | + self?.fail(error.localizedDescription) |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + nonisolated func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { |
| 219 | + Task { @MainActor [weak self] in |
| 220 | + self?.fail(error.localizedDescription) |
| 221 | + } |
| 222 | + } |
| 223 | +} |
0 commit comments