Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ad25342
Bump version
garthvh Oct 18, 2025
69c318a
Message list performance fixes into 2.7.6 (#1475)
garthvh Oct 18, 2025
6705a18
Explicitly set unmessagable, seems unnessary
garthvh Oct 18, 2025
b6b7107
Merge remote-tracking branch 'refs/remotes/origin/2.7.6'
garthvh Oct 18, 2025
9e0a1ff
Add back missing mesh map features
garthvh Oct 20, 2025
16e56e7
Fix: "Retrieving nodes" significantly slower after reconnect extract…
garthvh Oct 20, 2025
4114722
Hide route lines filter from mesh map
garthvh Oct 21, 2025
1d49e02
Merge remote-tracking branch 'refs/remotes/origin/2.7.6'
garthvh Oct 21, 2025
6c3c022
Mesh Map: fuzz imprecise locations so they're distinguishable and cli…
compumike Oct 21, 2025
4aa56b1
Fix bad merge
garthvh Oct 21, 2025
ddb01f5
Update Meshtastic/Extensions/CoreData/UserEntityExtension.swift
garthvh Oct 27, 2025
428b144
Update Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.s…
garthvh Oct 27, 2025
4facf10
Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift
garthvh Oct 27, 2025
5ecad21
Update Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift
garthvh Oct 27, 2025
0c3f1bd
Update Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift
garthvh Oct 27, 2025
3f27e3b
Keep list of previous manual connections (#1484)
jake-b Oct 28, 2025
7668a7a
Show who relayed messages (#1486)
RCGV1 Oct 28, 2025
e7b3583
upsertPositionPacket: don't use future timestamps to set node's lastH…
compumike Oct 28, 2025
92b1646
R1 NEO
garthvh Oct 28, 2025
12a1ca1
Neo
garthvh Oct 28, 2025
ebc84d3
Merge remote-tracking branch 'refs/remotes/origin/2.7.6'
garthvh Oct 28, 2025
58b1204
Update Meshtastic/Views/Settings/AppSettings.swift
garthvh Oct 28, 2025
3b9c0bf
Remove bad if
garthvh Oct 28, 2025
9e8290c
Merge remote-tracking branch 'refs/remotes/origin/2.7.6'
garthvh Oct 28, 2025
247ec49
Git rid of extra environment variable
garthvh Oct 28, 2025
59d106a
Update Meshtastic/Accessory/Transports/TCP/TCPTransport.swift
jake-b Oct 29, 2025
8df7140
MeshMap performance: quick wins (#1490)
compumike Oct 30, 2025
402cb83
NodeMap performance improvements for high # positions history (#1480)
compumike Oct 30, 2025
2ee6cdf
Fix wantRangeTestPackets to correctly follow rangeTestConfig.enabled …
compumike Oct 30, 2025
0fcf4fd
Fix interval drop down formatter
garthvh Oct 31, 2025
b4c749a
Clean up channel qr code functionality.
garthvh Nov 1, 2025
b327f13
perferredPeripheralId fix
jake-b Nov 1, 2025
feb9cf1
Set opt in
garthvh Nov 2, 2025
872c1ef
Retry once 5 second timer. dont throw the error
garthvh Nov 2, 2025
0f90d84
Queue for peripherals
garthvh Nov 6, 2025
ec5dfd5
Fix: hoplimit of dms would always fallback to hops away of the node e…
RCGV1 Nov 6, 2025
b51b5aa
Don't favorite client base
garthvh Nov 18, 2025
6aca186
Update device hardware
garthvh Nov 18, 2025
5762677
Prevent nil environment metrics
garthvh Nov 18, 2025
5707896
Bump datadog sdk
garthvh Nov 24, 2025
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
15 changes: 7 additions & 8 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -3905,10 +3905,6 @@
}
}
},
"Anonymous Usage and Crash data" : {
"comment" : "A description of how the app collects and uses data about its usage and crashes. It emphasizes that this data is anonymous and non-personally identifiable.",
"isCommentAutoGenerated" : true
},
"Any missed messages will be delivered again." : {
"localizations" : {
"it" : {
Expand Down Expand Up @@ -20930,6 +20926,10 @@
}
}
},
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : {
"comment" : "A description of Meshtastic's data collection practices.",
"isCommentAutoGenerated" : true
},
"Meshtastic Node %@ has shared channels with you" : {
"localizations" : {
"de" : {
Expand Down Expand Up @@ -40176,6 +40176,9 @@
}
}
}
},
"User Privacy" : {

},
"User Uploaded" : {
"comment" : "Data source label for user uploaded files",
Expand Down Expand Up @@ -41040,10 +41043,6 @@
},
"Waypoints" : {

},
"We anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : {
"comment" : "A description of how the app collects and uses user data. Includes a link to the app settings.",
"isCommentAutoGenerated" : true
},
"Weather Conditions" : {
"localizations" : {
Expand Down
8 changes: 4 additions & 4 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2094,7 +2094,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.5;
MARKETING_VERSION = 2.7.6;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
Expand Down Expand Up @@ -2129,7 +2129,7 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.5;
MARKETING_VERSION = 2.7.6;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
Expand Down Expand Up @@ -2161,7 +2161,7 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.5;
MARKETING_VERSION = 2.7.6;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -2194,7 +2194,7 @@
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.5;
MARKETING_VERSION = 2.7.6;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,11 @@ extension AccessoryManager {
return
}

// Check if we're in database retrieval mode to defer saves for performance
let isRetrievingDatabase = if case .retrievingDatabase = self.state { true } else { false }

// TODO: nodeInfoPacket's channel: parameter is not used
if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context) {
if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context, deferSave: isRetrievingDatabase) {
if let activeDevice = activeConnection?.device, activeDevice.num == nodeInfo.num {
if let user = nodeInfo.user {
updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ extension AccessoryManager {
}
}
// Set initial unread message badge states
appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0
appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0
appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages(context: context) ?? 0
appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 // skipLastMessageCheck=true because we don't update lastMessage on our own connected node
}
if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true {
wantRangeTestPackets = true
Expand Down
11 changes: 11 additions & 0 deletions Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,17 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
self.firstDatabaseNodeInfoContinuation = nil
}

// Perform a single batch save after database retrieval completes
// This significantly improves performance on reconnect
do {
try context.save()
Logger.data.info("💾 [Database] Batch saved all node info after database retrieval")
} catch {
context.rollback()
let nsError = error as NSError
Logger.data.error("💥 [Database] Error saving batch node info: \(nsError, privacy: .public)")
}

default:
Logger.transport.error("[Accessory] Unknown nonce completed: \(configCompleteID)")
}
Expand Down
6 changes: 5 additions & 1 deletion Meshtastic/Accessory/Protocols/Device.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ struct Device: Identifiable, Hashable {

var connectionState: ConnectionState
var wasRestored: Bool = false
init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, wasRestored: Bool = false) {

var connectionDetails: String?

init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, connectionDetails: String? = nil, wasRestored: Bool = false) {
self.id = id
self.name = name
self.transportType = transportType
Expand All @@ -32,6 +35,7 @@ struct Device: Identifiable, Hashable {
self.rssi = rssi
self.num = num
self.wasRestored = wasRestored
self.connectionDetails = connectionDetails
}

var rssiString: String {
Expand Down
21 changes: 19 additions & 2 deletions Meshtastic/Accessory/Transports/TCP/TCPTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,27 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe
// Save the resolved service locally for later
services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port)

let name: String
if let txtRecords = service.txtRecordData().map({NetService.dictionary(fromTXTRecord: $0)}) {
var nodeNameString = ""
if let shortNameData = txtRecords["shortname"] {
nodeNameString += String(decoding: shortNameData, as: UTF8.self)
}
if let nodeId = txtRecords["id"], nodeId.count > 4 {
if nodeNameString.count > 0 {
nodeNameString += "_"
}
nodeNameString += String(decoding: nodeId.suffix(4), as: UTF8.self)
}
Comment on lines +87 to +92
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number 4 is used without explanation. Add a comment or named constant to clarify why the last 4 characters of the node ID are used, e.g., let nodeIdSuffixLength = 4 // Last 4 hex digits of node ID.

Copilot uses AI. Check for mistakes.
name = nodeNameString
} else {
name = "\(service.name) (\(ip))"
}
let device = Device(id: idString,
name: "\(service.name) (\(ip))",
name: name,
transportType: .tcp,
identifier: "\(host):\(port)")
identifier: "\(host):\(port)",
connectionDetails: "\(ip):\(port)")
continuation?.yield(.deviceFound(device))
}

