Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@
BB0001000000000000000000 /* MBTilesArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0001000000000000000000 /* MBTilesArchive.swift */; };
BB0003000000000000000000 /* PMTilesMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0003000000000000000000 /* PMTilesMapView.swift */; };
BB0004000000000000000000 /* ClusterMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0004000000000000000000 /* ClusterMapView.swift */; };
517E000000000000000B0001 /* SitePlannerParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0001 /* SitePlannerParameters.swift */; };
517E000000000000000B0002 /* CoverageEstimateRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0002 /* CoverageEstimateRunner.swift */; };
517E000000000000000B0003 /* CoverageEstimateForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0003 /* CoverageEstimateForm.swift */; };
517E000000000000000B0004 /* SitePlannerParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 517E000000000000000A0004 /* SitePlannerParametersTests.swift */; };
BB0005000000000000000000 /* MeshMapMK.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0005000000000000000000 /* MeshMapMK.swift */; };
BB0F1900000000000000F190 /* TraceRouteFlyover.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0F1900000000000000F190 /* TraceRouteFlyover.swift */; };
BBFF00000000000000000001 /* OfflineMapRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFF00000000000000000001 /* OfflineMapRegion.swift */; };
Expand Down Expand Up @@ -766,6 +770,10 @@
AA0003000000000000000000 /* PMTilesMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PMTilesMapView.swift; path = Meshtastic/Views/Nodes/Helpers/Map/PMTilesMapView.swift; sourceTree = SOURCE_ROOT; };
AA000301CPTST000000000001 /* CarPlayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTests.swift; sourceTree = "<group>"; };
AA0004000000000000000000 /* ClusterMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ClusterMapView.swift; path = Meshtastic/Views/Nodes/Helpers/Map/ClusterMapView.swift; sourceTree = SOURCE_ROOT; };
517E000000000000000A0001 /* SitePlannerParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SitePlannerParameters.swift; path = Meshtastic/Helpers/SitePlanner/SitePlannerParameters.swift; sourceTree = SOURCE_ROOT; };
517E000000000000000A0002 /* CoverageEstimateRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CoverageEstimateRunner.swift; path = Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift; sourceTree = SOURCE_ROOT; };
517E000000000000000A0003 /* CoverageEstimateForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CoverageEstimateForm.swift; path = Meshtastic/Views/Nodes/Helpers/Map/CoverageEstimateForm.swift; sourceTree = SOURCE_ROOT; };
517E000000000000000A0004 /* SitePlannerParametersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitePlannerParametersTests.swift; sourceTree = "<group>"; };
AA000401SIRI000000000010 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
AA000401SIRI000000000011 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = de; path = de.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
AA000401SIRI000000000012 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = es; path = es.lproj/AppIntentVocabulary.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1668,6 +1676,7 @@
DD00001BINTCVRTST0000000 /* IntentConverterTests.swift */,
DD00001CMAPDATTST0000000 /* MapDataModelTests.swift */,
DD00001CMAPDMGRTST000000 /* MapDataManagerTests.swift */,
517E000000000000000A0004 /* SitePlannerParametersTests.swift */,
DD00001DCDENTTST00000000 /* EntityTests.swift */,
DD00001EURLDETTST0000000 /* URLExtensionDetailedTests.swift */,
DD00001FFWVMERRTST000000 /* FirmwareViewModelErrorTests.swift */,
Expand Down Expand Up @@ -2147,6 +2156,7 @@
AA0003000000000000000000 /* PMTilesMapView.swift */,
AA0005000000000000000000 /* MeshMapMK.swift */,
AA0004000000000000000000 /* ClusterMapView.swift */,
517E000000000000000A0003 /* CoverageEstimateForm.swift */,
AA0F1900000000000000F190 /* TraceRouteFlyover.swift */,
DDD3A2B12D5127B40045EB48 /* ci_scripts */,
DDDBC87A2BC62E4E001E8DF7 /* Settings.bundle */,
Expand Down Expand Up @@ -2347,6 +2357,8 @@
isa = PBXGroup;
children = (
3D3417D12E2DC260006A988B /* MapDataManager.swift */,
517E000000000000000A0001 /* SitePlannerParameters.swift */,
517E000000000000000A0002 /* CoverageEstimateRunner.swift */,
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
BEEF00102F00000100000001 /* MeshtasticChannelURL.swift */,
Expand Down Expand Up @@ -2820,6 +2832,7 @@
DD00001BINTCVRTST0000001 /* IntentConverterTests.swift in Sources */,
DD00001CMAPDATTST0000001 /* MapDataModelTests.swift in Sources */,
DD00001CMAPDMGRTST000001 /* MapDataManagerTests.swift in Sources */,
517E000000000000000B0004 /* SitePlannerParametersTests.swift in Sources */,
BBFF00000000000000000009 /* PMTilesExtractorTests.swift in Sources */,
DD00001DCDENTTST00000001 /* EntityTests.swift in Sources */,
DD00001EURLDETTST0000001 /* URLExtensionDetailedTests.swift in Sources */,
Expand Down Expand Up @@ -2878,6 +2891,9 @@
BB0003000000000000000000 /* PMTilesMapView.swift in Sources */,
BB0005000000000000000000 /* MeshMapMK.swift in Sources */,
BB0004000000000000000000 /* ClusterMapView.swift in Sources */,
517E000000000000000B0001 /* SitePlannerParameters.swift in Sources */,
517E000000000000000B0002 /* CoverageEstimateRunner.swift in Sources */,
517E000000000000000B0003 /* CoverageEstimateForm.swift in Sources */,
BB0F1900000000000000F190 /* TraceRouteFlyover.swift in Sources */,
86CCA3EEDD7E1036893B2663 /* DiscoverySessionEntity.swift in Sources */,
82F6BD3326F1BFEAF0F16723 /* DiscoveryPresetResultEntity.swift in Sources */,
Expand Down
30 changes: 30 additions & 0 deletions Meshtastic/Helpers/MapDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,36 @@ class MapDataManager: ObservableObject {
return try await processUploadedFile(from: tempURL)
}

/// Imports an in-memory GeoJSON string (e.g. the coverage `FeatureCollection` handed back by
/// the Site Planner's native bridge) through the same pipeline as `processUploadedFile`, so it
/// reuses the exact validation + render + styling path with no round-trip to the share sheet.
/// `name` becomes the on-disk file / layer name; a `.geojson` extension is enforced.
func importFromString(_ geoJSON: String, name: String) async throws -> MapDataMetadata {
guard let data = geoJSON.data(using: .utf8) else {
throw MapDataError.invalidContent
}
guard data.count <= maxFileSize else {
throw MapDataError.fileTooLarge
}

// Sanitise the caller-supplied name into a safe single path component and enforce `.geojson`.
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
let base = trimmed.isEmpty ? "coverage" : trimmed
let safeBase = base
.components(separatedBy: CharacterSet(charactersIn: "/\\:\n\r\t"))
.joined(separator: "-")
let fileName = safeBase.lowercased().hasSuffix(".geojson") ? safeBase : "\(safeBase).geojson"

let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathComponent(fileName)
try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: tempURL)
defer { try? FileManager.default.removeItem(at: tempURL) }

return try await processUploadedFile(from: tempURL)
}

