Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
17 changes: 17 additions & 0 deletions FindMySyncPlus.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AABB0002AABB0002AABB0002 /* CacheDecryptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABB0001AABB0001AABB0001 /* CacheDecryptorTests.swift */; };
CC110002CC110002CC110002 /* TextSanitizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC110001CC110001CC110001 /* TextSanitizationTests.swift */; };
CC110004CC110004CC110004 /* LocalStorageDecryptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC110003CC110003CC110003 /* LocalStorageDecryptorTests.swift */; };
MQTT0001MQTT0001MQTT0001 /* CocoaMQTT in Frameworks */ = {isa = PBXBuildFile; productRef = MQTT0002MQTT0002MQTT0002 /* CocoaMQTT */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -66,6 +67,7 @@
buildActionMask = 2147483647;
files = (
5DAE2C4B2E73A82E00EF354B /* Ink in Frameworks */,
MQTT0001MQTT0001MQTT0001 /* CocoaMQTT in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -136,6 +138,7 @@
name = FindMySyncPlus;
packageProductDependencies = (
5DAE2C4A2E73A82E00EF354B /* Ink */,
MQTT0002MQTT0002MQTT0002 /* CocoaMQTT */,
);
productName = FindMySequoia;
productReference = 5DA3BFED2E5B3B8800B73B01 /* FindMySyncPlus.app */;
Expand Down Expand Up @@ -215,6 +218,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
5DAE2C492E73A82E00EF354B /* XCRemoteSwiftPackageReference "Ink" */,
MQTT0003MQTT0003MQTT0003 /* XCRemoteSwiftPackageReference "CocoaMQTT" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 5DA3BFEE2E5B3B8800B73B01 /* Products */;
Expand Down Expand Up @@ -627,6 +631,14 @@
minimumVersion = 0.6.0;
};
};
MQTT0003MQTT0003MQTT0003 /* XCRemoteSwiftPackageReference "CocoaMQTT" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/emqx/CocoaMQTT.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.1.9;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -635,6 +647,11 @@
package = 5DAE2C492E73A82E00EF354B /* XCRemoteSwiftPackageReference "Ink" */;
productName = Ink;
};
MQTT0002MQTT0002MQTT0002 /* CocoaMQTT */ = {
isa = XCSwiftPackageProductDependency;
package = MQTT0003MQTT0003MQTT0003 /* XCRemoteSwiftPackageReference "CocoaMQTT" */;
productName = CocoaMQTT;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 5DA3BFE52E5B3B8800B73B01 /* Project object */;
Expand Down
2 changes: 1 addition & 1 deletion FindMySyncPlus/HAClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ enum AuthError: LocalizedError {
}
}