Expand Down
36 changes: 30 additions & 6 deletions Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,46 @@ import CoreData
import MeshtasticProtobufs

extension ChannelEntity {
var messagePredicate: NSPredicate {
return NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", self.index)
}

var allPrivateMessages: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
var messageFetchRequest: NSFetchRequest<MessageEntity> {
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
fetchRequest.predicate = NSPredicate(format: "channel == %ld AND toUser == nil AND isEmoji == false", self.index)
fetchRequest.predicate = messagePredicate
return fetchRequest
}

var allPrivateMessages: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest

return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
}

var unreadMessages: Int {
var mostRecentPrivateMessage: MessageEntity? {
// Most recent channel message (descending, limit 1)
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)]
fetchRequest.fetchLimit = 1

return (try? context.fetch(fetchRequest))?.first
}

let unreadMessages = allPrivateMessages.filter { ($0 as AnyObject).read == false }
return unreadMessages.count
func unreadMessages(context: NSManagedObjectContext) -> Int {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelvant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])

return (try? context.count(for: fetchRequest)) ?? 0
}

// Backwards-compatible property (uses viewContext)
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }

var protoBuf: Channel {
var channel = Channel()
channel.index = self.index
Expand Down
30 changes: 23 additions & 7 deletions Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,39 @@
//

import Foundation
import CoreData

extension MyInfoEntity {
var messagePredicate: NSPredicate {
return NSPredicate(format: "toUser == nil AND isEmoji == false")
}

var messageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
var messageFetchRequest: NSFetchRequest<MessageEntity> {
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
fetchRequest.predicate = NSPredicate(format: "toUser == nil")
fetchRequest.predicate = messagePredicate
return fetchRequest
}

return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
var messageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest

return (try? context.fetch(messageFetchRequest)) ?? [MessageEntity]()
}

var unreadMessages: Int {
let unreadMessages = messageList.filter { ($0 as AnyObject).read == false && ($0 as AnyObject).isEmoji == false }
return unreadMessages.count
func unreadMessages(context: NSManagedObjectContext) -> Int {
// Returns the count of unread *channel* messages
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelvant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])

return (try? context.count(for: fetchRequest)) ?? 0
}

