Meshtastic-Apple/Meshtastic/Router/Router.swift
2026-04-20 11:05:01 -07:00

186 lines
5.3 KiB
Swift

import Combine
import CoreData
import OSLog
import SwiftUI
@MainActor
class Router: ObservableObject {
@Published
var selectedTab: NavigationState.Tab
@Published
var messagesState: MessagesNavigationState?
@Published
var nodeListSelectedNodeNum: Int64?
@Published
var mapState: MapNavigationState?
@Published
var settingsState: SettingsNavigationState?
/// Computed property that assembles the individual per-tab properties into a `NavigationState`.
/// Provided for backward compatibility (e.g. tests) and convenience.
/// Use the individual `@Published` properties to mutate navigation state.
var navigationState: NavigationState {
NavigationState(
selectedTab: selectedTab,
messages: messagesState,
nodeListSelectedNodeNum: nodeListSelectedNodeNum,
map: mapState,
settings: settingsState
)
}
// MARK: Node Object ID Cache
/// In-memory cache mapping node numbers to their Core Data `NSManagedObjectID` for O(1) lookups.
/// Thread-safe by virtue of Router's @MainActor isolation all access is on the main thread.
private var nodeObjectIDCache: [Int64: NSManagedObjectID] = [:]
/// Updates the node cache from a set of fetched nodes. Call this when the node list changes.
func updateNodeIndex<C: Collection>(from nodes: C) where C.Element: NodeInfoEntity {
nodeObjectIDCache = Dictionary(
nodes.map { ($0.num, $0.objectID) },
uniquingKeysWith: { _, new in new }
)
}
/// Looks up a node using the in-memory cache for O(1) performance, falling back to a Core Data fetch.
func cachedNodeInfo(id: Int64, context: NSManagedObjectContext) -> NodeInfoEntity? {
if let objectID = nodeObjectIDCache[id] {
if let node = try? context.existingObject(with: objectID) as? NodeInfoEntity {
return node
}
// Stale entry (object deleted or faulted) evict and fall back to a fresh fetch
nodeObjectIDCache.removeValue(forKey: id)
}
// Cache miss fall back to standard fetch
let node = getNodeInfo(id: id, context: context)
if let node {
nodeObjectIDCache[id] = node.objectID
}
return node
}
private var cancellables: Set<AnyCancellable> = []
init(
navigationState: NavigationState = NavigationState(
selectedTab: .connect
)
) {
self.selectedTab = navigationState.selectedTab
self.messagesState = navigationState.messages
self.nodeListSelectedNodeNum = navigationState.nodeListSelectedNodeNum
self.mapState = navigationState.map
self.settingsState = navigationState.settings
$selectedTab.sink { tab in
Logger.services.info("🛣 [App] Routed to \(tab.rawValue, privacy: .public)")
}.store(in: &cancellables)
}
func route(url: URL) {
guard url.scheme == "meshtastic" else {
Logger.services.error("🛣 [App] Received routing URL \(url, privacy: .public) with invalid scheme. Ignoring route.")
return
}
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
Logger.services.error("🛣 [App] Received routing URL \(url, privacy: .public) with invalid host path. Ignoring route.")
return
}
if components.path == "/messages" {
routeMessages(components)
} else if components.path == "/connect" {
selectedTab = .connect
} else if components.path == "/nodes" {
routeNodes(components)
} else if components.path == "/map" {
routeMap(components)
} else if components.path.hasPrefix("/settings") {
routeSettings(components)
} else {
Logger.services.warning("🛣 [App] Failed to route url: \(url, privacy: .public)")
}
}
// MARK: Routing Helpers
private func routeMessages(
_ components: URLComponents
) {
let channelId = components.queryItems?
.first(where: { $0.name == "channelId" })?
.value
.flatMap(Int32.init)
let userNum = components.queryItems?
.first(where: { $0.name == "userNum" })?
.value
.flatMap(Int64.init)
let messageId = components.queryItems?
.first(where: { $0.name == "messageId" })?
.value
.flatMap(Int64.init)
let state: MessagesNavigationState? = if let channelId {
.channels(channelId: channelId, messageId: messageId)
} else if let userNum {
.directMessages(userNum: userNum, messageId: messageId)
} else {
nil
}
selectedTab = .messages
messagesState = state
}
private func routeNodes(_ components: URLComponents) {
let nodeId = components.queryItems?
.first(where: { $0.name == "nodenum" })?
.value
.flatMap(Int64.init)
selectedTab = .nodes
nodeListSelectedNodeNum = nodeId
}
func navigateToNodeDetail(nodeNum: Int64) {
Logger.services.info("🛣 [App] Direct route to node detail \(nodeNum, privacy: .public)")
selectedTab = .nodes
nodeListSelectedNodeNum = nodeNum
}
private func routeMap(_ components: URLComponents) {
let nodeId = components.queryItems?
.first(where: { $0.name == "nodenum" })?
.value
.flatMap(Int64.init)
let waypointId = components.queryItems?
.first(where: { $0.name == "waypointId" })?
.value
.flatMap(Int64.init)
selectedTab = .map
mapState = if let nodeId {
.selectedNode(nodeId)
} else if let waypointId {
.waypoint(waypointId)
} else {
nil
}
}
private func routeSettings(_ components: URLComponents) {
let settingFromPath = components.path
.split(separator: "/")
.dropFirst()
.first
.flatMap(String.init)
.flatMap(SettingsNavigationState.init(rawValue:))
selectedTab = .settings
settingsState = settingFromPath
}
}