private enum PostResult: Sendable {
enum PostResult: Sendable {
case success(id: String, status: Int)
case authRejected(id: String, status: Int) // 401/403
case transient(id: String, reason: String) // network/other HTTP
Expand Down
1 change: 1 addition & 0 deletions FindMySyncPlus/Helpers/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum KeychainKey: String {
case fmipSymmetricKey = "fmipSymmetricKey" // raw bytes
case fmfKey = "fmfKey" // raw bytes (FMF symmetric key)
case localStorageKey = "localStorageKey" // raw 32 bytes (AES-256)
case mqttPassword = "mqttPassword" // String (MQTT broker password)
}

enum Keychain {
Expand Down
21 changes: 20 additions & 1 deletion FindMySyncPlus/LocalStorageDecryptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
private static let reservedSize = 12
private static let sqliteMagic = Data("SQLite format 3\0".utf8)

private static let dbRelativePath = "Library/Group Containers/group.com.apple.findmy.findmylocateagent/Library/Application Support/LocalStorage.db"

Check warning on line 61 in FindMySyncPlus/LocalStorageDecryptor.swift

View workflow job for this annotation

GitHub Actions / lint

Line should be 150 characters or less; currently it has 151 characters (line_length)
private static let walRelativePath = dbRelativePath + "-wal"

// MARK: - Public API
Expand Down Expand Up @@ -97,7 +97,7 @@
return Data(dbData[start..<start + Self.pageSize])
}
for (idx, pageData) in walPages {
if idx < encPages.count {

Check warning on line 100 in FindMySyncPlus/LocalStorageDecryptor.swift

View workflow job for this annotation

GitHub Actions / lint

`where` clauses are preferred over a single `if` inside a `for` (for_where)
encPages[idx] = pageData
}
}
Expand Down Expand Up @@ -236,7 +236,7 @@

// MARK: - SQLite query

private nonisolated func queryFriends(dbPath: URL, logger: LogStore) -> Result<[DevicePoint], LocalStorageDecryptorError> {

Check warning on line 239 in FindMySyncPlus/LocalStorageDecryptor.swift

View workflow job for this annotation

GitHub Actions / lint

Function body should span 60 lines or less excluding comments and whitespace: currently spans 71 lines (function_body_length)
var db: OpaquePointer?
guard sqlite3_open_v2(dbPath.path, &db, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else {
let msg = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown"
Expand Down Expand Up @@ -282,13 +282,31 @@
guard blobLen > 0 else { continue }
let blobData = Data(bytes: blobPtr, count: blobLen)

guard let locDict = try? PropertyListSerialization.propertyList(from: blobData, options: [], format: nil) as? [String: Any] else { continue }

Check warning on line 285 in FindMySyncPlus/LocalStorageDecryptor.swift

View workflow job for this annotation

GitHub Actions / lint

Line should be 150 characters or less; currently it has 153 characters (line_length)

guard let lat = locDict["latitude"] as? Double,
let lon = locDict["longitude"] as? Double else { continue }

let acc = (locDict["horizontalAccuracy"] as? Double) ?? 0

// Extract rich location attributes from the bplist blob
let richTimestamp: Date? = {
if let ts = locDict["timestamp"] as? Double {
// NSDate reference date (2001-01-01) encoded as TimeInterval
return Date(timeIntervalSinceReferenceDate: ts)
}
return nil
}()
let rich = RichLocationAttributes(
verticalAccuracy: locDict["verticalAccuracy"] as? Double,
altitude: locDict["altitude"] as? Double,
speed: locDict["speed"] as? Double,
course: locDict["course"] as? Double,
timestamp: richTimestamp,
motionActivityState: locDict["motionActivityState"] as? Int,
locationLabel: locDict["locationLabel"] as? String
)

let name = prettyName ?? contactId ?? handleId ?? "(unknown)"

// Log types field at debug level for investigation
Expand All @@ -302,7 +320,8 @@
latitude: lat,
longitude: lon,
accuracy: acc,
battery: nil
battery: nil,
richAttributes: rich
))
}

Expand Down
277 changes: 277 additions & 0 deletions FindMySyncPlus/MQTTClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import Foundation
import CocoaMQTT

enum MQTTConnectionState: Sendable {
case disconnected
case connecting
case connected
}

@MainActor
final class MQTTClient: NSObject, ObservableObject {
@Published private(set) var connectionState: MQTTConnectionState = .disconnected

private var client: CocoaMQTT?
private var reconnectTask: Task<Void, Never>?
private var reconnectAttempts = 0
private var publishedDiscoveryIds: Set<String> = []

private weak var logger: LogStore?

func bind(logger: LogStore) {
self.logger = logger
}

// MARK: - Connection lifecycle

func connect(settings: SettingsStore) {
disconnect()
guard !settings.mqttHost.isEmpty else {
logger?.warn("MQTT: host not configured")
return
}

let clientId = "FindMySyncPlus-\(UUID().uuidString.prefix(8))"
let mqtt = CocoaMQTT(
clientID: clientId,
host: settings.mqttHost,
port: UInt16(settings.mqttPort)
)
if !settings.mqttUsername.isEmpty {
mqtt.username = settings.mqttUsername
if !settings.mqttPassword.isEmpty {
mqtt.password = settings.mqttPassword
}
}
mqtt.keepAlive = 60
mqtt.autoReconnect = false
if settings.mqttUseTLS {
mqtt.enableSSL = true
mqtt.allowUntrustCACertificate = true
}
mqtt.delegate = self
client = mqtt

connectionState = .connecting
logger?.info("MQTT connecting to \(settings.mqttHost):\(settings.mqttPort)")
_ = mqtt.connect()
}

func disconnect() {
reconnectTask?.cancel()
reconnectTask = nil
reconnectAttempts = 0
client?.disconnect()
client = nil
connectionState = .disconnected
publishedDiscoveryIds.removeAll()
}

func ensureConnected(settings: SettingsStore) async -> Bool {
if connectionState == .connected { return true }
connect(settings: settings)
// Wait up to 5 seconds for connection
for _ in 0..<50 {
try? await Task.sleep(for: .milliseconds(100))
if connectionState == .connected { return true }
}
return connectionState == .connected
}

// MARK: - Connection test

func testConnection(settings: SettingsStore) async -> (Bool, String) {
let wasConnected = connectionState == .connected
if !wasConnected {
connect(settings: settings)
}
// Wait up to 5 seconds
for _ in 0..<50 {
try? await Task.sleep(for: .milliseconds(100))
if connectionState == .connected {
if !wasConnected {
disconnect()
}
return (true, "Connected successfully to \(settings.mqttHost):\(settings.mqttPort)")
}
}
let msg = "Connection failed to \(settings.mqttHost):\(settings.mqttPort)"
if !wasConnected {
disconnect()
}
return (false, msg)
}

// MARK: - Publishing

func post(_ devices: [DevicePoint],
aliasByUUID: [String: String],
settings: SettingsStore,
logger: LogStore,
dryRun: Bool = false) async -> PostSummary {

if dryRun {
for d in devices {
let uuid = d.id.normalized()
if let alias = aliasByUUID[uuid] {
let devId = DeviceAlias.entityID(for: alias)
logger.info("[DRY] Would publish MQTT for dev_id=\(devId)")
} else {
logger.warn("[DRY] Skipping \(uuid): no alias mapping found")
}
}
return PostSummary(successCount: 0, authRejectedCount: 0, transientCount: 0)
}

guard connectionState == .connected, let client else {
logger.warn("MQTT: not connected, skipping publish")
return PostSummary(successCount: 0, authRejectedCount: 0,
transientCount: devices.count)
}

var successCount = 0
var transientCount = 0
let prefix = settings.mqttTopicPrefix
let iso = ISO8601DateFormatter()

for d in devices {
let uuid = d.id.normalized()
guard let alias = aliasByUUID[uuid] else {
transientCount += 1
logger.warn("MQTT: no alias for UUID \(uuid)")
continue
}

let devId = DeviceAlias.entityID(for: alias)

// Publish HA auto-discovery config (once per session)
if !publishedDiscoveryIds.contains(devId) {
let configTopic = "homeassistant/device_tracker/\(devId)/config"
let configPayload: [String: Any] = [
"name": d.name.isEmpty ? alias : d.name,
"unique_id": devId,
"object_id": devId,
"state_topic": "\(prefix)\(devId)/state",
"json_attributes_topic": "\(prefix)\(devId)/attributes",
"source_type": "gps"
]
publishJSON(client: client, topic: configTopic, payload: configPayload, retain: true)
publishedDiscoveryIds.insert(devId)
logger.info("MQTT discovery published for \(devId)")
}

// Publish state
let stateMsg = CocoaMQTTMessage(
topic: "\(prefix)\(devId)/state",
string: "not_home",
qos: .qos1,
retained: false
)
client.publish(stateMsg)

// Build and publish attributes
let attrs = buildAttributes(for: d, iso: iso)
publishJSON(client: client, topic: "\(prefix)\(devId)/attributes", payload: attrs, retain: true)
successCount += 1
logger.info("[\(devId)] MQTT published")
}

return PostSummary(successCount: successCount,
authRejectedCount: 0,
transientCount: transientCount)
}

// MARK: - Attribute building

private func buildAttributes(for device: DevicePoint, iso: ISO8601DateFormatter) -> [String: Any] {
var attrs: [String: Any] = [
"latitude": device.latitude,
"longitude": device.longitude,
"gps_accuracy": device.accuracy,
"last_update": iso.string(from: Date())
]
if let b = device.battery {
attrs["battery"] = Int((b * 100).rounded())
}
if let rich = device.richAttributes {
if let alt = rich.altitude { attrs["altitude"] = alt }
if let speed = rich.speed { attrs["speed"] = speed }
if let course = rich.course { attrs["course"] = course }
if let vAcc = rich.verticalAccuracy { attrs["vertical_accuracy"] = vAcc }
if let ts = rich.timestamp {
attrs["location_timestamp"] = iso.string(from: ts)
}
if rich.motionActivityState != nil {
attrs["motion_state"] = rich.motionStateDescription.lowercased()
}
if let label = rich.locationLabel {
attrs["location_label"] = label
}
}
return attrs
}

// MARK: - Helpers

private func publishJSON(client: CocoaMQTT, topic: String, payload: [String: Any], retain: Bool) {
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8) else { return }
let msg = CocoaMQTTMessage(topic: topic, string: json, qos: .qos1, retained: retain)
client.publish(msg)
}

private func scheduleReconnect(settings: SettingsStore) {
reconnectTask?.cancel()
reconnectAttempts += 1
guard reconnectAttempts <= 10 else {
logger?.warn("MQTT: max reconnect attempts reached")
return
}
let delay = min(Double(reconnectAttempts) * 5.0, 60.0)
connectionState = .connecting
logger?.warn("MQTT reconnecting (attempt \(reconnectAttempts), \(Int(delay))s)")
let settingsRef = settings
reconnectTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
self?.connect(settings: settingsRef)
}
}
}