// Backwards-compatible property (uses viewContext)
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }

var hasAdmin: Bool {
let adminChannel = channels?.filter { ($0 as AnyObject).name?.lowercased() == "admin" }
return adminChannel?.count ?? 0 > 0
Expand Down
30 changes: 30 additions & 0 deletions Meshtastic/Extensions/CoreData/PositionEntityExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,40 @@ extension PositionEntity {
}
return pointAnn
}

var isPreciseLocation: Bool {
precisionBits == 32 || precisionBits == 0
}

var fuzzedNodeCoordinate: CLLocationCoordinate2D? {
// With reduced precisionBits, many nodes can overlap on the map, making them unclickable.
// Use a hash of the position ID to fuzz coordinate slightly so that these nodes can be distinguished at the higest zoom levels. This allows them to be clicked individually.
if latitudeI != 0 && longitudeI != 0 {
// Derive two uniform pseudorandom numbers [0,1) from id.hashValue
let u1 = Double(id.hashValue & 0xFFFF) / 65536.0
let u2 = Double((id.hashValue >> 16) & 0xFFFF) / 65536.0

// Angle and radius
let offsetAngle = 2.0 * .pi * u1
let offsetRadius = 0.00001 * sqrt(u2) // 1.0e-5 degrees at equator is about 1.11 m or 4 ft

let dLat = sin(offsetAngle) * offsetRadius
let dLon = cos(offsetAngle) * offsetRadius

let coord = CLLocationCoordinate2D(
latitude: latitude! + dLat,
longitude: longitude! + dLon
)
return coord
} else {
return nil
}
}
}

