mirror of
https://github.com/jayanezj/traccar-client-macos.git
synced 2026-11-05 07:00:46 +01:00
Compare commits
8
Commits
6227d15cce
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6afd7e3a5 | ||
|
|
3f351296b8
|
||
|
|
db90ad9a0e | ||
|
|
026cd6aa40
|
||
|
|
8aaff37d7a | ||
|
|
a7435b48e8
|
||
|
|
86a6dd52be
|
||
|
|
744e3494d9
|
No files matched your search
@@ -0,0 +1,115 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: SwiftLint
|
||||||
|
runs-on: macos-15
|
||||||
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Lint Swift sources
|
||||||
|
run: scripts/lint.sh "$RUNNER_TEMP/ci-tools"
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.name }})
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 20
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: primary
|
||||||
|
runner: xcode-27
|
||||||
|
developer_dir: /Applications/Xcode_27.1.app/Contents/Developer
|
||||||
|
xcode_version: '27.1'
|
||||||
|
xcode_build: 27A9275
|
||||||
|
sdk_version: '27.0'
|
||||||
|
os_major: '27'
|
||||||
|
- name: macOS 15
|
||||||
|
runner: macos-15
|
||||||
|
developer_dir: /Applications/Xcode_26.3.app/Contents/Developer
|
||||||
|
xcode_version: '26.3'
|
||||||
|
xcode_build: 17C529
|
||||||
|
sdk_version: '26.2'
|
||||||
|
os_major: '15'
|
||||||
|
env:
|
||||||
|
DEVELOPER_DIR: ${{ matrix.developer_dir }}
|
||||||
|
EXPECTED_XCODE_VERSION: ${{ matrix.xcode_version }}
|
||||||
|
EXPECTED_XCODE_BUILD: ${{ matrix.xcode_build }}
|
||||||
|
EXPECTED_SDK_VERSION: ${{ matrix.sdk_version }}
|
||||||
|
EXPECTED_OS_MAJOR: ${{ matrix.os_major }}
|
||||||
|
# Untrusted PRs/forks never receive the Apple Development signing key.
|
||||||
|
TRACCAR_SIGNING_MODE: adhoc
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Verify runner and toolchain
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/ci-diagnostics"
|
||||||
|
scripts/verify-toolchain.sh "$EXPECTED_XCODE_VERSION" "$EXPECTED_XCODE_BUILD" \
|
||||||
|
"$EXPECTED_SDK_VERSION" "$EXPECTED_OS_MAJOR" \
|
||||||
|
2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/toolchain.log"
|
||||||
|
- name: Build Debug
|
||||||
|
run: |
|
||||||
|
scripts/build.sh "$RUNNER_TEMP/traccar-ci" Debug \
|
||||||
|
2>&1 | tee "$RUNNER_TEMP/ci-diagnostics/build-debug.log"
|
||||||
|
- name: Build Release
|
||||||
|
run: |
|
||||||
|
scripts/build.sh "$RUNNER_TEMP/traccar-ci" Release \
|
||||||
|
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
|
||||||
|
run: scripts/verify-build.sh "$RUNNER_TEMP/traccar-ci"
|
||||||
|
- name: Sanitize failure diagnostics
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(os.environ['RUNNER_TEMP'])
|
||||||
|
output = root / 'ci-diagnostics-sanitized'
|
||||||
|
output.mkdir(exist_ok=True)
|
||||||
|
replacements = [(os.environ[key], f'<{key}>')
|
||||||
|
for key in ('GITHUB_WORKSPACE', 'RUNNER_TEMP', 'HOME')]
|
||||||
|
for log in (root / 'ci-diagnostics').glob('*.log'):
|
||||||
|
content = log.read_text(errors='replace')
|
||||||
|
for value, replacement in replacements:
|
||||||
|
content = content.replace(value, replacement)
|
||||||
|
(output / log.name).write_text(content)
|
||||||
|
PY
|
||||||
|
- name: Upload failure diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: build-diagnostics-${{ matrix.runner }}
|
||||||
|
path: ${{ runner.temp }}/ci-diagnostics-sanitized/*.log
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
included:
|
||||||
|
- TraccarClient
|
||||||
|
- TraccarClientTests
|
||||||
|
|
||||||
|
# Match Swift's multiline collection style without disabling the default rules.
|
||||||
|
trailing_comma:
|
||||||
|
mandatory_comma: true
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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,138 @@ 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. Early CI is tracked in [issue #2](https://github.com/jayanezj/traccar-client-macos/issues/2), before subsequent functionality.
|
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
|
||||||
|
|
||||||
|
The workflow in `.github/workflows/ci.yml` runs on pull requests, pushes to `main` and manual dispatch. It exposes three checks:
|
||||||
|
|
||||||
|
| Check | Runner | Toolchain | Work |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| SwiftLint | `macos-15` arm64 | Xcode 26.3; SwiftLint 0.65.1 | Strict lint, including warnings |
|
||||||
|
| Build (primary) | `xcode-27` arm64 | Xcode 27.1, build 27A9275; macOS SDK 27.0 | Debug, Release and application tests |
|
||||||
|
| Build (macOS 15) | `macos-15` arm64 | Xcode 26.3, build 17C529; macOS SDK 26.2 | Debug, Release and application tests |
|
||||||
|
|
||||||
|
Both build jobs explicitly set `TRACCAR_SIGNING_MODE=adhoc`: CI builds and tests need no Apple account or private signing key, including for fork PRs. These builds are validation artifacts, not distribution packages. Local builds default to `development` and use Apple Development. Signature verification checks the selected mode explicitly.
|
||||||
|
|
||||||
|
Both build jobs verify the host architecture, OS major version, selected Xcode/build and SDK before compiling. They also verify arm64 binaries, deployment target 15.0 and application signatures. A missing or unexpected toolchain fails the check. The `xcode-27` runner image is a public preview, accepted for early CI; Xcode 27.1 build 27A9275 itself is an Apple final release. Review the runner's status before automating releases.
|
||||||
|
|
||||||
|
Both build jobs execute the hostless Swift Testing target. It compiles the same configuration/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
|
||||||
|
|
||||||
|
Run all local checks with one command, supplying an absolute validation directory outside the repository, preferably on your external development volume:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/check-local.sh /path/to/external/local-ci
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the example with your actual path. The default `primary` profile requires macOS 27 arm64, Xcode 27.1 build 27A9275 and SDK 27.0. The script uses the Xcode selected by `xcode-select`, or the `DEVELOPER_DIR` environment variable if set. To select a particular installation without changing the system selection:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DEVELOPER_DIR=/path/to/Xcode.app/Contents/Developer \
|
||||||
|
scripts/check-local.sh /path/to/external/local-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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/check-local.sh /path/to/external/local-ci-macos15 macos15
|
||||||
|
```
|
||||||
|
|
||||||
|
Profiles verify the actual machine and toolchain; they do not emulate another macOS version. This command does not reproduce GitHub's clean hosted environment, triggers, permissions or artifact uploads. It does not launch the application; perform the manual smoke check separately. A successful local run does not establish hosted CI or minimum-OS runtime coverage.
|
||||||
|
|
||||||
|
### Individual checks
|
||||||
|
|
||||||
|
Run the same lint locally, using an absolute tools directory outside the repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/lint.sh /path/to/external/tools-directory
|
||||||
|
```
|
||||||
|
|
||||||
|
The script downloads the official portable SwiftLint release, verifies its pinned SHA-256 and checks the executable version. `.swiftlint.yml` applies the default rules to application and test sources, with mandatory trailing commas in multiline collections. No baseline or automatic fixes are applied. SwiftLint is a development tool, not an application package dependency.
|
||||||
|
|
||||||
|
Run 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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/verify-toolchain.sh 27.1 27A9275 27.0 27
|
||||||
|
```
|
||||||
|
|
||||||
|
CI uses read-only repository permissions, Actions pinned to full commit SHAs, bounded timeouts and cancellation of superseded runs. Checkout does not persist credentials. It requires no production secrets or Apple account and does not run the application or send positions. Failed builds retain only text diagnostics, with workspace, temporary and home paths replaced, for seven days; app bundles and build caches are not uploaded.
|
||||||
|
|
||||||
|
Swift analysis in SonarQube requires Developer Edition, so SonarQube Community is not a required check. CycloneDX SBOM publication to Dependency-Track is deferred until the application has external dependencies; include it in release automation when applicable.
|
||||||
|
|
||||||
## Artwork
|
## Artwork
|
||||||
|
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
TabView {
|
VStack(spacing: 12) {
|
||||||
Form {
|
if model.hasLoaded {
|
||||||
LabeledContent("Status", value: "Paused")
|
TabView {
|
||||||
Text("Server and device configuration will be available in a later build.")
|
connection
|
||||||
|
.tabItem { Label("Connection", systemImage: "network") }
|
||||||
|
tracking
|
||||||
|
.tabItem { Label("Tracking", systemImage: "location") }
|
||||||
|
general
|
||||||
|
.tabItem { Label("General", systemImage: "gearshape") }
|
||||||
|
diagnostics
|
||||||
|
.tabItem { Label("Diagnostics", systemImage: "waveform.path.ecg") }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keychainIntroduction
|
||||||
}
|
}
|
||||||
.tabItem { Label("Connection", systemImage: "network") }
|
VStack(alignment: .leading) {
|
||||||
|
if let error = model.errorMessage {
|
||||||
Form {
|
Label(error, systemImage: "exclamationmark.triangle.fill")
|
||||||
Text("Continuous tracking, manual location and SOS are not implemented yet.")
|
.foregroundStyle(.red).textSelection(.enabled)
|
||||||
Text("This build does not request your location or send positions.")
|
} else if let status = model.statusMessage {
|
||||||
|
Label(status, systemImage: "info.circle").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tabItem { Label("Tracking", systemImage: "location") }
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: 64, alignment: .topLeading)
|
||||||
Form {
|
HStack {
|
||||||
Text("Login startup and tracking restoration are not enabled.")
|
if !model.hasLoaded {
|
||||||
Text("Closing Settings keeps the app in the menu bar. Use Quit to exit.")
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tabItem { Label("General", systemImage: "gearshape") }
|
|
||||||
|
|
||||||
Form {
|
|
||||||
LabeledContent("Status", value: "Paused")
|
|
||||||
Text("No location or network activity is started by this application shell.")
|
|
||||||
}
|
|
||||||
.tabItem { Label("Diagnostics", systemImage: "waveform.path.ecg") }
|
|
||||||
}
|
}
|
||||||
|
.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class LocationModel {
|
||||||
|
private(set) var availability: LocationAvailability
|
||||||
|
private(set) var continuousRequested = false
|
||||||
|
private(set) var singleStatus: SingleCaptureStatus = .idle
|
||||||
|
private(set) var lastFix: LocationFix?
|
||||||
|
private(set) var lastFixOrigin: LocationFixOrigin?
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private(set) var continuousError: String?
|
||||||
|
|
||||||
|
@ObservationIgnored private let provider: any LocationProviding
|
||||||
|
@ObservationIgnored private let readAccuracy: () throws -> LocationAccuracy
|
||||||
|
@ObservationIgnored private var permissionRequested = false
|
||||||
|
@ObservationIgnored private var continuousRequest: Int?
|
||||||
|
@ObservationIgnored private var singleRequest: Int?
|
||||||
|
@ObservationIgnored private var nextRequest = 0
|
||||||
|
@ObservationIgnored private var continuousBlocked = false
|
||||||
|
@ObservationIgnored private var continuousAccuracy: LocationAccuracy = .medium
|
||||||
|
@ObservationIgnored private var singleAccuracy: LocationAccuracy = .medium
|
||||||
|
|
||||||
|
init(provider: any LocationProviding, readAccuracy: @escaping () throws -> LocationAccuracy = { .medium }) {
|
||||||
|
self.provider = provider
|
||||||
|
self.readAccuracy = readAccuracy
|
||||||
|
availability = provider.availability
|
||||||
|
provider.onEvent = { [weak self] in self?.receive($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: String {
|
||||||
|
if availability.services == .disabled { return "Location Services disabled" }
|
||||||
|
if availability.permission == .denied { return "Location permission denied" }
|
||||||
|
if availability.permission == .restricted { return "Location permission restricted" }
|
||||||
|
if continuousError != nil { return "Location unavailable" }
|
||||||
|
if continuousRequested || singleStatus == .waiting {
|
||||||
|
if !availability.canCapture { return "Waiting for location authorization" }
|
||||||
|
return lastFix == nil ? "Waiting for location" : "Location capture active"
|
||||||
|
}
|
||||||
|
return "Paused"
|
||||||
|
}
|
||||||
|
|
||||||
|
func startContinuous(accuracy: LocationAccuracy? = nil) {
|
||||||
|
guard !continuousRequested else { return }
|
||||||
|
guard let selected = selectedAccuracy(accuracy) else { return }
|
||||||
|
preparePermissionRequest()
|
||||||
|
continuousRequested = true
|
||||||
|
continuousAccuracy = selected
|
||||||
|
continuousBlocked = false
|
||||||
|
continuousError = nil
|
||||||
|
errorMessage = nil
|
||||||
|
provider.refreshAvailability()
|
||||||
|
reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
continuousRequested = false
|
||||||
|
stopContinuous()
|
||||||
|
continuousError = nil
|
||||||
|
continuousBlocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureOnce(accuracy: LocationAccuracy? = nil) {
|
||||||
|
guard singleStatus != .waiting else { return }
|
||||||
|
guard let selected = selectedAccuracy(accuracy) else {
|
||||||
|
singleStatus = .failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preparePermissionRequest()
|
||||||
|
singleStatus = .waiting
|
||||||
|
singleAccuracy = selected
|
||||||
|
errorMessage = nil
|
||||||
|
provider.refreshAvailability()
|
||||||
|
reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelSingle() {
|
||||||
|
guard singleStatus == .waiting else { return }
|
||||||
|
singleRequest = nil
|
||||||
|
singleStatus = .cancelled
|
||||||
|
provider.cancelSingle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shutdown() {
|
||||||
|
pause()
|
||||||
|
cancelSingle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAvailability() { provider.refreshAvailability() }
|
||||||
|
|
||||||
|
private func preparePermissionRequest() {
|
||||||
|
// A new explicit attempt may retry an undetermined request; status refreshes never do so on their own.
|
||||||
|
if !continuousRequested && singleStatus != .waiting { permissionRequested = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectedAccuracy(_ requested: LocationAccuracy?) -> LocationAccuracy? {
|
||||||
|
do {
|
||||||
|
return try requested ?? readAccuracy()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Saved tracking preferences could not be read. Review Settings before capture."
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconcile() {
|
||||||
|
guard continuousRequested || singleStatus == .waiting else { return }
|
||||||
|
if availability.services == .disabled || [.denied, .restricted].contains(availability.permission) {
|
||||||
|
stopContinuous()
|
||||||
|
errorMessage = availability.services == .disabled
|
||||||
|
? "Enable Location Services in System Settings to capture locations."
|
||||||
|
: "Location access is denied or restricted. Review Location Services in System Settings."
|
||||||
|
if singleStatus == .waiting {
|
||||||
|
singleRequest = nil
|
||||||
|
singleStatus = .failed
|
||||||
|
provider.cancelSingle()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard availability.services == .enabled else { return }
|
||||||
|
if availability.permission == .notDetermined {
|
||||||
|
guard !permissionRequested else { return }
|
||||||
|
permissionRequested = true
|
||||||
|
provider.requestAuthorization()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard availability.canCapture else { return }
|
||||||
|
if continuousRequested && continuousRequest == nil && !continuousBlocked {
|
||||||
|
nextRequest += 1
|
||||||
|
continuousRequest = nextRequest
|
||||||
|
provider.startContinuous(accuracy: continuousAccuracy, request: nextRequest)
|
||||||
|
}
|
||||||
|
if singleStatus == .waiting && singleRequest == nil {
|
||||||
|
nextRequest += 1
|
||||||
|
singleRequest = nextRequest
|
||||||
|
provider.requestSingle(accuracy: singleAccuracy, request: nextRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopContinuous() {
|
||||||
|
continuousRequest = nil
|
||||||
|
provider.stopContinuous()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receive(_ event: LocationEvent) {
|
||||||
|
switch event {
|
||||||
|
case .availability(let value):
|
||||||
|
if availability != value {
|
||||||
|
continuousBlocked = false
|
||||||
|
continuousError = nil
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
availability = value
|
||||||
|
if !value.canCapture { stopContinuous() }
|
||||||
|
reconcile()
|
||||||
|
case .locations(let kind, let request, let fixes):
|
||||||
|
guard accepts(kind, request: request), let fix = fixes.max(by: { $0.timestamp < $1.timestamp }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastFix = fix
|
||||||
|
lastFixOrigin = kind == .single ? .single : .continuous
|
||||||
|
if kind == .single {
|
||||||
|
singleStatus = .received
|
||||||
|
singleRequest = nil
|
||||||
|
} else {
|
||||||
|
continuousError = nil
|
||||||
|
}
|
||||||
|
errorMessage = nil
|
||||||
|
case .failure(let kind, let request, let reason, let fallback):
|
||||||
|
guard accepts(kind, request: request) else { return }
|
||||||
|
handleFailure(kind, reason: reason, fallback: fallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accepts(_ kind: LocationRequestKind, request: Int) -> Bool {
|
||||||
|
kind == .single ? singleStatus == .waiting && singleRequest == request
|
||||||
|
: continuousRequested && continuousRequest == request
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFailure(_ kind: LocationRequestKind, reason: LocationFailure, fallback: LocationFix?) {
|
||||||
|
if kind == .continuous {
|
||||||
|
continuousError = reason.message
|
||||||
|
if reason == .permissionDenied {
|
||||||
|
continuousBlocked = true
|
||||||
|
stopContinuous()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
singleRequest = nil
|
||||||
|
if reason != .permissionDenied, let known = fallback ?? lastFix {
|
||||||
|
lastFix = known
|
||||||
|
lastFixOrigin = .lastKnown
|
||||||
|
singleStatus = .lastKnown
|
||||||
|
} else {
|
||||||
|
singleStatus = .failed
|
||||||
|
}
|
||||||
|
errorMessage = reason.message
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum LocationPermission: String {
|
||||||
|
case notDetermined = "Not requested"
|
||||||
|
case authorized = "Authorized"
|
||||||
|
case denied = "Denied"
|
||||||
|
case restricted = "Restricted"
|
||||||
|
|
||||||
|
init(_ status: CLAuthorizationStatus) {
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways: self = .authorized
|
||||||
|
case .notDetermined: self = .notDetermined
|
||||||
|
case .denied: self = .denied
|
||||||
|
default: self = .restricted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationServices: String {
|
||||||
|
case checking = "Checking"
|
||||||
|
case enabled = "Enabled"
|
||||||
|
case disabled = "Disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocationAvailability: Equatable {
|
||||||
|
var permission: LocationPermission
|
||||||
|
var services: LocationServices
|
||||||
|
|
||||||
|
var canCapture: Bool { permission == .authorized && services == .enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocationFix: Equatable {
|
||||||
|
let latitude: Double
|
||||||
|
let longitude: Double
|
||||||
|
let timestamp: Date
|
||||||
|
let horizontalAccuracy: Double
|
||||||
|
let altitude: Double?
|
||||||
|
let speed: Double?
|
||||||
|
let course: Double?
|
||||||
|
|
||||||
|
init?(_ location: CLLocation) {
|
||||||
|
guard CLLocationCoordinate2DIsValid(location.coordinate),
|
||||||
|
location.horizontalAccuracy.isFinite, location.horizontalAccuracy >= 0,
|
||||||
|
location.timestamp.timeIntervalSinceReferenceDate.isFinite else { return nil }
|
||||||
|
latitude = location.coordinate.latitude
|
||||||
|
longitude = location.coordinate.longitude
|
||||||
|
timestamp = location.timestamp
|
||||||
|
horizontalAccuracy = location.horizontalAccuracy
|
||||||
|
altitude = location.verticalAccuracy >= 0 && location.altitude.isFinite ? location.altitude : nil
|
||||||
|
speed = location.speed >= 0 && location.speed.isFinite ? location.speed : nil
|
||||||
|
course = (0..<360).contains(location.course) && location.course.isFinite ? location.course : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationFailure: Equatable {
|
||||||
|
case permissionDenied
|
||||||
|
case unavailable
|
||||||
|
case other(Int)
|
||||||
|
|
||||||
|
var message: String {
|
||||||
|
switch self {
|
||||||
|
case .permissionDenied: "Location access is denied. Review Location Services in System Settings."
|
||||||
|
case .unavailable: "No location is available. Try again when Location Services can obtain a fix."
|
||||||
|
case .other(let code): "Location could not be obtained (error \(code))."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationRequestKind { case continuous, single }
|
||||||
|
enum LocationFixOrigin: String {
|
||||||
|
case continuous = "Continuous capture"
|
||||||
|
case single = "Single capture"
|
||||||
|
case lastKnown = "Last-known location; no new fix"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SingleCaptureStatus: String {
|
||||||
|
case idle = "Idle"
|
||||||
|
case waiting = "Waiting for location"
|
||||||
|
case received = "Location received"
|
||||||
|
case lastKnown = "Last-known location used; no new fix"
|
||||||
|
case failed = "No location obtained"
|
||||||
|
case cancelled = "Cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocationEvent {
|
||||||
|
case availability(LocationAvailability)
|
||||||
|
case locations(LocationRequestKind, request: Int, fixes: [LocationFix])
|
||||||
|
case failure(LocationRequestKind, request: Int, reason: LocationFailure, fallback: LocationFix?)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol LocationProviding: AnyObject {
|
||||||
|
var availability: LocationAvailability { get }
|
||||||
|
var onEvent: ((LocationEvent) -> Void)? { get set }
|
||||||
|
func refreshAvailability()
|
||||||
|
func requestAuthorization()
|
||||||
|
func startContinuous(accuracy: LocationAccuracy, request: Int)
|
||||||
|
func stopContinuous()
|
||||||
|
func requestSingle(accuracy: LocationAccuracy, request: Int)
|
||||||
|
func cancelSingle()
|
||||||
|
}
|
||||||
@@ -30,5 +30,7 @@
|
|||||||
<string>Licensed under the Apache License, Version 2.0.</string>
|
<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>
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct LocationFixTests {
|
||||||
|
@Test func preservesOriginalFieldsAndOmitsInvalidOptionalValues() throws {
|
||||||
|
let timestamp = Date(timeIntervalSince1970: 1_500_000_000)
|
||||||
|
let location = CLLocation(
|
||||||
|
coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2), altitude: 42,
|
||||||
|
horizontalAccuracy: 8_000, verticalAccuracy: -1, course: -1, speed: -1, timestamp: timestamp
|
||||||
|
)
|
||||||
|
let fix = try #require(LocationFix(location))
|
||||||
|
#expect(fix.timestamp == timestamp && fix.horizontalAccuracy == 8_000)
|
||||||
|
#expect(fix.latitude == 1 && fix.longitude == 2)
|
||||||
|
#expect(fix.altitude == nil && fix.speed == nil && fix.course == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preservesValidOptionalFields() throws {
|
||||||
|
let location = CLLocation(
|
||||||
|
coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2), altitude: 42,
|
||||||
|
horizontalAccuracy: 100, verticalAccuracy: 5, course: 90, speed: 0,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
)
|
||||||
|
let fix = try #require(LocationFix(location))
|
||||||
|
#expect(fix.altitude == 42 && fix.speed == 0 && fix.course == 90)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func invalidCoordinatesOrInvalidFixAccuracyAreNotLocations() {
|
||||||
|
let timestamp = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
for (latitude, accuracy) in [(91.0, 100.0), (1, -1), (1, Double.nan)] {
|
||||||
|
let location = CLLocation(
|
||||||
|
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: 2), altitude: 0,
|
||||||
|
horizontalAccuracy: accuracy, verticalAccuracy: -1, timestamp: timestamp
|
||||||
|
)
|
||||||
|
#expect(LocationFix(location) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor @Test func nativeAccuracyMappingAndAuthorizationUseMacAPIs() {
|
||||||
|
#expect(CoreLocationProvider.desiredAccuracy(.medium) == kCLLocationAccuracyHundredMeters)
|
||||||
|
#expect(CoreLocationProvider.desiredAccuracy(.low) == kCLLocationAccuracyKilometer)
|
||||||
|
#expect(CoreLocationProvider.desiredAccuracy(.high) == kCLLocationAccuracyBest)
|
||||||
|
#expect(LocationPermission(.authorizedAlways) == .authorized)
|
||||||
|
#expect(LocationPermission(.denied) == .denied)
|
||||||
|
#expect(LocationPermission(.restricted) == .restricted)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite @MainActor
|
||||||
|
struct LocationModelTests {
|
||||||
|
@Test func passiveStatusChangesNeverRequestPermissionOrCapture() {
|
||||||
|
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.refreshAvailability()
|
||||||
|
provider.publish(permission: .authorized)
|
||||||
|
#expect(!model.continuousRequested && model.singleStatus == .idle)
|
||||||
|
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func explicitStartRequestsPermissionOnceAndWaitsForGrant() {
|
||||||
|
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
model.startContinuous()
|
||||||
|
provider.publish(permission: .notDetermined)
|
||||||
|
#expect(provider.authorizationRequests == 1 && provider.starts.isEmpty)
|
||||||
|
provider.publish(permission: .authorized)
|
||||||
|
provider.publish(permission: .authorized)
|
||||||
|
#expect(model.continuousRequested && provider.starts.count == 1)
|
||||||
|
#expect(provider.startedAccuracies == [.medium])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(arguments: [
|
||||||
|
LocationAvailability(permission: .denied, services: .enabled),
|
||||||
|
LocationAvailability(permission: .restricted, services: .enabled),
|
||||||
|
LocationAvailability(permission: .notDetermined, services: .disabled),
|
||||||
|
])
|
||||||
|
func blockedStatesDoNotPromptOrStart(availability: LocationAvailability) {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
provider.availability = availability
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
model.captureOnce()
|
||||||
|
#expect(model.continuousRequested && model.singleStatus == .failed)
|
||||||
|
#expect(model.errorMessage != nil)
|
||||||
|
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pauseBeforeAuthorizationPreventsDelayedStart() {
|
||||||
|
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
model.pause()
|
||||||
|
provider.publish(permission: .authorized)
|
||||||
|
#expect(!model.continuousRequested && provider.starts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func undeterminedPermissionCanBeRetriedOnlyByANewExplicitAttempt() {
|
||||||
|
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.captureOnce()
|
||||||
|
model.cancelSingle()
|
||||||
|
model.refreshAvailability()
|
||||||
|
#expect(provider.authorizationRequests == 1)
|
||||||
|
model.captureOnce()
|
||||||
|
#expect(provider.authorizationRequests == 2)
|
||||||
|
provider.publish(permission: .denied)
|
||||||
|
model.captureOnce()
|
||||||
|
#expect(provider.authorizationRequests == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pauseRejectsLateFixAndResumeUsesNewSession() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous(accuracy: .high)
|
||||||
|
let old = try #require(provider.starts.last)
|
||||||
|
let stops = provider.continuousStops
|
||||||
|
model.pause()
|
||||||
|
#expect(provider.continuousStops == stops + 1)
|
||||||
|
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.lastFix == nil)
|
||||||
|
model.startContinuous(accuracy: .low)
|
||||||
|
let current = try #require(provider.starts.last)
|
||||||
|
#expect(old != current && provider.startedAccuracies == [.high, .low])
|
||||||
|
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.lastFix == nil)
|
||||||
|
provider.emit(.locations(.continuous, request: current, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.lastFix != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func revokedPermissionStopsAcquisitionWithoutChangingUserIntent() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
let old = try #require(provider.starts.last)
|
||||||
|
let stops = provider.continuousStops
|
||||||
|
provider.publish(permission: .denied)
|
||||||
|
#expect(provider.continuousStops > stops)
|
||||||
|
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.lastFix == nil && model.continuousRequested)
|
||||||
|
#expect(model.status == "Location permission denied")
|
||||||
|
provider.publish(permission: .authorized)
|
||||||
|
#expect(provider.starts.count == 2 && provider.starts.last != old)
|
||||||
|
model.pause()
|
||||||
|
provider.publish(permission: .authorized)
|
||||||
|
#expect(provider.starts.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleCaptureRemainsPausedAndDuplicatePressIsIgnored() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.captureOnce()
|
||||||
|
model.captureOnce()
|
||||||
|
let request = try #require(provider.singles.last)
|
||||||
|
#expect(provider.singles.count == 1 && provider.starts.isEmpty)
|
||||||
|
let fix = try sampleFix(age: 86_400, accuracy: 5_000)
|
||||||
|
provider.emit(.locations(.single, request: request, fixes: [fix]))
|
||||||
|
#expect(!model.continuousRequested && model.singleStatus == .received)
|
||||||
|
#expect(model.lastFix == fix && model.lastFixOrigin == .single)
|
||||||
|
#expect(model.status == "Paused")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleCaptureSurvivesPausingContinuousCapture() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
model.captureOnce()
|
||||||
|
let request = try #require(provider.singles.last)
|
||||||
|
let cancels = provider.singleCancellations
|
||||||
|
model.pause()
|
||||||
|
#expect(provider.singleCancellations == cancels && model.singleStatus == .waiting)
|
||||||
|
provider.emit(.locations(.single, request: request, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.singleStatus == .received && !model.continuousRequested)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cancelSingleRejectsItsCallbacksWithoutStoppingContinuousCapture() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
model.captureOnce()
|
||||||
|
let old = try #require(provider.singles.last)
|
||||||
|
model.cancelSingle()
|
||||||
|
provider.emit(.locations(.single, request: old, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.lastFix == nil && model.continuousRequested && model.singleStatus == .cancelled)
|
||||||
|
model.captureOnce()
|
||||||
|
#expect(provider.singles.last != old)
|
||||||
|
provider.emit(.failure(.single, request: old, reason: .unavailable, fallback: nil))
|
||||||
|
#expect(model.singleStatus == .waiting && model.errorMessage == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unavailableSingleUsesKnownFixWithoutInventingTimestamp() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.captureOnce()
|
||||||
|
let request = try #require(provider.singles.last)
|
||||||
|
let old = try sampleFix(age: 40 * 86_400, accuracy: 7_500)
|
||||||
|
provider.emit(.failure(.single, request: request, reason: .unavailable, fallback: old))
|
||||||
|
#expect(model.singleStatus == .lastKnown && model.lastFixOrigin == .lastKnown)
|
||||||
|
#expect(model.lastFix == old && model.errorMessage != nil)
|
||||||
|
#expect(!model.continuousRequested)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unavailableWithoutKnownFixDoesNotReportSuccess() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.captureOnce()
|
||||||
|
let request = try #require(provider.singles.last)
|
||||||
|
provider.emit(.failure(.single, request: request, reason: .unavailable, fallback: nil))
|
||||||
|
#expect(model.singleStatus == .failed && model.lastFix == nil && model.errorMessage != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deniedSingleDoesNotUseFallback() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.captureOnce()
|
||||||
|
let request = try #require(provider.singles.last)
|
||||||
|
provider.emit(.failure(.single, request: request, reason: .permissionDenied, fallback: try sampleFix()))
|
||||||
|
#expect(model.singleStatus == .failed && model.lastFix == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func transientFailureKeepsContinuousIntentAndNextFixClearsError() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
let request = try #require(provider.starts.last)
|
||||||
|
provider.emit(.failure(.continuous, request: request, reason: .unavailable, fallback: nil))
|
||||||
|
#expect(model.continuousRequested && model.continuousError != nil)
|
||||||
|
provider.emit(.locations(.continuous, request: request, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.continuousError == nil && model.lastFix != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func newestSuppliedTimestampWinsWithinBatch() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
let request = try #require(provider.starts.last)
|
||||||
|
let newer = try sampleFix(age: 10)
|
||||||
|
provider.emit(.locations(.continuous, request: request, fixes: [newer, try sampleFix(age: 60)]))
|
||||||
|
#expect(model.lastFix == newer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func shutdownCancelsBothOperationsAndLateUpdatesAreIgnored() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
model.captureOnce()
|
||||||
|
let single = try #require(provider.singles.last)
|
||||||
|
let continuous = try #require(provider.starts.last)
|
||||||
|
model.shutdown()
|
||||||
|
provider.emit(.locations(.single, request: single, fixes: [try sampleFix()]))
|
||||||
|
provider.emit(.locations(.continuous, request: continuous, fixes: [try sampleFix()]))
|
||||||
|
#expect(!model.continuousRequested && model.singleStatus == .cancelled && model.lastFix == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocationModelTests {
|
||||||
|
@Test func changingReceiverPausesCaptureBeforeSaving() throws {
|
||||||
|
let fixture = try Fixture()
|
||||||
|
defer { fixture.cleanUp() }
|
||||||
|
fixture.connection.value = ReceiverConnection(deviceIdentifier: "000123-test")
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let location = LocationModel(provider: provider)
|
||||||
|
let configuration = ConfigurationModel(storage: fixture.storage, beforeConnectionChange: { _, _ in
|
||||||
|
location.shutdown()
|
||||||
|
})
|
||||||
|
configuration.loadIfNeeded()
|
||||||
|
location.startContinuous()
|
||||||
|
location.captureOnce()
|
||||||
|
configuration.draft.connection.serverURL = "https://receiver.example"
|
||||||
|
#expect(configuration.save() && !location.continuousRequested && location.singleStatus == .cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func savedAccuracyLoadsWithoutAccessingKeychainOrOpeningSettings() throws {
|
||||||
|
let fixture = try Fixture()
|
||||||
|
defer { fixture.cleanUp() }
|
||||||
|
var configuration = AppConfiguration()
|
||||||
|
configuration.connection.deviceIdentifier = "000123-test"
|
||||||
|
configuration.tracking.accuracy = .high
|
||||||
|
try fixture.storage.save(configuration)
|
||||||
|
fixture.connection.readError = .keychain(-128)
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider, readAccuracy: {
|
||||||
|
try fixture.storage.loadTrackingPreferences().accuracy
|
||||||
|
})
|
||||||
|
model.startContinuous()
|
||||||
|
#expect(provider.startedAccuracies == [.high] && fixture.connection.reads == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unreadablePreferencesDoNotStartCaptureOrAskPermission() {
|
||||||
|
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||||
|
let model = LocationModel(provider: provider, readAccuracy: { throw ConfigurationError.unreadableStorage })
|
||||||
|
model.startContinuous()
|
||||||
|
model.captureOnce()
|
||||||
|
#expect(model.errorMessage != nil && !model.continuousRequested)
|
||||||
|
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty && provider.singles.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func disabledServicesStopBothRequestsAndRecoveryRespectsContinuousIntent() throws {
|
||||||
|
let provider = FakeLocationProvider()
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
model.captureOnce()
|
||||||
|
let old = try #require(provider.starts.last)
|
||||||
|
let stops = provider.continuousStops
|
||||||
|
provider.availability.services = .disabled
|
||||||
|
model.refreshAvailability()
|
||||||
|
#expect(provider.continuousStops > stops && provider.singleCancellations > 0)
|
||||||
|
#expect(model.continuousRequested && model.singleStatus == .failed)
|
||||||
|
provider.emit(.locations(.continuous, request: old, fixes: [try sampleFix()]))
|
||||||
|
#expect(model.lastFix == nil)
|
||||||
|
provider.availability.services = .enabled
|
||||||
|
model.refreshAvailability()
|
||||||
|
#expect(provider.starts.count == 2 && provider.starts.last != old)
|
||||||
|
#expect(provider.singles.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func initialAvailabilityCheckDelaysPermissionUntilServicesAreEnabled() {
|
||||||
|
let provider = FakeLocationProvider(permission: .notDetermined)
|
||||||
|
provider.availability.services = .checking
|
||||||
|
let model = LocationModel(provider: provider)
|
||||||
|
model.startContinuous()
|
||||||
|
#expect(provider.authorizationRequests == 0 && provider.starts.isEmpty)
|
||||||
|
provider.availability.services = .enabled
|
||||||
|
model.refreshAvailability()
|
||||||
|
#expect(provider.authorizationRequests == 1 && provider.starts.isEmpty)
|
||||||
|
provider.publish(permission: .authorized)
|
||||||
|
#expect(provider.starts.count == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class FakeLocationProvider: LocationProviding {
|
||||||
|
var availability: LocationAvailability
|
||||||
|
var onEvent: ((LocationEvent) -> Void)?
|
||||||
|
var authorizationRequests = 0
|
||||||
|
var starts: [Int] = []
|
||||||
|
var singles: [Int] = []
|
||||||
|
var startedAccuracies: [LocationAccuracy] = []
|
||||||
|
var singleCancellations = 0
|
||||||
|
var continuousStops = 0
|
||||||
|
|
||||||
|
init(permission: LocationPermission = .authorized) {
|
||||||
|
availability = LocationAvailability(permission: permission, services: .enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publish(permission: LocationPermission) {
|
||||||
|
availability.permission = permission
|
||||||
|
onEvent?(.availability(availability))
|
||||||
|
}
|
||||||
|
|
||||||
|
func emit(_ event: LocationEvent) { onEvent?(event) }
|
||||||
|
func refreshAvailability() { onEvent?(.availability(availability)) }
|
||||||
|
func requestAuthorization() { authorizationRequests += 1 }
|
||||||
|
func stopContinuous() { continuousStops += 1 }
|
||||||
|
func cancelSingle() { singleCancellations += 1 }
|
||||||
|
func startContinuous(accuracy: LocationAccuracy, request: Int) {
|
||||||
|
starts.append(request)
|
||||||
|
startedAccuracies.append(accuracy)
|
||||||
|
}
|
||||||
|
func requestSingle(accuracy: LocationAccuracy, request: Int) { singles.append(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sampleFix(age: TimeInterval = 0, accuracy: Double = 100) throws -> LocationFix {
|
||||||
|
try #require(LocationFix(CLLocation(
|
||||||
|
coordinate: CLLocationCoordinate2D(latitude: 1, longitude: 2), altitude: 10,
|
||||||
|
horizontalAccuracy: accuracy, verticalAccuracy: 5,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_000 - age)
|
||||||
|
)))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: scripts/check-local.sh /absolute/path/to/validation-directory [primary|macos15]"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == --help || "${1:-}" == -h ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
validation_root="$1"
|
||||||
|
profile="${2:-primary}"
|
||||||
|
case "$validation_root" in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "The validation directory must be an absolute path." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
case "$profile" in
|
||||||
|
primary) toolchain=(27.1 27A9275 27.0 27) ;;
|
||||||
|
macos15) toolchain=(26.3 17C529 26.2 15) ;;
|
||||||
|
*) echo "Profile must be primary or macos15." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
source "$repo_root/scripts/signing-options.sh"
|
||||||
|
trap 'status=$?; echo "Local checks failed (exit $status)." >&2; exit "$status"' ERR
|
||||||
|
|
||||||
|
echo "Checking toolchain ($profile)..."
|
||||||
|
echo "Signing mode: $signing_mode"
|
||||||
|
scripts/verify-toolchain.sh "${toolchain[@]}"
|
||||||
|
|
||||||
|
echo "Checking shell syntax and Git whitespace..."
|
||||||
|
for script in scripts/*.sh; do
|
||||||
|
bash -n "$script"
|
||||||
|
done
|
||||||
|
git diff --check
|
||||||
|
git diff --cached --check
|
||||||
|
|
||||||
|
echo "Running SwiftLint..."
|
||||||
|
scripts/lint.sh "$validation_root/tools"
|
||||||
|
|
||||||
|
for configuration in Debug Release; do
|
||||||
|
echo "Building $configuration..."
|
||||||
|
scripts/build.sh "$validation_root/build" "$configuration"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Running application tests..."
|
||||||
|
scripts/test.sh "$validation_root/build"
|
||||||
|
|
||||||
|
echo "Checking application binaries and signatures..."
|
||||||
|
scripts/verify-build.sh "$validation_root/build"
|
||||||
|
|
||||||
|
echo "Local checks passed ($profile, $signing_mode): lint, Debug, Release, application tests, arm64, macOS 15.0 minimum and signatures."
|
||||||
|
echo "Hosted workflow execution and manual application checks remain separate."
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tools_root="${1:?Usage: scripts/lint.sh /absolute/path/to/tools-directory}"
|
||||||
|
case "$tools_root" in
|
||||||
|
/*) ;;
|
||||||
|
*) echo "The tools directory must be an absolute path." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
version="0.65.1"
|
||||||
|
checksum="c1e429b0599cf1b516f369a2d9ec04eaf0e436f3c12b637df8851fa52ff694d0"
|
||||||
|
install_root="$tools_root/swiftlint-$version"
|
||||||
|
binary="$install_root/swiftlint"
|
||||||
|
|
||||||
|
if [[ ! -x "$binary" ]]; then
|
||||||
|
mkdir -p "$tools_root"
|
||||||
|
download_root="$(mktemp -d "$tools_root/swiftlint-download.XXXXXX")"
|
||||||
|
trap 'rm -rf "$download_root"' EXIT
|
||||||
|
curl --fail --location --silent --show-error --retry 3 \
|
||||||
|
--proto '=https' --proto-redir '=https' \
|
||||||
|
"https://github.com/realm/SwiftLint/releases/download/$version/portable_swiftlint.zip" \
|
||||||
|
--output "$download_root/portable_swiftlint.zip"
|
||||||
|
printf '%s %s\n' "$checksum" "$download_root/portable_swiftlint.zip" | shasum -a 256 -c -
|
||||||
|
mkdir "$download_root/extracted"
|
||||||
|
unzip -q "$download_root/portable_swiftlint.zip" -d "$download_root/extracted"
|
||||||
|
mkdir -p "$install_root"
|
||||||
|
cp "$download_root/extracted/swiftlint" "$binary"
|
||||||
|
chmod +x "$binary"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$("$binary" version)" != "$version" ]]; then
|
||||||
|
echo "Expected SwiftLint $version. Remove $install_root and retry." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$repo_root"
|
||||||
|
"$binary" lint --strict --no-cache --config .swiftlint.yml
|
||||||
@@ -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
|
||||||
Executable
+26
@@ -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
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
build_root="${1:?Usage: scripts/verify-build.sh /absolute/path/to/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"
|
||||||
|
|
||||||
|
for configuration in Debug Release; do
|
||||||
|
app="$build_root/DerivedData/Build/Products/$configuration/Traccar Client macOS.app"
|
||||||
|
executable="$app/Contents/MacOS/Traccar Client macOS"
|
||||||
|
echo "Verifying $configuration..."
|
||||||
|
test "$(lipo -archs "$executable")" = arm64
|
||||||
|
test "$(xcrun vtool -show-build "$executable" | awk '/minos/ {print $2}')" = 15.0
|
||||||
|
test -n "$(/usr/libexec/PlistBuddy -c 'Print :NSLocationUsageDescription' "$app/Contents/Info.plist")"
|
||||||
|
codesign --verify --strict "$app"
|
||||||
|
metadata="$(codesign --display --verbose=4 "$app" 2>&1)"
|
||||||
|
case "$signing_mode" in
|
||||||
|
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
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
expected_version="${1:?Usage: scripts/verify-toolchain.sh XCODE_VERSION XCODE_BUILD SDK_VERSION OS_MAJOR}"
|
||||||
|
expected_build="${2:?Missing Xcode build}"
|
||||||
|
expected_sdk="${3:?Missing macOS SDK version}"
|
||||||
|
expected_os="${4:?Missing macOS major version}"
|
||||||
|
|
||||||
|
[[ "$(uname -m)" == arm64 ]] || { echo "An arm64 host is required." >&2; exit 1; }
|
||||||
|
[[ "$(sw_vers -productVersion | cut -d . -f 1)" == "$expected_os" ]] || {
|
||||||
|
echo "Expected macOS $expected_os." >&2; exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_xcode="$(xcodebuild -version)"
|
||||||
|
printf '%s\n' "$actual_xcode"
|
||||||
|
[[ "$actual_xcode" == "$(printf 'Xcode %s\nBuild version %s' "$expected_version" "$expected_build")" ]] || {
|
||||||
|
echo "Unexpected Xcode version/build." >&2; exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
actual_sdk="$(xcrun --sdk macosx --show-sdk-version)"
|
||||||
|
[[ "$actual_sdk" == "$expected_sdk" ]] || {
|
||||||
|
echo "Expected macOS SDK $expected_sdk; found $actual_sdk." >&2; exit 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sw_vers
|
||||||
|
xcrun swift --version
|
||||||
|
printf 'Architecture: %s\nmacOS SDK: %s\n' "$(uname -m)" "$actual_sdk"
|
||||||
Reference in new issue
Block a user