mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 02:00:45 +01:00
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:
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() }
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user