mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 00: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
+1029
-37
No files matched your search
@@ -81,7 +81,7 @@ jobs:
|
||||
run: |
|
||||
scripts/build.sh "$RUNNER_TEMP/traccar-ci" Release \
|
||||
2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/build-release.log"
|
||||
- name: Test configuration and persistence
|
||||
- name: Run application tests
|
||||
run: |
|
||||
scripts/test.sh "$RUNNER_TEMP/traccar-ci" \
|
||||
2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/tests.log"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
An independent, unofficial native Traccar client for Apple Silicon Macs.
|
||||
|
||||
**Development status:** local configuration and identity persistence for [issue #3](https://github.com/jayanezj/traccar-client-macos/issues/3). This build stays paused. Location access, tracking and position/SOS delivery are not implemented; their menu actions are disabled.
|
||||
**Development status:** native local location capture for [issue #4](https://github.com/jayanezj/traccar-client-macos/issues/4), alongside persistent configuration. The app starts paused; continuous capture and a separate single capture are available only on explicit actions. Position/SOS delivery, queueing and position-selection rules are not implemented; sending actions remain disabled.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -30,13 +30,13 @@ The app currently uses no provisioning profile or restricted entitlements. Apple
|
||||
|
||||
## Initial shell smoke check
|
||||
|
||||
1. Launch the built app. It should show a menu-bar icon and paused status. The Dock icon appears while Settings or About is open and disappears after the last window closes.
|
||||
2. Confirm Start tracking, Send location now and Send SOS are disabled.
|
||||
1. Launch the built app. It should show a menu-bar icon and paused status. The Dock icon appears while Settings, About or Location capture is open and disappears after the last window closes.
|
||||
2. Confirm capture starts paused, opening Settings or Location status does not ask for location permission, and Send location now / Send SOS remain disabled.
|
||||
3. Open Settings and switch to another app. Confirm Traccar Client is available in Command-Tab and the Dock. Minimize Settings and recover it from the Dock. Open About too: closing one window should keep the Dock icon; closing the last should hide it while the process and menu-bar icon remain running. Reopen Settings from the menu.
|
||||
4. Open About and check the version, unofficial-client statement and Apache-2.0 license information.
|
||||
5. Quit from the menu. The process should terminate.
|
||||
|
||||
No location permission request or server connection is part of this shell. Real-Mac UI checks remain distinct from automated compilation.
|
||||
Launching the app, opening Settings or inspecting Location status never requests location permission or contacts a receiver. Start and Capture once are explicit location actions. Real-Mac permission and UI checks remain distinct from compilation.
|
||||
|
||||
## Local configuration
|
||||
|
||||
@@ -57,7 +57,30 @@ Use a synthetic identifier and a test receiver URL; no receiver is contacted in
|
||||
3. Quit and reopen the app. Open Settings and authorize only if requested. Confirm URL, identifier and tracking preferences remain saved and status remains paused.
|
||||
4. Save an identifier such as `000123-test`, then quit and reopen. Confirm it is preserved exactly.
|
||||
5. Try an invalid URL such as `ftp://example.com`. Saving should report an error and preserve the last saved configuration.
|
||||
6. Confirm no location permission request or network request occurs. Check real Keychain access and Settings reopening separately from the isolated automated suite.
|
||||
6. Confirm loading/saving settings does not request location or contact the receiver. Check real Keychain access and Settings reopening separately from the isolated automated suite.
|
||||
|
||||
## Local location capture
|
||||
|
||||
Choose **Start location capture**, **Capture location once** or **Location status…** from the menu bar. If access is not yet available, the status window explains the operation; choose **Start** or **Capture once** there to request permission. Denied or restricted access is reported without repeatedly prompting; enable access in System Settings if appropriate and explicitly refresh status. The app starts paused and never restores capture on relaunch.
|
||||
|
||||
Continuous capture belongs to the app, not to a Settings/status window. Closing those windows or using another app does not request a pause. **Pause location capture** stops automatic acquisition; a single capture requested separately can still complete. Cancelling a single capture does not pause continuous capture. Quit stops both. A saved destination/identifier change cancels both operations before persistence. No location is sent to the configured receiver, including the demo; send and SOS actions remain disabled.
|
||||
|
||||
The accuracy setting is read from saved non-sensitive preferences at each explicit start, without opening Keychain or Settings. The default is medium (`kCLLocationAccuracyHundredMeters`); low requests one kilometre and high requests best available accuracy, matching the official SDK categories. These are requests, not guarantees or rejection thresholds. Changing accuracy applies on the next start. Interval/distance selection, buffering and stationary detection remain future work.
|
||||
|
||||
Location status and the menu show the actual fix timestamp and reported horizontal accuracy. Old or imprecise fixes are not discarded by extra age/maximum-accuracy limits. A single request may return a cached fix; no result is labelled as guaranteed fresh. If obtaining a fix fails and a valid last-known location is available, the window explicitly identifies that fallback and retains its original date/accuracy. Without a location, it reports failure. Invalid coordinates or negative/non-finite horizontal accuracy are invalid fixes; unsupported optional altitude/speed/course values are omitted. The app retains only the latest fix in memory and does not display coordinates, log them or keep a local position history.
|
||||
|
||||
This native macOS app uses `NSLocationUsageDescription`. It currently has no App Sandbox or Hardened Runtime capabilities and adds no restricted entitlements, provisioning profiles or iOS background modes. Managers are created on the main run loop; a separate manager handles a single request so it cannot cancel the continuous service. Location availability is determined by macOS; a portable Mac is not assumed to have GPS or to deliver a particular accuracy/cadence. No wake lock, simulated sleep-time positions or automatic stationary detection is added here.
|
||||
|
||||
### Real-Mac location check
|
||||
|
||||
These checks remain manual; they are not established by the deterministic test suite:
|
||||
|
||||
1. Launch the Apple Development-signed app with the established bundle identity. Confirm no capture/prompt on launch, Settings or Location status. Start from the status window and test the system's denial path first; refresh/reopen should not produce repeated prompts.
|
||||
2. If permitted by the system, enable location access in System Settings, then start explicitly. Verify condition, original timestamp and reported accuracy. Keep coordinates, account/receiver identifiers and private screenshots out of reports.
|
||||
3. Close Settings and Location capture, use another app, and inspect the menu later. Check that continuous capture stays requested and record whether macOS supplies updates. Pause and confirm no further automatic fix is accepted.
|
||||
4. While paused, request a single capture. Verify it stays paused after receiving a fix, shows fallback/date honestly on failure, and can be cancelled. Also try single capture during continuous acquisition; pausing continuous capture must not cancel the separate request.
|
||||
5. Quit, rebuild with the same certificate and bundle identifier, then reopen. Record location authorization and real-Keychain continuity separately; do not reset privacy settings or renew/revoke certificates for this check. Capture must start paused.
|
||||
6. Record early window-close, lock/unlock and sleep/wake behaviour: actual app/macOS/hardware, whether capture was requested, last-fix timestamps and availability, without coordinates. Do not infer captures during sleep or validated behaviour on macOS 15 from deployment settings. Full lifecycle/queue handling belongs to #11.
|
||||
|
||||
## Continuous integration
|
||||
|
||||
@@ -66,14 +89,14 @@ The workflow in `.github/workflows/ci.yml` runs on pull requests, pushes to `mai
|
||||
| Check | Runner | Toolchain | Work |
|
||||
| --- | --- | --- | --- |
|
||||
| SwiftLint | `macos-15` arm64 | Xcode 26.3; SwiftLint 0.65.1 | Strict lint, including warnings |
|
||||
| Build (primary) | `xcode-27` arm64 | Xcode 27.1, build 27A9275; macOS SDK 27.0 | Debug, Release and configuration tests |
|
||||
| Build (macOS 15) | `macos-15` arm64 | Xcode 26.3, build 17C529; macOS SDK 26.2 | Debug, Release and configuration tests |
|
||||
| Build (primary) | `xcode-27` arm64 | Xcode 27.1, build 27A9275; macOS SDK 27.0 | Debug, Release and application tests |
|
||||
| Build (macOS 15) | `macos-15` arm64 | Xcode 26.3, build 17C529; macOS SDK 26.2 | Debug, Release and application tests |
|
||||
|
||||
Both build jobs explicitly set `TRACCAR_SIGNING_MODE=adhoc`: CI builds and tests need no Apple account or private signing key, including for fork PRs. These builds are validation artifacts, not distribution packages. Local builds default to `development` and use Apple Development. Signature verification checks the selected mode explicitly.
|
||||
|
||||
Both build jobs verify the host architecture, OS major version, selected Xcode/build and SDK before compiling. They also verify arm64 binaries, deployment target 15.0 and application signatures. A missing or unexpected toolchain fails the check. The `xcode-27` runner image is a public preview, accepted for early CI; Xcode 27.1 build 27A9275 itself is an Apple final release. Review the runner's status before automating releases.
|
||||
|
||||
Both build jobs execute the hostless Swift Testing target. It compiles the same configuration sources as the application and tests validation, stable identity generation, save/reload, storage errors, transient status expiry/cancellation and the guard for future pending-position checks. Window-focus tests cover deferred activation, closed/minimized windows, activation failure and superseded requests. Tests use isolated UserDefaults suites, synthetic Keychain operations and fake windows/activation; they do not open the app, use the login Keychain or contact a server. A narrowly scoped legacy Keychain API suppresses interaction during the initial file-based read and immediately restores its previous setting. All application Keychain operations run synchronously on the main actor. The modern LAContext interaction flags do not suppress the legacy file-based dialogs. Real Keychain access, UI relaunch, location permissions, sleep behaviour and Gatekeeper require separate checks. Subsequent features extend this suite in the same PR as their implementation.
|
||||
Both build jobs execute the hostless Swift Testing target. It compiles the same configuration/location sources as the application and tests validation, stable identity generation, save/reload, storage errors, transient status expiry/cancellation and the guard for future pending-position checks. Location tests cover explicit authorization, denied/disabled states, pause/one-shot independence, stale callbacks, real timestamps/accuracy, fallback and saved-accuracy reads without Keychain. They construct only synthetic CLLocation values and never instantiate the live location provider or request runner location. Window-focus tests cover deferred activation, closed/minimized windows, activation failure and superseded requests. Tests use isolated UserDefaults suites, synthetic Keychain operations and fake windows/activation; they do not open the app, use the login Keychain or contact a server. A narrowly scoped legacy Keychain API suppresses interaction during the initial file-based read and immediately restores its previous setting. All application Keychain operations run synchronously on the main actor. The modern LAContext interaction flags do not suppress the legacy file-based dialogs. Real Keychain access, UI relaunch, location permissions, sleep behaviour and Gatekeeper require separate checks. Subsequent features extend this suite in the same PR as their implementation.
|
||||
|
||||
### Local checks before pushing
|
||||
|
||||
@@ -98,7 +121,7 @@ TRACCAR_SIGNING_MODE=adhoc scripts/check-local.sh /path/to/external/local-ci-adh
|
||||
|
||||
`TRACCAR_SIGNING_MODE` also applies to the individual build, test and verification scripts. Accepted values are `development` and `adhoc`; there is no automatic fallback. The ad hoc mode is for CI validation, not for producing the selected distribution build.
|
||||
|
||||
The command checks the toolchain, shell syntax and whitespace in staged/unstaged tracked changes, runs strict SwiftLint, builds Debug and Release, runs configuration tests, then verifies both binaries' architecture, deployment target and the selected signing mode. It stops at the first failure and returns a nonzero exit code. Tools are cached under `tools/` and incremental build products under `build/` in the supplied directory. The toolchain, lint, build, test and binary verification scripts are shared with CI. Test result bundles are saved under `build/TestResults/`, with a unique directory per run.
|
||||
The command checks the toolchain, shell syntax and whitespace in staged/unstaged tracked changes, runs strict SwiftLint, builds Debug and Release, runs application tests, then verifies both binaries' architecture, deployment target and the selected signing mode. It stops at the first failure and returns a nonzero exit code. Tools are cached under `tools/` and incremental build products under `build/` in the supplied directory. The toolchain, lint, build, test and binary verification scripts are shared with CI. Test result bundles are saved under `build/TestResults/`, with a unique directory per run.
|
||||
|
||||
On a separate macOS 15 arm64 environment with Xcode 26.3 build 17C529 and SDK 26.2, select the minimum-system profile explicitly:
|
||||
|
||||
@@ -118,7 +141,7 @@ scripts/lint.sh /path/to/external/tools-directory
|
||||
|
||||
The script downloads the official portable SwiftLint release, verifies its pinned SHA-256 and checks the executable version. `.swiftlint.yml` applies the default rules to application and test sources, with mandatory trailing commas in multiline collections. No baseline or automatic fixes are applied. SwiftLint is a development tool, not an application package dependency.
|
||||
|
||||
Run configuration tests alone with Xcode or the shared script:
|
||||
Run application tests alone with Xcode or the shared script:
|
||||
|
||||
```sh
|
||||
scripts/test.sh /path/to/external/build-directory
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
classes = {};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
B00000000000000000000040 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location/LocationTypes.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000040 = {isa = PBXBuildFile; fileRef = B00000000000000000000040; };
|
||||
A00000000000000000000050 = {isa = PBXBuildFile; fileRef = B00000000000000000000040; };
|
||||
B00000000000000000000041 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location/LocationModel.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000041 = {isa = PBXBuildFile; fileRef = B00000000000000000000041; };
|
||||
A00000000000000000000051 = {isa = PBXBuildFile; fileRef = B00000000000000000000041; };
|
||||
B00000000000000000000042 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location/CoreLocationProvider.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000042 = {isa = PBXBuildFile; fileRef = B00000000000000000000042; };
|
||||
A00000000000000000000052 = {isa = PBXBuildFile; fileRef = B00000000000000000000042; };
|
||||
B00000000000000000000043 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interface/LocationStatusView.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000043 = {isa = PBXBuildFile; fileRef = B00000000000000000000043; };
|
||||
B00000000000000000000060 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationModelTests.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000060 = {isa = PBXBuildFile; fileRef = B00000000000000000000060; };
|
||||
B00000000000000000000061 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationFixTests.swift; sourceTree = "<group>"; };
|
||||
A00000000000000000000061 = {isa = PBXBuildFile; fileRef = B00000000000000000000061; };
|
||||
B00000000000000000000025 = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration/Signing.xcconfig; sourceTree = "<group>"; };
|
||||
B00000000000000000000023 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFocusTests.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000024 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interface/WindowFocusController.swift; sourceTree = "<group>"; };
|
||||
@@ -24,8 +39,8 @@
|
||||
B00000000000000000000020 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationTests.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000021 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TraccarClientTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A00000000000000000000030 = {isa = PBXBuildFile; fileRef = B00000000000000000000020; };
|
||||
C00000000000000000000010 = {isa = PBXGroup; children = (B00000000000000000000020, B00000000000000000000022, B00000000000000000000023); path = TraccarClientTests; sourceTree = "<group>"; };
|
||||
D00000000000000000000010 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000020, A00000000000000000000021, A00000000000000000000022, A00000000000000000000030, A00000000000000000000031, A00000000000000000000032, A00000000000000000000034); runOnlyForDeploymentPostprocessing = 0; };
|
||||
C00000000000000000000010 = {isa = PBXGroup; children = (B00000000000000000000060, B00000000000000000000061, B00000000000000000000020, B00000000000000000000022, B00000000000000000000023); path = TraccarClientTests; sourceTree = "<group>"; };
|
||||
D00000000000000000000010 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000050, A00000000000000000000051, A00000000000000000000052, A00000000000000000000060, A00000000000000000000061, A00000000000000000000020, A00000000000000000000021, A00000000000000000000022, A00000000000000000000030, A00000000000000000000031, A00000000000000000000032, A00000000000000000000034); runOnlyForDeploymentPostprocessing = 0; };
|
||||
D00000000000000000000011 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
E00000000000000000000010 = {isa = PBXNativeTarget; buildConfigurationList = F00000000000000000000010; buildPhases = (D00000000000000000000010, D00000000000000000000011); buildRules = (); dependencies = (); name = TraccarClientTests; productName = TraccarClientTests; productReference = B00000000000000000000021; productType = "com.apple.product-type.bundle.unit-test"; };
|
||||
F00000000000000000000010 = {isa = XCConfigurationList; buildConfigurations = (F00000000000000000000011, F00000000000000000000012); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||
@@ -42,9 +57,9 @@
|
||||
B00000000000000000000006 = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources/Assets.xcassets; sourceTree = "<group>"; };
|
||||
B00000000000000000000005 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Traccar Client macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C00000000000000000000001 = {isa = PBXGroup; children = (B00000000000000000000025, C00000000000000000000010, C00000000000000000000002, C00000000000000000000003); sourceTree = "<group>"; };
|
||||
C00000000000000000000002 = {isa = PBXGroup; children = (B00000000000000000000024, B00000000000000000000010, B00000000000000000000011, B00000000000000000000012, B00000000000000000000001, B00000000000000000000002, B00000000000000000000003, B00000000000000000000004, B00000000000000000000006); path = TraccarClient; sourceTree = "<group>"; };
|
||||
C00000000000000000000002 = {isa = PBXGroup; children = (B00000000000000000000040, B00000000000000000000041, B00000000000000000000042, B00000000000000000000043, B00000000000000000000024, B00000000000000000000010, B00000000000000000000011, B00000000000000000000012, B00000000000000000000001, B00000000000000000000002, B00000000000000000000003, B00000000000000000000004, B00000000000000000000006); path = TraccarClient; sourceTree = "<group>"; };
|
||||
C00000000000000000000003 = {isa = PBXGroup; children = (B00000000000000000000005, B00000000000000000000021); name = Products; sourceTree = "<group>"; };
|
||||
D00000000000000000000001 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000010, A00000000000000000000011, A00000000000000000000012, A00000000000000000000001, A00000000000000000000002, A00000000000000000000003, A00000000000000000000033); runOnlyForDeploymentPostprocessing = 0; };
|
||||
D00000000000000000000001 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000040, A00000000000000000000041, A00000000000000000000042, A00000000000000000000043, A00000000000000000000010, A00000000000000000000011, A00000000000000000000012, A00000000000000000000001, A00000000000000000000002, A00000000000000000000003, A00000000000000000000033); runOnlyForDeploymentPostprocessing = 0; };
|
||||
D00000000000000000000002 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
D00000000000000000000003 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000004); runOnlyForDeploymentPostprocessing = 0; };
|
||||
E00000000000000000000001 = {isa = PBXNativeTarget; buildConfigurationList = F00000000000000000000002; buildPhases = (D00000000000000000000001, D00000000000000000000002, D00000000000000000000003); buildRules = (); dependencies = (); name = TraccarClient; productName = "Traccar Client macOS"; productReference = B00000000000000000000005; productType = "com.apple.product-type.application"; };
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,47 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct LocationFixTests {
|
||||
@Test func preservesOriginalFieldsAndOmitsInvalidOptionalValues() throws {
|
||||
let timestamp = Date(timeIntervalSince1970: 1_500_000_000)
|
||||
let location = CLLocation(
|
||||
coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2), altitude: 42,
|
||||
horizontalAccuracy: 8_000, verticalAccuracy: -1, course: -1, speed: -1, timestamp: timestamp
|
||||
)
|
||||
let fix = try #require(LocationFix(location))
|
||||
#expect(fix.timestamp == timestamp && fix.horizontalAccuracy == 8_000)
|
||||
#expect(fix.latitude == 1 && fix.longitude == 2)
|
||||
#expect(fix.altitude == nil && fix.speed == nil && fix.course == nil)
|
||||
}
|
||||
|
||||
@Test func preservesValidOptionalFields() throws {
|
||||
let location = CLLocation(
|
||||
coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2), altitude: 42,
|
||||
horizontalAccuracy: 100, verticalAccuracy: 5, course: 90, speed: 0,
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000)
|
||||
)
|
||||
let fix = try #require(LocationFix(location))
|
||||
#expect(fix.altitude == 42 && fix.speed == 0 && fix.course == 90)
|
||||
}
|
||||
|
||||
@Test func invalidCoordinatesOrInvalidFixAccuracyAreNotLocations() {
|
||||
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
for (latitude, accuracy) in [(91.0, 100.0), (1, -1), (1, Double.nan)] {
|
||||
let location = CLLocation(
|
||||
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: 2), altitude: 0,
|
||||
horizontalAccuracy: accuracy, verticalAccuracy: -1, timestamp: timestamp
|
||||
)
|
||||
#expect(LocationFix(location) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @Test func nativeAccuracyMappingAndAuthorizationUseMacAPIs() {
|
||||
#expect(CoreLocationProvider.desiredAccuracy(.medium) == kCLLocationAccuracyHundredMeters)
|
||||
#expect(CoreLocationProvider.desiredAccuracy(.low) == kCLLocationAccuracyKilometer)
|
||||
#expect(CoreLocationProvider.desiredAccuracy(.high) == kCLLocationAccuracyBest)
|
||||
#expect(LocationPermission(.authorizedAlways) == .authorized)
|
||||
#expect(LocationPermission(.denied) == .denied)
|
||||
#expect(LocationPermission(.restricted) == .restricted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite @MainActor
|
||||
struct LocationModelTests {
|
||||
@Test func passiveStatusChangesNeverRequestPermissionOrCapture() {
|
||||
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||
let model = LocationModel(provider: provider)
|
||||
model.refreshAvailability()
|
||||
provider.publish(permission: .authorized)
|
||||
#expect(!model.continuousRequested && model.singleStatus == .idle)
|
||||
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
|
||||
}
|
||||
|
||||
@Test func explicitStartRequestsPermissionOnceAndWaitsForGrant() {
|
||||
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
model.startContinuous()
|
||||
provider.publish(permission: .notDetermined)
|
||||
#expect(provider.authorizationRequests == 1 && provider.starts.isEmpty)
|
||||
provider.publish(permission: .authorized)
|
||||
provider.publish(permission: .authorized)
|
||||
#expect(model.continuousRequested && provider.starts.count == 1)
|
||||
#expect(provider.startedAccuracies == [.medium])
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
LocationAvailability(permission: .denied, services: .enabled),
|
||||
LocationAvailability(permission: .restricted, services: .enabled),
|
||||
LocationAvailability(permission: .notDetermined, services: .disabled),
|
||||
])
|
||||
func blockedStatesDoNotPromptOrStart(availability: LocationAvailability) {
|
||||
let provider = FakeLocationProvider()
|
||||
provider.availability = availability
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
model.captureOnce()
|
||||
#expect(model.continuousRequested && model.singleStatus == .failed)
|
||||
#expect(model.errorMessage != nil)
|
||||
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
|
||||
}
|
||||
|
||||
@Test func pauseBeforeAuthorizationPreventsDelayedStart() {
|
||||
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
model.pause()
|
||||
provider.publish(permission: .authorized)
|
||||
#expect(!model.continuousRequested && provider.starts.isEmpty)
|
||||
}
|
||||
|
||||
@Test func undeterminedPermissionCanBeRetriedOnlyByANewExplicitAttempt() {
|
||||
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||
let model = LocationModel(provider: provider)
|
||||
model.captureOnce()
|
||||
model.cancelSingle()
|
||||
model.refreshAvailability()
|
||||
#expect(provider.authorizationRequests == 1)
|
||||
model.captureOnce()
|
||||
#expect(provider.authorizationRequests == 2)
|
||||
provider.publish(permission: .denied)
|
||||
model.captureOnce()
|
||||
#expect(provider.authorizationRequests == 2)
|
||||
}
|
||||
|
||||
@Test func pauseRejectsLateFixAndResumeUsesNewSession() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous(accuracy: .high)
|
||||
let old = try #require(provider.starts.last)
|
||||
let stops = provider.continuousStops
|
||||
model.pause()
|
||||
#expect(provider.continuousStops == stops + 1)
|
||||
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||
#expect(model.lastFix == nil)
|
||||
model.startContinuous(accuracy: .low)
|
||||
let current = try #require(provider.starts.last)
|
||||
#expect(old != current && provider.startedAccuracies == [.high, .low])
|
||||
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||
#expect(model.lastFix == nil)
|
||||
provider.emit(.locations(.continuous, request: current, fixes: [try sampleFix()]))
|
||||
#expect(model.lastFix != nil)
|
||||
}
|
||||
|
||||
@Test func revokedPermissionStopsAcquisitionWithoutChangingUserIntent() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
let old = try #require(provider.starts.last)
|
||||
let stops = provider.continuousStops
|
||||
provider.publish(permission: .denied)
|
||||
#expect(provider.continuousStops > stops)
|
||||
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||
#expect(model.lastFix == nil && model.continuousRequested)
|
||||
#expect(model.status == "Location permission denied")
|
||||
provider.publish(permission: .authorized)
|
||||
#expect(provider.starts.count == 2 && provider.starts.last != old)
|
||||
model.pause()
|
||||
provider.publish(permission: .authorized)
|
||||
#expect(provider.starts.count == 2)
|
||||
}
|
||||
|
||||
@Test func singleCaptureRemainsPausedAndDuplicatePressIsIgnored() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.captureOnce()
|
||||
model.captureOnce()
|
||||
let request = try #require(provider.singles.last)
|
||||
#expect(provider.singles.count == 1 && provider.starts.isEmpty)
|
||||
let fix = try sampleFix(age: 86_400, accuracy: 5_000)
|
||||
provider.emit(.locations(.single, request: request, fixes: [fix]))
|
||||
#expect(!model.continuousRequested && model.singleStatus == .received)
|
||||
#expect(model.lastFix == fix && model.lastFixOrigin == .single)
|
||||
#expect(model.status == "Paused")
|
||||
}
|
||||
|
||||
@Test func singleCaptureSurvivesPausingContinuousCapture() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
model.captureOnce()
|
||||
let request = try #require(provider.singles.last)
|
||||
let cancels = provider.singleCancellations
|
||||
model.pause()
|
||||
#expect(provider.singleCancellations == cancels && model.singleStatus == .waiting)
|
||||
provider.emit(.locations(.single, request: request, fixes: [try sampleFix()]))
|
||||
#expect(model.singleStatus == .received && !model.continuousRequested)
|
||||
}
|
||||
|
||||
@Test func cancelSingleRejectsItsCallbacksWithoutStoppingContinuousCapture() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
model.captureOnce()
|
||||
let old = try #require(provider.singles.last)
|
||||
model.cancelSingle()
|
||||
provider.emit(.locations(.single, request: old, fixes: [try sampleFix()]))
|
||||
#expect(model.lastFix == nil && model.continuousRequested && model.singleStatus == .cancelled)
|
||||
model.captureOnce()
|
||||
#expect(provider.singles.last != old)
|
||||
provider.emit(.failure(.single, request: old, reason: .unavailable, fallback: nil))
|
||||
#expect(model.singleStatus == .waiting && model.errorMessage == nil)
|
||||
}
|
||||
|
||||
@Test func unavailableSingleUsesKnownFixWithoutInventingTimestamp() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.captureOnce()
|
||||
let request = try #require(provider.singles.last)
|
||||
let old = try sampleFix(age: 40 * 86_400, accuracy: 7_500)
|
||||
provider.emit(.failure(.single, request: request, reason: .unavailable, fallback: old))
|
||||
#expect(model.singleStatus == .lastKnown && model.lastFixOrigin == .lastKnown)
|
||||
#expect(model.lastFix == old && model.errorMessage != nil)
|
||||
#expect(!model.continuousRequested)
|
||||
}
|
||||
|
||||
@Test func unavailableWithoutKnownFixDoesNotReportSuccess() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.captureOnce()
|
||||
let request = try #require(provider.singles.last)
|
||||
provider.emit(.failure(.single, request: request, reason: .unavailable, fallback: nil))
|
||||
#expect(model.singleStatus == .failed && model.lastFix == nil && model.errorMessage != nil)
|
||||
}
|
||||
|
||||
@Test func deniedSingleDoesNotUseFallback() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.captureOnce()
|
||||
let request = try #require(provider.singles.last)
|
||||
provider.emit(.failure(.single, request: request, reason: .permissionDenied, fallback: try sampleFix()))
|
||||
#expect(model.singleStatus == .failed && model.lastFix == nil)
|
||||
}
|
||||
|
||||
@Test func transientFailureKeepsContinuousIntentAndNextFixClearsError() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
let request = try #require(provider.starts.last)
|
||||
provider.emit(.failure(.continuous, request: request, reason: .unavailable, fallback: nil))
|
||||
#expect(model.continuousRequested && model.continuousError != nil)
|
||||
provider.emit(.locations(.continuous, request: request, fixes: [try sampleFix()]))
|
||||
#expect(model.continuousError == nil && model.lastFix != nil)
|
||||
}
|
||||
|
||||
@Test func newestSuppliedTimestampWinsWithinBatch() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
let request = try #require(provider.starts.last)
|
||||
let newer = try sampleFix(age: 10)
|
||||
provider.emit(.locations(.continuous, request: request, fixes: [newer, try sampleFix(age: 60)]))
|
||||
#expect(model.lastFix == newer)
|
||||
}
|
||||
|
||||
@Test func shutdownCancelsBothOperationsAndLateUpdatesAreIgnored() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
model.captureOnce()
|
||||
let single = try #require(provider.singles.last)
|
||||
let continuous = try #require(provider.starts.last)
|
||||
model.shutdown()
|
||||
provider.emit(.locations(.single, request: single, fixes: [try sampleFix()]))
|
||||
provider.emit(.locations(.continuous, request: continuous, fixes: [try sampleFix()]))
|
||||
#expect(!model.continuousRequested && model.singleStatus == .cancelled && model.lastFix == nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension LocationModelTests {
|
||||
@Test func changingReceiverPausesCaptureBeforeSaving() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
fixture.connection.value = ReceiverConnection(deviceIdentifier: "000123-test")
|
||||
let provider = FakeLocationProvider()
|
||||
let location = LocationModel(provider: provider)
|
||||
let configuration = ConfigurationModel(storage: fixture.storage, beforeConnectionChange: { _, _ in
|
||||
location.shutdown()
|
||||
})
|
||||
configuration.loadIfNeeded()
|
||||
location.startContinuous()
|
||||
location.captureOnce()
|
||||
configuration.draft.connection.serverURL = "https://receiver.example"
|
||||
#expect(configuration.save() && !location.continuousRequested && location.singleStatus == .cancelled)
|
||||
}
|
||||
|
||||
@Test func savedAccuracyLoadsWithoutAccessingKeychainOrOpeningSettings() throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.cleanUp() }
|
||||
var configuration = AppConfiguration()
|
||||
configuration.connection.deviceIdentifier = "000123-test"
|
||||
configuration.tracking.accuracy = .high
|
||||
try fixture.storage.save(configuration)
|
||||
fixture.connection.readError = .keychain(-128)
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider, readAccuracy: {
|
||||
try fixture.storage.loadTrackingPreferences().accuracy
|
||||
})
|
||||
model.startContinuous()
|
||||
#expect(provider.startedAccuracies == [.high] && fixture.connection.reads == 0)
|
||||
}
|
||||
|
||||
@Test func unreadablePreferencesDoNotStartCaptureOrAskPermission() {
|
||||
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||
let model = LocationModel(provider: provider, readAccuracy: { throw ConfigurationError.unreadableStorage })
|
||||
model.startContinuous()
|
||||
model.captureOnce()
|
||||
#expect(model.errorMessage != nil && !model.continuousRequested)
|
||||
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
|
||||
}
|
||||
|
||||
@Test func disabledServicesStopBothRequestsAndRecoveryRespectsContinuousIntent() throws {
|
||||
let provider = FakeLocationProvider()
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
model.captureOnce()
|
||||
let old = try #require(provider.starts.last)
|
||||
let stops = provider.continuousStops
|
||||
provider.availability.services = .disabled
|
||||
model.refreshAvailability()
|
||||
#expect(provider.continuousStops > stops && provider.singleCancellations > 0)
|
||||
#expect(model.continuousRequested && model.singleStatus == .failed)
|
||||
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||
#expect(model.lastFix == nil)
|
||||
provider.availability.services = .enabled
|
||||
model.refreshAvailability()
|
||||
#expect(provider.starts.count == 2 && provider.starts.last != old)
|
||||
#expect(provider.singles.count == 1)
|
||||
}
|
||||
|
||||
@Test func initialAvailabilityCheckDelaysPermissionUntilServicesAreEnabled() {
|
||||
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||
provider.availability.services = .checking
|
||||
let model = LocationModel(provider: provider)
|
||||
model.startContinuous()
|
||||
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty)
|
||||
provider.availability.services = .enabled
|
||||
model.refreshAvailability()
|
||||
#expect(provider.authorizationRequests == 1 && provider.starts.isEmpty)
|
||||
provider.publish(permission: .authorized)
|
||||
#expect(provider.starts.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeLocationProvider: LocationProviding {
|
||||
var availability: LocationAvailability
|
||||
var onEvent: ((LocationEvent) -> Void)?
|
||||
var authorizationRequests = 0
|
||||
var starts: [Int] = []
|
||||
var singles: [Int] = []
|
||||
var startedAccuracies: [LocationAccuracy] = []
|
||||
var singleCancellations = 0
|
||||
var continuousStops = 0
|
||||
|
||||
init(permission: LocationPermission = .authorized) {
|
||||
availability = LocationAvailability(permission: permission, services: .enabled)
|
||||
}
|
||||
|
||||
func publish(permission: LocationPermission) {
|
||||
availability.permission = permission
|
||||
onEvent?(.availability(availability))
|
||||
}
|
||||
|
||||
func emit(_ event: LocationEvent) { onEvent?(event) }
|
||||
func refreshAvailability() { onEvent?(.availability(availability)) }
|
||||
func requestAuthorization() { authorizationRequests += 1 }
|
||||
func stopContinuous() { continuousStops += 1 }
|
||||
func cancelSingle() { singleCancellations += 1 }
|
||||
func startContinuous(accuracy: LocationAccuracy, request: Int) {
|
||||
starts.append(request)
|
||||
startedAccuracies.append(accuracy)
|
||||
}
|
||||
func requestSingle(accuracy: LocationAccuracy, request: Int) { singles.append(request) }
|
||||
}
|
||||
|
||||
private func sampleFix(age: TimeInterval = 0, accuracy: Double = 100) throws -> LocationFix {
|
||||
try #require(LocationFix(CLLocation(
|
||||
coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2), altitude: 10,
|
||||
horizontalAccuracy: accuracy, verticalAccuracy: 5,
|
||||
timestamp: Date(timeIntervalSince1970: 1_700_000_000 - age)
|
||||
)))
|
||||
}
|
||||
@@ -50,11 +50,11 @@ for configuration in Debug Release; do
|
||||
scripts/build.sh "$validation_root/build" "$configuration"
|
||||
done
|
||||
|
||||
echo "Running configuration tests..."
|
||||
echo "Running application tests..."
|
||||
scripts/test.sh "$validation_root/build"
|
||||
|
||||
echo "Checking application binaries and signatures..."
|
||||
scripts/verify-build.sh "$validation_root/build"
|
||||
|
||||
echo "Local checks passed ($profile, $signing_mode): lint, Debug, Release, configuration tests, arm64, macOS 15.0 minimum and signatures."
|
||||
echo "Local checks passed ($profile, $signing_mode): lint, Debug, Release, application tests, arm64, macOS 15.0 minimum and signatures."
|
||||
echo "Hosted workflow execution and manual application checks remain separate."
|
||||
+2
-2
@@ -11,7 +11,7 @@ source "$repo_root/scripts/signing-options.sh"
|
||||
mkdir -p "$build_root/TestResults"
|
||||
result_root="$(mktemp -d "$build_root/TestResults/run.XXXXXX")"
|
||||
|
||||
echo "Test results: $result_root/Configuration.xcresult"
|
||||
echo "Test results: $result_root/Application.xcresult"
|
||||
exec xcodebuild \
|
||||
-project "$repo_root/TraccarClient.xcodeproj" \
|
||||
-scheme TraccarClient \
|
||||
@@ -19,7 +19,7 @@ exec xcodebuild \
|
||||
-destination 'platform=macOS,arch=arm64' \
|
||||
-parallel-testing-enabled NO \
|
||||
-derivedDataPath "$build_root/DerivedData" \
|
||||
-resultBundlePath "$result_root/Configuration.xcresult" \
|
||||
-resultBundlePath "$result_root/Application.xcresult" \
|
||||
"CLANG_MODULE_CACHE_PATH=$build_root/ModuleCache" \
|
||||
"SWIFT_MODULE_CACHE_PATH=$build_root/ModuleCache" \
|
||||
"${signing_options[@]}" \
|
||||
|
||||
@@ -15,6 +15,7 @@ for configuration in Debug Release; do
|
||||
echo "Verifying $configuration..."
|
||||
test "$(lipo -archs "$executable")" = arm64
|
||||
test "$(xcrun vtool -show-build "$executable" | awk '/minos/ {print $2}')" = 15.0
|
||||
test -n "$(/usr/libexec/PlistBuddy -c 'Print :NSLocationUsageDescription' "$app/Contents/Info.plist")"
|
||||
codesign --verify --strict "$app"
|
||||
metadata="$(codesign --display --verbose=4 "$app" 2>&1)"
|
||||
case "$signing_mode" in
|
||||
|
||||
Reference in new issue
Block a user