extension PositionEntity: MKAnnotation {
public var coordinate: CLLocationCoordinate2D { nodeCoordinate ?? LocationsHandler.DefaultLocation }
public var fuzzedCoordinate: CLLocationCoordinate2D { fuzzedNodeCoordinate ?? LocationsHandler.DefaultLocation }
public var title: String? { nodePosition?.user?.shortName ?? "Unknown".localized }
public var subtitle: String? { time?.formatted() }
}
45 changes: 39 additions & 6 deletions Meshtastic/Extensions/CoreData/UserEntityExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,37 @@ import CoreData
import MeshtasticProtobufs

extension UserEntity {
var messagePredicate: NSPredicate {
return NSPredicate(format: "((toUser == %@) OR (fromUser == %@)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10", self, self)
}

var messageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
var messageFetchRequest: NSFetchRequest<MessageEntity> {
let fetchRequest = MessageEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: true)]
fetchRequest.predicate = NSPredicate(format: "((toUser == %@) OR (fromUser == %@)) AND toUser != nil AND fromUser != nil AND isEmoji == false AND admin = false AND portNum != 10", self, self)
fetchRequest.predicate = messagePredicate
return fetchRequest
}

var messageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest

return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
}

var mostRecentMessage: MessageEntity? {
// Most contacts will have no DMs history, so we can return early.
guard self.lastMessage != nil else { return nil; }

// Most recent DM for this user (descending, limit 1)
let context = PersistenceController.shared.container.viewContext
let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "messageTimestamp", ascending: false)]
fetchRequest.fetchLimit = 1

return (try? context.fetch(fetchRequest))?.first
}

var sensorMessageList: [MessageEntity] {
let context = PersistenceController.shared.container.viewContext
let fetchRequest = MessageEntity.fetchRequest()
Expand All @@ -29,10 +50,21 @@ extension UserEntity {
return (try? context.fetch(fetchRequest)) ?? [MessageEntity]()
}

var unreadMessages: Int {
let unreadMessages = messageList.filter { ($0 as AnyObject).read == false && ($0 as AnyObject).isEmoji == false }
return unreadMessages.count
func unreadMessages(context: NSManagedObjectContext, skipLastMessageCheck: Bool = false) -> Int {
// Most contacts will have no DMs history, so we can return early.
// (For our own node, set skipLastMessageCheck=true, because we don't update lastMessage on our own connected node.)
guard self.lastMessage != nil || skipLastMessageCheck else { return 0; }

let fetchRequest = messageFetchRequest
fetchRequest.sortDescriptors = [] // sort is irrelvant.
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])

return (try? context.count(for: fetchRequest)) ?? 0
}

// Backwards-compatible property (uses viewContext)
var unreadMessages: Int { unreadMessages(context: PersistenceController.shared.container.viewContext) }

/// SVG Images for Vendors who are signed project backers
var hardwareImage: String? {
guard let hwModel else { return nil }
Expand Down Expand Up @@ -130,6 +162,7 @@ public func createUser(num: Int64, context: NSManagedObjectContext) throws -> Us
newUser.longName = "Meshtastic \(last4)"
newUser.shortName = last4
newUser.hwModel = "UNSET"
newUser.unmessagable = false
}

return newUser
Expand Down
Loading