diff --git a/Localizable.xcstrings b/Localizable.xcstrings
index 33be032d5..04b316d5a 100644
--- a/Localizable.xcstrings
+++ b/Localizable.xcstrings
@@ -9143,6 +9143,10 @@
}
}
},
+ "Create Node Contact NFC Tag" : {
+ "comment" : "A section header that instructs the user to create a contact NFC tag.",
+ "isCommentAutoGenerated" : true
+ },
"Create Waypoint" : {
"localizations" : {
"de" : {
@@ -23746,6 +23750,10 @@
}
}
},
+ "Node Name: %@" : {
+ "comment" : "A text label displaying the name of the connected node.",
+ "isCommentAutoGenerated" : true
+ },
"Node Number" : {
"localizations" : {
"de" : {
@@ -37823,6 +37831,9 @@
}
}
}
+ },
+ "Tools" : {
+
},
"Topic: %@" : {
"localizations" : {
@@ -41719,6 +41730,10 @@
}
}
},
+ "Write Contact to NFC Tag" : {
+ "comment" : "A button that writes a contact to an NFC tag.",
+ "isCommentAutoGenerated" : true
+ },
"x" : {
"localizations" : {
"it" : {
@@ -42321,4 +42336,4 @@
}
},
"version" : "1.1"
-}
+}
\ No newline at end of file
diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist
index 863fb0e94..c2cbcdd8c 100644
--- a/Meshtastic/Info.plist
+++ b/Meshtastic/Info.plist
@@ -97,6 +97,8 @@
LSSupportsOpeningDocumentsInPlace
+ NFCReaderUsageDescription
+ We use NFC tags to share node contacts
NSBluetoothAlwaysUsageDescription
We use bluetooth to connect to nearby Meshtastic Devices
NSBluetoothPeripheralUsageDescription
diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements
index 4dbdb836e..a35e74eed 100644
--- a/Meshtastic/Meshtastic.entitlements
+++ b/Meshtastic/Meshtastic.entitlements
@@ -9,6 +9,10 @@
com.apple.developer.carplay-communication
+ com.apple.developer.nfc.readersession.formats
+
+ TAG
+
com.apple.developer.usernotifications.critical-alerts
com.apple.developer.weatherkit
diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift
index 48a97b93d..8c2ff6b37 100644
--- a/Meshtastic/Router/NavigationState.swift
+++ b/Meshtastic/Router/NavigationState.swift
@@ -52,6 +52,7 @@ enum SettingsNavigationState: String {
case debugLogs
case appFiles
case firmwareUpdates
+ case tools
}
struct NavigationState: Hashable {
diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift
index d3d15a66e..25b0e75a5 100644
--- a/Meshtastic/Views/Settings/Settings.swift
+++ b/Meshtastic/Views/Settings/Settings.swift
@@ -355,6 +355,13 @@ struct Settings: View {
Image(systemName: "gearshape")
}
}
+ NavigationLink(value: SettingsNavigationState.tools) {
+ Label {
+ Text("Tools")
+ } icon: {
+ Image(systemName: "hammer")
+ }
+ }
NavigationLink(value: SettingsNavigationState.routes) {
Label {
Text("Routes")
@@ -521,6 +528,8 @@ struct Settings: View {
AppData()
case .firmwareUpdates:
Firmware(node: node)
+ case .tools:
+ Tools()
}
}
.onChange(of: UserDefaults.preferredPeripheralNum ) { _, newConnectedNode in
diff --git a/Meshtastic/Views/Settings/Tools.swift b/Meshtastic/Views/Settings/Tools.swift
new file mode 100644
index 000000000..75e439de8
--- /dev/null
+++ b/Meshtastic/Views/Settings/Tools.swift
@@ -0,0 +1,164 @@
+//
+// Tools.swift
+// Meshtastic
+//
+// Created by Benjamin Faershtein on 12/31/25.
+//
+
+import SwiftUI
+import CoreNFC
+import MeshtasticProtobufs
+import OSLog
+
+struct Tools: View {
+ @EnvironmentObject var accessoryManager: AccessoryManager
+ @Environment(\.managedObjectContext) var context
+
+ @StateObject private var nfcReader = NFCReader()
+
+ var connectedNode: NodeInfoEntity? {
+ if let num = accessoryManager.activeDeviceNum {
+ return getNodeInfo(id: num, context: context)
+ }
+ return nil
+ }
+
+ var qrString: String {
+ var contact = SharedContact()
+ contact.nodeNum = UInt32(connectedNode?.num ?? 0)
+ contact.user = connectedNode?.toProto().user ?? User()
+ contact.manuallyVerified = true
+
+ do {
+ let contactString = try contact.serializedData().base64EncodedString()
+ return "https://meshtastic.org/v/#" + contactString.base64ToBase64url()
+ } catch {
+ Logger.services.error("Error serializing contact: \(error)")
+ return ""
+ }
+ }
+
+ var body: some View {
+ VStack{
+ List {
+ Section(header: Text("Create Node Contact NFC Tag")) {
+ if let node = connectedNode {
+ Text("Node Name: \(node.user?.longName ?? "Unknown")")
+
+ Button {
+ nfcReader.scan(theActualData: qrString)
+ } label: {
+ Label("Write Contact to NFC Tag", systemImage: "tag")
+ }
+ .disabled(qrString.isEmpty)
+ }
+ }
+ }
+ }
+ .navigationTitle("Tools")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
+
+#Preview {
+ Tools()
+}
+
+final class NFCReader: NSObject, ObservableObject, NFCNDEFReaderSessionDelegate {
+
+ private let logger = Logger(subsystem: "org.meshtastic.app", category: "NFC")
+ private var payloadString = ""
+ private var session: NFCNDEFReaderSession?
+
+ func scan(theActualData: String) {
+ payloadString = theActualData
+
+ session = NFCNDEFReaderSession(
+ delegate: self,
+ queue: nil,
+ invalidateAfterFirstRead: false
+ )
+
+ session?.alertMessage = "Hold your iPhone near the NFC tag."
+ session?.begin()
+ }
+
+ func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
+ logger.debug("NFC session became active")
+ }
+
+ func readerSession(_ session: NFCNDEFReaderSession,
+ didInvalidateWithError error: Error) {
+ logger.error("NFC session invalidated: \(error.localizedDescription)")
+ }
+
+ func readerSession(_ session: NFCNDEFReaderSession,
+ didDetectNDEFs messages: [NFCNDEFMessage]) {
+ }
+
+ func readerSession(_ session: NFCNDEFReaderSession,
+ didDetect tags: [NFCNDEFTag]) {
+
+ guard tags.count == 1, let tag = tags.first else {
+ session.alertMessage = "More than one tag detected. Please present only one."
+ DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(500)) {
+ session.restartPolling()
+ }
+ return
+ }
+
+ session.connect(to: tag) { error in
+ if let error {
+ self.logger.error("Failed to connect to tag: \(error.localizedDescription)")
+ session.alertMessage = "Failed to connect to tag."
+ session.invalidate()
+ return
+ }
+
+ tag.queryNDEFStatus { status, _, error in
+ if let error {
+ self.logger.error("Failed to query NDEF status: \(error.localizedDescription)")
+ session.alertMessage = "Failed to read tag."
+ session.invalidate()
+ return
+ }
+
+ switch status {
+ case .notSupported:
+ self.logger.error("Tag does not support NDEF")
+ session.alertMessage = "Tag does not support NDEF."
+ session.invalidate()
+
+ case .readOnly:
+ self.logger.error("Tag is read-only")
+ session.alertMessage = "Tag is read-only."
+ session.invalidate()
+
+ case .readWrite:
+ guard let payload =
+ NFCNDEFPayload.wellKnownTypeURIPayload(
+ string: self.payloadString
+ ) else {
+ self.logger.error("Invalid NDEF payload")
+ session.alertMessage = "Invalid payload."
+ session.invalidate()
+ return
+ }
+
+ let message = NFCNDEFMessage(records: [payload])
+
+ tag.writeNDEF(message) { error in
+ if let error {
+ self.logger.error("Failed to write NDEF: \(error.localizedDescription)")
+ session.alertMessage = "Failed to write tag."
+ } else {
+ self.logger.info("Successfully wrote NFC tag")
+ session.alertMessage = "NFC tag written successfully."
+ }
+ session.invalidate()
+ }
+ }
+ }
+ }
+ }
+}