feat: add native local location capture (#4)

Add explicit permission handling, independent continuous and single capture,
location status and deterministic CI coverage.

Final decisions and validation:
https://github.com/jayanezj/traccar-client-macos/issues/4#issuecomment-6399189792
This commit is contained in:
jayanezj committed 2026-10-25 15:58:05 +01:00
1 parent db90ad9a0e
commit 3f351296b8
18 files changed
+1030 -38

No files matched your search

@@ -5,21 +5,39 @@ import SwiftUI
struct TraccarClientApp: App {
@NSApplicationDelegateAdaptor(ApplicationDelegate.self) private var delegate
@State private var configuration = ConfigurationModel.live()
@State private var configuration: ConfigurationModel
@State private var location: LocationModel
init() {
let storage = PreferencesConfigurationStorage.live()
let capture = LocationModel(provider: CoreLocationProvider(), readAccuracy: {
try storage.loadTrackingPreferences().accuracy
})
_location = State(initialValue: capture)
_configuration = State(initialValue: ConfigurationModel(storage: storage, beforeConnectionChange: { _, _ in
capture.shutdown()
}))
}
var body: some Scene {
MenuBarExtra {
TrackingMenu()
TrackingMenu(location: location)
} label: {
Image("MenuBarIcon")
.renderingMode(.template)
.accessibilityLabel("Traccar Client — Paused")
.accessibilityLabel("Traccar Client — \(location.status)")
}
.menuBarExtraStyle(.menu)
Settings {
SettingsView(model: configuration)
SettingsView(model: configuration, location: location)
}
Window("Location capture", id: "location-capture") {
LocationStatusView(location: location)
}
.windowResizability(.contentSize)
.defaultLaunchBehavior(.suppressed)
}
}
@@ -31,12 +31,12 @@ final class ConfigurationModel {
self.beforeConnectionChange = beforeConnectionChange
}
static func live() -> ConfigurationModel {
ConfigurationModel(storage: PreferencesConfigurationStorage(
preferences: .standard, connectionStorage: KeychainConnectionStorage(
service: "\(Bundle.main.bundleIdentifier ?? "com.codigojose.traccar-client-macos").configuration"
)
))
static func live(
beforeConnectionChange: @escaping (ReceiverConnection, ReceiverConnection) throws -> Void = { _, _ in }
) -> ConfigurationModel {
ConfigurationModel(
storage: PreferencesConfigurationStorage.live(), beforeConnectionChange: beforeConnectionChange
)
}
func loadIfNeeded() {
@@ -83,7 +83,7 @@ final class ConfigurationModel {
self.saved = candidate
draft = candidate
errorMessage = nil
showStatus("Configuration saved locally. Tracking remains paused.")
showStatus("Configuration saved locally. Saving does not start capture or send positions.")
return true
} catch {
errorMessage = message(for: error)
@@ -9,6 +9,7 @@ protocol ConnectionStorage {
@MainActor
protocol ConfigurationStorage {
func loadTrackingPreferences() throws -> TrackingPreferences
func load(allowInteraction: Bool) throws -> AppConfiguration
func save(_ configuration: AppConfiguration) throws
}
@@ -37,7 +38,19 @@ final class PreferencesConfigurationStorage: ConfigurationStorage {
self.connectionStorage = connectionStorage
}
func load(allowInteraction: Bool) throws -> AppConfiguration {
static func live() -> PreferencesConfigurationStorage {
PreferencesConfigurationStorage(preferences: .standard, connectionStorage: KeychainConnectionStorage(
service: "\(Bundle.main.bundleIdentifier ?? "com.codigojose.traccar-client-macos").configuration"
))
}
func loadTrackingPreferences() throws -> TrackingPreferences {
let tracking = try readRecord()?.tracking ?? TrackingPreferences()
try tracking.validate()
return tracking
}
private func readRecord() throws -> Record? {
let data = preferences.data(forKey: Self.preferencesKey)
let record: Record?
if let data {
@@ -52,6 +65,11 @@ final class PreferencesConfigurationStorage: ConfigurationStorage {
} else {
record = nil
}
return record
}
func load(allowInteraction: Bool) throws -> AppConfiguration {
let record = try readRecord()
let connection = try connectionStorage.read(allowInteraction: allowInteraction)
if record != nil && connection == nil { throw ConfigurationError.missingIdentity }
var configuration = AppConfiguration()
@@ -0,0 +1,53 @@
import SwiftUI
struct LocationStatusView: View {
let location: LocationModel
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Label("Location capture", systemImage: "location")
.font(.title2)
Text("Capture your Mac's location continuously or once while paused. Nothing is sent to a server.")
Text("Choose Start or Capture once to allow macOS to request location permission if needed.")
.foregroundStyle(.secondary)
Form {
LabeledContent("Continuous capture", value: location.continuousRequested ? "Active" : "Paused")
LabeledContent("Condition", value: location.status)
LabeledContent("Location Services", value: location.availability.services.rawValue)
LabeledContent("Permission", value: location.availability.permission.rawValue)
LabeledContent("Single capture", value: location.singleStatus.rawValue)
if let fix = location.lastFix {
LabeledContent("Fix timestamp", value: fix.timestamp.formatted(date: .numeric, time: .standard))
LabeledContent("Reported accuracy", value: "\(fix.horizontalAccuracy.formatted()) m")
LabeledContent("Source", value: location.lastFixOrigin?.rawValue ?? "Location Services")
} else {
Text("No location received.").foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
if let error = location.errorMessage ?? location.continuousError {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
HStack {
Button(location.continuousRequested ? "Pause" : "Start") {
if location.continuousRequested { location.pause() } else {
location.startContinuous()
}
}
Button("Capture once") {
location.captureOnce()
}
.disabled(location.singleStatus == .waiting)
if location.singleStatus == .waiting {
Button("Cancel capture") { location.cancelSingle() }
}
Spacer()
Button("Refresh status") { location.refreshAvailability() }
}
}
.padding(20)
.frame(width: 620, height: 540)
.onAppear { location.refreshAvailability() }
}
}
+7 -3
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct SettingsView: View {
@Bindable var model: ConfigurationModel
let location: LocationModel
@State private var focus = WindowFocusController()
var body: some View {
@@ -122,7 +123,7 @@ struct SettingsView: View {
TextField("Heartbeat (seconds)", value: $model.draft.tracking.heartbeatSeconds, format: .number)
Toggle("Buffer positions offline", isOn: $model.draft.tracking.bufferingEnabled)
Toggle("Detect stationary periods", isOn: $model.draft.tracking.stationaryDetectionEnabled)
Text("Preferences are saved for later tracking implementation. Tracking remains paused in this build.")
Text("Accuracy applies on capture start. Selection, buffering and stationary detection are not active yet.")
}
.disabled(!model.hasLoaded)
}
@@ -132,13 +133,16 @@ struct SettingsView: View {
Toggle("Launch at login", isOn: $model.draft.tracking.launchAtLogin).disabled(true)
Toggle("Restore continuous tracking", isOn: $model.draft.tracking.restoreTracking).disabled(true)
Text("Login startup and tracking restoration will be available in a later build. Both default to off.")
Text("Closing Settings keeps the app in the menu bar. Use Quit to exit.")
Text("Closing Settings keeps requested capture running in the menu bar. Use Pause to stop capture.")
}
}
private var diagnostics: some View {
Form {
LabeledContent("Status", value: "Paused")
LabeledContent("Location capture", value: location.continuousRequested ? "Active" : "Paused")
LabeledContent("Condition", value: location.status)
LabeledContent("Location permission", value: location.availability.permission.rawValue)
LabeledContent("Location Services", value: location.availability.services.rawValue)
LabeledContent("Configuration", value: model.hasLoaded ? "Loaded locally" : "Unavailable")
Text("Connection and identity: Keychain. Tracking preferences: UserDefaults.")
Text("No location permission requests or network activity are started by loading or saving settings.")
+30 -4
View File
@@ -3,15 +3,35 @@ import SwiftUI
struct TrackingMenu: View {
@Environment(\.openSettings) private var openSettings
@Environment(\.openWindow) private var openWindow
let location: LocationModel
var body: some View {
Text("Paused")
Text("Tracking is not available in this build.")
Text(location.continuousRequested ? "Location capture active" : "Paused")
Text(location.status)
Text("Local capture only. No positions are sent.")
if let fix = location.lastFix {
Text("Last fix: \(fix.timestamp.formatted(date: .numeric, time: .standard))")
Text("Reported accuracy: \(fix.horizontalAccuracy.formatted()) m")
}
Divider()
Button("Start tracking") {}
.disabled(true)
Button(location.continuousRequested ? "Pause location capture" : "Start location capture") {
if location.continuousRequested { location.pause() } else if location.availability.canCapture {
location.startContinuous()
} else {
showCaptureWindow()
}
}
Button("Capture location once") {
if location.availability.canCapture {
location.captureOnce()
}
showCaptureWindow()
}
.disabled(location.singleStatus == .waiting)
Button("Location status…") { showCaptureWindow() }
Button("Send location now") {}
.disabled(true)
Button("Send SOS") {}
@@ -38,8 +58,14 @@ struct TrackingMenu: View {
Divider()
Button("Quit Traccar Client macOS") {
location.shutdown()
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q")
}
private func showCaptureWindow() {
NSApplication.shared.activate()
openWindow(id: "location-capture")
}
}
@@ -0,0 +1,158 @@
import CoreLocation
import Foundation
@MainActor
final class CoreLocationProvider: LocationProviding {
private(set) var availability: LocationAvailability
var onEvent: ((LocationEvent) -> Void)?
private let permissionManager: CLLocationManager
private var permissionDelegate: NativeLocationDelegate?
private var continuousManager: CLLocationManager?
private var continuousDelegate: NativeLocationDelegate?
private var singleManager: CLLocationManager?
private var singleDelegate: NativeLocationDelegate?
private var availabilityRevision = 0
private var continuousRequest: Int?
private var singleRequest: Int?
init() {
permissionManager = CLLocationManager()
availability = LocationAvailability(
permission: LocationPermission(permissionManager.authorizationStatus), services: .checking
)
permissionDelegate = NativeLocationDelegate { [weak self] _ in self?.refreshAvailability() }
permissionManager.delegate = permissionDelegate
refreshAvailability()
}
func refreshAvailability() {
availability.permission = LocationPermission(permissionManager.authorizationStatus)
onEvent?(.availability(availability))
availabilityRevision += 1
let revision = availabilityRevision
Task { [weak self] in
// The synchronous service-status query may block; do not perform it on the UI thread.
let enabled = await Task.detached { CLLocationManager.locationServicesEnabled() }.value
guard let self, revision == availabilityRevision else { return }
availability.services = enabled ? .enabled : .disabled
onEvent?(.availability(availability))
}
}
func requestAuthorization() { permissionManager.requestWhenInUseAuthorization() }
func startContinuous(accuracy: LocationAccuracy, request: Int) {
stopContinuous()
let manager = CLLocationManager()
continuousManager = manager
continuousRequest = request
continuousDelegate = makeDelegate(kind: .continuous, request: request)
manager.delegate = continuousDelegate
manager.desiredAccuracy = Self.desiredAccuracy(accuracy)
manager.distanceFilter = kCLDistanceFilterNone
manager.startUpdatingLocation()
}
func stopContinuous() {
continuousRequest = nil
continuousManager?.stopUpdatingLocation()
continuousManager?.delegate = nil
continuousManager = nil
continuousDelegate = nil
}
func requestSingle(accuracy: LocationAccuracy, request: Int) {
cancelSingle()
let manager = CLLocationManager()
singleManager = manager
singleRequest = request
singleDelegate = makeDelegate(kind: .single, request: request)
manager.delegate = singleDelegate
manager.desiredAccuracy = Self.desiredAccuracy(accuracy)
manager.requestLocation()
}
func cancelSingle() {
singleRequest = nil
singleManager?.stopUpdatingLocation()
singleManager?.delegate = nil
singleManager = nil
singleDelegate = nil
}
static func desiredAccuracy(_ accuracy: LocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .low: kCLLocationAccuracyKilometer
case .medium: kCLLocationAccuracyHundredMeters
case .high: kCLLocationAccuracyBest
}
}
private func makeDelegate(kind: LocationRequestKind, request: Int) -> NativeLocationDelegate {
NativeLocationDelegate { [weak self] event in
guard let self else { return }
switch event {
case .authorization:
refreshAvailability()
case .locations(let locations):
deliver(locations, kind: kind, request: request)
case .failure(let error):
deliver(error, kind: kind, request: request)
}
}
}
private func deliver(_ locations: [CLLocation], kind: LocationRequestKind, request: Int) {
guard isCurrent(kind, request: request) else { return }
let fixes = locations.compactMap(LocationFix.init)
if fixes.isEmpty {
onEvent?(.failure(kind, request: request, reason: .unavailable, fallback: nil))
} else {
onEvent?(.locations(kind, request: request, fixes: fixes))
}
if kind == .single && singleRequest == request { cancelSingle() }
}
private func deliver(_ error: any Error, kind: LocationRequestKind, request: Int) {
guard isCurrent(kind, request: request) else { return }
let reason: LocationFailure
switch (error as? CLError)?.code {
case .denied: reason = .permissionDenied
case .locationUnknown: reason = .unavailable
default: reason = .other((error as NSError).code)
}
let fallback = singleManager?.location.flatMap(LocationFix.init)
onEvent?(.failure(kind, request: request, reason: reason, fallback: fallback))
if kind == .single && singleRequest == request { cancelSingle() }
if reason == .permissionDenied { refreshAvailability() }
}
private func isCurrent(_ kind: LocationRequestKind, request: Int) -> Bool {
kind == .single ? singleRequest == request : continuousRequest == request
}
}
private enum NativeLocationEvent {
case authorization
case locations([CLLocation])
case failure(any Error)
}
// CLLocationManager is created on MainActor and delivers delegates on that thread's run loop.
@MainActor
private final class NativeLocationDelegate: NSObject, @preconcurrency CLLocationManagerDelegate {
private let receive: (NativeLocationEvent) -> Void
init(receive: @escaping (NativeLocationEvent) -> Void) { self.receive = receive }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { receive(.authorization) }
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
receive(.locations(locations))
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
receive(.failure(error))
}
}
+199
View File
@@ -0,0 +1,199 @@
import Foundation
import Observation
@MainActor
@Observable
final class LocationModel {
private(set) var availability: LocationAvailability
private(set) var continuousRequested = false
private(set) var singleStatus: SingleCaptureStatus = .idle
private(set) var lastFix: LocationFix?
private(set) var lastFixOrigin: LocationFixOrigin?
private(set) var errorMessage: String?
private(set) var continuousError: String?
@ObservationIgnored private let provider: any LocationProviding
@ObservationIgnored private let readAccuracy: () throws -> LocationAccuracy
@ObservationIgnored private var permissionRequested = false
@ObservationIgnored private var continuousRequest: Int?
@ObservationIgnored private var singleRequest: Int?
@ObservationIgnored private var nextRequest = 0
@ObservationIgnored private var continuousBlocked = false
@ObservationIgnored private var continuousAccuracy: LocationAccuracy = .medium
@ObservationIgnored private var singleAccuracy: LocationAccuracy = .medium
init(provider: any LocationProviding, readAccuracy: @escaping () throws -> LocationAccuracy = { .medium }) {
self.provider = provider
self.readAccuracy = readAccuracy
availability = provider.availability
provider.onEvent = { [weak self] in self?.receive($0) }
}
var status: String {
if availability.services == .disabled { return "Location Services disabled" }
if availability.permission == .denied { return "Location permission denied" }
if availability.permission == .restricted { return "Location permission restricted" }
if continuousError != nil { return "Location unavailable" }
if continuousRequested || singleStatus == .waiting {
if !availability.canCapture { return "Waiting for location authorization" }
return lastFix == nil ? "Waiting for location" : "Location capture active"
}
return "Paused"
}
func startContinuous(accuracy: LocationAccuracy? = nil) {
guard !continuousRequested else { return }
guard let selected = selectedAccuracy(accuracy) else { return }
preparePermissionRequest()
continuousRequested = true
continuousAccuracy = selected
continuousBlocked = false
continuousError = nil
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func pause() {
continuousRequested = false
stopContinuous()
continuousError = nil
continuousBlocked = false
}
func captureOnce(accuracy: LocationAccuracy? = nil) {
guard singleStatus != .waiting else { return }
guard let selected = selectedAccuracy(accuracy) else {
singleStatus = .failed
return
}
preparePermissionRequest()
singleStatus = .waiting
singleAccuracy = selected
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func cancelSingle() {
guard singleStatus == .waiting else { return }
singleRequest = nil
singleStatus = .cancelled
provider.cancelSingle()
}
func shutdown() {
pause()
cancelSingle()
}
func refreshAvailability() { provider.refreshAvailability() }
private func preparePermissionRequest() {
// A new explicit attempt may retry an undetermined request; status refreshes never do so on their own.
if !continuousRequested && singleStatus != .waiting { permissionRequested = false }
}
private func selectedAccuracy(_ requested: LocationAccuracy?) -> LocationAccuracy? {
do {
return try requested ?? readAccuracy()
} catch {
errorMessage = "Saved tracking preferences could not be read. Review Settings before capture."
return nil
}
}
private func reconcile() {
guard continuousRequested || singleStatus == .waiting else { return }
if availability.services == .disabled || [.denied, .restricted].contains(availability.permission) {
stopContinuous()
errorMessage = availability.services == .disabled
? "Enable Location Services in System Settings to capture locations."
: "Location access is denied or restricted. Review Location Services in System Settings."
if singleStatus == .waiting {
singleRequest = nil
singleStatus = .failed
provider.cancelSingle()
}
return
}
guard availability.services == .enabled else { return }
if availability.permission == .notDetermined {
guard !permissionRequested else { return }
permissionRequested = true
provider.requestAuthorization()
return
}
guard availability.canCapture else { return }
if continuousRequested && continuousRequest == nil && !continuousBlocked {
nextRequest += 1
continuousRequest = nextRequest
provider.startContinuous(accuracy: continuousAccuracy, request: nextRequest)
}
if singleStatus == .waiting && singleRequest == nil {
nextRequest += 1
singleRequest = nextRequest
provider.requestSingle(accuracy: singleAccuracy, request: nextRequest)
}
}
private func stopContinuous() {
continuousRequest = nil
provider.stopContinuous()
}
private func receive(_ event: LocationEvent) {
switch event {
case .availability(let value):
if availability != value {
continuousBlocked = false
continuousError = nil
errorMessage = nil
}
availability = value
if !value.canCapture { stopContinuous() }
reconcile()
case .locations(let kind, let request, let fixes):
guard accepts(kind, request: request), let fix = fixes.max(by: { $0.timestamp < $1.timestamp }) else {
return
}
lastFix = fix
lastFixOrigin = kind == .single ? .single : .continuous
if kind == .single {
singleStatus = .received
singleRequest = nil
} else {
continuousError = nil
}
errorMessage = nil
case .failure(let kind, let request, let reason, let fallback):
guard accepts(kind, request: request) else { return }
handleFailure(kind, reason: reason, fallback: fallback)
}
}
private func accepts(_ kind: LocationRequestKind, request: Int) -> Bool {
kind == .single ? singleStatus == .waiting && singleRequest == request
: continuousRequested && continuousRequest == request
}
private func handleFailure(_ kind: LocationRequestKind, reason: LocationFailure, fallback: LocationFix?) {
if kind == .continuous {
continuousError = reason.message
if reason == .permissionDenied {
continuousBlocked = true
stopContinuous()
}
return
}
singleRequest = nil
if reason != .permissionDenied, let known = fallback ?? lastFix {
lastFix = known
lastFixOrigin = .lastKnown
singleStatus = .lastKnown
} else {
singleStatus = .failed
}
errorMessage = reason.message
}
}
+102
View File
@@ -0,0 +1,102 @@
import CoreLocation
import Foundation
enum LocationPermission: String {
case notDetermined = "Not requested"
case authorized = "Authorized"
case denied = "Denied"
case restricted = "Restricted"
init(_ status: CLAuthorizationStatus) {
switch status {
case .authorizedAlways: self = .authorized
case .notDetermined: self = .notDetermined
case .denied: self = .denied
default: self = .restricted
}
}
}
enum LocationServices: String {
case checking = "Checking"
case enabled = "Enabled"
case disabled = "Disabled"
}
struct LocationAvailability: Equatable {
var permission: LocationPermission
var services: LocationServices
var canCapture: Bool { permission == .authorized && services == .enabled }
}
struct LocationFix: Equatable {
let latitude: Double
let longitude: Double
let timestamp: Date
let horizontalAccuracy: Double
let altitude: Double?
let speed: Double?
let course: Double?
init?(_ location: CLLocation) {
guard CLLocationCoordinate2DIsValid(location.coordinate),
location.horizontalAccuracy.isFinite, location.horizontalAccuracy >= 0,
location.timestamp.timeIntervalSinceReferenceDate.isFinite else { return nil }
latitude = location.coordinate.latitude
longitude = location.coordinate.longitude
timestamp = location.timestamp
horizontalAccuracy = location.horizontalAccuracy
altitude = location.verticalAccuracy >= 0 && location.altitude.isFinite ? location.altitude : nil
speed = location.speed >= 0 && location.speed.isFinite ? location.speed : nil
course = (0..<360).contains(location.course) && location.course.isFinite ? location.course : nil
}
}
enum LocationFailure: Equatable {
case permissionDenied
case unavailable
case other(Int)
var message: String {
switch self {
case .permissionDenied: "Location access is denied. Review Location Services in System Settings."
case .unavailable: "No location is available. Try again when Location Services can obtain a fix."
case .other(let code): "Location could not be obtained (error \(code))."
}
}
}
enum LocationRequestKind { case continuous, single }
enum LocationFixOrigin: String {
case continuous = "Continuous capture"
case single = "Single capture"
case lastKnown = "Last-known location; no new fix"
}
enum SingleCaptureStatus: String {
case idle = "Idle"
case waiting = "Waiting for location"
case received = "Location received"
case lastKnown = "Last-known location used; no new fix"
case failed = "No location obtained"
case cancelled = "Cancelled"
}
enum LocationEvent {
case availability(LocationAvailability)
case locations(LocationRequestKind, request: Int, fixes: [LocationFix])
case failure(LocationRequestKind, request: Int, reason: LocationFailure, fallback: LocationFix?)
}
@MainActor
protocol LocationProviding: AnyObject {
var availability: LocationAvailability { get }
var onEvent: ((LocationEvent) -> Void)? { get set }
func refreshAvailability()
func requestAuthorization()
func startContinuous(accuracy: LocationAccuracy, request: Int)
func stopContinuous()
func requestSingle(accuracy: LocationAccuracy, request: Int)
func cancelSingle()
}
+2
View File
@@ -30,5 +30,7 @@
<string>Licensed under the Apache License, Version 2.0.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSLocationUsageDescription</key>
<string>Use this Mac's location for capture you explicitly start. Continuous capture runs until paused or the app quits; Capture once requests one location. This build does not send positions.</string>
</dict>
</plist>