// MARK: - CocoaMQTTDelegate

extension MQTTClient: CocoaMQTTDelegate {
nonisolated func mqtt(_ mqtt: CocoaMQTT, didConnectAck ack: CocoaMQTTConnAck) {
let accepted = (ack == .accept)
let ackDesc = "\(ack)"
Task { @MainActor in
if accepted {
self.connectionState = .connected
self.reconnectAttempts = 0
self.reconnectTask?.cancel()
self.logger?.info("MQTT connected")
} else {
self.logger?.error("MQTT connection rejected: \(ackDesc)")
self.connectionState = .disconnected
}
}
}

nonisolated func mqttDidDisconnect(_ mqtt: CocoaMQTT, withError err: (any Error)?) {
Task { @MainActor in
self.connectionState = .disconnected
if let err {
self.logger?.warn("MQTT disconnected: \(err.localizedDescription)")
}
}
}

nonisolated func mqtt(_ mqtt: CocoaMQTT, didPublishMessage message: CocoaMQTTMessage, id: UInt16) {}
nonisolated func mqtt(_ mqtt: CocoaMQTT, didPublishAck id: UInt16) {}
nonisolated func mqtt(_ mqtt: CocoaMQTT, didReceiveMessage message: CocoaMQTTMessage, id: UInt16) {}
nonisolated func mqtt(_ mqtt: CocoaMQTT, didSubscribeTopics success: NSDictionary, failed: [String]) {}
nonisolated func mqtt(_ mqtt: CocoaMQTT, didUnsubscribeTopics topics: [String]) {}
nonisolated func mqttDidPing(_ mqtt: CocoaMQTT) {}
nonisolated func mqttDidReceivePong(_ mqtt: CocoaMQTT) {}
}
Loading
Loading