4 Commits
Author SHA1 Message Date
jayanezj d6afd7e3a5 Merge pull request #25 from jayanezj/feature/4-core-location-identity
Add native local location capture and permission handling
2026-10-25 16:08:41 +01:00
jayanezj 3f351296b8 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
2026-10-25 15:58:05 +01:00
jayanezj db90ad9a0e Merge pull request #24 from jayanezj/feature/3-configuration-persistence
Persist receiver configuration and device identity
2026-10-25 10:49:54 +01:00
jayanezj 026cd6aa40 feat: persist local configuration and device identity (#3)
Store receiver and identity together in Keychain, with versioned tracking
preferences, explicit authorization retry and stable Settings focus.
Adopt free Apple Development locally and credential-free ad hoc CI.
Run configuration and focus tests through both CI toolchains.

Final decisions: https://github.com/jayanezj/traccar-client-macos/issues/3#issuecomment-6396087703
2026-10-25 10:43:43 +01:00
30 changed files with 2440 additions and 48 deletions

No files matched your search

+6
View File
@@ -60,6 +60,8 @@ jobs:
EXPECTED_XCODE_BUILD: ${{ matrix.xcode_build }} EXPECTED_XCODE_BUILD: ${{ matrix.xcode_build }}
EXPECTED_SDK_VERSION: ${{ matrix.sdk_version }} EXPECTED_SDK_VERSION: ${{ matrix.sdk_version }}
EXPECTED_OS_MAJOR: ${{ matrix.os_major }} EXPECTED_OS_MAJOR: ${{ matrix.os_major }}
# Untrusted PRs/forks never receive the Apple Development signing key.
TRACCAR_SIGNING_MODE: adhoc
steps: steps:
- name: Check out source - name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -79,6 +81,10 @@ jobs:
run: | run: |
scripts/build.sh "$RUNNER_TEMP/traccar-ci" Release \ scripts/build.sh "$RUNNER_TEMP/traccar-ci" Release \
2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/build-release.log" 2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/build-release.log"
- name: Run application tests
run: |
scripts/test.sh "$RUNNER_TEMP/traccar-ci" \
2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/tests.log"
- name: Verify application binaries - name: Verify application binaries
run: scripts/verify-build.sh "$RUNNER_TEMP/traccar-ci" run: scripts/verify-build.sh "$RUNNER_TEMP/traccar-ci"
- name: Sanitize failure diagnostics - name: Sanitize failure diagnostics
+1
View File
@@ -35,6 +35,7 @@ xcuserdata/
*~ *~
# Local secrets and signing material (keep example configuration) # Local secrets and signing material (keep example configuration)
/Configuration/Signing.local.xcconfig
.env .env
.env.* .env.*
!.env.example !.env.example
+1
View File
@@ -1,5 +1,6 @@
included: included:
- TraccarClient - TraccarClient
- TraccarClientTests
# Match Swift's multiline collection style without disabling the default rules. # Match Swift's multiline collection style without disabling the default rules.
trailing_comma: trailing_comma:
@@ -0,0 +1,5 @@
// Copy to Signing.local.xcconfig (ignored by Git) and use your own Personal Team ID.
DEVELOPMENT_TEAM = YOUR_TEAM_ID
// Optional: use a certificate's SHA-1 identifier if Apple Development is ambiguous.
// CODE_SIGN_IDENTITY = YOUR_CERTIFICATE_SHA1
+9
View File
@@ -0,0 +1,9 @@
// Local apps use an Apple-issued development identity. No provisioning profile is needed
// while the app has no restricted entitlements. CI explicitly overrides this with ad hoc signing.
CODE_SIGN_STYLE = Manual
CODE_SIGN_IDENTITY = Apple Development
CODE_SIGNING_ALLOWED = YES
CODE_SIGNING_REQUIRED = YES
// Set DEVELOPMENT_TEAM locally; optionally select a certificate if several are available.
#include? "Signing.local.xcconfig"
+77 -11
View File
@@ -2,7 +2,7 @@
An independent, unofficial native Traccar client for Apple Silicon Macs. An independent, unofficial native Traccar client for Apple Silicon Macs.
**Development status:** initial application shell for [issue #1](https://github.com/jayanezj/traccar-client-macos/issues/1). This build stays paused. Location access, tracking, server configuration 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 ## Requirements
@@ -12,6 +12,10 @@ An independent, unofficial native Traccar client for Apple Silicon Macs.
## Build and run ## Build and run
Local builds use a free **Apple Development** certificate and a Personal Team; a paid Apple Developer Program subscription is not required. In Xcode → Settings → Apple Accounts, add your Apple Account, select the Personal Team and create an Apple Development certificate in Manage Certificates if needed. Keep the certificate's private key in Keychain.
Copy `Configuration/Signing.local.xcconfig.example` to `Configuration/Signing.local.xcconfig` and replace `YOUR_TEAM_ID` with your own Personal Team ID. The local file is ignored by Git. If several certificates match, optionally select the certificate's SHA-1 identifier there. Never put a private key, password or signing archive in either file. The shared signing settings apply to the app and test target in Debug and Release; missing credentials fail the build rather than silently switching signing mode.
Open `TraccarClient.xcodeproj`, select the shared **TraccarClient** scheme and run the macOS target. Set Xcode's Derived Data and Archives locations to your external development volume before building if internal storage is limited. Open `TraccarClient.xcodeproj`, select the shared **TraccarClient** scheme and run the macOS target. Set Xcode's Derived Data and Archives locations to your external development volume before building if internal storage is limited.
For a terminal build, supply an absolute build directory on that volume: For a terminal build, supply an absolute build directory on that volume:
@@ -22,17 +26,61 @@ scripts/build.sh /path/to/external/build-directory Debug
Replace the example path with your actual directory. The app is produced at `DerivedData/Build/Products/Debug/Traccar Client macOS.app` beneath it. Use `Release` instead of `Debug` for an optimized build. The shared scheme also supports archiving; configure Xcode's archive location separately. Replace the example path with your actual directory. The app is produced at `DerivedData/Build/Products/Debug/Traccar Client macOS.app` beneath it. Use `Release` instead of `Debug` for an optimized build. The shared scheme also supports archiving; configure Xcode's archive location separately.
The app uses ad hoc signing, with no Apple developer account, provisioning profile or paid certificate. It is not notarized. Downloaded application Gatekeeper behaviour and DMG installation will be validated in [issue #16](https://github.com/jayanezj/traccar-client-macos/issues/16). The app currently uses no provisioning profile or restricted entitlements. Apple Development is a development identity, not Developer ID, and the app is not notarized. The selected distribution plan is a DMG containing the Apple Development-signed app; installation on another Mac and Gatekeeper authorization remain to be validated in [issue #16](https://github.com/jayanezj/traccar-client-macos/issues/16). Creating a DMG does not grant Gatekeeper approval. Protected release signing will be addressed in [issue #17](https://github.com/jayanezj/traccar-client-macos/issues/17); it is not part of the ordinary PR workflow.
## Initial shell smoke check ## Initial shell smoke check
1. Launch the built app. It should show a menu-bar icon, no permanent Dock icon and a paused status. 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 Start tracking, Send location now and Send SOS are disabled. 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, close its window and reopen it from the menu. The process should remain running. 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. 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. 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
Open Settings from the menu bar. Configuration loads automatically with a noninteractive Keychain read when access is already available, or on a fresh installation. Only when authorization is needed does the explanation and **Access Keychain** button appear. Cancelling requires an explicit **Retry loading**; closing and reopening Settings does not repeat the denied request automatically. Once loaded, save the receiver URL, identifier and tracking preferences. The initial URL is the Traccar public demo, `http://demo.traccar.org:5055`. HTTP and HTTPS support IP addresses (including IPv6), domains, optional ports, paths and query parameters. Validation is local; embedded credentials and URL fragments are rejected. Use the protocol receiver URL, which can differ from the server's web interface.
Enter an existing identifier exactly, including leading zeros, to continue that device's history. Leaving it blank generates eight random digits on save, displays the value and persists it for reuse. This does not register a device: add the identifier in Traccar separately. Random identifiers are not guaranteed globally unique.
The receiver URL and identifier share one Keychain item, protecting any sensitive query parameters. Tracking preferences use a versioned UserDefaults record. macOS may request the login keychain password; the app never receives that password. The system dialog offers Allow, Always Allow or Deny. Always Allow applies to the recognized application. Keep the same Personal Team and compatible signing identity across updates. Moving from an ad hoc build or another identity may require authorization again. Continuity with the same Apple Development certificate has been checked manually; continuity after certificate renewal remains an untested assumption, not a guarantee. Cancelling reports that stored configuration is unchanged and leaves access available through **Retry loading**, with no automatic retries. Keychain errors are displayed; there is no plaintext fallback. Unreadable settings or a missing saved identity block saving until loading succeeds, rather than silently replacing the identity. Retry loading after resolving the storage problem. A generated identifier remains visible after a failed save so a retry reuses it.
Defaults are medium accuracy, 300 seconds, 75 metres, zero angle/heartbeat, buffering enabled and stationary detection enabled. Time OR distance will select positions when tracking is implemented. Launch-at-login and tracking restoration remain off and their controls are disabled. Loading and saving never request location, start tracking or contact a receiver. Editable fields have visible borders and explicit labels; informational notices use an information icon. Loaded/saved status messages disappear after ten seconds, with the timer restarting for a new message. The footer reserves a fixed message area so showing or hiding status does not resize the form. Errors remain visible until resolved.
### Configuration smoke check
Use a synthetic identifier and a test receiver URL; no receiver is contacted in this build.
1. Open Settings. With an accessible Keychain item or a fresh configuration, confirm Settings loads directly without an extra button or prompt. If authorization is required, confirm the explanation appears first; cancel the system dialog and verify the cancellation message and absence of automatic retries. With another app behind Settings, deny access twice using Retry loading and check that Settings returns to the foreground with keyboard focus each time. Then authorize access if desired. Confirm the public-demo notice and default tracking values on a fresh configuration.
2. Leave the identifier blank and save. Confirm an eight-digit identifier appears.
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 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 ## Continuous integration
@@ -41,12 +89,14 @@ The workflow in `.github/workflows/ci.yml` runs on pull requests, pushes to `mai
| Check | Runner | Toolchain | Work | | Check | Runner | Toolchain | Work |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| SwiftLint | `macos-15` arm64 | Xcode 26.3; SwiftLint 0.65.1 | Strict lint, including warnings | | 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 and Release | | 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 and Release | | 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 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.
This stage performs lint and compilation only: there is no test target yet. Configuration tests start with [issue #3](https://github.com/jayanezj/traccar-client-macos/issues/3); subsequent features extend this workflow with meaningful tests. Builds do not validate location permissions, sleep behaviour or Gatekeeper. 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 ### Local checks before pushing
@@ -63,7 +113,15 @@ DEVELOPER_DIR=/path/to/Xcode.app/Contents/Developer \
scripts/check-local.sh /path/to/external/local-ci scripts/check-local.sh /path/to/external/local-ci
``` ```
The command checks the toolchain, shell syntax and whitespace in staged/unstaged tracked changes, runs strict SwiftLint, builds Debug and Release, then verifies both binaries' architecture, deployment target and signatures. 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 and binary verification scripts are shared with CI. The default signing mode is `development` and requires the local Apple Development setup above. To run the credential-free CI signing mode explicitly, use a separate validation directory:
```sh
TRACCAR_SIGNING_MODE=adhoc scripts/check-local.sh /path/to/external/local-ci-adhoc
```
`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 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: On a separate macOS 15 arm64 environment with Xcode 26.3 build 17C529 and SDK 26.2, select the minimum-system profile explicitly:
@@ -81,7 +139,15 @@ Run the same lint locally, using an absolute tools directory outside the reposit
scripts/lint.sh /path/to/external/tools-directory 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 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. 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 application tests alone with Xcode or the shared script:
```sh
scripts/test.sh /path/to/external/build-directory
```
In Xcode, choose the shared TraccarClient scheme and Product → Test. A failed assertion makes the script and CI job fail. CI retains sanitized text diagnostics on failure; `.xcresult` bundles remain local to the runner. Test fixtures contain only synthetic values and clean up their isolated preferences.
For a matching machine, reproduce the main toolchain check with: For a matching machine, reproduce the main toolchain check with:
+49 -7
View File
@@ -4,6 +4,48 @@
classes = {}; classes = {};
objectVersion = 77; objectVersion = 77;
objects = { 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>"; };
A00000000000000000000032 = {isa = PBXBuildFile; fileRef = B00000000000000000000023; };
A00000000000000000000033 = {isa = PBXBuildFile; fileRef = B00000000000000000000024; };
A00000000000000000000034 = {isa = PBXBuildFile; fileRef = B00000000000000000000024; };
B00000000000000000000022 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainAccessTests.swift; sourceTree = "<group>"; };
A00000000000000000000031 = {isa = PBXBuildFile; fileRef = B00000000000000000000022; };
B00000000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration/AppConfiguration.swift; sourceTree = "<group>"; };
A00000000000000000000010 = {isa = PBXBuildFile; fileRef = B00000000000000000000010; };
A00000000000000000000020 = {isa = PBXBuildFile; fileRef = B00000000000000000000010; };
B00000000000000000000011 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration/ConfigurationPersistence.swift; sourceTree = "<group>"; };
A00000000000000000000011 = {isa = PBXBuildFile; fileRef = B00000000000000000000011; };
A00000000000000000000021 = {isa = PBXBuildFile; fileRef = B00000000000000000000011; };
B00000000000000000000012 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration/ConfigurationModel.swift; sourceTree = "<group>"; };
A00000000000000000000012 = {isa = PBXBuildFile; fileRef = B00000000000000000000012; };
A00000000000000000000022 = {isa = PBXBuildFile; fileRef = B00000000000000000000012; };
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 = (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; };
F00000000000000000000011 = {isa = XCBuildConfiguration; baseConfigurationReference = B00000000000000000000025; buildSettings = {ARCHS = arm64; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = com.codigojose.traccar-client-macos.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 6.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @executable_path/../Frameworks"; }; name = Debug; };
F00000000000000000000012 = {isa = XCBuildConfiguration; baseConfigurationReference = B00000000000000000000025; buildSettings = {ARCHS = arm64; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = com.codigojose.traccar-client-macos.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 6.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/../Frameworks @executable_path/../Frameworks"; }; name = Release; };
A00000000000000000000001 = {isa = PBXBuildFile; fileRef = B00000000000000000000001; }; A00000000000000000000001 = {isa = PBXBuildFile; fileRef = B00000000000000000000001; };
B00000000000000000000001 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application/TraccarClientApp.swift; sourceTree = "<group>"; }; B00000000000000000000001 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application/TraccarClientApp.swift; sourceTree = "<group>"; };
A00000000000000000000002 = {isa = PBXBuildFile; fileRef = B00000000000000000000002; }; A00000000000000000000002 = {isa = PBXBuildFile; fileRef = B00000000000000000000002; };
@@ -14,20 +56,20 @@
A00000000000000000000004 = {isa = PBXBuildFile; fileRef = B00000000000000000000006; }; A00000000000000000000004 = {isa = PBXBuildFile; fileRef = B00000000000000000000006; };
B00000000000000000000006 = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources/Assets.xcassets; sourceTree = "<group>"; }; 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; }; B00000000000000000000005 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Traccar Client macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
C00000000000000000000001 = {isa = PBXGroup; children = (C00000000000000000000002, C00000000000000000000003); sourceTree = "<group>"; }; C00000000000000000000001 = {isa = PBXGroup; children = (B00000000000000000000025, C00000000000000000000010, C00000000000000000000002, C00000000000000000000003); sourceTree = "<group>"; };
C00000000000000000000002 = {isa = PBXGroup; children = (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); name = Products; sourceTree = "<group>"; }; C00000000000000000000003 = {isa = PBXGroup; children = (B00000000000000000000005, B00000000000000000000021); name = Products; sourceTree = "<group>"; };
D00000000000000000000001 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000001, A00000000000000000000002, A00000000000000000000003); 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; }; D00000000000000000000002 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
D00000000000000000000003 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A00000000000000000000004); 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"; }; 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"; };
E00000000000000000000002 = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2710; TargetAttributes = {E00000000000000000000001 = {CreatedOnToolsVersion = 27.1; }; }; }; buildConfigurationList = F00000000000000000000001; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = C00000000000000000000001; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; productRefGroup = C00000000000000000000003; projectDirPath = ""; projectRoot = ""; targets = (E00000000000000000000001); }; E00000000000000000000002 = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2710; TargetAttributes = {E00000000000000000000001 = {CreatedOnToolsVersion = 27.1; }; }; }; buildConfigurationList = F00000000000000000000001; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = C00000000000000000000001; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; productRefGroup = C00000000000000000000003; projectDirPath = ""; projectRoot = ""; targets = (E00000000000000000000001, E00000000000000000000010); };
F00000000000000000000001 = {isa = XCConfigurationList; buildConfigurations = (F00000000000000000000003, F00000000000000000000004); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; F00000000000000000000001 = {isa = XCConfigurationList; buildConfigurations = (F00000000000000000000003, F00000000000000000000004); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
F00000000000000000000002 = {isa = XCConfigurationList; buildConfigurations = (F00000000000000000000005, F00000000000000000000006); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; F00000000000000000000002 = {isa = XCConfigurationList; buildConfigurations = (F00000000000000000000005, F00000000000000000000006); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
F00000000000000000000003 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; SDKROOT = macosx; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTABILITY = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone";}; name = Debug; }; F00000000000000000000003 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; SDKROOT = macosx; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTABILITY = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone";}; name = Debug; };
F00000000000000000000004 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; SDKROOT = macosx; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O";}; name = Release; }; F00000000000000000000004 = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; SDKROOT = macosx; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O";}; name = Release; };
F00000000000000000000005 = {isa = XCBuildConfiguration; buildSettings = {ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = TraccarClient/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 0.1.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.codigojose.traccar-client-macos; PRODUCT_NAME = "Traccar Client macOS"; SUPPORTED_PLATFORMS = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_MODULE_NAME = TraccarClient; SWIFT_VERSION = 6.0;}; name = Debug; }; F00000000000000000000005 = {isa = XCBuildConfiguration; baseConfigurationReference = B00000000000000000000025; buildSettings = {ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = TraccarClient/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 0.1.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.codigojose.traccar-client-macos; PRODUCT_NAME = "Traccar Client macOS"; SUPPORTED_PLATFORMS = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_MODULE_NAME = TraccarClient; SWIFT_VERSION = 6.0;}; name = Debug; };
F00000000000000000000006 = {isa = XCBuildConfiguration; buildSettings = {ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = TraccarClient/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 0.1.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.codigojose.traccar-client-macos; PRODUCT_NAME = "Traccar Client macOS"; SUPPORTED_PLATFORMS = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_MODULE_NAME = TraccarClient; SWIFT_VERSION = 6.0;}; name = Release; }; F00000000000000000000006 = {isa = XCBuildConfiguration; baseConfigurationReference = B00000000000000000000025; buildSettings = {ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = TraccarClient/Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 0.1.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.codigojose.traccar-client-macos; PRODUCT_NAME = "Traccar Client macOS"; SUPPORTED_PLATFORMS = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_MODULE_NAME = TraccarClient; SWIFT_VERSION = 6.0;}; name = Release; };
}; };
rootObject = E00000000000000000000002; rootObject = E00000000000000000000002;
} }
@@ -5,8 +5,18 @@
<BuildActionEntry buildForTesting="NO" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES"> <BuildActionEntry buildForTesting="NO" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="E00000000000000000000001" BuildableName="Traccar Client macOS.app" BlueprintName="TraccarClient" ReferencedContainer="container:TraccarClient.xcodeproj"/> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="E00000000000000000000001" BuildableName="Traccar Client macOS.app" BlueprintName="TraccarClient" ReferencedContainer="container:TraccarClient.xcodeproj"/>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="E00000000000000000000010" BuildableName="TraccarClientTests.xctest" BlueprintName="TraccarClientTests" ReferencedContainer="container:TraccarClient.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="E00000000000000000000010" BuildableName="TraccarClientTests.xctest" BlueprintName="TraccarClientTests" ReferencedContainer="container:TraccarClient.xcodeproj"/>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="NO"> <LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="NO">
<BuildableProductRunnable runnableDebuggingMode="0"> <BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="E00000000000000000000001" BuildableName="Traccar Client macOS.app" BlueprintName="TraccarClient" ReferencedContainer="container:TraccarClient.xcodeproj"/> <BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="E00000000000000000000001" BuildableName="Traccar Client macOS.app" BlueprintName="TraccarClient" ReferencedContainer="container:TraccarClient.xcodeproj"/>
@@ -5,24 +5,85 @@ import SwiftUI
struct TraccarClientApp: App { struct TraccarClientApp: App {
@NSApplicationDelegateAdaptor(ApplicationDelegate.self) private var delegate @NSApplicationDelegateAdaptor(ApplicationDelegate.self) private var delegate
@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 { var body: some Scene {
MenuBarExtra { MenuBarExtra {
TrackingMenu() TrackingMenu(location: location)
} label: { } label: {
Image("MenuBarIcon") Image("MenuBarIcon")
.renderingMode(.template) .renderingMode(.template)
.accessibilityLabel("Traccar Client — Paused") .accessibilityLabel("Traccar Client — \(location.status)")
} }
.menuBarExtraStyle(.menu) .menuBarExtraStyle(.menu)
Settings { Settings {
SettingsView() SettingsView(model: configuration, location: location)
} }
Window("Location capture", id: "location-capture") {
LocationStatusView(location: location)
}
.windowResizability(.contentSize)
.defaultLaunchBehavior(.suppressed)
} }
} }
@MainActor @MainActor
final class ApplicationDelegate: NSObject, NSApplicationDelegate { final class ApplicationDelegate: NSObject, NSApplicationDelegate {
private let openWindows = NSHashTable<NSWindow>.weakObjects()
private var closingWindows: Set<ObjectIdentifier> = []
func applicationDidFinishLaunching(_ notification: Notification) {
let center = NotificationCenter.default
let notifications = [
NSWindow.didBecomeKeyNotification, NSWindow.didUpdateNotification, NSWindow.willCloseNotification,
]
for name in notifications {
center.addObserver(self, selector: #selector(windowChanged(_:)), name: name, object: nil)
}
}
@objc private func windowChanged(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
window.level == .normal, window.canBecomeKey else { return }
let identity = ObjectIdentifier(window)
if notification.name == NSWindow.willCloseNotification {
closingWindows.insert(identity)
openWindows.remove(window)
} else {
if notification.name == NSWindow.didBecomeKeyNotification { closingWindows.remove(identity) }
guard !closingWindows.contains(identity), window.isVisible || window.isMiniaturized else { return }
openWindows.add(window)
}
let policy: NSApplication.ActivationPolicy = openWindows.allObjects.isEmpty ? .accessory : .regular
if NSApp.activationPolicy() != policy {
NSApp.setActivationPolicy(policy)
if policy == .regular { NSApp.activate() }
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
guard WindowFocusController.pendingActivations == 0 else { return false }
if let window = sender.windows.first(where: { openWindows.contains($0) }) {
if window.isMiniaturized { window.deminiaturize(nil) }
window.makeKeyAndOrderFront(nil)
}
return false
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false false
} }
@@ -0,0 +1,116 @@
import Foundation
import Security
struct ReceiverConnection: Codable, Equatable {
static let demoURL = "http://demo.traccar.org:5055"
var serverURL = demoURL
var deviceIdentifier = ""
var isDemo: Bool {
URLComponents(string: serverURL)?.host?.lowercased() == "demo.traccar.org"
}
func validated() throws -> Self {
var result = self
result.serverURL = try Self.canonicalURL(serverURL)
if !deviceIdentifier.isEmpty {
guard !deviceIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!deviceIdentifier.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw ConfigurationError.invalidIdentifier
}
}
return result
}
static func canonicalURL(_ value: String) throws -> String {
guard !value.isEmpty,
!value.unicodeScalars.contains(where: CharacterSet.whitespacesAndNewlines.contains),
let url = URL(string: value, encodingInvalidCharacters: false),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
let host = components.host, !host.isEmpty,
components.user == nil, components.password == nil, components.fragment == nil else {
throw ConfigurationError.invalidURL
}
if let separator = value.range(of: "://") {
let authority = value[separator.upperBound...].prefix { $0 != "/" && $0 != "?" }
if authority.hasSuffix(":") { throw ConfigurationError.invalidURL }
}
if let port = components.port, !(1...65535).contains(port) {
throw ConfigurationError.invalidURL
}
components.scheme = scheme
components.host = host.lowercased()
if (scheme == "http" && components.port == 80) || (scheme == "https" && components.port == 443) {
components.port = nil
}
if components.path == "/" { components.path = "" }
guard let normalized = components.url?.absoluteString else { throw ConfigurationError.invalidURL }
return normalized
}
}
enum LocationAccuracy: String, Codable, CaseIterable {
case low, medium, high
var title: String { rawValue.capitalized }
}
struct TrackingPreferences: Codable, Equatable {
var accuracy: LocationAccuracy = .medium
var intervalSeconds: Double = 300
var distanceMetres: Double = 75
var angleDegrees: Double = 0
var heartbeatSeconds: Double = 0
var bufferingEnabled = true
var stationaryDetectionEnabled = true
var launchAtLogin = false
var restoreTracking = false
func validate() throws {
let numbers = [intervalSeconds, distanceMetres, angleDegrees, heartbeatSeconds]
guard numbers.allSatisfy({ $0.isFinite && $0 >= 0 }), angleDegrees <= 180 else {
throw ConfigurationError.invalidTracking
}
}
}
struct AppConfiguration: Equatable {
var connection = ReceiverConnection()
var tracking = TrackingPreferences()
}
enum ConfigurationError: Error, LocalizedError, Equatable {
case invalidURL
case invalidIdentifier
case invalidTracking
case unreadableStorage
case unsupportedStorageVersion
case missingIdentity
case notLoaded
case keychain(Int32)
var errorDescription: String? {
switch self {
case .invalidURL:
"Enter an absolute HTTP or HTTPS URL with a host and valid port. No credentials or fragments."
case .invalidIdentifier:
"Enter an identifier without control characters, or leave it empty to generate one."
case .invalidTracking:
"Tracking values must be finite and non-negative; the angle must not exceed 180 degrees."
case .unreadableStorage:
"Saved configuration could not be read. No settings or identifier have been replaced."
case .unsupportedStorageVersion:
"This configuration was saved by an unsupported version. No settings have been replaced."
case .missingIdentity:
"The saved device identity is missing from Keychain. No replacement has been generated."
case .notLoaded:
"Reload the saved configuration successfully before saving changes."
case .keychain(let status) where status == errSecUserCanceled:
"Keychain access was cancelled. Saved configuration is unchanged. You can retry when ready."
case .keychain(let status):
"Keychain could not complete the operation (status \(status)). Allow access and try again."
}
}
}
@@ -0,0 +1,121 @@
import Foundation
import Observation
import Security
@MainActor
@Observable
final class ConfigurationModel {
var draft = AppConfiguration()
private(set) var saved: AppConfiguration?
private(set) var hasLoaded = false
private(set) var needsKeychainAuthorization = false
@ObservationIgnored private var attemptedAutomaticLoad = false
private(set) var errorMessage: String?
private(set) var statusMessage: String?
@ObservationIgnored private var statusTask: Task<Void, Never>?
@ObservationIgnored private let statusDelay: @MainActor () async throws -> Void
@ObservationIgnored private let storage: any ConfigurationStorage
@ObservationIgnored private let generateIdentifier: () -> String
@ObservationIgnored private let beforeConnectionChange: (ReceiverConnection, ReceiverConnection) throws -> Void
init(
storage: any ConfigurationStorage,
generateIdentifier: @escaping () -> String = { String(Int.random(in: 10_000_000...99_999_999)) },
statusDelay: @escaping @MainActor () async throws -> Void = { try await Task.sleep(for: .seconds(10)) },
beforeConnectionChange: @escaping (ReceiverConnection, ReceiverConnection) throws -> Void = { _, _ in }
) {
self.statusDelay = statusDelay
self.storage = storage
self.generateIdentifier = generateIdentifier
self.beforeConnectionChange = beforeConnectionChange
}
static func live(
beforeConnectionChange: @escaping (ReceiverConnection, ReceiverConnection) throws -> Void = { _, _ in }
) -> ConfigurationModel {
ConfigurationModel(
storage: PreferencesConfigurationStorage.live(), beforeConnectionChange: beforeConnectionChange
)
}
func loadIfNeeded() {
guard !hasLoaded, !attemptedAutomaticLoad else { return }
reload(allowInteraction: false)
}
func reload(allowInteraction: Bool = true) {
attemptedAutomaticLoad = true
do {
let configuration = try storage.load(allowInteraction: allowInteraction)
draft = configuration
saved = configuration
hasLoaded = true
needsKeychainAuthorization = false
errorMessage = nil
showStatus(configuration.connection.deviceIdentifier.isEmpty ? nil : "Configuration loaded.")
} catch {
hasLoaded = false
needsKeychainAuthorization = Self.requiresAuthorization(error)
errorMessage = !allowInteraction && needsKeychainAuthorization ? nil : message(for: error)
showStatus(nil)
}
}
@discardableResult
func save() -> Bool {
do {
guard hasLoaded, let saved else { throw ConfigurationError.notLoaded }
var candidate = draft
candidate.connection = try candidate.connection.validated()
try candidate.tracking.validate()
if candidate.connection.deviceIdentifier.isEmpty {
candidate.connection.deviceIdentifier = generateIdentifier()
// Retain the generated value on failure so an explicit retry does not generate another identity.
draft.connection.deviceIdentifier = candidate.connection.deviceIdentifier
}
guard !candidate.connection.deviceIdentifier.isEmpty else { throw ConfigurationError.invalidIdentifier }
if saved.connection != candidate.connection {
// Queue/tracking coordination will supply this guard before changing the active destination.
try beforeConnectionChange(saved.connection, candidate.connection)
}
try storage.save(candidate)
self.saved = candidate
draft = candidate
errorMessage = nil
showStatus("Configuration saved locally. Saving does not start capture or send positions.")
return true
} catch {
errorMessage = message(for: error)
showStatus(nil)
return false
}
}
private static func requiresAuthorization(_ error: Error) -> Bool {
guard case ConfigurationError.keychain(let status) = error else { return false }
return [errSecInteractionNotAllowed, errSecInteractionRequired, errSecAuthFailed, errSecUserCanceled]
.contains(status)
}
private func showStatus(_ message: String?) {
statusTask?.cancel()
statusMessage = message
guard message != nil else { return }
let delay = statusDelay
statusTask = Task { [weak self] in
do {
try await delay()
try Task.checkCancellation()
self?.statusMessage = nil
} catch {
// Cancellation leaves the newer message or persistent error in control.
}
}
}
private func message(for error: Error) -> String {
if let error = error as? ConfigurationError { return error.localizedDescription }
return "Configuration could not be saved or loaded. Check storage and try again."
}
}
@@ -0,0 +1,182 @@
import Foundation
import Security
@MainActor
protocol ConnectionStorage {
func read(allowInteraction: Bool) throws -> ReceiverConnection?
func write(_ connection: ReceiverConnection) throws
}
@MainActor
protocol ConfigurationStorage {
func loadTrackingPreferences() throws -> TrackingPreferences
func load(allowInteraction: Bool) throws -> AppConfiguration
func save(_ configuration: AppConfiguration) throws
}
extension ConnectionStorage {
func read() throws -> ReceiverConnection? { try read(allowInteraction: true) }
}
extension ConfigurationStorage {
func load() throws -> AppConfiguration { try load(allowInteraction: true) }
}
@MainActor
final class PreferencesConfigurationStorage: ConfigurationStorage {
private struct Record: Codable {
var version = 1
var tracking: TrackingPreferences
}
static let preferencesKey = "configuration.v1"
private let preferences: UserDefaults
private let connectionStorage: any ConnectionStorage
init(preferences: UserDefaults, connectionStorage: any ConnectionStorage) {
self.preferences = preferences
self.connectionStorage = connectionStorage
}
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 {
do {
record = try JSONDecoder().decode(Record.self, from: data)
} catch {
throw ConfigurationError.unreadableStorage
}
guard record?.version == 1 else { throw ConfigurationError.unsupportedStorageVersion }
} else if preferences.object(forKey: Self.preferencesKey) != nil {
throw ConfigurationError.unreadableStorage
} 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()
if let connection {
guard !connection.deviceIdentifier.isEmpty else { throw ConfigurationError.missingIdentity }
configuration.connection = try connection.validated()
}
if let record { configuration.tracking = record.tracking }
try configuration.tracking.validate()
return configuration
}
func save(_ configuration: AppConfiguration) throws {
let connection = try configuration.connection.validated()
guard !connection.deviceIdentifier.isEmpty else { throw ConfigurationError.invalidIdentifier }
try configuration.tracking.validate()
let data = try JSONEncoder().encode(Record(tracking: configuration.tracking))
// Keychain errors leave UserDefaults untouched. Connection and identity share one secure item.
try connectionStorage.write(connection)
preferences.set(data, forKey: Self.preferencesKey)
}
}
@MainActor
protocol KeychainOperations {
func copyMatching(
_ query: CFDictionary, allowInteraction: Bool, result: UnsafeMutablePointer<CFTypeRef?>
) -> OSStatus
func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus
func add(_ attributes: CFDictionary) -> OSStatus
}
@MainActor
struct SystemKeychainOperations: KeychainOperations {
func copyMatching(
_ query: CFDictionary, allowInteraction: Bool, result: UnsafeMutablePointer<CFTypeRef?>
) -> OSStatus {
if allowInteraction { return SecItemCopyMatching(query, result) }
// SecItem's LAContext/UI flags do not suppress dialogs for file-based keychains.
// Keep this synchronous and on MainActor with every other Keychain operation.
var previous: DarwinBoolean = false
var status = SecKeychainGetUserInteractionAllowed(&previous)
guard status == errSecSuccess else { return status }
status = SecKeychainSetUserInteractionAllowed(false)
guard status == errSecSuccess else { return status }
let readStatus = SecItemCopyMatching(query, result)
let restoreStatus = SecKeychainSetUserInteractionAllowed(previous.boolValue)
return restoreStatus == errSecSuccess ? readStatus : restoreStatus
}
func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus {
SecItemUpdate(query, attributes)
}
func add(_ attributes: CFDictionary) -> OSStatus {
SecItemAdd(attributes, nil)
}
}
@MainActor
final class KeychainConnectionStorage: ConnectionStorage {
private let service: String
private let operations: any KeychainOperations
private let account = "receiver-identity.v1"
init(
service: String = "com.codigojose.traccar-client-macos.configuration",
operations: any KeychainOperations = SystemKeychainOperations()
) {
self.service = service
self.operations = operations
}
private var query: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: false,
]
}
func read(allowInteraction: Bool) throws -> ReceiverConnection? {
var request = query
request[kSecReturnData as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = operations.copyMatching(
request as CFDictionary, allowInteraction: allowInteraction, result: &result
)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw ConfigurationError.keychain(status) }
guard let data = result as? Data,
let connection = try? JSONDecoder().decode(ReceiverConnection.self, from: data) else {
throw ConfigurationError.unreadableStorage
}
return connection
}
func write(_ connection: ReceiverConnection) throws {
let data = try JSONEncoder().encode(connection)
var status = operations.update(query as CFDictionary, attributes: [kSecValueData: data] as CFDictionary)
if status == errSecItemNotFound {
var attributes = query
attributes[kSecValueData as String] = data
status = operations.add(attributes as CFDictionary)
}
guard status == errSecSuccess else { throw ConfigurationError.keychain(status) }
}
}
@@ -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() }
}
}
+163 -20
View File
@@ -1,34 +1,177 @@
import AppKit
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@Bindable var model: ConfigurationModel
let location: LocationModel
@State private var focus = WindowFocusController()
var body: some View { var body: some View {
VStack(spacing: 12) {
if model.hasLoaded {
TabView { TabView {
Form { connection
LabeledContent("Status", value: "Paused")
Text("Server and device configuration will be available in a later build.")
}
.tabItem { Label("Connection", systemImage: "network") } .tabItem { Label("Connection", systemImage: "network") }
tracking
Form {
Text("Continuous tracking, manual location and SOS are not implemented yet.")
Text("This build does not request your location or send positions.")
}
.tabItem { Label("Tracking", systemImage: "location") } .tabItem { Label("Tracking", systemImage: "location") }
general
Form {
Text("Login startup and tracking restoration are not enabled.")
Text("Closing Settings keeps the app in the menu bar. Use Quit to exit.")
}
.tabItem { Label("General", systemImage: "gearshape") } .tabItem { Label("General", systemImage: "gearshape") }
diagnostics
Form {
LabeledContent("Status", value: "Paused")
Text("No location or network activity is started by this application shell.")
}
.tabItem { Label("Diagnostics", systemImage: "waveform.path.ecg") } .tabItem { Label("Diagnostics", systemImage: "waveform.path.ecg") }
} }
} else {
keychainIntroduction
}
VStack(alignment: .leading) {
if let error = model.errorMessage {
Label(error, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red).textSelection(.enabled)
} else if let status = model.statusMessage {
Label(status, systemImage: "info.circle").foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 64, alignment: .topLeading)
HStack {
if !model.hasLoaded {
Button(model.errorMessage == nil ? "Access Keychain" : "Retry loading") {
focus.perform { model.reload() }
}
.buttonStyle(.borderedProminent)
}
Spacer()
if model.hasLoaded {
Button("Save") { focus.perform { model.save() } }
.keyboardShortcut("s", modifiers: .command)
}
}
}
.textFieldStyle(.roundedBorder)
.formStyle(.grouped) .formStyle(.grouped)
.padding() .padding()
.frame(width: 560, height: 320) .frame(width: 720, height: 700)
.background(SettingsWindowReader(focus: focus).allowsHitTesting(false))
.onAppear { model.loadIfNeeded() }
}
private var keychainIntroduction: some View {
VStack(alignment: .leading, spacing: 18) {
Label(model.needsKeychainAuthorization ? "Keychain authorization required" : "Configuration unavailable",
systemImage: "lock.shield")
.font(.title2)
Text("Your server URL and device identifier are protected in the macOS Keychain.")
Text("Settings load automatically when access is available. Continue only if macOS needs authorization.")
Label("macOS may ask for your login keychain password. This app never receives that password.",
systemImage: "info.circle")
Text("In the system dialog, Always Allow remembers access for the recognized app. You can also cancel.")
Text("Cancelling leaves your saved configuration intact. Access is retried only when you choose to retry.")
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
private var connection: some View {
Form {
Section("Traccar Server") {
VStack(alignment: .leading, spacing: 6) {
Text("Server URL").font(.headline)
TextField("http://demo.traccar.org:5055", text: $model.draft.connection.serverURL)
.textFieldStyle(.roundedBorder)
.labelsHidden()
.accessibilityLabel("Server URL")
.privacySensitive()
}
Text("HTTP or HTTPS, with an IP address or domain and an optional port and path.")
if model.draft.connection.isDemo {
Label("Traccar public demo server. Configure your own receiver before sending private locations.",
systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
}
Section("Device identity") {
VStack(alignment: .leading, spacing: 6) {
Text("Unique identifier").font(.headline)
TextField("Leave blank to generate an identifier", text: $model.draft.connection.deviceIdentifier)
.textFieldStyle(.roundedBorder)
.labelsHidden()
.accessibilityLabel("Unique identifier")
.privacySensitive()
}
Text("Use an existing identifier to continue its history. Leave blank to generate eight digits.")
Text("Generated on save. Add it to the server separately; saving does not register a device.")
}
Label("Saving does not start tracking, request location access or contact the server.",
systemImage: "info.circle")
.foregroundStyle(.secondary)
}
.disabled(!model.hasLoaded)
}
private var tracking: some View {
Form {
Picker("Accuracy", selection: $model.draft.tracking.accuracy) {
ForEach(LocationAccuracy.allCases, id: \.self) { accuracy in
Text(accuracy.title).tag(accuracy)
}
}
TextField("Interval (seconds)", value: $model.draft.tracking.intervalSeconds, format: .number)
TextField("Distance (metres)", value: $model.draft.tracking.distanceMetres, format: .number)
Text("A position will be selected when time OR distance is reached. Zero disables that threshold.")
TextField("Angle (degrees)", value: $model.draft.tracking.angleDegrees, format: .number)
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("Accuracy applies on capture start. Selection, buffering and stationary detection are not active yet.")
}
.disabled(!model.hasLoaded)
}
private var general: some View {
Form {
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 requested capture running in the menu bar. Use Pause to stop capture.")
}
}
private var diagnostics: some View {
Form {
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.")
}
}
}
private struct SettingsWindowReader: NSViewRepresentable {
let focus: WindowFocusController
func makeNSView(context: Context) -> WindowReaderView {
WindowReaderView(focus: focus)
}
func updateNSView(_ nsView: WindowReaderView, context: Context) {}
final class WindowReaderView: NSView {
private let focus: WindowFocusController
init(focus: WindowFocusController) {
self.focus = focus
super.init(frame: .zero)
}
required init?(coder: NSCoder) { nil }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
focus.window = window
}
} }
} }
+32 -6
View File
@@ -3,15 +3,35 @@ import SwiftUI
struct TrackingMenu: View { struct TrackingMenu: View {
@Environment(\.openSettings) private var openSettings @Environment(\.openSettings) private var openSettings
@Environment(\.openWindow) private var openWindow
let location: LocationModel
var body: some View { var body: some View {
Text("Paused") Text(location.continuousRequested ? "Location capture active" : "Paused")
Text("Tracking is not available in this build.") 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() Divider()
Button("Start tracking") {} Button(location.continuousRequested ? "Pause location capture" : "Start location capture") {
.disabled(true) 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") {} Button("Send location now") {}
.disabled(true) .disabled(true)
Button("Send SOS") {} Button("Send SOS") {}
@@ -20,13 +40,13 @@ struct TrackingMenu: View {
Divider() Divider()
Button("Settings…") { Button("Settings…") {
NSApplication.shared.activate(ignoringOtherApps: true) NSApplication.shared.activate()
openSettings() openSettings()
} }
.keyboardShortcut(",") .keyboardShortcut(",")
Button("About Traccar Client for macOS") { Button("About Traccar Client for macOS") {
NSApplication.shared.activate(ignoringOtherApps: true) NSApplication.shared.activate()
NSApplication.shared.orderFrontStandardAboutPanel(options: [ NSApplication.shared.orderFrontStandardAboutPanel(options: [
.applicationName: "Traccar Client for macOS", .applicationName: "Traccar Client for macOS",
.credits: NSAttributedString( .credits: NSAttributedString(
@@ -38,8 +58,14 @@ struct TrackingMenu: View {
Divider() Divider()
Button("Quit Traccar Client macOS") { Button("Quit Traccar Client macOS") {
location.shutdown()
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)
} }
.keyboardShortcut("q") .keyboardShortcut("q")
} }
private func showCaptureWindow() {
NSApplication.shared.activate()
openWindow(id: "location-capture")
}
} }
@@ -0,0 +1,63 @@
import AppKit
@MainActor
protocol FocusRestorationWindow: AnyObject {
var isVisible: Bool { get }
var isMiniaturized: Bool { get }
func restoreKeyWindow()
}
extension NSWindow: FocusRestorationWindow {
func restoreKeyWindow() { makeKeyAndOrderFront(nil) }
}
@MainActor
final class WindowFocusController {
// A workspace activation sends a reopen event. Its window is selected only after activation finishes.
private(set) static var pendingActivations = 0
weak var window: (any FocusRestorationWindow)?
private var restoration: Task<Void, Never>?
private let activate: @MainActor () async -> Bool
init(activate: @escaping @MainActor () async -> Bool = activateCurrentApplication) {
self.activate = activate
}
@discardableResult
func perform(_ action: () -> Void) -> Task<Void, Never>? {
restoration?.cancel()
let initiatingWindow = window
action()
guard let initiatingWindow, initiatingWindow.isVisible, !initiatingWindow.isMiniaturized else { return nil }
let activate = activate
restoration = Task { [weak initiatingWindow] in
guard !Task.isCancelled, let initiatingWindow,
initiatingWindow.isVisible, !initiatingWindow.isMiniaturized else { return }
Self.pendingActivations += 1
defer { Self.pendingActivations -= 1 }
guard await activate(), !Task.isCancelled,
initiatingWindow.isVisible, !initiatingWindow.isMiniaturized else { return }
initiatingWindow.restoreKeyWindow()
}
return restoration
}
private static func activateCurrentApplication() async -> Bool {
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = true
configuration.createsNewApplicationInstance = false
configuration.allowsRunningApplicationSubstitution = false
configuration.addsToRecentItems = false
configuration.promptsUserIfNeeded = false
do {
// Launch Services supplies activation context that a plain NSApp.activate() cannot guarantee.
let application = try await NSWorkspace.shared.openApplication(
at: Bundle.main.bundleURL, configuration: configuration
)
return application.processIdentifier == ProcessInfo.processInfo.processIdentifier
} catch {
return false
}
}
}
@@ -0,0 +1,158 @@
import CoreLocation
import Foundation
@MainActor
final class CoreLocationProvider: LocationProviding {
private(set) var availability: LocationAvailability
var onEvent: ((LocationEvent) -> Void)?
private let permissionManager: CLLocationManager
private var permissionDelegate: NativeLocationDelegate?
private var continuousManager: CLLocationManager?
private var continuousDelegate: NativeLocationDelegate?
private var singleManager: CLLocationManager?
private var singleDelegate: NativeLocationDelegate?
private var availabilityRevision = 0
private var continuousRequest: Int?
private var singleRequest: Int?
init() {
permissionManager = CLLocationManager()
availability = LocationAvailability(
permission: LocationPermission(permissionManager.authorizationStatus), services: .checking
)
permissionDelegate = NativeLocationDelegate { [weak self] _ in self?.refreshAvailability() }
permissionManager.delegate = permissionDelegate
refreshAvailability()
}
func refreshAvailability() {
availability.permission = LocationPermission(permissionManager.authorizationStatus)
onEvent?(.availability(availability))
availabilityRevision += 1
let revision = availabilityRevision
Task { [weak self] in
// The synchronous service-status query may block; do not perform it on the UI thread.
let enabled = await Task.detached { CLLocationManager.locationServicesEnabled() }.value
guard let self, revision == availabilityRevision else { return }
availability.services = enabled ? .enabled : .disabled
onEvent?(.availability(availability))
}
}
func requestAuthorization() { permissionManager.requestWhenInUseAuthorization() }
func startContinuous(accuracy: LocationAccuracy, request: Int) {
stopContinuous()
let manager = CLLocationManager()
continuousManager = manager
continuousRequest = request
continuousDelegate = makeDelegate(kind: .continuous, request: request)
manager.delegate = continuousDelegate
manager.desiredAccuracy = Self.desiredAccuracy(accuracy)
manager.distanceFilter = kCLDistanceFilterNone
manager.startUpdatingLocation()
}
func stopContinuous() {
continuousRequest = nil
continuousManager?.stopUpdatingLocation()
continuousManager?.delegate = nil
continuousManager = nil
continuousDelegate = nil
}
func requestSingle(accuracy: LocationAccuracy, request: Int) {
cancelSingle()
let manager = CLLocationManager()
singleManager = manager
singleRequest = request
singleDelegate = makeDelegate(kind: .single, request: request)
manager.delegate = singleDelegate
manager.desiredAccuracy = Self.desiredAccuracy(accuracy)
manager.requestLocation()
}
func cancelSingle() {
singleRequest = nil
singleManager?.stopUpdatingLocation()
singleManager?.delegate = nil
singleManager = nil
singleDelegate = nil
}
static func desiredAccuracy(_ accuracy: LocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .low: kCLLocationAccuracyKilometer
case .medium: kCLLocationAccuracyHundredMeters
case .high: kCLLocationAccuracyBest
}
}
private func makeDelegate(kind: LocationRequestKind, request: Int) -> NativeLocationDelegate {
NativeLocationDelegate { [weak self] event in
guard let self else { return }
switch event {
case .authorization:
refreshAvailability()
case .locations(let locations):
deliver(locations, kind: kind, request: request)
case .failure(let error):
deliver(error, kind: kind, request: request)
}
}
}
private func deliver(_ locations: [CLLocation], kind: LocationRequestKind, request: Int) {
guard isCurrent(kind, request: request) else { return }
let fixes = locations.compactMap(LocationFix.init)
if fixes.isEmpty {
onEvent?(.failure(kind, request: request, reason: .unavailable, fallback: nil))
} else {
onEvent?(.locations(kind, request: request, fixes: fixes))
}
if kind == .single && singleRequest == request { cancelSingle() }
}
private func deliver(_ error: any Error, kind: LocationRequestKind, request: Int) {
guard isCurrent(kind, request: request) else { return }
let reason: LocationFailure
switch (error as? CLError)?.code {
case .denied: reason = .permissionDenied
case .locationUnknown: reason = .unavailable
default: reason = .other((error as NSError).code)
}
let fallback = singleManager?.location.flatMap(LocationFix.init)
onEvent?(.failure(kind, request: request, reason: reason, fallback: fallback))
if kind == .single && singleRequest == request { cancelSingle() }
if reason == .permissionDenied { refreshAvailability() }
}
private func isCurrent(_ kind: LocationRequestKind, request: Int) -> Bool {
kind == .single ? singleRequest == request : continuousRequest == request
}
}
private enum NativeLocationEvent {
case authorization
case locations([CLLocation])
case failure(any Error)
}
// CLLocationManager is created on MainActor and delivers delegates on that thread's run loop.
@MainActor
private final class NativeLocationDelegate: NSObject, @preconcurrency CLLocationManagerDelegate {
private let receive: (NativeLocationEvent) -> Void
init(receive: @escaping (NativeLocationEvent) -> Void) { self.receive = receive }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { receive(.authorization) }
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
receive(.locations(locations))
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
receive(.failure(error))
}
}
+199
View File
@@ -0,0 +1,199 @@
import Foundation
import Observation
@MainActor
@Observable
final class LocationModel {
private(set) var availability: LocationAvailability
private(set) var continuousRequested = false
private(set) var singleStatus: SingleCaptureStatus = .idle
private(set) var lastFix: LocationFix?
private(set) var lastFixOrigin: LocationFixOrigin?
private(set) var errorMessage: String?
private(set) var continuousError: String?
@ObservationIgnored private let provider: any LocationProviding
@ObservationIgnored private let readAccuracy: () throws -> LocationAccuracy
@ObservationIgnored private var permissionRequested = false
@ObservationIgnored private var continuousRequest: Int?
@ObservationIgnored private var singleRequest: Int?
@ObservationIgnored private var nextRequest = 0
@ObservationIgnored private var continuousBlocked = false
@ObservationIgnored private var continuousAccuracy: LocationAccuracy = .medium
@ObservationIgnored private var singleAccuracy: LocationAccuracy = .medium
init(provider: any LocationProviding, readAccuracy: @escaping () throws -> LocationAccuracy = { .medium }) {
self.provider = provider
self.readAccuracy = readAccuracy
availability = provider.availability
provider.onEvent = { [weak self] in self?.receive($0) }
}
var status: String {
if availability.services == .disabled { return "Location Services disabled" }
if availability.permission == .denied { return "Location permission denied" }
if availability.permission == .restricted { return "Location permission restricted" }
if continuousError != nil { return "Location unavailable" }
if continuousRequested || singleStatus == .waiting {
if !availability.canCapture { return "Waiting for location authorization" }
return lastFix == nil ? "Waiting for location" : "Location capture active"
}
return "Paused"
}
func startContinuous(accuracy: LocationAccuracy? = nil) {
guard !continuousRequested else { return }
guard let selected = selectedAccuracy(accuracy) else { return }
preparePermissionRequest()
continuousRequested = true
continuousAccuracy = selected
continuousBlocked = false
continuousError = nil
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func pause() {
continuousRequested = false
stopContinuous()
continuousError = nil
continuousBlocked = false
}
func captureOnce(accuracy: LocationAccuracy? = nil) {
guard singleStatus != .waiting else { return }
guard let selected = selectedAccuracy(accuracy) else {
singleStatus = .failed
return
}
preparePermissionRequest()
singleStatus = .waiting
singleAccuracy = selected
errorMessage = nil
provider.refreshAvailability()
reconcile()
}
func cancelSingle() {
guard singleStatus == .waiting else { return }
singleRequest = nil
singleStatus = .cancelled
provider.cancelSingle()
}
func shutdown() {
pause()
cancelSingle()
}
func refreshAvailability() { provider.refreshAvailability() }
private func preparePermissionRequest() {
// A new explicit attempt may retry an undetermined request; status refreshes never do so on their own.
if !continuousRequested && singleStatus != .waiting { permissionRequested = false }
}
private func selectedAccuracy(_ requested: LocationAccuracy?) -> LocationAccuracy? {
do {
return try requested ?? readAccuracy()
} catch {
errorMessage = "Saved tracking preferences could not be read. Review Settings before capture."
return nil
}
}
private func reconcile() {
guard continuousRequested || singleStatus == .waiting else { return }
if availability.services == .disabled || [.denied, .restricted].contains(availability.permission) {
stopContinuous()
errorMessage = availability.services == .disabled
? "Enable Location Services in System Settings to capture locations."
: "Location access is denied or restricted. Review Location Services in System Settings."
if singleStatus == .waiting {
singleRequest = nil
singleStatus = .failed
provider.cancelSingle()
}
return
}
guard availability.services == .enabled else { return }
if availability.permission == .notDetermined {
guard !permissionRequested else { return }
permissionRequested = true
provider.requestAuthorization()
return
}
guard availability.canCapture else { return }
if continuousRequested && continuousRequest == nil && !continuousBlocked {
nextRequest += 1
continuousRequest = nextRequest
provider.startContinuous(accuracy: continuousAccuracy, request: nextRequest)
}
if singleStatus == .waiting && singleRequest == nil {
nextRequest += 1
singleRequest = nextRequest
provider.requestSingle(accuracy: singleAccuracy, request: nextRequest)
}
}
private func stopContinuous() {
continuousRequest = nil
provider.stopContinuous()
}
private func receive(_ event: LocationEvent) {
switch event {
case .availability(let value):
if availability != value {
continuousBlocked = false
continuousError = nil
errorMessage = nil
}
availability = value
if !value.canCapture { stopContinuous() }
reconcile()
case .locations(let kind, let request, let fixes):
guard accepts(kind, request: request), let fix = fixes.max(by: { $0.timestamp < $1.timestamp }) else {
return
}
lastFix = fix
lastFixOrigin = kind == .single ? .single : .continuous
if kind == .single {
singleStatus = .received
singleRequest = nil
} else {
continuousError = nil
}
errorMessage = nil
case .failure(let kind, let request, let reason, let fallback):
guard accepts(kind, request: request) else { return }
handleFailure(kind, reason: reason, fallback: fallback)
}
}
private func accepts(_ kind: LocationRequestKind, request: Int) -> Bool {
kind == .single ? singleStatus == .waiting && singleRequest == request
: continuousRequested && continuousRequest == request
}
private func handleFailure(_ kind: LocationRequestKind, reason: LocationFailure, fallback: LocationFix?) {
if kind == .continuous {
continuousError = reason.message
if reason == .permissionDenied {
continuousBlocked = true
stopContinuous()
}
return
}
singleRequest = nil
if reason != .permissionDenied, let known = fallback ?? lastFix {
lastFix = known
lastFixOrigin = .lastKnown
singleStatus = .lastKnown
} else {
singleStatus = .failed
}
errorMessage = reason.message
}
}
+102
View File
@@ -0,0 +1,102 @@
import CoreLocation
import Foundation
enum LocationPermission: String {
case notDetermined = "Not requested"
case authorized = "Authorized"
case denied = "Denied"
case restricted = "Restricted"
init(_ status: CLAuthorizationStatus) {
switch status {
case .authorizedAlways: self = .authorized
case .notDetermined: self = .notDetermined
case .denied: self = .denied
default: self = .restricted
}
}
}
enum LocationServices: String {
case checking = "Checking"
case enabled = "Enabled"
case disabled = "Disabled"
}
struct LocationAvailability: Equatable {
var permission: LocationPermission
var services: LocationServices
var canCapture: Bool { permission == .authorized && services == .enabled }
}
struct LocationFix: Equatable {
let latitude: Double
let longitude: Double
let timestamp: Date
let horizontalAccuracy: Double
let altitude: Double?
let speed: Double?
let course: Double?
init?(_ location: CLLocation) {
guard CLLocationCoordinate2DIsValid(location.coordinate),
location.horizontalAccuracy.isFinite, location.horizontalAccuracy >= 0,
location.timestamp.timeIntervalSinceReferenceDate.isFinite else { return nil }
latitude = location.coordinate.latitude
longitude = location.coordinate.longitude
timestamp = location.timestamp
horizontalAccuracy = location.horizontalAccuracy
altitude = location.verticalAccuracy >= 0 && location.altitude.isFinite ? location.altitude : nil
speed = location.speed >= 0 && location.speed.isFinite ? location.speed : nil
course = (0..<360).contains(location.course) && location.course.isFinite ? location.course : nil
}
}
enum LocationFailure: Equatable {
case permissionDenied
case unavailable
case other(Int)
var message: String {
switch self {
case .permissionDenied: "Location access is denied. Review Location Services in System Settings."
case .unavailable: "No location is available. Try again when Location Services can obtain a fix."
case .other(let code): "Location could not be obtained (error \(code))."
}
}
}
enum LocationRequestKind { case continuous, single }
enum LocationFixOrigin: String {
case continuous = "Continuous capture"
case single = "Single capture"
case lastKnown = "Last-known location; no new fix"
}
enum SingleCaptureStatus: String {
case idle = "Idle"
case waiting = "Waiting for location"
case received = "Location received"
case lastKnown = "Last-known location used; no new fix"
case failed = "No location obtained"
case cancelled = "Cancelled"
}
enum LocationEvent {
case availability(LocationAvailability)
case locations(LocationRequestKind, request: Int, fixes: [LocationFix])
case failure(LocationRequestKind, request: Int, reason: LocationFailure, fallback: LocationFix?)
}
@MainActor
protocol LocationProviding: AnyObject {
var availability: LocationAvailability { get }
var onEvent: ((LocationEvent) -> Void)? { get set }
func refreshAvailability()
func requestAuthorization()
func startContinuous(accuracy: LocationAccuracy, request: Int)
func stopContinuous()
func requestSingle(accuracy: LocationAccuracy, request: Int)
func cancelSingle()
}
+2
View File
@@ -30,5 +30,7 @@
<string>Licensed under the Apache License, Version 2.0.</string> <string>Licensed under the Apache License, Version 2.0.</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <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> </dict>
</plist> </plist>
+371
View File
@@ -0,0 +1,371 @@
import Foundation
import Security
import Testing
@Suite @MainActor
struct ConfigurationTests {
@Test func defaultsDoNotGenerateIdentity() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
var generated = 0
let model = ConfigurationModel(storage: fixture.storage, generateIdentifier: {
generated += 1
return "12345678"
})
model.loadIfNeeded()
model.loadIfNeeded()
#expect(model.draft == AppConfiguration())
#expect(model.draft.connection.serverURL == "http://demo.traccar.org:5055")
#expect(model.draft.tracking.intervalSeconds == 300)
#expect(model.draft.tracking.distanceMetres == 75)
#expect(model.draft.tracking.accuracy == .medium)
#expect(!model.draft.tracking.launchAtLogin && !model.draft.tracking.restoreTracking)
#expect(generated == 0 && fixture.connection.writes == 0)
#expect(model.save())
#expect(generated == 1)
#expect(model.save())
#expect(generated == 1)
#expect(try fixture.storage.load().connection.deviceIdentifier == "12345678")
}
@Test func randomIdentityHasEightDigitsAndSurvivesModelRecreation() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let model = ConfigurationModel(storage: fixture.storage)
model.loadIfNeeded()
#expect(model.save())
let identifier = model.draft.connection.deviceIdentifier
#expect(identifier.count == 8 && identifier.utf8.allSatisfy { (48...57).contains($0) })
let reopened = ConfigurationModel(storage: fixture.storage, generateIdentifier: {
Issue.record("Loading an existing identity must never generate another one")
return "unexpected"
})
reopened.loadIfNeeded()
#expect(reopened.draft.connection.deviceIdentifier == identifier)
}
@Test func roundTripPreservesSettingsAndExactIdentifier() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
var configuration = AppConfiguration()
configuration.connection = ReceiverConnection(
serverURL: "https://tracker.example:8443/OsmAnd?token=synthetic", deviceIdentifier: "000123-AbC"
)
configuration.tracking.intervalSeconds = 42
configuration.tracking.distanceMetres = 12.5
configuration.tracking.accuracy = .high
configuration.tracking.angleDegrees = 30
configuration.tracking.heartbeatSeconds = 60
configuration.tracking.bufferingEnabled = false
configuration.tracking.stationaryDetectionEnabled = false
try fixture.storage.save(configuration)
let reopened = PreferencesConfigurationStorage(
preferences: fixture.preferences, connectionStorage: fixture.connection
)
#expect(try reopened.load() == configuration)
let data = try #require(fixture.preferences.data(forKey: PreferencesConfigurationStorage.preferencesKey))
let text = try #require(String(data: data, encoding: .utf8))
#expect(!text.contains("000123") && !text.contains("tracker.example") && !text.contains("synthetic"))
}
@Test(arguments: [
"", "ftp://example.com", "https:///", "example.com:5055", "http://host:0", "http://host:65536",
"http://host:abc", "http://host:", "https://host/a%xx", " https://host", "https://host/a b",
"https://user:password@host", "https://host/#fragment",
])
func invalidURLsAreRejected(value: String) {
#expect(throws: ConfigurationError.invalidURL) { try ReceiverConnection.canonicalURL(value) }
}
@Test(arguments: [
"http://192.0.2.1:5055", "https://tracker.example:8443/OsmAnd?key=synthetic",
"http://[2001:db8::1]:5055/receiver", "https://tracker.example/a%20b",
"https://tracker.example/receiver?next=http://synthetic:",
])
func supportedURLsRoundTrip(value: String) throws {
#expect(try ReceiverConnection.canonicalURL(value) == value)
}
@Test func URLNormalizationPreservesPathAndQueryCase() throws {
#expect(try ReceiverConnection.canonicalURL("HTTPS://TRACKER.EXAMPLE:443/") == "https://tracker.example")
#expect(try ReceiverConnection.canonicalURL("http://TRACKER.EXAMPLE:80/Path?Key=Value")
== "http://tracker.example/Path?Key=Value")
}
@Test func invalidTrackingAndIdentifierDoNotWrite() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let model = ConfigurationModel(storage: fixture.storage)
model.loadIfNeeded()
model.draft.tracking.intervalSeconds = -.infinity
#expect(!model.save())
model.draft.tracking = TrackingPreferences()
model.draft.connection.deviceIdentifier = "\n"
#expect(!model.save())
#expect(fixture.connection.writes == 0)
#expect(model.saved == AppConfiguration())
for value in [Double.nan, .infinity, -1] {
var tracking = TrackingPreferences()
tracking.distanceMetres = value
#expect(throws: ConfigurationError.invalidTracking) { try tracking.validate() }
}
var tracking = TrackingPreferences()
tracking.angleDegrees = 181
#expect(throws: ConfigurationError.invalidTracking) { try tracking.validate() }
}
@Test func failedSaveRetainsGeneratedIdentityAndPreferences() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
var generated = 0
let model = ConfigurationModel(storage: fixture.storage, generateIdentifier: {
generated += 1
return "87654321"
})
model.loadIfNeeded()
fixture.connection.writeError = .keychain(errSecAuthFailed)
#expect(!model.save())
#expect(model.draft.connection.deviceIdentifier == "87654321")
#expect(model.saved == AppConfiguration())
#expect(fixture.preferences.object(forKey: PreferencesConfigurationStorage.preferencesKey) == nil)
fixture.connection.writeError = nil
#expect(model.save())
#expect(generated == 1)
}
@Test func unavailableKeychainBlocksSaveUntilReload() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
fixture.connection.readError = .keychain(errSecInteractionNotAllowed)
let model = ConfigurationModel(storage: fixture.storage)
model.loadIfNeeded()
#expect(!model.hasLoaded && model.needsKeychainAuthorization)
#expect(!model.save())
#expect(fixture.connection.writes == 0)
fixture.connection.readError = nil
model.reload()
#expect(model.hasLoaded)
}
@Test func missingIdentityDoesNotSilentlyReplaceDevice() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
var configuration = AppConfiguration()
configuration.connection.deviceIdentifier = "00123"
try fixture.storage.save(configuration)
fixture.connection.value = nil
#expect(throws: ConfigurationError.missingIdentity) { try fixture.storage.load() }
}
@Test func corruptOrFuturePreferencesAreNotReset() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let key = PreferencesConfigurationStorage.preferencesKey
fixture.preferences.set(Data("invalid".utf8), forKey: key)
#expect(throws: ConfigurationError.unreadableStorage) { try fixture.storage.load() }
let encoded = try JSONEncoder().encode(TrackingPreferences())
let tracking = try #require(String(data: encoded, encoding: .utf8))
fixture.preferences.set(Data("{\"version\":2,\"tracking\":\(tracking)}".utf8), forKey: key)
#expect(throws: ConfigurationError.unsupportedStorageVersion) { try fixture.storage.load() }
fixture.preferences.set("unexpected type", forKey: key)
#expect(throws: ConfigurationError.unreadableStorage) { try fixture.storage.load() }
#expect(fixture.connection.writes == 0)
}
@Test func connectionGuardRunsBeforeMutationAndSkipsTrackingOnlyChanges() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
fixture.connection.value = ReceiverConnection(serverURL: "https://tracker.example", deviceIdentifier: "00123")
var guarded = 0
let model = ConfigurationModel(storage: fixture.storage, beforeConnectionChange: { _, _ in
guarded += 1
throw ConfigurationError.notLoaded
})
model.loadIfNeeded()
model.draft.connection.serverURL = "HTTPS://TRACKER.EXAMPLE:443/"
model.draft.tracking.intervalSeconds = 90
#expect(model.save())
#expect(guarded == 0)
model.draft.connection.deviceIdentifier = "other-device"
#expect(!model.save())
#expect(guarded == 1 && fixture.connection.writes == 1)
#expect(fixture.connection.value?.deviceIdentifier == "00123")
model.draft.connection.deviceIdentifier = "00123"
model.draft.connection.serverURL = "https://other.example"
#expect(!model.save())
#expect(guarded == 2 && fixture.connection.writes == 1)
}
@Test func keychainUsesUpdateThenAddOnlyWhenMissing() throws {
let operations = FakeKeychainOperations()
let storage = KeychainConnectionStorage(service: "test.synthetic", operations: operations)
let connection = ReceiverConnection(deviceIdentifier: "000123")
#expect(try storage.read() == nil)
try storage.write(connection)
#expect(operations.adds == 1)
operations.updateStatus = errSecSuccess
try storage.write(connection)
#expect(operations.adds == 1)
operations.readStatus = errSecSuccess
#expect(try storage.read() == connection)
operations.updateStatus = errSecAuthFailed
#expect(throws: ConfigurationError.keychain(errSecAuthFailed)) { try storage.write(connection) }
#expect(operations.adds == 1)
operations.readStatus = errSecInteractionNotAllowed
#expect(throws: ConfigurationError.keychain(errSecInteractionNotAllowed)) { try storage.read() }
operations.readStatus = errSecSuccess
operations.data = Data("broken".utf8)
#expect(throws: ConfigurationError.unreadableStorage) { try storage.read() }
}
}
@MainActor
struct Fixture {
let suite = "TraccarConfigurationTests.\(UUID().uuidString)"
let preferences: UserDefaults
let connection = FakeConnectionStorage()
let storage: PreferencesConfigurationStorage
init() throws {
preferences = try #require(UserDefaults(suiteName: suite))
storage = PreferencesConfigurationStorage(preferences: preferences, connectionStorage: connection)
}
func cleanUp() { preferences.removePersistentDomain(forName: suite) }
}
@MainActor
final class FakeConnectionStorage: ConnectionStorage {
var value: ReceiverConnection?
var readError: ConfigurationError?
var writeError: ConfigurationError?
var writes = 0
var reads = 0
var readModes: [Bool] = []
func read(allowInteraction: Bool) throws -> ReceiverConnection? {
reads += 1
readModes.append(allowInteraction)
if let readError { throw readError }
return value
}
func write(_ connection: ReceiverConnection) throws {
if let writeError { throw writeError }
writes += 1
value = connection
}
}
@MainActor
final class FakeKeychainOperations: KeychainOperations {
var readStatus = errSecItemNotFound
var updateStatus = errSecItemNotFound
var adds = 0
var data: Data?
var readModes: [Bool] = []
func copyMatching(
_ query: CFDictionary, allowInteraction: Bool, result: UnsafeMutablePointer<CFTypeRef?>
) -> OSStatus {
readModes.append(allowInteraction)
let attributes = query as NSDictionary
#expect(attributes[kSecAttrService] as? String == "test.synthetic")
#expect(attributes[kSecAttrSynchronizable] as? Bool == false)
result.pointee = data as CFData?
return readStatus
}
func update(_ query: CFDictionary, attributes: CFDictionary) -> OSStatus {
if updateStatus == errSecSuccess { data = (attributes as NSDictionary)[kSecValueData] as? Data }
return updateStatus
}
func add(_ attributes: CFDictionary) -> OSStatus {
adds += 1
data = (attributes as NSDictionary)[kSecValueData] as? Data
return errSecSuccess
}
}
@MainActor
private final class ControlledStatusDelay {
private var continuations: [CheckedContinuation<Void, Never>?] = []
private var returned: Set<Int> = []
func wait() async {
let index = continuations.count
await withCheckedContinuation { continuations.append($0) }
returned.insert(index)
}
func waitUntilScheduled(_ count: Int) async {
while continuations.count < count { await Task.yield() }
}
func finish(_ index: Int) {
continuations[index]?.resume()
continuations[index] = nil
}
func waitUntilReturned(_ index: Int) async {
while !returned.contains(index) { await Task.yield() }
}
}
extension ConfigurationTests {
@Test func informationalStatusExpiresAfterDelay() async throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let delay = ControlledStatusDelay()
let model = ConfigurationModel(storage: fixture.storage, statusDelay: { await delay.wait() })
model.loadIfNeeded()
#expect(model.save())
#expect(model.statusMessage != nil)
await delay.waitUntilScheduled(1)
delay.finish(0)
await delay.waitUntilReturned(0)
#expect(model.statusMessage == nil)
model.reload()
#expect(model.statusMessage == "Configuration loaded.")
await delay.waitUntilScheduled(2)
delay.finish(1)
await delay.waitUntilReturned(1)
#expect(model.statusMessage == nil)
}
@Test func olderTimerCannotDismissNewerStatus() async throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let delay = ControlledStatusDelay()
let model = ConfigurationModel(storage: fixture.storage, statusDelay: { await delay.wait() })
model.loadIfNeeded()
#expect(model.save())
await delay.waitUntilScheduled(1)
#expect(model.save())
await delay.waitUntilScheduled(2)
delay.finish(0)
await delay.waitUntilReturned(0)
#expect(model.statusMessage != nil)
delay.finish(1)
await delay.waitUntilReturned(1)
#expect(model.statusMessage == nil)
}
@Test func errorsRemainVisibleWhenOldTimerFinishes() async throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let delay = ControlledStatusDelay()
let model = ConfigurationModel(storage: fixture.storage, statusDelay: { await delay.wait() })
model.loadIfNeeded()
#expect(model.save())
await delay.waitUntilScheduled(1)
model.draft.connection.serverURL = "invalid"
#expect(!model.save())
let error = model.errorMessage
delay.finish(0)
await delay.waitUntilReturned(0)
#expect(model.statusMessage == nil && model.errorMessage == error && error != nil)
}
}
@@ -0,0 +1,106 @@
import Foundation
import Security
import Testing
extension ConfigurationTests {
@Test func keychainIsNotReadUntilExplicitLoadAndCancellationDoesNotReplaceIdentity() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let existing = ReceiverConnection(serverURL: "https://tracker.example", deviceIdentifier: "000123-test")
fixture.connection.value = existing
fixture.connection.readError = .keychain(errSecUserCanceled)
var generated = 0
let model = ConfigurationModel(storage: fixture.storage, generateIdentifier: {
generated += 1
return "12345678"
})
#expect(fixture.connection.reads == 0 && !model.hasLoaded)
model.reload()
#expect(!model.hasLoaded)
#expect(model.errorMessage?.contains("cancelled") == true)
#expect(model.errorMessage?.contains("not saved") == false)
#expect(model.statusMessage == nil)
#expect(fixture.connection.reads == 1 && fixture.connection.writes == 0)
#expect(fixture.connection.value == existing && generated == 0)
#expect(!model.save())
#expect(fixture.connection.reads == 1 && fixture.connection.writes == 0)
fixture.connection.readError = nil
model.reload()
#expect(model.hasLoaded && model.errorMessage == nil)
#expect(model.draft.connection == existing && generated == 0)
#expect(fixture.connection.reads == 2 && fixture.connection.writes == 0)
}
@Test func cancelledWritePreservesLoadedConfigurationAndCanBeRetried() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let existing = ReceiverConnection(deviceIdentifier: "000123-test")
fixture.connection.value = existing
let model = ConfigurationModel(storage: fixture.storage)
model.reload()
let original = model.saved
model.draft.connection.serverURL = "https://new.example"
fixture.connection.writeError = .keychain(errSecUserCanceled)
#expect(!model.save())
#expect(model.errorMessage?.contains("cancelled") == true)
#expect(model.hasLoaded && model.saved == original)
#expect(fixture.connection.value == existing)
#expect(fixture.preferences.object(forKey: PreferencesConfigurationStorage.preferencesKey) == nil)
fixture.connection.writeError = nil
#expect(model.save())
#expect(model.errorMessage == nil)
#expect(try fixture.storage.load().connection.serverURL == "https://new.example")
#expect(try fixture.storage.load().connection.deviceIdentifier == "000123-test")
}
}
extension ConfigurationTests {
@Test func trustedAndEmptyStoresLoadAutomaticallyWithoutAuthorizationStep() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let initial = ConfigurationModel(storage: fixture.storage)
initial.loadIfNeeded()
#expect(initial.hasLoaded && !initial.needsKeychainAuthorization)
#expect(initial.draft.connection.deviceIdentifier.isEmpty)
#expect(fixture.connection.readModes == [false])
fixture.connection.value = ReceiverConnection(deviceIdentifier: "000123-test")
let reopened = ConfigurationModel(storage: fixture.storage)
reopened.loadIfNeeded()
reopened.loadIfNeeded()
#expect(reopened.hasLoaded && !reopened.needsKeychainAuthorization)
#expect(reopened.draft.connection.deviceIdentifier == "000123-test")
#expect(fixture.connection.readModes == [false, false])
}
@Test func authorizationRequiredExplainsBeforeExplicitRetryWithoutAutomaticLoop() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
fixture.connection.readError = .keychain(errSecInteractionNotAllowed)
let model = ConfigurationModel(storage: fixture.storage)
model.loadIfNeeded()
#expect(!model.hasLoaded && model.needsKeychainAuthorization)
#expect(model.errorMessage == nil)
model.loadIfNeeded()
#expect(fixture.connection.readModes == [false])
fixture.connection.readError = .keychain(errSecUserCanceled)
model.reload()
model.loadIfNeeded()
#expect(model.errorMessage?.contains("cancelled") == true)
#expect(fixture.connection.readModes == [false, true])
fixture.connection.readError = nil
model.reload()
#expect(model.hasLoaded && !model.needsKeychainAuthorization && model.errorMessage == nil)
#expect(fixture.connection.readModes == [false, true, true])
}
@Test func keychainBackendReceivesNoninteractiveReadMode() throws {
let operations = FakeKeychainOperations()
let storage = KeychainConnectionStorage(service: "test.synthetic", operations: operations)
#expect(try storage.read(allowInteraction: false) == nil)
operations.readStatus = errSecInteractionNotAllowed
#expect(throws: ConfigurationError.keychain(errSecInteractionNotAllowed)) {
try storage.read(allowInteraction: false)
}
#expect(operations.readModes == [false, false])
}
}
+47
View File
@@ -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)
}
}
+326
View File
@@ -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)
)))
}
+104
View File
@@ -0,0 +1,104 @@
import Testing
@MainActor
struct WindowFocusTests {
@Test func restoresInitiatingWindowAfterActivationCompletes() async {
let activation = ActivationGate()
let window = FocusWindow()
let otherWindow = FocusWindow()
let focus = WindowFocusController { await activation.request() }
focus.window = window
var actionFinished = false
let task = focus.perform { actionFinished = true }
await activation.waitForRequest()
#expect(actionFinished && window.restorations == 0)
focus.window = otherWindow
activation.complete(success: true)
await task?.value
#expect(window.restorations == 1 && otherWindow.restorations == 0)
}
@Test(arguments: [false, true])
func closingOrMinimizingDuringActivationDoesNotRecoverWindow(minimized: Bool) async {
let activation = ActivationGate()
let window = FocusWindow()
let focus = WindowFocusController { await activation.request() }
focus.window = window
let task = focus.perform {}
await activation.waitForRequest()
if minimized { window.isMiniaturized = true } else { window.isVisible = false }
activation.complete(success: true)
await task?.value
#expect(window.restorations == 0)
}
@Test func missingOrClosedWindowDoesNotRequestActivation() async {
var requests = 0
let focus = WindowFocusController { requests += 1; return true }
#expect(focus.perform {} == nil)
let window = FocusWindow()
window.isVisible = false
focus.window = window
#expect(focus.perform {} == nil)
#expect(requests == 0)
}
@Test func activationFailureDoesNotOrderWindowForward() async {
let window = FocusWindow()
let focus = WindowFocusController { false }
focus.window = window
await focus.perform {}?.value
#expect(window.restorations == 0)
}
@Test func newerActionCancelsEarlierRestoration() async {
let activation = ActivationGate()
let window = FocusWindow()
var requests = 0
let focus = WindowFocusController {
requests += 1
if requests == 1 { return await activation.request() }
return true
}
focus.window = window
let first = focus.perform {}
await activation.waitForRequest()
await focus.perform {}?.value
#expect(window.restorations == 1)
activation.complete(success: true)
await first?.value
#expect(window.restorations == 1)
}
}
@MainActor
private final class FocusWindow: FocusRestorationWindow {
var isVisible = true
var isMiniaturized = false
var restorations = 0
func restoreKeyWindow() { restorations += 1 }
}
@MainActor
private final class ActivationGate {
private var completion: CheckedContinuation<Bool, Never>?
private var entered: CheckedContinuation<Void, Never>?
func request() async -> Bool {
await withCheckedContinuation { continuation in
completion = continuation
entered?.resume()
entered = nil
}
}
func waitForRequest() async {
if completion != nil { return }
await withCheckedContinuation { entered = $0 }
}
func complete(success: Bool) {
completion?.resume(returning: success)
completion = nil
}
}
+2
View File
@@ -12,6 +12,7 @@ case "$configuration" in
*) echo "Configuration must be Debug or Release." >&2; exit 2 ;; *) echo "Configuration must be Debug or Release." >&2; exit 2 ;;
esac esac
repo_root="$(cd "$(dirname "$0")/.." && pwd)" repo_root="$(cd "$(dirname "$0")/.." && pwd)"
source "$repo_root/scripts/signing-options.sh"
mkdir -p "$build_root" mkdir -p "$build_root"
exec xcodebuild \ exec xcodebuild \
@@ -22,4 +23,5 @@ exec xcodebuild \
-derivedDataPath "$build_root/DerivedData" \ -derivedDataPath "$build_root/DerivedData" \
"CLANG_MODULE_CACHE_PATH=$build_root/ModuleCache" \ "CLANG_MODULE_CACHE_PATH=$build_root/ModuleCache" \
"SWIFT_MODULE_CACHE_PATH=$build_root/ModuleCache" \ "SWIFT_MODULE_CACHE_PATH=$build_root/ModuleCache" \
"${signing_options[@]}" \
build build
+6 -1
View File
@@ -28,9 +28,11 @@ esac
repo_root="$(cd "$(dirname "$0")/.." && pwd)" repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root" cd "$repo_root"
source "$repo_root/scripts/signing-options.sh"
trap 'status=$?; echo "Local checks failed (exit $status)." >&2; exit "$status"' ERR trap 'status=$?; echo "Local checks failed (exit $status)." >&2; exit "$status"' ERR
echo "Checking toolchain ($profile)..." echo "Checking toolchain ($profile)..."
echo "Signing mode: $signing_mode"
scripts/verify-toolchain.sh "${toolchain[@]}" scripts/verify-toolchain.sh "${toolchain[@]}"
echo "Checking shell syntax and Git whitespace..." echo "Checking shell syntax and Git whitespace..."
@@ -48,8 +50,11 @@ for configuration in Debug Release; do
scripts/build.sh "$validation_root/build" "$configuration" scripts/build.sh "$validation_root/build" "$configuration"
done done
echo "Running application tests..."
scripts/test.sh "$validation_root/build"
echo "Checking application binaries and signatures..." echo "Checking application binaries and signatures..."
scripts/verify-build.sh "$validation_root/build" scripts/verify-build.sh "$validation_root/build"
echo "Local checks passed ($profile): lint, Debug, Release, 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." echo "Hosted workflow execution and manual application checks remain separate."
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Shared by build/test/verification scripts; development never falls back to ad hoc.
signing_mode="${TRACCAR_SIGNING_MODE:-development}"
case "$signing_mode" in
development)
signing_options=("CODE_SIGN_STYLE=Manual")
;;
adhoc)
signing_options=("CODE_SIGN_STYLE=Manual" "CODE_SIGN_IDENTITY=-" "DEVELOPMENT_TEAM=")
;;
*)
echo "TRACCAR_SIGNING_MODE must be development or adhoc." >&2
exit 2
;;
esac
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
build_root="${1:?Usage: scripts/test.sh /absolute/path/to/external/build-directory}"
case "$build_root" in
/*) ;;
*) echo "The build directory must be an absolute path." >&2; exit 2 ;;
esac
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
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/Application.xcresult"
exec xcodebuild \
-project "$repo_root/TraccarClient.xcodeproj" \
-scheme TraccarClient \
-configuration Debug \
-destination 'platform=macOS,arch=arm64' \
-parallel-testing-enabled NO \
-derivedDataPath "$build_root/DerivedData" \
-resultBundlePath "$result_root/Application.xcresult" \
"CLANG_MODULE_CACHE_PATH=$build_root/ModuleCache" \
"SWIFT_MODULE_CACHE_PATH=$build_root/ModuleCache" \
"${signing_options[@]}" \
test
+24
View File
@@ -6,6 +6,8 @@ case "$build_root" in
/*) ;; /*) ;;
*) echo "The build directory must be an absolute path." >&2; exit 2 ;; *) echo "The build directory must be an absolute path." >&2; exit 2 ;;
esac esac
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
source "$repo_root/scripts/signing-options.sh"
for configuration in Debug Release; do for configuration in Debug Release; do
app="$build_root/DerivedData/Build/Products/$configuration/Traccar Client macOS.app" app="$build_root/DerivedData/Build/Products/$configuration/Traccar Client macOS.app"
@@ -13,5 +15,27 @@ for configuration in Debug Release; do
echo "Verifying $configuration..." echo "Verifying $configuration..."
test "$(lipo -archs "$executable")" = arm64 test "$(lipo -archs "$executable")" = arm64
test "$(xcrun vtool -show-build "$executable" | awk '/minos/ {print $2}')" = 15.0 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" codesign --verify --strict "$app"
metadata="$(codesign --display --verbose=4 "$app" 2>&1)"
case "$signing_mode" in
development)
if [[ "$metadata" != *"Authority=Apple Development:"* ]]; then
echo "Expected an Apple Development signature for $configuration." >&2
exit 1
fi
team="$(awk -F= '/^TeamIdentifier=/ {print $2}' <<< "$metadata")"
if [[ -z "$team" || "$team" == "not set" || -e "$app/Contents/embedded.provisionprofile" ]]; then
echo "Expected a development Team and no provisioning profile for $configuration." >&2
exit 1
fi
;;
adhoc)
if [[ "$metadata" != *"Signature=adhoc"* ]]; then
echo "Expected an ad hoc signature for $configuration." >&2
exit 1
fi
;;
esac
echo "Verified $configuration: arm64, macOS 15.0 minimum, $signing_mode signature."
done done