/// Validate uploaded file
private func validateFile(at url: URL) throws {
let fileAttributes = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
Expand Down
223 changes: 223 additions & 0 deletions Meshtastic/Helpers/SitePlanner/CoverageEstimateRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
//
// CoverageEstimateRunner.swift
// Meshtastic
//
// Drives the hosted Meshtastic Site Planner headlessly to compute a coverage
// estimate and hand the styled GeoJSON straight back to the app — no visible
// browser hop or manual share-sheet tap.
//
// There is no headless params→GeoJSON HTTP API; the planner is a client-side
// WASM SPLAT!/ITM simulator, so coverage is only ever computed in a WebView.
// We load the planner with `run=1&bridge=1`, inject a tiny `__meshtasticNative`
// shim (the object-injection contract the planner expects, since WKWebView has
// no `addJavascriptInterface`), and receive the result via a script message.
//
// See meshtastic/Meshtastic-Apple#2058.
//

import Foundation
import CoreLocation
import WebKit
import OSLog

/// Progress of a single headless coverage run.
enum CoverageEstimateState: Equatable {
case idle
case running
case imported
case failed(String)
}

/// The result of a successful run: the imported overlay's metadata plus the
/// transmitter coordinate to recenter the map on. `Equatable` (by a per-result
/// token) so it can drive a SwiftUI `onChange`.
struct CoverageEstimateResult: Equatable {
let token = UUID()
let metadata: MapDataMetadata
let coordinate: CLLocationCoordinate2D

static func == (lhs: CoverageEstimateResult, rhs: CoverageEstimateResult) -> Bool {
lhs.token == rhs.token
}
}

@MainActor
final class CoverageEstimateRunner: NSObject, ObservableObject {

/// The message-handler name the injected shim forwards coverage GeoJSON to.
private static let bridgeName = "coverageBridge"
/// Matches the Android reference timeout for a headless run.
private static let timeout: TimeInterval = 45

/// JS injected at `.atDocumentStart` so `__meshtasticNative` exists before the
/// planner's success handler (`postCoverageToBridge`) runs. It defines the
/// object-shaped contract the planner checks for and forwards to the WKWebView
/// message handler.
private static let shimJS = """
window.__meshtasticNative = {
onCoverage: function (geojson) {
window.webkit.messageHandlers.\(bridgeName).postMessage(geojson);
}
};
"""

@Published private(set) var state: CoverageEstimateState = .idle

/// The most recent successful import (overlay metadata + transmitter coordinate).
/// Observe via `onChange` to recenter the map and enable the overlay.
@Published private(set) var importedResult: CoverageEstimateResult?

private var webView: WKWebView?
private var timeoutTask: Task<Void, Never>?
private var params: SitePlannerParameters?
private var didFinish = false

/// Whether a run is currently in flight.
var isRunning: Bool { state == .running }

/// Kick off a headless coverage run for `params`. Requires a valid coordinate.
func start(params: SitePlannerParameters) {
cancel() // tear down any prior run first
guard params.isValid, let url = params.queryURL(autorun: true, bridge: true) else {
state = .failed(String(localized: "Invalid coverage parameters."))
return
}
guard let hostWindow = Self.keyWindow() else {
state = .failed(String(localized: "Could not start the coverage estimate."))
return
}

self.params = params
self.didFinish = false
state = .running

let config = WKWebViewConfiguration()
let controller = WKUserContentController()
controller.add(self, name: Self.bridgeName)
controller.addUserScript(WKUserScript(source: Self.shimJS, injectionTime: .atDocumentStart, forMainFrameOnly: true))
config.userContentController = controller

// The WebView must be attached to a window and non-zero size, or WebGL/WASM
// never gets a GL context and the planner's autorun stalls on map-load. Keep
// it full-size but invisible and non-interactive behind the progress UI.
let webView = WKWebView(frame: hostWindow.bounds, configuration: config)
webView.navigationDelegate = self
webView.isUserInteractionEnabled = false
webView.alpha = 0
webView.isHidden = false
hostWindow.addSubview(webView)
self.webView = webView

Logger.services.info("🗺️ [SitePlanner] Starting headless coverage run: \(url.absoluteString, privacy: .public)")
webView.load(URLRequest(url: url))

timeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.timeout * 1_000_000_000))
guard !Task.isCancelled else { return }
await self?.fail(String(localized: "Coverage estimate timed out."))
}
}

