Skip to content

Commit 6762575

Browse files
authored
Merge pull request #2081 from bruschill/feat/site-planner-coverage-2058
feat(map): in-app Site Planner coverage estimate round-trip (#2058)
2 parents d4e4be5 + 871de38 commit 6762575

18 files changed

Lines changed: 1292 additions & 15 deletions

File tree

Meshtastic.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@
234234
BB0001000000000000000000 /* MBTilesArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001000000000000000000 /* MBTilesArchive.swift */; };
235235
BB0003000000000000000000 /* PMTilesMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0003000000000000000000 /* PMTilesMapView.swift */; };
236236
BB0004000000000000000000 /* ClusterMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004000000000000000000 /* ClusterMapView.swift */; };
237+
517E000000000000000B0001 /* SitePlannerParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0001 /* SitePlannerParameters.swift */; };
238+
517E000000000000000B0002 /* CoverageEstimateRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0002 /* CoverageEstimateRunner.swift */; };
239+
517E000000000000000B0003 /* CoverageEstimateForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0003 /* CoverageEstimateForm.swift */; };
240+
517E000000000000000B0004 /* SitePlannerParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0004 /* SitePlannerParametersTests.swift */; };
237241
BB0005000000000000000000 /* MeshMapMK.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005000000000000000000 /* MeshMapMK.swift */; };
238242
BB0F1900000000000000F190 /* TraceRouteFlyover.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0F1900000000000000F190 /* TraceRouteFlyover.swift */; };
239243
BBFF00000000000000000001 /* OfflineMapRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFF00000000000000000001 /* OfflineMapRegion.swift */; };
@@ -789,6 +793,10 @@
789793
AA0003000000000000000000 /* PMTilesMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PMTilesMapView.swift; path = Meshtastic/Views/Nodes/Helpers/Map/PMTilesMapView.swift; sourceTree = SOURCE_ROOT; };
790794
AA000301CPTST000000000001 /* CarPlayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTests.swift; sourceTree = "<group>"; };
791795
AA0004000000000000000000 /* ClusterMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ClusterMapView.swift; path = Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift; sourceTree = SOURCE_ROOT; };
796+
517E000000000000000A0001 /* SitePlannerParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SitePlannerParameters.swift; path = Meshtastic/Helpers/SitePlanner/SitePlannerParameters.swift; sourceTree = SOURCE_ROOT; };
797+
517E000000000000000A0002 /* CoverageEstimateRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CoverageEstimateRunner.swift; path = Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift; sourceTree = SOURCE_ROOT; };
798+
517E000000000000000A0003 /* CoverageEstimateForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CoverageEstimateForm.swift; path = Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift; sourceTree = SOURCE_ROOT; };
799+
517E000000000000000A0004 /* SitePlannerParametersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitePlannerParametersTests.swift; sourceTree = "<group>"; };
792800
AA000401SIRI000000000010 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
793801
AA000401SIRI000000000011 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = de; path = de.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
794802
AA000401SIRI000000000012 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = es; path = es.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
@@ -1715,6 +1723,7 @@
17151723
DD00001BINTCVRTST0000000 /* IntentConverterTests.swift */,
17161724
DD00001CMAPDATTST0000000 /* MapDataModelTests.swift */,
17171725
DD00001CMAPDMGRTST000000 /* MapDataManagerTests.swift */,
1726+
517E000000000000000A0004 /* SitePlannerParametersTests.swift */,
17181727
DD00001DCDENTTST00000000 /* EntityTests.swift */,
17191728
DD00001EURLDETTST0000000 /* URLExtensionDetailedTests.swift */,
17201729
DD00001FFWVMERRTST000000 /* FirmwareViewModelErrorTests.swift */,
@@ -2199,6 +2208,7 @@
21992208
AA0003000000000000000000 /* PMTilesMapView.swift */,
22002209
AA0005000000000000000000 /* MeshMapMK.swift */,
22012210
AA0004000000000000000000 /* ClusterMapView.swift */,
2211+
517E000000000000000A0003 /* CoverageEstimateForm.swift */,
22022212
AA0F1900000000000000F190 /* TraceRouteFlyover.swift */,
22032213
DDD3A2B12D5127B40045EB48 /* ci_scripts */,
22042214
DDDBC87A2BC62E4E001E8DF7 /* Settings.bundle */,
@@ -2401,6 +2411,8 @@
24012411
isa = PBXGroup;
24022412
children = (
24032413
3D3417D12E2DC260006A988B /* MapDataManager.swift */,
2414+
517E000000000000000A0001 /* SitePlannerParameters.swift */,
2415+
517E000000000000000A0002 /* CoverageEstimateRunner.swift */,
24042416
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
24052417
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
24062418
BEEF00102F00000100000001 /* MeshtasticChannelURL.swift */,
@@ -2880,6 +2892,7 @@
28802892
DD00001BINTCVRTST0000001 /* IntentConverterTests.swift in Sources */,
28812893
DD00001CMAPDATTST0000001 /* MapDataModelTests.swift in Sources */,
28822894
DD00001CMAPDMGRTST000001 /* MapDataManagerTests.swift in Sources */,
2895+
517E000000000000000B0004 /* SitePlannerParametersTests.swift in Sources */,
28832896
BBFF00000000000000000009 /* PMTilesExtractorTests.swift in Sources */,
28842897
DD00001DCDENTTST00000001 /* EntityTests.swift in Sources */,
28852898
DD00001EURLDETTST0000001 /* URLExtensionDetailedTests.swift in Sources */,
@@ -2938,6 +2951,9 @@
29382951
BB0003000000000000000000 /* PMTilesMapView.swift in Sources */,
29392952
BB0005000000000000000000 /* MeshMapMK.swift in Sources */,
29402953
BB0004000000000000000000 /* ClusterMapView.swift in Sources */,
2954+
517E000000000000000B0001 /* SitePlannerParameters.swift in Sources */,
2955+
517E000000000000000B0002 /* CoverageEstimateRunner.swift in Sources */,
2956+
517E000000000000000B0003 /* CoverageEstimateForm.swift in Sources */,
29412957
BB0F1900000000000000F190 /* TraceRouteFlyover.swift in Sources */,
29422958
86CCA3EEDD7E1036893B2663 /* DiscoverySessionEntity.swift in Sources */,
29432959
82F6BD3326F1BFEAF0F16723 /* DiscoveryPresetResultEntity.swift in Sources */,

Meshtastic/Helpers/MapDataManager.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,36 @@ class MapDataManager: ObservableObject {
134134
return try await processUploadedFile(from: tempURL)
135135
}
136136

137+
/// Imports an in-memory GeoJSON string (e.g. the coverage `FeatureCollection` handed back by
138+
/// the Site Planner's native bridge) through the same pipeline as `processUploadedFile`, so it
139+
/// reuses the exact validation + render + styling path with no round-trip to the share sheet.
140+
/// `name` becomes the on-disk file / layer name; a `.geojson` extension is enforced.
141+
func importFromString(_ geoJSON: String, name: String) async throws -> MapDataMetadata {
142+
guard let data = geoJSON.data(using: .utf8) else {
143+
throw MapDataError.invalidContent
144+
}
145+
guard data.count <= maxFileSize else {
146+
throw MapDataError.fileTooLarge
147+
}
148+
149+
// Sanitise the caller-supplied name into a safe single path component and enforce `.geojson`.
150+
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
151+
let base = trimmed.isEmpty ? "coverage" : trimmed
152+
let safeBase = base
153+
.components(separatedBy: CharacterSet(charactersIn: "/\\:\n\r\t"))
154+
.joined(separator: "-")
155+
let fileName = safeBase.lowercased().hasSuffix(".geojson") ? safeBase : "\(safeBase).geojson"
156+
157+
let tempURL = FileManager.default.temporaryDirectory
158+
.appendingPathComponent(UUID().uuidString)
159+
.appendingPathComponent(fileName)
160+
try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true)
161+
try data.write(to: tempURL)
162+
defer { try? FileManager.default.removeItem(at: tempURL) }
163+
164+
return try await processUploadedFile(from: tempURL)
165+
}
166+
137167
/// Validate uploaded file
138168
private func validateFile(at url: URL) throws {
139169
let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

Comments
 (0)