/// Cancel any in-flight run and tear down the WebView.
func cancel() {
didFinish = true
timeoutTask?.cancel()
timeoutTask = nil
teardownWebView()
if state == .running { state = .idle }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Reset back to idle (e.g. when the form is dismissed after success/failure).
func reset() {
cancel()
state = .idle
}

// MARK: - Private

private func teardownWebView() {
if let webView {
webView.stopLoading()
webView.navigationDelegate = nil
webView.configuration.userContentController.removeAllUserScripts()
webView.configuration.userContentController.removeScriptMessageHandler(forName: Self.bridgeName)
webView.removeFromSuperview()
}
webView = nil
}

private func fail(_ message: String) {
guard !didFinish else { return }
didFinish = true
Logger.services.error("🗺️ [SitePlanner] Coverage run failed: \(message, privacy: .public)")
teardownWebView()
timeoutTask?.cancel()
timeoutTask = nil
state = .failed(message)
}

private func handleCoverage(_ geoJSON: String) {
guard !didFinish, let params else { return }
didFinish = true
timeoutTask?.cancel()
timeoutTask = nil
teardownWebView()

let coordinate = CLLocationCoordinate2D(latitude: params.latitude, longitude: params.longitude)
let layerName = params.name.trimmingCharacters(in: .whitespacesAndNewlines)
let importName = layerName.isEmpty ? "Coverage" : layerName

// This method is `@MainActor`, so the Task inherits MainActor isolation — after each
// `await` we're back on the main actor and can assign published state directly.
Task { [weak self] in
do {
let metadata = try await MapDataManager.shared.importFromString(geoJSON, name: importName)
guard let self else { return }
self.importedResult = CoverageEstimateResult(metadata: metadata, coordinate: coordinate)
self.state = .imported
} catch {
self?.state = .failed(error.localizedDescription)
}
}
}

/// The foreground key window to host the headless WebView.
private static func keyWindow() -> UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive }
.flatMap { $0.windows }
.first { $0.isKeyWindow } ??
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first
}
}

// MARK: - WKScriptMessageHandler

extension CoverageEstimateRunner: WKScriptMessageHandler {
nonisolated func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == Self.bridgeName, let body = message.body as? String else { return }
Task { @MainActor [weak self] in
self?.handleCoverage(body)
}
}
}

// MARK: - WKNavigationDelegate

extension CoverageEstimateRunner: WKNavigationDelegate {
nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
Task { @MainActor [weak self] in
self?.fail(error.localizedDescription)
}
}

nonisolated func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
Task { @MainActor [weak self] in
self?.fail(error.localizedDescription)
}
}
}
Loading
Loading